基于领域模型的微服务划分--实战案例解析

1,506 阅读21分钟

前言

微服务的最大挑战之一是定义各个服务的边界。 一般的规则是服务应该只做“一件事”(参考SRP原则) — 但是,实践这条规则需要经过认真的考虑。 没有任何机械性的流程可以生成“适当的”设计。 必须深入考虑业务领域、需求和目标。 否则,最终可能得到一个杂乱无章的设计,它呈现一些不需要的特征,例如隐藏服务之间的依赖关系、紧密耦合,或者设计不佳的界面。

[迈入微服务架构的第一关--服务边界划分]一文中,简单介绍了基于领域的微服务划分方法。受篇幅所限,没能更深一步分析领域模型的构建,很多同学表示有一种似懂非懂的感觉。借用林群院士的话说:假传万卷书,真传一案例。一个好的案例能让人瞬间领悟真谛,胜过读万卷书。本文将借用微软Azure的官方案例--无人机快递服务,详细介绍如何基于领域模型划分微服务,希望对同学们有帮助。

DDD基础知识回顾

领域驱动设计 (DDD) 认为应该尽可能围绕业务功能而不是数据访问或消息传递等水平层来设计微服务。 此外,微服务应具有低耦合高内聚的特点。 如果在更新一个服务时无需同时更新其他服务,则该微服务是低耦合的。 如果微服务的职责单一且定义完善,例如管理用户帐户或跟踪投递历史记录,则它是高内聚的。 服务应该封装领域知识,使这些领域知识对于客户端而言处于抽象状态。 例如,客户端应该能够在无需知晓派遣算法或如何管理无人机群的情况下安排无人机。

DDD提供一个框架,可以让你顺利设计一组完善的微服务。 DDD 包括两个不同的阶段:战略和战术。 在 DDD 的战略模式中,可以定义系统的大规模结构。 战略模式有助于确保体系结构专注于业务功能。 战术性 DDD 提供一组可用于创建领域模型的设计模式。 这些模式包括实体、聚合和领域服务。 借助这些战术模式,可以设计低耦合高内聚的微服务。

域驱动设计 (DDD) 流程图

在本文和接下来的步骤中,我们将逐步执行以下步骤,将它们应用于无人机快递应用程序:

  1. 我们首先分析业务领域,以了解应用程序的功能要求。 该步骤输出领域的非正式说明,可将其优化成更正式的一组领域模型。
  2. 接下来,定义领域的限界上下文(Bounded Context)。 每个限界上下文包含一个领域模型,该模型表示大型系统的特定子域。
  3. 在限界上下文中,应用战术 DDD 模式以定义实体、聚合、领域服务。
  4. 使用前一步骤的结果标识出应用程序中的微服务。

请务必记住,DDD 是迭代的持续过程。 服务边界不是一成不变的。 随着应用程序的演变,你可以决定将某个服务分解成多个较小服务。

ℹ️ 本文未完整介绍全面的领域分析。 我们特意保留了简短内容来说明要点。 有关 DDD 的更多背景知识,我们建议阅读 Eric Evans 的 Domain-Driven Design(领域驱动设计),该书籍首次引入了该术语。 另一项优秀的参考资源是 Vaughn Vernon 撰写的《实现领域驱动设计》。也可以看考我的[领域驱动设计专栏]

在 DDD 的战略阶段,我们要绘制业务领域的关系图,并定义领域模型的限界上下文。 在战术 DDD 阶段,需要更精确地定义领域模型。 战术模式在单个限界上下文中应用。 在微服务体系结构中,我们对实体和聚合模式特别感兴趣。 应用这些模式有助于识别应用程序中服务的自然边界。 作为一般原则,微服务应该不小于聚合,且不大于限界上下文。 首先,让我们了解战术模式。 然后,我们对无人机快递应用程序中的“交货”限界上下文应用这些模式。

战术模式概述

本部分将介绍战术 DDD 模式的简要概述,如果你已熟悉 DDD,则可以跳过本部分。 Eric Evans 著作的第 5 – 6 章,以及 Vaughn Vernon 的 Implementing Domain-Driven Design(实现域驱动的设计)一书中更详细地介绍了这些模式。

