閘道

封裝存取外部系統或資源的物件

2021 年 8 月 10 日

Martin Fowler

有趣的軟體很少會孤立存在。團隊編寫的軟體通常必須與外部系統互動,這些系統可能是函式庫、對外部服務的遠端呼叫、與資料庫的互動,或是與檔案的互動。通常會針對外部系統提供某種形式的 API,但從我們軟體的脈絡來看,該 API 常常會顯得尷尬。API 可能會使用不同的類型、需要奇怪的參數,並以我們脈絡中沒有意義的方式組合欄位。處理這種 API 每次使用時都可能導致令人不快的失配。

閘道會充當一個單一據點來面對這個外來者。我們系統中的任何程式碼都會與閘道的介面互動,而該介面設計為使用我們系統所使用的條款。然後,閘道會將這個便利的 API 轉譯成外來者提供的 API。

雖然此模式廣泛使用(但應該更普遍),但「閘道」這個名稱尚未普及。因此,雖然您應該預期會經常看到此模式,但它並沒有廣泛使用的名稱。

運作方式

閘道通常是一個簡單的包裝器。我們會檢視我們的程式碼需要對外部系統做什麼,並建構一個介面來清楚且直接地支援它。然後,我們實作閘道以將該互動轉譯成外部系統的條款。這通常會涉及將熟悉的函式呼叫轉譯成外來 API 所需的內容,並根據需要調整參數以使其運作。當我們取得結果時,我們會將它們轉換成我們程式碼中容易使用的形式。隨著我們程式碼的成長,對外部系統提出新的需求,我們會增強閘道以持續支援其不同的需求。

閘道器僅應包含支援國內和國外概念之間轉換的邏輯。建立在該邏輯之上的任何邏輯都應在閘道器的用戶端中。

將連線物件新增到閘道器的基本結構中通常很有用。連線是對呼叫外國程式碼的簡單封裝。閘道器將其參數轉換為外國簽章,並使用該簽章呼叫連線。然後,連線只會呼叫外國 API 並傳回其結果。閘道器最後會將該結果轉換為更易於消化的形式。連線可以用兩種方式。首先,它可以封裝呼叫外國程式碼的任何尷尬部分,例如 REST API 呼叫所需的處理。其次,它作為插入測試替身的良好點。

何時使用

每當我存取某些外部軟體,並且該外部元素有任何尷尬之處時,我都會使用閘道器。我將尷尬之處包含在閘道器的單一位置,而不是讓尷尬之處散佈在我的程式碼中。

使用閘道器可以讓測試架構存根閘道器的連線物件,從而使系統更容易測試。這對於存取遠端服務的閘道器特別重要,因為它可以消除需要進行緩慢的遠端呼叫。對於需要提供罐頭資料進行測試但未設計為這樣做的外部系統,這至關重要。即使外部 API 否則可以使用(在這種情況下,閘道器將僅是連線物件),我也會在此處使用閘道器。

閘道器的另一個優點是,如果發生這種情況,它可以更容易地將一個外部系統換成另一個系統。同樣,如果外部系統更改其 API 或傳回的資料,閘道器可以更容易地調整我們的程式碼,因為任何變更都限制在單一位置。但是,儘管這個好處很方便,但它幾乎從來都不是使用閘道器的理由,因為僅封裝外國 API 就已經足夠了。

閘道器的主要目的是轉換外國詞彙,否則會使主機程式碼複雜化。但在這樣做之前,我們確實需要考慮是否應該只使用外國詞彙。我遇到過這樣的狀況:一個團隊將廣泛理解的外國詞彙轉換為他們程式碼庫中的特定詞彙,因為「他們不喜歡這些名稱」。對於這個決定,我無法陳述任何一般規則,團隊必須運用其判斷力來決定是否應採用外部詞彙或開發自己的詞彙。(在領域驅動設計模式中,這是順應主義和反腐敗層之間的選擇。)

此模式的一個特定範例是,我們在建構平台時,考慮是否要將自己與底層平台隔離。在許多情況下,平台的設施是如此普遍,以至於不值得花費精力來包裝它。例如,我不會考慮包裝語言的集合類別。在這種情況下,我只接受它們的詞彙是我的軟體詞彙的一部分。

進一步閱讀

我最初在 此模式 中描述了 P of EAA。當時,我一直在掙扎是否要創造一個新的模式名稱,而不是參考現有的四人幫模式:門面、轉接器和中介者。最後,我決定有足夠的差異,值得一個新名稱。

儘管門面簡化了更複雜的 API,但通常是由服務的撰寫者為一般用途而完成的。閘道是由客戶端為其特定用途撰寫的。

轉接器是最接近閘道的 GoF 模式,因為它會改變類別的介面以匹配另一個類別。但是轉接器是在兩個介面都已存在的背景下定義的,而使用閘道時,我是在包裝外來元素時定義閘道的介面。這種區別導致我將閘道視為一個獨立的模式。隨著時間的推移,人們對「轉接器」的使用變得更加寬鬆,因此看到閘道被稱為轉接器並不少見。

