微服务模式-全-

微服务模式(全)

原文:Microservices Patterns

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章. 逃离单体地狱

本章涵盖

  • 单体地狱的症状以及如何通过采用微服务架构来逃离它

  • 微服务架构的基本特征及其优缺点

  • 微服务如何使大型、复杂应用程序的 DevOps 开发风格成为可能

  • 微服务架构模式语言及其为什么应该使用它的原因

那天只是周一的中午,但 Mary,Food to Go,Inc.(FTGO)的首席技术官,已经感到沮丧。她的这一天开始得很顺利。上周,她与其他软件架构师和开发者参加了一个优秀的会议,学习了最新的软件开发技术,包括持续部署和微服务架构。Mary 还遇到了她从北卡罗来纳州 A&T 州立大学的计算机科学同学,分享了技术领导力的故事。会议让她感到充满力量,渴望改善 FTGO 的软件开发方式。

不幸的是,这种感觉很快就消失了。她刚刚在办公室里又经历了一次痛苦的会议,与高级工程和业务人员进行讨论。他们花了两个小时讨论为什么开发团队将错过另一个关键发布日期。遗憾的是,这类会议在过去几年中变得越来越常见。尽管采用了敏捷开发,但开发速度却在放缓,几乎不可能达到业务目标。更糟糕的是,似乎没有简单的解决方案。

会议让 Mary 意识到 FTGO 正遭受“单体地狱”的困扰,而治愈的方法是采用微服务架构。但会议中描述的微服务架构和相关最先进的软件开发实践似乎是一个遥不可及的梦想。Mary 不清楚她如何在应对当前的问题的同时,同时改善 FTGO 的软件开发方式。

幸运的是,正如你将在本书中学到的,有一个方法。但首先,让我们看看 FTGO 面临的问题以及他们是如何走到这一步的。

1.1. 向单体地狱缓慢迈进

自从 2005 年底推出以来,FTGO 发展迅速。如今,它是美国领先的在线食品配送公司之一。业务甚至计划拓展海外市场,尽管由于实施必要功能的延误,这些计划处于危险之中。

在本质上,FTGO 应用程序相当简单。消费者使用 FTGO 网站或移动应用程序在本地餐厅下订单。FTGO 协调一个快递网络,负责递送订单。它还负责支付快递和餐厅的费用。餐厅使用 FTGO 网站编辑他们的菜单和管理订单。该应用程序使用各种网络服务,包括 Stripe 支付、Twilio 消息传递和 Amazon Simple Email Service(SES)电子邮件服务。

与许多其他老化的企业应用程序一样,FTGO 应用程序是一个单体,由一个单一的 Java Web 应用程序存档(WAR)文件组成。多年来,它已经变成一个庞大而复杂的应用程序。尽管 FTGO 开发团队做出了最大的努力,但它已经成为 Big Ball of Mud 模式的例子(www.laputan.org/mud/)。引用该模式的作者 Foote 和 Yoder 的话,它是一个“随意结构、蔓延、混乱、胶带和铁丝、意大利面代码丛林”。软件交付的速度已经放缓。更糟糕的是,FTGO 应用程序使用了某些越来越过时的框架。FTGO 应用程序正显示出单体地狱的所有症状。

下一个部分将描述 FTGO 应用程序的架构。然后它将讨论为什么单体架构最初工作得很好。我们将探讨 FTGO 应用程序是如何超出其架构的,以及这是如何导致单体地狱的。

1.1.1. FTGO 应用程序的架构

FTGO 是一个典型的企业 Java 应用程序。图 1.1 显示了其架构。FTGO 应用程序具有六边形架构,这在第二章中描述得更为详细。在六边形架构中,应用程序的核心是业务逻辑。围绕业务逻辑的是各种适配器,它们实现 UI 并与外部系统集成。

图 1.1. FTGO 应用程序具有六边形架构。它由业务逻辑组成,周围是实现 UI 并与外部系统(如移动应用程序和支付、消息和电子邮件的云服务)接口的适配器。

图片 01fig01

业务逻辑由模块组成,每个模块都是一组领域对象。模块的例子包括订单管理配送管理计费支付。有几个适配器与外部系统接口。一些是入站适配器,通过调用业务逻辑处理请求,包括REST APIWeb UI适配器。其他是出站适配器,使业务逻辑能够访问 MySQL 数据库并调用 Twilio 和 Stripe 等云服务。

尽管逻辑上具有模块化架构,FTGO 应用程序仍然被打包成一个单一的 WAR 文件。该应用程序是广泛使用的单体软件架构风格的例子,它将系统结构为一个单一的可执行或可部署组件。如果 FTGO 应用程序是用 Go 语言(GoLang)编写的,它将是一个单一的执行文件。Ruby 或 NodeJS 版本的该应用程序将是一个单一的源代码目录层次结构。单体架构本身并不坏。FTGO 开发者在为应用程序选择单体架构时做出了明智的决定。

1.1.2. 单体架构的好处

在 FTGO 的早期,当应用程序相对较小的时候,其单体架构有很多好处:

  • 易于开发 集成开发环境(IDE)和其他开发者工具专注于构建单个应用程序。

  • 易于对应用程序进行重大更改 你可以更改代码和数据库模式,构建和部署。

  • 测试简单 开发者编写了端到端测试,启动应用程序,调用 REST API,并使用 Selenium 测试 UI。

  • 部署简单 开发者只需将 WAR 文件复制到已安装 Tomcat 的服务器上。

  • 易于扩展 FTGO 在负载均衡器后面运行了多个应用程序实例。

然而,随着时间的推移,开发、测试、部署和扩展变得更加困难。让我们看看原因。

1.1.3. 活在单体地狱中

不幸的是,正如 FTGO 的开发者所发现的,单体架构有一个巨大的限制。像 FTGO 这样的成功应用程序往往会长得超出单体架构的范畴。每个冲刺,FTGO 的开发团队实施了更多的故事,这使得代码库变得更大。此外,随着公司的日益成功,开发团队的大小稳步增长。这不仅增加了代码库的增长速度,还增加了管理成本。

如图 1.2 所示,曾经小巧、简单的 FTGO 应用程序在多年后已经成长为一个庞大的单体。同样,小型开发团队现在已成为多个 Scrum 团队,每个团队负责特定的功能区域。由于超出其架构,FTGO 陷入了单体地狱。开发缓慢且痛苦。敏捷开发和部署变得不可能。让我们看看这是为什么发生了。

图 1.2. 单体地狱的一个案例。庞大的 FTGO 开发团队将他们的更改提交到一个单一源代码仓库。从代码提交到生产的路径漫长而艰难,涉及手动测试。FTGO 应用程序庞大、复杂、不可靠,难以维护。

复杂性令开发者感到畏惧

FTGO 应用程序的一个主要问题是它过于复杂。它太大,以至于任何开发者都无法完全理解。因此,修复错误和正确实现新功能变得困难且耗时。截止日期被延误。

更糟糕的是,这种压倒性的复杂性往往会导致一个恶性循环。如果代码库难以理解,开发者就不会正确地做出更改。每次更改都会使代码库逐渐变得更加复杂,更难以理解。前面图 1.1 中展示的干净、模块化的架构并不反映现实。FTGO 正逐渐变成一个庞大、难以理解的“泥球”。

玛丽记得最近参加了一个会议,在那里她遇到了一个正在编写工具来分析他们数百万行代码(LOC)应用程序中数千个 JAR 之间依赖关系的开发者。当时,那个工具看起来像是 FTGO 可以使用的工具。现在她不太确定了。玛丽怀疑更好的方法是将架构迁移到更适合复杂应用程序的架构:微服务。

开发缓慢

除了要应对压倒性的复杂性,FTGO 开发者发现日常开发任务也缓慢。大型应用程序超载并减慢了开发者的 IDE。构建 FTGO 应用程序需要很长时间。此外,由于它如此庞大,应用程序启动也需要很长时间。因此,编辑-构建-运行-测试循环需要很长时间,这对生产力产生了严重影响。

从提交到部署的路径漫长且艰巨

FTGO 应用程序的另一个问题是将更改部署到生产是一个漫长且痛苦的过程。团队通常每月一次将更新部署到生产环境,通常是在周五或周六晚上。玛丽一直在阅读关于软件即服务(SaaS)应用程序的最新技术是持续部署:在办公时间内多次将更改部署到生产环境。显然,截至 2011 年,Amazon.com 每隔 11.6 秒就将一个更改部署到生产环境,而从未影响过用户!对于 FTGO 开发者来说,每月更新一次生产环境似乎是一个遥远的梦想。而且采用持续部署似乎几乎不可能。

FTGO 部分采用了敏捷开发。工程团队分为小队,并使用两周冲刺。不幸的是,从代码完成到在生产环境中运行的过程漫长且艰巨。这么多开发者提交到同一个代码库的一个问题是构建经常处于不可发布的状态。当 FTGO 开发者试图通过使用功能分支来解决这个问题时,他们的尝试导致了漫长而痛苦的合并。因此,一旦团队完成其冲刺,就会跟随一段长时间的测试和代码稳定期。

另一个原因是将更改部署到生产环境需要很长时间的原因是测试需要很长时间。由于代码库非常复杂,并且对更改的影响理解不充分,开发者和持续集成(CI)服务器必须运行整个测试套件。系统的一些部分甚至需要手动测试。诊断和修复测试失败的原因也需要一段时间。因此,完成一个测试周期需要几天时间。

扩展很困难

FTGO 团队在扩展其应用程序时也遇到了问题。这是因为不同的应用程序模块有不同的资源需求。例如,餐厅数据存储在一个大型的内存数据库中,理想情况下应该部署在拥有大量内存的服务器上。相比之下,图像处理模块对 CPU 资源需求较高,最好部署在拥有大量 CPU 的服务器上。由于这些模块是同一应用程序的一部分,FTGO 必须在服务器配置上做出妥协。

提供一个可靠的单体应用具有挑战性

FTGO 应用程序的另一个问题是可靠性不足。因此,经常出现生产中断。它不可靠的一个原因是由于其规模庞大,彻底测试应用程序很困难。这种不可测试性意味着错误会进入生产环境。更糟糕的是,应用程序缺乏故障隔离,因为所有模块都在同一个进程中运行。每隔一段时间,一个模块中的错误——例如内存泄漏——会逐一崩溃所有应用程序实例。FTGO 开发者不喜欢因为生产中断而在半夜被叫醒。商界人士对收入和信任的损失更是感到不满。

被锁定在越来越过时的技术堆栈中

FTGO 团队经历的最后一个单体地狱方面的问题是,该架构迫使他们使用一个变得越来越过时的技术堆栈。单体架构使得采用新框架和语言变得困难。重写整个单体应用程序以使用新的、可能更好的技术将极其昂贵且风险极高。因此,开发者被困在项目开始时所做的技术选择中。很多时候,他们必须维护使用越来越过时的技术堆栈编写的应用程序。

Spring 框架在保持向后兼容的同时持续发展,因此在理论上 FTGO 可能已经能够升级。不幸的是,FTGO 应用程序使用的框架版本与 Spring 的新版本不兼容。开发团队从未找到时间升级这些框架。因此,应用程序的主要部分是使用越来越过时的框架编写的。更重要的是,FTGO 开发者希望尝试使用 GoLang 和 NodeJS 等非 JVM 语言。遗憾的是,在单体应用程序中这是不可能的。

1.2. 为什么这本书对你很重要

很可能你是一名开发者、架构师、CTO 或工程副总裁。你负责的应用程序已经超出了其单体架构的范畴。像 FTGO 的玛丽一样,你在软件交付方面遇到了困难,并想知道如何逃离单体地狱。或者,也许你担心你的组织正在走向单体地狱,你希望在为时已晚之前改变方向。如果你需要逃离或避免单体地狱,这本书就是为你准备的。

本书花费了大量时间解释微服务架构的概念。我的目标是让你无论使用什么技术栈都能轻松理解这些材料。你所需要的只是熟悉企业应用程序架构和设计的基础知识。特别是,你需要了解以下内容:

  • 三层架构

  • 网络应用程序设计

  • 如何使用面向对象设计开发业务逻辑

  • 如何使用关系型数据库管理系统:SQL 和 ACID 事务

  • 如何使用消息代理和 REST API 进行进程间通信

  • 安全性,包括身份验证和授权

本书中的代码示例使用 Java 和 Spring 框架编写。这意味着为了充分利用示例,你需要熟悉 Spring 框架。

1.3. 本书你将学到什么

在你完成阅读这本书的时候,你将理解以下内容:

  • 微服务架构的基本特征、其优势和劣势,以及何时使用它

  • 分布式数据管理模式

  • 有效的微服务测试策略

  • 微服务的部署选项

  • 将单体应用程序重构为微服务架构的策略

你还将能够做以下事情:

  • 使用微服务架构模式设计应用程序

  • 为服务开发业务逻辑

  • 使用传奇(sagas)来维护服务之间的数据一致性

  • 实现跨服务的查询

  • 有效测试微服务

  • 开发安全、可配置和可观察的生产就绪服务

  • 将现有的单体应用程序重构为服务

1.4. 微服务架构的拯救

玛丽得出结论,FTGO 必须迁移到微服务架构。

有趣的是,软件架构与功能需求几乎没有关系。你可以使用任何架构来实现一组用例——应用程序的功能需求。实际上,对于像 FTGO 应用程序这样的成功应用,通常都是一团糟。

然而,架构很重要,因为它影响所谓的服务质量需求,也称为非功能性需求质量属性能力。随着 FTGO 应用程序的增长,各种质量属性都受到了影响,最值得注意的是那些影响软件交付速度的属性:可维护性、可扩展性和可测试性。

一方面,一个有纪律的团队能够减缓其走向单体地狱的步伐。团队成员可以努力保持其应用程序的模块化。他们可以编写全面的自动化测试。另一方面,他们无法避免一个大型团队在单个单体应用程序上工作的问题。他们也无法解决日益过时的技术堆栈问题。团队所能做的最好的事情就是推迟不可避免的事情。为了逃离单体地狱,他们必须迁移到新的架构:微服务架构。

现在,越来越多的人达成共识,如果你正在构建一个大型、复杂的应用程序,你应该考虑使用微服务架构。但微服务究竟是什么呢?不幸的是,这个名字并没有帮助,因为它过分强调了大小。微服务架构有众多定义。有些人过于字面地理解这个名字,声称服务应该是微小的——例如,100 行代码。其他人声称服务应该只花两周时间开发。前 Netflix 的阿德里安·科克罗斯(Adrian Cockcroft)将微服务架构定义为由松散耦合的元素组成的面向服务的架构,这些元素具有边界上下文。这不是一个坏的定义,但它有点复杂。让我们看看我们是否能做得更好。

1.4.1. 规模立方体和微服务

我对微服务架构的定义受到了马丁·艾布特(Martin Abbott)和迈克尔·费舍尔(Michael Fisher)的优秀著作《扩展的艺术》(The Art of Scalability,Addison-Wesley,2015)的启发。这本书描述了一个有用的三维扩展模型:规模立方体,如图 1.3 所示。

图 1.3. 规模立方体定义了三种独立的应用程序扩展方法:X 轴扩展在多个相同实例之间平衡请求;Z 轴扩展根据请求的属性路由请求;Y 轴在功能上将应用程序分解为服务。

该模型定义了三种扩展应用程序的方法:X、Y 和 Z。

X 轴扩展通过负载均衡器在多个实例之间平衡请求

X 轴 扩展是扩展单体应用程序的常见方法。图 1.4 展示了 X 轴扩展的工作原理。你运行多个应用程序实例,并在负载均衡器后面。负载均衡器将请求分配给应用程序的 N 个相同实例。这是一种提高应用程序容量和可用性的极好方法。

图 1.4. X 轴扩展在负载均衡器后面运行单体应用程序的多个、相同实例。

Z 轴扩展根据请求的属性路由请求

Z-axis 缩放也会运行单体应用的多个实例,但与 X-axis 缩放不同,每个实例只负责数据的一个子集。图 1.5 展示了 Z-axis 缩放的工作原理。实例前面的路由器使用请求属性将其路由到适当的实例。例如,一个应用程序可能会使用 userId 来路由请求。

图 1.5. Z-axis 缩放在路由器后面运行单体应用程序的多个相同实例,路由器根据 request 属性进行路由。每个实例只负责数据的一个子集。

在这个例子中,每个应用程序实例只负责用户的一个子集。路由器使用请求 Authorization 标头中指定的 userId 来选择应用程序的 N 个相同实例中的一个。Z-axis 缩放是扩展应用程序以处理增加的交易和数据量的绝佳方式。

Y-axis 缩放在功能上将应用程序分解为服务

X-和 Z-axis 缩放提高了应用程序的容量和可用性。但这两种方法都不能解决开发和应用复杂性增加的问题。要解决这些问题,你需要应用 Y-axis 缩放,或 功能分解。图 1.6 展示了 Y-axis 缩放的工作原理:通过将单体应用程序拆分为一组服务。

图 1.6. Y-axis 缩放将应用程序拆分为一组服务。每个服务负责特定的功能。服务使用 X-axis 缩放进行扩展,并且可能还使用 Z-axis 缩放。

服务 是一个实现狭窄关注功能的迷你应用程序,例如订单管理、客户管理等。服务使用 X-axis 缩放进行扩展,尽管一些服务也可能使用 Z-axis 缩放。例如,订单服务由一组负载均衡的服务实例组成。

微服务架构(微服务)的高级定义是一种将应用程序功能分解成一组服务的架构风格。请注意,这个定义并没有说任何关于大小的事情。相反,重要的是每个服务都有一组专注且连贯的责任。在本书的后面部分,我将讨论这意味着什么。

现在我们来看看微服务架构是如何成为模块化的一种形式的。

1.4.2. 微服务作为模块化的一种形式

模块化在开发大型、复杂应用程序时至关重要。像 FTGO 这样的现代应用程序太大,无法由个人开发。它也太复杂,无法由单一个人理解。应用程序必须分解成由不同的人开发和理解的模块。在单体应用程序中,模块是通过编程语言构造(如 Java 包)和构建工件(如 Java JAR 文件)的组合来定义的。然而,正如 FTGO 开发者所发现的那样,这种方法在实践中往往效果不佳。长期存在的单体应用程序通常会退化成大泥球。

微服务架构使用服务作为模块化的单元。一个服务有一个 API,这是一个难以违反的不透水边界。你不能像使用 Java 包那样绕过 API 访问内部类。因此,随着时间的推移,更容易保持应用程序的模块化。使用服务作为构建块还有其他好处,包括能够独立部署和扩展它们。

1.4.3. 每个服务都有自己的数据库

微服务架构的一个关键特征是服务之间松散耦合,并且仅通过 API 进行通信。实现松散耦合的一种方式是每个服务都有自己的数据存储。例如,在在线商店中,订单服务有一个包含ORDERS表的数据库,而客户服务有自己的数据库,其中包含CUSTOMERS表。在开发时,开发者可以更改服务的模式,而无需与其他服务的开发者协调。在运行时,服务之间是隔离的——例如,一个服务永远不会因为另一个服务持有数据库锁而被阻塞。

不要担心:松散耦合不会让拉里·埃里森更富有

每个服务需要有自己的数据库的要求并不意味着它有自己的数据库服务器。例如,你不必在 Oracle RDBMS 许可证上花费 10 倍的费用。第二章深入探讨了这一主题。

现在我们已经定义了微服务架构并描述了其一些基本特征,让我们看看这如何应用于 FTGO 应用程序。

1.4.4. FTGO 微服务架构

本书剩余部分将深入讨论 FTGO 应用程序的微服务架构。但首先让我们快速看一下将 Y 轴扩展应用于此应用程序意味着什么。如果我们对 FTGO 应用程序应用 Y 轴分解,我们得到图 1.7 所示的架构。分解后的应用程序由众多前端和后端服务组成。我们还会应用 X 轴和可能的 Z 轴扩展,以便在运行时每个服务都会有多个实例。

图 1.7. 基于微服务架构的 FTGO 应用程序的一些服务。API 网关将来自移动应用程序的请求路由到服务。服务通过 API 进行协作。

前端服务包括 API 网关和餐厅 Web UI。API 网关扮演门面角色,在第八章中详细描述,为消费者和快递员的移动应用程序提供 REST API。餐厅 Web UI 实现了餐厅用于管理菜单和处理订单的 Web 界面。

FTGO 应用程序的业务逻辑由多个后端服务组成。每个后端服务都有一个 REST API 和自己的私有数据存储。后端服务包括以下内容:

  • 订单服务 管理订单

  • 配送服务 管理从餐厅到消费者的订单配送

  • 餐厅服务 维护餐厅信息

  • 厨房服务 管理订单的准备

  • 会计服务 处理账单和支付

许多服务对应于本章前面描述的模块。不同之处在于每个服务及其 API 都定义得非常清晰。每个服务都可以独立开发、测试、部署和扩展。此外,这种架构很好地保持了模块化。开发者不能绕过服务的 API 来访问其内部组件。第十三章描述了如何将现有的单体应用程序转换为微服务。

1.4.5. 比较微服务架构和 SOA

一些微服务架构的批评者声称这并不是什么新东西——它是面向服务的架构(SOA)。在非常高的层面上,有一些相似之处。SOA 和微服务架构都是将系统结构化为服务集的架构风格。但正如表 1.1 所示,一旦深入挖掘,就会遇到显著的不同。

表 1.1. 比较 SOA 与微服务
SOA 微服务
服务间通信 使用重量级协议,如企业服务总线(ESB)的智能管道。 使用轻量级协议,如 REST 或 gRPC 的消息代理或直接服务到服务的通信的哑管道
数据 全局数据模型和共享数据库 每个服务的独立数据模型和数据库
典型服务 较大的单体应用程序 较小的服务

SOA 和微服务架构通常使用不同的技术栈。SOA 应用通常使用重量级技术,如 SOAP 和其他 WS标准。它们经常使用 ESB,一种包含业务和消息处理逻辑的智能管道来集成服务。使用微服务架构构建的应用程序倾向于使用轻量级、开源技术。服务通过哑管道*进行通信,例如消息代理或轻量级协议如 REST 或 gRPC。

SOA 和微服务架构在处理数据的方式上也存在差异。SOA 应用通常具有全局数据模型并共享数据库。相比之下,如前所述,在微服务架构中,每个服务都有自己的数据库。此外,正如第二章所述,每个服务通常被认为有自己的领域模型。

SOA 和微服务架构之间的另一个关键区别是服务的大小。SOA 通常用于集成大型、复杂、单体应用程序。尽管微服务架构中的服务并不总是微小的,但它们几乎总是比 SOA 中的服务小得多。因此,SOA 应用通常由几个大型服务组成,而基于微服务的应用程序通常由数十或数百个较小的服务组成。

1.5. 微服务架构的优点和缺点

让我们先考虑其优点,然后再看看其缺点。

1.5.1. 微服务架构的优点

微服务架构有以下优点:

  • 它使得大型、复杂的应用程序能够持续交付和部署。

  • 服务规模小且易于维护。

  • 服务可以独立部署。

  • 服务可以独立扩展。

  • 微服务架构使团队能够实现自治。

  • 它允许轻松实验和采用新技术。

  • 它具有更好的故障隔离性。

让我们看看每个优点。

使大型、复杂的应用程序能够持续交付和部署

微服务架构最重要的好处是它使得大型、复杂的应用程序能够持续交付和部署。如后文 1.7 节所述,持续交付/部署是DevOps的一部分,是一套旨在快速、频繁且可靠地交付软件的实践。表现优异的 DevOps 组织通常在生产中部署更改时遇到的生产问题非常少。

微服务架构实现持续交付/部署的三个方式如下:

  • *它具有持续交付/部署所需的可测试性—— 自动化测试是持续交付/部署的关键实践。由于微服务架构中的每个服务相对较小,自动化测试编写起来更容易,执行速度更快。因此,应用程序将具有更少的错误。

  • 它具有持续交付/部署所需的部署能力——*每个服务可以独立于其他服务进行部署。如果负责某个服务的开发者需要部署针对该服务的本地更改,他们不需要与其他开发者协调。他们可以部署他们的更改。因此,将更改频繁部署到生产中要容易得多。

  • 它使开发团队能够实现自治和松散耦合——*你可以将工程组织结构构建为一系列小型团队(例如,两个披萨大小的团队)。每个团队仅负责一个或多个相关服务的开发和部署。如图 1.8 所示,每个团队可以独立于其他团队开发、部署和扩展他们的服务。因此,开发速度要高得多。

图 1.8。基于微服务的 FTGO 应用程序由一系列松散耦合的服务组成。每个团队独立开发、测试和部署他们的服务。

图片

能够进行持续交付和部署具有以下几项商业优势:

  • 它缩短了上市时间,使企业能够快速响应客户的反馈。

  • 它使企业能够提供今天客户期望的可靠服务。

  • 由于更多时间用于交付有价值的功能而不是灭火,员工满意度更高。

因此,微服务架构已成为任何依赖软件技术的企业的基本要求。

每个服务都是小型且易于维护的

微服务架构的另一个好处是每个服务相对较小。代码更容易让开发者理解。小型代码库不会减慢集成开发环境(IDE),使开发者更加高效。而且每个服务通常启动得比大型单体应用快得多,这也使得开发者更加高效,并加快了部署速度。

服务可以独立扩展

在微服务架构中,每个服务都可以使用 X 轴克隆和 Z 轴分区独立于其他服务进行扩展。此外,每个服务都可以部署在最适合其资源需求硬件上。这与使用单体架构时大不相同,在单体架构中,具有截然不同资源需求(例如,CPU 密集型与内存密集型)的组件必须一起部署。

更好的故障隔离

微服务架构具有更好的故障隔离。例如,一个服务中的内存泄漏只会影响该服务。其他服务将继续正常处理请求。相比之下,单体架构中一个行为异常的组件可能会使整个系统崩溃。

轻松实验和采用新技术

最后但同样重要的是,微服务架构消除了对技术栈的长期承诺。原则上,当开发新服务时,开发者可以自由选择最适合该服务的语言和框架。在许多组织中,限制选择是有意义的,但关键点是你不受过去决策的限制。

此外,由于服务规模较小,使用更好的语言和技术重写它们变得可行。如果新技术的试验失败,你可以丢弃这项工作而不会危及整个项目。这与使用单体架构时大不相同,在单体架构中,你的初始技术选择会严重限制你未来使用不同语言和框架的能力。

1.5.2. 微服务架构的缺点

当然,没有哪种技术是万能的,微服务架构有许多显著的缺点和问题。实际上,本书的大部分内容都是关于如何解决这些缺点和问题。当你阅读关于挑战的内容时,不要担心。在这本书的后面部分,我将描述解决这些问题的方法。

下面是微服务架构的主要缺点和问题:

  • 找到合适的服务集是一项挑战。

  • 分布式系统很复杂,这使得开发、测试和部署变得困难。

  • 部署跨越多个服务的功能需要仔细的协调。

  • 决定何时采用微服务架构是困难的。

让我们逐一来看。

找到合适的服务是一项挑战

使用微服务架构的一个挑战是没有一个具体、明确地将系统分解为服务的算法。就像软件开发中的许多事情一样,这更像是一门艺术。更糟糕的是,如果你错误地分解了一个系统,你会构建一个分布式单体,这是一个由必须一起部署的耦合服务组成的系统。分布式单体既有单体架构的缺点,也有微服务架构的缺点。

分布式系统很复杂

使用微服务架构的另一个问题是开发者必须处理创建分布式系统带来的额外复杂性。服务必须使用进程间通信机制。这比简单的函数调用要复杂。此外,服务必须设计成能够处理部分故障,并处理远程服务不可用或延迟过高的情况。

实现跨越多个服务的用例需要使用不熟悉的技巧。每个服务都有自己的数据库,这使得实现跨越服务的交易和查询成为一项挑战。正如第四章所述,基于微服务的应用程序必须使用所谓的叙事来维护服务之间的数据一致性。第七章解释说,基于微服务的应用程序不能使用简单的查询从多个服务中检索数据。相反,它必须使用 API 组合或 CQRS 视图来实现查询。

集成开发环境(IDE)和其他开发工具专注于构建单体应用程序,并且不提供开发分布式应用程序的明确支持。编写涉及多个服务的自动化测试具有挑战性。这些都是特定于微服务架构的问题。因此,您的组织开发人员必须具备复杂的软件开发和交付技能,才能成功使用微服务。

微服务架构还引入了显著的操作复杂性。在生产中必须管理更多的移动部件——不同类型服务的多个实例。要成功部署微服务,您需要高度的自动化。您必须使用以下技术:

  • 自动部署工具,例如 Netflix Spinnaker

  • 一个现成的 PaaS,例如 Pivotal Cloud Foundry 或 Red Hat OpenShift

  • 一个 Docker 编排平台,例如 Docker Swarm 或 Kubernetes

我在第十二章中更详细地描述了部署选项。

部署跨越多个服务的功能需要仔细协调

使用微服务架构的另一个挑战是,部署跨越多个服务的功能需要在各个开发团队之间进行仔细的协调。您必须创建一个部署计划,该计划根据服务之间的依赖关系对服务部署进行排序。这与单体架构大不相同,在单体架构中,您可以轻松原子性地部署多个组件的更新。

决定何时采用是困难的

使用微服务架构的另一个问题是确定在应用程序的生命周期中何时应该使用这种架构。在开发应用程序的第一个版本时,您通常不会遇到这种架构解决的问题。此外,使用复杂的分布式架构会减慢开发速度。这对初创公司来说可能是一个重大的困境,因为最大的问题通常是如何快速演变商业模式和相应的应用程序。使用微服务架构会使快速迭代变得更加困难。初创公司几乎肯定应该从单体应用程序开始。

然而,后来当问题是如何处理复杂性时,那时将应用程序功能分解成一组微服务是有意义的。您可能会发现由于复杂的依赖关系而难以重构。第十三章介绍了将单体应用重构为微服务的方法。

如您所见,微服务架构提供了许多好处,但也存在一些显著的缺点。正因为这些问题,采用微服务架构不应轻率行事。但对于面向消费者的 Web 应用或 SaaS 应用等复杂应用来说,这通常是一个正确的选择。像 eBay(www.slideshare.net/RandyShoup/the-ebay-architecture-striking-a-balance-between-site-stability-feature-velocity-performance-and-cost)、Amazon.com、Groupon 和 Gilt 等知名网站都已经从单体架构演变为微服务架构。

在使用微服务架构时,您必须解决许多设计和架构问题。更重要的是,许多这些问题都有多个解决方案,每个解决方案都有一套不同的权衡。没有一种单一的完美解决方案。为了帮助您做出决策,我创建了微服务架构模式语言。我在本书的其余部分引用这个模式语言,向您介绍微服务架构。让我们看看模式语言是什么以及为什么它有帮助。

1.6. 微服务架构模式语言

架构和设计都是关于做决定。您需要决定单体架构或微服务架构最适合您的应用。在做出这些决定时,您需要考虑许多权衡。如果您选择微服务架构,您将需要解决许多问题。

描述各种架构和设计选项并提高决策质量的一个好方法是使用模式语言。让我们首先看看为什么我们需要模式和模式语言,然后我们将游览微服务架构模式语言。

1.6.1. 微服务架构并非万能药

回到 1986 年,《人月神话》(Addison-Wesley Professional,1995 年)的作者 Fred Brooks 说,在软件工程中,没有万能药。这意味着没有技术或技术如果采用就会给您带来十倍的生产力提升。然而,几十年后,开发者们仍然激烈地争论他们最喜欢的万能药,坚信他们最喜欢的技术将给他们带来巨大的生产力提升。

许多争论遵循好/坏二分法nealford.com/memeagora/2009/08/05/suck-rock-dichotomy.html),这是尼尔·福特创造的术语,用来描述软件世界中的每一件事要么很糟糕要么很棒,没有中间地带。这些争论具有以下结构:如果你做 X,那么一只小狗会死,所以你必须做 Y。例如,同步编程与反应式编程,面向对象与函数式,Java 与 JavaScript,REST 与消息传递。当然,现实要复杂得多。每种技术都有其倡导者经常忽视的缺点和局限性。因此,技术的采用通常遵循Gartner 炒作周期en.wikipedia.org/wiki/Hype_cycle),其中一种新兴技术要经历五个阶段,包括期望过高的顶峰(它很棒),接着是幻灭的低谷(它很糟糕),最后是生产力的平台期(我们现在理解了权衡和何时使用它)。

微服务并非对银弹现象免疫。这种架构是否适合你的应用程序取决于许多因素。因此,总是建议使用微服务架构是不良的建议,但同样,不建议永远不使用它。就像许多事情一样,这取决于具体情况。

这些关于技术的极端和夸张争论的潜在原因是人类主要受情感驱动。乔纳森·海蒂在他的优秀著作《正义之心:为什么好人会被政治和宗教分裂》(Vintage,2013 年)中,用大象和骑手的比喻来描述人类思维的工作方式。大象代表人类大脑的情感部分。它做出了大部分决定。骑手代表大脑的理性部分。它有时可以影响大象,但大多数时候只是为大象的决定提供正当理由。

我们——软件开发社区——需要克服我们的情感天性,找到更好的讨论和应用技术的方法。讨论和描述技术的一个好方法是使用模式格式,因为它客观。例如,在模式格式中描述一项技术时,你必须描述其缺点。让我们看看模式格式。

1.6.2. 模式和模式语言

模式是针对特定情境中出现的问题的可重用解决方案。它是一个源于现实世界建筑的思想,并在软件架构和设计中被证明是有用的。模式的概念是由现实世界建筑师克里斯托弗·亚历山大提出的。他还创造了模式语言的概念,这是一组相关的模式,用于解决特定领域内的问题。他的著作《模式语言:城镇、建筑、建造》(牛津大学出版社,1977 年)描述了一种建筑模式语言,包含 253 个模式。这些模式从解决高层次问题,如城市的位置(“水源接入”),到解决低层次问题,如如何设计房间(“每个房间两面都有光”)。每个模式通过安排从城市到窗户等不同范围的物理对象来解决问题。

克里斯托弗·亚历山大的著作启发了软件社区采用模式及其模式语言的概念。《设计模式:可复用面向对象软件元素》(Addison-Wesley Professional,1994 年),由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 所著,是一本面向对象设计模式的集合。这本书在软件开发者中普及了模式。自 1995 年代中期以来,软件开发者已经记录了大量的软件模式。软件模式通过定义一组协作的软件元素来解决软件架构或设计问题。

例如,让我们想象你正在构建一个必须支持各种透支政策的银行应用程序。每个政策定义了账户余额的限制以及透支账户的收费。你可以使用策略模式解决这个问题,这是经典《设计模式》书中一个众所周知的设计模式。策略模式定义的解决方案由三个部分组成:

  • 一个名为Overdraft的策略接口,它封装了透支算法

  • 一个或多个具体的策略类,每个类对应一个特定的情境

  • 使用算法的Account

策略模式是一种面向对象的设计模式,因此解决方案的元素是类。在本节的后半部分,我将描述高级设计模式,其中解决方案由协作服务组成。

模式有价值的一个原因是,模式必须描述其应用的上下文。一个解决方案是特定于某个特定上下文,可能在其他上下文中效果不佳的想法,是技术通常讨论方式的一种改进。例如,解决 Netflix 规模问题的解决方案可能不适合用户较少的应用程序。

然而,模式的价值远远超出了要求你考虑问题情境的需要。它迫使你描述其他关键但经常被忽视的解决方案方面。常用的模式结构包括三个特别有价值的部分:

  • 力量

  • 结果情境

  • 相关模式

让我们逐一查看这些内容,从力量开始。

力量:解决问题时必须解决的问题

模式的“力量”部分描述了在特定情境中解决问题时必须处理的力(问题)。力量可能存在冲突,因此可能无法解决所有问题。哪些力量更重要取决于情境。你必须优先解决某些力量而不是其他力量。例如,代码必须易于理解并且性能良好。以响应式风格编写的代码比同步代码性能更好,但通常更难以理解。明确列出力量是有用的,因为它清楚地表明哪些问题需要解决。

结果情境:应用模式的结果

模式的“结果情境”部分描述了应用模式的结果。它包括三个部分:

  • 好处模式的好处,包括已解决的力

  • 缺点模式的缺点,包括未解决的力

  • 问题应用模式所引入的新问题

结果情境提供了更完整且更少偏见的解决方案视角,这有助于做出更好的设计决策。

相关模式:五种不同类型的关系

模式的“相关模式”部分描述了模式与其他模式之间的关系。模式之间存在五种类型的关系:

  • 前驱模式前驱模式是激发对这种模式需求的前驱模式。例如,微服务架构模式是模式语言中除单体架构模式之外所有模式的先导模式。

  • 后续模式解决由该模式引入的问题的模式。例如,如果你应用了微服务架构模式,你必须随后应用许多后续模式,包括服务发现模式和断路器模式。

  • 替代方案提供替代解决方案的模式。例如,单体架构模式和微服务架构模式是构建应用程序的替代架构方式。你选择其中一个或另一个。

  • 泛化一个通用于解决问题的模式。例如,在第十二章中,你将了解单服务每主机模式的不同实现。

  • 专业化 某个特定模式的特殊形式。例如,在第十二章 chapter 12 中,您将了解到将服务作为容器部署模式是单机单服务模式的特殊形式。

此外,您还可以将解决特定问题领域的模式组织成组。相关模式的明确描述为如何有效地解决特定问题提供了宝贵的指导。图 1.9 展示了模式之间关系的视觉表示。

图 1.9. 模式之间不同类型关系的视觉表示:一个 后继 模式解决由 前驱 模式应用而产生的问题;两个或更多模式可以是同一问题的 替代 解决方案;一个模式可以是另一个模式的 专业化;并且解决同一领域问题的模式可以分组,或 泛化

图 1.9 中展示的模式之间的不同关系如下所示:

  • 表示前驱-后继关系

  • 相同问题的替代解决方案

  • 表示一个模式是另一个模式的特殊形式

  • 适用于特定问题领域的模式

通过这些关系相关联的模式集合有时形成所谓的模式语言。模式语言中的模式共同工作,以解决特定领域的问题。特别是,我创建了微服务架构模式语言。它是一系列相互关联的微服务软件架构和设计模式。让我们来看看这个模式语言。

1.6.3. 微服务架构模式语言概述

微服务架构模式语言是一系列模式,帮助您使用微服务架构来构建应用程序。图 1.10 展示了模式语言的高级结构。模式语言首先帮助您决定是否使用微服务架构。它描述了单体架构和微服务架构,以及它们的优缺点。然后,如果微服务架构适合您的应用程序,模式语言通过解决各种架构和设计问题,帮助您有效地使用它。

图 1.10. 微服务架构模式语言的概览,展示了这些模式解决的不同问题领域。左侧是应用架构模式:单体架构和微服务架构。所有其他模式组解决的是选择微服务架构模式所带来的问题。

模式语言由几个模式组组成。在 图 1.10 的左侧是应用架构模式组,包括单体架构模式和微服务架构模式。这些就是本章讨论的模式。模式语言的其余部分由解决使用微服务架构模式引入的问题的模式组组成。

模式还被分为三层:

  • 基础设施模式 这些解决大多数是开发之外的基础设施问题。

  • 应用基础设施 这些是影响开发的基础设施问题。

  • 应用模式 这些解决开发者面临的问题。

这些模式根据它们解决的问题类型分组。让我们看看主要模式组。

将应用程序分解为服务的模式

决定如何将系统分解为一系列服务在很大程度上是一种艺术,但有一些策略可以帮助。在 图 1.11 中显示的两个分解模式是您可以用来定义应用程序架构的不同策略。

图 1.11. 有两种分解模式:按业务能力分解,它围绕业务能力组织服务,以及按子域分解,它围绕领域驱动设计 (DDD) 子域组织服务。

第二章 详细描述了这些模式。

通信模式

使用微服务架构构建的应用程序是一个分布式系统。因此,进程间通信 (IPC) 是微服务架构的一个重要部分。您必须就您的服务如何相互通信以及与外部世界的通信做出各种架构和设计决策。图 1.12 显示了通信模式,这些模式被组织成五个组:

  • 通信风格 应该使用哪种 IPC 机制?

  • 发现 服务客户端如何确定服务实例的 IP 地址,例如,以便进行 HTTP 请求?

  • 可靠性 即使服务可能不可用,您如何确保服务之间的通信是可靠的?

  • 事务消息 如何将消息发送和事件发布的集成与更新业务数据的数据库事务相结合?

  • 外部 API 您的应用程序客户端如何与服务通信?

图 1.12. 五种通信模式组

第三章 探讨了前四个模式组:通信风格、发现、可靠性和事务消息。第八章 探讨外部 API 模式。

实现事务管理的数据一致性模式

如前所述,为了确保松耦合,每个服务都有自己的数据库。不幸的是,每个服务拥有自己的数据库引入了一些重大问题。我在第四章中描述了传统的使用分布式事务(2PC)的方法对于现代应用来说不是一个可行的选项。相反,应用程序需要通过使用 Saga 模式来维护数据一致性。图 1.13 展示了与数据相关的模式。

图 1.13。由于每个服务都有自己的数据库,你必须使用 Saga 模式来维护服务之间的数据一致性。

图片 1.14

第四章、第五章和第六章更详细地描述了这些模式。

微服务架构中查询数据的模式

使用每个服务一个数据库的另一个问题是,某些查询需要连接多个服务拥有的数据。服务的数据只能通过其 API 访问,因此你不能对其数据库执行分布式查询。图 1.14 展示了你可以用来实现查询的几个模式。

图 1.14。由于每个服务都有自己的数据库,你必须使用查询模式之一来检索分散在多个服务中的数据。

图片 1.13

有时你可以使用 API 组合模式,该模式调用一个或多个服务的 API 并聚合结果。其他时候,你必须使用命令查询责任分离(CQRS)模式,该模式维护一个或多个易于查询的数据副本。第七章探讨了实现查询的不同方法。

服务部署模式

部署单体应用并不总是容易,但从某种意义上说,它很简单,因为只有一个应用需要部署。你必须在负载均衡器后面运行应用程序的多个实例。

相比之下,部署基于微服务的应用要复杂得多。可能有成十或上百个服务,它们是用各种语言和框架编写的。有许多更多的移动部件需要管理。图 1.15 展示了部署模式。

图 1.15。部署微服务的几种模式。传统的方法是将服务部署在特定语言的打包格式中。有两种现代的服务部署方法。第一种是将服务作为虚拟机或容器部署。第二种是无服务器方法。你只需上传服务的代码,无服务器平台就会运行它。你应该使用服务部署平台,这是一个自动的、自助的平台,用于部署和管理服务。

图片 1.15

传统的、通常是手动的方式在特定语言的打包格式中部署应用程序,例如 WAR 文件,无法扩展以支持微服务架构。您需要一个高度自动化的部署基础设施。理想情况下,您应该使用提供开发者简单 UI(命令行或 GUI)以部署和管理其服务的部署平台。部署平台通常基于虚拟机(VM)、容器或无服务器技术。第十二章探讨了不同的部署选项。

可观察性模式提供对应用程序行为的洞察

运营应用程序的关键部分是理解其运行时行为和诊断问题,如失败的请求和高延迟。虽然理解和诊断单体应用程序并不总是容易,但请求以简单直接的方式处理是有帮助的。每个传入的请求都会被负载均衡到特定的应用程序实例,该实例对数据库进行少量调用并返回响应。例如,如果您需要了解特定请求的处理方式,您会查看处理该请求的应用程序实例的日志文件。

相比之下,理解和诊断微服务架构中的问题要复杂得多。在最终将响应返回给客户端之前,一个请求可以在多个服务之间弹跳。因此,没有单一的日志文件可以检查。同样,由于存在多个嫌疑人,延迟问题也更难以诊断。

您可以使用以下模式来设计可观察的服务:

  • 健康检查 API 暴露一个返回服务健康状况的端点。

  • 日志聚合 记录服务活动并将日志写入集中式日志服务器,该服务器提供搜索和警报功能。

  • 分布式跟踪 为每个外部请求分配一个唯一的 ID,并跟踪请求在服务之间的流动。

  • 异常跟踪 将异常报告给异常跟踪服务,该服务去重异常、提醒开发者并跟踪每个异常的解决情况。

  • 应用指标 维护指标,例如计数器和仪表,并将它们暴露给指标服务器。

  • 审计日志 记录用户操作。

第十一章更详细地描述了这些模式。

服务自动化测试模式

微服务架构使得单个服务更容易测试,因为它们比单体应用程序小得多。同时,尽管如此,测试不同服务协同工作而避免使用复杂、缓慢且脆弱的端到端测试(这些测试多个服务一起)也很重要。以下是在隔离测试服务中简化测试的模式:

  • 消费者驱动的合同测试 验证服务是否满足其客户端的期望。

  • 客户端合同测试 验证服务客户端能否与服务通信。

  • 服务组件测试 在隔离状态下测试服务。

第九章和第十章更详细地描述了这些测试模式。

处理横切关注点的模式

在微服务架构中,每个服务都必须实现许多关注点,包括可观察性模式和发现模式。它还必须实现外部化配置模式,该模式在运行时向服务提供配置参数,例如数据库凭证。当开发新的服务时,从头开始重新实现这些关注点将非常耗时。一个更好的方法是应用微服务底盘模式,并在处理这些关注点的框架之上构建服务。第十一章更详细地描述了这些模式。

安全模式

在微服务架构中,用户通常由 API 网关进行身份验证。然后它必须将有关用户的信息,例如身份和角色,传递给它调用的服务。一个常见的解决方案是应用访问令牌模式。API 网关传递一个访问令牌,例如 JWT(JSON Web Token),到服务,这些服务可以验证令牌并获取有关用户的信息。第十一章更详细地讨论了访问令牌模式。

毫不奇怪,微服务架构模式语言中的模式主要集中在解决架构和设计问题上。你当然需要正确的架构才能成功开发软件,但这并非唯一关注点。你还必须考虑流程和组织。

1.7. 超越微服务:流程和组织

对于大型、复杂的应用程序,微服务架构通常是最佳选择。但除了拥有正确的架构外,成功的软件开发还需要你有组织,以及开发和交付流程。图 1.16 展示了流程、组织和架构之间的关系。

图 1.16。快速、频繁和可靠地交付大型、复杂应用程序需要 DevOps 的组合,包括持续交付/部署、小型、自主团队和微服务架构。

图 1.16 的替代文本

我已经描述了微服务架构。让我们看看组织和流程。

1.7.1. 软件开发和交付组织

成功不可避免地意味着工程团队将扩大。一方面,这很好,因为更多的开发者可以完成更多的工作。大型团队的问题,正如弗雷德·布鲁克斯在《人月神话》中所写,团队规模为N的沟通开销是O(N²)。如果团队规模过大,由于沟通开销,它将变得低效。例如,想象一下试图与 20 人进行每日站立会议。

解决方案是将一个大型单一团队重构为多个团队。每个团队规模较小,由不超过 8-12 人组成。它有一个明确以业务为导向的使命:开发和可能运营一个或多个实现功能或业务能力的服务。团队是跨职能的,可以在不经常与其他团队沟通或协调的情况下开发、测试和部署其服务。

逆向康威行动

为了在使用微服务架构有效交付软件时,你需要考虑康威定律(en.wikipedia.org/wiki/Conway%27s_law),该定律如下所述:

组织在设计和构建系统时...会受到限制,只能产生与这些组织的沟通结构相匹配的设计。

梅尔文·康威

换句话说,你的应用程序的架构反映了开发它的组织的结构。因此,逆向应用康威定律(www.thoughtworks.com/radar/techniques/inverse-conway-maneuver)并设计你的组织,使其结构反映你的微服务架构是非常重要的。通过这样做,你确保你的开发团队与服务的耦合性一样松散。

多个团队的速度显著高于单个大型团队的速度。如前所述在第 1.5.1 节,微服务架构在使团队实现自治方面发挥着关键作用。每个团队可以开发、部署和扩展其服务,而无需与其他团队协调。此外,当服务未满足其服务级别协议(SLA)时,非常清楚应该联系谁。

此外,开发组织具有更高的可扩展性。你通过添加团队来扩大组织。如果一个单一团队变得过大,你可以将其及其相关的服务或服务拆分。由于团队松散耦合,你可以避免大型团队的沟通开销。因此,你可以添加人员而不影响生产力。

1.7.2. 软件开发和交付流程

使用瀑布式开发流程的微服务架构就像驾驶一辆马拉的法拉利——你浪费了使用微服务的大部分好处。如果你想使用微服务架构开发应用程序,采用敏捷开发和部署实践,如 Scrum 或 Kanban,是至关重要的。更好的是,你应该实践持续交付/部署,这是 DevOps 的一部分。

Jez Humble 在 continuousdelivery.com/ 将持续交付定义为如下:

持续交付是能够以安全、快速且可持续的方式将所有类型的更改(包括新功能、配置更改、错误修复和实验)投入生产或用户手中的能力。

持续交付的一个关键特征是软件始终可发布。它依赖于高度自动化,包括自动化测试。持续部署在自动将可发布代码部署到生产环境中进一步推进了持续交付的实践。实践持续部署的高性能组织每天多次将代码部署到生产中,生产中断事件远少,并且能够快速从任何发生的事件中恢复。如前所述,微服务架构直接支持持续交付/部署。

快速行动,不破坏事物

持续交付/部署(以及更广泛的 DevOps)的目标是快速且可靠地交付软件。以下四个用于评估软件开发的有用指标如下:

  • 部署频率 软件被部署到生产环境的频率

  • 平均恢复时间 从生产问题恢复所需的时间

  • 平均恢复时间 从生产问题恢复所需的时间

  • 变更失败率 导致生产问题的变更的百分比

在一个传统组织中,部署频率低,平均恢复时间长。压力山大的开发人员和运维人员通常会在维护窗口期间熬夜修复最后一刻的问题。相比之下,DevOps 组织频繁发布软件,通常每天发布多次,生产问题远少。例如,亚马逊在 2014 年每 11.6 秒就将更改部署到生产环境中 (www.youtube.com/watch?v=dxk8b9rSKOo),Netflix 一个软件组件的平均恢复时间为 16 分钟 (medium.com/netflix-techblog/how-we-build-code-at-netflix-c5d9bd727f15)。

1.7.3. 采用微服务的“人”的方面

采用微服务架构会改变你的架构、组织和开发流程。然而,最终,它改变了人们的办公环境,正如前面提到的,人们是情感动物。如果忽视他们的情绪,他们的情绪可能会使微服务的采用变得崎岖不平。玛丽和其他 FTGO 领导者将努力改变 FTGO 开发软件的方式。

畅销书《管理过渡》(Da Capo Lifelong Books,2017 年,wmbridges.com/books)的作者威廉和苏珊·布里奇斯介绍了“过渡”的概念,它指的是人们如何情感上对变革做出反应的过程。它描述了一个三阶段的过渡模型:

  1. 结束、失去和放手 当人们面临一个迫使他们走出舒适区的变革时,会出现情感动荡和抵抗的时期。他们常常哀悼失去旧做事方式。例如,当人们重组为跨职能团队时,他们会怀念他们以前的同学。同样,拥有全球数据模型的数据建模组可能会受到每个服务都有自己的数据模型这一想法的威胁。

  2. 中立区 在旧方式和新方式之间的中间阶段,人们常常感到困惑。他们经常努力学习新做事的方式。

  3. 新的开始 这是人们热情拥抱新做事方式并开始体验其益处的最终阶段。

本书描述了如何最好地管理过渡的每个阶段并提高成功实施变革的可能性。FTGO 当然正遭受单体地狱的困扰,需要迁移到微服务架构。它还必须改变其组织和开发流程。然而,为了使 FTGO 能够成功完成这一任务,它必须考虑过渡模型并考虑人们的情绪。

在下一章中,你将了解软件架构的目标以及如何将应用程序分解为服务。

摘要

  • 单体架构模式将应用程序结构化为一个单一的部署单元。

  • 微服务架构模式将系统分解为一系列独立可部署的服务,每个服务都有自己的数据库。

  • 单体架构对于简单应用来说是一个不错的选择,但对于大型、复杂的应用程序,微服务架构通常是更好的选择。

  • 微服务架构通过允许小型、自主团队并行工作,加速了软件开发的速度。

  • 微服务架构并不是万能的银弹——存在显著的缺点,包括复杂性。

  • 微服务架构模式语言是一系列帮助您使用微服务架构来构建应用程序的模式。它帮助您决定是否使用微服务架构,如果您选择了微服务架构,模式语言将帮助您有效地应用它。

  • 仅靠微服务架构并不能加速软件交付。成功的软件开发还需要 DevOps 和小型、自主的团队。

  • 不要忘记采用微服务的“人”的一面。为了成功过渡到微服务架构,您需要考虑员工的情绪。

第二章. 分解策略

本章涵盖

  • 理解软件架构及其重要性

  • 通过应用分解模式“按业务能力分解”和“按子域分解”将应用程序分解为服务

  • 使用领域驱动设计(DDD)中的边界上下文概念来解开数据并使分解更容易

有时候你必须小心你所期望的。经过一番激烈的游说努力,玛丽最终说服了公司,迁移到微服务架构是正确的做法。感到既兴奋又有些紧张,玛丽与她的建筑师们进行了一整天的会议,讨论从哪里开始。在讨论中,很明显,微服务架构模式语言的一些方面,如部署和服务发现,虽然新颖且不熟悉,但却是直接的。关键挑战,即微服务架构的本质,是将应用程序分解为服务。因此,架构的第一个也是最重要的方面是定义服务。当他们围在白板旁时,FTGO 团队想知道究竟该如何做!

在本章中,你将学习如何为应用程序定义微服务架构。我描述了将应用程序分解为服务的策略。你将了解到服务是围绕业务关注点而不是技术关注点组织的。我还展示了如何使用领域驱动设计(DDD)中的思想来消除神级类,这些类在整个应用程序中使用并导致纠缠的依赖关系,从而阻碍了分解。

我首先通过软件架构概念来定义微服务架构。之后,我描述了一个从应用需求出发定义微服务架构的过程。我讨论了将应用程序分解为服务集合的策略、面临的障碍以及如何克服它们。让我们先来考察一下软件架构的概念。

2.1. 精确来说,微服务架构是什么?

第一章描述了微服务架构的关键思想是功能分解。不是开发一个大型的应用程序,而是将应用程序结构化为一系列服务。一方面,将微服务架构描述为一种功能分解是有用的。但另一方面,它也留下了一些未解决的问题,包括微服务架构如何与更广泛的软件架构概念相关?什么是服务?以及服务的大小有多重要?

为了回答这些问题,我们需要退后一步,看看“软件架构”的含义。软件应用程序的架构是其高级结构,它由组成部分及其之间的依赖关系组成。正如你将在本节中看到的,应用程序的架构是多维的,因此有多种描述它的方法。架构之所以重要,是因为它决定了应用程序的软件质量属性或“-ilities”。传统上,架构的目标是可扩展性、可靠性和安全性。但今天,架构还必须能够实现软件的快速和安全交付。你将了解到微服务架构是一种提供高可维护性、可测试性和可部署性的架构风格。

我从这个部分开始,描述了“软件架构”的概念以及为什么它很重要。接下来,我讨论了架构风格的想法。然后,我将微服务架构定义为一种特定的架构风格。让我们先从软件架构的概念开始看起。

2.1.1. 什么是软件架构以及为什么它很重要?

架构显然很重要。至少有两个会议致力于这个主题:O’Reilly 软件架构会议(conferences.oreilly.com/software-architecture)和 SATURN 会议(resources.sei.cmu.edu/news-events/events/saturn/)。许多开发者的目标是成为一名架构师。但什么是架构以及为什么它很重要?

为了回答这个问题,我首先定义了“软件架构”这个术语的含义。之后,我讨论了应用程序的架构是多维的,并且最好使用一系列视图或蓝图来描述。然后,我说明了软件架构之所以重要,是因为它对应用程序的软件质量属性有影响。

软件架构的定义

软件架构有许多定义。例如,可以查看en.wikiquote.org/wiki/Software_architecture来阅读一些定义。我最喜欢的定义来自软件工程研究所的 Len Bass 和同事们(www.sei.cmu.edu),他们在建立软件架构作为一门学科中发挥了关键作用。他们如下定义软件架构:

计算系统的软件架构是用于推理系统的结构集合,它包括软件元素、它们之间的关系以及它们的属性。

《软件架构文档:Bass 等人著》

这显然是一个相当抽象的定义。但其本质是,一个应用程序的架构是其分解成部分(元素)以及这些部分之间的关系(关系)。分解之所以重要,有几个原因:

  • 它促进了劳动力和知识的分工。它使得具有可能专门知识的多个人(或多个团队)能够高效地共同工作在一个应用程序上。

  • 它定义了软件元素如何交互。

是将应用分解成部分以及这些部分之间的关系决定了应用的可用性

软件架构的 4+1 视图模型

更具体地说,一个应用程序的架构可以从多个角度来审视,就像一座建筑的架构可以从结构、管道、电气和其他角度来审视一样。菲利普·克鲁滕(Phillip Krutchen)撰写了一篇经典论文,描述了软件架构的 4+1 视图模型,“架构蓝图——软件架构的‘4+1’视图模型”(www.cs.ubc.ca/~gregor/teaching/papers/4+1view-architecture.pdf)。4+1 模型,如图 2.1 所示,定义了软件架构的四个不同视图。每个视图都描述了架构的特定方面,并包含一组特定的软件元素及其之间的关系。

图 2.1. 4+1 视图模型使用四个视图来描述应用程序的架构,以及场景展示了每个视图中的元素如何协作来处理请求。

每个视图的目的如下:

  • 逻辑视图 开发者创建的软件元素。在面向对象的语言中,这些元素是类和包。它们之间的关系包括类和包之间的关系,包括继承、关联和依赖。

  • 实现视图 构建系统的输出。这个视图由模块组成,代表打包的代码,以及组件,它们是由一个或多个模块组成的可执行或可部署单元。在 Java 中,模块是一个 JAR 文件,组件通常是 WAR 文件或可执行 JAR 文件。它们之间的关系包括模块之间的依赖关系和组件与模块之间的组合关系。

  • 过程视图 运行时的组件。每个元素是一个进程,进程之间的关系代表进程间通信。

  • 部署 过程是如何映射到机器上的。这个视图中的元素包括(物理或虚拟)机器和过程。机器之间的关系代表网络。这个视图还描述了过程和机器之间的关系。

除了这四个视图之外,还有场景——4+1 模型中的+1,它们使视图生动起来。每个场景描述了特定视图中各种架构组件如何协作来处理请求。例如,逻辑视图中的场景显示了类如何协作。同样,过程视图中的场景显示了进程如何协作。

4+1 视图模型是描述应用程序架构的绝佳方式。每个视图都描述了架构的一个重要方面,而场景说明了视图元素如何协作。现在让我们看看为什么架构很重要。

为什么架构很重要

应用程序有两个要求类别。第一个类别包括功能性要求,这些要求定义了应用程序必须做什么。它们通常以用例或用户故事的形式出现。架构与功能性要求几乎没有关系。你可以用几乎任何架构来实现功能性要求,甚至是一个大泥球。

架构之所以重要,是因为它使应用程序能够满足第二类要求:其服务质量要求。这些也被称为质量属性,也就是所谓的-ilities。服务质量要求定义了运行时质量,如可伸缩性和可靠性。它们还定义了开发时间质量,包括可维护性、可测试性和可部署性。你为应用程序选择的架构决定了它如何满足这些质量要求。

2.1.2. 架构风格概述

在物理世界中,一座建筑物的架构通常遵循特定的风格,例如维多利亚式、美国手工艺人或装饰艺术。每种风格都是一组设计决策的集合,限制了建筑物的功能和建筑材料。建筑风格的概念也适用于软件。大卫·加兰和玛丽·肖(《软件架构导论》,1994 年 1 月,www.cs.cmu.edu/afs/cs/project/able/ftp/intro_softarch/intro_softarch.pdf),软件架构领域的先驱,将架构风格定义为以下内容:

因此,架构风格定义了一组这样的系统,通过结构组织的模式。更具体地说,一个架构风格决定了在该风格实例中可以使用的组件和连接器的词汇,以及它们如何组合的一组约束。

特定的架构风格提供了一组有限的元素(组件)和关系(连接器),您可以从这些元素中定义应用程序架构的视图。应用程序通常使用多种架构风格的组合。例如,在本节的后面部分,我将描述单体架构是如何作为一种架构风格,将实现视图结构化为单个(可执行/可部署)组件的。微服务架构将应用程序结构化为一组松散耦合的服务。

分层架构风格

架构风格的经典例子是分层架构。分层架构将软件元素组织成层。每一层都有一个定义良好的责任集。分层架构还限制了层之间的依赖关系。一层只能依赖于它下面的层(如果严格分层)或任何下面的层。

您可以将分层架构应用于前面讨论的任何四个视图。流行的三层架构是将分层架构应用于逻辑视图。它将应用程序的类组织成以下层或层:

  • 表示层 包含实现用户界面或外部 API 的代码

  • 业务逻辑层 包含业务逻辑

  • 持久层 实现与数据库交互的逻辑

分层架构是架构风格的一个很好的例子,但它确实有一些显著的缺点:

  • 单一表示层 它没有反映出应用程序可能不仅仅被单个系统调用的现实。

  • 单一持久层 它没有反映出应用程序可能不仅仅与单个数据库交互的事实。

  • 将业务逻辑层定义为依赖于持久层 理论上,这种依赖关系阻止了您在没有数据库的情况下测试业务逻辑。

此外,分层架构错误地表示了设计良好的应用程序中的依赖关系。业务逻辑通常定义了一个接口或接口的存储库,这些接口定义了数据访问方法。持久层定义了实现存储库接口的 DAO 类。换句话说,依赖关系与分层架构所描述的相反。

让我们看看一种克服这些缺点的替代架构:六边形架构。

关于六边形架构风格

六边形架构是分层架构风格的替代方案。如图 2.2 所示,六边形架构风格以将业务逻辑置于中心的方式组织逻辑视图。与表示层不同,应用程序有一个或多个入站适配器,通过调用业务逻辑来处理来自外部的请求。同样,与数据持久层不同,应用程序有一个或多个由业务逻辑调用并调用外部应用程序的出站适配器。这种架构的关键特征和好处是业务逻辑不依赖于适配器。相反,它们依赖于它。

图 2.2. 一个六边形架构的例子,它由业务逻辑和一个或多个与外部系统通信的适配器组成。业务逻辑有一个或多个端口。入站适配器,处理来自外部系统的请求,调用入站端口。出站适配器实现出站端口,并调用外部系统。

图 2.2 的替代文本

业务逻辑有一个或多个端口。一个端口定义了一组操作,并且是业务逻辑如何与它之外的内容交互的方式。例如,在 Java 中,一个端口通常是一个 Java 接口。端口有两种类型:入站端口和出站端口。入站端口是业务逻辑暴露的 API,它使得外部应用程序能够调用它。一个入站端口的例子是服务接口,它定义了一个服务的公共方法。出站端口是业务逻辑调用外部系统的方式。一个出站端口的例子是存储库接口,它定义了一组数据访问操作。

围绕业务逻辑的是适配器。与端口一样,适配器有两种类型:入站和出站。入站适配器通过调用入站端口来处理来自外部世界的请求。一个入站适配器的例子是实现了 REST 端点或一组网页的 Spring MVC 控制器。另一个例子是订阅消息的消息代理客户端。多个入站适配器可以调用相同的入站端口。

出站适配器实现出站端口,并通过调用外部应用程序或服务来处理来自业务逻辑的请求。一个出站适配器的例子是实现访问数据库操作的数据访问对象(DAO)类。另一个例子是调用远程服务的代理类。出站适配器还可以发布事件。

六边形架构风格的一个重要优点是它将业务逻辑从适配器中的表示逻辑和数据访问逻辑解耦。业务逻辑不依赖于表示逻辑或数据访问逻辑。由于这种解耦,单独测试业务逻辑变得更加容易。另一个优点是它更准确地反映了现代应用程序的架构。业务逻辑可以通过多个适配器调用,每个适配器实现特定的 API 或 UI。业务逻辑也可以调用多个适配器,每个适配器调用不同的外部系统。六边形架构是描述微服务架构中每个服务架构的绝佳方式。

层次结构和六边形架构都是架构风格的例子。每个都定义了架构的构建块并对它们之间的关系施加约束。六边形架构和层次架构,以三层架构的形式,组织逻辑视图。现在让我们将微服务架构定义为一种组织实现视图的架构风格。

2.1.3. 微服务架构是一种架构风格

我已经讨论了 4+1 视图模型和架构风格,因此我现在可以定义单体和微服务架构。它们都是架构风格。单体架构是一种将实现视图结构化为单个组件的架构风格:一个单一的执行文件或 WAR 文件。这个定义对其他视图没有说任何东西。例如,单体应用程序可以有逻辑视图,该视图按照六边形架构组织。

模式:单体架构

将应用程序结构化为一个单一的可执行/可部署组件。请参阅microservices.io/patterns/monolithic.html

微服务架构也是一种架构风格。它将实现视图结构化为多个组件:可执行文件或 WAR 文件。这些组件是服务,连接器是使这些服务能够协作的通信协议。每个服务都有自己的逻辑视图架构,这通常是六边形架构。图 2.3 展示了 FTGO 应用程序的可能微服务架构。该架构中的服务对应于业务能力,例如订单管理和餐厅管理。

模式:微服务架构

将应用程序结构化为一组松散耦合、独立部署的服务。请参阅microservices.io/patterns/microservices.html

图 2.3. FTGO 应用程序的可能微服务架构。它由众多服务组成。

在本章的后面部分,我将描述“业务能力”的含义。服务之间的连接器是通过使用诸如 REST API 和异步消息等进程间通信机制来实现的。第三章更详细地讨论了进程间通信。

微服务架构强加的一个关键约束是服务之间的松散耦合。因此,对服务如何协作有一些限制。为了解释这些限制,我将尝试定义“服务”这个术语,描述松散耦合的含义,并告诉你这为什么很重要。

什么是服务?

“服务”是一个独立、可独立部署的软件组件,它实现了某些有用的功能。图 2.4 显示了服务的外部视图,在这个例子中是“订单服务”。服务有一个 API,它为客户端提供了对其功能的访问。有两种类型的操作:命令和查询。API 由命令、查询和事件组成。例如,createOrder()这样的命令执行操作并更新数据。例如,findOrderById()这样的查询检索数据。服务还发布事件,如OrderCreated,这些事件被其客户端消费。

图 2.4。服务有一个封装其实现的 API。API 定义了操作,这些操作由客户端调用。有两种类型的操作:命令更新数据,查询检索数据。当其数据发生变化时,服务会发布事件,客户端可以订阅这些事件。

图片

服务的 API 封装了其内部实现。与单体架构不同,开发者不能编写绕过其 API 的代码。因此,微服务架构强制执行应用程序的模块化。

在微服务架构中,每个服务都有自己的架构和,可能还有技术栈。但一个典型的服务具有六边形架构。它的 API 是通过与服务的业务逻辑交互的适配器来实现的。操作适配器调用业务逻辑,而事件适配器发布由业务逻辑发出的事件。

在第十二章的后面部分,当我讨论部署技术时,你会看到服务的实现视图可以有多种形式。组件可能是一个独立进程、一个运行在容器中的 Web 应用或 OSGI 包,或者是一个无服务器云函数。然而,一个基本要求是服务必须有一个 API 并且可以独立部署。

什么是松散耦合?

微服务架构的一个重要特征是服务之间松散耦合(en.wikipedia.org/wiki/Loose_coupling)。所有与服务的交互都通过其 API 进行,该 API 封装了其实现细节。这使得服务的实现可以改变,而不会影响其客户端。松散耦合的服务对于提高应用程序的开发时间属性至关重要,包括其可维护性和可测试性。它们更容易理解、更改和测试。

服务需要松散耦合并且只能通过 API 进行协作的要求禁止服务通过数据库进行通信。你必须像对待类的字段一样对待服务的持久数据,并保持其私有。保持数据私有使得开发者可以在不花费时间与其他服务上的开发者协调的情况下更改其服务的数据库模式。不共享数据库表还可以提高运行时隔离性。它确保,例如,一个服务不能持有阻止另一个服务的数据库锁。然而,稍后你将了解到不共享数据库的一个缺点是,维护数据一致性和跨服务查询变得更加复杂。

共享库的作用

开发者经常将功能打包到库(模块)中,以便可以在不重复代码的情况下由多个应用程序重用。毕竟,如果没有 Maven 或 npm 仓库,我们今天会是什么样子?你可能会倾向于在微服务架构中也使用共享库。表面上,这似乎是一种减少服务中代码重复的好方法。但你需要确保你不会意外地在服务之间引入耦合。

想象一下,例如,多个服务需要更新订单业务对象。一种方法是将该功能打包成一个库,供多个服务使用。一方面,使用库可以消除代码重复。另一方面,考虑一下当需求发生变化,影响订单业务对象时会发生什么。你需要同时重建和重新部署这些服务。一个更好的方法是将可能发生变化的功能,例如订单管理,实现为一个服务。

你应该努力使用库来实现那些不太可能改变的功能。例如,在一个典型的应用中,每个服务实现一个通用的货币类是没有意义的。相反,你应该创建一个由服务使用的库。

服务的规模大多并不重要

“微服务”这个术语的一个问题是,你首先听到的是“微”。这暗示服务应该非常小。其他基于大小的术语,如迷你服务或纳米服务,也是如此。实际上,大小并不是一个有用的指标。

一个更好的目标是为一个设计良好的服务定义,使其成为一个能够由小型团队开发的服务,具有最短的前期准备时间和与其他团队的最小协作。从理论上讲,一个团队可能只负责一个服务,因此这个服务绝不算是微服务。相反,如果一个服务需要大型团队或花费很长时间进行测试,那么将团队和服务拆分可能是有意义的。或者,如果你因为其他服务的变更而不断需要更改服务,或者它正在触发其他服务的变更,那么这是一个迹象表明它不是松散耦合的。你甚至可能已经构建了一个分布式单体。

微服务架构将应用程序结构化为一系列小型、松散耦合的服务。因此,它提高了开发时间属性——可维护性、可测试性、可部署性等——并使组织能够更快地开发更好的软件。它还提高了应用程序的可扩展性,尽管这并不是主要目标。为了为你的应用程序开发微服务架构,你需要确定服务并确定它们如何协作。让我们看看如何做到这一点。

2.2. 定义应用程序的微服务架构

我们应该如何定义微服务架构?与任何软件开发工作一样,起点是书面需求,希望有领域专家,也许还有一个现有的应用程序。像许多软件开发一样,定义架构更多的是艺术而不是科学。本节描述了一个简单、三步的过程,如图 2.5 所示,用于定义应用程序的架构。然而,重要的是要记住,这不是一个可以机械遵循的过程。它很可能是迭代的,并且需要大量的创造力。

图 2.5. 定义应用程序微服务架构的三步流程

图 2.5 的替代文本

应用程序存在是为了处理请求,因此定义其架构的第一步是将应用程序的需求提炼为关键请求。但我不使用特定的 IPC 技术(如 REST 或消息传递)来描述请求,而是使用更抽象的系统操作概念。一个系统操作是应用程序必须处理的一个请求的抽象。它要么是一个更新数据的命令,要么是一个检索数据的查询。每个命令的行为都是用抽象领域模型来定义的,该模型也是从需求中派生出来的。系统操作成为说明服务如何协作的架构场景。

流程的第二步是确定服务的分解。有几种策略可供选择。一种策略,其起源在于业务架构学科,是定义与业务能力相对应的服务。另一种策略是围绕领域驱动设计子域组织服务。最终结果是服务围绕业务概念而不是技术概念组织。

定义应用程序架构的第三步是确定每个服务的 API。要做到这一点,你需要将第一步中识别出的每个系统操作分配给一个服务。一个服务可能完全自行实现一个操作。或者,它可能需要与其他服务协作。在这种情况下,你需要确定服务如何协作,这通常需要服务支持额外的操作。你还需要决定实现每个服务 API 的 IPC 机制,我在第三章中描述了这些机制。

分解存在几个障碍。第一个是网络延迟。你可能会发现,由于服务之间往返次数过多,某种分解可能不切实际。分解的另一个障碍是服务之间的同步通信会降低可用性。你可能需要使用第三章中描述的自包含服务概念。第三个障碍是在服务之间维护数据一致性的要求。你通常需要使用在第四章中讨论的的 sagas。分解的第四个和最后一个障碍是所谓的神级类,这些类在整个应用程序中使用。幸运的是,你可以使用领域驱动设计中的概念来消除神级类。

本节首先描述如何识别应用程序的操作。之后,我们将探讨将应用程序分解为服务的策略和指南,以及分解的障碍以及如何解决它们。最后,我将描述如何定义每个服务的 API。

2.2.1. 识别系统操作

定义应用程序架构的第一步是定义系统操作。起点是应用程序的要求,包括用户故事及其相关的用户场景(注意,这些与架构场景不同)。系统操作是通过图 2.6 中所示的两步过程来识别和定义的。这个过程受到了 Craig Larman 在其书籍《应用 UML 和模式》(Prentice Hall,2004)中涵盖的面向对象设计过程的影响(有关详细信息,请参阅www.craiglarman.com/wiki/index.php?title=Book_Applying_UML_and_Patterns)。第一步创建了由关键类组成的高级领域模型,这些类提供了一个词汇表,可以用来描述系统操作。第二步识别系统操作,并描述每个操作的行为,用领域模型来表述。

图 2.6。系统操作是通过一个两步过程从应用程序的要求中推导出来的。第一步是创建一个高级领域模型。第二步是定义系统操作,这些操作是用领域模型来定义的。

图 2.6

领域模型主要来源于用户故事中的名词,系统操作主要来源于动词。你也可以使用一种称为事件风暴的技术来定义领域模型,我在第五章中提到了这种技术。第五章。每个系统操作的行为都是用其对一个或多个领域对象及其之间关系的影响来描述的。系统操作可以创建、更新或删除领域对象,以及创建或破坏它们之间的关系。

让我们看看如何定义一个高级领域模型。之后,我将根据领域模型来定义系统操作。

创建高级领域模型

定义系统操作的过程的第一步是为应用程序绘制一个高级领域模型。请注意,这个领域模型最终实现起来要简单得多。应用程序甚至不会有一个单独的领域模型,因为,正如你很快就会学到的,每个服务都有自己的领域模型。尽管这是一个极端的简化,但在这个阶段,高级领域模型是有用的,因为它定义了描述系统操作行为的词汇。

领域模型是通过使用标准技术创建的,例如分析故事和场景中的名词以及与领域专家交谈。以Place Order故事为例。我们可以将这个故事扩展到包括以下用户场景:

Given a consumer
  And a restaurant
  And a delivery address/time that can be served by that restaurant
  And an order total that meets the restaurant's order minimum
When the consumer places an order for the restaurant
Then consumer's credit card is authorized
  And an order is created in the PENDING_ACCEPTANCE state
  And the order is associated with the consumer
  And the order is associated with the restaurant

在这个用户场景中的名词暗示了各种类的存在,包括ConsumerOrderRestaurantCreditCard

同样,Accept Order故事可以扩展到如下场景:

Given an order that is in the PENDING_ACCEPTANCE state
  and a courier that is available to deliver the order
When a restaurant accepts an order with a promise to prepare by a particular
     time
Then the state of the order is changed to ACCEPTED
  And the order's promiseByTime is updated to the promised time
  And the courier is assigned to deliver the order

这种场景暗示了存在CourierDelivery类。经过几轮分析后的最终结果将是一个领域模型,不出所料,它由那些类和其他类组成,例如MenuItemAddress。图 2.7 是一个显示关键类的类图。

图 2.7. FTGO 领域模型中的关键类

每个类的职责如下:

  • Consumer 下订单的消费者。

  • Order 消费者下的一单。它描述了订单并跟踪其状态。

  • OrderLineItem 订单的行项目。

  • DeliveryInfo 交付订单的时间和地点。

  • Restaurant 为消费者准备订单以供交付的餐厅。

  • MenuItem 餐厅菜单上的项目。

  • Courier 将订单交付给消费者的快递员。它跟踪快递员的可用性和当前位置。

  • Address 消费者或餐厅的地址。

  • Location 快递员的纬度和经度。

如图 2.7 所示的类图说明了应用程序架构的一个方面。但没有场景来激活它,它不过是一幅漂亮的图片。下一步是定义系统操作,这些操作对应于架构场景。

定义系统操作

一旦定义了高级领域模型,下一步就是确定应用程序必须处理的请求。UI 的细节超出了本书的范围,但你可以想象在每个用户场景中,UI 将向后端业务逻辑发出请求以检索和更新数据。FTGO 主要是一个 Web 应用程序,这意味着大多数请求都是基于 HTTP 的,但某些客户端可能使用消息。因此,而不是承诺特定的协议,使用更抽象的系统操作概念来表示请求是有意义的。

系统操作有两种类型:

  • 命令 创建、更新和删除数据的系统操作

  • 查询 读取(查询)数据的系统操作

最终,这些系统操作将对应于 REST、RPC 或消息端点,但就目前而言,抽象地思考它们是有用的。让我们首先确定一些命令。

识别系统命令的一个好起点是分析用户故事和场景中的动词。例如,考虑Place Order故事。它清楚地表明系统必须提供Create Order操作。许多其他故事单独直接映射到系统命令。表 2.1 列出了一些关键系统命令。

表 2.1. FTGO 应用程序的关键系统命令
行动者 故事 命令 描述
消费者 创建订单 createOrder() 创建订单
餐厅 接受订单 acceptOrder() 表示餐厅已接受订单,并承诺在指定时间内准备订单
餐厅 订单准备就绪 noteOrderReadyForPickup() 表示订单已准备就绪,可以取货
快递员 更新位置 noteUpdatedLocation() 更新快递员当前的位置
快递员 取货完成 noteDeliveryPickedUp() 表示快递员已取走订单
快递员 送货完成 noteDeliveryDelivered() 表示快递员已投递订单

一个命令有一个规范,该规范定义了其参数、返回值以及从领域模型类角度的行为。行为规范包括在操作调用时必须为真的前置条件,以及在操作调用后为真的后置条件。例如,以下是createOrder()系统操作的规范:

操作 createOrder (消费者 ID, 支付方式, 送货地址, 送货时间, 餐厅 ID, 订单行项目)
返回值 orderId, ...
前置条件
  • 消费者存在并且可以下单。

  • 行项目对应于餐厅的菜单项。

  • 餐厅可以提供送货地址和时间服务。

|

后置条件
  • 消费者的信用卡已授权支付订单总额。

  • 已创建一个处于待接受状态的订单。

|

前置条件反映了之前描述的Place Order用户场景中的已知条件。后置条件反映了场景中的结果。当系统操作被调用时,它将验证前置条件并执行使后置条件为真的所需操作。

这是acceptOrder()系统操作的规范:

操作 acceptOrder(restaurantId, orderId, readyByTime)
返回值
前置条件
  • 订单状态为待接受

  • 有快递员可以投递订单。

|

后置条件
  • 订单状态已更改为接受

  • 订单的readyByTime被更改为readyByTime

  • 快递员被分配去投递订单。

|

其前置条件和后置条件与之前用户场景中的内容相匹配。

大多数与架构相关的系统操作都是命令。尽管如此,有时查询,即检索数据,也非常重要。

除了实现命令之外,应用程序还必须实现查询。查询为 UI 提供用户做出决策所需的信息。在这个阶段,我们还没有 FTGO 应用程序的特定 UI 设计,但考虑例如消费者下单时的流程:

  1. 用户输入送货地址和时间。

  2. 系统显示可用的餐厅。

  3. 用户选择餐厅。

  4. 系统显示菜单。

  5. 用户选择商品并结账。

  6. 系统创建订单。

此用户场景建议以下查询:

  • findAvailableRestaurants(deliveryAddress, deliveryTime) 获取在指定时间可以送达到指定送货地址的餐厅

  • findRestaurantMenu(id) 获取关于餐厅的信息,包括菜单项目

在这两个查询中,findAvailableRestaurants() 可能是架构上最重要的查询。它是一个复杂的查询,涉及地理搜索。查询的地理搜索组件包括找到所有靠近一个位置——送货地址的餐厅。它还会过滤掉那些在订单需要准备和取货时已关闭的餐厅。此外,性能至关重要,因为这个查询是在消费者想要下单时执行的。

高级领域模型和系统操作描述了应用程序的功能。它们有助于推动应用程序架构的定义。每个系统操作的行为都是用领域模型来描述的。每个重要的系统操作代表了一个架构上重要的场景,这是架构描述的一部分。

一旦定义了系统操作,下一步就是确定应用程序的服务。如前所述,没有机械的过程可以遵循。然而,有各种分解策略可以使用。每个策略都从不同的角度攻击问题,并使用自己的术语。但所有策略的最终结果都是相同的:一个由服务组成的架构,这些服务主要是围绕业务而不是技术概念组织的。

让我们看看第一种策略,它定义了与业务能力相对应的服务。

2.2.2. 通过应用按业务能力分解模式定义服务

创建微服务架构的一种策略是按业务能力进行分解。这是来自业务架构建模的一个概念,业务能力是指为了创造价值而进行的业务活动。给定业务的业务能力集合取决于业务类型。例如,保险公司的能力通常包括承保、索赔管理、计费、合规性等。在线商店的能力包括订单管理、库存管理、运输等。

模式:按业务能力分解

定义与业务能力相对应的服务。请参阅microservices.io/patterns/decomposition/decompose-by-business-capability.html

业务能力定义了一个组织做什么

一个组织的业务能力捕捉了组织的业务“是什么”。它们通常是稳定的,与组织如何开展业务相对,后者会随着时间的推移而变化,有时变化很大。这在今天尤其如此,随着技术自动化许多业务过程的快速增长。例如,不久前,你还需要把支票交给银行柜员来存款。后来,可以使用自动柜员机存款。如今,你可以方便地使用智能手机存款。正如你所看到的,存款支票业务能力保持稳定,但执行方式发生了巨大变化。

识别业务能力

一个组织的业务能力是通过分析组织的宗旨、结构和业务流程来识别的。每个业务能力都可以被视为一种服务,只不过它是面向业务的而不是技术性的。其规范包括各种组件,包括输入、输出和服务级别协议。例如,保险承保能力的输入是消费者的申请,输出包括批准和价格。

业务能力通常专注于特定的业务对象。例如,索赔业务对象是索赔管理能力的焦点。能力通常可以分解为子能力。例如,索赔管理能力有几个子能力,包括索赔信息管理、索赔审查和索赔付款管理。

想象 FTGO 的业务能力包括以下内容并不困难:

  • 供应商管理

    • 快递管理 管理快递信息

    • 餐厅信息管理 管理餐厅菜单和其他信息,包括位置和营业时间

  • 消费者管理—管理消费者信息

  • 订单接收和履行

    • 订单管理 允许消费者创建和管理订单

    • 餐厅订单管理 管理餐厅订单的准备工作

    • 物流

    • 快递可用性管理 管理快递员对配送订单的实时可用性

    • 配送管理 向消费者配送订单

  • 会计

    • 消费者会计 管理消费者的账单

    • 餐厅会计 管理对餐厅的付款

    • 快递会计 管理对快递员的付款

  • ...

最高层的能力包括供应商管理、消费者管理、订单接收和履行以及会计。可能还会有许多其他最高层能力,包括与营销相关的能力。大多数最高层能力都被分解为子能力。例如,订单接收和履行被分解为五个子能力。

这个能力层次结构的一个有趣方面是,有三个与餐厅相关的功能:餐厅信息管理、餐厅订单管理和餐厅会计。这是因为它们代表了餐厅运营的三个非常不同的方面。

接下来,我们将探讨如何使用业务能力来定义服务。

从业务能力到服务

一旦你确定了业务能力,然后为每个能力或相关能力组定义一个服务。图 2.8 显示了 FTGO 应用程序从能力到服务的映射。一些顶级能力,如会计能力,映射到服务。在其他情况下,子能力映射到服务。

图 2.8. 将 FTGO 业务能力映射到服务。能力层次结构的不同级别的能力映射到服务。

图 2.8

决定将能力层次结构的哪个级别映射到服务,因为这是主观的。我对这种特定映射的合理性如下:

  • 我将供应商管理的子能力映射到两个服务,因为餐厅和快递员是两种非常不同的供应商类型。

  • 我将订单接收和履约能力映射到三个服务,每个服务负责流程的不同阶段。我将快递员可用性管理和配送管理能力合并,并将它们映射到单个服务,因为它们紧密相连。

  • 我将会计能力映射到其自己的服务,因为不同类型的会计看起来很相似。

之后,将支付(餐厅和快递员的支付)和账单(消费者的账单)分开可能是有意义的。

将服务组织在能力周围的一个关键好处是,因为它们是稳定的,所以产生的架构也将相对稳定。随着业务“如何”方面的变化,架构的各个组成部分可能会发展,但架构保持不变。

话虽如此,重要的是要记住,图 2.8 中所示的服务仅仅是定义架构的第一次尝试。随着时间的推移,随着我们对应用领域的了解更多,它们可能会发生变化。特别是,在架构定义过程中,调查服务如何在每个关键架构服务中协作是一个重要步骤。例如,你可能会发现,由于进程间通信过多,某种特定的分解效率低下,你必须合并服务。相反,一个服务可能会变得复杂到值得将其拆分为多个服务。更重要的是,在第 2.2.5 节中,我描述了可能导致你重新考虑决定的几个分解障碍。

让我们来看看另一种基于领域驱动设计的应用分解方法。

2.2.3. 通过应用按子域分解模式定义服务

如同在埃里克·埃文斯(Eric Evans)所著的优秀书籍《领域驱动设计》(Domain-driven design,Addison-Wesley Professional,2003 年)中所描述的,DDD 是一种构建复杂软件应用程序的方法,该方法以面向对象领域模型的发展为中心。领域模型以可以用于解决该领域内问题的形式捕捉关于领域的知识。它定义了团队使用的词汇,DDD 称之为通用语言。领域模型在应用程序的设计和实现中紧密对应。DDD 有两个在应用微服务架构时非常有用的概念:子域和边界上下文。

模式:按子域分解

定义与 DDD 子域对应的服务的定义。参见microservices.io/patterns/decomposition/decompose-by-subdomain.html

DDD 与传统的企业建模方法大不相同,后者为整个企业创建一个单一模型。在这种模型中,例如,每个业务实体(如客户、订单等)都有一个单一的定义。这种建模的问题在于,让组织的不同部分就一个单一模型达成一致是一项艰巨的任务。此外,从组织某个部分的角度来看,该模型对于他们的需求来说过于复杂。此外,领域模型可能会令人困惑,因为组织的不同部分可能会使用相同的术语来表示不同的概念,或者使用不同的术语来表示相同的概念。DDD 通过定义多个领域模型来避免这些问题,每个模型都有一个明确的范围。

DDD 为每个子域定义一个单独的领域模型。子域是领域的一部分,DDD 术语,指的是应用程序的问题空间。子域是通过与识别业务能力相同的方法来识别的:分析业务并识别不同的专业领域。最终结果很可能产生与业务能力相似的子域。FTGO 中子域的例子包括订单处理、订单管理、厨房管理、配送和财务。正如你所看到的,这些子域与前面描述的业务能力非常相似。

DDD 将领域模型的范围称为边界上下文。边界上下文包括实现该模型的代码工件。当使用微服务架构时,每个边界上下文都是一个服务或可能是一组服务。我们可以通过应用 DDD 并为每个子域定义一个服务来创建微服务架构。图 2.9 显示了子域如何映射到服务,每个服务都有自己的领域模型。

图 2.9. 从子域到服务:FTGO 应用域的每个子域都映射到一个服务,该服务有自己的领域模型。

图片

DDD 和微服务架构几乎完美地一致。DDD 的子域和边界上下文的概念很好地映射到微服务架构中的服务。此外,微服务架构中拥有服务的自主团队的概念与 DDD 中每个领域模型由单一团队拥有和开发的概念完全一致。更好的是,正如我在本节后面描述的,具有自己领域模型的子域的概念是消除上帝类并因此使分解更容易的绝佳方式。

通过子域分解和通过业务能力分解是定义应用程序微服务架构的两个主要模式。然而,有一些有用的分解指南,其根源在于面向对象设计。让我们来看看它们。

2.2.4. 分解指南

到目前为止,在本章中,我们已经探讨了定义微服务架构的主要方法。我们还可以在应用微服务架构模式时,采用和利用面向对象设计的一些原则。这些原则是由罗伯特·C·马丁创建的,并在他的经典著作《使用 Booch 方法设计面向对象 C++应用程序》(Prentice Hall,1995 年)中描述。第一个原则是单一职责原则(SRP),用于定义类的职责。第二个原则是共同封闭原则(CCP),用于将类组织到包中。让我们来看看这些原则,并了解它们如何应用于微服务架构。

单一职责原则

软件架构和设计的主要目标之一是确定每个软件元素的职责。单一职责原则如下:

一个类应该只有一个变化的原因。

罗伯特·C·马丁

类所拥有的每个职责都是该类可能发生变化的潜在原因。如果一个类有多个独立变化的职责,那么该类将不会稳定。通过遵循 SRP,你可以定义每个类只有一个职责和因此只有一个变化原因的类。

我们可以在定义微服务架构时应用 SRP,创建小型、内聚的服务,每个服务只有一个职责。这将减少服务的大小并增加其稳定性。新的 FTGO 架构是 SRP 应用的例子。将食物送到消费者手中的每个方面——订单接收、订单准备和配送——都是独立服务的职责。

共同封闭原则

另一个有用的原则是共同封闭原则:

包中的类应该对同一类变化进行封闭。影响包的变化会影响该包中的所有类。

罗伯特·C·马丁

理念是,如果两个类因为相同的基本原因而同步更改,那么它们属于同一个包。例如,这些类可能实现了特定业务规则的不同方面。目标是当该业务规则更改时,开发者只需更改少量包中的代码(理想情况下只有一个包)。遵循 CCP 显著提高了应用程序的可维护性。

当创建微服务架构时,我们可以应用 CCP(共同原因原则),将因相同原因而更改的组件打包到同一个服务中。这样做将最小化在需求变更时需要更改和部署的服务数量。理想情况下,一个变更只会影响一个团队和一个服务。CCP 是分布式单体反模式的解药。

SRP 和 CCP 是 Bob Martin 开发的 11 个原则中的两个。它们在开发微服务架构时特别有用。其余的九个原则用于设计类和包。有关 SRP、CCP 和其他面向对象设计原则的更多信息,请参阅 Bob Martin 的网站上的文章“面向对象设计的原则”(butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod)。

通过业务能力和子域进行分解,以及 SRP(单一责任原则)和 CCP,是分解应用程序为服务的好方法。为了应用它们并成功开发微服务架构,你必须解决一些事务管理和进程间通信问题。

2.2.5. 将应用程序分解为服务的障碍

表面上看,通过定义对应于业务能力或子域的服务来创建微服务架构的策略看起来很简单。然而,你可能会遇到几个障碍:

  • 网络延迟

  • 由于同步通信而降低的可用性

  • 在服务之间保持数据一致性

  • 获取数据的一致视图

  • 阻碍分解的上帝类

让我们逐一审视每个障碍,从网络延迟开始。

网络延迟

网络延迟是分布式系统中一个始终存在的担忧。你可能会发现,将应用程序分解为服务的方式会导致两个服务之间的大量往返。有时,通过实现批量 API 以在一次往返中检索多个对象,你可以将延迟减少到可接受的程度。但在其他情况下,解决方案是合并服务,用语言级别的调用或方法调用替换昂贵的进程间通信(IPC)。

同步进程间通信降低可用性

另一个问题是如何以不降低可用性的方式实现服务间的通信。例如,实现createOrder()操作最直接的方式是Order Service通过 REST 同步调用其他服务。使用像 REST 这样的协议的缺点是它会降低Order Service的可用性。如果那些其他服务中的任何一个不可用,它将无法创建订单。有时这是一个值得的权衡,但在第三章中你将了解到,使用异步消息传递,这可以消除紧密耦合并提高可用性,通常是一个更好的选择。

维护服务间的数据一致性

另一个挑战是维护服务间的数据一致性。一些系统操作需要在多个服务中更新数据。例如,当餐馆接受订单时,必须在Kitchen ServiceDelivery Service中更新。Kitchen Service更改Ticket的状态。Delivery Service安排订单的配送。这两个更新都必须原子性地完成。

传统的解决方案是使用基于两阶段提交的分布式事务管理机制。但正如你将在第四章中看到的,这并不是现代应用的正确选择,你必须使用一种非常不同的方法来管理事务,即叙事法。叙事法是一系列使用消息协调的本地事务。叙事法比传统的 ACID 事务更复杂,但在许多情况下工作得很好。叙事法的一个局限性是它们最终是一致的。如果你需要原子性地更新某些数据,那么这些数据必须位于单个服务中,这可能会成为分解的障碍。

获取数据的一致视图

分解的另一个障碍是无法在多个数据库中获取数据的真正一致视图。在单体应用中,ACID 事务的特性保证了查询将返回数据库的一致视图。相比之下,在微服务架构中,尽管每个服务的数据库都是一致的,但你无法获得数据的全局一致视图。如果你需要某些数据的一致视图,那么这些数据必须位于单个服务中,这可能会阻止分解。幸运的是,在实践中这很少成为问题。

God 类阻止了分解

分解的另一个障碍是所谓的“上帝类”的存在。上帝类是那些在整个应用程序中使用的臃肿类(wiki.c2.com/?GodClass)。一个上帝类通常为应用程序的许多不同方面实现业务逻辑。它通常具有大量字段映射到具有许多列的数据库表。大多数应用程序至少有一个这样的类,每个类代表一个对领域至关重要的概念:银行中的账户、电子商务中的订单、保险中的政策等等。由于上帝类将应用程序许多不同方面的状态和行为捆绑在一起,因此它是将使用它的任何业务逻辑拆分为服务的一个不可逾越的障碍。

Order类是 FTGO 应用程序中上帝类的绝佳例子。这并不令人惊讶——毕竟,FTGO 的目的是将食品订单交付给客户。系统的许多部分都涉及订单。如果 FTGO 应用程序有一个单一的领域模型,Order类将是一个非常庞大的类。它将具有与应用程序许多不同部分相对应的状态和行为。图 2.10 显示了使用传统建模技术创建的这个类的结构。

图 2.10。Order上帝类因承担众多职责而变得臃肿。

图片

正如你所见,Order类具有与订单处理、餐厅订单管理、配送和支付相对应的字段和方法。这个类也具有复杂的状态模型,因为一个模型必须描述来自应用程序不同部分的状态转换。在其当前形式下,这个类使得将代码拆分为服务变得极其困难。

一种解决方案是将Order类打包成一个库,并创建一个中央Order数据库。所有处理订单的服务都使用这个库并访问这个数据库。这种方法的问题在于它违反了微服务架构的一个关键原则,并导致了不希望的紧密耦合。例如,对Order模式的任何更改都需要团队同步更新他们的代码。

另一种解决方案是将Order数据库封装在Order Service中,其他服务通过调用它来检索和更新订单。这种设计的问题在于Order Service将是一个数据服务,包含贫血的领域模型,其中包含很少或没有业务逻辑。这两种选择都不吸引人,但幸运的是,DDD 提供了一个解决方案。

一个更好的方法是应用 DDD,并将每个服务视为一个具有自己领域模型的独立子域。这意味着 FTGO 应用程序中与订单有关的每个服务都有自己的领域模型,以及自己的Order类版本。多个领域模型的好处的一个很好的例子是Delivery Service。它对Order的视图,如图 2.11 所示,非常简单:取货地址、取货时间、配送地址和配送时间。此外,它不是将其称为Order,而是使用更合适的名称Delivery

图 2.11. Delivery Service领域模型

Delivery Service对订单的任何其他属性都不感兴趣。

Kitchen Service对订单有一个更为简单的视图。它版本的Order被称为Ticket。如图 2.12 所示,Ticket仅包含状态、requestedDeliveryTimeprepareByTime以及一个列表,告诉餐厅需要准备什么。它与消费者、支付、配送等无关。

图 2.12. Kitchen Service领域模型

Order服务对订单有最复杂的视图,如图 2.13 所示。尽管它有很多字段和方法,但它仍然比原始版本简单得多。

图 2.13. Order Service领域模型

每个领域模型中的Order类代表同一Order业务实体的不同方面。FTGO 应用程序必须在不同的服务中保持这些不同对象的一致性。例如,一旦Order Service授权了消费者的信用卡,它就必须在Kitchen Service中触发创建Ticket。同样,如果餐厅通过Kitchen Service拒绝订单,它必须在Order Service服务中取消,并在计费服务中向客户退款。在第四章中,你将学习如何使用之前提到的基于事件的机制 sagas 在服务之间保持一致性。

拥有多个领域模型不仅带来了技术挑战,还影响了用户体验的实现。应用程序必须在用户体验(它自己的领域模型)和每个服务的领域模型之间进行转换。例如,在 FTGO 应用程序中,显示给消费者的Order状态是从多个服务中存储的Order信息派生出来的。这种转换通常由 API 网关处理,这在第八章中讨论过。尽管存在这些挑战,但在定义微服务架构时,识别和消除上帝类是至关重要的。

我们现在将探讨如何定义服务 API。

2.2.6. 定义服务 API

到目前为止,我们有一个系统操作列表和一个潜在的服务列表。下一步是定义每个服务的 API:它的操作和事件。服务 API 操作存在有两个原因之一:一些操作对应于系统操作。它们被外部客户端和其他服务调用。其他操作存在是为了支持服务间的协作。这些操作只被其他服务调用。

服务主要发布事件是为了能够与其他服务协作。第四章描述了如何使用事件来实现传奇,以在服务之间保持数据一致性。而第七章讨论了如何使用事件来更新 CQRS 视图,以支持高效查询。应用程序还可以使用事件来通知外部客户端。例如,它可以使用 WebSockets 将事件发送到浏览器。

定义服务 API 的起点是将每个系统操作映射到服务。之后,我们决定一个服务是否需要与其他服务协作以实现系统操作。如果需要协作,我们接着确定那些其他服务必须提供哪些 API 以支持协作。让我们首先看看如何将系统操作分配给服务。

将系统操作分配给服务

第一步是决定哪个服务是请求的初始入口点。许多系统操作可以很好地映射到服务,但有时映射并不明显。例如,考虑noteUpdatedLocation()操作,它更新快递员的位置。一方面,因为它与快递员相关,这个操作应该分配给Courier服务。另一方面,Delivery Service需要快递员的位置。在这种情况下,将操作分配给需要操作提供的信息的服务是一个更好的选择。在其他情况下,将操作分配给具有处理所需信息的服务可能更有意义。

表 2.2 显示了 FTGO 应用程序中哪些服务负责哪些操作。

表 2.2. FTGO 应用程序中系统操作到服务的映射
服务 操作
消费者服务 createConsumer()
订单服务 createOrder()
餐厅服务 findAvailableRestaurants()
厨房服务
  • acceptOrder()

  • noteOrderReadyForPickup()

|

配送服务
  • noteUpdatedLocation()

  • noteDeliveryPickedUp()

  • noteDeliveryDelivered()

|

在将操作分配给服务之后,下一步是决定服务如何协作以处理每个系统操作。

确定支持服务间协作所需的 API

一些系统操作完全由单个服务处理。例如,在 FTGO 应用中,消费者服务完全自行处理createConsumer()操作。但其他系统操作跨越多个服务。处理这些请求所需的数据可能分散在多个服务中。例如,为了实现createOrder()操作,订单服务必须调用以下服务以验证其先决条件并使后置条件成立:

  • 消费者服务 验证消费者能否下单并获取他们的支付信息。

  • 餐厅服务 验证订单行项目,确认配送地址/时间在餐厅的服务区域内,验证订单最低金额是否满足,并获取订单行项目的价格。

  • 厨房服务 创建票据

  • 会计服务 授权消费者的信用卡。

同样,为了实现acceptOrder()系统操作,厨房服务必须调用配送服务来安排快递员配送订单。表 2.3 显示了服务、它们的修订版 API 和它们的合作伙伴。为了完全定义服务 API,您需要分析每个系统操作并确定所需的协作。

表 2.3. 服务、它们的修订版 API 和它们的合作伙伴
服务 操作 合作伙伴
消费者服务 verifyConsumerDetails()
订单服务 createOrder()
  • 消费者服务 verifyConsumerDetails()

  • 餐厅服务 verifyOrderDetails()

  • 厨房服务 createTicket()

  • 会计服务 authorizeCard()

|

餐厅服务
  • findAvailableRestaurants()

  • verifyOrderDetails()

厨房服务
  • createTicket()

  • acceptOrder()

  • noteOrderReadyForPickup()

|

  • 配送服务 scheduleDelivery()

|

配送服务
  • scheduleDelivery()

  • noteUpdatedLocation()

  • noteDeliveryPickedUp()

  • noteDeliveryDelivered()

会计服务
  • authorizeCard()

到目前为止,我们已经确定了服务和每个服务实现的操作。但重要的是要记住,我们勾勒出的架构非常抽象。我们尚未选择任何特定的 IPC 技术。此外,尽管术语操作暗示了一种基于同步请求/响应的 IPC 机制,但您会发现异步消息发挥着重要作用。在这本书的整个过程中,我描述了影响这些服务协作的架构和设计概念。

第三章描述了特定的 IPC 技术,包括同步通信机制,如 REST,以及使用消息代理的异步消息。我讨论了同步通信如何影响可用性,并引入了自包含服务的概念,这种服务不会同步调用其他服务。实现自包含服务的一种方法是通过 CQRS 模式,这在第七章中有介绍。例如,Order Service可以维护Restaurant Service拥有的数据的副本,以消除同步调用Restaurant Service验证订单的需求。它通过订阅Restaurant Service发布的事件来保持副本的更新,每当Restaurant Service更新其数据时。

第四章介绍了叙事概念及其如何使用异步消息来协调参与叙事的服务。叙事不仅能够可靠地更新分散在多个服务中的数据,而且也是一种实现自包含服务的方式。例如,我描述了如何使用叙事来实现createOrder()操作,通过异步消息调用诸如Consumer ServiceKitchen ServiceAccounting Service等服务。

第八章描述了 API 网关的概念,它向外部客户端公开 API。API 网关可能会使用第七章中描述的 API 组合模式来实现查询操作,而不是简单地将其路由到服务。API 网关中的逻辑通过调用多个服务并组合结果来收集查询所需的数据。在这种情况下,系统操作分配给 API 网关而不是服务。服务需要实现 API 网关所需的查询操作。

摘要

  • 架构决定了你的应用程序的性,包括可维护性、可测试性和可部署性,这些直接影响开发速度。

  • 微服务架构是一种架构风格,它为应用程序提供了高度的维护性、可测试性和可部署性。

  • 微服务架构中的服务是围绕业务关注点——业务能力或子域——而不是技术关注点组织的。

  • 分解有两种模式:

    • 按业务能力分解,其起源在于业务架构

    • 按子域分解,基于领域驱动设计的概念

  • 通过应用领域驱动设计(DDD)并为每个服务定义一个单独的领域模型,你可以消除导致依赖纠缠的上帝类,从而实现分解。

第三章. 微服务架构中的进程间通信

本章涵盖

  • 应用通信模式:远程过程调用、断路器、客户端发现、自我注册、服务器端发现、第三方注册、异步消息、事务性输出箱、事务日志尾部、轮询发布者

  • 进程间通信在微服务架构中的重要性

  • 定义和演进 API

  • 各种进程间通信选项及其权衡

  • 使用异步消息通信的服务的好处

  • 作为数据库事务一部分可靠地发送消息

Mary 和她的团队,像大多数其他开发者一样,对进程间通信(IPC)机制有一些经验。FTGO 应用程序有一个 REST API,该 API 被移动应用程序和浏览器端的 JavaScript 使用。它还使用了各种云服务,例如 Twilio 消息服务和 Stripe 支付服务。但在像 FTGO 这样的单体应用程序中,模块通过语言级别的函数或方法调用相互调用。FTGO 开发者通常不需要考虑 IPC,除非他们正在处理 REST API 或与云服务集成的模块。

相比之下,正如你在第二章中看到的,微服务架构将应用程序结构化为一系列服务。这些服务必须经常协作以处理请求。因为服务实例通常是运行在多台机器上的进程,它们必须使用 IPC 进行交互。在微服务架构中,IPC 比在单体应用程序中扮演着更加重要的角色。因此,当 Mary 和其他 FTGO 开发者将应用程序迁移到微服务时,他们需要花费更多的时间来思考 IPC。

可供选择的 IPC 机制并不缺乏。今天,时尚的选择是 REST(使用 JSON)。然而,重要的是要记住,没有银弹。你必须仔细考虑选项。本章探讨了包括 REST 和消息在内的各种 IPC 选项,并讨论了权衡。

IPC 机制的选择是一个重要的架构决策。它可能影响应用程序的可用性。更重要的是,正如我在本章和下一章中解释的,IPC 甚至与事务管理相交。我倾向于一个由松散耦合的服务组成的架构,这些服务通过异步消息相互通信。同步协议,如 REST,主要用于与其他应用程序通信。

我以微服务架构中进程间通信的概述开始本章。接下来,我描述基于远程过程调用的 IPC,其中 REST 是最流行的例子。我涵盖了包括服务发现和如何处理部分失败等重要主题。之后,我描述基于异步消息的 IPC。我还谈论了在保持消息顺序的同时扩展消费者,正确处理重复消息和事务性消息。最后,我探讨了处理同步请求而不与其他服务通信的自包含服务的概念,以提高可用性。

3.1. 微服务架构中进程间通信概述

有很多不同的 IPC 技术可供选择。服务可以使用基于同步请求/响应的通信机制,如基于 HTTP 的 REST 或 gRPC。或者,它们可以使用基于异步、基于消息的通信机制,如 AMQP 或 STOMP。还有各种不同的消息格式。服务可以使用人类可读的、基于文本的格式,如 JSON 或 XML。或者,它们可以使用更高效的二进制格式,如 Avro 或 Protocol Buffers。

在深入探讨具体技术的细节之前,我想提出几个你应该考虑的设计问题。我以讨论交互风格开始这一节,交互风格是一种与技术无关的描述客户端和服务如何交互的方式。接下来,我将讨论在微服务架构中精确定义 API 的重要性,包括 API-first 设计概念。然后,我将讨论 API 进化的重要主题。最后,我将讨论消息格式的不同选项以及它们如何决定 API 进化的容易程度。让我们从查看交互风格开始。

3.1.1. 交互风格

在选择服务 API 的 IPC 机制之前,首先思考一下服务与其客户端之间的交互风格是有用的。首先考虑交互风格将有助于你集中精力考虑需求,避免陷入特定 IPC 技术的细节中。此外,如第 3.4 节所述,交互风格的选择会影响你应用程序的可用性。此外,正如你将在第九章和第十章中看到的那样,它有助于你选择适当的集成测试策略。

存在多种客户端-服务交互风格。如表 3.1 所示,它们可以从两个维度进行分类。第一个维度是交互是一对一还是一对多:

  • 一对一每个客户端请求由恰好一个服务处理

  • 一对多每个请求由多个服务处理

第二个维度是交互是同步还是异步:

  • 同步—** 客户端期望从服务中获得及时响应,甚至可能在等待时阻塞。

  • 异步—** 客户端不会阻塞,如果有的话,响应也不一定是立即发送的。

表 3.1. 不同的交互风格可以从两个维度进行描述:一对一与一对多,以及同步与异步。
一对一 一对多
同步 请求/响应
异步 异步请求/响应 单向通知 发布/订阅 发布/异步响应

以下是一对一交互的不同类型:

  • 请求/响应—** 客户端向服务发出请求并等待响应。客户端期望及时收到响应。它甚至可能在等待时阻塞。这种交互风格通常会导致服务紧密耦合。

  • 异步请求/响应—** 服务客户端向服务发送请求,服务异步回复。客户端在等待时不会阻塞,因为服务可能不会立即发送响应。

  • 单向通知—** 服务客户端向服务发送请求,但不需要也不发送回复。

记住这一点很重要,即同步请求/响应交互风格通常与进程间通信(IPC)技术正交。例如,一个服务可以通过使用 REST 或消息传递的请求/响应风格与另一个服务交互。即使两个服务正在使用消息代理进行通信,客户端服务也可能因为等待响应而被阻塞。这并不一定意味着它们是松散耦合的。这是我在本章后面讨论服务间通信对可用性影响时再次回顾的内容。

以下是一对多交互的不同类型:

  • 发布/订阅—** 客户端发布一个通知消息,该消息被零个或多个感兴趣的服务消费。

  • 发布/异步响应—** 客户端发布一个请求消息,然后等待一定时间,以从感兴趣的服务那里获取响应。

每个服务通常会使用这些交互风格的组合。FTGO 应用程序中的许多服务都有同步和异步 API 用于操作,许多服务还发布事件。

让我们看看如何定义服务的 API。

3.1.2. 在微服务架构中定义 API

API 或接口是软件开发的核心。一个应用程序由模块组成。每个模块都有一个接口,该接口定义了该模块客户端可以调用的操作集。一个设计良好的接口可以暴露有用的功能,同时隐藏实现细节。它使得实现可以改变而不会影响客户端。

在单体应用程序中,通常使用编程语言构造,如 Java 接口来指定接口。Java 接口定义了一组客户端可以调用的方法。实现类对客户端是隐藏的。此外,由于 Java 是一种静态类型语言,如果接口发生变化,与客户端不兼容,则应用程序将无法编译。

在微服务架构中,API 和接口同样重要。服务的 API 是服务与其客户端之间的合同。如第二章所述,服务的 API 包括客户端可以调用的操作和由服务发布的事件。一个操作有一个名称、参数和返回类型。一个事件有一个类型和一组字段,如第 3.3 节所述,发布到一个消息通道。

挑战在于服务 API 不是使用简单的编程语言构造来定义的。根据定义,服务和其客户端不是一起编译的。如果一个服务的新的不兼容 API 版本被部署,将不会有编译错误。相反,将会出现运行时错误。

无论你选择哪种 IPC 机制,使用某种类型的接口定义语言(IDL)精确地定义服务的 API 都是非常重要的。此外,有很好的理由使用 API 优先的方法来定义服务(更多信息请参阅www.programmableweb.com/news/how-to-design-great-apis-api-first-design-and-raml/how-to/2015/07/10)。首先,你编写接口定义。然后,与客户端开发者审查接口定义。只有在迭代 API 定义之后,你才实施服务。这种前期设计可以增加你构建满足客户端需求的服务的机会。

API 优先设计是至关重要的

即使在小项目中,我也见过由于组件没有就 API 达成一致而导致问题发生的情况。例如,在一个项目中,后端 Java 开发者和 AngularJS 前端开发者都表示他们已经完成了开发。然而,应用程序却无法工作。前端应用程序用于与后端通信的 REST 和 WebSocket API 定义得不好。结果,两个应用程序无法通信!

API 定义的性质取决于你使用的 IPC 机制。例如,如果你使用消息传递,API 包括消息通道、消息类型和消息格式。如果你使用 HTTP,API 包括 URL、HTTP 动词以及请求和响应格式。在本章的后面部分,我将解释如何定义 API。

服务的 API 很少是一成不变的。它很可能会随着时间的推移而演变。让我们看看如何做到这一点,并考虑你将面临的问题。

3.1.3. API 的演变

随着新功能的添加、现有功能的更改以及(可能)旧功能的删除,API 不可避免地会随着时间的推移而变化。在单体应用程序中,更改 API 并更新所有调用者相对简单。如果你使用静态类型语言,编译器会通过提供编译错误列表来帮助。唯一的挑战可能是变更的范围。更改广泛使用的 API 可能需要很长时间。

在基于微服务的应用程序中,更改服务的 API 要困难得多。服务的客户端是其他服务,这些服务通常由其他团队开发。客户端甚至可能是组织外的其他应用程序。你通常无法强迫所有客户端与服务同步升级。此外,由于现代应用程序通常永远不会因维护而停机,你通常会执行服务的滚动升级,因此服务的旧版本和新版本将同时运行。

制定应对这些挑战的策略很重要。你如何处理 API 的变更取决于变更的性质。

使用语义版本控制

语义版本控制规范(semver.org)是 API 版本化的有用指南。它是一组规则,指定了如何使用和递增版本号。语义版本控制最初旨在用于软件包的版本控制,但你也可以用于分布式系统中 API 的版本控制。

语义版本控制规范(Semvers)要求版本号由三部分组成:MAJOR.MINOR.PATCH。你必须按照以下方式递增版本号的每一部分:

  • MAJOR 当你对 API 进行不兼容的变更时

  • MINOR 当你对 API 进行向后兼容的增强时

  • PATCH 当你进行向后兼容的错误修复时

在 API 中,你可以使用版本号的地方有几个。如果你正在实现 REST API,你可以像下面提到的,将主版本号用作 URL 路径的第一个元素。或者,如果你正在实现使用消息传递的服务,你可以在它发布的消息中包含版本号。目标是正确地版本化 API,并按受控方式演进。让我们看看如何处理次要和主要变更。

进行次要的向后兼容变更

理想情况下,你应该努力只进行向后兼容的变更。向后兼容的变更是对 API 的增量变更:

  • 添加可选属性到请求中

  • 向响应中添加属性

  • 添加新操作

如果你只进行这类更改,较旧的客户端仍然可以与较新的服务一起工作,前提是它们遵循鲁棒性原则(en.wikipedia.org/wiki/Robustness_principle),该原则指出:“在行动上要保守,在接受他人时要有宽容。”服务应提供缺失请求属性的默认值。同样,客户端应忽略任何额外的响应属性。为了使这一过程不痛苦,客户端和服务必须使用支持鲁棒性原则的请求和响应格式。在本节后面的内容中,我将描述基于文本的格式,如 JSON 和 XML,通常如何使 API 的演变更容易。

进行重大、破坏性的更改

有时候你必须对 API 进行重大且不兼容的更改。由于你不能强迫客户端立即升级,因此服务必须在一段时间内同时支持 API 的旧版本和新版本。如果你使用基于 HTTP 的 IPC 机制,如 REST,一种方法是将主要版本号嵌入到 URL 中。例如,版本 1 的路径以'/v1/...'开头,版本 2 的路径以'/v2/...'开头。

另一个选择是使用 HTTP 的内容协商机制,并在 MIME 类型中包含版本号。例如,客户端可以通过以下请求来请求Order1.x版本:

GET /orders/xyz HTTP/1.1
Accept: application/vnd.example.resource+json; version=1
...

这个请求告诉Order Service,客户端期望得到一个版本1.x的响应。

为了支持 API 的多个版本,实现 API 的服务适配器将包含在旧版本和新版本之间进行转换的逻辑。此外,正如在第八章 chapter 8 中所述,API 网关几乎肯定会使用版本化的 API。它甚至可能需要支持 API 的多个旧版本。

现在我们将探讨消息格式的问题,选择哪种格式可能会影响 API 演变的难易程度。

3.1.4. 消息格式

IPC 的本质是消息的交换。消息通常包含数据,因此一个重要的设计决策是数据格式。消息格式的选择可能会影响 IPC 的效率、API 的可用性和其可扩展性。如果你使用消息系统或如 HTTP 之类的协议,你可以选择你的消息格式。一些 IPC 机制——如你很快就会了解到的 gRPC——可能会规定消息格式。在任何情况下,使用跨语言的消息格式都是至关重要的。即使你今天正在用单一语言编写微服务,将来也很可能使用其他语言。例如,你不应该使用 Java 序列化。

消息格式主要分为两大类:文本和二进制。让我们逐一来看。

基于文本的消息格式

第一类是基于文本的格式,如 JSON 和 XML。这些格式的优点是不仅可读性好,而且具有自描述性。一个 JSON 消息是一组命名的属性集合。同样,一个 XML 消息实际上是一组命名的元素和值的集合。这种格式使得消息的消费者能够挑选出感兴趣的值并忽略其余部分。因此,许多对消息架构的更改可以很容易地实现向后兼容。

XML 文档的结构由 XML 模式指定 (www.w3.org/XML/Schema)。随着时间的推移,开发社区逐渐意识到 JSON 也需要类似的机制。一个流行的选择是使用 JSON Schema 标准 (json-schema.org)。一个 JSON 模式定义了消息属性的名称和类型,以及它们是可选的还是必需的。除了作为有用的文档外,JSON 模式还可以由应用程序用于验证传入的消息。

使用基于文本的消息格式的缺点是消息往往很冗长,尤其是 XML。每个消息都包含属性名称及其值的开销。另一个缺点是解析文本的开销,尤其是当消息很大时。因此,如果效率和性能很重要,你可能想要考虑使用二进制格式。

二进制消息格式

有几种不同的二进制格式可供选择。流行的格式包括 Protocol Buffers (developers.google.com/protocol-buffers/docs/overview) 和 Avro (avro.apache.org)。这两种格式都提供了一种类型化的 IDL,用于定义消息的结构。然后编译器生成序列化和反序列化消息的代码。这使得你不得不采取 API 首选的方法来设计服务!此外,如果你用静态类型语言编写客户端,编译器会检查它是否正确使用了 API。

这两种二进制格式之间的一个区别是,Protocol Buffers 使用标记字段,而 Avro 消费者需要知道模式才能解释消息。因此,与 Avro 相比,使用 Protocol Buffers 处理 API 进化要容易得多。这篇博客文章 (martin.kleppmann.com/2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html) 是 Thrift、Protocol Buffers 和 Avro 之间优秀比较。

现在我们已经了解了消息格式,接下来让我们看看具体的 IPC 机制,这些机制用于传输消息,从远程过程调用(RPI)模式开始。

3.2. 使用同步远程过程调用模式进行通信

当使用基于远程过程调用的 IPC 机制时,客户端向服务发送请求,服务处理请求并发送响应。一些客户端可能会阻塞等待响应,而另一些可能具有反应性、非阻塞的架构。但与使用消息传递不同,客户端假设响应将及时到达。

图 3.1 展示了 RPI 的工作原理。客户端的业务逻辑调用一个由RPI 代理适配器类实现的接口。RPI 代理向服务发送请求。请求由一个RPI 服务器适配器类处理,该适配器类通过接口调用服务的业务逻辑。然后它将回复发送回RPI 代理RPI 代理将结果返回给客户端的业务逻辑。

模式:远程过程调用

客户端使用基于同步、远程过程调用协议的服务,例如 REST (microservices.io/patterns/communication-style/messaging.html)。

图 3.1. 客户端业务逻辑调用一个由RPI 代理适配器类实现的接口。RPI 代理类向服务发送请求。RPI 服务器适配器类通过调用服务的业务逻辑来处理请求。

代理接口通常封装了底层的通信协议。有众多协议可供选择。在本节中,我将介绍 REST 和 gRPC。我将解释如何通过妥善处理部分故障来提高服务的可用性,并解释为什么基于 RPI 的微服务应用必须使用服务发现机制。

让我们先看看 REST。

3.2.1. 使用 REST

现在,以 RESTful 风格开发 API 很流行 (en.wikipedia.org/wiki/Representational_state_transfer)。REST是一种 IPC 机制,几乎总是使用 HTTP。REST 的创造者 Roy Fielding 将其定义为如下:

REST 提供了一套架构约束,当整体应用时,强调组件交互的可扩展性、接口的通用性、组件的独立部署,以及中间件组件以减少交互延迟、加强安全性和封装遗留系统。

www.ics.uci.edu/~fielding/pubs/dissertation/top.htm

REST 中的一个关键概念是资源,它通常代表单个业务对象,如客户或产品,或一组业务对象。REST 使用 HTTP 动词来操作资源,这些动词通过 URL 引用。例如,GET 请求返回资源的表示,这通常是 XML 文档或 JSON 对象的形式,尽管也可以使用其他格式,如二进制格式。POST 请求创建新的资源,而 PUT 请求更新资源。例如,Order Service有一个POST /orders端点用于创建Order,以及一个GET /orders/{orderId}端点用于检索Order

许多开发人员声称他们的基于 HTTP 的 API 是 RESTful 的。但正如 Roy Fielding 在博客文章中描述的,并非所有这些 API 实际上都是 RESTful 的。为了了解原因,让我们看一下 REST 成熟度模型。

The REST 成熟度模型

Leonard Richardson(与您的作者无亲属关系)定义了一个非常实用的 REST 成熟度模型(martinfowler.com/articles/richardsonMaturityModel.html),它包括以下级别:

  • Level 0 0 级服务的客户端通过向其唯一的 URL 端点发送 HTTP POST 请求来调用服务。每个请求都指定要执行的操作、操作的目标(例如,业务对象)以及任何参数。

  • Level 1 1 级服务支持资源的概念。要对资源执行操作,客户端需要发送一个 POST 请求,指定要执行的操作和任何参数。

  • Level 2 2 级服务使用 HTTP 动词执行操作:GET 用于检索,POST 用于创建,PUT 用于更新。请求的查询参数和(如果有的话)正文指定了操作的参数。这使得服务可以使用 Web 基础设施,如缓存,用于 GET 请求。

  • Level 3 3 级服务的设计基于名为 HATEOAS(Hypertext As The Engine Of Application State)的糟糕命名原则。基本思想是,GET 请求返回的资源表示中包含执行该资源操作的超链接。例如,客户端可以使用 GET 请求返回的表示中的链接来取消订单。HATEOAS 的好处包括不再需要在客户端代码中硬编码 URL(www.infoq.com/news/2009/04/hateoas-restful-api-advantages)。

我鼓励您审查贵组织的 REST API,以了解它们对应哪个级别。

指定 REST API

如前文第 3.1 节所述,你必须使用接口定义语言(IDL)来定义你的 API。与像 CORBA 和 SOAP 这样的旧通信协议不同,REST 最初并没有 IDL。幸运的是,开发社区重新发现了 IDL 对 RESTful API 的价值。最受欢迎的 REST IDL 是 Open API 规范 (www.openapis.org),它起源于 Swagger 开源项目。Swagger 项目是一套用于开发文档化 REST API 的工具。它包括从接口定义生成客户端存根和服务器骨架的工具。

在单个请求中检索多个资源的挑战

REST 资源通常围绕业务对象,如ConsumerOrder。因此,设计 REST API 时常见的难题是如何使客户端能够在一个请求中检索多个相关对象。例如,想象一个 REST 客户端想要检索一个Order及其OrderConsumer。一个纯 REST API 将要求客户端至少发出两个请求,一个用于Order,另一个用于其Consumer。更复杂的场景可能需要更多的往返,并遭受过度的延迟。

解决这个问题的方法之一是让 API 允许客户端在获取资源时检索相关资源。例如,客户端可以使用GET /orders/order-id-1345?expand=consumer来检索Order及其Consumer。查询参数指定了与Order一起返回的相关资源。这种方法在许多场景下效果良好,但对于更复杂的场景通常是不够的。此外,实现起来可能也很耗时。这导致了像 GraphQL (graphql.org) 和 Netflix Falcor (netflix.github.io/falcor/) 这样的替代 API 技术的日益流行,这些技术旨在支持高效的数据检索。

将操作映射到 HTTP 动词的挑战

另一个常见的 REST API 设计问题是如何将你想要在业务对象上执行的操作映射到 HTTP 动词。REST API 应该使用 PUT 进行更新,但可能有多种更新订单的方式,包括取消订单、修改订单等。此外,更新可能不是幂等的,这是使用 PUT 的要求。一种解决方案是为更新资源的特定方面定义一个子资源。例如,Order Service有一个用于取消订单的POST /orders/{orderId}/cancel端点,以及一个用于修改订单的POST /orders/{orderId}/revise端点。另一种解决方案是将动词指定为 URL 查询参数。遗憾的是,这两种解决方案都不太符合 REST 原则。

将操作映射到 HTTP 动词的问题导致了 REST 替代方案的日益流行,如稍后将在第 3.2.2 节中讨论的 gPRC。但首先让我们看看 REST 的好处和缺点。

REST 的好处和缺点

使用 REST 有许多好处:

  • 它简单且熟悉。

  • 您可以使用例如 Postman 插件在浏览器内测试 HTTP API,或者使用 curl 从命令行测试(假设使用 JSON 或其他文本格式)。

  • 它直接支持请求/响应风格的通信。

  • 当然,HTTP 是防火墙友好的。

  • 它不需要中间代理,这简化了系统的架构。

使用 REST 有一些缺点:

  • 它仅支持请求/响应风格的通信。

  • 可用性降低。由于客户端和服务直接通信,没有中间件来缓冲消息,因此它们都必须在交换期间运行。

  • 客户端必须知道服务实例的位置(URL)。如第 3.2.4 节所述,这在现代应用中是一个非平凡的问题。客户端必须使用所谓的服务发现机制来定位服务实例。

  • 在单个请求中检索多个资源具有挑战性。

  • 将多个更新操作映射到 HTTP 动词有时很困难。

尽管有这些缺点,REST 似乎仍然是 API 的事实标准,尽管有几个有趣的替代方案。例如,GraphQL 实现了灵活、高效的数据获取。第八章讨论了 GraphQL 并涵盖了 API 网关模式。

gRPC 是 REST 的另一种选择。让我们看看它是如何工作的。

3.2.2. 使用 gRPC

如前所述,使用 REST 的一个挑战是,由于 HTTP 只提供有限数量的动词,因此设计支持多个更新操作的 REST API 并不总是直接的。避免此问题的 IPC 技术是 gRPC (www.grpc.io),这是一个用于编写跨语言客户端和服务器框架(更多内容请参见en.wikipedia.org/wiki/Remote_procedure_call)。gRPC 是一个基于二进制消息的协议,这意味着——如前所述,在讨论二进制消息格式时——您被迫采用以 API 为先的方法来设计服务。您使用基于 Protocol Buffers 的 IDL 定义您的 gRPC API,这是 Google 的用于序列化结构化数据的中立语言机制。您使用 Protocol Buffer 编译器生成客户端存根和服务器骨架。编译器可以为包括 Java、C#、NodeJS 和 GoLang 在内的多种语言生成代码。客户端和服务器使用 HTTP/2 在 Protocol Buffers 格式中交换二进制消息。

gRPC API 由一个或多个服务和请求/响应消息定义组成。服务定义类似于 Java 接口,是一组强类型方法的集合。除了支持简单的请求/响应 RPC 外,gRPC 还支持流式 RPC。服务器可以向客户端发送消息流作为响应。或者,客户端可以向服务器发送消息流。

gRPC 使用 Protocol Buffers 作为消息格式。正如之前提到的,Protocol Buffers 是一种高效、紧凑的二进制格式。它是一种标记格式。Protocol Buffers 消息的每个字段都进行了编号并具有类型代码。消息接收者可以提取它需要的字段,并跳过它不认识的字段。因此,gRPC 使得 API 在保持向后兼容的同时可以进化。

列表 3.1 展示了Order Service的 gRPC API 摘录。它定义了包括createOrder()在内的几个方法。该方法接受一个CreateOrderRequest作为参数,并返回一个CreateOrderReply

列表 3.1. Order Service的 gRPC API 摘录
service OrderService {
  rpc createOrder(CreateOrderRequest) returns (CreateOrderReply) {}
  rpc cancelOrder(CancelOrderRequest) returns (CancelOrderReply) {}
  rpc reviseOrder(ReviseOrderRequest) returns (ReviseOrderReply) {}
  ...
}

message CreateOrderRequest {
  int64 restaurantId = 1;
  int64 consumerId = 2;
  repeated LineItem lineItems = 3;
  ...
}

message LineItem {
  string menuItemId = 1;
  int32 quantity = 2;
}

message CreateOrderReply {
  int64 orderId = 1;
}
...

CreateOrderRequestCreateOrderReply是类型消息。例如,CreateOrderRequest消息有一个类型为int64restaurantId字段。该字段的标签值是 1。

gRPC 有以下几个优点:

  • 设计一个具有丰富更新操作的 API 很简单。

  • 它有一个高效、紧凑的 IPC 机制,尤其是在交换大消息时。

  • 双向流支持 RPI 和消息通信风格。

  • 它使得用各种语言编写的客户端和服务之间能够互操作。

gRPC 也有一些缺点:

  • 相比于基于 REST/JSON 的 API,JavaScript 客户端消费基于 gRPC 的 API 需要更多的工作。

  • 较旧的防火墙可能不支持 HTTP/2。

gRPC 是 REST 的强大替代品,但像 REST 一样,它也是一种同步通信机制,因此它也面临着部分失败的问题。让我们看看这是什么以及如何处理它。

3.2.3. 使用断路器模式处理部分失败

在分布式系统中,每当一个服务向另一个服务发起同步请求时,总存在部分失败的风险。因为客户端和服务是独立的进程,服务可能无法及时响应客户端的请求。服务可能因为故障或维护而宕机。或者服务可能过载,对请求响应极其缓慢。由于客户端被阻塞等待响应,风险是失败可能会级联到客户端的客户端,等等,从而导致服务中断。

模式:断路器

当连续失败次数超过指定阈值后,RPI 代理会立即拒绝超时期间的调用。参见microservices.io/patterns/reliability/circuit-breaker.html

例如,考虑图 3.2 中所示的场景,其中Order Service无响应。移动客户端向 API 网关发出 REST 请求,正如第八章中讨论的,API 网关是 API 客户端进入应用的入口点。API 网关将请求代理到无响应的Order Service

图 3.2. API 网关必须保护自己免受无响应服务,如Order Service的影响。

图片

OrderServiceProxy的简单实现会无限期地阻塞,等待响应。这不仅会导致糟糕的用户体验,而且在许多应用中还会消耗宝贵的资源,例如线程。最终,API 网关会耗尽资源,无法处理请求。整个 API 将不可用。

确保你设计的服务能够防止部分失败在整个应用中级联至关重要。解决方案有两个部分:

  • 您必须使用设计 RPI 代理,例如OrderServiceProxy,来处理无响应的远程服务。

  • 您需要决定如何从失败的远程服务中恢复。

首先,我们将探讨如何编写健壮的 RPI 代理。

开发健壮的 RPI 代理

每当某个服务同步调用另一个服务时,它应该使用 Netflix(techblog.netflix.com/2012/02/fault-tolerance-in-high-volume.html)描述的方法来保护自己。这种方法包括以下机制的组合:

  • 网络超时—**永远不要无限期地阻塞,并且在等待响应时始终使用超时。使用超时可以确保资源不会被无限期地占用。

  • 限制客户端对服务的未完成请求数量—**对客户端对特定服务可以发出的未完成请求的数量设置上限。如果达到限制,再进行额外的请求可能毫无意义,并且这些尝试应该立即失败。

  • 断路器模式—**跟踪成功和失败的请求数量,如果错误率超过某个阈值,则触发断路器,使进一步的尝试立即失败。大量请求失败表明服务不可用,发送更多请求是毫无意义的。在超时期间,客户端应再次尝试,如果成功,则关闭断路器。

Netflix Hystrix(github.com/Netflix/Hystrix)是一个开源库,实现了这些和其他模式。如果您使用 JVM,在实现 RPI 代理时,您绝对应该考虑使用 Hystrix。如果您在非 JVM 环境中运行,您应该使用等效的库。例如,Polly 库在.NET 社区中很受欢迎(github.com/App-vNext/Polly)。

从不可用的服务中恢复

使用像 Hystrix 这样的库只是解决方案的一部分。你还必须根据每个服务的情况决定如何从无响应的远程服务中恢复。一个选项是服务简单地向其客户端返回错误。例如,这种方法对于图 3.2 中显示的场景是有意义的,其中创建订单的请求失败。唯一的选择是 API 网关向移动客户端返回错误。

在其他场景中,返回回退值,例如默认值或缓存响应,可能是有意义的。例如,第七章描述了 API 网关如何通过使用 API 组合模式实现findOrder()查询操作。如图 3.3 所示,其实现在GET /orders/{orderId}端点的实现调用包括订单服务厨房服务配送服务在内的多个服务,并组合结果。

图 3.3。API 网关使用 API 组合实现了GET /orders/{orderId}端点。它调用多个服务,聚合它们的响应,并将响应发送到移动应用程序。实现端点的代码必须有一种处理它所调用每个服务失败的策略。

图片

很可能每个服务的数据对客户端的重要性并不相同。订单服务的数据是至关重要的。如果这个服务不可用,API 网关应该返回其数据的缓存版本或错误。其他服务的数据不太关键。例如,即使配送状态不可用,客户端也可以向用户显示有用的信息。如果配送服务不可用,API 网关应该返回其数据的缓存版本或从响应中省略它。

设计你的服务以处理部分故障是至关重要的,但这并不是在使用 RPI 时你需要解决的唯一问题。另一个问题是,为了使用 RPI 调用另一个服务,一个服务需要知道服务实例的网络位置。表面上这听起来很简单,但在实践中这是一个具有挑战性的问题。你必须使用服务发现机制。让我们看看它是如何工作的。

3.2.4. 使用服务发现

假设你正在编写一些调用具有 REST API 的服务的代码。为了发出请求,你的代码需要知道服务实例的网络位置(IP 地址和端口)。在一个运行在物理硬件上的传统应用程序中,服务实例的网络位置通常是静态的。例如,你的代码可以从偶尔更新的配置文件中读取网络位置。但在一个现代的基于云的微服务应用程序中,这通常并不那么简单。如图 3.4 所示,现代应用程序要动态得多。

图 3.4. 服务实例具有动态分配的 IP 地址。

图片

服务实例具有动态分配的网络位置。此外,由于自动扩展、故障和升级,服务实例的集合会动态变化。因此,您的客户端代码必须使用服务发现。

服务发现概述

正如您刚才看到的,您不能使用服务的 IP 地址静态配置客户端。相反,应用程序必须使用动态服务发现机制。服务发现从概念上讲相当简单:其关键组件是服务注册表,它是应用程序服务实例网络位置的数据库。

服务发现机制在服务实例启动和停止时更新服务注册表。当客户端调用服务时,服务发现机制查询服务注册表以获取可用服务实例的列表,并将请求路由到其中之一。

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

  • 服务及其客户端直接与服务注册表交互。

  • 部署基础设施处理服务发现。(我在第十二章中谈到了更多关于这一点。)

让我们看看每个选项。

应用应用程序级服务发现模式

实现服务发现的一种方式是让应用程序的服务及其客户端与服务注册表进行交互。图 3.5 展示了这是如何工作的。服务实例将其网络位置注册到服务注册表中。服务客户端通过首先查询服务注册表以获取服务实例列表来调用服务。然后,它向这些实例中的一个发送请求。

图 3.5. 服务注册表跟踪服务实例。客户端查询服务注册表以找到可用服务实例的网络位置。

图片

这种服务发现方法结合了两种模式。第一种模式是自动注册模式。服务实例通过调用服务注册表的注册 API 来注册其网络位置。它还可以提供一个详细的健康检查 URL,如第十一章中所述。健康检查 URL 是服务注册表定期调用的 API 端点,以验证服务实例是否健康且可以处理请求。服务注册表可能要求服务实例定期调用“心跳”API,以防止其注册过期。

模式:自动注册

服务实例将自己注册到服务注册表中。请参阅microservices.io/patterns/self-registration.html

第二种模式是客户端发现模式。当服务客户端想要调用一个服务时,它会查询服务注册表以获取该服务的实例列表。为了提高性能,客户端可能会缓存服务实例。然后服务客户端使用负载均衡算法,例如轮询或随机,来选择一个服务实例。然后它向选定的服务实例发出请求。

模式:客户端发现

服务客户端从服务注册表中检索可用服务实例的列表,并在它们之间进行负载均衡。请参阅microservices.io/patterns/client-side-discovery.html

应用级服务发现已被 Netflix 和 Pivotal 普及。Netflix 开发和开源了几个组件:Eureka,一个高度可用的服务注册表,Eureka Java 客户端,以及 Ribbon,一个支持 Eureka 客户端的复杂 HTTP 客户端。Pivotal 开发了基于 Spring 的 Spring Cloud 框架,这使得使用 Netflix 组件变得非常容易。基于 Spring Cloud 的服务会自动注册到 Eureka,基于 Spring Cloud 的客户端会自动使用 Eureka 进行服务发现。

应用级服务发现的优点之一是它处理了服务部署在多个部署平台上的场景。想象一下,例如,你只将一些服务部署在第十二章中讨论的 Kubernetes 上,其余的运行在传统环境中。例如,使用 Eureka 进行的应用级服务发现可以在两个环境中工作,而基于 Kubernetes 的服务发现仅限于 Kubernetes 内部。

应用级服务发现的一个缺点是,你需要为使用的每种语言(以及可能的项目框架)都提供一个服务发现库。Spring Cloud 仅帮助 Spring 开发者。如果你使用的是其他 Java 框架或非 JVM 语言,如 NodeJS 或 GoLang,你必须找到其他服务发现框架。应用级服务发现的另一个缺点是你需要负责设置和管理服务注册表,这会分散注意力。因此,通常最好使用由部署基础设施提供的服务发现机制。

应用平台提供的服务发现模式

在第十二章中,你将了解到许多现代部署平台,如 Docker 和 Kubernetes,都内置了服务注册和服务发现机制。部署平台为每个服务分配一个 DNS 名称、一个虚拟 IP(VIP)地址以及解析到 VIP 地址的 DNS 名称。服务客户端向 DNS 名称/VIP 发起请求,部署平台自动将请求路由到可用的服务实例之一。因此,服务注册、服务发现和请求路由完全由部署平台处理。图 3.6 展示了这是如何工作的。

图 3.6。平台负责服务注册、发现和请求路由。服务实例由注册器注册到服务注册表中。每个服务都有一个网络位置、一个 DNS 名称/虚拟 IP 地址。客户端向服务的网络位置发起请求。路由器查询服务注册表并在可用的服务实例之间负载均衡请求。

图片

部署平台包含一个服务注册表,跟踪已部署服务的 IP 地址。在这个例子中,客户端使用 DNS 名称order-service访问Order Service,该名称解析为虚拟 IP 地址10.1.3.4。部署平台自动在Order Service的三个实例之间负载均衡请求。

这种方法结合了两种模式:

  • 第三方注册模式 不同于服务将自己注册到服务注册表中,一个称为注册器的第三方,通常是部署平台的一部分,负责注册。

  • 服务器端发现模式 不同于客户端查询服务注册表,它向一个 DNS 名称发起请求,该名称解析为一个请求路由器,该路由器查询服务注册表并负载均衡请求。

模式:第三方注册

服务实例由第三方自动注册到服务注册表中。请参阅microservices.io/patterns/3rd-party-registration.html

模式:服务器端发现

客户端向负责服务发现的路由器发起请求。请参阅microservices.io/patterns/server-side-discovery.html

平台提供的服务发现的关键优势是服务发现的各个方面完全由部署平台处理。服务和客户端都不包含任何服务发现代码。因此,无论服务或客户端是用哪种语言或框架编写的,服务发现机制都可供所有服务和客户端使用。

平台提供的服务发现的一个缺点是它仅支持发现使用该平台部署的服务。例如,如前所述,在描述应用级发现时,基于 Kubernetes 的发现仅适用于在 Kubernetes 上运行的服务。尽管存在这种限制,我还是建议尽可能使用平台提供的服务发现。

现在我们已经了解了使用 REST 或 gRPC 进行的同步 IPC,让我们看看另一种选择:基于消息的异步通信。

3.3. 使用异步消息模式进行通信

当使用消息传递时,服务通过异步交换消息进行通信。基于消息传递的应用程序通常使用一个消息代理,它充当服务之间的中介,尽管另一种选择是使用无代理架构,其中服务直接相互通信。服务客户端通过向服务发送消息来向服务发出请求。如果预期服务实例将回复,它将通过向客户端发送一条单独的消息来这样做。因为通信是异步的,所以客户端不会阻塞等待回复。相反,客户端的编写假设回复不会立即收到。

模式:消息传递

客户端使用异步消息调用服务。参见microservices.io/patterns/communication-style/messaging.html

我以对消息的概述开始本节。我展示了如何独立于消息技术描述消息架构。接下来,我比较和对比了无代理和基于代理的架构,并描述了选择消息代理的标准。然后,我讨论了几个重要主题,包括在保持消息顺序的同时扩展消费者、检测和丢弃重复消息,以及作为数据库事务一部分发送和接收消息。让我们先看看消息是如何工作的。

3.3.1. 消息概述

在 Gregor Hohpe 和 Bobby Woolf 合著的书籍《企业集成模式》(Addison-Wesley Professional,2003 年)中定义了一个有用的消息传递模型。在这个模型中,消息通过消息通道进行交换。发送者(一个应用程序或服务)将消息写入通道,接收者(一个应用程序或服务)从通道读取消息。让我们先看看消息,然后再看看通道。

关于消息

消息由一个头部和消息体组成(www.enterpriseintegrationpatterns.com/Message.html)。头部是一系列名称-值对,是描述正在发送的数据的元数据。除了消息发送者提供的名称-值对之外,消息头部还包含名称-值对,例如由发送方或消息基础设施生成的唯一消息 ID,以及可选的返回地址,它指定了应写入的回复消息通道。消息是正在发送的数据,可以是文本或二进制格式。

存在几种不同类型的消息:

  • 文档 仅包含数据的通用消息。接收方决定如何解释它。命令的回复是一个文档消息的例子。

  • 命令 与 RPC 请求等价的消息。它指定要调用的操作及其参数。

  • 事件 指示发送方发生了值得注意的事情的消息。事件通常是领域事件,它代表领域对象(如OrderCustomer)的状态变化。

本书所描述的微服务架构方法广泛使用了命令和事件。

现在我们来看看通道,这是服务之间通信的机制。

关于消息通道

如图 3.7 所示,消息通过通道(www.enterpriseintegrationpatterns.com/MessageChannel.html)进行交换。发送方的业务逻辑调用一个发送端口接口,该接口封装了底层的通信机制。发送端口由一个消息发送器适配器类实现,它通过消息通道向接收方发送消息。消息通道是对消息基础设施的一种抽象。接收方的一个消息处理器适配器类被调用以处理消息。它调用由消费者的业务逻辑实现的接收端口接口。任何数量的发送者可以向通道发送消息。同样,任何数量的接收者都可以从通道接收消息。

图 3.7。发送方的业务逻辑调用一个发送端口接口,该接口由消息发送器适配器实现。消息发送器通过消息通道向接收方发送消息。消息通道是对消息基础设施的一种抽象。接收方的一个消息处理器适配器被调用以处理消息。它调用由接收方业务逻辑实现的接收端口接口。

有两种类型的通道:点对点(www.enterpriseintegrationpatterns.com/PointToPointChannel.html)和发布-订阅(www.enterpriseintegrationpatterns.com/PublishSubscribeChannel.html):

  • 一个 点对点 通道将消息发送给从通道中读取的恰好一个消费者。服务使用点对点通道来实现前面描述的一对一交互风格。例如,命令消息通常通过点对点通道发送。

  • 一个 发布-订阅 通道将每条消息发送给所有附加的消费者。服务使用发布-订阅通道来实现前面描述的一对多交互风格。例如,事件消息通常通过发布-订阅通道发送。

3.3.2. 使用消息实现交互风格

消息的一个宝贵特性是它足够灵活,可以支持第 3.1.1 节中描述的所有交互风格。一些交互风格可以通过消息直接实现。其他交互风格必须在消息之上实现。

让我们看看如何实现每种交互风格,从请求/响应和异步请求/响应开始。

实现请求/响应和异步请求/响应

当客户端和服务使用请求/响应或异步请求/响应进行交互时,客户端发送一个请求,服务发送一个回复。这两种交互风格之间的区别在于,在请求/响应中,客户端期望服务立即响应,而在异步请求/响应中则没有这样的期望。消息本质上是异步的,因此只提供异步请求/响应。但是客户端可以阻塞,直到收到回复。

客户端和服务通过交换一对消息来实现异步请求/响应风格的交互。如图 3.8 所示,客户端向服务拥有的点对点消息通道发送一个命令消息,该消息指定要执行的操作和参数。服务处理请求,并将包含结果的回复消息发送到客户端拥有的点对点通道。

图 3.8. 通过在请求消息中包含回复通道和消息标识符来实现异步请求/响应。接收者处理消息并将回复发送到指定的回复通道。

图 3.8 的替代文本

客户端必须告诉服务将回复消息发送到何处,并且必须将回复消息与请求匹配。幸运的是,解决这两个问题并不困难。客户端发送一个带有回复通道头的命令消息。服务器将包含与消息标识符相同值的关联 ID的回复消息写入回复通道。客户端使用关联 ID来匹配回复消息与请求。

由于客户端和服务通过消息进行通信,因此交互本质上是异步的。从理论上讲,消息客户端可能会阻塞,直到它收到回复,但在实践中,客户端将异步处理回复。更重要的是,回复通常由客户端的任何实例之一处理。

实现单向通知

使用异步消息实现单向通知很简单。客户端向服务拥有的点对点通道发送消息,通常是命令消息。服务订阅该通道并处理消息。它不会发送回复。

实现发布/订阅

消息内置了对发布/订阅交互风格的支撑。客户端向由多个消费者读取的发布/订阅通道发布消息。如第四章和 5 章所述,服务使用发布/订阅来发布表示领域对象变化的领域事件。发布领域事件的服务的拥有一个发布/订阅通道,其名称来自领域类。例如,Order ServiceOrder事件发布到Order通道,而Delivery ServiceDelivery事件发布到Delivery通道。只对特定领域对象的事件感兴趣的服务只需订阅适当的通道。

实现发布/异步响应

发布/异步响应交互风格是一种高级交互风格,通过结合发布/订阅和请求/响应的元素来实现。客户端向发布/订阅通道发布一条消息,该消息指定了一个回复通道头。消费者将包含关联 ID的回复消息写入回复通道。客户端通过使用关联 ID来匹配回复消息与请求来收集响应。

在您的应用程序中,每个具有异步 API 的服务都将使用这些实现技术之一或多个。具有用于调用操作异步 API 的服务将有一个用于请求的消息通道。同样,发布事件的服务会将事件发布到事件消息通道。

如第 3.1.2 节所述,为服务编写 API 规范非常重要。让我们看看如何为异步 API 执行此操作。

3.3.3. 为基于消息的服务 API 创建 API 规范

服务的异步 API 规范必须,如图 3.9 所示,指定消息通道的名称,每个通道上交换的消息类型及其格式。您还必须使用 JSON、XML 或 Protobuf 等标准描述消息的格式。但与 REST 和 Open API 不同,没有广泛采用的标准来记录通道和消息类型。相反,您需要编写一个非正式的文档。

图 3.9. 服务的异步 API 由消息通道和命令、回复和事件消息类型组成。

图片

服务的异步 API 由客户端调用的操作和由服务发布的事件组成。它们以不同的方式进行记录。让我们看看每个,从操作开始。

记录异步操作

服务的操作可以使用两种不同的交互风格之一来调用:

  • 请求/异步响应式 API* 这包括服务的命令消息通道,服务接受的命令消息类型的类型和格式,以及服务发送的回复消息的类型和格式。

  • 单向通知式 API* 这包括服务的命令消息通道和服务接受的命令消息类型的类型和格式。

服务可能使用相同的请求通道进行异步请求/响应和单向通知。

记录已发布的事件

服务还可以使用发布/订阅交互风格发布事件。这种 API 风格的规范包括事件通道和服务向该通道发布的消息类型和格式。

消息和通道模型是消息的一个很好的抽象,也是设计服务异步 API 的好方法。但为了实现服务,您需要选择一个消息技术并确定如何使用其功能来实现您的设计。让我们看看涉及的内容。

3.3.4. 使用消息代理

基于消息的应用程序通常使用一个消息代理,这是一个服务通过其进行通信的基础设施服务。但基于代理的架构并不是唯一的消息架构。您还可以使用无代理的消息架构,其中服务直接相互通信。这两种方法,如图 3.10 所示,有不同的权衡,但通常基于代理的架构是更好的方法。

图 3.10. 无代理架构中的服务直接通信,而基于代理架构中的服务通过消息代理进行通信。

图片

本书侧重于基于代理的架构,但快速看一下无代理架构是有价值的,因为可能存在您会发现它有用的场景。

无代理消息

在无代理架构中,服务可以直接交换消息。ZeroMQ (zeromq.org) 是一种流行的无代理消息技术。它既是一个规范,也是一套支持不同语言的库。它支持多种传输方式,包括 TCP、UNIX 风格的域套接字和多播。

无代理架构有一些好处:

  • 由于消息直接从发送者到接收者,而不是必须从发送者到消息代理再到接收者,因此允许更轻的网络流量和更好的延迟

  • 消除了消息代理成为性能瓶颈或单点故障的可能性

  • 由于没有消息代理需要设置和维护,因此具有更少的操作复杂性

尽管这些好处可能很有吸引力,但无代理消息存在显著的缺点:

  • 服务需要了解彼此的位置,因此必须使用之前在第 3.2.4 节中描述的发现机制之一。

  • 由于消息的发送者和接收者必须在消息交换期间都可用,因此它提供了降低的可用性

  • 实现机制,如保证交付,更具挑战性。

事实上,这些缺点中的一些,如降低的可用性和服务发现的需求,与使用同步、请求/响应相同。

由于这些限制,大多数企业应用程序使用基于消息代理的架构。让我们看看它是如何工作的。

基于代理的消息概述

消息代理是一个中介,所有消息都通过它流动。发送者将消息写入消息代理,然后消息代理将其传递给接收者。使用消息代理的一个重要好处是发送者不需要知道消费者的网络位置。另一个好处是消息代理可以缓冲消息,直到消费者能够处理它们。

有许多消息代理可供选择。以下是一些流行的开源消息代理的例子:

此外,还有基于云的消息服务,例如 AWS Kinesis (aws.amazon.com/kinesis/) 和 AWS SQS (aws.amazon.com/sqs/)。

在选择消息代理时,你需要考虑各种因素,包括以下内容:

  • 支持的编程语言 你可能应该选择支持多种编程语言的。

  • 支持的消息标准 消息代理是否支持任何标准,如 AMQP 和 STOMP,或者它是专有的?

  • 消息排序 消息代理是否保留消息的顺序?

  • 投递保证代理提供什么样的投递保证?

  • 持久性消息是否持久化到磁盘并且能够在代理崩溃后存活?

  • 持久性如果消费者重新连接到消息代理,它是否会收到在断开连接期间发送的消息?

  • 可扩展性消息代理的可扩展性如何?

  • 延迟端到端延迟是多少?

  • 竞争消费者消息代理支持竞争消费者吗?

每个代理都会做出不同的权衡。例如,一个低延迟的代理可能不会保持顺序,不保证投递消息,并且只在内存中存储消息。一个保证投递并可靠地将消息存储在磁盘上的消息代理可能会具有更高的延迟。哪种类型的消息代理最适合取决于您的应用程序需求。甚至可能您的应用程序的不同部分会有不同的消息需求。

尽管如此,消息排序和可扩展性可能是至关重要的。现在让我们看看如何使用消息代理实现消息通道。

使用消息代理实现消息通道

每个消息代理以不同的方式实现消息通道的概念。如表 3.2 所示,ActiveMQ 等 JMS 消息代理有队列和主题。基于 AMQP 的代理,如 RabbitMQ,有交换机和队列。Apache Kafka 有主题,AWS Kinesis 有流,AWS SQS 有队列。更重要的是,一些消息代理提供的消息传递比本章中描述的消息和通道抽象更灵活。

表 3.2。每个消息代理以不同的方式实现消息通道的概念。
消息代理 点对点通道 发布/订阅通道
JMS 队列 主题
Apache Kafka 主题 主题
基于 AMQP 的代理,如 RabbitMQ 交换机 + 队列 广播交换机以及每个消费者的队列
AWS Kinesis
AWS SQS 队列

几乎所有这里描述的消息代理都支持点对点和发布/订阅通道。唯一的例外是 AWS SQS,它只支持点对点通道。

现在让我们看看基于代理的消息传递的优缺点。

基于代理的消息传递的优缺点

使用基于代理的消息传递有许多优点:

  • 松散耦合客户端通过向适当的通道发送消息来发出请求。客户端完全不知道服务实例。它不需要使用发现机制来确定服务实例的位置。

  • 消息缓冲 消息代理将消息缓冲起来,直到它们可以被处理。在同步请求/响应协议(如 HTTP)中,客户端和服务必须在交换期间都可用。然而,在消息传递中,消息将排队直到它们可以被消费者处理。这意味着,例如,即使订单履行系统缓慢或不可用,在线商店也可以接受客户的订单。消息将简单地排队,直到它们可以被处理。

  • 灵活的通信 消息传递支持前面描述的所有交互样式。

  • 显式的进程间通信 基于 RPC 的机制试图使调用远程服务看起来与调用本地服务相同。但由于物理定律和部分失败的可能性,它们实际上相当不同。消息传递使得这些差异非常明确,因此开发者不会陷入虚假的安全感。

使用消息传递有一些缺点:

  • 潜在的性能瓶颈 消息代理可能成为性能瓶颈。幸运的是,许多现代消息代理都设计为高度可扩展。

  • 潜在的单一故障点 消息代理必须高度可用,这是至关重要的——否则,系统可靠性将受到影响。幸运的是,大多数现代代理都已被设计为高度可用。

  • 额外的操作复杂性 消息系统是必须安装、配置和运行的另一个系统组件。

让我们看看你可能会遇到的一些设计问题。

3.3.5. 竞争接收器和消息顺序

一个挑战是如何在保持消息顺序的同时扩展消息接收器。为了并发处理消息,通常需要多个服务实例。此外,即使单个服务实例也可能使用线程来并发处理多个消息。使用多个线程和服务实例并发处理消息可以增加应用程序的吞吐量。但并发处理消息的挑战在于确保每个消息只被处理一次,并且按照顺序处理。

例如,想象有三个服务实例从同一个点对点通道读取,并且发送者按顺序发布Order CreatedOrder UpdatedOrder Cancelled事件消息。一个简单的消息实现可能会并发地将每条消息发送给不同的接收者。由于网络问题或垃圾回收导致的延迟,消息可能会以错误的顺序处理,这可能导致奇怪的行为。理论上,一个服务实例可能会在另一个服务处理Order Created消息之前处理Order Cancelled消息!

一种常见的解决方案,由现代消息代理如 Apache Kafka 和 AWS Kinesis 使用,是使用分片(分区)通道。图 3.11 展示了这是如何工作的。该解决方案有三个部分:

  1. 分片通道由两个或多个分片组成,每个分片的行为都像一个通道。

  2. 发送者在消息头中指定一个分片键,这通常是一个任意字符串或字节序列。消息代理使用分片键将消息分配给特定的分片/分区。例如,它可能通过计算分片键的哈希值模分片数来选择分片。

  3. 消息代理将多个接收器实例组合在一起,并将它们视为同一个逻辑接收器。例如,Apache Kafka 使用术语消费者组。消息代理将每个分片分配给单个接收器。当接收器启动和关闭时,它会重新分配分片。

图 3.11. 通过使用分片(分区)消息通道在扩展消费者时保持消息排序。发送者在消息中包含分片键。消息代理将消息写入由分片键确定的分片。消息代理将每个分区分配给复制接收器的单个实例。

图片

在这个例子中,每个Order事件消息的orderId作为其分片键。特定订单的每个事件都发布到同一个分片,由单个消费者实例读取。因此,这些消息保证按顺序处理。

3.3.6. 处理重复消息

在使用消息传递时,你必须解决的另一个挑战是处理重复消息。理想情况下,消息代理应该只投递每条消息一次,但保证恰好一次投递通常成本太高。相反,大多数消息代理承诺至少投递一条消息。

当系统正常运行时,保证至少一次投递的消息代理将每条消息只投递一次。但是,客户端、网络或消息代理的故障可能导致消息被多次投递。假设一个客户端在处理消息并更新其数据库后崩溃,但在确认消息之前。消息代理将再次投递未确认的消息,要么在客户端重启时投递给该客户端,要么投递给客户端的另一个副本。

理想情况下,你应该使用在重新投递消息时保留排序的消息代理。想象一下,客户端处理了一个Order Created事件,然后处理了同一订单的Order Cancelled事件,但Order Created事件没有被确认。消息代理应该重新投递Order CreatedOrder Cancelled事件。如果它只重新投递Order Created,客户端可能会撤销取消订单的操作。

处理重复消息有几种不同的方法:

  • 编写幂等消息处理器。

  • 跟踪消息并丢弃重复项。

让我们逐一查看每个选项。

编写幂等消息处理器

如果处理消息的应用程序逻辑是幂等的,那么重复的消息是无害的。如果多次以相同的输入值调用它没有额外的效果,则应用程序逻辑是幂等的。例如,取消已取消的订单是一个幂等操作。创建带有客户端提供的 ID 的订单也是如此。只要消息代理在重新投递消息时保持顺序,幂等的消息处理器就可以安全地多次执行。

不幸的是,应用程序逻辑通常不是幂等的。或者你可能使用的是一个在重新投递消息时不保留顺序的消息代理。重复或顺序错误的消息可能导致错误。在这种情况下,你必须编写跟踪消息和丢弃重复消息的消息处理器。

跟踪消息和丢弃重复项

例如,考虑一个授权消费者信用卡的消息处理器。它必须为每个订单恰好授权一次卡。这个应用程序逻辑的例子每次被调用时都有不同的效果。如果重复的消息导致消息处理器多次执行此逻辑,应用程序的行为将不正确。执行此类应用程序逻辑的消息处理器必须通过检测和丢弃重复消息来成为幂等的。

一个简单的解决方案是消息消费者使用消息 ID跟踪它已处理的消息,并丢弃任何重复项。例如,它可以在数据库表中存储它消耗的每个消息的消息 ID。图 3.12 展示了如何使用专用表来实现这一点。

图 3.12。消费者通过在数据库表中记录已处理消息的 ID 来检测和丢弃重复消息。如果消息之前已被处理,向PROCESSED_MESSAGES表的INSERT操作将失败。

图片

当消费者处理消息时,它将消息 ID记录在数据库表中,作为创建和更新业务实体的事务的一部分。在这个例子中,消费者将包含消息 ID的行插入到PROCESSED_MESSAGES表中。如果消息是重复的,INSERT操作将失败,消费者可以丢弃该消息。

另一个选项是消息处理器在应用表中而不是专用表中记录消息 ID。当使用具有有限事务模型的 NoSQL 数据库时,这种方法特别有用,因为它不支持在数据库事务中更新两个表。第七章展示了这种方法的一个例子。

3.3.7. 事务性消息

一个服务通常需要在更新数据库的事务中发布消息。例如,在这本书的整个过程中,你都会看到服务在创建或更新业务实体时发布领域事件的示例。数据库更新和发送消息都必须在事务内完成。否则,服务可能在发送消息之前更新数据库并崩溃,例如。如果服务没有原子地执行这两个操作,失败可能会导致系统处于不一致的状态。

传统的解决方案是使用跨越数据库和消息代理的分布式事务。但正如你将在第四章(kindle_split_012.xhtml#ch04)中了解到的那样,分布式事务并不是现代应用程序的好选择。此外,许多现代代理,如 Apache Kafka,不支持分布式事务。

因此,应用程序必须使用不同的机制来可靠地发布消息。让我们看看它是如何工作的。

使用数据库表作为消息队列

让我们假设你的应用程序正在使用关系型数据库。一种可靠发布消息的简单方法是应用事务性输出队列模式。该模式使用数据库表作为临时消息队列。如图 3.13 所示,发送消息的服务有一个OUTBOX数据库表。作为创建、更新和删除业务对象的数据库事务的一部分,服务通过将消息插入OUTBOX表来发送消息。原子性得到保证,因为这是一个本地 ACID 事务。

图 3.13。一个服务通过将消息插入更新数据库的事务中的OUTBOX表来可靠地发布消息。Message Relay读取OUTBOX表并将消息发布到消息代理。

OUTBOX表充当临时消息队列。MessageRelay是一个组件,它读取OUTBOX表并将消息发布到消息代理。

模式:事务性输出队列

通过在数据库中的OUTBOX中保存事件或消息作为数据库事务的一部分来发布事件或消息。请参阅microservices.io/patterns/data/transactional-outbox.html

你可以使用类似的方法与一些 NoSQL 数据库一起使用。每个存储在数据库中作为record的业务实体都有一个属性,该属性是需要发布的消息列表。当服务更新数据库中的实体时,它会将一条消息追加到该列表中。这是原子的,因为它通过单个数据库操作完成。然而,挑战在于高效地找到具有事件的业务实体并将它们发布出去。

将消息从数据库移动到消息代理有几种不同的方法。我们将逐一查看。

通过轮询发布者模式发布事件

如果应用程序使用关系型数据库,一个发布OUTBOX表中插入的消息的非常简单的方法是让MessageRelay轮询表以查找未发布消息。它定期查询表:

SELECT * FROM OUTBOX ORDERED BY ... ASC

接下来,MessageRelay将这些消息发布到消息代理,将一条消息发送到其目标消息通道。最后,它从OUTBOX表中删除这些消息:

BEGIN
 DELETE FROM OUTBOX WHERE ID in (....)
COMMIT

模式:轮询发布者

通过轮询数据库中的OUTBOX来发布消息。参见microservices.io/patterns/data/polling-publisher.html

轮询数据库是一种简单的方法,在低规模下效果相当不错。缺点是频繁轮询数据库可能会很昂贵。此外,您是否可以使用此方法与 NoSQL 数据库一起使用取决于其查询能力。这是因为,而不是查询OUTBOX表,应用程序必须查询业务实体,这可能或可能无法高效地完成。由于这些缺点和限制,通常更好的做法——在某些情况下,这是必要的——是使用更复杂且性能更好的方法,即跟踪数据库事务日志。

通过应用事务日志跟踪模式发布事件

一个复杂的解决方案是让MessageRelay跟踪数据库事务日志(也称为提交日志)。应用程序所做的每个提交更新都表示为数据库事务日志中的一个条目。事务日志挖掘器可以读取事务日志,并将每个更改作为消息发布到消息代理。图 3.14 展示了这种方法是如何工作的。

图 3.14。一个服务通过挖掘数据库的事务日志来发布插入到OUTBOX表中的消息。

事务日志挖掘器读取事务日志条目。它将每个与插入消息相对应的相关日志条目转换为消息,并将该消息发布到消息代理。这种方法可以用来发布写入关系型数据库的OUTBOX表中的消息或追加到 NoSQL 数据库记录中的消息。

模式:事务日志跟踪

通过跟踪事务日志来发布对数据库所做的更改。参见microservices.io/patterns/data/transaction-log-tailing.html

有几个使用此方法的例子:

  • Debezium (debezium.io)——一个开源项目,将数据库更改发布到 Apache Kafka 消息代理。

  • LinkedIn Databus (github.com/linkedin/databus)——一个开源项目,挖掘 Oracle 事务日志并将更改作为事件发布。LinkedIn 使用 Databus 来同步各种派生数据存储与记录系统。

  • DynamoDB streams (docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.html)—DynamoDB streams 包含在过去 24 小时内对 DynamoDB 表中项目所做的更改(创建、更新和删除)的时间顺序序列。应用程序可以从流中读取这些更改,例如,将它们作为事件发布。

  • Eventuate Tram (github.com/eventuate-tram/eventuate-tram-core)—您作者的专属开源事务消息库,它使用 MySQL binlog 协议、Postgres WAL 或轮询来读取对OUTBOX表所做的更改,并将它们发布到 Apache Kafka。

尽管这种方法不太为人所知,但它工作得非常出色。挑战在于实现它需要一些开发工作。例如,你可以编写低级代码来调用数据库特定的 API。或者,你可以使用如 Debezium 这样的开源框架,它将 MySQL、Postgres 或 MongoDB 中应用程序所做的更改发布到 Apache Kafka。使用 Debezium 的缺点是它的重点是捕获数据库级别的更改,而发送和接收消息的 API 超出了其范围。这就是我创建 Eventuate Tram 框架的原因,它不仅提供了消息 API,还提供了事务跟踪和轮询功能。

3.3.8. 消息的库和框架

一个服务需要使用库来发送和接收消息。一种方法是用消息代理的客户端库,尽管直接使用此类库存在一些问题:

  • 客户端库将发布消息到消息代理 API 的业务逻辑耦合在一起。

  • 消息代理的客户端库通常是低级的,发送或接收消息需要很多行代码。作为开发者,你不想反复编写样板代码。此外,作为本书的作者,我不想示例代码被低级样板代码所充斥。

  • 客户端库通常只提供发送和接收消息的基本机制,不支持高级交互样式。

更好的方法是使用一个高级库或框架,它隐藏了低级细节并直接支持高级交互样式。为了简单起见,本书中的示例使用我的 Eventuate Tram 框架。它有一个简单、易于理解的 API,隐藏了使用消息代理的复杂性。除了发送和接收消息的 API 之外,Eventuate Tram 还支持异步请求/响应和领域事件发布等高级交互样式。

什么?!为什么是 Eventuate 框架?

本书中的代码示例使用我开发的开源 Eventuate 框架,用于事务消息、事件溯源和 sagas。我选择使用我的框架,因为与依赖注入和 Spring 框架不同,微服务架构所需的功能中,没有广泛采用的框架。如果没有 Eventuate Tram 框架,许多示例将不得不直接使用低级消息 API,这将使它们变得更加复杂,并掩盖重要的概念。或者,它们将使用一个不受广泛采用的框架,这也会引起批评。

相反,示例使用 Eventuate Tram 框架,它具有简单、易于理解的 API,隐藏了实现细节。您可以在您的应用程序中使用这些框架。或者,您可以研究 Eventuate Tram 框架,并自行重新实现这些概念。

Eventuate Tram 还实现了两个重要机制:

  • 事务消息 它将消息作为数据库事务的一部分发布。

  • 重复消息检测 Eventuate Tram 消息消费者检测并丢弃重复消息,这对于确保消费者恰好处理一次消息至关重要,如第 3.3.6 节中所述。

让我们来看看 Eventuate Tram API。

基本消息

基本消息 API 由两个 Java 接口组成:MessageProducerMessageConsumer。生产者服务使用 MessageProducer 接口将消息发布到消息通道。以下是如何使用此接口的示例:

MessageProducer messageProducer = ...;
String channel = ...;
String payload = ...;
messageProducer.send(destination, MessageBuilder.withPayload(payload).build())

消费者服务使用 MessageConsumer 接口订阅消息:

MessageConsumer messageConsumer;
messageConsumer.subscribe(subscriberId, Collections.singleton(destination),
     message -> { ... })

MessageProducerMessageConsumer 是异步请求/响应和领域事件发布的高级 API 的基础。

让我们谈谈如何发布和订阅事件。

领域事件发布

Eventuate Tram 提供了发布和消费领域事件的 API。第五章 解释了领域事件是当 聚合(业务对象)创建、更新或删除时发出的事件。服务使用 DomainEventPublisher 接口发布领域事件。以下是一个示例:

DomainEventPublisher domainEventPublisher;

String accountId = ...;

DomainEvent domainEvent = new AccountDebited(...);

domainEventPublisher.publish("Account", accountId, Collections.singletonList(
     domainEvent));

一个服务使用 DomainEventDispatcher 来消费领域事件。以下是一个示例:

DomainEventHandlers domainEventHandlers = DomainEventHandlersBuilder
            .forAggregateType("Order")
            .onEvent(AccountDebited.class, domainEvent -> { ... })
            .build();

new DomainEventDispatcher("eventDispatcherId",
            domainEventHandlers,
            messageConsumer);

事件不是 Eventuate Tram 支持的唯一高级消息模式。它还支持基于命令/回复的消息。

基于命令/回复的消息

客户端可以使用 CommandProducer 接口向服务发送命令消息。例如

CommandProducer commandProducer = ...;

Map<String, String> extraMessageHeaders = Collections.emptyMap();

String commandId = commandProducer.send("CustomerCommandChannel",
        new DoSomethingCommand(),
        "ReplyToChannel",
        extraMessageHeaders);

一个服务使用 CommandDispatcher 类来消费命令消息。CommandDispatcher 使用 MessageConsumer 接口订阅指定的事件。它将每个命令消息调度到适当的手动方法。以下是一个示例:

CommandHandlers commandHandlers =CommandHandlersBuilder
            .fromChannel(commandChannel)
            .onMessage(DoSomethingCommand.class, (command) -
     > { ... ; return withSuccess(); })
            .build();

CommandDispatcher dispatcher = new CommandDispatcher("subscribeId",
     commandHandlers, messageConsumer, messageProducer);

在本书的整个过程中,您将看到使用这些 API 发送和接收消息的代码示例。

正如你所看到的,Eventuate Tram 框架实现了 Java 应用程序的事务性消息传递。它提供了一个低级 API 用于事务性地发送和接收消息。它还提供了用于发布和消费领域事件以及发送和处理命令的高级 API。

现在我们来看一种使用异步消息传递来提高可用性的服务设计方法。

3.4. 使用异步消息传递来提高可用性

正如你所看到的,各种 IPC 机制有不同的权衡。一个特定的权衡是你选择的 IPC 机制如何影响可用性。在本节中,你将了解到作为请求处理一部分与其他服务进行同步通信会降低应用程序的可用性。因此,你应该尽可能设计你的服务使用异步消息传递。

让我们先看看同步通信的问题以及它如何影响可用性。

3.4.1. 同步通信降低可用性

REST 是一种极其流行的 IPC 机制。你可能想用它来进行服务间通信。然而,REST 的问题在于它是一个同步协议:HTTP 客户端必须等待服务发送响应。每当服务使用同步协议进行通信时,应用程序的可用性就会降低。

要了解原因,请考虑图 3.15 中所示的场景。Order Service 提供了一个用于创建 Order 的 REST API。它调用 Consumer ServiceRestaurant Service 来验证 Order。这两个服务也都有 REST API。

图 3.15. Order Service 使用 REST 调用其他服务。这很简单,但要求所有服务同时可用,这降低了 API 的可用性。

创建订单的步骤顺序如下:

  1. 客户端向 Order Service 发送 HTTP POST /orders 请求。

  2. Order Service 通过向 Consumer Service 发送 HTTP GET /consumers/id 请求来检索消费者信息。

  3. Order Service 通过向 Restaurant Service 发送 HTTP GET /restaurant/id 请求来检索餐厅信息。

  4. Order Taking 使用消费者和餐厅信息验证请求。

  5. Order Taking 创建一个订单。

  6. Order Taking 向客户端发送 HTTP 响应。

因为这些服务使用 HTTP,所以为了 FTGO 应用程序能够处理CreateOrder请求,它们必须同时可用。如果这三个服务中的任何一个出现故障,FTGO 应用程序就无法创建订单。从数学上讲,系统操作的可用性是调用该操作的服务可用性的乘积。如果Order Service及其调用的两个服务都是 99.5%可用,则整体可用性为 99.5%³ = 98.5%,这明显较低。每个参与处理请求的额外服务都会进一步降低可用性。

这个问题并不仅限于基于 REST 的通信。每当服务只能在收到另一个服务的响应后才能对其客户端做出响应时,可用性就会降低。即使服务通过异步消息的请求/响应风格进行通信,这个问题也存在。例如,如果Order Service通过消息代理向Consumer Service发送消息,然后等待响应,那么Order Service的可用性就会降低。

如果你想最大化可用性,你必须最小化同步通信的数量。让我们看看如何做到这一点。

3.4.2. 消除同步交互

在处理同步请求的同时,有几种不同的方法可以减少与其他服务的同步通信量。一种解决方案是通过定义仅具有异步 API 的服务来完全避免这个问题。但这并不总是可能的。例如,公共 API 通常是 RESTful 的。因此,有时服务需要具有同步 API。

幸运的是,有一些方法可以处理同步请求而不进行同步请求。让我们谈谈这些选项。

使用异步交互风格

理想情况下,所有交互都应使用本章前面描述的异步交互风格进行。例如,假设 FTGO 应用程序的一个客户端使用异步请求/异步响应风格的交互来创建订单。客户端通过向Order Service发送请求消息来创建订单。然后,该服务与其他服务异步交换消息,并最终向客户端发送回复消息。图 3.16 展示了设计。

图 3.16. 如果 FTGO 应用程序的服务使用异步消息而不是同步调用进行通信,则其可用性更高。

图片

客户端和服务通过通过消息通道发送消息进行异步通信。在这个交互中的任何参与者都不会因为等待响应而被阻塞。

这样的架构将具有极高的弹性,因为消息代理会缓冲消息,直到它们可以被消费。然而,问题是服务通常有一个使用同步协议(如 REST)的外部 API,因此它必须立即响应请求。

如果一个服务有一个同步 API,提高可用性的方法之一是复制数据。让我们看看这是如何工作的。

复制数据

在请求处理期间最小化同步请求的一种方法是通过复制数据。服务在处理请求时维护所需数据的副本。它通过订阅拥有数据的服务发布的事件来保持副本的更新。例如,订单服务可以维护由消费者服务餐厅服务拥有的数据的副本。这将使订单服务能够在不与那些服务交互的情况下处理创建订单的请求。图 3.17 展示了该设计。

图 3.17. 订单服务是自包含的,因为它拥有消费者和餐厅数据的副本。

消费者服务餐厅服务在其数据更改时发布事件。订单服务订阅这些事件并更新其副本。

在某些情况下,复制数据是一种有用的方法。例如,第五章描述了订单服务如何从餐厅服务复制数据,以便验证和定价菜单项。复制的缺点之一是有时可能需要复制大量数据,这效率低下。例如,由于消费者数量庞大,订单服务维护消费者服务拥有的数据的副本可能不切实际。复制的另一个缺点是它没有解决服务如何更新其他服务拥有的数据的问题。

解决该问题的一种方法是为服务延迟与其他服务交互,直到它向其客户端响应之后。我们接下来将看看这是如何工作的。

在返回响应后完成处理

在请求处理期间消除同步通信的另一种方法是服务按照以下方式处理请求:

  1. 仅使用本地可用的数据验证请求。

  2. 更新其数据库,包括将消息插入到OUTBOX表中。

  3. 向其客户端返回响应。

在处理请求时,服务不会与任何其他服务同步交互。相反,它异步地向其他服务发送消息。这种方法确保服务是松散耦合的。正如你将在下一章学到的那样,这通常是通过使用一个saga来实现的。

例如,如果订单服务使用这种方法,它会在待处理状态下创建一个订单,然后通过与其他服务交换消息异步验证订单。图 3.18 展示了当调用createOrder()操作时会发生什么。事件序列如下:

  1. 订单服务创建一个处于待处理状态的订单。

  2. 订单服务向其客户端返回包含订单 ID 的响应。

  3. 订单服务消费者服务发送ValidateConsumerInfo消息。

    图 3.18. Order Service 在不调用任何其他服务的情况下创建订单。然后它通过与其他服务(包括 Consumer ServiceRestaurant Service)交换消息来异步验证新创建的 Order

  4. Order ServiceRestaurant Service 发送一个 ValidateOrderDetails 消息。

  5. Consumer Service 接收到一个 ValidateConsumerInfo 消息,验证消费者能否下订单,并向 Order Service 发送一个 ConsumerValidated 消息。

  6. Restaurant Service 接收到一个 ValidateOrderDetails 消息,验证菜单项是否有效以及餐厅是否能够将订单送达指定地址,然后向 Order Service 发送一个 OrderDetailsValidated 消息。

  7. Order Service 接收到 ConsumerValidatedOrderDetailsValidated 并将订单状态改为 VALIDATED

  8. ...

Order Service 可以以任意顺序接收 ConsumerValidatedOrderDetailsValidated 消息。它通过改变订单的状态来跟踪它首先接收哪个消息。如果它首先接收到 ConsumerValidated 消息,它将订单状态改为 CONSUMER_VALIDATED;而如果它首先接收到 OrderDetailsValidated 消息,它将状态改为 ORDER_DETAILS_VALIDATED。当它接收到另一个消息时,Order ServiceOrder 的状态改为 VALIDATED

在订单验证完成后,Order Service 完成订单创建过程的其余部分,这些内容将在下一章中讨论。这种方法的好处是,即使 Consumer Service 停止运行,例如,Order Service 仍然可以创建订单并响应其客户端。最终,Consumer Service 将恢复运行并处理任何排队中的消息,订单将被验证。

服务在完全处理请求之前就做出响应的缺点是它使得客户端变得更加复杂。例如,当 Order Service 返回响应时,它对新建订单的状态只提供最基本保证。它在验证订单和授权消费者的信用卡之前立即创建订单并返回。因此,为了使客户端知道订单是否成功创建,它必须定期轮询或者 Order Service 必须发送通知消息。尽管听起来很复杂,但在许多情况下,这种方法是首选的——特别是因为它还解决了我在下一章中讨论的分布式事务管理问题。例如,在第 4 和 5 章中,我描述了 Order Service 如何使用这种方法。

摘要

  • 微服务架构是一种分布式架构,因此进程间通信扮演着关键角色。

  • 精心管理服务 API 的演变至关重要。向后兼容的更改最容易实现,因为它们不会影响客户端。如果你对服务的 API 进行了破坏性更改,通常需要同时支持旧版和新版,直到客户端升级。

  • 有许多 IPC 技术,每种技术都有不同的权衡。一个关键的设计决策是选择同步远程过程调用模式或异步消息模式。基于同步远程过程调用的协议,如 REST,最容易使用。但理想情况下,服务应使用异步消息进行通信,以提高可用性。

  • 为了防止故障在系统中级联,使用同步协议的服务客户端必须设计成能够处理部分故障,即调用服务时服务可能处于宕机状态或表现出高延迟。特别是,在发起请求时必须使用超时,限制未完成请求的数量,并使用断路器模式来避免调用失败的服务。

  • 使用同步协议的架构必须包括服务发现机制,以便客户端确定服务实例的网络位置。最简单的方法是使用部署平台实现的服务发现机制:服务器端发现和第三方注册模式。但另一种方法是实现应用级别的服务发现:客户端发现和自注册模式。这需要更多的工作,但它确实处理了服务运行在多个部署平台上的场景。

  • 设计基于消息的架构的一个好方法就是使用消息和通道模型,该模型抽象了底层消息系统的细节。然后你可以将这个设计映射到特定的消息基础设施上,这通常是基于消息代理的。

  • 使用消息时,一个关键挑战是原子地更新数据库并发布消息。一个好的解决方案是使用事务性输出队列模式,首先将消息作为数据库事务的一部分写入数据库。然后,一个单独的过程使用轮询发布者模式或事务日志尾部模式从数据库检索消息,并将其发布到消息代理。

第四章. 使用传奇管理事务

本章涵盖

  • 为什么分布式事务不适合现代应用程序

  • 使用传奇模式在微服务架构中保持数据一致性

  • 使用编排和协调来协调传奇

  • 使用对策来处理隔离不足的问题

当玛丽开始研究微服务架构时,她最大的担忧之一是如何实现跨越多个服务的事务。事务是每个企业应用程序的基本组成部分。没有事务,就难以保持数据一致性。

ACID(原子性、一致性、隔离、持久性)事务通过提供每个事务对数据具有独占访问的错觉,极大地简化了开发者的工作。在微服务架构中,单个服务内的操作仍然可以使用 ACID 事务。然而,挑战在于实现更新多个服务拥有的数据的操作的事务。例如,正如第二章中描述的,createOrder()操作跨越了多个服务,包括Order ServiceKitchen ServiceAccounting Service。这类操作需要一个跨服务工作的交易管理机制。

玛丽发现,正如第二章中提到的,传统的分布式事务管理方法并不适合现代应用程序。与传统 ACID 事务不同,跨越服务的操作必须使用所谓的传奇,即本地事务的消息驱动序列,以保持数据一致性。传奇的一个挑战是它们是 ACD(原子性、一致性、持久性)。它们缺乏传统 ACID 事务的隔离特性。因此,应用程序必须使用所谓的对策,即设计技术,以防止或减少由隔离不足引起的并发异常的影响。

在许多方面,玛丽和 FTGO 开发者采用微服务时将面临的最大障碍是从具有 ACID 事务的单数据库架构迁移到具有 ACD(原子性、一致性、持久性)传奇的多数据库架构。他们习惯了 ACID 事务模型的简单性。但在现实中,即使是像 FTGO 这样的单体应用程序通常也不使用教科书中的 ACID 事务。例如,许多应用程序使用较低的事务隔离级别以提高性能。此外,许多重要的业务流程,如在不同银行账户之间转账,最终都是一致的。甚至星巴克也不使用两阶段提交(www.enterpriseintegrationpatterns.com/ramblings/18_starbucks.html)。

我以探讨微服务架构中事务管理的挑战开始本章,并解释为什么传统的分布式事务管理方法不是一个可行的选择。接下来,我解释如何使用 sagas 来维护数据一致性。之后,我查看协调 sagas 的两种不同方式:编排,其中参与者交换事件而没有集中的控制点,以及编排,其中集中的控制器告诉 sagas 参与者执行什么操作。我讨论了如何使用对策来防止或减少由于 sagas 之间缺乏隔离性而引起的并发异常的影响。最后,我描述了一个示例 sagas 的实现。

让我们从探讨在微服务架构中管理事务的挑战开始。

4.1. 微服务架构中的事务管理

几乎每个由企业应用程序处理的需求都在数据库事务中执行。企业应用程序开发者使用框架和库来简化事务管理。一些框架和库提供了用于显式开始、提交和回滚事务的程序化 API。其他框架,例如 Spring 框架,提供了声明式机制。Spring 提供了一个@Transactional注解,该注解安排方法调用在事务中自动执行。因此,编写事务性业务逻辑变得非常简单。

或者,更准确地说,在访问单个数据库的单体应用程序中,事务管理是直接的。在复杂单体应用程序中使用多个数据库和消息代理时,事务管理更具挑战性。在微服务架构中,事务跨越多个服务,每个服务都有自己的数据库。在这种情况下,应用程序必须使用更复杂的机制来管理事务。正如你将学到的,使用分布式事务的传统方法对于现代应用程序来说不是一个可行的选择。相反,基于微服务的应用程序必须使用 sagas。

在解释 sagas 之前,让我们首先看看为什么在微服务架构中事务管理具有挑战性。

4.1.1. 微服务架构中分布式事务的需求

假设你是负责实现 createOrder() 系统操作的 FTGO 开发者。如第二章所述 [kindle_split_010.xhtml#ch02],此操作必须验证消费者能否下单,验证订单详情,授权消费者的信用卡,并在数据库中创建一个 Order。在单体 FTGO 应用程序中实现此操作相对简单。验证订单所需的所有数据都易于访问。更重要的是,你可以使用 ACID 事务来确保数据一致性。你可能会在 createOrder() 服务方法上使用 Spring 的 @Transactional 注解。

与此相反,在微服务架构中实现相同的操作要复杂得多。如图 4.1 所示,所需数据分散在多个服务中。createOrder() 操作访问多个服务中的数据。它从 Consumer Service 读取数据,并在 Order ServiceKitchen ServiceAccounting Service 中更新数据。

图 4.1. createOrder() 操作更新多个服务中的数据。它必须使用一种机制来维护这些服务之间的数据一致性。

图 4.1

由于每个服务都有自己的数据库,你需要使用一种机制来维护这些数据库之间的数据一致性。

4.1.2. 分布式事务的问题

在多个服务、数据库或消息代理之间维护数据一致性的传统方法是用分布式事务。分布式事务管理的既定标准是 X/Open 分布式事务处理 (DTP) 模型 (X/Open XA—见 en.wikipedia.org/wiki/X/Open_XA)。XA 使用 两阶段提交 (2PC) 来确保事务中的所有参与者要么提交要么回滚。符合 XA 的技术堆栈包括符合 XA 的数据库和消息代理、数据库驱动程序和消息 API,以及传播 XA 全局事务 ID 的进程间通信机制。大多数 SQL 数据库都符合 XA 标准,一些消息代理也是如此。例如,Java EE 应用程序可以使用 JTA 来执行分布式事务。

虽然听起来很简单,但分布式事务存在各种问题。一个问题就是许多现代技术,包括 MongoDB 和 Cassandra 这样的 NoSQL 数据库,都不支持它们。此外,现代消息代理,如 RabbitMQ 和 Apache Kafka,也不支持分布式事务。因此,如果你坚持使用分布式事务,你将无法使用许多现代技术。

分布式事务的另一个问题是,它们是一种同步 IPC 形式,这降低了可用性。为了使分布式事务提交,所有参与的服务都必须可用。如第三章所述,可用性是事务中所有参与者可用性的乘积。如果一个分布式事务涉及两个 99.5% 可用的服务,那么整体可用性将是 99%,这显著降低了。每个参与分布式事务的额外服务都会进一步降低可用性。甚至还有埃里克·布赖尔的 CAP 定理,该定理指出,一个系统只能拥有以下三个属性中的两个:一致性、可用性和分区容错性(en.wikipedia.org/wiki/CAP_theorem)。今天,架构师更倾向于拥有一个可用的系统,而不是一个一致的系统。

表面上看,分布式事务很有吸引力。从开发者的角度来看,它们与本地事务具有相同的编程模型。但由于前面提到的问题,分布式事务并不是现代应用的可行技术。第三章描述了在不使用分布式事务的情况下,作为数据库事务一部分发送消息的方法。为了解决在微服务架构中维护数据一致性的更复杂问题,应用程序必须使用不同的机制,该机制建立在松散耦合、异步服务概念的基础上。这就是传说发挥作用的地方。

4.1.3. 使用传说模式来维护数据一致性

传说 是在无需使用分布式事务的情况下,在微服务架构中维护数据一致性的机制。您为每个需要更新多个服务中数据的系统命令定义一个传说。传说是一系列本地事务。每个本地事务都使用前面提到的熟悉的 ACID 事务框架和库在单个服务内更新数据。

模式:传说

使用一系列由异步消息协调的本地事务来在服务之间维护数据一致性。请参阅microservices.io/patterns/data/saga.html

系统操作启动传说的第一步。本地事务的完成触发下一个本地事务的执行。稍后,在第 4.2 节中,您将看到如何使用异步消息实现步骤的协调。异步消息的一个重要好处是,它确保即使传说的一个或多个参与者暂时不可用,传说的所有步骤都会被执行。

Sagas 在几个重要方面与 ACID 事务不同。正如我在 第 4.3 节 中详细描述的那样,它们缺乏 ACID 事务的隔离属性。此外,由于每个本地事务都会提交其更改,因此必须使用补偿性事务来回滚 Saga。我将在本节后面更多地讨论补偿性事务。让我们看看一个示例 Saga。

一个示例 Saga:创建订单 Saga

本章中使用的示例 Saga 是 Create Order Saga,如图 4.2 所示。订单服务 使用这个 Saga 实现了 createOrder() 操作。这个 Saga 的第一个本地事务是由创建订单的外部请求启动的。其他五个本地事务分别由前一个事务的完成触发。

图 4.2. 使用 Saga 创建 订单createOrder() 操作由一个由几个服务中的本地事务组成的 Saga 实现。

图像 4.2

这个 Saga 包含以下本地事务:

  1. 订单服务APPROVAL_PENDING 状态下创建一个 订单

  2. 消费者服务 验证消费者能否下订单。

  3. 厨房服务 验证订单详情并在 CREATE_PENDING 状态下创建一个 票据

  4. 会计服务 授权消费者信用卡。

  5. 厨房服务票据 的状态更改为 AWAITING_ACCEPTANCE

  6. 订单服务订单 的状态更改为 APPROVED

在 第 4.2 节 中,我描述了参与 Sagas 的服务如何使用异步消息进行通信。当本地事务完成时,服务会发布一条消息。然后,这条消息触发 Saga 的下一步。使用消息不仅确保 Saga 参与者松散耦合,而且还保证了 Saga 的完成。这是因为如果消息的接收者暂时不可用,消息代理会缓冲该消息,直到可以交付。

表面上看,Sagas 看起来很简单,但使用它们时存在一些挑战。一个挑战是 Sagas 之间缺乏隔离性。第 4.3 节 描述了如何处理这个问题。另一个挑战是在发生错误时回滚更改。让我们看看如何做到这一点。

Sagas 使用补偿性交易来回滚更改

传统 ACID 事务的一个伟大特性是,如果业务逻辑检测到业务规则违规,它可以轻松地回滚事务。它执行一个 ROLLBACK 语句,数据库撤销迄今为止所做的所有更改。不幸的是,saga 不能自动回滚,因为每个步骤都将更改提交到本地数据库。这意味着,例如,如果 Create Order Saga 的第四步中信用卡授权失败,FTGO 应用程序必须显式地撤销前三个步骤所做的更改。你必须编写所谓的 补偿事务

假设 saga 的第 (n + 1) 次事务失败。前 n 次事务的效果必须被撤销。从概念上讲,这些步骤中的每一个,T[i],都有一个相应的补偿事务,C[i],它撤销 T[i] 的效果。为了撤销前 n 个步骤的效果,saga 必须以相反的顺序执行每个 C[i]。步骤的顺序是 T[1] ... T[n],C[n] ... C[1],如图 4.3 所示。在这个例子中,T[n+1] 失败,这要求撤销步骤 T[1] ... T[n]。

图 4.3. 当一个 saga 的步骤因业务规则违规而失败时,saga 必须通过执行补偿事务显式地撤销之前步骤所做的更新。

saga 以正向事务的相反顺序执行补偿事务:C[n] ... C[1]。C[i] 的排序机制与 T[i] 的排序机制没有任何不同。C[i] 的完成必须触发 C[i-1] 的执行。

例如,考虑 Create Order Saga。这个 saga 可能因多种原因而失败:

  • 消费者信息无效或消费者不允许创建订单。

  • 餐厅信息无效或餐厅无法接受订单。

  • 消费者的信用卡授权失败。

如果本地事务失败,saga 的协调机制必须执行补偿事务以拒绝 Order 和可能 Ticket。表 4.1 显示了 Create Order Saga 每个步骤的补偿事务。重要的是要注意,并非所有步骤都需要补偿事务。只读步骤,如 verifyConsumerDetails(),不需要补偿事务。也不需要像 authorizeCreditCard() 这样的步骤,这些步骤后面总是跟随成功的步骤。

表 4.1. Create Order Saga 的补偿事务
步骤 服务 事务 补偿事务
1 订单服务 createOrder() rejectOrder()
2 消费者服务 verifyConsumerDetails()
3 厨房服务 createTicket() rejectTicket()
4 会计服务 authorizeCreditCard()
5 厨房服务 approveTicket()
6 订单服务 approveOrder()

第 4.3 节 讨论了为什么创建订单史诗的前三个步骤被称为可补偿事务,因为它们后面跟着可能会失败的步骤,第四步被称为史诗的枢纽事务,因为它后面跟着永远不会失败的步骤,以及最后两个步骤被称为可重试事务,因为它们总是成功的。

为了了解补偿事务是如何使用的,想象一个场景,其中消费者的信用卡授权失败。在这个场景中,史诗执行以下本地事务:

  1. 订单服务— 在APPROVAL_PENDING状态下创建一个Order

  2. 消费者服务— 验证消费者能否下订单。

  3. 厨房服务— 验证订单详情并在CREATE_PENDING状态下创建一个Ticket

  4. 会计服务— 授权消费者的信用卡,但失败了。

  5. 厨房服务— 将Ticket的状态更改为CREATE_REJECTED

  6. 订单服务— 将Order的状态更改为REJECTED

第五步和第六步是补偿事务,分别撤销了厨房服务订单服务所做的更新。史诗的协调逻辑负责序列化执行正向和补偿事务。让我们看看这是如何工作的。

4.2. 协调史诗

一个史诗的实现包括协调史诗步骤的逻辑。当一个史诗被系统命令启动时,协调逻辑必须选择并告知第一个史诗参与者执行一个本地事务。一旦该事务完成,史诗的序列协调逻辑会选择并调用下一个史诗参与者。这个过程会一直持续到史诗执行了所有步骤。如果任何本地事务失败,史诗必须以相反的顺序执行补偿事务。有几种不同的方式来构建史诗的协调逻辑:

  • 编排 在史诗参与者之间分配决策和序列。他们主要通过交换事件进行通信。

  • 编排 在史诗编排器类中集中史诗的协调逻辑。一个史诗编排器向史诗参与者发送命令消息,告诉他们要执行哪些操作。

让我们看看每个选项,从编排开始。

4.2.1. 基于编排的史诗

实现史诗的一种方式是使用编排。当使用编排时,没有中央协调器告诉史诗参与者做什么。相反,史诗参与者订阅彼此的事件并相应地做出反应。为了展示基于编排的史诗是如何工作的,我将首先描述一个例子。然后,我将讨论你必须解决的一些设计问题。然后,我将讨论使用编排的好处和缺点。

使用编排实现创建订单史诗

图 4.4 显示了基于编排的创建订单叙事的设计。参与者通过交换事件进行通信。每个参与者,从订单服务开始,更新其数据库并发布一个触发下一个参与者的事件。

图 4.4。使用编排实现创建订单叙事。叙事参与者通过交换事件进行通信。

通过这个叙事的愉快路径如下:

  1. 订单服务APPROVAL_PENDING状态下创建一个订单,并发布一个OrderCreated事件。

  2. 消费者服务消费OrderCreated事件,验证消费者能否下订单,并发布一个ConsumerVerified事件。

  3. 厨房服务消费OrderCreated事件,验证订单,在CREATE_PENDING状态下创建一个票据,并发布TicketCreated事件。

  4. 会计服务消费OrderCreated事件,并在PENDING状态下创建一个CreditCardAuthorization

  5. 会计服务消费TicketCreatedConsumerVerified事件,对消费者信用卡进行收费,并发布CreditCardAuthorized事件。

  6. 厨房服务消费CreditCardAuthorized事件,并将票据的状态更改为AWAITING_ACCEPTANCE

  7. 订单服务接收CreditCardAuthorized事件,将订单的状态更改为APPROVED,并发布一个OrderApproved事件。

创建订单叙事还必须处理叙事参与者拒绝订单并发布某种类型的失败事件的场景。例如,消费者信用卡的授权可能会失败。叙事必须执行补偿性交易以撤销已经完成的事情。图 4.5 显示了当AccountingService无法授权消费者信用卡时事件流。

图 4.5。当消费者信用卡授权失败时创建订单叙事的事件序列。会计服务发布Credit Card Authorization Failed事件,导致厨房服务拒绝票据,以及订单服务拒绝订单

事件序列如下:

  1. 订单服务APPROVAL_PENDING状态下创建一个订单,并发布一个OrderCreated事件。

  2. 消费者服务消费OrderCreated事件,验证消费者能否下订单,并发布一个ConsumerVerified事件。

  3. 厨房服务消费OrderCreated事件,验证订单,在CREATE_PENDING状态下创建一个票据,并发布TicketCreated事件。

  4. 会计服务消费OrderCreated事件,并在PENDING状态下创建一个CreditCardAuthorization

  5. 会计服务消费TicketCreatedConsumerVerified事件,对消费者信用卡进行收费,并发布一个Credit Card Authorization Failed事件。

  6. 厨房服务消费了信用卡授权失败事件,并将的状态更改为已拒绝

  7. 订单服务消费了信用卡授权失败事件,并将订单的状态更改为已拒绝

正如你所见,基于编排的叙事的参与者通过发布/订阅进行交互。让我们更深入地了解一下在为你的叙事实现基于发布/订阅的通信时需要考虑的一些问题。

可靠的事件驱动通信

在实现基于编排的叙事时,你必须考虑一些与服务间通信相关的问题。第一个问题是确保叙事参与者更新其数据库并发布事件作为数据库事务的一部分。基于编排的叙事的每一步都会更新数据库并发布事件。例如,在创建订单叙事中,厨房服务接收到消费者验证事件,创建一个,并发布一个票已创建事件。数据库更新和事件发布的原子性是至关重要的。因此,为了可靠地通信,叙事参与者必须使用事务消息,这在第三章中有所描述。第三章。

第二个你需要考虑的问题是确保叙事参与者必须能够将接收到的每个事件映射到自己的数据。例如,当订单服务接收到信用卡已授权事件时,它必须能够查找相应的订单。解决方案是让叙事参与者发布包含关联 ID的事件,这是一种数据,使其他参与者能够执行映射。

例如,创建订单叙事的参与者可以使用orderId作为从一位参与者传递到下一位参与者的关联 ID。会计服务发布一个包含orderId信用卡已授权事件,该orderId来自票已创建事件。当订单服务接收到信用卡已授权事件时,它使用orderId来检索相应的订单。同样,厨房服务使用该事件中的orderId来检索相应的

基于编排的叙事的优缺点

基于编排的叙事有以下几个好处:

  • 简单性 服务在创建、更新或删除业务对象时发布事件。

  • 松散耦合 参与者订阅事件,并不直接了解彼此。

并且还有一些缺点:

  • 更难理解 与编排不同,代码中没有单一的地方定义叙事。相反,编排将叙事的实现分布在各个服务中。因此,有时对于开发者来说,理解一个特定的叙事是如何工作的可能很困难。

  • 服务之间的循环依赖 叙事参与者订阅彼此的事件,这通常会产生循环依赖。例如,如果你仔细检查图 4.4,你会看到循环依赖,例如Order ServiceAccounting ServiceOrder Service。尽管这不一定是一个问题,但循环依赖被认为是设计上的一个坏味道。

  • 紧密耦合的风险 每个叙事参与者都需要订阅影响它们的所有事件。例如,Accounting Service必须订阅所有导致消费者信用卡被收费或退款的操作。因此,它可能需要与Order Service实现的订单生命周期同步更新。

舞台编排(Choreography)对于简单的叙事可能效果很好,但由于这些缺点,对于更复杂的叙事,通常更好的做法是使用编排。让我们看看编排是如何工作的。

4.2.2. 基于编排的叙事

编排(Orchestration)是实现叙事(sagas)的另一种方式。当使用编排时,你定义一个编排器(orchestrator)类,其唯一责任是告诉叙事参与者他们应该做什么。叙事编排器通过命令/异步回复风格的交互与参与者进行通信。为了执行叙事步骤,它向参与者发送一个命令消息,告诉他们要执行的操作。在叙事参与者执行了操作之后,它向编排器发送一个回复消息。然后编排器处理该消息并确定下一个要执行的叙事步骤。

为了展示基于编排的叙事是如何工作的,我首先将描述一个示例。然后,我将描述如何将基于编排的叙事建模为状态机。我将讨论如何利用事务消息确保叙事编排器和叙事参与者之间可靠的通信。然后,我将描述使用基于编排的叙事的优缺点。

使用编排实现创建订单叙事(Create Order saga)

图 4.6 展示了基于编排的Create Order Saga的设计。叙事由CreateOrderSaga类编排,该类使用异步请求/响应调用叙事参与者。这个类跟踪流程并向叙事参与者发送命令消息,例如Kitchen ServiceConsumer ServiceCreateOrderSaga类从其回复通道读取回复消息,然后确定叙事中的下一步,如果有的话。

图 4.6. 使用编排实现Create Order SagaOrder Service实现一个叙事编排器,使用异步请求/响应调用叙事参与者。

Order Service首先创建一个Order和一个Create Order Saga编排器。之后,对于愉快的路径流程如下:

  1. 叙事编排器向Consumer Service发送一个Verify Consumer命令。

  2. 消费者服务回复一个消费者已验证的消息。

  3. 叙事协调器向厨房服务发送一个创建票据的命令。

  4. 厨房服务回复一个创建票据的消息。

  5. 叙事协调器向会计服务发送一个授权卡的消息。

  6. 会计服务回复一个卡已授权的消息。

  7. 叙事协调器向厨房服务发送一个批准票据的命令。

  8. 叙事协调器向订单服务发送一个批准订单的命令。

注意,在最后一步,叙事协调器向订单服务发送一个命令消息,即使它也是订单服务的一个组件。原则上,创建订单叙事可以通过直接更新来批准订单。但为了保持一致性,叙事将订单服务视为另一个参与者。

如图 4.6 所示的图表,每个都描述了一个叙事的场景,但一个叙事可能有许多场景。例如,创建订单叙事有四个场景。除了快乐路径外,叙事还可能因为消费者服务厨房服务会计服务中的任何一个失败而失败。因此,将叙事建模为状态机是有用的,因为它描述了所有可能的情况。

将叙事协调器建模为状态机

将一个叙事协调器建模为一个状态机是一个好方法。一个状态机由一组状态和一组由事件触发的状态转换组成。每个转换可以有一个动作,对于一个叙事来说,这个动作就是调用一个叙事参与者。状态之间的转换是由一个叙事参与者执行的一个本地事务的完成触发的。当前状态和本地事务的具体结果决定了状态转换以及是否执行某个动作。对于状态机也有有效的测试策略。因此,使用状态机模型使得设计、实现和测试叙事变得更加容易。

图 4.7 显示了创建订单叙事的状态机模型。这个状态机由许多状态组成,包括以下内容:

  • 验证消费者 初始状态。当处于此状态时,叙事正在等待消费者服务验证消费者能否下订单。

  • 创建票据 叙事正在等待对创建票据命令的回复。

  • 授权卡 等待会计服务授权消费者的信用卡。

  • 订单批准 表示叙事成功完成的最终状态。

  • 订单拒绝 表示订单被参与者之一拒绝的最终状态。

图 4.7. 创建订单叙事的状态机模型

状态机还定义了众多状态转换。例如,状态机从“创建票据”状态转换到“授权卡片”或“拒绝订单”状态。当它收到对“创建票据”命令的成功回复时,它转换到“授权卡片”状态。或者,如果“厨房服务”无法创建“票据”,状态机转换到“拒绝订单”状态。

状态机的初始动作是向“消费者服务”发送“验证消费者”命令。来自“消费者服务”的回复触发下一个状态转换。如果消费者验证成功,叙事创建“票据”并转换到“创建票据”状态。但如果消费者验证失败,叙事拒绝“订单”并转换到“拒绝订单”状态。状态机经历众多其他状态转换,由叙事参与者的回复驱动,直到达到“订单批准”或“订单拒绝”的最终状态。

叙事编排和事务消息

基于编排的叙事的每一步都包括一个服务更新数据库并发布消息。例如,“订单服务”持久化“订单”和“创建订单叙事编排器”,并发送消息给第一个叙事参与者。叙事参与者,如“厨房服务”,通过更新其数据库并发送回复消息来处理命令消息。“订单服务”通过更新叙事编排器的状态并发送命令消息到下一个叙事参与者来处理参与者的回复消息。如第三章所述,一个服务必须使用事务消息才能原子性地更新数据库并发布消息。稍后,在第 4.4 节中,我将更详细地描述“创建订单叙事编排器”的实现,包括它如何使用事务消息。

让我们来看看使用叙事编排的优缺点。

基于编排的叙事的优缺点

基于编排的叙事有几个好处:

  • 更简单的依赖关系编排的一个好处是它不会引入循环依赖。叙事编排器调用叙事参与者,但参与者不会调用编排器。因此,编排器依赖于参与者,但反之则不然,因此没有循环依赖。

  • 更少的耦合每个服务实现了一个由编排器调用的 API,因此它不需要了解叙事参与者发布的事件。

  • 提高关注点的分离并简化业务逻辑 悲剧协调逻辑位于悲剧编排器中。领域对象更简单,并且不知道它们参与的悲剧。例如,当使用编排时,Order 类不知道任何悲剧,因此它有一个更简单的状态机模型。在执行 Create Order Saga 的过程中,它直接从 APPROVAL_PENDING 状态过渡到 APPROVED 状态。Order 类没有与悲剧步骤相对应的任何中间状态。因此,业务变得更加简单。

编排也有一个缺点:将过多的业务逻辑集中在编排器中的风险。这导致了一种设计,其中智能编排器告诉愚蠢的服务要执行的操作。幸运的是,你可以通过设计仅负责排序而不包含任何其他业务逻辑的编排器来避免这个问题。

我建议除了最简单的悲剧之外,都使用编排。实现你的悲剧的协调逻辑只是你需要解决的设计问题之一。另一个,可能是你在使用悲剧时面临的最大挑战,是处理缺乏隔离。让我们看看这个问题以及如何解决它。

4.3. 处理缺乏隔离

ACID 中的I代表隔离。ACID 事务的隔离属性确保并发执行多个事务的结果与它们按某种顺序执行的结果相同。数据库提供了每个 ACID 事务对数据具有独占访问的错觉。隔离使得编写并发执行的业务逻辑变得容易得多。

使用悲剧的挑战在于它们缺乏 ACID 事务的隔离属性。这是因为悲剧的每个本地事务所做的更新一旦提交,就会立即对其他悲剧可见。这种行为可能导致两个问题。首先,其他悲剧可以在悲剧执行时更改悲剧访问的数据。并且其他悲剧可以在悲剧完成更新之前读取其数据,因此可能会暴露在不一致的数据中。实际上,你可以将悲剧视为 ACD:

  • 原子性 悲剧式实现确保所有事务都执行或所有更改都撤销。

  • 一致性 服务内的引用完整性由本地数据库处理。服务之间的引用完整性由服务处理。

  • 耐用性 由本地数据库处理。

这种缺乏隔离可能导致数据库文献中称为异常的情况。异常是指事务以它不会在单个事务执行时的方式读取或写入数据。当发生异常时,并发执行悲剧的结果与它们按顺序执行的结果不同。

表面上看,缺乏隔离性似乎不可行。但在实践中,开发者为了获得更高的性能,通常愿意接受降低隔离性。关系型数据库管理系统(RDBMS)允许你为每个事务指定隔离级别(dev.mysql.com/doc/refman/5.7/en/innodb-transaction-isolation-levels.html)。默认的隔离级别通常是一个比完全隔离更弱的隔离级别,也称为可序列化事务。现实世界的数据库事务通常与教科书中 ACID 事务的定义不同。

下一节将讨论一系列处理缺乏隔离性的 saga 设计策略。这些策略被称为对策。一些对策在应用层面实现隔离性。其他对策降低了缺乏隔离性的业务风险。通过使用对策,你可以编写基于 saga 的业务逻辑,使其正确运行。

我将首先描述由缺乏隔离性引起的异常。之后,我将讨论消除这些异常或降低其业务风险的对策。

4.3.1. 异常概述

缺乏隔离性可能导致以下三种异常:

  • 丢失更新 一个 saga 在没有读取另一个 saga 所做的更改的情况下进行覆盖。

  • 脏读 一个事务或 saga 读取了尚未完成更新的 saga 所做的更新。

  • 模糊/不可重复读取 saga 的两个不同步骤读取相同的数据并得到不同的结果,因为另一个 saga 已经进行了更新。

这三种异常都可能发生,但前两种最常见且最具挑战性。让我们先看看这两种类型的异常,从丢失更新开始。

丢失更新

当一个 saga 覆盖了另一个 saga 所做的更新时,就会发生丢失更新异常。例如,考虑以下场景:

  1. Create Order Saga 的第一步是创建一个 Order

  2. 当那个 saga 执行时,Cancel Order Saga 取消了 Order

  3. Create Order Saga 的最后一步是批准 Order

在这个场景中,Create Order Saga 忽略了 Cancel Order Saga 所做的更新并将其覆盖。因此,FTGO 应用程序将发货给客户已取消的订单。在本节稍后,我将展示如何防止丢失更新。

脏读

当一个 saga 读取另一个 saga 正在更新的数据时,就会发生脏读。例如,考虑一个 FTGO 应用程序存储版本,其中消费者有一个信用额度。在这个应用程序中,取消订单的 saga 由以下事务组成:

  • Consumer Service 增加可用信用额度。

  • Order ServiceOrder 的状态更改为已取消。

  • Delivery Service 取消配送。

让我们想象一个场景,其中取消订单创建订单叙事的执行交织在一起,并且由于取消交货太晚,取消订单叙事被回滚。可能调用消费者服务的事务序列如下:

  1. 取消订单叙事 增加可用信用。

  2. 创建订单叙事 减少可用信用。

  3. 取消订单叙事 减少可用信用的补偿性交易。

在这种情况下,创建订单叙事对可用信用进行了脏读,使得消费者可以放置超过其信用额的订单。这很可能是对业务不可接受的风险。

让我们看看如何防止这种情况以及其他类型的异常影响应用程序。

4.3.2. 处理隔离不足的对策

叙事事务模型是 ACD,其隔离不足可能导致导致应用程序行为异常的异常。开发者有责任编写叙事,以防止异常或最小化其对业务的影响。这听起来可能是一项艰巨的任务,但你已经看到了一个防止异常的策略的例子。订单使用*_PENDING状态,例如APPROVAL_PENDING,是这种策略的一个例子。更新订单的叙事,如创建订单叙事,首先将订单的状态设置为*_PENDING*_PENDING状态告诉其他事务,订单正在由叙事更新,并相应地采取行动。

订单使用*_PENDING状态是 Lars Frank 和 Torben U. Zahle 于 1998 年发表的论文“使用远程过程调用和更新传播在多数据库中实现语义 ACID 属性”中称为语义锁对策的例子(dl.acm.org/citation.cfm?id=284472.284478)。该论文描述了如何处理不使用分布式事务的多数据库架构中事务隔离不足的问题。其中许多想法在设计叙事时很有用。它描述了一系列处理由隔离不足引起的异常的对策,这些对策要么防止一个或多个异常,要么最小化其对业务的影响。该论文描述的对策如下:

  • 语义锁 应用级别的锁。

  • 交换更新 设计更新操作,使其可以按任何顺序执行。

  • 悲观视图 重新排序叙事的步骤以最小化业务风险。

  • 重读值 通过重新读取数据以验证在覆盖之前它未更改来防止脏写。

  • 版本文件 记录记录的更新,以便可以重新排序。

  • 按值 使用每个请求的业务风险动态选择并发机制。

在本节的后面部分,我将描述这些对策中的每一个,但首先我想介绍一些术语,用于描述 Saga 的结构,这在讨论对策时很有用。

Saga 的结构

上节中提到的对策论文定义了一个用于 Saga 结构的有用模型。在这个模型中,如图 4.8 所示,一个 Saga 由三种类型的事务组成:

  • 可补偿事务 可以使用补偿事务潜在地回滚的事务。

  • 枢纽事务 Saga 中的启动/停止点。如果枢纽事务提交,则 Saga 将运行至完成。枢纽事务可以既不是可补偿的也不是可重试的事务。或者,它可以是最后一个可补偿事务或第一个可重试事务。

  • 可重试事务 沿着枢纽事务进行的、有保证成功的事务。

图 4.8. Saga 由三种不同类型的事务组成:可补偿事务,可以回滚,因此有补偿事务;枢纽事务,是 Saga 的启动/停止点;可重试事务,是不需要回滚且保证完成的事务。

图片

Create Order Saga 中,createOrder()verifyConsumerDetails()createTicket() 步骤是可补偿事务。createOrder()createTicket() 事务有补偿事务来撤销它们的更新。verifyConsumerDetails() 事务是只读的,因此不需要补偿事务。authorizeCreditCard() 事务是这个 Saga 的枢纽事务。如果消费者的信用卡可以被授权,那么这个 Saga 就有保证完成。approveTicket()approveOrder() 步骤是跟随枢纽事务的可重试事务。

可补偿事务和可重试事务之间的区别特别重要。正如您将看到的,每种类型的事务在对策中扮演着不同的角色。第十三章 指出,在迁移到微服务时,单体有时必须参与 Saga,如果单体只需要执行可重试事务,那么这会显著简化。

现在我们来逐一查看每种对策,首先是语义锁对策。

对策:语义锁

当使用语义锁对策时,叙事的可补偿性事务会在它创建或更新的任何记录中设置一个标志。该标志表示记录尚未提交并且可能发生变化。该标志可以是防止其他事务访问记录的锁,或者是一个警告,表明其他事务应该对该记录持怀疑态度。它可以通过可重试的事务(叙事成功完成)或补偿性事务(叙事正在回滚)来清除。

Order.state字段是语义锁的一个很好的例子。*_PENDING状态,如APPROVAL_PENDINGREVISION_PENDING,实现了语义锁。它们告诉访问Order的其他叙事,叙事正在更新Order。例如,Create Order Saga的第一个步骤,这是一个可补偿性事务,在APPROVAL_PENDING状态下创建一个OrderCreate Order Saga的最终步骤,这是一个可重试的事务,将字段更改为APPROVED。补偿性事务将字段更改为REJECTED

管理锁只是问题的一半。你还需要根据具体情况决定叙事应该如何处理被锁定的记录。例如,考虑cancelOrder()系统命令。客户端可能会调用此操作来取消处于APPROVAL_PENDING状态的Order

处理这种场景有几种不同的方法。一个选项是让cancelOrder()系统命令失败,并告诉客户端稍后重试。这种方法的主要好处是易于实现。然而,缺点是它使客户端变得更加复杂,因为它必须实现重试逻辑。

另一个选项是让cancelOrder()方法阻塞,直到锁被释放。使用语义锁的好处是它们本质上重新创建了 ACID 事务提供的隔离性。更新相同记录的叙事是序列化的,这显著减少了编程工作量。另一个好处是它们从客户端移除了重试的负担。缺点是应用程序必须管理锁。它还必须实现一个死锁检测算法,该算法通过回滚叙事来打破死锁并重新执行它。

对策:交换更新

一个直接的对策是设计更新操作为可交换的。如果操作可以以任何顺序执行,则它们是可交换的Accountdebit()credit()操作是可交换的(如果你忽略透支检查)。这种对策很有用,因为它消除了丢失的更新。

例如,考虑这样一个场景:在可补偿性事务已经借记(或贷记)一个账户之后,需要回滚一个叙事。补偿性事务可以简单地贷记(或借记)该账户以撤销更新。不可能覆盖其他叙事所做的更新。

对策:悲观观点

处理隔离不足的另一种方法是悲观视图对策。它重新排序叙事的步骤以最小化因脏读而导致的业务风险。例如,考虑之前用来描述脏读异常的场景。在那个场景中,创建订单叙事执行了对可用信用的脏读并创建了一个超出消费者信用额度的订单。为了降低这种情况发生的风险,这种对策会重新排序取消订单叙事

  1. 订单服务—将订单的状态更改为已取消。

  2. 配送服务—取消配送。

  3. 客户服务—增加可用信用。

在这个重新排序的叙事版本中,可用信用通过可重试事务增加,从而消除了脏读的可能性。

对策:重读值

重读值对策防止丢失更新。使用这种对策的叙事在更新记录之前会重新读取记录,验证其未改变,然后更新记录。如果记录已改变,叙事将中止并可能重新启动。这种对策是乐观离线锁模式(martinfowler.com/eaaCatalog/optimisticOfflineLock.html)的一种形式。

创建订单叙事可以使用这种对策来处理在订单正在审批过程中被取消的场景。批准订单的事务验证订单自叙事早期创建以来是否未改变。如果未改变,事务批准订单。但如果订单已被取消,事务将中止叙事,这会导致其补偿事务被执行。

对策:版本文件

版本文件对策之所以得名,是因为它记录了对记录执行的操作,以便可以重新排序它们。这是一种将非交换操作转换为交换操作的方法。要了解这种对策是如何工作的,可以考虑一个场景,其中创建订单叙事取消订单叙事并发执行。除非叙事使用语义锁对策,否则取消订单叙事可能会在创建订单叙事授权卡片之前取消消费者的信用卡授权。

会计服务处理这些顺序错误的请求的一种方法是将操作按到达顺序记录下来,然后按正确的顺序执行它们。在这个场景中,它首先记录取消授权请求。然后,当会计服务收到随后的授权卡片请求时,它会注意到它已经收到了取消授权请求,并跳过授权信用卡。

对策:按值

最后一种对策是 值传递 对策。这是一种根据业务风险选择并发机制的战略。使用这种对策的应用程序使用每个请求的属性来决定使用 sagas 还是分布式事务。它使用 sagas 执行低风险请求,可能应用上一节中描述的对策。但对于涉及大量资金等高风险请求,它使用分布式事务。这种策略使应用程序能够动态地在业务风险、可用性和可伸缩性之间进行权衡。

在实现应用程序中的 sagas 时,你可能需要使用这些对策之一或多个。让我们看看使用语义锁对策的 Create Order Saga 的详细设计和实现。

4.4. Order ServiceCreate Order Saga 的设计

现在我们已经探讨了各种 saga 设计和实现问题,让我们来看一个例子。图 4.9 展示了 Order Service 的设计。该服务的业务逻辑由传统的业务逻辑类组成,例如 Order ServiceOrder 实体。还包括 saga 调度器类,例如 CreateOrderSaga 类,它调度 Create Order Saga。此外,因为 Order Service 参与其自身的 sagas,它有一个 OrderCommandHandlers 适配器类,该类通过调用 OrderService 来处理命令消息。

图 4.9. Order Service 及其 sagas 的设计

图 4.9

Order Service 的某些部分可能看起来很熟悉。就像在传统应用程序中一样,业务逻辑的核心是由 OrderServiceOrderOrderRepository 类实现的。在本章中,我将简要描述这些类。我在第五章中更详细地描述了它们。

关于 Order Service 的不太熟悉的部分是 saga 相关的类。这个服务既是 saga 调度器也是 saga 参与者。Order Service 有几个 saga 调度器,例如 CreateOrderSaga。saga 调度器通过使用 saga 参与者代理类(如 KitchenServiceProxyOrderServiceProxy)向 saga 参与者发送命令消息。saga 参与者代理定义了 saga 参与者的消息 API。Order Service 还有一个 OrderCommandHandlers 类,该类处理 sagas 发送给 Order Service 的命令消息。

让我们更详细地看看设计,从 OrderService 类开始。

4.4.1. OrderService

OrderService 类是一个由服务的 API 层调用的领域服务。它负责创建和管理订单。图 4.10 显示了 OrderService 及其一些协作者。OrderService 创建和更新 Orders,调用 OrderRepository 持久化 Orders,并使用 SagaManager 创建 saga,例如 CreateOrderSagaSagaManager 类是 Eventuate Tram Saga 框架提供的类之一,这是一个用于编写 saga 调度器和参与者的框架,将在本节稍后进行讨论。

图 4.10. OrderService 创建和更新 Orders,调用 OrderRepository 持久化 Orders,并创建包括 CreateOrderSaga 在内的 saga。

我将在第五章中更详细地讨论这个类。第五章。现在,让我们专注于 createOrder() 方法。以下列表显示了 OrderServicecreateOrder() 方法。此方法首先创建一个 Order,然后创建一个 CreateOrderSaga 来验证订单。

列表 4.1. OrderService 类及其 createOrder() 方法
@Transactional                                                           *1*
 public class OrderService {

  @Autowired
  private SagaManager<CreateOrderSagaState> createOrderSagaManager;

  @Autowired
  private OrderRepository orderRepository;

  @Autowired
  private DomainEventPublisher eventPublisher;

  public Order createOrder(OrderDetails orderDetails) {
    ...
    ResultWithEvents<Order> orderAndEvents = Order.createOrder(...);     *2*
     Order order = orderAndEvents.result;
    orderRepository.save(order);                                         *3*

    eventPublisher.publish(Order.class,                                  *4*
                            Long.toString(order.getId()),
                           orderAndEvents.events);

    CreateOrderSagaState data =
        new CreateOrderSagaState(order.getId(), orderDetails);           *5*
     createOrderSagaManager.create(data, Order.class, order.getId());

    return order;
  }

  ...
}
  • 1 确保服务方法是事务性的。

  • 2 创建订单。

  • 3 在数据库中持久化订单。

  • 4 发布领域事件。

  • 5 创建一个 CreateOrderSaga。

createOrder() 方法通过调用工厂方法 Order.createOrder() 创建一个 Order。然后使用 OrderRepository(一个基于 JPA 的存储库)持久化 Order。通过调用 SagaManager.create() 创建 CreateOrderSaga,传递一个包含新保存的 Order ID 和 OrderDetailsCreateOrderSagaStateSagaManager 实例化 saga 调度器,这导致它向第一个 saga 参与者发送命令消息,并在数据库中持久化 saga 调度器。

让我们看看 CreateOrderSaga 及其相关类。

4.4.2. Create Order Saga 的实现

图 4.11 显示了实现 Create Order Saga 的类。每个类的职责如下:

图 4.11. OrderService 的 saga,例如 Create Order Saga,是使用 Eventuate Tram Saga 框架实现的。

  • CreateOrderSaga—一个单例类,它定义了 saga 的状态机。它调用 CreateOrderSagaState 创建命令消息,并通过 saga 参与者代理类(如 KitchenServiceProxy)指定的消息通道将它们发送给参与者。

  • CreateOrderSagaState—一个 saga 的持久化状态,用于创建命令消息。

  • saga 参与者代理类,例如 KitchenServiceProxy—每个代理类定义了一个 saga 参与者的消息 API,它包括命令通道、命令消息类型和回复类型。

这些类是使用 Eventuate Tram Saga 框架编写的。

Eventuate Tram Saga 框架提供了一个领域特定语言 (DSL) 来定义悲剧的状态机。它执行悲剧的状态机,并使用 Eventuate Tram 框架与悲剧参与者交换消息。该框架还将悲剧的状态持久化到数据库中。

让我们更仔细地看看 Create Order Saga 的实现,从 CreateOrderSaga 类开始。

CreateOrderSaga 协调器

CreateOrderSaga 类实现了前面在 图 4.7 中显示的状态机。这个类实现了 SimpleSaga,这是悲剧的基接口。CreateOrderSaga 类的核心是以下列表中显示的悲剧定义。它使用 Eventuate Tram Saga 框架提供的 DSL 来定义 Create Order Saga 的步骤。

列表 4.2. CreateOrderSaga 的定义
public class CreateOrderSaga implements SimpleSaga<CreateOrderSagaState> {

  private SagaDefinition<CreateOrderSagaState> sagaDefinition;

  public CreateOrderSaga(OrderServiceProxy orderService,
                         ConsumerServiceProxy consumerService,
                         KitchenServiceProxy kitchenService,
                         AccountingServiceProxy accountingService) {
    this.sagaDefinition =
             step()
              .withCompensation(orderService.reject,
                                CreateOrderSagaState::makeRejectOrderCommand)
            .step()
              .invokeParticipant(consumerService.validateOrder,
                      CreateOrderSagaState::makeValidateOrderByConsumerCommand)
            .step()
              .invokeParticipant(kitchenService.create,
                      CreateOrderSagaState::makeCreateTicketCommand)
              .onReply(CreateTicketReply.class,
                      CreateOrderSagaState::handleCreateTicketReply)
              .withCompensation(kitchenService.cancel,
                  CreateOrderSagaState::makeCancelCreateTicketCommand)
            .step()
              .invokeParticipant(accountingService.authorize,
                      CreateOrderSagaState::makeAuthorizeCommand)
            .step()
              .invokeParticipant(kitchenService.confirmCreate,
                  CreateOrderSagaState::makeConfirmCreateTicketCommand)
            .step()
              .invokeParticipant(orderService.approve,
                                 CreateOrderSagaState::makeApproveOrderCommand)
            .build();
  }

 @Override
 public SagaDefinition<CreateOrderSagaState> getSagaDefinition() {
  return sagaDefinition;
 }

CreateOrderSaga 的构造函数创建悲剧定义并将其存储在 sagaDefinition 字段中。getSagaDefinition() 方法返回悲剧定义。

为了了解 CreateOrderSaga 的工作原理,让我们看看悲剧的第三步定义,如下所示。这一步的悲剧调用 Kitchen Service 来创建一个 Ticket。它的补偿事务取消该 Ticketstep()invokeParticipant()onReply()withCompensation() 方法是 Eventuate Tram Saga 提供的 DSL 的一部分。

列表 4.3. 悲剧第三步的定义
public class CreateOrderSaga ...

public CreateOrderSaga(..., KitchenServiceProxy kitchenService,
            ...) {
    ...
    .step()
      .invokeParticipant(kitchenService.create,                         *1*
                 CreateOrderSagaState::makeCreateTicketCommand)
      .onReply(CreateTicketReply.class,
                CreateOrderSagaState::handleCreateTicketReply)          *2*
       .withCompensation(kitchenService.cancel,                         *3*
               CreateOrderSagaState::makeCancelCreateTicketCommand)

    ...
  ;
  • 1 定义正向事务。*

  • 2 当收到成功回复时调用 handleCreateTicketReply()。*

  • 3 定义补偿事务。*

invokeParticipant() 的调用定义了正向事务。它通过调用 CreateOrderSagaState.makeCreateTicketCommand() 创建 CreateTicket 命令消息,并将其发送到由 kitchenService.create 指定的通道。onReply() 的调用指定当从 Kitchen Service 收到成功回复时,应调用 CreateOrderSagaState.handleCreateTicketReply()。此方法将返回的 ticketId 存储在 CreateOrderSagaState 中。withCompensation() 的调用定义了补偿事务。它通过调用 CreateOrderSagaState.makeCancelCreateTicket() 创建 RejectTicketCommand 命令消息,并将其发送到由 kitchenService.create 指定的通道。

悲剧的其他步骤以类似的方式定义。CreateOrderSagaState 创建每个消息,这些消息由悲剧发送到由 KitchenServiceProxy 定义的 messaging 端点。让我们看看这些类中的每一个,从 CreateOrderSagaState 开始。

CreateOrderSagaState

如下所示,CreateOrderSagaState 类代表 saga 实例的状态。该类的实例由 OrderService 创建,并由 Eventuate Tram Saga 框架保存在数据库中。其主要职责是创建发送给 saga 参与者的消息。

列表 4.4. CreateOrderSagaState 存储 saga 实例的状态
public class CreateOrderSagaState {

  private Long orderId;

  private OrderDetails orderDetails;
  private long ticketId;

  public Long getOrderId() {
    return orderId;
  }

  private CreateOrderSagaState() {
  }

  public CreateOrderSagaState(Long orderId, OrderDetails orderDetails) {  *1*
     this.orderId = orderId;
    this.orderDetails = orderDetails;
  }

  CreateTicket makeCreateTicketCommand() {                                *2*
     return new CreateTicket(getOrderDetails().getRestaurantId(),
                   getOrderId(), makeTicketDetails(getOrderDetails()));
  }

  void handleCreateTicketReply(CreateTicketReply reply) {                 *3*
     logger.debug("getTicketId {}", reply.getTicketId());
    setTicketId(reply.getTicketId());
  }

  CancelCreateTicket makeCancelCreateTicketCommand() {                    *4*
     return new CancelCreateTicket(getOrderId());
  }

  ...
  • 1 由 OrderService 调用以实例化 CreateOrderSagaState

  • 2 创建一个 CreateTicket 命令消息

  • 3 保存新创建的 Ticket 的 ID

  • 4 创建 CancelCreateTicket 命令消息

CreateOrderSaga 调用 CreateOrderSagaState 来创建命令消息。它将这些命令消息发送到由 SagaParticipantProxy 类定义的端点。让我们看看这些类中的一个:KitchenServiceProxy

KitchenServiceProxy

如 列表 4.5 所示,KitchenServiceProxy 类定义了 Kitchen Service 的命令消息端点。有三个端点:

  • create 创建一个 Ticket

  • confirmCreate 确认创建

  • cancel 取消一个 Ticket

每个 CommandEndpoint 指定命令类型、命令消息的目的地通道和预期的回复类型。

列表 4.5. KitchenServiceProxy 定义了 Kitchen Service 的命令消息端点
public class KitchenServiceProxy {

  public final CommandEndpoint<CreateTicket> create =
        CommandEndpointBuilder
          .forCommand(CreateTicket.class)
          .withChannel(
               KitchenServiceChannels.kitchenServiceChannel)
          .withReply(CreateTicketReply.class)
          .build();

  public final CommandEndpoint<ConfirmCreateTicket> confirmCreate =
         CommandEndpointBuilder
          .forCommand(ConfirmCreateTicket.class)
          .withChannel(
                KitchenServiceChannels.kitchenServiceChannel)
          .withReply(Success.class)
          .build();

  public final CommandEndpoint<CancelCreateTicket> cancel =
        CommandEndpointBuilder
          .forCommand(CancelCreateTicket.class)
          .withChannel(
                 KitchenServiceChannels.kitchenServiceChannel)
          .withReply(Success.class)
          .build();

}

代理类,如 KitchenServiceProxy,并非绝对必要。saga 可以直接将命令消息发送给参与者。但是代理类有两个重要的好处。首先,代理类定义了静态类型端点,这减少了 saga 向服务发送错误消息的机会。其次,代理类是一个定义良好的 API,用于调用服务,这使得代码更容易理解和测试。例如,第十章 描述了如何为 KitchenServiceProxy 编写测试,以验证 Order Service 正确调用 Kitchen Service。没有 KitchenServiceProxy,将无法编写如此范围狭窄的测试。

Eventuate Tram Saga 框架

如 图 4.12 所示,Eventuate Tram Saga 是一个用于编写 saga 调度器和 saga 参与者的框架。它使用 Eventuate Tram 的事务消息功能,这在 第三章 中讨论过。

图 4.12. Eventuate Tram Saga 是一个用于编写 saga 调度器和 saga 参与者的框架。

saga 协调 包是框架中最复杂的一部分。它提供了一个 SimpleSaga 基础接口用于 saga,以及一个 SagaManager 类,用于创建和管理 saga 实例。SagaManager 负责持久化 saga,发送它生成的命令消息,订阅回复消息,并调用 saga 处理回复。图 4.13 展示了当 OrderService 创建 saga 时的事件序列。事件序列如下:

  1. OrderService 创建 CreateOrderSagaState

  2. 通过调用 SagaManager 创建一个 saga 实例。

  3. SagaManager 执行 saga 定义的第一步。

  4. 调用 CreateOrderSagaState 生成一个命令消息。

  5. SagaManager 将命令消息发送给 saga 参与者(Consumer Service)。

  6. SagaManager 将 saga 实例保存到数据库中。

图 4.13. 当 OrderService 创建 Create Order Saga 实例时的事件序列

图片

图 4.14 展示了当 SagaManagerConsumer Service 接收到回复时的事件序列。

图 4.14. 当 SagaManager 从 saga 参与者接收到回复消息时的事件序列

图片

事件序列如下:

  1. Eventuate Tram 使用 Consumer Service 的回复调用 SagaManager

  2. SagaManager 从数据库中检索 saga 实例。

  3. SagaManager 执行 saga 定义的下一步。

  4. 调用 CreateOrderSagaState 生成一个命令消息。

  5. SagaManager 将命令消息发送给指定的 saga 参与者(Kitchen Service)。

  6. SagaManager 将更新后的 saga 实例保存到数据库中。

如果 saga 参与者失败,SagaManager 将以相反的顺序执行补偿事务。

Eventuate Tram Saga 框架的另一部分是 saga 参与者 包。它提供了 SagaCommandHandlersBuilderSagaCommandDispatcher 类,用于编写 saga 参与者。这些类将命令消息路由到处理方法,调用 saga 参与者的业务逻辑并生成回复消息。让我们看看这些类是如何被 Order Service 使用的。

4.4.3. OrderCommandHandlers

Order Service 参与其自己的 saga。例如,CreateOrderSaga 调用 Order Service 来批准或拒绝一个 Order。如图 4.15 所示的 OrderCommandHandlers 类定义了这些 saga 发送的命令消息的处理方法。

图 4.15. OrderCommandHandlers 实现了由各种 Order Service saga 发送的命令的处理程序。

图片

每个处理方法调用 OrderService 更新一个 Order 并生成一个回复消息。SagaCommandDispatcher 类将命令消息路由到适当的处理方法并发送回复。

以下列表显示了OrderCommandHandlers类。它的commandHandlers()方法将命令消息类型映射到处理方法。每个处理方法接受一个命令消息作为参数,调用OrderService,并返回一个回复消息。

列表 4.6。订单服务的命令处理程序
public class OrderCommandHandlers {

  @Autowired
  private OrderService orderService;

  public CommandHandlers commandHandlers() {                           *1*
     return SagaCommandHandlersBuilder
          .fromChannel("orderService")
          .onMessage(ApproveOrderCommand.class, this::approveOrder)
          .onMessage(RejectOrderCommand.class, this::rejectOrder)
          ...
          .build();

  }

  public Message approveOrder(CommandMessage<ApproveOrderCommand> cm) {
    long orderId = cm.getCommand().getOrderId();
    orderService.approveOrder(orderId);                                *2*
     return withSuccess();                                             *3*
   }

  public Message rejectOrder(CommandMessage<RejectOrderCommand> cm) {
    long orderId = cm.getCommand().getOrderId();
    orderService.rejectOrder(orderId);                                 *4*
     return withSuccess();
  }
  • 1 将每个命令消息路由到适当的处理方法。

  • 2 将订单的状态更改为已授权。

  • 3 返回一个通用的成功消息。

  • 4 将订单的状态更改为已拒绝。

approveOrder()rejectOrder()方法通过调用OrderService来更新指定的Order。参与 saga 的其他服务也有类似的命令处理类,用于更新它们的领域对象。

4.4.4。OrderServiceConfiguration

订单服务使用 Spring 框架。以下列表是OrderServiceConfiguration类的摘录,它是一个@Configuration类,用于实例化和连接 Spring @Beans

列表 4.7。OrderServiceConfiguration是一个 Spring @Configuration类,它定义了订单服务的 Spring @Beans
@Configuration
public class OrderServiceConfiguration {

 @Bean
 public OrderService orderService(RestaurantRepository restaurantRepository,
                                  ...
                                  SagaManager<CreateOrderSagaState>
                                          createOrderSagaManager,
                                  ...) {
  return new OrderService(restaurantRepository,
                          ...
                          createOrderSagaManager
                          ...);
 }

 @Bean
 public SagaManager<CreateOrderSagaState> createOrderSagaManager(CreateOrderS
     aga saga) {
  return new SagaManagerImpl<>(saga);
 }

 @Bean
 public CreateOrderSaga createOrderSaga(OrderServiceProxy orderService,
                                        ConsumerServiceProxy consumerService,
                                        ...) {
  return new CreateOrderSaga(orderService, consumerService, ...);
 }

 @Bean
 public OrderCommandHandlers orderCommandHandlers() {
  return new OrderCommandHandlers();
 }

 @Bean
 public SagaCommandDispatcher  orderCommandHandlersDispatcher(OrderCommandHan
     dlers orderCommandHandlers) {
  return new SagaCommandDispatcher("orderService", orderCommandHandlers.comma
     ndHandlers());
 }

 @Bean
 public KitchenServiceProxy kitchenServiceProxy() {
   return new KitchenServiceProxy();
 }

 @Bean
 public OrderServiceProxy orderServiceProxy() {
   return new OrderServiceProxy();
 }

 ...

}

此类定义了多个 Spring @Beans,包括orderServicecreateOrderSagaManagercreateOrderSagaorderCommandHandlersorderCommandHandlersDispatcher。它还定义了各种代理类的 Spring @Beans,包括kitchenServiceProxyorderServiceProxy

CreateOrderSaga只是订单服务众多 saga 之一。它的许多其他系统操作也使用 saga。例如,cancelOrder()操作使用取消订单 saga,而reviseOrder()操作使用修改订单 saga。因此,尽管许多服务有一个使用同步协议的外部 API,例如 REST 或 gRPC,但大量的服务间通信将使用异步消息。

正如你所见,在微服务架构中,事务管理和业务逻辑设计的某些方面相当不同。幸运的是,saga 编排器通常是相当简单的状态机,你可以使用 saga 框架来简化你的代码。然而,事务管理确实比在单体架构中更复杂。但这通常是为了微服务带来的巨大好处而付出的微小代价。

摘要

  • 一些系统操作需要更新多个服务中的分散数据。传统的基于 XA/2PC 的分布式事务并不适合现代应用。更好的方法是使用 saga 模式。saga 是一系列使用消息协调的本地事务。每个本地事务更新单个服务中的数据。因为每个本地事务都会提交其更改,如果 saga 必须因为违反业务规则而回滚,它必须执行补偿事务来显式撤销更改。

  • 你可以使用编排或编排来协调 Sagas 的步骤。在基于编排的 Sagas 中,本地事务发布事件,触发其他参与者执行本地事务。在基于编排的 Sagas 中,集中的 Sagas 编排器向参与者发送命令消息,告诉他们执行本地事务。你可以通过将 Sagas 编排器建模为状态机来简化开发和测试。简单的 Sagas 可以使用编排,但对于复杂的 Sagas,编排通常是一个更好的方法。

  • 设计基于 Sagas 的业务逻辑可能具有挑战性,因为与 ACID 事务不同,Sagas 之间并不是相互隔离的。你通常必须使用对策,这些对策是设计策略,用于防止由 ACD 事务模型引起的并发异常。应用程序甚至可能需要使用锁定来简化业务逻辑,尽管这可能导致死锁。

第五章:在微服务架构中设计业务逻辑

本章涵盖

  • 应用业务逻辑组织模式:事务脚本模式和领域模型模式

  • 使用领域驱动设计(DDD)聚合模式设计业务逻辑

  • 在微服务架构中应用领域事件模式

企业应用的核心是业务逻辑,它实现了业务规则。开发复杂的业务逻辑始终具有挑战性。FTGO 应用的业务逻辑实现了一些相当复杂的业务逻辑,尤其是在订单管理和配送管理方面。玛丽鼓励她的团队应用面向对象设计原则,因为在她看来,这是实现复杂业务逻辑的最佳方式。一些业务逻辑使用了程序性的事务脚本模式。但 FTGO 应用的大多数业务逻辑都是在一个面向对象的领域模型中实现的,该模型使用 JPA 映射到数据库。

在业务逻辑分散在多个服务中的微服务架构中,开发复杂的业务逻辑更具挑战性。你需要解决两个关键挑战。首先,典型的领域模型是一个相互连接的类错综复杂的网络。尽管在单体应用中这不是问题,但在类分散在不同服务中的微服务架构中,你需要消除跨越服务边界的对象引用。第二个挑战是在微服务架构的事务管理约束内设计业务逻辑。你的业务逻辑可以在服务内使用 ACID 事务,但如第四章所述,它必须使用 Saga 模式来维护服务间数据的一致性。

幸运的是,我们可以通过使用 DDD 中的聚合模式来解决这些问题。聚合模式将服务业务逻辑结构化为一系列聚合。一个聚合是一组可以作为一个单元处理的对象。在微服务架构中开发业务逻辑时,聚合之所以有用,有两个原因:

  • 聚合避免任何跨越服务边界的对象引用的可能性,因为聚合间的引用是一个主键值而不是对象引用。

  • 因为一个事务只能创建或更新一个聚合,所以聚合符合微服务事务模型的约束。

因此,ACID 事务保证在单个服务内完成。

我以描述组织业务逻辑的不同方式开始本章:转录脚本模式和领域模型模式。接下来,我介绍 DDD 聚合的概念,并解释为什么它是服务业务逻辑的良好构建块。之后,我描述领域事件模式事件,并解释为什么对服务发布事件是有用的。我以 Kitchen ServiceOrder Service 的几个业务逻辑示例结束本章。

让我们现在看看业务逻辑组织模式。

5.1. 业务逻辑组织模式

图 5.1 展示了一个典型服务的架构。正如 第二章 所描述的,业务逻辑是六边形架构的核心。围绕业务逻辑的是输入和输出适配器。一个 输入适配器 处理来自客户端的请求并调用业务逻辑。一个 输出适配器,由业务逻辑调用,调用其他服务和应用程序。

图 5.1. Order Service 具有六边形架构。它由业务逻辑和一个或多个适配器组成,这些适配器与外部应用程序和其他服务进行接口。

此服务由业务逻辑和以下适配器组成:

  • REST API 适配器 一个实现 REST API 的输入适配器,它调用业务逻辑

  • OrderCommandHandlers 一个从消息通道消费命令消息并调用业务逻辑的输入适配器

  • Database Adapter 一个由业务逻辑调用以访问数据库的输出适配器

  • 领域事件发布适配器 一个将事件发布到消息代理的输出适配器

业务逻辑通常是服务中最复杂的一部分。在开发业务逻辑时,你应该有意识地以最适合你应用程序的方式组织你的业务逻辑。毕竟,我相信你一定经历过维护他人糟糕结构化代码的挫败感。大多数企业应用程序是用面向对象的语言(如 Java)编写的,因此它们由类和方法组成。但是,使用面向对象的语言并不能保证业务逻辑具有面向对象的设计。在开发业务逻辑时,你必须做出的关键决策是使用面向对象的方法还是过程式方法。组织业务逻辑有两种主要模式:过程式的交易脚本模式和面向对象的领域模型模式。

5.1.1. 使用交易脚本模式设计业务逻辑

虽然我是面向对象方法的强烈支持者,但在某些情况下,它可能过于复杂,例如当你正在开发简单的业务逻辑时。在这种情况下,更好的方法是编写过程性代码,并使用马丁·福勒(Martin Fowler)在其著作《企业应用架构模式》(Patterns of Enterprise Application Architecture)中称为“事务脚本模式”的方法。而不是进行任何面向对象设计,你编写一个名为“事务脚本”的方法来处理来自表示层的每个请求。如图 5.2 所示,这种方法的一个重要特征是实现行为的类与存储状态的类是分开的。

图 5.2. 将业务逻辑组织成事务脚本。在典型的基于事务脚本的设计中,一组类实现行为,另一组类存储状态。事务脚本组织成通常没有状态的类。脚本使用数据类,这些数据类通常没有行为。

在使用事务脚本模式时,脚本通常位于服务类中,在这个例子中是OrderService类。服务类为每个请求/系统操作有一个方法。该方法实现该请求的业务逻辑。它使用数据访问对象(DAOs),如OrderDao,访问数据库。数据对象,在这个例子中是Order类,是纯数据,几乎没有行为。

模式:事务脚本

将业务逻辑组织成一系列过程性事务脚本,每个脚本对应一种请求类型。

这种设计风格高度过程化,并且很少依赖面向对象编程(OOP)语言的能力。如果你用 C 或另一种非面向对象语言编写应用程序,你会创建这样的应用程序。尽管如此,当适用时,使用过程性设计并不应该感到羞耻。这种方法对于简单的业务逻辑效果很好。缺点是这通常不是实现复杂业务逻辑的好方法。

5.1.2. 使用领域模型模式设计业务逻辑

过程性方法的简单性可能相当诱人。你可以编写代码而无需仔细考虑如何组织类。问题是如果业务逻辑变得复杂,你可能会得到难以维护的代码。事实上,就像单体应用程序有不断增长的习性一样,事务脚本也有同样的问题。因此,除非你正在编写极其简单的应用程序,否则你应该抵制编写过程性代码的诱惑,而是应用领域模型模式并开发面向对象的设计。

模式:领域模型

将业务逻辑组织成一个由具有状态和行为的类组成的对象模型。

在面向对象的设计中,业务逻辑由一个对象模型和相对较小类的网络组成。这些类通常直接对应于问题域中的概念。在这种设计中,一些类可能只有状态或行为,但许多类同时包含两者,这是设计良好的类的标志。图 5.3 展示了领域模型模式的一个示例。

图 5.3. 将业务逻辑组织为领域模型。大部分业务逻辑由具有状态和行为的类组成。

与事务脚本模式一样,OrderService类为每个请求/系统操作都有一个方法。但是,当使用领域模型模式时,服务方法通常是简单的。这是因为服务方法几乎总是委托给持久化的领域对象,这些对象包含大部分业务逻辑。例如,一个服务方法可能会从数据库中加载一个领域对象并调用其方法之一。在这个例子中,Order类既有状态又有行为。此外,其状态是私有的,只能通过其方法间接访问。

使用面向对象的设计有许多好处。首先,这种设计易于理解和维护。它不是由一个承担所有功能的庞大类组成,而是由许多具有少量职责的小类组成。此外,如AccountBankingTransactionOverdraftPolicy之类的类紧密地反映了现实世界,这使得它们在设计中的角色更容易理解。其次,我们的面向对象设计更容易测试:每个类都可以并且应该独立测试。最后,面向对象的设计更容易扩展,因为它可以使用诸如策略模式(Strategy pattern)和模板方法模式(Template method pattern)之类的知名设计模式,这些模式定义了在不修改代码的情况下扩展组件的方法。

领域模型模式效果良好,但这种方法存在一些问题,尤其是在微服务架构中。为了解决这些问题,你需要使用一种称为领域驱动设计(DDD)的面向对象设计的细化。

5.1.3. 关于领域驱动设计

领域驱动设计(DDD),由埃里克·埃文斯(Eric Evans)在其著作《领域驱动设计》(Domain-Driven Design)中描述,是面向对象设计的细化,并且是开发复杂业务逻辑的方法。我在第二章中介绍了 DDD,当时讨论了在将应用程序分解为服务时 DDD 子域的有用性。当使用 DDD 时,每个服务都有自己的领域模型,这避免了单一、应用范围领域模型的问题。子域和相关概念边界上下文(Bounded Context)是 DDD 的战略模式之二。

DDD 还有一些战术模式,它们是领域模型的构建块。每个模式都是一个类在领域模型中扮演的角色,并定义了类的特征。被开发者广泛采用的构建块包括以下内容:

  • 实体 具有持久身份的对象。具有相同属性的两个实体仍然是不同的对象。在 Java EE 应用程序中,使用 JPA @Entity 持久化的类通常是 DDD 实体。

  • 值对象 由值组成的对象。具有相同属性的两个值对象可以互换使用。一个值对象的例子是 Money 类,它由货币和金额组成。

  • 工厂 实现对象创建逻辑的对象或方法,这些逻辑过于复杂,不能直接通过构造函数完成。它还可以隐藏实例化的具体类。工厂可以作为一个类的静态方法实现。

  • 仓储 提供对持久化实体访问的对象,并封装了访问数据库的机制。

  • 服务 实现不属于实体或值对象的业务逻辑的对象。

这些构建块被许多开发者使用。其中一些由 JPA 和 Spring 框架等框架支持。还有一个构建块通常被忽视(包括我自己!),除了 DDD 纯粹主义者之外:聚合。实际上,聚合在开发微服务时是一个极其有用的概念。让我们首先看看经典 OOD 中的一些微妙问题,这些问题可以通过使用聚合来解决。

5.2. 使用 DDD 聚合模式设计领域模型

在传统的面向对象设计中,领域模型是一组类及其之间的关系。这些类通常被组织成包。例如,图 5.4 展示了 FTGO 应用程序领域模型的一部分。它是一个典型的领域模型,由相互连接的类网组成。

图 5.4. 传统的领域模型是一个相互连接的类网。它没有明确指定业务对象(如 ConsumerOrder)的边界。

图片

这个例子有几个与业务对象对应的类:ConsumerOrderRestaurantCourier。但有趣的是,这种传统领域模型中缺少每个业务对象的明确边界。例如,它没有指定哪些类是 Order 业务对象的一部分。这种边界缺失有时会导致问题,尤其是在微服务架构中。

我以一个由于缺乏明确边界而引起的问题为例开始本节。接下来,我描述了聚合的概念以及它具有明确的边界。然后,我描述了聚合必须遵守的规则以及它们如何使聚合适合微服务架构。然后,我描述了如何仔细选择聚合的边界以及为什么这很重要。最后,我讨论了如何使用聚合设计业务逻辑。让我们首先看看模糊边界引起的问题。

5.2.1. 模糊边界的弊端

例如,假设你想对一个Order业务对象执行一个操作,比如加载或删除。这究竟意味着什么?操作的范畴是什么?你当然会加载或删除Order对象。但在现实中,Order不仅仅是Order对象。还包括订单行项目、支付信息等等。图 5.4 将领域对象的边界留给了开发者的直觉。

除了概念上的模糊性之外,缺乏明确的边界在更新业务对象时也会引起问题。一个典型的业务对象有不变性,这是必须始终强制执行的商务规则。例如,Order有一个最小订单金额。FTGO 应用程序必须确保任何尝试更新订单的行为都不会违反不变性,例如最小订单金额。挑战在于,为了强制执行不变性,你必须仔细设计你的业务逻辑。

例如,让我们看看当多个消费者共同创建一个订单时,如何确保满足订单的最小金额。两个消费者——山姆和玛丽——正在共同处理一个订单,并且同时决定订单超出了他们的预算。山姆减少了萨莫萨饼的数量,玛丽减少了印度烤饼的数量。从应用程序的角度来看,两个消费者都从数据库中检索订单及其行项目。然后,两个消费者更新一个行项目以降低订单的成本。从每个消费者的角度来看,订单的最小金额得到了保留。以下是数据库事务的顺序。

|

Consumer - Mary

BEGIN TXN

   SELECT ORDER_TOTAL FROM ORDER
     WHERE ORDER ID = X

   SELECT * FROM ORDER_LINE_ITEM
      WHERE ORDER_ID = X
   ...
END TXN

Verify minimum is met

|

Consumer - Sam

BEGIN TXN

   SELECT ORDER_TOTAL FROM ORDER
     WHERE ORDER ID = X

   SELECT * FROM ORDER_LINE_ITEM
      WHERE ORDER_ID = X
   ...
END TXN

|

|

BEGIN TXN

   UPDATE ORDER_LINE_ITEM
     SET VERSION=..., QUANTITY=...
   WHERE VERSION = <loaded version>
    AND ID = ...

END TXN
Verify minimum is met

BEGIN TXN

   UPDATE ORDER_LINE_ITEM
     SET VERSION=..., QUANTITY=...
   WHERE VERSION = <loaded version>
    AND ID = ...

END TXN

|

每个消费者使用一系列两个事务来更改一个行项目。第一个事务加载订单及其行项目。UI 在执行第二个事务之前验证订单最小金额是否得到满足。第二个事务使用乐观离线锁定检查更新行项目数量,该检查验证订单行自第一个事务加载以来未发生变化。

在这个场景中,山姆减少了订单总额 X 美元,玛丽减少了 Y 美元。结果,Order不再有效,尽管应用程序在每次消费者更新后都验证了订单仍然满足订单最小金额。正如你所看到的,直接更新业务对象的一部分可能导致违反业务规则。DDD 聚合旨在解决这个问题。

5.2.2. 聚合具有明确的边界

一个 聚合 是一个边界内的领域对象簇,可以作为一个单元处理。它由一个根实体以及可能的一个或多个其他实体和值对象组成。许多业务对象被建模为聚合。例如,在第二章中,我们通过分析需求中使用的名词以及领域专家的分析创建了一个粗略的领域模型。其中许多名词,如OrderConsumerRestaurant,都是聚合。

模式:聚合

将领域模型组织为聚合的集合,每个聚合都是一个可以作为一个单元处理的对象图。

图 5.5 展示了Order聚合及其边界。一个Order聚合由一个Order实体、一个或多个OrderLineItem值对象以及其他值对象(如交付地址和支付信息)组成。

图 5.5. 将领域模型结构化为聚合集合使边界明确。

聚合将领域模型分解成块,这些块单独更容易理解。它们还澄清了诸如加载、更新和删除等操作的范围。这些操作作用于整个聚合,而不是其部分。聚合通常从数据库中完全加载,从而避免了懒加载的任何复杂性。删除聚合会从数据库中删除其所有对象。

聚合是一致性边界

更新整个聚合而不是其部分可以解决一致性问题,例如前面描述的例子。更新操作是在聚合根上触发的,这强制执行不变性。此外,并发通过使用版本号或数据库级别的锁等方式锁定聚合根来处理。例如,而不是直接更新行项的数量,客户端必须调用Order聚合根上的一个方法,该方法强制执行如最小订单金额这样的不变性。然而,请注意,这种方法不需要在数据库中更新整个聚合。例如,应用程序可能只更新与Order对象和更新的OrderLineItem对应的行。

识别聚合是关键

在 DDD 中,设计领域模型的关键部分是识别聚合、它们的边界和它们的根。聚合内部结构的细节是次要的。然而,聚合的好处远远超出了模块化领域模型。这是因为聚合必须遵守某些规则。

5.2.3. 聚合规则

DDD 要求聚合遵守一组规则。这些规则确保聚合是一个自包含的单元,可以强制执行其不变性。让我们看看每条规则。

规则 #1:仅引用聚合根

之前的例子说明了直接更新 OrderLineItems 的危险。第一个聚合规则的目标是消除这个问题。它要求根实体是聚合中唯一可以被聚合外部类引用的部分。客户端只能通过在聚合根上调用方法来更新聚合。

例如,一个服务使用存储库从数据库中加载聚合并获取聚合根的引用。它通过在聚合根上调用方法来更新聚合。这个规则确保聚合可以强制执行其不变性。

规则#2:聚合之间的引用必须使用主键

另一条规则是,聚合通过标识符(例如,主键)而不是对象引用相互引用。例如,如图 5.6 所示,Order 使用 consumerId 而不是 Consumer 对象的引用来引用其 Consumer。同样,Order 使用 restaurantId 来引用 Restaurant

图 5.6. 聚合之间的引用是通过主键而不是通过对象引用来实现的。Order 聚合包含 ConsumerRestaurant 聚合的 ID。在一个聚合内部,对象之间相互引用。

图片

这种方法与传统对象建模有很大不同,传统对象建模认为领域模型中的外键是一个设计问题。它有许多好处。使用标识符而不是对象引用意味着聚合是松散耦合的。它确保聚合之间的边界定义良好,并避免意外更新不同的聚合。此外,如果一个聚合是另一个服务的一部分,就不会存在跨越服务的对象引用问题。

这种方法也简化了持久性,因为聚合是存储的单位。这使得在 MongoDB 等 NoSQL 数据库中存储聚合变得更加容易。它还消除了透明延迟加载及其相关问题的需要。通过分片聚合来扩展数据库相对简单。

规则#3:一个事务创建或更新一个聚合

聚合必须遵守的另一条规则是,一个事务只能创建或更新单个聚合。多年前我第一次读到这条规则时,觉得它毫无意义!当时,我正在开发使用 RDBMS 的传统单体应用,事务可以更新多个聚合。如今,这个约束对微服务架构来说非常完美。它确保事务被包含在服务内部。这个约束也符合大多数 NoSQL 数据库有限的交易模型。

这个规则使得实现需要创建或更新多个聚合的操作变得更加复杂。但这正是传奇(在第四章中描述)旨在解决的问题。传奇的每一步都恰好创建或更新一个聚合。图 5.7 显示了这是如何工作的。

图 5.7。事务只能创建或更新单个聚合,因此应用程序使用传奇来更新多个聚合。传奇的每一步都创建或更新一个聚合。

图片

在这个例子中,传奇由三个事务组成。第一个事务在服务 A 中更新聚合 X。其他两个事务都在服务 B 中。一个事务更新聚合 X,另一个更新聚合 Y

在单个服务内维护多个聚合的一致性的一种替代方法是采取欺骗手段,在事务中更新多个聚合。例如,服务 B 可以在单个事务中更新聚合 YZ。这只有在使用支持丰富事务模型的数据库,如关系数据库管理系统(RDBMS)时才可行。如果你使用的是只有简单事务的 NoSQL 数据库,除了使用传奇(sagas)之外没有其他选择。

或者有其他选择吗?实际上,聚合边界并不是一成不变的。在开发领域模型时,你可以选择边界在哪里。但就像 20 世纪的殖民强国划定国家边界一样,你需要小心谨慎。

5.2.4. 聚合粒度

在开发领域模型时,你必须做出的一个关键决策是每个聚合的大小。一方面,理想情况下聚合应该尽可能小。因为每个聚合的更新都是序列化的,更细粒度的聚合将增加应用程序可以处理的并发请求数量,从而提高可伸缩性。它还将改善用户体验,因为它减少了两个用户尝试对同一聚合进行冲突更新的可能性。另一方面,因为聚合是事务的范围,你可能需要定义一个更大的聚合,以便执行特定的原子更新。

例如,我之前提到过在 FTGO 应用程序的领域模型中,“订单”和“消费者”是独立的聚合。另一种设计是将“订单”作为“消费者”聚合的一部分。图 5.8 显示了这种替代设计。

图 5.8。一种替代设计定义了一个包含“客户”类和“订单”类的“客户”聚合。这种设计使得应用程序能够原子性地更新一个“消费者”及其一个或多个“订单”。

图片

这种更大的Consumer聚合体的一个好处是,应用程序可以原子性地更新一个Consumer及其一个或多个Orders。这种方法的缺点是它降低了可扩展性。更新同一客户不同订单的事务将被序列化。同样,如果两个用户尝试编辑同一客户的不同的订单,他们将会发生冲突。

在微服务架构中,这种方法的一个缺点是它阻碍了分解。例如,OrdersConsumers的业务逻辑必须在同一服务中集中,这使得服务更大。由于这些问题,使聚合体尽可能细粒度是最好的。

5.2.5. 使用聚合体设计业务逻辑

在典型的(微)服务中,大部分业务逻辑由聚合体组成。其余的业务逻辑位于领域服务和传说中。传说通过编排一系列本地事务来强制执行数据一致性。服务是业务逻辑的入口点,并由入站适配器调用。服务使用存储库从数据库检索聚合体或将聚合体保存到数据库。每个存储库都由一个出站适配器实现,该适配器访问数据库。图 5.9 显示了基于聚合体的Order Service业务逻辑设计。

图 5.9. Order Service业务逻辑的基于聚合体的设计

业务逻辑包括Order聚合体、OrderService服务类、OrderRepository和一个或多个传说。OrderService调用OrderRepository来保存和加载Orders。对于仅限于服务本地的简单请求,服务会更新一个Order聚合体。如果更新请求跨越多个服务,OrderService还将创建一个传说,如第四章所述。

我们将查看代码——但首先,让我们考察一个与聚合体密切相关的概念:领域事件。

5.3. 发布领域事件

Merriam-Webster (www.merriam-webster.com/dictionary/event) 列出了“事件”这个词的几个定义,包括以下这些:

  1. 发生的事情

  2. 一个值得注意的事件

  3. 一个社交场合或活动

  4. 一个不利的或有害的医疗事件,如心脏病发作或其他心脏事件

在领域驱动设计(DDD)的上下文中,领域事件是发生在聚合体上的事情。它在领域模型中由一个类表示。事件通常表示状态变化。例如,考虑 FTGO 应用程序中的Order聚合体。其状态变化事件包括Order CreatedOrder CancelledOrder Shipped等。如果存在感兴趣的消费者,Order聚合体可能会在每次经历状态转换时发布其中一个事件。

模式:领域事件

当聚合创建或经历某些其他重大变化时,它会发布领域事件。

5.3.1. 为什么发布变更事件?

领域事件很有用,因为其他方——用户、其他应用程序或同一应用程序内的其他组件——通常对了解聚合的状态变化感兴趣。以下是一些示例场景:

  • 使用基于编排的叙事,维护跨服务的数据一致性,如第四章所述。

  • 通知维护副本的服务源数据已更改。这种方法被称为命令查询责任分离(CQRS),并在第七章中描述。

  • 通过注册的 webhook 或通过消息代理通知不同的应用程序,以触发业务流程的下一步。

  • 通知同一应用程序的另一个组件,例如,向用户的浏览器发送 WebSocket 消息或更新文本数据库,如 ElasticSearch。

  • 向用户发送通知——短信或电子邮件——告知他们的订单已发货,他们的 Rx 处方已准备好取药,或他们的航班延误。

  • 监控领域事件以验证应用程序是否表现正确。

  • 分析事件以建模用户行为。

在所有这些场景中,通知的触发器是应用程序数据库中聚合的状态变化。

5.3.2. 什么是领域事件?

一个 领域事件 是一个使用过去分词动词命名的类。它具有有意义的传达事件的属性。每个属性要么是原始值,要么是值对象。例如,OrderCreated 事件类有一个 orderId 属性。

领域事件通常也具有元数据,例如事件 ID 和时间戳。它还可能包含更改用户的身份,因为这对审计很有用。元数据可以是事件对象的一部分,也许定义在超类中。或者,事件元数据可以放在包装事件对象的信封对象中。发出事件的聚合的 ID 也可能是信封的一部分,而不是显式的事件属性。

OrderCreated 事件是领域事件的例子。它没有任何字段,因为订单的 ID 是事件信封的一部分。以下列表显示了 OrderCreated 事件类和 DomainEventEnvelope 类。

列表 5.1. OrderCreated 事件和 DomainEventEnvelope
interface DomainEvent {}

interface OrderDomainEvent extends DomainEvent {}

class OrderCreated implements OrderDomainEvent {}

class DomainEventEnvelope<T extends DomainEvent> {
  private String aggregateType;                        *1*
  private Object aggregateId;
  private T event;
  ...
}
  • 1 事件的元数据

DomainEvent 接口是一个标记接口,用于标识一个类作为领域事件。OrderDomainEvent 是一个标记接口,用于事件,例如 OrderCreated,这些事件由 Order 聚合发布。DomainEventEnvelope 是一个包含事件元数据和事件对象的类。它是一个泛型类,由领域事件类型参数化。

5.3.3. 事件丰富化

例如,让我们想象你正在编写一个处理 Order 事件的消费者。之前显示的 OrderCreated 事件类捕捉了所发生事情的本质。但你的事件消费者在处理 OrderCreated 事件时可能需要订单详情。一个选项是让它从 OrderService 中检索该信息。事件消费者查询服务以获取聚合的缺点是它会产生服务请求的开销。

另一种称为 事件丰富 的替代方法是为事件包含消费者所需的信息。这简化了事件消费者,因为他们不再需要从发布事件的服务的请求该数据。在 OrderCreated 事件中,Order 聚合可以通过包含订单详情来丰富事件。以下列表显示了丰富的事件。

列表 5.2. 丰富的 OrderCreated 事件
class OrderCreated implements OrderEvent {
  private List<OrderLineItem> lineItems;
  private DeliveryInformation deliveryInformation;       *1*
  private PaymentInformation paymentInformation;
  private long restaurantId;
  private String restaurantName;
  ...
}
  • 1 消费者通常需要的数据

因为这个版本的 OrderCreated 事件包含了订单详情,所以事件消费者,例如 Order History Service(在第七章中讨论过),在处理 OrderCreated 事件时不再需要获取这些数据。

虽然事件丰富简化了消费者,但其缺点是它可能会使事件类变得不稳定。事件类可能需要在消费者需求发生变化时进行更改。这可能会降低可维护性,因为这种类型的更改可能会影响应用程序的多个部分。满足每个消费者的需求也可能是一种徒劳的努力。幸运的是,在许多情况下,很明显应该将哪些属性包含在事件中。

现在我们已经介绍了领域事件的基础知识,让我们看看如何发现它们。

5.3.4. 识别领域事件

识别领域事件有几种不同的策略。通常,需求会描述需要通知的场景。需求可能包括诸如“当 X 发生时做 Y。”之类的语言。例如,FTGO 应用中的一个需求是“当订单被下单时,向消费者发送电子邮件。”一个通知需求暗示了领域事件的存在。

另一种越来越受欢迎的方法是使用事件风暴。事件风暴 是一种以事件为中心的工作坊格式,用于理解复杂的领域。它涉及将领域专家聚集在房间里,大量的便利贴,以及一个非常大的表面——白板或纸卷——将便利贴粘在上面。事件风暴的结果是一个以事件为中心的领域模型,由聚合和事件组成。

事件风暴包括三个主要步骤:

  1. 头脑风暴事件 请领域专家进行领域事件的头脑风暴。领域事件由橙色便利贴表示,这些便利贴在建模表面上以大致的时间顺序排列。

  2. 识别事件触发器 请领域专家识别每个事件的触发器,这通常包括以下几种:

    • 用户操作,用蓝色便利贴表示的命令

    • 外部系统,由紫色便利贴表示

    • 另一个领域事件

    • 时间的流逝

  3. 识别聚合 请领域专家识别消耗每个命令并发出相应事件的聚合。聚合用黄色便利贴表示。

图 5.10 显示了事件风暴工作坊的结果。在短短几个小时里,参与者确定了众多领域事件、命令和聚合。这是创建领域模型过程中的良好第一步。

图 5.10. 持续了几个小时的事件风暴工作坊的结果。便利贴代表事件,它们沿着时间线排列;命令,代表用户操作;以及聚合,它们在接收到命令时发出事件。

事件风暴是一种快速创建领域模型的有用技术。

现在我们已经介绍了领域事件的基础知识,让我们看看生成和发布它们的机制。

5.3.5. 生成和发布领域事件

使用领域事件进行通信是一种异步消息传递的形式,这在第三章中讨论过。但在业务逻辑可以将它们发布到消息代理之前,它必须首先创建它们。让我们看看如何做到这一点。

生成领域事件

从概念上讲,领域事件是由聚合发布的。聚合知道其状态何时改变,因此知道要发布什么事件。聚合可以直接调用消息 API。这种方法的缺点是,由于聚合不能使用依赖注入,消息 API 需要作为方法参数传递。这将基础设施关注点和业务逻辑交织在一起,这是极其不希望的。

更好的方法是让聚合和调用它的服务(或等效类)分担责任。服务可以使用依赖注入来获取消息 API 的引用,轻松发布事件。聚合在其状态改变时生成事件,并将它们返回给服务。聚合将事件返回给服务的方式有几种。一种选择是聚合方法的返回值包括事件列表。例如,以下列表显示了Ticket聚合的accept()方法如何向其调用者返回TicketAcceptedEvent

列表 5.3. Ticket聚合的accept()方法
public class Ticket {

   public List<DomainEvent> accept(ZonedDateTime readyBy) {
    ...
    this.acceptTime = ZonedDateTime.now();                       *1*
    this.readyBy = readyBy;
    return singletonList(new TicketAcceptedEvent(readyBy));      *2*
   }
}
  • 1 更新票据

  • 2 返回一个事件

服务调用聚合根的方法,然后发布事件。例如,以下列表显示了KitchenService如何调用Ticket.accept()并发布事件。

列表 5.4. KitchenService调用Ticket.accept()
public class KitchenService {

  @Autowired
  private TicketRepository ticketRepository;

  @Autowired
  private DomainEventPublisher domainEventPublisher;

  public void accept(long ticketId, ZonedDateTime readyBy) {
    Ticket ticket =
          ticketRepository.findById(ticketId)
            .orElseThrow(() ->
                      new TicketNotFoundException(ticketId));
    List<DomainEvent> events = ticket.accept(readyBy);
    domainEventPublisher.publish(Ticket.class, orderId, events);      *1*
  }
  • 1 发布领域事件

accept() 方法首先调用 TicketRepository 从数据库中加载 Ticket。然后通过调用 accept() 更新 TicketKitchenService 然后通过调用 DomainEventPublisher.publish() 发布 Ticket 返回的事件,这将在稍后进行描述。

这种方法相当简单。原本应该返回 void 类型的方法现在返回 List<Event>。唯一的潜在缺点是,非 void 方法的返回类型现在更复杂。它们必须返回一个包含原始返回值和 List<Event> 的对象。你很快就会看到一个这样的方法的例子。

另一个选项是聚合根在字段中累积事件。然后服务检索事件并发布它们。例如,以下列表显示了一个以这种方式工作的 Ticket 类的变体。

列表 5.5。Ticket 扩展了一个超类,该超类记录领域事件
public class Ticket extends AbstractAggregateRoot {

  public void accept(ZonedDateTime readyBy) {
    ...
    this.acceptTime = ZonedDateTime.now();
    this.readyBy = readyBy;
    registerDomainEvent(new TicketAcceptedEvent(readyBy));
  }

}

Ticket 扩展了 AbstractAggregateRoot,它定义了一个 registerDomainEvent() 方法来记录事件。服务将调用 AbstractAggregateRoot.getDomainEvents() 来检索这些事件。

我的偏好是第一种选项:将事件返回给服务的方法。但是,在聚合根中累积事件也是一个可行的选项。事实上,Spring Data Ingalls 版本列车(spring.io/blog/2017/01/30/what-s-new-in-spring-data-release-ingalls)实现了一个机制,该机制自动将事件发布到 Spring ApplicationContext。主要的缺点是,为了减少代码重复,聚合根应该扩展一个超类,如 AbstractAggregateRoot,这可能与扩展其他超类的要求相冲突。另一个问题是,尽管聚合根的方法调用 registerDomainEvent() 很容易,但聚合中其他类的方法会发现这很具挑战性。它们很可能会需要以某种方式将事件传递给聚合根。

如何可靠地发布领域事件?

第三章 讨论了如何在本地数据库事务中可靠地发送消息。领域事件并无不同。服务必须使用事务消息来发布事件,以确保它们作为更新数据库中聚合的事务的一部分发布。在第三章(kindle_split_011.xhtml#ch03)中描述的 Eventuate Tram 框架实现了这样的机制。它将事件插入到更新数据库的 ACID 事务中的 OUTBOX 表中。在事务提交后,插入到 OUTBOX 表中的事件随后被发布到消息代理。

Tram 框架提供了一个 DomainEventPublisher 接口,如下所示。它定义了几个重载的 publish() 方法,这些方法接受聚合类型和 ID 作为参数,以及一个领域事件列表。

列表 5.6. Eventuate Tram 框架的 DomainEventPublisher 接口
public interface DomainEventPublisher {
 void publish(String aggregateType, Object aggregateId,
     List<DomainEvent> domainEvents);

它使用 Eventuate Tram 框架的 MessageProducer 接口以事务方式发布这些事件。

服务可以直接调用 DomainEventPublisher 发布者。但这样做的一个缺点是它不能确保服务只发布有效的事件。例如,KitchenService 应该只发布实现 TicketDomainEvent 的事件,这是 Ticket 聚合事件的标记接口。更好的选择是服务实现 AbstractAggregateDomainEventPublisher 的子类,这在 列表 5.7 中展示。AbstractAggregateDomainEventPublisher 是一个抽象类,它为发布领域事件提供了一个类型安全的接口。它是一个泛型类,有两个类型参数,A 是聚合类型,E 是领域事件的标记接口类型。服务通过调用 publish() 方法发布事件,该方法有两个参数:类型为 A 的聚合和类型为 E 的事件列表。

列表 5.7. 类型安全领域事件发布器的抽象超类
public abstract class AbstractAggregateDomainEventPublisher<A, E extends Doma
     inEvent> {
  private Function<A, Object> idSupplier;
  private DomainEventPublisher eventPublisher;
  private Class<A> aggregateType;

  protected AbstractAggregateDomainEventPublisher(
     DomainEventPublisher eventPublisher,
     Class<A> aggregateType,
     Function<A, Object> idSupplier) {
    this.eventPublisher = eventPublisher;
    this.aggregateType = aggregateType;
    this.idSupplier = idSupplier;
  }

  public void publish(A aggregate, List<E> events) {
    eventPublisher.publish(aggregateType, idSupplier.apply(aggregate),
     (List<DomainEvent>) events);
  }

}

publish() 方法检索聚合的 ID 并调用 DomainEventPublisher.publish()。以下列表展示了 TicketDomainEventPublisher,它为 Ticket 聚合发布领域事件。

列表 5.8. 发布 Ticket 聚合领域事件的类型安全接口
public class TicketDomainEventPublisher extends
     AbstractAggregateDomainEventPublisher<Ticket, TicketDomainEvent> {

  public TicketDomainEventPublisher(DomainEventPublisher eventPublisher) {
    super(eventPublisher, Ticket.class, Ticket::getId);
  }

}

此类只发布是 TicketDomainEvent 子类的事件。

现在我们已经了解了如何发布领域事件,接下来让我们看看如何消费它们。

5.3.6. 消费领域事件

领域事件最终被发布为消息到消息代理,例如 Apache Kafka。消费者可以直接使用代理的客户端 API。但使用更高层次的 API,例如 Eventuate Tram 框架中的 DomainEventDispatcher 更为方便,这在 第三章 中有描述。DomainEventDispatcher 将领域事件分发给相应的处理方法。列表 5.9 展示了一个示例事件处理器类。KitchenServiceEventConsumer 订阅由 Restaurant Service 发布的事件,每当餐厅的菜单更新时。它负责保持 Kitchen Service 的数据副本是最新的。

列表 5.9. 将事件分发给事件处理方法
public class KitchenServiceEventConsumer {
  @Autowired
  private RestaurantService restaurantService;

  public DomainEventHandlers domainEventHandlers() {                         *1*
     return DomainEventHandlersBuilder
      .forAggregateType("net.chrisrichardson.ftgo.restaurantservice.Restaurant")
      .onEvent(RestaurantMenuRevised.class, this::reviseMenu)
      .build();
  }

  public void reviseMenu(DomainEventEnvelope<RestaurantMenuRevised> de) {    *2*
    long id = Long.parseLong(de.getAggregateId());
    RestaurantMenu revisedMenu = de.getEvent().getRevisedMenu();
    restaurantService.reviseMenu(id, revisedMenu);
  }

}
  • 1 将事件映射到事件处理器

  • 2 RestaurantMenuRevised 事件的处理器

reviseMenu() 方法处理 RestaurantMenuRevised 事件。它调用 restaurantService.reviseMenu(),更新餐厅的菜单。该方法返回一个领域事件列表,这些事件由事件处理器发布。

现在我们已经了解了聚合和领域事件,是时候考虑一些使用聚合实现的示例业务逻辑了。

5.4. 厨房服务业务逻辑

第一个例子是Kitchen Service,它使餐厅能够管理他们的订单。该服务中的两个主要聚合是RestaurantTicket聚合。Restaurant聚合了解餐厅的菜单和营业时间,并可以验证订单。Ticket代表餐厅必须为快递员准备的订单。图 5.11显示了这些聚合以及服务业务逻辑的其他关键部分,以及服务的适配器。

图 5.11. Kitchen Service的设计

图片

除了聚合之外,Kitchen Service的业务逻辑的其他主要部分是KitchenServiceTicketRepositoryRestaurantRepositoryKitchenService是业务逻辑的入口。它定义了创建和更新RestaurantTicket聚合的方法。TicketRepositoryRestaurantRepository分别定义了持久化TicketsRestaurants的方法。

Kitchen Service 服务有三个入站适配器:

  • REST API 由餐厅工作人员使用的用户界面调用的 REST API。它调用KitchenService来创建和更新Tickets

  • KitchenServiceCommandHandler 由 sagas 调用的基于异步请求/响应的 API。它调用KitchenService来创建和更新Tickets

  • KitchenServiceEventConsumer 订阅由Restaurant Service发布的事件。它调用KitchenService来创建和更新Restaurants

该服务还有两个出站适配器:

  • DB adapter 实现了TicketRepositoryRestaurantRepository接口并访问数据库。

  • DomainEventPublishingAdapter 实现了DomainEventPublisher接口并发布Ticket领域事件。

让我们更详细地看看KitchenService的设计,从Ticket聚合开始。

5.4.1. 票据聚合

TicketKitchen Service的聚合之一。如第二章中所述,当谈论边界上下文的概念时,这个聚合代表了餐厅厨房对订单的视图。它不包含有关消费者的信息,例如他们的身份、配送信息或支付详情。它专注于使餐厅的厨房能够为取货准备Order。此外,KitchenService不为这个聚合生成唯一的 ID。相反,它使用OrderService提供的 ID。

让我们首先看看这个类的结构,然后我们将检查其方法。

票据类结构

下面的列表显示了该类的代码摘录。Ticket类类似于传统的领域类。主要区别是其他聚合的引用是通过主键进行的。

列表 5.10. Ticket类的一部分,它是一个 JPA 实体
@Entity(table="tickets")
public class Ticket {

  @Id
  private Long id;
  private TicketState state;
  private Long restaurantId;

  @ElementCollection
  @CollectionTable(name="ticket_line_items")
  private List<TicketLineItem> lineItems;

  private ZonedDateTime readyBy;
  private ZonedDateTime acceptTime;
  private ZonedDateTime preparingTime;
  private ZonedDateTime pickedUpTime;
  private ZonedDateTime readyForPickupTime;
  ...

此类使用 JPA 持久化,并映射到 TICKETS 表。restaurantId 字段是一个 Long 而不是一个指向 Restaurant 对象的引用。readyBy 字段存储订单预计可以取走的时间。Ticket 类有多个字段跟踪订单的历史,包括 acceptTimepreparingTimepickupTime。让我们看看这个类的其他方法。

Ticket 聚合的行为

Ticket 聚合定义了几个方法。正如您之前所看到的,它有一个静态的 create() 方法,这是一个工厂方法,用于创建一个 Ticket。还有一些方法在餐厅更新订单状态时会被调用:

  • accept() 餐厅已接受订单。

  • preparing() 餐厅已经开始准备订单,这意味着订单不能再更改或取消。

  • readyForPickup() 订单现在可以取走了。

以下列表显示了其中的一些方法。

列表 5.11. Ticket 的部分方法
public class Ticket {

public static ResultWithAggregateEvents<Ticket, TicketDomainEvent>
     create(Long id, TicketDetails details) {
  return new ResultWithAggregateEvents<>(new Ticket(id, details), new
     TicketCreatedEvent(id, details));
}

public List<TicketPreparationStartedEvent> preparing() {
  switch (state) {
    case ACCEPTED:
      this.state = TicketState.PREPARING;
      this.preparingTime = ZonedDateTime.now();
      return singletonList(new TicketPreparationStartedEvent());
    default:
      throw new UnsupportedStateTransitionException(state);
  }
}

public List<TicketDomainEvent> cancel() {
    switch (state) {
      case CREATED:
      case ACCEPTED:
        this.state = TicketState.CANCELLED;
        return singletonList(new TicketCancelled());
      case READY_FOR_PICKUP:
        throw new TicketCannotBeCancelledException();

      default:
        throw new UnsupportedStateTransitionException(state);

    }
  }

create() 方法创建一个 Ticket。当餐厅开始准备订单时,会调用 preparing() 方法。它将订单状态更改为 PREPARING,记录时间,并发布一个事件。当用户尝试取消订单时,会调用 cancel() 方法。如果允许取消,此方法将更改订单状态并返回一个事件。否则,它抛出异常。这些方法在响应 REST API 请求以及事件和命令消息时被调用。让我们看看调用聚合方法类的示例。

KitchenService 领域服务

KitchenService 由服务的入站适配器调用。它定义了各种更改订单状态的方法,包括 accept()reject()preparing() 等。每个方法都加载指定的聚合,在聚合根上调用相应的方法,并发布任何领域事件。以下列表显示了它的 accept() 方法。

列表 5.12. 服务的 accept() 方法更新 Ticket
public class KitchenService {

  @Autowired
  private TicketRepository ticketRepository;

  @Autowired
  private TicketDomainEventPublisher domainEventPublisher;

  public void accept(long ticketId, ZonedDateTime readyBy) {
    Ticket ticket =
          ticketRepository.findById(ticketId)
            .orElseThrow(() ->
                      new TicketNotFoundException(ticketId));
    List<TicketDomainEvent> events = ticket.accept(readyBy);
    domainEventPublisher.publish(ticket, events);                *1*
  }

}
  • 1 发布领域事件

当餐厅接受新订单时,会调用 accept() 方法。它有两个参数:

  • orderId 要接受的订单 ID

  • readyBy 订单预计可以取走的时间

此方法检索 Ticket 聚合并调用其 accept() 方法。它发布任何生成的事件。

现在让我们看看处理异步命令的类。

KitchenServiceCommandHandler

KitchenServiceCommandHandler 类是一个适配器,负责处理由 Order Service 实现的各个 sagas 发送的命令消息。此类为每个命令定义了一个处理程序方法,调用 KitchenService 创建或更新 Ticket。以下列表显示了此类的摘录。

列表 5.13. 处理 sagas 发送的命令消息
public class KitchenServiceCommandHandler {

  @Autowired
  private KitchenService kitchenService;

  public CommandHandlers commandHandlers() {                        *1*
   return CommandHandlersBuilder
          .fromChannel("orderService")
          .onMessage(CreateTicket.class, this::createTicket)
          .onMessage(ConfirmCreateTicket.class,
                  this::confirmCreateTicket)
          .onMessage(CancelCreateTicket.class,
                  this::cancelCreateTicket)
          .build();
 }

 private Message createTicket(CommandMessage<CreateTicket>
                                               cm) {
  CreateTicket command = cm.getCommand();
  long restaurantId = command.getRestaurantId();
  Long ticketId = command.getOrderId();
  TicketDetails ticketDetails =
      command.getTicketDetails();

  try {
    Ticket ticket =                                                 *2*
       kitchenService.createTicket(restaurantId,
                                   ticketId, ticketDetails);
    CreateTicketReply reply =
                new CreateTicketReply(ticket.getId());
    return withSuccess(reply);                                      *3*
   } catch (RestaurantDetailsVerificationException e) {
    return withFailure();                                           *4*
   }
 }

 private Message confirmCreateTicket
         (CommandMessage<ConfirmCreateTicket> cm) {                 *5*
      Long ticketId = cm.getCommand().getTicketId();
     kitchenService.confirmCreateTicket(ticketId);
     return withSuccess();
 }

   ...
  • 1 将命令消息映射到消息处理器

  • 2 调用 KitchenService 创建 Ticket

  • 3 发送成功回复

  • 4 发送失败回复

  • 5 确认订单

所有命令处理方法都调用 KitchenService 并以成功或失败回复进行回复。

现在您已经看到了一个相对简单的服务的业务逻辑,我们将看看一个更复杂的例子:Order Service

5.5. 订单服务业务逻辑

如前几章所述,Order Service 提供了一个用于创建、更新和取消订单的 API。此 API 主要由消费者调用。图 5.12 展示了该服务的高级设计。Order 聚合是 Order Service 的中心聚合。但还有一个 Restaurant 聚合,它是 Restaurant Service 所拥有数据的部分副本。它使 Order Service 能够验证和定价 Order 的行项目。

图 5.12. Order Service 的设计。它有一个用于管理订单的 REST API。它通过几个消息通道与其他服务交换消息和事件。

除了 OrderRestaurant 聚合之外,业务逻辑还包括 OrderServiceOrderRepositoryRestaurantRepository 以及各种 saga,例如在第四章中描述的 CreateOrderSagaOrderService 是业务逻辑的主要入口点,并定义了创建和更新 OrdersRestaurants 的方法。OrderRepository 定义了持久化 Orders 的方法,而 RestaurantRepository 则有持久化 Restaurants 的方法。Order Service 有几个入站适配器:

  • REST API 用户界面调用的 REST API。它调用 OrderService 来创建和更新 Orders

  • OrderEventConsumer 订阅由 Restaurant Service 发布的事件。它调用 OrderService 来创建和更新其 Restaurants 的副本。

  • OrderCommandHandlers 由 sagas 调用的基于异步请求/响应的 API。它调用 OrderService 来更新 Orders

  • SagaReplyAdapter 订阅 saga 回复通道并调用 sagas。

该服务还有一些出站适配器:

  • DB adapter 实现 OrderRepository 接口并访问 Order Service 数据库

  • DomainEventPublishingAdapter 实现 DomainEventPublisher 接口并发布 Order 领域事件

  • OutboundCommandMessageAdapter 实现 CommandPublisher 接口并向 saga 参与者发送命令消息

让我们先仔细看看 Order 聚合,然后再检查 OrderService

5.5.1. 订单聚合

Order 聚合代表消费者下的一张订单。我们首先将查看 Order 聚合的结构,然后检查其方法。

订单聚合的结构

图 5.13 展示了 Order 聚合的结构。Order 类是 Order 聚合的根。Order 聚合还包括诸如 OrderLineItemDeliveryInfoPaymentInfo 这样的值对象。

图 5.13. 由 Order 聚合根和各种值对象组成的 Order 聚合的设计。

Order 类有一个 OrderLineItems 集合。因为 OrderConsumerRestaurant 是其他聚合,所以它通过主键值引用它们。Order 类有一个 DeliveryInfo 类,用于存储送货地址和期望的送货时间,以及一个 PaymentInfo,用于存储支付信息。以下列表显示了代码。

列表 5.14. Order 类及其字段
@Entity
@Table(name="orders")
@Access(AccessType.FIELD)
public class Order {

  @Id
  @GeneratedValue
  private Long id;

  @Version
  private Long version;

  private OrderState state;
  private Long consumerId;
  private Long restaurantId;

  @Embedded
  private OrderLineItems orderLineItems;

  @Embedded
  private DeliveryInformation deliveryInformation;

  @Embedded
  private PaymentInformation paymentInformation;

  @Embedded
  private Money orderMinimum;

这个类使用 JPA 进行持久化,并映射到 ORDERS 表。id 字段是主键。version 字段用于乐观锁。Order 的状态由 OrderState 枚举表示。DeliveryInformationPaymentInformation 字段使用 @Embedded 注解进行映射,并存储为 ORDERS 表的列。orderLineItems 字段是一个包含订单行项的嵌入对象。Order 聚合不仅包含字段,还实现了业务逻辑,这可以通过状态机来描述。让我们看看状态机。

Order 聚合状态机

为了创建或更新订单,Order Service 必须与其他服务协作使用 sagas。要么 OrderService 或 saga 的第一步调用一个 Order 方法来验证操作是否可以执行,并将 Order 的状态更改为待处理状态。正如 第四章 中解释的,待处理 状态是语义锁对策的一个例子,有助于确保 sagas 之间相互隔离。最终,一旦 saga 调用了参与的服务,它就会更新 Order 以反映结果。例如,正如 第四章 中描述的,Create Order Saga 有多个参与服务,包括 Consumer ServiceAccounting ServiceKitchen ServiceOrderService 首先以 APPROVAL_PENDING 状态创建一个 Order,然后稍后将其状态更改为 APPROVEDREJECTEDOrder 的行为可以建模为 图 5.14 中所示的状态机。

图 5.14. Order 聚合状态机模型的一部分

类似地,其他 Order Service 操作,如 revise()cancel(),首先将 Order 改为挂起状态,并使用 saga 来验证操作是否可以执行。一旦 saga 验证操作可以执行,它将 Order 转换为反映操作成功结果的其他状态。如果操作验证失败,Order 将恢复到之前的状态。例如,cancel() 操作首先将 Order 转换为 CANCEL_PENDING 状态。如果订单可以被取消,Cancel Order SagaOrder 的状态改为 CANCELLED 状态。否则,如果由于例如取消订单太晚等原因,cancel() 操作被拒绝,那么 Order 将转换回 APPROVED 状态。

现在我们来看看 Order 聚合是如何实现这个状态机的。

Order 聚合的方法

Order 类有多个方法组,每个组对应一个 saga。在每个组中,一个方法在 saga 的开始时被调用,其他方法在 saga 的结束时被调用。我首先将讨论创建 Order 的业务逻辑。之后,我们将看看如何更新 Order。以下列表显示了在创建 Order 的过程中被调用的 Order 的方法。

列表 5.15. 订单创建过程中调用的方法
public class Order { ...

  public static ResultWithDomainEvents<Order, OrderDomainEvent>
   createOrder(long consumerId, Restaurant restaurant,
                                        List<OrderLineItem> orderLineItems) {
    Order order = new Order(consumerId, restaurant.getId(), orderLineItems);
    List<OrderDomainEvent> events = singletonList(new OrderCreatedEvent(
            new OrderDetails(consumerId, restaurant.getId(), orderLineItems,
                    order.getOrderTotal()),
            restaurant.getName()));
    return new ResultWithDomainEvents<>(order, events);
  }

  public Order(OrderDetails orderDetails) {
    this.orderLineItems = new OrderLineItems(orderDetails.getLineItems());
    this.orderMinimum = orderDetails.getOrderMinimum();
    this.state = APPROVAL_PENDING;
  }
  ...

  public List<DomainEvent> noteApproved() {
    switch (state) {
      case APPROVAL_PENDING:
        this.state = APPROVED;
        return singletonList(new OrderAuthorized());
      ...
      default:
        throw new UnsupportedStateTransitionException(state);
    }
  }

  public List<DomainEvent> noteRejected() {
    switch (state) {
      case APPROVAL_PENDING:
        this.state = REJECTED;
        return singletonList(new OrderRejected());
        ...
      default:
        throw new UnsupportedStateTransitionException(state);
    }

  }

createOrder() 方法是一个静态工厂方法,用于创建一个订单并发布一个 OrderCreatedEventOrderCreatedEvent 包含了订单的详细信息,包括行项目、总金额、餐厅 ID 和餐厅名称。第七章 讨论了 Order History Service 如何使用 Order 事件,包括 OrderCreatedEvent,来维护一个易于查询的 Orders 复制品。

Order 的初始状态为 APPROVAL_PENDING。当 CreateOrderSaga 完成时,它将调用 noteApproved()noteRejected() 中的一个。当消费者的信用卡成功授权时,将调用 noteApproved() 方法。当其中一个服务拒绝订单或授权失败时,将调用 noteRejected() 方法。正如你所见,Order 聚合的 state 决定了其大多数方法的行为。像 Ticket 聚合一样,它也会发出事件。

除了 createOrder()Order 类还定义了几个更新方法。例如,Revise Order Saga 通过首先调用 revise() 方法,然后验证修订可以执行后,再调用 confirmRevised() 方法来修改订单。以下列表显示了这些方法。

列表 5.16. 修改 OrderOrder 方法
class Order ...

  public List<OrderDomainEvent> revise(OrderRevision orderRevision) {
    switch (state) {

      case APPROVED:
        LineItemQuantityChange change =
                orderLineItems.lineItemQuantityChange(orderRevision);
        if (change.newOrderTotal.isGreaterThanOrEqual(orderMinimum)) {
          throw new OrderMinimumNotMetException();
        }
        this.state = REVISION_PENDING;
        return singletonList(new OrderRevisionProposed(orderRevision,
                          change.currentOrderTotal, change.newOrderTotal));

      default:
        throw new UnsupportedStateTransitionException(state);
    }
  }

  public List<OrderDomainEvent> confirmRevision(OrderRevision orderRevision) {
    switch (state) {
      case REVISION_PENDING:
        LineItemQuantityChange licd =
          orderLineItems.lineItemQuantityChange(orderRevision);

        orderRevision
              .getDeliveryInformation()
              .ifPresent(newDi -> this.deliveryInformation = newDi);

        if (!orderRevision.getRevisedLineItemQuantities().isEmpty()) {
          orderLineItems.updateLineItems(orderRevision);
        }

        this.state = APPROVED;
        return singletonList(new OrderRevised(orderRevision,
                          licd.currentOrderTotal, licd.newOrderTotal));
      default:
        throw new UnsupportedStateTransitionException(state);
    }
  }

}

调用revise()方法来启动订单的修订。除了其他事情之外,它验证修订的订单不会违反订单最低限额,并将订单的状态更改为REVISION_PENDING。一旦Revise Order Saga成功更新了Kitchen ServiceAccounting Service,它随后调用confirmRevision()来完成修订。

这些方法由OrderService调用。让我们看看那个类。

5.5.2. OrderService

OrderService类定义了创建和更新Orders的方法。它是进入业务逻辑的主要入口点,并由各种传入适配器调用,例如REST API。它的大多数方法创建一个传奇来编排Order聚合的创建和更新。因此,这个服务比之前讨论的KitchenService类更复杂。以下列表显示了该类的摘录。OrderService被注入了各种依赖项,包括OrderRepositoryOrderDomainEventPublisher和几个传奇管理器。它定义了包括createOrder()reviseOrder()在内的几个方法。

列表 5.17. OrderService类具有创建和管理订单的方法
@Transactional
public class OrderService {

  @Autowired
  private OrderRepository orderRepository;

  @Autowired
  private SagaManager<CreateOrderSagaState, CreateOrderSagaState>
    createOrderSagaManager;

  @Autowired
  private SagaManager<ReviseOrderSagaState, ReviseOrderSagaData>
    reviseOrderSagaManagement;

  @Autowired
  private OrderDomainEventPublisher orderAggregateEventPublisher;

  public Order createOrder(OrderDetails orderDetails) {

    Restaurant restaurant = restaurantRepository.findById(restaurantId)
            .orElseThrow(() -
     > new RestaurantNotFoundException(restaurantId));

    List<OrderLineItem> orderLineItems =                                  *1*
       makeOrderLineItems(lineItems, restaurant);

    ResultWithDomainEvents<Order, OrderDomainEvent> orderAndEvents =
            Order.createOrder(consumerId, restaurant, orderLineItems);

    Order order = orderAndEvents.result;

    orderRepository.save(order);                                          *2*

    orderAggregateEventPublisher.publish(order, orderAndEvents.events);   *3*

    OrderDetails orderDetails =
      new OrderDetails(consumerId, restaurantId, orderLineItems,
                        order.getOrderTotal());
    CreateOrderSagaState data = new CreateOrderSagaState(order.getId(),
            orderDetails);

    createOrderSagaManager.create(data, Order.class, order.getId());      *4*

    return order;
  }

  public Order reviseOrder(Long orderId, Long expectedVersion,
                                OrderRevision orderRevision)  {
    public Order reviseOrder(long orderId, OrderRevision orderRevision) {
      Order order = orderRepository.findById(orderId)                     *5*
               .orElseThrow(() -> new OrderNotFoundException(orderId));
      ReviseOrderSagaData sagaData =
        new ReviseOrderSagaData(order.getConsumerId(), orderId,
              null, orderRevision);
      reviseOrderSagaManager.create(sagaData);                            *6*
       return order;
    }
  }
  • 1 创建订单聚合

  • 2 在数据库中持久化订单

  • 3 发布领域事件

  • 4 创建创建订单传奇

  • 5 获取订单

  • 6 创建修订订单传奇

createOrder()方法首先创建和持久化Order聚合。然后,它发布由聚合发出的领域事件。最后,它创建一个CreateOrderSagareviseOrder()检索Order然后创建一个ReviseOrderSaga

在许多方面,基于微服务的应用程序的业务逻辑与传统单体应用程序的业务逻辑并没有太大的不同。它由服务、JPA 支持的实体和存储库等类组成。尽管如此,也有一些不同之处。领域模型组织为一系列 DDD 聚合,这些聚合施加了各种设计约束。与传统对象模型不同,不同聚合之间类的引用是主键值而不是对象引用。此外,事务只能创建或更新单个聚合。当聚合的状态发生变化时,发布领域事件对聚合也很有用。

另一个主要区别是,服务通常使用传奇(sagas)来维护多个服务之间的数据一致性。例如,Kitchen Service 仅参与传奇,它不会启动它们。相比之下,Order Service 在创建和更新订单时严重依赖传奇。这是因为Orders必须与属于其他服务的数据进行事务一致性。因此,大多数OrderService方法创建一个传奇而不是直接更新Order

本章介绍了如何使用传统的持久化方法来实现业务逻辑。这包括将消息传递和事件发布与数据库事务管理集成。事件发布代码与业务逻辑交织在一起。下一章将探讨事件溯源,这是一种以事件为中心的方法来编写业务逻辑,其中事件生成是业务逻辑的组成部分,而不是附加的。

摘要

  • 程序性事务脚本模式通常是实现简单业务逻辑的好方法。但在实现复杂业务逻辑时,应考虑使用面向对象的领域模型模式。

  • 将服务业务逻辑组织为 DDD 聚合集合是一个好方法。DDD 聚合很有用,因为它们模块化了领域模型,消除了服务之间对象引用的可能性,并确保每个 ACID 事务都在服务内部。

  • 当聚合创建或更新时,应该发布领域事件。领域事件有广泛的应用。第四章讨论了它们如何实现基于编排的叙事。在第七章中,我谈论了如何使用领域事件来更新复制数据。领域事件订阅者还可以通知用户和其他应用程序,并向用户的浏览器发布 WebSocket 消息。

第六章. 使用事件源开发业务逻辑

本章涵盖

  • 使用事件源模式开发业务逻辑

  • 实现事件存储库

  • 集成剧情和基于事件源的业务逻辑

  • 使用事件源实现剧情协调器

玛丽喜欢第五章中描述的想法,即把业务逻辑结构化为发布领域事件的 DDD 聚合体的集合。她可以想象这些事件在微服务架构中的使用将非常有用。玛丽计划使用事件来实现基于剧情的剧情协调器,这些协调器在服务之间维护数据一致性,并在第四章中描述。她还预计将使用 CQRS 视图,这些视图是支持高效查询的副本,在第七章中描述。

然而,她担心事件发布逻辑可能存在错误。一方面,事件发布逻辑相对直接。聚合体中每个初始化或更改聚合体状态的方法的返回值都是一个事件列表。领域服务随后发布这些事件。但另一方面,事件发布逻辑是附加到业务逻辑上的。即使开发者忘记发布事件,业务逻辑仍然可以继续工作。玛丽担心这种发布事件的方式可能成为错误源。

多年前,玛丽了解到事件源,这是一种以事件为中心编写业务逻辑和持久化领域对象的方法。当时她对其众多好处感到好奇,包括它如何保留聚合体变更的完整历史,但它仍然是一个谜。鉴于领域事件在微服务架构中的重要性,她现在想知道在 FTGO 应用程序中使用事件源是否值得探索。毕竟,事件源通过保证在创建或更新聚合体时将发布事件,从而消除了编程错误的一个来源。

我在本章开始时将描述事件源的工作原理以及如何使用它来编写业务逻辑。我描述了事件源如何在所谓的事件存储中将每个聚合体持久化为一系列事件。我讨论了事件源的好处和缺点,并涵盖了如何实现事件存储库。我描述了一个编写基于事件源的业务逻辑的简单框架。之后,我讨论了事件源是如何成为实现剧情的良好基础的。让我们先看看如何使用事件源开发业务逻辑。

6.1. 使用事件源开发业务逻辑

事件源是一种不同的业务逻辑结构和聚合体持久化的方式。它将聚合体持久化为一系列事件。每个事件代表聚合体的状态变化。应用程序通过重放事件来重新创建聚合体的当前状态。

模式:事件源

将聚合持久化为一系列表示状态变化的领域事件序列。参见microservices.io/patterns/data/event-sourcing.html

事件溯源有几个重要的优点。例如,它保留了聚合的历史,这对于审计和监管目的非常有价值。它还可靠地发布领域事件,这在微服务架构中特别有用。事件溯源也有一些缺点。它涉及一个学习曲线,因为这是一种编写业务逻辑的不同方式。此外,查询事件存储通常很困难,这需要你使用第七章中描述的 CQRS 模式,第七章。

我在本节开始时描述了传统持久化的局限性。然后,我详细介绍了事件溯源,并讨论了它如何克服这些局限性。之后,我展示了如何使用事件溯源实现Order聚合。最后,我描述了事件溯源的优点和缺点。

首先,让我们看看传统持久化方法的局限性。

6.1.1. 传统持久化的麻烦

传统持久化方法将类映射到数据库表,将这些类的字段映射到表列,并将这些类的实例映射到表中的行。例如,图 6.1 显示了第五章中描述的Order聚合如何映射到ORDER表。它的OrderLineItems映射到ORDER_LINE_ITEM表。

图 6.1. 传统持久化方法将类映射到表,并将对象映射到这些表中的行。

应用程序将订单实例持久化为ORDERORDER_LINE_ITEM表中的行。它可能使用 ORM 框架,如 JPA,或更低级别的框架,如 MyBATIS 来实现这一点。

这种方法显然效果很好,因为大多数企业应用程序都是这样存储数据的。但它有几个缺点和局限性:

  • 对象-关系阻抗不匹配。

  • 缺乏聚合历史。

  • 实现审计日志既繁琐又容易出错。

  • 事件发布被附加到业务逻辑上。

让我们逐一分析这些问题,首先是对象-关系阻抗不匹配问题。

对象-关系阻抗不匹配

一个古老的难题是所谓的对象-关系阻抗不匹配问题。在表格关系模式与具有复杂关系的丰富领域模型的图结构之间存在根本的概念不匹配。这个问题的某些方面反映在关于对象/关系映射(ORM)框架适用性的两极分化辩论中。例如,Ted Neward 曾经说过:“对象-关系映射是计算机科学的越南”(blogs.tedneward.com/post/the-vietnam-of-computer-science/)。公平地说,我已经成功地使用 Hibernate 开发了一些应用程序,其中数据库模式是从对象模型派生出来的。但问题比任何特定 ORM 框架的限制要深。

缺乏聚合历史记录

传统持久化的另一个局限性是它只存储聚合的当前状态。一旦聚合被更新,其先前状态就会丢失。如果一个应用程序必须保留聚合的历史记录,可能出于监管目的,那么开发人员必须自己实现这个机制。实现聚合历史记录机制既耗时又涉及复制必须与业务逻辑同步的代码。

实施审计日志记录是繁琐且容易出错的

另一个问题也是审计日志。许多应用程序必须维护一个审计日志,以追踪哪些用户更改了聚合数据。一些应用程序需要出于安全或监管目的进行审计。在其他应用程序中,用户行为的历史记录是一个重要的功能。例如,问题跟踪器和任务管理应用程序,如 Asana 和 JIRA,会显示任务和问题的更改历史。实施审计的挑战在于,除了是一个耗时的工作外,审计日志代码和业务逻辑可能会出现分歧,从而导致错误。

事件发布被附加到业务逻辑上

传统持久化的另一个局限性是它通常不支持发布领域事件。领域事件在第五章(kindle_split_013.xhtml#ch05)中讨论过,是聚合状态改变时发布的事件。它们是同步数据和在微服务架构中发送通知的有用机制。一些 ORM 框架,如 Hibernate,可以在数据对象更改时调用应用程序提供的回调。但是,没有支持作为更新数据的交易的一部分自动发布消息。因此,与历史和审计一样,开发人员必须附加事件生成逻辑,这可能导致与业务逻辑不同步。幸运的是,这些问题有解决方案:事件源。

6.1.2. 事件源概述

事件源是一种以事件为中心的技术,用于实现业务逻辑和持久化聚合。聚合作为一系列事件存储在数据库中。每个事件代表聚合的状态变化。聚合的业务逻辑围绕产生和消费这些事件的要求来构建。让我们看看它是如何工作的。

事件源使用事件持久化聚合

在前面的 6.1.1 节中,我讨论了传统持久化如何将聚合映射到表,将它们的字段映射到列,将它们的实例映射到行。事件源是一种非常不同的持久化聚合的方法,它建立在领域事件的概念之上。它将每个聚合作为一系列事件持久化到数据库中,称为事件存储。

例如,考虑Order聚合。如图 6.2 所示,事件源不是将每个Order存储在ORDER表中的一行,而是将每个Order聚合持久化为EVENTS表中的一行或多行。每一行都是一个领域事件,例如Order CreatedOrder ApprovedOrder Shipped等。

图 6.2. 事件源将每个聚合持久化为一系列事件。基于 RDBMS 的应用程序可以将事件存储在EVENTS表中。

图片

当一个应用程序创建或更新一个聚合时,它会将聚合发出的事件插入到EVENTS表中。应用程序通过检索其事件并重新播放它们来从事件存储中加载聚合。具体来说,加载聚合包括以下三个步骤:

  1. 加载聚合的事件。

  2. 使用其默认构造函数创建聚合实例。

  3. 遍历事件,调用apply()

例如,Eventuate 客户端框架(在 6.2.2 节中介绍)使用类似于以下代码来重建一个聚合:

Class aggregateClass = ...;
Aggregate aggregate = aggregateClass.newInstance();
for (Event event : events) {
  aggregate = aggregate.applyEvent(event);
}
// use aggregate...

它创建类的实例,并遍历事件,调用聚合的applyEvent()方法。如果你熟悉函数式编程,你可能认识这作为一个折叠或归约操作。

通过加载事件并重新播放事件来重建聚合的内存状态可能显得奇怪且不熟悉。但以某种方式,这并不完全不同于 ORM 框架(如 JPA 或 Hibernate)加载实体的方式。ORM 框架通过执行一个或多个SELECT语句来检索当前持久化状态,使用它们的默认构造函数实例化对象。它使用反射来初始化这些对象。事件源的不同之处在于,内存状态的重建是通过使用事件来完成的。

现在我们来看看事件源对领域事件提出的要求。

事件表示状态变化

第五章定义了领域事件为通知订阅者聚合变化的一种机制。事件可以包含最小数据,例如仅包含聚合 ID,或者可以扩展以包含对典型消费者有用的数据。例如,当创建订单时,Order Service可以发布OrderCreated事件。OrderCreated事件可能仅包含orderId。或者,事件可以包含完整的订单,这样该事件的消费者就不需要从Order Service获取数据。事件是否发布以及事件包含的内容是由消费者的需求驱动的。然而,在使用事件溯源时,主要是聚合决定了事件及其结构。

在使用事件溯源时,事件不是可选的。聚合的每个状态变化,包括其创建,都由领域事件表示。每当聚合的状态发生变化时,它必须发出一个事件。例如,当创建时,Order聚合必须发出OrderCreated事件,每次更新时发出Order*事件。这是一个比以前更严格的要求,当时聚合只发出对消费者感兴趣的事件。

更重要的是,事件必须包含聚合执行状态转换所需的数据。聚合的状态由构成聚合的对象的字段值组成。状态变化可能只是简单地更改对象的字段值,例如更改Order.state的值。或者,状态变化可以涉及添加或删除对象,例如修改Order的行项目。

假设,如图 6.3 所示,聚合的当前状态是S,新状态是S'。表示状态变化的事件E必须包含数据,使得当Order处于状态S时,调用order.apply(E)将更新Order到状态S'。在下一节中,您将看到apply()是一个执行事件表示的状态变化的方法。

Order处于状态S时,应用事件E必须将Order状态更改为S'。事件必须包含执行状态转换所需的数据。

一些事件,例如Order Shipped事件,包含很少或没有数据,仅仅表示状态转换。apply()方法通过将Order的状态字段更改为SHIPPED来处理Order Shipped事件。然而,其他事件包含大量数据。例如,OrderCreated事件必须包含apply()方法初始化Order所需的所有数据,包括其行项目、支付信息、配送信息等。由于事件用于持久化聚合,因此不再有使用仅包含orderId的最小OrderCreated事件的选项。

聚合方法都是关于事件的

业务逻辑通过在聚合根上调用命令方法来处理更新聚合的请求。在传统应用中,命令方法通常验证其参数,然后更新聚合的一个或多个字段。基于事件源的应用中的命令方法之所以有效,是因为它们必须生成事件。如图 6.4 所示,调用聚合的命令方法会产生一系列事件,这些事件代表了必须进行的州变化。这些事件被保存在数据库中,并应用于聚合以更新其状态。

图 6.4. 处理命令生成事件,而不改变聚合的状态。聚合通过应用事件来更新。

图片

生成事件并将它们应用的要求需要对业务逻辑进行重构——尽管是机械的。事件源将命令方法重构为两个或更多方法。第一个方法接受一个表示请求的命令对象参数,并确定需要执行哪些状态变化。它验证其参数,并在不改变聚合状态的情况下返回表示状态变化的事件列表。如果命令无法执行,此方法通常抛出异常。

其他方法各自接受特定的事件类型作为参数并更新聚合。对于每个事件都有一个这样的方法。重要的是要注意,这些方法不能失败,因为事件代表了一个已经发生的状态变化。每个方法根据事件更新聚合。

Eventuate 客户端框架,一个在第 6.2.2 节中更详细描述的事件源框架,将这些方法命名为 process()apply()。一个 process() 方法接受一个命令对象作为参数,该对象包含更新请求的参数,并返回一个事件列表。一个 apply() 方法接受一个事件作为参数并返回空值。聚合将定义这些方法的多个重载版本:每个命令类一个 process() 方法,以及每个由聚合发出的事件类型一个 apply() 方法。图 6.5 展示了一个示例。

图 6.5. 事件源将更新聚合的方法拆分为一个 process() 方法,它接受一个命令并返回事件,以及一个或多个 apply() 方法,它们接受一个事件并更新聚合。

图片

在这个例子中,reviseOrder()方法被一个process()方法和一个apply()方法所取代。process()方法接受一个ReviseOrder命令作为参数。这个命令类是通过将引入参数对象重构(refactoring.com/catalog/introduceParameterObject.html)应用到reviseOrder()方法中定义的。process()方法要么返回一个OrderRevisionProposed事件,要么在修改Order太晚或提议的修订不符合订单最低要求时抛出异常。OrderRevisionProposed事件的apply()方法将Order的状态更改为REVISION_PENDING

使用以下步骤创建聚合:

  1. 使用默认构造函数实例化聚合根。

  2. 调用process()以生成新事件。

  3. 通过遍历新事件并调用其apply()来更新聚合。

  4. 将新事件保存到事件存储中。

使用以下步骤更新聚合:

  1. 从事件存储中加载聚合的事件。

  2. 使用默认构造函数实例化聚合根。

  3. 遍历加载的事件,在聚合根上调用apply()

  4. 调用其process()方法以生成新事件。

  5. 通过遍历新事件并调用apply()来更新聚合。

  6. 将新事件保存到事件存储中。

为了看到这个动作,现在让我们看看Order聚合的事件源版本。

基于事件源技术的订单聚合

列表 6.1 展示了Order聚合的字段以及负责创建它的方法。Order聚合的事件源版本与第五章中展示的基于 JPA 的版本有一些相似之处。它们的字段几乎相同,并且发出类似的事件。不同之处在于,其业务逻辑是通过处理发出事件和应用的命令来实现的,这更新了其状态。每个创建或更新基于 JPA 的聚合的方法,如createOrder()reviseOrder(),在事件源版本中被process()apply()方法所取代。

列表 6.1. Order聚合的字段及其初始化实例的方法
public class Order {

  private OrderState state;
  private Long consumerId;
  private Long restaurantId;
  private OrderLineItems orderLineItems;
  private DeliveryInformation deliveryInformation;
  private PaymentInformation paymentInformation;
  private Money orderMinimum;

  public Order() {
  }

  public List<Event> process(CreateOrderCommand command) {            *1*
     ... validate command ...
    return events(new OrderCreatedEvent(command.getOrderDetails()));
  }

  public void apply(OrderCreatedEvent event) {                        *2*
    OrderDetails orderDetails = event.getOrderDetails();
    this.orderLineItems = new OrderLineItems(orderDetails.getLineItems());
    this.orderMinimum = orderDetails.getOrderMinimum();
    this.state = APPROVAL_PENDING;
  }
  • 1 验证命令并返回一个 OrderCreatedEvent

  • 2 通过初始化订单的字段来应用 OrderCreatedEvent。

这个类的字段与基于 JPA 的Order类似。唯一的区别是聚合的id不存储在聚合中。Order的方法相当不同。createOrder()工厂方法已被process()apply()方法所取代。process()方法接受一个CreateOrder命令并发出一个OrderCreated事件。apply()方法接受OrderCreated并初始化Order的字段。

现在我们将探讨修改订单的稍微复杂一些的业务逻辑。之前这个业务逻辑由三个方法组成:reviseOrder()confirmRevision()rejectRevision()。事件溯源版本用三个 process() 方法和一些 apply() 方法替换了这三个方法。以下列表展示了 reviseOrder()confirmRevision() 的事件溯源版本。

列表 6.2. 修改 Order 聚合的 process()apply() 方法
public class Order {

public List<Event> process(ReviseOrder command) {                          *1*
  OrderRevision orderRevision = command.getOrderRevision();
  switch (state) {
    case APPROVED:
      LineItemQuantityChange change =
              orderLineItems.lineItemQuantityChange(orderRevision);
      if (change.newOrderTotal.isGreaterThanOrEqual(orderMinimum)) {
        throw new OrderMinimumNotMetException();
      }
      return singletonList(new OrderRevisionProposed(orderRevision,
                            change.currentOrderTotal, change.newOrderTotal));

    default:
      throw new UnsupportedStateTransitionException(state);
  }
}

public void apply(OrderRevisionProposed event) {                           *2*
   this.state = REVISION_PENDING;
}

public List<Event> process(ConfirmReviseOrder command) {                   *3*
  OrderRevision orderRevision = command.getOrderRevision();
  switch (state) {
    case REVISION_PENDING:
      LineItemQuantityChange licd =
            orderLineItems.lineItemQuantityChange(orderRevision);
      return singletonList(new OrderRevised(orderRevision,
              licd.currentOrderTotal, licd.newOrderTotal));
    default:
      throw new UnsupportedStateTransitionException(state);
  }
}

public void apply(OrderRevised event) {                                    *4*
  OrderRevision orderRevision = event.getOrderRevision();
  if (!orderRevision.getRevisedLineItemQuantities().isEmpty()) {
    orderLineItems.updateLineItems(orderRevision);
  }
  this.state = APPROVED;
}
  • 1 验证订单是否可以修改,并且修改后的订单符合订单最低要求。

  • 2 将订单状态更改为 REVISION_PENDING。

  • 3 验证修订是否可以确认,并返回一个 OrderRevised 事件。

  • 4 修改订单。

如您所见,每个方法都被替换为一个 process() 方法和一个或多个 apply() 方法。reviseOrder() 方法被替换为 process (ReviseOrder)apply(OrderRevisionProposed)。同样,confirmRevision() 被替换为 process(ConfirmReviseOrder)apply(OrderRevised)

6.1.3. 使用乐观锁处理并发更新

同时更新同一个聚合体的两个或多个请求并不少见。使用传统持久化的应用程序通常使用乐观锁来防止一个事务覆盖另一个事务的更改。乐观锁通常使用一个版本列来检测聚合体自读取以来是否已更改。应用程序将聚合根映射到一个具有 VERSION 列的表,每当聚合体更新时,该列都会递增。应用程序使用如下 UPDATE 语句更新聚合体:

UPDATE AGGREGATE_ROOT_TABLE
SET VERSION = VERSION + 1 ...
WHERE VERSION = <original version>

只有当版本号自应用程序读取聚合体以来未更改时,此 UPDATE 语句才会成功。如果有两个事务读取相同的聚合体,第一个更新聚合体的交易将成功。第二个将失败,因为版本号已更改,所以它不会意外地覆盖第一个事务的更改。

事件存储库也可以使用乐观锁来处理并发更新。每个聚合实例都有一个版本号,在读取事件时一起读取。当应用程序插入事件时,事件存储库会验证版本号是否未更改。一种简单的方法是将事件的数量作为版本号。或者,如您在下面的第 6.2 节中看到的,事件存储库可以维护一个显式的版本号。

6.1.4. 事件溯源和发布事件

严格来说,事件溯源将聚合体持久化为事件,并从这些事件中重建聚合体的当前状态。您还可以将事件溯源用作可靠的事件发布机制。在事件存储中保存事件是一个本质上原子的操作。我们需要实现一个机制来将所有持久化的事件传递给感兴趣的消费者。

第三章描述了几种不同的机制——轮询和事务日志尾部——用于发布作为事务一部分插入数据库的消息。基于事件源的应用可以使用这些机制之一来发布事件。主要区别在于它永久地将事件存储在EVENTS表中,而不是临时将事件存储在OUTBOX表中然后删除它们。让我们看看每种方法,从轮询开始。

使用轮询来发布事件

如果事件存储在图 6.6 中显示的EVENTS表中,事件发布者可以通过执行一个SELECT语句来轮询表以获取新事件,并将事件发布到消息代理。挑战在于确定哪些事件是新的。例如,假设eventIds是单调递增的。表面上吸引人的方法是让事件发布者记录它最后处理过的eventId。然后,它会使用如下查询来检索新事件:SELECT * FROM EVENTS where event_id > ? ORDER BY event_id ASC

图 6.6. 一个事件被跳过的情况,因为其事务A在事务B提交之后提交。轮询看到eventId=1020,然后后来跳过eventId=1010

这种方法的缺点是事务可以以不同于它们生成事件的顺序提交。因此,事件发布者可能会意外地跳过一个事件。图 6.6 展示了这种情况。

在这个场景中,事务A插入一个EVENT_ID为 1010 的事件。接下来,事务B插入一个EVENT_ID为 1020 的事件,然后提交。如果事件发布者现在查询EVENTS表,它会找到事件 1020。稍后,在事务A提交并且事件 1010 变得可见之后,事件发布者会忽略它。

解决这个问题的一个方案是在EVENTS表中添加一个额外的列来跟踪事件是否已发布。然后事件发布者将使用以下过程:

  1. 通过执行以下 SELECT 语句来查找未发布的事件:SELECT * FROM EVENTS where PUBLISHED = 0 ORDER BY event_id ASC

  2. 将事件发布到消息代理。

  3. 将事件标记为已发布:UPDATE EVENTS SET PUBLISHED = 1 WHERE EVENT_ID in

这种方法防止事件发布者跳过事件。

使用事务日志尾部可靠地发布事件

更复杂的事件存储使用事务日志尾部,正如第三章所描述的,这保证了事件将被发布,并且性能更高,可扩展性更好。例如,开源事件存储 Eventuate Local 就使用这种方法。它从数据库事务日志中读取插入到EVENTS表中的事件,并将它们发布到消息代理。第 6.2 节详细讨论了 Eventuate Local 的工作原理。

6.1.5. 使用快照提高性能

“订单”聚合具有相对较少的状态转换,因此它只有少量的事件。查询事件存储以获取这些事件并重建“订单”聚合是高效的。然而,长期存在的聚合可以具有大量的事件。例如,“账户”聚合可能具有大量的事件。随着时间的推移,加载和折叠这些事件将变得越来越低效。

一种常见的解决方案是定期持久化聚合状态的快照。图 6.7 展示了使用快照的示例。应用程序通过加载最近的快照以及自快照创建以来发生的事件来恢复聚合的状态。

图 6.7. 使用快照通过消除加载所有事件的必要性来提高性能。应用程序只需要加载快照以及之后发生的事件。

图片

在此示例中,快照版本为N。应用程序只需要加载快照以及紧随其后的两个事件,以便恢复聚合的状态。之前的N个事件不需要从事件存储中加载。

当从快照恢复聚合的状态时,应用程序首先从快照创建一个聚合实例,然后遍历事件,应用它们。例如,在第 6.2.2 节中描述的 Eventuate Client 框架,使用类似于以下代码来重建一个聚合:

Class aggregateClass = ...;
Snapshot snapshot = ...;
Aggregate aggregate = recreateFromSnapshot(aggregateClass, snapshot);
for (Event event : events) {
  aggregate = aggregate.applyEvent(event);
}
// use aggregate...

当使用快照时,聚合实例是从快照重新创建的,而不是使用其默认构造函数创建。如果一个聚合具有简单且易于序列化的结构,快照可以是其 JSON 序列化。更复杂的聚合可以使用备忘录模式(en.wikipedia.org/wiki/Memento_pattern)进行快照。

在在线商店示例中,“客户”聚合具有非常简单的结构:客户信息、信用额度以及信用预留。一个“客户”的快照是其状态的 JSON 序列化。图 6.8 展示了如何从对应于事件#103 时“客户”状态的快照中重新创建一个“客户”。客户服务需要加载快照以及事件#103 之后发生的事件。

图 6.8. “客户服务”通过反序列化快照的 JSON,然后加载并应用事件#104 至#106 来重新创建“客户”。

图片

“客户服务”通过反序列化快照的 JSON,然后加载并应用事件#104 至#106 来重新创建“客户”。

6.1.6. 幂等消息处理

服务通常从其他应用程序或其他服务中消费消息。例如,一个服务可能会消费由聚合体发布的领域事件或由叙事编排器发送的命令消息。如第三章所述,开发消息消费者时的重要问题是确保它是幂等的,因为消息代理可能会多次投递相同的消息。

如果消息消费者可以安全地多次使用相同的消息调用,则它是幂等的。例如,Eventuate Tram 框架通过检测和丢弃重复消息来实现幂等消息处理。它将处理消息的ids记录在业务逻辑创建或更新聚合体时使用的本地 ACID 事务的PROCESSED_MESSAGES表中。如果消息的 ID 在PROCESSED_MESSAGES表中,则它是重复的,可以被丢弃。基于事件源的业务逻辑必须实现等效机制。如何实现取决于事件存储使用的是 RDBMS 还是 NoSQL 数据库。

使用基于 RDBMS 的事件存储进行幂等消息处理

如果应用程序使用基于 RDBMS 的事件存储,它可以使用相同的方法来检测和丢弃重复消息。它在将事件插入EVENTS表的事务中将消息 ID 插入到PROCESSED_MESSAGES表中。

使用基于 NoSQL 的事件存储进行幂等消息处理

基于 NoSQL 的事件存储,由于具有有限的交易模型,必须使用不同的机制来实现幂等消息处理。消息消费者必须以某种方式原子性地持久化事件并记录消息 ID。幸运的是,有一个简单的解决方案。消息消费者在处理消息时将其 ID 存储在生成的事件中。它通过验证聚合体的事件中不包含消息 ID 来检测重复项。

使用这种方法的一个挑战是处理消息可能不会生成任何事件。没有事件意味着没有记录消息已被处理。对同一消息的后续重新投递和重新处理可能会导致不正确的行为。例如,考虑以下场景:

  1. 消息 A 被处理,但没有更新聚合体。

  2. 消息 B 被处理,并且消息消费者更新了聚合体。

  3. 消息 A 被重新投递,因为没有记录它已被处理,消息消费者更新了聚合体。

  4. 消息 B 再次被处理...

在这种情况下,事件的重新投递会导致不同的结果,可能是错误的结果。

避免这种问题的方法之一是始终发布一个事件。如果一个聚合体没有发出事件,应用程序将保存一个伪事件仅用于记录消息 ID。事件消费者必须忽略这些伪事件。

6.1.7. 领域事件的演变

事件源,至少从概念上讲,永久存储事件——这是一把双刃剑。一方面,它为应用程序提供了一个保证准确性的变更审计日志。它还使应用程序能够重建聚合的历史状态。另一方面,它也带来了挑战,因为事件的结构通常会随时间而变化。

应用程序可能需要处理多个事件版本。例如,加载 Order 聚合的服务可能需要合并多个事件版本。同样,事件订阅者可能看到多个版本。

让我们先看看事件可以以哪些不同的方式改变,然后我将描述一种常用的处理变更的方法。

事件模式演进

从概念上讲,事件源应用程序有一个分为三个级别的架构:

  • 由一个或多个聚合组成

  • 定义每个聚合发出的事件

  • 定义事件的架构

表 6.1 展示了在每个级别可能发生的不同类型的变更。

表 6.1. 应用程序事件演变的多种方式
级别 变更 向后兼容
架构 定义一个新的聚合类型
移除聚合 移除现有的聚合
重命名聚合 更改聚合类型的名称
聚合 添加一个新的事件类型
移除事件 移除一个事件类型
重命名事件 更改事件类型的名称
事件 添加一个新的字段
删除字段 删除一个字段
重命名字段 重命名一个字段
更改字段类型 更改字段的类型

这些变更随着服务领域模型随时间演变而自然发生——例如,当服务的需求发生变化或其开发者对领域有更深入的了解并改进领域模型时。在架构级别,开发者添加、删除和重命名聚合类。在聚合级别,特定聚合发出的事件类型可能会改变。开发者可以通过添加、删除、更改字段名称或类型来更改事件类型的结构。

幸运的是,许多这类变更都是向后兼容的。例如,向事件添加字段不太可能影响消费者。消费者会忽略未知字段。然而,其他变更则不是向后兼容的。例如,更改事件或字段的名称需要更改该事件类型的消费者。

通过向上转换管理架构变更

在 SQL 数据库世界中,数据库架构的变更通常通过架构迁移来处理。每个架构变更都由一个 迁移 表示,这是一个更改架构并将数据迁移到新架构的 SQL 脚本。架构迁移存储在版本控制系统,并使用如 Flyway 这样的工具应用到数据库中。

事件溯源应用程序可以使用类似的方法来处理不向后兼容的更改。但是,与在原地迁移事件到新架构版本不同,事件溯源框架在从事件存储加载事件时转换事件。一个通常称为升级器的组件将单个事件从旧版本更新到新版本。因此,应用程序代码始终只处理当前的事件架构。

现在我们已经了解了事件溯源的工作原理,让我们考虑其优点和缺点。

6.1.8. 事件溯源的优点

事件溯源既有优点也有缺点。其优点包括以下内容:

  • 可靠发布领域事件

  • 保留聚合的历史

  • 主要避免了 O/R 阻抗不匹配问题

  • 为开发者提供时间机器

让我们更详细地考察每个好处。

可靠发布领域事件

事件溯源的一个主要好处是它可以在聚合状态改变时可靠地发布事件。这对于事件驱动的微服务架构是一个良好的基础。此外,由于每个事件都可以存储更改用户的身份,事件溯源提供了一个保证准确性的审计日志。事件流可以用作各种其他目的,包括通知用户、应用程序集成、分析和监控。

保留聚合的历史

事件溯源的另一个好处是它存储了每个聚合的整个历史。你可以轻松实现时间查询,以检索聚合的过去状态。例如,要确定某个过去时刻聚合的状态,你可以折叠直到那个时刻发生的事件。例如,计算客户在某个过去时刻的可用信用额度是直接的。

主要避免了 O/R 阻抗不匹配问题

事件溯源是持久化事件而不是聚合它们。事件通常具有简单、易于序列化的结构。如前所述,一个服务可以通过序列化其状态的备忘录来快照一个复杂的聚合,这会在聚合及其序列化表示之间增加一个间接层。

为开发者提供时间机器

事件溯源存储了一个应用程序在其生命周期中发生的所有事情的历史。想象一下,FTGO 的开发者需要实现一个新需求,即向那些将商品添加到购物车后又删除它们的客户进行营销。传统的应用程序不会保留这些信息,因此只能在功能实现后向添加和删除商品的客户进行营销。相比之下,基于事件溯源的应用程序可以立即向那些过去做过这件事的客户进行营销。这就像事件溯源为开发者提供了一个时间机器,可以回到过去并实现未预见的需求。

6.1.9. 事件溯源的缺点

事件溯源不是万能的。它有以下缺点:

  • 它具有具有学习曲线的不同编程模型。

  • 它具有基于消息的应用程序的复杂性。

  • 事件演变可能很棘手。

  • 删除数据很棘手。

  • 查询事件存储具有挑战性。

让我们来看看每个缺点。

具有学习曲线的不同编程模型

它是一个不同且不熟悉的编程模型,这意味着有一个学习曲线。为了使现有应用程序使用事件溯源,你必须重写其业务逻辑。幸运的是,这是一个相当机械的转换,你可以在将应用程序迁移到微服务时进行。

基于消息的应用程序的复杂性

事件溯源的另一个缺点是消息代理通常保证至少一次投递。非幂等的事件处理器必须检测并丢弃重复的事件。事件溯源框架可以通过为每个事件分配一个单调递增的 ID 来帮助。事件处理器可以通过跟踪最高已见事件 ID 来检测重复事件。当事件处理器更新聚合时,这甚至可以自动发生。

事件演变可能很棘手

使用事件溯源时,事件的模式(以及快照!)会随着时间的推移而演变。因为事件是永久存储的,聚合可能需要折叠对应多个模式版本的事件。确实存在这样的风险,即聚合可能会因为处理所有不同版本而变得臃肿。如第 6.1.7 节所述,解决这个问题的一个好方法是当从事件存储中加载事件时将事件升级到最新版本。这种方法将升级事件的代码与聚合分离,从而简化了聚合,因为它们只需要应用事件的最新版本。

删除数据很棘手

因为事件溯源的一个目标是为了保留聚合的历史,它故意永久存储数据。在事件溯源中使用传统方式删除数据时,通常会进行软删除。应用程序通过设置一个已删除标志来删除聚合。聚合通常会发出一个Deleted事件,通知任何感兴趣的消费者。任何访问该聚合的代码都可以检查该标志并相应地操作。

使用软删除对于许多类型的数据来说效果很好。然而,一个挑战是遵守通用数据保护条例(GDPR),这是一项欧洲数据保护和隐私法规,赋予个人删除权(gdpr-info.eu/art-17-gdpr/)。应用程序必须有能力忘记用户的个人信息,例如他们的电子邮件地址。基于事件溯源的应用程序的问题是电子邮件地址可能存储在AccountCreated事件中或用作聚合的主键。应用程序必须以某种方式忘记用户,而不删除事件。

加密是你可以用来解决这个问题的一种机制。每个用户都有一个加密密钥,该密钥存储在单独的数据库表中。应用程序使用该加密密钥在将事件存储在事件存储之前加密包含用户个人信息的任何事件。当用户请求被删除时,应用程序会从数据库表中删除加密密钥记录。由于事件无法再被解密,因此用户的个人信息实际上被删除了。

加密事件可以解决删除用户个人信息的大部分问题。但如果用户的某些个人信息,例如电子邮件地址,被用作聚合 ID,仅仅丢弃加密密钥可能就不够了。例如,第 6.2 节描述了一个事件存储,它有一个entities表,其主键是聚合 ID。解决这个问题的一个方案是使用匿名化技术,用 UUID 令牌替换电子邮件地址,并将其用作聚合 ID。应用程序将 UUID 令牌与电子邮件地址之间的关联存储在数据库表中。当用户请求被删除时,应用程序会从该表中删除其电子邮件地址的行。这防止了应用程序将 UUID 映射回电子邮件地址。

查询事件存储具有挑战性

假设你需要找到已经耗尽信用额的客户。由于没有包含信用的列,你不能写SELECT * FROM CUSTOMER WHERE CREDIT_LIMIT = 0。相反,你必须使用一个更复杂且可能效率不高的查询,该查询包含嵌套的SELECT来通过折叠设置初始信用额并调整它来计算信用额。更糟糕的是,基于 NoSQL 的事件存储通常只支持基于主键的查找。因此,你必须使用第七章中描述的 CQRS 方法来实现查询。

6.2. 实现事件存储

使用事件源的应用程序将事件存储在事件存储中。事件存储是数据库和消息代理的混合体。它作为数据库,因为它有一个 API,可以通过主键插入和检索聚合的事件。它作为消息代理,因为它有一个 API,可以订阅事件。

实现事件存储有几种不同的方法。一种选择是自行实现事件存储和事件源框架。例如,你可以在关系型数据库管理系统(RDBMS)中持久化事件。发布事件的一个简单但性能较低的方法是让订阅者轮询EVENTS表以获取事件。但是,如第 6.1.4 节所述,一个挑战是确保订阅者按顺序处理所有事件。

另一种选择是使用专用的事件存储,这通常提供了一组丰富的功能,以及更好的性能和可扩展性。有几种可供选择:

  • 事件存储 由事件溯源先驱 Greg Young 开发的一个基于.NET 的开源事件存储。事件存储官网

  • Lagom 由公司 Lightbend(原名 Typesafe)开发的微服务框架。Lagom 框架官网

  • Axon 开源 Java 框架,用于开发使用事件溯源和 CQRS 的事件驱动应用程序。Axon 框架官网

  • Eventuate 由我的初创公司 Eventuate(eventuate.io)开发。Eventuate 有两个版本:Eventuate SaaS,一个云服务,以及 Eventuate Local,一个基于 Apache Kafka/RDBMS 的开源项目。

虽然这些框架在细节上有所不同,但核心概念保持不变。因为 Eventuate 是我最熟悉的框架,所以我在这里介绍它。它具有简单、易于理解的架构,可以说明事件溯源的概念。您可以在应用程序中使用它,自己重新实现这些概念,或者将在这里学到的知识应用于构建使用其他事件溯源框架的应用程序。

我在以下章节的开头描述了 Eventuate Local 事件存储的工作原理。然后我描述了 Eventuate Client 框架,这是一个用于 Java 的简单易用的框架,用于编写基于事件存储的业务逻辑,并使用 Eventuate Local 事件存储。

6.2.1. Eventuate Local 本地事件存储的工作原理

Eventuate Local 是一个开源事件存储。图 6.9(#ch06fig09)显示了其架构。事件存储在数据库中,如 MySQL。应用程序通过主键插入和检索聚合事件。应用程序从消息代理,如 Apache Kafka,消费事件。事务日志跟踪机制将事件从数据库传播到消息代理。

图 6.9. Eventuate Local 的架构。它由一个事件数据库(如 MySQL)组成,用于存储事件,一个事件代理(如 Apache Kafka),用于将事件传递给订阅者,以及一个事件中继,将存储在事件数据库中的事件发布到事件代理。

图片

让我们看看不同的 Eventuate Local 组件,从数据库模式开始。

Eventuate Local 事件数据库的模式

事件数据库由三个表组成:

  • events 存储事件

  • entities 每个实体一行

  • snapshots 存储快照

核心表是events表。这个表的结构与图 6.2 中显示的表非常相似。以下是它的定义:

create table events (
  event_id varchar(1000) PRIMARY KEY,
  event_type varchar(1000),
  event_data varchar(1000) NOT NULL,
  entity_type VARCHAR(1000) NOT NULL,
  entity_id VARCHAR(1000) NOT NULL,
  triggering_event VARCHAR(1000)
);

triggering_event列用于检测重复的事件/消息。它存储了生成此事件的已处理消息/事件的 ID。

entities表存储每个实体的当前版本。它用于实现乐观锁。以下是该表的定义:

create table entities (
  entity_type VARCHAR(1000),
  entity_id VARCHAR(1000),
  entity_version VARCHAR(1000) NOT NULL,
  PRIMARY KEY(entity_type, entity_id)
);

当实体被创建时,此表中插入一行。每次实体被更新时,entity_version 列都会更新。

snapshots 表存储每个实体的快照。以下是此表的定义:

create table snapshots (
  entity_type VARCHAR(1000),
  entity_id VARCHAR(1000),
  entity_version VARCHAR(1000),
  snapshot_type VARCHAR(1000) NOT NULL,
  snapshot_json VARCHAR(1000) NOT NULL,
  triggering_events VARCHAR(1000),
  PRIMARY KEY(entity_type, entity_id, entity_version)
)

entity_typeentity_id 列指定快照的实体。snapshot_json 列是快照的序列化表示,snapshot_type 是其类型。entity_version 指定这是快照的实体的版本。

此架构支持的三种操作是 find()create()update()find() 操作查询 snapshots 表以检索最新的快照(如果有的话)。如果存在快照,find() 操作将查询 events 表以找到所有 event_id 大于快照的 entity_version 的事件。否则,find() 检索指定实体的所有事件。find() 操作还查询 entity 表以检索实体的当前版本。

create() 操作在 entity 表中插入一行,并将事件插入到 events 表中。update() 操作将事件插入到 events 表中。它还通过使用此 UPDATE 语句在 entities 表中更新实体版本来执行乐观锁定检查:

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

此语句验证自 find() 操作检索以来版本未更改。它还更新 entity_version 到新版本。update() 操作在事务中执行这些更新,以确保原子性。

现在我们已经了解了 Eventuate Local 如何存储聚合的事件和快照,让我们看看客户端如何使用 Eventuate Local 的事件代理订阅事件。

通过订阅 Eventuate Local 的事件代理来消费事件

服务通过订阅事件代理来消费事件,该代理使用 Apache Kafka 实现。事件代理为每种聚合类型有一个主题。如第三章所述,主题是一个分区消息通道。这使消费者能够在保持消息顺序的同时水平扩展。聚合 ID 用作分区键,这保留了给定聚合发布的事件的顺序。为了消费聚合的事件,服务订阅了聚合的主题。

现在我们来看看事件中继——事件数据库和事件代理之间的粘合剂。

Eventuate Local 事件中继将事件从数据库传播到消息代理

事件中继将插入到事件数据库中的事件传播到事件代理。它尽可能使用事务日志跟踪,对于其他数据库则进行轮询。例如,事件中继的 MySQL 版本使用 MySQL 主/从复制协议。事件中继连接到 MySQL 服务器,就像是一个从服务器一样,读取 MySQL binlog,这是对数据库进行的更新记录。对应于事件的EVENTS表中的插入被发布到适当的 Apache Kafka 主题。事件中继忽略任何其他类型的更改。

事件中继作为一个独立进程部署。为了正确重启,它定期将当前位置(binlog 的文件名和偏移量)保存到一个特殊的 Apache Kafka 主题中。启动时,它首先从主题中检索最后记录的位置。然后事件中继从该位置开始读取 MySQL binlog。

事件数据库、消息代理和事件中继构成了事件存储。现在让我们看看 Java 应用程序使用该框架访问事件存储的方式。

6.2.2. Java 的 Eventuate 客户端框架

Eventuate 客户端框架使开发者能够编写基于事件源的应用程序,这些应用程序使用 Eventuate Local 事件存储。该框架,如图 6.10 所示,为开发基于事件源的聚合、服务和事件处理器提供了基础。

图 6.10. Eventuate 客户端框架为 Java 提供的主体类和接口

![Images/06fig10_alt.jpg]

该框架为聚合、命令和事件提供了基类。还有一个AggregateRepository类,它提供了 CRUD 功能。框架还有一个用于订阅事件的 API。

让我们简要地看看图 6.10 中显示的每个类型。

使用 ReflectiveMutableCommandProcessingAggregate 类定义聚合

ReflectiveMutableCommandProcessingAggregate是聚合的基类。它是一个泛型类,有两个类型参数:第一个是具体的聚合类,第二个是聚合命令类的超类。正如其相当长的名字所暗示的,它使用反射将命令和事件调度到适当的方法。命令被调度到process()方法,事件被调度到apply()方法。

你之前看到的Order类扩展了ReflectiveMutableCommandProcessingAggregate。下面的列表显示了Order类。

列表 6.3. Eventuate 版本的Order
public class Order extends ReflectiveMutableCommandProcessingAggregate<
      Order, OrderCommand> {

  public List<Event> process(CreateOrderCommand command) { ... }

  public void apply(OrderCreatedEvent event) { ... }

  ...
}

传递给ReflectiveMutableCommandProcessingAggregate的两个类型参数是OrderOrderCommand,这是Order命令的基接口。

定义聚合命令

聚合的命令类必须扩展一个特定于聚合的基接口,该接口本身必须扩展Command接口。例如,Order聚合的命令扩展了OrderCommand

public interface OrderCommand extends Command {
}

public class CreateOrderCommand implements OrderCommand { ... }

OrderCommand 接口扩展了 Command,而 CreateOrderCommand 命令类扩展了 OrderCommand

定义领域事件

一个聚合的事件类必须扩展 Event 接口,这是一个没有方法的标记接口。同时,定义一个通用的基接口,用于扩展所有聚合的事件类,也是很有用的。例如,以下是 OrderCreated 事件的定义:

interface OrderEvent extends Event {

}

public class OrderCreated extends OrderEvent { ... }

OrderCreated 事件类扩展了 OrderEvent,这是 Order 聚合的事件类的基接口。OrderEvent 接口扩展了 Event

使用 AggregateRepository 类创建、查找和更新聚合

框架提供了多种创建、查找和更新聚合的方法。这里描述的最简单的方法是使用 AggregateRepositoryAggregateRepository 是一个泛型类,它通过聚合类和聚合的基本命令类进行参数化。它提供了三个重载方法:

  • save() 创建一个聚合

  • find() 查找一个聚合

  • update() 更新一个聚合

save()update() 方法尤其方便,因为它们封装了创建和更新聚合所需的基本代码。例如,save() 方法接受一个命令对象作为参数,并执行以下步骤:

  1. 使用其默认构造函数实例化聚合

  2. 调用 process() 方法来处理命令

  3. 通过调用 apply() 方法应用生成的事件

  4. 在事件存储中保存生成的事件

update() 方法类似。它有两个参数,一个聚合 ID 和一个命令,并执行以下步骤:

  1. 从事件存储中检索聚合数据

  2. 调用 process() 方法来处理命令

  3. 通过调用 apply() 方法应用生成的事件

  4. 在事件存储中保存生成的事件

AggregateRepository 类主要用于服务,这些服务根据外部请求创建和更新聚合。例如,以下列表展示了 OrderService 如何使用 AggregateRepository 创建一个 Order

列表 6.4. OrderService 使用 AggregateRepository
public class OrderService {
  private AggregateRepository<Order, OrderCommand> orderRepository;

  public OrderService(AggregateRepository<Order, OrderCommand> orderRepository)
  {
    this.orderRepository = orderRepository;
  }

  public EntityWithIdAndVersion<Order> createOrder(OrderDetails orderDetails) {
    return orderRepository.save(new CreateOrder(orderDetails));
  }
}

OrderService 注入了 OrdersAggregateRepository。它的 create() 方法通过 CreateOrder 命令调用 AggregateRepository.save()

订阅领域事件

Eventuate 客户端框架还提供了一个用于编写事件处理器的 API。列表 6.5 展示了 CreditReserved 事件的事件处理器。@EventSubscriber 注解指定了持久订阅的 ID。当订阅者未运行时发布的事件将在启动时传递。@EventHandlerMethod 注解将 creditReserved() 方法标识为事件处理器。

列表 6.5. OrderCreatedEvent 的事件处理器
@EventSubscriber(id="orderServiceEventHandlers")
public class OrderServiceEventHandlers {

  @EventHandlerMethod
  public void creditReserved(EventHandlerContext<CreditReserved> ctx) {
    CreditReserved event = ctx.getEvent();
    ...
  }

事件处理器有一个类型为 EventHandlerContext 的参数,它包含事件及其元数据。

现在我们已经了解了如何使用 Eventuate 客户端框架编写基于事件源的业务逻辑,接下来让我们看看如何使用 saga 结合事件源的业务逻辑。

6.3. 使用 saga 和事件源结合

假设你已经使用事件源实现了一个或多个服务。你可能已经编写了类似于列表 6.4 中所示的服务。但如果你已经阅读了第四章,你会知道服务通常需要启动并参与saga,即用于在服务之间维护数据一致性的本地事务序列。例如,订单服务使用 saga 来验证订单厨房服务消费者服务会计服务参与该 saga。因此,你必须将 saga 和基于事件源的业务逻辑集成在一起。

事件源使得使用基于编排的 saga 变得容易。参与者交换它们聚合产生的领域事件。每个参与者的聚合通过处理命令和发出新事件来处理事件。您需要编写聚合和事件处理类,这些类会更新聚合。

但将基于事件源的业务逻辑与基于编排的 saga 集成可能更具挑战性。这是因为事件存储的事务概念可能相当有限。当使用某些事件存储时,应用程序只能创建或更新单个聚合并发布结果事件。但 saga 的每个步骤都由必须原子性执行的多项操作组成:

  • *** Saga 创建*—** 初始化 saga 的服务必须原子性地创建或更新聚合,并创建 saga 编排器。例如,订单服务createOrder()方法必须创建一个订单聚合和一个CreateOrderSaga

  • *** Saga 编排*—** Saga 编排器必须原子性地消费回复,更新其状态,并发送命令消息。

  • *** Saga 参与者*—** Saga 参与者,如厨房服务订单服务,必须原子性地消费消息,检测和丢弃重复项,创建或更新聚合,并发送回复消息。

由于这些要求与事件存储的事务能力之间存在不匹配,因此将基于编排的 saga 和事件源集成可能会带来一些有趣的挑战。

确定事件溯源和基于编排的叙事法集成难易程度的关键因素是事件存储是否使用关系型数据库管理系统(RDBMS)或 NoSQL 数据库。在第四章中描述的 Eventuate Tram 叙事法框架以及第三章中描述的底层 Tram 消息框架都依赖于 RDBMS 提供的灵活 ACID 事务。叙事法协调器和叙事法参与者使用 ACID 事务来原子性地更新其数据库并交换消息。如果应用程序使用基于 RDBMS 的事件存储,例如 Eventuate Local,那么它可以“作弊”并调用 Eventuate Tram 叙事法框架,并在 ACID 事务内更新事件存储。但如果事件存储使用无法与 Eventuate Tram 叙事法框架参与同一事务的 NoSQL 数据库,它将不得不采取不同的方法。

让我们更深入地看看一些不同的场景和需要解决的问题:

  • 实现基于编排的叙事法

  • 创建基于编排的叙事法

  • 使用事件溯源实现基于事件溯源的叙事法参与者

  • 使用事件溯源实现叙事法协调器

我们将首先探讨如何使用事件溯源来实现基于编排的叙事法。

6.3.1. 使用事件溯源实现基于编排的叙事法

事件溯源的事件驱动特性使得实现基于编排的叙事法变得相当直接。当一个聚合被更新时,它会发出一个事件。不同聚合的事件处理器可以消费该事件并更新其聚合。事件溯源框架自动使每个事件处理器具有幂等性。

例如,第四章讨论了如何使用编排来实现创建订单叙事法ConsumerServiceKitchenServiceAccountingService订阅了OrderService的事件,反之亦然。每个服务都有一个类似于列表 6.5 中所示的事件处理器。事件处理器更新相应的聚合,从而发出另一个事件。

事件溯源和基于编排的叙事法非常配合。事件溯源提供了叙事法所需的各种机制,包括基于消息的进程间通信(IPC)、消息去重以及状态和消息发送的原子更新。尽管其简单性,基于编排的叙事法仍有几个缺点。我在第四章中谈到了一些缺点,但还有一个特定于事件溯源的缺点。

使用事件进行 saga 编排的问题在于,事件现在具有双重目的。事件溯源使用事件来表示状态变化,但使用事件进行 saga 编排需要聚合体即使没有状态变化也发出一个事件。例如,如果更新聚合体会违反业务规则,那么聚合体必须发出一个事件来报告错误。更糟糕的问题是,当 saga 参与者无法创建聚合体时。没有可以发出错误事件的聚合体。

由于这些问题,最好使用编排来实现更复杂的 saga。以下各节将解释如何集成基于编排的 saga 和事件溯源。正如您将看到的,这涉及到解决一些有趣的问题。

让我们先看看服务方法,如OrderService.createOrder(),是如何创建 saga 编排器的。

6.3.2. 创建基于编排的 saga

Saga 编排器是由某些服务方法创建的。其他服务方法,例如OrderService.createOrder(),执行两件事:创建或更新一个聚合体并且创建一个 saga 编排器。服务必须以保证如果它执行第一个动作,那么第二个动作最终会被执行的方式来执行这两个动作。服务如何确保这两个动作都得到执行取决于它使用的存储事件的类型。

在使用基于 RDBMS 的事件存储时创建 saga 编排器

如果一个服务使用基于关系数据库管理系统(RDBMS)的事件存储,它可以在同一个 ACID 事务中更新事件存储并创建一个 saga 编排器。例如,假设OrderService使用 Eventuate Local 和 Eventuate Tram saga 框架。它的createOrder()方法看起来像这样:

class OrderService

  @Autowired
  private SagaManager<CreateOrderSagaState> createOrderSagaManager;

  @Transactional                                                             *1*
   public EntityWithIdAndVersion<Order> createOrder(OrderDetails orderDetails) {
    EntityWithIdAndVersion<Order> order =
        orderRepository.save(new CreateOrder(orderDetails));                 *2*

    CreateOrderSagaState data =
        new CreateOrderSagaState(order.getId(), orderDetails);               *3*

    createOrderSagaManager.create(data, Order.class, order.getId());

    return order;
  }
...
  • 1 确保 createOrder()在数据库事务中执行。

  • 2 创建 Order 聚合体。

  • 3 创建 CreateOrderSaga。

这是由列表 6.4 中的OrderService和第四章中描述的OrderService的组合。因为 Eventuate Local 使用 RDBMS,它可以参与与 Eventuate Tram saga 框架相同的 ACID 事务。但如果一个服务使用基于 NoSQL 的事件存储,创建 saga 编排器就不那么直接了。

在使用基于 NoSQL 的事件存储时创建 saga 编排器

使用基于 NoSQL 的事件存储的服务很可能会无法原子性地更新事件存储并创建一个 saga 编排器。saga 编排框架可能使用一个完全不同的数据库。即使它使用相同的 NoSQL 数据库,由于 NoSQL 数据库有限的交易模型,应用程序也无法原子性地创建或更新两个不同的对象。相反,服务必须有一个事件处理器,该处理器在聚合体发出的领域事件响应中创建 saga 编排器。

例如,图 6.11 展示了 Order Service 如何使用 OrderCreated 事件的处理器创建 CreateOrderSagaOrder Service 首先创建一个 Order 聚合并将其持久化到事件存储库中。事件存储库发布 OrderCreated 事件,该事件被事件处理器消费。事件处理器调用 Eventuate Tram 编排器框架来创建 CreateOrderSaga

图 6.11. 使用事件处理器在服务创建基于事件源聚合后可靠地创建编排器

图片 6.11

在编写创建一个编排器的事件处理器时需要记住的一个问题是它必须处理重复的事件。至少一次的消息投递意味着创建编排器的事件处理器可能会被多次调用。确保只创建一个编排器实例是很重要的。

一种简单的方法是从事件的唯一属性中派生出编排器的 ID。有几个不同的选项。一个是使用发出事件的聚合的 ID 作为编排器的 ID。这对于响应聚合创建事件的编排器来说效果很好。

另一个选项是使用事件 ID 作为编排器 ID。因为事件 ID 是唯一的,这将保证编排器 ID 是唯一的。如果一个事件是重复的,由于 ID 已经存在,事件处理器尝试创建编排器的操作将失败。当给定聚合实例可以存在多个相同编排器的实例时,这个选项很有用。

使用基于关系数据库管理系统(RDBMS)的事件存储库的服务也可以使用相同的事件驱动方法来创建编排器。这种方法的一个好处是它促进了松散耦合,因为像OrderService这样的服务不再显式实例化编排器。

现在我们已经了解了如何可靠地创建编排器编排器,让我们看看基于事件源的服务如何参与基于编排器的编排。

6.3.3. 实现基于事件源的编排器参与者

假设您使用事件源实现了一个需要参与基于编排器编排的服务。不出所料,如果您的服务使用基于关系数据库管理系统(RDBMS)的事件存储库,例如 Eventuate Local,您可以轻松确保它原子性地处理编排器命令消息并发送回复。它可以作为 Eventuate Tram 框架发起的 ACID 事务的一部分更新事件存储库。但是,如果您的服务使用无法与 Eventuate Tram 框架参与同一事务的事件存储库,您必须使用完全不同的方法。

您必须解决几个不同的问题:

  • 幂等命令消息处理

  • 原子性地发送回复消息

让我们先看看如何实现幂等命令消息处理器。

幂等命令消息处理

第一个要解决的问题是如何使基于事件源的 saga 参与者能够检测和丢弃重复消息,以实现幂等命令消息处理。幸运的是,这是一个很容易解决的问题,可以使用前面描述的幂等消息处理机制来解决。saga 参与者在处理消息时生成的事件中记录消息 ID。在更新聚合体之前,saga 参与者通过在事件中查找消息 ID 来验证它之前是否已经处理了该消息。

原子性地发送回复消息

第二个要解决的问题是如何使基于事件源的 saga 参与者能够原子性地发送回复。原则上,saga 调度器可以订阅聚合体发出的事件,但这种方法有两个问题。第一个问题是 saga 命令可能实际上并没有改变聚合体的状态。在这种情况下,聚合体不会发出事件,因此不会向 saga 调度器发送回复。第二个问题是这种方法要求 saga 调度器将使用事件源的 saga 参与者与其他参与者区别对待。这是因为为了接收领域事件,saga 调度器必须订阅聚合体的事件通道,除了自己的回复通道。

一种更好的方法是让 saga 参与者继续向 saga 调度器的回复通道发送回复消息。但而不是直接发送回复消息,saga 参与者使用两步过程:

  1. 当 saga 命令处理器创建或更新聚合体时,它会安排将一个 SagaReplyRequested 伪事件与聚合体发出的真实事件一起保存在事件存储中。

  2. 一个用于处理 SagaReplyRequested 伪事件的处理器使用事件中包含的数据来构建回复消息,然后将其写入 saga 调度器的回复通道。

让我们通过一个例子来看看它是如何工作的。

基于事件源的 saga 参与者示例

此示例分析了 Accounting Service,它是 Create Order Saga 参与者之一。图 6.12 展示了 Accounting Service 如何处理 saga 发送的 Authorize CommandAccounting Service 是使用 Eventuate Saga 框架实现的。Eventuate Saga 框架是一个用于编写使用事件源的 sagas 的开源框架。它是基于 Eventuate 客户端框架构建的。

图 6.12. 基于事件源 Accounting Service 如何参与 Create Order Saga

此图展示了 Create Order SagaAccountingService 之间的交互。事件发生的顺序如下:

  1. Create Order Saga 通过消息通道向 AccountingService 发送 AuthorizeAccount 命令。Eventuate Saga 框架的 SagaCommandDispatcher 调用 AccountingServiceCommandHandler 来处理命令消息。

  2. AccountingServiceCommandHandler 通过调用 AggregateRepository.update() 将命令发送到指定的 Account 聚合。

  3. 聚合发出两个事件,AccountAuthorizedSagaReplyRequestedEvent

  4. SagaReplyRequestedEventHandler 通过向 CreateOrderSaga 发送回复消息来处理 SagaReplyRequestedEvent

下面的列表中所示的 AccountingServiceCommandHandler 通过调用 AggregateRepository.update() 来处理 AuthorizeAccount 命令消息,以更新 Account 聚合。

列表 6.6. 处理由叙事发送的命令消息
public class AccountingServiceCommandHandler {

  @Autowired
  private AggregateRepository<Account, AccountCommand> accountRepository;

  public void authorize(CommandMessage<AuthorizeCommand> cm) {
    AuthorizeCommand command = cm.getCommand();
    accountRepository.update(command.getOrderId(),
            command,
            replyingTo(cm)
                .catching(AccountDisabledException.class,
                          () -> withFailure(new AccountDisabledReply()))
                .build());
  }

  ...

authorize() 方法调用 AggregateRepository 来更新 Account 聚合。update() 方法的第三个参数,即 UpdateOptions,由以下表达式计算得出:

replyingTo(cm)
    .catching(AccountDisabledException.class,
              () -> withFailure(new AccountDisabledReply()))
    .build()

这些 UpdateOptions 配置 update() 方法执行以下操作:

  1. 使用 消息 ID 作为幂等键以确保消息恰好处理一次。如前所述,Eventuate 框架将幂等键存储在所有生成的事件中,使其能够检测并忽略重复尝试更新聚合。

  2. 将一个 SagaReplyRequestedEvent 伪事件添加到事件存储中保存的事件列表。当 SagaReplyRequestedEventHandler 接收到 SagaReplyRequestedEvent 伪事件时,它会向 CreateOrderSaga 的回复通道发送回复。

  3. 当聚合抛出 AccountDisabledException 异常时,发送 AccountDisabledReply 而不是默认的错误回复。

现在我们已经了解了如何使用事件溯源实现叙事参与者,让我们来看看如何实现叙事编排器。

6.3.4. 使用事件溯源实现叙事编排器

到目前为止,在本节中,我已描述了基于事件溯源的服务如何发起和参与叙事。您还可以使用事件溯源来实现叙事编排器。这将使您能够开发完全基于事件存储的应用程序。

在实现叙事编排器时,你必须解决三个关键的设计问题:

  1. 你如何持久化一个叙事编排器?

  2. 你如何原子性地更改编排器的状态并发送命令消息?

  3. 你如何确保叙事编排器恰好处理一次回复消息?

第四章 讨论了如何实现基于 RDBMS 的叙事编排器。让我们看看在使用事件溯源时如何解决这些问题。

使用事件溯源持久化叙事编排器

叙事编排器有一个非常简单的生命周期。首先,它被创建。然后,它根据叙事参与者的回复进行更新。因此,我们可以使用以下事件来持久化叙事:

  • SagaOrchestratorCreated 叙事编排器已被创建。

  • SagaOrchestratorUpdated 叙事编排器已被更新。

当叙事协调器被创建时,它会发出一个 SagaOrchestratorCreated 事件,当它被更新时,会发出一个 SagaOrchestratorUpdated 事件。这些事件包含重新创建叙事协调器状态所需的数据。例如,第四章 中描述的 CreateOrderSaga 的事件将包含序列化(例如,JSON)的 CreateOrderSagaState

可靠地发送命令消息

另一个关键的设计问题是如何原子地更新叙事的状态并发送命令。如第四章 所述,基于 Eventuate Tram 的叙事实现通过更新协调器并将命令消息插入到 message 表中作为同一事务的一部分来完成此操作。使用基于 RDBMS 的事件存储的应用程序,如 Eventuate Local,可以使用相同的方法。使用基于 NoSQL 的事件存储的应用程序,如 Eventuate SaaS,尽管具有非常有限的交易模型,也可以使用类似的方法。

技巧是持久化一个 SagaCommandEvent,它代表一个要发送的命令。然后事件处理器订阅 SagaCommandEvents 并将每个命令消息发送到适当的通道。图 6.13 展示了这是如何工作的。

图 6.13. 基于事件源式的叙事协调器如何向叙事参与者发送命令

叙事协调器使用两步过程来发送命令:

  1. 一个叙事协调器为它想要发送的每个命令发出一个 SagaCommandEventSagaCommandEvent 包含发送命令所需的所有数据,例如目标通道和命令对象。这些事件在事件存储中持久化。

  2. 事件处理器处理这些 SagaCommandEvents 并向目标消息通道发送命令消息。

这种两步方法保证了命令至少会被发送一次。

由于事件存储提供至少一次投递,事件处理器可能会多次以相同的事件被调用。这会导致 SagaCommandEvents 的事件处理器发送重复的命令消息。幸运的是,一个叙事参与者可以很容易地检测并丢弃重复的命令,使用以下机制。SagaCommandEvent 的 ID,保证是唯一的,被用作命令消息的 ID。因此,重复的消息将具有相同的 ID。收到重复命令消息的叙事参与者将使用前面描述的机制丢弃它。

精确一次处理回复

叙事协调器还需要检测和丢弃重复的回复消息,它可以使用前面描述的机制来完成。协调器将回复消息的 ID 存储在它处理回复时发出的事件中。然后它可以很容易地确定消息是否是重复的。

如您所见,事件溯源是实现叙事的好基础。这还包括事件溯源的其他好处,包括数据更改时事件固有的可靠生成、可靠的审计日志和执行时间查询的能力。尽管如此,事件溯源并不是万能的。它涉及一个重大的学习曲线。事件模式的演变并不总是直接的。但尽管有这些缺点,事件溯源在微服务架构中仍然扮演着重要的角色。在下一章中,我们将转换方向,探讨如何在微服务架构中解决不同的分布式数据管理挑战:查询。我将描述如何实现查询,以检索分散在多个服务中的数据。

摘要

  • 事件溯源将聚合体持久化为一系列事件。每个事件代表聚合体的创建或状态变化。应用程序通过重放事件来重新创建聚合体的状态。事件溯源保留了领域对象的历史,提供了准确的审计日志,并可靠地发布领域事件。

  • 快照通过减少必须重放的事件数量来提高性能。

  • 事件存储在事件存储库中,它是数据库和消息代理的混合体。当服务在事件存储库中保存事件时,它将事件传递给订阅者。

  • Eventuate Local 是一个基于 MySQL 和 Apache Kafka 的开源事件存储库。开发者使用 Eventuate 客户端框架来编写聚合体和事件处理器。

  • 使用事件溯源的一个挑战是处理事件的演变。在重放事件时,应用程序可能必须处理多个事件版本。一个好的解决方案是使用向上转换,当事件从事件存储库加载时,将事件升级到最新版本。

  • 在事件溯源应用程序中删除数据是棘手的。应用程序必须使用加密和匿名化等技术来遵守像欧盟的 GDPR 这样的法规,该法规要求应用程序删除个人的数据。

  • 事件溯源是实现基于编排的叙事的一种简单方法。服务拥有事件处理器,它们监听基于事件溯源的聚合体发布的事件。

  • 事件溯源是实现叙事协调器的好方法。因此,你可以编写仅使用事件存储的应用程序。

第七章. 在微服务架构中实现查询

本章涵盖

  • 在微服务架构中查询数据的挑战

  • 何时以及如何使用 API 组合模式实现查询

  • 何时以及如何使用命令查询责任分离(CQRS)模式实现查询

玛丽和她的团队刚开始习惯于使用 sagas 来维护数据一致性。然后他们发现,在将 FTGO 应用程序迁移到微服务时,他们不仅要担心事务管理,还要解决如何实现查询的问题。

为了支持 UI,FTGO 应用程序实现了各种查询操作。在现有的单体应用程序中实现这些查询相对简单,因为它有一个单一的数据库。大多数情况下,FTGO 开发者需要做的只是编写 SQL SELECT 语句并定义必要的索引。正如玛丽发现的,在微服务架构中编写查询是具有挑战性的。查询通常需要检索分散在多个服务拥有的数据库中的数据。然而,你不能使用传统的分布式查询机制,因为即使技术上可行,它也违反了封装原则。

以例如 FTGO 应用程序在第二章中描述的查询操作为例。一些查询检索仅由一个服务拥有的数据。例如,findConsumerProfile()查询从Consumer Service返回数据。但其他 FTGO 查询操作,如findOrder()findOrderHistory(),返回由多个服务拥有的数据。实现这些查询操作并不像想象中那么简单。

在微服务架构中实现查询操作有两种不同的模式:

  • API 组合模式——这是最简单的方法,应尽可能使用。它通过使拥有数据的服务的客户端负责调用服务并组合结果来实现

  • 命令查询责任分离(CQRS)模式——这比 API 组合模式更强大,但也更复杂。它维护一个或多个仅用于支持查询的视图数据库

在讨论了这两种模式之后,我将讨论如何设计 CQRS 视图,然后是示例视图的实现。让我们先看看 API 组合模式。

7.1. 使用 API 组合模式进行查询

FTGO 应用程序实现了许多查询操作。正如之前提到的,一些查询是从单个服务中检索数据的。实现这些查询通常很简单——尽管在本章的后面部分,当我介绍 CQRS 模式时,你会看到一些难以实现的单一服务查询示例。

也有查询操作可以从多个服务中检索数据。在本节中,我描述了findOrder()查询操作,这是一个从多个服务中检索数据的查询示例。我解释了在微服务架构中实现此类查询时经常出现的挑战。然后,我描述了 API 组合模式,并展示了如何使用它来实现findOrder()等查询。

7.1.1. findOrder()查询操作

findOrder()操作通过主键检索订单。它接受一个orderId作为参数,并返回一个包含订单信息的OrderDetails对象。如图 7.1 所示,此操作由实现订单状态视图的前端模块(如移动设备或 Web 应用程序)调用。

图 7.1. findOrder()操作由 FTGO 前端模块调用,并返回Order的详情。

订单状态视图显示的信息包括订单的基本信息,包括其状态、支付状态、从餐厅角度的订单状态以及配送状态,包括其位置和预计配送时间(如果正在途中)。

由于其数据驻留在单个数据库中,单体 FTGO 应用程序可以轻松通过执行一个连接各种表的单一 SELECT 语句来检索订单详情。相比之下,在基于微服务的 FTGO 应用程序版本中,数据分散在以下服务中:

  • 订单服务 基本订单信息,包括细节和状态

  • 厨房服务 从餐厅的角度看订单的状态以及预计取餐准备时间

  • 配送服务 订单的配送状态、预计配送信息和当前位置

  • 会计服务 订单的支付状态

任何需要订单详情的客户都必须询问所有这些服务。

7.1.2. API 组合模式的概述

实现查询操作,例如findOrder(),以检索多个服务拥有的数据的一种方法是通过使用 API 组合模式。该模式通过调用拥有数据的服务并组合结果来执行查询操作。图 7.2 展示了该模式的结构。它有两种类型的参与者:

  • API 作曲家 通过查询提供者服务来实现查询操作。

  • 提供者服务 这是拥有查询返回的一些数据的服务。

图 7.2. API 组合模式由一个 API 作曲家和两个或更多提供者服务组成。API 作曲家通过查询提供者并组合结果来执行查询。

图 7.2 展示了三个提供者服务。API composer 通过从提供者服务检索数据并合并结果来实现查询。API composer 可能是一个客户端,例如需要数据来渲染网页的 Web 应用程序。或者,它可能是一个服务,例如 API 网关及其在 第八章 中描述的前端后端变体,它将查询操作作为 API 端点公开。

模式:API 组合

实现一个查询,通过通过每个服务的 API 查询每个服务并合并结果来检索来自几个服务的数据。参见 microservices.io/patterns/data/api-composition.html

您是否可以使用此模式实现特定的查询操作取决于多个因素,包括数据如何分区、拥有数据的服务的 API 的功能以及服务使用的数据库的功能。例如,即使 Provider services 有用于检索所需数据的 API,聚合器可能需要执行低效的内存中连接大型数据集。稍后,您将看到无法使用此模式实现的查询操作的示例。幸运的是,尽管如此,有许多场景适用此模式。为了看到它在实际中的应用,我们将查看一个示例。

7.1.3. 使用 API 组合模式实现 findOrder() 查询操作

findOrder() 查询操作对应于一个简单的基于主键的等值连接查询。可以合理地预期每个 Provider services 都有一个 API 端点,可以通过 orderId 检索所需数据。因此,findOrder() 查询操作是 API 组合模式的绝佳候选。API composer 调用四个服务并将结果合并在一起。图 7.3 展示了 Find Order Composer 的设计。

图 7.3. 使用 API 组合模式实现 findOrder()

在本例中,API composer 是一个将查询暴露为 REST 端点服务的服务。Provider services 也实现了 REST API。但如果使用的是其他进程间通信协议,如 gRPC 而不是 HTTP,概念是相同的。Find Order Composer 实现了一个 REST 端点 GET /order/{orderId}。它通过 orderId 调用四个服务并将响应合并。每个 Provider service 实现了一个 REST 端点,返回与单个聚合对应的响应。OrderService 通过主键检索其版本的 Order,而其他服务使用 orderId 作为外键来检索它们的聚合。

如您所见,API 组合模式相当简单。让我们看看在应用此模式时必须解决的一些设计问题。

7.1.4. API 组合设计问题

当使用此模式时,您必须解决几个设计问题:

  • 决定您的架构中哪个组件是查询操作API 作曲家

  • 如何编写高效的聚合逻辑

让我们看看每个问题。

谁扮演 API 作曲家的角色?

您必须做出的一个决定是,谁扮演查询操作API 作曲家的角色。您有三个选项。第一种选项,如图 7.4 所示,是让服务客户端成为API 作曲家

图 7.4. 在客户端实现 API 组合。客户端查询提供者服务以检索数据。

前端客户端,如运行在同一局域网上的 Web 应用程序,实现了订单状态视图,可以使用此模式高效地检索订单详情。但正如您将在第八章中了解到的那样,此选项对于在防火墙之外且通过较慢网络访问服务的客户端可能不太实用。

第二种选项,如图 7.5 所示,是为 API 网关,它实现了应用程序的外部 API,在查询操作中扮演API 作曲家的角色。

图 7.5. 在 API 网关中实现 API 组合。API 查询提供者服务以检索数据,合并结果,并将响应返回给客户端。

如果查询操作是应用程序外部 API 的一部分,则此选项是有意义的。而不是将请求路由到另一个服务,API 网关实现了 API 组合逻辑。这种方法使运行在防火墙之外,例如移动设备等客户端,能够通过单个 API 调用高效地从多个服务中检索数据。我在第八章中讨论了 API 网关。

第三种选项,如图 7.6 所示,是将API 作曲家实现为一个独立的服务。

图 7.6. 将多个客户端和服务使用的查询操作实现为一个独立的服务。

对于多个服务内部使用的查询操作,您应该使用此选项。此操作也可以用于外部可访问的查询操作,其聚合逻辑过于复杂,不适合作为 API 网关的一部分。

API 作曲家应使用响应式编程模型

在开发分布式系统时,最小化延迟是一个持续存在的担忧。尽可能的情况下,API composer应该并行调用提供者服务以最小化查询操作的响应时间。例如,“查找订单聚合器”应该并发调用四个服务,因为调用之间没有依赖关系。然而,有时API composer需要某个Provider 服务的结果来调用另一个服务。在这种情况下,它将需要按顺序调用一些——但希望不是所有——的提供者服务

高效执行顺序和并行服务调用的逻辑可能很复杂。为了使API composer既易于维护又具有高性能和可扩展性,它应该使用基于 Java CompletableFuture、RxJava 可观察对象或其他等效抽象的响应式设计。我在第八章中进一步讨论了这个主题,当时我介绍了 API 网关模式。

7.1.5. API 组合模式的利弊

这种模式是实现微服务架构中查询操作的一种简单直观的方法。但它也有一些缺点:

  • 增加开销

  • 减少可用性的风险

  • 事务数据一致性不足

让我们来看看它们。

增加开销

这种模式的另一个缺点是调用多个服务和查询多个数据库的开销。在单体应用中,客户端可以通过单个请求检索数据,这通常执行单个数据库查询。相比之下,使用 API 组合模式涉及多个请求和数据库查询。因此,需要更多的计算和网络资源,从而增加了应用程序的运行成本。

减少可用性的风险

这种模式的另一个缺点是可用性降低。如第三章中所述,操作的可用性随着涉及的服务数量而下降。因为查询操作的实现至少涉及三个服务——API composer和至少两个提供者服务——其可用性将显著低于单个服务。例如,如果单个服务的可用性为 99.5%,那么调用四个提供者服务的findOrder()端点的可用性为 99.5%^((4+1)) = 97.5%!

您可以使用几种策略来提高可用性。第一种策略是在API composer无法访问Provider 服务时返回之前缓存的资料。API composer有时会缓存Provider 服务返回的数据以提高性能。它也可以使用这个缓存来提高可用性。如果提供者不可用,API composer可以从缓存中返回数据,尽管这些数据可能已经过时。

提高可用性的另一种策略是让API composer返回不完整的数据。例如,假设Kitchen Service暂时不可用。findOrder()查询操作的API Composer可以省略该服务的数据,因为 UI 仍然可以显示有用的信息。你将在第八章(kindle_split_016.xhtml#ch08)中看到更多关于 API 设计、缓存和可靠性的细节。

缺乏事务性数据一致性

API 组合模式的另一个缺点是缺乏数据一致性。一个单体应用程序通常使用单个数据库事务来执行查询操作。ACID 事务——受隔离级别细节的影响——确保应用程序对数据有一个一致的观点,即使它执行多个数据库查询。相比之下,API 组合模式针对多个数据库执行多个数据库查询。因此,查询操作可能会返回不一致的数据。

例如,从Order Service检索的Order可能处于CANCELLED状态,而对应的从Kitchen Service检索的Ticket可能尚未被取消。API composer必须解决这种差异,这增加了代码的复杂性。更糟糕的是,API composer可能无法始终检测到不一致的数据,并将其返回给客户端。

尽管有这些缺点,API 组合模式仍然非常有用。你可以用它来实现许多查询操作。但有些查询操作无法有效地使用此模式实现。例如,一个查询操作可能需要API composer对大型数据集进行内存中的连接。

通常最好使用 CQRS 模式来实现这些类型的查询操作。让我们看看这个模式是如何工作的。

7.2. 使用 CQRS 模式

许多企业应用程序使用关系数据库管理系统(RDBMS)作为事务性记录系统,并使用文本搜索数据库,如 Elasticsearch 或 Solr,进行文本搜索查询。一些应用程序通过同时写入两个数据库来保持数据库同步。其他应用程序定期从 RDBMS 复制数据到文本搜索引擎。具有这种架构的应用程序利用多个数据库的优势:RDBMS 的事务属性和文本数据库的查询能力。

模式:命令查询责任分离

通过使用事件来维护只读视图并复制来自服务的数据,实现需要从多个服务获取数据的查询。请参阅microservices.io/patterns/data/cqrs.html

CQRS 是这种架构的泛化。它维护一个或多个视图数据库——不仅仅是文本搜索数据库——这些数据库实现了应用程序的一个或多个查询。为了理解为什么这很有用,我们将查看一些无法使用 API 组合模式有效实现的查询。我将解释 CQRS 是如何工作的,然后讨论 CQRS 的优点和缺点。让我们看看何时需要使用 CQRS。

7.2.1. 使用 CQRS 的动机

API 组合模式是实现必须从多个服务检索数据的许多查询的好方法。不幸的是,它只是微服务架构中查询问题的一个部分解决方案。这是因为 API 组合模式无法有效实现多个服务查询。

此外,还有一些难以实现的单一服务查询。也许服务的数据库不支持查询效率。或者,有时一个服务实现一个检索不同服务拥有的数据的查询是有意义的。让我们看看这些问题,从无法使用 API 组合有效实现的跨服务查询开始。

实现 findOrderHistory()查询操作

findOrderHistory()操作检索消费者的订单历史。它有几个参数:

  • consumerId—— 识别消费者

  • pagination—— 返回的结果页

  • filter—— 过滤条件,包括返回订单的最大年龄、可选的订单状态以及可选的关键字,这些关键字匹配餐厅名称和菜单项

此查询操作返回一个包含按年龄递增排序的匹配订单摘要的OrderHistory对象。它由实现Order History视图的模块调用。此视图显示每个订单的摘要,包括订单编号、订单状态、订单总额和预计配送时间。

表面上,这个操作与findOrder()查询操作相似。唯一的区别是它返回多个订单而不是一个。看起来API composer只需要对每个Provider service执行相同的查询并合并结果。不幸的是,事情并不那么简单。

这是因为并非所有服务都存储用于过滤或排序的属性。例如,findOrderHistory()操作的一个过滤条件是匹配菜单项的关键字。只有两个服务,Order ServiceKitchen Service,存储Order的菜单项。Delivery ServiceAccounting Service都不存储菜单项,因此不能使用此关键字过滤它们的数据。同样,Kitchen ServiceDelivery Service也不能按orderCreationDate属性排序。

一个API composer可以解决这个问题的两种方式。一种解决方案是API composer进行内存中的连接,如图 7.7 所示。它检索来自Delivery ServiceAccounting Service的所有订单,并与来自Order ServiceKitchen Service检索到的订单进行连接。

图 7.7。API 组合无法有效地检索消费者的订单,因为一些提供者,如Delivery Service,没有存储用于过滤的属性。

图片 7.7

这种方法的缺点是,它可能需要API composer检索和连接大量数据集,这是低效的。

另一种解决方案是API composerOrder ServiceKitchen Service检索匹配的订单,然后通过 ID 请求其他服务的订单。但这只有在那些服务有批量检索 API 的情况下才是实用的。由于网络流量过大,逐个请求订单可能会效率低下。

类似于findOrderHistory()的查询需要API composer复制 RDBMS 查询执行引擎的功能。一方面,这可能会将工作从可扩展性较低的数据库移动到可扩展性较高的应用程序。另一方面,这效率较低。此外,开发者应该编写业务功能,而不是查询执行引擎。

接下来,我将向您展示如何应用 CQRS 模式并使用一个单独的数据存储,该数据存储旨在有效地实现findOrderHistory()查询操作。但在那之前,让我们看看一个查询操作,尽管它位于单个服务中,但实现起来具有挑战性。

具有挑战性的单个服务查询:findAvailableRestaurants()

正如您刚才看到的,实现从多个服务中检索数据的查询可能具有挑战性。但即使是针对单个服务的本地查询也可能很难实现。这可能有几个原因。一方面,正如稍后讨论的,有时拥有数据的服务的实现查询可能并不合适。另一个原因是,有时一个服务的数据库(或数据模型)不支持查询的效率。

findAvailableRestaurants()查询操作为例。这个查询操作找到在特定时间可以配送至指定地址的餐厅。这个查询的核心是针对位于配送地址一定距离内的餐厅的地理空间(基于位置)搜索。它是订单流程的关键部分,并由显示可用餐厅的 UI 模块调用。

实现此查询操作时的主要挑战是执行高效的地理空间查询。如何实现findAvailableRestaurants()查询取决于存储餐厅的数据库的功能。例如,使用 MongoDB 或 Postgres 和 MySQL 的地理空间扩展来实现findAvailableRestaurants()查询非常直接。这些数据库支持地理空间数据类型、索引和查询。当使用这些数据库之一时,Restaurant ServiceRestaurant作为一个具有location属性的数据库记录持久化。它通过在location属性上的地理空间索引优化来执行地理空间查询以找到可用的餐厅。

如果 FTGO 应用程序在某种其他类型的数据库中存储餐厅信息,实现findAvailableRestaurant()查询将更具挑战性。它必须以支持地理空间查询的形式维护餐厅数据的副本。例如,应用程序可以使用 DynamoDB 的地理空间索引库(github.com/awslabs/dynamodb-geo),该库使用表作为地理空间索引。或者,应用程序可以将餐厅数据的副本存储在完全不同类型的数据库中,这种情况与使用文本搜索数据库进行文本查询非常相似。

使用副本的挑战在于,每当原始数据发生变化时,都需要保持副本的更新。正如你下面将要学到的,CQRS 解决了同步副本的问题。

需要分离关注点

单一服务查询难以实现的另一个原因是,有时拥有数据的服务不应该是实现查询的服务。findAvailableRestaurants()查询操作检索由Restaurant Service拥有的数据。该服务使餐厅老板能够管理他们的餐厅资料和菜单项。它存储餐厅的各种属性,包括其名称、地址、菜系、菜单和营业时间。鉴于该服务拥有数据,至少表面上,它实现此查询操作是有意义的。但数据所有权并不是唯一需要考虑的因素。

你还必须考虑到需要分离关注点,避免因过多的责任而使服务过载。例如,开发Restaurant Service团队的 主要责任是使餐厅经理能够维护他们的餐厅。这与实现高流量、关键查询截然不同。更重要的是,如果他们负责findAvailableRestaurants()查询操作,团队将不断生活在担心部署更改阻止消费者下订单的恐惧中。

对于Restaurant Service来说,仅仅将餐厅数据提供给另一个实现findAvailableRestaurants()查询操作的服务是有意义的,而这个服务很可能由Order Service团队拥有。正如findOrderHistory()查询操作一样,在需要维护地理空间索引时,需要维护一些数据的最终一致副本以实现查询。让我们看看如何使用 CQRS 来完成这个任务。

7.2.2. CQRS 概述

在 7.2.1 节中描述的示例突出了在微服务架构中实现查询时常见的三个问题:

  • 使用 API 组合模式检索分散在多个服务中的数据会导致昂贵的、低效的内存连接。

  • 拥有数据的该服务以某种形式或数据库存储数据,该数据库不支持所需的查询操作。

  • 需要分离关注点意味着拥有数据的该服务不应该是实现查询操作的服务。

解决这三个问题的解决方案是使用 CQRS 模式。

CQRS 将命令与查询分离

命令查询责任分离(Command Query Responsibility Segregation),正如其名称所暗示的,完全是关于分离,即关注点的分离。如图 7.8 图 7.8 所示,它将持久数据模型及其使用的模块分为两部分:命令端和查询端。命令端模块和数据模型实现创建、更新和删除操作(简称 CUD——例如,HTTP POSTs、PUTs 和 DELETEs)。查询端模块和数据模型实现查询(例如 HTTP GETs)。查询端通过订阅命令端发布的事件来保持其数据模型与命令端数据模型的同步。

图 7.8.左侧是非 CQRS 版本的服务,右侧是 CQRS 版本。CQRS 将服务重构为命令端和查询端模块,它们具有独立的数据库。

图片

该服务的非 CQRS 和 CQRS 版本都有一个由各种 CRUD 操作组成的 API。在非 CQRS 基于的服务中,这些操作通常由映射到数据库的领域模型实现。为了性能,一些查询可能会绕过领域模型直接访问数据库。单一持久数据模型支持命令和查询。

在基于 CQRS 的服务中,命令端领域模型处理 CRUD 操作,并映射到其自己的数据库。它也可能处理简单的查询,例如非连接、基于主键的查询。命令端在其数据更改时发布领域事件。这些事件可能通过 Eventuate Tram 或事件溯源等框架发布。

一个独立的查询模型处理非平凡查询。它比命令端简单得多,因为它不负责实现业务规则。查询端使用对它必须支持的查询有意义的任何类型的数据库。查询端有事件处理器,它们订阅领域事件并更新数据库或数据库。甚至可能有多个查询模型,每个模型对应于查询的一种类型。

CQRS 和仅查询服务

不仅可以在服务内部应用 CQRS,而且还可以使用此模式来定义查询服务。查询服务有一个仅包含查询操作的 API——没有命令操作。它通过查询一个数据库来实现查询操作,该数据库通过订阅一个或多个其他服务发布的事件来保持最新。查询端服务是实施通过订阅多个服务发布的事件构建的视图的好方法。这种视图不属于任何特定服务,因此将其作为独立服务实现是有意义的。此类服务的良好示例是 订单历史服务,这是一个实现 findOrderHistory() 查询操作的查询服务。如图 7.9 所示,此服务订阅了包括 订单服务配送服务 等在内的多个服务发布的事件。

图 7.9。订单历史服务 的设计,它是一个查询端服务。它通过查询数据库来实现 findOrderHistory() 查询操作,该数据库通过订阅多个其他服务发布的事件来维护。

图 7.9

订单历史服务 有事件处理器,它们订阅由多个服务发布的事件,并更新 订单历史视图数据库。我在 第 7.4 节 中更详细地描述了此服务的实现。

查询服务也是实现一个复制单个服务拥有的数据的视图的好方法,但由于需要分离关注点,它并不属于该服务的一部分。例如,FTGO 开发者可以定义一个 可用餐厅服务,该服务实现了前面描述的 findAvailableRestaurants() 查询操作。它订阅由 餐厅服务 发布的事件,并更新一个专为高效地理空间查询设计的数据库。

在许多方面,CQRS 是一种基于事件的通用方法,它将流行的使用 RDBMS 作为记录系统以及文本搜索引擎(如 Elasticsearch)来处理文本查询的方法进行了扩展。不同之处在于,CQRS 使用了更广泛的数据库类型——不仅仅是文本搜索引擎。此外,CQRS 查询端视图通过订阅事件在近实时更新。

现在我们来看看 CQRS 的优点和缺点。

7.2.3. CQRS 的优点

CQRS 既有优点也有缺点。其优点如下:

  • 使微服务架构中查询的高效实现成为可能

  • 使多样化查询的高效实现成为可能

  • 在基于事件源的应用中实现查询成为可能

  • 提高了关注点的分离

在微服务架构中实现查询的高效实现

CQRS 模式的一个好处是它有效地实现了检索多个服务拥有的数据的查询。如前所述,使用 API 组合模式来实现查询有时会导致大型数据集昂贵的、低效的内存连接。对于这些查询,使用一个易于查询的 CQRS 视图(预先连接来自两个或更多服务的数据)更有效率。

允许高效地实现多样化的查询

CQRS 的另一个好处是它允许应用程序或服务高效地实现一系列不同的查询。尝试使用单个持久数据模型来支持所有查询通常具有挑战性,在某些情况下甚至不可能。一些 NoSQL 数据库的查询能力非常有限。即使数据库有扩展来支持特定类型的查询,使用专用数据库通常更有效率。CQRS 模式通过定义一个或多个视图来避免单个数据存储的限制,每个视图都有效地实现了特定的查询。

在基于事件源的应用中实现查询

CQRS 还克服了事件源的一个主要限制。事件存储只支持基于主键的查询。CQRS 模式通过定义一个或多个聚合视图来解决这个问题,这些视图通过订阅基于事件源聚合发布的事件流来保持最新状态。因此,基于事件源的应用不可避免地使用 CQRS。

提高了关注点的分离

CQRS 的另一个好处是它分离了关注点。领域模型及其对应的持久数据模型不处理命令和查询。CQRS 模式为服务的命令和查询方面定义了单独的代码模块和数据库模式。通过分离关注点,命令方面和查询方面可能更简单且更容易维护。

此外,CQRS 允许实现查询的服务与拥有数据的服务不同。例如,我之前描述了尽管 Restaurant Service 拥有 findAvailableRestaurants 查询操作所查询的数据,但由另一个服务实现这样一个关键、高流量的查询是有意义的。CQRS 查询服务通过订阅拥有数据的服务或服务发布的事件来维护一个视图。

7.2.4. CQRS 的缺点

尽管 CQRS 有几个好处,但它也有显著的缺点:

  • 更复杂的架构

  • 处理复制延迟

让我们来看看这些缺点,首先是复杂性增加。

更复杂的架构

CQRS 的一个缺点是它增加了复杂性。开发者必须编写更新和查询视图的查询端服务。还有管理和操作额外数据存储的额外操作复杂性。更重要的是,一个应用程序可能使用不同类型的数据库,这给开发者和运维都增加了进一步的复杂性。

处理复制延迟

CQRS 的另一个缺点是处理命令端和查询端视图之间的“延迟”。正如你可能预料的那样,当命令端发布事件和该事件被查询端处理以及视图更新之间的延迟。一个更新聚合并立即查询视图的客户端应用可能会看到聚合的旧版本。它必须经常以避免向用户暴露这些潜在的不一致性为前提来编写。

一种解决方案是命令端和查询端 API 向客户端提供版本信息,使其能够知道查询端已过时。客户端可以轮询查询端视图,直到它是最新的。我很快就会讨论服务 API 如何使客户端能够做到这一点。

像原生移动应用或单页 JavaScript 应用这样的 UI 应用可以通过在命令成功后更新其本地模型一次来处理复制延迟,而不发出查询。例如,它可以使用命令返回的数据更新其模型。希望当用户操作触发查询时,视图将是最新的。这种方法的缺点是 UI 代码可能需要复制服务器端代码以更新其模型。

如你所见,CQRS 既有优点也有缺点。如前所述,你应该尽可能使用 API 组合,并在必须时才使用 CQRS。

现在你已经看到了 CQRS 的优点和缺点,让我们现在看看如何设计 CQRS 视图。

7.3. 设计 CQRS 视图

CQRS 视图模块有一个由一个或多个查询操作组成的 API。它通过订阅一个或多个服务发布的事件来维护它所查询的数据库,从而实现这些查询操作。如图 7.10 所示,视图模块由一个视图数据库和三个子模块组成。

图 7.10。CQRS 视图模块的设计。事件处理程序更新视图数据库,该数据库被查询 API 模块查询。

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

在开发视图模块时,你必须做出一些重要的设计决策:

  • 你必须选择一个数据库并设计模式。

  • 在设计数据访问模块时,你必须解决各种问题,包括确保更新是幂等的以及处理并发更新。

  • 在现有应用程序中实现新的视图或更改现有应用程序的模式时,您必须实现一个机制来高效地构建或重建视图。

  • 您必须决定如何使视图的客户端能够处理前面描述的复制延迟。

让我们看看这些问题中的每一个。

7.3.1. 选择视图数据存储

一个关键的设计决策是数据库的选择和模式的设计。数据库和数据模型的主要目的是高效地实现视图模块的查询操作。在选择数据库时,主要考虑的是这些查询的特性。但是,数据库也必须高效地实现事件处理器执行的操作更新。

SQL 与 NoSQL 数据库

不久以前,有一种类型的数据库可以统治一切:基于 SQL 的关系型数据库管理系统(RDBMS)。然而,随着网络的普及,各种公司发现 RDBMS 无法满足它们的 Web 规模需求。这导致了所谓的 NoSQL 数据库的创建。一个 NoSQL 数据库 通常具有有限的交易形式和较少的通用查询能力。对于某些用例,这些数据库在 SQL 数据库之上具有某些优势,包括更灵活的数据模型、更好的性能和可扩展性。

对于 CQRS 视图,通常选择一个 NoSQL 数据库是一个不错的选择,这样可以利用其优势并忽略其弱点。CQRS 视图受益于 NoSQL 数据库更丰富的数据模型和性能。它不受 NoSQL 数据库限制的影响,因为它只使用简单的交易并执行一组固定的查询。

虽然如此,有时使用 SQL 数据库来实现 CQRS 视图是有意义的。在现代硬件上运行的现代关系型数据库管理系统(RDBMS)具有出色的性能。开发者、数据库管理员和 IT 运营人员通常比 NoSQL 数据库更熟悉 SQL 数据库。如前所述,SQL 数据库通常具有非关系型功能的扩展,例如地理空间数据类型和查询。此外,CQRS 视图可能需要使用 SQL 数据库来支持报告引擎。

正如您在表 7.1 中可以看到的,有很多不同的选项可供选择。而且为了使选择更加复杂,不同类型数据库之间的差异开始变得模糊。例如,MySQL,作为一个关系型数据库管理系统(RDBMS),对 JSON 的支持非常出色,这是 MongoDB(一个 JSON 风格的面向文档的数据库)的一个优势。

表 7.1. 查询端视图存储
如果您需要 使用 示例
基于主键的 JSON 对象查找 例如 MongoDB 或 DynamoDB 这样的文档存储,或如 Redis 这样的键值存储 通过维护包含每个客户的 MongoDB 文档来实现订单历史记录。
基于查询的 JSON 对象查找 例如 MongoDB 或 DynamoDB 这样的文档存储 使用 MongoDB 或 DynamoDB 实现客户视图。
文本查询 如 Elasticsearch 这样的文本搜索引擎 通过维护每个订单的 Elasticsearch 文档来实现订单的文本搜索。
图查询 如 Neo4j 这样的图数据库 通过维护客户、订单和其他数据的图来实现欺诈检测。
传统 SQL 报告/BI RDBMS 标准的商业报告和分析。

既然我已经讨论了你可以用来实现 CQRS 视图的数据库类型,让我们看看如何高效地更新视图的问题。

支持更新操作

除了高效地实现查询外,视图数据模型还必须高效地实现事件处理器执行更新操作。通常,事件处理器将使用其主键在视图数据库中更新或删除记录。例如,我很快将描述 findOrderHistory() 查询的 CQRS 视图设计。它使用 orderId 作为主键将每个 Order 存储为数据库记录。当这个视图从 Order Service 接收到事件时,它可以直接更新相应的记录。

有时,它可能需要使用外键的等效方式来更新或删除记录。例如,考虑 Delivery* 事件的处理器。如果一个 Delivery 与一个 Order 之间存在一对一的对应关系,那么 Delivery.id 可能与 Order.id 相同。如果是这样,那么 Delivery* 事件处理器可以轻松地更新订单的数据库记录。

但假设 Delivery 有自己的主键,或者 OrderDelivery 之间存在一对多关系。某些 Delivery* 事件,如 DeliveryCreated 事件,将包含 orderId。但其他事件,如 DeliveryPickedUp 事件,可能不会。在这种情况下,DeliveryPickedUp 事件处理器需要使用 deliveryId 作为外键的等效方式来更新订单的记录。

一些类型的数据库有效地支持基于外键的更新操作。例如,如果你使用的是 RDBMS 或 MongoDB,你将在必要的列上创建索引。然而,当使用其他 NoSQL 数据库时,非主键的更新并不直接。应用程序需要维护某种数据库特定的映射,从外键到主键,以便确定要更新的记录。例如,使用仅支持基于主键的更新和删除的 DynamoDB 的应用程序必须首先查询 DynamoDB 的辅助索引(稍后讨论),以确定要更新或删除的项目的主键。

7.3.2. 数据访问模块设计

事件处理器和查询 API 模块不直接访问数据存储。相反,它们使用数据访问模块,该模块由一个数据访问对象(DAO)及其辅助类组成。DAO 有几个职责。它实现了由事件处理器调用的更新操作和由查询模块调用的查询操作。DAO 在高级代码使用的数据类型和数据库 API 之间进行映射。它还必须处理并发更新并确保更新是幂等的。

让我们来看看这些问题,从如何处理并发更新开始。

处理并发

有时 DAO 必须处理对同一数据库记录进行多次并发更新的可能性。如果一个视图订阅了单个聚合类型发布的事件,将不会出现任何并发问题。这是因为特定聚合实例发布的事件是顺序处理的。因此,对应于聚合实例的记录不会同时更新。但是,如果一个视图订阅了多个聚合类型发布的事件,那么可能多个事件处理器会同时更新相同的记录。

例如,一个针对 Order* 事件的处理器可能同时被同一个订单的 Delivery* 事件的处理器调用。然后这两个事件处理器同时调用 DAO 来更新该 Order 的数据库记录。DAO 必须以正确处理这种情况的方式编写。它不能允许一个更新覆盖另一个更新。如果 DAO 通过读取记录然后写入更新后的记录来实现更新,它必须使用悲观锁或乐观锁。在下一节中,您将看到一个通过不先读取数据库记录来更新数据库记录以处理并发更新的 DAO 的示例。

幂等事件处理器

如第三章所述,一个事件处理器可能被同一个事件多次调用。如果查询端事件处理器是幂等的,这通常不是问题。一个事件处理器是幂等的,如果处理重复事件会导致正确的结果。在最坏的情况下,视图数据存储将暂时过时。例如,维护 Order History 视图的事件处理器可能被(虽然可能性极低)以下事件序列调用:DeliveryPickedUpDeliveryDeliveredDeliveryPickedUpDeliveryDelivered。在第一次发送 DeliveryPickedUpDeliveryDelivered 事件后,消息代理,可能是因为网络错误,开始从较早的时间点发送事件,因此重新发送 DeliveryPickedUpDeliveryDelivered

图 7.11. DeliveryPickedUpDeliveryDelivered 事件被发送了两次,这导致视图中的订单状态暂时过时。

图 7.11

在事件处理器处理第二个DeliveryPickedUp事件之后,订单历史视图暂时包含订单的过时状态,直到处理了DeliveryDelivered。如果这种行为不可取,那么事件处理器应该检测并丢弃重复的事件,就像非幂等事件处理器一样。

如果重复的事件导致结果不正确,则事件处理器不是幂等的。例如,增加银行账户余额的事件处理器不是幂等的。非幂等事件处理器必须,如第三章所述,通过记录它在视图数据存储中处理的事件的 ID 来检测和丢弃重复的事件。

为了可靠,事件处理器必须记录事件 ID 并以原子方式更新数据存储。如何做这取决于数据库类型。如果视图数据库存储是 SQL 数据库,事件处理器可以在更新视图的事务中插入已处理的事件到PROCESSED_EVENTS表。但如果视图数据存储是具有有限事务模型的 NoSQL 数据库,事件处理器必须在它更新的数据存储“记录”(例如,MongoDB 文档或 DynamoDB 表项)中保存事件。

需要注意的是,事件处理器不需要记录每个事件的 ID。如果,像 Eventuate 那样,事件具有单调递增的 ID,那么每个记录只需要存储从给定的聚合实例接收到的max(eventId)。此外,如果记录对应于单个聚合实例,那么事件处理器只需要记录max(eventId)。只有代表来自多个聚合的事件连接的记录必须包含从[aggregate type, aggregate id]max(eventId)的映射。

例如,你很快就会看到订单历史视图的 DynamoDB 实现包含具有跟踪事件的属性,这些属性看起来像这样:

{...
      "Order3949384394-039434903" : "0000015e0c6fc18f-0242ac1100e50002",
      "Delivery3949384394-039434903" : "0000015e0c6fc264-0242ac1100e50002",
   }

此视图是各种服务发布的事件的连接。这些事件跟踪属性的名称是«aggregateType»«aggregateId»,其值是eventId。稍后我将更详细地描述它是如何工作的。

启用客户端应用程序使用最终一致视图

如我之前所说,使用 CQRS 的一个问题是,更新命令端并立即执行查询的客户端可能看不到自己的更新。视图最终一致是因为消息基础设施不可避免的延迟。

命令和查询模块 API 可以使客户端通过以下方法检测不一致。命令端操作返回一个包含已发布事件 ID 的令牌给客户端。然后客户端将令牌传递给查询操作,如果视图尚未被该事件更新,则查询操作返回错误。视图模块可以使用重复事件检测机制实现此机制。

7.3.3. 添加和更新 CQRS 视图

CQRS 视图将在应用程序的生命周期内添加和更新。有时你需要添加一个新的视图来支持新的查询。在其他时候,你可能需要重新创建视图,因为模式已更改或需要修复更新视图的代码中的错误。

添加和更新视图在概念上相当简单。要创建一个新的视图,你需要开发查询模块,设置数据存储,并部署服务。查询模块的事件处理器处理所有事件,最终视图将是最新的。同样,更新现有视图在概念上也很简单:你更改事件处理器并从头开始重建视图。然而,问题在于这种方法在实践中可能不起作用。让我们看看问题所在。

使用归档事件构建 CQRS 视图

一个问题是消息代理不能无限期地存储消息。传统的消息代理,如 RabbitMQ,一旦消费者处理了消息就会删除该消息。甚至更现代的代理,如 Apache Kafka,虽然可以保留消息一段时间,但并不是为了无限期地存储事件。因此,不能仅通过从消息代理中读取所有所需事件来构建视图。相反,应用程序还必须读取存档在例如 AWS S3 中的较旧事件。你可以通过使用可扩展的大数据技术,如 Apache Spark,来实现这一点。

逐步构建 CQRS 视图

视图创建的另一个问题是,处理所有事件所需的时间和资源会随着时间的推移而不断增加。最终,视图创建将变得过于缓慢且成本高昂。解决方案是使用两步增量算法。第一步基于每个聚合实例的先前快照以及自该快照创建以来发生的事件,定期计算每个聚合实例的快照。第二步使用这些快照和任何后续事件创建视图。

7.4. 实现使用 AWS DynamoDB 的 CQRS 视图

现在我们已经探讨了在使用 CQRS 时必须解决的各个设计问题,让我们考虑一个例子。本节描述了如何使用 DynamoDB 实现针对 findOrderHistory() 操作的 CQRS 视图。AWS DynamoDB 是一种可扩展的 NoSQL 数据库,可在亚马逊云上作为一项服务提供。DynamoDB 数据模型由包含项的表组成,这些项类似于 JSON 对象,是层次结构化的名称-值对的集合。AWS DynamoDB 是一个完全管理的数据库,你可以动态地上下调整表的吞吐量容量。

findOrderHistory() 的 CQRS 视图从多个服务消费事件,因此它被实现为一个独立的 Order View Service。该服务有一个 API,实现了两个操作:findOrderHistory()findOrder()。尽管 findOrder() 可以通过 API 组合来实现,但这个视图免费提供了这个操作。图 7.12 展示了该服务的架构。Order History Service 被构建为一组模块,每个模块都实现特定的职责,以简化开发和测试。每个模块的职责如下:

  • OrderHistoryEventHandlers—订阅由各种服务发布的事件并调用 OrderHistoryDAO

  • OrderHistoryQuery API 模块—实现了之前描述的 REST 端点

  • OrderHistoryDataAccess—包含 OrderHistoryDAO,它定义了更新和查询 ftgo-order-history DynamoDB 表及其辅助类的方法

  • ftgo-order-history DynamoDB 表—存储订单的表

图 7.12. OrderHistoryService 的设计。OrderHistoryEventHandlers 在响应事件时更新数据库。OrderHistoryQuery 模块通过查询数据库来实现查询操作。这两个模块使用 OrderHistoryDataAccess 模块来访问数据库。

图片

让我们更详细地看看事件处理器、DAO 和 DynamoDB 表的设计。

7.4.1. OrderHistoryEventHandlers 模块

此模块由消费事件并更新 DynamoDB 表的事件处理器组成。如下所示,事件处理器是简单的函数。每个方法都是一行代码,它调用 OrderHistoryDao 方法,并使用从事件中派生的参数。

列表 7.1. 调用 OrderHistoryDao 的事件处理器
public class OrderHistoryEventHandlers {

  private OrderHistoryDao orderHistoryDao;

  public OrderHistoryEventHandlers(OrderHistoryDao orderHistoryDao) {
    this.orderHistoryDao = orderHistoryDao;
  }

  public void handleOrderCreated(DomainEventEnvelope<OrderCreated> dee) {
    orderHistoryDao.addOrder(makeOrder(dee.getAggregateId(), dee.getEvent()),
                              makeSourceEvent(dee));
  }

  private Order makeOrder(String orderId, OrderCreatedEvent event) {
    ...
  }

  public void handleDeliveryPickedUp(DomainEventEnvelope<DeliveryPickedUp>
                                             dee) {
   orderHistoryDao.notePickedUp(dee.getEvent().getOrderId(),
           makeSourceEvent(dee));
  }

  ...

每个事件处理器有一个类型为 DomainEventEnvelope 的单个参数,它包含事件和一些描述事件的元数据。例如,handleOrderCreated() 方法被调用来处理 OrderCreated 事件。它调用 orderHistoryDao.addOrder() 在数据库中创建一个 Order。同样,handleDeliveryPickedUp() 方法被调用来处理 DeliveryPickedUp 事件。它调用 orderHistoryDao.notePickedUp() 更新数据库中 Order 的状态。

两个方法都调用辅助方法 makeSourceEvent(),该方法构建一个包含发出事件的聚合类型和 ID 以及事件 ID 的 SourceEvent。在下一节中,您将看到 OrderHistoryDao 使用 SourceEvent 来确保更新操作是幂等的。

现在我们来看 DynamoDB 表的设计,然后检查 OrderHistoryDao

7.4.2. 使用 DynamoDB 进行数据建模和查询设计

与许多 NoSQL 数据库一样,DynamoDB 的数据访问操作比 RDBMS 提供的弱得多。因此,您必须仔细设计数据的存储方式。特别是,查询通常决定了模式的设计。我们需要解决几个设计问题:

  • 设计 ftgo-order-history

  • findOrderHistory 查询定义索引

  • 实现 findOrderHistory 查询

  • 分页查询结果

  • 更新订单

  • 检测重复事件

我们将逐一查看每个问题。

设计 ftgo-order-history 表

DynamoDB 存储模型由表组成,其中包含项目,以及索引,它们提供了访问表项目的替代方式(稍后讨论)。一个项目是一组命名属性。一个属性值可以是字符串等标量值,也可以是字符串的多值集合,或者是一组命名属性。尽管一个项目在关系型数据库管理系统(RDBMS)中相当于一行,但它要灵活得多,可以存储整个聚合。

这种灵活性使得 OrderHistoryDataAccess 模块能够将每个 Order 作为单个项目存储在名为 ftgo-order-history 的 DynamoDB 表中。Order 类的每个字段都映射到一个项目属性,如图 7.13 所示。图 7.13。简单的字段,如 orderCreationTimestatus,映射到单值项目属性。lineItems 字段映射到一个列表属性,其中每个时间线对应一个映射。它可以被视为对象的 JSON 数组。

图 7.13. DynamoDB OrderHistory 表的初步结构

图片

表定义的一个重要部分是其主键。DynamoDB 应用程序通过主键插入、更新和检索表的项目。主键似乎是 orderId。这使得 Order History Service 能够通过 orderId 插入、更新和检索订单。但在最终确定这个决定之前,让我们首先探讨表的主键如何影响它支持的数据访问操作类型。

为 findOrderHistory 查询定义索引

此表定义支持基于主键的Orders的读取和写入。但它不支持返回按年龄递增排序的多个匹配订单的findOrderHistory()查询。这是因为,正如你将在本节后面看到的那样,此查询使用 DynamoDB 的query()操作,该操作要求表具有由两个标量属性组成的复合主键。第一个属性是分区键。所谓的分区键是因为 DynamoDB 的 Z 轴扩展(在第一章中描述)用它来选择一个项目的存储分区。第二个属性是排序键。query()操作返回具有指定分区键、具有指定范围内的排序键并匹配可选过滤表达式的项目。它按排序键指定的顺序返回项目。

findOrderHistory()查询操作返回按年龄递增排序的消费者订单。因此,它需要一个具有consumerId作为分区键和orderCreationDate作为排序键的主键。但(consumerId, orderCreationDate)作为ftgo-order-history表的主键没有意义,因为它不是唯一的。

解决方案是让findOrderHistory()查询 DynamoDB 在ftgo-order-history表上称为二级索引的内容。此索引的非唯一键为(consumerId, orderCreationDate)。像 RDBMS 索引一样,DynamoDB 索引在其表更新时自动更新。但与典型的 RDBMS 索引不同,DynamoDB 索引可以具有非键属性。非键属性可以提高性能,因为它们由查询返回,所以应用程序不需要从表中获取它们。此外,正如你很快就会看到的那样,它们可以用于过滤。图 7.14 显示了表和此索引的结构。

图 7.14. OrderHistory表和索引的设计

图片

该索引是ftgo-order-history表定义的一部分,称为ftgo-order-history-by-consumer-id-and-creation-time。索引的属性包括主键属性consumerIdorderCreationTime,以及非键属性,包括orderIdstatus

ftgo-order-history-by-consumer-id-and-creation-time索引使OrderHistoryDaoDynamoDb能够高效地检索按年龄递增排序的消费者订单。

现在我们来看如何检索仅匹配过滤条件的那些订单。

实现 findOrderHistory 查询

findOrderHistory()查询操作有一个filter参数,它指定了搜索条件。一个过滤器条件是返回的订单的最大年龄。这很容易实现,因为 DynamoDB 的Query操作的支持对排序键的范围限制的键条件表达式。其他过滤器条件对应于非键属性,可以使用过滤器表达式实现,这是一个布尔表达式。DynamoDB 的Query操作只返回满足过滤器表达式的项目。例如,为了找到已取消OrdersOrderHistoryDaoDynamoDb使用查询表达式orderStatus = :orderStatus,其中:orderStatus是一个占位符参数。

关键字过滤器条件更难实现。它选择那些餐厅名称或菜单项与指定的关键字之一匹配的订单。OrderHistoryDaoDynamoDb通过将餐厅名称和菜单项进行分词并将关键字集合存储在一个名为keywords的集合值属性中来启用关键字搜索。它通过使用包含contains()函数的过滤器表达式来找到匹配关键字的订单,例如contains(keywords, :keyword1) OR contains(keywords, :keyword2),其中:keyword1:keyword2是特定关键字的占位符。

分页查询结果

一些消费者会有大量的订单。因此,对于findOrderHistory()查询操作使用分页是有意义的。DynamoDB 的Query操作有一个pageSize参数,它指定了要返回的最大项目数。如果有更多项目,查询的结果将包含一个非空的LastEvaluatedKey属性。一个 DAO 可以通过设置exclusiveStartKey参数为LastEvaluatedKey来调用查询以检索下一页的项目。

如您所见,DynamoDB 不支持基于位置的分页。因此,Order History Service向其客户端返回一个不透明的分页令牌。客户端使用这个分页令牌来请求下一页的结果。

现在我已经描述了如何查询 DynamoDB 中的订单,让我们看看如何插入和更新它们。

更新订单

DynamoDB 支持两种用于添加和更新项目的操作:PutItem()UpdateItem()PutItem()操作通过其主键创建或替换整个项目。理论上,OrderHistoryDaoDynamoDb可以使用此操作来插入和更新订单。然而,使用PutItem()的一个挑战是确保对同一项目的并发更新被正确处理。

例如,考虑两个事件处理器同时尝试更新同一项目的场景。每个事件处理器都会调用OrderHistoryDaoDynamoDb从 DynamoDB 加载项目,在内存中更改它,并使用PutItem()在 DynamoDB 中更新它。一个事件处理器可能会覆盖另一个事件处理器所做的更改。OrderHistoryDaoDynamoDb可以通过使用 DynamoDB 的乐观锁定机制来防止更新丢失。但一个更简单、更有效的方法是使用UpdateItem()操作。

UpdateItem()操作更新项目的单个属性,如果需要则创建项目。由于不同的事件处理器更新Order项目的不同属性,因此使用UpdateItem是有意义的。此操作也更高效,因为不需要首先从表中检索订单。

在响应事件更新数据库时遇到的挑战之一,如前所述,是检测和丢弃重复事件。让我们看看在使用 DynamoDB 时如何做到这一点。

检测重复事件

Order History Service的所有事件处理器都是幂等的。每个处理器都会设置Order项目的单个或多个属性。因此,Order History Service可以简单地忽略重复事件的问题。然而,忽略问题的缺点是Order项目有时会暂时过时。这是因为接收重复事件的处理器会将Order项目的属性设置为以前的值。Order项目将不会具有正确的值,直到稍后的事件重新传递。

如前所述,防止数据过时的方法之一是检测和丢弃重复事件。OrderHistoryDaoDynamoDb可以通过在每一项中记录导致其更新的事件来检测重复事件。然后,它可以使用UpdateItem()操作的条件更新机制,仅在事件不是重复事件时更新项目。

仅当条件表达式为真时才执行条件更新。条件表达式测试属性是否存在或具有特定值。OrderHistoryDaoDynamoDb DAO 可以使用一个名为«aggregateType»«aggregateId»的属性来跟踪从每个聚合实例接收的事件,其值是接收到的最高事件 ID。如果属性存在且其值小于或等于事件 ID,则事件是重复的。OrderHistoryDaoDynamoDb DAO 使用以下条件表达式:

attribute_not_exists(«aggregateType»«aggregateId»)
     OR «aggregateType»«aggregateId» < :eventId

条件表达式 仅允许在属性不存在或eventId大于最后处理的事件 ID 时进行更新。

例如,假设事件处理器接收来自 ID 为 3949384394-039434903Delivery 聚合的 ID 为 123323-343434DeliveryPickup 事件。跟踪属性名为 Delivery3949384394-039434903。如果此属性值大于或等于 123323-343434,事件处理器应认为该事件是重复的。事件处理器调用的 query() 操作使用此条件表达式更新 Order 项:

attribute_not_exists(Delivery3949384394-039434903)
     OR Delivery3949384394-039434903 < :eventId

现在我已经描述了 DynamoDB 数据模型和查询设计,让我们看看 OrderHistoryDaoDynamoDb,它定义了更新和查询 ftgo-order-history 表的方法。

7.4.3. OrderHistoryDaoDynamoDb

OrderHistoryDaoDynamoDb 类实现了在 ftgo-order-history 表中读写项的方法。它的更新方法由 OrderHistoryEventHandlers 调用,而它的查询方法由 OrderHistoryQuery API 调用。让我们看看一些示例方法,从 addOrder() 方法开始。

addOrder() 方法

如 列表 7.2 所示的 addOrder() 方法将订单添加到 ftgo-order-history 表中。它有两个参数:ordersourceEventorder 参数是要添加的 Order,它从 OrderCreated 事件中获取。sourceEvent 参数包含 eventId 以及发出事件的聚合的类型和 ID。它用于实现条件更新。

列表 7.2. addOrder() 方法添加或更新一个 Order
public class OrderHistoryDaoDynamoDb ...

@Override
public boolean addOrder(Order order, Optional<SourceEvent> eventSource) {
 UpdateItemSpec spec = new UpdateItemSpec()
         .withPrimaryKey("orderId", order.getOrderId())                      *1*
         .withUpdateExpression("SET orderStatus = :orderStatus, " +          *2*
                  "creationDate = :cd, consumerId = :consumerId, lineItems =" +
                 " :lineItems, keywords = :keywords, restaurantName = " +
                 ":restaurantName")
         .withValueMap(new Maps()                                            *3*
                  .add(":orderStatus", order.getStatus().toString())
                 .add(":cd", order.getCreationDate().getMillis())
                 .add(":consumerId", order.getConsumerId())
                 .add(":lineItems", mapLineItems(order.getLineItems()))
                 .add(":keywords", mapKeywords(order))
                 .add(":restaurantName", order.getRestaurantName())
                 .map())
         .withReturnValues(ReturnValue.NONE);
 return idempotentUpdate(spec, eventSource);
}
  • 1 要更新的 Order 项的主键

  • 2 更新属性的表达式

  • 3 更新表达式中占位符的值

addOrder() 方法创建一个 UpdateSpec,它是 AWS SDK 的一部分,用于描述更新操作。在创建 UpdateSpec 之后,它调用 idempotentUpdate(),这是一个辅助方法,在添加防止重复更新的条件表达式后执行更新。

notePickedUp() 方法

如 列表 7.3 所示的 notePickedUp() 方法由 DeliveryPickedUp 事件的处理器调用。它将 Order 项的 deliveryStatus 更改为 PICKED_UP

列表 7.3. notePickedUp() 方法将订单状态更改为 PICKED_UP
public class OrderHistoryDaoDynamoDb ...

@Override
public void notePickedUp(String orderId, Optional<SourceEvent> eventSource) {
 UpdateItemSpec spec = new UpdateItemSpec()
         .withPrimaryKey("orderId", orderId)
         .withUpdateExpression("SET #deliveryStatus = :deliveryStatus")
         .withNameMap(Collections.singletonMap("#deliveryStatus",
                 DELIVERY_STATUS_FIELD))
         .withValueMap(Collections.singletonMap(":deliveryStatus",
                 DeliveryStatus.PICKED_UP.toString()))
         .withReturnValues(ReturnValue.NONE);
 idempotentUpdate(spec, eventSource);
}

此方法类似于 addOrder()。它创建一个 UpdateItemSpec 并调用 idempotentUpdate()。让我们看看 idempotentUpdate() 方法。

idempotentUpdate() 方法

以下列表显示了 idempotentUpdate() 方法,该方法在可能向 UpdateItemSpec 添加条件表达式以防止重复更新后更新项。

列表 7.4. idempotentUpdate() 方法忽略重复事件
public class OrderHistoryDaoDynamoDb ...

private boolean idempotentUpdate(UpdateItemSpec spec, Optional<SourceEvent>
        eventSource) {
 try {
  table.updateItem(eventSource.map(es -> es.addDuplicateDetection(spec))
          .orElse(spec));
  return true;
 } catch (ConditionalCheckFailedException e) {
  // Do nothing
  return false;
 }
}

如果提供了 sourceEventidempotentUpdate() 将调用 SourceEvent.addDuplicateDetection() 将之前描述的条件表达式添加到 UpdateItemSpec 中。idempotentUpdate() 方法捕获并忽略由 updateItem() 抛出的 ConditionalCheckFailedException,如果事件是重复的。

现在我们已经看到了更新表的代码,让我们看看查询方法。

findOrderHistory() 方法

如 列表 7.5 所示的 findOrderHistory() 方法通过查询 ftgo-order-history 表并使用 ftgo-order-history-by-consumer-id-and-creation-time 次级索引来检索消费者的订单。它有两个参数:consumerId 指定消费者,filter 指定搜索条件。此方法从其参数创建 QuerySpec(与 UpdateSpec 一样,是 AWS SDK 的一部分),查询索引并将返回的项目转换为 OrderHistory 对象。

列表 7.5. findOrderHistory() 方法检索消费者的匹配订单
public class OrderHistoryDaoDynamoDb ...

@Override
public OrderHistory findOrderHistory(String consumerId, OrderHistoryFilter
        filter) {

 QuerySpec spec = new QuerySpec()
         .withScanIndexForward(false)                                    *1*
          .withHashKey("consumerId", consumerId)
         .withRangeKeyCondition(new RangeKeyCondition("creationDate")    *2*
                                  .gt(filter.getSince().getMillis()));

 filter.getStartKeyToken().ifPresent(token ->
       spec.withExclusiveStartKey(toStartingPrimaryKey(token)));

 Map<String, Object> valuesMap = new HashMap<>();

 String filterExpression = Expressions.and(                              *3*
          keywordFilterExpression(valuesMap, filter.getKeywords()),
         statusFilterExpression(valuesMap, filter.getStatus()));

 if (!valuesMap.isEmpty())
  spec.withValueMap(valuesMap);

 if (StringUtils.isNotBlank(filterExpression)) {
  spec.withFilterExpression(filterExpression);
 }

 filter.getPageSize().ifPresent(spec::withMaxResultSize);                *4*

 ItemCollection<QueryOutcome> result = index.query(spec);

 return new OrderHistory(
         StreamSupport.stream(result.spliterator(), false)
            .map(this::toOrder)                                          *5*
             .collect(toList()),
         Optional.ofNullable(result
               .getLastLowLevelResult()
               .getQueryResult().getLastEvaluatedKey())
            .map(this::toStartKeyToken));
}
  • 1 指定查询必须按年龄递增的顺序返回订单**

  • 2 返回订单的最大年龄**

  • 3OrderHistoryFilter 构建一个过滤器表达式和占位符值映射。

  • 4 如果调用者指定了页面大小,则限制结果数量。**

  • 5 从查询返回的项目创建一个订单。**

在构建 QuerySpec 之后,此方法然后执行查询并从返回的项目构建一个 OrderHistory,其中包含 Orders 列表。

findOrderHistory() 方法通过将 getLastEvaluatedKey() 返回的值序列化为 JSON 令牌来实现分页。如果客户端在 OrderHistoryFilter 中指定了一个起始令牌,那么 findOrderHistory() 将会序列化它并调用 withExclusiveStartKey() 来设置起始键。

正如你所见,在实现 CQRS 视图时,你必须解决许多问题,包括选择数据库、设计高效实现更新和查询的数据模型、处理并发更新以及处理重复事件。代码中唯一复杂的部分是 DAO,因为它必须正确处理并发并确保更新是幂等的。

摘要

  • 实现从多个服务检索数据的查询具有挑战性,因为每个服务的数据是私有的。

  • 实现这类查询有两种方式:API 组合模式和命令查询责任分离(CQRS)模式。

  • 从多个服务中收集数据的 API 组合模式是实现查询的最简单方式,应尽可能使用。

  • API 组合模式的一个限制是,一些复杂的查询需要在大数据集上进行低效的内存连接。

  • CQRS 模式,通过使用视图数据库来实现查询,功能更强大但实现起来更复杂。

  • CQRS 视图模块必须处理并发更新以及检测和丢弃重复事件。

  • CQRS 通过允许一个服务实现返回不同服务所拥有数据的查询,从而提高了关注点的分离。

  • 客户端必须处理 CQRS 视图的最终一致性。

第八章. 外部 API 模式

本章涵盖

  • 设计支持多样化客户端的 API 的挑战

  • 应用 API 网关和后端为前端模式

  • 设计和实现 API 网关

  • 使用响应式编程简化 API 组合

  • 使用 GraphQL 实现 API 网关

与许多其他应用程序一样,FTGO 应用程序有一个 REST API。其客户端包括 FTGO 移动应用程序、在浏览器中运行的 JavaScript 以及合作伙伴开发的应用程序。在这样的单体架构中,暴露给客户端的 API 是单体 API。但是当 FTGO 团队开始部署微服务时,就不再有一个 API 了,因为每个服务都有自己的 API。玛丽和她的团队必须决定 FTGO 应用程序现在应该向其客户端暴露哪种类型的 API。例如,客户端是否应该知道服务的存在并直接向它们发出请求?

设计应用程序外部 API 的任务由于客户端的多样性而变得更加具有挑战性。不同的客户端通常需要不同的数据。基于桌面浏览器的 UI 通常显示比移动应用程序多得多的信息。此外,不同的客户端通过不同类型的网络访问服务。防火墙内的客户端使用高性能的局域网,而防火墙外的客户端使用互联网或移动网络,这将具有较低的性能。因此,正如你将学到的,通常没有单一、通用的 API 是有意义的。

本章首先描述了各种外部 API 设计问题。随后,我介绍了外部 API 模式。我涵盖了 API 网关模式和后端为前端模式。之后,我讨论了如何设计和实现 API 网关。我回顾了可用的各种选项,包括现成的 API 网关产品和用于开发自己的框架。我描述了使用 Spring Cloud Gateway 框架构建的 API 网关的设计和实现。我还描述了如何使用 GraphQL 框架,它提供了一个基于图查询语言,来构建 API 网关。

8.1. 外部 API 设计问题

为了探索各种 API 相关的问题,让我们考虑 FTGO 应用程序。如图 8.1 所示,该应用程序的服务被各种客户端消费。四种类型的客户端消费服务的 API:

  • 网络应用程序,例如消费者网络应用程序,它实现了面向消费者的基于浏览器的 UI,餐厅网络应用程序,它实现了面向餐厅的基于浏览器的 UI,以及管理员网络应用程序,它实现了内部管理员 UI

  • 在浏览器中运行的 JavaScript 应用程序

  • 移动应用程序,一个面向消费者,另一个面向快递员

  • 由第三方开发者编写的应用程序

图 8.1. FTGO 应用程序的服务及其客户端。存在几种不同类型的客户端。一些在防火墙内部,而另一些在外部。那些在防火墙外部的客户端通过低性能的互联网/移动网络访问服务。那些在防火墙内部的客户端使用高性能的局域网。

网络应用程序运行在防火墙内部,因此它们通过高带宽、低延迟的局域网访问服务。其他客户端运行在防火墙外部,因此它们通过低带宽、高延迟的互联网或移动网络访问服务。

API 设计的一种方法是为客户端直接调用服务。表面上,这似乎非常直接——毕竟,这就是客户端调用单体应用程序 API 的方式。但由于以下缺点,这种方法在微服务架构中很少使用:

  • 精细粒度的服务 API 要求客户端进行多次请求以检索所需的数据,这既低效又可能导致用户体验不佳。

  • 由于客户端了解每个服务和其 API,导致缺乏封装,这使得改变架构和 API 变得困难。

  • 服务可能使用对客户端来说不方便或不实用的 IPC 机制,尤其是那些在防火墙外部的客户端。

要了解更多关于这些缺点,让我们看看 FTGO 移动应用程序如何从服务中检索数据。

8.1.1. FTGO 移动客户端的 API 设计问题

消费者使用 FTGO 移动客户端来下单和管理他们的订单。想象一下,你正在开发移动客户端的“查看订单”视图,该视图显示一个订单。如第七章所述,此视图显示的信息包括基本订单信息,包括其状态、支付状态、从餐厅角度的订单状态以及配送状态,包括其位置和如果在途中,预计的配送时间。

FTGO 应用程序的单一版本有一个 API 端点,返回订单详情。移动客户端通过单次请求检索所需信息。相比之下,在 FTGO 应用程序的微服务版本中,订单详情,如前所述,分散在几个服务中,包括以下服务:

  • 订单服务 基本订单信息,包括细节和状态

  • 厨房服务 从餐厅角度的订单状态和预计取货准备时间

  • 配送服务 订单的配送状态、预计配送时间和当前位置

  • 会计服务 订单的支付状态

如果移动客户端直接调用服务,那么它必须,如图 8.2 所示,进行多次调用以检索这些数据。

图 8.2。客户端可以通过单个请求从单体 FTGO 应用程序中检索订单详情。但在微服务架构中,客户端必须进行多次请求来检索相同的信息。

图 8.2 的替代文本

在这个设计中,移动应用程序扮演着 API 作曲家的角色。它调用多个服务并组合结果。尽管这种方法看起来合理,但它有几个严重的问题。

由于客户端进行多次请求导致的用户体验差

第一个问题在于,移动应用程序有时必须进行多次请求以检索它想要向用户显示的数据。应用程序与服务之间的频繁交互可能会让应用程序看起来没有响应,尤其是在它使用互联网或移动网络时。互联网的带宽比局域网低得多,延迟也更高,而移动网络的情况更糟。移动网络(和互联网)的延迟通常是局域网的 100 倍。

当检索订单详情时,较高的延迟可能不是问题,因为移动应用程序通过并发执行请求来最小化延迟。整体响应时间不会超过单个请求。但在其他场景中,客户端可能需要顺序执行请求,这将导致用户体验不佳。

此外,由于网络延迟导致的用户体验差并不是频繁交互的 API 的唯一问题。它要求移动开发者编写可能复杂的 API 组合代码。这项工作会分散他们创建良好用户体验的主要任务。而且,因为每个网络请求都会消耗电量,频繁交互的 API 会更快地耗尽移动设备的电池。

缺乏封装要求前端开发者与后端同步更改他们的代码

移动应用程序直接访问服务的另一个缺点是封装不足。随着应用程序的发展,服务的开发者有时会以破坏现有客户端的方式更改 API。他们甚至可能改变系统分解为服务的方式。开发者可能会添加新的服务,分割或合并现有服务。但如果关于服务的知识已经嵌入到移动应用程序中,那么更改服务的 API 可能会变得困难。

与更新服务器端应用程序不同,推出移动应用程序的新版本可能需要数小时甚至数天。苹果或谷歌必须批准升级并使其可供下载。用户可能不会立即下载升级——如果他们下载的话。而且你可能不希望强迫不愿意升级的用户。将服务 API 暴露给移动的策略为这些 API 的演变设置了重大的障碍。

服务可能会使用对客户端不友好的 IPC 机制

对于直接调用服务的移动应用程序来说,另一个挑战是某些服务可能使用客户端难以消费的协议。运行在防火墙之外的客户应用程序通常使用 HTTP 和 WebSockets 等协议。但如第三章所述,服务开发者有许多协议可供选择——不仅仅是 HTTP。一个应用程序的一些服务可能使用 gRPC,而其他服务可能使用 AMQP 消息协议。这类协议在内部运行良好,但可能不易被移动客户端消费。有些甚至对防火墙不友好。

8.1.2. 其他类型客户端的 API 设计问题

我选择移动客户端,因为它是一种很好的方式来展示客户端直接访问服务的缺点。但是,向客户端公开服务所造成的问题并不仅限于移动客户端。其他类型的客户端,尤其是那些在防火墙之外运行的客户端,也会遇到这些问题。如前所述,FTGO 应用程序的服务被 Web 应用程序、基于浏览器的 JavaScript 应用程序和第三方应用程序消费。让我们看看这些客户端的 API 设计问题。

网络应用的 API 设计问题

传统的服务器端网络应用程序,它处理来自浏览器的 HTTP 请求并返回 HTML 页面,运行在防火墙内并通过局域网访问服务。网络带宽和延迟不是在 Web 应用程序中实现 API 组合的障碍。此外,Web 应用程序可以使用非 Web 友好的协议来访问服务。开发 Web 应用程序的团队属于同一组织,并且通常与编写后端服务的团队紧密合作,因此 Web 应用程序可以很容易地随时更新后端服务。因此,Web 应用程序直接访问后端服务是可行的。

基于浏览器的 JavaScript 应用程序的 API 设计问题

现代浏览器应用程序使用一定量的 JavaScript。即使 HTML 主要由服务器端网络应用程序生成,浏览器中运行的 JavaScript 调用服务是很常见的。例如,FTGO 应用程序的所有 Web 应用程序——ConsumerRestaurantAdmin——都包含调用后端服务的 JavaScript。例如,Consumer Web 应用程序使用调用服务 API 的 JavaScript 动态刷新 Order Details 页面。

一方面,基于浏览器的 JavaScript 应用程序在服务 API 更改时易于更新。另一方面,通过互联网访问服务的 JavaScript 应用程序与移动应用程序一样,存在网络延迟的问题。更糟糕的是,基于浏览器的 UI,尤其是桌面应用程序,通常更复杂,需要组合比移动应用程序更多的服务。很可能会出现,通过互联网访问服务的ConsumerRestaurant应用程序无法有效地组合服务 API。

为第三方应用程序设计 API

FTGO,像许多其他组织一样,向第三方开发者公开 API。开发者可以使用 FTGO API 编写放置和管理订单的应用程序。这些第三方应用程序通过互联网访问 API,因此 API 组合可能效率低下。但与设计供第三方应用程序使用的 API 相比,API 组合的低效率是一个相对较小的问题。这是因为第三方开发者需要一个稳定的 API。

很少有组织能够强迫第三方开发者升级到新的 API。具有不稳定 API 的组织可能会失去开发者,转而使用竞争对手。因此,您必须仔细管理供第三方开发者使用的 API 的演变。通常,您必须长期维护旧版本——可能永远如此。

这个要求对组织来说是一个巨大的负担。让后端服务的开发者负责维护长期向后兼容性是不切实际的。与其直接向第三方开发者公开服务,组织应该有一个由不同团队开发的独立公共 API。正如您稍后将要了解的,公共 API 是由一个称为API 网关的架构组件实现的。让我们看看 API 网关是如何工作的。

8.2. API 网关模式

正如您刚刚看到的,直接访问服务的服务存在许多缺点。客户端在互联网上执行 API 组合通常不切实际。缺乏封装使得开发者难以更改服务分解和 API。服务有时使用不适合防火墙外的通信协议。因此,一个更好的方法是用 API 网关。

模式:API 网关

实现一个服务,作为外部 API 客户端进入基于微服务的应用程序的入口点。参见microservices.io/patterns/apigateway.html

API 网关是一个服务,它是从外部世界进入应用程序的入口点。它负责请求路由、API 组合和其他功能,例如身份验证。本节介绍了 API 网关模式。我讨论了它的优点和缺点,并描述了在开发 API 网关时必须解决的各种设计问题。

8.2.1. API 网关模式的概述

第 8.1.1 节描述了客户端(如 FTGO 移动应用程序)的缺点,例如,为了向用户显示信息而进行多次请求。一个更好的方法是客户端向 API 网关发送单个请求,该网关作为 API 请求进入应用程序的外部防火墙的单一点。这与面向对象设计中的外观模式类似。像外观一样,API 网关封装了应用程序的内部架构,并为客户端提供了一个 API。它还可能具有其他职责,例如身份验证、监控和速率限制。图 8.3 显示了客户端、API 网关和服务之间的关系。

图 8.3。API 网关是外部防火墙进入应用程序进行 API 调用的单一入口点。

API 网关负责请求路由、API 组合和协议转换。所有来自外部客户端的 API 请求首先到达 API 网关,其中一些请求被路由到适当的服务。API 网关通过使用 API 组合模式和调用多个服务并汇总结果来处理其他请求。它还可能在客户端友好的协议(如 HTTP 和 WebSockets)和客户端不友好的协议(由服务使用)之间进行转换。

请求路由

API 网关的关键功能之一是请求路由。API 网关通过将请求路由到相应的服务来执行一些 API 操作。当它收到一个请求时,API 网关会咨询一个路由映射表,该映射表指定将请求路由到哪个服务。例如,一个路由映射表可能会将 HTTP 方法和路径映射到服务的 HTTP URL。这个功能与由像 NGINX 这样的 Web 服务器提供的反向代理功能相同。

API 组合

API 网关通常不仅仅是反向代理。它还可能使用 API 组合来实现一些 API 操作。例如,FTGO API 网关使用 API 组合来实现获取订单详情API 操作。如图 8.4 所示,移动应用程序向 API 网关发送一个请求,该网关从多个服务中检索订单详情。

图 8.4。API 网关通常执行 API 组合,这使得客户端(如移动设备)能够通过单个 API 请求有效地检索数据。

FTGO API 网关提供了一个粗粒度的 API,允许移动客户端通过单个请求检索所需的数据。例如,移动客户端向 API 网关发出单个getOrderDetails()请求。

协议转换

API 网关还可能执行协议转换。它可能为外部客户端提供 RESTful API,尽管应用程序服务内部使用多种协议,包括 REST 和 gRPC。当需要时,某些 API 操作的实现会在 RESTful 外部 API 和基于 gRPC 的内部 API 之间进行转换。

API 网关为每个客户端提供特定于客户端的 API

API 网关可能提供一个适用于所有情况的单一 API(OSFA)。单一 API 的问题在于,不同的客户端通常有不同的需求。例如,第三方应用程序可能需要Get Order Details API 操作返回完整的Order详情,而移动客户端只需要数据的一个子集。解决此问题的一种方法是在请求中允许客户端指定服务器应返回哪些字段和相关对象。这种方法对于必须服务于广泛第三方应用的公共 API 来说是足够的,但它通常不会给客户端提供他们需要的控制权。

更好的方法是 API 网关为每个客户端提供其自己的 API。例如,FTGO API 网关可以为 FTGO 移动客户端提供一个专门设计以满足其需求的 API。它甚至可能为 Android 和 iPhone 移动应用程序提供不同的 API。API 网关还将为第三方开发者实现一个公共 API。稍后,我将描述“前后端分离”模式,该模式通过为每个客户端定义一个单独的 API 网关将 API-per-client 的概念进一步扩展。

实现边缘函数

尽管 API 网关的主要职责是 API 路由和组合,但它也可能实现所谓的边缘函数。正如其名称所示,边缘函数是在应用程序边缘实现的请求处理函数。应用程序可能实现的边缘函数示例包括以下内容:

  • 认证验证发起请求的客户端身份

  • 授权验证客户端是否有权执行特定操作

  • 速率限制限制来自特定客户端和/或所有客户端的每秒请求数量

  • 缓存缓存响应以减少对服务的请求次数

  • 指标收集收集 API 使用情况指标,用于计费分析目的

  • 请求记录记录请求

在你的应用程序中,你可以在三个不同的地方实现这些边缘功能。首先,你可以在后端服务中实现它们。对于某些功能,如缓存、指标收集和可能授权,这可能是有意义的。但通常,在请求到达服务之前在边缘对请求进行身份验证会更安全。

第二种选择是在 API 网关上游的边缘服务中实现这些边缘功能。边缘服务是外部客户端的第一个接触点。它验证请求并在将其传递给 API 网关之前执行其他边缘处理。

使用专用边缘服务的一个重要好处是它分离了关注点。API 网关专注于 API 路由和组合。另一个好处是它将关键边缘功能(如身份验证)的责任集中化。当应用程序有多个 API 网关,且可能使用多种语言和框架编写时,这一点尤其有价值。我稍后会详细讨论这一点。这种方法的缺点是它增加了网络延迟,因为多了一个跳转。它还增加了应用程序的复杂性。

因此,通常使用第三种方法并在 API 网关本身实现这些边缘功能,特别是授权,是非常方便的。这样可以减少一个网络跳转,从而提高延迟。同时,移动部件也更少,这降低了复杂性。第十一章描述了 API 网关和服务的协作方式以实现安全性。

API 网关架构

API 网关具有分层、模块化的架构。其架构,如图 8.5 所示,由两层组成:API 层和通用层。API 层由一个或多个独立的 API 模块组成。每个 API 模块为特定客户端实现一个 API。通用层实现共享功能,包括如身份验证之类的边缘功能。

图 8.5. API 网关具有分层模块化的架构。每个客户端的 API 由一个单独的模块实现。通用层实现所有 API 共有的功能,如身份验证。

在这个例子中,API 网关有三个 API 模块:

  • 移动 API 实现 FTGO 移动客户端的 API

  • 浏览器 API 实现运行在浏览器中的 JavaScript 应用的 API

  • 公共 API 实现第三方开发者的 API

API 模块以两种方式之一实现每个 API 操作。一些 API 操作直接映射到单个服务 API 操作。API 模块通过将请求路由到相应的服务 API 操作来实现这些操作。它可能使用一个通用路由模块来路由请求,该模块读取描述路由规则的配置文件。

API 模块通过 API 组合实现其他更复杂的 API 操作。该 API 操作的实现由自定义代码组成。每个 API 操作实现通过调用多个服务并组合结果来处理请求。

API 网关所有权模型

你必须回答的一个重要问题是,谁负责 API 网关的开发和运营?有几个不同的选择。一个是设立一个专门的团队负责 API 网关。其缺点是,这与 SOA 类似,在 SOA 中,企业服务总线(ESB)团队负责所有 ESB 的开发。如果一个移动应用的开发者需要访问特定的服务,他们必须向 API 网关团队提交请求,并等待他们公开 API。这种在组织中的集中式瓶颈与微服务架构的哲学非常不符,微服务架构推崇松散耦合的自治团队。

Netflix 推广的一种更好的方法是,让客户端团队——移动、Web 和公共 API 团队——拥有暴露其 API 的 API 模块。API 网关团队负责开发“公共”模块和网关的运营方面。这种所有权模型如图 8.6 所示,使团队能够控制他们的 API。

图 8.6。客户端团队拥有自己的 API 模块。随着客户端的变化,他们可以更改 API 模块,而无需请求 API 网关团队进行更改。

当一个团队需要更改他们的 API 时,他们将更改检查到 API 网关的源代码库中。为了有效工作,API 网关的部署管道必须完全自动化。否则,客户端团队经常会因为等待 API 网关团队部署新版本而被阻塞。

使用前后端分离模式

对于 API 网关的一个担忧是,其责任界限模糊。多个团队共同贡献相同的代码库。API 网关团队负责其运营。虽然不如 SOA ESB 那样糟糕,但这种责任模糊与微服务架构哲学“如果你建造它,你就拥有它”相悖。

解决方案是为每个客户端提供一个 API 网关,即所谓的“前后端分离”(BFF)模式,这一模式由 Phil Calçado(philcalcado.com/)及其在 SoundCloud 的同事开创。如图 8.7 所示,每个 API 模块都成为其自己的独立 API 网关,由单个客户端团队开发和运营。

模式:前后端分离

为每种类型的客户端实现一个独立的 API 网关。参见microservices.io/patterns/apigateway.html

图 8.7。前后端分离模式为每个客户端定义了一个独立的 API 网关。每个客户端团队拥有自己的 API 网关。API 网关团队拥有公共层。

公共 API 团队拥有并运营他们的 API 网关,移动团队拥有并运营他们的,以此类推。从理论上讲,可以使用不同的技术栈来开发不同的 API 网关。但这可能导致重复编写实现边缘功能的通用代码,例如实现边缘功能的代码。理想情况下,所有 API 网关都使用相同的技术栈。通用功能是由 API 网关团队实现的共享库。

除了明确定义责任外,BFF 模式还有其他好处。API 模块彼此隔离,这提高了可靠性。一个表现不佳的 API 不太可能轻易影响其他 API。它还提高了可观察性,因为不同的 API 模块是不同的进程。BFF 模式的另一个好处是每个 API 都可以独立扩展。BFF 模式还减少了启动时间,因为每个 API 网关都是一个更小、更简单的应用程序。

8.2.2. API 网关的优缺点

如您所预期的那样,API 网关模式既有优点也有缺点。

API 网关的优点

使用 API 网关的一个主要优点是它封装了应用程序的内部结构。而不是调用特定的服务,客户端与网关进行通信。API 网关为每个客户端提供一个特定于客户端的 API,这减少了客户端与应用程序之间的往返次数。它还简化了客户端代码。

API 网关的缺点

API 网关模式也有一些缺点。它又是一个必须开发、部署和管理的可用性很高的组件。还有风险,即 API 网关成为开发瓶颈。开发者必须更新 API 网关才能公开他们的服务的 API。重要的是,更新 API 网关的过程应该尽可能轻量。否则,开发者将被迫排队等待更新网关。尽管如此,对于大多数实际应用来说,使用 API 网关是有意义的。如果需要,您可以使用前后端分离模式,以使团队能够独立开发和部署他们的 API。

8.2.3. Netflix 作为 API 网关的例子

API 网关的一个很好的例子是 Netflix API。Netflix 流媒体服务可在数百种不同的设备上使用,包括电视、蓝光播放器、智能手机以及许多其他小工具。最初,Netflix 试图为其流媒体服务提供一个通用的 API 风格(www.programmableweb.com/news/why-rest-keeps-me-night/2012/05/15)。但公司很快发现,由于设备种类繁多且需求不同,这并不奏效。如今,Netflix 使用一个 API 网关,为每种设备实现一个单独的 API。客户端设备团队开发和拥有 API 实现。

在 API 网关的第一个版本中,每个客户端团队使用 Groovy 脚本实现他们的 API,这些脚本执行路由和 API 组合。每个脚本使用服务团队提供的 Java 客户端库调用一个或多个服务 API。一方面,这效果很好,客户端开发者已经编写了数千个脚本。Netflix API 网关每天处理数十亿次请求,平均每个 API 调用会扩展到六个或七个后端服务。另一方面,Netflix 发现这种单体架构有些笨重。

因此,Netflix 现在正在转向与前端后端模式类似的 API 网关架构。在这个新的架构中,客户端团队使用 NodeJS 编写 API 模块。每个 API 模块运行自己的 Docker 容器,但脚本不会直接调用服务。相反,它们调用第二个“API 网关”,使用 Netflix Falcor 公开服务 API。Netflix Falcor是一种 API 技术,它执行声明性、动态的 API 组合,并允许客户端使用单个请求调用多个服务。这种新的架构有许多优点。API 模块彼此隔离,这提高了可靠性和可观察性,并且客户端 API 模块可以独立扩展。

8.2.4. API 网关设计问题

现在我们已经了解了 API 网关模式及其优点和缺点,让我们来探讨各种 API 网关设计问题。在设计 API 网关时需要考虑几个问题:

  • 性能和可扩展性

  • 通过使用响应式编程抽象编写可维护的代码

  • 处理部分失败

  • 成为应用程序架构中的良好公民

我们将逐一探讨。

性能和可扩展性

API 网关是应用程序的前门。所有外部请求都必须首先通过网关。尽管大多数公司没有 Netflix 那样每天处理数十亿次请求的规模,但 API 网关的性能和可扩展性通常非常重要。影响性能和可扩展性的一个关键设计决策是 API 网关应该使用同步 I/O 还是异步 I/O。

同步I/O 模型中,每个网络连接都由一个专用的线程处理。这是一个简单的编程模型,并且效果相当不错。例如,它是广泛使用的 Java EE servlet 框架的基础,尽管这个框架提供了异步完成请求的选项。然而,同步 I/O 的一个局限性是操作系统线程是重量级的,因此线程的数量,以及 API 网关可以拥有的并发连接数,都有限制。

另一种方法是使用 异步(非阻塞)I/O 模型。在这个模型中,单个事件循环线程将 I/O 请求调度到事件处理器。您有多种异步 I/O 技术可供选择。在 JVM 上,您可以使用基于 NIO 的框架之一,如 Netty、Vertx、Spring Reactor 或 JBoss Undertow。一个流行的非 JVM 选项是 NodeJS,这是一个基于 Chrome JavaScript 引擎的平台。

非阻塞 I/O 的可扩展性更好,因为它没有使用多个线程的开销。然而,缺点是异步、基于回调的编程模型更加复杂。代码更难编写、理解和调试。事件处理器必须快速返回,以避免阻塞事件循环线程。

此外,使用非阻塞 I/O 是否具有有意义的整体效益取决于 API 网关请求处理逻辑的特征。Netflix 在重写其边缘服务器 Zuul 时,使用 NIO 得到了混合的结果(见 medium.com/netflix-techblog/zuul-2-the-netflix-journey-to-asynchronous-non-blocking-systems-45947377fb5c)。一方面,正如预期的那样,使用 NIO 减少了每个网络连接的成本,因为不再为每个连接分配专用线程。此外,运行 I/O 密集型逻辑(如请求路由)的 Zuul 集群吞吐量提高了 25%,CPU 利用率降低了 25%。另一方面,运行 CPU 密集型逻辑(如解密和压缩)的 Zuul 集群没有显示出任何改进。

使用响应式编程抽象

如前所述,API 组成包括调用多个后端服务。一些后端服务请求完全依赖于客户端请求的参数。其他可能依赖于其他服务请求的结果。一种方法是由 API 端点处理方法根据依赖关系调用服务。例如,以下列表显示了以这种方式编写的 findOrder() 请求的处理程序。它依次调用每个服务。

列表 8.1. 通过依次调用后端服务来获取订单详情
@RestController
public class OrderDetailsController {
@RequestMapping("/order/{orderId}")
public OrderDetails getOrderDetails(@PathVariable String orderId) {

  OrderInfo orderInfo = orderService.findOrderById(orderId);

  TicketInfo ticketInfo = kitchenService
          .findTicketByOrderId(orderId);

  DeliveryInfo deliveryInfo = deliveryService
          .findDeliveryByOrderId(orderId);

  BillInfo billInfo = accountingService
          .findBillByOrderId(orderId);

  OrderDetails orderDetails =
       OrderDetails.makeOrderDetails(orderInfo, ticketInfo,
                                     deliveryInfo, billInfo);

  return orderDetails;
}
...

按顺序调用服务的缺点是响应时间是服务响应时间的总和。为了最小化响应时间,组成逻辑应尽可能并发调用服务。在这个例子中,服务调用之间没有依赖关系。所有服务都应并发调用,这显著减少了响应时间。挑战在于编写可维护的并发代码。

这是因为编写可扩展、并发代码的传统方式是使用回调。异步、事件驱动的 I/O 本质上基于回调。即使是基于 Servlet API 的 API 编曲器,在并发调用服务时通常也使用回调。它可以通过调用ExecutorService.submitCallable()来并发执行请求。问题在于,这个方法返回一个Future,它有一个阻塞的 API。一个更可扩展的方法是 API 编曲器调用ExecutorService.submit (Runnable),并为每个Runnable调用一个带有请求结果的回调。回调累积结果,一旦所有结果都接收完毕,它就向客户端发送响应。

使用传统的异步回调方法编写 API 编曲代码会迅速将你带入回调地狱。代码会变得混乱,难以理解,且容易出错,尤其是在编曲需要并行和顺序请求混合时。一个更好的方法是使用声明式风格和响应式方法编写 API 编曲代码。JVM 的响应式抽象示例包括以下内容:

  • Java 8 CompletableFutures

  • Project Reactor Monos

  • RxJava (Java 的响应式扩展) Observables,由 Netflix 专门为其 API 网关解决这个问题创建

  • Scala Futures

基于 NodeJS 的 API 网关将使用 JavaScript 承诺或 RxJS,这是 JavaScript 的响应式扩展。使用这些响应式抽象之一将使你能够编写简单且易于理解的并发代码。在本章的后面部分,我将使用 Project Reactor Monos和 Spring 框架的版本 5 展示这种风格的代码示例。

处理部分失败

除了可扩展性,API 网关还必须是可靠的。实现可靠性的方法之一是在负载均衡器后面运行网关的多个实例。如果一个实例失败,负载均衡器将路由请求到其他实例。

确保 API 网关可靠性的另一种方法是正确处理失败的请求和延迟过高的请求。当 API 网关调用服务时,服务可能会很慢或不可用。API 网关可能会等待很长时间,可能是不确定的,以等待响应,这会消耗资源并阻止它向其客户端发送响应。对失败服务的未完成请求甚至可能消耗有限的、宝贵的资源,如线程,最终导致 API 网关无法处理任何其他请求。解决方案,如第三章所述,是在调用服务时 API 网关使用断路器模式。

成为架构中的良好公民

在第三章中,我描述了服务发现模式,在第十一章第十一章中,我介绍了可观察性模式。服务发现模式使服务客户端,如 API 网关,能够确定服务实例的网络位置,以便它可以调用它。可观察性模式使开发者能够监控应用程序的行为并解决问题。API 网关,就像架构中的其他服务一样,必须实现为架构所选的模式。

8.3. 实现 API 网关

现在我们来看看如何实现 API 网关。如前所述,API 网关的职责如下:

  • 请求路由 使用诸如 HTTP 请求方法和方法路径等标准将请求路由到服务。当应用程序有一个或多个 CQRS 查询服务时,API 网关必须使用 HTTP 请求方法进行路由。如第七章第七章中所述,在这种架构中,命令和查询由不同的服务处理。

  • API 组合 使用第七章第七章中描述的 API 组合模式实现GET REST 端点。请求处理器结合调用多个服务的结果。

  • 边缘函数 其中最显著的是身份验证。

  • 协议转换 在客户端友好的协议和客户端不友好的服务协议之间进行转换。

  • 成为应用程序架构中的良好公民。

实现 API 网关有几种不同的方法:

  • 使用现成的 API 网关产品/服务 此选项几乎不需要开发工作,但灵活性最低。例如,现成的 API 网关通常不支持 API 组合。

  • 使用 API 网关框架或以 Web 框架作为起点自行开发 API 网关 这是最灵活的方法,尽管它需要一些开发工作。

让我们来看看这些选项,从使用现成的 API 网关产品或服务开始。

8.3.1. 使用现成的 API 网关产品/服务

几种现成的服务和产品实现了 API 网关功能。让我们首先看看 AWS 提供的一些服务。之后,我将讨论一些您可以下载、配置和运行的产品。

AWS API 网关

AWS API 网关是亚马逊网络服务提供的许多服务之一,是一种用于部署和管理 API 的服务。AWS API 网关 API 是一组 REST 资源,每个资源都支持一个或多个 HTTP 方法。您配置 API 网关将每个(方法, 资源)路由到后端服务。后端服务可以是 AWS Lambda 函数,这在第十二章中稍后描述,也可以是应用程序定义的 HTTP 服务或 AWS 服务。如果需要,您可以通过基于模板的机制配置 API 网关以转换请求和响应。AWS API 网关还可以对请求进行身份验证。

AWS API 网关满足了我之前列出的 API 网关的一些要求。API 网关由 AWS 提供,因此您不需要负责安装和运营。您配置 API 网关,AWS 处理其他所有事情,包括扩展。

不幸的是,AWS API 网关有几个缺点和限制,导致它无法满足其他要求。它不支持 API 组合,因此您需要在后端服务中实现 API 组合。AWS API 网关仅支持 HTTP(S),并且对 JSON 有很高的依赖。它只支持在第三章中描述的服务器端发现模式。应用程序通常会使用 AWS 弹性负载均衡器来在一系列 EC2 实例或 ECS 容器之间进行请求负载均衡。尽管有这些限制,除非您需要 API 组合,否则 AWS API 网关是 API 网关模式的良好实现。

AWS 应用程序负载均衡器

另一个提供类似 API 网关功能的 AWS 服务是 AWS 应用程序负载均衡器,这是一个用于 HTTP、HTTPS、WebSocket 和 HTTP/2 的负载均衡器([aws.amazon.com/blogs/aws/new-aws-application-load-balancer/](https://aws.amazon.com/blogs/aws/new-aws-application-load-balancer/))。当配置应用程序负载均衡器时,您定义路由规则,将请求路由到必须运行在 AWS EC2 实例上的后端服务。

与 AWS API 网关类似,AWS 应用程序负载均衡器满足了一些 API 网关的要求。它实现了基本的路由功能。它是托管服务,因此您不需要负责安装或运营。不幸的是,它相当有限。它不实现基于 HTTP 方法的路由。也不实现 API 组合或身份验证。因此,AWS 应用程序负载均衡器不满足 API 网关的要求。

使用 API 网关产品

另一个选择是使用 API 网关产品,如 Kong 或 Traefik。这些是您需要自行安装和操作的开源软件包。Kong 基于 NGINX HTTP 服务器,而 Traefik 是用 GoLang 编写的。这两个产品都允许您配置灵活的路由规则,这些规则使用 HTTP 方法、头部和路径来选择后端服务。Kong 允许您配置实现边缘功能(如身份验证)的插件。Traefik 甚至可以与某些服务注册表集成,这些注册表在第三章中有所描述。第三章。

虽然这些产品实现了边缘功能和强大的路由能力,但它们也有一些缺点。您必须自行安装、配置和操作它们。它们不支持 API 组合。如果您想使 API 网关执行 API 组合,您必须开发自己的 API 网关。

8.3.2. 开发自己的 API 网关

开发 API 网关并不特别困难。它基本上是一个代理其他服务的 Web 应用程序。您可以使用您喜欢的 Web 框架来构建它。然而,您将需要解决两个关键的设计问题:

  • 实现一个机制来定义路由规则,以最小化复杂的编码

  • 正确实现 HTTP 代理行为,包括如何处理 HTTP 头部信息

因此,开发 API 网关的更好起点是使用专为该目的设计的框架。其内置功能显著减少了您需要编写的代码量。

我们将探讨 Netflix Zuul,这是一个 Netflix 的开源项目,然后考虑 Spring Cloud Gateway,这是一个来自 Pivotal 的开源项目。

使用 Netflix Zuul

Netflix 开发了 Zuul 框架来实现边缘功能,如路由、速率限制和身份验证(github.com/Netflix/zuul)。Zuul 框架使用 过滤器 的概念,这些是可重用的请求拦截器,类似于 servlet 过滤器或 NodeJS Express 中间件。Zuul 通过组装一系列适用的过滤器来处理 HTTP 请求,然后转换请求,调用后端服务,并在将其发送回客户端之前转换响应。虽然您可以直接使用 Zuul,但使用来自 Pivotal 的开源项目 Spring Cloud Zuul 要容易得多。Spring Cloud Zuul 基于 Zuul,通过约定优于配置,使得基于 Zuul 的服务器开发变得非常简单。

Zuul 处理路由和边缘功能。您可以通过定义实现 API 组合的 Spring MVC 控制器来扩展 Zuul。但 Zuul 的一个主要限制是它只能实现基于路径的路由。例如,它无法将 GET /orders 路由到一个服务,而将 POST /orders 路由到另一个服务。因此,Zuul 不支持第七章中描述的查询架构。第七章。

关于 Spring Cloud Gateway

我之前描述的选项中没有任何一个能满足所有要求。事实上,我在寻找 API 网关框架的过程中已经放弃了,并开始基于 Spring MVC 开发一个 API 网关。但后来我发现 Spring Cloud Gateway 项目(cloud.spring.io/spring-cloud-gateway/)。它是一个基于多个框架之上的 API 网关框架,包括 Spring Framework 5、Spring Boot 2 和 Spring Webflux,后者是 Spring Framework 5 的一部分,是一个基于 Project Reactor 的响应式 Web 框架。Project Reactor 是一个基于 NIO 的响应式框架,用于 JVM,它提供了稍后在本章中稍后使用的 Mono 抽象。

Spring Cloud Gateway 提供了一种简单而全面的方式来执行以下操作:

  • 将路由请求转发到后端服务。

  • 实现执行 API 组合的请求处理器。

  • 处理边缘函数,如身份验证。

图 8.8 展示了使用此框架构建的 API 网关的关键部分。

图 8.8。使用 Spring Cloud Gateway 构建的 API 网关的架构

Images/08fig08_alt.jpg

API 网关由以下包组成:

  • ApiGatewayMain —定义了 API 网关的主程序。

  • 一个或多个 API 包—API 包实现一组 API 端点。例如,Orders包实现了与Order相关的 API 端点。

  • 代理包—由 API 包使用的代理类组成,用于调用服务。

OrderConfiguration类定义了负责路由Order相关请求的 Spring beans。一个路由规则可以匹配 HTTP 方法、头部和路径的一些组合。orderProxyRoutes @Bean定义了将 API 操作映射到后端服务 URL 的规则。例如,它将以/orders开头的路径路由到Order Service

orderHandlers @Bean定义了覆盖orderProxyRoutes中定义的规则。这些规则将 API 操作映射到处理器方法,这是 Spring WebFlux 对 Spring MVC 控制器方法的等效。例如,orderHandlers将操作GET /orders/{orderId}映射到OrderHandlers::getOrderDetails()方法。

OrderHandlers类实现了各种请求处理器方法,如OrderHandlers::getOrderDetails()。此方法使用 API 组合来获取订单详情(前面已描述)。处理方法使用远程代理类(如OrderService)调用后端服务。此类定义了调用OrderService的方法。

让我们来看看代码,从OrderConfiguration类开始。

OrderConfiguration

在 列表 8.2 中显示的 OrderConfiguration 类是一个 Spring @Configuration 类。它定义了实现 /orders 端点的 Spring @BeansorderProxyRoutingorderHandlerRouting @Beans 使用 Spring WebFlux 路由 DSL 定义请求路由。orderHandlers @Bean 实现执行 API 组合的请求处理器。

列表 8.2. 实现 /orders 端点的 Spring @Beans
@Configuration
@EnableConfigurationProperties(OrderDestinations.class)
public class OrderConfiguration {

  @Bean
  public RouteLocator orderProxyRouting(OrderDestinations orderDestinations) {
    return Routes.locator()
            .route("orders")
            .uri(orderDestinations.orderServiceUrl)
            .predicate(path("/orders").or(path("/orders/*")))             *1*
             .and()
            ...
            .build();
  }

  @Bean
  public RouterFunction<ServerResponse>
             orderHandlerRouting(OrderHandlers orderHandlers) {
    return RouterFunctions.route(GET("/orders/{orderId}"),                *2*
                       orderHandlers::getOrderDetails);
  }

  @Bean
  public OrderHandlers orderHandlers(OrderService orderService,
                               KitchenService kitchenService,
                               DeliveryService deliveryService,
                               AccountingService accountingService) {
    return new OrderHandlers(orderService, kitchenService,                *3*
                              deliveryService, accountingService);
  }

}
  • 1 默认情况下,将所有以 /orders 开头的请求路由到 orderDestinations.orderServiceUrl。

  • 2 将 GET /orders/{orderId} 路由到 orderHandlers::getOrderDetails。

  • 3 实现自定义请求处理逻辑的 @Bean

OrderDestinations,如以下列表所示,是一个 Spring @ConfigurationProperties 类,它允许外部化配置后端服务 URL。

列表 8.3. 后端服务 URL 的外部化配置
@ConfigurationProperties(prefix = "order.destinations")
public class OrderDestinations {

  @NotNull
  public String orderServiceUrl;

  public String getOrderServiceUrl() {
    return orderServiceUrl;
  }

  public void setOrderServiceUrl(String orderServiceUrl) {
    this.orderServiceUrl = orderServiceUrl;
  }
  ...
}

例如,您可以将 Order ServiceURL 指定为一个属性文件中的 order.destinations.orderServiceUrl 属性,或者指定为操作系统环境变量,ORDER_DESTINATIONS_ORDER_SERVICE_URL

OrderHandlers

在以下列表中显示的 OrderHandlers 类定义了实现自定义行为的请求处理器方法,包括 API 组合。例如,getOrderDetails() 方法执行 API 组合以检索有关订单的信息。此类注入了几个代理类,这些代理类向后端服务发送请求。

列表 8.4. OrderHandlers 类实现了自定义请求处理逻辑。
public class OrderHandlers {

  private OrderService orderService;
  private KitchenService kitchenService;
  private DeliveryService deliveryService;
  private AccountingService accountingService;

  public OrderHandlers(OrderService orderService,
                       KitchenService kitchenService,
                       DeliveryService deliveryService,
                       AccountingService accountingService) {
    this.orderService = orderService;
    this.kitchenService = kitchenService;
    this.deliveryService = deliveryService;
    this.accountingService = accountingService;
  }

  public Mono<ServerResponse> getOrderDetails(ServerRequest serverRequest) {
    String orderId = serverRequest.pathVariable("orderId");

    Mono<OrderInfo> orderInfo = orderService.findOrderById(orderId);

    Mono<Optional<TicketInfo>> ticketInfo =
       kitchenService
            .findTicketByOrderId(orderId)
            .map(Optional::of)                                      *1*
             .onErrorReturn(Optional.empty());                      *2*

    Mono<Optional<DeliveryInfo>> deliveryInfo =
        deliveryService
            .findDeliveryByOrderId(orderId)
            .map(Optional::of)
            .onErrorReturn(Optional.empty());

    Mono<Optional<BillInfo>> billInfo = accountingService
            .findBillByOrderId(orderId)
            .map(Optional::of)
            .onErrorReturn(Optional.empty());

    Mono<Tuple4<OrderInfo, Optional<TicketInfo>,                    *3*
                 Optional<DeliveryInfo>, Optional<BillInfo>>> combined =
            Mono.when(orderInfo, ticketInfo, deliveryInfo, billInfo);

    Mono<OrderDetails> orderDetails =                               *4*
         combined.map(OrderDetails::makeOrderDetails);

    return orderDetails.flatMap(person -> ServerResponse.ok()       *5*
             .contentType(MediaType.APPLICATION_JSON)
            .body(fromObject(person)));
  }

}
  • 1 将 TicketInfo 转换为 Optional

  • 2 如果服务调用失败,则返回 Optional.empty().

  • 3 将四个值合并为一个单一值,即 Tuple4。

  • 4 将 Tuple4 转换为 OrderDetails。

  • 5 将 OrderDetails 转换为 ServerResponse。

getOrderDetails() 方法实现 API 组合以获取订单详情。它使用 Project Reactor 提供的 Mono 抽象以可扩展、响应式的方式编写,Mono 是一种更丰富的 Java 8 CompletableFuture。它包含异步操作的结果,要么是一个值,要么是一个异常。它具有丰富的 API 用于转换和组合异步操作返回的值。您可以使用 Monos 以简单易懂的方式编写并发代码。在此示例中,getOrderDetails() 方法并行调用四个服务,并将结果组合以创建一个 OrderDetails 对象。

getOrderDetails() 方法接受一个 ServerRequest 参数,这是 Spring WebFlux 对 HTTP 请求的表示,并执行以下操作:

  1. 它从路径中提取 orderId

  2. 它通过它们的代理异步调用四个服务,这些代理返回 Monos。为了提高可用性,getOrderDetails() 将除 OrderService 之外的所有服务的返回结果视为可选的。如果一个可选服务返回的 Mono 包含异常,onErrorReturn() 调用将其转换为包含空 OptionalMono

  3. 它使用 Mono.when() 异步组合结果,该方法返回一个包含四个值的 Mono<Tuple4>

  4. 它通过调用 OrderDetails::makeOrderDetailsMono<Tuple4> 转换为 Mono<OrderDetails>

  5. 它将 OrderDetails 转换为 ServerResponse,这是 Spring WebFlux 对 JSON/HTTP 响应的表示。

如您所见,因为 getOrderDetails() 使用 Monos,它并发调用服务并组合结果,而不使用混乱的、难以阅读的回调。让我们看看其中一个返回服务 API 调用结果的 Mono 包装的服务代理。

The OrderService class

下面的列表中显示的 OrderService 类是 Order Service 的远程代理。它使用 WebClient 调用 Order ServiceWebClient 是 Spring WebFlux 反应式 HTTP 客户端。

列表 8.5. OrderService 类——Order Service 的远程代理
@Service
public class OrderService {

  private OrderDestinations orderDestinations;

  private WebClient client;

  public OrderService(OrderDestinations orderDestinations, WebClient client)
     {
    this.orderDestinations = orderDestinations;
    this.client = client;
  }

  public Mono<OrderInfo> findOrderById(String orderId) {
    Mono<ClientResponse> response = client
            .get()
            .uri(orderDestinations.orderServiceUrl + "/orders/{orderId}",
                 orderId)
            .exchange();                                                 *1*
     return response.flatMap(resp -> resp.bodyToMono(OrderInfo.class));  *2*
   }

}
  • 1 调用服务。

  • 2 将响应体转换为 OrderInfo。

findOrder() 方法检索订单的 OrderInfo。它使用 WebClientOrder Service 发送 HTTP 请求并将 JSON 响应反序列化为 OrderInfoWebClient 拥有反应式 API,响应被包装在 Mono 中。findOrder() 方法使用 flatMap()Mono<ClientResponse> 转换为 Mono<OrderInfo>。正如其名所示,bodyToMono() 方法将响应体作为 Mono 返回。

The ApiGatewayApplication class

下面的列表中显示的 ApiGatewayApplication 类实现了 API 网关的 main() 方法。它是一个标准的 Spring Boot 主类。

列表 8.6. API 网关的 main() 方法
@SpringBootConfiguration
@EnableAutoConfiguration
@EnableGateway
@Import(OrdersConfiguration.class)
public class ApiGatewayApplication {

  public static void main(String[] args) {
    SpringApplication.run(ApiGatewayApplication.class, args);
  }
}

@EnableGateway 注解导入 Spring Cloud Gateway 框架的 Spring 配置。

Spring Cloud Gateway 是实现 API 网关的出色框架。它允许您使用简单、简洁的路由规则 DSL 配置基本代理。将请求路由到执行 API 组合和协议转换的处理程序方法也非常简单。Spring Cloud Gateway 是使用可扩展的、反应式的 Spring Framework 5 和 Project Reactor 框架构建的。但还有另一个吸引人的选项来开发自己的 API 网关:GraphQL,这是一个提供基于图查询语言的框架。让我们看看它是如何工作的。

8.3.3. 使用 GraphQL 实现一个 API 网关

假设你负责实现 FTGO 的 API 网关的GET /orders/{orderId}端点,该端点返回订单详情。表面上,实现这个端点可能看起来很简单。但如第 8.1 节所述,这个端点从多个服务中检索数据。因此,你需要使用 API 组合模式并编写调用服务并组合结果的代码。

另一个挑战,如前所述,是不同的客户端需要稍微不同的数据。例如,与移动应用程序不同,桌面 SPA 应用程序显示你对订单的评分。如第三章所述,调整端点返回的数据的一种方法,是给客户端提供指定所需数据的能力。例如,端点可以支持查询参数,如expand参数,它指定要返回的相关资源,以及field参数,它指定要返回的每个资源的字段。另一种选择是在应用前后端分离模式时定义此端点的多个版本。这对于 FTGO 的 API 网关需要实现的众多 API 端点之一来说,工作量很大。

实现一个支持多种客户端的 REST API 的 API 网关是耗时的。因此,你可能想要考虑使用一个基于图的 API 框架,如 GraphQL,它旨在支持高效的数据检索。基于图 API 框架的关键思想是,如图 8.9 所示,服务器的 API 由一个基于图的架构组成。基于图的架构定义了一组节点(类型),它们具有属性(字段)与其他节点的关系。客户端通过执行一个查询来检索数据,该查询以图节点及其属性和关系为条件指定所需数据。因此,客户端可以在一次往返 API 网关中检索所需的数据。

图 8.9。API 网关的 API 由一个基于图的架构组成,该架构映射到服务上。客户端发出一个查询,检索多个图节点。基于图的 API 框架通过从一个或多个服务中检索数据来执行查询。

图 8.9 的替代文本

基于图的 API 技术有几个重要的好处。它使客户端能够控制返回哪些数据。因此,开发一个足够灵活的单个 API 以支持各种客户端变得可行。另一个好处是,尽管 API 更加灵活,但这种方法显著减少了开发工作量。这是因为你使用一个查询执行框架来编写服务器端代码,该框架旨在支持 API 组合和投影。这就像,而不是强迫客户端通过你需要编写和维护的存储过程来检索数据,你让他们对底层数据库执行查询。

模式驱动的 API 技术

最受欢迎的基于图的 API 技术是 GraphQL (graphql.org) 和 Netflix Falcor (netflix.github.io/falcor/)。Netflix Falcor 将服务器端数据建模为一个虚拟的 JSON 对象图。Falcor 客户端通过执行查询来检索该 JSON 对象的属性来从 Falcor 服务器获取数据。客户端还可以更新属性。在 Falcor 服务器中,对象图的属性映射到后端数据源,例如具有 REST API 的服务。服务器通过调用一个或多个后端数据源来处理设置或获取属性的请求。

由 Facebook 开发并于 2015 年发布的 GraphQL 是另一种流行的基于图的 API 技术。它将服务器端数据建模为具有字段和指向其他对象的引用的对象图。对象图映射到后端数据源。GraphQL 客户端可以执行查询以检索数据,以及创建和更新数据的突变。与 Netflix Falcor 不同,后者是一个实现,GraphQL 是一个标准,它为包括 NodeJS、Java 和 Scala 在内的多种语言提供了客户端和服务器。

Apollo GraphQL 是一个流行的 JavaScript/NodeJS 实现 (www.apollographql.com)。它是一个包含 GraphQL 服务器和客户端的平台。Apollo GraphQL 实现了对 GraphQL 规范的某些强大扩展,例如将更改的数据推送到客户端的订阅。

本节讨论如何使用 Apollo GraphQL 开发 API 网关。我只会介绍 GraphQL 和 Apollo GraphQL 的一些关键特性。更多详细信息,请参阅 GraphQL 和 Apollo GraphQL 文档。

展示在图 8.10 中的基于 GraphQL 的 API 网关是用 JavaScript 和 NodeJS Express Web 框架以及 Apollo GraphQL 服务器编写的。设计的关键部分如下:

  • GraphQL 模式 GraphQL 模式定义了服务器端数据模型及其支持的查询。

  • 解析函数 解析函数将模式元素映射到各种后端服务。

  • 代理类 代理类调用 FTGO 应用程序的服务。

图 8.10。基于 GraphQL 的 FTGO API 网关的设计

还有一些小的粘合代码,用于将 GraphQL 服务器与 Express Web 框架集成。让我们看看每个部分,从 GraphQL 模式开始。

定义 GraphQL 模式

GraphQL API 围绕一个 schema,它由一组类型组成,这些类型定义了服务器端数据模型的架构以及客户端可以执行的操作,如查询。GraphQL 有几种不同类型的类型。本节中的示例代码只使用了两种类型的类型:object 类型,这是定义数据模型的主要方式,以及 enums,它们类似于 Java 枚举。对象类型有一个名称和一组类型化的、命名的字段。字段 可以是标量类型,如数字、字符串或枚举;标量类型的列表;另一个对象类型的引用;或另一个对象类型的引用集合。尽管与传统面向对象类的字段相似,但 GraphQL 字段在概念上是一个返回值的函数。它可以有参数,这使 GraphQL 客户端能够定制函数返回的数据。

GraphQL 也使用字段来定义架构支持的查询。您通过声明一个对象类型来定义架构的查询,按照惯例称为 QueryQuery 对象的每个字段都是一个命名查询,它有一个可选的参数集和一个返回类型。我发现当我第一次遇到这种方式定义查询时,有点困惑,但记住 GraphQL 字段是一个函数会有所帮助。当我们查看字段如何连接到后端数据源时,这会变得更加清晰。

下面的列表显示了基于 GraphQL 的 FTGO API 网关架构的一部分。它定义了几个对象类型。大多数对象类型对应于 FTGO 应用程序的 ConsumerOrderRestaurant 实体。它还有一个 Query 对象类型,用于定义架构的查询。

列表 8.7. FTGO API 网关的 GraphQL 架构
type Query {                               *1*
  orders(consumerId : Int!): [Order]
  order(orderId : Int!): Order
  consumer(consumerId : Int!): Consumer
}

type Consumer {
  id: ID                                   *2*
  firstName: String
  lastName: String
  orders: [Order]                          *3*
 }

type Order {
  orderId: ID,
  consumerId : Int,
  consumer: Consumer
  restaurant: Restaurant

  deliveryInfo : DeliveryInfo

  ...
}

type Restaurant {
  id: ID
  name: String
  ...
}

type DeliveryInfo {
  status : DeliveryStatus
  estimatedDeliveryTime : Int
  assignedCourier :String
}

enum DeliveryStatus {
  PREPARING
  READY_FOR_PICKUP
  PICKED_UP
  DELIVERED
}
  • 1 定义客户端可以执行的查询*

  • 2 消费者的唯一标识*

  • 3 消费者有一系列订单。*

尽管语法不同,但 ConsumerOrderRestaurantDeliveryInfo 对象类型在结构上与相应的 Java 类相似。一个区别是 ID 类型,它代表一个唯一标识符。

此架构定义了三个查询:

  • orders() 返回指定 ConsumerOrders

  • order() 返回指定的 Order

  • consumer() 返回指定的 Consumer

这些查询可能看起来与等效的 REST 端点没有太大区别,但 GraphQL 给客户端提供了对返回数据的巨大控制权。为了理解原因,让我们看看客户端是如何执行 GraphQL 查询的。

执行 GraphQL 查询

使用 GraphQL 的主要好处是其查询语言赋予客户端对返回数据的巨大控制权。客户端通过向服务器发送包含查询文档的请求来执行查询。在简单的情况下,查询文档指定查询的名称、参数值和要返回的结果对象字段。以下是一个检索具有特定 ID 的消费者的 firstNamelastName 的简单查询:

query {
  consumer(consumerId:1)       *1*
   {                           *2*
     firstName
    lastName
  }
}
  • 1 指定名为 consumer 的查询,该查询用于获取消费者

  • 2 要返回的 Consumer 字段

此查询返回指定 Consumer 的那些字段。

这是一个更复杂的查询,它返回消费者、他们的订单以及每个订单的餐厅的 ID 和名称:

query {
    consumer(consumerId:1)  {
      id
      firstName
      lastName
      orders {
        orderId
        restaurant {
          id
          name
        }
        deliveryInfo {
          estimatedDeliveryTime
          name
        }
      }
  }
}

此查询指示服务器返回的不仅仅是 Consumer 的字段。它检索消费者的 Orders 以及每个 Order 的餐厅。如您所见,GraphQL 客户端可以指定要返回的确切数据,包括传递相关对象的字段。

查询语言比乍看之下更灵活。这是因为查询是 Query 对象的一个字段,而查询文档指定了服务器应返回哪些字段。这些简单的示例检索单个字段,但查询文档可以通过指定多个字段来执行多个查询。对于每个字段,查询文档提供字段参数并指定它对结果对象中哪些字段感兴趣。以下是一个检索两个不同消费者的查询:

query {
  c1: consumer (consumerId:1)  { id, firstName, lastName}
  c2: consumer (consumerId:2)  { id, firstName, lastName}
}

在此查询文档中,c1c2 是 GraphQL 所称的 别名。它们用于区分结果中的两个 Consumers,否则这两个都会被称作 consumer。此示例检索了相同类型的两个对象,但客户端可以检索不同类型的多个对象。

GraphQL 模式定义了数据的形状和受支持的查询。为了有用,它必须连接到数据源。让我们看看如何做到这一点。

将模式连接到数据

当 GraphQL 服务器执行查询时,它必须从一个或多个数据存储中检索请求的数据。在 FTGO 应用程序的情况下,GraphQL 服务器必须调用拥有数据的服务的 API。您通过将解析器函数附加到由模式定义的对象类型的字段来将 GraphQL 模式与数据源关联。GraphQL 服务器通过调用解析器函数来检索数据,首先为顶层查询,然后递归地为结果对象或对象的字段。

解析器函数如何与模式关联的细节取决于你使用的 GraphQL 服务器。列表 8.8 展示了在使用 Apollo GraphQL 服务器时如何定义解析器。你创建一个双层嵌套的 JavaScript 对象。每个顶层属性对应一个对象类型,例如 QueryOrder。每个第二层属性,例如 Order.consumer,定义了一个字段的解析器函数。

列表 8.8. 将解析器函数附加到 GraphQL 模式的字段
const resolvers = {
  Query: {
    orders: resolveOrders,                 *1*
    consumer: resolveConsumer,
    order: resolveOrder
  },
  Order: {
    consumer: resolveOrderConsumer,        *2*
    restaurant: resolveOrderRestaurant,
    deliveryInfo: resolveOrderDeliveryInfo
...
};
  • 1 查询的解析器*

  • 2 订单消费者字段的解析器

解析器函数有三个参数:

  • 对象 对于顶层查询字段,例如 resolveOrdersobject 是一个根对象,通常解析器函数会忽略它。否则,object 是解析器为父对象返回的值。例如,Order.consumer 字段的解析器函数接收 Order 解析器函数返回的值。

  • 查询参数 这些由查询文档提供。

  • 上下文 查询执行的全局状态,所有解析器都可以访问。例如,它用于将用户信息和依赖项传递给解析器。

解析器函数可能调用单个服务,也可能实现 API 组合模式并从多个服务检索数据。Apollo GraphQL 服务器的解析器函数返回一个 Promise,这是 JavaScript 的 CompletableFuture 版本。这个承诺包含解析器函数从数据存储中检索的对象(或对象列表)。GraphQL 引擎将返回值包含在结果对象中。

让我们看看几个例子。这是 resolveOrders() 函数,它是 orders 查询的解析器:

function resolveOrders(_, { consumerId }, context) {
  return context.orderServiceProxy.findOrders(consumerId);
}

此函数从 context 获取 OrderServiceProxy 并调用它以获取消费者的订单。它忽略其第一个参数。它将查询文档提供的 consumerId 参数传递给 OrderServiceProxy.findOrders()findOrders() 方法从 OrderHistoryService 检索消费者的订单。

这里是 resolveOrderRestaurant() 函数,它是用于检索订单餐厅的 Order.restaurant 字段的解析器:

function resolveOrderRestaurant({restaurantId}, args, context) {
    return context.restaurantServiceProxy.findRestaurant(restaurantId);
}

它的第一个参数是 Order。它使用 OrderrestaurantId 调用 RestaurantServiceProxy.findRestaurant(),这个 restaurantId 是由 resolveOrders() 提供的。

GraphQL 使用递归算法来执行解析器函数。首先,它执行由查询文档指定的顶层查询的解析器函数。接下来,对于查询返回的每个对象,它遍历查询文档中指定的字段。如果一个字段有解析器,它将使用对象和查询文档中的参数调用解析器。然后,它递归处理该解析器返回的对象或对象。

图 8.11 展示了该算法如何执行查询以检索消费者的订单以及每个订单的配送信息和餐厅。首先,GraphQL 引擎调用 resolveConsumer(),以检索 Consumer。接下来,它调用 resolveConsumerOrders(),这是 Consumer.orders 字段的解析器,返回消费者的订单。然后,GraphQL 引擎遍历 Orders,调用 Order.restaurantOrder.deliveryInfo 字段的解析器。

图 8.11. GraphQL 通过递归调用查询文档中指定的字段解析器来执行查询。首先,它执行查询的解析器,然后递归调用结果对象层次结构中的字段解析器。

执行解析器的结果是包含从多个服务检索到的数据的 Consumer 对象。

现在我们来看看如何通过使用批处理和缓存来优化解析器的执行。

使用批处理和缓存优化加载

当执行查询时,GraphQL 可能会执行大量解析器。由于 GraphQL 服务器独立执行每个解析器,因此存在因过度往返到服务而导致性能不佳的风险。例如,考虑一个检索消费者、他们的订单以及订单餐厅的查询。如果有 N 个订单,那么简单的实现将向 Consumer Service 发起一次调用,向 Order History Service 发起一次调用,然后向 Restaurant Service 发起 N 次调用。尽管 GraphQL 引擎通常会在并行调用 Restaurant Service,但仍然存在性能不佳的风险。幸运的是,您可以使用一些技术来提高性能。

一个重要的优化是使用服务器端批处理和缓存的组合。批处理 将对服务(如 Restaurant Service)的 N 次调用转换为一个调用,该调用检索 N 个对象。缓存 重新使用相同对象的先前获取结果,以避免进行不必要的重复调用。批处理和缓存的组合可以显著减少对后端服务的往返次数。

基于 NodeJS 的 GraphQL 服务器可以使用 DataLoader 模块来实现批处理和缓存 (github.com/facebook/dataloader)。它将事件循环单次执行中发生的加载合并在一起,并调用您提供的批加载函数。它还会缓存调用以消除重复加载。以下列表显示了 RestaurantServiceProxy 如何使用 DataLoaderfindRestaurant() 方法通过 DataLoader 加载 Restaurant

列表 8.9. 使用 DataLoader 优化对 Restaurant Service 的调用
const DataLoader = require('dataloader');

class RestaurantServiceProxy {
    constructor() {
        this.dataLoader =                                 *1*
            new DataLoader(restaurantIds =>
             this.batchFindRestaurants(restaurantIds));
    }

    findRestaurant(restaurantId) {                        *2*
         return this.dataLoader.load(restaurantId);
    }

    batchFindRestaurants(restaurantIds) {                 *3*
       ...
    }
}
  • 1 创建一个 DataLoader,使用 batchFindRestaurants() 作为批加载函数。

  • 2 通过 DataLoader 加载指定的餐厅。

  • 3 加载一批餐厅。

RestaurantServiceProxy以及因此产生的DataLoader为每个请求创建,因此不存在DataLoader混合不同用户数据的可能性。

现在我们来看看如何将 GraphQL 引擎与网络框架集成,以便客户端可以调用它。

将 Apollo GraphQL 服务器与 Express 集成

Apollo GraphQL 服务器执行 GraphQL 查询。为了使客户端能够调用它,您需要将其与一个网络框架集成。Apollo GraphQL 服务器支持多个网络框架,包括 Express,这是一个流行的 NodeJS 网络框架。

列表 8.10 展示了如何在 Express 应用程序中使用 Apollo GraphQL 服务器。关键函数是graphqlExpress,它由apollo-server-express模块提供。它构建了一个 Express 请求处理器,该处理器针对模式执行 GraphQL 查询。此示例配置 Express 将请求路由到此 GraphQL 请求处理器的GET /graphqlPOST /graphql端点。它还创建了一个包含代理的 GraphQL 上下文,这使得它们对解析器可用。

列表 8.10. 将 GraphQL 服务器与 Express 网络框架集成
const {graphqlExpress} = require("apollo-server-express");

const typeDefs = gql`                                                  *1*
   type Query {
    orders: resolveOrders,
   ...
  }

  type Consumer {
   ...

const resolvers = {                                                    *2*
   Query: {
  ...
  }
}

const schema = makeExecutableSchema({ typeDefs, resolvers });          *3*

const app = express();

function makeContextWithDependencies(req) {                            *4*
    const orderServiceProxy = new OrderServiceProxy();
    const consumerServiceProxy = new ConsumerServiceProxy();
    const restaurantServiceProxy = new RestaurantServiceProxy();
    ...
    return {orderServiceProxy, consumerServiceProxy,
              restaurantServiceProxy, ...};
}

function makeGraphQLHandler() {                                        *5*
     return graphqlExpress(req => {
        return {schema: schema, context: makeContextWithDependencies(req)}
    });
}

app.post('/graphql', bodyParser.json(), makeGraphQLHandler());         *6*

app.get('/graphql', makeGraphQLHandler());

app.listen(PORT);
  • 1 定义 GraphQL 模式。*

  • 2 定义解析器。*

  • 3 将模式与解析器结合以创建可执行模式。*

  • 4 将仓库注入上下文中,以便它们对解析器可用。*

  • 5 创建一个 Express 请求处理器,用于执行针对可执行模式的 GraphQL 查询。*

  • 6 将 POST /graphql 和 GET /graphql 端点路由到 GraphQL 服务器。*

这个例子没有处理诸如安全等问题,但这些问题的实现将非常直接。例如,API 网关可以使用 Passport,这是一个在第十一章中描述的 NodeJS 安全框架,来验证用户。makeContextWithDependencies()函数会将用户信息传递给每个仓库的构造函数,以便它们可以将用户信息传播到服务中。

现在我们来看看客户端如何调用此服务器来执行 GraphQL 查询。

编写 GraphQL 客户端

客户端应用程序可以以几种不同的方式调用 GraphQL 服务器。因为 GraphQL 服务器有一个基于 HTTP 的 API,客户端应用程序可以使用 HTTP 库来发送请求,例如GET http://localhost:3000/graphql?query={orders(consumerId:1){orderId,restaurant{id}}}'。然而,使用 GraphQL 客户端库更容易,它负责正确格式化请求,并通常提供客户端缓存等特性。

以下列表展示了FtgoGraphQLClient类,这是一个简单的基于 GraphQL 的 FTGO 应用程序客户端。它的构造函数实例化了ApolloClient,这是由 Apollo GraphQL 客户端库提供的。FtgoGraphQLClient类定义了一个findConsumer()方法,该方法使用客户端检索消费者的名称。

列表 8.11. 使用 Apollo GraphQL 客户端执行查询
class FtgoGraphQLClient {

    constructor(...) {
        this.client = new ApolloClient({ ... });
    }

    findConsumer(consumerId) {
        return this.client.query({
            variables: { cid: consumerId},             *1*
             query: gql`
              query foo($cid : Int!) {                 *2*
                 consumer(consumerId: $cid)  {         *3*
                     id
                    firstName
                    lastName
                }
            } `,
        })
    }

}
  • 1 提供值 $cid。

  • 2 定义 $cid 为 Int 类型的变量。

  • 3 将查询参数 consumerid 的值设置为 $cid。

FtgoGraphQLClient 类可以定义各种查询方法,例如 findConsumer()。每个方法都执行一个查询,精确地检索客户端所需的数据。

这一节只是浅尝辄止地介绍了 GraphQL 的功能。我希望我已经证明 GraphQL 是一个非常有吸引力的替代方案,用于更传统的基于 REST 的 API 网关。它允许您实现一个灵活的 API,足以支持多样化的客户端。因此,您应该考虑使用 GraphQL 来实现您的 API 网关。

摘要

  • 您的外部客户端通常通过 API 网关访问应用程序的服务。API 网关为每个客户端提供定制的 API。它负责请求路由、API 组合、协议转换以及实现边缘功能,如身份验证。

  • 您的应用程序可以有一个单独的 API 网关,或者可以使用前端后端模式,为每种类型的客户端定义一个 API 网关。前端后端模式的主要优势是它给予客户端团队更大的自主权,因为他们可以开发、部署和运行自己的 API 网关。

  • 您可以使用多种技术来实现 API 网关,包括现成的 API 网关产品。或者,您可以使用框架开发自己的 API 网关。

  • Spring Cloud Gateway 是一个开发 API 网关的好用且易于使用的框架。它使用任何请求属性路由请求,包括方法和路径。Spring Cloud Gateway 可以将请求直接路由到后端服务或自定义处理方法。它是使用可扩展的、反应式的 Spring Framework 5 和 Project Reactor 框架构建的。您可以使用 Project Reactor 的 Mono 抽象,以反应式风格编写自定义请求处理器。

  • GraphQL,一个提供基于图查询语言的框架,是开发 API 网关的另一个优秀基础。您编写一个面向图的模式来描述服务器端数据模型及其支持的查询。然后通过编写解析器将此模式映射到您的服务上,解析器用于检索数据。基于 GraphQL 的客户端针对指定服务器应返回的确切数据的模式执行查询。因此,基于 GraphQL 的 API 网关可以支持多样化的客户端。

第九章。测试微服务:第一部分

本章涵盖

  • 微服务的有效测试策略

  • 使用模拟和存根来单独测试软件元素

  • 使用测试金字塔来确定测试努力的焦点

  • 在服务内部对类进行单元测试

FTGO,像许多组织一样,已经采用了一种传统的测试方法。测试主要是在开发之后进行的活动。FTGO 的开发者将他们的代码扔给 QA 团队,由他们验证软件是否按预期工作。更重要的是,他们的大部分测试都是手动进行的。遗憾的是,这种测试方法是有缺陷的——有两个原因:

  • 手动测试效率极低你永远不应该要求人类去做机器能做得更好的事情。与机器相比,人类速度慢,不能 24/7 工作。如果你依赖手动测试,你将无法快速且安全地交付软件。编写自动化测试是至关重要的。

  • 测试在交付过程中的进行时间太晚当然,在应用程序编写完成后对应用程序进行批评性测试是有其作用的,但经验表明,这些测试是不够的。一个更好的方法是让开发者将自动化测试作为开发的一部分来编写。这提高了他们的生产力,因为例如,他们会有在编辑代码时提供即时反馈的测试。

在这方面,FTGO 是一个相当典型的组织。《Sauce Labs 2018 年测试趋势报告》描绘了一个相当黯淡的测试自动化状态图景(saucelabs.com/resources/white-papers/testing-trends-for-2018)。它描述了只有 26%的组织大部分是自动化的,而仅有微乎其微的 3%是完全自动化的!

对手动测试的依赖并不是因为缺乏工具和框架。例如,JUnit,一个流行的 Java 测试框架,最早在 1998 年发布。缺乏自动化测试的原因主要是文化上的:“测试是 QA 的工作”,“这不是开发者时间最好的使用方式”,等等。开发一个快速运行、有效且可维护的测试套件也是一个挑战。此外,一个典型的大型、单体应用程序非常难以测试。

使用微服务架构的一个关键动机,如第二章所述,是提高可测试性。然而,同时,微服务架构的复杂性要求你编写自动化测试。此外,测试微服务的一些方面具有挑战性。这是因为我们需要验证服务可以正确交互,同时最大限度地减少启动许多服务的缓慢、复杂和不可靠的全链路测试的数量。

本章是关于测试的两章中的第一章。它是测试的介绍。第十章涵盖了更高级的测试概念。这两章篇幅较长,但结合起来,它们涵盖了现代软件开发中至关重要的测试思想和技巧,特别是针对微服务架构。

我在本章的开头描述了针对基于微服务应用程序的有效测试策略。这些策略使你能够确信你的软件是有效的,同时最大限度地减少测试复杂性和执行时间。之后,我描述了如何为你的服务编写一种特定的测试:单元测试。第十章涵盖了其他类型的测试:集成、组件和端到端。

让我们从查看微服务的测试策略开始。

为什么要介绍测试?

你可能想知道为什么本章包括基本测试概念的介绍。如果你已经熟悉测试金字塔和不同类型的测试等概念,你可以快速阅读本章,然后继续阅读下一章,该章节专注于微服务特定的测试主题。但根据我为全球各地的客户咨询和培训的经验,许多软件开发组织的根本弱点是缺乏自动化测试。这是因为如果你想要快速且可靠地交付软件,进行自动化测试是绝对必要的。这是唯一能够缩短前置时间的方法,即把提交的代码放入生产所需的时间。也许更重要的是,自动化测试是必要的,因为它迫使你开发一个可测试的应用程序。通常,将自动化测试引入已经很大、很复杂的应用程序是非常困难的。换句话说,不编写自动化测试是通往单体地狱的快速途径。

9.1. 微服务架构的测试策略

假设你对 FTGO 应用程序的Order Service进行了更改。自然地,下一步是你运行你的代码并验证更改是否正确。一个选择是手动测试这个更改。首先,你运行Order Service及其所有依赖项,包括数据库和其他应用程序服务这样的基础设施服务。然后,通过调用其 API 或使用 FTGO 应用程序的用户界面来“测试”该服务。这种方法的不利之处在于,它是一种缓慢且手动的方式来测试你的代码。

一个更好的选择是拥有可以在开发过程中运行的自动化测试。你的开发工作流程应该是:编辑代码,运行测试(理想情况下只需按一个键),重复。快速运行的测试可以在几秒钟内快速告诉你你的更改是否有效。但是,你该如何编写快速运行的测试?它们是否足够,或者你需要更全面的测试?这些问题就是我在这章和其他章节中回答的问题。

我从这个部分开始,先概述一些重要的自动化测试概念。我们将探讨测试的目的和典型测试的结构。我涵盖了你需要编写的不同类型的测试。我还描述了测试金字塔,它提供了关于你应该在哪里集中测试努力的宝贵指导。在介绍测试概念之后,我将讨论测试微服务的策略。我们将探讨具有微服务架构的应用程序的独特挑战。我描述了你可以使用的技巧来编写更简单、更快,但仍然有效的微服务测试。

让我们来看看测试概念。

9.1.1. 测试概述

在本章中,我的重点是自动化测试,我使用术语测试作为自动化测试的简称。维基百科将测试用例或测试定义为如下:

测试用例是为特定目标开发的测试输入、执行条件和预期结果的一组,例如,为了执行特定的程序路径或验证符合特定的要求。

en.wikipedia.org/wiki/Test_case

换句话说,测试的目的,如图 9.1 所示,是验证被测系统(SUT)的行为。在这个定义中,系统是一个术语,意味着正在测试的软件元素。它可能小到是一个类,大到是整个应用程序,或者介于两者之间,例如类簇或单个服务。相关测试的集合形成一个测试套件

图 9.1. 测试的目标是验证被测系统的行为。SUT(系统单元)可能小到是一个类,也可能大到是一个完整的应用程序。

让我们先看看自动化测试的概念。然后我将讨论你需要编写的不同类型的测试。之后,我将讨论测试金字塔,它描述了你应该编写的不同类型测试的相对比例。

编写自动化测试

自动化测试通常使用测试框架编写。例如,JUnit 是一个流行的 Java 测试框架。图 9.2 显示了自动化测试的结构。每个测试都由一个属于测试类的测试方法实现。

图 9.2. 每个自动化测试都由一个属于测试类的测试方法实现。一个测试包括四个阶段:设置,初始化测试固定装置,这是运行测试所需的一切;执行,调用 SUT;验证,验证测试的结果;以及清理,清理测试固定装置。

自动化测试通常由四个阶段组成(xunitpatterns.com/Four%20Phase%20Test.html):

  1. 设置 初始化测试固定装置,该装置由 SUT 及其依赖项组成,到所需的初始状态。例如,创建正在测试的类并将其初始化到它需要展示所需行为的状态。

  2. 执行 调用 SUT——例如,在正在测试的类上调用一个方法。

  3. 验证 对调用的结果和 SUT 的状态进行断言。例如,验证方法的返回值和正在测试的类的新的状态。

  4. 清理 如果需要,清理测试固定装置。许多测试省略了这个阶段,但某些类型的数据库测试,例如,会回滚由设置阶段启动的事务。

为了减少代码重复并简化测试,测试类可能包含在测试方法之前运行的设置方法,以及在之后运行的清理方法。测试套件是一组测试类。测试由测试运行器执行。

使用模拟和存根进行测试

SUT 通常有依赖项。依赖项的问题在于它们可能会使测试复杂化并减慢测试速度。例如,OrderController类调用OrderService,这最终依赖于许多其他应用程序服务和基础设施服务。通过运行系统的大部分内容来测试OrderController类并不实用。我们需要一种方法来单独测试 SUT。

如图 9.3 所示,解决方案是用测试替身替换 SUT 的依赖项。测试替身是一个模拟依赖项行为的对象。

图 9.3。用测试替身替换依赖项使得可以单独测试 SUT。测试更简单,更快。

图 9.3

有两种类型的测试替身:存根和模拟。术语存根模拟通常可以互换使用,尽管它们的行为略有不同。存根是一个向 SUT 返回值的测试替身。模拟是一个测试使用的测试替身,用于验证 SUT 是否正确调用了依赖项。此外,模拟通常也是一个存根。

在本章的后面部分,您将看到测试替身在实际中的应用示例。例如,第 9.2.5 节展示了如何通过使用OrderService类的测试替身来单独测试OrderController类。在那个例子中,OrderService测试替身是使用 Mockito 实现的,Mockito 是一个流行的 Java 模拟对象框架。第十章展示了如何使用测试替身测试Order Service,这些测试替身响应由Order Service发送的命令消息。

让我们现在看看不同的测试类型。

测试的不同类型

存在许多不同类型的测试。一些测试,如性能测试和可用性测试,验证应用程序是否满足其服务质量要求。在本章中,我专注于验证应用程序或服务功能方面的自动化测试。我描述了如何编写四种不同类型的测试:

  • 单元测试—**测试服务的一个小部分,例如一个类。

  • 集成测试—**验证服务能否与基础设施服务(如数据库和其他应用程序服务)交互。

  • 组件测试—**对单个服务的验收测试。

  • 端到端测试—**对整个应用程序的验收测试。

它们在范围上主要有所不同。在光谱的一端是单元测试,它验证最小有意义的程序元素的行为。对于像 Java 这样的面向对象语言来说,这相当于一个类。在光谱的另一端是端到端测试,它验证整个应用程序的行为。在中间是组件测试,它测试单个服务。在第十章中,您将看到集成测试的范围相对较小,但它们比纯单元测试更复杂。范围只是描述测试的一种方式。另一种方式是使用测试象限。

编译时单元测试

测试是开发的一个组成部分。现代开发工作流程是先编辑代码,然后运行测试。此外,如果您是测试驱动开发(TDD)的实践者,您将首先编写一个失败的测试,然后编写代码使其通过,以此来开发新功能或修复错误。即使您不是 TDD 的忠实信徒,编写一个可以重现错误的测试,然后编写修复它的代码,也是一个修复错误的绝佳方法。

作为此工作流程一部分运行的测试被称为编译时测试。在现代 IDE,如 IntelliJ IDEA 或 Eclipse 中,您通常不会将代码作为单独的步骤进行编译。相反,您使用单个按键来编译代码并运行测试。为了保持流程,这些测试需要快速执行——理想情况下,不超过几秒钟。

使用测试象限对测试进行分类

一种对测试进行分类的好方法是布赖恩·马里克(Brian Marick)的测试象限www.exampler.com/old-blog/2003/08/21/#agile-testing-project-1)。测试象限,如图 9.4 所示,沿两个维度对测试进行分类:

  • 测试是面向业务还是面向技术—**面向业务的测试使用领域专家的术语进行描述,而面向技术的测试使用开发者和实现的术语进行描述。

  • 测试的目标是支持编程还是批评应用程序—**开发者将支持编程的测试作为他们日常工作的一部分。批评应用程序的测试旨在识别需要改进的领域。

图 9.4。测试象限根据两个维度对测试进行分类。第一个维度是测试是否面向业务或技术。第二个维度是测试的目的是支持编程还是评估应用程序。

测试象限定义了四种不同的测试类别:

  • Q1 支持面向编程/技术:单元和集成测试

  • Q2 支持面向编程/业务:组件和端到端测试

  • Q3 评估面向应用/业务的:可用性和探索性测试

  • Q4 评估面向应用/技术的:非功能性验收测试,例如性能测试

测试象限并不是组织测试的唯一方式。还有测试金字塔,它提供了关于需要编写多少种类型测试的指导。

使用测试金字塔作为指导来集中测试努力

我们必须编写不同类型的测试,以确保我们的应用程序能够正常工作。然而,挑战在于测试的执行时间和复杂性与其范围成正比。此外,测试的范围越大,包含的移动部件越多,其可靠性就越低。不可靠的测试几乎和没有测试一样糟糕,因为如果你不相信一个测试,你很可能会忽略失败。

在光谱的一端是对单个类的单元测试。它们执行速度快,编写简单,且可靠。在光谱的另一端是对整个应用程序的端到端测试。这些测试通常很慢,难以编写,并且由于它们的复杂性,往往不可靠。由于我们开发和测试的预算是有限的,我们希望专注于编写范围小且不损害测试套件有效性的测试。

测试金字塔,如图 9.5 所示,是一个很好的指导(martinfowler.com/bliki/TestPyramid.html)。金字塔的底部是快速、简单且可靠的单元测试。金字塔的顶部是缓慢、复杂且易碎的端到端测试。就像美国农业部食物金字塔一样,尽管更有用且争议较少(en.wikipedia.org/wiki/History_of_USDA_nutrition_guides),测试金字塔描述了每种类型测试的相对比例。

图 9.5。测试金字塔描述了需要编写的每种类型测试的相对比例。随着你向上移动金字塔,你应该编写越来越少的测试。

测试金字塔的关键思想是,随着我们向上移动金字塔,我们应该编写越来越少的测试。我们应该编写大量的单元测试和非常少的端到端测试。正如你将在本章中看到的,我描述了一种强调测试服务各个部分的策略。它甚至最小化了组件测试的数量,这些测试针对整个服务。

测试像消费者服务这样的单个微服务相对简单,因为它们不依赖于任何其他服务。但对于像订单服务这样的服务,它依赖于众多其他服务,我们该如何确保整个应用程序能够正常工作呢?这是测试具有微服务架构的应用程序的关键挑战。测试的复杂性已经从单个服务转移到它们之间的交互。让我们看看如何解决这个问题。

9.1.2. 测试微服务的挑战

在基于微服务的应用程序中,进程间通信比在单体应用程序中扮演着更加重要的角色。单体应用程序可能只与少数外部客户端和服务进行通信。例如,FTGO 应用程序的单体版本使用了一些第三方网络服务,如 Stripe 用于支付、Twilio 用于消息传递和 Amazon SES 用于电子邮件,它们都有稳定的 API。应用程序模块之间的任何交互都是通过基于编程语言的 API 进行的。进程间通信在应用程序的边缘非常明显。

相比之下,进程间通信在微服务架构中至关重要。基于微服务的应用程序是一个分布式系统。团队不断开发他们的服务并演进他们的 API。确保服务开发者编写的测试能够验证他们的服务与其依赖项和客户端的交互是至关重要的。

如第三章所述,服务之间使用各种交互样式和进程间通信(IPC)机制进行通信。一些服务使用基于请求/响应的交互,这种交互通过同步协议实现,例如 REST 或 gRPC。其他服务通过请求/异步回复或发布/订阅使用异步消息进行交互。例如,图 9.6 展示了 FTGO 应用中的一些服务是如何进行交互的。每个箭头都指向从消费者服务到生产者服务的方向。

图 9.6. FTGO 应用程序中的一些服务间通信。每个箭头都指向从消费者服务到生产者服务的方向。

箭头指向依赖的方向,从 API 的消费者到 API 的提供者。消费者对 API 的假设取决于交互的性质:

  • REST 客户端服务—API 网关路由请求到服务并实现 API 组合。

  • 领域事件消费者发布者订单历史服务消费由订单服务发布的事件。

  • 命令消息请求者回复者订单服务向各种服务发送命令消息并消费回复。

两个服务之间的每次交互代表两个服务之间的协议或合同。例如,Order History ServiceOrder Service必须就事件消息结构和发布到的通道达成一致。同样,API 网关和这些服务必须就 REST API 端点达成一致。Order Service和它使用异步请求/响应调用的每个服务必须就命令通道和命令及回复消息的格式达成一致。

作为服务开发者,你需要确信你消费的服务具有稳定的 API。同样,你也不想无意中破坏你服务 API 的兼容性。例如,如果你正在开发Order Service,你想要确保你的服务依赖项(如Consumer ServiceKitchen Service)的开发者不会以与你的服务不兼容的方式更改他们的 API。同样,你必须确保你不会以破坏API GatewayOrder History Service的方式更改Order Services的 API。

验证两个服务可以交互的一种方法是在运行这两个服务的情况下,调用一个触发通信的 API,并验证它具有预期的结果。这肯定会捕获集成问题,但基本上是一个端到端测试。测试可能需要运行这些服务的许多其他传递依赖项。测试还可能需要调用复杂的高级功能,如业务逻辑,即使其目标是测试相对低级的 IPC。最好避免编写这样的端到端测试。我们 somehow 需要编写更快、更简单、更可靠的测试,理想情况下是独立测试服务。解决方案是使用所谓的消费者驱动的合同测试

消费者驱动的合同测试

假设你是第八章中描述的API Gateway团队的一员。第八章。API GatewayOrderServiceProxy调用各种 REST 端点,包括GET /orders/{orderId}端点。我们编写测试以验证API GatewayOrder Service是否就 API 达成一致至关重要。在消费者合同测试的术语中,这两个服务参与一个消费者-提供者关系API Gateway是消费者,而Order Service是提供者。消费者合同测试是对提供者(如Order Service)的集成测试,它验证其 API 是否符合消费者的期望,例如API Gateway

消费者合同测试专注于验证提供者 API 的“形状”是否符合消费者的期望。对于一个 REST 端点,合同测试验证提供者实现了一个端点,该端点

  • 具有预期的 HTTP 方法和路径

  • 如果有的话,接受预期的头信息

  • 如果有的话,接受请求正文

  • 返回包含预期状态码、头信息和正文的响应

重要的是要记住,合同测试并不彻底测试提供者的业务逻辑。这是单元测试的工作。稍后,您将看到消费者合同测试实际上是模拟控制器测试。

开发消费者产品的团队编写一个合同测试套件,并将其(例如,通过拉取请求)添加到提供者的测试套件中。调用Order Service的其他服务的开发者也会贡献一个测试套件,如图 9.7 所示。每个测试套件将测试Order Service API 与每个消费者相关的方面。例如,Order History Service的测试套件会验证Order Service是否发布了预期的事件。

图 9.7。每个开发消费Order Service API 服务的团队贡献一个合同测试套件。该测试套件验证 API 是否符合消费者的期望。这个测试套件,以及其他团队贡献的测试套件,由Order Service的部署管道运行。

图片

这些测试套件由Order Service的部署管道执行。如果消费者合同测试失败,则该失败会告知生产团队他们对 API 进行了破坏性更改。他们必须修复 API 或与消费者团队沟通。

模式:消费者驱动的合同测试

验证服务是否满足其客户端的期望,请参阅microservices.io/patterns/testing/service-integration-contract-test.html

消费者驱动的合同测试通常使用示例测试。消费者和提供者之间的交互由一组示例定义,称为合同。每个合同包含在一次交互过程中交换的示例消息。例如,一个 REST API 的合同包含一个示例 HTTP 请求和响应。表面上,使用例如 OpenAPI 或 JSON schema 编写的模式来定义交互可能看起来更好。但事实证明,当编写测试时,模式并不那么有用。测试可以使用模式验证响应,但仍需要使用示例请求调用提供者。

此外,消费者测试还需要示例响应。这是因为尽管消费者驱动的合同测试的重点是测试提供者,但合同也用于验证消费者是否符合合同。例如,一个 REST 客户端的消费者端合同测试使用合同配置一个 HTTP 存根服务,该服务验证 HTTP 请求是否与合同的请求匹配,并发回合同的 HTTP 响应。测试交互的双方确保消费者和提供者对 API 达成一致。稍后我们将探讨如何编写此类测试的示例,但首先让我们看看如何使用 Spring Cloud Contract 编写消费者合同测试。

模式:消费者端合同测试

验证服务客户端能否与该服务通信。请参阅 microservices.io/patterns/testing/consumer-side-contract-test.html

使用 Spring Cloud Contract 测试服务

两个流行的合同测试框架是 Spring Cloud Contract (cloud.spring.io/spring-cloud-contract/),这是一个针对 Spring 应用的消费者合同测试框架,以及 Pact 框架家族(github.com/pact-foundation),它支持多种语言。FTGO 应用程序是一个基于 Spring 框架的应用程序,因此在本章中,我将介绍如何使用 Spring Cloud Contract。它提供了一种 Groovy 领域特定语言 (DSL) 用于编写合同。每个合同都是一个消费者和提供者之间交互的具体示例,例如 HTTP 请求和响应。Spring Cloud Contract 代码为提供者生成合同测试。它还配置了模拟,例如模拟 HTTP 服务器,用于消费者集成测试。

假设您正在处理 API Gateway 并想为 Order Service 编写一个消费者合同测试。图 9.8 显示了此过程,需要您与 Order Service 团队协作。您编写定义 API Gateway 如何与 Order Service 交互的合同。Order Service 团队使用这些合同来测试 Order Service,而您使用它们来测试 API Gateway。步骤顺序如下:

  1. 您编写一个或多个合同,例如 列表 9.1 中所示。每个合同由 API Gateway 可能发送给 Order Service 的 HTTP 请求和预期的 HTTP 响应组成。您通过 Git pull 请求等方式将这些合同提供给 Order Service 团队。

  2. Order Service 团队使用消费者合同测试来测试 Order Service,这些测试由 Spring Cloud Contract 代码从合同生成。

    图 9.8. API Gateway 团队编写合同。Order Service 团队使用这些合同来测试 Order Service 并将它们发布到存储库。API Gateway 团队使用发布的合同来测试 API Gateway

  3. Order Service 团队将测试 Order Service 的合同发布到 Maven 存储库。

  4. 您使用发布的合同来编写 API Gateway 的测试。

由于您使用发布的合同测试 API Gateway,您可以确信它与已部署的 Order Service 兼容。

合同是这个测试策略的关键部分。以下列表显示了一个示例 Spring Cloud Contract。它由一个 HTTP 请求和一个 HTTP 响应组成。

列表 9.1. 描述 API Gateway 如何调用 Order Service 的合同
org.springframework.cloud.contract.spec.Contract.make {
    request {                                             *1*
        method 'GET'
        url '/orders/1223232'
    }
    response {                                            *2*
        status 200
        headers {
            header('Content-Type': 'application/json;charset=UTF-8')
        }
        body("{ ... }")
    }
}
  • 1 HTTP 请求的方法和路径

  • 2 HTTP 响应的状态码、头和正文

请求元素是对 REST 端点GET /orders/{orderId}的 HTTP 请求。响应元素是描述API Gateway期望的Order的 HTTP 响应。Groovy 合同是提供者代码库的一部分。每个消费者团队编写合同来描述他们的服务如何与提供者交互,并通过 Git 拉取请求,可能提供给提供者团队。提供者团队负责将合同打包成 JAR 文件,并发布到 Maven 仓库。消费者端测试从仓库下载 JAR 文件。

每个合同的请求和响应都扮演着测试数据和预期行为规范的双重角色。在消费者端测试中,合同被用来配置一个存根,这类似于 Mockito 模拟对象,并模拟Order Service的行为。它使得可以在不运行Order Service的情况下测试API Gateway。在提供者端测试中,生成的测试类使用合同的请求调用提供者,并验证它返回的响应与合同的响应相匹配。下一章将讨论如何使用 Spring Cloud Contract 的详细情况,但现在我们将看看如何使用消费者合同测试来测试消息 API。

消息 API 的消费者合同测试

REST 客户端并不是唯一对提供者 API 有期望的消费者类型。订阅领域事件并使用基于异步请求/响应通信的服务也是消费者。它们消费其他服务的消息 API,并对该 API 的性质做出假设。我们也必须为这些服务编写消费者合同测试。

Spring Cloud Contract 还提供了对基于消息交互的测试支持。合同的结构和测试如何使用它取决于交互的类型。领域事件发布的合同由一个示例领域事件组成。提供者测试导致提供者发出事件,并验证它是否与合同的事件匹配。消费者测试验证消费者能否处理该事件。在下一章中,我将描述一个示例测试。

异步请求/响应交互的合同类似于 HTTP 合同。它由一个请求消息和一个响应消息组成。提供者测试使用合同的请求消息调用 API,并验证响应是否与合同的响应匹配。消费者测试使用合同配置一个存根订阅者,该订阅者监听合同的请求消息,并以指定的响应进行回复。下一章将讨论一个示例测试。但在那之前,我们将看看运行这些和其他测试的部署管道。

9.1.3. 部署管道

每个服务都有一个部署管道。Jez Humble 的书籍《持续交付》(Addison-Wesley,2010)将部署管道描述为将代码从开发者的桌面自动部署到生产环境的自动化过程。如图 9.9 所示,它由一系列执行测试套件的阶段组成,随后是一个发布或部署服务的阶段。理想情况下,它是完全自动化的,但它可能包含手动步骤。部署管道通常使用持续集成(CI)服务器,如 Jenkins 来实现。

图 9.9. Order Service的示例部署管道。它由一系列阶段组成。在提交代码之前,开发者会运行预提交测试。其余阶段由自动化工具执行,例如 Jenkins CI 服务器。

随着代码通过管道流动,测试套件在更类似生产环境的环境中对其进行越来越彻底的测试。同时,每个测试套件的执行时间通常会增加。目的是尽可能快速地提供关于测试失败的反馈。

如图 9.9 所示的示例部署管道包括以下阶段:

  • 预提交测试阶段 运行单元测试。这是在开发者提交更改之前执行的。

  • 提交测试阶段 编译服务,运行单元测试,并执行静态代码分析。

  • 集成测试阶段 运行集成测试。

  • 组件测试阶段 运行服务的组件测试。

  • 部署阶段 将服务部署到生产环境中。

当开发者提交更改时,CI 服务器会运行提交阶段。它执行得非常快,因此可以快速提供关于提交的反馈。后续阶段运行时间更长,提供的即时反馈较少。如果所有测试都通过,最终阶段就是将此管道部署到生产环境中。

在这个例子中,从提交到部署的整个部署管道是完全自动化的。然而,有些情况下需要手动步骤。例如,你可能需要一个手动测试阶段,比如预发布环境。在这种情况下,代码在测试人员点击按钮表示成功后进入下一阶段。或者,对于本地产品,部署管道会发布服务的新版本。稍后,发布的服务会被打包成产品版本并发送给客户。

现在我们已经了解了部署管道的组织结构和它执行不同类型测试的时间,让我们来看看测试金字塔的底部,看看如何为服务编写单元测试。

9.2. 为服务编写单元测试

假设你想编写一个测试来验证 FTGO 应用的Order Service正确计算Order的小计。你可以编写运行Order Service、调用其 REST API 创建Order并检查 HTTP 响应包含预期值的测试。这种方法的一个缺点是测试既复杂又慢。如果这些测试是Order类的编译时测试,你会浪费很多时间等待它完成。一个更有效的方法是为Order类编写单元测试。

如图 9.10 所示,单元测试是测试金字塔的最低层。它们是面向技术的测试,支持开发。单元测试验证一个单元(服务的一个非常小的部分)是否正确工作。一个单元通常是类,因此单元测试的目标是验证它是否按预期行为。

图 9.10。单元测试是金字塔的基础。它们运行速度快,易于编写,且可靠。一个独立的单元测试在隔离的情况下测试一个类,使用模拟或存根来处理其依赖项。一个社交单元测试测试一个类及其依赖项。

有两种类型的单元测试(martinfowler.com/bliki/UnitTest.html):

  • 独立单元测试 使用模拟对象对类的依赖项进行隔离测试

  • 社交单元测试 测试一个类及其依赖项

类的责任及其在架构中的作用决定了使用哪种类型的测试。图 9.11 展示了典型服务的六边形架构以及为每种类通常使用的单元测试类型。控制器和服务类通常使用独立单元测试进行测试。领域对象,如实体和价值对象,通常使用社交单元测试进行测试。

图 9.11。一个类的责任决定了是否使用独立或社交单元测试。

每个类典型的测试策略如下:

  • 实体,如Order,如第五章所述(kindle_split_013.xhtml#ch05)是具有持久身份的对象,使用社交单元测试进行测试。

  • 值对象,如Money,如第五章所述(kindle_split_013.xhtml#ch05)是值的集合,使用社交单元测试进行测试。

  • 传奇,如CreateOrderSaga,如第四章所述(kindle_split_012.xhtml#ch04),在服务之间维护数据一致性,使用社交单元测试进行测试。

  • 领域服务,如OrderService,如第五章所述(kindle_split_013.xhtml#ch05)是实现不属于实体或值对象的业务逻辑的类,使用独立单元测试进行测试。

  • 处理 HTTP 请求的控制器,如OrderController,使用独立单元测试进行测试。

  • 输入和输出消息网关使用单独的单元测试进行测试。

让我们先看看如何测试实体。

9.2.1. 为实体开发单元测试

下面的列表显示了 OrderTest 类的摘录,该类实现了 Order 实体的单元测试。该类有一个 @Before setUp() 方法,在运行每个测试之前创建一个 Order。它的 @Test 方法可能会进一步初始化 Order,调用其方法之一,然后对返回值和 Order 的状态进行断言。

列表 9.2. 对 Order 实体的一个简单、快速运行的单元测试
public class OrderTest {

  private ResultWithEvents<Order> createResult;
  private Order order;

  @Before
  public void setUp() throws Exception {
    createResult = Order.createOrder(CONSUMER_ID, AJANTA_ID, CHICKEN_VINDALOO
     _LINE_ITEMS);
    order = createResult.result;
  }

  @Test
  public void shouldCalculateTotal() {
    assertEquals(CHICKEN_VINDALOO_PRICE.multiply(CHICKEN_VINDALOO_QUANTITY),
     order.getOrderTotal());
  }

  ...

}

@Test shouldCalculateTotal() 方法验证 Order.getOrderTotal() 返回预期的值。单元测试彻底测试了业务逻辑。它们是针对 Order 类及其依赖项的社交单元测试。你可以将它们用作编译时测试,因为它们执行得非常快。Order 类依赖于 Money 值对象,因此测试该类也很重要。让我们看看如何进行这项测试。

9.2.2. 编写值对象的单元测试

值对象是不可变的,因此它们通常很容易测试。你不必担心副作用。值对象的测试通常创建一个处于特定状态的值对象,调用其方法之一,并对返回值进行断言。列表 9.3 显示了 Money 值对象的测试,它是一个表示货币值的简单类。这些测试验证了 Money 类的方法的行为,包括 add() 方法,它将两个 Money 对象相加,以及 multiply() 方法,它将一个 Money 对象乘以一个整数。它们是单独的测试,因为 Money 类不依赖于任何其他应用程序类。

列表 9.3. 对 Money 值对象的一个简单、快速运行的测试
public class MoneyTest {

  private final int M1_AMOUNT = 10;
  private final int M2_AMOUNT = 15;

  private Money m1 = new Money(M1_AMOUNT);
  private Money m2 = new Money(M2_AMOUNT);

  @Test
  public void shouldAdd() {                                       *1*
     assertEquals(new Money(M1_AMOUNT + M2_AMOUNT), m1.add(m2));
  }

  @Test
  public void shouldMultiply() {                                  *2*
    int multiplier = 12;
    assertEquals(new Money(M2_AMOUNT * multiplier), m2.multiply(multiplier));
  }

  ...
}
  • 1 验证两个 Money 对象可以相加。

  • 2 验证一个 Money 对象可以乘以一个整数。

实体和值对象是服务业务逻辑的构建块。但一些业务逻辑也存在于服务的 sagas 和服务中。让我们看看如何测试这些内容。

9.2.3. 为 sagas 开发单元测试

一个 saga,例如 CreateOrderSaga 类,实现了重要的业务逻辑,因此需要对其进行测试。它是一个持久对象,向 saga 参与者发送命令消息并处理它们的回复。如 第四章 所述,CreateOrderSaga 与多个服务(如 Consumer ServiceKitchen Service)交换命令/回复消息。对这个类的测试创建了一个 saga 并验证它向 saga 参与者发送了预期的消息序列。你需要编写的一个测试是针对快乐路径的测试。你还必须编写针对 saga 因为 saga 参与者发送了失败消息而回滚的各种场景的测试。

一种方法可能是编写使用真实数据库和消息代理以及模拟来模拟各种传说参与者的测试。例如,Consumer Service 的模拟会订阅 consumerService 命令通道并发送所需的回复消息。但使用这种方法编写的测试会相当慢。一个更有效的方法是编写模拟与数据库和消息代理交互的类的测试。这样,我们可以专注于测试传说的核心职责。

列表 9.4 展示了对 CreateOrderSaga 的测试。这是一个社交单元测试,用于测试传说类及其依赖项。它使用 Eventuate Tram Saga 测试框架编写(github.com/eventuate-tram/eventuate-tram-sagas)。此框架提供了一个易于使用的 DSL,它抽象化了与传说交互的细节。使用此 DSL,您可以创建一个传说并验证它是否发送了正确的命令消息。在底层,传说测试框架使用数据库和消息基础设施的模拟配置了传说框架。

列表 9.4. 对 CreateOrderSaga 的简单、快速运行的单元测试
public class CreateOrderSagaTest {

  @Test
  public void shouldCreateOrder() {
    given()
        .saga(new CreateOrderSaga(kitchenServiceProxy),                *1*
                 new CreateOrderSagaState(ORDER_ID,
                            CHICKEN_VINDALOO_ORDER_DETAILS)).
    expect().                                                          *2*
         command(new ValidateOrderByConsumer(CONSUMER_ID, ORDER_ID,
                CHICKEN_VINDALOO_ORDER_TOTAL)).
        to(ConsumerServiceChannels.consumerServiceChannel).
    andGiven().
        successReply().                                                *3*
     expect().
          command(new CreateTicket(AJANTA_ID, ORDER_ID, null)).        *4*
           to(KitchenServiceChannels.kitchenServiceChannel);
  }

  @Test
  public void shouldRejectOrderDueToConsumerVerificationFailed() {
    given()
        .saga(new CreateOrderSaga(kitchenServiceProxy),
                new CreateOrderSagaState(ORDER_ID,
                           CHICKEN_VINDALOO_ORDER_DETAILS)).
    expect().
        command(new ValidateOrderByConsumer(CONSUMER_ID, ORDER_ID,
                CHICKEN_VINDALOO_ORDER_TOTAL)).
        to(ConsumerServiceChannels.consumerServiceChannel).
    andGiven().
        failureReply().                                                *5*
     expect().
        command(new RejectOrderCommand(ORDER_ID)).
        to(OrderServiceChannels.orderServiceChannel);                  *6*
   }

}
  • 1 创建传说。

  • 2 验证它是否向消费者服务发送了 ValidateOrderByConsumer 消息。

  • 3 向该消息发送一个成功回复。

  • 4 验证它是否向厨房服务发送了 CreateTicket 消息。

  • 5 发送一个失败回复,表明消费者服务拒绝了订单。

  • 6 验证传说是否向订单服务发送了 RejectOrderCommand 消息。

@Test shouldCreateOrder() 方法测试了快乐路径。@Test shouldRejectOrderDueToConsumerVerificationFailed() 方法测试了消费者服务拒绝订单的场景。它验证了 CreateOrderSaga 向消费者被拒绝进行补偿发送了 RejectOrderCommandCreateOrderSagaTest 类有测试其他失败场景的方法。

现在我们来看如何测试领域服务。

9.2.4. 编写领域服务的单元测试

服务的大部分业务逻辑是由实体、值对象和传说实现的。例如,OrderService 类这样的领域服务类实现了其余部分。这是一个典型的领域服务类。它的方法调用实体和存储库并发布领域事件。测试此类的一个有效方法是使用主要孤立的单元测试,该测试模拟了存储库和消息类等依赖项。

列表 9.5 展示了测试 OrderServiceOrderServiceTest 类。它定义了孤立的单元测试,使用 Mockito 模拟服务依赖项。每个测试按照以下方式实现测试阶段:

  1. 设置 配置服务的依赖项的模拟对象

  2. 执行 调用服务方法

  3. 验证 验证服务方法返回的值是否正确,以及依赖项是否已正确调用

列表 9.5. OrderService 类的一个简单、快速运行的单元测试
public class OrderServiceTest {

  private OrderService orderService;
  private OrderRepository orderRepository;
  private DomainEventPublisher eventPublisher;
  private RestaurantRepository restaurantRepository;
  private SagaManager<CreateOrderSagaState> createOrderSagaManager;
  private SagaManager<CancelOrderSagaData> cancelOrderSagaManager;
  private SagaManager<ReviseOrderSagaData> reviseOrderSagaManager;

  @Before
  public void setup() {
    orderRepository = mock(OrderRepository.class);                         *1*
     eventPublisher = mock(DomainEventPublisher.class);
    restaurantRepository = mock(RestaurantRepository.class);
    createOrderSagaManager = mock(SagaManager.class);
    cancelOrderSagaManager = mock(SagaManager.class);
    reviseOrderSagaManager = mock(SagaManager.class);
    orderService = new OrderService(orderRepository, eventPublisher,       *2*
             restaurantRepository, createOrderSagaManager,
            cancelOrderSagaManager, reviseOrderSagaManager);
  }

  @Test
  public void shouldCreateOrder() {
    when(restaurantRepository                                              *3*
       .findById(AJANTA_ID)).thenReturn(Optional.of(AJANTA_RESTAURANT_);
    when(orderRepository.save(any(Order.class))).then(invocation -> {      *4*
       Order order = (Order) invocation.getArguments()[0];
      order.setId(ORDER_ID);
      return order;
    });

    Order order = orderService.createOrder(CONSUMER_ID,                    *5*
                     AJANTA_ID, CHICKEN_VINDALOO_MENU_ITEMS_AND_QUANTITIES);

    verify(orderRepository).save(same(order));                             *6*

    verify(eventPublisher).publish(Order.class, ORDER_ID,                  *7*
             singletonList(
                 new OrderCreatedEvent(CHICKEN_VINDALOO_ORDER_DETAILS)));

    verify(createOrderSagaManager)                                         *8*
           .create(new CreateOrderSagaState(ORDER_ID,
                       CHICKEN_VINDALOO_ORDER_DETAILS),
                  Order.class, ORDER_ID);
  }

}
  • 1 为 OrderService 的依赖项创建 Mockito 模拟。

  • 2 创建一个注入了模拟依赖的 OrderService。

  • 3 配置 RestaurantRepository.findById() 以返回 Ajanta 餐厅。

  • 4 配置 OrderRepository.save() 以设置订单的 ID。

  • 5 调用 OrderService.create()。

  • 6 验证 OrderService 是否已将新创建的订单保存到数据库中。

  • 7 验证 OrderService 是否发布了 OrderCreatedEvent。

  • 8 验证 OrderService 是否创建了一个 CreateOrderSaga。

setUp() 方法创建了一个注入了模拟依赖的 OrderService@Test shouldCreateOrder() 方法验证 OrderService.createOrder() 是否调用了 OrderRepository 来保存新创建的 Order,发布了一个 OrderCreatedEvent,并创建了一个 CreateOrderSaga

现在我们已经看到了如何对领域逻辑类进行单元测试,接下来让我们看看如何对与外部系统交互的适配器进行单元测试。

9.2.5. 开发控制器的单元测试

例如 Order Service 这样的服务通常有一个或多个控制器来处理来自其他服务和 API 网关的 HTTP 请求。控制器类由一组请求处理方法组成。每个方法实现一个 REST API 端点。方法参数代表 HTTP 请求中的值,例如路径变量。它通常调用领域服务或存储库并返回一个响应对象。例如,OrderController 调用 OrderServiceOrderRepository。对于控制器,一个有效的测试策略是单独的单元测试,模拟服务和存储库。

你可以编写一个类似于 OrderServiceTest 类的测试类来实例化一个控制器类并调用其方法。但这种方法无法测试一些重要的功能,例如请求路由。使用模拟 MVC 测试框架,例如 Spring Mock Mvc,它属于 Spring 框架的一部分,或者基于 Spring Mock Mvc 的 Rest Assured Mock MVC,效果会更好。使用这些框架编写的测试会模拟 HTTP 请求并对 HTTP 响应进行断言。这些框架使你能够在不进行实际网络调用的情况下测试 HTTP 请求路由以及 Java 对象与 JSON 之间的转换。在底层,Spring Mock Mvc 仅实例化了足够的 Spring MVC 类以实现这一点。

这真的是单元测试吗?

由于这些测试使用 Spring 框架,你可能会认为它们不是单元测试。它们当然比我之前描述的单元测试更重量级。Spring Mock Mvc 文档将这些测试称为出 Servlet 容器集成测试(docs.spring.io/spring/docs/current/spring-framework-reference/testing.html#spring-mvc-test-vs-end-to-end-integration-tests)。然而,Rest Assured Mock MVC 将这些测试描述为单元测试(github.com/rest-assured/rest-assured/wiki/Usage#spring-mock-mvc-module)。无论术语上的争论如何,这些都是重要的测试。

列表 9.6 展示了 OrderControllerTest 类,它测试 Order ServiceOrderController。它定义了使用 OrderController 依赖项的模拟的独立单元测试。它使用 Rest Assured Mock MVC 编写,它提供了一个简单的 DSL,可以抽象出与控制器交互的细节。Rest Assured 使得向控制器发送模拟 HTTP 请求并验证响应变得容易。OrderControllerTest 创建了一个控制器,该控制器注入了 Mockito 模拟的 OrderServiceOrderRepository。每个测试配置模拟,发送 HTTP 请求,验证响应是否正确,并可能验证控制器是否调用了模拟。

列表 9.6. OrderController 类的一个简单、快速运行的单元测试
public class OrderControllerTest {

  private OrderService orderService;
  private OrderRepository orderRepository;

  @Before
  public void setUp() throws Exception {
    orderService = mock(OrderService.class);                            *1*
    orderRepository = mock(OrderRepository.class);
    orderController = new OrderController(orderService, orderRepository);
  }

  @Test
  public void shouldFindOrder() {

    when(orderRepository.findById(1L))
          .thenReturn(Optional.of(CHICKEN_VINDALOO_ORDER_);             *2*

    given().
      standaloneSetup(configureControllers(                             *3*
               new OrderController(orderService, orderRepository))).
    when().
            get("/orders/1").                                           *4*
    then().
      statusCode(200).                                                  *5*
       body("orderId",                                                  *6*
            equalTo(new Long(OrderDetailsMother.ORDER_ID).intValue())).
      body("state",
           equalTo(OrderDetailsMother.CHICKEN_VINDALOO_ORDER_STATE.name())).
      body("orderTotal",
          equalTo(CHICKEN_VINDALOO_ORDER_TOTAL.asString()))
    ;
  }

  @Test
  public void shouldFindNotOrder() { ... }

  private StandaloneMockMvcBuilder controllers(Object... controllers) { ... }

}
  • 1 为 OrderController 的依赖项创建模拟。

  • 2 配置模拟的 OrderRepository 返回一个订单。

  • 3 配置 OrderController。

  • 4 发送 HTTP 请求。

  • 5 验证响应状态码。

  • 6 验证 JSON 响应体的元素。

shouldFindOrder() 测试方法首先配置 OrderRepository 模拟以返回一个 Order。然后它发送一个 HTTP 请求以检索订单。最后,它检查请求是否成功,以及响应体是否包含预期的数据。

控制器不是唯一处理外部系统请求的适配器。还有事件/消息处理器,让我们谈谈如何对这些进行单元测试。

9.2.6. 编写事件和消息处理器的单元测试

服务通常处理外部系统发送的消息。例如,Order ServiceOrderEventConsumer,这是一个消息适配器,用于处理其他服务发布的领域事件。像控制器一样,消息适配器通常是简单的类,调用领域服务。消息适配器的每个方法通常都会调用一个服务方法,并使用消息或事件中的数据。

我们可以使用与单元测试控制器类似的方法来单元测试消息适配器。每个测试实例化消息适配器,向通道发送消息,并验证服务模拟是否被正确调用。然而,在幕后,消息基础设施被模拟,因此没有涉及任何消息代理。让我们看看如何测试 OrderEventConsumer 类。

列表 9.7 展示了 OrderEventConsumerTest 类的一部分,该类测试 OrderEventConsumer。它验证 OrderEventConsumer 将每个事件路由到适当的手动方法,并正确调用 OrderService。该测试使用 Eventuate Tram Mock Messaging 框架,该框架提供了一个易于使用的 DSL,用于编写与 Rest Assured 相同的给定-当-然后格式模拟消息测试。每个测试实例化 OrderEventConsumer,注入模拟 OrderService,发布一个领域事件,并验证 OrderEventConsumer 是否正确调用服务模拟。

列表 9.7. OrderEventConsumer 类的快速运行的单元测试
public class OrderEventConsumerTest {

  private OrderService orderService;
  private OrderEventConsumer orderEventConsumer;

  @Before
  public void setUp() throws Exception {
    orderService = mock(OrderService.class);
    orderEventConsumer = new OrderEventConsumer(orderService);            *1*
  }

  @Test
  public void shouldCreateMenu() {

    given().
            eventHandlers(orderEventConsumer.domainEventHandlers()).      *2*
    when().
      aggregate("net.chrisrichardson.ftgo.restaurantservice.domain.Restaurant",
                AJANTA_ID).
      publishes(new RestaurantCreated(AJANTA_RESTAURANT_NAME,             *3*
                          RestaurantMother.AJANTA_RESTAURANT_MENU))
    then().
       verify(() -> {                                                     *4*
          verify(orderService)
                .createMenu(AJANTA_ID,
            new RestaurantMenu(RestaurantMother.AJANTA_RESTAURANT_MENU_ITEMS));
       })
    ;
  }

}
  • 1 使用模拟依赖项实例化 OrderEventConsumer。

  • 2 配置 OrderEventConsumer 领域处理器。

  • 3 发布一个 RestaurantCreated 事件。

  • 4 验证 OrderEventConsumer 是否调用了 OrderService.createMenu().

setUp() 方法创建了一个注入了模拟 OrderServiceOrderEventConsumershouldCreateMenu() 方法发布了一个 RestaurantCreated 事件,并验证 OrderEventConsumer 调用了 OrderService.createMenu()OrderEventConsumerTest 类和其他单元测试类执行得非常快。单元测试只需几秒钟就能完成。

但单元测试并没有验证服务,例如 Order Service,是否与其他服务正确交互。例如,单元测试没有验证 Order 是否可以持久化到 MySQL。也没有验证 CreateOrderSaga 是否以正确的格式向正确的消息通道发送命令消息。而且它们也没有验证 OrderEventConsumer 处理的 RestaurantCreated 事件的结构与 Restaurant Service 发布的事件相同。为了验证服务是否正确与其他服务交互,我们必须编写集成测试。我们还需要编写组件测试,以单独测试整个服务。下一章将讨论如何进行这些类型的测试,以及端到端测试。

摘要

  • 自动化测试是快速、安全交付软件的关键基础。更重要的是,由于其固有的复杂性,为了充分利用微服务架构,您 必须 自动化您的测试。

  • 测试的目的是验证被测试系统(SUT)的行为。在这个定义中,“系统”是一个术语,意味着正在测试的软件元素。它可能小到是一个类,大到是整个应用程序,或者介于两者之间,比如一组类或一个单独的服务。相关测试的集合形成一个测试套件。

  • 简化并加速测试的一个好方法是使用测试替身。测试替身是一个模拟 SUT(系统单元)依赖行为的对象。测试替身有两种类型:存根和模拟。存根是一种测试替身,它向 SUT 返回值。模拟是一种测试替身,测试用它来验证 SUT 是否正确调用了依赖。

  • 使用测试金字塔来确定为您的服务集中测试努力的地方。您的大多数测试应该是快速、可靠且易于编写的单元测试。您必须最小化端到端测试的数量,因为它们运行缓慢、脆弱,且编写起来耗时。

第十章。测试微服务:第二部分

本章涵盖

  • 隔离测试服务的技巧

  • 使用消费者驱动的契约测试来编写快速且可靠地验证服务间通信的测试

  • 何时以及如何进行应用程序的端到端测试

本章基于上一章,介绍了测试概念,包括测试金字塔。测试金字塔描述了你应该编写的不同类型测试的相对比例。上一章介绍了如何编写单元测试,这些测试位于测试金字塔的底部。在本章中,我们继续攀登测试金字塔。

本章首先介绍如何编写集成测试,这是测试金字塔中位于单元测试之上的层级。集成测试验证服务能否正确地与基础设施服务(如数据库)和其他应用服务进行交互。接下来,我将介绍组件测试,这是对服务的验收测试。组件测试通过使用存根来模拟其依赖项,从而在隔离状态下测试服务。之后,我将描述如何编写端到端测试,这些测试针对一组服务或整个应用程序。端到端测试位于测试金字塔的顶端,因此应谨慎使用。

让我们先看看如何编写集成测试。

10.1。编写集成测试

服务通常与其他服务进行交互。例如,如图 10.1 所示,Order Service与多个服务进行交互。它的 REST API 被API Gateway消费,它的领域事件被包括Order History Service在内的服务消费。Order Service还使用其他几个服务。它在 MySQL 中持久化Orders,同时也向其他几个服务发送命令并消费它们的回复,例如Kitchen Service

图 10.1。集成测试必须验证服务能否与其客户端和依赖项进行通信。但策略不是测试整个服务,而是测试实现通信的各个适配器类。

图片描述

为了确保像Order Service这样的服务按预期工作,我们必须编写测试来验证该服务能否正确地与基础设施服务和其他应用服务进行交互。一种方法是通过启动所有服务并通过它们的 API 进行测试。然而,这被称为端到端测试,它速度慢、脆弱且成本高。如第 10.3 节所述,有时端到端测试有其作用,但它位于测试金字塔的顶端,因此你希望最小化端到端测试的数量。

一种更有效的策略是编写所谓的集成测试。如图 10.2 所示,集成测试在测试金字塔中位于单元测试之上。它们验证服务能否正确地与基础设施服务和其它服务交互。但与端到端测试不同,它们不会启动服务。相反,我们使用一些策略,这些策略显著简化了测试,同时不影响其有效性。

图 10.2。集成测试位于单元测试之上。它们验证服务能否与其依赖项通信,这包括数据库等基础设施服务。

图 10.2

第一种策略是测试服务中的每个适配器,也许还包括适配器的支持类。例如,在第 10.1.1 节中,你会看到一个 JPA 持久化测试,该测试验证Orders是否正确持久化。而不是通过Order Service的 API 进行持久化测试,它直接测试OrderRepository类。同样,在第 10.1.3 节中,你会看到一个测试,通过测试OrderDomainEventPublisher类来验证Order Service是否正确发布结构化的域事件。仅测试少量类而不是整个服务的好处是,测试显著更简单、更快。

简化验证应用服务之间交互的集成测试的第二种策略是使用合约,这在第九章中讨论过。一个 合约 是一对服务之间交互的具体示例。如图 10.1 所示,合约的结构取决于服务之间的交互类型。

表 10.1。合约的结构取决于服务之间的交互类型。
交互风格 消费者 提供者 合约
基于 REST 的请求/响应 API 网关 订单服务 HTTP 请求和响应
发布/订阅 订单历史服务 订单服务 域事件
异步请求/响应 订单服务 厨房服务 命令消息和回复消息

合约在发布/订阅风格的交互中包含一个消息,在请求/响应和异步请求/响应风格的交互中包含两个消息。

合约用于测试消费者和提供者,这确保了它们对 API 达成一致。根据你是测试消费者还是提供者,它们的使用方式略有不同:

  • 消费者端测试* 这些是对消费者适配器的测试。它们使用合约来配置存根,模拟提供者,使你能够编写不需要运行提供者的消费者集成测试。

  • Provider-side tests 这些是对提供者适配器的测试。它们使用合约,通过模拟适配器依赖项来测试适配器。

在本节的后面部分,我将描述这些类型测试的示例——但首先让我们看看如何编写持久化测试。

10.1.1. 持久化集成测试

服务通常将数据存储在数据库中。例如,Order Service 使用 JPA 在 MySQL 中持久化聚合,如 Order。同样,Order History Service 在 AWS DynamoDB 中维护一个 CQRS 视图。我们之前编写的单元测试仅测试内存中的对象。为了确保服务正确工作,我们必须编写持久化集成测试,这些测试验证服务的数据访问逻辑是否按预期工作。在 Order Service 的情况下,这意味着测试 JPA 存储库,如 OrderRepository

持久化集成测试的每个阶段的行为如下:

  • Setup 通过创建数据库模式并将其初始化到已知状态来设置数据库。它也可能开始一个数据库事务。

  • Execute 执行数据库操作。

  • Verify 对数据库状态和从数据库检索的对象的状态进行断言。

  • Teardown 一个可选阶段,可能撤销由设置阶段启动的事务对数据库所做的更改。

列表 10.1 展示了 Order 聚合和 OrderRepository 的持久化集成测试。除了依赖于 JPA 创建数据库模式外,持久化集成测试对数据库的状态没有任何假设。因此,测试不需要回滚它们对数据库所做的更改,这避免了 ORM 在内存中缓存数据更改的问题。

列表 10.1. 一个验证 Order 可以被持久化的集成测试
@RunWith(SpringRunner.class)
@SpringBootTest(classes = OrderJpaTestConfiguration.class)
public class OrderJpaTest {

  @Autowired
  private OrderRepository orderRepository;

  @Autowired
  private TransactionTemplate transactionTemplate;

  @Test
  public void shouldSaveAndLoadOrder() {

    Long orderId = transactionTemplate.execute((ts) -> {
      Order order =
              new Order(CONSUMER_ID, AJANTA_ID, CHICKEN_VINDALOO_LINE_ITEMS);
      orderRepository.save(order);
      return order.getId();
    });

    transactionTemplate.execute((ts) -> {
      Order order = orderRepository.findById(orderId).get();

      assertEquals(OrderState.APPROVAL_PENDING, order.getState());
      assertEquals(AJANTA_ID, order.getRestaurantId());
      assertEquals(CONSUMER_ID, order.getConsumerId().longValue());
      assertEquals(CHICKEN_VINDALOO_LINE_ITEMS, order.getLineItems());
      return null;
    });

  }

}

shouldSaveAndLoadOrder() 测试方法执行两个事务。第一个事务在数据库中保存一个新创建的 Order。第二个事务加载 Order 并验证其字段是否正确初始化。

你需要解决的问题是如何为持久化集成测试提供数据库。在测试期间运行数据库实例的有效解决方案是使用 Docker。第 10.2 节 描述了如何使用 Docker Compose Gradle 插件在组件测试期间自动运行服务。你可以使用类似的方法在持久化集成测试期间运行 MySQL,例如。

数据库只是服务交互的外部服务之一。现在让我们看看如何编写应用服务之间交互的集成测试,从 REST 开始。

10.1.2. 基于 REST 的请求/响应风格交互的集成测试

REST 是一种广泛使用的服务间通信机制。REST 客户端和 REST 服务必须就 REST API 达成一致,这包括 REST 端点和请求/响应体的结构。客户端必须向正确的端点发送 HTTP 请求,而服务必须发送客户端期望的响应。

例如,第八章 描述了 FTGO 应用程序的 API Gateway 如何向包括 ConsumerServiceOrder ServiceDelivery Service 在内的多个服务发出 REST API 调用。OrderServiceGET /orders/{orderId} 端点是 API Gateway 调用的端点之一。为了确保 API GatewayOrder Service 可以在不使用端到端测试的情况下通信,我们需要编写集成测试。

如前一章所述,一个好的集成测试策略是使用消费者驱动的合约测试。API GatewayGET /orders/{orderId} 之间的交互可以使用一组基于 HTTP 的合约来描述。每个合约由一个 HTTP 请求和一个 HTTP 响应组成。合约用于测试 API GatewayOrder Service

图 10.3 展示了如何使用 Spring Cloud Contract 测试基于 REST 的交互。消费者端的 API Gateway 集成测试使用合约配置一个模拟 Order Service 行为的 HTTP 模拟服务器。合约的请求指定了来自 API 网关的 HTTP 请求,合约的响应指定了模拟发送回 API 网关的响应。Spring Cloud Contract 使用合约生成提供者端 Order Service 集成测试,这些测试使用 Spring Mock MVC 或 Rest Assured Mock MVC 测试控制器。合约的请求指定了要发送到控制器的 HTTP 请求,合约的响应指定了控制器期望的响应。

图 10.3 显示了合约用于验证 API GatewayOrder Service 之间基于 REST 的通信两端的适配器类是否符合合约。消费者端测试验证 OrderServiceProxy 正确调用 Order Service。提供者端测试验证 OrderController 正确实现了 REST API 端点。

图片 10.3

消费者端的 OrderServiceProxyTest 调用 OrderServiceProxy,该代理已被配置为向 WireMock 发送 HTTP 请求。WireMock 是一个用于高效模拟 HTTP 服务器的工具——在这个测试中,它模拟 Order Service。Spring Cloud Contract 管理 WireMock,并配置它响应由合约定义的 HTTP 请求。

在提供者端,Spring Cloud Contract 生成一个名为 HttpTest 的测试类,该类使用 Rest Assured Mock MVC 来测试 Order Service 的控制器。像 HttpTest 这样的测试类必须扩展一个手写的基类。在这个例子中,基类 BaseHttp 实例化注入了模拟依赖的 OrderController,并调用 RestAssuredMockMvc.standaloneSetup() 来配置 Spring MVC。

让我们更详细地看看它是如何工作的,从一个示例合同开始。

一个 REST API 的示例合同

一个 REST 合同,例如 列表 10.2 中所示,指定了一个 HTTP 请求,这是由 REST 客户端发送的,以及客户端期望从 REST 服务器接收的 HTTP 响应。合同请求指定了 HTTP 方法、路径和可选的头部。合同响应指定了 HTTP 状态码、可选的头部,以及当适用时,预期的体。

列表 10.2. 描述基于 HTTP 请求/响应样式交互的合同
org.springframework.cloud.contract.spec.Contract.make {
    request {
        method 'GET'
        url '/orders/1223232'
    }
    response {
        status 200
        headers {
            header('Content-Type': 'application/json;charset=UTF-8')
        }
        body('''{"orderId" : "1223232", "state" : "APPROVAL_PENDING"}''')
    }
}

这个特定的合同描述了 API Gateway 成功从 Order Service 中检索 Order 的尝试。现在让我们看看如何使用这个合同来编写集成测试,从 Order Service 的测试开始。

订单服务的消费者驱动合同集成测试

订单服务的消费者驱动合同集成测试验证其 API 是否满足客户端的期望。列表 10.3 显示了 HttpBase,这是由 Spring Cloud Contract 代码生成的测试类的基类。它负责测试的设置阶段。它创建了注入了模拟依赖的控制器,并配置这些模拟以返回导致控制器生成预期响应的值。

列表 10.3. 由 Spring Cloud Contract 代码生成的测试的抽象基类
public abstract class HttpBase {

  private StandaloneMockMvcBuilder controllers(Object... controllers) {
    ...
    return MockMvcBuilders.standaloneSetup(controllers)
                     .setMessageConverters(...);
  }

  @Before
  public void setup() {
    OrderService orderService = mock(OrderService.class);                    *1*
     OrderRepository orderRepository = mock(OrderRepository.class);
    OrderController orderController =
              new OrderController(orderService, orderRepository);

    when(orderRepository.findById(1223232L))                                 *2*
            .thenReturn(Optional.of(OrderDetailsMother.CHICKEN_VINDALOO_ORDER));
    ...
    RestAssuredMockMvc.standaloneSetup(controllers(orderController));        *3*

  }
}
  • 1 创建注入了模拟的 OrderRepository。

  • 2 配置 OrderResponse,当使用合同中指定的 orderId 调用 findById() 时返回一个 Order。

  • 3 使用 OrderController 配置 Spring MVC。

传递给模拟 OrderRepositoryfindById() 方法的参数 1223232L 与 列表 10.3 中显示的合同中指定的 orderId 匹配。这个测试验证了 Order Service 有一个 GET /orders/{orderId} 端点,该端点符合其客户端的期望。

让我们看看相应的客户端测试。

API Gateway 的 OrderServiceProxy 的消费者端集成测试

API GatewayOrderServiceProxy 调用 GET /orders/{orderId} 端点。列表 10.4 展示了 OrderServiceProxyIntegrationTest 测试类,它验证了其是否符合合约。这个类被 @AutoConfigureStubRunner 注解,由 Spring Cloud Contract 提供。它告诉 Spring Cloud Contract 在随机端口上运行 WireMock 服务器,并使用指定的合约进行配置。OrderServiceProxyIntegrationTest 配置 OrderServiceProxy 向 WireMock 端口发送请求。

列表 10.4. API GatewayOrderServiceProxy 的消费者端集成测试
@RunWith(SpringRunner.class)
@SpringBootTest(classes=TestConfiguration.class,
        webEnvironment= SpringBootTest.WebEnvironment.NONE)
@AutoConfigureStubRunner(ids =                                            *1*
         {"net.chrisrichardson.ftgo.contracts:ftgo-order-service-contracts"},
        workOffline = false)
@DirtiesContext
public class OrderServiceProxyIntegrationTest {

  @Value("${stubrunner.runningstubs.ftgo-order-service-contracts.port}")  *2*
  private int port;
  private OrderDestinations orderDestinations;
  private OrderServiceProxy orderService;

  @Before
  public void setUp() throws Exception {
    orderDestinations = new OrderDestinations();
    String orderServiceUrl = "http://localhost:" + port;
    orderDestinations.setOrderServiceUrl(orderServiceUrl);
    orderService = new OrderServiceProxy(orderDestinations,               *3*
                                          WebClient.create());
  }

  @Test
  public void shouldVerifyExistingCustomer() {
    OrderInfo result = orderService.findOrderById("1223232").block();
    assertEquals("1223232", result.getOrderId());
    assertEquals("APPROVAL_PENDING", result.getState());
  }

  @Test(expected = OrderNotFoundException.class)
  public void shouldFailToFindMissingOrder() {
    orderService.findOrderById("555").block();
  }

}
  • 1 告诉 Spring Cloud Contract 配置 WireMock 使用订单服务的合约。

  • 2 获取 WireMock 运行的随机分配端口。

  • 3 创建一个配置为向 WireMock 发送请求的 OrderServiceProxy。

每个测试方法都会调用 OrderServiceProxy 并验证它返回正确的值或抛出预期的异常。shouldVerifyExistingCustomer() 测试方法验证 findOrderById() 返回的值等于合约响应中指定的值。shouldFailToFindMissingOrder() 尝试检索一个不存在的 Order,并验证 OrderServiceProxy 抛出 OrderNotFoundException。使用相同的合约测试 REST 客户端和 REST 服务确保它们在 API 上达成一致。

现在我们来看如何对使用消息交互的服务进行相同类型的测试。

10.1.3. 集成测试发布/订阅式交互

服务通常会发布领域事件,这些事件被一个或多个其他服务消费。集成测试必须验证发布者和其消费者在消息通道和领域事件结构上达成一致。例如,Order Service 在创建或更新 Order 聚合时,会发布 Order* 事件。Order History Service 是这些事件的消费者之一。因此,我们必须编写测试来验证这些服务可以交互。

图 10.4 展示了集成测试发布/订阅交互的方法。它与测试 REST 交互的方法非常相似。与之前一样,交互由一组合约定义。不同的是,每个合约指定一个领域事件。

图 10.4. 合约被用来测试发布/订阅交互的两端。提供方测试验证 OrderDomainEventPublisher 发布符合合约的事件。消费者端测试验证 OrderHistoryEventHandlers 消费合约中的示例事件。

图片

每个消费者端测试都会发布由合约指定的事件,并验证 OrderHistoryEventHandlers 是否正确调用其模拟的依赖项。

在提供者端,Spring Cloud Contract 代码生成扩展MessagingBase的测试类,MessagingBase是一个手写的抽象超类。每个测试方法调用由MessagingBase定义的钩子方法,预期这将触发服务发布事件。在这个例子中,每个钩子方法调用OrderDomainEventPublisher,它负责发布Order聚合事件。然后测试方法验证OrderDomainEventPublisher发布了预期的事件。让我们看看这些测试如何工作的细节,从合约开始。

发布OrderCreated事件的合约

列表 10.5 显示了OrderCreated事件的合约。它指定了事件通道、预期的正文和消息头。

列表 10.5. 一个发布/订阅交互风格的合约
package contracts;

org.springframework.cloud.contract.spec.Contract.make {
    label 'orderCreatedEvent'                                         *1*
    input {
        triggeredBy('orderCreated()')                                 *2*
    }

    outputMessage {                                                   *3*
        sentTo('net.chrisrichardson.ftgo.orderservice.domain.Order')
        body('''{"orderDetails":{"lineItems":[{"quantity":5,"menuItemId":"1",
                 "name":"Chicken Vindaloo","price":"12.34","total":"61.70"}],
                 "orderTotal":"61.70","restaurantId":1,
        "consumerId":1511300065921},"orderState":"APPROVAL_PENDING"}''')
        headers {
            header('event-aggregate-type',
                        'net.chrisrichardson.ftgo.orderservice.domain.Order')
            header('event-aggregate-id', '1')
        }
    }
}
  • 1 由消费者测试用于触发要发布的事件

  • 2 由代码生成的提供者测试调用

  • 3 一个OrderCreated域事件

合约还有两个其他重要元素:

  • label——由消费者测试使用,通过 Spring Contact 触发事件的发布

  • triggeredBy——由生成的测试方法调用的超类方法名称,用于触发事件的发布

让我们看看合约是如何使用的,从OrderService的提供者端测试开始。

订单服务的消费者驱动合约测试

Order Service的提供者端测试是另一个消费者驱动的合约集成测试。它验证负责发布Order聚合域事件的OrderDomainEventPublisher是否发布了符合其客户端期望的事件。列表 10.6 显示了MessagingBase,这是由 Spring Cloud Contract 代码生成的测试类的基类。它负责配置OrderDomainEventPublisher类以使用内存中的消息存根。它还定义了orderCreated()等方法,这些方法由生成的测试调用以触发事件的发布。

列表 10.6. Spring Cloud Contract 提供者端测试的抽象基类
@RunWith(SpringRunner.class)
@SpringBootTest(classes = MessagingBase.TestConfiguration.class,
                webEnvironment = SpringBootTest.WebEnvironment.NONE)
@AutoConfigureMessageVerifier
public abstract class MessagingBase {

  @Configuration
  @EnableAutoConfiguration
  @Import({EventuateContractVerifierConfiguration.class,
           TramEventsPublisherConfiguration.class,
           TramInMemoryConfiguration.class})
  public static class TestConfiguration {

    @Bean
    public OrderDomainEventPublisher
            OrderDomainEventPublisher(DomainEventPublisher eventPublisher) {
      return new OrderDomainEventPublisher(eventPublisher);
    }
  }

  @Autowired
  private OrderDomainEventPublisher OrderDomainEventPublisher;

  protected void orderCreated() {                                   *1*
     OrderDomainEventPublisher.publish(CHICKEN_VINDALOO_ORDER,
          singletonList(new OrderCreatedEvent(CHICKEN_VINDALOO_ORDER_DETAILS)));
  }

}
  • 1 orderCreated()由代码生成的测试子类调用以发布事件。

这个测试类使用内存中的消息存根配置了OrderDomainEventPublisherorderCreated()方法由从前面列表 10.5 中显示的合约生成的测试方法调用。它调用OrderDomainEventPublisher来发布一个OrderCreated事件。测试方法尝试接收此事件,然后验证它是否与合约中指定的事件匹配。现在让我们看看相应的消费者端测试。

订单历史服务的消费者端合约测试

Order History Service 消费由 Order Service 发布的事件。正如我在第七章中描述的,处理这些事件的适配器类是 OrderHistoryEventHandlers 类。它的事件处理器调用 OrderHistoryDao 来更新 CQRS 视图。列表 10.7 展示了消费者端的集成测试。它创建了一个注入了模拟 OrderHistoryDaoOrderHistoryEventHandlers。每个测试方法首先调用 Spring Cloud 发布合约中定义的事件,然后验证 OrderHistoryEventHandlers 是否正确地调用了 OrderHistoryDao

列表 10.7. OrderHistoryEventHandlers 类的消费者端集成测试
@RunWith(SpringRunner.class)
@SpringBootTest(classes= OrderHistoryEventHandlersTest.TestConfiguration.class,
        webEnvironment= SpringBootTest.WebEnvironment.NONE)
@AutoConfigureStubRunner(ids =
        {"net.chrisrichardson.ftgo.contracts:ftgo-order-service-contracts"},
        workOffline = false)
@DirtiesContext
public class OrderHistoryEventHandlersTest {

  @Configuration
  @EnableAutoConfiguration
  @Import({OrderHistoryServiceMessagingConfiguration.class,
          TramCommandProducerConfiguration.class,
          TramInMemoryConfiguration.class,
          EventuateContractVerifierConfiguration.class})
  public static class TestConfiguration {

    @Bean
    public OrderHistoryDao orderHistoryDao() {
      return mock(OrderHistoryDao.class);                                    *1*
     }
  }

  @Test
  public void shouldHandleOrderCreatedEvent() throws ... {
    stubFinder.trigger("orderCreatedEvent");                                 *2*
     eventually(() -> {                                                      *3*
       verify(orderHistoryDao).addOrder(any(Order.class), any(Optional.class));
    });
  }
  • 1 创建一个模拟的 OrderHistoryDao 以注入到 OrderHistoryEventHandlers 中。

  • 2 触发 orderCreatedEvent 模拟器,它发出 OrderCreated 事件。

  • 3 验证 OrderHistoryEventHandlers 是否调用了 orderHistoryDao.addOrder()

shouldHandleOrderCreatedEvent() 测试方法告诉 Spring Cloud Contract 发布 OrderCreated 事件。然后它验证 OrderHistoryEventHandlers 是否调用了 orderHistoryDao.addOrder()。使用相同的合约测试领域事件的发布者和消费者确保他们就 API 达成一致。现在让我们看看如何进行使用异步请求/响应交互的集成测试服务。

10.1.4. 异步请求/响应交互的集成合约测试

发布/订阅不是基于消息的唯一交互风格。服务还可以使用异步请求/响应进行交互。例如,在第四章中我们看到了 Order Service 实现了向各种服务(如 Kitchen Service)发送命令消息的 sagas,并处理回复消息。

异步请求/响应交互的双方是请求者,即发送命令的服务,和回复者,即处理命令并发送回复的服务。他们必须就命令消息通道的名称和命令及回复消息的结构达成一致。让我们看看如何编写异步请求/响应交互的集成测试。

图 10.5 展示了如何测试 Order ServiceKitchen Service 之间的交互。异步请求/响应交互的集成测试方法与用于测试 REST 交互的方法相当相似。服务之间的交互由一系列合约定义。不同之处在于,合约指定了一个输入消息和一个输出消息,而不是 HTTP 请求和回复。

图 10.5. 这些合同用于测试实现异步请求/响应交互每一方的适配器类。提供端测试验证 KitchenServiceCommandHandler 处理命令并发送回复。消费端测试验证 KitchenServiceProxy 发送符合合同的命令,并处理合同中的示例回复。

消费端测试验证命令消息代理类发送正确结构的命令消息,并正确处理回复消息。在这个例子中,KitchenServiceProxyTest 测试 KitchenServiceProxy。它使用 Spring Cloud Contract 配置消息存根,以验证命令消息与合同的输入消息匹配,并回复相应的输出消息。

提供端测试由 Spring Cloud Contract 代码生成。每个测试方法对应一个合同。它发送合同输入消息作为命令消息,并验证回复消息是否与合同的输出消息匹配。让我们看看细节,从合同开始。

异步请求/响应合同示例

列表 10.8 展示了一个交互的合同。它包括一个输入消息和一个输出消息。两个消息都指定了消息通道、消息体和消息头。命名规范是从提供者的角度出发的。输入消息的 messageFrom 元素指定了消息读取的通道。同样,输出消息的 sentTo 元素指定了回复应该发送到的通道。

列表 10.8. 描述 Order Service 如何异步调用 Kitchen Service 的合同
package contracts;

org.springframework.cloud.contract.spec.Contract.make {
    label 'createTicket'
    input {                                                                 *1*
        messageFrom('kitchenService')
        messageBody('''{"orderId":1,"restaurantId":1,"ticketDetails":{...}}''')
        messageHeaders {
            header('command_type','net.chrisrichardson...CreateTicket')
            header('command_saga_type','net.chrisrichardson...CreateOrderSaga')
            header('command_saga_id',$(consumer(regex('[0-9a-f]{16}-[0-9a-f]
               {16}'))))
            header('command_reply_to','net.chrisrichardson...CreateOrderSaga-Reply')
        }
    }
    outputMessage {                                                         *2*
        sentTo('net.chrisrichardson...CreateOrderSaga-reply')
        body([
                ticketId: 1
        ])
        headers {
            header('reply_type', 'net.chrisrichardson...CreateTicketReply')
            header('reply_outcome-type', 'SUCCESS')
        }
    }
}
  • 1 订单服务发送到 kitchenService 通道的命令消息

  • 2 Kitchen Service 发送的回复消息

在这个合同示例中,输入消息是发送到 kitchenService 通道的 CreateTicket 命令。输出消息是发送到 CreateOrderSaga 回复通道的成功回复。让我们看看如何在测试中使用这个合同,从 Order Service 的消费端测试开始。

消费端异步请求/响应交互的合同集成测试

编写消费端异步请求/响应交互集成测试的策略与测试 REST 客户端相似。测试调用服务的信息代理,并验证其行为的两个方面。首先,它验证信息代理发送符合合同的命令消息。其次,它验证代理正确处理回复消息。

列表 10.9 展示了 KitchenServiceProxy 的消费者端集成测试,Order Service 使用 KitchenServiceProxy 调用 Kitchen Service。每个测试使用 KitchenServiceProxy 发送一个命令消息,并验证它返回预期的结果。它使用 Spring Cloud Contract 配置 Kitchen Service 的消息模拟,以找到与命令消息匹配的合约并以其输出消息作为回复。测试使用内存消息以提高简单性和速度。

列表 10.9. Order Service 的消费者端合约集成测试
@RunWith(SpringRunner.class)
@SpringBootTest(classes=
     KitchenServiceProxyIntegrationTest.TestConfiguration.class,
        webEnvironment= SpringBootTest.WebEnvironment.NONE)
@AutoConfigureStubRunner(ids =                                               *1*
         {"net.chrisrichardson.ftgo.contracts:ftgo-kitchen-service-contracts"},
        workOffline = false)
@DirtiesContext
public class KitchenServiceProxyIntegrationTest {

  @Configuration
  @EnableAutoConfiguration
  @Import({TramCommandProducerConfiguration.class,
          TramInMemoryConfiguration.class,
            EventuateContractVerifierConfiguration.class})
  public static class TestConfiguration { ... }

  @Autowired
  private SagaMessagingTestHelper sagaMessagingTestHelper;

  @Autowired
  private  KitchenServiceProxy kitchenServiceProxy;

  @Test
  public void shouldSuccessfullyCreateTicket() {
    CreateTicket command = new CreateTicket(AJANTA_ID,
          OrderDetailsMother.ORDER_ID,
      new TicketDetails(Collections.singletonList(
        new TicketLineItem(CHICKEN_VINDALOO_MENU_ITEM_ID,
                           CHICKEN_VINDALOO,
                           CHICKEN_VINDALOO_QUANTITY))));

    String sagaType = CreateOrderSaga.class.getName();

    CreateTicketReply reply =
       sagaMessagingTestHelper                                               *2*
             .sendAndReceiveCommand(kitchenServiceProxy.create,
                                   command,
                                    CreateTicketReply.class, sagaType);

    assertEquals(new CreateTicketReply(OrderDetailsMother.ORDER_ID), reply); *3*

  }

}
  • 1 配置模拟 Kitchen Service 以响应消息。

  • 2 发送命令并等待回复。

  • 3 验证回复。

shouldSuccessfullyCreateTicket() 测试方法发送一个 CreateTicket 命令消息,并验证回复包含预期的数据。它使用 SagaMessagingTestHelper,这是一个同步发送和接收消息的测试辅助类。

现在我们来看看如何编写提供者端集成测试。

编写异步请求/响应交互的提供者端、消费者驱动的合约测试

提供者端集成测试必须验证提供者通过发送正确的回复来处理命令消息。Spring Cloud Contract 生成测试类,每个合约都有一个测试方法。每个测试方法发送合约的输入消息,并验证回复与合约的输出消息匹配。

Kitchen Service 的提供者端集成测试测试 KitchenServiceCommandHandlerKitchenServiceCommandHandler 类通过调用 KitchenService 来处理消息。以下列表显示了 AbstractKitchenServiceConsumerContractTest 类,它是 Spring Cloud Contract 生成的测试的基础类。它创建了一个注入模拟 KitchenServiceKitchenServiceCommandHandler

列表 10.10. Kitchen Service 提供者端、消费者驱动的合约测试的超级类
@RunWith(SpringRunner.class)
@SpringBootTest(classes =
     AbstractKitchenServiceConsumerContractTest.TestConfiguration.class,
                webEnvironment = SpringBootTest.WebEnvironment.NONE)
@AutoConfigureMessageVerifier
public abstract class AbstractKitchenServiceConsumerContractTest {

  @Configuration
  @Import(RestaurantMessageHandlersConfiguration.class)
  public static class TestConfiguration {
    ...
    @Bean
    public KitchenService kitchenService() {            *1*
       return mock(KitchenService.class);
    }
  }

  @Autowired
  private KitchenService kitchenService;

  @Before
  public void setup() {
     reset(kitchenService);
     when(kitchenService
           .createTicket(eq(1L), eq(1L),                *2*
                           any(TicketDetails.class)))
           .thenReturn(new Ticket(1L, 1L,
                        new TicketDetails(Collections.emptyList())));
  }

}
  • 1 用模拟覆盖 kitchenService @Bean 的定义

  • 2 配置模拟以返回与合约输出消息匹配的值

KitchenServiceCommandHandler 使用从合约输入消息派生的参数调用 KitchenService,并创建一个从返回值派生的回复消息。测试类的 setup() 方法配置模拟的 KitchenService 以返回与合约输出消息匹配的值

集成测试和单元测试验证服务各个部分的行为。集成测试验证服务能否与其客户端和依赖项通信。单元测试验证服务的逻辑是否正确。这两种类型的测试都不会运行整个服务。为了验证服务作为一个整体是否工作,我们将向上移动金字塔,看看如何编写组件测试。

10.2. 开发组件测试

到目前为止,我们已经探讨了如何测试单个类和类簇。但想象一下,我们现在想要验证Order Service是否按预期工作。换句话说,我们想要编写服务的验收测试,将其视为一个黑盒,并通过其 API 验证其行为。一种方法就是编写本质上属于端到端测试,并部署Order Service及其所有传递依赖项。正如你现在应该知道的,这是一种缓慢、脆弱且昂贵的测试服务的方法。

模式:服务组件测试

在隔离状态下测试服务。参见microservices.io/patterns/testing/service-component-test.html

为服务编写验收测试的一个更好的方法是使用组件测试。如图 10.6 所示,组件测试位于集成测试和端到端测试之间。组件测试在隔离状态下验证服务的行为。它用模拟行为的存根替换了服务的依赖项。甚至可能使用内存中的基础设施服务版本,如数据库。因此,组件测试编写起来更容易,运行速度更快。

图 10.6.组件测试在隔离状态下测试服务。它通常使用服务依赖项的存根。

我首先简要描述如何使用名为 Gherkin 的测试 DSL 编写服务的验收测试,例如Order Service。之后,我将讨论各种组件测试设计问题。然后,我将展示如何为Order Service编写验收测试。

让我们看看如何使用 Gherkin 编写验收测试。

10.2.1. 定义验收测试

验收测试是面向业务的软件组件测试。它们从组件客户端的角度描述了期望的外部可见行为,而不是从内部实现的角度。这些测试是从用户故事或用例中派生出来的。例如,Order Service的一个关键故事是Place Order故事:

As a consumer of the Order Service
I should be able to place an order

我们可以将这个故事扩展到以下场景:

Given a valid consumer
Given using a valid credit card
Given the restaurant is accepting orders
When I place an order for Chicken Vindaloo at Ajanta
Then the order should be APPROVED
And an OrderAuthorized event should be published

这个场景描述了Order Service的 API 方面的期望行为。

每个场景定义一个验收测试。给定对应于测试的设置阶段,对应于执行阶段,而然后并且对应于验证阶段。稍后,你将看到对这个场景的测试,它执行以下操作:

  1. 通过调用POST /orders端点创建Order

  2. 通过调用GET /orders/{orderId}端点验证Order的状态

  3. 通过订阅适当的消息通道验证Order Service是否发布了OrderAuthorized事件

我们可以将每个场景翻译成 Java 代码。然而,一个更简单的方法是使用 Gherkin 之类的 DSL 编写验收测试。

10.2.2. 使用 Gherkin 编写验收测试

在 Java 中编写验收测试具有挑战性。存在场景和 Java 测试之间发生分歧的风险。此外,高级场景和由低级实现细节组成的 Java 测试之间存在脱节。还有风险是一个场景缺乏精确性或含糊不清,无法转换为 Java 代码。一个更好的方法是消除手动翻译步骤,并编写可执行的场景。

Gherkin 是一个用于编写可执行规范的 DSL。当使用 Gherkin 时,你使用类似英语的场景定义你的验收测试,例如前面所示。然后,使用 Gherkin 的测试自动化框架 Cucumber 执行规范。Gherkin 和 Cucumber 消除了手动将场景转换为可运行代码的需求。

对于 Order Service 等服务,Gherkin 规范由一系列特性组成。每个 特性 都由一组场景描述,例如你之前看到的。一个场景具有给定-当-然后的结构。给定 是前提条件, 是发生的行为或事件,而 然后/ 是预期的结果。

例如,Order Service 的预期行为由几个特性定义,包括 Place OrderCancel OrderRevise Order。列表 10.11 是 Place Order 特性的摘录。这个特性由几个元素组成:

  • 名称 对于这个特性,名称是 Place Order

  • 规范摘要 这描述了特性的存在原因。对于这个特性,规范摘要就是用户故事。

  • 场景 Order authorizedOrder rejected due to expired credit card

列表 10.11. Place Order 特性和其一些场景的 Gherkin 定义
Feature: Place Order

  As a consumer of the Order Service
  I should be able to place an order

  Scenario: Order authorized
    Given a valid consumer
    Given using a valid credit card
    Given the restaurant is accepting orders
    When I place an order for Chicken Vindaloo at Ajanta
    Then the order should be APPROVED
    And an OrderAuthorized event should be published

  Scenario: Order rejected due to expired credit card
    Given a valid consumer
    Given using an expired credit card
    Given the restaurant is accepting orders
    When I place an order for Chicken Vindaloo at Ajanta
    Then the order should be REJECTED
    And an OrderRejected event should be published

...

在这两个场景中,消费者试图下订单。在第一个场景中,他们成功了。在第二个场景中,由于消费者的信用卡已过期,订单被拒绝。有关 Gherkin 的更多信息,请参阅 Kamil Nicieja 所著的《编写优秀的规范:使用示例规范和 Gherkin》(Manning,2017)。

使用 Cucumber 执行 Gherkin 规范

Cucumber 是一个自动化测试框架,用于执行用 Gherkin 编写的测试。它支持多种语言,包括 Java。当使用 Cucumber 进行 Java 测试时,你将编写一个步骤定义类,例如 列表 10.12 中所示。一个 步骤定义类 包含定义每个给定-然后-当步骤含义的方法。每个步骤定义方法都带有 @Given@When@Then@And 之一的注解。每个注解都有一个 value 元素,它是一个正则表达式,Cucumber 会将其与步骤进行匹配。

列表 10.12. Java 步骤定义类使 Gherkin 场景可执行。
public class StepDefinitions ...  {

  ...

  @Given("A valid consumer")
  public void useConsumer() { ... }

  @Given("using a(.?) (.*) credit card")
  public void useCreditCard(String ignore, String creditCard) { ... }

  @When("I place an order for Chicken Vindaloo at Ajanta")
  public void placeOrder() { ... }

  @Then("the order should be (.*)")
  public void theOrderShouldBe(String desiredOrderState) { ... }

  @And("an (.*) event should be published")
  public void verifyEventPublished(String expectedEventClass)  { ... }

}

每种方法类型都是测试特定阶段的一部分:

  • @Given—设置阶段

  • @When—执行阶段

  • @Then @And—验证阶段

在第 10.2.4 节的后面,当我更详细地描述这个类时,你会看到许多这些方法都向Order Service发起 REST 调用。例如,placeOrder()方法通过调用POST /orders REST 端点来创建OrdertheOrderShouldBe()方法通过调用GET /orders/{orderId}来验证订单的状态。

但在深入探讨如何编写步骤类之前,让我们先探讨一些组件测试的设计问题。

10.2.3. 设计组件测试

假设你正在实现Order Service的组件测试。第 10.2.2 节展示了如何使用 Gherkin 指定期望的行为,并使用 Cucumber 执行它。但在组件测试可以执行 Gherkin 场景之前,它必须首先运行Order Service并设置服务的依赖项。你需要单独测试Order Service,因此组件测试必须为包括Kitchen Service在内的几个服务配置存根。它还需要设置数据库和消息基础设施。有几个不同的选项,这些选项在现实性、速度和简单性之间进行权衡。

在制品组件测试

一个选择是编写在制品组件测试。一种在制品组件测试在内存存根和模拟依赖项的情况下运行服务。例如,你可以使用 Spring Boot 测试框架编写一个基于 Spring Boot 服务的组件测试。一个带有@SpringBootTest注解的测试类在测试相同的 JVM 中运行服务。它使用依赖注入来配置服务以使用模拟和存根。例如,对Order Service的测试会配置它使用内存 JDBC 数据库,如 H2、HSQLDB 或 Derby,以及 Eventuate Tram 的内存存根。在制品测试编写起来更简单,速度更快,但缺点是它没有测试可部署的服务。

离进程组件测试

一种更现实的方法是将服务打包成生产就绪格式,并作为一个单独的进程运行。例如,第十二章解释说,将服务打包成 Docker 容器镜像的做法越来越普遍。一种离进程组件测试使用真实的底层服务,例如数据库和消息代理,但对于任何应用程序服务的依赖项则使用存根。例如,对FTGO Order Service的离进程组件测试将使用 MySQL 和 Apache Kafka,以及Consumer ServiceAccounting Service等服务用的存根。因为Order Service通过消息与这些服务交互,这些存根将消费 Apache Kafka 中的消息并返回回复消息。

进程外组件测试的一个关键优势是它提高了测试覆盖率,因为被测试的内容与部署的内容非常接近。缺点是这种类型的测试编写更复杂,执行速度更慢,并且可能比进程内组件测试更脆弱。您还必须弄清楚如何存根应用程序服务。让我们看看如何做到这一点。

如何在进程外组件测试中存根服务

被测试的服务通常使用涉及发送响应的交互样式调用依赖项。例如,Order Service使用异步请求/响应并向各种服务发送命令消息。API Gateway使用 HTTP,这是一种请求/响应交互样式。进程外测试必须为这些类型的依赖项配置存根,这些存根处理请求并发送回复。

一种选择是使用我们在第 10.1 节中讨论集成测试时查看的 Spring Cloud Contract。我们可以编写配置组件测试存根的合同。不过,有一点需要考虑的是,与用于集成的合同不同,这些合同可能只会被组件测试使用。

使用 Spring Cloud Contract 进行组件测试的另一个缺点是,由于其重点是消费者合同测试,因此它采取了一种相对重量级的策略。包含合同的 JAR 文件必须在 Maven 仓库中部署,而不仅仅是放在类路径上。处理涉及动态生成值的交互也是一项挑战。因此,一个更简单的选择是在测试本身内部配置存根。

例如,一个测试可以使用 WireMock 存根 DSL 配置 HTTP 存根。同样,对使用 Eventuate Tram 消息传递的服务进行的测试可以配置消息存根。在本节的后面部分,我将展示一个易于使用的 Java 库,它可以完成这项工作。

现在我们已经了解了如何设计组件测试,让我们考虑如何为 FTGO Order Service编写组件测试。

10.2.4. 为 FTGO Order Service编写组件测试

如您在本节前面所见,实现组件测试有几种不同的方法。本节描述了使用进程外策略来测试作为 Docker 容器运行的服务Order Service的组件测试。您将看到测试如何使用 Gradle 插件来启动和停止 Docker 容器。我还将讨论如何使用 Cucumber 来执行定义Order Service期望行为的基于 Gherkin 的场景。

图 10.7 显示了Order Service组件测试的设计。OrderServiceComponentTest是运行 Cucumber 的测试类:

@RunWith(Cucumber.class)
@CucumberOptions(features = "src/component-test/resources/features")
public class OrderServiceComponentTest {
}
图 10.7. Order Service 组件测试使用 Cucumber 测试框架执行使用 Gherkin 接受测试 DSL 编写的测试场景。测试使用 Docker 运行 Order Service 及其基础设施服务,例如 Apache Kafka 和 MySQL。

它有一个 @CucumberOptions 注解,指定了 Gherkin 特性文件的查找位置。它还带有 @RunWith(Cucumber.class) 注解,告诉 JUNIT 使用 Cucumber 测试运行器。但与典型的基于 JUNIT 的测试类不同,它没有测试方法。相反,它通过读取 Gherkin 特性来定义测试,并使用 OrderServiceComponentTestStepDefinitions 类使它们可执行。

使用 Cucumber 与 Spring Boot 测试框架需要稍微不寻常的结构。尽管它不是一个测试类,但 OrderServiceComponentTestStepDefinitions 仍然被 @ContextConfiguration 注解,这是 Spring 测试框架的一部分。它创建 Spring ApplicationContext,该上下文定义了各种 Spring 组件,包括消息存根。让我们看看步骤定义的详细情况。

OrderServiceComponentTestStepDefinitions

OrderServiceComponentTestStepDefinitions 类是测试的核心。该类定义了 Order Service 组件测试中每个步骤的含义。下面的列表展示了 usingCreditCard() 方法,该方法定义了 Given using ... credit card 步骤的含义。

列表 10.13. @GivenuseCreditCard() 方法定义了 Given using ... credit card 步骤的含义。
@ContextConfiguration(classes =
     OrderServiceComponentTestStepDefinitions.TestConfiguration.class)
public class OrderServiceComponentTestStepDefinitions {

  ...

  @Autowired
  protected SagaParticipantStubManager sagaParticipantStubManager;

  @Given("using a(.?) (.*) credit card")
  public void useCreditCard(String ignore, String creditCard) {
    if (creditCard.equals("valid"))
      sagaParticipantStubManager                                *1*
            .forChannel("accountingService")
            .when(AuthorizeCommand.class).replyWithSuccess();
    else if (creditCard.equals("invalid"))
      sagaParticipantStubManager                                *2*
               .forChannel("accountingService")
              .when(AuthorizeCommand.class).replyWithFailure();
    else
      fail("Don't know what to do with this credit card");
  }
  • 1 发送成功回复。

  • 2 发送失败回复。

此方法使用 SagaParticipantStubManager 类,这是一个测试辅助类,用于配置 saga 参与者的存根。useCreditCard() 方法使用它来配置 Accounting Service 存根以根据指定的信用卡回复成功或失败消息。

下面的列表展示了 placeOrder() 方法,该方法定义了 When I place an order for Chicken Vindaloo at Ajanta 步骤。它调用 Order Service REST API 创建 Order 并将响应保存以供后续步骤验证。

列表 10.14. placeOrder() 方法定义了 When I place an order for Chicken Vindaloo at Ajanta 步骤。
@ContextConfiguration(classes =
     OrderServiceComponentTestStepDefinitions.TestConfiguration.class)
public class OrderServiceComponentTestStepDefinitions {

  private int port = 8082;
  private String host = System.getenv("DOCKER_HOST_IP");

  protected String baseUrl(String path) {
    return String.format("http://%s:%s%s", host, port, path);
  }

  private Response response;

  @When("I place an order for Chicken Vindaloo at Ajanta")
  public void placeOrder() {

    response = given().                                               *1*
            body(new CreateOrderRequest(consumerId,
                    RestaurantMother.AJANTA_ID, Collections.singletonList(
                        new CreateOrderRequest.LineItem(
                           RestaurantMother.CHICKEN_VINDALOO_MENU_ITEM_ID,
                          OrderDetailsMother.CHICKEN_VINDALOO_QUANTITY)))).
            contentType("application/json").
            when().
            post(baseUrl("/orders"));
  }
  • 1 调用 Order Service REST API 创建订单

baseUrl() 辅助方法返回订单服务的 URL。

列表 10.15 展示了 theOrderShouldBe() 方法,该方法定义了 Then the order should be ... 步骤的含义。它验证 Order 已成功创建并且处于预期的状态。

列表 10.15. @ThentheOrderShouldBe() 方法验证 HTTP 请求是否成功。
@ContextConfiguration(classes =
     OrderServiceComponentTestStepDefinitions.TestConfiguration.class)
public class OrderServiceComponentTestStepDefinitions {

  @Then("the order should be (.*)")
  public void theOrderShouldBe(String desiredOrderState) {

    Integer orderId =                                     *1*
             this.response. then(). statusCode(200).
                    extract(). path("orderId");

    assertNotNull(orderId);

    eventually(() -> {
      String state = given().
              when().
              get(baseUrl("/orders/" + orderId)).
              then().
              statusCode(200)
              .extract().
                      path("state");
      assertEquals(desiredOrderState, state);             *2*
     });

  }
]
  • 1 验证订单是否成功创建。

  • 2 验证订单状态。

预期状态的断言被封装在 eventually() 调用中,该调用会重复执行断言。

下面的列表显示了 verifyEventPublished() 方法,它定义了 And an ... event should be published 步骤。它验证预期的领域事件是否已发布。

列表 10.16. Order Service 组件测试的 Cucumber 步骤定义类
@ContextConfiguration(classes =
     OrderServiceComponentTestStepDefinitions.TestConfiguration.class)
public class OrderServiceComponentTestStepDefinitions {

  @Autowired
  protected MessageTracker messageTracker;

  @And("an (.*) event should be published")
  public void verifyEventPublished(String expectedEventClass) throws ClassNot
     FoundException {
    messageTracker.assertDomainEventPublished("net.chrisrichardson.ftgo.order
     service.domain.Order",
            (Class<DomainEvent>)Class.forName("net.chrisrichardson.ftgo.order
     service.domain." + expectedEventClass));
  }
  ....
}

verifyEventPublished() 方法使用 MessageTracker 类,这是一个测试辅助类,用于记录测试期间已发布的事件。这个类和 SagaParticipantStubManager 都是由 TestConfiguration@Configuration 类实例化的。

现在我们已经查看步骤定义,让我们看看如何运行组件测试。

运行组件测试

由于这些测试相对较慢,我们不希望将它们作为 ./gradlew test 的一部分运行。相反,我们将测试代码放在一个单独的 src/component-test/java 目录中,并使用 ./gradlew componentTest 运行它们。查看 ftgo-order-service/build.gradle 文件以了解 Gradle 配置。

测试使用 Docker 运行 Order Service 及其依赖项。如第十二章所述,Docker 容器是一种轻量级的操作系统虚拟化机制,允许你在隔离的沙盒中部署服务实例。Docker Compose 是一个极其有用的工具,可以定义一组容器,并将它们作为一个单元启动和停止。FTGO 应用在根目录中有一个 docker-compose 文件,定义了所有服务以及基础设施服务的容器。

我们可以使用 Gradle Docker Compose 插件在执行测试之前运行容器,并在测试完成后停止容器:

apply plugin: 'docker-compose'

dockerCompose.isRequiredBy(componentTest)
componentTest.dependsOn(assemble)

dockerCompose {
   startedServices = [ 'ftgo-order-service']
}

前面的 Gradle 配置片段做了两件事。首先,它配置 Gradle Docker Compose 插件在组件测试之前运行,并启动 Order Service 以及它配置依赖的基础设施服务。其次,它配置 componentTest 依赖于 assemble,以确保 Docker 镜像所需的 JAR 文件首先构建。有了这些配置,我们可以使用以下命令运行这些组件测试:

./gradlew  :ftgo-order-service:componentTest

这些命令需要几分钟,执行以下操作:

  1. 构建 Order Service

  2. 运行服务及其基础设施服务。

  3. 运行测试。

  4. 停止运行中的服务。

现在我们已经了解了如何单独测试服务,我们将看到如何测试整个应用程序。

10.3. 编写端到端测试

组件测试分别测试每个服务。然而,端到端测试则测试整个应用程序。如图 10.8 所示,端到端测试位于测试金字塔的顶部。这是因为这些类型的测试——现在跟我一起说——速度慢、脆弱且开发耗时。

图 10.8. 端到端测试位于测试金字塔的顶部。它们速度慢、脆弱且开发耗时。你应该尽量减少端到端测试的数量。

端到端测试涉及许多移动部件。您必须部署多个服务和它们的支持性基础设施服务。因此,端到端测试运行缓慢。此外,如果您的测试需要部署大量服务,那么其中一个服务部署失败的可能性很大,这使得测试不可靠。因此,您应该尽量减少端到端测试的数量。

10.3.1. 设计端到端测试

正如我解释的,最好尽可能少地编写这些测试。一个好的策略是编写用户旅程测试。用户旅程测试对应于用户在系统中的旅程。例如,与其分别测试创建订单、修改订单和取消订单,您可以编写一个执行所有这三个操作的单一测试。这种方法显著减少了您必须编写的测试数量,并缩短了测试执行时间。

10.3.2. 编写端到端测试

端到端测试,就像第 10.2 节中提到的验收测试一样,是面向业务的测试。使用业务人员理解的高级 DSL 编写它们是有意义的。例如,您可以使用 Gherkin 编写端到端测试,并使用 Cucumber 执行它们。以下列表显示了一个此类测试的示例。它与之前我们查看的验收测试类似。主要区别是,这个测试有多个动作,而不仅仅是单个 Then

列表 10.17. 基于 Gherkin 的用户旅程规范
Feature: Place Revise and Cancel

  As a consumer of the Order Service
  I should be able to place, revise, and cancel an order

  Scenario: Order created, revised, and cancelled
    Given a valid consumer
    Given using a valid credit card
    Given the restaurant is accepting orders
    When I place an order for Chicken Vindaloo at Ajanta          *1*
     Then the order should be APPROVED
    Then the order total should be 16.33
    And when I revise the order by adding 2 vegetable samosas     *2*
     Then the order total should be 20.97
    And when I cancel the order
    Then the order should be CANCELLED                            *3*
  • 1 创建订单。

  • 2 修改订单。

  • 3 取消订单。

这个场景包括下单、修改订单,然后取消订单。让我们看看如何运行它。

10.3.3. 运行端到端测试

端到端测试必须运行整个应用程序,包括任何所需的必需基础设施服务。正如您在第 10.2 节中看到的,Gradle Docker Compose 插件提供了一个方便的方式来执行此操作。然而,与运行单个应用程序服务不同,Docker Compose 文件运行应用程序的所有服务。

现在我们已经探讨了设计和编写端到端测试的不同方面,让我们来看一个端到端测试的例子。

ftgo-end-to-end-test 模块实现了 FTGO 应用程序的端到端测试。端到端测试的实现与之前在第 10.2 节中讨论的组件测试实现相当相似。这些测试使用 Gherkin 编写,并使用 Cucumber 执行。Gradle Docker Compose 插件在测试运行之前启动容器。启动容器并运行测试大约需要四到五分钟。

这可能看起来时间不长,但这是一个相对简单的应用程序,只有几个容器和测试。想象一下,如果有数百个容器和更多的测试,测试可能需要相当长的时间。因此,最好专注于编写金字塔底部的测试。

摘要

  • 使用合同,即示例消息,来驱动服务之间交互的测试。而不是编写运行缓慢的测试,这些测试同时运行服务和它们的传递依赖项,编写测试以验证两个服务的适配器都符合合同。

  • 编写组件测试以通过其 API 验证服务的功能。你应该通过在隔离状态下测试服务,使用存根来测试其依赖项,来简化并加快组件测试。

  • 编写用户旅程测试以最小化端到端测试的数量,因为端到端测试速度慢、脆弱且耗时。用户旅程测试模拟用户在应用程序中的旅程,并验证应用程序功能较大块的高级行为。由于测试数量较少,每个测试的额外开销,如测试设置,被最小化,从而加快了测试速度。

第十一章. 开发生产就绪服务

本章涵盖

  • 开发安全服务

  • 应用外部化配置模式

  • 应用可观察性模式:

    • 健康检查 API

    • 日志聚合

    • 分布式跟踪

    • 异常跟踪

    • 应用程序度量

    • 审计日志

  • 通过应用微服务底盘模式简化服务的开发

玛丽和她的团队认为他们已经掌握了服务分解、服务间通信、事务管理、查询和业务逻辑设计以及测试。他们有信心能够开发出满足其功能要求的服务。但是,为了使服务准备好部署到生产环境中,他们需要确保它还会满足三个至关重要的质量属性:安全性、可配置性和可观察性。

第一个质量属性是应用安全。开发安全的应用程序是至关重要的,除非你希望你的公司在数据泄露的新闻头条上。幸运的是,微服务架构中的大多数安全方面与单体应用程序没有太大区别。FTGO 团队知道,他们在过去几年中开发单体应用程序所学到的大部分知识也适用于微服务。但是,微服务架构迫使你以不同的方式实现应用级安全的一些方面。例如,你需要实现一种机制,将用户的身份从一个服务传递到另一个服务。

你必须解决的第二个质量属性是服务可配置性。服务通常使用一个或多个外部服务,例如消息代理和数据库。每个外部服务的网络位置和凭证通常取决于服务运行的环境。你不能将配置属性硬编码到服务中。相反,你必须使用一个外部化配置机制,该机制在运行时为服务提供配置属性。

第三个质量属性是可观察性。FTGO 团队已经为现有应用程序实现了监控和日志记录。但是,微服务架构是一个分布式系统,这带来了一些额外的挑战。每个请求都由 API 网关和至少一个服务处理。想象一下,例如,你正在尝试确定六个服务中的哪一个导致了延迟问题。或者想象一下,当日志条目分散在五个不同的服务中时,尝试理解请求是如何被处理的。为了使理解应用程序的行为和解决问题更容易,你必须实现几个可观察性模式。

我以描述如何在微服务架构中实现安全性开始本章。接下来,我讨论如何设计可配置的服务。我介绍了几种不同的服务配置机制。然后,我谈论如何通过使用可观察性模式使服务更容易理解和调试。我通过展示如何在微服务底盘框架之上开发服务来简化这些和其他问题的实现。

让我们先看看安全性。

11.1. 开发安全服务

网络安全已成为每个组织的重大问题。几乎每天都有关于黑客如何窃取公司数据的头条新闻。为了开发安全的软件并避免成为头条,组织需要解决各种安全问题,包括硬件的物理安全、传输和静止状态下的数据加密、身份验证和授权,以及修补软件漏洞的政策。大多数这些问题无论你使用单体架构还是微服务架构都是相同的。本节重点介绍微服务架构如何影响应用层的安全性。

应用程序开发者主要负责实现安全性的四个不同方面:

  • 身份验证—**验证尝试访问应用程序的应用或人类(即主体)的身份。例如,应用程序通常验证主体的凭据,如用户 ID 和密码或应用程序的 API 密钥和密钥。

  • 授权—**验证主体是否被允许在指定数据上执行请求的操作。应用程序通常结合使用基于角色的安全和访问控制列表(ACL)。基于角色的安全为每个用户分配一个或多个角色,授予他们调用特定操作的权限。ACL 授予用户或角色在特定业务对象或聚合上执行操作的权限。

  • 审计—**跟踪主体执行的操作,以检测安全问题、帮助客户支持和执行合规性。

  • 安全进程间通信—**理想情况下,所有服务内部和外部通信都应通过传输层安全性(TLS)进行。服务间通信甚至可能需要使用身份验证。

我在第 11.3 节中详细介绍了审计,并在第 11.4.1 节讨论服务网格时简要提到了保护服务间通信。本节重点介绍实现身份验证和授权。

我首先描述 FTGO 单体应用程序中安全实现的细节。然后,我描述在微服务架构中实现安全的挑战,以及为什么在单体架构中效果良好的技术不能用于微服务架构。之后,我将介绍如何在微服务架构中实现安全。

让我们先回顾一下单体 FTGO 应用程序如何处理安全。

11.1.1. 传统单体应用程序中的安全概述

FTGO 应用程序有几种人类用户,包括消费者、快递员和餐厅员工。他们通过基于浏览器的 Web 应用程序和移动应用程序访问应用程序。所有 FTGO 用户都必须登录才能访问应用程序。图 11.1 展示了单体 FTGO 应用程序的客户端如何进行身份验证和发出请求。

图 11.1. FTGO 应用程序的客户端首先登录以获取会话令牌,这通常是一个 cookie。客户端在其向应用程序发出的每个后续请求中都包含会话令牌。

当用户使用用户 ID 和密码登录时,客户端向 FTGO 应用程序发出包含用户凭据的 POST 请求。FTGO 应用程序验证凭据并返回会话令牌给客户端。客户端在其向 FTGO 应用程序发出的每个后续请求中都包含会话令牌。

图 11.2 展示了 FTGO 应用程序实现安全的高级视图。FTGO 应用程序是用 Java 编写的,并使用 Spring Security 框架,但我会使用适用于其他框架的通用术语来描述设计,例如 NodeJS 的 Passport。

使用安全框架

正确实现身份验证和授权具有挑战性。最好使用经过验证的安全框架。选择哪个框架取决于你的应用程序的技术堆栈。以下是一些流行的框架:

安全架构的一个关键部分是会话,它存储主体的 ID 和角色。FTGO 应用程序是一个传统的 Java EE 应用程序,因此会话是一个内存中的HttpSession。会话通过会话令牌来识别,客户端将其包含在每个请求中。它通常是一个不透明的令牌,如具有强密码学随机数的令牌。FTGO 应用程序的会话令牌是一个名为JSESSIONID的 HTTP cookie。

安全实现的其他关键部分是安全上下文,它存储有关当前请求的用户的信息。Spring Security 框架使用标准的 Java EE 方法,将安全上下文存储在静态的、线程局部变量中,这使得任何被调用以处理请求的代码都可以轻松访问。请求处理器可以调用SecurityContextHolder.getContext().getAuthentication()来获取有关当前用户的信息,例如其身份和角色。相比之下,Passport 框架将安全上下文存储为requestuser属性。

图 11.2 中显示的事件序列如下:

  1. 客户端向 FTGO 应用程序发起登录请求。

  2. 登录请求由LoginHandler处理,该处理器验证凭据,创建会话,并在会话中存储有关主体的信息。

  3. Login Handler向客户端返回一个会话令牌。

  4. 客户端在其调用操作的请求中包含会话令牌。

  5. 这些请求首先由SessionBasedSecurityInterceptor处理。拦截器通过验证会话令牌来验证每个请求,并建立安全上下文。安全上下文描述了主体及其角色。

  6. 请求处理器使用安全上下文来确定是否允许用户执行请求的操作并获取其身份。

FTGO 应用程序使用基于角色的授权。它定义了几个与不同类型用户相对应的角色,包括CONSUMERRESTAURANTCOURIERADMIN。它使用 Spring Security 的声明式安全机制来限制对 URL 和服务方法的访问,仅限于特定角色。角色也编织到业务逻辑中。例如,消费者只能访问他们的订单,而管理员可以访问所有订单。

单体 FTGO 应用所使用的安全设计只是实现安全的一种可能方式。例如,使用内存中会话的一个缺点是它要求所有特定会话的请求都路由到同一个应用实例。这一要求使得负载均衡和操作变得复杂。例如,你必须实现一个会话排空机制,在关闭应用实例之前等待所有会话过期。一种避免这些问题的替代方法是将会话存储在数据库中。

有时你可以完全消除服务器端会话。例如,许多应用程序都有 API 客户端,它们在每次请求中都提供其凭证,例如 API 密钥和密钥。因此,不需要维护服务器端会话。或者,应用程序可以将会话状态存储在会话令牌中。在本节稍后,我将描述一种使用会话令牌存储会话状态的方法。但让我们首先看看在微服务架构中实现安全的挑战。

11.1.2. 在微服务架构中实现安全

微服务架构是一种分布式架构。每个外部请求都由 API 网关和至少一个服务处理。例如,考虑第八章中讨论的getOrderDetails()查询。API 网关通过调用多个服务来处理这个查询,包括订单服务厨房服务会计服务。每个服务都必须实现一些安全方面。例如,订单服务必须只允许消费者查看他们的订单,这需要认证和授权的组合。为了在微服务架构中实现安全,我们需要确定谁负责认证用户,谁负责授权。

在微服务应用程序中实现安全的一个挑战是我们不能简单地从单体应用程序复制设计。这是因为单体应用程序安全架构的两个方面对于微服务架构来说是不切实际的:

  • 内存安全上下文—**使用内存安全上下文,如线程局部,来传递用户身份。由于服务不能共享内存,因此它们不能使用内存安全上下文,如线程局部,来传递用户身份。在微服务架构中,我们需要一种不同的机制来从一个服务传递用户身份到另一个服务。

  • 集中式会话—**由于内存安全上下文没有意义,内存会话也没有意义。理论上,多个服务可以访问基于数据库的会话,但这会违反松耦合原则。在微服务架构中,我们需要不同的会话机制。

让我们通过看看如何处理认证来开始我们对微服务架构中安全的探索。

在 API 网关中处理身份验证

处理身份验证有几种不同的方法。一种选择是让各个服务单独对用户进行身份验证。这种方法的缺点是它允许未经身份验证的请求进入内部网络。它依赖于每个开发团队在其所有服务中正确实现安全性。因此,存在应用程序包含安全漏洞的重大风险。

在服务中实现身份验证的另一个问题是不同的客户端以不同的方式身份验证。纯 API 客户端在每个请求中提供凭证,例如使用基本身份验证。其他客户端可能首先登录,然后在每个请求中提供会话令牌。我们希望避免要求服务处理各种不同的身份验证机制。

一个更好的方法是让 API 网关在将请求转发到服务之前先对请求进行身份验证。在 API 网关中集中处理 API 身份验证的优势在于,只有一个地方需要正确设置。因此,安全漏洞的风险大大降低。另一个好处是,只有 API 网关需要处理各种不同的身份验证机制。它将这种复杂性隐藏在服务之外。

图 11.3 展示了这种方法是如何工作的。客户端与 API 网关进行身份验证。API 客户端在每个请求中包含凭证。基于登录的客户端将用户的凭证POST到 API 网关的认证,并接收一个会话令牌。一旦 API 网关验证了请求,它就会调用一个或多个服务。

模式:访问令牌

API 网关将包含用户信息(如身份和角色)的令牌传递给它调用的服务。请参阅microservices.io/patterns/security/access-token.html

图 11.3。API 网关对客户端的请求进行身份验证,并在对服务发出的请求中包含安全令牌。服务使用令牌来获取有关主体的信息。API 网关还可以将安全令牌用作会话令牌。

图片

由 API 网关调用的服务需要知道发起请求的主体。它还必须验证请求是否已通过身份验证。解决方案是 API 网关在每个服务请求中包含一个令牌。服务使用该令牌来验证请求并获取有关主体的信息。API 网关还可能将相同的令牌提供给面向会话的客户端,供其作为会话令牌使用。

API 客户端的事件序列如下:

  1. 客户端发出包含凭证的请求。

  2. API 网关验证凭证,创建安全令牌,并将其传递给服务或多个服务。

基于登录的客户端的事件序列如下:

  1. 客户端发起一个包含凭证的登录请求。

  2. API 网关返回一个安全令牌。

  3. 客户端在调用操作的请求中包含安全令牌。

  4. API 网关验证安全令牌并将其转发到服务或服务。

在本章稍后,我将描述如何实现令牌,但首先让我们看看安全的另一个主要方面:授权。

处理授权

客户端凭证的验证很重要,但不足以。应用程序还必须实现一个授权机制,以验证客户端是否有权执行请求的操作。例如,在 FTGO 应用程序中,getOrderDetails()查询只能由放置Order(实例化安全的一个例子)的消费者调用,以及帮助消费者的客户服务代表。

实现授权的一个地方是 API 网关。例如,它可以限制对GET /orders/{orderId}的访问,仅限于消费者和客户服务代表。如果用户不允许访问特定的路径,API 网关可以在将其转发到服务之前拒绝该请求。与身份验证一样,在 API 网关中集中授权可以降低安全漏洞的风险。您可以使用安全框架,如 Spring Security,在 API 网关中实现授权。

在 API 网关中实现授权的一个缺点是,它可能会将 API 网关与服务耦合,需要它们同步更新。更重要的是,API 网关通常只能实现基于角色的对 URL 路径的访问控制。对于 API 网关来说,实现控制对单个域对象的访问的 ACL(访问控制列表)通常是不切实际的,因为这需要详细了解服务的域逻辑。

实现授权的另一个地方是在服务中。服务可以为 URL 和服务方法实现基于角色的授权。它还可以实现 ACL 来管理对聚合的访问。例如,Order Service可以实现基于角色和基于 ACL 的授权机制来控制对订单的访问。FTGO 应用程序中的其他服务实现类似的授权逻辑。

使用 JWT 传递用户身份和角色

在微服务架构中实现安全时,您需要决定 API 网关应该使用哪种类型的令牌将用户信息传递给服务。有几种类型的令牌可供选择。一个选项是使用不透明令牌,通常是 UUID。不透明令牌的缺点是它们会降低性能和可用性,并增加延迟。这是因为令牌的接收者必须向安全服务发出同步 RPC 调用以验证令牌并检索用户信息。

一种替代方法,它消除了对安全服务的调用,是使用包含有关用户信息的透明令牌。透明令牌的一个流行标准是 JSON Web Token(JWT)。JWT 是安全表示两个当事人之间声明的标准方式,例如用户身份和角色。JWT 有一个有效载荷,它是一个包含有关用户信息的 JSON 对象,例如他们的身份和角色,以及其他元数据,例如过期日期。它使用只有 JWT 的创建者(如 API 网关)和 JWT 的接收者(如服务)所知的秘密进行签名。秘密确保恶意第三方无法伪造或篡改 JWT。

JWT 的一个问题是,由于令牌是自包含的,因此它是不可撤销的。按照设计,服务将在验证 JWT 签名和过期日期后执行请求操作。因此,没有实际的方法可以撤销落入恶意第三方手中的单个 JWT。解决方案是颁发具有短过期时间的 JWT,因为这限制了恶意方可能做的事情。然而,短期 JWT 的一个缺点是,应用程序必须以某种方式不断重新颁发 JWT 以保持会话活跃。幸运的是,这是许多由名为 OAuth 2.0 的安全标准解决的协议之一。让我们看看它是如何工作的。

在微服务架构中使用 OAuth 2.0

假设您想为 FTGO 应用程序实现一个用户服务,该服务管理包含用户信息(如凭证和角色)的用户数据库。API 网关调用用户服务以验证客户端请求并获取 JWT。您可以为用户服务API 进行设计并使用您喜欢的 Web 框架实现它。但这是一种通用的功能,并不特定于 FTGO 应用程序——开发此类服务不会是开发资源的有效利用。

幸运的是,您不需要开发这种安全基础设施。您可以使用现成的服务或框架,这些服务或框架实现了名为 OAuth 2.0 的标准。OAuth 2.0 是一种授权协议,最初设计用于允许公共云服务的用户,例如 GitHub 或 Google,在不泄露其密码的情况下,授予第三方应用程序访问其信息的权限。例如,OAuth 2.0 是使您能够安全地授予第三方基于云的持续集成(CI)服务访问您 GitHub 存储库的机制。

尽管 OAuth 2.0 的原始重点是授权访问公共云服务,但您也可以在您的应用程序中使用它进行身份验证和授权。让我们快速了解一下微服务架构可能如何使用 OAuth 2.0。

关于 OAuth 2.0

OAuth 2.0 是一个复杂的话题。在本章中,我只能提供一个简要概述,并描述它如何在微服务架构中使用。有关 OAuth 2.0 的更多信息,请参阅 Aaron Parecki 编写的在线书籍 OAuth 2.0 Servers (www.oauth.com)。Spring Microservices in Action (Manning, 2017) 的第七章 (livebook.manning.com/#!/book/spring-microservices-in-action/chapter-7/) 也涵盖了这一主题。

OAuth 2.0 的关键概念如下:

  • 授权服务器 提供用于验证用户和获取访问令牌和刷新令牌的 API。Spring OAuth 是构建 OAuth 2.0 授权服务器框架的一个很好的例子。

  • 访问令牌 一种授予对 资源服务器 访问权限的令牌。访问令牌的格式取决于实现。但某些实现,例如 Spring OAuth,使用 JWT。

  • 刷新令牌 一个长期有效但可撤销的令牌,客户端使用它来获取新的 访问令牌

  • 资源服务器 使用访问令牌来授权访问的服务。在微服务架构中,服务是资源服务器。

  • 客户端 想要访问 资源服务器 的客户端。在微服务架构中,API 网关 是 OAuth 2.0 客户端。

在本节后面,我将描述如何支持基于登录的客户端。但首先,让我们谈谈如何验证 API 客户端。

图 11.4 展示了 API 网关如何验证来自 API 客户端的请求。API 网关通过向 OAuth 2.0 授权服务器发送请求来验证 API 客户端,该服务器返回一个访问令牌。然后,API 网关向服务发送包含访问令牌的一个或多个请求。

图 11.4. API 网关通过向 OAuth 2.0 认证服务器发送密码授权请求来验证 API 客户端。服务器返回一个访问令牌,API 网关将其传递给服务。服务验证令牌的签名并提取有关用户的信息,包括他们的身份和角色。

图 11.4 中所示的事件序列如下:

  1. 客户端通过基本身份验证提供其凭据来发送请求。

  2. API 网关向 OAuth 2.0 认证服务器发送 OAuth 2.0 密码授权请求 (www.oauth.com/oauth2-servers/access-tokens/password-grant/)。

  3. 认证服务器验证 API 客户端的凭据,并返回一个访问令牌和一个刷新令牌。

  4. API 网关将其请求中包含的访问令牌传递给服务。服务验证访问令牌并使用它来授权请求。

基于 OAuth 2.0 的 API 网关可以通过使用 OAuth 2.0 访问令牌作为会话令牌来验证面向会话的客户端。更重要的是,当访问令牌过期时,它可以使用刷新令牌获取新的访问令牌。图 11.5 展示了 API 网关如何使用 OAuth 2.0 处理面向会话的客户端。API 客户端通过向 API 网关的/login端点 POST 其凭据来启动会话。API 网关向客户端返回访问令牌和刷新令牌。然后,API 客户端在向 API 网关发出请求时提供这两个令牌。

图 11.5。客户端通过将凭据 POST 到 API 网关来登录。API 网关使用 OAuth 2.0 认证服务器验证凭据,并将访问令牌和刷新令牌作为 cookie 返回。客户端将这些令牌包含在它向 API 网关发出的请求中。

事件序列如下:

  1. 基于登录的客户端将其凭据 POST 到 API 网关。

  2. API 网关的登录处理器向 OAuth 2.0 认证服务器发出 OAuth 2.0 密码授权请求(www.oauth.com/oauth2-servers/access-tokens/password-grant/)。

  3. 认证服务器验证客户端的凭据,并返回访问令牌和刷新令牌。

  4. API 网关将访问和刷新令牌返回给客户端——例如,作为 cookie。

  5. 客户端将其访问和刷新令牌包含在它向 API 网关发出的请求中。

  6. API 网关的会话认证拦截器验证访问令牌,并将其包含在它向服务发出的请求中。

如果访问令牌已过期或即将过期,API 网关通过向授权服务器发出包含刷新令牌的 OAuth 2.0 刷新授权请求(www.oauth.com/oauth2-servers/access-tokens/refreshing-access-tokens/)来获取新的访问令牌。如果刷新令牌尚未过期或未被撤销,授权服务器将返回一个新的访问令牌。API 网关将新的访问令牌传递给服务并返回给客户端。

使用 OAuth 2.0 的一个重要好处是它是一个经过验证的安全标准。使用现成的 OAuth 2.0 认证服务器意味着你不必浪费时间重新发明轮子或冒着开发不安全设计的风险。但 OAuth 2.0 并不是在微服务架构中实现安全性的唯一方式。无论你使用哪种方法,三个关键思想如下:

  • API 网关负责验证客户端。

  • API 网关和服务使用一个透明的令牌,例如 JWT,来传递有关主体的信息。

  • 服务使用令牌来获取主体的身份和角色。

现在我们已经了解了如何使服务安全,让我们看看如何使它们可配置。

11.2. 设计可配置的服务

假设你负责订单历史服务。如图 11.6 所示,该服务从 Apache Kafka 消费事件,并读取和写入 AWS DynamoDB 表项。为了使此服务运行,它需要各种配置属性,包括 Apache Kafka 的网络位置以及 AWS DynamoDB 的凭证和网络位置。

图 11.6. 订单历史服务使用 Apache Kafka 和 AWS DynamoDB。它需要配置每个服务的网络位置、凭证等。

这些配置属性的值取决于服务运行的环境。例如,开发和生产环境将使用不同的 Apache Kafka 代理和不同的 AWS 凭证。将特定环境的配置属性值硬编码到可部署的服务中是没有意义的,因为这需要为每个环境重新构建它。相反,服务应由部署管道构建一次,然后部署到多个环境中。

将不同的配置属性集硬编码到源代码中,并使用例如 Spring 框架的配置文件机制在运行时选择适当的集合,也没有意义。这样做会引入安全漏洞并限制其部署位置。此外,敏感数据,如凭证,应使用如 Hashicorp Vault (www.vaultproject.io) 或 AWS 参数存储 (docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-paramstore.html) 这样的秘密存储机制安全存储。相反,您应该通过使用外部化配置模式在运行时向服务提供适当的配置属性。

模式:外部化配置

在运行时向服务提供配置属性值,例如数据库凭证和网络位置。请参阅 microservices.io/patterns/externalized-configuration.html

外部化配置机制在运行时向服务实例提供配置属性值。主要有两种方法:

  • 推送模型 部署基础设施通过例如操作系统环境变量或配置文件将配置属性传递给服务实例。

  • 拉模型 服务实例从配置服务器读取其配置属性。

我们将探讨每种方法,从推送模型开始。

11.2.1. 使用基于推送的外部化配置

推送模型依赖于部署环境和服务的协作。当部署环境创建服务实例时,它会提供配置属性。如图 11.7 所示,它可能将配置属性作为环境变量传递。或者,部署环境也可以使用配置文件来提供配置属性。服务实例在启动时会读取这些配置属性。

图 11.7。当部署基础设施创建订单历史服务的实例时,它会设置包含外部化配置的环境变量。订单历史服务会读取这些环境变量。

图 11.7

部署环境和服务必须就配置属性的提供方式达成一致。确切的机制取决于特定的部署环境。例如,第十二章描述了如何指定 Docker 容器的环境变量。

让我们假设你已经决定使用环境变量来提供外部化配置属性的值。你的应用程序可以调用System.getenv()来获取它们的值。但如果你是 Java 开发者,你很可能正在使用一个提供更方便机制的框架。FTGO 服务是使用 Spring Boot 构建的,它具有非常灵活的外部化配置机制,可以从各种来源检索配置属性,并具有定义良好的优先级规则(docs.spring.io/spring-boot/docs/current/reference/html/boot-features-external-config.html)。让我们看看它是如何工作的。

Spring Boot 从各种来源读取属性。我发现以下来源在微服务架构中很有用:

  1. 命令行参数

  2. SPRING_APPLICATION_JSON,一个包含 JSON 的操作系统环境变量或 JVM 系统属性

  3. JVM 系统属性

  4. 操作系统环境变量

  5. 当前目录下的配置文件

列表中较早来源的特定属性值会覆盖列表中较晚来源的相同属性。例如,操作系统环境变量会覆盖从配置文件中读取的属性。

Spring Boot 将这些属性提供给 Spring 框架的ApplicationContext。例如,一个服务可以使用@Value注解来获取属性的值:

public class OrderHistoryDynamoDBConfiguration {

  @Value("${aws.region}")
  private String awsRegion;

Spring 框架将awsRegion字段初始化为aws.region属性的值。这个属性是从前面列出的某个来源读取的,例如配置文件或AWS_REGION环境变量。

推送模型是配置服务的一种有效且广泛使用的机制。然而,一个限制是,重新配置运行中的服务可能具有挑战性,甚至可能不可能。部署基础设施可能不允许在不重新启动的情况下更改运行服务的配置属性。例如,您不能更改运行进程的环境变量。另一个限制是,配置属性值可能会分散在多个服务的定义中,存在风险。因此,您可能希望考虑使用基于拉的模型。让我们看看它是如何工作的。

11.2.2. 使用基于拉的配置外部化

在拉模型中,服务实例从配置服务器读取其配置属性。图 11.8 展示了它是如何工作的。在启动时,服务实例查询配置服务以获取其配置。访问配置服务器的配置属性,如其网络位置,通过基于推送的配置机制(如环境变量)提供给服务实例。

图 11.8. 在启动时,服务实例从配置服务器检索其配置属性。部署基础设施提供了访问配置服务器的配置属性。

实现配置服务器有多种方式,包括以下几种:

  • 版本控制系统,如 Git

  • SQL 和 NoSQL 数据库

  • 专门的配置服务器,如 Spring Cloud Config Server、Hashicorp Vault(用于存储敏感数据,如凭据)和 AWS Parameter Store

Spring Cloud Config 项目是一个基于配置服务器的框架的绝佳示例。它由一个服务器和一个客户端组成。服务器支持多种后端来存储配置属性,包括版本控制系统、数据库和 Hashicorp Vault。客户端从服务器检索配置属性并将它们注入到 Spring ApplicationContext 中。

使用配置服务器具有几个优点:

  • 集中式配置 所有配置属性都存储在一个地方,这使得它们更容易管理。更重要的是,为了消除重复的配置属性,一些实现允许您定义全局默认值,这些默认值可以在每个服务的基础上进行覆盖。

  • 透明解密敏感数据 加密敏感数据,如数据库凭据,是安全最佳实践。然而,使用加密的一个挑战是,通常服务实例需要解密它们,这意味着它需要加密密钥。一些配置服务器实现会在将属性返回给服务之前自动解密属性。

  • 动态重新配置 服务实例可以通过例如轮询等方式检测更新的属性值,并重新配置自身。

使用配置服务的主要缺点是,除非它由基础设施提供,否则它又是一个需要设置和维护的基础设施组件。幸运的是,有各种开源框架,例如 Spring Cloud Config,它们使运行配置服务器变得更加容易。

现在我们已经了解了如何设计可配置的服务,让我们谈谈如何设计可观察的服务。

11.3. 设计可观察的服务

假设你已经将 FTGO 应用程序部署到生产环境中。你可能想知道应用程序正在做什么:每秒请求次数、资源利用率等等。如果出现问题,例如服务实例失败或磁盘空间不足——理想情况下在影响用户之前——你也需要收到警报。而且,如果出现问题,你需要能够进行故障排除并确定根本原因。

在生产环境中管理应用程序的许多方面都不在开发者的职责范围内,例如监控硬件可用性和利用率。这些显然是运维的责任。但作为服务开发者,你必须实现一些模式来使你的服务更容易管理和故障排除。这些模式,如图 11.9 所示,揭示了服务实例的行为和健康状况。它们使监控系统能够跟踪和可视化服务的状态,并在出现问题时生成警报。这些模式还使问题排除变得更加容易。

图 11.9. 可观察性模式使开发者和运维人员能够理解应用程序的行为并排除问题。开发者负责确保他们的服务是可观察的。运维负责收集服务暴露的信息的基础设施。

图片 11.9

你可以使用以下模式来设计可观察的服务:

  • 健康检查 API 暴露一个返回服务健康状况的端点。

  • 日志聚合 记录服务活动并将日志写入集中式日志服务器,该服务器提供搜索和警报功能。

  • 分布式跟踪 为每个外部请求分配一个唯一的 ID,并跟踪请求在服务之间流动的情况。

  • 异常跟踪 将异常报告给异常跟踪服务,该服务会去重异常、通知开发者并跟踪每个异常的解决情况。

  • 应用程序度量 服务维护度量,如计数器和仪表,并将它们暴露给度量服务器。

  • 审计日志 记录用户行为。

这些模式的大部分特点在于每个模式都有一个开发组件和一个运维组件。以健康检查 API 模式为例。开发者负责确保他们的服务实现一个健康检查端点。运维负责定期调用健康检查 API 的监控系统。同样,对于日志聚合模式,开发者负责确保他们的服务记录有用的信息,而运维负责日志聚合。

让我们逐一查看这些模式,从健康检查 API 模式开始。

11.3.1. 使用健康检查 API 模式

有时,一个服务可能正在运行但无法处理请求。例如,一个新启动的服务实例可能还没有准备好接受请求。例如,FTGO 的Consumer Service大约需要 10 秒钟来初始化消息和数据库适配器。对于部署基础设施在服务实例准备好处理请求之前将 HTTP 请求路由到服务实例来说,这是毫无意义的。

此外,一个服务实例可能会失败而不会终止。例如,一个错误可能会导致Consumer Service实例耗尽数据库连接,无法访问数据库。部署基础设施不应将请求路由到已失败但仍在运行的服务实例。而且,如果服务实例无法恢复,部署基础设施必须终止它并创建一个新的实例。

模式:健康检查 API

服务公开一个健康检查 API 端点,例如GET /health,它返回服务的健康状况。请参阅microservices.io/patterns/observability/healthcheck-api.html

服务实例需要能够通知部署基础设施它是否能够处理请求。一个不错的解决方案是服务实现一个健康检查端点,如图 11.10 所示。例如,Spring Boot Actuator Java 库实现了一个GET /actuator/health端点,如果服务健康则返回 200,否则返回 503。同样,HealthChecks .NET 库实现了一个GET /hc端点(docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/implement-resilient-applications/monitor-app-health)。部署基础设施定期调用此端点以确定服务实例的健康状况,并在不健康时采取适当的行动。

图 11.10。服务实现了一个健康检查端点,部署基础设施定期调用此端点以确定服务实例的健康状况。

Health Check Request Handler 通常会测试服务实例与外部服务的连接。例如,它可能对数据库执行测试查询。如果所有测试都成功,Health Check Request Handler 返回一个健康响应,如 HTTP 200 状态码。如果有任何测试失败,它返回一个不健康响应,如 HTTP 500 状态码。

Health Check Request Handler 可能简单地返回一个带有适当状态码的空 HTTP 响应。或者,它可能返回每个适配器的详细健康状况描述。详细信息对于故障排除很有用。但由于它可能包含敏感信息,一些框架,如 Spring Boot Actuator,允许您配置健康端点响应的详细程度。

在使用健康检查时,您需要考虑两个问题。第一个问题是端点的实现,它必须报告服务实例的健康状况。第二个问题是如何配置部署基础设施以调用健康检查端点。让我们首先看看如何实现端点。

实现健康检查端点

实现健康检查端点的代码必须以某种方式确定服务实例的健康状况。一种简单的方法是验证服务实例可以访问其外部基础设施服务。如何做到这一点取决于基础设施服务。例如,健康检查代码可以通过获取数据库连接并执行测试查询来验证它是否连接到一个 RDBMS。更复杂的方法是执行一个模拟客户端调用服务 API 的合成事务。这种类型的健康检查更为彻底,但实现起来可能更耗时,执行时间也更长。

健康检查库的一个优秀例子是 Spring Boot Actuator。如前所述,它实现了一个 /actuator/health 端点。实现此端点的代码返回执行一系列健康检查的结果。通过使用约定优于配置,Spring Boot Actuator 根据服务使用的底层基础设施服务实现了一套合理的健康检查。例如,如果一个服务使用 JDBC DataSource,Spring Boot Actuator 会配置一个执行测试查询的健康检查。同样,如果服务使用 RabbitMQ 消息代理,它会自动配置一个健康检查来验证 RabbitMQ 服务器是否运行正常。

您还可以通过为您的服务实现额外的健康检查来自定义此行为。您通过定义一个实现 HealthIndicator 接口的类来实现自定义健康检查。此接口定义了一个 health() 方法,该方法由 /actuator/health 端点的实现调用。它返回健康检查的结果。

调用健康检查端点

如果没有人调用它,健康检查端点就没有什么用处。当你部署你的服务时,你必须配置部署基础设施来调用该端点。你如何做这取决于你的部署基础设施的具体细节。例如,如第三章所述([kindle_split_011.xhtml#ch03]),你可以配置一些服务注册表,例如 Netflix Eureka,以调用健康检查端点,以确定是否应该将流量路由到服务实例。第十二章 讨论了如何配置 Docker 和 Kubernetes 来调用健康检查端点。

11.3.2. 应用日志聚合模式

日志是一个有价值的故障排除工具。如果你想了解你的应用程序出了什么问题,一个好的起点是查看日志文件。但在微服务架构中使用日志具有挑战性。例如,想象你正在调试 getOrderDetails() 查询的问题。如第八章所述([kindle_split_016.xhtml#ch08]),FTGO 应用程序使用 API 组合来实现这个查询。因此,你需要查看的日志条目分散在 API 网关和包括 Order ServiceKitchen Service 在内的几个服务的日志文件中。

模式:日志聚合

将所有服务的日志聚合到一个支持搜索和警报的集中式数据库中。请参阅 microservices.io/patterns/observability/application-logging.html

解决方案是使用日志聚合。如图 11.11 所示,日志聚合管道将所有服务实例的日志发送到一个集中的日志服务器。一旦日志被日志服务器存储,你就可以查看、搜索和分析它们。你还可以配置当某些消息出现在日志中时触发的警报。

图 11.11. 日志聚合基础设施将每个服务实例的日志发送到一个集中的日志服务器。用户可以查看和搜索日志。他们还可以设置警报,当日志条目匹配搜索条件时,警报会被触发。

日志管道和服务器通常由运维负责。但服务开发者负责编写生成有用日志的服务。让我们首先看看服务是如何生成日志的。

服务生成日志的方式

作为服务开发者,你需要考虑几个问题。首先,你需要决定使用哪个日志库。第二个问题是日志条目的写入位置。让我们首先看看日志库。

大多数编程语言都有一个或多个日志库,可以轻松生成正确结构的日志条目。例如,三个流行的 Java 日志库是 Logback、log4j 和 JUL(java.util.logging)。还有 SLF4J,它是各种日志框架的日志门面 API。同样,Log4JS 是 NodeJS 的一个流行的日志框架。使用日志的一个合理方式是在你的服务代码中调用这些日志库之一。但是,如果你有严格的日志要求,这些要求不能通过日志库强制执行,你可能需要定义自己的日志 API,该 API 封装了一个日志库。

你还需要决定在哪里记录日志。传统上,你会配置日志框架将日志写入文件系统中的一个已知位置的日志文件。但是,随着更现代的部署技术,如容器和无服务器,如第十二章 12 中所述,这通常不是最佳方法。在某些环境中,例如 AWS Lambda,甚至没有“永久”的文件系统来写入日志!相反,你的服务应该记录到stdout。然后,部署基础设施将决定如何处理你的服务的输出。

日志聚合基础设施

日志基础设施负责聚合日志、存储它们并使用户能够搜索它们。一个流行的日志基础设施是 ELK 堆栈。ELK 由三个开源产品组成:

  • Elasticsearch 一个以文本搜索为导向的 NoSQL 数据库,用作日志服务器

  • Logstash 一个聚合服务日志并将其写入 Elasticsearch 的日志管道

  • Kibana 用于 Elasticsearch 的可视化工具

其他开源日志管道包括 Fluentd 和 Apache Flume。日志服务器示例包括云服务,如 AWS CloudWatch Logs,以及众多商业产品。日志聚合是微服务架构中一个有用的调试工具。

现在我们来看看分布式追踪,这是理解基于微服务应用程序行为的另一种方式。

11.3.3. 使用分布式追踪模式

想象你是一名 FTGO 开发者,正在调查getOrderDetails()查询为何变慢。你已经排除了外部网络问题导致问题的可能性。延迟的增加必定是由 API 网关或它调用的某个服务引起的。一个选择是查看每个服务的平均响应时间。这个选项的问题在于它是请求的平均值,而不是单个请求的时间分解。此外,更复杂的场景可能涉及许多嵌套的服务调用。你可能甚至不熟悉所有服务。因此,在微服务架构中,诊断这类性能问题可能具有挑战性。

模式:分布式追踪

为每个外部请求分配一个唯一的 ID,并在提供可视化和分析的集中式服务器中记录它从服务到下一个服务的流动情况。请参阅microservices.io/patterns/observability/distributed-tracing.html

了解你的应用程序正在做什么的一个好方法是使用分布式追踪。分布式追踪在单体应用程序中类似于性能分析器。它记录了处理请求时做出的服务调用树的信息(例如,开始时间和结束时间)。然后你可以看到服务在处理外部请求时的交互,包括时间花费的分解。

图 11.12 展示了分布式追踪服务器如何显示 API 网关处理请求时发生的情况。它显示了 API 网关的入站请求和网关对订单服务发出的请求。对于每个请求,分布式追踪服务器显示了执行的操作和请求的时间。

图 11.12. Zipkin 服务器显示了 FTGO 应用程序如何处理由 API 网关路由到订单服务的请求。每个请求都由一个追踪表示。一个追踪是一系列跨度。每个跨度,可以包含子跨度,代表一个服务的调用。根据收集的详细程度,跨度也可以代表服务内部操作的调用。

图 11.12 展示了在分布式追踪术语中被称为追踪的内容。一个追踪代表一个外部请求,由一个或多个跨度组成。一个跨度代表一个操作,其关键属性是操作名称、开始时间戳和结束时间。一个跨度可以有零个或多个子跨度,它们代表嵌套操作。例如,顶层跨度可能代表 API 网关的调用,正如图 11.12 中所示。其子跨度代表 API 网关对服务的调用。

分布式追踪的一个宝贵副作用是它为每个外部请求分配一个唯一的 ID。一个服务可以将请求 ID 包含在其日志条目中。当与日志聚合结合使用时,请求 ID 可以让你轻松地找到特定外部请求的所有日志条目。例如,下面是从订单服务的一个示例日志条目:

2018-03-04 17:38:12.032 DEBUG [ftgo-order-
     service,8d8fdc37be104cc6,8d8fdc37be104cc6,false]
  7 --- [nio-8080-exec-6] org.hibernate.SQL                        :
  select order0_.id as id1_3_0_, order0_.consumer_id as consumer2_3_0_, order
     0_.city as city3_3_0_,
  order0_.delivery_state as delivery4_3_0_, order0_.street1 as street5_3_0_,
  order0_.street2 as street6_3_0_, order0_.zip as zip7_3_0_,
order0_.delivery_time as delivery8_3_0_, order0_.a

日志条目中的[ftgo-order-service,8d8fdc37be104cc6,8d8fdc37be104cc6,false]部分(SLF4J 映射诊断上下文—见www.slf4j.org/manual.html)包含了分布式追踪基础设施的信息。它由四个值组成:

  • ftgo-order-service 应用程序名称

  • 8d8fdc37be104cc6 traceId

  • 8d8fdc37be104cc6 spanId

  • false 表示这个跨度没有被导出到分布式追踪服务器

如果您在日志中搜索8d8fdc37be104cc6,您将找到该请求的所有日志条目。

图 11.13展示了分布式跟踪的工作原理。分布式跟踪有两个部分:一个仪表化库,每个服务都使用它,以及一个分布式跟踪服务器。仪表化库管理跟踪和跨度。它还向出站请求添加跟踪信息,例如当前的跟踪 ID 和父跨度 ID。例如,传播跟踪信息的一个常见标准是 B3 标准(github.com/openzipkin/b3-propagation),它使用如X-B3-TraceIdX-B3-ParentSpanId这样的头信息。仪表化库还将跟踪报告给分布式跟踪服务器。分布式跟踪服务器存储跟踪并提供一个用于可视化的 UI。

图 11.13。每个服务(包括 API 网关)都使用一个仪表化库。仪表化库为每个外部请求分配一个 ID,在服务之间传播跟踪状态,并将跨度报告给分布式跟踪服务器。

图片

让我们来看看仪表化库和分布式跟踪服务器,从库开始。

使用仪表化库

仪表化库构建跨度树并将它们发送到分布式跟踪服务器。服务代码可以直接调用仪表化库,但这会将仪表化逻辑与业务和其他逻辑交织在一起。一种更干净的方法是使用拦截器或面向切面编程(AOP)。

基于 AOP(面向切面编程)框架的一个优秀例子是 Spring Cloud Sleuth。它利用 Spring 框架的 AOP 机制自动将分布式跟踪集成到服务中。因此,您必须将 Spring Cloud Sleuth 作为项目依赖项添加。除非在 Spring Cloud Sleuth 无法处理的情况下,您的服务不需要调用分布式跟踪 API。

关于分布式跟踪服务器

仪表化库将跨度发送到分布式跟踪服务器。分布式跟踪服务器将跨度拼接在一起形成完整的跟踪,并将它们存储在数据库中。一个流行的分布式跟踪服务器是 Open Zipkin。Zipkin 最初由 Twitter 开发。服务可以通过 HTTP 或消息代理将跨度发送到 Zipkin。Zipkin 将跟踪存储在存储后端,该后端可以是 SQL 或 NoSQL 数据库。它有一个 UI,用于显示跟踪,如图 11.12 中所示。AWS X-ray 是另一个分布式跟踪服务器的例子。

11.3.4. 应用应用程序度量模式

生产环境的一个关键部分是监控和警报。如图 11.14 所示,监控系统从技术栈的每个部分收集指标,这些指标提供了关于应用程序健康状况的关键信息。指标范围从基础设施级别的指标,如 CPU、内存和磁盘利用率,到应用程序级别的指标,如服务请求延迟和执行的请求数量。例如,Order Service收集有关已放置、批准和拒绝的订单的指标。这些指标由提供可视化和警报的指标服务收集。

模式:应用程序指标

服务将指标报告给一个提供聚合、可视化和警报的中心服务器。

图 11.14. 栈的每一层都收集并存储在指标服务中,该服务提供可视化和警报。

图片

指标是定期采样的。一个指标样本具有以下三个属性:

  • 名称 指标的名称,例如jvm_memory_max_bytesplaced_orders

  • 一个数值

  • 时间戳 样本的时刻

此外,一些监控系统支持维度的概念,它们是任意的名称-值对。例如,jvm_memory_max_bytesarea="heap",id="PS Eden Space"area="heap",id="PS Old Gen"等维度一起报告。维度通常用于提供额外的信息,例如机器名或服务名,或服务实例标识符。监控系统通常在一条或多条维度上聚合(求和或平均)指标样本。

监控的许多方面是运维的责任。但是,服务开发者负责指标的两个方面。首先,他们必须对其服务进行仪表化,以便收集有关其行为的数据。其次,他们必须将那些服务指标,以及 JVM 和应用框架的指标,公开给指标服务器。

让我们先看看服务是如何收集指标的。

收集服务级指标

你需要做多少工作来收集指标取决于你的应用程序使用的框架以及你想要收集的指标。例如,一个基于 Spring Boot 的服务可以通过将 Micrometer Metrics 库作为依赖项并使用几行配置来收集(并公开)基本指标,如 JVM 指标。Spring Boot 的自动配置负责配置指标库并公开指标。如果服务需要收集特定于应用程序的指标,它只需直接使用 Micrometer Metrics API。

以下列表显示了OrderService如何收集有关已放置、批准和拒绝的订单数量的指标。它使用MeterRegistry,这是由接口提供的 Micrometer Metrics,来收集自定义指标。每个方法都会增加一个适当命名的计数器。

列表 11.1. OrderService跟踪已下单、批准和拒绝的订单数量。
public class OrderService {

  @Autowired
  private MeterRegistry meterRegistry;                          *1*

  public Order createOrder(...) {
    ...
    meterRegistry.counter("placed_orders").increment();         *2*
     return order;
  }

  public void approveOrder(long orderId) {
    ...
    meterRegistry.counter("approved_orders").increment();       *3*
   }

  public void rejectOrder(long orderId) {
    ...
    meterRegistry.counter("rejected_orders").increment();       *4*
   }
  • 1 用于管理应用程序特定仪表的 Micrometer Metrics 库 API

  • 2 当订单成功下单时增加placedOrders计数器

  • 3 当订单被批准时增加approvedOrders计数器

  • 4 当订单被拒绝时增加rejectedOrders计数器

将指标发送到指标服务

一个服务通过以下两种方式之一将指标发送到指标服务:推送或拉取。在推送模式下,服务实例通过调用 API 将指标发送到指标服务。例如,AWS Cloudwatch 指标实现了推送模式。

拉取模式下,指标服务(或其在本地上运行的代理)调用服务 API 以从服务实例检索指标。Prometheus,一个流行的开源监控和警报系统,使用拉取模式。

FTGO 应用程序的Order Service使用micrometer-registry-prometheus库与 Prometheus 集成。因为这个库在类路径上,Spring Boot 暴露了一个GET /actuator/prometheus端点,该端点以 Prometheus 期望的格式返回指标。OrderService的自定义指标报告如下:

$ curl -v http://localhost:8080/actuator/prometheus | grep _orders
# HELP placed_orders_total
# TYPE placed_orders_total counter
placed_orders_total{service="ftgo-order-service",} 1.0
# HELP approved_orders_total
# TYPE approved_orders_total counter
approved_orders_total{service="ftgo-order-service",} 1.0

例如,placed_orders计数器报告为类型为counter的指标。

Prometheus 服务器定期轮询此端点以检索指标。一旦指标在 Prometheus 中,您可以使用 Grafana,一个数据可视化工具(grafana.com)来查看它们。您还可以为这些指标设置警报,例如,当placed_orders_total的变化率低于某个阈值时。

应用程序指标提供了对应用程序行为的宝贵见解。由指标触发的警报使您能够快速响应生产问题,可能在它影响用户之前。现在让我们看看如何观察和响应另一个警报源:异常。

11.3.5. 使用异常跟踪模式

服务很少记录异常,当它这样做时,重要的是要确定根本原因。异常可能是失败或编程错误的症状。查看异常的传统方式是查看日志。您甚至可以配置日志服务器,如果日志文件中出现异常,则提醒您。然而,这种方法存在一些问题:

  • 日志文件以单行日志条目为导向,而异常由多行组成。

  • 没有机制来跟踪日志文件中发生的异常的解决情况。您必须手动将异常复制/粘贴到问题跟踪器中。

  • 很可能存在重复的异常,但没有自动机制将它们视为一个。

模式:异常跟踪

服务将异常报告给一个中央服务,该服务去重异常、生成警报并管理异常的解决。请参阅microservices.io/patterns/observability/audit-logging.html

更好的方法是使用异常跟踪服务。如图 11.15 所示,您可以通过例如 REST API 配置您的服务向异常跟踪服务报告异常。异常跟踪服务去重异常、生成警报并管理异常的解决。

图 11.15. 一个服务将异常报告给异常跟踪服务,该服务去重异常并向开发者发出警报。它有一个用于查看和管理异常的用户界面。

异常跟踪服务

有几个异常跟踪服务。其中一些,如 Honeybadger(www.honeybadger.io),是纯云基础的。其他,如 Sentry.io(sentry.io/welcome/),也有一个开源版本,您可以在自己的基础设施上部署。这些服务接收来自您应用程序的异常并生成警报。它们提供了一个控制台用于查看异常和管理它们的解决。异常跟踪服务通常提供多种语言的客户端库。

有几种方法可以将异常跟踪服务集成到您的应用程序中。您的服务可以直接调用异常跟踪服务的 API。更好的方法是使用异常跟踪服务提供的客户端库。例如,HoneyBadger 的客户端库提供了几个易于使用的集成机制,包括一个 Servlet Filter,它可以捕获并报告异常。

异常跟踪模式是快速识别和响应生产问题的有用方法。

跟踪用户行为也同样重要。让我们看看如何做到这一点。

11.3.6. 应用审计日志模式

审计日志的目的是记录每位用户的行为。审计日志通常用于帮助客户支持、确保合规性以及检测可疑行为。每个审计日志条目记录了用户的身份、他们执行的操作以及业务对象。应用程序通常将审计日志存储在数据库表中。

模式:审计日志

在数据库中记录用户行为,以帮助客户支持、确保合规性以及检测可疑行为。请参阅microservices.io/patterns/observability/audit-logging.html

实现审计日志有多种不同的方法:

  • 将审计日志代码添加到业务逻辑中。

  • 使用面向方面编程(AOP)。

  • 使用事件溯源。

让我们逐一查看每个选项。

将审计日志代码添加到业务逻辑

第一个也是最直接的选择是在服务业务逻辑中散布审计日志代码。例如,每个服务方法都可以创建一个审计日志条目并将其保存到数据库中。这种方法的一个缺点是它将审计日志代码与业务逻辑交织在一起,这降低了可维护性。另一个缺点是它可能存在错误,因为它依赖于开发者编写审计日志代码。

使用面向切面编程

第二种选择是使用 AOP。你可以使用 AOP 框架,如 Spring AOP,来定义建议,该建议可以自动拦截每个服务方法调用并持久化一个审计日志条目。这是一个更可靠的方案,因为它自动记录每个服务方法的调用。使用 AOP 的主要缺点是建议只能访问方法名称及其参数,因此可能难以确定正在操作的业务对象并生成面向业务的活动审计日志条目。

使用事件溯源

第三种也是最后一种选择是使用事件溯源来实现你的业务逻辑。如第六章所述,事件溯源自动为创建和更新操作提供审计日志。你需要在每个事件中记录用户的身份。然而,使用事件溯源的一个局限性是它不记录查询。如果你的服务必须为查询创建日志条目,那么你将不得不使用其他选项之一。

11.4. 使用微服务底盘模式开发服务

本章描述了服务必须实现的各种问题,包括指标、向异常跟踪器报告异常、日志记录和健康检查、外部化配置以及安全性。此外,如第三章所述,服务还可能需要处理服务发现和实现断路器。这不是每次实现新服务时都希望从头开始设置的事情。如果你这样做,可能需要几天甚至几周的时间才能编写第一行业务逻辑。

模式:微服务底盘

在处理跨切面关注点(如异常跟踪、日志记录、健康检查、外部化配置和分布式跟踪)的框架或框架集合上构建服务。请参阅microservices.io/patterns/microservice-chassis.html

开发服务的一个更快的方法是在微服务底盘上构建你的服务。如图 11.16 所示,微服务底盘是一个框架或一系列框架,用于处理这些问题。当使用微服务底盘时,你几乎不需要编写代码来处理这些问题。

图 11.16. 微服务框架是一个处理众多问题的框架,例如异常跟踪、日志记录、健康检查、外部化配置和分布式跟踪。

在本节中,我首先描述微服务框架的概念,并提出一些优秀的微服务框架。然后,我介绍服务网格的概念,在写作时,它正成为使用框架和库的有趣替代品。

让我们先看看微服务框架的想法。

11.4.1. 使用微服务框架

微服务框架是一个或一组框架,处理包括以下在内的众多问题:

  • 外部化配置

  • 健康检查

  • 应用程序指标

  • 服务发现

  • 断路器

  • 分布式跟踪

它显著减少了你需要编写的代码量。你可能甚至不需要编写任何代码。相反,你配置微服务框架以满足你的需求。微服务框架使你能够专注于开发你服务的业务逻辑。

FTGO 应用程序使用 Spring Boot 和 Spring Cloud 作为微服务框架。Spring Boot 提供外部化配置等功能。Spring Cloud 提供断路器等功能。它还实现了客户端服务发现,尽管 FTGO 应用程序依赖于基础设施进行服务发现。Spring Boot 和 Spring Cloud 并不是唯一的微服务框架。例如,如果你在 GoLang 中编写服务,你可以使用 Go Kit (github.com/go-kit/kit) 或 Micro (github.com/micro/micro)。

使用微服务框架的一个缺点是,你需要为每个你用来开发服务的语言/平台组合使用一个。幸运的是,许多微服务框架实现的功能可能会由基础设施来实现。例如,如第三章所述,许多部署环境处理服务发现。更重要的是,微服务框架的许多网络相关功能将由所谓的服务网格来处理,这是一个运行在服务之外的基础设施层。

11.4.2. 从微服务框架到服务网格

微服务框架是实现各种横切关注点的好方法,例如断路器。但使用微服务框架的一个障碍是,你需要为每种编程语言使用一个。例如,如果你是 Java/Spring 开发者,Spring Boot 和 Spring Cloud 很有用,但如果你想编写基于 NodeJS 的服务,它们就帮不上忙了。

模式:服务网格

将所有网络流量通过一个实现各种关注点的网络层进出服务,包括断路器、分布式跟踪、服务发现、负载均衡和基于规则的流量路由。请参阅microservices.io/patterns/deployment/service-mesh.html

一种避免此问题的新兴替代方案是在所谓的服务网格之外实现一些此功能。服务网格是一种网络基础设施,它调解服务与其他服务和外部应用程序之间的通信。如图 11.17 所示,所有进出服务的网络流量都通过服务网格。它实现了包括断路器、分布式跟踪、服务发现、负载均衡和基于规则的流量路由在内的各种关注点。服务网格还可以通过在服务之间使用基于 TLS 的 IPC 来确保进程间通信。因此,你不再需要在服务中实现这些特定的关注点。

图 11.17。所有进出服务的网络流量都通过服务网格。服务网格实现了包括断路器、分布式跟踪、服务发现和负载均衡在内的各种功能。微服务底盘实现的功能较少。它还通过在服务之间使用基于 TLS 的 IPC 来确保进程间通信。

图片

当使用服务网格时,微服务底盘要简单得多。它只需要实现与应用程序代码紧密集成的关注点,例如外部化配置和健康检查。微服务底盘必须通过传播分布式跟踪信息(如我之前在 11.3.3 节中讨论的 B3 标准头)来支持分布式跟踪。

服务网格的概念是一个极具前景的想法。它使开发者免于处理各种横切关注点。此外,服务网格路由流量的能力使您能够将部署与发布分离。它使您能够将服务的全新版本部署到生产环境中,但仅向某些用户(如内部测试用户)发布。第十二章在描述如何使用 Kubernetes 部署服务时进一步讨论了这一概念。

服务网格实现的当前状态

有各种服务网格实现,包括以下:

到写作时为止,Linkerd 是最成熟的,而 Istio 和 Conduit 仍在积极开发中。有关这项激动人心的新技术的更多信息,请查看每个产品的文档。

摘要

  • 一个服务实现其功能需求是至关重要的,但它还必须是安全的、可配置的和可观察的。

  • 在微服务架构中的许多安全方面与单体架构并无二致。但也有一些应用安全方面的考虑是必然不同的,包括用户身份如何在 API 网关和服务之间传递,以及谁负责认证和授权。常用的方法是由 API 网关对客户端进行认证。API 网关在每个请求中包含一个透明的令牌,例如 JWT,发送到服务。令牌包含主体的身份及其角色。服务使用令牌中的信息来授权对资源的访问。OAuth 2.0 是微服务架构中安全性的良好基础。

  • 服务通常使用一个或多个外部服务,例如消息代理和数据库。每个外部服务的网络位置和凭证通常取决于服务运行的环境。你必须应用外部化配置模式并实现一种机制,在运行时为服务提供配置属性。一种常用的方法是部署基础设施在创建服务实例时通过操作系统环境变量或属性文件提供这些属性。另一种选择是服务实例从配置属性服务器检索其配置。

  • 运维和开发人员共同负责实现可观察性模式。运维负责可观察性基础设施,例如处理日志聚合、指标、异常跟踪和分布式跟踪的服务器。开发人员负责确保他们的服务是可观察的。服务必须具有健康检查 API 端点、生成日志条目、收集和公开指标、向异常跟踪服务报告异常以及实现分布式跟踪。

  • 为了简化并加速开发,你应该在微服务框架之上开发服务。微服务框架是一个或一组框架,用于处理各种横切关注点,包括本章中描述的那些。然而,随着时间的推移,许多与微服务框架相关的网络功能可能会迁移到服务网格,这是一个基础设施软件层,所有服务的网络流量都通过它流动。

第十二章. 部署微服务

本章涵盖

  • 四种关键部署模式,它们的工作原理以及它们的优缺点:

    • 语言特定的打包格式

    • 将服务作为虚拟机部署

    • 将服务作为容器部署

    • 无服务器部署

  • 使用 Kubernetes 部署服务

  • 使用服务网格将部署与发布分离

  • 使用 AWS Lambda 部署服务

  • 选择部署模式

在 FTGO,玛丽和她的小组几乎完成了他们的第一个服务的编写。尽管它还没有完全具备所有功能,但它已经在开发者的笔记本电脑和 Jenkins CI 服务器上运行。但这还不够。软件在 FTGO 中没有价值,除非它在生产环境中运行并对用户可用。FTGO 需要将他们的服务部署到生产环境中。

部署 是两个相互关联的概念的组合:过程和架构。部署过程包括人们(开发人员和运维人员)必须执行的一系列步骤,以便将软件投入生产。部署架构定义了软件运行的环境结构。自我在 1990 年代后期开始开发企业级 Java 应用程序以来,这两个部署方面都发生了根本性的变化。开发者将代码扔过墙到生产环境的手动过程已经高度自动化。如图 12.1 所示,物理生产环境已被越来越轻量级和短暂的计算基础设施所取代。

图 12.1. 重量级和长期存在的物理机器已被越来越轻量级和短暂的科技所抽象化。

回到 1990 年代,如果你想将应用程序部署到生产环境,第一步是将你的应用程序以及一系列操作说明扔过墙给运维。例如,你可能提交一个故障单,要求运维部署应用程序。接下来发生的一切完全由运维负责,除非他们遇到了需要你帮助解决的问题。通常,运维会购买并安装昂贵的、重量级的应用程序服务器,如 WebLogic 或 WebSphere。然后他们会登录到应用程序服务器控制台并部署你的应用程序。他们会像照顾宠物一样爱护这些机器,安装补丁和更新软件。

在 2000 年代中期,昂贵的应用服务器被开源、轻量级的 Web 容器如 Apache Tomcat 和 Jetty 所取代。你仍然可以在每个 Web 容器上运行多个应用程序,但每个 Web 容器运行一个应用程序变得可行。此外,虚拟机开始取代物理机器。但机器仍然被视为心爱的宠物,部署仍然是一个基本的手动过程。

今天,部署过程已经发生了根本性的变化。不再是将代码交给单独的生产团队,DevOps 的采用意味着开发团队也负责部署他们的应用程序或服务。在某些组织中,运维为开发者提供了一个用于部署代码的控制台。或者,更好的是,一旦测试通过,部署管道会自动将代码部署到生产环境中。

在生产环境中使用的计算资源也发生了根本性的变化,因为物理机器被抽象化。在高度自动化的云平台上,如 AWS 上运行的虚拟机已经取代了长期存在的、类似宠物的物理和虚拟机。今天的虚拟机是不可变的。它们被视为可丢弃的牛群而不是宠物,因此被丢弃并重新创建,而不是重新配置。"容器",作为虚拟机之上的更轻量级的抽象层,正成为部署应用程序越来越受欢迎的方式。你也可以使用更轻量级的无服务器部署平台,例如 AWS Lambda,来处理许多用例。

部署过程和架构的演变与微服务架构的日益采用不谋而合并非巧合。一个应用程序可能有数十或数百个用各种语言和框架编写的服务。由于每个服务都是一个小的应用程序,这意味着在生产中有数十或数百个应用程序。例如,系统管理员手动配置服务器和服务已不再实用。如果你想要大规模部署微服务,你需要一个高度自动化的部署流程和基础设施。

图 12.2 展示了生产环境的高级视图。生产环境使开发者能够配置和管理他们的服务,部署管道以部署服务的最新版本,以及用户访问由这些服务实现的功能。

图 12.2. 生产环境的简化视图。它提供了四个主要功能:服务管理使开发者能够部署和管理他们的服务,运行时管理确保服务正在运行,监控可视化服务行为并生成警报,请求路由将用户的请求路由到服务。

生产环境必须实现四个关键能力:

  • 服务管理界面 使开发者能够创建、更新和配置服务。理想情况下,此界面是一个由命令行和 GUI 部署工具调用的 REST API。

  • 运行时服务管理 试图确保始终运行着所需数量的服务实例。如果一个服务实例崩溃或以某种方式无法处理请求,生产环境必须重新启动它。如果机器崩溃,生产环境必须在不同的机器上重新启动那些服务实例。

  • 监控—**为开发者提供对其服务正在做什么的洞察,包括日志文件和指标。如果有问题,生产环境必须通知开发者。第十一章 描述了监控,也称为 可观察性

  • 请求路由—**将用户请求路由到服务。

在本章中,我讨论了四种主要的部署选项:

  • 将服务作为特定语言的打包格式部署,例如 Java JAR 或 WAR 文件。探索这个选项是值得的,因为尽管我推荐使用其他选项之一,但其缺点促使其他选项的产生。

  • 将服务作为虚拟机部署,通过将服务打包成虚拟机镜像来封装服务的技术堆栈,从而简化部署。

  • 将服务作为容器部署,容器比虚拟机更轻量。我将展示如何使用 Kubernetes,一个流行的 Docker 集成框架,来部署 FTGO 应用程序的 Restaurant Service

  • 使用无服务器部署来部署服务,这比容器更现代。我们将探讨如何使用 AWS Lambda,一个流行的无服务器平台,来部署 Restaurant Service

让我们先看看如何将服务作为特定语言的打包格式部署。

12.1. 使用特定语言的打包格式模式部署服务

让我们设想,你想要部署 FTGO 应用程序的 Restaurant Service,这是一个基于 Spring Boot 的 Java 应用程序。部署此服务的一种方法是将服务作为特定语言的打包模式。当使用此模式时,在生产环境中部署并由服务运行时管理的,是一个特定语言的打包格式的服务。在 Restaurant Service 的例子中,这可能是可执行的 JAR 文件或 WAR 文件。对于其他语言,例如 NodeJS,服务是一个源代码和模块的目录。对于某些语言,例如 GoLang,服务是一个特定操作系统的可执行文件。

模式:特定语言的打包格式

将特定语言的打包格式部署到生产环境中。请参阅 microservices.io/patterns/deployment/language-specific-packaging.html

要在机器上部署 Restaurant Service,你首先需要安装必要的运行时,在这个例子中是 JDK。如果是 WAR 文件,你还需要安装一个 Web 容器,例如 Apache Tomcat。一旦配置好机器,你将包复制到机器上并启动服务。每个服务实例都作为一个 JVM 进程运行。

理想情况下,你已经设置了部署管道以自动将服务部署到生产环境,如图 12.3 所示。部署管道构建一个可执行的 JAR 文件或 WAR 文件。然后它调用生产环境的服务管理接口以部署新版本。

图 12.3. 部署管道构建可执行的 JAR 文件并将其部署到生产环境中。在生产环境中,每个服务实例是在安装了 JDK 或 JRE 的机器上运行的 JVM。

服务实例通常是单个进程,但有时也可能是一组进程。例如,Java 服务实例是一个运行 JVM 的进程。NodeJS 服务可能会产生多个工作进程以并发处理请求。一些语言支持在同一个进程中部署多个服务实例。

有时你可能会在机器上部署单个服务实例,同时保留在相同机器上部署多个服务实例的选项。例如,如图 12.4 所示,你可以在单台机器上运行多个 JVM。每个 JVM 运行一个服务实例。

图 12.4. 在同一台机器上部署多个服务实例。它们可能是同一服务的实例,也可能是不同服务的实例。操作系统的开销在服务实例之间共享。每个服务实例是一个单独的进程,因此它们之间有一定的隔离。

一些语言也允许你在单个进程中运行多个服务实例。例如,如图 12.5 所示,你可以在单个 Apache Tomcat 上运行多个 Java 服务。

图 12.5. 在同一 Web 容器或应用服务器上部署多个服务实例。它们可能是同一服务的实例,也可能是不同服务的实例。操作系统和运行时的开销在所有服务实例之间共享。但由于服务实例在同一个进程中,它们之间没有隔离。

这种方法通常用于在传统的昂贵且重量级的应用服务器上部署应用程序,如 WebLogic 和 WebSphere。你还可以将服务打包成 OSGI 包,并在每个 OSGI 容器中运行多个服务实例。

服务作为特定语言包模式既有优点也有缺点。让我们首先看看优点。

12.1.1. 服务作为特定语言包模式的优点

服务作为特定语言包模式有几个优点:

  • 快速部署

  • 有效利用资源,尤其是在同一台机器或同一进程中运行多个实例时

让我们逐一查看。

快速部署

这种模式的主要好处之一是部署服务实例相对较快:你将服务复制到主机并启动它。如果服务是用 Java 编写的,你将复制一个 JAR 或 WAR 文件。对于其他语言,如 NodeJS 或 Ruby,你将复制源代码。在两种情况下,通过网络复制的字节数相对较小。

此外,启动服务很少耗时。如果服务是其自己的进程,您就启动它。否则,如果服务是同一容器进程中运行的多个实例之一,您要么将其动态部署到容器中,要么重启容器。由于缺乏开销,启动服务通常很快。

资源利用效率高

这种模式的另一个主要好处是它相对高效地使用资源。多个服务实例共享机器及其操作系统。如果多个服务实例在同一个进程中运行,则效率更高。例如,多个网络应用程序可以共享同一个 Apache Tomcat 服务器和 JVM。

12.1.2. 作为特定语言包模式的“服务”的缺点

尽管这种模式具有吸引力,但作为特定语言包模式的“服务”存在几个显著的缺点:

  • 技术栈缺乏封装。

  • 无法限制服务实例消耗的资源。

  • 在同一台机器上运行多个服务实例时缺乏隔离。

  • 自动确定服务实例放置位置具有挑战性。

让我们来看看每个缺点。

技术栈缺乏封装

运维团队必须了解如何部署每个服务的具体细节。每个服务都需要特定版本的运行时。例如,一个 Java 网络应用程序需要特定版本的 Apache Tomcat 和 JDK。运维团队必须安装每个所需软件包的正确版本。

情况变得更糟,服务可以是用各种语言和框架编写的。它们也可能使用这些语言和框架的多个版本。因此,开发团队必须与运维团队分享大量细节。这种复杂性增加了部署过程中出现错误的风险。例如,一台机器可能运行着错误的语言运行时版本。

无法限制服务实例消耗的资源

另一个缺点是您无法限制服务实例消耗的资源。一个进程可能会消耗机器的所有 CPU 或内存,导致其他服务实例和操作系统资源匮乏。这种情况可能是因为一个错误。

在同一台机器上运行多个服务实例时缺乏隔离

当在同一台机器上运行多个实例时,问题更为严重。缺乏隔离意味着一个行为不当的服务实例可能会影响其他服务实例。因此,应用程序可能会变得不可靠,尤其是在同一台机器上运行多个服务实例时。

自动确定服务实例放置位置具有挑战性

在同一台机器上运行多个服务实例的另一个挑战是确定服务实例的位置。每台机器都有固定的一组资源,如 CPU、内存等,每个服务实例都需要一定量的资源。重要的是以高效使用机器且不过载的方式分配服务实例到机器上。我稍后会解释,基于 VM 的云和容器编排框架会自动处理这个问题。当原生部署服务时,你可能需要手动决定位置。

如你所见,尽管这种模式很熟悉,但作为语言特定包模式的 Service 也有一些显著的缺点。你应该很少使用这种方法,除非效率比其他所有问题都更重要。

现在我们来看看避免这些问题的现代服务部署方式。

12.2. 使用 Service 作为虚拟机模式部署服务

再次想象,你想要部署 FTGO 的 Restaurant Service,但这次是在 AWS EC2 上。一个选择是创建并配置一个 EC2 实例,并将可执行文件或 WAR 文件复制到上面。尽管使用云会带来一些好处,但这种方法存在前面章节中描述的缺点。一个更好、更现代的方法是将服务打包成亚马逊机器镜像(AMI),如图 12.6 所示。每个服务实例都是由该 AMI 创建的 EC2 实例。这些 EC2 实例通常由 AWS 自动扩展组管理,该组试图确保始终运行着所需数量的健康实例。

模式:将服务作为 VM 部署

将打包为 VM 镜像的服务部署到生产环境中。每个服务实例都是一个 VM。见 microservices.io/patterns/deployment/service-per-vm.html

图 12.6. 部署管道将服务打包成虚拟机镜像,如 EC2 AMI,其中包含运行服务所需的一切,包括语言运行时。在运行时,每个服务实例都是一个 VM,如 EC2 实例,由该镜像实例化。EC2 弹性负载均衡器将请求路由到这些实例。

图片

虚拟机镜像是由服务的部署管道构建的。如图 12.6 所示,部署管道运行一个 VM 镜像构建器来创建包含服务代码和运行它所需的任何软件的 VM 镜像。例如,FTGO 服务的 VM 构建器安装了 JDK 和服务的可执行 JAR。VM 镜像构建器配置 VM 镜像机器在 VM 启动时运行应用程序,使用 Linux 的 init 系统,如 upstart。

部署管道可以使用各种工具来构建虚拟机镜像。一个早期的用于创建 EC2 AMI 的工具是 Netflix 创建的 Aminator,它使用它来在 AWS 上部署其视频流媒体服务(github.com/Netflix/aminator)。一个更现代的虚拟机镜像构建器是 Packer,与 Aminator 不同,它支持多种虚拟化技术,包括 EC2、Digital Ocean、Virtual Box 和 VMware(www.packer.io)。要使用 Packer 创建 AMI,你需要编写一个配置文件,该文件指定了基础镜像和一组安装软件并配置 AMI 的 provisioners。

关于 Elastic Beanstalk

AWS 提供的 Elastic Beanstalk 是一个使用虚拟机部署服务的简单方法。你上传你的代码,例如 WAR 文件,Elastic Beanstalk 将其作为一个或多个负载均衡和管理 EC2 实例部署。Elastic Beanstalk 可能不像 Kubernetes 那样时尚,但它是一个在 EC2 上部署基于微服务的应用程序的简单方法。

有趣的是,Elastic Beanstalk 结合了本章中描述的三个部署模式的元素。它支持多种语言的多种打包格式,包括 Java、Ruby 和.NET。它以虚拟机(VM)的形式部署应用程序,但与构建 AMI 不同,它使用一个基础镜像,该镜像在启动时安装应用程序。

Elastic Beanstalk 还可以部署 Docker 容器。每个 EC2 实例运行一个或多个容器的集合。与本章后面将要介绍的 Docker 编排框架不同,扩展的单位是 EC2 实例而不是容器。

让我们来看看使用这种方法的好处和缺点。

12.2.1. 将服务作为虚拟机部署的好处

作为虚拟机的服务模式具有许多好处:

  • 虚拟机镜像封装了技术堆栈。

  • 隔离的服务实例。

  • 使用成熟的云基础设施。

让我们逐一来看。

虚拟机镜像封装了技术堆栈

这种模式的一个重要好处是虚拟机镜像包含了服务和所有依赖项。它消除了正确安装和设置服务运行所需软件的错误风险。一旦服务被封装为虚拟机,它就变成了一个封装了服务技术堆栈的黑盒。虚拟机镜像可以在不修改的情况下部署到任何地方。部署服务的 API 变成了虚拟机管理 API。部署变得简单且更可靠。

服务实例是隔离的

虚拟机的一个主要好处是每个服务实例都在完全隔离的环境中运行。毕竟,这是虚拟机技术的主要目标之一。每个虚拟机都有固定数量的 CPU 和内存,并且不能从其他服务中窃取资源。

使用成熟的云基础设施

将您的微服务作为虚拟机部署的另一个好处是您可以利用成熟的、高度自动化的云基础设施。公共云如 AWS 试图以避免过载机器的方式在物理机上调度虚拟机。它们还提供了如跨虚拟机的流量负载均衡和自动扩展等有价值的功能。

12.2.2. 将服务作为虚拟机部署的缺点

服务作为虚拟机模式也有一些缺点:

  • 资源利用率较低

  • 相对较慢的部署

  • 系统管理开销

让我们逐一分析每个缺点。

资源利用率较低

每个服务实例都有整个虚拟机的开销,包括其操作系统。此外,典型的公共 IaaS 虚拟机提供有限的虚拟机大小,因此虚拟机可能会被过度使用。这对于基于 Java 的服务来说不太可能成为问题,因为它们相对较重。但这种方法可能不是部署轻量级的 NodeJS 和 GoLang 服务的有效方式。

相对较慢的部署

构建虚拟机镜像通常需要一些时间,因为虚拟机的大小。需要通过网络传输大量的数据。此外,从虚拟机镜像实例化虚拟机由于再次需要通过网络传输大量数据而耗时。虚拟机内部运行的操作系统也需要一些时间来启动,尽管“慢”是一个相对术语。这个过程可能需要几分钟,比传统的部署过程快得多。但它比您即将读到的更轻量级的部署模式慢得多。

系统管理开销

您负责修补操作系统和运行时。在部署软件时,系统管理似乎是不可避免的,但在第 12.5 节中,我将描述无服务器部署,它消除了这种类型的系统管理。

现在让我们看看一种更轻量级但仍然具有许多虚拟机优点的方式来部署微服务。

12.3. 使用服务作为容器模式部署服务

容器是一种更现代且轻量级的部署机制。它们是操作系统级别的虚拟化机制。正如图 12.7 所示,容器通常包含一个但有时是多个在沙盒中运行的进程,这将其与其他容器隔离开来。例如,运行 Java 服务的容器通常包含 JVM 进程。

图 12.7. 容器由一个或多个在隔离沙盒中运行的进程组成。通常多个容器运行在单个机器上。容器共享操作系统。

图片

从容器中运行的过程的角度来看,它就像是在自己的机器上运行一样。它通常有自己的 IP 地址,这消除了端口冲突。例如,所有 Java 进程都可以监听 8080 端口。每个容器也有自己的根文件系统。容器运行时使用操作系统机制来隔离容器。最流行的容器运行时示例是 Docker,尽管还有其他,如 Solaris Zones。

模式:将服务作为容器部署

将打包为容器镜像的服务部署到生产环境中。每个服务实例都是一个容器。请参阅microservices.io/patterns/deployment/service-per-container.html

当你创建一个容器时,你可以指定它的 CPU、内存资源,以及根据容器实现的不同,可能还有 I/O 资源。容器运行时强制执行这些限制,并防止容器占用其机器的资源。当使用如 Kubernetes 这样的 Docker 编排框架时,指定容器的资源尤为重要。这是因为编排框架使用容器请求的资源来选择运行容器的机器,从而确保机器不会被过载。

图 12.8 展示了将服务作为容器部署的过程。在构建时,部署管道使用容器镜像构建工具,读取服务的代码和镜像描述,以创建容器镜像并将其存储在注册表中。在运行时,从注册表中拉取容器镜像并用于创建容器。

图 12.8. 服务被打包为容器镜像,并存储在注册表中。在运行时,服务由从该镜像实例化的多个容器组成。容器通常在虚拟机上运行。单个虚拟机通常会运行多个容器。

图片

让我们更详细地看看构建时和运行时步骤。

12.3.1. 使用 Docker 部署服务

要将服务作为容器部署,你必须将其打包为容器镜像。容器镜像是一个包含应用程序和运行服务所需的任何软件的文件系统镜像。它通常是一个完整的 Linux 根文件系统,尽管也使用了更轻量级的镜像。例如,要部署基于 Spring Boot 的服务,你需要构建一个包含服务的可执行 JAR 文件和正确版本的 JDK 的容器镜像。同样,要部署 Java Web 应用程序,你将构建一个包含 WAR 文件、Apache Tomcat 和 JDK 的容器镜像。

构建 Docker 镜像

构建镜像的第一步是创建一个 Dockerfile。一个 Dockerfile 描述了如何构建 Docker 容器镜像。它指定了基础容器镜像、一系列用于安装软件和配置容器的指令,以及容器创建时运行的 shell 命令。列表 12.1 展示了用于构建 Restaurant Service 镜像的 Dockerfile。它构建了一个包含服务可执行 JAR 文件的容器镜像。它配置容器在启动时运行 java -jar 命令。

列表 12.1. 构建 Restaurant Service 所使用的 Dockerfile
FROM openjdk:8u171-jre-alpine                                              *1*
RUN apk --no-cache add curl                                                *2*
CMD java ${JAVA_OPTS} -jar ftgo-restaurant-service.jar                     *3*
HEALTHCHECK --start-period=30s --
     interval=5s CMD curl http://localhost:8080/actuator/health || exit 1  *4*
COPY build/libs/ftgo-restaurant-service.jar .                              *5*
  • 1 基础镜像

  • 2 安装 curl 以供健康检查使用。

  • 3 配置 Docker 在容器启动时运行 java -jar 命令。

  • 4 配置 Docker 调用健康检查端点。

  • 5 将 Gradle 构建目录中的 JAR 复制到镜像中

基础镜像 openjdk:8u171-jre-alpine 是一个包含 JRE 的最小化 Linux 镜像。Dockerfile 将服务的 JAR 文件复制到镜像中,并配置镜像在启动时执行 JAR 文件。它还配置 Docker 定期调用 第十一章 中描述的健康检查端点。HEALTHCHECK 指令表示在初始 30 秒延迟后每 5 秒调用一次健康检查端点 API,这给服务提供了启动时间。

一旦编写了 Dockerfile,就可以构建镜像。以下列表展示了构建 Restaurant Service 镜像的 shell 命令。脚本构建了服务的 JAR 文件,并执行 docker build 命令以创建镜像。

列表 12.2. 构建 Restaurant Service 容器镜像所使用的 shell 命令
cd ftgo-restaurant-service                        *1*
../gradlew assemble                               *2*
docker build -t ftgo-restaurant-service .         *3*
  • 1 切换到服务的目录。

  • 2 构建服务的 JAR 文件。

  • 3 构建镜像。

docker build 命令有两个参数:-t 参数指定镜像的名称,. 指定 Docker 所称的上下文。在这个例子中,上下文是当前目录,包括 Dockerfile 和构建镜像所使用的文件。docker build 命令将上下文上传到 Docker 守护进程,该守护进程构建镜像。

将 Docker 镜像推送到注册库

构建过程的最后一步是将新构建的 Docker 镜像推送到称为注册库的地方。Docker 注册库 等同于 Java Maven 仓库中的 Java 库,或者 NodeJS npm 仓库中的 NodeJS 包。Docker Hub 是一个公共 Docker 注册库的例子,相当于 Maven Central 或 NpmJS.org。但对你自己的应用程序来说,你可能希望使用由服务提供者提供的私有注册库,例如 Docker Cloud 注册库或 AWS EC2 容器注册库。

您必须使用两个 Docker 命令将镜像推送到注册表。首先,您使用 docker tag 命令给镜像一个以注册表的主机名和可选端口为前缀的名称。镜像名称还附加了版本号,这在您发布服务的新版本时将非常重要。例如,如果注册表的主机名是 registry.acme.com,您将使用此命令标记镜像:

docker tag ftgo-restaurant-service registry.acme.com/ftgo-restaurant-
     service:1.0.0.RELEASE

接下来,您使用 docker push 命令将标记的镜像上传到注册表:

docker push registry.acme.com/ftgo-restaurant-service:1.0.0.RELEASE

此命令通常比您预期的要快得多。这是因为 Docker 镜像有一个被称为 分层文件系统 的特性,这使得 Docker 只需要通过网络传输镜像的一部分。镜像的操作系统、Java 运行时和应用程序位于不同的层。Docker 只需要传输那些在目标中不存在的层。因此,当 Docker 只需要移动应用程序的层(这些层是镜像的一小部分)时,通过网络传输镜像就非常快了。

现在我们已经将镜像推送到注册表,让我们看看如何创建一个容器。

运行 Docker 容器

一旦您将服务打包成容器镜像,您就可以创建一个或多个容器。容器基础设施将从注册表将镜像拉取到生产服务器上。然后,它将从这个镜像创建一个或多个容器。每个容器都是您服务的实例。

如您所预期的那样,Docker 提供了一个 docker run 命令来创建并启动一个容器。列表 12.3 展示了如何使用此命令运行 Restaurant Servicedocker run 命令有几个参数,包括容器镜像和在运行时容器中设置的指定环境变量。这些用于传递外部化配置,例如数据库的网络位置等。

列表 12.3. 使用 docker run 运行容器化服务
docker run \
  -d  \                                                               *1*
  --name ftgo-restaurant-service  \                                   *2*
  -p 8082:8080  \                                                     *3*
  -e SPRING_DATASOURCE_URL=... -e SPRING_DATASOURCE_USERNAME=...  \   *4*
  -e SPRING_DATASOURCE_PASSWORD=... \
  registry.acme.com/ftgo-restaurant-service:1.0.0.RELEASE             *5*
  • 1 作为后台守护进程运行

  • 2 容器的名称

  • 3 将容器的 8080 端口绑定到主机的 8082 端口

  • 4 环境变量

  • 5 要运行的镜像

docker run 命令在必要时从注册表拉取镜像。然后它创建并启动容器,该容器运行 Dockerfile 中指定的 java -jar 命令。

使用 docker run 命令可能看起来很简单,但有几个问题。一个是 docker run 并不是一个可靠的服务部署方式,因为它创建了一个在单个机器上运行的容器。Docker 引擎提供了一些基本的管理功能,例如在容器崩溃或机器重启时自动重启容器。但它不处理机器崩溃的情况。

另一个问题是在通常情况下,服务并不是孤立的存在的。它们依赖于其他服务,例如数据库和消息代理。能够作为一个单元部署或卸载服务及其依赖项将是非常好的。

一种更好的方法,尤其是在开发期间非常有用,是使用 Docker Compose。Docker Compose 是一个工具,它允许你使用 YAML 文件声明性地定义一组容器,然后作为一个组启动和停止这些容器。更重要的是,YAML 文件是一种方便的方式来指定许多外部化配置属性。要了解更多关于 Docker Compose 的信息,我建议阅读 Jeff Nickoloff 的《Docker 实战》(Manning,2016 年)并查看示例代码中的 docker-compose.yml 文件。

Docker Compose 的问题在于它仅限于单台机器。为了可靠地部署服务,你必须使用 Docker 编排框架,例如 Kubernetes,它将一组机器转换成一个资源池。我将在第 12.4 节中描述如何使用 Kubernetes。首先,让我们回顾一下使用容器的好处和缺点。

12.3.2. 将服务作为容器部署的好处

将服务作为容器部署有几个好处。首先,容器具有许多虚拟机的优点:

  • 技术堆栈的封装,其中管理你的服务的 API 成为容器 API。

  • 服务实例是隔离的。

  • 服务实例的资源受到限制。

但与虚拟机不同,容器是一种轻量级技术。容器镜像通常构建得很快。例如,在我的笔记本电脑上,将 Spring Boot 应用程序打包成容器镜像只需要大约五秒钟。在网络上移动容器镜像,例如到和从容器注册库,也相对较快,主要是因为只需要传输镜像层的一个子集。容器启动也非常快,因为没有漫长的操作系统启动过程。当容器启动时,只运行服务。

12.3.3. 将服务作为容器部署的缺点

容器的一个显著缺点是,你必须负责管理容器镜像的无差别的繁重工作。你必须修补操作系统和运行时。此外,除非你使用托管容器解决方案,如 Google Container Engine 或 AWS ECS,否则你必须管理容器基础设施,以及可能运行在其上的虚拟机基础设施。

12.4. 使用 Kubernetes 部署 FTGO 应用程序

现在我们已经了解了容器及其权衡,让我们看看如何使用 Kubernetes 部署 FTGO 应用的Restaurant Service。如 12.3.1 节中描述的 Docker Compose 非常适合开发和测试。但要在生产中可靠地运行容器化服务,您需要使用更复杂的容器运行时,例如 Kubernetes。Kubernetes 是一个 Docker 编排框架,它是 Docker 之上的一个软件层,将一组机器转换成一个单一的资源池来运行服务。它努力保持每个服务所需实例的数量始终运行,即使服务实例或机器崩溃。容器的灵活性与 Kubernetes 的复杂性相结合,是部署服务的一种有吸引力的方式。

在本节中,我首先概述 Kubernetes 的功能和架构。之后,我将展示如何使用 Kubernetes 部署服务。Kubernetes 是一个复杂的话题,全面覆盖它超出了本书的范围,因此我只从开发者的角度展示如何使用 Kubernetes。有关更多信息,我推荐 Marko Luksa 所著的《Kubernetes 实战》(Manning, 2018)。

12.4.1. Kubernetes 概述

Kubernetes 是一个 Docker 编排框架。Docker 编排框架将运行 Docker 的一组机器视为资源池。您告诉 Docker 编排框架运行您服务的N个实例,然后它处理其余部分。图 12.9 显示了 Docker 编排框架的架构。

图 12.9. Docker 编排框架将运行 Docker 的一组机器转换成一个资源集群。它将容器分配到机器上。该框架试图始终保持所需数量的健康容器运行。

图片

例如 Kubernetes 这样的 Docker 编排框架有三个主要功能:

  • 资源管理 将机器集群视为 CPU、内存和存储卷的资源池,将机器集合转换成一个单一机器。

  • 调度 选择运行您的容器的机器。默认情况下,调度会考虑容器的资源需求以及每个节点的可用资源。它还可能实现亲和性,将容器放置在同一节点上,以及反亲和性,将容器放置在不同的节点上。

  • 服务管理 实现了命名和版本化服务的概念,这些服务直接映射到微服务架构中的服务。编排框架确保始终运行着所需数量的健康实例。它在这些实例之间进行负载均衡。编排框架执行服务的滚动升级,并允许您回滚到旧版本。

Docker 编排框架是部署应用程序越来越受欢迎的方式。Docker Swarm 是 Docker 引擎的一部分,因此易于设置和使用。Kubernetes 的设置和管理要复杂得多,但功能更强大。在撰写本文时,Kubernetes 具有巨大的动力,拥有庞大的开源社区。让我们更深入地了解一下它是如何工作的。

Kubernetes 架构

Kubernetes 运行在机器集群上。图 12.10 显示了 Kubernetes 集群的架构。Kubernetes 集群中的每台机器要么是主节点,要么是节点。一个典型的集群拥有少量主节点——可能只有一个——以及许多节点。主节点负责管理集群。节点是一个运行一个或多个 Pod 的工作节点。Pod是 Kubernetes 的部署单元,由一组容器组成。

图 12.10. Kubernetes 集群由一个主节点组成,该节点管理集群,以及运行服务的节点。开发人员和部署管道通过 API 服务器与 Kubernetes 交互,该服务器与其他集群管理软件一起运行在主节点上。应用程序容器在节点上运行。每个节点运行一个 Kubelet,它管理应用程序容器,以及一个 kube-proxy,它将应用程序请求路由到 Pod,要么直接作为代理,要么通过配置 Linux 内核中内置的 iptables 路由规则间接地。

主节点运行以下组件:

  • API 服务器 部署和管理服务的 REST API,例如kubectl命令行界面所使用的。

  • Etcd 一个键值 NoSQL 数据库,用于存储集群数据。

  • 调度器 选择一个节点来运行 Pod。

  • 控制器管理器 运行控制器,确保集群的状态与预期状态相匹配。例如,一种称为 复制 控制器的控制器确保通过启动和终止实例来运行所需数量的服务实例。

节点运行以下组件:

  • Kubelet 创建和管理节点上运行的 Pod

  • Kube-proxy 管理网络,包括跨 Pod 的负载均衡

  • Pods 应用程序服务

现在我们来看看您需要掌握的关键 Kubernetes 概念,以便在 Kubernetes 上部署服务。

关键 Kubernetes 概念

如本节引言中所述,Kubernetes 相当复杂。但一旦掌握了几个关键概念,即所谓的 对象,就可以有效地使用 Kubernetes。Kubernetes 定义了许多类型的对象。从开发人员的角度来看,最重要的对象如下:

  • PodPod 是 Kubernetes 中部署的基本单元。它由一个或多个共享 IP 地址和存储卷的容器组成。服务实例的 pod 通常只包含一个容器,例如运行 JVM 的容器。但在某些场景下,pod 包含一个或多个 sidecar 容器,这些容器实现支持功能。例如,一个 NGINX 服务器可以有一个 sidecar,它定期执行 git pull 来下载网站的最新版本。Pod 是短暂的,因为 pod 的容器或其运行的节点可能会崩溃

  • 部署一个 pod 的声明性规范。部署是一个控制器,确保 pod(服务实例)的期望实例数始终在运行。它支持通过滚动升级和回滚进行版本控制。在第 12.4.2 节的后面,你将看到在微服务架构中,每个服务都是一个 Kubernetes 部署

  • 服务—**为应用程序服务的客户端提供一个静态/稳定的网络位置。它是一种基础设施提供的服务发现形式,在第三章中进行了描述。服务有一个 IP 地址和一个解析到该 IP 地址的 DNS 名称,并在一个或多个 pod 上进行 TCP 和 UDP 流量的负载均衡。IP 地址和 DNS 名称仅在 Kubernetes 内部可访问。稍后,我将描述如何配置可以从集群外部访问的服务。

  • ConfigMap—**一个命名集合,包含一组键值对,用于定义一个或多个应用程序服务的外部化配置(有关外部化配置的概述,请参阅第十一章)。pod 容器的定义可以引用 ConfigMap 来定义容器的环境变量。它还可以使用 ConfigMap 在容器内创建配置文件。你可以将敏感信息,如密码,以 ConfigMap 的形式存储在 Secret 中。

现在我们已经回顾了关键 Kubernetes 概念,让我们通过查看如何在 Kubernetes 上部署应用程序服务来观察它们在实际中的应用。

12.4.2. 在 Kubernetes 上部署 Restaurant 服务

如前所述,要在 Kubernetes 上部署服务,你需要定义一个部署。创建 Kubernetes 对象(如部署)的最简单方法是编写一个 YAML 文件。列表 12.4 是一个定义 Restaurant Service 部署的 YAML 文件。此部署指定运行两个 pod 实例。该 pod 只有一个容器。容器定义指定了运行的 Docker 镜像以及其他属性,如环境变量的值。容器的环境变量是服务的外部化配置。Spring Boot 读取这些环境变量,并在应用程序上下文中作为属性提供。

列表 12.4. Kubernetes Deployment for ftgo-restaurant-service
apiVersion: extensions/v1beta1
kind: Deployment                                               *1*
 metadata:
  name: ftgo-restaurant-service                                *2*
 spec:
  replicas: 2                                                  *3*
   template:
    metadata:
      labels:
        app: ftgo-restaurant-service                           *4*
     spec:                                                     *5*
       containers:
       - name: ftgo-restaurant-service
         image: msapatterns/ftgo-restaurant-service:latest
         imagePullPolicy: Always
         ports:
         - containerPort: 8080                                 *6*
           name: httpport
         env:                                                  *7*
           - name: JAVA_OPTS
             value: "-Dsun.net.inetaddr.ttl=30"
           - name: SPRING_DATASOURCE_URL
             value: jdbc:mysql://ftgo-mysql/eventuate
           - name: SPRING_DATASOURCE_USERNAME
             valueFrom:
               secretKeyRef:
                 name: ftgo-db-secret
                 key: username
           - name: SPRING_DATASOURCE_PASSWORD
             valueFrom:
               secretKeyRef:
                 name: ftgo-db-secret                          *8*
                 key: password
           - name: SPRING_DATASOURCE_DRIVER_CLASS_NAME
             value: com.mysql.jdbc.Driver
           - name: EVENTUATELOCAL_KAFKA_BOOTSTRAP_SERVERS
             value: ftgo-kafka:9092
           - name: EVENTUATELOCAL_ZOOKEEPER_CONNECTION_STRING
             value: ftgo-zookeeper:2181
         livenessProbe:                                        *9*
           httpGet:
             path: /actuator/health
             port: 8080
           initialDelaySeconds: 60
           periodSeconds: 20
         readinessProbe:
           httpGet:
             path: /actuator/health
             port: 8080
           initialDelaySeconds: 60
           periodSeconds: 20
  • 1 指定这是一个类型为 Deployment 的对象

  • 2 部署的名称

  • 3 Pod 副本的数量

  • 4 为每个 Pod 分配一个名为 app 的标签,其值为 ftgo-restaurant-service

  • 5 Pod 的规范,仅定义了一个容器

  • 6 容器的端口

  • 7 容器的环境变量,这些变量由 Spring Boot 读取

  • 8 从名为 ftgo-db-secret 的 Kubernetes Secret 中检索的敏感值

  • 9 配置 Kubernetes 调用健康检查端点。

此部署定义配置 Kubernetes 调用 Restaurant Service 的健康检查端点。如第十一章所述(kindle_split_019.xhtml#ch11),健康检查端点使 Kubernetes 能够确定服务实例的健康状况。Kubernetes 实现了两种不同的检查。第一种检查是 readinessProbe,它用于确定是否应将流量路由到服务实例。在此示例中,Kubernetes 在初始 30 秒延迟后每 20 秒调用一次 /actuator/health HTTP 端点,这给了它初始化的机会。如果连续出现一定数量(默认为 1)的 readinessProbes 成功,Kubernetes 认为服务已就绪;而如果连续出现一定数量(默认为 3)的 readinessProbes 失败,则认为服务未就绪。只有当 readinessProbe 指示服务就绪时,Kubernetes 才会将流量路由到服务实例。

第二个健康检查是 livenessProbe。它的配置方式与 readinessProbe 相同。但与确定是否将流量路由到服务实例不同,livenessProbe 确定 Kubernetes 是否应该终止并重新启动服务实例。如果连续出现一定数量(默认为 3)的 livenessProbes 失败,Kubernetes 将终止并重新启动服务。

一旦编写了 YAML 文件,您可以使用 kubectl apply 命令创建或更新部署:

kubectl apply -f ftgo-restaurant-service/src/deployment/kubernetes/ftgo-
     restaurant-service.yml

此命令向 Kubernetes API 服务器发出请求,导致部署和 Pod 的创建。

要创建此部署,您必须首先创建名为 ftgo-db-secret 的 Kubernetes Secret。一种快速但不安全的方法如下:

kubectl create secret generic ftgo-db-secret \
  --from-literal=username=mysqluser --from-literal=password=mysqlpw

此命令创建一个包含在命令行上指定的数据库用户 ID 和密码的 secret。有关创建 secret 的更安全方法,请参阅 Kubernetes 文档(kubernetes.io/docs/concepts/configuration/secret/#creating-your-own-secrets)。

创建 Kubernetes 服务

到目前为止,Pod 正在运行,Kubernetes 部署将尽力保持它们运行。问题是 Pod 具有动态分配的 IP 地址,因此对于想要发起 HTTP 请求的客户端来说并不那么有用。如第三章所述,解决方案是使用服务发现机制。一种方法是在客户端使用发现机制并安装服务注册表,例如 Netflix OSS Eureka。幸运的是,我们可以通过使用 Kubernetes 内置的服务发现机制并定义 Kubernetes 服务来避免这样做。

服务是 Kubernetes 对象,为一个或多个 Pod 的客户端提供一个稳定的端点。它有一个 IP 地址和一个 DNS 名称,该名称解析该 IP 地址。服务将流量负载均衡到该 IP 地址的 Pod 上。列表 12.5 显示了Restaurant Service的 Kubernetes 服务。此服务将流量从http://ftgo-restaurant-service:8080路由到列表中定义的部署所定义的 Pod。

列表 12.5. ftgo-restaurant-service的 Kubernetes 服务的 YAML 定义
apiVersion: v1
kind: Service
metadata:
  name: ftgo-restaurant-service          *1*
 spec:
  ports:
  - port: 8080                           *2*
     targetPort: 8080                    *3*
  selector:
    app: ftgo-restaurant-service         *4*
 ---
  • 1 服务的名称,也是 DNS 名称

  • 2 暴露的端口

  • 3 路由流量的容器端口

  • 4 选择要路由流量的容器

服务定义的关键部分是selector,它选择目标 Pod。它选择那些具有名为app且值为ftgo-restaurant-service的标签的 Pod。如果你仔细看,你会看到列表 12.4 中定义的容器具有这样的标签。

一旦你编写了 YAML 文件,你可以使用以下命令创建服务:

kubectl apply -f ftgo-restaurant-service-service.yml

现在我们已经创建了 Kubernetes 服务,任何在 Kubernetes 集群内部运行的Restaurant Service客户端都可以通过http://ftgo-restaurant-service:8080访问其 REST API。稍后我将讨论如何升级运行中的服务,但首先让我们看看如何使服务从 Kubernetes 集群外部可访问。

12.4.3. 部署 API 网关

Kubernetes 的Restaurant Service服务,如列表 12.5 所示,仅可在集群内部访问。这对Restaurant Service来说没问题,但API Gateway怎么办?它的作用是将外部世界的流量路由到服务。因此,它需要从集群外部可访问。幸运的是,Kubernetes 服务也支持这种用例。我们之前查看的服务是一个ClusterIP服务,这是默认设置,但事实上还有两种其他类型的服务:NodePortLoadBalancer

NodePort服务可通过集群中所有节点的集群端口访问。任何集群节点上的该端口的流量都将负载均衡到后端 Pod。你必须选择 30000-32767 范围内的可用端口。例如,列表 12.6 显示了一个将流量路由到Consumer Service的 30000 端口的服务的示例。

列表 12.6. NodePort服务的 YAML 定义,该服务将流量路由到Consumer Service的 8082 端口
apiVersion: v1
kind: Service
metadata:
  name: ftgo-api-gateway
spec:
  type: NodePort              *1*
  ports:
  - nodePort: 30000           *2*
     port: 80
    targetPort: 8080
  selector:
    app: ftgo-api-gateway
---
  • 1 指定 NodePort 类型

  • 2 集群端口

API Gateway位于集群内部,使用 URL http://ftgo-api-gateway,外部 URL 为http://<node-ip-address>:3000/,其中node-ip-address是集群中某个节点的 IP 地址。配置一个NodePort服务后,例如,你可以配置一个 AWS 弹性负载均衡器(ELB)来在节点之间负载均衡来自互联网的请求。这种方法的优点是 ELB 完全受你控制。在配置它时,你拥有完全的灵活性。

虽然NodePort类型服务不是唯一的选择,你也可以使用LoadBalancer服务,它会自动配置一个特定于云的负载均衡器。如果 Kubernetes 运行在 AWS 上,负载均衡器将是一个 ELB。这种类型服务的优点是,你不再需要配置自己的负载均衡器。然而,缺点是,尽管 Kubernetes 确实提供了一些配置 ELB 的选项,例如 SSL 证书,但你对其配置的控制力大大降低。

12.4.4. 无停机部署

假设你已经更新了Restaurant Service并希望将这些更改部署到生产环境中。当使用 Kubernetes 时,更新运行中的服务是一个简单的三步过程:

  1. 使用前面描述的相同过程构建一个新的容器镜像并将其推送到注册表。唯一的不同之处在于,该镜像将带有不同的版本标签——例如,ftgo-restaurant-service:1.1.0.RELEASE

  2. 编辑服务的部署的 YAML 文件,使其引用新的镜像。

  3. 使用kubectl apply -f命令更新部署。

Kubernetes 随后将对 Pod 执行滚动升级。它将逐步创建运行版本1.1.0.RELEASE的 Pod,并终止运行版本1.0.0.RELEASE的 Pod。Kubernetes 这样做的好处是,它不会在替代 Pod 准备好处理请求之前终止旧 Pod。它使用前面在本节中描述的readinessProbe机制来确定 Pod 是否就绪。因此,始终会有 Pod 可用于处理请求。最终,假设新 Pod 启动成功,所有部署的 Pod 都将运行新版本。

但如果出现问题,版本 1.1.0.RELEASE 的 pod 无法启动怎么办?可能是因为存在错误,例如容器镜像名称拼写错误或新配置属性缺少环境变量。如果 pod 启动失败,部署将陷入停滞。在这种情况下,您有两个选择。一个选择是修复 YAML 文件并重新运行 kubectl apply -f 来更新部署。另一个选择是回滚部署。

每次更新部署时,都会维护所谓的 发布历史。每次更新部署,它都会创建一个新的发布。因此,您可以通过执行以下命令轻松地将部署回滚到以前的版本:

kubectl rollout undo deployment ftgo-restaurant-service

Kubernetes 将用运行旧版本 1.0.0.RELEASE 的 pod 替换运行版本 1.1.0.RELEASE 的 pod。

Kubernetes 部署是一种在不中断服务的情况下部署服务的好方法。但是,如果错误仅在 pod 准备就绪并接收生产流量后出现怎么办?在这种情况下,Kubernetes 将继续推出新版本,因此越来越多的用户将受到影响。尽管您的监控系统可能会检测到问题并快速回滚部署,但您仍然无法避免至少影响一些用户。为了解决这个问题并使服务的新版本发布更加可靠,我们需要将 部署(意味着使服务在生产中运行)与 发布(意味着使其能够处理生产流量)分开。让我们看看如何使用服务网格来实现这一点。

12.4.5. 使用服务网格将部署与发布分离

部署服务新版本的传统方式是首先在预发布环境中进行测试。然后,一旦它通过了预发布环境的测试,您就可以通过滚动升级来部署到生产环境中,用新的服务实例替换旧的服务实例。一方面,正如您刚才看到的,Kubernetes 部署使滚动升级变得非常简单。另一方面,这种方法假设一旦服务版本通过了预发布环境的测试,它将在生产中工作。遗憾的是,这并不总是如此。

一个原因是,如果只是为了生产环境可能要大得多并处理更多的流量,那么预发布环境不太可能是一个精确的克隆。保持两个环境同步也很耗时。由于差异,一些错误可能仅在生产环境中出现。即使是一个精确的克隆,您也无法保证测试会捕获所有错误。

将部署与发布分离是一种推出新版本的更可靠的方法:

  • 部署 在生产环境中运行

  • 发布服务 使其可供最终用户使用

您可以使用以下步骤将服务部署到生产环境中:

  1. 将新版本部署到生产环境中,而不将任何最终用户请求路由到它。

  2. 在生产环境中测试它。

  3. 将其发布给少数最终用户。

  4. 逐步将其发布给越来越多的用户,直到它处理所有生产流量。

  5. 如果在任何时候出现问题,则回滚到旧版本——否则,一旦您确信新版本运行正确,则删除旧版本。

理想情况下,这些步骤将由一个完全自动化的部署管道执行,该管道会仔细监控新部署的服务以查找错误。

传统上,以这种方式分离部署和发布一直具有挑战性,因为它需要大量工作来实现。但使用服务网格的一个好处是,采用这种部署风格要容易得多。正如第十一章所述,服务网格是一种网络基础设施,它介助于服务与其他服务和外部应用程序之间的所有通信。除了承担一些微服务框架的责任外,服务网格还提供基于规则的负载均衡和流量路由,让您能够安全地同时运行多个服务版本。在本节稍后,您将看到您可以路由测试用户到服务的一个版本,而最终用户则使用另一个版本,例如。

正如第十一章所述,有几种服务网格可供选择。在本节中,我将向您展示如何使用 Istio,这是一个由 Google、IBM 和 Lyft 最初开发的开源服务网格。我首先提供一个关于 Istio 及其众多功能的简要概述。接下来,我描述了如何使用 Istio 部署应用程序。之后,我将展示如何使用其流量路由功能来部署和发布服务的升级。

Istio 服务网格概述

Istio 网站将 Istio 描述为“一个用于连接、管理和保护微服务的开放平台” (istio.io)。它是一个网络层,所有服务的网络流量都通过它流动。Istio 具有丰富的功能集,分为四大类:

  • 流量管理—** 包括服务发现、负载均衡、路由规则和断路器

  • 安全—** 使用传输层安全(TLS)确保服务间通信的安全

  • 遥测—** 捕获关于网络流量的指标并实现分布式跟踪

  • 策略执行—** 执行配额和速率限制

本节重点介绍 Istio 的流量管理功能。

图 12.11 显示了 Istio 的架构。它由控制平面和数据平面组成。控制平面实现管理功能,包括配置数据平面以路由流量。数据平面由 Envoy 代理组成,每个服务实例一个。

图 12.11. Istio 由一个控制平面组成,其组件包括 Pilot 和 Mixer,以及一个数据平面,该数据平面由 Envoy 代理服务器组成。Pilot 从底层基础设施中提取已部署服务的相关信息并配置数据平面。Mixer 强制执行诸如配额等策略并收集遥测数据,将其报告给监控基础设施服务器。Envoy 代理服务器在服务之间路由流量。每个服务实例都有一个 Envoy 代理服务器。

控制平面的两个主要组件是 Pilot 和 Mixer。Pilot 从底层基础设施中提取已部署服务的相关信息。例如,当在 Kubernetes 上运行时,Pilot 获取服务和健康 pod。它配置 Envoy 代理以根据定义的路由规则路由流量。Mixer 从 Envoy 代理收集遥测数据并强制执行策略。

Istio Envoy 代理是 Envoy 的修改版本 (www.envoyproxy.io)。它是一个高性能代理,支持多种协议,包括 TCP、低级协议如 HTTP 和 HTTPS,以及高级协议。它还理解 MongoDB、Redis 和 DynamoDB 协议。Envoy 还支持具有断路器、速率限制和自动重试等功能的强大跨服务通信。它可以通过使用 TLS 进行 Envoy 之间的通信来保护应用程序内的通信。

Istio 使用 Envoy 作为边车,一个与服务实例并行运行的进程或容器,并实现跨切面关注点。当在 Kubernetes 上运行时,Envoy 代理是服务 pod 内的一个容器。在其他没有 pod 概念的环境中,Envoy 在与服务的同一容器中运行。所有进入和离开服务的流量都通过其 Envoy 代理流动,该代理根据控制平面提供的路由规则路由流量。例如,直接服务 → 服务通信变为服务 → 源 Envoy → 目标 Envoy → 服务。

模式:边车

在与服务实例并行运行的边车进程或容器中实现跨切面关注点。请参阅 microservices.io/patterns/deployment/sidecar.html

Istio 使用类似 Kubernetes 风格的 YAML 配置文件进行配置。它有一个名为 istioctl 的命令行工具,类似于 kubectl。您使用 istioctl 创建、更新和删除规则和政策。当在 Kubernetes 上使用 Istio 时,您也可以使用 kubectl

让我们看看如何使用 Istio 部署服务。

使用 Istio 部署服务

在 Istio 上部署服务相当简单。你为你的应用程序服务的每个服务定义一个 Kubernetes Service 和一个 Deployment。列表 12.7 展示了 Consumer ServiceServiceDeployment 定义。尽管它与之前我展示的定义几乎相同,但有一些差异。这是因为 Istio 对 Kubernetes 服务和 Pod 有一些要求:

  • Kubernetes 服务端口必须使用 Istio 命名约定 <protocol>[-<suffix>],其中协议是 httphttp2grpcmongoredis。如果端口未命名,Istio 将该端口视为 TCP 端口,并且不会应用基于规则的路由。

  • Pod 应该有一个 app 标签,例如 app: ftgo-consumer-service,以标识服务,以便支持 Istio 分布式跟踪。

  • 为了同时运行服务的多个版本,Kubernetes 部署的名称必须包含版本,例如 ftgo-consumer-service-v1ftgo-consumer-service-v2 等。部署的 Pod 应该有一个 version 标签,例如 version: v1,以指定版本,这样 Istio 就可以路由到特定版本。

列表 12.7. 使用 Istio 部署消费者服务
apiVersion: v1
kind: Service
metadata:
  name: ftgo-consumer-service
spec:
  ports:
  - name: http                                    *1*
    port: 8080
    targetPort: 8080
  selector:
    app: ftgo-consumer-service
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
 name: ftgo-consumer-service-v2                   *2*
 spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: ftgo-consumer-service                *3*
        version: v2
    spec:
      containers:
      - image: image: ftgo-consumer-service:v2    *4*
 ...
  • 1 命名端口

  • 2 版本化部署

  • 3 推荐标签

  • 4 镜像版本

到目前为止,你可能想知道如何在服务的 Pod 中运行 Envoy 代理容器。幸运的是,Istio 通过自动化修改 Pod 定义以包含 Envoy 代理,使得这个过程变得非常简单。有两种方法可以实现这一点。第一种是使用 手动边车注入 并运行 istioctl kube-inject 命令:

istioctl kube-inject -f ftgo-consumer-service/src/deployment/kubernetes/ftgo-
     consumer-service.yml | kubectl apply -f -

此命令读取 Kubernetes YAML 文件,并输出包含 Envoy 代理的修改后的配置。然后,修改后的配置被管道传输到 kubectl apply

将 Envoy 边车添加到 Pod 的第二种方法是使用 自动边车注入。当此功能启用时,你使用 kubectl apply 部署服务。Kubernetes 会自动调用 Istio 修改 Pod 定义以包含 Envoy 代理。

如果你描述你的服务 Pod,你会看到它由你的服务容器以外的更多内容组成:

$ kubectl describe po ftgo-consumer-service-7db65b6f97-q9jpr

Name:           ftgo-consumer-service-7db65b6f97-q9jpr
Namespace:      default
  ...
Init Containers:
  istio-init:                                                   *1*
     Image:         docker.io/istio/proxy_init:0.8.0
    ....
Containers:
  ftgo-consumer-service:                                        *2*
     Image:          msapatterns/ftgo-consumer-service:latest
    ...
  istio-proxy:
    Image:         docker.io/istio/proxyv2:0.8.0                *3*
 ...
  • 1 初始化 Pod

  • 2 服务容器

  • 3 Envoy 容器

现在我们已经部署了服务,让我们看看如何定义路由规则。

创建路由规则以路由到 v1 版本

让我们假设你已经部署了 ftgo-consumer-service-v2 部署。在没有路由规则的情况下,Istio 会将请求负载均衡到服务的所有版本。因此,它会在 ftgo-consumer-service 的版本 1 和 2 之间进行负载均衡,这违背了使用 Istio 的目的。为了安全地推出新版本,你必须定义一个路由规则,将所有流量路由到当前的 v1 版本。

图 12.12 显示了将所有流量路由到 v1Consumer Service 路由规则。它由两个 Istio 对象组成:一个 VirtualService 和一个 DestinationRule

图 12.12。将所有流量路由到 v1 pod 的 Consumer Service 路由规则。它由一个 VirtualService 组成,该 VirtualService 将其流量路由到 v1 子集,以及一个 DestinationRule,该 DestinationRule 将 v1 子集定义为带有 version: v1 标签的 pod。一旦定义了此规则,就可以安全地部署新版本,而无需最初将其路由到任何流量。

一个 VirtualService 定义了如何路由一个或多个主机名的请求。在这个例子中,VirtualService 定义了单个主机名 ftgo-consumer-service 的路由。以下是 Consumer ServiceVirtualService 定义:

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: ftgo-consumer-service
spec:
  hosts:
  - ftgo-consumer-service                 *1*
  http:
    - route:
      - destination:
          host: ftgo-consumer-service     *2*
          subset: v1                      *3*
  • 1 适用于消费者服务

  • 2 路由到消费者服务

  • 3 v1 子集

它路由了 Consumer Service pod 的 v1 子集的所有请求。稍后,我将展示更复杂的示例,这些示例基于 HTTP 请求进行路由并在多个加权目的地之间进行负载均衡。

除了 VirtualService 之外,还必须定义一个 DestinationRule,该 DestinationRule 定义了服务的一个或多个 pod 子集。pod 子集通常是服务版本。DestinationRule 还可以定义流量策略,例如负载均衡算法。以下是 Consumer ServiceDestinationRule

apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: ftgo-consumer-service
spec:
  host: ftgo-consumer-service
  subsets:
  - name: v1                 *1*
    labels:
      version: v1            *2*
  - name: v2
    labels:
      version: v2
  • 1 子集的名称

  • 2 子集的 pod 选择器

DestinationRule 定义了两个 pod 子集:v1v2v1 子集选择带有标签 version: v1 的 pod。v2 子集选择带有标签 version: v2 的 pod。

一旦定义了这些规则,Istio 将仅路由带有 version: v1 标签的 pod 流量。现在可以安全地部署 v2

部署消费者服务的版本 2

这是 Consumer Service 版本 2 的 Deployment 的摘录:

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: ftgo-consumer-service-v2      *1*
 spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: ftgo-consumer-service
        version: v2                   *2*
 ...
  • 1 版本 2

  • 2 Pod 带有版本标签

此部署称为 ftgo-consumer-service-v2。它使用 version: v2 标签其 pod。创建此部署后,ftgo-consumer-service 的两个版本都将运行。但由于路由规则,Istio 不会将任何流量路由到 v2。你现在可以开始将一些测试流量路由到 v2

将测试流量路由到版本 2

部署服务的新版本后,下一步是测试它。假设测试用户的请求有一个 testuser 标头。我们可以通过以下更改增强 ftgo-consumer-service VirtualService,将带有此标头的请求路由到 v2 实例:

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: ftgo-consumer-service
spec:
  hosts:
  - ftgo-consumer-service
  http:
    - match:
      - headers:
          testuser:
            regex: "^.+$"                *1*
      route:
      - destination:
          host: ftgo-consumer-service
          subset: v2                     *2*
     - route:
      - destination:
          host: ftgo-consumer-service
          subset: v1                     *3*
  • 1 匹配非空 testuser 标头

  • 2 将测试用户路由到 v2

  • 3 将所有人路由到 v1

除了原始的默认路由外,VirtualService还有一个路由规则,将带有testuser头部的请求路由到v2子集。更新规则后,你现在可以测试Consumer Service。然后,一旦你确信 v2 正在正常工作,你可以将一些生产流量路由到它。让我们看看如何做到这一点。

将生产流量路由到版本 2

在测试了新部署的服务之后,下一步是将生产流量开始路由到它。一个好的策略是最初只路由一小部分流量。例如,这里有一条将 95%的流量路由到 v1,5%路由到 v2 的规则:

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: ftgo-consumer-service
spec:
  hosts:
  - ftgo-consumer-service
  http:
    - route:
      - destination:
          host: ftgo-consumer-service
          subset: v1
        weight: 95
      - destination:
          host: ftgo-consumer-service
          subset: v2
        weight: 5

随着你对服务能够处理生产流量的信心增强,你可以逐步增加流向版本 2 pod 的流量,直到达到 100%。在这个时候,Istio 不再将任何流量路由到 v1 pod。你可以在删除版本 1 的Deployment之前让它们运行一段时间。

通过让你轻松地将部署与发布分离,Istio 使得推出服务的新版本变得更加可靠。然而,我对 Istio 的功能只是略知一二。截至写作时,Istio 的当前版本是 0.8。我非常期待看到它和其他服务网格成熟并成为生产环境的标准部分。

12.5. 使用无服务器部署模式部署服务

语言特定的打包(第 12.1 节)、作为 VM 的服务(第 12.2 节)和作为容器的服务(第 12.3 节)模式都相当不同,但它们有一些共同的特征。第一个特征是,在所有三种模式中,你必须预先配置一些计算资源——无论是物理机器、虚拟机器还是容器。一些部署平台实现了自动扩展,根据负载动态调整 VM 或容器的数量。但即使它们处于空闲状态,你也需要为一些 VM 或容器付费。

另一个常见的特征是你需要负责系统管理。如果你在运行任何类型的机器,你必须修补操作系统。在物理机器的情况下,这也包括上架和堆叠。你还需要负责管理语言运行时。这是亚马逊所说的“无差别的重劳动”的例子。自从计算机的早期,系统管理就是那些你需要做的事情之一。然而,事实证明,有一个解决方案:无服务器。

12.5.1. 使用 AWS Lambda 的无服务器部署概述

在 2014 年的 AWS Re:Invent 大会上,亚马逊的首席技术官 Werner Vogels 用一句惊人的话介绍了 AWS Lambda:“在函数、事件和数据交汇处发生魔法。”正如这句话所暗示的,AWS Lambda 最初是为了部署事件驱动的服务。它的“魔法”之处在于,正如你将看到的,AWS Lambda 是服务器无部署技术的例子。

无服务器部署技术

所有主要公共云都提供无服务器部署选项,尽管 AWS Lambda 是最先进的。Google Cloud 有 Google Cloud functions,截至写作时仍在测试阶段(cloud.google.com/functions/)。Microsoft Azure 有 Azure functions(azure.microsoft.com/en-us/services/functions)。

此外,还有一些开源的无服务器框架,如 Apache Openwhisk(openwhisk.apache.org)和针对 Kubernetes 的 Fission(fission.io),您可以在自己的基础设施上运行。但我并不完全确信它们的价值。您需要管理运行无服务器框架的基础设施——这听起来并不完全像是无服务器。此外,正如您将在本节后面看到的那样,无服务器以最小的系统管理为代价提供了一个受限的编程模型。如果您需要管理基础设施,那么您将面临限制而没有好处。

AWS Lambda 支持 Java、NodeJS、C#、GoLang 和 Python。一个lambda函数是一个无状态服务。它通常通过调用 AWS 服务来处理请求。例如,当图像上传到 S3 存储桶时被调用的 lambda 函数可以将项目插入到 DynamoDB 的 IMAGES 表中,并向 Kinesis 发布消息以触发图像处理。lambda 函数还可以调用第三方网络服务。

要部署服务,您将应用程序打包成 ZIP 文件或 JAR 文件,上传到 AWS Lambda,并指定要调用的函数名称以处理请求(也称为事件)。AWS Lambda 会自动运行足够的实例来处理传入的请求。您将根据所花费的时间和消耗的内存为每个请求付费。当然,细节才是关键,稍后您将看到 AWS Lambda 有一些限制。但您作为开发人员或您组织中的任何人都无需担心服务器、虚拟机或容器的任何方面的观念是非常强大的。

模式:无服务器部署

使用公共云提供的服务器无部署机制部署服务。请参阅microservices.io/patterns/deployment/serverless-deployment.html

12.5.2. 开发 lambda 函数

与使用其他三种模式不同,您必须为 lambda 函数使用不同的编程模型。lambda 函数的代码和打包依赖于编程语言。Java lambda 函数是一个实现了 AWS Lambda Java 核心库中定义的通用接口 RequestHandler 的类。该接口定义在下面的列表中。此接口接受两个类型参数:I,它是输入类型,O,它是输出类型。IO 的类型取决于 lambda 处理的特定请求类型。

列表 12.8. Java lambda 函数是一个实现了 RequestHandler 接口的类。
public interface RequestHandler<I, O> {
    public O handleRequest(I input, Context context);
}

RequestHandler 接口定义了一个单独的 handleRequest() 方法。此方法有两个参数,一个输入对象和一个上下文,它们提供了对 lambda 执行环境的访问,例如请求 ID。handleRequest() 方法返回一个输出对象。对于由 AWS API Gateway 代理处理的 HTTP 请求的 lambda 函数,IO 分别是 APIGatewayProxyRequestEventAPIGatewayProxyResponseEvent。正如您很快就会看到的,处理函数与旧式的 Java EE servlets 非常相似。

Java lambda 函数打包为 ZIP 文件或 JAR 文件。JAR 文件是一个由 Maven Shade 插件等创建的超级 JAR(或胖 JAR)。ZIP 文件在根目录中有类,在 lib 目录中有 JAR 依赖项。稍后,我将展示如何使用 Gradle 项目创建 ZIP 文件。但首先,让我们看看调用 lambda 函数的不同方式。

12.5.3. 调用 lambda 函数

调用 lambda 函数有四种方式:

  • HTTP 请求

  • AWS 服务生成的事件

  • 计划调用

  • 直接使用 API 调用

让我们逐一查看。

处理 HTTP 请求

调用 lambda 函数的一种方式是配置 AWS API Gateway 将 HTTP 请求路由到您的 lambda。API 网关将您的 lambda 函数公开为 HTTPS 端点。它作为一个 HTTP 代理,使用 HTTP 请求对象调用 lambda 函数,并期望 lambda 函数返回一个 HTTP 响应对象。通过使用 AWS Lambda 和 API 网关,您可以将 RESTful 服务作为 lambda 函数部署。

处理 AWS 服务生成的事件

调用 lambda 函数的第二种方式是将您的 lambda 函数配置为处理由 AWS 服务生成的事件。可以触发 lambda 函数的事件示例包括以下内容:

  • 在 S3 存储桶中创建一个对象。

  • 在 DynamoDB 表中创建、更新或删除一个条目。

  • 可以从 Kinesis 流中读取一条消息。

  • 通过简单电子邮件服务收到一封电子邮件。

由于与其他 AWS 服务的这种集成,AWS Lambda 对于广泛的任务非常有用。

定义计划中的 lambda 函数

另一种调用 Lambda 函数的方式是使用类似 Linux cron 的计划。您可以配置 Lambda 函数定期调用——例如,每分钟、3 小时或 7 天。或者,您可以使用 cron 表达式来指定 AWS 应该何时调用您的 Lambda。cron 表达式提供了极大的灵活性。例如,您可以将 Lambda 配置为在周一至周五下午 2:15 调用。

使用网络服务请求调用 Lambda 函数

调用 Lambda 函数的第四种方式是您的应用程序通过网络服务请求来调用它。网络服务请求指定 Lambda 函数的名称和输入事件数据。您的应用程序可以同步或异步地调用 Lambda 函数。如果您的应用程序同步调用 Lambda 函数,则网络服务的 HTTP 响应包含 Lambda 函数的响应。否则,如果它异步调用 Lambda 函数,则网络服务响应指示 Lambda 执行是否成功启动。

12.5.4. 使用 Lambda 函数的好处

使用 Lambda 函数部署服务具有以下好处:

  • 与许多 AWS 服务集成编写消费 AWS 服务生成的事件(如 DynamoDB 和 Kinesis)并通过 AWS API Gateway 处理 HTTP 请求的 Lambda 非常简单

  • 消除许多系统管理任务您不再负责低级系统管理。没有操作系统或运行时需要修补。因此,您可以专注于开发您的应用程序

  • 弹性AWS Lambda 会根据需要运行您应用程序的实例。您不需要预测所需的容量,也不会面临虚拟机或容器配置不足或配置过量的风险

  • 按使用量计费与典型的 IaaS 云不同,即使虚拟机或容器空闲,IaaS 云也会按分钟或小时计费,而 AWS Lambda 只对处理每个请求时消耗的资源收费

12.5.5. 使用 Lambda 函数的缺点

正如您所看到的,AWS Lambda 是部署服务的一种极其方便的方式,但也有一些显著的缺点和限制:

  • 长尾延迟由于 AWS Lambda 动态运行您的代码,一些请求由于 AWS 分配您的应用程序实例和应用程序启动所需的时间而具有高延迟。这在运行基于 Java 的服务时尤其具有挑战性,因为它们通常至少需要几秒钟才能启动。例如,下一节中描述的示例 Lambda 函数启动需要一段时间。因此,AWS Lambda 可能不适合对延迟敏感的服务

  • 基于事件/请求的有限编程模型AWS Lambda 并不打算用于部署长时间运行的服务,例如从第三方消息代理消费消息的服务

由于这些缺点和限制,AWS Lambda 并不适合所有服务。但在选择部署模式时,我建议首先评估无服务器部署是否支持您服务的需求,然后再考虑其他替代方案。

12.6. 使用 AWS Lambda 和 AWS Gateway 部署 RESTful 服务

让我们看看如何使用 AWS Lambda 部署 Restaurant Service。这是一个具有创建和管理餐厅的 REST API 的服务。例如,它没有与 Apache Kafka 的长期连接,因此非常适合 AWS Lambda。图 12.13 显示了该服务的部署架构。该服务由几个 lambda 函数组成,每个 REST 端点一个。AWS API Gateway 负责将 HTTP 请求路由到 lambda 函数。

图 12.13. 将 Restaurant Service 部署为 AWS Lambda 函数。AWS API Gateway 将 HTTP 请求路由到 AWS Lambda 函数,这些函数由 Restaurant Service 定义的请求处理类实现。

每个 lambda 函数都有一个请求处理类。ftgo-create-restaurant lambda 函数调用 CreateRestaurantRequestHandler 类,而 ftgo-find-restaurant lambda 函数调用 FindRestaurantRequestHandler。由于这些请求处理类实现了同一服务的紧密相关方面,它们被打包在同一 ZIP 文件 restaurant-service-aws-lambda.zip 中。让我们看看包括这些处理类在内的服务设计。

12.6.1. Restaurant Service 的 AWS Lambda 版本设计

服务架构,如图 12.14 所示,与传统服务的架构相当相似。主要区别是 Spring MVC 控制器已被 AWS Lambda 请求处理类所取代。其余的业务逻辑保持不变。

图 12.14. 基于 AWS Lambda 的 Restaurant Service 设计。表示层由请求处理类组成,这些类实现了 lambda 函数。它们调用业务层,业务层采用传统风格编写,包括一个服务类、一个实体和一个仓库。

该服务由一个表示层组成,包括请求处理程序,这些处理程序由 AWS Lambda 调用以处理 HTTP 请求,以及一个传统的业务层。业务层包括 RestaurantServiceRestaurant JPA 实体和 RestaurantRepository,后者封装了数据库。

让我们来看看 FindRestaurantRequestHandler 类。

FindRestaurantRequestHandler

FindRestaurantRequestHandler类实现了GET /restaurant/{restaurantId}端点。这个类以及其他的请求处理类是图 12.15 中显示的类层次结构的叶子。层次结构的根是RequestHandler,它是 AWS SDK 的一部分。它的抽象子类处理错误和注入依赖项。

图 12.15。请求处理类的设计。抽象超类实现了依赖注入和错误处理。

AbstractHttpHandler类是 HTTP 请求处理程序的抽象基类。它捕获在请求处理过程中抛出的未处理异常,并返回一个500 - 内部服务器错误响应。AbstractAutowiringHttpRequestHandler类实现了请求处理程序的依赖注入。我将在稍后描述这些抽象超类,但首先让我们看看FindRestaurantRequestHandler的代码。

列表 12.9 展示了FindRestaurantRequestHandler类的代码。FindRestaurantRequestHandler类有一个handleHttpRequest()方法,该方法接受一个表示 HTTP 请求的APIGatewayProxyRequestEvent作为参数。它调用RestaurantService来查找餐厅,并返回一个描述 HTTP 响应的APIGatewayProxyResponseEvent

列表 12.9。GET /restaurant/{restaurantId}的处理类
public class FindRestaurantRequestHandler
     extends AbstractAutowiringHttpRequestHandler {

  @Autowired
  private RestaurantService restaurantService;

  @Override
  protected Class<?> getApplicationContextClass() {
    return CreateRestaurantRequestHandler.class;                         *1*
  }

  @Override
  protected APIGatewayProxyResponseEvent
       handleHttpRequest(APIGatewayProxyRequestEvent request, Context context) {
    long restaurantId;
    try {
      restaurantId = Long.parseLong(request.getPathParameters()
               .get("restaurantId"));
    } catch (NumberFormatException e) {
      return makeBadRequestResponse(context);                            *2*
     }

    Optional<Restaurant> possibleRestaurant = restaurantService.findById(restaur
     antId);

    return possibleRestaurant                                            *3*
             .map(this::makeGetRestaurantResponse)
            .orElseGet(() -> makeRestaurantNotFoundResponse(context,
                                   restaurantId));

  }

  private APIGatewayProxyResponseEvent makeBadRequestResponse(Context context) {
    ...
  }

  private APIGatewayProxyResponseEvent
      makeRestaurantNotFoundResponse(Context context, long restaurantId) { ... }

  private  APIGatewayProxyResponseEvent
                        makeGetRestaurantResponse(Restaurant restaurant) { ... }
}
  • 1 用于应用程序上下文的 Spring Java 配置类

  • 2 如果缺少或无效的 restaurantId,则返回 400 - 错误请求响应

  • 3 返回餐厅或 404 - 未找到响应

如您所见,它与 servlet 非常相似,只是它没有接受HttpServletRequest并返回HttpServletResponseservice()方法,而是有一个handleHttpRequest(),它接受APIGatewayProxyRequestEvent并返回APIGatewayProxyResponseEvent

让我们看看它的超类,它实现了依赖注入。

使用AbstractAutowiringHttpRequestHandler类进行依赖注入

AWS Lambda 函数既不是 Web 应用程序,也不是具有main()方法的程序。但是,如果不能使用我们习惯的 Spring Boot 功能,那就太遗憾了。下面的列表中显示的AbstractAutowiringHttpRequestHandler类实现了请求处理程序的依赖注入。它使用SpringApplication.run()创建一个ApplicationContext,并在处理第一个请求之前自动装配依赖项。例如FindRestaurantRequestHandler这样的子类必须实现getApplicationContextClass()方法。

列表 12.10。实现依赖注入的抽象RequestHandler
public abstract class AbstractAutowiringHttpRequestHandler
     extends AbstractHttpHandler {

  private static ConfigurableApplicationContext ctx;
  private ReentrantReadWriteLock ctxLock = new ReentrantReadWriteLock();
  private boolean autowired = false;

  protected synchronized ApplicationContext getAppCtx() {               *1*
     ctxLock.writeLock().lock();
    try {
      if (ctx == null) {
        ctx =  SpringApplication.run(getApplicationContextClass());
      }
      return ctx;
    } finally {
      ctxLock.writeLock().unlock();
    }
  }

  @Override
  protected void
        beforeHandling(APIGatewayProxyRequestEvent request, Context context) {
    super.beforeHandling(request, context);
    if (!autowired) {
      getAppCtx().getAutowireCapableBeanFactory().autowireBean(this);   *2*
       autowired = true;
    }
  }

  protected abstract Class<?> getApplicationContextClass();             *3*
 }
  • 1 只创建一次 Spring Boot 应用程序上下文

  • 2 在处理第一个请求之前使用自动装配将依赖项注入到请求处理程序中

  • 3 返回用于创建 ApplicationContext 的@Configuration 类

该类覆盖了 AbstractHttpHandler 定义的 beforeHandling() 方法。其 beforeHandling() 方法在处理第一个请求之前使用自动装配注入依赖项。

AbstractHttpHandler

Restaurant Service 的请求处理器最终扩展了 AbstractHttpHandler,如 列表 12.11 所示。该类实现了 RequestHandler<APIGatewayProxyRequestEventAPIGatewayProxyResponseEvent>。其关键责任是在处理请求时捕获抛出的异常,并抛出 500 错误代码。

列表 12.11. 一个捕获异常并返回 500 HTTP 响应的抽象 RequestHandler
public abstract class AbstractHttpHandler implements
  RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {

  private Logger log = LoggerFactory.getLogger(this.getClass());

  @Override
  public APIGatewayProxyResponseEvent handleRequest(
     APIGatewayProxyRequestEvent input, Context context) {
    log.debug("Got request: {}", input);
    try {
      beforeHandling(input, context);
      return handleHttpRequest(input, context);
    } catch (Exception e) {
      log.error("Error handling request id: {}", context.getAwsRequestId(), e);
      return buildErrorResponse(new AwsLambdaError(
              "Internal Server Error",
              "500",
              context.getAwsRequestId(),
              "Error handling request: " + context.getAwsRequestId() + " "
     + input.toString()));
    }
  }

  protected void beforeHandling(APIGatewayProxyRequestEvent request,
     Context context) {
    // do nothing
  }

  protected abstract APIGatewayProxyResponseEvent handleHttpRequest(
     APIGatewayProxyRequestEvent request, Context context);
}

12.6.2. 将服务打包成 ZIP 文件

在服务可以部署之前,我们必须将其打包成 ZIP 文件。我们可以使用以下 Gradle 任务轻松构建 ZIP 文件:

task buildZip(type: Zip) {
    from compileJava
    from processResources
    into('lib') {
        from configurations.runtime
    }
}

此任务构建一个 ZIP 文件,其中包含顶级目录中的类和资源以及 lib 目录中的 JAR 依赖项。

现在我们已经构建了 ZIP 文件,让我们看看如何部署 Lambda 函数。

12.6.3. 使用 Serverless 框架部署 Lambda 函数

使用 AWS 提供的工具部署 Lambda 函数和配置 API 网关相当繁琐。幸运的是,Serverless 开源项目使得使用 Lambda 函数变得更加容易。当使用 Serverless 时,你只需编写一个简单的 serverless.yml 文件,该文件定义了你的 Lambda 函数及其 RESTful 端点。然后 Serverless 部署 Lambda 函数并创建和配置一个 API 网关,将请求路由到这些函数。

以下列表是 serverless.yml 的摘录,它将 Restaurant Service 部署为 Lambda。

列表 12.12. serverless.yml 部署 Restaurant Service
service: ftgo-application-lambda

provider:
  name: aws                                                     *1*
  runtime: java8
  timeout: 35
  region: ${env:AWS_REGION}
  stage: dev
  environment:                                                  *2*
    SPRING_DATASOURCE_DRIVER_CLASS_NAME: com.mysql.jdbc.Driver
    SPRING_DATASOURCE_URL: ...
    SPRING_DATASOURCE_USERNAME: ...
    SPRING_DATASOURCE_PASSWORD: ...

package:                                                        *3*
   artifact: ftgo-restaurant-service-aws-lambda/build/distributions/
     ftgo-restaurant-service-aws-lambda.zip

functions:                                                      *4*
   create-restaurant:
    handler: net.chrisrichardson.ftgo.restaurantservice.lambda
     .CreateRestaurantRequestHandler
    events:
      - http:
          path: restaurants
          method: post
  find-restaurant:
    handler: net.chrisrichardson.ftgo.restaurantservice.lambda
     .FindRestaurantRequestHandler
    events:
      - http:
          path: restaurants/{restaurantId}
          method: get
  • 1 告诉无服务器在 AWS 上部署

  • 2 通过环境变量提供服务的外部化配置

  • 3 包含 Lambda 函数的 ZIP 文件

  • 4 由处理函数和 HTTP 端点组成的 Lambda 函数定义

然后,你可以使用 serverless deploy 命令,该命令读取 serverless.yml 文件,部署 Lambda 函数,并配置 AWS API 网关。稍等片刻后,你的服务将通过 API 网关的端点 URL 可访问。AWS Lambda 将根据需要为每个 Restaurant Service Lambda 函数提供所需数量的实例以支持负载。如果你更改了代码,你可以通过重新构建 ZIP 文件并重新运行 serverless deploy 来轻松更新 Lambda。无需服务器!

基础设施的演变是显著的。不久前,我们手动在物理机上部署应用程序。今天,高度自动化的公共云提供了一系列虚拟部署选项。一个选项是将服务作为虚拟机部署。或者更好,我们可以将服务打包成容器,并使用复杂的 Docker 编排框架(如 Kubernetes)进行部署。有时我们甚至完全不考虑基础设施,并将服务作为轻量级、短暂的 lambda 函数部署。

摘要

  • 你应该选择最轻量级的部署模式,以满足你的服务需求。以下顺序评估选项:无服务器、容器、虚拟机和语言特定的包。

  • 由于长尾延迟和需要使用基于事件/请求的编程模型,无服务器部署并不适合每个服务。然而,当它适合时,无服务器部署是一个极具吸引力的选项,因为它消除了管理操作系统和运行时的需要,并提供了自动弹性供应和基于请求的定价。

  • Docker 容器,这是一种轻量级的、操作系统级别的虚拟化技术,比无服务器部署更灵活,并且具有更可预测的延迟。最好使用 Docker 编排框架,如 Kubernetes,它管理机器集群上的容器。使用容器的缺点是,你必须管理操作系统和运行时,以及很可能是 Docker 编排框架和它运行的虚拟机。

  • 第三个部署选项是将你的服务作为虚拟机部署。一方面,虚拟机是一个重量级的部署选项,因此部署较慢,并且它可能比第二个选项使用更多的资源。另一方面,现代云服务如 Amazon EC2 高度自动化,并提供了一组丰富的功能。因此,有时使用虚拟机部署小型、简单的应用程序可能比设置 Docker 编排框架更容易。

  • 将你的服务作为语言特定的包部署通常最好避免,除非你只有少量服务。例如,如第十三章所述,当你开始你的微服务之旅时,你可能会使用与你的单体应用相同的机制来部署服务,这很可能是这个选项。一旦你开发了一些服务,你才应该考虑设置复杂的部署基础设施,如 Kubernetes。

  • 使用服务网格——一个充当所有服务进出网络流量的中介的网络层——的许多好处之一是,它使你能够在生产中部署一个服务,测试它,然后才将生产流量路由到它。将部署与发布分离可以提高推出新版本服务的可靠性。

第十三章. 微服务重构

本章涵盖

  • 何时将单体应用迁移到微服务架构

  • 为什么在重构单体应用为微服务时使用增量方法至关重要

  • 将新功能作为服务实现

  • 从单体中提取服务

  • 集成服务和单体

我希望这本书已经让你对微服务架构、其优势和劣势以及何时使用它有了很好的理解。然而,你很可能正在处理一个大型、复杂的单体应用。你开发和应用应用程序的日常体验缓慢且痛苦。看起来非常适合你的应用程序的微服务似乎是一个遥远的极乐世界。像玛丽和 FTGO 开发团队的其他成员一样,你可能想知道如何才能采用微服务架构?

幸运的是,有一些策略你可以使用,以避免从头开始重写你的应用程序,从而逃离单体地狱。你通过开发所谓的“绞杀应用”来逐步将单体转换为微服务。绞杀应用的灵感来源于热带雨林中的绞杀藤,它们通过包围和有时杀死树木来生长。一个绞杀应用是由微服务组成的新应用,你通过将新功能作为服务实现并从单体中提取服务来开发它。随着时间的推移,随着绞杀应用实现越来越多的功能,它逐渐缩小并最终杀死单体。开发绞杀应用的一个重要好处是,与一次性的大改写不同,它能够早期且频繁地向业务交付价值。

我以描述将单体重构为微服务架构的动机开始本章。然后,我描述了如何通过将新功能作为服务实现并从单体中提取服务来开发绞杀应用。接下来,我涵盖了各种设计主题,包括如何集成单体和服务,如何在单体和服务之间维护数据库一致性,以及如何处理安全问题。我通过描述几个示例服务来结束本章。一个服务是Delayed Order Service,它实现了全新的功能。另一个服务是Delivery Service,它是从单体中提取出来的。让我们先从查看重构到微服务架构的概念开始。

13.1. 微服务重构概述

换位思考,假设你是玛丽。你负责 FTGO 应用程序,这是一个庞大且老旧的单体应用程序。企业对工程团队无法快速和可靠地交付功能感到极度沮丧。FTGO 似乎正遭受单体地狱的典型案例。至少表面上,微服务似乎是一个解决方案。你应该提出将开发资源从功能开发转移到迁移到微服务架构的建议吗?

我从这个部分开始讨论为什么你应该考虑将单体架构重构为微服务。我还讨论了为什么确定你的软件开发问题是因为你处于单体地狱,而不是例如一个糟糕的软件开发流程中,这一点很重要。然后,我描述了将单体架构逐步重构为微服务架构的策略。接下来,我讨论了为了保持企业的支持,为什么需要尽早和经常交付改进的重要性。然后,我描述了为什么在开发了一些服务之前,你应该避免投资复杂的部署基础设施。最后,我描述了你可以用来将服务引入架构的各种策略,包括将新功能作为服务实现,以及从单体中提取服务。

13.1.1. 为什么重构单体架构?

如第一章所述,微服务架构具有众多优势。它具有更好的可维护性、可测试性和可部署性,因此可以加速开发。微服务架构更具有可扩展性并提高了故障隔离性。同时,它也更容易演进你的技术栈。但是,将单体架构重构为微服务是一项重大任务。这将分散资源,导致新功能开发受阻。因此,企业可能只有在微服务能够解决重大业务问题时,才会支持其采用。

如果你处于单体地狱,你很可能已经至少有一个业务问题。以下是一些由单体地狱引起的业务问题的例子:

  • 缓慢的交付速度—** 应用程序难以理解、维护和测试,因此开发者的生产力低下。结果,组织无法有效竞争,并面临被竞争对手超越的风险。

  • 有缺陷的软件发布—** 缺乏可测试性意味着软件发布往往存在缺陷。这使客户不满意,导致客户流失和收入减少。

  • 较差的可扩展性—** 扩展单体应用程序很困难,因为它将具有非常不同资源需求的功能模块组合成一个可执行组件。缺乏可扩展性意味着在某个点之后,要么无法扩展应用程序,要么成本过高。因此,应用程序无法满足当前或预测的业务需求。

确保这些问题是因为你已经超过了你的架构是很重要的。缓慢交付和有缺陷的发布的一个常见原因是软件开发流程不佳。例如,如果你仍然依赖于手动测试,那么仅采用自动化测试就可以显著提高开发速度。同样,有时你可以在不改变架构的情况下解决可伸缩性问题。你应该首先尝试更简单的解决方案。只有在你仍然有软件交付问题时,你才应该迁移到微服务架构。让我们看看如何做到这一点。

13.1.2. 绞杀单体

将单体应用转换为微服务的过程是一种应用现代化形式(en.wikipedia.org/wiki/Software_modernization)。应用现代化是将遗留应用转换为具有现代架构和技术堆栈的过程。开发者们已经进行了数十年的应用现代化。因此,我们在将应用重构为微服务架构时,可以借鉴通过经验积累的智慧。多年来学到的最重要的教训就是不要进行一次性大规模重构。

“一次性大规模重构”是指从头开始开发一个新应用——在这种情况下,是一个基于微服务的新应用。虽然从头开始并放弃遗留代码库听起来很有吸引力,但这非常冒险,很可能会以失败告终。你可能会花费数月甚至数年去复制现有的功能,然后才能实现业务今天需要的功能!此外,你仍然需要开发遗留应用,这会分散重构的努力,意味着你有一个不断移动的目标。更重要的是,你可能会浪费时间重新实现不再需要的功能。正如马丁·福勒据说所说,“一次性大规模重构唯一保证的就是一次性的爆炸!”(www.randyshoup.com/evolutionary-architecture)。

而不是进行一次性的大规模重构,你应该像图 13.1 所示的那样,逐步重构你的单体应用。你逐渐构建一个新的应用,这被称为“绞杀应用”。它由与你的单体应用一起运行的微服务组成。随着时间的推移,单体应用实现的功能量会逐渐减少,直到它完全消失或者变成另一个微服务。这种策略类似于在高速公路上以 70 英里每小时的速度开车时给你的车做保养。这很具挑战性,但比起尝试一次性的大规模重构风险要小得多。

图 13.1. 单体应用逐步被由服务组成的绞杀应用所取代。最终,单体应用完全被绞杀应用取代或变成另一个微服务。

图片

Martin Fowler 将这种应用现代化策略称为 Strangler 应用模式(www.martinfowler.com/bliki/StranglerApplication.html)。这个名字来源于热带雨林中发现的绞杀藤(或绞杀榕——见en.wikipedia.org/wiki/Strangler_fig)。绞杀藤围绕树木生长,以到达森林冠层之上的阳光。通常,树木会死亡,因为要么被藤蔓杀死,要么因年老而死亡,留下一个形状像树的藤蔓。

模式:Strangler 应用

通过逐步在遗留应用周围开发新的(绞杀)应用来现代化应用。见microservices.io/patterns/refactoring/strangler-application.html

重构过程通常需要数月或数年。例如,根据 Steve Yegge (plus.google.com/+RipRowan/posts/eVeouesvaVX)的说法,亚马逊.com 花费了几年时间来重构其单体应用。在非常大型系统中,你可能永远无法完成这个过程。例如,你可能达到一个点,此时你有比拆分单体更重要的任务,比如实现盈利功能。如果单体不是持续发展的障碍,那么你不妨让它保持原样。

早期并频繁地展示价值

逐步重构到微服务架构的一个重要好处是,你可以立即获得投资回报。这与一次性重写大不相同,后者直到完成才提供任何好处。当逐步重构单体应用时,你可以使用新的技术栈和现代、高速的 DevOps 风格开发和交付流程来开发每个新的服务。因此,随着时间的推移,你团队的交付速度稳步提高。

此外,你可以首先将应用中的高价值区域迁移到微服务中。例如,假设你正在开发 FTGO 应用。例如,业务可能会决定配送调度算法是一个关键竞争优势。配送管理可能是一个持续、不断发展的领域。通过将配送管理提取为一个独立的服务,配送管理团队将能够独立于 FTGO 的其他开发者工作,并显著提高他们的开发速度。他们能够频繁部署算法的新版本并评估其有效性。

能够更早地交付价值的好处之一是,它有助于维持业务对迁移工作的支持。他们的持续支持是至关重要的,因为重构工作将意味着在开发功能上花费的时间减少。一些组织难以消除技术债务,因为过去的尝试过于雄心勃勃且没有带来太多好处。结果,业务变得不愿意投资进一步的清理工作。将单体重构为微服务的增量性质意味着开发团队能够早期且频繁地展示价值。

最小化对单体的更改

本章的一个反复出现的主题是,在迁移到微服务架构时,你应该避免对单体应用进行大规模的更改。在支持迁移到服务的过程中,你不可避免地需要做出一些更改。第 13.3.2 节讨论了单体通常需要修改以便能够参与维护单体和服务之间数据一致性的叙事。对单体进行大规模更改的问题在于它耗时、成本高昂且风险较大。毕竟,这可能是你最初想要迁移到微服务的原因。

幸运的是,你可以使用一些策略来减少你需要做出的更改范围。例如,在第 13.2.3 节中,我描述了从提取的服务将数据复制回单体数据库的策略。在第 13.3.2 节中,我展示了如何谨慎地安排服务的提取以减少对单体的影响。通过应用这些策略,你可以减少重构单体所需的工作量。

技术部署基础设施:你目前不需要全部

在整本书中,我讨论了许多闪亮的新技术,包括 Kubernetes 和 AWS Lambda 这样的部署平台以及服务发现机制。你可能会被诱惑通过选择技术并构建基础设施来开始你的微服务迁移。你甚至可能感受到来自商业人士和友好的 PaaS 供应商的压力,开始在这类基础设施上花钱。

虽然一开始就构建这个基础设施看起来很有吸引力,但我建议只进行最小的前期投资来开发它。你无法离开的唯一东西是执行自动化测试的部署管道。例如,如果你只有少数几个服务,你不需要复杂的部署和可观察性基础设施。最初,你甚至可以用硬编码的配置文件来进行服务发现。我建议在获得微服务架构的真实经验之前,推迟任何涉及重大投资的技术基础设施决策。只有当你有几个服务运行时,你才会拥有选择技术的经验。

现在我们来看看你可以用来迁移到微服务架构的策略。

13.2. 单体重构为微服务的策略

有三种主要的策略可以扼杀单体并逐步用微服务替换它:

  1. 将新功能作为服务实现。

  2. 将表示层和后端分离。

  3. 通过将功能提取到服务中拆分单体。

第一种策略阻止单体增长。这通常是展示微服务价值的一种快速方式,有助于建立迁移工作的支持。其他两种策略将单体拆分。在重构单体时,你可能会使用第二种策略,但你肯定会使用第三种策略,因为这是将功能从单体迁移到缠扰应用的方式。

让我们逐一审视这些策略,从将新功能作为服务实现开始。

13.2.1. 将新功能作为服务实现

洞穴法则指出:“如果你发现自己陷入了一个洞,就停止挖掘”(en.m.wikipedia.org/wiki/Law_of_holes)。当你的单体应用变得难以管理时,这是一条很好的建议。换句话说,如果你有一个庞大而复杂的单体应用,不要通过向单体添加代码来实现新功能。这将使单体变得更大,更难以管理。相反,你应该将新功能作为服务来实现。

这是一种将单体应用程序迁移到微服务架构的绝佳方式。它减缓了单体的增长速度。由于你在全新的代码库中进行开发,因此它加速了新功能的发展。它还迅速展示了采用微服务架构的价值。

将新服务与单体集成

图 13.2 展示了实现新功能作为服务后的应用程序架构。除了新服务和单体之外,该架构还包括两个其他元素,它们将服务集成到应用程序中:

  • API 网关——将新功能请求路由到新服务,并将旧请求路由到单体。

  • 集成胶水代码* 将服务与单体应用集成。它使服务能够访问单体应用拥有的数据,并调用单体应用实现的功能。

图 13.2. 新功能作为 strangler 应用程序的一部分以服务的形式实现。集成胶水将服务与单体应用集成,包括实现同步和异步 API 的适配器。API 网关将调用新功能的请求路由到服务。

图 13.2 的替代文本

集成胶水代码不是一个独立的组件。相反,它由单体应用和服务中的适配器组成,这些适配器使用一个或多个进程间通信机制。例如,第 13.4.1 节中描述的 Delayed Delivery Service 的集成胶水使用 REST 和领域事件。服务通过调用 REST API 从单体应用检索客户合同信息。单体应用发布 Order 领域事件,以便 Delayed Delivery Service 能够跟踪 Orders 的状态并对无法按时交付的订单做出响应。第 13.3.1 节更详细地描述了集成胶水代码。

何时将新功能实现为服务

理想情况下,你应该在 strangler 应用程序中而不是在单体应用中实现每个新功能。你可以将新功能实现为一个新服务或作为现有服务的一部分。这样,你将永远不需要接触单体代码库。然而,不幸的是,并非每个新功能都可以作为服务实现。

这是因为微服务架构的本质是一组围绕业务能力组织的松散耦合服务。例如,一个功能可能太小,不足以成为一个有意义的服务。你可能只需要向现有类添加几个字段和方法。或者,新功能可能与单体应用中的代码过于紧密耦合。如果你尝试将此类功能作为服务实现,你通常会发现由于过多的进程间通信而导致性能下降。你也可能遇到维护数据一致性的问题。如果一个新功能不能作为服务实现,通常的解决方案是首先在单体应用中实现该新功能。然后,你可以将这个功能以及其他相关功能提取出来,形成它们自己的服务。

将新功能作为服务实现可以加速这些功能的开发。这是快速展示微服务架构价值的好方法。它还可以降低单体应用的增长速度。但最终,你需要使用另外两种策略来分解单体应用。你需要通过从单体应用中提取功能到服务中来迁移功能到 strangler 应用程序。你还可以通过水平拆分单体应用来提高开发速度。让我们看看如何做到这一点。

13.2.2. 将表示层从后端分离

缩小单体应用程序的一种策略是将表示层从业务逻辑和数据访问层中分离出来。一个典型的企业应用程序由以下层组成:

  • 表示逻辑—** 这包括处理 HTTP 请求并生成实现 Web UI 的 HTML 页面的模块。在一个具有复杂用户界面的应用程序中,表示层通常是大量的代码。

  • 业务逻辑—** 这包括实现业务规则的模块,在企业应用程序中可能很复杂。

  • 数据访问逻辑—** 这包括访问基础设施服务,如数据库和消息代理。

通常,表示逻辑与业务和数据访问逻辑之间有一个清晰的分离。业务层有一个粗粒度的 API,由一个或多个封装业务逻辑的门面组成。这个 API 是你可以沿着它将单体拆分为两个较小应用程序的自然缝隙,如图 13.3 所示。一个应用程序包含表示层,另一个包含业务和数据访问逻辑。拆分后,表示逻辑应用程序会对业务逻辑应用程序进行远程调用。

图 13.3. 将前端从后端分离,使得每个都可以独立部署。它还公开了一个服务可以调用的 API。

图片

以这种方式拆分单体有两个主要好处。它使你可以独立于彼此开发、部署和扩展两个应用程序。特别是,它允许表示层开发人员快速迭代用户界面,并轻松执行 A/B 测试,例如,而无需部署后端。这种方法的另一个好处是公开了一个远程 API,可以由你后来开发的微服务调用。

但这种策略只是部分解决方案。很可能至少有一个或两个结果应用程序仍然是一个难以管理的单体。你需要使用第三种策略用服务替换单体。

13.2.3. 将业务能力提取到服务中

将新功能作为服务实现并将前端 Web 应用程序与后端分离,这只能让你走这么远。你仍然会在单体代码库中进行大量的开发。如果你想显著改进应用程序的架构并提高开发速度,你需要通过逐步将业务能力从单体迁移到服务中来拆分单体。例如,第 13.5 节描述了如何将交付管理从 FTGO 单体中提取到新的Delivery Service中。当你使用这种策略时,随着时间的推移,由服务实现的企业能力数量会增加,而单体逐渐缩小。

你想要提取到服务中的功能是单体应用的一个垂直切片。该切片包括以下内容:

  • 实现 API 端点的入站适配器

  • 领域逻辑

  • 出站适配器,如数据库访问逻辑

  • 单体应用的数据库模式

如图 13.4 所示,此代码是从单体应用中提取出来并移动到独立服务中的。API 网关将调用提取出的业务能力的请求路由到服务,并将其他请求路由到单体应用。单体应用和服务通过集成粘合代码进行协作。如第 13.3.1 节所述,集成粘合代码由服务中的适配器和单体应用中使用一个或多个进程间通信(IPC)机制组成的适配器组成。

图 13.4。通过提取服务来分解单体应用。你确定一个功能切片,它由业务逻辑和适配器组成,并将其提取到服务中。你将那段代码移动到服务中。新提取的服务和单体应用通过集成粘合代码提供的 API 进行协作。

图 13.4

提取服务具有挑战性。你需要确定如何将单体应用的领域模型分割成两个独立的领域模型,其中一个将成为服务的领域模型。你需要打破诸如对象引用之类的依赖关系。你可能甚至需要分割类以将功能移动到服务中。你还需要重构数据库。

提取服务通常耗时较长,尤其是因为单体应用的代码库可能很混乱。因此,你需要仔细思考要提取哪些服务。重要的是要关注那些提供大量价值的应用程序的部分的重构。在提取服务之前,问问自己这样做的好处是什么。

例如,提取一个实现对业务至关重要且不断发展的功能的服务的做法是值得的。当这样做没有多少好处时,投入精力提取服务是没有价值的。在本节的后面部分,我将描述一些确定提取什么以及何时提取的策略。但首先,让我们更详细地看看在提取服务时你将面临的一些挑战以及如何解决它们。

在提取服务时,你将遇到一些挑战:

  • 分割领域模型

  • 重构数据库

让我们逐一来看,首先是分割领域模型。

分割领域模型

为了提取一个服务,你需要将其领域模型从单体领域模型中提取出来。你需要进行重大手术来分割领域模型。你将遇到的挑战之一是消除那些本应跨越服务边界的对象引用。可能留在单体中的类会引用已移动到服务中的类,反之亦然。例如,想象一下,正如图 13.5 所示,你提取了Order Service,因此其Order类引用了单体的Restaurant类。由于服务实例通常是进程,跨越服务边界的对象引用是没有意义的。你需要以某种方式消除这些类型的对象引用。

图 13.5. Order域类引用了一个Restaurant类。如果我们把Order提取到一个单独的服务中,我们需要对其对Restaurant的引用进行处理,因为进程之间的对象引用是没有意义的。

图片 13.5

解决这个问题的好方法之一是考虑 DDD 聚合,这在第五章中有描述。聚合通过主键而不是对象引用相互引用。因此,你会把OrderRestaurant类视为聚合,正如图 13.6 所示,将Order类中对Restaurant的引用替换为存储主键值的restaurantId字段。

图 13.6. 为了消除跨越进程边界的对象,Order类对Restaurant的引用被替换为Restaurant的主键。

图片 13.6

将对象引用替换为主键的问题在于,尽管这仅仅是类的一个小改动,但它可能会对期望对象引用的类的客户端产生重大影响。在本节稍后,我将描述如何通过在服务和单体之间复制数据来缩小更改的范围。例如,Delivery Service可以定义一个与单体Restaurant类相同的Restaurant类。

提取服务通常比将整个类移动到服务中涉及更多。在拆分领域模型时,一个更大的挑战是从具有其他职责的类中提取嵌入的功能。这个问题通常出现在第二章中描述的“全能类”中,这些类承担了过多的职责。例如,Order类是 FTGO 应用程序中的全能类之一。它实现了多个业务能力,包括订单管理、配送管理等。在第 13.5 节中,我讨论了如何将配送管理提取到服务中,这涉及到从Order类中提取Delivery类。Delivery实体实现了之前与Order类中其他功能捆绑在一起的配送管理功能。

重构数据库

将领域模型拆分不仅涉及代码的改变。领域模型中的许多类都是持久的。它们的字段映射到数据库模式中。因此,当你从一个单体中提取服务时,你也在移动数据。你需要将表从单体数据库移动到服务数据库。

此外,当你拆分一个实体时,你需要拆分相应的数据库表并将新表移动到服务中。例如,当将配送管理提取到服务中时,你拆分了Order实体并提取了Delivery实体。在数据库层面,你拆分了ORDERS表并定义了一个新的DELIVERY表。然后你将DELIVERY表移动到服务中。

Scott W. Ambler 和 Pramod J. Sadalage 合著的《重构数据库》(Addison-Wesley,2011 年)描述了一系列针对数据库模式的重构方法。例如,它描述了Split Table(拆分表)重构,将一个表拆分为两个或更多表。书中许多技术对于从单体中提取服务非常有用。其中一种技术是复制数据,以便你可以逐步更新数据库客户端以使用新架构。我们可以将这个想法适应到减少在提取服务时必须对单体进行的更改范围。

复制数据以避免广泛更改

如前所述,提取服务需要你修改单体领域的模型。例如,你用主键替换对象引用并拆分类。这类改变可能会在代码库中产生连锁反应,并要求你对单体进行广泛的修改。例如,如果你拆分了Order实体并提取了Delivery实体,你将不得不更改代码中所有引用已移动字段的部位。进行这类改变可能非常耗时,并可能成为拆分单体的一大障碍。

延迟并可能避免做出这些昂贵改变的一个好方法是用一种类似于在《重构数据库》中描述的方法。重构数据库的一个主要障碍是改变该数据库的所有客户端以使用新的模式。书中提出的解决方案是在过渡期间保留原始模式,并使用触发器来同步原始和新的模式。然后你可以在一段时间内将客户端从旧模式迁移到新模式。

当从单体应用中提取服务时,我们可以使用类似的方法。例如,当提取Delivery实体时,我们让Order实体在过渡期间基本保持不变。如图 13.7 所示,我们将与交付相关的字段设置为只读,并通过从Delivery Service复制数据回单体应用来保持它们是最新的。因此,我们只需要找到单体应用代码中更新这些字段的地方,并将它们更改为调用新的Delivery Service

通过从新提取的Delivery Service复制相关数据回单体数据库,最小化对 FTGO 单体应用更改的范围。

图 13.7

通过从Delivery Service复制数据以保留Order实体的结构,显著减少了我们立即需要做的工作量。随着时间的推移,我们可以将使用与交付相关的Order实体字段或ORDERS表列的代码迁移到Delivery Service。更重要的是,我们可能永远不需要在单体应用中进行这种更改。如果该代码随后被提取到一个服务中,那么该服务就可以访问Delivery Service

提取哪些服务和何时提取

正如我提到的,拆分单体应用是耗时的。它会分散实现功能的精力。因此,你必须仔细决定提取服务的顺序。你需要专注于提取能带来最大效益的服务。更重要的是,你想要不断地向业务展示迁移到微服务架构的价值。

在任何旅程中,了解你要去哪里是至关重要的。开始向微服务迁移的一个好方法是通过一个时间限制的架构定义努力。你应该花上一些时间,比如几周时间,来头脑风暴你的理想架构并定义一组服务。这为你提供了一个目标去追求。然而,重要的是要记住,这个架构并不是一成不变的。随着你将单体应用拆分并积累经验,你应该修订架构以考虑你所学到的内容。

一旦你确定了大致的目标,下一步就是开始拆分单体应用。你可以使用几种不同的策略来确定提取服务的顺序。

一种策略是有效地冻结单体应用的开发,并根据需求提取服务。而不是在单体应用中实现功能或修复错误,你提取必要的或多个服务并对其进行更改。这种方法的一个优点是它迫使你分解单体应用。一个缺点是服务的提取是由短期需求而不是长期需求驱动的。例如,即使你只是对系统的一个相对稳定的部分进行小的更改,这也要求你提取服务。结果,你可能会做很多工作,但收益却很小。

另一种策略是一种更计划的途径,其中你根据从提取模块中预期获得的收益对应用程序的模块进行排序。提取服务有几个好处:

  • 加速开发 如果你的应用程序路线图表明应用程序的某个部分将在下一年经历大量的开发,那么将其转换为服务可以加速开发。

  • 解决性能、扩展性或可靠性问题 如果应用程序的某个部分存在性能或可扩展性问题或不可靠,那么将其转换为服务是有价值的。

  • 使提取其他服务成为可能 有时,由于模块之间的依赖关系,提取一个服务可以简化另一个服务的提取。

你可以使用这些标准将重构任务添加到应用程序的待办事项列表中,按预期收益进行排序。这种方法的优点是它更加战略化,并且与业务需求更加紧密地一致。在冲刺计划期间,你决定是实施功能还是提取服务更有价值。

13.3. 设计服务与单体应用的协作方式

一个服务很少是独立的。它通常需要与单体应用协作。有时一个服务需要访问单体应用拥有的数据或调用其操作。例如,详细描述在第 13.4.1 节中的延迟交付服务,需要访问单体应用的订单和客户联系信息。单体应用也可能需要访问服务拥有的数据或调用其操作。例如,在第 13.5 节中稍后讨论如何将交付管理提取为服务时,我描述了单体应用需要调用交付服务

一个重要的关注点是维护服务与单体应用之间的数据一致性。特别是,当你从单体应用中提取服务时,你不可避免地会分割原本的 ACID 事务。你必须小心确保数据一致性仍然得到维护。正如本节稍后所述,有时你使用 sagas 来维护数据一致性。

如前所述,服务和单体之间的交互由集成粘合剂代码促进。图 13.8 显示了集成粘合剂的结构。它由服务和单体中使用某种 IPC 机制进行通信的适配器组成。根据要求,服务和单体可能通过 REST 交互,或者它们可能使用消息传递。它们甚至可能使用多种 IPC 机制进行通信。

图 13.8. 当将单体应用迁移到微服务时,服务和单体通常需要相互访问对方的数据。这种交互由集成粘合剂促进,它由实现 API 的适配器组成。一些 API 是基于消息的。其他 API 是基于 RPI 的。

例如,Delayed Delivery Service同时使用 REST 和领域事件。它使用 REST 从单体应用中检索客户联系信息。它通过订阅由单体应用发布的领域事件来跟踪Orders的状态。

在本节中,我首先描述了集成粘合剂的设计。我讨论了它解决的问题和不同的实现选项。之后,我描述了事务管理策略,包括使用 sagas。我讨论了有时保持数据一致性的要求会改变你提取服务时的顺序。

让我们先看看集成粘合剂的设计。

13.3.1. 设计集成粘合剂

当将一个功能作为服务实现或从单体应用中提取服务时,你必须开发一个集成粘合剂,使服务能够与单体应用协作。它由服务和单体中使用的某种 IPC 机制代码组成。集成粘合剂的结构取决于所使用的 IPC 机制类型。例如,如果服务使用 REST 调用单体应用,那么集成粘合剂由服务中的 REST 客户端和单体应用中的 Web 控制器组成。或者,如果单体应用订阅由服务发布的领域事件,那么集成粘合剂由服务中的事件发布适配器和单体应用中的事件处理器组成。

设计集成粘合剂 API

设计集成粘合剂的第一步是决定它为领域逻辑提供哪些 API。根据你是查询数据还是更新数据,你可以从几种不同的接口风格中进行选择。假设你正在处理Delayed Delivery Service,该服务需要从单体应用中检索客户联系信息。服务的业务逻辑不需要知道集成粘合剂用来检索信息的 IPC 机制。因此,该机制应该被一个接口封装。因为Delayed Delivery Service正在查询数据,所以定义一个CustomerContactInfoRepository是有意义的:

interface CustomerContactInfoRepository {
  CustomerContactInfo findCustomerContactInfo(long customerId)
}

服务的业务逻辑可以调用这个 API,而无需知道集成粘合剂如何检索数据。

让我们考虑一个不同的服务。想象一下,你正在从 FTGO 单体中提取配送管理。单体需要调用 Delivery Service 来安排、重新安排和取消配送。再次强调,底层 IPC 机制的细节对业务逻辑来说并不重要,应该由接口封装。在这种情况下,单体必须调用服务操作,因此使用存储库没有意义。更好的方法是定义一个服务接口,如下所示:

interface DeliveryService {
  void scheduleDelivery(...);
  void rescheduleDelivery(...);
  void cancelDelivery(...);
}

单体的业务逻辑调用此 API,而不知道它是如何由集成粘合剂实现的。

既然我们已经看到了接口设计,让我们来看看交互样式和 IPC 机制。

选择交互样式和 IPC 机制

在设计集成粘合剂时,你必须做出的一个重要设计决策是选择使服务和单体协作的交互样式和 IPC 机制。如第三章所述(kindle_split_011.xhtml#ch03),有几种交互样式和 IPC 机制可供选择。你应该使用哪一种取决于一方——服务或单体——需要什么来查询或更新另一方。

如果一方需要查询另一方的数据,有几个选择。一个选项是,如图 13.9 所示,实现存储库接口的适配器调用数据提供者的 API。这个 API 通常使用请求/响应交互样式,如 REST 或 gRPC。例如,Delayed Delivery Service 可能通过调用由 FTGO 单体实现的 REST API 来检索客户联系信息。

图 13.9. 实现存储库接口的适配器调用单体的 REST API 来检索客户信息。

图 13.9

在这个例子中,Delayed Delivery Service 的领域逻辑通过调用 CustomerContactInfoRepository 接口来检索客户联系信息。该接口的实现调用单体的 REST API。

通过调用查询 API 查询数据的一个重要好处是其简单性。主要缺点是它可能效率低下。消费者可能需要发出大量请求。提供者可能返回大量数据。另一个缺点是它减少了可用性,因为它是一种同步的 IPC。因此,使用查询 API 可能不切实际。

一种替代方法是数据消费者维护数据的副本,如图 13.10 所示。副本本质上是一个 CQRS 视图。数据消费者通过订阅数据提供者发布的领域事件来保持副本的更新。

图 13.10. 集成粘合剂从单体复制数据到服务。单体发布领域事件,由服务实现的事件处理器更新服务的数据库。

图 13.10

使用副本有几个好处。它避免了反复查询数据提供者的开销。相反,正如在描述第七章中的 CQRS 时讨论的那样,你可以设计副本以支持高效的查询。然而,使用副本的一个缺点是维护它的复杂性。本节后面将描述的一个潜在挑战是需要修改单体以发布领域事件。

既然我们已经讨论了如何进行查询,让我们考虑如何进行更新。执行更新的一个挑战是需要维护服务和单体之间的数据一致性。发起更新请求的方(请求者)已经更新或需要更新其数据库。因此,确保两个更新都发生至关重要。解决方案是服务和单体通过框架实现的交易性消息进行通信,例如 Eventuate Tram。在简单场景中,请求者可以发送通知消息或发布事件来触发更新。在更复杂场景中,请求者必须使用一个叙事来维护数据一致性。第 13.3.2 节讨论了使用叙事的后果。

实现反腐败层

想象一下,你正在作为一项全新的服务实现一个新功能。由于不受单体代码库的限制,你可以使用现代开发技术,如领域驱动设计(DDD),并开发一个全新的领域模型。此外,由于 FTGO 单体领域的定义不佳且有些过时,你可能会以不同的方式建模概念。因此,你的服务领域模型将具有不同的类名、字段名和字段值。例如,Delayed Delivery Service 有一个专注于特定责任的 Delivery 实体,而 FTGO 单体则有一个具有过多责任的 Order 实体。由于这两个领域模型不同,你必须实现 DDD 所说的反腐败层(ACL),以便服务能与单体通信。

模式:反腐败层

一种软件层,用于在两个不同的领域模型之间进行转换,以防止一个模型的概念污染另一个模型。请参阅microservices.io/patterns/refactoring/anti-corruption-layer.html

反腐败层的目的是防止遗留单体领域模型污染服务领域模型。它是一层代码,在不同的领域模型之间进行转换。例如,如图 13.11 所示,Delayed Delivery Service 有一个 CustomerContactInfoRepository 接口,该接口定义了一个返回 CustomerContactInfofindCustomerContactInfo() 方法。实现 CustomerContactInfoRepository 接口的类必须在 Delayed Delivery Service 的通用语言和 FTGO 单体之间的语言进行转换。

图 13.11。调用单体应用的服务适配器必须在服务的领域模型和单体应用的领域模型之间进行转换。

图片

findCustomerContactInfo() 方法的实现调用 FTGO 单体应用以检索客户信息,并将响应转换为CustomerContactInfo。在这个例子中,转换相当简单,但在其他场景中可能会非常复杂,例如涉及映射状态代码等值。

消费领域事件的领域事件订阅者也有一个访问控制列表(ACL)。领域事件是发布者领域模型的一部分。事件处理器必须将领域事件转换为订阅者的领域模型。例如,如图 13.12 所示,FTGO 单体应用发布Order领域事件。Delivery Service有一个订阅这些事件的处理器。

图 13.12。事件处理器必须从事件发布者的领域模型转换为订阅者的领域模型。

图片

事件处理器必须将单体应用的领域语言中的领域事件转换为Delivery Service的语言。它可能需要映射类和属性名称,以及可能属性值。

不仅服务使用反腐败层。单体应用在调用服务和订阅由服务发布的领域事件时,也会使用 ACL。FTGO 单体应用通过向Delivery Service发送通知消息来安排交付。它通过在DeliveryService接口上调用方法来发送通知。实现类将其参数转换为Delivery Service能够理解的消息。

单体应用如何发布和订阅领域事件

领域事件是一个重要的协作机制。对于新开发的服务来说,发布和消费事件是直接的。它可以使用第三章中描述的机制之一,例如 Eventuate Tram 框架。服务甚至可以使用第六章中描述的事件溯源来发布事件。然而,将单体应用改为发布和消费事件可能具有挑战性。让我们看看原因。

一个单体应用可以通过几种不同的方式发布领域事件。一种方法就是使用服务所使用的相同的领域事件发布机制。你需要在代码中找到所有改变特定实体的地方,并插入对事件发布 API 的调用。这种方法的问题在于改变单体应用并不总是容易的。定位所有这些地方并插入发布事件的调用可能会很耗时,且容易出错。更糟糕的是,单体应用的一些业务逻辑可能由无法轻松发布领域事件的存储过程组成。

另一种方法是在数据库级别发布领域事件。例如,你可以使用事务逻辑尾随或轮询,这些内容在第三章中有描述。使用事务尾随的一个关键好处是,你不需要改变单体应用。在数据库级别发布事件的缺点是,通常很难确定更新的原因并发布适当的高级业务事件。因此,服务通常会发布表示表变更的事件,而不是业务实体的事件。

幸运的是,对于单体应用来说,通常更容易订阅作为服务发布的领域事件。很多时候,你可以使用框架,如 Eventuate Tram,来编写事件处理器。但有时,单体应用订阅事件甚至具有挑战性。例如,单体应用可能使用的是没有消息代理客户端的语言。在这种情况下,你需要编写一个小型的“辅助”应用程序来订阅事件并直接更新单体应用的数据库。

现在我们已经探讨了如何设计集成胶水,使服务和单体应用能够协作,让我们看看在迁移到微服务时可能会遇到的另一个挑战:在服务和单体应用之间维护数据一致性。

13.3.2. 在服务和单体应用之间维护数据一致性

当你开发一个服务时,你可能会发现很难在服务和单体应用之间维护数据一致性。服务操作可能需要更新单体应用中的数据,或者单体应用操作可能需要更新服务中的数据。例如,想象一下你从单体应用中提取了“厨房服务”。你需要更改单体应用的订单管理操作,如createOrder()cancelOrder(),以使用 sagas 来保持“票据”与“订单”的一致性。

然而,使用 sagas 的问题在于,单体应用可能不愿意参与。如第四章所述,sagas 必须使用补偿事务来撤销更改。例如,“创建订单 sagas”包括一个补偿事务,如果“厨房服务”拒绝订单,则将订单标记为已拒绝。在单体应用中使用补偿事务的问题是你可能需要做出许多耗时且复杂的更改来支持它们。单体应用可能还需要实施对策来处理 sagas 之间缺乏隔离的问题。这些代码更改的成本可能是提取服务的一个巨大障碍。

关键 sagas 术语

我在第四章中介绍了 sagas。以下是一些关键术语:

  • Saga 通过异步消息协调的一系列本地事务。

  • 补偿事务 撤销本地事务所做的更新的交易。

  • 对策—— 用于处理故事(sagas)之间隔离不足的设计技术。

  • 语义锁—— 在由故事(saga)更新的记录中设置标志的一种对策。

  • 可补偿事务—— 需要补偿事务的事务,因为叙事(saga)中跟随它的某个事务可能会失败。

  • 枢纽事务—— 一个决定故事(saga)是否执行的事务。如果它成功,那么故事(saga)将运行到完成。

  • 可重试事务—— 跟随枢纽事务并保证成功的事务。

幸运的是,许多故事(sagas)的实现都很直接。如第四章第四章所述,如果单体架构的事务要么是枢纽事务要么是可重试事务,那么实现故事(sagas)应该很简单。你甚至可以通过仔细排序服务提取的顺序来简化实现,这样单体架构的事务就永远不需要是可补偿的。或者,改变单体架构以支持补偿事务可能相对困难。为了理解为什么在单体架构中实现补偿事务有时具有挑战性,让我们看看一些例子,从特别棘手的一个开始。

将单体架构改为支持可补偿事务的挑战

让我们深入探讨当你从单体架构中提取厨房服务时需要解决的补偿事务问题。这个重构涉及将订单实体拆分并在厨房服务中创建一个票证实体。它影响了单体架构实现的许多命令,包括createOrder()

单体架构将createOrder()命令实现为一个包含以下步骤的单个 ACID 事务:

  1. 验证订单详情。

  2. 验证消费者能否下单。

  3. 授权消费者的信用卡。

  4. 创建一个订单

你需要用以下步骤组成的叙事(saga)来替换这个 ACID 事务:

  1. 在单体架构中

    • 审批待定状态下创建一个订单

    • 验证消费者能否下单。

  2. 厨房服务

    • 验证订单详情。

    • 创建待定状态下创建一个票证

  3. 在单体架构中

    • 授权消费者的信用卡。

    • 订单的状态改为已批准

  4. 厨房服务

    • 票证的状态改为等待接受

这个故事与第四章中描述的CreateOrderSaga类似。第四章。它由四个本地事务组成,两个在单体架构中,两个在厨房服务中。第一个事务在审批待定状态下创建一个订单。第二个事务在创建待定状态下创建一个票证。第三个事务授权消费者的信用卡并将订单状态改为已批准。第四个也是最后一个事务将票证的状态改为等待接受

实现这个 Saga 的挑战在于,第一步,即创建Order,必须是可补偿的。这是因为第二个本地事务,发生在Kitchen Service中,可能会失败并要求单体应用撤销第一个本地事务所做的更新。因此,Order实体需要有一个APPROVAL_PENDING,一个在第四章中描述的语义锁定对策,它表明Order正在创建过程中。

引入新的Order实体状态的问题在于,这可能会要求对单体应用进行广泛的更改。你可能需要更改代码中所有触及Order实体的地方。对单体应用进行这类广泛的更改既耗时又不是开发资源的最佳投资。此外,这也可能存在风险,因为单体应用通常难以测试。

Saga 不一定需要单体应用来支持可补偿的事务。

Saga 具有高度领域特定性。有些,例如我们刚刚看到的,需要单体应用来支持补偿性事务。但完全有可能,当你提取一个服务时,你可能能够设计出不需要单体应用实现补偿性事务的 Saga。这是因为单体应用只需要支持补偿性事务,如果跟随单体应用事务的事务可能会失败。如果单体应用中的每个事务要么是关键事务要么是可重试事务,那么单体应用就永远不需要执行补偿性事务。因此,你只需要对单体应用进行最小程度的更改以支持 Saga。

例如,想象一下,如果你不是提取Kitchen Service,而是提取Order Service。这次重构涉及将Order实体拆分,并在Order Service中创建一个精简的Order实体。它还影响了包括createOrder()在内的许多命令,该命令从单体应用移动到Order Service。为了提取Order Service,你需要更改createOrder()命令以使用 saga,按照以下步骤进行:

  1. Order Service

    • APPROVAL_PENDING状态下创建一个Order
  2. 单体应用

    • 验证消费者可以下订单。

    • 验证订单详情并创建一个Ticket

    • 授权消费者的信用卡。

  3. Order Service

    • Order的状态更改为APPROVED

这个 Saga 由三个本地事务组成,一个在单体应用中,两个在Order Service中。第一个事务,在Order Service中,创建了一个处于APPROVAL_PENDING状态的Order。第二个事务,在单体应用中,验证消费者可以下订单,授权他们的信用卡并创建一个Ticket。第三个事务,在Order Service中,将Order的状态更改为APPROVED

单体的事务是 saga 的枢纽事务——saga 的不可逆转点。如果单体的事务完成,那么 saga 将运行至完成。只有这个 saga 的前两个步骤可能会失败。第三个事务不能失败,因此单体中的第二个事务永远不会需要回滚。因此,支持补偿性事务的所有复杂性都在订单服务中,这使得它比单体更容易测试。

如果在提取服务时需要编写的所有 sagas 都具有这种结构,那么您需要对单体进行更少的更改。更重要的是,您可以仔细地排序服务提取,以确保单体的事务要么是枢纽事务,要么是可重试事务。让我们看看如何做到这一点。

对服务提取进行排序以避免在单体中实现补偿性事务。

正如我们刚才看到的,提取厨房服务需要单体实现补偿性事务,而提取订单服务则不需要。这表明提取服务的顺序很重要。通过仔细排序服务提取,您可以避免需要对单体进行广泛的修改以支持补偿性事务。我们可以确保单体的事务要么是枢纽事务,要么是可重试事务。例如,如果我们首先从 FTGO 单体中提取订单服务,然后提取消费者服务,提取厨房服务将变得简单。让我们更详细地看看如何做到这一点。

一旦我们提取了消费者服务createOrder()命令使用以下 saga:

  1. 订单服务: 在APPROVAL_PENDING状态下创建一个订单

  2. 消费者服务: 验证消费者能否下订单。

  3. 单体

    • 验证订单详情并创建一个票据

    • 授权消费者的信用卡。

  4. 订单服务: 将订单的状态更改为APPROVED

在这个 saga 中,单体的事务是枢纽事务。订单服务实现补偿性事务。

现在我们已经提取了消费者服务,我们可以提取厨房服务。如果我们提取这个服务,createOrder()命令将使用以下 saga:

  1. 订单服务: 在APPROVAL_PENDING状态下创建一个订单

  2. 消费者服务: 验证消费者能否下订单。

  3. 厨房服务: 验证订单详情并创建一个PENDING票据

  4. 单体:授权消费者的信用卡。

  5. 厨房服务: 将票据的状态更改为APPROVED

  6. 订单服务: 将订单的状态更改为APPROVED

在这个 saga 中,单体的事务仍然是枢纽事务。订单服务厨房服务实现补偿性事务。

我们甚至可以通过提取会计服务来继续重构单体。如果我们提取这个服务,createOrder()命令将使用以下 saga:

  1. Order Service:创建一个处于 APPROVAL_PENDING 状态的 Order

  2. Consumer Service:验证消费者能否下订单。

  3. Kitchen Service:验证订单详情并创建一个 PENDING 的 Ticket

  4. Accounting Service:授权消费者的信用卡。

  5. Kitchen Service:将 Ticket 的状态更改为 APPROVED

  6. Order Service:将 Order 的状态更改为 APPROVED

如您所见,通过仔细排序提取,您可以避免使用需要修改单体应用的复杂 sagas。现在让我们看看如何在迁移到微服务架构时处理安保问题。

13.3.3. 处理身份验证和授权

当将单体应用重构为微服务架构时,需要解决的另一个设计问题是将单体应用的安保机制适应以支持服务。第十一章 描述了如何在微服务架构中处理安保问题。基于微服务的应用程序使用令牌,例如 JSON Web 令牌 (JWT),来传递用户身份。这与典型的传统单体应用大不相同,后者使用内存中的会话状态并通过线程局部传递用户身份。将单体应用转换为微服务架构的挑战在于,你需要同时支持单体和基于 JWT 的安保机制。

幸运的是,有一个简单直接的方法可以解决这个问题,只需对单体应用的登录请求处理程序进行一个小改动。图 13.13 展示了这是如何工作的。登录处理程序返回一个额外的 cookie,在这个例子中我称之为 USERINFO,它包含用户信息,如用户 ID 和角色。浏览器在每次请求中都包含该 cookie。API 网关从 cookie 中提取信息,并将其包含在它向服务发出的 HTTP 请求中。因此,每个服务都可以访问所需用户信息。

图 13.13 说明

事件序列如下:

  1. 客户端发出包含用户凭证的登录请求。

  2. API Gateway 将登录请求路由到 FTGO 单体应用。

  3. 单体应用返回一个包含 JSESSIONID 会话 cookie 和 USERINFO cookie 的响应,其中包含用户信息,如 ID 和角色。

  4. 客户端发出一个包含 USERINFO cookie 的请求,以调用一个操作。

  5. API Gateway 验证 USERINFO cookie,并将其包含在它向服务发出的请求的 Authorization 头中。服务验证 USERINFO 令牌并提取用户信息。

让我们更详细地看看 LoginHandlerAPI Gateway

LoginHandler处理用户的凭证的POST请求。它验证用户并将在会话中存储有关用户的信息。这通常由安全框架实现,例如 Spring Security 或 NodeJS 的 Passport。如果应用程序配置为使用默认的内存会话,HTTP 响应将设置一个会话 cookie,例如JSESSIONID。为了支持迁移到微服务,LoginHandler还必须设置包含描述用户的 JWT 的USERINFOcookie。

如第八章所述,API 网关负责请求路由和 API 组合。它通过向单体和各个服务发送一个或多个请求来处理每个请求。当 API 网关调用一个服务时,它会验证USERINFOcookie 并将其传递给服务,在 HTTP 请求的Authorization头中。通过将 cookie 映射到Authorization头,API 网关确保以标准方式将用户身份传递给服务,这种方式与客户端的类型无关。

最终,我们很可能会将登录和用户管理提取到服务中。但正如你所看到的,通过仅对单体登录处理程序进行一个小改动,现在服务就可以访问用户信息了。这使得你可以专注于开发为业务提供最大价值的服务,并推迟提取价值较低的服务,例如用户管理。

现在我们已经了解了在重构到微服务时如何处理安全问题,让我们来看一个将新功能作为服务实现的示例。

13.4. 将新功能作为服务实现:处理误送订单

假设你被分配去改进 FTGO 处理误送订单的方式。越来越多的客户抱怨客户服务如何处理未能送达的订单。大多数订单都能按时送达,但有时订单要么送达晚,要么根本没送达。例如,快递员因意外糟糕的交通而延误,所以订单被取走并晚些时候送达。或者,也许当快递员到达餐厅时,餐厅已经关门,无法完成配送。更糟糕的是,客户服务第一次听说误送是在收到一个愤怒的客户发来的电子邮件时。

一个真实的故事:我丢失的冰淇淋

一个周六晚上,我感到很懒,使用一个知名的食品配送应用从 Smitten 订购了冰淇淋,但从未送达。公司唯一的沟通是在第二天早上发来的电子邮件,说我的订单已被取消。我还接到一个来自非常困惑的客户服务代表的语音邮件,显然她不知道她为什么要打电话。也许这个电话是由我的一条推文触发的,描述了发生的事情。显然,配送公司没有建立任何处理不可避免的错误的机制。

许多配送问题的根本原因是 FTGO 应用使用的原始配送调度算法。一个更复杂的调度器正在开发中,但还需要几个月才能完成。临时解决方案是 FTGO 通过向客户道歉,并在某些情况下在客户投诉之前提供补偿,来主动处理延迟或取消的订单。

你的任务是实现一个新功能,该功能将执行以下操作:

  1. 当订单无法按时交付时,通知客户。

  2. 当订单因为无法在餐厅关门前取货而无法交付时,通知客户。

  3. 当订单无法按时交付时,通知客户服务,以便他们可以通过补偿客户来主动纠正情况。

  4. 跟踪配送统计数据。

这个新功能相当简单。新的代码必须跟踪每个 Order 的状态,如果 Order 无法按承诺交付,代码必须通过例如发送电子邮件的方式通知客户和客户支持。

但你应该如何——或者更确切地说,在哪里——实现这个新功能呢?一种方法是在单体中实现一个新的模块。那里的问题是开发和测试此代码将很困难。更重要的是,这种方法增加了单体的大小,从而使单体地狱变得更加糟糕。记住之前提到的洞穴法则:当你陷入洞穴时,最好的办法是停止挖掘。与其使单体更大,不如将这些新功能作为服务来实现是一个更好的方法。

13.4.1. 延迟配送服务的设计

我们将把这个功能实现为一个名为 Delayed Order Service 的服务。图 13.14 展示了在实现此服务后 FTGO 应用的架构。该应用由 FTGO 单体应用、新的 Delayed Delivery Service 和一个 API Gateway 组成。Delayed Delivery Service 有一个 API,定义了一个名为 getDelayedOrders() 的单一查询操作,该操作返回当前延迟或无法交付的订单。API GatewaygetDelayedOrders() 请求路由到服务,并将所有其他请求路由到单体。集成胶水为 Delayed Order Service 提供了对单体数据的访问。

图 13.14. Delayed Delivery Service 的设计。集成粘合剂为 Delayed Delivery Service 提供了对单体拥有的数据的访问权限,例如 OrderRestaurant 实体以及客户联系信息。

图 13.14

Delayed Order Service 的领域模型由各种实体组成,包括 DelayedOrderNotificationOrderRestaurant。核心逻辑由 DelayedOrderService 类实现。它由定时器定期调用以查找无法按时交付的订单。它是通过查询 OrdersRestaurants 来做到这一点的。如果一个 Order 无法按时交付,DelayedOrderService 会通知消费者和客户服务。

Delayed Order Service 不拥有 OrderRestaurant 实体。相反,这些数据是从 FTGO 单体复制的。更重要的是,该服务不存储客户联系信息,而是从单体中检索它。让我们看看为 Delayed Order Service 提供单体数据访问权限的集成粘合剂的设计。

13.4.2. 为 Delayed Delivery Service 设计集成粘合剂

即使实现新功能的服务的定义了自己的实体类,它通常也会访问单体拥有的数据。Delayed Delivery Service 也不例外。它有一个 DelayedOrderNotification 实体,代表它发送给消费者的通知。但正如我刚才提到的,它的 OrderRestaurant 实体复制了 FTGO 单体的数据。它还需要查询用户联系信息以便通知用户。因此,我们需要实现集成粘合剂,使 Delivery Service 能够访问单体的数据。

图 13.15 展示了集成粘合剂的设计。FTGO 单体发布 OrderRestaurant 领域事件。Delivery Service 消费这些事件并更新其那些实体的副本。FTGO 单体实现了一个用于查询客户联系信息的 REST 端点。Delivery Service 在需要通知用户他们的订单无法按时交付时调用此端点。

图 13.15. 集成粘合剂为 Delayed Delivery Service 提供了对单体拥有的数据的访问权限。

图 13.15

让我们看看集成每个部分的设计,从检索客户联系信息的 REST API 开始。

使用 CustomerContactInfoRepository 查询客户联系信息

如 第 13.3.1 节 所述,服务如 Delayed Delivery Service 读取单体应用数据的方式有几种。最简单的方法是 Delayed Order Service 使用单体应用的查询 API 来检索数据。当检索 User 联系信息时,这种方法是合理的。由于 Delayed Delivery Service 很少需要检索用户的联系信息,且数据量相当小,因此不存在延迟或性能问题。

CustomerContactInfoRepository 是一个接口,它使 Delayed Delivery Service 能够检索消费者的联系信息。它通过一个 CustomerContactInfoProxy 实现,该代理通过调用单体应用的 getCustomerContactInfo() REST 端点来检索用户信息。

发布和消费订单和餐厅领域事件

不幸的是,对于 Delayed Delivery Service 来说,查询单体应用以获取所有开放 OrdersRestaurant 小时的状态并不实用。这是因为反复在网络中传输大量数据效率低下。因此,Delayed Delivery Service 必须使用第二种更复杂的方法,通过订阅单体发布的事件来维护 OrdersRestaurants 的副本。重要的是要记住,副本并不是单体数据的一个完整副本——它只存储 OrderRestaurant 实体属性的一个小子集。

如前所述 第 13.3.1 节,我们可以通过几种不同的方式修改 FTGO 单体应用,使其发布 OrderRestaurant 领域事件。一种选择是修改单体应用中所有更新 OrdersRestaurants 的地方,以发布高级领域事件。第二种选择是跟踪事务日志以将更改作为事件进行复制。在这个特定场景中,我们需要同步两个数据库。我们不需要 FTGO 单体应用发布高级领域事件,所以两种方法都可以。

Delayed Order Service 实现了事件处理器,这些处理器订阅来自单体应用的事件,并更新其 OrderRestaurant 实体。事件处理器的细节取决于单体是否发布特定的低级事件或高级事件。在任一情况下,你可以将事件处理器视为将单体应用边界上下文中的事件转换为服务边界上下文中实体的更新。

使用副本的一个重要好处是它使Delayed Order Service能够高效地查询订单和餐厅营业时间。然而,一个缺点是它更复杂。另一个缺点是它需要单体发布必要的OrderRestaurant事件。幸运的是,因为Delayed Delivery Service只需要ORDERSRESTAURANT表的基本子集列,我们不应该遇到第 13.3.1 节中描述的问题。

将新功能如延迟订单管理作为一个独立服务实现可以加速其开发、测试和部署。更重要的是,它允许你使用全新的技术栈而不是单体较旧的技术栈来实现该功能。它还可以阻止单体继续增长。延迟订单管理只是 FTGO 应用程序计划中的许多新功能之一。FTGO 团队可以将这些功能中的许多作为独立服务实现。

不幸的是,你不能将所有更改都作为新服务实现。很多时候,你必须对单体进行大量更改来实现新功能或更改现有功能。任何涉及单体的开发都可能很慢且痛苦。如果你想加速这些功能的交付,你必须通过将功能从单体迁移到服务中来拆分单体。让我们看看如何做到这一点。

13.5. 拆分单体:提取配送管理

为了加速实现单体应用的功能交付,你需要将单体应用拆分成服务。例如,让我们设想你希望通过实现一个新的路由算法来增强 FTGO 的配送管理。开发配送管理的一个主要障碍是它与订单管理纠缠在一起,并且是单体代码库的一部分。开发和部署配送管理可能会很慢。为了加速其开发,你需要将配送管理提取为一个Delivery Service

我在这个部分开始描述配送管理以及它目前如何在单体中嵌入。接下来,我讨论新的独立Delivery Service及其 API 的设计。然后,我描述Delivery Service和 FTGO 单体如何协作。最后,我谈谈我们需要对单体进行的一些更改以支持Delivery Service

让我们先从回顾现有设计开始。

13.5.1. 现有配送管理功能概述

配送管理负责安排在餐厅取订单并将其递送给消费者的快递员。每个快递员都有一个计划,即取货和配送行动的日程表。一个取货行动告诉Courier在特定时间从餐厅取订单。一个配送行动告诉Courier将订单递送给消费者。每当下单、取消或修改订单,以及快递员的位置和可用性发生变化时,计划都会进行修订。

配送管理是 FTGO 应用程序中最古老的组成部分之一。如图 13.16 所示,它嵌入在订单管理中。管理配送的大部分代码都在OrderService中。更重要的是,没有显式地表示Delivery。它嵌入在Order实体中,该实体包含各种与配送相关的字段,例如scheduledPickupTimescheduledDeliveryTime

图 13.16。配送管理在 FTGO 单体中与订单管理交织在一起。

单体实现了许多命令,包括以下内容调用配送管理:

  • acceptOrder() 当餐厅接受订单并承诺在特定时间内准备时调用。此操作调用配送管理以安排配送。

  • cancelOrder() 当消费者取消订单时调用。如果需要,它将取消配送。

  • noteCourierLocationUpdated() 由快递员的移动应用程序调用以更新快递员的位置。它触发了配送的重新安排。

  • noteCourierAvailabilityChanged() 由快递员的移动应用程序调用以更新快递员的可用性。它触发了配送的重新安排。

此外,各种查询检索由配送管理维护的数据,包括以下内容:

  • getCourierPlan() 由快递员的移动应用程序调用并返回快递员的计划。

  • getOrderStatus() 返回订单的状态,包括与配送相关的信息,例如指定的快递员和预计到达时间(ETA)。

  • getOrderHistory() 返回与getOrderStatus()类似的信息,但关于多个订单。

很常见的是,提取到服务中的是,如第 13.2.3 节中提到的,一个完整的垂直切片,顶部是控制器,底部是数据库表。我们可以考虑与Courier相关的命令和查询是配送管理的一部分。毕竟,配送管理创建快递员计划,并且是Courier位置和可用信息的主要消费者。但为了最小化开发工作量,我们将保留这些操作在单体中,并仅提取算法的核心。因此,Delivery Service的第一迭代不会公开提供 API。相反,它将仅由单体调用。接下来,让我们探索Delivery Service的设计。

13.5.2. 配送服务概述

建议的新Delivery Service负责调度、重新调度和取消交付。图 13.17 显示了提取Delivery Service后的 FTGO 应用程序的架构视图。该架构由 FTGO 单体和Delivery Service组成。它们通过服务中的 API 和单体中的 API 组成的集成胶水进行协作。Delivery Service有自己的领域模型和数据库。

图 13.17. 提取Delivery Service后的 FTGO 应用程序的高级视图。FTGO 单体和Delivery Service通过它们各自的 API 组成的集成胶水进行协作。需要做出的两个关键决策是哪些功能和数据被移动到Delivery Service,以及单体和Delivery Service如何通过 API 进行协作?

为了完善这个架构并确定服务的领域模型,我们需要回答以下问题:

  • 哪些行为和数据被移动到Delivery Service

  • Delivery Service向单体暴露了哪些 API?

  • 该单体向Delivery Service暴露了哪些 API?

这些问题是相互关联的,因为单体和服务之间责任分配的分布会影响 API。例如,Delivery Service将需要调用单体提供的 API 来访问单体数据库中的数据,反之亦然。稍后,我将描述使Delivery Service和 FTGO 单体协作的集成胶水的结构。但首先,让我们看看Delivery Service的领域模型设计。

13.5.3. 设计Delivery Service领域模型

为了能够提取交付管理,我们首先需要识别实现它的类。一旦我们做到了这一点,我们就可以决定哪些类要移动到Delivery Service以形成其领域逻辑。在某些情况下,我们需要拆分类。我们还需要决定在服务与单体之间复制哪些数据。

让我们先识别实现交付管理的类。

确定哪些实体及其字段是交付管理的一部分

设计Delivery Service的第一步是仔细审查交付管理代码,并确定参与实体及其字段。图 13.18 显示了交付管理的一部分实体和字段。一些字段是交付调度算法的输入,而其他字段是输出。该图显示了哪些字段也被单体实现的其他功能使用。

图 13.18. 由单体实现的交付管理和其他功能访问的实体和字段。字段可以被读取或写入,或者两者都可以。它可以被交付管理、单体或两者访问。

配送调度算法读取各种属性,包括 OrderrestaurantpromisedDeliveryTimedeliveryAddress,以及 Courierlocationavailability 和当前计划。它更新 Courier 的计划、OrderscheduledPickupTimescheduledDeliveryTime。如您所见,配送管理使用的字段也被单体应用使用。

决定哪些数据迁移到 Delivery Service

既然我们已经确定了参与配送管理的实体和字段,下一步就是决定将哪些实体和字段移动到服务中。在理想情况下,服务访问的数据仅由服务使用,因此我们可以简单地将这些数据移动到服务中并完成。遗憾的是,这种情况很少见,这种情况也不例外。配送管理使用的所有实体和字段也被单体应用的其他功能使用。

因此,在确定要将哪些数据移动到服务中时,我们需要考虑两个问题。第一个问题是:服务如何访问单体应用中保留的数据?第二个问题是:单体应用如何访问已移动到服务中的数据?此外,如前文在 第 13.3 节 中所述,我们需要仔细考虑如何维护服务与单体应用之间的数据一致性。

Delivery Service 的基本责任是管理快递计划并更新 OrderscheduledPickupTimescheduledDeliveryTime 字段。因此,拥有这些字段是有意义的。我们还可以将 Courier.locationCourier.availability 字段移动到 Delivery Service。但由于我们正在尝试进行尽可能小的更改,因此我们将暂时保留这些字段在单体应用中。

Delivery Service 领域逻辑的设计

图 13.19 展示了 Delivery Service 的领域模型设计。服务的核心由 DeliveryCourier 等领域类组成。DeliveryServiceImpl 类是进入配送管理业务逻辑的入口点。它实现了 DeliveryServiceCourierService 接口,这些接口将由后续章节中描述的 DeliveryServiceEventsHandlerDeliveryServiceNotificationsHandlers 调用。

图 13.19. Delivery Service 领域模型的设计

图 13.19 的替代文本

配送管理业务逻辑主要是从单体应用中复制过来的代码。例如,我们将从单体应用中复制 Order 实体到 Delivery Service,并将其重命名为 Delivery,删除所有除配送管理使用的字段外的所有字段。我们还将复制 Courier 实体并删除其大部分字段。为了开发 Delivery Service 的领域逻辑,我们需要将代码从单体应用中解耦。我们需要打破许多依赖关系,这可能会很耗时。再次强调,使用静态类型语言重构代码要容易得多,因为编译器将成为你的朋友。

Delivery Service 不是一个独立的服务。让我们看看使 Delivery Service 和 FTGO 单体应用协作的集成粘合剂的设计。

13.5.4. Delivery Service 集成粘合剂的设计

FTGO 单体应用需要调用 Delivery Service 来管理配送。单体应用还需要与 Delivery Service 交换数据。这种协作是通过集成粘合剂实现的。图 13.20 展示了 Delivery Service 集成粘合剂的设计。Delivery Service 有一个配送管理 API。该服务与 FTGO 单体应用通过交换领域事件同步数据。

图 13.20. Delivery Service 集成粘合剂的设计。Delivery Service 有一个配送管理 API。该服务和 FTGO 单体应用通过交换领域事件同步数据。

让我们看看集成粘合剂的每个部分的设计,从 Delivery Service 管理配送的 API 开始。

Delivery Service API 的设计

Delivery Service 必须提供一个 API,使单体应用能够安排、修改和取消配送。正如你在本书中看到的,首选的方法是使用异步消息传递,因为它促进了松散耦合并增加了可用性。一种方法是为 Delivery Service 订阅由单体应用发布的 Order 领域事件。根据事件类型,它创建、修改和取消一个 Delivery。这种方法的优点是单体应用不需要显式调用 Delivery Service。依赖于领域事件的缺点是它要求 Delivery Service 了解每个 Order 事件如何影响相应的 Delivery

更好的方法是让配送服务实现一个基于通知的 API,使单体可以明确地告诉配送服务创建、修改和取消配送。配送服务的 API 由一个消息通知通道和三种消息类型组成:ScheduleDeliveryReviseDeliveryCancelDelivery。通知消息包含配送服务所需的信息。例如,ScheduleDelivery通知包含取货时间和地点以及配送时间和地点。这种方法的一个重要好处是配送服务订单生命周期没有详细的了解。它完全专注于管理配送,并且对订单一无所知。

这个 API 并不是配送服务和 FTGO 单体协作的唯一方式。它们还需要交换数据。

配送服务如何访问 FTGO 单体数据

配送服务需要访问由单体拥有的快递员位置和可用性数据。由于这可能是一大量数据,服务反复查询单体并不实际。相反,更好的方法是由单体通过发布快递员领域事件,CourierLocationUpdatedCourierAvailabilityUpdated,将数据复制到配送服务配送服务有一个CourierEventSubscriber,它订阅领域事件并更新其快递员版本。它还可能触发配送的重新安排。

FTGO 单体如何访问配送服务数据

FTGO 单体需要读取已移动到配送服务中的数据,例如快递员计划。理论上,单体可以查询服务,但这需要对单体进行大量更改。目前,保持单体领域模型和数据库模式不变,并从服务中复制数据回单体更容易。

实现这一点的最简单方法是让配送服务发布快递员配送领域事件。当服务更新快递员的计划时,它会发布一个CourierPlanUpdated事件,当它更新配送时,它会发布一个DeliveryScheduleUpdate事件。单体消费这些领域事件并更新其数据库。

现在我们已经了解了 FTGO 单体和配送服务的交互方式,让我们看看如何修改单体。

13.5.5. 将 FTGO 单体改为与配送服务交互

在许多方面,实现配送服务是提取过程中的较简单部分。修改 FTGO 单体要困难得多。幸运的是,将服务中的数据复制回单体可以减少更改的大小。但我们需要修改单体以通过调用配送服务来管理配送。让我们看看如何做到这一点。

定义 DeliveryService 接口

第一步是将配送管理代码封装在一个与之前定义的消息基础 API 对应的 Java 接口中。如图 13.21 所示的该接口定义了用于安排、重新安排和取消配送的方法。最终,我们将使用发送消息到配送服务的代理来实现这个接口。但最初,我们将使用一个调用配送管理代码的类来实现这个 API。

图 13.21. 第一步是定义 DeliveryService,它是一个粗粒度、可远程调用的 API,用于调用配送管理逻辑。

图片

DeliveryService 接口是一个粗粒度接口,非常适合由 IPC 机制实现。它定义了 schedule()reschedule()cancel() 方法,这些方法对应于之前定义的通知消息类型。

重构单块以调用 DeliveryService 接口

接下来,如图 13.22 所示,我们需要识别 FTGO 单块中所有调用配送管理的位置,并将它们更改为使用 DeliveryService 接口。这可能需要一些时间,并且是提取服务从单块中的一项最具挑战性的工作。

图 13.22. 第二步是将 FTGO 单块更改为通过 DeliveryService 接口调用配送管理。

图片

如果单块是用静态类型语言编写的,例如 Java,这肯定有帮助,因为工具在识别依赖关系方面做得更好。如果不是这样,那么希望您有一些自动化测试,足以覆盖需要更改的代码部分。

实现 DeliveryService 接口

最后一步是将 DeliveryServiceImpl 类替换为一个代理,该代理向独立的 Delivery Service 发送通知消息。但与其立即丢弃现有的实现,我们将使用如图 13.23 所示的设计,该设计使单块能够动态地在现有实现和 Delivery Service 之间切换。我们将使用一个使用动态功能开关的类来实现 DeliveryService 接口,以确定是否调用现有实现或 Delivery Service

图 13.23. 最后一步是实现 DeliveryService,使用一个发送消息到 Delivery Service 的代理类。一个功能开关控制 FTGO 单块是使用旧实现还是新的 Delivery Service

图片

使用功能开关可以显著降低推出 Delivery Service 的风险。我们可以部署 Delivery Service 并对其进行测试。然后,一旦我们确信它工作正常,我们可以翻转开关以将流量路由到它。如果我们随后发现 Delivery Service 并没有按预期工作,我们可以切换回旧实现。

关于功能开关

功能开关功能标志 允许你在不必向用户发布的情况下部署代码更改。它们还使你能够通过部署新代码来动态更改应用程序的行为。本文由马丁·福勒撰写,对这一主题提供了极好的概述:martinfowler.com/articles/feature-toggles.html

一旦我们确认 配送服务 正如预期那样工作,我们就可以从单体应用中移除配送管理代码。

配送服务延迟订单服务 是 FTGO 团队在他们的微服务架构之旅中将要开发的服务示例。在实现这些服务后,他们下一步将走向何方取决于业务的优先级。一条可能的路径是提取 第七章 中描述的 订单历史服务。提取此服务部分消除了 配送服务 需要向单体应用复制数据的需求。

在实现 订单历史服务 之后,FTGO 团队可以接着提取 第 13.3.2 节 中描述的服务:订单服务消费者服务厨房服务 等等。随着 FTGO 团队提取每个服务,他们应用程序的可维护性和可测试性逐渐提高,他们的开发速度也在增加。

摘要

  • 在迁移到微服务架构之前,确保你的软件交付问题是由于你的单体架构已经过时是很重要的。你可能会通过改进你的软件开发流程来加速交付。

  • 通过增量开发一个“杀手应用”来迁移到微服务是很重要的。一个“杀手应用”是由微服务组成的新应用程序,你围绕现有的单体应用程序构建它。你应该尽早并经常展示价值,以确保业务支持迁移工作。

  • 将微服务引入你的架构的一个好方法是将新功能作为服务来实现。这样做可以让你快速、轻松地使用现代技术和开发流程开发一个功能。这是快速展示迁移到微服务价值的不错方式。

  • 将单体应用拆分的一种方法是将表示层与后端分离,这导致出现两个更小的单体应用。尽管这不是巨大的改进,但它确实意味着你可以独立部署每个单体应用。例如,这允许 UI 团队更容易地对 UI 设计进行迭代,而不会影响后端。

  • 拆分单体应用的主要方法是通过增量地将功能从单体迁移到服务中。关注提取提供最大利益的服务是很重要的。例如,如果你提取一个实现正在积极开发的功能的服务,这将加速开发。

  • 新开发的几乎总是需要与单体交互。一个服务通常需要访问单体数据并调用其功能。有时单体需要访问服务数据并调用其功能。为了实现这种协作,开发集成胶水,它由单体中的入站和出站适配器组成。

  • 为了防止单体领域模型污染服务领域模型,集成胶水应使用反腐败层,这是一个在领域模型之间进行转换的软件层。

  • 为了最小化提取服务对单体的影响,可以将移动到服务中的数据复制回单体数据库。因为单体架构保持不变,这消除了对单体代码库进行可能广泛更改的需要。

  • 开发服务通常需要实现涉及单体的 sagas。但是,实现需要广泛更改单体的可补偿事务可能具有挑战性。因此,有时需要仔细安排服务的提取,以避免在单体中实现可补偿事务。

  • 当重构到微服务架构时,你需要同时支持单体应用的现有安全机制,这通常基于内存会话,以及服务使用的基于令牌的安全机制。幸运的是,一个简单的解决方案是修改单体应用的登录处理器以生成包含安全令牌的 cookie,然后通过 API 网关转发给服务。

模式列表

应用架构模式

单体架构(40)

微服务架构(40)

分解模式

按业务能力分解(51)

按子域分解(54)

消息风格模式

消息(85)

远程过程调用(72)

可靠通信模式

断路器(78)

服务发现模式

第三方注册(85)

客户端发现(83)

自注册(82)

服务器端发现(85)

事务性消息模式

轮询发布者(98)

事务日志尾部(99)

事务性输出箱(98)

数据一致性模式

Saga(114)

业务逻辑设计模式

聚合(150)

领域事件(160)

领域模型(150)

事件溯源(184)

事务脚本(149)

查询模式

API 组合(223)

命令查询责任分离(228)

外部 API 模式

API 网关(259)

前端后端(265)

测试模式

消费者驱动合约测试(302)

消费者端合约测试(303)

服务组件测试(335)

安全模式

访问令牌(354)

横切关注点模式

外部化配置(361)

微服务框架(379)

可观察性模式

应用指标(373)

审计日志(377)

分布式跟踪(370)

异常跟踪(376)

健康检查 API(366)

日志聚合(368)

部署模式

将服务作为容器部署(393)

将服务作为虚拟机部署(390)

语言特定的打包格式(387)

服务网格(380)

无服务器部署(416)

侧车(410)

重构为微服务模式

防腐层(447)

Strangler 应用(432)

图片

快速、频繁且可靠地交付大型、复杂应用程序需要结合 DevOps,包括持续交付/部署、小型、自治团队和微服务架构。

图片

微服务架构将应用程序结构化为一组围绕业务能力组织松散耦合的服务。每个团队独立开发、测试和部署他们的服务。

posted @ 2025-11-24 09:12  绝不原创的飞龙  阅读(16)  评论(0)    收藏  举报