ゲートウェイ

外部システムまたはリソースへのアクセスをカプセル化するオブジェクト

2021年8月10日

マーティン・ファウラー

興味深いソフトウェアは、単独で存在することは稀です。チームが記述するソフトウェアは、通常、外部システムと相互作用する必要があります。これらの外部システムは、ライブラリ、外部サービスへのリモート呼び出し、データベースとのやり取り、またはファイルとのやり取りかもしれません。通常、その外部システムのためのAPIがあるでしょうが、そのAPIは多くの場合、ソフトウェアのコンテキストから見ると扱いにくいと感じられます。APIは異なる型を使用したり、奇妙な引数を必要としたり、コンテキストでは意味をなさない方法でフィールドを組み合わせたりするかもしれません。このようなAPIを扱うと、使用するたびに不協和音が生じる可能性があります。

ゲートウェイは、この異質なものに対処するための単一のポイントとして機能します。システム内のコードは、システムの用語で動作するように設計されたゲートウェイのインターフェースと相互作用します。次に、ゲートウェイは、この便利なAPIを異質なものが提供するAPIに変換します。

このパターンは広く使用されていますが(より普及すべきですが)、その名前「ゲートウェイ」は定着していません。そのため、このパターンを頻繁に見かけるはずですが、広く使用されている名前はありません。

仕組み

ゲートウェイは通常、単純なラッパーです。外部システムでコードが何をする必要があるかを検討し、それを明確かつ直接的にサポートするインターフェースを構築します。次に、ゲートウェイを実装して、そのインタラクションを外部システムの用語に変換します。これには通常、使い慣れた関数呼び出しを、外部APIで必要なものに変換し、必要に応じてパラメーターを調整して動作させる作業が含まれます。結果が得られたら、それをコードで簡単に消費できる形式に変換します。コードが成長し、外部システムに新たな要求を行うにつれて、ゲートウェイを強化して、さまざまなニーズに対応し続けます。

ゲートウェイには、国内と海外の概念間のこの変換をサポートするロジックのみを含める必要があります。それに基づいて構築されるロジックは、ゲートウェイのクライアントに含める必要があります。

ゲートウェイの基本構造に接続オブジェクトを追加すると便利なことがよくあります。接続は、外部コードへの呼び出しをラップする単純なものです。ゲートウェイは、そのパラメーターを外部シグネチャに変換し、そのシグネチャを使用して接続を呼び出します。接続は、外部APIを呼び出してその結果を返します。ゲートウェイは、その結果をより消化しやすい形式に変換して終了します。接続は2つの点で役立ちます。1つ目は、REST API呼び出しに必要な操作など、外部コードへの呼び出しの扱いにくい部分をカプセル化できることです。2つ目は、テストダブルを挿入するのに適したポイントとして機能することです。

いつ使用するか

外部ソフトウェアにアクセスし、その外部要素に扱いにくさがある場合は、常にゲートウェイを使用します。扱いにくさをコード全体に広げるのではなく、ゲートウェイの単一の場所に封じ込めます。

ゲートウェイを使用すると、テストハーネスがゲートウェイの接続オブジェクトをスタブアウトできるようになるため、システムをはるかに簡単にテストできるようになります。これは、リモートサービスにアクセスするゲートウェイにとって特に重要です。低速なリモート呼び出しの必要性を排除できるためです。テスト用の缶詰データを供給する必要があるものの、そうするように設計されていない外部システムにとっては不可欠です。外部APIが他の点で問題なく使用できる場合でも(その場合、ゲートウェイは接続オブジェクトのみになります)、ここでゲートウェイを使用します。

ゲートウェイのもう1つの利点は、万が一の場合に、外部システムを別のシステムに簡単に交換できることです。同様に、外部システムがそのAPIまたは返されたデータを変更した場合、ゲートウェイを使用すると、変更が1つの場所に限定されるため、コードの調整がはるかに簡単になります。しかし、この利便性は便利ではありますが、外国のAPIをカプセル化するだけで十分な正当な理由であるため、ゲートウェイを使用する理由になることはほとんどありません。

