精通-SpringCloud-全-
精通 SpringCloud(全)
原文:
zh.annas-archive.org/md5/3341AF3ECE66B2253A7F83A5D112367C译者:飞龙
序言
开发、部署和运行云应用应该像本地应用一样简单。这是任何云平台、库或工具背后的主导原则。Spring Cloud 使得在云中开发 JVM 应用变得容易。在这本书中,我们向你介绍 Spring Cloud 并帮助你掌握其功能。
你将学习配置 Spring Cloud 服务器并运行 Eureka 服务器以启用服务注册和发现。然后,你将学习与负载均衡和断路器相关的技术,并利用 Feign 客户端的所有功能。接着,我们将深入探讨高级主题,你将学习为 Spring Cloud 实现分布式跟踪解决方案,并构建基于消息的微服务架构。
本书面向对象
本书适合那些希望利用 Spring Cloud 这一开源库快速构建分布式系统的开发者。了解 Java 和 Spring Framework 知识将会有所帮助,但不需要先前的 Spring Cloud 经验。
本书涵盖内容
第一章,微服务简介,将向你介绍微服务架构、云环境等。你将学习微服务应用与单体应用之间的区别,同时学习如何将单体应用迁移到微服务应用。
第二章,微服务与 Spring,将向你介绍 Spring Boot 框架。你将学习如何有效地使用它来创建微服务应用。我们将涵盖诸如使用 Spring MVC 注解创建 REST API、使用 Swagger2 提供 API 文档、以及使用 Spring Boot Actuator 端点暴露健康检查和指标等主题。
第三章,Spring Cloud 概览,将简要介绍作为 Spring Cloud 一部分的主要项目。它将重点描述 Spring Cloud 实现的主要模式,并将它们分配给特定的项目。
第四章,服务发现,将描述一个使用 Spring Cloud Netflix Eureka 的服务发现模式。你将学习如何以独立模式运行 Eureka 服务器,以及如何运行具有对等复制的多个服务器实例。你还将学习如何在客户端启用发现功能,并在不同区域注册这些客户端。
第五章,使用 Spring Cloud Config 的分布式配置,将介绍如何在应用程序中使用 Spring Cloud Config 实现分布式配置。你将学习如何启用不同属性源的后端存储库,并使用 Spring Cloud Bus 推送变更通知。我们将比较发现首先引导和配置首先引导的方法,以说明发现服务与配置服务器之间的集成。
第六章,微服务之间的通信,将介绍参与服务间通信的最重要元素:HTTP 客户端和负载均衡器。您将学习如何使用 Spring RestTemplate、Ribbon 和 Feign 客户端,以及如何使用服务发现。
第七章,高级负载均衡和断路器,将介绍与微服务之间的服务通信相关的更高级主题。您将学习如何使用 Ribbon 客户端实现不同的负载均衡算法,使用 Hystrix 启用断路器模式,并使用 Hystrix 仪表板来监控通信统计。
第八章,使用 API 网关的路由和过滤,将比较两个用作 Spring Cloud 应用程序的 API 网关和代理的项目:Spring Cloud Netlix Zuul 和 Spring Cloud Gateway。您将学习如何将它们与服务发现集成,并创建简单和更高级的路由和过滤规则。
第九章,分布式日志和跟踪,将介绍一些用于收集和分析由微服务生成的日志和跟踪信息的热门工具。您将学习如何使用 Spring Cloud Sleuth 附加跟踪信息以及关联的消息。我们将运行一些示例应用程序,这些应用程序与 Elastic Stack 集成以发送日志消息,并与 Zipkin 收集跟踪。
第十章,附加配置和发现特性,将介绍两个用于服务发现和分布式配置的流行产品:Consul 和 ZooKeeper。您将学习如何本地运行这些工具,并将您的 Spring Cloud 应用程序与它们集成。
第十一章,消息驱动的微服务,将指导您如何为您的微服务提供异步、基于消息的通信。您将学习如何将 RabbitMQ 和 Apache Kafka 消息代理与您的 Spring Cloud 应用程序集成,以实现异步的一对一和发布/订阅通信方式。
第十二章,保护 API,将描述保护您的微服务的三种不同方法。我们将实现一个系统,该系统由前面介绍的所有元素组成,通过 SSL 相互通信。您还将学习如何使用 OAuth2 和 JWT 令牌来授权对 API 的请求。
第十三章,测试 Java 微服务,将介绍不同的微服务测试策略。它将重点介绍消费者驱动的合同测试,这在微服务环境中特别有用。您将了解如何使用 Hoverfly、Pact、Spring Cloud Contract、Gatling 等框架实现不同类型的自动化测试。
第十四章,Docker 支持,将简要介绍 Docker。它将重点介绍在容器化环境中运行和监控微服务最常用的 Docker 命令。您还将学习如何使用流行的持续集成服务器 Jenkins 构建和运行容器,并将它们部署在 Kubernetes 平台上。
第十五章,云平台上的 Spring 微服务,将介绍两种支持 Java 应用程序的流行云平台:Pivotal Cloud Foundry 和 Heroku。您将学习如何使用命令行工具或网络控制台在這些平台上部署、启动、扩展和监控您的应用程序。
为了充分利用本书
为了成功阅读本书并弄懂所有代码示例,我们期望读者满足以下要求:
-
活动互联网连接
-
Java 8+
-
Docker
-
Maven
-
Git 客户端
下载示例代码文件
您可以从 www.packtpub.com 下载本书的示例代码文件。如果您在其他地方购买了此书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。
您可以按照以下步骤下载代码文件:
-
登录或注册 www.packtpub.com。
-
选择“支持”选项卡。
-
点击“代码下载与勘误”。
-
在搜索框中输入书籍名称,并按照屏幕上的指示操作。
文件下载完成后,请确保使用最新版本的软件解压或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
本书的代码包也托管在 GitHub 上,地址为 github.com/PacktPublishing/Mastering-Spring-Cloud。我们还有其他来自我们丰富目录的书籍和视频的代码包,可在 github.com/PacktPublishing/ 找到。去看看吧!
使用的约定
本书中使用了许多文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、假 URL、用户输入和 Twitter 处理。例如:“HTTP API 端点的最后一个可用版本,http://localhost:8889/client-service-zone3.yml,返回与输入文件相同的数据。”
代码块如下所示:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server</artifactId>
</dependency>
当我们希望吸引你对代码块的特定部分注意时,相关的行或项目会被设置为粗体:
spring:
rabbitmq:
host: 192.168.99.100
port: 5672
任何命令行输入或输出都如下所示:
$ curl -H "X-Vault-Token: client" -X GET http://192.168.99.100:8200/v1/secret/client-service
粗体:表示新术语、重要词汇或你在屏幕上看到的词汇。例如,菜单或对话框中的词汇在文本中会以这种方式出现。示例:“在谷歌浏览器中,你可以通过访问设置|显示高级设置...|HTTPS/SSL|管理证书来导入一个 PKCS12 密钥库。”
警告或重要说明以这种方式出现。
技巧和窍门以这种方式出现。
联系我们
我们总是欢迎读者的反馈。
一般反馈:发送电子邮件至feedback@packtpub.com,并在消息主题中提及书籍标题。如果你对本书的任何方面有疑问,请通过questions@packtpub.com向我们发送电子邮件。
勘误:虽然我们已经尽一切努力确保内容的准确性,但错误仍然会发生。如果你在这本书中发现了错误,我们将非常感谢你能向我们报告。请访问www.packtpub.com/submit-errata,选择你的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果你在互联网上以任何形式遇到我们作品的非法副本,我们将非常感谢你能提供位置地址或网站名称。请通过copyright@packtpub.com联系我们,并提供材料的链接。
如果你有兴趣成为作者:如果你在某个话题上有专业知识,并且有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
评审
请留下评论。一旦你阅读并使用了这本书,为什么不在这本书购买的网站上留下评论呢?潜在的读者可以看到和使用你的客观意见来做出购买决策,我们 Pactt 可以了解你对我们的产品的看法,我们的作者可以看到你对他们书籍的反馈。谢谢!
有关 Pactt 的更多信息,请访问packtpub.com。
第一章:微服务简介
微服务是近年来 IT 世界中出现的最热门趋势之一。相对容易地识别出它们日益受欢迎的最重要原因。它们的优点和缺点都是众所周知的,尽管我们所说的缺点可以通过使用正确的工具轻易解决。它们提供的优势包括可扩展性、灵活性和独立交付;这些是它们迅速受欢迎的原因。有一些早期的 IT 趋势对微服务受欢迎程度的增长产生了一些影响。我指的是像使用常见的基于云的环境和从关系型数据库迁移到 NoSQL 这样的趋势。
在详细讨论之前,让我们看看本章我们将要覆盖的主题:
-
使用 Spring Cloud 的云原生开发
-
微服务架构中的最重要元素
-
服务间通信模型
-
介绍断路器及其回退模式
微服务的恩赐
微服务概念定义了一种 IT 系统架构方法,该方法将应用程序划分为一系列松耦合的服务,这些服务实现业务需求。实际上,这是面向服务架构(SOA)概念的一个变种。迁移到微服务架构的最重要好处之一是能够执行大型复杂应用程序的持续交付。
到目前为止,你可能有机会阅读一些关于微服务的书籍或文章。我认为,大多数书籍都会给你详细描述它们的优点和缺点。使用微服务有很多优点。首先,对于一个新项目开发者来说,微服务相对较小,容易理解。我们通常想要确保代码中的一个变化不会对我们应用程序的所有其他模块产生不希望的效果。与微服务相比,我们可以对此有更多的确定性,因为我们只实现一个单一的业务领域,而不是像单体应用那样,有时即使看似不相关的功能也会放在同一个篮子里。不仅如此。我注意到,通常,在小微服务中维护预期的代码质量比在一个大的单体应用中(许多开发者引入了他们的更改)要容易。
我喜欢微服务架构的第二个方面与划分有关。到目前为止,当我不得不处理复杂的企业系统时,我总是看到系统根据其他子系统进行划分。例如,电信组织总是有一个计费子系统。然后你创建一个子系统来隐藏计费复杂性并提供一个 API。然后你发现你需要存储在计费系统中无法存储的数据,因为它不容易定制。所以你创建另一个子系统。这实际上导致你构建了一个复杂的子系统网格,如果不你是组织中的新员工,尤其难以理解。使用微服务,你不会有这样的问题。如果它们设计得很好,每个微服务都应该负责一个完整的选择区域。在某些情况下,这些区域与组织活动的部门无关。
使用 Spring Framework 构建微服务
尽管微服务概念已经是几年的重要话题,但支持运行完整微服务环境所需的所有功能的稳定框架仍然不多。自从我开始微服务的冒险以来,我一直试图跟上最新的框架,并找出针对微服务需求发展的特性。还有其他一些有趣的解决方案,如 Vert.x 或 Apache Camel,但它们没有一个能与 Spring Framework 相匹敌。
Spring Cloud 实现了所有在微服务架构中使用的经过验证的模式,如服务注册表、配置服务器、断路器、云总线、OAuth2 模式和 API 网关。它拥有强大的社区,因此新功能以高频率发布。它基于 Spring 的开放编程模型,该模型被全球数百万 Java 开发者使用。它也被很好地文档化。你在线找到许多可用的 Spring Framework 使用示例不会有任何问题。
云原生开发
微服务与云计算平台有着内在的联系,但微服务的概念并不是什么新东西。这种方法已经在 IT 开发世界中应用了多年,但现在,随着云解决方案的普及,它已经发展到了一个新的高度。指出这种普及的原因并不困难。与组织内部的本地解决方案相比,使用云可以为你提供可扩展性、可靠性和低维护成本。这导致了云原生应用开发方法的兴起,旨在让你充分利用云提供的所有优势,如弹性扩展、不可变部署和可弃实例。这一切都归结于一点——减少满足新需求所需的时间和成本。如今,软件系统和应用程序正在不断地得到改进。如果你采用基于单体的传统开发方法,代码库会不断增长,变得过于复杂,难以进行修改和维护。引入新功能、框架和技术变得困难,从而影响创新,抑制新想法。这是无法争辩的。
这个问题还有另一面。如今,几乎每个人都考虑迁移到云端,部分原因是因为这是潮流。每个人都需要这样做吗?当然不是。那些不确定是否要将应用程序迁移到远程云提供商(如 AWS、Azure 或 Google)的人,至少希望拥有一个本地私有云或 Docker 容器。但这真的能带来补偿所花费费用的好处吗?在考虑云原生开发和云平台之前,值得回答这个问题。
我并不是想阻止你使用 Spring Cloud,恰恰相反。我们必须彻底理解什么是云原生开发。这里有一个非常好的定义:
“云原生应用程序是一个专门为云计算环境而设计的程序,而不是简单地迁移到云端。”
Spring 旨在加速你的云原生开发。使用 Spring Boot 构建应用程序非常快;我将在下一章详细展示如何做到这一点。Spring Cloud 实现微服务架构模式,并帮助我们使用该领域最受欢迎的解决方案。使用这些框架开发的应用程序可以轻松地适应在 Pivotal Cloud Foundry 或 Docker 容器上部署,但它们也可以以传统方式作为一台或多台机器上的分离进程启动,并且你会拥有微服务方法的优点。现在让我们深入了解一下微服务架构。
学习微服务架构
设想一下,一个客户找上门来,希望您为他们设计一个解决方案。他们需要某种银行应用程序,该程序需要在整个系统中保证数据一致性。我们的客户到目前为止一直使用 Oracle 数据库,并且还从他们那里购买了支持。不假思索,我们决定设计一个基于关系数据模型的单体应用程序。您可以在以下简化系统设计图中看到系统设计:

数据库中映射了四个实体:
-
第一个实体,客户,存储和检索活动客户列表。
-
每个客户可能有一个或多个账户,这些账户由Account实体操作。
-
转账实体负责执行系统内账户间所有资金的转账。
-
还有一个产品实体,用于存储诸如客户存款和信贷等信息。
不深入讨论具体细节,应用程序暴露了 API,提供了实现对设计数据库上操作的所有必要操作。当然,实现符合三层模型。
一致性不再是最重要的要求,甚至不再是强制性的。客户期望一个解决方案,但不想让开发需要重新部署整个应用程序。系统应该是可扩展的,并且能够轻松地扩展新的模块和功能。另外,客户不会对开发者使用 Oracle 或其他关系型数据库施加压力——不仅如此,他还很高兴能避免使用它。这些足够成为决定迁移到微服务的理由吗?让我们假设它们是。我们将我们的单体应用程序分成四个独立的微服务,每个都有自己的专用数据库。在某些情况下,它仍然可以是关系型数据库,而在其他情况下则可以是 NoSQL 数据库。现在,我们的系统由许多独立构建和在我们环境中运行的服务组成。随着微服务数量的增加,系统复杂性也在上升。我们希望能够将这种复杂性隐藏在外部 API 客户端之外,它不应该知道它正在与服务X而不是Y进行通信。网关负责将所有请求动态路由到不同的端点。例如,单词dynamically意味着它应该基于服务发现中的条目,关于服务发现的需要,我将在后面的部分理解服务发现的需求中讨论。
隐藏特定服务的调用或动态路由并不是 API 网关的唯一功能。由于它是系统的入口点,因此它可以是一个跟踪重要数据、收集请求指标和其他统计信息的好地方。它可以通过丰富请求或响应头,来包含系统内部应用程序可用的某些额外信息。它应执行一些安全操作,例如身份验证和授权,并应能够检测到每个资源的每个要求,并拒绝不满足它们的请求。下面是一个说明示例系统的图表,该系统由四个独立的微服务组成,隐藏在 API 网关后面的外部客户端中:

理解服务发现的需求
假设我们已经将我们的单体应用程序划分为更小、独立的微服务。从外部看,我们的系统仍然和以前看起来一样,因为其复杂性隐藏在 API 网关后面。实际上,微服务并不多,但可能有更多。此外,它们中的每一个都可以与其他微服务进行交互。这意味着每个微服务都必须保留有关其他微服务的网络地址的信息。维护此类配置可能非常麻烦,尤其是当涉及到手动重写每个配置时。那么如果这些地址在重启后动态变化呢?下面的图表显示了示例微服务之间的调用路由:

服务发现是指在计算机网络上自动检测设备和设备提供的服务。在微服务架构中,这是必要的机制。每个服务启动后应该在自己名称的一个中央位置注册,以便其他所有服务都能访问。注册键应该是服务的名称或标识符,在整个系统中必须是唯一的,以便其他人能够通过该名称找到并调用该服务。每个具有给定名称的键都有一些值与之关联。在大多数情况下,这些属性指示服务的网络位置。更准确地说,它们指示微服务的一个实例,因为它可以作为在不同机器或端口上运行的独立应用程序进行复制。有时可以发送一些附加信息,但这取决于具体的服务发现提供程序。然而,重要的是,在同一键下,可以注册同一服务的多个实例。除了注册,每个服务还会获得其他注册在特定发现服务器上的服务完整列表。不仅如此,每个微服务都必须了解注册列表的任何更改。这可以通过定期更新从远程服务器先前收集的配置来实现。
一些解决方案结合了服务发现和服务器配置功能的使用。归根结底,这两种方法都非常相似。服务器的配置让你能够集中管理系统中的所有配置文件。通常,这样的配置是一个作为 REST web 服务的服务器。在启动之前,每个微服务都会尝试连接到服务器并获取为其准备好的参数。一种方法是将这样的配置存储在版本控制系统中,例如 Git。然后配置服务器更新其 Git 工作副本,并将所有属性作为 JSON 提供。另一种方法是使用存储键值对的解决方案,在服务发现过程中充当提供者的角色。最受欢迎的工具是 Consul 和 Zookeeper。以下图表说明了一个由一些微服务组成的系统架构,这些微服务带有数据库后端,并注册在一个名为发现服务的中央服务中:

服务之间的通信
为了保证系统的可靠性,我们不能让每个服务只运行一个实例。我们通常希望至少有两个实例在运行,以防其中一个出现故障。当然,可以更多,但我们为了性能原因会尽量减少。无论如何,相同服务多个实例的存在使得使用负载均衡来处理传入请求变得必要。首先,负载均衡器通常内置在 API 网关中。这个负载均衡器应该从发现服务器获取注册实例的列表。如果没有不用的理由,我们通常使用轮询规则,使传入流量在所有运行实例之间平均分配。同样的规则也适用于微服务侧的负载均衡器。
以下图表说明了两个示例微服务实例之间服务间通信的最重要的组件:

当人们听到微服务时,他们认为它由 RESTful web 服务组成,使用 JSON 表示法,但这只是可能性之一。我们可以使用一些其他的交互方式,这些方式当然不仅适用于基于微服务的架构。首先应该执行的分类是一对一或一对多的通信。在一对一的交互中,每个传入请求都由一个服务实例处理,而在一对多的情况下,它由多个服务实例处理。但最流行的分类标准是调用是同步还是异步。此外,异步通信可以分为通知。当客户端向服务发送请求,但不需要回复时,它只需执行一个简单的异步调用,这不会阻塞线程,而是异步回复。
此外,值得提及的是反应式微服务。现在,从版本 5 开始,Spring 也支持这种类型的编程。还有支持与 NoSQL 数据库(如 MongoDB 或 Cassandra)交互的反应式支持的库。最后一种著名的通信类型是发布-订阅。这是一种一对多的交互类型,其中客户端发布一条消息,然后被所有监听服务消费。通常,这个模型是使用消息代理实现的,如 Apache Kafka、RabbitMQ 和 ActiveMQ。
失败和断路器
我们已经讨论了与微服务架构相关的绝大多数重要概念。这样的机制,如服务发现、API 网关和配置服务器,是有用的元素,它们帮助我们创建一个可靠和高效的系统。即使你在设计系统架构时考虑了这些方面的许多方面,你也应该始终准备好应对失败。在许多情况下,失败的原因完全超出了持有者的控制范围,比如网络或数据库问题。对于基于微服务的系统来说,这类错误尤其严重,因为一个输入请求需要经过许多后续调用才能处理。第一个好的实践是在等待响应时始终使用网络超时。如果单个服务存在性能问题,我们应该尽量减小对其他服务的影响。发送错误响应比长时间等待回复更好,以免阻塞其他线程。
对于网络超时问题,一个有趣的解决方案可能是断路器模式。这是一个与微服务方法紧密相关的概念。断路器负责计算成功和失败的请求。如果错误率超过假设的阈值,它就会断开,并导致所有后续尝试立即失败。在特定时间段后,API 客户端应该重新开始发送请求,如果它们成功,则关闭断路器。如果每个服务都有多个实例,其中一个比其他实例慢,那么在负载均衡过程中它就会被忽视。处理部分网络故障的第二个常用机制是回退。这是一种在请求失败时必须执行的逻辑。例如,一个服务可以返回缓存数据、默认值或空的结果列表。我个人并不是这种解决方案的忠实粉丝。我更愿意将错误代码传播到其他系统,而不是返回缓存数据或默认值。
总结
Spring Cloud 的一大优势在于它支持我们所探讨的所有模式和机制。这些也是稳定的实现,与其他一些框架不同。我在第三章,Spring Cloud 概览中详细描述了哪些模式被哪个 Spring Cloud 项目所支持。
在本章中,我们讨论了与微服务架构相关的最重要概念,例如云原生开发、服务发现、分布式配置、API 网关以及断路器模式。我试图阐述我对这种方法在企业应用开发中的优缺点观点。然后,我描述了与微服务相关的的主要模式和解决方案。其中一些是已经存在多年的知名模式,在 IT 世界中被视为新事物。在这份总结中,我想引起您注意一些事情。微服务本质上就是云原生的。像 Spring Boot 和 Spring Cloud 这样的框架可以帮助您加速云原生开发。迁移到云原生开发的主要动机是能够更快地实施和交付应用程序,同时保持高质量。在许多情况下,微服务帮助我们实现这一点,但有时单体架构也是一个不错的选择。
尽管微服务是小型且独立的单元,但它们是集中管理的。例如网络位置、配置、日志文件和指标等信息应该存储在一个中央位置。有各种各样的工具和解决方案提供了所有这些功能。我们将在本书的几乎所有章节中详细讨论它们。Spring Cloud 项目旨在帮助我们整合所有这些内容。我希望能有效地引导您了解它提供的最重要的集成。
第二章:用于微服务的 Spring
我知道很多 Java 开发者都接触过 Spring Framework。实际上,它由许多项目组成,可以与许多其他框架一起使用,所以迟早你都会被迫尝试它。尽管与 Spring Boot 的接触经验相对较少,但它已经迅速获得了大量流行。与 Spring Framework 相比,Spring Boot 是一个相对较新的解决方案。它的实际版本是 2,而不是 Spring Framework 的 5。它的创建目的是什么?与标准 Spring Framework 方式相比,使用 Spring Boot 运行应用程序有什么区别?
本章我们将涵盖的主题包括:
-
使用启动器启用项目中的额外功能
-
使用 Spring Web 库实现暴露 REST API 方法的服务
-
使用属性和 YAML 文件自定义服务配置
-
为暴露的 REST 端点提供文档和规范
-
配置健康检查和监控功能
-
使用 Spring Boot 配置文件使应用程序适应不同模式运行
-
使用 ORM 功能与嵌入式和远程 NoSQL 数据库进行交互
介绍 Spring Boot
Spring Boot 专为独立运行 Spring 应用程序而设计,与简单的 Java 应用程序一样,可通过 java -jar 命令运行。使 Spring Boot 与标准 Spring 配置不同的基本要素就是简单。这种简单与我们需要了解的第一个重要术语紧密相关,那就是“启动器”(starter)。“启动器”是一个可以包含在项目依赖中的工件。它所做的就是为其他必须包含在你应用程序中的工件提供一套依赖项,以实现所需的功能。以这种方式提供的包已准备好使用,这意味着我们不需要配置任何内容使其工作。这让我们想到了与 Spring Boot 相关的第二个重要术语——自动配置。所有通过启动器包含的工件都设置了默认设置,这些设置可以通过属性或其他类型的启动器轻松覆盖。例如,如果你在你的应用程序依赖中包含了 spring-boot-starter-web,它将在应用程序启动时嵌入默认的 Web 容器并在默认端口上启动它。展望未来,Spring Boot 中的默认 Web 容器是 Tomcat,它在端口 8080 上启动。我们可以通过在应用程序属性文件中声明指定的字段轻松更改此端口,甚至可以通过在项目依赖中包含 spring-boot-starter-jetty 或 spring-boot-starter-undertow 来更改 Web 容器。
让我再来说一下启动器。它们的官方命名模式是spring-boot-starter-*,其中*是启动器的特定类型。在 Spring Boot 中有许多启动器可用,但我想要给你简单介绍一下其中最受欢迎的几个,这些也在这本书的后续章节中提供了示例:
| 名称 | 描述 |
|---|---|
spring-boot-starter |
核心启动器,包括自动配置支持、日志和 YAML。 |
spring-boot-starter-web |
允许我们构建 Web 应用程序,包括 RESTful 和 Spring MVC。使用 Tomcat 作为默认的嵌入式容器。 |
spring-boot-starter-jetty |
在项目中包含 Jetty,并将其设置为默认的嵌入式 servlet 容器。 |
spring-boot-starter-undertow |
在项目中包含 Undertow,并将其设置为默认的嵌入式 servlet 容器。 |
spring-boot-starter-tomcat |
包含 Tomcat 作为嵌入式 servlet 容器。spring-boot-starter-web默认使用的 servlet 容器启动器。 |
spring-boot-starter-actuator |
包含 Spring Boot Actuator,为应用程序提供监控和管理功能。 |
spring-boot-starter-jdbc |
包含 Spring JBDC 和 Tomcat 连接池。特定数据库的驱动应由您自己提供。 |
spring-boot-starter-data-jpa |
包含用于与关系型数据库使用 JPA/Hibernate 交互的所有工件。 |
spring-boot-starter-data-mongodb |
包含与 MongoDB 交互所需的所有工件,并在本地主机上初始化 Mongo 客户端连接。 |
spring-boot-starter-security |
将 Spring Security 包含在项目中,默认启用应用程序的基本安全性。 |
spring-boot-starter-test |
允许使用如 JUnit、Hamcrest 和 Mockito 等库创建单元测试。 |
spring-boot-starter-amqp |
将 Spring AMQP 包含在项目中,并作为默认的 AMQP 经纪人启动 RabbitMQ。 |
如果你对可用的启动器完整列表感兴趣,请参考 Spring Boot 规范。现在,让我们回到 Spring Boot 与 Spring Framework 标准配置之间的主要区别。正如我之前提到的,我们可以包含spring-boot-starter-web,它将 Web 容器嵌入到我们的应用程序中。使用标准的 Spring 配置,我们不会将 Web 容器嵌入应用程序中,而是将其作为 WAR 文件部署在 Web 容器上。这是 Spring Boot 用于创建部署在微服务架构中的应用程序的重要原因之一。微服务的一个主要特性是与其它微服务的独立性。在这种情况下,很明显,它们不应该共享常见的资源,如数据库或 Web 容器。在一个 Web 容器上部署许多 WAR 文件是微服务的反模式。因此,Spring Boot 是明显的选择。
个人而言,我在开发许多应用程序时使用了 Spring Boot,不仅是在微服务环境中工作。如果你尝试用它代替标准的 Spring Framework 配置,你将不希望回到过去。支持这个结论,你可以在 GitHub 上找到一个有趣的图表,展示了 Java 框架仓库的流行度:redmonk.com/fryan/files/2017/06/java-tier1-relbar-20170622-logo.png。让我们仔细看看如何使用 Spring Boot 开发应用程序。
使用 Spring Boot 开发应用程序
在项目中启用 Spring Boot 的推荐方式是使用一个依赖管理系统。在这里,你可以看到一个简短的片段,展示了如何在你的 Maven 和 Gradle 项目中包含适当的工件。以下是 Maven pom.xml的一个示例片段:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.5.7.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
使用 Gradle,我们不需要定义父级依赖。以下是build.gradle的一个片段:
plugins {
id 'org.springframework.boot' version '1.5.7.RELEASE'
}
dependencies {
compile("org.springframework.boot:spring-boot-starter-web:1.5.7.RELEASE")
}
当使用 Maven 时,继承spring-boot-starter-parent POM 并不是必要的。另外,我们可以使用依赖管理机制:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>1.5.7.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
现在,我们需要的只是创建一个主应用程序类并给它加上@SpringBootApplication注解,这个注解相当于其他三个注解的组合——@Configuration、@EnableAutoConfiguration和@ComponentScan:
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
一旦我们声明了主类并包括了spring-boot-starter-web,我们只需要运行我们的第一个应用程序。如果你使用一个开发 IDE,比如 Eclipse 或 IntelliJ,你应该直接运行你的主类。否则,应用程序必须像标准的 Java 应用程序一样使用java -jar命令进行构建和运行。首先,我们应该提供负责在应用程序构建过程中将所有依赖项打包成可执行 JAR(有时被称为胖 JAR)的配置。如果定义在 Maven pom.xml中,这个操作将由spring-boot-maven-plugin执行:
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
示例应用程序所做的不仅仅是启动在 Tomcat 容器上的 Spring 上下文,该容器在端口8080上可用。胖 JAR 的大小约为 14 MB。你可以很容易地,使用 IDE,查看项目中包含了哪些库。这些都是基本的 Spring 库,如spring-core、spring-aop、spring-context;Spring Boot;Tomcat 嵌入式;包括 Logback、Log4j 和 Slf4j 在内的日志库;以及用于 JSON 序列化或反序列化的 Jackson 库。一个好的建议是为项目设置默认的 Java 版本。你可以在pom.xml中很容易地设置它,通过声明java.version属性:
<properties>
<java.version>1.8</java.version>
</properties>
我们可以通过添加一个新的依赖项来更改默认的 Web 容器,例如,使用 Jetty 服务器:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jetty</artifactId>
</dependency>
定制配置文件
快速且不需要大量工作来创建应用程序的能力固然重要,但同样重要的是能够轻松自定义和覆盖默认设置的能力。Spring Boot 应运而生,并提供了实现配置管理的机制。实现这一点的最简单方法是使用配置文件,这些文件附加到应用程序的胖 JAR 中。Spring Boot 会自动检测以application前缀开头的配置文件。支持的文件类型是.properties和.yml。因此,我们可以创建如application.properties或application.yml的配置文件,甚至包括特定于配置文件后缀的文件,如application-prod.properties或application-dev.yml。此外,我们还可以使用操作系统环境变量和命令行参数来外部化配置。当使用属性文件或 YAML 文件时,它们应该放置在以下位置之一:
-
当前应用程序目录的
/config子目录 -
当前应用程序目录
-
类路径上的
/config包(例如,在你的 JAR 文件中) -
类路径根目录
如果你想给你的配置文件指定一个特定的名字,除了application或者application-{profile}之外,你需要在启动时提供一个spring.config.name环境属性。你也可以使用spring.config.location属性,它包含一个由逗号分隔的目录位置或文件路径列表:
java -jar sample-spring-boot-web.jar --spring.config.name=example
java -jar sample-spring-boot-web.jar --spring.config.location=classpath:/example.properties
在配置文件内部,我们可以定义两种类型的属性。首先是一组通用的、预定义的 Spring Boot 属性,这些属性通常由底层的类从spring-boot-autoconfigure库中消费。我们也可以定义我们自己的自定义配置属性,然后使用@Value或@ConfigurationProperties注解将它们注入到应用程序中。
让我们先来看看预定义的属性。Spring Boot 项目支持的全部属性在其文档中的附录 A,通用应用程序属性部分中列出。其中大部分是特定于某些 Spring 模块的,如数据库、网络服务器、安全和一些其他解决方案,但也有一组核心属性。我个人更喜欢使用 YAML 而不是属性文件,因为它可以很容易地被人类阅读,但最终决定权在你。通常,我会覆盖如应用程序名称、用于服务发现和分布式配置管理的网络服务器端口、日志记录或数据库连接设置等属性。通常,application.yml文件放在src/main/resources目录中,在 Maven 构建后,该目录位于 JAR 根目录中。这是一个覆盖默认服务器端口、应用程序名称和日志记录属性的示例配置文件:
server:
port: ${port:2222}
spring:
application:
name: first-service
logging:
pattern:
console: "%d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n"
file: "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
level:
org.springframework.web: DEBUG
file: app.log
这里真正酷的一点是,你不需要定义任何其他外部配置文件,例如log4j.xml或logback.xml,用于日志配置。在前一部分,你可以看到我将org.springframework.web的默认日志级别更改为DEBUG,并修改了日志模式,创建了一个日志文件app.log,放在当前应用程序目录中。现在,默认的应用程序名是first-service,默认的 HTTP 端口是2222。
我们的自定义配置设置也应该放在相同的属性或 YAML 文件中。以下是带有自定义属性的一个application.yml样本:
name: first-service
my:
servers:
- dev.bar.com
- foo.bar.com
可以使用@Value注解注入一个简单的属性:
@Component
public class CustomBean {
@Value("${name}")
private String name;
// ...
}
还可以使用@ConfigurationProperties注解注入更复杂的配置属性。YAML 文件中my.servers属性定义的值被注入到目标 bean 类型java.util.List中:
@ConfigurationProperties(prefix="my")
public class Config {
private List<String> servers = new ArrayList<String>();
public List<String> getServers() {
return this.servers;
}
}
到目前为止,我们已经成功创建了一个简单的应用程序,它所做的只是在一个诸如 Tomcat 或 Jetty 的 web 容器上启动 Spring。在本章的这部分,我想向您展示使用 Spring Boot 开始应用程序开发是多么简单。除此之外,我还描述了如何使用 YAML 或属性文件自定义配置。对于那些喜欢点击而不是打字的人来说,我推荐使用 Spring Initializr 网站(start.spring.io/),你可以在该网站上根据你选择的选项生成项目骨架。在简单视图中,你可以选择构建工具(Maven/Gradle)、语言(Java/Kotlin/Groovy)和 Spring Boot 版本。然后,你应该使用搜索引擎根据“搜索依赖项”标签提供所有必要的依赖项。我在其中包含了spring-boot-starter-web,正如你在下面的截图中看到的,在 Spring Initializr 上它只被标记为Web。点击“生成项目”后,生成的源代码的 ZIP 文件会被下载到你的电脑上。你可能还想知道,通过点击“切换到完整版本”,你可以看到 Spring Boot 和 Spring Cloud 几乎所有的库,这些库可以包含在生成的项目中:

我认为,既然我们已经复习了使用 Spring Boot 构建项目的基础知识,现在为我们的示例应用程序添加一些新功能正是时候。
创建 RESTful Web 服务
作为第一步,让我们创建一些面向调用客户端的 RESTful Web 服务。正如前面提到的,负责 JSON 消息序列化和反序列化的 Jackson 库,已经自动包含在我们的类路径中,与spring-boot-starter-web一起。因此,我们除了声明一个模型类之外,不需要做更多的操作,该模型类随后由 REST 方法返回或作为参数接收。以下是我们的示例模型类Person:
public class Person {
private Long id;
private String firstName;
private String lastName;
private int age;
private Gender gender;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
//...
}
Spring Web 提供了一些用于创建 RESTful Web 服务的注解。首先是@RestController注解,它应该设置在你负责处理传入 HTTP 请求的控制器 bean 类上。还有@RequestMapping注解,通常用于将控制器方法映射到 HTTP。正如你在下面的代码片段中所看到的,它可以用在整个控制器类上,为其中的所有方法设置请求路径。我们可以使用更具体的注解为具体的 HTTP 方法 such as @GetMapping或@PostMapping。@GetMapping与@RequestMapping参数method=RequestMethod.GET相同。另外两个常用的注解是@RequestParam和@RequestBody。第一个将路径和查询参数绑定到对象;第二个使用 Jackson 库将输入 JSON 映射到对象:
@RestController
@RequestMapping("/person")
public class PersonController {
private List<Person> persons = new ArrayList<>();
@GetMapping
public List<Person> findAll() {
return persons;
}
@GetMapping("/{id}")
public Person findById(@RequestParam("id") Long id) {
return persons.stream().filter(it -> it.getId().equals(id)).findFirst().get();
}
@PostMapping
public Person add(@RequestBody Person p) {
p.setId((long) (persons.size()+1));
persons.add(p);
return p;
}
// ...
}
为了与 REST API 标准兼容,我们应该处理PUT和DELETE方法。在它们的实现之后,我们的服务执行所有的 CRUD 操作:
| 方法 | 路径 | 描述 |
|---|---|---|
GET |
/person |
返回所有现有的人员 |
GET |
/person/{id} |
返回给定id的人员 |
POST |
/person |
添加新人员 |
PUT |
/person |
更新现有人员 |
DELETE |
/person/{id} |
使用给定的id从列表中删除人员 |
以下是带有DELETE和PUT方法的示例@RestController实现的片段:
@DeleteMapping("/{id}")
public void delete(@RequestParam("id") Long id) {
List<Person> p = persons.stream().filter(it -> it.getId().equals(id)).collect(Collectors.toList());
persons.removeAll(p);
}
@PutMapping
public void update(@RequestBody Person p) {
Person person = persons.stream().filter(it -> it.getId().equals(p.getId())).findFirst().get();
persons.set(persons.indexOf(person), p);
}
控制器代码非常简单。它将所有数据存储在本地java.util.List中,这显然不是一种好的编程实践。然而,将此视为为了基本示例而采用的简化。在本章的将应用程序与数据库集成部分,我将介绍一个更高级的示例应用程序,该应用程序集成了 NoSQL 数据库。
可能有些同学有使用 SOAP Web 服务的经验。如果我们用 SOAP 而不是 REST 创建了一个类似的的服务,我们将为客户端提供一个 WSDL 文件,其中包含所有服务定义。不幸的是,REST 不支持像 WSDL 这样的标准表示法。在 RESTful Web 服务的初期阶段,人们曾说过 Web 应用程序描述语言(WADL)将承担这一角色。但现实情况是,包括 Spring Web 在内的许多提供者,在应用程序启动后并不会生成 WADL 文件。我为什么要提到这些呢?嗯,我们已经完成了我们的第一个微服务,它通过 HTTP 暴露了一些 REST 操作。你可能在使用这个微服务时,在 IDE 中运行它,或者使用 java -jar 命令在构建完胖 JAR 之后运行它。如果你没有修改 application.yml 文件中的配置属性,或者在运行应用程序时没有设置 -Dport 选项,那么它将在 http://localhost:2222 上运行。为了使其他人调用我们的 API,我们有两个选择。我们可以分享一份描述其使用或自动生成 API 客户端机制的文档。或者两者都有。Swagger 就在这时介入了。
API 文档
Swagger 是设计、构建和文档化 RESTful API 的最受欢迎的工具。它是由 SoapUI(一个非常流行的 SOAP Web 服务工具)的设计者 SmartBear 创建的。我认为这对于那些有丰富 SOAP 经验的人来说已经足够推荐了。无论如何,使用 Swagger,我们可以使用表示法设计 API 然后从它生成源代码,或者反过来,我们从源代码开始然后生成一个 Swagger 文件。与 Spring Boot 一起,我们使用后一种方法。
使用 Swagger 2 与 Spring Boot 一起
Spring Boot 与 Swagger 2 的集成是由 Springfox 项目实现的。它在运行时检查应用程序,以推断基于 Spring 配置、类结构和 Java 注解的 API 语义。为了将 Swagger 与 Spring 结合使用,我们需要在 Maven pom.xml 中添加以下两个依赖,并用 @EnableSwagger2 注解主应用类:
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.7.0</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.7.0</version>
</dependency>
API 文档将在应用程序启动时由 Swagger 库从源代码自动生成。这个过程由 Docket bean 控制,它也声明在主类中。一个好主意可能是从 Maven pom.xml 文件中获取 API 版本。我们可以通过在类路径中包含 maven-model 库并使用 MavenXpp3Reader 类来实现。我们还使用 apiInfo 方法设置一些其他属性,如标题、作者和描述。默认情况下,Swagger 为所有 REST 服务生成文档,包括由 Spring Boot 创建的服务。我们想要限制此文档只包含位于 pl.piomin.services.boot.controller 包内的 @RestController:
@Bean
public Docket api() throws IOException, XmlPullParserException {
MavenXpp3Reader reader = new MavenXpp3Reader();
Model model = reader.read(new FileReader("pom.xml"));
ApiInfoBuilder builder = new ApiInfoBuilder()
.title("Person Service Api Documentation")
.description("Documentation automatically generated")
.version(model.getVersion())
.contact(new Contact("Piotr Mińkowski", "piotrminkowski.wordpress.com", "piotr.minkowski@gmail.com"));
return new Docket(DocumentationType.SWAGGER_2).select()
.apis(RequestHandlerSelectors.basePackage("pl.piomin.services.boot.controller"))
.paths(PathSelectors.any()).build()
.apiInfo(builder.build());
}
使用 Swagger UI 测试 API
应用程序启动后,在http://localhost:2222/swagger-ui.html上提供了 API 文档仪表板。这是 Swagger JSON 定义文件的更用户友好的版本,也是自动生成的,并在http://localhost:2222/v2/api-docs上可用。该文件可以被其他 REST 工具导入,例如 SoapUI:

如果你更喜欢 SoapUI 而不是 Swagger UI,你可以通过选择项目|导入 Swagger 来轻松导入 Swagger 定义文件。然后,你需要提供一个文件地址,正如你在这张截图中所看到的:

个人而言,我更喜欢 Swagger UI。你可以展开每个 API 方法以查看它们的详细信息。每个操作都可以通过提供所需的参数或 JSON 输入,并点击“尝试一下!”按钮来进行测试。这里有一张截图,展示了发送一个POST /person测试请求的情况:

这是响应屏幕:

Spring Boot Actuator 功能
仅仅创建工作应用程序并分享标准的 API 文档是不够的,特别是当我们谈论微服务时,那里有很多独立的实体结构成一个受管理的环境。接下来需要提到的重要事情是监控和收集应用程序的度量信息。在这方面,Spring Boot 也提供了支持。Spring Boot 项目提供了许多内置端点,允许我们监控并与应用程序互动。为了在我们的项目中启用它,我们应该在依赖项中包含spring-boot-starter-actuator。以下是最重要的 Actuator 端点列表:
| 路径 | 描述 |
|---|---|
/beans |
显示应用程序中初始化的所有 Spring bean 的完整列表。 |
/env |
暴露 Spring 的 Configurable Environment 中的属性,这意味着例如操作系统环境变量和配置文件中的属性。 |
/health |
显示应用程序的健康信息。 |
/info |
显示任意应用程序信息。它可以从例如build-info.properties或git.properties文件中获取。 |
/loggers |
显示并修改应用程序中的日志记录器配置。 |
/metrics |
显示当前应用程序的度量信息,例如内存使用情况、运行线程数或 REST 方法响应时间。 |
/trace |
显示跟踪信息(默认显示最后 100 个 HTTP 请求)。 |
使用 Spring 配置属性,端点可以很容易地进行自定义。例如,我们可以禁用默认启用的端点中的一个。默认情况下,除了shutdown之外的所有端点都是启用的。其中大多数端点都是受保护的。如果你想要从网页浏览器中调用它们,你应在请求头中提供安全凭据,或者为整个项目禁用安全功能。要实现后者,你需要在你的application.yml文件中包含以下语句:
management:
security:
enabled: false
应用程序信息
项目可用的端点完整列表在应用程序启动时的日志中可见。在禁用安全功能后,你可以在网页浏览器中测试它们全部。有趣的是,/info端点默认不提供任何信息。如果你想要改变这一点,你可以使用其中三个可用的自动配置InfoContributor bean 中的一个,或者编写你自己的。第一个,EnvironmentInfoContributor,在端点中暴露环境键。第二个,GitInfoContributor,在类路径中检测git.properties文件,然后显示关于提交的所有必要信息,如分支名称或提交 ID。最后一个,名为BuildInfoContributor,从META-INF/build-info.properties文件中收集信息,并在端点中也显示它。这两个用于 Git 和构建信息的属性文件可以在应用程序构建过程中自动生成。为了实现这一点,你应该在你的pom.xml中包含git-commit-id-plugin,并自定义spring-boot-maven-plugin以生成build-info.properties,如本代码片段中所见:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>build-info</goal>
<goal>repackage</goal>
</goals>
<configuration>
<additionalProperties>
<java.target>${maven.compiler.target}</java.target>
<time>${maven.build.timestamp}</time>
</additionalProperties>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>pl.project13.maven</groupId>
<artifactId>git-commit-id-plugin</artifactId>
<configuration>
<failOnNoGitDirectory>false</failOnNoGitDirectory>
</configuration>
</plugin>
有了可用的build-info.properties文件,你的/info将和之前有点不同:
{
"build": {
"version":"1.0-SNAPSHOT",
"java": {
"target":"1.8"
},
"artifact":"sample-spring-boot-web",
"name":"sample-spring-boot-web",
"group":"pl.piomin.services",
"time":"2017-10-04T10:23:22Z"
}
}
健康信息
与/info端点一样,/health端点也有一些自动配置的指标。我们可以监控磁盘使用情况、邮件服务、JMS、数据源以及 NoSQL 数据库(如 MongoDB 或 Cassandra)的状态。如果你从我们的示例应用程序中检查该端点,你只能得到关于磁盘使用情况的信息。让我们在项目中添加 MongoDB 来测试其中一个可用的健康指标,MongoHealthIndicator。MongoDB 并非随机选择。它在未来对于Person微服务的更高级示例中将很有用。为了启用 MongoDB,我们需要在pom.xml中添加以下依赖项。de.flapdoodle.embed.mongo构件在应用程序启动期间负责启动嵌入式数据库实例:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>de.flapdoodle.embed</groupId>
<artifactId>de.flapdoodle.embed.mongo</artifactId>
</dependency>
现在,/health端点返回了关于磁盘使用情况和 MongoDB 状态的信息:
{
"status":"UP",
"diskSpace":{
"status":"UP",
"total":499808989184,
"free":193956904960,
"threshold":10485760
},
"mongo":{
"status":"UP",
"version":"3.2.2"
}
}
在这个例子中,我们可以看到 Spring Boot 自动配置的力量。我们只需要将两个依赖项添加到项目中,就可以启用嵌入式 MongoDB。其状态已自动添加到/health端点。它还有一个对 Mongo ready-to-use 的客户端连接,这可以被进一步用于仓库 bean。
指标
正如我们通常所说的,没有免费的午餐。发展既快又容易,但在项目中包含一些额外的库后,庞大的 JAR 文件现在大约有 30 MB。使用自动配置的 actuator 端点之一,/metrics,我们可以轻松查看微服务的堆内存和非堆内存使用情况。发送一些测试请求后,堆内存使用大约为 140 MB,非堆内存为 65 MB。应用程序的总内存使用量约为 320 MB。当然,即使只是使用java -jar命令启动时使用-Xmx参数,这些值也可以稍微降低。然而,如果我们关心在生产模式下的可靠工作,就不应该将此限制降低太多。除了内存使用情况外,/metrics端点还显示了加载的类数量、活动线程数、每个 API 方法的平均持续时间等信息。以下是我们示例微服务端点响应的一个片段:
{
"mem":325484,
"mem.free":121745,
"processors":4,
"instance.uptime":765785,
"uptime":775049,
"heap.committed":260608,
"heap.init":131072,
"heap.used":138862,
"heap":1846272,
"nonheap.committed":75264,
"nonheap.init":2496,
"nonheap.used":64876,
"threads.peak":28,
"threads.totalStarted":33,
"threads":28,
"classes":9535,
"classes.loaded":9535,
"gauge.response.person":7.0,
"counter.status.200.person":4,
// ...
}
有可能创建我们自己的自定义指标。Spring Boot Actuator 提供了两个类,以便我们这样做——CounterService和GaugeService。正如其名称所暗示的,CounterService暴露了增加值、减少值和重置值的方法。相比之下,GaugeService旨在仅提交当前值。默认的 API 方法调用统计数据有点不完美,因为它们仅基于调用路径。如果它们在同一路径上可用,则无法区分方法类型。在我们的示例端点中,这适用于GET /person、POST /person和PUT /person。无论如何,我创建了PersonCounterService bean,用于计算add和delete方法调用的数量:
@Service
public class PersonCounterService {
private final CounterService counterService;
@Autowired
public PersonCounterService(CounterService counterService) {
this.counterService = counterService;
}
public void countNewPersons() {
this.counterService.increment("services.person.add");
}
public void countDeletedPersons() {
this.counterService.increment("services.person.deleted");
}
}
这个 bean 需要被注入到我们的 REST 控制器 bean 中,当一个人被添加或删除时,可以调用增加计数值的方法:
public class PersonController {
@Autowired
PersonCounterService counterService;
// ...
@PostMapping
public Person add(@RequestBody Person p) {
p.setId((long) (persons.size()+1));
persons.add(p);
counterService.countNewPersons();
return p;
}
@DeleteMapping("/{id}")
public void delete(@RequestParam("id") Long id) {
List<Person> p = persons.stream().filter(it -> it.getId().equals(id)).collect(Collectors.toList());
persons.removeAll(p);
counterService.countDeletedPersons();
}
}
现在,如果你再次显示应用程序指标,你将在 JSON 响应中看到以下两个新字段:
{
// ...
"counter.services.person.add":4,
"counter.services.person.deleted":3
}
所有由 Spring Boot 应用程序生成的指标都可以从内存缓冲区导出到一个可以分析和显示的地方。例如,我们可以将它们存储在 Redis、Open TSDB、Statsd 或甚至 InfluxDB 中。
我认为关于内置监控端点的细节差不多就这些了。我为此类主题如文档、指标和健康检查分配了相对较多的空间,但在我看来,这些都是微服务开发和维护的重要方面。开发者通常不在乎这些机制是否实现得很好,但其他人通常只是通过这些指标、健康检查和应用程序日志的质量来看我们的应用程序。Spring Boot 提供了这样的实现,因此开发者不必花太多时间来启用它们。
开发者工具
Spring Boot 为开发者提供了其他一些有用的工具。对我来说真正酷的是,项目类路径上的文件发生变化时,应用程序会自动重新启动。如果你使用 Eclipse 作为你的 IDE,要启用它,你只需要在 Maven 的 pom.xml 中添加 spring-boot-devtools 依赖。然后,尝试更改你其中一个类中的某个东西并保存它。应用程序会自动重新启动,而且所用时间远比标准方式停止和启动要少。当我启动我们的示例应用程序时,大约需要 9 秒钟,而自动重启只需要 3 秒:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
如果我们不需要在更改时触发重启,我们可以排除一些资源。默认情况下,类路径上可用的任何指向文件夹的文件都将被监控以检测更改,即使是静态资产或视图模板,也不需要重新启动。例如,如果它们放在静态文件夹中,你可以在 application.yml 配置文件中添加以下属性来排除它们:
spring:
devtools:
restart:
exclude: static/**
将应用程序与数据库集成
你可以在 Spring Boot 规范中找到更多有趣的特性。我想花更多时间描述该框架提供的其他酷功能,但我们不应该偏离主题太远——Spring 用于微服务。正如你可能记得的,通过在项目中包含嵌入式 MongoDB,我答应给你一个更高级的微服务示例。在开始处理它之前,让我们回到我们应用程序的当前版本。它的源代码可以在我的公共 GitHub 账户上找到。将以下 Git 仓库克隆到你的本地机器:github.com/piomin/sample-spring-boot-web.git。
构建一个示例应用程序
基本示例可以在 master 分支中找到。带有嵌入式 MongoDB 的更高级示例提交到了 mongo 分支。如果你想尝试运行更高级的示例,你需要使用 git checkout mongo 切换到那个分支。现在,我们需要在模型类中进行一些更改,以启用对 MongoDB 的对象映射。模型类必须用 @Document 注解,主键字段用 @Id 注解。我还将 ID 字段类型从 Long 改为 String,因为 MongoDB 使用 UUID 格式的的主键,例如 59d63385206b6d14b854a45c:
@Document(collection = "person")
public class Person {
@Id
private String id;
private String firstName;
private String lastName;
private int age;
private Gender gender;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
// ...
}
下一步是创建一个扩展了MongoRepository的仓库接口。MongoRepository 为搜索和存储数据提供了基本方法,如findAll、findOne、save和delete。Spring Data 有一个非常智能的机制,利用仓库对象执行查询。我们不需要自己实现查询,只需定义一个命名正确的接口方法。该方法名应具有findBy前缀和搜索字段名。它可能以一个标准的搜索关键字后缀结束,如GreaterThan、LessThan、Between、Like等。基于完整的方法名,Spring Data 类会自动生成 MongoDB 查询。相同的关键词可以与delete…By或remove…By结合使用,以创建删除查询。在PersonRepository接口中,我决定定义两个查找方法。第一个,findByLastName,选择所有给定lastName值的Person实体。第二个,findByAgeGreaterThan,旨在检索所有年龄大于给定值的Person实体:
public interface PersonRepository extends MongoRepository<Person, String> {
public List<Person> findByLastName(String lastName);
public List<Person> findByAgeGreaterThan(int age);
}
仓库应该被注入到 REST 控制器类中。然后,我们终于可以调用PersonRepository提供的所有必需的 CRUD 方法:
@Autowired
private PersonRepository repository;
@Autowired
private PersonCounterService counterService;
@GetMapping
public List<Person> findAll() {
return repository.findAll();
}
@GetMapping("/{id}")
public Person findById(@RequestParam("id") String id) {
return repository.findOne(id);
}
@PostMapping
public Person add(@RequestBody Person p) {
p = repository.save(p);
counterService.countNewPersons();
return p;
}
@DeleteMapping("/{id}")
public void delete(@RequestParam("id") String id) {
repository.delete(id);
counterService.countDeletedPersons();
}
我们还添加了两个从PersonRepository bean 自定义查找操作的 API 方法:
@GetMapping("/lastname/{lastName}")
public List<Person> findByLastName(@RequestParam("lastName") String lastName) {
return repository.findByLastName(lastName);
}
@GetMapping("/age/{age}")
public List<Person> findByAgeGreaterThan(@RequestParam("age") int age) {
return repository.findByAgeGreaterThan(age);
}
这就做完了所有的事情。我们的微服务已经准备好启动,它暴露了实现对嵌入式 Mongo 数据库进行 CRUD 操作的基本 API 方法。你可能已经注意到,它并没有要求我们创建大量的源代码。使用 Spring Data 实现与数据库的任何交互,无论是关系型还是 NoSQL,都是快速和相对简单的。无论如何,我们面前还有一个挑战。嵌入式数据库是一个不错的选择,但只适用于开发模式或单元测试,而不是生产模式。如果你必须在生产模式下运行你的微服务,你可能会启动一个独立的 MongoDB 实例或一些作为分片集群部署的 MongoDB 实例,并将应用程序连接到它们。对于我们的示例目的,我将使用 Docker 运行 MongoDB 的一个实例。
如果你不熟悉 Docker,你总是可以只在你的本地或远程机器上安装 Mongo。关于 Docker 的更多信息,你也可以参考第十四章、Docker 支持,在那里我会给你一个简短的介绍。那里有你开始所需的一切,例如如何在 Windows 上安装它和使用基本命令。我还将使用 Docker 在为下一章节和主题实现示例中,所以我认为如果你有基本的了解它会很有用。
运行应用程序
让我们使用 Docker run命令启动 MongoDB:
docker run -d --name mongo -p 27017:27017 mongo
对我们可能有用的一件事是 Mongo 数据库客户端。使用这个客户端,可以创建一个新的数据库并添加一些带有凭据的用户。如果您在 Windows 上安装了 Docker,默认虚拟机地址是192.168.99.100。由于在run命令内部设置了-p参数,Mongo 容器暴露了端口27017。实际上,我们不必创建数据库,因为当我们定义客户端连接时提供数据库名称,如果它不存在,它将自动创建:

接下来,我们应该为应用程序创建一个具有足够权限的用户:

最后,我们应该在application.yml配置文件中设置 Mongo 数据库连接设置和凭据:
server:
port: ${port:2222}
spring:
application:
name: first-service
// ...
---
spring:
profiles: production
application:
name: first-service
data:
mongodb:
host: 192.168.99.100
port: 27017
database: microservices
username: micro
password: micro
Spring Boot 很好地支持多配置文件。YAML 文件可以通过使用*---*行分隔成一系列文档,每个文档部分独立解析为一个扁平化的映射。前面的示例与使用application-production.yml的分离配置文件完全一样。如果您没有使用任何其他选项运行应用程序,它将使用默认设置,这些设置没有设置配置文件名称。如果您希望使用生产属性运行它,您应该设置 VM 参数spring.profiles.active:
java -jar -Dspring.profiles.active=production sample-spring-boot-web-1.0-SNAPSHOT.jar
这还不算完。现在,带有活动生产配置文件的应用程序无法启动,因为它尝试初始化embeddedMongoServerbean。正如您可能已经知道的,Spring Boot 中几乎所有的附加解决方案都设置了自动配置。这个例子也不例外。我们需要在生产配置文件中排除EmbeddedMongoAutoConfiguration类:
spring:
profiles: production
// ...
autoconfigure:
exclude: org.springframework.boot.autoconfigure.mongo.embedded.EmbeddedMongoAutoConfiguration
我们也可以使用配置类来排除该工件:
@Configuration
@Profile("production")
@EnableAutoConfiguration(exclude = EmbeddedMongoAutoConfiguration.class)
public class ApplicationConfig {
// ...
}
当然,我们本可以使用更优雅的解决方案,比如 Maven 配置文件,并从目标构建包中排除整个de.flapdoodle.embed.mongo工件。所示解决方案只是解决该问题的几种可能性之一,但它展示了 Spring Boot 中的自动配置和配置文件机制。现在,您可以运行我们的示例应用程序并使用例如 Swagger UI 进行一些测试。您还可以使用 Mongo 客户端连接到数据库并查看数据库中的更改。以下是我们的示例项目的最终文件结构:
pl
+- piomin
+- services
+- boot
+- Application.java
|
+- controller
| +- PersonController.java
|
+- data
| +- PersonRepository.java
|
+- model
| +- Person.java
| +- Gender.java
|
+- service
| +- PersonCounterService.java
示例应用程序完成了。这些都是我本章想要展示给你的 Spring Boot 功能。我主要关注那些特别适用于创建基于 REST 的服务的功能。
总结
我已经引导你经历了单微服务开发的过程,从一个非常基础的例子到一个更高级的、生产就绪的 Spring Boot 应用。我描述了如何使用启动器(starters)为项目启用附加特性;使用 Spring Web 库来实现暴露 REST API 方法的服务;然后我们转向使用属性和 YAML 文件自定义服务配置。我们还看到了如何文档化和提供暴露 REST 端点的规格说明。接下来,我们配置了健康检查和监控特性。我们使用了 Spring Boot 配置文件(profiles)使应用能够以不同的模式运行,最后,我们使用了对象关系映射(ORM)特性来与内嵌和远程的 NoSQL 数据库进行交互。
我没有在这一章中提到 Spring Cloud 绝非偶然。你没有基本的 Spring Boot 知识和经验,是无法开始使用 Spring Cloud 项目的。Spring Cloud 提供了许多不同的特性,让你可以将你的服务放置在一个完整的基于微服务的生态系统中。我们将在接下来的章节中逐一讨论这些功能。
第三章:Spring Cloud 概览
在第一章,微服务介绍中,我提到了基于云的开发风格,以及 Spring Cloud 如何帮助你轻松采用与这种概念相关的最佳实践。最常用的最佳实践已经被收集在一个有趣的倡议中,称为The Twelve-Factor App。正如你可能会在他们网站上读到的(12factor.net/),这是一种构建软件即服务(SaaS)现代应用程序的方法,这种应用程序必须是可扩展的,容易在云平台上部署,并以持续部署过程提供。熟悉这些原则尤其值得,特别是如果你是一个构建作为服务运行的应用程序的开发者。Spring Boot 和 Spring Cloud 提供了使你的应用程序符合Twelve-Factor 规则的特性和组件。我们可以区分出一些最现代分布式系统通常使用的典型特性。每个有见地的框架都应该提供它们,Spring Cloud 也不例外。这些特性如下:
-
分布式/版本化配置
-
服务注册与发现
-
路由
-
服务间调用
-
负载均衡
-
断路器
-
分布式消息传递
从基础开始
让我们先回到上一章的内容。在那儿,我已经详细介绍了 Spring Boot 项目的结构。配置应该提供在 YAML 文件或以应用程序或application-{profile}命名的属性文件中。与标准的 Spring Boot 应用程序相比,Spring Cloud 是基于从远程服务器获取的配置。然而,在应用程序内部只需要最少的设置;例如,其名称和配置服务器地址。这就是为什么 Spring Cloud 应用程序创建了一个引导上下文,负责从外部来源加载属性。引导属性具有最高优先级,它们不能被本地配置覆盖。引导上下文是主应用程序上下文的父级,它使用bootstrap.yml而不是application.yml。通常,我们将应用程序名称和 Spring Cloud Config 设置放在下面这样:
spring:
application:
name: person-service
cloud:
config:
uri: http://192.168.99.100:8888
通过将spring.cloud.bootstrap.enabled属性设置为false,可以轻松禁用 Bootstrap 上下文的启动。我们还可以使用spring.cloud.bootstrap.name属性更改引导配置文件的名称,或者通过设置spring.cloud.bootstrap.location来更改其位置。在这里也可以使用配置文件机制,因此我们可以创建例如bootstrap-development.yml的文件,在激活的开发配置文件上进行加载。Spring Cloud Context 库中提供了这些以及其他一些特性,该库作为项目类路径的父依赖与其他任何 Spring Cloud 库一起添加。其中一些特性包括与 Spring Boot Actuator 一起提供的附加管理端点:
-
env:新的POST方法用于Environment,日志级别更新和@ConfigurationProperties重新绑定 -
refresh:重新加载引导上下文并刷新所有带有@RefreshScope注解的 bean -
restart:重新启动 SpringApplicationContext -
pause:停止 SpringApplicationContext -
resume:启动 SpringApplicationContext
与 Spring Cloud Context 一起作为 Spring Cloud 项目的父依赖包含在项目中的下一个库是 Spring Cloud Commons。它为诸如服务发现、负载均衡和断路器等机制提供了一个共同的抽象层。这些包括其他常用注解,如@EnableDiscoveryClient或@LoadBalanced。关于它们的详细信息,我将在接下来的章节中介绍。
Netflix OSS
在阅读前两章之后,你们可能已经注意到了许多与微服务架构相关的关键词。对于一些人来说,这可能是一个新术语,对于其他人来说,它可能是众所周知的。但到目前为止,对微服务社区来说还有一个重要的词还没有提到。大多数你们肯定都知道,这个词是Netflix。嗯,我也喜欢他们的电视剧和其他制作,但对我来说,他们因为另一个原因而出名。这个原因就是微服务。Netflix 是最早从传统的开发模式迁移到基于云的微服务开发方法的先驱之一。这家公司通过将大部分源代码推送到公共仓库、在会议演讲中发言以及发布博客文章,与社区分享他们的专业知识。Netflix 在其架构概念上的成功是如此之大,以至于它们成为了其他大型组织和他们的 IT 架构师(如 Adrian Cockcroft)的榜样,这些人现在是微服务的突出倡导者。作为回报,许多开源框架将它们的库基于 Netflix 共享的代码下的解决方案。对于 Spring Cloud 来说也不例外,它提供了与最流行的 Netflix OSS 特性(如 Eureka、Hystrix、Ribbon 或 Zuul)的集成。
顺便说一下,我不知道你是否一直在关注 Netflix,但他们透露了他们决定开源大部分代码的原因。我认为值得引用,因为这部分解释了他们在 IT 世界中成功和持续受欢迎的原因:
“当我们说我们要将整个 Netflix 搬到云端时,每个人都认为我们完全疯了。他们不相信我们真的在做这件事,他们认为我们只是在编造故事。”
使用 Eureka 进行服务发现
由 Spring Cloud Netflix 提供的第一个模式是使用 Eureka 进行服务发现。这个包分为客户端和服务器端。
要在项目中包含 Eureka 客户端,你应该使用spring-cloud-starter-eureka启动器。客户端总是应用程序的一部分,负责连接远程发现服务器。一旦建立连接,它应该发送一个包含服务名称和网络位置的注册消息。如果当前微服务需要调用另一个微服务的端点,客户端应该从服务器检索带有已注册服务列表的最新配置。服务器可以作为独立的 Spring Boot 应用程序进行配置和运行,并且每个服务器都应该将其状态复制到其他节点以实现高可用性。要在项目中包含 Eureka 服务器,你需要使用spring-cloud-starter-eureka-server启动器。
使用 Zuul 进行路由
在 Spring Cloud Netflix 项目中可用的下一个流行模式是使用 Zuul 进行智能路由。它不仅仅是一个基于 JVM 的路由器,还充当服务器端负载均衡器,执行某些过滤操作。它还有各种各样的应用。Netflix 用它来处理诸如认证、负载均衡、静态响应处理或压力测试等情况。它与 Eureka Server 相同,可以作为独立的 Spring Boot 应用程序进行配置和运行。
要在项目中包含 Zuul,请使用spring-cloud-starter-zuul启动器。在微服务架构中,Zuul 作为 API 网关扮演着至关重要的角色,它是整个系统的入口点。它需要了解每个服务的网络位置,因此通过将发现客户端包含在类路径中与 Eureka Server 进行交互。
使用 Ribbon 进行负载均衡
我们不能忽视用于客户端负载均衡的下一个 Spring Cloud Netflix 功能——Ribbon。它支持最流行的协议,如 TCP、UDP 和 HTTP。它不仅可以用于同步 REST 调用,还可以用于异步和反应式模型。除了负载均衡外,它还提供与服务发现、缓存、批处理和容错集成的功能。Ribbon 是基本 HTTP 和 TCP 客户端的下一个抽象级别。
要将其纳入您的项目,请使用spring-cloud-starter-ribbon启动器。Ribbon 支持循环冗余、可用性过滤和加权响应时间负载均衡规则,并且可以很容易地通过自定义规则进行扩展。它基于命名客户端概念,其中用于负载均衡的服务应提供名称。
编写 Java HTTP 客户端
Feign 是 Netflix OSS 包中稍微不太流行的一个。它是一个声明性的 REST 客户端,可以帮助我们更容易地编写 Web 服务客户端。使用 Feign,开发者只需声明和注解一个接口,而实际实现将在运行时生成。
要在您的项目中包含 Feign,您需要使用spring-cloud-starter-feign启动器。它与 Ribbon 客户端集成,因此默认支持负载均衡和其他 Ribbon 功能,包括与发现服务的通信。
使用 Hystrix 实现延迟和容错
我已经在第一章,微服务简介中提到了断路器模式,Spring Cloud 提供了一个实现此模式的库。它基于 Netflix 创建的 Hystrix 包,作为断路器实现。Hystrix 默认与 Ribbon 和 Feign 客户端集成。回退与断路器概念紧密相关。使用 Spring Cloud 库,您可以轻松配置回退逻辑,如果存在读取或断路器超时,应执行此逻辑。您应该使用spring-cloud-starter-hystrix启动器将 Hystrix 纳入您的项目。
使用 Archaius 进行配置管理
在 Spring Cloud Netflix 项目中提供的最后一个重要功能是 Archaius。我个人没有接触过这个库,但在某些情况下可能很有用。Spring Cloud 参考 Archaius 是 Apache Commons Configuration 项目的扩展。它允许通过轮询源进行配置更新或将更改推送到客户端。
发现与分布式配置
服务发现和分布式配置管理是微服务架构的两个重要部分。这两种不同机制的技术实现非常相似。它归结为在灵活的键值存储中存储特定键下的参数。实际上,市场上有一些有趣的解决方案可以提供这两种功能。Spring Cloud 与其中最受欢迎的解决方案集成。但是,还有一个例外,Spring Cloud 有自己的实现,仅用于分布式配置。此功能在 Spring Cloud Config 项目中提供。相比之下,Spring Cloud 不提供其自己的服务注册和发现实现。
像往常一样,我们可以将这个项目分为服务器和客户端支持两部分。服务器是所有外部属性的集中管理的地方,跨所有环境管理应用程序的属性。配置可以同时维护几个版本和配置文件。这是通过使用 Git 作为存储后端来实现的。这个机制非常智能,我们将在第五章,Spring Cloud Config 的分布式配置中详细讨论它。Git 后端不是存储属性的唯一选项。配置文件也可以位于文件系统或服务器类路径上。下一个选项是使用 Vault 作为后端。Vault 是 HashiCorp 发布的一个开源工具,用于管理令牌、密码或证书等秘密。我知道许多组织特别关注诸如将凭据存储在安全地方等安全问题,所以这可能是他们的正确解决方案。通常,我们也可以在配置服务器访问级别管理安全。无论使用哪种后端存储属性,Spring Cloud Config Server 都暴露了一个基于 HTTP 的 API,提供轻松访问它们。默认情况下,这个 API 通过基本身份验证保护,但也可以设置使用私钥/公钥身份验证的 SSL 连接。
一个服务器可以作为一个独立的 Spring Boot 应用程序运行,并通过 REST API 暴露属性。为了在我们的项目中启用它,我们应该添加spring-cloud-config-server依赖。在客户端也有支持。每个使用配置服务器作为属性源的微服务在启动后都需要连接到它,在创建任何 Spring bean 之前。有趣的是,Spring Cloud Config Server 可以被非 Spring 应用程序使用。有一些流行的微服务框架在客户端与之集成。为了在你的应用程序中启用 Spring Cloud Config Client,你需要包含spring-cloud-config-starter依赖。
一个替代方案——Consul
对于 Netflix 发现和 Spring 分布式配置,Consul(由 Hashicorp 创建)似乎是一个有趣的选择。Spring Cloud 为与这个流行的工具集成提供了发现和配置服务器的整合。像往常一样,这个集成可以通过一些简单的公共注解启用,与之前介绍的解决方案相比,唯一的区别在于配置设置。为了与 Consul 服务器建立通信,应用程序需要有一个可用的 Consul 代理。它必须能够作为一个分离的进程运行,默认情况下可以通过http://localhost:8500地址访问。Consul 还提供了 REST API,可以直接用于注册、收集服务列表或配置属性。
要激活 Consul 服务发现,我们需要使用spring-cloud-starter-consul-discovery启动器。在应用程序启动和注册后,客户端将查询 Consul 以定位其他服务。它支持使用 Netflix Ribbon 的客户端负载均衡器以及使用 Netflix Zuul 的动态路由和过滤器。
Apache Zookeeper
在这个领域内,Spring Cloud 支持的下一个流行解决方案是 Apache Zookeeper。按照其文档,它是一个维护配置、命名的中间服务,也提供分布式同步,并能够分组服务。之前应用于 Consul 的支持在 Spring Cloud 中也是一样的。我想在这里提到的是简单的通用注解,它们必须用于启用集成、配置,通过设置文件中的属性以及与 Ribbon 或 Zuul 交互的自动配置。要在客户端方面启用与 Zookeeper 的服务发现,我们不仅需要包括spring-cloud-starter-zookeeper-discovery,还需要 Apache Curator。它提供了一个 API 框架和工具,使集成更加容易和可靠。在分布式配置客户端方面,我们只需要在项目依赖中包含spring-cloud-starter-zookeeper-config。
其他各种项目
值得提到的是另外两个现在处于孵化阶段的项目。所有这些项目都可以在 GitHub 仓库中找到,github.com/spring-cloud-incubator。其中一些可能会很快正式加入 Spring Cloud 包。第一个是 Spring Cloud Kubernetes,它提供了与这个非常受欢迎的工具的集成。我们可以谈论它很长时间,但让我们尝试用几句话来介绍它。它是一个自动化部署、扩展和管理容器化应用程序的系统,最初由 Google 设计。它用于容器编排,并具有许多有趣的功能,包括服务发现、配置管理和负载均衡。在某些情况下,它可能会被视为 Spring Cloud 的竞争对手。配置是通过使用 YAML 文件来提供的。
Spring Cloud 的角度来看,重要的功能包括服务发现和分布式配置机制,这些机制在 Kubernetes 平台上可用。要使用它们,你应该包括spring-cloud-starter-kubernetes启动器。
在孵化阶段的第二个有趣项目是 Spring Cloud Etcd。与之前完全一样,它的主要特点包括分布式配置、服务注册和发现。Etcd 并不是像 Kubernetes 那样的强大工具。它只是为集群环境提供了一个可靠的键值存储的分布式键值存储,以及一点小八卦——Etcd 是 Kubernetes 中服务发现、集群状态和配置管理的后端。
使用 Sleuth 的分布式追踪
Spring Cloud 的另一个关键功能是分布式追踪,它是在 Spring Cloud Sleuth 库中实现的。其主要目的是将处理单个输入请求的不同微服务之间传递的后续请求相关联。在大多数情况下,这些都是基于 HTTP 头实现追踪机制的 HTTP 请求。该实现基于 Slf4j 和 MDC。Slf4j 为特定的日志框架(如 logback、log4j 或 java.util.logging)提供外观和抽象。MDC 或者 映射诊断上下文,全称是解决方案,用于区分来自不同来源的日志输出,并丰富它们附加在实际作用域中不可用的信息。
Spring Cloud Sleuth 在 Slf4J MDC 中添加了追踪和跨度 ID,这样我们就能提取具有给定追踪或跨度所有的日志。它还添加了一些其他条目,如应用程序名称或可导出标志。它与最受欢迎的消息解决方案集成,如 Spring REST 模板、Feign 客户端、Zuul 过滤器、Hystrix 或 Spring Integration 消息通道。它还可以与 RxJava 或计划任务一起使用。为了在您的项目中启用它,您应该添加spring-cloud-starter-sleuth依赖。对于基本跨度 ID 和追踪 ID 机制的使用对开发者是完全透明的。
添加追踪头并不是 Spring Cloud Sleuth 的唯一功能。它还负责记录时间信息,这在延迟分析中非常有用。这些统计信息可以导出到 Zipkin,这是一个用于查询和可视化时间数据的工具。
Zipkin 是一个为分析微服务架构内部延迟问题而特别设计的分布式追踪系统。它暴露了用于收集输入数据的 HTTP 端点。为了启用生成并将追踪数据发送到 Zipkin,我们应该在项目中包含spring-cloud-starter-zipkin依赖。
通常,没有必要分析所有内容;输入流量如此之大,我们只需要收集一定比例的数据。为此,Spring Cloud Sleuth 提供了一个采样策略,我们可以决定发送多少输入流量到 Zipkin。解决大数据问题的第二个智能方案是使用消息代理发送统计数据,而不是默认的 HTTP 端点。为了启用这个特性,我们必须包含spring-cloud-sleuth-stream依赖,它允许您的应用程序成为发送到 Apache Kafka 或 RabbitMQ 的消息的生产者。
消息和集成
我已经提到了消息代理以及它们用于应用程序和 Zipkin 服务器之间通信的用法。通常,Spring Cloud 支持两种类型的通信,即通过同步/异步 HTTP 和消息代理。这一领域的第一个项目是 Spring Cloud Bus。它允许你向应用程序发送广播事件,通知它们关于状态变化的信息,例如配置属性更新或其他管理命令。实际上,我们可能想使用带有 RabbitMQ 代理或 Apache Kafka 的 AMQP 启动器。像往常一样,我们只需要将spring-cloud-starter-bus-amqp或spring-cloud-starter-bus-kafka包含在依赖管理中,其他所有必要操作都通过自动配置完成。
Spring Cloud Bus 是一个较小的项目,允许你为诸如广播配置变更事件等常见操作使用分布式消息功能。构建由消息驱动的微服务系统所需的正确框架是 Spring Cloud Stream。这是一个非常强大的框架,也是 Spring Cloud 项目中最大的一个,我为此专门写了一整章——书籍的第十一章,《消息驱动的微服务》Message Driven Microservices。与 Spring Cloud Bus 相同,这里也有两个绑定器可供选择,第一个是用于 RabbitMQ 的 AMQP,第二个是用于 Apache Kafka 的。Spring Cloud Stream 基于 Spring Integration,这是 Spring 的另一个大型项目。它提供了一个编程模型,支持大多数企业集成模式,如端点、通道、聚合器或转换器。整个微服务系统中的应用程序通过 Spring Cloud Stream 的输入和输出通道相互通信。它们之间的主要通信模型是发布/订阅,其中消息通过共享主题进行广播。此外,支持每个微服务的多实例也很重要。在大多数情况下,消息应仅由单个实例处理,而发布/订阅模型不支持这一点。这就是 Spring Cloud Stream 引入分组机制的原因,其中仅组中的一个成员从目的地接收消息。与之前一样,这两个启动器可以根据绑定的类型包括一个项目——spring-cloud-starter-stream-kafka或spring-cloud-starter-stream-rabbit。
还有两个与 Spring Cloud Stream 相关的项目。首先,Spring Cloud Stream App Starters 定义了一系列可以独立运行或使用第二个项目 Spring Cloud Data Flow 运行的 Spring Cloud Stream 应用程序。在这些应用程序中,我们可以区分出连接器、网络协议适配器和通用协议。Spring Cloud Data Flow 是另一个广泛且强大的 Spring Cloud 工具集。它通过提供构建数据集成和实时数据处理管道的智能解决方案,简化了开发和部署。使用简单的 DSL、拖放式 UI 仪表板和 REST API 共同实现了基于微服务的数据管道的编排。
云平台支持
Pivotal Cloud Foundry 是一个用于部署和管理现代应用程序的云原生平台。Pivotal Software,正如你们中的一些人可能已经知道的那样,是 Spring 框架商标的拥有者。大型商业平台的支持是 Spring 日益受欢迎的重要原因之一。显而易见的是,PCF 完全支持 Spring Boot 的可执行 JAR 文件以及所有 Spring Cloud 微服务模式,如 Config Server、服务注册表和断路器。这些类型的工具可以通过 UI 仪表板或客户端命令行上可用的市场轻松运行和配置。对于 PCF 的开发甚至比标准的 Spring Cloud 应用程序还要简单。我们唯一要做的就是在项目依赖项中包含正确的启动器:
-
spring-cloud-services-starter-circuit-breaker -
spring-cloud-services-starter-config-client -
spring-cloud-services-starter-service-registry
要找到一个没有支持 AWS 的观点明确的云框架很难。对于 Spring Cloud 来说也是如此。Spring Cloud for Amazon Web Services 提供了与那里最流行的网络工具的集成。这包括与简单队列服务(SQS)、简单通知服务(SNS)、ElasticCache和关系数据库服务(RDS)通信的模块,后者提供如 Aurora、MySQL 或 Oracle 等引擎。可以使用在 CloudFormation 堆栈中定义的名称访问远程资源。一切都是按照众所周知的 Spring 约定和模式进行操作的。有四个主要模块可供使用:
-
Spring Cloud AWS Core:通过使用
spring-cloud-starter-aws启动器包含,提供核心组件,实现对 EC2 实例的直接访问 -
Spring Cloud AWS Context:提供对简单存储服务、简单电子邮件服务和缓存服务的访问
-
Spring Cloud AWS JDBC:通过使用启动器
spring-cloud-starter-aws-jdbc,提供数据源查找和配置,可以与 Spring 支持的任何数据访问技术一起使用 -
Spring Cloud AWS 消息:包含使用
starter spring-cloud-starter-aws-messaging启动器,允许应用程序使用 SQS(点对点)或 SNS(发布/订阅)发送和接收消息。
还有一个值得提及的项目,尽管它仍然处于开发的早期阶段。那是 Spring Cloud Function,它提供了无服务器架构的支持。无服务器也被称为FaaS(Function-as-a-Service),在其中开发者只创建非常小的模块,这些模块完全由第三方提供商托管在容器上。实际上,Spring Cloud Functions 为最流行的 FaaS 提供商 AWS Lambda 和 Apache OpenWhisk 实现了适配器。我将关注这个旨在支持无服务器方法的项目的开发。
在这一节中,我们不应该忘记 Spring Cloud Connectors 项目,原名Spring Cloud。它为部署在云平台上的 JVM 基础应用程序提供了抽象。实际上,它支持 Heroku 和 Cloud Foundry,我们的应用程序可以使用 Spring Cloud Heroku Connectors 和 Spring Cloud Foundry Connector 模块连接 SMTP、RabbitMQ、Redis 或可用的关系型数据库。
其他有用的库
微服务架构周围有一些重要的方面,这些不能算作其核心特性,但也非常重要。其中第一个是安全性。
安全性
标准实现用于保护 API 的绝大多数机制,如 OAuth2、JWT 或基本认证,都可在 Spring Security 和 Spring Web 项目中找到。Spring Cloud Security 使用这些库,使我们能够轻松创建实现常见模式的系统,如单点登录和令牌传递。为了为我们的应用程序启用安全管理,我们应该包含spring-cloud-starter-security启动器。
自动化测试
微服务开发中的下一个重要领域是自动化测试。对于微服务架构,接触测试变得越来越重要。马丁·福勒给出了以下定义:
“集成合同测试是在外部服务边界上进行的测试,验证它满足消费服务期望的合同。”
Spring Cloud 针对这种单元测试方法有一个非常有趣的实现,即 Spring Cloud Contract。它使用 WireMock 进行流量记录和 Maven 插件生成存根。
您也可能有机会使用 Spring Cloud Task。它帮助开发者使用 Spring Cloud 创建短暂存在的微服务,并本地运行或在云环境中运行。为了在项目中启用它,我们应该包含spring-cloud-starter-task启动器。
集群特性
最后,最后一个项目,Spring Cloud Cluster。它提供了一个解决方案,用于领导选举和常见有状态模式,以及 Zookeeper、Redis、Hazelcast 和 Consul 的抽象和实现。
项目概览
正如你所看到的,Spring Cloud 包含许多子项目,提供与许多不同工具和解决方案的集成。我认为如果你是第一次使用 Spring Cloud,很容易迷失方向。根据一图千言的原则,我呈现了最重要的项目,按类别划分,如下面的图表所示:

发布列车
正如之前的图表所示,Spring Cloud 内部有许多项目,它们之间存在许多关系。定义上,这些都是具有不同发布级联和版本号的独立项目。在这种情况下,我们应用中的依赖管理可能会出现问题,这需要了解所有项目版本之间的关系。为了使事情变得容易,Spring Cloud 引入了启动机制,我们已经在前面讨论过,还有发布列车。发布列车通过名称而不是版本来标识,以避免与子项目混淆。有趣的是,它们以伦敦地铁站的名称命名,并且按字母顺序排列。第一个发布版是 Angel,第二个是 Brixton,依此类推。整个依赖管理机制基于BOM(物料清单),这是一个用于独立版本管理工件的标准 Maven 概念。下面是一个实际的表格,其中分配了 Spring Cloud 项目版本到发布列车。带有后缀 M[X]的名称,其中[X]是版本号,意味着里程碑,SR[X]意味着服务发布,指的是修复关键 bug 的变化。正如您在下面的表格中看到的,Spring Cloud Stream 有自己的发布列车,它使用与 Spring Cloud 项目相同的规则来分组其子项目:
| 组件 | Camden.SR7 | Dalston.SR4 | Edgware.M1 | Finchley.M2 | Finchley.BUILD-SNAPSHOT |
|---|---|---|---|---|---|
spring-cloud-aws |
1.1.4.RELEASE | 1.2.1.RELEASE | 1.2.1.RELEASE | 2.0.0.M1 | 2.0.0.BUILD-SN |
spring-cloud-bus |
1.2.2.RELEASE | 1.3.1.RELEASE | 1.3.1.RELEASE | 2.0.0.M1 | 2.0.0.BUILD-SNAPSHOT |
spring-cloud-cli |
1.2.4.RELEASE | 1.3.4.RELEASE | 1.4.0.M1 | 2.0.0.M1 | 2.0.0.BUILD-SNAPSHOT |
spring-cloud-commons |
1.1.9.RELEASE | 1.2.4.RELEASE | 1.3.0.M1 | 2.0.0.M2 | 2.0.0.BUILD-SNAPSHOT |
spring-cloud-contract |
1.0.5.RELEASE | 1.1.4.RELEASE | 1.2.0.M1 | 2.0.0.M2 | 2.0.0.BUILD-SNAPSHOT |
spring-cloud-config |
1.2.3.RELEASE | 1.3.3.RELEASE | 1.4.0.M1 | 2.0.0.M2 | 2.0.0.BUILD-SNAPSHOT |
spring-cloud-netflix |
1.2.7.RELEASE | 1.3.5.RELEASE | 1.4.0.M1 | 2.0.0.M2 | 2.0.0.BUILD-SNAPSHOT |
spring-cloud-security |
1.1.4.RELEASE | 1.2.1.RELEASE | 1.2.1.RELEASE | 2.0.0.M1 | 2.0.0.BUILD-SNAPSHOT |
spring-cloud-cloudfoundry |
1.0.1.RELEASE | 1.1.0.RELEASE | 1.1.0.RELEASE | 2.0.0.M1 | 2.0.0.BUILD-SNAPSHOT |
spring-cloud-consul |
1.1.4.RELEASE | 1.2.1.RELEASE | 1.2.1.RELEASE | 2.0.0.M1 | 2.0.0.BUILD-SNAPSHOT |
spring-cloud-sleuth |
1.1.3.RELEASE | 1.2.5.RELEASE | 1.3.0.M1 | 2.0.0.M2 | 2.0.0.BUILD-SNAPSHOT |
spring-cloud-stream |
Brooklyn.SR3 | Chelsea.SR2 | Ditmars.M2 | Elmhurst.M1 | Elmhurst.BUILD-SNAPSHOT |
spring-cloud-zookeeper |
1.0.4.RELEASE | 1.1.2.RELEASE | 1.2.0.M1 | 2.0.0.M1 | 2.0.0.BUILD-SNAPSHOT |
spring-boot |
1.4.5.RELEASE | 1.5.4.RELEASE | 1.5.6.RELEASE | 2.0.0.M3 | 2.0.0.M3 |
spring-cloud-task |
1.0.3.RELEASE | 1.1.2.RELEASE | 1.2.0.RELEASE | 2.0.0.M1 | 2.0.0.RELEASE |
现在,我们只需要在 Maven pom.xml的依赖管理部分提供正确的发行版名称,然后使用启动器包含项目:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Finchley.M2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>
...
</dependencies>
这是 Gradle 的相同示例:
dependencyManagement {
imports {
mavenBom ':spring-cloud-dependencies:Finchley.M2'
}
}
dependencies {
compile ':spring-cloud-starter-config'
...
}
总结
在本章中,我介绍了属于 Spring Cloud 的最重要的项目。我指出了几个区域,并为每个项目分配了这些区域。阅读完本章后,你应该能够识别出在你的应用程序中需要包含哪个库,以实现在服务发现、分布式配置、断路器或负载均衡器等模式。你也应该能够识别出应用上下文和引导上下文之间的差异,并理解如何使用基于发行版概念的依赖管理来在项目中包含依赖项。在本章的最后,我想引起你们注意的一些与 Spring Cloud 集成的工具,例如 Consul、Zookeeper、RabbitMQ 或 Zipkin。我详细描述了它们的所有内容。我还指出了与这些工具交互的项目。
本章完成了本书的第一部分。在这一部分中,主要目标是让你了解 Spring Cloud 项目的基本知识。阅读完它后,你应该能够识别出基于微服务架构的最重要元素,有效地使用 Spring Boot 创建简单和更高级的微服务,最后,你也应该能够列出所有最流行的子项目,这些子项目是 Spring Cloud 的一部分。现在,我们可以继续下一部分的书,并详细讨论那些负责在 Spring Cloud 中实现分布式系统常见模式的子项目。其中大多数是基于 Netflix OSS 库的。我们将从提供服务注册、Eureka 发现服务器的解决方案开始。
第四章:服务发现
在我们到达这一点之前,在前面的章节中我们已经多次讨论了服务发现。实际上,它是微服务架构中最受欢迎的技术方面之一。这样的主题不可能从 Netflix OSS 实现中省略。他们没有决定使用具有类似功能的任何现有工具,而是专门为他们的需求设计并开发了一个发现服务器。然后,它与其他几个工具一起开源了。Netflix OSS 发现服务器被称为Eureka。
用于与 Eureka 集成的 Spring Cloud 库包括两部分,客户端和服务端。服务端作为独立的 Spring Boot 应用程序启动,并暴露一个 API,可以收集注册服务列表以及添加带有位置地址的新服务。服务器可以配置和部署为高可用性,每个服务器都与其它服务器复制其状态。客户端作为微服务应用程序的一个依赖项包含在内。它负责启动后的注册、关机前的注销,并通过轮询 Eureka 服务器保持注册列表的最新。
以下是我们在本章中要覆盖的主题列表:
-
开发运行内嵌 Eureka 服务器的应用程序
-
从客户端应用程序连接到 Eureka 服务器
-
高级发现客户端配置
-
启用客户端和服务器之间的安全通信
-
配置故障转移和对等复制机制
-
在不同区域注册客户端应用程序实例
在服务器端运行 Eureka
在 Spring Boot 应用程序中运行 Eureka 服务器并不是一件困难的事情。让我们来看看这是如何做到的:
- 首先,必须包含正确的依赖项到我们的项目中。显然,我们将使用一个启动器:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-eureka-server</artifactId>
</dependency>
- 在主应用程序类上启用 Eureka 服务器:
@SpringBootApplication
@EnableEurekaServer
public class DiscoveryApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(DiscoveryApplication.class).web(true).run(args);
}
}
- 有趣的是,与服务器启动器一起,客户端的依赖项也包括在内。它们对我们可能有用,但只有在以高可用性模式运行 Eureka,并且发现实例之间有对等通信时。当运行独立实例时,它实际上不会带给我们任何东西,除了在启动时在日志中打印一些错误。我们可以从启动器依赖项中排除
spring-cloud-netflix-eureka-client,或者使用配置属性禁用发现客户端。我更喜欢第二个选择,并且在这个场合,我将默认服务器端口更改为除了8080之外的其它值。以下是application.yml文件的一个片段:
server:
port: ${PORT:8761}
eureka:
client:
registerWithEureka: false
fetchRegistry: false
- 在完成前面的步骤之后,我们终于可以启动我们的第一个 Spring Cloud 应用程序了。只需从你的 IDE 中运行主类,或者使用 Maven 构建项目并运行它,使用
java -jar命令等待日志行Started Eureka Server出现。它就绪了。一个简单的 UI 仪表板作为主页可通过http://localhost:8761访问,并且可以通过/eureka/*路径调用 HTTP API 方法。Eureka 仪表板并没有提供很多功能;实际上,它主要用于检查注册的服务列表。这可以通过调用 REST APIhttp://localhost:8761/eureka/apps端点来实现。
所以,总结一下,我们知道如何使用 Spring Boot 运行一个独立的 Eureka 服务器,以及如何使用 UI 控制台和 HTTP 方法检查注册的微服务列表。但我们仍然没有任何能够自己在发现中注册的服务,是时候改变这一点了。一个带有发现服务器和客户端实现示例应用程序可以在 GitHub 上的master分支找到(github.com/piomin/sample-spring-cloud-netflix.git)。
启用客户端端的 Eureka
与服务器端一样,只需要包含一个依赖项就可以为应用程序启用 Eureka 客户端。所以,首先在你的项目依赖中包含以下启动器:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-eureka</artifactId>
</dependency>
这个示例应用程序所做的只是与 Eureka 服务器通信。它必须注册自己并向 Eureka 发送元数据信息,如主机、端口、健康指标 URL 和主页。Eureka 从属于某个服务的每个实例接收心跳消息。如果在配置的时间段内没有收到心跳消息,实例将被从注册表中移除。发现客户端的第二个责任是从服务器获取数据,然后缓存它并周期性地询问更改。可以通过在主类上使用@EnableDiscoveryClient注解来启用它。令人惊讶的是,还有另一种激活此功能的方法。你可以使用@EnableEurekaClient注解,特别是如果类路径中有多个发现客户端实现(Consul、Eureka、ZooKeeper)的话。虽然@EnableDiscoveryClient位于spring-cloud-commons中,@EnableEurekaClient位于spring-cloud-netflix中,并且只对 Eureka 有效。以下是发现客户端应用程序的主类:
@SpringBootApplication
@EnableDiscoveryClient
public class ClientApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(ClientApplication.class).web(true).run(args);
}
}
客户端配置中不必提供发现服务器的地址,因为默认的主机和端口上可用。然而,我们很容易想象 Eureka 没有在其默认的8761端口上监听。下面的配置文件片段可见。可以通过EUREKA_URL参数覆盖发现服务器的网络地址,也可以通过PORT属性覆盖客户端的监听端口。应用程序在发现服务器中注册的名称取自spring.application.name属性:
spring:
application:
name: client-service
server:
port: ${PORT:8081}
eureka:
client:
serviceUrl:
defaultZone: ${EUREKA_URL:http://localhost:8761/eureka/}
让我们在本地主机上运行我们示例客户端应用程序的两个独立实例。为了实现这一点,需要在启动时覆盖监听端口的数量,像这样:
java -jar -DPORT=8081 target/sample-client-service-1.0-SNAPSHOT.jar
java -jar -DPORT=8082 target/sample-client-service-1.0-SNAPSHOT.jar
正如您在下面的截图所看到的,有一个名为client-service的实例注册了piomin这个主机名和8081和8082这两个端口:

关机时的注销
检查与 Eureka 客户端的注销工作有点更具挑战性。我们的应用程序应该优雅地关闭,以便能够拦截一个停止事件并向服务器发送一个事件。实现优雅关闭的最佳方式是使用 Spring Actuator 的/shutdown端点。Actuator 是 Spring Boot 的一部分,可以通过在pom.xml中声明spring-boot-starter-actuator依赖项来将其包含在项目中。它默认是禁用的,因此我们必须在配置属性中启用它。为了简单起见,禁用该端点的用户/密码安全性是值得的:
endpoints:
shutdown:
enabled: true
sensitive: false
要关闭应用程序,我们必须调用POST /shutdownAPI 方法。如果您收到响应{"message": "Shutting down, bye..."},这意味着一切都很顺利,流程已经开始。在应用程序被禁用之前,从 Shutting down DiscoveryClient...行开始的某些日志将被打印出来。之后,服务将从发现服务器上注销,并完全消失在注册服务列表中。我决定通过调用http://localhost:8082/shutdown(您可以使用任何 REST 客户端,例如 Postman)关闭客户端实例#2,因此只在端口8081上运行的实例在仪表板上仍然可见:

Eureka 服务器仪表板还提供了一种方便的方式来查看新创建和取消租约的历史记录:

优雅关闭显然是停止应用程序的最合适方式,但在现实世界中,我们并不总是能够实现它。许多意想不到的事情可能发生,例如服务器机器重新启动、应用程序失败或客户端与服务器之间的网络问题。从发现服务器的角度来看,这种情况与从 IDE 中停止客户端应用程序或从命令行杀死进程相同。如果您尝试这样做,您将发现发现客户端关闭程序不会被触发,服务在 Eureka 仪表板上仍然显示为UP状态。此外,租约永远不会过期。
为了避免这种情况,服务器端的默认配置应该进行更改。为什么在默认设置中会出现这样的问题? Eureka 提供了一个特殊的机制,当检测到一定数量的服务没有及时续租时,注册表停止过期条目。这应该保护注册表在网络部分故障时清除所有条目。这个机制被称为自我保护模式,可以在application.yml中使用enableSelfPreservation属性禁用它。当然,在生产环境中不应该禁用它:
eureka:
server:
enableSelfPreservation: false
使用发现客户端程序化
客户端应用程序启动后,注册服务列表会自动从 Eureka 服务器获取。然而,有时可能需要程序化地使用 Eureka 的客户端 API。我们有两种可能性:
-
com.netflix.discovery.EurekaClient:它实现了 Eureka 服务器暴露的所有 HTTP API 方法,这些方法在 Eureka API 部分已经描述过了。 -
org.springframework.cloud.client.discovery.DiscoveryClient:这是 Spring Cloud 的一个替代 NetflixEurekaClient的本地客户端。它提供了一个简单、通用的 API,对于所有的发现客户端都很有用。有两个方法可用,getServices和getInstances:
private static final Logger LOGGER = LoggerFactory.getLogger(ClientController.class);
@Autowired
private DiscoveryClient discoveryClient;
@GetMapping("/ping")
public List<ServiceInstance> ping() {
List<ServiceInstance> instances = discoveryClient.getInstances("CLIENT-SERVICE");
LOGGER.info("INSTANCES: count={}", instances.size());
instances.stream().forEach(it -> LOGGER.info("INSTANCE: id={}, port={}", it.getServiceId(), it.getPort()));
return instances;
}
有一个与前面实现相关有趣的点。如果你在服务启动后立即调用/ping端点,它不会显示任何实例。这与响应缓存机制有关,下一节会详细描述。
高级配置设置
Eureka 的配置设置可以分为三部分:
-
服务器:它定制了服务器的行为。它包括所有带有
eureka.server.*前缀的属性。可用的字段完整列表可以在EurekaServerConfigBean类中找到(github.com/spring-cloud/spring-cloud-netflix/blob/master/spring-cloud-netflix-eureka-server/src/main/java/org/springframework/cloud/netflix/eureka/server/EurekaServerConfigBean.java)。 -
客户端:这是 Eureka 客户端侧可用的两个属性部分中的第一个。它负责配置客户端如何查询注册表以定位其他服务。它包括所有带有
eureka.client.*前缀的属性。要查看所有可用字段的全列表,请参考EurekaClientConfigBean类 (github.com/spring-cloud/spring-cloud-netflix/blob/master/spring-cloud-netflix-eureka-client/src/main/java/org/springframework/cloud/netflix/eureka/EurekaClientConfigBean.java)。 -
实例:它定制了 Eureka 客户端当前实例的行为,例如端口或名称。它包括所有带有
eureka.instance.*前缀的属性。要查看所有可用字段的全列表,请参考EurekaInstanceConfigBean类 (github.com/spring-cloud/spring-cloud-netflix/blob/master/spring-cloud-netflix-eureka-client/src/main/java/org/springframework/cloud/netflix/eureka/EurekaInstanceConfigBean.java)。
我已经向你展示了如何使用这些属性以达到预期的效果。在下一部分中,我将讨论一些与配置设置自定义相关有趣的场景。不需要描述所有属性。你可以在前面列出的所有类的源代码中的注释中阅读它们。
刷新注册表
让我们先回到之前的示例。自保模式已被禁用,但仍然需要等待服务器取消租约,这需要很长时间。造成这种情况有几个原因。第一个原因是每个客户端服务会每 30 秒向服务器发送一次心跳(默认值),这可以通过eureka.instance.leaseRenewalIntervalInSeconds属性进行配置。如果服务器没有收到心跳,它会在 90 秒后从注册表中移除实例,从而切断发送到该实例的交通。这可以通过eureka.instance.leaseExpirationDurationInSeconds属性进行配置。这两个参数都是在客户端设置的。出于测试目的,我们在秒中定义了小的值:
eureka:
instance:
leaseRenewalIntervalInSeconds: 1
leaseExpirationDurationInSeconds: 2
在服务器端还应该更改一个属性。Eureka 在后台运行 evict 任务,负责检查客户端的心跳是否仍在接收。默认情况下,它每 60 秒触发一次。所以,即使租约续订间隔和租约到期时长被设置为相对较低的值,服务实例在最坏的情况下也可能在 60 秒后被移除。后续计时器滴答之间的延迟可以通过使用evictionIntervalTimerInMs属性来配置,与前面讨论的属性不同,这个属性是以毫秒为单位的:
eureka:
server:
enableSelfPreservation: false
evictionIntervalTimerInMs: 3000
所有必需的参数都已分别在客户端和服务端定义。现在,我们可以使用-DPORT VM 参数再次运行发现服务器,然后在端口8081、8082和8083上启动客户端应用程序的三个实例。在那之后,我们逐一关闭端口8081和8082上的实例,只需杀死它们的进程即可。结果是什么?禁用的实例几乎立即从 Eureka 注册表中移除。以下是 Eureka 服务器的日志片段:

仍有一个实例正在监听端口8083。在自我维护模式被禁用时,与之一相关的警告信息将在 UI 仪表板上打印出来。一些额外的信息,比如租约到期状态或上分钟内续租次数,也许也挺有趣。通过操作所有这些属性,我们能够定制过期的租约移除流程的维护。然而,确保定义的设置不会影响系统的性能是很重要的。还有一些其他元素对配置的变化很敏感,比如负载均衡器、网关和熔断器。如果你禁用了自我维护模式,Eureka 会打印一条警告信息,你可以在下面的截图中看到:

更改实例标识符
在 Eureka 上注册的实例按名称分组,但每个实例必须发送一个唯一 ID,基于此 ID,服务器能够识别它。也许你已经注意到instanceId在仪表板上每个服务组的Status列中显示。Spring Cloud Eureka 会自动生成这个数字,它等于以下字段的组合:
${spring.cloud.client.hostname}:${spring.application.name}:${spring.application.instance_id:${server.port}}}.
这个标识符可以通过eureka.instance.instanceId属性轻松覆盖。为了测试目的,让我们启动一些客户端应用程序实例,使用以下配置设置和-DSEQUENCE_NO=[n] VM 参数,其中[n]从1开始的序列号。以下是一个根据SEQUENCE_NO参数动态设置监听端口和发现instanceId的客户端应用程序的示例配置:
server:
port: 808${SEQUENCE_NO}
eureka:
instance:
instanceId: ${spring.application.name}-${SEQUENCE_NO}
结果可以在 Eureka 仪表板上查看:

优先选择 IP 地址
默认情况下,所有实例都注册在其主机名下。这是一个非常方便的方法,前提是我们在我们的网络上启用了 DNS。然而,对于用作组织中微服务环境的服务器组,DNS 通常是不可用的,我自己就遇到过这种情况。除了在所有 Linux 机器上的/etc/hosts文件中添加主机名及其 IP 地址外,别无他法。这种解决方案的替代方法是更改注册过程配置设置,以广告服务的 IP 地址而不是主机名。为了实现这一点,客户端应将eureka.instance.preferIpAddress属性设置为true。注册表中的每个服务实例仍然会以instanceId包含主机名的形式打印到 Eureka 仪表板中,但如果你点击这个链接,重定向将基于 IP 地址进行。负责通过 HTTP 调用其他服务的 Ribbon 客户端也将遵循相同的原则。
如果你决定使用 IP 地址作为确定服务网络位置的主要方法,你可能会有问题。如果你有多个网络接口分配给你的机器,可能会出现问题。例如,在我曾经工作过的某个组织中,管理模式(我的工作站与服务器之间的连接)和生产模式(两台服务器之间的连接)有不同的网络。因此,每台服务器机器都分配有两个网络接口,具有不同的 IP 前缀。为了选择正确的接口,你可以在application.yml配置文件中定义一个忽略的模式列表。例如,我们希望能够忽略所有接口,其名称以eth1开头:
spring:
cloud:
inetutils:
ignoredInterfaces:
- eth1*
还有一种方法可以获得那种效果。我们可以定义应该优先的网络地址:
spring:
cloud:
inetutils:
preferredNetworks:
- 192.168
响应缓存
Eureka Server 默认缓存响应。缓存每 30 秒失效一次。可以通过调用 HTTP API 端点/eureka/apps轻松检查。如果你在客户端应用程序注册后立即调用它,你会发现响应中仍然没有返回。30 秒后再试,你会发现新实例出现了。响应缓存超时可以通过responseCacheUpdateIntervalMs属性覆盖。有趣的是,在使用 Eureka 仪表板显示已注册实例列表时,并没有缓存。与 REST API 相比,它绕过了响应缓存:
eureka:
server:
responseCacheUpdateIntervalMs: 3000
我们应该记住,Eureka 注册表也缓存在客户端。所以,即使我们在服务器端更改了缓存超时时间,它可能仍然需要一段时间才能被客户端刷新。注册表通过一个默认每 30 秒调度一次的异步后台任务定期刷新。这个设置可以通过声明registryFetchIntervalSeconds属性来覆盖。它只获取与上一次抓取尝试相比的增量。可以通过使用shouldDisableDelta属性来禁用此选项。我在服务器和客户端两边都定义了3秒的超时时间。如果你用这样的设置启动示例应用程序,/eureka/apps将显示新注册服务的实例,可能在你的第一次尝试中。除非客户端端的缓存有意义,否则我不确定在服务器端缓存是否有意义,尤其是因为 Eureka 没有后端存储。就个人而言,我从未需要更改这些属性的值,但我猜想它可能很重要,例如,如果你使用 Eureka 开发单元测试,并且需要无缓存的即时响应:
eureka:
client:
registryFetchIntervalSeconds: 3
shouldDisableDelta: true
启用客户端和服务器之间的安全通信
到目前为止,Eureka 服务器没有对客户端的任何连接进行身份验证。在开发模式下,安全性并不像在生产模式下那么重要。缺乏安全性可能是一个问题。我们希望能够至少确保发现服务器通过基本身份验证进行安全,以防止任何知道其网络地址的服务遭受未经授权的访问。尽管 Spring Cloud 参考资料声称HTTP 基本身份验证将自动添加到您的 Eureka 客户端,但我还是不得不将带有安全性的启动器添加到项目依赖中:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
然后,我们应该启用安全功能,并通过在application.yml文件中更改配置设置来设置默认凭据:
security:
basic:
enabled: true
user:
name: admin
password: admin123
现在,所有 HTTP API 端点和 Eureka 仪表板都得到了保护。要在客户端启用基本身份验证模式,应在 URL 连接地址中提供凭据,正如您在以下配置设置中所看到的那样。一个实现了安全发现示例应用程序在同一个存储库中 basic example,但您需要切换到security分支(github.com/piomin/sample-spring-cloud-netflix/tree/security)。以下是客户端启用 HTTP 基本身份验证的配置:
eureka:
client:
serviceUrl:
defaultZone: http://admin:admin123@localhost:8761/eureka/
对于更高级的使用,例如在发现客户端和服务器之间使用证书认证的安全 SSL 连接,我们应该提供一个DiscoveryClientOptionalArgs的自定义实现。我们将在第十二章,保护 API,专门讨论 Spring Cloud 应用程序的安全性,讨论这样一个例子。
注册安全服务
保护服务器端是一回事,注册安全应用程序是另一回事。让我们看看我们如何做到这一点:
- 为了给 Spring Boot 应用程序启用 SSL,我们需要从生成自签名证书开始。我建议你使用
keytool,它可以在你 JRE 根目录下的bin目录中找到:
keytool -genkey -alias client -storetype PKCS12 -keyalg RSA -keysize 2048 -keystore keystore.p12 -validity 3650
- 输入所需数据,并将生成的密钥库文件
keystore.p12复制到您应用程序的src/main/resources目录中。下一步是使用application.yml中的配置属性为 Spring Boot 启用 HTTPS:
server:
port: ${PORT:8081}
ssl:
key-store: classpath:keystore.p12
key-store-password: 123456
keyStoreType: PKCS12
keyAlias: client
- 在运行应用程序之后,您应该能够调用安全端点
https://localhost:8761/info。我们还需要对 Eureka 客户端实例配置进行一些更改:
eureka:
instance:
securePortEnabled: true
nonSecurePortEnabled: false
statusPageUrl: https://${eureka.hostname}:${server.port}/info
healthCheckUrl: https://${eureka.hostname}:${server.port}/health
homePageUrl: https://${eureka.hostname}:${server.port}/
Eureka API
Spring Cloud Netflix 提供了一个用 Java 编写的客户端,将 Eureka HTTP API 隐藏在开发者面前。如果我们使用除 Spring 之外的其他框架,Netflix OSS 提供了一个原味的 Eureka 客户端,可以作为依赖项包含在内。然而,我们可能需要直接调用 Eureka API,例如,如果应用程序是用 Java 以外的语言编写的,或者我们需要在持续交付过程中注册的服务列表等信息。以下是一个快速参考表:
| HTTP 端点 | 描述 |
|---|---|
POST /eureka/apps/appID |
将服务的新实例注册到注册表 |
DELETE /eureka/apps/appID/instanceID |
从注册表中删除服务实例 |
PUT /eureka/apps/appID/instanceID |
向服务器发送心跳 |
GET /eureka/apps |
获取有关所有注册服务实例列表的详细信息 |
GET /eureka/apps/appID |
获取特定服务所有注册实例列表的详细信息 |
GET /eureka/apps/appID/instanceID |
获取特定服务实例的详细信息 |
PUT /eureka/apps/appID/instanceID/metadata?key=value |
更新元数据参数 |
GET /eureka/instances/instanceID |
获取具有特定 ID 的所有注册实例的详细信息 |
PUT /eureka/apps/appID/instanceID/status?value=DOWN |
更新实例的状态 |
复制和高度可用性
我们已经讨论了一些有用的 Eureka 设置,但到目前为止,我们只分析了一个单一服务发现服务器的系统。这种配置是有效的,但只适用于开发模式。对于生产模式,我们希望能够至少运行两个发现服务器,以防其中一个失败或发生网络问题。Eureka 按定义是为了可用性和弹性而构建的,这是 Netflix 开发的主要支柱之二。但它不提供标准的集群机制,如领导选举或自动加入集群。它是基于对等复制模型。这意味着所有服务器复制数据并向所有对等节点发送心跳,这些节点在当前服务器节点的配置中设置。这种算法简单有效,用于包含数据,但它也有一些缺点。它限制了可扩展性,因为每个节点都必须承受服务器上的整个写入负载。
示例解决方案的架构
有趣的是,复制机制是新版本 Eureka Server 开始工作的主要动机之一。Eureka 2.0 仍然处于积极开发中。除了优化的复制功能外,它还将提供一些有趣的功能,如服务器向客户端推送注册列表中任何更改的推送模型,自动扩展服务器和一个丰富的仪表板。这个解决方案看起来很有希望,但 Spring Cloud Netflix 仍然使用版本 1,老实说我没有找到任何迁移到版本 2 的计划。Dalston.SR4 发布列车当前的 Eureka 版本是 1.6.2。服务器端复制机制的配置归结为一点,即使用eureka.client.*属性部分设置另一个发现服务器的 URL。所选服务器只需在其他服务器中注册自己,这些服务器被选择作为创建的集群的一部分。展示这个解决方案在实践中如何工作的最好方式当然是通过示例。
让我们从示例系统的架构开始,如下面的图表所示。我们的所有应用程序都将在本地不同端口上运行。在这个阶段,我们必须介绍基于 Netflix Zuul 的 API 网关的示例。这对于在不同区域的三个服务实例之间进行负载均衡测试非常有帮助:

构建示例应用程序
对于 Eureka Server,所有必需的更改可能定义在配置属性中。在application.yml文件中,我为每个发现服务实例定义了三个不同的配置文件。现在,如果您尝试在 Spring Boot 应用程序中运行 Eureka Server,您需要通过提供 VM 参数-Dspring.profiles.active=peer[n]来激活特定的配置文件,其中[n]是实例序列号:
spring:
profiles: peer1
eureka:
instance:
hostname: peer1
metadataMap:
zone: zone1
client:
serviceUrl:
defaultZone: http://localhost:8762/eureka/,http://localhost:8763/eureka/
server:
port: ${PORT:8761}
---
spring:
profiles: peer2
eureka:
instance:
hostname: peer2
metadataMap:
zone: zone2
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka/,http://localhost:8763/eureka/
server:
port: ${PORT:8762}
---
spring:
profiles: peer3
eureka:
instance:
hostname: peer3
metadataMap:
zone: zone3
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka/,http://localhost:8762/eureka/
server:
port: ${PORT:8763}
在使用不同配置文件名称运行所有三个 Eureka 实例之后,我们创建了一个本地发现集群。如果您在启动后立即查看任何 Eureka 实例的仪表板,它总是看起来一样,我们可以看到三个 DISCOVERY-SERVICE 实例:

下一步是运行客户端应用程序。项目中的配置设置与 Eureka 服务器的应用程序非常相似。defaultZone字段中提供的地址顺序决定了尝试连接不同发现服务的顺序。如果无法连接到第一个服务器,它会尝试从列表中连接第二个服务器,依此类推。与之前一样,我们应该设置 VM 参数-Dspring.profiles.active=zone[n]以选择正确的配置文件。我还建议您设置-Xmx192m参数,考虑到我们本地测试所有的服务。如果您不为 Spring Cloud 应用程序提供任何内存限制,它在启动后大约会消耗 350MB 的堆内存,总内存大约 600MB。除非您有很多 RAM,否则它可能会使您在本地机器上运行微服务的多个实例变得困难:
spring:
profiles: zone1
eureka:
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka/,http://localhost:8762/eureka/,http://localhost:8763/eureka/
server:
port: ${PORT:8081}
---
spring:
profiles: zone2
eureka:
client:
serviceUrl:
defaultZone: http://localhost:8762/eureka/,http://localhost:8761/eureka/,http://localhost:8763/eureka/
server:
port: ${PORT:8082}
---
spring:
profiles: zone3
eureka:
client:
serviceUrl:
defaultZone: http://localhost:8763/eureka/,http://localhost:8761/eureka/,http://localhost:8762/eureka/
server:
port: ${PORT:8083}
让我们再次查看 Eureka 仪表板。我们有client-service的三个实例在所有地方注册,尽管应用程序最初只连接到一个发现服务实例。无论我们进入哪个发现服务实例的仪表板查看,结果都是一样的。这正是这次练习的目的。现在,我们创建一些额外的实现仅为了证明一切按预期工作:

客户端应用程序所做的不仅仅是暴露一个打印所选配置文件名称的 REST 端点。配置文件名称指向特定应用程序实例的主要发现服务实例。下面是一个简单的@RestController实现,打印当前区域的名称:
@RestController
public class ClientController {
@Value("${spring.profiles}")
private String zone;
@GetMapping("/ping")
public String ping() {
return "I'm in zone " + zone;
}
}
最后,我们可以继续实现 API 网关。在本章范围内详细介绍 Zuul,Netflix 的 API 网关和路由功能是不合适的。我们将在下一章讨论它。Zuul 现在将有助于测试我们的示例解决方案,因为它能够检索在发现服务器中注册的服务列表,并在客户端应用程序的所有运行实例之间执行负载均衡。正如您在下面的配置片段中所看到的,我们使用一个在端口8763上监听的发现服务器。所有带有/api/client/**路径的传入请求将被路由到client-service:
zuul:
prefix: /api
routes:
client:
path: /client/**
serviceId: client-service
eureka:
client:
serviceUrl:
defaultZone: http://localhost:8763/eureka/
registerWithEureka: false
接下来让我们进行测试。我们的应用通过 Zuul 代理启动时应使用java -jar命令,与之前的服务不同,这里无需设置任何额外参数,包括配置文件名。它默认与编号为#3 的发现服务相连。要通过 Zuul 代理调用客户端 API,你需要在网页浏览器中输入以下地址,http://localhost:8765/api/client/ping。结果如下截图所示:

如果你连续重试几次请求,它应该在所有现有的client-service实例之间进行负载均衡,比例为 1:1:1,尽管我们的网关只连接到发现#3。这个例子充分展示了如何使用多个 Eureka 实例构建服务发现。
前面提到的示例应用程序在 GitHub 上可获得,位于cluster分支中(github.com/piomin/sample-spring-cloud-netflix.git)(github.com/piomin/sample-spring-cloud-netflix/tree/cluster_no_zones)。
故障转移
你可能想知道如果服务发现的一个实例崩溃了会发生什么?为了检查集群在故障发生时的行为,我们将稍稍修改之前的示例。现在,Zuul 在其配置设置中有一个到第二个服务发现的故障转移连接,端口为8762。为了测试目的,我们关闭了端口8763上的第三个发现服务实例:
eureka:
client:
serviceUrl:
defaultZone: http://localhost:8763/eureka/,http://localhost:8762/eureka/
registerWithEureka: false
当前情况在下图中说明。测试通过调用网关端点进行,端点地址为http://localhost:8765/api/client/ping。结果与之前测试相同,负载均衡在所有三个client-service实例之间平均进行,符合预期。尽管发现服务#3 已被禁用,但另外两个实例仍能相互通信,并从实例#3 复制第三个客户端应用实例的网络位置信息,只要实例#3 处于活动状态。现在,即使我们重新启动网关,它仍能够使用defaultZone字段中的第二个地址顺序连接发现集群,地址为http://localhost:8762/eureka。对于客户端应用的第三个实例也适用,该实例反过来将发现服务#1 作为备份连接:

区域
基于对等复制的集群在大多数情况下是一个不错的选择,但并非总是足够。Eureka 还有一个在集群环境中可能非常有用的有趣特性。实际上,区域机制是默认行为。即使我们有一个单独的独立服务发现实例,每个客户端的属性也必须在配置设置中设置为eureka.client.serviceUrl.defaultZone。这什么时候对我们有用呢?为了解析它,我们回到前面章节中的示例。让我们假设现在我们的环境被划分为三个不同的物理网络,或者我们只是有三台不同的机器处理传入的请求。当然,服务发现服务在逻辑上仍然分组在集群中,但每个实例都位于一个单独的区域。每个客户端应用程序都将注册在与其主要服务发现服务器相同的区域。我们不是要启动一个 Zuul 网关的实例,而是要启动三个实例,每个实例对应一个单一的区域。如果请求进入网关,它应该在尝试调用注册在其他区域的服务之前,优先考虑利用同一区域内的服务客户端。当前系统架构在下图中可视化。当然,为了示例的目的,架构被简化为能够在单个本地机器上运行。在现实世界中,如我之前提到的,它将在三台不同的机器上启动,甚至可能在其他网络上物理分离成三组机器:

具有独立服务器的区域
在这个阶段,我们应该强调一点,区域机制仅在客户端实现。这意味着服务发现实例没有被分配到任何区域。所以前一个图表可能有些令人困惑,但它指示了哪个 Eureka 是特定区域中所有客户端应用程序和网关的默认服务发现。我们的目的是检查高可用性模式下的机制,但我们也可以只构建一个单一的服务发现服务器。以下图表展示了与前一个图表类似的情况,不同之处在于它假设只有一个服务发现服务器为所有应用程序服务:

构建示例应用程序
为了启用区域处理,我们需要对客户端和网关的配置设置进行一些更改。以下是从客户端应用程序中修改的application.yml文件:
spring:
profiles: zone1
eureka:
instance:
metadataMap:
zone: zone1
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka/,http://localhost:8762/eureka/,http://localhost:8763/eureka/
唯一需要更新的是eureka.instance.metadataMap.zone 属性,我们在其中设置了区域名称和我们的服务已注册的服务名称。
在网关配置中必须进行更多更改。首先,我们需要添加三个配置文件,以便能够在三个不同区域和三个不同的发现服务器上运行一个应用程序。现在当启动网关应用程序时,我们应该设置 VM 参数-Dspring.profiles.active=zone[n]以选择正确的配置文件。与client-service类似,我们还必须在配置设置中添加eureka.instance.metadataMap.zone属性。还有一个属性eureka.client.preferSameZoneEureka,在示例中首次使用,如果网关应该优先选择注册在同一区域的客户端应用程序实例,则必须将其设置为true:
spring:
profiles: zone1
eureka:
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka/
registerWithEureka: false
preferSameZoneEureka: true
instance:
metadataMap:
zone: zone1
server:
port: ${PORT:8765}
---
spring:
profiles: zone2
eureka:
client:
serviceUrl:
defaultZone: http://localhost:8762/eureka/
registerWithEureka: false
preferSameZoneEureka: true
instance:
metadataMap:
zone: zone2
server:
port: ${PORT:8766}
---
spring:
profiles: zone3
eureka:
client:
serviceUrl:
defaultZone: http://localhost:8763/eureka/
registerWithEureka: false
preferSameZoneEureka: true
instance:
metadataMap:
zone: zone3
server:
port: ${PORT:8767}
在启动发现、客户端和网关应用程序的所有实例后,我们可以尝试调用在http://localhost:8765/api/client/ping、http://localhost:8766/api/client/ping和http://localhost:8767/api/client/ping地址下可用的端点。它们都将始终与注册在相同区域的客户端实例进行通信。因此,与没有首选区域的测试相比,例如,端口8765上可用的第一个网关实例始终打印出“我在 zone1 区域”并在调用 ping 端点时:

当客户端#1 不可用时会发生什么?因为它们都位于与网关#1 不同的区域,所以传入的请求将被负载均衡 50/50 分配到两个其他客户端应用程序实例。
总结
在本章中,我们有机会首次在本书中使用 Spring Cloud 开发应用程序。在我看来,开始微服务框架冒险的最佳方式是尝试弄清楚如何正确实现服务发现。从最简单的用例和示例开始,我们已经经历了 Netflix OSS Eureka 项目提供的先进且生产就绪的功能。我向您展示了如何在五分钟内创建和运行一个基本客户端和一个独立的发现服务器。基于该实现,我介绍了如何自定义 Eureka 客户端和服务器以满足我们的特定需求,重点放在网络或应用程序失败等负面场景上。诸如 REST API 或 UI 仪表板等特性已经详细讨论。最后,我向您展示了如何使用 Eureka 的机制(如复制、区域和高可用性)创建一个生产就绪环境。有了这些知识,您应该能够选择通过 Eureka 构建适合您微服务架构特性的服务发现功能。
一旦我们讨论了服务发现,我们就可以继续探讨微服务架构中的下一个关键元素:配置服务器。服务和配置服务通常都基于键/值存储,因此它们可能由相同的产品提供。然而,由于 Eureka 只专注于发现,Spring Cloud 引入了自己的框架来管理分布式配置,即 Spring Cloud Config。
第五章:使用 Spring Cloud Config 的分布式配置
现在是引入我们架构中的一个新的元素,一个分布式配置服务器的时候了。与服务发现一样,这是微服务周围的的关键概念之一。在上一章中,我们详细讨论了如何准备发现,包括服务器和客户端两侧。但到目前为止,我们总是通过在一个胖 JAR 文件内部放置属性来为应用程序提供配置。这种方法有一个很大的缺点,它需要重新编译和部署微服务的实例。Spring Boot 支持另一种方法,它假定使用一个存储在胖 JAR 外部文件系统中的显式配置。在应用程序启动时,可以通过spring.config.location属性轻松地为应用程序配置。这种方法不需要重新部署,但它也不是没有缺点。对于很多微服务,基于显式文件放置在文件系统上的配置管理可能真的非常麻烦。此外,让我们想象一下,每个微服务都有很多实例,并且每个实例都有特定的配置。好吧,用那种方法最好不要去想象。
总之,分布式配置在云原生环境中是一个非常流行的标准。Spring Cloud Config 为分布式系统中的外部化配置提供了服务器端和客户端支持。有了这个解决方案,我们有一个中心位置,可以管理跨所有环境的应用程序的外部属性。这个概念真的很简单,易于实现。服务器所做的不仅仅是暴露 HTTP 和基于资源的 API 接口,返回property文件以 JSON、YAML 或属性格式。此外,它还执行返回属性值的解密和加密操作。客户端需要从服务器获取配置设置,如果服务器端启用了此类功能,还需要对其进行解密。
配置数据可能存储在不同的仓库中。EnvironmentRepository的默认实现使用 Git 后端。也可以设置其他 VCS 系统,如 SVN。如果你不想利用 VCS 作为后端所提供的特性,你可以使用文件系统或 Vault。Vault 是一个管理秘密的工具,它存储并控制对令牌、密码、证书和 API 密钥等资源的访问。
本章我们将要覆盖的主题有:
-
由 Spring Cloud Config Server 暴露的 HTTP API
-
服务器端的不同的仓库后端类型
-
整合服务发现
-
使用 Spring Cloud Bus 和消息代理自动重新加载配置
HTTP API 资源介绍
配置服务器提供 HTTP API,可以通过多种方式调用。以下端点可用:
-
/{application}/{profile}[/{label}]: 这返回以 JSON 格式数据;标签参数是可选的 -
/{application}-{profile}.yml: 这返回 YAML 格式。 -
/{label}/{application}-{profile}.yml: 此为前一个端点的变种,其中我们可以传递一个可选的标签参数。 -
/{application}-{profile}.properties: 这返回属性文件使用的简单键/值格式。 -
/{label}/{application}-{profile}.properties: 此为前一个端点的变种,其中我们可以传递一个可选的标签参数。
从客户端的角度来看,应用程序参数是应用程序的名称,它来自于spring.application.name或spring.config.name属性,配置文件参数是活动配置文件或由逗号分隔的活动配置文件列表。最后一个可用的参数label是一个可选属性,仅在作为后端存储的 Git 中工作时才重要。它设置了配置的 Git 分支名称,默认为master。
原生配置文件支持
让我们从最简单的例子开始,该例子基于文件系统后端。默认情况下,Spring Cloud Config Server 尝试从 Git 仓库获取配置数据。要启用原生配置文件,我们应该使用spring.profiles.active选项将服务器启动设置为native。它会在以下位置搜索存储的文件,classpath:/、classpath:/config、file:./、file:./config。这意味着属性文件或 YAML 文件也可以放在 JAR 文件内部。为了测试目的,我在src/main/resources内部创建了一个 config 文件夹。我们的配置文件将存储在该位置。现在,我们需要回到前一章节的例子。正如您可能记得的,我介绍了集群发现环境的配置,每个客户端服务实例在不同的区域启动。有三个可用的区域和三个客户端实例,每个实例在其application.yml文件中都有自己的配置文件。该示例的源代码在config分支中可用。这是链接:github.com/piomin/sample-spring-cloud-netflix/tree/config。
github.com/piomin/sample-spring-cloud-netflix/tree/config
我们当前的任务是将该配置迁移到 Spring Cloud Config Server。让我们回顾一下该示例中设置的属性。以下是为客户端应用程序的第一个实例使用的配置文件设置。根据所选配置文件,有一个可变的实例运行端口、一个默认的发现服务器 URL 和一个区域名称:
---
spring:
profiles: zone1
eureka:
instance:
metadataMap:
zone: zone1
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka/
server:
port: ${PORT:8081}
在所描述的示例中,我把所有配置文件设置放在了一个单独的application.yml文件中,以简化问题。这个文件完全可以被分成三个不同的文件,文件名包含各自配置文件,如application-zone1.yml、application-zone2.yml和application-zone3.yml。当然,这样的名字对于单个应用来说是唯一的,所以如果我们决定将这些文件移动到远程配置服务器,我们需要注意它们的名称。客户端应用程序名称是从spring.application.name注入的,在这个例子中,它是client-service。所以,总结来说,我在src/main/resources/config目录下创建了三个名为client-service-zone[n].yml的配置文件,其中[n]是实例编号。现在,当你调用http://localhost:8888/client-service/zone1端点时,你将以 JSON 格式收到以下响应:
{
"name":"client-service",
"profiles":["zone1"],
"label":null,
"version":null,
"state":null,
"propertySources":[{
"name":"classpath:/config/client-service-zone1.yml",
"source":{
"eureka.instance.metadataMap.zone":"zone1",
"eureka.client.serviceUrl.defaultZone":"http://localhost:8761/eureka/",
"server.port":"${PORT:8081}"
}
}]
}
我们还可以调用http://localhost:8888/client-service-zone2.properties获取第二个实例,它将以下响应作为属性列表返回:
eureka.client.serviceUrl.defaultZone: http://localhost:8762/eureka/
eureka.instance.metadataMap.zone: zone2
server.port: 8082
最后一个可用的 HTTP API 端点,http://localhost:8889/client-service-zone3.yml,返回与输入文件相同的数据。这是第三个实例的结果:
eureka:
client:
serviceUrl:
defaultZone: http://localhost:8763/eureka/
instance:
metadataMap:
zone: zone3
server:
port: 8083
构建服务器端应用程序
我们首先讨论了由 Spring Cloud Config Server 提供的基于资源的 HTTP API 以及在该处创建和存储属性的方法。但现在让我们回到基础。与发现服务器一样,Config Server 也可以作为 Spring Boot 应用程序运行。要在服务器端启用它,我们应在pom.xml文件中包含spring-cloud-config-server在我们的依赖项中:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server</artifactId>
</dependency>
此外,我们应在主应用程序类上启用 Config Server。将服务器端口更改为8888是个好主意,因为它是客户端侧spring.cloud.config.uri属性的默认值。例如,客户端会自动配置。要更改服务器端口,你应该设置server.port属性为8888,或者使用spring.config.name=configserver属性启动它。spring-cloud-config-server库中有一个configserver.yml:
@SpringBootApplication
@EnableConfigServer
public class ConfigApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(ConfigApplication.class).web(true).run(args);
}
}
构建客户端应用程序
如果你把8888设置为服务器的默认端口,客户端的配置就非常简单了。你只需要提供bootstrap.yml文件,其中包含应用程序名称,并在你的pom.xml中包含以下依赖关系。当然,这个规则只适用于本地主机,因为客户端自动配置的 Config Server 地址是http://localhost:8888:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>
如果您为服务器设置了不同于8888的端口,或者它运行在与客户端应用程序不同的机器上,您还应该在bootstrap.yml中设置其当前地址。以下是引导上下文设置,它允许您从端口8889上运行的服务器获取client-service的属性。当使用--spring.profiles.active=zone1参数运行应用程序时,它将自动获取配置服务器中为zone1配置文件设置的属性:
spring:
application:
name: client-service
cloud:
config:
uri: http://localhost:8889
添加 Eureka 服务器
正如您可能已经注意到的,客户端属性中有一个发现服务网络位置的地址。所以,在启动客户端服务之前,我们应该有一个 Eureka 服务器在运行。当然,Eureka 也有自己的配置,它已经被存储在前一章节的application.yml文件中。那个配置,类似于client-service,被分成了三个配置文件,每个文件在诸如服务器 HTTP 端口号和要通信的发现对等体列表等属性上与其他文件不同。
现在,我们将这些property文件放在配置服务器上。Eureka 在启动时获取分配给所选配置文件的所有的设置。文件命名与已经描述的标准一致,即discovery-service-zone[n].yml。在运行 Eureka 服务器之前,我们应该在依赖项中包括spring-cloud-starter-config以启用 Spring Cloud Config 客户端,并用以下所示的bootstrap.yml替换application.yml:
spring:
application:
name: discovery-service
cloud:
config:
uri: http://localhost:8889
现在,我们可以通过在--spring.profiles.active属性中设置不同的配置文件名称,以对等通信模式运行三个 Eureka 服务器实例。在启动三个client-service实例之后,我们的架构如下所示。与前一章节的示例相比,客户端和服务发现服务都从 Spring Cloud Config 服务器获取配置,而不是将其保存在胖 JAR 内的 YML 文件中:

客户端引导方法
在前面的示例解决方案中,所有应用程序必须持有配置服务器的网络位置。服务发现的位置作为属性存储在那里。在此时,我们面临一个有趣的问题进行讨论。我们可以问一下我们的微服务是否应该知道 Config Server 的网络地址。在之前的讨论中,我们已经同意所有服务的网络位置的主要位置应该是服务发现服务器。配置服务器也是像其他微服务一样的 Spring Boot 应用程序,所以从逻辑上讲,它应该向 Eureka 注册自己,以使其他必须从 Spring Cloud Config Server 获取数据的服务能够使用自动发现机制。这反过来又要求将服务发现连接设置放在bootstrap.yml中,而不是spring.cloud.config.uri属性。
在设计系统架构时需要做出的决定之一就是在这两种不同的方法之间进行选择。并不是说一种解决方案比另一种更好。对于使用spring-cloud-config-client工件的任何应用程序,其默认行为在 Spring Cloud 命名法中称为Config First Bootstrap。当配置客户端启动时,它会绑定到服务器并使用远程属性源初始化上下文。这种方法在本章的第一个示例中已经介绍过。在第二种解决方案中,Config Server 向服务发现注册,所有应用程序可以使用DiscoveryClient来定位它。这种方法称为Discovery First Bootstrap。让我们实现一个示例来阐述这个概念。
配置服务器发现
要访问 GitHub 上的这个示例,你需要切换到config_with_discovery分支。这是链接:
github.com/piomin/sample-spring-cloud-netflix/tree/config_with_discovery。
第一次更改与sample-service-discovery模块有关。在那里我们不需要spring-cloud-starter-config依赖。简单的配置不再从远程属性源获取,而是设置在bootstrap.yml中。与之前的示例相比,为了简化练习,我们启动一个单一的独立 Eureka 实例:
spring:
application:
name: discovery-service
server:
port: ${PORT:8761}
eureka:
client:
registerWithEureka: false
fetchRegistry: false
相比之下,我们应该为 Config Server 包含spring-cloud-starter-eureka依赖。现在,依赖关系的完整列表如下所示。此外,必须通过在主类上声明@EnableDiscoveryClient注解来启用发现客户端,并且通过在application.yml文件中将eureka.client.serviceUrl.defaultZone属性设置为http://localhost:8761/eureka/来提供 Eureka Server 地址:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-eureka</artifactId>
</dependency>
在客户端应用程序方面,不再需要持有配置服务器的地址。只需要设置服务 ID,以防它与 Config Server 不同。根据本例中服务命名惯例,该 ID 是config-server。它应该使用spring.cloud.config.discovery.serviceId属性覆盖。为了允许发现机制启用发现机制从配置服务器获取远程属性源,我们应该设置spring.cloud.config.discovery.enabled=true:
spring:
application:
name: client-service
cloud:
config:
discovery:
enabled: true
serviceId: config-server
下面是带有 Config Server 的一个实例和三个client-service实例注册的 Eureka 仪表板屏幕。客户端的 Spring Boot 应用程序的每个实例都与之前的示例相同,并使用--spring.profiles.active=zone[n]参数启动,其中n是区域编号。唯一不同的是,Spring Cloud Config Server 提供的所有客户端服务配置文件都有与 Eureka Server 相同的连接地址:

仓库后端类型
本章中前面的所有示例都使用了文件系统后端,这意味着配置文件是从本地文件系统或类路径中加载的。这种后端对于教程目的或测试来说非常不错。如果你想在生产环境中使用 Spring Cloud Config,考虑其他选项是值得的。这些选项中的第一个是基于 Git 的仓库后端,它也是默认启用的。它不是唯一一个可以用作配置源仓库的版本控制系统(VCS)。另一个选项是 SVN,或者我们可以决定创建一个复合环境,这可能包括 Git 和 SVN 仓库。下一个支持的后端类型是基于 HashiCorp 提供的工具 Vault。当管理诸如密码或证书的安全属性时,它特别有用。让我们更详细地看看这里列出的每个解决方案。
文件系统后端
我不会写太多关于这个主题的内容,因为已经在之前的示例中讨论过了。它们都展示了如何将属性源存储在类路径中。还有从磁盘加载它们的能力。默认情况下,Spring Cloud Config Server 尝试在应用程序的工作目录或此位置的 config 子目录内定位文件。我们可以使用spring.cloud.config.server.native.searchLocations属性来覆盖默认位置。搜索位置路径可能包含application、profile和label的占位符。如果在位置路径中不使用任何占位符,仓库会自动将标签参数作为后缀添加。
因此,配置文件从每个搜索位置和与标签同名的子目录中加载。例如,file:/home/example/config与file:/home/example/config,file:/home/example/config/{label}相同。可以通过将spring.cloud.config.server.native.addLabelLocations设置为false来禁用这种行为。
如我前面所提到的,文件系统后端不是生产部署的好选择。如果你将属性源放在 JAR 文件内的类路径中,每次更改都需要重新编译应用程序。另一方面,在 JAR 之外使用文件系统不需要重新编译,但如果你有多个实例的配置服务在高级可用性模式下工作,这种方法可能会有麻烦。在这种情况下,将文件系统跨所有实例共享或将每个运行实例的属性源副本保留。Git 后端免除了这些缺点,这就是为什么它推荐用于生产环境的原因。
Git 后端
Git 版本控制系统有一些功能使其作为属性源的仓库非常有用。它允许你轻松地管理和审计更改。通过使用众所周知的版本控制机制,如提交、回滚和分支,我们可以比文件系统方法更容易地执行重要的操作。这种后端还有另外两个关键优势。它强制将配置服务器源代码和property文件仓库分开。如果你再次查看之前的示例,你会发现property文件与应用程序源代码一起存储。也许有些人会说,即使我们使用文件系统后端,也可以将整个配置作为单独的项目存储在 Git 中,并在需要时上传到远程服务器上。当然,你的观点是正确的。但是,当使用与 Spring Cloud Config 结合的 Git 后端时,你可以直接获得这些机制。此外,它还解决了与运行服务器多个实例相关的问题。如果你使用远程 Git 服务器,更改可能很容易在所有运行实例之间共享。
不同的协议
要为应用程序设置 Git 仓库的位置,我们应该在application.yml中使用spring.cloud.config.server.git.uri属性。如果你熟悉 Git,你就会知道克隆可以通过文件、http/https 和 ssh 协议来实现。本地仓库访问允许你快速开始,而不需要远程服务器。它使用文件、前缀进行配置,例如,spring.cloud.config.server.git.uri=file:/home/git/config-repo。当在高级可用性模式下运行 Config Server 时,你应该使用远程协议 SSH 或 HTTPS。在这种情况下,Spring Cloud Config 克隆远程仓库,然后基于本地工作副本作为缓存。
在 URI 中使用占位符
这里支持所有最近列出的占位符,application、profile和label。我们可以使用占位符为每个应用程序创建一个单一仓库,如https://github.com/piomin/{application},甚至可以为每个配置文件创建,https://github.com/piomin/{profile}。这种后端实现将 HTTP 资源的 label 参数映射到 Git 标签,可能指的是提交 ID、分支或标签名。显然,发现对我们感兴趣的功能的最合适方式是通过一个示例。让我们先通过创建一个用于存储应用程序属性源的 Git 仓库开始。
构建服务器应用程序
我创建了一个示例配置仓库,您可以在 GitHub 上在此处找到它:
请参阅github.com/piomin/sample-spring-cloud-config-repo.git。
我将本章中使用的所有属性源放在了这里,这些示例展示了客户端应用程序在不同发现区域对本地配置文件的支持。现在,我们的仓库包含了此列表中可见的文件:

默认情况下,Spring Cloud Config Server 在第一次 HTTP 资源调用后尝试克隆一个仓库。如果你想在启动后强制克隆,你应该将cloneOnStart属性设置为true。此外,还需要设置仓库连接设置和账户认证凭据:
spring:
application:
name: config-server
cloud:
config:
server:
git:
uri: https://github.com/piomin/sample-spring-cloud-config-repo.git
username: ${github.username}
password: ${github.password}
cloneOnStart: true
在服务器运行后,我们可以调用之前练习中已知端点,例如http://localhost:8889/client-service/zone1或http://localhost:8889/client-service-zone2.yml。结果与早期测试相同;唯一不同的是数据源。现在,让我们进行另一个练习。正如您可能记得的,当我们首先使用native配置文件启用发现引导时,我们必须稍微更改客户端的属性。因为现在我们使用的是 Git 后端,我们可以为这种情况开发一个更聪明的解决方案。在当前方法中,我们将在 GitHub 上的配置仓库中创建discovery分支(github.com/piomin/sample-spring-cloud-config-repo/tree/discovery),并将专为应用程序演示发现首先引导机制的文件放置在此分支上。如果您用label参数设置为discovery调用 Config Server 端点,您将获取我们新分支的数据。尝试调用http://localhost:8889/client-service/zone1/discovery和/或http://localhost:8889/discovery/client-service-zone2.yml并检查结果.
让我们考虑另一种情况。我更改了 client-service 第三实例的服务器端口,但出于某种原因,我想恢复到以前的价值。我必须更改并提交 client-service-zone3.yml 以使用以前的端口值吗?不用,我只需要在调用 HTTP API 资源时传递 label 参数即可。下面截图展示了所执行的更改:

如果我用父提交 ID 调用 API 端点而不是分支名,那么会返回较旧的端口号作为响应。以下是调用 http://localhost:8889/e546dd6/client-service-zone3.yml 的结果,其中 e546dd6 是之前的提交 ID:
eureka:
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka/
instance:
metadataMap:
zone: zone3
server:
port: 8083
客户端配置
在用 Git 后端构建服务器端时,我仅向您展示了 HTTP 资源调用的例子。以下是客户端应用程序的配置示例。我们不仅可以设置 bootstrap.yml 中的 profile 属性,还可以在 spring.profiles.active 运行参数中传递它。这个配置使得客户端从 discovery 分支获取属性。我们还可以通过在 label 属性中设置某个提交 ID 来决定切换到某个提交 ID,正如我刚才已经提到的:
spring:
application:
name: client-service
cloud:
config:
uri: http://localhost:8889
profile: zone1
label: discovery
# label: e546dd6 // uncomment for rollback
多个仓库
有时,您可能需要为单个 Config Server 配置多个仓库。我可以想象到您需要将业务配置从典型的技术配置中分离出来的情况。这是完全可能的:
spring:
cloud:
config:
server:
git:
uri: https://github.com/piomin/spring-cloud-config-repo/config-repo
repos:
simple: https://github.com/simple/config-repo
special:
pattern: special*/dev*,*special*/dev*
uri: https://github.com/special/config-repo
local:
pattern: local*
uri: file:/home/config/config-repo
Vault 后端
我已经提到了 Vault 作为一个通过统一接口安全访问密钥的工具。为了使 Config Server 使用这种类型的后端,您必须使用 Vault 配置文件 --spring.profiles.active=vault 运行它。当然,在运行 Config Server 之前,您需要安装并启动 Vault 实例。我建议您使用 Docker 来做这件事。我知道这是本书中第一次接触 Docker,并不是每个人都熟悉这个工具。我在第十四章,Docker 支持中提供了 Docker 的简要介绍,包括其基本命令和用例。所以,如果您是第一次接触这项技术,请先查看这个介绍。对于那些熟悉 Docker 的同学,这里是在开发模式下运行 Vault 容器的命令。我们可以使用 VAULT_DEV_LISTEN_ADDRESS 参数或初始生成的根令牌 ID 覆盖默认监听地址:
docker run --cap-add=IPC_LOCK -d --name=vault -e 'VAULT_DEV_ROOT_TOKEN_ID=client' -p 8200:8200 vault
开始使用 Vault
Vault 提供了一个命令行界面,可用于向服务器添加新值和从服务器读取它们。下面展示了调用这些命令的示例。然而,我们已经以 Docker 容器的形式运行了 Vault,所以最方便管理密钥的方式是通过 HTTP API:
$ vault write secret/hello value=world
$ vault read secret/hello
Vault 在我们实例中的 HTTP API 可以通过http://192.168.99.100:8200/v1/secret地址进行访问。调用该 API 的每一个方法时,你需要传递一个令牌作为请求头X-Vault-Token。因为我们启动 Docker 容器时在VAULT_DEV_ROOT_TOKEN_ID环境变量中设置了这个值,所以它等于client。否则,在启动过程中会自动生成,并且可以通过调用命令docker logs vault从日志中读取。实际上,要与 Vault 一起工作,我们需要了解两种 HTTP 方法——POST和GET。调用POST方法时,我们可以定义应该添加到服务器的密钥列表。这里所示的curl命令中的参数是使用 kv 后端创建的,它像一个键/值存储器:
$ curl -H "X-Vault-Token: client" -H "Content-Type: application/json" -X POST -d '{"server.port":8081,"sample.string.property": "Client App","sample.int.property": 1}' http://192.168.99.100:8200/v1/secret/client-service
新添加的值可以通过使用GET方法从服务器读取:
$ curl -H "X-Vault-Token: client" -X GET http://192.168.99.100:8200/v1/secret/client-service
与 Spring Cloud Config 集成
如我之前提到的,我们必须使用--spring.profiles.active=vault参数运行 Spring Cloud Config Server,以启用 Vault 作为后端存储。为了覆盖默认的自动配置设置,我们应该在spring.cloud.config.server.vault.*键下定义属性。我们示例应用程序的当前配置如下所示。一个示例应用程序可以在 GitHub 上找到;你需要切换到config_vault分支(github.com/piomin/sample-spring-cloud-netflix/tree/config_vault)来访问它:
spring:
application:
name: config-server
cloud:
config:
server:
vault:
host: 192.168.99.100
port: 8200
现在,你可以调用 Config Server 暴露的端点。你必须在上传请求头中传递令牌,但这次它的名称是X-Config-Token:
$ curl -X "GET" "http://localhost:8889/client-service/default" -H "X-Config-Token: client"
响应应该与下面显示的相同。这些属性是客户端应用程序所有配置文件的全局默认值。你也可以通过在 Vault HTTP API方法中调用带有逗号字符的选定配置文件名称来为选定的配置文件添加特定设置,如下所示,http://192.168.99.100:8200/v1/secret/client-service,zone1。如果调用路径中包含了这样的配置文件名称,响应中会返回default和zone1配置文件的所有属性:
{
"name":"client-service",
"profiles":["default"],
"label":null,
"version":null,
"state":null,
"propertySources":[{
"name":"vault:client-service",
"source":{
"sample.int.property":1,
"sample.string.property":"Client App",
"server.port":8081
}
}]
}
客户端配置
当使用 Vault 作为 Config Server 的后端时,客户端需要传递一个令牌,以便服务器能够从 Vault 检索值。这个令牌应该在客户端配置设置中提供,在bootstrap.yml文件中的spring.cloud.config.token属性:
spring:
application:
name: client-service
cloud:
config:
uri: http://localhost:8889
token: client
额外特性
让我们来看看 Spring Cloud Config 的一些其他有用特性。
启动失败并重试
有时如果 Config Server 不可用,启动应用程序就没有任何意义。在这种情况下,我们希望能够用异常停止客户端。为了实现这一点,我们必须将引导配置属性spring.cloud.config.failFast设置为true。这种激进的解决方案并不总是期望的行为。如果 Config Server 只是偶尔不可达,更好的方法是在成功之前一直尝试重新连接。spring.cloud.config.failFast属性仍然必须等于true,但我们还需要在应用程序类路径中添加spring-retry库和spring-boot-starter-aop。默认行为假设重试六次,初始退避间隔为 1000 毫秒。您可以使用spring.cloud.config.retry.*配置属性覆盖这些设置。
保护客户端
与服务发现一样,我们可以通过基本认证来保护 Config Server。使用 Spring Security 可以在服务器端轻松启用。在这种情况下,客户端只需要在bootstrap.yml文件中设置用户名和密码:
spring:
cloud:
config:
uri: https://localhost:8889
username: user
password: secret
自动重新加载配置
我们已经讨论了 Spring Cloud Config 最重要的特性。在那一刻,我们实现了示例,说明如何使用不同的后端存储作为存储库。但是,无论我们决定选择文件系统、Git 还是 Vault,我们的客户端应用程序都需要重新启动,才能从服务器获取最新的配置。然而,有时这并不是一个最优的解决方案,尤其是如果我们有许多微服务在运行,其中一些使用相同的通用配置。
解决方案架构
即使我们为每个单一的应用程序创建了一个专用的property文件,动态地重新加载它而不重新启动的机会也非常有帮助。正如您可能已经推断出的那样,这样的解决方案对 Spring Boot 和因此对 Spring Cloud 都是可用的。在第四章,服务发现中,在介绍从服务发现服务器注销时,我引入了一个端点/shutdown,可以用于优雅地关闭。还有一个用于 Spring 上下文重启的端点,其工作方式与关闭相似。
客户端端的端点只是需要包含以使 Spring Cloud Config 支持推送通知的更大系统中的一个组件。最受欢迎的源代码仓库提供商,如 GitHub、GitLab 和 Bitbucket,通过提供 WebHook 机制,能够发送有关仓库中变化的通知。我们可以通过提供商的网页控制台,以 URL 和选择的事件类型列表来配置 WebHook。这样的提供商将通过调用 WebHook 中定义的POST方法,发送包含提交列表的正文。在 Config Server 端启用监控端点需要在项目中包含 Spring Cloud Bus 依赖。当由于 WebHook 的激活而调用此端点时,Config Server 会准备并发送一个事件,其中包含由最后提交修改的属性源列表。该事件被发送到消息代理。Spring Cloud Bus 为 RabbitMQ 和 Apache Kafka 提供了实现。第一个可以通过包含spring-cloud-starter-bus-amqp依赖项启用于项目,第二个可以通过包含spring-cloud-starter-bus-kafka依赖项启用于项目。这些依赖项还应该在客户端应用程序中声明,以使能够从消息代理接收消息。我们还可以通过在选择的配置类上使用@RefreshScope注解来启用客户端端的动态刷新机制。该解决方案的架构示例如下:

使用@RefreshScope 刷新配置
这次我们将从客户端端开始,这很不寻常。示例应用程序可以在 GitHub 上找到(github.com/piomin/sample-spring-cloud-config-bus.git)。与之前的示例一样,它使用 Git 仓库作为后端存储,该仓库也是在大 GitHub 上创建的(github.com/piomin/sample-spring-cloud-config-repo)。我在客户端的配置文件中添加了一些新属性,并将更改提交到仓库。以下是客户端当前配置的版本:
eureka:
instance:
metadataMap:
zone: zone1
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka/
server:
port: ${PORT:8081}
management:
security:
enabled: false
sample:
string:
property: Client App
int:
property: 1
通过将management.security.enabled设置为false,我禁用了 Spring Boot Actuator 端点的 Security。这样我们就可以调用这些端点,而无需传递安全凭据。我还添加了两个测试参数,sample.string.property和sample.int.property,以演示基于它们值的重试机制在示例中。Spring Cloud 为 Spring Boot Actuator 提供了一些额外的 HTTP 管理端点。其中之一是/refresh,它负责重新加载引导上下文和刷新注解为@RefreshScope的 bean。这是一个 HTTP POST方法,可以在http://localhost:8081/refresh的客户端实例上调用。在测试该功能之前,我们需要使发现和 Config Servers 运行。客户端应用程序应该使用--spring.profiles.active=zone1参数启动。下面是测试属性sample.string.property和sample.int.property被注入到字段中的类:
@Component
@RefreshScope
public class ClientConfiguration {
@Value("${sample.string.property}")
private String sampleStringProperty;
@Value("${sample.int.property}")
private int sampleIntProperty;
public String showProperties() {
return String.format("Hello from %s %d", sampleStringProperty, sampleIntProperty);
}
}
这个 bean 被注入到ClientController类中,并在ping方法中调用,该方法在http://localhost:8081/ping上暴露:
@RestController
public class ClientController {
@Autowired
private ClientConfiguration conf;
@GetMapping("/ping")
public String ping() {
return conf.showProperties();
}
}
现在,让我们更改client-service-zone1.yml中的测试属性值并提交它们。如果你调用 Config Server HTTP 端点/client-service/zone1,你将看到最新的值作为响应返回。但是,当你调用客户端应用程序上暴露的/ping方法时,你仍然会看到以下屏幕左侧显示的较旧值。为什么?尽管 Config Server 可以自动检测仓库更改,但客户端应用程序没有触发器是无法自动刷新的。它需要重启以读取最新的设置,或者我们可以通过调用前面描述的/refresh方法强制重新加载配置:

在客户端应用程序上调用/refresh端点后,你将在日志文件中看到配置已重新加载。现在,如果你再调用一次/ping,最新的属性值将返回在响应中。这个例子说明了 Spring Cloud 应用程序的热重载是如何工作的,但它显然不是我们的目标解决方案。下一步是启用与消息代理的通信:

从消息代理中消费事件
我已经提到,我们可以选择两种与 Spring Cloud Bus 集成的消息代理。在这个例子中,我将向你展示如何运行和使用 RabbitMQ。让我简单说一下这个解决方案,因为这是我们书中第一次接触到它。RabbitMQ 已经成为最受欢迎的消息代理软件。它用 Erlang 编写,实现了高级消息队列协议 (AMQP)。即使我们谈论的是如集群或高可用性这样的机制,它也易于使用和配置。
在您的机器上运行 RabbitMQ 最方便的方式是通过一个 Docker 容器。有两个端口已经暴露在容器外。第一个用于客户端连接(5672)第二个专用于管理仪表板(15672)。我还用管理标签运行了镜像以启用 UI 仪表板,这在默认版本中是不可用的:
docker run -d --name rabbit -p 5672:5672 -p 15672:15672 rabbitmq:management
为了支持我们的示例客户端应用程序的 RabbitMQ 代理,我们应该在pom.xml中包含以下依赖项:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bus-amqp</artifactId>
</dependency>
那个库包含了自动配置设置。因为我是在 Windows 上运行 Docker,所以我需要覆盖一些默认属性。完整的服务配置存储在一个 Git 仓库中,所以更改只影响远程文件。我们应该在之前使用的客户端属性源中添加以下参数:
spring:
rabbitmq:
host: 192.168.99.100
port: 5672
username: guest
password: guest
如果你运行客户端应用程序,RabbitMQ 会自动创建一个交换区和一个队列。你可以通过登录到位于http://192.168.99.100:15672的管理仪表板轻松查看这一点。默认的用户名和密码是guest/guest。以下是来自我 RabbitMQ 实例的屏幕截图。有一个名为SpringCloudBus的交换区被创建,与客户端队列和 Config Server 队列有两个绑定(我已经运行了下一节描述的更改)。在这个阶段,我不想深入了解 RabbitMQ 及其架构的细节。这样的讨论的好地方将是 Spring Cloud Stream 项目的第十一章,消息驱动的微服务:

监控 Config Server 上的仓库更改
Spring Cloud Config Server 在前面描述的过程中必须执行两项任务。首先,它必须检测存储在 Git 仓库中的property文件的变化。这可能通过暴露一个特殊的端点来实现,该端点将通过 WebHook 由仓库提供商调用。第二步是准备并向可能已更改的应用程序发送一个RefreshRemoteApplicationEvent。这需要我们建立与消息代理的连接。spring-cloud-config-monitor库负责启用/monitor端点。为了支持 RabbitMQ 代理,我们应该包含与客户端应用程序相同的启动工件:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-monitor</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bus-amqp</artifactId>
</dependency>
不仅如此。配置监视器还应在application.yml中激活。因为每个仓库提供商在 Spring Cloud 中都有专门的实现,所以有必要选择哪个应该被启用:
spring:
application:
name: config-server
cloud:
config:
server:
monitor:
github:
enabled: true
更改检测机制可以自定义。默认情况下,它检测与应用程序名称匹配的文件中的更改。要覆盖此行为,你需要提供一个自定义的PropertyPathNotificationExtractor实现。它接受请求头和正文参数,并返回一个已更改的文件路径列表。为了支持来自 GitHub 的通知,我们可以使用spring-cloud-config-monitor提供的GithubPropertyPathNotificationExtractor:
@Bean
public GithubPropertyPathNotificationExtractor githubPropertyPathNotificationExtractor() {
return new GithubPropertyPathNotificationExtractor();
}
手动模拟更改事件
监控端点可以通过配置在 Git 仓库提供商(如 GitHub、Bitbucket 或 GitLab)上的 WebHook 来调用。在本地主机上运行的应用程序测试这种功能是麻烦的。结果是我们可以通过手动调用POST /monitor来轻松模拟这种 WebHook 的激活。例如,Github命令应该在请求中包含X-Github-Event头。带有property文件中更改的 JSON 体应该如下所示:
$ curl -H "X-Github-Event: push" -H "Content-Type: application/json" -X POST -d '{"commits": [{"modified": ["client-service-zone1.yml"]}]}' http://localhost:8889/monitor
现在,让我们更改并提交client-service-zone1.yml文件中的一个属性值,例如sample.int.property。然后,我们可以使用前一个示例命令中显示的参数调用POST /monitor方法。如果你根据我的描述配置了所有内容,你应该在客户端应用程序侧看到以下日志行Received remote refresh request. Keys refreshed [sample.int.property]。如果你调用客户端微服务暴露的/ping端点,它应该返回更改属性的最新值。
使用 GitLab 实例在本地测试
对于那些不喜欢模拟事件的人来说,我提出一个更实用的练习。然而,我要指出这不仅需要你的开发技能,还需要对持续集成工具的基本了解。我们将从使用 GitLab 的 Docker 镜像在本地运行一个 GitLab 实例开始。GitLab 是一个开源的基于 Web 的 Git 仓库管理器,具有 wiki 和问题跟踪功能。它与 GitHub 或 Bitbucket 等工具非常相似,但可以轻松部署在你的本地机器上:
docker run -d --name gitlab -p 10443:443 -p 10080:80 -p 10022:22 gitlab/gitlab-ce:latest
网页仪表板可在http://192.168.99.100:10080访问。第一步是创建一个管理员用户,然后使用提供的凭据登录。我不会详细介绍 GitLab。它有一个用户友好且直观的图形界面,所以我确信您不需要花费太多努力就能掌握它。无论如何,继续前进,我在 GitLab 中创建了一个名为sample-spring-cloud-config-repo的项目。它可以从http://192.168.99.100:10080/root/sample-spring-cloud-config-repo.git克隆。我在那里提交了与 GitHub 上我们的示例存储库中相同的配置文件集。下一步是定义一个 WebHook,当有推送通知时调用 Config Server 的/monitor端点。要为项目添加新的 WebHook,您需要前往设置 | 集成部分,然后将 URL 字段填写为服务器地址(使用您的 hostname 而不是 localhost 代替)。保留推送事件复选框的选择:

与使用 GitHub 作为后端存储提供商的 Config Server 实现相比,我们需要在application.yml中更改启用的监控类型,当然也要提供不同的地址:
spring:
application:
name: config-server
cloud:
config:
server:
monitor:
gitlab:
enabled: true
git:
uri: http://192.168.99.100:10080/root/sample-spring-cloud-config-repo.git
username: root
password: root123
cloneOnStart: true
我们还应该注册另一个实现PropertyPathNotificationExtractor的 bean:
@Bean
public GitlabPropertyPathNotificationExtractor gitlabPropertyPathNotificationExtractor() {
return new GitlabPropertyPathNotificationExtractor();
}
最后,您可能需要在配置文件中做一些更改并推送它们。WebHook 应该被激活,客户端应用程序的配置应该被刷新。这是本章的最后一个例子;我们可以继续到结论。
摘要
在本章中,我描述了 Spring Cloud Config 项目的最重要特性。与服务发现一样,我们从基础开始,讨论了客户端和服务器端的简单用例。我们探讨了 Config Server 的不同后端存储类型。我实现了示例,说明了如何使用文件系统、Git,甚至第三方工具如 Vault 作为property文件的存储库。我特别关注与其他组件的互操作性,如服务发现或大型系统中的多个微服务实例。最后,我向您展示了如何基于 WebHooks 和消息代理无需重新启动应用程序来重新加载配置。总之,阅读本章后,您应该能够将 Spring Cloud Config 作为微服务架构的一个组成部分使用,并利用其主要特性。
在讨论了使用 Spring Cloud 的服务发现和配置服务器实现之后,我们可以继续研究服务间的通信。在接下来的两章中,我们将分析一些基本和更高级的示例,这些示例说明了几个微服务之间的同步通信。
第六章:微服务间的通信
在过去的两章中,我们讨论了与微服务架构中非常重要的元素相关的细节——服务发现和配置服务器。然而,值得记住的是,它们存在于系统中的主要原因只是为了帮助管理整个独立、独立的应用程序集合。这种管理的一个方面是微服务间的通信。在这里,服务发现扮演着特别重要的角色,它负责存储和提供所有可用应用程序的网络位置。当然,我们可以想象我们的系统架构没有服务发现服务器。本章也将呈现这样一个示例。
然而,参与服务间通信最重要的组件是 HTTP 客户端和客户端负载均衡器。在本章中,我们将只关注它们。
本章我们将覆盖的主题包括:
-
使用 Spring
RestTemplate进行带服务发现和不带服务发现的微服务间通信 -
自定义 Ribbon 客户端
-
描述 Feign 客户端提供的 main 特性,如与 Ribbon 客户端的集成、服务发现、继承和区域支持
不同的通信风格
我们可以识别出微服务间通信的不同风格。可以将它们分为两个维度进行分类。第一个维度是同步通信和异步通信协议的区分。异步通信的关键点是,客户端在等待响应时不应该阻塞线程。对于这种类型的通信,最流行的协议是 AMQP,我们在上一章的末尾已经有了使用该协议的示例。然而,服务间通信的主要方式仍然是同步 HTTP 协议。我们本章只讨论这个。
第二个维度是基于是否有单一的消息接收者或多个接收者来进行不同的通信类型区分。在一对一的通信中,每个请求都由一个确切的服务实例处理。在一对多的通信中,每个请求可能被多个不同的服务处理。这将在第十一章 消息驱动的微服务 中讨论。
使用 Spring Cloud 进行同步通信
Spring Cloud 提供了一系列组件,帮助你实现微服务之间的通信。第一个组件是 RestTemplate,它总是用于客户端消费 RESTful web 服务。它包含在 Spring Web 项目中。为了在一个微服务环境中有效地使用它,它应该用 @LoadBalanced 注解标记。得益于这一点,它会自动配置为使用 Netflix Ribbon,并能够利用服务发现,通过使用服务名称而不是 IP 地址。Ribbon 是客户端负载均衡器,它提供了一个简单的接口,允许控制 HTTP 和 TCP 客户端的行为。它可以轻松地与其他 Spring Cloud 组件集成,如服务发现或断路器,而且对开发者完全透明。下一个可用的组件是 Feign,来自 Netflix OSS 堆栈的声明式 REST 客户端。Feign 已经使用 Ribbon 进行负载均衡和从服务发现获取数据。它可以通过在接口方法上使用 @FeignClient 注解轻松声明。在本章中,我们将详细查看这里列出的所有组件。
使用 Ribbon 进行负载均衡
围绕 Ribbon 的主要概念是一个命名的 客户端。这就是为什么我们可以使用服务名称而不是带有主机名和端口的全地址来调用其他服务,而无需连接到服务发现。在这种情况下,地址列表应该在 application.yml 文件内的 Ribbon 配置设置中提供。
使用 Ribbon 客户端启用微服务之间的通信
让我们来看一个例子。这个例子包含四个独立的微服务。其中一些可能会调用其他微服务暴露的端点。应用程序源代码可以在以下链接找到:
链接:github.com/piomin/sample-spring-cloud-comm.git。
在这个例子中,我们将尝试开发一个简单的订单系统,顾客可以购买产品。如果顾客决定确认购买的选定产品列表,POST请求将被发送到order-service。它由 REST 控制器内的Order prepare(@RequestBody Order order) {...}方法处理,该方法负责订单准备。首先,它通过调用customer-service中的适当 API 方法计算最终价格,考虑列表中每个产品的价格、顾客订单历史以及他们在系统中的类别。然后,它通过调用账户服务验证顾客的账户余额是否足够执行订单,并最终返回计算出的价格。如果顾客确认该操作,将调用PUT /{id}方法。请求由 REST 控制器内的Order accept(@PathVariable Long id) {...}方法处理。它更改订单状态并从顾客账户中提取资金。系统架构如以下所示分解为单独的微服务:

静态负载均衡配置
我们的order-service必须与示例中的所有其他微服务通信以执行所需操作。因此,我们需要定义三个不同的 Ribbon 客户端,并使用ribbon.listOfServers属性设置网络地址。示例中的第二件重要的事情是禁用默认启用的 Eureka 发现服务。以下是order-service在其application.yml文件中定义的所有属性:
server:
port: 8090
account-service:
ribbon:
eureka:
enabled: false
listOfServers: localhost:8091
customer-service:
ribbon:
eureka:
enabled: false
listOfServers: localhost:8092
product-service:
ribbon:
eureka:
enabled: false
listOfServers: localhost:8093
为了与 Ribbon 客户端一起使用RestTemplate,我们应该在项目中包含以下依赖关系:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-ribbon</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
然后,我们应该通过声明在application.yml中配置的名称列表来启用 Ribbon 客户端。为了实现这一点,您可以注解主类或任何其他 Spring 配置类为@RibbonClients。您还应该注册RestTemplatebean,并将其注解为@LoadBalanced,以启用与 Spring Cloud 组件的交互:
@SpringBootApplication
@RibbonClients({
@RibbonClient(name = "account-service"),
@RibbonClient(name = "customer-service"),
@RibbonClient(name = "product-service")
})
public class OrderApplication {
@LoadBalanced
@Bean
RestTemplate restTemplate() {
return new RestTemplate();
}
public static void main(String[] args) {
new SpringApplicationBuilder(OrderApplication.class).web(true).run(args);
}
// ...
}
调用其他服务
最后,我们可以开始实现负责提供微服务外暴露的 HTTP 方法的OrderController。它注入了RestTemplatebean,以便能够调用其他 HTTP 端点。您可以在以下代码片段中看到使用了在application.yml中配置的 Ribbon 客户端名称,而不是 IP 地址或主机名。使用相同的RestTemplatebean,我们可以与三个不同的微服务进行通信。让我们在这里讨论一下控制器中可用的方法。在实现的方法中,我们调用product-service的GET端点,它返回所选产品的详细信息列表。然后,我们调用customer-service暴露的GET /withAccounts/{id}方法。它返回带有其账户列表的客户详细信息。
现在,我们已经有了计算最终订单价格和验证客户在他们主账户中是否有足够资金所需的所有信息。PUT方法调用account-service的端点从客户账户中提取资金。我花了很多时间讨论了OrderController中可用的方法。然而,我认为这是必要的,因为同一个示例将用于展示提供微服务间同步通信机制的 Spring Cloud 组件的主要特性:
@RestController
public class OrderController {
@Autowired
OrderRepository repository;
@Autowired
RestTemplate template;
@PostMapping
public Order prepare(@RequestBody Order order) {
int price = 0;
Product[] products = template.postForObject("http://product-service/ids", order.getProductIds(), Product[].class);
Customer customer = template.getForObject("http://customer-service/withAccounts/{id}", Customer.class, order.getCustomerId());
for (Product product : products)
price += product.getPrice();
final int priceDiscounted = priceDiscount(price, customer);
Optional<Account> account = customer.getAccounts().stream().filter(a -> (a.getBalance() > priceDiscounted)).findFirst();
if (account.isPresent()) {
order.setAccountId(account.get().getId());
order.setStatus(OrderStatus.ACCEPTED);
order.setPrice(priceDiscounted);
} else {
order.setStatus(OrderStatus.REJECTED);
}
return repository.add(order);
}
@PutMapping("/{id}")
public Order accept(@PathVariable Long id) {
final Order order = repository.findById(id);
template.put("http://account-service/withdraw/{id}/{amount}", null, order.getAccountId(), order.getPrice());
order.setStatus(OrderStatus.DONE);
repository.update(order);
return order;
}
// ...
}
有趣的是,customer-service中的GET /withAccounts/{id}方法,它被order-service调用,也使用 Ribbon 客户端与另一个微服务account-service进行通信。以下是CustomerController中实现上述方法的片段:
@GetMapping("/withAccounts/{id}")
public Customer findByIdWithAccounts(@PathVariable("id") Long id) {
Account[] accounts = template.getForObject("http://account-service/customer/{customerId}", Account[].class, id);
Customer c = repository.findById(id);
c.setAccounts(Arrays.stream(accounts).collect(Collectors.toList()));
return c;
}
首先,使用 Maven 命令mvn clean install构建整个项目。然后,您可以使用没有任何额外参数的java -jar命令以任何顺序启动所有微服务。可选地,您还可以从您的 IDE 中运行应用程序。每个微服务在启动时都会准备测试数据。没有持久化存储,所以重启后所有对象都会被清除。我们可以通过调用order-service暴露的POST方法来测试整个系统。以下是一个示例请求:
$ curl -d '{"productIds": [1,5],"customerId": 1,"status": "NEW"}' -H "Content-Type: application/json" -X POST http://localhost:8090
如果您尝试发送这个请求,您将能够看到 Ribbon 客户端打印出以下日志:
DynamicServerListLoadBalancer for client customer-service initialized: DynamicServerListLoadBalancer:{NFLoadBalancer:name=customer-service,current list of Servers=[localhost:8092],Load balancer stats=Zone stats: {unknown=[Zone:unknown; Instance count:1; Active connections count: 0; Circuit breaker tripped count: 0; Active connections per server: 0.0;]
},Server stats: [[Server:localhost:8092; Zone:UNKNOWN; Total Requests:0; Successive connection failure:0; Total blackout seconds:0; Last connection made:Thu Jan 01 01:00:00 CET 1970; First connection made: Thu Jan 01 01:00:00 CET 1970; Active Connections:0; total failure count in last (1000) msecs:0; average resp time:0.0; 90 percentile resp time:0.0; 95 percentile resp time:0.0; min resp time:0.0; max resp time:0.0; stddev resp time:0.0]
]}ServerList:com.netflix.loadbalancer.ConfigurationBasedServerList@7f1e23f6
本节描述的方法有一个很大的缺点,这使得它在由几个微服务组成的系统中不太可用。如果您有自动扩展,问题会更严重。很容易看出,所有服务的网络地址都必须手动管理。当然,我们可以将配置设置从每个胖 JAR 内的application.yml文件移动到配置服务器。然而,这并没有改变管理大量交互仍然会麻烦的事实。这种问题可以通过客户端负载均衡和服务发现之间的互动轻易解决。
使用与服务发现一起的 RestTemplate
实际上,与服务发现集成是 Ribbon 客户端的默认行为。正如您可能记得的,我们通过将ribbon.eureka.enabled属性设置为false来禁用客户端负载均衡的 Eureka。服务发现的存在简化了 Spring Cloud 组件在服务间通信时的配置,本节的示例就是如此。
构建示例应用程序
系统架构与之前的示例相同。要查看当前练习的源代码,你必须切换到ribbon_with_discovery分支 (github.com/piomin/shown here-spring-cloud-comm/tree/ribbon_with_discovery).在那里,你首先看到的是一个新模块,discovery-service。我们在第四章,服务发现中详细讨论了与 Eureka 几乎所有相关方面,所以你应该不会有任何问题启动它。我们运行一个带有非常基本设置的单一独立 Eureka 服务器。它可在默认端口8761上访问。
与之前示例相比,我们应该删除所有严格与 Ribbon 客户端相关的配置和注解。取而代之的是,必须使用@EnableDiscoveryClient启用 Eureka 发现客户端,并在application.yml文件中提供 Eureka 服务器地址。现在,order-service的主类看起来像这样:
@SpringBootApplication
@EnableDiscoveryClient
public class OrderApplication {
@LoadBalanced
@Bean
RestTemplate restTemplate() {
return new RestTemplate();
}
public static void main(String[] args) {
new SpringApplicationBuilder(OrderApplication.class).web(true).run(args);
}
// ...
}
这是当前的配置文件。我用spring.application.name属性设置了服务的名称:
spring:
application:
name: order-service
server:
port: ${PORT:8090}
eureka:
client:
serviceUrl:
defaultZone: ${EUREKA_URL:http://localhost:8761/eureka/}
这就是之前的内容;我们同样启动所有的微服务。但是,这次account-service和product-service将各增加两个实例。启动每个服务的第二个实例时,默认的服务器端口可以通过-DPORT或-Dserver.port参数来覆盖,例如,java -jar -DPORT=9093 product-service-1.0-SNAPSHOT.jar。所有实例都已注册到 Eureka 服务器中。这可以通过其 UI 仪表板轻松查看:

这是本书中第一次看到负载均衡的实际例子。默认情况下,Ribbon 客户端将流量平均分配到微服务的所有注册实例。这种算法叫做轮询。实际上,这意味着客户端记得它上一次将请求转发到哪里,然后将当前请求发送到队列中的下一个服务。这种方法可以被我接下来详细介绍的其他规则覆盖。负载均衡也可以为前面没有服务发现的例子进行配置,通过在ribbon.listOfServers中设置一个以逗号分隔的服务地址列表,例如,ribbon.listOfServers=localhost:8093,localhost:9093。回到例子应用程序,order-service发送的请求将在account-service和product-service的两个实例之间进行负载均衡。这与上面截图中显示的customer-service类似,后者将在两个account-service实例之间分配流量。如果你启动了上一截图中 Eureka 仪表板上可见的所有服务实例,并向order-service发送一些测试请求,你肯定会看到我贴出的以下日志。我突出了 Ribbon 客户端显示目标服务找到的地址列表的片段:
DynamicServerListLoadBalancer for client account-service initialized: DynamicServerListLoadBalancer:{NFLoadBalancer:name=account-service,current list of Servers=[minkowp-l.p4.org:8091, minkowp-l.p4.org:9091],Load balancer stats=Zone stats: {defaultzone=[Zone:defaultzone; Instance count:2; Active connections count: 0; Circuit breaker tripped count: 0; Active connections per server: 0.0;]
},Server stats: [[Server:minkowp-l.p4.org:8091; Zone:defaultZone; Total Requests:0; Successive connection failure:0; Total blackout seconds:0; Last connection made:Thu Jan 01 01:00:00 CET 1970; First connection made: Thu Jan 01 01:00:00 CET 1970; Active Connections:0; total failure count in last (1000) msecs:0; average resp time:0.0; 90 percentile resp time:0.0; 95 percentile resp time:0.0; min resp time:0.0; max resp time:0.0; stddev resp time:0.0]
, [Server:minkowp-l.p4.org:9091; Zone:defaultZone; Total Requests:0; Successive connection failure:0; Total blackout seconds:0; Last connection made:Thu Jan 01 01:00:00 CET 1970; First connection made: Thu Jan 01 01:00:00 CET 1970; Active Connections:0; total failure count in last (1000) msecs:0; average resp time:0.0; 90 percentile resp time:0.0; 95 percentile resp time:0.0; min resp time:0.0; max resp time:0.0; stddev resp time:0.0]
]}ServerList:org.springframework.cloud.netflix.ribbon.eureka.DomainExtractingServerList@3e878e67
使用 Feign 客户端
RestTemplate是 Spring 的一个组件,特别适用于与 Spring Cloud 和微服务进行交互。然而,Netflix 开发了自己的工具,作为 web 服务客户端,提供给独立的 REST 服务之间开箱即用的通信。Feign 客户端,在其中,通常与RestTemplate的@LoadBalanced注解做相同的事情,但以更优雅的方式。它是一个通过处理注解将其转换为模板化请求的 Java 到 HTTP 客户端绑定器。当使用 Open Feign 客户端时,你只需要创建一个接口并注解它。它与 Ribbon 和 Eureka 集成,提供一个负载均衡的 HTTP 客户端,从服务发现中获取所有必要的网络地址。Spring Cloud 为 Spring MVC 注解添加支持,并使用与 Spring Web 相同的 HTTP 消息转换器。
支持不同区域
让我回退一下,回到上一个例子。我打算对我们的系统架构做些改动以使其稍微复杂一些。当前的架构在下面的图表中有可视化展示。微服务之间的通信模型仍然是相同的,但现在我们启动每个微服务的两个实例并将它们分为两个不同的区域。关于区域划分机制已经在第四章、服务发现中讨论过,在讨论使用 Eureka 进行服务发现时,所以我想你们已经很熟悉了。这次练习的主要目的不仅是展示如何使用 Feign 客户端,还有微服务实例间通信中区域划分机制是如何工作的。那么我们从基础知识开始:

启用 Feign 应用程序
为了在项目中包含 Feign,我们必须添加依赖spring-cloud-starter-feignartifact 或spring-cloud-starter-openfeign对于 Spring Cloud Netflix 的最小版本 1.4.0:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-feign</artifactId>
</dependency>
下一步是启用应用程序中的 Feign,通过用@EnableFeignClients注解主类或配置类来实现。这个注解会导致搜索应用程序中所有实现的客户端。我们也可以通过设置clients或basePackages注解属性来减少使用的客户端数量,例如,@EnableFeignClients(clients = {AccountClient.class, Product.class})。这是order-service应用程序的主类:
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class OrderApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(OrderApplication.class).web(true).run(args);
}
@Bean
OrderRepository repository() {
return new OrderRepository();
}
}
构建 Feign 接口
一种只需要创建带有某些注解的接口来提供组件的方法是 Spring Framework 的标准做法。对于 Feign,必须用@FeignClient(name = "...")注解接口。它有一个必需的属性名,如果启用了服务发现,则对应于被调用的微服务名称。否则,它与url属性一起使用,我们可以设置一个具体的网络地址。@FeignClient并不是这里需要使用的唯一注解。我们客户端接口中的每个方法都通过用@RequestMapping或更具体的注解如@GetMapping、@PostMapping或@PutMapping来标记,与特定的 HTTP API 端点相关联,正如这个例子源代码片段中所示:
@FeignClient(name = "account-service")
public interface AccountClient {
@PutMapping("/withdraw/{accountId}/{amount}")
Account withdraw(@PathVariable("accountId") Long id, @PathVariable("amount") int amount);
}
@FeignClient(name = "customer-service")
public interface CustomerClient {
@GetMapping("/withAccounts/{customerId}")
Customer findByIdWithAccounts(@PathVariable("customerId") Long customerId);
}
@FeignClient(name = "product-service")
public interface ProductClient {
@PostMapping("/ids")
List<Product> findByIds(List<Long> ids);
}
这样的组件可以被注入到控制器 bean 中,因为它们也是 Spring Beans。然后,我们只需调用它们的方法。以下是order-service中当前 REST 控制器的实现:
@Autowired
OrderRepository repository;
@Autowired
AccountClient accountClient;
@Autowired
CustomerClient customerClient;
@Autowired
ProductClient productClient;
@PostMapping
public Order prepare(@RequestBody Order order) {
int price = 0;
List<Product> products = productClient.findByIds(order.getProductIds());
Customer customer = customerClient.findByIdWithAccounts(order.getCustomerId());
for (Product product : products)
price += product.getPrice();
final int priceDiscounted = priceDiscount(price, customer);
Optional<Account> account = customer.getAccounts().stream().filter(a -> (a.getBalance() > priceDiscounted)).findFirst();
if (account.isPresent()) {
order.setAccountId(account.get().getId());
order.setStatus(OrderStatus.ACCEPTED);
order.setPrice(priceDiscounted);
} else {
order.setStatus(OrderStatus.REJECTED);
}
return repository.add(order);
}
启动微服务
我已经在application.yml中更改了所有微服务的配置。现在,有两个不同的配置文件,第一个用于将应用程序分配给zone1,第二个用于zone2。你可以从feign_with_discovery分支查看版本(github.com/piomin/shown here-spring-cloud-comm/tree/feign_with_discovery)。然后,使用mvn clean install命令构建整个项目。应用应该使用java -jar --spring.profiles.active=zone[n]命令启动,其中[n]是区域编号。因为你要启动很多实例来执行那个测试,考虑通过设置-Xmx参数限制堆大小是有价值的,例如,-Xmx128m。以下是其中一个微服务当前的配置设置:
spring:
application:
name: account-service
---
spring:
profiles: zone1
eureka:
instance:
metadataMap:
zone: zone1
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka/
preferSameZoneEureka: true
server:
port: ${PORT:8091}
---
spring:
profiles: zone2
eureka:
instance:
metadataMap:
zone: zone2
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka/
preferSameZoneEureka: true
server:
port: ${PORT:9091}
我们将每个区域启动每一个微服务的一个实例。所以,有九个正在运行的 Spring Boot 应用程序,包括服务发现服务器,如图所示:

如果你向在zone1运行的order-service实例(http://localhost:8090)发送测试请求,所有流量都将转发到该区域的其他服务,zone2(http://localhost:9090)也是如此。我突出了 Ribbon 客户端在该区域注册的目标服务找到的地址列表的片段:
DynamicServerListLoadBalancer for client product-service initialized: DynamicServerListLoadBalancer:{NFLoadBalancer:name=product-service,current list of Servers=[minkowp-l.p4.org:8093],Load balancer stats=Zone stats: {zone1=[Zone:zone1; Instance count:1; Active connections count: 0; Circuit breaker tripped count: 0; Active connections per server: 0.0;]...
继承支持
你可能已经注意到,控制器实现内部的注解和为该控制器服务的 REST 服务的 Feign 客户端实现是相同的。我们可以创建一个包含抽象 REST 方法定义的接口。这个接口可以被控制器类实现或者被 Feign 客户端接口扩展:
public interface AccountService {
@PostMapping
Account add(@RequestBody Account account);
@PutMapping
Account update(@RequestBody Account account);
@PutMapping("/withdraw/{id}/{amount}")
Account withdraw(@PathVariable("id") Long id, @PathVariable("amount") int amount);
@GetMapping("/{id}")
Account findById(@PathVariable("id") Long id);
@GetMapping("/customer/{customerId}")
List<Account> findByCustomerId(@PathVariable("customerId") Long customerId);
@PostMapping("/ids")
List<Account> find(@RequestBody List<Long> ids);
@DeleteMapping("/{id}")
void delete(@PathVariable("id") Long id);
}
现在,控制器类为基本接口提供了所有方法的实现,但并未包含任何 REST 映射注解,而只用了@RestController。以下是account-service控制器的片段:
@RestController
public class AccountController implements AccountService {
@Autowired
AccountRepository repository;
public Account add(@RequestBody Account account) {
return repository.add(account);
}
// ...
}
调用account-service的 Feign 客户端接口不提供任何方法。它只是扩展了基础接口,AccountService。要查看基于接口和 Feign 继承的全实现,切换到feign_with_inheritance分支:
github.com/piomin/shown here-spring-cloud-comm/tree/feign_with_inheritance
以下是一个带有继承支持的 Feign 客户端声明示例。它扩展了AccountService接口,因此处理了所有由@RestController暴露的方法:
@FeignClient(name = "account-service")
public interface AccountClient extends AccountService {
}
手动创建客户端
如果你不喜欢注解式的风格,你总是可以手动创建一个 Feign 客户端,使用 Feign Builder API。Feign 有多个可以自定义的功能,比如消息的编码器和解码器或 HTTP 客户端实现:
AccountClient accountClient = Feign.builder().client(new OkHttpClient())
.encoder(new JAXBEncoder())
.decoder(new JAXBDecoder())
.contract(new JAXRSContract())
.requestInterceptor(new BasicAuthRequestInterceptor("user", "password"))
.target(AccountClient.class, "http://account-service");
客户端定制
客户端定制不仅可以使用 Feign Builder API 完成,还可以通过使用注解风格来进行。我们可以通过设置@FeignClient的configuration属性来提供一个配置类:
@FeignClient(name = "account-service", configuration = AccountConfiguration.class)
以下是一个配置 bean 的示例:
@Configuration
public class AccountConfiguration {
@Bean
public Contract feignContract() {
return new JAXRSContract();
}
@Bean
public Encoder feignEncoder() {
return new JAXBEncoder();
}
@Bean
public Decoder feignDecoder() {
return new JAXBDecoder();
}
@Bean
public BasicAuthRequestInterceptor basicAuthRequestInterceptor() {
return new BasicAuthRequestInterceptor("user", "password");
}
}
Spring Cloud 支持以下属性通过声明 Spring Beans 来覆盖:
-
Decoder:默认是ResponseEntityDecoder。 -
Encoder:默认是SpringEncoder。 -
Logger:默认是Slf4jLogger。 -
Contract:默认是SpringMvcContract。 -
Feign.Builder:默认是HystrixFeign.Builder。 -
Client:如果启用了 Ribbon,则是LoadBalancerFeignClient;否则,使用默认的 Feign 客户端。 -
Logger.Level:它为 Feign 设置了默认日志级别。你可以选择NONE、BASIC、HEADERS和FULL之间的一种。 -
Retryer:它允许在通信失败时实现重试算法。 -
ErrorDecoder:它允许将 HTTP 状态码映射为特定于应用程序的异常。 -
Request.Options:它允许为请求设置读取和连接超时。 -
Collection<RequestInterceptor>:已注册的RequestInterceptor实现集合,根据从请求中获取的数据执行某些操作。
Feign 客户端也可以通过配置属性进行定制。通过在feign.client.config属性前缀后提供其名称,可以覆盖所有可用客户端的设置,或仅覆盖单个选定客户端的设置。如果我们设置名为default而不是特定客户端名称,它将应用于所有 Feign 客户端。当使用@EnableFeignClients注解及其defaultConfiguration属性时,也可以在appplication.yml文件中指定默认配置。提供的设置始终优先于@Configuration bean。如果想要改变这种方法,优先使用@Configuration而不是 YAML 文件,你应该将feign.client.default-to-properties属性设置为false。以下是一个为account-service设置连接超时、HTTP 连接的读取超时和日志级别的 Feign 客户端配置示例:
feign:
client:
config:
account-service:
connectTimeout: 5000
readTimeout: 5000
loggerLevel: basic
摘要
在本章中,我们已经启动了几个相互通信的微服务。我们讨论了诸如 REST 客户端的不同实现、多个实例之间的负载均衡以及与服务发现集成等主题。在我看来,这些方面是如此重要,以至于我决定用两章的篇幅来描述它们。本章应被视为微服务间通信主题的介绍,以及对微服务架构中其他重要组件集成的讨论。下一章将展示负载均衡器和 REST 客户端的高级使用,特别关注网络和通信问题。阅读完本章后,您应该能够在自己的应用程序中正确使用 Ribbon、Feign,甚至RestTemplate,并将它们连接到 Spring Cloud 的其他组件。
在大多数情况下,这些知识已经足够。然而,有时您可能需要自定义客户端负载均衡器配置,或者启用像断路器或回退这样的更高级的通信机制。理解这些解决方案及其对您系统中微服务间通信的影响是很重要的。我们将在下一章中讨论它们。
第七章:高级负载均衡和断路器
在本章中,我们将继续讨论前一章中讨论的主题,即服务间通信。我们将扩展到更高级的负载均衡、超时和断路示例。
Spring Cloud 提供了使微服务间通信简单而优雅的功能。然而,我们绝不能忘记,这样的通信所面临的的主要困难涉及所涉及系统的处理时间。如果您系统中有很多微服务,您需要处理的第一个问题之一是延迟问题。在本章中,我想讨论一些 Spring Cloud 功能,帮助我们避免由于服务间处理单个输入请求时的许多跃点、多个服务的缓慢响应或服务的暂时不可用而引起的延迟问题。处理部分失败有几种策略,包括设置网络超时、限制等待请求的数量、实现不同的负载均衡方法,或设置断路器模式和回退实现。
我们还将再次讨论 Ribbon 和 Feign 客户端,这次重点关注它们更高级的配置功能。在这里将介绍一个全新的库,即 Netflix Hystrix。这个库实现了断路器模式。
本章我们将覆盖以下主题:
-
使用 Ribbon 客户端的不同负载均衡算法
-
启用应用程序的断路器
-
使用配置属性自定义 Hystrix
-
使用 Hystrix 仪表板监控服务间通信
-
使用 Hystrix 和 Feign 客户端一起
负载均衡规则
Spring Cloud Netflix 提供了不同的负载均衡算法,以向用户提供不同的好处。您支持的方法选择取决于您的需求。在 Netflix OSS 命名法中,此算法称为规则。自定义规则类应实现IRule基础接口。以下实现默认情况下在 Spring Cloud 中可用:
-
RoundRobinRule:此规则简单地使用众所周知的轮询算法选择服务器,其中传入请求按顺序分配到所有实例。它通常用作默认规则或更高级规则的回退,例如ClientConfigEnabledRoundRobinRule和ZoneAvoidanceRule。ZoneAvoidanceRule是 Ribbon 客户端的默认规则。 -
AvailabilityFilteringRule:This rule will skip servers that are marked as circuit tripped or with a high number of concurrent connections. It also usesRoundRobinRuleas a base class. By default, an instance is circuit tripped if an HTTP client fails to establish a connection with it three times in a row. This approach may be customized with theniws.loadbalancer.<clientName>.connectionFailureCountThresholdproperty. Once an instance is circuit tripped, it will remain in this state for the next 30 seconds before the next retry. This property may also be overridden in the configuration settings. -
WeightedResponseTimeRule: with this implementation, a traffic volume forwarder to the instance is inversely proportional to the instance's average response time. In other words, the longer the response time, the less weight it will get. In these circumstances, a load balancing client will record the traffic and response time of every instance of the service. -
BestAvailableRule:According to the description from the class documentation, this rule skips servers with tripped circuit breakers and picks the server with the lowest concurrent requests.
跳闸断路器是一个来自电气工程的术语,指的是电路中没有电流流动。在 IT 术语中,它指的是发送给服务器的连续请求失败次数过多,因此客户端软件会立即中断对远程服务的进一步调用,以减轻服务器端应用程序的负担。
权重响应时间规则
直到现在,我们通常还通过从网页浏览器或 REST 客户端调用服务来手动测试服务。目前的更改不允许采用这种方法,因为我们需要为服务设置模拟延迟,以及生成许多 HTTP 请求。
介绍 Hoverfly 用于测试
在此阶段,我想介绍一个可能完美解决这类测试的有趣框架。我指的是 Hoverfly,一个轻量级的服务虚拟化工具,用于模拟或虚拟 HTTP 服务。它最初是用 Go 编写的,但还为您提供了用于管理 Hoverfly 的 Java 语言的丰富 API。由 SpectoLabs 维护的 Hoverfly Java 提供了用于抽象二进制和 API 调用、创建模拟的 DSL 以及与 JUnit 测试框架集成的类。我喜欢这个框架的一个功能。您可以通过在 DSL 定义中调用一个方法,轻松地为每个模拟服务添加延迟。为了使 Hoverfly 适用于您的项目,您必须在 Maven pom.xml中包含以下依赖项:
<dependency>
<groupId>io.specto</groupId>
<artifactId>hoverfly-java</artifactId>
<version>0.9.0</version>
<scope>test</scope>
</dependency>
测试规则
我们在这里讨论的样本可以在 GitHub 上找到。要访问它,你必须切换到weighted_lb分支(github.com/piomin/sample-spring-cloud-comm/tree/weighted_lb)。我们的 JUnit 测试类,名为CustomerControllerTest,位于src/test/Java目录下。为了在测试中启用 Hoverfly,我们应该定义 JUnit @ClassRule。HoverflyRule类提供了一个 API,允许我们模拟具有不同地址、特性和响应的许多服务。在下面的源代码片段中,你可以看到我们的示例微服务account-service的两个实例被声明在@ClassRule中。正如你可能记得的,那个服务已经被customer-service和order-service调用过。
让我们来看一下customer-service模块中的一个测试类。它模拟了GET /customer/*方法,并为account-service的两个实例(分别监听端口8091和9091)定义了一个预定义的响应。其中第一个实例延迟了200毫秒,而第二个实例延迟了50毫秒:
@ClassRule
public static HoverflyRule hoverflyRule = HoverflyRule
.inSimulationMode(dsl(
service("account-service:8091")
.andDelay(200, TimeUnit.MILLISECONDS).forAll()
.get(startsWith("/customer/"))
.willReturn(success("[{\"id\":\"1\",\"number\":\"1234567890\",\"balance\":5000}]", "application/json")),
service("account-service:9091")
.andDelay(50, TimeUnit.MILLISECONDS).forAll()
.get(startsWith("/customer/"))
.willReturn(success("[{\"id\":\"2\",\"number\":\"1234567891\",\"balance\":8000}]", "application/json"))))
.printSimulationData();
在运行测试之前,我们还应该修改ribbon.listOfServers配置文件,将其更改为listOfServers: account-service:8091, account-service:9091。我们只有在使用 Hoverfly 时才应该进行这样的修改。
这是一个调用customer-service暴露的GET /withAccounts/ {id}端点的test方法,调用次数为一千次。反过来,它调用了account-service的GET customer/{customerId}端点,带有客户拥有的账户列表。每个请求都使用WeightedResponseTimeRule在account-service的两个实例之间进行负载均衡:
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = WebEnvironment.DEFINED_PORT)
public class CustomerControllerTest {
private static Logger LOGGER = LoggerFactory.getLogger(CustomerControllerTest.class);
@Autowired
TestRestTemplate template;
// ...
@Test
public void testCustomerWithAccounts() {
for (int i = 0; i < 1000; i++) {
Customer c = template.getForObject("/withAccounts/{id}", Customer.class, 1);
LOGGER.info("Customer: {}", c);
}
}
}
使用加权响应规则实现的工作方法真的很有趣。就在开始测试后,传入的请求在account-service的两个实例之间以 50:50 的比例进行了负载均衡。但是,过了一段时间后,大部分请求都被转发到了延迟较小的实例。
最后,在我的本地机器上启动的 JUnit 测试中,端口9091上的实例处理了 731 个请求,端口8091上的实例处理了 269 个请求。然而,在测试结束时,比例看起来有点不同,并且倾向于延迟较小的实例,其中传入流量在两个实例之间以 4:1 的比例进行了加权。
现在,我们将稍微改变一下我们的测试用例,通过添加一个延迟大约 10 秒的account-service的第三个实例。这个改动旨在模拟 HTTP 通信中的超时。以下是 JUnit @ClassRule定义中的一个片段,最新的服务实例监听在端口10091上:
service("account-service:10091")
.andDelay(10000, TimeUnit.MILLISECONDS).forAll()
.get(startsWith("/customer/"))
.willReturn(success("[{\"id\":\"3\",\"number\":\"1234567892\",\"balance\":10000}]", "application/json"))
我们应该相应地在 Ribbon 配置中进行更改,以启用对account-service最新实例的负载均衡:
listOfServers: account-service:8091, account-service:9091, account-service:10091
最后一个需要更改的东西,但在之前的测试用例中保持不变,就是RestTemplatebean 的声明。在这个实例中,我将读取和连接超时都设置为 1 秒,因为测试中启动的account-service的第三个实例延迟了 10 秒。每发送一个请求都会在 1 秒后因超时而终止:
@LoadBalanced
@Bean
RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
return restTemplateBuilder
.setConnectTimeout(1000)
.setReadTimeout(1000)
.build();
}
如果您像以前那样运行相同的测试,结果将不令人满意。所有声明的实例之间的分布将是 420,由端口8091上的实例处理(延迟 200 毫秒),468,由端口9091上的实例处理(延迟 50 毫秒),而 112 发送到第三个实例,由超时终止。我为什么引用这些统计数据?我们可以将默认负载均衡规则从WeightedResponseTimeRule更改为AvailabilityFilteringRule,并重新运行测试。如果我们这样做,496 个请求将发送给第一个和第二个实例,而只有 8 个请求将发送给第三个实例,有一个 1 秒的超时。有趣的是,如果您将BestAvailableRule设置为默认规则,所有请求都将发送到第一个实例。
现在您阅读了此示例,可以轻松地看到 Ribbon 客户端所有可用负载均衡规则之间的区别。
自定义 Ribbon 客户端
Ribbon 客户端的几个配置设置可以通过 Spring bean 声明来覆盖。与 Feign 一样,它应该在名为 configuration 的客户端注解字段中声明,例如,@RibbonClient(name = "account-service", configuration = RibbonConfiguration.class)。使用这种方法可以自定义以下功能:
-
IClientConfig:此接口的默认实现是DefaultClientConfigImpl。 -
IRule:此组件用于从列表中确定应选择哪个服务实例。ZoneAvoidanceRule实现类是自动配置的。 -
IPing:这是一个在后台运行的组件。它负责确保服务实例正在运行。 -
ServerList<Server>:这可以是静态的或动态的。如果是动态的(如DynamicServerListLoadBalancer所使用),后台线程将在预定义的间隔刷新和过滤列表。默认情况下,Ribbon 使用从配置文件中获取的服务器静态列表。它由ConfigurationBasedServerList实现。 -
ServerListFilter<Server>:ServerListFilter是DynamicServerListLoadBalancer用来过滤ServerList实现返回的服务器的组件。该接口有两个实现——自动配置的ZonePreferenceServerListFilter和ServerListSubsetFilter。 -
ILoadBalancer:此组件负责在客户端侧对服务的可用实例进行负载均衡。默认情况下,Ribbon 使用ZoneAwareLoadBalancer。 -
ServerListUpdater:它负责更新给定应用程序可用的实例列表。默认情况下,Ribbon 使用PollingServerListUpdater。
让我们来看一个定义 IRule 和 IPing 组件默认实现的配置类示例。这样的配置可以定义为单个 Ribbon 客户端,也可以定义为应用程序类路径中可用的所有 Ribbon 客户端,通过提供 @RibbonClients(defaultConfiguration = RibbonConfiguration.class) 注解来实现:
@Configuration
public class RibbonConfiguration {
@Bean
public IRule ribbonRule() {
return new WeightedResponseTimeRule();
}
@Bean
public IPing ribbonPing() {
return new PingUrl();
}
}
即使你没有 Spring 的经验,你可能也已经猜到(根据之前的示例),配置也可以通过使用 properties 文件进行自定义。在这种情况下,Spring Cloud Netflix 与 Netflix 提供的 Ribbon 文档中描述的属性兼容。以下类是支持的属性,它们应该以 <clientName>.ribbon 开头,或者如果它们适用于所有客户端,以 ribbon 开头:
-
NFLoadBalancerClassName:ILoadBalancer默认实现类 -
NFLoadBalancerRuleClassName:IRule默认实现类 -
NFLoadBalancerPingClassName:IPing默认实现类 -
NIWSServerListClassName:ServerList默认实现类 -
NIWSServerListFilterClassName:ServerListFilter默认实现类
以下是一个与前面 @Configuration 类相似的示例,它覆盖了 Spring Cloud 应用程序使用的 IRule 和 IPing 默认实现:
account-service:
ribbon:
NFLoadBalancerPingClassName: com.netflix.loadbalancer.PingUrl
NFLoadBalancerRuleClassName: com.netflix.loadbalancer.WeightedResponseTimeRule
Hystrix 电路断路器模式
我们已经讨论了 Spring Cloud Netflix 中负载均衡算法的不同实现。其中一些是基于监控实例响应时间或失败次数。在这些情况下,负载均衡器根据这些统计数据来决定调用哪个实例。电路断路器模式应被视为该解决方案的扩展。电路断路器背后的主要想法非常简单。一个受保护的函数调用被包装在一个电路断路器对象中,该对象负责监控失败调用次数。如果失败次数达到阈值,电路将打开,所有后续调用都将自动失败。通常,如果电路断路器触发,也希望有一种监控警报。应用程序中使用电路断路器模式的一些关键好处是,当相关服务失败时能够继续运行,防止级联失败,并给失败的服务时间来恢复。
使用 Hystrix 构建应用程序
Netflix 在他们的库中提供了一个名为 Hystrix 的断路器模式的实现。这个库也被作为 Spring Cloud 的默认断路器实现。Hystrix 还有一些其他有趣的特性,也应该被视为一个用于处理分布式系统延迟和容错的综合工具。重要的是,如果打开断路器,Hystrix 将所有调用重定向到指定的回退方法。回退方法被设计为提供一个不依赖于网络的通用响应,通常从内存缓存中读取或简单实现为静态逻辑。如果需要执行网络调用,建议您使用另一个 HystrixCommand 或 HystrixObservableCommand 来实现。为了在您的项目中包含 Hystrix,您应该使用 spring-cloud-starter-netflix-hystrix 或 spring-cloud-starter-hystrix 作为 Spring Cloud Netflix 1.4.0 版本之前的启动器:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-hystrix</artifactId>
</dependency>
实现 Hystrix 的命令
Spring Cloud Netflix Hystrix 会寻找带有 @HystrixCommand 注解的方法,然后将其包装在连接到断路器的代理对象中。正因为如此,Hystrix 能够监控这类方法的所有的调用。这个注解目前只对标记有 @Component 或 @Service 的类有效。这对我们来说是很重要的信息,因为我们已经在带有 @RestController 注解的 REST 控制器类中实现了与其它服务调用相关的所有逻辑。所以,在 customer-service 应用程序中,所有那部分逻辑都被移动到了新创建的 CustomerService 类中,然后将其注入到控制器 bean 中。负责与 account-service 通信的方法已经被标记为 @HystrixCommand。我还实现了一个回退方法,其名称传递到 fallbackMethod 注解的字段中。这个方法只返回一个空列表:
@Service
public class CustomerService {
@Autowired
RestTemplate template;
@Autowired
CustomerRepository repository;
// ...
@HystrixCommand(fallbackMethod = "findCustomerAccountsFallback")
public List<Account> findCustomerAccounts(Long id) {
Account[] accounts = template.getForObject("http://account-service/customer/{customerId}", Account[].class, id);
return Arrays.stream(accounts).collect(Collectors.toList());
}
public List<Account> findCustomerAccountsFallback(Long id) {
return new ArrayList<>();
}
}
不要忘记用@EnableHystrix标记你的主类,这是告诉 Spring Cloud 应该为应用程序使用断路器所必需的。我们也可以选择性地用@EnableCircuitBreaker注解一个类,它也能起到同样的作用。为了测试目的,account-service.ribbon.listOfServers属性应该包含localhost:8091, localhost:9091服务两个实例的网络地址。虽然我们为 Ribbon 客户端声明了两个account-service实例,但我们将在8091端口上启动唯一可用的一个。如果你调用customer-service方法的GET http://localhost:8092/withAccounts/{id},Ribbon 将尝试将在两个声明的实例之间平衡每个传入请求,即,一旦你收到包含账户列表的响应,第二次收到空账户列表,或相反。以下应用日志的片段说明了这一点。以下是对应用日志的一个片段。要访问示例应用程序的源代码,你应该切换到与前章示例相同的 GitHub 仓库中的hystrix_basic分支:(https://github.com/piomin/sample-spring-cloud-comm/tree/hystrix_basic)
{"id":1,"name":"John Scott","type":"NEW","accounts":[]}
{"id":1,"name":"John Scott","type":"NEW","accounts":[{"id":1,"number":"1234567890","balance":5000},{"id":2,"number":"1234567891","balance":5000},{"id":3,"number":"1234567892","balance":0}]}
实现带有缓存数据的回退
前面示例中呈现的回退实现非常简单。对于在生产环境中运行的应用程序来说,返回一个空列表并没有多大意义。在请求失败时,例如从缓存中读取数据时,在应用程序中使用回退方法更有意义。这样的缓存可以在客户端应用程序内部实现,也可以使用第三方工具实现,如 Redis、Hazelcast 或 EhCache。最简单的实现是在 Spring 框架内部提供的,在将spring-boot-starter-cache artifact 包含在依赖项之后可以使用。要为 Spring Boot 应用程序启用缓存,你应该用@EnableCaching注解标注主类或配置类,并提供以下上下文中的CacheManager bean:
@SpringBootApplication
@RibbonClient(name = "account-service")
@EnableHystrix
@EnableCaching
public class CustomerApplication {
@LoadBalanced
@Bean
RestTemplate restTemplate() {
return new RestTemplate();
}
public static void main(String[] args) {
new SpringApplicationBuilder(CustomerApplication.class).web(true).run(args);
}
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("accounts");
}
// ...
}
然后,你可以使用@CachePut注解标记被电路 breaker 包裹的方法。这会将调用方法的返回结果添加到缓存映射中。在这种情况下,我们的映射名为accounts。最后,您可以在回退方法实现内部直接调用CacheManager bean 来读取数据。如果你多次重试同一个请求,你会看到空账户列表不再作为响应返回。相反,服务总是返回在第一次成功调用期间缓存的数据:
@Autowired
CacheManager cacheManager;
@CachePut("accounts")
@HystrixCommand(fallbackMethod = "findCustomerAccountsFallback")
public List<Account> findCustomerAccounts(Long id) {
Account[] accounts = template.getForObject("http://account-service/customer/{customerId}", Account[].class, id);
return Arrays.stream(accounts).collect(Collectors.toList());
}
public List<Account> findCustomerAccountsFallback(Long id) {
ValueWrapper w = cacheManager.getCache("accounts").get(id);
if (w != null) {
return (List<Account>) w.get();
} else {
return new ArrayList<>();
}
}
触发断路器
让我给你提个练习题。到目前为止,你已经学会了如何使用 Hystrix,结合 Spring Cloud,在应用程序中启用和实现断路器,以及如何使用回退方法从缓存中获取数据。但你还没有使用过触发断路器来防止负载均衡器调用失败实例。现在,我想配置 Hystrix,在失败率超过30%的情况下,在三次失败的调用尝试后打开电路,并在接下来的 5 秒钟内防止 API 方法被调用。测量时间窗口大约是10秒。为了满足这些要求,我们必须重写几个默认的 Hystrix 配置设置。这可以在@HystrixCommand内的@HystrixProperty注解中执行。
以下是customer-service中获取账户列表方法的当前实现:
@CachePut("accounts")
@HystrixCommand(fallbackMethod = "findCustomerAccountsFallback",
commandProperties = {
@HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "500"),
@HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "10"),
@HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "30"),
@HystrixProperty(name = "circuitBreaker.sleepWindowInMilliseconds", value = "5000"),
@HystrixProperty(name = "metrics.rollingStats.timeInMilliseconds", value = "10000")
}
)
public List<Account> findCustomerAccounts(Long id) {
Account[] accounts = template.getForObject("http://account-service/customer/{customerId}", Account[].class, id);
return Arrays.stream(accounts).collect(Collectors.toList());
}
关于 Hystrix 配置属性的完整列表,可以在 Netflix 的 GitHub 网站上找到,网址为github.com/Netflix/Hystrix/wiki/Configuration。我不会讨论所有属性,只讨论微服务间通信最重要的属性。以下是我们在示例中使用的属性列表及其描述:
-
execution.isolation.thread.timeoutInMilliseconds:此属性设置在发生读取或连接超时的时间(以毫秒为单位),之后客户端将离开命令执行。Hystrix 将此类方法调用标记为失败,并执行回退逻辑。可以通过将command.timeout.enabled属性设置为false来完全关闭超时。默认值为 1,000 毫秒。 -
circuitBreaker.requestVolumeThreshold:此属性设置在滚动窗口中触发电路的最小请求数量。默认值是 20。在我们的示例中,此属性设置为10,这意味着前九个请求不会触发电路,即使它们都失败了。我设置这个值是因为我们假设如果30%的传入请求失败,电路应该被打开,但最少传入请求数量是三个。 -
circuitBreaker.errorThresholdPercentage:此属性设置最小的错误百分比。超过此百分比将导致打开电路,系统开始短路请求以执行回退逻辑。默认值是 50。我将其设置为30,因为在我们示例中,我希望30%的失败请求应该打开电路。 -
circuitBreaker.sleepWindowInMilliseconds:此属性设置在触发电路和允许尝试以确定是否应再次关闭电路之间的时间间隔。在这段时间内,所有传入请求都被拒绝。默认值是5,000。因为我们希望电路打开后在10秒内等待第一次调用被退休,所以我将其设置为10,000。 -
metrics.rollingStats.timeInMilliseconds:这个属性设置了统计滚动窗口的持续时间,单位为毫秒。Hystrix 就是用这个时间来保持电路断路器使用的指标和发布用的。
使用这些设置,我们可以运行与之前例子相同的 JUnit 测试。我们使用HoverflyRule启动两个account-service的存根。其中的第一个会被延迟 200 毫秒,而第二个延迟 2000 毫秒的会超过@HystrixCommand中execution.isolation.thread.timeoutInMilliseconds属性的设置。运行 JUnitCustomerControllerTest后,查看打印的日志。我插入了我机器上运行的测试的日志。customer-service的第一个请求会被负载均衡到第一个延迟 200 毫秒的实例(1)。发送到9091端口可用的实例的每个请求,在一秒后都会超时完成。在发送 10 个请求后,第一个失败触发了电路的断开(2)。然后,在接下来的 10 秒内,每个请求都由回退方法处理,返回缓存数据(3)、(4)。10 秒后,客户端再次尝试调用account-service的实例并成功(5),因为它击中了延迟 200 毫秒的实例。这次成功导致电路关闭。不幸的是,account-service的第二个实例仍然响应缓慢,所以整个场景再次重演,直到 JUnit 测试结束(6)和(7)。这个详细的描述准确地展示了 Spring Cloud 中的 Hystrix 电路断路器是如何工作的:
16:54:04+01:00 Found response delay setting for this request host: {account-service:8091 200} // (1)
16:54:05+01:00 Found response delay setting for this request host: {account-service:9091 2000}
16:54:05+01:00 Found response delay setting for this request host: {account-service:8091 200}
16:54:06+01:00 Found response delay setting for this request host: {account-service:9091 2000}
16:54:06+01:00 Found response delay setting for this request host: {account-service:8091 200}
...
16:54:09+01:00 Found response delay setting for this request host: {account-service:9091 2000} // (2)
16:54:10.137 Customer [id=1, name=John Scott, type=NEW, accounts=[Account [id=1, number=1234567890, balance=5000]]] // (3)
...
16:54:20.169 Customer [id=1, name=John Scott, type=NEW, accounts=[Account [id=1, number=1234567890, balance=5000]]] // (4)
16:54:20+01:00 Found response delay setting for this request host: {account-service:8091 200} // (5)
16:54:20+01:00 Found response delay setting for this request host: {account-service:9091 2000}
16:54:21+01:00 Found response delay setting for this request host: {account-service:8091 200}
...
16:54:25+01:00 Found response delay setting for this request host: {account-service:8091 200} // (6)
16:54:26.157 Customer [id=1, name=John Scott, type=NEW, accounts=[Account [id=1, number=1234567890, balance=5000]]] // (7)
监控延迟和容错
如我前面所提到的,Hystrix 不仅仅是一个实现断路器模式的简单工具。它是一个解决方案,用于处理分布式系统中的延迟和容错。Hystrix 提供的一个有趣功能是可以暴露与服务间通信相关的最重要的指标,并通过 UI 仪表板显示出来。这个功能适用于用 Hystrix 命令包装的客户端。
在之前的某些示例中,我们分析了我们系统的一部分,以模拟customer-service和account-service之间的通信延迟。当测试高级负载均衡算法或不同的断路器配置设置时,这是一种非常好的方法,但现在我们将回到分析我们示例系统的整体设置,作为一个独立的 Spring Boot 应用程序集合。这使我们能够观察到 Spring Cloud 与 Netflix OSS 工具结合在一起,如何帮助我们监控和响应微服务之间的通信延迟问题和故障。示例系统以一种简单的方式模拟了一个故障。它有一个静态配置,包含了两个实例account-service和product-service的网络地址,但每个服务只运行一个实例。
为了使您记忆犹新,以下是我们样本系统的架构,考虑到关于失败的假设:

这次,我们将以一种稍微不同方式开始,进行一个测试。以下是正在循环调用测试方法的片段。首先,它调用来自order-service的POST http://localhost:8090/端点,发送一个Order对象,并收到具有id、status和price设置的响应。在该请求中,如前一个图中所标记的(1),order-service与product-service和customer-service通信,并且,除此之外,customer-service调用来自account-service的端点。如果订单被接受,测试客户端调用PUT http://localhost:8090/{id}方法,带有订单的id来接受它并从账户中提取资金。在服务器端,在那情况下只有一次服务间通信,如前一个图中所标记的(2)。在运行这个测试之前,你必须启动我们系统中的所有微服务:
Random r = new Random();
Order order = new Order();
order.setCustomerId((long) r.nextInt(3)+1);
order.setProductIds(Arrays.asList(new Long[] {(long) r.nextInt(10)+1,(long) r.nextInt(10)+1}));
order = template.postForObject("http://localhost:8090", order, Order.class); // (1)
if (order.getStatus() != OrderStatus.REJECTED) {
template.put("http://localhost:8090/{id}", null, order.getId()); // (2)
}
暴露 Hystrix 的指标流
每个使用 Hystrix 在与其他微服务通信中可能暴露每个封装在 Hystrix 命令中的集成指标的微服务。要启用这样的指标流,你应该包括对spring-boot-starter-actuator的依赖。这将把/hystrix.stream对象作为管理端点暴露出来。还需要包括spring-cloud-starter-hystrix,这已经添加到我们的示例应用程序中:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
生成的流作为进一步的 JSON 条目暴露,包含描述单一调用内方法的指标。以下是来自customer-service的GET /withAccounts/{id}方法的一个调用条目:
{"type":"HystrixCommand","name":"customer-service.findWithAccounts","group":"CustomerService","currentTime":1513089204882,"isCircuitBreakerOpen":false,"errorPercentage":0,"errorCount":0,"requestCount":74,"rollingCountBadRequests":0,"rollingCountCollapsedRequests":0,"rollingCountEmit":0,"rollingCountExceptionsThrown":0,"rollingCountFailure":0,"rollingCountFallbackEmit":0,"rollingCountFallbackFailure":0,"rollingCountFallbackMissing":0,"rollingCountFallbackRejection":0,"rollingCountFallbackSuccess":0,"rollingCountResponsesFromCache":0,"rollingCountSemaphoreRejected":0,"rollingCountShortCircuited":0,"rollingCountSuccess":75,"rollingCountThreadPoolRejected":0,"rollingCountTimeout":0,"currentConcurrentExecutionCount":0,"rollingMaxConcurrentExecutionCount":1,"latencyExecute_mean":5,"latencyExecute":{"0":0,"25":0,"50":0,"75":15,"90":16,"95":31,"99":47,"99.5":47,"100":62},"latencyTotal_mean":5,"latencyTotal":{"0":0,"25":0,"50":0,"75":15,"90":16,"95":31,"99":47,"99.5":47,"100":62},"propertyValue_circuitBreakerRequestVolumeThreshold":10,"propertyValue_circuitBreakerSleepWindowInMilliseconds":10000,"propertyValue_circuitBreakerErrorThresholdPercentage":30,"propertyValue_circuitBreakerForceOpen":false,"propertyValue_circuitBreakerForceClosed":false,"propertyValue_circuitBreakerEnabled":true,"propertyValue_executionIsolationStrategy":"THREAD","propertyValue_executionIsolationThreadTimeoutInMilliseconds":2000,"propertyValue_executionTimeoutInMilliseconds":2000,"propertyValue_executionIsolationThreadInterruptOnTimeout":true,"propertyValue_executionIsolationThreadPoolKeyOverride":null,"propertyValue_executionIsolationSemaphoreMaxConcurrentRequests":10,"propertyValue_fallbackIsolationSemaphoreMaxConcurrentRequests":10,"propertyValue_metricsRollingStatisticalWindowInMilliseconds":10000,"propertyValue_requestCacheEnabled":true,"propertyValue_requestLogEnabled":true,"reportingHosts":1,"threadPool":"CustomerService"}
Hystrix 仪表板
Hystrix 仪表板可视化了以下信息:
-
健康和流量体积以一个随着传入统计数据变化而改变颜色和大小的圆形显示
-
过去 10 秒内的错误百分比
-
过去两分钟内的请求速率,通过数字显示结果在图表上
-
断路器状态(开启/关闭)
-
服务主机数量
-
过去一分钟内的延迟百分比
-
服务的线程池
构建带有仪表板的应用程序
Hystrix 仪表板与 Spring Cloud 集成。在系统内实现仪表板的最佳方法是将仪表板分离为一个独立的 Spring Boot 应用程序。要将在项目中包含 Hystrix 仪表板,请使用spring-cloud-starter-hystrix-netflix-dashboard启动器或对于旧于 1.4.0 的 Spring Cloud Netflix 版本使用spring-cloud-starter-hystrix-dashboard:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-hystrix-dashboard</artifactId>
</dependency>
应用程序的主类应使用@EnableHystrixDashboard注解。启动后,Hystrix 仪表板在/hystrix上下文路径下可用:
@SpringBootApplication
@EnableHystrixDashboard
public class HystrixApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(HystrixApplication.class).web(true).run(args);
}
}
我在我们示例系统中的 Hystrix 应用程序中配置了端口9000作为默认端口,该应用程序在hystrix-dashboard模块中实现。所以,在启动hystrix-dashboard后,用网络浏览器调用http://localhost:9000/hystrix地址,它会显示如下截图中的页面。在那里,您应提供 Hystrix 流端点的地址,可选提供一个标题。如果您想要为从order-service调用所有端点显示指标,请输入地址http://localhost:8090/hystrix.stream,然后点击监控流按钮:

在仪表板上监控指标
在本节中,我们将查看从customer-service调用GET /withAccounts/{id}方法。它被包裹在@HystrixCommand中。它显示在 Hystrix 仪表板上,标题为customer-service.findWithAccounts,来自一个commandKey属性。此外,UI 仪表板还显示了分配给每个提供 Hystrix 命令封装方法实现的 Spring Bean 的线程池信息。在此案例中,它是CustomerService:
@Service
public class CustomerService {
// ...
@CachePut("customers")
@HystrixCommand(commandKey = "customer-service.findWithAccounts", fallbackMethod = "findCustomerWithAccountsFallback",
commandProperties = {
@HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "2000"),
@HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "10"),
@HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "30"),
@HystrixProperty(name = "circuitBreaker.sleepWindowInMilliseconds", value = "10000"),
@HystrixProperty(name = "metrics.rollingStats.timeInMilliseconds", value = "10000")
})
public Customer findCustomerWithAccounts(Long customerId) {
Customer customer = template.getForObject("http://customer-service/withAccounts/{id}", Customer.class, customerId);
return customer;
}
public Customer findCustomerWithAccountsFallback(Long customerId) {
ValueWrapper w = cacheManager.getCache("customers").get(customerId);
if (w != null) {
return (Customer) w.get();
} else {
return new Customer();
}
}
}
这是 Hystrix 仪表板在 JUnit 测试开始后的屏幕。我们监控了三个用@HystrixCommand包裹的方法的状态。product-service的findByIds方法的电路如预期般已被打开。几秒钟后,account-service的withdraw方法的电路也已打开:

片刻之后,情况将稳定下来。所有电路都保持关闭状态,因为只有少量的流量被发送到应用程序的不活动实例。这展示了 Spring Cloud 结合 Hystrix 和 Ribbon 的力量。系统能够自动重新配置自己,以便基于负载均衡器和断路器生成的指标,将大部分传入请求重定向到工作实例:

使用 Turbine 聚合 Hystrix 的流
您可能已经注意到,我们在 Hystrix 仪表板上只能查看服务的一个实例。当我们显示order-service命令的状态时,没有从customer-service和account-service之间的通信指标,反之亦然。我们可能还会想象order-service有不止一个实例在运行,这使得在 Hystrix 仪表板上定期切换不同的实例或服务变得必要。幸运的是,有一个名为Turbine的应用程序可以将所有相关的/hystrix.stream端点聚合到一个组合的/turbine.stream中,使我们能够监控整个系统的整体健康状况。
启用 Turbine
在为我们的应用程序启用 Turbine 之前,我们首先应该启用服务发现,这是在这里必需的。切换到hystrix_with_turbine分支,以访问支持通过 Eureka 进行服务发现并使用 Turbine 聚合 Hystrix 流的一个版本我们的示例系统。要为项目启用 UI 仪表板,只需在依赖项中包含spring-cloud-starter-turbine,并用@EnableTurbine注解主应用类:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-turbine</artifactId>
</dependency>
turbine.appConfig配置属性是 Turbine 将要查找实例的 Eureka 服务名称列表。然后,在http://localhost:9000/turbine.stream URL 下,Hystrix 仪表板中的 Turbine 流即可使用。地址也由turbine.aggregator.clusterConfig属性的值决定,http://localhost:9000/turbine.stream?cluster=<clusterName>。如果集群名称为default,则可以省略集群参数。以下 Turbine 配置将所有 Hystrix 的可视化指标整合到单个 UI 仪表板上:
turbine:
appConfig: order-service,customer-service
clusterNameExpression: "'default'"
现在,整个示例系统的所有 Hystrix 指标都可以在一个仪表板网站上显示出来。要显示它们,我们只需要监控位于http://localhost:9000/turbine.stream下的统计流:

另外,我们可以为每个服务配置一个集群,通过提供turbine.aggregator.clusterConfig属性的服务列表来实现。在这种情况下,您可以通过提供服务名称cluster以及http://localhost:9000/turbine.stream?cluster=ORDER-SERVICE参数,在集群之间进行切换。因为 Eureka 服务器返回的值是大写的,所以集群名称必须是大写的:
turbine:
aggregator:
clusterConfig: ORDER-SERVICE,CUSTOMER-SERVICE
appConfig: order-service,customer-service
默认情况下,Turbine 在其 Eureka 注册实例的homePageUrl地址下寻找/hystrix.stream端点。然后,它在该 URL 后附加/hystrix.stream。我们的示例应用order-service在端口8090上启动,因此我们应该也覆盖默认的管理端口为8090。下面是order-service的当前配置代码片段。另外,您还可以通过eureka.instance.metadata-map.management.port属性来更改端口:
spring:
application:
name: order-service
server:
port: ${PORT:8090}
eureka:
client:
serviceUrl:
defaultZone: ${EUREKA_URL:http://localhost:8761/eureka/}
management:
security:
enabled: false
port: 8090
启用 Turbine 流式处理
经典 Turbine 模型从所有分布式 Hystrix 命令中提取指标,并不总是一个好的选择。例如,收集 HTTP 端点的指标也可以通过消息代理异步实现。要使 Turbine 支持流式处理,我们应该在项目中包含以下依赖项,然后用@EnableTurbineStream注解主应用。下面的示例使用 RabbitMQ 作为默认消息代理,但您可以通过包含spring-cloud-starter-stream-kafka来使用 Apache Kafka:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-turbine-stream</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-stream-rabbit</artifactId>
</dependency>
前面代码中可见的依赖项应该包含在服务器端。对于客户端应用程序,这些是order-service和customer-service,我们还需要添加spring-cloud-netflix-hystrix-stream库。如果你在本地运行了消息代理,它应该在自动配置的设置上成功工作。你也可以使用 Docker 容器运行 RabbitMQ,正如我们在第五章中描述的 Spring Cloud Config 与 AMQP 总线一样,分布式配置与 Spring Cloud Config。然后,你应该在客户端和服务器端应用程序的application.yml文件中覆盖以下属性:
spring:
rabbitmq:
host: 192.168.99.100
port: 5672
username: guest
password: guest
如果你登录到 RabbitMQ 管理控制台,该控制台可通过http://192.168.99.100:15672访问,你会看到在我们的示例应用程序启动后创建了一个名为springCloudHystrixStream的新交换机。现在,剩下要做的就是运行与之前部分中描述的经典 Turbine 方法的示例相同的 JUnit 测试。所有指标都通过消息代理发送,并可以在http://localhost:9000端点下观察。如果你想要亲自尝试,请切换到hystrix_with_turbine_stream分支(更多信息请参见github.com/piomin/sample-spring-cloud-comm/tree/hystrix_with_turbine_stream)。
使用 Feign 的失败和断路器模式
默认情况下,Feign 客户端与 Ribbon 和 Hystrix 集成。这意味着,如果你愿意,你可以在使用该库时应用不同的方法来处理系统的延迟和超时。这些方法中的第一种是由 Ribbon 客户端提供的连接重试机制。第二种是在 Hystrix 项目中提供的断路器模式和回退实现,这在本书的上一节中已经讨论过了。
使用 Ribbon 重试连接
当使用 Feign 库时,应用程序默认启用 Hystrix。这意味着如果你不想使用它,你应该在配置设置中禁用它。为了测试带有 Ribbon 的重试机制,我建议你禁用 Hystrix。为了使 Feign 具有连接重试功能,你只需要设置两个配置属性—MaxAutoRetries和MaxAutoRetriesNextServer。在此情况下,重要的设置还包括ReadTimeout和ConnectTimeout。它们都可以在application.yml文件中覆盖。以下是 Ribbon 设置中最重要的一些:
-
MaxAutoRetries:这是在同一服务器或服务实例上进行重试的最大次数。第一次尝试不包括在内。 -
MaxAutoRetriesNextServer:这是要重试的最大下一个服务器或服务实例次数,不包括第一个服务器。 -
OkToRetryOnAllOperations:这表示此客户端的所有操作都可以重试。 -
ConnectTimeout:这是等待与服务器或服务实例建立连接的最大时间。 -
ReadTimeout:这是在建立连接后等待服务器响应的最大时间。
假设我们有一个目标服务的两个实例。第一个实例的连接已经建立,但它响应太慢并且发生了超时。根据MaxAutoRetries=1属性,客户端对该实例进行一次重试。如果仍然不成功,它尝试连接该服务的第二个可用实例。在失败的情况下,这一动作根据MaxAutoRetriesNextServer=2属性重复两次。如果描述的机制最终不成功,超时将被返回到外部客户端。在这种情况下,即使在四秒以上之后也可能会发生。请查看以下配置:
ribbon:
eureka:
enabled: true
MaxAutoRetries: 1
MaxAutoRetriesNextServer: 2
ConnectTimeout: 500
ReadTimeout: 1000
feign:
hystrix:
enabled: false
这个解决方案是为微服务环境实现的标准重试机制。我们还可以看看与 Ribbon 的超时和重试不同配置设置相关的其他场景。我们没有理由不使用这个机制与 Hystrix 的断路器一起。然而,我们必须记住ribbon.ReadTimeout应该小于 Hystrix 的execution.isolation.thread.timeoutInMilliseconds属性的值。
我建议您测试我们刚才描述的配置设置作为一个练习。您可以使用之前介绍的 Hoverfly JUnit 规则来模拟服务实例的延迟和存根。
Hystrix 对 Feign 的支持
首先,我想重申一下,当使用 Feign 库时,Hystrix 默认对应用程序是启用的,但只适用于 Spring Cloud 的旧版本。根据最新版本 Spring Cloud 的文档,我们应该将feign.hystrix.enabled属性设置为true,这强制 Feign 包装所有方法为一个断路器。
在 Spring Cloud Dalston 版本之前,如果 Hystrix 在类路径上,Feign 会默认包装所有方法为一个断路器。这一默认行为在 Spring Cloud Dalston 版本中为了采用可选参与方式而改变。
当使用 Hystrix 和 Feign 客户端一起时,提供之前用@HystrixProperty在@HystrixCommand内部设置的配置属性的最简单方法是通过application.yml文件。以下是之前示例的等效配置:
hystrix:
command:
default:
circuitBreaker:
requestVolumeThreshold: 10
errorThresholdPercentage: 30
sleepWindowInMilliseconds: 10000
execution:
isolation:
thread:
timeoutInMilliseconds: 1000
metrics:
rollingStats:
timeInMilliseconds: 10000
Feign 支持回退的表示。要为给定的@FeignClient启用回退,我们应该将fallback属性设置为提供回退实现的类名。实现类应该被定义为一个 Spring Bean:
@FeignClient(name = "customer-service", fallback = CustomerClientFallback.class)
public interface CustomerClient {
@CachePut("customers")
@GetMapping("/withAccounts/{customerId}")
Customer findByIdWithAccounts(@PathVariable("customerId") Long customerId);
}
回退实现基于缓存,并实现了带有@FeignClient注解的接口:
@Component
public class CustomerClientFallback implements CustomerClient {
@Autowired
CacheManager cacheManager;
@Override
public Customer findByIdWithAccountsFallback(Long customerId) {
ValueWrapper w = cacheManager.getCache("customers").get(customerId);
if (w != null) {
return (Customer) w.get();
} else {
return new Customer();
}
}
}
选择性地,我们可以实现一个FallbackFactory类。这种方法有一个很大的优点,它让你能够访问触发回退的原因。要为 Feign 声明一个FallbackFactory类,只需在@FeignClient内部使用fallbackFactory属性:
@FeignClient(name = "account-service", fallbackFactory = AccountClientFallbackFactory.class)
public interface AccountClient {
@CachePut
@GetMapping("/customer/{customerId}")
List<Account> findByCustomer(@PathVariable("customerId") Long customerId);
}
自定义的FallbackFactory类需要实现一个FallbackFactory接口,该接口声明了一个必须重写的T create(Throwable cause)方法:
@Component
public class AccountClientFallbackFactory implements FallbackFactory<AccountClient> {
@Autowired
CacheManager cacheManager;
@Override
public AccountClient create(Throwable cause) {
return new AccountClient() {
@Override
List<Account> findByCustomer(Long customerId) {
ValueWrapper w = cacheManager.getCache("accounts").get(customerId);
if (w != null) {
return (List<Account>) w.get();
} else {
return new Customer();
}
}
}
}
}
摘要
如果你已经使用自动配置的客户端进行服务间通信,你可能不知道本章中描述的配置设置或工具。然而,我认为即使它们可以在后台运行,甚至可以开箱即用,了解一些高级机制也是值得的。在本章中,我试图通过演示它们如何使用简单示例来让你更接近主题,如负载均衡器、重试、回退或断路器。阅读本章后,你应该能够根据需要在微服务之间的通信中自定义 Ribbon、Hystrix 或 Feign 客户端。你也应该理解在系统中使用它们的何时何地。通过本章,我们结束了关于微服务架构内部核心元素的讨论。现在,我们需要关注的是系统外部的一个重要组件,即网关。它将系统复杂性隐藏在外部客户端之外。
第八章:使用 API 网关进行路由和过滤
在本章中,我们将讨论微服务架构中的下一个重要元素——API 网关。在实践中,这并不是我们第一次遇到这个元素。我们已经在第四章,服务发现中实现了一个简单的网关模式,以展示如何在 Eureka 中使用分区机制进行服务发现。我们使用了 Netflix 的 Zuul 库,它是一个基于 JVM 的路由和服务器端负载均衡器。Netflix 设计 Zuul 以提供诸如认证、压力和金丝雀测试、动态路由以及活动/活动多区域流量管理等功能。虽然这没有明确说明,但它也在微服务架构中充当网关,并其主要任务是隐藏系统的外部客户端复杂性。
直到现在,Zuul 在 Spring Cloud 框架内部实现 API 网关模式时实际上并没有任何竞争。然而,随着一个名为 Spring Cloud Gateway 的新项目的不断发展,这种情况正在动态变化。它基于 Spring Framework 5、Project Reactor 和 Spring Boot 2.0。该库的最后稳定版本是 1.0.0,但目前正在开发的版本 2.0.0 中有很多关键变化,目前仍处于里程碑阶段。Spring Cloud Gateway 旨在提供一种简单、有效的方式来路由 API 并提供与它们相关的交叉关注点,例如安全性、监控/度量以及弹性。尽管这个解决方案相对较新,但它绝对值得关注。
本章我们将涉及的主题包括:
-
根据 URL 的静态路由和负载均衡
-
将 Zuul 与 Spring Cloud Gateway 集成并实现服务发现
-
使用 Zuul 创建自定义过滤器
-
使用 Zuul 自定义路由配置
-
在路由失败的情况下提供 Hystrix 回退
-
Spring Cloud Gateway 中包含的主要组件的描述——预测器和网关过滤器
使用 Spring Cloud Netflix Zuul
Spring Cloud 实现了一个内嵌的 Zuul 代理,以便前端应用程序能够代理调用后端服务。这个特性对于外部客户端来说非常有用,因为它隐藏了系统复杂性,并帮助避免为所有微服务独立管理 CORS 和认证问题。要启用它,你应该用 @EnableZuulProxy 注解标注一个 Spring Boot 主类,然后它将传入的请求转发到目标服务。当然,Zuul 与 Ribbon 负载均衡器、Hystrix 断路器以及服务发现集成,例如与 Eureka。
构建网关应用程序
让我们回到前一章节的示例,以添加微服务架构的最后一步,API 网关。我们还没有考虑的是外部客户端如何调用我们的服务。首先,我们不希望暴露系统内所有微服务的网络地址。我们还可以在单一位置执行一些操作,例如请求认证或设置跟踪头。解决方案是只共享一个边缘网络地址,该地址将所有传入请求代理到适当的服务。当前示例的系统架构在下图中说明:

为了满足我们当前示例的需求,让我回到前一章节中已经讨论过的项目。它可以在 GitHub 上找到(github.com/piomin/sample-spring-cloud-comm.git),在master分支中。现在,我们将向该项目添加一个名为gateway-service的新模块。第一步是使用 Maven 依赖项包含 Zuul:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-zuul</artifactId>
</dependency>
在 Spring Boot 主类上使用@EnableZuulProxy注解后,我们可以继续进行路由配置,该配置位于application.yml文件中。默认情况下,Zuul 启动器 artifact 不包含服务发现客户端。路由是使用url属性静态配置的,该属性设置为服务的网络地址。现在,如果您启动了所有微服务和网关应用程序,您可以尝试通过网关调用它们。每个服务都可以在为每个路由配置的path属性设置的路径下访问,例如,http://localhost:8080/account/1将被转发到http://localhost:8091/1:
server:
port: ${PORT:8080}
zuul:
routes:
account:
path: /account/**
url: http://localhost:8091
customer:
path: /customer/**
url: http://localhost:8092
order:
path: /order/**
url: http://localhost:8090
product:
path: /product/**
url: http://localhost:8093
与服务发现集成
前面示例中呈现的静态路由配置对于基于微服务的系统来说是不够的。API 网关的主要要求是与服务发现的内置集成。为了使 Zuul 与 Eureka 集成,我们必须在项目依赖项中包含spring-cloud-starter-eureka启动器,并通过注释应用程序的主类来启用客户端@EnableDiscoveryClient。实际上,让网关自己在发现服务器上注册是没有意义的,它只能获取当前注册的服务列表。因此,我们将通过将eureka.client.registerWithEureka属性设置为false来禁用该注册。application.yml文件中的路由定义非常简单。每个路由的名称映射到 Eureka 中的应用程序服务名称:
zuul:
routes:
account-service:
path: /account/**
customer-service:
path: /customer/**
order-service:
path: /order/**
product-service:
path: /product/**
自定义路由配置
有一些配置设置,允许我们自定义 Zuul 代理的行为。其中一些与服务发现集成密切相关。
忽略注册的服务
默认情况下,Spring Cloud Zuul 会暴露 Eureka 服务器中注册的所有服务。如果您想跳过每个服务的自动添加,您必须使用与发现服务器中所有忽略的服务名称匹配的模式设置zuul.ignored-services属性。实际工作中它是如何工作的呢?即使您没有提供任何zuul.routes.*属性的配置,Zuul 也会从 Eureka 获取服务列表并将它们自动绑定到服务名称的路径下。例如,account-service将在网关地址http://localhost:8080/account-service/**下可用。现在,如果您在application.yml文件中设置了以下配置,它将忽略account-service并返回一个 HTTP 404 状态码:
zuul:
ignoredServices: 'account-service'
您还可以通过将zuul.ignored-services设置为'*'来忽略所有注册的服务。如果一个服务与被忽略的模式匹配,但同时也包含在路由映射配置中,那么 Zuul 将会包含它。在这种情况下,只有customer-service会被处理:
zuul:
ignoredServices: '*'
routes:
customer-service: /customer/**
显式设置服务名称
从发现服务器获取的服务名称也可以在配置中使用serviceId属性进行设置。它使你能够对路由进行细粒度控制,因为你可以独立指定路径和serviceId。以下是路由的等效配置:
zuul:
routes:
accounts:
path: /account/**
serviceId: account-service
customers:
path: /customer/**
serviceId: customer-service
orders:
path: /order/**
serviceId: order-service
products:
path: /product/**
serviceId: product-service
带有 Ribbon 客户端的路由定义
还有另一种配置路由的方法。我们可以禁用 Eureka 发现,以便只依赖于 Ribbon 客户端提供的listOfServers属性的网络地址列表。网关的所有传入请求默认通过 Ribbon 客户端在所有服务实例之间进行负载均衡。即使您启用了或禁用了服务发现,以下示例代码也是正确的:
zuul:
routes:
accounts:
path: /account/**
serviceId: account-service
ribbon:
eureka:
enabled: false
account-service:
ribbon:
listOfServers: http://localhost:8091,http://localhost:9091
为路径添加前缀
有时,为了让通过网关调用的服务设置不同的路径,而不是直接可用,这是必要的。在这种情况下,Zuul 提供了为所有定义的映射添加前缀的能力。这可以通过zuul.prefix属性轻松配置。默认情况下,Zuul 在将请求转发给服务之前截断该前缀。然而,通过将zuul.stripPrefix属性设置为false,可以禁用这种行为。stripPrefix属性不仅可以为所有定义的路由全局配置,还可以为每个单独的路由配置。
以下示例为所有转发请求添加了/api前缀。现在,例如,如果您想从account-service调用GET /{id}端点,您应该使用地址http://localhost:8080/api/account/1:
zuul:
prefix: /api
routes:
accounts:
path: /account/**
serviceId: account-service
customers:
path: /customer/**
serviceId: customer-service
如果我们提供了stripPrefix设置为false的配置会发生什么?Zuul 将尝试在目标服务的上下文路径/api/account和/api/customer下查找端点:
zuul:
prefix: /api
stripPrefix: false
连接设置和超时
Spring Cloud Netflix Zuul 的主要任务是将传入请求路由到下游服务。因此,它必须使用一个 HTTP 客户端实现与这些服务的通信。Zuul 目前默认使用的 HTTP 客户端是由 Apache HTTP Client 支持的,而不是已被弃用的 Ribbon RestClient。如果你想要使用 Ribbon,你应该将ribbon.restclient.enabled属性设置为true。你也可以通过将ribbon.okhttp.enabled属性设置为true来尝试OkHttpClient。
我们可以为 HTTP 客户端配置基本设置,如连接或读取超时以及最大连接数。根据我们是否使用服务发现,此类配置有两大选项。如果你通过url属性定义了具有指定网络地址的 Zuul 路由,那么你应该设置zuul.host.connect-timeout-millis和zuul.host.socket-timeout-millis。为了控制最大连接数,你应该覆盖默认值为200的zuul.host.maxTotalConnections属性。你也可以通过设置默认值为20的zuul.host.maxPerRouteConnections属性来定义每个单一路径的最大连接数。
如果 Zuul 配置为从发现服务器获取服务列表,你需要使用与 Ribbon 客户端属性ribbon.ReadTimeout和ribbon.SocketTimeout相同的超时配置。最大连接数可以通过ribbon.MaxTotalConnections和ribbon.MaxConnectionsPerHost进行自定义。
安全头
如果你在请求中设置了例如Authorization HTTP 头,但它没有被转发到下游服务,你可能会有些惊讶。这是因为 Zuul 定义了一个默认的敏感头列表,在路由过程中会移除这些头。这些头包括Cookie、Set-Cookie和Authorization。这一特性是为了与外部服务器通信而设计的。虽然对于同一系统中的服务之间共享头没有反对意见,但出于安全原因,不建议与外部服务器共享。可以通过覆盖sensitiveHeaders属性的默认值来自定义这种方法。它可以为所有路由或单个路由全局设置。sensitiveHeaders不是一个空的黑名单,所以为了使 Zuul 转发所有头,你应该明确将其设置为空列表:
zuul:
routes:
accounts:
path: /account/**
sensitiveHeaders:
serviceId: account-service
管理端点
Spring Cloud Netflix Zuul 暴露了两个用于监控的额外管理端点:
-
路由:打印出定义的路由列表
-
过滤器:打印出实现过滤器的列表(自 Spring Cloud Netflix 版本
1.4.0.RELEASE起可用)
要启用管理端点功能,我们必须(像往常一样)在项目依赖中包含spring-boot-starter-actuator。为了测试目的,禁用端点安全是一个好主意,通过将management.security.enabled属性设置为false。现在,你可以调用GET /routes方法,它将打印出我们示例系统的以下 JSON 响应:
{
"/api/account/**": "account-service",
"/api/customer/**": "customer-service",
"/api/order/**": "order-service",
"/api/product/**": "product-service",
}
要获取更多详细信息,必须在/routes路径上添加?format=details查询字符串。这个选项从 Spring Cloud 版本 1.4.0(Edgware 发布列车)也开始提供。还有一个POST /route方法,可以强制刷新当前存在的路由。另外,您可以通过将endpoints.routes.enabled设置为false来禁用整个端点:
"/api/account/**": {
"id": "account-service",
"fullPath": "/api/account/**",
"location": "account-service",
"path": "/**",
"prefix": "/api/account",
"retryable": false,
"customSensitiveHeaders": false,
"prefixStripped": true
}
/filters端点的响应结果非常有趣。你可以看到 Zuul 网关默认提供了多少种过滤器和过滤器类型。以下是带有选定过滤器的一个响应片段。它包含完整的类名,调用顺序和状态。关于过滤器的更多信息,你可以参考Zuul 过滤器部分:
"route": [{
"class": "org.springframework.cloud.netflix.zuul.filters.route.RibbonRoutingFilter",
"order": 10,
"disabled": false,
"static": true
}, {
...
]
提供 Hystrix 回退
我们可能需要为 Zuul 配置中定义的每个单独的路由提供一个回退响应,以防电路被打开。为此,我们应该创建一个类型为ZuulFallbackProvider(目前已被弃用)或FallbackProvider的 bean。在这个实现中,我们必须指定路由 ID 模式,以匹配所有应该由回退 bean 处理的路由。第二步是在fallbackResponse方法中返回ClientHttpResponse接口的实现作为响应。
这是一个简单的回退 bean,它将每个异常映射到 HTTP 状态200 OK,并在 JSON 响应中设置errorCode和errorMessage。仅针对account-service路由执行回退。
public class AccountFallbackProvider implements FallbackProvider {
@Override
public String getRoute() {
return "account-service";
}
@Override
public ClientHttpResponse fallbackResponse(Throwable cause) {
return new ClientHttpResponse() {
@Override
public HttpHeaders getHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
return headers;
}
@Override
public InputStream getBody() throws IOException {
AccountFallbackResponse response = new AccountFallbackResponse("1.2", cause.getMessage());
return new ByteArrayInputStream(new ObjectMapper().writeValueAsBytes(response));
}
@Override
public String getStatusText() throws IOException {
return "OK";
}
@Override
public HttpStatus getStatusCode() throws IOException {
return HttpStatus.OK;
}
@Override
public int getRawStatusCode() throws IOException {
return 200;
}
@Override
public void close() {
}
};
}
// ...
}
Zuul 过滤器
如我前面已经提到的,Spring Cloud Zuul 默认提供了一些 bean,这些 bean 是ZuulFilter接口的实现。每个内置过滤器都可以通过将zuul.<SimpleClassName>.<filterType>.disable属性设置为true来禁用。例如,要禁用org.springframework.cloud.netflix.zuul.filters.post.SendResponseFilter,你必须设置zuul.SendResponseFilter.post.disable=true。
HTTP 过滤机制你可能已经很熟悉了。过滤器动态地拦截请求和响应以转换,或者只是使用,从 HTTP 消息中获取的信息。它可能在 incoming request 或 outgoing response 之前或之后触发。我们可以识别出由 Zuul 为 Spring Cloud 提供的几种类型的过滤器:
-
预过滤器:它用于在
RequestContext中准备初始数据,以在下游过滤器中使用。主要责任是设置路由过滤器所需的信息。 -
路由过滤器:它在预过滤器之后调用,负责创建到其他服务的请求。使用它的主要原因是需要适应客户端所需的请求或响应模型。
-
后过滤器:最常见的是操作响应。它甚至可以转换响应体。
-
错误过滤器:它仅在其他过滤器抛出异常时执行。只有一个内置的错误过滤器实现。如果
RequestContext.getThrowable()不为空,则执行SendErrorFilter。
预定义过滤器
如果你用@EnableZuulProxy注解主类,Spring Cloud Zuul 会加载SimpleRouteLocator和DiscoveryClientRouteLocator使用的过滤器 bean。这是作为普通 Spring Bean 安装的一些最重要的实现列表:
-
ServletDetectionFilter:这是一个预过滤器。它检查请求是否通过 Spring Dispatcher。设置了一个布尔值,键为FilterConstants.IS_DISPATCHER_SERVLET_REQUEST_KEY。 -
FormBodyWrapperFilter:这是一个预过滤器。它解析表单数据并重新编码以供下游请求使用。 -
PreDecorationFilter:这是一个预过滤器。它根据提供的RouteLocator确定路由的位置和方式。它还负责设置与代理相关的头信息。 -
SendForwardFilter:这是一个路由过滤器。它使用RequestDispatcher转发请求。 -
RibbonRoutingFilter:这是一个路由过滤器。它使用 Ribbon、Hystrix 和外部 HTTP 客户端,如 ApacheHttpClient、OkHttpClient或 Ribbon HTTP 客户端来发送请求。服务 ID 从请求上下文中获取。 -
SimpleHostRoutingFilter:这是一个路由过滤器。它通过 Apache HTTP 客户端将请求发送到 URL。 URL 在请求上下文中找到。 -
SendResponseFilter:这是一个后过滤器。它将代理请求的响应写入当前响应。
自定义实现
除了默认安装的过滤器之外,我们还可以创建自己的自定义实现。 每个实现都必须实现ZuulFilter接口及其四个方法。 这些方法负责设置过滤器的类型(filterType)、确定与其他具有相同类型的过滤器执行的顺序(filterOrder)、启用或禁用过滤器(shouldFilter)以及最后过滤逻辑实现(run)。 以下是一个示例实现,它向响应中添加了X-Response-ID头:
public class AddResponseIDHeaderFilter extends ZuulFilter {
private int id = 1;
@Override
public String filterType() {
return "post";
}
@Override
public int filterOrder() {
return 10;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() {
RequestContext context = RequestContext.getCurrentContext();
HttpServletResponse servletResponse = context.getResponse();
servletResponse.addHeader("X-Response-ID",
String.valueOf(id++));
return null;
}
}
还有很多工作要做。自定义过滤器实现也应该在主类或 Spring 配置类中声明为@Bean:
@Bean
AddResponseIDHeaderFilter filter() {
return new AddResponseIDHeaderFilter();
}
使用 Spring Cloud Gateway
围绕 Spring Cloud Gateway 有三个基本概念:
-
路由:这是网关的基本构建块。它包括一个用于标识路由的唯一 ID、一个目标 URI、一个断言列表和一个过滤器列表。只有在所有断言都已满足时,才会匹配路由。
-
断言:这是在处理每个请求之前执行的逻辑。它负责检测 HTTP 请求的不同属性,如头和参数,是否与定义的 criteria 匹配。实现基于 Java 8 接口
java.util.function.Predicate<T>。输入类型反过来基于 Spring 的org.springframework.web.server.ServerWebExchange。 -
过滤器:它们允许修改传入的 HTTP 请求或 outgoing HTTP 响应。它们可以在发送下游请求之前或之后进行修改。路由过滤器针对特定的路由。它们实现 Spring 的
org.springframework.web.server.GatewayFilter。
启用 Spring Cloud Gateway
Spring Cloud Gateway 建立在 Netty 网络容器和 Reactor 框架之上。Reactor 项目和 Spring Web Flux 可以与 Spring Boot 2.0 一起使用。到目前为止,我们使用的是 1.5 版本,因此 parent 项目版本声明不同。目前,Spring Boot 2.0 仍然处于里程碑阶段。以下是继承自spring-boot-starter-parent项目的 Maven pom.xml片段:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.0.M7</version>
</parent>
与之前的示例相比,我们还需要更改 Spring Cloud 的发布列车。最新可用的里程碑版本是Finchley.M5:
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<spring-cloud.version>Finchley.M5</spring-cloud.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
在设置正确的 Spring Boot 和 Spring Cloud 版本之后,我们终于可以在项目依赖中包含spring-cloud-starter-gateway启动器:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
内置断言和过滤器
Spring Cloud Gateway 包括许多内置的路由断言和网关过滤器工厂。每个路由可以通过application.yml文件中的配置属性或使用 Fluent Java Routes API 以编程方式定义。可用的断言工厂列表如下表所示。多个工厂可以组合用于单一路由定义,使用逻辑and关系。过滤器的集合可以在application.yml文件中,在spring.cloud.gateway.routes属性下,每个定义的路由的predicates属性下进行配置:
| 名称 | 描述 | 示例 |
|---|---|---|
After 路由 |
它接受一个日期时间参数,并匹配在其之后发生的请求 | After=2017-11-20T... |
Before 路由 |
它接受一个日期时间参数,并匹配在其之前的请求 | Before=2017-11-20T... |
Between 路由 |
它接受两个日期时间参数,并匹配在这些日期之间的请求 | Between=2017-11-20T..., 2017-11-21T... |
Cookie 路由 |
它接受一个 cookie 名称和正则表达式参数,在 HTTP 请求的头中找到 cookie,并将其值与提供的表达式匹配 | Cookie=SessionID, abc. |
Header 路由 |
它接受头名称和正则表达式参数,在 HTTP 请求的头中找到一个特定的头,并将其值与提供的表达式匹配 | Header=X-Request-Id, \d+ |
Host 路由 |
它接受一个以.分隔符的主机名 ANT 风格模式作为参数,并与Host头匹配 |
Host=**.example.org |
Method 路由 |
它接受一个 HTTP 方法作为参数以进行匹配 | Method=GET |
Path 路由 |
它接受一个请求上下文路径模式作为参数 | Path=/account/{id} |
Query 路由 |
它接受两个参数——一个必需的参数和一个可选的正则表达式,并与查询参数匹配 | Query=accountId, 1. |
RemoteAddr 路由 |
它接受一个 CIDR 表示法的 IP 地址列表,如192.168.0.1/16,并与请求的远程地址匹配 |
RemoteAddr=192.168.0.1/16 |
还有几个网关过滤器模式的内置实现。以下表格还提供了可用工厂列表。每个filters属性下定义的路线可以在application.yml文件的spring.cloud.gateway.routes属性下配置过滤器集合:
| 名称 | 描述 | 示例 |
|---|---|---|
AddRequestHeader |
在 HTTP 请求中添加一个头,参数中提供了名称和值 | AddRequestHeader=X-Response-ID, 123 |
AddRequestParameter |
在 HTTP 请求中添加一个查询参数,参数中提供了名称和值 | AddRequestParameter=id, 123 |
AddResponseHeader |
在 HTTP 响应中添加一个头,参数中提供了名称和值 | AddResponseHeader=X-Response-ID, 123 |
Hystrix |
它接受一个参数,该参数是 HystrixCommand 的名称 | Hystrix=account-service |
PrefixPath |
在参数中定义的 HTTP 请求路径前添加一个前缀 | PrefixPath=/api |
RequestRateLimiter |
它根据三个输入参数限制单个用户的处理请求数量,包括每秒最大请求数、突发容量和一个返回用户键的 bean | RequestRateLimiter=10, 20, #{@userKeyResolver} |
RedirectTo |
它接受一个 HTTP 状态和一个重定向 URL 作为参数,将其放入Location HTTP 头中以执行重定向 |
RedirectTo=302, http://localhost:8092 |
RemoveNonProxyHeaders |
它从转发请求中移除一些跳过头的头信息,如 Keep-Alive、Proxy-Authenticate 或 Proxy-Authorization | - |
RemoveRequestHeader |
它接受一个头名称作为参数,并将其从 HTTP 请求中移除 | RemoveRequestHeader=X-Request-Foo |
RemoveResponseHeader |
它接受一个头名称作为参数,并将其从 HTTP 响应中移除 | RemoveResponseHeader=X-Response-ID |
RewritePath |
它接受一个路径正则表达式参数和一个替换参数,然后重写请求路径 | RewritePath=/account/(?<path>.*), /$\{path} |
SecureHeaders |
它在响应中添加一些安全头 | - |
SetPath |
它接受一个带有路径模板参数的单参数,并更改请求路径 | SetPath=/{segment} |
SetResponseHeader |
它接受名称和值参数,在 HTTP 响应中设置一个头 | SetResponseHeader=X-Response-ID, 123 |
SetStatus |
它接受一个单独的状态参数,该参数必须是一个有效的 HTTP 状态,并在响应中设置它 | SetStatus=401 |
这是一个带有两个谓词和两个过滤器设置的简单示例。每个传入的GET /account/{id}请求都会被转发到http://localhost:8080/api/account/{id},并包含新的 HTTP 头X-Request-ID:
spring:
cloud:
gateway:
routes:
- id: example_route
uri: http://localhost:8080
predicates:
- Method=GET
- Path=/account/{id}
filters:
- AddRequestHeader=X-Request-ID, 123
- PrefixPath=/api
相同的配置可以使用定义在Route类中的流利 API 提供。这种风格给我们更多的灵活性。虽然使用 YAML 可以组合使用逻辑and的谓词,但流利 Java API 允许你在Predicate类上使用and()、or()和negate()操作符。以下是使用流利 API 实现的替代路由:
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder routeBuilder) {
return routeBuilder.routes()
.route(r -> r.method(HttpMethod.GET).and().path("/account/{id}")
.addRequestHeader("X-Request-ID", "123").prefixPath("/api")
.uri("http://localhost:8080"))
.build();
}
微服务网关
让我们回到我们的基于微服务的系统示例。我们已经在基于 Spring Cloud Netflix Zuul 的 API 网关配置部分讨论了这个示例。我们希望能够为基于 Zuul 代理的应用程序准备相同的静态路由定义。然后,每个服务都可以在网关地址和特定路径下可用,例如http://localhost:8080/account/**。使用 Spring Cloud Gateway 声明此类配置的最合适方式是通过路径路由谓词工厂和重写路径网关过滤器工厂。重写路径机制通过取其一部分或添加某些模式来改变请求路径。在我们的案例中,每个传入的请求路径都被重写,例如,从account/123变为/123。以下是网关的application.yml文件:
server:
port: ${PORT:8080}
spring:
application:
name: gateway-service
cloud:
gateway:
routes:
- id: account-service
uri: http://localhost:8091
predicates:
- Path=/account/**
filters:
- RewritePath=/account/(?<path>.*), /$\{path}
- id: customer-service
uri: http://localhost:8092
predicates:
- Path=/customer/**
filters:
- RewritePath=/customer/(?<path>.*), /$\{path}
- id: order-service
uri: http://localhost:8090
predicates:
- Path=/order/**
filters:
- RewritePath=/order/(?<path>.*), /$\{path}
- id: product-service
uri: http://localhost:8093
predicates:
- Path=/product/**
filters:
- RewritePath=/product/(?<path>.*), /$\{path}
令人惊讶的是,这就足够了。我们不需要提供任何与使用 Eureka 或 Config Server 等其他 Spring Cloud 组件时相比额外的注解。所以,我们网关应用程序的主类如下面的代码片段所示。你必须使用mvn clean install构建项目,并使用java -jar启动它,或者直接从你的 IDE 运行主类。示例应用程序的源代码可以在 GitHub 上找到(github.com/piomin/sample-spring-cloud-gateway.git):
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}
服务发现集成
网关可以配置为基于服务发现中注册的服务列表创建路由。它可以与那些具有与DiscoveryClient兼容的服务注册解决方案集成的解决方案,例如 Netflix Eureka、Consul 或 Zookeeper。要启用DiscoveryClient路由定义定位器,你应该将spring.cloud.gateway.discovery.locator.enabled属性设置为true,并在类路径上提供一个DiscoveryClient实现。我们使用 Eureka 客户端和服务器进行发现。请注意,随着 Spring Cloud 最新里程碑版本Finchley.M5的发布,所有 Netflix 构件的名称都发生了变化,现在例如使用spring-cloud-starter-netflix-eureka-client而不是spring-cloud-starter-eureka:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
主类对 Eureka 客户端应用程序来说应该是相同的,用@DiscoveryClient注解。这是带有路由配置的application.yml文件。与之前的示例相比,唯一的变化是每个定义的路由的uri属性。我们不是提供它们的网络地址,而是使用从发现服务器中带有lb前缀的名称,例如lb://order-service:
spring:
application:
name: gateway-service
cloud:
gateway:
discovery:
locator:
enabled: true
routes:
- id: account-service
uri: lb://account-service
predicates:
- Path=/account/**
filters:
- RewritePath=/account/(?<path>.*), /$\{path}
- id: customer-service
uri: lb://customer-service
predicates:
- Path=/customer/**
filters:
- RewritePath=/customer/(?<path>.*), /$\{path}
- id: order-service
uri: lb://order-service
predicates:
- Path=/order/**
filters:
- RewritePath=/order/(?<path>.*), /$\{path}
- id: product-service
uri: lb://product-service
predicates:
- Path=/product/**
filters:
- RewritePath=/product/(?<path>.*), /$\{path}
总结
有了 API 网关,我们在 Spring Cloud 中实现微服务架构核心元素的讨论已经结束。阅读了本书这部分内容后,你应该能够定制并使用 Eureka、Spring Cloud Config、Ribbon、Feign、Hystrix 以及最后基于 Zuul 和 Spring Cloud Gateway 的网关。
将这一章节视为两种可用的解决方案——老版本的 Netflix Zuul 和最新版本的 Spring Cloud Gateway 之间的比较。其中一个新的解决方案正在动态变化。它的当前版本 2.0,可能只与 Spring 5 一起使用,并且还没有在发行版中提供。而第一个解决方案,Netflix Zuul,是稳定的,但它不支持异步、非阻塞连接。它仍然基于 Netflix Zuul 1.0,尽管有一个新的 Zuul 版本支持异步通信。不管它们之间的差异如何,我都描述了如何使用这两种解决方案提供简单和更高级的配置。我还根据前面章节的示例,展示了与服务发现、客户端负载均衡器和断路器的集成。
第九章:分布式日志记录和追踪
当将单体应用拆分为微服务时,我们通常会花很多时间思考业务边界或应用逻辑的划分,但我们忘记了日志。根据我自己作为开发者和软件架构师的经验,我可以说明开发者通常不会支付太多注意力到日志上。另一方面,负责应用程序维护的操作团队主要依赖日志。无论你的专业领域是什么,毫无疑问,日志是所有应用程序都必须做的工作,无论它们是有单体架构还是微服务架构。然而,微服务在设计和安排应用程序日志方面增加了一个全新的维度。有许多小型的、独立的、水平扩展的、相互通信的服务在多台机器上运行。请求通常由多个服务处理。我们必须关联这些请求,并将所有日志存储在单一的、中心位置,以便更容易查看它们。Spring Cloud 引入了一个专门的库,实现了分布式追踪解决方案,即 Spring Cloud Sleuth。
在这里还应该讨论一件事情。日志记录不同于追踪!指出它们之间的区别是值得的。追踪是跟随你的程序的数据流。它通常被技术支持团队用来诊断问题出现的位置。你要追踪你的系统流程以发现性能瓶颈或错误发生的时间。日志记录用于错误报告和检测。与追踪相比,它应该始终是启用的。当你设计一个大型系统,并且你希望跨机器有良好的、灵活的错误报告时,你肯定应该考虑以集中式方式收集日志数据。实现这一目标的推荐和最受欢迎的解决方案是ELK栈(Elasticsearch + Logstash + Kibana)。Spring Cloud 中没有为这个栈提供专门的库,但是可以通过 Java 日志框架(如 Logback 或 Log4j)来实现集成。在本章中还将讨论另一个工具,Zipkin。它是一个典型的追踪工具,帮助收集可以用来解决微服务架构中延迟问题的计时数据。
本章我们将要覆盖的主题包括以下内容:
-
微服务基础系统日志的最佳实践
-
使用 Spring Cloud Sleuth 向消息添加追踪信息并关联事件
-
将 Spring Boot 应用程序与 Logstash 集成
-
使用 Kibana 显示和筛选日志条目
-
使用 Zipkin 作为分布式追踪工具,并通过 Spring Cloud Sleuth 与应用程序集成
微服务最佳的日志实践
处理日志最重要的最佳实践之一是跟踪所有传入请求和传出响应。这可能对你来说很显然,但我见过几个不符合这一要求的应用程序。如果你满足这个需求,微服务架构有一个后果。与单片应用程序相比,系统的日志总数会增加,其中没有消息传递。这反过来又要求我们比以前更加关注日志记录。我们应该尽最大努力生成尽可能少的信息,尽管这些信息可以告诉我们很多情况。我们如何实现这一点?首先,拥有所有微服务相同的日志消息格式是很好的。例如,考虑如何在应用程序日志中打印变量。我建议你使用 JSON 表示法,因为通常,微服务之间交换的消息格式是 JSON。这种格式有一个非常直接的标准化,使得你的日志容易阅读和解析,如下面的代码片段所示:
17:11:53.712 INFO Order received: {"id":1,"customerId":5,"productId":10}
前面的格式比以下内容更容易分析:
17:11:53.712 INFO Order received with id 1, customerId 5 and productId 10.
但通常,这里最重要的是标准化。无论你选择哪种格式,关键是在到处使用它。你还应该小心确保你的日志是有意义的。尽量避免不包含任何信息的句子。例如,从以下格式来看,不清楚哪个顺序正在处理:
17:11:53.712 INFO Processing order
然而,如果你真的想要这种日志条目格式,尽量把它分配给不同的日志级别。将所有内容都以INFO相同的级别记录,真的是一种糟糕的做法。有些信息比其他信息更重要,所以这里的困难在于决定日志条目应该记录在哪个级别。以下是一些建议:
-
TRACE:这是非常详细的信息,仅用于开发。你可能会在部署到生产环境后短时间内保留它,但将其视为临时文件。 -
DEBUG:在这个级别,记录程序中发生的任何事件。这主要用于开发人员的调试或故障排除。DEBUG和TRACE之间的区别可能是最难的。 -
INFO:在这个级别,你应该记录操作期间最重要的信息。这些信息必须易于理解,不仅对开发者,对管理员或高级用户也是如此,让他们能够快速找出应用程序正在做什么。 -
WARN:在这个级别,记录所有可能变成错误的潜在事件。这样的过程可以继续进行,但你应该对此特别小心。 -
ERROR:通常,你会在这个级别打印异常。这里的关键不是到处都抛出异常,例如,如果只有一个业务逻辑执行没有成功的话。 -
FATAL:这个 Java 日志级别表示非常严重的错误事件,可能会导致应用程序停止运行。
还有其他一些好的日志实践,但我已经提到了在基于微服务的系统中使用的一些最重要的实践。还值得提到日志的一个方面,即规范化。如果您想轻松理解和解释您的日志,您肯定要知道它们是在何时何地收集的,它们包含什么,以及为什么要发出它们。在所有微服务中特别重要的特性应该进行规范化,例如Time(何时)、Hostname(何地)和AppName(何人)。正如您将在本章的下一部分看到的,这种规范化在系统中实现集中日志收集方法时非常有用。
使用 Spring Boot 进行日志记录
Spring Boot 内部日志使用 Apache Commons Logging,但如果您包含启动器中的依赖项,默认情况下您的应用程序将使用 Logback。它以任何方式都不妨碍使用其他日志框架的可能性。还提供了 Java Util Logging、Log4J2 和 SLF4J 的默认配置。日志设置可以在application.yml文件中使用logging.*属性进行配置。默认日志输出包含日期和时间(毫秒)、日志级别、进程 ID、线程名称、发出条目的类的全名和消息。可以通过分别使用logging.pattern.console和logging.pattern.file属性为控制台和文件附加器来覆盖它。
默认情况下,Spring Boot 只向控制台记录日志。为了允许除了控制台输出之外还写入日志文件,您应该设置logging.file或logging.path属性。如果您指定logging.file属性,日志将被写入确切位置或当前位置的文件。如果您设置logging.path,它将在指定目录中创建一个spring.log文件。日志文件在达到 10 MB 后会被轮换。
在application.yml设置文件中可以自定义的最后一件事情是日志级别。默认情况下,Spring Boot 记录ERROR、WARN和INFO级别的消息。我们可以使用logging.level.*属性为每个单独的包或类覆盖此设置。还可以使用logging.level.root配置根日志记录器。以下是在application.yml文件中的一个示例配置,它更改了默认模式格式,以及一些日志级别,并设置了日志文件的存储位置:
logging:
file: logs/order.log
level:
com.netflix: DEBUG
org.springframework.web.filter.CommonsRequestLoggingFilter: DEBUG
pattern:
console: "%d{HH:mm:ss.SSS} %-5level %msg%n"
file: "%d{HH:mm:ss.SSS} %-5level %msg%n"
正如您在之前的示例中所看到的,这样的配置相当简单,但在某些情况下,这并不足够。如果您想要定义额外的 appender 或过滤器,您肯定应该包括其中一个可用的日志系统的配置——Logback(logback-spring.xml),Log4j2(log4j2-spring.xml),或 Java Util Logging(logging.properties)。正如我之前提到的,Spring Boot 默认使用 Logback 来记录应用程序日志。如果您在类路径的根目录提供logback-spring.xml文件,它将覆盖application.yml中定义的所有设置。例如,您可以创建每日轮转日志的文件 appender,并保留最多 10 天的历史记录。这个功能在应用程序中非常常用。在本章的下一节中,您还将了解到,要集成您的微服务与 Logstash,需要一个自定义的 appender。以下是一个设置logs/order.log文件每日轮转策略的 Logback 配置文件片段的例子:
<configuration>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/order.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>order.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>10</maxHistory>
<totalSizeCap>1GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level %msg%n</pattern>
</encoder>
</appender>
<root level="DEBUG">
<appender-ref ref="FILE" />
</root>
</configuration>
值得一提的是,Spring 建议使用logback-spring.xml而不是默认的logback.xml对 Logback 进行配置。Spring Boot 包含对 Logback 的一些扩展,这些扩展对于高级配置可能很有帮助。它们不能用在标准的logback.xml中,只能与logback-spring.xml一起使用。我们已经列出了其中一些扩展,这些扩展将允许您定义特定于配置文件或从 Spring Environment 公开属性的配置:
<springProperty scope="context" name="springAppName" source="spring.application.name" />
<property name="LOG_FILE" value="${BUILD_FOLDER:-build}/${springAppName}"/>
<springProfile name="development">
...
</springProfile>
<springProfile name="production">
<appender name="flatfile" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_FILE}</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_FILE}.%d{yyyy-MM-dd}.gz</fileNamePattern>
<maxHistory>7</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
<charset>utf8</charset>
</encoder>
</appender>
...
</springProfile>
使用 ELK 栈集中日志
ELK 是三个开源工具的缩写——Elasticsearch、Logstash 和 Kibana。它也被称为Elastic Stack。这个系统的核心是Elasticsearch,一个基于另一个开源 Java 项目 Apache Lucene 的搜索引擎。这个库特别适合于需要在跨平台环境中进行全文搜索的应用程序。Elasticsearch 流行的主要原因是它的性能。当然,它还有一些其他优势,如可扩展性、灵活性和通过提供基于 RESTful、JSON 格式的 API 来搜索存储的数据,易于集成。它有一个庞大的社区和许多用例,但对我们来说最有趣的是它存储和搜索应用程序生成的日志的能力。日志是包含 Logstash 在 ELK 栈中的主要原因。这个开源数据处理管道允许我们收集、处理并将数据输入到 Elasticsearch 中。
Logstash支持许多输入,这些输入可以从外部来源提取事件。有趣的是,它有许多输出,而 Elasticsearch 只是其中之一。例如,它可以将事件写入 Apache Kafka、RabbitMQ 或 MongoDB,并且可以将指标写入 InfluxDB 或 Graphite。它不仅接收并将数据转发到它们的目的地,还可以实时解析和转换它们。
Kibana 是 ELK 堆栈的最后一个元素。它是一个开源的数据可视化插件,用于 Elasticsearch。它允许您可视化、探索和发现来自 Elasticsearch 的数据。我们可以通过创建搜索查询轻松地显示和筛选我们应用程序收集的所有日志。在此基础上,我们可以将数据导出为 PDF 或 CSV 格式以提供报告。
在机器上设置 ELK 堆栈
在我们将应用程序的任何日志发送到 Logstash 之前,我们必须在本地机器上配置 ELK 堆栈。最合适的方法是使用 Docker 容器运行它。堆栈中的所有产品都可以作为 Docker 镜像使用。ELastic Stack 的供应商提供了一个专用的 Docker 注册表。可以在www.docker.elastic.co找到所有发布镜像和标签的完整列表。它们都使用centos:7作为基础镜像。
我们将从 Elasticsearch 实例开始。其开发可以通过以下命令启动:
docker run -d --name es -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" docker.elastic.co/elasticsearch/elasticsearch:6.1.1
在开发模式下运行 Elasticsearch 是最方便的,因为我们不需要提供任何其他配置。如果您想要在生产模式下启动它,vm.max_map_count Linux 内核设置至少需要设置为262144。根据不同的操作系统平台,修改它的过程是不同的。对于带有 Docker Toolbox 的 Windows,必须通过docker-machine来设置:
docker-machine ssh
sudo sysctl -w vm.max_map_count=262144
下一步是运行带有 Logstash 的容器。除了启动带有 Logstash 的容器外,我们还应该定义一个输入和一个输出。输出是显而易见的——Elasticsearch,现在在默认的 Docker 机器地址192.168.99.100下可用。作为输入,我们定义了与我们的示例应用程序中用作日志附加器的LogstashTcpSocketAppender兼容的简单 TCP 插件logstash-input-tcp。我们所有的微服务日志都将以 JSON 格式发送。现在,重要的是为该插件设置json编码器。每个微服务都将以其名称和micro前缀在 Elasticsearch 中索引。以下是 Logstash 配置文件logstash.conf:
input {
tcp {
port => 5000
codec => json
}
}
output {
elasticsearch {
hosts => ["http://192.168.99.100:9200"]
index => "micro-%{appName}"
}
}
这是一个运行 Logstash 并将其暴露在端口5000上的命令。它还将带有前述设置的文件复制到容器中,并覆盖 Logstash 配置文件的默认位置:
docker run -d --name logstash -p 5000:5000 -v ~/logstash.conf:/config-dir/logstash.conf docker.elastic.co/logstash/logstash-oss:6.1.1 -f /config-dir/logstash.conf
最后,我们可以运行堆栈的最后一个元素,Kibana。默认情况下,它暴露在端口5601上,并连接到端口9200上的 Elasticsearch API,以便能够从那里加载数据:
docker run -d --name kibana -e "ELASTICSEARCH_URL=http://192.168.99.100:9200" -p 5601:5601 docker.elastic.co/kibana/kibana:6.1.1
如果您想在带有 Docker 的 Windows 机器上运行 Elastic Stack 的所有产品,您可能需要将 Linux 虚拟机图像的默认 RAM 内存增加到至少 2 GB。在启动所有容器后,您最终可以通过http://192.168.99.100:5601访问可用的 Kibana 仪表板,然后继续将您的应用程序与 Logstash 集成。
将应用程序与 ELK 堆栈集成
有多种方法可以通过 Logstash 将 Java 应用程序与 ELK 堆栈集成。其中一种方法涉及到使用 Filebeat,它是一个用于本地文件的日志数据传输器。这种方法需要为 Logstash 实例配置一个 beats(logstash-input-beats)输入,实际上这就是默认选项。你还需要在服务器机器上安装并启动一个 Filebeat 守护进程。它负责将日志传递给 Logstash。
个人而言,我更喜欢基于 Logback 和专用追加器的配置。这似乎比使用 Filebeat 代理简单。除了需要部署一个附加服务外,Filebeat 还要求我们使用诸如 Grok 过滤器的解析表达式。使用 Logback 追加器时,你不需要任何日志传输器。这个追加器可在项目中的 Logstash JSON 编码器内使用。你可以通过在logback-spring.xml文件内声明net.logstash.logback.appender.LogstashSocketAppender追加器来为你的应用程序启用它。
我们还将讨论一种将数据发送到 Logstash 的替代方法,使用消息代理。在我们即将研究的示例中,我将向你展示如何使用 Spring AMQPAppender将日志事件发布到 RabbitMQ 交换。在这种情况下,Logstash 订阅该交换并消费发布的消息。
使用 LogstashTCPAppender
库logstash-logback-encoder提供了三种类型的追加器——UDP、TCP 和异步。TCP 追加器最常用。值得一提的是,TCP 追加器是异步的,所有的编码和通信都委托给一个线程。除了追加器,该库还提供了一些编码器和布局,以使你能够以 JSON 格式记录日志。因为 Spring Boot 默认包含一个 Logback 库,以及spring-boot-starter-web,我们只需在 Maven pom.xml中添加一个依赖项:
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>4.11</version>
</dependency>
下一步是在 Logback 配置文件中定义带有LogstashTCPAppender类的追加器。每个 TCP 追加器都需要你配置一个编码器。你可以选择LogstashEncoder和LoggingEventCompositeJsonEncoder之间。LoggingEventCompositeJsonEncoder给你更多的灵活性。它由一个或多个映射到 JSON 输出的 JSON 提供者组成。默认情况下,没有提供者被配置。LogstashTCPAppender不是这样。默认情况下,它包括几个标准字段,如时间戳、版本、日志器名称和堆栈跟踪。它还添加了来自映射诊断上下文(MDC)和上下文的所有条目,除非你通过将includeMdc或includeContext属性设置为false来禁用它:
<appender name="STASH" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
<destination>192.168.99.100:5000</destination>
<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers>
<mdc />
<context />
<logLevel />
<loggerName />
<pattern>
<pattern>
{
"appName": "order-service"
}
</pattern>
</pattern>
<threadName />
<message />
<logstashMarkers />
<stackTrace />
</providers>
</encoder>
</appender>
现在,我想回到我们的示例系统片刻。我们仍然在同一个 Git 仓库(github.com/piomin/sample-spring-cloud-comm.git)的feign_with_discovery分支(github.com/piomin/sample-spring-cloud-comm/tree/feign_with_discovery)。我在源代码中添加了一些日志条目,按照微服务最佳日志实践部分描述的建议。以下是order-service内部的POST方法的当前版本。我通过从org.slf4j.LoggerFactory调用getLogger方法,使用 Logback over SLF4J 作为日志记录器:
@PostMapping
public Order prepare(@RequestBody Order order) throws JsonProcessingException {
int price = 0;
List<Product> products = productClient.findByIds(order.getProductIds());
LOGGER.info("Products found: {}", mapper.writeValueAsString(products));
Customer customer = customerClient.findByIdWithAccounts(order.getCustomerId());
LOGGER.info("Customer found: {}", mapper.writeValueAsString(customer));
for (Product product : products)
price += product.getPrice();
final int priceDiscounted = priceDiscount(price, customer);
LOGGER.info("Discounted price: {}", mapper.writeValueAsString(Collections.singletonMap("price", priceDiscounted)));
Optional<Account> account = customer.getAccounts().stream().filter(a -> (a.getBalance() > priceDiscounted)).findFirst();
if (account.isPresent()) {
order.setAccountId(account.get().getId());
order.setStatus(OrderStatus.ACCEPTED);
order.setPrice(priceDiscounted);
LOGGER.info("Account found: {}", mapper.writeValueAsString(account.get()));
} else {
order.setStatus(OrderStatus.REJECTED);
LOGGER.info("Account not found: {}", mapper.writeValueAsString(customer.getAccounts()));
}
return repository.add(order);
}
让我们看看 Kibana 仪表板。它可通过http://192.168.99.100:5601访问。应用程序日志在那里可以轻松发现和分析。你可以在页面左侧的菜单中选择所需的索引名称(在以下屏幕快照中标记为1)。日志统计信息以时间线图的形式展示(2)。你可以通过点击具体柱状图或选择一组柱状图来缩小搜索参数所花费的时间。给定时间段内的所有日志都显示在图表下方的面板中(3):

每个条目都可以扩展以查看其详细信息。在详细表格视图中,我们可以看到,例如,Elasticsearch 索引的名称(_index)和微服务的级别或名称(appName)。大多数这些字段都是由LoggingEventCompositeJsonEncoder设置的。我只定义了一个应用程序特定的字段,appName:

Kibana 赋予我们搜索特定条目的强大能力。我们只需点击选中的条目即可定义过滤器,以定义一组搜索条件。在前面的屏幕快照中,你可以看到我过滤掉了所有进入 HTTP 请求的条目。正如你可能记得的,org.springframework.web.filter.CommonsRequestLoggingFilter类负责记录它们。我只是定义了一个名称与完全限定日志类名相等的过滤器。以下是我 Kibana 仪表板上的屏幕截图,它只显示由CommonsRequestLoggingFilter生成的日志:

使用 AMQP appender 和消息代理
使用 Spring AMQP appender 和消息代理的配置比使用简单的 TCP appender 的方法要复杂一些。首先,你需要在你的本地机器上启动一个消息代理。我在第五章,与 Spring Cloud Config 的分布式配置中描述了这一过程,其中我介绍了使用 Spring Cloud Bus 的 RabbitMQ 进行动态配置重载。假设你已经在本地下启动了一个 RabbitMQ 实例或作为 Docker 容器启动,你可以继续进行配置。我们必须为发布传入事件创建一个队列,然后将其绑定到交换机。为此,你应该登录到 Rabbit 管理控制台,然后单击队列部分。我创建了一个名为q_logstash的队列。我定义了一个名为ex_logstash的新交换机,如下面的屏幕截图所示。该队列已使用所有示例微服务的路由键绑定到交换机:

在我们启动和配置了 RabbitMQ 实例之后,我们可以在应用程序方面开始集成。首先,你必须将spring-boot-starter-amqp包含在项目依赖项中,以提供 AMQP 客户端和 AMQP appender 的实现:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
然后,你唯一需要做的是在 Logback 配置文件中定义具有org.springframework.amqp.rabbit.logback.AmqpAppender类的 appender。需要设置的最重要属性是 RabbitMQ 网络地址(host,port),声明的交换机名称(exchangeName)和路由键(routingKeyPattern),它必须与为交换机绑定声明的其中一个键匹配。与 TCP appender 相比,这种方法的缺点是需要自己准备发送给 Logstash 的 JSON 消息。以下是order-service的 Logback 配置片段:
<appender name="AMQP"
class="org.springframework.amqp.rabbit.logback.AmqpAppender">
<layout>
<pattern>
{
"time": "%date{ISO8601}",
"thread": "%thread",
"level": "%level",
"class": "%logger{36}",
"message": "%message"
}
</pattern>
</layout>
<host>192.168.99.100</host>
<port>5672</port>
<username>guest</username>
<password>guest</password>
<applicationId>order-service</applicationId>
<routingKeyPattern>order-service</routingKeyPattern>
<declareExchange>true</declareExchange>
<exchangeType>direct</exchangeType>
<exchangeName>ex_logstash</exchangeName>
<generateId>true</generateId>
<charset>UTF-8</charset>
<durable>true</durable>
<deliveryMode>PERSISTENT</deliveryMode>
</appender>
通过声明rabbitmq(logstash-input-rabbitmq)输入,Logstash 可以轻松集成 RabbitMQ:
input {
rabbitmq {
host => "192.168.99.100"
port => 5672
durable => true
exchange => "ex_logstash"
}
}
output {
elasticsearch {
hosts => ["http://192.168.99.100:9200"]
}
}
Spring Cloud Sleuth
Spring Cloud Sleuth 是一个相当小型的、简单的项目,但它提供了一些对日志记录和跟踪有用的功能。如果你参考使用 LogstashTCPAppender部分中讨论的示例,你可以很容易地看出,没有可能过滤出与单个请求相关的所有日志。在基于微服务的环境中,关联应用程序在处理进入系统的请求时交换的消息也非常重要。这是创建 Spring Cloud Sleuth 项目的主要动机。
如果为应用程序启用了 Spring Cloud Sleuth,它会向请求中添加一些 HTTP 头,这允许您将请求与响应以及独立应用程序之间交换的消息链接起来,例如,通过 RESTful API。它定义了两个基本工作单位——跨度(span)和跟踪(trace)。每个都有一个独特的 64 位 ID。跟踪 ID 的值等于跨度 ID 的初始值。跨度指的是一个单独的交换,其中响应是作为对请求的反应发送的。跟踪通常被称为上下文关联(correlation IT),它帮助我们链接系统处理传入请求时不同应用程序生成的所有日志。
每个跟踪和跨度 ID 都添加到 Slf4J MDC(映射诊断上下文)中,因此您将能够在日志聚合器中提取具有给定跟踪或跨度的所有日志。MDC 只是一个存储当前线程上下文数据的映射。每个到达服务器的客户端请求都是由不同的线程处理的。得益于这一点,每个线程在其线程生命周期内都可以访问其 MDC 的值。除了spanId和traceId之外,Spring Cloud Sleuth 还将在 MDC 中添加以下两个跨度:
-
appName:生成日志条目的应用程序名称 -
exportable:这指定了日志是否应导出到 Zipkin
除了前面的特性外,Spring Cloud Sleuth 还提供了:
-
一种对常见分布式跟踪数据模型的抽象,允许与 Zipkin 集成。
-
记录时间信息以帮助进行延迟分析。它还包括不同的抽样策略来管理导出到 Zipkin 的数据量。
-
与参与通信的常见 Spring 组件集成,如 servlet 过滤器、异步端点、RestTemplate、消息通道、Zuul 过滤器和 Feign 客户端。
将 Sleuth 集成到应用程序中
为了在应用程序中启用 Spring Cloud Sleuth 功能,只需将spring-cloud-starter-sleuth启动器添加到依赖项中:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-sleuth</artifactId>
</dependency>
包含此依赖项后,应用程序生成的日志条目的格式已更改。您可以通过以下方式看到这一点:
2017-12-30 00:21:31.639 INFO [order-service,9a3fef0169864e80,9a3fef0169864e80,false] 49212 --- [nio-8090-exec-6] p.p.s.order.controller.OrderController : Products found: [{"id":2,"name":"Test2","price":1500},{"id":9,"name":"Test9","price":2450}]
2017-12-30 00:21:31.683 INFO [order-service,9a3fef0169864e80,9a3fef0169864e80,false] 49212 --- [nio-8090-exec-6] p.p.s.order.controller.OrderController : Customer found: {"id":2,"name":"Adam Smith","type":"REGULAR","accounts":[{"id":4,"number":"1234567893","balance":5000},{"id":5,"number":"1234567894","balance":0},{"id":6,"number":"1234567895","balance":5000}]}
2017-12-30 00:21:31.684 INFO [order-service,9a3fef0169864e80,9a3fef0169864e80,false] 49212 --- [nio-8090-exec-6] p.p.s.order.controller.OrderController : Discounted price: {"price":3752}
2017-12-30 00:21:31.684 INFO [order-service,9a3fef0169864e80,9a3fef0169864e80,false] 49212 --- [nio-8090-exec-6] p.p.s.order.controller.OrderController : Account found: {"id":4,"number":"1234567893","balance":5000}
2017-12-30 00:21:31.711 INFO [order-service,58b06c4c412c76cc,58b06c4c412c76cc,false] 49212 --- [nio-8090-exec-7] p.p.s.order.controller.OrderController : Order found: {"id":4,"status":"ACCEPTED","price":3752,"customerId":2,"accountId":4,"productIds":[9,2]}
2017-12-30 00:21:31.722 INFO [order-service,58b06c4c412c76cc,58b06c4c412c76cc,false] 49212 --- [nio-8090-exec-7] p.p.s.order.controller.OrderController : Account modified: {"accountId":4,"price":3752}
2017-12-30 00:21:31.723 INFO [order-service,58b06c4c412c76cc,58b06c4c412c76cc,false] 49212 --- [nio-8090-exec-7] p.p.s.order.controller.OrderController : Order status changed: {"status":"DONE"}
使用 Kibana 搜索事件
Spring Cloud Sleuth 自动向所有请求和响应添加 HTTP 头X-B3-SpanId和X-B3-TraceId。这些字段也包括在 MDC 中作为spanId和traceId。但在移到 Kibana 仪表板之前,我想让您看一下下面的图表。这是一个顺序图,展示了样本微服务之间的通信流程:

order-service暴露了两种可用方法。第一种是创建新订单,第二种是确认它。第一个POST /方法,实际上,直接从customer-service、product-service和account-service通过customer-service调用所有其他服务的端点。第二个PUT /{id}方法只与account-service的一个端点集成。
前述流程现在可以通过存储在 ELK Stack 中的日志条目进行映射。当使用 Kibana 作为日志聚合器,结合由 Spring Cloud Sleuth 生成的字段时,我们可以通过使用 trace 或 span ID 过滤它们来轻松找到条目。这是一个例子,我们发现所有与从order-service调用POST /端点有关的事件,其X-B3-TraceId字段等于103ec949877519c2:

下面是一个与前一个例子类似的例子,但是在这个例子中,所有在处理请求期间存储的事件都被发送到PUT /{id}端点。这些条目也通过X-B3-TraceId字段过滤出来,该字段的值等于7070b90bfb36c961:

在这里,你可以看到已经发送到 Logstash 的微服务应用程序的完整字段列表。带有X-前缀的字段已经被 Spring Cloud Sleuth 库包含在消息中:

将 Sleuth 与 Zipkin 集成
Zipkin 是一个流行的、开源的分布式追踪系统,它帮助收集分析微服务架构中延迟问题的所需时序数据。它能够使用 UI web 控制台收集、查询和可视化数据。Zipkin UI 提供了一个依赖关系图,显示了系统内所有应用程序处理了多少追踪请求。Zipkin 由四个元素组成。我已经提到了其中一个,Web UI。第二个是 Zipkin 收集器,负责验证、存储和索引所有传入的追踪数据。Zipkin 使用 Cassandra 作为默认的后端存储。它还原生支持 Elasticsearch 和 MySQL。最后一个元素是查询服务,它为查找和检索追踪提供了简单的 JSON API。它主要由 Web UI 消费。
运行 Zipkin 服务器
我们可以通过几种方式在本地运行 Zipkin 服务器。其中一种方式是使用 Docker 容器。以下命令启动一个内存中的服务器实例:
docker run -d --name zipkin -p 9411:9411 openzipkin/zipkin
在运行 Docker 容器之后,Zipkin API 在http://192.168.99.100:9411可用。或者,你可以使用 Java 库和 Spring Boot 应用程序来启动它。为了启用 Zipkin,你应该在你的 Maven pom.xml文件中包含以下依赖项,如下面的代码片段所示。默认版本由spring-cloud-dependencies管理。在我们的示例应用程序中,我使用了Edgware.RELEASE Spring Cloud Release Train:
<dependency>
<groupId>io.zipkin.java</groupId>
<artifactId>zipkin-server</artifactId>
</dependency>
<dependency>
<groupId>io.zipkin.java</groupId>
<artifactId>zipkin-autoconfigure-ui</artifactId>
</dependency>
我在我们的示例系统中增加了一个新的zipkin-service模块。它非常简单。必须实现的唯一事情是应用的主类,它用@EnableZipkinServer注解标记。得益于这一点,Zipkin 实例被嵌入到 Spring Boot 应用程序中:
@SpringBootApplication
@EnableZipkinServer
public class ZipkinApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(ZipkinApplication.class).web(true).run(args);
}
}
为了在默认端口上启动 Zipkin 实例,我们必须在application.yml文件中覆盖默认服务器端口。启动应用程序后,Zipkin API 在http://localhost:9411处可用:
spring:
application:
name: zipkin-service
server:
port: ${PORT:9411}
构建客户端应用程序
如果你想在项目中同时使用 Spring Cloud Sleuth 和 Zipkin,只需在依赖项中添加spring-cloud-starter-zipkin启动器。它通过 HTTP API 实现了与 Zipkin 的集成。如果你已经在 Spring Boot 应用程序内部以内嵌实例启动了 Zipkin 服务器,你不需要提供包含连接地址的任何附加配置。如果你使用 Docker 容器,你应该在application.yml中覆盖默认 URL:
spring:
zipkin:
baseUrl: http://192.168.99.100:9411/
你总是可以利用与服务发现的集成。如果你通过@EnableDiscoveryClient为带有内嵌 Zipkin 服务器的应用程序启用了发现客户端,你只需将属性spring.zipkin.locator.discovery.enabled设置为true即可。在这种情况下,即使它不在默认端口上可用,所有应用程序都可以通过注册名称来定位它。你还应该用spring.zipkin.baseUrl属性覆盖默认的 Zipkin 应用程序名称:
spring:
zipkin:
baseUrl: http://zipkin-service/
默认情况下,Spring Cloud Sleuth 只发送一些选定的传入请求。这是由属性spring.sleuth.sampler.percentage决定的,其值必须是一个在 0.0 和 1.0 之间的双精度值。采样解决方案已经实现,因为分布式系统之间交换的数据量有时可能非常高。Spring Cloud Sleuth 提供了采样器接口,可以实现来控制采样算法。默认实现位于PercentageBasedSampler类中。如果你想追踪你应用程序之间交换的所有请求,只需声明AlwaysSamplerbean。这对于测试目的可能是有用的:
@Bean
public Sampler defaultSampler() {
return new AlwaysSampler();
}
使用 Zipkin UI 分析数据
让我们回到我们的示例系统一会儿。如我之前提到的,新的zipkin-service模块已经增加。我还为所有微服务(包括gateway-service)启用了 Zipkin 跟踪。默认情况下,Sleuth 将spring.application.name的值作为跨度服务名称。你可以用spring.zipkin.service.name属性覆盖那个名称。
为了成功使用 Zipkin 测试我们的系统,我们必须启动微服务、网关、发现和 Zipkin 服务器。为了生成并发送一些测试数据,你可以运行由pl.piomin.services.gateway.GatewayControllerTest类实现的 JUnit 测试。它通过gateway-service向order-service发送 100 条消息,gateway-service可通过http://localhost:8080/api/order/**访问。
让我们分析 Zipkin 从所有服务收集的数据。你可以通过其 Web 控制台 UI 轻松查看。所有跟踪都被标记为服务的名称跨度。如果一个条目有五个跨度,这意味着进入系统的请求被五个不同的服务处理。你可以在以下屏幕截图中看到这一点:

你可以用不同的标准过滤条目,比如服务名称、跨度名称、跟踪 ID、请求时间或持续时间。Zipkin 还可视化失败的请求并按持续时间降序或升序排序:

你可以查看每个条目的详细信息。Zipkin 可视化了所有参与通信的微服务之间的流程。它考虑了每个传入请求的时间数据。你可以揭示系统延迟的原因:

Zipkin 提供了一些额外有趣的功能。其中之一是能够可视化应用程序之间的依赖关系。以下屏幕截图说明了我们的示例系统的通信流程:

你可以通过点击相关元素来查看服务之间交换了多少消息:

通过消息代理进行集成
通过 HTTP 集成 Zipkin 并不是唯一选项。正如 Spring Cloud 通常所做的那样,我们可以使用消息代理作为代理。有两个可用的代理商—RabbitMQ 和 Kafka。第一个可以通过使用spring-rabbit依赖项包含在项目中,而第二个可以通过spring-kafka包含。这两个代理商的默认目的地名称都是zipkin:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-zipkin</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.amqp</groupId>
<artifactId>spring-rabbit</artifactId>
</dependency>
这个功能还要求 Zipkin 服务器端进行更改。我们配置了一个消费者,它正在监听来自 RabbitMQ 或 Kafka 队列的数据。为了实现这一点,只需在你的项目中包含以下依赖项。你仍然需要将zipkin-server和zipkin-autoconfigure-ui工件包含在类路径中:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-sleuth-zipkin-stream</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-stream-rabbit</artifactId>
</dependency>
你应该用@EnableZipkinStreamServer而不是@EnableZipkinServer注解主应用类。幸运的是,@EnableZipkinStreamServer也注解有@EnableZipkinServer,这意味着你也可以使用标准的 Zipkin 服务器端点通过 HTTP 收集跨度,以及使用 Web 控制台查找它们:
@SpringBootApplication
@EnableZipkinStreamServer
public class ZipkinApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(ZipkinApplication.class).web(true).run(args);
}
}
摘要
在开发过程中,日志记录和跟踪通常并不是非常重要,但这些是系统维护中的关键特性。在本章中,我重点介绍了开发和运维领域。我向您展示了如何以几种不同的方式将 Spring Boot 微服务应用程序与 Logstash 和 Zipkin 集成。我还向您展示了如何启用 Spring Cloud Sleuth 功能的一些示例,以便更容易监视许多微服务之间的调用。阅读完本章后,您还应该能够有效地使用 Kibana 作为日志聚合工具,以及使用 Zipkin 作为跟踪工具,发现系统内部通信的瓶颈。
Spring Cloud Sleuth 与 Elastic Stack 和 Zipkin 结合使用,似乎是一个非常强大的生态系统,它消除了您可能对由许多独立微服务组成的监控系统存在问题的任何疑虑。
第十章:额外的配置和发现功能
我们在第四章服务发现和第五章使用 Spring Cloud Config 进行分布式配置中详细讨论了服务发现和分布式配置。我们讨论了两个解决方案。第一个,Eureka,由 Netflix OSS 提供,并被 Spring Cloud 用于服务发现。第二个是仅致力于分布式配置的 Spring Cloud Config 项目。然而,市场上有一些有趣的产品,它们有效地结合了这两项功能。目前,Spring Cloud 支持其中的两个:
-
Consul:这个产品是由 HashiCorp 构建的。它是一个高可用的分布式解决方案,旨在连接和配置跨动态、分布式基础设施的应用程序。Consul 是一个相当复杂的产品,具有多个组件,但其主要功能是在任何基础设施上发现和配置服务。
-
Zookeeper:这个产品是由 Apache 软件基金会构建的。它是一个用 Java 编写的分布式、层次化的键/值存储。它旨在维护配置信息、命名和分布式同步。与 Consul 相比,它更像是原始的键/值存储,而不是现代的服务发现工具。然而,Zookeeper 仍然非常受欢迎,特别是对于基于 Apache 软件栈的解决方案。
支持该领域内的另外两个流行产品仍处于开发阶段。以下项目尚未添加到官方 Spring Cloud 发行版中:
-
Kubernetes:这是一个开源解决方案,旨在自动化容器化应用程序的部署、扩展和管理,最初由 Google 创建。目前这个工具非常受欢迎。最近,Docker 平台开始支持 Kubernetes。
-
Etcd:这是一个用 Go 编写的分布式可靠键/值存储,用于存储分布式系统中最关键的数据。许多公司和软件产品在生产环境中使用它,例如 Kubernetes。
在本章中,我将只介绍官方支持的两个解决方案,即 Consul 和 Zookeeper。Kubernetes,它不仅仅是键/值存储或服务注册表,将在第十四章Docker 支持中讨论。
使用 Spring Cloud Consul
Spring Cloud Consul 项目通过自动配置为 Consul 和 Spring Boot 应用程序提供集成。通过使用众所周知的 Spring Framework 注解风格,我们可以在微服务环境中启用和配置常见模式。这些模式包括使用 Consul 代理的服务发现,使用 Consul 键/值存储的分布式配置,使用 Spring Cloud Bus 的分布式事件,以及 Consul 事件。该项目还支持基于 Netflix 的 Ribbon 的客户端负载均衡器和一个基于 Netflix 的 Zuul 的 API 网关。在我们讨论这些特性之前,我们首先必须运行和配置 Consul 代理。
运行 Consul 代理
我们将从在本地机器上以最简单的方式启动 Consul 代理开始。使用 Docker 容器独立开发模式可以很容易地设置。以下是命令,它将从一个在 Docker Hub 上可用的官方 HashiCorp 镜像启动 Consul 容器:
docker run -d --name consul -p 8500:8500 consul
启动后,Consul 可以在地址http://192.168.99.100:8500下访问。它暴露了 RESTful HTTP API,即主要接口。所有 API 路由都带有/v1/前缀。当然,不直接使用 API 也是可以的。还有一些编程库可以更方便地消费 API。其中之一是consul-api,这是用 Java 编写的客户端,也是 Spring Cloud Consul 内部使用的。还有由 Consul 提供的 web UI 仪表板,在相同的地址下,但上下文路径不同,为/ui/。它允许查看所有注册的服务和节点,查看所有健康检查及其当前状态,以及读取和设置键/值数据。
如我在本节前言中提到的,我们将使用 Consul 的三个不同功能——代理、事件和 KV 存储。每个功能都由一组端点代表,分别是/agent、/event和/kv。最有趣的代理端点是与服务注册相关的那些。以下是这些端点的列表:
| 方法 | 路径 | 描述 |
|---|---|---|
GET |
/agent/services |
它返回已注册到本地代理的服务列表。如果 Consul 以集群模式运行,该列表可能与在集群成员之间执行同步之前由/catalog端点报告的列表不同。 |
PUT |
/agent/service/register |
它向本地代理添加了一个新服务。代理负责管理本地服务,并向服务器发送更新以执行全局目录的同步。 |
PUT |
/agent/service/deregister/:service_id |
它从本地代理中移除具有service_id的服务。代理负责在全球目录中注销该服务。 |
/kv端点用于管理简单的键/值存储,这对于存储服务配置或其他元数据特别有用。值得注意的是,每个数据中心都有自己的 KV 存储,因此为了在多个节点之间共享它,我们应该配置 Consul 复制守护进程。无论如何,这里是为管理键/值存储列出的三个端点:
| 方法 | 路径 | 描述 |
|---|---|---|
GET |
/kv/:key |
它返回给定键名的值。如果请求的键不存在,则返回 HTTP 状态 404 作为响应。 |
PUT |
/kv/:key |
它用于向存储中添加新键,或者只是用键名更新现有键。 |
DELETE |
/kv/:key |
它是用于删除单个键或具有相同前缀的所有键的最后 CRUD 方法。 |
Spring Cloud 使用 Consul 事件来提供动态配置重载。其中有两个简单的 API 方法。第一个,PUT /event/fire/:name,触发一个新的事件。第二个,GET /event/list,返回一个事件列表,可能通过名称、标签、节点或服务名称进行过滤。
客户端集成
要在您的项目中激活 Consul 服务发现,您应该将启动器spring-cloud-starter-consul-discovery包含在依赖项中。如果您希望启用与 Consul 的分布式配置,只需包含spring-cloud-starter-consul-config。在某些情况下,您可能在客户端应用程序中使用这两个功能。然后,您应该声明对spring-cloud-starter-consul-all工件的依赖关系:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-all</artifactId>
</dependency>
默认情况下,Consul 代理预计将在localhost:8500地址下可用。如果对于您的应用程序不同,您应该在application.yml或bootstrap.yml文件中提供适当的地址:
spring:
cloud:
consul:
host: 192.168.99.100
port: 18500
服务发现
通过在主类上使用泛型的 Spring Cloud @EnableDiscoveryClient注解,可以使应用程序启用 Consul 发现。你应该记得从第四章,服务发现,因为与 Eureka 相比没有区别。默认服务名称也来自${spring.application.name}属性。在 GitHub 上的github.com/piomin/sample-spring-cloud-consul.git存储库中提供了使用 Consul 作为发现服务器的微服务示例。系统的架构与前几章中的示例相同。有四个微服务,order-service、product-service、customer-service和account-service,并且 API 网关在gateway-service模块中实现。对于服务间通信,我们使用 Feign 客户端和 Ribbon 负载均衡器:
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class CustomerApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(CustomerApplication.class).web(true).run(args);
}
}
默认情况下,Spring Boot 应用程序在 Consul 中注册,实例 ID 是spring.application.name、spring.profiles.active、server.port属性值的拼接。在大多数情况下,确保 ID 是唯一的就足够了,但如果需要自定义模式,可以通过spring.cloud.consul.discovery.instanceId属性轻松设置:
spring:
cloud:
consul:
discovery:
instanceId: ${spring.application.name}:${vcap.application.instance_id:${spring.application.instance_id:${random.value}}}
启动所有示例微服务后,查看 Consul UI 控制台。您应该会在那里看到四个不同的服务注册,如下面的屏幕截图所示:

另外,您可以使用 RESTful HTTP API 端点GET /v1/agent/services查看已注册服务的列表。这是 JSON 响应的一个片段:
"customer-service-zone1-8092": {
"ID": "customer-service-zone1-8092",
"Service": "customer-service",
"Tags": [],
"Address": "minkowp-l.p4.org",
"Port": 8092,
"EnableTagOverride": false,
"CreateIndex": 0,
"ModifyIndex": 0
},
"order-service-zone1-8090": {
"ID": "order-service-zone1-8090",
"Service": "order-service",
"Tags": [],
"Address": "minkowp-l.p4.org",
"Port": 8090,
"EnableTagOverride": false,
"CreateIndex": 0,
"ModifyIndex": 0
}
现在,您可以轻松地通过使用pl.piomin.services.order.OrderControllerTest JUnit 测试类向order-service发送一些测试请求来测试整个系统。一切应该都会正常工作,与使用 Eureka 进行发现相同。
健康检查
Consul 通过调用/health端点检查每个注册实例的健康状态。如果您不想在类路径中提供 Spring Boot Actuator 库,或者您的服务存在一些问题,它将会在网页控制台上显示出来:

如果出于任何原因健康检查端点在不同的上下文路径下可用,您可以通过spring.cloud.consul.discovery.healthCheckPath属性覆盖该路径。还可以通过定义healthCheckInterval属性来更改状态刷新间隔,例如,使用10s表示秒或2m表示分钟。
spring:
cloud:
consul:
discovery:
healthCheckPath: admin/health
healthCheckInterval: 20s
区域
我假设您还记得我们在第四章《服务发现》中关于 Eureka 的分区机制的讨论。当主机位于不同位置时,它很有用,您希望实例在同一区域之间进行通信。Spring Cloud Consul 的官方文档(cloud.spring.io/spring-cloud-static/spring-cloud-consul/1.2.3.RELEASE/single/spring-cloud-consul.html)没有提到这种解决方案,幸运的是这意味着它没有实现。Spring Cloud 提供了一个基于 Consul 标签的分区机制。应用程序的默认区域可以通过spring.cloud.consul.discovery.instanceZone属性进行配置。它设置了在spring.cloud.consul.discovery.defaultZoneMetadataName属性中配置的标签,并传递给传入的值。默认的元数据标签名是zone。
让我们回到示例应用程序。我将所有配置文件扩展了两个配置文件,zone1和zone2。这是order-service的bootstrap.yml文件:
spring:
application:
name: order-service
cloud:
consul:
host: 192.168.99.100
port: 8500
---
spring:
profiles: zone1
cloud:
consul:
discovery:
instanceZone: zone1
server:
port: ${PORT:8090}
---
spring:
profiles: zone2
cloud:
consul:
discovery:
instanceZone: zone2
server:
port: ${PORT:9090}
每个微服务在两个不同的区域都有两个运行实例。在用mvn clean install命令构建整个项目后,你应该使用zone1或zone2活动配置启动 Spring Boot 应用程序,例如,java -jar --spring.profiles.active=zone1 target/order-service-1.0-SNAPSHOT.jar。您可以在节点部分查看带有区域标签的注册实例的完整列表。Consul 仪表板的观点在以下屏幕快照中可见:

我们架构的最后一部分是一个基于 Zuul 的 API 网关。我们还在不同的区域运行两个gateway-service实例。我们想省略在 Consul 中的注册,并只允许获取配置,该配置由 Ribbon 客户端在执行负载均衡时使用。以下是gateway-service的bootstrap.yml文件的一个片段。通过设置属性`spring.cloud.
consul.discovery.register和spring.cloud.consul.discovery.
registerHealthCheck设置为false`:
---
spring:
profiles: zone1
cloud:
consul:
discovery:
instanceZone: zone1
register: false
registerHealthCheck: false
server:
port: ${PORT:8080}
---
spring:
profiles: zone2
cloud:
consul:
discovery:
instanceZone: zone2
register: false
registerHealthCheck: false
server:
port: ${PORT:9080}
客户端设置自定义
可以通过配置文件中的属性自定义 Spring Cloud Consul 客户端。本章前面部分已经介绍了其中一些设置。其他有用设置列在下面的表格中。它们都带有spring.cloud.consul.discovery前缀:
| 属性 | 默认值 | 描述 |
|---|---|---|
enabled |
true |
它设置应用程序是否启用 Consul 发现 |
failFast |
true |
如果为真,则在服务注册时抛出异常;否则,记录警告 |
hostname |
- | 它在 Consul 中注册实例时设置实例的主机名 |
preferIpAddress |
false |
它强制应用程序在注册时发送其 IP 地址,而不是主机名 |
scheme |
http |
它设置服务是否通过 HTTP 或 HTTPS 协议可用 |
serverListQueryTags |
- | 它允许通过单个标签过滤服务 |
serviceName |
- | 它覆盖了服务名称,默认情况下从spring.application.name属性中获取 |
tags |
- | 它设置在注册服务时使用的标签及其值的列表 |
运行在集群模式下
到目前为止,我们总是启动一个独立的 Consul 实例。虽然在开发模式下这是一个合适的解决方案,但在生产环境中是不够的。在那里,我们希望能够有一个可扩展的、生产级别的服务发现基础设施,由一些在集群内部协同工作的节点组成。Consul 提供了基于八卦协议的集群支持,该协议用于成员之间的通信,以及基于 Raft 共识协议的领导选举。我不想深入了解这个过程,但关于 Consul 架构的一些基本知识应该澄清。
我们已经谈论过 Consul 代理,但它到底是什么以及它的作用并没有被解释。代理是 Consul 集群上每个成员上的长运行守护进程。它可以在客户端或服务器模式下运行。所有代理都负责运行检查并保持服务在不同节点上注册并全局同步。
我们在本节中的主要目标是使用 Docker 镜像设置和配置 Consul 集群。首先,我们将启动一个容器,它作为集群的领导者。与独立的 Consul 服务器相比,当前使用的 Docker 命令只有一个区别。我们设置了环境变量CONSUL_BIND_INTERFACE=eth0,以将集群代理的网络地址从127.0.0.1更改为对其他成员容器可用的地址。我的 Consul 服务器现在在内部地址172.17.0.2上运行。要查看您的地址(它应该相同),您可以运行命令docker logs consul。容器启动后立即记录了适当的信息:
docker run -d --name consul-1 -p 8500:8500 -e CONSUL_BIND_INTERFACE=eth0 consul
了解这个地址非常重要,因为现在我们必须将其作为集群加入参数传递给每个成员容器的启动命令。通过将0.0.0.0设置为客户端地址,我们还将其绑定到所有接口。现在,我们可以使用-p参数轻松地将客户端代理 API 暴露在容器外:
docker run -d --name consul-2 -p 8501:8500 consul agent -server -client=0.0.0.0 -join=172.17.0.2
docker run -d --name consul-3 -p 8502:8500 consul agent -server -client=0.0.0.0 -join=172.17.0.2
在两个容器中运行 Consul 代理后,您可以在领导者的容器上执行以下命令,以查看集群成员的完整列表:

Consul 服务器代理暴露在8500端口上,而成员代理在8501和8502端口上。即使微服务实例将自己注册到一个成员代理上,它对集群中的所有成员都是可见的:

我们可以通过更改配置属性轻松地更改 Spring Boot 应用程序的默认 Consul 代理地址:
spring:
application:
name: customer-service
cloud:
consul:
host: 192.168.99.100
port: 8501
分布式配置
使用 Spring Cloud Consul Config 库在类路径中的应用程序在引导阶段从 Consul 键/值存储中获取配置。也就是说,默认存储在/config文件夹中。当我们创建一个新的键时,我们必须设置一个文件夹路径。然后,该路径用于识别键并将它分配给应用程序。Spring Cloud Config 尝试根据应用程序名称和活动配置文件解析存储在文件夹中的属性。假设我们在bootstrap.yml文件中将spring.application.name属性设置为order-service,并且将spring.profiles.active运行参数设置为zone1,它将按照以下顺序查找属性源:config/order-service,zone1/, config/order-service/, config/application,zone1/, config/application/。所有前缀为config/application的文件夹都是为所有没有服务特定属性源的应用程序提供的默认配置。
管理 Consul 中的属性
将一个键添加到 Consul 中最舒适的方式是通过它的网页控制台。另一种方式是使用/kv HTTP 端点,这在章节的开始部分已经描述过了。当使用网页控制台时,你必须去到 KEY/VALUE 部分。然后,你可以查看所有当前存在的键,也可以通过提供其完整路径和值(任何格式)来创建一个新的。这个功能在下面的截图中可视化:

每一个键可能被更新或删除:

为了访问使用存储在 Consul 中的属性源的示例应用程序,你应该切换到与之前示例相同的仓库中的配置分支。我为每个微服务创建了键server.port和spring.cloud.consul.discovery.instanceZone,而不是在application.yml或bootstrap.yml文件中定义它。
客户端自定义
Consul Config 客户端可以通过以下属性进行自定义,这些属性前面带有spring.cloud.consul.config前缀:
-
enabled:通过将此属性设置为false,您可以禁用 Consul Config。如果您包含spring-cloud-starter-consul-all,它启用了发现和分布式配置,这个属性很有用。 -
fail-fast:这设置了在配置查找期间是否抛出异常或连接失败时是否记录警告。设置为true可以让应用程序正常启动。 -
prefix:这设置了所有配置值的基础文件夹。默认是/config。 -
defaultContext:这设置了所有没有特定配置的应用程序使用的文件夹名称。默认是/application。例如,如果你重写它为app,属性应该在/config/apps文件夹中搜索。 -
profileSeparator:默认情况下,一个配置文件使用逗号和一个应用名称进行分隔。这个属性允许你覆盖那个分隔符的值。例如,如果你设置它为::,你应该创建文件夹/config/order-service::zone1/。这是一个例子:
spring:
cloud:
consul:
config:
enabled: true
prefix: props
defaultContext: app
profileSeparator: '::'
有时,您可能希望将创建在 YAML 或 Properties 格式的属性块,与单独的键/值对相对比。在这种情况下,你应该将spring.cloud.consul.config.format属性设置为YAML或PROPERTIES。然后,应用程序会在带有数据键的文件夹中查找配置属性,例如,config/order-service,zone1/data,config/order-service/data,config/application,zone1/data或config/application/data。默认数据键可以通过spring.cloud.consul.config.data-key属性进行更改。
观察配置更改
在前一部分讨论的示例中,应用程序启动时加载配置。如果你希望重新加载配置,你应该向/refresh端点发送 HTTP POST请求。为了查看我们应用程序的刷新如何工作,我们修改了负责创建一些测试数据的应用程序代码片段。到目前为止,它作为带有硬编码内存对象的存储库(@Bean)提供。请看以下代码:
@Bean
CustomerRepository repository() {
CustomerRepository repository = new CustomerRepository();
repository.add(new Customer("John Scott", CustomerType.NEW));
repository.add(new Customer("Adam Smith", CustomerType.REGULAR));
repository.add(new Customer("Jacob Ryan", CustomerType.VIP));
return repository;
}
我们的目标是将此处可见的代码移动到使用 Consul 键/值功能的配置存储中。为了实现这一点,我们必须为每个对象创建三个键,键名分别为id、name和type。配置从带有repository前缀的属性加载:
@RefreshScope
@Repository
@ConfigurationProperties(prefix = "repository")
public class CustomerRepository {
private List<Customer> customers = new ArrayList<>();
public List<Customer> getCustomers() {
return customers;
}
public void setCustomers(List<Customer> customers) {
this.customers = customers;
}
// ...
}
下一步是在 Consul web 仪表板上为每个服务定义适当的键。以下是为包含Customer对象的列表的示例配置。列表在应用程序启动时初始化:

你可以更改每个属性的值。由于 Consul 具有监视键前缀的能力,更新事件会自动发送到应用程序。如果有新的配置数据,则会发布刷新事件到队列中。所有队列和交换机都在应用程序启动时由 Spring Cloud Bus 创建,该组件作为spring-cloud-starter-consul-all项目的依赖项包含在内。如果你的应用程序接收到此类事件,它将在日志中打印以下信息:
Refresh keys changed: [repository.customers[1].name]
使用 Spring Cloud Zookeeper
Spring Cloud 支持各种作为微服务架构一部分的产品。在阅读本章时,你可以了解到 Consul 作为发现工具与 Eureka 进行了比较,与 Spring Cloud Config 作为分布式配置工具进行了比较。Zookeeper 是另一个可能作为前面列出的选择之一替代的解决方案。与 Consul 一样,它可用于服务发现和分布式配置。为了在项目中启用 Spring Cloud Zookeeper,你应该包含用于服务发现功能的spring-cloud-starter-zookeeper-discovery启动器,或用于配置服务器功能的spring-cloud-starter-zookeeper-config。或者,您可以声明一个spring-cloud-starter-zookeeper-all依赖项,为应用程序激活所有功能。不要忘记包含spring-boot-starter-web,它仍然需要提供 web 功能:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-zookeeper-all</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
Zookeeper 连接设置是自动配置的。默认情况下,客户端尝试连接到localhost:2181。为了覆盖它,你应该定义spring.cloud.zookeeper.connect-string属性,并使用当前服务器网络地址:
spring:
cloud:
zookeeper:
connect-string: 192.168.99.100:2181
正如 Spring Cloud Consul 一样,Zookeeper 支持 Spring Cloud Netflix 提供的所有最受欢迎的通信库,如 Feign、Ribbon、Zuul 或 Hystrix。在我们开始样本实现之前,首先必须启动 Zookeeper 实例。
运行 Zookeeper
正如你可能会猜到的,我将使用 Docker 镜像在本地机器上启动 Zookeeper。下面的命令启动了 Zookeeper 服务器实例。由于它快速失败
,最好的方法总是重新启动它:
docker run -d --name zookeeper --restart always -p 2181:2181 zookeeper
与本领域之前讨论的解决方案,如 Consul 或 Eureka 相比,Zookeeper 没有提供简单的 RESTful API 或一个 web 管理控制台,使我们能够轻松管理它。它有一个官方的 API 绑定用于 Java 和 C。我们还可以使用其命令行界面,这可以在 Docker 容器内轻松启动。这里显示的命令使用命令行客户端启动容器,并将其连接到 Zookeeper 服务器容器:
docker run -it --rm --link zookeeper:zookeeper zookeeper zkCli.sh -server zookeeper
Zookeeper CLI 允许执行一些有用的操作,如下所示:
-
创建 znode:要使用给定路径创建 znode,请使用命令
create /path /data。 -
获取数据:命令
get /path返回与 znode 相关的数据和元数据。 -
监控 znode 的变化:如果 znode 或 znode 的子节点数据发生变化,这将显示一个通知。监控只能与
get命令一起设置。 -
设置数据:要设置 znode 数据,请使用命令
set /path /data。 -
创建 znode 的子节点:这个命令与创建单个 znode 的命令类似。唯一的区别是子 znode 的路径包括了父路径
create /parent/path/subnode/path /data。 -
列出 znode 的子节点:这可以通过
ls /path命令来显示。 -
检查状态:这可以通过命令
stat /path来查看。状态描述了指定 znode 的元数据,如时间戳或版本号。 -
删除/删除 znode:命令
rmr /path删除了所有子节点的 znode。
在那个片段中,术语znode第一次出现。在存储数据时,Zookeeper 使用树状结构,每个节点称为znode。这些 znode 的名称基于从根节点开始的路径。每个节点都有一个名字。可以使用从根节点开始的绝对路径来访问它。这个概念与 Consul 文件夹类似,并已用于在键/值存储中创建键。
服务发现
最受欢迎的 Apache Zookeeper 的 Java 客户端库是 Apache Curator。它提供了一个 API 框架和工具,使使用 Apache Zookeeper 变得更容易。它还包括常见用例和扩展的食谱,例如服务发现或 Java 8 异步 DSL。Spring Cloud Zookeeper 利用了其中一个扩展来实现服务发现。Curator 库在 Spring Cloud Zookeeper 中的使用对开发者完全透明,因此我在这里不想再详细描述。
客户端实现
客户端的使用与其他与服务发现相关的 Spring Cloud 项目相同。应用程序的主类或@Configuration类应使用@EnableDiscoveryClient注解。默认的服务名称、实例 ID 和端口分别从spring.application.name、Spring 上下文 ID 和server.port获取。
示例应用程序的源代码可以在 GitHub 仓库中找到,网址为github.com/piomin/sample-spring-cloud-zookeeper.git。本质上,它与为 Consul 引入的示例系统没有区别,除了依赖 Spring Cloud Zookeeper 发现。它仍然由四个微服务组成,它们相互通信。现在,在克隆仓库后,使用mvn clean install命令来构建它。然后,使用java -jar命令运行每个服务的活动配置文件名称,例如,java -jar --spring.profiles.active=zone1 order-service/target/order-service-1.0-SNAPSHOT.jar。
您可以通过使用 CLI 命令ls和get来查看已注册服务和实例的列表。Spring Cloud Zookeeper 默认将在/services根目录下注册所有实例。这可以通过设置spring.cloud.zookeeper.discovery.root属性来更改。您可以通过使用带有命令行客户端的 Docker 容器来查看当前注册的服务列表:

Zookeeper 依赖项
Spring Cloud Zookeeper 具有一项额外的功能,称为Zookeeper 依赖项。依赖项是指在 Zookeeper 中注册的其他应用程序,它们通过 Feign 客户端或 Spring RestTemplate进行调用。这些依赖项可以作为应用程序的属性提供。在包含spring-cloud-starter-zookeeper-discovery启动器到项目后,通过自动配置启用此功能。通过将spring.cloud.zookeeper.dependency.enabled属性设置为false可以禁用它。
Zookeeper 依赖机制的配置由spring.cloud.zookeeper.dependencies.*属性提供。以下是order-service中的bootstrap.yml文件的一个片段。这个服务与所有其他可用服务集成:
spring:
application:
name: order-service
cloud:
zookeeper:
connect-string: 192.168.99.100:2181
dependency:
resttemplate:
enabled: false
dependencies:
account:
path: account-service
loadBalancerType: ROUND_ROBIN
required: true
customer:
path: customer-service
loadBalancerType: ROUND_ROBIN
required: true
product:
path: product-service
loadBalancerType: ROUND_ROBIN
required: true
让我们仔细看看前面的配置。每个调用服务的主属性是别名,然后可以被 Feign 客户端或@LoadBalanced RestTemplate用作服务名称:
@FeignClient(name = "customer")
public interface CustomerClient {
@GetMapping("/withAccounts/{customerId}")
Customer findByIdWithAccounts(@PathVariable("customerId") Long customerId);
}
配置中的下一个非常重要的字段是路径。它设置了在 Zookeeper 中注册依赖项的路径。所以,如果该属性的值为customer-service,这意味着 Spring Cloud Zookeeper 尝试在路径/services/customer-service下查找适当的服务 znode。还有一些其他属性可以自定义客户端的行为。其中之一是loadBalancerType,用于应用负载均衡策略。我们可以选择三种可用的策略——ROUND_ROBIN、RANDOM和STICKY。我还为每个服务映射设置了required属性为true。现在,如果您的应用程序在启动时无法检测到所需的依赖项,它将无法启动。Spring Cloud Zookeeper 依赖项还允许管理 API 版本(contentTypeTemplate和versions属性)和请求头(headers属性)。
默认情况下,Spring Cloud Zookeeper 为与依赖项的通信启用RestTemplate。在可用的分支依赖中(github.com/piomin/sample-spring-cloud-zookeeper/tree/dependencies),我们使用 Feign 客户端而不是@LoadBalanced RestTemplate。为了禁用该功能,我们应该将属性spring.cloud.zookeeper.dependency.resttemplate.enabled设置为false。
分布式配置
配置管理使用 Zookeeper 与 Spring Cloud Consul Config 中描述的配置非常相似。默认情况下,所有的属性源都存储在/config文件夹中(在 Zookeeper 的术语中叫做 znode)。让我再强调一次。假设我们在bootstrap.yml文件中将spring.application.name属性设置为order-service,并且将spring.profiles.active运行参数设置为zone1,它将按照以下顺序尝试定位属性源:config/order-service,zone1/、config/order-service/、config/application,zone1/、config/application/。存储在以config/application为前缀的命名空间中的文件夹中的属性,可供所有使用 Zookeeper 进行分布式配置的应用程序使用。
要访问示例应用程序,你需要切换到github.com/piomin/sample-spring-cloud-zookeeper.git仓库的分支配置。这里可见的本地application.yml或bootstrap.yml文件中定义的配置,现在已移动到 Zookeeper 中:
---
spring:
profiles: zone1
server:
port: ${PORT:8090}
---
spring:
profiles: zone2
server:
port: ${PORT:9090}
必须使用 CLI 创建所需的 znode。以下是创建给定路径的 znode 的 Zookeeper 命令列表。我使用了create /path /data命令:

摘要
在本章中,我引导你了解了两个 Spring Cloud 项目——Consul 和 Zookeeper 的主要功能。我不仅关注 Spring Cloud 的功能,还向你讲解了如何启动、配置和维护其工具的实例。我们甚至讨论了更高级的场景,比如使用 Docker 设置由多个成员组成的集群。在那里,你有机会看到 Docker 作为开发工具真正的力量。它允许我们仅通过三个简单命令初始化一个由三个成员组成的集群,而无需任何其他配置。
当使用 Spring Cloud 时,Consul 似乎是 Eureka 的一个重要的发现服务器替代品。对于 Zookeeper 我无法这么说。正如你可能已经注意到的,我写了很多关于 Consul 而不是 Zookeeper 的内容。此外,Spring Cloud 将 Zookeeper 视为第二选择。它仍然没有实现区域机制或监视配置变化的能力,这与 Spring Cloud Consul 不同。你不应该对此感到惊讶。Consul 是为满足最新架构的需求而设计的现代解决方案,如基于微服务的系统,而 Zookeeper 是一个作为分布式环境中运行的应用程序的服务发现工具的关键/值存储。然而,如果你在你的系统中使用 Apache Foundation 堆栈,考虑这个工具是有价值的。借助这一点,你可以利用 Zookeeper 与其他 Apache 组件(如 Camel 或 Karaf)的集成,并轻松发现使用 Spring Cloud 框架创建的服务。
总之,在阅读了本章之后,你应该能够在你基于微服务的架构中使用 Spring Cloud Consul 和 Spring Cloud Zookeeper 的主要功能。你还应该知道 Spring Cloud 中所有可用发现和配置工具的主要优点和缺点,以便为你的系统选择最合适的解决方案。
第十一章:消息驱动的微服务
我们已经讨论了围绕由 Spring Cloud 提供的微服务架构的许多特性。然而,我们一直都在考虑基于同步、RESTful 的跨服务通信。正如您可能从第一章,《微服务简介》中记忆的那样,还有一些其他流行的通信方式,如发布/订阅或异步、事件驱动的点对点消息传递。在本章中,我想介绍一种与前几章中介绍的微服务不同的方法。我们将更详细地讨论如何使用 Spring Cloud Stream 来构建消息驱动的微服务。
本章我们将覆盖的主题包括:
-
与 Spring Cloud Stream 相关的术语和概念
-
使用 RabbitMQ 和 Apache Kafka 消息代理作为绑定器
-
Spring Cloud Stream 编程模型
-
绑定、生产者和消费者的高级配置
-
实现缩放、分组和分区机制
-
支持多个绑定器
学习 Spring Cloud Stream
Spring Cloud Stream 是建立在 Spring Boot 之上的。它允许我们创建独立的、生产级别的 Spring 应用程序,并使用 Spring Integration 来实现与消息代理的通信。使用 Spring Cloud Stream 创建的每个应用程序通过输入和输出通道与其他微服务集成。这些通道通过特定于中间件的绑定器实现与外部消息代理的连接。内置的绑定器实现有两个——Kafka 和 Rabbit MQ。
Spring Integration 将 Spring 编程模型扩展以支持著名的企业集成模式(EIP)。EIP 定义了一系列通常用于分布式系统中的编排的组件。您可能已经听说过诸如消息通道、路由器、聚合器或端点之类的模式。Spring Integration 框架的主要目标是提供一个简单的模型,用于构建基于 EIP 的 Spring 应用程序。如果您对 EIP 的更多细节感兴趣,请访问www.enterpriseintegrationpatterns.com/patterns/messaging/toc.html网站。
构建消息系统
我认为介绍 Spring Cloud Stream 的主要特性的最适合方式是通过一个基于微服务的示例系统。我们将轻微修改一下在前几章中讨论过的系统架构。让我回顾一下那个架构。我们的系统负责处理订单。它由四个独立的微服务组成。order-service 微服务首先与 product-service 通信,以收集所选产品的详细信息,然后与 customer-service 通信,以获取有关客户和他的账户的信息。现在,发送到 order-service 的订单将被异步处理。仍然有一个暴露的 RESTful HTTP API 端点,用于客户端提交新订单,但它们不被应用程序处理。它只保存新订单,将其发送到消息代理,然后向客户端回应订单已被批准处理。目前讨论的示例的主要目标是展示点对点通信,所以消息只会被一个应用程序,account-service 接收。以下是说明示例系统架构的图表:

在接收到新消息后,account-service 调用 product-service 暴露的方法,以找出其价格。它从账户中提取资金,然后将当前订单状态的响应发送回 order-service。该消息也是通过消息代理发送的。order-service 微服务接收到消息并更新订单状态。如果外部客户想要检查当前订单状态,它可以通过调用暴露 find 方法的端点来提供订单详情。示例应用程序的源代码可以在 GitHub 上找到(github.com/piomin/sample-spring-cloud-messaging.git)。
启用 Spring Cloud Stream
将 Spring Cloud Stream 包含在项目中的推荐方法是使用依赖管理系统。Spring Cloud Stream 在整个 Spring Cloud 框架方面有独立的发布列车管理。然而,如果我们已经在 dependencyManagement 部分声明了 Edgware.RELEASE 版本的 spring-cloud-dependencies,我们不必在 pom.xml 中声明其他内容。如果您更喜欢只使用 Spring Cloud Stream 项目,您应该定义以下部分:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream-dependencies</artifactId>
<version>Ditmars.SR2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
下一步是向项目依赖中添加 spring-cloud-stream。我还建议您至少包含 spring-cloud-sleuth 库,以提供与通过 Zuul 网关传入 order-service 的源请求相同的 traceId 发送消息:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-sleuth</artifactId>
</dependency>
为了使应用程序能够连接到消息代理,请用@EnableBinding注解标记主类。@EnableBinding注解需要一个或多个接口作为参数。您可以选择 Spring Cloud Stream 提供的三个接口之一:
-
Sink:这用于标记接收来自入站通道消息的服务。 -
Source:用于向出站通道发送消息。 -
Processor:如果您需要入站通道和出站通道,可以使用它,因为它扩展了Source和Sink接口。因为order-service发送消息,以及接收消息,所以它的主类被用@EnableBinding(Processor.class)注解标记。
这是order-service的main类,它启用了 Spring Cloud Stream 绑定:
@SpringBootApplication
@EnableDiscoveryClient
@EnableBinding(Processor.class)
public class OrderApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(OrderApplication.class).web(true).run(args);
}
}
声明和绑定通道
得益于 Spring Integration 的使用,应用程序与项目中包含的消息代理实现是独立的。Spring Cloud Stream 会自动检测并使用类路径中找到的绑定器。这意味着我们可以选择不同类型的中间件,并用相同的代码使用它。所有中间件特定的设置都可以通过 Spring Boot 支持的格式(如应用程序参数、环境变量,或仅仅是application.yml文件)的外部配置属性来覆盖。正如我之前提到的,Spring Cloud Stream 为 Kafka 和 Rabbit MQ 提供了绑定器实现。要包括对 Kafka 的支持,您需要将以下依赖项添加到项目中:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-stream-kafka</artifactId>
</dependency>
个人而言,我更喜欢 RabbitMQ,但在这章节,我们将为 RabbitMQ 和 Kafka 都创建一个示例。因为我们已经讨论过 RabbitMQ 的功能,我将从基于 RabbitMQ 的示例开始:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-stream-rabbit</artifactId>
</dependency>
在启用 Spring Cloud Stream 并包括绑定器实现之后,我们可以创建发送者和监听者。让我们从负责将新订单消息发送到代理的生产者开始。这通过order-service中的OrderSender实现,它使用Outputbean 来发送消息:
@Service
public class OrderSender {
@Autowired
private Source source;
public boolean send(Order order) {
return this.source.output().send(MessageBuilder.withPayload(order).build());
}
}
这个 bean 被控制器调用,控制器暴露了一个允许提交新订单的 HTTP 方法:
@RestController
public class OrderController {
private static final Logger LOGGER = LoggerFactory.getLogger(OrderController.class);
private ObjectMapper mapper = new ObjectMapper();
@Autowired
OrderRepository repository;
@Autowired
OrderSender sender;
@PostMapping
public Order process(@RequestBody Order order) throws JsonProcessingException {
Order o = repository.add(order);
LOGGER.info("Order saved: {}", mapper.writeValueAsString(order));
boolean isSent = sender.send(o);
LOGGER.info("Order sent: {}", mapper.writeValueAsString(Collections.singletonMap("isSent", isSent)));
return o;
}
}
包含关于订单信息的消息已经发送到消息代理。现在,它应该被account-service接收。使这成为可能,我们必须声明接收者,它正在监听来自消息代理上创建的队列的消息。为了接收带有订单数据的消息,我们只需用@StreamListener注解来标记接受Order对象作为参数的方法:
@SpringBootApplication
@EnableDiscoveryClient
@EnableBinding(Processor.class)
public class AccountApplication {
@Autowired
AccountService service;
public static void main(String[] args) {
new SpringApplicationBuilder(AccountApplication.class).web(true).run(args);
}
@Bean
@StreamListener(Processor.INPUT)
public void receiveOrder(Order order) throws JsonProcessingException {
service.process(order);
}
}
现在您可以启动示例应用程序了。但是,还有一个重要细节尚未提到。这两个应用程序都尝试连接到运行在 localhost 上的 RabbitMQ,并且它们都将相同的交换机作为输入或输出。这是一个问题,因为order-service将消息发送到输出交换机,而account-service监听其输入交换机传入的消息。这些是不同的交换机,但首先事情要一件一件来做。让我们先从运行一个消息代理开始。
使用 RabbitMQ 代理自定义连接
在之前的章节中,我们已经使用 RabbitMQ 的 Docker 镜像启动了 RabbitMQ 代理,因此值得提醒这个命令。它启动了一个带有 RabbitMQ 的独立 Docker 容器,端口为5672,以及其 UI 网页控制台,端口为15672:
docker run -d --name rabbit -p 15672:15672 -p 5672:5672 rabbitmq:management
默认的 RabbitMQ 地址应该在application.yml文件中使用spring.rabbit.*属性进行覆盖:
spring:
rabbitmq:
host: 192.168.99.100
port: 5672
默认情况下,Spring Cloud Stream 为通信创建了一个主题交换机。这种类型的交换机更适合发布/订阅交互模型。我们可以使用exchangeType属性来覆盖它,如application.yml的片段所示:
spring:
cloud:
stream:
rabbit:
bindings:
output:
producer:
exchangeType: direct
input:
consumer:
exchangeType: direct
相同的配置设置应该提供给order-service和account-service。您不需要手动创建任何交换机。如果不存在,应用程序在启动时会自动创建。否则,应用程序只是绑定到该交换机。默认情况下,它为@Input通道创建名为 input 的交换机,为@Output通道创建名为 output 的交换机。这些名称可以通过spring.cloud.stream.bindings.output.destination和spring.cloud.stream.bindings.input.destination属性进行覆盖,其中 input 和 output 是通道的名称。这个配置选项不仅仅是 Spring Cloud Stream 功能的一个很好的补充,而且是用于跨服务通信中关联输入和输出目的地的一个关键设置。解释为什么会出现这种情况非常简单。在我们的示例中,order-service是消息源应用程序,因此它将消息发送到输出通道。另一方面,account-service监听输入通道传入的消息。如果order-service输出通道和account-service输入通道不指向代理上的相同目的地,它们之间的通信将失败。总之,我决定使用名为orders-out和orders-in的目标,并为order-service提供了以下配置:
spring:
cloud:
stream:
bindings:
output:
destination: orders-out
input:
destination: orders-in
对于account-service,类似的配置设置是反向的:
spring:
cloud:
stream:
bindings:
output:
destination: orders-in
input:
destination: orders-out
两个应用程序启动后,您可以通过访问 http://192.168.99.100:15672(quest/guest)的 RabbitMQ 代理的 Web 管理控制台,轻松查看声明的交换机列表。以下是为测试目的创建的两个目的地:

默认情况下,Spring Cloud Stream 提供了一个输入消息通道和一个输出消息通道。我们可以想象一种情况,我们的系统需要为每种类型的消息通道设置多个目的地。让我们回到示例系统架构中一会儿,考虑每个订单都由两个其他微服务异步处理的情况。到目前为止,只有 account-service 在监听来自 order-service 的传入事件。在当前示例中,product-service 将是传入订单的接收者。在该场景中,其主要目标是管理可用产品的数量,并根据订单详情减少产品数量。它需要我们在 order-service 内部定义两个输入和输出消息通道,因为基于直接 RabbitMQ 交换的点对点通信,每个消息可能由一个消费者处理。
在这种情况下,我们应该声明两个带有 @Input 和 @Output 方法的接口。每个方法都必须返回一个 channel 对象。Spring Cloud Stream 为出站通信提供了一个可绑定消息组件——MessageChannel,以及其扩展,SubscribableChannel,用于入站通信。以下是与 product-service 交互的接口定义。已经为与 account-service 消息通信创建了类似接口:
public interface ProductOrder {
@Input
SubscribableChannel productOrdersIn();
@Output
MessageChannel productOrdersOut();
}
下一步是通过对主类使用 @EnableBinding(value={AccountOrder.class, ProductOrder.class}) 注解来激活应用程序中声明的组件。现在,您可以使用它们的名称在配置属性中引用这些通道,例如,spring.cloud.stream.bindings.productOrdersOut.destination=product-orders-in。每个通道名称可以通过在使用 @Input 和 @Output 注解时指定通道名称来自定义,如下例所示:
public interface ProductOrder {
@Input("productOrdersIn")
SubscribableChannel ordersIn();
@Output("productOrdersOut")
MessageChannel ordersOut();
}
基于自定义接口的声明,Spring Cloud Stream 将生成实现该接口的 bean。但是,它仍然必须在负责发送消息的 bean 中被访问。与之前的示例相比,直接注入绑定通道会更方便。以下是当前产品订单发送者的 bean 实现。还有一个类似的实现,用于向 account-service 发送消息:
@Service
public class ProductOrderSender {
@Autowired
private MessageChannel output;
@Autowired
public SendingBean(@Qualifier("productOrdersOut") MessageChannel output) {
this.output = output;
}
public boolean send(Order order) {
return this.output.send(MessageBuilder.withPayload(order).build());
}
}
每个消息通道的自定义接口也应提供给目标服务。监听器应绑定到消息代理上的正确消息通道和目的地:
@StreamListener(ProductOrder.INPUT)
public void receiveOrder(Order order) throws JsonProcessingException {
service.process(order);
}
与其他 Spring Cloud 项目的集成
你可能已经注意到,示例系统混合了不同的服务间通信风格。有些微服务使用典型的 RESTful HTTP API,而其他的则使用消息代理。也没有反对在单个应用程序中混合不同的通信风格。例如,你可以将spring-cloud-starter-feign添加到带有 Spring Cloud Stream 的项目中,并用@EnableFeignClients注解启用它。在我们的示例系统中,这两种不同的通信风格结合了account-service,它通过消息代理与order-service集成,并通过 REST API 与product-service通信。以下是account-service模块中product-service的 Feign 客户端实现:
@FeignClient(name = "product-service")
public interface ProductClient {
@PostMapping("/ids")
List<Product> findByIds(@RequestBody List<Long> ids);
}
还有其他好消息。得益于 Spring Cloud Sleuth,通过网关进入系统的一个单一请求期间交换的所有消息都有相同的traceId。无论是同步的 REST 通信,还是异步的消息传递,你都可以很容易地使用标准日志文件,或像 Elastic Stack 这样的日志聚合工具,在微服务之间跟踪和关联日志。
我认为现在是一个运行和测试我们的示例系统的好时机。首先,我们必须使用mvn clean install命令构建整个项目。要访问包含两个微服务,分别在两个不同的交换机上监听消息的代码示例,你应该切换到advanced分支(github.com/piomin/sample-spring-cloud-messaging/tree/advanced). 你应该启动那里所有的应用程序——网关、发现以及三个微服务(account-service, order-service, product-service)。目前讨论的案例假设我们已经使用 Docker 容器启动了 RabbitMQ、Logstash、Elasticsearch 和 Kibana。关于如何使用 Docker 镜像在本地运行 Elastic Stack 的详细说明,请参考第九章,分布式日志和跟踪。下面的图表详细显示了系统的架构:

在运行所有必要的应用程序和工具后,我们可以进行测试。以下是可以通过 API 网关发送到order-service的示例请求:
curl -H "Content-Type: application/json" -X POST -d '{"customerId":1,"productIds":[1,3,4],"status":"NEW"}' http://localhost:8080/api/order
当我第一次运行测试时,按照前几节的描述配置应用程序,它不起作用。我可以理解,你们中的一些人可能会有些困惑,因为通常它是用默认设置进行测试的。为了使其正常运行,我还需要在application.yml中添加以下属性:spring.cloud.stream.rabbit.bindings.output.producer.routingKeyExpression: '"#"'. 它将默认生产者的路由键设置为自动在应用程序启动期间创建的交换机路由键,以符合输出交换定义。在下面的屏幕截图中,你可以看到输出交换定义之一:

在前面描述的修改之后,测试应该成功完成。微服务打印的日志通过 traceId 相互关联。我在 logback-spring.xml 中稍微修改了默认的 Sleuth 日志格式,现在它是这样配置的——%d{HH:mm:ss.SSS} %-5level [%X{X-B3-TraceId:-},%X{X-B3-SpanId:-}] %msg%n。在发送 order-service 测试请求后,记录以下信息:
12:34:48.696 INFO [68038cdd653f7b0b,68038cdd653f7b0b] Order saved: {"id":1,"status":"NEW","price":0,"customerId":1,"accountId":null,"productIds":[1,3,4]}
12:34:49.821 INFO [68038cdd653f7b0b,68038cdd653f7b0b] Order sent: {"isSent":true}
正如您所看到的,account-service 也使用相同的日志格式,并打印出与 order-service 相同的 traceId:
12:34:50.079 INFO [68038cdd653f7b0b,23432d962ec92f7a] Order processed: {"id":1,"status":"NEW","price":0,"customerId":1,"accountId":null,"productIds":[1,3,4]}
12:34:50.332 INFO [68038cdd653f7b0b,23432d962ec92f7a] Account found: {"id":1,"number":"1234567890","balance":50000,"customerId":1}
12:34:52.344 INFO [68038cdd653f7b0b,23432d962ec92f7a] Products found: [{"id":1,"name":"Test1","price":1000},{"id":3,"name":"Test3","price":2000},{"id":4,"name":"Test4","price":3000}]
在单个事务期间生成的所有日志可以使用 Elastic Stack 进行聚合。例如,您可以根据 X-B3-TraceId 字段过滤条目,例如 9da1e5c83094390d:

发布/订阅模型
创建 Spring Cloud Stream 项目的主要动机实际上是为了支持持久的发布/订阅模型。在前面的部分,我们已经讨论了微服务之间的点对点通信,这只是额外的特性。然而,无论我们决定使用点对点还是发布/订阅模型,编程模型都是相同的。
在发布/订阅通信中,数据通过共享主题进行广播。它简化了生产者和消费者的复杂性,并且允许在没有更改流程的情况下,轻松地向现有拓扑添加新应用程序。这一点在前面展示的系统示例中可以明显看到,我们决定向由源微服务生成的事件添加第二个应用程序。与初始架构相比,我们不得不为每个目标应用程序定义自定义消息通道。通过队列进行直接通信,消息只能被一个应用程序实例消费,因此,这种解决方案是必要的。发布/订阅模型的使用简化了架构。
运行示例系统
对于发布/订阅模型,示例应用程序的开发比点对点通信要简单。我们不需要重写任何默认消息通道以实现与多个接收者的交互。与最初示例相比,我们只需要稍改配置设置。因为 Spring Cloud Stream 默认绑定到主题,所以我们不需要重写输入消息通道的 exchangeType。如您在下面的配置片段中所见,我们仍然在使用点对点通信发送对 order-service 的响应。如果我们仔细想想,这是有道理的。order-service 微服务发送的消息必须被 account-service 和 product-service 接收,而它们的响应只针对 order-service:
spring:
application:
name: product-service
rabbitmq:
host: 192.168.99.100
port: 5672
cloud:
stream:
bindings:
output:
destination: orders-in
input:
destination: orders-out
rabbit:
bindings:
output:
producer:
exchangeType: direct
routingKeyExpression: '"#"'
产品-服务的主要处理方法的逻辑非常简单。它只需要从接收到的订单中找到所有的productIds,为每一个它们改变存储产品的数量,然后将响应发送给order-service:
@Autowired
ProductRepository productRepository;
@Autowired
OrderSender orderSender;
public void process(final Order order) throws JsonProcessingException {
LOGGER.info("Order processed: {}", mapper.writeValueAsString(order));
for (Long productId : order.getProductIds()) {
Product product = productRepository.findById(productId);
if (product.getCount() == 0) {
order.setStatus(OrderStatus.REJECTED);
break;
}
product.setCount(product.getCount() - 1);
productRepository.update(product);
LOGGER.info("Product updated: {}", mapper.writeValueAsString(product));
}
if (order.getStatus() != OrderStatus.REJECTED) {
order.setStatus(OrderStatus.ACCEPTED);
}
LOGGER.info("Order response sent: {}", mapper.writeValueAsString(Collections.singletonMap("status", order.getStatus())));
orderSender.send(order);
}
要访问当前示例,您只需切换到publish_subscribe分支,可在github.com/piomin/sample-spring-cloud-messaging/tree/publish_subscribe找到。然后,您应该构建父项目并像前一个示例一样运行所有服务。如果您想测试,直到您只有一个运行的account-service和product-service实例,所有都正常工作。让我们来讨论那个问题。
扩展和分组
当谈论基于微服务的架构时,可扩展性总是作为其主要优点之一被提出。通过创建给定应用程序的多个实例来扩展系统的能力非常重要。这样做时,应用程序的不同实例被放置在竞争性消费者关系中,其中只有一个实例预期处理给定消息。对于点对点通信,这不是问题,但在发布-订阅模型中,消息被所有接收者消费,这可能是一个挑战。
运行多个实例
对于扩展微服务实例的数量,Spring Cloud Stream 的可用性是围绕其主要概念之一。然而,这个想法背后并没有魔法。使用 Spring Cloud Stream 运行应用程序的多个实例非常容易。其中一个原因是消息代理的原生支持,它被设计用来处理许多消费者和大量流量。
在我们的案例中,所有的消息微服务也都暴露了 RESTful HTTP API,因此首先我们必须为每个实例定制服务器端口。我们之前已经进行了这样的操作。我们还可以考虑设置两个 Spring Cloud Stream 属性,spring.cloud.stream.instanceCount和spring.cloud.stream.instanceIndex。得益于它们,每个微服务实例都能够接收到关于有多少其他相同应用程序的实例被启动以及它自己的实例索引的信息。只有在您想要启用分区特性时,才需要正确配置这些属性。我稍后会详细讲解这个机制。现在,让我们来看看扩展应用程序的配置设置。account-service和product-service都为运行应用程序的多个实例定义了两个配置文件。我们在那里定制了服务器的 HTTP 端口、数量和实例索引:
---
spring:
profiles: instance1
cloud:
stream:
instanceCount: 2
instanceIndex: 0
server:
port: ${PORT:8091}
---
spring:
profiles: instance2
cloud:
stream:
instanceCount: 2
instanceIndex: 1
server:
port: ${PORT:9091}
构建父项目后,您可以运行应用程序的两个实例。每个实例在启动时都分配有属性,例如,java -jar --spring.profiles.active=instance1 target/account-service-1.0-SNAPSHOT.jar。如果您向order-service端点POST /发送测试请求,新订单将被转发到 RabbitMQ 主题交换,以便被连接到该交换的account-service和product-service接收。问题在于,消息被每个服务的所有实例接收,这并不是我们想要实现的效果。在这里,分组机制提供了帮助。
消费者组
我们的目标很明确。我们有多个微服务消费同一个主题的消息。应用程序的不同实例处于竞争性消费者关系中,但只有一个实例应该处理给定的消息。Spring Cloud Stream 引入了消费者组的概念来模拟这种行为。要激活这种行为,我们应该设置一个名为spring.cloud.stream.bindings.<channelName>.group的属性,并指定一个组名。设置后,所有订阅给定目的地的组都会接收到发布的数据副本,但每个组中只有一个成员会从那个目的地接收并处理消息。在我们这个案例中,有两个组。首先,为所有account-service实例命名 account,其次,为名为 product 的product-service。
这是account-service当前的绑定配置。orders-in目的地是为与order-service直接通信而创建的队列,所以只有orders-out按服务名称分组。为product-service准备了类似的配置:
spring:
cloud:
stream:
bindings:
output:
destination: orders-in
input:
destination: orders-out
group: account
第一个区别体现在为 RabbitMQ 交换自动创建的队列的名称上。现在,它不是一个随机生成的名称,如orders-in.anonymous.qNxjzDq5Qra-yqHLUv50PQ,而是一个由目的地和组名组成的确定字符串。下面的屏幕截图显示了目前在 RabbitMQ 上存在的所有队列:

您可以自行重新测试以验证消息是否仅被同一组中的一个应用程序接收。然而,您无法确定哪个实例会处理传入的消息。为了确定这一点,您可以使用分区机制。
分区
Spring Cloud Stream 为应用程序的多个实例之间的数据分区提供了支持。在典型用例中,目的地被视为被分成不同的分区。每个生产者,在向多个消费者实例发送消息时,确保数据通过配置的字段来标识,以强制由同一个消费者实例处理。
为了启用您应用程序的分区功能,您必须在生产者配置设置中定义partitionKeyExpression或partitionKeyExtractorClass属性,以及partitionCount。以下是为您的应用程序可能提供的示例配置:
spring.cloud.stream.bindings.output.producer.partitionKeyExpression=payload.customerId
spring.cloud.stream.bindings.output.producer.partitionCount=2
分区机制还需要在消费者侧设置spring.cloud.stream.instanceCount和spring.cloud.stream.instanceIndex属性。它还需要通过将spring.cloud.stream.bindings.input.consumer.partitioned属性设置为true来显式启用。实例索引负责识别特定实例从哪个唯一分区接收数据。通常,生产者侧的partitionCount和消费者侧的instanceCount应该相等。
让我来向您介绍由 Spring Cloud Stream 提供的分区机制。首先,它根据partitionKeyExpression计算分区键,该表达式针对出站消息或实现PartitionKeyExtractorStrategy接口的实现进行评估,该接口定义了提取消息键的算法。一旦计算出消息键,目标分区就被确定为零到partitionCount - 1之间的一个值。默认的计算公式是key.hashCode() % partitionCount。可以通过设置partitionSelectorExpression属性,或通过实现org.springframework.cloud.stream.binder.PartitionSelectorStrategy接口来定制它。计算出的键与消费者侧的instanceIndex相匹配。
我认为分区的主要概念已经解释清楚了。接下来让我们看一个示例。以下是product-service的输入通道当前的配置(与account-service设置账户组名相同):
spring:
cloud:
stream:
bindings:
input:
consumer:
partitioned: true
destination: orders-out
group: product
在我们每个从主题交换中消费数据的微服务中,都有两个运行实例。在order-service内部也为生产者设置了两个分区。消息键是基于Order对象中的customerId字段计算得出的。索引为0的分区专门用于customerId字段中偶数的订单,而索引为1的分区则用于奇数。
实际上,RabbitMQ 并没有对分区提供原生支持。有趣的是,Spring Cloud Stream 是如何使用 RabbitMQ 实现分区过程的。下面是一张说明在 RabbitMQ 中创建的交换器绑定的列表的屏幕截图。正如你所看到的,为交换器定义了两个路由键——orders-out-0和orders-out-1:

例如,如果你在一个 JSON 消息中发送一个customerId等于 1 的订单,例如{"customerId": 1,"productIds": [4],"status": "NEW"},它总是会由instanceIndex=1的实例处理。可以通过应用程序日志或使用 RabbitMQ 网页控制台进行检查。下面是一个每个队列的消息率的图表,其中customerId=1的消息已经发送了几次:

配置选项
Spring Cloud Stream 的配置设置可以通过 Spring Boot 支持的任何机制进行覆盖,例如应用程序参数、环境变量以及 YAML 或属性文件。它定义了一系列通用的配置选项,可以应用于所有绑定器。然而,还有一些特定于应用程序使用的消息代理的其他属性。
Spring Cloud Stream 属性
当前组的属性适用于整个 Spring Cloud Stream 应用程序。以下所有属性都带有spring.cloud.stream前缀:
| 名称 | 默认值 | 描述 |
|---|---|---|
instanceCount |
1 |
应用程序正在运行的实例数量。有关详细信息,请参阅扩展和分组部分。 |
instanceIndex |
0 |
应用程序的实例索引。有关详细信息,请参阅扩展和分组部分。 |
dynamicDestinations |
- | 可以动态绑定的目的地列表。 |
defaultBinder |
- | 如果有多个绑定器定义,则使用的默认绑定器。有关详细信息,请参阅多个绑定器部分。 |
overrideCloudConnectors |
false |
仅当云处于活动状态且 Spring Cloud Connectors 在类路径上时才使用。当设置为true时,绑定器完全忽略已绑定的服务,并依赖于spring.rabbitmq.*或spring.kafka.*的 Spring Boot 属性。 |
绑定属性
下一组属性与消息通道有关。在 Spring Cloud 命名法中,这些是绑定属性。它们只能分配给消费者、生产者,或同时分配给两者。以下是这些属性及其默认值和描述:
| 名称 | 默认值 | 描述 |
|---|---|---|
destination |
- | 配置为消息通道的消息代理的目标目的地名称。如果通道只被一个消费者使用,它可以被指定为以逗号分隔的目的地列表。 |
group |
null |
通道的消费者组。有关详细信息,请参阅扩展和分组部分。 |
contentType |
null |
给定通道上交换消息的内容类型。例如,我们可以将其设置为application/json。然后,从该应用程序发送的所有对象都会自动转换为 JSON 字符串。 |
binder |
null |
通道使用的默认绑定器。有关详细信息,请参阅多个绑定器部分。 |
消费者
下面的属性列表仅适用于输入绑定,并且必须以spring.cloud.stream.bindings.<channelName>.consumer为前缀。我将只指示其中最重要的几个:
| 名称 | 默认值 | 描述 |
|---|---|---|
concurrency |
1 |
每个单一输入通道的消费者数量 |
partitioned |
false |
它使能够从分区生产者接收数据 |
headerMode |
embeddedHeaders |
如果设置为raw,则禁用输入上的头部解析 |
maxAttempts |
3 |
如果消息处理失败,则重试的次数。将此选项设置为1将禁用重试机制 |
生产者
下面的属性绑定仅适用于输出绑定,并且必须以spring.cloud.stream.bindings.<channelName>.producer为前缀。我也会只指示其中最重要的几个:
| 名称 | 默认值 | 描述 |
|---|---|---|
requiredGroups |
- | 必须在与消息代理上创建的分隔的组列表 |
headerMode |
embeddedHeaders |
如果设置为raw,则禁用输入上的头部解析 |
useNativeEncoding |
false |
如果设置为true,则出站消息由客户端库直接序列化 |
errorChannelEnabled |
false |
如果设置为true,则将失败消息发送到目的地的错误通道 |
高级编程模型
Spring Cloud Stream 编程模型的基础知识已经介绍过了,还包括点对点和发布/订阅通信的示例。让我们讨论一些更高级的示例特性。
发送消息
在本章中 presented 的所有示例中,我们通过 RESTful API 发送订单以进行测试。然而,我们很容易通过在应用程序内部定义消息源来创建一些测试数据。下面是一个使用@Poller每秒生成一条消息并将其发送到输出通道的 bean:
@Bean
@InboundChannelAdapter(value = Source.OUTPUT, poller = @Poller(fixedDelay = "1000", maxMessagesPerPoll = "1"))
public MessageSource<Order> ordersSource() {
Random r = new Random();
return () -> new GenericMessage<>(new Order(OrderStatus.NEW, (long) r.nextInt(5), Collections.singletonList((long) r.nextInt(10))));
}
转换
正如您可能记得的,account-service和product-service一直在从order-service接收事件,然后发送回响应消息。我们创建了OrderSenderbean,它负责准备响应载荷并将其发送到输出通道。结果是,如果我们在方法中返回响应对象并将其注解为@SentTo,则实现可能更简单:
@StreamListener(Processor.INPUT)
@SendTo(Processor.OUTPUT)
public Order receiveAndSendOrder(Order order) throws JsonProcessingException {
LOGGER.info("Order received: {}", mapper.writeValueAsString(order));
return service.process(order);
}
我们甚至可以想象这样一个实现,比如下面的实现,而不使用@StreamListener。变换器模式负责改变对象的形式。在这种情况下,它修改了两个order字段—status和price:
@EnableBinding(Processor.class)
public class OrderProcessor {
@Transformer(inputChannel = Processor.INPUT, outputChannel = Processor.OUTPUT)
public Order process(final Order order) throws JsonProcessingException {
LOGGER.info("Order processed: {}", mapper.writeValueAsString(order));
// ...
products.forEach(p -> order.setPrice(order.getPrice() + p.getPrice()));
if (order.getPrice() <= account.getBalance()) {
order.setStatus(OrderStatus.ACCEPTED);
account.setBalance(account.getBalance() - order.getPrice());
} else {
order.setStatus(OrderStatus.REJECTED);
}
return order;
}
}
条件性地接收消息
假设我们希望对同一消息通道传入的消息进行不同的处理,我们可以使用条件分发。Spring Cloud Stream 支持根据条件将消息分发到输入通道上注册的多个@StreamListener方法。这个条件是一个Spring 表达式语言(SpEL)表达式,定义在@StreamListener注解的condition属性中:
public boolean send(Order order) {
Message<Order> orderMessage = MessageBuilder.withPayload(order).build();
orderMessage.getHeaders().put("processor", "account");
return this.source.output().send(orderMessage);
}
这是一个定义了两个注有@StreamListener注解的方法的示例,它们监听同一个主题。其中一个只处理来自account-service的消息,而第二个只处理product-service的消息。传入的消息根据其头部的processor名称进行分发:
@SpringBootApplication
@EnableDiscoveryClient
@EnableBinding(Processor.class)
public class OrderApplication {
@StreamListener(target = Processor.INPUT, condition = "headers['processor']=='account'")
public void receiveOrder(Order order) throws JsonProcessingException {
LOGGER.info("Order received from account: {}", mapper.writeValueAsString(order));
// ...
}
@StreamListener(target = Processor.INPUT, condition = "headers['processor']=='product'")
public void receiveOrder(Order order) throws JsonProcessingException {
LOGGER.info("Order received from product: {}", mapper.writeValueAsString(order));
// ...
}
}
使用 Apache Kafka
在讨论 Spring Cloud 与消息代理的集成时,我提到了 Apache Kafka 几次。然而,到目前为止,我们还没有基于该平台运行任何示例。事实上,当与 Spring Cloud 项目一起使用时,RabbitMQ 往往是最受欢迎的选择,但 Kafka 也值得我们关注。它相对于 RabbitMQ 的一个优势是对分区的大力支持,这是 Spring Cloud Stream 最重要的特性之一。
Kafka 不是一个典型的消息代理。它更像是一个分布式流处理平台。它的主要特性是允许您发布和订阅记录流。它特别适用于实时流应用程序,这些应用程序转换或对数据流做出反应。它通常作为由一个或多个服务器组成的集群运行,并将记录流存储在主题中。
运行 Kafka
不幸的是,没有官方的 Apache Kafka Docker 镜像。然而,我们可以使用一个非官方的镜像,例如 Spotify 共享的镜像。与其他可用的 Kafka Docker 镜像相比,这个镜像在同一个容器中同时运行 Zookeeper 和 Kafka。以下是启动 Kafka 并将其暴露在端口9092上的 Docker 命令。Zookeeper 也外部可访问端口2181:
docker run -d --name kafka -p 2181:2181 -p 9092:9092 --env ADVERTISED_HOST=192.168.99.100 --env ADVERTISED_PORT=9092 spotify/kafka
定制应用程序设置
要为应用程序启用 Apache Kafka,请将spring-cloud-starter-stream-kafka启动器包括在依赖项中。我们当前的示例与在发布/订阅模型章节中介绍的 RabbitMQ 的发布/订阅、带分组和分区的示例非常相似。唯一的区别在于依赖项和配置设置。
Spring Cloud Stream 会自动检测并使用类路径中找到的绑定器。连接设置可以通过spring.kafka.*属性进行覆盖。在我们的案例中,我们只需要将自动配置的 Kafka 客户端地址更改为 Docker 机器的地址192.168.99.100。对于由 Kafka 客户端使用的 Zookeeper 也应进行相同的修改:
spring:
application:
name: order-service
kafka:
bootstrap-servers: 192.168.99.100:9092
cloud:
stream:
bindings:
output:
destination: orders-out
producer:
partitionKeyExpression: payload.customerId
partitionCount: 2
input:
destination: orders-in
kafka:
binder:
zkNodes: 192.168.99.100
启动发现、网关以及所有必需的微服务实例之后,您可以执行与之前示例相同的测试。如果配置正确,您在应用启动过程中在日志中应看到以下片段。测试结果与基于 RabbitMQ 的示例完全相同:
16:58:30.008 INFO [,] Discovered coordinator 192.168.99.100:9092 (id: 2147483647 rack: null) for group account.
16:58:30.038 INFO [,] Successfully joined group account with generation 1
16:58:30.039 INFO [,] Setting newly assigned partitions [orders-out-0, orders-out-1] for group account
16:58:30.081 INFO [,] partitions assigned:[orders-out-0, orders-out-1]
支持 Kafka Streams API
Spring Cloud Stream Kafka 提供了一个专门为 Kafka Streams 绑定设计的绑定器。通过这个绑定器,应用程序可以利用 Kafka Streams API。为了为您的应用程序启用此功能,请在您的项目中包含以下依赖项:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream-binder-kstream</artifactId>
</dependency>
Kafka Streams API 提供了高级流 DSL。可以通过声明接收 KStream 接口作为参数的 @StreamListener 方法来访问它。KStream 为流处理提供了些有用的方法,这些方法在其他流式 API 中也很知名,如 map、flatMap、join 或 filter。还有一些 Kafka Stream 特有的方法,例如 to(...)(用于将流发送到主题)或 through(...)(与 to 相同,但还会从主题创建一个新的 KStream 实例):
@SpringBootApplication
@EnableBinding(KStreamProcessor.class)
public class AccountApplication {
@StreamListener("input")
@SendTo("output")
public KStream<?, Order> process(KStream<?, Order> input) {
// ..
}
public static void main(String[] args) {
SpringApplication.run(AccountApplication.class, args);
}
}
配置属性
一些 Spring Cloud 针对 Kafka 的配置设置在讨论示例应用程序实现时已经介绍过。下面是一个包含为自定义 Apache Kafka 绑定器设置的最重要属性的表格,所有这些属性都带有 spring.cloud.stream.kafka.binder 前缀:
| Name | 默认值 | 描述 |
|---|---|---|
brokers |
localhost |
带或不带端口信息的经纪人列表,以逗号分隔。 |
defaultBrokerPort |
9092 |
如果没有使用brokers属性定义端口,则设置默认端口。 |
zkNodes |
localhost |
带或不带端口信息的 ZooKeeper 节点列表,以逗号分隔。 |
defaultZkPort |
2181 |
如果没有使用 zkNodes 属性定义端口,则设置默认 ZooKeeper 端口。 |
configuration |
- | Kafka 客户端属性的键/值映射。它适用于绑定器创建的所有客户端。 |
headers |
- | 将由绑定器传递的自定义头列表。 |
autoCreateTopics |
true |
如果设置为true,则绑定器会自动创建新主题。 |
autoAddPartitions |
false |
如果设置为true,则绑定器会自动创建新的分区。 |
多个绑定器
在 Spring Cloud Stream 命名约定中,可以实现以提供对外部中间件的物理目的地连接的接口称为绑定器。目前,有两大内置绑定器实现——Kafka 和 RabbitMQ。如果您想要提供一个自定义的绑定器库,关键的接口是一个将输入和输出连接到外部中间件的策略的抽象,称为 Binder,有两个方法——bindConsumer 和 bindProducer。有关更多详细信息,请参考 Spring Cloud Stream 规范。
对我们来说重要的是,能够在单个应用程序中使用多个绑定器。你甚至可以混合不同的实现,例如,RabbitMQ 和 Kafka。Spring Cloud Stream 在绑定过程中依赖于 Spring Boot 的自动配置。可用的实现自动使用。如果您想要同时使用默认的绑定器,请将以下依赖项包含在项目中:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream-binder-rabbit</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream-binder-kafka</artifactId>
</dependency>
如果在类路径中找到了多个绑定器,应用程序必须检测出哪个应该用于特定的通道绑定。我们可以通过spring.cloud.stream.defaultBinder属性全局配置默认的绑定器,或者每个通道分别通过spring.cloud.stream.bindings.<channelName>.binder属性配置。现在,我们回到我们的示例中,在那里配置多个绑定器。我们为account-service和order-service之间的直接通信定义 RabbitMQ,为order-service与其他微服务之间的发布/订阅模型定义 Kafka。
以下是在publish_subscribe分支中为account-service提供的等效配置(github.com/piomin/sample-spring-cloud-messaging/tree/publish_subscribe),但基于两种不同的绑定器:
spring:
cloud:
stream:
bindings:
output:
destination: orders-in
binder: rabbit1
input:
consumer:
partitioned: true
destination: orders-out
binder: kafka1
group: account
rabbit:
bindings:
output:
producer:
exchangeType: direct
routingKeyExpression: '"#"'
binders:
rabbit1:
type: rabbit
environment:
spring:
rabbitmq:
host: 192.168.99.100
kafka1:
type: kafka
environment:
spring:
kafka:
bootstrap-servers: 192.168.99.100:9092
概要
Spring Cloud Stream 与其他所有 Spring Cloud 项目相比可以被视为一个单独的类别。它经常与其他项目关联,而这些项目目前由 Pivotal Spring Cloud Data Flow 强烈推广。这是一个用于构建数据集成和实时数据处理管道的工具包。然而,这是一个庞大的主题,更是一个需要单独讨论的书本内容。
更具体地说,Spring Cloud Stream 提供了异步消息传递的支持,这可以通过使用 Spring 注解风格轻松实现。我认为对于你们中的某些人来说,这种服务间通信的风格不如 RESTful API 模型明显。因此,我专注于向你们展示使用 Spring Cloud Stream 的点对点和发布/订阅通信的示例。我还描述了这两种消息传递风格之间的区别。
发布/订阅模型并非新事物,但得益于 Spring Cloud Stream,它可以轻松地包含在基于微服务的系统中。本章中还描述了一些关键概念,例如消费者组或分区。阅读后,你应该能够实现基于消息模型的微服务,并将它们与 Spring Cloud 库集成,以提供日志记录、跟踪,或者只是将它们作为现有 REST-based 微服务系统的一部分部署。
第十二章:保护 API
安全性是关于微服务架构的最常讨论的问题之一。对于所有安全关注,总是有一个主要问题——网络。在微服务中,通常网络通信比单体应用程序多,因此应该重新考虑认证和授权的方法。传统的系统通常在边界处进行安全保护,然后允许前端服务完全访问后端组件。微服务的迁移迫使我们改变这种委托访问管理的方法。
Spring Framework 是如何解决基于微服务的架构的安全问题的?它提供了几个项目,实现了关于认证和授权的不同模式。这些项目中的第一个是 Spring Security,它是基于 Spring 的 Java 应用程序的安全事实标准。它包括几个子模块,可以帮助你开始使用 SAML、OAuth2 或 Kerberos。还有 Spring Cloud Security 项目。它提供了几个组件,允许你将 Spring Security 的基本功能与微服务架构的主要元素(如网关、负载均衡器和 REST HTTP 客户端)集成。
在本章中,我将向您展示如何保护您基于微服务的系统中的所有主要组件。我将描述与主题相关的特定元素,按照构成本书第二部分的章节的顺序。所以,我们将从使用 Eureka 的服务发现开始,然后转移到 Spring Cloud Config Server 和跨服务通信,最后讨论 API 网关安全。
在本章中,我们将探讨以下内容:
-
为单个 Spring Boot 应用程序配置安全连接
-
为微服务架构的最重要元素启用 HTTPS 通信
-
在 Config Server 上存储的配置文件中加密和解密属性值
-
为微服务使用基于 OAuth2 的简单内存身份验证
-
使用 JDBC 后端存储和 JWT 令牌进行更高级的 OAuth2 配置
-
在 Feign 客户端中使用 OAuth2 授权进行服务间通信
但是首先,让我们从基础知识开始。我将向您展示如何创建第一个安全微服务,该微服务通过 HTTPS 暴露 API。
为 Spring Boot 启用 HTTPS
如果您想要使用 SSL 并为您提供 RESTful API 的 HTTPS 服务,您需要生成一个证书。实现这一目标最快的途径是通过自签名证书,这对于开发模式来说已经足够了。JRE 提供了一个简单的证书管理工具——keytool。它位于您的JRE_HOME\bin目录下。以下代码中的命令生成一个自签名证书并将其放入 PKCS12 KeyStore 中。除了 KeyStore 的类型之外,您还需要设置其有效期、别名以及文件名。在开始生成过程之前,keytool会要求您输入密码和一些其他信息,如下所示:
keytool -genkeypair -alias account-key -keyalg RSA -keysize 2048 -storetype PKCS12 -keystore account-key.p12 -validity 3650
Enter keystore password:
Re-enter new password:
What is your first and last name?
[Unknown]: localhost
What is the name of your organizational unit?
[Unknown]: =
What is the name of your organization?
[Unknown]: piomin
What is the name of your City or Locality?
[Unknown]: Warsaw
What is the name of your State or Province?
[Unknown]: mazowieckie
What is the two-letter country code for this unit?
[Unknown]: PL
Is CN=localhost, OU=Unknown, O=piomin, L=Warsaw, ST=mazowieckie, C=PL correct?
[no]: yes
我已经将生成的证书复制到了 Spring Boot 应用程序内的src/main/resources目录中。在构建并运行应用程序后,它将出现在类路径上。为了启用 SSL,我们必须在application.yml文件中提供一些配置设置。通过设置各种server.ssl.*属性,可以为 Spring 自定义 SSL:
server:
port: ${PORT:8090}
ssl:
key-store: classpath:account-key.p12
key-store-password: 123456
key-store-type: PKCS12
key-alias: account-key
security:
require-ssl: true
安全发现
正如您所看到的,为微服务应用程序配置 SSL 并不是一个非常困难的任务。然而,现在是提高难度级别的时候了。我们已经启动了一个单一的微服务,它通过 HTTPS 提供 RESTful API。现在我们想要这个微服务与发现服务器集成。由此产生的两个问题,首先是需要在 Eureka 中发布关于安全微服务实例的信息。第二个问题是如何通过 HTTPS 暴露 Eureka,并强制发现客户端使用私钥对发现服务器进行身份验证。让我们详细讨论这些问题。
注册安全应用程序
如果您的应用程序通过安全的 SSL 端口暴露,您应该将EurekaInstanceConfig中的两个标志更改为nonSecurePortEnabled为false和securePortEnabled为true。这使得 Eureka 发布显式偏好安全通信的实例信息。对于这样配置的服务,Spring Cloud DiscoveryClient总是会返回一个以 HTTPS 开头的 URL,并且 Eureka 实例信息将有一个安全的健康检查 URL:
eureka:
instance:
nonSecurePortEnabled: false
securePortEnabled: true
securePort: ${PORT:8091}
statusPageUrl: https://localhost:${eureka.instance.securePort}/info
healthCheckUrl: https://localhost:${eureka.instance.securePort}/health
homePageUrl: https://localhost:${eureka.instance.securePort}
通过 HTTPS 提供 Eureka
当使用 Spring Boot 启动 Eureka 服务器时,它部署在嵌入式 Tomcat 容器中,因此 SSL 配置与标准微服务相同。区别在于我们必须考虑客户端应用程序,它通过 HTTPS 与发现服务器建立安全连接。发现客户端应该对自己进行身份验证,以对抗 Eureka 服务器,并且还应该验证服务器的证书。客户端和服务器之间的这种通信过程称为双向 SSL或相互认证。还有一种单向认证,实际上是默认选项,其中只有客户端验证服务器的公钥。Java 应用程序使用 KeyStore 和 trustStore 来存储与公钥对应的私钥和证书。trustStore 和 KeyStore 之间的唯一区别在于它们存储的内容和目的。当客户端和服务器之间执行 SSL 握手时,trustStore 用于验证凭据,而 KeyStore 用于提供凭据。换句话说,KeyStore 为给定应用程序保存私钥和证书,而 trustStore 保存用于从第三方识别它的证书。开发者在配置安全连接时通常不会过多关注这些术语,但正确理解它们可以帮助您轻松了解接下来会发生什么。
在典型的基于微服务的架构中,有大量的独立应用程序和一个发现服务器。每个应用程序都有自己的私钥存储在 KeyStore 中,以及对应于发现服务器公钥的证书存储在 trustStore 中。另一方面,服务器保留了为客户端应用程序生成的所有证书。现在我们已经有了足够多的理论。让我们看看下面的图表。它说明了我们在前几章中用作示例的系统的当前情况:

Keystore 生成
在讨论了 Java 安全性的基础知识之后,我们可以继续生成微服务的私钥和公钥。像以前一样,我们将使用 JRE 下的命令行工具——keytool。让我们从一个生成keystore文件的键对的知名命令开始。一个 KeyStore 为发现服务器生成,另一个为选定的微服务生成,在本例中,为account-service生成:
keytool -genkey -alias account -store type JKS -keyalg RSA -keysize 2048 -keystore account.jks -validity 3650
keytool -genkey -alias discovery -storetype JKS -keyalg RSA -keysize 2048 -keystore discovery.jks -validity 3650
然后,必须将自签名证书从 KeyStore 导出到文件中——例如,具有.cer或.crt扩展名。然后系统会提示您输入在生成 KeyStore 时提供的密码:
keytool -exportcert -alias account -keystore account.jks -file account.cer
keytool -exportcert -alias discovery -keystore discovery.jks -file discovery.cer
从 KeyStore 中提取了与公钥对应的证书,因此现在它可以分发给所有感兴趣的各方。account-service的公共证书应该包含在发现服务器的 trustStore 中,反之亦然:
keytool -importcert -alias discovery -keystore account.jks -file discovery.cer
keytool -importcert -alias account -keystore discovery.jks -file account.cer
对account-service执行的相同步骤也必须重复应用于每个随后注册自己的 Eureka 服务器的微服务。以下是order-service生成 SSL 密钥和证书时使用的keytool命令:
keytool -genkey -alias order -storetype JKS -keyalg RSA -keysize 2048 -keystore order.jks -validity 3650
keytool -exportcert -alias order -keystore order.jks -file order.cer
keytool -importcert -alias discovery -keystore order.jks -file discovery.cer
keytool -importcert -alias order -keystore discovery.jks -file order.cer
为微服务和 Eureka 服务器配置 SSL
每个keystore文件都被放置在每个安全微服务和服务发现src/main/resources目录中。每个微服务的 SSL 配置设置与启用 Spring Boot HTTPS节中的示例非常相似。唯一的区别是当前使用的 KeyStore 类型,现在是 JKS 而不是 PKCS12。然而,早期示例与服务发现配置之间还有更多区别。首先,我通过将server.ssl.client-auth属性设置为need来启用了客户端证书认证。这反过来要求我们提供一个server.ssl.trust-store属性的 trustStore。以下是discovery-service的application.yml中的当前 SSL 配置设置:
server:
port: ${PORT:8761}
ssl:
enabled: true
client-auth: need
key-store: classpath:discovery.jks
key-store-password: 123456
trust-store: classpath:discovery.jks
trust-store-password: 123456
key-alias: discovery
如果您使用前面的配置运行 Eureka 应用程序,然后尝试访问其可通过https://localhost:8761/访问的网络仪表板,您可能会得到一个错误代码,如SSL_ERROR_BAD_CERT_ALERT。出现这个错误是因为您的网络浏览器中没有导入可信证书。为此,我们可以导入一个客户端应用程序的 KeyStore,例如account-service的。但首先,我们需要将其从 JKS 格式转换为受网络浏览器支持的另一种格式,例如 PKCS12。以下是keytool命令,用于将 KeyStore 从 JKS 格式转换为 PKCS12 格式:
keytool -importkeystore -srckeystore account.jks -srcstoretype JKS -deststoretype PKCS12 -destkeystore account.p12
PKCS12 格式被所有主流的网络浏览器支持,比如 Google Chrome 和 Mozilla Firefox。您可以通过导航到设置|显示高级设置...|HTTPS/SSL|管理证书部分,在 Google Chrome 中导入 PKCS12 KeyStore。如果您再次尝试访问 Eureka 网络仪表板,您应该能够成功认证,并能够看到已注册服务列表。然而,在那里注册的应用程序将不存在。为了在发现客户端和服务器之间提供安全的通信,我们需要为每个微服务创建一个@Bean类型的DiscoveryClientOptionalArgs,覆盖发现客户端的实现。有趣的是,Eureka 使用 Jersey 作为 REST 客户端。使用EurekaJerseyClientBuilder,我们可以轻松地构建一个新的客户端实现,并传递keystore和truststore文件的路径。以下是从account-service中获取的代码片段,我们创建了一个新的EurekaJerseyClient对象,并将其设置为DiscoveryClientOptionalArgs的参数:
@Bean
public DiscoveryClient.DiscoveryClientOptionalArgs discoveryClientOptionalArgs() throws NoSuchAlgorithmException {
DiscoveryClient.DiscoveryClientOptionalArgs args = new DiscoveryClient.DiscoveryClientOptionalArgs();
System.setProperty("javax.net.ssl.keyStore",
"src/main/resources/account.jks");
System.setProperty("javax.net.ssl.keyStorePassword", "123456");
System.setProperty("javax.net.ssl.trustStore",
"src/main/resources/account.jks");
System.setProperty("javax.net.ssl.trustStorePassword", "123456");
EurekaJerseyClientBuilder builder = new EurekaJerseyClientBuilder();
builder.withClientName("account-client");
builder.withSystemSSLConfiguration();
builder.withMaxTotalConnections(10);
builder.withMaxConnectionsPerHost(10);
args.setEurekaJerseyClient(builder.build());
return args;
}
我们示例系统中的每个微服务都应该提供类似的实现。一个示例应用程序的源代码可以在 GitHub 上找到(github.com/piomin/sample-spring-cloud-security.git)。你可以克隆它,并用你的 IDE 运行所有的 Spring Boot 应用程序。如果一切正常,你应该在 Eureka 仪表板上看到与以下屏幕截图相同的注册服务列表。如果 SSL 连接有任何问题,尝试在应用程序启动时设置-Djava.net.debug=ssl VM 参数,以能够查看 SSL 握手过程的完整日志:

安全配置服务器
在我们架构中还有一个关键要素需要在讨论安全时考虑——Spring Cloud Config 配置服务器。我觉得保护配置服务器甚至比保护发现服务更为重要。为什么?因为我们通常将它们的认证凭据存储在外部系统上,还有其他一些不应该被未授权访问和使用的数据。有几种方法可以妥善保护你的配置服务器。你可以配置 HTTP 基本认证,安全的 SSL 连接,加密/解密敏感数据,或者使用第三方工具,如在第五章中介绍的,使用 Spring Cloud Config 进行分布式配置。让我们 closer 看看其中的一些。
加密和解密
在开始之前,我们必须下载并安装由 Oracle 提供的Java Cryptography Extension(JCE)。它包括两个 JAR 文件(local_policy.jar和US_export_policy.jar),需要覆盖 JRE lib/security 目录中现有的策略文件。
如果配置服务器上存储的远程属性源包含加密数据,它们的值应该以{cipher}为前缀,并用引号括起来,以表示它是一个 YAML 文件。对于.properties文件,不需要用引号括起来。如果无法解密这样的值,它将被替换为同样的键前缀invalid的附加值(通常是<n/a>)。
在我们上一个示例中,我们在应用程序配置设置中存储了用于保护keystore文件的密码短语。将其保存在纯文本文件中可能不是最好的主意,所以它是加密的第一候选。问题是,我们如何加密它?幸运的是,Spring Boot 提供了两个 RESTful 端点可以帮助实现。
让我们看看它是如何工作的。首先,我们需要启动一个配置服务器实例。最简单的方法是激活--spring.profiles.active=native配置文件,该配置文件会使用来自本地类路径或文件系统的属性源来启动服务器。现在我们可以调用两个 POST 端点/encrypt和/decrypt。/encrypt方法接受我们的明文密码作为参数。我们可以通过使用逆操作/decrypt,它接受一个加密密码作为参数,来检查结果:
$ curl http://localhost:8888/encrypt -d 123456
AQAzI8jv26K3n6ff+iFzQA9DUpWmg79emWu4ndEXyvjYnKFSG7rBmJP0oFTb8RzjZbTwt4ehRiKWqu5qXkH8SAv/8mr2kdwB28kfVvPj/Lb5hdUkH1TVrylcnpZaKaQYBaxlsa0RWAKQDk8MQKRw1nJ5HM4LY9yjda0YQFNYAy0/KRnwUFihiV5xDk5lMOiG4b77AVLmz+9aSAODKLO57wOQUzM1tSA7lO9HyDQW2Hzl1q93uOCaP5VQLCJAjmHcHvhlvM442bU3B29JNjH+2nFS0RhEyUvpUqzo+PBi4RoAKJH9XZ8G7RaTOeWIcJhentKRf0U/EgWIVW21NpsE29BHwf4F2JZiWY2+WqcHuHk367X21vk11AVl9tJk9aUVNRk=
加密使用公钥,而解密使用私钥。因此,如果你只进行加密,那么在服务器上只需提供公钥即可。出于测试目的,我们可以使用keytool创建 KeyStore。我们之前已经创建了一些 KeyStores,所以在这方面你不会有任何问题。生成的文件应该放在类路径中,然后在config-service配置设置中使用encrypt.keyStore.*属性:
encrypt:
keyStore:
location: classpath:/config.jks
password: 123456
alias: config
secret: 123456
现在,如果你将每个微服务的配置设置移动到配置服务器,你可以像下面示例片段中那样加密每个密码:
server:
port: ${PORT:8091}
ssl:
enabled: true
key-store: classpath:account.jks
key-store-password: '{cipher}AQAzI8jv26K3n6ff+iFzQA9DUpWmg79emWu4ndEXyvjYnKFSG7rBmJP0oFTb8RzjZbTwt4ehRiKWqu5qXkH8SAv/8mr2kdwB28kfVvPj/Lb5hdUkH1TVrylcnpZaKaQYBaxlsa0RWAKQDk8MQKRw1nJ5HM4LY9yjda0YQFNYAy0/KRnwUFihiV5xDk5lMOiG4b77AVLmz+9aSAODKLO57wOQUzM1tSA7lO9HyDQW2Hzl1q93uOCaP5VQLCJAjmHcHvhlvM442bU3B29JNjH+2nFS0RhEyUvpUqzo+PBi4RoAKJH9XZ8G7RaTOeWIcJhentKRf0U/EgWIVW21NpsE29BHwf4F2JZiWY2+WqcHuHk367X21vk11AVl9tJk9aUVNRk='
key-alias: account
为客户端和服务器配置认证
Spring Cloud Config 服务器的认证实现与 Eureka 服务器的认证实现完全一样。我们可以使用基于标准 Spring 安全机制的 HTTP 基本认证。首先,我们需要确保spring-security工件在类路径上。然后我们应该使用`security.basic.
将enabled设置为true并定义用户名和密码。示例配置设置如下代码片段所示:
security:
basic:
enabled: true
user:
name: admin
password: admin123
基本认证必须在客户端也启用。这可以通过两种不同的方式实现。第一种是通过配置服务器的 URL:
spring:
cloud:
config:
uri: http://admin:admin123@localhost:8888
第二种方法基于独立的username和password属性:
spring:
cloud:
config:
uri: http://localhost:8888
username: admin
password: admin123
如果你想设置 SSL 认证,你需要遵循安全发现部分描述的步骤。在生成带有私钥和证书的 KeyStores 并设置正确的配置之后,我们可以运行配置服务器。现在,它通过 HTTPS 暴露其 RESTful API。唯一的区别在于客户端的实现。这是因为 Spring Cloud Config 使用的是与 Spring Cloud Netflix Eureka 不同的 HTTP 客户端。正如你可能猜到的,它利用了RestTemplate,因为它是完全在 Spring Cloud 项目中创建的。
为了强制客户端应用程序使用双向 SSL 认证而不是标准的、不安全的 HTTP 连接,我们首先应该创建一个实现PropertySourceLocator接口的@Configurationbean。在那里,我们可以构建一个自定义的RestTemplate,它使用一个安全的 HTTP 连接工厂:
@Configuration
public class SSLConfigServiceBootstrapConfiguration {
@Autowired
ConfigClientProperties properties;
@Bean
public ConfigServicePropertySourceLocator configServicePropertySourceLocator() throws Exception {
final char[] password = "123456".toCharArray();
final File keyStoreFile = new File("src/main/resources/discovery.jks");
SSLContext sslContext = SSLContexts.custom()
.loadKeyMaterial(keyStoreFile, password, password)
.loadTrustMaterial(keyStoreFile).build();
CloseableHttpClient httpClient = HttpClients.custom().setSSLContext(sslContext).build();
HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient);
ConfigServicePropertySourceLocator configServicePropertySourceLocator = new ConfigServicePropertySourceLocator(properties);
configServicePropertySourceLocator.setRestTemplate(new RestTemplate(requestFactory));
return configServicePropertySourceLocator;
}
}
然而,默认情况下,这个 bean 在应用程序尝试与配置服务器建立连接之前不会被创建。要改变这种行为,我们还应该在/src/main/resources/META-INF中创建spring.factories文件,并指定自定义的引导配置类:
org.springframework.cloud.bootstrap.BootstrapConfiguration = pl.piomin.services.account.SSLConfigServiceBootstrapConfiguration
使用 OAuth2 进行授权
我们已经讨论了一些与微服务环境中的认证相关的概念和解决方案。我向您展示了微服务之间以及微服务与服务发现和配置服务器之间的基本和 SSL 认证的例子。在服务间通信中,授权似乎比认证更重要,而认证则实现在系统的边缘。理解认证和授权之间的区别是值得的。简单地说,认证验证你是谁,而授权验证你被授权做什么。
目前最流行的 RESTful HTTP API 授权方法是 OAuth2 和Java Web Tokens(JWT)。它们可以混合使用,因为它们互补性比其他解决方案要强。Spring 为 OAuth 提供商和消费者提供了支持。借助 Spring Boot 和 Spring Security OAuth2,我们可以快速实现常见的 security patterns,如单点登录、令牌传递或令牌交换。但在我们深入了解这些项目以及其他开发细节之前,我们需要先掌握前面解决方案的基本知识。
OAuth2 简介
OAuth2 是目前几乎所有主要网站所使用的标准,它允许您通过共享 API 访问他们的资源。它将用户认证委托给一个独立的服务,该服务存储用户凭据并授权第三方应用程序访问关于用户账户的共享信息。OAuth2 用于在保护用户账户凭据的同时给予您的用户访问数据的能力。它为 web、桌面和移动应用程序提供了流程。以下是与 OAuth2 相关的一些基本术语和角色:
-
资源所有者:这个角色管理对资源的访问。这种访问受授予授权的范围限制。
-
授权许可:它授予访问权限。您可以选择以各种方式确认访问——授权代码、隐式、资源所有者密码凭据和客户端凭据。
-
资源服务器:这是一个存储可以使用特殊令牌共享所有者资源的服务器。
-
授权服务器:它管理密钥、令牌和其他临时资源访问代码的分配。它还需要确保授予相关用户的访问权限。
-
访问令牌:这是一个允许访问资源的钥匙。
为了更好地理解这些术语和实践中的角色,请看下面的图表。它通过 OAuth 协议可视化了一个典型的授权过程流程:

让我们回顾一下前面列出个别组件之间交互的进一步步骤。应用程序请求资源所有者的授权,以便能够访问所请求的服务。资源以授权授予作为响应发送,应用程序将其与自身的身份一起发送到授权服务器。授权服务器验证应用程序身份凭据和授权授予,然后发送访问令牌。应用程序使用收到的访问令牌从资源服务器请求资源。最后,如果访问令牌有效,应用程序能够调用请求服务。
构建授权服务器
从单体应用程序移动到微服务后,明显的解决方案似乎是通过创建一个授权服务来集中授权努力。使用 Spring Boot 和 Spring Security,你可以轻松地创建、配置和启动一个授权服务器。首先,我们需要将以下starters包括到项目依赖中:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
</dependency>
使用 Spring Boot 实现授权服务器模式非常简单。我们只需要将主类或配置类注解为@EnableAuthorizationServer,然后提供security.oauth2.client.client-id和security.oauth2.client.client-secret属性在application.yml文件中。当然,这个变体尽可能简单,因为它定义了客户端详情服务的内存实现。
一个示例应用程序在同一存储库中,本章之前的示例中(github.com/piomin/sample-spring-cloud-security.git),但在不同的分支,oauth2 (github.com/piomin/sample-spring-cloud-security/tree/oauth2)。授权服务器在auth-service模块下可用。以下是auth-service的主类:
@SpringBootApplication
@EnableAuthorizationServer
public class AuthApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(AuthApplication.class).web(true).run(args);
}
}
以下是应用程序配置设置的片段。除了客户端的 ID 和密钥外,我还设置了它的默认范围并在整个项目中启用了基本安全:
security:
user:
name: root
password: password
oauth2:
client:
client-id: piotr.minkowski
client-secret: 123456
scope: read
在我们运行授权服务之后,我们可以进行一些测试。例如,我们可以调用POST /oauth/token方法,使用资源所有者密码凭证来创建访问令牌,就像以下命令一样:
$ curl piotr.minkowski:123456@localhost:9999/oauth/token -d grant_type=password -d username=root -d password=password
我们还可以通过从你的网络浏览器调用GET /oauth/authorize端点来使用授权码授权类型:
http://localhost:9999/oauth/authorize?response_type=token&client_id=piotr.minkowski&redirect_uri=http://example.com&scope=read
然后,你将被重定向到批准页面。你可能确认这个动作,最后得到你的访问令牌。它将被发送到初始请求中redirect_uri参数传递的回调 URL。以下是我测试后收到的示例响应:
http://example.com/#access_token=dd736a4a-1408-4f3f-b3ca-43dcc05e6df0&token_type=bearer&expires_in=43200.

在application.yml文件内提供的相同的 OAuth2 配置也可以以编程方式实现。为了实现这一点,我们应该声明任何实现AuthorizationServerConfigurer的@Beans。其中的一个是AuthorizationServerConfigurerAdapter适配器,它提供空方法,允许您创建以下分离配置器的自定义定义:
-
ClientDetailsServiceConfigurer:这定义了客户端详情服务。客户端详情可以初始化,或者你可以简单地引用一个现有的存储。 -
AuthorizationServerSecurityConfigurer:这定义了在/oauth/token_key和/oauth/check_token令牌端点上的安全约束。 -
AuthorizationServerEndpointsConfigurer:这定义了授权和令牌端点以及令牌服务。
这种对授权服务器实现的方法为我们提供了更多的机会。例如,我们可以定义一个带有 ID 和密钥的多个客户端,如下面的代码片段所示。我将在本章的下一部分展示一些更高级的示例:
@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {
@Override
public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
oauthServer
.tokenKeyAccess("permitAll()")
.checkTokenAccess("isAuthenticated()");
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("piotr.minkowski").secret("123456")
.scopes("read")
.authorities("ROLE_CLIENT")
.authorizedGrantTypes("authorization_code", "refresh_token", "implicit")
.autoApprove(true)
.and()
.withClient("john.smith").secret("123456")
.scopes("read", "write")
.authorities("ROLE_CLIENT")
.authorizedGrantTypes("authorization_code", "refresh_token", "implicit")
.autoApprove(true);
}
}
我们必须为我们的授权服务器配置的最后一件事情是网络安全。在扩展了WebSecurityConfigurerAdapter的类中,我们定义了一个内存中的用户凭据存储和访问特定资源的权限,例如登录页面:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationManager authenticationManager;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.requestMatchers()
.antMatchers("/login", "/oauth/authorize")
.and()
.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin().permitAll();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.parentAuthenticationManager(authenticationManager)
.inMemoryAuthentication()
.withUser("piotr.minkowski").password("123456").roles("USERS");
}
}
客户端配置
您的应用程序可以使用配置的 OAuth2 客户端以两种不同的方式。这两种方式中的第一种是通过@EnableOAuth2Client注解,它创建了一个 ID 为oauth2ClientContextFilter的过滤器 bean,负责存储请求和上下文。它还负责管理您应用程序与授权服务器之间的通信。然而,我们将查看 OAuth2 客户端端实现的第二种方法,通过@EnableOAuth2Sso。单点登录(SSO)是一个众所周知的安全模式,允许用户使用一组登录凭据访问多个应用程序。这个注解提供了两个特性——OAuth2 客户端和认证。认证部分使您的应用程序与典型的 Spring Security 机制(如表单登录)对齐。客户端部分具有与@EnableOAuth2Client提供的功能相同的功能。因此,我们可以将@EnableOAuth2Sso视为比@EnableOAuth2Client更高层次的注解。
在下面的示例代码片段中,我用@EnableOAuth2Sso注解了扩展了WebSecurityConfigurerAdapter的类。得益于这个扩展,Spring Boot 配置了携带 OAuth2 身份处理器的网络安全过滤链。在这种情况下,允许访问/login页面,而所有其他请求都需要认证。表单登录页面路径可以通过security.oauth2.sso.login-path属性进行覆盖。在覆盖它之后,我们还应该记得在WebSecurityConfig内部更改路径模式:
@Configuration
@EnableOAuth2Sso
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.antMatcher("/**")
.authorizeRequests()
.antMatchers("/login**")
.permitAll()
.anyRequest()
.authenticated();
}
}
还有一些需要设置的配置设置。首先,我们应该禁用基本认证,因为我们使用了与@EnableOAuth2Sso注解一起启用的表单登录方法。然后,我们必须提供一些基本的 OAuth2 客户端属性,例如客户端凭据和授权服务器公开的 HTTP API 端点的地址:
security:
basic:
enabled: false
oauth2:
client:
clientId: piotr.minkowski
clientSecret: 123456
accessTokenUri: http://localhost:9999/oauth/token
userAuthorizationUri: http://localhost:9999/oauth/authorize
resource:
userInfoUri: http://localhost:9999/user
application.yml文件片段中的最后一个属性是security.oauth2.resource.userInfoUri,这需要在服务器端实现一个额外的端点。UserController实现的端点返回java.security.Principal对象,表示当前认证的用户:
@RestController
public class UserController {
@RequestMapping("/user")
public Principal user(Principal user) {
return user;
}
}
现在,如果您调用我们微服务中公开的任何端点,您将自动重定向到登录页面。由于我们为内存中的客户端详细信息存储设置了autoApprove选项,因此授权授予和访问令牌无需用户任何交互即可自动生成。在登录页面提供您的凭据后,您应该能够获得请求资源的响应。
使用 JDBC 后端存储
在前几节中,我们配置了一个认证服务器和客户端应用程序,它授予访问受资源服务器保护的资源的权限。然而,整个授权服务器配置都提供在内存存储中。这种解决方案在开发过程中满足我们的需求,但在生产模式下并不是最理想的方法。目标解决方案应该将所有的认证凭据和令牌存储在数据库中。我们可以选择 Spring 支持的关系数据库之一。在此案例中,我决定使用 MySQL。
所以,第一步是在本地启动 MySQL 数据库。最舒适的方法是使用 Docker 容器。除了启动数据库,下面的命令还将创建一个名为oauth2的架构和用户:
docker run -d --name mysql -e MYSQL_DATABASE=oauth2 -e MYSQL_USER=oauth2 -e MYSQL_PASSWORD=oauth2 -e MYSQL_ALLOW_EMPTY_PASSWORD=yes -p 33306:3306 mysql
一旦我们启动了 MySQL,现在必须在客户端提供连接设置。如果您在 Windows 机器上运行 Docker,则 MySQL 可在主机地址192.168.99.100上访问,端口为33306。数据源属性应在auth-service的application.yml中设置。Spring Boot 还能够在应用程序启动时在选定的数据源上运行一些 SQL 脚本。这对我们来说是个好消息,因为我们必须在为我们的 OAuth2 过程专用的架构上创建一些表:
spring:
application:
name: auth-service
datasource:
url: jdbc:mysql://192.168.99.100:33306/oauth2?useSSL=false
username: oauth2
password: oauth2
driver-class-name: com.mysql.jdbc.Driver
schema: classpath:/script/schema.sql
data: classpath:/script/data.sql
创建的架构包含一些用于存储 OAuth2 凭据和令牌的表——oauth_client_details、oauth_client_token、oauth_access_token、oauth_refresh_token、oauth_code和oauth_approvals。包含 SQL 创建命令的完整脚本可在/src/main/resources/script/schema.sql中找到。还有一个第二个 SQL 脚本,/src/main/resources/script/data.sql,其中有一些用于测试目的的insert命令。最重要的是要添加一些客户端 ID/客户端密钥对:
INSERT INTO `oauth_client_details` (`client_id`, `client_secret`, `scope`, `authorized_grant_types`, `access_token_validity`, `additional_information`) VALUES ('piotr.minkowski', '123456', 'read', 'authorization_code,password,refresh_token,implicit', '900', '{}');
INSERT INTO `oauth_client_details` (`client_id`, `client_secret`, `scope`, `authorized_grant_types`, `access_token_validity`, `additional_information`) VALUES ('john.smith', '123456', 'write', 'authorization_code,password,refresh_token,implicit', '900', '{}');
当前认证服务器版本与基本示例中描述的版本在实现上有一些不同。这里的第一件重要事情是设置默认令牌存储到数据库,通过提供 JdbcTokenStore bean 作为默认数据源的参数。尽管现在所有令牌都存储在数据库中,但我们仍然希望以 JWT 格式生成它们。这就是为什么在类中必须提供第二个 bean,JwtAccessTokenConverter。通过重写从基类继承的不同 configure 方法,我们可以为 OAuth2 客户端详情设置默认存储,并配置授权服务器始终验证在 HTTP 头中提交的 API 密钥:
@Configuration
@EnableAuthorizationServer
public class OAuth2Config extends AuthorizationServerConfigurerAdapter {
@Autowired
private DataSource dataSource;
@Autowired
private AuthenticationManager authenticationManager;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(this.authenticationManager)
.tokenStore(tokenStore())
.accessTokenConverter(accessTokenConverter());
}
@Override
public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
oauthServer.checkTokenAccess("permitAll()");
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
return new JwtAccessTokenConverter();
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.jdbc(dataSource);
}
@Bean
public JdbcTokenStore tokenStore() {
return new JdbcTokenStore(dataSource);
}
}
Spring 应用程序提供了一个自定义的认证机制。要在应用程序中使用它,我们必须实现 UserDetailsService 接口并重写其 loadUserByUsername 方法。在我们示例应用程序中,用户凭据和权限也存储在数据库中,因此我们向自定义 UserDetailsService 类注入 UserRepository bean:
@Component("userDetailsService")
public class UserDetailsServiceImpl implements UserDetailsService {
private final Logger log = LoggerFactory.getLogger(UserDetailsServiceImpl.class);
@Autowired
private UserRepository userRepository;
@Override
@Transactional
public UserDetails loadUserByUsername(final String login) {
log.debug("Authenticating {}", login);
String lowercaseLogin = login.toLowerCase();
User userFromDatabase;
if(lowercaseLogin.contains("@")) {
userFromDatabase = userRepository.findByEmail(lowercaseLogin);
} else {
userFromDatabase = userRepository.findByUsernameCaseInsensitive(lowercaseLogin);
}
if (userFromDatabase == null) {
throw new UsernameNotFoundException("User " + lowercaseLogin + " was not found in the database");
} else if (!userFromDatabase.isActivated()) {
throw new UserNotActivatedException("User " + lowercaseLogin + " is not activated");
}
Collection<GrantedAuthority> grantedAuthorities = new ArrayList<>();
for (Authority authority : userFromDatabase.getAuthorities()) {
GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(authority.getName());
grantedAuthorities.add(grantedAuthority);
}
return new org.springframework.security.core.userdetails.User(userFromDatabase.getUsername(), userFromDatabase.getPassword(), grantedAuthorities);
}
}
服务间授权
我们示例中的服务间通信是使用 Feign 客户端实现的。以下是所选实现之一——在这种情况下,来自 order-service ——它调用 customer-service 的端点:
@FeignClient(name = "customer-service")
public interface CustomerClient {
@GetMapping("/withAccounts/{customerId}")
Customer findByIdWithAccounts(@PathVariable("customerId") Long customerId);
}
与其它服务一样,customer-service 中所有可用方法都基于 OAuth 令牌作用域的保护预授权机制。它允许我们用 @PreAuthorize 注解标记每个方法,定义所需的作用域:
@PreAuthorize("#oauth2.hasScope('write')")
@PutMapping
public Customer update(@RequestBody Customer customer) {
return repository.update(customer);
}
@PreAuthorize("#oauth2.hasScope('read')")
@GetMapping("/withAccounts/{id}")
public Customer findByIdWithAccounts(@PathVariable("id") Long id) throws JsonProcessingException {
List<Account> accounts = accountClient.findByCustomer(id);
LOGGER.info("Accounts found: {}", mapper.writeValueAsString(accounts));
Customer c = repository.findById(id);
c.setAccounts(accounts);
return c;
}
预授权默认是禁用的。要为 API 方法启用它,我们应该使用 @EnableGlobalMethodSecurity 注解。我们还应指示这种预授权将基于 OAuth2 令牌作用域:
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class OAuth2ResourceServerConfig extends GlobalMethodSecurityConfiguration {
@Override
protected MethodSecurityExpressionHandler createExpressionHandler() {
return new OAuth2MethodSecurityExpressionHandler();
}
}
如果您通过 Feign 客户端调用账户服务端点,将会得到以下异常:
feign.FeignException: status 401 reading CustomerClient#findByIdWithAccounts(); content:{"error":"unauthorized","error_description":"Full authentication is required to access this resource"}
为什么会出现这样的异常呢?当然,customer-service 是通过 OAuth2 令牌授权进行保护的,但是 Feign 客户端在请求头中没有发送授权令牌。解决这个问题的一种方法是为 Feign 客户端定义一个自定义配置类。它允许我们声明一个请求拦截器。在这种情况下,我们可以使用 Spring Cloud OAuth2 库中提供的 OAuth2FeignRequestInterceptor 实现的 OAuth2。出于测试目的,我决定使用资源所有者密码授权类型:
public class CustomerClientConfiguration {
@Value("${security.oauth2.client.access-token-uri}")
private String accessTokenUri;
@Value("${security.oauth2.client.client-id}")
private String clientId;
@Value("${security.oauth2.client.client-secret}")
private String clientSecret;
@Value("${security.oauth2.client.scope}")
private String scope;
@Bean
RequestInterceptor oauth2FeignRequestInterceptor() {
return new OAuth2FeignRequestInterceptor(new DefaultOAuth2ClientContext(), resource());
}
@Bean
Logger.Level feignLoggerLevel() {
return Logger.Level.FULL;
}
private OAuth2ProtectedResourceDetails resource() {
ResourceOwnerPasswordResourceDetails resourceDetails = new ResourceOwnerPasswordResourceDetails();
resourceDetails.setUsername("root");
resourceDetails.setPassword("password");
resourceDetails.setAccessTokenUri(accessTokenUri);
resourceDetails.setClientId(clientId);
resourceDetails.setClientSecret(clientSecret);
resourceDetails.setGrantType("password");
resourceDetails.setScope(Arrays.asList(scope));
return resourceDetails;
}
}
最后,我们可以测试所实现的解决方案。这次,我们将创建一个 JUnit 自动化测试,而不是在网页浏览器中点击它或使用其他工具发送请求。以下代码片段显示了测试方法。我们使用OAuth2RestTemplate和ResourceOwnerPasswordResourceDetails执行资源所有者凭据授予操作,并调用来自order-service的POST / API 方法,请求头中发送了 OAuth2 令牌。当然,在运行那个测试之前,您必须启动所有微服务以及发现和授权服务器:
@Test
public void testClient() {
ResourceOwnerPasswordResourceDetails resourceDetails = new ResourceOwnerPasswordResourceDetails();
resourceDetails.setUsername("root");
resourceDetails.setPassword("password");
resourceDetails.setAccessTokenUri("http://localhost:9999/oauth/token");
resourceDetails.setClientId("piotr.minkowski");
resourceDetails.setClientSecret("123456");
resourceDetails.setGrantType("password");
resourceDetails.setScope(Arrays.asList("read"));
DefaultOAuth2ClientContext clientContext = new DefaultOAuth2ClientContext();
OAuth2RestTemplate restTemplate = new OAuth2RestTemplate(resourceDetails, clientContext);
restTemplate.setMessageConverters(Arrays.asList(new MappingJackson2HttpMessageConverter()));
Random r = new Random();
Order order = new Order();
order.setCustomerId((long) r.nextInt(3) + 1);
order.setProductIds(Arrays.asList(new Long[] { (long) r.nextInt(10) + 1, (long) r.nextInt(10) + 1 }));
order = restTemplate.postForObject("http://localhost:8090", order, Order.class);
if (order.getStatus() != OrderStatus.REJECTED) {
restTemplate.put("http://localhost:8090/{id}", null, order.getId());
}
}
在 API 网关上启用单点登录
您可以通过在主类上添加@EnableOAuth2Sso注解来仅通过注解在 API 网关上启用单点登录功能。确实,这是强制 Zuul 生成或获取当前认证用户的访问令牌的最佳选择,对于您的微服务架构来说:
@SpringBootApplication
@EnableOAuth2Sso
@EnableZuulProxy
public class GatewayApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(GatewayApplication.class).web(true).run(args);
}
}
通过包含@EnableOAuth2Sso,你可以触发一个对 ZuulFilter 可用的自动配置。这个过滤器负责从当前已认证的用户中提取访问令牌,然后将其放入转发到微服务网关后面的请求头中。如果对这些服务启用了@EnableResourceServer,它们将会在Authorization HTTP 头中收到预期的令牌。@EnableZuulProxy下游的授权行为可以通过声明proxy.auth.*属性来控制。
当在您的架构中使用网关时,您可能在其后面隐藏一个授权服务器。在这种情况下,您应该在 Zuul 的配置设置中提供额外的路由—例如,uaa。然后,OAuth2 客户端与服务器之间交换的所有消息都通过网关。这是在网关的application.yml文件中的正确配置:
security:
oauth2:
client:
accessTokenUri: /uaa/oauth/token
userAuthorizationUri: /uaa/oauth/authorize
clientId: piotr.minkowski
clientSecret: 123456
resource:
userInfoUri: http://localhost:9999/user
zuul:
routes:
account-service:
path: /account/**
customer-service:
path: /customer/**
order-service:
path: /order/**
product-service:
path: /product/**
uaa:
sensitiveHeaders:
path: /uaa/**
url: http://localhost:9999
add-proxy-headers: true
摘要
如果在本书的第二部分每个章节中都包含一个安全部分,那也不会有什么问题。但我决定专门用一章来介绍这个主题,以便向您展示如何逐步为基于微服务架构的关键元素提供安全保护的步骤。与安全相关的主题通常比其他主题更高级,所以我花了一些时间来解释该领域的一些基本概念。我向您展示了示例,说明了双向 SSL 认证、敏感数据的加密/解密、Spring Security 认证以及使用 JWT 令牌的 OAuth2 授权。您需要决定在您的系统架构中使用哪个来提供您所需的安全级别。
阅读本章后,你应该能够为你的应用程序设置基本和更高级的安全配置。你还应该能够保护你系统架构中的每一个组件。当然,我们只讨论了一些可能的解决方案和框架。例如,你不必仅依赖于 Spring 作为授权服务器提供者。我们可以使用第三方工具,如 Keycloak,它可以在基于微服务的系统中作为授权和认证服务器。它还可以轻松地与 Spring Boot 应用程序集成。它支持所有最流行的协议,如 OAuth2、OpenId Connect 和 SAML。因此,实际上,Keycloak 是一个非常强大的工具,应该被视为 Spring 授权服务器的替代品,特别是在大型企业系统和其他更高级的使用场景中。
在下一章中,我们将讨论微服务测试的不同策略。
第十三章:测试 Java 微服务
在开发新应用程序时,我们永远不要忘记自动化测试。如果考虑基于微服务的架构,这些尤其重要。测试微服务需要与为单体应用程序创建的测试不同的方法。就单体而言,主要关注的是单元测试和集成测试,以及数据库层。在微服务的情况下,最重要的事情是以尽可能细粒度的覆盖每个通信。尽管每个微服务都是独立开发和发布的,但其中一个服务的更改可能会影响所有与之交互的其他服务。它们之间的通信是通过消息实现的。通常,这些消息是通过 REST 或 AMQP 协议发送的。
本章我们将覆盖以下主题:
-
Spring 对自动化测试的支持
-
Spring Boot 微服务中组件测试与集成测试的区别
-
使用 Pact 实施合同测试
-
使用 Spring Cloud Contract 实施合同测试
-
使用 Gatling 实施性能测试
测试策略
有五种不同的微服务测试策略。其中前三种与单体应用相同:
-
单元测试:单元测试中,我们测试代码的最小单元,例如,一个单独的方法或组件,并模拟其他方法和组件的每次调用。有许多流行的 Java 框架支持单元测试,如 JUnit、TestNG 和 Mockito(用于模拟)。这类测试的主要任务是确认实现符合需求。单元测试尤其是一个强大的工具,尤其是在与测试驱动开发结合使用时。
-
集成测试:仅使用单元测试并不能保证您将验证整个系统的行为。集成测试取模块并尝试将它们一起测试。这种方法为您提供了在子系统中锻炼通信路径的机会。我们根据模拟的外部服务接口测试组件之间的交互和通信。在基于微服务的系统中,集成测试可以用于包括其他微服务、数据源或缓存。
-
端到端测试:端到端测试也称为功能测试。这些测试的主要目标是验证系统是否符合外部要求。这意味着我们应该设计测试场景,以测试参与该过程的所有微服务。设计一个好的端到端测试并不是一件简单的事。由于我们需要测试整个系统,因此特别重视测试场景的设计非常重要。
-
契约测试:契约测试用于确保微服务的显式和隐式契约如预期般工作。当消费者集成并使用组件的接口时,总是形成契约。在微服务系统中,通常有一个组件被多个消费者使用。每个消费者通常需要一个满足其需求的不同的契约。基于这些假设,每个消费者都负责源组件接口的行为。
-
组件测试:在我们完成了微服务中所有对象和方法的单元测试之后,我们应该孤立地测试整个微服务。为了在孤立环境中运行测试,我们需要模拟或替换其他微服务的调用。外部数据存储应被等效的内存数据存储所替代,这也显著提高了测试性能。
契约测试与组件测试的区别是显而易见的。以下图表在我们的示例order-service微服务中说明了这些差异:

现在,有一个问题是我们是否真的需要为基于微服务的系统测试添加两个额外的策略。通过适当的单元和集成测试,我们可能对构成微服务的一部分的单个组件的实现的正确性有信心。然而,如果没有为微服务制定更具体的测试策略,我们不能确定它们如何共同工作以满足我们的业务需求。因此,增加了组件和契约测试。这是帮助我们理解组件、契约和集成测试之间差异的一个非常重要的变化。因为组件测试是在与外界隔离的情况下进行的,所以集成测试负责验证与那个世界的交互。这就是为什么我们应该为集成测试提供存根,而不是为组件测试。契约测试与集成测试类似,强调微服务之间的交互,但它们将它们视为黑盒,仅验证响应的格式。
一旦你为你的微服务提供了功能测试,你也应该考虑性能测试。我们可以区分出以下性能测试策略:
-
负载测试:这些测试用于确定系统在正常和预期负载条件下的行为。这里的主要想法是识别一些弱点,例如响应时间延迟、异常中断或如果网络超时设置不正确则尝试次数过多。
-
压力测试:这些测试检查系统的上限,以观察在极端重载下系统的表现。除了负载测试之外,它还检查内存泄漏、安全问题以及数据损坏。它可能使用与负载测试相同的工具。
以下图表说明了在您的系统上执行所有测试策略的逻辑顺序。我们从最简单的单元测试开始,该测试验证小块软件,然后继续下一阶段,最后完成压力测试,将整个系统推向极限:

测试 Spring Boot 应用程序
正如您在上一节可能已经读到的,您的应用程序中有不同的测试策略和方法。我简要提到了它们的所有内容,所以现在我们可以继续实践方面的问题。Spring Boot 提供了一系列工具,有助于实现自动化测试。为了在项目中启用这些特性,您必须将 spring-boot-starter-test 启动器添加到依赖项中。它不仅导入了 spring-test 和 spring-boot-test 工件,还导入了其他一些有用的测试库,如 JUnit、Mockito 和 AssertJ:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
构建示例应用程序
在我们开始自动化测试之前,我们需要为测试目的准备一个示例业务逻辑。我们可以使用前几章中的同一个示例系统,但它必须稍作修改。到目前为止,我们从未使用过外部数据源来存储和收集测试数据。在本章中,为了说明不同的策略如何处理持久性测试问题,这样做将很有帮助。现在,每个服务都有自己的数据库尽管,通常,选择哪个数据库并不重要。Spring Boot 支持大量解决方案,包括关系型和 NoSQL 数据库。我决定使用 Mongo。让我们回顾一下示例系统的架构。以下图表所示的当前模型考虑了关于每个服务专用数据库的先前描述的假设:

数据库集成
为了在 Spring Boot 应用程序中启用 Mongo 支持,请在依赖项中包含 spring-boot-starter-data-mongo 启动器。这个项目提供了一些有趣的特性来简化与 MongoDB 的集成。在这些特性中,特别值得一提的是丰富的对象映射、MongoTemplate,当然还有对仓库编写风格的支持,这是其他 Spring Data 项目所熟知的。以下是 pom.xml 中所需的依赖声明:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
可以使用 MongoDB 的 Docker 镜像轻松启动 MongoDB 的实例。运行以下命令以启动一个容器,该容器在端口 27017 上暴露 Mongo 数据库:
docker run --name mongo -p 27017:27017 -d mongo
为了将应用程序与之前启动的数据源连接,我们应该覆盖 application.yml 中的 auto-configured 设置。这可以通过 spring.data.mongodb.* 属性来实现:
spring:
application:
name: account-service
data:
mongodb:
host: 192.168.99.100
port: 27017
database: micro
username: micro
password: micro123
我已经提到了对象映射功能。Spring Data Mongo 提供了一些可用于此的注解。存储在数据库中的每个对象都应该用@Document注解。目标集合的主键是一个 12 字节的字符串,应该在 Spring Data 的@Id中每个映射类中指示。以下是Account对象实现的片段:
@Document
public class Account {
@Id
private String id;
private String number;
private int balance;
private String customerId;
// ...
}
单元测试
我花了很长时间描述与 MongoDB 的集成。然而,测试持久性是自动化测试的关键点之一,所以正确配置它非常重要。现在,我们可以进行测试的实现。Spring Test 为最典型的测试场景提供支持,例如通过 REST 客户端与其他服务集成或与数据库集成。我们有一套库可供我们轻松模拟与外部服务的交互,这对于单元测试尤为重要。
下面的测试类是一个典型的 Spring Boot 应用程序的单元测试实现。我们使用了 JUnit 框架,这是 Java 事实上的标准。在这里,我们使用 Mockito 库用它们的存根替换真实的仓库和控制器。这种方法允许我们轻松验证@Controller类实现的每个方法的正确性。测试在与外部组件隔离的环境中进行,这是单元测试的主要假设:
@RunWith(SpringRunner.class)
@WebMvcTest(AccountController.class)
public class AccountControllerUnitTest {
ObjectMapper mapper = new ObjectMapper();
@Autowired
MockMvc mvc;
@MockBean
AccountRepository repository;
@Test
public void testAdd() throws Exception {
Account account = new Account("1234567890", 5000, "1");
when(repository.save(Mockito.any(Account.class))).thenReturn(new Account("1","1234567890", 5000, "1"));
mvc.perform(post("/").contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(account)))
.andExpect(status().isOk());
}
@Test
public void testWithdraw() throws Exception {
Account account = new Account("1", "1234567890", 5000, "1");
when(repository.findOne("1")).thenReturn(account);
when(repository.save(Mockito.any(Account.class))).thenAnswer(new Answer<Account>() {
@Override
public Account answer(InvocationOnMock invocation) throws Throwable {
Account a = invocation.getArgumentAt(0, Account.class);
return a;
}
});
mvc.perform(put("/withdraw/1/1000"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(jsonPath("$.balance", is(4000)));
}
}
尤其是在微服务的背景下,我们可以很容易地模拟 Feign 客户端通信。下面的例子测试类验证了order-service中用于提款的端点,通过调用account-service暴露的端点。正如你可能已经注意到的,那个端点已经被之前介绍的测试类测试过了。这是order-service的带有单元测试实现的类:
@RunWith(SpringRunner.class)
@WebMvcTest(OrderController.class)
public class OrderControllerTest {
@Autowired
MockMvc mvc;
@MockBean
OrderRepository repository;
@MockBean
AccountClient accountClient;
@Test
public void testAccept() throws Exception {
Order order = new Order("1", OrderStatus.ACCEPTED, 2000, "1", "1", null);
when(repository.findOne("1")).thenReturn(order);
when(accountClient.withdraw(order.getAccountId(), order.getPrice())).thenReturn(new Account("1", "123", 0));
when(repository.save(Mockito.any(Order.class))).thenAnswer(new Answer<Order>() {
@Override
public Order answer(InvocationOnMock invocation) throws Throwable {
Order o = invocation.getArgumentAt(0, Order.class);
return o;
}
});
mvc.perform(put("/1"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(jsonPath("$.status", is("DONE")));
}
}
组件测试
如果你为应用程序中的所有关键类和接口提供了单元测试,你可以继续进行组件测试。组件测试的主要思想是使用内存中的测试替身和数据存储实例化完整的微服务。这允许我们跳过网络连接。而在单元测试中,我们模拟了所有的数据库或 HTTP 客户端,在这里我们不模拟任何东西。我们为数据库客户端提供内存中的数据源,并为 REST 客户端模拟 HTTP 响应。
使用内存数据库运行测试
我选择 MongoDB 的一个原因是因为它很容易与 Spring Boot 应用程序集成以用于测试目的。为了为你的项目启用嵌入式 MongoDB,请在 Maven 的pom.xml中包含以下依赖项:
<dependency>
<groupId>de.flapdoodle.embed</groupId>
<artifactId>de.flapdoodle.embed.mongo</artifactId>
<scope>test</scope>
</dependency>
Spring Boot 为内嵌 MongoDB 提供了自动配置,所以我们除了在application.yml中设置本地地址和端口外,不需要做任何事情。因为默认情况下,我们使用运行在 Docker 容器上的 Mongo,所以我们应该在一个额外的 Spring 配置文件中声明这样的配置。这个特定的配置文件在测试用例执行期间通过在测试类上使用@ActiveProfiles注解来激活。下面是application.yml文件的一个片段,我们定义了两个配置文件dev和test,它们具有不同的 MongoDB 连接设置:
---
spring:
profiles: dev
data:
mongodb:
host: 192.168.99.100
port: 27017
database: micro
username: micro
password: micro123
---
spring:
profiles: test
data:
mongodb:
host: localhost
port: 27017
如果你使用的是除 MongoDB 之外的数据库,例如 MySQL 或 Postgres,你可以很容易地将它们替换为替代的、基于内存的、内嵌的关系型数据库,如 H2 或 Derby。Spring Boot 支持它们,并为可能通过@DataJpaTest激活的测试提供自动配置。除了使用@SpringBootTest之外,你还可以使用@DataMongoTest注解来进行内嵌 MongoDB 的测试。这不仅会配置一个基于内存的内嵌 MongoDB,还会配置一个MongoTemplate,扫描带有@Document注解的类,并配置 Spring Data MongoDB 仓库。
处理 HTTP 客户端和服务发现
有关使用内嵌数据库测试持久化的 issue 已经解决。然而,我们仍然需要考虑测试的其他方面,例如模拟来自其他服务的 HTTP 响应或与服务发现集成。当你为微服务实现一些测试时,你可以选择服务发现的两种典型方法。第一种是在测试用例执行期间将发现服务器嵌入到应用程序中,第二种只是禁用在客户端上的发现。第二种选项通过 Spring Cloud 相对容易地进行配置。对于 Eureka Server,可以通过设置eureka.client.enabled=false属性来禁用它。
这只是练习的第一部分。我们还应该禁用 Ribbon 客户端的服务发现功能,它负责服务间通信的负载均衡。如果有多个目标服务,我们必须给每个客户端打上服务名称的标签。下面配置文件中最后一个属性的值listOfServers与用于自动化测试实现的框架密切相关。我将向你展示一个基于 Hoverfly Java 库的示例,该库在第七章《高级负载均衡和断路器》中已经介绍过,用于模拟调用目标服务时的延迟,以展示 Ribbon 客户端和 Hystrix 如何处理网络超时。在这里,我们只是使用它来返回预制的响应,使我们的组件测试涉及到网络通信。下面是配置文件的一个片段,其中包含负责禁用 Eureka 发现和设置 Ribbon 客户端测试属性的配置文件。该配置文件还应通过用@ActiveProfiles注解来激活测试类:
---
spring:
profiles: no-discovery
eureka:
client:
enabled: false
account-service:
ribbon:
eureka:
enable: false
listOfServers: account-service:8080
customer-service:
ribbon:
eureka:
enable: false
listOfServers: customer-service:8080
product-service:
ribbon:
eureka:
enable: false
listOfServers: product-service:8080
我不想深入讲解 Hoverfly 的使用细节,因为这在第七章《高级负载均衡和断路器》中已经讨论过了,理查德·费曼。正如你可能记得的,Hoverfly 可以通过声明@ClassRule和HoverflyRule来为 JUnit 测试激活,通过定义需要模拟的服务和端点的列表来实现。每个服务的名称必须与其在listOfServers属性中定义的地址相同。下面是一个定义 Hoverfly 测试规则的示例,该规则模拟来自三个不同服务的响应:
@ClassRule
public static HoverflyRule hoverflyRule = HoverflyRule
.inSimulationMode(dsl(
service("account-service:8080")
.put(startsWith("/withdraw/"))
.willReturn(success("{\"id\":\"1\",\"number\":\"1234567890\",\"balance\":5000}", "application/json")),
service("customer-service:8080")
.get("/withAccounts/1")
.willReturn(success("{\"id\":\"{{ Request.Path.[1] }}\",\"name\":\"Test1\",\"type\":\"REGULAR\",\"accounts\":[{\"id\":\"1\",\"number\":\"1234567890\",\"balance\":5000}]}", "application/json")),
service("product-service:8080")
.post("/ids").anyBody()
.willReturn(success("[{\"id\":\"1\",\"name\":\"Test1\",\"price\":1000}]", "application/json"))))
.printSimulationData();
实现示例测试
为了总结前两节所讲的内容,我们现在将准备一个使用内存内嵌入的 MongoDB、Hoverfly(用于模拟 HTTP 响应)和服务发现禁用的组件测试。专门为我们测试目的准备的正确配置设置位于test和no-discovery配置文件中。每个组件测试都是通过TestRestTemplate初始化的,它调用order-service的 HTTP 端点。测试结果的验证可以基于 HTTP 响应或存储在嵌入式 MongoDB 中的数据。下面是针对order-service的组件测试的一个示例实现:
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
@ActiveProfiles({"test", "no-discovery"})
public class OrderComponentTest {
@Autowired
TestRestTemplate restTemplate;
@Autowired
OrderRepository orderRepository;
// ...
@Test
public void testAccept() {
Order order = new Order(null, OrderStatus.ACCEPTED, 1000, "1", "1", Collections.singletonList("1"));
order = orderRepository.save(order);
restTemplate.put("/{id}", null, order.getId());
order = orderRepository.findOne(order.getId());
Assert.assertEquals(OrderStatus.DONE, order.getStatus());
}
@Test
public void testPrepare() {
Order order = new Order(null, OrderStatus.NEW, 1000, "1", "1", Collections.singletonList("1"));
order = restTemplate.postForObject("/", order, Order.class);
Assert.assertNotNull(order);
Assert.assertEquals(OrderStatus.ACCEPTED, order.getStatus());
Assert.assertEquals(940, order.getPrice());
}
}
集成测试
在创建单元和组件测试之后,我们已经验证了微服务中的所有功能。然而,我们仍然需要测试与其他服务、外部数据存储和缓存的交互。在基于微服务的架构集成测试中,测试的处理方式与单体应用程序中的处理方式不同。因为所有内部模块之间的关系都通过组件测试进行了测试,所以我们只测试了与外部组件交互的模块。
分类测试
把集成测试分离到 CI 管道中也是有意义的,这样外部故障就不会阻塞或破坏项目的构建。你应该通过用@Category注解标记它们来分类你的测试。你可以为集成测试创建一个特别的接口,例如IntegrationTest:
public interface IntegrationTest { }
然后,你可以使用@Category注解标记你的测试:
@Category(IntegrationTest.class)
public class OrderIntegrationTest { ... }
最后,你可以配置 Maven 只运行选定的测试类型,例如,使用maven-failsafe-plugin:
<plugin>
<artifactId>maven-failsafe-plugin</artifactId>
<dependencies>
<dependency>
<groupId>org.apache.maven.surefire</groupId>
<artifactId>surefire-junit47</artifactId>
</dependency>
</dependencies>
<configuration>
<groups>pl.piomin.services.order.IntegrationTest</groups>
</configuration>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
</goals>
<configuration>
<includes>
<include>**/*.class</include>
</includes>
</configuration>
</execution>
</executions>
</plugin>
捕获 HTTP 流量
分类是处理自动化测试期间与外部微服务通信问题的方法之一。另一种流行的方法涉及记录外出请求和进入响应,以便在未来不建立与外部服务的连接的情况下使用它们。
在之前的示例中,我们只是使用了 Hoverfly 的模拟模式。然而,它也可以以捕获模式运行,这意味着请求将像往常一样发送到真实服务,但它们将被 Hoverfly 拦截、记录并存储在文件中。存储在 JSON 格式的捕获流量文件随后可以在模拟模式下使用。你可以在你的 JUnit 测试类中创建一个 Hoverfly 规则,如果模拟文件不存在,它将以捕获模式启动,如果存在,则以模拟模式启动。它总是存储在src/test/resources/hoverfly目录中。
这是一种简单的方法,用于打破对外部服务的依赖。例如,如果你知道那里没有发生变化,那么与真实服务交互就不是必要的。如果这样的服务被修改了,你可以删除 JSON 模拟文件,从而切换到捕获模式。如果你的测试失败了,这意味着修改影响到了你的服务,你需要在回到捕获模式之前进行一些修复。
这是一个位于order-service内的集成测试示例。它添加了一个新账户,然后调用从该账户取款的的方法。由于使用了inCaptureOrSimulationMode方法,只有在account.json文件不存在或你更改了传递给服务的输入数据时,才会调用真实服务:
@RunWith(SpringRunner.class)
@SpringBootTest
@ActiveProfiles("dev")
@Category(IntegrationTest.class)
public class OrderIntegrationTest {
@Autowired
AccountClient accountClient;
@Autowired
CustomerClient customerClient;
@Autowired
ProductClient productClient;
@Autowired
OrderRepository orderRepository;
@ClassRule
public static HoverflyRule hoverflyRule = HoverflyRule.inCaptureOrSimulationMode("account.json").printSimulationData();
@Test
public void testAccount() {
Account account = accountClient.add(new Account(null, "123", 5000));
account = accountClient.withdraw(account.getId(), 1000);
Assert.notNull(account);
Assert.equals(account.getBalance(), 4000);
}
}
合同测试
有一些有趣的工具专门用于合同测试。我们将通过查看最受欢迎的两个工具——Pact 和 Spring Cloud Contract——来讨论这个概念。
使用 Pact
正如我们已经在前面提到的,合同测试的主要概念是定义消费者和提供者之间的合同,然后独立地为每个服务验证它。由于创建和维护合同的责任主要在消费者端,这种类型的测试通常被称为消费者驱动的测试。在 Pact JVM 中,消费者和提供者端的分界是非常明显的。它提供了两个分离的库,第一个以pact-jvm-consumer为前缀,第二个以pact-jvm-provider为前缀。当然,合同是由消费者与提供商共同创建和维护的,这在下面的图表中已经说明:

Pact 实际上是一组提供支持消费者驱动合同测试的框架集合。这些实现适用于不同的语言和框架。幸运的是,Pact 可以与 JUnit 和 Spring Boot 一起使用。考虑我们在示例系统中实现的一个集成,即customer-service和account-service之间的集成。名为customer-service的微服务使用 Feign 客户端与account-service进行通信。消费者端的 Feign 客户端定义实际上代表我们的合同:
@FeignClient(name = "account-service")
public interface AccountClient {
@GetMapping("/customer/{customerId}")
List<Account> findByCustomer(@PathVariable("customerId") String customerId);
}
消费者端
要在消费者端启用带有 JUnit 支持的 Pact,请将以下依赖项包含在你的项目中:
<dependency>
<groupId>au.com.dius</groupId>
<artifactId>pact-jvm-consumer-junit_2.12</artifactId>
<version>3.5.12</version>
<scope>test</scope>
</dependency>
现在我们只需要创建一个 JUnit 测试类。我们可以通过用@SpringBootTest注解它并使用 Spring Runner 运行它来实现一个标准的 Spring Boot 测试。为了成功执行创建的测试,我们首先需要禁用发现客户端,并确保 Ribbon 客户端将使用@Rule PactProviderRuleMk2与account-service的存根进行通信。测试的关键点是callAccountClient方法,它用@Pact注解并返回一个RequestResponsePact。它定义了请求的格式和响应的内容。在测试用例执行期间,Pact 会自动生成该定义的 JSON 表示,该表示位于target/pacts/addressClient-customerServiceProvider.json文件中。最后,在用@PactVerification注解的测试方法中调用 Feign 客户端实现的方法,并验证 Pact @Rule返回的响应。下面是针对customer-service的消费者端合同测试的一个示例实现:
@RunWith(SpringRunner.class)
@SpringBootTest(properties = {
"account-service.ribbon.listOfServers: localhost:8092",
"account-service.ribbon.eureka.enabled: false",
"eureka.client.enabled: false",
})
public class CustomerConsumerContractTest {
@Rule
public PactProviderRuleMk2 stubProvider = new PactProviderRuleMk2("customerServiceProvider", "localhost", 8092, this);
@Autowired
private AccountClient accountClient;
@Pact(state = "list-of-3-accounts", provider = "customerServiceProvider", consumer = "accountClient")
public RequestResponsePact callAccountClient(PactDslWithProvider builder) {
return builder.given("list-of-3-accounts").uponReceiving("test-account-service")
.path("/customer/1").method("GET").willRespondWith().status(200)
.body("[{\"id\":\"1\",\"number\":\"123\",\"balance\":5000},{\"id\":\"2\",\"number\":\"124\",\"balance\":5000},{\"id\":\"3\",\"number\":\"125\",\"balance\":5000}]", "application/json").toPact();
}
@Test
@PactVerification(fragment = "callAccountClient")
public void verifyAddressCollectionPact() {
List<Account> accounts = accountClient.findByCustomer("1");
Assert.assertEquals(3, accounts.size());
}
}
在target/pacts目录中生成的 JSON 测试结果文件必须在提供者一侧可用。最简单的解决方案假设它可以通过使用@PactFolder注解来访问生成的文件。当然,这需要提供者能够访问target/pacts目录。尽管这对我们的示例有效,因为其源代码存储在同一个 Git 仓库中,但这不是我们的目标解决方案。幸运的是,我们可以使用 Pact Broker 在网络上发布 Pact 测试结果。Pact Broker 是一个提供 HTTP API 用于发布和消费 Pact 文件的存储库服务器。我们可以使用其 Docker 镜像启动 Pact Broker。它需要一个 Postgres 数据库作为后端存储,所以我们还需要启动带有 Postgres 的容器。以下是所需的 Docker 命令:
docker run -d --name postgres -p 5432:5432 -e POSTGRES_USER=oauth -e POSTGRES_PASSWORD=oauth123 -e POSTGRES_DB=oauth postgres
docker run -d --name pact-broker --link postgres:postgres -e PACT_BROKER_DATABASE_USERNAME=oauth -e PACT_BROKER_DATABASE_PASSWORD=oauth123 -e PACT_BROKER_DATABASE_HOST=postgres -e PACT_BROKER_DATABASE_NAME=oauth -p 9080:80 dius/pact_broker
在 Docker 上运行 Pact Broker 后,我们必须在那里发布我们的测试报告。我们可以使用pact-jvm-provider-maven_2.12插件轻松地执行此操作。如果您运行mvn clean install pack:publish命令,所有放置在/target/pacts目录中的文件都将发送到代理的 HTTP API:
<plugin>
<groupId>au.com.dius</groupId>
<artifactId>pact-jvm-provider-maven_2.12</artifactId>
<version>3.5.12</version>
<configuration>
<pactBrokerUrl>http://192.168.99.100:9080</pactBrokerUrl>
</configuration>
</plugin>
已发布 Pact 的完整列表可以通过在http://192.168.99.100:9080上可用的 web 控制台显示。它还提供了列表中每个 Pact 的最后验证日期和详细信息,如下面的屏幕截图所示:

生产者一侧
假设消费者已经在代理上创建了一个 Pact 并发布了它,我们可以在提供者一侧继续实现验证测试。要在提供者一侧启用支持 Pact 的 JUnit,请在项目中包含pact-jvm-provider-junit依赖项。还有一个可用的框架,pact-jvm-provider-spring。这个库允许您使用 Spring 和 JUnit 对提供者运行合同测试。所需依赖项如下面的 Maven pom.xml片段所示:
<dependency>
<groupId>au.com.dius</groupId>
<artifactId>pact-jvm-provider-junit_2.12</artifactId>
<version>3.5.12</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>au.com.dius</groupId>
<artifactId>pact-jvm-provider-spring_2.12</artifactId>
<version>3.5.12</version>
<scope>test</scope>
</dependency>
由于有专门针对 Spring 的库,我们可以使用SpringRestPactRunner而不是默认的PactRunner。这反过来又允许您使用 Spring 测试注解,如@MockBean。在下面的 JUnit 测试中,我们模拟了AccountRepositorybean。它返回测试消费者一侧期望的三个对象。测试自动启动 Spring Boot 应用程序并调用/customer/{customerId}端点。还有另外两个重要的事情。通过使用@Provider和@State注解,我们需要在@Pact注解中设置与消费者一侧测试相同的名称。最后,通过在测试类上声明@PactBroker,我们提供了连接到 Pact 存储库的设置。以下是使用 Pact 的示例测试,验证由customer-service发布的合同:
@RunWith(SpringRestPactRunner.class)
@Provider("customerServiceProvider")
@PactBroker(host = "192.168.99.100", port = "9080")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT, properties = { "eureka.client.enabled: false" })
public class AccountProviderContractTest {
@MockBean
private AccountRepository repository;
@TestTarget
public final Target target = new HttpTarget(8091);
@State("list-of-3-accounts")
public void toDefaultState() {
List<Account> accounts = new ArrayList<>();
accounts.add(new Account("1", "123", 5000, "1"));
accounts.add(new Account("2", "124", 5000, "1"));
accounts.add(new Account("3", "125", 5000, "1"));
when(repository.findByCustomerId("1")).thenReturn(accounts);
}
}
使用 Spring Cloud Contract
- Spring Cloud Contract 在合同测试方面提出了与 Pack 略有不同的方法。在 Pack 中,消费者负责发布合同,而在 Spring Cloud Contract 中,这一行动的发起者是提供者。合同作为 JAR 存储在 Maven 仓库中,其中包含基于合同定义文件自动生成的存根。这些定义可以使用 Groovy DSL 语法创建。每个定义都包含两部分:请求和响应规格。基于这些文件,Spring Cloud Contract 生成 JSON 存根定义,这些定义由 WireMock 用于客户端方面的集成测试。与用作支持 REST API 的消费者驱动合同测试工具的 Pact 相比,它特别设计用于测试基于 JVM 的微服务。它包含三个子项目:
-
- Spring Cloud Contract Verifier
-
Spring Cloud Contract Stub Runner
-
- Spring Cloud Contract WireMock
-
让我们分析如何根据之前在 Pact 框架部分描述的相同示例来使用它们进行合同测试。
-
WireMock 是一个基于 HTTP 的 API 模拟器。有些人可能认为它是一个服务虚拟化工具或模拟服务器。它可以通过捕获现有 API 的流量快速启动。
- 定义合同并生成存根
-
正如我已经在前面提到的,与 Pact 相比,在 Spring Cloud Contract 中,提供者(服务器端)负责发布合同规格。因此,我们将从
account-service开始实现,该服务是customer-service调用的端点。但在继续实现之前,看看下面的图表。它描述了在我们测试过程中参与的主要组件。示例应用程序的源代码可在 GitHub 仓库中的上一个示例的不同分支 contract 中找到: -
![]()
-
为了在提供者端应用程序中启用 Spring Cloud Contract 的功能,首先你必须将 Spring Cloud Contract Verifier 添加到你的项目依赖中:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-contract-verifier</artifactId>
<scope>test</scope>
</dependency>
- 下一步是添加 Spring Cloud Contract Verifier Maven 插件,该插件生成并运行你的合同测试。它还会生成并安装存根到本地 Maven 仓库中。你必须为它定义的唯一参数是生成的测试类所扩展的基本类所在的包:
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<version>1.2.0.RELEASE</version>
<extensions>true</extensions>
<configuration>
<packageWithBaseClasses>pl.piomin.services.account</packageWithBaseClasses>
</configuration>
</plugin>
现在,我们必须为合同测试创建一个基类。它应该放在pl.piomin.services.account包内。在下面的基类中,我们用@SpringBootTest设置了 Spring Boot 应用程序,然后模拟了AccountRepository。我们还使用RestAssured来模拟 Spring MVC,只向我们的控制器发送请求。由于所有的模拟,测试不与任何外部组件(如数据库或 HTTP 端点)交互,只测试合同:
@RunWith(SpringRunner.class)
@SpringBootTest(classes = {AccountApplication.class})
public abstract class AccountProviderTestBase {
@Autowired
private WebApplicationContext context;
@MockBean
private AccountRepository repository;
@Before
public void setup() {
RestAssuredMockMvc.webAppContextSetup(context);
List<Account> accounts = new ArrayList<>();
accounts.add(new Account("1", "123", 5000, "1"));
accounts.add(new Account("2", "124", 5000, "1"));
accounts.add(new Account("3", "125", 5000, "1"));
when(repository.findByCustomerId("1")).thenReturn(accounts);
}
}
我们已经提供了所有运行与 Spring Cloud Contract 一起的测试所需的配置和基类。因此,我们可以进行最重要的部分,使用 Spring Cloud Contract Groovy DSL 定义合同。所有合同的规格都应该位于/src/test/resources/contracts目录下。这个目录下具体的位置,包含存根定义,被视为基测试类名。每个存根定义代表一个单独的合同测试。根据这个规则,spring-cloud-contract-maven-plugin会自动找到合同并将其分配给基测试类。在我们当前讨论的示例中,我把我的存根定义放在了/src/test/resources/contracts/accountService目录下。因此生成的测试类名是AccountServiceTest,并且它也继承了AccountServiceBase类。
这是返回属于客户账户列表的示例合同规格。这个合同并不简单,所以有些东西需要解释。你可以使用正则表达式来编写你的请求 Contract DSL。你还可以为每个属性提供不同的值,这取决于通信方(消费者或生产者)。Contract DSL 还允许你通过使用fromRequest方法来引用请求。下面的合同返回了三个账户列表,从请求路径中获取customerId字段和由五位数字组成的id字段:
org.springframework.cloud.contract.spec.Contract.make {
request {
method 'GET'
url value(consumer(regex('/customer/[0-9]{3}')), producer('/customer/1'))
}
response {
status 200
body([
[
id: $(regex('[0-9]{5}')),
number: '123',
balance: 5000,
customerId: fromRequest().path(1)
], [
id: $(regex('[0-9]{5}')),
number: '124',
balance: 5000,
customerId: fromRequest().path(1)
], [
id: $(regex('[0-9]{5}')),
number: '125',
balance: 5000,
customerId: fromRequest().path(1)
]
])
headers {
contentType(applicationJson())
}
}
}
测试类在 Maven 构建的测试阶段会在target/generated-test-sources目录下生成。下面是早先描述的合同规格生成的类:
public class AccountServiceTest extends AccountServiceBase {
@Test
public void validate_customerContract() throws Exception {
// given:
MockMvcRequestSpecification request = given();
// when:
ResponseOptions response = given().spec(request)
.get("/customer/1");
// then:
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.header("Content-Type")).matches("application/json.*");
// and:
DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
assertThatJson(parsedJson).array().contains("['number']").isEqualTo("123");
assertThatJson(parsedJson).array().contains("['balance']").isEqualTo(5000);
assertThatJson(parsedJson).array().contains("['number']").isEqualTo("124");
assertThatJson(parsedJson).array().contains("['customerId']").isEqualTo("1");
assertThatJson(parsedJson).array().contains("['id']").matches("[0-9]{5}");
}
}
在消费者侧验证合同
假设我们已经成功在提供者侧构建并运行了测试,存根将会被生成,然后发布在我们的本地 Maven 仓库中。为了能够在消费者应用程序测试时使用它们,我们应该将 Spring Cloud Contract Stub Runner 添加到项目依赖中:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
<scope>test</scope>
</dependency>
然后我们应该用@AutoConfigureStubRunner注解我们的测试类。它接受两个输入参数—ids和workOffline。Ids字段是artifactId、groupId、版本号、stubs限定符和端口号的组合,通常指出提供者发布的存根的 JAR。workOffline标志指示存根仓库的位置。默认情况下,消费者尝试自动从 Nexus 或 Artifactory 下载工件。如果你想要强制 Spring Cloud Contract Stub Runner 只从本地 Maven 仓库下载存根,可以将workOffline参数的值切换为true。
以下是一个使用 Feign 客户端调用由提供方发布的存根的端点的 JUnit 测试类。Spring Cloud Contract 查找pl.piomin.services:account-service工件的最新版本。这通过在@AutoConfigureStubRunner注解中传递+作为存根的版本来指示。如果你想要使用该工件的具体版本,你可以在pom.xml文件中设置当前版本而不是+,例如,@AutoConfigureStubRunner(ids = {"pl.piomin.services:account-service:1.0-SNAPSHOT:stubs:8091"}):
@RunWith(SpringRunner.class)
@SpringBootTest(properties = {
"eureka.client.enabled: false"
})
@AutoConfigureStubRunner(ids = {"pl.piomin.services:account-service:+:stubs:8091"}, workOffline = true)
public class AccountContractTest {
@Autowired
private AccountClient accountClient;
@Test
public void verifyAccounts() {
List<Account> accounts = accountClient.findByCustomer("1");
Assert.assertEquals(3, accounts.size());
}
}
剩下要做的就是使用mvn clean install命令来构建整个项目,以验证测试是否成功运行。然而,我们应该记住,之前创建的测试只覆盖了customer-service和account-service之间的集成。在我们的示例系统中,还有其他一些微服务之间的集成应该被验证。我会再给你一个例子,它测试了整个系统。它测试了order-service中暴露的方法,该服务与其他所有微服务进行通信。为此,我们将使用 Spring Cloud Contract 场景的另一个有趣特性。
场景
使用 Spring Cloud Contract 定义场景并不困难。你只需要在做合同创建时提供合适的命名约定。这个约定假设每个场景中的合同名称都由一个序号和一个下划线前缀。一个场景中包含的所有合同必须位于同一个目录中。Spring Cloud Contract 场景基于 WireMock 的场景。以下是一个包含为创建和接受订单需求定义的合同的目录结构:
src\main\resources\contracts
orderService\
1_createOrder.groovy
2_acceptOrder.groovy
以下是为此场景生成的测试源代码:
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class OrderScenarioTest extends OrderScenarioBase {
@Test
public void validate_1_createOrder() throws Exception {
// ...
}
@Test
public void validate_2_acceptOrder() throws Exception {
// ...
}
}
现在,让我们想象一下我们有很多微服务,其中大多数都与其他一个或多个微服务进行通信。所以,即使你测试了一个单一的合约,你也不能确保所有其他在服务间通信过程中的合约都能如预期般工作。然而,借助 Spring Cloud Contract,你完全可以轻松地将所有必需的存根(stubs)包含到你的测试类中。这赋予了你验证所有合约在定义场景中的能力。为此,你必须将spring-cloud-starter-contract-verifier和spring-cloud-starter-contract-stub-runner这两个依赖项包含到项目中。下面的类定义作为 Spring Cloud Contract 测试类的基类,并包含了由其他微服务生成的存根。为order-service端点生成的存根可以被任何其他需要与order-service验证合约的外部服务使用。如下面的测试代码不仅会验证本服务与order-service之间的合约,还会验证order-service与其他被该服务使用的服务之间的合约:
@RunWith(SpringRunner.class)
@SpringBootTest(properties = {
"eureka.client.enabled: false"
})
@AutoConfigureStubRunner(ids = {
"pl.piomin.services:account-service:+:stubs:8091",
"pl.piomin.services:customer-service:+:stubs:8092",
"pl.piomin.services:product-service:+:stubs:8093"
}, workOffline = true)
public class OrderScenarioBase {
@Autowired
private WebApplicationContext context;
@MockBean
private OrderRepository repository;
@Before
public void setup() {
RestAssuredMockMvc.webAppContextSetup(context);
when(repository.countByCustomerId(Matchers.anyString())).thenReturn(0);
when(repository.save(Mockito.any(Order.class))).thenAnswer(new Answer<Order>() {
@Override
public Order answer(InvocationOnMock invocation) throws Throwable {
Order o = invocation.getArgumentAt(0, Order.class);
o.setId("12345");
return o;
}
});
}
}
性能测试
我们还需要讨论一种自动化测试的最后类型。在本章的开头已经提到了它。我当然是在谈论性能测试。有一些非常有趣的工具和框架可以帮助你创建和运行这类测试。如果我们谈论的是 HTTP API 测试,特别是在仪器选择上有很多选择。我不想讨论它们全部,但我将讨论一个可能会有帮助的框架。它就是 Gatling。让我们更仔细地看看它。
Gatling
Gatling 是一个用 Scala 编写的开源性能测试工具。它允许你使用一种易于阅读和编写的领域特定语言(DSL)来开发测试。它通过生成详尽、图表化的负载报告,展示了测试过程中收集的所有指标,从而区别于其他竞争对手。还有插件可用于将 Gatling 与 Gradle、Maven 和 Jenkins 集成。
启用 Gatling
为了使项目启用 Gatling 框架,我们应该在依赖项中包含io.gatling.highcharts:gatling-charts-highcharts这个构件。
定义测试场景
每个 Gatling 测试套件都应该扩展Simulation类。在每一个测试类中,我们可以使用 Gatling Scala DSL 声明一系列场景。我们通常会声明可以同时调用 HTTP 端点的线程数以及每个线程发送的请求总数。在 Gatling 的术语中,线程数是由使用atOnceUsers方法设置的用户数决定的。测试类应该放在src/test/scala目录中。
假设我们想要测试由order-service暴露的两个端点,在该服务上运行 20 个客户端,每个客户端按顺序发送 500 个请求,总共将发送 20,000 个请求。通过在短时间内发送它们全部,我们能够测试我们应用程序的性能。
下面的测试场景是用 Scala 编写的。让我们仔细看看。在运行这个测试之前,我通过调用account-service和product-service暴露的 HTTP API 创建了一些账户和产品。因为它们连接到一个外部数据库,所以 ID 是自动生成的。为了提供一些测试数据,我将它们复制到了测试类中。账户和产品 ID 的列表都被传递到测试场景作为数据源。然后,在每次迭代中,都会从列表中随机选择所需值。我们的测试场景名为AddAndConfirmOrder。它由两个exec方法组成。第一个方法通过调用POST /orderHTTP 方法创建一个新订单。订单 ID 由服务自动生成,因此应该作为属性保存。然后,它可以用在下一个exec方法中,通过调用PUT /order/{id}端点确认订单。测试后验证的唯一事情是 HTTP 状态:
class OrderApiGatlingSimulationTest extends Simulation {
val rCustomer = Iterator.continually(Map("customer" -> List("5aa8f5deb44f3f188896f56f", "5aa8f5ecb44f3f188896f570", "5aa8f5fbb44f3f188896f571", "5aa8f620b44f3f188896f572").lift(Random.nextInt(4)).get))
val rProduct = Iterator.continually(Map("product" -> List("5aa8fad2b44f3f18f8856ac9","5aa8fad8b44f3f18f8856aca","5aa8fadeb44f3f18f8856acb","5aa8fae3b44f3f18f8856acc","5aa8fae7b44f3f18f8856acd","5aa8faedb44f3f18f8856ace","5aa8faf2b44f3f18f8856acf").lift(Random.nextInt(7)).get))
val scn = scenario("AddAndConfirmOrder").feed(rCustomer).feed(rProduct).repeat(500, "n") {
exec(
http("AddOrder-API")
.post("http://localhost:8090/order")
.header("Content-Type", "application/json")
.body(StringBody("""{"productIds":["${product}"],"customerId":"${customer}","status":"NEW"}"""))
.check(status.is(200), jsonPath("$.id").saveAs("orderId"))
)
.
exec(
http("ConfirmOrder-API")
.put("http://localhost:8090/order/${orderId}")
.header("Content-Type", "application/json")
.check(status.is(200))
)
}
setUp(scn.inject(atOnceUsers(20))).maxDuration(FiniteDuration.apply(10, "minutes"))
}
运行一个测试场景
有几种不同的方法可以在你的机器上运行 Gatling 性能测试。其中一种是通过可用的 Gradle 插件之一,它提供在项目构建过程中运行测试的支持。你也可以使用 Maven 插件,或者尝试从你的 IDE 中运行它。如果你用 Gradle 构建你的项目,你还可以定义简单的任务,只需通过启动io.gatling.app.Gatling主类来运行测试。下面是在gradle.build文件中此类任务的定义:
task loadTest(type: JavaExec) {
dependsOn testClasses
description = "Load Test With Gatling"
group = "Load Test"
classpath = sourceSets.test.runtimeClasspath
jvmArgs = [
"-Dgatling.core.directory.binaries=${sourceSets.test.output.classesDir.toString()}"
]
main = "io.gatling.app.Gatling"
args = [
"--simulation", "pl.piomin.services.gatling.OrderApiGatlingSimulationTest",
"--results-folder", "${buildDir}/gatling-results",
"--binaries-folder", sourceSets.test.output.classesDir.toString(),
"--bodies-folder", sourceSets.test.resources.srcDirs.toList().first().toString() + "/gatling/bodies",
]
}
现在你可以通过调用gradle loadTest命令来运行该任务。当然,在运行这些测试之前,你需要启动所有示例微服务、MongoDB 和discovery-service。默认情况下,Gatling 会打印发送的所有请求、收到的响应以及最终的测试结果,包括时间统计和成功与失败的 API 调用次数。如果你需要更详细的信息,你应该参考测试后生成的文件,这些文件可以在build/gatling-results目录下找到。你可能会发现那里的 HTML 文件以图表和图形的形式提供了可视化。其中的第一个(如图所示)显示了生成的请求总数以及按百分位数划分的最大响应时间。例如,你可能看到AddOrder API 的 95%响应中的最大响应时间是 835 毫秒:

还有一些其他有趣的统计数据进行了可视化。让我们特别关注以下两个报告。第一个报告显示了一个图表,显示按平均响应时间分组的请求百分比,而第二个报告则显示了按百分位数显示的平均响应时间的时间线:

总结
在本章中,我介绍了一些框架,这些框架可以帮助您有效地测试用 Java 编写的基于 REST 的应用程序。每个解决方案都被分配到一种特定的测试类型。我专注于与微服务直接相关的测试,例如契约测试和组件测试。本章的主要目标是比较两种最流行的用于契约测试的框架,即 Pact 和 Spring Cloud Contract。尽管它们看起来很相似,但实际上它们之间有一些显著的区别。我试图向您展示基于我们在前几章中查看的相同示例应用程序的最重要相似之处和差异。
微服务与自动化密切相关。请记住,从单体迁移到微服务为您提供了一个机会来重构您的代码,而且更重要的是,提高自动化测试的质量和代码覆盖率。当一起使用时,如 Mockito、Spring Test、Spring Cloud Contract 和 Pact 这样的框架为您提供了一个非常强大的解决方案,用于开发基于 REST 的 Java 微服务的测试。自动化测试是 CI/CD 过程的一个重要组成部分,下一章将讨论这一点。
第十四章:Docker 支持
我们已经在本书的第一部分讨论了微服务架构的基础和 Spring Cloud 项目。在第二部分中,我们研究了该架构的最常见元素,并讨论了如何使用 Spring Cloud 实现它们。到目前为止,我们已经谈到了与微服务迁移有关的一些重要主题,例如集中日志记录、分布式追踪、安全和自动化测试。现在,由于我们掌握了这些知识,我们可以继续讨论书的最后一部分,那里我们将讨论微服务作为一种云原生开发方法的真正力量。使用容器化工具将应用程序彼此隔离、在软件交付过程中实现持续部署以及轻松扩展应用程序的能力,所有这些都有助于微服务的迅速普及。
正如您可能还记得早前的章节,我们使用了 Docker 镜像在本地机器上运行第三方工具和解决方案。有了这个前提,我想向您介绍 Docker 的主要概念,比如其基本命令和使用场景。这些信息将帮助您运行前几章中呈现的示例。然后,我们将讨论如何使用我们的示例 Spring Boot 应用程序来构建镜像,以及如何在本地机器上的容器内运行它们。为此,我们将使用简单的 Docker 命令,以及更高级的工具,如 Jenkins 服务器,它帮助您执行完整的、持续的交付,并在您的组织中启用持续集成流程。最后,我们将介绍用于自动化部署、扩展和管理容器化应用程序的最受欢迎的工具之一:Kubernetes。我们所有的示例都将在通过 Minikube 运行的单节点 Kubernetes 集群上本地运行。
本章我们将覆盖的主题如下:
-
最有用的 Docker 命令
-
使用 Spring Boot 微服务构建 Docker 容器
-
在 Docker 上运行 Spring Cloud 组件
-
使用 Jenkins 和 Docker 进行持续集成/持续交付
-
在 Minikube 上部署和运行微服务
介绍 Docker
Docker 是一个帮助你通过容器创建、部署和运行应用程序的工具。它旨在根据 DevOps 哲学,同时造福开发人员和系统管理员。Docker 通过解决与软件交付相关的一些重要问题来改进软件交付过程。其中一个关注点是不可变交付的概念,这与所谓的“对我有效”有关。当在 Docker 中工作时,尤其是重要的,开发者使用与生产中相同的镜像进行测试。唯一应该看到的不同是在配置上。在不可变交付模式下,软件交付对于微服务基础系统尤为重要,因为有很多独立部署的应用程序。多亏了 Docker,开发者现在可以专注于编写代码,而不用担心目标操作系统(应用程序将被启动的地方)。因此,操作人员可以使用相同的接口来部署、启动和维护所有应用程序。
还有许多其他原因促使 Docker 越来越受欢迎。毕竟,容器化概念在信息技术世界中并不是什么新事物。Linux 容器多年前就已经被引入,并自 2008 年起成为内核的一部分。然而,Docker 引入了几项其他技术和解决方案,这是其他技术所没有的。首先,它提供了一个简单的接口,允许你轻松地将应用程序及其依赖打包到一个容器中,然后在不同的 Linux 内核版本和实现中运行。容器可以在本地或远程的任何启用了 Docker 的服务器上运行,每个容器都在几秒钟内启动。我们还可以轻松地在容器外部对其执行每个命令。此外,Docker 镜像的共享和分发机制允许开发人员像分享源代码一样提交更改、推送和拉取镜像,例如使用 Git。目前,几乎所有最受欢迎的软件工具都在 Docker 中心以镜像的形式发布,有些我们已经成功用于运行我们样本应用程序所需的工具。
有一些基本的定义和元素构成了 Docker 架构,最重要的是容器。容器在单一机器上运行,并与该机器共享操作系统内核。它们包含运行特定软件所需的一切,包括运行时、系统工具、系统库和设置。容器是由 Docker 镜像中发现的指令创建的。镜像就像一种食谱或模板,定义了在容器上安装和运行必要软件的步骤。容器还可以与虚拟机相比较,因为它们具有类似的资源隔离和分配优势。然而,它们虚拟化操作系统而不是硬件,使它们比虚拟机更便携、更高效。以下图表展示了 Docker 容器与虚拟机之间的架构差异:

所有容器都部署在一个称为Docker 主机的物理或虚拟机上。Docker 主机反过来运行一个 Docker 守护进程,该守护进程通过 Docker API 监听 Docker 客户端发送的命令。Docker 客户端可能是命令行工具或其他软件,如 Kinematic。除了运行守护进程,Docker 主机还负责存储从这些镜像创建的缓存镜像和容器。每个镜像都是由一系列层构建的。每个层仅包含与父层相比的增量差异。这样的镜像不是很小,需要存储在其他地方。这个地方称为Docker 仓库。你可以创建自己的私有仓库,或者使用网络上的现有公共仓库。最受欢迎的仓库是 Docker Hub,其中包含几乎所有必需的镜像。
安装 Docker
Linux 下的 Docker 安装步骤因发行版而异(docs.docker.com/install/#supported-platforms)。然而,有时在安装后你不得不运行 Docker 守护进程,你可以通过调用以下命令来实现:
dockerd --host=unix:///var/run/docker.sock --host=tcp://0.0.0.0:2375
在本节中,我们将重点关注 Windows 平台的指令。通常,当你在 Windows 或 Mac 上安装 Docker Community Edition (CE)时有两种可用的选项。最快最简单的方法是使用 Docker for Windows,你可以在www.docker.com/docker-windows找到它。这是一个原生的 Windows 应用程序,为构建、部署和运行容器化应用程序提供了易于使用的开发环境。这绝对是利用最好的选择,因为它使用了 Windows 本地的 Hyper-V 虚拟化和网络。然而,有一个缺点——它仅适用于 Microsoft Windows 10 专业版或企业版 64 位。更早的 Windows 版本应该使用 Docker Toolbox,你可以在docs.docker.com/toolbox/toolbox_install_windows/下载到它。这包括 Docker 平台、带有 Docker Machine 的命令行、Docker Compose、Kitematic 和 VirtualBox。请注意,你不能在 Windows 上使用 Docker Toolbox 本地运行 Docker Engine,因为它使用了特定于 Linux 的内核功能。相反,你必须使用 Docker Machine 命令(docker-machine),它在本机上创建一个 Linux 虚拟机,并使用 Virtual Box 运行它。这个虚拟机可以通过默认的虚拟地址192.168.99.100被你的机器访问。所有之前讨论的示例都是与那个 IP 地址上可用的 Docker 工具集成的。
常用的 Docker 命令
在 Windows 上安装 Docker Toolbox 后,你应该运行 Docker 快速启动终端。它会完成所有需要做的事情,包括创建和启动 Docker Machine 以及提供命令行界面。如果你输入一个没有参数的 Docker 命令,你现在应该能够看到完整的可用 Docker 客户端命令列表及其描述。我们将要查看的就是这类命令:
-
运行和停止容器
-
列出并删除容器
-
拉取和推送镜像
-
构建镜像
-
网络配置
运行和停止容器
安装后通常运行的第一个 Docker 命令是docker run。正如您可能记得的,这个命令在前面的示例中是最常用的命令之一。这个命令做两件事:它从注册表中拉取和下载镜像定义,以防它没有在本地缓存,然后启动容器。对这个命令可以设置很多选项,您可以通过运行docker run --help来轻松查看这些选项。有些选项有一个字母的简写,这些通常是使用最频繁的选项。选项–d让容器在后台运行,而–i即使在未附加的情况下也保持stdin打开。如果您需要在容器外部暴露任何端口,您可以使用带有定义<port_outside_container>:<port_inside_container>的激活选项–p。一些镜像需要额外的配置,这通常通过环境变量完成,这些环境变量可以通过–e选项覆盖。为了轻松运行其他命令,设置容器的好友名称也很有用,使用--name选项。看看这里可见的示例 Docker 命令。它启动了带有 Postgres 的容器,创建了一个具有密码的数据库用户,并在端口55432上暴露它。现在,Postgres 数据库可以在地址192.168.99.100:55432上访问:
$ docker run -d --name pg -e POSTGRES_PASSWORD=123456 -e POSTGRES_USER=piomin -e POSTGRES_DB=example -p 55432:5432 postgres
带有 Postgres 的容器持久化数据。建议通过卷机制来存储外部应用程序访问的数据的容器。可以通过–v选项将卷传递给容器,其中值由冒号分隔的字段组成,:。第一个字段是卷的名称,第二个字段是在容器中挂载文件或目录的路径。下一个有趣的选项是使用–m选项限制容器分配的最大 RAM 量。以下是为新卷创建并挂载到启动的容器的命令。最大 RAM 设置为 500 MB。容器在停止后自动删除,使用激活的选项--rm,如下所示:
$ docker volume create pgdata
$ docker run --rm -it -e -m 500M -v pgdata:/var/lib/postgresql/data -p 55432:5432 postgres
每个运行中的容器都可以使用docker stop命令来停止。我们已经为我们的容器设置了一个名字,因此我们可以很容易地将其作为标签使用,如下所示:
$ docker stop pg
容器的整个状态都会写入磁盘,因此我们可以用完全相同的数据再次运行它,例如:
$ docker start pg
如果您只想重新启动容器,而不是停止/启动容器,您可以使用以下命令:
$ docker restart pg
列出和删除容器
如果你已经启动了一些容器,你可能想考虑显示你 Docker 机器上所有正在运行的容器列表。应该使用docker ps命令来实现。这个命令显示关于容器的一些基本信息,比如暴露的端口列表和源镜像的名称。这个命令只打印当前启动的容器。如果你想看到已停止或未活跃的容器,请在 Docker 命令中使用-a选项,如下所示:

如果一个容器不再需要,可以使用docker rm命令将其删除。有时你可能需要删除一个正在运行的容器,但默认情况下这是不允许的。要强制这个选项,请在 Docker 上使用以下命令设置-f选项:
$ docker rm -f pg
你应该记得docker ps命令只删除容器。它创建的镜像仍然在本地下缓存。这类镜像可能会占用相当大的空间,从兆字节到几百兆字节不等。你可以使用以下参数使用docker rmi命令删除每个镜像:
$ docker rmi 875263695ab8
我们还没有创建任何 Docker 镜像,但在创建镜像过程中很容易产生大量不需要或未命名的镜像。这些镜像很容易识别,因为它们的名称是<none>。在 Docker 的术语中,这些被称为悬空镜像,可以通过以下命令轻松删除。当前缓存的所有镜像列表可以使用docker images命令显示,如下所示:
$ docker rmi $(docker images -q -f dangling=true)
拉取和推送镜像
我们已经讨论过 Docker Hub。它是网络上最大的最受欢迎的 Docker 仓库。它位于hub.docker.com。Docker 客户端默认会尝试拉取该仓库的所有镜像。有许多经过认证的官方镜像,如 Redis、Java、Nginx 或 Mongo,但您也可以找到数十万人创建的镜像。如果您使用docker run命令,则镜像会在本地没有缓存的情况下从仓库拉取。您还可以运行以下docker pull命令,它只负责下载镜像:
$ docker pull postgres
前一个命令下载了一个镜像的最新版本(具有最新标签的名称)。如果你想要使用一个较老版本的 Postgres Docker 镜像,你应该在标签后加上具体版本的数字。通常,可用的所有版本列表会发布在镜像的网站上,这个情况也不例外。访问hub.docker.com/r/library/postgres/tags/获取可用标签的列表。
$ docker pull postgres:9.3
一旦你运行并验证了你的镜像,你应该考虑将其远程保存。当然,最合适的地方是 Docker Hub。然而,有时你可能想将镜像存储在其他存储中,比如私有仓库。在推送镜像之前,你必须使用你的注册表用户名、镜像名称和其版本号来标记它。以下命令从 Postgres 源镜像创建了一个名为piomin/postgres和1.0版本标签的新镜像:
$ docker tag postgres piomin/postgres:1.0
现在,如果你运行docker images命令,你会发现有两个具有相同 ID 的镜像。第一个镜像的名称是 Postgres,并且是最新的标签,而第二个镜像的名称是piomin/postgres,标签是1.0。重要的是piomin是我的 Docker Hub 用户名。因此,在进一步操作之前,我们首先应该在那里注册这个镜像。之后,我们还应该使用docker login命令登录到我们的 Docker 客户端。在这里,系统会提示你输入用户名、密码和用于注册的电子邮件地址。最后,你可以使用以下docker push命令推送一个带标签的镜像:
$ docker push piomin/postgres:1.0
现在剩下的所有事情就是使用网络浏览器登录到你的 Docker Hub 账户,以检查推送到那里的镜像是否出现。如果一切工作正常,你将在网站上看到一个新的公开仓库和你的镜像。下面的屏幕截图显示了我 Docker Hub 账户中当前推送的镜像:

构建镜像
在上一节中,我们将 Postgres 的 Docker 镜像副本推送到 Docker Hub 仓库。通常,我们会将基于文件Dockerfile创建的自己的镜像推送到仓库,Dockerfile中定义了安装和配置软件所需的所有指令。关于Dockerfile结构的细节将在后面讨论。现在重要的是构建 Docker 镜像时使用的命令,即docker build。这个命令应该在Dockerfile所在的同一个目录中运行。构建新镜像时,建议使用-t选项为其设置名称和标签。以下命令创建了名为piomin/order-service的镜像,版本标记为1.0。您可以像之前推送 Postgres 镜像一样,将此镜像推送到您的 Docker Hub 账户中,如下所示:
$ docker build -t piomin/order-service:1.0 .
网络配置
网络配置是 Docker 架构的一个重要方面,因为我们必须经常在不同容器上运行的应用程序之间提供通信。一个常见的用例可能是一个需要访问数据库的 web 应用程序。现在我们将参考在第十一章中已经介绍过的另一个例子,即消息驱动的微服务。这是 Apache Kafka 与 ZooKeeper 之间的通信。Kafka 需要 ZooKeeper,因为它将各种配置作为键/值对存储在 ZK 数据树中,并在整个集群中使用它。正如您可能记得的,我们首先必须创建一个自定义网络并在那里运行这两个容器。以下命令用于在 Docker 主机上创建一个用户定义的网络:
$ docker network create kafka-network
在上一个命令运行完成后,您可以使用以下命令查看可用的网络列表。默认情况下,Docker 为您创建了三个网络,所以您应该看到四个网络,名称分别为 bridge、host、none 和kafka-network:
$ docker network ls
下一步是向使用docker run命令创建的容器传递网络名称。这可以通过--network参数实现,正如您在以下示例中看到的那样。如果您为两个不同的容器设置相同的网络名称,它们将在同一个网络中启动。让我们实际分析一下这意味着什么。如果您在一个容器内,可以用它的名字而不是 IP 地址来调用它,这就是为什么在启动带有 Apache Kafka 的容器时,我们可以将环境变量ZOOKEEPER_IP设置为 ZooKeeper 的原因。Kafka 在这个容器内启动,如下所示连接到默认端口的 ZooKeeper 实例:
$ docker run -d --name zookeeper --network kafka-net zookeeper:3.4
$ docker run -d --name kafka --network kafka-net -e ZOOKEEPER_IP=zookeeper ches/kafka
创建带有微服务的 Docker 镜像
我们已经讨论了可用于运行、创建和管理容器的基本 Docker 命令。现在是我们创建和构建第一个 Docker 镜像的时候了,这个镜像启动了我们在上一章中介绍的示例微服务。为此,我们应该回到地址github.com/piomin/sample-spring-cloud-comm.git可用的仓库,然后切换到feign_with_discovery分支上github.com/piomin/sample-spring-cloud-comm/tree/feign_with_discovery。在那里,你可以找到每个微服务、网关和发现模块的Dockerfile。然而,在讨论这些示例之前,我们应该参考Dockerfile参考资料,以了解我们可以在其中放置的基本命令。实际上,Dockerfile不是构建 Docker 镜像的唯一方法;我们还将向您展示如何使用 Maven 插件创建包含微服务的镜像。
Dockerfiles
Docker 可以通过读取Dockerfile中提供的指令来自动构建镜像,这是一个包含所有在命令行中调用以组装镜像的命令的文档。Dockerfile中的所有命令都必须由Dockerfile规范中定义的关键字前缀。以下是最常用的指令列表。它们按照在Dockerfile中找到的顺序执行。在这里,我们还可以添加一些以#字符开头的注释:
| 指令 | 描述 |
|---|---|
FROM |
这初始化一个新的构建阶段并设置后续指令的基础镜像。实际上,每个有效的Dockerfile都必须以FROM指令开始。 |
MAINTAINER |
这设置了生成镜像的作者身份。这个指令已经过时,所以你可能会在许多旧镜像中找到它。我们应该使用LABEL指令代替MAINTAINER,如下所示:LABEL maintainer="piotr.minkowski@gmail.com"。 |
RUN |
这执行 Linux 命令,用于在新的层上配置和安装当前镜像所需的应用程序,然后提交结果。它可以有两种形式:RUN <命令>或RUN ["可执行文件", "参数 1", "参数 2"]。 |
ENTRYPOINT |
这配置了一个最终脚本,用于引导作为可执行文件的容器。它覆盖了所有使用CMD指定的元素,并有两个形式:ENTRYPOINT ["可执行文件", "参数 1", "参数 2"]和ENTRYPOINT命令参数 1 参数 2。值得注意的是,Dockerfile中最后一个ENTRYPOINT指令才会生效。 |
CMD |
Dockerfile中只能包含一个CMD指令。这个指令通过 JSON 数组格式为ENTRYPOINT提供默认参数。 |
ENV |
这为容器设置环境变量,以键/值形式。 |
COPY |
这个指令会将给定源路径的新文件或目录复制到容器文件系统内的目标路径。它的格式如下:COPY [--chown=<用户>:<组>] <源>... <目标>。 |
ADD |
这是COPY指令的一个替代选项。它比COPY指令多做了一些事情,例如,它允许<src>是一个 URL 地址。 |
WORKDIR |
这个指令为RUN、CMD、ENTRYPOINT、COPY和ADD设置工作目录。 |
EXPOSE |
这个指令负责告知 Docker 容器在运行时监听指定的网络端口。它实际上并不发布端口。端口的发布是通过docker run命令的-p选项来实现的。 |
VOLUME |
这个指令创建了指定名称的挂载点。卷是 Docker 容器内持久化数据的首选机制。 |
USER |
这个指令为运行镜像以及RUN、CMD和ENTRYPOINT指令设置用户名和可选的用户组。 |
让我们看看实际操作中它是如何工作的。我们应该为每个微服务定义一个Dockerfile,并将其放在其 Git 项目的根目录中。下面是为account-service创建的Dockerfile:
FROM openjdk:8u151-jdk-slim-stretch
MAINTAINER Piotr Minkowski <piotr.minkowski@gmail.com>
ENV SPRING_PROFILES_ACTIVE zone1
ENV EUREKA_DEFAULT_ZONE http://localhost:8761/eureka/
ADD target/account-service-1.0-SNAPSHOT.jar app.jar
ENTRYPOINT ["java", "-Xmx160m", "-jar", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-Deureka.client.serviceUrl.defaultZone=${EUREKA_DEFAULT_ZONE}", "/app.jar"]
EXPOSE 8091
前面的例子并不复杂。它只是将微服务生成的胖 JAR 文件添加到 Docker 容器中,并将java -jar命令作为ENTRYPOINT。即便如此,让我们逐一分析它。我们示例中的Dockerfile执行了以下指令:
-
该镜像扩展了一个现有的 OpenJDK 镜像,这是一个官方的、开源的 Java 平台标准版实现。OpenJDK 镜像有很多版本。可用的镜像变体之间的主要区别在于它们的大小。标记为
8u151-jdk-slim-stretch的镜像提供了 JDK 8,并包括运行 Spring Boot 微服务所需的所有库。它也比这个版本的 Java(8u151-jdk)的基本镜像小得多。 -
在这里,我们定义了两个可以在运行时覆盖的环境变量,它们是通过
docker run命令的-e选项来设置的。第一个是活动的 Spring 配置文件名,默认初始化为zone1值。第二个是发现服务器的地址,默认等于[localhost:8761/eureka/](http://localhost:8761/eureka/)。 -
胖 JAR 文件包含了所有必需的依赖项以及应用程序的二进制文件。因此,我们必须使用
ADD指令将生成的 JAR 文件放入容器中。 -
我们将容器配置为执行 Java 应用程序。定义的
ENTRYPOINT相当于在本地机器上运行以下命令:
java -Xmx160m -jar –Dspring.profiles.active=zone1 -Deureka.client.serviceUrl.defaultZone=http://localhost:8761/eureka/ app.jar
- 我们使用
EXPOSE指令告知 Docker 可能会暴露容器内部应用程序的 HTTP API,该 API 可通过端口8091访问。
运行容器化的微服务
假设我们已经为每个服务准备了一个有效的Dockerfile,下一步是在为每个服务构建 Docker 镜像之前,使用mvn clean install命令构建整个 Maven 项目。
构建 Docker 镜像时,你应该始终位于每个微服务源代码的root目录。在我们基于微服务的系统中,需要运行的第一个容器是一个发现服务器。其 Docker 镜像被命名为piomin/discovery-service。在运行 Docker 的build命令之前,请转到模块discovery-service。这个Dockerfile比其他微服务要简单一些,因为容器内部不需要设置环境变量,如下所示:
FROM openjdk:8u151-jdk-slim-stretch
MAINTAINER Piotr Minkowski <piotr.minkowski@gmail.com>
ADD target/discovery-service-1.0-SNAPSHOT.jar app.jar
ENTRYPOINT ["java", "-Xmx144m", "-jar", "/app.jar"]
EXPOSE 8761
在这里只需要执行五个步骤,你可以在构建目标镜像时生成的日志中看到,在运行docker build命令之后。如果一切正常,你应该看到Dockerfile中定义的所有五个步骤的进度,以及以下告诉您镜像已成功构建和标记的最终消息:
$ docker build -t piomin/discovery-service:1.0 .
Sending build context to Docker daemon 39.9MB
Step 1/5 : FROM openjdk:8u151-jdk-slim-stretch
8u151-jdk-slim-stretch: Pulling from library/openjdk
8176e34d5d92: Pull complete
2208661344b7: Pull complete
99f28966f0b2: Pull complete
e991b55a8065: Pull complete
aee568884a84: Pull complete
18b6b371c215: Pull complete
Digest: sha256:bd394fdc76e8aa73adba2a7547fcb6cde3281f70d6b3cae6fa62ef1fbde327e3
Status: Downloaded newer image for openjdk:8u151-jdk-slim-stretch
---> 52de5d98a41d
Step 2/5 : MAINTAINER Piotr Minkowski <piotr.minkowski@gmail.com>
---> Running in 78fc78cc21f0
---> 0eba7a369e43
Removing intermediate container 78fc78cc21f0
Step 3/5 : ADD target/discovery-service-1.0-SNAPSHOT.jar app.jar
---> 1c6a2e04c4dc
Removing intermediate container 98138425b5a0
Step 4/5 : ENTRYPOINT java -Xmx144m -jar /app.jar
---> Running in 7369ba693689
---> c246470366e4
Removing intermediate container 7369ba693689
Step 5/5 : EXPOSE 8761
---> Running in 74493ae54220
---> 06af6a3c2d41
Removing intermediate container 74493ae54220
Successfully built 06af6a3c2d41
Successfully tagged piomin/discovery-service:1.0
一旦我们成功构建了一个镜像,我们就应该运行它。我们建议创建一个网络,在该网络中启动所有我们的微服务容器。要在新创建的网络中启动容器,我们需要使用--network参数将容器名称传递给docker run命令。为了检查容器是否已成功启动,运行docker logs命令。此命令将应用程序打印到控制台的所有日志行输出到控制台,如下所示:
$ docker network create sample-spring-cloud-network
$ docker run -d --name discovery -p 8761:8761 --network sample-spring-cloud-network piomin/discovery-service:1.0
de2fac673806e134faedee3c0addaa31f2bbadcffbdff42a53f8e4ee44ca0674
$ docker logs -f discovery
下一步是使用我们的四个微服务—account-service、customer-service、order-service和product-service—构建和运行容器。每个服务的步骤都相同。例如,如果你想构建account-service,首先需要进入示例项目源代码中的那个目录。这里的build命令与发现服务相同;唯一的区别在于镜像名称,如下所示片段:
$ docker build -t piomin/account-service:1.0 .
对于discovery-service,运行 Docker 镜像的命令要稍微复杂一些。在这种情况下,我们必须将 Eureka 服务器的地址传递给启动容器。因为此容器与发现服务容器在同一网络中运行,我们可以使用其名称而不是其 IP 地址或其他任何标识符。可选地,我们还可以使用-m参数设置容器的内存限制,例如,设置为 256 MB。最后,我们可以使用以下方式使用docker logs命令查看容器上运行的应用程序生成的日志:
$ docker run -d --name account -p 8091:8091 -e EUREKA_DEFAULT_ZONE=http://discovery:8761/eureka -m 256M --network sample-spring-cloud-network piomin/account-service:1.0
$ docker logs -f account
与之前描述的步骤相同,应对所有其他微服务重复这些步骤。最终结果是五个正在运行的容器,可以使用docker ps命令来显示,如下所示截图:

所有的微服务都注册在 Eureka 服务器上。Eureka 仪表板可在地址http://192.168.99.100:8761/找到,如下截图所示:

这里再提一个有趣的 Docker 命令:docker stats。这个命令打印了一些关于启动容器的统计信息,比如内存或 CPU 使用情况。如果你使用该命令的--format参数,你可以自定义它打印统计信息的方式;例如,你可以打印容器名称而不是它的 ID。在运行那个命令之前,你可能需要进行一些测试,以检查一切是否按预期工作。检查微服务之间的通信是否成功完成是很值得的。你可能还想尝试从customer-service调用端点GET /withAccounts/{id},该端点由account-service暴露出来。我们运行以下命令:
docker stats --format "table {{.Name}}\t{{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}"
以下截图可见:

使用 Maven 插件构建镜像
如我们之前提到的,Dockerfile不是创建和构建容器的唯一方式。还有其他一些方法可用,例如,通过使用 Maven 插件。我们有多个用于构建镜像的插件,它们与mvn命令一起使用。其中比较流行的是com.spotify:docker-maven-plugin。这个插件在其配置中有与Dockerfile指令相当的标签。account-service的pom.xml中插件的配置如下:
<plugin>
<groupId>com.spotify</groupId>
<artifactId>docker-maven-plugin</artifactId>
<version>1.0.0</version>
<configuration>
<imageName>piomin/${project.artifactId}</imageName>
<imageTags>${project.version}</imageTags>
<baseImage>openjdk:8u151-jdk-slim-stretch</baseImage>
<entryPoint>["java", "-Xmx160m", "-jar", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-Deureka.client.serviceUrl.defaultZone=${EUREKA_DEFAULT_ZONE}", "/${project.build.finalName}.jar"] </entryPoint>
<env>
<SPRING_PROFILES_ACTIVE>zone1</SPRING_PROFILES_ACTIVE>
<EUREKA_DEFAULT_ZONE>http://localhost:8761/eureka/</EUREKA_DEFAULT_ZONE>
</env>
<exposes>8091</exposes>
<maintainer>piotr.minkowski@gmail.com</maintainer>
<dockerHost>https://192.168.99.100:2376</dockerHost>
<dockerCertPath>C:\Users\Piotr\.docker\machine\machines\default</dockerCertPath>
<resources>
<resource>
<directory>${project.build.directory}</directory>
<include>${project.build.finalName}.jar</include>
</resource>
</resources>
</configuration>
</plugin>
这个插件可以在 Maven 的build命令期间被调用。如果你想在构建应用程序之后立即构建一个 Docker 镜像,可以使用以下的 Maven 命令:
$ mvn clean install docker:build
另外,你也可以设置dockerDirectory标签,以便基于Dockerfile进行构建。无论你选择哪种方法,效果都是一样的。任何用应用程序构建的新镜像都会在你的 Docker 机器上可用。在使用docker-maven-plugin时,你可以通过将pushImage设置为true来强制自动镜像推送到仓库,如下所示:
<plugin>
<groupId>com.spotify</groupId>
<artifactId>docker-maven-plugin</artifactId>
<version>1.0.0</version>
<configuration>
<imageName>piomin/${project.artifactId}</imageName>
<imageTags>${project.version}</imageTags>
<pushImage>true</pushImage>
<dockerDirectory>src/main/docker</dockerDirectory>
<dockerHost>https://192.168.99.100:2376</dockerHost>
<dockerCertPath>C:\Users\Piotr\.docker\machine\machines\default</dockerCertPath>
<resources>
<resource>
<directory>${project.build.directory}</directory>
<include>${project.build.finalName}.jar</include>
</resource>
</resources>
</configuration>
</plugin>
高级 Docker 镜像
到目前为止,我们已经构建了一些相当简单的 Docker 镜像。然而,有时需要创建一个更高级的镜像。我们将需要这样一个镜像来进行持续交付演示。这个 Docker 镜像将作为 Jenkins 奴隶运行,并连接到作为 Docker 容器启动的 Jenkins 主节点。我们在 Docker Hub 上没有找到这样的镜像,所以我们自己创建了一个。在这里,镜像必须包含 Git、Maven、JDK8 和 Docker。这些都是构建我们的示例微服务的 Jenkins 奴隶所需的全部工具。我将在本章的后面部分给你一个关于使用 Jenkins 服务器进行持续交付的基本概述。现在,我们将重点关注 just building the required image。以下是Dockerfile中提供的镜像的完整定义:
FROM docker:18-dind
MAINTAINER Piotr Minkowski <piotr.minkowski@gmail.com>
ENV JENKINS_MASTER http://localhost:8080
ENV JENKINS_SLAVE_NAME dind-node
ENV JENKINS_SLAVE_SECRET ""
ENV JENKINS_HOME /home/jenkins
ENV JENKINS_REMOTING_VERSION 3.17
ENV DOCKER_HOST tcp://0.0.0.0:2375
RUN apk --update add curl tar git bash openjdk8 sudo
ARG MAVEN_VERSION=3.5.2
ARG USER_HOME_DIR="/root"
ARG SHA=707b1f6e390a65bde4af4cdaf2a24d45fc19a6ded00fff02e91626e3e42ceaff
ARG BASE_URL=https://apache.osuosl.org/maven/maven-3/${MAVEN_VERSION}/binaries
RUN mkdir -p /usr/share/maven /usr/share/maven/ref \
&& curl -fsSL -o /tmp/apache-maven.tar.gz ${BASE_URL}/apache-maven-${MAVEN_VERSION}-bin.tar.gz \
&& echo "${SHA} /tmp/apache-maven.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/apache-maven.tar.gz -C /usr/share/maven --strip-components=1 \
&& rm -f /tmp/apache-maven.tar.gz \
&& ln -s /usr/share/maven/bin/mvn /usr/bin/mvn
ENV MAVEN_HOME /usr/share/maven
ENV MAVEN_CONFIG "$USER_HOME_DIR/.m2"
RUN adduser -D -h $JENKINS_HOME -s /bin/sh jenkins jenkins && chmod a+rwx $JENKINS_HOME
RUN echo "jenkins ALL=(ALL) NOPASSWD: /usr/local/bin/dockerd" > /etc/sudoers.d/00jenkins && chmod 440 /etc/sudoers.d/00jenkins
RUN echo "jenkins ALL=(ALL) NOPASSWD: /usr/local/bin/docker" > /etc/sudoers.d/01jenkins && chmod 440 /etc/sudoers.d/01jenkins
RUN curl --create-dirs -sSLo /usr/share/jenkins/slave.jar http://repo.jenkins-ci.org/public/org/jenkins-ci/main/remoting/$JENKINS_REMOTING_VERSION/remoting-$JENKINS_REMOTING_VERSION.jar && chmod 755 /usr/share/jenkins && chmod 644 /usr/share/jenkins/slave.jar
COPY entrypoint.sh /usr/local/bin/entrypoint
VOLUME $JENKINS_HOME
WORKDIR $JENKINS_HOME
USER jenkins
ENTRYPOINT ["/usr/local/bin/entrypoint"]
让我们分析一下发生了什么。在这里,我们扩展了 Docker 基础镜像。这是一个相当智能的解决方案,因为现在这个镜像提供了 Docker 内的 Docker。尽管通常不建议在 Docker 内运行 Docker,但有一些期望的使用案例,比如使用 Docker 的持续交付。除了 Docker 之外,还使用RUN指令在镜像上安装了其他软件,如 Git、JDK、Maven 或 Curl。我们还添加了一个 OS 用户,在dockerd脚本中有sudoers权限,负责在机器上运行 Docker 守护进程。这不是在运行容器中必须启动的唯一进程;启动 JAR 与 Jenkins 奴隶也是必须的。这两个命令在entrypoint.sh中执行,作为镜像的ENTRYPOINT。这个 Docker 镜像的完整源代码可以在 GitHub 上找到,地址为github.com/piomin/jenkins-slave-dind-jnlp.git。你可以省略从源代码构建它,只需使用以下命令从我的 Docker Hub 账户下载一个现成的镜像:
docker pull piomin/jenkins-slave-dind-jnlp
这里是在 Docker 镜像中的entrypoint.sh脚本,它启动了 Docker 守护进程和 Jenkins 奴隶:
#!/bin/sh
set -e
echo "starting dockerd..."
sudo dockerd --host=unix:///var/run/docker.sock --host=tcp://0.0.0.0:2375 --storage-driver=vfs &
echo "starting jnlp slave..."
exec java -jar /usr/share/jenkins/slave.jar \
-jnlpUrl $JENKINS_URL/computer/$JENKINS_SLAVE_NAME/slave-agent.jnlp \
-secret $JENKINS_SLAVE_SECRET
持续交付
迁移到基于微服务的架构的关键好处之一就是能够快速交付软件。这应该是你在组织中实施持续交付或持续部署流程的主要动机。简而言之,持续交付流程是一种尝试自动化软件交付的所有阶段的方法,比如构建、测试代码和发布应用程序。有许多工具可以赋能这个过程。其中之一就是 Jenkins,这是一个用 Java 编写的开源自动化服务器。Docker 能够将你的持续集成(CI)或持续交付(CD)流程提升到一个新的水平。例如,不可变交付是 Docker 最重要的优势之一。
将 Jenkins 与 Docker 集成
这里的主要目标是使用 Jenkins 和 Docker 在本地设计和运行持续交付过程。这个过程涉及到四个元素。其中第一个元素已经准备好了:我们的微服务源代码仓库,它位于 GitHub 上。第二个元素,Jenkins,需要运行和配置。Jenkins 是我们持续交付系统的一个关键元素。它必须从 GitHub 仓库下载应用程序的源代码,构建它,然后将生成的 JAR 文件放入 Docker 镜像中,将该镜像推送到 Docker Hub,最后运行带有微服务的容器。这个过程中所有的任务都是直接在 Jenkins 主节点上执行的,但是是在其从节点上。Jenkins 及其从节点都是作为 Docker 容器启动的。这个解决方案的架构如下所示:

值得一提的是,Jenkins 是基于插件概念构建的。核心是一个过于简单的自动化构建引擎。Jenkins 的真正力量在于其插件,并且在更新中心有数百个插件。现在,我们将只讨论一些感谢 Jenkins 服务器为我们提供的机会。我们需要安装以下插件才能在 Docker 容器中构建和运行我们的微服务:
-
流水线:这是一套插件,可以让您使用 Groovy 脚本创建自动化,遵循流水线即代码的理念 (
wiki.jenkins.io/display/JENKINS/Pipeline+Plugin) -
Docker 流水线:这允许您在流水线中构建 Docker 容器 ([
wiki.jenkins.io/display/JENKINS/Docker+Pipeline+Plugin](https://clicktime.symantec.com/a/1/3BcsCubSP1UZ0ssSZFCe2iSCQQ_b1asMBhlt_0nQFKI=?d=GiSMteljxw-3ox0rf3cMazK9IOHzeSrn0vm9sus4y_n0hehkoAHvPijqT9dNXanC2Z3KtWbAm0BF-YDyp2HFvxXpFa6IkS_tvoddqdWrcb2R6vx-7YEpFHbt4IzErozigZnPecmyLha58i_mX_GOqw8nGcIkFmptcNTdFqB6DA-shedWhYxMv5VpzsTWPmDZA52S7fjMHuYvrTP5MOqqgejXY -
- Git: 该插件将 Git 与 Jenkins 集成(https://wiki.jenkins.io/display/JENKINS/Git+Plugin)
-
- Maven 集成: 当使用 Maven 和 Jenkins 构建应用程序时,该插件提供了一些有用的命令([
plugins.jenkins.io/maven-plugin](https://clicktime.symantec.com/a/1/jmIwLdZZ-wtodkRm1Goje_nuKFV98VcZYPHn5cWj1KM=?d=GiSMteljxw-3ox0rf3cMazK9IOHzeSrn0vm9sus4y_n0hehkoAHvPijqT9dNXanC2Z3KtWbAm0BF-YDyp2HFvxXpFa6IkS_tvoddqdWrcb2R6vx-7YEpFHbt4IzErozigZnP
- Maven 集成: 当使用 Maven 和 Jenkins 构建应用程序时,该插件提供了一些有用的命令([
- 所需插件可以通过 UI 仪表盘进行配置,可以在启动后或通过管理 Jenkins | 管理插件进行配置。为了在本地运行 Jenkins,我们将使用其 Docker 镜像。下面的命令将创建一个名为
jenkins的网络,并启动 Jenkins 主容器,在端口38080上暴露 UI 仪表盘。注意,当你启动 Jenkins 容器并首次使用其 Web 控制台时,需要使用生成的初始密码进行设置。你可以通过调用docker logs jenkins命令轻松地从 Jenkins 日志中获取此密码,如下所示:
$ docker network create jenkins
$ docker run -d --name jenkins -p 38080:8080 -p 50000:50000 --network jenkins jenkins/jenkins:lts
- 一旦我们成功配置了 Jenkins 主节点及其所需插件,我们需要添加新的奴隶节点。为此,你应该前往管理 Jenkins | 管理节点部分,然后选择新建节点。在显示的表单中,你必须将
/home/jenkins设置为远程根目录,并通过 Java Web Start 将启动代理作为启动方法。现在你可以按照之前讨论的启动带有 Jenkins 奴隶的 Docker 容器。请注意,你必须覆盖两个环境变量,指示奴隶的名称和密钥。name参数在节点创建时设置,而密钥由服务器自动生成。你可以查看节点的详细信息页面以获取更多信息,如下所示的屏幕截图:

- 以下是在 Docker 中使用 Docker 的 Jenkins 奴隶容器启动的 Docker 命令:
$ docker run --privileged -d --name slave --network jenkins -e JENKINS_SLAVE_SECRET=5664fe146104b89a1d2c78920fd9c5eebac3bd7344432e0668e366e2d3432d3e -e JENKINS_SLAVE_NAME=dind-node-1 -e JENKINS_URL=http://jenkins:38080 piomin/jenkins-slave-dind-jnlp
这篇关于 Jenkins 配置的简短介绍应该可以帮助你在自己的机器上重复讨论的持续交付过程。记住,我们只查看了与 Jenkins 相关的几个方面,包括设置,这允许你为你的微服务基础系统设置 CI 或 CD 环境。如果你对深入研究这个话题感兴趣,你应该参考可用的文档,具体请访问 jenkins.io/doc。
构建流水线
在 Jenkins 服务器的旧版本中,工作单位是作业。目前,其主要特性是能够将流水线定义为代码。这种变化与 IT 架构中更现代的趋势有关,该趋势认为应用程序交付与正在交付的应用程序一样重要。由于应用程序堆栈的所有组件已经自动化,并以代码的形式在版本控制系统中表示,因此可以利用同样的好处来定义 CI 或 CD 流水线。
Jenkins Pipeline 提供了一套用于将简单和更高级的交付流水线建模为代码的工具。这样的流水线的定义通常写入一个名为 Jenkinsfile 的文本文件中。它支持通过 共享库 功能提供的特定步骤的领域特定语言。流水线支持两种语法:声明式(在 Pipeline 2.5 中引入)和脚本化流水线。无论使用哪种语法,它都会逻辑上分为阶段和步骤。步骤是流水线的最基本部分,因为它们告诉 Jenkins 需要做什么。阶段逻辑上分组了几个步骤,然后在流水线的结果屏幕上显示。下面的代码是一个脚本化流水线的示例,为 account-service 定义了一个构建过程。对于其他微服务也需要创建类似的定义。所有这些定义都位于每个应用程序源代码的 root 目录中的 Jenkinsfile:
node('dind-node-1') {
withMaven(maven:'M3') {
stage('Checkout') {
git url: 'https://github.com/piomin/sample-spring-cloud-comm.git', credentialsId: 'github-piomin', branch: 'master'
}
stage('Build') {
dir('account-service') {
sh 'mvn clean install'
}
def pom = readMavenPom file:'pom.xml'
print pom.version
env.version = pom.version
currentBuild.description = "Release: ${env.version}"
}
stage('Image') {
dir ('account-service') {
def app = docker.build "piomin/account-service:${env.version}"
app.push()
}
}
stage ('Run') {
docker.image("piomin/account-service:${env.version}").run('-p 8091:8091 -d --name account --network sample-spring-cloud-network')
}
}
}
之前的定义被分为四个阶段。在第一个阶段,Checkout,我们克隆包含所有示例应用程序源代码的 Git 仓库。在第二个阶段,Build,我们从 account-service 模块构建一个应用程序,然后从 root 的 pom.xml 中读取整个 Maven 项目的版本号。在 Image 阶段,我们从 Dockerfile 构建一个镜像,并将其推送到 Docker 仓库。最后,在 Run 阶段我们在 dind-node-1 上运行一个包含 account-service 应用程序的容器。所有描述的阶段都按照节点元素的定义在 dind-node-1 上执行,节点元素是流水线定义中所有其他元素的根。
现在我们可以继续在 Jenkins 的网页控制台中定义流水线。选择新建项目,然后检查管道项目类型并输入其名称。确认后,你应该会被重定向到管道的配置页面。在那里你唯一需要做的是提供 Git 仓库中Jenkinsfile的位置,然后按照下面的屏幕截图设置 SCM 认证凭据:

保存更改后,管道的配置就准备好了。为了启动构建,点击“立即构建”按钮。在这个阶段,有两件事需要澄清。在生产模式下,你可以使用由最流行的 Git 托管提供商(包括 GitHub、BitBucket 和 GitLab)提供的webhook机制。这个机制可以在将更改推送到仓库后自动触发 Jenkins 中的构建。为了演示这个,我们本应运行一个本地的版本控制系统,例如使用 GitLab 和 Docker。还有一种更简单的测试方法。容器化的应用程序直接在 Jenkins 的 Docker in Docker 奴隶上运行;在正常情况下,我们会在专门用于应用程序部署的分离远程机器上启动。下面的屏幕截图是 Jenkins 的网页控制台,展示了product-service的构建过程,分为不同的阶段:

我们应该现在为每个微服务创建一个管道。创建的所有管道的列表如下:

与 Kubernetes 一起工作
我们已经在我们本地的 Docker 容器上启动了我们的示例微服务。我们甚至使用了 CI 和 CD 自动化管道,以便在本地机器上运行它们。然而,你可能有一个重要的问题。我们如何在大规模和生产模式下组织我们的环境,在那里我们必须在多台机器上运行多个容器呢?好吧,这正是我们在根据云原生开发的观念实现微服务时必须做的。然而,在这个实例中,仍然存在许多挑战。假设我们在多个实例中启动了许多微服务,将有很多容器需要管理。在正确的时间启动正确的容器,处理存储考虑,进行扩展或缩放,以及手动处理故障将是一场噩梦。幸运的是,有一些平台可以帮助在大规模上进行 Docker 容器的集群和编排。目前,在这个领域的领导者是 Kubernetes。
Kubernetes 是一个用于管理容器化工作负载和服务的开源平台。它可以作为容器平台,微服务平台,云平台,还有更多。它自动化了在不同机器上运行容器、扩展、缩减、在容器之间分配负载,以及在应用程序的多个实例之间保持存储一致性等操作。它还有许多其他功能,包括服务发现、负载均衡、配置管理、服务命名和滚动更新。然而,这些功能并非都对我们有用,因为许多类似的功能由 Spring Cloud 提供。
值得一提的是,Kubernetes 并不是市面上唯一的容器管理工具。还有 Docker Swarm,这是 Docker 自带的本地工具。然而,由于 Docker 已经宣布对 Kubernetes 提供原生支持,似乎这是一个自然的选择。在深入实践之前,我们应该了解几个关于 Kubernetes 的重要概念和组件。
概念和组件
使用 Kubernetes 时,您可能首先要处理的第一个术语是 pod,这是 Kubernetes 中的基本构建块。pod 表示集群中的运行进程。它可以由一个或多个容器组成,这些容器保证在主机机器上共同定位,并将共享相同的资源。每个 pod 中只有一个容器是最常见的 Kubernetes 用例。每个 pod 在集群中都有一个唯一的 IP 地址,但部署在同一 pod 中的所有容器可以通过 localhost 与彼此通信。
另一个常见的组件是服务。服务逻辑上组了一组 pod,并定义了对其访问的策略;有时它被称为微服务。默认情况下,服务是在集群内部暴露的,但它也可以暴露在外的 IP 地址上。我们可以使用四种可用行为之一来暴露服务:ClusterIP、NodePort、LoadBalancer 和 ExternalName。默认选项是 ClusterIP。这将在集群内部 IP 上暴露服务,使其仅可在集群内部访问。NodePort 将在每个节点的 IP 上以静态端口暴露服务,并自动创建 ClusterIP 以在集群内部暴露服务。反过来,LoadBalancer 使用云提供商的负载均衡器在外部暴露服务,而 ExternalName 将服务映射到 externalName 字段的内容。我们还应该花点时间讨论 Kubernetes 的复制控制器。此控制器通过在集群中运行指定数量的 pod 副本来处理复制和扩展。如果底层节点失败,它还负责替换 pod。Kubernetes 中的每个控制器都是由 kube-controller-manager 运行的独立进程。你还可以在 Kubernetes 中找到节点控制器、端点控制器以及服务账号和令牌控制器。
Kubernetes 使用一个 etcd 键/值存储作为所有集群数据的后端存储。在集群中的每个节点都有一个名为 kubelet 的代理,它负责确保容器在 pod 中运行。用户发送给 Kubernetes 的每个命令都由 kubeapi-server 暴露的 Kubernetes API 处理。
当然,这是对 Kubernetes 架构的一个非常简化的解释。为了成功运行高可用的 Kubernetes 集群,还有更多组件和工具需要正确配置。执行此任务并非易事,它需要对这个平台有大量的了解。幸运的是,有一个工具可以使在本地运行 Kubernetes 集群变得容易——Minikube。
通过 Minikube 在本地运行 Kubernetes
Minikube 是一个使在本地运行 Kubernetes 变得简单的工具。它在一个本地机器上的 VM 中运行一个单节点 Kubernetes 集群。在开发模式下,它绝对是最佳选择。当然,Minikube 不支持 Kubernetes 提供的所有功能;只包括最重要的功能,包括 DNS、NodePorts、Config Map、Dashboard 和 Ingress。
要在 Windows 上运行 Minikube,我们需要安装一个虚拟化工具。然而,如果您已经运行了 Docker,您可能已经安装了 Oracle VM VirtualBox。在这种情况下,您只需要下载并安装 Minikube 的最新版本,您可以查看 github.com/kubernetes/minikube/releases ,并 kubectl.exe ,如 storage.googleapis.com/kubernetes-release/release/stable.txt 描述。文件 minikube.exe 和 kubectl.exe 应该包括在 PATH 环境变量中。此外,Minikube 提供自己的安装程序 minikube-installer.exe ,它将自动将 minikube.exe 添加到您的路径中。然后,您可以从命令行通过运行以下命令启动 Minikube:
$ minikube start
前一个命令初始化了一个名为minikube的kubectl上下文。它包含了允许你与 Minikube 集群通信的配置。现在你可以使用kubectl命令来维护由 Minikube 创建的本地集群,并在其中部署容器。命令行界面的替代方案是 Kubernetes 仪表板。通过调用minikube dashboard,可以为你的节点启用 Kubernetes 仪表板。您可以使用这个仪表板创建、更新或删除部署,以及列出和查看所有 pods、服务、ingress 和复制控制器的配置。通过以下命令可以轻松停止和删除本地集群:
$ minikube stop
$ minikube delete
部署应用程序
Kubernetes 集群上存在的每个配置都由 Kubernetes 对象表示。这些对象可以通过 Kubernetes API 进行管理,并且应该以 YAML 格式表达。你可能会直接使用那个 API,但可能会决定利用kubectl命令行界面为你做所有必要的调用。在 Kubernetes 上新建对象的描述必须提供描述其期望状态的规格,以及关于对象的一些基本信息。以下是在 YAML 配置文件中应始终设置的一些必需字段:
-
apiVersion:这指示了用于创建对象的 Kubernetes API 的版本。API 在请求中总是需要 JSON 格式,但kubectl会自动将 YAML 输入转换为 JSON。 -
kind:这设置了要创建的对象的种类。有一些预定义的类型可供选择,例如 Deployment、Service、Ingress 或 ConfigMap。 -
metadata:这允许你通过名称、UID 或可选的命名空间来标识对象。 -
spec:这是对象的正确定义。规格的精确格式取决于对象的类型,并包含特定于该对象的嵌套字段。
通常,在 Kubernetes 上创建新对象时,其kind是部署。在下面的Deployment YAML 文件中,有两个重要的字段被设置。首先是replicas,它指定了期望的 pods 数量。实际上,这意味着我们将运行容器化应用程序的两个实例。第二个是spec.template.spec.containers.image,它设置了将在 pods 内部启动的 Docker 镜像的名称和版本。容器将在端口8090上暴露,order-service在此端口监听 HTTP 连接:
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
replicas: 2
selector:
matchLabels:
app: order-service
template:
metadata:
labels:
app: order-service
spec:
containers:
- name: order-service
image: piomin/order-service:1.0
env:
- name: EUREKA_DEFAULT_ZONE
value: http://discovery-service:8761/eureka
ports:
- containerPort: 8090
protocol: TCP
假设前面的代码存储在文件order-deployment.yaml中,我们现在可以使用以下命令基于 imperative management 在 Kubernetes 上部署我们的容器化应用程序:
$ kubectl create -f order-deployment.yaml
另外,你可以基于声明式管理方法执行相同的操作,如下所示:
$ kubectl apply -f order-deployment.yaml
我们现在必须为所有微服务和discovery-service创建相同的部署文件。discovery-service的主题是一个非常好奇的事情。我们有使用基于 pods 和服务的内置 Kubernetes 发现的选项,但我们的主要目标是在这个平台上部署和运行 Spring Cloud 组件。所以,在部署任何微服务之前,我们首先应该部署、运行并暴露 Eureka 在 Kubernetes 上。以下是discovery-service的部署文件,也可以通过调用kubectl apply命令应用于 Kubernetes:
apiVersion: apps/v1
kind: Deployment
metadata:
name: discovery-service
labels:
run: discovery-service
spec:
replicas: 1
selector:
matchLabels:
app: discovery-service
template:
metadata:
labels:
app: discovery-service
spec:
containers:
- name: discovery-service
image: piomin/discovery-service:1.0
ports:
- containerPort: 8761
protocol: TCP
如果你创建了一个 Deployment,Kubernetes 会自动为你创建 pods。它们的数量等于replicas字段中设置的值。一个 pods 不能暴露部署在容器上的应用程序提供的 API,它只是代表集群上运行的一个进程。为了访问运行在 pods 内的微服务提供的 API,我们必须定义一个服务。让我们回顾一下服务是什么。服务是一个定义了逻辑集合 of pods 和访问它们的策略的抽象。服务针对的 pods 集合通常由一个标签选择器确定。Kubernetes 中提供了四种服务类型。最简单且默认的是ClusterIP,它在一个内部暴露服务。如果你希望从集群外部访问一个服务,你应该定义类型为NodePort的服务。这个选项已经在下面的 YAML 文件示例中设置;现在,所有微服务都可以使用其 Kubernetes 服务名称与 Eureka 通信:
apiVersion: v1
kind: Service
metadata:
name: discovery-service
labels:
app: discovery-service
spec:
type: NodePort
ports:
- protocol: TCP
port: 8761
targetPort: 8761
selector:
app: discovery-service
实际上,我们部署在 Minikube 上的所有微服务都应该能在集群外部访问,因为我们需要访问它们暴露的 API。为此,你需要提供与前面示例类似的 YAML 配置,只更改服务的名称、标签和端口。
我们架构中只有一个组件应该存在:API 网关。我们可以部署一个带有 Zuul 代理的容器,但是我们需要引入流行的 Kubernetes 对象,Ingress。这个组件负责管理通常通过 HTTP 暴露的服务的外部访问。Ingress 提供负载均衡、SSL 终止和基于名称的虚拟托管。Ingress 配置的 YAML 文件如下所示;注意所有服务可以在不同 URL 路径上的相同端口80上访问:
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: gateway-ingress
spec:
backend:
serviceName: default-http-backend
servicePort: 80
rules:
- host: microservices.example.pl
http:
paths:
- path: /account
backend:
serviceName: account-service
servicePort: 8091
- path: /customer
backend:
serviceName: customer-service
servicePort: 8092
- path: /order
backend:
serviceName: order-service
servicePort: 8090
- path: /product
backend:
serviceName: product-service
servicePort: 8093
维护集群
维护 Kubernetes 集群是非常复杂的。在本节中,我们将向您展示如何使用一些基本命令和 UI 仪表板来查看集群上当前存在的对象。首先,我们列出为运行我们的微服务 based 系统而创建的元素。首先,我们通过运行kubectl get deployments命令来显示部署列表,这应该会导致以下结果:

一个部署可以创建多个 pods。您可以如下调用kubectl get pods命令来查看 pods 列表:

可以使用 UI 仪表板查看相同的列表。通过点击选中的行或者点击每行右侧的图标来查看这些详细信息,如下图所示:

可以使用命令kubectl get services显示所有可用服务的完整列表。这里有一些有趣的字段,包括一个指示集群内部可用服务 IP 地址的字段(CLUSTER-IP),以及服务内部和外部暴露的一对端口(PORT(S))。我们还可以通过http://192.168.99.100:31099调用account-service上暴露的 HTTP API,或者通过http://192.168.99.100:31931调用 Eureka UI 仪表板,如下所示:

与之前的对象类似,服务也可以使用 Kubernetes 仪表板显示,如下所示:

概要
在本章中,我们讨论了许多与 Spring Cloud 明显不相关的主题,但本章解释的工具将使您能够利用迁移到基于微服务的架构。在使用 Docker、Kubernetes 或 CI/CD 工具时,采用 Spring Cloud 进行云原生开发具有明显的优势。当然,所有示例都已在本机上启动,但您可以参考这些示例来想象该过程如何在远程机器集群的生产环境中设计。
在本章中,我们想向您展示将 Spring 微服务手动运行在本地机器转变为完全自动化的过程是多么简单和快速,该过程从源代码构建应用程序,创建包含应用程序的 Docker 镜像,并在由多台机器组成的集群上部署它。在一章中很难描述 Docker、Kubernetes 或 Jenkins 等复杂工具提供的所有功能。取而代之的是,这里的主要目的是为您提供如何基于容器化、自动化部署、扩展和私有、本地云等概念设计和维护现代架构的更广阔视野。
现在,我们离书的结尾已经非常近了。我们已经讨论了与 Spring Cloud 框架相关的计划主题的大部分。在下一章中,我们将向您展示如何使用两个最受欢迎的在线云平台,使您能够持续交付 Spring Cloud 应用程序。
第十五章:云平台上的 Spring 微服务
Pivotal 将 Spring Cloud 定义为一个加速云原生应用程序开发的框架。今天,当我们谈论云原生应用程序时,首先想到的是快速交付软件的能力。为了满足这些需求,我们应该能够快速构建新的应用程序并设计可扩展、可移植且准备频繁更新的架构。提供容器化和编排机制的工具帮助我们设置和维护此类架构。实际上,像 Docker 或 Kubernetes 这样的工具,我们在之前的章节中已经探讨过,允许我们创建自己的私有云并在其上运行 Spring Cloud 微服务。尽管应用程序不必部署在公共云上,但它包含了云软件最重要的特性。
在公共云上部署您的 Spring 应用程序只是一个可能性,而不是必需的。然而,确实有一些非常有趣的云平台,可以让您在几分钟内轻松运行微服务并将它们暴露在网络上。其中一个平台是Pivotal Cloud Foundry(PCF);它与其他平台相比的优势在于其对 Spring Cloud 服务的原生支持,包括使用 Eureka 的发现、Config Server 以及使用 Hystrix 的断路器。您还可以通过启用 Pivotal 提供的托管服务轻松设置完整的微服务环境。
我们还应该提到的另一个云平台是 Heroku。与 PCF 相比,它不偏爱任何编程框架。Heroku 是一个全托管的、多语言平台,可以让您快速交付软件。一旦您将存储在 GitHub 仓库中的源代码更改推送到 Heroku,它就可以自动构建和运行应用程序。它还提供许多可以单命令部署和扩展的附加服务。
本章涵盖的主题如下:
-
Pivotal Web Services 平台简介
-
使用 CLI、Maven 插件和 UI 仪表板在 Pivotal Cloud Foundry 上部署和管理应用程序
-
使用 Spring Cloud Foundry 库准备应用程序以在平台上正确运行
-
在 Heroku 平台上部署 Spring Cloud 微服务
-
管理托管服务
Pivotal Cloud Foundry
尽管 Pivotal 平台可以运行用多种语言编写的应用程序,包括 Java、.NET、Ruby、JavaScript、Python、PHP 和 Go,但它对 Spring Cloud Services 和 Netflix OSS 工具的支持最为出色。这是有道理的,因为它们是开发 Spring Cloud 的人。看看下面的图表,也可在 Pivotal 的官方网站上找到。下面的图表说明了 Pivotal Cloud 平台提供的基于微服务的架构。你可以在 Cloud Foundry 上使用 Spring Cloud 快速利用常见的微服务模式,包括分布式配置管理、服务发现、动态路由、负载均衡和容错:

使用模型
你可以以三种不同的模型使用 Pivotal 平台。模型是根据宿主区分,这是应用程序被部署的地方。以下是可用的解决方案列表:
-
PCF Dev: 这个 Pivotal 平台的实例可以在单个虚拟机上本地运行。它旨在满足实验和开发的需求。它并不提供所有可能的特性和服务。例如,它只有一些内置服务,如 Redis、MySQL 和 RabbitMQ。然而,PCF Dev 也支持Spring Cloud Services(SCS),以及 PCF 完整版本中支持的所有语言。需要注意的是,如果你想本地运行带有 SCS 的 PCF Dev,你需要有超过 6GB 的可用 RAM。
-
Pivotal Web Services: 这是一个在线的云原生平台,网址为
run.pivotal.io/。它就像 Pivotal Cloud Foundry,但有由 SaaS 合作伙伴提供的服务,以及按小时计费的托管服务。它并不提供 Pivotal Cloud Foundry 中可用的所有特性和服务。Pivotal Web Services 最适合初创公司或个人团队。在本书接下来的部分,我们将使用这个 Pivotal 平台托管模型进行展示。 -
Pivotal Cloud Foundry:这是一个功能全面的云原生平台,可以在任何主要的公共 IaaS 上运行,包括 AWS、Azure 和 Google Cloud Platform,或者基于 OpenStack 或 VMware vSphere 的私有云上运行。这是一个针对大型企业环境的商业解决方案。
准备应用程序
由于 Pivotal Web Services 对 Spring Cloud 应用有本地支持,所以部署过程非常简单。但是,它需要在应用程序方面指定特定的依赖项和配置—特别是如果你的微服务必须与 Pivotal 平台提供的内置服务(如服务注册表、配置服务器或断路器)集成。除了 Spring Cloud 的标准依赖管理外,我们还应该在pom.xml中包括spring-cloud-services-dependencies,并且是与Edgware.SR2发布列车一起工作的最新版本,如下所示:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Edgware.SR2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>io.pivotal.spring.cloud</groupId>
<artifactId>spring-cloud-services-dependencies</artifactId>
<version>1.6.1.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
根据所选的集成服务,您可能希望将以下工件包括在您的项目中。我们决定使用 Pivotal 平台提供的所有 Spring Cloud 功能,因此我们的微服务从配置服务器获取属性,在 Eureka 中注册自己,并将服务间通信封装在 Hystrix 命令中。以下是为在 Pivotal 平台上部署的应用程序启用发现客户端、配置客户端和断路器所需的依赖项:
<dependency>
<groupId>io.pivotal.spring.cloud</groupId>
<artifactId>spring-cloud-services-starter-circuit-breaker</artifactId>
</dependency>
<dependency>
<groupId>io.pivotal.spring.cloud</groupId>
<artifactId>spring-cloud-services-starter-config-client</artifactId>
</dependency>
<dependency>
<groupId>io.pivotal.spring.cloud</groupId>
<artifactId>spring-cloud-services-starter-service-registry</artifactId>
</dependency>
我们将为我们的示例微服务提供另一个集成。它们都将将数据存储在 MongoDB 中,该 MongoDB 也作为 Pivotal 平台上的服务提供。为了实现这一点,我们首先应该在项目依赖项中包括启动器spring-boot-starter-data-mongodb:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
在配置设置中应使用spring.data.mongodb.uri属性提供 MongoDB 地址。为了允许应用程序与 MongoDB 连接,我们必须创建一个 Pivotal 的服务 mLab,然后将其绑定到应用程序。默认情况下,与绑定服务相关的元数据作为环境变量$VCAP_SERVICES暴露给应用程序。这种方法的主要动机是,Cloud Foundry 被设计为多语言的,这意味着任何语言和平台都可以作为构建包支持。所有 Cloud Foundry 属性都可以使用vcap前缀注入。如果您想访问 Pivotal 的服务,您应该使用vcap.services前缀,然后传递如下所示的服务名称:
spring:
data:
mongodb:
uri: ${vcap.services.mlab.credentials.uri}
实际上,应用程序方面需要做的就是与在 Pivotal 平台上创建的组件正确配合。现在我们只需要像对用 Spring 编写的标准微服务一样启用 Spring Cloud 功能,如下例所示:
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
@EnableCircuitBreaker
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
}
部署应用程序
应用程序可以通过三种不同的方式在Pivotal Web Service(PWS)平台上进行管理。第一种是通过位于console.run.pivotal.io的 web 控制台。我们可以通过这种方式监控、扩展、重新启动部署的应用程序,启用和禁用服务,定义新的配额,更改账户设置。然而,使用 web 控制台无法完成这项工作——也就是说,初始应用程序部署。这可以通过CLI(命令行界面)完成。您可以从pivotal.io网站下载所需的安装程序。安装后,您应该能够在您的机器上通过输入cf来调用 Cloud Foundry CLI,例如,cf help。
使用 CLI
CLI 提供了一组命令,允许您管理在 Cloud Foundry 上的应用程序、有偿服务、空间、域和其他组件。让我向您展示一些最重要的命令,您需要了解这些命令才能在 PWS 上运行您的应用程序:
- 为了部署应用程序,你首先必须导航到其目录。然后使用以下
cf login命令登录 PWS:
$ cf login -a https://api.run.pivotal.io
- 下一步是使用
cf push命令将应用程序推送到 PWS,并传递服务的名称:
$ cf push account-service -p target/account-service-1.0.0-SNAPSHOT.jar
- 另外,你可以在应用程序的根目录下提供
manifest.yml文件,其中包含所有必需的部署设置。在这种情况下,你只需要运行没有任何额外参数的cf push命令,如下所示:
---
applications:
- name: account-service
memory: 300M
random-route: true
path: target/account-service-1.0-SNAPSHOT.jar
- 使用
manifest.yml中提供的配置设置部署将失败。要了解原因,请运行命令cf logs。原因是堆内存限制不足:
$ cf logs account-service --recent
默认情况下,平台为代码缓存分配了 240 MB,为元空间分配了 140 MB,并为每个线程分配了 1 MB,假设 Tomcat 连接器最多有 200 个线程。很容易计算出,根据这些设置,每个应用程序需要大约 650 MB 的分配内存。我们可以通过调用cf set-env命令并传递JAVA_OPTS参数来更改这些设置,如您在以下示例中看到的。这样的内存限制在生产模式中是不够的,但在测试目的上应该是可以的。为确保这些更改生效,使用以下cf restage命令:
$ cf set-env account-service JAVA_OPTS "-Xmx150M -Xss250K -XX:ReservedCodeCacheSize=70M -XX:MaxMetaspaceSize=90M"
$ cf restage account-service
分配的内存很重要,特别是如果只有 2 GB RAM 可供免费账户使用。应用默认的内存设置,我们只能在 Pivotal 平台上部署两个应用程序,因为每个应用程序都会占用 1 GB 的 RAM。尽管我们解决了前面描述的问题,但我们的应用程序仍然无法正常工作。
绑定服务
在启动过程中,应用程序无法与所需服务连接。问题发生是因为服务默认情况下不会绑定到应用程序。你可以通过运行命令cf services来显示你在你的空间中创建的所有服务,并通过调用命令cf bind-service将每个服务绑定到给定的微服务。在以下命令执行示例中,我们将 Eureka、配置服务器和 MongoDB 绑定到account-service。最后,我们再次运行cf restage,一切应该都能正常工作,如下所示:
$ cf bind-service account-service discovery-service
$ cf bind-service account-service config-service
$ cf bind-service account-service sample-db
使用 Maven 插件
正如我们之前提到的,CLI 和 Web 控制台并不是在 Pivotal 平台上管理应用程序的唯一方式。Cloud Foundry 团队已经实现了 Maven 插件,以促进和加快应用程序的部署。有趣的是,同一个插件可以用来管理任何 Cloud Foundry 实例的推送和更新,不仅仅是由 Pivotal 提供的实例。
当使用 Cloud Foundry 的 Maven 插件时,你可以轻松地将云部署集成到他们的 Maven 项目的生命周期中。这允许你在 Cloud Foundry 中推送、删除和更新项目。如果你想要与 Maven 一起推送你的项目,只需运行以下命令:
$ mvn clean install cf:push
通常,Maven 插件提供的命令与 CLI 提供的命令非常相似。例如,你可以通过执行命令mvn cf:apps来显示应用程序列表。要删除一个应用程序,请运行以下命令:
$ mvn cf:delete -Dcf.appname=product-service
如果你想要上传一些更改到现有应用程序,请使用以下cf:update命令:
$ mvn clean install cf:update
在运行任何命令之前,我们必须正确配置插件。首先,需要传递 Cloud Foundry 登录凭据。建议将它们单独存储在 Maven 的settings.xml中。服务器标签内的典型条目可能如下所示:
<settings>
...
<servers>
<server>
<id>cloud-foundry-credentials</id>
<username>piotr.minkowski@play.pl</username>
<password>123456</password>
</server>
</servers>
...
</settings>
使用 Maven 插件而不是 CLI 命令有一个重要的优势:你可以在一个地方配置所有必要的配置设置,并在应用构建时使用一个命令应用它们。插件的完整配置如下所示。除了包括空间、内存和实例数量等一些基本设置外,还可以通过JAVA_OPTS环境变量和将所需服务绑定到应用程序来改变内存限制。在运行cf:push命令后,product-service可以在https://product-service-piomin.cfapps.io/地址上使用:
<plugin>
<groupId>org.cloudfoundry</groupId>
<artifactId>cf-maven-plugin</artifactId>
<version>1.1.3</version>
<configuration>
<target>http://api.run.pivotal.io</target>
<org>piotr.minkowski</org>
<space>development</space>
<appname>${project.artifactId}</appname>
<memory>300</memory>
<instances>1</instances>
<server>cloud-foundry-credentials</server>
<url>https://product-service-piomin.cfapps.io/</url>
<env>
<JAVA_OPTS>-Xmx150M -Xss250K -XX:ReservedCodeCacheSize=70M -XX:MaxMetaspaceSize=90M</JAVA_OPTS>
</env>
<services>
<service>
<name>sample-db</name>
<label>mlab</label>
<plan>sandbox</plan>
</service>
<service>
<name>discovery-service</name>
<label>p-service-registry</label>
<plan>standard</plan>
</service>
<service>
<name>config-service</name>
<label>p-config-server</label>
<plan>standard</plan>
</service>
</services>
</configuration>
</plugin>
维护
假设我们已经成功部署了构成我们示例微服务系统的所有应用程序,我们可以使用 Pivotal Web Services 仪表板轻松地管理和监控它们,甚至只需使用 CLI 命令。Pivotal 平台提供的免费试用为我们维护应用程序提供了许多可能性和工具,所以让我们探索它的一些最有趣的功能。
访问部署详情
我们可以通过运行cf apps命令或通过在 Web 控制台中导航到我们空间的主页来列出所有已部署的应用程序。你可以在下面的屏幕截图中看到这个列表。表格的每一行代表一个单独的应用程序。除了它的名称外,还有关于其状态、实例数量、分配的内存、部署时间和平台外可访问服务的外部 URL 的信息。如果你在应用部署时没有指定一个 URL 地址,它会自动生成:

你可以点击每一行以发现有关应用程序的详细信息。使用 CLI 命令cf app <app-name>或cf app order-service也可以获取类似的信息。下面的屏幕截图显示了一个应用程序详细视图的主要面板,其中包含事件历史、摘要以及每个实例的内存、磁盘和 CPU 使用情况。在这个面板中,你可以通过点击缩放按钮来扩展应用程序。还有几个其他标签可用。通过切换到其中一个,你可以查看所有绑定服务(服务)、分配的外部 URL(规则)、显示日志(日志)和传入请求历史(追踪):

当然,你总是可以使用 CLI 来收集前例中显示的相同细节。如果你执行命令cf logs <app-name>,你会附加到由应用程序生成的stdout。你还可以显示已激活的 Pivotal 管理服务的列表,以及绑定应用程序的列表,如下面的截图所示:

管理应用程序生命周期
Pivotal Web Services 提供的另一个非常有用的功能是管理应用程序生命周期的能力。换句话说,我们只需点击一次就可以轻松地停止、启动和重新启动一个应用程序。在执行请求的命令之前,你会被提示确认,如下面的截图所示:

以下任一 CLI 命令运行可以达到相同的效果:
$ cf stop <app-name>
$ cf restart <app-name>
$ cf start <app-name>
扩展
使用云解决方案最重要的原因之一是能够轻松扩展应用程序。Pivotal 平台以非常直观的方式处理这些问题。首先,你可能决定在每个部署阶段启动应用程序的实例数量。例如,如果你决定使用manifest.yml并使用cf push命令部署它,创建的实例数量将由字段实例决定,如下面的代码片段所示:
---
applications:
- name: account-service
memory: 300M
instances: 2
host: account-service-piomin
domain: cfapps.io
path: target/account-service-1.0-SNAPSHOT.jar
运行实例的数量,以及内存和 CPU 的限制,可以在启动的应用程序中进行修改。实际上,有两种可用的扩展方法。你可以手动设置应该启动多少实例,或者启用自动扩展,你只需要基于选定指标的阈值定义一个标准。Pivotal 平台上的自动扩展是通过一个名为PCF App Autoscaler的工具实现的。我们可以从以下五个可用的规则中选择,如下所示:
-
CPU 利用率
-
内存利用率
-
HTTP 延迟
-
HTTP 吞吐量
-
RabbitMQ 深度
你可以定义多个活跃规则。每个这些规则都有每个单一指标缩放 down 的最小值和缩放 up 的最大值。customer-service的自动扩展设置如下面的截图所示。在这里,我们决定应用 HTTP 吞吐量和 HTTP 延迟规则。如果 99%的流量延迟低于20毫秒,应该禁用一个应用程序实例,以防有多个实例。类似地,如果延迟超过200毫秒,平台应该附加一个更多的实例:

我们也可以手动控制运行实例的数量。自动扩展有很多优点,但手动方法能让你对这个过程有更多的控制权。由于每个应用程序的内存有限,仍有多余的空间用于其他实例。我们示例系统中压力最大的应用程序是account-service,因为它在订单创建以及订单确认时都会被调用。所以,让我们为这个微服务添加一个实例。为此,请前往account-service详情面板,点击进程和实例下的扩展。然后,你应该增加实例数量并应用必要的更改;你应该会看到account-service有两个实例可用,如下面的屏幕截图所示:

托管服务的部署
我们已经查看了如何使用cf bind-service命令和 Maven 插件将应用程序绑定到服务。然而,我们现在应该看看如何启用和配置我们的服务。你可以轻松显示所有可用服务的列表,然后使用 Pivotal 的仪表板启用它们;这可以在市场下找到。
使用 Pivotal Web Services 提供的托管服务非常简单。安装后,一些服务无需任何额外配置即可使用。我们只需要将它们绑定到选定的应用程序,并在应用程序的设置中正确传递它们的网络地址。每个应用程序都可以通过 UI 仪表板轻松绑定到服务。首先,导航到服务的主页面。在那里,你会看到当前已绑定应用程序的列表。你可以通过点击绑定应用并从显示的列表中选择一个来将新应用程序绑定到服务,如下面的屏幕截图所示:

你只需要在市场上下启用注册表服务并将其绑定到应用程序,就可以在 Pivotal Web Services 上启用发现功能。当然,如果需要,你可以在客户端覆盖一些配置设置。可以在服务的主要配置面板下的管理中显示注册的所有应用程序的完整列表。由于我们在上一节中扩展了它,account-service有两个运行实例;其他微服务只有一个运行实例,如下所示:

与发现服务相比,配置服务器需要包括额外的设置。像以前一样,你应该导航到它的主面板,然后选择“管理”。在这里,你会被重定向到配置表单。配置参数必须以 JSON 对象的形式提供在那里。count参数指定了需要预配的节点的数量,如果实例可以升级的升级选项,以及force即使实例已经是可用的最新版本也强制升级。其他配置参数取决于用于存储属性源的后端类型。正如您可能还记得第五章,使用 Spring Cloud Config 进行分布式配置,Spring Cloud Config Server 最受欢迎的解决方案是基于 Git 仓库的。我们在 GitHub 上创建了一个示例仓库,其中提交了所有所需的源代码。以下是在 Pivotal Web Services 上为 Config Server 提供的 JSON 格式的参数:
{
"count": 1,
"git": {
"password": "****",
"uri": "https://github.com/piomin/sample-spring-cloud-pcf-config.git",
"username": "piomin"
}
}
示例应用程序使用的最后一个代理服务托管了一个 MongoDB 实例。在服务的管理主面板中导航到“管理”,你应该会被重定向到mlab.com/home,在那里你可以使用数据库节点。
Heroku 平台
Heroku 是使用PaaS(平台即服务)模型创建的最古老的云平台之一。与 Pivotal Cloud Foundry 相比,Heroku 没有内置的对 Spring Cloud 应用程序的支持。这使我们的模型稍微复杂了一些,因为我们不能使用平台的服务的典型微服务组件,包括服务发现、配置服务器或断路器。尽管如此,Heroku 包含了一些 Pivotal Web Services 没有的非常有趣的功能。
部署方法
我们可以使用 CLI、网络控制台或专用的 Maven 插件来管理我们的应用程序。在 Heroku 上部署应用程序与在 Pivotal 平台上部署非常相似,但方法有些不同。主要方法假设你是通过从本地 Git 仓库或 GitHub 存储的源代码构建应用程序的。构建完成后,Heroku 平台会自动执行,当你向仓库的分支推送了一些更改,或者从选定分支的最新版本中按需执行。部署应用程序的另一种有趣方式是将你的 Docker 镜像推送到 Heroku 的容器注册表。
使用 CLI
你可以从cli-assets.heroku.com/heroku-cli/channels/stable/heroku-cli-x64.exe下载Heroku 命令行界面(CLI),这是为 Windows 用户提供的(对于 Windows 用户)。为了使用 CLI 在 Heroku 上部署和运行你的应用程序,你必须按照以下步骤进行:
- 安装后,你可以在 shell 中使用
Heroku命令。首先,使用你的凭据登录到 Heroku,如下所示:
$ heroku login
Enter your Heroku credentials:
Email: piotr.minkowski@play.pl
Password: ********
Logged in as piotr.minkowski@play.pl
- 接下来,导航到应用的
root目录并在 Heroku 上创建一个应用。在运行以下命令后,不仅会创建应用,还会创建一个名为heroku的 Git 远程。这与你本地的 Git 仓库相关联,如下所示:
$ heroku create
Creating app... done, aqueous-retreat-66586
https://aqueous-retreat-66586.herokuapp.com/ | https://git.heroku.com/aqueous-retreat-66586.git
Git remote heroku added
- 现在你可以通过将代码推送到 Heroku 的 Git 远程来部署你的应用。Heroku 会为你完成所有工作,具体如下:
$ git push heroku master
- 如果应用启动成功,你将能够使用一些基本命令来管理它。根据以下顺序,你可以显示日志、更改运行中的 dyno 数量(换句话说,扩展应用)、分配新的附加组件,以及列出所有启用的附加组件:
$ heroku logs --tail
$ heroku ps:scale web=2
$ heroku addons:create mongolab
$ heroku addons
连接到 GitHub 仓库
个人而言,我更喜欢通过连接到项目的 GitHub 仓库来将我的应用部署到 Heroku。关于这种部署方法有两种可能的方法:手动和自动。你可以通过导航到应用详情面板上的部署标签,然后将其连接到指定的 GitHub 仓库,如以下屏幕截图所示。如果你点击“部署分支”按钮,将在给定的 Git 分支上立即开始构建和部署。另外,你也可以通过点击启用自动部署来在选定的分支上启用自动部署。此外,如果你为你的 GitHub 仓库启用了持续集成,你还可以配置 Heroku 等待持续集成构建结果;这是一个非常有用的功能,因为它允许你在推送之前运行项目的自动化测试:

Docker 容器注册表
紧跟最新趋势,Heroku 允许你使用 Docker 部署容器化应用。为了做到这一点,你应该在你的本地机器上安装 Docker 和 Heroku CLI:
- 首先,通过运行命令
heroku login登录到 Heroku 云。下一步是登录到容器注册表:
$ heroku container:login
- 接下来,确保你的当前目录包含
Dockerfile。如果存在,你可以通过执行以下命令来构建并在 Heroku 容器注册表中推送镜像:
$ heroku container:push web
- 如果你有一个现有的构建镜像,你可能只对给镜像打标签并推送到 Heroku 感兴趣。为了做到这一点,你需要使用 Docker 的命令行,通过执行以下命令来实现(假设你的应用名称是
piomin-order-service):
$ docker tag piomin/order-service registry.heroku.app/piomin-order-service/web
$ docker push registry.heroku.app/piomin-order-service/web
成功推送镜像后,新应用应该在 Heroku 仪表板上可见。
准备应用
当将基于 Spring Cloud 组件的应用程序部署到 Heroku 时,我们不再需要对其源代码进行任何额外的更改或添加任何额外的库,这是我们本地在本地运行它时所需要做的。这里唯一的不同在于配置设置,我们需要设置一个地址以便将应用程序与服务发现、数据库或任何其他可以为您微服务启用的附加组件集成。当前的示例,与 Pivotal 的部署示例相同,是将数据存储在分配给应用程序作为 mLab 服务的 MongoDB 中。另外,在这里,每个客户端都会在作为piomin-discovery-service部署的 Eureka 服务器上注册自己。下面的屏幕截图显示了部署在 Heroku 上的我们示例中的应用程序列表:

我将前面的应用程序通过连接 GitHub 仓库部署到 Heroku。这要求你为每个微服务创建一个单独的仓库。例如,order-service的仓库可在github.com/piomin/sample-heroku-order-service.git;进行测试。
现在让我们来看看为其中一个示例应用程序提供的配置设置:account-service。首先,我们必须覆盖 MongoDB 的自动配置地址,使用 Heroku 平台提供的MONGODB_URI环境变量。还必须提供正确的 Eureka 服务器地址,以及覆盖注册时发现客户端发送的主机名和端口。这是因为默认情况下,每个应用程序都会尝试使用对其他应用程序不可用的内部地址进行注册。如果不覆盖这些值,使用 Feign 客户端的服务间通信将失败:
spring:
application:
name: account-service
data:
mongodb:
uri: ${MONGODB_URI}
eureka:
instance:
hostname: ${HEROKU_APP_NAME}.herokuapp.com
nonSecurePort: 80
client:
serviceUrl:
defaultZone: http://piomin-discovery-service.herokuapp.com/eureka
请注意,环境变量HEROKU_APP_NAME是部署在 Heroku 上的当前应用程序的名称,如前面的片段中所见。这并非默认可用。要为您的应用程序启用变量,例如customer-service,请运行以下命令并使用实验性附加组件runtime-dyno-metadata:
$ heroku labs:enable runtime-dyno-metadata -a piomin-customer-service
测试部署
-
部署后,每个应用程序都可以在其名称和平台域名组成的地址上访问,例如,
piomin-order-service.herokuapp.com。您可以使用 URL 调用 Eureka 仪表板,即piomin-discovery-service.herokuapp.com/,这将允许您检查我们的示例微服务是否已注册。如果一切工作正常,您应该会看到类似于以下屏幕截图的东西: -
![]()
-
每个微服务都暴露了由 Swagger2 自动生成的 API 文档,所以你可以通过从
/swagger-ui.html获取的 Swagger UI 仪表板轻松地测试每个端点;例如,piomin-order-service.herokuapp.com/swagger-ui.html。order-service的 HTTP API 视图如下: -
![]()
-
每个微服务都在 MongoDB 中存储数据。这个数据库可以通过向 Heroku 项目添加插件来启用,例如 mLab。正如您可能记得的,我们已经在 Pivotal 平台上部署的应用程序中使用过相同服务的示例来存储数据。插件可以通过在应用程序的详细信息面板的资源标签中为其选择计划来为应用程序启用。完成后,您可以简单地点击它来管理每个插件。对于 mLab,您将被重定向到 mLab 网站(mlab.com),在那里您可以查看所有集合、用户和生成的统计信息的列表。以下屏幕截图说明了我们的示例的 mLab 仪表板:

- 总结
- 我们的 Spring Cloud 微服务之旅已经结束!我们的练习始于在本地机器上的简单部署,但在上一章中,我们的微服务部署在完全由云供应商管理的环境中,该环境还自动构建、启动并在指定域名上暴露 HTTP API。我个人认为,我们能够如此轻松地使用任何一种流行的编程语言或第三方工具(如数据库或消息代理)运行、扩展和将数据暴露于应用程序之外,这是非常惊人的。事实上,我们中的每一个人现在都可以在几小时内实施并将一个生产就绪的应用程序部署到网上,而无需担心必须安装的软件。
本章向你们展示了如何在不同的平台上轻松运行 Spring Cloud 微服务。所给示例说明了云原生应用的真正力量。无论你是在自己的笔记本电脑上本地启动应用,还是在 Docker 容器内,使用 Kubernetes,或是在如 Heroku 或 Pivotal Web Services 这样的在线云平台上启动应用,你都不需要在应用的源代码中做任何更改;修改只需要在其属性中进行。(假设你在你的架构中使用 Config Server,这些更改是非侵入性的。)
在过去的两章中,我们探讨了 IT 世界中的一些最新趋势。如持续集成和持续部署(CI 和 CD)、使用 Docker 的容器化、使用 Kubernetes 的编成以及云平台等主题正被越来越多的组织所使用。实际上,这些解决方案在微服务的日益普及中起到了部分作用。目前,在这个编程领域有一个领导者——Spring Cloud。没有其他 Java 框架有如此多的功能,或者能够实现与微服务相关的如此多的模式,如 Spring Cloud。我希望这本书能帮助你在构建和精炼你的基于微服务的企业系统时有效地使用这个框架。





浙公网安备 33010602011771号