构建领域驱动的微服务

2020-07-07 13:26:59

微服务中的术语“微”虽然表示服务的大小,但并不是使应用程序成为微服务的唯一标准。当团队迁移到基于微服务的架构时,他们的目标是提高其敏捷性--自主且频繁地部署特性。很难给这种架构风格下一个简明的定义。我喜欢Adrian Cockcroft的这个简短定义-“面向服务的体系结构由具有受限上下文的松散耦合元素组成。”

虽然这定义了高级设计启发式,但微服务体系结构具有一些独特的特征,这些特征使其有别于过去的面向服务的体系结构。下面是这些特征中的几个。这些和其他几个都有很好的文档记录-马丁·福勒的文章和萨姆·纽曼的建筑微服务,仅举几个例子。

服务在其边界之外不共享其内部结构。例如,不共享数据库。

团队拥抱自动化文化。例如,自动化测试、持续集成和持续交付。

松散耦合的面向服务的体系结构,其中每个服务都包含在定义良好的有界上下文中,从而实现快速、频繁和可靠的应用程序交付。

微服务的力量来自于明确它们的职责和界定它们之间的边界。这里的目的是在边界内建立高内聚力,在边界外建立低耦合。也就是说,倾向于一起变化的东西应该属于一起。就像在许多现实生活中的问题一样,这说起来容易做起来难-企业在发展,假设也在改变。因此,在设计系统时,重构能力是另一个需要考虑的关键问题。

域驱动设计(DDD)是一个关键,在我们看来,它是设计微服务时的一个必要工具,无论它是打破一个整体还是实现一个新的项目。域驱动设计由Eric Evans在他的书中成名,它是一组思想、原则和模式,帮助基于业务域的底层模型设计软件系统。开发人员和领域专家一起工作,以一种无处不在的公共语言创建业务模型。然后,他们将这些模型绑定到有意义的系统,在这些系统和处理这些服务的团队之间建立协作协议。更重要的是,他们设计了系统之间的概念轮廓或边界。

微服务设计从这些概念中获得灵感,因为所有这些原则都有助于构建可以彼此独立更改和发展的模块化系统。

在我们继续之前,让我们快速浏览一下DDD的一些基本术语。领域驱动设计的完整概述超出了本博客的范围。我们向任何试图构建微服务的人强烈推荐Eric Evans的书。

域:表示组织所做的工作。在下面的示例中,它将是零售业或电子商务。

子域:组织或组织内的业务单位。一个域由多个子域组成。

无处不在的语言:这是用来表达模型的语言。在下面的示例中,Item是属于这些子域中的每个子域的无处不在语言的模型。开发人员、产品经理、领域专家和业务干系人同意使用相同的语言,并在他们的工件--代码、产品文档等中使用该语言。

有界上下文:域驱动的设计将有界上下文定义为“确定其含义的单词或语句出现的设置”。简而言之,这意味着模型有意义的边界。在上面的示例中,“Item”在每个上下文中都具有不同的含义。在Catalog上下文中,Item指的是可销售的产品,而在Cart上下文中,它指的是客户添加到购物车中的项目。在履行上下文中,它表示将发货给客户的仓库项目。这些模型中的每一个都是不同的,并且每个模型都有不同的含义,并且可能包含不同的属性。通过在它们各自的边界内分离和隔离这些模型,我们可以自由地、没有歧义地表达这些模型。

注意:理解子域和有界上下文之间的区别是很重要的。子域属于问题空间,即您的业务如何看待问题,而有界上下文属于解决方案空间,即我们将如何实现问题的解决方案。从理论上讲,每个子域可以有多个有界上下文,尽管我们争取每个子域有一个有界上下文。

现在,微服务在哪里适合呢?可以说每个受限上下文都映射到一个微服务吗?是也不是。我们将拭目以待。可能会出现有界上下文的边界或轮廓相当大的情况。