ゲートウェイの主な目的は、そうしないとホストコードを複雑にするであろう外国の語彙を翻訳することです。しかし、その前に、外国の語彙をそのまま使用すべきかどうかを検討する必要があります。「名前が好きではない」という理由で、チームが広く理解されている外国の語彙をコードベースの特定の語彙に翻訳した状況に遭遇したことがあります。この決定について述べる一般的なルールはありません。チームは、外部の語彙を採用するか、独自の語彙を開発するかについて判断する必要があります。(ドメイン駆動設計パターンでは、これは適合主義者と破損防止レイヤーの間の選択です。)

この特定の例としては、プラットフォーム上に構築しており、基盤となるプラットフォームから自身を隔離したいかどうかを検討する場合です。多くの場合、プラットフォームの機能は非常に広範囲に及ぶため、ラップする手間をかける価値はありません。たとえば、言語のコレクションクラスをラップすることは考えません。そのような状況では、その語彙がソフトウェアの語彙の一部であることを受け入れます。

参考文献

私は当初、このパターンP of EAAで説明しました。その時、既存のGang of Fourパターン(ファサード、アダプター、メディエーター)を参照するのではなく、新しいパターン名を造語するかどうかで悩みました。最終的に、新しい名前を付ける価値があるほど十分な違いがあると考えました。

ファサードはより複雑なAPIを簡素化しますが、通常、サービスの作成者が一般的な使用のために行います。ゲートウェイは、クライアントが特定の使用のために作成します。

アダプターは、別のクラスのインターフェースと一致するようにクラスのインターフェースを変更するため、ゲートウェイに最も近いGoFパターンです。しかし、アダプターは両方のインターフェースがすでに存在しているというコンテキストで定義されていますが、ゲートウェイでは、外国の要素をラップするときにゲートウェイのインターフェースを定義しています。その区別により、ゲートウェイを別のパターンとして扱うようになりました。時間が経つにつれて、「アダプター」をより緩やかに使用する人が増えているため、ゲートウェイがアダプターと呼ばれるのは珍しいことではありません。

メディエーターは複数のオブジェクトを分離して、互いに認識する必要がないようにします。メディエーターについてのみ認識します。ゲートウェイでは、通常、ゲートウェイの背後にカプセル化されているリソースは1つだけであり、そのリソースはゲートウェイについて認識しません。

ゲートウェイの概念は、ドメイン駆動設計境界づけられたコンテキストの概念とうまく適合します。異なるコンテキストのものを扱っているときにゲートウェイを使用します。ゲートウェイは、外国のコンテキストと自身のコンテキスト間の変換を処理します。ゲートウェイは、破損防止レイヤーを実装する方法です。したがって、一部のチームはその用語を使用して、ゲートウェイに「ACL」という略語のような名前を付けます。

「ゲートウェイ」という用語の一般的な使用例は、APIゲートウェイです。上記で概説した原則によると、これはサービスプロバイダーが一般的なクライアントの使用のために構築するため、実際にはファサードに近いものです。

例:単純な関数(TypeScript)

さまざまな治療プログラムを監視する架空の病院アプリケーションを考えてみましょう。これらの治療プログラムの多くは、患者が骨融合機を使用する時間を予約する必要があります。これを行うために、アプリケーションは病院の機器予約サービスと対話する必要があります。アプリケーションは、機器の利用可能な予約スロットをリストする関数を公開するライブラリを介してサービスと対話します。

equipmentBookingService.ts…

  export function listAvailableSlots(equipmentCode: string, duration: number, isEmergency: boolean) : Slot[]

アプリケーションは骨融合機のみを使用し、緊急時に使用することはないため、この関数呼び出しを簡略化するのは理にかなっています。ここでの単純なゲートウェイは、現在のアプリケーションにとって意味のある方法で名前が付けられた関数にすることができます。

