遗留缝合

2024 年 1 月 4 日

在处理遗留系统时,识别和创建缝合点非常有价值:这些点可以让我们在不编辑源代码的情况下改变系统的行为。一旦我们找到了缝合点,就可以利用它来打破依赖关系,简化测试,插入探针以获得可观察性,并将程序流程重定向到新模块,作为遗留代码替换的一部分。

迈克尔·费瑟斯在他的著作《有效地处理遗留代码》中,在遗留系统的背景下创造了“缝合点”一词。他的定义是:“缝合点是指程序中可以改变行为而不必在该处进行编辑的地方”

以下是一个缝合点非常有用的例子。想象一下,一些代码用于计算订单的价格。

// TypeScript
export async function calculatePrice(order:Order) {
  const itemPrices = order.items.map(i => calculateItemPrice(i))
  const basePrice = itemPrices.reduce((acc, i) => acc + i.price, 0)
  const discount = calculateDiscount(order)
  const shipping = await calculateShipping(order)
  const adjustedShipping = applyShippingDiscounts(order, shipping)
  return basePrice + discount + adjustedShipping
}

函数 calculateShipping 会访问一个外部服务,该服务速度很慢(而且很昂贵),因此我们在测试时不想访问它。相反,我们希望引入一个 存根,这样我们就可以为每个测试场景提供一个预先准备好的确定性响应。不同的测试可能需要函数的不同响应,但我们不能在测试中编辑 calculatePrice 的代码。因此,我们需要在对 calculateShipping 的调用周围引入一个缝合点,这样我们的测试就可以将调用重定向到存根。

一种方法是将 calculateShipping 的函数作为参数传递。

export async function calculatePrice(order:Order, shippingFn: (o:Order) => Promise<number>) {
  const itemPrices = order.items.map(i => calculateItemPrice(i))
  const basePrice = itemPrices.reduce((acc, i) => acc + i.price, 0)
  const discount = calculateDiscount(order)
  const shipping = await shippingFn(order)
  const adjustedShipping = applyShippingDiscounts(order, shipping)
  return basePrice + discount + adjustedShipping
}

此函数的单元测试可以替换一个简单的存根。

const shippingFn = async (o:Order) => 113
expect(await calculatePrice(sampleOrder, shippingFn)).toStrictEqual(153)

每个缝合点都带有一个启用点:“可以决定使用哪种行为的地方” [WELC]。将函数作为参数传递会在 calculateShipping 的调用者中打开一个启用点。

现在测试变得容易多了,我们可以输入不同的运费值,并检查 applyShippingDiscounts 是否正确响应。虽然我们必须更改原始源代码以引入缝合点,但对该函数的任何进一步更改都不需要我们更改该代码,所有更改都发生在启用点,该点位于测试代码中。

将函数作为参数传递并不是引入缝合点的唯一方法。毕竟,更改 calculateShipping 的签名可能很麻烦,我们可能不想在生产代码中将运费函数参数传递到遗留调用堆栈中。在这种情况下,查找可能是一个更好的方法,例如使用服务定位器。

export async function calculatePrice(order:Order) {
  const itemPrices = order.items.map(i => calculateItemPrice(i))
  const basePrice = itemPrices.reduce((acc, i) => acc + i.price, 0)
  const discount = calculateDiscount(order)
  const shipping = await ShippingServices.calculateShipping(order)
  const adjustedShipping = applyShippingDiscounts(order, shipping)
  return basePrice + discount + adjustedShipping
}
class ShippingServices {
  static #soleInstance: ShippingServices
  static init(arg?:ShippingServices) {
    this.#soleInstance = arg || new ShippingServices()
  }
  static async calculateShipping(o:Order) {return this.#soleInstance.calculateShipping(o)}
  async calculateShipping(o:Order)  {return legacy_calcuateShipping(o)}
  // ... more services

定位器允许我们通过定义一个子类来覆盖行为。

class ShippingServicesStub extends ShippingServices {
  calculateShippingFn: typeof ShippingServices.calculateShipping =
     (o) => {throw new Error("no stub provided")}
  async calculateShipping(o:Order) {return this.calculateShippingFn(o)}
  // more services

然后,我们可以在测试中使用一个启用点。

const stub = new ShippingServicesStub()
stub.calculateShippingFn = async (o:Order) => 113
ShippingServices.init(stub)
expect(await calculatePrice(sampleOrder)).toStrictEqual(153)

这种服务定位器是通过函数查找设置缝合点的经典面向对象方法,我在这里展示它是为了说明我在其他语言中可能使用的方法,但我不会在 TypeScript 或 JavaScript 中使用这种方法。相反,我会将类似的东西放到一个模块中。

export let calculateShipping = legacy_calculateShipping

export function reset_calculateShipping(fn?: typeof legacy_calculateShipping) {
  calculateShipping = fn || legacy_calculateShipping
}

然后,我们可以在测试中使用这段代码。

const shippingFn = async (o:Order) => 113
reset_calculateShipping(shippingFn)
expect(await calculatePrice(sampleOrder)).toStrictEqual(153)

正如最后一个例子所暗示的那样,用于缝合点的最佳机制在很大程度上取决于语言、可用框架,甚至遗留系统的风格。控制遗留系统意味着学习如何在代码中引入各种缝合点,以便在最大限度地减少对遗留软件的干扰的同时提供合适的启用点。虽然函数调用是引入这种缝合点的简单示例,但它们在实践中可能要复杂得多。一个团队可能需要花费几个月的时间来弄清楚如何在经过充分验证的遗留系统中引入缝合点。在遗留系统中添加缝合点的最佳机制可能与我们在绿地中为了类似的灵活性而做的事情不同。

费瑟斯的书主要关注如何让遗留系统接受测试,因为这通常是能够以理智的方式处理遗留系统的关键。但缝合点还有更多用途。一旦我们有了缝合点,我们就可以将探针放置到遗留系统中,从而提高系统的可观察性。我们可能希望监控对 calculateShipping 的调用,找出我们使用它的频率,并捕获其结果以进行单独分析。

但缝合点最有价值的用途可能是它们允许我们将行为从遗留代码中迁移出去。缝合点可以将高价值客户重定向到不同的运费计算器。有效的遗留代码替换是基于在遗留系统中引入缝合点,并使用它们将行为逐渐迁移到更现代的环境中。

在编写新软件时,我们也应该考虑缝合点,毕竟每个新系统迟早都会变成遗留代码。我的许多设计建议都是关于在适当的位置构建软件,以便我们可以轻松地测试、观察和增强它。如果我们在编写软件时考虑到测试,我们往往会得到一组良好的缝合点,这也是 测试驱动开发 如此有用的技术的原因。