请考虑上面的示例。限定定价上下文有三个不同的模型-价格、定价项目和折扣,每个模型分别负责目录项目的价格、计算项目列表的总价和应用折扣。我们可以创建一个包含上述所有模型的单一系统,但它可能会变成一个不合理的大型应用程序。如前所述,每个数据模型都有其不变量和业务规则。随着时间的推移,如果我们不小心,这个系统可能会变成一个边界模糊、职责重叠的大泥球,很可能会回到我们开始的地方-一个巨石。

对此系统建模的另一种方式是将相关模型分离或分组到单独的微服务中。在DDD中,这些模型-价格、定价项目和折扣-被称为Aggregate。聚合是由相关模型组成的自包含模型。您只能通过发布的接口更改聚合的状态,聚合可确保一致性,并且不变量保持良好。

从形式上讲,聚合是被视为数据更改单元的关联对象的群集。外部引用仅限于指定为根的聚合中的一个成员。在聚合的边界内应用一组一致性规则。

同样,没有必要将每个聚合建模为不同的微服务。事实证明,图3中的服务(聚合)是这样的,但这不一定是规则。在某些情况下,在单个服务中托管多个聚合可能是有意义的,特别是当我们不完全了解业务领域时。需要注意的重要一点是,一致性只能在单个聚合中得到保证,并且聚合只能通过发布的接口进行修改。任何违反这些规定的行为都有可能变成一个大泥球。

您的武器库中的另一个基本工具包是上下文映射的概念-同样来自领域驱动设计(Domain Driven Design)。一个整体通常由不同的模型组成,大多是紧密耦合的-模型之间可能知道彼此的亲密细节,改变一个可能会对另一个造成副作用,以此类推。当您拆分这些整体时,确定这些模型(在本例中为聚合)及其关系至关重要。上下文地图正是帮助我们做到这一点的。它们用于标识和定义各种绑定上下文和聚合之间的关系。在上面的示例中,有界上下文定义了模型的边界-价格、折扣等,而上下文映射定义了这些模型之间以及不同上下文之间的关系。在确定这些依赖关系之后,我们可以确定将实现这些服务的团队之间的正确协作模型。

对上下文地图的全面探索超出了本博客的范围,但我们将用一个示例来说明它。下图表示处理电子商务订单付款的各种应用程序。

购物车环境负责订单的在线授权;订单环境处理执行后的支付流程,如结算;联系中心处理任何异常,如重试支付和更改订单使用的支付方法。

为简单起见,我们假设所有这些上下文都作为单独的服务实现。

请注意,这些模型在逻辑上是相同的。也就是说,它们都遵循相同的无处不在的领域语言-支付方法、授权和结算。只是它们是不同背景下的一部分。

同一模型分布在不同环境中的另一个迹象是,所有这些都直接与单个支付网关集成,并且彼此执行相同的操作。

上面的设计中有几个非常明显的问题(图4)。付款汇总是多个上下文的一部分。不可能在各种服务之间强制执行不变量和一致性,更不用说这些服务之间的并发问题了。例如,如果联系中心更改了与订单相关联的付款方式,而订单服务正在尝试发布先前提交的付款方式的结算,会发生什么情况。此外,请注意,支付网关中的任何更改都将强制更改多个服务和潜在的众多团队,因为不同的组可能拥有这些上下文。

通过一些调整并将聚合与正确的上下文对齐,我们可以更好地表示这些子域-图5。有很多变化。让我们回顾一下这些更改:

Payments Aggregate推出了一项新的购房付款服务。此服务还将支付网关从需要支付服务的其他服务中抽象出来。由于单个有界上下文现在拥有一个聚合,因此不变量易于管理;所有事务都发生在相同的服务边界内,有助于避免任何并发问题。

