第 3 章 如何建模服务

第 3 章 如何建模服务

“对手的论证让我想到了异教徒。当别人问异教徒世界由什么支撑时,他说:‘一只乌龟。’别人再问他那乌龟又由什么支撑呢?他回答:‘另一只乌龟。’”

——Joseph Barker(1854)

现在你已经知道什么是微服务了,希望你对它的主要优点也有所理解。你可能已经迫不及待地想要实现它了,对吗?但是从何做起呢?在本章中,我们会讨论如何确定服务之间的边界,以期最大化微服务的好处,避开它的劣势。但是,首先我们需要有一个产品作为讨论的载体。

3.1 MusicCorp简介

讨论想法的书最好有例子作为辅助。我会尽可能跟大家分享真实的故事,但是我发现,其实使用一个虚构的领域也挺有用的。在本书的剩余部分,我们会不断地回到这个领域来看看微服务架构对其产生了什么样的影响。

让我们把注意力转移到前沿在线零售商 MusicCorp 上来。MusicCorp 最初是实体店经营,但是在唱片生意跌入谷底之后,他们开始把更多的注意力放在了网上。该公司有一个网站,他们认为现在是时候把在线业务的投入翻倍了。毕竟,iPod 只是昙花一现的东西(Zune 明显要好得多),音乐迷们还是很希望有人能够把 CD 送上门。质量比方便更重要,对吧?说到这儿,有一个问题我一直不太明白:人们经常提起的 Spotify(https://www.spotify.com/)是干什么的,是给年轻人做皮肤护理的吗?

尽管有点落后于时代了,但是 MusicCorp 还是有很大的野心。幸运的是,它认为赢得世界的方法是,保证自己很容易对应用进行修改。这正是微服务的用武之地!

3.2 什么样的服务是好服务

在 MusicCorp 的团队为了把八轨带(eight track tape)递送到所有人手中而开始辛苦工作、创建一个又一个的服务之前,让我们先缓缓,讨论一些很重要的基本原则。什么是好的服务?如果你曾经尝试过 SOA 并且失败了,大概就知道我下一步要说什么了。不过万一你没那么幸运(不幸),我希望你专注在两个重要的概念上:松耦合高内聚。在本书的剩余部分,我们会讨论更多的实践和细节,因为如果这两点做不到,那么微服务也就没什么价值了。

这两个概念在不同的上下文中被大量使用,尤其是在面向对象编程中,所以,我们先讨论一下这两个概念在微服务中是什么含义。

3.2.1 松耦合

如果做到了服务之间的松耦合,那么修改一个服务就不需要修改另一个服务。使用微服务最重要的一点是,能够独立修改及部署单个服务而不需要修改系统的其他部分,这真的非常重要。

什么会导致紧耦合呢?一个典型的错误是,使用紧耦合的方式做服务之间的集成,从而使得一个服务的修改会致使其消费者的修改。第 4 章会进一步讨论如何避免这种问题。

一个松耦合的服务应该尽可能少地知道与之协作的那些服务的信息。这也意味着,应该限制两个服务之间不同调用形式的数量,因为除了潜在的性能问题之外,过度的通信可能会导致紧耦合。

3.2.2 高内聚

我们希望把相关的行为聚集在一起,把不相关的行为放在别处。为什么呢?因为如果你要改变某个行为的话,最好能够只在一个地方进行修改,然后就可以尽快地发布。如果需要在很多不同的地方做这些修改,那么可能就需要同时发布多个微服务才能交付这个功能。在多个不同的地方进行修改会很慢,同时部署多个服务风险也很高,这两者都是我们想要避免的。

所以,找到问题域的边界就可以确保相关的行为能放在同一个地方,并且它们会和其他边界以尽量松耦合的形式进行通信。

3.3 限界上下文

Eric Evans 的《领域驱动设计》一书主要专注如何对现实世界的领域进行建模。该书中有很多非常棒的想法,比如通用语言、仓储、抽象等。其中 Evans 引入的一个很重要的概念是限界上下文(bounded context),刚听到这个概念的时候,我深受启发。他认为任何一个给定的领域都包含多个限界上下文,每个限界上下文中的东西(Eric 更常使用模型这个词,应该比“东西”好得多)分成两部分,一部分不需要与外部通信,另一部分则需要。每个上下文都有明确的接口,该接口决定了它会暴露哪些模型给其他的上下文。

另一个我比较喜欢的限界上下文的定义是:“一个由显式边界限定的特定职责。”(http://blog.sapiensworks.com/post/2012/04/17/DDD-The-Bounded-Context-Explained.aspx)如果你想要从一个限界上下文中获取信息,或者向其发起请求,需要使用模型和它的显式边界进行通信。在这本书中,Evans 使用细胞作为比喻:“细胞之所以会存在,是因为细胞膜定义了什么在细胞内,什么在细胞外,并且确定了什么物质可以通过细胞膜。”

让我们回到 MusicCorp 的业务上来,其业务领域涉及运营的方方面面。它涵盖了从仓储到前台、从财务到订单的所有元素。这些元素就是领域,尽管我们不一定要对所有的元素进行建模。让我们尝试在领域中寻找 Evans 所提到的那些限界上下文。在 MusicCorp 中,仓库是一个很热闹的场所,它负责管理发出去的订单(及退回的剩余产品),接收新到的库存,保证多个铲车能同时正常运行等。别的地方,比如财务部门就没那么好玩了,但它在组织内部还是非常重要的。财务部的员工负责管理工资单和公司的账户,并生成重要的报表,这些报表的数量相当大。他们很可能还有一些有趣的桌面玩具。

3.3.1 共享的隐藏模型

对于 MusicCorp 来说,财务部门和仓库就可以是两个独立的限界上下文。它们都有明确的对外接口(在存货报告、工资单等方面),也都有着只需要自己知道的一些细节(铲车、计算器)。

财务部门不需要知道仓库的内部细节。但它确实也需要知道一些事情,比如,需要知道库存水平以便于更新账户。图 3-1 展示了一个上下文图表示例。可以看到其中包含了仓库的内部概念,比如订单提取员、货架等。类似地,公司的总账是财务部必备的一部分,但是不会对外共享。

{%}

图 3-1:财务部门和仓库之间共享的模型

为了算出公司的估值,财务部的雇员需要库存信息,所以库存项就变成了两个上下文之间的共享模型。然而,我们不会盲目地把库存项在仓库上下文中的所有内容都暴露出去。比如,尽管在仓库内部有相应的模型来表示库存项,但是我们不会直接把这个模型暴露出去。也就是对该模型来说,存在内部和外部两种表示方式。很多情况下,这都会导致是否要采用 REST 的讨论。第 4 章会对 REST 做更多的讨论。

有时候,同一个名字在不同的上下文中有着完全不同的含义。比如,退货表示的是客户退回的一些东西。在客户的上下文中,退货意味着打印运送标签、寄送包裹,然后等待退款。在仓库的上下文中,退货表示的是一个即将到来的包裹,而且这个包裹会重新入库。退货这个概念会与将要执行的任务相关,比如我们可能会发起一个重新入库的请求。这个退货的共享模型会在多个不同的进程中使用,并且在每个限界上下文中都会存在相应的实体,不过,这些实体仅仅是在每个上下文的内部表示而已。

3.3.2 模块和服务

明白应该共享特定的模型,而不应该共享内部表示这个道理之后,就可以避免潜在的紧耦合(即我们不希望成为的样子)风险。我们还识别出了领域内的一些边界,边界内部是相关性比较高的业务功能,从而得到高内聚。这些限界上下文可以很好地形成组合边界。

就像在第 1 章中讨论过的,在同一个进程内使用模块来减少彼此之间的耦合也是一种选择。刚开始开发一个代码库的时候,这可能是比较好的办法。所以一旦你发现了领域内部的限界上下文,一定要使用模块对其进行建模,同时使用共享和隐藏模型。

这些模块边界就可以成为绝佳的微服务候选。一般来讲,微服务应该清晰地和限界上下文保持一致。熟练之后,就可以省掉在单块系统中先使用模块的这个步骤,而直接使用单独的服务。然而对于一个新系统而言,可以先使用一段时间的单块系统,因为如果服务之间的边界搞错了,后面修复的代价会很大。所以最好能够等到系统稳定下来之后,再确定把哪些东西作为一个服务划分出去。第 5 章会对此做更多讨论,同时也会介绍一些技术来把已有的单块系统划分成微服务。

所以,如果服务边界和领域的限界上下文能保持一致,并且微服务可以很好地表示这些限界上下文的话,那么恭喜你,你跨出了走向高内聚低耦合的微服务架构的第一步。

3.3.3 过早划分

在 ThoughtWorks,我们也经历过一些由过早进行服务划分带来的挑战。除了咨询业务之外,我们也做过一些产品。其中一个产品叫作 SnapCI,它是一个持续集成和持续交付的云平台(第 6 章会进一步讨论这些概念)。该产品团队之前做了另一个类似的产品:Go-CD。现在 Go-CD 是一个开源的持续交付工具,与 SnapCI 不同,该工具可以部署在本地。

尽管在 SnapCI 和 Go-CD 之间有一些代码重用,但 SnapCI 最终成为了一个全新的代码库。之前在 CD 工具领域上的经验使团队很有信心地、快速地识别边界,并且直接使用微服务的方式来构建系统。

几个月之后,我们发现 SnapCI 的用例和之前想的有所不同,而这些不同足以证明之前的服务划分方式是有问题的。这导致了很多跨服务的修改,而这些修改的代价相当高。团队逐渐又把这些服务合并成了一个单块系统,从而给所有人时间去理解服务边界到底应该在哪。一年之后,团队识别出了出非常稳定的边界,并据此将这个单块系统拆分成多个微服务。当然这并不是我见过的唯一一个过早划分的例子。过早将一个系统划分成为微服务的代价非常高,尤其是在面对新领域时。很多时候,将一个已有的代码库划分成微服务,要比从头开始构建微服务简单得多。

3.4 业务功能

当你在思考组织内的限界上下文时,不应该从共享数据的角度来考虑,而应该从这些上下文能够提供的功能来考虑。比如,仓库的一个功能是提供当前的库存清单,财务上下文能够提供月末账目或者为一个新招的员工创建工资单。为了实现这些功能,可能需要交换存储信息的模型,但是我见过太多只考虑模型从而导致贫血的、基于 CRUD(create,read, update,delete)的服务。所以首先要问自己“这个上下文是做什么用的”,然后再考虑“它需要什么样的数据”。

建模服务时,应该将这些功能作为关键操作提供给其协作者(其他服务)。

3.5 逐步划分上下文

一开始你会识别出一些粗粒度的限界上下文,而这些限界上下文可能又包含一些嵌套的限界上下文。举个例子,你可以把仓库分解成为不同的部分:订单处理、库存管理、货物接受等。当考虑微服务的边界时,首先考虑比较大的、粗粒度的那些上下文,然后当发现合适的缝隙后,再进一步划分出那些嵌套的上下文。

我见过有一种做法是,使这些嵌套的上下文不直接对外可见。对于外界来说,它们用的还是仓库的功能,但发出的请求其实被透明地映射到了两个或者更多的服务上,如图 3-2 所示。有时候你会认为,高层次的限界上下文不应该被显式地建模成为一个服务,如图 3-3 所示,也就是说,不存在一个单独的仓库边界,而是把库存管理、订单处理和货物接收等这些服务分离开来。

图像说明文字

图 3-2:在仓库内部使用微服务表示嵌套限界上下文

图像说明文字

图 3-3:仓库内部的限界上下文被提升到顶层上下文的层次

通常很难说哪种规则更合理,但是你应该根据组织结构来决定,到底是使用嵌套的方法还是完全分离的方法。如果订单处理、库存管理及货物接收是由不同的团队维护的,那么他们大概会希望这些服务都是顶层微服务。另一方面,如果它们都是由一个团队管理的,那么嵌套式结构会更合理。其原因在于,组织结构和软件架构会互相影响,第 10 章会对此做详细讨论。

另一个倾向于嵌套式方法的原因是,它可以使得架构更成块儿从而更好地测试。举个例子,当测试仓库的消费方服务时,不需要对仓库上下文中的每个服务进行打桩,只需要专注于粗粒度的 API 即可。当考虑更大范围的测试时,这也能够给你一定的单元隔离。比如,我可以有这样一种端到端测试,该测试会使用仓库上下文中的所有服务,但其他的所有协作者可以做打桩处理。第 7 章会对测试和隔离做更多讨论。

3.6 关于业务概念的沟通

修改系统的目的是为了满足业务需求。我们会修改面向客户的功能。如果把系统分解成为限界上下文来表示领域的话,那么对于某个功能所要做的修改,就更倾向于局限在一个单独的微服务边界之内。这样就减小了修改的范围,并能够更快地进行部署。

微服务之间如何就同一个业务概念进行通信,也是一件很重要的事情。基于业务领域的软件建模不应该止于限界上下文的概念。在组织内部共享的那些相同的术语和想法,也应该被反映到服务的接口上。以跟组织内通信相同的方式,来思考微服务之间的通信形式是非常有用的。事实上,通信形式在整个组织范围内都非常重要。

3.7 技术边界

服务被错误建模会造成什么样的影响?不久之前,我和一些同事为一个加州的客户工作,帮助他们采用整洁代码实践及自动化测试。一开始做的事情比较简单,比如当注意到有些事情让人很担忧的时候,对服务进行划分。我不能透露更多该应用的信息,但可以说的是,它是一个面向大众的应用,拥有全球大量用户。

团队和系统开始增长。一开始只包含一个人的愿景,现在整个系统的功能和用户越来越多。这个组织逐渐决定对团队进行扩容,增加了一个巴西团队来分担一部分工作。系统被划分成两部分,一部分面向前端,该部分不保存任何状态,如图 3-4 所示;后端部分就是一个简单的数据存储,通过 RPC(Remote Procedure Call,远程过程调用)来提供服务。基本上你可以理解为,把一个代码库中的仓储层变成一个独立的服务。

图像说明文字

图 3-4:基于技术接缝划分的服务边界

后来发现,需要频繁地同时修改两个服务。两个服务都使用偏底层的、RPC 风格的方法调用,而这是非常不稳定的(第 4 章会就此做进一步讨论)。这个服务接口也很繁琐,会导致性能问题。这就导致了对 RPC 批处理的需求。我把这种架构称为洋葱架构,因为它有很多层,而且当纵切这些层次时,我只想哭。

基于这些事实可以看出,前面提到的按照地理位置或者组织结构对单块系统进行划分是很合理的,第 10 章会做进一步讨论。然而上面这个例子,并不是按照业务进行的垂直划分,而是把原来进程内部的 API 水平划分了出去。

按照技术接缝对服务边界进行建模也并不总是错误的。比如,我见过当一个组织想要达到某个性能目标时,这种划分方式反而更合理。然而一般来讲,这不应该成为你考虑的首要方式。

3.8 小结

在本章中,你学到了什么是好的服务,以及如何在问题空间中寻找能达到高内聚低耦合的接缝。限界上下文是寻找这些接缝的一个非常重要的工具,通过将微服务与这些边界相匹配,可以保证最终的系统能够得到微服务提供的所有好处。我们也大概了解了一些进一步划分微服务的方法,后面的章节会深入讨论这个话题。本章还引入了 MusicCorp,一个会贯穿本书剩余部分的示例领域。

Eric Evans 在《领域驱动设计》中提到的概念对于寻找明显的服务边界来说非常有用。在本章中我只提到了其中的一小部分。我推荐你看一看 Vaughn Vernon 的《实现领域驱动设计》,它能够帮助你理解如何实践这些方法。

本章讨论的内容比较宽泛,下一章的内容技术性会更强。在实现服务间接口方面存在很多的陷阱,从而会引入各种各样的麻烦。如果不想系统乱成一团麻,就必须深入讨论一下该话题。

目录