域驱动设计中的战术模式图

实体。 实体是一直保持唯一标识的对象。 例如,在银行应用程序中,客户和帐户就是实体。

  • 实体在系统中有唯一的标识符,使用该标识符可以查找和检索该实体。 这并不意味着,该标识符始终直接向用户公开。 它可能是数据库中的 GUID 或主键。
  • 一个标识可以跨多个限界上下文,并可能保留到应用程序生命期结束之后。 例如,银行帐号或政府颁发的身份证号不会与特定应用程序的生存期相关联。
  • 实体的属性可随时变化。 例如,某人的姓名或地址可能有变化,但他(她)仍是同一个人。
  • 一个实体可以包含对其他实体的引用。

值对象。 值对象没有标识。 它只由其属性值定义。 值对象也是不可变的。 若要更新值对象,始终需要创建一个新实例来替换旧实例。 值对象可以包含用于封装领域逻辑的方法,但这些方法不应该给对象的状态产生负面影响。 值对象的典型示例包括颜色、日期时间和货币值。

聚合。 聚合定义一个或多个实体的一致性边界。 一个聚合只包含一个根实体。 可以使用根实体的标识符执行查找。 从根开始的引用可以找到聚合中的其他任何实体。

聚合的作用是为事务一致性建模。 现实世界中的事物具有复杂的关系。 客户创建订单,订单包含产品,产品有供应商,等等。 如果应用程序修改了多个相关对象,它如何保证一致性?

传统应用程序通常使用数据库事务来实施一致性。 但是,在分布式应用程序中,这种做法通常不可行。 单个业务事务可能跨越多个数据存储、长时间运行,或者涉及第三方服务。 最终由应用程序而不是数据层来实施域所需的一致性。 这就是要为聚合建模的目的。

ℹ️ 聚合可以包含单个实体且不包含子实体。 聚合的定义由事务边界确定。

领域服务和应用服务。 在 DDD 术语中,服务是实现某种逻辑且不保存任何状态的对象。 Evans 区分 域服务(用于封装域逻辑)和 应用程序服务(提供技术功能,如用户身份验证或发送短信)。 领域服务通常用于对跨多个实体的行为建模。

ℹ️ 软件开发中广泛使用了“服务”一词。 此处的定义不直接与微服务相关。

领域事件。 发生某种情况时,可以使用领域事件来通知系统的其他部件。 顾名思义,领域事件应该表示领域中发生的某些情况。 例如,“在表中插入了记录”不是领域事件。 “已取消投递”是领域事件。 领域事件与微服务体系结构密切相关。 由于微服务为分发式且不共享数据存储,领域事件可为微服务提供相互协调的途径。

文章 服务间通信 更详细地讨论了异步消息传送。

还有其他几种 DDD 模式未在此处列出,包括工厂、Repository和模块。 开发微服务时,这些模式可能十分有用;但是,在微服务之间设计边界时,它们作用不大。

从领域模型到微服务

微服务的适当大小是什么? 我们经常听到有人说,“不要太大,也不要太小”— 这句话绝对正确,但实际上没有太大意义。 但是,如果从一个精心设计的领域模型着手,则规划出微服务就容易得多。

在为限界上下文标识了一组实体、聚合和领域服务之后,我们可以从领域模型转到应用程序设计。 下面是一个用于从域模型派生微服务的方法。

  1. 从限定上下文开始。 通常,微服务中的功能不应跨多个限定上下文。 根据定义,限界上下文标记特定领域模型的边界。 如果你发现微服务混用了不同的领域模型,可能意味着需要重新进行领域分析以优化领域模型。

  2. 接下来,查看领域模型中的聚合。 聚合通常是微服务的适当候选项。 合理设计的聚合能够体现一个设计优良的微服务的许多特征,例如:

    • 聚合派生自业务要求,而不是数据访问或消息传递等技术因素。
    • 聚合应具有较高的功能内聚性。
    • 聚合是持久性的边界。
    • 聚合应为松散耦合。
  3. 域服务也是微服务的适当候选项。 域服务是跨多个聚合的无状态操作。 典型的示例是涉及多个微服务的工作流。 我们将在无人机快递应用程序中看到此示例。

  4. 最后,考虑非功能性要求。 分析团队规模、数据类型、技术、可伸缩性、可用性和安全性需求等因素。 这些因素可能导致需要进一步将微服务分解成两个或更多个较小服务,或者相反的,将多个微服务合并成一个。

