« 微服务架构设计模式 » 学习笔记

  • microservice
  • sysdesign

posted on 01 May 2023 under category note

书评: http://unifreak.github.io/book_review/microservice_patterns

架构

“架构” 关注软件各组成部分和它们之间的依赖关系, 它的重要性在于它影响了应用的非功能性需求 (质量或能力). 如应用在运行时的质量属性: 可扩展性, 可靠性; 以及在开发阶段的质量属性: 可维护性, 可测试性, 可部署性.

软件架构的 4+1 视图模型: 每个视图都包括一些特定的软件元素和它们相互之间的关系, +1 是指场景, 它 描述在一个视图中多个元素如何协作以完成一个请求.

特定的 “架构风格” 提供了有限的元素和关系, 可以从中定义架构的视图. 软件通常使用多种风格的组合.

  • “分层架构”, 如 MVC. 其弊端在于: 单个表现层和数据持久层, 无法反应现实中应用可以被多个系统调用 以及它也可能与多个数据库交互的事实. 而且将业务层定义为依赖于数据持久化层, 这样的依赖妨碍我们 在没有数据库的情况下进行测试.

  • “六边形架构” 作为分层架构的替代品, 它的关键特性和优点是业务逻辑不依赖于适配器, 而是适配器依赖 于业务逻辑. 业务逻辑有一或多个 “端口 (port)”, 端口定义了一组关于业务逻辑如何与外部交互的操作. 外部通过 “入站适配器” 调用业务逻辑的入站端口, 出站适配器则实现出站端口, 通过调用外部应用或服 务处理来自业务逻辑的请求. 用这种方式将业务逻辑与适配器中包含的表示层和数据访问层的逻辑分离开. 六边形架构非常适合用于描述微服务架构中每个服务. “

  • “微服务” 也是一种架构风格, 其组件是服务, 连接器则是服务间的通信协议. 每个服务都有自己的逻 辑视图架构, 通常也是六边形架构.

扩展立方体:

  • X 轴: 通过扩充实例, 提高吞吐量可用性
  • Y 轴: 功能性分解为不同的 “服务”, 应对复杂性
  • Z 轴: 数据分片, 应对增加的事务和数据量

微服务

“微服务架构”: 把应用功能性分解 (Y 轴) 为一组服务的架构风格, 每一个服务都是一组专注, 内聚的功 能职责组成. 它的关键特性是每一个服务之间都是松耦合的, 仅通过 API 进行通信, 为实现这种松耦合, 每个服务都拥有自己的私有数据库.

微服务 VS SOA

                SOA                     微服务
----------------------------------------------------------------------------
服务间通信       智能管道, 如 ESB          哑管道, 如消息代理
               重量级协议, 如 SOAP        轻量协议, 如 REST, gRPC
数据管理         全局数据模型, 共享数据库    每个服务有自己的数据模型和数据库
典型服务的规模    较大的单体应用             较小的服务
约束            强约束, 各种规范标准        "实践标准", 无统一方案, 架构能力要求高

微服务 9 个核心的业务与技术特征:

  1. 围绕业务能力构建. 康威定律, 团队与产品拥有一致的结构.
  2. 分散治理: 开发团队直接对服务负责, 团队有选择异构技术的权力.
  3. 通过服务实现独立自治的组件.
  4. 产品化思维.
  5. 数据去中心化.
  6. 强终端弱管道.
  7. 容错性设计. 接受并应对服务总会出错的现实.
  8. 演进式设计.
  9. 基础设施自动化.

微服务架构的优点如下:

  • 持续交付和持续部署
  • 可维护性: 每个服务都较小
  • 可部署性: 服务可以独立布署
  • 可扩展性: 服务可以独立扩展
  • 加速开发: 实现团队自治
  • 技术栈独立性: 更易实验和采纳新技术
  • 容错性: 故障隔离

它主要的缺点是, 分布式带来的复杂性:

  • 进程间通信的方式更复杂, 远程服务可能不可用或高延迟
  • 数据一致性
  • 运维复杂性, 需要高度自动化的基础设施
  • 更谨慎地作好团队沟通和协作.
  • 对架构能力提出更高要求. 架构者的第一职责就是决策权衡, 如果架构者本身知识面不足以覆盖所需要决 策的内容, 不清楚其中利弊, 难免要陷入选择困难.

模式语言

概览

服务拆分模式

服务通信模式

事务和数据一致性模式

数据查询模式

部署模式

可观测模式

  • 健康检查 API
  • 日志聚合
  • 分布式追踪
  • 异常跟踪
  • 应用指标
  • 审计日志

自动化测试模式

  • 消费者驱动的契约测试
  • 消费端契约测试
  • 服务组件测试

解决基础设施和边界问题的模式

  • 外部化配置
  • 微服务基底
  • 服务网格

安全相关模式

  • 访问令牌

拆分服务

“服务” 是一个单一的, 可独立布署的软件组件. 它的 API 由命令, 查询和事件组成. “操作适配器” 调用 业务逻辑, 实现命令和查询; “事件适配器” 对外发布业务逻辑产生的事件.

服务的大小并不重要, 重要的是它们之间是松耦合的. 如果你因为其它服务的变更需要不断变更自己负责 的服务, 或者自己服务的变更会触发其他服务同步变更, 这表明它们没有实现松耦合, 你构建的甚至可能是 一个分布式的单体.

定义微服务的步骤

这是一个不断迭代的过程. 每次迭代可用以下的三步流程来指导:

  1. 识加系统操作

“系统操作” 是应用必须处理的请求的一种抽象描述. 这一步的是将应用的需求提炼为各种关键请求. 系统操 作可分为命令型和查询型.

这是一个两步流程:

  • 先通过用户故事中的名词, 创建由关键类组成的领域模型, 这些关键类提供用于描述系统操作的词汇表.
  • 然后分析用户故事和场景中的动词, 将其映射为系统命令或查询型操作. 命令包括其参数, 返回值, 和领 域模型类的行为, 行为包括前置条件和后置条件.
  1. 拆分服务

有两种策略来划分各个服务. 传统策略是根据业务能力划分. “业务能力” 指通为公司产生价值的商 业活动. 我们通过对公司的目标, 结构和商业流程识别出有哪些业务能力, 并将每种能力认为是一个服务. 这种方式好处是, 因为业务能力是稳定的, 架构也会是稳定的. 但挑战在于, 这种方法中的建模方式一往往 会为整个公司建立一个单独的模型, 这导致:

  • 要求所有团队对全局单一的建模和术语达成一致很因难
  • 某些实体的定义对于特定团队所需的来说, 可能过于复杂
  • 不同团队对同一个概念可能使用不同术语, 或对不同概念使用相同术语, 容易造成混乱

