网关

封装对外部系统或资源访问的对象

2021 年 8 月 10 日

马丁·福勒

有趣的软件很少孤立存在。团队编写的软件通常需要与外部系统交互,这些系统可能是库、对外部服务的远程调用、与数据库的交互或与文件的交互。通常,外部系统会有一些形式的 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。治疗计划用户不关心设备 ID 代码,但他们确实关心时段中可用的设备型号。因此,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 来针对测试实例或存根服务测试系统。这很棒,但通过操作网关,我们可以完全避免远程调用,这可以显着提高测试的速度。

连接还负责使用用于调用远程调用的机制的麻烦,在本例中是 JavaScript 的 fetch API。外部网关负责将网关的接口转换为远程 API 方面的远程签名,而连接则采用该签名并将其表示为 HTTP get。将这两个任务分开可以使每个任务保持简单。

然后,我在构造时将此连接添加到网关类中。然后,公共函数使用此传入的连接。

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 日:开始示例