构建领域驱动的微服务

构建领域驱动的微服务

加速架构学习!

译自:Building Domain Driven Microservices

微服务的定义

微服务中的术语"微"传达了一个服务的大小,但这不是将一个应用变为微服务的唯一准则。当团队转变到基于微服务的架构时,需要提高敏捷性(自动部署和频繁发布)。很难对微服务架构的风格做一个准确的定义。我倾向于Adrian Cockcroft 的定义:"由松耦合且具有边界上下文的元素构成的面向服务的架构"。

虽然它给出了一个启发式的高层设计,但微服务架构具有一些独特的特征,使其有别于以往的面向服务的架构。下面展示了部分特性,更多可以参见Martin Fowler的文章Sam Newman的构建微服务

  1. 服务具有围绕业务上下文(而不是围绕对任意技术的抽象)定义的边界。
  2. 隐藏实现细节,并通过带目的性的接口暴露接口。
  3. 服务不会在边界外暴露内部结构,例如,不会共享数据库。
  4. 服务具有容错能力。
  5. 各个团队的功能是独立的,且可以独立发布。
  6. 团队拥抱自动化文化。例如,自动化测试,持续集成和持续发布。

我们对这种架构风格概括如下:

松耦合的,面向服务的架构,每个服务都有明确的边界上下文,可以快速、频繁、可靠地发布应用。

领域驱动设计和边界上下文

微服务的能力在于可以明确地定义各个服务的职责,并划清服务之间的界限。目的是实现边界内的高内聚以及边界外的低耦合。即,把倾向于共同变动的服务归为一类。但就像生活中的很多问题一样,说起来容易,做起来难(涉及到业务以及各种可能的变化)。因此在设计系统时还应该考虑到重构能力。

领域驱动设计(DDD)是设计微服务的关键,它可以帮助拆分一体式架构,或构建一个全新的项目。领域驱动设计是Eric Evans 在他的中提出的一些列理念、原则和模式,用于帮助设计基于底层业务领域的软件系统。开发者和领域专家使用一种通用的语言来共同构建业务模型,然后将其绑定到到有意义的系统上,并在这些系统与从事这些服务的团队之间建立协作协议。更重要的是,他们设计了系统之间的轮廓或边界。

微服务从这些概念中汲取了灵感,所有这些概念都可以帮助构建支持独立变更和演化的模块化的系统。

在继续后面的内容之前,先快速回顾一下基本的DDD术语。对领域驱动设计的完整概述超出了本文的范畴。强烈推荐参考Eric Evans的来构建微服务 。

:表示一个组织,在下面的例子中为零售(Retail)或电子商务(eCommerce)。

子域:一个组织中的组织或业务单元。一个域由多个子域构成。

通用语言:用于表示模型的语言。下例中,每个子域中的Item就是通用语言模型。且开发者,产品经理,领域专家以及业务利益相关者都认同该语言,并在交付件(代码,产品文档等)中使用该语言。

Fig 1. Sub-domains and Bounded Contexts in eCommerce domain

边界上下文

领域驱动设计中将边界上下文定义为"可以使用一个单词或一句话确定其内部含义的配置"。换句话说边界也是一个模型。在上面例子中,"Item"在不同的上下文中的意义不同。在Catalog 上下文中,一个Item意味着畅销产品;在Cart上下文中,意味着客户向购物车添加了商品;在Fulfillment 上下文中,意味着会把一个Warehouse商品送到客户手中。每个模型都是不同的,且每个模型都有不同的意义,可能包含不同的属性。通过对这些模型进行区分,并将它们隔离在各自的边界内,我们可以清晰地表达这些模型。

注意:理解子域和边界上下文非常重要。子域属于问题空间,即业务是如何看待问题的。而边界上下文用于解决空间问题,即如何实现方案来解决问题。理论上,每个子域可能会存在多个边界上下文,但我们尽量将子域中的边界上下文限制为一个。