在应用程序中标识微服务之后,请根据以下条件验证设计:

  • 每个服务承担单一责任
  • 服务之间不存在琐碎的调用。 如果将功能拆分成两个服务会导致它们过度琐碎,该症状的原因可能是这些功能属于同一个服务。
  • 每个服务足够小,独立工作的小团队即可构建它。
  • 两个或更多个服务的部署不应该存在相互依赖的关系。 应该始终可以在不重新部署其他任何服务的情况下部署某个服务。
  • 服务未紧密耦合,可独立演变。
  • 服务边界不会造成数据一致性或完整性方面的问题。 有时,必须通过将功能放入单个微服务来保持数据一致性。 话虽如此,但应该是否确实需要强一致性。 可通过某些策略来解决分布式系统中的最终一致性,分解服务的好处通常比管理最终一致性所存在的挑战更具效益

最重要的是,必须追求实用,并记住领域驱动的设计是一个迭代过程。 如果有疑问,可以从粗粒度的微服务入手。 相比跨多个现有的微服务进行功能重构,将现有的微服务拆分成较小服务更加容易。

实战案例:无人机快递

Fabrikam, Inc. 正在推出无人机快递服务。 该公司经营无人机群。 各商家注册该服务,用户可以请求无人机收取要投递的商品。 当用户安排取件时,后端系统会分配一架无人机,并将估计的投递时间告知用户。 在投递过程中,用户可以通过持续更新的 ETA(Estimated time of arrival,预计到达时间) 跟踪无人机的位置。

此方案涉及到一个相当复杂的领域。 部分业务难题包括安排无人机、跟踪包裹、管理用户帐户,以及存储和分析历史数据。 此外,Fabrikam 希望快速投放市场扩张业务,同时添加新功能。 该应用程序需要在云环境运行,并附带较高的服务级别目标 (SLO)。 此外,Fabrikam 预期系统的不同部件在数据存储和查询方面具有截然不同的要求。 所有这些考虑因素促使 Fabrikam 为无人机快递应用程序选择了微服务体系结构。

分析领域 Analyze Domain

借助 DDD 方法设计微服务,可以使每个服务都能符合业务功能要求。 此方法有助于避免组织边界或技术选择左右你的设计。

在编写任何代码之前,需要获取所创建的系统的鸟瞰图。 DDD 首先构建业务领域并创建 域模型。 领域模型是业务领域的抽象模型。 它可以提取和组织领域知识,并为开发人员和领域专家提供通用语言。

首先,映射所有业务功能及它们之间的连接。 这可能需要领域专家、软件架构师和其他利益干系人的互相协作。 无需使用任何特定的形式。 可以直接草绘或者在白板上绘制关系图。

在绘制关系图时,可以开始标识离散的子域。 哪些功能密切相关? 哪些功能是业务的核心?哪些功能提供辅助服务? 什么是依赖项关系图? 在此初始阶段,不需要考虑技术或实施细节。 也就是说,应该注意应用程序要在哪个位置与 CRM、付款处理系统或计费系统等外部系统集成。

完成一些初始域分析之后,Fabrikam 团队绘制了一份描绘无人机快递领域的草图。

无人机快递领域图

  • 送货 (Shipping)位于关系图的中心,因为它是业务的核心。 关系图中的其他任何元素都是为了支持此功能。
  • 无人机管理 (Drone Management)也是业务的核心。 与无人机管理密切相关的功能包括 无人机维修,以及使用 预测分析 来预测无人机何时需要检修和维护。
  • ETA 分析 提供取件和投递的估计时间。
  • 如果包裹无法完全由无人机投递,则应用程序可以通过 第三方运输 来安排替代的运输方式。
  • 无人机共享 是核心业务的可能扩展。 公司的无人机在某些时段可能容量过剩,在这种情况下,可以出租无人机,以避免闲置。 初始版本未包括此功能。
  • 视频监督 (Video surveillance)是公司以后可以拓展到的另一个领域。
  • 用户帐户开票 (Invoicing)和 呼叫中心 是支持核心业务的子域。