为了避免这些限制, 另一种策略是使用 DDD (领域驱动开发) 为每个子域定义单独的领域模型. See ddd.md. DDD 的子域和限界上下文的概念能很好的匹配微服务中的服务.

  1. 定义服务的 API

先将已经确定的的系统操作分配给各服务. 我们应尽可能将操作分配给需要操作所提供信息的服务, 其次是分 配给具有处理它所需信息的服务.

还需确定用于支持服务协作所需的 API, 为此会识别出为完成每个系统操作时, 相关的协作者的 API.

进程间通信

进程间通信机制影响应用的可用性, 我们必须处理 “局部失效” 问题. 它也与事务管理相互影响.

选择进程间通信机制, 可以从以下几个角度考量:

  • 交互方式

根据客户端和服务的交互方式, 可以从两个维度分为以下几类. 交互方式影响应用的可用性.

        一对一                         一对多
-------------------------------------------------------------------
同步     请求/响应                     —
异步     异步请求/响应                 发布/订阅 (不期待响应)
        单向通知 (不期待响应)           发布/异步响应
  • 为应对 API 的演化, 可以使用 Semvers 管理版本. 并在 API 网关中进行版本协商和转发.

  • 消息的格式. 格式的选择影响通信效率, 可用性和可演化性.

文本 vs 二进制.

        文本                          二进制
---------------------------------------------------------------------------
如       JSON, XML                   Protobuf, Avro
---------------------------------------------------------------------------
Pro     可读性高, 自描述
        后向兼容性
---------------------------------------------------------------------------
Con     过度冗长造成的传输开销
        解析文本的开销
        性能差

同步通信

同步通信中, 常见有 REST 和 gRPC 两种通信协议. 后面也会讲 GraphQL 用于灵活高效的数据提取.

使用 REST (see restful.md) 的问题:

  • 如何在一个请求中获取多个资源, 一般可用类似 expand URL 参数指定相关资源, 但对于复杂场景, 这 通常不够, 且实现它可能很耗时.
  • 有些操作难以映射到 HTTP 动词, 如订单更新可能有取消和修改两种. 一般通过添加一个 “子资源” 来 解决, 如 POST /orders/{id}/cancel 端点; 另一种方法是将操作指定为 URL 参数. 这两种方 式都不是很符合 RESTful 的要求.

gRPC 使用 Protobuf 作为消息格式, 同时支持流式 RPC. 但它与 REST 一样是一种同步通信机制, 也 存在局部故障问题.

REST vs gRPC

        REST                                gRPC
---------------------------------------------------------------------------
Pro     简单, 开发者熟悉                      设计复杂更新操作的 API 很简单
        人类易读, 易于调试                    高效,紧凑, 尤其在交换大量消息时
        防火墙友好, 无需中间代理, 简化系统架构    支持双向流式消息
                                            实现了客户端和用各种语言写的服务端之间的
                                            互操作性
---------------------------------------------------------------------------
Con     只支持请求/响应方式通信                JS 客户端需要做更多工作
        + See 文本格式                       旧式防火墙可能不支持 HTTP/2
        + 上述两个问题的挑战

局部故障

在分布式环境, 使用同步方式请求服务, 必须处理服务不可用的 “局部故障” 问题, 防止故障的传导扩散.

远程过程调用代理必须能够正确处理无响应的请求:

  • 设定一个超时时间, 不要在无响应的请求上一直浪费时间.
  • 设定重试次数上限值.
  • “断路器模式”: 监控成功和失败数量, 如果失败比例超过一定阈值, 就启动断路器, 让后续调用立即失效; 经过一定时间后继续尝试, 如果成功就解除断路器.

还必须决定如何从失败中恢复, 这要视数据的重要程度而定:

  • 直接返回错误
  • 如果情况允许, 可以返回备用值, 如缓存的响应或者默认值

服务发现

服务发现的关键组件是 “服务注册表”, 它是一个存储服务实例的网络位置信息的数据库.

主要有两种实现服务发现的方式:

  • 应用层服务发现: 指服务及其客户端直接与服务注册表交互. 它是 “自注册模式”(服务实例向注册表注册 自己) 和 “客户端发现模式”(客户端从注册表检索可用服务实例列表并进行负载均衡) 的结合.

  • 平台层服务发现: 通过布署基础设施处理服务发现. 它是 “第三方注册模式” (这里的第三方一般是部署 平台的注册服务器) 和 “服务端发现模式” (客户端向路由器发出请求, 路由器负责服务发现) 的结合.

两者的优劣对比如下表, 建议优先使用平台(如 k8s)提供的服务发现.

        应用层                              服务层
---------------------------------------------------------------------------
Pro     与布署平台无关, 可以处理多平台布署问题   平台自动处理, 应用不用任何服务发现逻辑
---------------------------------------------------------------------------
Con     必须为每种编程语言和框架提供库          仅限于支持该平台布署的服务
        开发者负责设置和管理注册表

基于消息的异步通信

See kafka.md.

基于消息机制的应用通常使用 “消息代理” 充当服务之间的中介; “无代理架构” 则通过直接向服务发送 消息. 两者对比如下表:

     无代理                            基于代理
---------------------------------------------------------------------------
工具  ZeroMQ                           ActiveMQ, RabbitMQ, Kafka...
---------------------------------------------------------------------------
Pro  因为无代理, 更少网络流量和延迟        发送和接收方松耦合
     没有消息代理的单点性能或故障问题       消息在处理前一直缓存, 提高可用性
     较低的操作复杂性                    灵活, 可以用来实现*所有*交互方式
---------------------------------------------------------------------------
Con  要求发送和接收方必须同时在线, 可用性低 潜在的单点性能和故障, 不过大多数现代代理都是
     需要知道对方位置 (服务发现机制)       高可用的.
     实现如可靠投递等复杂功能时挑战大       操作复杂性

消息通道可分为以下两种:

  • “点对点通道”: 服务通常用它发送命令式消息, 实现一对一交互.
  • “发布-订阅通道”: 服务通常用它发送事件式消息, 实现一对多交互.

重复消息

满足幂等的消息处理程序可以被放心的执行多次, 如果处理程序本身不是幂等的, 可以通过检测和丢弃重 复消息使之成为幂等的.

事务性消息的可靠发送

更新数据库和发送消息这两个操作经常需要被原子的完成, 以避免不一致状态. 如果不能作到把这两个操作 放在同一个数据库事务中, 可以使用 “事务性发件箱模式”:

  • 对于关系数据库, 使用数据库表作为临时消息队列, 作为更新数据库的本地事务的一部分, 服务将消息 插入到发件箱数据表中. 然后 “消息中继 (Message relay)” 读取发件箱并发送消息.

  • 对于 NoSQL 数据库, 由于其事务保证较弱, 可以为实体附加一个用于保存 “待发送消息列表” 的属性, 当更新实体时也原子的更新此消息列表. 挑战在于如何高效查找这些需发送消息的实体.