boneFusionGateway.ts…

  export function listBoneFusionSlots(length: Duration) {
    return ebs.listAvailableSlots("BFSN", length.toMinutes(), false)
      .map(convertSlot)
  }

このゲートウェイ関数は、いくつかの有用なことを行っています。まず、その名前はアプリケーション内の特定の使用法に結び付けられ、多くの呼び出し元が読みやすいコードを含めることができます。

ゲートウェイ関数は、機器予約サービスの機器コードをカプセル化します。この関数のみが、骨融合機を入手するには、コード「BFSN」が必要であることを知る必要があります。

ゲートウェイ関数は、アプリケーション内で使用される型からAPIで使用される型への変換を行います。この場合、アプリケーションは、JavaScriptでのあらゆる種類の日付/時刻処理を簡略化するために、一般的で賢明な選択であるjs-jodaを使用して時間を処理します。ただし、APIは整数分の数値を使用します。ゲートウェイ関数を使用すると、呼び出し元は外部APIの規則に変換する方法を気にすることなく、アプリケーションの規則を使用できます。

アプリケーションからのすべてのリクエストは緊急ではないため、ゲートウェイは常に同じ値になるパラメーターを公開しません。

最後に、APIからの戻り値は、変換関数を使用して機器予約サービスのコンテキストから変換されます。

機器予約サービスは、次のようなスロットオブジェクトを返します。

equipmentBookingService.ts…

  export interface Slot {
    duration: number,
    equipmentCode: string,
    date: string,
    time: string,
    equipmentID: string,
    emergencyOnly: boolean,
  }

しかし、呼び出し元のアプリケーションは、次のようなスロットを持つ方が便利だと考えています。

treatmentPlanningAppointment.ts…

  export interface Slot {
    date: LocalDate,
    time: LocalTime,
    duration: Duration,
    model: EquipmentModel
  }

そのため、このコードは変換を実行します。

boneFusionGateway.ts…

  function convertSlot(slot:ebs.Slot) : Slot {
    return {
      date: LocalDate.parse(slot.date),
      time: LocalTime.parse(slot.time),
      duration: Duration.ofMinutes(slot.duration),
      model: modelFor(slot.equipmentID),
    }
  }

変換は、治療計画アプリケーションにとって意味のないフィールドを省略します。日付と時刻の文字列からjs-jodaに変換します。治療計画のユーザーは機器IDコードを気にしませんが、スロットで利用可能な機器のモデルを気にします。そのため、convertSlotは、ローカルストアから機器モデルをルックアップし、モデルレコードでスロットデータを強化します。

これを行うことで、治療計画アプリケーションは機器予約サービスの言語を処理する必要がなくなります。機器予約サービスが治療計画の世界でシームレスに機能しているふりをすることができます。

例:交換可能な接続の使用(TypeScript)

ゲートウェイは外国のコードへのパスであり、多くの場合、外国のコードは他の場所にある重要なデータへのルートです。このような外国のデータはテストを複雑にする可能性があります。治療アプリケーションの開発者がテストを実行するたびに、機器のスロットを予約したくはありません。サービスがテストインスタンスを提供する場合でも、リモート呼び出しの低速性は、高速なテストスイートの使いやすさを損なうことがよくあります。これは、テストダブルを使用するのが理にかなっている場合です。

ゲートウェイは、そのようなテストダブルを挿入する自然なポイントですが、リモートゲートウェイにはもう少し構造を持たせる価値があるため、いくつかの異なる方法があります。リモートサービスを使用する場合、ゲートウェイは2つの責任を果たします。ローカルゲートウェイと同様に、リモートサービスの語彙からホストアプリケーションの語彙への変換を行います。しかし、リモートサービスでは、リモート呼び出しがどのように行われるかの詳細など、そのリモートサービスのリモート性をカプセル化する責任もあります。2番目の責任は、リモートゲートウェイにはそれを処理するための別の要素を含める必要があることを意味します。これを接続と呼びます。

この状況では、listAvailableSlotsは構成から提供できるURLへのリモート呼び出しである可能性があります。