请注意,在此阶段,我们尚未做出有关实施或技术的任何决策。 某些子系统可能涉及到外部软件系统或第三方服务。 即便如此,应用程序也需要与这些系统和服务进行交互,因此,必须将它们包含在领域模型中。

ℹ️ 如果应用程序依赖于外部系统,则存在一种风险:外部系统的数据架构或 API 会渗入应用程序,最终暴露了体系结构设计。 不遵循最佳实践,并使用复杂数据架构或过时 API 的旧式系统尤其如此。 在这种情况下,必须在这些外部系统与应用程序之间妥善定义边界。 出于此目的,请考虑使用 扼杀者模式 或 防腐层模式 。

定义限界上下文 Define Bounded Context

领域模型将包含现实世界中事物的表示形式 — 用户、无人机、包裹等等。 但这并不意味着系统的每个部分都需要对相同的事物使用相同的表示形式。

例如,处理无人机维修和预测分析的子系统将需要表示无人机的许多物理特征,例如,其维护历史记录、里程、生产年份、型号、性能特征等。 但是,在安排投递时,我们并不需要关心这些方面。 计划子系统只需知道无人机是否可用,以及取件和交货的 ETA。

如果尝试为这两个子系统创建了单个模型,则会不必要地增大复杂性。 此外,模型会更难得到发展,因为任何更改都需要满足处理不同子系统的多个团队的要求。 因此,更好的做法通常是设计不同的模型,用于在两种不同的上下文中呈现相同的真实实体(在本例中为无人机)。 每个模型仅包含其特定上下文中相关的功能和属性。

这就是DDD限界上下文的 概念 发挥作用的地方。 限界上下文只是应用特定领域模型的领域中的边界。 在下图中,我们可以根据各种功能是否共享单个领域模型将功能分组。

image.png

限界上下文不一定相互独立。 在此图中,连接限界上下文的实线表示两个限界上下文交互的位置。 例如,“送货”依赖于“用户帐户”来获取有关客户的信息,并依赖于“无人机管理”来安排机群中的无人机。

在 Domain Driven Design(领域驱动设计)一书中,Eric Evans 描述了当某个领域模型与另一个限界上下文交互时,保持该模型完整性的多种模式。 微服务的主要原则之一是服务通过完善定义的 API 进行通信。 此方法对应于两种模式,即 Evans 所说的“开放主机服务(Open Host Service)”和“发布语言(Published Language)”。 “开放主机服务”的思路是子系统针对与它通信的其他子系统定义一个正式协议 (API)。 “发布语言”扩展了这种思路,以特定格式发布 API,其他团队可以直接用来编写客户端。 比如使用OpenAPI规范 ((以前称为 Swagger) )为 REST API 定义与语言无关的接口说明(以 JSON 或 YAML 格式表示)。

以下部分侧重于“送货”限界上下文。

定义实体、聚合及服务 Define Entities, Aggregates & Services

限界上下文图

首先,我们探讨“送货”限界上下文必须处理的场景。

  • 某个客户可以请求派遣无人机,到已在系统中注册的公司取件。
  • 寄件人生成了一个标记(条形码或 RFID)并粘贴在包裹上。
  • 无人机将会收取包裹,然后将包裹从起始位置投递到目标位置。
  • 当客户安排投递时,系统将会根据路线信息、天气情况和历史数据提供 ETA。
  • 当无人机起飞时,用户可以跟踪当前位置和最新的 ETA。
  • 在无人机收取包裹之前,客户可以取消投递。
  • 完成投递时,客户将收到通知。
  • 寄件人可以请求客户提供签名或指纹形式的收货确认信息。
  • 用户可以查找已完成投递的历史记录。

