将单一职责原则应用于前端FE/BFF分层架构 - Expedia


前端后端(BFF)模式是一种最近越来越流行的软件架构模式。在 Expedia Group ,我们在整个微前端团队中大量使用这种模式,作为我们平台解决方案实施的一部分。在发展我们的架构的过程中,我们最近引入了一种新方法,在这个 3 篇系列文章中进行了解释。我们希望这可以帮助将来遇到类似情况的其他人。
 
开发微前端和 BFF 的分层方法
受单一职责原则 (SRP) [2] 的启发,我们分解了构成我们微前端功能的不同层,如下所示:
前端

  • UI 视图层:负责所有渲染方面(React 组件、样式、可访问性、UX)。它处理视图模型并且对域模型一无所知。
  • UI 状态层:负责全局状态,以及对 BFF API 的 API 调用。

后端
  • BFF API:负责编排对下游 API 的调用以及支持前端所需的所有后端功能。它将下游域模型转换为 UI 视图模型,反之亦然。
  • 下游 API:负责基础设施服务、持久化和业务逻辑。它处理领域模型。

如果我们尊重每一层的职责,同时保持它们之间的清晰界限,我们可以得到类似于下图的架构。

 
付诸实践
在考虑这些层时,在考虑任何代码逻辑之前,先考虑消息契约是有帮助的。为了完成用例,每一层需要获取或发送到任何相邻层的消息是什么?如上图所示,我们的架构中有两个关键的“模型”:视图模型和域模型。它们代表 Web 客户端和 BFF API 之间的消息契约,以及相应的 BFF API 和下游 API。所以让我们暂时关注这些。
用例示例
让我们将此用例作为假设能力的示例(假设您正在管理一家在全球拥有多家商店的全球公司):
作为管理员用户,我想查看为我的商店配置的营业时间列表,以便我可以更好地管理不同时区的员工。
以下是此用例的线框:

 
视图模型

为了支持像线框中那样的视图,我们需要一个形状类似于以下的对象数组:

  { 
    uri: "<the-uri>"
    name:
"EG1"
    country:
"US"
    city:
"Seattle"
    hours:

    8am - 10pm
", timeZone: "PDT", 
    lastUpdatedOn:
"01/01 /2021"
    lastUpdatedBy:
"John Doe"
  } 
]

现在我们已经定义了一个潜在的视图模型,让我们看看域模型。
 
领域模型
此示例假设下游 API 已经创建并且可能由不同的团队管理,因此在实现 BFF 时,我们需要做的就是找出 (a) 这些端点是什么,以及 (b) 它们的 API 契约是什么(输入请求形状和输出响应形状)。理想情况下,我们需要单个下游 API 调用来为我们提供所需的数据,但有时我们可能需要执行多个 API 调用。无论情况如何,我们都可以将所有实现逻辑留在 BFF 层,以便我们的客户端不知道任何域级别的细节。
在这个例子中,我们假设有一个下游 API 调用需要对我们的视图模型进行水合: GET: /stores
 
将领域模型映射到视图模型
现在我们已经定义了一个视图模型并且我们理解了处理相关领域模型的契约,下一个问题是如何将后者拟合到前者中。进入mapper,它只是一个下游 API 响应调整为我们的 Web 客户端期望的层(即视图模型)。如上图所示,这是 BFF 层的职责,因此这就是我们实现它的地方。
这个映射器简单地将域模型消息作为输入(或它们的组合),并返回视图模型消息。然后控制器可以自由地返回客户端理解的这个改编的消息。
这个映射器实现的细节超出了这篇文章的范围,但这个例子或多或少地总结了它:

// 示例:从域模型映射 `name` 属性
onst toViewModel = domainModel => domainModel.map(m => m.name);

更复杂的映射操作也可以在这里完成(例如,连接域模型属性以构造视图模型属性)。
 
利弊
就像您做出的任何架构决策一样,总是有利有弊。这是在这种情况下要考虑的方面的简要列表。
 
系统复杂性
最初,这种方法的缺点之一是在将系统作为一个整体来理解时更复杂。特别是,如果您的应用程序正在从概念证明 (POC) 转移到更成熟的代码库。在 POC 阶段,我们倾向于(甚至可能被迫)走捷径,并且很可能不优先考虑重构或使用健全的软件工程原则。在那个阶段,我们的首要任务通常是证明一个概念和/或获得市场牵引力。
当我们通过我们的代码库采用更分层的方法时,更多的抽象出现了,随之而来的是更多的间接层次,需要以不同的方式对代码进行推理。对于某些开发人员来说,这种思维方式的转变可能具有挑战性,尤其是从第一天起就一直在研究 POC 代码库的开发人员(时间越长,情况越糟)。尽管如此,它在某种程度上是一种必要的“邪恶”。代码结构与我们人类在日常生活中建立以帮助我们管理复杂性的层次结构没有什么不同。抽象,尤其是在正确实施的情况下,可以使理解整个画面变得更加困难,但也可以帮助我们实现新的生产力和可扩展性水平。
想想我们可以通过像 React 这样的库实现的生产力水平。在基本层面上,React 可以被认为是 HTML 渲染逻辑的抽象。也许您并不完全了解幕后发生的事情,但这并不能阻止您用它实现伟大的事业。

分而治之
在 UI 和 BFF 之间有明确的边界/契约,独立于下游服务 API 可以为团队效率带来一些有趣的机会。例如,如果下游服务仍在开发中,则前端工作不必搁置。
同样,如果 BFF 端点尚未实现,甚至没有定义,前端开发人员可以开始取得进展。所需要的只是一个清晰的视图模型定义来定义合同,这通常可以从需求和/或 UX 线框图中得出。
 
单元测试
在完成 BFF 开发工作的同时,UI 开发人员可以使用基于商定的视图模型的模拟数据来实现 UI 渲染和状态管理层。此外,这个合同将允许您更轻松地在 UI 和 BFF 上执行 TDD,如果这在您的工具带中。
通过前端和 BFF(即视图模型)之间定义的契约,我们可以尝试在双方进行更多的黑盒测试。事实上,即使我们仍在进行单元测试,只要我们在两个代码库之间保持这些“视图模型契约”同步,我们就可以倾向于拥有更多类似集成的测试策略,同时仍然不必忍受建立一个真正的集成环境。
注意:这并不是说这种技术应该取代真正的集成测试。但是,它可以成为我们整体测试方法中非常有用的工具。


前端单元测试——蓝色箭头表示测试输入;绿色箭头表示测试输出;视图模型(以前设计为与后端通信)可用于存根后端 API 响应。这允许我们在前端做更多的黑盒测试方法。

后端单元测试——蓝色箭头表示测试输入;绿色箭头表示测试输出(即视图模型);下游 API 可以被存根。这允许我们在后端做更多的黑盒测试方法。
明天再来看看这个由三部分组成的系列的第 2 部分,我们将更详细地了解使用这种方法时的前端注意事项。