微服务建模
本文将讲解一些微服务的基本概念,如信息隐藏、耦合和内聚,并理解它们将如何改变我们对围绕微服务划分边界的想法。了解使用的不同形式的分解,关注领域驱动设计,这是微服务领域中非常有用的技术。我们将了解如何考虑微服务的边界,以最大化其优势并避免一些潜在的劣势。
我们以MusicCorp为例(一家销售CD的在线零售商),看下微服务的概念是如何工作。
微服务边界
我们希望微服务能够以一种独立的方式被更改和部署,并将其功能发布给用户。独立更改一个微服务的能力至关重要。当我们考虑如何在他们周围划定界限是,我们需要记住什么呢?
从本质上讲,微服务只是模块化分解的另一种形式,尽管它在模型和所带来的所有相关挑战之间具有基于网络的交互。幸运的是,这意味着我们可以依靠模块化软件和结构化编程领域的许多现有技术来帮助我们确定如何定义我们的边界。考虑到这一点,让我们更深入地了解三个关键概念—-信息隐藏、内聚和耦合,这三个概念对于理解好的微服务边界是至关重要的。
信息隐藏
信息隐藏是David Parnas提出的一个概念,用来研究定义模块边界的最有效方法,信息隐藏描述了在模块(或者微服务)边界后面隐藏尽可能多的细节的愿望。Parnas研究了模块理论上应该给我们带来的好处,即:
缩短开发时间
通过允许独立开发模块,我们可以允许更多的工作并行完成,并减少为项目添加更多开发人员的影响;
可理解性
每个模块都可以单独查看和理解。这反过来使我们更容易理解系统作为一个整体的作用。
灵活性
模块可以彼此独立地进行更改,这样就可以在不需要更改其他模块的情况下更改系统的功能。此外,模块可以以不同的方式组合,以交付新的功能。
这些理想的特性很好地补充了我们试图用微服务体系结构实现的目标—-事实上,可以把微服务看作是模块化体系结构的另一种形式。
事实上,拥有模块并不会导致你真正实现这些结果。很大程度上取决于模块边界是如何形成的。信息隐藏是帮助最大限度地利用模块化架构的关键技术,从现代的角度来看,这同样适用于微服务。
通过减少一个模块(或微服务)对另一个模块的假设,我们可以直接影响它们之间的联系。通过保持较小的假设数量,可以更容易地确保我们可以在不影响其他模块的情况下更改一个模块。如果更改模块的开发人员对其他人如何使用该模块有清晰的理解,那么开发人员将更容易安全地进行更改,而上游调用者也不必进行更改。
这也适用于微服务,除非我们也有机会在不部署任何其他东西的情况下部署更改的微服务,可以证明Parnas描述的三个理想特征是改进开发时间、可理解性和灵活性。
内聚(Cohesion)
内聚性的最简洁的定义之一是:“一起更改的代码保持在一起”。就我们的目的而言,这是一个相当好的定义。正如我们已经讨论过的,我们围绕业务功能更改的便利性来优化我们的微服务架构——因此我们希望以一种可以在尽可能少的地方进行更改的方式对功能进行分组。
我们希望相关的行为放在一起,不相关的行为放在其他地方。如果我们想改变行为,我们希望能够在一个地方改变它,并尽快释放这种改变。如果我们必须在许多不同的地方改变这种行为,我们就必须发布许多不同的服务(可能是同时)来交付这种改变。在许多不同的地方进行更改会更慢,同时部署大量服务也有风险。
因此,我们希望在问题域内找到边界,以帮助确保相关行为在一个地方,并与其他边界尽可能松散地沟通。如果相关的功能在整个系统中传播,我们说内聚性较弱—-而对于微服务架构,目标是强内聚性。
耦合(Coupling)
当服务是松耦合时,对一个服务的更改不应引起对另一个服务的更改。微服务的要点是能够更改一个服务并部署它,而不需要更改系统的任何部分。
什么类型会导致紧密耦合?一个典型的错误是选择将一个服务紧密绑定到另一个服务的集成样式,从而导致服务内部的更改需要对使用者进行更改。
一个松散耦合的服务对与之协作的服务所知甚少。这也意味着我们可能希望限制从一个服务到另一个服务的不同类型调用的数量。
耦合有很多种形式,它属于基于服务的体系结构。
耦合和内聚的相互作用
耦合和内聚的概念显然是相关的,从逻辑上讲,如果相关的功能在我们的系统中得到扩展,那么对该功能的更改将波及到这些边界,这意味着更紧密的耦合。康斯坦丁定律简洁地总结了这一点:如果内聚性强而耦合性低(高内聚、低耦合),则结构是稳定的。
这里的稳定性概念很重要,为了让微服务的边界能够实现独立部署的目标,允许并行地处理微服务,并减少处理这些微服务的团队之间的协调,我们需要边界具有一定程度的稳定性。如果微服务公开的契约以一种向后不兼容的方式不断变化,那么这将导致上游消费者也必须不断变化。
耦合和内聚是紧密相关的,至少在某种程度上可以说是相同的,因为这两个概念都描述了事物之间的关系。内聚适用于边界内事物之间的关系(上下文中的微服务),而耦合则描述跨边界事物之间的关系。没有绝对最好的方法来组织代码,耦合和内聚只是一种方法,可以清楚地说明我们在哪里分组代码以及为什么分组代码。我们所能做的就是在这两种想法之间找到正确的平衡,一个对你所处的环境和你目前面临的问题最优意义的想法。
耦合的类型
系统中的某些耦合将是不可避免的,我们能做的是减少耦合度。
在结构化编程的上下文中,已经做了大量的工作来研究不同形式的耦合,主要考虑的是模块化(非分布式、单体)软件。许多用于评估耦合重叠或冲突的不同模型,主要讨论的是代码级别的事情,而不是考虑基于服务的交互。由于微服务是模块化架构的一种风格(尽管增加了分布式系统的复杂性),我们可以使用许多原始概念,并将它们应用到基于微服务的系统上下文中。
如图1所示,可以看到不同类型耦合的简要概述,从低(理想的)到高(不理想的)。

