笔记 - 微服务架构设计模式
posted on 01 May 2020 under category note
书评: http://unifreak.github.io/book_review/microservice_patterns
“架构” 关注软件各组成部分和它们之间的依赖关系, 它的重要性在于它影响了应用的非功能性需求 (质量或能力). 如应用在运行时的质量属性: 可扩展性, 可靠性; 以及在开发阶段的质量属性: 可维护性, 可测试性, 可部署性.
软件架构的 4+1 视图模型: 每个视图都包括一些特定的软件元素和它们相互之间的关系, +1 是指场景, 它 描述在一个视图中多个元素如何协作以完成一个请求.
特定的 “架构风格” 提供了有限的元素和关系, 可以从中定义架构的视图. 软件通常使用多种风格的组合.
“分层架构”, 如 MVC. 其弊端在于: 单个表现层和数据持久层, 无法反应现实中应用可以被多个系统调用 以及它也可能与多个数据库交互的事实. 而且将业务层定义为依赖于数据持久化层, 这样的依赖妨碍我们 在没有数据库的情况下进行测试.
“六边形架构” 作为分层架构的替代品, 它的关键特性和优点是业务逻辑不依赖于适配器, 而是适配器依赖 于业务逻辑. 业务逻辑有一或多个 “端口 (port)”, 端口定义了一组关于业务逻辑如何与外部交互的操作. 外部通过 “入站适配器” 调用业务逻辑的入站端口, 出站适配器则实现出站端口, 通过调用外部应用或服 务处理来自业务逻辑的请求. 用这种方式将业务逻辑与适配器中包含的表示层和数据访问层的逻辑分离开. 六边形架构非常适合用于描述微服务架构中每个服务. “
扩展立方体:
“微服务架构”: 把应用功能性分解 (Y 轴) 为一组服务的架构风格, 每一个服务都是一组专注, 内聚的功 能职责组成. 它的关键特性是每一个服务之间都是松耦合的, 仅通过 API 进行通信, 为实现这种松耦合, 每个服务都拥有自己的私有数据库.
微服务 VS SOA
SOA 微服务
----------------------------------------------------------------------------
服务间通信 智能管道, 如 ESB 哑管道, 如消息代理
重量级协议, 如 SOAP 轻量协议, 如 REST, gRPC
数据管理 全局数据模型, 共享数据库 每个服务有自己的数据模型和数据库
典型服务的规模 较大的单体应用 较小的服务
约束 强约束, 各种规范标准 "实践标准", 无统一方案, 架构能力要求高
微服务 9 个核心的业务与技术特征:
微服务架构的优点如下:
它主要的缺点是, 分布式带来的复杂性:
“服务” 是一个单一的, 可独立布署的软件组件. 它的 API 由命令, 查询和事件组成. “操作适配器” 调用 业务逻辑, 实现命令和查询; “事件适配器” 对外发布业务逻辑产生的事件.
服务的大小并不重要, 重要的是它们之间是松耦合的. 如果你因为其它服务的变更需要不断变更自己负责 的服务, 或者自己服务的变更会触发其他服务同步变更, 这表明它们没有实现松耦合, 你构建的甚至可能是 一个分布式的单体.
这是一个不断迭代的过程. 每次迭代可用以下的三步流程来指导:
“系统操作” 是应用必须处理的请求的一种抽象描述. 这一步的是将应用的需求提炼为各种关键请求. 系统操 作可分为命令型和查询型.
这是一个两步流程:
有两种策略来划分各个服务. 传统策略是根据业务能力划分. “业务能力” 指通为公司产生价值的商 业活动. 我们通过对公司的目标, 结构和商业流程识别出有哪些业务能力, 并将每种能力认为是一个服务. 这种方式好处是, 因为业务能力是稳定的, 架构也会是稳定的. 但挑战在于, 这种方法中的建模方式一往往 会为整个公司建立一个单独的模型, 这导致:
为了避免这些限制, 另一种策略是使用 DDD (领域驱动开发) 为每个子域定义单独的领域模型. See ddd.md. DDD 的子域和限界上下文的概念能很好的匹配微服务中的服务.
先将已经确定的的系统操作分配给各服务. 我们应尽可能将操作分配给需要操作所提供信息的服务, 其次是分 配给具有处理它所需信息的服务.
还需确定用于支持服务协作所需的 API, 为此会识别出为完成每个系统操作时, 相关的协作者的 API.
进程间通信机制影响应用的可用性, 我们必须处理 “局部失效” 问题. 它也与事务管理相互影响.
选择进程间通信机制, 可以从以下几个角度考量:
根据客户端和服务的交互方式, 可以从两个维度分为以下几类. 交互方式影响应用的可用性.
一对一 一对多
-------------------------------------------------------------------
同步 请求/响应 —
异步 异步请求/响应 发布/订阅 (不期待响应)
单向通知 (不期待响应) 发布/异步响应
为应对 API 的演化, 可以使用 Semvers 管理版本. 并在 API 网关中进行版本协商和转发.
消息的格式. 格式的选择影响通信效率, 可用性和可演化性.
文本 vs 二进制.
文本 二进制
---------------------------------------------------------------------------
如 JSON, XML Protobuf, Avro
---------------------------------------------------------------------------
Pro 可读性高, 自描述
后向兼容性
---------------------------------------------------------------------------
Con 过度冗长造成的传输开销
解析文本的开销
性能差
同步通信中, 常见有 REST 和 gRPC 两种通信协议. 后面也会讲 GraphQL 用于灵活高效的数据提取.
使用 REST (see restful.md) 的问题:
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 要求发送和接收方必须同时在线, 可用性低 潜在的单点性能和故障, 不过大多数现代代理都是
需要知道对方位置 (服务发现机制) 高可用的.
实现如可靠投递等复杂功能时挑战大 操作复杂性
消息通道可分为以下两种:
满足幂等的消息处理程序可以被放心的执行多次, 如果处理程序本身不是幂等的, 可以通过检测和丢弃重 复消息使之成为幂等的.
更新数据库和发送消息这两个操作经常需要被原子的完成, 以避免不一致状态. 如果不能作到把这两个操作 放在同一个数据库事务中, 可以使用 “事务性发件箱模式”:
为了发布发件箱中的事件, 一种方法是通过 “轮询模式”, 轮询发件箱中未发布的消息并发送给消息代理, 这 在小规模下运行良好, 但会对数据库造成昂贵开销. 最好用 “事务日志拖尾模式”, 如下图:
这个模式的挑战在于需要做一些开发工作, 但有一些工具可以参考, 如:
在多个服务、数据库和消息代理间维持数据一致性的方式是 “分布式事务”. 如 XA 采用了 “两阶段提交 (2PC)” 来保证事务参与方同时完成提交, 但它要求整个技术栈满足 XA 标准. 另外, 分布式事务本质 上是同步通信, 会降低系统可用性, 为了让事务完成, 所有参与方都必须可用. 基于这两点, 分布式事务并 不适合微服务架构.
更好的方式是使用 “Saga 模式”. 一个 Saga 代表需要更新多个服务中数据的一个系统操作. 它由一系列 本地事务组成, 每个本地事务负责更新它的在服务的私有数据库. 系统操作启动 Saga 的第一步, 完成本 地事务的服务会发布一条消息, 以触发下一个本地事务的执行.
与传统 ACID 事务不同的是, Saga 无法自动回滚, 我们必须使用 “补偿事务”. 假设第 n+1 个事务失败 了, 必须撤消前 n 个事务的影响. 每个事务 Tᵢ 都有相应的补偿事务 Cᵢ, 顺序为 T₁…Tₙ,Cₙ…C₁. 并非 所有步骤都需要补偿事务.
如何协调每个服务完成一个 Saga, 可有两种方式: 协同式以及编排式.
由每个参与方通过订阅和交换事件来协调.
由一个 “sage 编排器” 集中进行协调, 编排器使用命令/异步响应方式与参与方通信. 通过向参与方发 出一个命令, 告诉它该做什么操作, 参与方完成后给编排器发送一个答复消息, 编排器处理这个消息并决定 saga 下一步操作是什么.
图中最后一步, 虽然可以直接更新 Order, 但为保持一致, Saga 还是将 Order Service 视为另一个 参与方并向其发送命令.
为更轻松的设计, 实现和测试 Saga, 最好把编排器建模为状态机. 如下图:
两者对比如下表:
协同式 编排式
---------------------------------------------------------------------------
Pro 实现简单 更简单的依赖关系, 不会出现循环依赖
协调逻辑集中于编排器, 领域对象更简单
---------------------------------------------------------------------------
Con 逻辑分布在各服务中, 难理解 编排器存在集中过多业务逻辑的风险, 它应该*只负责排序*,
服务之间可能出现循环依赖 不应包含任何其他业务逻辑
---------------------------------------------------------------------------
For 简单的 Saga 除最简单情况, 都建议使用
无论使用哪种方式, 都要确保每个参与者的业务操作和消息发送操作应在一个本地事务, See #事务性消息.
See acm#284472.284478.
Saga 缺乏 ACID 事务中的隔离属性, 因为一旦其中的本地事务提交, 它所作的更新都会被其他 Saga 看 到. 这导致以下问题:
这些问题可能在业务上导致超卖等不可接受的风险.
有几种对策可以帮助我们应对隔离问题. 我们可将 Saga 中的事务分为三种类型: 可补偿性事务, 关键性事 务, 和可重复性事务. 如图:
每种事务在对策中扮演着不同角色, 可补偿性事务和可重复性事务之间的区别尤其重要 (注意这两种事务都 无需补偿事务).
语义锁 (semantic lock)
可补偿性事务在其创建或更新的任何记录中设置 “标志”, 如 *_PENDING
, 表示该记录未提交. 这个标记可以是阻止其他事务访问的锁, 或者只是一个警告. 标志会被一个可重复事务或通过补偿 事务清除, 分别代表 Saga 成功完成或发生了回滚.
还需考虑另一个事务如何处理被锁定的记录, 如 cancelOrder() 这个系统命令如果遇到一个处于 APPROVAL_PENDING 状态的订单, 它应该怎么做:
交换式更新 (commutative updates)
“可交换” 意即更新操作被设计为可以按任何顺序执行. 这样可以避免丢失更新.
悲观视图 (pessimistic view)
重新排序 Saga 步骤以最大限度降低由于脏读而导致的业务风险. 如把风险最大的操作放进可重复 事务中.
重读值 (reread value)
乐观离线锁的一种形式, 用于防止丢失更新. Saga 在更新前重读记录的值, 如果未更改则更新, 否则中止操作.
版本文件 (version file)
这是将不可交换操作转换为可交换的一种方法. Saga 记录对数据执行的操作, 以便对它们重新排序.
业务风险评级 (by value)
使用分布式事务执行高风险请求, 使用 Saga 执行低风险请求.
组织业务逻辑主要有两种方式: 面向过程的事务脚本和面向对象的领域建模模式.
“事务脚本”: 将业务逻辑组织为面向过程的事务脚本的集合, 每种类型的请求都有一个脚本. 如下图例中, 每 个服务类都有一个用于请求或系统操作的方法, 这个方法实现该请求的业务逻辑. 它使用数据访问对象 (DAO) 访问数据库, 数据对象是纯数据, 几乎没有行为.
除非应用非常简单, 否则我们应该使用领域模型模式, 进行面向对象的设计.
“领域模型”: 将业务逻辑组织为由具有状态和行为的类构成的对象模型. 与事务脚本一样, 服务类具有针对 每个请求或系统操作的方法, 但是服务方法通常很简单, 它几乎总是调用持久化领域对象, 这些对象中包含 大量业务逻辑.
传统的领域模型是一个由互相关联的类构成的网络. 它没有明确的边界, 这会带来一些问题, 比如如下的领 域模型中:
See ddd.md.
我们可以通过使用 DDD 中的聚合, 来明确边界. 我们使用聚合, 将领域模型分解为块, 操作是针对整个聚 合的:
为了保证聚合是一个可以强制执行各种不变量约束的自包含单元, 使用聚合须遵守如下规则:
“领域事件” 是发生在聚合上的事件. 通过有聚合被创建或发生其它重大更改时发布领域事件, 可以实现如下 功能:
领域事件应该由谁来发布? 有三种选择:
另外, 领域事件也需要被可靠的发布, 参见 #事务性消息.
传统上的持久化技术是将类映射到数据表, 可以用 ORM 来实现. 这种方式的弊端在于:
如之前所讲, 如果我们将业务逻辑设计为一组可以对外发布领域事件 DDD 的聚合, 这是在微服务中同步数 据和发送通知的有效机制, 但也会带来如下问题:
“事件溯源” 则以事件为中心, 是构建业务逻辑和持久化聚合的另外一种选择. 它使用一系列表示状态更改的 领域事件来持久化聚合, 通过重放事件来重新创建聚何的当前状态.
事件不再是可有可无的, 当聚合发生状态变化时它必须发出一个事件, 而且事件必须包含从原状态转化 为新状态的必需数据.
业务逻辑也需要重构, 以适应 “重放” 需求. 聚合根上原先的命令方法需要重构成多个方法:
如下图, 每个方法都被 process() 方法和一或多个 apply() 方法所取代:
事件被保存在 “事件存储库” 中. 对于关系型数据库来说, 如下图中的 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 并组合结果.
对于由谁来扮演 “组合器”, 可以有三个选择:
Pro: 简单直观.
Con:
但还是应该尽可能使用这种模式.
使用 API 组合模式, 对于特定的查询实现起来很有难度, 比如:
“CQRS (Command Query Responsibility Segregation, 命令查询职责分离) 模式”: 使用事件来 维护从多个服务复制数据的只读视图, 借此实现查询. 它将持久化数据模型和使用数据的模块分为两部分:
如下图:
CQRS 不仅可以用于服务内, 还可以使用多个单独的 “查询服务” 来实现. 它也是实现复制单个服务所拥有 的数据的视图的好方法.
Pro:
Con:
CQRS 内部模块设计如下:
以下是一些重要的设计决择:
SQL 还是 NoSQL 数据库? NoSQL 数据库有更丰富的数据模型和性能, 因为 CQRS 只需要简单的事务并只 执行一组固定的查询, NoSQL 受限的事务模型影响不大. 但虽然事件处理程序一般只使用主键更新视图, 也 有需要使用外键来更新的可能, NoSQL 对于基于外键更新一般支持不好, 我们可以通过维护一个外键到主 键的映射, 来确定需要更新的主键.
在数据访问模块内:
有时需要添加新的视图, 有时需要更新现有视图, 但问题是消息代理无法无限期的存储信息, 可用以下两种 方式完成:
常见的会调用服务 API 的客户端有:
不同客户端需要不同数据, 它们所处的网络 (墙内, 墙外, 高速, 慢速的移动网络) 环境也不同. 所以为它 们提供单一的, 适合所有客户端的 API 是没有意义的.
通常也要避免让它们直接调用服务, 因为:
“API Gateway 模式”: 实现一个服务, 它是外部 API 客户端进入基于微服务应用的入口点. 类似于 OOD 中的外观模式. 它负责实现以下功能:
它本身是一个分层的模块化架构:
使用单个 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 可以方便的支持客户端定制响应数据. 相当于让客户端直接对底层数据执行查询, 这让开发 一个支持不同客户端需求的灵活单一的 API 变得可行, 也能显著减少为其编写各种检索过程的开发量.
graphql 在执行查询时可能会执行大量解析器, 由于 graphql 服务器单独执行每个解析器, 可能会因为 过多的服务往返而性能不佳, 产生类似 ORM 的 N+1 问题. 两个重要的优化是:
“测试替身” 用于消除被测系统 (SUT) 的依赖性. 有两种类型的测试替身:
测试按其范围分类:
“测试象限” 将测试按两个维度分类:
“测试金字塔” 可用于指导每种类型的测试的数量:
单元测试可分为两类, 类的角色通常决定了应使用哪种类型:
对于 saga 类 (如 CreateOrderSage) 需要测试它是否将消息按预期的顺序发送给参与方. 除了正常场 景, 也必须为回滚等异常场景编写单元测试. 通常使用 mock 来模拟数据库和消息代理, 使用桩服务来模 拟各种参与方.
领域服务类 (如 OrderService) 实现了除实体, 值对象和 saga 负责实现的业务逻辑之外其它的业务逻 辑. 对其使用独立型单元测试, 模拟存储库和消息代理等基础实施.
控制器亦是独立型的, 使用模拟 mvc 测试框架更有效. 这些框架产生模拟的 http 请求, 并对 http 响 应进行断言.
对于事件和消息处理程序, 也使用独立型. 每个测试都实例化一个消息适配器, 向消息通道发送消息, 并验 证是否正确调用了服务模拟. 而背后实际是, 消息传递是基于桩的, 不涉及真实代理.
集成测试必须验证服务是否可以与其客户端和依赖项通信, 但是不是测试整个服务, 而是测试实现通信的各 个适配器类. 与端到端测试不同, 它们不会启动服务.
我们使用 “消费者驱动的契约测试模式” 来更快, 更简单, 更可靠实现单独测试服务. 调用方和被调用方是 “消费者-提供者” 的关系, 消费者契约测试是针对提供者的集成测试, 用于验证其 api 是否符合消费者 的预期. 它不会彻底测试提供者的业务逻辑, 因为那是单元测试的工作.
消费者服务的团队负现编写契约测试套件, 并提交到提供者的测试套件代码库. 这些测试在提供者的部署 流水线中执行, 如果出现失败就代表提供者对 API 的更改影响到了消费者, 必须修复.
通常使用样例测试, 即它们之间的交互由一组样例定义, 称为 “契约”. 例如, rest api 的契约包含样 例 HTTP 请求和响应.
虽然重点是测试提供者, 但契约也可用于验证消费者是否符合契约. 因为每个契约的请求和响应都扮演着 测试数据和预期行为规范的双重角色.
如下图 (api gateway 是 order service 的消费者):
对于持久化层的集成测试, 在测试其间运行数据库实例的有效解决方案是使用 Docker.
对于基于 rest 的请求/响应式交互的集成测试:
对于发布/订阅式交互的集成测试:
对于异步请求/响应式交互 (命令式消息) 的集成测试:
组件测试单独验证服务的行为, 它使用模拟其行为的桩代替服务的依赖关系, 甚至可能使用内存版本的基础 设施. 对每个场景都定义一个 “验收测试”, 有如下形式:
使用 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 的好处是, 它是经过验证的安全标准. 但无论使用哪种方法, 三个关键思想是:
验证 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) 报告异常, 异常追踪服务器则进行去重, 生成警报并管理异常的解决方案.
记录用户操作可帮助客户支持, 确保合规性, 并检测可疑行为. 有三种方法来实现审计日志的记录:
“服务基底 (Chassis) 模式” 指对于微服务架构中的共性问题, 我们在能够处理这些问题的框架或框架集 合上构建服务.
使用这种模式, 开发者必须确保有与其正使用的编程语言/平台组合匹配的基底框架或类库. 幸运的是, 许多 功能很可能已由基础设施实现了, 更重要的是, 许多与网络相关的功能将由所谓的 “服务网格” 处理.
“服务网格 (Service Mesh) 模式” 把所有进出服务的网络流量通过一个网络层进行路由, 这个网络层 负责解决如断路器, 分布式追踪, 服务发现, 负载均衡和基于规则的流量路由等具有共性的需求. 服务网格 是网络基础设施. 还可以通过在服务之间使用 TLS 来保护进程间通信.
使用服务网格后, 基底的责任就少多了, 它只需实现与应用代码紧密集成的问题, 如外部化配置和健康检查.
当前的服务网格实现有: Istio, Linkerd, Conduit 等.
Istio 功能丰富, 可分为四大类:
它由 “控制平面” 和 “数据平面” 组成. 控制平面实现管理功能 (其组件包括 Pilot 和 Mixer). 数据 平面由 Envoy 代理组成. Pilot 从底层基础设施中提取有关已部署服务的信息并配置数据平面. Mixer 负责执行配额和收集遥测信息等策略, 并将其报告给监控基础设施服务器. Envoy 作为 “边车 (sidecar)” 将流量路由到服务中并路由到服务外. 每个服务实例都有一个 Envoy 代理服务器. 这样, 通信模式从直接 的 “服务 → 服务” 变为 “服务 → 源 Envoy → 目标 Envoy → 服务”.
“绞杀者应用模式” 指通过在遗留应用程序周围逐步开发新的 (绞杀) 应用程序来实现应用程序的现代化. 相 比于一步到位, 这样能在尽可能少对单体作修改的情况下, 尽早并且频繁的体现出微服务的价值.
有三种主要策略可以实现绞杀:
将新功能实现为服务 (“挖坑法则”: 如果你发现自己已经陷入困境, 就不要再给自己挖坑了), 并使用 “集成胶水” 代码让新服务能访问单体所拥有的数据及调用单体中的功能. 但并非每个新功能都能实现为 有意义的新服务 (如只是为某个类加一个字段), 最终还是要配合后面两种方法, 先实现在单体, 再提取 为新服务.
隔离表现层与后端 (水平切片). 表现层通常与业务和数据访问层有天然的接缝, 沿着它将单体分成两个 较小的应用. 把前后端拆分出来后可以分别独立布署, 还能公开一组可用于服务调用的 API.
提取业务能力到服务中 (垂直切片). 这项工作具有挑战性, 需要打破对象引用等依赖, 甚至需要拆分类, 还需要重构数据库. 应当优先关注那些能够提供高价值的部分进行.
保留在单体中的类可能会引用已移动到服务的类, 反之亦然. 如何消除跨越服务边界的对象引用?
我们根据 DDD 聚合进行思考, 把直接的对象引用改成使用主键相互引用. 但这么做可能会对本来期望的 是对象引用的客户类造成很大影响. 可以使用后面讲到的 “通过在服务和单体之间复制数据” 来减少更改 的范围.
提取服务时, 也需要移动数据, 将表从单体数据库中移动到服务的数据库. 重构数据库的一个主要挑战是会 影响该数据库的所有客户端, 它们需要进行改动以使用新的 Schema.
为避免大范围更改, 我们单体中的与提取到服务中的实体相关字段设置为只读, 并通过将数据从服务复制 回单体来使其保持最新. 还需要在单体中找出更新这些字段的代码, 把它们改成调用新服务. 如下图:
服务和单体通过 “集成胶水” 进行协作.
第一步是确定胶水为领域逻辑提供的 API. 应当根据是为了查询数据还是为了更新数据, 把服务和单体之间 的进程间通信机制封装在接口之后.
另一个设计决策是选择两者的交互方式和进程间通信机制. 这也取决于是查询还是更新数据:
另外, 我们不希望单体中的业务概念污染服务中的, 为此需要设计一个反腐层, 让它负责在两个不 同的领域模型之间进行转换. 它既要实现在我们在第一步中抽象出来的接口中以对 “通用语言” (DDD 中的 概念) 进行转换, 也得实现在事件处理程序中以对领域事件进行转换.
新服务可以直接使用我们讲过的模式发布和订阅事件, 而对于单体则可能没那么简单. 我们通常在单体中使用 事务拖尾或轮询, 在数据库级别发布事件.
上面我们讲了, 如果有一致性的需要, 必须使用 saga. 但在单体中实现补偿事务的问题是, 可能需要对它 进行大量且耗时的更改. 如果单体中的事务都是关键性事务或可重复性事务的话, 因为这两种类型的事务 无需补偿, 如此就能让单体中的 saga 实现变得简单的多了. 为了做到这一点, 必须小心的安排服务提取 的顺序, 以确保单体事务总是这两种类型的事务.
绞杀过程中, 一个挑战是: 你必须同时支持基于单体和基于 JWT 的安全机制. 幸运的是, 仅需要对单体的 登录处理程序进行一次小的修改即可: 额外返回一个 USERINFO 的 cookie, 其中包含用户信息, api gateway 将它带入标准的 Authorization 标头中请求服务.