为了发布发件箱中的事件, 一种方法是通过 “轮询模式”, 轮询发件箱中未发布的消息并发送给消息代理, 这 在小规模下运行良好, 但会对数据库造成昂贵开销. 最好用 “事务日志拖尾模式”, 如下图:

这个模式的挑战在于需要做一些开发工作, 但有一些工具可以参考, 如:

  • Debezium 可以向 Kafka 发布数据库更改.
  • Databus 用于挖掘 Oracle 事务日志并发布为事件.
  • DynamoDB streams
  • Eventuate Tram 使用 MySQL binlog, Postgres WAL 或轮询, 发布到 Kafka.

使用 Saga 管理事务

在多个服务、数据库和消息代理间维持数据一致性的方式是 “分布式事务”. 如 XA 采用了 “两阶段提交 (2PC)” 来保证事务参与方同时完成提交, 但它要求整个技术栈满足 XA 标准. 另外, 分布式事务本质 上是同步通信, 会降低系统可用性, 为了让事务完成, 所有参与方都必须可用. 基于这两点, 分布式事务并 不适合微服务架构.

更好的方式是使用 “Saga 模式”. 一个 Saga 代表需要更新多个服务中数据的一个系统操作. 它由一系列 本地事务组成, 每个本地事务负责更新它的在服务的私有数据库. 系统操作启动 Saga 的第一步, 完成本 地事务的服务会发布一条消息, 以触发下一个本地事务的执行.

与传统 ACID 事务不同的是, Saga 无法自动回滚, 我们必须使用 “补偿事务”. 假设第 n+1 个事务失败 了, 必须撤消前 n 个事务的影响. 每个事务 Tᵢ 都有相应的补偿事务 Cᵢ, 顺序为 T₁…Tₙ,Cₙ…C₁. 并非 所有步骤都需要补偿事务.

如何协调每个服务完成一个 Saga, 可有两种方式: 协同式以及编排式.

协同式 (choreography)

由每个参与方通过订阅和交换事件来协调.

编排式 (orchestration)

由一个 “sage 编排器” 集中进行协调, 编排器使用命令/异步响应方式与参与方通信. 通过向参与方发 出一个命令, 告诉它该做什么操作, 参与方完成后给编排器发送一个答复消息, 编排器处理这个消息并决定 saga 下一步操作是什么.

图中最后一步, 虽然可以直接更新 Order, 但为保持一致, Saga 还是将 Order Service 视为另一个 参与方并向其发送命令.

为更轻松的设计, 实现和测试 Saga, 最好把编排器建模为状态机. 如下图:

两者对比如下表:

        协同式                     编排式
---------------------------------------------------------------------------
Pro     实现简单                   更简单的依赖关系, 不会出现循环依赖
                                  协调逻辑集中于编排器, 领域对象更简单
---------------------------------------------------------------------------
Con     逻辑分布在各服务中, 难理解    编排器存在集中过多业务逻辑的风险, 它应该*只负责排序*,
        服务之间可能出现循环依赖      不应包含任何其他业务逻辑
---------------------------------------------------------------------------
For     简单的 Saga                除最简单情况, 都建议使用

无论使用哪种方式, 都要确保每个参与者的业务操作和消息发送操作应在一个本地事务, See #事务性消息.

隔离问题

See acm#284472.284478.

Saga 缺乏 ACID 事务中的隔离属性, 因为一旦其中的本地事务提交, 它所作的更新都会被其他 Saga 看 到. 这导致以下问题:

  • 丢失更新: 一个 Saga 的更新被另一个 Saga 覆盖.
  • 脏读: 一个 Saga 读取了尚未完成的另一个 Saga 的更新.
  • 不可重复读: 一个 Saga 中两个不同步骤读取相同的数据却得到不同的结果.

这些问题可能在业务上导致超卖等不可接受的风险.

有几种对策可以帮助我们应对隔离问题. 我们可将 Saga 中的事务分为三种类型: 可补偿性事务, 关键性事 务, 和可重复性事务. 如图:

每种事务在对策中扮演着不同角色, 可补偿性事务和可重复性事务之间的区别尤其重要 (注意这两种事务都 无需补偿事务).

  1. 语义锁 (semantic lock)

    可补偿性事务在其创建或更新的任何记录中设置 “标志”, 如 *_PENDING, 表示该记录未提交. 这个标记可以是阻止其他事务访问的锁, 或者只是一个警告. 标志会被一个可重复事务或通过补偿 事务清除, 分别代表 Saga 成功完成或发生了回滚.

    还需考虑另一个事务如何处理被锁定的记录, 如 cancelOrder() 这个系统命令如果遇到一个处于 APPROVAL_PENDING 状态的订单, 它应该怎么做:

    • 锁外的事务直接失败并告诉客户端稍后再试, 这种方法易于实现, 但客户端必须实现重试逻辑.
    • 阻塞锁外事务直到语义锁被释放, 这种方法把更新相同记录的 Saga 序列化, 相当于重新实现了 ACID 的隔离属性, 缺点是应用必须管理锁, 还必须实现死锁检测算法.
  2. 交换式更新 (commutative updates)

    “可交换” 意即更新操作被设计为可以按任何顺序执行. 这样可以避免丢失更新.

  3. 悲观视图 (pessimistic view)

    重新排序 Saga 步骤以最大限度降低由于脏读而导致的业务风险. 如把风险最大的操作放进可重复 事务中.

  4. 重读值 (reread value)

    乐观离线锁的一种形式, 用于防止丢失更新. Saga 在更新前重读记录的值, 如果未更改则更新, 否则中止操作.

  5. 版本文件 (version file)

    这是将不可交换操作转换为可交换的一种方法. Saga 记录对数据执行的操作, 以便对它们重新排序.

  6. 业务风险评级 (by value)

    使用分布式事务执行高风险请求, 使用 Saga 执行低风险请求.

业务逻辑

组织业务逻辑主要有两种方式: 面向过程的事务脚本和面向对象的领域建模模式.

“事务脚本”: 将业务逻辑组织为面向过程的事务脚本的集合, 每种类型的请求都有一个脚本. 如下图例中, 每 个服务类都有一个用于请求或系统操作的方法, 这个方法实现该请求的业务逻辑. 它使用数据访问对象 (DAO) 访问数据库, 数据对象是纯数据, 几乎没有行为.

除非应用非常简单, 否则我们应该使用领域模型模式, 进行面向对象的设计.

“领域模型”: 将业务逻辑组织为由具有状态和行为的类构成的对象模型. 与事务脚本一样, 服务类具有针对 每个请求或系统操作的方法, 但是服务方法通常很简单, 它几乎总是调用持久化领域对象, 这些对象中包含 大量业务逻辑.

使用聚合解决模糊边界的问题