下面,我们依次分析每种耦合形式,并举一些例子,说明这些形式如何在微服务体系结构中体现。
域耦合(Domain Coupling)
域耦合描述了一个微服务需要与另一个微服务交互的情况,因为第一个微服务需要利用另一个微服务提供的功能。
在图2所示中,我们看到了MusicCorp内部如何管理CD订单的部分内容。Order Processor
调用Warehouse
微服务来储备库存,调用Payment
微服务来接受付款。因此,对于此操作,订单处理器依赖并耦合到Warehouse
和Payment
微服务。但是,我们没有看到Warehouse
和Payment
之间的这种耦合,因为它们没有交互。

在微服务架构中,这种类型的交互在很大程度上是不可避免的。一个基于微服务的系统依赖于多个微服务的协作来完成它的工作。不过,我们仍然希望将其控制在最低程度,当看到单个微服务以这种方式依赖于多个下游服务时,就应该引起关注—-这意味着微服务的工作太多了。
域耦合被认为是一种松散形式的耦合,一个需要与大量下游微服务交互的微服务可能会导致太多的逻辑被集中。当服务之间发送更复杂的数据集时,域耦合也会出现问题。
记住信息隐藏的重要性,只分享你绝对需要的,只发送你需要的绝对最小的数据量。
Pass-Through Coupling
“Pass-Through Coupling”描述了这样一种情况:一个微服务将数据传递给另一个微服务,纯粹是因为下游的其他一些微服务需要这些数据。在许多方面,它是最有问题的实现耦合形式之一,因为它不仅意味着调用者不仅知道它正在调用的微服务还会调用另一个微服务,而且还可能需要知道微服务是如何工作的。
作为“Pass-Through Coupling”的一个例子,我们看看MusicCrop的订单处理是如何工作的。如图3所示,我们有一个Order Processor
,它向Warehouse
发送一个请求,以准备一个订单进行分派。作为请求有效负载的一部分,我们发送一份Shipping Manifest
。这个Shipping Manifest
不仅包含客户的地址,还包含Shipping类型。仓库只是把清单传递给下游的Shipping
微服务。

