如何将微服务应用设计四层结构:平台层/服务层/边界层/客户端层

异步社区官方博客

本章主要内容

在微服务应用中设计新功能时,需要仔细而合理地划定微服务的范围。设计师需要决定何时开发一个新服务、何时扩展已有服务、如何划定服务之间的边界以及服务之间采用何种协作方式。

设计良好的服务有三大关键特性:只负责单一职责、可独立部署以及可替换。如果微服务的边界设计不合理或者粒度太小的话,这些微服务之间就会耦合得越来越紧,进而也就越来越难以独立部署和替换。紧耦合会增大变更的影响和风险。如果服务太大——承担了太多职责功能的话——这些服务的内聚性就会变差,从而导致在开发过程中出现越来越多的摩擦。

即便一开始服务的大小是合适的,工程师也需要牢记:大多数复杂软件应用的需求都是会随着时间不断变化的,早期阶段可行的方案并不意味着永远都是合适的。没有哪种设计方案是永远完美的。

在已经运行很久的应用(以及大型的工程组织)中,工程师会面临很多新的挑战。一个团队负责的服务可能依赖于许多由其他团队维护的服务,这些服务之间相互依赖,构成了一张巨大的依赖网。作为团队中的一名工程师,我们需要依靠那些可能并不受我们控制的服务设计出内聚的功能,同时还需要清楚何时将那些不再满足系统要求的服务下线和迁移出去。

在本章中,我们会引导读者使用微服务来设计一个新功能。我们会通过这个例子来探讨一些能够指导设计出具有可维护性的微服务的技术和实践。我们既可以将其应用于新的微服务应用开发,也可以将其用于那些已经运行很久的微服务应用。

4.1 SimpleBank的新功能

还记得 SimpleBank 公司吗?他们的团队做得非常棒——客户非常喜欢他们的产品!但是SimpleBank公司发现大部分客户并不想由他们自己来选择投资产品,而更愿意让SimpleBank公司来替他们做这份苦差事。那么我们研究一下,在微服务应用中找出一个解决这一问题的办法。在下面的几节中,我们会分四个阶段来完成设计。

(1)了解业务问题、用户案例和潜在的解决方案。

(2)确定服务所要支持的不同实体和业务功能。

(3)为负责这些功能的服务划定范围

(4)根据当前和未来的潜在需求验证设计方案。

这建立在我们在第2章和第3章中所探讨的一小部分服务的基础上:order服务、market gateway服务、account transaction服务、fee服务、market data服务、holding服务。

首先,我们了解一下需要解决的业务问题。在现实世界中,我们可以使用一些技巧来发现和分析业务问题,其中包括市场调研、客户访谈或者影响地图(impact mapping)。除了要了解的问题本身,我们还需要判断这是否是公司应该解决的问题。好在这不是一本关于产品管理的书——我们可以跳过这部分内容。

注意

我们不打算推出一套用于了解业务问题的通用方案,那完全可以写另一本书了。

归根结底,SimpleBank公司的客户是想要通过预付费或者定期支付的方式来投资,而且希望能够在一段时间后能看到他们财富有所增长,或者最终达到某个目标,比如攒够了购房定金。就目前而言,SimpleBank公司的客户需要自行选择如何利用自己的钱来进行投资——即便他们对投资一无所知。无知的投资者可能选择有高预期回报的资产,却没有意识到预期回报越高通常意味着风险也就越高。

为了解决这个问题,SimpleBank公司会让用户从许多预先制订好的多种投资策略(investment strategy)中选择一种,然后以客户的名义进行投资。投资策略取决于不同的资产类型(债券、股票、基金等的占比情况),这些资产的比例是根据风险水平和投资时间来设计的。客户向自己的账户充值以后,SimpleBank会自动将这笔钱按照对应的策略来进行投资。这个过程可以用图4.1来概括。

通过图4.1,我们可以确定出如下需要实现的用例:

(1)SimpleBank公司必须能够创建和更新可选的策略;

(2)客户必须能够创建账户和选择适合的投资策略;

(3)客户必须能够采用一种策略进行投资,并且按照该策略投资能够正确生成对应的订单。

c04_01{80%}

图4.1 定义投资策略并进行选择的用例图

在后面几节,我们会对这些用例进行探讨。在确定业务领域的用例时,读者可能倾向于使用一种更加结构化和详细的方案,如行为驱动开发(Behavior-Driven Development,BDD)。重要的是,读者要开始对问题形成自己的理解,并在之后用它来验证解决方案是否满足要求。

4.2 按业务能力划分

在明确了业务需求后,下一步就是确定技术解决方案:需要开发哪些功能,如何利用已有的微服务和要新开发的微服务来支持这些功能。想要开发出成功的、可维护的微服务应用,为每个微服务划定合适的范围和确定目标是至关重要的。

这个过程被称作服务划界(service scoping)。它也被称作分解(decomposition)或分区(partitioning)。将一个应用拆分为一个个的服务是非常有挑战性的工作——这既是一门科学,也是一门艺术。在下面的几节中,我们会考察以下3种服务划分的策略。

(1)按照业务能力和限界上下文(bounded context)划分——服务将对应于粒度相对粗一些但又紧密团结成一个整体的业务功能领域。

(2)按用例划分——这种服务应该是一个“动词”型,它反映了系统中将发生的动作。

(3)按易变性划分——服务会将那些未来可能发生变化的领域封装在内部。

我们没有必要孤立地应用这些方法。在许多微服务应用中,我们可以综合应用这些划分策略,以确保设计出来的服务能够适合不同的场景和需求。