传统的领域模型是一个由互相关联的类构成的网络. 它没有明确的边界, 这会带来一些问题, 比如如下的领 域模型中:

  • 加载或删除一个 Order 对象意味着什么? 要包含与 Order 相关的 OrderLineItem 和 PaymentInfo 吗? 操作边界是什么?
  • 更新业务对象时, 如果直接更新对象的一部分, 可能会导致违反业务规则.

See ddd.md.

我们可以通过使用 DDD 中的聚合, 来明确边界. 我们使用聚合, 将领域模型分解为块, 操作是针对整个聚 合的:

  • 聚合通常从数据库中完整加载, 避免延迟加载所导致的任何复杂性.
  • 删除聚合会从数据库中删除其所有对象.
  • 在聚合根上调用更新操作会强制执行各种不变量约束, 可以使用版本号或数据库锁锁定聚合根来处理并 发性. 但注意, 这意味着需要在数据库中更新整个聚合.

为了保证聚合是一个可以强制执行各种不变量约束的自包含单元, 使用聚合须遵守如下规则:

  • 聚合根是聚合中唯一可以由外部类引用的部分, 客户端只能通过调用聚合根上的方法来更新聚合.
  • 必须是通过使用主键实现聚合间的引用 (当然, 聚合内部可以互相引用). 虽然传统上外键被视为不好的设 计, 但使用主键(或其它标识)可以避免出现跨服务的对象引用问题, 也保证聚合间是松耦合的. 另外, 因为聚合也是存储的单元, 这样作还能让持久化变得简单.
  • 在一个事务中, 只能创建或更新一个聚合 (除非你的数据库支持复杂的事务模型). 这样可以确保事务 范围不超越服务的边界, 还能满足 NoSQL 数据库受限的事务模型. 但它也让创建或更新多个聚合变得复 杂, 而这正是 Saga 旨在解决的问题. See #saga.

领域事件

“领域事件” 是发生在聚合上的事件. 通过有聚合被创建或发生其它重大更改时发布领域事件, 可以实现如下 功能:

  • 使用基于编排的 Saga 维护服务间数据一致性.
  • 通知维护副本的服务, 源数据已经更改.
  • 通过 Webhook 或消息代理通知不同的应用程序, 以触发下一步业务流程.
  • 按顺序通知同一应用程序的不同组件. 如将 Websocket 消息发送到浏览器或更新 ES 等.
  • 向用户发送短信或 email 通知.

领域事件应该由谁来发布? 有三种选择:

  1. 概念上讲, 应该由聚合发布. 但因为聚合不能使用依赖注入, 需要将消息传递 API 作为方法参数传入, 这会把消息传递的基础设施和业务逻辑交织在一起, 不可取.
  2. 调用聚合的服务可以使用依赖注入获取消息传递 API, 轻松发布事件. 聚合通过将事件返回给服务让它代发.
  3. 聚合根在内部字段中积累保存事件, 服务检索这些事件并发布.

另外, 领域事件也需要被可靠的发布, 参见 #事务性消息.

事件溯源

传统上的持久化技术是将类映射到数据表, 可以用 ORM 来实现. 这种方式的弊端在于:

  • 对象与关系的 “阻抗失调”.
  • 缺乏聚合的历史状态, 如果有审记需要, 开发审计将非常烦琐且容易出错 (可能审计相关逻辑偏离业务逻辑).

如之前所讲, 如果我们将业务逻辑设计为一组可以对外发布领域事件 DDD 的聚合, 这是在微服务中同步数 据和发送通知的有效机制, 但也会带来如下问题:

  • 事件发布逻辑被固化在业务逻辑中.
  • 开发人员可能忘记发布事件, 但业务逻辑依然会执行.
  • 对于历史状态和审计相关需求, 也会有相同的开发负担.

“事件溯源” 则以事件为中心, 是构建业务逻辑和持久化聚合的另外一种选择. 它使用一系列表示状态更改的 领域事件来持久化聚合, 通过重放事件来重新创建聚何的当前状态.

业务逻辑

事件不再是可有可无的, 当聚合发生状态变化时它必须发出一个事件, 而且事件必须包含从原状态转化 为新状态的必需数据.

业务逻辑也需要重构, 以适应 “重放” 需求. 聚合根上原先的命令方法需要重构成多个方法:

  • 一个方法接收 “命令”, 并在不更改聚合状态的情况下, 返回事件列表.
  • 其它方法都将特定事件作为参数来更新聚合. 执行这些不应该出现失败, 因为每个事件代表的是已经 发生的状态变化.

如下图, 每个方法都被 process() 方法和一或多个 apply() 方法所取代:

事件存储库 (Event Store)

事件被保存在 “事件存储库” 中. 对于关系型数据库来说, 如下图中的 EVENTS 表:

  • 当创建或更新聚合时, 聚合发生的事件被插入到 EVENTS 表中.
  • 当加载聚合时, 从中检索并重放.

除了和事件溯源持久化聚合, 它也可以用作可靠的事件发布机制. 这样的话, 事件存储库将表现为数据库和 消息代理的结合: 它具用于通过主键插入和检索事件的 API, 也有用于订阅事件的 API.

为了发布事件, 可以参考 “事务性发件箱模式” (把 EVENT 表看作发件箱, 只不过是永久的, 发完不会删 除): 使用轮询, 通过一个额外的列, 跟踪哪些事件还未发布; 使用事务日志拖尾. 如下图, 是 Eventuate Local 的实现:

events 表存储事件, 其中 triggering_event 是生成此事件的事件, 它用于检测重复事件, 实现幂等 处理.

entities 表存储实体的当前版本, 每个实体一行. 它通过比对版本号实现乐观锁, 以处理并发更新. 当创建实体时, 插入一行; 当更新实体时, 更新 entity_version 列. 如下:

UPDATE entities SET entity_version=?
    WHERE entity_type=? AND entity_id=? and entity_version=?

snapshots 存储每个实体的快照, 以应对长生命周期 (如 User) 的聚合包含大量事件时, 重放过于低效 的问题. 如果聚合的结构很简单且易于序列化, 可以使用 JSON 序列化; 复杂结构的聚合, 可以使用 Memento模式进行快照. 加载聚合时, 先从快照创建创建聚合实例, 然后遍历应用仅快照之后的事件.

“事件中继” 通过事件务日志拖尾方式 (如将自己作为从服务器连接到 MySQL, 读取 binlog) 同步更新. 对于 events 表的插入操作, 它将发布相应事件到消息代理 (如 Kafka), 并忽略任何其它类型的更改. 事件中继布署为独立进程.

事件演化

事件结构可能会发生变化, 因此应用可能需要处理多个事件版本. 幸运的是, 大部分结构变更是后向兼容的, 如下表:

层级      变更              后向兼容?
---------------------------------------
聚合     新的聚合类型           Y
        移除已存在的聚合        N
        更改聚合名             N
---------------------------------------
聚合内   添加新事件             Y
        移除一个事件类型        N
        更改一个事件类型名      N
---------------------------------------
事件内   添加新字段             Y
        删除一个字段           N
        更改字段名             N
        更改字段类型           N

对于不是后向兼容的变更, 可以通过在从事件存储库加载事件时执行 “向上转换 (upcasting)”, 将各 个事件从旧版本更新为新版本. 这通常实现为一个单独组件.

实现查询

微服务中的查询通常需要检索多个服务所在的多个数据库.

API 组合模式

“API 组合模式”: 通过查询每个服务的 API 并组合结果.

对于由谁来扮演 “组合器”, 可以有三个选择:

  1. 由客户端. 适于在同一局域网内上运行的前端客户端 (如 Web 应用程序). 但对于防火墙之外发及 通过较慢网络访问服务的客户, 不适合.
  2. 由实现应用外部 API 的 API gateway.
  3. 将其实现为独立的服务. 适于由多个服务在内部使用的查询操作; 对于外部 API, 如果聚合逻辑过于 复杂, 无法在 API gateway 中完成, 也可以使用.

Pro: 简单直观.

Con:

  • 需要调用多个服务查询多个数据库, 更多的计算和网络资源.
  • 操作的可用性随着涉及的服务数量而下降. 有几种方式可以提高可用性: 当某服务不可用时, 可以返回缓 存过的数据, 或者返回不完整的数据.
  • 缺乏事务的一致性. 如从 Order Service 检索的订单可能处于 CANCELED 状态, 而从 Kitchen Service 拿到的可能是尚未取消状态. API 组合器必须解决这种差异, 这会增加代码复杂性, 更糟的是 它可能根本检测不到不一致.
  • 一些查询无法使用它有效的实现, 如可能需要执行大规模数据的内存连接.

但还是应该尽可能使用这种模式.

CQRS 模式

使用 API 组合模式, 对于特定的查询实现起来很有难度, 比如:

  • 并非所有的服务都存储了查询需要的过滤或排序属性, 如果让 API 组合器在内存中实现连接会相当低效.
  • 有些操作需要返回列表数据, 如果此时相关服务没有提供批量接口, 挨个儿单独请求数据有巨大的网络开销.
  • 即使是单个服务的本地查询, 也会可能遇到这些难题: 数据可能不适合查询, 如需要对存储在不支持地理 索引的数据库进行地理位置查询.
  • 出于 Seperate Concern 的考量, 拥有数据的服务未必应该是实现查询的服务.

“CQRS (Command Query Responsibility Segregation, 命令查询职责分离) 模式”: 使用事件来 维护从多个服务复制数据的只读视图, 借此实现查询. 它将持久化数据模型和使用数据的模块分为两部分:

  1. “命令端” 模块和数据模型实现创建, 更新和删除操作 (CUD).
  2. “查询端” 通过订阅命令端发布的事件, 使其数据模型与命令端数据模型保持同步 (R). 它甚至根据查 询的类型需要, 可以使用多个模型.

如下图:

CQRS 不仅可以用于服务内, 还可以使用多个单独的 “查询服务” 来实现. 它也是实现复制单个服务所拥有 的数据的视图的好方法.

Pro:

  • 通过专用数据库, 高效的实现各种不同类型的查询, 还能避免单个数据库存储的限制.
  • 事件溯源的限制在于事件存储库仅支持基于主键的查询, CQRS 通过订阅事件流并保持最新的聚合的一或多 个视图, 能绕过此限制.
  • 领域模型和相应的持久化数据模型可以不必同时处理命令和查询, 通过问题隔离, 命令端和查询端更简单.

Con:

  • 架构复杂性增加. 开发者必须额外编写代码, 运维必须管理额外的数据库.
  • 同步的滞后会导致不一致. 例如如果客户端更新聚合并立即查询, 可能看到聚合的旧版本. 通常解决方 案有:
    • 命令端和查询端 API 提供版本信息 (比如命令端产生的事件 ID), 客户端轮询查询端视图直到它是 最新版本.
    • 客户端更新成功后通过更新其本地缓存的领域模型, 而不用再发出查询请求. 这样的缺点是客户端可能 需要复制服务端代码逻辑.

设计

CQRS 内部模块设计如下:

  • 数据访问模块实现数据库访问逻辑.
  • 事件处理程序和查询 API 模块使用数据访问模块来更新和查询数据库.
  • 事件处理程序订阅事件并更新数据库.

以下是一些重要的设计决择:

SQL 还是 NoSQL 数据库? NoSQL 数据库有更丰富的数据模型和性能, 因为 CQRS 只需要简单的事务并只 执行一组固定的查询, NoSQL 受限的事务模型影响不大. 但虽然事件处理程序一般只使用主键更新视图, 也 有需要使用外键来更新的可能, NoSQL 对于基于外键更新一般支持不好, 我们可以通过维护一个外键到主 键的映射, 来确定需要更新的主键.

在数据访问模块内:

  • 可能会发生并发更新, 要使用悲观锁或乐观锁处理好这种情况.
  • 为避免事件重复处理带来问题, 可能需要检测和丢弃重复事件.
  • 为确保可靠更新, 对于 SQL 数据库, 要以原子方式更新 PROCESSED_EVENTS 表; 对于 NoSQL 数据 库, 必须将事件保存在数据记录内 (如 MongoDB 的文档或 DynamoDB 的表项等).

有时需要添加新的视图, 有时需要更新现有视图, 但问题是消息代理无法无限期的存储信息, 可用以下两种 方式完成:

  • 可以使用 AWS S3 或其它可扩展的大数据技术 (如 Spark) 来归档旧事件, 并从中创建.
  • 使用 “两步增量法”: 定期计算并创建每个聚合实例的快照, 并使用快照和后续事件创建视图.

外部 API 模式

常见的会调用服务 API 的客户端有:

  • 通常位于防火墙内的 Web 应用.
  • 浏览器中运行的 js 应用.
  • 移动端应用.
  • 第三方应用.

不同客户端需要不同数据, 它们所处的网络 (墙内, 墙外, 高速, 慢速的移动网络) 环境也不同. 所以为它 们提供单一的, 适合所有客户端的 API 是没有意义的.

通常也要避免让它们直接调用服务, 因为:

  • 对细粒度服务 API, 需要多个请求才能检索出所需数据, 效率低下.
  • 让客户端了理服务 API 导致紧耦合, 不利于服务架构和 API 的演化.
  • 服务使用的通信机制不便或不能让客户端使用, 如 grpc 等非 http 流量可能穿不过防火墙.

API Gateway, BFF