微服务如何关联边界上下文

应该把微服务放到哪里?可以认为一个边界上下文对应一个微服务吗?可以,但也不可以。下面看看为什么会这样。在很多情况下,边界上下文的边或轮廓会非常大。

Fig 2. Bounded context and microservices

考虑上例。Pricing 边界上下文由三个不同的模型--Price,Priced items,和Discounts。分别负责一个商品的价格,一系列商品的总价以及应用折扣等。我们创建了一个单独的系统,并包含了上述所有模型,但也可能会成为一个不合理的大型应用程序 。如前面所述,每个数据模型都有其不变量和业务规则。随着时间的推移,如果我们不够谨慎,该系统可能会变成一个边界模糊的"大泥球",且职责重叠,有可能导致会退回到一开始的地方--一体式模型。

另一种对该系统建立模型的方式是对相关的模型进行分离或分组,成为独立的微服务。在DDD中,这些模型(Price, Priced Items, 和Discounts)称为聚合。聚合是一个由相关模型组成的自包含的模型。可以通过一个公开的接口修改某个聚合的状态,聚合会保证一致性以及不变量的执行。

通常,一个聚合是指:一个集群中包含关联关系的对象,将这些对象视为数据变更的单元。外部对聚合的引用被限制到聚合中的某个成员,该成员称为根。聚合边界会使用一致性规则。

Fig 3. Microservices in the Pricing Context

没有必要为每个聚合建立一个独立的微服务模型。图3为我们最终的服务(聚合)模型(但不是唯一的模型)。某些情况下,一个单独的服务可能会托管多个聚合,特别是在我们没有完全理解业务领域之前。需要注意的是,只能在单个聚合中保证一致性,且只能通过已发布的界面修改聚合。任何违背该准则的行为都有可能导致架构变为一个"大泥球"。

上下文映射-精确确定微服务边界的一种方法

另一个重要的概念是上下文映射(仍然来自领域驱动设计)。一体式架构通常由不同的模型构成,且大多数是强耦合的(一个模型能够清楚了解到另一个模型的细节,且对任一个模型的修改都可能会影响到另一个,等等)。在分解一体式架构之后,确定这些模型(在这种情况下为确认聚合)及其关系至关重要。上下文映射可以帮助我们确认和定义各种边界上下文和聚合之间的关系。边界上下文定义了一个模型的边界--Price,Discounts等。在上面例子中,上下文映射定义了这些模型之间的以及不同上下文之间的关系。在确认这些依赖之后,我们可以确定实现这些服务的团队之间的协作关系。

对上下文映射的完整描述超出了本文范围。下图是一个上下文映射的例子,展示了各种用于处理支付电子商务订单的应用。

  1. 购物车上下文(cart context)处理订单的在线授权;订单上下文(Order context)处理付款完成后的付款流程,如清算;客服中心(Context center)用于处理类型订单的重复付款和修改付款方式等异常。
  2. 为了简化,我们假设这些上下文都是用独立的服务实现。
  3. 所有上下文都封装了相同的模型。
  4. 注意这些模型逻辑上都是相同的,即它们都遵循通用领域语言(Ubiquitous domain language)---支付方法,授权和清算。它们只是不同上下文的一部分。

相同模型分布在不同上下文中的另一个迹象是,所有这些模型都直接集成到了相同的支付网关,且彼此之间执行相同的操作。

Fig 4. An incorrectly defined context map

重定义服务边界--将聚合映射到正确的上下文

图4中有一些非常明显的问题。Payments聚合作为多个上下文的一部分。跨多个服务保证不变量和一致性非常重要,更不用说这些服务之间的并行问题。例如,如果在订单服务(Orders service)在尝试通过以提交的付款方式进行清算时,客服中心(contact center)更改了与订这些单关联的付款方式,会发生什么情况。同时应该注意,付款网关(payment gateway)的任意变动都会影响到多个服务,有可能会影响到多个团队或拥有这些上下文的组。