4.2.1 能力和领域建模

业务能力是指组织为了创造价值和实现业务目标所做的事情。被划为业务能力一类的微服务直接反映的是业务目标。在商业软件开发中,它们的业务目标通常是系统内部主要的变革驱动力。因此,设计系统的组织结构时,将那些变化区域封装在内部是很自然的事情。到目前为止,我们已经见过了一些通过服务所实现的业务能力:订单管理、分类交易账簿、费用收取和向市场下单(图4.2)。

c04_02{80%}

图4.2 微服务提供的功能及其与SimpleBank体现的业务能力的关系

业务能力与领域驱动设计(Domain-Driven Design,DDD)的方法有着紧密的联系。领域驱动设计因埃里克·埃文斯(Eric Evans)的同名图书而得到普及。DDD专注于将现实世界领域构建为共享的、不断演进的视图和模型系统[1],其中一个最有用的概念就是Evans引入的限界上下文(bounded context)。一个领域的任何解决方案都是由若干个限界上下文组成的。每个上下文内的各个模型是高度内聚的,并且对现实世界抱有相同的认知。每个上下文之间都有明确且牢固的边界。

限界上下文是有着清晰范围和明确外部边界的内聚单元。这就使得它们很自然会成为服务范围划分的起点。在一套解决方案中的各个领域部分之间通过上下文相互划定界限。这种上下文边界通常和组织边界非常吻合。比如,电子商务公司在发货和顾客支付这两个领域上的需求并不相同,对应的开发团队也不同。

在开始的时候,一个限界上下文通常直接和一个服务以及一块业务能力领域相关联。随着业务不断发展得越来越复杂,到最后,我们可能需要将一个上下文分解为多个子功能,其中的许多子功能需要实现为一个个独立的、相互协作的服务。但是,从客户端的角度看,这个上下文仍旧可以从逻辑上展示为一个服务。

Stnd001{5%} 提示

我们在第3章介绍的API网关模式可用于在应用的不同上下文(以及底层的服务组)之间建立边界。

4.2.2 创建投资策略

我们可以按照业务能力的划分方式来设计一些服务来提供创建投资策略的功能。为了让用例更加直观,我们用线框图画出了这个功能的界面原型,如图4.3所示。

c04_03{70%}

图4.3 管理员创建投资策略的用户界面

想要按照业务能力来设计服务的话,最好从一个领域模型开始。领域模型就是对限界上下文中业务所要执行的功能以及所涉及的实体的描述。通过图4.3,读者或许已经发现它的领域模型了。一个简单的投资策略包含两部分:名称和一组按百分比分配的资产。SimpleBank公司的管理员负责创建策略。图4.4初步列出了这些实体。

c04_04{75%}

图4.4 创建投资策略功能所需要的实体组成的领域模型

这些实体的设计模型图会有助读者理解服务所要拥有和保存的数据。只有3个实体,另外,我们至少已经确定了两个新的服务:用户管理(user management)和资产信息(asset information)。用户(user)和资产(asset)实体分别属于截然不同的两个限界上下文。

(1)用户管理——它包括诸如注册、认证和授权这样的功能。在银行环境中,出于安全、法规和隐私方面的原因,为不同的资源和功能授权是受到严格管制的。

(2)资产信息——它包括与第三方市场数据提供方服务的集成,这些数据包括资产价格、类别、等级以及财务业绩等。另外,还包括用户界面上所要求的资产搜索功能(图4.3)。

有趣的是,这两个不同的领域同时反映了SimpleBank公司本身的组织结构。公司有一个专门的运营团队管理资产数据,也有专门的团队负责用户管理这方面的工作。这种相似性是值得的,因为这意味着我们的服务会如实反映现实世界中一系列的跨团队沟通。

关于服务与组织结构的关系,稍后再详细介绍。我们先回到投资策略部分。我们可以将策略和客户账户关联起来,然后用它们来生成订单。账户和订单是两个截然不同的限界上下文,但是投资策略既不属于账户上下文,也不属于订单上下文。当策略发生变化时,这个变化并不会影响账户和订单它们自己的功能。反过来,将投资策略添加到账户或者订单中任何一个服务中都会阻碍相应服务的可替代性,降低服务的内聚性,增大修改的难度。

这些因素表明投资策略是一种独立的业务能力,我们需要一个新的服务。上下文与现有功能的关系如图4.5所示。

c04_05{90%}

图4.5 SimpleBank应用中新业务功能与其他限界上下文之间的关系

大家可以注意到,有些上下文是知道其他上下文的信息的。上下文中的某些实体是共用的:它们在概念上是相同的,但是在不同的上下文中,它们与上下文的关系及其行为是各不相同的。比如,我们会以多种方式使用资产实体:策略上下文记录了不同策略的资产配置情况;订单上下文管理资产的买进和卖出;资产上下文存储诸如价格和分类这些基本的资产信息,供其他上下文使用。

图4.5所示的模型并不会告诉开发者服务的具体行为。它只会告诉开发者,这个服务包括的业务范围。现在我们已经对服务边界的设置有了更深入的了解,可以起草一份契约将服务提供给其他服务或者终端用户了。

注意

在这个阶段,不要过分担心使用什么通信技术。本章中的例子可以很轻松地应用于任何点到点的消息方式。

首先,投资策略服务需要暴露创建和获取投资策略的方法。这样,其他服务或管理后台界面就可以访问这个数据了。我们初步定义一个用来创建投资策略的url端点接口。这个端点定义采用了OpenAPI规范(以前称为Swagger),如代码清单4.1所示。OpenAPI规范是一种用于设计和编写REST API接口文档的流行技术。如果读者对它感兴趣,OpenAPI规范的Github网页是很好的入门材料。