“API Gateway 模式”: 实现一个服务, 它是外部 API 客户端进入基于微服务应用的入口点. 类似于 OOD 中的外观模式. 它负责实现以下功能:

  1. 请求路由. 类似 nginx 等反向代理, 它查询路由映射, 将请求路由到正确的服务.
  2. 使用 API 组合, 提供粗粒度的 API.
  3. 协议转换. 如对外提供 restful api, 内部则混用 restful 和 grpc 等.
  4. 使用 “后端前置模式”, 为每个客户端提供单独的 API.
  5. 也可以实现边缘功能: 身份验证, 访问授权, 速率限制, 缓存, 指标收集, 请求日志等…

它本身是一个分层的模块化架构:

使用单个 api gateway 为多个客户端服务的话, 通常是让客户端团队拥有和维护向他们公开的 api 模 块, 而 api gateway 团队负责公共模块和运维. 部署流水线必须完全自动化, 否则客户端团队不得不等 api gateway 团队手工布署新版本.

可以使用 “后端前置模式 (Backends For Frontends, BFF)”, 避免多个团队贡献一个代码库带来的职 责不明确问题: 即为每种类型的客户端实现单独的 api gateway, 如下图.

公共层功能是由 api gateway 团队实现的共享库, 理想情况下所有 api gateway 都用相同的技术栈, 否则可能需要通过复制代码逻辑来实现公共层功能.

BFF 的好处是提供了通过隔离提高了可靠性, 可观测性, 可独立扩展性, 还能减少启动时间.

为避免 api gateway 成为开发瓶颈的风险. 更新 api gateway 的过程应尽量轻量化, 避免其它团队排 队等待它.

设计考量

api gateway 的性能和可扩展性非常重要. 影响这两点的一个关键决策是使用同步还是异步 I/O. 同步 I/O 中, 每个连接由用线程处理, 这样的好处是编程模型简单, 但限制是线程是重量级的, 可拥有的数量 和并发连接数存在上限. 异步(非阻塞) I/O 中, 单个事件循环线程将 I/O 请求公派给事件处理程序, 这 样的好处是更且可扩展性, 因为它没有使用多个线程的开销, 坏处则是比较复杂, 更难编写, 理解和调试.

对于多个服务的调用, 一般我们按依赖排序顺序调用, 为提高性能, 也会并发调用的无依赖关系的服务. 传 统开发并发代码的方式是使用回调, 但使用传统异步回调方法编写的代码很快会导致 “回调地狱”, 更好的方 法是使用响应式编程, 如 Java 的 CompletableFutures.

api gateway 也必须可靠. 通常会将多个 api gateway 实例放在负载均衡器后面, 并使用断路器模式.

另外, 作为服务中的一员, 它也需实现服务发现, 可观测性等模式.

实现

一般有两种实现 api gateway 的方法: 使用现成的产品或服务, 如 Kong 或 Traefik, 但它们通常不 支持 api 组合; 更灵活的是使用 api gateway 框架或 web 框架作为起点, 开发属于自己的 api gateway.

选择框架要考量以下几点: 它是否支持你需要的路由规则 (基于路径, 方法, 自定义…)? 是否正确实现 了 http 代理行为, 包括如何处理 http 标头?

GraphQL

使用 GraphQL 可以方便的支持客户端定制响应数据. 相当于让客户端直接对底层数据执行查询, 这让开发 一个支持不同客户端需求的灵活单一的 API 变得可行, 也能显著减少为其编写各种检索过程的开发量.

graphql 在执行查询时可能会执行大量解析器, 由于 graphql 服务器单独执行每个解析器, 可能会因为 过多的服务往返而性能不佳, 产生类似 ORM 的 N+1 问题. 两个重要的优化是:

  • 批处理: 将 N 个调用转换为服务, 变成单个调用, 该调用将检索一批 N 个对象.
  • 缓存: 利用先前获取的同一对象结果, 避免不必要的重复调用.

测试

“测试替身” 用于消除被测系统 (SUT) 的依赖性. 有两种类型的测试替身:

  • “桩 (stub)”: 代替依赖向 SUT 发送调用的返回值.
  • “模拟 (mock)”: 通常也扮演桩的角色向调用返回值, 但重点是验证 SUT 是否正确调用了依赖项.

测试按其范围分类:

  • 单元测试: 服务的一小部分, 如类.
  • 集成测试: 与基础设施服务 (如数据库) 和基它应用服务的交互.
  • 组件测试: 个服务的验收测试.
  • 端到端测试: 整个应用的验收测试.

“测试象限” 将测试按两个维度分类:

  • 是面向业务还是技术
  • 目的是协助开发还是寻找 bug

“测试金字塔” 可用于指导每种类型的测试的数量:

单元测试

单元测试可分为两类, 类的角色通常决定了应使用哪种类型:

  • 独立型 (solitary): 使用模拟对象隔离测试类和依赖. 控制器和服务类通常是独立型的.
  • 协作型 (sociable): 使用真实的依赖. 领域对象通常是协作型的.

对于 saga 类 (如 CreateOrderSage) 需要测试它是否将消息按预期的顺序发送给参与方. 除了正常场 景, 也必须为回滚等异常场景编写单元测试. 通常使用 mock 来模拟数据库和消息代理, 使用桩服务来模 拟各种参与方.

领域服务类 (如 OrderService) 实现了除实体, 值对象和 saga 负责实现的业务逻辑之外其它的业务逻 辑. 对其使用独立型单元测试, 模拟存储库和消息代理等基础实施.

控制器亦是独立型的, 使用模拟 mvc 测试框架更有效. 这些框架产生模拟的 http 请求, 并对 http 响 应进行断言.

对于事件和消息处理程序, 也使用独立型. 每个测试都实例化一个消息适配器, 向消息通道发送消息, 并验 证是否正确调用了服务模拟. 而背后实际是, 消息传递是基于桩的, 不涉及真实代理.

集成测试

集成测试必须验证服务是否可以与其客户端和依赖项通信, 但是不是测试整个服务, 而是测试实现通信的各 个适配器类. 与端到端测试不同, 它们不会启动服务.

我们使用 “消费者驱动的契约测试模式” 来更快, 更简单, 更可靠实现单独测试服务. 调用方和被调用方是 “消费者-提供者” 的关系, 消费者契约测试是针对提供者的集成测试, 用于验证其 api 是否符合消费者 的预期. 它不会彻底测试提供者的业务逻辑, 因为那是单元测试的工作.

消费者服务的团队负现编写契约测试套件, 并提交到提供者的测试套件代码库. 这些测试在提供者的部署 流水线中执行, 如果出现失败就代表提供者对 API 的更改影响到了消费者, 必须修复.

通常使用样例测试, 即它们之间的交互由一组样例定义, 称为 “契约”. 例如, rest api 的契约包含样 例 HTTP 请求和响应.