Pass-Through Coupling的主要问题是,对所需要数据的下游更改可能导致更重要的上游更改。在我们的示例中,如果Shipping
现在需要更改数据的格式或内容,那么Warehouse
和Order Processor
可能都需要更改。
有几种方法可以解决这个问题。第一个是考虑调用微服务绕过中介是否有意义。在我们的示例中,这意味着Order Processor
直接与Shipping
对话,如图4所示。然而,这导致了其他一些令人头痛的问题。我们的Order Processor
正在增加它的域耦合,因为Shipping
是它需要了解的另一个微服务—-如果这是唯一的问题,这种方案是好的,因为域耦合是一种更松散的耦合方式。但是,这个解决方案在这里变得更加复杂,因为在使用Shipping
分发包之前,库存必须在Warehouse
中保留,并且在发运完成之后,我们需要相应地更新库存。这将使之前隐藏在Warehouse
中的Order Processor
更加复杂。

对于这个特定的示例,可以考虑一个更简单的更改—-即完全从Order Processor
中隐藏Shipping Manifest
的需求。将管理库存和安排包分发的工作委托给我们的Warehouse
服务,但是这暴露了一些较低级的实现—-即Shipping
微服务需要Shipping Manifest
。隐藏这个细节的一种方法是让Warehouse
将所需的信息作为其约束的一部分,然后让它在本地构造Shipping Manifest
,如图5所示。这意味着,如果Shipping
服务更改了它的服务约束,从Order Processor
的角度来看,这个更改是不可见的,只要Warehouse
收集了所需的数据。

虽然这将有助于保护Warehouse
微服务不受Shipping
更改的影响,但仍有一些事情需要各方进行更改。让我们考虑一下这个想法,我们想开始国际航运。作为其中的一部分,Shipping
服务需要在Shipping Manifest
中包含一个海关声明。如果这是一个可选参数,那么我们可以毫无问题地部署新版本的Shipping
微服务,但是,如果这是必需的参数,那么Warehouse
将需要创建一个参数,它可以使用它拥有的现有信息来实现这一点,或者它可能需要Order Processor
向它传递额外的信息。
虽然在示例中,我们没有消除在三个微服务中进行更改的需要,但是何时以及如何进行这些更改方面,我们被赋予了更大的权利。如果我们具有初始示例的紧密耦合,那么添加这个新的Customs Declaration
可能需要对所有三个微服务进行同步部署。至少通过隐藏这个细节,我们可以更容易地进行阶段部署。
最后一种有助于减少Pass-Through Coupling的方法是,Order Processor
仍然通过Warehouse
将Shipping Manifest
发送给Shipping
微服务,但要让Warehouse
完全不知道Shipping Manifest
本身的结构。Order Processor
将清单作为订单请求的一部分发送,但Warehouse
不尝试查看或处理该字段—-它只是将其视为一组数据,而不关心其中的内容。相反,它只是把它发送出去。Shipping Manifest
格式的更改仍然需要对Order Processor
和Shipping
微服务进行更改,但是由于Warehouse
并不关心清单中的实际内容,因此它不需要更改。
Common Coupling
当两个或多个微服务使用功能一组公共数据时,就会发生公用耦合。这种耦合形式的一个简单而常见的例子是多个微服务使用同一个共享数据库,但它也可以通过使用共享内存或共享文件系统来体现。
公共耦合的主要问题是,对数据结构的更改可能同时影响多个微服务。考虑图6中一些MusicCrop服务的示例。MusicCrop在世界各地都有业务,因此它需要有关其业务所在国的各种信息。这里,多个服务器都从共享数据库读取静态引用数据。如果该数据库的模式以向后不兼容的方式更改,则需要对数据库的每个使用者进行更改。在实际中,这样的共享数据往往很难更改。

图6所示的例子相对来说是良性的,因为从本质上将,静态引用数据往往不会经常更改,而且这个数据是只读的—-因此对以这种方式共享静态引用数据的情况无需太过担心。但是,如果共享数据的结构变化频繁,或者多个微服务对相同的数据进行读写,那么公共耦合就会变得很有问题。
图7向我们展示了这样一种情况,Order Processor
和Warehouse
服务同时从共享的Order
表中读写,以帮助管理将CD分发给MusicCorp的客户的过程。两个微服务都在更新Status
列。Order Processor
可以设置PLACED
、PAID
、COMPLETED
状态,而仓库将使用PICKING
或SHIPPED
状态。

这个公共耦合的简单实例有助于说明一个核心问题。从概念上讲,我们有Order Processor
和Warehouse
微服务来管理订单生命周期的不同方面。当Order Processor
中进行更改时,是否可以确保更改订单数据的方式不会破坏Warehouse
?
确保某种状态以正确的方式更改的一种方法是创建一个有限状态机。状态机可用于管理某些实体从一种状态到另一种状态的转换,确保禁止无效的状态转换。在图8中,可以看到MusicCorp中允许的订单状态转换。订单可以直接从PLACED
转到PAID
,但不能直接从PLACED
转到PICKING
。