代码清单4.1  投资策略服务的API

openapi: "3.0.0"
info:    ⇽---   API的元数据
  title: Investment Strategies
servers:
  - url: https://investment-strategies.simplebank.internal    ⇽---   
paths:
  /strategies:
    post:    ⇽---  定义“POST /strategies”路径 
      summary: Create an investment strategy
      operationId: createInvestmentStrategy
      requestBody:     ⇽---  请求的消息体是要新创建的投资策略
        description: New strategy to create
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NewInvestmentStrategy'    ⇽---  参考文档的其他位置:components 标签部分
      responses:
        '201':
          description: Created strategy
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InvestmentStrategy'    ⇽---  在components 部分定义响应类型 
components:    ⇽---  定义可以复用的数据类型  
  schemas:
    NewInvestmentStrategy:    ⇽---  创建新的投资策略所用的结构  
      required:
        - name
        - assets
      properties:
        name:
          type: string
        assets:
          type: array    ⇽---  包含一组类型为AssetAllocation的assets 
          items:
            $ref: '#/components/schemas/AssetAllocation' 
    AssetAllocation:
      required:
        - assetId
        - percentage
      properties:
        assetId:
          type: string
        percentage:
          type: number
          format: float
    InvestmentStrategy:
      allOf:
        - $ref: '#/components/schemas/NewInvestmentStrategy'    ⇽---  根据实体模型,InvestmentStrategy类型对NewInvestmentStrategy进行了扩展增加了一些字段
        - required:
          - id
          - createdByUserId
          - createdAt
          properties:
            id:
              type: integer
              format: int64
            createdByUserId:
              type: integer
              format: int64
            createdAt:
              type: string
              format: date-time

如果我们之后还要再次使用策略的话,就需要获取这些策略数据。紧跟着在代码清单4.2的paths:标签下面添加如下代码:

代码清单4.2 从投资策略服务获取策略数据

/strategies/{id}:    ⇽---  获取某个投资策略信息的url路径   
  get:
    description: Returns an investment strategy by ID
    operationId: findInvestmentStrategy    ⇽---  定义ID的格式
    parameters:
      - name: id
        in: path
        description: ID of strategy to fetch
        required: true
        schema:
          type: integer
          format: int64    ⇽---   
    responses:
      '200':
        description: investment strategy
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InvestmentStrategy'    ⇽---  返回一个投资策略

我们还应该考虑一下这个服务应该发出什么事件消息。基于事件的模型有助于解除服务之间的耦合,这可以确保我们能编排耗时较长的服务交互,而不需要显式地编配出这些交互过程。

警告

预测服务未来的使用情况是服务设计中最困难的部分。但是如果开发出的API和制订的服务间集成点足够灵活的话,就能够减少未来重新开发以及跨团队协作的需要。

比如,假设策略创建成功后会触发系统向对该策略感兴趣的潜在用户发送一封邮件通知。这个功能与Investment strategy服务本身的功能范围是无关的;Investment strategy服务并不了解客户的信息以及他们的偏好。在这种场景下,事件是非常理想的方案,如果向/strategies发送POST请求,事后触发了一个StrategyCreated事件,那么任何微服务都可以监听这个事件并执行相应的操作。图4.6完整地列出了investment strategy服务的API的范围。

c04_06{65%}

图4.6 投资策略微服务的内外通信契约

太棒了!——我们已经确定了这个用例所有需要支持的功能。为了了解这些功能是如何相互配合的,我们可以将investment strategy服务与其他在线框图中已经明确的功能进行关联(图4.7)。

c04_07{90%}

图4.7 业务能力及服务与用户界面中的功能关联起来

我们总结一下目前所做的工作:针对示例问题,确定了业务创造价值所要完成的功能以及SimpleBank公司不同的业务领域范围之间固有的边界;借助这些知识,识别出属于不同能力的实体和职责,确定了微服务应用的边界;将系统划分到一个个能够反映这些业务领域边界的服务中。

通过这种方法设计出来的服务相对稳定、有内聚性、面向业务价值并且相互之间耦合度低。

4.2.3 内嵌型上下文和服务

每个限界上下文都会为其他上下文提供API,同时将内部操作封装起来。我们以资产信息为例研究一下(图4.8)。

c04_08{75%}

图4.8 上下文暴露的对外接口以及内部嵌套的上下文

(1)它为其他上下文暴露了一些方法,比如查询和获取资产信息。

(2)SimpleBank公司的专家团队或第三方集成方负责添加资产数据。

这种私有操作和对外接口分开的方案提供了为服务演化提供了一种非常有用的途径。在系统生命周期的前期,我们可以选择开发粒度粗一些的服务来体现高层次的边界。随着时间的推移,我们可能在未来需要将服务分解,将内部嵌套的上下文中的功能开放出来。这样就能够保持服务的可代替性以及高内聚性,即使业务逻辑的复杂度越来越高。

4.2.4 挑战和不足

通过前面几节的内容,我们确定了组织机构中的业务领域之间的边界,并将其应用于服务划分中。这种方式很有效,因为它将服务直接映射到一块业务的功能结构上,直接反映了组织机构的业务运营领域。但是这种方式并不完美。

1.需要大量的业务知识

