在使用遗留系统时,识别和创建接缝是很有价值的:我们可以在不编辑源代码的情况下改变系统的行为。一旦找到接缝,我们就可以使用它来打破依赖关系以简化测试,插入探针以获得可观察性,并将程序流重定向到新模块作为遗留置换的一部分。
Micheal Feathers 在他的《有效处理遗留代码》一书中,在遗留系统的背景下创造了术语“接缝”。他的定义是: “接缝是一个你可以改变程序行为而无需在该地方进行编辑的地方” 。
这是一个可以方便使用接缝的示例。想象一些计算订单价格的代码。
// 打字稿
导出异步函数calculatePrice(order:Order) {
const itemPrices = order.items.map(i =>calculateItemPrice(i))
const basePrice = itemPrices.reduce((acc, i) => acc + i.price, 0)
常量折扣=计算折扣(订单)
const Shipping =等待calculateShipping (订单)
const adjustmentShipping = applyShippingDiscounts(订单,运费)
返回基本价格+折扣+调整后的运费
}
函数calculateShipping
会调用外部服务,该服务速度慢(且成本高),因此我们不想在测试时调用它。相反,我们想引入一个存根,这样我们就可以为每个测试场景提供预先确定的响应。不同的测试可能需要函数的不同响应,但我们不能在测试中编辑calculatePrice
的代码。我们需要在对calculateShipping
的调用周围引入一个接缝,这将允许我们的测试将调用重定向到存根。
实现此目的的一种方法是将calculateShipping
函数作为参数传递
导出异步函数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 Shipping = 等待shippingFn (订单) const adjustmentShipping = applyShippingDiscounts(订单,运费) 返回基本价格+折扣+调整后的运费 }
然后,该函数的单元测试可以替代简单的存根。
const ShippingFn = async (o:Order) => 113 期望(等待计算价格(sampleOrder,shippingFn))。toStrictEqual(153)
每个接缝都带有一个启用点:“一个您可以决定使用一种行为或另一种行为的地方” [WELC] 。将函数作为参数传递会在calculateShipping
的调用者中打开一个启用点。
现在这使得测试变得更加容易,我们可以输入不同的运费值,并检查applyShippingDiscounts
是否正确响应。尽管我们必须更改原始源代码来引入接缝,但对该功能的任何进一步更改都不需要我们更改该代码,所有更改都发生在测试代码中的启用点中。
将函数作为参数传递并不是我们引入接缝的唯一方法。毕竟,更改calculateShipping
的签名可能会令人担忧,并且我们可能不希望通过生产代码中的遗留调用堆栈来线程化运输函数参数。在这种情况下,查找可能是更好的方法,例如使用服务定位器。
导出异步函数calculatePrice(order:Order) {
const itemPrices = order.items.map(i =>calculateItemPrice(i))
const basePrice = itemPrices.reduce((acc, i) => acc + i.price, 0)
常量折扣=计算折扣(订单)
const Shipping =等待ShippingServices.calculateShipping (订单)
const adjustmentShipping = applyShippingDiscounts(订单,运费)
返回基本价格+折扣+调整后的运费
}
类运输服务{ 静态#soleInstance:运输服务 静态 init(arg?:ShippingServices) { this.#soleInstance = arg ||新的运输服务() } 静态异步calculateShipping(o:Order) {return this.#soleInstance.calculateShipping(o)} 异步计算运费(o:订单){返回legacy_calcuateShipping(o)} // ...更多服务
定位器允许我们通过定义子类来覆盖行为。
类 ShippingServicesStub 扩展 ShippingServices { calculateShippingFn: typeof ShippingServices.calculateShipping = (o) => {抛出新错误(“未提供存根”)} 异步calculateShipping(o:Order) {返回this.calculateShippingFn(o)} // 更多服务
然后我们可以在测试中使用启用点
const 存根 = new ShippingServicesStub() 存根.calculateShippingFn = async (o:Order) => 113 ShippingServices.init(存根) 期望(等待计算价格(sampleOrder)).toStrictEqual(153)
这种服务定位器是一种经典的面向对象方法,通过函数查找来设置接缝,我在此处展示它是为了指示我可能在其他语言中使用的方法类型,但我不会在 TypeScript 中使用这种方法或 JavaScript。相反,我会将类似的东西放入模块中。
导出让calculateShipping = Legacy_calculateShipping 导出函数reset_calculateShipping(fn?: typeof Legacy_calculateShipping) { 计算运费 = fn ||传统_计算运费 }
然后我们可以在这样的测试中使用代码
const ShippingFn = async (o:Order) => 113 重置_计算运费(shippingFn) 期望(等待计算价格(sampleOrder)).toStrictEqual(153)
正如最后一个示例所示,用于接缝的最佳机制在很大程度上取决于语言、可用框架以及遗留系统的风格。控制遗留系统意味着学习如何在代码中引入各种接缝,以提供正确类型的启用点,同时最大限度地减少对遗留软件的干扰。虽然函数调用是引入此类接缝的一个简单示例,但在实践中它们可能要复杂得多。团队可能会花费几个月的时间来弄清楚如何将接缝引入陈旧的遗留系统中。向遗留系统添加接缝的最佳机制可能与我们在新领域中实现类似灵活性所采用的机制不同。
Feathers 的书主要关注测试遗留系统,因为这通常是能够以合理的方式使用它的关键。但接缝的用途远不止于此。一旦我们有了接缝,我们就可以将探测器放置到遗留系统中,从而提高系统的可观察性。我们可能想要监视对calculateShipping
调用,计算出我们使用它的频率,并捕获其结果以进行单独分析。
但接缝最有价值的用途可能是它们允许我们将行为从遗留中迁移出来。接缝可能会将高价值客户重定向到不同的运费计算器。有效的遗留遗留转移的基础是将接缝引入遗留系统,并利用它们逐渐将行为转移到更现代的环境中。
当我们编写新软件时,接缝也是需要考虑的问题,毕竟每个新系统迟早都会成为遗留系统。我的大部分设计建议都是关于构建具有适当放置接缝的软件,以便我们可以轻松地测试、观察和增强它。如果我们在编写软件时考虑到测试,我们往往会得到一组良好的接缝,这就是测试驱动开发如此有用的技术的原因。