虽然重点是测试提供者, 但契约也可用于验证消费者是否符合契约. 因为每个契约的请求和响应都扮演着 测试数据和预期行为规范的双重角色.

  • 在提供者端的测试中, 根据契约生成的类使用契约的请求调用提供者, 并验证它是否返回与响应相匹配的 响应, 以此测试指供者适配器
  • 在消息者端的测试中, 契约用于配置桩的行为在无需真实提供者情况下进行集成测试, 以此测试消费者适 配器.

如下图 (api gateway 是 order service 的消费者):

对于持久化层的集成测试, 在测试其间运行数据库实例的有效解决方案是使用 Docker.

对于基于 rest 的请求/响应式交互的集成测试:

对于发布/订阅式交互的集成测试:

对于异步请求/响应式交互 (命令式消息) 的集成测试:

组件测试

组件测试单独验证服务的行为, 它使用模拟其行为的桩代替服务的依赖关系, 甚至可能使用内存版本的基础 设施. 对每个场景都定义一个 “验收测试”, 有如下形式:

  1. given 对应的是测试的设置阶段.
  2. when 对应的是执行阶段.
  3. then/adn 对应的是验证阶段.

使用 Gherkin 和 Cucumber 可以自动将场景转换为可运行的代码. Gherkin 是用于编写可执行规范的 DSL, 然后使用 Cucumber 执行规范. Cucumber 是 Gherkin 的测试自动化框架. 而对于基础设施, 可 以使用 Docker Gradle. 如下图:

端到端测试

端到端测试会测试整个应用, 包括任何所需的基础设施服务.

一个好的策略是编写 “用户旅程测试”, 它对应于用户使用系统的过程. 例如你可以写一个完成创建订单, 修改订单和删除订单的单个测试, 而不用分别为它们编写测试. 端到端测试类似验收测试, 是面向业务的测 试, 可以使用 Gherkin 和 Cucumber, 主要区别在于测试可以有多个动作而不是单一的 then.

安全

应用开发者主要负责实现安全性的四个方面是: 身份验证, 访问授权, 审计, 安全的进程间通信.

安全架构中一个关键部分是 “会话”, 它存储主体的 ID 和角色. 另一个是 “安全上下文”, 它存储发出请求 的用户信息.

身份验证

让各个服务分别对用户进行身份验证,

  • 允许未经身份验证的请求进入内部网络.
  • 依赖于每个团队正确实现安全性. 出现安全漏洞的风险很大.

更好的方法是让 api gateway来做这件事.

访问授权

在 api gateway 中实现访问授权的问题在于, 可能产生 api gateway 与服务间的耦合, 要求它们以同 步的方式进行代码更新; 而且 api gateway 通常只能实现对 url 路径的 rbac.

另一个实现位置是服务本身, 它可以对 url 和服务方法实现 rbac 访问授权.

OAuth2 和 JWT

使用 oauth2 的好处是, 它是经过验证的安全标准. 但无论使用哪种方法, 三个关键思想是:

  • api gateway 负责验证客户端的身份.
  • api gateway 和服务之间使用透明令牌传递主体信息.
  • 服务使用令牌获取主体的身份和角色.

验证 api 客户端:

验证基于登录, 面向会话的客户端:

可配置

“外部化配置模式” 指在运行时向服务提供配置属性值. 主要有两种方式:

  • 推送模型: 部署基础设施通过命令行参数, 系统环境变量, 配置文件或其它特定机制将配置值给到服务 实例. 这种方法的限制在于, 重新配置运行中的服务很难或不可能, 另外配置可能分散在很多的服务定义 中.
  • 拉取模型: 服务实例从配置服务器读取配置值 (在此之前, 还需要先推送一些必须预先知道的配置值, 如配置服务器的网络位置等). 可以使用 Git, SQL 或 NoSQL 数据库, 专用的配置服务器等来实现配置 服务器. 这种方法的好处在于: 集中配置便于管理, 配置服务器可以实现对加密数据的透明解密, 服务也 能通过轮询动态重新配置.

可观测

有许多模式用于设计可观测的服务, 包括:

  • 健康检查 API
  • 日志聚合
  • 分布式追踪
  • 异常跟踪
  • 应用指标
  • 审计日志

大多数这些模式有一个显著特征: 有一个开发人员组件和一个运维人员组件.

健康检查 API 模式

通常要测试服务实例与外部服务的连接. 简单一点可以只验证是否可以连接; 复杂一点可以执行模拟客户 端调用服务 API 的综合事务.

日志聚合模式

日志聚合流水线和服务器通常是运维的职责, 服务开发人员负责编写生成日志.

不应将日志写入文件, 因为基于容器的实例中的文件不是永久的. 相反, 应该写入到 stdout 中, 然后 由部署基础设施决定如何 处理实例的输出.

日志记录的基础设施负责聚合, 存储日志有及使之可搜索. 常见的如 ELK, 其它开源的日志流水线有 Fluentd/Flume 等.

分布式追踪模式

分布式追踪类似于单体应用中的性能分析器, 它记录处理请求时的服务树调用信息. “追踪 (trace)” 表示外部请求, 它由一或多个跨度组成. “跨度 (span)” 表示操作, 具有名称, 开始和结束时间. 跨度 可以有一或多个 “子跨度”, 表示嵌套操作.

分布式追踪会为每个外部请求分配唯一 ID, 可用来方便的查找.

它通常由两部分组成: “追踪工具类库” 构建跨度树, 并发送到分布式追踪服务器 (如 Zipkin). 传播追踪 信息的一个通用标准是 B3 标准, 它使用如 X-B3-TraceId 和 X-B3-ParentSpanId 之类的头部.

服务代码可直接调用追踪工具类库, 但这会造成和业务代码交织在一起, 更简洁的方法是使用拦截器或 “面向切面编程 (AOP, aspect oriented programming)”.

应用程序指标模式

监控的许多方面是运维人员的职责. 但开发人员需要编写监测服务的代码, 以便收集有关其行为的指标, 还 必须将指标发送给指标服务器. 使用 “推送模型”, 服务实例通过调用指标服务的 API 发送; 使用 “拉取 模型”, 指标服务或其本地运行的代理调调用服务 API 从服务实例检索指标信息. Prometheus 是一个流 行的开源监控和警报系统, 它使用拉取模型.

异常追踪模式

查看异常的传统方式是通过日志, 但这有以下缺点:

  • 日志通常是单行的, 而异常通常由多行组成.
  • 没有机制追踪异常的解决方案, 你必须手动记录到问题追踪器中.
  • 可能存在重复多个异常, 没有自动机制将它们视为一个, 不方便调试.

最好使用异常追踪模式, 将服务配置为通过如 rest api 向异常追踪服务器 (如 Sentry.io) 报告异常, 异常追踪服务器则进行去重, 生成警报并管理异常的解决方案.