equipmentBookingService.ts…

  export async function listAvailableSlots(equipmentCode: string, duration: number, isEmergency: boolean) : Promise<Slot[]>
  {
    const url = new URL(config['equipmentServiceRootUrl'] + '/availableSlots')
    const params = url.searchParams;
    params.set('duration', duration.toString())
    params.set('isEmergency', isEmergency.toString())
    params.set('equipmentCode', equipmentCode)
    const response = await fetch(url)
    const data = await response.json()
    return data
  }

構成にルートURLを含めることで、異なるルートURLを提供することにより、テストインスタンスまたはスタブサービスに対してシステムをテストできます。これは素晴らしいことですが、ゲートウェイを操作することでリモート呼び出しをすべて回避できるため、テストを大幅に高速化できます。

接続は、この場合のJavaScriptのfetch APIなど、リモート呼び出しを呼び出すためのメカニズムを使用する手間も軽減します。外側のゲートウェイは、ゲートウェイのインターフェースをリモートAPIの観点からリモートシグネチャに変換する処理を行い、接続は、そのシグネチャを取得してHTTP getとして表現します。これらの2つのタスクを分離することで、それぞれがシンプルに保たれます。

次に、この接続を構築時にゲートウェイ クラスに追加します。その後、パブリック関数はこの渡された接続を使用します。

class BoneFusionGateway…

  private readonly conn: Connection
  constructor(conn:Connection) {
    this.conn = conn
  }

  async listSlots(length: Duration) : Promise<Slot[]> {
    const slots = await this.conn("BFSN", length.toMinutes(), false)
    return slots.map(convertSlot)
  }

多くの場合、ゲートウェイは同じ基盤となる接続で複数のパブリック関数をサポートします。したがって、治療アプリケーションで後で血液フィルターマシンを予約する必要がある場合、異なる機器コードで同じ接続関数を使用する別の関数をゲートウェイに追加できます。ゲートウェイは、複数の接続からのデータを単一のパブリック関数に結合することもできます。

このようなサービス呼び出しに何らかの構成が必要な場合、通常はそれを使用するコードとは別に構成するのが賢明です。治療計画のアポイントメントコードは、どのように構成すべきかを知らなくても、ゲートウェイを簡単に使用できるようにする必要があります。これを行うための簡単で便利な方法は、サービスロケーターを使用することです。

class ServiceLocator…

  boneFusionGateway: BoneFusionGateway

serviceLocator.ts…

  export let theServiceLocator: ServiceLocator

構成(通常はアプリケーションの起動時に実行されます)

  theServiceLocator.boneFusionGateway = new BoneFusionGateway(listAvailableSlots)

ゲートウェイを使用するアプリケーションコード

  const slots =  await theServiceLocator.boneFusionGateway.listSlots(Duration.ofHours(2))

この種の設定の場合、次のように接続のスタブを使用してテストを作成できます。

it('stubbing the connection', async function() {
  const input: ebs.Slot[] = [
    {duration:  120, equipmentCode: "BFSN", equipmentID: "BF-018",
     date: "2020-05-01", time: "13:00", emergencyOnly: false},
    {duration: 180, equipmentCode: "BFSN", equipmentID: "BF-018",
     date: "2020-05-02", time: "08:00", emergencyOnly: false},
    {duration: 150, equipmentCode: "BFSN", equipmentID: "BF-019",
     date: "2020-04-06", time: "10:00", emergencyOnly: false},
   
  ]
  theServiceLocator.boneFusionGateway = new BoneFusionGateway(async () => input)
  const expected: Slot[] = [
    {duration: Duration.ofHours(2), date: LocalDate.of(2020, 5,1), time: LocalTime.of(13,0),
     model: new EquipmentModel("Marrowvate D12")},
    {duration: Duration.ofHours(3), date: LocalDate.of(2020, 5,2), time: LocalTime.of(8,0),
     model: new EquipmentModel("Marrowvate D12")},
  ]
  expect(await suitableSlots()).toStrictEqual(expected)
});

