SpringBoot-和-SpringCloud-微服务实用指南-全-
SpringBoot 和 SpringCloud 微服务实用指南(全)
原文:
zh.annas-archive.org/md5/328F7FCE73118A0BA71B389914A67B52译者:飞龙
前言
本书介绍了使用 Spring Boot 和 Spring Cloud 构建生产就绪的微服务。五年前,当我开始探索微服务时,我一直在寻找这样的书。
在我学会并精通用于开发、测试、部署和管理协作微服务生态的开源软件之后,这本书才得以编写。
本书主要涵盖了 Spring Boot、Spring Cloud、Docker、Kubernetes、Istio、EFK 堆栈、Prometheus 和 Grafana。这些开源工具各自都很好用,但理解如何将它们以有利的方式结合起来可能会有挑战性。在某些领域,它们相互补充,但在其他领域,它们重叠,对于特定情况选择哪一个并不明显。
这是一本实用书籍,详细介绍了如何逐步使用这些开源工具。五年前,当我开始学习微服务时,我一直在寻找这样的书籍,但现在它涵盖了这些开源工具的最新版本。
本书面向人群
这本书面向希望学习如何将现有单体拆分为微服务并在本地或云端部署的 Java 和 Spring 开发者及架构师,使用 Kubernetes 作为容器编排器,Istio 作为服务网格。无需对微服务架构有任何了解即可开始本书的学习。
为了最大化本书的收益
需要对 Java 8 有深入了解,以及对 Spring Framework 有基本知识。此外,对分布式系统的挑战有一个大致的了解,以及对在生产环境中运行自己代码的一些经验,也将有益于学习。
下载示例代码文件
您可以从www.packt.com您的账户上下载本书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以按照以下步骤下载代码文件:
-
登录或注册www.packt.com。
-
选择“支持”选项卡。
-
点击“代码下载”。
-
在搜索框中输入书籍名称,然后按照屏幕上的指示操作。
文件下载完成后,请确保使用最新版本解压或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
本书的代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud。如有代码更新,它将在现有的 GitHub 仓库中更新。
我们还有其他丰富的书籍和视频目录中的代码包,托管在github.com/PacktPublishing/。去看看它们!
下载彩色图片
我们还提供了一个 PDF 文件,其中包含了本书中使用的屏幕截图/图表的颜色图像。您可以通过以下链接下载: static.packt-cdn.com/downloads/9781789613476_ColorImages.pdf。
代码在行动
若要查看代码的执行情况,请访问以下链接: bit.ly/2kn7mSp。
本书中使用了一些约定。
本书中使用了许多文本约定。
CodeInText:表示文本中的代码单词,数据库表名,文件夹名,文件名,文件扩展名,路径名,假 URL,用户输入和 Twitter 处理。这是一个示例:"要使用本地文件系统,配置服务器需要启动带有 Spring 配置文件native的特性"。
代码块如下所示:
management.endpoint.health.show-details: "ALWAYS"
management.endpoints.web.exposure.include: "*"
logging.level.root: info
当我们希望引起你对代码块中的某个特定部分的关注时,相关的行或项目会被设置为粗体:
backend:
serviceName: auth-server
servicePort: 80
- path: /product-composite
任何命令行输入或输出都会如下书写:
brew install kubectl
粗体:表示一个新术语,一个重要的单词,或者你在屏幕上看到的单词。例如,菜单或对话框中的单词会在文本中像这样出现。这是一个示例:"正如前一个屏幕截图所示,Chrome 报告:此证书有效!"
警告或重要注释会像这样出现。
提示和技巧会像这样出现。
联系我们
读者反馈始终受欢迎。
一般反馈:如果你对本书的任何方面有疑问,请在消息的主题中提到书名,并通过 customercare@packtpub.com 向我们发送电子邮件。
勘误:虽然我们已经尽一切努力确保我们的内容的准确性,但是错误确实存在。如果您在这本书中发现了错误,我们将非常感激如果您能向我们报告。请访问 www.packtpub.com/support/errata,选择您的书籍,点击勘误表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,我们将非常感激如果您能提供其位置地址或网站名称。请通过 copyright@packt.com 与我们联系,并附上材料的链接。
如果你想成为作者:如果你对你的某个主题有专业知识,并且你想写书或者为某个书做贡献,请访问 authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用了这本书,为什么不在这本书购买的网站上留下评论呢?潜在的读者可以看到并使用您的客观意见来做出购买决策,我们 Pactt 出版社可以了解您对我们产品的看法,我们的作者可以看到您对他们书籍的反馈。谢谢!
关于 Pactt 出版社的更多信息,请访问 packt.com。
第一部分:使用 Spring Boot 开始微服务开发
在本节中,你将学习如何使用 Spring Boot 的一些最重要的特性来开发微服务。
本节包括以下章节:
-
第一章,微服务简介
-
第二章,Spring Boot 简介
-
第三章,创建一组协作的微服务
-
第四章,使用 Docker 部署我们的微服务
-
第五章,使用 OpenAPI/Swagger 添加 API 描述
-
第六章,添加持久化
-
第七章,开发响应式微服务
第一章:微服务简介
本书并非盲目地赞美微服务。相反,它关于我们如何能够利用它们的好处,同时能够处理构建可扩展、有弹性和可管理的微服务的挑战。
作为本书的引言,本章将涵盖以下内容:
-
我如何了解微服务以及我对它们的好处和挑战的经验
-
微服务基础架构是什么?
-
微服务的挑战
-
处理挑战的设计模式
-
可以帮助我们处理这些挑战的软件促进者
-
本书未涵盖的其他重要考虑因素
技术要求
本章无需安装。不过,您可能想查看 C4 模型约定,c4model.com,因为本章的插图灵感来自于 C4 模型。
本章不包含任何源代码。
我进入微服务的方式
当我第一次在 2014 年了解微服务概念时,我意识到我在开发微服务(好吧,有点)已经好几年了,却不知道自己处理的微服务。我参与了一个始于 2009 年的项目,我们基于一系列分离的功能开发了一个平台。该平台被部署在多个客户的本地服务器上。为了使客户能够轻松选择他们想要从平台中使用的功能,每个功能都是作为自主软件组件开发的;也就是说,它有自己的持久数据,并且只使用定义良好的 API 与其他组件通信。
由于我无法讨论这个平台项目的特定功能,我将组件的名称进行了泛化,从组件 A 到 组件 F 进行标记。平台组成一组组件的如下所示:

每个组件都是使用 Java 和 Spring Framework 开发的,打包成 WAR 文件,并在 Java EE 网络容器中(例如,Apache Tomcat)部署为 Web 应用程序。根据客户的具体要求,平台可以在单台或多台服务器上部署。双节点部署可能如下所示:

自主软件组件的好处
将平台的 functionality 分解为一系列自主软件组件提供了许多好处:
-
客户可以在自己的系统景观中部署平台的某些部分,使用其定义良好的 API 将其与现有系统集成。
以下是一个示例,其中一个客户决定部署平台中的组件 A,组件 B,组件 D 和 组件 E,并将它们与客户系统景观中的两个现有系统系统 A 和 系统 B 集成:

- 另一客户可以选择用其在客户系统景观中已存在的实现替换平台的部分功能,这可能会需要对平台 API 中现有的功能进行一些采用。以下是一个客户用其自己的实现替换了平台中的组件 C和组件 F的示例:

-
平台中的每个组件都可以单独交付和升级。由于使用了定义良好的 API,一个组件可以升级到新版本,而无需依赖于其他组件的生命周期。
以下是一个示例,其中组件 A从版本v1.1升级到了v1.2。由于它使用了定义良好的 API 调用组件 A的组件 B,在升级后不需要更改(或者至少是向后兼容的):

- 由于使用了定义良好的 API,平台中的每个组件也可以独立于其他组件扩展到多台服务器。扩展可以是为了满足高可用性要求或处理更高数量的请求。技术上,这是通过手动在运行 Java EE Web 容器的几台服务器前设置负载均衡器来实现的。一个组件 A扩展到三个实例的示例如下:

自主软件组件的挑战
我们还发现,将平台分解成多个部分引入了许多新的挑战,我们在开发更传统、单片应用程序时并没有暴露(至少没有暴露到同样的程度)到这些挑战:
-
向组件添加新实例需要手动配置负载均衡器并手动设置新节点。这项工作既耗时又易出错。
-
平台最初容易在与它通信的其他系统出现错误。如果一个系统没有及时响应从平台发送的请求,平台很快就会耗尽关键资源,例如,操作系统线程,特别是当暴露于大量并发请求时。这会导致平台中的组件挂起甚至崩溃。由于平台中的大多数通信基于同步通信,一个组件的崩溃可能会导致级联故障;也就是说,崩溃组件的客户端也可能在一段时间后崩溃。这被称为故障链。
-
保持组件所有实例中的配置一致并更新迅速成为一个问题,导致大量手动和重复工作。这导致时不时会出现质量问题。
-
与监控单体应用程序单个实例的状态(例如,CPU、内存、磁盘和网络的使用情况)相比,监控平台在延迟问题和硬件使用方面的状态更为复杂。
-
从多个分布式组件中收集日志文件并关联组件相关的日志事件是困难的,但可行的,因为组件的数量是固定的,且事先已知。
随着时间的推移,我们通过开发内部工具和处理这些挑战的良好文档说明,解决了前述列表中提到的绝大多数挑战。操作规模通常在一个级别,在该级别上,手动程序对于发布新版本的组件和处理运行时问题是可接受的,尽管这不是理想的。
进入微服务
2014 年了解微服务架构让我意识到其他项目也面临过类似的挑战(部分原因是除了我之前描述的原因之外,例如,大型云服务提供商满足网络规模要求)。许多微服务先驱发表了他们学到的课程细节。从这些教训中学习非常有意思。
许多先驱者最初开发了单体应用,这在商业上使他们非常成功。但随着时间的推移,这些单体应用变得越来越难以维护和进化。它们也挑战性地超出了最大机器的容量(也称为垂直扩展)。最终,先驱们开始寻找将单体应用拆分为更小组件的方法,这些组件可以独立于彼此进行发布和扩展。可以通过水平扩展来扩展小组件,即在多个小型服务器上部署一个组件并在其前面放置一个负载均衡器。如果在云环境中进行,扩展能力是潜在无限的——这只是一个你引入多少虚拟服务器的问题(假设你的组件可以在大量实例上扩展,但稍后再详细介绍)。
2014 年,我还了解了许多新的开源项目,这些项目提供了工具和框架,简化了微服务的开发,并可用于处理基于微服务架构的挑战。其中一些如下:
-
Pivotal 发布了Spring Cloud,该框架封装了Netflix OSS的部分内容,以提供动态服务发现、配置管理、分布式跟踪、断路器等功能。
-
我还了解到了Docker 和容器革命,这对于缩小开发和生产之间的差距非常有益。能够将一个组件包装为一个可部署的运行时工件(例如,一个 Java、
war或者jar文件),也可以作为一个完整的镜像在运行 Docker 的服务器上启动(例如,一个隔离的进程),这对开发和测试来说是一个巨大的进步。 -
一个容器引擎,比如 Docker,不足以在生产环境中使用容器。需要的东西例如能确保所有容器都运行正常,以及能在多台服务器上扩展容器,从而提供高可用性和/或增加计算资源。这类产品被称为容器编排器。过去几年中,出现了一系列产品,例如 Apache Mesos、Docker 的 Swarm 模式、亚马逊 ECS、HashiCorp Nomad 和 Kubernetes。Kubernetes 最初由谷歌开发。当谷歌发布 v1.0 版本时,他们还把 Kubernetes 捐赠给了 CNCF(
www.cncf.io/)。在 2018 年,Kubernetes 成为了一种事实上的标准,既可以预先打包用于本地部署,也可以从大多数主要云服务提供商那里作为服务提供。 -
我最近开始学习关于服务网格 的概念以及服务网格如何补充容器编排器,进一步卸载微服务的职责,使它们变得可管理和有弹性。
微服务示例架构
由于这本书不能涵盖我刚才提到的所有技术方面,我将重点介绍自 2014 年以来我参与的客户项目中证明有用的部分。我将描述它们如何一起使用,以创建可管理、可扩展和有弹性的协作微服务。
本书的每一章都将关注一个特定的问题。为了演示事物是如何整合在一起的,我将使用一组协作的微服务,我们将在本书中逐步完善它们:

既然我们已经了解了微服务的如何和什么,让我们开始探讨如何定义一个微服务。
定义微服务
对我来说,微服务架构是关于将单体应用程序拆分成更小的组件,这实现了两个主要目标:
-
加快开发,实现持续部署
-
更容易扩展,手动或自动
微服务本质上是一个可以独立升级和扩展的自主软件组件。为了能够作为一个自主组件行动,它必须满足以下某些标准:
-
它必须遵循一种无共享架构;也就是说,微服务之间不会在数据库中共享数据!
-
它必须仅通过定义良好的接口进行通信,例如,使用同步服务,或者更 preferably,通过使用 API 和稳定的、文档齐全的消息格式彼此发送消息,并且这些消息格式遵循一个定义好的版本策略来发展。
-
它必须作为独立的运行时进程部署。每个微服务的实例运行在一个单独的运行时进程中,例如,一个 Docker 容器。
-
微服务实例是无状态的,这样对微服务的传入请求可以由其任何一个实例处理。
使用一组微服务,我们可以将部署到多个较小的服务器上,而不是被迫将部署到一个大的单体服务器上,正如我们在部署单体应用时必须做的那样。
考虑到前面提到的条件已经满足,相较于将一个大的单体应用进行扩展,将一个微服务扩展到更多的实例(例如,使用更多的虚拟服务器)会更加容易。利用云服务中可用的自动扩展功能也是一种可能性,但对于一个大的单体应用来说,通常并不可行。与升级一个大的单体应用相比,升级或替换一个微服务也更为容易。
这一点可以通过以下图表来说明,其中一个大单体应用被划分为六个微服务,它们都被部署到一个单独的服务器上。其中一些微服务还独立于其他服务进行了扩展:

我经常从客户那里收到的一个问题是,“微服务应该有多大?”
我试图使用以下经验法则:
-
足够小,以至于能够装进开发者的头脑中
-
足够小,不会影响性能(即,延迟)和/或数据一致性(存储在不同微服务中的数据之间的 SQL 外键不再是你可以轻易假设的东西)。
所以,总结一下,微服务架构本质上是这样一种架构风格:我们将一个单体应用分解为一组协作的自主软件组件。动机是为了实现更快的开发,并使应用的扩展变得更容易。
接下来,我们将转向了解在微服务方面我们将面临的一些挑战。
服务发现
服务发现模式有以下问题、解决方案和解决方案要求。
微服务的挑战
在“自主软件组件的挑战”一节中,我们已经看到了一些自主软件组件可能会带来的挑战(它们都适用于微服务),如下所示:
-
许多使用同步通信的小组件可能会导致连锁故障问题,尤其是在高负载下。
-
对于许多小组件保持配置的最新状态可能会很有挑战性。
-
跟踪正在处理并涉及许多组件的请求可能很困难,例如,在执行根本原因分析时,每个组件都本地存储日志事件。
-
分析组件级别硬件资源的使用也可能具有挑战性。
-
手动配置和管理许多小型组件可能会变得昂贵且容易出错。
将应用程序分解为一组自主组件的另一个缺点(但通常一开始并不明显)是,它们形成了一个分布式系统。分布式系统以其本质而言,很难处理。这一点已经知道很多年了(但在许多情况下直到证明否则才被忽视)。我用来证明这个事实的最喜欢引语来自彼得·德意志,他在 1994 年提出了以下观点:
分布式计算的 8 大谬误:基本上每个人在第一次构建分布式应用程序时都会做出以下八个假设。所有这些最终都被证明是错误的,并且都会造成巨大的麻烦和痛苦的学习经验:*
-
网络是可靠的
-
延迟为零
-
带宽是无限的
-
网络是安全的
-
拓扑不会改变
-
有一个管理员
-
传输成本为零
-
网络是同质的
-- 彼得·德意志,1994
注:第八个谬误实际上是由詹姆斯·高斯林在后来添加的。更多信息,请访问www.rgoarchitects.com/Files/fallacies.pdf。
一般来说,基于这些错误假设构建微服务会导致解决方案容易出现临时网络故障和其他微服务实例中的问题。当系统景观中的微服务数量增加时,问题的可能性也会上升。一个好的经验法则是,设计你的微服务架构时,假设系统景观中总是有一些东西在出错。微服务架构需要处理这一点,包括检测问题和重新启动失败组件,以及在客户端方面,以便请求不会发送到失败的微服务实例。当问题得到解决时,应恢复对之前失败的微服务的请求;也就是说,微服务客户端需要具有弹性。所有这些当然都需要完全自动化。对于大量的微服务,操作员手动处理这是不可能的!
这个范围很大,但我们将暂时限制自己,并继续研究微服务的设计模式。
微服务的设计模式
本节将介绍使用设计模式减轻微服务挑战的方法。在这本书的后面,我们将看到我们如何使用 Spring Boot、Spring Cloud 和 Kubernetes 实现这些设计模式。
设计模式的概念实际上相当古老;它是在 1977 年由克里斯托弗·亚历山大发明的。本质上,设计模式是关于在给定特定上下文时描述一个问题的可重用解决方案。
我们将涵盖的设计模式如下:
-
服务发现
-
边缘服务器
-
响应式微服务
-
集中式配置
-
集中式日志分析
-
分布式追踪
-
熔断器
-
控制循环
-
集中式监控和警报
此列表并非旨在全面,而是我们之前描述的挑战所需的最小设计模式列表。
我们将采用一种轻量级的方法来描述设计模式,并关注以下内容:
-
问题
-
解决方案
-
解决方案要求
在本书的后面部分,我们将更深入地探讨如何应用这些设计模式。这些设计模式的上下文是一个由合作的微服务组成的系统架构,微服务通过同步请求(例如,使用 HTTP)或发送异步消息(例如,使用消息代理)相互通信。
问题
客户端如何找到微服务和它们的实例?
微服务实例在启动时通常会被分配动态分配的 IP 地址,例如,当它们在容器中运行时。这使得客户端难以向微服务发起请求,例如,向暴露 HTTP 上的 REST API 的微服务发起请求。请参考以下图表:

解决方案
在系统架构中添加一个新组件——服务发现服务——跟踪当前可用的微服务和其实例的 IP 地址。
解决方案要求
一些解决方案要求如下:
-
自动注册/注销微服务和它们的实例,因为它们来来去去。
-
客户端必须能够向微服务的逻辑端点发起请求。请求将被路由到可用的微服务实例之一。
-
对微服务的请求必须在可用实例上进行负载均衡。
-
我们必须能够检测到当前不健康的实例;也就是说,请求不会被路由到这些实例。
实现说明: 正如我们将看到的,这个设计模式可以使用两种不同的策略来实现:
-
客户端路由:客户端使用与服务发现服务通信的库,以找出要发送请求的正确实例。
-
服务器端路由:服务发现服务的架构还暴露了一个反向代理,所有请求都发送到该代理。反向代理代表客户端将请求转发到适当的微服务实例。
边缘服务器
边缘服务器模式有以下问题、解决方案和解决方案要求。
问题
在微服务系统架构中,许多情况下,希望将一些微服务暴露给系统架构的外部,并将其余的微服务隐藏在外部访问之外。必须保护暴露的微服务免受恶意客户端的请求。
解决方案
向系统架构中添加一个新组件,即边缘服务器,所有传入请求都将通过它:

实现说明:边缘服务器通常表现得像反向代理,可以与发现服务集成,提供动态负载均衡功能。
解决方案要求
一些解决方案要求如下:
-
隐藏不应暴露在外部上下文中的内部服务;也就是说,只将请求路由到配置为允许外部请求的微服务。
-
暴露外部服务并保护它们免受恶意请求;也就是说,使用标准协议和最佳实践,如 OAuth、OIDC、JWT 令牌和 API 密钥,确保客户端是可信的。
反应式微服务
反应式微服务模式有以下问题、解决方案和解决方案要求。
问题
传统上,作为 Java 开发者,我们习惯于使用阻塞 I/O 实现同步通信,例如,通过 HTTP 实现的 RESTful JSON API。使用阻塞 I/O 意味着操作系统会为请求的长度分配一个线程。如果并发请求的数量增加(以及/或者请求中涉及的组件数量增加,例如,一系列协作的微服务),服务器可能会在操作系统中耗尽可用的线程,导致问题从更长的响应时间到服务器崩溃。
此外,正如我们在本章中已经提到的,过度使用阻塞 I/O 会使微服务系统容易出现错误。例如,一个服务的延迟增加可能会导致客户端耗尽可用的线程,从而导致它们失败。这反过来又可能导致它们的客户端出现相同类型的问题,这也被称为故障链。请参阅断路器部分,了解如何处理与故障链相关的问题。
解决方案
使用非阻塞 I/O,确保在等待另一个服务(例如,数据库或另一个微服务)处理时不会分配线程。
解决方案要求
一些解决方案要求如下:
-
只要可行,使用异步编程模型;也就是说,发送消息而不等待接收者处理它们。
-
如果偏好同步编程模型,确保使用反应式框架,这些框架可以使用非阻塞 I/O 执行同步请求,即在等待响应时不会分配线程。这将使微服务更容易扩展以处理增加的工作负载。
-
微服务还必须设计成有恢复力,也就是说,能够产生响应,即使它依赖的服务失败了。一旦失败的服务恢复正常运营,它的客户端必须能够继续使用它,这被称为自愈。
在 2013 年,设计这些方式的关键原则在《反应式宣言》中得到了确立(www.reactivemanifesto.org/)。根据宣言,反应式系统的基石是它们是消息驱动的;也就是说,它们使用异步通信。这使得它们能够是弹性的,也就是说,可伸缩的,并且有恢复力,也就是说,能够容忍失败。弹性和恢复力共同使得一个反应式系统能够是有响应性的,这样它能够及时做出反应。
集中配置
集中配置模式有以下问题、解决方案和解决方案要求。
问题
一个应用程序,传统上,是与它的配置一起部署的,例如,一组环境变量和/或包含配置信息的文件。考虑到一个基于微服务架构的系统景观,也就是有大量部署的微服务实例,会有一些查询产生:
-
我如何获得所有运行中的微服务实例中现行的配置的完整视图?
-
我如何更新配置并确保所有受影响的微服务实例都被正确更新?
解决方案
在系统景观中添加一个新的组件,一个配置服务器,以存储所有微服务的配置。
解决方案要求
使存储一组微服务的配置信息成为可能,在同一个地方有不同的设置针对不同的环境(例如,dev、test、qa和prod)。
集中日志分析
集中日志分析有以下问题、解决方案和解决方案要求。
问题
传统上,一个应用程序会将日志事件写入存储在运行应用程序的本机机器上的日志文件中。考虑到一个基于微服务架构的系统景观,也就是有大量部署在众多小型服务器上的微服务实例,我们可以提出以下问题:
-
我如何获得系统景观的概览,当每个微服务实例向自己的本地日志文件中写入时,系统景观中发生了什么?
-
我如何找出是否有任何微服务实例遇到麻烦并开始向它们的日志文件中写入错误消息?
-
如果最终用户开始报告问题,我如何找到相关的日志消息;也就是说,我如何确定哪个微服务实例是问题的根源?以下图表说明了这个问题:

解决方案
添加一个新的组件,它可以管理集中日志,并能够执行以下操作:
-
检测新的微服务实例并从它们那里收集日志事件
-
在中心数据库中以结构化和可搜索的方式解释和存储日志事件
-
提供 API 和图形工具以查询和分析日志事件
分布式追踪
分布式追踪有以下问题、解决方案和解决方案要求。
问题
必须能够在处理系统景观的外部调用时跟踪微服务之间的请求和消息。
以下是一些故障场景的例子:
-
如果最终用户开始就特定的故障提起支持案例,我们如何确定导致问题的微服务,即根本原因?
-
如果一个支持案例提到了与特定实体相关的问题,例如,特定的订单号,我们如何找到与处理这个特定订单相关的日志消息——例如,参与处理这个特定订单的所有微服务的日志消息?
以下图表展示了这一点:

解决方案
为了跟踪合作微服务之间的处理过程,我们需要确保所有相关请求和消息都标记有一个共同的关联 ID,并且关联 ID 是所有日志事件的一部分。基于关联 ID,我们可以使用集中的日志服务找到所有相关的日志事件。如果其中一个日志事件还包括与业务相关的标识信息,例如客户、产品、订单等的 ID,我们可以使用关联 ID 找到与该业务标识所有相关的日志事件。
解决方案要求
解决方案要求如下:
-
为所有传入或新请求和事件分配唯一的关联 ID,例如,在一个有已知名称的头部中。
-
当一个微服务发出一个外部请求或发送一个消息时,它必须给请求和消息添加一个关联 ID。
-
所有日志事件必须以预定义的格式包括关联 ID,以便集中的日志服务可以从日志事件中提取关联 ID 并使其可搜索。
断路器模式
断路器模式将会有以下问题、解决方案和解决方案要求。
问题
使用同步交互的微服务系统景观可能会遭受故障链。如果一个微服务停止响应,它的客户端也可能遇到问题并且停止响应它们客户端的请求。问题可能会递归地在系统景观中传播,并使其大部分失效。
这尤其在同步请求使用阻塞 I/O 执行时非常常见,即阻塞来自底层操作系统的线程,当请求正在被处理。结合大量并发请求和服务开始意外地缓慢响应,线程池可能会迅速耗尽,导致调用者挂起和/或崩溃。这种失败会不愉快地迅速传播到调用者的调用者,等等。
解决方案
添加一个断路器,如果它检测到它调用的服务有问题,则阻止调用者发出新的外出请求。
解决方案要求
解决方案要求如下:
-
如果检测到服务问题,打开电路并快速失败(不等待超时)。
-
探针失败修复(也称为半开电路);也就是说,定期让一个请求通过,以查看服务是否再次正常运行。
-
如果探针检测到服务再次正常运行,关闭电路。这种能力非常重要,因为它使系统景观对这些类型的问题具有弹性;也就是说,它具有自我修复能力。
以下图表展示了所有微服务系统景观中的同步通信都通过断路器的情景。所有断路器都是关闭的;也就是说,它们允许流量,除了一个断路器检测到请求所服务的有问题。因此,这个断路器是打开的,并使用快速失败逻辑;也就是说,它不调用失败的服务,等待超时发生。在下面,它会立即返回一个响应,在响应之前可选地应用一些回退逻辑:

控制循环
控制循环模式将有以下问题、解决方案和解决方案要求。
问题
在一个有大量微服务实例的系统景观中,这些实例分布在多个服务器上,手动检测和纠正崩溃或挂起的微服务实例等问题非常困难。
解决方案
向系统景观添加一个新组件,一个控制循环,这个组件不断观察系统景观的实际状态;将其与操作员指定的期望状态进行比较,如有必要,采取行动。例如,如果这两个状态不同,它需要使实际状态等于期望状态:

解决方案要求
实现说明:在容器的世界里,通常使用如 Kubernetes 之类的容器编排器来实现这个模式。我们将在第十五章,Kubernetes 简介中了解更多关于 Kubernetes 的内容。
集中监控和警报
对于这个模式,我们将有以下问题、解决方案和解决方案要求。
问题
如果观察到的响应时间和/或硬件资源的使用变得不可接受地高,找出问题的根本原因可能非常困难。例如,我们需要能够分析每个微服务的硬件资源消耗。
解决方案
为了解决这个问题,我们在系统景观中增加了一个新组件,一个监控服务,它能够收集每个微服务实例级别的硬件资源使用情况。
解决方案要求
解决方案要求如下:
-
它必须能够从系统景观中使用的所有服务器收集指标,包括自动扩展服务器。
-
它必须能够检测到在可用服务器上启动的新微服务实例,并开始从它们收集指标。
-
它必须能够为查询和分析收集的指标提供 API 和图形工具。
下面的屏幕截图显示了 Grafana,它可视化了来自我们稍后在本书中将介绍的监控工具 Prometheus 的指标:

那是一个很长的列表!我相信这些设计模式帮助您更好地理解了微服务的挑战。接下来,我们将转向了解软件使能器。
软件使能器
正如我们前面已经提到的,我们有多种非常好的开源工具可以帮助我们满足对微服务的期望,最重要的是,它们可以帮助我们处理与它们相关的新的挑战:
-
Spring Boot
-
Spring Cloud/Netflix OSS
-
Docker
-
Kubernetes
-
Istio(服务网格)
下面的表格将我们需要处理这些挑战的设计模式以及实现该设计模式的相应开源工具进行了映射:
| 设计模式 | Spring Boot | Spring Cloud | Kubernetes | Istio |
|---|---|---|---|---|
| 服务发现 | Netflix Eureka 和 Netflix Ribbon | Kubernetes kube-proxy 和服务资源 |
||
| 边缘服务器 | Spring Cloud 和 Spring Security OAuth | Kubernetes Ingress 控制器 | Istio 入口网关 | |
| 反应式微服务 | Spring Reactor 和 Spring WebFlux | |||
| 集中式配置 | Spring Config Server | Kubernetes ConfigMaps 和 Secrets |
集中式日志分析 | | | Elasticsearch、Fluentd 和 Kibana 注意:实际上不是 Kubernetes 的一部分
但是可以轻松地与 Kubernetes 一起部署和配置 | |
| 分布式追踪 | Spring Cloud Sleuth 和 Zipkin | Jaeger | ||
|---|---|---|---|---|
| 电路 breaker | Resilience4j | 异常检测 | ||
| 控制循环 | Kubernetes 控制器管理器 |
集中式监控和警报 | | | Grafana 和 Prometheus 注意:实际上不是 Kubernetes 的一部分
但是可以轻松地与 Kubernetes 一起部署和配置 | Kiali、Grafana 和 Prometheus |
请注意,Spring Cloud、Kubernetes 和 Istio 可以用来实现一些设计模式,如服务发现、边缘服务器和集中配置。我们将在本书的后面讨论使用这些替代方案的优缺点。
现在,让我们看看其他一些我们需要考虑的重要事情。
其他重要考虑因素
实现微服务架构的成功,还需要考虑许多相关领域。我不会在这本书中涵盖这些领域;相反,我只是在这里简要提及如下:
-
Dev/Ops 的重要性:微服务架构的一个好处是,它能够缩短交付时间,在极端情况下甚至允许持续交付新版本。为了能够那么快地交付,你需要建立一个组织,在这个组织中,开发和运维人员共同工作,遵循“你构建它,你运行它”的宗旨。这意味着开发者不再被允许只是将软件的新版本交给运维团队。相反,开发和运维组织需要更紧密地一起工作,组成具有全面责任的一个微服务(或一组相关的微服务)的整个生命周期的团队。除了组织的
dev/ops部分,团队还需要自动化交付链,即构建、测试、打包和将微服务部署到各种部署环境中的步骤。这被称为建立一个交付管道。 -
组织方面和康威定律:微服务架构可能如何影响组织的另一个有趣方面是康威定律,它陈述如下:
“任何设计系统(定义广泛)的组织都会产生一个其结构是该组织通信结构副本的设计。”
-- Melvyn Conway,1967
这意味着,基于技术专长(例如,UX、业务逻辑和数据库团队)来组织大型应用程序的传统方法会导致一个大的三层应用程序——通常是一个大的单体应用程序,其中有一个可独立部署的 UI 单元、一个处理业务逻辑的单元和一个大数据库。为了成功交付一个基于微服务架构的应用程序,组织需要变成一个或一组相关微服务的团队。这个团队必须拥有那些微服务所需的技能,例如,业务逻辑的语言和框架以及持久化其数据的数据库技术。
-
将单体应用分解为微服务:最困难和昂贵的决定之一是如何将单体应用分解为一组协作的微服务。如果这样做错了,你最终会面临如下问题:
-
交付缓慢:业务需求的变化将影响太多的微服务,导致额外的工作。
-
性能缓慢:为了能够执行特定的业务功能,许多请求必须在不同的微服务之间传递,导致响应时间长。
-
数据不一致性:由于相关数据被分离到不同的微服务中,随着时间的推移,由不同微服务管理的数据可能会出现不一致。
-
寻找微服务适当边界的良好方法是应用领域驱动设计及其边界上下文概念。根据 Eric Evans 的说法,边界上下文是"一个描述(通常是一个子系统,或特定团队的工作)的边界,在这个边界内定义了一个特定的模型并且适用。"这意味着由边界上下文定义的微服务将拥有其自身数据的良好定义模型。
-
API 设计的重要性:如果一组微服务暴露了一个共同的、对外可用的 API,那么这个 API 必须是易于理解的,并且要符合以下要求:
-
如果同一个概念在多个 API 中使用,那么在命名和数据类型方面应该有相同的描述。
-
允许 API 以受控的方式进行演变是非常重要的。这通常需要为 API 应用适当的版本控制方案,例如,
semver.org/,并有能力在特定时间段内处理 API 的多个主要版本,允许 API 的客户端按照自己的节奏迁移到新的主要版本。
-
-
从本地部署到云的迁移路径:如今,许多公司仍在本地运行其工作负载,但正在寻找将部分工作负载迁移到云的方法。由于大多数云服务提供商今天都提供 Kubernetes 作为服务,一个吸引人的迁移方法可以是首先将工作负载迁移到本地的 Kubernetes(作为微服务或不是),然后将其重新部署在首选云提供商提供的Kubernetes 作为服务上。
-
微服务和 12 因子应用的良好设计原则:12 因子应用(
12factor.net)是一组适用于构建可部署在云上的软件的设计原则。其中大多数设计原则适用于独立于部署位置(即云或本地)构建微服务,但并非全部。
第一章就到这里!希望这为您提供了微服务的好基本概念,并帮助您理解本书将涵盖的大规模主题。
总结
在这章开头,我描述了我自己对微服务的理解,并简要了解了它们的历史。我们定义了微服务是什么,即具有一些特定要求的一种自主分布式组件。我们还讨论了微服务架构的优点和挑战。
为了应对这些挑战,我们定义了一组设计模式,并简要地将开源产品如 Spring Boot、Spring Cloud 和 Kubernetes 的能力与它们进行了映射。
你现在迫不及待地想开发你的第一个微服务了吧?在下一章中,我们将介绍 Spring Boot 以及与之互补的开源工具,我们将使用它们来开发我们的第一个微服务。
第二章:介绍 Spring Boot
在本章中,我们将介绍如何使用 Spring Boot 构建一套协同工作的微服务,重点是如何开发具有业务价值的功能。我们在上一章中指出的挑战只会考虑一部分,但它们将在后面的章节中得到全面解决。
我们将使用 Spring WebFlux、基于 Swagger/OpenAPI 的 REST API 文档和 SpringFox 以及数据持久性,开发包含业务逻辑的微服务,同时使用 Spring Data 将数据存储在 SQL 和 NoSQL 数据库中。
自从 Spring Boot v2.0 于 2018 年 3 日发布以来,开发响应式微服务变得容易多了(参考第一章,微服务介绍,响应式微服务部分以获取更多信息)。因此,我们也将介绍如何在本章创建响应式微服务,包括非阻塞同步 REST API 和基于消息的异步服务。我们将使用 Spring WebFlux 开发非阻塞同步 REST API 和 Spring Cloud Stream 开发基于消息的异步服务。
最后,我们将使用 Docker 将我们的微服务作为容器运行。这将允许我们用一个命令启动和停止我们的微服务景观,包括数据库服务器和消息代理。
这是很多技术和框架,所以我们简要地看看它们都是关于什么!
在本章中,我们将介绍以下主题:
-
学习 Spring Boot
-
从 Spring WebFlux 开始
-
探索 SpringFox
-
了解 Spring Data
-
了解 Spring Cloud Stream
-
学习关于 Docker 的内容
关于每个产品的更多详细信息将在接下来的章节中提供。
技术要求
本章不包含可以下载的源代码,也不需要安装任何工具。
学习 Spring Boot
Spring Boot 以及 Spring Boot 基于的 Spring Framework,是用于在 Java 中开发微服务的好框架。
当 Spring Framework 在 2004 年发布 v1.0 时,它是为了修复过于复杂的 J2EE 标准(Java 2 Platforms, Enterprise Edition 的缩写)而发布的,其臭名昭著的部署描述符非常繁重。Spring Framework 提供了一种基于依赖注入(DI)概念的更轻量级开发模型。与 J2EE 中的部署描述符相比,Spring Framework 还使用了更轻量的 XML 配置文件。
至于 J2EE 标准,更糟糕的是,重量级的部署描述符实际上分为两种类型:
-
标准部署描述符,以标准方式描述配置
-
特定于供应商的部署描述符,将配置映射到供应商特定应用服务器中的供应商特定功能
2006 年,J2EE 被重新命名为Java EE,即Java Platform, Enterprise Edition,最近,Oracle 将 Jave EE 提交给了 Eclipse 基金会。2018 年 2 月,Java EE 被重新命名为 Jakarta EE。
多年来,尽管 Spring Framework 越来越受欢迎,其功能也显著增长。慢慢地,使用不再那么轻量级的 XML 配置文件来设置 Spring 应用程序的负担变得成为一个问题。
2014 年,Spring Boot 1.0 版本发布,解决了这些问题!
约定优于配置和胖 JAR 文件
Spring Boot 通过强烈地规定了如何设置 Spring Framework 的核心模块以及第三方产品,如用于日志记录或连接数据库的库,从而快速开发生产就绪的 Spring 应用程序。Spring Boot 通过默认应用一系列约定并最小化配置需求来实现这一点。每当需要时,每个约定都可以通过编写一些配置来个别覆盖。这种设计模式被称为约定优于配置,并最小化了初始配置的需求。
当需要配置时,我认为最好使用 Java 和注解来编写配置。虽然它们比 Spring Boot 出现之前的要小得多,但仍然可以使用那些基于 XML 的古老配置文件。
除了使用c**onvention over configuration之外,Spring Boot 还倾向于一个基于独立 JAR 文件的运行时模型,也称为胖 JAR 文件。在 Spring Boot 之前,运行 Spring 应用程序最常见的方式是将它部署为 Apache Tomcat 等 Java EE 网络服务器上的 WAR 文件。Spring Boot 仍然支持 WAR 文件部署。
一个胖 JAR 文件不仅包含应用程序自身的类和资源文件,还包括应用程序所依赖的所有.jar文件。这意味着胖 JAR 文件是运行应用程序所需的唯一 JAR 文件;也就是说,我们只需要将一个 JAR 文件传输到我们想要运行应用程序的环境中,而不是将应用程序的 JAR 文件及其依赖的所有 JAR 文件一起传输。
启动胖 JAR 不需要安装单独的 Java EE 网络服务器,如 Apache Tomcat。相反,可以使用简单的命令如java -jar app.jar来启动,这使它成为在 Docker 容器中运行的理想选择!如果 Spring Boot 应用程序使用 HTTP,例如,暴露一个 REST API,它将包含一个内嵌的网络服务器。
设置 Spring Boot 应用程序的代码示例
为了更好地理解这意味着什么,让我们看看一些源代码示例。
在这里我们只看一些代码片段来指出主要特性。要看到一个完全可工作的示例,您必须等到下一章!
神奇的@SpringBootApplication 注解
基于约定的自动配置机制可以通过注解应用程序类来启动,即包含静态main方法的类,用@SpringBootApplication注解。以下代码显示了这一点:
@SpringBootApplication
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
以下功能将由此注解提供:
-
它支持组件扫描,即在应用程序类的包及其所有子包中查找 Spring 组件和配置类。
-
应用程序类本身成为一个配置类。
-
它支持自动配置,其中 Spring Boot 在类路径中查找可以自动配置的 JAR 文件。例如,如果你在类路径中有 Tomcat,Spring Boot 将自动将 Tomcat 配置为内嵌 web 服务器。
组件扫描
假设我们在应用程序类的包(或其子包之一)中有一个 Spring 组件:
@Component
public class MyComponentImpl implements MyComponent { ...
应用程序中的另一个组件可以自动导入组件,也称为自动焊接,使用@Autowired注解:
public class AnotherComponent {
private final MyComponent myComponent;
@Autowired
public AnotherComponent(MyComponent myComponent) {
this.myComponent = myComponent;
}
我更喜欢使用构造函数注入(而不是字段和设置器注入)来保持组件状态不可变。不可变的州对于希望在多线程运行时环境中运行组件很重要。
如果我们想要使用声明在应用程序包之外的包中的组件,例如,被多个 Spring Boot 应用程序共享的实用组件,我们可以在应用程序类中的@SpringBootApplication注解补充一个@ComponentScan注解:
package se.magnus.myapp;
@SpringBootApplication
@ComponentScan({"se.magnus.myapp","se.magnus.utils"})
public class MyApplication {
现在我们可以在应用程序代码中自动导入se.magnus.util包的组件,例如,如下所示的一个实用组件:
package se.magnus.utils;
@Component
public class MyUtility { ...
这个实用组件可以这样在应用程序组件中自动导入:
package se.magnus.myapp.services;
public class AnotherComponent {
private final MyUtility myUtility;
@Autowired
public AnotherComponent(MyUtility myUtility) {
this.myUtility = myUtility;
}
基于 Java 的配置
如果我们想要覆盖 Spring Boot 的默认配置,或者如果我们想要添加自己的配置,我们只需用@Configuration注解一个类,它将被我们之前描述的组件扫描机制找到。
例如,如果我们想要在由 Spring WebFlux(如下所述)处理的 HTTP 请求处理中设置一个过滤器,该过滤器在请求处理的开头和结尾分别写入日志消息,我们可以如下配置一个日志过滤器:
@Configuration
public class SubscriberApplication {
@Bean
public Filter logFilter() {
CommonsRequestLoggingFilter filter = new
CommonsRequestLoggingFilter();
filter.setIncludeQueryString(true);
filter.setIncludePayload(true);
filter.setMaxPayloadLength(5120);
return filter;
}
我们还可以将配置直接放在应用程序类中,因为@SpringBootApplication注解隐含了@Configuration注解。
现在我们已经了解了 Spring Boot,接下来让我们谈谈 Spring WebFlux。
从 Spring WebFlux 开始
Spring Boot 2.0 基于 Spring Framework 5.0,它提供了内置的支持来开发反应式应用程序。Spring Framework 使用Project Reactor作为其反应式支持的基线实现,并且还带来了一个新的 web 框架 Spring WebFlux,它支持开发反应式的,即非阻塞的 HTTP 客户端和服务。
Spring WebFlux 支持两种不同的编程模型:
-
基于注解的命令式风格,与已经存在的 Web 框架 Spring Web MVC 类似,但支持响应式服务
-
基于路由和处理器的新的函数式模型
在这本书中,我们将使用基于注解的命令式风格来展示将 REST 服务从 Spring Web MVC 迁移到 Spring WebFlux 是多么容易,然后开始重构服务,使它们变得完全响应式。
Spring WebFlux 还提供了一个完全响应式的 HTTP 客户端,WebClient,作为现有RestTemplate客户端的补充。
Spring WebFlux 支持在 Servlet 容器上运行(它需要 Servlet v3.1 或更高版本),但也支持响应式非 Servlet 内嵌 Web 服务器,如 Netty(netty.io/)。
使用 Spring WebFlux 设置 REST 服务的代码示例
在我们能够基于 Spring WebFlux 创建 REST 服务之前,需要将 Spring WebFlux(及其所需的依赖项)添加到 Spring Boot 的类路径中,以便在启动时检测并配置。Spring Boot 提供大量方便的启动依赖项,每个依赖项都带来一个特定的特性,以及每个特性通常所需的依赖项。所以,让我们使用 Spring WebFlux 的启动依赖项,然后看看简单的 REST 服务长什么样!
启动依赖项
在这本书中,我们将使用 Gradle 作为我们的构建工具,因此 Spring WebFlux 的启动依赖项将被添加到build.gradle文件中。它看起来像这样:
implementation('org.springframework.boot:spring-boot-starter-webflux')
你可能想知道为什么我们没有指定一个版本号。
我们将在第三章中讨论这一点,创建一组协作的微服务!
当微服务启动时,Spring Boot 将检测到类路径中的 Spring WebFlux 并对其进行配置,以及其他用于启动内嵌 Web 服务器的所用东西。默认使用 Netty,我们可以从日志输出中看到:
2018-09-30 15:23:43.592 INFO 17429 --- [ main] o.s.b.web.embedded.netty.NettyWebServer : Netty started on port(s): 8080
如果我们想要将 Netty 更改为 Tomcat 作为我们的内嵌 Web 服务器,可以通过从启动依赖项中排除 Netty 并添加 Tomcat 的启动依赖项来覆盖默认配置:
implementation('org.springframework.boot:spring-boot-starter-webflux')
{
exclude group: 'org.springframework.boot', module: 'spring-boot-
starter-reactor-netty'
}
implementation('org.springframework.boot:spring-boot-starter-tomcat')
重启微服务后,我们可以看到 Spring Boot 选择了 Tomcat:
2018-09-30 18:23:44.182 INFO 17648 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8080 (http)
属性文件
从前面的示例中,可以看到 Web 服务器使用端口8080启动。如果你想要更改端口,可以使用属性文件覆盖默认值。Spring Boot 应用程序属性文件可以是.properties文件或 YAML 文件。默认情况下,它们分别命名为application.properties和application.yml。
在这本书中,我们将使用 YAML 文件,以便内嵌 Web 服务器所使用的 HTTP 端口可以更改为7001。通过这样做,我们可以避免与其他在同一服务器上运行的微服务发生端口冲突。为此,需要在application.yml文件中添加以下行:
server.port: 7001
示例 RestController
现在,有了 Spring WebFlux 和我们所选择的嵌入式 Web 服务器,我们可以像使用 Spring MVC 一样编写 REST 服务,即使用 RestController:
@RestController
public class MyRestService {
@GetMapping(value = "/my-resource", produces = "application/json")
List<Resource> listResources() {
...
}
@GetMapping 注解应用于 listResources() 方法,它将 Java 方法映射到 host:8080/myResource URL 上的 HTTP GET API。List<Resource> 类型的返回值将被转换为 JSON。
既然我们谈论了 Spring WebFlux,现在让我们来看看 SpringFox 是关于什么的。
探索 SpringFox
开发 API 的一个非常重要的方面,例如 RESTful 服务,是如何文档化它们,以便它们易于使用。当涉及到 RESTful 服务时,Swagger 是文档化 RESTful 服务最广泛使用的方法之一。许多领先的 API 网关都有内置支持,用于通过 Swagger 暴露 RESTful 服务的文档。
在 2015 年,SmartBear Software 将 Swagger 规范捐赠给了 Linux Foundation 旗下的 OpenAPI Initiative,并创建了 OpenAPI 规范。Swagger 这个名称仍被用于 SmartBear Software 提供的工具中。
SpringFox 是一个开源项目,与 Spring Framework 分开,它可以在运行时创建基于 Swagger 的 API 文档。它通过在应用程序启动时检查来做到这一点,例如,检查 WebFlux 和基于 Swagger 的注解。
在接下来的章节中,我们将查看完整的源代码示例,但现在以下这个示例 API 文档的屏幕快照就足够了:

注意那个大大的执行按钮,它可以用来实际尝试 API,而不仅仅是阅读其文档!
SpringFox 帮助我们理解了微服务如何深入到 Spring Framework 中。现在,让我们转向 Spring Data。
理解 Spring Data
Spring Data 提供了一个用于在不同类型的数据库引擎中持久化数据的常见编程模型,范围从传统的关系数据库(SQL 数据库)到各种类型的 NoSQL 数据库引擎,例如文档数据库(例如,MongoDB)、键值数据库(例如,Redis)和图数据库(例如,Neo4J)。
Spring Data 项目分为几个子项目,在这本书中,我们将使用已映射到 MySQL 数据库的 Spring Data MongoDB 和 JPA 子项目。
JPA 是 Java Persistence API 的缩写,是关于如何处理关系数据的一个 Java 规范。请访问 jcp.org/aboutJava/communityprocess/mrel/jsr338/index.html 查看最新的规范,截至撰写本文时是 JPA 2.2。
Spring Data 编程模型的两个核心概念是实体和仓库。实体和仓库概括了从各种类型的数据库存储和访问数据的方式。它们提供了一个通用的抽象,但仍然支持向实体和仓库添加数据库特定的行为。这两个核心概念将在本章中一起简要解释,并附有一些示例代码。请注意,更多的细节将在接下来的章节中提供!
尽管 Spring Data 为不同类型的数据库提供了一个共同的编程模型,但这并不意味着您将能够编写可移植的源代码,例如,在不更改源代码的情况下,将数据库技术从 SQL 数据库更改为 NoSQL 数据库!
实体
实体描述了 Spring Data 将存储的数据。实体类通常用通用的 Spring Data 注解和特定于每种数据库技术的注解进行注释。
例如,一个将存储在关系型数据库中的实体可以注释如下 JPA 注解:
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.IdClass;
import javax.persistence.Table;
@Entity
@IdClass(ReviewEntityPK.class)
@Table(name = "review")
public class ReviewEntity {
@Id private int productId;
@Id private int reviewId;
private String author;
private String subject;
private String content;
如果一个实体要存储在 MongoDB 数据库中,可以使用 Spring Data MongoDB 子项目的注解以及通用的 Spring Data 注解。例如,考虑以下代码:
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.mongodb.core.mapping.Document;
@Document
public class RecommendationEntity {
@Id
private String id;
@Version
private int version;
private int productId;
private int recommendationId;
private String author;
private int rate;
private String content;
@Id和@Version注解是通用的注解,而@Document注解是特定于 Spring Data MongoDB 子项目的。
这一点可以通过研究导入声明来揭示;也就是说,包含mongodb的导入声明来自 Spring Data MongoDB 子项目。
仓库
仓库用于存储和访问不同类型的数据库中的数据。在其最基本的形式中,一个仓库可以声明为一个 Java 接口,Spring Data 将使用有偏见的约定实时生成其实现。这些约定可以被覆盖和/或补充额外的配置,如果需要,还一些 Java 代码。Spring Data 还提供了一些基础 Java 接口,例如CrudRepository,以使仓库的定义更加简单。基础接口CrudRepository为我们提供了创建、读取、更新和删除操作的标准方法。
为了指定一个用于处理 JPA 实体ReviewEntity的仓库,我们只需要声明以下内容:
import org.springframework.data.repository.CrudRepository;
public interface ReviewRepository extends CrudRepository<ReviewEntity, ReviewEntityPK> {
Collection<ReviewEntity> findByProductId(int productId);
}
在这个例子中,我们使用一个类ReviewEntityPK来描述一个组合主键。它如下所示:
public class ReviewEntityPK implements Serializable {
public int productId;
public int reviewId;
}
我们还添加了一个额外的方法findByProductId,它允许我们根据productId——主键的一部分——来查找Review实体。该方法的命名遵循 Spring Data 定义的命名约定,允许 Spring Data 实时生成这个方法的实现。
如果我们想要使用仓库,我们可以简单地注入它,然后开始使用它,例如:
private final ReviewRepository repository;
@Autowired
public ReviewService(ReviewRepository repository) {
this.repository = repository;
}
public void someMethod() {
repository.save(entity);
repository.delete(entity);
repository.findByProductId(productId);
还添加到了CrudRepository接口中,Spring Data 还提供了一个反应式基础接口,ReactiveCrudRepository,它使反应式仓库成为可能。该接口中的方法不返回对象或对象集合;相反,它们返回Mono和Flux对象。如我们在后面的章节中将看到的,Mono和Flux对象是反应式流,能够返回0..1或0..m个实体,实体随着流变得可用。基于反应式的接口只能由支持反应式数据库驱动器的 Spring Data 子项目使用;也就是说,它们基于非阻塞 I/O。Spring Data MongoDB 子项目支持反应式仓库,而 Spring Data JPA 则不支持。
为处理前面描述的 MongoDB 实体RecommendationEntity指定反应式仓库可能会像以下内容一样:
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;
public interface RecommendationRepository extends ReactiveCrudRepository<RecommendationEntity, String> {
Flux<RecommendationEntity> findByProductId(int productId);
}
本节关于 Spring Data 的内容就到这里。现在让我们来看看 Spring Cloud Stream 是关于什么的。
理解 Spring Cloud Stream
我们本章不会专注于 Spring Cloud;我们将在第九章,使用 Netflix Eureka 和 Ribbon 添加服务发现到第十四章,理解分布式跟踪中这样做。然而,我们将引入 Spring Cloud 的一个模块:Spring Cloud Stream。Spring Cloud Stream 为消息提供了一种流式抽象,基于发布-订阅集成模式。Spring Cloud Stream 目前内置了对 Apache Kafka 和 RabbitMQ 的支持。存在许多独立的项目,为其他流行的消息系统提供集成。有关更多信息,请参见github.com/spring-cloud?q=binder。
Spring Cloud Stream 中的核心概念如下:
-
消息:用于描述发送到和从消息系统接收的数据的数据结构。
-
发布者:向消息系统发送消息。
-
订阅者:从消息系统中接收消息。
-
通道:用于与消息系统进行通信。发布者使用输出通道,订阅者使用输入通道。
-
绑定器:提供与特定消息系统的实际集成,类似于 JDBC 驱动程序对特定类型的数据库所做的那样。
实际要使用的消息系统在运行时确定,取决于在类路径中找到的内容。Spring Cloud Stream 带有关于如何处理消息的有见解的约定。这些约定可以通过指定消息功能的配置来覆盖,如消费者组、分区、持久化、耐用性和错误处理,如重试和死信队列处理。
发送和接收消息的 Spring Cloud Stream 代码示例
为了更好地理解这一切是如何组合在一起的,让我们来看看一些源代码示例。
让我们假设我们有一个简单的消息类,如下所示(构造函数、getter 和 setter 已省略,以提高可读性):
public class MyMessage {
private String attribute1 = null;
private String attribute2 = null;
Spring Cloud Stream 带有默认的输入和输出通道,Sink和Source,所以我们可以开始使用,而不需要创建自己的。要发布一条消息,我们可以使用以下源代码:
import org.springframework.cloud.stream.messaging.Source;
@EnableBinding(Source.class)
public class MyPublisher {
@Autowired private Source mysource;
public String processMessage(MyMessage message) {
mysource.output().send(MessageBuilder.withPayload(message).build());
为了接收消息,我们可以使用以下代码:
import org.springframework.cloud.stream.messaging.Sink;
@EnableBinding(Sink.class)
public class MySubscriber {
@StreamListener(target = Sink.INPUT)
public void receive(MyMessage message) {
LOG.info("Received: {}",message);
为了绑定到 RabbitMQ,我们将在构建文件中使用专门的启动依赖项build.gradle:
implementation('org.springframework.cloud:spring-cloud-starter-stream-rabbit')
为了让订阅者从发布者那里接收消息,我们需要配置输入和输出通道以使用相同的目的地。如果我们使用 YAML 来描述我们的配置,它可能如下所示对于发布者:
spring.cloud.stream:
default.contentType: application/json
bindings.output.destination: mydestination
订阅者的配置如下:
spring.cloud.stream:
default.contentType: application/json
bindings.input.destination: mydestination
我们使用default.contentType来指定我们更喜欢消息以 JSON 格式序列化。
现在我们已经了解了各种 Spring API,让我们在下一节了解一个相对较新的概念,Docker。
学习关于 Docker
我假设 Docker 和容器概念不需要深入介绍。Docker 在 2013 年非常流行的容器作为虚拟机的轻量级替代品。实际上,容器是在使用 Linux namespaces在 Linux 主机上处理,以提供容器之间全局系统资源,如用户、进程、文件系统、网络。Linux 控制组(也称为cgroups)用于限制容器允许消耗的 CPU 和内存量。与在每一个虚拟机中运行操作系统的完整副本的虚拟机相比,容器的开销只是虚拟机开销的一小部分。这导致了更快的启动时间和在 CPU 和内存使用上显著降低的开销。然而,容器提供的隔离并不被认为是像虚拟机提供的隔离那样安全的。随着 Windows Server 2016 的发布,微软支持在 Windows 服务器上使用 Docker。
容器在开发和测试中都非常有用。能够通过一条命令启动一个完整的微服务合作系统景观(例如,数据库服务器、消息代理等)进行测试,这真是令人惊叹。
例如,我们可以编写脚本以自动化我们微服务景观的端到端测试。一个测试脚本可以启动微服务景观,使用暴露的服务运行测试,并拆除景观。这种类型的自动化测试脚本非常实用,既可以在开发者在将代码推送到源代码仓库之前在本地的开发机上运行,也可以作为交付管道中的一个步骤执行。构建服务器可以在持续集成和部署过程中,在开发者将代码推送到源代码仓库时运行这些类型的测试。
对于生产使用,我们需要一个容器编排器,如 Kubernetes。我们将在本书的后面回到容器编排器和 Kubernetes。
在本书中我们将要研究的绝大多数微服务,只需要如下的 Dockerfile 就可以将微服务作为 Docker 容器运行:
FROM openjdk:12.0.2
MAINTAINER Magnus Larsson <magnus.larsson.ml@gmail.com>
EXPOSE 8080
ADD ./build/libs/*.jar app.jar
ENTRYPOINT ["java","-jar","/app.jar"]
如果我们想要用一个命令来启动和停止许多容器,Docker Compose 是完美的工具。Docker Compose 使用一个 YAML 文件来描述要管理的容器。对于我们的微服务,它可能看起来像如下这样:
product:
build: microservices/product-service
recommendation:
build: microservices/recommendation-service
review:
build: microservices/review-service
composite:
build: microservices/product-composite-service
ports:
- "8080:8080"
让我稍微解释一下前面的源代码:
-
build指令用于指定每个微服务使用哪个 Dockerfile。Docker Compose 会使用它来构建一个 Docker 镜像,然后基于这个 Docker 镜像启动一个 Docker 容器。 -
复合服务中的
ports指令用于在运行 Docker 的服务器上暴露端口8080。在开发者的机器上,这意味着可以通过使用localhost:8080简单地访问复合服务的端口!
YAML 文件中的所有容器都可以用如下简单命令进行管理:
-
docker-compose up -d:启动所有容器。-d意味着容器在后台运行,不会锁定执行命令的终端。 -
docker-compose down:停止并删除所有容器。 -
docker-compose logs -f --tail=0:输出所有容器的日志消息。-f意味着该命令不会完成,而是等待新的日志消息。--tail=0意味着我们不想看到任何之前的日志消息,只想要新的。
这是对 Docker 的简要介绍。在本书的最后几章,我们将更详细地介绍 Docker。
总结
在本章中,我们介绍了 Spring Boot 以及可以用来构建协作微服务的互补的开源工具。
Spring Boot 用于简化基于 Spring 的生产级应用程序的开发。它强烈地规定了如何设置 Spring Framework 的核心模块和第三方产品。
Spring WebFlux 是 Spring 家族中的一个新模块,用于开发反应式的,也就是非阻塞的 REST 服务。它既可以在 Netty 这样的轻量级 web 服务器上运行,也可以在任何 Servlet 3.1+兼容的 web 服务器上运行。它还支持来自较老的 Spring MVC 模块的编程模型;无需完全重写代码,就可以轻松地将为 Spring MVC 编写的 REST 服务迁移到 Spring WebFlux。
SpringFox 可以用来创建基于 Swagger 和 OpenAPI 的关于 REST 服务的文档。它通过检查 REST 服务的注解(既 Spring 的注解和一些 Swagger 特定的注解,如果使用的话)在运行时动态创建文档。
Spring Data 提供了一种优雅的抽象,用于使用实体和仓库访问和管理持久数据。编程模型相似,但不同类型的数据库(例如,关系型、文档型、键值型和图数据库)之间并不兼容。
Spring Cloud Stream 为消息传递提供了基于发布和订阅集成模式的流抽象。Spring Cloud Stream 默认支持 Apache Kafka 和 RabbitMQ,但可以通过自定义绑定器扩展支持其他消息代理。
Docker 使得容器这一轻量级的虚拟机替代方案变得易于使用。基于 Linux 命名空间和控制组,容器提供了与传统虚拟机相似的隔离性,但在 CPU 和内存使用方面有显著的较低开销。Docker 是一个非常适合开发和测试的工具,但在大多数情况下,在生产环境中使用需要一个容器编排器,如 Kubernetes。
问题
-
@SpringBootApplication注解的目的是什么? -
老版本的用于开发 REST 服务的 Spring 组件 Spring Web MVC 和新版本的 Spring WebFlux 之间的主要区别是什么?
-
SpringFox 是如何帮助开发者文档化 REST API 的?
-
在 Spring Data 中,仓库的功能是什么,仓库的最简单可能实现是什么?
-
在 Spring Cloud Stream 中,绑定的目的是什么?
-
Docker Compose 的目的是什么?
第三章:创建一套协作的微服务
在本章中,我们将构建我们的第一个微服务。我们将学习如何创建具有最小功能的协作微服务。在接下来的章节中,我们将向这些微服务添加越来越多的功能。到本章末尾,我们将通过一个复合微服务暴露一个 RESTful API。复合微服务将使用它们的 RESTful API 调用其他三个微服务,以创建一个聚合响应。
本章将涵盖以下主题:
-
介绍微服务架构
-
生成微服务骨架
-
添加 RESTful API
-
添加一个复合微服务
-
添加错误处理
-
手动测试 API
-
向微服务添加隔离的自动化测试
-
向微服务架构添加半自动化测试
技术要求
本书中描述的所有命令都是在 MacBook Pro 上使用 macOS Mojave 运行的,但它们应该很容易修改,以便在如 Linux 或 Windows 等其他平台上运行。
工具安装
为了能够执行本章中使用的命令,你需要在你计算机上安装以下工具:
-
Git: 可以从
git-scm.com/downloads下载并安装。 -
Java:可以从
www.oracle.com/technetwork/java/javase/downloads/index.html下载并安装。 -
curl:这个用于测试基于 HTTP 的 API 的命令行工具可以从curl.haxx.se/download.html下载并安装。 -
jq:这个命令行 JSON 处理器可以从stedolan.github.io/jq/download/下载并安装。 -
Spring Boot CLI:这个 Spring Boot 应用程序的命令行工具可以从
docs.spring.io/spring-boot/docs/current/reference/html/getting-started-installing-spring-boot.html#getting-started-installing-the-cli下载并安装。
安装 Homebrew
要在 macOS 上安装这些工具,我建议你使用 Homebrew,brew.sh/。如果你没有安装,可以使用以下命令安装:
/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
安装 Xcode 的命令行工具会安装 Homebrew,如果你还没有安装,可能需要一些时间。
使用以下命令验证 Homebrew 的安装:
brew --version
期望得到如下响应:
Homebrew 1.7.7
使用 Homebrew 安装 Java、curl、jq 和 Spring Boot CLI
在 macOS 上,curl 已经预装,git 是 Homebrew 安装的一部分。剩下的工具可以使用以下命令在 macOS 上使用 Homebrew 安装:
brew tap pivotal/tap && \
brew cask install java && \
brew install jq && \
brew install springboot
这些工具的安装可以通过以下命令来验证:
git --version
java -version
curl --version
jq --version
spring --version
这些命令将返回如下内容(删除了一些不相关的输出):

使用 IDE
我建议你使用支持 Spring Boot 应用程序开发的 IDE,如 Spring Tool Suite 或 IntelliJ IDEA Ultimate Edition 来编写 Java 代码。查看手动测试 API部分,了解如何使用 Spring Boot 控制台。然而,你不需要 IDE 就能按照本书中的说明操作。
访问源代码
本章的源代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud/tree/master/Chapter03。
为了能够运行本书中描述的命令,将源代码下载到文件夹中,并设置一个环境变量$BOOK_HOME,该变量指向该文件夹。一些示例命令如下:
export BOOK_HOME=~/Documents/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud
git clone https://github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud $BOOK_HOME
cd $BOOK_HOME/Chapter03
Java 源代码是为 Java 8 编写的,并在 Java 12 上进行了测试运行。为了避免与 Spring Boot 2.0(和 Spring 5.0)的一些问题,本章使用了 Spring Boot 2.1.0 RC1(和 Spring 5.1.1),这是在撰写本书时可用的最新 Spring Boot 版本。
本章中的代码示例都来自$BOOK_HOME/Chapter03的源代码,但在许多情况下,为了删除源代码中不相关部分,例如注释、导入和日志声明,对这些代码进行了编辑。
有了这些,我们安装了所需的工具,并下载了本章的源代码。在下一节中,我们将学习本书中我们将要创建的协作微服务系统架构。
介绍微服务架构
在第一章中,微服务简介,我们简要介绍了将在本书中使用的基于微服务的系统架构:

它由三个核心微服务组成,分别是产品、评论和推荐服务,这三个服务都处理一种资源类型,还有一个名为产品组合的复合微服务,它聚合了这三个核心服务的信息。
暂时替换发现服务
由于在这个阶段,我们没有任何服务发现机制,我们将为每个微服务使用硬编码端口号。我们将使用以下端口:
-
产品组合服务:
7000 -
产品服务:
7001 -
审查服务:
7002 -
推荐服务:
7003
我们稍后开始使用 Docker 和 Kubernetes 时,将不再使用这些硬编码端口!
在本节中,我们已经介绍了将要创建的微服务以及它们将处理的信息。在下一节中,我们将使用 Spring Initializr 创建微服务的骨架代码。
微服务处理的信息
为了使本书中的源代码示例容易理解,它们包含的业务逻辑量最小。它们处理的业务对象的信息模型同样因为此原因保持最小。在本节中,我们将了解每个微服务处理的信息,以及微服务处理的基础设施相关信息。
产品服务
product服务管理产品信息,并使用以下属性描述每个产品:
-
产品 ID
-
名称
-
重量
服务回顾
review服务管理产品评论,并存储关于每个评论以下信息:
-
产品 ID
-
回顾 ID
-
作者
-
主题
-
内容
推荐服务
recommendation服务管理产品推荐,并存储关于每个推荐以下信息:
-
产品 ID
-
推荐 ID
-
作者
-
评分
-
内容
产品复合服务
product复合服务汇总三个核心服务的信息,如下所示呈现关于产品的信息:
-
产品信息,如
product服务中所描述 -
指定产品的产品评论列表,如
review服务中所描述 -
指定产品的产品推荐列表,如
recommendation服务中所描述
与基础设施相关的信息
一旦我们开始将我们的微服务作为由基础架构管理(首先是 Docker,后来是 Kubernetes)的容器运行,跟踪实际响应我们请求的容器将会很有趣。为了简化这种跟踪,我们还向所有我们的响应中添加了一个serviceAddress属性,格式为hostname/ip-address:port。
生成骨架微服务
是时候看看我们如何为我们的微服务创建项目了。这个主题的最终结果可以在$BOOK_HOME/Chapter03/1-spring-init文件夹中找到。为了简化项目的设置,我们将使用 Spring Initializr 为每个微服务生成一个骨架项目。骨架项目包含构建项目所需的文件,以及为微服务空白的main类和测试类。之后,我们将了解如何使用我们将要使用的构建工具 Gradle 中的多项目构建,用一个命令构建所有的微服务。
使用 Spring Initializr 生成骨架代码
为了开始开发我们的微服务,我们将使用一个名为Spring Initializr的工具来为我们生成骨架代码。它可以通过使用start.spring.io/ URL 从网络浏览器调用,或者通过命令行工具spring init调用。为了更容易地复现微服务的创建,我们将使用命令行工具。
针对每个微服务,我们将创建一个 Spring Boot 项目,执行以下操作:
-
使用 Gradle 作为构建工具
-
为 Java 8 生成代码
-
将项目打包为胖 JAR 文件
-
引入了
Actuator和WebFluxSpring 模块的依赖项 -
基于 Spring Boot v2.1.0 RC1(依赖于 Spring Framework v5.1.1)
Spring Boot Actuator 为管理和监控启用了许多有价值的端点。我们稍后可以看到它们的具体应用。在这里,我们将使用 Spring WebFlux 创建我们的 RESTful API。
为了为我们的微服务创建骨架代码,我们需要针对product-service运行以下命令:
spring init \
--boot-version=2.1.0.RC1 \
--build=gradle \
--java-version=1.8 \
--packaging=jar \
--name=product-service \
--package-name=se.magnus.microservices.core.product \
--groupId=se.magnus.microservices.core.product \
--dependencies=actuator,webflux \
--version=1.0.0-SNAPSHOT \
product-service
如果你想了解更多关于spring init CLI 的信息,可以运行spring help init命令。要查看您可以添加的依赖项,请运行spring init --list命令。
如果你想自己创建这四个项目,而不是使用本书 GitHub 仓库中的源代码,可以尝试使用$BOOK_HOME/Chapter03/1-spring-init/create-projects.bash,如下所示:
mkdir some-temp-folder cd some-temp-folder
$BOOK_HOME/Chapter03/1-spring-init/create-projects.bash
在使用create-projects.bash创建我们的四个项目后,我们将拥有以下文件结构:
microservices/
├── product-composite-service
├── product-service
├── recommendation-service
└── review-service
对于每个项目,我们可以列出创建的文件。让我们为product-service项目这样做:
find microservices/product-service -type f
我们将收到以下输出:

Spring Initializr 为 Gradle 创建了许多文件,包括一个.gitignore文件和三个 Spring Boot 文件:
-
ProductServiceApplication.java,我们的主应用类 -
application.properties,一个空属性文件 -
ProductServiceApplicationTests.java,一个已配置为使用 JUnit 在我们的 Spring Boot 应用程序上运行测试的测试类
main应用类ProductServiceApplication.java看起来与上一章预期的一致:
package se.magnus.microservices.core.product;
@SpringBootApplication
public class ProductServiceApplication {
public static void main(String[] args) {
SpringApplication.run(ProductServiceApplication.class, args);
}
}
测试类如下所示:
package se.magnus.microservices.core.product;
@RunWith(SpringRunner.class)
@SpringBootTest
public class ProductServiceApplicationTests {
@Test
public void contextLoads() {
}
}
@RunWith(SpringRunner.class)和@SpringBootTest注解将以前述方式初始化我们的应用:当运行应用时,@SpringBootApplication会设置 Spring 应用上下文;也就是说,在执行测试之前,使用组件扫描和自动配置设置上下文,如上一章所述。
让我们也看看最重要的 Gradle 文件,即build.gradle。这个文件的内容描述了如何构建项目,例如编译、测试和打包源代码。Gradle 文件从设置buildscript元素并列出要应用的插件来开始,为其余的构建文件设置条件:
buildscript {
ext {
springBootVersion = '2.1.0.RC1'
}
repositories {
mavenCentral()
maven { url "https://repo.spring.io/snapshot" }
maven { url "https://repo.spring.io/milestone" }
}
dependencies {
classpath("org.springframework.boot:spring-boot-gradle-
plugin:${springBootVersion}")
}
}
apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
让我们更详细地解释前面的源代码:
-
Spring Boot 版本设置为我们运行
spring init命令时指定的版本,即2.1.0.RC1。 -
声明了许多 Gradle 插件。最重要的插件是
org.springframework.boot和io.spring.dependency-management插件,这两个插件一起确保 Gradle 会构建一个胖 JAR 文件,并且我们不需要在 Spring Boot 启动器依赖项上指定任何显式的版本号。相反,它们由springBootVersion属性隐含。 -
插件是从中央 Maven 仓库以及 Spring 的快照和里程碑仓库中获取的,因为我们指定的是 Spring Boot 的发行候选版本,即 v2.1.0 RC1,而不是一个已经发布并可在中央 Maven 仓库中找到的版本。
在构建文件的其余部分,我们基本上为我们的项目声明了一个组名和版本,Java 版本及其依赖项:
group = 'se.magnus.microservices.core.product'
version = '1.0.0-SNAPSHOT'
sourceCompatibility = 1.8
repositories {
mavenCentral()
maven { url "https://repo.spring.io/snapshot" }
maven { url "https://repo.spring.io/milestone" }
}
dependencies {
implementation('org.springframework.boot:spring-boot-starter-
actuator')
implementation('org.springframework.boot:spring-boot-starter-
webflux')
testImplementation('org.springframework.boot:spring-boot-starter-
test')
testImplementation('io.projectreactor:reactor-test')
}
让我们更详细地解释上述源代码如下:
-
依赖项,像之前的插件一样,从中央 Maven 仓库和 Spring 的快照和里程碑仓库中获取。
-
依赖项是按照
Actuator和WebFlux模块中指定的设置的,还有一些有用的测试依赖项。
我们可以使用以下命令单独构建每个微服务:
cd microservices/product-composite-service; ./gradlew build; cd -; \
cd microservices/product-service; ./gradlew build; cd -; \
cd microservices/recommendation-service; ./gradlew build; cd -; \ cd microservices/review-service; ./gradlew build; cd -;
注意我们如何使用由 Spring Initializr 创建的gradlew可执行文件;也就是说,我们不需要安装 Gradle!
第一次运行gradlew命令时,它会自动下载 Gradle。使用的 Gradle 版本由gradle/wrapper/gradle-wrapper.properties文件中的distributionUrl属性确定。
在 Gradle 中设置多项目构建
为了使用一个命令构建所有微服务稍微简单一些,我们可以在 Gradle 中设置一个多项目构建。步骤如下:
- 首先,我们创建一个
settings.gradle文件,描述 Gradle 应该构建哪些项目:
cat <<EOF > settings.gradle
include ':microservices:product-service'
include ':microservices:review-service'
include ':microservices:recommendation-service'
include ':microservices:product-composite-service'
EOF
- 接下来,我们将复制从一个项目中生成的 Gradle 可执行文件,以便我们可以在多项目构建中重复使用它们:
cp -r microservices/product-service/gradle .
cp microservices/product-service/gradlew .
cp microservices/product-service/gradlew.bat .
cp microservices/product-service/.gitignore .
- 我们不再需要每个项目中生成的 Gradle 可执行文件,所以我们可以使用以下命令删除它们:
find microservices -depth -name "gradle" -exec rm -rfv "{}" \; find microservices -depth -name "gradlew*" -exec rm -fv "{}" \;
结果应该与您在$BOOK_HOME/Chapter03/1-spring-init文件夹中找到的代码类似。
- 现在,我们可以用一个命令构建所有微服务:
./gradlew build
如果您还没有运行前面的命令,您可以简单地直接去书源代码那里并从中构建:
cd $BOOK_HOME/Chapter03/1-spring-init
./gradlew build
- 这应该会导致以下输出:

使用 Spring Initializr 创建的微服务骨架项目和成功使用 Gradle 构建后,我们在下一节准备向微服务中添加一些代码。
从 DevOps 的角度来看,多项目设置可能不是首选。相反,为每个微服务项目设置一个单独的构建管道可能更受欢迎。然而,为了本书的目的,我们将使用多项目设置,以便更容易用一个命令构建和部署整个系统架构。
添加 RESTful API
现在我们已经为我们的微服务设置了项目,接下来让我们向我们的三个核心微服务中添加一些 RESTful API 吧!
本章剩余主题的最终结果可以在 $BOOK_HOME/Chapter03/2-basic-rest-services 文件夹中找到。
首先,我们将添加两个项目(api 和 util),它们将包含由微服务项目共享的代码,然后我们将实现 RESTful API。
添加 API 和 util 项目
要添加 API,我们需要执行以下操作:
- 首先,我们将建立一个单独的 Gradle 项目,用于放置我们的 API 定义。我们将使用 Java 接口来描述我们的 RESTful API,并使用模型类来描述 API 在其请求和响应中使用的数据。在我看来,使用 Java 接口而不是直接在 Java 类中描述 RESTful API 是一种很好的将 API 定义与其实现分离的方法。在本书的后续内容中,我们将进一步扩展这一模式,当我们向 Java 接口中添加更多 API 信息以在 Swagger/OpenAPI 定义中公开时。更多信息请参阅 第五章,使用 OpenAPI/Swagger 添加 API 描述。
描述 RESTful API 的 Java 接口直到 Spring Framework v5.1.0 才得到完全支持。具体请参阅 jira.spring.io/browse/SPR-11055。
是否应该将一组微服务的 API 定义存储在公共 API 模块中,这一点是有争议的。在我看来,这对于属于同一交付组织的微服务来说是一个不错的选择,也就是说,这些微服务的发布由同一个组织管理(与 Domain-Driven Design 中的 Bounded Context 相比,我们的微服务位于同一个 bounded context 中)。
- 然后,我们将创建一个
util项目,用于存放一些由我们的微服务共享的帮助类,例如,以统一的方式处理错误。
再次从 DevOps 的角度来看,最好为所有项目建立它们自己的构建管道,并在微服务项目中使用版本控制的 api 和 util 项目依赖;也就是说,每个微服务可以选择使用 api 和 util 项目的哪些版本。但为了在本书的上下文中保持构建和部署步骤简单,我们将使 api 和 util 项目成为多项目构建的一部分。
api 项目
api 项目将被打包为库;也就是说,它将没有自己的 main 应用程序类。不幸的是,Spring Initializr 不支持创建库项目。相反,库项目需要从头开始手动创建。API 项目的源代码可在 $BOOK_HOME/Chapter03/2-basic-rest-services/api 找到。
库项目的结构与应用程序项目相同,不同之处在于我们不再有main应用程序类,以及在build.gradle文件中的一些小差异。Gradle org.springframework.boot和io.spring.dependency-management插件被替换为一个dependencyManagement部分:
plugins {
id "io.spring.dependency-management" version "1.0.5.RELEASE"
}
dependencyManagement {
imports { mavenBom("org.springframework.boot:spring-boot-
dependencies:${springBootVersion}") }
}
这允许我们在替换构建步骤中构建胖 JAR 的方法为创建正常 JAR 文件的同时保留 Spring Boot 依赖管理;也就是说,它们只包含库项目自己的类和属性文件。
api项目中我们三个核心微服务的 Java 文件如下:
$BOOK_HOME/Chapter03/2-basic-rest-services/api/src/main/java/se/magnus/api/core
├── product
│ ├── Product.java
│ └── ProductService.java
├── recommendation
│ ├── Recommendation.java
│ └── RecommendationService.java
└── review
├── Review.java
└── ReviewService.java
三个核心微服务的 Java 类结构非常相似,所以我们只查看product服务的源代码。
首先,我们将查看ProductService.javaJava 接口,如下代码所示:
package se.magnus.api.core.product;
public interface ProductService {
@GetMapping(
value = "/product/{productId}",
produces = "application/json")
Product getProduct(@PathVariable int productId);
}
让我们更详细地解释一下前面的源代码:
-
product服务只暴露了一个 API 方法,getProduct()(我们将在本书后面扩展 API)。 -
为了将方法映射到 HTTP
GET请求,我们使用@GetMappingSpring 注解,其中我们指定方法将被映射到的 URL 路径(/product/{productId})以及响应的格式,这次是 JSON。 -
路径中的
{productId}部分映射到一个名为productId的path变量。 -
productId方法参数用@PathVariable注解标记,这将把通过 HTTP 请求传递的值映射到参数。例如,对/product/123的 HTTPGET请求将导致getProduct()方法以productId参数设置为123被调用。
该方法返回一个Product对象,这是一个基于 plain POJO 的模型类,其成员变量对应于Product的属性。Product.java如下所示(省略了构造函数和 getter 方法):
public class Product {
private final int productId;
private final String name;
private final int weight;
private final String serviceAddress;
}
这种 POJO 类也被称为数据传输对象(Data Transfer Object,DTO),因为它用于在 API 实现和 API 调用者之间传输数据。当我们在第六章中讨论添加持久化时,我们会看到另一种可以用来描述数据在数据库中存储方式的 POJO,也称为实体对象。
工具项目
util项目将以与api项目相同的方式打包为库。util项目的源代码可以在$BOOK_HOME/Chapter03/2-basic-rest-services/util中找到。该项目包含以下 Java 文件:
-
InvalidInputException和NotFoundException异常类 -
GlobalControllerExceptionHandler、HttpErrorInfo和ServiceUtil工具类
除了ServiceUtil.java中的代码,这些类是可重用的实用工具类,我们可以使用它们将 Java 异常映射到适当的 HTTP 状态码,如添加错误处理部分所述。ServiceUtil.java的主要目的是找出微服务使用的主机名、IP 地址和端口。该类暴露了一个方法getServiceAddress(),微服务可以使用它来找到它们的主机名、IP 地址和端口。
实现我们的 API
现在我们可以开始在核心微服务中实现我们的 API 了!
三个核心微服务的实现看起来非常相似,所以我们只查看product服务的源代码。你可以在$BOOK_HOME/Chapter03/2-basic-rest-services/microservices中找到其他文件。让我们看看我们是如何进行这项工作的:
- 我们需要在我们的
build.gradle文件中添加api和util项目作为依赖,即$BOOK_HOME/Chapter03/2-basic-rest-services/microservices/product-service/build.gradle:
dependencies {
implementation project(':api')
implementation project(':util')
- 为了使 Spring Boot 的自动配置功能能够检测到
api和util项目中的 Spring beans,我们还需要在主应用类中添加一个@ComponentScan注解,包括api和util项目的包:
@SpringBootApplication
@ComponentScan("se.magnus")
public class ProductServiceApplication {
- 接下来,我们创建我们的服务实现文件
ProductServiceImpl.java,以便实现api项目中的 Java 接口ProductService,并使用@RestController注解类,以便 Spring 根据Interface类中指定的映射调用这个类的方法:
package se.magnus.microservices.core.product.services;
@RestController
public class ProductServiceImpl implements ProductService {
}
- 为了能够使用来自
util项目的ServiceUtil类,我们将通过构造函数注入它,如下所示:
private final ServiceUtil serviceUtil;
@Autowired
public ProductServiceImpl(ServiceUtil serviceUtil) {
this.serviceUtil = serviceUtil;
}
- 现在,我们可以通过覆盖
api项目中的接口的getProduct()方法来实现 API:
@Override
public Product getProduct(int productId) {
return new Product(productId, "name-" + productId, 123,
serviceUtil.getServiceAddress());
}
由于我们目前不使用数据库,我们只需根据productId的输入返回一个硬编码的响应,加上由ServiceUtil类提供的服务地址。
对于最终结果,包括日志和错误处理,请参阅$BOOK_HOME/Chapter03/2-basic-rest-services/microservices/product-service/src/main/java/se/magnus/microservices/core/product/services/ProductServiceImpl.java。
- 最后,我们还需要设置一些运行时属性——使用哪个端口以及所需的日志级别。这添加到了
$BOOK_HOME/Chapter03/2-basic-rest-services/microservices/product-service/src/main/resources/application.yml属性文件中:
server.port: 7001
logging:
level:
root: INFO
se.magnus.microservices: DEBUG
- 我们可以尝试单独测试
product服务。使用以下命令构建并启动微服务:
cd $BOOK_HOME/Chapter03/2-basic-rest-services
./gradlew build
java -jar microservices/product-service/build/libs/*.jar &
- 等待终端打印以下内容:

- 对
product服务进行测试调用:
curl http://localhost:7001/product/123
- 它应该响应以下类似的内容:

- 最后,停止
product服务:
kill $(jobs -p)
我们已经构建、运行并测试了我们的第一个单一微服务。在下一节中,我们将实现一个复合微服务,该服务将使用我们迄今为止创建的三个核心微服务。
添加复合微服务
现在,是时候通过添加将调用三个核心服务的复合服务来整合一切了!
复合服务的实现分为两部分:一个处理对核心服务发出的 HTTP 请求的集成组件和复合服务实现本身。这种责任划分的主要原因是它简化了自动化单元和集成测试;也就是说,我们可以通过用模拟替换集成组件来孤立地测试服务实现。
正如我们在这本书后面所看到的,这种责任划分也使得引入断路器变得更容易!
在深入源代码之前,我们需要先了解复合微服务将使用的 API 类,以及学习运行时属性如何用于持有核心微服务的地址信息。
两个组件的完整实现,包括集成组件和复合服务的实现,可以在$BOOK_HOME/Chapter03/2-basic-rest-services/microservices/product-composite-service/src/main/java/se/magnus/microservices/composite/product/services文件夹中找到。
api 类
在本节中,我们将查看描述复合组件 API 的类。它们可以在$BOOK_HOME/Chapter03/2-basic-rest-services/api中找到。以下是要查看的 API 类:
$BOOK_HOME/Chapter03/2-basic-rest-services/api
└── src/main/java/se/magnus/api/composite
└── product
├── ProductAggregate.java
├── ProductCompositeService.java
├── RecommendationSummary.java
├── ReviewSummary.java
└── ServiceAddresses.java
ProductCompositeService.java这个 Java 接口类遵循与核心服务相同的模式,如下所示:
package se.magnus.api.composite.product;
public interface ProductCompositeService {
@GetMapping(
value = "/product-composite/{productId}",
produces = "application/json")
ProductAggregate getProduct(@PathVariable int productId);
}
模型类ProductAggregate.java比核心模型稍微复杂一些,因为它包含推荐和评论的列表字段:
package se.magnus.api.composite.product;
public class ProductAggregate {
private final int productId;
private final String name;
private final int weight;
private final List<RecommendationSummary> recommendations;
private final List<ReviewSummary> reviews;
private final ServiceAddresses serviceAddresses;
属性
为了避免在复合微服务的源代码中硬编码核心服务的地址信息,后者使用一个属性文件,其中存储了如何找到核心服务的信息。这个属性文件可以在$BOOK_HOME/Chapter03/2-basic-rest-services/microservices/product-composite-service/src/main/resources/application.yml中找到,如下所示:
server.port: 7000
app:
product-service:
host: localhost
port: 7001
recommendation-service:
host: localhost
port: 7002
review-service:
host: localhost
port: 7003
这种配置将在本书后面被服务发现机制所取代。
集成组件
让我们看看集成组件ProductCompositeIntegration.java。它使用@Component注解作为一个 Spring Bean 声明,并实现了三个核心服务的 API 接口:
package se.magnus.microservices.composite.product.services;
@Component
public class ProductCompositeIntegration implements ProductService, RecommendationService, ReviewService {
整合组件使用 Spring Framework 中的一个助手类RestTemplate.java来对核心微服务执行实际的 HTTP 请求。在我们能够将其注入整合组件之前,我们需要对其进行配置。我们是在main应用程序类ProductCompositeServiceApplication.java中如下完成的:
@Bean
RestTemplate restTemplate() {
return new RestTemplate();
}
RestTemplate高度可配置,但我们现在将其保留为其默认值。
我们现在可以在整合组件的构造函数中注入RestTemplate,以及用于错误处理的 JSON 映射器和我们 在属性文件中设置的配置值。让我们看看这是如何完成的:
- 用于设置三个核心服务 URL 的配置值如下所示注入到构造函数中:
private final RestTemplate restTemplate;
private final ObjectMapper mapper;
private final String productServiceUrl;
private final String recommendationServiceUrl;
private final String reviewServiceUrl;
@Autowired
public ProductCompositeIntegration(
RestTemplate restTemplate,
ObjectMapper mapper,
@Value("${app.product-service.host}") String productServiceHost,
@Value("${app.product-service.port}") int productServicePort,
@Value("${app.recommendation-service.host}") String
recommendationServiceHost,
@Value("${app.recommendation-service.port}") int
recommendationServicePort,
@Value("${app.review-service.host}") String reviewServiceHost,
@Value("${app.review-service.port}") int reviewServicePort
)
构造函数的正文根据注入的值构建 URL,如下所示:
{
this.restTemplate = restTemplate;
this.mapper = mapper;
productServiceUrl = "http://" + productServiceHost + ":" +
productServicePort + "/product/";
recommendationServiceUrl = "http://" + recommendationServiceHost
+ ":" + recommendationServicePort + "/recommendation?
productId="; reviewServiceUrl = "http://" + reviewServiceHost +
":" + reviewServicePort + "/review?productId=";
}
- 最后,整合组件通过使用
RestTemplate来实际发起调用,实现了三个核心服务的 API 方法:
public Product getProduct(int productId) {
String url = productServiceUrl + productId;
Product product = restTemplate.getForObject(url, Product.class);
return product;
}
public List<Recommendation> getRecommendations(int productId) {
String url = recommendationServiceUrl + productId;
List<Recommendation> recommendations =
restTemplate.exchange(url, GET, null, new
ParameterizedTypeReference<List<Recommendation>>()
{}).getBody();
return recommendations;
}
public List<Review> getReviews(int productId) {
String url = reviewServiceUrl + productId;
List<Review> reviews = restTemplate.exchange(url, GET, null,
new ParameterizedTypeReference<List<Review>>() {}).getBody();
return reviews;
}
让我们更详细地解释前面的源代码:
-
对于
getProduct()实现,RestTemplate中的getForObject()方法可以使用。预期的响应是一个Product对象, 它可以通过在getForObject()调用中指定Product.class类来表示,RestTemplate会将 JSON 响应映射到这个类。 -
对于
getRecommendations()和getReviews()的调用,必须使用一个更高级的方法,exchange()。这是因为RestTemplate执行了从 JSON 响应到模型类的自动映射。 -
getRecommendations()和getReviews()方法期望在响应中有一个泛型列表,即List<Recommendation>和List<Review>。由于泛型在运行时 不持有任何类型信息,我们不能指定方法期望在响应中有泛型列表。相反,我们可以使用 Spring Framework 中的一个助手类,ParameterizedTypeReference,这个类设计用来在运行时持有类型信息,解决 这个问题。这意味着RestTemplate可以弄清楚要将 JSON 响应映射到哪个类。为了使用这个助手类,我们必须使用更为复杂的exchange()方法而不是RestTemplate上的更简单的getForObject()方法。
组合 API 实现
最后,我们将查看组合微服务实现的最后一部分:ProductCompositeServiceImpl.java实现类。让我们一步步地来看:
- 与核心服务一样,组合服务实现了其 API 接口,
ProductCompositeService,并用@RestController注解标记为 REST 服务:
package se.magnus.microservices.composite.product.services;
@RestController
public class ProductCompositeServiceImpl implements ProductCompositeService {
- 实现类需要
ServiceUtilbean 及其自己的整合组件,所以它们是在其构造函数中注入的:
private final ServiceUtil serviceUtil;
private ProductCompositeIntegration integration;
@Autowired
public ProductCompositeServiceImpl(ServiceUtil serviceUtil, ProductCompositeIntegration integration) {
this.serviceUtil = serviceUtil;
this.integration = integration;
}
- 最后,API 方法如下实现:
@Override
public ProductAggregate getProduct(int productId) {
Product product = integration.getProduct(productId);
List<Recommendation> recommendations =
integration.getRecommendations(productId);
List<Review> reviews = integration.getReviews(productId);
return createProductAggregate(product, recommendations,
reviews, serviceUtil.getServiceAddress());
}
集成组件用于调用三个核心服务,并且使用一个助手方法createProductAggregate(),根据对集成组件的调用的响应创建ProductAggregate类型的响应对象。
助手方法createProductAggregate()的实现相当长,并不是很重要,因此在本章中省略;然而,它可以在本书的源代码中找到。
集成组件和复合服务的完整实现可以在$BOOK_HOME/Chapter03/2-basic-rest-services/microservices/product-composite-service/src/main/java/se/magnus/microservices/composite/product/services文件夹中找到。
从功能角度来看,复合微服务的实现已经完成。在下一节中,我们将了解如何添加源代码以处理错误。
添加错误处理
在微服务架构中,大量的微服务通过同步 API 进行通信,例如使用 HTTP 和 JSON,以结构化和深思熟虑的方式处理错误至关重要。将协议特定的错误处理,如 HTTP 状态码,与业务逻辑分离也很重要。
在实现微服务时,可以认为应当添加一个单独的层来处理业务逻辑。这应该确保业务逻辑与协议特定的代码相分离,从而使得测试和重用更加容易。为了避免本书中提供的示例不必要的复杂性,我们省略了业务逻辑的单独层,即微服务直接在@RestController组件中实现其业务逻辑。
我在util项目中创建了一套 Java 异常,这些异常既被 API 实现使用,也被 API 客户端使用,最初有InvalidInputException和NotFoundException。有关详细信息,请参见$BOOK_HOME/Chapter03/2-basic-rest-services/util/src/main/java/se/magnus/util/exceptions。
全局 REST 控制器异常处理
为了将协议特定的错误处理从 REST 控制器中分离,即 API 实现,我在util项目中创建了一个工具类GlobalControllerExceptionHandler.java,它被注解为@RestControllerAdvice。
对于 API 实现抛出的每个 Java 异常,工具类都有一个异常处理方法,它将 Java 异常映射到一个适当的 HTTP 响应,即具有适当的 HTTP 状态和 HTTP 响应体。
例如,如果一个 API 实现类抛出InvalidInputException,工具类将其映射为状态码设置为422(UNPROCESSABLE_ENTITY)的 HTTP 响应。以下代码展示了这一点:
@ResponseStatus(UNPROCESSABLE_ENTITY)
@ExceptionHandler(InvalidInputException.class)
public @ResponseBody HttpErrorInfo handleInvalidInputException(ServerHttpRequest request, Exception ex) {
return createHttpErrorInfo(UNPROCESSABLE_ENTITY, request, ex);
}
同样,NotFoundException被映射到404(NOT_FOUND)HTTP 状态码。
无论何时 REST 控制器抛出这些异常中的任何一个,Spring 都会使用实用类来创建一个 HTTP 响应。
请注意,当 Spring 检测到无效请求(例如,请求中包含非数字的产品 ID)时,它会返回 HTTP 状态码400(BAD_REQUEST)。在 API 声明中,productId指定为整数。
要查看实用类的完整源代码,请参阅$BOOK_HOME/Chapter03/2-basic-rest-services/util/src/main/java/se/magnus/util/http/GlobalControllerExceptionHandler.java。
API 实现中的错误处理
API 实现使用util项目中的异常来表示错误。它们将被报告回 REST 客户端,作为表明出了什么问题的 HTTP 状态码。例如,Product微服务实现类ProductServiceImpl.java使用InvalidInputException异常来返回一个指示无效输入的错误,以及使用NotFoundException异常告诉我们所请求的产品不存在。代码如下:
if (productId < 1) throw new InvalidInputException("Invalid productId:
" + productId);
if (productId == 13) throw new NotFoundException("No product found for
productId: " + productId);
由于我们目前没有使用数据库,我们必须模拟何时抛出NotFoundException。
API 客户端中的错误处理
API 客户端,即Composite微服务的集成组件,执行的是相反的操作;也就是说,它将422(UNPROCESSABLE_ENTITY)HTTP 状态码映射到InvalidInputException,并将404(NOT_FOUND)HTTP 状态码映射到NotFoundException。有关此错误处理逻辑的实现,请参阅ProductCompositeIntegration.java中的getProduct()方法。源代码如下:
catch (HttpClientErrorException ex) {
switch (ex.getStatusCode()) {
case NOT_FOUND:
throw new NotFoundException(getErrorMessage(ex));
case UNPROCESSABLE_ENTITY :
throw new InvalidInputException(getErrorMessage(ex));
default:
LOG.warn("Got a unexpected HTTP error: {}, will rethrow it",
ex.getStatusCode());
LOG.warn("Error body: {}", ex.getResponseBodyAsString());
throw ex;
}
}
集成组件中getRecommendations()和getReviews()的错误处理要宽松一些——归类为尽力而为,意思是如果成功获取了产品信息但未能获取推荐信息或评论,仍然认为是可以的。但是,会在日志中写入警告。
要了解更多信息,请参阅$BOOK_HOME/Chapter03/2-basic-rest-services/microservices/product-composite-service/src/main/java/se/magnus/microservices/composite/product/services/ProductCompositeIntegration.java。
完成了代码和组合微服务的实现。在下一节中,我们将测试微服务和它们暴露的 API。
测试 API 手动
这是我们微服务的实现结束。让我们通过执行以下步骤来尝试它们:
-
构建并作为后台进程启动它们。
-
使用
curl调用组合 API。 -
停止它们。
首先,以后台进程的形式构建和启动每个微服务,如下所示:
cd $BOOK_HOME/Chapter03/2-basic-rest-services/
./gradlew build
构建完成后,我们可以使用以下代码将我们的微服务作为后台进程启动到终端进程中:
java -jar microservices/product-composite-service/build/libs/*.jar &
java -jar microservices/product-service/build/libs/*.jar &
java -jar microservices/recommendation-service/build/libs/*.jar &
java -jar microservices/review-service/build/libs/*.jar &
会有很多日志消息被写入终端,但在几秒钟后,事情会平静下来,我们会在日志中找到以下消息:

这意味着它们都准备好接收请求。用以下代码尝试一下:
curl http://localhost:7000/product-composite/1
经过一些日志输出,我们将得到一个类似于以下的 JSON 响应:

为了获得美观的 JSON 响应,您可以使用jq工具:
curl http://localhost:7000/product-composite/1 -s | jq .
这会导致以下输出(为了提高可读性,一些细节被...替换):

如果您愿意,还可以尝试以下命令来验证错误处理是否如预期工作:
# Verify that a 404 (Not Found) error is returned for a non-existing productId (13)
curl http://localhost:7000/product-composite/13 -i
# Verify that no recommendations are returned for productId 113
curl http://localhost:7000/product-composite/113 -s | jq .
# Verify that no reviews are returned for productId 213
curl http://localhost:7000/product-composite/213 -s | jq .
# Verify that a 422 (Unprocessable Entity) error is returned for a productId that is out of range (-1)
curl http://localhost:7000/product-composite/-1 -i
# Verify that a 400 (Bad Request) error is returned for a productId that is not a number, i.e. invalid format
curl http://localhost:7000/product-composite/invalidProductId -i
最后,您可以使用以下命令关闭微服务:
kill $(jobs -p)
如果您使用的是 Spring Tool Suite 或 IntelliJ IDEA Ultimate Edition 作为您的 IDE,您可以使用它们的 Spring Boot 仪表板一键启动和停止您的微服务。
下面的截图显示了 Spring Tool Suite 的使用:

下面的截图显示了 IntelliJ IDEA Ultimate Edition 的使用:

在本节中,我们学习了如何手动启动、测试和停止合作微服务的系统景观。这类测试耗时较长,因此显然需要自动化。在接下来的两节中,我们将迈出学习如何自动化测试的第一步,测试单个微服务以及整个合作微服务的系统景观。在整个本书中,我们将改进我们如何测试微服务。
防止本地主机名查找缓慢
从 macOS Sierra 开始,在 macOS 上的 Java 程序中查找本地主机使用的 hostname 可能会花费很长时间,即 5 秒钟,使得测试变得非常缓慢。在使用 macOS Mojave 时,这个问题似乎得到了修复,但如果您使用的是较老版本的 macOS,这个问题很容易解决。
首先,您需要通过从 GitHub 下载一个小型工具并运行它来验证问题是否影响您:
git clone https://github.com/thoeni/inetTester.git
java -jar inetTester/bin/inetTester.jar
假设程序响应了类似以下内容:

如果您有 5 秒的响应时间,那么您遇到问题了!
解决方案是编辑/etc/hosts文件,在localhost之后添加您的本地主机名,在前面的示例中是Magnuss-Mac.local,例如:
127.0.0.1 localhost Magnuss-Mac.local
::1 localhost Magnuss-Mac.local
重新运行测试。它应该以几毫秒的响应时间响应,例如:

现在让我们看看如何为微服务添加隔离的自动化测试。
为微服务添加隔离的自动化测试
在我们完成实现之前,还需要编写一些自动化测试。
目前我们没有太多业务逻辑需要测试,所以不需要编写任何单元测试。相反,我们将重点测试我们微服务暴露的 API;也就是说,我们将以集成测试的方式启动它们,带有内嵌的 web 服务器,然后使用测试客户端执行 HTTP 请求并验证响应。随着 Spring WebFlux 的推出,出现了一个新的测试客户端WebTestClient,它提供了一个流畅的 API 来发送请求,然后在它的结果上应用断言。
以下是一个示例,我们通过执行以下操作来测试组合产品 API:
-
发送一个现有的产品的
productId,并断言我们得到一个 200 的 HTTP 响应码和一个包含所需productId以及一个推荐和一个评论的 JSON 响应。 -
发送一个缺失的
productId,并断言我们得到一个 404 的 HTTP 响应码和一个包含相关错误信息的 JSON 响应。
这两个测试的实现如下面的代码所示:
@Autowired
private WebTestClient client;
@Test
public void getProductById() {
client.get()
.uri("/product-composite/" + PRODUCT_ID_OK)
.accept(APPLICATION_JSON_UTF8)
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(APPLICATION_JSON_UTF8)
.expectBody()
.jsonPath("$.productId").isEqualTo(PRODUCT_ID_OK)
.jsonPath("$.recommendations.length()").isEqualTo(1)
.jsonPath("$.reviews.length()").isEqualTo(1);
}
让我们更详细地解释一下前面的源代码:
-
该测试使用流畅的
WebTestClientAPI 来设置要调用的 URL"/product-composite/" + PRODUCT_ID_OK,并指定接受的响应格式,即 JSON。 -
在使用
exchange()方法执行请求后,测试验证响应状态是 OK(200)并且实际的响应格式确实是 JSON(如所请求的)。 -
最终,该测试检查响应体,并验证它包含了关于
productId以及推荐次数和评论数预期的信息。
第二个测试如下所示:
@Test
public void getProductNotFound() {
client.get()
.uri("/product-composite/" + PRODUCT_ID_NOT_FOUND)
.accept(APPLICATION_JSON_UTF8)
.exchange()
.expectStatus().isNotFound()
.expectHeader().contentType(APPLICATION_JSON_UTF8)
.expectBody()
.jsonPath("$.path").isEqualTo("/product-composite/" +
PRODUCT_ID_NOT_FOUND)
.jsonPath("$.message").isEqualTo("NOT FOUND: " +
PRODUCT_ID_NOT_FOUND);
}
让我们更详细地解释一下前面的源代码:
- 这个负测试在结构上与前面的测试非常相似;主要区别是它验证了返回了一个错误状态码,未找到(404),并且响应体包含了预期的错误消息。
为了单独测试组合产品 API,我们需要模拟其依赖项,即由集成组件ProductCompositeIntegration执行的对其他三个微服务的请求。我们使用 Mockito 来实现,如下所示:
private static final int PRODUCT_ID_OK = 1;
private static final int PRODUCT_ID_NOT_FOUND = 2;
private static final int PRODUCT_ID_INVALID = 3;
@MockBean
private ProductCompositeIntegration compositeIntegration;
@Before
public void setUp() {
when(compositeIntegration.getProduct(PRODUCT_ID_OK)).
thenReturn(new Product(PRODUCT_ID_OK, "name", 1, "mock-address"));
when(compositeIntegration.getRecommendations(PRODUCT_ID_OK)).
thenReturn(singletonList(new Recommendation(PRODUCT_ID_OK, 1,
"author", 1, "content", "mock address")));
when(compositeIntegration.getReviews(PRODUCT_ID_OK)).
thenReturn(singletonList(new Review(PRODUCT_ID_OK, 1, "author",
"subject", "content", "mock address")));
when(compositeIntegration.getProduct(PRODUCT_ID_NOT_FOUND)).
thenThrow(new NotFoundException("NOT FOUND: " +
PRODUCT_ID_NOT_FOUND));
when(compositeIntegration.getProduct(PRODUCT_ID_INVALID)).
thenThrow(new InvalidInputException("INVALID: " +
PRODUCT_ID_INVALID));
}
让我们更详细地解释一下前面的源代码:
-
首先,我们在测试类中声明了三个常量,分别用于
PRODUCT_ID_OK、PRODUCT_ID_NOT_FOUND和PRODUCT_ID_INVALID。 -
如果对集成组件调用
getProduct()、getRecommendations()和getReviews()方法,并且productId设置为PRODUCT_ID_OK,则模拟将返回一个正常响应。 -
如果
getProduct()方法以PRODUCT_ID_NOT_FOUND设置productId,则模拟将抛出NotFoundException。 -
如果以
PRODUCT_ID_INVALID设置productId调用getProduct()方法,则模拟将抛出InvalidInputException。
可以在$BOOK_HOME/Chapter03/2-basic-rest-services/microservices/product-composite-service/src/test/java/se/magnus/microservices/composite/product/ProductCompositeServiceApplicationTests.java找到对复合产品 API 的自动化集成测试的完整源代码。
三个核心微服务暴露的 API 上的自动化集成测试类似,但更简单,因为它们不需要模拟任何内容!测试的源代码可以在每个微服务的test文件夹中找到。
当执行构建时,Gradle 会自动运行测试:
./gradlew build
然而,你可以指定只想运行测试(而不执行构建的其余部分):
./gradlew test
这是介绍如何为微服务编写隔离测试的介绍。在下一节中,我们将学习如何编写自动测试整个微服务景观的测试。在本章中,这些测试将是半自动化的。在后续章节中,测试将完全自动化,这是一个显著的改进。
添加对微服务景观的半自动化测试
当然,能够自动测试每个微服务是很有用的,但不够!
我们需要一种自动测试所有微服务的方法,以确保它们提供我们所期望的内容!
为此,我编写了一个简单的 bash 脚本,可以使用 curl 对 RESTful API 进行调用并验证其返回代码及其 JSON 响应的一部分,使用 jq。脚本包含两个辅助函数,assertCurl() 和 assertEqual(),以使测试代码更加紧凑,易于阅读。
例如,发送一个正常请求,期望状态码为 200,以及断言我们返回的 JSON 响应返回请求的productId,还附带三个推荐和三个评论,如下所示:
# Verify that a normal request works, expect three recommendations and three reviews
assertCurl 200 "curl http://$HOST:${PORT}/product-composite/1 -s"
assertEqual 1 $(echo $RESPONSE | jq .productId)
assertEqual 3 $(echo $RESPONSE | jq ".recommendations | length")
assertEqual 3 $(echo $RESPONSE | jq ".reviews | length")
验证我们返回404 (Not Found)作为 HTTP 响应代码(当我们尝试查找不存在的产品)如下所示:
# Verify that a 404 (Not Found) error is returned for a non-existing productId (13)
assertCurl 404 "curl http://$HOST:${PORT}/product-composite/13 -s"
测试脚本实现了在手动测试 API部分描述的手动测试,可以在$BOOK_HOME/Chapter03/2-basic-rest-services/test-em-all.bash找到。
尝试测试脚本
为了尝试测试脚本,执行以下步骤:
- 首先,像以前一样启动微服务:
cd $BOOK_HOME/Chapter03/2-basic-rest-services
java -jar microservices/product-composite-service/build/libs/*.jar
& java -jar microservices/product-service/build/libs/*.jar &
java -jar microservices/recommendation-service/build/libs/*.jar &
java -jar microservices/review-service/build/libs/*.jar &
- 一旦它们都启动完毕,运行测试脚本:
./test-em-all.bash
- 期望输出如下所示:

- 用以下命令关闭微服务:
kill $(jobs -p)
在本节中,我们迈出了自动化测试合作微服务系统景观的第一步,所有这些都将在本章后续部分进行改进。
总结
现在我们已经使用 Spring Boot 构建了我们的几个微服务。在介绍了我们将在此书中使用的微服务景观之后,我们学习了如何使用 Spring Initializr 创建每个微服务的骨架项目。
接下来,我们学习了如何使用 Spring WebFlux 为三个核心服务添加 API,并实现了一个组合服务,该服务使用三个核心服务的 API 来创建它们中信息的聚合视图。组合服务使用 Spring Framework 中的RestTemplate类来对核心服务公开的 API 执行 HTTP 请求。在为服务添加错误处理逻辑后,我们在微服务架构上进行了一些手动测试。
我们通过学习如何在隔离环境中为微服务添加测试以及它们作为一个系统架构一起工作时的测试来结束这一章。为了为组合服务提供受控的隔离,我们使用 Mockito 模拟其对核心服务的依赖。整个系统架构的测试是通过一个 bash 脚本完成的,该脚本使用curl对组合服务的 API 执行调用。
有了这些技能,我们准备好了下一步,进入下一章的 Docker 和容器世界!在接下来的内容中,我们将学习如何使用 Docker 完全自动化测试一个合作微服务的系统架构。
问题
-
使用spring init Spring Initializr CLI 工具创建新的 Spring Boot 项目时,列出可用依赖项的命令是什么?
-
你如何设置Gradle,用一个命令就能构建多个相关联的项目?
-
@PathVariable和@RequestParam注解是用来做什么的? -
在 API 实现类中,你如何将协议特定的错误处理与业务逻辑分开?
-
Mockito是用来做什么的?
第四章:使用 Docker 部署我们的微服务。
在本章中,我们将开始使用 Docker 并将我们的微服务放入容器中!
到本章末尾,我们将运行完全自动化的微服务架构测试,以 Docker 容器的形式启动我们的所有微服务,除了 Docker 引擎之外不需要其他基础架构。我们还将运行一系列测试,以验证微服务按预期一起工作,并在最后关闭所有微服务,不留下我们执行的测试的任何痕迹。
能够以这种方式测试多个协作的微服务非常有用。作为开发者,我们可以在本地开发机上验证其工作效果。我们还可以在构建服务器上运行完全相同的测试,以自动验证源代码的更改不会在系统层面破坏测试。此外,我们不需要为运行这些类型的测试分配专用的基础架构。在接下来的章节中,我们将了解如何将数据库和队列管理器添加到我们的测试架构中,所有这些都将作为 Docker 容器运行。
然而,这并不取代自动化单元和集成测试的需要,这些测试孤立地测试单个微服务。它们的重要性与日俱增。
对于生产使用,如本书前面提到的,我们需要一个容器编排器,如 Kubernetes。我们将在本书后面回到容器编排器和 Kubernetes。
本章将涵盖以下主题:
-
容器简介。
-
Docker 和 Java。Java 在历史上对容器并不友好,但随着 Java 10 的发布,这一切都改变了。让我们看看 Docker 和 Java 在这个话题上是如何结合在一起的!
-
使用 Docker 和一个微服务。
-
使用 Docker Compose 管理微服务架构。
-
自动测试它们全部。
技术要求
本书中描述的所有命令都是在 MacBook Pro 上使用 macOS Mojave 运行的,但如果你想在其他平台(如 Linux 或 Windows)上运行它们,应该很容易进行修改。
除了前章的技术要求之外,我们还需要安装 Docker。Docker 社区版可以从 store.docker.com/search?type=edition&offering=community下载。
为了能够运行本书中的示例,建议您配置 Docker,使其可以使用除一个以外的所有 CPU(将所有 CPU 分配给 Docker 可能会在测试运行时使计算机失去响应)以及至少 6 GB 的内存。这可以在 Docker 的“偏好设置”的“高级”选项卡中配置,如下面的屏幕截图所示:

本章的源代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud/tree/master/Chapter04。
为了能够运行本书中描述的命令,将源代码下载到一个文件夹中,并设置一个环境变量$BOOK_HOME,该变量指向该文件夹。一些示例命令如下:
export BOOK_HOME=~/Documents/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud
git clone https://github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud $BOOK_HOME
cd $BOOK_HOME/Chapter04
本章所用的 Java 源代码是为 Java 8 编写的,并在 Java 12 上进行了测试运行。在本章写作时,Spring Boot 的最新版本是 2.1.0(以及 Spring 5.1.2)。
本章中的代码示例都来自$BOOK_HOME/Chapter04的源代码,但在许多情况下,已经编辑了源代码中不相关部分,例如注释、导入和日志声明。
如果你想查看本章应用于源代码中的更改,即了解为 Docker 添加支持所做的工作,你可以将第三章创建一组协作的微服务的源代码进行比较,创建一组协作的微服务。你可以使用你喜欢的diff工具,比较两个文件夹$BOOK_HOME/Chapter03/2-basic-rest-services和$BOOK_HOME/Chapter04。
容器入门
正如我们在第二章 Spring Boot 入门中提到的,Docker 在 2013 年使容器作为轻量级虚拟机替代品的概念变得非常流行。容器实际上是在使用 Linux 命名空间的 Linux 主机上处理的,以提供隔离容器之间全局系统资源的隔离,例如用户、进程、文件系统和网络。Linux 控制组(也称为cgroups)用于限制容器可以消耗的 CPU 和内存量。与在每台虚拟机中使用虚拟化器运行操作系统完整副本相比,容器的开销只是虚拟机开销的一小部分。这导致了更快的启动时间以及 CPU 和内存使用上的显著降低。然而,容器提供的隔离被认为不如虚拟机提供的隔离安全。随着 Windows Server 2016 和 Windows 10 Pro(1607 周年更新)的发布,微软也开始支持在 Windows 上使用 Docker。请看下面的图表:

前一个图表说明了虚拟机和容器的资源使用差异,可视化同一类型的服务器可以运行远比虚拟机更多的容器。
运行我们的第一个 Docker 命令
- 让我们尝试通过使用 Docker 的
run命令在 Docker 中启动一个 Ubuntu 服务器:
docker run -it --rm ubuntu
-
使用前面的命令,我们要求 Docker 创建一个运行 Ubuntu 的容器,基于官方 Docker 镜像中可用的最新版本的 Ubuntu。
-it选项用于使我们能够使用终端与容器交互,--rm选项告诉 Docker,一旦我们退出终端会话,就删除容器;否则,容器将保留在 Docker 引擎中,状态为Exited。 -
第一次使用我们没有自己构建的 Docker 镜像时,Docker 将从 Docker 注册表中下载它,默认是 Docker Hub (
hub.docker.com)。这需要一些时间,但对于该 Docker 镜像的后续使用,容器将在几秒钟内启动! -
一旦 Docker 镜像下载完毕并启动容器,Ubuntu 服务器应该会以如下提示响应:

- 我们可以尝试通过询问它运行的是哪个版本的 Ubuntu 来测试容器:
cat /etc/os-release | grep 'VERSION='
- 它应该会像下面这样响应:

-
我们可以使用
exit命令离开容器,并验证使用docker ps -a命令 Ubuntu 容器是否不再退出。我们需要使用-a选项来查看停止的容器;否则,只显示运行中的容器。 -
如果你更喜欢 CentOS 而不是 Ubuntu,可以尝试使用
docker run --rm -it centos命令。一旦 CoreOS 服务器在其容器中启动运行,你可以,例如,使用cat /etc/redhat-release命令询问它运行的是哪个版本的 CoreOS。它应该会像下面这样响应:

-
使用
exit命令离开容器以删除它。 -
如果你发现 Docker 引擎中有许多不想要的容器,并且你想获得一个干净的起点,即摆脱它们全部,你可以运行以下命令:
docker rm -f $(docker ps -aq)
-
docker rm -f命令停止并删除指定容器 ID 的容器。docker ps -aq命令列出 Docker 引擎中所有运行和停止容器的容器 ID。-q选项减少docker ps命令的输出,使其只列出容器 ID。 -
在了解 Docker 是什么之后,接下来我们可以理解在 Docker 中运行 Java 时可能遇到的问题。
- 在 Docker 中运行 Java 的挑战
- 当谈到 Java 时,过去几年里,有很多尝试让 Java 在 Docker 中良好地运行。目前,Java 的官方 Docker 镜像基于 OpenJDK:
hub.docker.com/_/openjdk/。我们将使用带有 Docker 标签openjdk:12.0.2的 Java SE 12,即 Java SE v12.0.2。
历史上,Java 在尊重 Docker 容器中 Linux cgroups 指定的配额方面做得并不好;它只是简单地忽略了这些设置。因此,Java 并不是在 JVM 内部根据容器中可用的内存来分配内存,而是好像它能够访问 Docker 主机的所有内存,这显然是不好的!同样,Java 分配与 Docker 主机的总 CPU 核心数相关的资源,如线程池,而不是为运行的 JVM 分配的 CPU 核心数。在 Java SE 9 中,提供了一些初始支持,这也被反向移植到了 Java SE 8 的后续版本中。然而,在 Java 10 中,对 CPU 和内存约束提供了大幅改进的支持。
让我们试一试!
首先,我们将尝试在本地下执行 Java 命令,不使用 Docker,因为这将告诉我们 JVM 看到多少内存和 CPU 核心数。接下来,我们将使用 Java SE 12 在 Docker 中尝试这些命令,以验证它是否尊重我们在其中运行的 Docker 容器上设置的约束。最后,我们还将尝试一个 Java SE 9 容器,并看看它如何不尊重约束以及可能造成什么问题。
没有 Docker 的 Java
在我们将自己投入到 Docker 之前,让我们不使用 Docker 尝试 Java 命令,以熟悉 Java 命令!
让我们先找出 Java 在 Docker 外部运行时看到的有多少可用处理器,即 CPU 核心数。我们可以通过将 Runtime.getRuntime().availableprocessors() Java 语句发送到 Java CLI 工具 jshell 来完成这个操作:
echo 'Runtime.getRuntime().availableProcessors()' | jshell -q
jshell 需要 Java SE 9 或更高版本!
在我的机器上,我得到以下响应:

好吧,12 个核心是符合预期的,因为我的笔记本电脑的处理器是六核心的英特尔 Core i9 CPU,具有超线程技术(操作系统为每个物理核心看到两个虚拟核心)。
关于可用的内存量,让我们询问 JVM 它认为可以为其堆分配的最大大小。我们可以通过使用 -XX:+PrintFlagsFinal Java 选项向 JVM 请求额外的运行时信息,然后使用 grep 命令过滤出 MaxHeapSize 参数来实现这一点:
java -XX:+PrintFlagsFinal -version | grep MaxHeapSize
在我的机器上,我得到以下响应:

8589934592 字节碰巧正好是 8 GB,即 8 * 1,024³。由于我们没有为 JVM 使用 -Xmx 参数指定任何最大堆大小,JVM 将最大值设置为可用内存的四分之一。由于我的笔记本电脑有 32 GB 的内存,32/4=8,这也是符合预期的!
让我们通过验证能够将最大堆内存大小通过 -Xmx 参数降低到例如 200 MB 来总结一下:
java -Xmx200m -XX:+PrintFlagsFinal -version | grep MaxHeapSize
JVM 将响应为 209,715,200 字节,即 200 * 1,024³ 字节 = 200 MB,符合预期!
既然我们已经了解了在没有 Docker 的情况下 Java 命令是如何工作的,那么让我们试着用 Docker 来执行这个命令!
Docker 中的 Java
让我们看看 Java SE 12 如何响应我们在其运行的容器中设置的限制!
由于我使用的是 Docker for macOS,实际上我是在我的 MacBook Pro 上的虚拟机上运行 Docker 引擎作为 Docker 宿主。我已经为 macOS 配置了 Docker,使其允许 Docker 宿主使用我 macOS 中的所有 12 个核心,但只使用最多 16GB 内存。总的来说,Docker 宿主有 12 个核心和 16GB 内存。
CPU
首先,我们不施加任何限制,也就是说,我们用同样的测试方法,但是不使用 Docker:
echo 'Runtime.getRuntime().availableProcessors()' | docker run --rm -i openjdk:12.0.2 jshell -q
这个命令会将Runtime.getRuntime().availableProcessors()字符串发送到 Docker 容器,该容器将使用jshell处理这个字符串。
它将响应同样的结果,即在我的情况下为$1 ==> 12。让我们继续限制 Docker 容器只能使用三个 CPU 核心,使用--cpus 3 Docker 选项,并询问 JVM 它看到了多少可用的处理器:
echo 'Runtime.getRuntime().availableProcessors()' | docker run --rm -i --cpus 3 openjdk:12.0.2 jshell -q
JVM 现在响应为$1 ==> 3,即 Java SE 12 尊重容器中的设置,因此,它能够正确配置与 CPU 相关的资源,比如线程池!
让我们试着指定可用的 CPU 的相对份额,而不是 CPU 的确切数量。1024 个份额默认对应一个核心,所以如果我们想要将容器限制为两个核心,我们将--cpu-shares Docker 选项设置为 2048,像这样:
echo 'Runtime.getRuntime().availableProcessors()' | docker run --rm -i --cpu-shares 2048 openjdk:12.0.2 jshell -q
JVM 将响应$1 ==> 2,即 Java SE 12 也尊重相对share选项!
尽管--cpus选项是一个硬性限制,但--cpu-shares选项只有在 Docker 宿主承受高负载时才会生效。这意味着,如果 CPU 资源可用,容器可以消耗比share选项显示的更多的 CPU。
接下来,让我们尝试限制内存量。
内存
如果没有内存限制,Docker 将把内存的四分之一分配给容器:
docker run -it --rm openjdk:12.0.2 java -XX:+PrintFlagsFinal -version | grep MaxHeapSize
它将响应 4,202,692,608 字节,等于 4GB,即8 * 1024³。由于我的 Docker 宿主有 16GB 内存,这是正确的,即16/4 = 4。
然而,如果我们限制 Docker 容器只能使用最多 1GB 内存,使用-m=1024M Docker 选项,我们会看到较低的内存分配:
docker run -it --rm -m=1024M openjdk:12.0.2 java -XX:+PrintFlagsFinal -version | grep MaxHeapSize
JVM 将响应 268,435,456 字节,即 256MB,也就是2 * 1024²字节。256MB 是 1GB 的四分之一,所以这也在意料之中。
我们可以像往常一样,自己设置最大堆大小。例如,如果我们想要允许堆内存使用 1GB 中的 800MB,我们可以使用-Xmx800m Java 选项指定:
docker run -it --rm -m=1024M openjdk:12.0.2 java -Xmx800m -XX:+PrintFlagsFinal -version | grep MaxHeapSize
JVM 将响应 838,860,800 字节= 800 * 1024²字节= 800MB,如预期一样。
最后,让我们通过一些内存溢出测试来确保这真的有效。
让我们使用jshell在分配了 1GB 内存的 JVM 中尝试,也就是说,它的最大堆大小为 256MB。
首先,尝试分配一个 100 MB 的字节数组:
echo 'new byte[100_000_000]' | docker run -i --rm -m=1024M openjdk:12.0.2 jshell -q
命令将会回应$1 ==>,意味着它工作得很好!
通常,jshell将打印出命令的结果值,但是 100 MB 的字节数组全部设置为零输出太多,所以我们什么也没有。
现在,让我们尝试分配一个大于最大堆大小的字节数组,例如 500 MB:
echo 'new byte[500_000_000]' | docker run -i --rm -m=1024M openjdk:12.0.2 jshell -q
JVM 看到它不能执行该操作,因为它尊重容器的最大内存设置,并立即回应Exception java.lang.OutOfMemoryError: Java heap space。太好了!
如果我们使用一个不尊重容器设置的最大内存的 JVM 会怎样?
让我们用 Java SE 9 来找出答案!
Docker 和 Java SE 9(或更早版本)的问题
首先,尝试使用openjdk:9-jdk镜像将 Java SE 9 JVM 限制在三个 CPU 核心。
Java 9 无法遵守三个 CPU 的限制:
echo 'Runtime.getRuntime().availableProcessors()' | docker run --rm -i --cpus 3 openjdk:9-jdk jshell -q
在我的机器上,它回应为$1 ==> 12,也就是说,它忽略了三个 CPU 核心的限制。
如果我们尝试--cpu-shares选项,我们也会得到同样的结果,即$1 ==> 12:
echo 'Runtime.getRuntime().availableProcessors()' | docker run --rm -i --cpu-shares 2048 openjdk:9-jdk jshell -q
现在,尝试将内存限制为 1 GB:
docker run -it --rm -m=1024M openjdk:9-jdk java -XX:+PrintFlagsFinal -version | grep MaxHeapSize
如预期那样,Java SE 9 不尊重我们在 Docker 中设置的内存约束;也就是说,它报告最大堆大小为 4,202,692,608 字节= 4 GB – 4 * 1024³字节。在这里,Java 9 在给定 Docker 主机的内存时计算了可用的内存,而不是在实际的容器中!
那么,如果我们重复对 Java SE 12 进行的内存分配测试呢?
让我们尝试第一个测试,即分配一个 100 MB 数组:
echo 'new byte[100_000_000]' | docker run -i --rm -m=1024M openjdk:9-jdk jshell -q
命令回应为$1 ==> byte[100000000] { 0, 0, 0, ...,所以这工作得很好!
现在,让我们进行一个真正有趣的测试:如果我们为 Docker 分配给容器的内存中分配一个 500 MB 的字节数组,会发生什么?
echo 'new byte[500_000_000]' | docker run -i --rm -m=1024M openjdk:9-jdk jshell -q
从 Java 的角度来看,这应该可以工作。由于 Java 认为总内存为 16 GB,它已将最大堆大小设置为 4 GB,因此它开始为字节数组分配 500 MB。但是过了一会儿,JVM 的总大小超过 1 GB,Docker 将无情地杀死容器,导致诸如State engine terminated的混淆异常。我们基本上不知道出了什么问题,尽管我们可以猜测我们耗尽了内存。
所以,总结一下,如果你计划在 Docker 和 Java 上做任何严肃的工作,确保你使用 Java SE 10 或更高版本!
公平地说,应该提到 Java SE 9 包含对 cgroups 的一些初步支持。如果你指定了 Java 选项-XX:+UnlockExperimentalVMOptions和-XX:+UseCGroupMemoryLimitForHeap,它将尊重 cgroup 约束的一部分,但不是全部,并且应该注意的是这仅是实验性的。由于这一点,应该避免在生产环境中使用。简单地在 Docker 中使用 Java SE 10 或更高版本!
使用单个微服务的 Docker
既然我们理解了 Java 的工作原理,我们就可以开始使用 Docker 与我们其中一个微服务一起工作了。在我们能够将微服务作为 Docker 容器运行之前,我们需要将其打包到 Docker 镜像中。要构建 Docker 镜像,我们需要一个 Dockerfile,所以我们从那个开始。接下来,我们需要为我们的微服务创建一个 Docker 特定的配置。由于在容器中运行的微服务与其他微服务隔离,例如,它有自己的 IP 地址、主机名和端口,因此它需要与在同一主机上与其他微服务一起运行时的配置不同。例如,由于其他微服务不再在同一主机上运行,所以不会发生端口冲突。当在 Docker 中运行时,我们可以为所有微服务使用默认端口 8080,而无需担心端口冲突的风险。另一方面,如果我们需要与其他微服务通信,我们不能再像在同一主机上运行它们时那样使用 localhost。微服务的源代码不会受到将微服务以容器形式运行的影响,只有它们的配置会受到影响。
为了处理在没有 Docker 的情况下本地运行和作为 Docker 容器运行微服务时所需的不同配置,我们将使用 Spring 配置文件。自从第三章 创建一组协作的微服务 以来,我们就一直在使用默认的 Spring 配置文件来本地运行而不使用 Docker,因此我们将创建一个名为 docker 的 Spring 配置文件,用于在 Docker 中作为容器运行我们的微服务。
源代码中的更改
我们将使用 product 微服务,该微服务可以在源代码中的 $BOOK_HOME/Chapter04/microservices/product-service/ 找到。在下一节中,我们将也将这个应用到其他微服务上。
首先,我们在属性文件 $BOOK_HOME/Chapter04/microservices/product-service/src/main/resources/application.yml 的末尾添加 Docker 的 Spring 配置文件:
---
spring.profiles: docker
server.port: 8080
Spring 配置文件可以用来指定特定环境的配置,这里的情况是指当微服务在 Docker 容器中运行时才使用该配置。其他例子是那些特定于 dev、test 和生产环境的配置。配置文件中的值会覆盖默认值,即默认配置文件中的值。使用 .yaml 文件,可以在同一个文件中放置多个 Spring 配置文件,它们之间用 --- 分隔。
我们唯一要更改的参数是正在使用的端口;也就是说,当微服务在容器中运行时,我们将使用默认端口 8080。
接下来,我们将创建一个 Dockerfile,用于构建 Docker 镜像,$BOOK_HOME/Chapter04/microservices/product-service/Dockerfile。它看起来像这样:
FROM openjdk:12.0.2
EXPOSE 8080
ADD ./build/libs/*.jar app.jar
ENTRYPOINT ["java","-jar","/app.jar"]
需要注意的一些事情如下:
-
我们将基于 OpenJDK 的官方 Docker 镜像,并使用 Java SE v12.0.2。
-
我们将向其他 Docker 容器暴露端口
8080。 -
我们从 Gradle 构建库
build/libs中添加我们的fat-jar文件到 Docker 镜像中: -
我们将指定 Docker 在容器启动时使用的命令,即
java -jar /app.jar。
在考虑源代码中的这些更改之后
构建 Docker 镜像
要构建 Docker 镜像,我们需要为product-service构建部署工件,即脂肪文件:
cd $BOOK_HOME/Chapter04
./gradlew :microservices:product-service:build
由于我们只想构建product-service及其依赖项api和util,所以我们不使用正常的build命令(它会构建所有微服务),而是使用一个告诉 Gradle 只构建product-service的变体::microservices:product-service:build。
我们可以在 Gradle 构建库build/libs中找到fat-jar文件。例如,ls -l microservices/product-service/build/libs命令将会报告如下内容:

正如你所看到的,JAR 文件的大小接近 20 MB——难怪它们被称为fat-jar文件!
如果你好奇它的实际内容,可以使用unzip -l microservices/product-service/build/libs/product-service-1.0.0-SNAPSHOT.jar命令查看。
接下来,我们将构建 Docker 镜像并将其命名为product-service,如下所示:
cd microservices/product-service
docker build -t product-service .
Docker 将使用当前目录中的 Dockerfile 来构建 Docker 镜像。该镜像将被命名为product-service并存储在 Docker 引擎内部。
验证我们是否获取了 Docker 镜像,使用以下命令:
docker images | grep product-service
预期的输出如下:

既然我们已经构建了镜像,那么让我们看看如何启动服务。
启动服务
使用以下命令以容器形式启动product微服务:
docker run --rm -p8080:8080 -e "SPRING_PROFILES_ACTIVE=docker" product-service
这是我们从前面的代码可以推断出的事情:
-
docker run:Docker 运行命令将启动容器并在终端中显示日志输出。只要容器运行,终端就会被锁定。 -
我们已经看到了
--rm选项;它将告诉 Docker 我们在使用Ctrl + C从终端停止执行时清理容器。 -
-p8080:8080选项将容器中的端口8080映射到 Docker 主机的端口8080,这使得它可以从外部调用。在 macOS 上的 Docker 中,Docker 在本地 Linux 虚拟机中运行,端口也将被映射到 macOS 上,在本地主机上可用。我们只能在 Docker 主机上有一个特定端口的容器映射! -
使用
-e选项,我们可以为容器指定环境变量,这个例子中是SPRING_PROFILES_ACTIVE=docker。SPRING_PROFILES_ACTIVE环境变量用于告诉 Spring 使用哪个配置文件。在我们的例子中,我们希望 Spring 使用docker配置文件。 -
最后,我们有了
product-service,这是 Docker 将用来启动容器的 Docker 镜像的名称。
预期的输出如下:

这是我们从上述输出中推断出的:
-
Spring 使用的配置文件是
docker。在输出中查找以下配置文件处于活动状态: docker来验证这一点。 -
容器分配的端口是
8080。在输出中查找Netty started on port(s): 8080来验证这一点。 -
当日志消息
Started ProductServiceApplication被写入时,微服务就准备好接受请求了!
在另一个终端窗口尝试以下代码:
curl localhost:8080/product/3
注意我们可以使用 localhost 上的端口8080,如前所述!
以下是预期输出:

这与我们从上一章获得的输出类似,但有一个主要区别;我们有"service Address":"aebb42b32fef/172.17.0.2:8080"的内容,端口是8080,如预期那样,IP 地址172.17.0.2是一个从 Docker 内部网络分配给容器的 IP 地址——但是主机名aebb42b32fef是从哪里来的?
询问 Docker 所有正在运行的容器:
docker ps
我们会看到类似以下内容:

从上述输出中我们可以看到,主机名相当于容器的 ID,如果你想要了解哪个容器实际响应了你的请求,这一点是很有帮助的!
用Ctrl + C命令停止终端中的容器。完成这一步后,我们可以继续运行分离的容器。
分离运行容器
好的,这很好,但如果我们不想挂起我们从哪里开始容器的终端窗口怎么办?
是时候开始作为分离容器运行了,也就是说,运行容器而不锁定终端!
我们可以通过添加-d选项并同时使用--name选项为其命名来实现。由于我们将在使用完毕时明确停止和删除容器,所以不再需要--rm选项:
docker run -d -p8080:8080 -e "SPRING_PROFILES_ACTIVE=docker" --name my-prd-srv product-service
如果我们再次运行docker ps命令,我们将看到我们新创建的容器,名为my-prd-srv:

但是,我们如何获取容器的日志输出呢?
介绍 Docker 的logs命令:
docker logs my-prd-srv -f
-f选项告诉命令跟随日志输出,即,当所有当前日志输出被写入终端时,不要结束命令,但也要等待更多输出。如果你预期有很多不想看到的旧日志消息,你还可以添加--tail 0选项,这样你只看到新的日志消息。或者,你可以使用--since选项,并使用绝对时间戳或相对时间,例如--since 5m,来看最多五分钟内的日志消息。
用一个新的curl请求尝试这个。你应该看到一个新的日志消息已经被写入终端的日志输出!
通过停止和删除容器来结束:
docker rm -f my-prd-srv
-f选项强制 Docker 删除容器,即使它正在运行。Docker 会在删除之前自动停止容器。
现在我们已经知道如何使用 Docker 与微服务,我们可以进一步了解如何使用 Docker Compose 管理微服务架构,并查看其变化。
使用 Docker Compose 管理微服务架构
我们已经看到如何运行单个微服务作为 Docker 容器,但是管理整个系统架构的微服务呢?
如我们之前提到的,这就是docker-compose的目的。通过使用单一命令,我们可以构建、启动、记录和停止作为 Docker 容器运行的一组协作微服务!
源代码的变化
为了能够使用 Docker Compose,我们需要创建一个配置文件docker-compose.yml,描述 Docker Compose 将为我们管理的微服务。我们还需要为剩余的微服务设置 Dockerfile,并为每个微服务添加一个特定的 Spring 配置文件。
所有四个微服务都有自己的 Dockerfile,但它们都与前一个相同。您可以在以下位置找到它们:
-
$BOOK_HOME/Chapter04/microservices/product-service/Dockerfile -
$BOOK_HOME/Chapter04/microservices/recommendation-service/Dockerfile -
$BOOK_HOME/Chapter04/microservices/review-service/Dockerfile -
$BOOK_HOME/Chapter04/microservices/product-composite-service/Dockerfile
当涉及到 Spring 配置文件时,三个核心服务product、recommendation和review-service具有相同的docker配置文件,它只指定当作为容器运行时应使用默认端口8080。
对于product-composite-service,事情变得有些复杂,因为它需要知道如何找到核心服务。当我们所有服务都运行在 localhost 上时,它被配置为使用 localhost 和每个核心服务的个别端口号7001-7003。当在 Docker 中运行时,每个服务将有自己的主机名,但可以在相同的端口号8080上访问。在此处,product-composite-service的docker配置文件如下所示:
---
spring.profiles: docker
server.port: 8080
app:
product-service:
host: product
port: 8080
recommendation-service:
host: recommendation
port: 8080
review-service:
host: review
port: 8080
详细信息请参阅$BOOK_HOME/Chapter04/microservices/product-composite-service/src/main/resources/application.yml。
主机名、产品、推荐和评论从何而来?
这些在docker-compose.yml文件中指定,该文件位于$BOOK_HOME/Chapter04文件夹中。它看起来像这样:
version: '2.1'
services:
product:
build: microservices/product-service
mem_limit: 350m
environment:
- SPRING_PROFILES_ACTIVE=docker
recommendation:
build: microservices/recommendation-service
mem_limit: 350m
environment:
- SPRING_PROFILES_ACTIVE=docker
review:
build: microservices/review-service
mem_limit: 350m
environment:
- SPRING_PROFILES_ACTIVE=docker
product-composite:
build: microservices/product-composite-service
mem_limit: 350m
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=docker
对于每个微服务,我们指定如下内容:
-
微服务名称。这也将是内部 Docker 网络中容器的的主机名。
-
构建指令指定了查找用于构建 Docker 镜像的 Dockerfile 的位置。
-
内存限制为 350 MB。这确保了本章及接下来的章节中所有的容器都能 fits in the 6 GB of memory that we allocated to the Docker engine in the Technical requirements section。
-
为容器设置的环境变量。在我们的案例中,我们使用这些来指定要使用的 Spring 配置文件。
对于product-composite服务,我们还将指定端口映射,即,我们将将其端口暴露给 Docker 外部。其他微服务将无法从外部访问。接下来,我们将了解如何启动微服务架构。
启动微服务架构
有了所有必要的代码更改,我们可以构建 Docker 镜像,启动微服务架构,并运行一些测试来验证它是否按预期工作。为此,我们需要做以下工作:
- 首先,我们使用 Gradle 构建我们的部署工件,然后使用 Docker Compose 构建 Docker 镜像:
cd $BOOK_HOME/Chapter04
./gradlew build
docker-compose build
- 然后,我们需要验证我们是否可以看到我们的 Docker 镜像,如下所示:
docker images | grep chapter04
- 我们应该看到以下输出:

- 使用以下命令启动微服务架构:
docker-compose up -d
-d选项的意义与 Docker 之前描述的意义相同。
我们可以使用以下命令监控每个容器日志中写入的输出,以跟踪启动过程:
docker-compose logs -f
docker compose logs命令支持与docker logs相同的-f和--tail选项,如前所述。
Docker Compose logs命令也支持将日志输出限制为一组容器。只需在logs命令之后添加您想要查看日志输出的容器的名称。例如,要只查看product和review服务的日志输出,请使用docker-compose logs -f product review。
当四个微服务都报告它们已经启动时,我们就可以尝试使用微服务架构了。寻找以下内容:

请注意,每个日志消息都以前缀的方式加上了产生输出的容器的名称!
现在,我们准备运行一些测试来验证这是否如预期工作。当我们从前一章直接在 localhost 上运行复合服务时,调用 Docker 中的复合服务所需做的唯一更改是端口号。现在我们使用端口8080:
curl localhost:8080/product-composite/123 -s | jq .
我们将得到相同的响应:

然而,有一个很大的区别——serviceAddresses中报告的主机名和端口:

在这里,我们可以看到分配给每个 Docker 容器的主机名和 IP 地址。
我们完成了;现在只剩下一步:
docker-compose down
前面命令将关闭微服务架构。
一起自动测试它们
当手动管理一组微服务时,Docker Compose 非常有帮助!在本节中,我们将更进一步地将 Docker Compose 集成到我们的测试脚本test-em-all.bash中。测试脚本将自动启动微服务景观,运行所有必要的测试以验证微服务景观按预期工作,并最终拆除它,不留下任何痕迹。
测试脚本可以在$BOOK_HOME/Chapter04/test-em-all.bash找到。
在测试脚本运行测试套件之前,它会检查测试脚本的调用中是否存在start参数。如果找到,它将使用以下代码重新启动容器:
if [[ $@ == *"start"* ]]
then
echo "Restarting the test environment..."
echo "$ docker-compose down"
docker-compose down
echo "$ docker-compose up -d"
docker-compose up -d
fi
之后,测试脚本将等待product-composite服务响应 OK:
waitForService http://$HOST:${PORT}/product-composite/1
waitForServicebash 函数可以如此实现:
function testUrl() {
url=$@
if curl $url -ks -f -o /dev/null
then
echo "Ok"
return 0
else
echo -n "not yet"
return 1
fi;
}
function waitForService() {
url=$@
echo -n "Wait for: $url... "
n=0
until testUrl $url
do
n=$((n + 1))
if [[ $n == 100 ]]
then
echo " Give up"
exit 1
else
sleep 6
echo -n ", retry #$n "
fi
done
}
接下来,像之前一样执行所有测试。之后,如果发现测试脚本的调用中存在stop参数,它将拆除景观:
if [[ $@ == *"stop"* ]]
then
echo "We are done, stopping the test environment..."
echo "$ docker-compose down"
docker-compose down
fi
请注意,如果某些测试失败,测试脚本将不会拆除景观;它只会停止,留下景观用于错误分析!
测试脚本还将将默认端口从7000更改为8080,我们在没有 Docker 的情况下运行微服务时使用7000,而8080被我们的 Docker 容器使用。
让我们试试吧!要启动景观,运行测试并在之后拆除它,像这样:
./test-em-all.bash start stop
以下是从一次测试运行中获取的一些示例输出(包括被删除的特定测试的输出):

测试这些之后,我们可以继续了解如何解决失败的测试问题。
测试运行故障排除
如果运行./test-em-all.bash start stop的测试失败,按照这些步骤可以帮助您识别问题并修复问题后继续测试:
- 首先,使用以下命令检查运行中的微服务的状态:
docker-compose ps
- 如果所有微服务都运行正常且健康,您将收到以下输出:

- 如果有任何微服务的状态不是
Up,使用docker-compose logs命令检查其日志输出是否有任何错误。例如,如果您想检查product服务的日志输出,可以使用以下代码:
docker-compose logs product
- 如果错误日志输出显示 Docker 磁盘空间不足,可以使用以下命令回收部分空间:
docker system prune -f --volumes
- 如有需要,您可以使用
docker-compose up -d --scale命令重新启动失败的微服务。例如,如果您想重新启动product服务,可以使用以下代码:
docker-compose up -d --scale product=0
docker-compose up -d --scale product=1
- 如果一个微服务丢失,例如,由于崩溃,您可以使用
docker-compose up -d --scale命令启动它。例如,您会使用以下代码为product服务:
docker-compose up -d --scale product=1
- 一旦所有微服务都运行并保持健康状态,再次运行测试脚本,但这次不启动微服务:
./test-em-all.bash
测试应该运行得很好!
最后,关于一个组合命令的提示,该命令从源代码构建运行时工件和 Docker 镜像,然后在每个 Docker 容器中运行所有测试:
./gradlew clean build && docker-compose build && ./test-em-all.bash start stop
如果你想在将新代码推送到你的 Git 仓库之前或作为你构建服务器中构建管道的部分来检查一切是否正常,这太完美了!
总结
在本章中,我们看到了 Docker 如何被用来简化对一组协同工作的微服务的测试。
我们了解到,从 Java SE v10 开始,它尊重我们对容器施加的约束,关于容器可以使用多少 CPU 和内存。
我们也看到了,要让一个基于 Java 的微服务作为 Docker 容器运行,需要多么小的改动。多亏了 Spring 配置文件,我们可以在不进行任何代码更改的情况下在 Docker 中运行微服务。
最后,我们看到了 Docker Compose 如何帮助我们用单一命令管理一组协同工作的微服务,无论是手动还是更好的自动方式,当与像test-em-all.bash这样的测试脚本集成时。
在下一章中,我们将学习如何使用 OpenAPI/Swagger 描述来添加 API 文档。
问题
-
虚拟机和 Docker 容器之间有哪些主要区别?
-
命名空间和 Docker 中的 cgroups 有什么作用?
-
一个 Java 应用程序如果不尊重容器中的最大内存设置并且分配了比允许更多的内存,会发生什么?
-
我们如何让一个基于 Spring 的应用程序在不修改其源代码的情况下作为 Docker 容器运行?
-
为什么下面的 Docker Compose 代码段不会工作?
review:
build: microservices/review-service
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=docker
product-composite:
build: microservices/product-composite-service
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=docker
第五章:使用 OpenAPI/Swagger 添加 API 描述
一个 API(如 RESTful 服务)的价值在很大程度上取决于它是多么容易消费!良好且易于访问的文档是 API 是否有用的一个重要因素。在本章中,我们将学习如何使用 OpenAPI/Swagger 来文档化我们可以从微服务架构中外部访问的 API。
正如我们在第二章,《Spring Boot 简介》中提到的,Swagger 是文档 RESTful 服务时最常用的规范之一,许多领先的 API 网关都有对 Swagger 的本地支持。我们将学习如何使用 SpringFox 生成此类文档,以及使用 SpringFox 文档永恒 API 所需的源代码更改。我们将尝试使用内嵌的 Swagger 查看器来查看文档和测试 API。
到本章结束时,我们将拥有一个基于 Swagger 的外部 API 文档,该 API 是由product-composite-service微服务暴露的。我们将能够使用内嵌的 Swagger 查看器来可视化和测试 API。
本章将涵盖以下主题:
-
使用 SpringFox 简介
-
源代码的更改
-
构建和启动微服务
-
尝试 Swagger 文档
技术要求
本书中描述的所有命令都是在使用 macOS Mojave 的 MacBook Pro 上运行的,但如果您想在其他平台(如 Linux 或 Windows)上运行它们,应该很容易进行修改。
在你通过本章的学习之前,不需要安装任何新工具。
本章的源代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud/tree/master/Chapter05。
为了能够运行本书中描述的命令,请将源代码下载到文件夹中,并设置一个环境变量$BOOK_HOME,使其指向该文件夹。一些示例命令如下:
export BOOK_HOME=~/Documents/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud
git clone https://github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud $BOOK_HOME
cd $BOOK_HOME/Chapter05
本章中提到的 Java 源代码是为 Java 8 编写的,并在 Java 12 上进行了测试。本章使用了 Spring Boot 2.1.0(以及 Spring 5.1.2),这是在撰写本章时可用的最新 Spring Boot 版本。
本章中的代码示例都来自$BOOK_HOME/Chapter05的源代码,但在许多情况下,已经编辑了这些示例,以删除源代码中的无关部分,例如注释、导入和日志语句。
如果你想要查看本章应用于源代码的变化,即查看使用 SpringFox 创建基于 Swagger 的 API 文档所采取的措施,你可以与第四章,使用 Docker 部署我们的微服务的源代码进行比较。你可以使用你喜欢的diff工具,比较两个文件夹,即$BOOK_HOME/Chapter04和$BOOK_HOME/Chapter05。
使用 SpringFox 的介绍
SpringFox 使得可以将与实现 API 的源代码一起保持 API 文档。对我来说,这是一个重要的特性。如果 API 文档与 Java 源代码在不同的生命周期中维护,它们将随着时间的推移而相互偏离。根据我的经验,在很多情况下,这种情况比预期的要早。像往常一样,将组件的接口与实现分离是很重要的。在记录 RESTful API 方面,我们应该将 API 文档添加到描述 API 的 Java 接口中,而不是添加到实现 API 的 Java 类中。为了简化 API 文档的更新,我们可以将文档的部分内容放在属性文件中,而不是直接放在 Java 代码中。
2015 年,SmartBear Software 将 Swagger 规范捐赠给 Linux Foundation 的 OpenAPI Initiative,并创建了 OpenAPI 规范。为了创建 API 文档,我们将使用SpringFox,它可以在运行时创建基于 Swagger 的 API 文档。
它基于我们提供的配置以及通过检查由 Spring WebFlux 和 Swagger 提供的注释来实现:

计划支持 OpenAPI 的 SpringFox 版本为 v3。在撰写本章时,SpringFox V3 仍在开发中,因此我们将使用 SpringFox V3 的快照版本,并根据 Swagger V2 创建 API 文档。一旦 SpringFox V3 发布,本书的源代码将会更新。
为了启用 SpringFox 以便我们可以创建 API 文档,我们将为 SpringFox 设置一个基本配置,并向定义 RESTful 服务的 Java 接口添加注解。
如果文档的某些部分被放置在属性文件中以简化 API 文档的更新,那么这些属性文件必须与源代码在相同的生命周期和版本控制下处理,否则它们可能会开始与实现偏离,也就是说,变得过时。
源代码的变化
为了添加product-composite-service微服务暴露的外部 API 的基于 Swagger 的文档,我们需要改变两个模块的源代码:
-
product-composite-services:在这里,我们将在 Java 应用程序类ProductCompositeServiceApplication中设置 SpringFox 配置,并描述 API 的一般信息。 -
api:在这里,我们将向 Java 接口ProductCompositeService添加 Swagger 注解,描述每个 RESTful 服务。在这个阶段,我们只有一个 RESTful 服务,即/product-composite/{productId},用于请求关于特定产品的复合信息。
实际用于描述 API 操作的文本将被放置在默认的属性文件application.yml中。
在我们可以使用 SpringFox 之前,我们需要将其作为依赖项添加到 Gradle 构建文件中。所以,让我们从这一点开始!
向 Gradle 构建文件添加依赖项
如我们之前提到的,我们将使用 SpringFox V3 的快照版本。SpringFox 产品分为多个模块。我们需要指定依赖关系的模块如下:
-
springfox-swagger2,这样我们就可以创建基于 Swagger 2 的文档 -
springfox-spring-webflux,这样我们就可以支持基于 Spring WebFlux 的 RESTful 操作 -
springfox-swagger-ui,这样我们可以在微服务中嵌入 Swagger 查看器
我们可以将这些添加到product-composite-service模块的 Gradle 构建文件build.gradle中,如下所示:
implementation('io.springfox:springfox-swagger2:3.0.0-SNAPSHOT')
implementation('io.springfox:springfox-swagger-ui:3.0.0-SNAPSHOT')
implementation('io.springfox:springfox-spring-webflux:3.0.0-SNAPSHOT')
api项目只需要springfox-swagger2模块的一个依赖项,因此只需要在其build.gradle文件中添加以下依赖项:
implementation('io.springfox:springfox-swagger2:3.0.0-SNAPSHOT')
SpringFox 项目在 Maven 仓库发布快照构建(oss.jfrog.org/artifactory/oss-snapshot-local/),所以我们还需要添加这个:
repositories {
mavenCentral()
maven { url 'http://oss.jfrog.org/artifactory/oss-snapshot-local/' }
}
为了能够构建核心模块,即product-service、recommendation-service和review-service,我们需要将 Maven 仓库添加到它们的 Gradle 构建文件build.gradle中。
向产品组合服务应用程序添加配置和一般 API 文档
为了在product-composite-service微服务中启用 SpringFox,我们必须添加一个配置。为了保持源代码紧凑,我们将直接将其添加到ProductCompositeServiceApplication应用程序类中。
如果你喜欢,你可以将 SpringFox 的配置放在一个单独的 Spring 配置类中。
首先,我们需要添加@EnableSwagger2WebFlux注解,以便让 SpringFox 为我们的使用 Spring WebFlux 实现的 RESTful 服务生成 Swagger V2 文档。然后,我们需要定义一个返回 SpringFox Docketbean 的 Spring Bean,用于配置 SpringFox。
我们将要添加到$BOOK_HOME/Chapter05/microservices/product-composite-service/src/main/java/se/magnus/microservices/composite/product/ProductCompositeServiceApplication.java的源代码如下所示:
@EnableSwagger2WebFlux
public class ProductCompositeServiceApplication {
@Bean
public Docket apiDocumentation() {
return new Docket(SWAGGER_2)
.select()
.apis(basePackage("se.magnus.microservices.composite.product"))
.paths(PathSelectors.any())
.build()
.globalResponseMessage(GET, emptyList())
.apiInfo(new ApiInfo(
apiTitle,
apiDescription,
apiVersion,
apiTermsOfServiceUrl,
new Contact(apiContactName, apiContactUrl,
apiContactEmail),
apiLicense,
apiLicenseUrl,
emptyList()
));
}
从前面的代码,我们可以理解如下:
-
@EnableSwagger2WebFlux注解是启动 SpringFox 的起点。 -
Docketbean 被初始化以创建 Swagger V2 文档。 -
使用
apis()和paths()方法,我们可以指定 SpringFox 应在哪里查找 API 文档。 -
使用
globalResponseMessage()方法,我们要求 SpringFox 不要向 API 文档中添加任何默认 HTTP 响应代码,如401和403,这些我们目前不使用。 -
用于配置
Docketbean 的一般 API 信息的api*变量是从属性文件中使用 Spring@Value注解初始化的。这些如下:
@Value("${api.common.version}") String apiVersion;
@Value("${api.common.title}") String apiTitle;
@Value("${api.common.description}") String apiDescription;
@Value("${api.common.termsOfServiceUrl}") String
apiTermsOfServiceUrl;
@Value("${api.common.license}") String apiLicense;
@Value("${api.common.licenseUrl}") String apiLicenseUrl;
@Value("${api.common.contact.name}") String apiContactName;
@Value("${api.common.contact.url}") String apiContactUrl;
@Value("${api.common.contact.email}") String apiContactEmail;
添加配置和 API 文档后,我们可以继续了解如何向 ProductCompositeService 添加 API 特定的文档。
向 ProductCompositeService 添加 API 特定的文档
为了文档化实际的 API ProductCompositeService 及其 RESTful 操作,我们将向 Java 接口声明中添加 @Api 注解,以便我们可以提供 API 的通用描述。对于 API 中的每个 RESTful 操作,我们将添加一个 @ApiOperation 注解,并在相应的 Java 方法上添加 @ApiResponse 注解,以描述操作及其预期的错误响应。
SpringFox 将检查 @GetMapping Spring 注解,以了解操作接受什么输入参数,如果产生成功响应,响应将是什么样子。
在以下示例中,我们从 @ApiOperation 注解中提取了实际文本到一个属性文件中。注解包含属性占位符,SpringFox 将使用它们在运行时从属性文件中查找实际文本。
资源级别的 API 文档如下所示:
@Api(description = "REST API for composite product information.")
public interface ProductCompositeService {
单个 API 操作如下所示:
@ApiOperation(
value = "${api.product-composite.get-composite-
product.description}",
notes = "${api.product-composite.get-composite-product.notes}")
@ApiResponses(value = {
@ApiResponse(code = 400, message = "Bad Request, invalid format
of the request. See response message for more information."),
@ApiResponse(code = 404, message = "Not found, the specified id
does not exist."),
@ApiResponse(code = 422, message = "Unprocessable entity, input
parameters caused the processing to fails. See response
message for more information.")
})
@GetMapping(
value = "/product-composite/{productId}",
produces = "application/json")
ProductAggregate getProduct(@PathVariable int productId);
对于 @ApiOperation Swagger 注解中指定的值,我们可以直接使用属性占位符,而不用 Spring @Value 注解。对于预期 ApiResponses 的描述,即预期的错误代码,SpringFox 目前不支持使用属性占位符,因此在这种情况下,每个错误代码的实际文本直接放在 Java 源代码中。
详细信息,请参阅 $BOOK_HOME/Chapter05/api/src/main/java/se/magnus/api/composite/product/ProductCompositeService.java。
将 API 的文本描述添加到属性文件
最后,我们需要将 API 的文本描述添加到属性文件 application.yml 中。在此处,我们有如下 @Value 注解:
@Value("${api.common.version}") String apiVersion;
对于每个 @Value 注解,我们需要在 YAML 文件中指定一个相应的属性;例如:
api:
common:
version: 1.0.0
同样,我们有 Swagger 注解,其外观如下:
@ApiOperation(value = "${api.product-composite.get-composite-product.description}")
这些期待 YAML 文件中有相应的属性;例如:
api:
product-composite:
get-composite-product:
description: Returns a composite view of the specified product id
如果您想了解更多关于如何构建 YAML 文件的信息,请查看规范:yaml.org/spec/1.2/spec.html。
首先,API 的通用描述,它是在 SpringFox Docket bean 中配置的,如下所示:
api:
common:
version: 1.0.0
title: Sample API
description: Description of the API...
termsOfServiceUrl: MINE TERMS OF SERVICE URL
license: License
licenseUrl: MY LICENSE URL
contact:
name: Contact
url: My
email: me@mail.com
接下来,给出了实际 API 操作的详细描述:
product-composite:
get-composite-product:
description: Returns a composite view of the specified product id
notes: |
# Normal response
If the requested product id is found the method will return
information regarding:
1\. Base product information
1\. Reviews
1\. Recommendations
1\. Service Addresses\n(technical information regarding the
addresses of the microservices that created the response)
# Expected partial and error responses
In the following cases, only a partial response be created (used
to simplify testing of error conditions)
## Product id 113
200 - Ok, but no recommendations will be returned
## Product id 213
200 - Ok, but no reviews will be returned
## Non numerical product id
400 - A <b>Bad Request</b> error will be returned
## Product id 13
404 - A <b>Not Found</b> error will be returned
## Negative product ids
422 - An <b>Unprocessable Entity</b> error will be returned
请注意,SpringFox 支持使用 markdown 语法提供 API 操作的多行描述。
有关详细信息,请参阅$BOOK_HOME/Chapter05/microservices/product-composite-service/src/main/resources/application.yml。
构建和启动微服务架构
在我们尝试 Swagger 文档之前,我们需要构建和启动微服务架构!
这可以通过以下命令完成:
cd $BOOK_HOME/Chapter05
./gradlew build && docker-compose build && docker-compose up -d
你可能会遇到一个关于端口8080已经被分配的错误信息。这将会是这样的:
ERROR: for product-composite Cannot start service product-composite: driver failed programming external connectivity on endpoint chapter05_product-composite_1 (0138d46f2a3055ed1b90b3b3daca92330919a1e7fec20351728633222db5e737): Bind for 0.0.0.0:8080 failed: port is already allocated
如果是这种情况,你可能忘记从上一章关闭微服务架构。要找出正在运行的容器的名称,请运行以下命令:
docker ps --format {{.Names}}
当上一章的微服务架构仍在运行时,示例响应如下:
chapter05_review_1
chapter05_product_1
chapter05_recommendation_1
chapter04_review_1
chapter04_product-composite_1
chapter04_product_1
chapter04_recommendation_1
如果在命令的输出中找到了其他章节的容器,例如,来自第四章,使用 Docker 部署我们的微服务,如前一个示例所示,你需要跳到那一章并关闭那个章节的容器:
cd ../Chapter04
docker-compose down
现在,你可以启动本章缺失的容器了:
cd ../Chapter05
docker-compose up -d
请注意,由于其他容器已经成功启动,该命令只启动了缺失的容器product-composite:
Starting chapter05_product-composite_1 ... done
为了等待微服务架构启动并验证它是否正常工作,你可以运行以下命令:
./test-em-all.bash
这个微服务的成功启动有助于我们更好地理解其架构,也有助于理解我们将在下一节学习的 Swagger 文档。
尝试 Swagger 文档
为了浏览 Swagger 文档,我们将使用内嵌的 Swagger 查看器。如果我们打开http://localhost:8080/swagger-ui.html URL 在网页浏览器中,我们将看到一个类似于以下屏幕截图的网页:

这里,我们可以找到以下内容:
-
我们在 SpringFox
Docketbean 中指定的通用信息,以及实际 Swagger 文档的链接,http://localhost:8080/v2/api-docs -
API 资源的列表;在我们这个案例中,是
product-composite-serviceAPI -
页面底部有一个部分,我们可以查看 API 中使用的模型
它的工作原理如下:
-
点击
product-composite-serviceAPI 资源来展开它。你会得到一个资源上可用的操作列表。 -
你只能看到一个操作,
/product-composite/{productId}。点击它来展开它。你会看到我们在ProductCompositeServiceJava 接口中指定的操作的文档:

这里,我们可以看到以下内容:
-
操作的一行描述。
-
一个包含操作详细信息的章节,包括它支持的输入参数。请注意
@ApiOperation注解中的notes字段是如何漂亮地渲染出来的 markdown 语法!
如果你滚动网页向下,你还会找到有关预期响应的文档,包括正常的 200 响应和我们定义的各种 4xx 错误响应,如下面的截图所示:

如果我们滚动回参数描述,我们会找到“尝试一下!”按钮。如果我们点击该按钮,我们可以输入实际的参数值,并通过点击“执行”按钮向 API 发送请求。例如,如果我们输入 productId 123,我们将得到以下响应:

我们将得到一个预期的 200(OK)作为响应代码,以及一个我们已熟悉的 JSON 结构作为响应体!
如果我们输入一个错误的输入,比如 -1,我们将得到一个正确的错误代码作为响应代码,以及一个相应的基于 JSON 的错误描述作为响应体:

如果你想尝试调用 API 而不用 Swagger UI,你可以从响应部分复制相应的 curl 命令,并在终端窗口中运行它!以下是一个例子:
curl -X GET "http://localhost:8080/product-composite/123" -H "accept: application/json"
很棒,不是吗?
摘要
API 的良好文档化对其接受度至关重要,而 Swagger 是最常用于文档化 RESTful 服务的规范之一。SpringFox 是一个开源项目,它使得通过检查 Spring WebFlux 和 Swagger 注解,在运行时动态创建基于 Swagger 的 API 文档变得可能。API 的文本描述可以从 Java 源代码中的注解中提取,并放置在属性文件中以便于编辑。SpringFox 可以配置为将内嵌的 Swagger 查看器带入微服务,这使得阅读微服务公开的 API 以及从查看器中尝试它们变得非常容易。
现在,那么通过向我们的微服务中添加持久性(即保存数据库中数据的能力)来为我们的微服务带来一些生机呢?为此,我们需要添加一些更多 API,这样我们才能创建和删除微服务处理的信息。翻到下一章了解更多信息!
问题
-
SpringFox 是如何帮助我们为 RESTful 服务创建 API 文档的?
-
SpringFox 支持哪些 API 文档化规范?
-
SpringFox 中的
Docketbean 的目的是什么? -
说出一些 SpringFox 在运行时读取的注解,以创建 API 文档!
-
: |在 YAML 文件中是什么意思? -
如何在不使用嵌入式 Swagger 查看器的情况下重复对 API 的调用?
第六章:添加持久化
在本章中,我们将学习如何将微服务正在使用数据进行持久化。正如在第二章《Spring Boot 简介》中提到的,我们将使用 Spring Data 项目将数据持久化到 MongoDB 和 MySQL 数据库中。project和recommendation微服务将使用 Spring Data 进行 MongoDB 操作,而review微服务将使用 Spring Data 的JPA(Java Persistence API 的缩写)访问 MySQL 数据库。我们将向 RESTful API 添加操作,以能够创建和删除数据库中的数据。现有的用于读取数据的 API 将更新以访问数据库。我们将以 Docker 容器的形式运行数据库,由 Docker Compose 管理,也就是我们运行微服务的方式。
本章将涵盖以下主题:
-
向核心微服务添加持久化层
-
编写专注于持久化的自动化测试
-
在服务层中使用持久化层
-
扩展组合服务 API
-
向 Docker Compose 环境中添加数据库
-
手动测试新 API 和持久化层
-
更新微服务环境中的自动化测试
技术要求
本书中描述的所有命令都是在 MacBook Pro 上使用 macOS Mojave 运行的,但应该很容易修改以在另一个平台,如 Linux 或 Windows 上运行。
在本章中不需要安装任何新工具。
为了能够手动访问数据库,我们将使用用于运行数据库的 Docker 镜像中提供的 CLI 工具。不过,我们将在 Docker Compose 中暴露每个数据库所使用的标准端口——MySQL 的3306和 MongoDB 的27017。这将允许你使用你最喜欢的数据库工具以与它们在本机运行相同的方式访问数据库。
本章的源代码可以在 GitHub 上找到:github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud/tree/master/Chapter06。
为了能够按照书中描述运行命令,请将源代码下载到一个文件夹中,并设置一个环境变量$BOOK_HOME,使其指向该文件夹。以下是一些示例命令:
export BOOK_HOME=~/Documents/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud
git clone https://github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud $BOOK_HOME
cd $BOOK_HOME/Chapter06
本章所用的 Java 源代码是为 Java 8 编写的,并在 Java 12 上进行了测试。本章使用的是 Spring Boot 2.1.0(以及 Spring 5.1.2)——这是在撰写本章时 Spring Boot 可用的最新版本。
源代码包含以下 Gradle 项目:
-
api -
util -
microservices/product-service -
microservices/review-service -
microservices/recommendation-service -
microservices/product-composite-service
本章中的所有代码示例都来自$BOOK_HOME/Chapter06的源代码,但在许多情况下,为了删除源代码中不相关部分,例如注释和导入以及日志语句,都进行了编辑。
如果你想要查看在第六章,添加持久化中应用到源代码的变化,可以看到添加了持久化到微服务中使用 Spring Data 所需要的一切,你可以与第五章,使用 OpenAPI/Swagger 添加 API 描述的源代码进行比较。你可以使用你喜欢的 diff 工具,比较两个文件夹,$BOOK_HOME/Chapter05和$BOOK_HOME/Chapter06。
但首先,让我们看看我们的目标在哪里
到本章结束时,我们的微服务内部将会有如下的层次结构:

协议层非常薄,仅包含RestController注解和公共GlobalControllerExceptionHandler。每个微服务的主要功能都存在于服务层中。product-composite服务包含一个集成层,用于与三个核心微服务进行通信。核心微服务都将有一个用于与它们数据库通信的持久化层。
我们可以使用如下命令查看存储在 MongoDB 中的数据:
docker-compose exec mongodb mongo product-db --quiet --eval "db.products.find()"
命令的结果应该像以下这样:

关于存储在 MySQL 中的数据,我们可以使用如下命令查看:
docker-compose exec mysql mysql -uuser -p review-db -e "select * from reviews"
命令的结果应该如下所示:

注意: mongo和mysql命令的输出已经被缩短以提高可读性。
让我们看看如何进行操作。
为核心微服务添加持久化层
让我们先为核心微服务添加一个持久化层。除了使用 Spring Data,我们还将使用一个 Java bean 映射工具 MapStruct,它使得在 Spring Data 实体对象和 API 模型类之间转换变得容易。有关详细信息,请参阅mapstruct.org/。
首先,我们需要添加对 MapStruct、Spring Data 以及我们打算使用的数据库的 JDBC 驱动的依赖。之后,我们可以定义我们的 Spring Data 实体类和仓库。Spring Data 实体类和仓库将被放置在它们自己的 Java 包中,persistence。例如,对于产品微服务,它们将被放置在 Java 包se.magnus.microservices.core.product.persistence中。
添加依赖
我们将使用 MapStruct V1.3.0-Beta 2,所以我们首先在每一个核心微服务的构建文件中定义一个变量,以保存版本信息,build.gradle:
ext {
mapstructVersion = "1.3.0.Beta2"
}
接下来,我们声明对 MapStruct 的依赖:
implementation("org.mapstruct:mapstruct:${mapstructVersion}")
由于 MapStruct 在编译时通过处理 MapStruct 注解来生成 bean 映射的实现,我们需要添加一个annotationProcessor和一个testAnnotationProcessor依赖:
iannotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}"
testAnnotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}"
为了使在流行的 IDE 如 IntelliJ IDEA 中的编译时生成工作,我们还需要添加以下依赖:
compileOnly "org.mapstruct:mapstruct-processor:${mapstructVersion}"
如果你使用的是 IntelliJ IDEA,你还需要确保启用了注解处理支持。打开首选项,导航到构建、执行、部署 | 编译器 | 注解处理器。验证名为“启用注解处理”的复选框是否被选中!
对于project和recommendation微服务,我们在 Spring Data for MongoDB 中声明了以下依赖:
implementation('org.springframework.boot:spring-boot-starter-data-mongodb')
testImplementation('de.flapdoodle.embed:de.flapdoodle.embed.mongo')
对de.flapdoodle.embed.mongo的测试依赖使我们能够在运行 JUnit 基础测试时运行 MongoDB 嵌入式。
review微服务将使用 Spring Data for JPA,并搭配 MySQL 作为其数据库在运行时使用,在测试时会使用嵌入式数据库 H2。因此,在它的构建文件build.gradle中声明了以下依赖:
implementation('org.springframework.boot:spring-boot-starter-data-jpa')
implementation('mysql:mysql-connector-java')
testImplementation('com.h2database:h2')
使用实体类存储数据
实体类在包含字段方面与相应的 API 模型类相似——查看api项目中的 Java 包se.magnus.api.core。我们将在与 API 模型类字段相比在实体类中添加两个字段id和version。
id字段用于持有每个存储实体的数据库身份——在使用关系数据库时是主键。我们将负责生成身份字段唯一值的职责委托给 Spring Data。根据所使用的数据库,Spring Data 可以将这个职责委托给数据库引擎。无论哪种情况,应用程序代码都不需要考虑如何设置数据库id的唯一值。id字段在 API 中不暴露,这是从安全角度出发的最佳实践。模型类中的字段,用于标识实体,将在相应的实体类中分配一个唯一索引,以确保从业务角度保持数据库的一致性。
version字段用于实现乐观锁,即允许 Spring Data 验证数据库中实体的更新是否覆盖了并发更新。如果存储在数据库中的版本字段值高于更新请求中的版本字段值,这表明更新是基于过时数据进行的——即自从从数据库中读取数据以来,要更新的信息已被其他人更新。Spring Data 将防止基于过时数据执行更新。在编写持久性测试的部分,我们将看到测试验证 Spring Data 中的乐观锁机制防止对过时数据执行更新。由于我们只实现创建、读取和删除操作的 API,因此我们不会在 API 中暴露版本字段。
产品实体类最有趣的部分看起来像这样:
@Document(collection="products")
public class ProductEntity {
@Id
private String id;
@Version
private Integer version;
@Indexed(unique = true)
private int productId;
private String name;
private int weight;
以下是从前面代码得出的观察结果:
-
@Document(collection="products")注解用于标记用作 MongoDB 实体的类,即映射到名为products的 MongoDB 集合。 -
@Id和@Version注解用于标记由 Spring Data 使用的id和version字段,如前所述。 -
@Indexed(unique = true)注解用于为业务键productId创建一个唯一的索引。
Recommendation 实体类最有趣的部分看起来是这样的:
@Document(collection="recommendations")
@CompoundIndex(name = "prod-rec-id", unique = true, def = "{'productId': 1, 'recommendationId' : 1}")
public class RecommendationEntity {
@Id
private String id;
@Version
private Integer version;
private int productId;
private int recommendationId;
private String author;
private int rating;
private String content;
在前面产品实体的解释基础上,我们可以看到如何使用 @CompoundIndex 注解为基于字段 productId 和 recommendationId 的复合业务键创建唯一的复合索引。
最后,Review 实体类最有趣的部分看起来是这样的:
@Entity
@Table(name = "reviews", indexes = { @Index(name = "reviews_unique_idx", unique = true, columnList = "productId,reviewId") })
public class ReviewEntity {
@Id @GeneratedValue
private int id;
@Version
private int version;
private int productId;
private int reviewId;
private String author;
private String subject;
private String content;
以下是对前面代码的观察:
-
@Entity和@Table注解用于标记一个类作为一个用于 JPA 的实体类——映射到 SQL 数据库中的一个名为products的表。 -
@Table注解也用于指定基于字段productId和reviewId的复合业务键应创建一个唯一的复合索引。 -
@Id和@Version注解用于标记id和version字段,如前所述,由 Spring Data 使用。为了指导 Spring Data for JPA 自动为id字段生成唯一的id值,我们使用了@GeneratedValue注解。
实体类的完整源代码可以在以下链接中找到:
-
se.magnus.microservices.core.product.persistence.ProductEntity在product项目中 -
se.magnus.microservices.core.recommendation.persistence.RecommendationEntity在recommendation项目中 -
se.magnus.microservices.core.review.persistence.ReviewEntity在review项目中
在 Spring Data 中定义仓库
Spring Data 带有一组用于定义仓库的基础类。我们将使用基础类 CrudRepository 和 PagingAndSortingRepository。CrudRepository 基础类提供了执行基本的数据库创建、读取、更新和删除操作的标准方法。PagingAndSortingRepository 基础类在 CrudRepository 基础类中增加了分页和排序的支持。
我们将使用 CrudRepository 类作为 Recommendation 和 Review 仓库的基础类,以及 PagingAndSortingRepository 类作为 Product 仓库的基础类。
我们还将向我们的仓库中添加几个额外的查询方法,用于使用业务键 productId 查找实体。
Spring Data 支持基于方法签名的命名约定定义额外的查询方法。例如,findByProductId(int productId) 方法签名可以用来指导 Spring Data 自动创建一个查询,当调用查询方法时,返回底层集合或表中productId字段设置为productId参数中指定值的实体。有关如何声明额外查询的详细信息,请参阅docs.spring.io/spring-data/data-commons/docs/current/reference/html/#repositories.query-methods.query-creation。
Product 仓库类看起来是这样的:
public interface ProductRepository extends PagingAndSortingRepository<ProductEntity, String> {
Optional<ProductEntity> findByProductId(int productId);
}
因为findByProductId方法可能返回零个或一个产品实体,所以通过将其包裹在Optional对象中来标记返回值为可选的。
Recommendation 仓库类看起来是这样的:
public interface RecommendationRepository extends CrudRepository<RecommendationEntity, String> {
List<RecommendationEntity> findByProductId(int productId);
}
在这个案例中,findByProductId方法将返回零到多个推荐实体,所以返回值被定义为一个列表。
最后,Review 仓库类的样子是这样的:
public interface ReviewRepository extends CrudRepository<ReviewEntity, Integer> {
@Transactional(readOnly = true)
List<ReviewEntity> findByProductId(int productId);
}
由于 SQL 数据库是事务性的,我们必须为查询方法findByProductId()指定默认的事务类型——在我们的案例中是只读的。
就这样——这就是为我们的核心微服务建立持久化层所需的所有步骤。
要在以下位置查看仓库类的完整源代码:
-
se.magnus.microservices.core.product.persistence.ProductRepository在product项目中 -
se.magnus.microservices.core.recommendation.persistence.RecommendationRepository在recommendation项目中 -
se.magnus.microservices.core.review.persistence.ReviewRepository在review项目中
让我们通过编写一些持久化测试来验证它们是否如预期般工作。
编写关注持久化的自动化测试
在编写持久化测试时,我们希望当测试开始时启动一个嵌入式数据库,当测试完成时将其销毁。然而,我们不希望测试等待其他资源启动,例如,Netty 之类的 Web 服务器(在运行时是必需的)。
Spring Boot 带有两个针对此特定要求定制的类级注解:
-
@DataMongoTest:当测试开始时启动一个嵌入式 MongoDB 数据库。 -
@DataJpaTest:当测试开始时启动一个嵌入式 SQL 数据库:-
自从我们在构建文件中向评论微服务的 H2 数据库添加了测试依赖后,它将被用作嵌入式 SQL 数据库。
-
默认情况下,Spring Boot 配置测试以回滚 SQL 数据库的更新,以最小化对其他测试的负面副作用风险。在我们的情况下,这种行为将导致一些测试失败。因此,通过类级注解禁用了自动回滚:
@Transactional(propagation = NOT_SUPPORTED)。
-
三个核心微服务的持久化测试彼此相似,因此我们只需查看Product微服务的持久化测试。
测试类声明了一个方法setupDb(),用@Before注解标记,在每种测试方法之前执行。设置方法从数据库中删除以前测试的任何实体,并插入一个测试方法可以作为其测试基础的实体:
@RunWith(SpringRunner.class)
@DataMongoTest
public class PersistenceTests {
@Autowired
private ProductRepository repository;
private ProductEntity savedEntity;
@Before
public void setupDb() {
repository.deleteAll();
ProductEntity entity = new ProductEntity(1, "n", 1);
savedEntity = repository.save(entity);
assertEqualsProduct(entity, savedEntity);
}
接下来是各种测试方法。首先是create测试:
@Test
public void create() {
ProductEntity newEntity = new ProductEntity(2, "n", 2);
savedEntity = repository.save(newEntity);
ProductEntity foundEntity =
repository.findById(newEntity.getId()).get();
assertEqualsProduct(newEntity, foundEntity);
assertEquals(2, repository.count());
}
此测试创建了一个新实体,并验证它可以通过findByProductId()方法找到,并以断言数据库中存储了两个实体结束,一个是通过setup方法创建的,另一个是测试本身创建的。
update测试看起来像这样:
@Test
public void update() {
savedEntity.setName("n2");
repository.save(savedEntity);
ProductEntity foundEntity =
repository.findById(savedEntity.getId()).get();
assertEquals(1, (long)foundEntity.getVersion());
assertEquals("n2", foundEntity.getName());
}
此测试更新了由设置方法创建的实体,再次使用标准的findById()方法从数据库中读取它,并断言它的一些字段包含期望的值。注意,当实体被创建时,其version字段由 Spring Data 设置为0。
delete测试看起来像这样:
@Test
public void delete() {
repository.delete(savedEntity);
assertFalse(repository.existsById(savedEntity.getId()));
}
此测试删除由setup方法创建的实体,并验证它不再存在于数据库中。
read测试看起来像这样:
@Test
public void getByProductId() {
Optional<ProductEntity> entity =
repository.findByProductId(savedEntity.getProductId());
assertTrue(entity.isPresent());
assertEqualsProduct(savedEntity, entity.get());
}
此测试使用了findByProductId()方法来获取由setup方法创建的实体,验证它是否被找到,然后使用本地助手方法assertEqualsProduct()来验证findByProductId()返回的实体是否与setup方法存储的实体相同。
接下来,它跟随两个测试方法,验证替代流程——错误条件的处理。首先是验证重复正确处理的测试:
@Test(expected = DuplicateKeyException.class)
public void duplicateError() {
ProductEntity entity = new
ProductEntity(savedEntity.getProductId(), "n", 1);
repository.save(entity);
}
测试尝试存储一个与setup方法保存的实体具有相同业务键的实体。如果保存操作成功,或者保存失败并抛出预期之外的异常,DuplicateKeyException,则测试将失败。
在我看来,另一个负向测试是测试类中最有趣的测试。这是一个测试,用于验证在更新陈旧数据时的正确错误处理——它验证乐观锁定机制是否工作。它看起来像这样:
@Test
public void optimisticLockError() {
// Store the saved entity in two separate entity objects
ProductEntity entity1 =
repository.findById(savedEntity.getId()).get();
ProductEntity entity2 =
repository.findById(savedEntity.getId()).get();
// Update the entity using the first entity object
entity1.setName("n1");
repository.save(entity1);
// Update the entity using the second entity object.
// This should fail since the second entity now holds a old version
// number, that is, a Optimistic Lock Error
try {
entity2.setName("n2");
repository.save(entity2);
fail("Expected an OptimisticLockingFailureException");
} catch (OptimisticLockingFailureException e) {}
// Get the updated entity from the database and verify its new
// state
ProductEntity updatedEntity =
repository.findById(savedEntity.getId()).get();
assertEquals(1, (int)updatedEntity.getVersion());
assertEquals("n1", updatedEntity.getName());
}
从前面的代码中观察到以下情况:
-
首先,测试两次读取同一个实体,并将其存储在两个不同的变量
entity1和entity2中。 -
接下来,它使用其中一个变量
entity1来更新实体。在数据库中更新实体将导致 Spring Data 自动增加实体的版本字段。另一个变量entity2现在包含陈旧数据,体现在其版本字段持有的值低于数据库中对应值。 -
当测试尝试使用包含陈旧数据的变量
entity2更新实体时,预计会通过抛出OptimisticLockingFailureException异常来失败。 -
测试通过断言数据库中的实体反映了第一次更新,即包含名称
"n1",并且版本字段具有值1,即只在数据库中更新了实体的一次。
最后,product服务包含一个测试,演示了 Spring Data 中内置的排序和分页支持的用法:
@Test
public void paging() {
repository.deleteAll();
List<ProductEntity> newProducts = rangeClosed(1001, 1010)
.mapToObj(i -> new ProductEntity(i, "name " + i, i))
.collect(Collectors.toList());
repository.saveAll(newProducts);
Pageable nextPage = PageRequest.of(0, 4, ASC, "productId");
nextPage = testNextPage(nextPage, "[1001, 1002, 1003, 1004]",
true);
nextPage = testNextPage(nextPage, "[1005, 1006, 1007, 1008]",
true);
nextPage = testNextPage(nextPage, "[1009, 1010]", false);
}
从前面的代码中观察到以下内容:
-
测试从删除任何现有数据开始,然后插入具有
productId字段从1001到1010的 10 个实体。 -
接下来,它创建了
PageRequest,请求每页 4 个实体的分页计数,并根据ProductId升序排序。 -
最后,它使用一个助手方法
testNextPage来读取预期的三页内容,验证每页中预期的产品 ID,并验证 Spring Data 正确报告是否存在更多页面。
助手方法testNextPage看起来像这样:
private Pageable testNextPage(Pageable nextPage, String expectedProductIds, boolean expectsNextPage) {
Page<ProductEntity> productPage = repository.findAll(nextPage);
assertEquals(expectedProductIds, productPage.getContent()
.stream().map(p -> p.getProductId()).collect(Collectors.
toList()).toString());
assertEquals(expectsNextPage, productPage.hasNext());
return productPage.nextPageable();
}
助手方法使用分页请求对象nextPage从仓库方法的findAll()获取下一页。根据结果,它从返回的实体中提取产品 ID,将其转换为字符串,并与期望的产品 ID 列表进行比较。最后,它返回一个布尔值,指示是否可以检索更多页面。
三篇持久化测试类的完整源代码,请参见以下内容:
-
se.magnus.microservices.core.product.PersistenceTests在product项目中 -
se.magnus.microservices.core.recommendation.PersistenceTests在recommendation项目中 -
se.magnus.microservices.core.review.PersistenceTests在review项目中
product微服务中的持久化测试可以通过使用 Gradle 执行以下命令来执行:
cd $BOOK_HOME/Chapter06
./gradlew microservices:product-service:test --tests PersistenceTests
运行测试后,它应该响应以下内容:

在持久化层就位后,我们可以将核心微服务中的服务层更新为使用持久化层。
在服务层使用持久化层
在本节中,我们将学习如何在服务层使用持久化层来存储和从数据库中检索数据。我们将按照以下步骤进行:
-
日志记录数据库连接 URL。
-
添加新的 API。
-
使用持久化层。
-
声明一个 Java bean mapper。
-
更新服务测试。
日志记录数据库连接 URL
当扩展微服务的数量时,每个微服务连接到自己的数据库,我发现自己有时不确定每个微服务实际上使用的是哪个数据库。因此,我通常在微服务启动后直接添加一个日志语句,记录用于连接数据库的连接 URL。
例如,Product服务的启动代码看起来像这样:
public class ProductServiceApplication {
private static final Logger LOG =
LoggerFactory.getLogger(ProductServiceApplication.class);
public static void main(String[] args) {
ConfigurableApplicationContext ctx =
SpringApplication.run(ProductServiceApplication.class, args);
String mongodDbHost =
ctx.getEnvironment().getProperty("spring.data.mongodb.host");
String mongodDbPort =
ctx.getEnvironment().getProperty("spring.data.mongodb.port");
LOG.info("Connected to MongoDb: " + mongodDbHost + ":" +
mongodDbPort);
}
}
在日志中,应期望以下类型的输出:

要查看完整的源代码,请参阅product项目中的se.magnus.microservices.core.product.ProductServiceApplication类。
添加新 API
在我们能够使用持久化层在数据库中创建和删除信息之前,我们需要在我们的核心服务 API 中创建相应的 API 操作。
创建和删除产品实体的 API 操作看起来像这样:
@PostMapping(
value = "/product",
consumes = "application/json",
produces = "application/json")
Product createProduct(@RequestBody Product body);
@DeleteMapping(value = "/product/{productId}")
void deleteProduct(@PathVariable int productId);
删除操作的实现将是幂等的,也就是说,如果多次调用,它将返回相同的结果。这在故障场景中是一个宝贵的特性。例如,如果客户端在调用删除操作时遇到网络超时,它可以简单地再次调用删除操作,而不用担心不同的响应,例如,第一次响应为 OK (200) 和连续调用响应为 Not Found (404),或者任何意外的副作用。这暗示了即使实体在数据库中不再存在,操作也应该返回 OK (200)的状态码。
recommendation 和 review 实体的 API 操作看起来很相似;然而,注意,当涉及到recommendation 和 review 实体的删除操作时,它将删除指定productId的所有recommendations和reviews。
要查看完整的源代码,请查看api项目中的以下类:
-
se.magnus.api.core.product.ProductService -
se.magnus.api.core.recommendation.RecommendationService -
se.magnus.api.core.review.ReviewService
使用持久化层
在服务层中使用持久化层的源代码对于所有核心微服务都是结构相同的。因此,我们只查看Product微服务的源代码。
首先,我们需要从持久化层注入仓库类和一个 Java bean 映射器类到构造函数中:
private final ServiceUtil serviceUtil;
private final ProductRepository repository;
private final ProductMapper mapper;
@Autowired
public ProductServiceImpl(ProductRepository repository, ProductMapper mapper, ServiceUtil serviceUtil) {
this.repository = repository;
this.mapper = mapper;
this.serviceUtil = serviceUtil;
}
在下一节中,我们将看到 Java 映射器类是如何定义的。
接下来,按照以下方式实现createProduct方法:
public Product createProduct(Product body) {
try {
ProductEntity entity = mapper.apiToEntity(body);
ProductEntity newEntity = repository.save(entity);
return mapper.entityToApi(newEntity);
} catch (DuplicateKeyException dke) {
throw new InvalidInputException("Duplicate key, Product Id: " +
body.getProductId());
}
}
create方法使用了仓库中的save方法来存储一个新的实体。应注意映射器类是如何使用两个映射器方法apiToEntity()和entityToApi(),在 API 模型类和实体类之间转换 Java bean 的。我们为create方法处理的唯一错误是DuplicateKeyException异常,我们将它转换为InvalidInputException异常。
getProduct方法看起来像这样:
public Product getProduct(int productId) {
if (productId < 1) throw new InvalidInputException("Invalid
productId: " + productId);
ProductEntity entity = repository.findByProductId(productId)
.orElseThrow(() -> new NotFoundException("No product found for
productId: " + productId));
Product response = mapper.entityToApi(entity);
response.setServiceAddress(serviceUtil.getServiceAddress());
return response;
}
在进行了基本输入验证(即确保productId不是负数)之后,仓库中的findByProductId()方法用于查找产品实体。由于仓库方法返回一个Optional产品,我们可以使用Optional类中的orElseThrow()方法方便地抛出如果没有找到产品实体就抛出NotFoundException异常。在返回产品信息之前,使用serviceUtil对象填充微服务当前使用的地址。
最后,让我们看看deleteProduct方法:
public void deleteProduct(int productId) {
repository.findByProductId(productId).ifPresent(e ->
repository.delete(e));
}
delete 方法还使用了仓库中的findByProductId()方法,并使用了Optional类中的ifPresent()方法,方便地仅在实体存在时删除实体。注意,该实现是幂等的,即,如果找不到实体,它不会报告任何故障。
三个服务实现类的源代码可以在以下位置找到:
-
se.magnus.microservices.core.product.services.ProductServiceImpl在product项目中 -
se.magnus.microservices.core.recommendation.services.RecommendationServiceImpl在recommendation项目中 -
se.magnus.microservices.core.review.services.ReviewServiceImpl在review项目中
声明一个 Java bean 映射器
那么,魔法的 Java bean 映射器又如何呢?
正如前面提到的,我们使用 MapStruct 来声明我们的映射器类。MapStruct 在三个核心微服务中的使用是相似的,所以我们只查看Product微服务中的映射器对象源代码。
product 服务的映射器类看起来像这样:
@Mapper(componentModel = "spring")
public interface ProductMapper {
@Mappings({
@Mapping(target = "serviceAddress", ignore = true)
})
Product entityToApi(ProductEntity entity);
@Mappings({
@Mapping(target = "id", ignore = true),
@Mapping(target = "version", ignore = true)
})
ProductEntity apiToEntity(Product api);
}
从前面的代码中观察到以下内容:
-
entityToApi()方法将实体对象映射到 API 模型对象。由于实体类没有serviceAddress字段,entityToApi()方法被注解忽略serviceAddress。 -
apiToEntity()方法将 API 模型对象映射到实体对象。同样,apiToEntity()方法被注解忽略在 API 模型类中缺失的id和version字段。
MapStruct 不仅支持按名称映射字段,还可以指定它映射具有不同名称的字段。在Recommendation服务的映射器类中,使用以下注解将rating实体字段映射到 API 模型字段rate:
@Mapping(target = "rate", source="entity.rating"),
Recommendation entityToApi(RecommendationEntity entity);
@Mapping(target = "rating", source="api.rate"),
RecommendationEntity apiToEntity(Recommendation api);
成功构建 Gradle 后,生成的映射实现可以在build/classes 文件夹中找到,例如,Product服务:$BOOK_HOME/Chapter06/microservices/product-service/build/classes/java/main/se/magnus/microservices/core/product/services/ProductMapperImpl.java。
三个映射器类的源代码可以在以下位置找到:
-
se.magnus.microservices.core.product.services.ProductMapper在product项目中 -
se.magnus.microservices.core.recommendation.services.RecommendationMapper在recommendation项目中 -
se.magnus.microservices.core.review.services.ReviewMapper在review项目中
更新服务测试
自上一章以来,核心微服务暴露的 API 的测试已经更新,增加了对创建和删除 API 操作的测试。
新增的测试在三个核心微服务中都是相似的,所以我们只查看Product微服务中的服务测试源代码。
为了确保每个测试都有一个已知的状态,声明了一个设置方法,setupDb(),并用 @Before 注解,这样它会在每个测试运行之前运行。设置方法移除了之前创建的任何实体:
@Autowired
private ProductRepository repository;
@Before
public void setupDb() {
repository.deleteAll();
}
创建 API 的测试方法验证了一个产品实体在创建后可以被检索到,并且使用相同的 productId 创建另一个产品实体会导致预期的错误,UNPROCESSABLE_ENTITY,在 API 请求的响应中:
@Test
public void duplicateError() {
int productId = 1;
postAndVerifyProduct(productId, OK);
assertTrue(repository.findByProductId(productId).isPresent());
postAndVerifyProduct(productId, UNPROCESSABLE_ENTITY)
.jsonPath("$.path").isEqualTo("/product")
.jsonPath("$.message").isEqualTo("Duplicate key, Product Id: " +
productId);
}
删除 API 的测试方法验证了一个产品实体可以被删除,并且第二个删除请求是幂等的——它还返回了状态码 OK,即使实体在数据库中已不再存在:
@Test
public void deleteProduct() {
int productId = 1;
postAndVerifyProduct(productId, OK);
assertTrue(repository.findByProductId(productId).isPresent());
deleteAndVerifyProduct(productId, OK);
assertFalse(repository.findByProductId(productId).isPresent());
deleteAndVerifyProduct(productId, OK);
}
为了简化向 API 发送创建、读取和删除请求并验证响应状态,已经创建了三个辅助方法:
-
postAndVerifyProduct() -
getAndVerifyProduct() -
deleteAndVerifyProduct()
postAndVerifyProduct() 方法看起来是这样的:
private WebTestClient.BodyContentSpec postAndVerifyProduct(int productId, HttpStatus expectedStatus) {
Product product = new Product(productId, "Name " + productId,
productId, "SA");
return client.post()
.uri("/product")
.body(just(product), Product.class)
.accept(APPLICATION_JSON_UTF8)
.exchange()
.expectStatus().isEqualTo(expectedStatus)
.expectHeader().contentType(APPLICATION_JSON_UTF8)
.expectBody();
}
除了执行实际的 HTTP 请求并验证其响应码外,辅助方法还将响应的正文返回给调用者进行进一步调查,如果需要的话。另外两个用于读取和删除请求的辅助方法类似,可以在本节开头指出的源代码中找到。
三个服务测试类的源代码可以在以下位置找到:
-
se.magnus.microservices.core.product.ProductServiceApplicationTests在product项目中 -
se.magnus.microservices.core.recommendation.RecommendationServiceApplicationTests在recommendation项目中 -
se.magnus.microservices.core.review.ReviewServiceApplicationTests在review项目中
现在,让我们来看看如何扩展复合服务 API。
扩展复合服务 API
在本节中,我们将了解如何扩展复合 API 以创建和删除复合实体。我们将按照以下步骤进行:
-
在复合服务 API 中添加新操作
-
在集成层中添加方法
-
实现新的复合 API 操作
-
更新复合服务测试
在复合服务 API 中添加新操作
创建和删除实体的复合版本以及处理聚合实体的方法与核心服务 API 中的创建和删除操作相似。主要区别在于,它们添加了用于基于 Swagger 的文档的注解。有关 Swagger 注解的使用说明,请参阅 第五章,使用 OpenAPI/Swagger 添加 API 描述 节,在 ProductCompositeService 中添加 API 特定文档。创建复合产品实体的 API 操作声明如下:
@ApiOperation(
value = "${api.product-composite.create-composite-
product.description}",
notes = "${api.product-composite.create-composite-product.notes}")
@ApiResponses(value = {
@ApiResponse(code = 400, message = "Bad Request, invalid format of
the request. See response message for more information."),
@ApiResponse(code = 422, message = "Unprocessable entity, input
parameters caused the processing to fail. See response message for
more information.")
})
@PostMapping(
value = "/product-composite",
consumes = "application/json")
void createCompositeProduct(@RequestBody ProductAggregate body);
删除复合产品实体的 API 操作声明如下:
@ApiOperation(
value = "${api.product-composite.delete-composite-
product.description}",
notes = "${api.product-composite.delete-composite-product.notes}")
@ApiResponses(value = {
@ApiResponse(code = 400, message = "Bad Request, invalid format of
the request. See response message for more information."),
@ApiResponse(code = 422, message = "Unprocessable entity, input
parameters caused the processing to fail. See response message for
more information.")
})
@DeleteMapping(value = "/product-composite/{productId}")
void deleteCompositeProduct(@PathVariable int productId);
完整的源代码,请参阅api项目中的 Java 接口se.magnus.api.composite.product.ProductCompositeService。
我们还需要像以前一样,将 API 文档的描述性文本添加到属性文件application.yml中:
create-composite-product:
description: Creates a composite product
notes: |
# Normal response
The composite product information posted to the API will be
splitted up and stored as separate product-info, recommendation and
review entities.
# Expected error responses
1\. If a product with the same productId as specified in the posted
information already exists, an <b>422 - Unprocessable Entity</b>
error with a "duplicate key" error message will be returned
delete-composite-product:
description: Deletes a product composite
notes: |
# Normal response
Entities for product information, recommendations and reviews
related to the specificed productId will be deleted.
The implementation of the delete method is idempotent, that is, it
can be called several times with the same response.
This means that a delete request of a non existing product will
return <b>200 Ok</b>.
具体细节,请查看product-composite项目中的src/main/resources/application.yml配置文件。
更新后的 Swagger 文档将如下所示:

在本章后面,我们将使用 Swagger UI 来尝试新的组合 API 操作。
在集成层中添加方法
在我们能够实现组合服务中的新创建和删除 API 之前,我们需要扩展集成层,使其能够调用核心微服务 API 中的底层创建和删除操作。
调用三个核心微服务中的创建和删除操作的集成层方法简单且彼此相似,所以我们只查看调用Product微服务的方法的源代码。
createProduct()方法看起来像这样:
@Override
public Product createProduct(Product body) {
try {
return restTemplate.postForObject(productServiceUrl, body,
Product.class);
} catch (HttpClientErrorException ex) {
throw handleHttpClientException(ex);
}
}
它简单地将发送 HTTP 请求的责任委托给RestTemplate对象,并将错误处理委托给助手方法handleHttpClientException。
deleteProduct()方法看起来像这样:
@Override
public void deleteProduct(int productId) {
try {
restTemplate.delete(productServiceUrl + "/" + productId);
} catch (HttpClientErrorException ex) {
throw handleHttpClientException(ex);
}
}
它的实现方式与创建方法相同,但执行的是 HTTP 删除请求。
集成层完整的源代码可以在product-composite项目中的se.magnus.microservices.composite.product.services.ProductCompositeIntegration类中找到。
实现新的组合 API 操作
现在,我们可以实现组合的创建和删除方法!
组合的创建方法会将聚合产品对象拆分为product、recommendation和review的独立对象,并在集成层中调用相应的创建方法:
@Override
public void createCompositeProduct(ProductAggregate body) {
try {
Product product = new Product(body.getProductId(),
body.getName(), body.getWeight(), null);
integration.createProduct(product);
if (body.getRecommendations() != null) {
body.getRecommendations().forEach(r -> {
Recommendation recommendation = new
Recommendation(body.getProductId(),
r.getRecommendationId(), r.getAuthor(), r.getRate(),
r.getContent(), null);
integration.createRecommendation(recommendation);
});
}
if (body.getReviews() != null) {
body.getReviews().forEach(r -> {
Review review = new Review(body.getProductId(),
r.getReviewId(), r.getAuthor(), r.getSubject(),
r.getContent(), null);
integration.createReview(review);
});
}
} catch (RuntimeException re) {
LOG.warn("createCompositeProduct failed", re);
throw re;
}
}
组合的删除方法 simply calls the three delete methods in the integration layer to delete the corresponding entities in the underlying databases:
@Override
public void deleteCompositeProduct(int productId) {
integration.deleteProduct(productId);
integration.deleteRecommendations(productId);
integration.deleteReviews(productId);
}
完整的源代码,请参阅product-composite项目中的se.magnus.microservices.composite.product.services.ProductCompositeServiceImpl类。
对于快乐路径场景,这个实现会很好,但如果我们考虑各种错误场景,这个实现将会带来麻烦!
例如,如果底层的核心微服务之一暂时不可用,可能是由于内部、网络或数据库问题,那该怎么办?
这可能导致部分创建或删除的组合产品。对于删除操作,如果请求者简单地调用组合的删除方法直到成功,这可以得到修复。然而,如果底层问题持续一段时间,请求者可能会放弃,导致组合产品的不一致状态——在大多数情况下这是不可接受的!
在下一章第七章中,开发反应式微服务,我们将了解如何使用同步 API(如 RESTful API)来解决这些问题!
现在,让我们带着这个脆弱的设计继续前进。
更新组合服务测试:
正如在第三章中提到的创建一组协作微服务(参考隔离微服务的自动化测试部分),测试组合服务限于使用简单的模拟组件而不是实际的核心服务。这限制了我们测试更复杂场景的能力,例如,在尝试在底层数据库中创建重复项时的错误处理。组合的创建和删除 API 操作的测试相对简单:
@Test
public void createCompositeProduct1() {
ProductAggregate compositeProduct = new ProductAggregate(1, "name",
1, null, null, null);
postAndVerifyProduct(compositeProduct, OK);
}
@Test
public void createCompositeProduct2() {
ProductAggregate compositeProduct = new ProductAggregate(1, "name",
1, singletonList(new RecommendationSummary(1, "a", 1, "c")),
singletonList(new ReviewSummary(1, "a", "s", "c")), null);
postAndVerifyProduct(compositeProduct, OK);
}
@Test
public void deleteCompositeProduct() {
ProductAggregate compositeProduct = new ProductAggregate(1, "name",
1,singletonList(new RecommendationSummary(1, "a", 1, "c")),
singletonList(new ReviewSummary(1, "a", "s", "c")), null);
postAndVerifyProduct(compositeProduct, OK);
deleteAndVerifyProduct(compositeProduct.getProductId(), OK);
deleteAndVerifyProduct(compositeProduct.getProductId(), OK);
}
完整的源代码,请参阅product-composite项目中的测试类,se.magnus.microservices.composite.product.ProductCompositeServiceApplicationTests。
接下来,我们将了解如何将数据库添加到 Docker Compose 的景观中。
向 Docker Compose 景观添加数据库:
现在,我们已经将所有源代码放到位。在我们能够启动微服务景观并尝试新的 API 以及新的持久层之前,我们必须启动一些数据库。
我们将把 MongoDB 和 MySQL 带入由 Docker Compose 控制的系统景观,并向我们的微服务添加配置,以便它们在运行时能够找到它们的数据库,无论是否作为 Docker 容器运行。
Docker Compose 配置:
MongoDB 和 MySQL 在 Docker Compose 配置文件docker-compose.yml中声明如下:
mongodb:
image: mongo:3.6.9
mem_limit: 350m
ports:
- "27017:27017"
command: mongod --smallfiles
mysql:
image: mysql:5.7
mem_limit: 350m
ports:
- "3306:3306"
environment:
- MYSQL_ROOT_PASSWORD=rootpwd
- MYSQL_DATABASE=review-db
- MYSQL_USER=user
- MYSQL_PASSWORD=pwd
healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-uuser", "-ppwd", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 10
以下是从前面代码中观察到的:
-
我们将使用官方的 MongoDB V3.6.9 和 MySQL 5.7 Docker 镜像,并将它们的默认端口
27017和3306转发到 Docker 主机,在 Mac 上使用 Docker 时也可在localhost上访问。 -
对于 MySQL,我们还声明了一些环境变量,定义如下:
-
root 密码:
-
将在图像启动时创建的数据库的名称:
-
为在图像启动时为数据库设置的用户设置用户名和密码:
-
-
对于 MySQL,我们还声明了一个健康检查,Docker 将运行该检查以确定 MySQL 数据库的状态。
为了避免微服务在数据库启动之前尝试连接到它们的数据库的问题,product和recommendation服务被声明为依赖于mongodb数据库,如下所示:
product/recommendation:
depends_on:
- mongodb
这意味着 Docker Compose 将在启动mongodb容器后启动product和recommendation容器。
出于同样的原因,review服务被声明为依赖于mysql数据库:
review:
depends_on:
mysql:
condition: service_healthy
在这种情况下,review服务依赖于不仅启动了mysql容器,而且mysql容器的健康检查报告也正常。之所以采取这一额外步骤,是因为mysql容器的初始化包括设置数据库并创建数据库超级用户。这需要几秒钟,为了在完成此操作之前阻止review服务启动,我们指示 Docker Compose 在mysql容器通过其健康检查报告正常之前,不要启动review容器。
数据库连接配置
有了数据库之后,我们现在需要为核心微服务设置配置,以便它们知道如何连接到其数据库。这在每个核心微服务的配置文件src/main/resources/application.yml中进行设置,位于product,recommendation和review项目中。
product和recommendation服务的配置类似,所以我们只查看product服务的配置。以下配置部分值得关注:
spring.data.mongodb:
host: localhost
port: 27017
database: product-db
logging:
level:
org.springframework.data.mongodb.core.MongoTemplate: DEBUG
---
spring.profiles: docker
spring.data.mongodb.host: mongodb
以下是从前面代码中观察到的:
-
在没有 Docker 的情况下运行,使用默认的 Spring 配置文件,期望数据库可以在
localhost:27017上访问。 -
将
MongoTemplate的日志级别设置为DEBUG将允许我们查看在日志中执行了哪些 MongoDB 语句。 -
在使用 Spring 配置文件运行 Docker 内部时,
Docker,数据库期望可以在mongodb:27017上访问。
影响review服务如何连接其 SQL 数据库的配置如下所示:
spring.jpa.hibernate.ddl-auto: update
spring.datasource:
url: jdbc:mysql://localhost/review-db
username: user
password: pwd
spring.datasource.hikari.initializationFailTimeout: 60000
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
---
spring.profiles: docker
spring.datasource:
url: jdbc:mysql://mysql/review-db
以下是从前面代码中观察到的:
-
默认情况下,Spring Data JPA 将使用 Hibernate 作为 JPA 实体管理器。
-
spring.jpa.hibernate.ddl-auto属性用于告诉 Spring Data JPA 在启动期间创建新的或更新现有的 SQL 表。注意:强烈建议在生产环境中将
spring.jpa.hibernate.ddl-auto属性设置为none——这防止 Spring Data JPA 操作 SQL 表的结构。 -
在没有 Docker 的情况下运行,使用默认的 Spring 配置文件,期望数据库可以在
localhost上使用默认端口3306访问。 -
默认情况下,Spring Data JPA 使用 HikariCP 作为 JDBC 连接池。为了在硬件资源有限的计算机上最小化启动问题,将
initializationFailTimeout参数设置为 60 秒。这意味着 Spring Boot 应用程序在启动期间会等待最多 60 秒以建立数据库连接。 -
Hibernate 的日志级别设置会导致 Hibernate 打印使用的 SQL 语句和实际值。请注意,在生产环境中,出于隐私原因,应避免将实际值写入日志。
-
当使用 Spring 配置文件
Docker在 Docker 内运行时,数据库预期可以通过mysql主机名使用默认端口3306可达。
MongoDB 和 MySQL CLI 工具
为了能够运行数据库 CLI 工具,可以使用 Docker Compose exec命令。
本节描述的命令将在下一节的手动测试中使用。现在不要尝试运行它们;因为我们现在还没有运行数据库,所以它们会失败!
要启动 MongoDB CLI 工具mongo,在mongodb容器内运行以下命令:
docker-compose exec mongodb mongo --quiet
>
输入exit以离开mongo CLI。
要启动 MySQL CLI 工具mysql,在mysql容器内并使用启动时创建的用户登录到review-db,请运行以下命令:
docker-compose exec mysql mysql -uuser -p review-db
mysql>
mysql CLI 工具将提示您输入密码;您可以在docker-compose.yml文件中找到它。查找环境变量的值MYSQL_PASSWORD。
输入exit以离开mysql CLI。
我们将在下一节看到这些工具的使用。
如果您更喜欢图形数据库工具,您也可以本地运行它们,因为 MongoDB 和 MySQL 容器都将在本地主机上暴露它们的标准端口。
对新 API 和持久化层进行手动测试。
现在,终于可以启动一切并使用 Swagger UI 进行手动测试了。
使用以下命令构建并启动系统架构:
cd $BOOK_HOME/Chapter06
./gradlew build && docker-compose build && docker-compose up
在网络浏览器中打开 Swagger UI,http://localhost:8080/swagger-ui.html,并在网页上执行以下步骤:
-
点击产品组合服务实现(product-composite-service-impl)和 POST 方法以展开它们。
-
点击尝试一下(Try it out)按钮并下移到正文字段。
-
将
productId字段的默认值0替换为123456。 -
滚动到底部的执行按钮并点击它。
-
验证返回的响应码是
200。
点击执行按钮后的示例屏幕截图如下:

从docker-compose up命令的日志输出中,我们应该能够看到如下输出(为了提高可读性而简化):

我们还可以使用数据库 CLI 工具来查看不同数据库中的实际内容。
在product服务中查找内容,即 MongoDB 中的products集合,使用以下命令:
docker-compose exec mongodb mongo product-db --quiet --eval "db.products.find()"
期望得到如下响应:

在recommendation服务中查找内容,即 MongoDB 中的recommendations集合,使用以下命令:
docker-compose exec mongodb mongo recommendation-db --quiet --eval "db.recommendations.find()"
期望得到如下响应:
10
在review服务中查找内容,即 MySQL 中的reviews表,使用以下命令:
docker-compose exec mysql mysql -uuser -p review-db -e "select * from reviews"
mysql CLI 工具将提示您输入密码;您可以在docker-compose.yml文件中找到它。查找环境变量的值MYSQL_PASSWORD。预期得到如下响应:

通过按下Ctrl + C中断docker-compose up命令,然后执行docker-compose down命令,可以关闭系统环境。之后,我们将看看如何在微服务环境中更新自动化测试。
更新微服务环境的自动化测试
微服务环境的自动化测试test-em-all.bash需要更新,以确保在运行测试之前,每个微服务数据库都处于已知状态。
脚本增加了一个设置函数setupTestdata(),该函数使用组合实体的创建和删除 API 将测试使用的产品重新创建到已知状态。
setupTestdata函数如下所示:
function setupTestdata() {
body=\
'{"productId":1,"name":"product 1","weight":1, "recommendations":[
{"recommendationId":1,"author":"author
1","rate":1,"content":"content 1"},
{"recommendationId":2,"author":"author
2","rate":2,"content":"content 2"},
{"recommendationId":3,"author":"author
3","rate":3,"content":"content 3"}
], "reviews":[
{"reviewId":1,"author":"author 1","subject":"subject
1","content":"content 1"},
{"reviewId":2,"author":"author 2","subject":"subject
2","content":"content 2"},
{"reviewId":3,"author":"author 3","subject":"subject
3","content":"content 3"}
]}'
recreateComposite 1 "$body"
body=\
'{"productId":113,"name":"product 113","weight":113, "reviews":[
{"reviewId":1,"author":"author 1","subject":"subject
1","content":"content 1"},
{"reviewId":2,"author":"author 2","subject":"subject
2","content":"content 2"},
{"reviewId":3,"author":"author 3","subject":"subject
3","content":"content 3"}
]}'
recreateComposite 113 "$body"
body=\
'{"productId":213,"name":"product 213","weight":213,
"recommendations":[
{"recommendationId":1,"author":"author
1","rate":1,"content":"content 1"},
{"recommendationId":2,"author":"author
2","rate":2,"content":"content 2"},
{"recommendationId":3,"author":"author
3","rate":3,"content":"content 3"}
]}'
recreateComposite 213 "$body"
}
它使用一个辅助函数recreateComposite()来对创建和删除 API 执行实际的请求:
function recreateComposite() {
local productId=$1
local composite=$2
assertCurl 200 "curl -X DELETE http://$HOST:$PORT/product-
composite/${productId} -s"
curl -X POST http://$HOST:$PORT/product-composite -H "Content-Type:
application/json" --data "$composite"
}
setupTestdata函数在waitForService函数之后直接调用:
waitForService curl -X DELETE http://$HOST:$PORT/product-composite/13
setupTestdata
waitForService函数的主要目的是验证所有微服务是否都已启动并运行。在前一章节中,使用了组合产品服务的 get API。在本章节中,我们使用的是 delete API。使用 get API 时,如果找不到实体,只会调用产品核心微服务;推荐和review服务不会被调用以验证它们是否启动并运行。调用 delete API 也将确保productId 13的未找到测试成功。在本书的后面部分,我们将了解如何为检查微服务环境的运行状态定义特定的 API。
以下命令可执行更新后的测试脚本:
cd $BOOK_HOME/Chapter06
./test-em-all.bash start stop
执行应该以编写如下日志消息结束:

这结束了微服务环境的自动化测试的更新。
总结
在本章节中,我们看到了如何使用 Spring Data 为核心微服务添加一个持久层。我们使用了 Spring Data 的核心概念,存储库和实体,在 MongoDB 和 MySQL 中以一种类似的编程模型存储数据,即使不是完全可移植的。我们还看到了 Spring Boot 的注解@DataMongoTest和@DataJpaTest如何用于方便地设置针对持久层的测试;在这种情况下,在测试运行之前自动启动嵌入式数据库,但不会启动微服务在运行时需要的其他基础架构,例如 Netty 这样的 web 服务器。这导致持久层测试易于设置,并且启动开销最小。
我们也看到了持久层如何被服务层使用,以及我们如何为创建和删除实体(包括核心和组合实体)添加 API。
最后,我们学习了使用 Docker Compose 在运行时启动 MongoDB 和 MySQL 等数据库是多么方便,以及如何使用新的创建和删除 API 在运行微服务基础系统景观的自动化测试之前设置测试数据。
然而,在本章中识别出了一个主要问题。使用同步 API 更新(创建或删除)复合实体——一个其部分存储在多个微服务中的实体——如果不成功更新所有涉及的微服务,可能会导致不一致。这通常是不可接受的。这引导我们进入下一章,我们将探讨为什么以及如何构建响应式微服务,即可扩展和健壮的微服务。
问题
-
Spring Data 是一种基于实体和仓库的常见编程模型,可以用于不同类型的数据库引擎。从本章的源代码示例中,MySQL 和 MongoDB 的持久化代码最重要的区别是什么?
-
实现乐观锁需要 Spring Data 提供什么?
-
MapStruct 是用来做什么的?
-
什么是幂等操作,为什么这很有用?
-
我们如何在不使用 API 的情况下访问存储在 MySQL 和 MongoDB 数据库中的数据?
第七章:开发反应式微服务
在本章中,我们将学习如何开发反应式微服务,即如何使用 Spring 开发非阻塞同步 REST API 和基于事件的异步服务。我们还将学习如何在这两种替代方案之间进行选择。最后,我们将了解如何创建和运行反应式微服务架构的手动和自动化测试。
正如在第一章的响应式微服务部分所描述的,反应式系统的基础是它们是消息驱动的——它们使用异步通信。这使得它们具有弹性,即可伸缩和有韧性,意味着它们将能够忍受失败。弹性和韧性相结合将使反应式系统能够变得响应性;它们将能够及时做出反应。
本章将涵盖以下主题:
-
在非阻塞同步 API 和基于事件的异步服务之间进行选择
-
使用 Spring 开发非阻塞同步 REST API
-
开发基于事件驱动的异步服务
-
反应式微服务架构的手动测试
-
反应式微服务架构的自动化测试
技术要求
本书中描述的所有命令都是在 MacBook Pro 上使用 macOS Mojave 运行的,但应该很容易修改,以便它们可以在其他平台如 Linux 或 Windows 上运行。
在本章中不需要安装任何新工具。
本章的源代码可以在 GitHub 上找到:github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud/tree/master/Chapter07。
为了能够运行书中描述的命令,将源代码下载到一个文件夹中,并设置一个环境变量$BOOK_HOME,使其指向该文件夹。一些示例命令如下:
export BOOK_HOME=~/Documents/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud
git clone https://github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud $BOOK_HOME
cd $BOOK_HOME/Chapter07
Java 源代码是为 Java 8 编写的,并在 Java 12 上进行了测试。本章使用 Spring Cloud 2.1.0(也称为Greenwich版本),Spring Boot 2.1.2 和 Spring 5.1.4,这些是编写本章时可用的 Spring 组件的最新版本。
源代码包含以下 Gradle 项目:
-
api -
util -
microservices/product-service -
microservices/review-service -
microservices/recommendation-service -
microservices/product-composite-service
本章中的代码示例均来自$BOOK_HOME/Chapter07的源代码,但在许多情况下进行了编辑,以删除源代码中不相关的内容,例如注释和import以及日志语句。
在本章中,您可以查看已对源代码所做的更改以及使微服务变得响应式所需的努力。此代码可与第六章的添加持久化源代码进行比较。您可以使用您喜欢的diff工具并比较两个文件夹—$BOOK_HOME/Chapter06和$BOOK_HOME/Chapter07。
在非阻塞的同步 API 和事件驱动的异步服务之间进行选择
在开发响应式微服务时,并不总是明显何时使用非阻塞的同步 API,何时使用事件驱动的异步服务。通常,为了使微服务具有鲁棒性和可伸缩性,使其尽可能自治是很重要的,例如,最小化其运行时依赖。这也被称为松耦合。因此,异步消息传递事件优于同步 API。这是因为微服务仅在运行时依赖于对消息系统的访问,而不是依赖于对多个其他微服务的同步访问。
然而,有许多情况下使用非阻塞的同步 API 可能是合适的,例如:
-
对于读操作,用户端正在等待响应
-
客户端平台更适合消耗同步 API,例如,移动应用或 SPA 网络应用
-
客户端将连接到来自其他组织的服务—在这些情况下,可能很难就跨组织使用的共同消息系统达成一致
对于本书中使用的系统架构,我们将使用以下内容:
-
产品组合微服务暴露的创建、读取和删除服务将基于同步 API。组合微服务假定具有 web 和移动平台以及来自其他组织(而非操作系统架构的组织)的客户端。因此,同步 API 似乎是一个自然的匹配。
-
核心微服务提供的读取服务也将开发为非阻塞的同步 API,因为有一个终端用户在等待它们的响应。
-
核心微服务提供的创建和删除服务将开发为事件驱动的异步服务。组合微服务提供的创建和删除聚合产品信息的同步 API 将简单地在核心服务监听的主题上发布、创建和删除事件,然后返回 200(OK)响应。
以下图表说明了这一点:

首先,让我们学习如何开发非阻塞的同步 REST API,之后,我们将查看如何开发事件驱动的异步服务。
使用 Spring 开发非阻塞的同步 REST API
在本节中,我们将学习如何开发读取 API 的非阻塞版本。复合服务将对三个核心服务并行地做出反应性的,即非阻塞的调用。当复合服务从核心服务收到响应后,它将创建一个复合响应并将其发送回调用者。以下图示说明了这一点:

我们将介绍以下内容:
-
介绍 Spring Reactor
-
使用 Spring Data for MongoDB 进行非阻塞持久化
-
核心服务中的非阻塞 REST API,包括如何处理基于 JPA 的持久化层的阻塞代码
-
非阻塞 REST API 在复合服务中
介绍 Spring Reactor
正如我们在第二章《Spring Boot 入门》中的 Beginning with Spring WebFlux 部分提到的,Spring 5 中的反应式支持基于 Project Reactor (projectreactor.io)。 Project Reactor 基于 Reactive Streams 规范 (www.reactive-streams.org),用于构建反应式应用程序的标准。 Spring Reactor 是基础,它是 Spring WebFlux、Spring WebClient 和 Spring Data 提供其反应性和非阻塞特性的依赖。
编程模型基于处理数据流,Project Reactor 的核心数据类型是 Flux 和 Mono。Flux 对象用于处理一个元素流 0...n,而 Mono 对象用于处理 0...1 个元素。在本章中我们将看到许多使用它们的示例。作为一个简短的介绍,让我们看看下面的测试:
@Test
public void TestFlux() {
List<Integer> list = new ArrayList<>();
Flux.just(1, 2, 3, 4)
.filter(n -> n % 2 == 0)
.map(n -> n * 2)
.log()
.subscribe(n -> list.add(n));
assertThat(list).containsExactly(4, 8);
}
以下是前面源代码的解释:
-
我们用整数
1、2、3和4初始化流。 -
接下来,我们
filter掉奇数——我们只允许偶数通过流继续进行——在这个测试中,这些是2和4。 -
接下来,我们将通过乘以
2对流中的值进行转换,即得到4和8。 -
然后,我们在
map操作后的流中log数据。 -
到目前为止,我们只是声明了数据流 processing。要实际处理数据流,我们需要有人来订阅它。
subscribe方法的最终调用将注册一个订阅者,订阅者将对从流中获取的每个元素应用subscribe方法中的 lambda 函数。此后,它将把它们添加到list元素。 -
最后,我们可以断言,在数据流处理后
list包含期望的结果——整数4和8。
日志输出将如下代码所示:
20:01:45.714 [main] INFO reactor.Flux.MapFuseable.1 - | onSubscribe([Fuseable] FluxMapFuseable.MapFuseableSubscriber)
20:01:45.716 [main] INFO reactor.Flux.MapFuseable.1 - | request(unbounded)
20:01:45.716 [main] INFO reactor.Flux.MapFuseable.1 - | onNext(4)
20:01:45.717 [main] INFO reactor.Flux.MapFuseable.1 - | onNext(8)
20:01:45.717 [main] INFO reactor.Flux.MapFuseable.1 - | onComplete()
以下是前面源代码的解释:
-
数据流的 processing 是由一个订阅者启动的,该订阅者订阅流并请求其内容。
-
接下来,整数
4和8通过了log操作。 -
处理以调用订阅者的
onComplete方法结束,通知它流已经结束。
完整的源代码请参阅util项目中的se.magnus.util.reactor.ReactorTests测试类。
通常,我们不会初始化流的处理。相反,我们只定义它应该如何被处理,而发起处理的职责将留给一个基础架构组件,比如 Spring WebFlux,例如,作为对传入 HTTP 请求的响应。这个规则的一个例外是阻塞代码需要从反应式流中获取响应的情况。在这些情况下,阻塞代码可以调用Flux或Mono对象上的block()方法,以阻塞方式从Flux或Mono对象获取响应。
非阻塞式持久化使用 Spring Data for MongoDB
将基于 MongoDB 的product和recommendation服务的存储库变为反应式非常简单:
-
将
ReactiveCrudRepository基类更改为存储库 -
将自定义查找方法更改为返回一个
Mono或Flux对象
更改后的ProductRepository和RecommendationRepository看起来像这样:
public interface ProductRepository extends ReactiveCrudRepository<ProductEntity, String> {
Mono<ProductEntity> findByProductId(int productId);
}
public interface RecommendationRepository extends ReactiveCrudRepository<RecommendationEntity, String> {
Flux<RecommendationEntity> findByProductId(int productId);
}
对于review服务的持久化代码没有进行任何更改,它将保持使用 JPA 存储库的阻塞式!
完整的源代码请参考以下类:
-
se.magnus.microservices.core.product.persistence.ProductRepository在product项目中。 -
se.magnus.microservices.core.recommendation.persistence.RecommendationRepository在recommendation项目中。
测试代码的变化
当涉及到测试持久层时,我们必须做一些改变。由于我们现在的持久化方法返回了一个Mono或Flux对象,测试方法必须等待响应在返回的反应式对象中可用。测试方法可以调用Mono/Flux对象的block()方法来等待响应可用,或者使用来自 Project Reactor 的StepVerifier帮助类来声明一个可验证的异步事件序列。
下面的示例展示了如何更改测试代码以适应存储库的反应式版本:
ProductEntity foundEntity = repository.findById(newEntity.getId()).get();
assertEqualsProduct(newEntity, foundEntity);
我们可以在repository.findById()方法返回的Mono对象上调用block()方法,并保持命令式编程风格,如下所示:
ProductEntity foundEntity = repository.findById(newEntity.getId()).block();
assertEqualsProduct(newEntity, foundEntity);
另外,我们可以使用StepVerifier类来设置一个处理步骤序列,既执行存储库查找操作,又验证结果。该序列通过最终调用verifyComplete()方法来初始化,如下所示:
StepVerifier.create(repository.findById(newEntity.getId()))
.expectNextMatches(foundEntity -> areProductEqual(newEntity,
foundEntity))
.verifyComplete();
有关使用StepVerifier类编写测试的示例,请参阅product项目中的se.magnus.microservices.core.product.PersistenceTests测试类。
有关使用block()方法编写测试的相应示例,请参阅recommendation项目中的se.magnus.microservice.core.recommendation.PersistenceTests测试类。
核心服务的非阻塞 REST API
在非阻塞持久层就位之后,是时候也让核心服务的 API 变为非阻塞式的了。我们需要进行以下更改:
-
修改 API,使它们只返回反应式数据类型
-
修改服务实现,使它们不包含任何阻塞代码
-
修改我们的测试,使它们能够测试反应式服务
-
处理阻塞代码—将仍需阻塞的代码与非阻塞代码隔离
API 的变化
为了使核心服务的 API 变为反应式的,我们需要更新它们的方法,使它们返回一个Mono或Flux对象。
例如,product服务中的getProduct()现在返回Mono<Product>而不是一个Product对象:
Mono<Product> getProduct(@PathVariable int productId);
完整的源代码请参阅api项目中的以下类:
-
se.magnus.api.core.product.ProductService -
se.magnus.api.core.recommendation.RecommendationService -
se.magnus.api.core.review.ReviewService
服务实现的变化
对于在product和recommendation服务中使用反应式持久层的服务实现,我们可以使用 Project Reactor 中的流式 API。例如,getProduct()方法的实现如下所示:
public Mono<Product> getProduct(int productId) {
if (productId < 1) throw new InvalidInputException("Invalid
productId: " + productId);
return repository.findByProductId(productId)
.switchIfEmpty(error(new NotFoundException("No product found
for productId: " + productId)))
.log()
.map(e -> mapper.entityToApi(e))
.map(e -> {e.setServiceAddress(serviceUtil.getServiceAddress()); return e;});
}
以下是前述源代码的解释:
-
该方法将返回一个
Mono对象;这里的处理是声明式的,而不是触发式的。一旦WebFlux接收到对此服务的请求,它就会被触发! -
将使用其
productId从底层数据库中检索产品,使用持久性仓库中的findByProductId()方法。 -
如果为给定的
productId找不到产品,将抛出NotFoundException。 -
log方法将产生日志输出。 -
将调用
mapper.entityToApi()方法将来自持久层返回的实体转换为 API 模型对象。 -
最终的
map方法将在模型对象的serviceAddress字段中设置处理请求的微服务的 DNS 名称和 IP 地址。
成功处理的一些示例日志输出如下:
2019-02-06 10:09:47.006 INFO 62314 --- [ctor-http-nio-2] reactor.Mono.SwitchIfEmpty.1 : onSubscribe(FluxSwitchIfEmpty.SwitchIfEmptySubscriber)
2019-02-06 10:09:47.007 INFO 62314 --- [ctor-http-nio-2] reactor.Mono.SwitchIfEmpty.1 : request(unbounded)
2019-02-06 10:09:47.034 INFO 62314 --- [ntLoopGroup-2-2] reactor.Mono.SwitchIfEmpty.1 : onNext(ProductEntity: 1)
2019-02-06 10:09:47.048 INFO 62314 --- [ntLoopGroup-2-2] reactor.Mono.SwitchIfEmpty.1 : onComplete()
以下是处理失败的一个示例(抛出一个未找到异常):
2019-02-06 10:09:52.643 INFO 62314 --- [ctor-http-nio-3] reactor.Mono.SwitchIfEmpty.2 : onSubscribe(FluxSwitchIfEmpty.SwitchIfEmptySubscriber)
2019-02-06 10:09:52.643 INFO 62314 --- [ctor-http-nio-3] reactor.Mono.SwitchIfEmpty.2 : request(unbounded)
2019-02-06 10:09:52.648 ERROR 62314 --- [ntLoopGroup-2-2] reactor.Mono.SwitchIfEmpty.2 : onError(se.magnus.util.exceptions.NotFoundException: No product found for productId: 2)
2019-02-06 10:09:52.654 ERROR 62314 --- [ntLoopGroup-2-2] reactor.Mono.SwitchIfEmpty.2 :
se.magnus.util.exceptions.NotFoundException: No product found for productId: 2
at se.magnus.microservices.core.product.services.ProductServiceImpl.getProduct(ProductServiceImpl.java:58) ~[classes/:na]
...
完整的源代码请参阅以下类:
-
product项目中的se.magnus.microservices.core.product.services.ProductServiceImpl -
recommendation项目中的se.magnus.microservices.core.recommendation.services.RecommendationServiceImpl
测试代码的变化
服务实现测试代码已经按照我们之前描述的持久层测试进行了更改。为了处理反应式返回类型的异步行为,Mono 和 Flux,测试中混合了调用block()方法和使用StepVerifier 助手类。
完整的源代码可以在以下测试类中找到:
-
se.magnus.microservices.core.product.ProductServiceApplicationTests在product项目中 -
se.magnus.microservices.core.recommendation.RecommendationServiceApplicationTests在recommendation项目中
处理阻塞代码
对于使用 JPA 在其关系型数据库中访问数据的review 服务,我们不支持非阻塞编程模型。相反,我们可以使用Scheduler来运行阻塞代码,它能够在有限线程数的专用线程池中运行线程。使用线程池来运行阻塞代码,避免了耗尽微服务中可用的线程(避免了影响微服务中的非阻塞处理)。
让我们看看这个过程是如何按照以下步骤展开的:
- 首先,我们在
mainReviewServiceApplication类中配置线程池,如下所示:
@Autowired
public ReviewServiceApplication (
@Value("${spring.datasource.maximum-pool-size:10}") Integer
connectionPoolSize
) {
this.connectionPoolSize = connectionPoolSize;
}
@Bean
public Scheduler jdbcScheduler() {
LOG.info("Creates a jdbcScheduler with connectionPoolSize = " +
connectionPoolSize);
return Schedulers.fromExecutor(Executors.newFixedThreadPool
(connectionPoolSize));
}
我们可以使用spring.datasource.maximum-pool-size 参数配置线程池的大小。如果没有设置,它将默认为 10 个线程。完整的源代码可以在se.magnus.microservices.core.review.ReviewServiceApplication 类中找到,该类在review 项目中。
- 接下来,我们将调度器注入到
review服务实现类中,如下所示:
@RestController
public class ReviewServiceImpl implements ReviewService {
private final Scheduler scheduler;
@Autowired
public ReviewServiceImpl(Scheduler scheduler, ...) {
this.scheduler = scheduler;
}
- 最后,我们在反应式实现中的
getReviews()方法中使用线程池,如下所示:
@Override
public Flux<Review> getReviews(int productId) {
if (productId < 1) throw new InvalidInputException("Invalid
productId: " + productId);
return asyncFlux(getByProductId(productId)).log();
}
protected List<Review> getByProductId(int productId) {
List<ReviewEntity> entityList =
repository.findByProductId(productId);
List<Review> list = mapper.entityListToApiList(entityList);
list.forEach(e ->
e.setServiceAddress(serviceUtil.getServiceAddress()));
LOG.debug("getReviews: response size: {}", list.size());
return list;
}
private <T> Flux<T> asyncFlux(Iterable<T> iterable) {
return Flux.fromIterable(iterable).publishOn(scheduler);
}
以下是前述代码的解释:
-
阻塞代码放在了
getByProductId()方法中 -
getReviews()方法使用asyncFlux()方法在线程池中运行阻塞代码
完整的源代码可以在se.magnus.microservices.core.review.services.ReviewServiceImpl 类中找到,该类在review 项目中。
复合服务中的非阻塞 REST API
为了使复合服务中的 REST API 非阻塞,我们需要做以下工作:
-
更改 API,使其只返回反应式数据类型
-
更改集成层,使其使用非阻塞 HTTP 客户端
-
更改服务实现,使其以并行和非阻塞的方式调用核心服务 API
-
更改我们的测试,以便它们可以测试反应式服务
API 的更改
为了使复合服务的 API 反应式,我们需要应用与之前描述的核心服务 API 相同的更改。这意味着getCompositeProduct 方法的返回类型ProductAggregate需要替换为Mono<ProductAggregate>。
完整的源代码可以在se.magnus.api.composite.product.ProductCompositeService 类中找到,该类在api 项目中。
集成层的变更
在ProductCompositeIntegration集成类中,我们将RestTemplate阻塞式 HTTP 客户端替换为 Spring 5 提供的WebClient非阻塞式 HTTP 客户端。
WebClient的构建器自动注入到构造函数中。如果需要自定义,例如设置公共头或过滤器,可以在构造函数中完成。有关可用的配置选项,请参阅docs.spring.io/spring/docs/current/spring-framework-reference/web-reactive.html#webflux-client-builder。请查看以下步骤:
- 在这里,我们简单地构建了将在集成类中使用的
WebClient实例,而不进行任何配置:
public class ProductCompositeIntegration implements ProductService, RecommendationService, ReviewService {
private final WebClient webClient;
@Autowired
public ProductCompositeIntegration(
WebClient.Builder webClient, ...
) {
this.webClient = webClient.build();
}
- 接下来,我们使用
webClient实例来调用product服务的非阻塞请求:
@Override
public Mono<Product> getProduct(int productId) {
String url = productServiceUrl + "/product/" + productId;
return webClient.get().uri(url).retrieve().bodyToMono(Product.class).log().onErrorMap(WebClientResponseException.class, ex -> handleException(ex));
}
如果对product服务的 API 调用失败,整个请求将会失败。WebClient onErrorMap()方法将调用我们的handleException(ex)方法,该方法将之前由 HTTP 层抛出的异常映射到我们自己的异常,例如NotFoundException和InvalidInputException。
然而,如果对product服务的调用成功,但对推荐或评论 API 的调用失败,我们不希望让整个请求失败。相反,我们希望能够返回尽可能多的可用信息给调用者。因此,在这些情况下,我们不会传播异常,而是使用WebClient onErrorResume(error -> empty())方法返回推荐或评论的空列表。考虑以下代码:
@Override
public Flux<Recommendation> getRecommendations(int productId) {
String url = recommendationServiceUrl + "/recommendation?
productId=" + productId;
// Return an empty result if something goes wrong to make it
// possible for the composite service to return partial responses
return webClient.get().uri(url).retrieve().bodyToFlux(Recommendation.class).log().onErrorResume(error -> empty());
}
要查看完整的源代码,请参阅product-composite项目中的se.magnus.microservices.composite.product.services.ProductCompositeIntegration类。
服务实现变更
为了能够并行调用三个 API,服务实现使用了Mono类上的静态zip()方法。zip方法能够处理多个并行请求,并在它们都完成后将它们组合在一起。代码如下:
@Override
public Mono<ProductAggregate> getCompositeProduct(int productId) {
return Mono.zip(
values -> createProductAggregate((Product) values[0],
(List<Recommendation>) values[1], (List<Review>) values[2],
serviceUtil.getServiceAddress()),
integration.getProduct(productId),
integration.getRecommendations(productId).collectList(),
integration.getReviews(productId).collectList())
.doOnError(ex -> LOG.warn("getCompositeProduct failed: {}",
ex.toString()))
.log();
}
以下是先前源代码的解释:
-
zip方法的第一参数是一个 lambda 函数,该函数将接收响应数组。三个 API 调用响应的实际聚合由之前的同一个助手方法处理,即createProductAggregate,没有进行任何更改。 -
在 lambda 函数后面的参数是一个请求列表,
zip方法将并行调用这些请求,每个请求对应一个Mono对象。在我们这个案例中,我们发送了三个由集成类方法创建的Mono对象,每个对象对应发送到每个核心微服务的每个请求。
要查看完整的源代码,请参阅product-composite项目中的se.magnus.microservices.composite.product.services.ProductCompositeServiceImpl类。
测试代码中的更改
测试类中唯一需要更改的是更新集成类的 mock 设置,以便使用Mono.just()帮助方法和Flux.fromIterable()返回Mono和Flux对象,如下面的代码所示:
public class ProductCompositeServiceApplicationTests {
@Before
public void setUp() {
when(compositeIntegration.getProduct(PRODUCT_ID_OK)).
thenReturn(just(new Product(PRODUCT_ID_OK, "name", 1,
"mock-address")));
when(compositeIntegration.getRecommendations(PRODUCT_ID_OK)).
thenReturn(Flux.fromIterable(singletonList(new
Recommendation(PRODUCT_ID_OK, 1, "author", 1, "content",
"mock address"))));
when(compositeIntegration.getReviews(PRODUCT_ID_OK)).
thenReturn(Flux.fromIterable(singletonList(new
Review(PRODUCT_ID_OK, 1, "author", "subject", "content",
"mock address"))));
完整的源代码,请参阅product-composite项目中的se.magnus.microservices.composite.product.ProductCompositeServiceApplicationTests测试类。
现在我们已经使用 Spring 开发了非阻塞 REST API,是时候开发一个基于事件的同步服务了。
开发基于事件的异步服务
在本节中,我们将学习如何开发基于事件的异步创建和删除服务版本。组合服务将在每个核心服务主题上发布创建和删除事件,然后不等待核心服务中的处理,向调用者返回一个 OK 响应。以下图表说明了这一点:

我们将涵盖以下主题:
-
配置 Spring Cloud Stream 以处理消息传递挑战
-
定义主题和事件
-
Gradle 构建文件中的更改
-
在组合服务中发布事件
-
在核心服务中消费事件
配置 Spring Cloud Stream 以处理消息传递挑战
为了实现基于事件创建和删除服务,我们将使用 Spring Cloud Stream。在第二章,《Spring Boot 入门》中的Spring Cloud Stream部分,我们已经看到了使用 Spring Cloud Stream 在主题上发布和消费消息是多么简单。
例如,要发布一个由mysource定义的主题上的消息,我们只需要写以下内容:
mysource.output().send(MessageBuilder.withPayload(message).build());
为了消费消息,我们编写以下代码:
@StreamListener(target = Sink.INPUT)
public void receive(MyMessage message) {
LOG.info("Received: {}",message);
这种编程模型可以独立于使用的消息系统,例如,RabbitMQ 或 Apache Kafka!
尽管异步消息传递优先于同步 API 调用,但它带来了挑战。我们将了解如何使用 Spring Cloud Stream 来处理其中一些问题。以下 Spring Cloud Stream 功能将得到覆盖:
-
消费者群体
-
重试和死信队列
-
保证顺序和分区
我们将在以下章节中研究每个这些内容。
消费者群体
这里的问题在于,如果我们增加消息消费者的实例数量,例如,启动产品微服务的两个实例,两个产品微服务实例都将消费相同的消息,如下面的图表所示:

这个问题的解决方案是我们只希望每个消费者实例处理每条消息。这可以通过引入一个消费者组来解决,如下面的图表所示:

在 Spring Cloud Stream 中,消费者组可以在消费者端进行配置,例如,对于产品微服务,如下所示:
spring.cloud.stream:
bindings.input:
destination: products
group: productsGroup
在前面的配置中,Spring Cloud Stream 将使用group字段的值将product微服务的实例添加到名为productsGroup的消费者组中。这意味着发送到products主题的消息将只由 Spring Cloud Stream 交付给产品微服务的一个实例。
重试和死信队列
在本节中,我们将学习消息消费者如何使用重试和死信队列。
如果消费者未能处理消息,它可能会丢失或被重新排队,直到失败消费者成功处理。如果消息内容无效,也称为毒消息,它将阻塞消费者处理其他消息,直到手动移除。如果失败是由于临时问题,例如,由于临时网络错误无法访问数据库,经过多次重试后处理可能会成功。
必须能够指定重试次数,直到消息被移动到另一个存储进行故障分析和修正。失败的消息通常会被移动到一个专门的队列,称为死信队列。为了避免在临时故障时,例如网络错误,过度负载基础架构,必须能够配置重试的频率,最好每次重试之间的时间逐渐增加。
在 Spring Cloud Stream 中,这可以在消费者端进行配置,例如,对于产品微服务,如下所示:
spring.cloud.stream.bindings.input.consumer:
maxAttempts: 3
backOffInitialInterval: 500
backOffMaxInterval: 1000
backOffMultiplier: 2.0
spring.cloud.stream.rabbit.bindings.input.consumer:
autoBindDlq: true
republishToDlq: true
spring.cloud.stream.kafka.bindings.input.consumer:
enableDlq: true
在前面的示例中,我们指定 Spring Cloud Stream 在将消息放置到死信队列之前应执行3次重试。第一次重试将在500毫秒后尝试,其余两次尝试将在1000毫秒后进行。
启用死信队列的使用是与绑定特定的;因此,我们有针对 RabbitMQ 和 Kafka 各一个配置。
保证顺序和分区
我们可以使用分区来确保消息按发送顺序交付,同时不失去性能和可扩展性。
如果业务逻辑要求消息按发送顺序被消费和处理,我们不能为了提高处理性能而使用每个消费者多个实例;例如,我们不能使用消费者组。在某些情况下,这可能导致处理传入消息时出现不可接受的延迟。
在大多数情况下,消息处理中的严格顺序仅对影响相同业务实体的消息 required,例如,产品。
例如,影响产品 ID 为1的消息在很多情况下可以独立于影响产品 ID 为2的消息进行处理。这意味着只需要为具有相同产品 ID 的消息保证顺序。
这个问题的解决办法是,使其能够为每个消息指定一个键,消息传递系统可以使用该键来保证具有相同键的消息之间的顺序。这可以通过在主题中引入子主题(也称为分区)来解决。消息传递系统根据其键将消息放置在特定的分区中。具有相同键的消息总是放置在同一个分区中。消息传递系统只需要保证同一分区的消息的交付顺序。为了确保消息的顺序,我们在消费者组内的每个分区配置一个消费者实例。通过增加分区数,我们可以允许消费者增加其实例数。这在不失去交付顺序的情况下增加了其处理消息的性能。这在下面的图中说明:

在 Spring Cloud Stream 中,这需要在发布者和消费者双方进行配置。在发布者方面,必须指定键和分区数。例如,对于product-composite服务,我们有以下内容:
spring.cloud.stream.bindings.output:
destination: products
producer:
partition-key-expression: payload.key
partition-count: 2
前面的配置意味着将使用名为key的字段从消息负载中获取键,并使用两个分区。
每个消费者可以指定它想要接收消息的分区。例如,对于product微服务,我们有以下内容:
spring.cloud.stream.bindings.input:
destination: products
group:productsGroup
consumer:
partitioned: true
instance-index: 0
前面的配置告诉 Spring Cloud Stream 这个消费者只将接收来自分区编号0的消息,即第一个分区。
定义主题和事件
正如我们在第二章的Spring Cloud Stream部分提到的,Spring Boot 入门,Spring Cloud Stream 基于发布和订阅模式,发布者将消息发布到主题,订阅者订阅他们感兴趣的主题以接收消息。
我们将为每种类型的实体使用一个主题:products、recommendations和reviews。
消息传递系统处理消息,这些消息通常由标题和正文组成。事件是描述已经发生的事情的消息。对于事件,消息正文可以用来描述事件类型、事件数据以及事件发生的日期时间戳。
事件在本书的范围内由以下内容定义:
-
事件类型,例如,创建或删除事件
-
一个键,用于标识数据,例如,产品 ID
-
一个数据元素,即事件中的实际数据
-
一个时间戳,描述事件发生的时间
我们将使用的事件类如下所示:
public class Event<K, T> {
public enum Type {CREATE, DELETE}
private Event.Type eventType;
private K key;
private T data;
private LocalDateTime eventCreatedAt;
public Event() {
this.eventType = null;
this.key = null;
this.data = null;
this.eventCreatedAt = null;
}
public Event(Type eventType, K key, T data) {
this.eventType = eventType;
this.key = key;
this.data = data;
this.eventCreatedAt = now();
}
public Type getEventType() {
return eventType;
}
public K getKey() {
return key;
}
public T getData() {
return data;
}
public LocalDateTime getEventCreatedAt() {
return eventCreatedAt;
}
}
让我们详细解释一下前面的源代码:
-
Event类是一个泛型类,其key和data字段类型为K和T。 -
事件类型被声明为一个枚举器,其允许的值是,即
CREATE和DELETE。 -
这个类定义了两个构造函数,一个空构造函数和一个可以用来初始化类型、键和值成员的构造函数。
-
最后,这个类为其成员变量定义了 getter 方法。
要查看完整的源代码,请参阅api项目中的se.magnus.api.event.Event类。
在 Gradle 构建文件中的更改
为了引入 Spring Cloud Stream 及其对 RabbitMQ 和 Kafka 的绑定器,我们需要添加两个启动依赖项,分别称为spring-cloud-starter-stream-rabbit和spring-cloud-starter-stream-kafka。我们还需要一个测试依赖项,spring-cloud-stream-test-support,以引入测试支持。下面的代码展示了这一点:
dependencies {
implementation('org.springframework.cloud:spring-cloud-starter-stream-rabbit')
implementation('org.springframework.cloud:spring-cloud-starter-stream-kafka')
testImplementation('org.springframework.cloud:spring-cloud-stream-test-support')
}
为了指定我们想要使用的 Spring Cloud 版本,我们首先声明一个版本变量的变量:
ext {
springCloudVersion = "Greenwich.RELEASE"
}
为了完成那个版本的依赖管理设置,我们使用了以下代码:
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-
dependencies:${springCloudVersion}"
}
}
要查看完整的源代码,请参阅product-composite项目中的build.gradle构建文件。
在复合服务中发布事件
当复合服务接收到创建或删除产品的请求时,它应将相应的事件发布到核心服务的主题上。为了能够在复合服务中发布事件,我们需要执行以下步骤:
-
在集成层声明消息源并发布事件。
-
添加发布事件的配置。
-
更改我们的测试,以便它们可以测试事件的发布。
复合服务实现类中不需要进行任何更改!
在集成层声明消息源并发布事件。
为了能够将事件发布到不同的主题,我们需要在 Java 接口中声明一个MessageChannel per topic,并声明我们想要使用它与EnableBinding annotation。让我们看看如何做到这一点:
- 我们在
ProductCompositeIntegration类中的MessageSources接口中声明我们的消息通道,并请求 Spring 在构造函数中注入它的一个实例,如下所示:
@EnableBinding(ProductCompositeIntegration.MessageSources.class)
@Component
public class ProductCompositeIntegration implements ProductService, RecommendationService, ReviewService {
private MessageSources messageSources;
public interface MessageSources {
String OUTPUT_PRODUCTS = "output-products";
String OUTPUT_RECOMMENDATIONS = "output-recommendations";
String OUTPUT_REVIEWS = "output-reviews";
@Output(OUTPUT_PRODUCTS)
MessageChannel outputProducts();
@Output(OUTPUT_RECOMMENDATIONS)
MessageChannel outputRecommendations();
@Output(OUTPUT_REVIEWS)
MessageChannel outputReviews();
}
public ProductCompositeIntegration(
MessageSources messageSources,
) {
this.messageSources = messageSources;
}
当我们想要在某个主题上发表一个事件时,我们会使用注入的messageSources对象。例如,要为一个产品发送一个删除事件,我们可以使用outputProducts()方法获取产品的主题的消息通道,然后使用其send()方法发布一个事件。
- 要创建包含事件的消息,我们可以使用内置的
MessageBuilder类,如下所示:
@Override
public void deleteProduct(int productId) {
messageSources.outputProducts().send(MessageBuilder.
withPayload(new Event(DELETE, productId, null)).build());
}
要查看完整的源代码,请参阅product-composite项目中的se.magnus.microservices.composite.product.services.ProductCompositeIntegration类。
添加发布事件的配置
我们还需要为消息系统设置一个配置,以便能够发布事件。为此,我们需要完成以下步骤:
- 我们声明 RabbitMQ 是默认的消息系统,默认的内容类型是 JSON:
spring.cloud.stream:
defaultBinder: rabbit
default.contentType: application/json
- 接下来,我们将我们的输出通道绑定到特定的主题名称,如下所示:
bindings:
output-products:
destination: products
output-recommendations:
destination: recommendations
output-reviews:
destination: reviews
- 最后,我们声明了 Kafka 和 RabbitMQ 的连接信息:
spring.cloud.stream.kafka.binder:
brokers: 127.0.0.1
defaultBrokerPort: 9092
spring.rabbitmq:
host: 127.0.0.1
port: 5672
username: guest
password: guest
---
spring.profiles: docker
spring.rabbitmq.host: rabbitmq
spring.cloud.stream.kafka.binder.brokers: kafka
在默认的 Spring 配置文件中,我们指定了当不使用 Docker 在localhost上运行我们的系统景观时使用的主机名,IP 地址为127.0.0.1。在dockerSpring 配置文件中,我们指定了在 Docker 和 Docker Compose 中运行时将使用的主机名,即rabbitmq和kafka。
为了查看完整的源代码,请查看product-composite项目中的src/main/resources/application.yml配置文件。
测试代码的变化
测试异步事件驱动的微服务,按其性质来说,是困难的。测试通常需要以某种方式同步异步后台处理,以能够验证其结果。Spring Cloud Stream 提供了支持,通过TestSupportBinder,在测试中不使用任何消息系统就可以验证发送了哪些消息!
测试支持包括一个MessageCollector助手类,可以用来获取测试期间发送的所有消息。要了解如何做到这一点,请查看以下步骤:
- 在
MessagingTests测试类中,我们设置了一个队列,可以用来检查发送到每个主题的消息,如下所示:
@Autowired
private MessageCollector collector;
BlockingQueue<Message<?>> queueProducts = null;
BlockingQueue<Message<?>> queueRecommendations = null;
BlockingQueue<Message<?>> queueReviews = null;
@Before
public void setUp() {
queueProducts = getQueue(channels.outputProducts());
queueRecommendations =
getQueue(channels.outputRecommendations());
queueReviews = getQueue(channels.outputReviews());
}
private BlockingQueue<Message<?>> getQueue(MessageChannel
messageChannel) {
return collector.forChannel(messageChannel);
}
- 一个实际的测试可以验证队列中的内容,如下面的测试可以验证产品的创建:
@Test
public void createCompositeProduct1() {
ProductAggregate composite = new ProductAggregate(1, "name", 1,
null, null, null);
postAndVerifyProduct(composite, OK);
// Assert one expected new product events queued up
assertEquals(1, queueProducts.size());
Event<Integer, Product> expectedEvent = new Event(CREATE,
composite.getProductId(), new Product(composite.getProductId(),
composite.getName(), composite.getWeight(), null));
assertThat(queueProducts,
is(receivesPayloadThat (sameEventExceptCreatedAt
(expectedEvent))));
// Assert none recommendations and review events
assertEquals(0, queueRecommendations.size());
assertEquals(0, queueReviews.size());
}
receivesPayloadThat()方法是 Spring Cloud Stream 中另一个测试支持类MessageQueueMatcher的静态方法。这个类包含了一组方法,可以简化队列中消息的验证。
sameEventExceptCreatedAt()方法是IsSameEvent类中的一个静态方法,它比较Event对象,如果所有字段都相等,除了eventCreatedAt字段,则认为它们相等。
为了查看完整的源代码,请查看product-composite项目中的以下测试类:
-
se.magnus.microservices.composite.product.MessagingTests -
se.magnus.microservices.composite.product.IsSameEvent
在核心服务中消费事件
为了在核心服务中消费事件,我们需要做以下事情:
-
声明监听其主题上事件的消息处理器。
-
更改我们的服务实现,使其正确使用反应式持久层。
-
添加用于消费事件的配置。
-
更改我们的测试,使它们可以测试事件的异步处理。
声明消息处理器
创建和删除实体的 REST API 已经被每个核心微服务中的消息处理器所取代,该处理器监听每个实体主题上的创建和删除事件。为了能够消费已经发布到主题的消息,我们需要绑定到SubscribableChannel,这与我们想要发布消息时绑定到MessageChannel类似。由于每个消息处理器只监听一个主题,我们可以使用内置的Sink接口来绑定该主题。我们使用EnableBinding注解来声明使用Sink接口,如下所示:
@EnableBinding(Sink.class)
public class MessageProcessor {
为了实际消费和处理消息,我们可以用StreamListener 注解标注一个方法,其中我们指定我们要监听哪个通道:
@StreamListener(target = Sink.INPUT)
public void process(Event<Integer, Product> event) {
process()方法的实现使用一个switch语句来调用服务组件中的创建方法以创建事件和删除方法以删除事件。源代码如下所示:
switch (event.getEventType()) {
case CREATE:
Product product = event.getData();
LOG.info("Create product with ID: {}", product.getProductId());
productService.createProduct(product);
break;
case DELETE:
int productId = event.getKey();
LOG.info("Delete recommendations with ProductID: {}", productId);
productService.deleteProduct(productId);
break;
default:
String errorMessage = "Incorrect event type: " +
event.getEventType() + ", expected a CREATE or DELETE event";
LOG.warn(errorMessage);
throw new EventProcessingException(errorMessage);
}
让我们详细解释一下前面的源代码:
-
switch语句期望一个事件类型,该事件类型是一个CREATE或DELETE事件。 -
productService.createProduct()方法用于创建事件。 -
productService.deleteProduct()方法用于删除事件。 -
如果事件类型既不是
CREATE也不是DELETE事件;将抛出EventProcessingException类型的异常。
服务组件像往常一样通过构造函数注入,如下所示:
private final ProductService productService;
@Autowired
public MessageProcessor(ProductService productService) {
this.productService = productService;
}
要查看完整的源代码,请查看以下类:
-
se.magnus.microservices.core.product.services.MessageProcessor在product项目中 -
se.magnus.microservices.core.recommendation.services.MessageProcessor在recommendation项目中 -
se.magnus.microservices.core.review.services.MessageProcessor在review项目中
服务实现中的更改
product和recommendation服务的创建和删除方法的实现已重写,以使用非阻塞的反应式 MongoDB 持久层。例如,创建产品实体的操作如下所示:
public class ProductServiceImpl implements ProductService {
@Override
public Product createProduct(Product body) {
if (body.getProductId() < 1) throw new
InvalidInputException("Invalid productId: " +
body.getProductId());
ProductEntity entity = mapper.apiToEntity(body);
Mono<Product> newEntity = repository.save(entity)
.log()
.onErrorMap(
DuplicateKeyException.class,
ex -> new InvalidInputException("Duplicate key, Product
Id: " + body.getProductId()))
.map(e -> mapper.entityToApi(e));
return newEntity.block();
}
onErrorMap() 方法用于将DuplicateKeyException 持久化异常映射到我们自己的InvalidInputException 异常。
由于我们的消息处理程序基于阻塞编程模型,因此在我们将其返回给消息处理程序之前,需要在持久层返回的Mono对象上调用block()方法。如果我们不调用block()方法,如果在服务实现中处理失败,我们将无法触发消息系统中的错误处理;事件将不会重新入队,最终,它将被移动到死信队列中,如预期的那样。
使用阻塞持久层JPA的review服务,如前所述,不需要更新。
要查看完整的源代码,请查看以下类:
-
se.magnus.microservices.core.product.services.ProductServiceImpl在product项目中 -
se.magnus.microservices.core.recommendation.services.RecommendationServiceImpl在recommendation项目中
添加用于消费事件的配置
我们还需要为消息系统设置配置,以便能够消费事件;这类似于我们对发布者所做的工作。将 RabbitMQ 声明为默认的消息系统,JSON 作为默认内容类型,以及 Kafka 和 RabbitMQ 的连接信息与发布者相同。除了公共部分,消费者配置还指定了消费者组;重试处理和死信队列与之前在配置 Spring Cloud Stream 以处理消息挑战部分中描述的一致。
要查看完整的源代码,请查看以下配置文件:
-
src/main/resources/application.yml在product项目中 -
src/main/resources/application.yml在recommendation项目中 -
src/main/resources/application.yml在review项目中
测试代码中的更改
由于核心服务现在接收创建和删除实体的事件,测试需要更新,以便它们发送事件而不是像以前那样调用 REST API。在下面的源代码中,我们可以看到如何使用input方法通道的send()方法发送一个事件:
private void sendCreateProductEvent(int productId) {
Product product = new Product(productId, "Name " + productId,
productId, "SA");
Event<Integer, Product> event = new Event(CREATE, productId,
product);
input.send(new GenericMessage<>(event));
}
private void sendDeleteProductEvent(int productId) {
Event<Integer, Product> event = new Event(DELETE, productId, null);
input.send(new GenericMessage<>(event));
}
input通道由测试类在运行任何测试之前设置。它基于与消息处理器使用的相同内置Sink接口。在下面的源代码中,我们可以看到input通道是在setupDb()方法中创建的。由于setupDb()方法用@Before注解,所以它将在执行任何测试之前运行:
@Autowired
private Sink channels;
private AbstractMessageChannel input = null;
@Before
public void setupDb() {
input = (AbstractMessageChannel) channels.input();
repository.deleteAll().block();
}
这种构造绕过了消息系统,input通道上的send()方法的调用将由消息处理器同步处理,也就是说,它的process()方法就像一个普通的方法调用。这意味着测试代码不需要为事件的异步处理实现任何同步或等待逻辑。相反,测试代码可以在调用sendCreateProductEvent和sendDeleteProductEvent发送助手方法返回后直接应用验证逻辑。
要查看完整的源代码,请查看以下测试类:
-
se.magnus.microservices.core.product.ProductServiceApplicationTests在product项目中 -
se.magnus.microservices.core.recommendation.RecommendationServiceApplicationTests在recommendation项目中 -
se.magnus.microservices.core.review.ReviewServiceApplicationTests在review项目中
手动测试反应式微服务架构
现在,我们拥有完全反应式的微服务,无论是在非阻塞同步 REST API 还是在事件驱动的异步服务方面。让我们尝试一下它们!
准备了三种不同的配置,每个都在一个单独的 Docker Compose 文件中:
-
使用不使用分区的 RabbitMQ
-
使用每个主题两个分区的 RabbitMQ
-
使用每个主题两个分区的 Kafka
然而,在测试这三个配置之前,我们首先需要简化对响应式微服务架构的测试。简化后,我们可以继续测试微服务。
因此,需要检查以下两个功能:
-
使用 RabbitMQ 保存事件以供稍后检查。
-
一个可以用来监控景观状态的健康 API。
保存事件。
在对事件驱动的异步服务进行一些测试后,可能会有兴趣查看实际发送了哪个事件。当使用 Spring Cloud Stream 和 Kafka 时,事件即使在消费者处理后也会保留在主题中。然而,当使用 Spring Cloud Stream 和 RabbitMQ 时,事件在成功处理后被移除。
为了能够查看每个主题上已经发布的事件,Spring Cloud Stream 被配置为在每个主题上保存发布的事件到一个单独的auditGroup消费者组中。对于products主题,配置如下所示:
spring.cloud.stream:
bindings:
output-products:
destination: products
producer:
required-groups: auditGroup
当使用 RabbitMQ 时,这将导致创建额外的队列,以便将事件存储以供稍后检查。
要查看完整的源代码,请参阅product-composite项目中的src/main/resources/application.yml配置文件。
添加健康 API。
测试使用同步 API 和异步消息传递的微服务系统架构是具有挑战性的。例如,我们如何知道一个新启动的微服务架构(及其数据库和消息系统)是否准备好处理请求和消息?
为了更容易地知道所有微服务是否准备好处理请求和消息,我们在所有微服务中添加了一个健康 API。它们基于 Spring Boot 模块中名为 Actuator 的支持健康端点的支持。默认情况下,基于 Actuator 的健康端点回答UP(并给出 200 作为 HTTP 返回状态)如果微服务本身以及 Spring Boot 知道的所有的依赖项都可用,例如,对数据库和消息系统的依赖;否则,健康端点回答DOWN(并返回 500 作为 HTTP 返回状态)。
我们还可以扩展health端点以覆盖 Spring Boot 不知道的依赖项。我们将使用这个特性来扩展产品组合的health端点,这也将包括三个核心服务的健康状况。这意味着产品组合的health端点只会在自身和三个核心微服务都健康的情况下回答UP。这可以手动或自动地由test-em-all.bash脚本来使用,以找出所有微服务及其依赖项是否都已启动并运行。
在ProductCompositeIntegration集成类中,我们添加了用于检查三个核心微服务健康状况的帮助方法,如下所示:
public Mono<Health> getProductHealth() {
return getHealth(productServiceUrl);
}
public Mono<Health> getRecommendationHealth() {
return getHealth(recommendationServiceUrl);
}
public Mono<Health> getReviewHealth() {
return getHealth(reviewServiceUrl);
}
private Mono<Health> getHealth(String url) {
url += "/actuator/health";
LOG.debug("Will call the Health API on URL: {}", url);
return webClient.get().uri(url).retrieve().bodyToMono(String.class)
.map(s -> new Health.Builder().up().build())
.onErrorResume(ex -> Mono.just(new
Health.Builder().down(ex).build()))
.log();
}
这段代码与我们之前用于调用核心服务以读取 API 的代码相似。
有关完整源代码,请参阅 product-composite 项目中的 se.magnus.microservices.composite.product.services.ProductCompositeIntegration 类。
在主 ProductCompositeServiceApplication 应用程序类中,我们使用这些辅助方法注册三个健康检查,每个核心微服务一个:
@Autowired
HealthAggregator healthAggregator;
@Autowired
ProductCompositeIntegration integration;
@Bean
ReactiveHealthIndicator coreServices() {
ReactiveHealthIndicatorRegistry registry = new
DefaultReactiveHealthIndicatorRegistry(new LinkedHashMap<>());
registry.register("product", () -> integration.getProductHealth());
registry.register("recommendation", () ->
integration.getRecommendationHealth());
registry.register("review", () -> integration.getReviewHealth());
return new CompositeReactiveHealthIndicator(healthAggregator,
registry);
}
有关完整源代码,请参阅 product-composite 项目中的 se.magnus.microservices.composite.product.ProductCompositeServiceApplication 类。
最后,在所有四个微服务的 application.yml 文件中,我们配置了 Spring Boot Actuator,使其执行以下操作:
-
显示有关健康状态的详细信息,这不仅包括
UP或DOWN,还包括有关其依赖项的信息: -
通过 HTTP 暴露其所有端点:
这两个设置的配置如下所示:
management.endpoint.health.show-details: "ALWAYS"
management.endpoints.web.exposure.include: "*"
有关完整源代码的示例,请参阅 product-composite 项目中的 src/main/resources/application.yml 配置文件。
警告:这些配置设置在开发过程中很好,但在生产系统中暴露太多信息在 actuator 端点上可能是一个安全问题。因此,计划最小化在生产中 actuator 端点暴露的信息!
有关由 Spring Boot Actuator 暴露的端点的详细信息,请参阅 docs.spring.io/spring-boot/docs/current/reference/html/production-ready-endpoints.html:
- 尝试一下(当你使用 Docker Compose 启动所有微服务时,如下一节所述):
curl localhost:8080/actuator/health -s | jq .
- 这将导致以下响应:

在前面的输出中,我们可以看到复合服务报告它是健康的,即它的状态是 UP。在响应的末尾,我们可以看到三个核心微服务也被报告为健康。
有了健康 API,我们就准备好测试我们的反应式微服务了。
不使用分区来使用 RabbitMQ:
在本节中,我们将测试与 RabbitMQ 一起使用的反应式微服务,但不用分区。
在此配置中使用默认的 docker-compose.yml Docker Compose 文件。已对文件应用了以下更改:
- RabbitMQ 已经被添加,如图所示:
rabbitmq:
image: rabbitmq:3.7.8-management
mem_limit: 350m
ports:
- 5672:5672
- 15672:15672
healthcheck:
test: ["CMD", "rabbitmqctl", "status"]
interval: 10s
timeout: 5s
retries: 10
- 微服务现在对 RabbitMQ 服务有了依赖声明。这意味着 Docker 不会启动微服务容器,直到 RabbitMQ 服务被报告为健康:
depends_on:
rabbitmq:
condition: service_healthy
要运行我们的测试,请执行以下步骤:
- 使用以下命令构建并启动系统架构:
cd $BOOK_HOME/Chapter07
./gradlew build && docker-compose build && docker-compose up -d
-
现在,我们必须等待微服务架构运行起来。
尝试运行以下命令几次:
curl -s localhost:8080/actuator/health | jq -r .status
当它返回 UP 时,我们就准备好运行我们的测试了!
- 首先,使用以下命令创建一个复合产品:
body='{"productId":1,"name":"product name C","weight":300, "recommendations":[
{"recommendationId":1,"author":"author 1","rate":1,"content":"content 1"},
{"recommendationId":2,"author":"author 2","rate":2,"content":"content 2"},
{"recommendationId":3,"author":"author 3","rate":3,"content":"content 3"}
], "reviews":[
{"reviewId":1,"author":"author 1","subject":"subject 1","content":"content 1"},
{"reviewId":2,"author":"author 2","subject":"subject 2","content":"content 2"},
{"reviewId":3,"author":"author 3","subject":"subject 3","content":"content 3"}
]}'
curl -X POST localhost:8080/product-composite -H "Content-Type: application/json" --data "$body"
当 Spring Cloud Stream 与 RabbitMQ 一起使用时,它将根据我们的配置为每个主题创建一个 RabbitMQ 交换和一个队列集。
看看 Spring Cloud Stream 为我们创建了哪些队列吧!
- 在网页浏览器中打开以下 URL:
http://localhost:15672/#/queues。你应该看到以下队列:

对于每个主题,我们可以看到一个 auditGroup 队列,一个由相应核心微服务使用的消费者组队列,以及一个死信队列。我们还可以看到 auditGroup 队列中包含消息,正如我们所期望的那样!
- 点击
products.auditGroup队列,向下滚动到 Get Message(s),展开它,然后点击名为 Get Message(s)的按钮查看队列中的消息:

- 接下来,尝试使用以下代码获取产品组合:
curl localhost:8080/product-composite/1 | jq
- 最后,像这样删除它:
curl -X DELETE localhost:8080/product-composite/1
试图再次获取已删除的产品应该会导致一个404 - "NotFound"的响应!
如果你再次查看 RabbitMQ 审计队列,你应该能够找到包含删除事件的新消息。
- 通过以下命令结束测试,关闭微服务架构:
docker-compose down
这样就完成了我们使用没有分区的 RabbitMQ 的测试。现在,让我们继续测试带有分区的 RabbitMQ。
使用每个主题两个分区的 RabbitMQ
现在,让我们尝试一下 Spring Cloud Stream 中的分区支持!
我们为使用每个主题两个分区的 RabbitMQ 准备了一个单独的 Docker Compose 文件:docker-compose-partitions.yml。它还将为每个核心微服务启动两个实例,每个分区一个。例如,第二个product实例的配置如下:
product-p1:
build: microservices/product-service
mem_limit: 350m
environment:
- SPRING_PROFILES_ACTIVE=docker
- SPRING_CLOUD_STREAM_BINDINGS_INPUT_CONSUMER_PARTITIONED=true
- SPRING_CLOUD_STREAM_BINDINGS_INPUT_CONSUMER_INSTANCECOUNT=2
- SPRING_CLOUD_STREAM_BINDINGS_INPUT_CONSUMER_INSTANCEINDEX=1
depends_on:
mongodb:
condition: service_healthy
rabbitmq:
condition: service_healthy
以下是前述源代码的解释:
-
我们使用与第一个
product实例相同的源代码和 Dockerfile,但它们进行了不同的配置。 -
具体来说,我们将两个
product实例分配到不同的分区,使用的是我们本章前面描述的instance-index属性。 -
当使用系统环境变量来指定 Spring 属性时,我们必须使用大写字母格式,其中点被下划线替换。
-
这个
product实例只处理异步事件;它不会响应 API 调用。由于它的名称不同,product-p1(也用作其 DNS 名称),所以它不会响应以http://product:8080开头的 URL 调用。
使用以下命令启动microservice landscape:
export COMPOSE_FILE=docker-compose-partitions.yml
docker-compose build && docker-compose up -d
重复前一部分的测试,但也要创建一个产品 ID 设置为2的产品。如果你查看 Spring Cloud Stream 设置的队列,你会看到每个分区有一个队列,并且产品审计队列现在每个都包含一个消息,即产品 ID 1的事件放在一个分区的,而产品 ID 2的事件放在另一个分区的。如果你回到浏览器中的http://localhost:15672/#/queues,你应该会看到类似以下的内容:

要结束使用分区的 RabbitMQ 测试,请使用以下命令关闭微服务架构:
docker-compose down
unset COMPOSE_FILE
我们现在完成了使用 RabbitMQ 的测试,包括有分区和没有分区的情况。我们将尝试的最后一种测试配置是同时测试微服务与 Kafka。
使用 Netflix Eureka 作为发现服务
发现服务可能是使一组合作的微服务生产就绪所需的最重要的支持功能。正如我们在第一章、微服务介绍中的服务发现部分已经描述的,服务发现服务可以用来跟踪现有的微服务和它们实例。Spring Cloud 支持的第一个发现服务是Netflix Eureka。
我们将在第九章、使用 Netflix Eureka 和 Ribbon 添加服务发现中使用这个,以及负载均衡器和新的 Spring Cloud 负载均衡器。
我们将看到在使用 Spring Cloud 时注册微服务有多么简单,以及当客户端发送 HTTP 请求(例如对注册在 Netflix Eureka 中的一个实例的 RESTful API 的调用)时会发生什么。我们还将了解如何扩展微服务的实例数量,以及如何将请求负载均衡到微服务的可用实例上(基于,默认情况下,轮询调度)。
以下屏幕快照展示了 Eureka 的网页用户界面,我们可以看到我们已经注册了哪些微服务:

评论服务有三个实例可用,而其他两个服务只有一个实例。
随着 Netflix Eureka 的引入,让我们介绍一下如何使用 Spring Cloud Gateway 作为边缘服务器。
使用每个主题两个分区的 Kafka
现在,我们将尝试 Spring Cloud Stream 的一个非常酷的功能:将消息系统从 RabbitMQ 更改为 Apache Kafka!
这可以通过将spring.cloud.stream.defaultBinder属性的值从rabbit更改为kafka来简单实现。这由docker-compose-kafka.ymlDocker Compose 文件处理,该文件也将 RabbitMQ 替换为 Kafka 和 Zookeeper。Kafka 和 Zookeeper 的配置如下所示:
kafka:
image: wurstmeister/kafka:2.12-2.1.0
mem_limit: 350m
ports:
- "9092:9092"
environment:
- KAFKA_ADVERTISED_HOST_NAME=kafka
- KAFKA_ADVERTISED_PORT=9092
- KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181
depends_on:
- zookeeper
zookeeper:
image: wurstmeister/zookeeper:3.4.6
mem_limit: 350m
ports:
- "2181:2181"
environment:
- KAFKA_ADVERTISED_HOST_NAME=zookeeper
Kafka 还配置为每个主题使用两个分区,像以前一样,我们为每个核心微服务启动两个实例,每个分区一个。详情请查看 Docker Compose 文件docker-compose-kafka.yml!
使用以下命令启动微服务架构:
export COMPOSE_FILE=docker-compose-kafka.yml
docker-compose build && docker-compose up -d
重复上一节的测试,例如,创建两个产品,一个产品 ID 设置为1,另一个产品 ID 设置为2。
不幸的是,Kafka 没有附带任何可以用来检查主题、分区以及其中的消息的图形工具。相反,我们可以在 Kafka Docker 容器中运行 CLI 命令。
要查看主题列表,请运行以下命令:
docker-compose exec kafka /opt/kafka/bin/kafka-topics.sh --zookeeper zookeeper --list
预期输出如下所示:

以下是对前面源代码的解释:
-
前缀为
error的主题是对应于死信队列的主题。 -
在 RabbitMQ 的情况下,你找不到
auditGroup;相反,所有消息都可供任何消费者处理。
要查看特定主题的分区,例如products主题,请运行以下命令:
docker-compose exec kafka /opt/kafka/bin/kafka-topics.sh --describe --zookeeper zookeeper --topic products
预期输出如下所示:

要查看特定主题的所有消息,例如products主题,请运行以下命令:
docker-compose exec kafka /opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic products --from-beginning --timeout-ms 1000
预期输出如下所示:

要查看特定分区的所有消息,例如products主题中的分区1,请运行以下命令:
docker-compose exec kafka /opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic products --from-beginning --timeout-ms 1000 --partition 1
预期输出如下所示:

输出将以超时异常结束,因为我们通过指定1000毫秒的命令超时来停止命令。
使用以下命令关闭微服务架构:
docker-compose down
unset COMPOSE_FILE
现在,我们已经了解到如何使用 Spring Cloud Stream 将消息代理从 RabbitMQ 切换到 Kafka,而无需更改源代码。它只需要在 Docker Compose 文件中进行一些更改。
反应式微服务架构的自动化测试
为了能够自动运行反应式微服务架构的测试,而不是手动运行,自动test-em-all.bash测试脚本已经得到增强。最重要的变化如下:
- 脚本使用新的
health端点来了解微服务架构何时运行正常,如下所示:
waitForService curl http://$HOST:$PORT/actuator/health
- 脚本有一个新的
waitForMessageProcessing()函数,它在测试数据设置后调用。它的目的是简单地等待异步创建服务完成测试数据的创建。
要使用测试脚本自动运行与 RabbitMQ 和 Kafka 相关的测试,请执行以下步骤:
- 使用默认的 Docker Compose 文件运行测试,即不使用 RabbitMQ 分区,使用以下命令:
unset COMPOSE_FILE
./test-em-all.bash start stop
- 使用以下命令运行带有两个分区的 RabbitMQ 测试:
export COMPOSE_FILE=docker-compose-partitions.yml
./test-em-all.bash start stop
unset COMPOSE_FILE
- 最后,使用以下命令运行带有 Kafka 和每个主题两个分区的测试:
export COMPOSE_FILE=docker-compose-kafka.yml
./test-em-all.bash start stop
unset COMPOSE_FILE
在本节中,我们学习了如何使用test-em-all.bash测试脚本自动运行使用 RabbitMQ 或 Kafka 作为消息代理配置的反应式微服务架构的测试。
总结
在本章中,我们看到了我们如何可以开发反应式微服务!
使用 Spring WebFlux 和 Spring WebClient,我们可以开发非阻塞同步 API,这些 API 可以处理传入的 HTTP 请求并发送非阻塞线程的出站 HTTP 请求。利用 Spring Data 对 MongoDB 的反应式支持,我们还可以以非阻塞方式访问 MongoDB 数据库,即在等待数据库响应时不会阻塞任何线程。Spring WebFlux、Spring WebClient 和 Spring Data 依赖于 Spring Reactor 提供它们的反应式和非阻塞特性。当我们必须使用阻塞代码时,例如在使用 Spring Data for JPA 时,我们可以通过在专用线程池中安排处理来封装阻塞代码的处理。
我们还看到了 Spring Data Stream 如何用于开发既适用于 RabbitMQ 又适用于 Kafka 作为消息系统的基于事件的异步服务,而无需更改代码。通过进行一些配置,我们可以使用 Spring Cloud Stream 中的特性,如消费者组、重试、死信队列和分区,以处理异步消息的各种挑战。
我们还学习了如何手动和自动测试由反应式微服务组成的系统架构。
这是关于如何在 Spring Boot 和 Spring Framework 中使用基本特性的最后一章。
接下来将介绍 Spring Cloud 以及如何使用它来使我们的服务达到生产级、可扩展、健壮、可配置、安全和有弹性!
问题
-
为什么知道如何开发反应式微服务很重要?
-
您如何选择非阻塞同步 API 和事件/消息驱动的异步服务?
-
消息与事件有什么不同?
-
列出一些消息驱动异步服务的挑战。我们如何处理它们?
-
为什么以下测试失败?
@Test
public void TestFlux() {
List<Integer> list = new ArrayList<>();
Flux.just(1, 2, 3, 4)
.filter(n -> n % 2 == 0)
.map(n -> n * 2)
.log();
assertThat(list).containsExactly(4, 8);
- 使用 JUnit 编写反应式代码的测试时面临哪些挑战,我们该如何应对?
第二部分:利用 Spring Cloud 管理微服务
在本节中,你将了解 Spring Cloud 如何用于管理在开发微服务时遇到的挑战(即构建分布式系统)。
本部分包括以下章节:
-
第八章,Spring Cloud 简介 链接
-
第九章,使用 Netflix Eureka 和 Ribbon 添加服务发现 链接
-
第十章,使用 Spring Cloud Gateway 在边缘服务器后面隐藏微服务 链接
-
第十一章,保护 API 访问安全 链接
-
第十二章,集中式配置 链接
-
第十三章,使用 Resilience4j 改善弹性 链接
-
第十四章,理解分布式追踪 链接
第八章:Spring Cloud 简介
迄今为止,我们已经了解了如何使用 Spring Boot 构建具有良好文档化 API 的微服务,以及 Spring WebFlux 和 SpringFox;使用 Spring Data for MongoDB 和 JPA 在 MongoDB 和 SQL 数据库中持久化数据;构建响应式微服务,无论是作为使用 Project Reactor 的非阻塞 API,还是作为使用 Spring Cloud Stream 与 RabbitMQ 或 Kafka 的事件驱动异步服务,以及 Docker;以及管理和测试由微服务、数据库和消息系统组成的系统架构。
现在,是时候看看我们如何使用Spring Cloud使我们的服务变得可生产、可扩展、健壮、可配置、安全且具有恢复能力。
在本章中,我们将向您介绍如何使用 Spring Cloud 实现以下设计模式,这些模式来自第一章的微服务介绍部分的微服务设计模式:
-
服务发现
-
边缘服务器
-
集中式配置
-
断路器
-
分布式跟踪
技术要求
本章不包含任何源代码,因此无需安装任何工具。
Spring Cloud 的发展
在 2015 年 3 月的最初 1.0 版本中,Spring Cloud 主要是围绕 Netflix OSS 工具的包装器,如下所示:
-
Netflix Eureka,一个发现服务器
-
Netflix Ribbon,一个客户端负载均衡器
-
Netflix Zuul,一个边缘服务器
-
Netflix Hystrix,一个断路器
Spring Cloud 的初始版本还包含了一个配置服务器和与 Spring Security 的集成,后者提供了 OAuth 2.0 受保护的 API。2016 年 5 月,Brixton 版本(V1.1)的 Spring Cloud 正式发布。随着 Brixton 版本的发布,Spring Cloud 获得了对基于 Spring Cloud Sleuth 和 Zipkin 的分布式跟踪的支持,这些起源于 Twitter。这些最初的 Spring Cloud 组件可以用来实现前面提到的设计模式。有关详细信息,请参阅spring.io/blog/2015/03/04/spring-cloud-1-0-0-available-now和spring.io/blog/2016/05/11/spring-cloud-brixton-release-is-available。
自成立以来,Spring Cloud 在几年内已经显著增长,并增加了对以下内容的支持, among others:
-
基于 HashiCorp Consul 和 Apache Zookeeper 的服务发现和集中配置
-
使用 Spring Cloud Stream 的事件驱动微服务
-
诸如 Microsoft Azure、Amazon Web Services 和 Google Cloud Platform 这样的云提供商
请参阅spring.io/projects/spring-cloud以获取完整的工具列表。
自 2019 年 1 月 Spring Cloud Greenwich(V2.1)发布以来,前面提到的 Netflix 工具中的一些已经在 Spring Cloud 中进入了维护模式。Spring Cloud 项目推荐以下替代品:
| 当前组件 | 被替换为 |
|---|---|
| Netflix Hystrix | Resilience4j |
| Netflix Hystrix Dashboard/Netflix Turbine | Micrometer 和监控系统 |
| Netflix Ribbon | Spring Cloud 负载均衡器 |
| Netflix Zuul | Spring Cloud Gateway |
有关更多详细信息,例如维护模式意味着什么,请参阅spring.io/blog/2019/01/23/spring-cloud-greenwich-release-is-now-available。
在这本书中,我们将使用替换选项来实现前面提到的设计模式。以下表格将每个设计模式映射到将要用来实现它们的软件组件:
| 设计模式 | 软件组件 |
|---|---|
| 服务发现 | Netflix Eureka 和 Spring Cloud 负载均衡器 |
| 边缘服务器 | Spring Cloud Gateway 和 Spring Security OAuth |
| 集中式配置 | Spring Cloud Configuration Server |
| 熔断器 | Resilience4j |
| 分布式追踪 | Spring Cloud Sleuth 和 Zipkin |
现在,让我们来回顾一下设计模式,并介绍将要用来实现它们的软件组件!
使用 Spring Cloud Gateway 作为边缘服务器
另一个非常重要的支持功能是边缘服务器。正如我们在第一章、微服务简介、边缘服务器部分已经描述过的,它可以用来保护微服务架构,即隐藏私有服务以防止外部使用,并在外部客户端使用公共服务时保护它们。
最初,Spring Cloud 使用 Netflix Zuul v1 作为其边缘服务器。自从 Spring Cloud Greenwich 版本以来,建议使用Spring Cloud Gateway代替。Spring Cloud Gateway 带有对关键功能的支持,例如基于 URL 路径的路由和通过使用 OAuth 2.0 和OpenID Connect(OIDC)保护端点。
Netflix Zuul v1 和 Spring Cloud Gateway 之间的一个重要区别是,Spring Cloud Gateway 基于非阻塞 API,使用 Spring 5、Project Reactor 和 Spring Boot 2,而 Netflix Zuul v1 基于阻塞 API。这意味着 Spring Cloud Gateway 应该能够处理比 Netflix Zuul v1 更多的并发请求,这对于所有外部流量都要经过的边缘服务器来说很重要。
以下图表显示了所有来自外部客户端的请求都通过 Spring Cloud Gateway 作为边缘服务器。基于 URL 路径,它将请求路由到预期的微服务:

在前面的图中,我们可以看到边缘服务器将发送具有以/product-composite/开始的 URL 路径的外部请求到产品组合微服务。核心服务产品、推荐和评论不能从外部客户端访问。
在第十章 使用 Spring Cloud Gateway 将微服务隐藏在边缘服务器后面 中,我们将查看如何为我们的微服务设置 Spring Cloud Gateway。
在第十一章 保护 API 访问安全 中,我们将了解如何使用 Spring Cloud Gateway 与 Spring Security OAuth2 一起保护边缘服务器的访问,通过 OAuth 2.0 和 OIDC 来实现。我们还将了解 Spring Cloud Gateway 如何在调用者身份信息(例如,调用者的用户名或电子邮件地址)下传播到我们的微服务中。
随着 Spring Cloud Gateway 的引入,让我们介绍一下如何使用 Spring Cloud Config 进行集中配置。
第九章:使用 Netflix Eureka 和 Ribbon 添加服务发现
在本章中,我们将学习如何使用 Netflix Eureka 作为基于 Spring Boot 的微服务的发现服务器。为了使我们的微服务能够与 Netflix Eureka 通信,我们将使用 Netflix Eureka 客户端的 Spring Cloud 模块。在深入细节之前,我们将详细介绍为什么需要发现服务器以及为什么 DNS 服务器是不够的。
本章将涵盖以下主题:
-
服务发现简介
-
DNS 基于的服务发现的问题
-
服务发现面临的挑战
-
使用 Netflix Eureka 在 Spring Cloud 中的服务发现
-
-
设置 Netflix Eureka 服务器
-
将微服务连接到 Netflix Eureka 服务器
-
为开发过程设置配置
-
尝试服务发现服务
介绍服务发现
在第一章微服务简介中描述了服务发现的概念;请参阅服务发现部分以获取更多信息。在第八章Spring Cloud 简介中介绍了 Netflix Eureka 作为发现服务;请参阅Netflix Eureka 作为发现服务部分以获取更多信息。在深入了解实现细节之前,我们将讨论以下主题:
-
DNS 基于的服务发现的问题
-
服务发现面临的挑战
-
使用 Netflix Eureka 在 Spring Cloud 中的服务发现
DNS 基于的服务发现的问题
那么问题是什么?
为什么我们不能简单地启动微服务的新实例,并依赖轮询 DNS 呢?本质上,由于微服务实例具有相同的 DNS 名称,DNS 服务器将解析为可用实例的 IP 地址列表。因此,客户端可以以轮询方式调用服务实例。
让我们试试看会发生什么,好吗?请按照以下步骤操作:
- 假设你已经按照第七章开发反应式微服务的说明操作,使用以下命令启动系统架构并向其中插入一些测试数据:
cd $BOOK_HOME/chapter07
./test-em-all.bash start
- 将
review微服务扩展到两个实例:
docker-compose up -d --scale review=2
- 询问复合产品服务为
review微服务找到的 IP 地址:
docker-compose exec product-composite getent hosts review
- 期待如下回答:
172.19.0.9 review
172.19.0.8 review
太好了,复合产品服务看到了两个 IP 地址——在我的情况下,172.19.0.8和172.19.0.9——分别为review微服务的每个实例!
- 如果你想验证这些确实是正确的 IP 地址,可以使用以下命令:
docker-compose exec --index=1 review cat /etc/hosts
docker-compose exec --index=2 review cat /etc/hosts
每个命令的输出最后一行应包含一个 IP 地址,如前所示。
- 现在,让我们尝试对复合产品服务进行几次调用,看看它是否使用了
review微服务的两个实例:
curl localhost:8080/product-composite/2 -s | jq -r .serviceAddresses.rev
不幸的是,我们只能从其中一个微服务实例获得响应,如这个例子所示:

那真是令人失望!
好吧,这里发生了什么事?
一个 DNS 客户端通常缓存已解析的 IP 地址,并在收到已为 DNS 名称解析的 IP 地址列表时,保留它尝试的第一个有效 IP 地址。DNS 服务器或 DNS 协议都不适合处理时而出现时而消失的微服务实例。因此,基于 DNS 的服务发现从实际角度来看并不很有吸引力。
使用 Spring Cloud Config 进行集中配置
为了管理微服务系统架构的配置,Spring Cloud 包含 Spring Cloud Config,它根据第一章中描述的要求,提供集中管理配置文件的功能,该章节为微服务介绍中的集中配置部分。
Spring Cloud Config 支持将配置文件存储在多种不同的后端中,例如以下后端:
-
一个 Git 仓库,例如,在 GitHub 或 Bitbucket 上
-
本地文件系统
-
HashiCorp Vault
-
一个 JDBC 数据库
Spring Cloud Config 允许我们以分层结构处理配置;例如,我们可以将配置的通用部分放在一个公共文件中,将微服务特定的设置放在单独的配置文件中。
Spring Cloud Config 还支持检测配置变化并将通知推送给受影响的微服务。它使用Spring Cloud Bus来传输通知。Spring Cloud Bus 是我们已经熟悉的 Spring Cloud Stream 的抽象;也就是说,它支持使用 RabbitMQ 或 Kafka 作为消息系统来传输通知。
以下图表说明了 Spring Cloud Config、其客户端、Git 仓库和 Spring Cloud Bus 之间的协作:

该图显示了以下内容:
-
当微服务启动时,它们会向配置服务器请求其配置。
-
配置服务器从这个例子中的 Git 仓库获取配置。
-
可选地,Git 仓库可以配置为在 Git 提交推送到 Git 仓库时向配置服务器发送通知。
-
配置服务器将使用 Spring Cloud Bus 发布变更事件。受到变更影响的微服务将做出反应,并从配置服务器获取其更新的配置。
最后,Spring Cloud Config 还支持对配置中的敏感信息进行加密,例如凭据。
我们将在第十二章中学习 Spring Cloud Config,集中配置。
随着 Spring Cloud Config 的引入,让我们了解一下如何使用 Resilience4j 提高韧性。
使用 Resilience4j 提高韧性
正如我们在第一章微服务介绍中已经提到的,在电路断路器部分,事情偶尔会出错。在一个相当大规模的微服务合作系统中,我们必须假设任何时候都在出现问题。失败必须被视为一种正常状态,系统景观必须设计成能够处理它!
最初,Spring Cloud 随 Netflix Hystrix 一起提供,这是一个经过验证的电路断路器。但是自从 Spring Cloud Greenwich 版本发布以来,建议将 Netflix Hystrix 替换为 Resilience4j。原因是 Netflix 最近将 Hystrix 置于维护模式。有关详细信息,请参阅github.com/Netflix/Hystrix#hystrix-status。
Resilience4j是一个基于开源的容错库。您可以在github.com/resilience4j/resilience4j了解更多信息。它内置了以下容错机制:
-
电路断路器用于防止远程服务停止响应时的故障连锁反应。
-
速率限制器用于在指定时间段内限制对服务的请求数量。
-
舱壁用于限制对服务的并发请求数量。
-
重试用于处理可能时不时发生的随机错误。
-
超时用于避免等待慢速或无响应服务的响应时间过长。
在第十三章使用 Resilience4j 提高韧性中,我们将重点关注 Resilience4j 中的电路断路器。它遵循以下状态图所示的经典电路断路器设计:

让我们更详细地查看状态图:
-
一个电路断路器开始时是关闭的,也就是说,允许请求被处理。
-
只要请求成功处理,它就保持在关闭状态。
-
如果开始出现故障,一个计数器开始递增。
-
如果达到配置的失败阈值,电路断路器将跳闸,也就是说,进入打开状态,不允许进一步处理请求。
-
相反,请求将快速失败,也就是说,立即返回异常。
-
在可配置的时间后,电路断路器将进入半开状态,并允许一个请求通过,如一个探针,以查看故障是否已解决。
-
如果探针请求失败,电路断路器回到打开状态。
-
如果探针请求成功,电路断路器回到初始关闭状态,也就是说,允许处理新请求。
Resilience4j 中电路断路器的示例用法
假设我们有一个通过 Resilience4j 实现的带有熔断器的 REST 服务,称为myService。
如果服务开始产生内部错误,例如,因为它无法访问它依赖的服务,我们可能会从服务中得到如500 Internal Server Error的响应。在经过一系列可配置的尝试后,电路将会打开,我们将得到一个快速失败,返回一个如CircuitBreaker 'myService' is open的错误消息。当错误解决后(在可配置的等待时间后)我们进行新的尝试,熔断器将允许作为探测器的新的尝试。如果调用成功,熔断器将再次关闭;也就是说,它正在正常运行。
当与 Spring Boot 一起使用 Resilience4j 时,我们能够通过 Spring Boot Actuator 的health端点监控微服务中的熔断器状态。例如,我们可以使用curl查看熔断器的状态,即myService:
curl $HOST:$PORT/actuator/health -s | jq .details.myServiceCircuitBreaker
如果它正常运行,即电路关闭,它会响应一些如下类似的内容:

如果出了问题且电路打开,它会响应一些如下类似的内容:

有了 Resilience4j 以及特别介绍的它的熔断器,我们看到了一个例子,说明熔断器可以如何用于处理 REST 客户端的错误。让我们了解一下如何使用 Spring Cloud Sleuth 和 Zipkin 进行分布式追踪。
使用 Spring Cloud Sleuth 和 Zipkin 进行分布式追踪。
要理解分布式系统(如合作微服务的系统景观)中发生了什么,能够追踪和可视化处理系统景观的外部调用时请求和消息在微服务之间的流动至关重要。
参阅第一章,微服务简介,分布式追踪部分,了解有关这个主题的更多信息。
Spring Cloud 自带Spring Cloud Sleuth,它可以标记属于同一处理流程的请求和消息/事件,使用共同的相关 ID。
Spring Cloud Sleuth 还可以用相关 ID 装饰日志消息,以便更容易追踪来自相同处理流程的不同微服务日志消息.Zipkin是一个分布式追踪系统(zipkin.io),Spring Cloud Sleuth 可以将追踪数据发送到该系统进行存储和可视化。
Spring Cloud Sleuth 和 Zipkin 处理分布式追踪信息的基础架构基于 Google Dapper(ai.google/research/pubs/pub36356). 在 Dapper 中,来自完整工作流的追踪信息称为追踪树,树的部分,如工作基本单元,称为跨度。 跨度可以进一步由子跨度组成,形成追踪树。 一个关联 ID 称为TraceId,跨度由其唯一的SpanId以及它所属的追踪树的TraceId来标识。
Spring Cloud Sleuth 可以通过 HTTP 同步或使用 RabbitMQ 或 Kafka 异步发送请求到 Zipkin。 为了避免从我们的微服务中创建对 Zipkin 服务器的运行时依赖,我们更倾向于异步使用 RabbitMQ 或 Kafka 将追踪信息发送到 Zipkin。 这如下面的图表所示:

在第十四章中,理解分布式追踪,我们将了解如何使用 Spring Cloud Sleuth 和 Zipkin 来追踪我们微服务架构中进行的处理。以下是来自 Zipkin UI 的屏幕截图,它可视化了创建聚合产品处理结果所生成的追踪树:

一个 HTTP POST请求发送到产品组合服务,并通过发布创建事件到产品、推荐和评论的主题来响应。 这些事件被三个核心微服务并行消费,并且创建事件中的数据存储在每个微服务的数据库中。
随着 Spring Cloud Sleuth 和 Zipkin 分布式追踪的引入,我们看到了一个例子,该例子追踪了一个外部同步 HTTP 请求的处理,包括涉及微服务之间异步传递事件的分布式追踪。
总结
在本章中,我们看到了 Spring Cloud 如何从较为 Netflix OSS 中心演变成今天的范围更广。 我们还介绍了如何使用 Spring Cloud Greenwich 的最新版本来实现我们微服务介绍章节中描述的设计模式,在微服务设计模式部分。 这些设计模式是使一组合作的微服务准备好生产环境的必要条件。
翻到下一章,了解我们如何使用 Netflix Eureka 和 Spring Cloud 负载均衡器实现服务发现!
问题
-
Netflix Eureka 的目的是什么?
-
Spring Cloud Gateway 的主要特性是什么?
-
Spring Cloud Config 支持哪些后端?
-
Resilience4j 提供了哪些功能?
-
分布式跟踪中 trace tree 和 span 的概念是什么,定义它们的论文叫什么?
技术要求
本书中描述的所有命令都是在 MacBook Pro 上使用 macOS Mojave 运行的,但是修改起来应该很容易,使其可以在其他平台上运行,例如 Linux 或 Windows。
在本章中不需要安装任何新工具。
本章的源代码可以在 GitHub 上找到:github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud/tree/master/Chapter09。
为了能够运行本书中描述的命令,将源代码下载到文件夹中,并设置一个环境变量$BOOK_HOME,使其指向该文件夹。一些示例命令如下:
export BOOK_HOME=~/Documents/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud
git clone https://github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud $BOOK_HOME
cd $BOOK_HOME/Chapter09
本章的 Java 源代码是为 Java 8 编写的,并在 Java 12 上进行了测试。本章使用 Spring Cloud 2.1.0(也称为Greenwich版本),Spring Boot 2.1.3 和 Spring 5.1.5,即在本章撰写时可用的 Spring 组件的最新版本。
源代码包含了以下 Gradle 项目:
-
api -
util -
microservices/product-service -
microservices/review-service -
microservices/recommendation-service -
microservices/product-composite-service -
spring-cloud/eureka-server
本章中的代码示例都来自$BOOK_HOME/Chapter09目录中的源代码,但在多个地方进行了编辑,以删除源代码中不相关的内容,例如注释和导入日志语句。
如果你想查看在第九章中应用于源代码的更改,使用 Netflix Eureka 和 Ribbon 添加服务发现,以了解向微服务架构添加 Netflix Eureka 作为发现服务所需的内容,你可以将其与第七章的源代码进行比较,开发反应式微服务。你可以使用你喜欢的diff工具,分别比较两个文件夹,$BOOK_HOME/Chapter07和$BOOK_HOME/Chapter09。
服务发现的问题
因此,我们需要比普通的 DNS 更强大的东西来跟踪可用的微服务实例!
当我们跟踪许多小的移动部件,即微服务实例时,我们必须考虑以下几点:
-
新的实例可以在任何时间点启动。
-
现有的实例在任何时间点都可能停止响应并最终崩溃。
-
一些失败的实例可能过一会儿就没事了,应该重新开始接收流量,而其他的则不应该,应该从服务注册表中删除。
-
一些微服务实例可能需要一些时间来启动;也就是说,仅仅因为它们能够接收 HTTP 请求,并不意味着应该将流量路由到它们那里。
-
无意中的网络分区和其他网络相关错误可能会随时发生。
构建一个健壮和有弹性的发现服务器绝非易事。让我们看看我们如何可以使用 Netflix Eureka 来应对这些挑战!
使用 Spring Cloud 中的 Netflix Eureka 进行服务发现
Netflix Eureka 实现了客户端服务发现,这意味着客户端运行与发现服务(Netflix Eureka)通信的软件,以获取有关可用微服务实例的信息。以下图表说明了这一点:

流程如下:
-
每当一个微服务实例启动时—例如,Review服务—它会将自己注册到其中一个 Eureka 服务器上。
-
每个微服务实例定期向 Eureka 服务器发送心跳消息,告诉它该微服务实例是正常的,并准备好接收请求。
-
客户端—例如,Product Composite服务—使用一个客户端库,该库定期向 Eureka 服务询问有关可用服务的信息。
-
当客户端需要向另一个微服务发送请求时,它已经在客户端库中有一个可用实例的列表,可以选择其中的一个,而不需要询问发现服务器。通常,可用实例是按照轮询方式选择的;也就是说,它们是依次调用,然后再重新调用第一个。
在第十七章中,作为替代的 Kubernetes 特性实现,我们将探讨一种替代方法,使用 Kubernetes 中的服务器端服务概念来提供发现服务。
Spring Cloud 包含如何与发现服务(如 Netflix Eureka)通信的抽象,并提供了一个名为DiscoveryClient的接口。这可以用来与发现服务进行交互,获取有关可用服务和实例的信息。DiscoveryClient接口的实现也能够在启动时自动将 Spring Boot 应用程序注册到发现服务器上。
Spring Boot 可以在启动过程中自动找到DiscoveryClient接口的实现,因此我们只需要引入对应实现的依赖项即可连接到发现服务器。在 Netflix Eureka 的情况下,我们微服务所使用的依赖是spring-cloud-starter-netflix-eureka-client。
Spring Cloud 还有支持使用 Apache Zookeeper 或 HashiCorp Consul 作为发现服务器的DiscoveryClient实现。
Spring Cloud 还提供了一个抽象——LoadBalancerClient接口——对于希望通过负载均衡器向发现服务中的注册实例发起请求的客户端。标准反应式 HTTP 客户端WebClient可以配置为使用LoadBalancerClient实现。通过在返回WebClient.Builder对象的@Bean声明上添加@LoadBalanced注解,LoadBalancerClient实现将被注入到Builder实例中作为ExchangeFilterFunction。由于在类路径上有spring-cloud-starter-netflix-eureka-client依赖项,RibbonLoadBalancerClient将自动注入,即基于 Netflix Ribbon 的负载均衡器。所以,即使 Netflix Ribbon 已进入维护模式,如在第八章Spring Cloud 介绍中描述,它仍然在幕后使用。在本章后面的将微服务连接到 Netflix Eureka 服务器部分,我们将查看一些源代码示例,了解如何使用它。
总之,Spring Cloud 让使用 Netflix Eureka 作为发现服务变得非常简单。通过介绍服务发现及其挑战以及 Netflix Eureka 如何与 Spring Cloud 一起使用,我们准备好学习如何设置一个 Netflix Eureka 服务器。
尝试使用发现服务
所有细节就绪后,我们就可以尝试服务了:
- 首先,使用以下命令构建 Docker 镜像:
cd $BOOK_HOME/Chapter09
./gradlew build && docker-compose build
- 接下来,使用以下命令启动系统架构并执行常规测试:
./test-em-all.bash start
预期输出与我们在前面的章节中看到的内容类似:

系统架构运行起来后,我们可以开始测试如何扩展其中一个微服务实例的数量。
设置 Netflix Eureka 服务器
在本节中,我们将学习如何为服务发现设置一个 Netflix Eureka 服务器。使用 Spring Cloud 设置 Netflix Eureka 服务器真的很容易——只需按照以下步骤操作:
-
使用 Spring Initializr 创建一个 Spring Boot 项目,具体操作见第三章创建一组协作的微服务中的使用 Spring Initializr 生成骨架代码部分。
-
添加
spring-cloud-starter-netflix-eureka-server依赖项。 -
在应用程序类上添加
@EnableEurekaServer注解。 -
添加一个 Dockerfile,与用于我们的微服务的 Dockerfile 类似,不同之处在于我们导出 Eureka 默认端口
8761,而不是我们微服务默认端口8080。 -
把我们三个 Docker Compose 文件中添加 Eureka 服务器,即
docker-compose.yml、docker-compose-partitions.yml和docker-compose-kafka.yml:
eureka:
build: spring-cloud/eureka-server
mem_limit: 350m
ports:
- "8761:8761"
- 最后,请转到本章的设置开发过程中使用的配置部分,我们将介绍 Eureka 服务器和我们的微服务的配置。
这就完成了!
您可以在$BOOK_HOME/Chapter09/spring-cloud/eureka-server文件夹中找到 Eureka 服务器的源代码。
了解如何为服务发现设置一个 Netflix Eureka 服务器后,我们准备学习如何将微服务连接到 Netflix Eureka 服务器。
将微服务连接到 Netflix Eureka 服务器
在本节中,我们将学习如何将微服务实例连接到 Netflix Eureka 服务器。我们将了解微服务实例在启动时如何向 Eureka 服务器注册自己,以及客户端如何使用 Eureka 服务器找到它想要调用的微服务实例。
为了能够在 Eureka 服务器中注册一个微服务实例,我们需要执行以下操作:
- 在构建文件
build.gradle中添加spring-cloud-starter-netflix-eureka-client依赖项:
implementation('org.springframework.cloud:spring-cloud-starter-netflix-eureka-client')
- 当在单个微服务上运行测试时,我们不希望依赖于 Eureka 服务器的运行。因此,我们将禁用所有 Spring Boot 测试中使用 Netflix Eureka,即使用
@SpringBootTest注解的 JUnit 测试。这可以通过在注解中添加eureka.client.enabled属性并将其设置为false来实现,如下所示:
@SpringBootTest(webEnvironment=RANDOM_PORT, properties = {"eureka.client.enabled=false"})
- 最后,请转到设置开发过程中使用的配置部分,我们将介绍 Eureka 服务器和我们的微服务的配置。
然而,在配置中有一个非常重要的属性:spring.application.name。它用于给每个微服务一个虚拟主机名,即 Eureka 服务用来识别每个微服务的名称。Eureka 客户端将在用于向微服务发起 HTTP 调用的 URL 中使用这个虚拟主机名,正如我们接下来所看到的。
为了能够在product-composite微服务中通过 Eureka 服务器查找可用的微服务实例,我们还需要执行以下操作:
- 在应用程序类中,即
se.magnus.microservices.composite.product.ProductCompositeServiceApplication,添加一个负载均衡意识WebClient构建器,如前所述:
@Bean
@LoadBalanced
public WebClient.Builder loadBalancedWebClientBuilder() {
final WebClient.Builder builder = WebClient.builder();
return builder;
}
- 更新在集成类
se.magnus.microservices.composite.product.services.ProductCompositeIntegration中WebClient对象的创建方式。如前所述,@LoadBalanced注解会导致 Spring 向WebClient.Builderbean 中注入一个负载均衡器感知过滤器。不幸的是,在集成类的构造函数运行之后才执行这个操作。这意味着我们必须将webClient的构造从构造函数中移开,就像在第七章,开发响应式微服务中做的那样,移到一个单独的 getter 方法,该方法延迟创建webClient,即在第一次使用时创建。以下代码显示了这一点:
private WebClient getWebClient() {
if (webClient == null) {
webClient = webClientBuilder.build();
}
return webClient;
}
- 每当使用
WebClient创建一个出站 HTTP 请求时,它是通过getWebClient()getter 方法访问的(而不是直接使用webClient字段)。以下示例说明了这一点:
@Override
public Mono<Product> getProduct(int productId) {
String url = productServiceUrl + "/product/" + productId;
return getWebClient().get().uri(url).retrieve()
.bodyToMono(Product.class).log()
.onErrorMap(WebClientResponseException.class, ex -> handleException(ex));
}
- 现在我们可以摆脱在
application.yml中硬编码的可用微服务配置。例如,考虑以下代码:
app:
product-service:
host: localhost
port: 7001
recommendation-service:
host: localhost
port: 7002
review-service:
host: localhost
port: 7003
处理硬编码配置的集成类中相应的代码被替换为声明核心微服务 API 的基本 URL。以下代码显示了这一点:
private final String productServiceUrl = "http://product";
private final String recommendationServiceUrl = "http://recommendation";
private final String reviewServiceUrl = "http://review";
前述 URL 中的主机名不是实际的 DNS 名称。相反,它们是微服务在向 Eureka 服务器注册时使用的虚拟主机名,即spring.application.name属性的值。
知道如何将微服务实例连接到 Netflix Eureka 服务器后,我们可以继续学习如何配置 Eureka 服务器以及需要连接到 Eureka 服务器的微服务实例。
为开发过程设置配置
现在,是设置 Netflix Eureka 作为发现服务最棘手的部分的时候了,也就是说,为 Eureka 服务器及其客户端(即我们的微服务实例)设置一个工作配置。
Netflix Eureka 是一个高度可配置的发现服务器,可以设置为多种不同的使用场景,并提供健壮、弹性、容错性强的运行时特性。这种灵活性和健壮性的一个缺点是,它有令人望而生畏的大量配置选项。幸运的是,Netflix Eureka 为大多数可配置参数提供了良好的默认值——至少在使用它们的生产环境来说是这样。
当在开发过程中使用 Netflix Eureka 时,默认值会导致长时间启动。例如,客户端首次成功调用注册在 Eureka 服务器中的微服务实例可能需要很长时间。
使用默认配置值时,可能会经历长达两分钟的等待时间。这种等待时间是在 Eureka 服务及其微服务启动所需的时间之上加上的。这段等待时间的原因是涉及到的进程需要彼此同步注册信息。
微服务实例需要向 Eureka 服务器注册,客户端需要从 Eureka 服务器获取信息。这种通信主要基于心跳,默认每 30 秒发生一次。还有几个缓存也涉及其中,这减缓了更新的传播。
我们将使用一种减少等待时间的配置,这在开发时很有用。对于生产环境,应该以默认值作为起点!
我们只使用一个 Netflix Eureka 服务器实例,这在开发环境中是可以的。在生产环境中,为了确保 Netflix Eureka 服务器的高可用性,你应该始终使用两个或更多的实例。
让我们开始了解我们需要知道哪些类型的配置参数。
Eureka 配置参数
对于 Eureka 的配置参数分为三组:
-
有用于 Eureka 服务器的参数,前缀为
eureka.server。 -
有用于 Eureka 客户端的参数,前缀为
eureka.client。这是用于与 Eureka 服务器通信的客户端。 -
有用于 Eureka 实例的参数,前缀为
eureka.instance。这是用于希望在 Eureka 服务器上注册自己的微服务实例。
一些可用的参数在 Spring Cloud 文档中有描述:服务发现:Eureka 服务器:cloud.spring.io/spring-cloud-static/Greenwich.RELEASE/single/spring-cloud.html#spring-cloud-eureka-server 服务发现:Eureka 客户端:cloud.spring.io/spring-cloud-static/Greenwich.RELEASE/single/spring-cloud.html#_service_discovery_eureka_clients
要获取可用参数的详细列表,我建议阅读源代码:
-
对于 Eureka 服务器参数,你可以查看
org.springframework.cloud.netflix.eureka.server.EurekaServerConfigBean类以获取默认值和com.netflix.eureka.EurekaServerConfig接口的相关文档。 -
对于 Eureka 客户端参数,你可以查看
org.springframework.cloud.netflix.eureka.EurekaClientConfigBean类以获取默认值和文档。 -
对于 Eureka 实例参数,你可以查看
org.springframework.cloud.netflix.eureka.EurekaInstanceConfigBean类以获取默认值和文档。
让我们开始了解 Eureka 服务器的配置参数。
配置 Eureka 服务器
为了在开发环境中配置 Eureka 服务器,可以使用以下配置:
server:
port: 8761
eureka:
instance:
hostname: localhost
client:
registerWithEureka: false
fetchRegistry: false
serviceUrl:
defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/
server:
waitTimeInMsWhenSyncEmpty: 0
response-cache-update-interval-ms: 5000
Eureka 服务器的配置第一部分,对于一个instance(实例)和client(客户端)是一个独立 Eureka 服务器的标准配置。详细信息,请参阅我们之前引用的 Spring Cloud 文档。用于 Eurekaserver(服务器)的最后两个参数waitTimeInMsWhenSyncEmpty和response-cache-update-interval-ms用于最小化启动时间。
配置了 Eureka 服务器之后,我们准备看看如何配置 Eureka 服务器的客户端,即微服务实例。
配置 Eureka 服务器的客户端
为了能够连接到 Eureka 服务器,微服务具有以下配置:
eureka:
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka/
initialInstanceInfoReplicationIntervalSeconds: 5
registryFetchIntervalSeconds: 5
instance:
leaseRenewalIntervalInSeconds: 5
leaseExpirationDurationInSeconds: 5
---
spring.profiles: docker
eureka.client.serviceUrl.defaultZone: http://eureka:8761/eureka/
eureka.client.serviceUrl.defaultZone参数用于查找 Eureka 服务器,而其他参数用于最小化启动时间和停止微服务实例的时间。
使用 Eureka 服务器查找其他微服务的product-composite微服务也有两个 Netflix Ribbon 特定参数:
ribbon.ServerListRefreshInterval: 5000
ribbon.NFLoadBalancerPingInterval: 5
这两个参数也用于最小化启动时间。
现在,我们已经有了在 Netflix Eureka 服务器和我们的微服务中实际尝试发现服务所需的一切。
扩展
现在,我们可以通过启动两个额外的review微服务实例来尝试发现服务:
docker-compose up -d --scale review=3
使用前面的命令,我们要求 Docker Compose 运行review服务的三个实例。由于一个实例已经在运行,将启动两个新实例。
一旦新实例启动并运行,浏览到http://localhost:8761/,预期如下所示:

在运行此 localhost 之后,验证您是否可以在 Netflix Eureka web UI 中看到三个review实例,如前截图所示。
知道新实例何时启动并运行的一种方法是运行docker-compose logs -f review命令,并查找如下所示的输出:

我们还可以使用 Eureka 服务暴露的 REST API。为了获取实例 ID 列表,我们可以发出如下curl命令:
curl -H "accept:application/json" localhost:8761/eureka/apps -s | jq -r .applications.application[].instance[].instanceId
期待类似于以下内容的响应:

现在我们已经让所有实例运行起来,尝试通过发送一些请求并关注review服务在响应中的地址,如下所示:
curl localhost:8080/product-composite/2 -s | jq -r .serviceAddresses.rev
期待类似于以下的响应:

注意review服务的地址在每次响应中都会改变;也就是说,负载均衡器使用轮询依次调用可用的review实例,一个接一个!
我们还可以使用以下命令查看review实例的日志:
docker-compose logs -f review
之后,你将看到类似于以下内容的输出:

在前面的输出中,我们可以看到三个review微服务实例review_1、review_2和review_3如何依次响应请求。
在尝试扩展现有的微服务实例之后,我们将尝试缩减这些实例。
缩放向下
让我们也看看如果我们失去了一个review微服务的实例会发生什么。我们可以通过运行以下命令来模拟这个实例意外停止:
docker-compose up -d --scale review=2
在review实例关闭后,有一个短暂的时间段,API 调用可能会失败。这是由于信息传播到客户端(即product-composite服务)所需的时间,也就是失去实例的时间。在这段时间内,客户端负载均衡器可能会选择不再存在的实例。为了防止这种情况发生,可以使用诸如超时和重试等弹性机制。在第十三章,使用 Resilience4j 改进弹性,我们将看到如何应用这些机制。现在,让我们在我们的curl命令上指定一个超时,使用-m 2开关来指定我们不会等待超过两秒钟的响应:
curl localhost:8080/product-composite/2 -m 2
如果发生超时,即客户端负载均衡器尝试调用一个不再存在的实例,curl应返回以下响应:
curl: (28) Operation timed out after 2003 milliseconds with 0 bytes received
除了预期两个剩余实例的正常响应;也就是说,serviceAddresses.rev字段应包含两个实例的地址,如下所示:

在前面的示例输出中,我们可以看到报告了两个不同的容器名称和 IP 地址。这意味着请求已经被不同的微服务实例处理。
在尝试微服务实例的缩放向下之后,我们可以尝试更具破坏性的事情:停止 Eureka 服务器,看看当发现服务暂时不可用时会发生什么。
带 Eureka 服务器的破坏性测试
让我们给我们的 Eureka 服务器带来一些混乱,看看系统景观如何处理它!
首先,如果我们使 Eureka 服务器崩溃会怎样?
只要客户端在 Eureka 服务器停止之前从服务器读取了有关可用微服务实例的信息,客户端就会没问题,因为它们会在本地缓存这些信息。但是,新的实例不会提供给客户端,并且如果任何正在运行的实例被终止,它们也不会收到通知。因此,调用不再运行的实例将导致失败。
让我们试试看!
停止 Eureka 服务器
要模拟 Eureka 服务器的崩溃,请按照以下步骤操作:
- 首先,停止 Eureka 服务器,同时保持两个
review实例运行:
docker-compose up -d --scale review=2 --scale eureka=0
- 尝试对 API 进行几次调用并提取
review服务的服务地址:
curl localhost:8080/product-composite/2 -s | jq -r .serviceAddresses.rev
- 响应将—就像我们停止 Eureka 服务器之前一样—包含两个
review实例的地址,如下所示:

这表明客户端甚至可以在 Eureka 服务器不再运行时对现有实例进行调用!
停止一个review实例
为了进一步调查停止运行的 Eureka 服务器的影响,让我们模拟剩下的一个review微服务实例也崩溃。使用以下命令终止两个review实例中的一个:
docker-compose up -d --scale review=1 --scale eureka=0
客户端,即product-composite服务,由于没有运行 Eureka 服务器,不会通知其中一个review实例已经消失。因此,它仍然认为有两个实例正在运行。每两次对客户端的调用会导致它调用一个不再存在的review实例,导致客户端的响应不包含任何来自review服务的信息。review服务的服务地址将变为空。
尝试使用前面的curl命令验证review服务的服务地址将会在第二次变为空。这可以通过使用之前描述的时间 outs 和 retries 等弹性机制来防止。
启动产品服务的额外实例
作为对停止运行的 Eureka 服务器效果的最终测试,如果我们启动product微服务的新实例,会发生什么情况呢?执行以下步骤:
- 尝试启动
product服务的新的实例:
docker-compose up -d --scale review=1 --scale eureka=0 --scale product=2
- 对 API 进行几次调用并使用以下命令提取
product服务的地址:
curl localhost:8080/product-composite/2 -s | jq -r .serviceAddresses.pro
- 由于没有运行 Eureka 服务器,客户端不会通知新的
product实例,所以所有的调用都会发送到第一个实例,如下例所示:

现在我们已经看到了在没有运行 Netflix Eureka 服务器时的一些最重要的方面。让我们通过再次启动 Netflix Eureka 服务器来结束本节的干扰性测试,看看系统景观如何处理自我修复,即弹性。
重新启动 Eureka 服务器
在本节中,我们将通过重新启动 Eureka 服务器来结束干扰性测试。我们还应验证系统景观是否自我修复,即验证新的product微服务实例是否被 Netflix Eureka 服务器注册,并且客户端是否被 Eureka 服务器更新。执行以下步骤:
- 使用以下命令启动 Eureka 服务器:
docker-compose up -d --scale review=1 --scale eureka=1 --scale product=2
进行一些新的 API 调用,并验证以下情况是否发生:
-
所有调用都发送到剩余的
review实例,即客户端检测到第二个review实例已经消失。 -
对
product服务的调用在两个product实例之间进行负载均衡,也就是说,客户端检测到有这两个product实例可用。
- 多次调用以下调用以提取
product和review服务的地址:
curl localhost:8080/product-composite/2 -s | jq -r .serviceAddresses
- 确认 API 调用响应包含涉及
product和review实例的地址,如下所示:

这是第二个响应:

192.168.128.3 和 192.168.128.7 IP 地址属于两个 product 实例。192.168.128.9 是 review 实例的 IP 地址。
总结来说,Eureka 服务器提供了一个非常健壮和灵活的发现服务实现。如果需要更高的可用性,可以启动并配置多个 Eureka 服务器以相互通信。在 Spring Cloud 文档中可以找到有关如何设置多个 Eureka 服务器的详细信息:cloud.spring.io/spring-cloud-static/Greenwich.RELEASE/single/spring-cloud.html#spring-cloud-eureka-server-peer-awareness。
- 最后,使用以下命令关闭系统景观:
docker-compose down
这完成了对发现服务器 Netflix Eureka 的测试,我们既学习了如何扩展和缩小微服务实例,也学习了 Netflix Eureka 服务器崩溃后重新上线会发生什么。
总结
在本章中,我们学习了如何使用 Netflix Eureka 进行服务发现。首先,我们探讨了简单基于 DNS 的服务发现解决方案的不足之处,以及健壮和灵活的服务发现解决方案必须能够处理的问题。
Netflix Eureka 是一个功能强大的服务发现解决方案,提供了健壮、灵活和容错性运行时特性。然而,正确配置可能会具有一定挑战性,尤其是为了提供平滑的开发体验。使用 Spring Cloud,设置 Netflix Eureka 服务器和适配基于 Spring Boot 的微服务变得容易,这样它们可以在启动时注册到 Eureka,并且在作为其他微服务客户端时,可以跟踪可用的微服务实例。
有了发现服务之后,是时候看看我们如何使用 Spring Cloud Gateway 作为边缘服务器来处理外部流量了。翻到下一章,找出答案吧!
问题
-
要将使用 Spring Initializr 创建的 Spring Boot 应用程序转换为完全功能的 Netflix Eureka 服务器需要什么?
-
要让基于 Spring Boot 的微服务自动作为启动项注册到 Netflix Eureka 需要什么?
-
要让一个基于 Spring Boot 的微服务调用注册在 Netflix Eureka 服务器上的另一个微服务需要什么?
-
假设你有一个正在运行的网飞 Eureka 服务器,以及一个微服务A的实例和两个微服务B的实例。所有微服务实例都会向网飞 Eureka 服务器注册。微服务A根据从 Eureka 服务器获取的信息对微服务B发起 HTTP 请求。那么,如果依次发生以下情况:
-
网飞 Eureka 服务器崩溃了
-
微服务B的一个实例崩溃了
-
微服务A的一个新实例启动了
-
微服务B的一个新实例启动了
-
网飞 Eureka 服务器再次启动了
-
第十章:使用 Spring Cloud Gateway 将微服务隐藏在边缘服务器后面
在本章中,我们将学习如何使用 Spring Cloud Gateway 作为边缘服务器,即控制从我们的基于微服务的系统架构中暴露哪些 API。我们将了解具有公共 API 的微服务将通过边缘服务器从外部访问,而只有私有 API 的微服务只能从微服务架构的内部访问。在我们的系统架构中,这意味着产品组合服务和企业级 Eureka 服务将通过边缘服务器暴露。三个核心服务—product、recommendation和review—将对外隐藏。
本章将涵盖以下主题:
-
将边缘服务器添加到我们的系统架构中
-
设置一个 Spring Cloud Gateway,包括配置路由规则
-
尝试边缘服务器
技术要求
本书中描述的所有命令都是在 MacBook Pro 上使用 macOS Mojave 运行的,但应该很容易修改以在其他平台如 Linux 或 Windows 上运行。
在本章中不需要安装任何新工具。
本章的源代码可以在 GitHub 上找到:github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud/tree/master/Chapter10。
为了能够按照本书描述运行命令,将源代码下载到一个文件夹中,并设置一个环境变量$BOOK_HOME,该变量指向该文件夹。以下是一些示例命令:
export BOOK_HOME=~/Documents/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud
git clone https://github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud $BOOK_HOME
cd $BOOK_HOME/Chapter10
Java 源代码是为 Java 8 编写的,并在 Java 12 上进行了测试。本章使用 Spring Cloud 2.1.0, SR1(也称为Greenwich版本),Spring Boot 2.1.3 和 Spring 5.1.5,这些是编写本章时可用的 Spring 组件的最新版本。
源代码包含以下 Gradle 项目:
-
api -
util -
microservices/product-service -
microservices/review-service -
microservices/recommendation-service -
microservices/product-composite-service -
spring-cloud/eureka-server -
spring-cloud/gateway
本章中的所有代码示例都来自$BOOK_HOME/Chapter10的源代码,但在许多情况下,为了删除源代码中不相关部分,例如注释、导入和日志声明,都对代码进行了编辑。
如果你想查看第十章中应用于源代码的更改,使用 Spring Cloud Gateway 将微服务隐藏在边缘服务器后面,也就是说,看看添加 Spring Cloud Gateway 作为边缘服务器到微服务架构中需要做些什么,你可以将其与第九章的源代码使用 Netflix Eureka 和 Ribbon 添加服务发现进行比较。你可以使用你喜欢的diff工具,比较两个文件夹$BOOK_HOME/Chapter09和$BOOK_HOME/Chapter10。
向我们的系统架构中添加边缘服务器
在本节中,我们将了解如何将边缘服务器添加到系统架构中以及它如何影响外部客户端访问微服务公开 API 的方式。所有传入请求现在都将通过边缘服务器路由,如下面的 diagram 所示:

从前面 diagram 可以看出,外部客户端将其所有请求发送到边缘服务器。边缘服务器可以根据 URL 路径来路由传入的请求。例如,以/product-composite/开头的 URL 的请求会被路由到产品组合微服务,以/eureka/开头的 URL 的请求会基于 Netflix Eureka 被路由到发现服务器。
在前面的第九章中,使用 Netflix Eureka 和 Ribbon 添加服务发现,我们向外部暴露了product-composite服务和发现服务,Netflix Eureka。当我们在本章中引入边缘服务器时,这将不再适用。这是通过删除两个服务在 Docker Compose 文件中的以下端口声明来实现的:
product-composite:
build: microservices/product-composite-service
ports:
- "8080:8080"
eureka:
build: spring-cloud/eureka-server
ports:
- "8761:8761"
在系统中添加了边缘服务器后,我们将在下一节学习如何基于 Spring Cloud Gateway 设置边缘服务器。
设置 Spring Cloud Gateway
在本节中,我们将学习如何基于 Spring Cloud Gateway 设置边缘服务器。
设置 Spring Cloud Gateway 作为边缘服务器是直接的,可以通过以下步骤完成:
-
使用在第三章中描述的 Spring Initializr 创建 Spring Boot 项目,创建一组协作的微服务—参考使用 Spring Initializr 生成骨架代码部分。
-
添加
spring-cloud-starter-gateway依赖。 -
为了能够通过 Netflix Eureka 定位微服务实例,也请添加
spring-cloud-starter-netflix-eureka-client依赖。 -
在通用构建文件
settings.gradle中添加边缘服务器:
include ':spring-cloud:gateway'
-
在 Dockerfile 中添加与我们的微服务相同内容。
-
在我们的三个 Docker Compose 文件中添加边缘服务器:
gateway:
environment:
- SPRING_PROFILES_ACTIVE=docker
build: spring-cloud/gateway
mem_limit: 350m
ports:
- "8080:8080"
边缘服务器的8080端口暴露在 Docker 引擎外部。350 MB 的内存限制是为了确保本章及接下来的章节中的所有容器都能容纳在我们为 Docker 引擎分配的 6 GB 内存中。
-
添加路由规则配置等;参考本章后续的配置 Spring Cloud Gateway部分。
-
由于边缘服务器将处理所有传入流量,我们将把复合健康检查从产品复合服务移动到边缘服务器。这在添加复合健康检查部分有所描述。
你可以找到 Spring Cloud Gateway 的源代码在$BOOK_HOME/Chapter10/spring-cloud/gateway。
添加复合健康检查
有了边缘服务器后,外部的健康检查请求也必须通过边缘服务器。因此,检查所有微服务状态的复合健康检查从product-composite服务移动到了边缘服务器。参考第七章开发反应式微服务,参考添加健康 API部分实现复合健康检查的细节。
以下内容已添加到边缘服务器:
- 新增了
se.magnus.springcloud.gateway.HealthCheckConfiguration类,该类声明了一个健康指标:
@Bean
ReactiveHealthIndicator healthcheckMicroservices() {
ReactiveHealthIndicatorRegistry registry =
new DefaultReactiveHealthIndicatorRegistry
(new LinkedHashMap<>());
registry.register("product",() ->
getHealth("http://product"));
registry.register("recommendation", () ->
getHealth("http://recommendation"));
registry.register("review", () ->
getHealth("http://review"));
registry.register("product-composite", () ->
getHealth("http://product-composite"));
return new CompositeReactiveHealthIndicator
(healthAggregator, registry);
}
private Mono<Health> getHealth(String url) {
url += "/actuator/health";
LOG.debug("Will call the Health API on URL: {}", url);
return getWebClient().get().uri(url)
.retrieve().bodyToMono(String.class)
.map(s -> new Health.Builder().up().build())
.onErrorResume(ex ->
Mono.just(new Health.Builder().down(ex).build()))
.log();
}
我们在复合健康检查中添加了product-composite服务。
- 主应用类
se.magnus.springcloud.gateway.GatewayApplication声明了一个WebClient.builderbean,供健康指标的实现使用:
@Bean
@LoadBalanced
public WebClient.Builder loadBalancedWebClientBuilder() {
final WebClient.Builder builder = WebClient.builder();
return builder;
}
从前面的源代码中,我们可以看到WebClient.builder被注解为@LoadBalanced,这使得它能够意识到注册在发现服务器 Netflix Eureka 中的微服务实例。参考第九章使用 Netflix Eureka 进行服务发现部分的使用 Spring Cloud 与 Netflix Eureka 和 Ribbon 添加服务发现。
在边缘服务器中设置了复合健康检查后,我们就可以看看可以为 Spring Cloud Gateway 设置的配置了。
配置 Spring Cloud Gateway
当谈到配置 Spring Cloud Gateway 时,最重要的是设置路由规则。我们还需要在配置中设置其他几件事:
-
由于 Spring Cloud Gateway 将使用 Netflix Eureka 查找它将路由流量的微服务,所以它必须像描述的那样配置为 Eureka 客户端第九章使用 Netflix Eureka 和 Ribbon 添加服务发现,参考配置 Eureka 服务器客户端部分。
-
如第七章所述,为开发用途配置 Spring Boot Actuator 开发反应式微服务:参考添加健康 API部分。
management.endpoint.health.show-details: "ALWAYS"
management.endpoints.web.exposure.include: "*"
- 配置日志级别,以便我们可以看到 Spring Cloud Gateway 内部处理中有趣部分的发出的日志消息,例如,它是如何决定将传入请求路由到哪里的:
logging:
level:
root: INFO
org.springframework.cloud.gateway.route.RouteDefinitionRouteLocator: INFO
org.springframework.cloud.gateway: TRACE
要查看完整的源代码,请参阅配置文件:src/main/resources/application.yml。
路由规则
设置路由规则可以采用两种方法;编程方式,使用 Java DSL,或者通过配置。使用 Java DSL 编程方式设置路由规则在规则存储在外部存储中时很有用,例如数据库,或者在运行时给定,例如,通过 RESTful API 或发送给网关的消息。在大多数情况下,我发现声明路由在配置文件中很方便,该配置文件位于src/main/resources/application.yml。
一个路由由以下内容定义:
-
谓词,根据传入 HTTP 请求中的信息选择路由。
-
过滤器,可以修改请求和/或响应。
-
目标 URI,描述请求发送到哪里。
-
一个ID,即路由的名称。
要查看可用的谓词和过滤器的完整列表,请参阅参考文档:cloud.spring.io/spring-cloud-gateway/single/spring-cloud-gateway.html。
将请求路由到 product-composite API
如果我们想将 URL 路径以/product-composite/开头的传入请求路由到我们的product-composite服务,我们可以指定一个像这样的路由规则:
spring.cloud.gateway.routes:
- id: product-composite
uri: lb://product-composite
predicates:
- Path=/product-composite/**
以下是一些从前面代码中需要注意的要点:
-
id: product-composite:路由的名称是product-composite。 -
uri: lb://product-composite:如果路由通过其谓词被选中,请求将被路由到在发现服务中名为product-composite的服务,即 Netflix Eureka。lb://用于指示 Spring Cloud Gateway 使用客户端负载均衡器在发现服务中查找目的地。 -
predicates:- Path=/product-composite/**用于指定此路由应该匹配哪些请求。**匹配路径中的零个或多个元素。
将请求路由到 Eureka 服务器的 API 和网页
Eureka 为它的客户端提供了 API 和网页。为了在 Eureka 中提供 API 和网页之间的清晰分离,我们将按以下方式设置路由:
-
发送到边缘服务器,路径以
/eureka/api/开头的请求应被视为对 Eureka API 的调用。 -
发送到边缘服务器,路径以
/eureka/web/开头的请求应被视为对 Eureka 网页的调用。
API 请求将被路由到http://${app.eureka-server}:8761/eureka。Eureka API 的路由规则如下所示:
- id: eureka-api
uri: http://${app.eureka-server}:8761
predicates:
- Path=/eureka/api/{segment}
filters:
- SetPath=/eureka/{segment}
{segment}部分在Path值中匹配路径中的零个或多个元素,并将用于替换SetPath值中的{segment}部分。
网页请求将被路由到http://${app.eureka-server}:8761。网页将加载几个网页资源,如.js、.css和.png文件。这些请求将被路由到http://${app.eureka-server}:8761/eureka。Eureka 网页的路由规则看起来像这样:
- id: eureka-web-start
uri: http://${app.eureka-server}:8761
predicates:
- Path=/eureka/web
filters:
- SetPath=/
- id: eureka-web-other
uri: http://${app.eureka-server}:8761
predicates:
- Path=/eureka/**
从前面的配置中,我们可以得出以下结论:${app.eureka-server}属性取决于激活的 Spring 配置文件,由 Spring 的属性机制解析:
-
当在同一主机上运行服务而不使用 Docker 时,例如,用于调试目的,属性将被翻译为使用
default配置文件的localhost。 -
当以 Docker 容器的形式运行服务时,Netflix Eureka 服务器将在具有 DNS 名称
eureka的容器中运行。因此,属性将被翻译为eureka,使用docker配置文件。
在定义此翻译的application.yml文件的相关部分看起来像这样:
app.eureka-server: localhost
---
spring.profiles: docker
app.eureka-server: eureka
使用谓词和过滤器路由请求
为了了解更多关于 Spring Cloud Gateway 中的路由功能,我们将尝试基于主机的路由;也就是说,Spring Cloud Gateway 使用传入请求的主机名来确定如何路由请求。我们将使用一个我喜欢的用于测试 HTTP 代码的网站:httpstat.us/。
对http://httpstat.us/${CODE}的调用简单地返回具有${CODE} HTTP 代码和包含 HTTP 代码及相关描述性文本的响应体。例如,请看以下的curl命令:
curl http://httpstat.us/200 -i
这将返回 HTTP 代码 200,以及包含文本200 OK的响应体。
假设我们想要将调用http://${hostname}:8080/headerrouting的路由如下:
-
对
i.feel.lucky主机的调用应返回 200 OK。 -
对
im.a.teapot主机的调用应返回418 I'm a teapot。 -
对所有其他主机名的调用应返回
501 Not Implemented。
在 Spring Cloud Gateway 中实现这些路由规则时,我们可以使用Host路由谓词来选择具有特定主机名的请求,并使用SetPath过滤器来设置请求路径中的期望 HTTP 代码。这可以按以下方式完成:
- 为了使对
http://i.feel.lucky:8080/headerrouting的调用返回 200 OK,我们可以设置以下路由:
- id: host_route_200
uri: http://httpstat.us
predicates:
- Host=i.feel.lucky:8080
- Path=/headerrouting/**
filters:
- SetPath=/200
- 为了使对
http://im.a.teapot:8080/headerrouting的调用返回418 I'm a teapot,我们可以设置以下路由:
- id: host_route_418
uri: http://httpstat.us
predicates:
- Host=im.a.teapot:8080
- Path=/headerrouting/**
filters:
- SetPath=/418
- 最后,为了使所有其他主机名的调用返回
501 Not Implemented,我们可以设置以下路由:
- id: host_route_501
uri: http://httpstat.us
predicates:
- Path=/headerrouting/**
filters:
- SetPath=/501
好的,那有很多配置,现在让我们试试吧!
尝试边缘服务器
为了尝试边缘服务器,我们执行以下步骤:
- 首先,使用以下命令构建 Docker 镜像:
cd $BOOK_HOME/Chapter10
./gradlew build && docker-compose build
- 接下来,在 Docker 中启动系统架构,并使用以下命令运行常规测试:
./test-em-all.bash start
期待输出与我们在前面的章节中看到类似的输出:

在包括边缘服务器的系统景观中,让我们探讨以下主题:
-
检查边缘服务器在 Docker 引擎中运行的系统景观外部暴露了什么。
-
尝试一些最常用的路由规则:
-
使用基于 URL 的路由通过边缘服务器调用我们的 API。
-
使用基于 URL 的路由调用通过边缘服务器的 Netflix Eureka,包括使用其 API 和基于网页的 UI。
-
使用基于头部的路由查看我们如何根据请求中的主机名路由请求。
-
检查 Docker 引擎外部暴露了什么
为了了解边缘服务器向系统景观外部暴露了什么,请执行以下步骤:
- 使用
docker-compose ps命令来看看我们的服务暴露了哪些端口:
docker-compose ps gateway eureka product-composite product recommendation review
- 正如我们在下面的输出中所看到的,只有边缘服务器(名为
gateway)在其外部暴露了端口(8080):

- 如果我们想查看边缘服务器设置的路由,我们可以使用
/actuator/gateway/routesAPI。此 API 的响应相当详细。为了限制响应只包含我们感兴趣的信息,我们可以应用一个jq过滤器。在下面的示例中,我选择了路由的id和路由中的第一个谓词:
curl localhost:8080/actuator/gateway/routes -s | jq '.[] | {"\(.route_id)": "\(.route_definition.predicates[0].args._genkey_0)"}'
- 此命令将回应以下内容:

这让我们对边缘服务器中配置的实际路由有了一个很好的概览。现在,让我们尝试一下这些路由!
尝试路由规则
在本节中,我们将尝试边缘服务器及其向系统景观外部暴露的路由。让我们先调用产品组合 API,然后调用 Eureka API 并访问其网页,最后测试基于主机名的路由。
通过边缘服务器调用产品组合 API
让我们执行以下步骤通过边缘服务器调用产品组合 API:
- 为了能够看到边缘服务器中发生的情况,我们可以跟踪其日志输出:
docker-compose logs -f --tail=0 gateway
- 现在,通过边缘服务器调用产品组合 API:
curl http://localhost:8080/product-composite/2
- 期望从组合产品 API 获得正常类型的响应:

- 我们应该能在日志输出中找到以下有趣的信息:
Pattern "/product-composite/**" matches against value "/product-composite/2"
Route matched: product-composite
LoadBalancerClientFilter url chosen: http://b8013440aea0:8080/product-composite/2
从日志输出中,我们可以看到基于我们在配置中指定的谓词的模式匹配,我们还可以看到边缘服务器从发现服务器中可用的实例中选择了哪个微服务实例——在本例中,http://b8013440aea0:8080/product-composite/2。
通过边缘服务器调用 Eureka
要通过边缘服务器调用 Eureka,请执行以下步骤:
- 首先,通过边缘服务器调用 Eureka API 以查看目前在发现服务器中注册的实例:
curl -H "accept:application/json" localhost:8080/eureka/api/apps -s | \ jq -r .applications.application[].instance[].instanceId
- 期望得到如下类似的响应:

注意,边缘服务器(名为gateway)也包含在响应中。
- 接下来,使用 URL
http://localhost:8080/eureka/web在 Web 浏览器中打开 Eureka 网页:

从前面的屏幕截图中,我们可以看到 Eureka 网页报告的可用实例与上一步的 API 响应相同。
基于主机头的路由
让我们通过测试基于请求中使用的主机名的路由设置来结束!
通常,请求中的主机名会在 HTTP 客户端的Host头自动设置。当在本地测试边缘服务器时,主机名将是localhost——这对于测试基于主机名的路由来说并不很有用。但是我们可以通过在 API 调用中指定另一个主机名来欺骗,从而在Host头中实现。让我们看看如何做到这一点:
- 要调用
i.feel.lucky主机名,请使用此代码:
curl http://localhost:8080/headerrouting -H "Host: i.feel.lucky:8080"
- 预期响应为 200 OK。对于主机名
im.a.teapot,使用以下命令:
curl http://localhost:8080/headerrouting -H "Host: im.a.teapot:8080"
预期响应为418 I'm a teapot。
- 最后,如果不指定任何
Host头,请将localhost作为Host头:
curl http://localhost:8080/headerrouting
预期响应为501 Not Implemented。
- 我们也可以在请求中使用
i.feel.lucky和im.a.teapot作为真实的主机名,如果我们添加它们到本地的/etc/hosts文件,并指定它们应该被翻译成与localhost相同的 IP 地址,即127.0.0.1。运行以下命令以在/etc/hosts文件中添加所需信息的行:
sudo bash -c "echo '127.0.0.1 i.feel.lucky im.a.teapot' >> /etc/hosts"
- 现在,我们可以根据主机名进行相同的路由,但不必指定
Host头。尝试通过运行以下命令来实现:
curl http://i.feel.lucky:8080/headerrouting
curl http://im.a.teapot:8080/headerrouting
预期与先前相同的响应,即 200 OK 和418 I'm a teapot。
- 通过以下命令关闭系统景观的系统:
docker-compose down
- 此外,还应清理为
i.feel.lucky和im.a.teapot主机名添加的 DNS 名称翻译的/etc/hosts文件。编辑/etc/hosts文件,删除我们添加的行:127.0.0.1 i.feel.lucky im.a.teapot。
这些测试系统景观中边缘服务器的路由功能结束了本章。
总结
在本章中,我们看到了 Spring Cloud Gateway 如何可以作为边缘服务器使用,以控制哪些服务被允许从系统景观的外部调用。基于断言、过滤器和目标 URI,我们可以非常灵活地定义路由规则。如果我们愿意,我们可以配置 Spring Cloud Gateway 以使用诸如 Netflix Eureka 之类的发现服务来查找目标微服务实例。
还有一个重要的问题尚未回答,那就是我们如何防止未经授权访问边缘服务器暴露的 API,以及我们如何防止第三方拦截流量。
在下一章中,我们将了解如何使用诸如 HTTPS、OAuth 和 OpenID Connect 等标准安全机制来保护边缘服务器的访问。
问题
-
构成 Spring Cloud Gateway 中路由规则的元素是什么?
-
它们有什么用途?
-
我们如何指导 Spring Cloud Gateway 通过像 Netflix Eureka 这样的服务发现机制来定位微服务实例?
-
在 Docker 环境中,我们如何确保对外部 HTTP 请求只能到达 Docker 引擎的边缘服务器?
-
我们如何更改路由规则,使得边缘服务器接受对
http://$HOST:$PORT/api/productURL 的product-composite服务的调用,而不是目前使用的http://$HOST:$PORT/product-composite?
第十一章:保护 API 访问
在本章中,我们将了解如何保护前一章中引入的边缘服务器暴露的 API 和网页。我们将学习使用 HTTPS 来防止对 API 的外部访问进行窃听,并了解如何使用 OAuth 2.0 和 OpenID Connect 对用户和客户端应用程序访问我们的 API 进行认证和授权。最后,我们将研究使用 HTTP 基本认证来保护对 Netflix Eureka 发现服务的访问。
本章将涵盖以下主题:
-
介绍 OAuth 2.0 和 OpenID Connect 标准
-
关于如何保护系统架构的一般讨论
-
在我们的系统架构中添加一个授权服务器
-
使用 HTTPS 保护外部通信
-
保护对 Netflix Eureka 发现服务的访问
-
使用 OAuth 2.0 和 OpenID Connect 对 API 访问进行认证和授权
-
使用本地授权服务器进行测试
-
使用 Auth0 的 OpenID Connect 提供者进行测试
技术要求
本书中描述的所有命令都是在 MacBook Pro 上使用 macOS Mojave 运行的,但是修改它们以便在另一个平台如 Linux 或 Windows 上运行应该是很直接的。
在本章中不需要安装任何新工具。
本章的源代码可以在 GitHub 上找到,地址为github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud/tree/master/Chapter11。
为了能够按照书中描述运行命令,将源代码下载到一个文件夹中,并设置一个环境变量$BOOK_HOME,使其指向该文件夹。可以使用以下命令来执行这些步骤:
export BOOK_HOME=~/Documents/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud
git clone https://github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud $BOOK_HOME
cd $BOOK_HOME/Chapter11
Java 源代码是为 Java 8 编写的,并在 Java 12 上进行了测试。本章使用 Spring Cloud 2.1.0, SR1(也称为Greenwich版本),Spring Boot 2.1.3 和 Spring 5.1.5,即在编写时可用的 Spring 组件的最新版本。
源代码包含以下 Gradle 项目:
-
api -
util -
microservices/product-service -
microservices/review-service -
microservices/recommendation-service -
microservices/product-composite-service -
spring-cloud/eureka-server -
spring-cloud/gateway -
spring-cloud/authorization-server
本章中的代码示例都来自$BOOK_HOME/Chapter11中的源代码,但在许多情况下,都编辑了源代码中与主题无关的部分,例如注释、导入和日志声明。
如果你想要查看源代码中在第十一章,API 的安全访问中应用的改动,也就是查看在微服务架构中为 API 安全访问所付出的努力,你可以将其与第十章,使用 Spring Cloud Gateway 将微服务隐藏在边缘服务器后面的源代码进行对比。你可以使用你喜欢的diff工具,比较两个文件夹,$BOOK_HOME/Chapter10和$BOOK_HOME/Chapter11。
介绍 OAuth 2.0 和 OpenID Connect
在介绍 OAuth 2.0 和 OpenID Connect 之前,让我们先澄清我们所说的认证和授权是什么意思。认证意味着通过验证用户提供的凭据来识别用户,例如用户名和密码。授权是关于给予认证用户,也就是已识别用户,访问我们这里所说的 API 的不同部分的权限。在我们这里,一个用户将被分配一组基于 OAuth 2.0 范围的特权,如下所述。微服务将基于这些特权来确定用户是否被允许访问一个 API。
OAuth 2.0是一个开放标准的授权协议,而OpenID Connect是 OAuth 2.0 的一个补充,它允许客户端应用程序基于授权服务器执行的认证来验证用户身份。让我们简要地分别了解 OAuth 2.0 和 OpenID Connect,以获得它们目的的初步理解!
介绍 OAuth 2.0
OAuth 2.0 是一个广泛接受的开源标准授权协议,它允许用户授权第三方客户端应用以用户的名义访问受保护的资源。
那么,这意味着什么呢?
让我们先来梳理一下所使用的概念:
-
资源所有者:终端用户。
-
客户端:第三方客户端应用程序,例如,一个网络应用或一个原生移动应用,它想要以终端用户的名义调用一些受保护的 API。
-
资源服务器:暴露我们想要保护的 API 的服务器。
-
授权服务器:在资源所有者,即终端用户被认证后,向客户端发放令牌的服务器。用户信息的管理和用户的认证通常是在幕后委托给一个身份提供者(IdP)。
客户端在授权服务器中注册,并获得一个客户端 ID和一个客户端密钥。客户端密钥必须像密码一样受到客户端的保护。客户端还会获得一组允许的重定向 URI,授权服务器在用户被认证后使用这些 URI 将授权码和令牌发送回客户端应用程序。
以下是一个说明性的例子。假设一个用户访问了一个第三方客户端应用程序,并且客户端应用程序想要调用一个受保护的 API 来为用户服务。为了允许访问这些 API,客户端应用程序需要一种方式来告诉 API 它是在用户的名义下操作。为了避免用户必须与客户端应用程序共享他们的凭据以进行身份验证的解决方案,授权服务器会发放一个 访问令牌,该令牌允许客户端应用程序在用户的名义下访问一组选定的 API。
这意味着用户永远不需要向客户端应用程序透露他们的凭据。用户还可以给予客户端应用程序访问代表用户的具体 API 的权限。访问令牌代表了一组时间受限的访问权限,在 OAuth 2.0 术语中表达为 scope。一个 刷新令牌 也可以由授权服务器发放给客户端应用程序。客户端应用程序可以使用刷新令牌来获取新的访问令牌,而无需涉及用户。
OAuth 2.0 规格定义了四种授权 grant 流程,用于发放访问令牌,如下解释:
- 授权码 grant 流程:这是最安全,也是最复杂的 grant 流程。此流程需要用户通过网络浏览器与授权服务器进行交互,以进行身份验证和对客户端应用程序给予同意,如下面的图示:

此图的解释如下:
-
-
客户端应用程序通过在网络浏览器中发送用户到授权服务器来启动授权流程。
-
授权服务器将会验证用户并请求用户的同意。
-
授权服务器将会把用户通过一个 grant code 重定向回客户端应用程序。授权服务器将会使用客户端在第一步中指定的 redirect-URI 来知道重定向的位置。由于 grant code 是通过网络浏览器,也就是到一个不安全的环境中,恶意 JavaScript 代码可能获取 grant code,因此 grant code 仅允许使用一次,并且只在短时间内有效。
-
为了用 grant code 换取访问令牌,客户端应用程序预计需要再次调用授权服务器,使用服务器端代码。客户端应用程序必须向授权服务器提供其客户端 ID 和客户端密钥以及 grant code。
-
授权服务器发放一个访问令牌并将其发送回客户端应用程序。授权服务器还可以选择性地发放并返回一个刷新令牌。
-
使用访问令牌,客户端可以向资源服务器公开的受保护 API 发送请求。
-
资源服务器在验证访问令牌成功后验证访问令牌并处理请求。只要访问令牌有效,步骤 6 和 7 可以重复进行。当访问令牌的寿命过期时,客户端可以使用他们的刷新令牌来获取新的访问令牌。
-
-
隐式授权流:这个流程也是基于网络浏览器的,但旨在为无法保护客户端密钥的客户端应用程序,例如单页网络应用程序。它从授权服务器获取访问令牌,而不是授权代码,但由于它使用的是比代码授权流安全性较低的隐式授权流,因此无法请求刷新令牌。
-
资源所有者密码凭证授权流:如果客户端应用程序不能与网络浏览器进行交互,它可以回退到这个授权流。在这个授权流中,用户必须与客户端应用程序共享他们的凭据,并且客户端应用程序将使用这些凭据来获取访问令牌。
-
客户端凭证授权流:当客户端应用程序需要调用与特定用户无关的 API 时,它可以使用这个授权流来使用自己的客户端 ID 和客户端密钥获取访问令牌。
当涉及到针对受 OAuth 2.0 保护的 API 进行自动化测试时,资源所有者密码凭证授权流非常方便,因为它不需要使用网络浏览器进行手动交互。我们稍后将在本章中使用这个授权流与我们的测试脚本一起使用;请参阅测试脚本的变化部分。
完整的规范可以在这里找到:tools.ietf.org/html/rfc6749。还有许多其他规范详细说明了 OAuth 2.0 的各种方面;要了解概况,请参阅www.oauth.com/oauth2-servers/map-oauth-2-0-specs/。
一个值得额外关注的标准是RFC 7636 – OAuth 公共客户端(PKCE)代码交换证明密钥[https://tools.ietf.org/html/rfc7636]。这个标准描述了如何通过添加一个额外的安全层来使一个通常不安全的公共客户端(如移动原生应用或桌面应用)利用代码授权流。
保护发现服务,Netflix Eureka 的安全性
以前,我们学习了用 HTTPS 保护外部通信。现在我们将使用 HTTP 基本认证来限制对发现服务器上 API 和网页的访问,即要求用户提供用户名和密码以获得访问权限。需要在 Eureka 服务器以及下面描述的 Eureka 客户端上进行更改。
介绍 OpenID Connect
OpenID Connect(缩写为OIDC)正如前面已经提到的,是 OAuth 2.0 的一个补充,使客户端应用程序能够验证用户身份。OIDC 添加了一个额外的令牌,即 ID 令牌,客户端应用程序在完成授权流程后从授权服务器获得。
身份令牌编码为JSON Web Token(JWT),包含用户 ID 和电子邮件地址等声明。身份令牌使用 JSON Web 签名进行数字签名。这使得客户端应用程序可以通过使用授权服务器提供的公钥验证数字签名来信任身份令牌中的信息。
可选地,访问令牌也可以以与 ID 令牌相同的方式进行编码和签名,但根据规范这不是强制性的。最后,OIDC 定义了一个发现端点,这是建立重要端点的 URL 的标准方式,例如启动授权流程、获取验证数字签名 JWT 令牌的公钥,以及一个用户信息端点,可以使用该端点根据用户的访问令牌获取额外信息。
要查看可用的规范,请参阅openid.net/developers/specs/。
这结束了我们对 OAuth 2.0 和 OpenID Connect 标准的介绍。在下一节中,我们将了解系统景观将如何得到保护的高级视图。
保护系统景观
为了确保本章介绍的系统景观的安全,我们将执行以下步骤:
-
使用 HTTPS 对发往我们外部 API 的外部请求和响应进行加密,以防止窃听
-
使用 OAuth 2.0 和 OpenID Connect 对访问我们 API 的用户和客户端应用程序进行身份验证和授权
-
使用 HTTP 基本认证访问发现服务 Netflix Eureka
我们只将为边缘服务器的外部通信应用 HTTPS,而将系统景观内部的通信使用普通 HTTP。
在本书稍后的第十八章(第十八章,使用服务网格提高可观测性和管理)中,我们将看到如何借助服务网格产品自动为系统景观内的通信提供 HTTPS 加密。
出于测试目的,我们将在我们的系统景观中添加一个本地 OAuth 2.0 授权服务器。所有与授权服务器的外部通信将通过边缘服务器路由。边缘服务器和产品组合服务将作为 OAuth 2.0 资源服务器运行;也就是说,它们将要求有效的 OAuth 2.0 访问令牌才能访问。
为了最小化验证访问令牌的开销,我们将假设它们以签名 JWT 令牌的形式编码,并且授权服务器暴露了一个端点,资源服务器可以使用该端点访问验证签名所需的公钥,也称为jwk-set。
系统景观将如下所示:

从前面的图表中,我们可以注意到:
-
外部通信使用 HTTPS,而系统景观内部使用明文 HTTP。
-
本地 OAuth 2.0 授权服务器将通过边缘服务器外部访问。
-
边缘服务器和产品复合微服务都将验证作为签名 JWT 令牌的访问令牌。
-
边缘服务器和产品复合微服务将从授权服务器的
jwk-set端点获取其公钥,并使用它们验证基于 JWT 的访问令牌的签名。
请注意,我们将重点关注通过 HTTP 保护 API 的访问,而不是涵盖通用最佳实践来保护网络应用程序,例如,管理由类别:OWASP 前十名项目指出的网络应用程序安全风险。有关 OWASP 前十名的更多信息,请参阅www.owasp.org/index.php/Category:OWASP_Top_Ten_Project。
在了解了系统景观如何得到保护的概述之后,让我们首先在系统景观中添加一个本地授权服务器。
在我们的系统景观中添加授权服务器
为了能够本地运行并完全自动化地与使用 OAuth 2.0 和 OpenID Connect 保护的 API 进行测试,我们将向我们的系统景观中添加一个基于 OAuth 2.0 的授权服务器。Spring Security 5.1 不幸地没有提供开箱即用的授权服务器。但是有一个遗留项目(目前处于维护模式),Spring Security OAuth,提供了一个我们可以使用的授权服务器。
实际上,在 Spring Security 5.1 提供的示例中,一个使用来自 Spring Security OAuth 的授权服务器的项目是可用的。它被配置为使用 JWT 编码的访问令牌,并且它还暴露了一个JSON Web 密钥集(JWKS)(OpenID Connect 发现标准的组成部分)的端点,这是一组包含资源服务器可以用来验证由授权服务器发行的 JWT 令牌的公钥的密钥。
因此,即使它不是一个完整的 OpenID Connect 提供者,它也适合与我们可以本地运行并完全自动化的测试一起使用。
有关 Spring Security 中计划支持 OAuth 2.0 的更多详细信息,请参阅spring.io/blog/2018/01/30/next-generation-oauth-2-0-support-with-spring-security。
在 Spring Security 示例项目中,授权服务器可以在这里找到:github.com/spring-projects/spring-security/tree/master/samples/boot/oauth2authorizationserver。
Spring Security 示例项目配置了两个 OAuth 客户端,reader和writer,其中reader客户端被授予了读取作用域,而writer客户端则被授权了读取和写入作用域。两个客户端都配置为将机密设置为secret;参考sample.AuthorizationServerConfiguration类中的configure()方法。
以下更改已应用于示例项目:
-
已经在与其它微服务相同的方式下添加了一个 Eureka 客户端。参见第九章,使用 Netflix Eureka 和 Ribbon 添加服务发现; 参考将微服务连接到 Netflix Eureka 服务器部分。
-
添加了 Spring Boot Actuator 以获取对
health端点的访问。 -
添加了一个 Dockerfile,以便能够以 Docker 容器的形式运行授权服务器。
-
spring-security-samples-boot-oauth2authorizationserver.gradle构建文件已更改为与本书源代码中使用的build.gradle文件更加相似。 -
sample/AuthorizationServerConfiguration类中的配置已经按照以下方式更改:-
已经添加了对以下授权类型的支持:
code、authorization_code和implicit。 -
作用域的名称
message:read和message:write已经被改成了product:read和product:write。 -
在授权服务器中注册的用户用户名已更改为
magnus,密码为password;参考UserConfig类中的userDetailsService()方法(在sample/AuthorizationServerConfiguration.java文件中找到)。
-
授权服务器的源代码可以在$BOOK_HOME/Chapter11/spring-cloud/authorization-server中找到。
为了将授权服务器整合到系统景观中,已经应用了一系列更改。已将授权服务器添加到以下内容中:
-
常用的构建文件
settings.gradle -
三个 Docker Compose 文件
docker-compose*.yml -
边缘服务器,
spring-cloud/gateway:-
在
HealthCheckConfiguration中添加了一个健康检查。 -
添加了一个以
/oauth/开头的 URI 路由。
-
了解了如何在系统景观中添加一个本地授权服务器之后,让我们继续探讨如何使用 HTTPS 来保护外部通信免遭窃听。
使用 HTTPS 保护外部通信
在本节中,我们将学习如何防止通过边缘服务器公开的公共 API 窃听外部通信,例如来自互联网的通信。我们将使用 HTTPS 来加密通信。要使用 HTTPS,我们需要执行以下操作:
-
创建证书:我们将创建自己的自签名证书,足够用于开发目的。
-
配置边缘服务器:必须配置它只接受基于 HTTPS 的外部流量并使用证书。
自签名的证书是通过以下命令创建的:
keytool -genkeypair -alias localhost -keyalg RSA -keysize 2048 -storetype PKCS12 -keystore edge.p12 -validity 3650
源代码附带一个示例证书文件,所以你不需要运行这个命令来运行以下示例。
命令将询问多个参数。当询问密码时,我输入了password。对于其他参数,我简单地输入了一个空值来接受默认值。创建的证书文件edge.p12放在gateway项目的src/main/resources/keystore文件夹中。这意味着当它被构建时,证书文件将被放在.jar文件中,并在运行时类路径上可用的位置:keystore/edge.p12。
在开发过程中使用类路径提供证书是足够的,但不适用于其他环境,例如生产环境。以下是如何在运行时用外部证书替换此证书的示例!
为了配置边缘服务器使用证书和 HTTPS,以下内容添加到gateway项目的application.yml中:
server.port: 8443
server.ssl:
key-store-type: PKCS12
key-store: classpath:keystore/edge.p12
key-store-password: password
key-alias: localhost
以下是对前面源代码的解释:
-
证书的路径在
server.ssl.key-store参数中指定,设置为classpath:keystore/edge.p12的值。这意味着证书将从类路径上的位置keystore/edge.p12加载。 -
证书的密码在
server.ssl.key-store-password参数中指定。 -
为了指示边缘服务器使用 HTTPS 而不是 HTTP,我们还在
server.port参数中将端口从8080更改为8443。
除了在边缘服务器中的这些更改外,还需要在以下文件中进行更改,以反映端口和 HTTP 协议的变化:
-
三个 Docker Compose 文件,
docker-compose*.yml -
测试脚本,
test-em-all.bash
如前所述,使用类路径提供证书仅适用于开发;让我们看看我们如何在运行时用外部证书替换这个证书!
在运行时替换自签名证书
将自签名证书放在.jar文件中仅适用于开发。对于运行时环境的工作解决方案,例如用于测试或生产,必须能够使用由授权CA(简称证书颁发机构)签名的证书。
还必须能够在不需要重新构建.jar文件的情况下指定在运行时使用的证书,并且在使用 Docker 时,能够在包含.jar文件的 Docker 镜像中指定证书。当使用 Docker Compose 管理 Docker 容器时,我们可以在 Docker 容器中映射一个卷到位于 Docker 宿主机上的证书。我们还可以为 Docker 容器设置环境变量,指向 Docker 卷中的新证书。
在第十五章,《Kubernetes 简介》中,我们将学习 Kubernetes,在那里我们将看到更适合在集群中运行 Docker 容器的处理秘密(如证书)的更强大解决方案;也就是说,容器调度在 Docker 宿主机的组上,而不是单个 Docker 宿主机上。
本主题描述的更改没有应用到书籍 GitHub 仓库中的源代码中;也就是说,你需要亲自做出这些更改才能看到它们的作用!
要替换.jar文件中的证书,请执行以下步骤:
- 创建第二个证书,并将密码设置为
testtest:
cd $BOOK_HOME/Chapter11
mkdir keystore
keytool -genkeypair -alias localhost -keyalg RSA -keysize 2048 -storetype PKCS12 -keystore keystore/edge-test.p12 -validity 3650
- 更新
docker-compose.yml文件,用新的证书位置和密码以及映射到新证书放置文件夹的卷的环境变量。更改后,边缘服务器的配置将如下所示:
gateway:
environment:
- SPRING_PROFILES_ACTIVE=docker
- SERVER_SSL_KEY_STORE=file:/keystore/edge-test.p12
- SERVER_SSL_KEY_STORE_PASSWORD=testtest
volumes:
- $PWD/keystore:/keystore
build: spring-cloud/gateway
mem_limit: 350m
ports:
- "8443:8443"
- 如果边缘服务器正在运行,它需要使用以下命令重新启动:
docker-compose up -d --scale gateway=0
docker-compose up -d --scale gateway=1
docker-compose restart gateway命令看起来像是重启gateway服务的不错选择,但实际上并没有考虑docker-compose.yml中的更改。因此,在这个情况下,它不是一个有用的命令。
新的证书现在正在使用中!
本节介绍了如何使用 HTTPS 保护外部通信的内容。下一节我们将学习如何使用 HTTP 基本认证保护 Netflix Eureka 发现服务。
Eureka 服务器的变化
为了保护 Eureka 服务器,已经应用了以下更改:
- 在
build.gradle中添加了 Spring Security 依赖项:
implementation 'org.springframework.boot:spring-boot-starter-security'
se.magnus.springcloud.eurekaserver.SecurityConfig类中添加了安全配置:
- 用户定义如下:
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.passwordEncoder(NoOpPasswordEncoder.getInstance())
.withUser(username).password(password)
.authorities("USER");
}
username和password从配置文件中注入到构造函数中:
@Autowired
public SecurityConfig(
@Value("${app.eureka-username}") String username,
@Value("${app.eureka-password}") String password
) {
this.username = username;
this.password = password;
}
- 所有 API 和网页都通过以下定义使用 HTTP 基本认证进行保护:
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.anyRequest().authenticated()
.and()
.httpBasic();
}
- 用户凭据在配置文件
application.yml中设置:
app:
eureka-username: u
eureka-password: p
- 最后,测试类
se.magnus.springcloud.eurekaserver.EurekaServerApplicationTests在测试 Eureka 服务器的 API 时使用了配置文件中的凭据:
@Value("${app.eureka-username}")
private String username;
@Value("${app.eureka-password}")
private String password;
@Autowired
public void setTestRestTemplate(TestRestTemplate testRestTemplate) {
this.testRestTemplate = testRestTemplate.withBasicAuth(username, password);
}
前面的是限制对发现服务器 Netflix Eureka 的 API 和网页访问所需的步骤,现在它将使用 HTTP 基本认证,并要求用户提供用户名和密码以获取访问权限。在下一节中,我们将学习如何配置 Netflix Eureka 客户端,以便在访问 Netflix Eureka 服务器时传递凭据。
更改 Eureka 客户端
对于 Eureka 客户端,凭据必须指定在 Eureka 服务器的连接 URL 中。这在每个客户端的配置文件application.yml中如下指定:
app:
eureka-username: u
eureka-password: p
eureka:
client:
serviceUrl:
defaultZone: "http://${app.eureka-username}:${app.eureka-
password}@${app.eureka-server}:8761/eureka/"
我们将在 Netflix Eureka 客户端测试受保护的系统架构时看到这个配置的使用,在使用本地授权服务器进行测试一节中。
在下一节中,我们将学习如何在手动访问 Netflix Eureka 服务器时添加凭据,无论是使用其 API 还是其网页。
测试受保护的 Eureka 服务器
一旦受保护的 Eureka 服务器运行起来,我们必须提供有效的凭据,才能访问其 API 和网页。
例如,通过以下curl命令向 Eureka 服务器请求注册实例:
curl -H "accept:application/json" https://u:p@localhost:8443/eureka/api/apps -ks | jq -r .applications.application[].instance[].instanceId
以下是一个示例响应:

当访问https://localhost:8443/eureka/web网页时,我们首先必须接受一个不安全的连接,因为我们的证书是自签名的,接下来我们必须提供有效的凭据,如前面配置文件中所指定:

登录成功后,我们将看到 Eureka 服务器的熟悉网页:

这一节关于如何限制对 Netflix Eureka 服务器的访问就此结束。在下一节中,我们将学习如何使用 OAuth 2.0 和 OpenID Connect 对 API 的访问进行身份验证和授权。
使用 OAuth 2.0 和 OpenID Connect 对 API 访问进行身份验证和授权
在授权服务器就位之后,我们可以增强边缘服务器和product-composite服务,使它们成为 OAuth 2.0 资源服务器;也就是说,它们需要一个有效的访问令牌才能允许访问。我们将配置边缘服务器,接受任何可以使用授权服务器提供的签名验证的访问令牌。product-composite服务也将要求访问令牌包含一些 OAuth 2.0 范围:
-
product:read范围将用于访问只读 API。 -
product:write范围将用于访问创建和删除 API。
我们还必须增强我们的测试脚本,test-em-all.bash,以便在运行测试之前获取访问令牌。
边缘服务器和 product-composite 服务的变化
以下更改已应用:
build.gradle中已添加 Spring Security 5.1 依赖项,以支持 OAuth 2.0 资源服务器:
implementation('org.springframework.boot:spring-boot-starter-security')
implementation('org.springframework.security:spring-security-oauth2-resource-server')
implementation('org.springframework.security:spring-security-oauth2-jose')
- 已经向
se.magnus.springcloud.gateway.SecurityConfig和se.magnus.microservices.composite.product.SecurityConfig类添加了安全配置:
@EnableWebFluxSecurity
public class SecurityConfig {
@Bean
SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http
.authorizeExchange()
.pathMatchers("/actuator/**").permitAll()
.anyExchange().authenticated()
.and()
.oauth2ResourceServer()
.jwt();
return http.build();
}
}
前面源代码的解释如下:
-
.pathMatchers("/actuator/**").permitAll()用于允许访问不应受保护的 URL,例如,此处的actuator端点。参阅源代码以了解被视为不受保护的 URL。小心哪些 URL 暴露为不受保护。例如,在上线之前,actuator端点应该受到保护:-
.anyExchange().authenticated()确保在允许访问所有其他 URL 之前,用户已进行身份验证。 -
.oauth2ResourceServer().jwt()指定基于 JWT 编码的 OAuth 2.0 访问令牌进行身份验证和授权。
-
-
已经在配置文件
application.yml中注册了授权服务器的jwk-set端点的地址:
spring.security.oauth2.resourceserver.jwt.jwk-set-uri: http://${app.auth-server}:9999/.well-known/jwks.json
为了使边缘服务器和product-composite服务作为 OAuth 2.0 资源服务器行动,我们对它们进行了这些更改,同时还需对仅适用于product-composite服务进行一些更改。
产品组合服务中的更改
除了前面部分应用的常见更改外,还向product-composite服务应用了以下更改:
- 已经通过要求访问令牌中具有 OAuth 2.0 范围来细化安全配置,以允许访问:
.pathMatchers(POST, "/product-composite/**").hasAuthority("SCOPE_product:write")
.pathMatchers(DELETE, "/product-composite/**").hasAuthority("SCOPE_product:write")
.pathMatchers(GET, "/product-composite/**").hasAuthority("SCOPE_product:read")
按照约定,在 Spring Security 中检查权限时,OAuth 2.0 范围应前缀为SCOPE_。
-
添加了一个方法,
logAuthorizationInfo(),用于在每次调用 API 时记录 JWT 编码访问令牌的相关部分。可以使用标准 Spring Security,SecurityContext获取访问令牌,在反应式环境中,可以使用静态帮助方法ReactiveSecurityContextHolder.getContext()获取。有关详细信息,请参阅se.magnus.microservices.composite.product.services.ProductCompositeServiceImpl类。 -
在基于 Spring 的集成测试中,已禁用了 OAuth 的使用。为了防止在运行集成测试时 OAuth 机制启动,我们按照如下方式禁用它:
-
添加了一个安全配置
TestSecurityConfig,用于在测试期间允许访问所有资源:
http.csrf().disable().authorizeExchange().anyExchange().permitAll();
- 在每个 Spring 集成测试类中,我们配置了
TestSecurityConfig以用以下内容覆盖现有的安全配置:
@SpringBootTest( classes =
{ProductCompositeServiceApplication.class, TestSecurityConfig.class },
properties = {"spring.main.allow-bean-definition-overriding=true"})
有了这些更改,边缘服务器和product-composite服务都可以作为 OAuth 2.0 资源服务器。为了引入 OAuth 2.0 和 OpenID Connect 的使用,我们需要采取的最后一步是更新测试脚本,使其在运行测试时获取访问令牌并使用它们。
测试脚本中的更改
首先,我们需要在调用任何 API(除了健康 API)之前获取访问令牌,这使用 OAuth 2.0 密码流完成。为了能够调用创建和删除 API,我们以writer客户端的身份获取访问令牌,如下所示:
ACCESS_TOKEN=$(curl -k https://writer:secret@$HOST:$PORT/oauth/token -d grant_type=password -d username=magnus -d password=password -s | jq .access_token -r)
为了验证基于范围的授权是否有效,在测试脚本中添加了两个测试:
-
第一个测试调用 API 时没有提供访问令牌。API 预期返回 401 未授权 HTTP 状态。
-
另一个测试使用
reader客户端调用更新 API,该客户端仅授予读取范围。API 预期返回 403 禁止 HTTP 状态。
要查看完整的源代码,请参阅test-em-all.bash:
# Verify that a request without access token fails on 401, Unauthorized
assertCurl 401 "curl -k https://$HOST:$PORT/product-composite/$PROD_ID_REVS_RECS -s"
# Verify that the reader - client with only read scope can call the read API but not delete API.
READER_ACCESS_TOKEN=$(curl -k https://reader:secret@$HOST:$PORT/oauth/token -d grant_type=password -d username=magnus -d password=password -s | jq .access_token -r)
READER_AUTH="-H \"Authorization: Bearer $READER_ACCESS_TOKEN\""
assertCurl 200 "curl -k https://$HOST:$PORT/product-composite/$PROD_ID_REVS_RECS $READER_AUTH -s"
assertCurl 403 "curl -k https://$HOST:$PORT/product-composite/$PROD_ID_REVS_RECS $READER_AUTH -X DELETE -s"
更新测试脚本以获取和使用 OAuth 2.0 访问令牌后,我们准备在下一节中尝试它们!
使用本地授权服务器进行测试
在本节中,我们将尝试使用受保护的系统环境;也就是说,我们将一起测试所有的安全组件。我们将使用本地授权服务器来发放访问令牌。以下测试将执行:
-
首先,我们从源代码构建并运行测试脚本,以确保一切都能配合在一起。
-
接下来,我们将学习如何使用 OAuth 2.0 授权流获取访问令牌:密码、隐式和代码授权流。
-
最后,我们将使用访问令牌来调用 API。我们还将验证,为读者客户端颁发的访问令牌不能用于调用更新 API。
构建和运行自动化测试
要构建和运行自动化测试,我们执行以下步骤:
- 首先,使用以下命令构建 Docker 镜像:
cd $BOOK_HOME/Chapter11
./gradlew build && docker-compose build
- 接下来,使用以下命令启动 Docker 中的系统环境并运行常规测试:
./test-em-all.bash start
注意最后的新增的负面测试,验证当我们没有认证时返回 401 未授权代码,以及当我们没有授权时返回 403 禁止。
获取访问令牌
现在我们可以使用 OAuth 2.0 定义的各种授权流来获取访问令牌。我们将尝试以下授权流:密码、隐式和代码授权。
使用密码授权流获取访问令牌
为了获取writer客户端的访问令牌,即具有product:read和product:write范围的访问令牌,请发出以下命令:
curl -k https://writer:secret@localhost:8443/oauth/token -d grant_type=password -d username=magnus -d password=password -s | jq .
客户端使用 HTTP 基本认证来标识自己,传递其writer客户端 ID 和其秘密secret。它使用username和password参数发送资源所有者的凭据,即终端用户。
以下是一个示例响应:

在响应中将access_token字段的值设置为环境变量中的访问令牌:
ACCESS_TOKEN=eyJ...SyIlQ
要为reader客户端获取访问令牌,即只有product:read范围,只需在前面的命令中将writer替换为reader:
curl -k https://reader:secret@localhost:8443/oauth/token -d grant_type=password -d username=magnus -d password=password -s | jq .
使用隐式授权流获取访问令牌
要使用隐式授权流获取访问令牌,我们需要涉及一个网络浏览器。打开接受自我签名的证书的网络浏览器,例如 Chrome。然后执行以下步骤:
- 为了获取
reader客户端的访问令牌,请打开以下 URL:https://localhost:8443/oauth/authorize?response_type=token&client_id=reader&redirect_uri=http://my.redirect.uri&scope=product:read&state=48532。当网络浏览器要求登录时,请使用授权服务器配置中指定的凭据,例如magnus和password:

- 接下来,您将被要求授权
reader客户端以您的名义调用 API:

- 最后,我们将得到以下响应:

这可能乍一看有点令人失望。授权服务器返回给网络浏览器的 URL 基于客户端在初始请求中指定的重定向-URI。将 URL 复制到文本编辑器中,您会发现类似于以下内容的东西:
http://my.redirect.uri/#access_token=eyJh...C8pw&token_type=bearer&state=48532&expires_in=599999999&jti=8956b38b-36ea-4888-80dc-685b49f20f91
太好了!我们可以在新 URL 中的access_token请求参数中找到访问令牌。
将访问令牌保存到环境变量中,如下所示:
ACCESS_TOKEN=eyJh...C8pw
为了获取writer客户端的访问令牌,您可以使用以下 URL:https://localhost:8443/oauth/authorize?response_type=token&client_id=writer&redirect_uri=http://my.redirect.uri&scope=product:read+product:write&state=95372。
您已经认证,因此不需要再次登录,但您必须给予writer客户端同意。
注意,不需要客户端密钥;也就是说,隐式授权流并不非常安全。
任何应用程序都可以声称它是writer客户端,并可以要求用户同意使用请求的范围以用户的身份访问 API,所以要小心!
使用代码授权流获取访问令牌
最后,让我们尝试 OAuth 2.0 中最安全的授权流——代码授权流!
至于代码授权流,为了使流程更加安全,事情变得有点复杂。在第一个不安全步骤中,我们将使用网络浏览器获取一个只能使用一次的代码,当它与访问令牌交换时。代码应从网络浏览器传递到安全层,例如服务器端代码,它可以再次向授权服务器发起新请求以交换代码和访问令牌。在此交换中,服务器必须提供客户端密钥以验证其来源。
为了获取reader客户端的代码,请在网络浏览器中使用以下 URL:https://localhost:8443/oauth/authorize?response_type=code&client_id=reader&redirect_uri=http://my.redirect.uri&scope=product:read&state=35725。
这次,你将得到一个更短的 URL,例如,http://my.redirect.uri/?code=T2pxvW&state=72489。
从code参数中提取授权代码,并定义一个名为CODE的环境变量,其值为它的值:
CODE=T2pxvW
接下来,假设你是后端服务器,使用以下curl命令将授权代码与访问令牌进行交换:
curl -k https://reader:secret@localhost:8443/oauth/token \
-d grant_type=authorization_code \
-d client_id=reader \
-d redirect_uri=http://my.redirect.uri \
-d code=$CODE -s | jq .
示例响应如下:

最后,像之前一样将访问令牌保存到环境变量中:
ACCESS_TOKEN=eyJh...KUBA
为了获取writer客户端的代码,请使用以下 URL:https://localhost:8443/oauth/authorize?response_type=code&client_id=writer&redirect_uri=http://my.redirect.uri&scope=product:read+product:write&state=72489.
使用访问令牌调用受保护的 API
现在,让我们使用我们已经获取的访问令牌来调用受保护的 API!
- 首先,调用一个 API 来检索没有有效访问令牌的复合产品:
ACCESS_TOKEN=an-invalid-token
curl https://localhost:8443/product-composite/2 -k -H "Authorization: Bearer $ACCESS_TOKEN" -i
- 它应该返回以下响应:

错误信息清楚地指出访问令牌无效!
- 接下来,尝试使用 API 检索复合产品,使用之前为
reader客户端获取的其中一个访问令牌:
ACCESS_TOKEN={a-reader-access-token}
curl https://localhost:8443/product-composite/2 -k -H "Authorization: Bearer $ACCESS_TOKEN" -i
- 现在我们将得到
200 OK状态码,并将返回预期的响应体:

如果我们尝试使用为reader客户端获取的访问令牌访问更新 API,例如删除 API,调用将失败:
ACCESS_TOKEN={a-reader-access-token}
curl https://localhost:8443/product-composite/999 -k -H "Authorization: Bearer $ACCESS_TOKEN" -X DELETE -i
它将失败,响应类似于以下内容:

如果我们重复调用删除 API,但使用的是为writer客户端获取的访问令牌,那么调用将成功,响应为 200 OK。
删除操作即使 underlying database 中不存在具有指定 product ID 的产品,也应该返回200,因为删除操作如第六章中所述,是幂等的,添加持久性。参考添加新 API部分。
如果你使用docker-compose logs -f product-composite命令查看日志输出,你应该能够找到如下授权信息:

这些信息是通过product-composite服务中的新方法logAuthorizationInfo()从 JWT 编码的访问令牌中提取的;也就是说,product-composite服务不需要与授权服务器通信就能获取这些信息!
通过这些测试,我们看到了如何使用各种授权流获取访问令牌,即密码、隐式和代码授权流。我们还看到了如何使用范围来限制客户端可以使用特定访问令牌执行的操作,例如只用于阅读操作。
在下一节中,我们将用外部 OpenID Connect 提供商替换本节中使用的本地授权服务器。
使用 OpenID Connect 提供商进行测试 - Auth0
所以,与我们自己控制的授权服务器一起,OAuth 舞蹈运行良好。但如果我们用一个认证的 OpenID Connect 提供商来替换它呢?从理论上讲,它应该可以即插即用。让我们来找出答案,好吗?
要查看 OpenID Connect 的认证实现列表,请参阅openid.net/developers/certified/。我们将使用 Auth0 进行 OpenID 提供者的测试。auth0.com/。为了能够使用 Auth0 而不是我们自己的授权服务器,我们将讨论以下主题:
-
在 Auth0 中设置带有 OAuth 客户端和用户的账户
-
应用所需更改以使用 Auth0 作为 OpenID 提供商并运行测试脚本来验证它是否正常工作
-
使用以下方法获取访问令牌:
-
密码授权流
-
隐式授权流
-
授权代码授权流
-
-
使用访问令牌调用受保护的 API。
-
使用用户信息端点获取有关用户的更多信息。
让我们在下面的章节中理解每一个概念。
在 Auth0 中设置账户和 OAuth 2.0 客户端
执行以下步骤以在 Auth0 中注册免费账户,配置 OAuth 2.0 客户端和product-composite API,最后注册用户:
-
在浏览器中打开 URL,
auth0.com。 -
点击 SIGN UP 按钮:
-
使用您选择的账户注册。
-
注册成功后,您将被要求创建一个租户域。
输入您选择的租户名称,在我的情况下:
dev-ml.eu.auth0.com。 -
填写有关您账户的信息。
-
-
注册后,您将被引导到您的仪表板。在左侧选择应用程序标签,以查看在注册过程中为您创建的默认客户端应用程序。
-
点击默认应用进行配置:
-
复制
Client ID和Client Secret;稍后您需要它们。 -
作为应用程序类型,选择机器对机器。
-
作为令牌端点认证方法,选择 POST。
-
将
http://my.redirect.uri作为允许的回调 URL 输入。 -
点击显示高级设置,转到授权类型标签,取消选中客户端凭据,选择密码框。
-
点击 SAVE CHANGES。
-
-
现在为我们的 API 定义授权:
-
点击左侧的 APIs 标签,然后点击+ CREATE API 按钮。
-
将 API 命名为
product-composite,给它一个标识符https://localhost:8443/product-composite,然后单击 CREATE 按钮。 -
点击“权限”标签页,为
product:read和product:write创建两个权限(即,OAuth 范围)。
-
-
接下来,创建一个用户:
-
点击“用户与角色”->“用户”标签页(在左侧),然后点击“创建您的第一个用户”按钮。
-
输入您喜欢的
email和password,然后点击“保存”按钮。 -
寻找 Auth0 发送到您提供的电子邮件地址的邮箱中的验证邮件。
-
-
最后,验证用于密码授予流的默认目录设置:
-
点击右上角的上传箭头旁边的租户配置文件,选择设置。
-
在名为“常规”的标签页中,滚动到名为“默认目录”的字段,并验证它包含
Username-Password-Authentication值。如果没有,更新字段并保存更改。
-
-
就这么多!请注意,默认应用和 API 都获得一个客户端 ID 和一个秘密。我们将使用默认应用的客户端 ID 和秘密;即 OAuth 客户端。
创建并配置了 Auth0 账户后,我们可以继续在系统架构中应用必要的配置更改。
将必要的更改应用于使用 Auth0 作为 OpenID 提供者
在本节中,我们将学习需要进行哪些配置更改,才能用 Auth0 替换本地授权服务器。我们只需要更改作为 OAuth 资源服务器的两个服务的配置,即product-composite和gateway服务。我们还需要稍微更改测试脚本,以便它从 Auth0 获取访问令牌,而不是从我们的本地授权服务器获取。让我们从 OAuth 资源服务器开始,即product-composite和gateway服务。
本主题中描述的更改尚未应用于书中 Git 仓库中的源代码;也就是说,您需要自己进行这些更改以亲眼看到它们的作用!
在 OAuth 资源服务器中更改配置
当使用 OpenID Connect 提供者时,我们只需要在 OAuth 资源服务器中配置基础 URI 到标准的发现端点,即product-composite和gateway服务。Spring Security 将使用来自发现端点的响应中的信息来配置资源服务器。
在product-composite和gateway项目中,对resource/application.yml文件进行以下更改:
现在找到以下属性设置:
spring.security.oauth2.resourceserver.jwt.jwk-set-uri: http://${app.auth-server}:9999/.well-known/jwks.json
用以下内容替换:
spring.security.oauth2.resourceserver.jwt.issuer-uri: https://${TENANT_DOMAIN_NAME}/
注意:用您的租户域名替换前一个配置中的${TENANT_DOMAIN_NAME};在我的情况下,它是dev-ml.eu.auth0.com,不要忘记结尾的/!
如果您好奇,可以通过运行以下命令查看发现文档中的内容:
curl https://${TENANT_DOMAIN_NAME}/.well-known/openid-configuration -s | jq
按照以下方式重新构建product-composite和gateway服务:
cd $BOOK_HOME/Chapter11
./gradlew build && docker-compose up -d --build product-composite gateway
更新了product-composite和gateway服务后,我们还可以继续更新测试脚本。
更改测试脚本以从 Auth0 获取访问令牌
我们还需要更新测试脚本,使其从 Auth0 OIDC 提供者获取访问令牌。这可以通过在test-em-all.bash中执行以下更改来实现。
使用以下命令:
ACCESS_TOKEN=$(curl http://writer:secret@$HOST:$PORT/oauth/token -d grant_type=password -d username=magnus -d password=password -s | jq .access_token -r)
用以下命令替换它:
ACCESS_TOKEN=$(curl --request POST \
--url 'https://${TENANT_DOMAIN_NAME}/oauth/token' \
--header 'content-type: application/json' \
--data '{"grant_type":"password", "username":"${USER_EMAIL}", "password":"${USER_PASSWORD}", "audience":"https://localhost:8443/product-composite", "scope":"openid email product:read product:write", "client_id": "${CLIENT_ID}", "client_secret": "${CLIENT_SECRET}"}' -s | jq -r .access_token)
现在,用之前在 Auth0 注册过程中收集的值替换${TENANT_DOMAIN_NAME}、${USER_EMAIL}、${USER_PASSWORD}、${CLIENT_ID}和${CLIENT_SECRET},然后使用以下命令:
READER_ACCESS_TOKEN=$(curl -k https://reader:secret@$HOST:$PORT/oauth/token -d grant_type=password -d username=magnus -d password=password -s | jq .access_token -r)
用以下命令替换它:
READER_ACCESS_TOKEN=$(curl --request POST \
--url 'https://${TENANT_DOMAIN_NAME}/oauth/token' \
--header 'content-type: application/json' \
--data '{"grant_type":"password", "username":"${USER_EMAIL}", "password":"${USER_PASSWORD}", "audience":"https://localhost:8443/product-composite", "scope":"openid email product:read", "client_id": "${CLIENT_ID}", "client_secret": "${CLIENT_SECRET}"}' -s | jq -r .access_token)
将前面的更改应用到命令中。同时注意我们只要求product:read作用域,而不需要product:write作用域。这是为了模拟只具有读取权限的客户端。
现在,访问令牌是由 Auth0 签发,而不是我们的本地授权服务器,我们的 API 实现可以验证访问令牌(是否由 Auth0 正确签名且未过期),使用application.yml文件中标记的 Auth0 发现服务提供的信息。与之前一样,API 实现可以使用访问令牌中的作用域来授权客户端调用 API 或不允许调用。
现在我们已经实施了所有必需的更改,让我们运行一些测试来验证我们是否可以从 Auth0 获取访问令牌。
使用 Auth0 作为 OpenID Connect 提供者运行测试脚本
现在,我们准备尝试 Auth0!
使用以下命令对 Auth0 进行常规测试:
./test-em-all.bash
在日志中(使用docker-compose logs -f product-composite命令),你将能够找到 Auth0 签发的访问令牌的授权信息:
使用同时带有product:read和product:write作用域的访问令牌时,我们会看到两个作用域都按如下列出:

仅使用带有product:read作用域的访问令牌时,我们会看到只列出该作用域如下:

从日志输出中,我们可以看到我们现在得到了关于这个访问令牌打算面向的受众的信息。为了加强安全,我们可以在我们的服务中添加一个测试,验证其 URL,在本例中为https://localhost:8443/product-composite,是否是受众列表的一部分。这将防止有人尝试使用为其他目的签发的访问令牌来获取对我们的 API 的访问。
有了与 Auth0 集成的自动化测试,我们可以继续学习如何使用不同类型的授权流获取访问令牌。让我们从密码授权流开始。
使用密码授权流获取访问令牌
在本节中,我们将学习如何使用密码授权流从 Auth0 获取访问令牌。
如果你自己想要从 Auth0 获取访问令牌,你可以通过运行以下命令来实现:
curl --request POST \
--url 'https://${TENANT_DOMAIN_NAME}/oauth/token' \
--header 'content-type: application/json' \
--data '{"grant_type":"password", "username":"${USER_EMAIL}", "password":"${USER_PASSWORD}", "audience":"https://localhost:8443/product-composite", "scope":"openid email product:read", "client_id": "${CLIENT_ID}", "client_secret": "${CLIENT_SECRET}"}' -s | jq
根据使用访问令牌调用受保护的 API节中的说明,你应该能够使用获取的访问令牌调用 API。我们接下来要尝试的授权流程是隐式授权流程。
使用隐式授权流程获取访问令牌
在本节中,我们将学习如何使用隐式授权流程从 Auth0 获取访问令牌。
如果你想要尝试更复杂的隐式授权流程,你可以在网页浏览器中打开下面的链接:
https://${TENANT_DOMAIN_NAME}/authorize?response_type=token&scope=openid email product:read product:write&client_id=${CLIENT_ID}&state=98421&&nonce=jxdlsjfi0fa&redirect_uri=http://my.redirect.uri&audience=https://localhost:8443/product-composite
将前面 URL 中的${TENANT_DOMAIN_NAME}和${CLIENT_ID}替换为你在 Auth0 注册过程中收集的租户域名和客户端 ID。
让我们看看以下步骤:
- Auth0 应该显示以下登录屏幕:

- 登录成功后,Auth0 会要求你给予客户端应用程序你的同意:

访问令牌现在在浏览器中的 URL 里,就像我们在本地授权服务器中尝试隐式流程时一样:

要获取对应于reader客户端的访问令牌,需要从前面的 URL 中移除product:write范围,这个 URL 是我们用来启动隐式授权流程的。
既然我们已经知道如何使用隐式授权流程获取访问令牌,我们可以继续尝试第三个也是最后一个授权流程,即授权码授权流程。
使用授权码授权流程获取访问令牌
最后,我们来到了最安全的授权流程——授权码授权流程。我们将遵循与本地授权服务器相同的程序;也就是说,我们首先获取代码,然后用它来换取访问令牌。通过在网页浏览器中打开下面的链接来获取代码,如下所示:
https://${TENANT_DOMAIN_NAME}/authorize?audience=https://localhost:8443/product-composite&scope=openid email product:read product:write&response_type=code&client_id=${CLIENT_ID}&redirect_uri=http://my.redirect.uri&state=845361
将前面 URL 中的${TENANT_DOMAIN_NAME}和${CLIENT_ID}替换为你在 Auth0 注册过程中收集的租户域名和客户端 ID。
期望在网页浏览器中会尝试重定向到一个如下所示的 URL:
http://my.redirect.uri/?code=6mQ7HK--WyX9fMnv&state=845361
提取代码并运行以下命令以获取访问令牌:
curl --request POST \
--url 'https://${TENANT_DOMAIN_NAME}/oauth/token' \
--header 'content-type: application/json' \
--data '{"grant_type":"authorization_code","client_id": "${CLIENT_ID}","client_secret": "${CLIENT_SECRET}","code": "${CODE}","redirect_uri": "http://my.redirect.uri"}' -s | jq .
将前面 URL 中的${TENANT_DOMAIN_NAME}、${CLIENT_ID}、${CLIENT_SECRET}和${CODE}替换为你在 Auth0 注册过程中收集的租户域名、客户端 ID 和客户端密钥。
既然我们已经学会了使用三种类型的授权流程获取访问令牌,我们准备在下一节中尝试使用从 Auth0 获取的访问令牌调用外部 API。
使用 Auth0 访问令牌调用受保护的 API
在本节中,我们将了解如何使用从 Auth0 获取的访问令牌调用外部 API。
我们可以使用 Auth0 发行的访问令牌来调用我们的 API,就像我们之前使用本地授权服务器发行的访问令牌一样。
对于只读 API,执行以下命令:
ACCESS_TOKEN=...
curl https://localhost:8443/product-composite/2 -k -H "Authorization: Bearer $ACCESS_TOKEN" -i
对于一个更新的 API,执行以下命令:
ACCESS_TOKEN=...
curl https://localhost:8443/product-composite/999 -k -H "Authorization: Bearer $ACCESS_TOKEN" -X DELETE -i
因为我们请求了两个范围,product:read和product:write,所以预计前面的 API 调用都会返回 200 OK。
获取关于用户的其他信息
正如你在日志输出中看到的,主题(即用户)的 ID 有点难以理解,例如,auth0|5ca0b73c97f31e11bc85a5e6。
如果你想要你的 API 实现了解更多关于用户的信息,它可以调用 Auth0 的userinfo_endpoint,如前所述的发现请求的响应:
curl -H "Authorization: Bearer $ACCESS_TOKEN" https://${TENANT_DOMAIN_NAME}/userinfo -s | jq
在前面的命令中将${TENANT_DOMAIN_NAME}替换为你之前在 Auth0 注册过程中收集的租户域名。
以下是一个示例响应:

此端点还可以用于验证用户是否在 Auth0 中撤销了访问令牌。
通过以下命令关闭系统架构,结束测试:
docker-compose down
到这里,我们已经学会了如何用一个外部的替代品替换掉只用于测试的本地 OAuth 2.0 授权服务器。我们还看到了如何重新配置微服务架构,使用外部的 OIDC 提供者来验证访问令牌。
总结
在本章中,我们学习了如何使用 Spring Security 保护我们的 API。
我们已经看到了如何通过 Spring Security 启用 HTTPS 来防止第三方监听。通过 Spring Security,我们还了解到,限制访问和发现服务器 Netflix Eureka 非常简单,使用 HTTP 基本认证。最后,我们看到了如何使用 Spring Security 简化 OAuth 2.0 和 OpenID Connect 的使用,允许第三方客户端应用程序以用户的名义访问我们的 API,而无需要求用户与客户端应用程序共享凭据。我们已经学习了如何基于 Spring Security 设置本地 OAuth 2.0 授权服务器,以及如何更改配置,以便使用外部的 OpenID Connect 提供者 Auth0。
然而,一个关注点是如何管理所需的配置。对于涉及其中的微服务,必须设置许多小的配置项,并且这些配置必须同步以匹配。除了分散的配置之外,还有一些配置包含敏感信息,例如凭据或证书。看来我们需要一种更好的方法来处理多个协作微服务的配置,同时也需要一个解决方案来处理配置中的敏感部分。
在下一章中,我们将探索 Spring Cloud Configuration 服务器,并了解如何使用它来处理这些类型的需求。
问题
-
使用自签名证书的好处和缺点是什么?
-
OAuth 2.0 授权码的目的是什么?
-
什么是 OAuth 2.0 范围的目的?
-
当一个令牌是一个 JWT 令牌时,这意味着什么?
-
我们如何信任存储在 JWT(JSON Web Token)令牌中的信息?
-
对于原生移动应用,使用 OAuth 2.0 授权码授权流是否合适?
-
开放 ID 连接(OpenID Connect)为 OAuth 2.0 增加了什么?
第十二章:集中式配置
在本章中,我们将学习如何使用 Spring Cloud Configuration 服务器来集中管理我们微服务的配置。正如在第一章“微服务简介”中“集中配置”部分所描述的,越来越多的微服务通常伴随着越来越多的需要管理和更新的配置文件。
使用 Spring Cloud Configuration 服务器,我们可以将所有微服务的配置文件放在一个中心配置存储库中,这将使我们更容易管理它们。我们的微服务将在启动时从配置服务器检索其配置。
本章将涵盖以下主题:
-
介绍 Spring Cloud Configuration 服务器
-
设置配置服务器
-
配置配置服务器的客户端
-
组织配置仓库
-
尝试使用 Spring Cloud Configuration 服务器
技术要求
本书中描述的所有命令都是在 MacBook Pro 上使用 macOS Mojave 运行的,但应该足够直观,以便能够修改以在其他平台(如 Linux 或 Windows)上运行。
在本章中不需要安装任何新工具。
本章的源代码可以在 GitHub 上找到,地址为github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud/tree/master/Chapter12。
为了能够按照书中描述的命令运行,将源代码下载到一个文件夹中,并设置一个环境变量,$BOOK_HOME,指向那个文件夹。示例命令包括以下内容:
export BOOK_HOME=~/Documents/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud
git clone https://github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud $BOOK_HOME
cd $BOOK_HOME/Chapter12
Java 源代码是为 Java 8 编写的,并在 Java 12 上进行了测试。本章使用 Spring Cloud 2.1.0,SR1(也称为Greenwich版本),Spring Boot 2.1.4 和 Spring 5.1.6,即在撰写本章时可用的 Spring 组件的最新版本。
所有 Dockerfile 中使用的基础 Docker 镜像,openjdk:12.0.2。
源代码包含以下 Gradle 项目:
-
api -
util -
microservices/product-service -
microservices/review-service -
microservices/recommendation-service -
microservices/product-composite-service -
spring-cloud/eureka-server -
spring-cloud/gateway -
spring-cloud/authorization-server -
spring-cloud/config-server
本章中的所有源代码示例都来自$BOOK_HOME/Chapter12的源代码,但在许多情况下,为了删除源代码中不相关部分,例如注释、导入语句和日志语句,进行了编辑。
如果你想看到第十二章《集中配置》中应用到源代码的变化,即添加配置服务器所做的工作,你可以将其与第十一章《保护 API 访问》的源代码进行比较。你可以使用你喜欢的diff工具,比较两个文件夹$BOOK_HOME/Chapter11和$BOOK_HOME/Chapter12。
介绍 Spring Cloud Config 服务器
在第十章《使用 Spring Cloud Gateway 隐藏边缘服务器后的微服务》中,Spring Cloud Config 服务器(缩写为config server)将添加到边缘服务器后面的现有微服务景观中,就像其他微服务一样:

当涉及到设置配置服务器时,有许多选项需要考虑:
-
为配置仓库选择存储类型
-
决定初始客户端连接,是连接到配置服务器还是发现服务器
-
保护配置,既防止未经授权访问 API 和
避免在配置仓库中以明文形式存储敏感信息
让我们逐一浏览每个选项,并介绍配置服务器暴露的 API。
选择配置仓库的存储类型
如在第八章《Spring Cloud 简介》中集中配置的 Spring Cloud Config部分所述,配置服务器支持将配置文件存储在多种不同的后端中,例如:
-
Git 仓库
-
本地文件系统
-
HashiCorp Vault
-
一个 JDBC 数据库
在本章中,我们将使用本地文件系统。要使用本地文件系统,配置服务器需要启动 Spring 配置文件native。配置仓库的位置是通过使用属性spring.cloud.config.server.native.searchLocations来指定的。
决定初始客户端连接
默认情况下,客户端首先连接到配置服务器以获取其配置。基于配置,它连接到发现服务器,即我们情况下的 Netflix Eureka,以注册自己。也可以反过来做,即客户端首先连接到发现服务器找到一个配置服务器实例,然后连接到配置服务器获取其配置。这两种方法都有利弊。
在本章中,客户端将首先连接到配置服务器。采用这种方法,将能够在配置服务器中存储发现服务器的配置,即 Netflix Eureka。
要了解关于其他替代方案的更多信息,请参阅cloud.spring.io/spring-cloud-static/spring-cloud-config/2.1.0.RELEASE/single/spring-cloud-config.html#discovery-first-bootstrap。
首先连接到配置服务器的担忧之一是,配置服务器可能成为一个单点故障。如果客户端首先连接到发现服务,如 Netflix Eureka,可以有多个配置服务器实例注册,从而避免单点故障。当我们后来学习 Kubernetes 中的服务概念时,将了解如何通过运行多个容器(例如,配置服务器)来避免单点故障。
保护配置
一般来说,配置信息将被视为敏感信息。这意味着我们需要保护配置信息在传输和静态存储时的安全。从运行时角度看,配置服务器不需要通过边缘服务器暴露给外部。然而,在开发过程中,能够访问配置服务器的 API 来检查配置是有用的。在生产环境中,建议锁定对外部访问配置服务器。
保护传输中的配置
当微服务请求配置信息,或者任何使用配置服务器 API 的人,它将受到边缘服务器的保护,因为它已经使用 HTTPS 来防止窃听。
为了确保 API 用户是已知的客户端,我们将使用 HTTP 基本认证。我们可以在配置服务器中使用 Spring Security 来设置 HTTP 基本认证,并通过指定环境变量SPRING_SECURITY_USER_NAME和SPRING_SECURITY_USER_PASSWORD来指定许可凭证。
静态保护配置
为了避免任何可以访问配置仓库的人窃取敏感信息,如密码,配置服务器支持在磁盘上存储配置信息时进行加密。配置服务器支持使用对称和非对称密钥。非对称密钥更安全,但更难管理。
在本章中,我们将使用对称密钥。对称密钥在配置服务器启动时通过指定环境变量ENCRYPT_KEY赋予配置服务器。加密的密钥只是一个需要像任何敏感信息一样保护的普通文本字符串。
要了解关于非对称密钥的使用,请参阅cloud.spring.io/spring-cloud-static/spring-cloud-config/2.1.0.RELEASE/single/spring-cloud-config.html#_key_management。
介绍配置服务器 API
配置服务器暴露了一个 REST API,客户端可以使用它来检索他们的配置。在本章中,我们将使用 API 以下端点:
-
/actuator:所有微服务暴露的标准 actuator 端点。像往常一样,这些应该小心使用。它们在开发过程中非常有用,但在投入生产前必须被锁定。
-
/encrypt和/decrypt:用于加密和解密敏感信息的端点。这些在投入生产前也必须被锁定。 -
/{microservice}/{profile}:返回指定微服务和指定 Spring 配置文件的营养配置。
当我们尝试配置服务器时,将看到 API 的一些示例使用。
设置配置服务器
基于讨论的决定来设置配置服务器是简单的:
-
使用第三章中描述的 Spring Initializr 创建 Spring Boot 项目创建一组协作微服务。参考使用 Spring Initializr 生成骨架代码部分。
-
在 Gradle 构建文件
build.gradle中添加依赖项spring-cloud-config-server和spring-boot-starter-security。 -
在应用类
ConfigServerApplication上添加注解@EnableConfigServer:
@EnableConfigServer
@SpringBootApplication
public class ConfigServerApplication {
- 将配置服务器的配置添加到默认属性文件
application.yml中:
server.port: 8888
spring.cloud.config.server.native.searchLocations: file:${PWD}/config-repo
management.endpoint.health.show-details: "ALWAYS"
management.endpoints.web.exposure.include: "*"
logging.level.root: info
---
spring.profiles: docker
spring.cloud.config.server.native.searchLocations: file:/config-repo
最重要的配置是指定配置仓库的位置,由spring.cloud.config.server.native.searchLocations属性指定。
-
在边缘服务器上添加路由规则,使配置服务器的 API 可以从微服务景观外部访问。
-
在三个 Docker Compose 文件中添加 Dockerfile 和对配置服务器的定义。
-
将敏感配置参数外部化到标准 Docker Compose 环境文件
.env中。 -
在常用的构建文件
settings.gradle中添加配置服务器:
include ':spring-cloud:config-server'
配置服务器 Spring Cloud Configuration 的源代码可以在$BOOK_HOME/Chapter12/spring-cloud/config-server中找到。
现在,让我们更深入地了解一下如何设置路由规则以及如何为在 Docker 中使用配置服务器进行配置。
在边缘服务器上设置路由规则
为了能够从微服务景观外部访问配置服务器的 API,我们在边缘服务器上添加一个路由规则。所有以/config开头的对边缘服务器的请求将被路由到配置服务器,以下路由规则:
- id: config-server
uri: http://${app.config-server}:8888
predicates:
- Path=/config/**
filters:
- RewritePath=/config/(?<segment>.*), /$\{segment}
在前面路由规则中的RewritePath过滤器将从传入 URL 中删除前缀/config,然后将其发送到配置服务器。
有了这个路由规则,我们可以使用配置服务器的 API;例如,运行以下命令,当产品服务使用 Docker Spring 配置文件时,请求其配置:
curl https://dev-usr:dev-pwd@localhost:8443/config/product/docker -ks | jq
当我们尝试配置服务器时,我们将运行前面的命令。
配置配置服务器以与 Docker 一起使用
配置服务器的 Dockerfile 与其他微服务相同,不同之处在于它暴露了端口8888,而不是端口8080。
当涉及到将配置服务器添加到 Docker Compose 文件时,它与我们已经看到的其他微服务有所不同:
config-server:
environment:
- SPRING_PROFILES_ACTIVE=docker,native
- ENCRYPT_KEY=${CONFIG_SERVER_ENCRYPT_KEY}
- SPRING_SECURITY_USER_NAME=${CONFIG_SERVER_USR}
- SPRING_SECURITY_USER_PASSWORD=${CONFIG_SERVER_PWD}
volumes:
- $PWD/config-repo:/config-repo
build: spring-cloud/config-server
mem_limit: 350m
以下是对前述源代码的解释:
-
添加 Spring 配置文件
native,以向配置服务器表明配置仓库是基于普通文件,换句话说,它不是一个 Git 仓库。 -
环境变量
ENCRYPT_KEY用于指定配置服务器用于加密和解密敏感配置信息的对称加密密钥。 -
环境变量
SPRING_SECURITY_USER_NAME和SPRING_SECURITY_USER_PASSWORD用于指定用于保护使用基本 HTTP 认证的 API 的凭据。 -
卷声明将使
config-repo文件夹在 Docker 容器中的/config-repo处可用。
.env文件中定义的前三个环境变量的值由 Docker Compose 从该文件中获取:
CONFIG_SERVER_ENCRYPT_KEY=my-very-secure-encrypt-key
CONFIG_SERVER_USR=dev-usr
CONFIG_SERVER_PWD=dev-pwd
.env文件中存储的信息,即用户名、密码和加密密钥,都是敏感信息,如果用于除开发和测试之外的其他目的,必须加以保护。另外,请注意,失去加密密钥将导致配置仓库中的加密信息无法被解密!
配置配置服务器的客户端
为了使微服务能够从配置服务器获取其配置,我们需要更新微服务。这可以通过以下步骤完成:
-
在 Gradle 构建文件
build.gradle中添加spring-cloud-starter-config和spring-retry依赖项。 -
将配置文件
application.yml移动到配置仓库,并将其重命名为根据spring.application.name属性指定的客户端名称。 -
在
src/main/resources文件夹中添加一个名为bootstrap.yml的文件。该文件包含了连接到配置服务器的配置信息。有关其内容的解释请参考以下内容。 -
在 Docker Compose 文件中添加访问配置服务器的凭据,例如,
product服务:
product:
environment:
- CONFIG_SERVER_USR=${CONFIG_SERVER_USR}
- CONFIG_SERVER_PWD=${CONFIG_SERVER_PWD}
- 禁用在基于 Spring Boot 的自动化测试中使用配置服务器。这可以通过在
@DataMongoTest、@DataJpaTest和@SpringBootTest注解中添加spring.cloud.config.enabled=false来实现。例如,执行以下命令:
@DataMongoTest(properties = {"spring.cloud.config.enabled=false"})
@DataJpaTest(properties = {"spring.cloud.config.enabled=false"})
@SpringBootTest(webEnvironment=RANDOM_PORT, properties = {"eureka.client.enabled=false", "spring.cloud.config.enabled=false"})
配置连接信息
如前所述,src/main/resources/bootstrap.yml文件包含了连接到配置服务器所需的客户端配置。除指定为spring.application.name的属性(在以下示例中设置为product)之外,该文件对所有配置服务器的客户端具有相同的内容:
app.config-server: localhost
spring:
application.name: product
cloud.config:
failFast: true
retry:
initialInterval: 3000
multiplier: 1.3
maxInterval: 10000
maxAttempts: 20
uri: http://${CONFIG_SERVER_USR}:${CONFIG_SERVER_PWD}@${app.config-server}:8888
---
spring.profiles: docker
app.config-server: config-server
此配置将使客户端执行以下操作:
-
当在 Docker 外部运行时,使用
http://localhost:8888URL 连接到配置服务器,当在 Docker 容器中运行时,使用http://config-server:8888URL 连接到配置服务器。 -
使用
CONFIG_SERVER_USR和CONFIG_SERVER_PWD属性的值进行 HTTP 基本认证,作为其用户名和密码。 -
在启动过程中,如果需要,尝试重新连接配置服务器高达 20 次。
-
如果连接尝试失败,客户端将首先等待 3 秒然后尝试重新连接。
-
后续重试的等待时间将增加 1.3 倍。
-
连接尝试之间的最大等待时间为 10 秒。
-
如果客户端在 20 次尝试后仍无法连接到配置服务器,则启动失败。
此配置通常有助于提高配置服务器临时连接问题的弹性。当整个微服务及其配置服务器一次性启动时,它特别有用,例如,使用docker-compose up命令时。在这种情况下,许多客户端将试图在配置服务器准备好之前连接到它,重试逻辑将使客户端在配置服务器启动并运行后成功连接到它。
将分区配置从 Docker Compose 文件移动到配置仓库
docker-compose-partitions.yml和docker-compose-kafka.ymlDocker Compose 文件包含一些用于处理消息代理中的分区的额外配置,分别是 RabbitMQ 和 Kafka。具体请参考第七章、开发反应式微服务中的保证顺序和分区部分。此配置也已移动到集中配置仓库。
例如,在docker-compose-kafka.yml中,读取 Kafka 中产品主题第一个分区的产品消费者的配置如下所示:
product:
environment:
- SPRING_PROFILES_ACTIVE=docker
- MANAGEMENT_HEALTH_RABBIT_ENABLED=false
- SPRING_CLOUD_STREAM_DEFAULTBINDER=kafka
- SPRING_CLOUD_STREAM_BINDINGS_INPUT_CONSUMER_PARTITIONED=true
- SPRING_CLOUD_STREAM_BINDINGS_INPUT_CONSUMER_INSTANCECOUNT=2
- SPRING_CLOUD_STREAM_BINDINGS_INPUT_CONSUMER_INSTANCEINDEX=0
此配置已结构化为多个 Spring 配置文件,以提高可重用性,并移动到配置仓库中的相应配置文件中。添加的 Spring 配置文件如下:
-
streaming_partitioned包含用于在消息代理中启用分区的属性。 -
streaming_instance_0包含从第一个分区消费消息所需的属性。 -
streaming_instance_1包含从第二个分区消费消息所需的属性。 -
kafka包含特定于使用 Kafka 作为消息代理的属性。
以下配置已添加到消息消费者的配置文件中,即产品、评论和推荐服务:
---
spring.profiles: streaming_partitioned
spring.cloud.stream.bindings.input.consumer:
partitioned: true
instanceCount: 2
---
spring.profiles: streaming_instance_0
spring.cloud.stream.bindings.input.consumer.instanceIndex: 0
---
spring.profiles: streaming_instance_1
spring.cloud.stream.bindings.input.consumer.instanceIndex: 1
---
spring.profiles: kafka
management.health.rabbit.enabled: false
spring.cloud.stream.defaultBinder: kafka
以下配置已添加到消息生产者(即产品组合服务)的配置文件中:
---
spring.profiles: streaming_partitioned
spring.cloud.stream.bindings.output-products.producer:
partition-key-expression: payload.key
partition-count: 2
spring.cloud.stream.bindings.output-recommendations.producer:
partition-key-expression: payload.key
partition-count: 2
spring.cloud.stream.bindings.output-reviews.producer:
partition-key-expression: payload.key
partition-count: 2
---
spring.profiles: kafka
management.health.rabbit.enabled: false
spring.cloud.stream.defaultBinder: kafka
现在 Docker Compose 文件更加简洁,只包含访问配置服务器和激活的 Spring 配置文件的凭据列表。例如,产品消费者从 Kafka 的产品主题的第一分区读取消息的配置现在减少到以下内容:
product:
environment:
- SPRING_PROFILES_ACTIVE=docker,streaming_partitioned,streaming_instance_0,kafka
- CONFIG_SERVER_USR=${CONFIG_SERVER_USR}
- CONFIG_SERVER_PWD=${CONFIG_SERVER_PWD}
完整的源代码请参考以下内容:
-
docker-compose-partitions.yml -
docker-compose-kafka.yml -
config-repo/product-composite.yml -
config-repo/product.yml -
config-repo/recommendation.yml -
config-repo/review.yml
结构化配置仓库
将每个客户端的配置文件移动到配置仓库后,我们在许多配置文件中会有一定程度的一致性配置,例如,actuator 端点的配置和如何连接到 Eureka、RabbitMQ 和 Kafka。公共部分已放置在一个名为application.yml的配置文件中,该文件由所有客户端共享。配置仓库包含以下文件:
-
application.yml -
eureka-server.yml -
product-composite.yml -
recommendation.yml -
auth-server.yml -
gateway.yml -
product.yml -
review.yml
配置仓库configuration repository可以在$BOOK_HOME/Chapter12/config-repo找到。
尝试使用 Spring Cloud Configuration 服务器
现在是我们尝试配置服务器的时候了:
-
首先,我们从源代码开始构建,并运行测试脚本来确保一切都能正常运行。
-
接下来,我们将尝试使用配置服务器 API 来获取我们微服务的配置。
-
最后,我们将了解如何加密和解密敏感信息,例如密码。
构建和运行自动化测试
现在我们按照以下方式构建和运行:
- 首先,使用以下命令构建 Docker 镜像:
cd $BOOK_HOME/Chapter12
./gradlew build && docker-compose build
- 接下来,在 Docker 中启动系统架构,并使用以下命令运行常规测试:
./test-em-all.bash start
使用配置服务器 API 获取配置
如前面所述,我们可以通过边缘服务器使用 URL 前缀/config来访问配置服务器的 API。我们还需要提供/.env文件中指定的 HTTP 基本认证凭据。例如,要在产品服务作为 Docker 容器运行时获取配置,即激活了 Spring 配置文件docker,请运行以下命令:
curl https://dev-usr:dev-pwd@localhost:8443/config/product/docker -ks | jq .
预期响应具有以下结构(响应中的许多属性被...替换以提高可读性):
{
"name": "product",
"profiles": [
"docker"
],
...
"propertySources": [
{
"name": "file:/config-repo/product.yml (document #1)",
"source": {
"spring.profiles": "docker",
"server.port": 8080,
...
}
},
{
"name": "file:/config-repo/application.yml (document #1)",
"source": {
"spring.profiles": "docker",
...
}
},
{
"name": "file:/config-repo/product.yml (document #0)",
"source": {
"server.port": 7001,
...
}
},
{
"name": "file:/config-repo/application.yml (document #0)",
"source": {
...
"app.eureka-password": "p",
"spring.rabbitmq.password": "guest"
}
}
]
}
以下是对前面响应的解释:
-
响应包含来自多个属性源的属性,每个属性源对应一个匹配 API 请求的 Spring 配置文件和属性文件。属性源按优先级顺序返回;也就是说,如果一个属性在多个属性源中指定,响应中的第一个属性具有优先权。前面的示例响应包含以下属性源:
-
/config-repo/product.yml,用于dockerSpring 配置文件 -
/config-repo/application.yml,用于dockerSpring 配置文件 -
/config-repo/product.yml,用于defaultSpring 配置文件 -
/config-repo/application.yml,用于defaultSpring 配置文件 docker
-
-
例如,将使用的端口将是
8080,而不是7001,因为在前面的响应中"server.port": 8080位于"server.port": 7001之前。 -
敏感信息(如 Eureka 和 RabbitMQ 的密码)以明文形式返回,例如
"p"和"guest",但它们在磁盘上是加密的。在配置文件application.yml中,它们如下所示:
app:
eureka-password: '{cipher}bf298f6d5f878b342f9e44bec08cb9ac00b4ce57e98316f030194a225fac89fb'
spring.rabbitmq:
password: '{cipher}17fcf0ae5b8c5cf87de6875b699be4a1746dd493a99d926c7a26a68c422117ef'
加密和解密敏感信息
信息可以使用配置服务器暴露的/encrypt和/decrypt端点进行加密和解密。/encrypt端点可用于创建加密值,以便将其放置在配置仓库中的属性文件中。参考前面的示例,其中 Eureka 和 RabbitMQ 的密码以加密形式存储在磁盘上。/decrypt端点可用于验证存储在配置仓库磁盘上的加密信息。
要加密hello world字符串,请运行以下命令:
curl -k https://dev-usr:dev-pwd@localhost:8443/config/encrypt --data-urlencode "hello world"
使用curl调用/encrypt端点时,使用--data-urlencode标志很重要,以确保正确处理'+'等特殊字符。
预期如下形式的响应:

要解密加密值,请运行以下命令:
curl -k https://dev-usr:dev-pwd@localhost:8443/config/decrypt -d 9eca39e823957f37f0f0f4d8b2c6c46cd49ef461d1cab20c65710823a8b412ce
预期hello world字符串作为响应:

如果您想在配置文件中使用加密值,您需要在其前加上{cipher}并将其包裹在''中。例如,要存储hello world的加密版本,请运行以下命令:
my-secret:'{cipher}9eca39e823957f37f0f0f4d8b2c
6c46cd49ef461d1cab20c65710823a8b412ce'
这些测试总结了关于集中配置章节的内容。通过关闭系统架构来结束:
docker-compose down
总结
在本章中,我们看到了如何使用 Spring Cloud Configuration 服务器来集中管理微服务的配置。我们可以将配置文件放在一个共同的配置仓库中,并在一个配置文件中共享公共配置,同时将微服务特定的配置保存在微服务特定的配置文件中。微服务在启动时已更新,可以从配置服务器检索其配置,并配置为在从配置服务器检索配置时处理临时故障。
配置服务器可以通过要求对其 API 进行基本 HTTP 认证的认证使用来保护配置信息,并通过使用 HTTPS 的边缘服务器对外暴露其 API 来防止窃听。为了防止获取到磁盘上配置文件访问权的入侵者获取密码等敏感信息,我们可以使用配置服务器的/encrypt端点来加密信息并将其加密存储在磁盘上。
虽然在外部暴露配置服务器的 API 在开发过程中很有用,但在生产中使用前应该加以限制。
在下一章中,我们将学习如何使用Resilience4j来减轻过度使用同步通信可能带来的潜在缺点。例如,如果我们建立一个使用 REST API 同步调用彼此的微服务链,最后一个微服务停止响应,可能会发生一些坏事,影响到很多涉及的微服务。Resilience4j 带有断路器模式的实现,可以用来处理这类问题。
问题
-
启动时,评审服务期望从配置服务器中调用什么 API 来检索其配置?评审服务是使用以下命令启动的:
docker compose up -d。 -
使用此命令从配置服务器调用 API 期望返回什么配置信息:[完整命令]
curl https://dev-usr:dev-pwd@localhost:8443/config/application/default -ks | jq
-
Spring Cloud Config支持哪些类型的存储后端?
-
我们如何使用 Spring Cloud Config 对磁盘上的敏感信息进行加密?
-
我们如何保护配置服务器的 API 免受滥用?
-
请列举一些首次连接配置服务器与首次连接发现服务器的客户端的优缺点。
第十三章:使用 Resilience4j 提高弹性
在本章中,我们将学习如何使用 Resilience4j 使我们的微服务更具弹性,也就是说,如何减轻和恢复错误。正如我们在第一章微服务介绍,"断路器"部分,和第八章Spring Cloud 介绍,"Resilience4j 以提高弹性"部分,所讨论的,断路器可以用来自动减少一个慢速或无响应的后端微服务在一个大规模的同步微服务景观中所造成的损害。我们将看到 Resilience4j 中的断路器如何与超时和重试机制一起使用,以防止我经验中最为常见的两个错误情况:
-
响应缓慢或根本不响应的微服务
-
请求偶尔会因临时网络问题而失败
本章将涵盖以下主题:
-
介绍 Resilience4j 断路器和重试机制
-
向源代码添加断路器和重试机制
-
尝试使用断路器和重试机制
技术要求
本书中描述的所有命令都是在 MacBook Pro 上使用 macOS Mojave 运行的,但如果你想在其他平台(如 Linux 或 Windows)上运行它们,应该是非常直接的。
在本章中不需要安装任何新工具。
本章的源代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud/tree/master/Chapter13。
为了能够运行本书中描述的命令,将源代码下载到一个文件夹中,并设置一个环境变量$BOOK_HOME,该变量指向该文件夹。一些示例命令如下:
export BOOK_HOME=~/Documents/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud
git clone https://github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud $BOOK_HOME
cd $BOOK_HOME/Chapter13
Java 源代码是为 Java 8 编写的,并在 Java 12 上进行了测试。本章使用 Spring Cloud 2.1.0, SR1(也被称为Greenwich版本),Spring Boot 2.1.4 和 Spring 5.1.6,即在撰写本章时可用的 Spring 组件的最新版本。
所有 Dockerfile 中都使用了openjdk:12.0.2基础 Docker 镜像。
源代码包含以下 Gradle 项目:
-
api -
util -
microservices/product-service -
microservices/review-service -
microservices/recommendation-service -
microservices/product-composite-service -
spring-cloud/eureka-server -
spring-cloud/gateway -
spring-cloud/authorization-server -
spring-cloud/config-server
配置文件可以在 config 仓库中找到,config-repo。
本章中的所有源代码示例均来自$BOOK_HOME/Chapter13中的源代码,但在某些情况下,去除了源代码中不相关部分,例如注释、导入和日志语句。
如果你想查看本章中应用于源代码的变化,即了解使用 Resilience4j 添加弹性所需的内容,你可以与第十二章的集中配置源代码进行比较。你可以使用你喜欢的diff工具,比较两个文件夹,$BOOK_HOME/Chapter12和$BOOK_HOME/Chapter13。
介绍 Resilience4j 电路断路器和重试机制
重试和电路断路器在两个软件组件之间的任何同步通信中都有潜在的用处,例如微服务。由于 Spring Cloud Gateway 目前只支持较旧的断路器 Netflix Hystrix,我们的所有微服务都可以使用 Resilience4j,除了边缘服务器。在本章中,我们将在一个地方应用电路断路器和重试机制,即从product-composite服务调用product服务。以下图表说明了这一点:

请注意,在前面的图表中没有显示其他微服务对发现和配置服务器的同步调用(为了更容易阅读)。
随着本章的写作,一直在进行的工作是为 Spring Cloud 添加一个电路断路器的抽象层,这对 Spring Cloud Gateway 可能是有益的。详情请参阅spring.io/blog/2019/04/16/introducing-spring-cloud-circuit-breaker。
介绍电路断路器
让我们快速回顾一下来自第八章的Spring Cloud 简介中的Resilience4j 改进弹性部分的电路断路器状态图:

电路断路器的键特性如下:
-
如果电路断路器检测到太多故障,它将打开其电路,即不允许新的调用。
-
当电路处于断开状态时,电路断路器将执行快速失败逻辑。这意味着它不是等待新的故障发生,例如超时,在后续调用中发生。相反,它直接将调用重定向到一个回退 方法。回退方法可以应用各种业务逻辑以产生最佳努力响应。例如,回退方法可以从本地缓存返回数据,或者简单地返回一个立即的错误消息。这可以防止微服务在它依赖的服务停止正常响应时变得无响应。在高负载下,这特别有用。
-
过了一段时间后,断路器将变为半开放状态,允许新的调用查看导致失败的问题是否已解决。如果断路器检测到新的失败,它将再次打开电路并回到快速失败逻辑。否则,它将关闭电路并恢复正常操作。这使得微服务能够抵抗故障,而在与其他微服务同步通信的系统架构中,这种能力是不可或缺的!
Resilience4j 以多种方式在运行时暴露有关断路器的信息:
-
可以通过微服务的 actuator
health端点监控断路器的当前状态,即/actuator/health。 -
断路器还会在
actuator端点上发布事件,例如,状态转换、/actuator/circuitbreakerevents。 -
最后,断路器与 Spring Boot 的度量系统集成,并可以使用它将指标发布到监控工具,例如 Prometheus。
在本章中,我们将尝试使用health和event端点。在第二十章“微服务监控”中,我们将看到 Prometheus 的实际应用情况,以及它如何收集由 Spring Boot 暴露出来的指标,例如,我们的断路器中的指标。
为了控制断路器中的逻辑,Resilience4J 可以使用标准 Spring Boot 配置文件进行配置。我们将使用以下配置参数:
-
ringBufferSizeInClosedState:在关闭状态中的调用次数,用于确定电路是否应打开。 -
failureRateThreshold:导致电路打开的失败调用百分比阈值。 -
waitInterval:指定电路保持开放状态的时间长度,即,在过渡到半开放状态之前。 -
ringBufferSizeInHalfOpenState:在半开放状态下用于确定电路是否应再次打开或回到正常、关闭状态的调用次数。 -
automaticTransitionFromOpenToHalfOpenEnabled:确定电路在等待期结束后是否自动变为半开放状态,或者在等待期间等待第一个调用直到变为半开放状态。 -
ignoreExceptions:可以用来指定不应被计为错误的异常。例如,找不到或输入无效的业务异常通常是断路器应该忽略的异常,即,搜索不存在的数据或输入无效输入的用户不应该导致电路打开。
Resilience4j 在关闭状态和半开放状态下使用环形缓冲区跟踪成功和失败的调用,因此有了参数名ringBufferSizeInClosedState和ringBufferSizeInHalfOpenState。
本章将使用以下设置:
-
ringBufferSizeInClosedState = 5和failureRateThreshold = 50%,意味着如果最后五个调用中有三个或更多是故障,那么电路将打开。 -
waitInterval = 10000和automaticTransitionFromOpenToHalfOpenEnabled = true,意味着断路器将保持电路开启 10 秒,然后过渡到半开状态。 -
ringBufferSizeInHalfOpenState = 3,意味着断路器将基于断路器过渡到半开状态后的三个首次调用来决定是否打开或关闭电路。由于failureRateThreshold参数设置为 50%,如果两个或所有三个调用失败,电路将再次打开。否则,电路将关闭。 -
ignoreExceptions = InvalidInputException和NotFoundException,意味着我们的两个业务异常在断路器中不会被视为故障。
引入重试机制
重试机制对于随机和偶尔出现的故障非常有用,例如暂时的网络问题。重试机制可以简单地尝试失败请求多次,每次尝试之间有可配置的延迟。使用重试机制的一个非常重要的限制是,它重试的服务必须是幂等的,也就是说,用相同的请求参数调用服务一次或多次会得到相同的结果。例如,读取信息是幂等的,但创建信息通常不是。你不希望重试机制因为第一次创建订单的响应在网络中丢失而意外地创建两个订单。
当涉及到事件和指标时,Resilience4j 以与断路器相同的方式暴露重试信息,但不提供任何健康信息。重试事件可以在actuator端点,/actuator/retryevents上访问。为了控制重试逻辑,可以使用标准的 Spring Boot 配置文件配置 Resilience4J。我们将使用以下配置参数:
-
maxRetryAttempts: 包括第一次调用在内的重试次数上限 -
waitDuration: 下次重试尝试之前的等待时间 -
retryExceptions: 需要触发重试的异常列表
在本章中,我们将使用以下值:
-
maxRetryAttempts = 3: 我们将最多尝试两次重试。 -
waitDuration= 1000: 我们将在重试之间等待一秒钟。 -
retryExceptions = InternalServerError: 我们只会在遇到InternalServerError异常时触发重试,也就是说,当 HTTP 请求响应码为 500 时。
配置重试和断路器设置时要小心,例如,确保断路器在预期的重试次数完成之前不要打开电路!
在源代码中添加断路器和重试机制
在向源代码中添加断路器和重试机制之前,我们将添加代码,使其能够强制发生错误——要么是延迟,要么是随机故障。然后,我们将添加一个断路器来处理慢速或无响应的 API,以及一个可以处理随机发生故障的重试机制。从 Resilience4j 添加这些功能遵循传统的 Spring Boot 方式:
-
在构建文件中添加一个针对 Resilience4j 的启动依赖。
-
在源代码中添加注解,以在断路器和重试机制应适用的位置使用。
-
添加控制断路器和重试机制行为的配置。
一旦我们实施了断路器和重试机制,我们将扩展我们的测试脚本test-em-all.bash,以包含断路器的测试。
添加可编程延迟和随机错误
为了能够测试我们的断路器和重试机制,我们需要一种控制错误发生时间的方法。实现这一目标的一种简单方法是在 API 中添加可选的查询参数,以检索产品和组合产品。组合产品 API 将参数传递给产品 API。以下查询参数已添加到两个 API 中:
-
delay:导致product微服务的getProductAPI 延迟其响应。参数以秒为单位指定。例如,如果参数设置为3,它将在返回响应之前造成三秒的延迟。 -
faultPercentage:导致product微服务的getProductAPI 以查询参数指定的概率随机抛出异常,从 0 到 100%。例如,如果参数设置为25,它将使平均每四次 API 调用中的第四次失败并抛出异常。在这些情况下,它将返回 HTTP 错误 500 内部服务器错误。
API 定义的更改
我们之前引入的两个查询参数delay和faultPercentage,已在api项目中的以下两个 Java 接口中定义:
se.magnus.api.composite.product.ProductCompositeService:
Mono<ProductAggregate> getCompositeProduct(
@PathVariable int productId,
@RequestParam(value = "delay", required = false, defaultValue =
"0") int delay,
@RequestParam(value = "faultPercent", required = false,
defaultValue = "0") int faultPercent
);
se.magnus.api.core.product.ProductService:
Mono<Product> getProduct(
@PathVariable int productId,
@RequestParam(value = "delay", required = false, defaultValue
= "0") int delay,
@RequestParam(value = "faultPercent", required = false,
defaultValue = "0") int faultPercent
);
产品组合微服务的更改
product-composite 微服务只是将参数传递给产品 API。服务实现接收到 API 请求,并将参数传递给调用产品 API 的集成组件:
- 对
se.magnus.microservices.composite.product.services.ProductCompositeServiceImpl类的调用:
public Mono<ProductAggregate> getCompositeProduct(int productId, int delay, int faultPercent) {
return Mono.zip(
...
integration.getProduct(productId, delay, faultPercent),
....
- 对
se.magnus.microservices.composite.product.services.ProductCompositeIntegration类的调用:
public Mono<Product> getProduct(int productId, int delay, int faultPercent) {
URI url = UriComponentsBuilder
.fromUriString(productServiceUrl + "/product/{pid}?delay=
{delay}&faultPercent={fp}")
.build(productId, delay, faultPercent);
return getWebClient().get().uri(url)...
产品微服务的更改
product 微服务在se.magnus.microservices.core.product.services.ProductServiceImpl中实现实际延迟和随机错误生成器,如下所示:
public Mono<Product> getProduct(int productId, int delay, int faultPercent) {
if (delay > 0) simulateDelay(delay);
if (faultPercent > 0) throwErrorIfBadLuck(faultPercent);
...
}
延迟函数simulateDelay()使用Thread.sleep()函数来模拟延迟:
private void simulateDelay(int delay) {
LOG.debug("Sleeping for {} seconds...", delay);
try {Thread.sleep(delay * 1000);} catch (InterruptedException e) {}
LOG.debug("Moving on...");
}
随机错误生成器throwErrorIfBadLuck()创建一个在1和100之间的随机数,如果它等于或大于指定的故障百分比,则抛出异常:
private void throwErrorIfBadLuck(int faultPercent) {
int randomThreshold = getRandomNumber(1, 100);
if (faultPercent < randomThreshold) {
LOG.debug("We got lucky, no error occurred, {} < {}",
faultPercent, randomThreshold);
} else {
LOG.debug("Bad luck, an error occurred, {} >= {}",
faultPercent, randomThreshold);
throw new RuntimeException("Something went wrong...");
}
}
private final Random randomNumberGenerator = new Random();
private int getRandomNumber(int min, int max) {
if (max < min) {
throw new RuntimeException("Max must be greater than min");
}
return randomNumberGenerator.nextInt((max - min) + 1) + min;
}
添加断路器
正如我们之前提到的,我们需要添加依赖项、注解和配置。我们还需要添加一些处理超时和回退逻辑的代码。我们将在接下来的章节中看到如何进行操作。
向构建文件添加依赖项
要在电路中添加断路器,我们必须在构建文件build.gradle中添加对适当 Resilience4j 库的依赖:
ext {
resilience4jVersion = "0.14.1"
}
dependencies {
implementation("io.github.resilience4j:resilience4j-spring-
boot2:${resilience4jVersion}")
implementation("io.github.resilience4j:resilience4j-
reactor:${resilience4jVersion}")
...
添加断路器和超时逻辑
断路器可以通过在期望其保护的方法上使用@CircuitBreaker(name="nnn")注解来应用,这里是指se.magnus.microservices.composite.product.services.ProductCompositeIntegration类中的getProduct()方法。断路器是由异常触发的,而不是由超时本身触发的。为了能够在超时后触发断路器,我们必须添加在超时后生成异常的代码。使用基于 Project Reactor 的WebClient,我们可以通过使用其timeout(Duration)方法方便地做到这一点。源代码如下所示:
@CircuitBreaker(name = "product")
public Mono<Product> getProduct(int productId, int delay, int faultPercent) {
...
return getWebClient().get().uri(url)
.retrieve().bodyToMono(Product.class).log()
.onErrorMap(WebClientResponseException.class, ex ->
handleException(ex))
.timeout(Duration.ofSeconds(productServiceTimeoutSec));
}
断路器的名称"product"用于标识我们将要通过的配置。超时参数productServiceTimeoutSec作为可配置参数值注入到构造函数中:
private final int productServiceTimeoutSec;
@Autowired
public ProductCompositeIntegration(
...
@Value("${app.product-service.timeoutSec}") int productServiceTimeoutSec
) {
...
this.productServiceTimeoutSec = productServiceTimeoutSec;
}
要激活断路器,必须作为 Spring Bean 调用注解方法。在我们的情况下,是 Spring 将集成类注入到服务实现类中,因此作为 Spring Bean 使用:
private final ProductCompositeIntegration integration;
@Autowired
public ProductCompositeServiceImpl(... ProductCompositeIntegration integration) {
this.integration = integration;
}
public Mono<ProductAggregate> getCompositeProduct(int productId, int delay, int faultPercent) {
return Mono.zip(..., integration.getProduct(productId, delay, faultPercent), ...
添加快速失败回退逻辑
为了在断路器打开时应用回退逻辑,即在请求快速失败时,我们可以捕获断路器打开时抛出的CircuitBreakerOpenException异常,并调用回退方法。这必须在断路器之外完成,即在调用者中。在我们的情况下,是product-composite服务的实现调用集成类。
在这里,我们使用onErrorReturn方法在捕获CircuitBreakerOpenException时调用getProductFallbackValue()方法:
public Mono<ProductAggregate> getCompositeProduct(int productId, int delay, int faultPercent) {
return Mono.zip(
...
integration.getProduct(productId, delay, faultPercent)
.onErrorReturn(CircuitBreakerOpenException.class,
getProductFallbackValue(productId)),
...
回退逻辑可以根据从替代来源获取的产品productId查找信息,例如,内部缓存。在我们的情况下,除非productId是13,否则我们返回一个硬编码的值;否则,我们抛出一个未找到异常:
private Product getProductFallbackValue(int productId) {
if (productId == 13) {
throw new NotFoundException("Product Id: " + productId + " not
found in fallback cache!");
}
return new Product(productId, "Fallback product" + productId,
productId, serviceUtil.getServiceAddress());
}
添加配置
最后,断路器的配置添加到配置存储库中的product-composite.yml文件中,如下所示:
app.product-service.timeoutSec: 2
resilience4j.circuitbreaker:
backends:
product:
registerHealthIndicator: true
ringBufferSizeInClosedState: 5
failureRateThreshold: 50
waitInterval: 10000
ringBufferSizeInHalfOpenState: 3
automaticTransitionFromOpenToHalfOpenEnabled: true
ignoreExceptions:
- se.magnus.util.exceptions.InvalidInputException
- se.magnus.util.exceptions.NotFoundException
配置中的大多数值已经在介绍断路器部分中描述过,除了以下内容:
-
app.product-service.timeoutSec:用于配置我们之前引入的超时。这个设置为两秒。 -
registerHealthIndicator:决定熔断器是否在health端点显示信息。这设置为true。
添加重试机制
与熔断器类似,通过添加依赖项、注解和配置来设置重试机制。依赖项已经在之前添加,所以我们只需要添加注解并设置一些配置。然而,由于重试机制会抛出特定的异常,我们还需要添加一些错误处理逻辑。
添加重试注解
重试机制可以通过注解@Retry(name="nnn")应用于方法,其中nnn是用于此方法的配置条目的名称。关于配置的详细信息,请参见添加配置部分。在我们这个案例中,与熔断器相同,是se.magnus.microservices.composite.product.services.ProductCompositeIntegration类中的getProduct()方法:
@Retry(name = "product")
@CircuitBreaker(name = "product")
public Mono<Product> getProduct(int productId, int delay, int faultPercent) {
处理重试特定异常
通过@Retry注解的方法抛出的异常可以被重试机制用RetryExceptionWrapper异常包装。为了能够处理方法抛出的实际异常,例如在抛出CircuitBreakerOpenException时应用备用方法,调用者需要添加解包RetryExceptionWrapper异常并将它们替换为实际异常的逻辑。
在我们的案例中,是ProductCompositeServiceImpl类中的getCompositeProduct方法使用 Project Reactor API 对Mono对象进行调用。Mono API 有一个方便的方法onErrorMap,可以用来解包RetryExceptionWrapper异常。它被用在getCompositeProduct方法中,如下所示:
public Mono<ProductAggregate> getCompositeProduct(int productId, int delay, int faultPercent) {
return Mono.zip(
...
integration.getProduct(productId, delay, faultPercent)
.onErrorMap(RetryExceptionWrapper.class, retryException ->
retryException.getCause())
.onErrorReturn(CircuitBreakerOpenException.class,
getProductFallbackValue(productId)),
添加配置
重试机制的配置是以与熔断器相同的方式添加的,即在配置存储库中的product-composite.yml文件中,如下所示:
resilience4j.retry:
backends:
product:
maxRetryAttempts: 3
waitDuration: 1000
retryExceptions:
- org.springframework.web.reactive.function.client.WebClientResponseException$InternalServerError
实际值在介绍重试机制部分进行了讨论。
添加自动化测试
已经向test-em-all.bash测试脚本中的单独函数testCircuitBreaker()添加了电路 breaker 的自动化测试:
...
function testCircuitBreaker() {
echo "Start Circuit Breaker tests!"
...
}
...
testCircuitBreaker
echo "End, all tests OK:" `date`
为了能够进行一些必要的验证,我们需要访问product-composite微服务的actuator端点,这些端点不会通过边缘服务器暴露。因此,我们将通过一个独立的 Docker 容器访问actuator端点,这个容器将连接到由 Docker Compose 为我们的微服务设置的内部网络。
默认情况下,网络名称基于放置 Docker Compose 文件的文件夹名称。为了避免这种不确定的依赖关系,在docker-compose文件中定义了一个显式的网络名称my-network。所有容器定义都已更新,以指定它们应附加到my-network网络。以下是来自docker-compose.yml的一个例子:
...
product:
build: microservices/product-service
networks:
- my-network
...
networks:
my-network:
name: my-network
由于容器附属于内部网络,它可以直接访问产品组合的actuator端点,而不需要通过边缘服务器。我们将使用 Alpine 作为 Docker 镜像,并使用wget而不是curl,因为curl默认不包括在 Alpine 发行版中。例如,为了能够找出名为product的电路 breaker 在product-composite微服务中的状态,我们可以运行以下命令:
docker run --rm -it --network=my-network alpine wget product-composite:8080/actuator/health -qO - | jq -r .details.productCircuitBreaker.details.state
命令预期返回值为CLOSED。
由于我们使用--rm标志创建了 Docker 容器,wget命令完成后,Docker 引擎将停止并销毁它。
测试开始执行正好这一点,即在执行测试之前验证断路器是否关闭:
EXEC="docker run --rm -it --network=my-network alpine"
assertEqual "CLOSED" "$($EXEC wget product-composite:8080/actuator/health -qO - | jq -r .details.productCircuitBreaker.details.state)"
接下来,测试将依次运行三个命令,迫使断路器打开,所有这些命令都将因为product服务响应缓慢而失败:
for ((n=0; n<3; n++))
do
assertCurl 500 "curl -k https://$HOST:$PORT/product-
composite/$PROD_ID_REVS_RECS?delay=3 $AUTH -s"
message=$(echo $RESPONSE | jq -r .message)
assertEqual "Did not observe any item or terminal signal within
2000ms" "${message:0:57}"
done
快速重复配置:product服务的超时设置为两秒,因此三秒的延迟将导致超时。当电路断开时,断路器配置为评估最后五个调用。脚本中先于断路器特定测试的测试已经执行了几次成功的调用。失败阈值设置为 50%,即,三次带有三秒延迟的调用足以打开电路。
在电路断开的情况下,我们期望快速失败,也就是说,我们不需要等待超时就能得到响应。我们还期望调用回退方法返回尽力而为的响应。这也适用于正常调用,即,没有请求延迟。以下代码验证了这一点:
assertCurl 200 "curl -k https://$HOST:$PORT/product-composite/$PROD_ID_REVS_RECS?delay=3 $AUTH -s"
assertEqual "Fallback product2" "$(echo "$RESPONSE" | jq -r .name)"
assertCurl 200 "curl -k https://$HOST:$PORT/product-composite/$PROD_ID_REVS_RECS $AUTH -s"
assertEqual "Fallback product2" "$(echo "$RESPONSE" | jq -r .name)"
我们还可以验证模拟未找到错误逻辑在回退方法中按预期工作,即回退方法返回404、NOT_FOUND对于产品 ID 13:
assertCurl 404 "curl -k https://$HOST:$PORT/product-composite/$PROD_ID_NOT_FOUND $AUTH -s"
assertEqual "Product Id: $PROD_ID_NOT_FOUND not found in fallback cache!" "$(echo $RESPONSE | jq -r .message)"
如配置所示,断路器在10秒后会将其状态更改为半打开。为了能够验证这一点,测试等待10秒:
echo "Will sleep for 10 sec waiting for the CB to go Half Open..."
sleep 10
在验证预期状态(半关闭)后,测试运行三个正常请求,使断路器回到正常状态,这也得到了验证:
assertEqual "HALF_OPEN" "$($EXEC wget product-composite:8080/actuator/health -qO - | jq -r .details.productCircuitBreaker.details.state)"
for ((n=0; n<3; n++))
do
assertCurl 200 "curl -k https://$HOST:$PORT/product-
composite/$PROD_ID_REVS_RECS $AUTH -s"
assertEqual "product name C" "$(echo "$RESPONSE" | jq -r .name)"
done
assertEqual "CLOSED" "$($EXEC wget product-composite:8080/actuator/health -qO - | jq -r .details.productCircuitBreaker.details.state)"
快速重复配置:断路器在半打开状态下配置为评估前三个调用。因此,我们需要运行三个请求,其中超过 50%的成功率,然后电路才会关闭。
测试通过使用由断路器暴露出的/actuator/circuitbreakereventsactuator API 结束,该 API 用于揭示内部事件。例如,它可以用来找出断路器执行了哪些状态转换。我们期望最后三个状态转换如下:
-
首先状态转换:从关闭到开放
-
下一个状态转换:从开放到半关闭
-
最后状态转换:从半关闭到关闭
这由以下代码验证:
assertEqual "CLOSED_TO_OPEN" "$($EXEC wget product-composite:8080/actuator/circuitbreakerevents/product/STATE_TRANSITION -qO - | jq -r .circuitBreakerEvents[-3].stateTransition)"
assertEqual "OPEN_TO_HALF_OPEN" "$($EXEC wget product-composite:8080/actuator/circuitbreakerevents/product/STATE_TRANSITION -qO - | jq -r .circuitBreakerEvents[-2].stateTransition)"
assertEqual "HALF_OPEN_TO_CLOSED" "$($EXEC wget product-composite:8080/actuator/circuitbreakerevents/product/STATE_TRANSITION -qO - | jq -r .circuitBreakerEvents[-1].stateTransition)"
jq表达式circuitBreakerEvents[-1]意味着数组中的最后一个事件[-2]是倒数第二个事件,而[-3 ]是倒数第三个事件。它们一起是三个最新的事件,即我们感兴趣的事件。默认情况下,Resilience4j 为每个断路器保持最后 100 个事件。这可以通过eventConsumerBufferSize配置参数进行自定义。
我们在测试脚本中添加了许多步骤,但有了这个,我们可以自动验证我们断路器预期的基本行为是否到位。在下一节,我们将尝试它!
尝试断路器和重试机制
现在,是尝试断路器和重试机制的时候了。我们将像往常一样开始,构建 Docker 镜像并运行测试脚本test-em-all.bash。之后,我们将手动运行我们之前描述的测试,以确保我们了解发生了什么!我们将执行以下手动测试:
-
断路器的快乐日测试,也就是说,验证在正常操作中断路器是关闭的
-
断路器的负面测试,也就是说,当事情开始出错时,验证断路器是否会打开
-
恢复正常操作,也就是说,一旦问题解决,验证断路器是否回到了关闭状态
-
尝试带有随机错误的的重试机制
构建和运行自动化测试
为了构建和运行自动化测试,我们需要做以下工作:
- 首先,使用以下命令构建 Docker 镜像:
cd $BOOK_HOME/Chapter13
./gradlew build && docker-compose build
- 接下来,在 Docker 中启动系统架构并使用以下命令运行常规测试:
./test-em-all.bash start
当测试脚本打印出Start Circuit Breaker tests!时,我们之前描述的测试被执行!
验证在正常操作中断路器是关闭的
在我们能够调用 API 之前,我们需要一个访问令牌。运行以下命令以获取访问令牌:
unset ACCESS_TOKEN
ACCESS_TOKEN=$(curl -k https://writer:secret@localhost:8443/oauth/token -d grant_type=password -d username=magnus -d password=password -s | jq -r .access_token)
echo $ACCESS_TOKEN
尝试一个正常请求并验证它返回 HTTP 响应代码200:
curl -H "Authorization: Bearer $ACCESS_TOKEN" -k https://localhost:8443/product-composite/2 -w "%{http_code}\n" -o /dev/null -s
-w "%{http_code}\n"选项用于打印 HTTP 返回状态。只要命令返回200,我们就对响应体不感兴趣,因此使用该选项抑制它,即-o /dev/null。
使用healthAPI 验证断路器是否关闭:
docker run --rm -it --network=my-network alpine wget product-composite:8080/actuator/health -qO - | jq -r .details.productCircuitBreaker.details.state
我们期望它响应CLOSED。
当事情出错时强制打开断路器
现在,是让事情变糟的时候了!我的意思是,是时候尝试一些负测试,以验证当事情开始出错时电路是否会打开。调用 API 三次,并将product服务导致超时,即每次调用延迟3秒的响应。这应该足以触发断路器:
curl -H "Authorization: Bearer $ACCESS_TOKEN" -k https://localhost:8443/product-composite/2?delay=3 -s | jq .
我们期望每次都得到如下响应:
{
"timestamp": "2019-05-03T15:12:57.554+0000",
"path": "/product-composite/2",
"status": 500,
"error": "Internal Server Error",
"message": "Did not observe any item or terminal signal within 2000ms
in 'onErrorResume' (and no fallback has been configured)"
}
断路器现在打开了,所以如果你在waitInterval内尝试第四次(即10秒),你会看到快速失败的响应和回退方法的行动。你将立即得到响应,而不是在2秒超时触发后得到错误消息:
{
"productId": 2,
"name": "Fallback product2",
...
}
响应将来自回退方法。这可以通过查看 name 字段中的值来识别,即Fallback product2。
快速失败和回退方法是断路器的关键能力!
鉴于我们的配置中设置的等待时间仅为 10 秒,这要求你必须非常迅速,才能看到快速失败和回退方法在行动中!处于半开启状态时,你总是可以提交三个新的请求导致超时,迫使断路器回到开启状态,然后迅速尝试第四个请求。然后,你应该从回退方法中得到一个快速失败的响应!你也可以将等待时间增加到一两分钟,但等待这么长时间才能看到电路切换到半开启状态可能会相当无聊。
等待 10 秒钟,让断路器切换到半开启状态,然后运行以下命令验证电路现在是否处于半开启状态:
docker run --rm -it --network=my-network alpine wget product-composite:8080/actuator/health -qO - | jq -r .details.productCircuitBreaker.details.state
预期它会响应HALF_OPEN。
再次关闭断路器
一旦断路器处于半开启状态,它等待三个调用以确定它应该再次打开电路还是恢复正常,即关闭它。
让我们提交三个普通请求来关闭断路器:
curl -H "Authorization: Bearer $ACCESS_TOKEN" -k https://localhost:8443/product-composite/2 -w "%{http_code}\n" -o /dev/null -s
它们都应该响应200。通过使用health API 验证电路是否再次关闭:
docker run --rm -it --network=my-network alpine wget product-composite:8080/actuator/health -qO - | jq -r .details.productCircuitBreaker.details.state
我们期望它响应为CLOSED。
用以下命令列出最后三个状态转换:
docker run --rm -it --network=my-network alpine wget product-composite:8080/actuator/circuitbreakerevents/product/STATE_TRANSITION -qO - | jq -r '.circuitBreakerEvents[-3].stateTransition, .circuitBreakerEvents[-2].stateTransition, .circuitBreakerEvents[-1].stateTransition'
预期它会响应以下命令:
CLOSED_TO_OPEN
OPEN_TO_HALF_OPEN
HALF_OPEN_TO_CLOSED
这个响应告诉我们,我们已经将我们的断路器带遍了它的状态图:
-
当错误开始阻止请求成功时,从关闭状态变为开启状态
-
从开启状态变为半开启状态,以查看错误是否消失
-
当错误消失时,即当我们恢复正常操作时,从半开启状态变为关闭状态
尝试由随机错误引起的重试
让我们模拟我们的product服务或与其通信存在一个-希望是暂时的-随机问题。
我们可以通过使用faultPercent参数来实现。如果我们将其设置为25,我们期望每个第四个请求都会失败。我们希望重试机制会自动重试请求来帮助我们。注意到重试机制已经启动的一个方法是测量curl命令的响应时间。正常响应应该不会超过 100 毫秒。由于我们配置了重试机制等待一秒钟(参见前面的重试机制中的waitDuration参数),我们期望每次重试尝试的响应时间会增加一秒钟。要强制发生随机错误,多次运行以下命令:
time curl -H "Authorization: Bearer $ACCESS_TOKEN" -k https://localhost:8443/product-composite/2?faultPercent=25 -w "%{http_code}\n" -o /dev/null -s
命令应当返回200状态码,表示请求成功。响应时间前缀为real的,例如real 0m0.078s,意味着响应时间为 0.078 秒或 78 毫秒。正常的响应,即没有进行任何重试的响应,应该如下所示:
200
real 0m0.078s
...
一次重试后的响应应该如下所示:
200
real 0m1.077s
HTTP 状态码 200 表示请求已经成功,即使它需要重试一次才能成功!
在你注意到响应时间为一秒之后,即请求需要重试一次才能成功时,运行以下命令来查看最后的两次重试事件:
docker run --rm -it --network=my-network alpine wget product-composite:8080/actuator/retryevents -qO - | jq '.retryEvents[-2], .retryEvents[-1]'
你应该能够看到失败的请求和下一次成功的尝试。creationTime时间戳预计会相差一秒钟。期待如下的响应:
{
"retryName": "product",
"type": "RETRY",
"creationTime": "2019-05-01T05:40:18.458858Z[Etc/UTC]",
"errorMessage": "org.springframework.web.reactive.
function.client.WebClientResponseException$InternalServerError: 500
Internal Server Error",
"numberOfAttempts": 1
}
{
"retryName": "product",
"type": "SUCCESS",
"creationTime": "2019-05-01T05:40:19.471136Z[Etc/UTC]",
"numberOfAttempts": 1
}
如果你真的非常倒霉,你会连续得到两个错误,然后你的响应时间会变成两秒而不是一秒。如果你重复执行前面的命令,你可以看到numberOfAttempts字段对每次重试尝试进行计数,在本例中设置为2:"numberOfAttempts": 2。如果调用继续失败,熔断器将启动并打开其电路,即后续的调用将会快速失败并应用回退方法!
就是这么简单!
随意发挥配置中的参数,以更好地了解熔断器和重试机制!
总结
在本章中,我们看到了 Resilience4j 及其熔断器和重试机制的实际应用。
当熔断器打开时,使用快速失败和fallback方法,可以防止微服务在它依赖的正常响应的同步服务停止响应时变得无响应。熔断器还可以通过在半开状态下允许请求来使微服务具有弹性,以查看失败的服务是否再次正常运行并关闭电路。
重试机制可以使微服务具有弹性,通过重试偶尔由于临时网络问题而失败的请求。非常重要的一点是,只有对幂等性服务应用重试请求,也就是说,可以处理相同请求发送两次或多次的服务。
断路器和重试机制遵循 Spring Boot 约定实现,即声明依赖项,并添加注解和配置。Resilience4j 在运行时通过actuator端点暴露有关其断路器和重试机制的信息,包括断路器和事件以及重试的事件和度量指标。
在本章中,我们看到了健康和事件端点的使用,但我们必须等到第二十章,监控微服务,我们才能使用任何度量指标。
在下一章中,我们将涵盖使用 Spring Cloud 的最后部分,届时我们将学习如何使用 Spring Cloud Sleuth 和 Zipkin 通过一组协作的微服务跟踪调用链。前往第十四章,理解分布式跟踪,开始学习吧!
问题
-
断路器有哪些状态,它们是如何使用的?
-
我们如何处理断路器中的超时错误?
-
当断路器快速失败时,我们如何应用回退逻辑?
-
重试机制和断路器如何相互干扰?
-
提供一个无法应用重试机制的服务示例。
第十四章:理解分布式追踪
在本章中,我们将学习如何使用分布式追踪更好地了解我们的微服务如何协作,例如,对外部 API 发送请求。能够利用分布式追踪对于能够管理相互协作的微服务系统架构至关重要。如已在第八章, Spring Cloud 简介中提到的Spring Cloud Sleuth 和 Zipkin 进行分布式追踪部分所述,Spring Cloud Sleuth 将用于收集追踪信息,而 Zipkin 将用于存储和可视化所述追踪信息。
在本章中,我们将学习以下主题:
-
使用 Spring Cloud Sleuth 和 Zipkin 引入分布式追踪
-
如何将分布式追踪添加到源代码中
-
如何进行分布式追踪:
-
我们将学习如何使用 Zipkin 可视化追踪信息,并与以下内容相关:
-
成功和失败的 API 请求
-
API 请求的同步和异步处理
-
-
我们将同时使用 RabbitMQ 和 Kafka 将微服务中的追踪事件发送到 Zipkin 服务器
-
技术要求
本书中描述的所有命令都是在 MacBook Pro 上使用 macOS Mojave 运行的,但应该很容易修改,以便它们可以在其他平台(如 Linux 或 Windows)上运行。
在本章中不需要安装任何新工具。
本章的源代码可以在 GitHub 上找到,地址为github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud/tree/master/Chapter14。
为了能够按照书中描述运行命令,将源代码下载到一个文件夹中,并设置一个环境变量$BOOK_HOME,使其指向该文件夹。一些示例命令如下:
export BOOK_HOME=~/Documents/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud
git clone https://github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud $BOOK_HOME
cd $BOOK_HOME/Chapter14
该 Java 源代码是为 Java 8 编写的,并在 Java 12 上进行了测试。本章使用 Spring Cloud 2.1.0, SR1(也称为Greenwich版本),Spring Boot 2.1.4 和 Spring 5.1.6,即在撰写本章时可用的 Spring 组件的最新版本。
所有 Dockerfile 中均使用基础 Docker 镜像openjdk:12.0.2。
本章中的所有示例代码均来自$BOOK_HOME/Chapter14的源代码,但在许多情况下,为了删除源代码中不相关部分,例如注释和导入以及日志声明,对其进行了编辑。
如果你想查看本章源代码所做的更改,即了解添加 Spring Cloud Sleuth 和 Zipkin 进行分布式追踪所需的内容,你可以将其与第十三章, 使用 Resilience4j 提高弹性的源代码进行比较。你可以使用你喜欢的diff工具,比较两个文件夹——$BOOK_HOME/Chapter13和$BOOK_HOME/Chapter14。
使用 Spring Cloud Sleuth 和 Zipkin 引入分布式跟踪。
回顾第八章,Spring Cloud 简介,在分布式跟踪的 Spring Cloud Sleuth 和 Zipkin部分,整个工作流程的跟踪信息称为一个跟踪或一个跟踪树,树的子部分,例如工作基本单元,称为一个跨度。跨度可以包括子跨度,形成跟踪树。Zipkin UI 可以如下可视化跟踪树和其跨度:

Spring Cloud Sleuth 可以通过 HTTP 同步发送跟踪信息到 Zipkin,或者使用 RabbitMQ 或 Kafka 等消息代理异步发送。为了避免在微服务中创建对 Zipkin 服务器的运行时依赖,最好使用 RabbitMQ 或 Kafka 异步发送跟踪信息到 Zipkin。以下图表说明了这一点:

Zipkin 支持本地存储跟踪信息,存储在内存中,或存储在 Apache Cassandra、Elasticsearch 或 MySQL 中。此外,还有许多扩展可用。具体信息请参考zipkin.apache.org/pages/extensions_choices.html。在本章中,我们将把跟踪信息存储在内存中。
向源代码添加分布式跟踪。
在本节中,我们将学习如何更新源代码,使用 Spring Cloud Sleuth 和 Zipkin 启用分布式跟踪。可以通过以下步骤完成:
-
向构建文件添加依赖项,以引入 Spring Cloud Sleuth 和将跟踪信息发送到 Zipkin 的能力。
-
为之前未使用过的项目(即 Spring Cloud 项目的
authorization-server、eureka-server和gateway)添加 RabbitMQ 和 Kafka 依赖项。 -
配置微服务使用 RabbitMQ 或 Kafka 将跟踪信息发送到 Zipkin。
-
在 Docker Compose 文件中添加一个 Zipkin 服务器。
-
在
docker-compose-kafka.yml中为 Spring Cloud 项目的authorization-server、eureka-server和gateway添加kafkaSpring 配置文件。
添加 Zipkin 服务器将通过使用 Docker Hub 上由 Zipkin 项目发布的 Docker 镜像来实现。具体细节请参考hub.docker.com/r/openzipkin/zipkin。
Zipkin 本身是一个 Spring Boot 应用程序,在撰写本文时,它正在 Apache 软件基金会(ASF)下孵化。更多信息请参考zipkin.apache.org/。
向构建文件添加依赖项。
为了能够使用 Spring Cloud Sleuth 并发送跟踪信息到 Zipkin,我们需要在 Gradle 项目的构建文件build.gradle中添加几个依赖项:
这通过添加以下两行来实现:
implementation('org.springframework.cloud:spring-cloud-starter-sleuth') implementation('org.springframework.cloud:spring-cloud-starter-zipkin')
对于尚未使用过 RabbitMQ 和 Kafka 的 Gradle 项目,即 Spring Cloud 项目authorization-server、eureka-server和gateway,需要添加以下依赖项:
implementation('org.springframework.cloud:spring-cloud-starter-stream-rabbit')
implementation('org.springframework.cloud:spring-cloud-starter-stream-kafka')
为 Spring Cloud Sleuth 和 Zipkin 添加配置
在公共配置文件config-repo/application.yml中添加了使用 Spring Cloud Sleuth 和 Zipkin 的配置。在默认配置文件中,指定跟踪信息应通过 RabbitMQ 发送到 Zipkin:
spring.zipkin.sender.type: rabbit
默认情况下,Spring Cloud Sleuth 只将 10%的跟踪信息发送到 Zipkin。为了确保所有跟踪信息都发送到 Zipkin,在默认配置文件中添加了以下属性:
spring.sleuth.sampler.probability: 1.0
当使用 Kafka 将跟踪信息发送到 Zipkin 时,将使用kafkaSpring 配置文件。在前几章中,kafkaSpring 配置文件是在特定于组合和核心微服务的配置文件中定义的。在本章中,Spring Cloud 服务也将使用 Kafka 将跟踪信息发送到 Zipkin,因此将kafkaSpring 配置文件移动到公共配置文件config-repo/application.yml中。在kafkaSpring 配置文件中还添加了以下两个属性:
-
spring.zipkin.sender.type: kafka告诉 Spring Cloud Sleuth 使用 Kafka 将跟踪信息发送到 Zipkin。 -
spring.kafka.bootstrap-servers: kafka:9092指定了 Kafka 服务器的所在位置。
总的来说,kafkaSpring 配置文件如下所示:
---
spring.profiles: kafka
management.health.rabbit.enabled: false
spring.cloud.stream.defaultBinder: kafka
spring.zipkin.sender.type: kafka
spring.kafka.bootstrap-servers: kafka:9092
将 Zipkin 添加到 Docker Compose 文件中
正如我们之前提到的,Zipkin 服务器是通过使用已经存在的 Docker 镜像openzipkin/zipkin添加到 Docker Compose 文件中的,该镜像是由 Zipkin 项目发布的。在docker-compose.yml和docker-compose-partitions.yml中,其中使用 RabbitMQ 时,Zipkin 服务器的定义如下所示:
zipkin:
image: openzipkin/zipkin:2.12.9
networks:
- my-network
environment:
- RABBIT_ADDRESSES=rabbitmq
- STORAGE_TYPE=mem
mem_limit: 512m
ports:
- 9411:9411
depends_on:
rabbitmq:
condition: service_healthy
让我们解释一下前面的源代码:
-
Docker 镜像
openzipkin/zipkin的版本被指定为2.12.19版本。 -
环境变量
RABBIT_ADDRESSES=rabbitmq用于指定 Zipkin 使用 RabbitMQ 接收跟踪信息,并且 Zipkin 使用主机名rabbitmq连接到 RabbitMQ。 -
环境变量
STORAGE_TYPE=mem用于指定 Zipkin 将所有跟踪信息保存在内存中。 -
Zipkin 的内存限制增加到 512 MB,而其他容器的内存限制为 350 MB。这是因为 Zipkin 被配置为将所有跟踪信息保存在内存中,所以过了一段时间后,它将比其他容器消耗更多的内存。
-
Zipkin 暴露出 HTTP 端口
9411,供浏览器访问其 Web 用户界面。 -
Docker 将等待启动 Zipkin 服务器,直到 RabbitMQ 服务向 Docker 报告自己运行正常。
虽然这对于将跟踪信息存储在 Zipkin 内存中以进行开发和测试活动来说是可行的,但在生产环境中,Zipkin 应配置为将跟踪信息存储在数据库中,例如 Apache Cassandra、Elasticsearch 或 MySQL。
在docker-compose-kafka.yml中,其中使用了 Kafka,Zipkin 服务器的定义如下所示:
zipkin:
image: openzipkin/zipkin:2.12.9
networks:
- my-network
environment:
- KAFKA_BOOTSTRAP_SERVERS=kafka:9092
- STORAGE_TYPE=mem
mem_limit: 512m
ports:
- 9411:9411
depends_on:
- kafka
让我们详细解释一下前面的源代码:
-
使用 Zipkin 和 Kafka 的配置与之前使用 Zipkin 和 RabbitMQ 的配置相似。
-
主要区别在于使用
KAFKA_BOOTSTRAP_SERVERS=kafka:9092环境变量,该变量用于指定 Zipkin 应使用 Kafka 接收跟踪信息,并且 Zipkin 应通过主机名kafka和端口9092连接到 Kafka。
在docker-compose-kafka.yml中,为 Spring Cloud 服务eureka、gateway和auth-server添加了kafka Spring 配置文件:
environment:
- SPRING_PROFILES_ACTIVE=docker,kafka
这就是使用 Spring Cloud Sleuth 和 Zipkin 添加分布式跟踪所需的一切,所以在下一节让我们试试吧!
尝试分布式跟踪
在源代码中进行了必要的更改后,我们可以尝试分布式跟踪!我们将通过执行以下步骤来实现:
-
构建、启动并验证使用 RabbitMQ 作为队列管理器的系统架构。
-
发送一个成功的 API 请求,看看我们可以找到与这个 API 请求相关的 Zipkin 中的跟踪信息。
-
发送一个失败的 API 请求,看看 Zipkin 中的跟踪信息是什么样子。
-
发送一个成功的 API 请求,触发异步处理,并查看其在 Zipkin 中的跟踪信息表示。
-
调查如何监控通过 RabbitMQ 传递给 Zipkin 的跟踪信息。
-
将队列管理器切换到 Kafka,并重复前面的步骤。
我们将在接下来的部分详细讨论这些步骤。
使用 RabbitMQ 作为队列管理器启动系统架构
让我们启动系统架构。使用以下命令构建 Docker 镜像:
cd $BOOK_HOME/Chapter14
./gradlew build && docker-compose build
使用 Docker 启动系统架构,并使用以下命令运行常规测试:
./test-em-all.bash start
在我们可以调用 API 之前,我们需要一个访问令牌。运行以下命令以获取访问令牌:
unset ACCESS_TOKEN
ACCESS_TOKEN=$(curl -k https://writer:secret@localhost:8443/oauth/token -d grant_type=password -d username=magnus -d password=password -s | jq -r .access_token)
echo $ACCESS_TOKEN
发送一个成功的 API 请求
现在,我们准备发送一个正常的 API 请求。运行以下命令:
curl -H "Authorization: Bearer $ACCESS_TOKEN" -k https://localhost:8443/product-composite/2 -w "%{http_code}\n" -o /dev/null -s
期望命令返回成功的 HTTP 状态码,即 200。
现在我们可以启动 Zipkin UI,查看已经发送到 Zipkin 的跟踪信息:
-
在您的网络浏览器中打开以下 URL:
http://localhost:9411/zipkin/。 -
为了找到我们请求的跟踪信息,请执行以下步骤:
-
选择“服务名称”:gateway。
-
设置排序顺序:最新优先。
-
点击“查找跟踪”按钮。
-
查找跟踪的响应应如下所示:

我们之前的 API 请求的跟踪信息是列表中的第一个。点击它以查看与跟踪相关的详细信息:

在详细的跟踪信息视图中,我们可以观察到以下内容:
-
请求被网关服务接收。
-
它将请求的处理委托给了
product-composite服务。 -
product-composite服务反过来向核心服务发送了三个并行请求:product、recommendation和review。 -
一旦
product-composite服务收到了所有三个核心服务的响应,它就创建了一个复合响应。 -
复合响应通过网关服务返回到调用者。
当使用 Safari 时,我注意到跟踪树并不总是正确渲染。切换到 Chrome 或 Firefox 可以解决此问题。
如果我们点击第一个跨度,网关,我们可以看到更多细节:

这里,我们可以看到我们实际发送的请求:product-composite/2。这在我们分析例如长时间完成的跟踪时非常有价值!
发送一个失败的 API 请求
让我们看看如果我们发起一个失败的 API 请求会怎样,例如,搜索一个不存在的产品:
- 为产品 ID
12345发送 API 请求,并验证它返回了未找到的 HTTP 状态码,即 404:
curl -H "Authorization: Bearer $ACCESS_TOKEN" -k https://localhost:8443/product-composite/12345 -w "%{http_code}\n" -o /dev/null -s
- 在 Zipkin UI 中,回到搜索页面(在网页浏览器中使用后退按钮)并点击“查找跟踪”按钮。你应该会在返回列表的顶部看到失败的请求,用红色标出:

- 点击标记为红色的顶部跟踪:

- 在详细跟踪视图中,我们可以通过颜色编码看到产品服务在调用
product-composite时出了错。点击产品跨度以查看出错详情:

这里,我们可以看到导致错误的请求product/12345以及返回的错误代码和原因:404 Not Found。这在我们分析故障的根本原因时非常有用!
发送一个触发异步处理的 API 请求
在 Zipkin UI 中看到的第三种有趣的请求类型是一个部分处理异步的请求。让我们尝试一个删除请求,其中核心服务中的删除过程是异步完成的。product-composite服务向消息代理的每个核心服务发送一个删除事件,并且每个核心服务都会拾取该删除事件并异步处理它。得益于 Spring Cloud Sleuth,发送到消息代理的事件中添加了跟踪信息,从而实现了对删除请求整体处理的连贯视图。
运行以下命令删除具有产品 ID12345的产品,并验证它返回成功的 HTTP 状态码,200:
curl -X DELETE -H "Authorization: Bearer $ACCESS_TOKEN" -k https://localhost:8443/product-composite/12345 -w "%{http_code}\n" -o /dev/null -s
记住删除操作是幂等的,即即使产品不存在,它也会成功!
在 Zipkin UI 中,回到搜索页面(在 Web 浏览器中使用后退按钮)并点击Find Traces按钮。你应该在返回列表的顶部看到删除请求的跟踪:

点击第一个跟踪以查看其跟踪信息:

在这里,我们可以看到处理删除请求的跟踪信息:
-
请求被
gateway服务接收。 -
它将请求的处理委托给了
product-composite服务。 -
反过来,
product-composite服务在消息代理(本例中为 RabbitMQ)上发布了三个事件。 -
product-composite服务现在完成并返回一个成功的 HTTP 状态码,200,通过网关服务返回到调用者。 -
核心服务
product、recommendation和review接收到删除事件并开始异步处理它们,即彼此独立处理。
要查看更详细的信息,点击产品跨度:

在这里,我们可以看到产品服务被输入通道的事件触发,该事件是从消息代理发送的。
Zipkin UI 包含更多查找感兴趣跟踪的功能!
为了更熟悉 Zipkin UI,尝试使用Annotation Query参数;例如,使用http.path=/product-composite/214或error=401查找因授权失败而失败的请求。注意默认设置为10的Limit参数,这可能会隐藏感兴趣的结果。还要确保Lookback参数不会删除感兴趣的跟踪!
监控通过 RabbitMQ 发送到 Zipkin 的跟踪信息
要监控通过 RabbitMQ 发送到 Zipkin 的跟踪信息,我们可以使用 RabbitMQ 管理 Web UI。在 Web 浏览器中打开以下 URL:http://localhost:15672/#/queues/%2F/zipkin。如果需要,使用用户名guest和密码guest登录。期待一个看起来像以下的网页:

在名为Message Rates的图表中,我们可以看到跟踪消息正在以每秒 1.2 条消息的平均速率发送到 Zipkin。
使用以下命令结束 RabbitMQ 的分布式跟踪测试,关闭系统架构:
docker-compose down
使用 Kafka 作为消息代理
让我们也验证一下我们可以使用 Kafka 而不是 RabbitMQ 向 Zipkin 发送跟踪信息!
使用以下命令启动系统架构:
export COMPOSE_FILE=docker-compose-kafka.yml
./test-em-all.bash start
重复我们在前面章节中执行的命令,当时我们使用 RabbitMQ,并验证您可以在使用 Kafka 时在 Zipkin UI 中看到相同的跟踪信息:
Kafka 不提供像 RabbitMQ 那样的管理 Web UI。因此,我们需要运行一些 Kafka 命令来验证跟踪事件实际上是通过 Kafka 发送到 Zipkin 服务器的:
要在 Docker 容器中运行 Kafka 命令,请参阅第七章 《开发响应式微服务》 中的“每个主题使用两个分区”部分。
- 首先,列出 Kafka 中可用的主题:
docker-compose exec kafka /opt/kafka/bin/kafka-topics.sh --zookeeper zookeeper --list
- 问题:
跟踪事件的具体细节并不重要。Zipkin 服务器为我们整理了信息,并在 Zipkin UI 中使其易于查看。这里的关键是我们可以看到通过 Kafka 发送到 Zipkin 服务器的跟踪事件。
- 接下来,询问发送到
zipkin话题的跟踪事件:
docker-compose exec kafka /opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic zipkin --from-beginning --timeout-ms 1000
- 期待很多与以下类似的长时间运行的请求:

在下一章中,我们将学习容器编排器,特别是 Kubernetes。我们将学习如何使用 Kubernetes 部署和管理微服务,同时提高重要的运行时特性,如可伸缩性、高可用性和弹性。
现在,请关闭系统架构并取消设置COMPOSE_FILE环境变量:
docker-compose down
unset COMPOSE_FILE
这结束了关于分布式跟踪的章节!
摘要
期待找到一个名为zipkin的话题:
Zipkin UI 使识别复杂工作流中的哪个部分导致意外的长时间响应或错误变得非常容易。无论是同步还是异步工作流,都可以通过 Zipkin UI 进行可视化。
在本章中,我们学习了如何使用分布式跟踪来了解微服务如何协同工作。我们还学习了如何使用 Spring Cloud Sleuth 收集跟踪信息,以及如何使用 Zipkin 存储和可视化跟踪信息。
spring.sleuth.sampler.probability配置参数的目的是什么?
-
控制跟踪信息发送到 Zipkin 的配置参数是什么?
-
如何在执行
test-em-all.bash测试脚本后识别最长的运行请求? -
如何推广运行时组件的解耦?我们已经了解到如何在构建文件中添加几个依赖项,并设置一些配置参数。
-
我们如何在第十三章 使用 Resilience4j 提高弹性 中找到被超时中断的请求?
-
当第十三章中引入的断路器Improving Resilience Using Resilience4j打开时,API 请求的跟踪日志是什么样的?
-
我们如何定位因调用者未获得授权而失败的 API?
第三部分:使用 Kubernetes 开发轻量级微服务
本节将帮助你理解 Kubernetes 作为容器化工作负载的运行时平台的重要性。你将学习如何在本地开发环境中设置 Kubernetes,并在 Kubernetes 上部署微服务。最后,你将学习如何使用 Kubernetes 的一些最重要的特性,而不是相应的 Spring Cloud 特性,以提供一个更轻量级的微服务系统架构(例如,更容易维护和管理)。
本节包括以下章节:
-
第十五章,Kubernetes 简介
-
第十六章,在 Kubernetes 中部署我们的微服务
-
第十七章,作为替代实现 Kubernetes 特性
-
第十八章,使用服务网格提高可观测性和管理
-
第十九章,使用 EFK 堆栈进行集中日志管理
-
第二十章,监控微服务
第十五章:介绍 Kubernetes
在本章中,我们将开始学习 Kubernetes,这是在撰写本书时最受欢迎和广泛使用的容器编排器。由于一般容器编排器以及 Kubernetes 本身的内容太多,无法在一章中覆盖,我将重点介绍在我过去几年使用 Kubernetes 时发现最重要的内容。
本章将涵盖以下主题:
-
介绍 Kubernetes 概念
-
介绍 Kubernetes API 对象
-
介绍 Kubernetes 运行时组件
-
创建本地 Kubernetes 集群
-
尝试一个示例部署并熟悉
kubectlKubernetes 命令行工具: -
管理一个 Kubernetes 集群
技术要求
为了在本地与 Kubernetes 合作,我们将使用在 VirtualBox 上运行的 Minikube。我们还将大量使用名为kubectl的 Kubernetes CLI 工具。kubectl随 Docker for macOS 提供,但不幸的是,版本太旧(至少在撰写本章时)。因此,我们需要安装一个新版本。总共我们需要以下内容:
-
Minikube 1.2 或更高版本
-
kubectl1.15 或更高版本 -
VirtualBox 6.0 或更高版本
这些工具可以使用 Homebrew 以下命令安装:
brew install kubectl
brew cask install minikube
brew cask install virtualbox
在安装kubectl后,运行以下命令确保使用新版本的kubectl:
brew link --overwrite kubernetes-cli
安装 VirtualBox 时,它会要求你依赖 VirtualBox 附带的系统扩展:

点击对话框中的“确定”按钮,然后点击下一个对话窗口中的“允许”按钮:

通过以下命令验证安装工具的版本:
kubectl version --client --short
minikube version
vboxmanage --version
期望得到如下响应:

本章的源代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud/tree/master/Chapter15。
为了能够运行本书中描述的命令,你需要将源代码下载到一个文件夹中,并设置一个环境变量$BOOK_HOME,该变量指向该文件夹。一些示例命令如下:
export BOOK_HOME=~/Documents/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud
git clone https://github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud $BOOK_HOME
cd $BOOK_HOME/Chapter15
本章中的所有源代码示例都来自$BOOK_HOME/Chapter15的源代码,并使用 Kubernetes 1.15 进行了测试。
介绍 Kubernetes 概念
在较高层面上,作为容器编排器,Kubernetes 使得运行容器的服务器集群(物理或虚拟)呈现为一个运行容器的巨大逻辑服务器。作为操作员,我们通过使用 Kubernetes API 创建对象来向 Kubernetes 集群声明期望状态。Kubernetes 持续将期望状态与当前状态进行比较。如果检测到差异,它会采取行动确保当前状态与期望状态一致。
Kubernetes 集群的主要目的之一是部署和运行容器,同时也支持使用绿色/蓝色和金丝雀部署等技术实现零停机滚动升级。Kubernetes 可以安排容器,即包含一个或多个并列容器的豆荚,到集群中可用的节点。为了能够监控运行中容器的健康状况,Kubernetes 假定容器实现了存活探针。如果存活探针报告了一个不健康的容器,Kubernetes 将重新启动该容器。容器可以在集群中手动或自动扩展,使用水平自动扩展器。为了优化集群中可用硬件资源的使用,例如内存和 CPU,容器可以配置配额,指明容器需要多少资源。另一方面,可以在命名空间级别指定关于一组容器允许消耗多少资源的上限。随着本章的进行,将介绍命名空间。如果多个团队共享一个 Kubernetes 集群,这尤为重要。
Kubernetes 的另一个主要目的是提供运行豆荚及其容器的服务发现。Kubernetes Service 对象可以定义为服务发现,并且还会负载均衡传入请求到可用的豆荚。Service 对象可以暴露在 Kubernetes 集群的外部。然而,正如我们将看到的,在许多情况下,Ingress 对象更适合处理一组服务的外部传入流量。为了帮助 Kubernetes 查明一个容器是否准备好接受传入请求,容器可以实现一个就绪探针。
内部而言,Kubernetes 集群提供了一个大的扁平化 IP 网络,每个豆荚获得自己的 IP 地址,并且可以独立于它们运行的节点到达所有其他豆荚。为了支持多个网络供应商,Kubernetes 允许使用符合容器网络接口(CNI)规范的网络插件(github.com/containernetworking/cni)。豆荚默认情况下是不隔离的,也就是说,它们接受所有传入请求。支持使用网络策略定义的网络插件可以用来锁定对豆荚的访问,例如,只允许来自同一命名空间中豆荚的流量。
为了使多个团队能够安全地在同一个 Kubernetes 集群上工作,可以应用基于角色的访问控制(RBAC,kubernetes.io/docs/reference/access-authn-authz/rbac/)。例如,管理员可以被授权访问集群级别的资源,而团队成员的访问可以被限制在他们团队拥有的命名空间中创建的资源。
总的来说,这些概念为运行容器提供了一个可扩展、安全、高可用性和弹性的平台。
让我们更深入地了解一下 Kubernetes 中可用的 API 对象,然后看看组成 Kubernetes 集群的运行时组件是什么。
介绍 Kubernetes API 对象
Kubernetes 定义了一个 API,用于管理不同类型的对象或资源,在 API 中也被称为种类。根据我的经验,一些最常用的类型或种类如下:
-
节点: 节点代表集群中的一个服务器,可以是虚拟的或物理的。
-
Pod: Pod 是 Kubernetes 中可部署的最小组件,由一个或多个共置的容器组成。通常,一个 Pod 包含一个容器,但有一些用例通过在 Pod 中运行第二个容器来扩展主容器的功能。在第十八章,使用服务网格提高可观测性和管理,将在 Pod 中运行第二个容器,运行一个边车使主容器加入服务网格。
-
部署:部署用于部署和升级 Pod。部署对象将创建和监控 Pod 的责任交给了副本集。第一次创建部署时,部署对象所做的工作并不多,只是创建了副本集对象。在执行部署的滚动升级时,部署对象的角色更加复杂。
-
副本集:副本集用于确保始终运行指定数量的 Pod。如果一个 Pod 被删除,副本集会用一个新的 Pod 来替换它。
-
服务(Service):服务是一个稳定的网络端点,您可以使用它来连接一个或多个 Pod。服务在 Kubernetes 集群的内部网络中被分配一个 IP 地址和 DNS 名称。服务的 IP 地址在其生命周期内保持不变。发送到服务的请求将通过轮询负载均衡转发到可用的 Pod 之一。默认情况下,服务只通过集群 IP 地址在集群内部暴露。还可以将服务暴露在集群外部,要么在每个节点上专用端口上,要么——更好的方法——通过一个意识到 Kubernetes 的外部负载均衡器,也就是说,它可以自动为服务分配一个公共 IP 地址和/或 DNS 名称。通常,提供 Kubernetes 作为服务的云提供商支持这种负载均衡器。
-
入口(Ingress):入口可以管理 Kubernetes 集群中服务的对外访问,通常使用 HTTP。例如,它可以根据 URL 路径或 HTTP 头(如主机名)将流量路由到底层服务。与其在外部暴露多个服务,使用节点端口或负载均衡器,通常在服务前设置一个入口更为方便。为了处理 Ingress 对象定义的实际通信,必须在集群中运行一个 Ingress 控制器。我们将在后面看到一个 Ingress 控制器的示例。
-
命名空间(Namespace):命名空间用于将资源分组并在某些层面上隔离在 Kubernetes 集群中。资源在其命名空间内的名称必须是唯一的,但命名空间之间不需要唯一。
-
配置映射(ConfigMap):ConfigMap 用于存储容器使用的配置。ConfigMaps 可以映射到运行中的容器作为环境变量或文件。
-
密钥(Secret):此功能用于存储容器使用的敏感数据,例如凭据。密钥可以像 ConfigMaps 一样供容器使用。任何具有对 API 服务器完全访问权限的人都可以访问创建的密钥的值,因此它们并不像名称暗示的那样安全。
-
守护进程集(DaemonSet):这确保在集群的一组节点中每个节点上运行一个 Pod。在第十九章,使用 EFK 堆栈进行集中日志记录,我们将看到一个日志收集器 Fluentd 的示例,它将在每个工作节点上运行。
有关 Kubernetes API 在 v1.15 中涵盖的资源对象列表,请参阅kubernetes.io/docs/reference/generated/kubernetes-api/v1.15/。
以下图表总结了处理传入请求的 Kubernetes 资源:

在前面的图表中,我们可以看到以下内容:
-
两个部署,Deployment A 和 Deployment B,已经部署在具有两个节点的集群上,分别是Node 1和Node 2。
-
Deployment A 包含两个 Pod,Pod A1 和 Pod A2。
-
Deployment B 包含一个 Pod B1。
-
Pod A1 被调度到节点 1。
-
Pod A2 和 Pod B1 被调度到节点 2。
-
每个部署都有一个对应的服务,服务 A 和 服务 B,它们在所有节点上都可用。
-
定义了一个 Ingress 以将传入请求路由到两个服务。
-
客户端通常通过外部负载均衡器向集群发送请求。
这些对象本身并不是运行中的组件;相反,它们是不同类型期望状态的定义。为了将期望状态反映到集群的当前状态,Kubernetes 包含一个由多个运行时组件组成的架构,如下一节所述。
介绍 Kubernetes 运行时组件
一个 Kubernetes 集群包含两种类型的节点:主节点和工作节点。主节点负责管理集群,而工作节点的的主要用途是运行实际的工作负载,例如我们在集群中部署的容器。Kubernetes 由多个运行时组件构成。最重要的组件如下:
-
在主节点上运行的组件构成了控制平面:
-
api-server,控制平面的入口点。它暴露一个 RESTful API,例如,Kubernetes CLI 工具kubectl使用该 API。 -
etcd,一个高可用性和分布式键/值存储,用作所有集群数据的数据库。 -
一个控制器管理器,其中包含多个控制器,这些控制器不断地评估对象在
etcd数据库中定义的期望状态与当前状态。 -
每当期望状态或当前状态发生变化时,负责该类型状态的控制器会采取行动将当前状态移动到期望状态。例如,负责管理 Pod 的复制控制器如果通过 API 服务器添加新的 Pod 或者运行中的 Pod 停止运行,会做出反应并确保新的 Pod 被启动。控制器的一个其他例子是节点控制器。如果一个节点变得不可用,它负责确保在失败节点上运行的 Pod 被重新调度到集群中的其他节点。
-
一个调度器,负责将新创建的 Pod 分配给具有可用能力的节点,例如,在内存和 CPU 方面。可以使用亲和规则来控制 Pod 如何分配到节点。例如,执行大量磁盘 I/O 的 Pod 可以将分配给拥有快速 SSD 磁盘的一组工作节点。可以定义反亲和规则来分离 Pod,例如,避免将来自同一部署的 Pod 调度到同一工作节点。
-
-
在所有节点上运行构成数据平面的组件如下:
-
kubelet,这是一个在节点操作系统中直接作为进程执行而不是作为容器的节点代理。它负责在分配给kubelet运行的节点上运行的 pod 中的容器运行和启动。它充当api-server和其节点上的容器运行时之间的通道。 -
kube-proxy,这是一个网络代理,它使 Kubernetes 中的服务概念成为可能,并能够将请求转发到适当的 pod,通常如果有多个 pod 可用,就会以轮询方式转发。kube-proxy作为 DaemonSet 部署。 -
容器运行时,运行在节点上的容器的软件。通常这是 Docker,但任何实现 Kubernetes 容器运行时接口(CRI)的都可以使用,例如
cri-o(cri-o.io)、containerd(containerd.io/) 或rktlet(github.com/kubernetes-incubator/rktlet)。 -
Kubernetes DNS,这是一个在集群内部网络中使用的 DNS 服务器。服务和 pod 会被分配一个 DNS 名称,而 pod 会被配置使用这个 DNS 服务器来解析内部 DNS 名称。DNS 服务器作为部署对象和服务对象部署。
-
以下图表总结了 Kubernetes 运行时组件:

既然我们已经了解了 Kubernetes 运行时组件以及它们支持什么和运行在什么上,那么接下来让我们使用 Minikube 创建一个 Kubernetes 集群。
使用 Minikube 创建 Kubernetes 集群
现在,我们准备创建一个 Kubernetes 集群!我们将使用 Minikube 创建一个在 VirtualBox 上运行的本地单节点集群。
在创建 Kubernetes 集群之前,我们需要了解一下 Minikube 配置文件、被称为 kubectl 的 Kubernetes CLI 工具以及其使用的上下文。
使用 Minikube 配置文件工作
为了在本地运行多个 Kubernetes 集群,Minikube 带有一个配置文件的概念。例如,如果你想与多个版本的 Kubernetes 一起工作,可以使用 Minikube 创建多个 Kubernetes 集群。每个集群将被分配一个单独的 Minikube 配置文件。Minikube 的大部分命令都接受一个 --profile 标志(或 -p 的简写),可以用来指定哪个 Kubernetes 集群应应用该命令。如果你计划与一个特定的配置文件工作一段时间,还有一个更方便的替代方案,你通过以下命令指定当前配置文件:
minikube profile my-profile
上述命令会将 my-profile 配置文件设置为当前配置文件。
要获取当前配置文件,请运行以下命令:
minikube config get profile
如果没有指定配置文件,既没有使用 minikube profile 命令也没有使用 --profile 选项,那么将使用名为 minikube 的默认配置文件。
有关现有配置文件的信息可以在 ~/.minikube/profiles 文件夹中找到。
使用 Kubernetes CLI,kubectl
kubectl 是 Kubernetes 的命令行工具。一旦建立了一个集群,这通常是管理集群所需的所有工具!
为了管理本章前面描述的 API 对象,kubectl apply命令是您需要了解的唯一命令。它是一个声明性命令,也就是说,作为操作员,我们要求 Kubernetes 应用我们给出的对象定义到命令中。然后由 Kubernetes 决定实际需要执行哪些操作。
许多阅读本书的读者可能熟悉的另一个声明性命令是一个SQL SELECT语句,它从几个数据库表中连接信息。我们只在 SQL 查询中声明期望的结果,而数据库查询优化器则负责决定按什么顺序访问表以及使用哪些索引以最有效的方式检索数据。
在某些情况下,显式告诉 Kubernetes 做什么的命令式语句更受欢迎。一个例子是kubectl delete命令,我们明确告诉 Kubernetes 删除一些 API 对象。也可以使用显式的kubectl create namespace命令方便地创建一个命名空间对象。
重复使用命令式语句会导致它们失败,例如,使用kubectl delete删除两次相同的 API 对象,或者使用kubectl create创建两次相同的命名空间。声明性命令,即使用kubectl apply,在重复使用时不会失败——它只会声明没有变化并退出,不采取任何行动。
以下是一些用于获取关于 Kubernetes 集群信息的一些常用命令:
-
kubectl get显示指定 API 对象的信息。 -
kubectl describe为指定的 API 对象提供更多详细信息。 -
kubectl logs显示容器的日志输出。
我们将在本章及接下来的章节中看到许多这些以及其他kubectl命令的示例!
如果您对如何使用kubectl工具感到困惑,kubectl help和kubectl <command> --help命令始终可用,并提供有关如何使用kubectl工具非常有用的信息。
使用 kubectl 上下文工作
为了能够与多个 Kubernetes 集群一起工作,使用本地 Minikube 或者在本地服务器或云上设置的 Kubernetes 集群,kubectl 带来了上下文(contexts)的概念。上下文是以下内容的组合:
-
Kubernetes 集群
-
用户认证信息
-
默认命名空间
默认情况下,上下文保存在~/.kube/config文件中,但可以通过KUBECONFIG环境变量来更改该文件。在这本书中,我们将使用默认位置,因此我们将使用unset KUBECONFIG命令来取消设置KUBECONFIG。
当在 Minikube 中创建 Kubernetes 集群时,会创建一个与 Minikube 配置文件同名上下文,并将其设置为当前上下文。因此,在 Minikube 中创建集群后发布的kubectl命令将会发送到该集群。
要列出可用的上下文,请运行以下命令:
kubectl config get-contexts
以下是一个示例响应:

第一列中的通配符*标记当前上下文。
只有在集群创建完成后,你才会在前面的响应中看到handson-spring-boot-cloud上下文,下面我们将进行描述。
如果你想要将当前上下文切换到另一个上下文,即与其他 Kubernetes 集群一起工作,请运行以下命令:
kubectl config use-context my-cluster
在前面的示例中,当前上下文将更改为my-cluster。
要更新上下文,例如,切换kubectl使用的默认命名空间,请使用kubectl config set-context命令。
例如,要将当前上下文的默认命名空间更改为my-namespace,请使用以下命令:
kubectl config set-context $(kubectl config current-context) --namespace my-namespace
在前面的命令中,kubectl config current-context用于获取当前上下文的名字。
创建 Kubernetes 集群
要使用 Minikube 创建 Kubernetes 集群,我们需要运行几个命令:
-
取消设置
KUBECONFIG环境变量,以确保kubectl上下文创建在默认配置文件~/.kube/config中。 -
指定要用于集群的 Minikube 配置文件。我们将使用
handson-spring-boot-cloud作为配置文件名。 -
使用
minikube start命令创建集群,我们还可以指定要分配给集群的硬件资源量。为了能够完成本书剩余章节中的示例,请至少为集群分配 10 GB 内存,即 10,240 MB。 -
集群创建完成后,我们将使用 Minikube 的插件管理器来启用 Minikube 自带的 Ingress 控制器和指标服务器。Ingress 控制器和指标将在接下来的两章中使用。
在使用 Minikube 创建 Kubernetes 集群之前,关闭 macOS 上的 Docker 可能是个好主意,以避免内存不足。
运行以下命令来创建 Kubernetes 集群:
unset KUBECONFIG
minikube profile handson-spring-boot-cloud
minikube start \
--memory=10240 \
--cpus=4 \
--disk-size=30g \
--kubernetes-version=v1.15.0 \
--vm-driver=virtualbox
minikube addons enable Ingress
minikube addons enable metrics-server
在前面的命令完成后,你应该能够与集群通信。尝试运行kubectl get nodes命令。它应该响应与以下内容相似的东西:

创建后,集群将在后台初始化自己,在kube-system命名空间中启动多个系统 pods。我们可以通过以下命令监控其进度:
kubectl get pods --namespace=kube-system
一旦启动完成,之前的命令应该报告所有 pods 的状态为运行中,并且 READY 计数应该是1/1,这意味着每个 pods 中的单个容器都在运行中。

我们现在准备采取一些行动!
尝试一个示例部署
那么我们应该如何进行以下操作呢?
-
在我们的 Kubernetes 集群中部署一个基于 NGINX 的简单 web 服务器。
-
对部署应用一些更改:
-
删除一个 pods 并验证 ReplicaSet 创建一个新的。
-
将 web 服务器扩展到三个 pods,以验证 ReplicaSet 填充差距。
-
-
使用具有节点端口的服务的路由将外部流量指向它。
首先,创建一个名为first-attempts的命名空间,并更新kubectl上下文,使其默认使用此命名空间:
kubectl create namespace first-attempts
kubectl config set-context $(kubectl config current-context) --namespace=first-attempts
我们现在可以使用kubernetes/first-attempts/nginx-deployment.yaml文件在命名空间中创建一个 NGINX 部署。这个文件如下所示:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deploy
spec:
replicas: 1
selector:
matchLabels:
app: nginx-app
template:
metadata:
labels:
app: nginx-app
spec:
containers:
- name: nginx-container
image: nginx:latest
ports:
- containerPort: 80
让我们更详细地解释前面的源代码:
-
kind和apiVersion属性用于指定我们正在声明一个部署对象。 -
metadata部分用于描述部署对象,例如,当我们给它一个名字nginx-deploy时。 -
接下来是一个
spec部分,它定义了部署对象的期望状态:-
replicas: 1指定我们希望运行一个 pods。 -
selector部分指定部署如何查找其管理的 pods。在这种情况下,部署将查找具有app标签设置为nginx-app的 pods。 -
template部分用于指定如何创建 pods:-
metadata部分指定了label,app: nginx-app,用于标识 pods,从而匹配选择器。 -
spec部分指定单个容器在 pods 中的创建细节,即name和image以及它使用哪些ports。
-
-
使用以下命令创建部署:
cd $BOOK_HOME/Chapter15
kubectl apply -f kubernetes/first-attempts/nginx-deployment.yaml
让我们看看使用kubectl get all命令我们能得到什么:

如预期那样,我们得到了一个部署、ReplicaSet 和 pods 对象。在短暂的时间后,这主要取决于下载 NGINX Docker 镜像所需的时间,pods 将启动并运行,期望的状态将等于当前状态!
通过以下命令删除 pods 来改变当前状态:
kubectl delete pod --selector app=nginx-app
由于 pods 有一个随机名称(在前面的示例中为nginx-deploy-59b8c5f7cd-mt6pg),pods 是基于设置为nginx-app的app标签来选择的。
运行随后的kubectl get all命令将揭示 ReplicaSet 在几秒钟内检测到期望状态和当前状态之间的差异并处理,即几乎立即启动一个新的 pods。
通过在kubernetes/first-attempts/nginx-deployment.yaml部署文件中将期望的 pods 数量设置为三个副本来改变期望状态。只需重复之前的kubectl apply命令,就可以将更改应用到期望的状态。
快速运行几次kubectl get all命令,以监控 Kubernetes 如何采取行动确保当前状态满足新的期望状态。几秒钟后,将会有两个新的 NGINX pod 启动并运行。期望的状态再次等于具有三个运行中的 NGINX pod 的当前状态。期待看到的响应类似于以下内容:

为了使外部通信能够与 Web 服务器通信,请使用kubernetes/first-attempts/nginx-service.yaml文件创建服务:
apiVersion: v1
kind: Service
metadata:
name: nginx-service
spec:
type: NodePort
selector:
app: nginx-app
ports:
- targetPort: 80
port: 80
nodePort: 30080
让我们更详细地解释前面的源代码:
-
kind和apiVersion属性用于指定我们正在声明一个Service对象。 -
metadata部分用于描述Service对象,例如,给它一个名字:nginx-service。 -
接下来是
spec部分,它定义了Service对象的期望状态:-
使用
type字段,我们指定我们希望是NodePort,即在每个集群节点上的专用端口上可访问的外部服务。这意味着外部调用者可以使用这个端口访问集群中的任何节点的 pods,而不依赖于 pods 实际运行在哪些节点上。 -
选择器由服务用来查找可用的 pods,在我们的案例中,是标记有
app: nginx-app的 pods。 -
最后,
ports如下声明:-
port: 80指定服务将在哪个端口上内部可访问,即在集群内部。 -
nodePort: 30080指定服务将在哪个端口上使用集群中的任何节点对外部可访问。默认情况下,节点端口必须在30000到32767的范围内。 -
targetPort: 80指定请求将在哪个端口上转发到 pods 中。
-
-
此端口范围用于最小化与其他正在使用的端口冲突的风险。在生产系统中,通常会在 Kubernetes 集群前放置一个负载均衡器,保护外部用户既不知道这些端口,也不知道 Kubernetes 集群中节点的 IP 地址。参见第十八章、使用服务网格提高可观测性和管理节的设置 Istio 所需的端口转发,了解有关LoadBalanced Kubernetes 服务的使用。
使用以下命令创建服务:
kubectl apply -f kubernetes/first-attempts/nginx-service.yaml
要查看我们得到了什么,运行kubectl get svc命令。期待如下的响应:

kubectl支持许多 API 对象的简称,作为其全名的替代。例如,在前面的命令中使用了svc而不是完整名称service。
为了尝试这个,我们需要知道我们集群中单个节点的 IP 地址。我们可以通过运行 minikube ip 命令来获取。在我的情况下,它是 192.168.99.116。使用这个 IP 地址和节点端口 30080,我们可以将网页浏览器定向到部署的 Web 服务器。在我的情况下,地址是 http://192.168.99.116:30080。预期如下的响应:

太好了!但是内部集群 IP 地址和端口又如何呢?
验证的一种方法是,在集群内部启动一个小型 pod,我们可以用它从内部运行 curl,也就是说,我们能够使用集群内部的 IP 地址和端口。我们不需要使用 IP 地址,相反,我们可以使用为服务在内部 DNS 服务器上创建的 DNS 名称。DNS 名称的短名称与服务的名称相同,即 nginx-service。
运行以下命令:
kubectl run -i --rm --restart=Never curl-client --image=tutum/curl:alpine --command -- curl -s 'http://nginx-service:80'
前一个命令看起来有点复杂,但它只会做以下事情:
-
基于
tutum/curl:alpineDocker 镜像创建一个小型容器,该镜像包含curl命令。 -
在容器内运行
curl -s 'http://nginx-service:80'命令,并使用-i选项将输出重定向到终端。 -
使用
--rm选项删除 pod。
预期前面命令的输出将包含以下信息(我们这里只展示了响应的一部分):

这意味着 Web 服务器也可以在集群内部访问!
这基本上是我们需要了解的,以便能够部署我们的系统架构。
通过删除包含 nginx 部署的命名空间来结束:
kubectl delete namespace first-attempts
在我们结束关于 Kubernetes 的入门章节之前,我们需要学习如何管理我们的 Kubernetes 集群。
管理 Kubernetes 集群
运行中的 Kubernetes 集群会消耗大量资源,主要是内存。因此,当我们完成在 Minikube 中与 Kubernetes 集群的工作时,我们必须能够挂起它,以释放分配给它的资源。我们还需要知道如何恢复集群,当我们想继续工作时。最终,我们也必须能够永久删除集群,当我们不想再在磁盘上保留它时。
Minikube 带有一个 stop 命令,可以用来挂起一个 Kubernetes 集群。我们用来最初创建 Kubernetes 集群的 start 命令也可以用来从挂起状态恢复集群。要永久删除一个集群,我们可以使用 Minikube 的 delete 命令。
挂起和恢复 Kubernetes 集群
运行以下命令来挂起(即 stop)Kubernetes 集群:
minikube stop
运行以下命令来恢复(即 start)Kubernetes 集群:
minikube start
当恢复一个已经存在的集群时,start 命令会忽略你在创建集群时使用的开关。
在恢复 Kubernetes 集群后,kubectl 上下文将更新为使用此集群,当前使用的命名空间设置为 default。如果你正在使用另一个命名空间,例如我们将在下一章使用的 hands-on 命名空间,即 第十六章,将我们的微服务部署到 Kubernetes,你可以使用以下命令更新 kubectl 上下文:
kubectl config set-context $(kubectl config current-context) --namespace=hands-on
随后的 kubectl 命令将在适用的情况下应用于 hands-on 命名空间。
销毁 Kubernetes 集群
运行以下命令以终止 Kubernetes 集群:
minikube delete --profile handson-spring-boot-cloud
你实际上可以不指定配置文件运行 delete 命令,但我发现指明配置文件更安全。否则,你可能会意外地删除错误的 Kubernetes 集群!
neither the Minikube profile definition under ~/.minikube/profiles/ nor the kubectl context in ~/.kube/config is deleted by this command. If they are no longer required, they can be deleted with the following commands:
rm -r ~/.minikube/profiles/handson-spring-boot-cloud
kubectl config delete-context handson-spring-boot-cloud
kubectl config delete-context 命令会警告你关于删除活动上下文的内容,但是没关系。
我们已经成功学会了如何管理在 Minikube 中运行的 Kubernetes 集群。我们现在知道如何挂起和恢复集群,当不再需要时,我们知道如何永久删除它。
总结
在本章中,我们已经介绍了 Kubernetes 作为容器编排器。Kubernetes 使得运行容器的集群服务器看起来像一个大的逻辑服务器。作为操作员,我们向集群声明一个期望状态,Kubernetes 持续将期望状态与当前状态进行比较。如果它检测到差异,它将采取行动确保当前状态与期望状态相同。
期望的状态通过使用 Kubernetes API 服务器创建资源来声明。Kubernetes 控制器管理器和其控制器对由 API 服务器创建的各种资源做出反应,并采取行动确保当前状态满足新的期望状态。调度器为新生成的容器分配节点,即包含一个或多个容器的 pod。在每个节点上,都有一个代理,kubelet 运行并确保调度到其节点的 pod 正在运行。kube-proxy 充当网络代理,通过将发送到服务的请求转发到集群中可用的 pod,实现服务抽象。外部请求可以由指定节点上可用的节点端口的服务处理,或者通过专用的 Ingress 资源处理。
我们还通过使用 Minikube 和 VirtualBox 创建了一个本地单节点集群来尝试 Kubernetes。使用名为 kubectl 的 Kubernetes CLI 工具,我们部署了一个基于 NGINX 的简单 Web 服务器。我们通过删除 Web 服务器来尝试弹性能力,并观察它自动重建以及通过请求在 Web 服务器上运行三个 Pod 来扩展它。最后,我们创建了一个具有节点端口的服务的服务,并验证了我们可以从集群内外访问它。
最后,我们学会了如何管理在 VirtualBox 上运行的 Minikube 中的 Kubernetes 集群,包括如何休眠、恢复和终止 Kubernetes 集群。
我们现在准备将前面章节中的系统架构部署到 Kubernetes 中。翻到下一章,了解如何进行部署!
问题
-
如果你两次运行相同的
kubectl create命令会发生什么? -
如果你两次运行相同的
kubectl apply命令会发生什么? -
关于问题 1 和 2,为什么它们第二次运行时行为不同?
-
ReplicaSet 的目的是什么,还有哪些资源会创建 ReplicaSet?
-
在 Kubernetes 集群中
etcd的作用是什么? -
容器如何找出同一 Pod 中运行的另一容器的 IP 地址?
-
如果你创建了两个名称相同但在不同命名空间中的部署会发生什么?
-
如果你在两个不同的命名空间中创建了两个名称相同的服务,你会使得这两个服务的创建失败。
第十六章:将我们的微服务部署到 Kubernetes
在本章中,我们将把本书中的微服务部署到 Kubernetes。我们还将学习 Kubernetes 的一些核心特性,例如使用Kustomize为不同的运行时环境配置部署,以及使用 Kubernetes 部署对象进行滚动升级。在那之前,我们需要回顾一下我们如何使用服务发现。由于 Kubernetes 内置了对服务发现的支持,因此似乎没有必要部署我们自己的服务发现,毕竟我们到目前为止一直在使用 Netflix Eureka。
本章将涵盖以下主题:
-
用 Kubernetes
Service对象和kube-proxy替换 Netflix Eureka 进行服务发现 -
使用 Kustomize 准备在不同环境中部署的微服务
-
使用测试脚本的某个版本来测试部署,
test-em-all.bash -
执行滚动升级
-
学习如何回滚一个失败的升级
技术要求
本书中描述的所有命令都是在一个 MacBook Pro 上使用 macOS Mojave 运行的,但如果你想在其他平台(如 Linux 或 Windows)上运行它们,应该很容易进行修改。
本章所需的一个新工具是siege命令行工具,用于基于 HTTP 的负载测试和基准测试。在我们执行滚动升级时,我们将使用siege给 Kubernetes 集群施加一些负载。该工具可以通过 Homebrew 使用以下命令安装:
brew install siege
本章的源代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud/tree/master/Chapter16。
为了能够运行本书中描述的命令,你需要将源代码下载到一个文件夹中,并设置一个环境变量,$BOOK_HOME,该变量指向该文件夹。一些示例命令如下:
export BOOK_HOME=~/Documents/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud
git clone https://github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud $BOOK_HOME
cd $BOOK_HOME/Chapter16
本章中的所有源代码示例都来自$BOOK_HOME/Chapter16的源代码,并且已经使用 Kubernetes 1.15 进行了测试。
如果你想要查看在本章中应用到源代码的变化,也就是说,查看部署到 Kubernetes 上的微服务所需的变化,你可以与第十五章的Kubernetes 入门源代码进行对比。你可以使用你喜欢的diff工具,比较两个文件夹,$BOOK_HOME/Chapter15和$BOOK_HOME/Chapter16。
用 Kubernetes 服务替换 Netflix Eureka
如前章所示,第十五章,Kubernetes 简介,Kubernetes 带有一个基于 Kubernetes Service对象和kube-proxy运行时组件的内置发现服务。这使得不需要部署一个单独的发现服务,如我们前几章中使用的 Netflix Eureka。使用 Kubernetes 发现服务的一个优点是,它不需要像我们与 Netflix Eureka 一起使用的 Netflix Ribbon 这样的客户端库。这使得 Kubernetes 发现服务易于使用,且与微服务基于哪种语言或框架无关。使用 Kubernetes 发现服务的缺点是,它只能在 Kubernetes 环境中运行。然而,由于发现服务基于kube-proxy,后者接受对服务对象 DNS 名称或 IP 地址的请求,因此应该相当简单地用类似的服务替换它,例如另一个容器编排器的捆绑服务。
总结来说,我们将从我们的微服务架构中移除基于 Netflix Eureka 的发现服务器,如图所示:

为了将基于 Netflix Eureka 的发现服务器替换为 Kubernetes 内置的发现服务,已对源代码应用了以下更改:
-
我们已经从配置仓库
config-repo中移除了 Netflix Eureka 和 Ribbon 特定的配置(客户端和服务器)。 -
网关服务中的路由规则已从
config-repo/gateway.yml文件中移除。 -
我们已经移除了 Eureka 服务器项目,即移除了
spring-cloud/eureka-server文件夹。 -
我们已经从 Docker Compose 文件和
settings.gradleGradle 文件中移除了 Eureka 服务器。 -
我们已经在所有 Eureka 客户端的构建文件中移除了对
spring-cloud-starter-netflix-eureka-client的依赖,即build.gradle文件。 -
我们已经从所有 Eureka 客户端集成测试中移除了不再需要的
eureka.client.enabled=false属性设置。 -
网关服务不再使用基于客户端负载均衡器的 Spring Cloud 路由,使用
lb协议。例如,lb://product-composite路由目的地已替换为http://product-composite在config-repo/gateway.yml文件中。 -
微服务和授权服务器使用的 HTTP 端口已从端口
8080(在授权服务器的情况下为端口9999)更改为默认的 HTTP 端口80。这在受影响的每个服务的config-repo中进行了配置,如下所示:
spring.profiles: docker
server.port: 80
我们使用的所有 HTTP 地址都不会因将 Netflix Eureka 替换为 Kubernetes 服务而受到影响。例如,复合服务使用的地址不受影响:
private final String productServiceUrl = "http://product";
private final String recommendationServiceUrl = "http://recommendation";
private final String reviewServiceUrl = "http://review";
这是通过改变微服务和授权服务器所使用的 HTTP 端口为默认的 HTTP 端口80,如前所述来实现的。
使用 Docker Compose 仍然可行,尽管 Netflix Eureka 已经被移除。这可以用来在不将微服务部署到 Kubernetes 的情况下运行其功能测试,例如,与 macOS 上的 Docker 一起运行test-em-all.bash,就像前几章中一样。然而,移除 Netflix Eureka 意味着当我们仅使用 Docker 和 Docker Compose 时,我们不再有一个发现服务。因此,只有在部署到 Kubernetes 时,微服务才能进行扩展。
现在我们已经熟悉了 Kubernetes 服务,接下来让我们看看 Kustomize,这是一个用于自定义 Kubernetes 对象的工具有。
介绍 Kustomize
Kustomize是一个用于创建 Kubernetes 定义文件(即 YAML 文件)的环境特定自定义的工具,例如,用于开发、测试、暂存和生产环境。常见的定义文件存储在一个base文件夹中,而环境特定的添加内容则保存在特定的overlay文件夹中。环境特定的信息可以是以下任意一种:
-
要使用哪个版本的 Docker 镜像
-
要运行的副本数量
-
关于 CPU 和内存的资源配额
每个文件夹中都包含一个kustomization.yml文件,它描述了其内容给 Kustomize。当部署到特定环境时,Kustomize 将从base文件夹和环境特定的overlay文件夹中获取内容,并将组合后的结果发送给kubectl。来自overlay文件夹中的文件属性将覆盖base文件夹中相应的属性,如果有的话。
在本章中,我们将为两个示例环境设置自定义:开发和生产。
$BOOK_HOME/Chapter16下的文件夹结构如下所示:

自 Kubernetes 1.14 起,kubectl自带了对 Kustomize 的内置支持,使用-k标志。正如我们将继续看到的,使用 Kustomize 将服务部署到开发环境,将由kubectl apply -k kubernetes/services/overlays/dev命令完成。
在基础文件夹中设置常见定义
在base文件夹中,我们将为每个微服务都有一个定义文件,但对于资源管理器(MongoDB、MySQL 和 RabbitMQ)则没有。资源管理器只在开发环境中部署到 Kubernetes,并预期在生产环境中运行在 Kubernetes 之外——例如,作为现有本地数据库和消息队列管理服务的一部分,或者作为云上的托管服务。
base 文件夹中的定义文件包含每个微服务的部署对象和服务对象。让我们来看一下 kubernetes/services/base/product.yml 中的典型部署对象。它旨在满足开发环境的需求。它从以下代码开始:
apiVersion: apps/v1
kind: Deployment
metadata:
name: product
spec:
replicas: 1
selector:
matchLabels:
app: product
template:
metadata:
labels:
app: product
spec:
containers:
- name: pro
这部分看起来与前一章中使用的 NGINX 部署完全一样,因此我们不需要再次讨论。第十五章 Kubernetes 简介中的尝试样本部署部分,所以我们不需要再次讨论。
下一部分看起来有点不同:
image: hands-on/product-service
imagePullPolicy: Never
env:
- name: SPRING_PROFILES_ACTIVE
value: "docker"
envFrom:
- secretRef:
name: config-client-credentials
ports:
- containerPort: 80
resources:
limits:
memory: 350Mi
让我们更详细地解释前面的源代码:
-
指定的 Docker 镜像
hands-on/product-service将在我们构建微服务时创建。有关更多信息,请参阅构建 Docker 镜像部分。 -
imagePullPolicy: Never声明告诉 Kubernetes 不要尝试从 Docker 注册表下载 Docker 镜像。有关更多信息,请参阅构建 Docker 镜像部分。 -
SPRING_PROFILES_ACTIVE环境变量被定义为告诉 Spring 应用程序在配置存储库中使用dockerSpring 配置文件。 -
使用秘密
config-client-credentials为容器提供访问配置服务器的凭据。 -
使用的 HTTP 端口是默认的 HTTP 端口
80。 -
定义了资源限制,以将可用内存最大化到 350 MB,这与前面章节中使用 Docker Compose 的方式相同。
部署对象的最后一部分包含存活和就绪探针:
livenessProbe:
httpGet:
scheme: HTTP
path: /actuator/info
port: 80
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 20
successThreshold: 1
readinessProbe:
httpGet:
scheme: HTTP
path: /actuator/health
port: 80
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
successThreshold: 1
让我们更详细地解释前面的源代码:
-
存活探针是基于发送到 Spring Boot Actuator
info端点的 HTTP 请求。这意味着,如果微服务实例处于如此糟糕的状态,以至于无法对发送到轻量级info端点的请求返回 200(OK)状态码,那么是时候让 Kubernetes 重新启动微服务实例了。 -
就绪探针是基于发送到 Spring Boot Actuator
health端点的 HTTP 请求。Kubernetes 只会在微服务实例的health端点返回 HTTP 状态码 200(OK)时发送请求到微服务实例。如果没有返回 200(OK)状态码,通常意味着微服务实例在访问其所依赖的一些资源时存在问题,因此在微服务实例没有在health端点返回 200(OK)时,不向其发送任何请求是有意义的。 -
存活和就绪探针可以通过以下属性进行配置:
-
initialDelaySeconds指定 Kubernetes 在容器启动后等待探针的时间。 -
periodSeconds指定 Kubernetes 发送探针请求之间的时间。 -
timeoutSeconds指定 Kubernetes 等待响应的时间,如果在规定时间内没有响应,则认为探针失败。 -
failureThreshold指定 Kubernetes 在放弃之前尝试失败的次数。对于存活探针,这意味着重启容器。对于就绪探针,这意味着 Kubernetes 将不再向容器发送任何请求。 -
successThreshold指定探针在失败后需要成功尝试的次数才能被认为是成功的。这仅适用于就绪探针,因为如果为存活探针指定,它们必须设置为1。
-
为探针寻找最佳设置可能具有挑战性,也就是说,找到当探针的可用性发生变化时 Kubernetes 能够快速反应以及不过度加载探针请求之间的适当平衡。特别是如果为存活探针配置的值过低,可能导致 Kubernetes 重启刚刚需要一些时间启动的容器,即不需要重启的容器。如果为存活探针设置的值过低,启动大量容器可能会导致很多不必要的重启。在探针上设置配置值过高(除了successThreshold值)会使 Kubernetes 反应变慢,这在开发环境中可能会很烦人。适当的值还取决于可用硬件,这会影响容器的启动时间。对于本书的范围,存活探针的failureThreshold设置为一个高值20,以避免在硬件资源有限的计算机上进行不必要的重启。
kubernetes/services/base/product.yml文件中的服务对象如下所示:
apiVersion: v1
kind: Service
metadata:
name: product
spec:
selector:
app: product
ports:
- port: 80
targetPort: 80
服务对象与我们在上一章第十五章、Kubernetes 简介中的尝试样本部署部分使用的 NGINX 服务对象类似。不同之处在于服务类型是ClusterIP(这是默认类型,因此没有指定)。服务对象将接收端口80上的内部请求,并将它们转发到所选容器的目标端口80。这个规则的唯一例外是通过宿主机的端口NodePort暴露的外部网关微服务,即31443:
apiVersion: v1
kind: Service
metadata:
name: gateway
spec:
type: NodePort
selector:
app: gateway
ports:
- port: 443
nodePort: 31443
targetPort: 8443
最后,我们在base文件夹中有一个将所有内容结合在一起的 Kustomize 文件:
resources:
- auth-server.yml
- config-server.yml
- gateway.yml
- product-composite.yml
- product.yml
- recommendation.yml
- review.yml
- zipkin-server.yml
它简单地列出了 Kustomize 将在base文件夹中使用的 YAML 定义文件。
现在,我们将看看我们如何可以使用这些基本定义与overlay文件夹中的定义一起使用,并了解它们是如何使用kubectl apply命令的-k选项应用的。
将应用程序部署到 Kubernetes 以供开发和测试使用
在本节中,我们将部署用于开发和测试活动的微服务环境,例如系统集成测试。这种环境主要用于功能测试,因此配置为使用最少的系统资源。
由于base文件夹中的部署对象是为开发环境配置的,因此它们在开发的上层叠加中不需要进一步的细化。我们只需要像使用 Docker Compose 一样为 RabbitMQ、MySQL 和 MongoDB 的三个资源管理器添加部署和服务对象。我们将在这三个资源管理器中部署与微服务相同的 Kubernetes 命名空间。下面的图表展示了这一点:

资源管理器的定义文件可以在kubernetes/services/overlays/dev文件夹中找到。
kustomization.yml文件看起来像这样:
bases:
- ../../base
resources:
- mongodb-dev.yml
- rabbitmq-dev.yml
- mysql-dev.yml
它定义了base文件夹作为基础,并添加了我们之前提到的三个资源。
构建 Docker 镜像
通常,我们需要将镜像推送到 Docker 注册表,并配置 Kubernetes 从注册表中拉取镜像。在我们的案例中,我们有一个本地的单节点集群,我们可以通过将 Docker 客户端指向 Minikube 中的 Docker 引擎,然后运行docker-compose build命令,来简化这个过程。这将使 Docker 镜像立即可供 Kubernetes 使用。对于开发,我们将使用latest作为微服务的 Docker 镜像版本。
您可能想知道我们如何更新使用latest Docker 镜像的 pods。
从 Kubernetes 1.15 开始,这非常简单。只需更改代码并重新构建 Docker 镜像,例如使用这里描述的build命令。然后,使用kubectl rollout restart命令更新一个 pods。
例如,如果product服务已更新,运行kubectl rollout restart deploy product命令。
您可以从源代码构建 Docker 镜像,如下所示:
cd $BOOK_HOME/Chapter16
eval $(minikube docker-env)
./gradlew build && docker-compose build
eval $(minikube docker-env)命令使本地 Docker 客户端与 Minikube 中的 Docker 引擎通信,例如,在构建 Docker 镜像时。
docker-compose.yml文件已更新以指定构建的 Docker 镜像的名称。例如,对于product服务,我们有如下内容:
product:
build: microservices/product-service
image: hands-on/product-service
latest是 Docker 镜像名称的默认标签,因此不需要指定。
构建 Docker 镜像后,我们可以开始创建 Kubernetes 资源对象!
部署到 Kubernetes
在我们将微服务部署到 Kubernetes 之前,我们需要创建一个命名空间,所需的 config maps 和 secrets。部署完成后,我们将等待部署运行起来,并验证我们在部署的 pods 和每个 pod 中使用的 Docker 镜像是否符合预期。
创建一个命名空间,hands-on,并将其设置为kubectl的默认命名空间:
kubectl create namespace hands-on
kubectl config set-context $(kubectl config current-context) --namespace=hands-on
所有应用程序配置都保存在由配置服务器管理的配置仓库中。唯一需要存储在配置仓库外的配置信息是连接到配置服务器的凭据和一个加密密钥。加密密钥由配置服务器使用,以保持配置仓库中的敏感信息在磁盘上加密。
我们将把配置仓库存储在一个带有所有敏感信息加密的 config map 中;具体请参阅第十二章,集中配置。连接配置服务器和加密密钥的凭据将存储在两个秘密中,一个用于配置服务器,一个用于其客户端。
为了验证这一点,请执行以下步骤:
- 基于
config-repo文件夹中的文件,使用以下命令创建 config map:
kubectl create configmap config-repo --from-file=config-repo/ --save-config
- 使用以下命令创建配置服务器秘密:
kubectl create secret generic config-server-secrets \
--from-literal=ENCRYPT_KEY=my-very-secure-encrypt-key \
--from-literal=SPRING_SECURITY_USER_NAME=dev-usr \
--from-literal=SPRING_SECURITY_USER_PASSWORD=dev-pwd \
--save-config
- 使用以下命令为配置服务器的客户端创建秘密:
kubectl create secret generic config-client-credentials \
--from-literal=CONFIG_SERVER_USR=dev-usr \
--from-literal=CONFIG_SERVER_PWD=dev-pwd --save-config
由于我们刚刚输入了包含敏感信息的明文命令,例如密码和加密密钥,清除history命令是一个好主意。要清除内存和磁盘上的history命令,请运行history -c; history -w命令。
有关history命令的详细信息,请参阅unix.stackexchange.com/a/416831的讨论。
- 为了避免由于 Kubernetes 下载 Docker 镜像而导致部署缓慢(可能会导致我们之前描述的存活探针重启我们的 pods),请运行以下
docker pull命令以下载镜像:
docker pull mysql:5.7
docker pull mongo:3.6.9
docker pull rabbitmq:3.7.8-management
docker pull openzipkin/zipkin:2.12.9
- 基于
dev覆盖层,使用-k开关激活 Kustomize,如前所述部署开发环境的微服务:
kubectl apply -k kubernetes/services/overlays/dev
- 通过运行以下命令等待部署及其 pods 启动并运行:
kubectl wait --timeout=600s --for=condition=ready pod --all
期望每个命令的响应为deployment.extensions/... condition met。...将被实际部署的名称替换。
- 要查看用于开发的 Docker 镜像,请运行以下命令:
kubectl get pods -o json | jq .items[].spec.containers[].image
响应应类似于以下内容:

我们现在准备好测试我们的部署!
但在我们能做到这一点之前,我们需要经历测试脚本中必须与 Kubernetes 一起使用的更改。
用于与 Kubernetes 一起使用的测试脚本的更改
为了测试部署,我们将像往常一样运行测试脚本,即test-em-all.bash。为了与 Kubernetes 配合工作,电路断路器测试做了一些微小修改。详情请查看testCircuitBreaker()函数。电路断路器测试调用product-composite服务上的actuator端点,以检查其健康状态并获得电路断路器事件访问权限。actuator端点并未对外暴露,因此当使用 Docker Compose 和 Kubernetes 时,测试脚本需要使用不同的技术来访问内部端点:
-
当使用 Docker Compose 时,测试脚本将使用简单的
docker run命令启动一个 Docker 容器,该命令从 Docker Compose 创建的网络内部调用actuator端点。 -
当使用 Kubernetes 时,测试脚本将启动一个 Kubernetes pod,它可以在 Kubernetes 内部运行相应的命令。
让我们看看在使用 Docker Compose 和 Kubernetes 时是如何做到的。
使用 Docker Compose 访问内部 actuator 端点
为 Docker Compose 定义的基本命令如下:
EXEC="docker run --rm -it --network=my-network alpine"
请注意,在每次执行测试命令后,使用--rm选项将容器杀死。
使用 Kubernetes 访问内部 actuator 端点
由于在 Kubernetes 中启动 pod 比启动容器慢,测试脚本将启动一个名为alpine-client的单个 pod,该 pod 将在testCircuitBreaker()函数的开始处启动,并且测试将使用kubectl exec命令在这个 pod 中运行测试命令。这将比为每个测试命令创建和删除一个 pod 要快得多。
启动单个 pod 是在testCircuitBreaker()函数的开始处处理的:
echo "Restarting alpine-client..."
local ns=$NAMESPACE
if kubectl -n $ns get pod alpine-client > /dev/null ; then
kubectl -n $ns delete pod alpine-client --grace-period=1
fi
kubectl -n $ns run --restart=Never alpine-client --image=alpine --command -- sleep 600
echo "Waiting for alpine-client to be ready..."
kubectl -n $ns wait --for=condition=Ready pod/alpine-client
EXEC="kubectl -n $ns exec alpine-client --"
在电路断路器测试的最后,使用以下命令删除 pod:
kubectl -n $ns delete pod alpine-client --grace-period=1
选择 Docker Compose 和 Kubernetes
为了使测试脚本能够与 Docker Compose 和 Kubernetes 一起工作,它假定如果HOST环境变量设置为localhost,则将使用 Docker Compose;否则,它假定将使用 Kubernetes。如下代码所示:
if [ "$HOST" = "localhost" ]
then
EXEC="docker run --rm -it --network=my-network alpine"
else
echo "Restarting alpine-client..."
...
EXEC="kubectl -n $ns exec alpine-client --"
fi
测试脚本中HOST环境变量的默认值是localhost。
一旦设置了EXEC变量,根据测试是在 Docker Compose 还是 Kubernetes 上运行,它将在testCircuitBreaker()测试函数中使用。测试首先通过以下语句验证电路断路器是关闭的:
assertEqual "CLOSED" "$($EXEC wget product-composite:${MGM_PORT}/actuator/health -qO - | jq -r .details.productCircuitBreaker.details.state)"
测试脚本中的最后一步更改是因为我们的服务现在可以在集群内的80端口访问;也就是说,它们不再在8080端口。
如果我们使用过的各种端口看起来令人困惑,请回顾在基础文件夹中设置常用定义部分中服务定义。
测试部署
在启动测试脚本时,我们必须给它运行 Kubernetes 的主机的地址,即我们的 Minikube 实例,以及我们的网关服务监听外部请求的外部端口。可以使用 minikube ip 命令来查找 Minikube 实例的 IP 地址,正如在 在基础文件夹中设置公共定义 部分提到的,我们已经将网关服务的外部 NodePort 31443 分配给了网关服务。
使用以下命令开始测试:
HOST=$(minikube ip) PORT=31443 ./test-em-all.bash
从脚本的输出中,我们将看到 Minikube 实例的 IP 地址的使用,以及如何创建和销毁 alpine-client 容器:

在我们继续查看如何为阶段和生产使用设置相应的环境之前,让我们清理一下我们在开发环境中安装的内容,以节省 Kubernetes 集群中的资源。我们可以通过简单地删除命名空间来实现这一点。删除命名空间将递归删除命名空间中存在的所有资源。
使用以下命令删除命名空间:
kubectl delete namespace hands-on
移除了开发环境之后,我们可以继续设置一个针对阶段和生产的环境。
将微服务部署到 Kubernetes 用于阶段和生产
在这一节中,我们将把微服务部署到一个用于阶段和生产环境的系统中。阶段环境用于进行质量保证(QA)和用户验收测试(UAT),这是将新版本投入生产之前的最后一步。为了验证新版本不仅满足功能性需求,还包括性能、健壮性、可伸缩性和弹性等非功能性需求,阶段环境应尽可能与生产环境相似。
当将服务部署到用于阶段或生产的环境时,与开发或测试相比需要进行许多更改:
-
资源管理器应运行在 Kubernetes 集群之外:从技术上讲,将数据库和队列管理器作为有状态容器在 Kubernetes 上运行以供生产使用是可行的,可以使用
StatefulSets和PersistentVolumes。在撰写本章时,我建议不要这样做,主要是因为对有状态容器的支持相对较新,在 Kubernetes 中尚未得到验证。相反,我建议使用本地或云上的现有数据库和队列管理服务,让 Kubernetes 做它最擅长的事情,即运行无状态容器。对于本书的范围,为了模拟生产环境,我们将使用现有的 Docker Compose 文件,将 MySQL、MongoDB 和 RabbitMQ 作为普通的 Docker 容器在 Kubernetes 之外运行。 -
锁定:
-
出于安全原因,诸如
actuator端点和日志级别等事物需要在生产环境中受到限制。 -
外部暴露的端点也应从安全角度进行审查。例如,配置服务器的访问在生产环境中很可能需要受到限制,但为了方便起见,我们将在本书中将其暴露出来。
-
Docker 镜像标签必须指定,才能跟踪已部署微服务的哪些版本。
-
-
扩大可用资源规模:为了满足高可用性和更高负载的需求,每个部署至少需要运行两个 pods。我们可能还需要增加每个 pods 允许使用的内存和 CPU。为了避免 Minikube 实例中内存耗尽,我们将在每个部署中保留一个 pods,但在生产环境中增加允许的最大内存。
-
建立一个生产就绪的 Kubernetes 集群:这超出了本书的范围,但如果可行,我建议使用领先云服务提供商提供的托管 Kubernetes 服务。在本书的范围内,我们将部署到我们的本地 Minikube 实例。
这并不是在设置生产环境时需要考虑的详尽列表,但这是一个不错的开始。
我们的模拟生产环境将如下所示:

源代码中的更改:
以下更改已应用于源代码,以准备在用于生产的环境中部署:
- 在
config-repo配置仓库中添加了一个名为prod的 Spring 配置文件:
spring.profiles: prod
-
在
prod配置文件中,已添加以下内容: -
运行为普通 Docker 容器的资源管理器 URL:
spring.rabbitmq.host: 172.17.0.1
spring.data.mongodb.host: 172.17.0.1
spring.datasource.url: jdbc:mysql://172.17.0.1:3306/review-db
我们使用172.17.0.1IP 地址来访问 Minikube 实例中的 Docker 引擎。这是在创建 Minikube 时,至少对于版本 1.2 的 Minikube,Docker 引擎的默认 IP 地址。
正在开展的工作是建立一个标准的 DNS 名称,供容器在需要访问它们正在运行的 Docker 主机时使用,但在撰写本章时,这项工作尚未完成。
- 日志级别已设置为警告或更高,即错误或致命。例如:
logging.level.root: WARN
- 通过 HTTP 暴露的
actuator端点仅有info和health端点,这些端点被 Kubernetes 中的存活和就绪探针使用,以及被测试脚本test-em-all.bash使用的circuitbreakerevents端点:
management.endpoints.web.exposure.include: health,info,circuitbreakerevents
-
在生产
overlay文件夹kubernetes/services/overlays/prod中,为每个微服务添加了一个部署对象,并具有以下内容,以便与基本定义合并: -
对于所有微服务,
v1被指定为 Dockerimage标签,并且prod配置文件被添加到活动 Spring 配置文件中。例如,对于product服务,我们有以下内容:
image: hands-on/product-service:v1
env:
- name: SPRING_PROFILES_ACTIVE
value: "docker,prod"
- 对于不将其配置保存在配置仓库中的 Zipkin 和配置服务器,在它们的部署定义中添加了相应的环境变量:
env:
- name: LOGGING_LEVEL_ROOT
value: WARN
- name: MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE
value: "health,info"
- name: RABBIT_ADDRESSES
value: 172.17.0.1
- 最后,
kustomization.yml文件定义了将prod overlay文件夹中的文件合并的patchesStrategicMerge补丁机制,并在base文件夹中指定相应的定义:
bases:
- ../../base
patchesStrategicMerge:
- auth-server-prod.yml
- ...
在实际的生产环境中,我们还应该将 imagePullPolicy: Never 设置更改为 IfNotPresent,即从 Docker 仓库下载 Docker 镜像。但是,由于我们将把生产设置部署到 Minikube 实例,我们在那里手动构建和打标签 Docker 镜像,所以不会更新此设置。
部署到 Kubernetes
为了模拟生产级别的资源管理器,MySQL、MongoDB 和 RabbitMQ 将使用 Docker Compose 在 Kubernetes 外运行。我们像前几章一样启动它们:
eval $(minikube docker-env)
docker-compose up -d mongodb mysql rabbitmq
我们还需要使用以下命令将现有的 Docker 镜像标记为 v1:
docker tag hands-on/auth-server hands-on/auth-server:v1
docker tag hands-on/config-server hands-on/config-server:v1
docker tag hands-on/gateway hands-on/gateway:v1
docker tag hands-on/product-composite-service hands-on/product-composite-service:v1
docker tag hands-on/product-service hands-on/product-service:v1
docker tag hands-on/recommendation-service hands-on/recommendation-service:v1
docker tag hands-on/review-service hands-on/review-service:v1
从这里开始,命令与部署到开发环境非常相似。
我们将使用另一个 Kustomize 覆盖层,并为配置服务器使用不同的凭据,但是,除此之外,它将保持不变(这当然是一件好事!)。我们将使用相同的配置仓库,但配置 Pod 以使用 prod Spring 配置文件,如前所述。按照以下步骤进行操作:
- 创建一个名为
hands-on的命名空间,并将其设置为kubectl的默认命名空间:
kubectl create namespace hands-on
kubectl config set-context $(kubectl config current-context) --namespace=hands-on
- 使用以下命令基于
config-repo文件夹中的文件为配置仓库创建配置映射:
kubectl create configmap config-repo --from-file=config-repo/ --save-config
- 使用以下命令为配置服务器创建密钥:
kubectl create secret generic config-server-secrets \
--from-literal=ENCRYPT_KEY=my-very-secure-encrypt-key \
--from-literal=SPRING_SECURITY_USER_NAME=prod-usr \
--from-literal=SPRING_SECURITY_USER_PASSWORD=prod-pwd \
--save-config
- 使用以下命令为配置服务器的客户端创建密钥:
kubectl create secret generic config-client-credentials \
--from-literal=CONFIG_SERVER_USR=prod-usr \
--from-literal=CONFIG_SERVER_PWD=prod-pwd --save-config
- 将明文加密密钥和密码从命令历史中删除:
history -c; history -w
- 基于
prod覆盖层,使用-k选项激活 Kustomize,如前所述,部署开发环境中的微服务:
kubectl apply -k kubernetes/services/overlays/prod
- 等待部署运行起来:
kubectl wait --timeout=600s --for=condition=ready pod --all
- 为了查看当前用于生产的 Docker 镜像,运行以下命令:
kubectl get pods -o json | jq .items[].spec.containers[].image
响应应该类似于以下内容:

注意 Docker 镜像的 v1 版本!
还要注意,MySQL、MongoDB 和 RabbitMQ 的资源管理器 Pod 已经消失了;这些可以通过 docker-compose ps 命令找到。
运行测试脚本 thest-em-all.bash 以验证模拟的生产环境:
HOST=$(minikube ip) PORT=31443 ./test-em-all.bash
期望得到与针对开发环境运行测试脚本时相同的输出。
执行滚动升级
历史上,更新往往导致被更新组件的短暂停机。在具有越来越多的独立更新彼此的其他组件的系统架构中,由于频繁更新微服务而导致的重复停机是不可接受的。能够在不停机的情况下部署更新变得至关重要。
在本节中,我们将了解如何执行滚动升级,即在不需要任何停机的情况下将微服务更新为其 Docker 镜像的新版本。执行滚动升级意味着 Kubernetes 首先在新 pods 中启动微服务的新版本,当它报告为健康时,Kubernetes 将终止旧的 pods。这确保了在升级期间始终有一个 pods 在运行,准备处理传入的请求。滚动升级能够工作的前提是升级是向后兼容的,这包括与其他服务和数据库结构通信时使用的 API 和消息格式。如果微服务的新版本需要对外部 API、消息格式或数据库结构进行更改,而旧版本无法处理,则无法应用滚动升级。默认情况下,部署对象被配置为执行任何更新作为滚动升级。
为了尝试这个,我们将为product服务创建一个 v2 版本的 Docker 镜像,然后启动一个测试客户端siege,在滚动升级期间每秒提交一个请求。假设测试客户端在升级期间发送的所有请求都会报告 200(OK)。
准备滚动升级
为了准备滚动升级,首先验证我们已经部署了v1版本的产品 pods:
kubectl get pod -l app=product -o jsonpath='{.items[*].spec.containers[*].image} '
预期的输出应该显示 Docker 镜像的v1版本正在使用:

使用以下命令在 Docker 镜像上为product服务创建一个v2标签:
docker tag hands-on/product-service:v1 hands-on/product-service:v2
为了从 Kubernetes 的角度尝试滚动升级,我们不需要在product服务中更改任何代码。部署一个不同于现有版本的 Docker 镜像将启动滚动升级。
为了能够观察到升级期间是否发生停机,我们将使用siege启动低负载负载测试。以下命令启动了一个模拟一个用户(-c1)平均每秒提交一个请求的负载测试(-d1):
siege https://$(minikube ip):31443/actuator/health -c1 -d1
由于测试调用网关的健康端点,它验证了所有服务都是健康的。
你应该收到如下所示的输出:

响应中的有趣部分是 HTTP 状态码,我们期望它始终为200。
也要监控产品 pods 状态的变化,可以使用以下命令:
kubectl get pod -l app=product -w
从 v1 升级到 v2 的产品服务
-
要升级
product服务,请编辑kubernetes/services/overlays/prod/product-prod.yml文件,将image: hands-on/product-service:v1更改为image: hands-on/product-service:v2。 -
使用以下命令应用更新:
kubectl apply -k kubernetes/services/overlays/prod
-
期望命令的响应报告大多数对象保持不变,除了产品部署应报告为更新到
deployment.apps/product configured。 -
Kubernetes 提供了一些简写命令。例如,
kubectl set image deployment/product pro=hands-on/product-service:v2可以用来执行与更新定义文件并运行kubectl apply命令相同的更新。使用kubectl apply命令的一个主要好处是我们可以通过将更改推送到 Git 等版本控制系统的源代码来跟踪更改。如果我们想能够以代码方式处理我们的基础设施,这非常重要。在测试 Kubernetes 集群时,只使用它来测试简写命令,因为这将非常有用。 -
在准备滚动升级部分中启动的
kubectl get pod -l app=product -w命令的输出中,我们将看到一些动作发生。请看以下截图:

-
在这里,我们可以看到现有的 Pod(
ffrdh)最初报告它正在运行,并在启动新的 Pod(t8mcl)后也报告为健康。经过一段时间(在我的案例中是16s),它也被报告为正在运行。在一段时间内,两个 Pod 都会运行并处理请求。经过一段时间,第一个 Pod 被终止(在我的案例中是 2 分钟)。 -
当查看
siege输出时,有时可以在503服务不可用错误方面找到一些错误:

-
这通常发生在旧 Pod 被终止时。在旧 Pod 被 readiness 探针报告为不健康之前,它可以在终止过程中接收到几个请求,即它不再能够处理任何请求时。
-
在第十八章《使用服务网格提高可观测性和管理能力》中,我们将了解如何设置路由规则,以更平滑地将流量从旧容器移动到新容器,而不会导致 503 错误。我们还将了解如何应用重试机制,以防止临时故障影响到最终用户。
-
通过验证 Pod 是否正在使用 Docker 镜像的新
v2版本来完成更新:
kubectl get pod -l app=product -o jsonpath='{.items[*].spec.containers[*].image} '
- 期望的输出显示 Docker 镜像的
v2版本正在使用:

- 在执行此升级后,我们可以继续学习当事情失败时会发生什么。在下一节中,我们将了解如何回滚一个失败的部署。
- 回滚失败的部署
有时,事情并不会按照计划进行,例如,部署和 pods 的升级可能会因各种原因失败。为了演示如何回滚失败的升级,让我们尝试在不创建v3标签的 Docker 镜像的情况下升级到v3!
让我们尝试使用以下简写命令来执行更新:
kubectl set image deployment/product pro=hands-on/product-service:v3
预期kubectl get pod -l app=product -w命令会报告以下变化(在“准备滚动升级”部分启动):

我们可以清楚地看到,新部署的 pods(在我的案例中以m2dtn结尾)因为找不到 Docker 镜像而无法启动,这是预期的。如果我们查看siege测试工具的输出,没有错误报告,只有 200(OK)!在这里,部署挂起,因为它找不到请求的 Docker 镜像,但终端用户没有受到任何影响,因为新 pods 甚至没有启动。
让我们查看 Kubernetes 关于产品部署的历史记录。运行以下命令:
kubectl rollout history deployment product
你将收到如下类似输出:

我们可以猜测修订 2 是最新成功部署的,也就是 Docker 镜像的v2。让我们用以下命令来验证:
kubectl rollout history deployment product --revision=2
在响应中,我们可以看到revision #2带有 Docker 镜像v2:

以下命令可以将部署回滚到revision=2:
kubectl rollout undo deployment product --to-revision=2
预期会有一个确认回滚的响应,如下所示:

在“准备滚动升级”部分启动的kubectl get pod -l app=product -w命令会报告新(不可用)pods 已被rollback命令移除:

我们可以通过验证当前镜像版本仍为v2来结束本章:
kubectl get pod -l app=product -o jsonpath='{.items[*].spec.containers[*].image} '
清理
为了删除我们使用的资源,请运行以下命令:
-
停止
kubectl get pod -l app=product -w命令(用于监控)和siege负载测试程序。 -
删除命名空间:
kubectl delete namespace hands-on
- 关闭运行在 Kubernetes 之外的资源管理器:
eval $(minikube docker-env)
docker-compose down
kubectl delete namespace命令将递归删除命名空间中存在的所有 Kubernetes 资源,docker-compose down命令将停止 MySQL、MongoDB 和 RabbitMQ。删除生产环境后,我们结束了这一章。
摘要
在本章中,我们学习了如何在 Kubernetes 上部署本书中的微服务。我们还介绍了 Kubernetes 的一些核心功能,例如使用 Kustomize 为不同的运行时环境配置部署,使用 Kubernetes 部署对象进行滚动升级,以及如果需要如何回滚失败的更新。为了帮助 Kubernetes 了解何时需要重新启动微服务以及它们是否准备好接收请求,我们实现了生存和就绪探针。
最后,为了能够部署我们的微服务,我们必须用 Kubernetes 内置的发现服务替换 Netflix Eureka。更改发现服务时,没有进行任何代码更改——我们所需要做的就是应用构建依赖项和一些配置的变化。
在下一章中,我们将了解如何进一步利用 Kubernetes 来减少我们需要在 Kubernetes 中部署的支持服务的数量。翻到下一章,了解我们如何消除配置服务器的需求,以及我们的边缘服务器如何被 Kubernetes 入口控制器所替代。
问题
-
为什么我们在将微服务部署到 Kubernetes 时删除了 Eureka 服务器?
-
我们用什么替换了 Eureka 服务器,这次变更如何影响了微服务的源代码?
-
Kustomize 中 base 和 overlay 文件夹是如何使用的?
-
我们如何将配置映射(config map)或机密(secret)中的更改应用到正在运行的 Pod?
-
如果我们正在使用 Docker 镜像的最新标签,那么如何使用新的 Docker 镜像构建来运行正在运行的 Pod?
-
我们可以使用哪些命令来回滚一个失败的部署?
-
存活探针(liveness probes)和就绪探针(readiness probes)的目的是什么?
-
以下服务定义中使用了哪些不同的端口?
apiVersion: v1
kind: Service
spec:
type: NodePort
ports:
- port: 80
nodePort: 30080
targetPort: 8080
第十七章:作为 Spring Cloud Gateway 的替代品实现 Kubernetes 特性
当前微服务架构包含许多实现大规模微服务架构中所需的重要设计模式的支撑服务;例如边缘、配置和授权服务器,以及分布式跟踪服务。详情请见第一章,微服务简介,并参考微服务设计模式小节。在前一章中,我们用 Kubernetes 内置的发现服务替换了基于 Netflix Eureka 的服务发现设计模式。在本章中,我们将通过减少需要部署的支撑服务数量来进一步简化微服务架构。相反,相应的设计模式将由 Kubernetes 内置功能处理。Spring Cloud Config Server 将被 Kubernetes 配置映射和机密替换。Spring Cloud Gateway 将被 Kubernetes 入口资源替换,后者可以像 Spring Cloud Gateway 一样充当边缘服务器。
在第十一章中,关于保护 API 访问安全,参考使用 HTTPS 保护外部通信小节,我们使用证书来保护外部 API。手动处理证书既耗时又容易出错。作为替代方案,我们将介绍 Cert Manager,它可以自动为入口暴露的外部 HTTPS 端点提供新证书并替换已过期的证书。我们将配置cert-manager使用Let's Encrypt发行证书。Let's Encrypt 是一个可以用来自动颁发证书的免费证书授权机构。Let's Encrypt 必须能够验证我们拥有将发行证书的 DNS 名称。由于我们的 Kubernetes 集群在 Minikube 中本地运行,我们必须让 Let's Encrypt 在配置过程中能够访问我们的集群。我们将使用ngrok创建一个从互联网到我们本地 Kubernetes 集群的临时 HTTP 隧道,供 Let's Encrypt 使用。
当使用像 Kubernetes 这样的平台越来越多的特性时,确保微服务的源代码不依赖于该平台是很重要的;也就是说,应该确保微服务在没有 Kubernetes 的情况下仍然可以使用。为了确保我们可以在没有 Kubernetes 的情况下使用微服务,本章将通过使用 Docker Compose 部署微服务架构并以执行test-em-all.bash测试脚本结束,验证微服务在没有 Kubernetes 的情况下仍然可以工作。
本章将涵盖以下主题:
-
用 Kubernetes 配置映射和机密替换 Spring Cloud Config Server
-
用 Kubernetes 入口资源替换 Spring Cloud Gateway
-
添加 Cert Manager 以自动提供由 Let's Encrypt 签发的证书
-
使用
ngrok从互联网建立到我们本地 Kubernetes 集群的 HTTP 隧道。 -
使用 Docker Compose 部署和测试微服务架构,以确保微服务中的源代码不会锁定在 Kubernetes 上。
技术要求
本书中描述的所有命令都是在 MacBook Pro 上使用 macOS Mojave 运行的,但修改后可以在其他平台,如 Linux 或 Windows 上运行,应该是很简单的。
本章所需的新工具是命令行ngrok工具,用于建立从互联网到我们本地环境的 HTTP 隧道。它可以通过 Homebrew 使用以下命令安装:
brew cask install ngrok
要使用ngrok,必须创建一个免费账户并注册一个授权令牌,具体步骤如下:
-
在这里注册:
dashboard.ngrok.com/signup。 -
账户创建后,运行以下命令:
ngrok authtoken <YOUR_AUTH_TOKEN>
在这里,<YOUR_AUTH_TOKEN>用以下页面找到的授权令牌替换——dashboard.ngrok.com/auth。
本章的源代码可以在 GitHub 上找到:github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud/tree/master/Chapter17。
为了能够按照书中描述运行命令,你需要将源代码下载到一个文件夹中,并设置一个环境变量,$BOOK_HOME,指向那个文件夹。示例命令如下:
export BOOK_HOME=~/Documents/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud
git clone https://github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud $BOOK_HOME
cd $BOOK_HOME/Chapter17
Java 源代码是为 Java 8 编写的,并在 Java 12 上进行了测试。本章使用了 Spring Cloud 2.1, SR2(也称为Greenwich版本),Spring Boot 2.1.6 和 Spring 5.1.8——这些是编写本章时可用的 Spring 组件的最新版本。源代码已经使用 Kubernetes v1.15 进行了测试。
本章中所有源代码示例均来自$BOOK_HOME/Chapter17的源代码,但在某些情况下进行了编辑,去除了源代码中不相关的内容,例如注释、导入和日志语句。
如果你想查看对第十七章中源代码所做的更改,即替换 Spring Cloud Config Server 和 Spring Cloud Gateway 以便使用 Kubernetes 中对应特性的更改,你可以将其与第十六章的源代码进行比较,《将我们的微服务部署到 Kubernetes》。你可以使用你喜欢的diff工具,比较$BOOK_HOME/Chapter16和$BOOK_HOME/Chapter17文件夹。
替换 Spring Cloud Config Server。
正如我们在上一章看到的,第十六章,将我们的微服务部署到 Kubernetes,在部署到 Kubernetes部分,配置映射和秘钥可用于持有我们微服务的配置信息。Spring Cloud Config Server 增加了诸如将所有配置放在一个地方、可选的 Git 版本控制以及加密磁盘上的敏感信息等价值。但它也消耗了相当大的内存(就像任何基于 Java 和 Spring 的应用程序一样)并在启动时增加了显著的开销。例如,当运行本书中使用的自动化集成测试,如测试脚本test-em-all.bash时,所有微服务都会同时启动,包括配置服务器。由于其他微服务必须在启动之前从配置服务器获取其配置,它们都必须等待配置服务器启动并运行后才能启动。这导致集成测试运行时出现显著延迟。如果我们使用 Kubernetes 的配置映射和秘钥,则消除了这个延迟,使自动化集成测试运行得更快。在我看来,在底层平台不提供类似功能时使用 Spring Cloud Config Server 是有意义的,但在部署到 Kubernetes 时,最好使用配置映射和秘钥。
使用 Kubernetes 的配置映射和秘钥,而不是 Spring Cloud Config Server,可以加快微服务架构的启动速度,并减少所需内存。它还将通过消除一个支持服务,即配置服务器,简化微服务架构。如下图所示:

让我们看看替换 Spring Cloud Config Server 所需的步骤!
特别注意,我们只更改了配置;也就是说,不需要更改 Java 源代码!
源代码中的更改,以替换 Spring Cloud Config Server
已应用于源代码配置的以下更改,以替换 Spring Cloud Config Server 与 Kubernetes 配置映射和秘钥:
-
删除了项目
spring-cloud/config-server,还包括:-
删除了
settings.gradle构建文件中的项目。 -
删除了
config-server的 YAML 文件及其在kubernetes/services/base和kubernetes/services/overlays/prod文件夹中的声明。
-
-
删除了所有微服务中的配置:
-
删除了
build.gradle构建文件中的spring-cloud-starter-config依赖项。 -
删除了每个项目
src/main/resource文件夹中的bootstrap.yml文件。 -
删除了集成测试中的
spring.clod.config.enabled=false属性设置。
-
-
config-repo文件夹中的配置文件更改:-
移除了包含敏感信息的属性,也就是说,MongoDB、MySQL、RabbitMQ 的凭据以及边缘服务器使用的 TLS 证书的密码。它们将被 Kubernetes 密钥替换。
-
在边缘服务器的配置中移除了对配置服务器 API 的路径
-
-
kubernetes/services/base文件夹中部署资源定义文件的变化:-
配置映射作为卷挂载,也就是说,作为容器文件系统中的文件夹。每个微服务都有自己的配置映射,包含适用于特定微服务的配置文件。
-
定义
SPRING_CONFIG_LOCATION环境变量以指出卷中的配置文件。 -
使用密钥定义访问资源管理器的凭据。
-
大部分变化都在 Kubernetes 部署资源的定义文件中。让我们以product微服务的部署资源定义为例来看一下:
apiVersion: apps/v1
kind: Deployment
metadata:
name: product
spec:
template:
spec:
containers:
- name: pro
env:
- name: SPRING_PROFILES_ACTIVE
value: "docker"
- name: SPRING_CONFIG_LOCATION
value: file:/config-repo/application.yml,file:/config-
repo/product.yml
envFrom:
- secretRef:
name: rabbitmq-credentials
- secretRef:
name: mongodb-credentials
volumeMounts:
- name: config-repo-volume
mountPath: /config-repo
volumes:
- name: config-repo-volume
configMap:
name: config-repo-product
请注意,由于变化未影响到的定义部分被省略以提高可读性。完整的源代码请查看kubernetes/services/base/product.yml。
以下解释了前面的源代码:
-
config-repo-product配置映射映射在一个名为config-repo-volume的卷中。 -
config-repo-volume卷挂载在文件系统的/config-repo目录下。 -
SPRING_CONFIG_LOCATION环境变量告诉 Spring 在哪里可以找到属性文件,在这个例子中,是/config-repo/application.yml和/config-repo/product.yml文件。 -
访问 RabbitMQ 和 MongoDB 的凭据是基于
rabbitmq-credentials和mongodb-credentials密钥的内容设置为环境变量的。
在使用 ConfigMaps、密钥和 ingress 进行测试节中创建 Kubernetes 配置映射和密钥。
替换配置服务器需要的就是这些,接下来的一节中,我们将学习如何用 Kubernetes 的 ingress 资源替换 Spring Cloud Gateway。
替换 Spring Cloud Gateway
在本节中,我们将通过用 Kubernetes 内置的 ingress 资源替换 Spring Cloud Gateway,进一步简化微服务架构,减少需要部署的支持服务数量。
如第十五章、Kubernetes 简介中介绍的,Kubernetes 中的 ingress 资源可以像 Spring Cloud Gateway 一样作为边缘服务器使用。Spring Cloud Gateway 带有比 ingress 资源更丰富的路由功能。但是 ingress 特性是 Kubernetes 平台的一部分,也可以使用 Cert Manager 自动提供证书,如我们在本章后面所看到的。
我们还使用 Spring Cloud Gateway 保护我们的微服务免受未经验证的请求;也就是说,微服务需要一个来自可信 OAuth 授权服务器或 OIDC 提供者的有效 OAuth 2.0/OIDC 访问令牌。如果需要回顾,请参见第十一章,保护 API 访问。通常,Kubernetes 入口资源不支持这一点。然而,入口控制器的特定实现可能会支持它。
最后,我们在第十章,使用 Spring Cloud Gateway 将微服务隐藏在边缘服务器后面中向网关添加的综合健康检查可以被每个微服务部署资源中定义的 Kubernetes 存活和就绪探针所替换。对我来说,在底层平台不提供类似功能时使用 Spring Cloud Gateway 是有意义的,但在部署到 Kubernetes 时,最好使用入口资源。
在本章中,我们将验证请求是否包含有效访问令牌的责任委托给product-composite微服务。下一章将介绍服务网格的概念,我们将看到一个完全支持验证 JWT 编码访问令牌的入口的替代实现,即我们在本书中使用的访问令牌类型。
在验证微服务在没有 Kubernetes 的情况下是否工作部分,我们仍然将使用 Spring Cloud Gateway 和 Docker Compose,所以我们将不会移除项目。
以下图表展示了在将 Spring Cloud Gateway 部署到 Kubernetes 时,如何将其从微服务架构中移除:

让我们看看替换 Spring Cloud Gateway 为 Kubernetes 入口资源需要什么!
特别注意,我们只更改了配置;也就是说,不需要更改 Java 源代码!
Spring Cloud Gateway 源代码的变化
以下更改已应用于源代码配置,以将 Spring Cloud Gateway 替换为 Kubernetes 入口资源:
-
kubernetes/services文件夹中部署资源的变化。-
删除了网关的 YAML 文件及其在
base和overlays/prod文件夹中的声明。 -
在
base/ingress-edge-server.yml中添加了入口资源,并在base/kustomization.yml中对其进行了引用
-
入口资源的定义如下代码所示:
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: edge
spec:
tls:
- hosts:
- minikube.me
secretName: tls-certificate
rules:
- host: minikube.me
http:
paths:
- path: /oauth
backend:
serviceName: auth-server
servicePort: 80
- path: /product-composite
backend:
serviceName: product-composite
servicePort: 80
- path: /actuator/health
backend:
serviceName: product-composite
servicePort: 80
以下是对前面源代码的解释:
-
入口资源的名称为
edge。 -
tls部分指定了入口将需要使用 HTTPS,并且它将使用为minikube.me主机名发行的证书。 -
证书存储在一个名为
tls-certificate的秘密中在使用 Kubernetes ConfigMaps、secrets 和 ingress 资源进行测试部分的步骤 4中创建
tls-certificate密钥。 -
为请求到
minikube.me主机名定义路由规则。在下一主题中,DNS 名称
minikube.me将被映射到 Minikube 实例的 IP 地址。 -
为以下内容定义路由:
-
在
/oauth路径上的auth-server。 -
在
/product-composite路径上的product-composite微服务。 -
product-composite微服务中的/actuator/health路径上的health端点。
-
在下一节中,我们将创建 Kubernetes 的 ingress 资源,并一起测试微服务架构,包括 Kubernetes 的 config maps、secrets 和 ingress 资源。
使用 Kubernetes ConfigMaps、secrets 和 ingress 资源进行测试。
在前述更改描述之后,我们准备测试使用 Spring Cloud Config Server 和 Spring Cloud Gateway 替换 Kubernetes config maps、secrets 和 ingress 资源的系统架构。与之前一样,当我们使用 Spring Cloud Gateway 作为边缘服务器时,外部 API 将通过 HTTPS 保护。在此部署中,我们将配置 ingress 资源以重用我们与 Spring Cloud Gateway 一起用于 HTTPS 的自签名证书。以下图表展示了这一点:

在下一节中,我们将增强证书使用,并将自签名证书替换为 Let's Encrypt 发行的证书。
ingress 将在 Minikube 实例的默认 HTTPS 端口443上暴露。这由我们在执行minikube addons enable ingress命令时安装的 ingress 控制器处理;参见第十五章Kubernetes 简介,并参考创建 Kubernetes 集群部分进行回顾。ingress 控制器由在kube-system命名空间中的部署nginx-ingress-controller组成。部署使用hostPort将其端口443映射到宿主机上,即 Minikube 实例中的端口443,该端口在容器中运行。部署的定义中的主要部分如下所示:
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: nginx-ingress-controller
spec:
template:
spec:
containers:
image: quay.io/kubernetes-ingress-controller/nginx-ingress-
controller:0.23.0
ports:
- containerPort: 443
hostPort: 443
此设置适用于用于开发和测试的单节点 Kubernetes 集群。在多节点 Kubernetes 集群中,外部负载均衡器用于暴露高可用性和可伸缩性的 ingress 控制器。
部署使用了与我们在第十六章部署我们的微服务到 Kubernetes中使用的相同类型的命令;参考部署到 Kubernetes 以进行开发和测试部分。
主要区别是,此部署将:
-
为每个微服务创建一个 config map,而不是为配置服务器创建一个 config map。
-
创建用于资源管理器凭据的秘密和用于 ingress 的 TLS 证书的秘密,而不是创建用于配置服务器凭据的秘密
-
创建一个 Ingress,而不是使用 Spring Cloud Gateway
为了简化部署,已将开发和生产环境的部署脚本添加到源代码中。让我们来看看我们将在此部分使用的开发环境的部署脚本。
浏览部署脚本
kubernetes/scripts/deploy-dev-env.bash脚本包含了执行部署所需的所有命令。该脚本将执行以下步骤:
- 它将创建一个配置文件,每个微服务一个。例如,对于
product微服务,我们有以下内容:
kubectl create configmap config-repo-product --from-file=config-repo/application.yml --from-file=config-repo/product.yml --save-config
- 然后,它将创建所需的秘密。例如,使用以下命令创建访问 RabbitMQ 的凭据:
kubectl create secret generic rabbitmq-credentials \
--from-literal=SPRING_RABBITMQ_USERNAME=rabbit-user-dev \
--from-literal=SPRING_RABBITMQ_PASSWORD=rabbit-pwd-dev \
--save-config
- 也为资源管理器创建了秘密;它们的名称以
server-credentials结尾。它们在kubernetes/services/overlays/dev文件夹的 Kubernetes 定义文件中使用。例如,使用以下命令创建 RabbitMQ 的凭据:
kubectl create secret generic rabbitmq-server-credentials \
--from-literal=RABBITMQ_DEFAULT_USER=rabbit-user-dev \
--from-literal=RABBITMQ_DEFAULT_PASS=rabbit-pwd-dev \
--save-config
- 包含 ingress 的 TLS 证书的秘密
tls-certificate基于kubernetes/cert文件夹中已经存在的自签名证书。它使用以下命令创建:
kubectl create secret tls tls-certificate --key kubernetes/cert/tls.key --cert kubernetes/cert/tls.crt
以下命令创建了自签名证书:
openssl req -x509 -sha256 -nodes -days 365 -newkey rsa:2048 -keyout kubernetes/cert/tls.key -out kubernetes/cert/tls.crt -subj "/CN=minikube.me/O=minikube.me"
- 基于
dev覆盖层部署开发环境的微服务,使用-k开关激活 Kustomize:
kubectl apply -k kubernetes/services/overlays/dev
- 等待部署及其 pods 运行:
kubectl wait --timeout=600s --for=condition=ready pod --all
完成本指南后,我们就可以运行部署和测试所需的命令了!
部署和测试命令的运行
在我们能够执行部署之前,需要做以下准备工作:
-
将 ingress 使用的 DNS 名称
minikube.me映射到 Minikube 实例的 IP 地址 -
从源代码构建 Docker 镜像
-
在 Kubernetes 中创建一个命名空间
运行以下命令以准备、部署和测试:
- 将
minikube.me映射到 Minikube 实例的 IP 地址,通过在/etc/hosts文件中添加以下命令的行来实现:
sudo bash -c "echo $(minikube ip) minikube.me | tee -a /etc/hosts"
使用cat /etc/hosts命令验证结果。预期会出现包含 Minikube 实例 IP 地址后跟minikube.me的行,如下所示:

- 使用以下命令从源代码构建 Docker 镜像:
cd $BOOK_HOME/Chapter17
eval $(minikube docker-env)
./gradlew build && docker-compose build
- 重新创建命名空间
hands-on,并将其设置为默认命名空间:
kubectl delete namespace hands-on
kubectl create namespace hands-on
kubectl config set-context $(kubectl config current-context) --namespace=hands-on
- 运行以下命令执行部署脚本:
./kubernetes/scripts/deploy-dev-env.bash
- 部署完成后,使用以下命令启动测试:
HOST=minikube.me PORT=443 ./test-em-all.bash
期待我们看到前几章中的正常输出,如下面的屏幕截图所示:

- 您可以尝试手动执行早期章节中的相同步骤来测试 API:只需将主机和端口替换为
minikube.me。获取 OAuth/OIDC 访问令牌,并使用它调用具有以下命令的 API:
ACCESS_TOKEN=$(curl -k https://writer:secret@minikube.me/oauth/token -d grant_type=password -d username=magnus -d password=password -s | jq .access_token -r)
curl -ks https://minikube.me/product-composite/2 -H "Authorization: Bearer $ACCESS_TOKEN" | jq .productId
期待在响应中收到请求的产品 ID,2。
本节中设置的部署是基于一个用于开发和测试的环境。如果您想要建立一个用于 staging 和生产的环境,例如在第十六章《将我们的微服务部署到 Kubernetes》中描述的那样,请参考“为 staging 和生产部署到 Kubernetes”部分。为此,您可以使用 ./kubernetes/scripts/deploy-prod-env.bash 脚本。在第 4 步中像以前概述的那样使用它,而不是 deploy-dev-env.bash 脚本。
注意,deploy-prod-env.bash 脚本将使用 Docker Compose 启动 MySQL、MongoDB 和 RabbitMQ 的三个资源管理器;也就是说,它们将作为 Docker 容器在 Kubernetes 外运行(正如在第十六章《将我们的微服务部署到 Kubernetes》中所描述的那样)。
此部署使用了一个由 Kubernetes ingress 暴露的自签名证书,该证书需要手动提供。手动处理证书既耗时又容易出错。例如,很容易忘记及时续签一个证书。在下一节中,我们将学习如何使用 Cert Manager 和 Let's Encrypt 来自动化这个提供过程!
自动化证书的提供
正如本章介绍中提到的,我们将使用 Cert Manager 来自动化由 ingress 暴露的外部 HTTPS 端点所使用的证书的提供。Cert Manager 将在 Kubernetes 中作为一个附加组件运行,并配置为从 Let's Encrypt 请求免费证书颁发机构的证书,以自动化证书的颁发。为了能够验证我们拥有证书将要颁发的 DNS 名称,Let's Encrypt 要求访问我们要为其颁发证书的端点。由于我们的 Kubernetes 集群在本地运行 Minikube,我们必须让 Let's Encrypt 在证书提供期间能够访问我们的集群。我们将使用 ngrok 工具创建一个从互联网到我们的本地 Kubernetes 集群的临时 HTTP 隧道,供 Let's Encrypt 使用。
有关每个产品的更多信息,请参阅以下内容:
-
Cert Manager:
docs.cert-manager.io/en/latest/index.html -
Let's Encrypt:
letsencrypt.org/docs/ -
ngrok:
ngrok.com/docs
所有这一切可能看起来有些令人望而却步,所以让我们一步一步来:
-
部署证书管理器并在 Kubernetes 中基于 Let's Encrypt 定义发行者。
-
使用 ngrok 创建 HTTP 隧道。
-
使用证书管理器(Cert Manager)和让我们加密(Let's Encrypt)提供证书。
-
验证我们是否从 Let's Encrypt 获取了证书。
-
清理。
只有当你的 Kubernetes 集群无法通过互联网访问时,才需要 HTTP 隧道。如果其入口资源可以直接从互联网访问,则可以跳过使用 ngrok。
部署证书管理器并定义 Let's Encrypt 发行者
要部署证书管理器(Cert Manager),我们可以执行一个 Kubernetes 定义文件,这将创建一个名为 cert-manager 的命名空间,然后将证书管理器部署到该命名空间中。我们将安装编写本章时的最新版本 0.8.1。运行以下命令:
kubectl apply -f https://github.com/jetstack/cert-manager/releases/download/v0.8.1/cert-manager.yaml
如果你收到类似“无法识别 "https://github.com/jetstack/cert-manager/releases/download/v0.8.1/cert-manager.yaml":在版本 "certmanager.k8s.io/v1alpha1" 中没有找到 "Issuer" 种类”的错误消息,那么只需重新运行命令即可。
等待部署及其 Pod 可用:
kubectl wait --timeout=600s --for=condition=ready pod --all -n cert-manager
预期从命令中输出与以下类似的结果:

有了证书管理器(Cert Manager)之后,我们可以在 Kubernetes 中定义基于 Let's Encrypt 的发行者。
让我们加密(Let's Encrypt)暴露了以下发行者:
-
开发和测试阶段使用的暂存环境,可以预期在这一阶段会请求很多短期证书。暂存环境允许创建许多证书,但证书中的根CA(证书授权中心)不被信任。这意味着来自暂存环境的证书不能用于保护由网页浏览器使用的网页或 API。当用户打开由暂存环境中的证书保护的网页时,网页浏览器不会信任其根 CA 并抱怨。
-
生产环境,它使用一个受信任的根 CA 来颁发证书。因此,它可以用来颁发被网页浏览器信任的证书。生产环境限制了可以颁发的证书数量。例如,每个注册域名每周只能发行 50 个新证书,例如在
ngrok.io的情况下。
我们将在 Kubernetes 中注册两个发行者,一个用于暂存环境,一个用于生产环境。发行者可以在集群全局或命名空间局部注册。为了保持一致性,我们将使用命名空间局部发行者。
在证书提供过程中,证书管理器(Cert Manager)和让我们加密(Let's Encrypt)之间的通信基于一个标准协议,即自动化证书管理环境 v2(ACME v2)。让我们加密将充当证书授权中心(CA),而证书管理器将充当 ACME 客户端。为了验证 DNS 名称的所有权,ACME 协议规定了 CA 可以使用两种类型的挑战:
-
http-01: 认证机构(CA)要求 ACME 客户端在以下 URL 下创建一个随机命名的文件:http://<domainname>/.well-known/acme-challenge/<randomfilename>。如果 CA 能够使用这个 URL 成功访问该文件,那么域名所有权得到验证。 -
dns-01: 认证机构(CA)要求 ACME 客户端在 DNS 服务器下的域名_acme-challenge.<YOUR_DOMAIN>放置一个指定的值。这通常通过 DNS 提供商的 API 来实现。如果 CA 能够在 DNS 服务器中的 TXT 记录中访问到指定的内容,那么域名所有权得到验证。
自动化dns-01挑战通常比自动化http-01挑战要困难,但在 HTTP 端点不可通过互联网的情况下,它是更优的选择。dns-01挑战还支持发行通配符证书,这是http-01挑战无法使用的。在本章中,我们将配置 Cert Manager 以使用基于http-01的挑战。
对于 Let's Encrypt 测试环境的发行者定义如下:
apiVersion: certmanager.k8s.io/v1alpha1
kind: Issuer
metadata:
name: letsencrypt-issuer-staging
spec:
acme:
email: <your email address>
server: https://acme-staging-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-issuer-staging-account-key
solvers:
- http01:
ingress:
class: nginx
以下解释了前面的源代码:
-
发行者的
name,letsencrypt-issuer-staging,将在负载均衡器中引用发行者时使用,以提供证书。 -
email必须填写您的电子邮件地址。Let's Encrypt 将使用该电子邮件地址联系您关于到期的证书和与您账户相关的问题。 -
server字段指出了 Let's Encrypt 测试环境的 URL。 -
privateKeySecretRef字段包含一个密钥的名字。这个密钥将由 Cert Manager 创建,并将包含一个 ACME/Let's Encryptaccount private key。这个密钥用来识别您(或您的公司)作为 ACME 服务的用户,即 Let's Encrypt。它用于签署发送到 Let's Encrypt 的请求,以验证您的身份。 -
solver定义声明使用http-01挑战来验证域名所有权。
对于 Let's Encrypt 生产环境的发行者定义看起来是一样的,主要区别在于使用的 ACME 服务器 URL:acme-v02.api.letsencrypt.org/directory。
编辑以下文件,并将<your email address>替换为您的电子邮件地址:
-
kubernetes/services/base/letsencrypt-issuer-staging.yaml -
kubernetes/services/base/letsencrypt-issuer-prod.yaml
使用以下命令应用定义:
kubectl apply -f kubernetes/services/base/letsencrypt-issuer-staging.yaml
kubectl apply -f kubernetes/services/base/letsencrypt-issuer-prod.yaml
现在我们已经有了 Cert Manager,并注册了 Let's Encrypt 测试环境和生产环境的发行者。下一步是使用ngrok创建一个 HTTP 隧道。
使用 ngrok 创建 HTTP 隧道
ngrok的免费订阅可以用来创建一个 HTTP 隧道,其中ngrok使用其自己的通配符证书***.ngrok.io来终止 HTTPS 流量,也就是说,在 HTTP 请求到达 Kubernetes 中的 ingress 资源之前。发送 HTTPS 请求的客户端只能看到ngrok证书,而看不到 Kubernetes 中 ingress 资源暴露的证书。这意味着我们不能使用 HTTP 隧道来测试由 Let's Encrypt 发行并由 Kubernetes 中的 ingress 资源使用的证书。这在以下图表中有所说明:

但是在提供证书的过程中,可以让 HTTP 隧道来使用,这时 Let's Encrypt 需要验证 ACME 客户端是否拥有它请求颁发证书的 DNS 名称。DNS 名称将是 HTTP 隧道分配给的主机名,例如,6cc09528.ngrok.io。一旦完成提供证书的过程,我们可以关闭 HTTP 隧道并将主机名重定向到 Minikube 实例的 IP 地址(使用本地的/etc/hosts文件)。这在以下图表中有所说明:

对于付费客户,ngrok提供了一个 TLS 隧道,它通过 HTTPS 流量而不是终止它;也就是说,发送 HTTPS 请求的客户端能够看到并验证 Kubernetes 中 ingress 资源暴露的证书。使用 TLS 隧道而不是 HTTP 隧道应该使这一额外步骤变得 unnecessary。
执行以下步骤以创建 HTTP 隧道:
- 使用以下命令创建 HTTP 隧道:
ngrok http https://minikube.me:443
- 期望输出类似于以下屏幕截图:

- 拿起 HTTP 隧道的主机名,例如前面的示例中的
6cc09528.ngrok.io,并将其保存在一个环境变量中,如下所示:
NGROK_HOST=6cc09528.ngrok.io
在 HTTP 隧道就位的情况下,我们可以为使用 Cert Manager 和 Let's Encrypt 自动提供其证书的 ingress 资源的定义做好准备!
使用 Cert Manager 和 Let's Encrypt 提供证书
在配置 ingress 资源之前,了解提供证书的高级过程可能会有所帮助。使用 Cert Manager 和 Let's Encrypt 自动提供证书的过程如下:

以下步骤将在提供过程中进行:
-
创建了一个注有
certmanager.k8s.io/issuer: "name of a Let's Encrypt issuer"的 ingress。 -
这个注解将触发 Cert Manager 开始使用 Let's Encrypt 为 ingress 提供证书。
-
在提供证书的过程中,Let's Encrypt 将执行一个
http-01挑战,并使用 HTTP 隧道来验证 Cert Manager 拥有 DNS 名称。 -
一旦配置完成,Cert Manager 将在 Kubernetes 中存储证书,并创建一个由入口指定的名称的秘密。
我们将添加一个新的入口edge-ngrok,定义在ingress-edge-server-ngrok.yml文件中,它将路由请求到 HTTP 隧道的主机名。这个入口将具有与现有入口相同的路由规则。不同的部分看起来像以下这样:
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: edge-ngrok
annotations:
certmanager.k8s.io/issuer: "letsencrypt-issuer-staging"
spec:
tls:
- hosts:
- xxxxxxxx.ngrok.io
secretName: tls-ngrok-letsencrypt-certificate
rules:
- host: xxxxxxxx.ngrok.io
以下是对前面源代码的解释:
-
使用
certmanager.k8s.io/issuer: "letsencrypt-issuer-staging"注解,我们请求 Cert Manager 使用名为letsencrypt-issuer-staging的发行版为这个入口配置证书。 -
tls和rules声明中的xxxxxxxx.ngrok.io主机名必须用你的 HTTP 隧道实际主机名替换。 -
一旦配置完成,证书将被存储在名为
tls-ngrok-letsencrypt-certificate的秘密中。
在深入了解配置过程的高级层面并准备好使用它的入口资源之后,我们可以开始使用 Let's Encrypt 支持的两个环境来配置证书。首先从适合开发和测试活动的 staging 环境开始。
使用 Let's Encrypt 的 staging 环境
执行以下步骤从 Let's Encrypt 的 staging 环境配置证书并验证它是否工作:
-
编辑
kubernetes/services/base/ingress-edge-server-ngrok.yml文件,并将xxxxxxxx.ngrok.io用你的 HTTP 隧道的主机名替换两次!(之前的示例中的
6cc09528.ngrok.io。) -
在启动配置之前,在另一个终端窗口中运行一个 watch 命令来监控证书的配置。运行以下命令:
kubectl get cert --watch
- 通过以下命令应用新的入口定义来启动配置:
kubectl apply -f kubernetes/services/base/ingress-edge-server-ngrok.yml
-
Cert Manager 现在将检测到新的入口并开始使用由
ngrok设置的 HTTP 隧道通过 ACME v2 协议提供 Let's Encrypt staging 环境的证书。 -
过了一会儿,你应该注意到在运行 HTTP 隧道的终端窗口中出现了
http-01挑战。预期输出中将有一个像以下的请求:

- 将创建一个
tls-ngrok-letsencrypt-certificate证书,并将其存储在tls-ngrok-letsencrypt-certificate秘密中,如入口指定。预期kubectl get cert --watch命令的输出将与以下类似:

-
过了一会儿,证书的
READY状态将变为True,这意味着证书已经配置好,我们可以尝试使用它! -
为了尝试由 Let's Encrypt 提供的证书,我们需要将
ngrok主机名重定向以直接指向 Minikube IP 地址。我们将向/etc/hosts文件中添加 HTTP 隧道的主机名,使其解析到 Minikube 实例的 IP 地址。这将导致本地请求发送到 HTTP 隧道的主机名被引导到 Minikube 实例,如下所示图所示:

- 编辑
/etc/hosts文件,在我们在本章早些时候添加的行中添加 HTTP 隧道的主机名在minikube.me之后。编辑后,该行应类似于以下内容:

- 使用
keytool命令查看 HTTP 隧道主机名暴露的证书:
keytool -printcert -sslserver $NGROK_HOST:443 | grep -E "Owner:|Issuer:"
- 期望收到如下响应:

如果您的keytool是本地化的,也就是说,它将其输出打印成除英语外的其他语言,您将需要更改前面grep命令使用的Owner:|Issuer:字符串,以使用本地化版本。
-
证书是为 HTTP 隧道的主机名(在前一个示例中为
6cc09528.ngrok.io)签发的,并由Fake LE Intermediate X1使用Fake LE Root X1作为其根 CA 签发。这证明 ingress 使用了 Let's Encrypt 的测试证书! -
通过使用相同的命令运行
test-em-all.bash测试脚本总结:
HOST=$NGROK_HOST PORT=443 ./test-em-all.bash
期望测试脚本输出通常的结果;检查它以如下结论结束:

由 Let's Encrypt 测试环境提供的证书,如前所述,适合开发和测试活动。但由于其根 CA 不被网络浏览器信任,因此它们不能用于生产场景。让我们也尝试使用 Let's Encrypt 的生产环境,该环境能够提供可信的证书,尽管数量有限。
使用 Let's Encrypt 的生产环境
为了从 Let's Encrypt 的生产环境获取证书,而不是测试环境,我们必须在 ingress 定义中更改发行者,然后应用更新后的定义。执行以下步骤:
- 编辑
kubernetes/services/base/ingress-edge-server-ngrok.yml文件,并更改以下代码:
certmanager.k8s.io/issuer: "letsencrypt-issuer-staging"
前面的代码现在应该如下所示:
certmanager.k8s.io/issuer: "letsencrypt-issuer-prod"
- 通过运行以下命令应用更改:
kubectl apply -f kubernetes/services/base/ingress-edge-server-ngrok.yml
-
监控
kubectl get cert --watch命令的输出,并等待新证书被提供。在应用命令后立即,其就绪状态将更改为False,然后过一会儿将回到True。这意味着 Cert Manager 已经提供了由 Let's Encrypt 生产环境签发的证书! -
使用以下
keytool命令检查证书:
keytool -printcert -sslserver $NGROK_HOST:443 | grep -E "Owner:|Issuer:"
期望输出如下所示:

-
新证书就像之前为 HTTP 隧道的主机名发行的证书(在前一个示例中为
6cc09528.ngrok.io),但这次发行者和根 CA 来自生产环境。这意味着证书应该被网页浏览器信任。 -
在本地网络浏览器中打开
https://6cc09528.ngrok.io/actuator/healthURL(将6cc09528.ngrok.io替换为你的 HTTP 隧道的主机名)。如果你使用 Google Chrome 并点击证书图标(URL 前的锁),你应该会看到如下输出:

如前一个屏幕截图所示,Chrome 报告:此证书有效!
- 通过以下方式验证
test-em-all.bash测试脚本是否也与此证书一起工作:
HOST=$NGROK_HOST PORT=443 ./test-em-all.bash
期望测试脚本输出通常的结果;检查它是否以以下输出结束:

您可以按照相同的过程返回 staging 发行者,同时在 ingress 定义中也将回到 staging 发行者。
清理
当你完成后,使用 Docker Compose 运行以下命令清理在 Kubernetes 中(可选在 Docker 中)创建的资源:
-
使用 Ctrl + C 停止
kubectl get cert --watch命令。 -
使用 Ctrl + C. 停止 HTTP 隧道。
-
使用以下命令在 Kubernetes 中删除命名空间:
kubectl delete namespace hands-on
- 如果你尝试使用
./kubernetes/scripts/deploy-prod-env.bash脚本部署生产环境,你还需要停止使用 Docker Compose 启动的资源管理器。运行以下命令停止它们:
docker-compose down mongodb mysql rabbitmq
既然我们已经自动化了证书的发放,让我们看看如何在没有 Kubernetes 的情况下验证微服务是否工作。让我们看看这是如何完成的。
验证在没有 Kubernetes 的情况下微服务是否工作
在本章和上一章中,我们看到了 Kubernetes 平台上的特性,如 config maps、secrets、服务以及 ingress 资源,如何简化开发合作微服务的景观的努力。但重要的是确保微服务的源代码在功能上不依赖于平台。避免此类锁定使得未来如果需要,可以以最小的努力切换到另一个平台。切换平台不应该需要更改源代码,而只需要更改微服务的配置。
使用 Docker Compose 和 test-em-all.bash 测试脚本测试微服务,可以确保从功能角度来说它们是工作的,这意味着它们将验证微服务源代码中的功能在没有 Kubernetes 的情况下仍然有效。在没有 Kubernetes 的情况下运行微服务时,我们将失去 Kubernetes 为我们提供的非功能性特性,例如监控、扩展和重启容器。
当使用 Docker Compose 时,我们将映射以下 Kubernetes 特性:
-
而不是使用配置图,我们使用卷,直接从宿主文件系统映射配置文件。
-
而不是使用密钥,我们将敏感信息(如凭据)保存在
.env文件中。 -
而不是入口,我们将使用 Spring Cloud Gateway。
-
代替服务,我们将将客户端使用的主机名直接映射到容器的主机名,这意味着我们将不实施任何服务发现,并且无法扩展容器。
使用这种方式的 Docker Compose 将在非功能性方面与使用 Kubernetes 相比具有显著劣势。但是,鉴于 Docker Compose 只用于运行功能测试,这是可以接受的。
在我们使用 Docker Compose 运行测试之前,让我们查看一下docker-compose*.yml文件中的代码更改。
Docker Compose 源代码的变化
要在 Kubernetes 之外使用 Docker Compose 运行微服务,对docker-compose*.yml文件应用了以下更改:
-
移除了配置服务器定义
-
移除了以下配置服务器环境变量的使用:
CONFIG_SERVER_USR和CONFIG_SERVER_PWD -
在需要从配置仓库读取配置文件的每个容器中,将
config-repo文件夹作为卷映射。 -
定义了
SPRING_CONFIG_LOCATION环境变量,以指向配置仓库中的配置文件。 -
将敏感信息如凭据和密码存储在 Docker Compose 的
.env文件中的 TLS 证书中 -
使用在
.env文件中定义的变量,为访问资源管理器的凭据定义环境变量。
例如,在docker-compose.yml中,product微服务的配置如下所示:
product:
build: microservices/product-service
image: hands-on/product-service
environment:
- SPRING_PROFILES_ACTIVE=docker
- SPRING_CONFIG_LOCATION=file:/config-repo/application.yml,file:/config-repo/product.yml
- SPRING_RABBITMQ_USERNAME=${RABBITMQ_USR}
- SPRING_RABBITMQ_PASSWORD=${RABBITMQ_PWD}
- SPRING_DATA_MONGODB_AUTHENTICATION_DATABASE=admin
- SPRING_DATA_MONGODB_USERNAME=${MONGODB_USR}
- SPRING_DATA_MONGODB_PASSWORD=${MONGODB_PWD}
volumes:
- $PWD/config-repo:/config-repo
以下是前述源代码的解释:
-
config-repo文件夹作为卷映射到容器的/config-repo。 -
SPRING_CONFIG_LOCATION环境变量告诉 Spring 在哪里找到属性文件,在本例中,是/config-repo/application.yml和/config-repo/product.yml文件。 -
根据
.env文件的内容,设置访问 RabbitMQ 和 MongoDB 的凭据作为环境变量。
前述源代码中提到的凭据在.env文件中定义为:
RABBITMQ_USR=rabbit-user-prod
RABBITMQ_PWD=rabbit-pwd-prod
MONGODB_USR=mongodb-user-prod
MONGODB_PWD=mongodb-pwd-prod
使用 Docker Compose 进行测试
要用 Docker Compose 进行测试,我们将使用 Docker Desktop(以前称为 Docker for macOS)而不是 Minikube。执行以下步骤:
- 要以 Docker Desktop 而不是 Minikube 运行 Docker 客户端,请运行以下命令:
eval $(minikube docker-env --unset)
- 为了节省内存,您可能想要停止 Minikube 实例:
minikube stop
-
启动 Docker Desktop(如果尚未运行)。
-
使用以下命令在 Docker Desktop 中构建 Docker 镜像:
docker-compose build
- 使用 RabbitMQ(每个主题一个分区)运行测试:
COMPOSE_FILE=docker-compose.yml ./test-em-all.bash start stop
测试应该从启动所有容器开始,运行测试,最后停止所有容器。预期输出如下:

- 可选地,使用具有多个分区的 RabbitMQ 运行测试:
COMPOSE_FILE=docker-compose-partitions.yml ./test-em-all.bash start stop
预期输出应该与前面的测试类似。
- 或者,使用具有多个分区的 Kafka 运行测试:
COMPOSE_FILE=docker-compose-kafka.yml ./test-em-all.bash start stop
预期输出应该与前面的测试类似。
-
停止 Docker Desktop 以节省内存。
-
如果之前停止了 Minikube 实例,请启动它,并将默认命名空间设置为
hands-on:
minikube start
kubectl config set-context $(kubectl config current-context) --namespace=hands-on
- 将 Docker 客户端指向 Minikube 实例中的 Kubernetes 集群:
eval $(minikube docker-env)
通过这些测试的成功执行,我们验证了在没有 Kubernetes 的情况下微服务也能正常工作。
总结
在本章中,我们看到了 Kubernetes 的功能如何简化微服务架构,这意味着我们需要开发的支撑服务数量减少了,这些服务原本需要和微服务一起部署。我们看到了如何使用 Kubernetes 的配置映射(config maps)和机密(secrets)来替代 Spring Cloud Config Server,以及如何使用 Kubernetes 入口(ingress)来替代基于 Spring Cloud Gateway 的边缘服务。
使用 Cert Manager 和 Let's Encrypt 允许我们自动为 ingress 暴露的 HTTPS 端点提供证书,从而消除了手动且繁琐的工作。由于我们运行在本地 Minikube 实例中的 Kubernetes 集群无法从互联网访问,我们使用了ngrok来建立从互联网到 Minikube 实例的 HTTP 隧道。HTTP 隧道被 Let's Encrypt 用来验证我们是否是请求证书的 DNS 名的所有者。
为了验证微服务的源代码可以在其他平台上运行,也就是说不是锁定在 Kubernetes 上,我们使用 Docker Compose 部署了微服务并运行了test-em-all.bash测试脚本。
在下一章中,我们将介绍服务网格(service mesh)的概念,并学习如何使用服务网格产品Istio来改善部署在 Kubernetes 上的合作微服务景观的可观测性、安全性、弹性和路由。
前往下一章!
问题
-
Spring Cloud Config Server 是如何被 Kubernetes 资源替代的?
-
Spring Cloud Gateway 是如何被 Kubernetes 资源替代的?
-
ACME 代表什么,它有什么用途?
-
Cert Manager 和 Let's Encrypt 在自动化提供证书中扮演什么角色?
-
在自动化证书提供过程中涉及哪些 Kubernetes 资源?
-
我们为什么使用
ngrok,以及如何修改以去除对ngrok的使用? -
我们为什么使用 Docker Compose 运行测试?
第十八章:使用服务网格改善可观测性和管理
在本章中,你将介绍服务网格的概念,并了解其功能如何用于处理微服务系统架构在安全性、策略执行、弹性和流量管理方面的挑战。服务网格还可以用于提供可观测性,即可视化服务网格中微服务之间流量流动的能力。
服务网格在与本书前面所学的 Spring Cloud 和 Kubernetes 的功能有所重叠的同时,也提供了大部分 Spring Cloud 和 Kubernetes 所没有的功能,正如我们将在本章所看到的。
本章将涵盖以下主题:
-
介绍服务网格概念以及 Istio,一个流行的开源实现
-
你还将学习如何进行以下操作:
-
在 Kubernetes 中部署 Istio
-
创建一个服务网格
-
观察服务网格
-
保护服务网格
-
确保服务网格具有弹性
-
使用服务网格执行零停机部署
-
使用 Docker Compose 测试微服务架构,以确保微服务中的源代码既不受 Kubernetes 或 Istio 的限制。
-
技术要求
本书中描述的所有命令都是在 MacBook Pro 上使用 macOS Mojave 运行的,但修改这些命令以在另一个平台(如 Linux 或 Windows)上运行应该是非常直接的。
本章唯一需要的新工具是 Istio 的命令行工具istioctl。这可以通过使用 Homebrew 以下命令进行安装:
brew install istioctl
本章的源代码可以在 GitHub 上找到,地址为github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud/tree/master/Chapter18。
为了能够按照书中所述运行命令,你需要把源代码下载到一个文件夹中,并设置一个环境变量$BOOK_HOME,使其指向该文件夹。示例命令包括以下内容:
export BOOK_HOME=~/Documents/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud
git clone https://github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud $BOOK_HOME
cd $BOOK_HOME/Chapter18
Java 源代码是为 Java 8 编写的,并在 Java 12 上进行了测试。本章使用了 Spring Cloud 2.1, SR2(也被称为Greenwich版本),Spring Boot 2.1.6 和 Spring 5.1.8,即在撰写本章时可用的 Spring 组件的最新版本。源代码已经使用 Kubernetes V1.15 进行了测试。
本章中的所有源代码示例均来自$BOOK_HOME/Chapter18的源代码,但在许多情况下,为了去除源代码中不相关的内容(如注释、导入和日志语句),对这些示例进行了编辑。
如果你想查看源代码中第十八章,使用服务网格提高可观测性和管理能力的更改,也就是使用 Istio 创建服务网格所需的更改,你可以将其与第十七章,作为替代实现 Kubernetes 特性的源代码进行比较。你可以使用你喜欢的差异工具,比较这两个文件夹,$BOOK_HOME/Chapter17和$BOOK_HOME/Chapter18。
使用 Istio 的服务网格简介
服务网格是一个基础设施层,用于控制和观察服务之间的通信,例如微服务。服务网格的功能,例如可观测性、安全性、策略执行、弹性和流量管理,是通过控制和监控服务网格内的所有内部通信实现的,即服务网格中微服务之间的通信。服务网格中的一个核心组件是一个轻量级的代理组件,它被注入到将成为服务网格一部分的所有微服务中。微服务所有进出流量都被配置为通过其代理组件。代理组件在运行时通过服务网格中的控制平面使用代理暴露的 API 进行配置。控制平面还通过这些 API 从代理收集遥测数据,以可视化服务网格中的流量流动。
服务网格还包括一个数据平面,由服务网格中所有微服务中的代理组件以及处理来自和服务网格之间外部传入和传出流量的单独组件组成。以下图表说明了这一点:

第一个公开可用的服务网格实现是开源项目 Linkerd,由 Buoyant 管理(linkerd.io),其起源于 Twitter 的 Finagle 项目(twitter.github.io/finagle)。它于 2016 年推出,一年后的 2017 年,IBM、Google 和 Lyft 推出了开源项目 Istio(istio.io)。
在 Istio 中,核心组件之一,代理组件,是基于 Lyft 的 Envoy 代理(www.envoyproxy.io)。在撰写本章时,Linkerd 和 Istio 是两个最受欢迎且广泛使用的服务网格实现。在本章中,我们将使用 Istio。
Istio 可以在各种环境中部署,包括 Kubernetes(参见 istio.io/docs/setup)。在 Kubernetes 上部署 Istio 时,其运行时组件被部署到一个单独的 Kubernetes 命名空间 istio-system 中。Istio 还提供一套 Kubernetes 自定义资源定义(CRD)。CRD 用于在 Kubernetes 中扩展其 API,即添加新的对象到其 API 中。添加的 Istio 对象用于配置 Istio 的使用方式。最后,Istio 提供了一个 CLI 工具 istioctl,它将用于将 Istio 代理注入到参与服务网格的微服务中。
如前所述,Istio 被分为控制平面和数据平面。作为一个操作员,我们将通过在 Kubernetes API 服务器中创建 Istio 对象来定义期望的状态,例如,声明路由规则。控制平面将读取这些对象,并向数据平面中的代理发送命令,以根据期望的状态采取行动,例如,配置路由规则。代理处理微服务之间的实际通信,并向控制平面报告遥测数据。遥测数据被控制平面的各个组件用来可视化服务网格中正在发生的事情。
在以下小节中,我们将涵盖以下主题:
-
如何将 Istio 代理注入到微服务中
-
本章我们将使用的 Istio API 对象
-
构成 Istio 控制平面和数据平面的运行时组件
-
引入 Istio 后微服务景观的变化
将 Istio 代理注入现有微服务
我们在前几章中在 Kubernetes 中部署的微服务作为一个容器在 Kubernetes pod 中运行(回顾第十五章,Kubernetes 简介中的介绍 Kubernetes API 对象部分)。要使一个微服务加入基于 Istio 的服务网格,向每个微服务中注入 Istio 代理。这是通过向运行 Istio 代理的 pod 添加一个额外容器来实现的。
被添加到 pod 中以支持主容器(如 Istio 代理)的容器被称为sidecar。
以下图表显示了如何将 Istio 代理作为sidecar注入到样本 pod Pod A 中:

在 pod 中的主容器容器 A,被配置通过 Istio 代理路由其所有流量。
Istio 代理可以当创建部署对象时自动注入,也可以使用istioctl工具手动注入。
在本章中,我们将手动注入 Istio 代理。原因是 Istio 代理不支持 MySQL、MongoDB 和 RabbitMQ 使用的协议,所以我们只会在使用 HTTP 协议的 pod 中注入 Istio 代理。可以通过以下命令将 Istio 代理注入现有部署对象的 pods:
kubectl get deployment sample-deployment -o yaml | istioctl kube-inject -f - | kubectl apply -f -
这个命令初看起来可能有些令人畏惧,但实际上它只是三个独立的命令。前一个命令将其输出通过管道发送给下一个命令,即|字符。让我们逐一介绍每个命令:
-
kubectl get deployment命令从 Kubernetes API 服务器获取名为sample-deployment的部署的当前定义,并以 YAML 格式返回其定义。 -
istioctl kube-inject命令从kubectl get deployment命令中读取定义,并在部署处理的 pod 中添加一个额外的 Istio 代理容器。部署对象中现有容器的配置更新,以便传入和传出的流量都通过 Istio 代理。istioctl命令返回部署对象的新定义,包括一个 Istio 代理的容器。 -
kubectl apply命令从istioctl kube-inject命令中读取更新后的配置,并应用更新后的配置。将启动与之前我们所见过的相同的滚动升级(请参阅第十六章,将我们的微服务部署到 Kubernetes中的执行滚动升级部分),部署的 pods 将以相同的方式开始。
kubernetes/scripts文件夹中的部署脚本已扩展为使用istioctl来注入 Istio 代理。有关详细信息,请参考即将到来的创建服务网格部分。
介绍 Istio API 对象
Istio 通过其 CRDs 使用许多对象扩展了 Kubernetes API。回顾 Kubernetes API,请参考第十五章,Kubernetes 简介中的介绍 Kubernetes API 对象部分。在本章中,我们将使用以下 Istio 对象:
-
Gateway用于配置如何处理服务网格的入站和出站流量。网关依赖于一个虚拟服务,将入站流量路由到 Kubernetes 服务。我们将使用网关对象接受通过 HTTPS 到 DNS 名称minikube.me的入站流量。有关详细信息,请参考用 Istio Ingress Gateway 替换 Kubernetes Ingress 资源作为边缘服务器部分。 -
VirtualService用于在服务网格中定义路由规则。我们将使用虚拟服务来描述如何将来自 Istio 网关的流量路由到 Kubernetes 服务和服务之间。我们还将使用虚拟服务注入故障和延迟,以测试服务网格的可靠性和弹性能力。 -
DestinationRule用于定义路由到特定服务(即目的地)的流量的策略和规则(使用虚拟服务)。我们将使用目的地规则来设置加密策略,以加密内部 HTTP 流量并定义描述服务可用版本的服务子集。在将微服务的现有版本部署到新版本时,我们将使用服务子集执行零停机(蓝/绿)部署。 -
Policy用于定义请求如何进行认证。我们将使用策略要求服务网格传入的请求使用基于 JWT 的 OAuth 2.0/OIDC 访问令牌进行认证。请参阅本章节中使用 OAuth 2.0/OIDC 访问令牌认证外部请求部分。策略还可以用于定义服务网格内部通信的安全部分。例如,策略可以要求内部请求使用 HTTPS 进行加密或允许明文请求。最后,可以使用MeshPolicy对象定义适用于整个服务网格的全球策略。
在 Istio 中引入运行时组件
Istio 包含许多运行时组件,在选择使用哪些组件方面具有高度可配置性,并提供了对每个组件配置的细粒度控制。有关我们将在此章节中使用的配置信息,请参阅本章节中在 Kubernetes 集群中部署 Istio部分。
在本章节使用的配置中,Istio 控制平面包含以下运行时组件:
-
Pilot:负责向所有边车提供服务网格配置的更新。
-
Mixer:包含两个不同的运行时组件:
-
策略–执行网络策略,例如认证、授权、速率限制和配额。
-
遥测–收集遥测信息并将其发送到 Prometheus,例如。
-
-
Galley:负责收集和验证配置信息,并将其分发到控制平面上的其他 Istio 组件。
-
Citadel:负责发放和轮换内部使用的证书。
-
Kiali:为服务网格提供可观测性,可视化网格中正在发生的事情。Kiali 是一个独立的开源项目(参见
www.kiali.io)。 -
Prometheus:对基于时间序列的数据执行数据摄取和存储,例如,性能指标。
Prometheus 是一个独立的开源项目(请参阅
prometheus.io)。 -
Grafana:可视化 Prometheus 收集的性能指标和其他时间序列相关数据。Grafana 是一个独立的开源项目(参见
grafana.com)。 -
追踪:处理并可视化分布式追踪信息。基于 Jaeger,它是一个开源的分布式追踪项目(参考
www.jaegertracing.io)。Jaeger 提供与 Zipkin 相同的功能,我们在第十四章中使用过,理解分布式追踪。
Kiali 通过网页浏览器访问,并集成了 Grafana 以查看性能指标和 Jaeger 以可视化分布式追踪信息。
Istio 数据平面包括以下运行时组件:
-
入口网关:处理服务网格的入站流量
-
出口网关:处理服务网格的出站流量
-
所有带有 Istio 代理的 pod 都作为边车注入。
Istio 控制平面和数据平面的运行时组件总结如下:

在下一节中,我们将介绍由于引入 Istio 而对微服务景观所做的更改。
微服务景观的变化
如前所述,Istio 带有与当前在微服务景观中使用的组件在功能上重叠的组件:
-
Istio Ingress Gateway 可以作为边缘服务器,是 Kubernetes Ingress 资源的替代品。
-
Istio 自带的 Jaeger 组件可以用于分布式追踪,而不是 Zipkin。
在接下来的两个子节中,我们将学习为什么以及如何用 Istio Ingress Gateway 替换 Kubernetes Ingress 资源,以及为什么用 Jaeger 替换 Zipkin。
用 Istio Ingress Gateway 作为边缘服务器替换 Kubernetes Ingress 资源
在上一章中,我们介绍了 Kubernetes Ingress 资源作为边缘服务器(参考第十七章中的用 Kubernetes 特性作为替代部分,Replacing the Spring Cloud Gateway)。不幸的是,ingress 资源无法配置以处理 Istio 带来的细粒度路由规则。相反,Istio 有自己的边缘服务器,即 Istio ingress Gateway,在前面的介绍 Istio 运行时组件一节中已经介绍过。通过创建前面介绍的引入 Istio API 对象一节中描述的 Gateway 和 VisualService 资源来使用 Istio Ingress Gateway。
因此,以下 Kubernetes Ingress 资源定义文件kubernetes/services/base/ingress-edge-server.yml和kubernetes/services/base/ingress-edge-server-ngrok.yml已被移除。在创建服务网格一节中将添加 Istio Gateway 和 VirtualService 资源的定义文件。
访问 Istio Ingress Gateway 时使用的 IP 地址与访问 Kubernetes Ingress 资源的 IP 地址不同,因此我们还需要更新映射到主机名minikube.me的 IP 地址,我们在运行测试时使用这个主机名。这在本书的设置对 Istio 服务的访问一节中处理。
简化系统架构,用 Jaeger 替换 Zipkin
在在 Istio 中引入运行时组件一节中提到,Istio 内置了对分布式追踪的支持,使用 Jaeger.通过 Jaeger,我们可以卸载并简化微服务架构,删除我们在第十四章,理解分布式追踪中引入的 Zipkin 服务器。
以下是对源代码所做的更改,以移除 Zipkin 服务器:
-
在所有微服务构建文件
build.gradle中,已移除了对org.springframework.cloud:spring-cloud-starter-zipkin的依赖。 -
在三个 Docker Compose 文件
docker-compose.yml、docker-compose-partitions.yml和docker-compose-kafka.yml中,已移除了对 Zipkin 服务器的定义。 -
已删除以下 Zipkin 的 Kubernetes 定义文件:
-
kubernetes/services/base/zipkin-server.yml -
kubernetes/services/overlays/prod/zipkin-server-prod.yml
-
在创建服务网格一节中,将安装 Jaeger。
由于引入了 Istio,对微服务架构进行了更改。现在我们准备在 Kubernetes 集群中部署 Istio。
在 Kubernetes 集群中部署 Istio
在本节中,我们将学习如何在 Kubernetes 集群中部署 Istio 以及如何访问其中的 Istio 服务。
我们将使用在本章撰写时可用的最新版本的 Istio,即 v1.2.4。
我们将使用一个适合在开发环境中测试 Istio 的 Istio 演示配置,即大多数功能启用但配置为最小化资源使用的配置。
此配置不适合生产使用和性能测试。
有关其他安装选项,请参阅istio.io/docs/setup/kubernetes/install。
要部署 Istio,请执行以下步骤:
- 按照以下方式下载 Istio:
cd $BOOK_HOME/Chapter18
curl -L https://git.io/getLatestIstio | ISTIO_VERSION=1.2.4 sh -
- 确保您的 Minikube 实例正在运行以下命令:
minikube status
如果运行正常,预期会收到类似以下内容的响应:

- 在 Kubernetes 中安装 Istio 特定的自定义资源定义(CRDs):
for i in istio-1.2.4/install/kubernetes/helm/istio-init/files/crd*yaml; do kubectl apply -f $i; done
- 按照以下方式在 Kubernetes 中安装 Istio 演示配置:
kubectl apply -f istio-1.2.4/install/kubernetes/istio-demo.yaml
- 等待 Istio 部署变得可用:
kubectl -n istio-system wait --timeout=600s --for=condition=available deployment --all
命令将会逐一报告 Istio 中的部署资源为可用。在命令结束前,预期会收到 12 条类似于deployment.extensions/NNN condition met的消息。这可能会花费几分钟(或更长时间),具体取决于您的硬件和互联网连接速度。
- 使用以下命令更新 Kiali 的配置文件,为其添加 Jaeger 和 Grafana 的 URL:
kubectl -n istio-system apply -f kubernetes/istio/setup/kiali-configmap.yml && \
kubectl -n istio-system delete pod -l app=kiali && \
kubectl -n istio-system wait --timeout=60s --for=condition=ready pod -l app=kiali
配置文件kubernetes/istio/setup/kiali-configmap.yml包含了利用下一节中使用的minikube tunnel命令设置的 DNS 名称的 Jaeger 和 Grafana 的 URL。
Istio 现在已部署在 Kubernetes 中,但在我们继续创建服务网格之前,我们需要了解一些关于如何在 Minikube 环境中访问 Istio 服务的内容。
设置对 Istio 服务的访问
前一小节中用于安装 Istio 的演示配置包含一些与连通性相关的问题需要我们解决。Istio Ingress Gateway 被配置为一个负载均衡的 Kubernetes 服务;也就是说,它的类型是LoadBalancer。
它也可以通过 Minikube 实例的 IP 地址上的节点端口访问,端口范围在30000-32767之间。不幸的是,Istio 中的基于 HTTPS 的路由不能包括端口号;也就是说,Istio 的 Ingress Gateway 必须通过 HTTPS 的默认端口(443)访问。因此,不能使用节点端口。相反,必须使用负载均衡器才能使用 Istio 的路由规则进行 HTTPS 访问。
Minikube 包含一个可以用来模拟本地负载均衡器的命令,即minikube tunnel。此命令为每个负载均衡的 Kubernetes 服务分配一个外部 IP 地址,包括 Istio Ingress Gateway。这将为我们更新主机名minikube.me的翻译提供所需的内容,我们在测试中使用该主机名。现在,主机名minikube.me需要被翻译为 Istio Ingress Gateway 的外部 IP 地址,而不是我们在前几章中使用的 Minikube 实例的 IP 地址。
minikube tunnel命令还使使用它们的 DNS 名称的集群本地 Kubernetes 服务可用。DNS 名称基于命名约定:{service-name}.{namespace}.svc.cluster.local。例如,当隧道运行时,可以从本地网页浏览器通过 DNS 名称kiali.istio-system.svc.cluster.local访问 Istio 的 Kiali 服务。
以下图表总结了如何访问 Istio 服务:

执行以下步骤来设置 Minikube 隧道:
- 使 Kubernetes 服务在本地可用。在一个单独的终端窗口中运行以下命令(当隧道运行时,该命令会锁定终端窗口):
minikube tunnel
请注意,此命令要求您的用户具有sudo权限,并且在启动和关闭时输入您的密码。在命令要求输入密码之前,会有一两秒钟的延迟,所以很容易错过!
-
配置
minikube.me以解析到 Istio Ingress Gateway 的 IP 地址如下:- 获取
minikube tunnel命令为 Istio Ingress Gateway 暴露的 IP 地址,并将其保存为名为INGRESS_HOST的环境变量:
- 获取
INGRESS_HOST=$(kubectl -n istio-system get service istio-ingressgateway -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
-
- 更新
/etc/hosts,使minikube.me指向 Istio Ingress Gateway:
- 更新
echo "$INGRESS_HOST minikube.me" | sudo tee -a /etc/hosts
-
- 删除
/etc/hosts文件中指向 Minikube 实例(minikube ip)IP 地址的minikube.me那一行。确认/etc/hosts只包含将minikube.me翻译为 IP 地址的一条线,并且它指向 Istio Ingress Gateway 的 IP 地址;例如,$INGRESS_HOST的值:
- 删除

- 确认 Kiali,Jaeger 和 Grafana 可以通过隧道访问,使用以下命令:
curl -o /dev/null -s -L -w "%{http_code}" http://kiali.istio-system.svc.cluster.local:20001/kiali/
curl -o /dev/null -s -L -w "%{http_code}" http://grafana.istio-system.svc.cluster.local:3000
curl -o /dev/null -s -L -w "%{http_code}" http://jaeger-query.istio-system.svc.cluster.local:16686
每个命令应返回200(OK)。
minikube tunnel命令可能会因为例如您的计算机或虚拟机中的 Minikube 实例被暂停或重新启动而停止运行。在这些情况下,需要手动重新启动该命令。因此,如果您无法调用https://minikube.me URL 上的 API,或者 Kiali 的 Web UI 无法访问 Jaeger 以可视化分布式跟踪,或者无法通过 Grafana 以可视化性能指标,总是要检查 Minikube 隧道是否正在运行,并在需要时重新启动它。
使用minikube tunnel命令的额外好处
运行minikube tunnel命令还使可以访问一些其他可能感兴趣的集群内部 Kubernetes 服务成为可能。一旦按照运行创建服务网格的命令部分描述的方式启动环境,以下是可以实现的:
product-composite微服务的health端点可以通过以下命令进行检查:
curl -k http://product-composite.hands-on.svc.cluster.local:4004/actuator/health
有关端口4004的用法的解释,请参阅观察服务网格部分。
- 可以在以下命令中访问审查数据库中的 MySQL 表:
mysql -umysql-user-dev -pmysql-pwd-dev review-db -e "select * from reviews" -h mysql.hands-on.svc.cluster.local
- 可以通过以下命令访问
product和recommendations数据库中的 MongoDB 集合:
mongo --host mongodb.hands-on.svc.cluster.local -u mongodb-user-dev -p mongodb-pwd-dev --authenticationDatabase admin product-db --eval "db.products.find()"
mongo --host mongodb.hands-on.svc.cluster.local -u mongodb-user-dev -p mongodb-pwd-dev --authenticationDatabase admin recommendation-db --eval "db.recommendations.find()"
- 可以通过以下 URL 访问 RabbitMQ 的 Web UI:
http://rabbitmq.hands-on.svc.cluster.local:15672。使用rabbit-user-dev和rabbit-pwd-dev凭据登录。
在 Minikube 隧道就位后,我们现在准备创建服务网格。
创建服务网格
在 Istio 部署完成后,我们准备创建服务网格。我们将使用kubernetes/scripts/deploy-dev-env.bash脚本来为开发和测试设置环境。
创建服务网格所需的步骤基本上与我们在第十七章中使用的作为替代实现 Kubernetes 特性(请参阅使用 Kubernetes ConfigMaps, secrets 和 ingress 进行测试部分)相同。首先让我们看看已经对 Kubernetes 定义文件做了哪些添加,以设置服务网格,然后再运行创建服务网格的命令。
源代码更改
为了能够在由 Istio 管理的服务网格中运行微服务,已经对 Kubernetes 定义文件进行了以下更改:
-
部署脚本已更新以注入 Istio 代理
-
Kubernetes 定义文件的文件结构已更改
-
已经添加了 Istio 的 Kubernetes 定义文件
让我们逐一进行。
更新部署脚本以注入 Istio 代理
在kubernetes/scripts文件夹中的用于在 Kubernetes 中部署微服务的脚本deploy-dev-env.bash和deploy-prod-env.bash,都已更新以向五个微服务注入 Istio 代理,即auth-server、product-composite、product、recommendation和review服务。
deploy-prod-env.bash脚本将在执行零停机部署部分使用。
之前在向现有微服务中注入 Istio 代理部分描述的istioctl kube-inject命令已添加到两个部署脚本中,如下所示:
kubectl get deployment auth-server product product-composite recommendation review -o yaml | istioctl kube-inject -f - | kubectl apply -f -
由于kubectl apply命令将启动滚动升级,以下命令已添加以等待升级完成:
waitForPods 5 'version=<version>'
在滚动升级期间,我们将为每个微服务运行两个容器:一个没有 Istio 代理的老版本容器和一个注入了 Istio 代理的新版本容器。waitForPods函数将等待直到老版本容器终止;也就是说,滚动升级完成,只有五个新容器在运行。为了确定要等待哪个容器,使用了一个名为version的标签。在开发环境中,所有微服务容器都标记为version=latest。
例如,产品微服务的部署文件kubernetes/services/base/deployments/product-deployment.yml,version标签的定义如下:
metadata:
labels:
version: latest
在第执行零停机部署部分,我们将从版本v1升级微服务到v2,版本标签将设置为v1和v2。
最后,脚本中已添加以下命令,以使脚本等待部署及其容器就绪:
kubectl wait --timeout=120s --for=condition=Ready pod --all
在查看部署脚本的更新后,让我们看看引入 Istio 后 Kubernetes 定义文件的文件结构有何变化。
更改 Kubernetes 定义文件的文件结构
自第十六章《将我们的微服务部署到 Kubernetes》以来,kubernetes/services中的 Kubernetes 定义文件结构已经有所扩展,Introduction to Kustomize部分进行了说明,现在的结构如下所示:

base文件夹包含三个子文件夹。这是因为在执行零停机部署部分,我们将同时运行微服务的两个版本,即每个微服务版本一个容器。由于容器由部署对象管理,我们还需要每个微服务两个部署对象。为了实现这一点,部署对象的基础版本已放在一个单独的文件夹deployments中,服务对象和 Istio 定义分别放在它们自己的基础文件夹:services和istio。
在开发环境中,我们将为每个微服务运行一个版本。其 kustomization 文件kubernetes/services/overlays/dev/kustomization.yml已更新,以将所有三个文件夹作为基本文件夹包括在内:
bases:
- ../../base/deployments
- ../../base/services
- ../../base/istio
有关如何在生产环境设置中部署两个并发微服务版本的详细信息,请参阅后面的零停机部署部分。
现在,让我们也浏览一下 Istio 文件夹中的新文件。
为 Istio 添加 Kubernetes 定义文件
在istio文件夹中添加了 Istio 定义。本节中感兴趣的 Istio 文件是网关定义及其相应的虚拟服务。其他 Istio 文件将在使用 OAuth 2.0/OIDC 访问令牌进行外部请求认证和使用相互认证(mTLS)保护内部通信部分进行解释。
Istio 网关在kubernetes/services/base/istio/gateway.yml文件中声明,如下所示:
apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
name: hands-on-gw
spec:
selector:
istio: ingressgateway
servers:
- hosts:
- "minikube.me"
port:
number: 443
name: https
protocol: HTTPS
tls:
mode: SIMPLE
serverCertificate: /etc/istio/ingressgateway-certs/tls.crt
privateKey: /etc/istio/ingressgateway-certs/tls.key
以下是对前面源代码的一些解释:
-
网关名为
hands-on-gw;这个名称被下面的虚拟服务使用。 -
selector字段指定网关资源将由内置的 Istio Ingress Gateway 处理。 -
hosts和port字段指定网关将使用端口443上的 HTTPS 处理minikube.me主机名的传入请求。 -
tls字段指定了 Istio Ingress Gateway 用于 HTTPS 通信的证书和私钥的位置。有关这些证书文件如何创建的详细信息,请参阅使用 HTTPS 和证书保护外部端点部分。
用于将网关请求路由到product-composite服务的虚拟服务对象,即kubernetes/services/base/istio/product-composite-virtual-service.yml,如下所示:
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: product-composite-vs
spec:
hosts:
- "minikube.me"
gateways:
- hands-on-gw
http:
- match:
- uri:
prefix: /product-composite
route:
- destination:
port:
number: 80
host: product-composite
前面源代码的解释如下:
-
hosts字段指定虚拟服务将路由发送到主机minikube.me的请求。 -
match和route块指定包含以/product-composite开头的 URI 的请求将被转发到名为product-composite的 Kubernetes 服务。
在前面的源代码中,目标主机使用其简称指定,也就是说,product-composite。由于本章节的示例将所有 Kubernetes 定义保存在同一个命名空间hands-on中,因此这样可以工作。如果不是这种情况,Istio 文档建议使用主机的完全限定域名(FQDN)代替,即product-composite.hands-on.svc.cluster.local。
最后,虚拟服务对象用于将网关请求路由到认证服务器,即kubernetes/services/base/istio/auth-server-virtual-service.yml,看起来非常相似,不同之处在于它将带有/oauth前缀的请求路由到 Kubernetes 服务auth-server。
在源代码中实施了这些更改后,我们现在准备创建服务网格。
运行创建服务网格的命令
通过运行以下命令创建服务网格:
- 使用以下命令从源代码构建 Docker 镜像:
cd $BOOK_HOME/Chapter18
eval $(minikube docker-env)
./gradlew build && docker-compose build
- 重新创建
hands-on命名空间,并将其设置为默认命名空间:
kubectl delete namespace hands-on
kubectl create namespace hands-on
kubectl config set-context $(kubectl config current-context) --namespace=hands-on
- 通过运行以下命令执行部署:
./kubernetes/scripts/deploy-dev-env.bash
- 部署完成后,验证我们每个微服务 pods 中都有两个容器:
kubectl get pods
期望的响应类似于以下内容:

注意,运行我们微服务的 pods 每个 pods 报告两个容器;也就是说,它们有一个作为边车的 Istio 代理被注入!
- 使用以下命令运行常规测试:
./test-em-all.bash
script test-em-all.bash的默认值已从之前的章节更新,以适应在 Minikube 中运行的 Kubernetes。
期望输出与我们在前面的章节中看到的内容类似:

- 你可以通过运行以下命令手动尝试 API。
ACCESS_TOKEN=$(curl -k https://writer:secret@minikube.me/oauth/token -d grant_type=password -d username=magnus -d password=password -s | jq .access_token -r)
curl -ks https://minikube.me/product-composite/2 -H "Authorization: Bearer $ACCESS_TOKEN" | jq .productId
期望在响应中收到请求的产品 ID,2。
服务网格运行起来后,让我们看看如何使用 Kiali 观察服务网格中正在发生的事情!
观察服务网格
在本节中,我们将使用 Kiali 和 Jaeger 一起观察服务网格中的情况。有关使用 Grafana 进行性能监控的信息,请参阅第二十章,监控微服务。
在这样做之前,我们需要消除由 Kubernetes 的生存和就绪探针执行的健康检查产生的某些噪声。在前面的章节中,它们一直使用与 API 请求相同的端口。这意味着 Istio 将收集健康检查和发送到 API 的请求的遥测数据。这会导致 Kiali 显示的图表不必要地变得混乱。Kiali 可以过滤掉我们不感兴趣的流量,但一个更简单的解决方案是使用不同的端口进行健康检查。
微服务可以配置为使用单独的端口来发送到 actuator 端点的请求,例如,发送到/actuator/health端点的健康检查。所有微服务的共同配置文件config-repo/application.yml中已添加以下行:
management.server.port: 4004
这将使所有微服务使用端口4004来暴露健康端点。kubernetes/services/base/deployments文件夹中的所有部署文件都已被更新为在其生存和就绪探针中使用端口4004。
Spring Cloud Gateway(保留此内容,这样我们可以在 Docker Compose 中运行测试)将继续为 API 和health端点的请求使用相同的端口。在config-repo/gateway.yml配置文件中,管理端口已恢复为用于 API 的端口:
management.server.port: 8443
处理了发送到健康端点的请求后,我们可以开始通过服务网格发送一些请求。
我们将使用siege开始一次低流量的负载测试,我们在第十六章中了解过,将我们的微服务部署到 Kubernetes(参考执行滚动升级部分)。之后,我们将浏览 Kiali 的一些最重要的部分,了解如何使用 Kiali 观察服务网格。我们还将探索 Kiali 与 Jaeger 的集成以及 Jaeger 如何用于分布式跟踪:
使用以下命令开始测试:
ACCESS_TOKEN=$(curl -k https://writer:secret@minikube.me/oauth/token -d grant_type=password -d username=magnus -d password=password -s | jq .access_token -r)
siege https://minikube.me/product-composite/2 -H "Authorization: Bearer $ACCESS_TOKEN" -c1 -d1
第一个命令将获取一个 OAuth 2.0/OIDC 访问令牌,该令牌将在下一个命令中使用,其中siege用于向 product-composite API 每秒提交一个 HTTP 请求。
预计siege命令的输出如下:

- 使用
http://kiali.istio-system.svc.cluster.local:20001/kialiURL 在 Web 浏览器中打开 Kiali 的 Web UI,并使用以下用户名和密码登录:admin和admin。预计会出现如下网页:

-
点击概述标签,如果尚未激活。
-
在 hands-on 命名空间中点击图表图标。预计会显示一个图表,代表服务网格当前的流量流向,如下所示:

-
点击显示按钮,取消选择服务节点,选择流量动画。
Kiali 显示一个代表通过服务网格发送的当前请求的图表,其中活动请求由小移动圆圈和箭头表示。
这为服务网格中正在发生的事情提供了相当不错的初始概览!
-
现在让我们使用 Jaeger 进行一些分布式跟踪:

-
点击产品节点。
-
点击服务:product 链接。在服务网页上,点击菜单中的跟踪标签,Kiali 将使用 Jaeger 显示产品服务参与的跟踪的内嵌视图。预计会出现如下网页:

- 点击其中一个跟踪项进行检查。预计会出现如下网页:

这基本上与 Zipkin 相同跟踪信息,在第十四章中提供,理解分布式跟踪。
还有很多值得探索的内容,但这已经足够作为一个介绍了。可以自由地探索 Kiali、Jaeger 和 Grafana 的 Web UI。
在第二十章中,监控微服务,我们将进一步探索性能监控功能。
让我们继续学习如何使用 Istio 改进服务网格的安全性!
保护服务网格
在本节中,我们将学习如何使用 Istio 提高服务网格的安全性。我们将涵盖以下主题:
-
如何使用 HTTPS 和证书保护外部端点
-
如何要求外部请求使用 OAuth 2.0/OIDC 访问令牌进行认证
-
如何使用相互认证(mTLS)保护内部通信
让我们现在理解这些每个部分。
使用 HTTPS 和证书保护外部端点
在创建服务网格一节中,我们看到了 Istio Ingress Gateway 是如何配置使用以下证书文件来保护通过 HTTPS 发送到minikube.me的外部请求的。Istio Ingress Gateway 的配置如下:
spec:
servers:
- hosts:
- "minikube.me"
...
tls:
mode: SIMPLE
serverCertificate: /etc/istio/ingressgateway-certs/tls.crt
privateKey: /etc/istio/ingressgateway-certs/tls.key
但这些文件是从哪里来的,你可能会问?
我们可以通过运行以下命令来查看 Istio Ingress Gateway 的配置:
kubectl -n istio-system get deploy istio-ingressgateway -o json
我们会发现它准备挂载一个名为istio-ingressgateway-certs的可选证书,并将其映射到/etc/istio/ingressgateway-certs/文件夹:

这导致从名为istio-ingressgateway-certs的秘密中创建了证书文件tls.crt和tls.key,使 Istio Ingress Gateway 能够在/etc/istio/ingressgateway-certs/tls.crt和/etc/istio/ingressgateway-certs/tls.key文件路径上使用。
这个秘密的创建是通过在kubernetes/scripts文件夹中使用deploy-dev-env.bash和deploy-prod-env.bash部署脚本来处理的,使用以下命令:
kubectl create -n istio-system secret tls istio-ingressgateway-certs \
--key kubernetes/cert/tls.key --cert kubernetes/cert/tls.crt
证书文件是在第十七章实现 Kubernetes 特性作为替代中创建的,使用 Kubernetes ConfigMaps、secrets 和 ingress 进行测试部分。
为了验证确实是这些证书被 Istio Ingress Gateway 使用,我们可以运行以下命令:
keytool -printcert -sslserver minikube.me:443 | grep -E "Owner:|Issuer:"
期待以下输出:

输出显示证书是为minikube.se发行的,并且是自签名的,即发行者也是minikube.me。
这个自签名的证书可以替换为信任的证书授权机构(CA)为生产使用案例购买的证书。Istio 最近添加了对使用例如 cert manager 和 Let's Encrypt 的信任证书的自动化支持,正如我们在第十七章实现 Kubernetes 特性作为替代中一样,使用 cert manager 和 Let's Encrypt 提供证书部分。这种支持目前过于复杂,不适合这一章。
证书配置验证完成后,让我们接下来看看 Istio Ingress Gateway 如何保护微服务免受未经认证的请求**。
使用 OAuth 2.0/OIDC 访问令牌验证外部请求
Istio Ingress Gateway 能够要求并验证基于 JWT 的 OAuth 2.0/OIDC 访问令牌,换句话说,保护服务网格中的微服务免受外部未认证请求的侵害。回顾 JWT,OAuth 2.0 和 OIDC,请参阅第十一章《保护 API 的访问》,安全的 API 访问(参考使用 OAuth 2.0 和 OpenID Connect 对 API 访问进行身份验证和授权部分)。
为了启用身份验证,我们需要创建一个 Istio Policy对象,指定应受保护的目标和应信任的访问令牌发行商,即 OAuth 2.0/OIDC 提供程序。这是在kubernetes/services/base/istio/jwt-authentication-policy.yml文件中完成的,并如下所示:
apiVersion: "authentication.istio.io/v1alpha1"
kind: "Policy"
metadata:
name: "jwt-authentication-policy"
spec:
targets:
- name: product-composite
peers:
- mtls:
mode: PERMISSIVE
origins:
- jwt:
issuer: "http://auth-server.local"
jwksUri: "http://auth-server.hands-on.svc.cluster.local/.well-known/jwks.json"
principalBinding: USE_ORIGIN
以下源代码的解释如下:
-
targets列表指定了将对发送到product-composite微服务的请求执行身份验证检查。 -
origins列表指定了我们依赖的 OAuth 2.0/OIDC 提供程序。对于每个发行者,指定了发行者的名称及其 JSON Web 密钥集的 URL。回顾一下,请参阅第十一章《保护 API 的访问》,介绍 OpenId Connect(参考使用 OAuth 2.0 和 OpenID Connect 对 API 访问进行身份验证和授权部分)。我们已经指定了本地认证服务器,http://auth-server.local。
策略文件是由kubernetes/scripts/deploy-dev-env.bash部署脚本在运行创建服务网格的命令部分应用的。
要验证无效请求是否被 Istio Ingress Gateway 拒绝而不是product-composite微服务,最简单的方法是发送一个没有访问令牌的请求,并观察返回的错误信息。在认证失败的情况下,Istio Ingress Gateway 会返回以下错误信息,Origin authentication failed.,而product-composite微服务会返回一个空字符串。两者都返回 HTTP 代码401(未授权)。
使用以下命令尝试:
- 发送类似以下内容的无访问令牌请求:
curl https://minikube.me/product-composite/2 -kw " HTTP Code: %{http_code}\n"
期待一个说Origin authentication failed. HTTP Code: 401的响应。
- 暂时使用以下命令删除策略:
kubectl delete -f kubernetes/services/base/istio/jwt-authentication-policy.yml
等待一分钟,让策略更改传播到 Istio Ingress Gateway,然后尝试不带访问令牌发送请求。现在响应应仅包含 HTTP 代码:HTTP Code: 401。
- 使用以下命令再次启用策略:
kubectl apply -f kubernetes/services/base/istio/jwt-authentication-policy.yml
建议的额外练习:尝试使用 Auth0 OIDC 提供程序,如第十一章《保护 API 的访问》所述,使用 OpenID Connect 提供程序 Auth0 进行测试。将您的 Auth0 提供程序添加到jwt-authentication-policy.yml中。在我的情况下,它如下所示:
- jwt:
issuer: "https://dev-magnus.eu.auth0.com/"
jwksUri: "https://dev-magnus.eu.auth0.com/.well-known/jwks.json"
现在,让我们转向我们将要在 Istio 中涵盖的最后一种安全机制——使用相互认证(mTLS)保护服务网格内部通信的自动保护。
使用相互认证(mTLS)保护内部通信
在本节中,我们将学习如何配置 Istio 以自动保护服务网格内部通信,使用相互 认证,即 mTLS。在使用相互认证时,服务端通过暴露证书来证明其身份,客户端也通过暴露客户端证书来向服务器证明其身份。与仅证明服务器身份的正常 TLS/HTTPS 使用相比,这提供了更高的安全性。设置和维护相互认证——即提供新的证书和轮换过时的证书——被认为很复杂,因此很少使用。Istio 完全自动化了用于服务网格内部通信的相互认证的证书提供和轮换。这使得与手动设置相比,使用相互认证变得容易得多。
那么,我们为什么应该使用相互认证?仅仅保护外部 API 的 HTTPS 和 OAuth 2.0/OIDC 访问令牌难道还不够吗?
只要攻击是通过外部 API 发起的,可能就足够了。但如果 Kubernetes 集群中的一个 pods 被攻陷了会怎样呢?例如,如果攻击者控制了一个 pods,那么攻击者可以开始监听 Kubernetes 集群中其他 pods 之间的通信。如果内部通信以明文形式发送,攻击者将很容易获取集群中 pods 之间传输的敏感信息。为了尽量减少此类入侵造成的损害,可以使用相互认证来防止攻击者监听内部网络流量。
为了启用由 Istio 管理的相互认证的使用,Istio 需要在服务器端使用策略进行配置,在客户端使用目的地规则进行配置。
在使用 Istio 的演示配置时,如我们在在 Kubernetes 集群中部署 Istio一节中那样,我们创建了一个全局网格策略,配置服务器端使用宽容模式,这意味着 Istio 代理将允许明文和加密的请求。这可以通过以下命令来验证:
kubectl get meshpolicy default -o yaml
期望的回答与以下类似:

为了使微服务在向其他微服务内部发送请求时使用相互认证,需要为每个微服务创建一个目的地规则。这是在kubernetes/services/base/istio/internal_mtls_destination_rules.yml文件中完成的。目的地规则看起来都一样;例如,对于product-composite服务,它们如下所示:
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
name: product-composite-dr
spec:
host: product-composite
trafficPolicy:
tls:
mode: ISTIO_MUTUAL
trafficPolicy 设置为使用 tls 与 ISTIO_MUTUAL,这意味着相互认证是由 Istio 管理的。
目的地规则是在前面的运行创建服务网格的命令部分中使用 kubernetes/scripts/deploy-dev-env.bash 部署脚本时应用的。
为了验证内部通信是否得到保护,请执行以下步骤:
-
确保在前面的观察服务网格部分中启动的负载测试仍在运行。
-
在网络浏览器中打开 Kiali 图(
kiali.istio-system.svc.cluster.local:20001/kiali)。 -
点击显示按钮以启用安全标签。图表将在所有由 Istio 自动相互认证保护的通信链路上显示一个锁,如下所示:

期望所有链接上都有一个锁,除了那些到资源管理器的链接——RabbitMQ、MySQL 和 MongoDB。
对 RabbitMQ、MySQL 和 MongoDB 的调用不由 Istio 代理处理,因此需要手动配置以使用 TLS 进行保护。
有了这些,我们已经看到了 Istio 中所有三种安全机制的实际应用,现在该看看 Istio 是如何帮助我们验证服务网格具有弹性的。
确保服务网格具有弹性
在本节中,我们将学习如何使用 Istio 确保服务网格具有弹性;也就是说,它能够处理服务网格中的临时故障。Istio 提供了类似于 Spring Framework 在超时、重试以及称为异常检测的类型断路器方面的机制来处理临时故障。当涉及到决定是否使用语言原生机制来处理临时故障,或者是否将此任务委托给如 Istio 的服务网格时,我倾向于使用语言原生机制,如在第十三章、使用 Resilience4J 提高弹性中的例子所示。在许多情况下,将处理错误的逻辑与其他微服务业务逻辑保持在一起是很重要的,例如为断路器处理回退选项。
在 Istio 中相应的机制可以提供很大帮助的情况。例如,如果一个微服务被部署,并且确定它不能处理在生产中偶尔发生的临时故障,那么使用 Istio 添加一个超时或重试机制而不是等待具有相应错误处理功能的微服务新版本发布将非常方便。
随着 Istio 而来的弹性方面的另一个功能是向现有服务网格注入故障和延迟的能力。为什么有人想这么做呢?
以受控的方式注入故障和延迟非常有用,可以验证微服务中的弹性能力是否如预期工作!我们将在本节中尝试它们,验证 product-composite 微服务中的重试、超时和断路器是否如预期工作。
在第十三章 使用 Resilience4j 提高韧性 中(参考 在微服务源代码中添加可编程延迟和随机错误 部分),我们添加了对在微服务源代码中注入故障和延迟的支持。最好使用 Istio 的功能在运行时注入故障和延迟,如下所示。
我们将首先注入故障,以查看 product-composite 微服务中的重试机制是否按预期工作。之后,我们将延迟产品服务的响应,并验证断路器如何按预期处理延迟。
通过注入故障测试弹性
让我们让产品服务抛出随机错误,并验证微服务架构是否正确处理此情况。我们期望 product-composite 微服务中的重试机制启动,并重试请求,直到成功或达到最大重试次数限制。这将确保短暂故障不会比重试尝试引入的延迟对最终用户产生更大影响。参考第十三章 使用 Resilience4j 提高韧性 中的 添加重试机制 部分,回顾 product-composite 微服务中的重试机制。
可以使用 kubernetes/resilience-tests/product-virtual-service-with-faults.yml 注入故障。这如下所示:
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: product-vs
spec:
hosts:
- product
http:
- route:
- destination:
host: product
fault:
abort:
httpStatus: 500
percent: 20
定义说明,发送到产品服务的 20%的请求应使用 HTTP 状态码 500(内部服务器错误)终止。
执行以下步骤以测试此功能:
-
确保正在运行使用
siege的负载测试,如在第 观察服务网格 节中启动。 -
使用以下命令应用故障注入:
kubectl apply -f kubernetes/resilience-tests/product-virtual-service-with-faults.yml
- 监控
siege负载测试工具的输出。期望输出类似于以下内容:

从样本输出中,我们可以看到所有请求仍然成功,换句话说,返回状态 200(OK);然而,其中一些(20%)需要额外一秒钟才能完成。这表明 product-composite 微服务中的重试机制已经启动,并重试了发送到产品服务的失败请求。
-
Kiali 也会指示产品服务收到的请求存在问题,如下所示:
-
前往我们之前用于观察我们命名空间
hands-on中的流量的 Kiali web UI 的调用图。 -
点击显示菜单按钮,选择服务节点。
-
点击显示按钮左侧的菜单按钮,名为无边缘标签,然后选择响应时间选项。
-
-
- 图表将显示如下内容:

服务节点产品的箭头将显示为红色,以指示检测到失败请求。如果我们点击箭头,可以在右侧看到故障统计。
在前面的示例屏幕截图中,报告的错误率为 19.4%,与我们要求的 20%相符。请注意,从 Istio 网关到product-composite服务的箭头仍然是绿色的。这意味着product-composite服务中的重试机制保护了终端用户;换句话说,故障不会传播到终端用户。
使用以下命令结束故障注入的移除:
kubectl delete -f kubernetes/resilience-tests/product-virtual-service-with-faults.yml
现在让我们进入下一节,在那里我们将注入延迟以触发电路断路器。
通过注入延迟来测试弹性
从第十三章,使用 Resilience4j 提高弹性(参考介绍电路断路器部分),我们知道电路断路器可以用来防止服务响应缓慢或服务根本不响应的问题。
让我们通过向product-composite服务中注入延迟来验证电路断路器是否按预期工作。可以使用虚拟服务注入延迟。
请参考kubernetes/resilience-tests/product-virtual-service-with-delay.yml。其代码如下所示:
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: product-vs
spec:
hosts:
- product
http:
- route:
- destination:
host: product
fault:
delay:
fixedDelay: 3s
percent: 100
前面的定义说明所有发送到产品服务的请求都应延迟 3 秒。
从product-composite服务发送到产品服务的请求配置为在 2 秒后超时。如果连续三次请求失败,电路断路器配置为打开电路。当电路打开时,它将快速失败;换句话说,它将立即抛出异常,不尝试调用底层服务。product-composite微服务中的业务逻辑将捕获此异常并应用回退逻辑。回顾一下,请参阅第十三章,使用 Resilience4j 提高弹性(参考添加电路断路器部分)。
通过以下步骤测试通过注入延迟的电路断路器:
-
通过在运行
siege的终端窗口中按下Ctrl + C命令来停止负载测试运行。 -
使用以下命令在产品服务中创建临时延迟:
kubectl apply -f kubernetes/resilience-tests/product-virtual-service-with-delay.yml
- 如下获取访问令牌:
ACCESS_TOKEN=$(curl -k https://writer:secret@minikube.me/oauth/token -d grant_type=password -d username=magnus -d password=password -s | jq .access_token -r)
- 连续发送六个请求。期望在第一次三次失败调用后打开电路,电路断路器对最后三次调用应用快速失败逻辑,并返回回退响应,如下所示:
for i in {1..6}; do time curl -k https://minikube.me/product-composite/2 -H "Authorization: Bearer $ACCESS_TOKEN"; done
前三次调用的响应预期会出现与超时相关的错误信息,响应时间为 2 秒,换句话说,就是超时时间。期望前三次调用的响应类似于以下内容:

最后 3 次调用的响应预计将是回退逻辑的响应,响应时间短。期待最后 3 次调用的响应如下:

- 通过以下命令删除临时延迟来模拟延迟问题已解决:
kubectl delete -f kubernetes/resilience-tests/product-virtual-service-with-delay.yml
- 通过使用前一个命令发送新请求,验证正确答案再次返回,且没有任何延迟。
如果您想检查熔断器的状态,您可以使用以下命令:
curl product-composite.hands-on.svc.cluster.local:4004/actuator/health -s | jq -r .details.productCircuitBreaker.details.state
它应该报告CLOSED,OPEN或HALF_OPEN,具体取决于其状态。
这证明了我们使用 Istio 注入延迟时,熔断器按预期反应。这结束了在 Istio 中测试可以验证微服务架构具有弹性的功能。我们将在 Istio 中探索的最后一项功能是其对流量管理的支持;我们将了解如何使用它来实现零停机部署。
执行零停机部署
正如在第十六章中提到的将我们的微服务部署到 Kubernetes(请参阅执行滚动升级部分),随着越来越多的自治微服务独立于彼此进行更新,能够在不停机的情况下部署更新变得至关重要。
在本节中,我们将学习 Istio 的流量管理和路由功能,以及如何使用它们来执行微服务的全新版本的部署,而不需要任何停机时间。在第十六章中,我们看到了 Kubernetes 如何用来执行滚动升级而不需要停机。(请参阅执行滚动升级部分)。使用 Kubernetes 的滚动升级机制可以自动化整个过程,但不幸的是,它提供了一个测试新版本的机会,在所有用户都被路由到新版本之前。
使用 Istio,我们可以部署新版本,但最初将所有用户路由到现有版本(在本章中称为旧版本)。在那之后,我们可以使用 Istio 的精细路由机制来控制用户如何被路由到新旧版本。我们将了解如何使用 Istio 实现两种流行的升级策略:
-
**金丝雀部署*: 当使用金丝雀部署时,大多数用户被路由到旧版本,除了被选中的测试用户被路由到新版本。当测试用户批准了新版本,可以使用蓝/绿部署将普通用户路由到新版本。
-
蓝/绿 部署:传统上,蓝/绿部署意味着所有用户都被切换到蓝色或绿色版本之一,一个是新版本,另一个是旧版本。如果切换到新版本时出现问题,很容易切换回旧版本。使用 Istio,可以通过逐渐将用户切换到新版本来优化此策略,例如,从 20%的用户开始,然后逐渐增加被路由到新版本的用户百分比。在任何时候,如果新版本中揭示了致命错误,很轻易地将所有用户路由回旧版本。
正如在第十六章《将我们的微服务部署到 Kubernetes》(参考执行滚动升级部分)中提到的,这些升级策略的一个重要前提是升级是向后兼容的。这种升级在 API 和消息格式上都兼容,这些是与其他服务和数据库结构通信所使用的。如果微服务的新版本需要对外部 API、消息格式或数据库结构进行更改,而旧版本无法处理,则无法应用这些升级策略。
我们将在以下子节中逐步部署以下部署场景:
-
我们首先部署
v1和v2版本的微服务,路由配置为将所有请求发送到v1版本的微服务。 -
接下来,我们将允许一个测试组运行金丝雀测试;也就是说,我们将验证微服务的新的
v2版本。为了简化测试,我们将只将测试用户路由到核心微服务的新版本,即product、recommendation和review微服务。 -
最后,我们将开始使用蓝/绿部署将普通用户切换到新版本:最初是一小部分用户,然后随着时间的推移,越来越多的用户,直到最终所有用户都被路由到新版本。我们还将了解如何在检测到新 v2 版本中的致命错误时,快速切换回
v1版本。
首先,让我们看看为了部署两个并发版本的微服务(v1和v2),源代码中已经应用了哪些更改。
源代码更改
正如在改变 Kubernetes 定义文件的文件结构章节中提到的,为了支持在生产环境中部署微服务的并发版本,在本章中kubernetes/services中的 Kubernetes 定义文件的文件结构进行了扩展。扩展后的文件结构如下所示:

前述图表中已经移除了有关开发环境的具体细节。
首先让我们看看微服务v1和v2版本的服务和部署对象是如何配置和创建的。之后,我们将查看用于控制路由的 Istio 对象的附加定义文件。
微服务并发版本的服务和部署对象
为了能够同时运行微服务的多个版本,部署对象及其相应的 pods 必须有不同的名称,例如product-v1和product-v2。然而,每个微服务只能有一个 Kubernetes 服务对象。所有特定微服务的流量总是通过一个相同的 service 对象,无论请求最终会被路由到哪个版本的 pods。这是通过将部署对象和服务对象分成不同的文件夹来使用 Kustomize 实现的。
为了给部署对象及其 pods 赋予版本依赖的名称,kustomization.yml文件可以使用nameSuffix指令告诉 Kustomize 在它创建的所有 Kubernetes 对象上添加给定后缀。例如,用于v1版本的微服务在kubernetes/services/overlays/prod/v1文件夹中的kustomization.yml文件如下所示:
nameSuffix: -v1
bases:
- ../../../base/deployments
patchesStrategicMerge:
- ...
nameSuffix: -v1设置会导致使用这个kustomization.yml文件创建的所有对象都带有-v1后缀。
为了创建没有版本后缀的对象,以及具有v1和v2版本后缀的部署对象及其 pods,kubernetes/scripts/deploy-prod-env.bash部署脚本执行单独的kubectl apply命令,如下所示:
kubectl apply -k kubernetes/services/base/services
kubectl apply -k kubernetes/services/overlays/prod/v1
kubectl apply -k kubernetes/services/overlays/prod/v2
我们再看看我们都添加了哪些 Istio 定义文件来配置路由规则。
添加了用于 Istio 的 Kubernetes 定义文件。
为了配置路由规则,我们将向kubernetes/services/overlays/prod/istio文件夹添加 Istio 对象。每个微服务都有一个虚拟服务对象,定义了旧版本和新版本之间的路由权重分布。最初,它被设置为将 100%的流量路由到旧版本。例如,产品微服务在product-routing-virtual-service.yml中的路由规则如下所示:
http:
- route:
- destination:
host: product
subset: old
weight: 100
- destination:
host: product
subset: new
weight: 0
虚拟服务定义了旧版本和新版本的子集。为了定义旧版本和新版本实际的版本,每个微服务也定义了一个目的地规则。目的地规则详细说明了如何识别旧子集和新子集,例如,在old_new_subsets_destination_rules.yml中的产品微服务:
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
name: product-dr
spec:
host: product
subsets:
- name: old
labels:
version: v1
- name: new
labels:
version: v2
名为old的子集指向标签version设置为v1的产品 pods,而名为new的子集指向标签version设置为v2的 pods。
为了将流量路由到特定版本,Istio 文档建议将 pod 标记为名为 version 的标签以标识其版本。 有关详细信息,请参阅 istio.io/docs/setup/kubernetes/additional-setup/requirements/。
最后,为了支持金丝雀测试人员,在三个核心微服务(product、recommendation 和 review)的虚拟服务中添加了一个额外的路由规则。 此路由规则说明,任何带有名为 X-group 的 HTTP 头的 incoming 请求设置为值 test 总是会路由到服务的新版本。 它如下所示:
http:
- match:
- headers:
X-group:
exact: test
route:
- destination:
host: product
subset: new
match 和 route 部分指定,带有 HTTP 头 X-group 设置为 test 值的请求将被路由到名为 new 的子集中。
为了创建这些 Istio 对象,kubernetes/scripts/deploy-prod-env.bash 部署脚本执行了以下命令:
kubectl apply -k kubernetes/services/overlays/prod/istio
最后,为了能够基于基于头部的路由将金丝雀测试人员路由到新版本,product-composite 微服务已更新以转发 HTTP 头 X-group。 有关详细信息,请参阅 se.magnus.microservices.composite.product.services.ProductCompositeServiceImpl 类中的 getCompositeProduct() 方法。
现在,我们已经看到了源代码的所有更改,我们准备部署微服务的 v1 和 v2 版本。
部署带有路由到 v1 版本的 v1 和 v2 版本的微服务
为了能够测试微服务的 v1 和 v2 版本,我们需要删除本章前面一直在使用的开发环境,并创建一个生产环境,我们可以在其中部署 v1 和 v2 版本的微服务。
为了实现这一点,请运行以下命令:
- 重新创建
hands-on命名空间:
kubectl delete namespace hands-on
kubectl create namespace hands-on
- 通过运行以下命令来执行部署脚本:
./kubernetes/scripts/deploy-prod-env.bash
该命令需要几分钟时间,最终应如下列出所有 v1 或 v2 版本的 pod:

- 执行以下常规测试以验证一切是否正常工作:
SKIP_CB_TESTS=true ./test-em-all.bash
如果在此命令执行后立即执行 deploy 命令,有时会失败。 只需重新运行命令,它应该会正常运行!
由于我们现在为每个微服务运行了两个 pod(V1 和 V2 版本),因此电路测试不再工作。 这是因为测试脚本无法控制它通过 Kubernetes 服务与哪个 pod 进行通信。 测试脚本通过 product-composite 微服务的 actuator 端口 4004 查询电路 breaker 的状态。 此端口不由 Istio 管理,因此其路由规则不适用。 测试脚本因此不知道它是在检查 product-composite 微服务的 V1 或 V2 版本的电路 breaker 状态。 我们可以通过使用 SKIP_CB_TESTS=true 标志来跳过电路测试。
期望输出类似于我们在前几章中看到的内容,但排除熔断器测试:

我们现在准备好运行一些零停机部署测试。首先,让我们验证所有流量都路由到了微服务的 v1 版本!
验证所有流量最初都路由到 v1 版本的微服务
为了验证所有请求都被路由到微服务的 v1 版本,我们将启动负载测试工具siege,然后观察通过服务网格使用 Kiali 流动的流量。
执行以下步骤:
- 获取一个新的访问令牌并启动
siege负载测试工具,使用以下命令:
ACCESS_TOKEN=$(curl -k https://writer:secret@minikube.me/oauth/token -d grant_type=password -d username=magnus -d password=password -s | jq .access_token -r)
siege https://minikube.me/product-composite/2 -H "Authorization: Bearer $ACCESS_TOKEN" -c1 -d1
-
在 Kiali 的 Web UI 中转到图表视图(
kiali.istio-system.svc.cluster.local:20001/kiali):-
点击显示菜单按钮并取消选择服务节点。
-
在一两分钟后,期望只有以下 v1 版本的微服务流量:
-

太好了!这意味着尽管已经部署了微服务的 v2 版本,但它们并没有获得任何路由到的流量。现在让我们尝试进行金丝雀测试,其中选定的测试用户被允许尝试微服务的 v2 版本!
运行金丝雀测试
要运行金丝雀测试,换句话说,即所有其他用户仍然被路由到部署的微服务的旧版本时,我们需要在我们的对外 API 请求中添加X-groupHTTP 头,设置为test值。
要查看响应了请求的微服务的哪个版本,可以检查响应中的serviceAddresses字段。serviceAddresses字段包含每个参与创建响应的服务的主机名。主机名等于 pods 的名称,因此我们可以通过主机名找到版本;例如,对于版本 V1 的产品服务,主机名为product-v1-...,对于版本 V2 的产品服务,主机名为product-v2-...。
首先,发送一个正常请求并验证是微服务的 v1 版本响应了我们的请求。接下来,发送一个X-groupHTTP 头设置为test值的请求,并验证新的 v2 版本正在响应。
为此,执行以下步骤:
- 发送一个正常请求以验证请求被路由到微服务的 v1 版本,使用
jq过滤掉响应中的serviceAddresses字段:
curl -ks https://minikube.me/product-composite/2 -H "Authorization: Bearer $ACCESS_TOKEN" | jq .serviceAddresses
期望得到如下类似的响应:

正如预期的那样,所有三个核心服务都是微服务的 v1 版本。
- 如果我们添加了
X-group=test头,我们期望请求由核心微服务的 v2 版本处理。运行以下命令:
curl -ks https://minikube.me/product-composite/2 -H "Authorization: Bearer $ACCESS_TOKEN" -H "X-group: test" | jq .serviceAddresses
期望得到如下类似的响应:

如预期那样,现在所有三个核心微服务都是 v2 版本;也就是说,作为金丝雀测试员,我们被路由到新的 v2 版本!
考虑到金丝雀测试返回了预期结果,我们准备允许普通用户通过蓝/绿部署路由到新的 v2 版本。
运行蓝/绿测试
为了将部分普通用户路由到微服务的新的 v2 版本,我们必须修改虚拟服务中的权重分布。目前它们是 100/0;也就是说,所有流量都被路由到旧的 v1 版本。我们可以像以前一样实现这一点,即通过编辑kubernetes/services/overlays/prod/istio文件夹中虚拟服务的定义文件,然后运行kubectl apply命令以使更改生效。作为一种替代方案,我们可以使用kubectl patch命令直接在 Kubernetes API 服务器中的虚拟服务对象上更改权重分布。
我发现 patch 命令在要对同一对象进行许多更改以尝试某些东西时很有用,例如,更改路由规则中的权重分布。在本节中,我们将使用kubectl patch命令快速更改微服务 v1 和 v2 版本之间的路由规则的权重分布。要在一系列kubectl patch命令执行后获取虚拟服务的状态,可以发出类似于kubectl get vs NNN -o yaml的命令。例如,要获取产品微服务的虚拟服务状态,可以发出以下命令:kubectl get vs product-vs -o yaml。
由于我们以前没有使用过kubectl patch命令,而且一开始可能会有些复杂,因此在执行绿/蓝部署之前,让我们先简要介绍它的工作原理。
简要介绍 kubectl patch 命令
kubectl patch命令可用于在 Kubernetes API 服务器中更新现有对象的具体字段。我们将尝试在名为review-vs的评论微服务的虚拟服务上使用 patch 命令。虚拟服务review-vs的定义的相关部分如下所示:
spec:
http:
- match:
...
- route:
- destination:
host: review
subset: old
weight: 100
- destination:
host: review
subset: new
weight: 0
完整的源代码请参阅kubernetes/services/overlays/prod/istio/review-routing-virtual-service.yml。
一个改变review微服务中路由到 v1 和 v2 容器的权重分布的示例 patch 命令如下所示:
kubectl patch virtualservice review-vs --type=json -p='[
{"op": "add", "path": "/spec/http/1/route/0/weight", "value": "80"},
{"op": "add", "path": "/spec/http/1/route/1/weight", "value": "20"}
]'
该命令将配置评论微服务的路由规则,将 80%的请求路由到旧版本,将 20%的请求路由到新版本。
为了指定weight值将在review-vs虚拟服务中更改,旧版本给出了/spec/http/1/route/0/weight路径,新版本给出/spec/http/1/route/1/weight。
路径中的0和1用于指定虚拟服务定义中数组元素的索引。例如,http/1意味着在http元素下的第二个元素。请参阅前面的review-vs虚拟服务的定义。
从前面的定义中我们可以看出,第二个元素是route元素。第一个元素,索引为0的是匹配元素。
现在我们已经对kubectl patch命令有了更多的了解,我们准备测试蓝绿部署。
执行蓝绿部署
现在,是逐渐让更多用户使用蓝绿部署转移到新版本的时候了。要执行部署,请按照以下步骤操作:
-
确保负载测试工具
Siege仍在运行。它是在前面的验证所有流量最初都发送到微服务的 v1 版本部分启动的。
-
为了将 20%的用户路由到新的 v2 版本的
review微服务,我们可以修补虚拟服务,并使用以下命令更改权重:
kubectl patch virtualservice review-vs --type=json -p='[
{"op": "add", "path": "/spec/http/1/route/0/weight", "value":
"80"},
{"op": "add", "path": "/spec/http/1/route/1/weight", "value":
"20"}
]'
-
为了观察路由规则的变化,请前往 Kiali web UI(
kiali.istio-system.svc.cluster.local:20001/kiali)并选择图表视图。 -
将边缘标签更改为
请求百分比。 -
在 Kiali 中更新统计数据之前等待一分钟,这样我们就可以观察到变化。期望 Kiali 中的图表显示如下:

取决于你等待了多长时间,图表可能看起来会有点不同!
在截图中,我们可以看到 Istio 现在将流量路由到review微服务的 v1 和 v2 版本。
从product-composite微服务发送到review微服务的 33%流量中,7%被路由到新的 v2 pod,26%到旧的 v1 pod。这意味着有 7/33(=21%)的请求被路由到 v2 pod,有 26/33(=79%)被路由到 v1 pod。这与我们请求的 20/80 分布相符:
-
请随意尝试前面的
kubectl patch命令,以影响其他核心微服务(product和recommendation)的路由规则: -
如果你想将所有流量路由到所有微服务的 v2 版本,你可以运行以下脚本:
./kubernetes/scripts/route-all-traffic-to-v2-services.bash
你必须给 Kiali 一分钟左右的时间来收集指标,然后它才能可视化微服务 v1 和 v2 版本之间的路由变化,但请记住,实际路由的变化是即时的!
过了一会儿,期望微服务图中的只有 v2 版本显示出来:

取决于你等待了多长时间,图表可能看起来会有点不同!
- 如果升级到 v2 后出现严重问题,以下命令可以执行以将所有流量恢复到所有微服务的 v1 版本:
./kubernetes/scripts/route-all-traffic-to-v1-services.bash
经过一段时间后,Kiali 中的图表应该看起来像前文验证微服务初始所有流量都发送到 v1 版本部分所示的截图;也就是说,再次可视化所有请求都发送到所有微服务的 v1 版本。
这结束了服务网格概念和作为概念实现的 Istio 的介绍。
在我们结束本章之前,让我们回顾一下我们如何使用 Docker Compose 运行测试,以确保我们的微服务源代码不依赖于在 Kubernetes 中的部署。
使用 Docker Compose 运行测试
如第十七章所述,实施 Kubernetes 特性作为替代(参考验证微服务在没有 Kubernetes 的情况下是否工作部分),从功能角度确保微服务的源代码不依赖于 Kubernetes 或 Istio 这样的平台是很重要的。
为了验证在没有 Kubernetes 和 Istio 的情况下微服务是否按预期工作,请按照第一章 7 节,实施 Kubernetes 特性作为替代(参考使用 Docker Compose 测试部分)所述运行测试。由于测试脚本test-em-all.bash的默认值已如前所述在运行创建服务网格的命令部分更改,因此在使用 Docker Compose 时必须设置以下参数:HOST=localhost PORT=8443 HEALTH_URL=https://localhost:8443。例如,要使用默认的 Docker Compose 文件docker-compose.yml运行测试,请运行以下命令:
HOST=localhost PORT=8443 HEALTH_URL=https://localhost:8443 ./test-em-all.bash start stop
测试应如之前所述,首先启动所有容器;然后运行测试,最后停止所有容器。关于预期输出的详细信息,请参见第十七章,实施 Kubernetes 特性作为替代(参考验证微服务在没有 Kubernetes 的情况下是否工作部分)。
在成功使用 Docker Compose 执行测试后,我们验证了微服务在功能上既不依赖于 Kubernetes 也不依赖于 Istio。这些测试结论了使用 Istio 作为服务网格的章节。
总结
在本章中,我们学习了服务网格概念以及实现该概念的开源项目 Istio。服务网格为处理系统微服务景观中的挑战提供了能力,如安全、策略执行、弹性和流量管理。服务网格还可以通过可视化微服务之间的流量来使微服务景观可观测。
对于可观测性,Istio 使用了 Kiali、Jaeger 和 Grafana(关于 Grafana 的更多信息请参见第二十章,监控微服务)。当谈到安全性时,Istio 可以配置为使用证书来保护使用 HTTPS 的外部 API,并要求外部请求包含有效的基于 JWT 的 OAuth 2.0/OIDC 访问令牌。最后,Istio 可以配置为使用相互认证(mTLS)来自动保护内部通信。
为了弹性和健壮性,Istio 提供了处理重试、超时和类似于断路器的异常检测机制的机制。在许多情况下,如果可能的话,在微服务的源代码中实现这些弹性能力是更好的。Istio 注入故障和延迟的能力对于验证服务网格中的微服务作为一个弹性和健壮的系统景观一起工作非常有用。Istio 还可以用于处理零停机部署。使用其细粒度的路由规则,可以执行金丝雀和蓝/绿部署。
我们还没有涉及的一个重要领域是如何收集和分析由所有微服务实例创建的日志文件。在下一章中,我们将了解如何使用流行的工具堆栈,即基于 Elasticsearch、Fluentd 和 Kibana 的 EFK 堆栈,来完成这项工作。
问题
-
服务网格中的代理组件有什么作用?
-
服务网格中的控制平面和数据平面有什么区别?
-
istioctl kube-inject命令是用来做什么的? -
minikube tunnel命令是用来做什么的? -
Istio 中用于可观测性的工具有哪些?
-
为了让 Istio 使用相互认证来保护服务网格内的通信,需要进行哪些配置?
-
在虚拟服务中,
abort和delay元素可以用来做什么? -
设置蓝/绿部署场景需要进行哪些配置?
第十九章:集中日志记录的 EFK 堆栈
在本章中,我们将学习如何从微服务实例收集和存储日志记录,以及如何搜索和分析日志记录。正如我们在第一章《微服务简介》(参考集中日志分析部分)提到的,当每个微服务实例将日志记录写入其本地文件系统时,很难获得微服务系统景观的概览。我们需要一个组件,它可以从微服务的本地文件系统收集日志记录并将它们存储在中央数据库中进行分析、搜索和可视化。针对这一问题,一个流行的开源解决方案基于以下工具构建:
-
Elasticsearch,一个用于搜索和分析大数据集的分布式数据库
-
Fluentd,一个数据收集器,可以用来从各种来源收集日志记录,过滤和转换收集的信息,最后发送给各种消费者,例如 Elasticsearch
-
Kibana,一个 Elasticsearch 的图形用户界面,可以用来可视化搜索结果和对收集的日志记录进行分析
这些工具合称为EFK 堆栈,以每个工具的首字母命名。
本章将涵盖以下主题:
-
配置 Fluentd
-
在 Kubernetes 上部署 EFK 堆栈以供开发和测试使用
-
通过以下方式尝试 EFK 堆栈:
-
分析收集的日志记录
-
从微服务中发现日志记录并查找相关日志记录
-
执行根本原因分析
-
技术要求
本书中描述的所有命令都已经在使用 macOS Mojave 的 MacBook Pro 上运行过,但应该很容易修改,以便它们可以在其他平台上运行,例如 Linux 或 Windows。
在本章中不需要安装任何新工具。
本章的源代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud/tree/master/Chapter19
为了能够运行本书中描述的命令,你需要将源代码下载到一个文件夹中,并设置一个环境变量$BOOK_HOME,它指向该文件夹。一些示例命令如下:
export BOOK_HOME=~/Documents/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud
git clone https://github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud $BOOK_HOME
cd $BOOK_HOME/Chapter19
本章中的所有源代码示例都来自$BOOK_HOME/Chapter19的源代码,并使用 Kubernetes 1.15 进行了测试。
如果你想查看我们在这个章节中应用到源代码的变化,即查看我们做出的改变以便我们可以使用 EFK 栈进行集中式日志分析,你可以使用你最喜欢的 diff 工具,比较两个文件夹 $BOOK_HOME/Chapter18 和 $BOOK_HOME/Chapter19。
配置 Fluentd
在本节中,我们将学习如何配置 Fluentd 的基础知识。在那之前,让我们先了解一下 Fluentd 的背景以及它在高层次上是如何工作的。
介绍 Fluentd
历史上,处理日志记录最受欢迎的开源栈之一就是来自 Elastic 的 ELK 栈(www.elastic.co),基于 Elasticsearch、Logstash(用于日志收集和转换)和 Kibana。由于 Logstash 运行在 Java VM 上,它需要相对较多的内存。多年来,已经开发出许多比 Logstash 占用更少内存的开源替代品,其中之一就是 Fluentd(www.fluentd.org)。
Fluentd 是由 云原生计算基金会 (CNCF)管理的(www.cncf.io),也就是管理 Kubernetes 项目的同一个组织。因此,Fluentd 已经成为在 Kubernetes 中运行的开源日志收集器的自然选择。与 Elastic 和 Kibana 一起,它构成了 EFK 栈。
Fluentd 是用 C 和 Ruby 编写的混合语言,使用 C 处理性能关键部分,而在需要灵活性时使用 Ruby,例如,允许使用 Ruby 的 gem install 命令简单地安装第三方插件。
Fluentd 中的日志记录作为事件处理,并包括以下信息:
-
一个
time字段,描述了日志记录创建的时间。 -
一个
tag字段,用来标识它是什么类型的日志记录——这个标签由 Fluentd 的路由引擎用来确定日志记录应该如何被处理。 -
一个 记录,包含实际的日志信息,以 JSON 对象的形式存储。
Fluentd 配置文件用于告诉 Fluentd 如何收集、处理并最终将日志记录发送到各种目标,例如 Elasticsearch。一个配置文件由以下类型的核心元素组成:
-
<source>:源元素描述了 Fluentd 将会从哪里收集日志记录。例如,追踪由 Docker 容器写入的日志文件。源元素通常给日志记录打上标签,描述日志记录的类型。它可以用来自动标记来自运行在 Kubernetes 中的容器的日志记录。 -
<filter>:过滤器元素用于处理日志记录,例如,一个过滤器元素可以解析来自基于 Spring Boot 的微服务的日志记录,并将日志消息中的有趣部分提取到日志记录中的单独字段中。将信息提取到日志记录中的单独字段,使得可以通过 Elasticsearch 搜索这些信息。过滤器元素根据它们的标签选择要处理的日志记录。 -
<match>:输出元素用于执行两个主要任务:-
将处理后的日志记录发送到目标,如 Elasticsearch。
-
路由是决定如何处理日志记录的方法。路由规则可以重写标签,并将日志记录重新发射到 Fluentd 路由引擎以进行进一步处理。路由规则被表达为
<match>元素内的嵌入<rule>元素。输出元素决定根据日志记录的标签处理哪些日志记录,与过滤器的方式相同。
-
Fluentd 自带了许多内置和外部的第三方插件,这些插件被源、过滤器和输出元素所使用。在我们下一节查看配置文件时,将看到其中的一些插件。关于可用插件的更多信息,请参阅 Fluentd 的文档,该文档可访问于 docs.fluentd.org。
在介绍了 Fluentd 之后,我们准备了解 Fluentd 应该如何配置以处理我们微服务中的日志记录。
配置 Fluentd
Fluentd 的配置基于 GitHub 上的 Fluentd 项目的配置文件,fluentd-kubernetes-daemonset。该项目包含 Fluentd 配置文件,说明如何从在 Kubernetes 中运行的容器收集日志记录,以及一旦它们被处理,如何将它们发送到 Elasticsearch。我们可以不进行任何更改地重用此配置,这将极大地简化我们自己的配置。Fluentd 配置文件可以在 github.com/fluent/fluentd-kubernetes-daemonset/tree/master/docker-image/v1.4/debian-elasticsearch/conf 找到。
提供这种功能的配置文件有 kubernetes.conf 和 fluent.conf。kubernetes.conf 配置文件包含以下信息:
-
源元素用于监控容器日志文件以及运行在 Kubernetes 之外的过程的日志文件,例如
kubelet和 Docker 守护进程。源元素还将为 Kubernetes 中的日志记录打上完整的日志文件名,并用/替换.',并加上前缀kubernetes。由于标签基于完整的文件名,其中包含命名空间、Pod 和容器的名称等信息,因此标签对于通过匹配标签找到感兴趣的日志记录非常有用。例如,product-composite微服务的标签可能类似于kubernetes.var.log.containers.product-composite-7...s_hands-on_comp-e...b.log,而同一 Pod 中相应的istio-proxy的标签可能类似于kubernetes.var.log.containers.product-composite-7...s_hands-on_istio-proxy-1...3.log`。 -
一个过滤器元素,用于丰富来自 Kubernetes 内部运行的容器以及包含容器名称和它们运行的命名空间等信息的 Kubernetes 特定字段的日志记录。
主配置文件fluent.conf包含以下信息:
-
@include语句用于其他配置文件,例如我们之前描述的kubernetes.conf文件。它还包括放置在特定文件夹中的自定义配置文件,使我们能够不进行任何更改就重用这些配置文件,并只处理与自身日志记录相关的处理。我们只需将自定义配置文件放置在fluent.conf文件指定的文件夹中。 -
一个输出元素,用于将日志记录发送到 Elasticsearch。
正如我们在部署 Fluentd部分所描述的,这两个配置文件将被打包到我们将为 Fluentd 构建的 Docker 镜像中。
在我们自己的配置文件中需要覆盖的是以下内容:
-
检测并解析我们微服务中的 Spring Boot 格式的日志记录。
-
处理多行堆栈跟踪。例如,堆栈跟踪是使用多行写入日志文件的。这使得 Fluentd 难以将堆栈跟踪作为单个日志记录处理。
-
将
istio-proxy侧边的日志记录与在同一 Pod 中运行的微服务生成的日志记录分开。由istio-proxy生成的日志记录不遵循我们基于 Spring Boot 的微服务生成的日志模式。因此,它们必须分开处理,以便 Fluentd 不要尝试将它们解析为 Spring Boot 格式的日志记录。
为了实现这一目标,配置很大程度上是基于使用rewrite_tag_filter插件。这个插件可以用于根据改变标签名称的概念来路由日志记录,然后将日志记录重新发射到 Fluentd 路由引擎。
此处理总结如下 UML 活动图:

从高层次来看,配置文件的设计如下所示:
-
来自 Istio 的所有日志记录的标签,包括
istio-proxy,都加上istio前缀,以便将它们与基于 Spring Boot 的日志记录区分开来。 -
所有来自
hands-on命名空间的日志记录(除了来自istio-proxy的日志记录)的标签都加上spring-boot前缀。 -
来自 Spring Boot 的日志记录检查是否有多行堆栈跟踪。如果日志记录是多行堆栈跟踪的一部分,它将使用第三方
detect-exceptions插件来重新创建堆栈跟踪。否则,它将使用正则表达式提取感兴趣的信息。关于这个第三方插件的详细信息,请参见部署 Fluentd部分。
fluentd-hands-on.conf配置文件紧密遵循这个活动图。该配置文件放在一个 Kubernetes 配置映射中(参见kubernetes/efk/fluentd-hands-on-configmap.yml)。让我们一步一步地讲解,如下所述:
- 首先是配置映射和配置文件 filename 的定义,
fluentd-hands-on.conf。它看起来像这样:
apiVersion: v1
kind: ConfigMap
metadata:
name: fluentd-hands-on-config
namespace: kube-system
data:
fluentd-hands-on.conf: |
从前面的源代码中,我们了解到data元素将包含 Fluentd 的配置。它从文件名开始,并使用竖线|标记 Fluentd 嵌入式配置文件的开始。
- 第一个
<match>元素匹配来自 Istio 的日志记录,即以kubernetes为前缀并包含作为其命名空间或容器名称一部分的istio的标签。它看起来像这样:
<match kubernetes.**istio**>
@type rewrite_tag_filter
<rule>
key log
pattern ^(.*)$
tag istio.${tag}
</rule>
</match>
让我们更详细地解释前面的源代码:
-
-
<match>元素匹配任何符合kubernetes.**istio**模式的标签,即,它以kubernetes开始,然后在标签名中包含单词istio。istio可以来自命名空间名称或容器名称;两者都是标签的一部分。 -
<match>元素只包含一个<rule>元素,它为标签加上istio前缀。${tag}变量持有当前标签的值。 -
由于这是
<match>元素中唯一的<rule>元素,它被配置为如此匹配所有日志记录:-
由于所有来自 Kubernetes 的日志记录都有一个
log字段,所以key字段被设置为log,即规则在日志记录中查找一个log字段。 -
为了匹配
log字段中的任何字符串,pattern字段被设置为^(.*)$正则表达式。^标志着一个字符串的开始,而$标志着一个字符串的结束。(.*)匹配任何数量的字符,除了换行符。 -
日志记录被重新发送到 Fluentd 路由引擎。由于配置文件中没有其他元素与以
istio开头的标签匹配,它们将被直接发送到我们之前描述的fluent.conf文件中定义的 Elasticsearch 输出元素。
-
-
- 第二个
<match>元素匹配来自hands-on命名空间的全部日志记录,也就是说,是我们微服务发出的日志记录。它看起来像这样:
<match kubernetes.**hands-on**>
@type rewrite_tag_filter
<rule>
key log
pattern ^(.*)$
tag spring-boot.${tag}
</rule>
</match>
从前的源代码我们可以看出:
-
-
我们微服务发出的日志记录使用 Spring Boot 定义的日志消息格式化规则,因此它们的标签前缀为
spring-boot。然后,它们被重新发出以进行进一步处理。 -
<match>元素的配置方式与我们在前面查看的<match kubernetes.**istio**>元素相同。
-
- 第三个
<match>元素匹配spring-boot日志记录,并确定它们是普通的 Spring Boot 日志记录还是多行堆栈跟踪的一部分。它看起来像这样:
<match spring-boot.**>
@type rewrite_tag_filter
<rule>
key log
pattern /^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}\.\d{3}.*/
tag parse.${tag}
</rule>
<rule>
key log
pattern /^.*/
tag check.exception.${tag}
</rule>
</match>
如前所见的源代码,这是通过使用两个<rule>元素来确定的:
-
-
第一个使用正则表达式检查日志元素中的
log字段是否以时间戳开头。 -
如果
log字段以时间戳开头,则将日志记录视为普通的 Spring Boot 日志记录,并在其标签前加上parse前缀。 -
否则,第二个
<rule>元素将匹配,并将日志记录作为多行日志记录处理。其标签前缀为check.exception。 -
无论如何,在这种情况下,日志记录都会被重新发出,其标签将在此过程后以
check.exception.spring-boot.kubernetes或parse.spring-boot.kubernetes开头。
-
- 在第四个
<match>元素中,所选的日志记录具有以check.exception.spring-boot开头的标签,即,是多行堆栈跟踪的一部分的日志记录。它看起来像这样:
<match check.exception.spring-boot.**>
@type detect_exceptions
languages java
remove_tag_prefix check
message log
multiline_flush_interval 5
</match>
detect_exceptions插件的源代码像这样工作:
-
-
detect_exceptions插件用于将多个单行日志记录组合成一个包含完整堆栈跟踪的日志记录。 -
在将多行日志记录重新输入路由引擎之前,将标签中的
check前缀移除,以防止日志记录的处理循环无限进行。
-
- 最后,配置文件由一个过滤器元素组成,该元素使用正则表达式解析 Spring Boot 日志消息,提取感兴趣的信息。它看起来像这样:
<filter parse.spring-boot.**>
@type parser
key_name log
time_key time
time_format %Y-%m-%d %H:%M:%S.%N
reserve_data true
format /^(?<time>\d{4}-\d{2}-
\d{2}\s\d{2}:\d{2}:\d{2}\.\d{3})\s+
(?<spring.level>[^\s]+)\s+
(\[(?<spring.service>[^,]*),(?<spring.trace>[^,]*),(?
<spring.span>[^,]*),[^\]]*\])\s+
(?<spring.pid>\d+)\s+---\s+\[\s*(?<spring.thread>[^\]]+)\]\s+
(?<spring.class>[^\s]+)\s*:\s+
(?<log>.*)$/
</filter>
让我们更详细地解释前面的源代码:
-
-
请注意,过滤器元素不会重新发出日志记录;相反,它们只是将它们传递给配置文件中与日志记录的标签匹配的下一个元素。
-
从存储在日志记录中的
log字段的 Spring Boot 日志消息中提取以下字段:-
<time>:创建日志记录的时间戳 -
<spring.level>:日志记录的日志级别,例如,FATAL、ERROR、WARN、INFO、DEBUG或TRACE -
<spring.service>:微服务名称 -
<spring.trace>:用于执行分布式跟踪的跟踪 ID -
<spring.span>:跨度 ID,即这个微服务执行的分布式处理的部分的 ID -
<spring.pid>:进程 ID -
<spring.thread>:线程 ID -
<spring.class>:Java 类的名称 -
<log>:实际的日志消息
-
-
使用spring.application.name属性指定基于 Spring Boot 的微服务名称。此属性已添加到配置存储库中的每个微服务特定属性文件中,在config-repo文件夹中。
准确地编写正则表达式可以说是一项挑战。幸运的是,有多个网站可以提供帮助。当涉及到与 Fluentd 一起使用正则表达式时,我推荐使用以下网站:fluentular.herokuapp.com/。
既然你已经了解了 Fluentd 如何工作以及配置文件是如何构建的,我们就可以部署 EKF 堆栈了。
在 Kubernetes 上部署 EFK 堆栈
在 Kubernetes 上部署 EFK 堆栈的方式将与我们部署自己的微服务的方式相同:使用 Kubernetes 定义文件为部署、服务和配置映射等对象。
EFK 堆栈的部署分为两部分:
-
我们部署 Elasticsearch 和 Kibana 的一部分
-
我们部署 Fluentd 的一部分
但首先,我们需要构建和部署我们自己的微服务。
构建和部署我们的微服务
使用test-em-all.bash测试脚本构建、部署并验证部署的方式与第十八章中的使用服务网格提高可观测性和管理,运行创建服务网格的命令部分相同。开始运行以下命令:
- 首先,使用以下命令从源代码构建 Docker 镜像:
cd $BOOK_HOME/Chapter19
eval $(minikube docker-env)
./gradlew build && docker-compose build
- 重新创建命名空间
hands-on,并将其设置为默认命名空间:
kubectl delete namespace hands-on
kubectl create namespace hands-on
kubectl config set-context $(kubectl config current-context) --namespace=hands-on
- 通过运行以下命令执行部署:
./kubernetes/scripts/deploy-dev-env.bash
- 如果 Minikube 隧道尚未运行,请启动(如果需要,请参阅第十八章使用服务网格提高可观测性和管理,设置对 Istio 服务的访问部分):
minikube tunnel
请记住,此命令要求您的用户具有sudo权限,并且在启动和关闭时输入您的密码。在命令要求输入密码之前需要几秒钟,所以很容易错过!
- 使用以下命令运行正常测试以验证部署:
./test-em-all.bash
期望输出与前几章看到的内容类似:

- 您还可以通过运行以下命令手动测试 API:
ACCESS_TOKEN=$(curl -k https://writer:secret@minikube.me/oauth/token -d grant_type=password -d username=magnus -d password=password -s | jq .access_token -r)
curl -ks https://minikube.me/product-composite/2 -H "Authorization: Bearer $ACCESS_TOKEN" | jq .productId
期望在响应中收到请求的产品 ID,2。
微服务部署完成后,我们可以继续部署 Elasticsearch 和 Kibana!
部署 Elasticsearch 和 Kibana
我们将 Elasticsearch 和 Kibana 部署到其自己的命名空间 logging。Elasticsearch 和 Kibana 将使用 Kubernetes 部署对象部署用于开发和测试。这将通过单个 pod 和 Kubernetes 节点端口服务完成。服务将内部在 Kubernetes 集群中暴露 Elasticsearch 和 Kibana 的标准端口,即 Elasticsearch 的端口 9200 和 Kibana 的端口 5601。多亏了 minikube tunnel 命令,我们将能够使用以下 URL 本地访问这些服务:
-
elasticsearch.logging.svc.cluster.local:9200对于 Elasticsearch。 -
kibana.logging.svc.cluster.local:5601对于 Kibana。
要查看在 Kubernetes 生产环境中推荐部署,请参阅 www.elastic.co/elasticsearch-kubernetes。
我们将使用在本章写作时可用的版本:
-
Elasticsearch 版本 7.3.0
-
Kibana 版本 7.3.0
在执行部署之前,让我们看看定义文件中最有趣的部分。
定义文件的逐步讲解
Elasticsearch 的定义文件 kubernetes/efk/elasticsearch.yml 包含了一个标准的 Kubernetes 部署和服务对象,我们在之前见过多次,例如在 第十五章 Kubernetes 简介 的 尝试样本部署 部分。正如我们之前解释的,定义文件最有趣的部分如下:
apiVersion: extensions/v1beta1
kind: Deployment
...
containers:
- name: elasticsearch
image: docker.elastic.co/elasticsearch/elasticsearch-oss:7.3.0
resources:
limits:
cpu: 500m
memory: 2Gi
requests:
cpu: 500m
memory: 2Gi
让我们详细解释前面的源代码:
-
我们使用来自 Elastic 的官方 Docker 镜像,可在
docker.elastic.co获得,以及只包含开源组件的包。这通过在 Docker 镜像名称中使用-oss后缀来保证,即elasticsearch-oss。版本设置为7.3.0。 -
Elasticsearch 容器被允许分配相对较多的内存 - 2 GB,以能够良好地执行查询。内存越多,性能越好。
Kibana 的定义文件 kubernetes/efk/kibana.yml 也包含了一个标准的 Kubernetes 部署和服务对象。定义文件中最有趣的部分如下:
apiVersion: extensions/v1beta1
kind: Deployment
...
containers:
- name: kibana
image: docker.elastic.co/kibana/kibana-oss:7.3.0
env:
- name: ELASTICSEARCH_URL
value: http://elasticsearch:9200
让我们详细解释前面的源代码:
-
对于 Kibana,我们还使用来自 Elastic 的官方 Docker 镜像,可在
docker.elastic.co获得,以及只包含开源组件的包kibana-oss。版本设置为7.3.0。 -
为了将 Kibana 与 Elasticsearch pod 连接,定义了一个环境变量
ELASTICSEARCH_URL,以指定 Elasticsearch 服务的地址,http://elasticsearch:9200。
有了这些洞察,我们准备执行 Elasticsearch 和 Kibana 的部署。
运行部署命令
通过执行以下步骤部署 Elasticsearch 和 Kibana:
- 使用以下命令为 Elasticsearch 和 Kibana 创建一个命名空间:
kubectl create namespace logging
- 为了使部署步骤运行得更快,使用以下命令预取 Elasticsearch 和 Kibana 的 Docker 镜像:
eval $(minikube docker-env)
docker pull docker.elastic.co/elasticsearch/elasticsearch-oss:7.3.0
docker pull docker.elastic.co/kibana/kibana-oss:7.3.0
- 使用以下命令部署 Elasticsearch 并等待其 pod 准备好:
kubectl apply -f kubernetes/efk/elasticsearch.yml -n logging
kubectl wait --timeout=120s --for=condition=Ready pod -n logging --all
- 使用以下命令验证 Elasticsearch 是否正在运行:
curl http://elasticsearch.logging.svc.cluster.local:9200 -s | jq -r .tagline
期待响应为You Know, for Search。
根据您的硬件,您可能需要等待一两分钟,直到 Elasticsearch 回应此消息。
- 使用以下命令部署 Kibana 并等待其 pod 准备好:
kubectl apply -f kubernetes/efk/kibana.yml -n logging
kubectl wait --timeout=120s --for=condition=Ready pod -n logging --all
- 使用以下命令验证 Kibana 是否正在运行:
curl -o /dev/null -s -L -w "%{http_code}\n" http://kibana.logging.svc.cluster.local:5601
期待响应为 200。
在部署 Elasticsearch 和 Kibana 之后,我们可以开始部署 Fluentd。
部署 Fluentd
与部署 Elasticsearch 和 Kibana 相比,部署 Fluentd 稍微复杂一些。为了部署 Fluentd,我们将使用 Fluentd 项目在 Docker Hub 上发布的 Docker 镜像,fluent/fluentd-kubernetes-daemonset,并从 GitHub 上的 Fluentd 项目中采样 Kubernetes 定义文件,fluentd-kubernetes-daemonset。它位于 github.com/fluent/fluentd-kubernetes-daemonset。正如项目名称所暗示的,Fluentd 将作为 daemon set 部署,在 Kubernetes 集群中的每个节点运行一个 pod。每个 Fluentd pod 负责收集运行在同一节点上的进程和容器的日志输出。由于我们使用的是 Minikube,即单节点集群,所以我们只有一个 Fluentd pod。
为了处理包含异常堆栈跟踪的多行日志记录,我们将使用 Google 提供的一个第三方 Fluentd 插件,fluent-plugin-detect-exceptions,该插件可在 github.com/GoogleCloudPlatform/fluent-plugin-detect-exceptions 获得。为了能够使用这个插件,我们将构建我们自己的 Docker 镜像,其中将安装 fluent-plugin-detect-exceptions 插件。将使用 Fluentd 的 Docker 镜像 fluentd-kubernetes-daemonset 作为基础镜像。
我们将使用在本章编写时可用的版本:
-
Fluentd 版本 1.4.2
-
fluent-plugin-detect-exceptions 版本 0.0.12
在执行部署之前,让我们看看定义文件的最有趣的部分。
从微服务中发现日志记录
在本节中,我们将学习如何利用集中日志的一个主要功能,那就是从我们的微服务中找到日志记录。我们还将学习如何在日志记录中使用 trace ID 来查找属于同一进程的其他微服务的日志记录,例如 API 的请求。
让我们先通过 API 创建一些我们可以用 Kibana 查找的日志记录。我们将使用 API 创建一个具有唯一产品 ID 的产品,然后检索有关产品的信息。之后,我们可以尝试找到在检索产品信息时创建的日志记录。
与上一章相比,微服务中的日志记录创建稍有更新,以便产品组合和三个核心微服务(product、recommendation和review)在处理 get 请求时都设置日志级别为INFO。让我们来看看为每个微服务添加的源代码:
- 产品组合微服务日志创建:
LOG.info("Will get composite product info for product.id={}", productId);
- 产品微服务日志创建:
LOG.info("Will get product info for id={}", productId);
- 推荐微服务日志创建:
LOG.info("Will get recommendations for product with id={}", productId)
- 评论微服务日志创建:
LOG.info("Will get reviews for product with id={}", productId);
有关详细信息,请参阅microservices文件夹中的源代码。
执行以下步骤使用 API 创建日志记录,然后使用 Kibana 查找日志记录:
- 使用以下命令获取访问令牌:
ACCESS_TOKEN=$(curl -k https://writer:secret@minikube.me/oauth/token -d grant_type=password -d username=magnus -d password=password -s | jq .access_token -r)
- 如本节介绍中所述,我们将首先通过 API 创建具有唯一产品 ID 的产品。为
"productId" :1234创建一个简约的产品(不包含推荐和评论)通过执行以下命令:
curl -X POST -k https://minikube.me/product-composite \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
--data '{"productId":1234,"name":"product name 1234","weight":1234}'
- 使用以下命令读取产品:
curl -H "Authorization: Bearer $ACCESS_TOKEN" -k 'https://minikube.me/product-composite/1234'
期待如下响应:

希望这些 API 调用创建了一些日志记录。让我们跳到 Kibana 去看看!
- 在 Kibana 网页上,点击左侧的
Discover菜单。你会看到类似以下内容:

在左上角,我们可以看到 Kibaba 找到了 326,642 条日志记录。时间选择器显示它们来自过去 7 天。在直方图中,我们可以看到日志记录随时间如何分布。之后是一个表,显示查询找到的最新日志事件。
-
如果您想更改时间间隔,可以使用时间选择器。点击其日历图标调整时间间隔。
-
为了更好地查看日志记录中的内容,请将日志记录中的某些字段添加到直方图下的表中。从左侧的可用字段列表中选择字段。滚动到底部直到找到字段。将光标悬停在字段上,会出现一个添加按钮;点击它将字段作为列添加到表中。选择以下字段,按顺序:
-
spring.level,日志级别
-
kubernetes.container_name,容器名称
-
spring.trace,用于分布式跟踪的跟踪 ID
-
-
- log,实际日志消息。网页应看起来与以下类似:

表格现在包含了有关日志记录的信息!
- 要查找来自
GETAPI 调用的日志记录,我们可以请 Kibana 查找日志字段包含 product.id=1234 的日志记录。这匹配了前面显示的产品组合微服务的日志输出。这可以通过在搜索字段中输入log:"product.id=1234"并点击更新按钮(这个按钮也可以标记为刷新)来完成。预期会找到一条日志记录:

-
验证时间戳是否来自您调用
GETAPI 的时间,并验证创建日志记录的容器名称是comp,即验证日志记录是由产品组合微服务发送的。 -
现在,我们想要查看其他参与返回产品 ID 1234 信息过程的微服务的相关日志记录,即查找具有与找到的日志记录相同的跟踪 ID 的日志记录。为此,将光标悬停在日志记录的
spring.trace字段上。字段右侧将显示两个小放大镜图标,一个带加号,一个带减号。点击带有加号的放大镜图标以根据跟踪 ID 进行过滤。 -
清空搜索字段,以便唯一的搜索条件是跟踪字段的过滤。然后,点击更新按钮以查看结果。预期会有类似于下面的响应:

我们可以看到很多详细的 debug 和 trace 消息,让视图变得杂乱,让我们去掉它们!
-
将光标悬停在 TRACE 值上,然后点击带有减号的放大镜图标以过滤出日志级别设置为 TRACE 的日志记录。
-
重复上述步骤以处理 DEBUG 日志记录。
-
现在我们应该能够看到四个预期的日志记录,每个记录都涉及查找产品信息的产品 ID 1234 的每个微服务:

另外,请注意应用的过滤器包括跟踪 ID,但不包括日志级别设置为 DEBUG 或 TRACE 的日志记录。
既然我们知道如何查找预期的日志记录,我们就可以进行下一步了。这将是要学习如何查找意外的日志记录,即错误消息,以及如何进行根本原因分析,即找到这些错误消息的原因。
定义文件的概览
用于构建 Docker 镜像的 Dockerfile,kubernetes/efk/Dockerfile,如下所示:
FROM fluent/fluentd-kubernetes-daemonset:v1.4.2-debian-elasticsearch-1.1
RUN gem install fluent-plugin-detect-exceptions -v 0.0.12 \
&& gem sources --clear-all \
&& rm -rf /var/lib/apt/lists/* \
/home/fluent/.gem/ruby/2.3.0/cache/*.gem
让我们详细解释前面的源代码:
-
基础镜像使用的是 Fluentd 的 Docker 镜像,
fluentd-kubernetes-daemonset。标签v1.4.2-debian-elasticsearch-1.1指定了应使用版本 v1.4.2,并且包含发送日志记录到 Elasticsearch 的内置支持的包。基础 Docker 镜像包含了在配置 Fluentd部分提到的 Fluentd 配置文件。 -
使用 Ruby 的包管理器
gem安装 Google 插件fluent-plugin-detect-exceptions。
守护进程集的定义文件kubernetes/efk/fluentd-ds.yml基于fluentd-kubernetes-daemonset项目中的一个示例定义文件,该文件可在此处找到github.com/fluent/fluentd-kubernetes-daemonset/blob/master/fluentd-daemonset-elasticsearch.yaml。这个文件有点复杂,所以让我们分别查看最有趣的部分:
- 首先,以下是守护进程集的声明:
apiVersion: extensions/v1beta1
kind: DaemonSet
metadata:
name: fluentd
namespace: kube-system
让我们详细解释一下前面的源代码:
-
-
kind键指定了这是一个守护进程集。 -
namespace键指定守护进程集应创建于kube-system命名空间中,而不是部署 Elasticsearch 和 Kibana 的logging命名空间。
-
- 下一部分指定了由守护进程集创建的 Pod 模板。最有趣的部分如下:
spec:
template:
spec:
containers:
- name: fluentd
image: hands-on/fluentd:v1
env:
- name: FLUENT_ELASTICSEARCH_HOST
value: "elasticsearch.logging"
- name: FLUENT_ELASTICSEARCH_PORT
value: "9200"
让我们详细解释一下前面的源代码:
-
-
用于 Pod 的 Docker 镜像为
hands-on/fluentd:v1。我们在之前描述的 Dockerfile 中走过定义文件后,将构建这个 Docker 镜像。 -
Docker 镜像支持许多环境变量,用于自定义它。其中最重要的两个如下:
-
FLUENT_ELASTICSEARCH_HOST,指定 Elasticsearch 服务的主机名,即elasticsearch.logging -
FLUENT_ELASTICSEARCH_PORT,指定与 Elasticsearch 通信的端口,即9200
-
-
由于 Fluentd Pod 在 Elasticsearch 之外的命名空间中运行,不能使用其短名称(即elasticsearch)来指定主机名。相反,DNS 名称的命名空间部分也必须指定,即elasticsearch.logging。作为替代方案,可以使用完全合格域名(FQDN),elasticsearch.logging.svc.cluster.local。但由于 DNS 名称的最后一部分,svc.cluster.local,在 Kubernetes 集群内的所有 DNS 名称中是共享的,因此不需要指定。
- 最后,有一系列卷,即文件系统,被映射到 Pod 中,如下所示:
volumeMounts:
- name: varlog
mountPath: /var/log
- name: varlibdockercontainers
mountPath: /var/lib/docker/containers
readOnly: true
- name: journal
mountPath: /var/log/journal
readOnly: true
- name: fluentd-extra-config
mountPath: /fluentd/etc/conf.d
volumes:
- name: varlog
hostPath:
path: /var/log
- name: varlibdockercontainers
hostPath:
path: /var/lib/docker/containers
- name: journal
hostPath:
path: /run/log/journal
- name: fluentd-extra-config
configMap:
name: "fluentd-hands-on-config"
让我们详细解释一下前面的源代码:
-
-
宿主机(即节点)上的三个文件夹被映射到 Fluentd Pod 中。这些文件夹包含 Fluentd 将跟踪和收集日志记录的日志文件。这些文件夹是:
/var/log、/var/lib/docker/containers和/run/log/journal。 -
我们自己的配置文件,指定 Fluentd 如何处理来自我们微服务的日志记录,通过一个名为
fluentd-hands-on-config的 config map 映射到/fluentd/etc/conf.d文件夹。之前使用的 Fluentd 的 Docker 镜像fluentd-kubernetes-daemonset,配置 Fluentd 包括在/fluentd/etc/conf.d文件夹中找到的任何配置文件。有关详细信息,请参见配置 Fluentd部分。
-
关于 daemon set 定义文件的完整源代码,请参阅kubernetes/efk/fluentd-ds.yml文件。
既然我们已经涵盖了所有内容,我们就可以准备部署 Fluentd 了。
运行部署命令
要部署 Fluentd,我们必须构建 Docker 镜像,创建 config map,最后部署 daemon set。运行以下命令以执行这些步骤:
- 使用以下命令构建 Docker 镜像并标记为
hands-on/fluentd:v1:
eval $(minikube docker-env)
docker build -f kubernetes/efk/Dockerfile -t hands-on/fluentd:v1 kubernetes/efk/
- 使用以下命令创建 config map,部署 Fluentd 的 daemon set,并等待 pod 就绪:
kubectl apply -f kubernetes/efk/fluentd-hands-on-configmap.yml
kubectl apply -f kubernetes/efk/fluentd-ds.yml
kubectl wait --timeout=120s --for=condition=Ready pod -l app=fluentd -n kube-system
- 使用以下命令验证 Fluentd pod 是否正常:
kubectl logs -n kube-system $(kubectl get pod -l app=fluentd -n kube-system -o jsonpath={.items..metadata.name}) | grep "fluentd worker is now running worker"
期望的响应是2019-08-16 15:11:33 +0000 [info]: #0 fluentd worker is now running worker=0。
- Fluentd 将开始从 Minkube 实例中的各种进程和容器收集大量的日志记录。大约一分钟后,您可以使用以下命令询问 Elasticsearch 已经收集了多少日志记录:
curl http://elasticsearch.logging.svc.cluster.local:9200/_all/_count
该命令首次执行时可能会有点慢,但应该返回类似于以下的响应:

在这个例子中,Elasticsearch 包含144750条日志记录。
这就完成了 EFK 堆栈的部署。现在,是时候尝试它并找出收集的所有日志记录都关于什么了!
尝试 EFK 堆栈
在我们可以尝试 EFK 堆栈之前,我们需要做的第一件事是初始化 Kibana,这样它就知道在 Elasticsearch 中使用哪些搜索索引。一旦完成,我们将尝试以下,根据我的经验,常见的任务:
-
我们将首先分析 Fluentd 已经收集并在 Elasticsearch 中存储了哪些类型的日志记录。Kibana 具有非常实用的可视化功能,可用于此目的。
-
接下来,我们将学习如何发现属于同一外部 API 请求处理的不同微服务的日志记录。我们将使用日志记录中的追踪 ID作为相关日志记录的关联 ID 来找到相关的日志记录。
-
第三,我们将学习如何使用 Kibana 进行根本原因分析,也就是说,找到错误的实际原因。
初始化 Kibana
在我们开始使用 Kibana 之前,我们必须指定在 Elasticsearch 中使用哪些搜索索引以及索引中哪个字段持有日志记录的时间戳。
执行以下步骤初始化 Kibana:
-
在 web 浏览器中使用
http://kibana.logging.svc.cluster.local:5601URL 打开 Kibana 的 web UI。 -
在欢迎页面上,点击“自行探索”按钮。
-
点击左下角的展开按钮以查看菜单选择的名称。这些将在左侧显示。
-
点击左侧菜单中的发现。你将被要求定义一个模式,Kibana 用它来确定应从哪个 Elasticsearch 索引中检索日志记录。
-
输入
logstash-*索引模式,然后点击下一步。 -
在下一页上,你将被要求指定包含日志记录时间戳的字段名称。点击时间过滤字段名称的下拉列表,选择唯一可用的字段,即
@timestamp。 -
点击创建索引模式按钮。
-
Kibana 将显示一个页面,总结在选定的索引中可用的字段。
默认情况下,索引以logstash命名,这是出于历史原因,尽管用于日志收集的是 Flutentd。
初始化 Kibana 后,我们准备检查我们收集的日志记录。
分析日志记录
从 Fluentd 的部署来看,我们知道它立即开始收集大量的日志记录。因此,我们首先需要了解 Fluentd 已经收集并存储在 Elasticsearch 中的日志记录的类型。
我们将使用 Kibana 的可视化功能,按 Kubernetes 命名空间分割日志记录,然后要求 Kibana 显示每个命名空间内按容器类型分割的日志记录。饼图是这种分析的合适图表类型。按照以下步骤创建饼图:
-
在 Kibana 的 Web UI 中,点击左侧菜单中的可视化。
-
点击创建新可视化按钮。
-
选择饼图作为可视化类型。
-
选择
logstash-*作为源。 -
在饼图上方的日期选择器(日期间隔选择器)中,设置一个你喜欢的日期间隔(以下屏幕截图设置为最后 7 天)。点击其日历图标调整时间间隔。
-
点击添加以创建第一个桶,如下所示:
-
选择桶类型,即分割切片。
-
对于聚合类型,从下拉列表中选择项。
-
作为字段,选择
kubernetes.namespace_name.keyword。 -
对于大小,选择 10。
-
启用将其他值分组到单独的桶中。
-
启用显示缺失值。
-
按下应用更改按钮(Bucket 定义上方蓝色播放图标)。期待一个类似于以下的饼图:
-

我们可以看到,日志记录已经分布在我们在前几章中工作的命名空间上:kube-system、istio-system、logging、cert-manager以及我们自己的hands-on命名空间。为了查看按命名空间分割的日志记录是由哪些容器创建的,我们需要创建第二个桶。
-
点击再次添加以创建第二个桶:
-
选择桶类型,即分割切片。
-
对于子聚合类型,从下拉列表中选择项。
-
作为字段,选择
kubernetes.container_name.keyword。 -
对于大小,选择“10”。
-
启用“将其他值分组到单独的桶中”。
-
启用“显示缺失值”。
-
再次点击“应用更改”按钮。预期会出现类似下面的饼图:
-

在这里,我们可以找到来自我们微服务的日志记录。大多数日志记录来自product-composite微服务。
-
在饼图的顶部,有一组被标记为
missing的日志记录,即它们既没有 Kubernetes 命名空间也没有容器名称指定。这些缺失的日志记录背后有什么?这些日志记录来自在 Minikube 实例外的 Kubernetes 集群中运行的进程,并且它们是使用 Syslog 存储的。它们可以使用 Syslog 特定的字段进行分析,特别是标识符字段。让我们创建一个第三个桶,根据它们的 Syslog 标识符字段(如果有)来划分日志记录。 -
点击“添加”再次创建一个第三个桶:
-
选择桶类型,即“分割切片”。
-
作为子聚合类型,从下拉列表中选择“Terms”。
-
作为字段,选择
SYSLOG_IDENTIFIER.keyword。 -
启用“将其他值分组到单独的桶中”。
-
启用“显示缺失值”。
-
点击“应用更改”按钮,预期会出现类似下面的饼图:
-

missing日志记录最终来自kubelet进程,该进程从 Kubernetes 的角度管理节点,以及dockerd,管理所有容器的 Docker 守护进程。
既然我们已经找到了日志记录的来源,我们就可以开始从我们的微服务中定位实际的日志记录了。
执行根本原因分析
集中日志的最重要特性之一是,它使得使用来自许多源的日志记录来分析错误成为可能,并且基于此执行根本原因分析,即找到错误消息的实际原因。
在本节中,我们将模拟一个错误,并看看我们如何能够找到有关它的信息,一直找到系统中微服务中的某一个微服务中的源代码行引起了错误。为了模拟错误,我们将重新使用我们在第十三章中引入的故障参数使用 Resilience4j 提高弹性节的添加可编程延迟和随机错误。我们可以使用这个来强制产品微服务抛出异常。按照以下步骤进行操作:
- 运行以下命令,在搜索具有产品 ID
666的产品信息时,在产品微服务中生成故障:
curl -H "Authorization: Bearer $ACCESS_TOKEN" -k https://minikube.me/product-composite/666?faultPercent=100
预期响应中会出现以下错误:

现在,我们必须假装我们对这个错误的原因一无所知!否则,根本原因分析将不会非常令人兴奋,对吧?假设我们工作在支持组织中,并被要求调查一些刚刚在最终用户尝试查找有关产品 ID 666的信息时发生的问题。
- 在开始分析问题之前,让我们在 Kibana Web UI 中删除之前的搜索过滤器,以便我们可以从头开始。对于我们在上一节中定义的每个过滤器,点击它们的关闭图标(一个 X)以删除它们。删除所有过滤器后,网页应看起来与以下类似:

-
首先,使用时间选择器选择一个包括问题发生时间的时间间隔。例如,如果您知道问题发生在过去七天之内,就搜索最后七天。
-
接下来,在此时间范围内搜索日志记录,日志级别设置为 ERROR。这可以通过点击列表中的
spring.level字段来实现。当你点击这个字段时,它最常用的值将显示在其下方。通过点击带有加号标志的放大镜过滤ERROR值。现在 Kibana 将显示在此选定时间范围内,其日志级别设置为 ERROR 的日志记录,如下所示:

-
我们可以看到许多与产品 ID
666相关的错误信息。其中前四个具有相同的跟踪 ID,因此这似乎是一个值得进一步调查的跟踪 ID。 -
我们还可以看到在顶部四个下面还有更多与同一错误相关的错误信息,但具有不同的跟踪 ID。这些是由产品组合微服务中的重试机制引起的,即在放弃并向调用者返回错误消息之前,它将请求重试几次。
-
以与上一节相同的方式过滤第一个日志记录的跟踪 ID。
-
移除
ERROR日志级别的过滤器,以便能够查看属于此跟踪 ID 的所有记录。预计 Kibana 将响应大量的日志记录。查看最古老的日志记录,即最先发生的记录,该记录看起来可疑。例如,它可能具有WARN或ERROR日志级别或奇怪的日志消息。默认的排序顺序是显示最新的日志记录在顶部,因此向下滚动到底部并向后搜索(您还可以通过点击Time列标题旁边的向上/向下箭头小图标来更改排序顺序,以首先显示最古老的日志记录)。显示Bad luck, and error occurred的WARN日志消息看起来可能是问题的根本原因。让我们进一步调查:

-
一旦找到了可能成为问题根源的日志记录,能够找到描述在源代码中异常抛出的附近堆栈跟踪就显得非常重要了.不幸的是,我们用于收集多行异常的 Fluentd 插件
fluent-plugin-detect-exceptions,无法将堆栈跟踪与使用的跟踪 ID 关联起来。因此,当我们在跟踪 ID 上过滤时,堆栈跟踪不会在 Kibana 中显示。相反,我们可以使用 Kibana 中的一个功能来查找显示在特定日志记录附近发生的日志记录的周围日志记录。 -
使用日志记录左侧的箭头展开显示“坏运气”的日志记录。详细信息关于这个具体的日志记录将显示出来。还有一个名为“查看周围文档”的链接;点击它,就能看到附近的日志记录。期待一个类似于以下的网页:

- 在显示“坏运气”日志记录上面的带有错误消息“Something went wrong...”的堆栈跟踪的日志记录看起来很有趣,并且是由产品微服务在它记录了坏运气日志记录的两毫秒后记录的。它们似乎有关联!那个日志记录中的堆栈跟踪指向了
ProductServiceImpl.java的第 96 行。查看源代码(见microservices/product-service/src/main/java/se/magnus/microservices/core/product/services/ProductServiceImpl.java),第 96 行看起来如下:
throw new RuntimeException("Something went wrong...");
这是错误的根本原因。我们之前知道这一点,但现在我们也看到了如何导航到它。
在这个案例中,问题的解决相当简单:只需在 API 请求中省略faultPercent参数即可。在其他情况下,找出根本原因的解决可能要困难得多!
本章关于使用 EFK 堆栈进行集中日志记录的内容就此结束。
总结
在本章中,我们了解了收集系统景观中微服务的日志记录到一个共同的集中数据库的重要性,在该数据库中可以对存储的日志记录进行分析和搜索。我们使用了 EFK 堆栈,即 Elasticsearch、Fluentd 和 Kibana,来收集、处理、存储、分析和搜索日志记录。
我们不仅用 Fluentd 收集微服务的日志记录,还收集了 Kubernetes 集群中各种支持容器和进程的日志记录。Elasticsearch 被用作文本搜索引擎。与 Kibana 一起,我们了解了识别我们收集了哪些类型的日志记录是多么容易。
我们还学会了如何使用 Kibana 执行重要任务,例如查找来自合作微服务的相关日志记录以及如何进行根本原因分析,即找到错误消息的真实问题。最后,我们学会了如何更新 Fluentd 的配置以及如何让执行的 Fluentd pod 反映出这个更改。
能够以这种方式收集和分析日志记录是在生产环境中的一项重要能力,但是这类活动总是要在日志记录被收集之后进行。另一项重要能力是能够监控微服务的当前健康状况,也就是说,收集并可视化关于硬件资源使用、响应时间等方面的运行时指标。我们在上一章,第十八章,使用服务网格提高可观测性和管理,和下一章,第二十章,监控微服务,将会了解更多关于监控微服务的内容。
问题
- 一个用户在过去 30 天里在
hands-on命名空间中搜索了 ERROR 日志信息,使用的是下面截图中的搜索条件,但是没有找到任何结果。为什么?

- 一个用户找到了一个感兴趣的日志记录。用户如何从这个以及其他微服务中找到相关日志记录,例如,那些来自处理外部 API 请求的记录?

- 一个用户找到了一个似乎指示了一个由终端用户报告的问题的根源的日志记录。用户如何找到显示错误发生在源代码中的堆栈跟踪?

- 下面的 Fluentd 配置元素为什么不起作用?
<match kubernetes.**hands-on**>
@type rewrite_tag_filter
<rule>
key log
pattern ^(.*)$
tag spring-boot.${tag}
</rule>
</match>
-
你怎么确定 Elasticsearch 是否正在运行?
-
突然之间,你从网页浏览器上失去了对 Kibana 的连接。这个问题是由什么引起的?
第二十章:监控微服务
在本章中,我们将学习如何使用 Prometheus 和 Grafana 来收集、监控和关于性能指标的警报。正如我们在第一章中提到的微服务简介,在集中监控和警报部分,在生产环境中,能够收集应用程序性能和硬件资源使用情况的数据至关重要。监控这些指标是为了避免 API 请求和其他进程的响应时间过长或出现故障。
为了能够以成本效益高和主动的方式监控微服务系统架构,我们需要定义一些自动触发的警报,如果指标超过配置的限制就会触发这些警报。
在本章中,我们将涵盖以下主题:
-
使用 Prometheus 和 Grafana 进行性能监控简介
-
源代码中收集应用程序指标的变化
-
构建和部署微服务
-
使用 Grafana 仪表板监控微服务
-
在 Grafana 中设置警报
技术要求
本书中描述的所有命令都已经在使用 macOS Mojave 的 MacBook Pro 上运行过,但应该很容易进行修改,以便它们可以在其他平台上运行,例如 Linux 或 Windows。
本章的源代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud/tree/master/Chapter20。
为了能够运行本书中描述的命令,你需要将源代码下载到一个文件夹中,并设置一个环境变量$BOOK_HOME,使其指向该文件夹。一些示例命令如下:
export BOOK_HOME=~/Documents/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud
git clone https://github.com/PacktPublishing/Hands-On-Microservices-with-Spring-Boot-and-Spring-Cloud $BOOK_HOME
cd $BOOK_HOME/Chapter20
本章中的所有源代码示例均来自$BOOK_HOME/Chapter20的源代码,并且已经使用 Kubernetes 1.15 进行了测试。
如果你想要查看我们为本章源代码所做的更改,以便你可以使用 Prometheus 和 Grafana 监控一个性能指标的警报,你可以将它们与第十九章的源代码使用 EFK 堆栈进行集中日志记录进行比较。你可以使用你最喜欢的区分工具,比较两个文件夹$BOOK_HOME/Chapter19和$BOOK_HOME/Chapter20。
使用 Prometheus 和 Grafana 进行性能监控简介
在本章中,我们将重用我们在第十八章 使用服务网格提高可观测性和管理中创建的 Prometheus 和 Grafana 部署,在在 Kubernetes 集群中部署 Istio部分。在那一章中,在介绍 Istio 的运行时组件部分,我们简要介绍了 Prometheus,这是一个流行的开源时间序列数据库,如性能指标。我们还介绍了 Grafana 作为一个开源工具,用于可视化性能指标。Istio 的可观测性控制台 Kiali 与 Grafana 集成。用户可以从 Kiali 中的感兴趣服务导航到其在 Grafana 中的相应性能指标。Kiali 还可以在不使用 Grafana 的情况下渲染一些与性能相关的图表。在本章中,我们将通过使用这些工具一起获得与这种集成相关的实践经验。
我们在第十八章 使用服务网格提高可观测性和管理中部署的 Istio 配置包括一个 Prometheus 配置,它自动从 Kubernetes 中的 pods 收集指标。我们只需要在我们的微服务中设置一个端点,以产生 Prometheus 可以消费的指标格式。我们还需要向 Kubernetes pod 添加注解,以便 Prometheus 可以找到端点的地址。有关如何设置的详细信息,请参见本章的收集应用程序指标的源代码变化部分。
以下图表说明了刚刚讨论的运行时组件之间的关系:

在这里,我们可以看到 Prometheus 如何使用 Kubernetes pod 定义中的注解来能够从我们的微服务中收集指标。然后,它将这些指标存储在其数据库中。用户可以通过访问 Kiali 和 Grafana 的 web UI 来监控这些指标,网页浏览器。网页浏览器使用在第十八章 使用服务网格提高可观测性和管理中介绍的minikube 隧道,访问 Kiali 和 Grafana。
请记住,第十八章 使用服务网格提高可观测性和管理中用于部署 Istio 的配置仅适用于开发和测试,而不是生产。例如,存储在 Prometheus 数据库中的性能指标在 Prometheus pod 重新启动后将不会保留!
在下一节中,我们将查看对源代码进行了哪些更改,以使微服务产生 Prometheus 可以收集的性能指标。
源代码中收集应用程序指标的变化
Spring Boot 2 支持使用 Micrometer 库产生性能指标,并以普罗米修斯格式呈现(micrometer.io)。我们只需要对源代码做一次更改:需要在每个微服务的 Gradle 构建文件build.gradle中添加对 Micrometer 库的依赖micrometer-registry-prometheus。在此,已添加以下依赖项:
implementation("io.micrometer:micrometer-registry-prometheus")
这将使得微服务在端口4004上使用"/actuator/prometheus" URI 产生普罗米修斯指标。为了让普罗米修斯了解这些端点,每个微服务的容器都用以下代码进行了注释:
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "4004"
prometheus.io/scheme: http
prometheus.io/path: "/actuator/prometheus"
查看kubernetes/services/base/deployments文件夹中的部署定义以获取更多详细信息。
为了在普罗米修斯收集完指标后更容易识别指标的来源,指标被标记为产生该指标的微服务名称。这通过在共同配置文件config-repo/application.yml中添加以下配置来实现:
management.metrics.tags.application: ${spring.application.name}
这将导致每个产生的指标都有一个额外的标签,名为application。它将包含微服务名称的标准 Spring 属性值,即spring.application.name。
这就是准备微服务产生性能指标并使普罗米修斯意识到要使用哪些端点来开始收集这些指标所需的所有更改。在下一节中,我们将构建和部署微服务。
构建和部署微服务
使用test-em-all.bash测试脚本构建、部署并验证部署的方式与在构建和部署微服务章节中的第十九章、使用 EFK 堆栈进行集中日志记录相同。运行以下命令:
- 从源代码构建 Docker 镜像,使用以下命令:
cd $BOOK_HOME/Chapter20
eval $(minikube docker-env)
./gradlew build && docker-compose build
- 重新创建命名空间
hands-on,并将其设置为默认命名空间:
kubectl delete namespace hands-on
kubectl create namespace hands-on
kubectl config set-context $(kubectl config current-context) --namespace=hands-on
- 通过运行以下命令执行
deploy-dev-env.bash脚本:
./kubernetes/scripts/deploy-dev-env.bash
- 如果 Minikube 隧道尚未运行,按照以下方式启动(如果需要,请回顾第十八章、使用服务网格提高可观测性和管理、设置对 Istio 服务的访问部分):
minikube tunnel
- 使用以下命令验证部署的正常测试:
./test-em-all.bash
预期输出将与前几章中看到的内容类似:

微服务部署完成后,我们可以开始使用 Grafana 监控我们的微服务!
使用 Grafana 仪表板监控微服务
正如我们在介绍中提到的,Kiali 与 Grafana 集成,并提供一些非常实用的仪表板。一般来说,它们关注的是每秒请求数、响应时间和处理请求的故障百分比等应用级性能指标。正如我们即将看到的,它们在应用层面非常有用。但如果我们想要了解底层硬件资源的使用情况,我们需要更详细的指标,例如 Java VM 相关指标。
Grafana 有一个活跃的社区,社区成员会分享可重用的仪表板。我们将尝试使用社区提供的仪表板,该仪表板专为从像我们的微服务这样的 Spring Boot 2 应用程序获取许多有价值的 Java VM 相关指标而设计。最后,我们将了解如何在 Grafana 中创建我们自己的仪表板。但首先,让我们探索一下 Kiali 和 Grafana 之间的集成。
在这样做之前,我们需要做两项准备工作:
-
为测试安装一个本地邮件服务器并配置 Grafana,使其能够向其发送电子邮件。
我们将在“在 Grafana 中设置警报”一节中使用邮件服务器。
-
为了能够监视一些指标,我们将启动在前几章中使用的负载测试工具。
为测试安装一个本地邮件服务器
在本节中,我们将搭建一个本地测试邮件服务器,并配置 Grafana 以将警报电子邮件发送到该邮件服务器。
Grafana 可以向任何 SMPT 邮件服务器发送电子邮件,但为了保持测试本地化,我们将部署一个名为maildev的测试邮件服务器。考虑以下步骤:
- 使用以下命令安装测试邮件服务器:
kubectl create deployment mail-server --image djfarrelly/maildev:1.1.0
kubectl expose deployment mail-server --port=80,25 --type=ClusterIP
kubectl wait --timeout=60s --for=condition=ready pod -l app=mail-server
- 通过访问
mail-server.hands-on.svc.cluster.local上的网页来验证测试邮件服务器是否正在运行。期待显示出如下类似的网页:

- 通过设置一些环境变量配置 Grafana 以向测试邮件服务器发送电子邮件。运行以下命令:
kubectl -n istio-system set env deployment/grafana \
GF_SMTP_ENABLED=true \
GF_SMTP_SKIP_VERIFY=true \
GF_SMTP_HOST=mail-server.hands-on.svc.cluster.local:25 \
GF_SMTP_FROM_ADDRESS=grafana@minikube.me
kubectl -n istio-system wait --timeout=60s --for=condition=ready pod -l app=grafana
有关详细信息,请参阅hub.docker.com/r/djfarrelly/maildev。
现在,我们已经运行了一个测试邮件服务器,并且 Grafana 已经配置为向其发送电子邮件。在下一节中,我们将启动负载测试工具。
启动负载测试
为了监视一些指标,让我们使用在前几章中使用的 Siege 启动负载测试。运行以下命令:
ACCESS_TOKEN=$(curl -k https://writer:secret@minikube.me/oauth/token -d grant_type=password -d username=magnus -d password=password -s | jq .access_token -r)
siege https://minikube.me/product-composite/2 -H "Authorization: Bearer $ACCESS_TOKEN" -c1 -d1
现在,我们准备学习 Kiali 和 Grafana 之间的集成,并探索随 Istio 提供的 Grafana 仪表板。
使用 Kiali 内置的 Grafana 仪表板
在第十八章“使用服务网格提高可观测性和管理”的“观察服务网格”一节中,我们了解了 Kiali,但我们跳过了 Kiali 显示性能指标的部分。现在,是时候回到这个主题了!
执行以下步骤了解 Kiali 与 Grafana 的集成:
-
使用
http://kiali.istio-system.svc.cluster.local:20001URL 在 web 浏览器中打开 Kiali web UI。如果需要,使用admin/admin登录。 -
通过点击左侧菜单中的服务标签,进入服务页面。
-
通过点击它,选择产品服务页面。
-
在服务:产品页面上,选择入站指标标签。您将看到以下页面:

- Kiali 将可视化一些整体性能图表。然而,在 Grafana 中还有更多详细的性能指标。点击“在 Grafana 中查看”链接,Grafana 将在新标签页中打开。期待一个类似于以下的网页:

- 这里展示了大量应用级别的性能指标,例如 HTTP 请求率、响应时间和错误率。这些指标是针对选中的 Kiali 中的 Product 服务的。在页面上方左侧点击服务下拉菜单,选择另一个服务。随意查看!
Istio 带有一组预安装的 Grafana 仪表板;点击 Istio/Istio 服务仪表板查看可用的仪表板列表。现在,选择 Istio 网格仪表板。您将看到一个类似于以下内容的网页:

这个仪表板能很好地概述服务网格中的微服务以及它们每秒请求数、响应时间和成功率等当前状态。
正如我们之前提到的,Istio 仪表板能提供很好的应用级别概述。但是,也需要监控每个微服务的硬件使用指标。在下一节,我们将学习如何导入现有的仪表板——具体来说,一个显示基于 Spring Boot 2 的应用程序的 Java VM 指标的仪表板。
导入现有的 Grafana 仪表板
正如我们之前提到的,Grafana 有一个活跃的社区,他们分享可重用的仪表板。它们可以在 grafana.com/grafana/dashboards 上找到。我们将尝试一个名为 JVM (Micrometer) 的仪表板,它是为从 Spring Boot 2 应用程序获取许多有价值的 JVM 相关指标而量身定制的。仪表板的 URL 是 grafana.com/grafana/dashboards/4701。在 Grafana 中导入仪表板非常容易。按照以下步骤导入这个仪表板:
-
按照这些步骤导入名为 JVM (Micrometer) 的仪表板:
-
在 Grafana 网页上,点击左侧菜单中的 + 符号,然后选择导入。
-
在导入页面,将仪表板 ID
4701粘贴到 Grafana.com 仪表板字段中,并按 Tab 键离开该字段。 -
在将显示的导入页面中,点击 Prometheus 下拉菜单,选择 Prometheus。
-
现在,点击“导入”按钮,JVM(Micrometer)仪表板将被导入并显示。
-
-
通过以下步骤检查 JVM(Micrometer)仪表板:
-
为了获得良好的指标视图,点击页面右上角的时钟选择器。这将允许您选择一个适当的时间间隔:
-
选择最后 5 分钟作为范围。再次点击时钟选择器,将刷新率设置为 5 秒。
-
在指定刷新率后点击“应用”按钮。
-
-
在页面左上角的“应用”下拉菜单中,选择 product-composite 微服务。
-
-
- 由于我们在后台使用 Siege 进行负载测试,我们将看到很多指标。以下是的一个示例屏幕截图:

在这个仪表板中,我们可以找到所有类型的与 Java VM 相关的指标,例如 CPU、内存和 I/O 使用情况,以及与 HTTP 相关的指标,如每秒请求数、平均持续时间和错误率。请随意自行探索这些指标!
能够导入现有的仪表板在我们想要快速开始时非常有价值。然而,更重要的是要知道如何创建自己的仪表板。我们将在下一节学习这一点。
开发自己的 Grafana 仪表板
开始开发 Grafana 仪表板是很直接的。我们需要理解的是 Prometheus 为我们提供了哪些指标。
在本节中,我们将学习如何检查可用的指标。基于这些指标,我们将创建一个仪表板,用于监控一些更有趣的指标。
检查 Prometheus 指标
在源代码中收集应用指标的变化一节中,我们配置了 Prometheus 从我们的微服务中收集指标。我们实际上可以调用相同的端点并查看 Prometheus 收集的指标。运行以下命令:
curl http://product-composite.hands-on.svc.cluster.local:4004/actuator/prometheus -s
预期命令会有很多输出,如下例所示:

在所有报告的指标中,有两个非常有趣的:
-
resilience4j_retry_calls:Resilience4j 报告其重试机制的运行情况。它成功和失败的请求,以及是否有重试,各报告了四个不同的值。 -
resilience4j_circuitbreaker_state:Resilience4j 报告电路断路器的状态。
请注意,这些指标有一个名为application的标签,包含微服务名称。这个字段来自我们在源代码中收集应用指标的变化一节中配置的management.metrics.tags.application属性。
这些指标看起来很有趣,值得我们监控。我们迄今为止使用的所有仪表板都没有使用来自 Resilience4j 的指标。在下一节中,我们将为这些指标创建一个仪表板。
创建仪表板
在本节中,我们将学习如何创建一个可视化我们在上一节中描述的 Resilience4j 指标的仪表板。
我们将在以下子节中设置仪表板:
-
创建一个空白的仪表板
-
为电路断路器指标创建一个新的面板
-
为重试指标创建一个新的面板
-
排列面板
创建一个空白的仪表板
执行以下步骤以创建一个空白的仪表板:
-
在 Grafana 网页上,点击左侧菜单中的+号,然后选择仪表板。
-
将显示一个名为“新仪表板”的网页:

-
点击仪表板设置按钮(它的图标是一个齿轮),如前一个屏幕截图所示。然后,按照以下步骤操作:
-
在“名称”字段中指定仪表板名称,并将其设置为“动手实践仪表板”。
-
点击“保存”按钮。
-
-
点击时间选择器以选择仪表板的默认值,如下所示:
-
选择“最后 5 分钟”作为时间范围。
-
再次点击时间选择器,并在面板底部的“每 5 秒刷新”字段中指定 5 秒作为刷新率。
-
在指定刷新率后点击“应用”按钮。
-
从页面顶部的菜单中点击“保存”按钮。
-
启用“保存当前时间范围”功能,并在“保存更改”对话框中点击“保存”按钮。
-
为电路断路器指标创建一个新的面板
执行以下步骤以创建用于电路断路器指标的新面板:
-
点击页面顶部左侧的“添加面板”按钮(它的图标是一个带有+号的图表)。
-
点击“添加查询”按钮。将显示一个页面,可以在其中配置新面板。
-
在查询字段中,在“A 字母”下指定电路断路器指标的名称,即“resilience4j_circuitbreaker_state”。
-
在“图例”字段中指定格式,即
{{application}}.{{namespace}}。这将创建一个图例,在面板中为涉及的微服务标记其名称和命名空间。 -
填写的值应如下所示:

-
从左侧菜单中点击第三个标签,名为“通用”,并将“标题”字段设置为“电路断路器”。
-
点击页面左上角的返回按钮以返回到仪表板。
为重试指标创建一个新的面板
在这里,我们将重复我们为先前电路断路器指标添加面板的相同步骤,但这次我们将指定重试指标的值:
-
在查询字段中指定
rate(resilience4j_retry_calls[30s])。由于重试指标是一个计数器,其值只会增加。不断增加的指标对于监控来说相当无趣。rate函数用于将重试指标转换为每秒的速率指标。指定的时间窗口,即 30 秒,是 rate 函数用来计算速率平均值的时间窗口。 -
对于图例,指定
{{application}}.{{namespace}} ({{kind}})。就像前面 Prometheus 端点的输出一样,我们将为重试机制获得四个指标。为了在图例中区分它们,需要添加kind属性。 -
请注意,Grafana 立即开始在面板编辑器中根据指定值渲染图表。
-
将“重试”指定为标题。
-
点击返回按钮返回到仪表板。
安排面板
执行以下步骤以在仪表板上安排面板:
-
您可以通过拖动其右下角来调整面板的大小到所需大小。
-
您还可以通过拖动其标题将面板移动到所需位置。
-
以下是对两个面板的一个示例布局:

- 最后,点击页面顶部的“保存”按钮。将显示“保存更改”对话框;输入可选描述并点击保存按钮。
创建仪表板后,我们准备尝试它:在下一节中,我们将尝试两者指标。
尝试新的仪表板
在我们开始测试新仪表板之前,我们必须停止负载测试工具 Siege。为此,转到 Siege 正在运行的命令窗口并按Ctrl + C停止它。
首先,我们通过测试如何监视断路器开始。之后,我们将尝试重试指标。
测试断路器指标
如果我们强制打开断路器,它的状态将从一个关闭改变为打开,然后最终变为半开状态。这应该在断路器面板中报告。
打开电路,就像我们在第十三章《使用 Resilience4j 提高弹性》中的尝试断路器和重试机制部分所做的那样;也就是说,连续对 API 发起三次请求,全部失败。运行以下命令:
ACCESS_TOKEN=$(curl -k https://writer:secret@minikube.me/oauth/token -d grant_type=password -d username=magnus -d password=password -s | jq .access_token -r)
for ((n=0; n<3; n++)); do curl -o /dev/null -skL -w "%{http_code}\n" https://minikube.me/product-composite/2?delay=3 -H "Authorization: Bearer $ACCESS_TOKEN" -s; done
我们可以预期会收到三个 500 响应,表示连续三次错误,也就是说,这是打开断路器所需要的一切!
在某些罕见的情况下,我注意到断路器指标没有在 Grafana 中报告。如果它们在一分钟后没有出现,简单地重新运行前面的命令重新打开断路器。
期望断路器指标的值上升到1,表示电路已打开。过了一会儿,它应该上升到2,表示电路现在是半开状态。这证明了我们可以在出现问题时监控断路器的打开。通过以下命令关闭电路断路器:
for ((n=0; n<3; n++)); do curl -o /dev/null -skL -w "%{http_code}\n" https://minikube.me/product-composite/2?delay=0 -H "Authorization: Bearer $ACCESS_TOKEN" -s; done
我们将得到三个200作为响应。注意,断路器指标在仪表板中又回到了 0;也就是说,它是关闭的。
进行此测试后,Grafana 仪表板应如下所示:

从上面的屏幕截图中,我们可以看到重试机制也报告了成功和失败的指标。
既然我们已经看到了电路断路器指标的实际应用,让我们看看重试指标的实际应用!
测试重试指标
为了触发重试机制,我们将使用在前几章中使用的faultPercentage参数。为了避免触发电路断路器,我们需要为该参数使用相对较低的值。运行以下命令:
while true; do curl -o /dev/null -s -L -w "%{http_code}\n" -H "Authorization: Bearer $ACCESS_TOKEN" -k https://minikube.me/product-composite/2?faultPercent=10; sleep 3; done
前一条命令将每三秒调用一次 API。它指定有 10%的请求应失败,以便重试机制启动并重试失败的请求。几分钟之后,仪表板应报告如下指标:

在前一个屏幕截图中,我们可以看到大多数请求已经成功执行,没有进行重试。大约有 10%的请求被重试机制重试并成功执行。在继续下一节之前,记得停止我们为前一个重试测试启动的请求循环!
在下一节中,我们将学习如何在 Grafana 中根据这些指标设置警报。
在 Grafana 中设置警报
能够监控电路断路器和重试指标非常有价值,但更重要的是,能够根据这些指标定义自动化警报。自动化警报使我们不必手动监控指标。
Grafana 内置了对定义警报和向多个目的地发送通知的支持。在本节中,我们将为电路断路器定义警报,并配置 Grafana 在警报触发时向测试邮件服务器发送邮件。本地的测试邮件服务器在“为测试安装本地邮件服务器”一节中安装。
在下一节中,我们将定义一个基于邮件的通知渠道,该渠道将在这一节之后的警报中使用。
设置基于邮件的通知渠道
要在 Grafana 中配置基于邮件的通知渠道,请执行以下步骤:
-
在 Grafana 网页上,在左侧菜单中,点击“警报”菜单选项(带有警铃图标的选项)并选择通知渠道。
-
单击“添加频道”按钮。
-
将名称设置为
mail。 -
选择类型为
Email。 -
启用所有警报发送。
-
启用包含图片。
-
输入你选择的任意电子邮件地址。邮件只会发送到本地的测试邮件服务器,无论指定的是哪个电子邮件地址。通知渠道的配置应如下所示:

-
点击“发送测试”按钮发送测试邮件。
-
点击“保存”按钮。
-
点击左侧菜单中的“仪表板”按钮,然后点击主页按钮。
-
从列表中选择“动手仪表板”以返回仪表板。
-
检查测试邮件服务器的网页,确保我们已经收到测试邮件。你应该收到以下输出:

有了通知渠道后,我们就可以定义断路器上的警报了。
在断路器上设置警报。
要创建断路器的警报,我们需要先创建警报,然后将警报列表添加到仪表板中,我们可以在其中查看随着时间的推移发生了哪些警报事件。
执行以下步骤创建断路器的警报:
-
在互动仪表板中,点击断路器面板的标题。一个下拉菜单将出现。
-
选择编辑菜单选项。
-
在左侧标签列表中选择警报标签(显示为警铃图标)。
-
点击创建警报按钮。
-
在“评估每”字段中,将值设置为
10s。 -
在“对于”字段中,将值设置为
0m。 -
在条件部分,指定以下值:
-
将“WHEN”字段设置为
max()。 -
将“OF”字段设置为
query(A, 1m, now)。 -
将“IS ABOVE”字段设置为
0.5。
-
-
滚动到底部以确认通知已发送到默认通知渠道,即我们之前定义的邮件渠道。警报定义应如下所示:

- 点击返回按钮(左箭头)回到仪表板。
然后,我们需要执行以下步骤来创建警报列表:
-
点击页面顶部的添加面板按钮。
-
在“新面板”页面中选择“选择可视化”。
-
在提供的可视化中,选择“警报列表”。点击它两次以显示选项列表。
-
选择显示名为“最近状态更改”的选项。
-
启用从这个仪表板发出的警报。设置应该如下所示:

-
点击返回按钮以回到仪表板。
-
重新排列面板以满足您的需求。
-
保存仪表板更改。
以下是带有警报列表添加的示例布局:

我们可以看到断路器报告指标为正常(带有绿色心形),并且警报列表包含电路断路器的正常事件。
现在,是尝试警报的时候了!
尝试断路器警报。
在这里,我们将重复“测试断路器指标”部分中的测试,但这次我们希望警报被触发并且还发送电子邮件!让我们开始吧:
- 首先打开断路器:
for ((n=0; n<3; n++)); do curl -o /dev/null -skL -w "%{http_code}\n" https://minikube.me/product-composite/2?delay=3 -H "Authorization: Bearer $ACCESS_TOKEN" -s; done
仪表板应该报告电路像以前一样打开。一分钟后,应该触发警报并且还会发送电子邮件。预期仪表板应该与下面的屏幕截图类似:

注意断路器面板标题中的警报图标(一个红色的心形)。红线标记警报事件的时间,并且已经将警报添加到警报列表中。
- 在测试邮件服务器上,您应该看到一个电子邮件,如下面的屏幕截图所示:

- 太好了;我们得到了警报,就像我们预期的那样!现在,关闭电路,用以下命令使问题消失:
for ((n=0; n<3; n++)); do curl -o /dev/null -skL -w "%{http_code}\n" https://minikube.me/product-composite/2?delay=0 -H "Authorization: Bearer $ACCESS_TOKEN" -s; done
指标应该恢复到正常值,即0,一分钟之后,警报应该再次变为绿色。
期望仪表板看起来像下面的屏幕截图:

请注意,电路 breaker 面板顶部图标是绿色的;绿色线条标志着 OK 事件的时间,并且已经在警报列表中添加了一个 OK 事件。
在测试邮件服务器上,你应该看到一封电子邮件,如下面的屏幕截图所示:

这样我们就完成了使用 Prometheus 和 Grafana 监控微服务的方法。
总结
在本章中,我们学习了如何使用 Prometheus 和 Grafana 收集和监控性能指标的警报。
我们了解到,在 Kubernetes 环境中收集性能指标时,可以使用 Prometheus。接着,我们学习了当在 pods 定义中添加几个 Prometheus 注解时,Prometheus 如何能够自动从 pods 收集指标。为了在我们的微服务中产生指标,我们使用了 Micrometer。
然后,我们了解了如何使用 Grafana 仪表板来监控收集的指标。既有 Kiali 自带的仪表板,也有 Grafana 社区分享的仪表板。我们还学习了如何开发我们自己的仪表板,其中我们使用了来自 Resilience4j 的指标来监控其断路器和重试机制的使用。
最后,我们学习了如何在 Grafana 中定义指标警报以及如何使用 Grafana 发送警报通知。我们使用了一个本地的测试邮件服务器来接收 Grafana 的电子邮件警报。
我希望这本书能帮助你学习如何使用 Spring Boot、Spring Cloud、Kubernetes 和 Istio 的所有惊人特性来开发微服务,并鼓励你去尝试它们!
问题
-
我们需要对微服务中的源代码进行哪些更改,以便它们产生被 Prometheus 消费的指标?
-
management.metrics.tags.application配置参数是用来做什么的? -
如果你想要分析一个关于高 CPU 消耗的支持案例,你会从本章中的哪个仪表板开始?
-
如果你想要分析一个关于慢 API 响应的支持案例,你会从本章中的哪个仪表板开始?
-
基于计数器类型的指标(如 Resilience4J 的重试指标)存在哪些问题,我们又能做些什么以使它们以有用的方式被监控?
-
为什么电路 breaker 的指标在报告 2 之前暂时报告 1?请看下面的屏幕截图:

。


浙公网安备 33010602011771号