经过一些调整并使聚合与正确的上下文保持一致,我们获得了对这些子域更加准确的表达方式,如图5,进行了很多变更,现在看下涉及的改动:

  1. Payments聚合成为了一个单独的服务--Payment服务。该服务从其他需要付款功能的服务中抽象了Payment网关。现在一个边界上下文仅包含一个聚合,很容易就可以对这些不变量进行管理,同时也避免了相同服务边界内的事务的一致性问题。
  2. Payments聚合使用一个防护层(ACL)来隔离核心领域模型与付款网关的数据模型,该数据模型通常是第三方提供的,且可能会发生变化。ACL层通常包含将付款网关的数据模型转换为Payments聚合的数据模型的适配器。
  3. Cart 服务会通过直接API调用方式来调用Payments服务,购物车服务可能需要完成付款授权。
  4. 注意Orders和Payments服务之间的交互,Orders服务会发送领域事件,Payments服务会监听该事件,并完成订单清算。
  5. Contact center服务可能会包含多个聚合,但此处我们只关心Orders聚合。该服务会发送付款方式变更的事件,且Payments服务会对此做出反应,撤消之前使用的信用卡,并处理新的信用卡。

image-20210303103510345

Fig 5. Redefined context map

通常一个一体式或传统应用程式会包含很多聚合,以及重叠的边界。为这些聚合和依赖创建一个上下文映射可以帮助我们理解到如何借助微服务的轮廓来使我们脱离一体式的泥沼。记住,微服务架构的成功和失败取决于这些聚合之间的低耦合,以及聚合内的高内聚。

同时应该注意到,边界上下文本身也是合理的高内聚单元。即使一个包含多个聚合的上下文,整个上下文和聚合都可以构成一个单独的微服务。我们发现这种启发式非常适用于比较模糊的领域--例如组织正在进入的新业务领域。这种情况下,你可能没有足够的洞察力来为业务划清界限,过早分解聚合可能会造成高昂的重构成本。假设我们偶然发现两个聚合可以放到一起,然后将两个数据库合二为一(涉及数据迁移)。在合并前需要保证使用接口对这些聚合进行了充分的隔离,这样我们就不需要各个聚合内的复杂的细节。

事件风暴-识别服务边界的另一种技术

事件风暴是另一种识别系统中聚合(以及微服务)的基本技术。它是一种很有用的工具,可以在设计复杂的微服务生态时对一体式架构进行分解。

简而言之,事件风暴是在应用程序上工作的团队之间的头脑风暴演习(在我们的场景中,用来确定一体式系统中的各种领域事件,以及如何对这些事件进行处理)。团队也需要确认这些事件影响的聚合或模型,以及后续影响。在团队进行该演习时,可能会提出重叠的概念,模糊的领域语言以及冲突的业务流程。然后按照模型分组,重新定义聚合,确认重复的流程。通过不断的演习,聚合所在的边界上下文会变得越来越清晰。事件风暴研讨非常有用,当所有团队在一起的时候(物理或虚拟的)时,可以在敏捷风格的白板上映射事件,命令和过程。在演习结束之后,通常会得到如下结果:

  1. 重定义聚合列表,这些列表可能会变成新的微服务。
  2. 领域事件需要经过这些微服务。
  3. 其他应用或用户可以直接调用的命令。

最后展示了一个事件风暴研讨的简单样板。这是一个很棒的协作演习,可以使团队对聚合和边界上下文达成共识。除做了一个很好的团建之外,团队也可以通过此次活动对领域、通用语言以及准确的服务边界有共同的理解。

Fig 6. Event Storming board

微服务间的通信