按照业务能力划分服务需要对业务或者问题领域有深入的了解。这是很困难的。如果信息了解不充分——或者做出了错误的假设的话——我们就无法百分之百确定所做的设计决策是否正确。不管是了解哪个领域的业务需求,这都是一个复杂、耗时、需要反复迭代的过程。

这并不是微服务独有的问题,但是如果对业务范围的理解出现偏差,然后又错误地将其体现到服务上,就可能导致在进行架构重构时付出更大的代价,因为将数据和功能从一个服务迁移到另一个服务内是需要耗费大量时间的。

2.粗粒度服务不断发展

同样,按业务能力进行服务划分的方式倾向于在初期阶段开发粗粒度服务——比如order、account和asset这种业务边界覆盖较大的服务。新的需求会不断拓展对应业务领域的广度和深度,相应地,服务的职责范围也会越来越广。这些新的变更理由可能与单一职责的原则相冲突。为了保持服务的内聚性和可替换性处于合理的水平,就需要进一步拆分服务。

警告

服务团队有时候会将功能添加到已有的服务中,而不是投入更多的时间来创建一个新的服务或者将现有的服务相应地再次拆分。他们的理由是这么做最省事。尽管团队有时候确实需要结合实际情况做一些务实的决定,但是他们仍需要不断鞭策自己,以尽可能地减少这种形式的技术债务。

4.3 按用例划分

到目前为止,我们介绍的这些服务都是“名词”性质的,都是面向业务领域中已有的对象和事物。还有一种进行服务划分的方式就是识别应用中的“动词”或者用例,然后开发与这些职责对应的服务。比如,电子商务网站可能会将复杂的注册流程以微服务的形式开发。这个微服务会调用诸如用户信息、欢迎通知和特殊优惠等不同的服务。

这种方式在下列场景中非常有效:功能并不明确属于特定领域,或者需要和多个业务领域交互;所要实现的用例非常复杂,将其放到其他服务中会违背单一职责的原则。

下面我们将这种方法应用到SimpleBank中,来了解它与面向“名词”的拆分方式的区别。现在准备好纸和笔!

4.3.1 按投资策略下单

客户可以将他的钱投入某个投资策略中,这样系统就会生成相应的订单,比如,如果客户投入1000美元,而这个策略指定了20%的资金要投到ABC股票,那么就会生成一个购买200美元ABC股票的订单。

这就引出了如下几个问题。

(1)假设客户是通过信用卡或者银行转账这样的外部支付方式来进行投资的,SimpleBank公司如何接收这笔投资资金呢?

(2)哪个服务负责按照策略来创建订单呢?它与已有的order服务和investment strategy服务的关系如何呢?

(3)如何跟踪这些按照策略创建的订单呢?

我们可以把这个功能放到已有的investment strategy服务中开发。但是下单的动作会不必要地扩大这个服务所包含的职责范围。同样,将这个功能放到order服务中也不合理。将所有可能的订单来源都合并到一个服务中会导致这个服务频繁地因为各种原因而被修改。

我们可以以这个用例为起点来初步制订一个独立的服务——我们将其称为PlaceStrategyOrder服务。图4.9大致描述了这个服务所要执行的流程。

c04_09{90%}

图4.9 PlaceStrategyOrder服务要执行的操作

考虑一下这个服务的输入。为了完成下单操作,这个服务需要3样东西:要下单的账号、所使用的策略以及投资的金额。我们可以采用代码清单4.3所示的形式将输入内容规范化。

代码清单4.3 PlaceStrategyOrder服务的输入信息

paths:
  /strategies/{id}/orders:    ⇽---  发布订单是投资策略资源的子资源  
    post:
      summary: Place strategy orders
      operationId: PlaceStrategyOrders
      parameters:
        - name: id
          in: path
          description: ID of strategy to order against
          required: true
          schema:
            type: integer
            format: int64
      requestBody:
        description: Details of order
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StrategyOrder'
components:
  schemas:
    StrategyOrder:
      required:
        - destinationAccountId     ⇽---      订单需要一个目标账户和投资账户
        - amount                     
      properties:
        destinationAccountId:
          type: integer
          format: int64
        amount:
          type: number
          format: decimal

这非常简洁明了,但是有点太简单了。如果所支付的资金来自于外部系统,那我们就必须等到这笔钱到账以后才能发布订单。然而,由PlaceStrategyOrders服务来处理资金的收取是不合理的。显然,这是一种截然不同的业务能力。反之,我们可以将策略订单与支付记录相关联起来,如代码清单4.4所示。

代码清单4.4 PlaceStrategyOrder 使用payment ID

components:
  schemas:
    StrategyOrder:
      required:
        - destinationAccountId
        - amount
        - paymentId    ⇽---  新增加的必填字段:paymentId
      properties:
        destinationAccountId:
        type: integer
        format: int64
      amount:
        type: number
        format: decimal
      paymentId:
        type: integer
        format: int64

这预示了一个新的服务功能的存在:支付(payment)。这一功能要支持:初始化用户的支付款项、处理与第三方支付系统交互的款项以及更新SimpleBank中的账户信息。

众所周知,支付并不是即时到账的,所以我们可以预料到这个payment服务会触发像PaymentCompleted这样的异步事件来供其他服务监听。这个支付能力如图4.10所示。

从PlaceStrategyOrder服务的角度看,如何实现这个支付功能是不重要的,只要它实现了消费方要求的接口就可以。我们可以通过一个单独的payment服务来实现,也可以用一组面向操作的服务(如CompleteBankTransfer)来实现。

以序列图的形式来总结这一设计方案,如图4.11所示。

