傳統接縫
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)
正如最後一個範例所建議的,用於接縫的最佳機制在很大程度上取決於語言、可用架構,當然還有舊式系統的樣式。控制舊式系統意味著學習如何將各種接縫引入程式碼,以提供正確類型的啟用點,同時將對舊式軟體的干擾降至最低。雖然函式呼叫是引入此類接縫的簡單範例,但它們在實務上可能會複雜得多。一個團隊可能花幾個月的時間找出如何將接縫引入一個使用多年的舊式系統。在舊式系統中新增接縫的最佳機制可能與我們在全新系統中為類似的彈性所做的不同。
Feathers 的書主要著重於讓舊式系統接受測試,因為這通常是能夠以合理的方式使用它的關鍵。但接縫還有更多用途。一旦我們有了接縫,我們就可以將探針置入舊式系統,讓我們可以增加系統的可觀察性。我們可能想要監控對 calculateShipping
的呼叫,找出我們使用它的頻率,並擷取其結果以進行個別分析。
但接縫最具價值的用途可能是它們允許我們將行為從舊系統中遷移出來。接縫可能會將高價值客戶重新導向到不同的運費計算器。有效的舊系統取代建立在將接縫引入舊系統,並使用它們逐漸將行為移至更現代化的環境之上。
接縫也是我們在撰寫新軟體時需要考慮的事情,畢竟每個新系統遲早都會變成舊系統。我的許多設計建議都是關於建立具有適當放置接縫的軟體,以便我們可以輕鬆地測試、觀察和增強它。如果我們在撰寫軟體時考慮到測試,我們往往會得到一組良好的接縫,這就是為什麼測試驅動開發是一種如此有用的技術。