快速回顾一下,一体式主机会在一个处理边界内承载多个聚合。因此能够在该边界内保证聚合的一致性。例如,如果一个客户发起一个订单,我们可以减少库存的商品数,然后发送邮件给客户,所有操作都在一个事务中完成。所有的操作最终会成功或失败。但在我们分解一体式,并将聚合打散到多个不同的上下文后,我们将拥有几十甚至上百个微服务。现在存在于一体式的单个边界内的流程被延申到了多个分布式系统上。要跨这些分布式系统达到事务的完整性和一致性是非常困难的,而且要付出一定的代价--系统的可用性。

微服务同时也是分布式系统,因此也可以运用CAP理论---"一个分布式系统只能兑现三个特性中的两个:一致性,可用性和分区容错性"。在真实系统中,分区容错性是必须要保证的,如网络不可达,虚拟机宕机,地区间的延迟变大等等。

因此,我们只能从可用性和一致性中二选一。现在我们知道,在现代应用中,牺牲可用性并不是一个好主意。

image-20210303101603990

Fig 7. CAP Theorem

围绕事件一致性设计应用

如果尝试构建跨多个分布式系统的事务,则最终会有可能会导致分布一体式。如果任意一个系统不可用,则整个处理将不可用,通常会导致糟糕的客户体验,承诺失信等等。此外对一个服务的变更可能会涉及另一个服务,导致复杂而昂贵的部署。因此我们最好根据特定的场景来设计应用,为了可用性而牺牲一部分一致性。如上例中,我们异步处理所有的过程,从而最终保持一致。如果后续仓库中的某个商品不可用,则可能需要重新订购,或在达到一定阈值后停止接收订单。

有时候,我们可能会需要在事务跨两个不同处理边界中的聚合时,保证强ACID。这是重新审视这些聚合数据并将其合并为一体的绝佳标志。事件风暴和上下文映射可以帮助我们在不同的处理边界中分解聚合前识别到依赖性。合并两个微服务是有代价的,但有时候又难以避免。

支持事件驱动架构

当微服务的聚合发生变化时可以发送基本的变更信息,称为领域事件。任何对这些事件感兴趣的服务都可以监听这些事件,并在其所在的领域中做出相应的动作。这种方式可以避免耦合行为(一个域没有规定其他域应该做什么)以及临时耦合(对一个流程的成功处理并不会在同一时间依赖所有的系统)。当然,这也意味着系统最终会保持一致。

Fig 8. Event driven architecture

在上例中,订单服务提交了一个事件--订单取消。订阅该事件的其他服务会执行相应的事件功能:付款服务会退还款项,库存服务会调整商品的库存等等。为了保证整体的可靠性和弹性,需要注意:

  1. 生产者应该保证至少产生一次事件。如果不能(因为某种失败),则应该保证提供回退机制来重新出发事件。
  2. 消费者应该保证以幂等的方式消费事件。如果再次出现了相同的事件,则不能对消费者产生任何影响。事件有可能需要保证到达的顺序,消费者可以使用时间戳或版本字段来保证事件的唯一性。

在一些使用场景下,有可能不能使用基于事件的集成方式。看下购物车服务和付款服务,它们是同步集成的,因此需要注意到这一点。这是一个行为(购物车服务可能会调用到付款服务的REST API,然后以此完成对一个订单的付款授权)和时间耦合(在购物车服务接收订单时,付款服务必须是可用的)的例子。这种耦合降低了这些上下文的自治能力,可能会产生不良依赖。有一些方式可以避免这种耦合,但这些方式也会使我们无法向客户提供即时反馈。

  1. 将REST API转换为基于事件的集成方式。但如果付款服务仅暴露了一个REST API,那么这种方式是不可行的。
  2. 购物车服务立即接收订单,并使用一个批处理任务获取订单,并调用付款服务的API。
  3. 购物车服务产生一个本地事件,然后调用服务服务的API。