这张图缺少了将订单提交到市场的那部分。正如前面介绍的,尽管这个PlaceStrategyOrder服务会创建订单,但是这个功能显然不属于已有的order服务。order服务会把提交订单到市场功能开放出来供其他不同的消费者使用,其中也包括这个新开发的PlaceStrategyOrder服务;尽管订单的来源各不相同,但是将它们提交到市场的流程还是一致的。

c04_10{80%}

图4.10 支付功能所提供的接口

c04_11{70%}

图4.11 利用PlaceStrategyOrder服务进行支付和投资的流程

最后,我们需要将订单与创建这些订单的策略以及投资记录的联系持久化保存下来。图4.12所示的PlaceStrategyOrder服务会负责存储它收到的任何请求——显然,它明确拥有这些数据。因此,我们应该在PlaceStrategyOrder服务中将订单ID记录下来以保留外键关联。我们也可以将订单的源ID——这个投资策略的投资请求 ID 保存到订单服务中,尽管看起来不大可能需要从这个方向来进行查询。

c04_12{65%}

图4.12 order服务会提供API供系统中其他服务消费

order服务会在订单完成以后发出OrderCompleted事件消息,而PlaceStrategyOrder服务会监听这些事件来更新整个投资请求的状态。

我们可以将order服务添加到图4.13中,将其与其他服务联系到一起。

c04_13{80%}

图4.13 PlaceStrategyOrder服务根据投资策略创建订单的完整流程

太棒了!我们现在又设计了一个新服务。不同于前面章节的内容,我们设计的这个服务准确体现了一个具体的复杂用例,而非泛泛的功能。

通过上述方式设计出来的服务只负责单一职责、可替换并且能够独立部署,这也符合我们在划分微服务时所要求的良好特性。相比之下,与关注于业务功能不同的是,聚焦于单个用例的服务未来在其他用例中复用时会受到限制。这种灵活性上的缺陷也表明细粒度的用例服务最好与那些粗粒度的服务配合使用,而不要单独使用。

4.3.2 动作和存储

我们通过上面的例子能够发现一种很有意思的模式:多个更高层的微服务共同访问一个粗粒度的底层业务功能。这在面向“动词”的方法中是很普遍的,因为不同操作的数据需求经常会有重叠。

比如,假设系统有两个操作:更新订单和取消订单。这两种操作都要对底层的同一个订单状态进行修改,所以它们都不能排他地单独拥有这个状态。在某些情况下,我们就需要对这个冲突进行调节。在前面的例子中,order服务会处理这个问题。这个服务是应用的订单持久化状态的最终拥有者。

这种模式和鲍勃·马丁(Bob Martin)在“简洁架构”[2]以及阿里斯泰尔·科克伯恩(Alistair Cockburn)的六角架构比较相似[3]。在这些模型中,应用的核心由两层组成:实体——企业范围内的业务对象和规则;用例——应用中指导实体来实现用例目标的具体操作。

在这两层外面,用接口适配器(interface adapter)来将这些业务逻辑问题与应用层的实现问题(如特指的Web框架或者数据库类库)连接起来。同样,在内部服务层面上,用例(或者动作)会与底层的实体(或者存储)相互作用,从而产生一些有益的结果。然后,我们可以将它们封装在一个统一的门面(如API网关)中,将底层的服务之间调用形式映射成一种对外部消费者友好的输出结果(如RESTful API),如图4.14所示。

从概念上看,“简洁架构”是一种优雅的架构方案,但将其应用于微服务系统时需要更加谨慎。将底层的能力视作持久化状态的存储会导致服务变成贫血的“傻瓜”服务。这种服务无法真正实现自治,因为如果没有其他更高层的服务从中对这些底层服务进行调解,这些服务就什么都做不了。这种架构方案还会增加远程调用的数量以及执行各种操作所需要的服务调用链的长度。

此方案还存在使操作与底层存储之间的耦合度增加的风险,从而妨碍独立部署服务的能力。为了避免这些缺陷,我们建议读者由内至外地设计微服务,在构建面向操作的细粒度的服务之前先开发粗粒度的功能。

c04_14{80%}

图4.14 微服务应用架构与鲍勃·马丁的“简洁架构”的对比

4.3.3 编配与编排

在第2章中,我们讨论了服务交互中编配(orchestration)和编排(choreography)之间的区别。选择编排方式有助于提高服务的灵活性、自治性和可维护性。图4.15展示了它们两者之间的区别。

c04_15{80%}

图4.15 服务交互中的编配和编排

如果根据用例来划分服务,读者可能会发现我们所编写的服务是在显式地编配其他服务的行为。这并不总是理想的:编配会增加服务之间的耦合度,并且会增加独立部署的风险;为了让业务成果更有价值,负责编配协调的服务承担了越来越多的职责,底层的服务也会因此变得“贫血”和缺乏目的性。

在设计用来体现用例的服务时,站在更广阔的职责链的范围内考虑服务的定位,这是非常重要的。比如,之前设计的PlaceStrategyOrder服务同时编排行为(下单)和响应其他服务发出的事件消息(支付处理)。在编配和编排之间实现平衡能够降低各服务缺乏自治性的风险。

4.4 按易变性划分

在理想世界中,我们可以通过组合复用已有服务来完成任何功能的开发。这可能听起来有点不切实际,但是考虑如何最大限度地提高所构建服务的复用性,从而实现长期价值,是非常有意义的。

到目前为止,我们采用的都是按功能划分服务的方法。这种方法很有效,但是也存在一些弊端。按功能分解倾向于满足应用的当前需要,并不会明确考虑应用会如何演进。纯粹地按功能划分会导致服务在面对新的需求或者需求发生变化时不够灵活,进而增大修改的风险,最终限制系统未来的发展。