このようにスタブを作成すると、リモート呼び出しをまったく行わずにテストを作成できます。

ただし、ゲートウェイが行う変換の複雑さによっては、リモートサービスの言語ではなく、アプリケーションの言語でテストデータを記述したい場合があります。suitableSlotsが間違った種類の機器モデルのスロットを削除することを確認するこのようなテストでそれができます。

it('stubbing the gateway', async function() {
  const stubGateway = new StubBoneFusionGateway()
  theServiceLocator.boneFusionGateway = stubGateway
  stubGateway.listSlotsData = [
    {duration: Duration.ofHours(2), date: LocalDate.of(2020, 5,1), time: LocalTime.of(12,0),
     model: new EquipmentModel("Marrowvate D10")}, // not suitable
    {duration: Duration.ofHours(2), date: LocalDate.of(2020, 5,1), time: LocalTime.of(13,0),
     model: new EquipmentModel("Marrowvate D12")},
    {duration: Duration.ofHours(3), date: LocalDate.of(2020, 5,2), time: LocalTime.of(8,0),
     model: new EquipmentModel("Marrowvate D12")},
  ]
  const expected: Slot[] = [
    {duration: Duration.ofHours(2), date: LocalDate.of(2020, 5,1), time: LocalTime.of(13,0),
     model: new EquipmentModel("Marrowvate D12")},
    {duration: Duration.ofHours(3), date: LocalDate.of(2020, 5,2), time: LocalTime.of(8,0),
     model: new EquipmentModel("Marrowvate D12")},
  ]
  expect(await suitableSlots()).toStrictEqual(expected)   
});
class StubBoneFusionGateway extends BoneFusionGateway {  
  listSlotsData: Slot[] = []

  async listSlots(length: Duration) : Promise<Slot[]> {
    return this.listSlotsData
  }
  
  constructor() {
    super (async () => []) //connection not used, but needed for type check
  }
}

ゲートウェイをスタブ化すると、suitableSlots内のアプリケーションロジックが何をすべきかが明確になります。この場合は、Marrowvate D10をフィルタリングします。しかし、これを行う場合、ゲートウェイ内の変換ロジックをテストしていないため、少なくとも接続レベルでスタブするいくつかのテストが必要です。また、リモートシステムのデータがそれほど理解しにくいものでなければ、接続のみをスタブするだけで済むかもしれません。ただし、作成するテストに応じて、両方のポイントでスタブできると便利なことがよくあります。

私のプログラミングプラットフォームでは、リモート呼び出しを直接スタブする何らかの形式がサポートされている場合があります。たとえば、JavaScriptのテスト環境であるJestでは、モック関数を使用してあらゆる種類の関数呼び出しをスタブできます。利用できるものは使用しているプラットフォームによって異なりますが、ご覧のとおり、追加のツールなしでこれらのフックを備えるようにゲートウェイを設計することは難しくありません。

このようなリモートサービスをスタブ化する場合、リモートサービスに関する私の仮定が、そのサービスが行う変更と同期していることを確認するために、コントラクトテストを使用するのが賢明です。

例:YouTubeにアクセスするコードをリファクタリングしてゲートウェイを導入する(Ruby)

数年前、記事を書き、YouTubeのAPIにアクセスして動画に関する情報を表示するコードをいくつか示しました。コードがどのように異なる懸念事項を絡み合わせているかを示し、それらを明確に分離するためにコードをリファクタリングします。その過程でゲートウェイを導入します。既存のコードベースにゲートウェイを導入する方法を段階的に説明します。

謝辞

(Chris)Chakrit Likitkhajorn、Cam Jackson、Deepti Mittal、Jason Smith、Karthik Krishnan、Marcelo de Moraes Leite、Matthew Harward、およびPavlo Keresteyは、社内のメーリングリストでこの投稿の草案について話し合いました。

重要な改訂

2021年8月10日:公開

2021年7月28日:リモート例を開始

2021年5月20日:テキストを開始

2021年4月26日:例を開始