为了防止依赖的上游(付款服务)的失败和不可用,上述方式可以加入重试机制。例如,在购物车和付款同步集成出现失败的场景下,可以使用事件或批量重试作为备选方式。这种方式会对客户体验产生一些额外的影响,如客户可能输入错误的付款信息,而当我们处理这些离线付款时,这些信息可能并不在线;或业务可能会增加一些代价来回收失败的付款。 但有可能,购物车服务因此增加了应对支付服务不可用性或故障情况下的弹性,其利大于弊。例如,当无法采集离线的付款时,我们可以通知到客户。简而言之,在设计系统时,需要在用户体验,弹性和运维成本权衡。

避免针对特定消费者的数据需求而在服务之间进行编排

很多面向服务的架构中的一个反例是:服务是为了迎合特定访问模式的用户。通常这种模式发生在客户团队和服务团队紧密合作的情况下。如果团队正在开发一体式应用,则经常会创建一个跨多个聚合边界的API,因此这些聚合是强耦合的。看一个例子,假设需要同时在Web和移动应用上的一个订单详情页面中展示一个订单的详情,以及订单的退款流程详情。在一体式应用中,会使用GET Order API(假设是REST API)同时请求订单和退款服务,合并两个聚合,并向调用者发送复合响应。由于聚合属于相同的处理边界,因此可能不会造成很大的开销。因此消费者可以在一个调用中获取所有必需的数据。

如果订单或退款服务位于不同的上下文,则无法从单一的微服务或聚合边界内获取所需的数据。为消费者保留相同功能的一种方式是让订单服务负责调用退款服务,并创建复合响应:

  1. 订单服务集成了其他服务,用于支持需要退款数据以及订单数据的消费者。此时当退款聚合发生变化的同时也会影响到订单聚合,订单服务的自治性变弱。
  2. 由于订单服务集成了其他服务,因此需要考虑故障点--如果退款服务宕机,那么订单服务是否能够发生部分数据,消费者是否能够正常失败?
  3. 如果需要修改消费者来从退款聚合获取更多的数据,则需要两个团队配合修改。
  4. 如果使用这种模式进行跨平台协作,则有可能在各种领域服务之间造成复杂的web依赖。造成这种局面的原因就是所有的服务都迎合了调用者使用的特定访问模式。

前端的后端(BFFs)

一种缓解这种风险的方式是让消费者团队管理各种领域服务的编排。最终,调用者能够更好地了解访问模式,并完全掌握对这些模式的修改。这种方式从表示层对领域服务进行解耦,使它们能够专注于核心业务的处理。但如果web和移动apps直接调用不同的服务(而不是调用一体式的复合API),则可能会对这些apps造成一定的性能开销(在低带宽网络上执行多个调用,处理以及从不同的API合并数据等)。

可以使用另一种模式:BFF(Backend for Front-ends)。这种设计模式下,消费者会创建并管理一个后端服务(在上例中为web和移动团队),处理跨多领域服务的集成(完全是为了给客户提供前后端体验)。现在web和移动端都可以根据需求来设计数据合同(contract)。甚至可以使用GraphQL ,而非REST API来提供灵活的访问,并返回所需要的内容。需要注意的是,该服务是消费者团队(而不是领域服务的团队)所有并维护的。前后端团队可以根据需要进行优化,例如一个移动app可以通过请求一个小的载荷来降低移动app的调用数等等。下图展示了修改后的编排逻辑。BFF服务可以同时调用订单和退款领域服务。

尽早构建BFF服务也很有用(在将一体式拆分为大量服务前)。否则,要么领域服务必须支持域间业务流程,要么Web和移动应用程序必须直接从前端调用多个服务。 这两个选项都将导致性能开销,且团队之间缺少自治。

DDD中的概念比较模糊,但有一些概念(如领域和子域,边界上下文等)和实施(聚合中的值对象和根成员等)是比较清晰的。

参考

posted @ 2021-04-02 13:32  charlieroro  阅读(445)  评论(0编辑  收藏  举报