因此,除了考虑系统的功能,我们还应该考虑应用未来可能发生的变化。这也被称为易变性。将很可能变化的部分封装起来,有助于确保领域内的不确定性因素不会对其他领域产生消极影响。读者会发现这与面向对象编程中的稳定依赖原则(stable dependencies principle)很类似:“包只应该依赖于比自己更加稳定的包”。

SimpleBank公司的业务领域存在许多维度的易变性。比如,向市场下单是易变的:不同类型的订单需要提交到不同的市场中;SimpleBank为每个市场要调用不同的API(比如,通过第三方代理商交互或者直接与交易中心交互);随着SimpleBank提供的金融资产的范围扩大,这些市场也可能发生变化。

将与市场交互的功能作为服务的一部分会增加系统的耦合度,并极大提高系统的不稳定性。反之,我们可以将市场服务进行拆分,最终开发多个服务来满足不同市场的需要。该方案如图4.16所示。

c04_16{90%}

图4.16 SimpleBank公司与不同的金融市场服务提供方之间的通信方式的变化封装在market服务内部,随着时间的推移,它可能变成多个不同的服务

下面我们再举一个例子。假设系统中有多种类型的投资策略。某些策略可能是采用深度学习算法优化过的,它们在市场上的业绩表现会推动其对策略配置进一步调整。

将这种复杂的功能添加到投资策略服务会极大地增加其变更的可能性,也会使内聚性降低。相反,我们应该为这个功能职责创建一个新服务,如图4.17所示。如果采用这种方式,我们就可以独立开发和发布这些服务了,而且不会和其他的功能、不同的变更节奏产生耦合。

c04_17{80%}

图4.17 将系统中某个独特的易变区域——投资策略优化——作为一个独立的服务拆分出来

从根本上讲,优秀的架构会很好地平衡系统当前和未来的需求。一方面,如果微服务的范围限定得太窄的话,大家可能在未来会发现,之前的设想对系统造成越来越大的限制,系统修改的代价也变得越来越高。另一方面,永远应该将YAGNI(you aren’t gonna need it)牢牢记在心里。大家不可能总是有那么多的时间和金钱来预测和满足应用中未来所有可能的排列组合。

4.5 按技术能力划分

到目前为止,我们所设计的服务都反映了与业务能力紧密相关的操作和实体,比如下单。这些面向业务的服务是所有微服务应用中主要开发的类型。

我们也可以设计一些反映技术能力的服务。技术能力通过支持其他微服务来间接地为业务成果做出贡献。一些常见的技术能力包括与第三方系统集成以及横向跨领域的技术问题,如发送通知。

4.5.1 发送通知

我们来看一个例子。想象一下,SimpleBank 公司想要在支付完成以后,(可能通过邮件)给客户发送一个通知。大家的第一直觉可能是在payment服务中编写代码。但是这种方式存在3个问题:第一,payment服务并不清楚客户的联系方式和偏好信息,需要扩展payment服务的接口来支持客户的联系方式信息(由服务消费方将数据传给payment服务)或者让payment服务查询其他的服务以获取客户信息;第二,应用的其他模块可能同样需要发送通知,很容易想到的功能有订单、账号设置、营销,这些功能都有可能触发邮件;第三,客户可能并不想接收电子邮件——他们可能更喜欢短信和推送通知甚至是纸质邮件。

前两点表明推送通知应该是一个单独的服务。第三点表明可能需要多个服务来分别处理不同类型的通知。图4.18对此做了概述。通知服务可以监听支付服务发送的PaymentCompleted事件。

c04_18{80%}

图4.18 技术型的通知微服务

我们可以设置一组通知服务来监听所有服务的任何需要发送通知的事件。每个通知服务都需要知道客户的联系方式以及要发送的通知内容。我们可以将这些信息存储到一个单独的服务中,比如customer服务,也可以让每个通知服务自己单独维护。这其中还隐藏着一些复杂的难题,例如,许多客户会有付款的目标账户,这就需要触发多个通知。

读者可能已经意识到,通知服务还负责根据每个事件生成适当的消息内容,这表明这些通知服务的规模将来可能随着潜在的通知数量的增加而显著增长。最终可能需要把消息内容从邮件传递中拆分出来以降低这种复杂性。

这个例子表明,实现技术能力能够最大限度地提高复用性,同时还能够简化业务型服务,解除其与重要的技术问题之间的耦合。

4.5.2 何时使用技术能力

我们应该使用技术能力来支持和简化其他微服务,降低业务能力的规模和复杂度。在以下场景中,将功能拆分出来是值得的。

(1)在面向业务的服务中包含这个功能会使得服务过于复杂、增加未来替换的复杂度。

(2)许多服务都需要的技术能力——比如,发送邮件通知。

(3)可以独立于业务能力进行修改的技术能力——比如,重要的第三方系统集成。

将这些功能封装到一个独立的服务中,就可以控制那些很可能独立变化的部分,并且可以最大化提升服务的复用性。

在另一些情况中,将技术功能拆分出来则是不明智的。在某些场景中,将功能拆分出来会降低服务的内聚性,比如,在经典的SOA中,系统通常是水平拆分的,因为人们相信将数据存储从业务功能中拆分出来会最大限度地提高可用性。这种方法的请求处理流程如图4.19所示。

c04_19{75%}

图4.19 水平拆分的服务应用创建订单的请求的生命周期