这里的一个潜在解决方案是确保单个微服务管理订单状态。如图9中,无论Warehouse
还是Order Processor
都可以向Order
服务发送状态更新请求。在这里,Order
微服务是任何给定订单的来源。在这种情况下,将来自Warehouse
和Order Processor
的请求视为请求是非常重要的。在此场景中,Order
服务的工作是管理与订单聚合相关联的可接受状态转换。因此,如果Order
服务从Order Processor
接收到将一个状态直接从PLACED
移动到COMPLETED
的请求,如果这是一个无效的更改,它可以自由地拒绝该请求。
在这种情况下,另一种方法是将Order
服务实现为数据库CRUD操作的包装器,其中请求直接映射到数据库更新。这类似于一个拥有私有字段但拥有公共getter和setter的对象—-行为已经从微服务暴露给了上游消费者(降低了内聚性),我们又回到了管理跨多个不同服务的可接受状态转换的世界。

内容耦合(Content Coupling)
内容耦合描述了这样一种情况,上游服务进入下游服务的内部并更改其内部状态。最常见的表现是外部服务访问另一个微服务的数据库并直接更改它。内容耦合和公共耦合的区别是微妙的。在这两种情况下,两个或多个微服务对同一组数据进行读写操作。使用公共耦合,可以理解为你正在使用一个共享的外部依赖项。有了内容耦合,所有权的界限就变得不那么清晰了,开发人员更改系统也变得更加困难。
让我们重新访问之前的MusicCorp实例。在图10中,我们有一个Order
服务,它应该管理系统中订单的允许状态更改。Order Processor
正在向Order
服务发送请求,它不仅委托将进行的状态更改,还负责决定允许哪些状态转换。另一方面,Warehouse
服务直接更新存储订单数据的表,绕过order
服务中可能检查允许更改的任何功能。我们只能希望Warehouse
服务具有一致的逻辑集,以确保只进行有效的更改。在最好的情况下,这代表了逻辑的重复。在最坏的情况下,对Warehouse
中允许的更改的检查与Order
服务中的检查是不同的,因此,我们可能会以非常奇怪、令人困惑的状态结束订单。

在这种情况下,我们还面临一个问题,即订单表的内部数据结构暴露给外部。在更改Order
服务时,我们现在必须非常小心地更改特定的表—-即使假设对我们来说这个表显然是被外部直接访问的。这里的简单解决方法是让Warehouse
将请求发送到Order
服务本身,我们可以在这里审查请求,但也隐藏内部细节,从而更容易对Order服务进行后续更改。
如果你正在开发一款微服务,你必须清楚区分哪些内容可以自由更改,哪些内容不能更改。明确地说,作为开发人员,需要直到何时更改服务向外部公开的约束的一部分功能。需要确保,如果做出更改,不会破坏上游消费者。
当然,在公共耦合中出现的问题也适用于内容耦合,但内容耦合还有一些额外的令人头痛的问题,一些人将其称为pathological coupling。
当你允许外部直接访问你的数据库时,数据库实际上就成为了该外部约束的一部分,你无法轻松地推理出哪些内容可以更改,哪些内容不可以更改。你已经失去了定义什么是共享的和什么是隐藏的。信息隐藏已经不存在了。因此我们应该避免内容耦合。
领域驱动设计
我们用于寻找微服务边界的主要机制是围绕领域本身,利用领域驱动设计(DDD)来帮助创建我们的领域模型,让我们来看下DDD在微服务上下文中是如何工作的。
DDD的一些核心概念,如下:
Ubiquitous language
定义并采用在代码和描述领域中使用的通用语言,以帮助交流;
Aggregate
作为单个实体管理的对象集合,通常引用真实世界的概念;
Bounded context
业务领域内的显式边界,为更广泛的系统提供功能,但也隐藏了复杂性。
Ubiquitous language
通用语言指的是,我们应该尽量在代码中使用与用户使用相同的术语。这个想法是,在交付团队和实际使用人员之间拥有一种共同的语言,将使对真实世界领域建模变得更容易,并且改善交流。
通过在代码中使用真实世界的语言,事情变得容易多了。开发人员使用直接来自产品负责人的属于编写的代码更能理解其含义,并确定需要做什么。
Aggregate
在DDD中,聚合是一个有点令人困惑的概念,有许多不同的定义。它只是一个任意的对象集合吗?应该从数据库中取出的最小单元是什么?其实一直有效的模型是首先考虑一个聚合作为一个实际领域概念的表示—-可以考虑类似于Order、Invoice、Stock Item等等的东西。聚合通常有一个生命周期围绕它们,这使它们可以作为状态机实现。
例如,在MusicCorp域中,Order
聚合可能包含表示订单中的项的多个行。这些行仅作为整个Order
聚合的一部分。
我们想把聚合看成是自包含的单位,希望确保处理聚合状态转换的代码与状态本身一起分组。因此,一个聚合应该由一个微服务管理、尽管一个微服务可能拥有多个聚合的管理。
但是,一般来说,应该将聚合视为具有状态、生命周期的东西,这些生命周期将作为系统的一部分进行管理。聚合通常引用真实世界的概念。
单个微服务将处理一个或多个不同类型聚合的生命周期和数据存储。如果在另一个服务功能想要改变这些聚集物之一,它需要直接请求改变聚合的系统中其他的东西来启动自己的状态转换,通过订阅其他microservices
发行的事件。
聚合可以与其他聚合相关,在图11中,我们有一个Customer
聚合,它与一个或多个Orders
和一个或多个Wishlists
相关联。这些聚合可以由相同的微服务或不同的微服务管理。