Payments Aggregate使用反腐败层(ACL)将核心域模型与支付网关的数据模型隔离,该数据模型通常是第三方提供商,可能会发生变化。在以后的文章中,我们将更深入地研究诸如使用端口和适配器模式的服务的应用程序设计。ACL层通常包含将支付网关的数据模型转换为支付聚合数据模型的适配器。

购物车服务通过直接API调用来调用支付服务,因为购物车服务可能需要在客户在网站上时完成支付授权。

记录订单和支付服务之间的交互作用。Orders服务发出一个域事件(在本博客后面将详细介绍)。支付服务监听此事件并完成订单结算。

联系中心服务可能有许多聚合,但我们只对此用例的订单聚合感兴趣。此服务在支付方法更改时发出事件,支付服务通过撤销以前使用的信用卡并处理新信用卡来对此作出反应。

通常,单个或遗留应用程序有许多聚合,通常具有重叠的边界。创建这些聚合及其依赖项的上下文地图有助于我们理解任何新的微服务的轮廓,我们将从这些巨石中获取这些服务。请记住,微服务体系结构的成败取决于聚合之间的低耦合性和这些聚合内的高内聚性。

同样重要的是要注意,有界上下文本身就是合适的内聚单元。即使上下文具有多个聚合,整个上下文及其聚合也可以组合成单个微服务。我们发现这个启发式对于不太清楚的领域特别有用-考虑一下组织正在冒险进入的新业务线。您可能对正确的分离边界没有足够的洞察力,任何过早的聚合分解都可能导致代价高昂的重构。想象一下,在进行数据迁移的同时,必须将两个数据库合并为一个数据库,因为我们碰巧发现两个聚合属于一起。但是要确保这些聚合通过接口充分隔离,这样它们就不会知道彼此的复杂细节。

事件风暴是识别系统中的聚合(以及微服务)的另一项基本技术。它是一个有用的工具,无论是在打破单一的基础上,还是在设计复杂的微服务生态系统时都是如此。我们已经使用此技术分解了我们的一个复杂应用程序,我们打算在单独的博客中介绍我们在事件风暴方面的经验。对于本博客的范围,我们想给出一个简要的概述。如果你有兴趣进一步探索,请观看阿尔贝托·布兰德罗尼(Alberto Brandelloni)关于这个主题的视频。

简而言之,事件风暴是开发应用程序(在我们的例子中是一个整体)的团队之间的集思广益练习,目的是识别系统中发生的各种域事件和流程。团队还确定这些事件影响的聚合或模型及其任何后续影响。在团队进行此练习时,他们识别不同的重叠概念、模棱两可的领域语言和相互冲突的业务流程。它们对相关模型进行分组、重新定义聚合并识别重复的流程。随着本练习的进行,这些聚合所属的有界上下文变得清晰起来。如果所有团队都在一个房间里(物理的或虚拟的),并且开始在Scrum风格的白板上映射事件、命令和流程,那么事件风暴研讨会非常有用。在本练习结束时,通常的结果如下:

我们已经在下面的事件风暴研讨会结束时展示了一个样板。对于团队来说,就正确的聚合和受限上下文达成一致是一次很好的协作练习。除了是一次很棒的团队建设练习之外,本次会议结束后,团队对领域、无处不在的语言和精确的服务边界有着共同的理解。

简单地说,一个整体承载单个进程边界内的多个聚合。因此,在此边界内管理聚合的一致性是可能的。例如,如果客户下了订单,我们可以减少物品的库存,向客户发送电子邮件-所有这些都可以在一次交易中完成。所有的操作都会成功,否则所有的操作都会失败。但是,当我们打破这块巨石并将聚合的数据分散到不同的环境中时,我们将拥有数十甚至数百个微服务。迄今为止存在于单个整体边界内的进程现在分布在多个分布式系统中。在所有这些分布式系统中实现事务完整性和一致性是非常困难的,而且这是以系统的可用性为代价的。

