函数式领域建模(二)


Simon Brown’s “C4”

  1. The “system context” is the top level representing the entire system.
  2. The system context comprises a number of “containers”, which are deployable units such as a website, a web service, a database, etc.
  3. Each container in turn comprises a number of “components”, which are the major structural building blocks in the code.
  4. Finally, each component comprises a number of “classes” (or in a functional architecture, “modules”) that contain a set of low-level methods or functions.

One of the goals of a good architecture is to define the various boundaries between containers, components, and modules, such that when new requirements arise, as they will, the “cost of change” is minimized.

Bounded Contexts as Autonomous Software Components

界限上下文本身就一个具有明确边界的独立子系统. 有一些常见的架构实践可供参考.

  • 如果整个系统被实现为整体部署 (a single container using the C4 terminology above), 那么界限上下文可以被实现成是一个个的具有明确接口定义的单独模块.
  • 否则的话, 每个界限上下文可以单独部署在自己的容器中 – 面向服务架构(SOA).
  • 或者, 我们甚至可以更细化, 将每个单独的工作流放入一个独立的可部署容器 – 微服务架构.

PS, 一个界限上下文可能会包含多个工作流. 例如仓储部门可能会有收货工作流和发货工作流.

但是, 在项目的早期阶段, 我们不需要致力于确定到底用哪种实践方式. 随着我们对领域的深入了解, 界限也会相应地发生改变, 而重构整体组件要容易得多, 因此一个好的实践是将系统最初构建为整体组件, 然后仅在需要时才重构为解耦的独立容器. 只要我们能确保界限上下文保持独立低耦合, 那么从逻辑设计转换到物理架构就不会太多问题.

除非我们确定利大于弊, 否则无需直接转向微服务. 创建真正低耦和的微服务架构非常棘手 – 如果关闭其中的一个微服务, 而其他所有事情都中断了, 那么实际上并不是微服务架构, 而只是一个分布式的整体!

Communicating Between Bounded Contexts

正如前面所看到的, 我们使用事件在不同界限上下文之间传递信息. 例如, 接单上下文和运输上下文之间的关系可能如下所示:

  • 接单上下文中的接单工作流触发了 OrderPlaced 事件.
  • OrderPlaced 事件放到一个队列中(或其它发布方式).
  • 运输上下文监听 OrderPlaced 事件.
  • 收到事件后, 将创建 ShipOrder 命令.
  • ShipOrder 命令启动 Ship-Order 工作流.
  • Ship-Order 工作流成功完成时, 它将发出 OrderShipped 事件.

可以看到这是一个完全解耦的设计: 上游组件(接单子系统)和下游组件(运输子系统)彼此不了解, 仅通过事件进行通信. 如果我们想要拥有真正独立自主的组件, 这种解耦至关重要.

在上下文之间传输事件的确切机制取决于我们选择的架构体系, 也许是消息队列, 也有可能是直接的函数调用; 至于将事件转换为命令的处理程序(例如 OrderPlaced -> ShipOrder), 它可以是下游的一部分, 也可以由基础设施层(infrastructure)来完成.

如之前所述, 项目的早期阶段不需要立即确定事件的通信及转换机制, 我们需要做的是保证不同上下文之间的低耦合.

Transferring Data Between Bounded Contexts

通常, 上游发送给下游的事件还需要包含下游处理该事件所需的所有数据. 例如, OrderPlaced 事件需要包含已下达订单的完整信息.这样就为下游提供了构建相应的 ShipOrder 命令所需的信息(如果数据太大而无法包含在事件中, 则可以传递共享数据存储的位置).

传递的数据对象可能在表面上类似于领域对象, 但它们并不相同. 传递的数据对象专门设计用于序列化并作为上下文间基础架构的一部分共享, 我们称这些对象为 DTO (Data Transfer Object). 换句话说, OrderPlaced 事件中的 OrderDTO 将包含与 Order 领域对象大多数相同的信息, 但会按照其目的进行不同的结构调整 (后续会说怎么调整, 现在只要知道 DTO 的概念).

在上游的边界处, 将域对象转换为 DTO, 再将 DTO 序列化为 JSON/XML 或其它某种序列化格式.

在下游中, 则是这个过程的逆向: 将 JSON/XML 反序列化为 DTO, 然后将其转换为领域对象.

实际上, 序列化的顶级 DTO 通常是事件 DTO, 而事件 DTO 又包含子 DTO, 例如 OrderDTO, 后者又包含其他子 DTO, 例如 OrderLineDTO 的列表.

Trust Boundaries and Validation

界限上下文的界限会充当 “信任边界”. 界限上下文内部的任何内容都将是可信且有效的, 而界限上下文之外的任何内容都将不可信且可能无效的. 因此, 我们将在工作流的开头和结尾添加 “门”, 它们充当可信任领域和不受信任外部世界之间的中介.

在输入门, 我们将始终验证输入以确保其符合领域模型的约束. 例如, 假设某个订单的某个属性必须为非空且少于 50 个字符. 传入的 OrderDTO 不会有此类约束, 但是在输入门处进行验证之后, 我们可以确保进入工作流中的 Order 领域对象是有效的. 因为如果验证失败, 则将绕过工作流程并生成错误(后续会说明具体怎么操作).

输出门的工作则不一样, 它的工作是确保隐私属性不会泄漏到界限上下文之外, 既避免上下文之间的意外耦合, 又出于安全原因. 例如, 运输上下文不需要知道用于支付订单的信用卡号. 因此, 在将领域对象转换为 DTO 的过程中, 输出门通常会故意 “丢失” 一些信息(例如卡号).

Contracts Between Bounded Contexts