审计日志模式

记录用户操作可帮助客户支持, 确保合规性, 并检测可疑行为. 有三种方法来实现审计日志的记录:

  • 在业务逻辑中实现. 代码将交织在一起, 且容易出错.
  • 使用 AOP.
  • 使用事件溯源. 限制是事件溯源不记录查询.

服务基底和服务网格

“服务基底 (Chassis) 模式” 指对于微服务架构中的共性问题, 我们在能够处理这些问题的框架或框架集 合上构建服务.

使用这种模式, 开发者必须确保有与其正使用的编程语言/平台组合匹配的基底框架或类库. 幸运的是, 许多 功能很可能已由基础设施实现了, 更重要的是, 许多与网络相关的功能将由所谓的 “服务网格” 处理.

“服务网格 (Service Mesh) 模式” 把所有进出服务的网络流量通过一个网络层进行路由, 这个网络层 负责解决如断路器, 分布式追踪, 服务发现, 负载均衡和基于规则的流量路由等具有共性的需求. 服务网格 是网络基础设施. 还可以通过在服务之间使用 TLS 来保护进程间通信.

使用服务网格后, 基底的责任就少多了, 它只需实现与应用代码紧密集成的问题, 如外部化配置和健康检查.

当前的服务网格实现有: Istio, Linkerd, Conduit 等.

Istio

Istio 功能丰富, 可分为四大类:

  • 流量管理: 服务发现, 负载均衡, 路由规则和断路器.
  • 通信安全: 使用 TLS 保护服务间通信.
  • 遥测 (telemetry): 捕获有关网络流量的指标并实施分布式追踪.
  • 策略执行: 实施配额和费率限制.

它由 “控制平面” 和 “数据平面” 组成. 控制平面实现管理功能 (其组件包括 Pilot 和 Mixer). 数据 平面由 Envoy 代理组成. Pilot 从底层基础设施中提取有关已部署服务的信息并配置数据平面. Mixer 负责执行配额和收集遥测信息等策略, 并将其报告给监控基础设施服务器. Envoy 作为 “边车 (sidecar)” 将流量路由到服务中并路由到服务外. 每个服务实例都有一个 Envoy 代理服务器. 这样, 通信模式从直接 的 “服务 → 服务” 变为 “服务 → 源 Envoy → 目标 Envoy → 服务”.

重构策略

“绞杀者应用模式” 指通过在遗留应用程序周围逐步开发新的 (绞杀) 应用程序来实现应用程序的现代化. 相 比于一步到位, 这样能在尽可能少对单体作修改的情况下, 尽早并且频繁的体现出微服务的价值.

有三种主要策略可以实现绞杀:

  1. 将新功能实现为服务 (“挖坑法则”: 如果你发现自己已经陷入困境, 就不要再给自己挖坑了), 并使用 “集成胶水” 代码让新服务能访问单体所拥有的数据及调用单体中的功能. 但并非每个新功能都能实现为 有意义的新服务 (如只是为某个类加一个字段), 最终还是要配合后面两种方法, 先实现在单体, 再提取 为新服务.

  2. 隔离表现层与后端 (水平切片). 表现层通常与业务和数据访问层有天然的接缝, 沿着它将单体分成两个 较小的应用. 把前后端拆分出来后可以分别独立布署, 还能公开一组可用于服务调用的 API.

  3. 提取业务能力到服务中 (垂直切片). 这项工作具有挑战性, 需要打破对象引用等依赖, 甚至需要拆分类, 还需要重构数据库. 应当优先关注那些能够提供高价值的部分进行.

拆解领域模型

保留在单体中的类可能会引用已移动到服务的类, 反之亦然. 如何消除跨越服务边界的对象引用?

我们根据 DDD 聚合进行思考, 把直接的对象引用改成使用主键相互引用. 但这么做可能会对本来期望的 是对象引用的客户类造成很大影响. 可以使用后面讲到的 “通过在服务和单体之间复制数据” 来减少更改 的范围.

重构数据库

提取服务时, 也需要移动数据, 将表从单体数据库中移动到服务的数据库. 重构数据库的一个主要挑战是会 影响该数据库的所有客户端, 它们需要进行改动以使用新的 Schema.

为避免大范围更改, 我们单体中的与提取到服务中的实体相关字段设置为只读, 并通过将数据从服务复制 回单体来使其保持最新. 还需要在单体中找出更新这些字段的代码, 把它们改成调用新服务. 如下图:

服务与单体的协作

集成胶水的设计

服务和单体通过 “集成胶水” 进行协作.

第一步是确定胶水为领域逻辑提供的 API. 应当根据是为了查询数据还是为了更新数据, 把服务和单体之间 的进程间通信机制封装在接口之后.

另一个设计决策是选择两者的交互方式和进程间通信机制. 这也取决于是查询还是更新数据:

  • 对于查询, 一种选择是让实现 repository 接口的适配器调用数据提供者的 API, 此 API 通常是使用 请求/响应的交互方式, 如 REST 或 gRPC. 另一种方法是让数据使用者维护数据副本, 副本本质上是 CQRS 视图, 数据使用者通过订阅数据提供者发布的领域事件来保持最新. 这种方法的挑战在于需要修改 单体以便发布领域事件.
  • 对于更新, 挑战是需要维护服务和单体的数据一致性. 在简单的场景中, 请求者可以发送通知消息或发布 事件以触发更新. 在更复杂的场景中, 请求者必须使用 saga 来维护数据一致性, 见下节.

另外, 我们不希望单体中的业务概念污染服务中的, 为此需要设计一个反腐层, 让它负责在两个不 同的领域模型之间进行转换. 它既要实现在我们在第一步中抽象出来的接口中以对 “通用语言” (DDD 中的 概念) 进行转换, 也得实现在事件处理程序中以对领域事件进行转换.

新服务可以直接使用我们讲过的模式发布和订阅事件, 而对于单体则可能没那么简单. 我们通常在单体中使用 事务拖尾或轮询, 在数据库级别发布事件.

维护数据一致性

上面我们讲了, 如果有一致性的需要, 必须使用 saga. 但在单体中实现补偿事务的问题是, 可能需要对它 进行大量且耗时的更改. 如果单体中的事务都是关键性事务可重复性事务的话, 因为这两种类型的事务 无需补偿, 如此就能让单体中的 saga 实现变得简单的多了. 为了做到这一点, 必须小心的安排服务提取 的顺序, 以确保单体事务总是这两种类型的事务.

身份验证和访问授权

绞杀过程中, 一个挑战是: 你必须同时支持基于单体和基于 JWT 的安全机制. 幸运的是, 仅需要对单体的 登录处理程序进行一次小的修改即可: 额外返回一个 USERINFO 的 cookie, 其中包含用户信息, api gateway 将它带入标准的 Authorization 标头中请求服务.