遗憾的是,这种刻意为之的可重用性的代价是非常高的。将应用按层拆分会导致部署单元之间出现严重的耦合。当要交付一个功能时,开发者需要同时修改多个应用(图4.20)。如果不得不在多个不同的组件中协调完成这些修改时,就很容易出错,而且会导致在部署阶段也需要确保一环套一环地依次执行上线——这彻彻底底就是一个分布式的单体。

c04_20{80%}

图4.20 服务按水平拆分以及按业务能力划分的影响

如果首先聚焦于业务能力,就可以避免掉入这些陷阱。但是也应该谨慎仔细地划分技术能力,以确保这些技术能力真正是自治的和独立于其他服务的。

4.6 处理不确定性

划分微服务不仅是一门科学,还是一门艺术。软件设计的很大一部分内容就是在面对不明确的状况时,找到一种有效的方法来获得最佳解决方案。

(1)对问题领域的了解可能是不完整的,甚至还有可能是错误的。了解任何业务问题的需求都是一个复杂的、耗费时间和不断迭代的过程。

(2)需要预测在未来可能需要如何使用服务,而非仅仅现在,但是也经常会遇到短期功能需求与长期服务可塑性之间的冲突。

在微服务中,如果服务划分方案没有达到最佳标准,那么代价是很高的:它会导致开发过程中出现更多的摩擦以及重构过程中工作量的进一步增加。

注意
深入了解业务领域并不只是微服务或者工程流程方法本身所单独要求的。大多数现代产品工程方法论的目标都是在逐渐深入了解需求的过程中保持灵活性和敏捷性。因此,我们强烈建议在构建微服务应用时遵循迭代和精益开发流程。

4.6.1 从粗粒度服务开始

在一些复杂场景中,可能并没有一些显而易见且正确的服务拆分方案。在本节中,我们将探讨一些方法来帮助读者做出实用的决策。在前面,我们已经讨论了保持服务职责的集中、内聚以及受控的重要性,所以下面这句话可能听起来有些违反直觉。有时候,当不太确定服务的边界时,最好构建大一些的服务。

如果错误地开发了一堆特别小的服务,则可能导致本应合并到一个服务中的那些服务之间耦合得太紧。这表明,业务功能分解得过头了,这会导致每个服务的责任不够明确,并且对这块功能的重构也会变得更加困难且代价昂贵。

反之,如果将那些功能组合成一个大一些的服务,就可以降低未来重构的成本,同时还能避免“跨服务依赖”这一棘手的问题。同样,在微服务应用中,代价最大的就是修改公共接口。缩小组件之间接口的范围有助于保持组件的灵活性,尤其在开发的早期阶段。

要明白,服务变大也会产生成本,因为服务越大越难以修改和代替。但是在项目前期,服务一般是比较小的,和拆分过细引入的复杂性的代价相比,和服务体量过大相关的成本是比较低的。开发者需要认真观察服务的大小和复杂度,以确保没有开发更多的单体服务。

在这种情况下,运用精益软件开发的关键原则是非常有帮助的:尽可能晚一些做决定。因为不管在开发实现阶段还是运维阶段,构建一个服务都是需要成本的,所以在遇到不确定的场景时,避免过早分解可以为开发者省下时间来完善对问题领域的了解。这样也能够确保随着应用的发展,对应用架构做出的决定都是合理的。

4.6.2 准备进一步分解

本章前面介绍的建模和范围划分技术可以帮助读者确定服务是否变得过大。通常,在服务生命周期的早期,开发者就能够发现那些潜在的内部领域边界和连接区了。如果可以的话,开发者在设计服务的内部结构时要尽量把这些内部的边界和连接区体现出来——可以通过设计不同的类和命名空间来实现,也可以通过封装单独的库来实现。

通过清晰的公共API(通常是良好的软件设计)来规范内部模块的边界。在微服务中,降低代码的耦合度以及保持代码的条理性和可读性可以降低未来重构的成本。即便如此,也要小心——代码库上下文中设计良好的API可能并不总是理想的微服务接口。

4.6.3 下线和迁移

我们已经讨论了未来服务拆分的计划,但是还应该讨论一下服务下线的问题。微服务开发需要适当地残忍一些。要牢记,重要的是应用程序,而不是代码。随着时间的推移——特别是开发者从一个比较大的服务开始开发时——慢慢会发现有必要从现有的服务中剥离出新的微服务或者将微服务完全下线。

这个过程可能很难。最重要的是,开发者需要确保不会影响服务的消费者,并且确保将服务及时迁移到替代服务上。

要剥离出新的服务,我们会采用“扩展-迁移-收缩”的模式。设想一下,我们正在从订单订单服务中剥离出一个新的服务。在最初开发订单服务时,我们非常自信,相信订单服务能够满足所有订单类型的需求,因此将其构造为单个服务。但是有一种订单类型被证明和其他订单类型不同,而且为了支持这个订单类型,订单服务后来变得特别臃肿。

首先,我们需要扩展——将目标功能添加到新的服务中(图4.21);其次,需要将旧服务的使用者迁移到新服务上(图4.22)。如果使用方是通过API网关来访问服务的,那么开发者需要将相应的请求重定向到新服务上。

如果其他服务调用了订单服务,那么开发者需要迁移这些调用。但是,仅仅通知其他团队,让他们迁移并不总是管用的,因为这些团队有着自己的需求优先级、发布周期和风险管理。相反,我们需要保证新服务是有吸引力的——要么让人们有意愿投入精力完成迁移,要么替这些团队完成迁移工作。

c04_21{80%}