虽然我们希望尽可能减少界限上下文之间的耦合, 但是共享的通信格式总是会引起某种耦合 – 事件和相关的 DTO 在界限上下文之间将形成一种契约. 为了使通信成功, 这两个上下文需要针对它们的通用格式达成共识.

那么谁来决定契约呢? 这依赖于上下文之间的关系(实际上是拥有这些上下文的团队之间的关系), DDD 社区已经总结了一些通用术语:

  • Shared Kernel, 两个上下文互相协商
  • Customer/Supplier or Consumer Driven Contract, 下游定义契约, 上游遵守
  • Conformist, 上游定义契约, 下游遵守

Anti-Corruption Layers

通常, 当与外部系统进行通信时, 它们现有的接口可能与我们的领域模型根本不匹配. 在这种情况下, 需要将交互及数据转换为更适合在我们领域中使用的格式, 否则我们的领域模型将因尝试适应外部系统的模型而变得 “被破坏”.

外部系统通常是指第三方系统, 数据存储库甚至是遗留代码. 因为这些系统我们无法和它签订契约, 只能被迫接受.

这种额外的解耦级别在 DDD 术语中称为 “反腐层”, 通常缩写为 ACL, ACL 角色通常由上面提到的 “输入门” 扮演 – 它防止内部干净的领域模型因外界知识而 “被破坏”.

反腐层的主要目的不是执行验证或防止数据损坏, 而是充当领域之间的 “词汇翻译器”; 同时也避免了以后切换系统时修改核心层代码.

例如在我们要实现的接单系统中, 可能需要向外部某个地图服务查询订单提供的地址, 因此我们将插入一个显式的反腐层(ACL)用来转换第三方地图服务的领域词汇.

A Context Map With Relationships

随着我们对领域的了解, 是时候更新之前定义的 Context Maps 了.

可以看到, 上下文映射不再只是显示上下文之间的纯粹技术关系, 还显示了拥有上下文的团队之间的关系, 以及我们期望他们如何协作.

Workflows Within a Bounded Context

在探索过程中, 我们将业务工作流视为由命令启动的 mini-process, 该工作流会生成一个或多个领域事件. 在函数式架构中. 所有这些工作流都将映射成单个函数, 其中输入是命令对象, 而输出是事件对象的列表.

当创建设计图时, 将工作流表示为带有输入和输出的小管道, 公开工作流(那些从界限上下文之外触发的工作流)将 “伸出” 边界.

一个工作流始终包含在单个界限上下文中, 永远不会有通过多个上下文实现 “端到端” 的情况 (后续再说怎么为工作流建模).

Workflow Inputs and Outputs

工作流的输入始终是与命令关联的数据, 而输出始终是要与其他上下文进行通信的一组事件. 例如, 在接单工作流中, 输入是与 PlaceOrder 命令关联的数据, 输出则是一组如 OrderPlaced 之类的事件.

但是请记住, 如上面的映射图所示, 接单上下文与计费上下文之间是 “Customer/Supplier” 关系. 这意味着, 我们应遵守计费部门的契约, 仅发送他们需要的信息过去, 而不是一个通用的 OrderPlaced 事件. 例如, 可能仅仅是账单地址和账单总额, 而不是送货地址或项目列表.

这意味着我们需要从工作流中发出一个新事件(BillableOrderPlaced), 其结构可能如下所示:

data BillableOrderPlaced =
    OrderId
    AND BillingAddress
    AND AmountToBill

同样的道理, 我们可能还希望发出 OrderAcknowledgementSent 事件.

现在让我们来绘制 Place-Order 工作流的流程图:

请务必注意, 工作流函数并不会 “发布” 领域事件(它只是返回它们), 图上只是我们的逻辑设计, 如何真正的发布事件则是另一个单独的话题.

Avoid Domain Events Within a Bounded Context

在面向对象的设计中, 通常会有领域事件在界限上下文内部引发. 用这种方式, 工作流在内部引发 OrderPlaced 事件, 然后会有一些 event handler 监听到该事件, 并触发后续的流程. 可能如下图所示:

在函数式设计中, 我们宁愿不使用此方法, 因为它会创建隐藏的依赖项, 即事件的处理程序依赖于它要监听的事件. 因此, 如果我们需要一个事件的 “侦听器”, 我们只需将其追加到工作流的末尾, 如下所示:

这种方式更明确 – 上下文内没有具有可变状态的全局事件管理器, 因此更易于理解和维护(后续有这种实践的详细说明).

Code Structure Within a Bounded Context

现在来看看界限上下文中的代码结构.

这个话题已经是老生长谈了, 基本上不是洋葱结构就是六边形结构. 我们这里使用洋葱结构.

当然, 为了确保所有层都向内依赖, 必须要使用依赖注入, 后续实践章节中会有说明.

Keep I/O at the Edges

函数编程的一个主要目标是使用没有副作用的纯函数. 然而大部分的程序都会涉及到 I/O (例如读写数据库), 这是一个有副作用的操作. 那么, 我们要如何处理这部分有副作用的代码呢?

答案是将任何 I/O 推送到洋葱结构的边缘, 也就是说, 只在工作流的开始或结束时读写数据库, 而不是在工作流内部进行读写.

这样还会有一个额外的好处 – 迫使我们关注点分离: 核心 domain 层只涉及业务逻辑, 而持久性和其他 I/O 则是 infrastructural 层该关心的问题.

事实上, 这种设计也避免了我们无意识的使用 “数据驱动开发”, 因为如果甚至无法从工作流内部访问数据库, 则无法使用数据库对领域进行建模! (后续实践会详细讨论数据库的设计).