微服务也是分布式系统。因此,CAP定理也适用于它们-“分布式系统只能提供三个期望特征中的两个:一致性、可用性和分区容错性(CAP中的‘C’、‘A’和‘P’)。”在现实系统中,分区容忍度是不可协商的-网络不可靠,虚拟机可能会崩溃,区域之间的延迟可能会变得更糟,等等。

因此,我们可以在可用性和一致性之间做出选择。现在,我们知道在任何现代应用程序中,牺牲可用性也不是一个好主意。

如果您试图跨多个分布式系统构建事务,您最终将再次陷入困境。只是这一次它将是最糟糕的一种,一块分散的巨石。如果任何一个系统变得不可用,整个过程就变得不可用,通常会导致令人沮丧的客户体验、失败的承诺等等。此外,对一项服务的更改通常可能会导致对另一项服务的更改,从而导致复杂且成本高昂的部署。因此,我们最好设计应用程序,让我们的用例能够容忍一点不一致,从而有利于可用性。对于上面的示例,我们可以使所有流程异步,从而最终保持一致。我们可以独立于其他流程异步发送电子邮件;如果承诺的项目稍后在仓库中不可用,则可以延交该项目,或者我们可以停止接收超过某个阈值的项目订单。有时,您可能会遇到可能需要跨不同流程边界中的两个聚合的强ACID样式事务的场景。这是一个很好的迹象,可以重新审视这些聚合体,或许可以将它们合并为一个聚合体。在我们开始分解不同流程边界中的这些聚合之前,事件风暴和上下文映射将有助于在早期识别这些依赖关系。将两项微服务合并为一项服务代价高昂,这是我们应该努力避免的。

微服务可以发出发生在其聚合上的基本更改。这些事件称为域事件,任何对这些更改感兴趣的服务都可以侦听这些事件,并在其域内采取相应的操作。此方法避免了任何行为耦合-一个域不规定其他域应该做什么,而时间耦合-流程的成功完成并不依赖于所有系统同时可用。当然,这将意味着系统最终将是一致的。

在上面的示例中,Orders服务发布一个事件-Order Cancel。订阅了该事件的其他服务处理其各自的域功能:支付服务退款、库存服务调整物品的库存,等等。要确保此集成的可靠性和弹性,请注意以下事项:

制片人应该确保他们至少制作一次活动。如果这样做失败,他们应该确保存在一个后备机制来重新触发事件。

消费者应该确保他们以幂等方式消费事件。如果同样的事件再次发生,应该不会对消费者产生任何副作用。事件也可能无序到达。消费者可以使用时间戳或版本号字段来保证事件的唯一性。

由于某些用例的性质,可能并不总是可以使用基于事件的集成。请看一下购物车服务和支付服务的整合情况。这是一个同步集成,因此有几件事我们应该注意。这是行为耦合的一个示例-购物车服务可能从支付服务调用REST API并指示它授权订单付款,并且购物车服务需要提供临时耦合支付服务才能接受订单。这种耦合降低了这些上下文的自主性,可能还会降低不需要的依赖性。有几种方法可以避免这种耦合,但使用所有这些选项,我们将失去向客户提供即时反馈的能力。

将rest API转换为基于事件的集成。但是,如果支付服务仅公开REST API,则此选项可能不可用。

购物车服务即时接受订单,并且有一个批处理作业来提取订单并调用支付服务API

在上游依赖支付服务出现故障和不可用的情况下,将上述内容与重试相结合,可以产生更具弹性的设计。例如,购物车和支付服务之间的同步集成可以在失败的情况下通过事件重试或基于批的重试进行备份。这种方法对客户体验有额外的影响-客户可能输入了错误的付款详细信息,当我们离线处理付款时,他们将不会在线。或者可能会给企业带来额外的成本来收回失败的付款。但很有可能,购物车服务对支付服务不可用或故障的弹性好处大于缺点。例如,如果我们不能合作,我们可以通知客户。

..