图4.21 功能从现有服务扩展到新的服务中

c04_22{70%}

图4.22 现有消费方迁移到新的服务

要完成这个过程,还有最后一步。最后,我们可以收缩最初的服务,删除过时的代码(图4.23)。

c04_23{50%}

图4.23 在服务迁移的最终状态中,收缩服务以删除已经属于新服务的功能

太棒了,我们做到了!通过一步步的谨慎操作,我们有条不紊地下线了原有的功能,同时没有对现有服务的消费者造成破坏的风险。

4.7 组织中的服务所有权

截至目前,我们的例子大部分都有一个假设前提,那就是只有一个团队负责开发和修改微服务。在大型的组织结构中,不同的团队会维护不同的微服务。这并不是一件坏事——这是工程团队规模化扩展很重要的一部分内容。

正如我们前面指出的那样,在将应用的所有权拆分给组织机构中不同的团队时,限界上下文是一种非常有效的方式。如果组建的团队拥有特定的限界上下文中的服务,那么可以利用一下逆-康威法则:如果系统体现了创建它们的组织的结构,就可以通过先塑造组织的结构和职责来得到所期望的系统架构。SimpleBank公司就是围绕已经确定的服务和限界上下文来组建工程团队,如图4.24所示。

{75%}

图4.24 随着SimpleBank公司的工程组织的不断发展壮大,不同工程团队的服务和能力归属模型

将服务的所有权和交付分到不同团队有如下三方面的影响。

(1)控制变弱——可能无法全面控制所依赖服务的接口形式和性能,比如,对于提交投资策略订单的PlaceStrategyOrder服务而言,支付服务是至关重要的,但是图4.24中的团队模型意味着负责这一服务的是另一个团队。

(2)设计受限——消费者的需求会限制服务的契约——需要确保对服务的修改不会影响现有的消费者。同样,其他已有服务可能提供的功能也会限制我们潜在的设计方案。

(3)开发速度不一致——由于不同团队的规模、效率和工作优先级各不相同,因此隶属于这些团队的服务的修改和演进的节奏也会各有差异。投资团队向客户团队提了一个需求,而这个需求可能在客户团队的优先级列表中级别并不高。

这些影响可能带来巨大挑战,但应用如下一些策略是有帮助的。

(1)开放化——保证所有工程师可以查看和修改所有代码,这虽然降低了防护能力,但能够帮助不同的团队互相了解对方的工作,还可以减少阻碍。

(2)接口明确化——为服务提供明确的、文档化的接口,能够降低沟通成本,并能够提高应用整体的质量。

(3)不要太担心DRY(don’t repeat yourself)——微服务方案更偏向于交付节奏,而非效率。虽然工程师期望践行DRY,但应该能预料到,在微服务方案中会存在一些重复的工作。

(4)明确的期望——团队应该对生产环境的服务的性能、可用性和特性设定明确的期望。

这些策略关系到微服务中“人”的一面。这是一个很宏大的主题,我们会在本书的最后一章中作深入探讨。

4.8 小结

(1)了解业务问题——识别实体和用例——划分服务责任,我们可以通过这一流程来划定服务范围。

(2)可以采用不同的方式来对服务进行划分:按业务功能划分、按用例划分和按易变性划分。读者可以综合运用这些方法。

(3)好的划分决策能够让服务满足微服务的三大关键特性:只负责单一职责、可替换和可独立部署。

(4)限界上下文通常是与服务边界相对应的,在思考服务的未来发展时,这是一种很有效的方法。

(5)通过对易变领域的深入思考,开发者可以将那些会一起变化的领域封装起来,以提高应对未来变化的适应能力。

(6)如果服务划分不好,后期修正的代价是特别大的,因为到那时,开发者需要重构多个代码库,由此产生的工作量会变得特别大。

(7)我们也可以将技术功能封装成一个服务,这么做既能够简化业务能力,又可以对业务功能提供支撑,并能最大限度地提高服务的可用性。

(8)如果服务边界还不够明确,我们宁可选择粗粒度的服务,但是要主动在服务内部采用模块化的方案来为未来的拆分做准备。

(9)服务下线是一件特别有挑战性的工作,但是随着微服务应用的不断发展,我们未来终有一天会需要这么做。

(10)在大型组织机构中,将所有权拆分到多个团队中是很有必要的,但是这又会引入新的问题:控制变弱、设计受限、开发速度不一致。

(11)代码开放化、接口明确化、沟通持续化以及放宽对DRY原则的要求都可以缓解团队之间的紧张关系。


[1] 尽管Evans介绍的许多实现模式——存储库(repository)模式\聚合(aggregate)模式和工厂(factory)模式——都是特定于面向对象编程的,但其中还有许多分析技术(如通用语言)是适用于任何编程范式的。

[2] 有关“简洁架构”的更详细解释,请参阅鲍勃·马丁于2012年8月13日发表的The Clean Architecture

[3] 它们并不完全相同:马丁的架构设计关注于面向对象应用中的实现细节的独立性(例如,保证业务逻辑独立于数据存储解决方案),这在内部服务层面上并不特别相关。

本文摘自:《微服务实战》

《微服务实战》主要介绍如何开发和维护基于微服务的应用。本书源自作者从日常开发中得到的积累和感悟,其中给出的案例覆盖从微服务设计到部署的各个阶段,能够带给你真实的沉浸式体验。通过阅读本书,你不仅能够了解用微服务搭建高效的持续交付流水线的方法,还能够运用Kubernetes、Docker 以及Google Container Engine 进一步探索书中的示例。