面试官:如何基于 DDD 构建微服务?
本文将讨论微服务与 DDD 涉及到的概念、策划和设计方法,并且尝试将一个单体应用拆分成多个基于 DDD 的微服务。
微服务的定义
微服务中的“微”虽然表示服务的规模,但它并不是使应用程序成为微服务的唯一标准。当团队转向基于微服务的架构时,他们的目标是提高敏捷性,即自主且频繁地部署功能。
因此,很难给微服务架构风格下一个简单的定义。我喜欢 Adrian Cockcroft 关于微服务的简短定义:“面向服务的架构由具有界限上下文、松散耦合的元素组成。”
尽管这定义了一种高级的设计启发式方法,但微服务架构具有的特性,使其有别于以往的面向服务架构。根据以往的文章,我们总结了微服务架构应具备的一些特征:
服务以业务上下文为中心定义了良好的边界,而不是以任意的技术抽象为中心;
隐藏实现细节,并通过意图接口暴露功能;
服务不会共享超出其边界的内部结构,例如不共享数据库;
服务具有故障快速恢复能力;
团队职能独立,能够自主发布变更;
团队拥护自动化文化,例如自动化测试、持续集成和持续交付。
简而言之,我们可以将这种架构风格总结如下:
松散耦合的面向服务的架构,其中每个服务都封装在定义良好的界限上下文中,支持应用程序快速、频繁且可靠的交付。
领域驱动设计和界限上下文
微服务的强大之处在于清晰地定义了它们的职责并划定了它们之间的边界。它的目的是在边界内建立高内聚,在边界外建立低耦合。也就是说,倾向于一起改变的事物应该放在一起。正如现实生活中的许多问题一样,但这说起来容易做起来难,业务在不断发展,设想也随之改变。因此,重构能力是设计系统时考虑的另一项关键问题。
在我们看来,领域驱动设计 (DDD) 是关键,它是设计微服务时必不可少的工具,无论是对单体应用进行拆分还是从头开始构建一个新项目。领域驱动设计因 Eric Evans 的著作而出名,它是一组思想、原则和模式,可以帮助我们基于业务领域的底层模型设计软件系统。开发人员和领域专家一起使用统一的通用语言创建业务模型。然后将这些模型绑定到有意义的系统上,在这些系统和处理这些服务的团队之间建立协作协议。更重要的是,它们设计了系统之间的概念轮廓或边界。
微服务设计从这些概念中汲取了灵感,因为所有这些原理都有助于构建可以独立变更和发展的模块化系统。
在继续深入之前,让我们快速浏览一下 DDD 的一些基本术语。对领域驱动设计的完整概述不在本文的讨论范围之内。
领域(Domain): 代表组织所做的工作。例如零售或电子商务。
子域(Subdomain): 组织或组织内的业务部门。一个领域由多个子域组成。
统一语言(Ubiquitous language):这是用于表达模型的语言。在下面的例子中(图 1),Item 是一个模型,它是每个子域的统一语言。开发人员、产品经理、领域专家和业务各涉众方都能就使用这种语言达成一致,并在他们的工件(代码、产品文档等)中使用该语言。
图 1:电子商务领域中的子域和界限上下文界限上下文(Bounded Contexts):领域驱动设计将界限上下文定义为“一个单词或语句出现时确定其含义的设置”。简而言之,这意味着模型在边界内是有含义的。在上面的例子中(图 1),“Item”在每个上下文中都有不同的含义。在 Catalog 上下文中,Item 表示可出售的产品,而在 Cart 上下文中,它表示客户已添加到购物车中的商品选项。在 Fulfillment 上下文中,它表示将要运送给客户的仓库物料。这些模型各不相同,每个模型都有不同的含义,并且可能包含不同的属性。通过将这些模型分离并将其隔离在各自的边界内,我们就可以自由地表达这些模型,而不会产生歧义。
注意: 必须理解子域和界限上下文之间的区别。子域属于问题空间,即我们的业务要如何看待问题,而界限上下文属于解决方案空间,即我们将如何实施问题的解决方案。理论上,每个子域可能有多个界限上下文,尽管我们努力每个子域只提供一个界限上下文。
微服务和界限上下文如何关联
现在,微服务适用于哪些地方?每个界限上下文都能映射到对应的微服务吗?不一定。我们来看看原因。在某些情况下,界限上下文的边界或轮廓可能会非常大。
图 2:界限上下文和微服务考虑上面的例子。定价界限上下文有三个不同的模型:价格(Price)、定价项(Priced items) 和折扣(Discounts),分别负责目录项的价格、计算列表项的总价以及各自使用的折扣。我们可以创建一个包含上述所有模型的单一系统,但它可能是一个不合理的大型应用程序。如前所述,每个数据模型都有其不变性和业务规则。随着时间的推移,如果我们不小心的话,这个系统就可能会变成一个大泥球,界限模糊,职责重叠,甚至很可能会回到我们开始的地方——单体应用。
对这个系统建模的另一种方法是将相关模型分离或分组到单独的微服务中。在 DDD 中,这些模型(价格、定价项和折扣)被称为聚合(Aggregates)。聚合是由相关模型组成的自包含模型。我们只能通过已发布的接口来变更聚合的状态,并且聚合可以确保一致性,而且不变量可以始终保持良好状态。
在形式上,聚合是关联对象的集群,被视为数据变更的单元。外部引用仅限于指定聚合的一个成员,即聚合根。在聚合的边界内需应用一组一致性规则。
图 3:定价上下文中的微服务同样,没有必要将每个聚合都建模为一个不同的微服务。事实证明,图 3 中的服务(聚合)就是如此,但这不一定是一个规则。在某些情况下,在单个服务中托管多个聚合可能是有意义的,特别是在我们不完全了解业务领域的情况下。需要注意的一点是,一致性只在单个聚合中才能得到保证,并且聚合只能通过已发布的接口进行修改。任何违反这些规则的行为都有增加应用程序变成一个大泥球的风险。
上下文映射:一种精确划分微服务边界的方法
另一个基本工具是上下文映射,同样,它也是来自领域驱动设计。一个单体应用通常由不同的模型组成,这些模型大多是紧密耦合的,模型之间可能知道彼此的实现细节,变更一个模型可能造成另一个模型的副作用等等。当你分解单体应用时,确定这些模型(在这里是聚合)及其关系是至关重要的。上下文映射可以帮助我们做到这一点。它们用于识别和定义各种界限上下文和聚合之间的关系。在上面的例子中,界限上下文定义了模型的边界(价格、折扣等等)。而上下文映射定义了这些模型之间以及不同上下文之间的关系。在确定了这些依赖关系之后,我们就可以确定下来实现这些服务的团队之间的正确协作模型了。
对上下文映射的完整探索不在本文的讨论范围之内,但我们将用一个示例来说明。下图显示了处理电子商务订单支付的各种应用程序。
购物车上下文负责订单的在线授权;订单上下文处理订单履行完成后的支付流程,如结算;联络中心处理任何异常情况,如支付重试和变更订单使用的支付方式。为了简单起见,我们假设所有这些上下文都是作为单独的服务实现的,所有这些上下文封装了同一个模型。请注意,这些模型在逻辑上是相同的。也就是说,它们都遵循相同的统一领域语言——支付方式、授权和结算。只是它们是不同上下文的一部分。
另一个迹象表明,同一个模型在不同的上下文中传播,所有这些模型都直接与单个支付网关相集成,并且彼此执行相同的操作。
图 4:定义错误的上下文映射重新定义服务边界:将聚合映射到正确的上下文
在上面的设计中有一些非常明显的问题(图 4)。支付聚合是多个上下文的一部分。在各种服务之间强制执行不变性和一致性是不可能的,更不用说这些服务之间的并发问题了。例如,如果在订单服务尝试按之前提交的付款方式进行结算的过程中,联络中心更改了与订单关联的付款方式会发生什么情况。另外,请注意,支付网关中的任何更改都将迫使对多个服务进行更改,可能会涉及到多个团队,因为它们共同拥有这些上下文。
通过一些调整并将聚合与正确的上下文对齐,我们就可以更好地表示这些子域了(图 5)。需要进行很多的更改。
我们来看下更改的点:
支付聚合有了一个新家——支付服务。该服务还从其他需要支付服务的服务中提取了支付网关。由于单个界限上下文现在拥有了单个聚合,所以不变量很容易管理;所有事务都在同一个服务的边界内进行,从而避免了任何并发问题。
支付聚合使用了反腐层(ACL)将核心领域模型与支付网关的数据模型隔离开来,后者通常是由第三方提供的,可能会发生变化。在以后的文章中,我们将深入研究基于“端口和适配器”模式的应用程序设计。ACL 层通常包含将支付网关的数据模型转换为支付聚合数据模型的适配器。
购物车服务通过直接调用 API 的方式来调用支付服务,因为当客户在网站上购物时,购物车服务需要完成支付授权。
记录订单和支付服务之间的交互作用。订单服务发出一个域事件(稍后会在本文中对此进行详细介绍)。支付服务监听此事件并完成订单的结算
联络中心服务可能有许多聚合,但我们只对该用例中的订单聚合感兴趣。当更改付款方式时,此服务发出一个事件,支付服务将通过以下方式对此事件做出响应:将先前使用的信用卡撤销,再处理新的信用卡。
图 5:重新定义的上下文映射通常,单体或遗留应用程序有许多聚合,且边界重叠。创建这些聚合及其依赖关系的上下文映射,将有助于我们理解从这些单体应用中获取任何新微服务的轮廓。请记住,微服务架构的成败取决于聚合之间的低耦合以及聚合之内的高内聚。
还需要注意的是,界限上下文本身就是合适的内聚单元。即使上下文有多个聚合,也可以将整个上下文及其聚合组成单个微服务。我们发现这种启发式方法对于有些模糊的领域特别有用,比如组织正在涉足的新业务领域。我们可能对分离的正确边界没有足够的了解,并且任何过早的聚合分解都可能导致昂贵的重构。试想一下,由于数据迁移,不得不将两个数据库合并为一个,因为我们偶然发现两个聚合属于同一类。但是要确保这些聚合通过接口是充分隔离的,这样它们就不知道彼此的复杂细节了。
事件风暴:另一种识别服务边界的技术
事件风暴(Event Storming)是识别系统中聚合(以及微服务)的另一种必不可少的技术。它是一个非常有用的工具,既可用于分解单体应用,也可用于设计复杂的微服务生态系统。我们已经使用这种技术分解了一个复杂的应用程序,并打算写一篇单独的文章来介绍我们的事件风暴经验。在本文中,我们只给出一个快速的高层概述。
简而言之,事件风暴是在应用程序团队(这里,指单体)中进行的头脑风暴,以识别系统中发生的各种领域事件和流程。团队还需确定这些事件影响的总和或模型,以及由此产生的任何后续影响。当团队做这个头脑风暴时,他们将识别不同的重叠概念、模棱两可的领域语言和相互冲突的业务流程。他们对相关的模型进行分组,重新定义聚合并识别重复的流程。随着这些工作的进行,这些聚合所属的界限上下文变得清晰起来。如果所有团队都在同一个房间(物理或虚拟)里,并开始在 Scrum 风格的白板上绘制事件、命令和流程的映射,那么事件风暴研讨就会非常有用。在本练习结束时,通常会产出如下成果:
重新定义的聚合列表。这些可能会成为新的微服务
需要在这些微服务之间流动的领域事件
其他应用程序或用户直接调用的命令
下面是我们在一次事件风暴研讨会结束时产生的一个示例样板。对于团队来说,就正确的聚合和界限上下文达成一致是一次很棒的协作活动。此外,团队在本次会议结束时还对领域、统一语言和精确的服务边界有着共同的理解。
图 6:事件风暴板微服务之间的通信
快速回顾一下,一个单体应用在单个流程边界内拥有多个聚合。因此,可以在这个边界内管理各个聚合的一致性。例如,如果客户下了订单,我们可以减少商品库存,并向客户发送电子邮件,所有这些都在一个事务中完成。所有操作要么都成功,要么都会失败。但是,当我们分解了单体并将聚合分散到不同的上下文中时,我们将拥有数十个甚至数百个微服务。但目前为止,存在于单体应用单一边界内的流程,现在被分散到了多个分布式系统中。要在所有这些分布式系统中实现事务的完整性和一致性是非常困难的,而且要以系统的可用性为代价。
微服务也是分布式系统。因此,CAP 定理也适用于它们:“一个分布式系统只能提供三个所需特性中的两个:一致性、可用性和分区容错(CAP 中的‘C’——Consistency、‘A’——Availability 和 ‘P’——Partition Tolerance)。”在现实世界的系统中,分区容错是不可协商的——网络是不可靠的、虚拟机可以宕机、区域之间的延迟可能会变得更糟等等。
因此,我们可以选择“可用性”或“一致性”。现在,我们知道,在任何现代应用程序中,牺牲“可用性”也不是一个好主意。
图 7:CAP 定理围绕最终一致性设计应用程序
如果我们想要跨多个分布式系统构建事务,那么我们将再次陷入单体应用的困境。但这一次它会是最糟糕的一种单体:一个分散的单体应用。如果这些系统中的任何一个变得不可用,则整个流程不可用,通常会导致极差的客户体验、承诺的失败等等。此外,对一个服务的变更通常会导致另一个服务的变更,从而引起复杂和昂贵的部署。因此,我们最好根据自己的用例来设计应用程序,容忍稍微的不一致,以提高可用性。对于上面的例子,我们可以使所有流程异步,从而达到最终的一致性。我们可以独立于其他流程,异步发送电子邮件;如果已经承诺的商品以后在仓库中不可用,那么该商品可能需要补货,或者我们可以停止接受超出某个阈值的该商品的订单。
有时,我们可能会遇到这样的场景:可能需要跨越不同流程边界的两个聚合的强 ACID 式的事务。这是一个重新审视这些聚合并将它们组合成一个聚合的极好迹象。开始在不同流程边界中分解这些聚合之前,事件风暴和上下文映射将有助于我们及早识别这些依赖关系。将两个微服务合并为一个的成本很高,这是我们应该努力避免的。
支持事件驱动的架构
微服务可以将发生在其聚合上的基本更改发出来,这些称为领域事件(Domain Event),并且任何对这些更改感兴趣的服务都可以监听这些事件并在其领域内执行相应的操作。这种方法避免了任何行为上的耦合(一个领域无需规定其他领域应该做什么)以及时间上的耦合(一个流程的成功完成不依赖于所有系统同时可用)。当然,这将意味着系统最终是一致的。
事件驱动架构在上面的示例中,订单服务发布一个事件:订单已取消。订阅了该事件的其他服务处理各自的领域功能:支付服务退款,库存服务调整商品的库存,等等。为确保此集成的可靠性和弹性,需要注意以下几点:
生产者应确保至少发出了一次事件。如果过程中出现失败,则应确保存在回退机制以重新触发事件
消费者应该确保以幂等的方式消费事件。如果再次发生同一事件,不会对消费者产生任何副作用。事件也可能不是顺序到达的。消费者可以使用时间戳或版本号字段来保证事件的唯一性。
由于某些用例的特性,不一定总是可以使用基于事件的集成。请看一下购物车服务和支付服务之间的集成。这是一个同步集成,因此我们需要注意一些事项。这是一个行为耦合的例子——购物车服务可能会从支付服务调用一个 REST API,并指示它授权订单的支付,而时间耦合——支付服务需要对购物车服务可用,它才能接受订单。这种耦合降低了这些上下文的自治性,也可能会引入不必要的依赖。有几种方法可以避免这种耦合,但是如果使用了所有这些选项,我们将失去向客户提供即时反馈的能力。
将 REST API 转换为基于事件的集成。但是,如果支付服务仅公开 REST API,则此选项可能不可用
购物车服务立即接受订单,并且有一个批处理作业来接管订单并调用支付服务 API
购物车服务生成一个本地事件,然后调用支付服务 API
在出现失败和上游依赖项(支付服务)不可用的情况下,将上述方法与重试相结合,可以产生更具弹性的设计。例如,在发生故障的情况下,可以通过基于事件或批处理的重试来备份购物车和支付服务之间的同步集成。这种方法会对客户体验产生额外的影响:客户可能输入了不正确的支付详细信息,当我们离线处理支付时,无法强制他们在线。或者,收回失败的支付可能会增加企业的成本。但在所有可能的情况下,让购物车服务对支付服务的不可用性或故障具有弹性,利大于弊。例如,如果我们无法离线收款,我们可以通知客户。简而言之,在用户体验、弹性和运营成本之间存在着权衡,在设计系统时,明智的做法是充分考虑这些折衷。
避免为了满足调用者的特定数据需求而编排服务
存在于任何面向服务架构的一个反模式是:服务迎合调用者的特定访问模式。通常,当调用者团队与服务提供者团队紧密合作时,就会发生这种情况。如果团队正在开发一个单体应用程序,它们通常会创建一个跨越不同聚合边界的 API,从而使这些聚合紧密耦合在一起。我们来看一个例子。比如说 Web 中的订单详情页面,移动应用程序需要在单个页面上显示订单详情和订单退款处理详情。在一个单体应用程序中,订单获取 API(Order-GET-API,假设它是 REST API)需要同时查询订单和退款,合并两个聚合并向调用方发送一个复合响应。由于聚合属于同一流程边界,因此可以在没有太多开销的情况下实现这一点。调用者可以在一次会话中获得所需的所有数据。
如果订单和退款是不同上下文的一部分,那么数据不再出现在单个微服务或聚合边界内。为调用者保留相同功能的一个选项是,让订单服务负责调用退款服务并创建一个复合响应。这种方法会引起以下几个问题:
订单服务现在与另一个服务集成,纯粹是为了支持那些需要退款数据和订单数据的调用者。现在订单服务的自治性降低了,因为退款聚合的任何更改都会导致订单聚合的更改。
订单服务有另一个集成,因此需要考虑另一个故障点:如果退款服务出现故障,订单服务是否仍可以发送部分数据,并且调用者是否可以优雅地处理故障呢?
如果调用者需要变更,以从退款聚合中获取更多的数据,那么现在需要两个团队同时进行变更
如果跨平台都遵循这种模式,则可能会导致各种域服务之间形成复杂的依赖关系网,这都是因为这些服务迎合了调用者特定的访问模式。
专门服务于前端的后端(BFFs)
一种减轻这种风险的方法是让调用者团队管理各种域服务之间的编排。毕竟,调用方更了解访问模式,并且可以完全控制对这些模式的任何更改。这种方法将域服务从表示层解耦出来,让它们专注于核心业务流程。但是,如果 Web 和移动应用程序开始直接调用不同的服务,而不是从单体中调用一个复合 API,这可能会给这些应用程序带来性能开销——在较低带宽的网络上进行多次调用,处理和合并来自不同 API 的数据,等等。
相反,可以使用另一种称为用于前端的后端模式(Backend for Front-ends)。在这种设计模式中,由消费者创建和管理的后端服务,在本例中是 Web 和移动团队,它负责对多个域服务进行集成,纯粹是为了向客户提供前端体验。Web 和移动团队现在可以根据它们所需要的用例来设计数据契约。它们甚至可以使用 GraphQL 而不是 REST API 来灵活地查询并获取所需的内容。需要注意的是,该服务是由消费者团队拥有和维护的,而不是提供域服务的团队。前端团队现在可以根据它们的需求进行优化——移动应用程序可以请求更小的有效负载,减少来自移动应用程序的会话次数,等等。下面是修订后的业务流程图。BFF 服务现在为其用例调用“订单”和“退款”域服务。
用于前端的后端尽早构建 BFF 服务也很有用,这样可以避免从单体系统中分解出过多的服务。否则,要么域服务必须支持域间编排,要么 Web 和移动应用程序必须直接从前端调用多个服务。这两个选项都会导致性能开销、一次性工作以及团队之间缺乏自治。