在这些场景中,开发团队确定了以下 实体

  • 投递
  • 包裹
  • 无人机
  • 帐户
  • 确认
  • 通知
  • 标记

前四个项(“投递”、“包裹”、“无人机”和“帐户”)都是表示事务一致性边界的 聚合。 “确认”和“通知”是“投递”的子实体,“标记”是“包裹”的子实体。

此设计中的 值对象 包括 LOCATION、ETA、PackageWeight 和 PackageSize。

为便于演示,下面提供了“投递”聚合的 UML 关系图。 请注意,该聚合包含对其他聚合(包括“帐户”、“包裹”和“无人机”)的引用。

“投递”聚合的 UML 关系图

有两个领域事件:

  • 当无人机起飞时,“无人机”实体将发送 DroneStatus 事件,用于描述无人机的位置和状态(飞行中、已着陆)。
  • 每当投递阶段发生变化时,“投递”实体将发送 DeliveryTracking 事件。 这些事件包括 DeliveryCreated、DeliveryRescheduled、DeliveryHeadedToDropoff 和 DeliveryCompleted。

请注意,这些事件描述领域模型中有意义的事物。 它们描述有关域的某些信息,但不与特定的编程语言构造相关。

开发团队还确定了另一个功能领域,但该功能领域并不与前面所述的任何实体紧密相关。 系统的某个部分必须协调有关安排或更新投递的所有步骤。 因此,开发团队在设计中添加了两个 领域服务 :协调步骤的 计划程序 ,以及监视每个步骤的状态的 监督程序 ,以便检测是否有任何步骤失败或超时。这是 Scheduler Agent Supervisor模式的一个变体。修改后的域模型的关系图如下:

修改后的域模型的关系图

识别微服务 Identify Microservices

前面已标识四个聚合(“投递”、“包裹”、“无人机”和“帐户”)和两个领域服务(“计划程序”和“监督程序”)。

投递”和“包裹”是微服务的优先候选项。 “计划程序”和“监督程序”协调其他微服务执行的活动,因此,将这些领域服务作为微服务比较有利。

无人机”和“帐户”比较特别,它们属于其他限界上下文。 一种做法是让“计划程序”直接调用“无人机”和“帐户”限界上下文。 另一种做法是在“送货”限界上下文中创建“无人机”和“帐户”微服务。 这些微服务通过公开更适合“送货”上下文的 API 或数据架构,在限界上下文之间充当中介。

“无人机”和“帐户”限界上下文的详细信息超出了本文的范畴,因此我们在参考实现中创建了它们的模拟服务。 但在此情况下,需考虑一些因素:

  • 直接调入其他限界上下文会产生多大的网络开销?
  • 其他限界上下文的数据架构是否适用于此上下文,或者,专门针对此限界上下文定制一个架构是否更好?
  • 其他限界上下文是否为旧式系统? 如果是,则可以创建一个充当防腐层的服务,用于在旧式系统与新式应用程序之间进行转换。
  • 团队结构是什么? 是否能够方便地与负责其他限界上下文的团队通信? 如果不是,创建一个充当两个上下文之间的中介的服务可能有助于降低跨团队通信所产生的成本。

到目前为止,我们尚未考虑任何非功能性要求。 考虑到应用程序的吞吐量要求,开发团队决定创建一个负责接入客户端请求的独立“接入”微服务。 此微服务将传入的请求放入缓冲区进行处理,以此实施负载调节。 计划程序将从缓冲区读取请求,并执行工作流。

非功能性要求使得团队必须额外创建一个服务。 到目前为止,所有服务都与包裹的实时安排和投递过程相关。 但是,系统还需要在长期存储中存储每项投递的历史记录,以进行数据分析。 团队认为这是投递服务的责任。 但是,历史分析与现行操作的数据存储要求有较大的差别。 因此,团队决定创建一个独立的投递历史记录服务,用于侦听来自投递服务的 DeliveryTracking 事件,并将这些事件写入长期存储。

到这里微服务的划分基本完成,下图展示了最终的微服务设计:

设计图

参考