如果聚合之间的这些关系存在于单个微服务的范围内,那么如果使用关系型数据库,就可以使用外键关系轻松地存储它们。但是,如果这些聚合之间的关系跨越了微服务边界,那么我们需要某种方式来对这些关系建模。
现在,我们可以直接将聚合的ID存储在本地数据库中。例如,考虑一个Finance
微服务,它管理一个财务账本,该账本存储针对客户的事务。在本地,Finance
微服务的数据库中,我们可以有一个包含该客户ID的CustID
列。如果我们想要获得关于该客户的更多信息,就必须使用该ID对customer
微服务进行查找。
这个概念的问题在于它不是显式的—-事实上,CustID
列和远程客户之间的关系是完全隐式的。要直到这个ID是如何被使用的,我们必须查看Finance
微服务本身的代码。如果我们能够以一种更明显的方式存储对外部聚合的引用,那就更好了。
在图12中,我们更改了一些内容以使关系显式。我们不是存储客户引用的普通ID,而是存储一个URI,如果构建基于REST的系统,我们可以使用这个URI。这种关系的性质是显式的,在REST系统中,我们可以直接引用这个URI来查找相关的资源。

这种关系的性质是显式的,在REST系统中,我们可以直接引用这个URI来查找相关的资源。
Bounded Context
一个有边界的上下文通常代表一个更大的组织边界。在这一界限范围内,需要履行明确的职责。让我们看下具体的例子:
在MusicCorp中,我们的仓库管理正在发运的订单(和零星的退货),接受新库存的交付等等。在其他地方,财务部分处理工资、支付运费等等。
有界上下文隐藏了实现细节。这里有一些内部问题—-例如,除了仓库里的人之外,没有人对使用的叉车的类型感兴趣。这些内在的关切应该隐藏起来,不让外界知道,外界也不应该关心。
从实现的角度来看,边界上下文包含一个或多个聚合。有些聚合可能在有边界的上下文中公开;其他的可能隐藏在内部。与聚合一样,有界上下文可能与其他有界上下文有关系—-当映射到服务时,这些依赖关系变成了服务间的依赖关系。
回到MusicCorp的业务上。我们的领域是经营的整个业务。它涵盖了从仓库到前台,从财务到订购的一切。可以软件中模拟所有这些,让我们考虑一下这个定义域,它看起来就像Eric Evans提到的边界环境。
总结
在本文中,了解了什么是好的微服务边界,以及如何在我们的问题空间中找到接缝,从而使我们获得低耦合和高内聚的双重好处。对我们的领域有一个详细的了解是帮助我们找到这些接缝的一个重要工具,通过将微服务对准这些边界,可以确保最终系统有机会保持这些优点的完整性。
感兴趣的关注如下公众号!





