レガシーシーム

2024年1月4日

レガシーシステムに取り組む際には、ソースコードを編集せずにシステムの動作を変更できる場所であるシームを特定して作成することが重要です。シームを見つけたら、それを利用して依存関係を解消し、テストを簡素化し、プローブを挿入して可観測性を高め、レガシーシステムの置き換えの一環としてプログラムの流れを新しいモジュールにリダイレクトできます。

マイケル・フェザーズは、彼の著書「Working Effectively with Legacy Code」の中で、レガシーシステムの文脈で「シーム」という用語を作り出しました。彼の定義は次のとおりです。「シームとは、その場所で編集することなくプログラムの動作を変更できる場所のことです」。

シームが便利な場所の例を示します。注文価格を計算するコードがあるとします。

// 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`への呼び出しの周りにシームを導入する必要があります。これは、テストで呼び出しをスタブにリダイレクトできるようにするものです。

これを行う1つの方法は、`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`への呼び出しを監視し、使用頻度を把握し、結果を個別に分析するためにキャプチャしたい場合があります。

しかし、おそらくシームの最も価値のある用途は、動作をレガシーから移行できることです。シームは、高価値顧客を別の送料計算機にリダイレクトする場合があります。効果的なレガシーシステムの置き換えは、レガシーシステムにシームを導入し、それらを使用して動作を徐々により近代的な環境に移行することに基づいています。

シームは、新しいソフトウェアを作成する場合にも考慮すべき点です。結局のところ、すべての新しいシステムは遅かれ早かれレガシーになります。私の設計アドバイスの多くは、適切な場所にシームを配置してソフトウェアを構築することに関するものであり、これにより、容易にテスト、監視、拡張できます。テストを念頭に置いてソフトウェアを作成すると、優れたシームセットが得られる傾向があり、これがテスト駆動開発が非常に有用な手法である理由です。