中介者將多個物件分開,因此它們不需要彼此了解,它們只知道中介者。使用閘道時,通常只有一個資源被封裝在閘道後面,並且該資源不會知道閘道。

閘道的概念非常符合 限界脈絡領域驅動設計。當我處理不同脈絡中的事物時,我會使用閘道,閘道處理外來脈絡與我自己的脈絡之間的轉換。閘道是一種實作防腐層的方式。因此,有些團隊會使用該術語,將他們的閘道命名為「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 使用的類型。在此情況下,應用程式使用 js-joda 來處理時間,這是簡化 JavaScript 中任何類型的日期/時間工作的常見且明智的選擇。然而,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。治療規劃使用者不在乎 equipmentID 代碼,但他們在乎時段中有哪些設備型號可用。因此,convertSlot 會從其本機儲存空間查詢設備型號,並使用型號記錄豐富時段資料。

透過執行此操作,治療規劃應用程式不必處理設備預訂服務的語言。它可以假裝設備預訂服務在治療規劃的世界中無縫運作。

範例:使用可替換連線(TypeScript)

閘道是通往外部程式碼的路徑,而外部程式碼通常是通往存在於其他地方的重要資料的路徑。此類外部資料會使測試變得複雜。我們不希望在治療應用程式的開發人員執行我們的測試時預訂設備時段。即使服務提供測試執行個體,但遠端呼叫的緩慢速度通常會損害快速測試套件的可用性。此時,使用 測試替身 就很有意義。

閘道器是插入此類測試替身的自然點,但有幾種不同的方法可以執行此操作,因為讓遠端閘道器具備更多結構是值得的。在使用遠端服務時,閘道器會履行兩項職責。與本地閘道器一樣,它會將遠端服務的詞彙轉換為主機應用程式的詞彙。但對於遠端服務,它還負責封裝該遠端服務的遠端性,例如遠端呼叫的執行方式的詳細資訊。第二項職責意味著遠端閘道器應包含一個獨立的元素來處理這項職責,我稱之為連線。

在這種情況下,listAvailableSlots 可能會是對某些 URL 的遠端呼叫,該 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 來針對測試執行個體或 Stub 服務測試系統。這很棒,但透過操作閘道器,我們可以完全避免遠端呼叫,這對於測試來說可以大幅提升速度。

連線也會處理使用執行遠端呼叫的機制所產生的麻煩,在本例中是 JavaScript 的 fetch API。外部閘道器會處理將閘道器的介面轉換為遠端 API 中的遠端簽章,而連線會取得該簽章並將其表示為 HTTP GET。將這兩個工作分開執行可以讓每個工作保持簡單。

然後我在建構時將此連線新增到閘道器類別。接著,公開函數會使用此傳入的連線。

類別 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)
  }

閘道器通常會在同一個基礎連線上支援多個公開函數。因此,如果我們的治療應用程式稍後需要預約血液過濾器機器,我們可以新增另一個函數到閘道器,該函數會使用具有不同設備代碼的相同連線函數。閘道器也可以將來自多個連線的資料合併到單一公開函數中。

當像這樣的服務呼叫需要一些設定時,通常最好將其與使用它的程式碼分開執行。我們希望我們的治療計畫預約程式碼能夠簡單地使用閘道器,而不需要知道如何設定它。執行此操作的一個簡單且有用的方法是使用服務定位器。

類別 ServiceLocator…

  boneFusionGateway: BoneFusionGateway

serviceLocator.ts…

  export let theServiceLocator: ServiceLocator

組態(通常在應用程式啟動時執行)

  theServiceLocator.boneFusionGateway = new BoneFusionGateway(listAvailableSlots)

使用閘道的應用程式碼

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

在這種設定下,我可以撰寫一個測試,並使用連線的 stub,如下所示

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)
});

透過這種方式進行 stubbing,我可以在完全不需要進行遠端呼叫的情況下撰寫測試。

但是,根據閘道執行的轉譯複雜度,我可能會偏好使用應用程式的語言,而非遠端服務的語言撰寫測試資料。我可以透過類似這樣的測試來執行此操作,此測試會檢查 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
  }
}

透過 stubbing 閘道,可以更清楚 suitableSlots 內部的應用程式邏輯應執行的動作,在本例中,會過濾掉 Marrowvate D10。但是,執行此操作時,我並未測試閘道內的轉譯邏輯,因此我至少需要一些在連線層級進行 stubbing 的測試。如果遠端系統資料並不難以追蹤,我可能只需要 stubbing 連線即可。但是,通常在撰寫測試時,能夠在兩個點上進行 stubbing 會很有用。

我的程式設計平台可能會直接支援某種形式的遠端呼叫 stubbing。例如,JavaScript 測試環境 Jest 允許我使用其模擬函式 stubbing 各種函式呼叫。我可以使用的功能取決於我使用的平台,但正如您所見,設計閘道以在沒有任何其他工具的情況下擁有這些掛鉤並不困難。

在 stubbing 遠端服務時,最好使用 合約測試 來確保我對遠端服務的假設與該服務所做的任何變更保持同步。

範例:重構存取 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 日:開始範例