云原生-Spring-实战-全-

云原生 Spring 实战(全)

原文:Cloud Native Spring in Action

译者:飞龙

协议:CC BY-NC-SA 4.0

前置内容

前言

我多年来写了数十篇前言和序言,但这次可能是第一次我感到不得不写一篇前言感到烦恼。为什么?我是一个输不起的人!我感到难过,因为我没有写这本书。我感到难过,因为我怀疑我甚至能否写这本书。

本书非常棒。它的每一页都充满了基于明显、深刻经验的有价值想法。我一直想看到所有这些概念都放在一个地方,我将在可预见的未来向人们推荐这本书。

构建生产级别的应用程序,其中生产环境大多是 Kubernetes,并且构建生产环境本身?这无疑是一项艰巨的任务,就像这本书一样,篇幅超过 600 页!但别让我对这本书的篇幅描述让你却步。对于这样一个庞大的主题,这本书实在是大得惊人。

本书涵盖了常见的主题:如何构建服务和微服务,如何处理持久化、消息传递、为可观察性进行仪表化、配置和安全。此外,书中还专门用整章内容来介绍这些概念中的某些部分。

你的 Spring Boot 应用程序是本书雄心壮志的巅峰之作(一个店面系统,当然),但这绝不是本书关注的唯一事物。本书在关注点上既深入又广泛——这是一个惊人的成就!我想我应该列出一些具体内容,这样你就可以开始欣赏这本书的覆盖范围是多么细腻。在我这样做之前,请记住,你必须阅读这本书。以下列表绝非详尽无遗,但它包括了我发现自己对阅读内容感到震惊的东西。这些都是关于 Spring Boot 和 Spring Cloud 的书中应该包含的内容,但不幸的是,很少见。

  • 对 Loki、FluentBit 和 Grafana 的日志处理非常出色。

  • 本书不仅带你进入 Kubernetes 的“上手”阶段,而且带你深入到 Kubernetes 部署的各个方面。你将轻松使用 Knative 和 Spring Cloud Function 实现无服务器架构。接下来,你将使用 GitHub Actions、Kustomize 和 Kubeval 等工具构建管道。最后,你将使用 Tilt 和 Docker Compose 等工具在本地进行开发。

  • 在单页应用(SPA)的背景下讨论安全可能是一本优秀的书籍。它是有能力的、迭代的、快速的,并且注重生产。不要错过这一章节。

  • 所有的工作都是以测试为前提的。Spring 为各种项目提供了互补的测试模块,在这里它们被优雅地展示出来。

  • 本书介绍了 GraalVM 的原生图像编译器和 Spring Native 项目。Spring Native 是生态系统中相对较新的成员,所以没有人会责怪托马斯没有包括它。但他确实包括了。真是个传奇人物!

  • 托马斯介绍了本书中描述的许多做法背后的原因,这与新的技术概念相一致。我特别欣赏对敏捷和 GitOps 的处理。

Spring Boot 改变了世界,托马斯的这本书是探索这个勇敢的、“bootiful”新世界的最佳地图。买下它。阅读它。付诸行动。构建一些令人惊叹的东西,并享受你通往生产的旅程!

——Josh Long

Spring 开发者倡导者

VMware Tanzu

@starbuxman

前言

我清楚地记得第一次我参加实地考察,去了解我和我工作的公司开发的软件是如何在日常工作中被护士和从业者使用的。目睹我们的应用程序如何改善他们照顾患者的方式是一个难以置信的时刻。软件可以产生影响。这就是我们构建它的原因。我们通过技术解决问题,目标是向我们的用户、客户以及企业本身提供价值。

另一个我难以忘记的时刻是我了解到 Spring Boot。在此之前,我非常喜欢使用核心 Spring 框架。我特别喜爱我编写的用于管理安全、数据持久化、HTTP 通信和集成的代码。这是一项艰巨的工作,但值得,尤其是在当时 Java 生态系统的替代方案中。Spring Boot 改变了这一切。突然间,平台本身开始为我处理所有这些方面。所有处理基础设施问题和集成的代码都不再需要了。

但随后我意识到:所有处理基础设施问题和集成的代码都不再需要了!当我开始删除所有这些代码时,我意识到我在这上面花费了这么多时间,与应用程序的业务逻辑相比,这部分是产生价值的。我还意识到,与所有样板代码相比,真正属于业务逻辑的代码其实很少。这是一个转折点!

经过多年,Spring Boot 仍然是 Java 领域中构建企业级软件产品的领先平台之一,其受欢迎的原因之一就是它对开发者生产力的关注。使每个应用程序变得特殊的是其业务逻辑,而不是它如何暴露数据或连接到数据库。正是这种业务逻辑最终为用户、客户和业务带来了价值。利用广泛的框架、库和集成生态系统,Spring Boot 使开发者能够专注于业务逻辑,同时处理管道和样板代码。

云计算以及 Kubernetes(它迅速成为云的“操作系统”)也是我们领域中的另一个颠覆者。利用云计算模型的功能,我们可以构建云原生应用程序,并为我们的项目实现更好的可扩展性、弹性、速度和成本优化。最终,我们有通过我们的软件增加我们产生价值的机会,并以一种以前不可能的方式解决新类型的问题。

这本书的想法源于我帮助软件工程师在创造价值的过程中取得成功的愿望。我很高兴您决定加入我从代码到生产的这次冒险。Spring Boot 以及 Spring 生态系统在整体上代表了这样的旅程的骨干。云原生原则和模式将指导我们实现各种应用。持续交付实践将支持我们安全、快速、可靠地交付高质量的软件。Kubernetes 及其生态系统将为将我们的应用部署和发布给用户提供一个平台。

在构建和撰写这本书时,我的指导原则是提供相关、真实的示例,您可以直接将其应用到日常工作中。书中涵盖的所有技术和模式都旨在在有限的空间内,在生产的限制范围内,交付高质量的软件。我希望我成功地达到了这个目标。

再次感谢您与我一起踏上从代码到生产的云原生之旅。希望您阅读这本书时能有一个愉快且富有教育性的体验,并希望它能帮助您通过软件创造更多价值,并产生积极影响。

致谢

写一本书很困难,没有许多人在整个开发过程中给予我支持,这是不可能完成的。首先,我要感谢我的家人和朋友,他们一直在鼓励我,支持我。特别感谢我的父母 Sabrina 和 Plinio,我的姐姐 Alissa,以及我的祖父 Antonio,他们始终如一的支持和对我充满信心。

我要感谢我的朋友和同行工程师 Filippo、Luciano、Luca 和 Marco,他们在从最初的建议阶段就一直在支持我,并始终愿意提供反馈和建议来改进这本书。我要感谢 Systematic 的同事和朋友,他们在整个过程中一直给予我鼓励。能与你们一起工作,我感到非常幸运。

我要感谢都灵理工大学 Giovanni Malnati 教授,是他首先向我介绍了 Spring 生态系统,并改变了我职业生涯的轨迹。对 Spring 团队创建如此高效且宝贵的生态系统表示衷心的感谢。特别感谢 Josh Long,他的杰出工作让我受益匪浅,并为这本书写了序言。这对我的意义非常重大!

我要感谢整个 Manning 团队在将这本书打造成宝贵资源方面提供的巨大帮助。我特别想感谢 Michael Stephens(收购编辑)、Susan Ethridge(开发编辑)、Jennifer Stout(开发编辑)、Nickie Buckner(技术开发编辑)和 Niek Palm(技术校对)。他们的反馈、建议和鼓励为这本书增添了巨大价值。还要感谢 Mihaela Batinic´(审稿编辑)、Andy Marinkovich(生产编辑)、Andy Carroll(校对)、Keri Hales(校对)和 Paul Wells(生产经理)。

致所有审稿人:Aaron Makin、Alexandros Dallas、Andres Sacco、Conor Redmond、Domingo Sebastian、Eddú Meléndez Gonzales、Fatih Mehmet Ucar、François-David Lessard、George Thomas、Gilberto Taccari、Gustavo Gomes、Harinath Kuntamukkala、Javid Asgarov、João Miguel Pires Dias、John Guthrie、Kerry E. Koitzsch、Michał Rutka、Mladen Knežić、Mohamed Sanaulla、Najeeb Arif、Nathan B. Crocker、Neil Croll、Özay Duman、Raffaella Ventaglio、Sani Sudhakaran Subhadra、Simeon Leyzerzon、Steve Rogers、Tan Wee、Tony Sweets、Yogesh Shetty 和 Zorodzayi Mukuya,你们的建议帮助使这本书变得更好。

最后,我想感谢 Java 社区以及这些年来我在那里遇到的所有了不起的人:开源贡献者、同行演讲者、会议组织者,以及为使这个社区如此特别而做出贡献的每一个人。

关于本书

《云原生 Spring 实战》旨在帮助你使用 Spring Boot 和 Kubernetes 设计和部署云原生应用。它定义了一条通往生产的精选路径,并教授你可以立即应用于企业级应用的有效技术。它还逐步引导你从最初的想法到生产,展示云原生开发如何在软件开发生命周期的每个阶段增加商业价值。当你开发在线书店系统时,你将学习如何使用 Spring 和 Java 生态系统中的强大库来构建和测试云原生应用。逐章阅读,你将使用 REST API、数据持久化、响应式编程、API 网关、函数、事件驱动架构、弹性、安全、测试和可观察性。然后,本书扩展了如何将应用程序打包为容器镜像,如何为 Kubernetes 等云环境配置部署,如何使你的应用程序准备好生产,以及如何使用持续交付和持续部署设计从代码到生产的路径。

本书提供了一本动手、项目驱动的指南,帮助你导航日益复杂的云环境,并学习如何将模式和科技结合起来构建一个真正的云原生系统并将其投入生产。

适合阅读本书的人群?

本书面向希望了解更多关于使用 Spring Boot 和 Kubernetes 设计和部署生产级云原生应用的的开发者和架构师。

为了从这本书中获得最大收益,你需要具备 Java 编程技能、构建 Web 应用的经验,以及 Spring 核心功能的基本知识。我将假设你熟悉 Git、面向对象编程、分布式系统、数据库和测试。不需要有 Docker 和 Kubernetes 的经验。

本书组织结构:路线图

本书分为 4 部分,共涵盖 16 章。第一部分为你的云原生之旅从代码到生产奠定基础,并帮助你更好地理解本书中涵盖的主题,并将它们正确地放置在整体云原生图中。

  • 第一章是云原生景观的介绍。它定义了云原生的含义,云原生应用程序的基本特性以及支持它们的流程。

  • 第二章涵盖了云原生开发的原则,并指导你通过首次动手实践构建一个最小化的 Spring Boot 应用程序,并将其作为容器部署到 Kubernetes。

第二部分介绍了使用 Spring Boot 和 Kubernetes 构建生产就绪云原生应用程序的主要实践和模式。

  • 第三章涵盖了启动新云原生项目的基本知识,包括组织代码库、管理依赖项和定义部署管道的提交阶段策略。你将学习如何使用 Spring MVC 和 Spring Boot Test 实现和测试 REST API。

  • 第四章讨论了外部化配置的重要性,并涵盖了 Spring Boot 应用程序可用的某些选项,包括属性文件、环境变量和 Spring Cloud Config 配置服务。

  • 第五章介绍了云中数据服务的主要方面,并展示了如何使用 Spring Data JDBC 将数据持久性添加到 Spring Boot 应用程序中。你将了解使用 Flyway 管理数据的生产选项以及使用 Testcontainers 进行测试的策略。

  • 第六章是关于容器的;你将了解更多关于 Docker 的知识,以及如何使用 Dockerfile 和 Cloud Native Buildpacks 将 Spring Boot 应用程序打包成容器镜像。

  • 第七章讨论了 Kubernetes,涵盖了服务发现、负载均衡、可伸缩性和本地开发工作流程。你还将了解如何将 Spring Boot 应用程序部署到 Kubernetes 集群。

第三部分涵盖了云原生系统中分布式系统的基本特性和模式,包括弹性、安全性、可伸缩性和 API 网关。它还描述了响应式编程和事件驱动架构。

  • 第八章介绍了响应式编程和 Spring 响应式堆栈的主要功能,包括 Spring WebFlux 和 Spring Data R2DBC。它还教你如何使用 Project Reactor 使应用程序更具弹性。

  • 第九章涵盖了 API 网关模式以及如何使用 Spring Cloud Gateway 构建边缘服务。你将学习如何使用 Spring Cloud 和 Resilience4J 构建具有弹性的应用程序,使用诸如重试、超时、回退、断路器和速率限制器等模式。

  • 第十章描述了事件驱动架构,并教你如何使用 Spring Cloud Function、Spring Cloud Stream 和 RabbitMQ 来实现它们。

  • 第十一章全部关于安全性,展示了如何使用 Spring Security、OAuth2、OpenID Connect 和 Keycloak 在云原生系统中实现身份验证。它还描述了当单页应用程序是系统的一部分时,如何解决 CORS 和 CSRF 等安全担忧。

  • 第十二章继续安全之旅,涵盖了如何使用 OAuth2 和 Spring Security 在分布式系统中委派访问、保护 API 和数据以及根据用户的角色授权用户。

第四部分指导您完成最后几步,使您的云原生应用程序准备好生产环境,解决可观察性、配置管理、秘密管理和部署策略等问题。它还涵盖了无服务器和原生镜像。

  • 第十三章介绍了如何使用 Spring Boot Actuator、OpenTelemetry 和 Grafana 可观察性堆栈使您的云原生应用程序可观察。您将学习如何配置 Spring Boot 应用程序以生成相关的遥测数据,例如日志、健康、指标、跟踪等。

  • 第十四章涵盖了高级配置和秘密管理策略,包括 Kubernetes 原生选项,如 ConfigMaps、Secrets 和 Kustomize。

  • 第十五章指导您完成云原生之旅的最后几步,并教您如何为生产环境配置 Spring Boot。然后,您将为应用程序设置持续部署并将它们部署到公共云中的 Kubernetes 集群,采用 GitOps 策略。

  • 第十六章涵盖了使用 Spring Native 和 Spring Cloud Function 的无服务器架构和函数。您还将了解 Knative 及其强大的功能,这些功能在 Kubernetes 之上提供了卓越的开发者体验。

通常,我建议从第一章开始,按顺序逐章阅读。如果您根据您的特定兴趣选择不同的阅读顺序,请确保您首先阅读第一章到第三章,以便更好地理解书中使用的术语、模式和策略。即便如此,每一章都是基于前一章的,所以如果您那样做,可能会缺少一些上下文。

关于代码

本书提供了一种动手和以项目为导向的体验。从第二章开始,您将为一个虚构的在线书店构建由几个云原生应用程序组成的系统。

您可以从本书的 liveBook(在线)版本中获取可执行的代码片段,网址为 livebook.manning.com/book/cloud-native-spring-in-action。本书中开发的所有项目的所有源代码都可在 GitHub 上找到,并受 Apache License 2.0 许可 (github.com/ThomasVitale/cloud-native-spring-in-action)。对于每一章,您都会找到一个“开始”文件夹和一个“结束”文件夹。每一章都是基于前一章构建的,但您始终可以使用给定章节的“开始”文件夹作为起点,即使您没有跟随前面的章节。而“结束”文件夹包含完成该章节步骤后的最终结果,您可以将其与自己的解决方案进行比较。例如,您可以在 Chapter03 文件夹中找到第三章的源代码,其中包含 03-begin 和 03-end 文件夹。

本书开发的所有应用程序都是基于 Java 17 和 Spring Boot 2.7,并使用 Gradle 构建。这些项目可以导入任何支持 Java、Gradle 和 Spring Boot 的 IDE 中,例如 Visual Studio Code、IntelliJ IDEA 或 Eclipse。您还需要安装 Docker。第二章和附录 A 将提供更多信息,以帮助您设置本地环境。

这些示例已在 macOS、Ubuntu 和 Windows 上进行了测试。在 Windows 上,我建议使用 Windows Subsystem for Linux 来完成书中描述的部署和配置任务。在 macOS 上,如果您使用的是 Apple Silicon 计算机运行所有示例,但您可能会遇到一些工具的性能问题,因为当时它们没有为 ARM64 架构提供原生支持。当相关时,章节将包含额外的上下文信息。

之前提到的 GitHub 仓库 (github.com/ThomasVitale/cloud-native-spring-in-action) 包含本书主分支上的所有源代码。除此之外,我计划维护一个 sb-2-main 分支,其中我将保持与 Spring Boot 2.x 未来版本发布的源代码同步更新,以及一个 sb-3-main 分支,其中我将根据 Spring Boot 3.x 的未来版本发布来更新源代码。

这本书包含了许多源代码示例,既有编号列表中的,也有与普通文本内联的。在两种情况下,源代码都以固定宽度字体格式化,如这样,以将其与普通文本区分开来。有时代码也会加粗,以突出显示章节中从先前步骤中更改的代码,例如,当新功能添加到现有代码行时。

在许多情况下,原始源代码已被重新格式化;我们添加了换行并重新调整了缩进,以适应书籍中的可用页面空间。在极少数情况下,即使这样也不够,列表中还包括了行续接标记(➥)。此外,当代码在文本中描述时,源代码中的注释通常已从列表中删除。许多列表旁边都有代码注释,突出显示重要概念。

liveBook 讨论论坛

购买 《云原生 Spring 实战》 包括免费访问 liveBook,Manning 的在线阅读平台。使用 liveBook 的独家讨论功能,您可以在全球范围内或针对特定章节或段落附加评论。为自己做笔记、提问和回答技术问题,以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问 livebook.manning.com/book/cloud-native-spring-in-action/discussion。您还可以在 livebook.manning.com/discussion 了解更多关于 Manning 的论坛和行为准则。

Manning 对我们读者的承诺是提供一个场所,在那里个人读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量承诺的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要书籍在印刷中,论坛和先前讨论的存档将从出版社的网站提供访问。

其他在线资源

您可以通过 Twitter (@vitalethomas)、LinkedIn (www.linkedin.com/in/vitalethomas) 或我的博客 thomasvitale.com 在线找到我。

如果您想了解更多关于 Spring 生态系统的内容,我维护了一个包含书籍、视频、播客、课程和活动的教育资源列表,可在 github.com/ThomasVitale/awesome-spring 找到。

关于作者

Vitale_FM_Author-Photo

托马斯·维塔莱(Thomas Vitale)是一位专注于构建云原生、弹性且安全的企业应用的软件工程师和架构师。他在丹麦的 Systematic 公司设计和开发软件解决方案,在那里他一直在为云原生世界现代化平台和应用,重点关注开发体验和安全。

他的主要兴趣和关注领域包括 Java、Spring Boot、Kubernetes、Knative 以及云原生技术。托马斯支持持续交付实践,并相信一种旨在共同为用户、客户和业务创造价值的协作文化。他喜欢为 Spring Security 和 Spring Cloud 等开源项目做出贡献,并与社区分享知识。

托马斯拥有都灵理工大学(意大利)计算机工程硕士学位,专攻软件。他是 CNCF 认证的 Kubernetes 应用程序开发者、Pivotal 认证的 Spring 专业人员和 RedHat 认证的企业应用程序开发者。他的演讲活动包括 SpringOne、Spring I/O、KubeCon+CloudNativeCon、Devoxx、GOTO、JBCNConf、DevTalks 和 J4K。

关于封面插图

《云原生 Spring 实战》封面上的插图被标注为“Paisan Dequito”,或“基多农民”,取自雅克·格拉塞·德·圣索沃尔(Jacques Grasset de Saint-Sauveur)的作品集,该作品集于 1797 年出版。每一幅插图都是手工精细绘制和着色的。

在那些日子里,人们通过他们的服饰就能轻易地识别出他们居住的地方以及他们的职业或社会地位。曼宁通过基于几个世纪前丰富多样的地区文化的书封面来庆祝计算机行业的创新精神和主动性,这些文化通过像这一系列这样的图片被重新带回生活。

第一部分 云原生基础

云原生领域如此广泛,以至于入门可能会让人感到不知所措。本书的这一部分将为您的云原生之旅从代码到生产的阶段做好准备。第一章是对云原生领域的理论介绍。它定义了云原生意味着什么,云原生应用的基本特性以及支持它们的流程。在第二章中,您将了解云原生开发的原则,并获得构建最小化 Spring Boot 应用程序并将其作为容器部署到 Kubernetes 的第一手经验。所有这些都将帮助您更好地理解本书其余部分涵盖的主题,并将它们正确地放置在整体云原生图景中。

1 云原生简介

本章涵盖了

  • 云和云计算模型是什么

  • 云原生的定义

  • 云原生应用程序的特点

  • 支持云原生的文化和实践

  • 何时以及为何考虑采用云原生方法

  • 云原生应用程序的拓扑和架构

云原生应用程序是高度分布的系统,它们生活在云中,并且对变化具有弹性。系统由多个服务组成,这些服务通过网络进行通信,并在一个动态环境中部署,其中一切都在不断变化。

在深入探讨技术之前,定义什么是云原生是至关重要的。像我们领域中的其他流行词汇(如 敏捷DevOps微服务)一样,云原生 有时会被误解,并可能成为混淆的来源,因为它对不同的人意味着不同的事情。

在本章中,我将为您提供本书其余部分所需的概念性工具。我将首先定义云原生意味着什么,以及一个应用程序如何被认定为云原生。我将解释云原生应用程序的特性,检查云计算模型的特性,并讨论何时以及为何你可能想要迁移到云。我还会介绍云原生拓扑和架构的一些基本概念。图 1.1 展示了在本章中我将定义和验证的不同元素概览。在本章结束时,你将准备好开始使用 Spring 构建云原生应用程序并部署到 Kubernetes 的旅程。

01-01

图 1.1 云原生是一种旨在利用云技术进行应用程序开发的方法。

1.1 什么是云原生?

2010 年 5 月 25 日,云行业的老将 Paul Fremantle 在他的博客上发布了一篇题为“Cloud Native”的文章。¹ 他是最早使用 cloud native 这个术语的人之一。在微服务、Docker、DevOps、Kubernetes 和 Spring Boot 等概念和技术还不存在的时候,Fremantle 与他在 WSO2 的团队讨论了“应用程序和中间件在云环境中良好工作所需的条件”——即成为 cloud native

Fremantle 解释的关键概念是,云原生应用程序应专门为云设计,并具有利用云环境和云计算模型特性的属性。你可以将传统应用程序(设计为在本地运行 on the ground)迁移到云中,这种做法通常被称为“提升和迁移”,但这并不意味着应用程序是云 native 的。让我们看看是什么意思。

1.1.1 云原生的三个 P

应用程序专门为云设计意味着什么?云原生计算基金会 (CNCF) 在其云原生定义中回答了这个问题:²

云原生技术使组织能够在现代、动态的环境中(如公共云、私有云和混合云)构建和运行可扩展的应用程序。容器、服务网格、微服务、不可变基础设施和声明式 API 是这种方法的例证。

这些技术使系统松散耦合,具有弹性、可管理和可观察性。结合强大的自动化,它们允许工程师频繁且可预测地做出高影响的变化,而工作量最小化。

从这个定义中,我确定了三个我喜欢称之为云原生的三个 P

  • 平台—云原生应用运行在基于动态、分布式环境的平台上:云(公共的、私有的或混合的)。

  • 属性—云原生应用被设计成可扩展的、松散耦合的、有弹性的、可管理的和可观察的。

  • 实践—围绕云原生应用的实践——自动化、持续交付和 DevOps——包括强大的自动化以及频繁和可预测的变化。

什么是云原生计算基金会?

云原生计算基金会(CNCF)是 Linux 基金会的一部分,它“构建可持续的生态系统,并培养社区以支持云原生开源软件的增长和健康。”CNCF 托管了许多云原生技术和项目,以实现无供应商锁定机制的云可移植性。如果你想发现许多针对任何云原生方面的项目,我建议查看 CNCF 云原生交互式景观.^a

^a 云原生计算基金会,“CNCF 云原生交互式景观”,landscape.cncf.io/.

在接下来的部分中,我将进一步探讨这些概念。然而,我首先想让你注意到云原生定义并没有与任何特定的实现细节或技术绑定。CNCF 在其定义中提到了一些,如容器和微服务,但它们只是例子。在开始迁移到云的过程中,常见的误解之一是必须采用微服务架构,构建容器,并将它们部署到 Kubernetes。这并不正确。Fremantle 在 2010 年的帖子就是证明。他没有提到这些,因为它们当时并不存在。然而,他描述的应用不仅仍然被认为是云原生,而且也符合 CNCF 在八年后给出的定义。

1.2 云和云计算模型

在专注于主要角色,即云原生应用之前,我想通过描述云原生应用运行的环境来设定场景:(图 1.2)。在本节中,我将定义云及其主要特征。毕竟,如果云原生应用被设计成在云环境中良好工作,我们就应该知道是什么样的环境。

01-02

图 1.2 云是一种 IT 基础设施,具有不同的计算模型,根据消费者需要的控制程度由提供商提供服务。

云是一种 IT 基础设施,根据云计算模式向消费者提供计算资源。美国国家标准与技术研究院(NIST)将云计算定义为如下:³

云计算是一种使网络访问无处不在、方便、按需的共享计算资源池(例如,网络、服务器、存储、应用程序和服务)的模型,这些资源可以快速配置和释放,同时管理努力或服务提供商的交互最小化。

就像您从供应商那里获取电力而不是自己发电一样,通过云,您可以像获取商品一样获取计算资源(如服务器、存储和网络)。

云服务提供商管理底层云基础设施,因此消费者无需担心物理资源,如机器或网络。转向云的公司可以通过网络(通常是互联网)通过一组 API 获取他们所需的全部计算资源,这些 API 允许他们在按需、自助的基础上根据需要配置和扩展资源。

弹性是该模型的主要特征之一:计算资源可以根据需求动态配置和释放。

弹性是指系统通过自动配置和取消配置资源的能力来适应工作负载变化,以便在每一时刻,可用的资源尽可能接近当前需求。⁴

传统的 IT 基础设施无法提供弹性。公司不得不计算所需的最高计算能力,并建立一个即使大部分时间只需要一部分也能支持该能力的基础设施。在云计算模式下,计算资源的使用情况得到监控,消费者只需为他们实际使用的部分付费。

对于云基础设施应该在哪里或由谁管理没有严格的要求。有几种云服务的部署模式。主要的有私有云、公有云和混合云。

  • 私有云——为单个组织提供使用的云基础设施。它可以由该组织自己或第三方管理,并且可以内部托管或外部托管。对于处理敏感数据或高度关键系统的组织来说,私有云通常是首选选项。它也是确保基础设施符合特定法律和要求的常见选择,如通用数据保护条例(GDPR)或加利福尼亚消费者隐私法案(CCPA)。例如,银行和医疗保健提供者可能会建立自己的云基础设施。

  • 公有云—为公共使用而提供的云基础设施。它通常由一个组织,即云服务提供商拥有和管理,并托管在提供商的场所。公有云服务提供商的例子包括亚马逊网络服务(AWS)、微软 Azure、谷歌云、阿里云和 DigitalOcean。

  • 混合云—由两种或更多不同类型的云基础设施组成,这些基础设施属于之前提到的任何一种类型,它们被绑定在一起,提供如同单一环境的服务。

图 1.3 描述了五种主要的云计算服务模型,每个模型中平台提供的内容,以及提供给消费者的哪些抽象。例如,在基础设施即服务(IaaS)模型中,平台提供并管理计算、存储和网络资源,而消费者则配置和管理虚拟机。选择哪种服务模型的决定应该由消费者对基础设施的控制程度以及他们需要管理的计算资源类型来驱动。

01-03

图 1.3 云计算服务模型的不同之处在于它们提供的抽象级别以及谁负责管理哪些级别(平台或消费者)。

1.2.1 基础设施即服务(IaaS)

基础设施即服务(IaaS)模型中,消费者可以直接控制和配置资源,如服务器、存储和网络。例如,他们可以配置虚拟机并安装操作系统和库等软件。尽管这种模型已经使用了很长时间,但直到 2006 年,亚马逊通过亚马逊网络服务(AWS)使其变得流行并广泛可用。IaaS 提供的例子包括 AWS 弹性计算云(EC2)、Azure 虚拟机、谷歌计算引擎、阿里云虚拟机和 DigitalOcean Droplets。

1.2.2 容器即服务(CaaS)

使用容器即服务(CaaS)模型,消费者无法控制原始的虚拟化资源。相反,他们配置和管理容器。云服务提供商负责配置满足这些容器需求的基础资源,例如通过启动新的虚拟机并配置网络使其可通过互联网访问。Docker Swarm、Apache Mesos 和 Kubernetes 是构建容器平台所使用的工具的例子。所有主要的云服务提供商都提供托管 Kubernetes 服务,这已成为 CaaS 提供的实际技术:亚马逊弹性 Kubernetes 服务(EKS)、Azure Kubernetes 服务(AKS)、谷歌 Kubernetes 引擎(GKE)、阿里云 Kubernetes 容器服务(ACK)和 DigitalOcean Kubernetes。

1.2.3 平台即服务(PaaS)

平台即服务(PaaS)模型中,平台提供基础设施、工具和 API,开发者可以使用它们来构建和部署应用程序。例如,作为一名开发者,你可以构建一个 Java 应用程序,将其打包成 Java 归档(JAR)文件,然后将其部署到一个按照 PaaS 模型工作的平台上。平台提供 Java 运行时和其他所需的中间件,还可以提供额外的服务,如数据库或消息系统。PaaS 服务的例子包括 Cloud Foundry、Heroku、AWS Elastic Beanstalk、Azure App Service、Google App Engine、Alibaba Web App Service 和 DigitalOcean App Platform。在过去的几年里,供应商们正逐渐转向 Kubernetes,为开发者和运维人员构建新的 PaaS 体验。这些新一代服务的例子包括 VMware Tanzu Application Platform 和 RedHat OpenShift。

1.2.4 函数即服务(FaaS)

函数即服务(FaaS)模型依赖于无服务器计算,让消费者专注于实现其应用程序的业务逻辑(通常以函数的形式),而平台则负责提供服务器和其余的基础设施。无服务器应用程序由事件触发,例如 HTTP 请求或消息。例如,你可能编写一个函数,当从消息队列中可用时分析数据集,并按照某些算法计算结果。商业 FaaS 服务的例子包括 Amazon AWS Lambda、Microsoft Azure Functions、Google Cloud Functions 和 Alibaba Functions Compute。开源 FaaS 服务的例子包括 Knative 和 Apache OpenWhisk。

1.2.5 软件即服务(SaaS)

最高抽象级别的服务是软件即服务(SaaS)。在这个模型中,消费者作为用户访问应用程序,而云服务提供商管理整个软件和基础设施栈。许多公司构建自己的应用程序,使用 CaaS 或 PaaS 模型来运行它们,然后将使用情况作为 SaaS 销售给最终客户。SaaS 应用程序的消费者通常使用瘦客户端,如网络浏览器或移动设备来访问它们。可作为 SaaS 提供的应用程序示例包括 Proton Mail、GitHub、Plausible Analytics 和 Microsoft Office 365。

平台与 PaaS

平台这个术语在云原生讨论中可能会引起一些混淆,所以让我们澄清一下。一般来说,平台是一个你用来运行和管理应用程序的操作环境。Google Kubernetes Engine(GKE)是一个提供 CaaS 模型云服务的平台。Microsoft Azure Functions 是一个提供遵循 FaaS 模型云服务的平台。在较低级别,如果你直接在 Ubuntu 机器上部署你的应用程序,那么那将是你的平台。在本书的其余部分,当我使用平台这个术语时,我指的是前面解释的更广泛的概念,除非另有说明。

1.3 云原生应用程序的特性

场景设定:你身处云中。你应该如何设计应用程序以利用其特性?

CNCF 确定了云原生应用程序应具备的五个主要特性:可扩展性、松耦合、弹性、可观察性和可管理性。云原生是一种构建和运行具有这些特性的应用程序的方法。Cornelia Davis 总结说:“云原生软件的定义在于你如何计算,而不是你计算在哪里。”⁵换句话说,云关乎“在哪里”,而云原生关乎“如何”。

我已经涵盖了“在哪里”的部分:云。让我们继续探索“如何”。为了快速参考,图 1.4 列出了特性及其简要描述。

01-04

图 1.4 云原生应用程序的主要特性

1.3.1 可扩展性

云原生应用程序被设计为可扩展,这意味着如果提供额外的资源,它们可以支持增加的工作负载。根据这些额外资源的性质,我们可以区分垂直可扩展性和水平可扩展性:

  • 垂直可扩展性——垂直扩展,或扩展或缩减,意味着向计算节点添加硬件资源或从计算节点中移除它们,例如 CPU 或内存。这种方法是有限的,因为不可能无限制地添加硬件资源。另一方面,应用程序不需要被明确设计为可扩展或缩减。

  • 水平可扩展性——水平扩展,或扩展或缩减,意味着向系统添加更多计算节点或容器,或者从系统中移除它们。这种方法没有垂直可扩展性那样的限制,但它要求应用程序是可扩展的。

在工作负载增加的情况下,传统系统通常会采用垂直可扩展性。增加 CPU 和内存是使应用程序能够支持更多用户而不需要为可扩展性重新设计的常见方法。在特定场景下,这仍然是一个好选择,但我们需要为云做些其他事情。

在云环境中,由于一切都在动态变化中,因此水平扩展更受欢迎。得益于云计算模型提供的抽象层,启动应用程序的新实例比增加已运行机器的计算能力要简单得多。由于云是弹性的,我们可以快速且动态地扩展和缩减应用程序实例。我讨论了弹性作为云的主要特性之一:计算资源可以根据需求主动配置和释放。可扩展性是弹性的先决条件。

图 1.5 显示了垂直和水平可扩展性的区别。在第一种情况下,我们通过向现有的虚拟机添加更多资源来进行扩展。在第二种情况下,我们添加另一个虚拟机以帮助现有的虚拟机处理额外的工作负载。

01-05

图 1.5 当需要支持增加的工作负载时,垂直可扩展性模型会向计算节点添加硬件资源,而水平可扩展性模型则会添加更多的计算节点。

当我们讨论 Kubernetes 时,您将看到,平台(无论是 CaaS、PaaS 还是其他什么)负责动态地扩展和缩减应用程序。作为开发者,我们的责任是设计可扩展的应用程序。可扩展性的主要障碍是应用程序状态,这本质上是一个应用程序是否具有状态的问题。在整本书中,我将介绍构建无状态应用程序并使其无问题扩展的技术。其中之一,我将向您展示如何将应用程序状态从 Spring 推送到像 PostgreSQL 和 Redis 这样的数据存储中。

1.3.2 松耦合

松耦合是一个系统的基本属性,其中各个部分尽可能少地了解彼此。目标是独立地发展每个部分,以便当一个部分发生变化时,其他部分不需要相应地改变。

耦合及其孪生概念内聚在软件工程中已经扮演了数十年的关键角色。将系统分解为模块(模块化)是一种良好的设计实践,其中每个模块对其他部分的依赖最小(松耦合)并且封装一起变化的代码(高内聚)。根据架构风格,一个模块可以模拟一个单体组件或一个独立的服务(例如,一个微服务)。无论哪种方式,我们都应该努力实现适当的模块化,同时保持松耦合和高内聚。

Parnas 确定了模块化的三个好处:⁶

  • 管理性——由于每个模块都是松耦合的,因此负责该模块的团队不需要花费太多时间与其他团队协调和沟通。

  • 产品灵活性——整体系统应该是灵活的,因为每个模块都是独立于其他模块进行演化的。

  • 可理解性——人们应该能够在不研究整个系统的情况下理解并使用一个模块。

前面的好处通常与微服务相关联,但事实是,您不需要微服务就能实现这些好处。在过去的几年里,许多组织决定从单体迁移到微服务。其中一些失败是因为它们缺乏适当的模块化。由紧密耦合、缺乏凝聚力的组件组成的单体在迁移时会产生一个紧密耦合、缺乏凝聚力的微服务系统,有时被称为分布式单体。我不认为这是一个好名字,因为它暗示了单体本质上是由紧密耦合、缺乏凝聚力的组件组成的。这不是真的。架构风格并不重要:坏的设计就是坏的设计。事实上,我喜欢 Simon Brown 提出的模块化单体这个术语,以提高人们对单体可以促进松耦合和高凝聚力的认识,以及单体和微服务最终都可能变成“大泥球”的认识。

在整本书中,我将讨论一些强制实施应用程序中松耦合的技术。特别是,我们将采用基于服务的架构,并专注于构建具有清晰接口的服务,以便相互通信,对其他服务的依赖性最小,并且具有高度的凝聚力。

1.3.3 弹性

如果系统即使在出现故障或环境变化的情况下也能提供其服务,则该系统是弹性的。弹性是“硬件-软件网络在面临故障和正常操作挑战时提供并维持可接受服务水平的特性。”⁷

在构建云原生系统时,我们的目标应该是确保我们的应用程序始终可用,无论基础设施或软件中是否存在故障。云原生应用程序运行在动态环境中,一切都在不断变化,故障可能发生,也必然会发生。这无法避免。在过去,我们曾将变化和故障视为例外,但对于像云原生这样的高度分布式系统,变化不是例外:它们是规则。

讨论弹性时,值得定义三个基本概念:故障、错误和故障:⁸

  • 错误—错误是软件或基础设施中产生不正确内部状态的一种缺陷。例如,即使其规范要求返回非空值,方法调用也可能返回 null 值。

  • 错误—错误是系统预期行为与实际行为之间的差异。例如,由于前面的故障,抛出了 NullPointerException。

  • 故障—当触发故障并导致错误时,可能会发生故障,使系统无法响应,无法按照其规格行事。例如,如果未捕获到 NullPointerException,错误会引发故障:系统对任何请求都返回 500 响应。

故障可能会变成错误,这可能会引发故障,因此我们应该设计容错的应用程序。弹性的一个重要部分是确保故障不会级联到系统的其他组件,而是在修复期间保持隔离。我们还希望系统能够自我修复或自我恢复,云模型可以实现这一点。

在整本书中,我将向你展示一些容忍故障和防止其影响传播到系统其他部分以及扩散故障的技术。例如,我们将使用断路器、重试、超时和速率限制等模式。

1.3.4 可观测性

可观测性是来自控制理论世界的属性。如果你考虑一个系统,可观测性是衡量你从其外部输出推断其内部状态有多好的一个指标。在软件工程背景下,系统可以是单个应用程序或整个分布式系统。外部输出可以是指标、日志和跟踪等数据。图 1.6 展示了可观测性是如何工作的。

01-06

图 1.6 可观测性是关于从应用程序的外部输出推断其内部状态。可管理性是关于通过外部输入改变内部状态和输出。在这两种情况下,应用程序的工件永远不会改变。它是不可变的。

Twitter 的可观测性工程团队确定了可观测性的四个支柱:⁹

  • 监控—监控是关于测量应用程序的特定方面以获取其整体健康状况和识别故障的信息。在这本书中,你将了解 Spring Boot Actuator 的有用监控功能,并将 Prometheus 与 Spring 集成以导出有关应用程序的相关指标。

  • 警报/可视化—收集关于系统状态的资料只有在用于采取某些行动时才有用。当在监控应用程序时识别到故障,应触发警报,并采取一些行动来处理它。特定的仪表板用于可视化收集到的数据,并将它们绘制在相关的图表中,以提供系统行为的良好视图。本书将展示如何使用 Grafana 来可视化从云原生应用程序收集到的数据。

  • 分布式系统跟踪基础设施—在分布式系统中,仅仅跟踪每个子系统的行为是不够的。跟踪通过不同子系统流动的数据是至关重要的。在这本书中,你将集成 Spring 和 OpenTelemetry,并使用 Grafana Tempo 收集和可视化跟踪。

  • 日志聚合/分析——跟踪应用程序中的主要事件对于推断软件的行为以及在出现问题时进行调试至关重要。在云原生系统中,日志应该被聚合和收集,以提供对系统行为的更好了解,并确保能够运行分析以从这些数据中提取信息。在整个书中,我将更多地讨论日志。您将使用 Fluent Bit、Loki 和 Grafana 来收集和可视化日志,并学习在云原生环境中日志的最佳实践。

1.3.5 可管理性

在控制理论中,可观察性的对应物是可控性——外部输入在有限时间间隔内改变系统状态或输出的能力。这个概念引导我们来到云原生主要特性的最后一个:可管理性。

再次从控制理论中汲取灵感,我们可以这样说,可管理性衡量外部输入改变系统状态或输出的容易程度和效率。用不那么数学化的语言来说,这是在不改变代码的情况下修改应用程序行为的能力。这不同于可维护性,可维护性衡量的是你通过改变代码从内部改变系统容易程度和效率。图 1.6 展示了可管理性是如何工作的。

可管理性的一方面是在保持整体系统运行的同时部署和更新应用程序。另一个要素是配置,我将在整本书中深入探讨。我们希望云原生应用程序可配置,这样我们就可以在不改变代码和构建新版本的情况下修改它们的行为。常见的可配置设置包括数据源 URL、服务凭证和证书。例如,根据环境的不同,您可能使用不同的数据源:一个用于开发,一个用于测试,一个用于生产。其他类型的配置可能是功能标志,它决定了在运行时是否应该启用特定功能。我将在整本书中向您展示配置应用程序的不同策略,包括使用 Spring Cloud Config Server、Kubernetes ConfigMaps 和 Secrets 以及 Kustomize。

可管理性不仅关乎特定的变化本身,还关乎你能够多么轻松和高效地应用这些变化。云原生系统复杂,因此设计能够适应功能、环境和安全方面变化要求的应用至关重要。由于这种复杂性,我们应该尽可能通过自动化来管理,这使我们转向云原生三大原则中的最后一个:实践。

1.4 支持云原生的文化和实践

本节将重点关注 CNCF 提供的云原生技术定义中的最后一句:“结合强大的自动化,它们允许工程师频繁且可预测地以最小的努力进行高影响力的更改。” 我将讨论三个概念:自动化、持续交付和 DevOps(图 1.7)。

01-07

图 1.7 云原生开发的文化和实践

1.4.1 自动化

自动化是云原生的核心原则。其理念是通过自动化重复的手动任务来加速云原生应用程序的交付和部署。许多任务可以自动化,从构建应用程序到部署它们,从配置基础设施到管理配置。自动化最重要的优势是它使流程和任务可重复,并使整体系统更加稳定和可靠。手动执行任务容易出错且成本高昂。通过自动化,我们可以得到既可靠又高效的成果。

在云计算模型中,计算资源以自动化、自助服务的方式提供,并且可以弹性地增加或减少。云的自动化有两个重要类别:基础设施配置和配置管理。我们称它们为“基础设施即代码”和“配置即代码”。

马丁·福勒将“基础设施即代码”定义为“通过源代码定义计算和网络基础设施的方法,然后可以像任何软件系统一样对待。”¹⁰

云服务提供商提供了方便的 API 来创建和配置服务器、网络和存储。通过使用 Terraform 等工具自动化这些任务,将代码放入源代码控制,并应用与应用程序开发中使用的相同测试和交付实践,我们得到一个更可靠和可预测的基础设施,它是可重复的、更高效的,且风险更低。一个简单的自动化任务示例可以是创建一个具有 8 个 CPU、64 GB 内存和 Ubuntu 22.04 作为操作系统的虚拟机。

在我们配置了计算资源之后,我们可以管理它们并自动化它们的配置。根据之前的定义,我们可以将“配置即代码”解释为通过源代码定义计算资源配置的方法,它可以像任何软件系统一样对待。

使用 Ansible 等工具,我们可以指定服务器或网络应该如何配置。例如,在上一段中配置 Ubuntu 服务器之后,我们可以自动化安装 Java 运行时环境 (JRE) 17 和从防火墙中打开端口 8080 和 8443 的任务。配置即代码也适用于应用程序配置。

通过自动化所有基础设施配置和管理任务,我们可以避免不稳定的、不可靠的 雪花服务器。当每个服务器都是手动配置、管理和配置时,结果是 雪花:一个脆弱的、独特的服务器,无法复制且更改风险高。自动化有助于避免雪花,转而使用 凤凰服务器:所有作用于这些服务器的任务都是自动化的,每个更改都可以在源控制中跟踪,降低风险,并且每个设置都是可复制的。通过将这一概念发挥到极致,我们实现了所谓的 不可变服务器,CNCF 在其云原生定义中也提到了不可变基础设施。

注意:当比较传统的雪花式基础设施(需要大量的关注和照顾,就像宠物一样)和不可变基础设施或容器(以可丢弃和可替换为特征,就像牛群一样)时,你可能听说过“宠物与牛群”这个表达。在本书中,我不会使用这个表达,但在讨论这个主题时,有时会用到它,所以你应该了解这一点。

在进行初始配置后,不可变服务器不会发生变化:它们是不可变的。如果需要任何更改,它将被定义为代码并交付。然后,从新代码中为新服务器进行配置,同时销毁旧服务器。

例如,如果你的当前基础设施由 Ubuntu 20.04 服务器组成,并且你想升级到 Ubuntu 22.04,你有两种选择。第一种是通过代码定义升级并运行自动化脚本来在现有机器(凤凰服务器)上执行操作。第二种选择是自动化运行 Ubuntu 22.04 的新机器的配置,并开始使用这些(不可变服务器),而不是在现有机器上执行升级。

在下一节中,我将讨论构建和部署应用程序的自动化。

1.4.2 持续交付

持续交付是一种“软件开发学科,其中你以这种方式构建软件,使得软件可以随时发布到生产环境中。”¹¹ 通过持续交付,团队在短周期内实现功能,确保软件可以随时可靠地发布。这种学科是“以最小的努力,频繁且可预测地实现高影响变化”的关键,正如 CNCF 的云原生定义。

持续集成(CI)是持续交付的基础实践。开发者持续地将他们的更改提交到主线(主分支),至少每天提交一次。在每次提交时,软件会自动编译、测试和打包为可执行工件(如 JAR 文件或容器镜像)。其目的是在每次新更改后快速获取关于软件状态的反馈。如果检测到错误,应立即修复以确保主线保持为进一步开发的稳定基础。

持续交付(CD)建立在 CI 的基础上,专注于保持主线始终健康且处于可发布状态。在主线集成过程中生成可执行工件后,软件被部署到一个类似生产环境。它将经过额外的测试来评估其可发布性,例如用户验收测试、性能测试、安全测试、合规性测试以及任何可能增加软件发布信心的其他测试。如果主线始终处于可发布状态,发布软件的新版本就变成了一个业务决策,而不是技术决策。

持续交付鼓励通过一个部署管道(也称为持续交付管道)来自动化整个流程,正如 Jez Humble 和 David Farley 所著的基础书籍《持续交付》(Addison-Wesley Professional, 2010)中所描述的。部署管道从代码提交到可发布的结果,这是唯一的进入生产的方式。在整个书中,我们将构建一个部署管道,以确保我们应用程序的主要分支始终处于可发布状态。最后,我们将使用它来自动将应用程序部署到生产环境中的 Kubernetes 集群。

有时持续交付会被与持续部署混淆。前者确保每次更改后,软件都处于可以部署到生产状态。何时实际执行这是一个业务决策。使用持续部署,我们在部署管道中添加最后一步,在每次更改后自动将新版本部署到生产环境中。

持续交付不仅仅是关于工具。它是一种涉及你组织文化和管理结构变化的学科。设置一个自动化的管道来测试和交付你的应用程序并不意味着你正在进行持续交付。同样,使用 CI 服务器来自动化构建并不意味着你正在进行持续集成。¹²)。

在所有关于 DevOps 的定义中,我发现由 ThoughtWorks 的首席技术官 Ken Mugrage 提出的定义特别有信息量和趣味性。他强调了我认为是 DevOps 真正含义的东西。

一个人们无论头衔或背景如何,共同想象、开发、部署和运营系统的文化。¹³

因此,DevOps 是一种文化,它关乎共同目标下的协作。开发者、测试人员、运维人员、安全专家以及其他人员,无论头衔或背景如何,共同工作,将想法转化为生产并创造价值。

这意味着 壁垒 的终结——特征团队、QA 团队和运维团队之间不再有墙。DevOps 通常被认为是敏捷的自然延续,敏捷通过小团队频繁地向客户交付价值的概念,是 DevOps 的一个促进者。用一句著名的话来简洁地描述 DevOps,那就是亚马逊 CTO Werner Vogels 在 2006 年提出的,当时 DevOps 甚至还不是一件事情:“你构建它,你运行它。”¹⁴

在定义了 DevOps 是什么之后,我将简要提及它不是什么:

  • DevOps 并不意味着 NoOps。认为开发者负责运维,操作员的角色消失是一种常见的错误。相反,这是一种协作。一个团队将包括这两个角色,共同贡献于将产品从原始想法带到生产的整体团队技能。

  • DevOps 不是一个工具。像 Docker、Ansible、Kubernetes 和 Prometheus 这样的工具通常被称为 DevOps 工具,但这并不正确。DevOps 是一种文化。你不会通过使用特定的工具而变成 DevOps 组织。换句话说,DevOps 不是一个产品,但工具是相关的促进者。

  • DevOps 不是自动化。 即使自动化是 DevOps 的一个基本组成部分,但自动化并不是它的定义。DevOps 是关于开发者和操作者从最初的想法到生产过程中一起工作,同时可能自动化他们的一些流程,例如持续交付。

  • DevOps 不是一个角色。 如果我们将 DevOps 视为一种文化或一种心态,那么很难理解 DevOps 角色的意义。然而,对 DevOps 工程师的需求却在不断增加。通常,当招聘人员寻找 DevOps 工程师时,他们寻找的是像自动化工具、脚本和 IT 系统熟练度这样的技能。

  • DevOps 不是一个团队。 如果组织没有完全理解上述观点,他们可能会保留与以前相同的隔阂,只是将操作隔阂替换为 DevOps 隔阂,或者更糟糕的是,仅仅添加一个新的 DevOps 隔阂。

当走向云原生时,开发者和操作者之间的协作至关重要。正如你可能已经注意到的,设计和构建云原生应用程序需要你始终牢记你将部署这些应用程序的地方:云。与操作者一起工作允许开发者设计和构建更高品质的产品。

它被称为 DevOps,但请记住,这个定义不仅适用于开发者和操作者。相反,它普遍适用于人们,无论他们的头衔或背景如何。这意味着协作还涉及其他角色,如测试人员和安全专家(尽管我们可能不需要像 DevSecOps、DevTestOps、DevSecTestOps 或 DevBizSecTestOps 这样的新术语)。他们一起对整个产品生命周期负责,并且是实现持续交付目标的关键。

1.5 云是你的最佳选择吗?

我们行业最大的错误之一就是决定采用一种技术或方法,仅仅因为它很新,而且每个人都正在谈论它。关于公司将其单体迁移到微服务并最终以灾难性的失败告终的故事不计其数。我已经解释了云和云原生应用程序的特性。这些应该为你提供一些指导。如果你的系统不需要这些特性,因为它没有它们试图解决的问题,那么“走向云原生”可能不是你项目的最佳选择。

作为技术人员,我们很容易陷入最新、最流行、最闪亮的技术。关键是要弄清楚一个特定的技术或方法是否能够解决你的问题。我们将想法转化为软件,交付给我们的客户,并为它们提供一些价值。这是我们最终的目标。如果一个技术或方法能帮助你为你的客户提供更多价值,你应该考虑它。如果它不值得,而你决定无论如何都要采用它,你很可能会面临更高的成本和许多问题。

在什么情况下迁移到云是一个好主意?为什么公司采用云原生方法?图 1.8 中说明了迁移到云原生的主要原因,包括速度、规模、弹性和成本。如果你的业务愿景包括这些目标,并且面临云技术试图解决的问题,那么考虑迁移到云并采用云原生方法是个不错的选择。否则,可能最好是留在原地。例如,如果你的公司正在通过其维护阶段的单体应用程序提供服务,该应用程序不会进一步扩展新功能,并且在过去十年中表现良好,那么将其迁移到云可能没有很好的理由,更不用说将其转变为云原生应用程序了。

01-08

图 1.8 采用云原生可以帮助你实现与速度、弹性、规模和成本优化相关的多个目标。

1.5.1 速度

能够更快地交付软件是企业当今的一个重要目标。尽可能快地将想法投入生产,从而缩短上市时间,这是一个关键的竞争优势。在正确的时间将正确的想法投入生产可能会决定成功与失败的区别。

客户期望越来越多地实现功能或修复错误,并且他们希望立即得到这些功能。他们不会高兴地等待六个月才能使用你软件的下一个版本。他们的期望不断增长,你需要一种方法来跟上他们。最终,这一切都是为了向客户提供价值,并确保他们对结果感到满意。否则,你的业务将无法在激烈的竞争中生存。

快速且频繁地交付不仅关乎竞争和客户截止日期,还关乎缩短反馈周期。频繁的小规模发布意味着你可以更快地从客户那里获得反馈。反过来,较短的反馈循环会降低你发布的新功能相关的风险。你不必花费数月时间尝试实现完美的功能,而是可以更快地将它推出,从客户那里获得反馈,并根据他们的期望进行调整。此外,较小的发布包含的更改较少,因此减少了可能失败的部分数量。

需要灵活性,因为客户期望你的软件持续进化。例如,它应该足够灵活,以支持新的客户端类型。如今,我们日常生活中越来越多的物品已经连接到互联网,例如各种移动和物联网系统。你希望对任何未来的扩展和客户端类型都持开放态度,以便以新的方式提供商业服务。

传统的软件开发方法不支持这一目标。它往往以大规模发布、灵活性低和延长发布周期为特征。云原生方法,结合自动化任务、持续交付工作流程和 DevOps 实践,有助于企业加快速度并缩短上市时间。

1.5.2 弹性

一切都在变化,故障随时都会发生。我们试图预测故障并将它们视为异常的时代已经过去了。正如我之前提到的,变化不是异常,而是规则。

客户希望软件能够全天候可用,并且在新功能发布时立即升级。停机或故障可能导致直接的经济损失和客户不满。它们甚至可能影响一个人的声誉,损害组织的未来市场机会。

无论基础设施还是软件出现故障,您的目标是保证系统的可用性和可靠性,即使是在降级操作模式下也是如此。为了保证可用性,您需要准备应对故障的措施,处理它们,并确保整体系统能够继续为用户提供服务。处理故障或升级等任务所需的任何操作都应要求零停机时间。客户期望如此。

我们希望云原生应用具有弹性,云技术提供了实现弹性基础设施的策略。如果始终可用、安全且具有弹性是您业务的要求,那么云原生方法对您来说是一个不错的选择。软件系统的弹性反过来又使速度得到提升:系统越稳定,您就越能频繁地安全发布新功能。

1.5.3 规模

弹性是指根据负载调整软件的能力。您可以将弹性系统扩展到确保为所有客户提供充足的服务水平。如果负载高于正常水平,您将需要启动更多服务实例来支持额外的流量。或者,也许发生了可怕的事情,某些服务失败——您需要能够启动新实例来替代它们。

预测未来会发生什么很难,如果不是不可能的话。仅仅构建可扩展的应用程序是不够的——您需要它们能够动态扩展。每当有高负载时,您的系统应该能够快速、轻松地动态扩展。当高峰期过后,它应该再次缩小规模。

如果您的业务需要快速有效地适应新客户或需要灵活性以支持新类型的客户(这会增加服务器的负载),云的本质可以为您提供所需的全部弹性,结合云原生应用程序,这些应用程序按定义是可扩展的。

1.5.4 成本

作为软件开发者,你可能不会直接处理金钱,但在设计解决方案时,考虑成本是你的责任。云计算模型通过其弹性和按需付费的使用政策,有助于优化 IT 基础设施成本。不再需要始终在线的基础设施:你需要资源时才配置资源,根据实际使用付费,不再需要时再将其销毁。

此外,采用云原生方法还能进一步优化成本。云原生应用程序被设计成可扩展的,以便可以利用云的弹性。它们具有弹性,因此在生产中与停机时间和硬故障相关的成本较低。它们松散耦合,使团队能够更快地工作并加快上市时间,具有显著的竞争优势。还有更多。

迁移到云的成本

在决定迁移到云之前,还必须考虑其他类型的成本。一方面,你可以通过只为你使用的部分付费来优化成本。但另一方面,你应该考虑迁移的成本及其后果。

迁移到云需要特定的技能,员工可能还没有掌握。这可能意味着投资他们的教育以获得必要的技能,也许需要聘请专业人士作为顾问来帮助进行云迁移。根据所选方案,组织可能需要承担一些额外的责任,例如在云中处理安全问题,这反过来又需要特定的技能。还有其他考虑因素,如迁移期间的业务中断、重新培训最终用户以及更新文档和支持材料。

1.6 云原生拓扑

我对云原生的解释没有涉及特定的技术或架构。CNCF 在其定义中提到了一些,如容器和微服务,但这些都只是例子。你的应用程序要成为云原生,不必一定要使用 Docker 容器。考虑无服务器或 PaaS 解决方案。为 AWS Lambda 平台编写函数或部署到 Heroku 不需要你构建容器。尽管如此,它们仍被归类为云原生。

在本节中,我将描述一些常见的云原生拓扑结构(见图 1.9)。首先,我将介绍容器和编排的概念,这些概念将在后续讨论 Docker 和 Kubernetes 时进一步探讨。然后,我将介绍无服务器技术和函数(FaaS)的主题。在这本书中,我不会过多关注 FaaS 模型,但我会涵盖使用 Spring Native 和 Spring Cloud Function 构建无服务器应用程序的基础知识。

01-09

图 1.9 主要的云原生计算模型是容器(由编排器管理)和无服务器。

1.6.1 容器

想象一下,你加入了一个团队并开始开发一个应用程序。你做的第一件事是遵循指南设置与同事使用的本地开发环境相似的本地开发环境。你开发了一个新功能,然后在质量保证(QA)环境中对其进行测试。一旦验证无误,应用程序可以部署到预发布环境进行额外测试,最后部署到生产环境。应用程序被构建成在具有特定特性的环境中运行,因此确保所有不同的环境尽可能相似是至关重要的。你将如何做到这一点?这就是容器出现的地方。

在容器出现之前,你会依赖虚拟机来保证环境的可重复性、隔离性和可配置性。虚拟化通过利用虚拟化组件来抽象硬件,使得在同一台机器上以隔离的方式运行多个操作系统成为可能。虚拟化直接在机器硬件(类型 1)或宿主操作系统(类型 2)上运行。

另一方面,操作系统容器是一个轻量级的可执行包,它包含了运行应用程序所需的一切。容器与宿主机共享相同的内核:无需引导完整的操作系统来添加新的隔离环境。在 Linux 上,这是通过利用 Linux 内核提供的几个特性来实现的:

  • namespaces 用于在进程之间划分资源,以便每个进程(或进程组)只能看到机器上可用的资源子集

  • cgroups 用于控制并限制进程(或进程组)的资源使用

注意:当仅使用虚拟化时,硬件是共享的,而容器还共享相同的操作系统内核。两者都为在隔离环境中运行软件提供计算环境,尽管隔离程度不同。

图 1.10 展示了虚拟化和容器技术之间的差异。

01-10

图 1.10 虚拟化和容器技术在隔离环境中共享的内容上有所不同。虚拟机仅共享硬件。容器还共享操作系统内核。容器更轻量级且易于携带。

为什么容器在云原生应用程序中如此受欢迎?传统上,你需要在虚拟机上安装和维护 Java 运行时环境(JRE)和中间件,以便使你的应用程序运行。相反,容器可以在几乎任何计算环境中可靠地运行,独立于应用程序、其依赖项或中间件。它无关紧要它是哪种类型的应用程序,是用哪种语言编写的,或者它使用了哪些库。所有容器在外观上都有类似的形状,就像用于运输的容器一样。

因此,容器使敏捷性、跨不同环境的可移植性和部署可重复性成为可能。由于它们轻量级且资源需求较低,它们非常适合在云中运行,在那里应用程序是可丢弃的,并且可以动态和快速地扩展。相比之下,构建和销毁虚拟机要昂贵得多,耗时也长。

容器!到处都是容器!

容器 是那些可以有不同的含义的词汇之一。有时这种歧义可能会产生混淆,所以让我们看看它在特定语境中的含义。

  • OS——OS 容器是在与系统其他部分隔离的环境中运行一个或多个进程的方法。本书将重点介绍 Linux 容器,但请注意,Windows 容器也存在。

  • Docker——Docker 容器是 Linux 容器的一种实现。

  • OCI——OCI 容器是由开放容器倡议(OCI)对 Docker 容器实现的标准化的结果。

  • Spring——Spring 容器是管理并执行对象、属性和其他应用程序资源的应用程序上下文。

  • Servlet——Servlet 容器为利用 Java Servlet API 的 Web 应用程序提供运行时。Tomcat 服务器中的 Catalina 组件是 Servlet 容器的一个例子。

虚拟化和容器不是互斥的。你可以在云原生环境中同时使用它们,拥有由运行容器的虚拟机组成的底层基础设施。基础设施即服务(IaaS)模型提供了一个虚拟化层,你可以使用它来启动新的虚拟机。在此基础上,你可以安装容器运行时并运行你的容器。

应用程序通常由不同的容器组成,这些容器可以在开发期间或早期测试时在同一台机器上运行。但很快你就会达到一个难以管理许多容器的复杂程度,尤其是当你开始复制它们以实现可伸缩性和跨不同机器分发时。那时,你将开始依赖由容器即服务(CaaS)模型提供的更高层次的抽象,该模型提供了在机器集群中部署和管理容器的能力。请注意,幕后仍然存在一个虚拟化层。

即使你在使用像 Heroku 或 Cloud Foundry 这样的 PaaS 平台时,容器也会被涉及。你只需提供 JAR 文件,就可以在这些平台上部署你的应用程序,因为它们会处理 JRE、中间件、操作系统以及任何需要的依赖项。幕后,它们会构建一个由所有这些组件组成的容器,并最终运行它。区别在于,不再是你要负责构建容器——平台会为你完成这项工作。一方面,这对开发者来说很方便,因为责任更少。另一方面,你正在放弃对运行时和中间件的控制,并且可能会面临供应商锁定。

在这本书中,你将学习如何使用 Cloud Native Buildpacks(一个 CNCF 项目)来容器化 Spring 应用程序,你将使用 Docker 在本地环境中运行它们。

1.6.2 编排

因此,你已经决定使用容器,太好了!你可以依赖它们的可移植性将它们部署到任何提供容器运行时的基础设施上。你可以实现可重复性,因此当容器从开发到预发布再到生产迁移时,不会有任何意外的坏情况。由于它们非常轻量级,你可以快速扩展它们,并为你的应用程序提供高可用性。你已经准备好为你的下一个云原生系统采用它们了。或者,你是吗?

在单个机器上提供和管理容器相当直接。但是,当你开始处理在多个机器上扩展和部署的数十或数百个容器时,你需要其他东西。

当你从虚拟服务器(IaaS 模型)迁移到容器集群(CaaS 模型)时,你也在改变你的视角。¹⁵ 在 IaaS 中,你关注单个计算节点,即虚拟服务器。在 CaaS 中,底层基础设施被抽象化,你关注的是节点的集群。

通过 CaaS 解决方案提供的新视角,部署目标将不再是单个机器,而是一组机器的集群。基于 Kubernetes 等平台的 CaaS 平台提供了许多功能,以解决我们在云原生环境中寻找的所有重大关注点,编排跨机器的容器。两种不同的拓扑结构如图 1.11 所示。

01-11

图 1.11 容器的部署目标是单个机器,而编排器的目标是集群。

容器编排帮助你自动化许多不同的任务:

  • 管理集群,在必要时启动和关闭机器

  • 在集群内调度和部署容器到满足 CPU 和内存要求的机器

  • 动态扩展容器以实现高可用性和弹性,利用健康监控

  • 为容器设置网络以相互通信,定义路由、服务发现和负载均衡

  • 将服务暴露给互联网,建立端口和网络

  • 根据特定标准为容器分配资源

  • 配置容器内运行的应用程序

  • 确保安全并执行访问控制策略

编排工具通过声明性指令进行指导,例如通过 YAML 文件。遵循特定工具定义的格式和语言,你通常描述你想要达到的状态;例如,你希望在集群中部署三个你的 Web 应用程序容器的副本,将其服务暴露给互联网。

容器编排器的例子有 Kubernetes(一个 CNCF 项目)、Docker Swarm 和 Apache Mesos。在这本书中,你将学习如何使用 Kubernetes 来编排你的 Spring 应用的容器。

1.6.3 无服务器

在从虚拟机迁移到容器之后,我们可以进一步抽象化基础设施:这就是无服务器技术所在的位置。无服务器计算模型使开发者能够专注于实现他们应用程序的业务逻辑。

“无服务器”这个名字可能具有误导性。当然,有一个服务器。区别在于你不需要管理它或在该服务器上编排应用程序的部署。现在这是平台的责任。当你使用 Kubernetes 这样的编排器时,你仍然必须考虑基础设施的提供、容量规划和扩展。相比之下,无服务器平台负责设置应用程序所需的底层基础设施,包括虚拟机、容器和动态扩展。

无服务器架构通常与函数相关联,但它们由两种主要模型组成,这些模型通常一起使用:

  • 后端即服务 (BaaS)—在这个模型中,应用程序严重依赖云提供商提供的第三方服务,例如数据库、身份验证服务和消息队列。重点是降低与后端服务相关的开发和运营成本。开发者可以实现前端应用程序(如单页应用程序或移动应用程序),同时将大多数或全部后端功能卸载给 BaaS 供应商。例如,他们可以使用 Okta 进行用户身份验证,使用 Google Firebase 持久化数据,以及使用 Amazon API Gateway 发布和管理 REST API。

  • 函数即服务 (FaaS)—在这个模型中,应用程序是无状态的,由事件触发,并由平台完全管理。重点是降低与编排和扩展应用程序相关的部署和运营成本。开发者可以为他们的应用程序实现业务逻辑,而平台则负责其余部分。无服务器应用程序不需要用函数来实现才能被归类为这种类型。主要有两种主要的 FaaS 提供方式。一种选择是采用特定供应商的 FaaS 平台,例如 AWS Lambda、Azure Functions 或 Google Cloud Functions。另一种选择是选择基于开源项目的无服务器平台,这些平台可以在公有云或本地运行,解决供应商锁定和控制不足等问题。此类项目的例子有 Knative 和 Apache OpenWhisk。Knative 在 Kubernetes 之上提供了一个无服务器运行环境,正如你在第十六章中看到的。它被用作 VMware Tanzu Application Platform、RedHat OpenShift Serverless 和 Google Cloud Run 等企业级无服务器平台的基础。

无服务器应用程序通常是事件驱动的,仅在需要处理事件时运行,例如 HTTP 请求或消息。事件可以是外部的,也可以由另一个函数产生。例如,每当消息被添加到队列中时,一个函数可能会被触发,处理该消息,然后退出执行。

当没有需要处理的内容时,无服务器平台会关闭与函数相关的所有资源,这样你就可以真正地为实际使用付费。在其他云原生拓扑结构,如 CaaS 或 PaaS 中,总有一台服务器在 24/7 运行。与传统系统相比,它们提供了动态可伸缩性的优势,减少了在任何给定时间配置的资源数量。然而,始终有东西在运行,并且它是有成本的。在无服务器模型中,资源仅在必要时配置。如果没有需要处理的内容,一切都会关闭。这就是我们所说的缩放到零,这是无服务器平台提供的主要功能之一。

除了成本优化外,无服务器技术还将一些额外的责任从应用程序转移到平台。这可能是一个优势,因为它允许开发者专注于业务逻辑。但考虑你希望拥有的控制程度以及你将如何处理供应商锁定也是至关重要的。每个 FaaS 平台,以及一般意义上的无服务器平台,都有自己的特性和 API。一旦你开始为特定平台编写函数,你就不容易将其轻松地移动到另一个平台,就像处理容器那样。使用 FaaS,你可能会比其他任何方法都更多地做出妥协——以牺牲控制和可移植性为代价,优先考虑责任和范围。这就是为什么 Knative 迅速流行起来的原因:它是建立在 Kubernetes 之上的,这意味着你可以轻松地在平台和供应商之间移动你的无服务器工作负载。最终,这是一个权衡的问题。

1.7 云原生应用程序的架构

我们已经到达了定义云原生之旅的最后一站,我在书中介绍了我们将依赖的主要特征。在前一节中,你熟悉了主要的云原生拓扑结构,特别是容器,它们是我们的计算单元。现在让我们看看里面的内容,并探索一些在架构和设计云原生应用程序中涉及的高级原则。图 1.12 展示了本节涵盖的主要概念。

01-12

图 1.12 云原生架构元素

1.7.1 从多层架构到微服务架构以及更远

IT 基础设施始终影响着软件应用程序的架构和设计方式。最初,单体应用程序作为单一组件部署在巨大的主机上。当互联网和 PC 变得流行时,我们开始根据客户端/服务器范式设计应用程序。多层架构,依赖于这个范式,被广泛用于桌面和 Web 应用程序,将代码分解为表示层、业务层和数据层。

随着应用程序复杂性的增加和对敏捷性的需求,探索了进一步分解代码的新方法,一种新的架构风格——微服务(microservices)应运而生。在过去的几年里,这种架构风格越来越受欢迎,许多公司决定根据这种新风格重构他们的应用程序。微服务通常与图 1.13 所示的单体应用程序进行比较。

01-13

图 1.13 单体应用程序与微服务。单体架构通常是多层的。微服务由不同组件组成,这些组件可以独立部署。

主要区别在于应用程序的分解方式。单体应用程序通常与使用三个大型层相关联。相比之下,基于微服务(microservices)的应用程序则与许多组件相关联,每个组件只实现一部分功能。已经提出了许多模式来将单体(monolith)分解成微服务,并处理由于拥有多个组件而不是一个组件而产生的复杂性。

注意:本书不是关于微服务的,因此我不会详细介绍它们。如果您对这个主题感兴趣,可以查看山姆·纽曼(Sam Newman)的《Building Microservices》,第二版(O’Reilly,2021 年)和克里斯·理查森(Chris Richardson)的《Microservices Patterns》(Manning,2018 年)。如果您对 Spring 有更多兴趣,可以在 Manning 目录中找到约翰·卡内尔(John Carnell)和伊拉里·华伊卢波·桑切斯(Illary Huaylupo Sanchez)的《Spring Microservices in Action》,第二版(Manning,2021 年)。如果您不熟悉微服务,不用担心。您不需要这些知识就能跟随本书。

在多年的名声和失败的迁移之后,关于这种流行架构风格未来的激烈讨论在开发者社区中兴起。一些工程师开始谈论宏服务(macroservices)以减少组件数量,从而降低管理它们的复杂性。术语“宏服务”是由辛迪·斯里达兰(Cindy Sridharan)讽刺性地提出的,但它在业界被采用,并被像 Dropbox 和 Airbnb 这样的公司用来描述他们的新架构。¹⁶其他人提出了堡垒(citadel)架构风格,由一个中心单体和周围的微服务组成。还有一些人主张以模块化单体(modular monoliths)的形式回归单体应用程序。

最后,重要的是选择一种能够为我们客户和业务创造价值的架构。这就是我们最初开发应用程序的原因。每种架构风格都有其用例。没有银弹或一刀切解决方案。与微服务相关的许多负面经历都是由其他问题引起的,例如代码模块化不良或组织结构不合适。单体和微服务之间不应该有战争。

在这本书中,我感兴趣的是向你展示如何使用 Spring 构建云原生应用并将它们作为容器部署到 Kubernetes。云原生应用是分布式系统,就像微服务一样。一些通常在微服务背景下讨论的主题实际上属于任何分布式系统,例如路由和服务发现。云原生应用根据定义是松散耦合的,这也是微服务的一个特性。

即使它们有一些相似之处,我们也要明白云原生应用和微服务并不相同。你当然可以使用微服务风格来构建云原生应用。许多开发者确实这样做,但这并不是一个必要条件。在这本书中,我将使用一种我们可能称之为基于服务的架构风格。也许这个名字不够吸引人,也不是很花哨,但对于我们的目的来说已经足够了。我们将处理服务。它们可以是任何大小,并且可以根据不同的原则封装逻辑。这并不重要。我们想要的只是设计服务以满足我们的开发、组织和业务需求。

1.7.2 云原生应用的基于服务的架构

在整本书中,我们将根据基于服务的架构设计和构建云原生应用。

我们的工作中心将是一个可以以不同方式与其他服务交互的服务。使用 Cornelia Davis 在她的书《云原生模式》(Manning,2019)中提出的方法,我们可以识别出架构的两个要素:服务和交互。

  • 服务——为另一个组件提供任何类型服务的组件

  • 交互——服务之间为了完成系统需求而进行的通信

服务是非常通用的组件——它们可能是任何东西。我们可以根据它们是否存储任何类型的状态来对它们进行分类,区分应用服务(无状态)和数据服务(有状态)。

图 1.14 显示了云原生架构的元素。一个用于管理图书馆库存的应用程序将是一个应用服务。一个用于存储书籍信息的 PostgreSQL 数据库将是一个数据服务。

01-14

图 1.14 云原生应用的基于服务的架构。主要元素是服务(应用或数据),它们以不同的方式相互交互。

应用服务

应用服务是无状态的,负责实现任何类型的逻辑。只要它们暴露了本章前面学到的所有云原生属性,它们就不必遵守像微服务那样的特定规则。

在设计每个服务时,考虑松散耦合和高内聚至关重要。服务应尽可能独立。分布式系统很复杂,因此在设计阶段应格外小心。增加服务的数量会导致问题数量增加。

你可能需要自己开发和维护系统中的大多数应用服务,但你也可以使用云服务提供商提供的一些服务,例如身份验证或支付服务。

数据服务

数据服务是有状态的,并负责存储任何类型的状态。状态是关闭服务或启动新实例时应保留的一切。

数据服务可以是关系数据库,如 PostgreSQL,键值存储,如 Redis,或消息代理,如 RabbitMQ。你可以自己管理这些服务。这样做比管理云原生应用更具挑战性,因为需要存储来保存状态,但你将对自己的数据有更多的控制。另一种选择是使用云提供商提供的数据服务,在这种情况下,提供商将负责管理所有与存储、弹性、可扩展性和性能相关的关注点。在这种情况下,你可以利用为云专门构建的许多数据服务,例如 Amazon DynamoDB、Azure Cosmos DB 和 Google BigQuery。

云原生数据服务是一个有趣的话题,但本书我们将主要处理应用。关于集群、复制、一致性和分布式事务等数据相关的问题不会在本书中详细讨论。我非常愿意这样做,但它们值得有自己的一本书来充分覆盖。

交互

云原生服务通过相互通信来满足系统的需求。这种通信方式将影响系统的整体属性。例如,选择请求/响应模式(同步 HTTP 调用)而不是基于事件的方案(通过 RabbitMQ 流的消息)将导致应用程序具有不同的弹性水平。在本书中,我们将使用不同类型的交互,了解它们之间的差异,并了解何时使用每种方法。

摘要

  • 云原生应用是高度分布式的系统,专门为云设计和运行。

  • 云是基于计算、存储和网络资源作为商品提供的 IT 基础设施。

  • 在云中,用户只需为实际使用的资源付费。

  • 云平台在不同的抽象级别上提供其服务:基础设施(IaaS)、容器(CaaS)、平台(PaaS)、函数(FaaS)或软件(SaaS)。

  • 云原生应用是水平可扩展的、松散耦合的、高度内聚的、对故障有弹性的、可管理的和可观察的。

  • 云原生开发由自动化、持续交付和 DevOps 支持。

  • 持续交付是一种整体工程实践,旨在快速、可靠和安全地交付高质量的软件。

  • DevOps 是一种文化,它使不同角色之间能够协作,共同创造商业价值。

  • 现代企业采用云原生来生产可以快速交付、根据需求动态扩展,并且始终可用且对故障具有弹性的软件,同时优化成本。

  • 在设计云原生系统时,容器(如 Docker 容器)可以用作计算单元。它们比虚拟机更轻量,并提供可移植性、不可变性和灵活性。

  • 专用平台(如 Kubernetes)提供管理容器而不直接处理底层层的服务。它们提供容器编排、集群管理、网络服务和调度。

  • 无服务器计算是一种模型,其中平台(如 Knative)管理服务器和底层基础设施,而开发者只需关注业务逻辑。后端功能基于按使用付费的方式实现,以优化成本。

  • 微服务架构可以用来构建云原生应用,但这不是必需的。

  • 设计云原生应用时,我们将使用以服务和它们之间的交互为特征的服务式风格。

  • 云原生服务可以分为应用服务(无状态)和数据服务(有状态)。


^(1.)P. Fremantle,“云原生”,保罗·弗雷曼特的博客,2010 年 5 月 28 日,mng.bz/Vy1G.

^(2.)云原生计算基金会,“CNCF 云原生定义 v1.0”,mng.bz/de1w.

^(3.)NIST,“云计算的 NIST 定义”,SP 800-145,2011 年 9 月,mng.bz/rnWy.

^(4.)N.R. Herbst,S. Kounev,和 R. Reussner,“云计算中的弹性:它是什么,它不是什么”,在第 10 届国际自适应性计算会议(ICAC 2013)论文集中,mng.bz/BZm2.

^(5.)C. Davis,“面对基础设施不稳定实现软件可靠性”,在IEEE 云计算 4, 5, 第 34-40 页,2017 年 9 月/10 月。

^(6.)D.L. Parnas,“在将系统分解为模块时使用的标准”,“ACM 通讯”15, 12 (1972 年 12 月), 1053-1058, mng.bz/gwOl.

^(7.)J.E. Blyler,“用于弹性的启发式方法——比可靠性更丰富的指标”,“2016 年 IEEE 国际系统工程会议(ISSE)”,2016 年,第 1-4 页。

^(8.)A. Avižienis,J. Laprie,和 B. Randell,“可靠性基本概念”,2001 年,mng.bz/e7ez.

^(9.)A. Asta,“Twitter 的可观察性:技术概述,第一部分”,2016 年 3 月 18 日,mng.bz/pO8G.

^(10.) M. Fowler, “基础设施即代码,” 2016 年 3 月 1 日, martinfowler.com/bliki/InfrastructureAsCode.html.

^(11.) M. Fowler, “持续交付,” 2013 年 5 月 30 日, mng.bz/lRWo.

^(12.) M. Fowler, “持续集成认证,” 2017 年 1 月 18 日, mng.bz/xM4X.

^(13.) K. Mugrage, “我对 DevOps 的定义,” 2020 年 12 月 8 日, mng.bz/AVox.

^(14.) J. Barr, “ACM Queue:采访亚马逊的 Werner Vogels,” AWS 新闻博客, 2006 年 5 月 16 日, mng.bz/ZpqA.

^(15.) N. Kratzke 和 R. Peinl, “ClouNS——面向企业架构师的云原生应用程序参考模型,” 2016 IEEE 第 20 届国际企业分布式对象计算研讨会 (EDOCW), 2016, 第 1-10 页, doi: 10.1109/EDOCW.2016.7584353.

^(16.) C. Sridharan, 2022 年 5 月 15 日, mng.bz/YG5N.

2 云原生模式和科技

本章涵盖

  • 理解云原生应用的开发原则

  • 使用 Spring Boot 构建云原生应用

  • 使用 Docker 和 Buildpacks 容器化应用

  • 使用 Kubernetes 将应用部署到云端

  • 介绍本书中使用的模式和科技

我们为云设计应用的方式与传统方法不同。由最佳实践和开发模式组成的 12-Factor 方法,是构建可被视为云原生应用的应用的良好起点。我将在本章的第一部分解释该方法,并在整本书中对其进行扩展。

在本章的后面部分,我们将构建一个简单的 Spring Boot 应用,并使用 Java、Docker 和 Kubernetes 运行它,如图 2.1 所示。在整个书中,我将深入探讨这些主题中的每一个,所以如果你觉得某些内容不是完全清楚,请不要担心。本章旨在为你提供从代码到云环境中生产的旅程的心理地图,同时让你熟悉我们将在本书的其余部分使用的模式和科技。

02-01

图 2.1 Spring 应用从 Java 到容器再到 Kubernetes 的旅程

最后,我将向你介绍我们将在整本书中使用 Spring 和 Kubernetes 构建的云原生项目。我们将采用本书第一部分中介绍的云原生应用的属性和模式。

2.1 云原生开发原则:12 要素及其超越

在 Heroku 云平台工作的工程师们提出了 12-Factor 方法,作为设计和构建云原生应用的开发原则集合。¹ 他们将他们的经验提炼为构建具有以下特性的 Web 应用的最佳实践:

  • 适合部署在云平台上

  • 设计即具有可扩展性

  • 可跨系统移植

  • 持续部署和敏捷性的推动者

目标是帮助开发者构建云应用,强调实现最佳结果应考虑的重要因素。

之后,该方法由 Kevin Hoffman 在他的书《超越十二要素应用》中修订和扩展,刷新了原始要素的内容,并增加了三个额外的要素。² 从现在起,我将把这一扩展的原则集合称为“15 要素方法”。

这 15 个因素将贯穿整本书,因为它们是开发云原生应用的良好起点。如果你是从零开始构建新应用或迁移传统系统到云端,这些原则可以帮助你在旅途中。当相关时,我会进一步阐述它们,并展示如何将它们应用到 Spring 应用中。熟悉它们是至关重要的。

让我们深入探讨这些因素。

2.1.1 一个代码库,一个应用程序

15-Factor 方法在应用程序与其代码库之间建立一对一的映射,因此每个应用程序都有一个代码库。任何共享的代码都应该在其自己的代码库中跟踪,作为一个库可以包含为依赖项,或者作为一个可以独立运行的服务,作为其他应用程序的后备服务。每个代码库可以选择在其自己的存储库中进行跟踪。

部署是应用程序的运行实例。在不同的环境中,可以存在多个部署,它们共享相同的应用程序工件。不需要重新构建代码库来将应用程序部署到特定环境:任何在部署之间发生变化的方面(如配置)都应该位于应用程序代码库之外。

2.1.2 API 优先

云原生系统通常由不同的服务组成,这些服务通过 API 进行通信。在设计云原生应用程序时采用API 优先方法,鼓励您考虑将其适应到分布式系统中,并有利于将工作分配给不同的团队。通过首先设计 API,另一个使用该应用程序作为后备服务的团队可以针对该 API 创建他们的解决方案。通过提前设计合同,与其他系统的集成将更加健壮且易于作为部署管道的一部分进行测试。内部,API 实现可以更改,而不会影响依赖于它的其他应用程序(和团队)。

2.1.3 依赖项管理

所有应用程序依赖项都应在清单中明确声明,并可供依赖项管理器从中央存储库下载。在 Java 应用程序的上下文中,我们通常使用 Maven 或 Gradle 等工具很好地遵循这一原则。应用程序对周围环境的唯一隐式依赖是语言运行时和依赖项管理工具。这意味着私有依赖项应通过依赖项管理器解决。

2.1.4 设计、构建、发布、运行

代码库在其从设计到生产部署的旅程中会经历不同的阶段:

  • 设计阶段—确定特定应用程序功能所需的技术、依赖项和工具。

  • 构建阶段—代码库与其依赖项一起编译和打包,形成一个不可变的工件,称为构建。构建工件必须具有唯一标识。

  • 发布阶段—构建与特定部署的配置相结合。每个发布都是不可变的,并且应该具有唯一标识,例如使用语义版本(例如,3.9.4)或时间戳(例如,2022-07-07_17:21)。发布应存储在中央存储库中,以便于访问,例如在需要回滚到先前版本时。

  • 运行阶段—应用程序从特定发布在执行环境中运行。

15-Factor 方法要求这些阶段有严格的分离,并且不允许在运行时更改代码,因为这会导致与构建阶段的冲突。构建和发布工件应该是不可变的,并带有唯一的标识符,以确保可重复性。

2.1.5 配置、凭证和代码

15-Factor 方法将配置定义为部署之间可能发生变化的任何内容。每次你需要更改应用程序的配置时,你应该能够在不更改代码的情况下这样做,并且不需要再次构建应用程序。

配置可能包括对数据库或消息系统等后端服务的资源句柄、访问第三方 API 的凭证以及功能标志。问问自己,如果代码库突然公开,任何凭证或特定环境的信息是否会受到损害。这将告诉你是否正确地将配置外部化了。

为了符合这个因素,配置不能包含在代码中或与同一代码库跟踪。唯一的例外是默认配置,它可以与应用程序代码库一起打包。你仍然可以使用配置文件来存储任何其他类型的配置,但你应该将它们存储在单独的存储库中。

该方法建议将配置存储为环境变量。通过这样做,你可以将相同的应用程序部署在不同的环境中,但根据环境的配置具有不同的行为。

2.1.6 日志

云原生应用程序不关心日志的路由和存储。应用程序应将日志记录到标准输出,将日志视为按时间顺序排放的事件。日志存储和轮换不再是应用程序的责任。外部工具(日志聚合器)将检索、收集并使日志可供检查。

2.1.7 可丢弃性

在传统环境中,你会非常关心你的应用程序,确保它们保持运行状态,永远不会终止。在云环境中,你不需要那么关心:应用程序是短暂的。如果发生故障并且应用程序不再响应,你可以终止它并启动一个新的实例。如果你有一个高负载峰值,你可以启动更多应用程序实例以维持增加的工作负载。我们说,如果一个应用程序可以随时启动或停止,那么它是可丢弃的。

为了以这种方式处理应用程序实例,你应该设计它们,以便在需要新实例时快速启动,并在不再需要时优雅地关闭。快速的启动使系统的弹性得以实现,确保系统的健壮性和弹性。如果没有快速的启动,你将面临性能和可用性问题。

优雅关闭是指当应用程序收到终止信号时,停止接受新的请求,完成正在进行的请求,并最终退出。在 Web 进程的情况下,这很简单。在其他情况下,例如与工作进程一起,它们负责的工作必须返回到工作队列,然后它们才能退出。

2.1.8 后端服务

后端服务可以被定义为应用程序用来提供其功能的外部资源。后端服务的例子包括数据库、消息代理、缓存系统、SMTP 服务器、FTP 服务器或 RESTful Web 服务。将它们视为附加资源意味着你可以轻松地更改它们,而无需修改应用程序代码。

考虑你在软件开发生命周期中如何使用数据库。很可能根据阶段的不同:开发、测试或生产,你会使用不同的数据库。如果你将数据库视为一个附加资源,你可以根据环境使用不同的服务。附加是通过资源绑定完成的。例如,数据库的资源绑定可能包括一个 URL、用户名和密码。

2.1.9 环境一致性

环境一致性是指尽可能保持所有环境相似。实际上,有三个差距是这个因素试图解决的:

  • 时间差距——代码更改与其部署之间的时间可能相当长。该方法努力促进自动化和持续部署,以减少开发人员编写代码到其在生产中部署之间的时间。

  • 人员差距——开发者构建应用程序,而操作员在生产中管理它们的部署。这个差距可以通过拥抱 DevOps 文化、改善开发者和操作员之间的协作以及拥抱“你构建它,你运行它”的哲学来解决。

  • 工具差距——环境之间主要区别之一是处理后端服务的方式。例如,开发者可能在本地环境中使用 H2 数据库,但在生产中使用 PostgreSQL。一般来说,所有环境中都应该使用相同类型和版本的备份服务。

2.1.10 管理过程

一些管理任务通常需要支持应用程序。像数据库迁移、批量作业或维护作业这样的任务应该被视为一次性过程。就像应用程序进程一样,管理任务的代码应该在版本控制中进行跟踪,与它们支持的应用程序一起交付,并在与应用程序相同的环境中执行。

通常将管理任务作为一次运行后即丢弃的小型独立服务来构建,或者作为在特定事件发生时触发的无状态平台上的函数来配置,或者你可以将它们嵌入到应用程序本身中,通过调用特定的端点来激活它们。

2.1.11 端口绑定

遵循 15 个要素方法的应用程序应该是自包含的,并通过端口绑定导出其服务。在生产环境中,可能会有一些路由服务将来自公共端点的请求转换为内部端口绑定服务。

如果应用程序在执行环境中不依赖于外部服务器,则该应用程序是自包含的。一个 Java Web 应用程序可能会在 Tomcat、Jetty 或 Undertow 等服务器容器中运行。相比之下,云原生应用程序不需要环境中有 Tomcat 服务器可用;它会像处理其他依赖项一样自行管理。例如,Spring Boot 允许您使用嵌入式服务器:应用程序将包含服务器,而不是依赖于执行环境中可用的服务器。这种方法的后果之一是,应用程序和服务器之间始终存在一对一的映射,这与传统方法不同,在传统方法中,多个应用程序部署到同一服务器上。

应用程序提供的服务随后通过端口绑定导出。一个 Web 应用程序会将 HTTP 服务绑定到特定端口,并可能成为另一个应用程序的后端服务。这就是在云原生系统中通常发生的情况。

2.1.12 无状态进程

在上一章中,您了解到高可扩展性是我们迁移到云的一个原因。为了确保可扩展性,我们设计应用程序为无状态进程,并采用无共享架构:不同应用程序实例之间不应共享任何状态。问问自己,如果您的应用程序实例被销毁并重新创建,是否会有数据丢失。如果答案是肯定的,那么您的应用程序就不是无状态的。

无论怎样,我们总是需要保存一些状态,否则我们的应用程序在大多数情况下将变得无用。因此,我们设计应用程序为无状态,然后只在特定的有状态服务(如数据存储)中处理状态。换句话说,无状态应用程序将状态管理和存储委托给后端服务。

2.1.13 并发

仅创建无状态应用程序不足以确保可扩展性。如果您需要扩展,这意味着您需要服务更多的用户。因此,您的应用程序应该允许并发处理,以同时服务多个用户。

15 个要素方法将进程定义为第一类公民。这些进程应该是水平可扩展的,将工作负载分布在多台机器上的多个进程之间,而这种并发处理只有在应用程序是无状态的情况下才可能。在 JVM 应用程序中,我们通过多个线程处理并发,这些线程来自线程池。

进程可以根据其类型进行分类。例如,您可能有处理 HTTP 请求的 Web 进程和执行后台计划作业的工作进程。

2.1.14 遥测

可观测性是云原生应用的一个特性。在云中管理分布式系统是复杂的,而管理这种复杂性的唯一方法是通过确保每个系统组件提供正确数据来远程监控系统的行为。遥测数据的例子包括日志、指标、跟踪、健康状态和事件。霍夫曼使用一个非常吸引人的形象来强调遥测的重要性:将你的应用程序视为太空探测器。你需要什么样的遥测来远程监控和控制你的应用程序呢?

2.1.15 认证和授权

安全性是软件系统的一个基本特性,但它往往没有得到必要的关注。遵循 零信任 方法,我们必须在任何架构和基础设施级别确保系统内任何交互的安全性。安全性远不止认证和授权,但这些是一个良好的起点。

通过认证,我们可以跟踪谁在使用应用程序。了解这一点后,我们可以检查用户权限,以验证用户是否被允许执行特定操作。有几个标准可用于实现身份和访问管理,包括 OAuth 2.1 和 OpenID Connect,我们将在本书中使用它们。

2.2 使用 Spring 构建云原生应用程序

现在是时候更加具体一些,开始讨论技术了。到目前为止,你已经了解了云原生方法以及我们将遵循的主要开发实践。现在让我们来看看 Spring。如果你正在阅读这本书,你可能已经有一些 Spring 的先前经验,并且你想要学习如何使用它来构建云原生应用程序。

Spring 生态系统提供了处理你应用程序可能需要的几乎所有功能,包括云原生应用程序的功能。Spring 是迄今为止最常用的 Java 框架。它已经存在很多年了,它强大且可靠。Spring 背后的社区非常出色,愿意推动它向前发展并使其持续改进。技术和开发实践不断演变,Spring 在跟上它们方面做得非常好。因此,使用 Spring 来进行你的下一个云原生项目是一个非常好的选择。

本节将突出展示 Spring 生态系统的一些有趣特性。然后我们将开始创建一个 Spring Boot 应用程序。

2.2.1 Spring 生态系统概述

Spring 包含了多个项目,涵盖了软件开发的不同方面:Web 应用程序、安全、数据访问、集成、批处理、配置、消息传递、大数据等等。Spring 平台的魅力在于它被设计成模块化的,因此你可以使用和组合你需要的项目。无论你需要构建哪种类型的应用程序,Spring 都有可能帮助你。

Spring 框架是 Spring 平台的核心,是所有一切开始的项目。它支持依赖注入、事务管理、数据访问、消息传递、Web 应用程序等。框架建立了企业应用的“管道”,这样你就可以专注于业务逻辑。

Spring 框架提供了一个执行上下文(称为Spring 上下文容器),在应用程序的生命周期中管理 bean、属性和资源。我将假设你已经熟悉框架的核心功能,因此我不会在这方面花费太多时间。特别是,你应该了解 Spring 上下文的作用,并且能够舒适地与 Spring bean、基于注解的配置和依赖注入一起工作。我们将依赖这些功能,所以你应该已经弄清楚它们。

基于Spring Boot框架,可以快速构建独立的生产级应用程序。Spring Boot 对 Spring 和第三方库持有一种有见地的观点,并附带合理的默认配置,这使得开发者可以以最小的前期工作开始,同时仍然提供完整的自定义可能性。

在本书中,你将有机会使用几个 Spring 项目来实施云原生应用程序的模式和最佳实践,包括 Spring Boot、Spring Cloud、Spring Data、Spring Security、Spring Session 和 Spring Native。

注意:如果你对学习更多关于 Spring 核心功能感兴趣,你可以在 Manning 目录中找到一些关于这个主题的书籍,包括 Laurențiu Spilcă的Spring Start Here(Manning,2021)和 Craig Walls 的Spring in Action第六版(Manning,2022)。你也可以参考 Mark Heckler 的Spring Boot: Up & Running(O’Reilly,2021)。

2.2.2 构建 Spring Boot 应用程序

假设你被雇佣来为 Polarsophia 构建一个极地书店应用程序。这个组织管理一家专门的书店,并希望在网上销售关于北极和北冰洋的书籍。正在考虑采用云原生方法。

作为试点项目,你的老板分配给你一个任务,向你的同事展示如何在云中从实现到生产的整个过程。你被要求构建的 Web 应用程序是目录服务,目前它只有一个职责:欢迎用户进入图书目录。如果这个试点项目成功并且受到好评,它将成为作为云原生应用程序构建的实际产品的基石。

考虑到任务的目标,您可能会决定将应用程序实现为一个 RESTful 服务,该服务具有单个 HTTP 端点,负责返回欢迎信息。令人惊讶的是,您选择采用 Spring 作为应用程序(由一个服务组成,即目录服务)的主要技术栈。系统的架构如图 2.2 所示,您将在接下来的章节中尝试构建和部署应用程序。

02-02

图 2.2 Polar Bookshop 应用程序的架构图,遵循 C4 模型

在图 2.2 中,您可以看到我将使用以下符号来表示本书中使用的架构图,遵循由 Simon Brown 创建的 C4 模型 (c4model.com)。为了描述 Polar Bookshop 项目的架构,我依赖于模型中的三个抽象:

  • 人员——这代表软件系统的人类用户之一。在我们的例子中,它是书店的客户。

  • 系统——这代表您将构建的整个应用程序,以向其用户提供价值。在我们的例子中,它是 Polar Bookshop 系统。

  • 容器——这代表一个服务,无论是应用程序还是数据。它不要与 Docker 混淆。在我们的例子中,它是目录服务。

对于这个任务,我们将使用 Spring 框架和 Spring Boot 来完成以下操作:

  • 声明实现应用程序所需的依赖项。

  • 使用 Spring Boot 引导应用程序。

  • 实现一个控制器以公开一个 HTTP 端点,用于返回欢迎信息。

  • 运行并尝试应用程序。

本书中的所有示例都基于 Java 17,这是撰写时的最新长期发布版 Java。在继续之前,请按照附录 A 的 A.1 节中的说明安装 OpenJDK 17 分发版。然后确保您有一个支持 Java、Gradle 和 Spring 的 IDE。我将使用 IntelliJ IDEA,但您也可以选择其他 IDE,例如 Visual Studio Code。最后,如果您还没有,请创建一个免费的 GitHub 账户 (github.com)。您将使用它来存储您的代码并定义持续交付管道。

初始化项目

在整本书中,我们将构建几个云原生应用程序。我建议您为每个应用程序定义一个 Git 仓库,并使用 GitHub 来存储它们。在下一章中,我将更多地讨论代码库的管理。现在,请继续创建一个 catalog-service Git 仓库。

接下来,您可以从 Spring Initializr (start.spring.io) 生成项目,并将其存储在您刚刚创建的 catalog-service Git 仓库中。Spring Initializr 是一个方便的服务,您可以通过浏览器或其 REST API 使用它来生成基于 JVM 的项目。它甚至集成到流行的 IDE 中,如 IntelliJ IDEA 和 Visual Studio Code。目录服务的初始化参数如图 2.3 所示。

02-03

图 2.3 从 Spring Initializr 初始化 Catalog Service 项目的参数

在初始化过程中,你可以提供一些关于你想要构建的应用程序的相关细节,如表 2.1 所示。

表 2.1 从 Spring Initializr 生成项目时可以配置的主要参数

参数 描述 Catalog Service 的值
项目 你可以决定是否想使用 Gradle 或 Maven 作为项目的构建工具。本书中的所有示例都将使用 Gradle。 Gradle
语言 Spring 支持三种主要的 JVM 语言:Java、Kotlin 和 Groovy。本书中的所有示例都将使用 Java。 Java
Spring Boot 你可以选择想要使用的 Spring Boot 版本。本书中的所有示例都将使用 Spring Boot 2.7.3,但任何后续补丁版本都应没问题。 Spring Boot 2.7.3
项目的组 ID,如 Maven 仓库中使用的。 com.polarbookshop
工件 项目的工件 ID,如 Maven 仓库中使用的。 catalog-service
名称 项目名称。 catalog-service
包名 项目的基 Java 包。 com.polarbookshop.catalogservice
打包 如何打包项目:WAR(用于在应用服务器上部署)或 JAR(用于独立应用程序)。云原生应用程序应打包为 JAR,因此本书中的所有示例都将使用该选项。 JAR
Java 你想用于构建项目的 Java 版本。本书中的所有示例都将使用 Java 17。 17
依赖项 要包含在项目中的依赖项。 Spring Web

新生成的项目结构如图 2.4 所示。在接下来的章节中,我将引导你了解它。

02-04

图 2.4 从 Spring Initializr 生成的 Spring Boot 项目结构

在本书配套的代码仓库(github.com/ThomasVitale/cloud-native-spring-in-action)中,你可以找到每个章节的“begin”和“end”文件夹,这样你就可以始终以与我相同的设置开始,并检查最终结果。例如,你目前正在阅读第二章,因此你将在 Chapter02/02-begin 和 Chapter02/02-end 中找到相关的代码。

提示:在本章的“begin”文件夹中,你可以找到一个 curl 命令,你可以在终端窗口中运行它来下载一个包含所有启动所需代码的 zip 文件,无需通过 Spring Initializr 网站上的手动项目生成。

Gradle 还是 Maven?

我在这本书中使用 Gradle,但你可以自由选择使用 Maven。在本书附带的代码仓库中,你可以找到一个将 Gradle 命令映射到 Maven 的表格,这样你就可以轻松地跟随,如果你选择第二个选项的话(github.com/ThomasVitale/cloud-native-spring-in-action)。每个项目都有不同的需求,这可能导致你选择一个构建工具而不是另一个。

我选择使用 Gradle 是基于个人偏好,并且有两个主要原因。使用 Gradle 构建和测试 Java 项目比 Maven 更快,这得益于其增量构建、并行构建和缓存系统。此外,我发现 Gradle 构建语言(Gradle DSL)比 Maven XML 更易读、更易于表达和维护。在 Spring 生态系统中,你可以找到使用 Gradle 的项目和使用 Maven 的项目。它们都是不错的选择。我建议你尝试两者,并选择让你更高效的工具。

探索构建配置

打开你在最喜欢的 IDE 中刚刚初始化的项目,查看目录服务应用的 Gradle 构建配置,该配置在 build.gradle 文件中定义。你可以在那里找到你提供给 Spring Initializr 的所有信息。

列表 2.1 目录服务的构建配置

plugins {
  id 'org.springframework.boot' version '2.7.3'    ❶
  id 'io.spring.dependency-management'
➥ version '1.0.13.RELEASE'                        ❷
  id 'java'                                        ❸
}

group = 'com.polarbookshop'                        ❹
version = '0.0.1-SNAPSHOT'                         ❺
sourceCompatibility = '17'                         ❻

repositories {                                     ❼
  mavenCentral()
}

dependencies {                                     ❽
  implementation 'org.springframework.boot:spring-boot-starter-web'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
  useJUnitPlatform()                               ❾
}

❶ 在 Gradle 中提供 Spring Boot 支持并声明要使用的版本

❷ 为 Spring 提供依赖管理功能

❸ 在 Gradle 中提供 Java 支持,设置任务以编译、构建和测试应用

❹ 目录服务项目的组 ID

❺ 应用的版本。默认情况下,它是 0.0.1-SNAPSHOT。

❻ 构建项目使用的 Java 版本

❼ 搜索依赖项的工件仓库

❽ 应用使用的依赖项

❾ 启用 JUnit 5 提供的 JUnit 平台进行测试

项目包含以下主要依赖项:

  • Spring Web(org.springframework.boot:spring-boot-starter-web)提供了构建 Spring MVC Web 应用所需的库,并包括 Tomcat 作为默认的嵌入式服务器。

  • Spring Boot Test(org.springframework.boot:spring-boot-starter-test)提供了用于测试应用的多个库和实用工具,包括 Spring Test、JUnit、AssertJ 和 Mockito。它自动包含在每一个 Spring Boot 项目中。

注意 Spring Boot 提供了方便的入门级依赖项,这些依赖项将所有必要的库捆绑在一起,用于特定的用例,并确保选择兼容的版本。这个特性显著简化了你的构建配置。

项目的名称定义在第二个名为 settings.gradle 的文件中:

rootProject.name = 'catalog-service'

启动应用

在前面的章节中,您初始化了 Catalog Service 项目并选择了 JAR 打包选项。任何打包为 JAR 的 Java 应用程序都必须有一个公共静态 void main(String[] args) 方法,该方法在启动时执行,Spring Boot 也不例外。在 Catalog Service 中,在初始化过程中自动生成了一个 CatalogServiceApplication 类;这就是 main() 方法定义的地方,也是 Spring Boot 应用程序运行的方式。

列表 2.2 Catalog Service 的引导类

package com.polarbookshop.catalogservice;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication                           ❶
public class CatalogServiceApplication {
  public static void main(String[] args) {       ❷
   SpringApplication.run(CatalogServiceApplication.class, args);
  }
}

❶ 定义一个 Spring 配置类并触发组件扫描和 Spring Boot 自动配置

❷ 用于启动应用程序的方法。它在应用程序的引导阶段注册要运行的当前类。

@SpringBootApplication 注解是一个包含三个不同注解的快捷方式:

  • @Configuration 标记该类为 beans 定义源。

  • @ComponentScan 启用组件扫描以自动在 Spring 上下文中查找和注册 beans。

  • @EnableAutoConfiguration 启用 Spring Boot 提供的自动配置功能。

Spring Boot 自动配置由多个条件触发,例如类路径中存在某些类、存在特定的 beans 或某些属性的值。由于 Catalog Service 项目依赖于 spring-boot-starter-web,Spring Boot 将初始化一个嵌入的 Tomcat 服务器实例,并应用几乎零时间即可启动和运行 Web 应用程序所需的最小配置。

应用程序设置到此结束。让我们继续从 Catalog Service 暴露一个 HTTP 端点。

实现控制器

到目前为止,我们已经查看由 Spring Initializr 生成的项目。现在是时候实现应用程序的业务逻辑了。

Catalog Service 将暴露一个 HTTP GET 端点,向用户返回友好的问候语,欢迎他们来到图书目录。您可以在控制器类中定义一个处理程序。图 2.5 显示了交互流程。

02-05

图 2.5 用户与应用程序之间的交互,以从 Catalog Service 暴露的 HTTP 端点获取欢迎信息

在 Catalog Service 项目中,创建一个新的 HomeController 类,并实现一个负责处理根端点 (/) 的 GET 请求的方法。

列表 2.3 定义 HTTP 端点以返回欢迎信息

package com.polarbookshop.catalogservice;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController                    ❶
public class HomeController {

  @GetMapping("/")                 ❷
  public String getGreeting() {
    return "Welcome to the book catalog!";
  }
}

❶ 识别一个定义 REST/HTTP 端点处理程序的类

❷ 处理根端点的 GET 请求

@RestController 注解标识了一个类,该类处理传入的 HTTP 请求。使用 @GetMapping 注解,您可以标记 getGreeting() 方法为处理到达根端点 (/) 的 GET 请求的处理程序。任何对该端点的 GET 请求都将由该方法处理。在下一章中,我将更详细地介绍如何使用 Spring 构建 RESTful 服务。

测试应用程序

当你从 Spring Initializr 创建 Spring 项目时,会包含一个基本的测试设置。在 build.gradle 文件中,你自动获得测试 Spring 应用程序所需的依赖项。此外,还会自动生成一个测试类。让我们看看初始化项目后,CatalogServiceApplicationTests 类可能的样子。

列表 2.4 自动生成的测试类,用于验证 Spring 上下文

package com.polarbookshop.catalogservice;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest                          ❶
class CatalogServiceApplicationTests {

  @Test                                  ❷
  void contextLoads() {                  ❸
  }
}

❶ 为测试 Spring Boot 应用程序提供设置

❷ 识别测试用例

❸ 空测试用例,用于验证应用程序上下文是否正确加载

默认测试类由@SpringBootTest 注解标识,它为测试 Spring Boot 应用程序提供了许多有用的功能。我将在本书中更详细地介绍它们。现在,只需知道它为要运行的测试加载了完整的 Spring 应用程序上下文即可。目前只有一个测试用例,它是空的:它用于验证 Spring 上下文是否正确加载。

打开一个终端窗口,导航到应用程序根目录(catalog-service),并运行测试 Gradle 任务以执行应用程序的测试。

$ ./gradlew test

任务应该成功,测试结果为绿色,这意味着 Spring 应用程序可以无错误地启动。那么 HTTP 端点呢?让我们来看看。

运行应用程序

你已完成应用程序的实现,因此可以继续运行它。有几种不同的方法可以做到这一点,我将在稍后向你展示其中一些。现在,你可以使用 Spring Boot Gradle 插件提供的任务:bootRun。

从启动测试的同一终端窗口中,运行以下命令:

$ ./gradlew bootRun

应用程序应在瞬间启动并准备好接受请求。在图 2.6 中,你可以看到启动阶段流出的日志。

02-06

图 2.6 目录服务应用程序的启动日志

从图 2.6 中的日志中,你会注意到启动阶段由两个主要步骤组成:

  • 内嵌 Tomcat 服务器的初始化和运行(默认情况下,通过 HTTP 监听 8080 端口)

  • Spring 应用程序上下文的初始化和运行

到目前为止,你终于可以验证你的 HTTP 端点是否按预期工作。打开一个浏览器窗口,导航到 http://localhost:8080/,准备被 Polar Bookshop 的图书目录欢迎。

Welcome to the book catalog!

Polar Bookshop 应用程序的开发部分已完成:你有一个目录服务应用程序欢迎用户进入图书目录。记住在继续之前,终止 bootRun 进程(Ctrl-C)以停止应用程序执行。

下一步是将应用程序部署到云中。为了使其在任何云基础设施上都具有可移植性,你应该首先将其容器化。进入 Docker。

2.3 使用 Docker 容器化应用程序

目录服务应用程序正在运行。然而,在将其部署到云之前,你应该将其容器化。为什么?容器提供了与周围环境的隔离,并且它们配备了应用程序运行所需的所有依赖项。

在我们的案例中,大多数依赖项由 Gradle 管理,并与应用程序(JAR 工件)一起打包。但是 Java 运行时不包括在内。没有容器,你必须在任何你想部署应用程序的机器上安装 Java 运行时。将应用程序容器化意味着它将是自包含的,并且可以在任何云环境中移植。使用容器,你可以以标准方式管理所有应用程序,无论它们是用什么语言或框架实现的。

开放容器倡议(OCI),一个 Linux 基金会项目,为与容器一起工作定义了行业标准(opencontainers.org)。特别是,OCI 镜像规范定义了如何构建容器镜像,OCI 运行时规范定义了如何运行这些容器镜像,而 OCI 分发规范定义了如何分发它们。我们将用于与容器一起工作的工具是 Docker(www.docker.com),它符合 OCI 规范。

Docker 是一个开源平台,它“提供了在称为容器的松散隔离环境中打包和运行应用程序的能力”(docs.docker.com)。Docker也是这个技术背后的公司的名字,该公司是 OCI 的创始成员。这个术语也用于他们的一些商业产品中。除非另有说明,每次我写Docker时,我指的是我们将用于构建和运行容器的开源平台。

在继续之前,请按照附录 A 中的 A.2 节中的说明在你的开发环境中安装和配置 Docker。

2.3.1 介绍 Docker:镜像和容器

当你在你的机器上安装 Docker 平台时,你会得到一个具有客户端/服务器架构的 Docker 引擎包。Docker 服务器包含Docker 守护进程,这是一个负责创建和管理 Docker 对象(如镜像、容器、卷和网络)的后台进程。运行 Docker 服务器的机器被称为Docker 主机。你想要运行容器的每台机器都应该是一个 Docker 主机,因此它应该有一个正在运行的 Docker 守护进程。容器的可移植性是由守护进程本身实现的。

Docker 守护进程提供了一个 API,你可以使用它来发送指令,例如运行一个容器或创建一个卷。Docker 客户端通过该 API 与守护进程通信。客户端是基于命令行的,可以用来通过脚本(例如 Docker Compose)或直接通过 Docker CLI 与 Docker 守护进程交互。

除了表征 Docker 引擎的客户端和服务器组件之外,平台的一个基本元素是 容器注册库,它具有类似于 Maven 仓库的功能。虽然 Maven 仓库用于托管和分发 Java 库,但容器注册库为容器镜像执行相同的操作,并遵循 OCI 分发规范。我们区分公共和私有注册库。Docker 公司提供了一个名为 Docker Hub 的公共注册库(hub.docker.com),默认情况下与您的本地 Docker 安装配置,并托管许多流行的开源项目的镜像,如 Ubuntu、PostgreSQL 和 OpenJDK。

根据 Docker 文档(docs.docker.com)中包含的架构描述,图 2.7 显示了 Docker 客户端、Docker 服务器和容器注册库之间的交互。

02-07

图 2.7 Docker 引擎具有客户端/服务器架构,并与注册库交互。

Docker 守护进程管理不同的对象。目前我们将重点关注镜像和容器。

容器镜像(或简单地称为 镜像)是一个轻量级的可执行包,它包含运行应用程序所需的一切。Docker 镜像格式是创建容器镜像最常用的格式,它已被 OCI 项目(在 OCI 镜像规范中)标准化。可以从头开始创建 OCI 镜像,通过在 Dockerfile 中定义指令来实现,这是一个包含生成镜像所需所有步骤的基于文本的文件。通常,镜像基于另一个镜像创建。例如,您可能基于 OpenJDK 构建一个镜像,在其上您可以添加 Java 应用程序。创建后,镜像可以推送到容器注册库,如 Docker Hub。每个镜像都有一个基本名称和标签来标识,其中标签通常是版本号。例如,版本 22.04 的 Ubuntu 镜像称为 ubuntu:22.04。冒号分隔基本名称和版本。

容器 是容器镜像的可运行实例。您可以通过 Docker CLI 或 Docker Compose 管理容器的生命周期:您可以启动、停止、更新和删除容器。容器由其基于的镜像和启动时提供的配置(例如,用于自定义容器的环境变量)定义。默认情况下,容器彼此之间以及与主机机器是隔离的,但您可以通过称为 端口转发端口映射 的过程使它们通过特定端口向外部世界公开服务。容器可以有任意名称。如果您没有指定,Docker 服务器将分配一个随机的名称,例如 bazinga_schrodinger。要作为容器运行 OCI 镜像,您需要 Docker 或任何与 OCI 规范兼容的其他容器运行时。

当您想要运行一个新的容器时,您可以使用 Docker CLI 与 Docker 守护进程交互,该守护进程会检查指定的镜像是否已经存在于本地服务器上。如果没有,它将在注册表中找到该镜像,下载它,然后使用它来运行一个容器。该工作流程再次在图 2.7 中显示。

macOS 和 Windows 上的 Docker。它是如何工作的?

在上一章中,您了解到容器共享相同的操作系统内核,并依赖于 Linux 功能,如命名空间和 cgroups。我们将使用 Docker 在 Linux 容器中运行 Spring Boot 应用程序,但 Docker 如何在 macOS 或 Windows 机器上运行呢?

当您在 Linux 操作系统上安装 Docker 时,您将在 Linux 主机上获得完整的 Docker Engine。然而,如果您安装了MacWindows上的Docker Desktop,则只有 Docker 客户端安装在了您的 macOS/Windows 主机上。在底层,配置了一个带有 Linux 的轻量级虚拟机,并且 Docker 服务器组件安装在该机器上。作为用户,您将获得几乎与 Linux 机器上相同的体验;您几乎不会注意到任何差异。但事实上,每当您使用 Docker CLI 执行操作时,您实际上是在与另一台机器上的 Docker 服务器(运行 Linux 的虚拟机)交互。

您可以通过启动 Docker 并运行 docker version 命令来验证这一点。您会注意到 Docker 客户端正在 darwin/amd64 架构上运行(在 macOS 上)或 windows/amd64(在 Windows 上),而 Docker 服务器正在 linux/amd64 上运行。

$ docker version
Client:
 Cloud integration: v1.0.24
 Version:           20.10.14
 API version:       1.41
 Go version:        go1.16.15
 Git commit:        a224086
 Built:             Thu Mar 24 01:49:20 2022
 OS/Arch:           darwin/amd64 
 Context:           default
 Experimental:      true

Server:
 Engine:
  Version:          20.10.14
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.16.15
  Git commit:       87a90dc
  Built:            Thu Mar 24 01:45:44 2022
  OS/Arch:          linux/amd64 
  Experimental:     false

Docker 支持除了 AMD64 以外的架构。例如,如果您使用搭载苹果硅(ARM 架构芯片)的 MacBook,您会发现您的 Docker 客户端运行在 darwin/arm64 架构上,而 Docker 服务器运行在 linux/arm64 上。

2.3.2 将 Spring 应用程序作为容器运行

让我们回到目录服务,看看您如何将其作为容器运行。有几种不同的方法可以实现这一点,但在这里您将使用 Spring Boot 与 Cloud Native Buildpacks 的即插即用集成(buildpacks.io),这是一个由 Heroku 和 Pivotal 发起并由 CNCF 托管的项目。它为自动将应用程序源代码转换为容器镜像提供了一个高级抽象,而不是使用低级的 Dockerfile。

Paketo Buildpacks(Cloud Native Buildpacks 规范的实现)与 Spring Boot 插件完全集成,适用于 Gradle 和 Maven。这意味着您可以在不下载任何额外工具、不提供任何额外依赖项或编写 Dockerfile 的情况下容器化您的 Spring Boot 应用程序。

第六章将描述 Cloud Native Buildpacks 项目的工作原理以及如何配置它以容器化您的 Spring Boot 应用程序。现在,我将为您展示其功能的一些预览。

打开一个终端窗口,导航到你的 Catalog Service 项目(catalog-service)的根目录,并运行 bootBuildImage Gradle 任务。这就是你需要做的,以使用底层的 Cloud Native Buildpacks 将你的应用程序打包成容器镜像。

$ ./gradlew bootBuildImage

警告:在我撰写本文时,Paketo 项目正在努力添加对 ARM64 镜像的支持。你可以在 GitHub 上的 Paketo Buildpacks 项目中跟踪该功能的进展:github.com/paketo-buildpacks/stacks/issues/51。在完成之前,你仍然可以使用 Buildpacks 来构建容器并通过 Docker Desktop 在 Apple Silicon 计算机上运行它们,但构建过程和应用程序启动阶段将比通常慢。在官方支持添加之前,你可以使用以下命令,它指向一个带有 ARM64 支持的实验性 Paketo Buildpacks 版本:./gradlew bootBuildImage --builder ghcr.io/thomasvitale/java -builder-arm64。请注意,这是实验性的,并不适合生产环境。更多信息,请参考 GitHub 上的文档:github.com/ThomasVitale/paketo-arm64

第一次运行任务时,将花费一分钟下载用于创建容器镜像的 Buildpacks 所使用的包。第二次运行时,只需几秒钟。默认情况下,生成的镜像将被命名为 catalog-service:0.0.1-SNAPSHOT(<project_name>:)。你可以运行以下命令来获取新创建镜像的详细信息:

$ docker images catalog-service:0.0.1-SNAPSHOT
REPOSITORY        TAG              IMAGE ID       CREATED        SIZE
catalog-service   0.0.1-SNAPSHOT   f0247a113eff   42 years ago   275MB

注意:你可能在之前的命令输出中注意到,镜像看起来像是 42 年前创建的。这是 Cloud Native Buildpacks 用于实现可重复构建的惯例。如果输入没有变化,后续的构建命令应该会给出相同的输出。使用准确的创建时间戳将使这变得不可能,因此 Cloud Native Buildpacks 使用传统的日期时间戳(1980 年 1 月 1 日)。

最后一步是运行镜像并验证容器化应用程序是否正常工作。打开一个终端窗口并运行以下命令:

$ docker run --rm --name catalog-service -p 8080:8080 \
    catalog-service:0.0.1-SNAPSHOT

警告:如果你在 Apple Silicon 计算机上运行容器,之前的命令可能会返回类似“WARNING: 请求的镜像的平台(linux/amd64)与检测到的宿主平台(linux/arm64/v8)不匹配,且未请求特定平台。”的消息。在这种情况下,你需要向之前的命令(在镜像名称之前)添加一个额外的参数,直到 Paketo Buildpacks 添加对 ARM64 的支持:--platform linux/amd64。

你可以参考图 2.8 来了解该命令的描述。

02-08

图 2.8 从镜像启动容器化应用程序的 Docker 命令

打开一个浏览器窗口,导航到 http://localhost:8080/,并验证你是否仍然收到之前得到的问候。

Welcome to the book catalog!

完成后,使用 Ctrl-C 停止容器。

在第六章中,您将了解 Docker 的工作原理,如何从 Spring Boot 应用程序构建容器镜像,以及如何使用容器注册库。我还会向您展示如何使用 Docker Compose 来管理容器,而不是使用 Docker CLI。

2.4 使用 Kubernetes 管理容器

到目前为止,您已经使用 Spring Boot(目录服务)构建了一个 Web 应用程序,使用 Cloud Native Buildpacks 对其进行容器化,并使用 Docker 运行它。为了完成 Polar Bookshop 的试点项目,您必须执行最后一步:将应用程序部署到云环境。为此,您将使用已成为容器编排事实标准的 Kubernetes。我将在后面的章节中提供更多关于 Kubernetes 的详细信息,但我想让您先尝尝它的运作方式以及您如何使用它来部署 Web 应用程序。

Kubernetes(通常简称为K8s)是一个开源系统,用于自动化容器化应用的部署、扩展和管理(kubernetes.io)。当您在 Docker 中使用容器时,您的部署目标是机器。在前一节的例子中,它是您的电脑。在其他情况下,它可能是虚拟机(VM)。无论如何,这都是将容器部署到特定机器的过程。然而,当涉及到无停机时间部署容器、利用云的弹性进行扩展或在不同主机之间连接容器时,您需要比容器引擎更多的东西。您不是部署到特定的机器,而是部署到机器集群,而 Kubernetes(以及其他一些东西)为您管理机器集群。我在上一章的拓扑结构上下文中讨论了这种区别。图 2.9 将提醒您容器拓扑结构和编排拓扑结构中的不同部署目标。

02-09

图 2.9 容器部署的目标是机器,而对于编排器来说,则是集群。

在继续之前,请按照附录 A 中的 A.3 节说明安装 minikube 并在您的本地开发环境中设置 Kubernetes 集群。完成安装过程后,您可以使用以下命令启动本地 Kubernetes 集群:

$ minikube start

2.4.1 Kubernetes 简介:部署、Pod 和 Service

Kubernetes 是由 CNCF 托管的开放源代码容器编排器。仅在几年内,它已成为最常用的容器编排解决方案,所有主要的云提供商都提供了 Kubernetes 作为服务的解决方案。Kubernetes 可以在桌面、本地数据中心、云中,甚至物联网设备上运行。

当使用容器拓扑时,你需要一台具有容器运行时的机器。然而,使用 Kubernetes 时,你切换到编排拓扑,这意味着你需要一个集群。Kubernetes 集群是一组运行容器化应用程序的工作机器(节点)。每个集群至少有一个工作节点。使用 minikube,你可以在本地机器上轻松创建一个单节点集群。在生产环境中,你将使用由云提供商管理的集群。

Kubernetes 集群由称为工作节点的机器组成,你的容器化应用程序在这些机器上部署。它们提供 CPU、内存、网络和存储等容量,以便容器可以运行并连接到网络。

控制平面是管理工作节点的容器编排层。它公开 API 和接口来定义、部署和管理容器的生命周期。它提供了实现编排器典型功能的所有基本元素,如集群管理、调度和健康监控。

注意:在容器编排的背景下,调度意味着将容器实例与将要运行的节点进行匹配。这种匹配基于一系列标准,包括节点上是否有足够的计算资源来运行容器。

你可以通过 CLI 客户端 kubectl 与 Kubernetes 交互,kubectl 与控制平面通信以在工作节点上执行某些操作。客户端不直接与工作节点交互。图 2.10 显示了 Kubernetes 架构的高级组件。

02-10

图 2.10 Kubernetes 的主要组件是 API、控制平面和工作节点。

Kubernetes 可以管理许多不同的对象,无论是内置的还是定制的。在本节中,你将使用 Pod、Deployment 和 Service。

  • Pod——最小的可部署单元,可以包含一个或多个容器。Pod 通常只包含你的一个应用程序。它也可能包含支持主要应用程序的额外容器(例如,在初始化步骤中运行提供额外功能如日志记录或管理任务的容器)。Kubernetes 直接管理 Pod 而不是容器。

  • Deployment——Deployment 向 Kubernetes 通知你应用程序期望的部署状态。对于每个实例,它创建一个 Pod 并保持其健康。除了其他功能外,Deployment 允许你将 Pod 作为一个集合来管理。

  • Service——可以通过定义一个 Service 来将 Deployment(一组 Pod)暴露给集群中的其他节点或外部,该 Service 还负责在 Pod 实例之间平衡负载。

注意:在整个书中,我将使用大写字母来书写 Kubernetes 资源,以区分它们与在不同含义下使用时的相同术语。例如,当提到应用程序时,我会使用service,而当我指的是 Kubernetes 对象时,我会写Service

当你想运行一个新的应用程序时,你可以定义一个资源清单,这是一个描述应用程序所需状态的文件。例如,你可能指定它应该被复制五次,并通过端口 8080 公开给外部世界。资源清单通常使用 YAML 编写。然后,你可以使用 kubectl 客户端请求控制平面创建清单中描述的资源。最后,控制平面使用其内部组件处理请求,并在工作节点上创建资源。控制平面仍然依赖于容器注册库来检索资源清单中定义的镜像。工作流程,再次,如图 2.10 所示。

2.4.2 在 Kubernetes 上运行 Spring 应用程序

让我们回到 Polar Bookshop 项目。在上一个章节中,你将 Catalog Service 应用程序容器化了。现在,是时候使用 Kubernetes 将其部署到集群中。你已经在本地环境中有一个运行中的集群。你需要的是一个资源清单。

与 Kubernetes 交互的标准方式是通过你在 YAML 或 JSON 文件中定义的声明性指令。我将在第七章中向你展示如何编写资源清单。在此之前,你可以像之前使用 Docker 一样使用 Kubernetes CLI。

首先,你需要告诉 Kubernetes 从容器镜像部署 Catalog Service。你之前已经构建了一个(catalog-service:0.0.1-SNAPSHOT)。默认情况下,minikube 使用 Docker Hub 注册库来拉取镜像,并且它无法访问你本地的镜像。因此,它将找不到你为 Catalog Service 应用程序构建的镜像。但别担心:你可以手动将其导入到你的本地集群中。

打开一个终端窗口,并运行以下命令:

$ minikube image load catalog-service:0.0.1-SNAPSHOT

部署单元将是一个 Pod,但你不会直接管理 Pod。相反,你希望让 Kubernetes 来处理。Pod 是应用程序实例,因此它们是短暂的。为了实现云原生目标,你希望平台负责实例化 Pod,以便如果一个 Pod 失败,它可以被另一个 Pod 替换。你需要的是一个Deployment资源,这样 Kubernetes 就能创建应用程序实例作为Pod资源。

从终端窗口运行以下命令:

$ kubectl create deployment catalog-service \
    --image=catalog-service:0.0.1-SNAPSHOT

你可以参考图 2.11 来了解命令的描述。

02-11

图 2.11:从容器镜像创建 Deployment 的 Kubernetes 命令。Kubernetes 将负责为应用程序创建 Pod。

你可以按照以下方式验证 Deployment 对象的创建:

$ kubectl get deployment
NAME              READY   UP-TO-DATE   AVAILABLE   AGE
catalog-service   1/1     1            1           7s

在幕后,Kubernetes 为在 Deployment 资源中定义的应用程序创建了一个 Pod。你可以按照以下方式验证 Pod 对象的创建:

$ kubectl get pod
NAME                               READY   STATUS    RESTARTS   AGE
catalog-service-5b9c996675-nzbhd   1/1     Running   0          21s

提示:你可以通过运行kubectl logs deployment/catalog-service来检查应用程序日志。

默认情况下,在 Kubernetes 中运行的应用程序是不可访问的。让我们解决这个问题。首先,你可以通过运行以下命令通过 Service 资源将目录服务暴露给集群:

$ kubectl expose deployment catalog-service \
    --name=catalog-service \
    --port=8080

图 2.12 提供了该命令的描述。

02-12

图 2.12 将 Deployment 作为 Service 暴露的 Kubernetes 命令。目录服务应用程序将通过端口 8080 暴露给集群网络。

Service 对象将应用程序暴露给集群内的其他组件。你可以使用以下命令验证它是否已正确创建:

$ kubectl get service catalog-service
NAME              TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
catalog-service   ClusterIP   10.96.141.159   <none>        8080/TCP   7s

然后,你可以将来自你电脑上本地端口的流量(例如,8000)转发到集群内 Service 暴露的端口(8080)。还记得 Docker 中的端口映射吗?这与此类似。命令的输出将告诉你端口转发是否配置正确:

$ kubectl port-forward service/catalog-service 8000:8080
Forwarding from 127.0.0.1:8000 -> 8080
Forwarding from [::1]:8000 -> 8080

你可以参考图 2.13 来了解该命令的描述。

02-13

图 2.13 将本地主机的端口转发到集群内 Service 的 Kubernetes 命令。目录服务应用程序将通过端口 8000 暴露给本地主机。

现在,无论何时你访问本地主机的端口 8000,你都会被转发到负责暴露目录服务应用程序的 Kubernetes 集群内的 Service。打开一个浏览器窗口,导航到 http://localhost:8000/(确保使用 8000 而不是 8080),并验证你是否仍然得到之前相同的问候。

Welcome to the book catalog!

干得好!你从一个使用 Spring Boot 实现的 Java 应用程序开始。然后你使用 Cloud Native Buildpacks 对其进行容器化,并在 Docker 上运行它。最后,你使用 Kubernetes 将应用程序部署到集群中。当然,这是一个本地集群,但它也可以是云中的远程集群。这个过程的美丽之处在于,它无论在什么环境中工作方式都是一样的。你可以使用完全相同的方法将目录服务部署到公共云基础设施中的集群。这不是很棒吗?

在第七章中,你将更多地使用 Kubernetes。现在,使用 Ctrl-C 终止端口转发过程,使用 kubectl delete service catalog -service 删除 Service,并使用 kubectl delete deployment catalog -service 删除 Deployment。最后,你可以使用 minikube stop 停止 Kubernetes 集群。

2.5 极地书店:一个云原生应用程序

在这本书中,我旨在尽可能多地提供现实世界的代码示例。现在你已经探索了一些关键概念,并尝试过构建、容器化和部署一个 Spring 应用程序,让我们承担一个稍微复杂一些的项目:一个在线书店。在本书的其余部分,我将指导你开发一个基于 Spring 应用程序的完整云原生系统,将其容器化,并在公共云中使用 Kubernetes 进行部署。

对于我们在以下章节中涵盖的每个概念,我将向你展示如何将其应用于实际的云原生场景,以获得完整的动手学习体验。请记住,本书中使用的所有代码都可在本书的 GitHub 仓库中找到。

本节将定义我们将构建的云原生项目的需求,并描述其架构。然后我将概述我们将用于实现它的主要技术和模式。

2.5.1 理解系统的需求

Polar Bookshop 是一家专门的书店,其使命是传播关于北极和北极地区的知识和信息,书店就位于那里:北极的历史、地理、动物等等。管理书店的 Polarsophia 组织决定开始在线销售书籍,以将它们传播到世界各地,但这只是开始。该项目非常雄心勃勃,愿景包括一系列软件产品,以实现 Polarsophia 的使命。在本章前面的成功试点项目之后,该组织决定开始云原生之旅。

在整本书中,你将构建一个系统的基础部分,这个系统在功能和集成方面具有无限的可能性。管理层计划通过短周期迭代交付新功能,缩短上市时间,并从用户那里获得早期反馈。他们的目标是让书店接近每个人,无论身在何处,因此应用应该具有高度的可扩展性。由于面向全球受众,这样一个系统需要高度可用,因此弹性是必不可少的。

Polarsophia 是一个小型组织,他们需要优化成本,特别是与基础设施相关的成本。他们负担不起建立自己的数据中心,因此他们决定从第三方租赁 IT 硬件。

到目前为止,你可能已经能够识别出一些公司为何要迁移到云的原因。这就是我们将为 Polar Bookshop 应用程序所做的事情。当然,它将是一个云原生应用程序。

书籍将通过应用程序出售。当客户购买书籍时,他们应该能够检查他们订单的状态。将有两类人使用 Polar Bookshop 应用程序:

  • 客户可以在目录中浏览书籍,购买一些,并检查他们的订单。

  • 员工可以管理书籍,更新现有书籍,并向目录中添加新项目。

图 2.14 描述了 Polar Bookshop 云原生系统的架构。如图所示,它由几个服务组成。其中一些将实现系统的业务逻辑,以提供已提到的功能。其他服务将实现共享关注点,如集中式配置。为了清晰起见,该图没有显示负责安全和可观察性的服务。你将在本书后面的章节中了解它们。

在接下来的章节中,我将更详细地引导你了解图 2.14,添加有关特定服务的更多信息,并采用不同的视角来可视化系统的部署阶段。现在,让我们看看我们在项目中将使用的模式和技术的概述。

2.5.2 探索项目中使用的模式和技术的探索

当我在书中介绍每个新主题时,我会向你展示如何将特定的技术或模式应用于 Polar Bookshop 项目。在这里,我将为你概述我们将要解决的主要问题以及我们将使用的技术和模式来完成它们。

02-14

图 2.14 Polar Bookshop 的架构。云原生系统包含具有不同责任的应用程序和数据服务。为了清晰起见,安全和可观察性服务未显示。

网络和交互

Polar Bookshop 包含几个服务,这些服务必须相互通信以提供其功能。你将构建 RESTful 服务,这些服务通过 HTTP 以同步方式交互,包括阻塞方式(使用传统的 servlets)和非阻塞方式(使用响应式编程)。Spring MVC 和 Spring WebFlux(基于 Project Reactor)将是实现这种结果的主要工具。

当构建云原生应用程序时,你应该设计松耦合的服务,并考虑如何在分布式系统环境中保持数据一致性。当多个服务参与完成一个功能时,同步通信可能会产生问题。这就是为什么事件驱动编程在云中变得越来越受欢迎:它允许你克服同步通信的问题。

我将向你展示如何使用事件和消息系统来解耦服务并确保数据一致性。你将使用 Spring Cloud Stream 在服务之间实现数据流,并使用 Spring Cloud Function 将消息处理器定义为函数。后者在部署到 Azure Functions、AWS Lambda 或 Knative 等平台的无服务器应用程序中可以自然地演进。

数据

数据是软件系统的重要组成部分。在 Polar Bookshop 中,你将使用 PostgreSQL 关系数据库永久存储应用程序处理的数据。我将向你展示如何使用 Spring Data JDBC(命令式)和 Spring Data R2DBC(响应式)将应用程序与数据源集成。然后,你将学习如何使用 Flyway 演进数据源和管理模式迁移。

云原生应用程序应该是无状态的,但状态需要存储在某个地方。在 Polar Bookshop 中,你将使用 Redis 将会话存储外部化到数据存储中,以保持应用程序无状态和可扩展。Spring Session 使得实现集群用户会话变得简单。特别是,我将向你展示如何使用 Spring Session Data Redis 将应用程序会话管理集成到 Redis 中。

除了持久化数据和会话数据外,您还将处理消息以实现事件驱动编程模式。您将使用 Spring AMQP 和 RabbitMQ 来完成这项工作。

在本地,您将在 Docker 容器中运行这些数据服务。在生产环境中,您将依赖由云提供商(如 DigitalOcean 或 Azure)提供的托管服务,这些服务负责处理高可用性、集群、存储和数据复制等关键问题。

配置

在整本书中,我将向您展示如何以不同的方式配置 Polar Bookshop 中的服务。我将首先探讨 Spring Boot 属性和配置文件提供的选项,以及何时使用它们。然后您将学习如何在以 JAR 或容器形式运行 Spring 应用程序时使用环境变量来应用外部配置。然后您将看到如何通过 Spring Cloud Config 配置服务器来集中管理配置。最后,我将教您如何在 Kubernetes 中使用 ConfigMaps 和 Secrets。

路由

由于 Polar Bookshop 是一个分布式系统,它将需要一些路由配置。Kubernetes 具有内置的服务发现功能,可以帮助您将服务与其物理地址和主机名解耦。云原生应用是可扩展的,因此它们之间的任何交互都应该考虑到这一点:您应该调用哪个实例?Kubernetes 再次为您提供原生的负载均衡功能,因此您不需要在应用程序中实现任何内容。

使用 Spring Cloud Gateway,我将指导您实现一个作为 API 网关的服务,以保护外部不受任何内部 API 更改的影响。它还将是一个边缘服务,您将使用它来处理诸如安全性和弹性等横切关注点。此类服务将是 Polar Bookshop 的入口点,它必须具有高可用性、高性能和容错性。

可观察性

Polar Bookshop 系统中的服务应该是可观察的,才能被定义为云原生。我将向您展示如何使用 Spring Boot Actuator 设置健康和 info 端点,并使用 Micrometer 暴露指标,以便 Prometheus 可以抓取和处理。然后您将使用 Grafana 在信息仪表板中可视化最关键的指标。

请求可以由多个服务处理,因此您需要分布式跟踪功能来跟踪请求从一个服务到另一个服务的流程。您将使用 OpenTelemetry 来设置该功能。然后 Grafana Tempo 将抓取、处理和可视化跟踪,以向您展示系统如何完成其功能的全貌。

最后,您需要实施一个日志策略。我们应该将日志作为事件流来处理,因此您将使您的 Spring 应用程序将日志事件流式传输到标准输出,而不考虑它们是如何被处理或存储的。Fluent Bit 将负责从所有服务中收集日志,Loki 将存储和处理它们,而 Grafana 将允许您浏览它们。

弹性

云原生应用应该具有弹性。在 Polar Bookshop 项目中,我将向您展示使用 Project Reactor、Spring Cloud Circuit Breaker 和 Resilience4J 实现断路器、重试、超时和其他模式的多种技术,以使应用程序具有弹性。

安全性

安全性是一个庞大的主题,我无法在这本书中深入探讨。尽管如此,我仍然建议您探索这个主题,因为它现在是软件领域最关键的担忧之一。这是一个从项目一开始就需要持续关注的问题。

对于 Polar Bookshop,我将向您展示如何向云原生应用程序添加身份验证和授权功能。您将了解如何保护服务之间的通信,以及用户与应用程序之间的通信。OAuth 2.1 和 OpenID Connect 将是您实现此类功能所依赖的标准。Spring Security 支持这些标准,并与外部服务无缝集成以提供身份验证和授权。您将使用 Keycloak 进行身份和访问控制管理。

此外,我将介绍秘密管理和加密的概念。我无法对这些主题进行深入探讨,但我将向您展示如何管理秘密以配置 Spring Boot 应用程序。

测试

自动化测试对于云原生应用程序的成功至关重要。几个自动化测试级别将涵盖 Polar Bookshop 应用程序。我将向您展示如何使用 JUnit5 编写单元测试。Spring Boot 添加了许多方便的实用工具,这些工具可以改善集成测试,您将使用它们来确保服务的质量。您将为 Polar Bookshop 中使用的各种功能编写测试,包括 REST 端点、消息流、数据集成和安全。

保持环境之间的平衡对于确保应用程序的质量至关重要。这在备份服务方面尤其如此。在生产环境中,您将使用 PostgreSQL 和 Redis 等服务。在测试期间,您应该使用类似的服务,而不是模拟或特定于测试的工具,如 H2 内存数据库。Testcontainers 框架将帮助您在自动化测试中使用真实服务作为容器。

构建和部署

Polar Bookshop 的主要服务将使用 Spring。您将了解如何打包 Spring 应用程序,将其作为 JAR 文件运行,使用 Cloud Native Buildpacks 进行容器化,使用 Docker 运行,并最终使用 Kubernetes 部署容器。您还将了解如何使用 Spring Native 和 GraalVM 将 Spring 应用程序编译为原生镜像,并在无服务器架构中使用它们,利用它们的即时启动时间、即时峰值性能、降低内存消耗和减少镜像大小。然后,您将在基于 Kubernetes 的 Knative 无服务器平台上部署它们。

我会向你展示如何通过设置 GitHub Actions 部署管道来自动化构建阶段。该管道将在每次提交时构建应用程序,运行测试,并将其打包为就绪状态以供部署。这种自动化将支持持续交付文化,快速且可靠地为客户带来价值。最后,你还将使用 GitOps 实践和 Argo CD 自动化 Polar Bookshop 的生产 Kubernetes 集群部署。

UI

这本书专注于后端技术,所以我不会教你任何前端主题。当然,你的应用程序需要前端供用户与之交互。在 Polar Bookshop 的情况下,你将依赖于使用 Angular 框架的客户端应用程序。我不会在这本书中展示 UI 应用程序代码,因为它超出了范围,但我已经将它包含在随书附带的代码仓库中。

摘要

  • 15-Factor 方法确定了构建应用程序的开发原则,这些应用程序在执行环境中提供最大可移植性,适合部署在云平台上,可扩展,保证开发和生产环境之间的环境一致性,并支持持续交付。

  • Spring 是一套项目,为使用 Java 构建现代应用程序提供所有最常用的功能。

  • Spring 框架提供了一个应用程序上下文,在其中管理着整个生命周期中的 beans 和属性。

  • Spring Boot 通过加速构建生产就绪应用程序,包括嵌入式服务器、自动配置、监控和容器化功能,为云原生开发奠定了基础。

  • 容器镜像是一种轻量级的可执行包,包含运行应用程序所需的一切。

  • Docker 是一个符合 OCI 标准的平台,用于构建和运行容器。

  • Spring Boot 应用程序可以打包为容器镜像,使用 Cloud Native Buildpacks,这是一个 CNCF 项目,它指定了如何将应用程序源代码转换为生产就绪的容器镜像。

  • 当处理多个容器时,这在云原生系统中通常是情况,你需要管理这个复杂的系统。Kubernetes 提供了编排、调度和管理容器的功能。

  • Kubernetes Pods 是最小的部署单元。

  • Kubernetes 部署描述了如何从容器镜像开始创建应用程序实例作为 Pods。

  • Kubernetes 服务允许你将应用程序端点暴露在集群外部。


^(1.) A. Wiggins, “十二要素应用*”,12factor.net

^(2.) K. Hoffman, 《超越十二要素应用*》(O’Reilly,2016 年)。

第二部分 云原生开发

第一部分定义了云原生应用程序的主要 features,并让你对从代码到部署的 journey 有了一个初步的 taste。第二部分将介绍使用 Spring Boot 和 Kubernetes 构建生产就绪的云原生应用程序的主要 practices 和 patterns。

第三章涵盖了启动新云原生项目的 fundamentals,包括组织代码库、管理依赖项和定义部署管道的提交阶段 strategies。你将学习如何使用 Spring MVC 和 Spring Boot Test 实现 and 测试 REST API。第四章讨论了 externalized configuration 的重要性,并涵盖了 Spring Boot 应用程序可用的某些 options,包括属性文件、环境变量和 Spring Cloud Config 提供的配置服务。第五章介绍了云中数据服务的 main aspects,并展示了如何使用 Spring Data JDBC 将数据持久性添加到 Spring Boot 应用程序中。你还将学习使用 Flyway 管理数据的生产选项以及使用 Testcontainers 的测试策略。第六章是关于 containers 的;你将了解更多关于 Docker 的知识,以及如何使用 Dockerfiles 和 Cloud Native Buildpacks 将 Spring Boot 应用程序打包为 container images。最后,第七章讨论了 Kubernetes,并涵盖了服务发现、负载均衡、可伸缩性和本地开发工作流程。你还将学习更多关于如何将 Spring Boot 应用程序部署到 Kubernetes 集群的方法。

3 开始云原生开发

本章涵盖

  • 启动云原生项目

  • 与嵌入式服务器和 Tomcat 一起工作

  • 使用 Spring MVC 构建 RESTful 应用程序

  • 使用 Spring Test 测试 RESTful 应用程序

  • 使用 GitHub Actions 自动化构建和测试

云原生领域如此广泛,以至于开始时可能会感到不知所措。本书的第一部分,你得到了云原生应用程序及其支持过程的理论介绍,并有了构建最小 Spring Boot 应用程序并将其作为容器部署到 Kubernetes 的第一次动手实践。所有这些都将帮助你更好地理解整体云原生图景,并正确地定位本书其余部分将要涉及的主题。

云计算为我们使用各种应用程序所能实现的目标打开了无限可能。在本章中,我将从一个最常见类型开始:一个通过 REST API 在 HTTP 上暴露其功能的 Web 应用程序。我将引导你通过后续章节中将要遵循的开发过程,讨论传统 Web 应用程序和云原生 Web 应用程序之间的显著差异,整合 Spring Boot 和 Spring MVC 的一些必要方面,并强调重要的测试和生产考虑因素。我还会解释 15-Factor 方法中推荐的一些指南,包括依赖管理、并发性和 API 优先。

在这个过程中,你将实现上一章中初始化的目录服务应用程序。它将负责管理 Polar Bookshop 系统中的图书目录。

注意:本章示例的源代码可在 Chapter03/03-begin 和 Chapter03/03-end 文件夹中找到,其中包含项目的初始状态和最终状态(github.com/ThomasVitale/cloud-native-spring-in-action)。

3.1 启动云原生项目

开始一个新的开发项目总是令人兴奋的。15-Factor 方法包含了一些启动云原生应用程序的实用指南。

  • 一个代码库,一个应用程序—云原生应用程序应由一个版本控制系统跟踪的单个代码库组成。

  • 依赖管理—云原生应用程序应使用一个显式管理依赖关系的工具,不应依赖于它们部署的环境中的隐式依赖。

在本节中,我将提供关于这两个原则的更多细节,并解释如何将它们应用于目录服务,这是 Polar Bookshop 系统中的第一个云原生应用程序。

3.1.1 一个代码库,一个应用程序

云原生应用程序应由一个在版本控制系统(如 Git)中跟踪的单一代码库组成。每个代码库必须产生不可变的工件,称为构建,这些构建可以部署到多个环境。图 3.1 显示了代码库、构建和部署之间的关系。

03-01

图 3.1 每个应用程序都有自己的代码库,从中产生不可变的构建,然后部署到适当的环境,而无需更改代码。

正如你在下一章中将要看到的,任何特定于环境的配置,如配置,都必须在应用程序代码库之外。如果代码被多个应用程序需要,你应该将其转换为独立的服务,或者将其转换为可以作为一个依赖项导入项目的库。你应该仔细评估后者,以防止系统成为一个分布式单体

注意:思考你的代码是如何组织到代码库和仓库中的,可以帮助你更多地关注系统架构,并识别那些可能实际上可以独立作为服务存在的部分。如果这样做正确,代码库的组织可以有利于模块化和松散耦合。

根据十五要素方法,每个代码库都应该映射到一个应用程序,但关于仓库没有提及。你可以选择在单独的仓库中跟踪每个代码库,或者在同一个仓库中。这两种选项在云原生业务中都得到了应用。在整个书中,你将构建几个应用程序,我建议你在自己的 Git 仓库中跟踪每个代码库,因为这将提高可维护性和可部署性。

在上一章中,你初始化了 Polar Bookshop 系统中的第一个应用程序,目录服务,并将其放置在 catalog-service Git 仓库中。我建议你使用 GitHub 来存储你的仓库,因为稍后我们将使用 GitHub Actions 作为工作流引擎来定义支持持续交付的部署管道。

3.1.2 使用 Gradle 和 Maven 进行依赖项管理

你如何管理应用程序的依赖项是相关的,因为它会影响它们的可靠性和可移植性。在 Java 生态系统中,用于依赖项管理的两个最常用的工具是 Gradle 和 Maven。两者都提供了在清单中声明依赖项并从中央仓库下载它们的功能。列出项目所需的所有依赖项的原因是确保你不会依赖于任何从周围环境中泄露的隐式库。

注意:除了依赖项管理之外,Gradle 和 Maven 还为构建、测试和配置 Java 项目提供了额外的功能,这对于应用程序开发是基本的。本书中的所有示例都将使用 Gradle,但你可以自由地使用 Maven。

即使你已经有了依赖项清单,你仍然需要提供依赖管理器本身。Gradle 和 Maven 都提供了一种从名为 gradlew 或 mvnw 的 包装脚本 运行工具的功能,你可以将其包含在你的代码库中。例如,你不必运行 gradle build 这样的 Gradle 命令(这假设你在你的机器上安装了 Gradle),你可以运行 ./gradlew build。该脚本调用项目中定义的特定版本的构建工具。如果构建工具尚未存在,包装脚本将首先下载它,然后运行命令。使用包装脚本,你可以确保所有团队成员和构建项目的自动化工具使用相同的 Gradle 或 Maven 版本。当你从 Spring Initializr 生成新项目时,你也会得到一个可用的包装脚本,因此你不需要下载或配置任何内容。

注意:无论如何,你通常至少有一个外部依赖项:运行时。在我们的例子中,那就是 Java 运行时环境(JRE)。如果你将应用程序打包成容器镜像,Java 运行时将包含在镜像本身中,这让你对其有更多的控制权。另一方面,最终的应用程序工件将依赖于运行镜像所需的容器运行时。你将在第六章中了解更多关于容器化过程的信息。

现在,让我们看看代码。Polar Bookshop 系统有一个负责管理目录中书籍的 Catalog Service 应用程序。在前一章中,我们初始化了项目。系统的架构再次在图 3.2 中展示。

03-02

图 3.2 Polar Bookshop 系统的架构,目前仅由一个应用服务组成

应用程序所需的所有依赖项都列在自动生成的 build.gradle 文件中(catalog-service/build.gradle)。

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-web'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

这些是主要的依赖项:

  • Spring Web (org.springframework.boot:spring-boot-starter-web) 提供了构建使用 Spring MVC 的 Web 应用程序所需的库,包括默认的嵌入服务器 Tomcat。

  • Spring Boot Test (org.springframework.boot:spring-boot-starter-test) 提供了用于测试应用程序的多个库和实用工具,包括 Spring Test、JUnit、AssertJ 和 Mockito。它自动包含在每一个 Spring Boot 项目中。

Spring Boot 的一个重要特性是它处理依赖管理的方式。例如,spring-boot-starter-web 这样的启动依赖项可以减轻你管理更多依赖项和验证导入的特定版本之间兼容性的负担。这又是 Spring Boot 的一个特性,它将以简单且高效的方式帮助你开始。

在下一节中,你将了解嵌入在 Spring Boot 中的服务器是如何工作的,以及如何对其进行配置。

3.2 与嵌入服务器一起工作

使用 Spring Boot,你可以构建不同类型的应用程序(例如,Web、事件驱动、无服务器、批处理和任务应用程序),这些应用程序具有各种用例和模式。在云原生环境中,它们都共享一些共同方面:

  • 它们完全是自包含的,除了运行时之外没有其他外部依赖。

  • 它们被打包为标准的可执行工件。

考虑一个 Web 应用程序。传统上,你会将其打包为 WAR 或 EAR 文件(用于打包 Java 应用程序的存档格式)并部署到像 Tomcat 或 WildFly 这样的 Web 服务器或应用程序服务器。对外部服务器的依赖会限制应用程序本身的可移植性和进化,并增加维护成本。

在本节中,你将了解如何使用 Spring Boot、Spring MVC 和嵌入式服务器在云原生 Web 应用程序中解决这些问题,但类似的原则也适用于其他类型的应用程序。你将学习传统应用程序和云原生应用程序之间的区别,嵌入式服务器(如 Tomcat)的工作方式,以及如何配置它。我还会详细说明 15-Factor 方法论中关于服务器、端口绑定和并发的几个指南:

  • 端口绑定—与依赖于执行环境中外部服务器可用的传统应用程序不同,云原生应用程序是自包含的,并通过绑定到一个可配置的端口来导出其服务,该端口取决于环境。

  • 并发—在 JVM 应用程序中,我们通过多个线程(作为线程池可用)来处理并发。当达到并发限制时,我们倾向于水平扩展而不是垂直扩展。我们不是向应用程序添加更多的计算资源,而是更倾向于部署更多实例并将工作负载在他们之间分配。

依据这些原则,我们将继续在目录服务上工作,以确保它是自包含的,并打包为一个可执行 JAR。

服务器!到处都是服务器!

到目前为止,我使用了 应用程序服务器Web 服务器 这些术语。稍后,我还会提到 Servlet 容器。它们之间的区别是什么?

  • Web 服务器—一个处理来自客户端的 HTTP 请求并回复 HTTP 响应的服务器,例如 Apache HTTPD。

  • Servlet 容器—一个组件,是 Web 服务器的一部分,为使用 Java Servlet API 的 Web 应用程序提供执行上下文(如 Spring MVC 应用程序)。Tomcat(Catalina)是一个例子。

  • 应用程序服务器—一个为不同类型的应用程序提供完整执行环境(如 Jakarta EE)并支持多个协议的服务器,例如 WildFly。

3.2.1 可执行 JAR 和嵌入式服务器

传统方法与云原生方法之间的一个区别在于如何打包和部署应用程序。传统上,我们通常使用应用服务器或独立的 Web 服务器。在生产环境中,它们设置和维护成本高昂,因此它们被用来部署多个应用程序,这些应用程序打包成 EAR 或 WAR 工件以提高效率。这种场景在应用程序之间创建了耦合。如果其中任何一个想要在服务器级别进行更改,那么更改必须与其他团队协调并应用于所有应用程序,这限制了敏捷性和应用程序的演变。此外,应用程序的部署依赖于机器上可用的服务器,限制了应用程序在不同环境中的可移植性。

当你转向云原生时,事情就不同了。云原生应用程序应该是自包含的,并且不依赖于执行环境中可用的服务器。相反,必要的服务器功能包含在应用程序本身中。Spring Boot 提供了内置的服务器功能,可以帮助你去除外部依赖,使应用程序独立。Spring Boot 附带了一个预配置的 Tomcat 服务器,但也可以将其替换为 Undertow、Jetty 或 Netty。

解决了服务器依赖问题后,我们需要相应地改变打包应用程序的方式。在 JVM 生态系统中,云原生应用程序被打包成 JAR 工件。由于它们是自包含的,它们可以作为独立的 Java 应用程序运行,除了 JVM 之外没有外部依赖。Spring Boot 足够灵活,允许 JAR 和 WAR 类型的打包。然而,对于云原生应用程序,你将希望使用自包含的 JAR 文件,也称为胖 JARuber-JAR,因为它们包含了应用程序本身、依赖项和嵌入式服务器。图 3.3 比较了传统和云原生打包和运行 Web 应用程序的方式。

03-03

图 3.3 传统上,应用程序被打包成 WAR 文件,需要在执行环境中有一个可用的服务器来运行。云原生应用程序被打包成 JAR 文件,是自包含的,并使用嵌入式服务器。

用于云原生应用程序的嵌入式服务器通常包括一个 Web 服务器组件和一个执行上下文,以便 Java Web 应用程序能够与 Web 服务器交互。例如,Tomcat 包含一个 Web 服务器组件(Coyote)和一个基于 Java Servlet API 的执行上下文,通常称为 Servlet 容器(Catalina)。我将交替使用Web 服务器Servlet 容器。另一方面,不建议在云原生应用程序中使用应用服务器。

在上一章中,当生成目录服务项目时,我们选择了 JAR 打包选项。然后我们使用 bootRun Gradle 任务运行了应用程序。这是一个在开发期间构建项目并作为独立应用程序运行的好方法。但现在你对我们关于嵌入式服务器和 JAR 打包的了解更多了,我将向你展示另一种方法。

首先,让我们将应用程序打包成一个 JAR 文件。打开一个终端窗口,导航到目录服务项目(catalog-service)的根文件夹,并运行以下命令。

$ ./gradlew bootJar

bootJar Gradle 任务编译代码并将应用程序打包成一个 JAR 文件。默认情况下,JAR 文件生成在 build/libs 文件夹中。你应该得到一个名为 catalog-service-0.0.1-SNAPSHOT.jar 的可执行 JAR 文件。一旦你得到 JAR 艺术品,你就可以像任何标准 Java 应用程序一样运行它。

$ java -jar build/libs/catalog-service-0.0.1-SNAPSHOT.jar

注意:另一个实用的 Gradle 任务是 build,它结合了 bootJar 和 test 任务的操作。

由于项目包含 spring-boot-starter-web 依赖项,Spring Boot 自动配置了一个嵌入的 Tomcat 服务器。通过查看图 3.4 中的日志,你可以看到第一个执行步骤之一是初始化应用程序本身嵌入的 Tomcat 服务器实例。

03-04

图 3.4 目录服务应用程序的启动日志

在下一节中,你将了解 Spring Boot 中嵌入式服务器的工作方式。在继续之前,你可以使用 Ctrl-C 停止应用程序。

3.2.2 理解 thread-per-request 模型

让我们考虑在 Web 应用程序中常用的一种请求/响应模式,以在 HTTP 上建立同步交互。客户端向服务器发送 HTTP 请求,服务器执行一些计算,然后以 HTTP 响应的形式回复。

在像 Tomcat 这样的 Servlet 容器中运行的 Web 应用程序中,请求是基于名为 thread-per-request 的模型处理的。对于每个请求,应用程序专门分配一个线程来处理该特定请求;该线程在返回响应给客户端之前不会用于其他任何事情。当请求处理涉及像 I/O 这样的密集型操作时,线程将阻塞直到操作完成。例如,如果需要数据库读取,线程将等待直到从数据库返回数据。这就是为什么我们说这种处理方式是 同步阻塞 的。

Tomcat 使用一个线程池来初始化,该线程池用于管理所有传入的 HTTP 请求。当所有线程都在使用时,新请求将被排队,等待一个线程变得空闲。换句话说,Tomcat 中的线程数量定义了可以同时支持多少个请求的上限。在调试性能问题时记住这一点非常有用。如果连续遇到线程并发限制,您始终可以调整线程池配置以接受更多的负载。对于传统应用程序,我们会向特定实例添加更多的计算资源。对于云原生应用程序,我们依赖于水平扩展和部署更多的副本。

注意:在某些必须响应高需求的应用程序中,按请求分配线程的模型可能不是理想的,因为它由于阻塞而没有最有效地使用可用的计算资源。在第八章中,我将介绍一个基于 Spring WebFlux 和 Project Reactor 的 异步非阻塞 的替代方案,采用反应式编程范式。

Spring MVC 是 Spring 框架中包含的库,用于实现网络应用程序,无论是完整的 MVC 还是基于 REST 的。无论如何,其功能基于像 Tomcat 这样的服务器,它提供了一个符合 Java Servlet API 的 Servlet 容器。图 3.5 展示了在 Spring 网络应用程序中基于 REST 的请求/响应交互是如何工作的。

03-05

图 3.5 DispatcherServlet 组件是 Servlet 容器(Tomcat)的入口点。它将实际的 HTTP 请求处理委托给 HandlerMapping 识别的控制器,该控制器负责特定的端点。

DispatcherServlet 组件提供了请求处理的中心入口点。当客户端发送一个针对特定 URL 模式的新的 HTTP 请求时,DispatcherServlet 会请求 HandlerMapping 组件以获取负责该端点的控制器,并最终将请求的实际处理委托给指定的控制器。控制器处理请求,可能通过调用其他服务,然后向 DispatcherServlet 返回一个响应,最后 DispatcherServlet 以 HTTP 响应的形式回复客户端。

注意 Tomcat 服务器是如何嵌入到 Spring Boot 应用程序中的。Spring MVC 依赖于网络服务器来完成其功能。对于实现 Servlet API 的任何网络服务器也是如此,但由于我们明确使用 Tomcat,让我们继续探索一些配置它的选项。

3.2.3 配置嵌入式 Tomcat

Tomcat 是任何 Spring Boot 网络应用程序预配置的默认服务器。有时默认配置可能足够,但对于生产中的应用程序,您可能需要自定义其行为以满足特定的要求。

注意:在传统的 Spring 应用程序中,你会在专门的文件中配置服务器,例如 server.xml 和 context.xml。使用 Spring Boot,你可以通过两种方式配置嵌入式 Web 服务器:通过属性或通过 WebServerFactoryCustomizer bean。

本节将向您展示如何通过属性配置 Tomcat。您将在下一章中了解更多关于配置应用程序的内容。现在,只需知道您可以在项目的 src/main/resources 文件夹中的 application.properties 或 application.yml 文件中定义属性即可。您可以选择使用哪种格式:.properties 文件依赖于键/值对,而.yml 文件使用 YAML 格式。在这本书中,我将使用 YAML 定义属性。Spring Initializr 默认生成一个空的 application.properties 文件,所以记得在继续之前将其扩展名从.properties 更改为.yml。

让我们继续配置 Catalog Service 应用程序(catalog-service)的嵌入式服务器。所有配置属性都将放入 application.yml 文件中。

HTTP 端口

默认情况下,嵌入式服务器正在监听 8080 端口。只要您只使用一个应用程序,这就可以了。如果您在开发期间运行多个 Spring 应用程序,这在云原生系统中通常是情况,您将希望使用 server.port 属性为每个应用程序指定不同的端口号。

列表 3.1 配置 Web 服务器端口

server:
  port: 9001

连接超时

server.tomcat.connection-timeout 属性定义了 Tomcat 在从客户端接受 TCP 连接和实际接收 HTTP 请求之间应该等待多长时间的限制。它有助于防止拒绝服务(DoS)攻击,在这种攻击中,连接被建立,Tomcat 保留一个线程来处理请求,但请求从未到来。相同的超时时间也用于限制在存在 HTTP 请求体时读取 HTTP 请求体的时间。

默认值是 20 秒(20 秒),这可能对于标准云原生应用程序来说太多了。在云中高度分布的系统背景下,我们可能不想等待超过几秒钟,以免因为 Tomcat 实例挂起时间过长而导致级联故障。大约 2 秒会更好。您还可以使用 server.tomcat.keep-alive-timeout 属性来配置在等待新的 HTTP 请求时保持连接打开的时间。

列表 3.2 配置 Tomcat 的超时

server:
  port: 9001
  tomcat: 
    connection-timeout: 2s 
    keep-alive-timeout: 15s 

线程池

Tomcat 有一组线程池来处理请求,遵循每个请求一个线程的模型。可用的线程数将决定可以同时处理多少个请求。你可以通过 server.tomcat.threads.max 属性配置请求处理线程的最大数量。你也可以定义应该始终运行的最小线程数(server.tomcat.threads.min-spare),这也是启动时创建的线程数。

确定线程池的最佳配置很复杂,没有计算它的魔法公式。资源分析、监控和多次试验通常是找到合适配置所必需的。默认线程池可以增长到 200 个线程,并且始终有 10 个工作线程在运行,这在生产中是良好的起始值。在您的本地环境中,您可能希望降低这些值以优化资源消耗,因为随着线程数量的增加,它呈线性增长。

列表 3.3 配置 Tomcat 线程池

server:
  port: 9001
  tomcat:
    connection-timeout: 2s
    keep-alive-timeout: 15s
    threads: 
      max: 50 
      min-spare: 5 

到目前为止,您已经看到,使用 Spring Boot 的云原生应用程序被打包成 JAR 文件,并依赖于嵌入式服务器以去除对执行环境的额外依赖,并实现敏捷性。您学习了线程请求模型的工作原理,熟悉了使用 Tomcat 和 Spring MVC 的请求处理流程,并配置了 Tomcat。在下一节中,我们将继续探讨目录服务的业务逻辑以及使用 Spring MVC 实现 REST API。

3.3 使用 Spring MVC 构建 RESTful 应用程序

如果您正在构建云原生应用程序,那么您很可能会在一个由多个服务组成的分布式系统中工作,这些服务(如微服务)相互交互以完成产品的整体功能。您的应用程序可能被您组织中的另一个团队开发的服务所消费,或者您可能正在将其功能暴露给第三方。无论哪种方式,任何跨服务通信中都有一个基本元素:API。

15-Factor 方法提倡“API 优先”模式。它鼓励您首先建立服务接口,然后进行实现。API 代表您应用程序与其消费者之间的公共合同,因此最好首先定义它。

假设您同意一个合同并首先定义 API。在这种情况下,其他团队可以开始工作并针对您的 API 开发解决方案,以实现与您的应用程序的集成。如果您不首先开发 API,那么将出现瓶颈,其他团队将不得不等待您完成应用程序。提前讨论 API 还可以与利益相关者进行富有成效的讨论,这可能有助于您明确应用程序的范围,甚至定义要实现的用户故事。

在云中,任何应用程序都可以成为另一个应用程序的后端服务。采用 API 优先的心态将帮助您演进您的应用程序并适应未来的需求。

本节将通过定义一个作为 REST API 的目录服务合同来引导您,这是云原生应用中最常用的服务接口模型。您将使用 Spring MVC 来实现 REST API,验证它,并对其进行测试。我还会概述一些考虑因素,以适应未来需求对 API 进行演变,这在高度分布式的系统(如云原生应用)中是一个常见问题。

3.3.1 首先设计 REST API,然后是业务逻辑

首先设计 API 假设您已经定义了需求,因此让我们从这些开始。目录服务将负责支持以下用例:

  • 查看目录中的书籍列表。

  • 通过国际标准书号(ISBN)搜索书籍。

  • 将新书籍添加到目录中。

  • 编辑现有书籍的信息。

  • 从目录中删除书籍。

换句话说,我们可以这样说,应用程序应该提供 API 来执行书籍的 CRUD 操作。格式将遵循应用于 HTTP 的 REST 风格。有几种方法可以设计 API 来满足这些用例。在本章中,我们将使用表 3.1 中描述的方法。

表 3.1 目录服务将公开的 REST API 规范

端点 HTTP 方法 请求体 状态 响应体 描述
/books GET 200 书籍数组 获取目录中的所有书籍。
/books POST 书籍 201 书籍 将新书籍添加到目录中。
422 已存在具有相同 ISBN 的书籍。
/books/ GET 200 书籍 获取给定 ISBN 的书籍。
404 没有找到给定 ISBN 的书籍。
/books/ PUT 书籍 200 书籍 更新给定 ISBN 的书籍。
201 书籍 使用给定的 ISBN 创建书籍。
/books/ DELETE 204 删除给定 ISBN 的书籍。

记录 API

在遵循 API 首先的方法时,记录 API 是一项基本任务。在 Spring 生态系统中,有两个主要选项:

  • Spring 提供了 Spring REST Docs 项目 (spring.io/projects/spring-restdocs),它通过测试驱动开发(TDD)帮助您记录 REST API,从而生成高质量且易于维护的文档。生成的文档面向人员,依赖于 Asciidoc 或 Markdown 等格式。如果您还想获得 OpenAPI 表示,可以查看 restdocs-api-spec 社区驱动的项目,以将 OpenAPI 支持添加到 Spring REST Docs (github.com/ePages-de/restdocs-api-spec)。

  • springdoc-openapi 社区驱动的项目帮助根据 OpenAPI 3 格式自动生成 API 文档 (springdoc.org)。

通过 REST API 建立合同,因此让我们继续并查看业务逻辑。解决方案围绕三个概念展开:

  • 实体—实体代表域中的名词,例如“书籍。”

  • 服务—服务定义了域的使用案例。例如,“将一本书添加到目录中。”

  • 仓库—仓库是一个抽象,允许域层独立于其来源访问数据。

让我们从域实体开始。

定义域实体

表 3.1 中定义的 REST API 应该能够对书籍进行操作。这是领域实体。在 Catalog Service 项目中,为业务逻辑创建一个新的 com.polarbookshop.catalogservice.domain 包,并创建一个 Book Java 记录来表示领域实体。

列表 3.4 使用 Book 记录定义应用程序的领域实体

package com.polarbookshop.catalogservice.domain;

public record Book (    ❶
  String isbn,          ❷
  String title,
  String author,
  Double price
){}

❶ 领域模型实现为一个记录,一个不可变对象。

❷ 唯一标识一本书

实现使用案例

应用程序需求列举的使用案例可以在一个@Service 类中实现。在 com.polarbookshop.catalogservice.domain 包中创建一个 BookService 类,如下所示。该服务依赖于你将在下一分钟创建的一些类。

列表 3.5 实现应用程序的使用案例

package com.polarbookshop.catalogservice.domain;

import org.springframework.stereotype.Service;

@Service                                                  ❶
public class BookService {
  private final BookRepository bookRepository;

  public BookService(BookRepository bookRepository) {
    this.bookRepository = bookRepository;                 ❷
  }

  public Iterable<Book> viewBookList() {
    return bookRepository.findAll();
  }

  public Book viewBookDetails(String isbn) {
    return bookRepository.findByIsbn(isbn)                ❸
      .orElseThrow(() -> new BookNotFoundException(isbn));
  }

  public Book addBookToCatalog(Book book) {
    if (bookRepository.existsByIsbn(book.isbn())) {       ❹
     throw new BookAlreadyExistsException(book.isbn());
    }
    return bookRepository.save(book);
  }

  public void removeBookFromCatalog(String isbn) {
    bookRepository.deleteByIsbn(isbn);
  }

  public Book editBookDetails(String isbn, Book book) {
    return bookRepository.findByIsbn(isbn)
      .map(existingBook -> {
        var bookToUpdate = new Book(                      ❺
          existingBook.isbn(),
          book.title(),
          book.author(),
          book.price());
        return bookRepository.save(bookToUpdate);
      })
      .orElseGet(() -> addBookToCatalog(book));           ❻
  }
}

❶ 标记一个类为 Spring 管理的服务泛型注解

❷ BookRepository 通过构造函数自动装配提供。

❸ 当尝试查看一个不存在的书籍时,会抛出一个专门的异常。

❹ 当多次将同一本书添加到目录中时,会抛出一个专门的异常。

❺ 在编辑书籍时,可以更新除 ISBN 代码之外的所有 Book 字段,因为它是实体标识符。

❻ 当更改尚未在目录中的书籍的详细信息时,创建一个新的书籍。

注意:Spring 框架提供了两种依赖注入方式:基于构造函数基于 setter。我们将遵循 Spring 团队的建议,在任何生产代码中使用基于构造函数的依赖注入,因为它确保所需的依赖项始终以完全初始化的状态返回,并且永远不会为 null。此外,它鼓励构建不可变对象,并提高它们的可测试性。有关更多信息,请参阅 Spring 框架文档(spring.io/projects/spring-framework)。

使用存储库抽象进行数据访问

BookService 类依赖于 BookRepository 对象来检索和保存书籍。领域层应该不知道数据是如何持久化的,因此 BookRepository 应该是一个接口,用于将抽象与实际实现解耦。在 com.polarbookshop.catalogservice.domain 包中创建一个 BookRepository 接口,以定义访问书籍数据的抽象。

列表 3.6 领域层用于访问数据使用的抽象

package com.polarbookshop.catalogservice.domain;

import java.util.Optional;

public interface BookRepository {
  Iterable<Book> findAll();
  Optional<Book> findByIsbn(String isbn);
  boolean existsByIsbn(String isbn);
  Book save(Book book);
  void deleteByIsbn(String isbn);
}

虽然存储库接口属于领域层,但其实现是持久化层的一部分。我们将在第五章中使用关系数据库添加数据持久化层。现在,添加一个简单的内存映射来检索和保存书籍就足够了。你可以在位于新 com.polarbookshop.catalogservice.persistence 包中的 InMemoryBookRepository 类中定义实现。

列表 3.7 BookRepository 接口的内存实现

package com.polarbookshop.catalogservice.persistence;

import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import com.polarbookshop.catalogservice.domain.Book;
import com.polarbookshop.catalogservice.domain.BookRepository;
import org.springframework.stereotype.Repository;

@Repository                                                      ❶
public class InMemoryBookRepository implements BookRepository {
  private static final Map<String, Book> books =                 ❷
    new ConcurrentHashMap<>();

  @Override
  public Iterable<Book> findAll() {
    return books.values();
  }

  @Override
  public Optional<Book> findByIsbn(String isbn) {
    return existsByIsbn(isbn) ? Optional.of(books.get(isbn)) :
      Optional.empty();
  }

  @Override
  public boolean existsByIsbn(String isbn) {
    return books.get(isbn) != null;
  }

  @Override
  public Book save(Book book) {
    books.put(book.isbn(), book);
    return book;
  }

  @Override
  public void deleteByIsbn(String isbn) {
    books.remove(isbn);
  }
}

❶ 标记一个类为 Spring 管理的存储库的泛型注解

❷ 用于测试的内存映射表,用于存储书籍

使用异常来指示领域中的错误

让我们通过实现列表 3.5 中使用的两个异常来完成目录服务的业务逻辑。

BookAlreadyExistsException 是在尝试向目录中添加已存在的书籍时抛出的运行时异常。它防止目录中出现重复条目。

列表 3.8 添加已存在的书籍时抛出的异常

package com.polarbookshop.catalogservice.domain;

public class BookAlreadyExistsException extends RuntimeException {
  public BookAlreadyExistsException(String isbn) {
    super("A book with ISBN " + isbn + " already exists.");
  }
}

BookNotFoundException 是在尝试获取目录中不存在的书籍时抛出的运行时异常。

列表 3.9 当找不到书籍时抛出的异常

package com.polarbookshop.catalogservice.domain;

public class BookNotFoundException extends RuntimeException {
  public BookNotFoundException(String isbn) {
    super("The book with ISBN " + isbn + " was not found.");
  }
}

这完成了目录服务的业务逻辑。它相对简单,但建议不要受数据持久化或与客户端交换方式的影响。业务逻辑应该独立于其他任何事物,包括 API。如果您对这个主题感兴趣,我建议探索 领域驱动设计六边形架构 的概念。

3.3.2 使用 Spring MVC 实现 REST API

在实现业务逻辑后,我们可以通过 REST API 公开用例。Spring MVC 提供了 @RestController 类来定义处理特定 HTTP 方法和服务端点的传入 HTTP 请求的方法。

如前节所示,DispatcherServlet 组件将为每个请求调用正确的控制器。图 3.6 显示了客户端发送 HTTP GET 请求以查看特定书籍详情的场景。

03-06

图 3.6 HTTP GET 请求到达 /books/ 端点的处理流程

我们希望为应用程序要求中定义的每个用例实现一个方法处理程序,因为我们希望将它们全部提供给客户端。为网络层(com.polarbookshop.catalogservice.web)创建一个包,并添加一个 BookController 类,该类负责处理发送到 /books 基础端点的 HTTP 请求。

列表 3.10 定义 REST 端点的处理程序

package com.polarbookshop.catalogservice.web;

import com.polarbookshop.catalogservice.domain.Book;
import com.polarbookshop.catalogservice.domain.BookService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

@RestController                                              ❶
@RequestMapping("books")                                     ❷
public class BookController {
  private final BookService bookService;

  public BookController(BookService bookService) {
    this.bookService = bookService;
  }

  @GetMapping                                                ❸
  public Iterable<Book> get() {
    return bookService.viewBookList();
  }

  @GetMapping("{isbn}")                                      ❹
  public Book getByIsbn(@PathVariable String isbn) {         ❺
    return bookService.viewBookDetails(isbn);
  }

  @PostMapping                                               ❻
  @ResponseStatus(HttpStatus.CREATED)                        ❼
  public Book post(@RequestBody Book book) {                 ❽
    return bookService.addBookToCatalog(book);
  }

  @DeleteMapping("{isbn}")                                   ❾
  @ResponseStatus(HttpStatus.NO_CONTENT)                     ❿
  public void delete(@PathVariable String isbn) {
    bookService.removeBookFromCatalog(isbn);
  }

  @PutMapping("{isbn}")                                      ⓫
  public Book put(@PathVariable String isbn, @RequestBody Book book) {
    return bookService.editBookDetails(isbn, book);
  }
}

❶ 标记类为 Spring 组件和 REST 端点处理程序源的构造型注解

❷ 识别类提供的处理程序根路径映射 URI("/books")

❸ 将 HTTP GET 请求映射到特定的处理程序方法

❹ 添加到根路径映射 URI("/books/{isbn}")的 URI 模板变量

❺ @PathVariable 将方法参数绑定到 URI 模板变量({isbn})。

❻ 将 HTTP POST 请求映射到特定的处理程序方法

❼ 如果书籍创建成功,则返回 201 状态

❽ @RequestBody 将方法参数绑定到网络请求的主体。

❾ 将 HTTP DELETE 请求映射到特定的处理程序方法

❿ 如果书籍成功删除,则返回 204 状态

⓫ 将 HTTP PUT 请求映射到特定的处理程序方法

继续运行应用程序(./gradlew bootRun)。在验证应用程序的 HTTP 交互时,你可以使用命令行工具如 curl 或具有图形用户界面的软件如 Insomnia。我将使用一个方便的命令行工具 HTTPie (httpie.org)。你可以在附录 A 的 A.4 节中找到有关如何安装它的信息。

打开一个终端窗口,执行一个 HTTP POST 请求以将一本书添加到目录中:

$ http POST :9001/books author="Lyra Silverstar" \
    title="Northern Lights" isbn="1234567891" price=9.90

结果应该是一个带有 201 代码的 HTTP 响应,这意味着书籍已成功创建。让我们通过提交一个 HTTP GET 请求来获取我们创建时使用的 ISBN 代码的书来再次检查。

$ http :9001/books/1234567891

HTTP/1.1 200
Content-Type: application/json

{
  "author": "Lyra Silverstar",
  "isbn": "1234567891",
  "price": 9.9,
  "title": "Northern Lights"
}

当你完成尝试应用程序后,使用 Ctrl-C 停止其执行。

关于内容协商

BookController 中的所有处理器方法都针对 Book Java 对象工作。然而,当你执行一个请求时,你却得到了一个 JSON 对象。这是怎么可能的?

Spring MVC 依赖于一个 HttpMessageConverter bean 将返回的对象转换为客户端支持的具体表示形式。关于内容类型的决定是由一个称为内容协商的过程驱动的,在这个过程中,客户端和服务器就双方都能理解的一种表示形式达成一致。客户端可以通过 HTTP 请求中的 Accept 头通知服务器它支持哪些内容类型。

默认情况下,Spring Boot 配置了一组 HttpMessageConverter bean,以 JSON 表示形式返回对象,HTTPie 工具默认配置为接受任何内容类型。结果是客户端和服务器都支持 JSON 内容类型,因此它们同意使用该类型进行通信。

我们迄今为止实现的应用程序仍然不完整。例如,没有任何东西可以阻止你以错误的格式或未指定标题的方式发布一本新书。我们需要验证输入。

3.3.3 数据验证和错误处理

作为一般规则,在保存任何数据之前,你应该始终验证内容,无论是为了数据一致性还是出于安全原因。在我们的应用程序中,没有标题的书将毫无用处,它可能会使应用程序失败。

对于 Book 类,我们可能会考虑使用以下验证约束:

  • ISBN 必须定义,并且格式正确(ISBN-10 或 ISBN-13)。

  • 标题必须定义。

  • 作者必须定义。

  • 价格必须定义,并且必须大于零。

Java Bean Validation 是一个流行的规范,用于通过注解在 Java 对象上表达约束和验证规则。Spring Boot 提供了一个方便的启动依赖项,包含 Java Bean Validation API 及其实现。在您的目录服务项目构建.gradle 文件中添加新的依赖项。记住,在添加新项后,刷新或重新导入 Gradle 依赖项。

列表 3.11 为 Spring Boot Validation 添加依赖项

dependencies {
  ...
  implementation 'org.springframework.boot:spring-boot-starter-validation' 
}

您现在可以使用 Java Bean Validation API 直接在 Book 记录字段上定义验证约束作为注释。

列表 3.12 为每个字段定义的验证约束

package com.polarbookshop.catalogservice.domain;

import javax.validation.constraints.NotBlank; 
import javax.validation.constraints.NotNull; 
import javax.validation.constraints.Pattern; 
import javax.validation.constraints.Positive; 

public record Book (

  @NotBlank(message = "The book ISBN must be defined.") 
  @Pattern(                                               ❶
    regexp = "^([0-9]{10}|[0-9]{13})$", 
    message = "The ISBN format must be valid." 
  ) 
  String isbn,

  @NotBlank(                                              ❷
    message = "The book title must be defined." 
  ) 
  String title,

  @NotBlank(message = "The book author must be defined.") 
  String author,

  @NotNull(message = "The book price must be defined.") 
  @Positive(                                              ❸
    message = "The book price must be greater than zero." 
  ) 
  Double price
){}

❶ 注释的元素必须匹配指定的正则表达式(标准 ISBN 格式)。

❷ 注释的元素不能为空,并且必须包含至少一个非空白字符。

❸ 注释的元素不能为空,并且必须大于零。

注意:书籍通过其 ISBN(国际标准书号)唯一标识。ISBN 过去由 10 位数字组成,但现在由 13 位组成。为了简单起见,我们将限制自己通过使用正则表达式检查它们的长度以及所有元素是否都是数字。

Java Bean Validation API 中的注释定义了约束,但它们尚未强制执行。我们可以通过在 BookController 类中使用@Valid 注释来指示 Spring 在指定@RequestBody 作为方法参数时验证 Book 对象。这样,每次我们创建或更新一本书时,Spring 都会运行验证,如果任何约束被违反,则会抛出错误。我们可以按如下方式更新 BookController 类中的 post()和 put()方法。

列表 3.13 验证请求体中传入的书籍

...
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Book post(@Valid @RequestBody Book book) {
  return bookService.addBookToCatalog(book);
}
@PutMapping("{isbn}")
public Book put(@PathVariable String isbn, @Valid @RequestBody Book book) {
  return bookService.editBookDetails(isbn, book);
}
...

Spring 允许您以不同的方式处理错误消息。当构建 API 时,考虑它可以抛出哪些类型的错误是个好主意,因为它们与领域数据一样重要。当它是 REST API 时,您想确保 HTTP 响应使用最适合目的的状态码,并包含一个有意义的消息来帮助客户端识别问题。

当我们定义的验证约束未满足时,会抛出 MethodArgumentNotValidException。如果我们尝试获取一个不存在的书籍会发生什么?我们之前实现的业务逻辑会抛出专门的异常(BookAlreadyExistsException 和 BookNotFoundException)。所有这些异常都应该在 REST API 上下文中处理,以返回原始规范中定义的错误代码。

要处理 REST API 的错误,我们可以使用标准的 Java 异常,并依赖于一个@RestControllerAdvice 类来定义当抛出给定异常时要执行的操作。这是一个集中式方法,允许我们将异常处理与抛出异常的代码解耦。在 com.polarbookshop.catalogservice.web 包中,创建一个 BookControllerAdvice 类,如下所示。

列表 3.14 定义如何处理异常的咨询类

package com.polarbookshop.catalogservice.web;

import java.util.HashMap;
import java.util.Map;
import com.polarbookshop.catalogservice.domain.BookAlreadyExistsException;
import com.polarbookshop.catalogservice.domain.BookNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice                                               ❶
public class BookControllerAdvice {

  @ExceptionHandler(BookNotFoundException.class)                    ❷
  @ResponseStatus(HttpStatus.NOT_FOUND)
  String bookNotFoundHandler(BookNotFoundException ex) {
    return ex.getMessage();                                         ❸
  }

  @ExceptionHandler(BookAlreadyExistsException.class)
  @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)                  ❹
  String bookAlreadyExistsHandler(BookAlreadyExistsException ex) {
    return ex.getMessage();
  }

  @ExceptionHandler(MethodArgumentNotValidException.class)
  @ResponseStatus(HttpStatus.BAD_REQUEST)
  public Map<String, String> handleValidationExceptions(
   MethodArgumentNotValidException ex                               ❺
  ) {
    var errors = new HashMap<String, String>();
    ex.getBindingResult().getAllErrors().forEach(error -> {
      String fieldName = ((FieldError) error).getField();
      String errorMessage = error.getDefaultMessage();
      errors.put(fieldName, errorMessage);                          ❻
    });
    return errors;
  }
}

❶ 将类标记为集中式异常处理程序。

❷ 定义了处理程序必须执行的异常。

❸ 将包含在 HTTP 响应体中的消息。

❹ 定义当抛出异常时创建的 HTTP 响应的状态码。

❺ 处理 Book 验证失败时抛出的异常。

❻ 收集有关哪些 Book 字段无效的有意义的错误消息,而不是返回空消息。

在@RestControllerAdvice 类中提供的映射使得在尝试创建目录中已存在的书籍时,能够获得状态为 422(不可处理的实体)的 HTTP 响应,在尝试读取不存在的书籍时,获得状态为 404(未找到)的响应,以及在 Book 对象的一个或多个字段无效时,获得状态为 400(请求错误)的响应。每个响应都将包含一个有意义的消息,这是我们作为验证约束或自定义异常的一部分定义的。

构建并重新运行应用程序(./gradlew bootRun):如果你现在尝试创建一个没有标题且 ISBN 格式错误的书籍,请求将失败。

$ http POST :9001/books author="Jon Snow" title="" isbn="123ABC456Z" \
    price=9.90

结果将是一个带有“400 Bad Request”状态的错误消息,这意味着服务器无法处理 HTTP 请求,因为它是不正确的。响应体包含有关请求哪个部分不正确以及如何修复它的详细消息,正如我们在列表 3.12 中定义的那样。

HTTP/1.1 400
Content-Type: application/json

{
  "isbn": "The ISBN format must be valid.",
  "title": "The book title must be defined."
}

当你完成尝试应用程序后,使用 Ctrl-C 停止其执行。

这就完成了我们实现 REST API 的工作,该 API 公开了目录服务的书籍管理功能。接下来,我将讨论我们如何演进 API 以适应新需求的几个方面。

3.3.4 为未来需求演进 API

在分布式系统中,我们需要一个计划来演进 API,以避免破坏其他应用的功能。这是一个具有挑战性的任务,因为我们希望应用程序独立,但它们可能存在是为了向其他应用提供服务,因此我们在独立于客户端进行更改的数量上有所限制。

最佳做法是对 API 进行向后兼容的更改。例如,我们可以在不影响目录服务应用客户端的情况下向 Book 对象添加一个可选字段。

有时候,进行破坏性更改是必要的。在这种情况下,你可以使用 API 版本控制。例如,如果你决定对目录服务应用的 REST API 进行破坏性更改,你可能需要为端点引入一个版本控制系统。版本号可能是端点本身的一部分,例如 /v2/books。或者它可能被指定为 HTTP 头。这个系统有助于防止现有客户端崩溃,但它们迟早需要更新其接口以匹配新的 API 版本,这意味着需要协调。

另一种方法侧重于使 REST API 客户端尽可能对 API 更改具有弹性。解决方案是使用 REST 架构的超媒体方面,正如 Roy Fielding 博士在其博士论文“架构风格和网络化软件架构的设计”(www.ics.uci.edu/~fielding/pubs/dissertation/top.htm)中所描述的。REST API 可以返回请求的对象,以及关于如何继续下一步的信息和执行相关操作的链接。这个特性的美妙之处在于,只有在有道理跟随时才会显示链接,从而提供关于何时继续的信息。

这个超媒体方面也被称为HATEOAS(超媒体作为应用程序状态引擎),根据 Richardson 成熟度模型,它代表了 API 成熟度的最高水平。Spring 提供了 Spring HATEOAS 项目,以向 REST API 添加超媒体支持。我不会在本书中使用它,但我鼓励您查看该项目的在线文档spring.io/projects/spring-hateoas

这些考虑因素结束了我们对使用 Spring 构建 RESTful 应用程序的讨论。在下一节中,您将看到如何编写自动化测试来验证应用程序的行为。

3.4 使用 Spring 测试 RESTful 应用程序

自动化测试对于生产高质量的软件至关重要。采用云原生方法的一个目标就是速度。如果代码没有经过充分的自动化测试,那么快速移动是不可能的,更不用说实施持续交付流程了。

作为一名开发者,您通常会实现一个功能,交付它,然后转向新的一个,可能还会重构现有代码。重构代码是有风险的,因为您可能会破坏一些现有功能。自动化测试可以降低风险并鼓励重构,因为您知道如果破坏了某些东西,测试将会失败。您可能还希望减少反馈周期,以便您能尽快知道是否犯了错误。这将导致您以最大化测试有用性和效率的方式设计测试。您不应该旨在达到最大的测试覆盖率,而应该编写有意义的测试。例如,为标准的 getter 和 setter 编写测试是没有意义的。

持续交付的一个基本实践是测试驱动开发(TDD),它有助于实现快速、可靠和安全地交付软件的目标。其理念是在实现生产代码之前先编写测试来驱动软件开发。我建议在实际场景中采用 TDD。然而,当在书中教授新技术和框架时,它并不非常适用,因此在这里我不会遵循其原则。

自动化测试断言新功能按预期工作,并且你没有破坏任何现有功能。这意味着自动化测试作为回归测试工作。你应该编写测试来保护你和你的同事免犯错误。要测试什么以及测试的深度由特定代码片段的风险驱动。编写测试也是一种学习经历,并将提高你的技能,尤其是如果你刚开始你的软件开发之旅。

软件测试的一种分类方法是由 Brian Marick 首先提出的 Agile 测试象限模型,后来在 Lisa Crispin 和 Janet Gregory 的书籍 Agile Testing(Addison-Wesley Professional,2008)、More Agile Testing(Addison-Wesley Professional,2014)和 Agile Testing Condensed(Library and Archives Canada,2019)中描述和扩展。他们的模型也被 Jez Humble 和 Dave Farley 在 Continuous Delivery(Addison-Wesley Professional,2010)中采用。象限根据它们是否面向技术或业务以及它们是否支持开发团队或用于评估产品来分类软件测试。图 3.7 显示了我将在本书中提到的测试类型的一些示例,这些示例基于 Agile Testing Condensed 中提出的模型。

03-07

图 3.7 Agile 测试象限模型有助于规划软件测试策略。

遵循持续交付实践,我们应该力争在四个象限中的三个实现完全自动化测试,如图 3.7 所示。在本书中,我们将主要关注左下象限。在本节中,我们将使用单元测试和集成测试(有时称为组件测试)。我们编写单元测试来验证单个应用程序组件在隔离状态下的行为,而集成测试则断言应用程序不同部分相互交互的整体功能。

在 Gradle 或 Maven 项目中,测试类通常放在 src/test/java 文件夹中。在 Spring 中,单元测试不需要加载 Spring 应用程序上下文,也不依赖于任何 Spring 库。另一方面,集成测试需要 Spring 应用程序上下文才能运行。本节将向您展示如何使用单元测试和集成测试测试 RESTful 应用程序,如目录服务。

3.4.1 使用 JUnit 5 的单元测试

单元测试不了解 Spring,也不依赖于任何 Spring 库。它们旨在测试单个组件作为独立单元的行为。单元边缘的任何依赖都被模拟,以保持测试不受外部组件的影响。

为 Spring 应用程序编写单元测试与为任何其他 Java 应用程序编写单元测试没有区别,所以我就不详细介绍了。默认情况下,从 Spring Initializr 创建的任何 Spring 项目都包含 spring-boot-starter-test 依赖项,该依赖项将测试库(如 JUnit 5、Mockito 和 AssertJ)导入到项目中。因此,我们已经准备好编写单元测试了。

应用程序的业务逻辑通常是单元测试的一个合理区域。在 Catalog Service 应用程序中,单元测试的一个良好候选可能是 Book 类的验证逻辑。验证约束是通过 Java Validation API 注解定义的,我们感兴趣的是测试它们是否正确应用于 Book 类。我们可以在新的 BookValidationTests 类中检查这一点,如下所示。

列表 3.15 验证书籍约束的单元测试

package com.polarbookshop.catalogservice.domain;

import java.util.Set;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

class BookValidationTests {
  private static Validator validator;

  @BeforeAll                                                 ❶
  static void setUp() {
    ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    validator = factory.getValidator();
  }

  @Test                                                      ❷
  void whenAllFieldsCorrectThenValidationSucceeds() {
    var book =                                               ❸
      new Book("1234567890", "Title", "Author", 9.90);
    Set<ConstraintViolation<Book>> violations = validator.validate(book);
    assertThat(violations).isEmpty();                        ❹
  }

  @Test
  void whenIsbnDefinedButIncorrectThenValidationFails() {
    var book =                                               ❺
      new Book("a234567890", "Title", "Author", 9.90);
    Set<ConstraintViolation<Book>> violations = validator.validate(book);
    assertThat(violations).hasSize(1);
    assertThat(violations.iterator().next().getMessage())
      .isEqualTo("The ISBN format must be valid.");          ❻
  }
}

❶ 识别在类中所有测试之前执行的代码块

❷ 识别一个测试用例

❸ 创建一个具有有效 ISBN 的书籍

❹ 断言没有验证错误

❺ 创建一个具有非有效 ISBN 代码的书籍

❻ 断言违反的验证约束是关于错误的 ISBN

然后,我们可以使用以下命令运行测试:

$ ./gradlew test --tests BookValidationTests

3.4.2 使用 @SpringBootTest 的集成测试

集成测试覆盖软件组件之间的交互,在 Spring 中,它们需要一个定义了的应用程序上下文。spring-boot-starter-test 依赖项还导入了来自 Spring 框架和 Spring Boot 的测试实用工具。

Spring Boot 提供了一个强大的 @SpringBootTest 注解,你可以在测试类上使用它,在运行测试时自动引导应用程序上下文。如果需要,可以自定义用于创建上下文的配置。否则,带有 @SpringBootApplication 注解的类将成为组件扫描和属性的配置源,包括 Spring Boot 提供的常规自动配置。

当与 Web 应用程序一起工作时,你可以在模拟 Web 环境或运行中的服务器上运行测试。你可以通过定义 @SpringBootTest 注解提供的 webEnvironment 属性的值来配置它,如表 3.2 所示。

当使用模拟 Web 环境时,你可以依赖 MockMvc 对象向应用程序发送 HTTP 请求并检查其结果。对于具有运行服务器的环境,TestRestTemplate 实用工具允许你对运行在实际服务器上的应用程序执行 REST 调用。通过检查 HTTP 响应,你可以验证 API 是否按预期工作。

表 3.2 一个 Spring Boot 集成测试可以用模拟 Web 环境或运行中的服务器初始化。

Web environment option 描述
MOCK 使用模拟 Servlet 容器创建 Web 应用程序上下文。这是默认选项。
RANDOM_PORT 使用 Servlet 容器监听随机端口的 Web 应用程序上下文创建。
定义端口 使用通过 server.port 属性定义的端口创建一个带有 Servlet 容器的 Web 应用程序上下文。
创建一个不带 Servlet 容器的应用程序上下文。

Spring Framework 和 Spring Boot 的最新版本扩展了测试 Web 应用程序的功能。现在,您可以使用 WebTestClient 类在模拟环境和运行服务器上测试 REST API。与 MockMvc 和 TestRestTemplate 相比,WebTestClient 提供了一个现代且流畅的 API 以及额外的功能。此外,您可以使用它来测试命令式(例如,目录服务)和响应式应用程序,优化学习和生产力。

由于 WebTestClient 是 Spring WebFlux 项目的一部分,您需要在目录服务项目中添加一个新的依赖项(build.gradle)。请记住,在添加新依赖项后,刷新或重新导入 Gradle 依赖项。

列表 3.16 为 Spring Reactive Web 添加测试依赖

dependencies {
  ...
  testImplementation 'org.springframework.boot:spring-boot-starter-webflux' 
}

第八章将涵盖 Spring WebFlux 和响应式应用程序。目前,我们只对使用 WebTestClient 对象测试目录服务公开的 API 感兴趣。

在上一章中,您看到 Spring Initializr 生成了一个空的 CatalogServiceApplicationTests 类。让我们用集成测试来填充它。为此设置,我们将使用配置为提供完整 Spring 应用程序上下文的 @SpringBootTest 注解,包括一个运行的服务器,该服务器通过随机端口公开其服务(因为哪个端口都无关紧要)。

列表 3.17 目录服务的集成测试

package com.polarbookshop.catalogservice;

import com.polarbookshop.catalogservice.domain.Book;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.reactive.server.WebTestClient;
import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest(                                             ❶
  webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
)
class CatalogServiceApplicationTests {

  @Autowired
  private WebTestClient webTestClient;                       ❷

  @Test
  void whenPostRequestThenBookCreated() {
    var expectedBook = new Book("1231231231", "Title", "Author", 9.90);

    webTestClient
      .post()                                                ❸
      .uri("/books")                                         ❹
      .bodyValue(expectedBook)                               ❺
      .exchange()                                            ❻
      .expectStatus().isCreated()                            ❼
      .expectBody(Book.class).value(actualBook -> {
        assertThat(actualBook).isNotNull();                  ❽
        assertThat(actualBook.isbn())
          .isEqualTo(expectedBook.isbn());                   ❾
      });
  }
}

❶ 加载完整的 Spring Web 应用程序上下文和一个监听随机端口的 Servlet 容器

❷ 用于执行测试 REST 调用的实用工具

❸ 发送 HTTP POST 请求

❹ 将请求发送到 "/books" 端点

❺ 在请求体中添加书籍

❻ 发送请求

❼ 验证 HTTP 响应状态为“201 已创建”

❽ 验证 HTTP 响应体非空

❾ 验证创建的对象符合预期

注意:您可能想知道为什么我没有在列表 3.17 中使用基于构造函数的依赖注入,尽管我之前提到这是推荐选项。在生产代码中使用基于字段的依赖注入已被弃用,并且强烈不建议使用,但在测试类中自动装配依赖项仍然是可接受的。在其他所有情况下,我建议坚持使用基于构造函数的依赖注入,原因我在前面已经解释过了。有关更多信息,您可以参考官方 Spring Framework 文档(spring.io/projects/spring-framework)。

然后,您可以使用以下命令运行测试:

$ ./gradlew test --tests CatalogServiceApplicationTests

根据应用程序的大小,加载包含所有集成测试自动配置的全应用程序上下文可能太多。Spring Boot 有一个方便的功能(默认启用),可以缓存上下文,以便在所有带有@SpringBootTest 注解且配置相同的测试类中重用。有时这还不够。

测试执行时间很重要,因此 Spring Boot 完全准备好通过仅加载应用程序所需的部分来运行集成测试。让我们看看它是如何工作的。

3.4.3 使用 @WebMvcTest 测试 REST 控制器

一些集成测试可能不需要完全初始化的应用程序上下文。例如,当你测试数据持久层时,不需要加载网络组件。如果你正在测试网络组件,你不需要加载数据持久层。

Spring Boot 允许你使用仅初始化了一组组件(bean)的上下文,针对特定的应用程序切片。切片测试不使用@SpringBootTest 注解,而是使用一系列针对应用程序特定部分的注解:Web MVC、Web Flux、REST 客户端、JDBC、JPA、Mongo、Redis、JSON 等。每个这样的注解都会初始化一个应用程序上下文,过滤掉该切片之外的所有 bean。

我们可以通过使用@WebMvcTest 注解来测试 Spring MVC 控制器是否按预期工作,该注解在模拟网络环境中加载 Spring 应用程序上下文(没有运行的服务器),配置 Spring MVC 基础设施,并仅包含 MVC 层使用的 bean,如@RestController 和@RestControllerAdvice。限制上下文到特定控制器使用的 bean 也是一个好主意。我们可以在新的 BookControllerMvcTests 类中通过将控制器类作为参数传递给@WebMvcTest 注解来实现这一点。

列表 3.18 Web MVC 切片集成测试

package com.polarbookshop.catalogservice.web;

import com.polarbookshop.catalogservice.domain.BookNotFoundException;
import com.polarbookshop.catalogservice.domain.BookService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request
➥.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result
➥.MockMvcResultMatchers.status;

@WebMvcTest(BookController.class)                       ❶
class BookControllerMvcTests {

  @Autowired
  private MockMvc mockMvc;                              ❷

  @MockBean                                             ❸
  private BookService bookService;

  @Test
  void whenGetBookNotExistingThenShouldReturn404() throws Exception {
    String isbn = "73737313940";
    given(bookService.viewBookDetails(isbn))
      .willThrow(BookNotFoundException.class);          ❹
    mockMvc
      .perform(get("/books/" + isbn))                   ❺
      .andExpect(status().isNotFound());                ❻
  }
}

❶ 识别一个专注于 Spring MVC 组件的测试类,明确针对 BookController

❷ 用于在模拟环境中测试网络层的实用工具类

❸ 将 BookService 的模拟添加到 Spring 应用程序上下文

❹ 定义 BookService 模拟 bean 的预期行为

❺ 使用 MockMvc 执行 HTTP GET 请求并验证结果。

❻ 预期响应状态为“404 未找到”

警告:如果你使用 IntelliJ IDEA,可能会收到 MockMvc 无法自动装配的警告。不要担心,这是一个假阳性。你可以通过在字段上注解@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")来消除警告。

然后,你可以使用以下命令运行测试:

$ ./gradlew test --tests BookControllerMvcTests

MockMvc 是一个实用工具类,允许你在不加载服务器(如 Tomcat)的情况下测试网络端点。这种测试比我们在上一节中编写的需要嵌入服务器的测试要轻量得多。

切片测试针对只包含该应用程序切片请求的配置部分的上下文运行。在切片外部的协作 Bean(如 BookService 类)的情况下,我们使用模拟。

使用 @MockBean 注解创建的模拟与标准模拟(例如,使用 Mockito 创建的模拟)不同,因为类不仅被模拟,模拟也被包含在应用程序上下文中。每当上下文被要求自动装配该 Bean 时,它会自动注入模拟而不是实际实现。

3.4.4 使用 @JsonTest 测试 JSON 序列化

BookController 方法返回的 Book 对象被解析为 JSON 对象。默认情况下,Spring Boot 自动配置 Jackson 库将 Java 对象解析为 JSON(序列化)以及相反(反序列化)。

使用 @JsonTest 注解,你可以测试你的领域对象的 JSON 序列化和反序列化。@JsonTest 加载一个 Spring 应用程序上下文,并自动配置特定库(默认为 Jackson)的 JSON 映射器。此外,它配置了 JacksonTester 实用工具,你可以使用它来检查 JSON 映射是否按预期工作,依赖于 JsonPath 和 JSONAssert 库。

注意 JsonPath 提供了你可以用来导航 JSON 对象并从中提取数据的表达式。例如,如果我想从 Book 对象的 JSON 表示中获取 isbn 字段,我可以使用以下 JsonPath 表达式:@.isbn。有关 JsonPath 库的更多信息,你可以参考项目文档:github.com/json-path/JsonPath

下面的列表展示了在新的 BookJsonTests 类中实现的序列化和反序列化测试的示例。

列表 3.19 JSON 切片集成测试

package com.polarbookshop.catalogservice.web;

import com.polarbookshop.catalogservice.domain.Book;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.JsonTest;
import org.springframework.boot.test.json.JacksonTester;
import static org.assertj.core.api.Assertions.assertThat;

@JsonTest                                                             ❶
class BookJsonTests {

  @Autowired
  private JacksonTester<Book> json;                                   ❷

  @Test
  void testSerialize() throws Exception {
    var book = new Book("1234567890", "Title", "Author", 9.90);
    var jsonContent = json.write(book);                               ❸
    assertThat(jsonContent).extractingJsonPathStringValue("@.isbn")
      .isEqualTo(book.isbn());
    assertThat(jsonContent).extractingJsonPathStringValue("@.title")
      .isEqualTo(book.title());
    assertThat(jsonContent).extractingJsonPathStringValue("@.author")
      .isEqualTo(book.author());
    assertThat(jsonContent).extractingJsonPathNumberValue("@.price")
      .isEqualTo(book.price());
  }

  @Test
  void testDeserialize() throws Exception {
    var content = """                                                 ❹
      {
        "isbn": "1234567890",
        "title": "Title",
        "author": "Author",
        "price": 9.90
      }
      """;
    assertThat(json.parse(content))                                   ❺
      .usingRecursiveComparison()
      .isEqualTo(new Book("1234567890", "Title", "Author", 9.90));
  }
}

❶ 识别一个专注于 JSON 序列化的测试类

❷ 用于断言 JSON 序列化和反序列化的实用类

❸ 使用 JsonPath 格式验证从 Java 到 JSON 的解析

❹ 使用 Java 文本块功能定义一个 JSON 对象

❺ 验证从 JSON 到 Java 的解析

警告:如果你使用 IntelliJ IDEA,可能会收到 JacksonTester 无法自动装配的警告。不要担心,这是一个假阳性。你可以通过在字段上注解 @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") 来消除警告。

你可以使用以下命令运行测试:

$ ./gradlew test --tests BookJsonTests

在与本书配套的代码仓库中,你可以找到 Catalog Service 项目单元和集成测试的更多示例。

在自动化应用程序的测试之后,当有新的功能或错误修复交付时,就需要自动化其执行。接下来的部分将介绍持续交付的关键模式:部署管道。

3.5 部署管道:构建和测试

持续交付是一种全面的方法,用于快速、可靠和安全地交付高质量的软件,正如我在第一章中解释的那样。采用这种方法的主体模式是部署管道,它从代码提交到可发布软件。它应该尽可能地自动化,并且应该代表通向生产的唯一路径。

根据 Jez Humble 和 Dave Farley 在他们 2010 年出版的《持续交付》(Addison-Wesley Professional)一书中以及 Dave Farley 在他 2021 年出版的《持续交付管道》一书中描述的概念,我们可以在部署管道中识别出几个关键阶段:

  • 提交阶段——开发者在主线提交新代码后,这一阶段将经历构建、单元测试、集成测试、静态代码分析和打包。在这个阶段结束时,一个可执行的应用程序工件被发布到一个工件仓库。它是一个发布候选版本。例如,它可以是发布到 Maven 仓库的 JAR 工件或发布到容器注册表的容器镜像。这一阶段支持持续集成实践。它应该很快,可能不到五分钟,以便为开发者提供关于他们更改的快速反馈,并允许他们继续进行下一项任务。

  • 验收阶段——将新的发布候选版本发布到工件仓库会触发这一阶段,该阶段包括将应用程序部署到类似生产环境并运行额外的测试,以增加对其可发布性的信心。在验收阶段运行的测试通常很慢,但我们应努力将整个部署管道执行时间控制在不到一小时。这一阶段包括的测试示例有功能验收测试和非功能验收测试,如性能测试、安全测试和合规性测试。如果需要,这一阶段还可以包括手动任务,如探索性和可用性测试。在这一阶段结束时,发布候选版本随时可以部署到生产环境。如果我们仍然对其没有信心,这一阶段就缺少一些测试。

  • 生产阶段——在发布候选版本经过提交和验收阶段后,我们对其信心足够,可以将其部署到生产环境。这一阶段可以是手动或自动触发的,具体取决于组织是否决定采用持续部署实践。新的发布候选版本使用在验收阶段使用(并测试)的相同部署脚本部署到生产环境。可选地,可以运行一些最终自动化测试来验证部署是否成功。

本节将指导您启动目录服务的部署管道,并定义提交阶段的初步步骤。然后我将向您展示如何使用 GitHub Actions 自动化这些步骤。

3.5.1 理解部署管道的提交阶段

持续集成是持续交付的基础实践。当成功采用时,开发者会以小步前进,并每天多次向主线(主分支)提交。每次代码提交后,部署管道的提交阶段会负责使用新更改构建和测试应用程序。

这个阶段应该快速,因为开发者会在它成功完成后才继续进行下一个任务。这是一个关键点。如果提交阶段失败,负责该阶段的开发者应立即提供修复或撤销他们的更改,以免将主线置于损坏状态,并防止其他开发者集成他们的代码。

让我们开始设计一个用于类似目录服务这样的云原生应用程序的部署管道。现在,我们将专注于提交阶段的前几个步骤(图 3.8)。

03-08

图 3.8 部署管道中提交阶段的第一部分

开发者将新代码推送到主线后,提交阶段开始通过从仓库检出源代码。起点始终是主分支的一个提交。遵循持续集成实践,我们将力求以小步前进,并每天多次将我们的更改与主分支集成(持续集成)。

接下来,管道可以执行多种类型的静态代码分析。对于这个例子,我们将专注于漏洞扫描。在实际项目中,您可能希望包括额外的步骤,例如运行静态代码分析以识别安全问题并检查是否符合特定的编码标准(代码审查)。

最后,管道构建应用程序并运行自动化测试。在提交阶段,我们包括技术性测试,这些测试不需要部署整个应用程序。这些是单元测试,通常是集成测试。如果集成测试耗时过长,最好将它们移至验收阶段,以保持提交阶段快速。

我们将在 Polar Bookshop 项目中使用的漏洞扫描器是 grype (github.com/anchore/grype),这是一个在云原生世界中越来越受欢迎的强大开源工具。例如,它是 VMware Tanzu 应用平台提供的供应链安全解决方案的一部分。您可以在附录 A 的 A.4 节中找到如何安装它的说明。

让我们看看 grype 是如何工作的。打开一个终端窗口,导航到你的目录服务项目(catalog-service)的根文件夹,并使用 ./gradlew build 构建应用程序。然后使用 grype 扫描你的 Java 代码库中的漏洞。该工具将下载已知漏洞列表(一个 漏洞数据库)并将你的项目与之进行扫描。扫描是在你的机器上本地进行的,这意味着你的任何文件或工件都没有发送到外部服务。这使得它在更受监管的环境或断网场景中是一个很好的选择。

$ grype .
 ✔ Vulnerability DB        [updated]
 ✔ Indexed .
 ✔ Cataloged packages      [35 packages]
 ✔ Scanned image           [0 vulnerabilities]

No vulnerabilities found

注意:请记住,安全性不是系统的一个静态属性。在撰写本文时,目录服务使用的依赖项没有已知的漏洞,但这并不意味着这将是永远如此。你应该持续扫描你的项目,并在发布后立即应用安全补丁,以修复新发现的漏洞。

第六章和第七章将涵盖提交阶段剩余的步骤。目前,让我们看看如何使用 GitHub Actions 自动化部署管道。这就是下一节的主题。

3.5.2 使用 GitHub Actions 实现提交阶段

当涉及到自动化部署管道时,有许多解决方案可供选择。在这本书中,我将使用 GitHub Actions (github.com/features/actions)。这是一个托管解决方案,它为我们提供了项目所需的所有功能,并且已经方便地配置了所有 GitHub 仓库。我在本书的早期就介绍这个主题,这样你就可以在阅读本书的过程中使用部署管道来验证你的更改。

注意:在云原生生态系统中,Tekton (tekton.dev) 是定义部署管道和其他软件工作流程的流行选择。它是一个开源的、Kubernetes 原生解决方案,托管在持续交付基金会 (cd.foundation)。它直接在集群上运行,并允许你声明管道和任务作为 Kubernetes 自定义资源。

GitHub Actions 是一个内置在 GitHub 中的平台,允许你直接从你的代码仓库自动化软件工作流程。工作流程是一个自动化的过程。我们将使用工作流程来模拟我们的部署管道的提交阶段。每个工作流程都监听特定的 事件 来触发其执行。

工作流程应该在 GitHub 仓库根目录下的 .github/workflows 文件夹中定义,并且应该按照 GitHub Actions 提供的 YAML 格式进行描述。在你的目录服务项目(catalog-service)中,在新的 .github/workflows 文件夹下创建一个 commit-stage.yml 文件。这个工作流程将在新代码推送到仓库时被触发。

列表 3.20 定义工作流程名称和触发器

name: Commit Stage     ❶
on: push               ❷

❶ 工作流程的名称

❷ 当新代码推送到仓库时,工作流程将被触发。

每个工作流程都组织成并行运行的 作业。目前,我们将定义一个单独的作业来收集图 3.8 中描述的先前步骤。每个作业都在一个 运行器 实例上执行,这是一个由 GitHub 提供的服务器。您可以选择 Ubuntu、Windows 和 macOS。对于目录服务,我们将在 GitHub 提供的 Ubuntu 运行器上运行所有内容。我们还将具体说明每个作业应具有哪些 权限。构建和测试作业将需要读取 Git 仓库的访问权限,并在提交漏洞报告到 GitHub 时写入安全事件的访问权限。

列表 3.21 配置用于构建和测试应用程序的作业

name: Commit Stage
on: push

 jobs: 
  build:                        ❶
    name: Build and Test        ❷
    runs-on: ubuntu-22.04       ❸
    permissions:                ❹
      contents: read            ❺
      security-events: write    ❻

❶ 作业的唯一标识符

❷ 应运行作业的机器类型

❸ 为作业指定一个便于理解的名字

❹ 授予作业的权限

❺ 检出当前 Git 仓库的权限

❻ 提交安全事件到 GitHub 的权限

每个作业由 步骤 组成,这些步骤按顺序执行。一个步骤可以是 shell 命令或 操作。操作是用于以更结构化和可重复的方式执行复杂任务的定制应用程序。例如,您可以为打包应用程序为可执行文件、运行测试、创建容器镜像或将镜像推送到容器注册表创建操作。GitHub 组织提供了一套基本操作,但还有由社区开发的大量更多操作的市场。

警告 当使用 GitHub 市场中的操作时,像处理任何其他第三方应用程序一样处理它们,并相应地管理安全风险。优先使用 GitHub 或经过验证的组织提供的受信任操作,而不是其他第三方选项。

让我们通过描述“构建和测试”作业应运行的步骤来完成提交阶段的这一部分。最终结果如下所示。

列表 3.22 实现构建和测试应用程序的步骤

name: Commit Stage
on: push

jobs:
  build:
    name: Build and Test
    runs-on: ubuntu-22.04
    permissions:
      contents: read
      security-events: write
    steps: 
      - name: Checkout source code 
        uses: actions/checkout@v3                          ❶
      - name: Set up JDK 
        uses: actions/setup-java@v3                        ❷
        with:                                              ❸
          distribution: temurin 
          java-version: 17 
          cache: gradle 
      - name: Code vulnerability scanning 
        uses: anchore/scan-action@v3                       ❹
        id: scan                                           ❺
        with: 
          path: "${{ github.workspace }}"                  ❻
          fail-build: false                                ❼
          severity-cutoff: high                            ❽
          acs-report-enable: true                          ❾
      - name: Upload vulnerability report 
        uses: github/codeql-action/upload-sarif@v2         ❿
        if: success() || failure()                         ⓫
        with: 
          sarif_file: ${{ steps.scan.outputs.sarif }}      ⓬
      - name: Build, unit tests and integration tests 
        run: | 
          chmod +x gradlew                                 ⓭
          ./gradlew build                                  ⓮

❶ 检出当前 Git 仓库(catalog-service)

❷ 安装和配置 Java 运行时

❸ 定义要使用的版本、发行版和缓存类型

❹ 使用 grype 扫描代码库中的漏洞

❺ 为当前步骤分配一个标识符,以便可以从后续步骤中引用

❻ 检出仓库的路径

❼ 在发生安全漏洞时是否使构建失败

❽ 被视为错误的最小安全类别(低、中、高、严重)

❾ 是否在扫描完成后启用生成报告

❿ 将安全漏洞报告上传到 GitHub(SARIF 格式)

⓫ 即使前一个步骤失败,也上传报告

⓬ 从前一个步骤的输出中获取报告

⓭ 确保 Gradle 包装器可执行,解决 Windows 兼容性问题

⓮ 运行 Gradle 构建任务,编译代码库并运行单元和集成测试

警告:上传漏洞报告的操作需要 GitHub 仓库是公开的。如果你有企业订阅,它仅适用于私有仓库。如果你更喜欢保持你的仓库私有,你需要跳过“上传漏洞报告”步骤。在本书中,我将假设我们为 Polar Bookshop 项目在 GitHub 上创建的所有仓库都是公开的。

在完成部署管道初始提交阶段的声明后,提交你的更改并将它们推送到远程 GitHub 仓库。新创建的工作流程将立即触发。你可以在 GitHub 仓库页面的操作标签页中看到执行结果。图 3.9 显示了在列表 3.22 中运行工作流程后的结果示例。通过保持提交阶段的绿色结果,你可以相当确信你没有破坏任何东西或引入新的回归(假设你有适当的测试)。

03-09

图 3.9 在你向远程仓库推送新更改后,提交阶段的工作流程被执行。

运行漏洞扫描的步骤基于由 grype 背后的公司 Anchore 提供的一个操作。在列表 3.22 中,我们不会在发现严重漏洞时使工作流程失败。然而,你可以在 catalog-service GitHub 仓库的安全部分找到扫描结果。

在撰写本文时,目录服务项目中没有发现高或关键漏洞,但未来情况可能会有所不同。如果出现这种情况,请考虑使用受影响依赖项的最新安全补丁。为了举例说明,并且因为我不想打断你的学习之旅,我决定在发现漏洞时不使构建失败。然而,在实际场景中,我建议你根据公司关于供应链安全的规定仔细配置和调整 grype,并在结果不符合规范时使工作流程失败(将 fail-build 属性设置为 true)。有关更多信息,请参阅官方 grype 文档(github.com/anchore/grype)。

在扫描 Java 项目的漏洞之后,我们还包括了一个步骤,用于获取由 grype 生成的安全报告并将其独立于构建是否成功上传到 GitHub。如果发现任何安全漏洞,你可以在 GitHub 仓库页面的安全标签页中看到结果(图 3.10)。

03-10

图 3.10 由 grype 生成并发布到 GitHub 的安全漏洞报告

注意:在撰写本文时,grype 没有在本书提供的代码库中找到任何漏洞。为了展示一个漏洞报告的例子,图 3.10 显示了 grype 扫描项目不同版本的结果,该版本故意充满了已知的漏洞。

本章到此结束。接下来,我将介绍主要的云原生开发实践之一:外部化配置。

摘要

  • 每个云原生应用程序都应该在其自己的代码库中进行跟踪,并且所有依赖项都应该使用 Gradle 或 Maven 等工具在清单中声明。

  • 云原生应用程序不依赖于注入到环境中的服务器。相反,它们使用嵌入式服务器并且是自包含的。

  • Tomcat 是 Spring Boot 应用程序的默认嵌入式服务器,并且可以通过属性进行配置,以自定义其监听的端口、连接、超时和线程。

  • Servlet 容器(如 Tomcat)提供的请求/响应交互既同步又阻塞。每个线程处理一个 HTTP 请求,直到返回响应。

  • API 的第一原则建议在实现业务逻辑之前设计 API,以建立合同。这样,其他团队可以根据合同本身开发他们的服务来消费您的应用程序,而无需等待应用程序完成。

  • 在 Spring MVC 中,REST API 在 @RestController 类中实现。

  • 每个 REST 控制器方法都通过特定的方法(GET、POST、PUT、DELETE)和端点(例如,/books)处理传入的请求。

  • 控制器方法可以通过 @GetMapping@PostMapping@PutMapping@DeleteMapping@RequestMapping 等注解声明它们处理哪些端点和操作。

  • @RestController 类的方法可以通过应用 @Valid 注解在处理之前验证 HTTP 请求体。

  • 对于给定的 Java 对象,使用 Java Bean 验证 API 的注解在字段上定义验证约束(例如,@NotBlank、@Pattern、@Positive)。

  • 在处理 HTTP 请求期间抛出的 Java 异常可以映射到一个集中式的 @RestControllerAdvice 类中的 HTTP 状态码和正文,从而将 REST API 的异常处理与抛出异常的代码解耦。

  • 单元测试不了解 Spring 配置,但可以使用 JUnit、Mockito 和 AssertJ 等熟悉的工具编写为标准 Java 测试。

  • 集成测试需要一个 Spring 应用程序上下文来运行。可以使用 @SpringBootTest 注解初始化一个完整的应用程序上下文,包括可选的嵌入式服务器,以进行测试。

  • 当测试仅关注应用程序的“切片”并且只需要部分配置时,Spring Boot 提供了几个注解以进行更精确的集成测试。当使用这些注解时,会初始化一个 Spring 应用程序上下文,但只加载特定功能切片使用的组件和配置部分。

  • @WebMvcTest 用于测试 Spring MVC 组件。

  • @JsonTest 用于测试 JSON 序列化和反序列化。

  • GitHub Actions 是 GitHub 提供的一个工具,用于声明用于自动化任务的管道(或工作流程)。它可以用来构建部署管道。

4 外部化配置管理

本章涵盖

  • 使用属性和配置文件配置 Spring

  • 使用 Spring Boot 应用外部配置

  • 使用 Spring Cloud Config Server 实现配置服务器

  • 使用 Spring Cloud Config Client 配置应用程序

在上一章中,我们构建了一个用于管理图书目录的 RESTful 应用程序。作为实现的一部分,我们定义了一些数据来配置应用程序的某些方面(在 application.yml 文件中),例如 Tomcat 线程池和连接超时。下一步可能是在不同的环境中部署应用程序:首先在测试环境中,然后是预发布,最后是生产环境。如果你需要为这些环境中的每一个都使用不同的 Tomcat 配置怎么办?你将如何实现这一点?

传统应用程序通常被打包成一个包,包括源代码和一系列包含不同环境数据的配置文件,通过运行时的一个标志来选择适当的配置。这意味着每次你需要更新特定环境的配置数据时,都必须创建一个新的应用程序构建。这个过程的一个变体是为每个环境创建不同的构建,这意味着你无法保证在预发布环境中运行的内容在生产环境中是否以相同的方式工作,因为它们是不同的工件。

配置 被定义为在部署之间可能需要更改的所有内容(根据 15-Factor 方法),如凭证、资源句柄和后端服务的 URL。在多个位置部署的应用程序可能在每个位置都有不同的需求,并需要不同的配置。云原生应用程序的一个关键方面是应用程序工件将在各个环境中保持不可变。无论你将其部署到哪个环境,应用程序构建都不会改变。

你部署的每个版本都是构建和配置的组合。相同的构建可以部署到具有不同配置数据的不同环境中,如图 4.1 所示。

04-01

图 4.1 每次部署的每个版本都是构建和配置的组合,这因环境而异。

任何可能需要在部署之间更改的内容都应该可配置。例如,你可能想要更改功能标志、访问后端服务的凭证、数据库的资源句柄或外部 API 的 URL,所有这些都取决于你部署应用程序的环境。云原生应用程序倾向于外部化配置,这样你就可以在不重新构建代码的情况下替换它。至于凭证,不将其与应用程序代码一起存储更为关键。由于公司不小心将凭证包含在公开的存储库中,已经发生了无数的数据泄露。确保你不是其中之一。

在 Spring 中,配置数据被抽象为定义在不同源中的属性(键/值对),例如属性文件、JVM 系统属性和系统环境变量。本章涵盖了在云原生环境中与配置相关的各个方面。我首先将介绍 Spring 处理配置背后的主要概念,包括属性和配置文件,以及如何使用 Spring Boot 应用外部化配置。然后,我将向您展示如何使用 Git 仓库作为后端存储配置数据来设置配置服务器。最后,您将学习如何通过依赖 Spring Cloud Config Client 来使用配置服务器配置 Spring Boot 应用。

到本章结束时,您将能够根据您的需求和拥有的配置数据类型以不同的方式配置您的云原生 Spring 应用。表 4.1 总结了本章中涵盖的为云原生应用定义配置数据的三种主要策略。第十四章将进一步扩展本章所涵盖的主题,包括密钥管理以及如何在 Kubernetes 中使用 ConfigMaps 和 Secrets。

注意:本章中示例的源代码可在 Chapter04/04-begin 和 Chapter04/04-end 文件夹中找到,这些文件夹包含项目的初始状态和最终状态(github.com/ThomasVitale/cloud-native-spring-in-action)。

表 4.1 云原生应用可以根据不同的策略进行配置。根据配置数据类型和应用需求,你可能会使用它们全部。

配置策略 特点
随应用程序打包的属性文件
  • 这些文件可以作为应用程序支持哪些配置数据的规范。

  • 这些对于定义合理的默认值非常有用,主要面向开发环境。

|

环境变量
  • 环境变量被任何操作系统支持,因此它们非常适合便携性。

  • 大多数编程语言都允许你访问环境变量。在 Java 中,你可以使用 System.getenv()方法访问它们。在 Spring 中,你也可以依赖 Environment 抽象。

  • 这些对于定义依赖于应用程序部署的基础设施和平台的配置数据非常有用,例如活动配置文件、主机名、服务名称和端口号。

|

配置服务
  • 提供配置数据持久性、审计和问责制。

  • 允许通过加密或专用密钥库进行密钥管理。

  • 这对于定义特定于应用程序的配置数据非常有用,例如连接池、凭证、功能标志、线程池以及第三方服务的 URL。

|

4.1 Spring 中的配置:属性和配置文件

术语 配置 可以根据上下文有不同的含义。当讨论 Spring 框架的核心特性和其 ApplicationContext 时,配置指的是哪些 bean(在 Spring 中注册的 Java 对象)被定义为由 Spring 容器管理,并在需要的地方注入。例如,您可以在 XML 文件(XML 配置)中定义 bean,在 @Configuration 类(Java 配置)中定义,或者通过依赖注解如 @Component(注解驱动配置)。

在本书中,除非另有说明,每次提到 配置 时,我都不指代先前的概念,而是指在部署之间可能发生变化的所有内容,如 15-Factor 方法所定义的。

Spring 为您提供了一个方便的环境抽象,无论配置数据的来源如何,都能让您访问任何配置数据。Spring 应用程序环境的关键两个方面是 属性配置文件。您已经在上一章中处理过属性。配置文件是用于标记逻辑分组中的 bean 或配置数据的一个工具,这些 bean 或配置数据在运行时只有当指定的配置文件被启用时才加载。图 4.2 展示了 Spring 应用程序环境的主要方面。

04-02

图 4.2 环境接口提供了访问任何 Spring 应用程序配置的两个关键方面:属性和配置文件。

本节将介绍云原生应用程序的属性和配置文件的基本方面,包括如何定义自定义属性以及何时使用配置文件。

4.1.1 属性:配置的关键/值对

属性是 Java 中作为一等公民支持的关键/值对,由 java.util.Properties 提供。它们在许多应用程序中扮演着至关重要的角色,用于在编译的 Java 代码之外存储配置参数。Spring Boot 会自动从不同的来源加载它们。当相同的属性在多个来源中定义时,有一些规则决定了哪个具有优先权。例如,如果您在属性文件和命令行参数中为 server.port 属性指定了值,则后者将优先于前者。以下是一些最常见的属性来源的优先级列表,从最高优先级开始:

  1. 测试类上的 @TestPropertySource 注解

  2. 命令行参数

  3. 来自 System.getProperties() 的 JVM 系统属性

  4. 来自 System.getenv() 的操作系统环境变量

  5. 配置数据文件

  6. @PropertySource 注解在 @Configuration 类上

  7. SpringApplication.setDefaultProperties 的默认属性

对于完整的列表,您可以参考 Spring Boot 文档(spring.io/projects/spring-boot)。

配置数据文件可以进一步按优先级排序,从最高优先级开始:

  1. 来自打包在 JAR 外部的 application-{profile}.properties 和 application-{profile}.yml 文件针对特定配置文件的应用程序属性

  2. 来自打包在 JAR 外部的 application.properties 和 application.yml 文件的应用程序属性

  3. 来自打包在 JAR 内部的 application-{profile}.properties 和 application-{profile}.yml 文件针对特定配置文件的应用程序属性

  4. 来自打包在 JAR 内部的 application.properties 和 application.yml 文件的应用程序属性

Spring 中属性处理的美妙之处在于,您不需要知道具体的属性源就能获取值:Environment 抽象让您可以通过统一的接口访问任何源中定义的属性。如果相同的属性在多个源中定义,它将返回优先级最高的那个。您甚至可以添加自己的自定义源并为它们分配优先级。

注意,Spring 框架内置了对按照 Properties 格式定义的属性的支撑。在此基础上,Spring Boot 还增加了使用 YAML 格式定义属性的支持。YAML 是 JSON 的超集,它比简单的 Properties 格式提供了更多的灵活性。官方网站将 YAML 描述为“一种适用于所有编程语言的、人性化的数据序列化语言” (yaml.org)。您可以在应用程序中自由选择任一方法。本书中的所有示例都将使用 YAML。

使用应用程序属性

有几种方法可以从 Java 类中访问属性,如图 4.3 所示。最通用的方法是基于 Environment 接口,您可以在需要访问应用程序属性的地方自动装配它。例如,您可以使用它来访问 server.port 属性的值,如下所示:

@Autowired
private Environment environment;

public String getServerPort() {
  return environment.getProperty("server.port");
}

04-03

图 4.3 您可以通过不同的方式访问 Spring 属性。

属性也可以在不显式调用 Environment 对象的情况下注入。就像您使用 @Autowired 注解来注入 Spring Bean 一样,您可以将 @Value 注解应用于注入属性值:

@Value("${server.port}")
private String serverPort;

public String getServerPort() {
  return serverPort;
}

您可以使用属性配置应用程序,而不需要在代码中硬编码值,这是我们的一项目标。但是,当使用 Environment 对象或 @Value 注解时,您仍然有一个硬编码的值,这可能变得难以管理:属性键。一个更健壮且易于维护的选项,也是 Spring 团队推荐的选项,是使用带有 @ConfigurationProperties 注解的特殊 Bean 来保存配置数据。我们将在下一节中探讨这个特性,同时您将学习如何定义自定义属性。

定义自定义属性

Spring Boot 随带了大量用于配置应用程序任何方面的属性,具体取决于您将哪个启动器依赖项导入到项目中。但迟早,您会发现您需要定义自己的属性。

让我们考虑一下我们一直在工作的目录服务应用程序。在第二章中,我们定义了一个 HTTP 端点,它向用户返回一个欢迎消息。我们现在有一个新的需求要实现:欢迎消息应该是可配置的。这可能不是最有用的功能,但它将帮助我展示不同的配置选项。

首件事是告诉 Spring Boot 扫描应用程序上下文以查找配置数据 bean。我们可以通过将 @ConfigurationPropertiesScan 注解添加到你的目录服务项目(catalog-service)中的 CatalogServiceApplication 类来实现这一点。

列表 4.1 启用配置数据 bean 的扫描

package com.polarbookshop.catalogservice;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties 
➥ .ConfigurationPropertiesScan; 

@SpringBootApplication
@ConfigurationPropertiesScan              ❶
public class CatalogServiceApplication {
  public static void main(String[] args) {
    SpringApplication.run(CatalogServiceApplication.class, args);
  }
}

❶ 在 Spring 上下文中加载配置数据 bean

注意:你不必让 Spring 扫描应用程序上下文以查找配置数据 bean,你可以直接使用 @EnableConfigurationProperties 注解来指定 Spring 应该考虑哪些 bean。

接下来,你可以定义一个新的 com.polarbookshop.catalogservice.config 包,并创建一个 PolarProperties 类,使用 @ConfigurationProperties 注解来标记它作为配置数据的持有者。@ConfigurationProperties 注解接受一个前缀参数,结合字段名,生成最终的属性键。Spring Boot 将尝试将具有该前缀的所有属性映射到类中的字段。在这种情况下,只有一个属性映射到该 bean:polar.greeting。可选地,你可以为每个属性添加描述,使用 JavaDoc 注释,这些注释可以转换为元数据,就像我稍后要展示的那样。

列表 4.2 在 Spring bean 中定义自定义属性

package com.polarbookshop.catalogservice.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "polar")    ❶
public class PolarProperties {
  /**
   * A message to welcome users.
   */
  private String greeting;                    ❷

  public String getGreeting() {
    return greeting;
  }

  public void setGreeting(String greeting) {
    this.greeting = greeting;
  }
}

❶ 将类标记为以“polar”前缀开始的配置属性的源

❷ 解析为 String 的自定义 polar.greeting(前缀 + 字段名)属性字段

可选地,你可以在你的 build.gradle 文件中添加一个新的依赖项,即 Spring Boot 配置处理器。这样,在构建项目时,它会自动为新的属性生成元数据,并将它们存储在 META-INF/spring-configuration-metadata.json 中。IDE 可以识别这些元数据,显示每个属性的描述信息,并帮助你进行自动补全和类型检查。记得在添加新依赖后刷新或重新导入 Gradle 依赖。

列表 4.3 添加 Spring Boot 配置处理器依赖项

configurations {        ❶
  compileOnly { 
    extendsFrom annotationProcessor 
  } 
 } 

dependencies {
  ...
  annotationProcessor  
  ➥ 'org.springframework.boot:spring-boot-configuration-processor' 
}

❶ 配置 Gradle 在构建项目时使用配置处理器

现在,你可以通过构建你的项目(./gradlew clean build)来触发元数据生成。在这个阶段,你可以在 application.yml 文件中为 polar.greeting 属性定义一个默认值。当你插入新属性时,你的 IDE 应该提供自动补全选项和类型检查,如图 4.4 所示。

列表 4.4 在目录服务中定义自定义属性的值

polar:
  greeting: Welcome to the local book catalog!

04-04

图 4.4 使用 Spring Boot 配置处理器,你的自定义属性 Bean 的 JavaDoc 注释被转换为 IDE 用于提供有用信息、自动完成和类型检查的元数据。

在列表 4.2 中,问候字段将被映射到 polar.greeting 属性,你已经在 application.yml 中为该属性定义了一个值。

使用自定义属性

使用@ConfigurationProperties 注解的类或记录是标准的 Spring Bean,因此你可以将它们注入到你需要的地方。Spring Boot 在启动时初始化所有配置 Bean,并通过任何支持的配置数据源提供的数据填充它们。在目录服务的情况下,数据将从 application.yml 文件中填充。

新的要求是使目录服务的根端点返回的欢迎消息可以通过 polar.greeting 属性进行配置。打开 HomeController 类,并更新处理方法以从自定义属性获取消息,而不是使用固定值。

列表 4.5 使用配置属性 Bean 的自定义属性

package com.polarbookshop.catalogservice;

import com.polarbookshop.catalogservice.config.PolarProperties;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HomeController {
  private final PolarProperties polarProperties;           ❶

  public HomeController(PolarProperties polarProperties) { 
    this.polarProperties = polarProperties; 
  } 

  @GetMapping("/")
  public String getGreeting() {
    return polarProperties.getGreeting();                  ❷
  }
}

❶ 通过构造函数自动装配注入自定义属性的 Bean

❷ 使用配置数据 Bean 的欢迎消息

现在,你可以构建并运行应用程序以验证它是否按预期工作(./gradlew bootRun)。然后打开一个终端窗口,向目录服务公开的根端点发送 GET 请求。结果应该是你在 application.yml 中为 polar.greeting 属性配置的消息:

$ http :9001/
Welcome to the local book catalog!

注意:与应用程序代码打包的属性文件对于定义配置数据的合理默认值很有用。它们还可以作为应用程序支持的配置属性规范的说明。

以下部分将介绍由 Spring 环境抽象建模的另一个关键方面:配置文件,以及如何使用它们来为云原生应用程序。在继续之前,你可以使用 Ctrl-C 停止应用程序。

4.1.2 配置文件:功能标志和配置组

有时候你可能希望在特定条件下将一个 Bean 加载到 Spring 上下文中。例如,你可能只想在你本地工作或测试应用程序时定义一个负责生成测试数据的 Bean。配置文件是逻辑上的 Bean 组,只有当指定的配置文件处于活动状态时,这些 Bean 才会被加载到 Spring 上下文中。Spring Boot 也将这一概念扩展到属性文件,允许你定义只有当特定配置文件处于活动状态时才会加载的配置数据组。

你可以同时激活零个、一个或多个配置文件。所有未分配给配置文件的 Bean 始终会被激活。分配给默认配置文件的 Bean 只有在没有其他配置文件处于活动状态时才会被激活。

本节在两个不同的用例的背景下介绍 Spring 配置文件:功能标志和配置组。

使用配置文件作为功能标志

配置文件的第一个用途是仅在指定的配置文件活动时加载豆类组。部署环境不应过多地影响分组背后的推理。一个常见的错误是使用像 dev 或 prod 这样的配置文件来条件性地加载豆类。如果你这样做,应用程序将与环境耦合,这对于云原生应用程序通常不是我们想要的。

考虑这样一个情况,你将应用程序部署到三个不同的环境(开发、测试和生产)并定义三个配置文件来条件性地加载某些豆类(dev、test 和 prod)。在某个时候,你决定添加一个预发布环境,你同样希望启用标记为 prod 配置文件的豆类。你该怎么办?你有两个选择。要么在预发布环境中激活 prod 配置文件(这并没有太多意义),要么更新源代码以添加预发布配置文件,并将其分配给标记为 prod 的豆类(这会阻止你的应用程序不可变且无需更改源代码即可部署到任何环境)。相反,我建议当配置文件与要条件加载的豆类组相关联时,将其用作功能标志。考虑配置文件提供的功能,并相应地命名它,而不是考虑它将在哪里启用。

你可能仍然有一些情况需要特定的平台中处理基础设施问题的豆类。例如,你可能有一些豆类,只有在应用程序部署到 Kubernetes 环境时才应该加载(无论它是预发布还是生产)。在这种情况下,你可以定义一个 kubernetes 配置文件。

在第三章中,我们构建了目录服务应用程序来管理书籍。每次你在本地运行它时,目录中还没有任何书籍,如果你想使用该应用程序,需要显式地添加一些。更好的选择是让应用程序在启动时生成一些测试数据,但仅在需要时(例如,在开发或测试环境中)。加载测试数据可以建模为一个可以通过配置启用或禁用的功能。你可以定义一个测试数据配置文件来切换此测试数据的加载。这样,你将保持配置文件与部署环境独立,并且你可以无任何约束地将其用作功能标志。让我们这样做。

首先,向你的目录服务项目添加一个新的 com.polarbookshop.catalogservice.demo 包,并创建一个 BookDataLoader 类。你可以通过应用@Profile 注解来指示 Spring 仅在测试数据配置文件活动时加载这个类。然后你可以使用我们在第三章中实现的 BookRepository 来保存数据。最后,@EventListener(ApplicationReadyEvent.class)注解将在应用程序完成启动阶段后触发测试数据生成。

列表 4.6 当测试数据配置文件激活时加载书籍测试数据

package com.polarbookshop.catalogservice.demo;

import com.polarbookshop.catalogservice.domain.Book;
import com.polarbookshop.catalogservice.domain.BookRepository;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Profile;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
@Profile("testdata")                                 ❶
public class BookDataLoader {
  private final BookRepository bookRepository;
  public BookDataLoader(BookRepository bookRepository) {
    this.bookRepository = bookRepository;
  }

  @EventListener(ApplicationReadyEvent.class)        ❷
  public void loadBookTestData() {
    var book1 = new Book("1234567891", "Northern Lights",
      "Lyra Silverstar", 9.90);
    var book2 = new Book("1234567892", "Polar Journey",
      "Iorek Polarson", 12.90);
    bookRepository.save(book1);
    bookRepository.save(book2);
  }
}

❶ 将类分配给测试数据配置文件。它只有在测试数据配置文件激活时才会被注册。

❷ 当发送 ApplicationReadyEvent 时触发测试数据生成——即应用程序启动阶段完成时。

在您的开发环境中,您可以使用 spring.profiles.active 属性将测试数据配置文件设置为激活状态。您可以在 Catalog Service 项目的 application.yml 文件中设置它,但默认启用测试数据功能并不是最佳选择。如果忘记在生产环境中覆盖它怎么办?更好的选择是在运行 bootRun 任务时专门为本地开发环境配置它。您可以通过在 build.gradle 文件中添加以下代码来实现这一点。

列表 4.7 定义开发环境的激活配置文件

bootRun {
  systemProperty 'spring.profiles.active', 'testdata'
}

让我们验证它是否工作。构建并运行应用程序(./gradlew bootRun)。您将在应用程序日志中看到一条消息,列出所有激活的配置文件(在这种情况下,只是 testdata,但可能有更多),如图 4.5 所示。

04-05

图 4.5 当“testdata”配置文件激活时 Catalog Service 的日志

然后,您可以向应用程序发送请求以获取目录中的所有书籍:

$ http :9001/books

应返回列表 4.6 中创建的测试数据。当您完成时,使用 Ctrl-C 停止应用程序。

注意:与其使用配置文件作为功能标志,不如定义自定义属性来配置功能,并依赖于如 @ConditionalOnProperty 和 @ConditionalOnCloudPlatform 这样的注解来控制何时将某些 bean 加载到 Spring 应用程序上下文中。这是 Spring Boot 自动配置的基础之一。例如,您可以定义一个 polar.testdata.enabled 自定义属性,并在 BookDataLoader 类上使用 @ConditionalOnProperty(name = "polar.testdata .enabled", havingValue = "true") 注解。

接下来,我将向您展示如何使用配置文件来分组配置数据。

使用配置文件作为配置组

Spring 框架的配置文件功能允许您仅在给定配置文件激活时注册一些 bean。同样,Spring Boot 允许您定义仅在特定配置文件激活时加载的配置数据。一种常见的方法是在以配置文件命名的属性文件中定义配置数据。在 Catalog Service 的例子中,您可以创建一个新的 application-dev.yml 文件,并定义 polar.greeting 属性的值,该值只有在 dev 配置文件激活时才会被 Spring Boot 使用。特定配置文件的属性文件优先于非特定属性文件,因此 application-dev.yml 中定义的值将优先于 application.yml 中的值。

在属性文件的情况下,配置文件用于分组配置数据,并且它们可以映射到部署环境,而不会遇到我们在上一节中分析使用配置文件作为功能标志时遇到的问题。但这仅在你没有将特定配置文件的属性文件捆绑到应用程序中时适用。15-Factor 方法建议不要将配置值批处理到以环境命名并捆绑到应用程序源代码中的组中,因为这不会扩展。随着项目的增长,可能会为不同的阶段创建新的环境;开发者可能会创建自己的自定义环境来尝试新功能。你可能会迅速拥有过多的配置组,就像实现 Spring 配置文件一样,并需要新的构建。相反,你希望将它们保留在应用程序外部,例如在由配置服务器提供服务的专用存储库中,正如你将在本章后面看到的那样。唯一的例外是默认值和面向开发的配置。

以下部分将介绍 Spring Boot 如何处理外部化配置。你将学习如何使用命令行参数、JVM 系统属性和环境变量从外部提供配置数据,同时使用相同的应用程序构建。

4.2 外部化配置:一次构建,多种配置

与应用程序源代码捆绑的属性文件对于定义一些合理的默认值很有用。然而,如果你需要根据环境提供不同的值,你将需要其他东西。外部化配置允许你根据应用程序部署的位置来配置应用程序,同时始终使用相同的不可变构建来构建应用程序代码。关键方面是你在构建和打包应用程序后不要更改应用程序。如果需要任何配置更改(例如,不同的凭证或数据库句柄),则从外部进行更改。

15-Factor 方法提倡在环境中存储配置,Spring Boot 提供了多种实现方式。你可以使用优先级较高的属性源来覆盖默认值,具体取决于应用程序部署的位置。在本节中,你将了解如何使用命令行参数、JVM 属性和环境变量来配置云原生应用程序,而无需重新构建它。图 4.6 展示了如何根据优先级规则覆盖 Spring 属性。

04-06

图 4.6 展示了 Spring Boot 根据优先级列表评估所有属性源。最终,每个属性都将具有从最高优先级源定义的值。

让我们考虑 Catalog Service 应用程序。首先,你需要将应用程序打包成一个 JAR 文件。你可以在终端窗口中这样做,导航到项目的根文件夹,并运行以下命令:

$ ./gradlew bootJar

这次我们不依赖 Gradle 来运行应用程序,因为我想要演示如何在使用相同的不可变 JAR 艺术品(即,不重新构建应用程序的情况下)更改应用程序配置。你可以将其作为标准 Java 应用程序运行:

$ java -jar build/libs/catalog-service-0.0.1-SNAPSHOT.jar

你还没有覆盖任何属性,因此根端点将返回在 application.yml 文件中定义的 polar.greeting 值:

$ http :9001/
Welcome to the local book catalog!

在接下来的几节中,你将了解如何为 polar .greeting 属性提供不同的值。记住在进入新的示例之前终止 Java 进程(Ctrl-C)。

4.2.1 通过命令行参数配置应用程序

默认情况下,Spring Boot 将任何命令行参数转换为属性键/值对,并将其包含在 Environment 对象中。在生产应用程序中,这是具有最高优先级的属性源。使用你之前构建的相同 JAR,你可以指定一个命令行参数来自定义应用程序配置:

$ java -jar build/libs/catalog-service-0.0.1-SNAPSHOT.jar \
    --polar.greeting="Welcome to the catalog from CLI"

命令行参数具有与 Spring 属性相同的名称,前面带有熟悉的 -- 用于 CLI 参数。这次应用程序将使用命令行参数中定义的消息,因为它比属性文件具有更高的优先级:

$ http :9001/
Welcome to the catalog from CLI

4.2.2 通过 JVM 系统属性配置应用程序

JVM 系统属性可以像命令行参数一样覆盖 Spring 属性,但它们的优先级较低。这全部是外部化配置的一部分,因此你不需要构建新的 JAR 艺术品——你仍然可以使用之前打包的那个。终止上一个示例中的 Java 进程(Ctrl-C),然后运行以下命令:

$ java -Dpolar.greeting="Welcome to the catalog from JVM" \
    -jar build/libs/catalog-service-0.0.1-SNAPSHOT.jar

JVM 系统属性具有与 Spring 属性相同的名称,前面带有 JVM 参数的常规 -D。这次应用程序将使用作为 JVM 系统属性定义的消息,因为它比属性文件具有更高的优先级:

$ http :9001/
Welcome to the catalog from JVM

如果你指定了 JVM 系统属性和 CLI 参数,会发生什么?优先级规则将确保 Spring 使用作为命令行参数指定的值,因为它比 JVM 属性具有更高的优先级。

再次终止上一个 Java 进程(Ctrl-C)并运行以下命令:

$ java -Dpolar.greeting="Welcome to the catalog from JVM" \
    -jar build/libs/catalog-service-0.0.1-SNAPSHOT.jar \
    --polar.greeting="Welcome to the catalog from CLI"

想象一下,结果将是以下内容:

$ http :9001/
Welcome to the catalog from CLI

CLI 参数和 JVM 属性都允许你外部化配置并保持应用程序构建不可变。然而,它们需要不同的命令来运行应用程序,这可能会导致部署时出现错误。一个更好的方法是使用环境变量,这是 15-Factor 方法论所推荐的。在进入下一节之前,终止当前的 Java 进程(Ctrl-C)。

4.2.3 通过环境变量配置应用程序

在操作系统中定义的环境变量通常用于外部化配置,并且根据 15-Factor 方法论,它们是推荐选项。环境变量的一个优点是每个操作系统都支持它们,这使得它们可以在任何环境中移植。此外,大多数编程语言都提供了访问环境变量的功能。例如,在 Java 中,你可以通过调用 System.getenv() 方法来实现。

在 Spring 中,你不需要明确地从周围系统中读取环境变量。Spring 在启动阶段自动读取它们,并将它们添加到 Spring 环境对象中,使它们可访问,就像任何其他属性一样。例如,如果你在一个定义了 MY_ENV_VAR 变量的环境中运行 Spring 应用程序,你可以通过环境接口或使用 @Value 注解来访问其值。

此外,Spring Boot 通过允许你使用环境变量来自动覆盖 Spring 属性来扩展 Spring 框架的功能。对于命令行参数和 JVM 系统属性,你使用了与 Spring 属性相同的命名约定。然而,环境变量有一些由操作系统规定的命名约束。例如,在 Linux 上,常见的语法是全部使用大写字母,单词之间用下划线分隔。

你可以通过将所有字母转换为大写,并用下划线替换任何点或破折号,将 Spring 属性键转换为环境变量。Spring Boot 将正确地将其映射到内部语法。例如,POLAR_GREETING 环境变量被识别为 polar.greeting 属性。这个特性被称为 宽松绑定

在目录服务应用程序中,你可以使用以下命令覆盖 polar.greeting 属性:

$ POLAR_GREETING="Welcome to the catalog from ENV" \
    java -jar build/libs/catalog-service-0.0.1-SNAPSHOT.jar

提示:在 Windows 上,你可以在 PowerShell 控制台中运行 $env:POLAR_GREETING="Welcome to the catalog from ENV"; java -jar build/libs/catalog-service-0.0.1-SNAPSHOT.jar 来达到相同的结果。

在目录服务启动阶段,Spring Boot 将读取周围环境中定义的变量,识别 POLAR_GREETING 可以映射到 polar.greeting 属性,并将其值存储在 Spring 环境对象中,覆盖 application.yml 中定义的值。结果如下:

$ http :9001/
Welcome to the catalog from ENV

在测试完应用程序后,使用 Ctrl-C 停止进程。如果你是从 Windows PowerShell 运行应用程序,请记住使用 Remove-Item Env:\POLAR_GREETING 来取消设置环境变量。

当你使用环境变量来存储配置数据时,你不需要更改运行应用程序的命令(就像你为 CLI 参数和 JVM 属性所做的那样)。Spring 将自动从其部署的上下文中读取环境变量。这种方法比使用 CLI 参数或 JVM 系统属性更不易出错且更稳定。

注意:你可以使用环境变量来定义依赖于应用程序部署的基础设施或平台的配置值,例如配置文件、端口号、IP 地址和 URL。

环境变量在虚拟机、OCI 容器和 Kubernetes 集群上无缝工作。然而,它们可能不足以满足需求。在下一节中,我将介绍影响环境变量的某些问题以及 Spring Cloud Config 如何帮助解决这些问题。

4.3 使用 Spring Cloud Config Server 进行集中式配置管理

使用环境变量,你可以将应用程序的配置外部化,并遵循 15-Factor 方法。然而,它们无法处理一些问题:

  • 配置数据与应用程序代码一样重要,因此它应该从持久化开始就得到同样的关注和重视。你应该在哪里存储配置数据?

  • 环境变量不提供细粒度的访问控制功能。你如何控制对配置数据的访问?

  • 配置数据将像应用程序代码一样演变并需要更改。你应该如何跟踪配置数据的修订?你应该如何审计发布中使用的配置?

  • 在更改配置数据后,你如何使应用程序在运行时读取它,而无需完全重启?

  • 当应用程序实例的数量增加时,以分布式方式为每个实例处理配置可能具有挑战性。你如何克服这些挑战?

  • Spring Boot 属性和环境变量都不支持配置加密,因此你不能安全地存储密码。你应该如何管理机密?

Spring 生态系统提供了许多选项来解决这些问题。我们可以将它们分为三类。

  • 配置服务——Spring Cloud 项目提供了模块,你可以使用它们来运行自己的配置服务并配置你的 Spring Boot 应用程序。

    • Spring Cloud Alibaba 使用 Alibaba Nacos 作为数据存储提供配置服务。

    • Spring Cloud Config 提供了一个配置服务,该服务由可插拔的数据源支持,例如 Git 仓库、数据存储或 HashiCorp Vault。

    • Spring Cloud Consul 使用 HashiCorp Consul 作为数据存储提供配置服务。

    • Spring Cloud Vault 使用 HashiCorp Vault 作为数据存储提供配置服务。

    • Spring Cloud Zookeeper 使用 Apache Zookeeper 作为数据存储提供配置服务。

  • 云服务提供商服务——如果你在云服务提供商提供的平台上运行应用程序,你可能考虑使用他们提供的配置服务之一。Spring Cloud 提供了与主要云服务提供商配置服务的集成,你可以使用它来配置你的 Spring Boot 应用程序。

    • Spring Cloud AWS 提供了与 AWS Parameter Store 和 AWS Secrets Manager 的集成。

    • Spring Cloud Azure 提供了与 Azure Key Vault 的集成。

    • Spring Cloud GCP 提供了与 GCP Secret Manager 的集成。

  • 云平台服务——当在 Kubernetes 平台上运行应用程序时,您可以使用 ConfigMaps 和 Secrets 无缝地配置 Spring Boot。

本节将向您展示如何使用 Spring Cloud Config 设置一个集中配置服务器,该服务器负责将存储在 Git 仓库中的配置数据传递给所有应用程序。第十四章将涵盖更高级的配置主题,包括密钥管理以及 Kubernetes 功能,如 ConfigMaps 和 Secrets。您将使用 Spring Cloud Config 的许多功能和模式很容易地应用于涉及配置服务和云供应商服务的其他解决方案。

注意:您选择的配置服务将取决于您的基础设施和需求。例如,假设您已经在 Azure 上运行工作负载,并且需要一个图形用户界面来管理配置数据。在这种情况下,使用 Azure Key Vault 而不是自己运行配置服务可能更有意义。如果您想使用 Git 来版本控制配置数据,Spring Cloud Config 或 Kubernetes ConfigMaps 和 Secrets 将是一个更好的选择。您甚至可以妥协,使用由 Azure 或 VMware Tanzu 等供应商提供的托管 Spring Cloud Config 服务。

集中配置的想法围绕两个主要组件构建:

  • 配置数据的数据存储,提供持久性、版本控制和可能访问控制

  • 位于数据存储之上以管理配置数据并将其提供给多个应用程序的服务器

想象一下,在多个不同的环境中部署了许多应用程序。一个配置服务器可以从集中位置管理所有这些应用程序的配置数据,并且这些配置数据可能以不同的方式存储。例如,您可以使用专门的 Git 仓库来存储非敏感数据,并使用 HashiCorp Vault 来存储您的机密。无论数据如何存储,配置服务器将通过统一的接口将其传递给不同的应用程序。图 4.7 显示了集中配置的工作方式。

04-07

图 4.7 一个集中配置服务器管理所有环境中许多应用程序的外部属性。

从图 4.7 中可以看出,配置服务器成为所有应用程序的后备服务,这意味着它可能存在单点故障的风险。如果它突然不可用,所有应用程序可能都无法启动。这种风险可以通过扩展配置服务器来轻松缓解,就像您对需要高可用性的其他应用程序所做的那样。在使用配置服务器时,至少部署两个副本是基本的。

注意:您可以使用集中式配置服务器来存储不依赖于特定基础设施或部署平台的配置数据,例如凭证、功能标志、第三方服务的 URL、线程池和超时。

我们将使用 Spring Cloud Config Server 为 Polar Bookshop 系统设置一个集中式配置服务器。该项目还提供了一个客户端库(Spring Cloud Config Client),您可以使用它将 Spring Boot 应用程序与配置服务器集成。

让我们先定义一个用于存储配置数据的仓库。

4.3.1 使用 Git 存储您的配置数据

配置服务器将负责为 Spring 应用程序提供配置数据。在设置之前,我们需要一种存储和跟踪这些数据的方法。Spring Cloud Config Server 与许多不同的后端解决方案集成,以存储配置数据。最常见的选项之一是 Git 仓库。

首先,创建一个新的 config-repo Git 仓库(对于最终结果,您可以参考第四章/04-end/config-repo)。该仓库可以是本地的或远程的,但在这个例子中,我建议在 GitHub 上初始化一个远程仓库,就像您为应用程序仓库所做的那样。我使用 main 作为默认分支名称。

在配置仓库内部,您可以直接以 *.properties 或 *.yml 文件格式存储属性。

继续使用目录服务示例,让我们定义一个用于欢迎信息的外部属性。导航到 config-repo 文件夹,并创建一个 catalog-service.yml 文件。然后为 Catalog 服务使用的 polar.greeting 属性定义一个值。

列表 4.8 定义当使用配置服务器时的新消息

polar:
  greeting: "Welcome to the catalog from the config server"

接下来,创建一个 catalog-service-prod.yml 文件,并为 polar.greeting 属性定义一个不同的值,仅在 prod 配置文件活跃时使用。

列表 4.9 定义当 prod 配置文件活跃时的新消息

polar:
  greeting: "Welcome to the production catalog from the config server"

最后,将您的更改提交并推送到远程仓库。

Spring Cloud Config 如何为每个应用程序解析正确的配置数据?您应该如何组织仓库以托管多个应用程序的属性?该库依赖于三个参数来识别用于配置特定应用程序的属性文件:

  • {application}—由 spring.application.name 属性定义的应用程序名称。

  • {profile}—由 spring.profiles.active 属性定义的活跃配置文件之一。

  • {label}—由特定的配置数据仓库定义的区分器。在 Git 的情况下,它可以是标签、分支名称或提交 ID。它对于识别配置文件的版本化集合很有用。

根据您的需求,您可以使用不同的组合来组织文件夹结构,例如这些:

/{application}/application-{profile}.yml
/{application}/application.yml
/{application}-{profile}.yml
/{application}.yml
/application-{profile}.yml
/application.yml

对于每个应用程序,你可以使用以应用程序本身命名的属性文件,并将其放置在根目录中(例如,/catalog-service.yml 或 /catalog-service-prod.yml),或者使用默认命名并将它们放在以应用程序命名的子目录中(例如,/catalog-service/application.yml 或 /catalog-service/application-prod.yml)。

你也可以将 application.yml 或 application-{profile}.yml 文件放在根目录中,为所有应用程序定义默认值。当没有更具体的属性源时,它们可以用作后备。Spring Cloud Config Server 将始终返回最具体路径的属性,使用应用程序名称、活动配置文件和 Git 标签。

当使用 Git 作为配置服务后端时,标签 概念特别有趣。例如,你可以为不同的环境创建配置仓库的长久分支,或者在测试特定功能时创建短暂分支。Spring Cloud Config Server 可以使用标签信息从正确的 Git 分支、标签或提交 ID 返回正确的配置数据。

现在你已经为配置数据设置了一个 Git 仓库,是时候设置一个配置服务器来管理它们了。

4.3.2 设置配置服务器

Spring Cloud Config Server 是一个项目,让你以最小的努力设置配置服务器。它是一个具有特定属性的标准化 Spring Boot 应用程序,这些属性启用了配置服务器功能以及将 Git 仓库作为配置数据后端。Polar Bookshop 系统将使用此服务器为 Catalog Service 应用程序提供配置。图 4.8 展示了解决方案的架构。

04-08

图 4.8 由 Git 仓库支持的集中式配置服务器为 Catalog Service 应用程序提供配置。

现在,让我们看看代码。

项目引导

Polar Bookshop 系统需要一个配置服务应用程序来提供集中式配置。你可以从 Spring Initializr (start.spring.io/) 初始化项目,并将结果存储在一个新的 config-service Git 仓库中。初始化的参数如图 4.9 所示。

04-09

图 4.9 从 Spring Initializr 初始化 Config Service 项目的参数

小贴士:你可能更喜欢避免通过 Spring Initializr 网站手动生成项目。在本章的开始文件夹中,你可以找到一个可以在终端窗口中运行的 curl 命令,用于下载包含所有启动所需代码的 zip 文件。

在生成的 build.gradle 文件中,你可以看到 Spring Cloud 依赖项的管理方式与 Spring Boot 不同。所有 Spring Cloud 项目都遵循一个独立的发布列车,该列车依赖于一个物料清单(BOM)来管理所有依赖项。Spring Cloud 发布列车以年份命名(例如,2021.0.3),而不是采用语义版本策略(例如,Spring Boot 版本是 2.7.3)。

列表 4.10 Config Service 的 Gradle 配置

plugins {
  id 'org.springframework.boot' version '2.7.3'
  id 'io.spring.dependency-management' version '1.0.13.RELEASE'
  id 'java'
}
group = 'com.polarbookshop'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

repositories {
  mavenCentral()
}

ext {
  set('springCloudVersion', "2021.0.3")                ❶
}

dependencies {
  implementation 'org.springframework.cloud:spring-cloud-config-server'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

dependencyManagement {
  imports {
    mavenBom "org.springframework.cloud:
➥spring-cloud-dependencies:${springCloudVersion}"     ❷
  }
}

tasks.named('test') {
  useJUnitPlatform()
}

❶ 定义要使用的 Spring Cloud 版本

❷ Spring Cloud 依赖项管理的 BOM

这些是主要的依赖项:

  • Spring Cloud Config Server(org.springframework.cloud:spring-cloud-config-server)—提供库和实用工具,在 Spring Web 上构建配置服务器。

  • Spring Boot Test(org.springframework.boot:spring-boot-starter-test)—提供了一些库和实用工具来测试应用程序,包括 Spring Test、JUnit、AssertJ 和 Mockito。它自动包含在每一个 Spring Boot 项目中。

启用配置服务器

将你之前初始化的项目转变为一个功能性的配置服务器不需要太多步骤。在 Java 中,你只需要在配置类(如 ConfigServiceApplication)上添加 @EnableConfigServer 注解。

列表 4.11 在 Spring Boot 中启用配置服务器

package com.polarbookshop.configservice;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.config.server.EnableConfigServer; 

@SpringBootApplication
@EnableConfigServer                         ❶
public class ConfigServiceApplication {
  public static void main(String[] args) {
    SpringApplication.run(ConfigServiceApplication.class, args);
  }
}

❶ 在 Spring Boot 应用程序中激活配置服务器实现

Java 端就到这里了。

配置配置服务器

下一步是配置配置服务器的行为。没错,即使是配置服务器也需要配置!首先,Spring Cloud Config Server 在一个嵌入的 Tomcat 服务器上运行,因此你可以像为 Catalog Service 配置的那样配置连接超时和线程池。

你之前初始化了一个 Git 仓库来托管配置数据,因此你现在应该指导 Spring Cloud Config Server 在哪里找到它。你可以在位于 Config Service 项目 src/main/resources 路径的应用程序.yml 文件中这样做(将自动生成的 application.properties 文件重命名为 application.yml)。

spring.cloud.config.server.git.uri 属性应指向你定义的配置仓库位置。如果你跟随我做的步骤,它将在 GitHub 上,默认分支将被称为 main。你可以通过设置 spring.cloud.config.server.git .default-label 属性来配置配置服务器默认应考虑的分支。记住,当使用 Git 仓库时,标签概念是 Git 分支、标签或提交 ID 的抽象。

列表 4.12 配置配置服务器和配置仓库之间的集成

server:
  port: 8888                                     ❶
  tomcat:
    connection-timeout: 2s
    keep-alive-timeout: 15s
    threads:
      max: 50
      min-spare: 5

spring:
  application:
    name: config-service                         ❷
  cloud:
    config:
      server:
        git:
          uri: <your-config-repo-github-url>     ❸
          default-label: main                    ❹

❶ Config Service 应用程序将监听的端口

❷ 当前应用程序的名称

❸ 用于配置数据后端的远程 Git 仓库的 URL。例如,https://github.com/PolarBookshop/config-repo。

❹ 默认情况下,服务器将从“main”分支返回配置数据。

警告:我用于配置服务的配置假设配置仓库在 GitHub 上公开可用。当您使用私有仓库(这在实际应用中通常是真实的)时,您需要通过使用额外的配置属性来指定如何通过代码仓库提供商进行身份验证。有关更多信息,请参阅官方 Spring Cloud Config 文档(spring.io/projects/spring-cloud-config)。我将在第十四章进一步讨论处理凭证的问题。

4.3.3 使配置服务器具有弹性

配置服务可能会成为您系统中的单点故障。如果所有应用程序都依赖于它来获取配置数据,您需要确保它具有高可用性。实现这一目标的第一步是在生产环境中部署多个配置服务实例。如果其中一个因某种原因停止工作,另一个副本可以提供所需的配置。在第七章中,您将了解更多关于扩展应用程序以及如何在 Kubernetes 中实现这一点的内容。

然而,仅扩展配置服务是不够的。由于它使用远程 Git 仓库作为配置数据后端,您还需要使这种交互更加弹性。首先,您可以定义一个超时时间,以防止配置服务器等待太长时间与远程仓库建立连接。您可以通过设置 spring.cloud.config.server.git.timeout 属性来实现这一点。

Spring Cloud Config 在首次请求配置数据时会在本地克隆远程仓库。我建议使用 spring.cloud.config.server.git.clone-on-start 属性,以便在启动时进行仓库克隆。尽管这会使启动阶段稍微慢一些,但如果与远程仓库通信有任何困难,它会使部署更快地失败,而不是等待第一次请求发现有问题。此外,它还会使客户端的第一次请求更快。

仓库的本地副本提高了配置服务器的容错能力,因为它确保即使在与远程仓库的通信暂时失败的情况下(例如,GitHub 宕机或网络问题),它也能向客户端应用程序返回配置数据。然而,如果配置服务器尚未在本地克隆仓库,则没有设置回退机制。这就是为什么在启动时快速失败并立即调查问题会更好。

当成功创建本地仓库副本时,本地仓库可能会独立于远程仓库发生变化。你可以通过设置 spring.cloud.config.server.git.force-pull 属性来确保配置服务器始终使用远程仓库中定义的相同数据,这样当本地副本损坏时,就会拉取一个新的副本,并且任何本地更改都会被丢弃。默认情况下,本地仓库是在一个随机命名的文件夹中克隆的。如果需要,你可以通过 spring.cloud.config.server.git .basedir 属性来控制其克隆位置。对于配置服务,我们将依赖默认行为。

你可以按照以下方式更新 Config Service 应用程序的应用程序.yml 文件,并使其对影响与代码仓库服务(在本例中为 GitHub)交互的故障具有更强的容错能力。

列表 4.13 使配置服务更具容错能力

spring:
  application:
    name: config-service
  cloud:
    config:
      server:
        git:
          uri: <your-config-repo-github-url>
          default-label: main
          timeout: 5               ❶
          clone-on-start: true     ❷
          force-pull: true         ❸

❶ 与远程仓库建立连接的时间限制

❷ 在启动时在本地克隆远程仓库

❸ 强制拉取远程仓库并丢弃任何本地更改

在下一节中,我们将验证配置服务是否正常工作。

4.3.4 理解配置服务器 REST API

Spring Cloud Config Server 与 Spring Boot 应用程序无缝协作,通过 REST API 提供其原生格式的属性。你可以相当容易地尝试它。构建并运行 Config Service(./gradlew bootRun),打开一个终端窗口,并对 /catalog-service/default 发起 HTTP GET 请求:

$ http :8888/catalog-service/default

结果是当没有激活任何 Spring 配置文件时返回的配置。你可以尝试以下方式获取当 prod 配置文件激活时的配置:

$ http :8888/catalog-service/prod

如图 4.10 所示,结果是为 Catalog Service 应用程序在 catalog-service.yml 和 catalog-service-prod.yml 中定义的配置,其中后者优先于前者,因为指定了 prod 配置文件。

04-10

图 4.10 配置服务器通过 REST API 暴露了一个基于应用程序名称、配置文件和标签获取配置数据的接口。此图像显示了 /catalog-service/prod 端点的结果。

当你完成应用程序的测试后,使用 Ctrl-C 停止其执行。

Spring Cloud Config Server 通过一系列端点使用不同的组合方式暴露属性,包括 {application}、{profile} 和 {label} 参数:

/{application}/{profile}[/{label}]
/{application}-{profile}.yml
/{label}/{application}-{profile}.yml
/{application}-{profile}.properties
/{label}/{application}-{profile}.properties

当使用 Spring Cloud Config Client 时,你不需要从应用程序中调用这些端点(它为你做了),但了解服务器如何暴露配置数据是有用的。使用 Spring Cloud Config Server 构建的配置服务器暴露了一个标准 REST API,任何应用程序都可以通过网络访问。你可以使用相同的服务器为使用其他语言和框架构建的应用程序提供服务,并直接使用 REST API。

在第十四章,我将讨论如何处理配置的更多方面。例如,Spring Cloud Config 在将属性存储在 Git 仓库之前,具有加密包含机密属性的功能。此外,可以使用多个后端解决方案作为配置数据仓库,这意味着您可以将所有非敏感属性保存到 Git 中,并使用 HashiCorp Vault 来存储机密。此外,REST API 本身应该受到保护,我还会讨论这一点。我将从安全角度讨论所有这些关键方面——在部署到生产之前考虑这些问题是必要的。

现在,让我们完成我们的解决方案,并更新目录服务以集成配置服务应用程序。

4.4 使用 Spring Cloud Config Client 配置服务器

在上一节中构建的配置服务应用程序是一个通过 REST API 公开配置的服务器。通常,应用程序会与该 API 交互,但您可以使用 Spring Cloud Config Client 为 Spring 应用程序提供服务。

本节将指导您如何使用 Spring Cloud Config Client 并将目录服务与配置服务器集成。您将了解如何使交互更加健壮,以及当新更改推送到配置仓库时如何刷新客户端的配置。

4.4.1 设置配置客户端

要将 Spring Boot 应用程序与配置服务器集成,您首先需要为 Spring Cloud Config Client 添加一个新的依赖项。按照以下方式更新目录服务项目(catalog-service)的 build.gradle 文件。请记住,在添加新内容后,刷新或重新导入 Gradle 依赖项。

列表 4.14 为 Spring Cloud Config Client 添加依赖

ext { 
  set('springCloudVersion', "2021.0.3") 
} 

dependencies {
  ...
  implementation 'org.springframework.cloud:spring-cloud-starter-config' 
}

dependencyManagement { 
  imports { 
    mavenBom "org.springframework.cloud: 
    ➥ spring-cloud-dependencies:${springCloudVersion}" 
  } 
} 

现在,我们需要指示目录服务从配置服务获取其配置。您可以通过传递 configserver: 作为属性值通过 spring.config.import 属性来实现。当与目录服务之类的客户端应用程序一起工作时,您可能不希望在本地环境中运行配置服务器,在这种情况下,您可以使用 optional: 前缀来使交互可选(可选:configserver:)。如果启动目录服务时配置服务器没有运行,应用程序将记录一条警告,但不会停止工作。请注意,在生产环境中不要使此选项可选,否则您可能会使用错误的配置。

接下来,目录服务需要知道联系配置服务的 URL。您有两个选择。您可以将它添加到 spring.config.import 属性中(可选:configserver:http://localhost:8888)或者依赖于更具体的 spring.cloud.config.uri 属性。我们将使用第二种选项,这样我们只需要在部署应用程序到不同环境时更改 URL 值。

由于配置服务器使用应用程序名称来返回正确的配置数据,你还需要设置 spring.application.name 属性为 catalog-service。还记得 {application} 参数吗?那里就是 spring .application.name 值被使用的地方。

打开你的 Catalog Service 项目的 application.yml 文件并应用以下配置。

列表 4.15 指示 Catalog Service 从 Config Service 获取配置

spring:
  application: 
    name: catalog-service               ❶
  config: 
    import: "optional:configserver:"    ❷
  cloud: 
    config: 
      uri: http://localhost:8888       ❸

❶ 应用程序名称,由配置服务器用于过滤配置

❷ 当可用时从配置服务器导入配置数据

❸ 配置服务器的 URL

让我们继续验证它是否正确工作。Catalog Service 应用程序包含一个 polar.greeting 属性,其值为“欢迎使用本地图书目录!”当使用配置服务器时,集中式属性优先于本地属性,因此将使用在 config-repo 仓库中定义的值。

首先,运行 Config Service(./gradlew bootRun)。然后打包 Catalog Service 为 JAR 文件(./gradlew bootJar)并按以下方式运行:

$ java -jar build/libs/catalog-service-0.0.1-SNAPSHOT.jar

然后,在另一个终端窗口中,向根端点发送一个 GET 请求:

$ http :9001/
Welcome to the catalog from the config server!

如预期,应用程序返回的欢迎消息是在 config-repo 仓库中定义的,具体在 catalog-service.yml 文件中。

你还可以尝试启用 prod 配置文件运行应用程序。使用 Ctrl-C 停止 Catalog Service,然后以 prod 作为活动配置文件重新启动应用程序:

$ java -jar build/libs/catalog-service-0.0.1-SNAPSHOT.jar \
    --spring.profiles.active=prod

预期的结果是现在使用 config-repo 仓库中 catalog-service-prod.yml 文件中定义的消息:

$ http :9001/
Welcome to the production catalog from the config server

再次使用 Ctrl-C 停止之前应用的执行。

下一个部分将介绍如何使应用程序与配置服务器之间的交互更加容错。

4.4.2 使配置客户端更加健壮

当与配置服务器的集成不是可选的时,如果应用程序无法联系到配置服务器,它将无法启动。如果服务器正在运行,你可能会因为交互的分布式特性而遇到问题。因此,定义一些超时以使应用程序更快地失败是一个好主意。你可以使用 spring.cloud.config.request-connect-timeout 属性来控制与配置服务器建立连接的时间限制。spring.cloud.config.request-read-timeout 属性让你可以限制从服务器读取配置数据所花费的时间。

打开你的 Catalog Service 项目的 application.yml 文件,并应用以下配置以使与 Config Service 的交互更加健壮。再次强调,设置超时没有通用的规则。根据你的架构和基础设施特性,你可能需要调整这些值。

列表 4.16 使 Spring Cloud Config 客户端更加健壮

spring:
  application:
    name: catalog-service
  config:
    import: "optional:configserver:"
  cloud:
    config:
      uri: http://localhost:8888
      request-connect-timeout: 5000     ❶
      request-read-timeout: 5000        ❷

❶ 等待连接到配置服务器的超时时间(毫秒)

❷ 等待从配置服务器读取配置数据的超时时间(毫秒)

即使配置服务被复制,当客户端应用程序(如目录服务)启动时,仍然有可能暂时不可用。在这种情况下,您可以利用 重试 模式,并配置应用程序在放弃并失败之前再次尝试连接到配置服务器。Spring Cloud Config 客户端的重试实现基于 Spring Retry,因此您需要将一个新的依赖项添加到目录服务项目的 build.gradle 文件中。请记住,在添加新内容后刷新或重新导入 Gradle 依赖项。

列表 4.17 在目录服务中添加 Spring Retry 依赖

dependencies {
  ...
  implementation 'org.springframework.retry:spring-retry' 
}

在第八章中,我将详细解释重试模式。现在,我将向您展示如何配置目录服务,使其在失败之前尝试连接到配置服务几次(spring.cloud.config.retry.max-attempts)。每次连接尝试都会根据指数退避策略延迟,该策略的计算方式为当前延迟乘以 spring.cloud.config.retry.multiplier 属性的值。初始延迟由 spring.cloud.config.retry.initial-interval 配置,并且每次延迟不能超过 spring.cloud.config.retry.max-interval 的值。

您可以将重试配置添加到目录服务项目中的 application.yml 文件。

列表 4.18 将重试模式应用于 Spring Cloud Config 客户端

spring:
  application:
    name: catalog-service
  config:
    import: "optional:configserver:"
  cloud:
    config:
      uri: http://localhost:8888
      request-connect-timeout: 5000
      request-read-timeout: 5000
      fail-fast: true                ❶
      retry: 
        max-attempts: 6              ❷
        initial-interval: 1000       ❸
        max-interval: 2000           ❹
        multiplier: 1.1              ❺

❶ 将连接到配置服务器的失败设置为致命错误

❷ 最大尝试次数

❸ 退避的初始重试间隔(毫秒)

❹ 退避的最大重试间隔(毫秒)

❺ 计算下一个间隔的乘数

只有当 spring.cloud.config.fail-fast 属性设置为 true 时,才会启用重试行为。如果配置服务器宕机,您可能不想在本地环境中进行重试,尤其是考虑到我们将其设置为可选的后端服务。您可以自由测试当配置服务器宕机时重试连接的应用程序行为,但请记住,如果您想在本地环境中保持其为可选,请将 fail-fast 属性设置回 false。在生产环境中,您可以使用本章中介绍的一种策略将其设置为 true。测试完应用程序后,使用 Ctrl-C 停止它们。

您现在可以使用配置服务来配置您想要的任何应用程序。然而,还有一个方面我还没有涉及。我们如何能够在运行时更改配置呢?

4.4.3 在运行时刷新配置

当将新更改推送到支持 Config 服务的 Git 仓库时会发生什么?对于标准的 Spring Boot 应用程序,当您更改属性(无论是在属性文件中还是在环境变量中)时,您必须重新启动它。然而,Spring Cloud Config 给您在运行时刷新客户端应用程序配置的可能性。每当将新更改推送到配置仓库时,您都可以向与配置服务器集成的所有应用程序发出信号,它们将重新加载受配置更改影响的部分。Spring Cloud Config 提供了不同的选项来实现这一点。

在本节中,我将向您展示一个简单的刷新选项,即向运行的 Catalog Service 实例发送特殊的 POST 请求以触发已更改的配置数据的重新加载(热重载)。图 4.11 展示了它是如何工作的。

04-11

图 4.11 在更改支持 Config 服务的 Git 仓库中的配置后,向 Catalog Service 发送信号以刷新使用配置的应用程序部分。

这种功能是第二章中介绍的 15-Factor 方法论中描述的 管理流程 之一。在这种情况下,管理流程的策略是将其嵌入到应用程序本身中,通过调用特定的 HTTP 端点来激活它。

注意:在生产环境中,您可能需要一个比显式触发每个应用程序实例更自动化和高效的方式来刷新配置。当远程 Git 仓库支持配置服务器时,您可以在配置服务器上配置一个 webhook,每当将新更改推送到仓库时,它会自动通知配置服务器。反过来,配置服务器可以通过使用 Spring Cloud Bus 这样的消息代理来通知所有客户端应用程序。第十四章将涵盖更多关于生产环境中配置刷新的场景。

启用配置刷新

在将新的配置更改提交并推送到远程 Git 仓库后,您可以通过一个特定的端点向客户端应用程序发送 POST 请求,这将触发应用程序上下文中的 RefreshScopeRefreshedEvent。您可以通过在 Catalog Service 项目的 build.gradle 文件中添加一个新的依赖项来依赖 Spring Boot Actuator 项目公开刷新端点。请记住,在添加新内容后刷新或重新导入 Gradle 依赖项。

列表 4.19 在 Catalog Service 中添加 Spring Boot Actuator 依赖项

dependencies {
  ...
  implementation 'org.springframework.boot:spring-boot-starter-actuator' 
}

Spring Boot Actuator 库配置了一个 /actuator/refresh 端点,该端点触发刷新事件。默认情况下,该端点未公开,因此您必须在 Catalog Service 项目(catalog-service)的应用程序.yml 文件中显式启用它。

列表 4.20 使 Spring Boot Actuator 公开刷新端点

management:
  endpoints:
    web:
      exposure:
        include: refresh    ❶

❶ 通过 HTTP 公开 /actuator/refresh 端点

注意:我将在第十三章中详细讨论 Spring Boot Actuator,所以如果你不完全理解前面的配置,请不要担心。现在,你只需要知道 Spring Boot Actuator 提供了许多用于在生产环境中监控和管理应用程序的有用端点。

如果没有组件监听,刷新事件 RefreshScopeRefreshedEvent 将没有任何效果。你可以在任何你希望刷新时重新加载的 bean 上使用@RefreshScope 注解。这里有个好消息:由于你通过@ConfigurationProperties bean 定义了自定义属性,它默认已经监听 RefreshScopeRefreshedEvent,所以你不需要对你的代码进行任何更改。当触发刷新时,PolarProperties bean 将使用最新的配置重新加载。让我们看看它是否工作。

运行时更改配置

在本节的最后,我将向你展示如何运行时更改配置。首先,确保配置服务和目录服务都在运行(./gradlew bootRun)。然后打开托管配置数据的 config-repo 仓库,并在 config-repo/catalog-service.yml 文件中更改 polar.greeting 属性的值。

列表 4.21 在配置仓库中更新欢迎信息值

polar:
  greeting: "Welcome to the catalog from a fresh config server" 

然后,提交并推送更改。

配置服务现在将返回新的属性值。你可以通过运行 http :8888/catalog-service/default 命令来检查这一点。然而,尚未向目录服务发送任何信号。如果你尝试运行 http :9001/命令,你仍然会得到旧的“欢迎来到配置服务器目录”消息。让我们触发一个刷新。

继续发送一个 POST 请求到位于/actuator/refresh 端点的目录服务应用程序:

$ http POST :9001/actuator/refresh

这个请求将触发一个 RefreshScopeRefreshedEvent 事件。由于 PolarProperties bean 上注有@ConfigurationProperties,它将响应事件并读取新的配置数据。让我们验证一下:

$ http :9001/
Welcome to the catalog from a fresh config server

最后,使用 Ctrl-C 停止两个应用程序的执行。

干得好!你刚刚在运行时更新了应用程序的配置,而没有重新启动它,也没有重新构建应用程序,并确保了变更的可追溯性。这对于云环境来说非常完美。在第十四章中,你将学习更多在生产环境中管理配置的高级技术,包括密钥管理、ConfigMaps 和 Secrets。

摘要

  • Spring 环境抽象提供了一个统一的接口,用于访问属性和配置文件。

  • 属性是用于存储配置的键/值对。

  • 配置文件是逻辑组,仅在特定配置文件激活时注册的 bean。

  • Spring Boot 根据优先级规则从不同的来源收集属性。从最高优先级到最低优先级,属性可以在命令行参数、JVM 系统变量、OS 环境变量、特定配置文件的属性文件和通用属性文件中定义。

  • Spring Bean 可以通过使用 @Value 注解注入值,或通过使用 @ConfigurationProperties 注解映射到一组属性的 Bean 来访问 Environment 对象中的属性。

  • 可以使用 spring.profiles.active 属性来定义活动配置文件。

  • @Profile 注解标记了仅在指定配置文件活动时才应考虑的 Bean 或配置类。

  • 在 Spring Boot 中管理的属性,提供了由 15-Factor 方法定义的外部化配置,但这还不够。

  • 配置服务器处理诸如密钥加密、配置可追溯性、版本控制和运行时上下文刷新等方面,无需重启即可完成。

  • 可以使用 Spring Cloud Config Server 库来设置配置服务器。

  • 配置本身可以根据不同的策略进行存储,例如在专门的 Git 仓库中。

  • 配置服务器使用应用程序名称、活动配置文件和 Git 特定标签来识别应向哪个应用程序提供哪个配置。

  • 可以使用 Spring Cloud Config Client 库通过配置服务器配置 Spring Boot 应用程序。

  • @ConfigurationProperties Bean 被配置为监听 RefreshScopeRefreshedEvent 事件。

  • 在将新更改推送到配置仓库后,可以触发 RefreshScopeRefreshedEvent 事件,以便客户端应用程序使用最新的配置数据重新加载上下文。

  • Spring Boot Actuator 定义了一个 /actuator/refresh 端点,您可以使用它来手动触发事件。

5 在云中持久化和管理数据

本章涵盖

  • 理解云原生系统中的数据库

  • 使用 Spring Data JDBC 实现数据持久性

  • 使用 Spring Boot 和 Testcontainers 测试数据持久性

  • 使用 Flyway 在生产中管理数据库

在第一章中,我在云原生系统中区分了应用服务和数据服务。到目前为止,我们已经与应用服务合作,这些服务应该是无状态的,以便在云环境中良好运行。然而,如果应用不存储任何状态或数据,它们大多数都是无用的。例如,我们在第三章中构建的目录服务应用没有持久化存储机制,因此你实际上无法用它来管理书籍目录。一旦你关闭它,你添加到目录中的所有书籍都将消失。由于具有状态,你甚至无法水平扩展应用。

状态是你关闭服务并启动新实例时应保留的一切。数据服务是系统的有状态组件。例如,它们可以是像 PostgreSQL、Cassandra 和 Redis 这样的数据存储,也可以是像 RabbitMQ 和 Apache Kafka 这样的消息系统。

本章将介绍云原生系统的数据库以及云中持久化数据的主要方面。我们将依靠 Docker 在本地环境中运行 PostgreSQL,但在生产中我们将用云平台提供的托管服务来替换它。然后我们将使用 Spring Data JDBC 向目录服务添加数据持久化层。最后,我将涵盖一些关于使用 Flyway 在生产中管理和演进数据库的常见问题。

注意:本章中示例的源代码可在 GitHub 上的 Chapter05/05-begin、Chapter05/05-intermediate 和 Chapter05/05-end 文件夹中找到,包含项目的初始、中间和最终状态(github.com/ThomasVitale/cloud-native-spring-in-action)。

5.1 云原生系统的数据库

数据可以以多种方式存储。传统上,你可能倾向于使用单个大型数据库服务器来尽可能多地保存数据,因为获取一个新的服务器既昂贵又耗时。根据你组织的流程,这样的任务可能需要几天到几个月。但在云中不是这样。

云提供了弹性、自助和按需配置功能——这些是迁移你的数据服务到那里的强大动力。对于你设计的每个云原生应用,你应该考虑最适合其生成数据的存储类型。然后你的云平台应该允许你通过 API 或图形用户界面来配置它。曾经非常耗时的一项任务现在只需要几分钟。例如,在 Azure 上部署 PostgreSQL 数据库服务器的实例就像运行 az postgres server create 命令一样简单。

云原生应用的设计是为了无状态,这是云本身的特性。它是一个动态的基础设施,计算节点可以分布在不同的集群、地理区域和云中。应用存储状态的问题很明显。状态在这样的分布式和动态环境中如何生存?这就是我们希望保持应用无状态的原因。

然而,我们需要在云中实现有状态。本节将介绍云中数据服务和持久性管理的挑战,并描述你的选项,取决于你是否想自己管理数据服务或依赖云提供商的某些服务。然后我将指导你设置一个 PostgreSQL 数据库实例作为本地环境的容器。

5.1.1 云中的数据服务

数据服务是云原生架构的组件,旨在有状态。通过设计应用为无状态,你可以将云存储挑战限制在少数几个组件中。

传统上,存储是由运维工程师和数据库管理员处理的。但云和 DevOps 实践使开发者能够选择最适合应用需求的数据服务,并以与云原生应用相同的方式部署它。数据库管理员等专家被咨询以充分利用开发者选择的技术,解决性能、安全性和效率等方面的问题。然而,目标是像为云原生应用提供存储和数据服务一样,按需提供,并以自助方式配置。

应用和数据服务之间的区别也可以通过云基础设施的三个基本构建块来可视化:计算、存储和网络。如图 5.1 所示,应用服务使用计算和网络资源,因为它们是无状态的。另一方面,数据服务是有状态的,需要存储来持久化状态。

05-01

图 5.1 应用服务(无状态)在云基础设施中仅使用计算和网络资源。数据服务(有状态)还需要存储。

让我们来看看云环境中数据服务的挑战。我们还将探讨数据服务的主要类别,从这些类别中你可以为你的应用选择最合适的解决方案。

数据服务的挑战

在云原生系统中,数据服务通常是现成的组件,如数据库和消息代理。在选择最合适的技术时,你应该考虑以下几个属性。

  • 可伸缩性—云原生应用可以动态地扩展和缩减。数据服务也不例外:它们应该能够扩展以适应增加或减少的工作负载。新的挑战是在确保安全访问数据存储的同时进行扩展。在云中通过系统的数据量比以往任何时候都要大,可能会有突然的增加,因此数据服务应该支持增加工作负载的可能性并具有弹性。

  • 弹性—与云原生应用类似,数据服务应该能够抵御故障。这里的新方面是,使用特定存储技术持久化的数据也应该具有弹性。确保您的数据具有弹性和防止数据丢失的关键策略之一是复制。在不同集群和地理区域之间复制数据使其更具弹性,但这需要付出代价。像关系型数据库这样的数据服务允许复制同时确保数据一致性。其他一些非关系型数据库提供高水平的弹性,但并不总能保证数据一致性(它们提供所谓的最终一致性)。

  • 性能—数据的复制方式可能会影响性能,这还受到特定存储技术的 I/O 访问延迟和网络延迟的限制。存储相对于依赖它的数据服务所在的位置变得很重要——这是我们未曾遇到过的云原生应用的担忧。

  • 合规性—在与数据服务打交道时,你可能会面临比与云原生应用更多的合规性挑战。持久化数据通常对业务至关重要,并且通常包含受特定法律、法规或客户协议保护的信息,这些协议规定了其管理方式。例如,在处理个人和敏感信息时,按照隐私法管理数据至关重要。在欧洲,这意味着遵守通用数据保护条例(GDPR)。在加利福尼亚,有加利福尼亚消费者隐私法案(CCPA)。在其他领域,还有更多的法律适用。例如,美国的健康数据应按照健康保险可携带性和问责制法案(HIPAA)进行管理。云原生存储和云服务提供商都应遵守您必须遵守的任何法律或协议。由于这一挑战,一些处理非常敏感数据(如医疗保健提供商和银行)的组织更喜欢在其场所使用某种云原生存储,以便他们对数据管理有更多的控制,并确保符合适用的法规。

数据服务类别

数据服务可以根据谁负责它们来分类:云提供商还是您。云提供商为数据服务提供多种选择,解决云原生存储的所有主要挑战。

您可以找到行业标准的服务,如 PostgreSQL、Redis 和 MariaDB。一些云提供商甚至在这些服务之上提供增强功能,针对可扩展性、可用性、性能和安全进行了优化。例如,如果您需要关系型数据库,可以使用 Amazon Relational Database Service (RDS)、Azure Database 或 Google Cloud SQL。

云提供商还提供专为云构建的新类型数据服务,并公开它们自己独特的 API。例如,Google BigQuery 是一个无服务器数据仓库解决方案,特别关注高可扩展性。另一个例子是 Azure 提供的极快、非关系型数据库 Cosmos DB。

另一个选择是自行管理数据服务,这会增加您的复杂性,但同时也让您对解决方案有更多的控制权。您可以选择基于虚拟机的更传统设置,或者使用容器并利用您在管理云原生应用程序中学到的经验。使用容器将允许您通过统一的界面管理系统中所有的服务,例如 Kubernetes,处理计算和存储资源,并降低成本。图 5.2 展示了云数据服务的这些类别。

05-02

图 5.2 数据服务可以由您(作为容器或虚拟机)或云提供商管理。在前一种情况下,您可以使用更传统的服务,而在后一种情况下,您还可以访问提供商为云专门构建的多个服务。

注意:当选择自行运行和管理数据服务(无论是虚拟机还是 Kubernetes 上的容器)时,另一个重要的决定是您将使用哪种类型的存储。本地持久化存储?远程持久化存储?云原生存储的主题非常吸引人,但超出了本书的范围。如果您想了解更多信息,我建议您查看 CNCF 云原生交互式景观中的云原生存储部分(landscape.cncf.io)。

以下部分将专注于关系型数据库,并指导您为本地环境设置 PostgreSQL 容器。

5.1.2 将 PostgreSQL 作为容器运行

对于目录服务应用程序,我们将使用关系型数据库 PostgreSQL 来存储目录中书籍的数据(www.postgresql.org)。PostgreSQL 是一个流行的开源数据库,具有强大的可靠性、健壮性和性能,支持关系型和非关系型数据。大多数云提供商都提供 PostgreSQL 作为托管服务,让您免于处理高可用性、弹性和持久化存储等问题。例如,Azure Database for PostgreSQL、Amazon RDS for PostgreSQL、Google Cloud SQL for PostgreSQL、阿里云 ApsaraDB RDS for PostgreSQL 和 DigitalOcean PostgreSQL。

在本书的后面部分,我们将部署 Polar Bookshop 系统到由云服务提供商管理的 Kubernetes 集群,我会向您展示如何使用他们提供的托管 PostgreSQL 服务。您需要确保环境一致性,正如 15-Factor 方法所建议的,因此您也会在开发中使用 PostgreSQL。Docker 使得在本地运行数据库比以往任何时候都要简单,所以我会向您展示如何在您的本地机器上以容器形式运行 PostgreSQL。

在第二章中,您使用目录服务应用程序首次尝试了 Docker。以容器形式运行 PostgreSQL 并没有什么不同。请确保您的 Docker Engine 正在运行,打开一个终端窗口,并执行以下命令:

$ docker run -d \
    --name polar-postgres \              ❶
    -e POSTGRES_USER=user \              ❷
    -e POSTGRES_PASSWORD=password \      ❸
    -e POSTGRES_DB=polardb_catalog \     ❹
    -p 5432:5432 \                       ❺
    postgres:14.4                        ❻

❶ 容器的名称

❷ 定义了管理员用户的用户名

❸ 定义了管理员用户的密码

❹ 定义了要创建的数据库的名称

❺ 将数据库暴露到您机器上的 5432 端口

❻ Docker Hub 拉取的 PostgreSQL 容器镜像

与您运行目录服务容器的方式相比,您会注意到一些新元素。首先,您运行容器的 Docker 镜像(postgres:14.4)不是由您创建的——它是从 Docker Hub 容器注册库(在安装 Docker 时默认配置)中拉取的。

第二个新功能是将环境变量作为参数传递给容器。PostgreSQL 接受一些环境变量,这些变量在容器创建期间用于配置数据库。

注意:在这本书中,我不会介绍如何在 Docker 中配置存储()。这意味着一旦您删除容器,您在本地 PostgreSQL 容器中保存的所有数据都将丢失。考虑到本章的主题,这可能会显得有些不合逻辑,但在生产环境中,所有与存储相关的问题将由云服务提供商处理,因此您不需要自己处理。如果您需要向本地容器添加持久存储,您可以在官方 Docker 文档中阅读如何使用卷(docs.docker.com)。

在下一节中,您将看到如何使用 Spring Data JDBC 和 PostgreSQL 将数据持久化添加到 Spring Boot 应用程序中。

注意:如果您需要,可以使用docker stop polar-postgres停止容器,然后使用docker start polar-postgres重新启动它。如果您想从头开始,可以使用docker rm -fv polar-postgres删除容器,然后使用之前的docker run命令重新创建它。

5.2 使用 Spring Data JDBC 进行数据持久化

Spring 通过 Spring Data 项目支持多种数据持久化技术,该项目包含特定于关系型(JDBC、JPA、R2DBC)和非关系型数据库(Cassandra、Redis、Neo4J、MongoDB 等)的模块。Spring Data 提供了常见的抽象和模式,使得在不同模块之间导航变得简单。本节重点介绍关系型数据库,但使用 Spring Data 和数据库(如图 5.3 所示)之间的交互的关键点适用于所有这些。

05-03

图 5.3 一个驱动程序配置应用程序和数据库之间的连接。实体代表领域对象,可以通过存储库进行存储和检索。

图 5.3 所示交互中的主要元素是数据库驱动程序、实体和存储库:

  • 数据库驱动程序—提供与特定数据库(通过 连接工厂)集成的组件。对于关系型数据库,您可以在命令式/阻塞应用程序中使用 JDBC 驱动程序(Java 数据库连接 API)或在响应式/非阻塞应用程序中使用 R2DBC 驱动程序。对于非关系型数据库,每个供应商都有自己的专用解决方案。

  • 实体—在数据库中持久化的领域对象。它们必须包含一个字段来唯一标识每个实例(主键),并且可以使用专用注解来配置 Java 对象和数据库条目之间的映射。

  • 存储库—用于数据存储和检索的抽象。Spring Data 提供了基本实现,每个模块都进一步扩展以提供特定于所用数据库的功能。

本节将向您展示如何使用 Spring Data JDBC 将数据持久化添加到像目录服务这样的 Spring Boot 应用程序中。您将配置连接池以通过 JDBC 驱动程序与 PostgreSQL 数据库交互,定义要持久化的实体,使用存储库来访问数据,并处理事务。图 5.4 展示了在本章结束时 Polar Bookshop 架构将如何看起来。

05-04

图 5.4 目录服务应用程序使用 PostgreSQL 数据库来持久化书籍数据。

Spring Data JDBC 或 Spring Data JPA?

Spring Data 提供了两种主要选项来通过 JDBC 驱动程序将应用程序与关系型数据库集成:Spring Data JDBC 和 Spring Data JPA。如何在这两者之间选择?像往常一样,答案是这取决于您的需求和具体环境。

Spring Data JPA (spring.io/projects/spring-data-jpa) 是 Spring Data 项目中最常用的模块。它基于 Java 持久化 API (JPA),这是包含在 Jakarta EE(之前称为 Java EE)中的标准规范。Hibernate 是最受欢迎的实现。它是一个健壮且经过实战考验的对象关系映射(ORM)框架,用于管理 Java 应用程序中的数据持久化。Hibernate 提供了许多有用的功能,但它也是一个复杂的框架。如果你不了解持久化上下文、延迟加载、脏检查或会话等概念,你可能会遇到难以调试的问题,除非你对 JPA 和 Hibernate 有足够的了解。一旦你更了解这个框架,你就会欣赏 Spring Data JPA 如何简化事情并提高你的生产力。要了解更多关于 JPA 和 Hibernate 的信息,你可以查看 Vlad Mihalcea 的《高性能 Java 持久化和 SQL》(vladmihalcea.com)以及 Ca˘ta˘lin Tudose 的《使用 Spring Data 和 Hibernate 进行 Java 持久化》(Manning, 2022)。

Spring Data JDBC (spring.io/projects/spring-data-jdbc) 是 Spring Data 家族中较新的成员。它遵循领域驱动设计(DDD)概念,如聚合、聚合根和仓库,与关系数据库集成。它轻量级、简单,是微服务的绝佳选择,在微服务中,领域通常被定义为边界上下文(另一个 DDD 概念)。它为开发者提供了更多对 SQL 查询的控制,并允许使用不可变实体。作为 Spring Data JPA 的简单替代方案,它并不是每个场景的即插即用替代品,因为它不提供 JPA 提供的所有功能。我建议根据你的需求学习两者,然后决定哪个模块更适合特定的场景。

我选择在这里介绍 Spring Data JDBC,因为它与云原生应用程序的良好匹配和其简单性。多亏了 Spring Data 的通用抽象和模式,你可以轻松地将项目从 Spring Data JDBC 转换为 Spring Data JPA。在接下来的章节中,我会指出两者之间的主要区别,以便你在想要尝试使用 Spring Data JPA 实现相同要求的情况下有足够的信息。在本书附带的代码库中,你还可以找到一个 JPA 版本的 Catalog Service,你可以将其用作参考(第五章/05-end/catalog-service-jpa)。

5.2.1 使用 JDBC 连接到数据库

让我们开始为 Catalog Service 应用程序实现数据持久化层。至少,你需要导入你想要使用的特定数据库的 Spring Data 模块,如果需要,还可以导入数据库驱动程序。由于 Spring Data JDBC 支持不同的关系数据库,你需要明确声明对你要使用的特定数据库驱动的依赖。

您可以将两个新的依赖项添加到目录服务项目(catalog-service)的 build.gradle 文件中。请记住,在添加新依赖项后刷新或重新导入 Gradle 依赖项。

列表 5.1 在目录服务中添加 Spring Data JDBC 依赖项

dependencies {
  ...
  implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' 
  runtimeOnly 'org.postgresql:postgresql' 
}

这些是主要依赖项:

  • Spring Data JDBC(org.springframework.boot:spring-boot-starter-data-jdbc)—提供必要的库,使用 Spring Data 和 JDBC 在关系型数据库中持久化数据。

  • PostgreSQL(org.postgresql:postgresql)—提供 JDBC 驱动程序,允许应用程序连接到 PostgreSQL 数据库。

PostgreSQL 数据库是目录服务应用程序的后端服务。因此,它应根据 15 个因素方法作为附加资源进行处理。附加是通过资源绑定完成的,在 PostgreSQL 的情况下,包括以下内容:

  • 一个 URL,用于定义要使用的驱动程序、数据库服务器的位置以及将应用程序连接到的数据库

  • 用于建立与指定数据库连接的用户名和密码

多亏了 Spring Boot,您可以将这些值作为配置属性提供。这意味着您可以通过更改资源绑定的值轻松替换附加的数据库。

打开目录服务项目的 application.yml 文件,并添加配置与 PostgreSQL 连接的属性。这些值是在创建 PostgreSQL 容器时作为环境变量定义的。

列表 5.2 使用 JDBC 配置数据库连接

spring:
  datasource:            ❶
    username: user 
    password: password 
    url: jdbc:postgresql://localhost:5432/polardb_catalog 

❶ 具有访问给定数据库权限的用户凭据以及一个 JDBC URL,用于标识您想与之建立连接的数据库

打开和关闭数据库连接是相对昂贵的操作,因此您不希望在每次应用程序访问数据时都这样做。解决方案是连接池:应用程序与数据库建立多个连接并重用它们,而不是为每次数据访问操作创建新的连接。这是一个相当的性能优化。

Spring Boot 使用 HikariCP 进行连接池管理,您可以从 application.yml 文件中进行配置。您需要配置至少一个连接超时(spring.datasource.hikari.connection-timeout)和连接池中的最大连接数(spring.datasource.hikari.maximum-pool-size),因为这两个因素都会影响应用程序的弹性和性能。正如您在 Tomcat 线程池中看到的,多个因素会影响您应该使用哪些值。作为一个起点,您可以参考 HikariCP 的池大小分析(github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing)。

列表 5.3 配置连接池以与数据库交互

spring:
  datasource:
    username: user
    password: password
    url: jdbc:postgresql://localhost:5432/polardb_catalog
    hikari: 
      connection-timeout: 2000     ❶
      maximum-pool-size: 5         ❷

❶ 从连接池获取连接的最大等待时间(毫秒)

❷ HikariCP 将在池中保持的最大连接数

现在您已经将 Spring Boot 应用程序连接到了 PostgreSQL 数据库,您可以继续定义您想要持久化的数据。

5.2.2 使用 Spring Data 定义持久化实体

在目录服务中,您已经有了代表应用领域实体的 Book 记录。根据业务领域及其复杂性,您可能想要区分领域实体和持久化实体,使领域层完全独立于持久化层。如果您想了解如何建模这种情况,我建议参考领域驱动设计和六边形架构原则。

在这种情况下,业务领域相当简单,因此我们将更新 Book 记录,使其也成为持久化实体。

使领域类持久化

Spring Data JDBC 鼓励使用不可变实体。使用 Java 记录来建模实体是一个很好的选择,因为它们按设计是不可变的,并且暴露了一个所有参数的构造函数,框架可以使用它来填充对象。

持久化实体必须有一个字段作为对象的标识符,这将在数据库中对应为主键。您可以使用来自 org.springframework.data.annotation 包的@Id 注解将字段标记为标识符。数据库负责为每个创建的对象自动生成一个唯一的标识符。

笔记本通过 ISBN 唯一标识,我们可以将其称为该领域实体的自然键(或业务键)。我们可以决定使用它作为主键,或者引入一个技术键(或代理键)。这两种方法各有优缺点。我选择使用技术键,以便更容易管理和将领域关注点与持久化实现细节解耦。

这就足够在数据库中创建和持久化一本书了。当单个用户独立更新现有的 Book 对象时,这也是可以的。但如果多个用户同时更新相同的实体会发生什么呢?Spring Data JDBC 支持乐观锁来解决这个问题。用户可以并发读取数据。当用户尝试执行更新操作时,应用程序会检查自上次读取以来是否发生了任何变化。如果有变化,则不执行操作,并抛出异常。检查基于一个从 0 开始计数并在每次更新操作时自动增加的数字字段。您可以使用来自 org.springframework.data.annotation 包的@Version 注解标记这样的字段。

当@Id 字段为 null 且@Version 字段为 0 时,Spring Data JDBC 假定它是一个新对象。因此,它依赖于数据库在向表中插入新行时生成标识符。当提供值时,它期望在数据库中找到该对象并更新它。

让我们继续添加两个新字段到 Book 记录中,用于标识符和版本号。由于这两个字段都由 Spring Data JDBC 在底层填充和处理,使用全参数构造函数在生成测试数据等情况下可能过于冗长。为了方便,让我们在 Book 记录中添加一个静态工厂方法,通过仅传递业务字段来构建对象。

列表 5.4 定义 Book 对象的标识符和版本

package com.polarbookshop.catalogservice.domain;

public record Book (

  @Id                                            ❶
  Long id, 

  @NotBlank(message = "The book ISBN must be defined.")
  @Pattern(
    regexp = "^([0-9]{10}|[0-9]{13})$",
    message = "The ISBN format must be valid."
  )
  String isbn,

  @NotBlank(message = "The book title must be defined.")
  String title,

  @NotBlank(message = "The book author must be defined.")
  String author,

  @NotNull(message = "The book price must be defined.")
  @Positive(message = "The book price must be greater than zero.")
  Double price,

  @Version                                       ❷
  int version 

){
  public static Book of( 
    String isbn, String title, String author, Double price 
  ) { 
    return new Book( 
      null, isbn, title, author, price, 0        ❸
    ); 
  } 
}

❶ 将字段标识为主实体的主键

❷ 实体版本号,用于乐观锁定

❸ 当 ID 为 null 且版本为 0 时,实体被视为新实体。

注意 Spring Data JPA 与可变对象一起工作,因此你不能使用 Java 记录。JPA 实体类必须用@Entity 注解标记并公开无参数构造函数。JPA 标识符用 javax.persistence 包中的@Id 和@Version 注解,而不是 org.springframework.data.annotation。

在添加新字段后,我们需要使用 Book 构造函数更新几个类,现在该构造函数需要传递 id 和版本值。

BookService 类包含更新书籍的逻辑。打开它,并将 editBookDetails()方法更改为确保在调用数据层时正确传递书籍标识符和版本。

列表 5.5 在书籍更新中包含现有的标识符和版本

package com.polarbookshop.catalogservice.domain;

@Service
public class BookService {

  ...

  public Book editBookDetails(String isbn, Book book) {
    return bookRepository.findByIsbn(isbn)
      .map(existingBook -> {
        var bookToUpdate = new Book(
          existingBook.id(),               ❶
          existingBook.isbn(),
          book.title(),
          book.author(),
          book.price(),
          existingBook.version());         ❷
        return bookRepository.save(bookToUpdate);
      })
      .orElseGet(() -> addBookToCatalog(book));
  }
}

❶ 使用现有书籍的标识符

❷ 使用现有书籍的版本,如果更新操作成功,版本将自动增加。

在 BookDataLoader 中,我们可以使用新的静态工厂方法来构建 Book 对象。框架将负责处理 id 和版本字段。

列表 5.6 在创建书籍时使用静态工厂方法

package com.polarbookshop.catalogservice.demo;

@Component
@Profile("testdata")
public class BookDataLoader {

  ...

  @EventListener(ApplicationReadyEvent.class)
  public void loadBookTestData() {
    var book1 = Book.of("1234567891", "Northern Lights", 
      "Lyra Silverstar", 9.90);                              ❶
    var book2 = Book.of("1234567892", "Polar Journey", 
      "Iorek Polarson", 12.90);                              ❶
    bookRepository.save(book1);
    bookRepository.save(book2);
  }
}

❶ 框架在底层负责为标识符和版本分配值。

我将更新自动测试的任务留给你。你还可以扩展 BookJsonTests 类中的测试以验证新字段的序列化和反序列化。作为参考,你可以在伴随本书的代码仓库中的 Chapter05/05-intermediate/catalog-service 中查看。

作为持久化实体,Book 记录将自动映射到关系资源。类和字段名称被转换为小写,驼峰式命名法被转换为由下划线连接的单词。Book 记录将生成 book 表,title 字段将生成 title 列,price 字段将生成 price 列,等等。图 5.5 显示了 Java 对象与关系表之间的映射。

05-05

图 5.5 Java 类标记为持久化实体,将由 Spring Data JDBC 自动映射到数据库中的关系资源。

创建数据库模式

数据库必须定义一个表(如图 5.5 所示),映射才能工作。Spring Data 提供了一个在启动时初始化数据源的功能。默认情况下,你可以使用 schema.sql 文件来创建模式,并使用 data.sql 文件在新建的表中插入数据。这些文件应放置在 src/main/resources 文件夹中。

这是一个方便的功能,对于演示和实验很有用。然而,在生产环境中使用它过于有限。正如你将在本章后面看到的那样,使用更复杂的工具(如 Flyway 或 Liquibase)创建和演进关系型资源会更好,这将允许你进行数据库版本控制。现在我们将使用内置的数据库初始化机制,以便我们首先关注数据层实现。

注意 Hibernate,Spring Data JPA 的基础,提供了一种从 Java 中定义的实体自动生成模式的有意思的功能。这再次方便了演示和实验,但在生产环境中使用之前请三思。

在你的 Catalog Service 项目中,在 src/main/resources 文件夹中添加一个新的 schema.sql 文件。然后编写 SQL 指令以创建书籍表,该表将映射到 Java 中的 Book 记录。

列表 5.7 定义创建书籍表的 SQL 指令

DROP TABLE IF EXISTS book;                               ❶
CREATE TABLE book (
  id                  BIGSERIAL PRIMARY KEY NOT NULL,    ❷
  author              varchar(255) NOT NULL,
  isbn                varchar(255) UNIQUE NOT NULL,      ❸
  price               float8 NOT NULL,
  title               varchar(255) NOT NULL,             ❹
  version             integer NOT NULL                   ❺
);

❶ 如果书籍表已存在,则将其删除

❷ 表的主键。数据库将生成它作为一系列数字(bigserial 类型)。

❸ 唯一约束确保特定的 ISBN 只分配给一本书。

❹ NOT NULL 约束确保相关列被分配一个值。

❺ 实体版本号,以整数形式存储

默认情况下,Spring Data 仅在使用嵌入式内存数据库时加载 schema.sql 文件。由于我们使用的是 PostgreSQL,我们需要显式启用此功能。在 Catalog Service 项目的 application.yml 文件中,添加以下配置以从 schema.sql 文件初始化数据库模式。

列表 5.8 从 SQL 脚本初始化数据库模式

spring:
  sql: 
    init: 
      mode: always 

在启动时,Spring Data 将读取该文件并在 PostgreSQL 数据库中执行 SQL 指令以创建一个新的书籍表,并使其能够开始插入数据。

在下一节中,你将能够捕获与持久化实体相关的审计事件,并跟踪每一行何时被插入到表中以及最近一次的修改。

5.2.3 启用和配置 JDBC 审计

在持久化数据时,知道表中每一行的创建日期以及最后更新的日期很有用。在通过身份验证和授权确保应用程序安全后,你甚至可以记录每个实体是由谁创建的以及最近更新了它。所有这些统称为 数据库审计

使用 Spring Data JDBC,您可以通过在配置类上使用@EnableJdbcAuditing注解来为所有持久化实体启用审计。在com.polarbookshop.catalogservice.config包中,添加一个 DataConfig 类以收集 JDBC 相关配置。

列表 5.9 通过注解配置启用 JDBC 审计

package com.polarbookshop.catalogservice.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jdbc.repository.config.EnableJdbcAuditing;

@Configuration             ❶
@EnableJdbcAuditing        ❷
public class DataConfig {}

❶ 指示一个类作为 Spring 配置的来源

❷ 为持久化实体启用审计

注意:在 Spring Data JPA 中,您会使用@EnableJpaAuditing注解来启用 JPA 审计,并且您会使用@EntityListeners(AuditingEntityListener.class)注解实体类以使其监听审计事件,这与 Spring Data JDBC 中的自动监听不同。

当此功能启用时,每当数据被创建、更新或删除时,都会生成审计事件。Spring Data 提供了方便的注解(如表 5.1 中列出),我们可以在专用字段上使用这些注解来捕获此类事件的信息(审计元数据),并将其作为实体的一部分存储在数据库中。

表 5.1 当数据库审计启用时,这些注解可以用于实体字段以捕获审计元数据。

注解 在实体字段上执行的操作
@CreatedBy 识别表示创建实体的用户的字段。它在创建时定义,并且永远不会更改。
@CreatedDate 识别表示实体创建时间的字段。它在创建时定义,并且永远不会更改。
@LastModifiedBy 识别表示最近修改实体的用户的字段。它在每次创建或更新操作时更新。
@LastModifiedDate 识别表示实体最后修改时间的字段。它在每次创建或更新操作时更新。

在目录服务中,我们可以向书籍记录添加 createdDate 和 lastModifiedDate 字段。在第十二章中,在介绍 Spring Security 之后,我们将扩展此对象以捕获谁创建了或更新了实体。

打开书籍记录,添加两个新字段,并相应地更新静态工厂方法。在实例化新对象时,它们可以是 null,因为它们将由 Spring Data 在底层填充。

列表 5.10 向持久化实体添加存储审计元数据的字段

package com.polarbookshop.catalogservice.domain;

public record Book (

  @Id
  Long id,

  ...

  @CreatedDate             ❶
  Instant createdDate, 

  @LastModifiedDate        ❷
  Instant lastModifiedDate, 

  @Version
  int version

){
  public static Book of(
    String isbn, String title, String author, Double price
  ) {
    return new Book(null, isbn, title, author, price, null, null, 0);
  }
}

❶ 实体创建的时间

❷ 实体最后修改的时间

在扩展书籍记录之后,BookService 类需要再次更新。打开它,并更改 editBookDetails()方法以确保在调用数据层时正确传递审计元数据。

列表 5.11 在更新书籍时包含现有的审计元数据

package com.polarbookshop.catalogservice.domain;

@Service
public class BookService {

  ...

  public Book editBookDetails(String isbn, Book book) {
    return bookRepository.findByIsbn(isbn)
      .map(existingBook -> {
        var bookToUpdate = new Book(
          existingBook.id(),
          existingBook.isbn(),
          book.title(),
          book.author(),
          book.price(),
          existingBook.createdDate(),        ❶
          existingBook.lastModifiedDate(),   ❷
          existingBook.version());
        return bookRepository.save(bookToUpdate);
      })
      .orElseGet(() -> addBookToCatalog(book));
  }
}

❶ 使用现有书籍记录的创建日期

❷ 使用现有书籍记录的最后修改日期。如果操作成功,它将由 Spring Data 自动更新。

接下来,让我们更新 schema.sql 文件,为书籍表添加新字段的列。

列表 5.12 向书籍表添加审计元数据列

DROP TABLE IF EXISTS book;
CREATE TABLE book (
  id                  BIGSERIAL PRIMARY KEY NOT NULL,
  author              varchar(255) NOT NULL,
  isbn                varchar(255) UNIQUE NOT NULL,
  price               float8 NOT NULL,
  title               varchar(255) NOT NULL,
  created_date        timestamp NOT NULL,      ❶
  last_modified_date  timestamp NOT NULL,      ❷
  version             integer NOT NULL
);

❶ 当实体被创建(存储为时间戳)

❷ 当实体最后被修改(存储为时间戳)

我会把它留给你,在必要时更新自动测试。您还可以扩展 BookJsonTests 中的测试以验证新字段的序列化和反序列化。作为参考,您可以在伴随本书的代码仓库中检查 Chapter05/05-intermediate/catalog-service。

到目前为止,您已经将所有内容都设置好了,以便将 Java 对象映射到数据库中的关系对象,包括审计元数据。尽管如此,您仍然需要一种从数据库访问数据的方法。这就是下一节的主题。

5.2.4 使用 Spring Data 的数据仓库

仓库模式提供了一种访问数据而不依赖于其来源的抽象。BookService 使用的 BookRepository 接口是一个仓库的例子。包含业务逻辑的领域层不需要知道数据来自哪里,只要它能访问即可。在第三章中,我们添加了一个仓库接口的实现,用于在内存中存储数据。现在,在构建持久化层时,我们需要一个不同的实现来从 PostgreSQL 访问数据。

好消息是,我们可以使用 Spring Data 仓库,这是一种技术解决方案,它提供了从数据存储中访问数据的功能,而不依赖于所使用的特定持久化技术。这是 Spring Data 最有价值的特性之一,因为我们可以在任何持久化场景中使用相同的仓库抽象,无论是关系型还是非关系型。

使用数据仓库

当使用 Spring Data 仓库时,您的责任仅限于定义一个接口。在启动时,Spring Data 将动态为您生成接口的实现。在 Catalog Service 项目(catalog-service)中,请继续删除 InMemoryBookRepository 类。

让我们现在看看我们如何从 Catalog Service 项目重构 BookRepository 接口。首先,它应该扩展 Spring Data 提供的可用仓库接口之一。大多数 Spring Data 模块添加了针对支持的数据源特定的 Repository 实现。Catalog Service 应用程序需要对 Book 对象执行标准 CRUD 操作,因此您可以让 BookRepository 接口扩展自 CrudRepository。

CrudRepository 提供了执行 CRUD 操作的方法,包括 save()和 findAll(),因此您可以从接口中删除它们的显式声明。CrudRepository 为 Book 对象定义的默认方法基于它们的@Id 注解字段。由于应用程序需要根据 ISBN 访问书籍,我们必须显式声明这些操作。

列表 5.13 访问书籍的仓库接口

package com.polarbookshop.catalogservice.domain;

import java.util.Optional;
import org.springframework.data.jdbc.repository.query.Modifying;
import org.springframework.data.jdbc.repository.query.Query;
import org.springframework.data.repository.CrudRepository;

public interface BookRepository
    extends CrudRepository<Book,Long> {            ❶

  Optional<Book> findByIsbn(String isbn);          ❷
  boolean existsByIsbn(String isbn);

  @Modifying                                       ❸
  @Query("delete from Book where isbn = :isbn")    ❹
  void deleteByIsbn(String isbn);
}

❶ 扩展提供 CRUD 操作的仓库,指定管理实体的类型(Book)及其主键类型(Long)

❷ Spring Data 在运行时实现的方法

❸ 识别一个将修改数据库状态的操作

❹ 声明 Spring Data 将用于实现该方法的查询

在启动时,Spring Data 将为 BookRepository 提供一个实现,包括所有最常用的 CRUD 操作和您在接口中声明的方法。在 Spring Data 中定义自定义查询有两个主要选项:

  • 使用@Query 注解提供一个将被方法执行的类似 SQL 的语句。

  • 按照官方文档中描述的特定命名约定定义查询方法(spring.io/projects/spring-data)。通常,您可以通过将多个部分组合起来构建方法名,如表 5.2 中所述。在撰写本文时,Spring Data JDBC 仅支持此选项用于读取操作。另一方面,Spring Data JPA 提供了对此的全面支持。

表 5.2 您可以通过遵循特定的命名约定,包括以下构建块,将自定义查询添加到存储库,并让 Spring Data 为您生成实现。

存储库方法构建块 示例
操作 find, exists, delete, count
限制 One, All, First10
-
属性表达式 findByIsbn, findByTitleAndAuthor, findByAuthorOrPrice
比较 findByTitleContaining, findByIsbnEndingWith, findByPriceLessThan
排序运算符 orderByTitleAsc, orderByTitleDesc

通过使用由 CrudRepository 接口提供并由 BookRepository 继承的一些方法,我们可以改进 BookDataLoader 类,以便在开发时从一个空数据库开始,并通过单个命令创建书籍。

列表 5.14 使用 Spring Data 方法删除和保存书籍

package com.polarbookshop.catalogservice.demo;

@Component
@Profile("testdata")
public class BookDataLoader {
  private final BookRepository bookRepository;

  public BookDataLoader(BookRepository bookRepository) {
    this.bookRepository = bookRepository;
  }

  @EventListener(ApplicationReadyEvent.class)
  public void loadBookTestData() {
    bookRepository.deleteAll();                           ❶
    var book1 = Book.of("1234567891", "Northern Lights",
      "Lyra Silverstar", 9.90);
    var book2 = Book.of("1234567892", "Polar Journey",
      "Iorek Polarson", 12.90);
    bookRepository.saveAll(List.of(book1, book2));        ❷
  }
}

❶ 如果存在,删除所有现有书籍,以便从一个空数据库开始

❷ 一次性保存多个对象

定义事务上下文

Spring Data 提供的存储库为所有操作配置了事务上下文。例如,CrudRepository 中的所有方法都是事务性的。这意味着您可以安全地调用 saveAll()方法,知道它将在事务中执行。

当您添加自己的查询方法时,就像您为 BookRepository 所做的那样,您需要定义哪些方法应该包含在事务中。您可以使用 Spring 框架提供的声明性事务管理,并在类或方法上使用@Transactional 注解(来自 org.springframework.transaction.annotation 包)来确保它们作为单个工作单元的一部分执行。

在您在 BookRepository 中定义的自定义方法中,deleteByIsbn()是一个很好的候选事务方法,因为它修改了数据库状态。您可以通过应用@Transactional 注解来确保它在事务中运行。

列表 5.15 定义事务性操作

package com.polarbookshop.catalogservice.domain;

import java.util.Optional;
import org.springframework.data.jdbc.repository.query.Modifying;
import org.springframework.data.jdbc.repository.query.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.transaction.annotation.Transactional; 

public interface BookRepository extends CrudRepository<Book,Long> {

  Optional<Book> findByIsbn(String isbn);
  boolean existsByIsbn(String isbn);

  @Modifying
  @Transactional                               ❶
  @Query("delete from Book where isbn = :isbn")
  void deleteByIsbn(String isbn);
}

❶ 识别要执行的事务中要执行的方法

注意:有关 Spring 框架提供的声明式事务管理的信息,您可以参考官方文档(spring.io/projects/spring-framework)。

干得好!你成功地为目录服务应用程序添加了数据持久性功能。让我们验证它是否正常工作。首先,确保 PostgreSQL 容器仍在运行。如果不是,按照本章开头描述的方式运行它。然后启动应用程序(./gradlew bootRun),向每个 REST 端点发送 HTTP 请求,并确保它按预期工作。完成后,删除数据库容器(docker rm -fv polar-postgres)并停止应用程序(Ctrl-C)。

提示:在本书的配套仓库中,你可以找到用于直接查询 PostgreSQL 数据库以及验证应用程序生成的模式和数据的实用命令(Chapter05/05-intermediate/catalog-service/README.md)。

手动验证数据持久性是可以的,但自动验证更好。这正是下一节要讨论的内容。

5.3 使用 Spring 和 Testcontainers 测试数据持久性

在前面的章节中,我们通过在一个容器中针对 PostgreSQL 数据库开发,为应用程序添加了数据持久性功能,这是在生产环境中使用的技术。这是朝着 15-Factor 方法论推荐的环境一致性迈出的良好一步。尽可能保持所有环境相似性,可以提高项目的质量。

数据源是导致环境之间差异的主要原因之一。在本地开发时使用内存数据库是一种常见的做法——比如 H2 或 HSQL。但这会影响你应用程序的可预测性和健壮性。即使所有关系型数据库都使用 SQL 语言,Spring Data JDBC 也提供了通用抽象,但每个供应商都有自己的方言和独特功能,这使得在生产环境中使用与开发和测试中相同的数据库变得至关重要。否则,你可能无法捕捉到仅在生产中可能发生的错误。

“那关于测试呢?”你可能会问。这是一个非常好的问题。使用内存数据库的另一个原因是使集成测试更容易进行。然而,集成测试也应当测试与你的应用程序外部服务的集成。使用类似 H2 的工具会使这些测试变得不那么可靠。在采用持续交付方法时,每个提交都应当是发布候选。假设部署管道中运行的自动测试没有使用与生产中相同的后端服务。在这种情况下,在安全地将应用程序部署到生产之前,你需要进行额外的手动测试,因为你无法确定它是否能够正确工作。因此,减少环境之间的差距至关重要。

Docker 使您能够更容易地设置和开发使用实际数据库的应用程序,就像您使用 PostgreSQL 时的体验一样。以类似的方式,Testcontainers(一个用于测试的 Java 库)使您能够在集成测试的上下文中轻松使用支持服务作为容器。

本节将向您展示如何使用 @DataJdbcTest 注解编写数据持久层的切片测试,并使用 @SpringBootTest 注解在集成测试中包含数据库。在这两种情况下,您都将依赖 Testcontainers 来运行针对实际 PostgreSQL 数据库的自动测试。

5.3.1 为 PostgreSQL 配置 Testcontainers

Testcontainers (testcontainers.org) 是一个用于测试的 Java 库。它支持 JUnit,并提供轻量级、一次性容器,如数据库、消息代理和 Web 服务器。它非常适合实现使用生产中实际使用的支持服务的集成测试。结果是更可靠和稳定的测试,这有助于提高应用程序的质量,并有利于持续交付实践。

您可以使用 Testcontainers 配置一个轻量级的 PostgreSQL 容器,并在涉及数据持久层的自动测试中使用它。让我们看看它是如何工作的。

首先,您需要在 Catalog Service 项目的 build.gradle 文件中添加对 Testcontainers PostgreSQL 模块的依赖。记得在添加新依赖后刷新或重新导入 Gradle 依赖。

列表 5.16 在 Catalog Service 中添加 Testcontainers 依赖

ext {
  ...
  set('testcontainersVersion', "1.17.3")                 ❶
}

dependencies {
  ...
  testImplementation 'org.testcontainers:postgresql'     ❷
}

dependencyManagement {
  imports {
    ...
    mavenBom "org.testcontainers: 
    ➥ testcontainers-bom:${testcontainersVersion}"      ❸
  }
}

❶ 定义要使用的 Testcontainers 版本

❷ 为 PostgreSQL 数据库提供容器管理功能

❸ Testcontainers 依赖管理 BOM(物料清单)

在运行测试时,我们希望应用程序使用 Testcontainers 提供的 PostgreSQL 实例,而不是我们之前通过 spring.datasource.url 属性配置的那个实例。我们可以在 src/test/resources 下创建一个新的 application-integration.yml 文件中覆盖该值。当集成配置文件启用时,在此文件中定义的任何属性都将优先于主属性。在这种情况下,我们将按照 Testcontainers 定义的格式覆盖 spring.datasource.url 的值。

在 src/test/resources 中创建一个新的 application-integration.yml 文件,并添加以下配置。

列表 5.17 使用 Testcontainers 提供的 PostgreSQL 数据源

spring:
  datasource:
    url: jdbc:tc:postgresql:14.4:///      ❶

❶ 在 Testcontainers 中标识 PostgreSQL 模块。“14.4”是使用的 PostgreSQL 版本。

这就是我们需要配置 Testcontainers 的所有内容。当集成配置文件启用时,Spring Boot 将使用 Testcontainers 实例化的 PostgreSQL 容器。我们现在可以编写自动测试来验证数据持久层了。

5.3.2 使用 @DataJdbcTest 和 Testcontainers 测试数据持久性

如您在第三章中可能记得的,Spring Boot 允许您通过仅加载特定应用程序切片(切片测试)使用的 Spring 组件来运行集成测试。在 Catalog Service 中,我们创建了 MVC 和 JSON 切片的测试。现在我将向您展示如何编写数据切片的测试。

创建一个 BookRepositoryJdbcTests 类,并用 @DataJdbcTest 注解标记它。这将触发 Spring Boot 将所有 Spring Data JDBC 实体和存储库包含在应用程序上下文中。它还将自动配置 JdbcAggregateTemplate,这是一个我们可以用来为每个测试用例设置上下文的更低级别的对象,而不是使用存储库(被测试的对象)。

列表 5.18 数据 JDBC 切片集成测试

package com.polarbookshop.catalogservice.domain;

import java.util.Optional;
import com.polarbookshop.catalogservice.config.DataConfig;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.data.jdbc.DataJdbcTest;
import org.springframework.boot.test.autoconfigure.jdbc
➥ .AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import org.springframework.data.jdbc.core.JdbcAggregateTemplate;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;

@DataJdbcTest                                                          ❶
@Import(DataConfig.class)                                              ❷
@AutoConfigureTestDatabase(                                            ❸
  replace = AutoConfigureTestDatabase.Replace.NONE
)
@ActiveProfiles("integration")                                         ❹
class BookRepositoryJdbcTests {

  @Autowired
  private BookRepository bookRepository;

  @Autowired
  private JdbcAggregateTemplate jdbcAggregateTemplate;                 ❺

  @Test
  void findBookByIsbnWhenExisting() {
    var bookIsbn = "1234561237";
    var book = Book.of(bookIsbn, "Title", "Author", 12.90);
    jdbcAggregateTemplate.insert(book);                                ❻
    Optional<Book> actualBook = bookRepository.findByIsbn(bookIsbn);

    assertThat(actualBook).isPresent();
    assertThat(actualBook.get().isbn()).isEqualTo(book.isbn());
  }
}

❶ 识别一个专注于 Spring Data JDBC 组件的测试类

❷ 导入数据配置(需要启用审计)

❸ 禁用默认的依赖嵌入式测试数据库的行为,因为我们想使用 Testcontainers

❹ 启用“集成”配置文件从 application-integration.yml 加载配置

❺ 一个用于与数据库交互的更低级别的对象

❻ 使用 JdbcAggregateTemplate 准备测试目标的数据。

@DataJdbcTest 注解封装了便捷的功能。例如,它使每个测试方法都在事务中运行,并在方法执行结束时回滚,以保持数据库的清洁。在运行列表 5.18 中的测试方法后,数据库将不会包含 findBookByIsbnWhenExisting() 中创建的书籍,因为事务在方法执行结束时回滚。

让我们验证 Testcontainers 配置是否正常工作。首先,确保 Docker 引擎在您的本地环境中正在运行。然后打开一个终端窗口,导航到 Catalog Service 项目的根目录,并运行以下命令以确保测试成功。在底层,Testcontainers 将在测试执行之前创建一个 PostgreSQL 容器,并在测试结束时将其删除。

$ ./gradlew test --tests BookRepositoryJdbcTests

在与本书配套的代码仓库中,您可以找到更多 Catalog Service 项目的单元和集成测试示例。下一节将介绍如何使用 Testcontainers 运行完整的集成测试。

5.3.3 使用 @SpringBootTest 和 Testcontainers 的集成测试

在 Catalog Service 应用程序中,我们已经有了一个带有 @SpringBootTest 注解的 CatalogServiceApplicationTests 类,其中包含完整的集成测试。我们之前定义的 Testcontainers 配置适用于所有启用集成配置文件的自动测试,因此我们需要将配置文件添加到 CatalogServiceApplicationTests 类中。

列表 5.19 启用集成配置文件以进行集成测试

package com.polarbookshop.catalogservice;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("integration")                                             ❶
class CatalogServiceApplicationTests {
  ...
}

❶ 启用“集成”配置文件从 application-integration.yml 加载配置

打开终端窗口,导航到您的 Catalog Service 项目根目录,并运行以下命令以确保测试成功。在底层,Testcontainers 会在测试执行前创建一个 PostgreSQL 容器,并在测试结束后将其删除。

$ ./gradlew test --tests CatalogServiceApplicationTests

干得好!您已经为 Spring Boot 应用程序添加了数据持久性,并编写了测试,同时确保了环境一致性。让我们继续前进,通过讨论如何在生产中管理模式和数据进行本章的完成。

5.4 使用 Flyway 在生产中管理数据库

记录任何数据库变更是一个好习惯,就像您通过版本控制为您的应用程序源代码做的那样。您需要一个确定性和自动化的方式来推断数据库的状态,即是否已经应用了特定的变更,如何从头开始重新创建数据库,以及如何以受控、可重复和可靠的方式进行迁移。持续交付方法鼓励尽可能自动化,包括数据库管理。

在 Java 生态系统中,用于跟踪、版本控制和部署数据库变更的两个最常用的工具是 Flyway (flywaydb.org) 和 Liquibase (liquibase.org)。它们都完全集成到 Spring Boot 中。本节将向您展示如何使用 Flyway。

5.4.1 理解 Flyway:数据库的版本控制

Flyway 是一个提供数据库版本控制的工具。它为数据库的状态版本提供了一个单一的事实来源,并按增量跟踪任何变更。它自动化变更,并允许您重现或回滚数据库的状态。Flyway 非常可靠,在集群环境中安全使用,并支持包括 Amazon RDS、Azure Database 和 Google Cloud SQL 在内的多个关系数据库。

注意:在本节中,我将介绍 Flyway 提供的一些功能,但我建议您查看官方文档以发现该工具提供的所有强大功能(flywaydb.org)。

Flyway 的核心功能是管理数据库变更。任何数据库变更都被称为 迁移,迁移可以是 版本化的可重复的。版本化迁移通过唯一的版本号进行标识,并且按照顺序恰好应用一次。对于每个 常规 版本化迁移,你还可以提供一个可选的 撤销 迁移来撤销其影响(以防出现错误)。它们可以用来创建、修改或删除关系对象,如模式、表、列和序列,或者用于修正数据。另一方面,可重复迁移每次其校验和发生变化时都会应用。它们可以用来创建或更新视图、过程和包。

这两种类型的迁移都可以在标准 SQL 脚本(用于 DDL 更改)或 Java 类(用于 DML 更改,如数据迁移)中定义。Flyway 通过在第一次运行时自动创建在数据库中的 flyway_schema_history 表来跟踪哪些迁移已经被应用。你可以将迁移想象成 Git 仓库中的提交,而模式历史表则是包含随时间应用的所有提交列表的仓库日志(图 5.6)。

05-06

图 5.6 Flyway 迁移表示数据库更改,这些更改可以想象成 Git 仓库中的提交。

注意:使用 Flyway 的前提是你要管理的数据库以及具有正确访问权限的用户都存在。一旦你有了数据库和用户,Flyway 就可以为你管理数据库更改。你不应该使用 Flyway 来管理用户。

你可以使用 Flyway 的独立模式或将其嵌入到 Java 应用程序中。Spring Boot 为其提供了自动配置,使得在应用程序中包含 Flyway 变得非常方便。当与 Spring Boot 集成时,Flyway 将在 src/main/resources/db/migration 文件夹中查找 SQL 迁移,并在 src/main/java/db/migration 中查找 Java 迁移。

运行模式和数据迁移是第二章中介绍的 15-Factor 方法论所描述的行政流程之一。在这种情况下,管理此类流程的策略是将它嵌入到应用程序本身中。默认情况下,它在应用程序启动阶段被激活。让我们看看我们如何为目录服务实现它。

打开你的目录服务项目(catalog-service),并在 build.gradle 文件中添加 Flyway 依赖。记得在添加后刷新或重新导入 Gradle 依赖。

列表 5.20 在目录服务中添加 Flyway 依赖

dependencies {
  ...
  implementation 'org.flywaydb:flyway-core' 
}

在下一节中,你将学习如何创建你的第一个迁移来初始化数据库模式。

5.4.2 使用 Flyway 初始化数据库模式

你将应用的第一项数据库更改通常是初始化模式。到目前为止,我们一直依赖于 Spring Boot 提供的内置数据源初始化功能,并提供一个包含要运行的 SQL 语句的 schema.sql 文件。现在我们可以使用 SQL Flyway 迁移来初始化模式。

首先,删除 schema.sql 文件,并从你的目录服务项目中的应用.yml 文件中移除 spring.sql.init.mode 属性。

接下来,创建一个 src/main/resources/db/migration 文件夹。这是 Flyway 默认查找 SQL 迁移的地方。在文件夹内,创建一个 V1__Initial_schema.sql 文件,该文件将包含目录服务应用程序所需的初始化数据库模式的 SQL 语句。确保在版本号后输入两个下划线。

Flyway 期望 SQL 迁移文件遵循特定的命名模式。常规版本化迁移应遵循以下结构:

  • 前缀—V 表示版本化迁移

  • 版本——使用点或下划线分隔的版本号(例如,2.0.1)

  • 分隔符——两个下划线:__

  • 描述——用下划线分隔的单词

  • 后缀——.sql

在 V1__Initial_schema.sql 迁移脚本中,您可以包含创建书籍表的 SQL 指令,该表将由 Spring Boot JDBC 映射到 Book 持久实体。

列表 5.21 初始化模式的 Flyway 迁移脚本

CREATE TABLE book (                                     ❶
  id                  BIGSERIAL PRIMARY KEY NOT NULL,   ❷
  author              varchar(255) NOT NULL,
  isbn                varchar(255) UNIQUE NOT NULL,     ❸
  price               float8 NOT NULL,
  title               varchar(255) NOT NULL,
  created_date        timestamp NOT NULL,
  last_modified_date  timestamp NOT NULL,
  version             integer NOT NULL
);

❶ 书籍表的定义

❷ 将 id 字段声明为主键

❸ 将 isbn 字段约束为唯一

当您让 Flyway 管理数据库模式变更时,您将获得版本控制的所有好处。现在,您可以按照 5.1.2 节中提供的说明启动一个新的 PostgreSQL 容器(如果之前还在运行,请使用 docker rm -fv polar-postgres 删除它),运行应用程序(./gradlew bootRun),并验证一切是否正常工作。

注意:在伴随书籍的仓库中,您可以找到查询 PostgreSQL 数据库并验证 Flyway 生成的模式和数据的实用命令(Chapter05/05-end/catalog-service/README.md)。

您的自动测试也将使用 Flyway。继续运行它们;它们都应该成功。完成后,将您的更改推送到远程 Git 仓库,并检查 GitHub Actions 的提交阶段结果。它们也应该成功。最后,停止应用程序执行(Ctrl-C)和 PostgreSQL 容器(docker rm -fv polar-postgres)。

在最后一节中,您将学习如何使用 Flyway 迁移来演进数据库。

5.4.3 使用 Flyway 演进数据库

假设您已经完成了 Catalog Service 应用程序并将其部署到生产环境中。书店的员工已经开始向目录中添加书籍并收集关于应用程序的反馈。结果是目录功能的新需求:它应该提供关于书籍出版者的信息。您该如何做到这一点?

由于应用程序已经在生产中,并且已经创建了一些数据,您可以使用 Flyway 应用新的数据库变更,修改书籍表以添加新的出版者列。在您的 Catalog Service 项目 src/main/resources/db/migration 文件夹中创建一个新的 V2__Add_publisher_column.sql 文件,并添加以下 SQL 指令以添加新列。

列表 5.22 更新表模式的 Flyway 迁移脚本

ALTER TABLE book
ADD COLUMN publisher varchar(255);

然后相应地更新 Java 的 Book 记录。变更应考虑到在生产环境中,数据库中已经保存了一些没有出版信息书籍,因此必须是一个可选字段,否则现有数据将变得无效。您还应该相应地更新静态工厂方法。

列表 5.23 向现有数据实体添加新的可选字段

package com.polarbookshop.catalogservice.domain;

public record Book (
  @Id
  Long id,

  ...

  String publisher,      ❶

  @CreatedDate
  Instant createdDate,

  @LastModifiedDate
  Instant lastModifiedDate,

  @Version
  int version

){
  public static Book of(
    String isbn, String title, String author, Double price, String publisher 
  ) {
    return new Book(
      null, isbn, title, author, price, publisher, null, null, 0
    );
  }
}

❶ 一个新的、可选的字段

注意:在做出此更改后,你必须更新调用静态工厂方法和 Book() 构造函数的类,以包含出版社字段的值。你可以使用 null(因为它可选)或像 Polarsophia 这样的字符串值。检查源代码(Chapter05/05-end/catalog-service)以查看最终结果。最后,检查自动测试和应用程序是否运行正确。

当这个新版本的目录服务部署到生产环境时,Flyway 将跳过 V1__Initial_schema.sql 迁移,因为它已经被应用,但它将执行 V2__Add_publisher_column.sql 中描述的更改。此时,书店员工可以在添加新书到目录时开始包含出版社名称,并且所有现有数据仍然有效。

如果你需要使出版社字段成为必填项,你可以在目录服务的第三个版本中这样做,使用 SQL 迁移强制出版社列不能为 NULL,并实现一个 Java 迁移,将出版社添加到数据库中所有尚未包含出版社的现有书籍中。

这种两步法在升级期间确保向后兼容性非常常见。正如你将在后面的章节中学到的,通常有多个相同应用实例在运行。部署新版本通常通过 滚动升级 程序完成,该程序一次更新一个(或几个)实例,以确保零停机时间。在升级期间,将同时运行旧版本和新版本的应用程序,因此确保旧实例即使在应用了最新版本中引入的数据库更改后仍能正确运行至关重要。

摘要

  • 状态是关闭服务并启动新实例时应保留的一切。

  • 数据服务是云原生架构中的有状态组件,需要存储技术来持久化状态。

  • 在云中使用数据服务具有挑战性,因为它是一个动态的环境。

  • 选择数据服务时需要考虑的一些问题包括可伸缩性、弹性、性能以及符合特定法规和法律。

  • 你可以使用由你的云提供商提供和管理的服务,或者自己管理,无论是依赖于虚拟机还是容器。

  • Spring Data 提供了访问数据的通用抽象和模式,使得导航针对关系型和非关系型数据库的不同模块变得简单直接。

  • Spring Data 的主要元素包括数据库驱动程序、实体和仓库。

  • Spring Data JDBC 是一个框架,它支持通过 JDBC 驱动程序将 Spring 应用程序与关系型数据库集成。

  • 实体代表领域对象,并且可以作为不可变对象由 Spring Data JDBC 管理。它们必须有一个字段包含主键,并使用 @Id 注解。

  • Spring Data 允许你在实体创建或更新时捕获审计元数据。你可以通过使用 @EnableJdbcAuditing 来启用此功能。

  • 数据仓库允许从数据库访问实体。你需要定义一个接口,然后 Spring Data 将为你生成实现。

  • 根据你的需求,你可以扩展 Spring Data 提供的可用 Repository 接口之一,例如 CrudRepository。

  • 在 Spring Data JDBC 中,所有修改自定义操作(创建、更新、删除)都应该在事务中运行。

  • 使用 @Transactional 注解来运行单个工作单元的操作。

  • 你可以使用 @DataJdbcTest 注解来运行 Spring Data JDBC 切片的集成测试。

  • 环境一致性对于测试和部署管道的质量和可靠性至关重要。

  • 你可以使用 Testcontainers 库来测试你的应用程序与作为容器定义的后端服务之间的集成。它允许你在集成测试中使用轻量级、可丢弃的容器。

  • 数据库模式对于应用程序至关重要。在生产环境中,你应该使用像 Flyway 这样的工具,它为你的数据库提供版本控制。

  • Flyway 应该管理任何数据库更改,以确保可重复性、可追踪性和可靠性。

6 容器化 Spring Boot

本章涵盖

  • 在 Docker 上使用容器镜像

  • 将 Spring Boot 应用程序打包为容器镜像

  • 使用 Docker Compose 管理 Spring Boot 容器

  • 使用 GitHub Actions 自动构建和推送镜像

到目前为止,我们已经开发了一个 Catalog Service 应用程序,该应用程序公开 REST API 并通过运行在容器内的 PostgreSQL 数据库持久化数据。我们正越来越接近将 Polar Bookshop 系统的第一个组件部署到 Kubernetes 集群。然而,在这样做之前,你需要学习如何将 Spring Boot 应用程序打包为容器镜像并管理其生命周期。

本章将教你容器镜像的基本特性和如何构建一个。我们将使用 Docker 来处理容器,但你也可以使用任何其他与 Open Container Initiative (OCI) 标准兼容的容器运行时(opencontainers.org)。在本书的剩余部分,每当我提到 容器镜像Docker 镜像 时,我指的是与 OCI 镜像规范兼容的镜像。

在此过程中,我会与你分享关于为生产构建容器镜像的几个考虑因素,例如安全和性能。我们将探讨两种可能性:Dockerfile 和云原生构建包。

当我们开始处理多个容器时,Docker CLI 并不高效。相反,我们将使用 Docker Compose 来管理多个容器及其生命周期。

最后,我们将继续在第三章中开始的工作,部署管道。我会向你展示如何为打包和自动发布容器镜像到 GitHub 容器注册库添加新的提交阶段步骤。

注意:本章示例的源代码可在 Chapter06/06-begin 和 Chapter06/06-end 文件夹中找到,这些文件夹包含项目的初始状态和最终状态(github.com/ThomasVitale/cloud-native-spring-in-action)。

6.1 在 Docker 上使用容器镜像

在第二章中,我介绍了 Docker 平台的主要组件。Docker 引擎具有客户端/服务器架构。Docker CLI 是你用来与 Docker 服务器交互的客户端。后者负责通过 Docker 守护进程管理所有 Docker 资源(例如,镜像、容器和网络)。服务器还可以与容器注册库交互以上传和下载镜像。为了方便起见,图 6.1 再次显示了这些组件之间的交互流程。

06-01

图 6.1 Docker 引擎具有客户端/服务器架构,并与容器注册库进行交互。

本节将从我们在第二章结束的地方继续,并进一步阐述容器镜像,这些镜像是有轻量级可执行包,包括运行内部应用程序所需的一切。您将学习容器镜像的主要特征,如何创建一个,以及最后如何将其发布到容器注册库。在继续之前,请确保通过在终端窗口中执行 docker version 命令来确认您计算机上的 Docker 引擎正在运行。

6.1.1 理解容器镜像

容器镜像是通过执行一系列有序指令产生的,每个指令都产生一个。每个镜像由多个层组成,每个层代表由相应指令产生的修改。最终的产物,即镜像,可以作为容器运行。

镜像可以从头创建,或者从一个基础镜像开始。后者是最常见的方法。例如,您可以从 Ubuntu 镜像开始,并在其上应用一系列修改。指令的顺序如下:

  1. 使用 Ubuntu 作为基础镜像。

  2. 安装 Java 运行时环境。

  3. 执行 java --version 命令。

这些指令中的每一个都会生成一个层,产生图 6.2 中显示的最终容器镜像。

06-02

图 6.2 容器镜像由一系列只读层组成。第一个代表基础镜像;其余的表示在其之上应用的修改。

容器镜像中的所有层都是只读的。一旦它们被应用,您就不能再修改它们了。如果您需要更改某些内容,您可以通过在其上应用一个新的层来做到这一点(通过执行一个新的指令)。应用在上层的变化不会影响下层。这种方法被称为写时复制:在顶层创建原始项目的副本,并将更改应用于副本而不是原始项目。

当镜像作为容器运行时,会在所有现有层之上自动应用一个最后的层:容器层。这是唯一的可写层,它用于存储容器自身执行过程中创建的数据。在运行时,此层可能用于生成应用程序运行所需的文件,或者用于存储临时数据。尽管它是可写的,但请记住它是易变的:一旦您删除容器,该层中存储的所有内容都将消失。图 6.3 比较了运行中的容器和相应镜像中的层。

06-03

图 6.3 运行的容器在其镜像层之上有一个额外的层。这是唯一的可写层,但请记住它是易变的。

注意 容器镜像中的所有层都是只读的,这有一些安全影响。你不应该在底层存储机密或敏感信息,因为它们始终是可访问的,即使上层删除了它们。例如,你不应该在容器镜像中打包密码或加密密钥。

到目前为止,你已经学习了容器镜像是如何组成的,但你还没有看到如何创建一个。接下来就是了。

6.1.2 使用 Dockerfile 创建镜像

根据 OCI 格式,你可以通过在一个称为 Dockerfile 的特定文件中列出指令的顺序来定义容器镜像。它是一个脚本,充当包含构建所需镜像所有步骤的配方。

在 Dockerfile 中,每条指令都以 Docker 特定语法中的命令开头。然后,你可以根据你使用的 Linux 发行版作为基础镜像,将熟悉的 shell 命令作为参数传递给指令。格式如下:

INSTRUCTION arguments

注意 Docker 支持在具有 AMD64 和 ARM64 架构的机器上运行 Linux 容器。它还支持与 Windows 容器(只能在 Windows 系统上运行)一起工作,但在这本书中我们将仅使用 Linux 容器。

让我们通过定义一个 Dockerfile 来构建上一节中提到的容器镜像,其中包含以下主要指令来实践一下:

  1. 使用 Ubuntu 作为基础镜像。

  2. 安装 Java 运行时环境。

  3. 运行 java --version 命令。

创建一个名为 my-java-image 的文件夹,并在其中创建一个名为 Dockerfile 的空文件,没有扩展名(Chapter06/06-end/my-java-image)。你可能会有不同的命名,但在这个例子中,让我们遵循默认约定。

列表 6.1 包含构建 OCI 镜像指令的 Dockerfile

FROM ubuntu:22.04                                      ❶

RUN apt-get update && apt-get install -y default-jre   ❷

ENTRYPOINT ["java", "--version"]                       ❸

❶ 基于 Ubuntu 的官方镜像,版本 22.04

❷ 使用熟悉的 bash 命令安装 JRE

❸ 定义运行容器的执行入口点

默认情况下,Docker 配置为使用 Docker Hub 来查找和下载镜像。这就是 ubuntu:22.04 镜像的来源。Docker Hub 是一个你可以免费使用(在特定的速率限制内)的注册表,当你安装 Docker 时它会自动配置。

java --version 命令是执行容器的 入口点。如果你没有指定任何入口点,容器将不会作为可执行文件运行。与虚拟机不同,容器旨在运行任务,而不是操作系统。确实,当使用 docker run ubuntu 运行 Ubuntu 容器时,容器会立即退出,因为没有定义任何任务作为入口点,只有操作系统。

在 Dockerfile 中定义的最常见指令列于表 6.1 中。

表 6.1 在 Dockerfile 中构建容器镜像时最常用的指令

指令 描述 示例
FROM 定义后续指令的基础镜像。它必须是 Dockerfile 中的第一条指令。 FROM ubuntu:22.04
LABEL 以键/值格式向镜像添加元数据。可以定义多个 LABEL 指令。 LABEL version="1.2.1"
ARG 定义用户可以在构建时传递的变量。可以定义多个 ARG 指令。 ARG JAR_FILE
RUN 在现有层之上执行作为参数传递的命令。可以定义多个 RUN 指令。 RUN apt-get update && apt-get install -y default-jre
COPY 从主机文件系统复制文件或目录到容器内部的文件系统。 COPY app-0.0.1-SNAPSHOT.jar app.jar
USER 定义将运行所有后续指令和镜像本身(作为一个容器)的用户。 USER sheldon
ENTRYPOINT 定义当镜像作为容器运行时要执行的程序。只有 Dockerfile 中的最后一个 ENTRYPOINT 指令被考虑。 ENTRYPOINT ["/bin/bash"]
CMD 指定正在运行的容器的默认值。如果定义了 ENTRYPOINT 指令,它们将作为参数传递。如果没有,它也应该包含一个可执行文件。只有 Dockerfile 中的最后一个 CMD 指令被考虑。 CMD ["sleep", "10"]

一旦你在 Dockerfile 中声明了创建容器镜像的规范,你可以使用 docker build 命令逐条运行所有指令,为每个指令产生一个新的层。从 Dockerfile 到镜像再到容器的整个过程如图 6.4 所示。注意 Dockerfile 中的第一条指令产生了镜像的最低层。

06-04

图 6.4 图片是从 Dockerfile 构建的。Dockerfile 中的每条指令都会在镜像中产生一个有序的层序列。

现在打开一个终端窗口,导航到包含你的 Dockerfile 的 my-java-image 文件夹,并运行以下命令(别忘了最后的点)。

$ docker build -t my-java-image:1.0.0 .

命令语法在图 6.5 中解释。

06-05

图 6.5 使用给定名称和版本的 Docker CLI 命令构建新镜像

完成后,你可以使用 docker images 命令获取有关你新创建的镜像的一些详细信息:

$ docker images my-java-image
REPOSITORY      TAG       IMAGE ID       CREATED         SIZE
my-java-image   1.0.0     96d1f58857aa   6 seconds ago   549MB

分层方法使镜像构建非常高效。每个镜像层都是前一个层的增量,Docker 缓存了所有这些层。如果你只更改其中之一并重新构建镜像,只有那个层和随后的层会被重新创建。如果你从一个存储在注册表中的镜像的新版本运行容器,只有新的层会被下载,从而提高了运行时的性能。

因此,建议你根据层可能发生变化的概率来排序层,优化镜像构建过程。将更频繁更改的指令放在 Dockerfile 的末尾。

容器镜像可以使用 docker run 命令运行,该命令启动一个容器并执行 Dockerfile 中描述的过程作为入口点:

$ docker run --rm my-java-image:1.0.0

openjdk 11.0.15 2022-04-19
OpenJDK Runtime Environment (build 11.0.15+10-Ubuntu-0ubuntu0.22.04.1)
OpenJDK 64-Bit Server VM (build 11.0.15+10-Ubuntu-0ubuntu0.22.04.1, mixed mode)

执行完成后,容器将停止。由于您使用了 --rm 参数,执行结束后容器将被自动删除。

注意:当您运行前面的命令时,您会看到 Ubuntu 22.04 的默认 OpenJDK 是 Java 11,而不是我们在整本书中使用的 17 版本。

现在我们来看看如何将镜像发布到容器注册表。

6.1.3 在 GitHub Container Registry 上发布镜像

到目前为止,您已经学习了如何定义、构建和运行容器镜像。在本节中,我将通过扩展容器注册表来完善整个图景。

容器注册表对镜像的作用就像 Maven 仓库对 Java 库的作用。许多云提供商提供自己的注册表解决方案,并附带额外服务,如漏洞扫描和认证镜像。默认情况下,Docker 安装配置为使用 Docker 公司提供的容器注册表(Docker Hub),该注册表托管了许多流行的开源项目的镜像,如 PostgreSQL、RabbitMQ 和 Redis。我们将继续使用它来拉取第三方镜像,就像您在上一节中为 Ubuntu 所做的那样。

那么,发布您自己的镜像怎么样?您当然可以使用 Docker Hub 或云提供商(如 Azure Container Registry)提供的注册表之一。对于我们在整本书中工作的特定项目,我选择依赖 GitHub Container Registry (docs.github.com/en/packages),原因有以下几点:

  • 它对所有个人 GitHub 账户都可用,并且对公共存储库是免费的。您也可以用它来使用私有存储库,但有一些限制。

  • 它允许您无需速率限制即可匿名访问公共容器镜像,即使使用免费账户。

  • 它完全集成到 GitHub 生态系统,使得从镜像导航到相关源代码变得无缝。

  • 它允许您即使使用免费账户也能生成多个令牌来访问注册表。建议您为每个用例发行不同的访问令牌,GitHub 通过个人访问令牌(PAT)功能让您这样做,且对令牌数量没有限制。此外,如果您从 GitHub Actions 访问 GitHub Container Registry,您不需要配置 PAT——GitHub 会自动为您生成一个令牌,并且它会安全地提供给自动化管道,无需进一步配置。

将镜像发布到 GitHub 容器注册库需要你进行身份验证,为此你需要一个个人访问令牌(PAT)。前往你的 GitHub 账户,导航到设置 > 开发者设置 > 个人访问令牌,并选择生成新令牌。输入一个有意义的名称,并分配 write:packages 范围以给令牌发布镜像到容器注册库的权限(图 6.6)。最后,生成令牌并复制其值。GitHub 只会显示一次令牌值。请确保你保存它,因为你很快就会需要它。

06-06

图 6.6 一个个人访问令牌,允许写入访问 GitHub 容器注册库

接下来,打开一个终端窗口,并使用 GitHub 容器注册库进行身份验证(确保你的 Docker 引擎正在运行)。当被要求时,输入用户名(你的 GitHub 用户名)和密码(你的 GitHub PAT):

$ docker login ghcr.io

如果你一直跟着做,你应该在你的机器上有了自定义的 my-java-image Docker 镜像。如果没有,请确保你执行了上一节中描述的操作。

容器镜像遵循常见的命名约定,这些约定被 OCI 兼容的容器注册库采用:<container_registry>//[:]:

  • 容器注册库—存储镜像的容器注册库的主机名。当使用 Docker Hub 时,主机名是 docker.io,通常省略。如果你没有指定注册库,Docker 引擎会隐式地将 docker.io 前缀添加到镜像名称。当使用 GitHub 容器注册库时,主机名是 ghcr.io,并且必须是显式的。

  • 命名空间—当使用 Docker Hub 或 GitHub 容器注册库时,命名空间将是你的 Docker/GitHub 用户名,全部小写。在其他注册库中,它可能是存储库的路径。

  • 名称和标签—镜像名称代表包含你镜像所有版本的存储库(或 )。它可选地后面跟着一个标签以选择特定版本。如果没有定义标签,默认将使用最新标签。

可以通过指定名称来下载官方镜像,如 ubuntu 或 postgresql,该名称会被隐式转换为完全限定名称,如 docker.io/library/ubuntu 或 docker.io/library/postgres。

当你将镜像上传到 GitHub 容器注册库时,你必须使用完全限定名称,格式为 ghcr.io/<你的 _github_username>/<image_name>。例如,我的 GitHub 用户名是 ThomasVitale,我所有的个人镜像都命名为 ghcr.io/thomasvitale/<image_name>(注意用户名是如何转换为小写的)。

由于你之前使用 my-java-image:1.0.0 的名称构建了一个镜像,在将其发布到容器注册库之前,你必须给它分配一个完全限定名称(即,你需要 标记 镜像)。你可以使用 docker tag 命令这样做:

$ docker tag my-java-image:1.0.0 \
    ghcr.io/<your_github_username>/my-java-image:1.0.0

然后,你最终可以将其 push 到 GitHub 容器注册库:

$ docker push ghcr.io/<your_github_username>/my-java-image:1.0.0

访问您的 GitHub 账户,导航到您的个人资料页面,并进入“包”部分。您应该看到一个名为“my-java-image”的新条目。如果您点击它,您将找到您刚刚发布的 ghcr.io/<your_github_username>/my-java-image:1.0.0 镜像(图 6.7)。默认情况下,托管您新镜像的仓库将是私有的。

06-07

图 6.7 GitHub 容器注册库是一个公共注册库,您可以使用它来发布您的容器镜像。您可以在 GitHub 个人资料中的“包”部分查看您的镜像。

提示:从相同的“包”页面,您也可以通过侧边栏中的链接进入“包设置”,删除已发布的镜像或整个镜像仓库(在 GitHub 中称为“包”)。

这部分内容到此结束。现在您已经了解了容器镜像的主要功能、创建方法和发布方法,让我们更深入地探讨如何将 Spring Boot 应用程序打包成镜像。

6.2 将 Spring Boot 应用程序打包成容器镜像

在前面的章节中,我们构建了具有 REST API 和数据库集成的目录服务应用程序。在本节中,作为将其部署到 Kubernetes 之前的中间步骤,我们将构建一个镜像,以便在 Docker 上以容器形式运行目录服务。

首先,我将回顾一些您在将 Spring Boot 应用程序打包成容器镜像时应考虑的方面。然后,我将向您展示如何使用 Dockerfile 和 Cloud Native Buildpacks 来实现这一点。

6.2.1 为容器化准备 Spring Boot

将 Spring Boot 应用程序打包成容器镜像意味着该应用程序将在一个隔离的环境中运行,包括计算资源和网络。由此隔离可能引发两个主要问题:

  • 您如何通过网络访问该应用程序?

  • 您如何使其与其他容器交互?

我们将接下来探讨这两个问题。

通过端口转发暴露应用程序服务

在第二章中,当您将目录服务作为容器运行时,您将应用程序暴露服务的端口 8080 映射到本地机器上的端口 8080。完成此操作后,您可以通过访问 http://localhost:8080 来使用该应用程序。您在那里所做的被称为端口转发端口映射端口发布,它用于使您的容器化应用程序对外部世界可访问。

默认情况下,容器加入 Docker 主机内部的隔离网络。如果您想从本地网络访问任何容器,您必须明确配置端口映射。例如,当您运行目录服务应用程序时,您将映射指定为 docker run 命令的参数:-p 8080:8080(其中第一个是外部端口,第二个是容器端口)。图 6.8 说明了这是如何工作的。

06-08

图 6.8 端口映射允许您通过将容器网络流量转发到外部世界来访问容器化应用程序暴露的服务。

使用 Docker 内置的 DNS 服务器进行服务发现

多亏了端口转发,上一章中的 Catalog Service 应用程序能够通过 URL jdbc:postgresql://localhost:5432 访问 PostgreSQL 数据库服务器,即使它是在容器内运行的。这种交互在图 6.9 中展示。然而,当将 Catalog Service 作为容器运行时,您将无法再这样做,因为 localhost 将代表您的容器内部而不是您的本地机器。您如何解决这个问题?

06-09

图 6.9 通过端口映射,Catalog Service 应用程序可以与 PostgreSQL 容器交互,从而使数据库对外界可访问。

Docker 内置了一个 DNS 服务器,它可以使同一网络中的容器能够通过容器名称而不是主机名或 IP 地址找到彼此。例如,Catalog Service 将能够通过 URL jdbc:postgresql://polar-postgres:5432 调用 PostgreSQL 服务器,其中 polar-postgres 是容器名称。图 6.10 展示了它是如何工作的。在本章的后面部分,您将看到如何在代码中实现这一结果。

06-10

图 6.10 由于 Catalog Service 容器和 PostgreSQL 容器都在同一个 Docker 网络上,因此 Catalog Service 容器可以直接与 PostgreSQL 容器交互。

因此,在继续之前,让我们创建一个网络,在这个网络中,Catalog Service 和 PostgreSQL 可以通过容器名称而不是 IP 地址或主机名相互通信。您可以从任何终端窗口运行此命令:

$ docker network create catalog-network

接下来,验证网络是否已成功创建:

$ docker network ls
NETWORK ID     NAME              DRIVER    SCOPE
178c7a048fa9   catalog-network   bridge    local
...

您可以启动一个 PostgreSQL 容器,指定它应该成为您刚刚创建的 catalog-network 的一部分。使用 --net 参数确保容器将加入指定的网络并依赖于 Docker 内置的 DNS 服务器:

$ docker run -d \
    --name polar-postgres \
    --net catalog-network \
    -e POSTGRES_USER=user \
    -e POSTGRES_PASSWORD=password \
    -e POSTGRES_DB=polardb_catalog \
    -p 5432:5432 \
    postgres:14.4

如果命令失败,您可能还有第五章中运行的 PostgreSQL 容器。使用 docker rm -fv polar-postgres 删除它,然后再次运行前面的命令。

6.2.2 使用 Dockerfile 容器化 Spring Boot

云原生应用程序是自包含的。Spring Boot 允许您将应用程序打包为独立的 JAR 文件,包括它们运行所需的一切,除了运行时环境。这使得容器化变得非常简单,因为您在容器镜像中除了 JAR 文件之外还需要的是操作系统和 JRE。本节将向您展示如何使用 Dockerfile 容器化 Catalog Service 应用程序。

首先,您需要确定您想要基于哪个镜像。您可以选择一个 Ubuntu 镜像,就像我们之前做的那样,然后明确安装 JRE,或者您可以选择一个已经提供了 JRE 的基础镜像,这会更方便。所有主要的 OpenJDK 发行版都在 Docker Hub 上提供了相关的镜像。请随意选择您喜欢的。在这个例子中,我将使用 Eclipse Temurin 17,这是我迄今为止在本地使用相同的 OpenJDK 发行版。然后您需要将目录服务 JAR 文件复制到镜像本身中。最后,声明容器执行的入口点是运行 JRE 上的应用程序的命令。

打开您的目录服务项目(catalog-service),在根目录下创建一个名为 Dockerfile(无扩展名)的空文件。该文件将包含容器化您的应用程序的配方。

列表 6.2 描述目录服务镜像的 Dockerfile

FROM eclipse-temurin:17                                ❶
WORKDIR workspace                                      ❷
ARG JAR_FILE=build/libs/*.jar                          ❸
COPY ${JAR_FILE} catalog-service.jar                   ❹
ENTRYPOINT ["java", "-jar", "catalog-service.jar"]     ❺

❶ 预装 Eclipse Temurin JRE 的 Ubuntu 基础镜像

❷ 将当前工作目录更改为“工作区”

❸ 构建指定应用程序 JAR 文件在项目中的位置的参数

❹ 将应用程序 JAR 文件从本地机器复制到镜像

❺ 设置容器入口点以运行应用程序

此 Dockerfile 声明了一个 JAR_FILE 参数,可以在使用 docker build 命令创建镜像时指定。

在继续之前,您需要构建目录服务应用程序的 JAR 工件。打开一个终端窗口,导航到目录服务项目的根目录。首先,构建 JAR 工件:

$ ./gradlew clean bootJar

默认情况下,Dockerfile 脚本将从 Gradle 使用的位置路径复制应用程序的 JAR 文件:build/libs/。所以如果您使用 Gradle,您可以通过运行以下命令来构建容器镜像:

$ docker build -t catalog-service .

如果您使用 Maven,您可以使用以下命令指定 Maven 使用的位置作为构建参数(不要忘记最后的点):

$ docker build --build-arg JAR_FILE=target/*.jar -t catalog-service .

在任何情况下,您最终都会得到一个打包为容器镜像的目录服务应用程序。由于我们没有指定任何版本,该镜像将自动标记为最新。让我们验证它是否工作。

记住我在上一节中提到的两个方面:端口转发和使用 Docker 内置 DNS 服务器。您可以通过向 docker run 命令添加两个参数来处理它们:

  • -p 9001:9001 将容器内部的 9001 端口(目录服务暴露其服务的地方)映射到您的 localhost 上的 9001 端口。

  • --net catalog-network 将目录服务容器连接到您之前创建的 catalog-network,以便它可以联系 PostgreSQL 容器。

这仍然不够。在上一章中,我们为目录服务设置了 spring.datasource.url 属性,值为 jdbc:postgresql://localhost:5432/polardb_catalog。由于它指向 localhost,因此在容器内部将无法工作。你已经知道如何在不重新编译的情况下从外部配置 Spring Boot 应用程序,对吧?一个环境变量就可以做到。我们需要覆盖 spring.datasource.url 属性并指定相同的 URL,将 localhost 替换为 PostgreSQL 容器名称:polar-postgres。使用另一个环境变量,我们还可以启用 testdata Spring 配置文件以触发在目录中创建测试数据:

$ docker run -d \
    --name catalog-service \
    --net catalog-network \
    -p 9001:9001 \
    -e SPRING_DATASOURCE_URL=
➥jdbc:postgresql://polar-postgres:5432/polardb_catalog \
    -e SPRING_PROFILES_ACTIVE=testdata \
    catalog-service

这条命令相当长,不是吗?不过,我保证你不会长时间使用 Docker CLI。在本章的后面部分,我将介绍 Docker Compose。

打开一个终端窗口,调用应用程序,并验证它是否正确工作,就像在第五章中做的那样:

$ http :9001/books

完成后,记得删除两个容器:

$ docker rm -f catalog-service polar-postgres

你刚刚采用的方法对于在开发环境中实验 Docker 和理解镜像的工作原理是完全可以的,但在实现生产级别的镜像之前,你需要考虑几个方面。这就是下一节的主题。

6.2.3 为生产构建容器镜像

开始使用 Dockerfile 可能并不那么困难,但构建生产级别的镜像可能具有挑战性。在本节中,你将看到如何改进上一节中构建的镜像。

你将使用 Spring Boot 提供的分层-JAR 功能来构建更高效的镜像。然后,你将考虑与容器镜像相关的关键安全方面。最后,我将讨论在选择将 Dockerfile 与云原生构建包用于应用程序容器化时需要考虑的一些因素。

性能

在构建容器镜像时,你应该考虑构建时和运行时的性能。表征 OCI 镜像的分层架构使得在构建镜像时可以缓存和重用未更改的层。容器注册库按层存储镜像,因此当你拉取新版本时,只有更改过的层会被下载。考虑到你将节省所有应用程序实例的时间和带宽,这在云环境中是一个相当大的优势。

在上一节中,你将目录服务独立 JAR 文件复制到镜像的一个层中。结果,每次你更改应用程序中的内容时,整个层都必须重建。考虑这样一个场景:你只是向应用程序中添加了一个新的 REST 端点。即使所有的 Spring 库和依赖项都没有改变,唯一的区别在于你的代码,你也必须重建整个层,因为所有内容都是一起的。我们可以做得更好。Spring Boot 可以帮助我们。

将 uber-JAR 放入容器镜像中从未是高效的。JAR 工件是一个包含应用程序使用的所有依赖项、类和资源的压缩归档。所有这些文件都组织在 JAR 内的文件夹和子文件夹中。我们可以扩展标准 JAR 工件,并将每个文件夹放在不同的容器镜像层上。从 2.3 版本开始,Spring Boot 通过引入一种新的打包应用程序为 JAR 工件的方式:分层 JAR 模式,使其变得更加高效。自 Spring Boot 2.4 以来,这已成为默认模式,因此您无需任何额外配置即可使用新功能。

使用分层 JAR 模式打包的应用程序由层组成,类似于容器镜像的工作方式。这个新特性对于构建更高效的镜像非常出色。当使用新的 JAR 打包时,我们可以扩展 JAR 工件,然后为每个 JAR 层创建不同的镜像层。目标是让您的类(这些类更改得更频繁)与项目依赖项(这些依赖项更改得较少)在不同的层上。

默认情况下,Spring Boot 应用程序被打包成由以下层组成的 JAR 工件,从最低层开始:

  • dependencies—对于添加到项目中的所有主要依赖项

  • spring-boot-loader—用于 Spring Boot 加载器组件使用的类

  • snapshot-dependencies—对于所有快照依赖项

  • application—对于您的应用程序类和资源

如果您考虑之前的场景,其中您向现有应用程序添加了新的 REST 端点,那么在容器化时只需构建应用程序层。此外,当您在生产中升级应用程序时,只需将新层下载到容器正在运行的节点上,这使得升级更快、更便宜(尤其是在云平台上,云平台按使用的带宽计费)。

让我们更新之前的 Dockerfile,使用分层 JAR 模式更有效地容器化目录服务。使用这种新策略意味着需要进行一些准备工作,将 JAR 文件复制到镜像中并扩展成之前描述的四个层。我们不希望在镜像中保留原始 JAR 文件,否则我们的优化计划将无法工作。Docker 为此提供了一个解决方案:多阶段构建

我们将工作分为两个阶段。在第一阶段,我们从 JAR 文件中提取层。第二阶段是将每个 JAR 层放置到单独的镜像层中。最终,第一阶段的结果将被丢弃(包括原始 JAR 文件),而第二阶段将生成最终的容器镜像。

列表 6.3 更高效的 Dockerfile 构建目录服务镜像

FROM eclipse-temurin:17 AS builder                      ❶
WORKDIR workspace
ARG JAR_FILE=build/libs/*.jar                           ❷
COPY ${JAR_FILE} catalog-service.jar                    ❸
RUN java -Djarmode=layertools -jar 
➥ catalog-service.jar extract                          ❹

FROM eclipse-temurin:17                                 ❺
WORKDIR workspace 
COPY --from=builder workspace/dependencies/ ./          ❻
COPY --from=builder workspace/spring-boot-loader/ ./ 
COPY --from=builder workspace/snapshot-dependencies/ ./ 
COPY --from=builder workspace/application/ ./ 
ENTRYPOINT ["java", 
➥ "org.springframework.boot.loader.JarLauncher"]       ❼

❶ 第一阶段的 OpenJDK 基础镜像

❷ 指定应用程序 JAR 文件在项目中的位置的构建参数

❸ 将应用程序 JAR 文件从本地机器复制到“工作区”文件夹内的镜像中

❹ 从存档中提取层,应用分层-JAR 模式

❺ 第二阶段的 OpenJDK 基础镜像

❻ 从“工作区”文件夹中的第一个阶段复制每个 JAR 层到第二个阶段

❼ 使用 Spring Boot Launcher 从层而不是 uber-JAR 启动应用程序

注意:如果你想要更改 JAR 文件中层的配置怎么办?像往常一样,Spring Boot 提供了合理的默认值,但你也可以自定义它并适应你的需求。也许你的项目有内部共享依赖项,你可能希望将它们放在一个单独的层中,因为它们比第三方依赖项更改得更频繁。你可以通过 Spring Boot Gradle 或 Maven 插件来实现这一点。有关更多信息,请参阅 Spring Boot 文档spring.io/projects/spring-boot

构建和运行容器的过程与之前相同,但现在镜像更高效,在构建和执行时间上进行了优化。然而,它仍然不适合生产环境。那么安全性呢?这是下一节的主题。

安全性

安全性是一个经常被初学者低估的关键方面,尤其是在使用 Docker 和容器化技术时。你应该意识到,容器默认使用 root 用户运行,这可能会让它们获得对 Docker 主机的 root 访问权限。你可以通过创建一个非特权用户并使用它来运行 Dockerfile 中定义的入口点进程,遵循最小权限原则来降低风险。

考虑你为目录服务编写的 Dockerfile。你可以通过添加新步骤来创建一个新的非 root 用户来运行应用程序来改进它。

列表 6.4 更安全的 Dockerfile 以构建目录服务镜像

FROM eclipse-temurin:17 AS builder
WORKDIR workspace
ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} catalog-service.jar
RUN java -Djarmode=layertools -jar catalog-service.jar extract

FROM eclipse-temurin:17
RUN useradd spring           ❶
USER spring                  ❷
WORKDIR workspace
COPY --from=builder workspace/dependencies/ ./
COPY --from=builder workspace/spring-boot-loader/ ./
COPY --from=builder workspace/snapshot-dependencies/ ./
COPY --from=builder workspace/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

❶ 创建一个“spring”用户

❷ 配置“spring”为当前用户

如前所述,你不应该在容器镜像中存储像密码或密钥这样的机密信息。即使它们在上层被移除,它们仍然会保留在原始层中,并且容易被访问。

最后,使用最新的基础镜像和库在 Dockerfile 中也是至关重要的。在部署管道中扫描容器镜像以查找漏洞是一种最佳实践,应该被采纳并自动化。在第三章中,你学习了如何使用 grype 扫描代码库中的漏洞。现在我们将用它来扫描容器镜像。

使用更新的 Dockerfile,为目录服务构建一个新的容器镜像。打开一个终端窗口,导航到目录服务根文件夹,并运行此命令(别忘了最后的点):

$ docker build -t catalog-service .

接下来,使用 grype 检查新创建的镜像是否包含任何漏洞:

$ grype catalog-service

你是否发现了任何严重漏洞?讨论供应链安全和相关风险管理超出了本书的范围。我想向你展示如何执行和自动化应用程序工件的安全扫描,但我会让你自己跟进扫描结果。我无法强调得更多,即定义组织的安全策略并尽可能在整个价值流中自动化其合规性验证是多么重要。

在本节中,我提到了在构建生产级容器镜像时应考虑的一些基本方面,但还有更多内容需要探讨。是否有另一种构建生产级容器镜像的方法?下一节将介绍另一种选择。

Dockerfiles 或 Buildpacks

Dockerfiles 非常强大,它们让你对结果有完全细粒度的控制。然而,它们需要额外的关注和维护,可能会在你的价值流中引起一些挑战。

作为开发者,你可能不想处理我们讨论的所有性能和安全问题。你可能更愿意专注于应用程序代码。毕竟,迁移到云的一个原因是为了更快地向客户交付价值。添加 Dockerfile 步骤并考虑所有这些问题可能不适合你。

作为操作员,当容器镜像由 Dockerfile 构建时,可能很难在组织内部控制并保护供应链。在脚本编写完美的 Dockerfile 并复制到多个存储库以供不同应用程序使用是很常见的。但是,很难让所有团队保持一致,验证对批准的 Dockerfile 的遵守,在整个组织内同步任何更改,并了解谁负责什么。

Cloud Native Buildpacks 提供了一种不同的方法,侧重于一致性、安全性、性能和治理。作为开发者,你将获得一个工具,该工具可以从你的应用程序源代码自动构建一个生产就绪的 OCI 镜像,而无需编写 Dockerfile。作为操作员,你将获得一个工具,该工具定义、控制和保护整个组织中的应用程序工件。

最终,使用 Dockerfile 或像 Buildpacks 这样的工具的决定取决于你的组织和需求。两种方法都是有效的,并且在生产中使用。一般来说,我的建议是使用 Buildpacks,除非没有理由不这样做。

注意:另一种无需编写 Dockerfile 即可将 Java 应用程序打包为容器镜像的选项是使用 Jib,这是由 Google 开发的一个 Gradle 和 Maven 插件(github.com/GoogleContainerTools/jib)。

在下一节和本书的其余部分,我们将使用 Cloud Native Buildpacks 而不是 Dockerfile。对我而言,向您展示 Dockerfile 的工作原理非常重要,因为它使得理解容器镜像特性和层变得更容易。此外,我还想向您展示如何编写一个基本的 Dockerfile 来容器化 Spring Boot 应用程序,以突出所需的内容并展示应用程序 JAR 在容器内的执行。最后,当出现问题时,您将更容易调试容器,即使它们是由 Buildpacks 自动生成的,因为您现在知道如何从头开始构建镜像。如果您想了解更多关于 Spring Boot 应用程序的 Dockerfile,我建议您查看官方文档(spring.io/projects/spring-boot)。

6.2.4 使用 Cloud Native Buildpacks 容器化 Spring Boot

Cloud Native Buildpacks (buildpacks.io) 是由 CNCF 托管的项目,旨在“将您的应用程序源代码转换为可以在任何云上运行的镜像。”在第一章介绍容器时,我强调了 PaaS 平台如 Heroku 和 Cloud Foundry 实际上在幕后使用容器,在运行之前将您的应用程序源代码转换为容器。Buildpacks 是他们用来完成这个任务的工具。

Cloud Native Buildpacks 是基于 Heroku 和 Pivotal 多年运行云原生应用程序(在他们的 PaaS 平台上作为容器)的经验开发和改进的。这是一个成熟的项目,自 Spring Boot 2.3 以来,它已经集成到 Gradle 和 Maven 的 Spring Boot 插件中,因此您不需要安装专门的 Buildpacks CLI(pack)。

这些是它的一些特性:

  • 它可以自动检测应用程序的类型,并在不需要 Dockerfile 的情况下对其进行打包。

  • 它支持多种语言和平台。

  • 它通过缓存和分层实现高性能。

  • 它保证了可重复构建。

  • 它在安全性方面遵循最佳实践。

  • 它可以生成生产级别的图像。

  • 它支持使用 GraalVM 构建原生镜像。

注意:如果您想了解更多关于 Cloud Native Buildpacks 的信息,我建议您观看“Cloud Native Buildpacks with Emily Casey”(mng.bz/M0xB)。Emily Casey 是 Buildpacks 核心团队的一员。

容器生成过程由一个包含如何容器化您的应用程序的完整信息的 builder 镜像进行编排。这些信息以 buildpacks 的序列形式提供,每个 buildpacks 都针对应用程序的特定方面(例如操作系统、OpenJDK 和 JVM 配置)。Spring Boot 插件采用了 Paketo Buildpacks builder,这是 Cloud Native Buildpacks 规范的实现,为包括 Java 和 Spring Boot 在内的许多类型的应用程序提供支持(paketo.io)。

Paketo 构建器组件依赖于一系列默认的构建包来进行实际的构建操作。这种结构高度模块化和可定制。您可以向序列中添加新的构建包(例如,向应用程序添加监控代理),替换现有的构建包(例如,用 Microsoft OpenJDK 替换默认的 Bellsoft Liberica OpenJDK),或者甚至完全使用不同的构建器镜像。

注意:Cloud Native Buildpacks 项目管理着一个注册表,您可以在其中发现和分析可用于容器化应用程序的构建包,包括 Paketo 实现中的所有构建包(registry.buildpacks.io)。

Spring Boot 插件提供的 Buildpacks 集成可以在位于您的 Catalog Service 项目中的 build.gradle 文件中进行配置。让我们配置镜像名称并通过环境变量定义要使用的 Java 版本。

列表 6.5 容器化 Catalog 服务配置

bootBuildImage {                              ❶
  imageName = "${project.name}"               ❷
  environment = ["BP_JVM_VERSION" : "17.*"]   ❸
}

❶ 使用 Buildpacks 构建 OCI 镜像的 Spring Boot 插件任务

❷ 要构建的 OCI 镜像的名称。该名称与项目 Gradle 配置中定义的名称相同。在本地工作时,我们依赖于隐式的“最新”标签,而不是版本号。

❸ 需要在镜像中安装的 JVM 版本。它使用最新的 Java 17 版本。

运行以下命令继续构建镜像:

$ ./gradlew bootBuildImage

警告:在撰写本文时,Paketo 项目正在努力添加对 ARM64 镜像的支持。您可以在 GitHub 上的 Paketo Buildpacks 项目中跟踪该功能的进展:github.com/paketo-buildpacks/stacks/issues/51。在它完成之前,您仍然可以使用 Buildpacks 构建容器并通过 Docker Desktop 在 Apple Silicon 计算机上运行它们。然而,构建过程和应用启动阶段将比通常慢。在官方支持添加之前,您还可以使用以下命令,指向具有 ARM64 支持的实验性 Paketo Buildpacks 版本:./gradlew bootBuildImage --builder ghcr.io/thomasvitale/java-builder-arm64。请注意,这是实验性的,并不适合生产环境。有关更多信息,您可以参考 GitHub 上的文档:github.com/ThomasVitale/paketo-arm64

第一次运行任务时,将花费一分钟下载 Buildpacks 创建容器镜像所使用的包。第二次,只需几秒钟。如果你仔细查看命令的输出,你可以看到 Buildpacks 生成镜像所执行的所有步骤。这些步骤包括添加 JRE 和使用 Spring Boot 构建的分层 JAR。插件接受更多属性来自定义其行为,例如提供你自己的构建组件而不是 Paketo 的。请查看官方文档以获取完整的配置选项列表(spring.io/projects/spring-boot)。

让我们再次尝试以容器形式运行 Catalog 服务,但这次我们将使用 Buildpacks 生成的镜像。请记住,首先按照 6.2.1 节中的说明启动 PostgreSQL 容器:

$ docker run -d \
    --name catalog-service \
    --net catalog-network \
    -p 9001:9001 \
    -e SPRING_DATASOURCE_URL=
➥jdbc:postgresql://polar-postgres:5432/polardb_catalog \
    -e SPRING_PROFILES_ACTIVE=testdata \
    catalog-service

警告:如果你在 Apple Silicon 计算机上运行容器,之前的命令可能会返回类似“WARNING: 请求的镜像的平台(linux/amd64)与检测到的宿主平台(linux/arm64/v8)不匹配,且未请求特定平台。”的消息。在这种情况下,你需要将此附加参数包含到之前的命令中(在镜像名称之前),直到 Paketo Buildpacks 添加对 ARM64 的支持:--platform linux/amd64。

打开一个浏览器窗口,在 http://localhost:9001/books 上调用应用程序,并验证其是否正常工作。完成后,请记住删除 PostgreSQL 和 Catalog 服务容器:

$ docker rm -f catalog-service polar-postgres

最后,你可以移除用于使 Catalog 服务与 PostgreSQL 通信的网络。在下一节介绍 Docker Compose 之后,你将不再需要它:

$ docker network rm catalog-network

自从 Spring Boot 2.4 版本开始,你也可以配置 Spring Boot 插件直接将镜像发布到容器注册库。为此,你首先需要在 build.gradle 文件中添加用于与特定容器注册库进行身份验证的配置。

列表 6.6 对 Catalog 服务进行容器化的配置

bootBuildImage {
  imageName = "${project.name}"
  environment = ["BP_JVM_VERSION" : "17.*"]

  docker {                                                   ❶
    publishRegistry {                                        ❷
      username = project.findProperty("registryUsername") 
      password = project.findProperty("registryToken") 
      url = project.findProperty("registryUrl") 
    } 
  } 
}

❶ 配置与容器注册库连接的章节

❷ 配置对发布容器注册库进行身份验证的章节。这些值作为 Gradle 属性传递。

如何与容器注册库进行身份验证的详细信息被外部化为 Gradle 属性,这既是为了灵活性(你可以在不更改 Gradle 构建的情况下将镜像发布到不同的注册库)也是为了安全(特别是令牌,永远不应该包含在版本控制中)。

记住这个关于凭证的黄金法则:你永远不应该泄露你的密码。永远不要!如果你需要代表你授权某些服务访问资源,你应该依赖访问令牌。Spring Boot 插件允许你使用密码与注册表进行身份验证,但你应该使用令牌。在第 6.1.3 节中,你已经在 GitHub 上生成了一个个人访问令牌,以便你从本地环境将镜像推送到 GitHub 容器注册表。如果你不再知道它的值,可以自由地按照我在本章前面解释的步骤生成一个新的令牌。

最后,你可以通过运行 bootBuildImage 任务来构建和发布镜像。使用 --imageName 参数,你可以定义一个完全限定的镜像名称,因为容器注册表需要。使用 --publishImage 参数,你可以指示 Spring Boot 插件直接将镜像推送到容器注册表。此外,记得通过 Gradle 属性传递容器注册表的值:

$ ./gradlew bootBuildImage \
    --imageName ghcr.io/<your_github_username>/catalog-service \
    --publishImage \
    -PregistryUrl=ghcr.io \
    -PregistryUsername=<your_github_username> \
    -PregistryToken=<your_github_token>

提示:如果你在 ARM64 机器(例如苹果硅电脑)上工作,你可以在之前的命令中添加 --builder ghcr.io/thomasvitale/java-builder-arm64 参数来使用带有 ARM64 支持的实验性 Paketo Buildpacks 版本。请注意,这是实验性的,并不适合生产环境。更多信息,你可以参考 GitHub 上的文档:github.com/ThomasVitale/paketo-arm64。在没有官方支持添加之前(github.com/paketo-buildpacks/stacks/issues/51),你仍然可以使用 Buildpacks 来构建容器并通过 Docker Desktop 在苹果硅电脑上运行它们,但构建过程和应用启动阶段将比通常慢。

一旦命令成功完成,请转到你的 GitHub 账户,导航到你的个人资料页面,并进入“包”部分。你应该会看到一个新目录服务条目(默认情况下,托管容器镜像的包是私有的),类似于你在第 6.1.3 节中发布的 my-java-image 所见到的。如果你点击目录服务条目,你会找到你刚刚发布的 ghcr.io/<你的 GitHub 用户名>/catalog-service:latest 镜像(图 6.11)。

06-11

图 6.11 发布到 GitHub 容器注册表的镜像组织为“包”。

然而,目录服务包尚未链接到你的目录服务源代码仓库。稍后,我会向你展示如何使用 GitHub Actions 自动化构建和发布你的镜像,这使得从构建它们的源代码仓库上下文中发布镜像成为可能。

现在,让我们删除在发布镜像时创建的 catalog-service 包,以避免您开始使用 GitHub Actions 发布镜像时产生任何冲突。从 catalog-service 包页面(图 6.11),点击侧边菜单中的“Package Settings”,滚动到设置页面的底部,然后点击“Delete This Package”(图 6.12)。

06-12

图 6.12 删除手动创建的 catalog-service 包。

注意:到目前为止,我们一直在使用隐式的 latest 标签来命名容器镜像。在生产场景中,这并不推荐。在第十五章中,您将了解在发布应用程序时如何处理版本。在此之前,我们将依赖隐式的 latest 标签。

6.3 使用 Docker Compose 管理 Spring Boot 容器

Cloud Native Buildpacks 允许您快速高效地将 Spring Boot 应用程序容器化,而无需自己编写 Dockerfile。但是,当涉及到运行多个容器时,Docker CLI 可能会有些繁琐。在终端窗口中编写命令可能会出错,难以阅读,而且在应用版本控制时也会遇到挑战。

Docker Compose 提供了比 Docker CLI 更好的体验。您不是使用命令行,而是使用描述您想要运行哪些容器及其特性的 YAML 文件。使用 Docker Compose,您可以在一个地方定义组成系统的所有应用程序和服务,并可以一起管理它们的生命周期。

在本节中,您将配置使用 Docker Compose 执行 Catalog Service 和 PostgreSQL 容器的执行。然后,您将学习如何调试在容器中运行的 Spring Boot 应用程序。

如果您已安装 Docker Desktop for Mac 或 Docker Desktop for Windows,您已经安装了 Docker Compose。如果您使用的是 Linux,请访问 Docker Compose 安装页面 www.docker.com,并按照您发行版的说明进行操作。在任何情况下,您都可以通过运行命令 docker-compose --version 来验证 Docker Compose 是否正确安装。

6.3.1 使用 Docker Compose 管理容器生命周期

Docker Compose 的语法非常直观且易于理解。通常,它可以与 Docker CLI 参数一一对应。docker-compose.yml 文件的两个根部分是 version,其中您指定要使用 Docker Compose 的哪个语法,以及 services,包含您想要运行的所有容器的规范。您还可以添加其他可选的根级部分,如 volumes 和 networks。

注意:如果您不添加任何网络配置,Docker Compose 将自动为您创建一个,并将文件中的所有容器连接到它。这意味着它们可以通过容器名称相互交互,依赖于 Docker 内置的 DNS 服务器。

将所有与部署相关的脚本收集到一个单独的代码库中,并在可能的情况下,到一个单独的仓库中,这是一种良好的做法。继续在 GitHub 上创建一个新的 polar-deployment 仓库。它将包含运行 Polar Bookshop 系统所需的所有 Docker 和 Kubernetes 脚本。在仓库内部,创建一个“docker”文件夹来托管 Polar Bookshop 的 Docker Compose 配置。在本书的源代码中,你可以参考 Chapter06/06-end/ polar-deployment 以获取最终结果。

在 polar-deployment/docker 文件夹中,创建一个 docker-compose.yml 文件,并定义如下要运行的服务。

列表 6.7 描述目录服务的 Docker Compose 文件

version: "3.8"                                ❶
services:                                     ❷

  catalog-service:                            ❸
    depends_on:
      - polar-postgres                        ❹
    image: "catalog-service"                  ❺
    container_name: "catalog-service"         ❻
    ports:                                    ❼
      - 9001:9001
    environment:                              ❽
      - BPL_JVM_THREAD_COUNT=50               ❾
      - SPRING_DATASOURCE_URL=
➥jdbc:postgresql://polar-postgres:5432/polardb_catalog
      - SPRING_PROFILES_ACTIVE=testdata       ❿

  polar-postgres:                             ⓫
    image: "postgres:14.4"
    container_name: "polar-postgres"
    ports:
      - 5432:5432
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=polardb_catalog

❶ Docker Compose 语法版本

❷ 包含所有要运行容器的部分

❸ 描述目录服务容器的部分

❹ 目录服务应该在 PostgreSQL 数据库启动后启动。

❺ 运行容器的镜像

❻ 容器的名称

❽ 列出端口映射的部分

❽ 列出环境变量的部分

❾ 一个 Paketo Buildpacks 环境变量,用于配置内存计算的线程数

❿ 启用“testdata”Spring 配置文件

⓫ 描述 polar-postgres 容器的部分

你可能已经注意到目录服务容器存在一个额外的环境变量。在第十五章,你将学习到 Paketo Buildpacks 提供的 Java 内存计算器以及如何为 Spring Boot 应用程序配置 CPU 和内存。现在,只需知道 BPL_JVM_THREAD_COUNT 环境变量用于配置在 JVM 堆栈中分配的线程数。基于 Servlet 的应用程序的默认值是 250。在第三章,我们为 Tomcat 线程池使用了低值,对于 JVM 内存配置来说,这样做同样好,以保持本地容器内存使用量低。你将在本书中部署许多容器(包括应用程序和后端服务),这样的配置有助于在不超载你的计算机的情况下实现这一点。

Docker Compose 默认将两个容器配置在同一个网络中,因此你不需要明确指定,就像之前做的那样。

现在我们来看看如何启动它们。打开一个终端窗口,导航到包含文件的文件夹,并运行以下命令以在分离模式下启动容器:

$ docker-compose up -d

命令完成后,尝试调用位于 http://localhost:9001/books 的目录服务应用,并验证其是否正确工作。然后保持容器运行,继续下一部分,在那里你将调试目录服务应用。

6.3.2 调试 Spring Boot 容器

当您从您的 IDE 中以标准 Java 方式运行 Spring Boot 应用程序时,您可以指定是否希望以调试模式运行。如果您这样做,IDE 将附加一个调试器到运行您应用程序的本地 Java 进程。然而,当您在容器内运行它时,您的 IDE 就不再能这样做,因为进程不是在本地机器上运行的。

幸运的是,在容器中运行的 Spring Boot 应用程序可以像本地运行一样轻松地进行调试。首先,您需要指示容器内的 JVM 在特定端口上监听调试连接。由 Paketo Buildpacks 生成的容器镜像支持运行应用程序在调试模式下的专用环境变量(BPL_DEBUG_ENABLED 和 BPL_DEBUG_PORT)。然后,您需要将调试端口暴露在容器外部,以便您的 IDE 可以访问它。图 6.13 说明了它是如何工作的。

06-13

图 6.13 从容器中,您可以暴露您想要的任何数量的端口。对于 Catalog Service,暴露服务器端口和调试端口。

接下来,更新您的 docker-compose.yml 文件以配置 Catalog Service 应用程序进行调试。

列表 6.8 配置 Catalog Service 以在调试模式下运行

version: "3.8"
services:

  catalog-service:
    depends_on:
      - polar-postgres
    image: "catalog-service"
    container_name: "catalog-service"
    ports:
      - 9001:9001
      - 8001:8001                        ❶
    environment:
      - BPL_JVM_THREAD_COUNT=50
      - BPL_DEBUG_ENABLED=true           ❷
      - BPL_DEBUG_PORT=8001              ❸
      - SPRING_DATASOURCE_URL=
➥jdbc:postgresql://polar-postgres:5432/polardb_catalog
      - SPRING_PROFILES_ACTIVE=testdata
  ...

❶ JVM 将监听调试连接的端口

❷ 激活 JVM 配置以接受调试连接(由 Buildpacks 提供)

❸ 通过端口 8001 上的套接字(由 Buildpacks 提供)接受调试连接。

从终端窗口导航到 docker-compose.yml 文件所在的文件夹,并重新运行以下命令:

$ docker-compose up -d

您会注意到 Docker Compose 足够智能,能够知道 PostgreSQL 容器配置没有改变,因此它对此不会采取任何行动。相反,它将使用新的配置重新加载 Catalog Service 容器。

然后,在您选择的 IDE 中,您需要配置一个远程调试器并将其指向端口 8001。请参考您 IDE 的文档以找到如何操作的说明。图 6.14 展示了如何在 IntelliJ IDEA 中配置远程调试器。

06-14

图 6.14 从 IntelliJ IDEA 调试容器化 Java 应用程序的配置

一旦运行了 Catalog Service,您就可以像它本地运行一样对其进行调试。

本节到此结束。您可以使用以下命令从保存 docker-compose.yml 文件的同一文件夹停止并删除两个容器:

$ docker-compose down

注意:在这本书中,我只涵盖你在成功部署 Spring Boot 应用程序到生产环境中所需的 Docker 主题。如果你对学习更多关于 Docker 镜像、网络、卷、安全和架构感兴趣,请参阅docs.docker.com上的官方文档。此外,Manning 在其目录中也有几本关于该主题的书籍,例如 Elton Stoneman 的《一个月午餐时间学习 Docker》(Manning,2020 年)和 Ian Miell 和 Aidan Hobson Sayers 的《实践 Docker》(第二版,Manning,2019 年)。

当你对应用程序进行更改时,你不想手动构建和发布新的镜像。这是一个自动化工作流引擎(如 GitHub Actions)的工作。下一节将向您展示如何完成我们在第三章中开始的部署管道的提交阶段。

6.4 部署管道:打包和发布

在第三章中,我们开始实施一个部署管道来支持 Polar Bookshop 项目的持续交付。持续交付是一种全面的工程方法,旨在快速、可靠和安全地交付高质量的软件。部署管道是从代码提交到可发布软件的整个旅程自动化的主要模式。我们确定了部署管道的三个主要阶段:提交阶段、验收阶段和生产阶段。

我们将继续关注提交阶段。在开发人员将新代码提交到主线后,这个阶段将经过构建、单元测试、集成测试、静态代码分析和打包。在这个阶段的最后,一个可执行的应用程序工件被发布到一个工件仓库。这是一个发布候选者。第三章涵盖了所有主要步骤,除了发布候选者的最终打包和发布。这就是你将在本节中看到的内容。

6.4.1 在提交阶段构建发布候选

在运行静态代码分析、编译、单元测试和集成测试之后,是时候将应用程序打包成一个可执行工件并发布它了。在我们的案例中,可执行工件是一个容器镜像,我们将将其发布到一个容器注册库。

持续交付的一个基本理念,也存在于 15 个要素方法中,就是你应该只构建一次工件。在提交阶段结束时,我们将生成一个容器镜像,我们可以在部署管道的任何后续阶段(直到生产阶段)重复使用。如果管道在任何一点证明有错误(测试失败),则发布候选者将被拒绝。如果发布候选者成功通过所有后续阶段,则证明其已准备好在生产中部署。

在我们构建了一个可执行的工件之后,我们可以在发布之前执行额外的操作。例如,我们可以扫描它以查找漏洞。这正是我们将使用 grype 所做的事情,就像我们对代码库所做的那样。容器镜像包括应用程序库,但也包括之前安全分析中未包含的系统库。这就是为什么我们需要扫描代码库和工件以查找漏洞。图 6.15 说明了我们将添加到提交阶段以构建和发布候选版本的新步骤。

06-15

图 6.15 在提交阶段结束时,候选版本被发布到一个工件仓库中。在这种情况下,容器镜像被发布到一个容器注册库中。

一旦候选版本发布,多个方可以下载并使用它,包括部署管道中的下一阶段。我们如何确保所有感兴趣的各方都使用来自 Polar Bookshop 项目的合法容器镜像,而不是一个已被破坏的镜像?我们可以通过签名镜像来实现这一点。在发布步骤之后,我们可以添加一个新的步骤来签名候选版本。例如,我们可以使用 Sigstore (www.sigstore.dev),这是一个非营利性服务,它为签名、验证和保护软件完整性提供开源工具。如果您对这个主题感兴趣,我建议您访问该项目的网站。

在下一节中,我将向您展示如何在我们的部署管道的提交阶段实现新步骤。

6.4.2 使用 GitHub Actions 发布容器镜像

GitHub Actions 是一个引擎,您可以直接从您的 GitHub 仓库自动化软件工作流程。工作流程定义通常存储在 GitHub 仓库根目录下的.github/workflows 目录中。

在第三章中,我们开始开发一个工作流程以实现目录服务的部署管道的提交阶段。现在,让我们通过添加进一步步骤来打包和发布应用程序来继续实施。

从您的目录服务项目(catalog-service)中打开提交阶段的工作流程定义(.github/workflows/commit-stage.yml),并定义一些环境变量以存储在构建应用程序容器镜像时所需的某些基本事实。通过使用环境变量,您可以轻松地更改您使用的容器注册库或发布工件版本。请记住,在以下列表中,用您的 GitHub 用户名全部小写替换占位符。第十五章将涵盖软件发布策略,但在此之前,我们将用最新标签而不是版本号来标记每个镜像。

列表 6.9 配置候选版本的详细信息

name: Commit Stage
on: push

env: 
  REGISTRY: ghcr.io                                    ❶
  IMAGE_NAME: <your_github_username>/catalog-service   ❷
  VERSION: latest                                      ❸

jobs:
  ...

❶ 使用 GitHub 容器注册库

❷ 图像的名称。请记住,要将您的 GitHub 用户名全部小写添加进去。

❸ 目前,任何新的镜像都将被标记为“latest”。

接下来,让我们向工作流程中添加一个新的“打包和发布”作业。如果“构建和测试”作业成功完成,并且工作流程在主分支上运行,则新作业将被执行。我们将使用与本地相同的策略将 Catalog Service 打包为容器镜像,依赖于 Spring Boot Gradle 插件提供的 Buildpacks 集成。请注意,我们不会直接推送镜像。这是因为我们首先想要扫描镜像中的漏洞,我们将在稍后进行。现在,按照以下方式更新 commit-stage.yml 文件。

列表 6.10 使用 Buildpacks 将应用程序打包为 OCI 镜像

name: Commit Stage
on: push

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: <your_github_username>/catalog-service
  VERSION: latest

jobs:
  build:
    ...
  package:                                       ❶
    name: Package and Publish 
    if: ${{ github.ref == 'refs/heads/main' }}   ❷
    needs: [ build ]                             ❸
    runs-on: ubuntu-22.04                        ❹
    permissions: 
      contents: read                             ❺
      packages: write                            ❻
      security-events: write                     ❼
    steps: 
      - name: Checkout source code 
        uses: actions/checkout@v3                ❽
      - name: Set up JDK 
        uses: actions/setup-java@v3              ❾
        with: 
          distribution: temurin 
          java-version: 17 
          cache: gradle 
      - name: Build container image 
        run: | 
          chmod +x gradlew 
          ./gradlew bootBuildImage \             ❿
            --imageName 
            ➥ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }} 

❶ 作业的唯一标识符

❷ 仅在主分支上运行作业

❸ 仅当“构建”作业成功完成时运行作业

❹ 在 Ubuntu 22.04 机器上运行作业

❺ 允许检出当前的 Git 仓库

❻ 允许上传镜像到 GitHub 容器注册表

❼ 允许提交安全事件到 GitHub

❽ 检出当前的 Git 仓库(catalog-service)

❾ 安装和配置 Java 运行时

❿ 依赖于 Spring Boot 中的 Buildpacks 集成来构建容器镜像,并为发布候选版本定义名称

在将应用程序打包为容器镜像后,让我们更新 commit-stage.yml 文件以使用 grype 扫描镜像中的漏洞并将报告发布到 GitHub,类似于我们在第三章中所做的。最后,我们可以使用容器注册表进行身份验证并推送代表我们的发布候选版本的镜像。

列表 6.11 扫描镜像中的漏洞并发布

name: Commit Stage
on: push

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: polarbookshop/catalog-service
  VERSION: latest

jobs:
  build:
    ...
  package:
    ...
    steps:
      - name: Checkout source code
        ...
      - name: Set up JDK
        ...
      - name: Build container image
        ...
      - name: OCI image vulnerability scanning 
        uses: anchore/scan-action@v3                         ❶
        id: scan 
        with:                                                ❷
          image: 
          ➥ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }} 
          fail-build: false                                  ❸
          severity-cutoff: high 
          acs-report-enable: true 
      - name: Upload vulnerability report 
        uses: github/codeql-action/upload-sarif@v2           ❹
        if: success() || failure() 
        with: 
          sarif_file: ${{ steps.scan.outputs.sarif }} 
      - name: Log into container registry 
        uses: docker/login-action@v2                         ❺
        with: 
          registry: ${{ env.REGISTRY }}                      ❻
          username: ${{ github.actor }}                      ❼
          password: ${{ secrets.GITHUB_TOKEN }}              ❽
      - name: Publish container image                        ❾
        run: docker push 
        ➥ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }} 

❶ 使用 grype 扫描发布候选镜像以查找漏洞

❷ 扫描的镜像为发布候选版本

❸ 如果镜像中发现漏洞,它不会使构建失败。

❹ 将安全漏洞报告上传到 GitHub(SARIF 格式)

❺ 使用 GitHub 容器注册表进行身份验证

❻ 在之前定义的环境变量中定义的注册表值

❼ 当前用户的 GitHub 用户名,由 GitHub Actions 提供

❽ 用于与注册表进行身份验证的令牌,由 GitHub Actions 提供

❾ 将发布候选版本推送到注册表

在列表 6.11 中,如果发现严重漏洞,我们不会使工作流程失败。然而,您可以在 catalog-service GitHub 仓库的安全部分找到扫描结果。在撰写本文时,在 Catalog Service 项目中没有发现高或关键漏洞,但未来可能会有所不同。正如第三章中提到的,在现实场景中,我建议您根据公司关于供应链安全的政策仔细配置和调整 grype,并在结果不符合规范时使工作流程失败(将 fail-build 属性设置为 true)。有关更多信息,请参阅官方 grype 文档(github.com/anchore/grype)。

在完成部署管道的提交阶段后,确保你的 catalog-service GitHub 仓库是公开的。然后,将你的更改推送到远程仓库的主分支,并在“操作”选项卡中查看工作流程执行结果。

警告:上传漏洞报告的操作需要 GitHub 仓库是公开的。如果你有企业订阅,它仅适用于私有仓库。如果你更喜欢保持你的仓库私有,你需要跳过“上传漏洞报告”步骤。在整个书中,我将假设你为 Polar Bookshop 项目在 GitHub 上创建的所有仓库都是公开的。

来自 GitHub Actions 并以存储库命名的图像将自动关联。工作流程执行完成后,你会在 GitHub catalog-service 仓库主页的侧边栏中找到一个“catalog-service”项目(图 6.16)。点击该项目,你将被引导到 Catalog 服务的容器镜像仓库。

06-16

图 6.16 当使用 GitHub 容器注册库时,你可以将容器镜像存储在源代码旁边。

注意:发布到 GitHub 容器注册库的图像将与相关的 GitHub 代码仓库具有相同的可见性。如果没有与图像关联的存储库,则默认为私有。在整个书中,我将假设你为 Polar Bookshop 构建的图像都可以通过 GitHub 容器注册库公开访问。如果不是这样,你可以转到包的主页,从侧边栏菜单中选择“包设置”,滚动到设置页面的底部,通过点击“更改可见性”按钮使包公开。

干得好!到目前为止,你已经构建了一个暴露 REST API 并与关系型数据库交互的 Spring Boot 应用程序;你为应用程序编写了单元和集成测试;你使用 Flyway 处理数据库模式,使其准备好生产环境;你将所有内容都在容器中运行,并处理了镜像生成、Docker、Cloud Native Buildpacks 和漏洞扫描。下一章将通过深入研究 Kubernetes 来完成这一云原生之旅的第一部分。但在继续之前,休息一下,为自己到目前为止所取得的成就表示祝贺,也许还可以喝上一杯你喜欢的饮料。

Polar Labs

随意将本章学到的知识应用到 Config 服务上。

  1. 配置 Cloud Native Buildpacks 集成并将应用程序打包为容器。

  2. 更新你的 Docker Compose 文件以运行 Config 服务作为容器。

  3. 通过 SPRING_CLOUD_CONFIG_URI 环境变量使用 Config 服务 URL 配置 Catalog 服务,依赖于 Docker 内置的 DNS。

  4. 通过使用 GitHub Actions 实现提交阶段的流程,为 Config 服务启动部署管道。

您可以参考书中附带的代码仓库中的 Chapter06/06-end 文件夹,以查看最终结果(github.com/ThomasVitale/cloud-native-spring-in-action)。

摘要

  • 容器镜像是一种轻量级的可执行包,包含运行应用程序所需的所有内容。

  • 每个镜像由多个层组成,每一层代表由相应指令产生的修改。最终工件可以作为容器运行。

  • 当您运行容器时,会在镜像层之上添加一个额外的可写层。

  • 定义容器镜像的标准方式是通过列出特定文件(称为 Dockerfile)中的指令序列。

  • Dockerfile 充当一个配方,包含构建所需镜像的所有步骤。

  • 性能和安全是构建容器镜像时的重要关注点。例如,您不应该在任何镜像层中存储机密,并且永远不要以 root 用户运行容器。

  • 容器注册库对于 OCI 镜像来说,就像 Maven 仓库对于 Java 库来说一样。容器注册库的例子包括 Docker Hub 和 GitHub Container Registry。

  • 您可以用不同的方式将 Spring Boot 应用程序打包为容器镜像。

  • Dockerfile 提供了最大的灵活性,但需要您负责配置所需的一切。

  • Cloud Native Buildpacks(与 Spring Boot 插件集成)让您可以直接从源代码构建 OCI 镜像,为您优化安全性、性能和存储。

  • 当您以容器形式运行 Spring Boot 应用程序时,您应该考虑您想要向外界开放哪些端口(例如 8080),以及容器是否应该相互通信。如果是的话,您可以使用 Docker DNS 服务器通过容器名称而不是 IP 或主机名来联系同一网络中的容器。

  • 如果您想调试作为容器运行的应用程序,请记住要公开调试端口。

  • Docker Compose 是用于与 Docker 服务器交互的客户端,它提供了比 Docker CLI 更好的用户体验。从 YAML 文件中,您可以管理所有容器。

  • 您可以使用 GitHub Actions 来自动化将应用程序打包为容器镜像、扫描漏洞并将其发布到容器注册库的过程。这是部署管道的提交阶段的一部分。

  • 部署管道的提交阶段的输出是一个发布候选版本。

7 个 Spring Boot 的 Kubernetes 基本概念

本章涵盖

  • 从 Docker 迁移到 Kubernetes

  • 在 Kubernetes 上部署 Spring Boot 应用程序

  • 理解服务发现和负载均衡

  • 构建可扩展和可丢弃的应用程序

  • 建立本地 Kubernetes 开发工作流程

  • 使用 GitHub Actions 验证 Kubernetes 清单

在上一章中,你学习了 Docker 以及镜像和容器的主要特性。通过 Buildpacks 和 Spring Boot,你可以通过一条命令构建一个生产就绪的镜像,甚至无需编写自己的 Dockerfile 或安装额外的工具。使用 Docker Compose,你可以同时控制多个应用程序,这对于微服务架构来说非常方便。但万一容器停止工作怎么办?如果你的容器运行所在的机器(Docker 主机)崩溃了怎么办?如果你想扩展你的应用程序怎么办?这一章将引入 Kubernetes 到你的工作流程中,以解决 Docker 单独无法解决的问题。

作为开发者,配置和管理 Kubernetes 集群不是你的工作。你可能使用云提供商(如亚马逊、微软或谷歌)提供的托管服务,或者由你组织中的专业团队(通常称为“平台团队”)管理的本地服务。目前,你将使用由 minikube 提供的本地 Kubernetes 集群。在本书的后面部分,你将使用云提供商提供的托管 Kubernetes 服务。

在我们作为开发者的日常工作中,我们不希望花费太多时间在基础设施问题上,但了解基础知识是至关重要的。Kubernetes 已经成为事实上的编排工具和容器化部署的通用语言。云提供商已经在 Kubernetes 上构建平台,为开发者提供更好的体验。一旦你了解了 Kubernetes 的工作原理,使用这些平台将变得非常简单,因为你将熟悉这种语言和抽象。

本章将带你了解 Kubernetes 的主要功能,并教你如何为你的 Spring Boot 应用程序创建和管理 Pods、Deployments 和 Services。在这个过程中,你将使你的应用程序能够优雅地关闭,学习如何扩展它们,以及如何使用 Kubernetes 提供的服务发现和负载均衡功能。你还将学习如何使用 Tilt 自动化你的本地开发工作流程,使用 Octant 可视化你的工作负载,以及验证你的 Kubernetes 清单。

注意:本章示例的源代码可在 Chapter07/07-begin 和 Chapter07/07-end 文件夹中找到,这些文件夹包含项目的初始状态和最终状态(github.com/ThomasVitale/cloud-native-spring-in-action)。

7.1 从 Docker 迁移到 Kubernetes

使用 Docker Compose,你可以一次性管理多个容器的部署,包括网络和存储的配置。这非常强大,但仅限于单个机器。

使用 Docker CLI 和 Docker Compose,交互是通过单个 Docker 守护进程进行的,该守护进程管理单个机器上的 Docker 资源,称为 Docker 主机。此外,无法扩展容器。当你需要系统具有云原生属性,如可扩展性和弹性时,所有这些都会受到限制。图 7.1 展示了使用 Docker 时如何针对单个机器进行定位。

07-01

图 7.1 Docker 客户端与 Docker 守护进程交互,该守护进程只能管理其安装的机器上的资源,称为 Docker 主机。应用程序作为容器部署到 Docker 主机上。

你在第二章中学到,当我们从像 Docker 这样的容器运行时转移到像 Kubernetes 这样的编排平台时,我们会改变我们的视角。使用 Docker,我们将容器部署到单个机器上。使用 Kubernetes,我们将容器部署到机器集群上,从而实现可扩展性和弹性。

Kubernetes 客户端使用 API 与 Kubernetes 控制平面交互,该控制平面负责在 Kubernetes 集群中创建和管理对象。在这个新场景中,我们仍然向单个实体发送命令,但它作用于多个机器,而不仅仅是单个机器。图 7.2 展示了使用 Kubernetes 时的逻辑基础设施。

07-02

图 7.2 Kubernetes 客户端与控制平面交互,该控制平面负责管理由一个或多个节点组成的集群中的容器化应用程序。应用程序作为 Pods 部署到集群的节点上。

这些是图 7.2 中显示的主要组件:

  • 集群——运行容器化应用程序的一组节点。它托管控制平面,并包括一个或多个工作节点。

  • 控制平面——集群组件,公开 API 和接口以定义、部署和管理 Pods 的生命周期。它包括实现编排器典型功能的所有基本元素,如集群管理、调度和健康监控。

  • 工作节点——提供 CPU、内存、网络和存储等能力的物理或虚拟机器,以便容器可以运行并连接到网络。

  • Pod——封装应用程序容器的最小可部署单元。

现在你已经对 Kubernetes 基础设施有了很好的理解,让我们看看如何在你的本地机器上创建和管理一个 Kubernetes 集群。

7.1.1 在本地 Kubernetes 集群中工作

在第二章中,我们使用了 minikube (minikube.sigs.k8s.io),这是一个在本地环境中运行 Kubernetes 集群的工具。我们使用 minikube CLI 创建了一个依赖于默认配置的本地 Kubernetes 集群。在本节中,你将看到如何为 minikube 定义一个自定义配置,你可以在初始化用于部署 Polar Bookshop 的新本地 Kubernetes 集群时使用它。

注意:如果你还没有安装 minikube,请参考附录 A 的 A.3 节中的说明。

由于我们在 Docker 上运行 minikube,请记住首先启动 Docker 引擎。然后确保默认集群没有运行,通过执行 minikube stop 命令。从现在开始,我们不会使用默认集群。相反,我们将创建一个用于与 Polar Bookshop 一起工作的自定义集群。使用 minikube,你可以创建和控制多个通过 配置文件 识别的集群。如果没有指定配置文件,minikube 将回退到默认集群。

警告:在本地 Kubernetes 集群上运行示例需要 Docker 至少有 2 个 CPU 和 4 GB 的内存。如果你使用的是 Docker Desktop for Mac 或 Windows,并且需要增加分配给 Docker 引擎的资源,请参考产品文档了解如何在你的特定操作系统上执行此操作(docs.docker.com/desktop)。

让我们在 Docker 上创建一个新的名为 polar 的 Kubernetes 集群。这次,我们还想声明 CPU 和内存的资源限制:

$ minikube start --cpus 2 --memory 4g --driver docker --profile polar

你可以使用以下命令获取集群中所有节点的列表:

$ kubectl get nodes

NAME    STATUS   ROLES                  AGE   VERSION
polar   Ready    control-plane,master   21s   v1.24.3

我们刚刚创建的集群由单个节点组成,该节点托管控制平面并作为部署容器化工作负载的工作节点。

你可以使用相同的 Kubernetes 客户端(kubectl)与不同的本地或远程集群交互。以下命令将列出所有可用的 上下文,你可以通过这些上下文进行交互:

$ kubectl config get-contexts

CURRENT   NAME      CLUSTER     AUTHINFO
*         polar     polar       polar

如果你拥有多个上下文,请确保 kubectl 已配置为使用 polar。你可以通过运行此命令来验证当前上下文:

$ kubectl config current-context
polar

如果结果与 polar 不同,你可以按照以下方式更改当前上下文:

$ kubectl config use-context polar
Switched to context "polar".

在本章的剩余部分,我将假设你已经启动并运行了这个本地集群。任何时候,你都可以使用 minikube stop --profile polar 命令停止集群,并使用 minikube start --profile polar 命令再次启动它。如果你想要删除它并重新开始,你可以运行 minikube delete --profile polar 命令。

在下一节中,你将通过部署 PostgreSQL 数据库来完成本地 Kubernetes 集群的设置。

7.1.2 在本地集群中管理数据服务

正如你在第五章中学到的,数据服务是系统的有状态组件,由于处理其存储的挑战,在云环境中需要特别注意。在 Kubernetes 中管理持久性和存储是一个复杂的话题,通常不是开发者的责任。

当您在生产环境中部署 Polar Bookshop 系统时,您将依赖云提供商提供的托管数据服务,因此我已经为您准备好了在本地 Kubernetes 集群中部署 PostgreSQL 的配置。检查本书附带源代码仓库(第七章/07-end)并将 polar-deployment/kubernetes/platform/development 文件夹的内容复制到您的 polar-deployment 仓库中的相同路径。该文件夹包含运行 PostgreSQL 数据库的基本 Kubernetes 清单。

打开一个终端窗口,导航到位于您的 polar-deployment 仓库中的 kubernetes/platform/development 文件夹,并运行以下命令以在本地集群中部署 PostgreSQL:

$ kubectl apply -f services

注意:前面的命令会在服务文件夹中创建在清单中定义的资源。在下一节中,您将了解更多关于 kubectl apply 命令和 Kubernetes 清单的内容。

结果将在您的本地 Kubernetes 集群中运行一个运行 PostgreSQL 容器的 Pod。您可以使用以下命令进行检查:

$ kubectl get pod

NAME                              READY   STATUS    RESTARTS   AGE
polar-postgres-677b76bfc5-lkkqn   1/1     Running   0          48s

提示:您可以通过运行 kubectl logs deployment/polar-postgres 来检查数据库日志。

使用 Helm 运行 Kubernetes 服务

在 Kubernetes 集群中运行第三方服务的一种流行方式是通过 Helm (helm.sh)。将其视为一个包管理器。要在您的计算机上安装软件,您可以使用操作系统包管理器之一,如 Apt(Ubuntu)、Homebrew(macOS)或 Chocolatey(Windows);在 Kubernetes 中,您可以使用 Helm,但我们称之为 charts 而不是 packages

在我们云原生之旅的这个阶段,使用 Helm 可能有些过早,也许会让人困惑。要完全理解它的工作原理,首先需要更多地熟悉 Kubernetes。

对于本章的其余部分,我将假设您在本地集群中运行了一个 PostgreSQL 实例。如果您在任何时候需要卸载数据库,您可以从同一文件夹中运行 kubectl delete -f services 命令。

以下部分将介绍主要的 Kubernetes 概念,并指导您在本地集群上部署 Spring Boot 应用程序。

7.2 Kubernetes 部署 Spring Boot

本节将带您了解作为开发者将与之合作的主要 Kubernetes 对象以及与平台团队高效沟通所需的词汇,并将您的应用程序部署到集群中。

您已经经历了 Spring Boot 应用程序的容器化。在 Kubernetes 上的 Spring Boot 应用程序仍然被打包为容器,但它运行在由 Deployment 对象控制的 Pod 中。

Pods 和 Deployments 是您在处理 Kubernetes 时需要理解的核心概念。让我们首先看看它们的一些主要特性,然后您将练习声明和创建 Kubernetes 资源以部署 Catalog Service 应用程序。

7.2.1 从容器到 Pods

正如我们在上一节中讨论的,Pods 是 Kubernetes 中最小的可部署单元。当从 Docker 迁移到 Kubernetes 时,我们从管理容器切换到管理 Pods。

Pod是 Kubernetes 中最小的对象,它“代表集群中一组运行的容器”。它通常被设置为运行单个主要容器(你的应用程序),但它也可以运行具有额外功能(如日志记录、监控或安全性)的可选辅助容器(kubernetes.io/docs/reference/glossary)。

Pod 通常由一个容器组成:应用程序实例。当这种情况发生时,它与直接使用容器的工作方式没有太大区别。然而,在某些场景中,你的应用程序容器需要与一些辅助容器一起部署,这些辅助容器可能执行应用程序所需的初始化任务或添加额外的功能,如日志记录。例如,Linkerd(一个服务网格)将其自己的容器(一个边车)添加到 Pods 中,以执行诸如拦截 HTTP 流量并对其进行加密以通过 mTLS(相互传输层安全性)保证所有 Pod 之间的安全通信等操作。图 7.3 说明了单容器和多容器 Pods。

07-03

图 7.3 Pods 是 Kubernetes 中最小的可部署单元。它们至少运行一个主要容器(应用程序)并可能运行用于额外功能(如日志记录、监控或安全性)的可选辅助容器。

在这本书中,你将使用单容器 Pods,其中容器是应用程序。与容器相比,Pods 允许你将相关的容器作为一个单一实体来管理。但这还不够。直接创建和管理 Pods 与直接使用纯 Docker 容器的工作方式没有太大区别。我们需要一个更高层次的抽象来定义我们想要如何部署和扩展我们的应用程序。这就是 Deployment 对象发挥作用的地方。

7.2.2 使用部署控制 Pods

你如何将一个应用程序扩展到运行五个副本?如何在出现故障时确保始终有五个副本在运行?如何在不停机的情况下部署应用程序的新版本?使用部署

部署是一个管理无状态、复制应用程序生命周期的对象。每个副本由一个 Pod 表示。副本在集群的节点之间分布,以提高弹性(kubernetes.io/docs/reference/glossary)。

在 Docker 中,你通过创建和删除容器直接管理应用程序实例。在 Kubernetes 中,你不需要管理 Pod。你让部署为你做这件事。部署对象具有几个重要且宝贵的特性。你可以使用它们来部署你的应用程序,无停机时间地发布升级,在出错时回滚到先前的版本,以及暂停和恢复升级。

部署还允许你管理复制。它们使用名为副本集的对象来确保集群中始终有所需数量的 Pod 运行。如果其中一个崩溃,会自动创建一个新的来替换它。此外,副本会在集群的不同节点上部署,以确保在某个节点崩溃时具有更高的可用性。图 7.4 显示了容器、Pod、副本集和部署之间的关系。

07-04

图 7.4 一个部署通过副本集和 Pod 管理集群中的复制应用程序。副本集确保始终有所需数量的 Pod 运行。Pod 运行容器化应用程序。

部署为我们提供了一个方便的抽象,使我们能够声明我们想要实现的内容(所需状态),我们可以让 Kubernetes 来实现它。你不需要担心如何实现特定的结果。与 Ansible 或 Puppet 等命令式工具不同,你只需告诉 Kubernetes 你想要什么,协调器就会找出如何实现所需的结果并保持其一致性。这就是我们所说的声明式配置

Kubernetes 使用控制器来监视系统,并将所需状态与实际状态进行比较。当两者之间存在任何差异时,它会采取措施使它们再次匹配。部署和副本集是控制器对象,负责滚动发布、复制和自我修复。例如,假设你声明你想要部署三个 Spring Boot 应用的副本。如果一个崩溃了,相关的副本集会注意到这一点并创建一个新的 Pod 来使实际状态与所需状态一致。

将 Spring Boot 应用打包为 OCI 镜像后,你只需定义一个部署对象即可在 Kubernetes 集群中运行它。你将在下一节中学习如何操作。

7.2.3 为 Spring Boot 应用创建部署

在集群中创建和管理 Kubernetes 对象有几个选项。在第二章中,我们直接使用了 kubectl 客户端,但这种方法缺乏版本控制和可重复性。这也是我们为什么更喜欢 Docker Compose 而不是 Docker CLI 的原因。

在 Kubernetes 中,建议的方法是在 manifest 文件中描述对象的期望状态,通常指定为 YAML 格式。我们使用 声明性配置:我们声明我们想要的内容,而不是如何实现它。在第二章中,我们 命令式 使用 kubectl 创建和删除对象,但当我们处理清单时,我们 应用 它们到集群。然后 Kubernetes 将自动将集群中的实际状态与清单中的期望状态进行协调。

Kubernetes 清单通常包含四个主要部分,如图 7.5 所示:

  • apiVersion 定义了特定对象表示的版本化模式。核心资源,如 Pods 或 Services,遵循仅由版本号(例如 v1)组成的版本化模式。其他资源,如 Deployments 或 ReplicaSet,遵循由组和版本号(例如,apps/v1)组成的版本化模式。如果您不确定使用哪个版本,可以参考 Kubernetes 文档 (kubernetes.io/docs) 或使用 kubectl explain <object_name> 命令来获取有关对象更多信息,包括要使用的 API 版本。

  • kind 是您想要创建的 Kubernetes 对象类型,例如 Pod、ReplicaSet、Deployment 或 Service。您可以使用 kubectl api-resources 命令列出集群支持的所有对象。

  • metadata 提供了您要创建的对象的详细信息,包括名称和一组用于分类的标签(键/值对)。例如,您可以指示 Kubernetes 复制所有带有特定标签的对象。

  • spec 是针对每种对象类型特有的部分,用于声明所需的配置。

07-05

图 7.5 Kubernetes 清单通常由四个主要部分组成:apiVersion、kind、metadata 和 spec。

现在您已经熟悉了 Kubernetes 清单的主要部分,让我们定义一个用于运行 Spring Boot 应用程序的 Deployment 对象。

使用 YAML 定义 Deployment 清单

组织 Kubernetes 清单有不同的策略。对于 Catalog Service 应用程序,在项目根目录(catalog-service)中创建一个“k8s”文件夹。我们将使用它来存储应用程序的清单。

注意:如果您没有跟随前几章中实现的示例,可以参考本书附带的代码库 (github.com/ThomasVitale/cloud-native-spring-in-action),并以第七章的 07-begin/catalog-service 中的项目作为起点。

让我们从在 catalog-service/k8s 文件夹内创建 deployment.yml 文件开始。如图 7.5 所示,您需要包含的第一个部分是 apiVersion、kind 和 metadata。

列表 7.1 初始化 Catalog Service 的 Deployment 清单

apiVersion: apps/v1          ❶
kind: Deployment             ❷
metadata:
  name: catalog-service      ❸
  labels:                    ❹
    app: catalog-service     ❺

❶ Deployment 对象的 API 版本

❷ 要创建的对象类型

❸ Deployment 的名称

❹附加到 Deployment 的一组标签

❺ 此 Deployment 被标记为“app=catalog-service。”

注意:Kubernetes API 可能会随时间而变化。请确保您始终使用运行 Kubernetes 版本支持的 API。如果您到目前为止一直跟随,您不应该有这个问题。但如果发生这种情况,kubectl 将返回一个非常详细的错误消息,告诉您确切的问题以及如何修复它。您还可以使用 kubectl explain <对象名称>命令来检查您的 Kubernetes 安装对给定对象的 API 版本支持情况。

Deployment 清单的 spec 部分包含一个选择器部分,用于定义一个策略,以确定哪些对象应该由 ReplicaSet 进行扩展(关于这一点稍后会有更多介绍)以及一个模板部分,描述创建所需 Pod 和容器的规范。

列表 7.2 目录服务部署的期望状态

apiVersion: apps/v1
kind: Deployment
metadata:
  name: catalog-service
  labels:
    app: catalog-service
spec: 
  selector:                                                  ❶
    matchLabels: 
      app: catalog-service 
  template:                                                  ❷
    metadata: 
      labels:                                                ❸
        app: catalog-service 
    spec: 
      containers:                                            ❹
      - name: catalog-service                                ❺
        image: catalog-service                               ❻
        imagePullPolicy: IfNotPresent                        ❼
        ports: 
          - containerPort: 9001                              ❽
        env:                                                 ❾
          - name: BPL_JVM_THREAD_COUNT                       ❿
            value: "50" 
          - name: SPRING_DATASOURCE_URL                      ⓫
            value: jdbc:postgresql://polar-postgres/polardb_catalog 
          - name: SPRING_PROFILES_ACTIVE                     ⓬
            value: testdata 

❶ 定义用于选择要扩展的 Pod 的标签

❷ 创建 Pod 的模板

❸附加到 Pod 对象的标签。它们应该与用作选择器的标签匹配。

❹ Pod 中的容器列表(本例中只有一个)

❺ Pod 的名称

❻ 运行容器所使用的镜像。没有定义标签,因此将隐式地使用“latest”。

❼ 指示 Kubernetes 仅在本地尚未存在时从容器注册库拉取镜像

❽ 容器暴露的端口

❾ 传递给 Pod 的环境变量列表

❿ 用于配置内存计算线程数的 Paketo Buildpacks 环境变量

⓫ 指向之前部署的 PostgreSQL Pod 的 spring.datasource.url 属性的值

⓬ 启用“testdata”Spring 配置文件

容器部分应该看起来很熟悉,因为它类似于您在 Docker Compose 文件的 services 部分中定义容器的方式。就像您使用 Docker 一样,您可以使用环境变量来定义应用程序应使用的 PostgreSQL 实例的 URL。URL 的主机部分(polar-postgres)是用于暴露数据库并之前从 kubernetes/platform/development 文件夹中创建的 Service 对象的名称。您将在本章的后面部分了解更多关于 Service 的信息。现在,只需知道 polar-postgres 是通过其他集群对象与 PostgreSQL 实例通信的名称。

在生产场景中,镜像将从容器注册库中获取。在开发过程中,使用本地镜像更方便。让我们为目录服务构建一个,正如您在上一章中学到的。

打开一个终端窗口,导航到目录服务根文件夹(catalog-service),并按照以下步骤构建一个新的容器镜像:

$ ./gradlew bootBuildImage

提示:如果你在 ARM64 机器(如苹果硅电脑)上工作,你可以在之前的命令中添加--builder ghcr.io/thomasvitale/java-builder-arm64 参数来使用具有 ARM64 支持的 Paketo Buildpacks 的实验版本。请注意,这是实验性的,并不适合生产环境。有关更多信息,你可以参考 GitHub 上的文档:github.com/ThomasVitale/paketo-arm64。在没有官方支持添加之前(github.com/paketo-buildpacks/stacks/issues/51),你仍然可以使用 Buildpacks 来构建容器并通过 Docker Desktop 在苹果硅电脑上运行它们,但构建过程和应用启动阶段将比通常慢。

默认情况下,minikube 无法访问你的本地容器镜像,因此它将找不到你为目录服务刚刚构建的镜像。但别担心:你可以手动将其导入到你的本地集群中:

$ minikube image load catalog-service --profile polar

注意 YAML 是一种表达性语言,但由于其对空格或可能是编辑器支持的缺乏,它可能会使你的编码体验相当糟糕。当涉及 YAML 文件的 kubectl 命令失败时,请验证空格和缩进是否使用正确。对于 Kubernetes,你可以在编辑器中安装一个插件来支持你编写 YAML 清单,确保你始终使用正确的语法、空格和缩进。你可以在本书附带的存储库中的 README.md 文件中找到一些插件选项:github.com/ThomasVitale/cloud-native-spring-in-action

现在你已经有了部署清单,让我们继续看看如何将其应用到你的本地 Kubernetes 集群。

从清单创建部署对象

你可以使用 kubectl 客户端将 Kubernetes 清单应用到集群。打开一个终端窗口,导航到你的目录服务根目录(catalog-service),并运行以下命令:

$ kubectl apply -f k8s/deployment.yml

命令由 Kubernetes 控制平面处理,它将在集群中创建并维护所有相关对象。你可以使用以下命令来验证已创建的对象:

$ kubectl get all -l app=catalog-service

NAME                                   READY   STATUS    RESTARTS   AGE
pod/catalog-service-68bc5659b8-k6dpb   1/1     Running   0          42s

NAME                              READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/catalog-service   1/1     1            1           42s

NAME                                         DESIRED   CURRENT   READY   AGE
replicaset.apps/catalog-service-68bc5659b8   1         1         1       42s

由于你在部署清单中一致地使用了标签,你可以使用标签 app=catalog-service 来获取与目录服务部署相关的所有 Kubernetes 对象。正如你所看到的,deployment.yml 中的声明导致了 Deployment、ReplicaSet 和 Pod 的创建。

为了验证目录服务是否正确启动,你可以按照以下方式检查其部署的日志:

$ kubectl logs deployment/catalog-service

注意:你可以通过运行 kubectl get pods 时检查 STATUS 列来监控 Pod 是否已成功创建。如果 Pod 部署失败,请检查该列。常见的错误状态是 ErrImagePull 或 ImagePullBackOff。它们发生在 Kubernetes 无法从配置的容器注册库中拉取 Pod 使用的镜像时。我们目前正在使用本地镜像,所以请确保你已构建并将目录服务容器镜像加载到 minikube 中。你可以使用 kubectl describe pod <pod_name>命令来获取有关错误的更多信息,以及使用 kubectl logs <pod_name>来获取特定 Pod 实例的应用程序日志。

当在类似于 Kubernetes 集群的云环境中部署容器时,你想要确保它有足够的资源来运行。在第十五章中,你将学习如何将 CPU 和内存资源分配给在 Kubernetes 中运行的容器,以及如何通过应用 Cloud Native Buildpacks 提供的 Java 内存计算器来配置 JVM 的内存。现在,我们将依赖于默认的资源配置。

到目前为止,你已经为 Spring Boot 应用程序创建了一个 Deployment 并在你的本地 Kubernetes 集群中运行它。但是,由于它被隔离在集群内部,所以目前还不能使用它。在下一节中,你将学习如何将你的应用程序暴露给外部世界,以及如何使用 Kubernetes 提供的服务发现和负载均衡功能。

7.3 服务发现和负载均衡

我们已经讨论了 Pods 和 Deployments,那么让我们深入了解一下 Services。你在本地 Kubernetes 集群中运行了目录服务应用程序作为 Pod,但还有一些未解决的问题。它如何与集群中运行的 PostgreSQL Pod 交互?它是如何知道其位置的?你如何将 Spring Boot 应用程序暴露给集群中的其他 Pod 使用?你如何将其暴露在集群之外?

本节将通过介绍云原生系统的两个重要方面来回答这些问题:服务发现和负载均衡。我将介绍在处理 Spring 应用程序时实现这两个功能的主要模式:客户端和服务器端。然后,你将应用后者方法,该方法通过 Kubernetes 的 Service 对象原生提供,这意味着你不需要更改代码来支持它(与客户端选项不同)。最后,你将了解目录服务 Pod 和 PostgreSQL Pod 之间的通信是如何发生的,以及你将如何将目录服务应用程序作为网络服务公开。

7.3.1 理解服务发现和负载均衡

当一个服务需要与另一个服务通信时,它必须提供有关其位置的信息,例如 IP 地址或 DNS 名称。让我们考虑两个应用程序:Alpha 应用程序和 Beta 应用程序。图 7.6 显示了如果只有一个 Beta 应用程序实例,这两个应用程序之间的通信将如何发生。

07-06

图 7.6 如果只有一个 Beta App 实例,Alpha App 和 Beta App 之间的进程间通信将基于一个解析到 Beta App IP 地址的 DNS 名称。

在图 7.6 所示的场景中,我们说 Alpha App 是上游,Beta App 是下游。此外,对于 Alpha App 来说,Beta App 是一个后端服务。Beta App 只有一个实例在运行,所以 DNS 名称解析到其 IP 地址。

在云中,你可能希望运行多个服务实例,并且每个服务实例都将有自己的 IP 地址。与物理机器或长期运行的虚拟机不同,服务实例在云中不会存活很长时间。应用程序实例是可丢弃的——它们可以因为各种原因被移除或替换,例如当它们不再响应时。你甚至可以启用自动扩展功能,根据工作负载自动扩展和缩小应用程序。在云中使用 IP 地址进行进程间通信不是一个选择。

为了克服这个问题,你可能考虑使用 DNS 记录,依靠一个循环冗余名称解析指向分配给副本之一的 IP 地址。知道主机名后,即使其中一个 IP 地址发生变化,你也可以访问后端服务,因为 DNS 服务器会更新为新地址。然而,这种方法并不适合云环境,因为拓扑结构变化过于频繁。一些 DNS 实现甚至在名称查找应该过期后仍然缓存结果。同样,一些应用程序也会长时间缓存 DNS 查找响应。无论哪种方式,使用不再有效的域名/IP 地址解析的可能性很高。

云环境中的服务发现需要不同的解决方案。首先,我们需要跟踪所有运行的服务实例,并将这些信息存储在服务注册表中。每当创建一个新的实例时,应该在注册表中添加一个条目。当它关闭时,应该相应地删除。注册表认识到同一应用程序的多个实例可以同时运行。当应用程序需要调用后端服务时,它会在注册表中执行查找以确定要联系哪个 IP 地址。如果可用多个实例,则应用负载均衡策略将工作负载分配给它们。

我们根据问题解决的地点区分客户端和服务器端的服务发现。让我们看看这两种选项。

7.3.2 客户端服务发现和负载均衡

客户端服务发现要求应用程序在启动时向服务注册表注册自己,并在关闭时注销。每当它们需要调用后端服务时,它们都会向服务注册表请求一个 IP 地址。如果有多个实例可用,注册表将返回 IP 地址列表。应用程序将根据应用程序本身定义的负载均衡策略选择其中之一。图 7.7 展示了这是如何工作的。

07-07

图 7.7 Alpha App 和 Beta App 之间的进程间通信基于要调用的特定实例的 IP 地址,该地址从服务注册表中查询返回的 IP 地址列表中选择。

Spring Cloud 项目为向 Spring 应用程序添加客户端服务发现提供了几种选项。其中一种流行的选择是 Spring Cloud Netflix Eureka,它封装了 Netflix 开发的 Eureka 服务注册表。其他替代方案包括 Spring Cloud Consul、Spring Cloud Zookeeper Discovery 和 Spring Cloud Alibaba Nacos。

除了显式管理服务注册表外,您还需要将正确的集成添加到所有应用程序中。对于上述提到的每个选项,Spring Cloud 都提供了一个客户端库,您可以将其添加到 Spring 应用程序中,以便它能够以最小的努力使用服务注册表。最后,Spring Cloud Load Balancer 可用于客户端负载均衡,这是比 Spring Cloud Netflix Ribbon(不再维护)更受欢迎的选择。

Spring Cloud 提供的所有这些库都有助于使其成为构建云原生应用程序和实现微服务架构的绝佳选择。这种解决方案的好处是您的应用程序可以完全控制负载均衡策略。假设您需要实现像对冲这样的模式:向多个实例发送相同的请求,以增加在特定时间限制内正确响应的几率。客户端服务发现可以帮助您实现这一点。

一个缺点是客户端服务发现将更多的责任分配给了开发者。如果您的系统包括使用不同语言和框架构建的应用程序,您将需要以不同的方式处理每个应用程序的客户端部分。此外,它还导致需要部署和维护一个额外的服务(服务注册表),除非您使用像 Azure Spring Apps 或 VMware Tanzu Application Service 这样的 PaaS 解决方案,这些解决方案为您提供了它。服务器端发现解决方案以牺牲应用程序的细粒度控制为代价来解决这些问题。让我们看看它是如何做到的。

7.3.3 服务器端服务发现和负载均衡

服务器端服务发现解决方案将很多责任转移到部署平台,这样开发者可以专注于业务逻辑,并依赖平台提供所有必要的服务发现和负载均衡功能。此类解决方案会自动注册和注销应用程序实例,并依赖于负载均衡器组件根据特定策略将任何传入请求路由到可用的实例之一。在这种情况下,应用程序不需要与服务注册表交互,该注册表由平台更新和管理。图 7.8 展示了其工作原理。

07-08

图 7.8 Alpha 应用和 Beta 应用之间的进程间通信基于一个 DNS 名称,该名称由负载均衡器组件解析为一个实例 IP 地址。服务注册过程由平台透明地处理。

Kubernetes 对这种服务发现模式的实现基于服务对象。一个服务是“将运行在一系列 Pod 上的应用程序以网络服务的方式暴露的一种抽象方法”(kubernetes.io/docs/reference/glossary)。

服务对象是一个针对一组 Pod(通常使用标签)的抽象,并定义了访问策略。当应用程序需要联系由服务对象暴露的 Pod 时,它可以使用服务名称而不是直接调用 Pod。这正是您让目录服务应用程序与 PostgreSQL 实例交互的方式(polar-postgres 是暴露 PostgreSQL Pod 的服务名称)。然后,本地 DNS 服务器将服务名称解析为服务本身的 IP 地址,该 DNS 服务器运行在 Kubernetes 控制平面中。

注意:分配给服务的 IP 地址在其生命周期内是固定的。因此,服务名称的 DNS 解析不像应用程序实例那样频繁变化。

在解析服务名称到其 IP 地址后,Kubernetes 依赖于一个代理(称为kube-proxy),该代理拦截对服务对象的连接,并将请求转发到服务目标之一。代理知道所有可用的副本,并根据服务类型和代理配置采用负载均衡策略。这一步不涉及 DNS 解析,解决了我之前提到的问题。Kubernetes 采用的服务发现实现如图 7.9 所示。

07-09

图 7.9 在 Kubernetes 中,Alpha 应用和 Beta 应用之间的进程间通信是通过一个服务对象实现的。到达服务的任何请求都会被一个代理拦截,该代理根据特定的负载均衡策略将其转发到服务目标之一。

这种解决方案对你的 Spring Boot 应用程序来说是透明的。与 Spring Cloud Netflix Eureka 等选项不同,你可以在 Kubernetes 中获得开箱即用的服务发现和负载均衡功能,而无需对代码进行任何更改。这就是为什么当你使用基于 Kubernetes 的平台部署应用程序时,它是首选选项。

服务发现与 Spring Cloud Kubernetes

如果你需要迁移之前提到的客户端服务发现选项之一的应用程序,你可以使用 Spring Cloud Kubernetes 来使过渡更加平滑。你可以在应用程序中保留现有的服务发现和负载均衡逻辑。然而,与 Spring Cloud Netflix Eureka 等解决方案不同,你可以使用Spring Cloud Kubernetes Discovery Server进行服务注册。这可以是一种方便地将应用程序迁移到 Kubernetes 而无需在应用程序代码中做太多更改的方法。有关更多信息,请参阅项目文档:spring.io/projects/spring-cloud-kubernetes

除非你的操作需要对你的应用程序中的服务实例和负载均衡进行特定处理,我的建议是逐步迁移到使用 Kubernetes 提供的原生服务发现功能,目标是消除应用程序中的基础设施关注点。

在了解了如何在 Kubernetes 中实现服务发现和负载均衡之后,让我们看看如何定义一个服务来暴露 Spring Boot 应用程序。

7.3.4 使用 Kubernetes 服务暴露 Spring Boot 应用程序

正如你在上一节中学到的,Kubernetes 服务允许你通过一个接口暴露一组 Pod,其他应用程序可以通过该接口调用,而无需了解单个 Pod 实例的详细信息。这种模型为应用程序提供了透明的服务发现和负载均衡功能。

首先,根据你想要强制执行的应用程序访问策略,存在不同类型的 Service。默认且最常见的一种类型被称为ClusterIP,它将一组 Pod 暴露给集群。这使得 Pod 之间能够相互通信(例如,目录服务和 PostgreSQL)。

四个信息点定义了一个 ClusterIP 服务:

  • 服务用于匹配所有应被服务和暴露的 Pod 的标签选择器

  • 服务使用的网络协议

  • 服务监听的端口(我们将使用端口 80 来监听所有应用程序服务)

  • targetPort,即目标 Pod 暴露的端口,服务将请求转发到该端口

图 7.10 显示了 ClusterIP 服务与一组在端口 8080 上运行应用程序的目标 Pod 之间的关系。服务名称必须是一个有效的 DNS 名称,因为它将被其他 Pod 用作主机名来访问目标 Pod。

07-10

图 7.10 一个 ClusterIP 服务将一组 Pod 暴露给集群内部的网络。

使用 YAML 定义 Service 清单

让我们看看如何定义一个 Service 对象的清单,以通过 DNS 名称 catalog-service 和端口 80 暴露 Catalog Service 应用程序。打开您之前创建的 catalog-service/k8s 文件夹,并添加一个新的 service.yml 文件。

列表 7.3 Catalog Service 应用程序的 Service 清单

apiVersion: v1              ❶
kind: Service               ❷
metadata:
  name: catalog-service     ❸
  labels:
    app: catalog-service    ❹
spec:
  type: ClusterIP           ❺
  selector:
    app: catalog-service    ❻
  ports:
  - protocol: TCP           ❼
    port: 80                ❽
    targetPort: 9001        ❾

❶ Service 对象的 API 版本

❷ 要创建的对象类型

❸ 服务的名称;它必须是一个有效的 DNS 名称。

❹ 附加到服务的标签

❺ 服务的类型

❻ 用于将 Pod 匹配到目标和暴露的标签

❼ 服务的网络协议

❽ 服务的暴露端口

❾ 服务的目标 Pod 暴露的端口

从清单创建 Service 对象

您可以像对 Deployments 一样应用 Service 清单。打开一个终端窗口,导航到您的 Catalog Service 根目录(catalog-service),并运行以下命令:

$ kubectl apply -f k8s/service.yml

该命令将由 Kubernetes 控制平面处理,它将在集群中创建并维护 Service 对象。您可以使用以下命令验证结果:

$ kubectl get svc -l app=catalog-service

NAME              TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
catalog-service   ClusterIP   10.102.29.119   <none>        80/TCP    42s

由于它是 ClusterIP 类型,Service 使得集群内的其他 Pod 能够通过其 IP 地址(称为集群 IP)或通过其名称与 Catalog Service 应用程序通信。这对您将在下一章中构建的应用程序很有用,但我们怎么办?我们如何将应用程序暴露在集群外部以进行测试?

目前,我们将依赖 Kubernetes 提供的端口转发功能来将对象(在这种情况下,是 Service)暴露到本地机器。您已经在第二章中这样做过了,所以命令应该看起来很熟悉:

$ kubectl port-forward service/catalog-service 9001:80
Forwarding from 127.0.0.1:9001 -> 9001
Forwarding from [::1]:9001 -> 9001

您现在可以从本地主机上的端口 9001 调用应用程序,所有请求都将转发到 Service 对象,最终转发到 Catalog Service Pod。尝试从您的浏览器访问 http://localhost:9001 来查看欢迎信息,或访问 http://localhost:9001/books 来浏览目录中的书籍。

提示:由 kubectl port-forward 命令启动的过程将一直运行,直到您使用 Ctrl-C 明确停止它。在此之前,如果您想运行 CLI 命令,则需要打开另一个终端窗口。

图 7.11 展示了您的计算机、Catalog Service 和 PostgreSQL 之间的通信工作方式。

07-11

图 7.11 通过端口转发,Catalog Service 应用程序暴露给了您的本地机器。Catalog Service 和 PostgreSQL 都通过分配给 Service 对象的集群本地主机名、IP 地址和端口暴露给集群内部。

到目前为止,我们只与 Catalog 服务的一个实例一起工作,但我们可以利用 Kubernetes 来扩展它。下一节将介绍如何扩展 Spring Boot 应用,并解决诸如快速启动和优雅关闭等问题,这对于云原生应用至关重要。

注意:想象一下,每次您在应用中更改某些内容时都要运行所有这些命令,并且您想在本地上测试它。这看起来并不吸引人,对吧?别担心!在第 7.5 节中,我将向您展示如何设置本地 Kubernetes 开发工作流程来自动化所有这些操作。

7.4 可扩展性和可丢弃性

部署同一应用的多个实例有助于实现高可用性。当负载较高时,它可以在不同的副本之间进行分配。当一个实例进入故障状态并且无法再处理请求时,它可以被删除并创建一个新的实例。这种对应用实例的持续和动态扩展需要无状态和可丢弃的应用,根据 15 个因素方法。

本节将向您展示一个应用成为可丢弃应用的意义,如何启用优雅关闭,以及如何在 Kubernetes 中扩展应用。

7.4.1 确保可丢弃性:快速启动

部署在应用服务器上的传统应用启动需要相当长的时间。它们在准备好接受连接之前可能需要几分钟并不罕见。另一方面,云原生应用应该优化快速启动,只需几秒钟而不是几分钟即可准备好。Spring Boot 已经针对快速启动进行了优化,并且每个新版本都带来了更多的改进。

快速启动在云环境中很重要,因为应用是可丢弃的,并且经常被创建、销毁和扩展。启动越快,新的应用实例就越快准备好接受连接。

标准应用,如微服务,对于几秒钟的启动时间表现良好。另一方面,无服务器应用通常需要比秒更快的启动阶段,在毫秒范围内。Spring Boot 涵盖了这两种需求,但第二种用例可能需要一些额外的配置。

在第十六章中,您将了解使用 Spring Cloud Function 的无服务器应用,我将向您展示如何使用 Spring Native 和 GraalVM 将它们打包为原生镜像。结果是具有几乎即时启动时间、减少的资源消耗和减少的镜像大小的应用。

7.4.2 确保可丢弃性:优雅关闭

仅让应用快速启动不足以解决我们的可扩展性需求。每当一个应用实例关闭时,它必须优雅地发生,而不会让客户端经历停机或错误。优雅关闭意味着应用停止接受新的请求,完成所有仍在进行中的请求,并关闭任何打开的资源,如数据库连接。

Spring Boot 中可用的所有嵌入式服务器都支持优雅关闭模式,但方式略有不同。当接收到关闭信号时,Tomcat、Jetty 和 Netty 完全停止接受新请求。另一方面,Undertow 继续接受新请求,但会立即回复 HTTP 503 响应。

默认情况下,Spring Boot 在接收到终止信号(SIGTERM)后立即停止服务器。您可以通过配置 server.shutdown 属性切换到优雅模式。您还可以配置宽限期,即应用程序可以花费多长时间处理所有挂起的请求。宽限期过后,即使还有挂起的请求,应用程序也会被终止。默认的宽限期是 30 秒。您可以通过 spring.lifecycle.timeout-per-shutdown-phase 属性来更改它。

让我们为目录服务配置优雅关闭。我们可以通过环境变量或将其设置为默认配置来实现。我们将选择第二种方法。打开位于 catalog-service/src/main/resources 文件夹中的 application.yml 文件,并按照以下方式更新配置:

server:
  port: 9001
  shutdown: graceful                  ❶
  tomcat:
    connection-timeout: 2s
    keep-alive-timeout: 15s
    threads:
      max: 50
      min-spare: 5

spring:
  application:
    name: catalog-service
  lifecycle: 
    timeout-per-shutdown-phase: 15s   ❷
...

❶ 启用优雅关闭

❷ 定义了 15 秒的宽限期

由于我们已经修改了应用程序源代码,我们需要构建一个新的容器镜像并将其加载到 minikube 中。这并不高效,是吗?在本章的后面部分,我会向您展示一个更好的方法。现在,请按照我之前描述的步骤将目录服务打包为容器镜像(./gradlew bootBuildImage)并将其加载到我们用于 Polar Bookshop 的 Kubernetes 集群中(minikube image load catalog -service --profile polar)。

在启用应用程序支持优雅关闭后,您需要相应地更新部署清单。当一个 Pod 需要被终止(例如,在缩放过程或作为升级的一部分时),Kubernetes 会向其发送 SIGTERM 信号。Spring Boot 将拦截该信号并开始优雅地关闭。默认情况下,Kubernetes 等待 30 秒的宽限期。如果在此期间 Pod 没有被终止,Kubernetes 会发送 SIGKILL 信号强制终止 Pod。由于 Spring Boot 的宽限期低于 Kubernetes,因此应用程序控制着何时终止。

当它向 Pod 发送 SIGTERM 信号时,Kubernetes 还会通知其自身组件停止将请求转发到正在终止的 Pod。由于 Kubernetes 是一个分布式系统,这两个动作是并行发生的,因此在终止 Pod 可能仍然接收请求的短暂时间内,可能会出现这种情况,即使它已经开始了优雅关闭过程。当这种情况发生时,这些新请求将被拒绝,导致客户端出现错误。我们的目标是使关闭过程对客户端透明,因此这种情况是不可接受的。

建议的解决方案是延迟向 Pod 发送 SIGTERM 信号,以便 Kubernetes 有足够的时间将消息传播到整个集群。这样做,所有 Kubernetes 组件在 Pod 开始优雅关闭过程时,都已经知道不要向 Pod 发送新的请求。技术上,延迟可以通过 preStop 钩子进行配置。让我们看看我们如何更新 Catalog Service 的 Deployment 清单以支持透明和优雅的关闭。

打开位于 catalog-service/k8s 的 deployment.yml 文件,并添加一个 preStop 钩子,以延迟 SIGTERM 信号 5 秒。

列表 7.4 在 Kubernetes 中配置关闭开始前的延迟

apiVersion: apps/v1
kind: Deployment
metadata:
  name: catalog-service
  labels:
    app: catalog-service
spec:
  ...
  template:
    metadata:
      labels:
        app: catalog-service
    spec:
      containers:
        - name: catalog-service
          image: catalog-service
          imagePullPolicy: IfNotPresent
          lifecycle: 
            preStop:                    ❶
              exec: 
                command: [ "sh", "-c", "sleep 5" ] 
          ...

❶ 让 Kubernetes 在向 Pod 发送 SIGTERM 信号前等待 5 秒

最后,使用 kubectl apply -f k8s/deployment.yml 应用 Deployment 对象的更新版本。Kubernetes 将协调新的期望状态,并用一个完全配置了优雅关闭的新 Pod 替换现有的 Pod。

注意:当一个 Pod 包含多个容器时,SIGTERM 信号会并行发送到所有容器。Kubernetes 将等待最多 30 秒。如果 Pod 中的任何容器尚未终止,它将强制关闭它们。

现在我们已经为 Catalog Service 配置了优雅关闭的行为,让我们看看如何在 Kubernetes 集群中对其进行扩展。

7.4.3 扩展 Spring Boot 应用

可扩展性是云原生应用的主要特性之一,正如你在第一章中学到的。为了实现可扩展性,应用应该是可丢弃的和无状态的,按照 15-Factor 方法论。

我们在前一节中处理了可丢弃性,并且 Catalog Service 已经是一个无状态的应用。它没有状态,但依赖于有状态的服务(PostgreSQL 数据库)来永久存储关于书籍的数据。我们扩展和缩小应用,如果它们不是无状态的,每次实例关闭时我们都会丢失状态。一般想法是保持应用无状态,并依赖于数据服务来存储状态,就像我们在 Catalog Service 中做的那样。

在 Kubernetes 中,副本通过 ReplicaSet 对象在 Pod 层级进行管理。正如你之前看到的,Deployment 对象已经配置为使用 ReplicaSet。你所需要做的就是指定你想要部署的副本数量。你可以在 Deployment 清单中做到这一点。

打开位于 catalog-service/k8s 的 deployment.yml 文件,并定义你想要运行的 Catalog Service Pod 的副本数量。让我们选择两个。

列表 7.5 配置 Catalog Service Pod 的副本数量

apiVersion: apps/v1
kind: Deployment
metadata:
  name: catalog-service
  labels:
    app: catalog-service
spec:
  replicas: 2         ❶
  selector:
    matchLabels:
      app: catalog-service
  ...

❶ 应部署多少个 Pod 副本

副本是通过标签进行控制的。在列表 7.5 中,配置指示 Kubernetes 管理所有带有标签 app=catalog-service 的 Pod,以确保始终运行两个副本。

让我们来看看。打开一个终端窗口,导航到 catalog-service 文件夹,并应用 Deployment 资源更新的版本:

$ kubectl apply -f k8s/deployment.yml

Kubernetes 会意识到实际状态(一个副本)和期望状态(两个副本)不匹配,并会立即部署一个新的 Catalog 服务副本。您可以使用以下命令来验证结果:

$ kubectl get pods -l app=catalog-service

NAME                               READY   STATUS    RESTARTS   AGE
catalog-service-68bc5659b8-fkpcv   1/1     Running   0          2s 
catalog-service-68bc5659b8-kmwm5   1/1     Running   0          3m94s

在“年龄”列中,您可以判断哪个 Pod 是刚刚部署以实现两个副本状态的 Pod。

如果其中一个终止会发生什么?让我们来看看。选择两个 Pod 副本中的一个,并复制其名称。例如,我可能会使用名为 catalog-service-68bc5659b8-kmwm5 的 Pod。然后,从终端窗口,使用以下命令删除该 Pod:

$ kubectl delete pod <pod-name>

Deployment 清单声明了两个副本作为期望状态。由于现在只有一个,Kubernetes 会立即采取措施确保实际状态和期望状态一致。如果您再次使用 kubectl get pods -l app=catalog-service 检查 Pods,您仍然会看到两个 Pods,但其中一个刚刚被创建来替换被删除的 Pod。您可以通过检查其年龄来识别它:

$ kubectl get pods -l app=catalog-service

NAME                               READY   STATUS    RESTARTS   AGE
catalog-service-68bc5659b8-fkpcv   1/1     Running   0          42s
catalog-service-68bc5659b8-wqchr   1/1     Running   0          3s 

在底层,ReplicaSet 对象会持续检查已部署的副本数量,并确保它们始终处于期望状态。这是您可以在其上配置自动扩展器以动态增加或减少 Pod 数量的基本功能,根据工作负载进行调整,而无需每次都更新清单。

在进入下一节之前,请确保将副本数量改回一个,并通过删除到目前为止创建的所有资源来清理您的集群。首先,打开一个终端窗口,导航到您定义 Kubernetes 清单的 catalog-service 文件夹,并删除为 Catalog 服务创建的所有对象:

$ kubectl delete -f k8s

最后,转到您的 polar-deployment 仓库,导航到 kubernetes/platform/development 文件夹,并删除 PostgreSQL 安装:

$ kubectl delete -f services

7.5 使用 Tilt 进行本地 Kubernetes 开发

在前面的章节中,您学习了 Kubernetes 的基本概念,并使用用于将应用程序部署到集群的基本对象:Pods、ReplicaSets、Deployments 和 Services。在定义 Deployment 和 Service 清单之后,您可能不想手动重新构建容器镜像,并在每次更改时使用 kubectl 客户端更新 Pods。幸运的是,您不必这样做。

本节将向您展示如何设置本地 Kubernetes 开发工作流程来自动化构建镜像和将清单应用到 Kubernetes 集群等步骤。这是实现与 Kubernetes 平台一起工作的 内部开发循环 的一部分。Tilt 负责许多基础设施问题,让您更多地关注应用程序的业务逻辑。我还会介绍 Octant,它将帮助您通过方便的 GUI 可视化和管理您的 Kubernetes 对象。

7.5.1 使用 Tilt 进行内部开发循环

Tilt (tilt.dev) 致力于在 Kubernetes 上工作时提供良好的开发者体验。它是一个开源工具,提供在本地环境中构建、部署和管理容器化工作负载的功能。我们将使用其一些基本功能来自动化特定应用程序的开发工作流程,但 Tilt 还可以帮助您以集中化的方式编排多个应用程序和服务的部署。您可以在附录 A 的 A.4 节中找到有关如何安装它的信息。

我们的目标将是设计一个工作流程,以自动化以下步骤:

  • 使用云原生构建包将 Spring Boot 应用程序打包为容器镜像。

  • 将镜像上传到 Kubernetes 集群(在我们的例子中,是使用 minikube 创建的)。

  • 应用在 YAML 清单中声明的所有 Kubernetes 对象。

  • 启用端口转发功能,以便从您的本地计算机访问应用程序。

  • 让您轻松访问集群上运行的应用程序的日志。

在配置 Tilt 之前,请确保您在本地 Kubernetes 集群中有一个正在运行的 PostgreSQL 实例。打开一个终端窗口,导航到您的 polar-deployment 仓库中的 kubernetes/platform/development 文件夹,并运行以下命令以部署 PostgreSQL:

$ kubectl apply -f services

现在我们来看看如何配置 Tilt 以建立自动化的开发工作流程。

Tilt 可以通过一个 Tiltfile 进行配置,这是一个用 Starlark(一种简化的 Python 方言)编写的可扩展配置文件。转到您的 Catalog Service 项目(catalog-service),在根文件夹中创建一个名为“Tiltfile”的文件(不带扩展名)。该文件将包含三个主要配置:

  • 如何构建容器镜像(云原生构建包)

  • 如何部署应用程序(Kubernetes YAML 清单)

  • 如何访问应用程序(端口转发)

列表 7.6 为 Catalog Service 配置的 Tilt(Tiltfile)

# Build
custom_build(
    # Name of the container image
    ref = 'catalog-service',
    # Command to build the container image
    command = './gradlew bootBuildImage --imageName $EXPECTED_REF',
    # Files to watch that trigger a new build
    deps = ['build.gradle', 'src']
)

# Deploy
k8s_yaml(['k8s/deployment.yml', 'k8s/service.yml'])

# Manage
k8s_resource('catalog-service', port_forwards=['9001'])

提示:如果您在 ARM64 机器(例如苹果硅电脑)上工作,您可以将 --builder ghcr.io/thomasvitale/java-builder-arm64 参数添加到 ./gradlew bootBuildImage --imageName $EXPECTED_REF 命令中,以使用带有 ARM64 支持的实验性 Paketo Buildpacks 版本。请注意,这是实验性的,并不适合生产环境。有关更多信息,您可以参考 GitHub 上的文档:github.com/ThomasVitale/paketo-arm64

Tiltfile 配置 Tilt 使用我们在本章中用于在本地 Kubernetes 集群上构建、加载、部署和发布应用程序的相同方法。主要区别是什么?现在一切都是自动化的!让我们试试看。

打开一个终端窗口,导航到您的 Catalog Service 项目的根文件夹,并运行以下命令以启动 Tilt:

$ tilt up
Tilt started on http://localhost:10350/

tilt up 命令启动的过程将持续运行,直到您明确使用 Ctrl-C 停止它。Tilt 提供的一个有用功能是方便的 GUI,您可以在其中跟踪 Tilt 管理的服务,检查应用程序日志,并手动触发更新。转到 Tilt 启动其服务的 URL(默认情况下,应该是 http://localhost:10350),并监控 Tilt 构建和部署目录服务(图 7.12)的过程。第一次可能需要一或两分钟,因为需要下载 Buildpacks 库。随后的时间将会快得多。

07-12

图 7.12 Tilt 提供了一个方便的 GUI,您可以在其中监控和管理应用程序。

除了构建和部署应用程序外,Tilt 还激活了端口转发到您的本地机器的 9001 端口。继续验证应用程序是否正常工作:

$ http :9001/books

Tilt 将使应用程序与源代码保持同步。无论何时您对应用程序进行任何更改,Tilt 都将触发一个 更新 操作来构建和部署一个新的容器镜像。所有这些都会自动且持续进行。

注意:每次在代码中更改内容时重新构建整个容器镜像并不高效。您可以通过配置 Tilt 仅同步更改的文件并将它们上传到当前镜像来实现这一点。为此,您可以使用 Spring Boot DevTools (mng.bz/nY8v) 和 Paketo Buildpacks (mng.bz/vo5x) 提供的功能。

当您完成应用程序的测试后,在目录服务项目中停止 Tilt 进程,并运行以下命令来卸载应用程序:

$ tilt down

7.5.2 使用 Octant 可视化您的 Kubernetes 工作负载

当您开始将多个应用程序部署到 Kubernetes 集群时,管理所有相关的 Kubernetes 对象或调查发生故障时可能会变得具有挑战性。有不同解决方案用于可视化和管理 Kubernetes 工作负载。本节将介绍 Octant (octant.dev),这是一个“针对 Kubernetes 的开源开发者中心化网络界面,允许您检查 Kubernetes 集群及其应用程序。”您可以在附录 A 的 A.4 节中找到有关如何安装它的信息。

我预计您仍然运行着上一节中使用的本地 Kubernetes 集群和已部署的 PostgreSQL。您也可以通过进入项目的根目录并运行 tilt up 来部署目录服务。然后,打开一个新的终端窗口并运行以下命令:

$ octant

此命令将在您的浏览器中打开 Octant 仪表板(通常位于 http://localhost:7777)。图 7.13 展示了仪表板。概览页面提供了集群中所有运行的 Kubernetes 对象的概览。如果您一直跟随操作,您应该在集群中运行 PostgreSQL 和目录服务。

07-13

图 7.13 Octant 为检查 Kubernetes 集群及其工作负载提供了一个网络界面。

从概述页面,你可以展开对象以获取更多详细信息。例如,如果你点击对应于目录服务 Pod 的项目,你将获得有关该对象的信息,如图 7.14 所示。你还可以执行一些操作,如启用端口转发、读取日志、修改 Pod 的清单以及调查故障。

07-14

图 7.14 Octant 允许你轻松访问 Pod 信息,检查它们的日志,并启用端口转发。

仔细探索 Octant 提供的众多功能。这是一个方便的工具,你可以用它来检查和调试本地 Kubernetes 集群或远程集群。我们还将使用 Octant 来检查我们将部署 Polar Bookshop 应用程序的生产集群。现在,通过使用 Ctrl-C 停止其进程来关闭 Octant。

当你完成操作后,你可以在目录服务项目中停止 Tilt 进程,并运行 tilt 以卸载应用程序。然后前往你的 polar-deployment 仓库,导航到 kubernetes/platform/development 文件夹,并使用 kubectl delete -f services 删除 PostgreSQL 安装。最后,按照以下步骤停止集群:

$ minikube stop --profile polar

7.6 部署管道:验证 Kubernetes 清单

第三章介绍了部署管道的概念及其在快速、可靠和安全地交付软件的持续交付方法中的重要性。到目前为止,我们已经自动化了部署管道的第一部分:提交阶段。当开发者将新代码提交到主线后,这一阶段将经历构建、单元测试、集成测试、静态代码分析和打包。在这个阶段的末尾,可执行的应用程序工件被发布到工件存储库。这被称为 发布候选

在本章中,你学习了如何使用基于 资源清单 的声明性方法在 Kubernetes 上部署 Spring Boot 应用程序。它们对于在 Kubernetes 上成功部署发布候选版本至关重要,因此我们应该保证它们的正确性。本节将向你展示如何在提交阶段验证 Kubernetes 清单。

7.6.1 在提交阶段验证 Kubernetes 清单

在本章的整个过程中,我们一直在使用资源清单在 Kubernetes 集群中创建 Deployments 和 Services。清单 是“Kubernetes API 对象的 JSON 或 YAML 格式规范。”它指定了“当你应用清单时 Kubernetes 将维护的对象的期望状态” (kubernetes.io/docs/reference/glossary)。

由于清单指定了对象的期望状态,我们应该确保我们的规范符合 Kubernetes 提供的 API。在部署管道的提交阶段自动进行此验证是一个好主意,以便在出现错误时快速获得反馈(而不是等到验收阶段,那时我们需要使用这些清单在 Kubernetes 集群中部署应用程序)。图 7.15 展示了包含 Kubernetes 清单验证的提交阶段的主要步骤。

07-15

图 7.15 当 Kubernetes 清单包含在应用程序仓库中时,提交阶段将包含一个新步骤来验证它们。

有几种方法可以验证 Kubernetes 清单与 Kubernetes API 的兼容性。我们将使用 Kubeval (www.kubeval.com),这是一个开源工具。您可以在附录 A 的 A.4 节中找到有关如何安装它的信息。

让我们看看它是如何工作的。打开一个终端窗口,导航到您的目录服务项目(catalog-service)的根目录。然后使用 kubeval 命令验证 k8s 目录中的 Kubernetes 清单(-d k8s)。--strict 标志禁止添加对象模式中未定义的额外属性:

$ kubeval --strict -d k8s

PASS - k8s/deployment.yml contains a valid Deployment (catalog-service)
PASS - k8s/service.yml contains a valid Service (catalog-service)

在下一节中,您将看到如何使用 Kubeval 在我们使用 GitHub Actions 实现的提交阶段工作流程中。

7.6.2 使用 GitHub Actions 自动化 Kubernetes 清单验证

GitHub Actions 是我们用来实现目录服务部署管道提交阶段的工作流程引擎。让我们扩展它以包含 Kubernetes 清单验证步骤,如图 7.15 所示。

前往您的目录服务项目(catalog-service),并在 .github/workflows 文件夹中打开 commit-stage.yml 文件。为了实现验证步骤,我们将依赖 Stefan Prodan 构建的操作。他是 FluxCD 的维护者,FluxCD 是一个 CNCF 孵化项目,基于 GitOps 原则提供 Kubernetes 上的持续部署解决方案。该操作允许您安装特定版本的实用 Kubernetes 相关工具。我们将配置该操作以安装 kubectl 和 Kubeval。

列表 7.7 验证目录服务的 Kubernetes 清单

name: Commit Stage
on: push
...

jobs:
  build:
    name: Build and Test
    ...
    steps:
      ...
      - name: Validate Kubernetes manifests 
        uses: stefanprodan/kube-tools@v1         ❶
        with: 
          kubectl: 1.24.3                        ❷
          kubeval: 0.16.1                        ❸
          command: | 
            kubeval --strict -d k8s              ❹
  package:
    ...

❶ 一个能够安装用于与 Kubernetes 一起工作的有用工具的操作

❷ 在安装中包含 Kubernetes CLI

❸ 在安装中包含 Kubeval

❹ 使用 Kubeval 验证 k8s 文件夹中的 Kubernetes 清单

在将附加验证步骤更新到 commit-stage.yml 文件后,您可以将更改提交并推送到 GitHub 上的 catalog-service 仓库,并验证提交阶段工作流程是否成功完成,这意味着您包含在清单中的内容与 Kubernetes API 兼容。

Polar Labs

随意将本章学到的知识应用到配置服务中,并为部署准备应用程序。

  1. 配置应用程序的优雅关闭和宽限期。

  2. 为将 Config 服务部署到 Kubernetes 集群编写部署和服务清单。

  3. 更新部署管道中 Config 服务的提交阶段以验证 Kubernetes 清单。

  4. 通过 SPRING_CLOUD_CONFIG_URI 环境变量,使用 Config 服务 URL 配置目录服务部署,依赖于 Kubernetes 原生的服务发现功能。

  5. 配置 Tilt 以自动化将 Config 服务部署到使用 minikube 引导的本地 Kubernetes 集群。

完成后,尝试部署我们迄今为止构建的 Polar Bookshop 系统的所有组件,并在 Octant 中检查它们的状态。您可以通过查看书中附带的代码仓库中的 Chapter07/ 07-end 文件夹来检查最终结果 (github.com/ThomasVitale/cloud-native-spring-in-action)。

恭喜!

摘要

  • Docker 在单机运行单实例容器时运行良好。当您的系统需要像可扩展性和弹性这样的属性时,您可以使用 Kubernetes。

  • Kubernetes 提供了在机器集群中扩展容器的所有功能,确保在容器失败和机器宕机时都具有弹性。

  • Pod 是 Kubernetes 中最小的可部署单元。

  • 而不是直接创建 Pod,您可以使用 Deployment 对象来声明应用程序的期望状态,Kubernetes 将确保它匹配实际状态。这包括在任何时候都有期望数量的副本正在运行。

  • 云是一个动态的环境,拓扑结构不断变化。服务发现和负载均衡让您能够动态地建立服务之间的交互,这些服务可以在客户端(例如,使用 Spring Cloud Netflix Eureka)或服务器端(例如,使用 Kubernetes)管理。

  • Kubernetes 提供了原生的服务发现和负载均衡功能,您可以通过 Service 对象来使用这些功能。

  • 每个服务名称都可以用作 DNS 名称。Kubernetes 将解析该名称到服务 IP 地址,并最终将请求转发到可用的实例之一。

  • 您可以通过定义两个 YAML 清单将 Spring Boot 应用程序部署到 Kubernetes 集群:一个用于 Deployment 对象,另一个用于 Service 对象。

  • kubectl 客户端允许您使用命令 kubectl apply -f <your-file.yml> 从文件创建对象。

  • 云原生应用程序应该是可丢弃的(快速启动和优雅关闭)并且无状态的(依赖于数据服务来存储状态)。

  • 优雅关闭在 Spring Boot 和 Kubernetes 中都受到支持,并且是可扩展应用程序的一个基本方面。

  • Kubernetes 使用 ReplicaSet 控制器来复制您的应用程序 Pod 并保持它们运行。

  • Tilt 是一个工具,它通过 Kubernetes 自动化你的本地开发工作流程:你专注于应用开发,而 Tilt 则负责构建镜像、将镜像部署到你的本地 Kubernetes 集群,并在你更改代码时保持其最新状态。

  • 你可以通过运行 tilt up 来为你的项目启动 Tilt。

  • Octant 仪表板让你能够可视化你的 Kubernetes 工作负载。

  • Octant 是一个方便的工具,你可以用它不仅来检查和排除本地 Kubernetes 集群的故障,也可以用于远程集群。

  • Kubeval 是一个方便的工具,你可以用它来验证 Kubernetes 清单。当它包含在你的部署管道中时,尤其有用。

第三部分 云原生分布式系统

云原生应用根据定义是高度分布式和可扩展的系统。迄今为止,我们只处理了一个单一的应用程序。现在是时候拓宽我们的视野,并探讨在云中构建分布式系统的模式、挑战和技术。第三部分涵盖了云原生系统的基本特性,如弹性、可扩展性和安全性。它还描述了响应式编程和事件驱动架构。

第八章介绍了响应式编程和 Spring 响应式堆栈的主要特性,包括 Project Reactor、Spring WebFlux 和 Spring Data R2DBC。第九章涵盖了 API 网关模式以及如何使用 Spring Cloud Gateway 构建边缘服务。你将学习如何使用重试、超时、回退、断路器和速率限制器等模式,利用 Reactor、Spring Cloud 和 Resilience4J 构建弹性应用程序。第十章描述了事件驱动架构,并教你如何使用 Spring Cloud Function、Spring Cloud Stream 和 RabbitMQ 来实现它们。安全性是所有云原生应用的关键关注点,第十一章和第十二章都是关于安全性的。你将学习如何使用 Spring Security、OAuth2 和 OpenID Connect 实现身份验证和授权。你还将看到一些保护 API 和数据的技巧,包括当单页应用程序是系统的一部分时。

8 反应式 Spring:弹性和可伸缩性

本章涵盖

  • 使用 Reactor 和 Spring 理解反应式编程

  • 使用 Spring WebFlux 和 Spring Data R2DBC 构建反应式服务器

  • 使用 WebClient 构建反应式客户端

  • 使用 Reactor 提高应用程序的弹性

  • 使用 Spring 和 Testcontainers 测试反应式应用程序

Polarsophia,Polar Bookshop 业务的背后组织,对其新软件产品的进展感到非常高兴。其使命是传播关于北极和北极地区的知识和意识,使其图书目录在全球范围内可用是这一使命的重要组成部分。

你迄今为止构建的目录服务应用程序是一个良好的起点。它满足了浏览和管理书籍的要求,并且在遵循云原生模式和实践中做到了这一点。它是自包含且无状态的。它使用数据库作为后端服务来存储状态。可以通过环境变量或配置服务器外部配置。它尊重环境一致性。它通过作为部署管道一部分的自动化测试执行来验证,遵循持续交付实践。为了最大程度的便携性,它也被容器化,可以使用服务发现、负载均衡和复制等原生功能部署到 Kubernetes 集群。

系统的另一个基本功能是购买书籍的可能性。在本章中,你将开始构建订单服务应用程序。这个新组件不仅将与数据库交互,还将与目录服务交互。当你有大量依赖 I/O 操作的应用程序,如数据库调用或与其他服务(如 HTTP 请求/响应通信)的交互时,目录服务中使用的每个请求一个线程的模型开始暴露其技术限制。

在每个请求一个线程的模型中,每个请求都绑定到一个专门为其处理分配的线程。如果数据库或服务调用是处理的一部分,线程将发送请求并阻塞,等待响应。在空闲时间内,为该线程分配的资源被浪费,因为它们不能用于其他任何事情。反应式编程范式解决了这个问题,并提高了所有 I/O 密集型应用程序的可伸缩性、弹性和成本效益。

反应式应用程序以异步和非阻塞的方式运行,这意味着计算资源被更有效地使用。这在云中是一个巨大的优势,因为你只为使用付费。当一个线程向后端服务发送调用时,它不会空闲等待,而是会继续执行其他操作。这消除了线程数量与并发请求数量之间的线性依赖关系,导致更可伸缩的应用程序。在相同的计算资源下,反应式应用程序可以比它们的非反应式对应物服务更多的用户。

云原生应用是高度分布的系统,部署在动态环境中,其中变化是常态,故障可能发生且必然会发生。如果服务不可用怎么办?如果请求在前往目标服务的路上丢失了怎么办?如果响应在返回调用者的路上丢失了怎么办?在这种情况下,我们能否保证高可用性?

弹性是迁移到云端的其中一个目标,也是表征云原生应用的特性之一。我们的系统应该能够抵御故障,并足够稳定以确保为用户提供一定的服务水平。在网络中服务之间的集成点是实现稳定和弹性生产系统最关键的领域之一。这一点如此重要,以至于迈克尔·T·尼加德在他的书《发布它!设计和部署生产就绪软件》(Pragmatic Bookshelf,2018 年)中花费了大量篇幅来讨论这个主题。

本章将专注于使用反应式范式构建云端的弹性、可扩展和高效的应用程序。首先,我将介绍事件循环模型和 Reactive Streams、Project Reactor 以及 Spring 反应式堆栈的主要特性。然后,您将使用 Spring WebFlux 和 Spring Data R2DBC 构建一个反应式订单服务应用程序。

订单服务将与目录服务交互,以检查书籍的可用性和详细信息,因此您将看到如何使用 Spring WebClient 实现一个反应式 REST 客户端。这两个服务之间的集成点是需要额外关注以实现健壮性和容错性的关键区域。依靠 Reactor 项目,您将采用重试、超时和故障转移等稳定性模式。最后,您将编写自动测试来验证使用 Spring Boot 和 Testcontainers 的反应式应用程序的行为。

注意:本章中示例的源代码可在 Chapter08/08-begin 和 Chapter08/08-end 文件夹中找到,包含项目的初始和最终状态(github.com/ThomasVitale/cloud-native-spring-in-action)。

8.1 使用 Reactor 和 Spring 的异步和非阻塞架构

反应式宣言》(www.reactivemanifesto.org)将反应式系统描述为响应的、弹性的、可伸缩的和基于消息驱动的。其构建松散耦合、可伸缩、弹性和成本效益高的应用程序的使命与我们对云原生的定义完全一致。新的部分是通过使用基于消息传递的异步和非阻塞通信范式来实现这一目标。

在深入构建 Spring 中的反应式应用程序之前,我们将探讨反应式编程的基础知识、为什么它对云原生应用程序很重要,以及它与命令式编程的不同之处。我将介绍事件循环模型,它克服了按请求分配线程模型的缺点。然后,你将学习由 Project Reactor 和 Spring 反应式堆栈实现的 Reactive Streams 规范的基本概念。

8.1.1 从按请求分配线程到事件循环

正如你在第三章中看到的,非反应式应用程序为每个请求分配一个线程。在返回响应之前,该线程将不会被用于其他任何操作。这就是所谓的按请求分配线程模型。当请求处理涉及像 I/O 这样的密集型操作时,线程将阻塞,直到这些操作完成。例如,如果需要数据库读取,线程将等待直到从数据库返回数据。在等待期间,分配给处理线程的资源没有得到有效利用。如果你想支持更多的并发用户,你必须确保你有足够的线程和资源可用。最终,这种范式对应用程序的可扩展性设置了限制,并且没有以最有效的方式使用计算资源。图 8.1 展示了它是如何工作的。

08-01

图 8.1 在按请求分配线程模型中,每个请求都由一个专门用于其处理的线程来处理。

反应式应用程序在设计上就更加可扩展和高效。在反应式应用程序中处理请求不涉及为特定线程分配——请求基于事件异步完成。例如,如果需要数据库读取,处理该流程部分的线程将不会等待直到从数据库返回数据。相反,注册一个回调,每当信息准备好时,就会发送通知,并且可用的线程之一将执行回调。在这段时间里,请求数据的线程可以被用来处理其他请求,而不是闲置等待。

这种被称为事件循环的范式,不对应用程序的可扩展性设置硬性约束。实际上,它使得可扩展性更容易实现,因为并发请求的增加并不严格依赖于线程的数量。事实上,Spring 中反应式应用程序的默认配置是每个 CPU 核心只使用一个线程。通过非阻塞 I/O 能力和基于事件的通信范式,反应式应用程序允许更有效地利用计算资源。图 8.2 展示了它是如何工作的。

08-02

图 8.2 在事件循环模型中,请求由不会在等待密集型操作时阻塞的线程处理,这使得它们可以在同时处理其他请求。

我想简要地提及这两种范式之间的区别,因为这有助于解释响应式编程背后的推理。然而,你不需要了解这些范式内部机制的细节,因为我们不需要在如此低级别工作或实现事件循环。相反,我们将依赖方便的高级抽象,这将使我们能够专注于应用程序的业务逻辑,而不是花费时间处理线程级别的处理。

规模和成本优化是迁移到云的两个关键原因,因此响应式范式非常适合云原生应用程序。将应用程序扩展以支持工作负载增加变得不那么具有挑战性。通过更有效地使用资源,你可以节省云提供商提供的计算资源费用。迁移到云的另一个原因是弹性,响应式应用程序也有助于这一点。

响应式应用程序的一个基本特征是它们提供非阻塞背压(也称为控制流)。这意味着消费者可以控制他们接收的数据量,这降低了生产者发送比消费者能处理更多的数据的风险,这可能导致 DoS 攻击,减慢应用程序,级联故障,甚至导致完全崩溃。

响应式范式是解决需要更多线程来处理高并发且可能导致应用缓慢或完全无响应的阻塞 I/O 操作问题的解决方案。有时,这种范式被误认为是提高应用程序速度的一种方式。响应式是关于提高可扩展性和弹性,而不是速度。

尽管如此,强大的力量伴随着巨大的麻烦。当你预期高流量和高并发,但计算资源较少或在流式场景中时,转向响应式是一个很好的选择。然而,你也应该意识到这种范式引入的额外复杂性。除了需要转变思维模式以事件驱动的方式思考外,由于异步 I/O,响应式应用程序更难以调试和故障排除。在匆忙将所有应用程序重写为响应式之前,请三思是否真的有必要,并考虑其利弊。

响应式编程不是一个新概念。它已经使用了多年。这种范式在 Java 生态系统中的近期成功归因于响应式流规范及其实现,如 Project Reactor、RxJava 和 Vert.x,它们为开发者提供了方便的高级接口,用于构建异步和非阻塞应用程序,而无需处理设计消息驱动流程的底层细节。下一节将介绍 Project Reactor,这是 Spring 使用的响应式框架。

8.1.2 Project Reactor:使用 Mono 和 Flux 的响应式流

Reactive Spring 基于 Project Reactor,这是一个在 JVM 上构建异步、非阻塞应用程序的框架。Reactor 是响应式流规范的实现,旨在提供“一个用于异步流处理的标准,具有非阻塞背压”(www.reactive-streams.org)。

从概念上讲,响应式流在用于构建数据管道的方式上类似于 Java Stream API。其中一个关键区别是 Java 流是基于拉取的:消费者以命令式和同步的方式处理数据。相反,响应式流是基于推送的:当生产者通知有新数据可用时,消费者会被通知,因此处理是异步的。

响应式流根据生产者/消费者模式工作。生产者被称为发布者。他们生产可能最终可用的数据。Reactor 提供了两个核心 API,实现了类型为的对象的 Producer接口,并用于组合异步、可观察的数据流:Mono和 Flux

  • Mono—表示单个异步值或空结果(0..1)

  • Flux—表示零个或多个项目的异步序列(0..N)

在 Java 流中,您会处理像 Optional或 Collection 这样的对象。在响应式流中,您将拥有 Mono或 Flux。响应式流的可能结果是一个空结果、一个值或一个错误。所有这些都被视为数据。当发布者返回所有数据时,我们说响应式流已成功完成

消费者被称为订阅者,因为他们订阅了一个出版商,并且每当有新数据可用时都会收到通知。作为订阅的一部分,消费者还可以通过通知出版商他们一次只能处理一定量的数据来定义背压。这是一个强大的功能,使消费者能够控制接收的数据量,防止他们被淹没并变得无响应。只有当有订阅者时,响应式流才会被激活。

您可以构建响应式流,将来自不同来源的数据组合在一起,并使用 Reactor 庞大的操作符集合来操作它。在 Java 流中,您可以使用流畅的 API 通过 map、flatMap 或 filter 等操作符处理数据,每个操作符都会构建一个新的 Stream 对象,保持之前步骤的不变性。同样,您可以使用流畅的 API 和操作符构建响应式流来处理异步接收到的数据。

除了 Java 流可用的标准操作符之外,你还可以使用更强大的操作符来应用背压、处理错误并提高应用程序的弹性。例如,你将了解如何使用 retryWhen()和 timeout()操作符来使订单服务和目录服务之间的交互更加健壮。操作符可以在发布者上执行操作并返回一个新的发布者,而不修改原始的发布者,因此你可以轻松地构建函数性和不可变的数据流。

Project Reactor 是 Spring 反应式堆栈的基础,它允许你使用 Mono和 Flux来实现你的业务逻辑。在下一节中,你将了解使用 Spring 构建反应式应用程序有哪些选项。

8.1.3 理解 Spring 反应式堆栈

当你使用 Spring 构建应用程序时,你可以在 servlet 堆栈和反应式堆栈之间进行选择。servlet 堆栈依赖于同步、阻塞 I/O,并使用每个请求一个线程的模型来处理请求。另一方面,反应式堆栈依赖于异步、非阻塞 I/O,并使用事件循环模型来处理请求。

servlet 堆栈基于 Servlet API 和一个 Servlet 容器(如 Tomcat)。相比之下,反应式模型基于 Reactive Streams API(由 Project Reactor 实现)和 Netty 或 Servlet 容器(至少是版本 3.1)。这两个堆栈都允许你使用标注为@RestController 的类(你在第三章中使用过)或称为路由函数的功能端点(你将在第九章中了解)来构建 RESTful 应用程序。servlet 堆栈使用 Spring MVC,而反应式堆栈使用 Spring WebFlux。图 8.3 比较了这两个堆栈。(对于更广泛的概述,你可以参考spring.io/reactive.

08-03

图 8.3 servlet 堆栈基于 Servlet API,支持同步和阻塞操作。反应式堆栈基于 Project Reactor,支持异步和非阻塞操作。

Tomcat 是像目录服务这样的基于 servlet 的应用程序的首选选择。Netty 是反应式应用程序的首选选择,提供最佳性能。

Spring 生态系统中的所有主要框架都提供非反应性和反应性选项,包括 Spring Security、Spring Data 和 Spring Cloud。总的来说,Spring 反应式堆栈提供了一个高级接口来构建反应式应用程序,依赖于熟悉的 Spring 项目,而不必关心反应流底层的实现。

8.2 使用 Spring WebFlux 和 Spring Data R2DBC 构建反应式服务器

到目前为止,我们一直在使用 Spring MVC 和 Spring Data JDBC 构建非响应式(或命令式)的目录服务应用。本节将教你如何使用 Spring WebFlux 和 Spring Data R2DBC 构建响应式 Web 应用(订单服务)。订单服务将提供购买书籍的功能。像目录服务一样,它将公开 REST API 并将数据存储在 PostgreSQL 数据库中。与目录服务不同,它将使用响应式编程范式来提高可伸缩性、弹性和成本效益。

你会发现,你在前几章中学到的原则和模式也适用于响应式应用。主要区别在于,我们将从以命令式方式实现业务逻辑转变为构建异步处理的响应式流。

订单服务还将通过其 REST API 与目录服务交互,以获取书籍的详细信息并检查其可用性。这将是第 8.3 节的重点。图 8.4 显示了系统的新的组件。

08-04

图 8.4 订单服务应用公开了一个提交和检索书籍订单的 API,使用 PostgreSQL 数据库存储数据,并与图书服务通信以获取图书详情。

如你在第三章中学到的,我们应该从 API 开始。订单服务将公开 REST API 以检索现有书籍订单并提交新的订单。每个订单只能关联一本书,最多五本。API 在表 8.1 中描述。

表 8.1 订单服务将公开的 REST API 规范

端点 HTTP 方法 请求体 状态 响应体 描述
/orders POST OrderRequest 200 Order 提交给定数量给定书籍的新订单
/orders GET 200 Order[] 获取所有订单

现在,让我们来看代码。

注意:如果你没有跟随前几章中实现的示例,你可以参考书籍附带的存储库,并使用第八章/08-begin 文件夹中的项目作为起点(github.com/ThomasVitale/cloud-native-spring-in-action)。

8.2.1 使用 Spring Boot 启动响应式应用

你可以从 Spring Initializr(start.spring.io)初始化订单服务项目,将结果存储在一个新的 order-service Git 仓库中,并将其推送到 GitHub。初始化的参数如图 8.5 所示。

08-05

图 8.5 从 Spring Initializr 初始化订单服务项目的参数

提示:如果你不想在 Spring Initializr 网站上手动生成,你可以在本章的开始文件夹中找到一个 curl 命令,你可以在终端窗口中运行它以下载 zip 文件。它包含你开始所需的所有代码。

自动生成的 build.gradle 文件的依赖项部分如下所示:

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
  implementation 'org.springframework.boot:spring-boot-starter-validation'
  implementation 'org.springframework.boot:spring-boot-starter-webflux'

  runtimeOnly 'org.postgresql:r2dbc-postgresql'

  testImplementation 'org.springframework.boot:spring-boot-starter-test'
  testImplementation 'io.projectreactor:reactor-test'
  testImplementation 'org.testcontainers:junit-jupiter'
  testImplementation 'org.testcontainers:postgresql'
  testImplementation 'org.testcontainers:r2dbc'
}

这些是主要的依赖项:

  • Spring Reactive Web (org.springframework.boot:spring-boot-starter-webflux)—提供构建使用 Spring WebFlux 的响应式 Web 应用程序所需的库,包括 Netty 作为默认的嵌入服务器。

  • Spring Data R2DBC (org.springframework.boot:spring-boot-starter-data-r2dbc)—提供在响应式应用程序中使用 R2DBC 在关系数据库中持久化数据的必要库。

  • 验证 (org.springframework.boot:spring-boot-starter-validation)—提供使用 Java Bean 验证 API 进行对象验证的必要库。

  • PostgreSQL (org.postgresql:r2dbc-postgresql)—提供一个 R2DBC 驱动程序,允许应用程序以响应式方式连接到 PostgreSQL 数据库。

  • Spring Boot Test (org.springframework.boot:spring-boot-starter-test)—提供用于测试应用程序的多个库和实用工具,包括 Spring Test、JUnit、AssertJ 和 Mockito。它自动包含在每一个 Spring Boot 项目中。

  • Reactor Test (io.projectreactor:reactor-test)—提供基于 Project Reactor 测试响应式应用程序的实用工具。它自动包含在每一个响应式 Spring Boot 项目中。

  • Testcontainers (org.testcontainers:junit-jupiter, org.testcontainers:postgresql, org.testcontainers:r2dbc)—提供使用轻量级 Docker 容器测试应用程序所需的库。特别是,它为支持 R2DBC 驱动的 PostgreSQL 提供测试容器。

Spring Boot 中响应式应用程序的默认和推荐嵌入服务器是 Reactor Netty,它基于 Netty 构建,在 Project Reactor 中提供响应式功能。你可以通过属性或通过定义一个 WebServerFactoryCustomizer组件来配置它。让我们使用第一种方法。

首先,将 Spring Initializr 生成的 application.properties 文件重命名为 application.yml,并使用 spring.application.name 属性定义应用程序名称。就像为 Tomcat 做的那样,你可以通过 server.port 属性定义服务器端口,通过 server.shutdown 配置优雅关闭,并通过 spring.lifecycle.timeout-per-shutdown-phase 设置宽限期。使用特定的 Netty 属性,你可以进一步自定义服务器的行为。例如,你可以使用 server.netty.connection-timeout 和 server.netty.idle-timeout 属性为 Netty 定义连接和空闲超时。

列表 8.1 配置 Netty 服务器和优雅关闭

server:
  port: 9002                           ❶
  shutdown: graceful                   ❷
  netty:
    connection-timeout: 2s             ❸
    idle-timeout: 15s                  ❹

spring:
  application:
    name: order-service
  lifecycle:
    timeout-per-shutdown-phase: 15s    ❺

❶ 服务器将接受连接的端口

❷ 启用优雅关闭

❸ 等待与服务器建立 TCP 连接的时间长度

❹ 如果没有数据传输,则在关闭 TCP 连接之前等待的时间长度

❺ 定义了 15 秒的宽限期

在此基本设置到位后,我们现在可以定义领域实体及其持久化。

8.2.2 使用 Spring Data R2DBC 反应式持久化数据

在第五章中,您了解到 Spring Boot 应用程序与数据库之间的交互涉及数据库驱动程序、实体和仓库。您在 Spring Data JDBC 的上下文中学习的相同概念也适用于 Spring Data R2DBC。Spring Data 提供了常见的抽象和模式,使得在不同模块之间导航变得简单。

与目录服务相比,订单服务的主要区别在于数据库驱动程序的类型。JDBC 是 Java 应用程序与关系型数据库通信最常用的驱动程序,但它不支持反应式编程。已经有一些尝试提供对关系型数据库的反应式访问。一个突出且得到广泛支持的项目是由 Pivotal(现在是 VMware Tanzu)发起的反应式关系型数据库连接(R2DBC)。R2DBC 驱动程序适用于所有主要数据库(如 PostgreSQL、MariaDB、MySQL、SQL Server 和 Oracle DB),并且有多个项目的客户端,包括带有 Spring Data R2DBC 的 Spring Boot 和 Testcontainers。

本节将指导您使用 Spring Data R2DBC 和 PostgreSQL 定义订单服务的领域实体和持久化层。让我们开始吧。

运行订单服务的 PostgreSQL 数据库

首先,我们需要一个数据库。我们将采用“每个服务一个数据库”的方法来保持我们的应用程序松散耦合。既然我们决定目录服务和订单服务各自将有一个数据库,我们有两个实际的存储选项。我们可以使用相同的数据库服务器为两个数据库服务,或者使用两个不同的服务器。为了方便,我们将使用第五章中设置的相同的 PostgreSQL 服务器来托管目录服务使用的 polardb_catalog 数据库和订单服务使用的新 polardb_order 数据库。

前往您的 polar-deployment 仓库,并创建一个新的 docker/postgresql 文件夹。然后在文件夹中添加一个新的 init.sql 文件。将以下代码添加到 init.sql 文件中;这是 PostgreSQL 在启动阶段应运行的初始化脚本。

列表 8.2 使用两个数据库初始化 PostgreSQL 服务器

CREATE DATABASE polardb_catalog;
CREATE DATABASE polardb_order;

接下来,打开 docker-compose.yml 文件,并更新 PostgreSQL 容器定义以加载初始化脚本。请记住删除 POSTGRES_DB 环境变量的值,因为我们现在将数据库创建委托给脚本。在本书的源代码中,请参考 Chapter08/08-end/polar-deployment/docker 检查最终结果。

列表 8.3 从 SQL 脚本初始化 PostgreSQL 服务器

version: "3.8"
services:
  ...
  polar-postgres:
    image: "postgres:14.4"
    container_name: "polar-postgres"
    ports:
      - 5432:5432
    environment:                       ❶
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=password
    volumes:                           ❷
      - ./postgresql/init.sql:/docker-entrypoint-initdb.d/init.sql 

❶ 已不再为 POSTGRES_DB 定义值。

❷ 将初始化 SQL 脚本挂载到容器中作为卷

最后,根据新的配置启动一个新的 PostgreSQL 容器。打开一个终端窗口,导航到定义 docker-compose.yml 文件的文件夹,并运行以下命令:

$ docker-compose up -d polar-postgres

在本章的剩余部分,我将假设您的数据库已经启动并运行。

使用 R2DBC 连接到数据库

Spring Boot 允许您通过 spring.r2dbc 属性配置一个反应式应用程序与关系型数据库的集成。打开 Order Service 项目的 application.yml 文件,并配置与 PostgreSQL 的连接。默认情况下启用连接池,您可以进一步通过定义连接超时和大小来配置它,就像在第五章中为 JDBC 所做的那样。由于它是一个反应式应用程序,连接池可能比使用 JDBC 时要小。您可以在监控应用程序在正常条件下的运行后调整这些值。

列表 8.4 通过 R2DBC 配置数据库集成

spring:
  r2dbc: 
    username: user                                         ❶
    password: password                                     ❷
    url: r2dbc:postgresql://localhost:5432/polardb_order   ❸
    pool: 
      max-create-connection-time: 2s                       ❹
      initial-size: 5                                      ❺
      max-size: 10                                         ❻

❶ 具有访问给定数据库权限的用户

❷ 给定用户的密码

❸ 识别您想要建立连接的数据库的 R2DBC URL

❹ 从池中获取连接的最大等待时间

❺ 连接池的初始大小

❻ 池中保持的最大连接数

现在您已经通过 R2DBC 驱动程序将一个反应式 Spring Boot 应用程序连接到了 PostgreSQL 数据库,您可以继续定义您想要持久化的数据。

定义持久化实体

Order Service 应用程序提供了提交和检索订单的功能。这就是 领域实体。为业务逻辑添加一个新的 com.polarbookshop.orderservice.order .domain 包,并创建一个 Order Java 记录来表示领域实体,就像您在 Catalog Service 中定义 Book 一样。

按照第五章中使用的方法,使用 @Id 注解标记数据库中代表主键的字段,并使用 @Version 提供一个版本号,这对于处理并发更新和使用乐观锁至关重要。您还可以添加必要的字段来存储审计元数据,使用 @CreatedDate 和 @LastModifiedDate 注解。

将实体映射到关系表的默认策略是将 Java 对象名称转换为小写。在这个例子中,Spring Data 会尝试将 Order 记录映射到 order 表。问题是 order 是 SQL 中的一个保留词。不建议将其用作表名,因为它需要特殊处理。您可以通过将表命名为 orders 并通过 @Table 注解(来自 org.springframework.data.relational.core.mapping 包)配置对象关系映射来克服这个问题。

列表 8.5 Order 记录定义了领域和持久化实体

package com.polarbookshop.orderservice.order.domain;

import java.time.Instant;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.annotation.Version;
import org.springframework.data.relational.core.mapping.Table;

@Table("orders")                ❶
public record Order (

  @Id
  Long id,                      ❷

  String bookIsbn,
  String bookName,
  Double bookPrice,
  Integer quantity,
  OrderStatus status,

  @CreatedDate
  Instant createdDate,          ❸

  @LastModifiedDate
  Instant lastModifiedDate,     ❹

  @Version
  int version                   ❺
){
  public static Order of(
    String bookIsbn, String bookName, Double bookPrice,
    Integer quantity, OrderStatus status
  ) {
    return new Order(
      null, bookIsbn, bookName, bookPrice, quantity, status, null, null, 0
    );
  }
}

❶ 配置“Order”对象与“orders”表之间的映射

❷ 实体的主键

❸ 实体创建的时间

❹ 实体上次修改的时间

❺ 实体的版本号

订单可以经历不同的阶段。如果请求的书籍在目录中可用,则订单将被 接受。如果不可用,则被 拒绝。一旦订单被接受,它就可以被 发货,正如你在第十章中看到的。你可以在 com.polarbookshop.orderservice.order.domain 包中的 OrderStatus 枚举中定义这三个状态。

列表 8.6 描述订单状态的枚举

package com.polarbookshop.orderservice.order.domain;

public enum OrderStatus {
  ACCEPTED,
  REJECTED,
  DISPATCHED
}

可以使用 @EnableR2dbcAuditing 注解在配置类中启用 R2DBC 审计功能。在新的 com.polarbookshop.orderservice.config 包中创建一个 DataConfig 类,并在此处启用审计。

列表 8.7 通过注解配置启用 R2DBC 审计

package com.polarbookshop.orderservice.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.r2dbc.config.EnableR2dbcAuditing;

@Configuration                 ❶
@EnableR2dbcAuditing           ❷
public class DataConfig {}

❶ 将一个类标记为 Spring 配置的来源

❷ 启用 R2DBC 审计以持久化实体

在定义了要持久化的数据后,你可以继续探索如何访问它。

使用反应式仓库

Spring Data 为项目中的所有模块提供 repository 抽象,包括 R2DBC。与第五章中你所做不同的是,你将使用一个反应式仓库。

在 com.polarbookshop.orderservice.order.domain 包中,创建一个新的 OrderRepository 接口,并使其扩展 ReactiveCrudRepository,指定处理的数据类型(订单)和 @Id 注解字段的 数据类型(Long)。

列表 8.8 用于访问订单的仓库接口

package com.polarbookshop.orderservice.order.domain;

import org.springframework.data.repository.reactive.ReactiveCrudRepository;

public interface OrderRepository
  extends ReactiveCrudRepository<Order,Long> {}   ❶

❶ 扩展反应式仓库以提供 CRUD 操作,指定管理实体的类型(订单)及其主键类型(Long)

ReactiveCrudRepository 提供的 CRUD 操作足以满足订单服务应用程序的使用案例,因此你不需要添加任何自定义方法。然而,我们仍然缺少数据库中的订单表。让我们使用 Flyway 来定义它。

使用 Flyway 管理数据库模式

Spring Data R2DBC 支持通过 schema.sql 和 data.sql 文件初始化数据源,就像 Spring Data JDBC 一样。正如你在第五章中学到的,这个功能对于演示和实验来说很方便,但最好在生产用例中显式管理模式。

对于目录服务,我们使用了 Flyway 来创建和演进其数据库模式。我们也可以为订单服务做同样的事情。然而,Flyway 目前还不支持 R2DBC,因此我们需要提供一个 JDBC 驱动程序来与数据库通信。Flyway 迁移任务仅在应用程序启动时和在单个线程中运行,所以在这个特定情况下使用非反应式通信方法不会影响应用程序的整体可伸缩性和效率。

在你的订单服务项目的 build.gradle 文件中,添加新的依赖项到 Flyway、PostgreSQL JDBC 驱动程序和 Spring JDBC。记得在添加新依赖项后刷新或重新导入 Gradle 依赖项。

列表 8.9 在订单服务中添加 Flyway 和 JDBC 依赖项

dependencies {
  ...
  runtimeOnly 'org.flywaydb:flyway-core'             ❶
  runtimeOnly 'org.postgresql:postgresql'            ❷
  runtimeOnly 'org.springframework:spring-jdbc'      ❸
}

❶ 提供通过迁移来版本控制数据库的功能

❷ 提供一个 JDBC 驱动,允许应用程序连接到 PostgreSQL 数据库

❸ 提供了与 JDBC API 的 Spring 集成。它是 Spring 框架的一部分,不要与 Spring Data JDBC 混淆。

然后,你可以在 src/main/resources/db/migration 目录下的 V1__Initial_ schema.sql 文件中编写创建订单表的 SQL 脚本。确保在版本号后输入两个下划线。

列表 8.10 用于模式初始化的 Flyway 迁移脚本

CREATE TABLE orders (                                   ❶
  id                  BIGSERIAL PRIMARY KEY NOT NULL,   ❷
  book_isbn           varchar(255) NOT NULL,
  book_name           varchar(255),
  book_price          float8,
  quantity            int NOT NULL,
  status              varchar(255) NOT NULL,
  created_date        timestamp NOT NULL,
  last_modified_date  timestamp NOT NULL,
  version             integer NOT NULL
);

❶ 订单表的定义

❷ 将 id 字段声明为主键

最后,打开 application.yml 文件,并配置 Flyway 以使用与 Spring Data R2DBC 管理的相同数据库,但使用 JDBC 驱动。

列表 8.11 通过 JDBC 配置 Flyway 集成

spring:
  r2dbc:
    username: user
    password: password
    url: r2dbc:postgresql://localhost:5432/polardb_order
    pool:
      max-create-connection-time: 2s
      initial-size: 5
      max-size: 10
  flyway: 
    user: ${spring.r2dbc.username}                         ❶
    password: ${spring.r2dbc.password}                     ❷
    url: jdbc:postgresql://localhost:5432/polardb_order    ❸

❶ 从为 R2DBC 配置的用户名中获取值

❷ 从为 R2DBC 配置的密码中获取值

❸ 使用 JDBC 驱动的与 R2DBC 配置相同的数据库

如你或许注意到的,在反应式应用程序中定义域对象和添加持久化层与你在命令式应用程序中要做的事情类似。在这个会话中你遇到的主要区别是使用 R2DBC 驱动而不是 JDBC,并且有一个独立的 Flyway 配置(至少直到 R2DBC 支持被添加到 Flyway 项目:github.com/flyway/flyway/issues/2502)。

在下一个部分中,你将学习如何在业务逻辑中使用 MonoFlux

8.2.3 使用反应式流实现业务逻辑

Spring 反应式堆栈使得构建异步、非阻塞应用程序变得简单直接。在上一个部分中,我们使用了 Spring Data R2DBC 并且不需要处理任何底层的反应式问题。这对于 Spring 中所有的反应式模块都是普遍适用的。作为开发者,你可以依赖一个熟悉、简单且高效的方法来构建反应式应用程序,而框架则负责所有繁重的工作。

默认情况下,Spring WebFlux 假设一切都是反应式的。这个假设意味着你期望通过交换 Publisher<T> 对象(如 Mono<T>Flux<T>)与框架交互。例如,我们之前创建的 OrderRepository 将会以 Mono<Order>Flux<Order> 对象的形式提供订单访问,而不是像在非反应式环境中那样返回 Optional<Order>Collection<Order>。让我们看看这是如何实现的。

com.polarbookshop.orderservice.order.domain 包中,创建一个新的 OrderService 类。首先,让我们实现通过仓库读取订单的逻辑。当涉及多个订单时,你可以使用 Flux<Order> 对象,它代表零个或多个订单的异步序列。

列表 8.12 通过反应式流获取订单

package com.polarbookshop.orderservice.order.domain;

import reactor.core.publisher.Flux;
import org.springframework.stereotype.Service;

@Service                                         ❶
public class OrderService {
  private final OrderRepository orderRepository;
  public OrderService(OrderRepository orderRepository) {
    this.orderRepository = orderRepository;
  }
  public Flux<Order> getAllOrders() {            ❷
    return orderRepository.findAll();
  }
}

❶ 标记一个类为 Spring 管理的服务的类型注解

❷ 使用 Flux 发布多个订单(0..N)

接下来,我们需要一个提交订单的方法。在我们与 Catalog 服务集成到位之前,我们总是可以默认拒绝提交的订单。OrderRepository 暴露了由 ReactiveCrudRepository 提供的 save() 方法。你可以构建一个反应流,将类型为 Mono 的对象传递给 OrderRepository,以便在数据库中保存订单。

给定一个识别书籍的 ISBN 和要订购的副本数量,你可以使用 Mono.just() 创建一个 Mono 对象,就像使用 Stream.of() 创建 Java Stream 对象一样。区别在于反应行为。

你可以使用 Mono 对象来启动一个反应流,然后依靠 flatMap() 操作符将数据传递到 OrderRepository。将以下代码添加到 OrderService 类中,并完成业务逻辑实现。

列表 8.13 在提交订单请求时持久化拒绝的订单

...
public Mono<Order> submitOrder(String isbn, int quantity) {
  return Mono.just(buildRejectedOrder(isbn, quantity))        ❶
    .flatMap(orderRepository::save);                          ❷
}

public static Order buildRejectedOrder(
  String bookIsbn, int quantity
) {                                                           ❸
  return Order.of(bookIsbn, null, null, quantity, OrderStatus.REJECTED);
}
...

❶ 从“Order”对象创建一个“Mono”

❷ 将由反应流前一步异步产生的 Order 对象保存到数据库中

❸ 当订单被拒绝时,我们只指定 ISBN、数量和状态。Spring Data 会负责添加标识符、版本和审计元数据。

map 与 flatMap 的比较

当使用 Reactor 时,选择 map() 和 flatMap() 操作符通常是一个令人困惑的来源。这两个操作符都返回一个反应流(要么是 Mono 要么是 Flux),但 while map() 在两个标准 Java 类型之间映射,flatMap() 则从 Java 类型映射到另一个反应流。

在列表 8.13 中,我们将 Order 类型的对象映射到 Mono(由 OrderRepository 返回)。由于 map() 操作符期望目标类型不是反应流,但它仍然会将其包装在一个反应流中,并返回一个 Mono<Mono> 对象。另一方面,flatMap() 操作符期望目标类型是反应流,因此它知道如何处理 OrderRepository 生成的发布者,并正确地返回一个 Mono 对象。

在下一节中,你将通过暴露一个用于获取和提交订单的 API 来完成 Order 服务的基本实现。

8.2.4 使用 Spring WebFlux 暴露 REST API

在 Spring WebFlux 应用程序中定义 RESTful 端点有两种选择:@RestController 类或功能豆(路由函数)。对于 Order 服务应用程序,我们将使用第一种选项。与第三章中我们所做的不同,方法处理器将返回反应对象。

对于 GET 端点,我们可以使用我们之前定义的 Order 领域实体,并返回一个 Flux 对象。当提交订单时,用户必须提供所需书籍的 ISBN 和他们想要购买的副本数量。我们可以在 OrderRequest 记录中建模这些信息,该记录将充当数据传输对象(DTO)。按照第三章中学到的,验证输入也是一个好的实践。

创建一个新的 com.polarbookshop.orderservice.order.web 包,并定义一个 OrderRequest 记录来保存提交的订单信息。

列表 8.14 带有验证约束的 OrderRequest DTO 类

package com.polarbookshop.orderservice.order.web;

import javax.validation.constraints.*;

public record OrderRequest (

  @NotBlank(message = "The book ISBN must be defined.")
  String isbn,                                                     ❶

  @NotNull(message = "The book quantity must be defined.")
  @Min(value = 1, message = "You must order at least 1 item.")
  @Max(value = 5, message = "You cannot order more than 5 items.")
  Integer quantity                                                 ❷
){}

❶ 不能为 null,并且必须包含至少一个非空白字符

❷ 不能为 null,并且必须包含 1 到 5 之间的值

在同一个包中,创建一个 OrderController 类来定义 Order Service 应用程序暴露的两个 RESTful 端点。由于你为 OrderRequest 对象定义了验证约束,因此你还需要使用熟悉的@Valid 注解来在方法调用时触发验证。

列表 8.15 定义处理程序以处理 REST 请求

package com.polarbookshop.orderservice.order.web;

import javax.validation.Valid;
import com.polarbookshop.orderservice.order.domain.Order;
import com.polarbookshop.orderservice.order.domain.OrderService;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.web.bind.annotation.*;

@RestController                                      ❶
@RequestMapping("orders")                            ❷
public class OrderController {
  private final OrderService orderService;
  public OrderController(OrderService orderService) {
    this.orderService = orderService;
  }

  @GetMapping
  public Flux<Order> getAllOrders() {                ❸
    return orderService.getAllOrders();
  }

  @PostMapping
  public Mono<Order> submitOrder(
    @RequestBody @Valid OrderRequest orderRequest    ❹
  ) {
    return orderService.submitOrder(
     orderRequest.isbn(), orderRequest.quantity()
    );
  }
}

❶ 轮廓注解标记一个类为 Spring 组件和 REST 端点的处理程序源

❷ 确定类提供的处理程序的根路径映射 URI(/orders)

❸ 使用 Flux 发布多个订单(0..N)。

❹ 接受一个 OrderRequest 对象,对其进行验证并用于创建订单。创建的订单作为 Mono 返回。

这个 REST 控制器完成了 Order Service 应用程序的基本实现。让我们看看它的实际效果。首先,确保你之前创建的 PostgreSQL 容器仍在运行。然后打开一个终端窗口,导航到 Order Service 项目的根文件夹,并运行应用程序:

$ ./gradlew bootRun

你可以通过提交一个订单来尝试 API。应用程序会将订单保存为已拒绝,并向客户端返回 200 响应:

$ http POST :9002/orders isbn=1234567890 quantity=3

HTTP/1.1 200 OK
{
  "bookIsbn": "1234567890",
  "bookName": null,
  "bookPrice": null,
  "createdDate": "2022-06-06T09:40:58.374348Z",
  "id": 1,
  "lastModifiedDate": "2022-06-06T09:40:58.374348Z",
  "quantity": 3,
  "status": "REJECTED",
  "version": 1
}

为了能够成功提交订单,我们需要让 Order Service 调用 Catalog Service 来检查书籍的可用性并获取处理订单所需的信息。这是下一节的重点。在继续之前,使用 Ctrl-C 停止应用程序。

8.3 使用 Spring WebClient 的响应式客户端

在一个云原生系统中,应用程序可以以不同的方式交互。本节重点介绍 Order Service 和 Catalog Service 之间通过 HTTP 建立的请求/响应交互。在这种交互中,发起请求的客户端期望收到响应。在命令式应用程序中,这会转化为线程阻塞,直到返回响应。相反,在响应式应用程序中,我们可以更有效地使用资源,这样就没有线程会等待响应,从而释放资源来处理其他处理。

Spring 框架附带两个执行 HTTP 请求的客户端:RestTemplate 和 WebClient。RestTemplate 是原始的 Spring REST 客户端,它允许基于模板方法 API 的阻塞 HTTP 请求/响应交互。自 Spring Framework 5.0 以来,它处于维护模式,实际上已被弃用。它仍然被广泛使用,但在未来的版本中不会获得任何新功能。

WebClient 是 RestTemplate 的现代替代品。它提供阻塞和非阻塞 I/O,使其成为命令式和响应式应用程序的完美候选者。它可以通过函数式风格的流畅 API 操作,允许你配置 HTTP 交互的任何方面。

本节将向您介绍如何使用 WebClient 建立非阻塞的请求/响应交互。我还会解释如何通过采用超时、重试和故障转移等模式,使用 Reactor 操作符 timeout()、retryWhen() 和 onError() 来提高您的应用程序的健壮性。

8.3.1 Spring 中的服务间通信

根据 15-Factor 方法论,任何后端服务都应该通过资源绑定附加到应用程序。对于数据库,您依赖于 Spring Boot 提供的配置属性来指定凭据和 URL。当后端服务是另一个应用程序时,您需要以类似的方式提供其 URL。遵循外部化配置原则,URL 应该是可配置的,而不是硬编码的。在 Spring 中,您可以通过 @ConfigurationProperties bean 实现这一点,正如您在第四章中学到的。

在 Order Service 项目中,在 com.polarbookshop.orderservice.config 包中添加一个 ClientProperties 记录。在那里,定义您的自定义 polar.catalog-service-uri 属性来配置调用目录服务的 URI。

列表 8.16 为目录服务 URI 定义自定义属性

package com.polarbookshop.orderservice.config;

import java.net.URI;
import javax.validation.constraints.NotNull;
import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "polar")    ❶
public record ClientProperties(

  @NotNull
  URI catalogServiceUri                       ❷
){}

❶ 自定义属性的名称前缀

❷ 用于指定目录服务 URI 的属性。它不能为空。

注意:为了从您的 IDE 获取自动完成和类型验证检查,您需要在 build.gradle 文件中添加对 org.springframework.boot:spring-boot-configuration-processor 的依赖,范围设置为 annotationProcessor,就像您在第四章中做的那样。您可以通过查看代码库中随书附带的 Chapter08/08-end/order-service/build.gradle 文件来检查最终结果(github.com/ThomasVitale/cloud-native-spring-in-action)。

然后,使用 @ConfigurationPropertiesScan 注解在 OrderServiceApplication 类中启用自定义配置属性。

列表 8.17 启用自定义配置属性

package com.polarbookshop.orderservice;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties 
➥ .ConfigurationPropertiesScan; 

@SpringBootApplication
@ConfigurationPropertiesScan             ❶
public class OrderServiceApplication {
  public static void main(String[] args) {
    SpringApplication.run(OrderServiceApplication.class, args);
  }
}

❶ 在 Spring 上下文中加载配置数据 Bean

最后,将新属性的值添加到您的 application.yml 文件中。默认情况下,您可以使用运行在本地环境中的目录服务实例的 URI。

列表 8.18 配置目录服务的 URI(application.yml)

...
polar:
  catalog-service-uri: "http://localhost:9001"

注意:当使用 Docker Compose 或 Kubernetes 部署系统时,您可以通过环境变量覆盖属性值,利用这两个平台提供的服务发现功能。

在下一节中,您将使用通过此属性配置的值从订单服务调用目录服务。

8.3.2 理解如何交换数据

当用户提交特定书籍的订单时,订单服务需要调用目录服务来检查请求的书籍的可用性并获取其详细信息,如标题、作者和价格。交互(HTTP 请求/响应)如图 8.6 所示。

08-06

图 8.6 当提交订单时,订单服务通过 HTTP 调用目录服务以检查书籍的可用性并获取其详细信息。

每个订单请求都是针对特定的 ISBN 提交的。订单服务需要知道书籍的 ISBN、标题、作者和价格才能正确处理订单。目前,目录服务公开了一个返回关于书籍所有可用信息的/books/{bookIsbn}端点。在真实场景中,你可能公开一个返回仅包含所需信息的对象的不同端点(一个 DTO)。为了这个示例,我们将重用现有的端点,因为我们现在的重点是构建响应式客户端。

已经确定了要调用的端点,那么应该如何建模两个应用程序之间的交换?你刚刚到达一个十字路口:

  • 创建共享库—一个选项是创建一个包含两个应用程序都使用的类的共享库,并将其作为依赖项导入到两个项目中。根据 15 因素方法,这样一个库将有自己的代码库进行跟踪。这样做将确保两个应用程序使用的模型是一致的,并且永远不会不同步。然而,这意味着增加了实现耦合。

  • 复制类—另一个选项是将类复制到上游应用程序中。通过这样做,你不会有实现耦合,但你需要注意随着下游应用程序中原始类的变化而演变复制的模型。有一些技术,如消费者驱动的契约,可以通过自动化测试识别出被调用的 API 何时发生变化。除了检查数据模型外,这些测试还会验证暴露的 API 的其他方面,如 HTTP 方法、响应状态、头信息、变量等。我这里不会涉及这个主题,但如果你对此感兴趣,建议查看 Spring Cloud Contract 项目(https://spring.io/projects/spring-cloud-contract)。

这两种选择都是可行的。你采用哪种策略取决于你的项目需求和你的组织结构。对于极地书店项目,我们将使用第二种选项。

在新的 com.polarbookshop.orderservice.book 包中,创建一个 Book 记录作为 DTO 使用,并仅包含订单处理逻辑使用的字段。正如我之前指出的,在真实场景中,我会在目录服务中公开一个新的端点,返回以这种 DTO 建模的书籍对象。为了简单起见,我们将使用现有的/books/{bookIsbn}端点,因此当将接收到的 JSON 反序列化为 Java 对象时,任何映射不到此类字段的信息都将被丢弃。确保你定义的字段与目录服务中定义的 Book 对象中的字段名称相同,否则解析将失败。这是消费者驱动的契约测试可以为你自动验证的事情。

列表 8.19 书籍记录是一个用于存储书籍信息的 DTO

package com.polarbookshop.orderservice.book;

public record Book(
  String isbn,
  String title,
  String author,
  Double price
){}

现在你已经在 Order Service 中准备好了一个 DTO 来存储书籍信息,让我们看看你如何从 Catalog Service 中检索它。

8.3.3 使用 WebClient 实现 REST 客户端

Spring 中 REST 客户端的现代和响应式选择是 WebClient。该框架提供了几种实例化 WebClient 对象的方法——在这个例子中我们将使用 WebClient.Builder。请参考官方文档以探索其他选项(spring.io/projects/spring-framework)。

在 com.polarbookshop.orderservice.config 包中,创建一个 ClientConfig 类来配置一个带有由 ClientProperties 提供的基本 URL 的 WebClient 实例。

列表 8.20 配置 WebClient 实例以调用 Catalog Service

package com.polarbookshop.orderservice.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;

@Configuration
public class ClientConfig {

  @Bean
  WebClient webClient(
    ClientProperties clientProperties,
    WebClient.Builder webClientBuilder                           ❶
  ) {
    return webClientBuilder                                      ❷
      .baseUrl(clientProperties.catalogServiceUri().toString())
      .build();
  }
}

❶ 由 Spring Boot 自动配置以构建 WebClient 实例的对象

❷ 将 WebClient 的基本 URL 配置为自定义属性中定义的 Catalog Service URL

警告 如果你使用 IntelliJ IDEA,你可能会收到一个警告,表明 WebClient.Builder 无法自动装配。不要担心,这是一个误报。你可以通过在字段上注解 @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") 来消除警告。

接下来,在 com.polarbookshop.orderservice.book 包中创建一个 BookClient 类。你将在这个类中使用 WebClient 实例发送 HTTP 请求到 Catalog Service 通过其流畅 API 暴露的 GET /books/{bookIsbn} 端点。WebClient 最终将返回一个包含在 Mono 发布者中的 Book 对象。

列表 8.21 使用 WebClient 定义一个响应式 REST 客户端

package com.polarbookshop.orderservice.book;

import reactor.core.publisher.Mono;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;

@Component
public class BookClient {
  private static final String BOOKS_ROOT_API = "/books/";
  private final WebClient webClient;

  public BookClient(WebClient webClient) {
    this.webClient = webClient;                   ❶
  }

  public Mono<Book> getBookByIsbn(String isbn) {
    return webClient
      .get()                                      ❷
      .uri(BOOKS_ROOT_API + isbn)                 ❸
      .retrieve()                                 ❹
      .bodyToMono(Book.class);                    ❺
  }
}

❶ 如前所述配置的 WebClient 实例

❷ 请求应使用 GET 方法。

❸ 请求的目标 URI 是 /books/{isbn}。

❹ 发送请求并检索响应

❺ 将检索到的对象作为 Mono 返回

WebClient 是一个响应式 HTTP 客户端。你已经看到了它如何作为响应式发布者返回数据。特别是,调用 Catalog Service 获取特定书籍详情的结果是一个 Mono 对象。让我们看看你如何在 OrderService 中实现的订单处理逻辑中包含它。

OrderService 类中的 submitOrder() 方法目前一直在拒绝订单。但这种情况不会持续太久。你现在可以自动装配一个 BookClient 实例,并使用其底层的 WebClient 来启动一个响应式流以处理书籍信息并创建订单。map() 操作符允许你将 Book 映射到一个已接受的 Order。如果 BookClient 返回空结果,你可以使用 defaultIfEmpty() 操作符定义一个被拒绝的 Order。最后,通过调用 OrderRepository 保存订单(无论是已接受还是被拒绝)来结束流。

列表 8.22 在下单时调用 BookClient 获取书籍信息

package com.polarbookshop.orderservice.order.domain;

import com.polarbookshop.orderservice.book.Book; 
import com.polarbookshop.orderservice.book.BookClient; 
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.stereotype.Service;

@Service
public class OrderService {
  private final BookClient bookClient;
  private final OrderRepository orderRepository;

  public OrderService(
   BookClient bookClient, OrderRepository orderRepository
  ) {
    this.bookClient = bookClient; 
    this.orderRepository = orderRepository;
  }

  ...

  public Mono<Order> submitOrder(String isbn, int quantity) {
    return bookClient.getBookByIsbn(isbn)                                ❶
      .map(book -> buildAcceptedOrder(book, quantity))                   ❷
      .defaultIfEmpty(                                                   ❸
        buildRejectedOrder(isbn, quantity) 
      ) 
      .flatMap(orderRepository::save);                                   ❹
  }

  public static Order buildAcceptedOrder(Book book, int quantity) { 
    return Order.of(book.isbn(), book.title() + " - " + book.author(), 
      book.price(), quantity, OrderStatus.ACCEPTED);                     ❺
  } 

  public static Order buildRejectedOrder(String bookIsbn, int quantity) {
    return Order.of(bookIsbn, null, null, quantity, OrderStatus.REJECTED);
  }
}

❶ 调用 Catalog Service 检查书籍的可用性

❷ 如果书籍可用,它将接受订单。

❸ 如果书籍不可用,它将拒绝订单。

❹ 保存订单(无论是已接受还是被拒绝)

❺ 当订单被接受时,我们指定 ISBN、书名(标题 + 作者)、数量和状态。Spring Data 负责添加标识符、版本和审计元数据。

让我们试试看。首先,确保 PostgreSQL 容器正在运行,通过从您保存 Docker Compose 配置的文件夹中执行以下命令:

$ docker-compose up -d polar-postgres

然后构建并运行目录服务和订单服务(./gradlew bootRun)。

警告:如果您使用的是苹果硅电脑,订单服务的应用程序日志可能包含一些与 Netty 中 DNS 解析相关的警告。在这种情况下,应用程序仍然可以正常工作。如果您遇到问题,可以将以下附加依赖项添加到订单服务项目中作为 runtimeOnly 来修复问题:io.netty:netty-resolver-dns-native-macos:4.1.79.Final:osx-aarch_64。

最后,在启动时发送一个目录服务中创建的图书的订单。如果图书存在,订单应该被接受:

$ http POST :9002/orders isbn=1234567891 quantity=3

HTTP/1.1 200 OK
{
  "bookIsbn": "1234567891",
  "bookName": "Northern Lights - Lyra Silverstar",
  "bookPrice": 9.9,
  "createdDate": "2022-06-06T09:59:32.961420Z",
  "id": 2,
  "lastModifiedDate": "2022-06-06T09:59:32.961420Z",
  "quantity": 3,
  "status": "ACCEPTED",
  "version": 1
}

当您完成验证交互后,使用 Ctrl-C 停止应用程序,并使用 docker-compose down 停止容器。

这就完成了我们订单创建逻辑的实现。如果图书在目录中存在,订单将被接受。如果返回空结果,则被拒绝。但是,如果目录服务回复时间过长怎么办?如果它暂时不可用且无法处理任何新请求怎么办?如果它回复错误怎么办?下一节将回答并处理所有这些问题。

8.4 带有 Reactive Spring 的弹性应用程序

弹性是指即使在发生故障的情况下,也能保持系统可用并交付其服务。由于故障总会发生,而且无法完全防止,因此设计容错应用程序至关重要。目标是让用户在没有任何故障的情况下保持系统可用。在最坏的情况下,系统可能具有降级功能(优雅降级),但它仍然应该是可用的。

实现弹性(或容错)的关键点是,在故障修复之前将故障组件隔离。通过这样做,你将防止迈克尔·T·奈格德所说的裂纹传播。想想 Polar Bookshop。如果目录服务进入故障状态并变得无响应,你不想让订单服务也受到影响。应用服务之间的集成点应该仔细保护,并使其能够抵御影响另一方的故障。

建立弹性应用程序有几种模式。在 Java 生态系统中,Netflix 开发的 Hystrix 是实现这些模式的一个流行的库,但截至 2018 年,它已进入维护模式,并且不会再进一步开发。Resilience4J 获得了很大的流行,填补了 Hystrix 留下的空白。Project Reactor,Reactive Spring 堆栈的基础,也提供了一些有用的弹性功能。

在本节中,您将使用 Reactive Spring 配置超时、重试和回退来使订单服务与目录服务的集成点更加健壮。在下一章中,您将了解更多关于使用 Resilience4J 和 Spring Cloud Circuit Breaker 构建弹性应用程序的内容。

8.4.1 超时

每当您的应用程序调用远程服务时,您不知道是否会收到响应以及何时收到。超时(也称为时间限制器)是在合理时间内未收到响应时保持应用程序响应性的简单而有效的工具。

设置超时的主要有两个原因:

  • 如果您不对客户端等待的时间进行限制,您的计算资源可能会被阻塞太长时间(对于命令式应用程序)。在最坏的情况下,您的应用程序将完全无响应,因为所有可用的线程都被阻塞,等待远程服务的响应,并且没有线程可以处理新的请求。

  • 如果您无法满足服务水平协议(SLA),就没有理由继续等待答案。最好是失败请求。

这里有一些超时的示例:

  • 连接超时—这是与远程资源建立通信通道的时间限制。之前您已配置了 server.netty.connection-timeout 属性以限制 Netty 等待 TCP 连接建立的时间。

  • 连接池超时—这是客户端从连接池获取连接的时间限制。在第五章中,您通过 spring.datasource.hikari.connection-timeout 属性配置了 Hikari 连接池的超时。

  • 读取超时—这是在建立初始连接后从远程资源读取的时间限制。在以下章节中,您将为 BookClient 类对目录服务进行的调用定义读取超时。

在本节中,您将为 BookClient 定义一个超时,如果它到期,订单服务应用程序将抛出异常。您还可以指定故障转移而不是将异常抛给用户。图 8.7 详细说明了定义超时和故障转移时请求/响应交互的工作方式。

08-07

图 8.7 当在时间限制内从远程服务收到响应时,请求成功。如果超时到期且没有收到响应,则执行任何回退行为。否则,抛出异常。

定义 WebClient 的超时

Project Reactor 提供了一个 timeout()操作符,您可以使用它来定义完成操作的时间限制。您可以将它与 WebClient 调用的结果链接起来,以继续反应流。按照以下方式更新 BookClient 类中的 getBookByIsbn()方法,以定义 3 秒的超时。

列表 8.23 定义 HTTP 交互的超时

...
public Mono<Book> getBookByIsbn(String isbn) {
  return webClient
    .get()
    .uri(BOOKS_ROOT_API + isbn)
    .retrieve()
    .bodyToMono(Book.class)
    .timeout(Duration.ofSeconds(3));     ❶
}
...

❶ 为 GET 请求设置 3 秒超时

当超时到期时,你有机会提供回退行为。考虑到订单服务在书籍可用性未验证的情况下无法接受订单,你可能考虑返回一个空的结果,以便拒绝订单。你可以在 BookClient 类中定义一个响应式空结果,使用 Mono.empty()更新 getBookByIsbn()方法,如下所示。

列表 8.24 定义 HTTP 交互的超时和回退

...
public Mono<Book> getBookByIsbn(String isbn) {
  return webClient
    .get()
    .uri(BOOKS_ROOT_API + isbn)
    .retrieve()
    .bodyToMono(Book.class)
    .timeout(Duration.ofSeconds(3), Mono.empty())    ❶
}
...

❶ 回退返回一个空的 Mono 对象。

注意:在实际的生产场景中,你可能希望通过向 ClientProperties 添加一个新字段来外部化超时配置。这样,你可以根据环境更改其值,而无需重新构建应用程序。同样重要的是要监控任何超时,并在必要时调整其值。

理解如何有效地使用超时

超时设置可以提高应用程序的弹性,并遵循快速失败的原则。但为超时设置一个合适的值可能很棘手。你应该考虑你的整个系统架构。在先前的例子中,你定义了一个 3 秒的超时。这意味着响应应该在规定的时间内从目录服务传送到订单服务。否则,将发生故障或回退。目录服务反过来会向 PostgreSQL 数据库发送请求以获取特定书籍的数据,并等待响应。连接超时保护了这种交互。你应该仔细设计系统所有集成点的时间限制策略,以满足软件的 SLA 并保证良好的用户体验。

如果目录服务可用,但响应在规定时间内无法到达订单服务,请求很可能会仍然由目录服务处理。这是配置超时时需要考虑的关键点。对于读取或查询操作来说,这并不重要,因为它们是无状态的。对于写入或命令操作,你希望在超时到期时确保适当的处理,包括向用户提供关于操作结果的正确状态。

当目录服务过载时,它可能需要几秒钟才能从连接池中获取 JDBC 连接,从数据库中获取数据,并将响应发送回订单服务。在这种情况下,你可以考虑重试请求,而不是回退到默认行为或抛出异常。

8.4.2 重试

当下游服务在特定时间内没有响应或回复与它暂时无法处理请求相关的服务器错误时,你可以配置客户端尝试再次发送请求。当服务没有正确响应时,很可能是因为它遇到了一些问题,并且它不太可能立即恢复。连续启动一系列重试尝试可能会使系统更加不稳定。你不希望对你的应用程序发起 DoS 攻击!

一种更好的方法是使用指数退避策略,通过增加延迟来执行每个重试尝试。通过在尝试之间等待越来越长的时间,您更有可能给后端服务时间来恢复并再次变得响应。可以配置计算延迟的策略。

在本节中,您将配置 BookClient 的重试。图 8.8 详细说明了配置指数退避重试时请求/响应交互的工作方式。例如,该图显示了一个场景,其中每次重试尝试的延迟是尝试次数乘以 100 毫秒(初始退避值)。

08-08

图 8.8 当目录服务未成功响应时,订单服务将尝试最多三次,每次尝试的延迟都会增加。

定义 WebClient 的重试

Project Reactor 提供了一个 retryWhen() 操作符,用于在操作失败时重试。将此操作符应用到反应流中的位置很重要。

  • 将 retryWhen() 操作符放在 timeout() 之后意味着超时应用于每个重试尝试。

  • 将 retryWhen() 操作符放在 timeout() 之前意味着超时应用于整体操作(即,整个初始请求和重试序列必须在给定的时间限制内发生)。

在 BookClient 中,我们希望超时应用于每个重试尝试,因此我们将使用第一个选项。首先应用时间限制器。如果超时到期,retryWhen() 操作符将启动并再次尝试请求。

更新 BookClient 类中的 getBookByIsbn() 方法以配置重试策略。您可以定义尝试次数和第一次退避的最小持续时间。每次重试的延迟是当前尝试次数乘以最小退避周期。可以使用抖动因子为每次退避的指数添加随机性。默认情况下,使用的抖动值不超过计算延迟的 50%。当有多个 Order Service 实例运行时,抖动因子确保副本不会同时重试请求。

列表 8.25 定义 HTTP 调用的指数退避重试

public Mono<Book> getBookByIsbn(String isbn) {
  return webClient
    .get()
    .uri(BOOKS_ROOT_API + isbn)
    .retrieve()
    .bodyToMono(Book.class)
    .timeout(Duration.ofSeconds(3), Mono.empty())
    .retryWhen(                                    ❶
      Retry.backoff(3, Duration.ofMillis(100)) 
    ); 
}

❶ 指数退避被用作重试策略。允许进行三次尝试,初始退避时间为 100 毫秒。

理解如何有效地使用重试

重试增加了在远程服务暂时过载或无响应时收到响应的机会。请明智地使用它们。在超时的情况下,我强调了处理读取和写入操作的不同需求。当涉及到重试时,这一点尤为重要。

像读取操作这样的幂等请求可以无损害地重试。甚至一些写请求也可以是幂等的。例如,将给定 ISBN 的书籍作者从“S.L. Cooper”更改为“Sheldon Lee Cooper”的请求是幂等的。你可以多次执行它,但结果不会改变。你不应该重试非幂等请求,否则会冒着生成不一致状态的风险。当你订购一本书时,你不希望因为第一次尝试失败(由于响应在网络中丢失而未收到)而被多次收费。

当在涉及用户的流程中配置重试时,请记住在弹性和用户体验之间取得平衡。你不想让用户在后台重试请求时等待太长时间。如果你无法避免这种情况,请确保通知用户并给他们提供关于请求状态的反馈。

当下游服务因过载而暂时不可用或缓慢时,重试是一种有用的模式,但服务很可能会很快恢复。在这种情况下,你应该限制重试次数并使用指数退避来防止给已经过载的服务增加额外负载。另一方面,如果服务因重复错误而失败,例如完全关闭或返回可接受的错误,如 404,则不应重试请求。下一节将向您展示如何定义在特定错误发生时的回退。

8.4.3 回退和错误处理

如果系统在出现故障时仍然能够提供服务而用户没有注意到,那么这个系统是具有弹性的。有时这是不可能的,所以你至少可以确保服务级别以优雅的方式降低。指定回退行为可以帮助你将故障限制在一个小范围内,同时防止系统的其余部分出现异常行为或进入错误状态。

在之前关于超时讨论中,你已经提供了一个在时间限制内未收到响应时的回退行为。你希望在总体策略中包含回退,以使系统具有弹性,而不仅仅是针对特定情况,如超时。当发生某些错误或异常时,可以触发回退函数,但它们并不完全相同。

一些错误在你的业务逻辑上下文中是可以接受的,并且具有语义意义。当订单服务调用目录服务以获取关于特定书籍的信息时,可能会返回 404 响应。这是一个可以接受并应通知用户的响应,告知用户由于书籍在目录中不可用,订单无法提交。

在上一节中定义的重试策略并不受限制:只要收到错误响应,包括可接受的 404 响应,它就会重试请求。然而,在这种情况下,你不想重试请求。Project Reactor 提供了一个 onErrorResume()算子来定义特定错误发生时的回退。你可以在 timeout()算子之后和 retryWhen()算子之前将其添加到反应流中,这样如果收到 404 响应(WebClientResponseException.NotFound 异常),重试算子就不会被触发。然后你可以在流的末尾再次使用相同的算子来捕获任何其他异常,并回退到空的 Mono。按照以下方式更新 BookClient 类中的 getBookByIsbn()方法。

列表 8.26 定义 HTTP 调用的异常处理和回退

public Mono<Book> getBookByIsbn(String isbn) {
  return webClient
    .get()
    .uri(BOOKS_ROOT_API + isbn)
    .retrieve()
    .bodyToMono(Book.class)
    .timeout(Duration.ofSeconds(3), Mono.empty())
    .onErrorResume(WebClientResponseException.NotFound.class, 
      exception -> Mono.empty())                                ❶
    .retryWhen(Retry.backoff(3, Duration.ofMillis(100)))
    .onErrorResume(Exception.class, 
      exception -> Mono.empty());                              ❷
}

❶ 当收到 404 响应时返回一个空对象

❷ 如果在 3 次重试尝试之后发生任何错误,捕获异常并返回一个空对象。

注意:在实际场景中,你可能希望根据错误类型返回一些上下文信息,而不是总是返回一个空对象。例如,你可以在订单对象中添加一个原因字段来描述为什么它被拒绝。是因为图书在目录中不可用,还是因为网络问题?在后一种情况下,你可以通知用户,由于暂时无法检查图书的可用性,订单无法处理。更好的选择是将订单保存为挂起状态,排队订单提交请求,稍后再尝试,使用我在第十章中介绍的一种策略。

主要目标是设计一个具有弹性的系统,在最佳情况下,可以在用户没有注意到失败的情况下提供服务。相比之下,在最坏的情况下,它仍然应该工作,但具有优雅的降级。

注意:Spring WebFlux 和 Project Reactor 是 Spring 生态系统中的热门主题。如果你想了解更多关于反应式 Spring 的工作原理,我建议查看 Josh Long 的《Reactive Spring》(reactivespring.io)。在 Manning 目录中,查看 Craig Walls 所著的《Spring in Action》第六版(Manning,2022)的第三部分。

在下一节中,你将编写自动化测试来验证订单服务应用程序的不同方面。

8.5 使用 Spring、Reactor 和 Testcontainers 测试反应式应用程序

当一个应用程序依赖于下游的服务时,你应该测试与该服务的 API 规范之间的交互。在本节中,你将首先尝试使用充当 Catalog Service 的模拟 Web 服务器测试 BookClient 类,以确保客户端的正确性。然后,你将使用@DataR2dbcTest 注解和 Testcontainers 进行切片测试来测试数据持久层,就像你在第五章中使用@DataJdbcTest 所做的那样。最后,你将使用@WebFluxTest 注解编写针对 Web 层的切片测试,它与@WebMvcTest 的工作方式相同,但适用于反应式应用程序。

你已经有了 Spring Boot 测试库和 Testcontainers 的必要依赖项。缺少的是对 com.squareup.okhttp3:mockwebserver 的依赖项,这将提供运行模拟 Web 服务器的实用工具。打开 Order Service 项目的 build.gradle 文件并添加缺少的依赖项。

列表 8.27 为 OkHttp MockWebServer 添加测试依赖

dependencies {
  ...
  testImplementation 'com.squareup.okhttp3:mockwebserver' 
}

让我们从测试 BookClient 类开始。

8.5.1 使用模拟 Web 服务器测试 REST 客户端

OkHttp 项目提供了一个模拟 Web 服务器,你可以用它来测试与下游服务基于 HTTP 的请求/响应交互。BookClient 返回一个 Mono对象,因此你可以使用 Project Reactor 提供的方便工具来测试反应式应用程序。StepVerifier 对象允许你通过流畅的 API 分步骤处理反应式流并编写断言。

首先,让我们设置模拟的 Web 服务器并配置 WebClient 以在新的 BookClientTests 类中使用它。

列表 8.28 使用模拟 Web 服务器准备测试设置

package com.polarbookshop.orderservice.book;

import java.io.IOException;
import okhttp3.mockwebserver.MockWebServer;
import org.junit.jupiter.api.*;
import org.springframework.web.reactive.function.client.WebClient;

class BookClientTests {
  private MockWebServer mockWebServer;
  private BookClient bookClient;

  @BeforeEach
  void setup() throws IOException {
    this.mockWebServer = new MockWebServer();
    this.mockWebServer.start();                        ❶
    var webClient = WebClient.builder()                ❷
      .baseUrl(mockWebServer.url("/").uri().toString())
      .build();
    this.bookClient = new BookClient(webClient);
  }

  @AfterEach
  void clean() throws IOException {
    this.mockWebServer.shutdown();                     ❸
  }
}

❶ 在运行测试用例之前启动模拟服务器

❷ 使用模拟服务器 URL 作为 WebClient 的基本 URL

❸ 在完成测试用例后关闭模拟服务器

接下来,在 BookClientTests 类中,你可以定义一些测试用例来验证客户端在 Order Service 中的功能。

列表 8.29 测试与 Catalog Service 应用程序的交互

package com.polarbookshop.orderservice.book;

...
import okhttp3.mockwebserver.MockResponse;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;

class BookClientTests {
  private MockWebServer mockWebServer;
  private BookClient bookClient;

  ...

  @Test
  void whenBookExistsThenReturnBook() {
    var bookIsbn = "1234567890";

    var mockResponse = new MockResponse()              ❶
      .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
      .setBody("""
        {
          "isbn": %s,
          "title": "Title",
          "author": "Author",
          "price": 9.90,
          "publisher": "Polarsophia"
        }
        """.formatted(bookIsbn));

    mockWebServer.enqueue(mockResponse);               ❷

    Mono<Book> book = bookClient.getBookByIsbn(bookIsbn);

    StepVerifier.create(book)                          ❸
      .expectNextMatches(
        b -> b.isbn().equals(bookIsbn))                ❹
      .verifyComplete();                               ❺
  }
}

❶ 定义模拟服务器返回的响应

❷ 向由模拟服务器处理的队列中添加模拟响应

❸ 使用 BookClient 返回的对象初始化 StepVerifier 对象

❹ 断言返回的 Book 具有请求的 ISBN

❺ 验证反应式流成功完成

让我们运行测试并确保它们成功。打开一个终端窗口,导航到 Order Service 项目的根目录,并运行以下命令:

$ ./gradlew test --tests BookClientTests

注意:当使用模拟时,可能会有这样的情况,即测试结果取决于测试用例执行的顺序,这在同一操作系统上通常是相同的。为了防止不希望的执行依赖,你可以使用@TestMethodOrder(MethodOrderer.Random.class)注解测试类,以确保每次执行都使用伪随机顺序。

在测试 REST 客户端部分之后,你可以继续并验证 Order Service 的数据持久层。

8.5.2 使用 @DataR2dbcTest 和 Testcontainers 测试数据持久性

如您可能从前面的章节中回忆起来,Spring Boot 允许您通过仅加载特定应用程序切片使用的 Spring 组件来运行集成测试。对于 REST API,您将为 WebFlux 切片创建测试。在这里,我将向您展示如何使用 @DataR2dbcTest 注解编写 R2DBC 切片的测试。

这种方法与您在第五章中用于测试目录服务数据层的测试方法相同,但有两大主要区别。首先,您将使用 StepVerifier 工具来反应式地测试 OrderRepository 的行为。其次,您将明确定义一个 PostgreSQL 测试容器实例。

对于目录服务应用程序,我们依赖于测试容器自动配置。在这种情况下,我们将在测试类中定义一个测试容器并将其标记为 @Container。然后,类上的 @Testcontainers 注解将激活测试容器的自动启动和清理。最后,我们将使用 Spring Boot 提供的 @DynamicProperties 注解将测试数据库的凭据和 URL 传递给应用程序。这种定义测试容器和覆盖属性的方法是通用的,可以应用于其他场景。

现在,让我们来看代码。创建一个 OrderRepositoryR2dbcTests 类,并实现自动测试以验证应用程序的数据持久层。

列表 8.30 数据 R2DBC 切片的集成测试

package com.polarbookshop.orderservice.order.domain;

import com.polarbookshop.orderservice.config.DataConfig;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import reactor.test.StepVerifier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;

@DataR2dbcTest                                                     ❶
@Import(DataConfig.class)                                          ❷
@Testcontainers                                                    ❸
class OrderRepositoryR2dbcTests {

  @Container                                                       ❹
  static PostgreSQLContainer<?> postgresql =
    new PostgreSQLContainer<>(DockerImageName.parse("postgres:14.4"));

  @Autowired
  private OrderRepository orderRepository;

  @DynamicPropertySource                                           ❺
  static void postgresqlProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.r2dbc.url", OrderRepositoryR2dbcTests::r2dbcUrl);
    registry.add("spring.r2dbc.username", postgresql::getUsername);
    registry.add("spring.r2dbc.password", postgresql::getPassword);
    registry.add("spring.flyway.url", postgresql::getJdbcUrl);
  }

  private static String r2dbcUrl() {                               ❻
    return String.format("r2dbc:postgresql://%s:%s/%s",
      postgresql.getContainerIpAddress(),
      postgresql.getMappedPort(PostgreSQLContainer.POSTGRESQL_PORT),
      postgresql.getDatabaseName());
  }

  @Test
  void createRejectedOrder() {
    var rejectedOrder = OrderService.buildRejectedOrder("1234567890", 3);
    StepVerifier
      .create(orderRepository.save(rejectedOrder))                 ❼
      .expectNextMatches(                                          ❽
        order -> order.status().equals(OrderStatus.REJECTED))
      .verifyComplete();                                           ❾
  }
}

❶ 识别一个专注于 R2DBC 组件的测试类

❷ 导入启用审计所需的 R2DBC 配置

❸ 激活测试容器的自动启动和清理

❹ 识别用于测试的 PostgreSQL 容器

❺ 覆盖 R2DBC 和 Flyway 配置以指向测试 PostgreSQL 实例

❻ 构建 R2DBC 连接字符串,因为 Testcontainers 不像 JDBC 那样提供内置的连接字符串

❼ 使用 OrderRepository 返回的对象初始化 StepVerifier 对象

❽ 断言返回的订单具有正确状态

❾ 验证反应式流成功完成

由于这些切片测试基于 Testcontainers,请确保您的本地环境中 Docker 引擎正在运行。然后运行测试:

$ ./gradlew test --tests OrderRepositoryR2dbcTests

在下一节中,您将为网络切片编写测试。

8.5.3 使用 @WebFluxTest 测试 REST 控制器

WebFlux 切片可以像第三章中测试 MVC 层一样进行测试,并使用与集成测试相同的 WebTestClient 工具。它是标准 WebClient 对象的增强版本,包含额外的功能以简化测试。

创建一个 OrderControllerWebFluxTests 类,并使用 @WebFluxTest(OrderController.class) 注解来收集 OrderController 的切片测试。正如您在第三章中学到的,您可以使用 @MockBean Spring 注解来模拟 OrderService 类,并让 Spring 将其添加到测试中使用的 Spring 上下文中。这就是使其可注入的原因。

列表 8.31 WebFlux 切片的集成测试

package com.polarbookshop.orderservice.order.web;

import com.polarbookshop.orderservice.order.domain.Order;
import com.polarbookshop.orderservice.order.domain.OrderService;
import com.polarbookshop.orderservice.order.domain.OrderStatus;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.reactive.server.WebTestClient;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;

@WebFluxTest(OrderController.class)                 ❶
class OrderControllerWebFluxTests {

  @Autowired
  private WebTestClient webClient;                  ❷

  @MockBean                                         ❸
  private OrderService orderService;

  @Test
  void whenBookNotAvailableThenRejectOrder() {
    var orderRequest = new OrderRequest("1234567890", 3);
    var expectedOrder = OrderService.buildRejectedOrder(
     orderRequest.isbn(), orderRequest.quantity());
    given(orderService.submitOrder(
     orderRequest.isbn(), orderRequest.quantity())
    ).willReturn(Mono.just(expectedOrder));         ❹

    webClient
      .post()
      .uri("/orders/")
      .bodyValue(orderRequest)
      .exchange()
      .expectStatus().is2xxSuccessful()             ❺
      .expectBody(Order.class).value(actualOrder -> {
        assertThat(actualOrder).isNotNull();
        assertThat(actualOrder.status()).isEqualTo(OrderStatus.REJECTED);
      });
  }
}

❶ 识别一个专注于 Spring WebFlux 组件的测试类,针对 OrderController

❷ 一个具有额外功能以简化 RESTful 服务测试的 WebClient 变体

❸ 将 OrderService 的模拟添加到 Spring 应用程序上下文中

❹ 定义 OrderService 模拟 Bean 的预期行为

❺ 预期订单创建成功

接下来,运行网络层的切片测试以确保它们通过:

$ ./gradlew test --tests OrderControllerWebFluxTests

干得好!你成功构建并测试了一个反应式应用程序,最大化了可伸缩性、弹性和成本效益。在书中的源代码中,你可以找到更多测试示例,包括使用 @SpringBootTest 注解的完整集成测试,以及使用 @JsonTest 对 JSON 层进行切片测试,正如你在第三章中学到的。

Polar Labs

随意应用你在前几章学到的知识,并为部署订单服务应用做好准备。

  1. 将 Spring Cloud Config Client 添加到订单服务中,使其能够从配置服务中获取配置数据。

  2. 配置 Cloud Native Buildpacks 集成,容器化应用程序,并定义部署管道的提交阶段。

  3. 编写部署和服务清单,以便将订单服务部署到 Kubernetes 集群。

  4. 配置 Tilt 以自动化将订单服务部署到使用 minikube 初始化的本地 Kubernetes 集群。

你可以参考书中附带的代码仓库中的 Chapter08/08-end 文件夹来检查最终结果 (github.com/ThomasVitale/cloud-native-spring-in-action)。你可以使用 kubectl apply -f services 从 Chapter08/08-end/polar-deployment/kubernetes/platform/development 文件夹中可用的清单部署支持服务。

下一章将继续我们关于弹性的讨论,并介绍更多模式,如断路器和速率限制器,使用 Spring Cloud Gateway、Spring Cloud Circuit Breaker 和 Resilience4J。

摘要

  • 当你预期高流量和并发,但计算资源较少时,反应式范式可以在牺牲更陡峭的学习曲线的代价下提高应用程序的可伸缩性、弹性和成本效益。

  • 根据你的需求,在非反应式和反应式堆栈之间进行选择。

  • Spring WebFlux 基于 Project Reactor,是 Spring 中反应式堆栈的核心。它支持异步、非阻塞 I/O。

  • 可以通过 @RestController 类或路由函数实现反应式 RESTful 服务。

  • 可以通过 @WebFluxTest 注解测试 Spring WebFlux 部分。

  • Spring Data R2DBC 提供了使用 R2DBC 驱动程序支持反应式数据持久化的功能。该方法是任何 Spring Data 项目相同的:数据库驱动程序、实体和仓库。

  • 可以使用 Flyway 管理数据库模式。

  • 可以使用 @DataR2dbcTest 注解和 Testcontainers 测试反应式应用程序的持久化部分。

  • 如果系统在出现故障时仍能继续提供服务而用户没有察觉到,则该系统是具有弹性的。有时这可能不可能实现,所以您至少要确保服务的优雅降级。

  • WebClient 基于 Project Reactor,并与 Mono 和 Flux 发布者协同工作。

  • 您可以使用 Reactor 操作符来配置超时、重试、回退和错误处理,以使与服务交互更加健壮,能够抵御服务下游或网络中的任何故障。

9 API 网关和断路器

本章涵盖

  • 使用 Spring Cloud Gateway 和反应式 Spring 实现边缘服务

  • 使用 Spring Cloud Circuit Breaker 和 Resilience4J 配置断路器

  • 使用 Spring Cloud Gateway 和 Redis 定义速率限制器

  • 使用 Spring Session Data Redis 管理分布式会话

  • 使用 Kubernetes Ingress 路由应用程序流量

在上一章中,您学习了使用反应式范式构建弹性、可扩展和成本效益高的应用程序的几个方面。在本章中,Spring 反应式堆栈将成为实现 Polar Bookshop 系统的 API 网关的基础。API 网关是分布式架构(如微服务)中的一种常见模式,用于将内部 API 与客户端解耦。在建立系统入口点时,您还可以使用它来处理跨切面关注点,如安全、监控和弹性。

本章将教会您如何使用 Spring Cloud Gateway 构建边缘服务应用程序并实现 API 网关以及一些跨切面关注点。您将通过配置 Spring Cloud Circuit Breaker 中的断路器、使用 Spring Data Redis Reactive 定义速率限制器以及使用重试和超时(正如您在上一章中学到的)来提高系统的弹性。

接下来,我将讨论如何设计无状态应用程序。一些状态需要保存,以便应用程序有用——您已经使用了关系型数据库。本章将教会您如何使用 Spring Session Data Redis,一个 NoSQL 内存数据存储,来存储 Web 会话状态。

最后,您将了解如何通过依赖 Kubernetes Ingress API 来管理运行在 Kubernetes 集群中的应用程序的外部访问。

图 9.1 展示了完成本章后 Polar Bookshop 系统将呈现的样子。

09-01

图 9.1 添加边缘服务和 Redis 后的 Polar Bookshop 系统架构

注意:本章示例的源代码可在 Chapter09/09-begin 和 Chapter09/09-end 文件夹中找到,包含项目的初始和最终状态 (github.com/ThomasVitale/cloud-native-spring-in-action)。

9.1 边缘服务器和 Spring Cloud Gateway

Spring Cloud Gateway 是一个基于 Spring WebFlux 和 Project Reactor 的项目,旨在提供 API 网关以及处理跨切面关注点(如安全、弹性和监控)的中心位置。它是为开发者构建的,非常适合 Spring 架构和异构环境。

API 网关为您的系统提供了一个入口点。在微服务这样的分布式系统中,这是一种方便的方法,可以将客户端与内部服务 API 的任何更改解耦。您可以自由地更改系统如何分解为服务和它们的 API,依靠网关能够从更稳定、客户端友好的公共 API 转换为内部 API。

假设您正在从单体架构迁移到微服务架构的过程中。在这种情况下,API 网关可以用作单体杀手,并可以包裹您的遗留应用程序,直到它们迁移到新的架构,使客户端对整个过程保持透明。对于不同类型的客户端(单页应用程序、移动应用程序、桌面应用程序、物联网设备),API 网关为您提供了根据其需求提供更精心设计的 API 的选项(也称为前端后端模式)。有时网关还可以实现API 组合模式,让您在将结果返回给客户端之前查询和合并来自不同服务的数据(例如,使用新的 Spring for GraphQL 项目)。

根据指定的路由规则,调用从网关转发到下游服务,类似于反向代理。这样,客户端不需要跟踪参与事务的不同服务,简化了客户端的逻辑并减少了其必须进行的调用次数。

由于 API 网关是您系统的入口点,它也可以是一个处理跨领域关注点(如安全、监控和弹性)的绝佳位置。边缘服务器是位于系统边缘的应用程序,实现了诸如 API 网关和跨领域关注点等方面的功能。您可以为调用下游服务时配置断路器以防止级联故障。您可以定义对所有内部服务调用的重试和超时。您可以控制入站流量并实施配额策略,根据某些标准(例如用户的会员级别:基本、高级、专业)限制对系统的使用。您还可以在边缘实施身份验证和授权,并将令牌传递给下游服务(如第十一章和第十二章所示)。

然而,重要的是要记住,边缘服务器会增加系统的复杂性。它是另一个需要在生产环境中构建、部署和管理组件。它还向系统添加了一个新的网络跳数,因此响应时间会增加。这通常是一个微不足道的成本,但您应该记住这一点。由于边缘服务器是系统的入口点,它有可能成为单点故障。作为基本缓解策略,您应该至少部署两个边缘服务器的副本,遵循我们在第四章中讨论的配置服务器的相同方法。

Spring Cloud Gateway 极大地简化了边缘服务的构建,专注于简洁和高效。此外,由于它基于响应式堆栈,它可以高效地扩展以处理系统边缘自然发生的高工作量。

以下部分将教你如何使用 Spring Cloud Gateway 设置边缘服务器。你将了解路由、断言和过滤器,它们是网关的构建块。你还将将上一章中学到的重试和超时模式应用到网关与下游服务之间的交互中。

注意:如果你没有跟随前几章中实现的示例,你可以参考本书附带的仓库,并使用第九章/09-begin 中的项目作为起点 (github.com/ThomasVitale/cloud-native-spring-in-action)。

9.1.1 使用 Spring Cloud Gateway 启动边缘服务器

Polar Bookshop 系统需要一个边缘服务器来路由流量到内部 API 并解决几个横切关注点。你可以从 Spring Initializr (start.spring.io)初始化我们的新 Edge Service 项目,将结果存储在一个新的 edge-service Git 仓库中,并将其推送到 GitHub。初始化的参数如图 9.2 所示。

09-02

图 9.2 初始化 Edge Service 项目的参数

提示:在本章的开始文件夹中,你可以找到一个可以在终端窗口中运行的 curl 命令。它下载一个包含所有启动所需代码的 zip 文件,无需在 Spring Initializr 网站上手动生成。

自动生成的 build.gradle 文件的依赖关系部分看起来像这样:

dependencies {
  implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

这些是主要的依赖项:

  • Spring Cloud Gateway (org.springframework.cloud:spring-cloud-starter-gateway)—提供将请求路由到 API 和横切关注点(如弹性、安全和监控)的实用工具。它建立在 Spring 响应式堆栈之上。

  • Spring Boot Test (org.springframework.boot:spring-boot-starter-test)—提供用于测试应用程序的多个库和实用工具,包括 Spring Test、JUnit、AssertJ 和 Mockito。它自动包含在每一个 Spring Boot 项目中。

在其核心,Spring Cloud Gateway 是一个 Spring Boot 应用程序。它提供了我们在前几章中使用过的所有方便功能,例如自动配置、内嵌服务器、测试实用工具、外部化配置等。它也建立在 Spring 响应式堆栈之上,因此你可以使用你在前一章中关于 Spring WebFlux 和 Reactor 学到的工具和模式。让我们先配置内嵌的 Netty 服务器。

首先,将 Spring Initializr 生成的 application.properties 文件(edge-service/src/main/resources)重命名为 application.yml。然后打开文件,并配置 Netty 服务器,就像你在上一章中学到的那样。

列表 9.1 配置 Netty 服务器和优雅关闭

server:
  port: 9000                           ❶
  netty:
    connection-timeout: 2s             ❷
    idle-timeout: 15s                  ❸
  shutdown: graceful                   ❹

spring:
  application:
    name: edge-service
  lifecycle:
    timeout-per-shutdown-phase: 15s    ❺

❶ 服务器将接受连接的端口

❷ 等待与服务器建立 TCP 连接的时间长度

❸ 如果没有数据传输,则在关闭 TCP 连接之前等待的时间长度

❹ 启用优雅关闭

❺ 定义了 15 秒的宽限期

应用程序已设置,因此您可以继续探索 Spring Cloud Gateway 的功能。

9.1.2 定义路由和谓词

Spring Cloud Gateway 提供了三个主要构建块:

  • 路由—这由一个唯一的 ID、一组用于决定是否遵循路由的谓词、一个允许转发请求的 URI 以及一组在转发请求到下游服务之前或之后应用的过滤器组成。

  • 谓词—这匹配来自 HTTP 请求的任何内容,包括路径、主机、头部、查询参数、cookies 和主体。

  • 过滤器—在将请求转发到下游服务之前或之后修改 HTTP 请求或响应。

假设客户端向 Spring Cloud Gateway 发送请求。如果请求通过其谓词匹配到某个路由,则 Gateway HandlerMapping 会将请求发送到 Gateway WebHandler,后者将运行请求通过一系列过滤器。

有两个过滤器链。一个链包含在请求发送到下游服务之前运行的过滤器。另一个链在发送请求到下游并转发响应之前运行。您将在下一节中了解不同类型的过滤器。图 9.3 显示了 Spring Cloud Gateway 中的路由工作方式。

09-03

图 9.3 显示了请求如何与谓词匹配、过滤,并最终转发到下游服务,该服务在返回给客户端之前通过另一组过滤器发送响应。

在 Polar Bookshop 系统中,我们构建了两个具有 API 的应用程序,这些 API 旨在对外界世界(公共 API)开放:目录服务和订单服务。我们可以使用边缘服务来隐藏它们背后的 API 网关。首先,我们需要定义路由。

一个最小路由必须配置一个唯一的 ID、一个请求应该转发到的 URI 以及至少一个谓词。打开 Edge Service 项目的 application.yml 文件,并配置两个路由到目录服务和订单服务。

列表 9.2 配置下游服务的路由

spring:
  cloud:
    gateway:
      routes:                                              ❶
        - id: catalog-route                                ❷
          uri: ${CATALOG_SERVICE_URL:http://localhost:9001}/books
          predicates:
            - Path=/books/**                               ❸
        - id: order-route
          uri:
➥${ORDER_SERVICE_URL:http://localhost:9002}/orders        ❹
          predicates:
            - Path=/orders/**

❶ 路由定义列表

❷ 路由 ID

❸ 谓词是匹配的路径

❹ URI 值来自环境变量,否则来自默认值。

Catalog Service 和 Order Service 的路由都是基于 Path 谓词进行匹配的。所有以/path 开始的请求都将转发到 Catalog Service。如果路径以/path 开始,则 Order Service 将接收请求。URI 是通过环境变量的值(CATALOG_SERVICE_URL 和 ORDER_SERVICE_URL)计算的。如果它们未定义,则将使用第一个冒号(:)符号后面的默认值。这与我们在上一章中基于自定义属性定义 URL 的方法相比是一种替代方法;我想向您展示两种选项。

该项目内置了许多不同的谓词,您可以在路由配置中使用它们来匹配 HTTP 请求的任何方面,包括 Cookie、Header、Host、Method、Path、Query 和 RemoteAddr。您还可以将它们组合起来形成AND条件。在之前的示例中,我们使用了 Path 谓词。有关 Spring Cloud Gateway 中可用的谓词的详细列表,请参阅官方文档:spring.io/projects/spring-cloud-gateway

使用 Java/Kotlin DSL 定义路由

Spring Cloud Gateway 是一个非常灵活的项目,它允许您以最适合您需求的方式配置路由。在这里,您已经在属性文件(application.yml 或 application.properties)中配置了路由,但 Java 或 Kotlin 中也有可用于程序化配置路由的 DSL。项目的未来版本也将实现从数据源获取路由配置的功能,使用 Spring Data。

您如何使用它取决于您。将路由放在配置属性中可以让你根据环境轻松自定义它们,并在运行时更新它们,而无需重新构建和重新部署应用程序。例如,当使用 Spring Cloud Config Server 时,您将获得这些好处。另一方面,Java 和 Kotlin 的 DSL 允许您定义更复杂的路由。配置属性允许您仅使用AND逻辑运算符组合不同的谓词。DSL 还允许您使用其他逻辑运算符,如ORNOT

让我们验证它是否按预期工作。我们将使用 Docker 来运行下游服务和 PostgreSQL,而我们将在本地的 JVM 上运行 Edge Service 以提高工作效率,因为我们正在积极实现应用程序。

首先,我们需要 Catalog Service 和 Order Service 都启动并运行。从每个项目的根目录中,运行./gradlew bootBuildImage 将它们打包为容器镜像。然后通过 Docker Compose 启动它们。打开一个终端窗口,导航到您的 docker-compose.yml 文件所在的文件夹(polar-deployment/docker),并运行以下命令:

$ docker-compose up -d catalog-service order-service

由于两个应用程序都依赖于 PostgreSQL,Docker Compose 也将运行 PostgreSQL 容器。

当下游服务全部启动并运行时,是时候启动 Edge Service 了。从终端窗口导航到项目的根文件夹(edge-service),并运行以下命令:

$ ./gradlew bootRun

Edge Service 应用程序将在端口 9000 上开始接受请求。对于最终测试,尝试通过 API 网关(即使用端口 9000 而不是 Catalog Service 和 Order Service 监听的各个端口)执行书籍和订单操作。它们应该返回 200 OK 响应:

$ http :9000/books
$ http :9000/orders

结果与直接调用 Catalog Service 和 Order Service 相同,但这次你只需要知道一个主机名和端口号。完成应用程序测试后,使用 Ctrl-C 停止其执行。然后使用 Docker Compose 终止所有容器:

$ docker-compose down

在底层,Edge Service 使用 Netty 的 HTTP 客户端将请求转发到下游服务。正如前一章广泛讨论的那样,每当应用程序调用外部服务时,配置超时以使其能够抵御进程间通信失败是至关重要的。Spring Cloud Gateway 提供了专门的属性来配置 HTTP 客户端超时。

再次打开 Edge Service application.yml 文件,并定义连接超时(与下游服务建立连接的时间限制)和响应超时(接收响应的时间限制)的值。

列表 9.3 配置网关 HTTP 客户端超时

spring:
  cloud:
    gateway:
      httpclient:                   ❶
        connect-timeout: 2000       ❷
        response-timeout: 5s        ❸

❶ HTTP 客户端配置属性

❷ 建立连接的时间限制(以毫秒为单位)

❸ 接收响应的时间限制(持续时间)

默认情况下,Spring Cloud Gateway 使用的 Netty HTTP 客户端配置为 弹性 连接池,以便随着工作负载的增加动态增加并发连接的数量。根据您的系统同时接收的请求数量,您可能希望切换到 固定 连接池,以便您对连接数量有更多的控制。您可以通过 application.yml 文件中的 spring.cloud.gateway.httpclient.pool 属性组在 Spring Cloud Gateway 中配置 Netty 连接池。

列表 9.4 配置网关 HTTP 客户端连接池

spring:
  cloud:
    gateway:
      httpclient:
        connect-timeout: 5000
        response-timeout: 5s
        pool: 
          type: elastic         ❶
          max-idle-time: 15s    ❷
          max-life-time: 60s    ❸

❶ 连接池类型(弹性、固定或禁用)

❷ 通信通道关闭后的空闲时间

❸ 通信通道关闭后的时间

您可以参考官方 Reactor Netty 文档以获取有关连接池如何工作、可用的配置以及根据特定场景使用哪些值的更多详细信息(projectreactor.io/docs)。

在下一节中,我们将开始实现比仅仅转发请求更有趣的事情——我们将探讨 Spring Cloud Gateway 过滤器的强大功能。

9.1.3 通过过滤器处理请求和响应

路由和断言本身使应用程序充当代理,但过滤器才是使 Spring Cloud Gateway 真正强大的原因。

过滤器可以在将传入请求转发到下游应用程序之前运行(前过滤器)。它们可以用于:

  • 操作请求头

  • 应用速率限制和断路器

  • 定义代理请求的重试和超时

  • 使用 OAuth2 和 OpenID Connect 触发身份验证流程

其他过滤器可以在从下游应用程序接收响应后,在将其发送回客户端之前应用于传出响应(后过滤器)。它们可以用于:

  • 设置安全头

  • 操作响应体以移除敏感信息

Spring Cloud Gateway 附带了许多过滤器,你可以使用它们执行不同的操作,包括向请求添加头信息、配置断路器、保存 Web 会话、在失败时重试请求或激活速率限制器。

在上一章中,你学习了如何使用重试模式来提高应用程序的弹性。现在,你将学习如何将其作为默认过滤器应用于通过网关定义的路由的所有 GET 请求。

使用重试过滤器

你可以在位于 src/main/resources 下的 application.yml 文件中定义默认过滤器。Spring Cloud Gateway 提供的一个过滤器是重试过滤器。配置与第八章中我们所做的是类似的。

让我们定义一个最大三次的重试尝试,对于所有 GET 请求,当错误在 5xx 范围(SERVER_ERROR)内时(例如,如果结果是 404 响应,重试请求就没有意义)。我们不希望在 4xx 范围内重试请求。例如,如果结果是 404 响应,重试请求就没有意义。我们还可以列出应尝试重试的异常,例如 IOException 和 TimeoutException。

到目前为止,你知道你不应该连续重试请求。你应该使用退避策略。默认情况下,延迟是通过公式 firstBackoff * (factor ^ n)计算的。如果你将 basedOnPreviousValue 参数设置为 true,公式将是 prevBackoff * factor。

列表 9.5 将重试过滤器应用于所有路由

spring:
  cloud:
    gateway:
      default-filters:                                   ❶
        - name: Retry                                    ❷
          args:  
            retries: 3                                   ❸
            methods: GET                                 ❹
            series: SERVER_ERROR                         ❺
            exceptions: java.io.IOException, 
            ➥ java.util.concurrent.TimeoutException     ❻
            backoff:                                     ❼
              firstBackoff: 50ms 
              maxBackOff: 500ms 
              factor: 2 
              basedOnPreviousValue: false 

❶ 默认过滤器列表

❷ 过滤器的名称

❸ 最大 3 次重试尝试

❹ 仅重试 GET 请求

❺ 仅在 5XX 错误时重试

❻ 仅在抛出给定异常时重试

❼ 使用“firstBackoff * (factor ^ n)”计算延迟的重试

当下游服务暂时不可用时,重试模式很有用。但如果它持续几秒钟以上呢?在那个时刻,我们可以停止转发请求,直到我们确信它已经恢复。继续发送请求对调用者或被调用者都没有好处。在这种情况下,断路器模式就派上用场了。这就是下一节的主题。

9.2 使用 Spring Cloud Circuit Breaker 和 Resilience4J 实现容错

正如你所知,弹性是云原生应用的关键特性之一。实现弹性的原则之一是阻止失败级联并影响其他组件。考虑一个分布式系统,其中应用 X 依赖于应用 Y。如果应用 Y 失败,应用 X 也会失败吗?电路断路器可以阻止一个组件的失败传播到依赖它的其他组件,保护系统的其余部分。这是通过暂时停止与故障组件的通信直到其恢复来实现的。这种模式来源于电气系统,其中电路被物理打开以断开电气连接,避免系统因部分因电流过载而失败时整个房屋被破坏。

在分布式系统的世界中,你可以在组件之间的集成点建立电路断路器。考虑 Edge 服务和目录服务。在典型场景中,电路是关闭的,这意味着两个服务可以通过网络进行交互。对于目录服务返回的每个服务器错误响应,Edge 服务中的电路断路器将记录失败。当失败次数超过某个阈值时,电路断路器跳闸,电路过渡到开启状态。

当电路处于开启状态时,Edge 服务与目录服务之间的通信是不允许的。任何应该转发到目录服务的请求将立即失败。在此状态下,客户端会收到错误信息,或者执行回退逻辑。在允许系统恢复的适当时间后,电路断路器过渡到半开启状态,允许下一个调用目录服务的请求通过。这是一个探索阶段,以检查是否还有联系下游服务的问题。如果调用成功,电路断路器将被重置并过渡到关闭状态。否则,它将回到开启状态。图 9.4 展示了电路断路器如何改变状态。

09-04

图 9.4 电路断路器确保在下游服务超过允许的最大失败次数时具有容错性,通过阻断上游和下游服务之间的任何通信来实现。其逻辑基于三种状态:关闭、开启和半开启。

与重试不同,当电路断路器跳闸时,不再允许对下游服务的调用。与重试类似,电路断路器的行为取决于阈值和超时,并允许你定义一个回退方法进行调用。弹性的目标是即使在面对失败的情况下,也要保持系统对用户可用。在最坏的情况下,例如当电路断路器跳闸时,你应该保证优雅降级。你可以采用不同的策略来定义回退方法。例如,在 GET 请求的情况下,你可能会决定返回默认值或缓存中的最后一个可用值。

Spring Cloud Circuit Breaker 项目为在 Spring 应用程序中定义断路器提供了一个抽象层。您可以根据 Resilience4J(resilience4j.readme.io)选择响应式和非响应式实现。Netflix Hystrix 是微服务架构中流行的选择,但它在 2018 年进入了维护模式。之后,Resilience4J 成为了首选选择,因为它提供了与 Hystrix 相同的功能,并且更多。

Spring Cloud Gateway 与 Spring Cloud Circuit Breaker 原生集成,为您提供了一个 CircuitBreaker 网关过滤器,您可以使用它来保护与所有下游服务的交互。在接下来的章节中,您将配置从 Edge Service 到 Catalog Service 和 Order Service 的路由断路器。

9.2.1 使用 Spring Cloud Circuit Breaker 介绍断路器

要在 Spring Cloud Gateway 中使用 Spring Cloud Circuit Breaker,您需要添加您想要使用的特定实现的依赖项。在这种情况下,我们将使用 Resilience4J 的响应式版本。请继续在 Edge Service 项目的 build.gradle 文件中添加新的依赖项(edge-service)。请记住,在添加新依赖项后,刷新或重新导入 Gradle 依赖项。

列表 9.6 添加 Spring Cloud Circuit Breaker 依赖项

dependencies {
  ...
  implementation 'org.springframework.cloud: 
  ➥ spring-cloud-starter-circuitbreaker-reactor-resilience4j' 
}

Spring Cloud Gateway 中的 CircuitBreaker 过滤器依赖于 Spring Cloud Circuit Breaker 来包装路由。与 Retry 过滤器类似,您可以选择将其应用于特定路由或将其定义为默认过滤器。让我们选择第一种选项。您还可以指定一个可选的回退 URI,以便在断路器处于开启状态时处理请求。在这个例子(application.yml)中,将配置两个路由使用 CircuitBreaker 过滤器,但只有 catalog-route 将具有 fallbackUri 值,这样我就可以向您展示两种场景。

列表 9.7 配置网关路由的断路器

spring:
  cloud:
    gateway:
      routes:
        - id: catalog-route
          uri: ${CATALOG_SERVICE_URL:http://localhost:9001}/books
          predicates:
            - Path=/books/**
          filters:
            - name: CircuitBreaker                            ❶
              args:
                name: catalogCircuitBreaker                   ❷
                fallbackUri: forward:/catalog-fallback        ❸
        - id: order-route
          uri: ${ORDER_SERVICE_URL:http://localhost:9002}/orders
          predicates:
            - Path=/orders/**
          filters:
            - name: CircuitBreaker                            ❹
              args:
                name: orderCircuitBreaker

❶ 过滤器名称

❷ 断路器名称

❸ 当断路器开启时,将请求转发到此 URI

❹ 此断路器未定义回退

下一步是配置断路器。

9.2.2 使用 Resilience4J 配置断路器

在定义了您想要应用 CircuitBreaker 过滤器的路由后,您需要配置断路器本身。在 Spring Boot 中,您通常有两个主要选择。您可以通过 Resilience4J 提供的属性配置断路器,或者通过 Customizer bean。由于我们正在使用 Resilience4J 的响应式版本,具体的配置 bean 类型将是 Customizer

无论哪种方式,您都可以选择在 application.yml 文件中为您的应用程序中使用的每个断路器定义特定的配置(在我们的例子中是 catalogCircuitBreaker 和 orderCircuitBreaker)或声明一些默认值,这些值将应用于所有断路器。

对于当前示例,我们可以定义电路断路器,考虑 20 个调用窗口(slidingWindowSize)。每个新的调用将使窗口移动,丢弃最老的已注册调用。当窗口中的调用至少有 50%产生错误(failureRateThreshold)时,电路断路器将触发,电路将进入开启状态。之后 15 秒(waitDurationInOpenState),电路将被允许过渡到半开启状态,此时允许 5 个调用(permittedNumberOfCallsInHalfOpenState)。如果其中至少有 50%的结果是错误,电路将回到开启状态。否则,电路断路器将触发到关闭状态。

接下来是代码。在 Edge Service 项目(edge-service)中,在 application.yml 文件末尾,为所有 Resilience4J 电路断路器定义一个默认配置。

列表 9.8 配置电路断路器和时间限制器

resilience4j:
  circuitbreaker:
    configs:
      default:                                       ❶
        slidingWindowSize: 20                        ❷
        permittedNumberOfCallsInHalfOpenState: 5     ❸
        failureRateThreshold: 50                     ❹
        waitDurationInOpenState: 15000               ❺
  timelimiter:
    configs:
      default:                                       ❻
        timeoutDuration: 5s                           ❼

❶ 所有电路断路器的默认配置 Bean

❷ 电路关闭时记录调用结果的滑动窗口大小

❸ 电路半开启时的允许调用数

❹ 当失败率高于阈值时,电路变为开启。

❺ 从开启状态到半开启状态前的等待时间(毫秒)

❻ 所有时间限制器的默认配置 Bean

❼ 配置超时(秒)

我们配置了电路断路器和时间限制器,这是在使用 Spring Cloud Circuit Breaker 的 Resilience4J 实现时必需的组件。通过 Resilience4J 配置的超时将优先于我们在上一节中为 Netty HTTP 客户端定义的响应超时(spring.cloud.gateway.httpclient.response-timeout)。

当电路断路器切换到开启状态时,我们希望至少优雅地降低服务级别,并尽可能让用户体验愉快。我将在下一节中展示如何做到这一点。

9.2.3 使用 Spring WebFlux 定义回退 REST API

当我们将 CircuitBreaker 过滤器添加到 catalog-route 时,我们为 fallbackUri 属性定义了一个值,当电路处于开启状态时,将请求转发到/catalog-fallback 端点。由于重试过滤器也应用于该路由,即使对于给定请求的所有重试尝试都失败,回退端点也会被调用。是时候定义该端点了。

正如我在前面的章节中提到的,Spring 支持使用@RestController 类或路由函数来定义 REST 端点。让我们使用声明回退端点的函数式方法。

在 Edge Service 项目的 com.polarbookshop.edgeservice.web 包中创建一个新的 WebEndpoints 类。在 Spring WebFlux 中,功能端点作为 RouterFunction Bean 中的路由定义,使用 RouterFunctions 提供的流畅 API。对于每个路由,你需要定义端点 URL、一个方法和一个处理器。

列表 9.9 当目录服务不可用时回退端点

package com.polarbookshop.edgeservice.web;

import reactor.core.publisher.Mono;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;

@Configuration
public class WebEndpoints {

  @Bean                                                             ❶
  public RouterFunction<ServerResponse> routerFunction() {
    return RouterFunctions.route()                                  ❷
      .GET("/catalog-fallback", request ->                          ❸
        ServerResponse.ok().body(Mono.just(""), String.class))
      .POST("/catalog-fallback", request ->                         ❹
        ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).build())
      .build();                                                     ❺
  }
}

❶ 在一个 bean 中定义功能 REST 端点。

❷ 提供一个流畅的 API 来构建路由

❸ 用于处理 GET 端点的回退响应

❹ 用于处理 POST 端点的回退响应

❺ 构建功能端点

为了简单起见,GET 请求的回退返回一个空字符串,而 POST 请求的回退返回 HTTP 503 错误。在实际场景中,你可能希望根据上下文采用不同的回退策略,包括抛出自定义异常由客户端处理或返回原始请求保存的最后一个值。

到目前为止,我们已经使用了重试、超时、断路器和故障转移(回退)。在下一节中,我将扩展我们如何一起使用所有这些弹性模式。

9.2.4 结合断路器、重试和时限器

当你结合多个弹性模式时,它们应用的顺序是基本的。Spring Cloud Gateway 负责首先应用 TimeLimiter(或 HTTP 客户端的超时),然后是 CircuitBreaker 过滤器,最后是 Retry。图 9.5 展示了这些模式如何一起工作以增加应用程序的弹性。

09-05

图 9.5 当实现多个弹性模式时,它们按照特定的顺序应用。

你可以使用 Apache Benchmark 等工具(httpd.apache.org/docs/2.4/programs/ab.html)来验证这些模式应用于边缘服务的结果。如果你使用的是 macOS 或 Linux,你可能已经安装了此工具。否则,你可以按照官方网站上的说明进行安装。

确保目录服务和订单服务都没有运行,这样你就可以在故障场景中测试断路器。然后启用 Resilience4J 的调试日志,以便你可以跟踪断路器的状态转换。在你的边缘服务项目中的应用.yml 文件末尾,添加以下配置。

列表 9.10 启用 Resilience4J 的调试日志

logging:
  level:
    io.github.resilience4j: DEBUG

接下来,构建并运行边缘服务(./gradlew bootRun)。由于没有下游服务正在运行(如果有的话,你应该停止它们),从边缘服务发送到它们的所有请求都将导致错误。让我们看看如果我们向 /orders 端点运行 21 个连续的 POST 请求(-n 21 -c 1 -m POST)会发生什么。记住,POST 请求没有重试配置,order-route 没有回退,所以结果将仅受超时和断路器的影响:

$ ab -n 21 -c 1 -m POST http://localhost:9000/orders

从 ab 输出中,你可以看到所有请求都返回了错误:

Complete requests: 21
Non-2xx responses: 21

当一个 20 秒时间窗口内的至少 50%的调用失败时,断路器被配置为跳转到开启状态。由于您刚刚启动了应用程序,电路将在 20 次请求后过渡到开启状态。在应用程序日志中,您可以分析请求是如何被处理的。所有请求都失败了,因此断路器为每个请求注册了一个 ERROR 事件:

Event ERROR published: CircuitBreaker 'orderCircuitBreaker'
  recorded an error.

在第 20 次请求时,记录了一个 FAILURE_RATE_EXCEEDED 事件,因为它超过了失败阈值。这将导致一个 STATE_TRANSITION 事件,打开电路:

Event ERROR published: CircuitBreaker 'orderCircuitBreaker'
  recorded an error.
Event FAILURE_RATE_EXCEEDED published: CircuitBreaker 'orderCircuitBreaker'
  exceeded failure rate threshold.
Event STATE_TRANSITION published: CircuitBreaker 'orderCircuitBreaker'
  changed state from CLOSED to OPEN

第 21 次请求甚至不会尝试联系订单服务:断路器处于开启状态,因此无法通过。注册了一个 NOT_PERMITTED 事件来表示请求失败的原因:

Event NOT_PERMITTED published: CircuitBreaker 'orderCircuitBreaker'
  recorded a call which was not permitted.

注意:在生产环境中监控断路器的状态是一项关键任务。在第十三章中,我将向您展示如何将此信息导出为 Prometheus 指标,您可以在 Grafana 仪表板上可视化这些指标,而不是检查日志。同时,为了更直观的解释,您可以自由观看我在 2022 年 Spring I/O 上关于断路器的“Spring Cloud Gateway: Resilience, Security, and Observability”会议(mng.bz/z55A)。

现在让我们看看当我们调用已配置重试和回退的 GET 端点时会发生什么。在继续之前,重新运行应用程序,以便您可以从清晰的断路器状态开始(./gradlew bootRun)。然后运行以下命令:

$ ab -n 21 -c 1 -m GET http://localhost:9000/books

如果您检查应用程序日志,您将看到断路器的行为与之前完全一样:20 个允许的请求(关闭电路),然后是一个不允许的请求(开启电路)。然而,上一个命令的结果显示有 21 个请求完成且没有错误:

Complete requests: 21
Failed requests: 0

这次,所有请求都已转发到回退端点,因此客户端没有遇到任何错误。

我们配置了重试过滤器,使其在发生 IOException 或 TimeoutException 时触发。在这种情况下,由于下游服务没有运行,抛出的异常是 ConnectException 类型,因此请求方便地没有被重试,这让我能够向您展示在无需重试的情况下断路器和回退的组合行为。

到目前为止,我们已经探讨了使边缘服务和下游应用程序之间的交互更具弹性的模式。那么系统的入口点呢?下一节将介绍速率限制器,它将通过边缘服务应用程序控制进入系统的请求流。在继续之前,使用 Ctrl-C 停止应用程序的执行。

9.3 使用 Spring Cloud Gateway 和 Redis 进行请求速率限制

速率限制是一种用于控制发送到或从应用程序接收的流量速率的模式,有助于使您的系统更具弹性和健壮性。在 HTTP 交互的上下文中,您可以使用这种模式分别通过客户端和服务器端速率限制器来控制出站或入站网络流量。

客户端速率限制器 用于限制在给定时间段内发送到下游服务的请求数量。当第三方组织,如云提供商管理和提供下游服务时,这是一个有用的模式。您希望避免因发送超出订阅允许的请求数量而产生额外费用。在按使用付费的服务中,这有助于防止意外费用。

如果下游服务属于您的系统,您可能使用速率限制器来避免给自己造成 DoS 问题。然而,在这种情况下,bulkhead 模式(或 并发请求限制器)将更适合,它会对允许的并发请求数量设置限制,并将阻塞的请求排队。更好的是自适应 bulkhead,其并发限制会由算法动态更新,以更好地适应云基础设施的弹性。

服务器端速率限制器 用于限制在给定时间段内接收到的上游服务(或客户端)的请求数量。当在 API 网关中实现时,这种模式便于保护整个系统免受过载或 DoS 攻击。当用户数量增加时,系统应以弹性的方式扩展,确保所有用户都能获得可接受的服务质量。预计用户流量会突然增加,通常最初通过向基础设施或更多应用程序实例添加更多资源来应对。然而,随着时间的推移,它们可能成为问题,甚至导致服务中断。服务器端速率限制器有助于解决这个问题。

当用户在特定时间窗口内超出允许的请求数量时,所有额外的请求都会以 HTTP 429 - Too Many Requests(请求过多)的状态被拒绝。限制是根据给定的策略应用的。例如,您可以按会话、按 IP 地址、按用户或按租户限制请求。整体目标是在逆境中保持系统对所有用户的可用性。这就是弹性的定义。这种模式也便于根据用户的订阅级别提供服务。例如,您可能为基本用户、高级用户和企业用户定义不同的速率限制。

Resilience4J 支持客户端速率限制器和 bulkhead 模式,适用于反应性和非反应性应用程序。Spring Cloud Gateway 支持服务器端速率限制器模式,本节将向您展示如何使用 Spring Cloud Gateway 和 Spring Data Redis Reactive 通过边缘服务使用它。让我们从设置 Redis 容器开始。

9.3.1 将 Redis 作为容器运行

假设你想限制对 API 的访问,以便每个用户每秒只能执行 10 个请求。实现这样的要求需要一个存储机制来跟踪每个用户每秒执行的请求数量。当达到限制时,应拒绝后续请求。当秒数结束时,每个用户可以在下一个秒内再执行 10 个请求。速率限制算法使用的数据量小且临时,因此你可能想将其保存在应用程序本身的内存中。

然而,这样做会使应用程序具有状态性并导致错误,因为每个应用程序实例都会根据部分数据集来限制请求。这意味着让用户在每个实例上每秒执行 10 个请求,而不是整体上,因为每个实例只会跟踪自己的传入请求。解决方案是使用一个专门的数据服务来存储速率限制状态,并使其对所有应用程序副本可用。这就是 Redis 的作用。

Redis (redis.com) 是一个内存存储,通常用作缓存、消息代理或数据库。在 Edge Service 中,我们将使用它作为 Spring Cloud Gateway 提供的请求速率限制实现背后的数据服务。Spring Data Redis Reactive 项目提供了 Spring Boot 应用程序与 Redis 之间的集成。

首先,让我们定义一个 Redis 容器。打开你在 polar-deployment 仓库中创建的 docker-compose.yml 文件。(如果你没有跟随示例进行操作,你可以使用书中附带的源代码中的 Chapter09/09-begin/polar-deployment/docker/docker-compose.yml 作为起点。)然后,使用 Redis 官方镜像添加一个新的服务定义,并通过端口 6379 公开它。

列表 9.11 定义 Redis 容器

version: "3.8"
services:
  ...
  polar-redis: 
    image: "redis:7.0"             ❶
    container_name: "polar-redis" 
    ports: 
      - 6379:6379                  ❷

❶ 使用 Redis 7.0

❷ 通过端口 6379 公开 Redis

接下来,打开一个终端窗口,导航到你的 docker-compose.yml 文件所在的文件夹,并运行以下命令以启动一个 Redis 容器:

$ docker-compose up -d polar-redis

在下一节中,你将配置 Redis 与 Edge Service 的集成。

9.3.2 集成 Spring 与 Redis

Spring Data 项目有支持多个数据库选项的模块。在前几章中,我们使用了 Spring Data JDBC 和 Spring Data R2DBC 来使用关系数据库。现在我们将使用 Spring Data Redis,它提供了对这个内存型非关系数据存储的支持。它支持命令式和响应式应用程序。

首先,我们需要在 Edge Service 项目(edge-service)的 build.gradle 文件中添加一个新的依赖项 Spring Data Redis Reactive。记住,在添加新依赖项后,要刷新或重新导入 Gradle 依赖项。

列表 9.12 添加 Spring Data Redis Reactive 依赖项

dependencies {
  ...
  implementation 
  ➥ 'org.springframework.boot:spring-boot-starter-data-redis-reactive' 
}

然后,在 application.yml 文件中,通过 Spring Boot 提供的属性配置 Redis 集成。除了 spring.redis.host 和 spring.redis.port 用于定义如何连接 Redis 之外,您还可以分别使用 spring.redis.connect-timeout 和 spring.redis.timeout 指定连接和读取超时。

列表 9.13 配置 Redis 集成

spring:
  redis: 
    connect-timeout: 2s     ❶
    host: localhost         ❷
    port: 6379              ❸
    timeout: 1s             ❹

❶ 建立连接的时间限制

❷ 默认 Redis 主机

❸ 默认 Redis 端口

❹ 接收响应的时间限制

在下一节中,您将了解如何使用 Redis 来支持 RequestRateLimiter 网关过滤器的服务器端速率限制。

9.3.3 配置请求速率限制器

根据需求,您可以配置 RequestRateLimiter 过滤器针对特定路由或作为默认过滤器。在这种情况下,我们将将其配置为默认过滤器,以便应用于所有路由,包括当前和未来的路由。

RequestRateLimiter 在 Redis 上的实现基于 令牌桶算法。每个用户都会分配一个桶,其中以特定速率(补充速率)在一段时间内滴入令牌。每个桶都有一个最大容量(突发容量)。当用户发起请求时,会从其桶中移除一个令牌。当没有更多令牌时,请求将不被允许,用户将不得不等待直到更多令牌滴入其桶中。

注意:如果您想了解更多关于令牌桶算法的信息,我建议阅读 Paul Tarjan 的“使用速率限制器扩展您的 API”文章,该文章介绍了他们如何在 Stripe 中使用它来实现速率限制器(stripe.com/blog/rate-limiters)。

在此示例中,让我们配置算法,使得每个请求消耗 1 个令牌(redis-rate-limiter.requestedTokens)。令牌会按照配置的补充速率(redis-rate-limiter.replenishRate)滴入桶中,我们将将其设置为每秒 10 个令牌。有时可能会出现峰值,导致请求数量比平常多。您可以通过为桶定义更大的容量(redis-rate-limiter.burstCapacity)来允许暂时的突发,例如 20。这意味着当出现峰值时,每秒最多允许 20 个请求。由于补充速率低于突发容量,后续的突发是不允许的。如果连续出现两个峰值,只有第一个会成功,而第二个将导致一些请求被丢弃,并返回 HTTP 429 - 请求过多的响应。以下是在 application.yml 文件中的配置示例。

列表 9.14 配置请求速率限制器作为网关过滤器

spring:
  cloud:
    gateway:
      default-filters:
        name: RequestRateLimiter 
          args: 
            redis-rate-limiter: 
              replenishRate: 10     ❶
              burstCapacity: 20     ❷
              requestedTokens: 1    ❸

❶ 每秒桶中滴入的令牌数量

❷ 允许最多 20 个请求的突发

❸ 请求消耗的令牌数量

在为请求速率限制器选择好的数字时没有一般规则可遵循。你应该从应用程序需求开始,采用试错法:分析你的生产流量,调整配置,然后重复此过程,直到你达到一个设置,既能保持系统可用,又不会严重影响用户体验。即使在那之后,你也应该继续监控速率限制器的状态,因为未来可能会有变化。

Spring Cloud Gateway 依赖于 Redis 来跟踪每秒发生的请求数量。默认情况下,每个用户都会分配一个桶。然而,我们还没有介绍认证机制,所以我们将使用单个桶处理所有请求,直到我们在第十一章和第十二章解决安全担忧。

注意:如果 Redis 变得不可用会发生什么?Spring Cloud Gateway 已经考虑到容错性,所以它将保持其服务水平,但速率限制器将禁用,直到 Redis 再次运行起来。

请求速率限制器过滤器依赖于 KeyResolver bean 来确定每个请求使用哪个桶。默认情况下,它使用 Spring Security 中当前认证的用户。在我们为 Edge Service 添加安全功能之前,我们将定义一个自定义的 KeyResolver bean,并使其返回一个常量值(例如,匿名),这样所有请求都将映射到同一个桶。

在你的 Edge Service 项目中,在新的 com.polarbookshop.edgeservice.config 包中创建一个 RateLimiterConfig 类,并声明一个 KeyResolver bean,实现一个返回常量键的策略。

列表 9.15 定义一个策略以解决每个请求使用的桶

package com.polarbookshop.edgeservice.config;

import reactor.core.publisher.Mono;
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RateLimiterConfig {

   @Bean
   public KeyResolver keyResolver() {
      return exchange -> Mono.just("anonymous");     ❶
   }
}

❶ 使用常量键对请求进行速率限制。

Spring Cloud Gateway 配置为将有关速率限制的详细信息附加到每个 HTTP 响应中,我们可以使用它来验证其行为。重新构建并运行 Edge Service(./gradlew bootRun),然后尝试调用其中一个端点。

$ http :9000/books

响应体取决于目录服务是否正在运行,但在本例中这不重要。需要注意的是响应的 HTTP 头。它们显示了速率限制器的配置以及时间窗口(1 秒)内允许的剩余请求数量:

HTTP/1.1 200 OK
Content-Type: application/json
X-RateLimit-Burst-Capacity: 20
X-RateLimit-Remaining: 19
X-RateLimit-Replenish-Rate: 10
X-RateLimit-Requested-Tokens: 1

在某些情况下,你可能不希望将此信息暴露给客户端,因为这可能会帮助恶意行为者针对你的系统进行攻击。或者你可能需要不同的头名称。无论如何,你可以使用 spring.cloud.gateway.redis-rate-limiter 属性组来配置这种行为。完成应用程序测试后,使用 Ctrl-C 停止它。

注意:当速率限制器模式与其他模式(如时间限制器、断路器和重试)结合使用时,速率限制器首先应用。如果用户的请求超过速率限制,它将立即被拒绝。

Redis 是一个高效的数据存储,确保快速数据访问、高可用性和弹性。在本节中,我们使用它来提供速率限制器的存储,下一节将向你展示如何在另一个常见场景中使用它:会话管理。

9.4 使用 Redis 进行分布式会话管理

在前面的章节中,我经常强调云原生应用应该是无状态的。我们对其进行扩展和缩减,如果它们不是无状态的,每次实例关闭时我们都会丢失状态。某些状态需要保存,否则应用程序可能毫无用处。例如,目录服务和订单服务是无状态的,但它们依赖于一个有状态的服务(PostgreSQL 数据库)来永久存储关于书籍和订单的数据。即使应用程序关闭,数据也会存活并可供所有应用程序实例访问。

边缘服务不处理任何需要存储的业务实体,但它仍然需要一个有状态的服务(Redis)来存储与请求速率限制器过滤器相关的状态。当边缘服务被复制时,跟踪在超过阈值之前剩余多少请求是很重要的。使用 Redis,速率限制功能可以保证一致性和安全性。

此外,在第十一章中,你将扩展边缘服务以添加身份验证和授权。由于它是 Polar 书店系统的入口点,因此在那里进行用户身份验证是有意义的。由于与速率限制器信息相同的原因,必须将已验证会话的数据保存在应用程序之外。如果不是这样,用户可能每次请求击中不同的边缘服务实例时都需要进行身份验证。

通用思路是保持应用程序无状态,并使用数据服务来存储状态。正如你在第五章中学到的,数据服务需要保证高可用性、复制和持久性。在你的本地环境中,你可以忽略这一点,但在生产环境中,你将依赖于云提供商提供的数据服务,无论是 PostgreSQL 还是 Redis。

以下部分将介绍如何使用 Spring Session Data Redis 来建立分布式会话管理。

9.4.1 使用 Spring Session Data Redis 处理会话

Spring 通过 Spring Session 项目提供会话管理功能。默认情况下,会话数据存储在内存中,但这在云原生应用程序中是不可行的。你希望将其保存在外部服务中,以便数据在应用程序关闭后仍然存在。使用分布式会话存储的另一个基本原因是,通常你有一个给定应用程序的多个实例。你希望它们能够访问相同的会话数据,以向用户提供无缝体验。

Redis 是会话管理的一个流行选项,并且它由 Spring Session Data Redis 支持。此外,您已经为速率限制器设置了它。您可以通过最小配置将其添加到 Edge Service 中。

首先,您需要在 Edge Service 项目的 build.gradle 文件中添加对 Spring Session Data Redis 的新依赖项。您还可以添加 Testcontainers 库,以便在编写集成测试时使用轻量级的 Redis 容器。请记住,在添加新依赖项后刷新并重新导入 Gradle 依赖项。

列表 9.16 为 Spring Session 和 Testcontainers 添加依赖

ext {
  ...
  set('testcontainersVersion', "1.17.3") 
}

dependencies {
  ...
  implementation 'org.springframework.session:spring-session-data-redis' 
  testImplementation 'org.testcontainers:junit-jupiter' 
}

dependencyManagement {
  imports {
    ...
    mavenBom 
    ➥ "org.testcontainers:testcontainers-bom:${testcontainersVersion}" 
  }
}

接下来,您需要指导 Spring Boot 使用 Redis 进行会话管理(spring.session.store-type)并定义一个唯一的命名空间来前缀所有来自 Edge Service 的会话数据(spring.session.redis.namespace)。您还可以定义会话的超时时间(spring.session.timeout)。如果您没有指定超时时间,默认为 30 分钟。

在 application.yml 文件中配置 Spring Session 如下。

列表 9.17 配置 Spring Session 以在 Redis 中存储数据

spring:
  session: 
    store-type: redis 
    timeout: 10m 
    redis: 
      namespace: polar:edge 

在网关中管理 Web 会话需要额外的注意,以确保在正确的时间保存正确的状态。在这个例子中,我们希望在将请求转发到下游之前将会话保存到 Redis 中。我们该如何做呢?如果您在考虑是否有针对它的网关过滤器,那么您是对的!

在 Edge Service 项目的 application.yml 文件中,将 SaveSession 添加为默认过滤器,以指示 Spring Cloud Gateway 在将请求转发到下游之前始终保存 Web 会话。

列表 9.18 配置网关以保存会话数据

spring:
  cloud:
    gateway:
      default-filters:
        - SaveSession      ❶

❶ 确保在转发请求到下游之前保存会话数据

当 Spring Session 与 Spring Security 结合使用时,这是一个关键点。第十一章和第十二章将详细介绍会话管理。现在,让我们设置一个集成测试来验证 Edge Service 中的 Spring 上下文是否正确加载,包括与 Redis 的集成。

我们将使用的方法与我们在上一章中定义 PostgreSQL 测试容器的类似。让我们扩展由 Spring Initializr 生成的现有 EdgeServiceApplicationTests 类,并配置一个 Redis 测试容器。对于这个例子,验证当使用 Redis 存储与 Web 会话相关的数据时,Spring 上下文是否正确加载就足够了。

列表 9.19 使用 Redis 容器测试 Spring 上下文加载

package com.polarbookshop.edgeservice;

import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;

@SpringBootTest(                                                  ❶
  webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
)
@Testcontainers                                                   ❷
class EdgeServiceApplicationTests {

   private static final int REDIS_PORT = 6379;

   @Container
   static GenericContainer<?> redis =                             ❸
      new GenericContainer<>(DockerImageName.parse("redis:7.0"))
       .withExposedPorts(REDIS_PORT);

   @DynamicPropertySource                                         ❹
   static void redisProperties(DynamicPropertyRegistry registry) {
      registry.add("spring.redis.host",
          () -> redis.getHost());
      registry.add("spring.redis.port",
          () -> redis.getMappedPort(REDIS_PORT));
   }

   @Test
   void verifyThatSpringContextLoads() {                          ❺
   }

}

❶ 加载一个完整的 Spring Web 应用程序上下文和一个监听随机端口的 Web 环境

❷ 激活测试容器的自动启动和清理

❸ 定义用于测试的 Redis 容器

❹ 覆盖 Redis 配置以指向测试 Redis 实例

❺ 一个空测试用于验证应用程序上下文是否正确加载,并且与 Redis 的连接已成功建立

最后,按照以下步骤运行集成测试:

$ ./gradlew test --tests EdgeServiceApplicationTests

如果您想在某些测试中禁用通过 Redis 的会话管理,您可以通过在特定的测试类中使用 @TestPropertySource 注解或在属性文件中设置 spring.session.store-type 属性为 none 来实现,如果您想使其适用于所有测试类。

Polar Labs

随意应用您在前几章中学到的知识,并为部署边缘服务做好准备。

  1. 将 Spring Cloud Config Client 添加到边缘服务中,使其能够从配置服务中获取配置数据。

  2. 配置 Cloud Native Buildpacks 集成,容器化应用程序,并定义部署管道的提交阶段,正如您在第三章和第六章中学到的。

  3. 编写部署和服务清单,以将边缘服务部署到 Kubernetes 集群。

  4. 配置 Tilt 以自动化将边缘服务部署到使用 minikube 初始化的本地 Kubernetes 集群。

您可以参考书中附带的代码仓库中的 Chapter09/09-end 文件夹以检查最终结果(github.com/ThomasVitale/cloud-native-spring-in-action)。您还可以使用 kubectl apply -f services 从 Chapter09/09-end/polar-deployment/kubernetes/platform/development 文件夹中可用的清单部署支持服务。

9.5 使用 Kubernetes Ingress 管理外部访问

Spring Cloud Gateway 帮助您定义一个边缘服务,您可以在系统的入口点实现多个模式和横切关注点。在前面的章节中,您看到了如何将其用作 API 网关,实现诸如速率限制和断路器等弹性模式,并定义分布式会话。在第十一章和第十二章中,我们还将向边缘服务添加身份验证和授权功能。

边缘服务代表 Polar 书店系统的入口点。然而,当它在 Kubernetes 集群中部署时,它只能从集群内部访问。在第七章中,我们使用了 端口转发 功能,将 minikube 集群中定义的 Kubernetes 服务暴露到您的本地计算机。这在开发期间是一个有用的策略,但并不适合生产环境。

本节将介绍如何使用 Ingress API 管理运行在 Kubernetes 集群中的应用程序的外部访问。

注意:本节假设您已经完成了上一节“Polar Labs”侧边栏中列出的任务,并已为 Kubernetes 部署边缘服务做好准备。

9.5.1 理解 Ingress API 和 Ingress Controller

当涉及到在 Kubernetes 集群内部暴露应用程序时,我们可以使用类型为 ClusterIP 的 Service 对象。这正是我们迄今为止所做的一切,以便使 Pods 能够在集群内部相互交互。例如,这就是 Catalog Service Pods 如何与 PostgreSQL Pod 进行通信的方式。

服务对象也可以是负载均衡器类型,它依赖于云服务提供商提供的负载均衡器来将应用程序暴露给互联网。我们可以为边缘服务定义一个负载均衡器服务,而不是集群 IP 服务。当在公共云中运行系统时,供应商会提供负载均衡器,分配一个公共 IP 地址,所有来自该负载均衡器的流量都会被导向边缘服务 Pod。这是一种灵活的方法,可以直接将服务暴露给互联网,并且适用于不同类型的流量。

负载均衡器服务方法涉及为决定暴露给互联网的每个服务分配不同的 IP 地址。由于服务是直接暴露的,我们没有机会应用任何进一步的网络配置,例如 TLS 终止。我们可以在边缘服务中配置 HTTPS,将所有指向集群的流量通过网关路由(即使是属于极书书店平台的服务),并在那里应用进一步的网络配置。Spring 生态系统提供了我们解决这些问题的所有所需工具,这可能是我们在许多场景中会做的事情。然而,由于我们想在 Kubernetes 上运行我们的系统,我们可以在平台级别管理这些基础设施问题,并保持我们的应用程序更简单、更易于维护。这就是 Ingress API 派上用场的地方。

Ingress是一个“管理集群中服务的对外访问,通常是 HTTP”的对象。Ingress 可能提供负载均衡、SSL 终止和基于名称的虚拟主机(kubernetes.io/docs)。Ingress 对象充当 Kubernetes 集群的入口点,能够将来自单个外部 IP 地址的流量路由到集群内部运行的多项服务。我们可以使用 Ingress 对象进行负载均衡,接受指向特定 URL 的外部流量,并管理 TLS 终止,通过 HTTPS 暴露应用程序服务。

Ingress 对象本身并不能完成任何事情。我们使用 Ingress 对象来声明关于路由和 TLS 终止的期望状态。实际执行这些规则并将流量从集群外部路由到集群内部应用程序的组件是ingress 控制器。由于有多种实现方式,核心 Kubernetes 发行版中不包含默认的 ingress 控制器——安装一个取决于你。Ingress 控制器是通常使用反向代理如 NGINX、HAProxy 或 Envoy 构建的应用程序。一些例子包括 Ambassador Emissary、Contour 和 Ingress NGINX。

在生产环境中,云平台或专用工具将被用来配置 Ingress 控制器。在我们的本地环境中,我们需要进行一些额外的配置才能使路由工作。对于 Polar Bookshop 的例子,我们将在两种环境中都使用 Ingress NGINX (github.com/kubernetes/ingress-nginx)。

注意:基于 NGINX 的有两个流行的 Ingress 控制器。Ingress NGINX 项目(github.com/kubernetes/ingress-nginx)是在 Kubernetes 项目内部开发和维护的。它是开源的,也是本书中我们将使用的内容。NGINX 控制器(www.nginx.com/products/nginx-controller)是由 F5 NGINX 公司开发和维护的产品,它提供了免费和商业选项。

让我们看看如何在我们的本地 Kubernetes 集群上使用 Ingress NGINX。Ingress 控制器就像在 Kubernetes 上运行的其他任何应用程序一样是一个工作负载,并且可以以不同的方式部署。最简单的方法是使用 kubectl 将其部署清单应用到集群中。由于我们使用 minikube 来管理本地 Kubernetes 集群,我们可以依赖一个内置的附加组件来启用基于 Ingress NGINX 的 Ingress 功能。

首先,让我们启动在第七章中介绍的 polar 本地集群。由于我们配置了 minikube 在 Docker 上运行,请确保你的 Docker 引擎正在运行:

$ minikube start --cpus 2 --memory 4g --driver docker --profile polar

接下来,我们可以启用 ingress 附加组件,这将确保 Ingress NGINX 被部署到我们的本地集群中:

$ minikube addons enable ingress --profile polar

最后,你可以这样获取使用 Ingress NGINX 部署的不同组件的信息:

$ kubectl get all -n ingress-nginx

前面的命令包含了一个我们尚未遇到的参数:-n ingress-nginx。这意味着我们想要获取在 ingress-nginx 命名空间中创建的所有对象。

命名空间是“Kubernetes 用于在单个集群内支持资源组隔离的一种抽象。命名空间用于在集群中组织对象,并提供了一种划分集群资源的方法” (kubernetes.io/docs/reference/glossary)。

我们使用命名空间来保持我们的集群井然有序,并定义网络策略以出于安全原因隔离某些资源。到目前为止,我们一直在使用默认命名空间,并且我们将继续在所有 Polar Bookshop 应用程序中使用它。然而,当涉及到 Ingress NGINX 这样的平台服务时,我们将依赖专用命名空间来隔离这些资源。

现在 Ingress NGINX 已经安装好了,让我们继续部署 Polar Bookshop 应用所使用的后端服务。检查本书附带源代码仓库(第九章/09-end)并复制 polar-deployment/kubernetes/platform/development 文件夹的内容到你的 polar-deployment 仓库中相同的路径,覆盖掉我们在前几章中使用的任何现有文件。该文件夹包含运行 PostgreSQL 和 Redis 的基本 Kubernetes 清单。

打开一个终端窗口,导航到你的 polar-deployment 仓库中的 kubernetes/platform/development 文件夹,并运行以下命令在你的本地集群中部署 PostgreSQL 和 Redis:

$ kubectl apply -f services

你可以使用以下命令来验证结果:

$ kubectl get deployment
NAME             READY   UP-TO-DATE   AVAILABLE   AGE
polar-postgres   1/1     1            1           73s
polar-redis      1/1     1            1           73s

提示:为了方便起见,我准备了一个脚本,它可以执行所有之前的操作。你可以运行它来创建一个本地 Kubernetes 集群,启用 Ingress NGINX 插件,并部署 Polar Bookshop 使用的后端服务。你将在你刚刚复制到 polar-deployment 仓库中的 kubernetes/platform/development 文件夹中找到 create-cluster.sh 和 destroy-cluster.sh 文件。在 macOS 和 Linux 上,你可能需要通过 chmod +x create-cluster.sh 命令使脚本可执行。

让我们通过将 Edge Service 打包成容器镜像并将其加载到本地 Kubernetes 集群中来结束本节。打开一个终端窗口,导航到 Edge Service 根目录(edge-service),并运行以下命令:

$ ./gradlew bootBuildImage
$ minikube image load edge-service --profile polar

在下一节中,你将定义一个 Ingress 对象,并配置它来管理运行在 Kubernetes 集群中的 Polar Bookshop 系统的外部访问。

9.5.2 与 Ingress 对象一起工作

Edge Service 负责应用路由,但它不应该关心底层的基础设施和网络配置。使用 Ingress 资源,我们可以解耦这两个责任。开发者将维护 Edge Service,而平台团队将管理 ingress 控制器和网络配置(可能依赖于像 Linkerd 或 Istio 这样的服务网格)。图 9.6 显示了引入 Ingress 后的 Polar Bookshop 部署架构。

09-06

图 9.6 引入 Ingress 管理集群外部访问后的 Polar Bookshop 系统部署架构

让我们定义一个 Ingress,将所有来自集群外部的 HTTP 流量路由到 Edge Service。根据发送 HTTP 请求使用的 DNS 名称定义 Ingress 路由和配置是很常见的。由于我们是在本地工作,并且假设我们没有 DNS 名称,我们可以调用为 Ingress 分配的、可以从集群外部访问的外部 IP 地址。在 Linux 上,你可以使用分配给 minikube 集群的 IP 地址。你可以通过运行以下命令来检索该值:

$ minikube ip --profile polar
192.168.49.2

在 macOS 和 Windows 上,当在 Docker 上运行时,路由器附加组件尚不支持使用 minikube 集群的 IP 地址。相反,我们需要使用 minikube tunnel --profile polar 命令将集群暴露到本地环境中,然后使用 127.0.0.1 IP 地址调用集群。这与 kubectl port-forward 命令类似,但它适用于整个集群而不是特定服务。

在确定要使用的 IP 地址后,让我们为 Polar 书店定义 Ingress 对象。在 Edge Service 项目中,在 k8s 文件夹中创建一个新的 ingress.yml 文件。

列表 9.20 通过 Ingress 将 Edge 服务暴露在集群外部

apiVersion: networking.k8s.io/v1        ❶
kind: Ingress                           ❷
metadata:
  name: polar-ingress                   ❸
spec:
  ingressClassName: nginx               ❹
  rules:
    - http:                             ❺
        paths:
          - path: /                     ❻
            pathType: Prefix
            backend:
              service:
                name: edge-service      ❼
                port:
                  number: 80            ❽

❶ Ingress 对象的 API 版本

❷ 要创建的对象类型

❸ Ingress 的名称

❹ 配置负责管理此对象的路由器

❺ HTTP 流量的 Ingress 规则

❻ 为所有请求设置默认规则

❼ 应将流量转发到该服务对象名称

❽ 应将流量转发到该服务的端口号

到目前为止,我们已经准备好将 Edge 服务和 Ingress 部署到本地 Kubernetes 集群。打开一个终端窗口,导航到 Edge Service 根目录(edge-service),并运行以下命令:

$ kubectl apply -f k8s

让我们使用以下命令验证 Ingress 对象是否已正确创建:

$ kubectl get ingress

NAME               CLASS   HOSTS   PORTS   AGE
polar-ingress      nginx   *       80      21s

现在是时候测试 Edge 服务是否可以通过 Ingress 正确访问了。如果您在 Linux 上,您不需要进行任何进一步的准备步骤。如果您在 macOS 或 Windows 上,请打开一个新的终端窗口并运行以下命令以将您的 minikube 集群暴露到本地主机。该命令将保持运行,以便隧道可访问,因此请确保您保持终端窗口开启。您第一次运行此命令时,可能会被要求输入您的机器密码以授权隧道到集群:

$ minikube tunnel --profile polar

最后,打开一个新的终端窗口并运行以下命令以测试应用程序(在 Linux 上,请使用 minikube 的 IP 地址而不是 127.0.0.1):

$ http 127.0.0.1/books

由于目录服务没有运行,Edge Service 将执行我们之前配置的回退行为,并返回一个带有空体的 200 OK 响应。这正是我们预期的,这也证明了 Ingress 配置是有效的。

当您完成部署的尝试后,可以使用以下命令停止并删除本地 Kubernetes 集群:

$ minikube stop --profile polar
$ minikube delete --profile polar

小贴士:为了方便起见,您还可以使用之前从书籍源代码中复制的 destroy-cluster.sh 脚本(位于您的 polar-deployment 存储库的 kubernetes/platform/development 文件夹中)。在 macOS 和 Linux 上,您可能需要通过 chmod +x destroy-cluster.sh 命令使脚本可执行。

干得好!我们现在已经准备好通过添加身份验证和授权来使边缘服务变得更好。然而,在配置安全设置之前,我们仍然需要完成极地书店的业务逻辑,以便分派订单。在下一章中,你将在学习事件驱动架构、Spring Cloud Function 和 Spring Cloud Stream 与 RabbitMQ 的同时完成这项工作。

摘要

  • 在分布式架构中,API 网关提供了几个好处,包括解耦内部服务与外部 API,并提供一个集中、方便的地方来处理诸如安全、监控和弹性等横切关注点。

  • Spring Cloud Gateway 基于 Spring 响应式堆栈。它提供了一个 API 网关实现,并与其他 Spring 项目集成,为应用程序添加横切关注点,包括 Spring Security、Spring Cloud Circuit Breaker 和 Spring Session。

  • 路由是 Spring Cloud Gateway 的核心。它们通过一个唯一的 ID、一组确定是否遵循路由的谓词、一个用于如果谓词允许则转发请求的 URI 以及一组在转发请求之前或之后应用的过滤器来识别。

  • 重试过滤器用于配置特定路由的重试尝试。

  • 请求速率限制过滤器与 Spring Data Redis Reactive 集成,限制了在特定时间窗口内可以接受请求的数量。

  • 基于 Spring Cloud Circuit Breaker 和 Resilience4J 的断路器过滤器定义了断路器、时间限制器和针对特定路由的回退。

  • 云原生应用应该是无状态的。数据服务应用于存储状态。例如,PostgreSQL 用于持久化存储,Redis 用于缓存和会话数据。

  • Kubernetes Ingress 资源允许你管理运行在 Kubernetes 集群内部的应用程序的外部访问。

  • 路由规则由一个入口控制器强制执行,这是一个也在集群中运行的应用程序。

10 个事件驱动应用程序和函数

本章涵盖

  • 理解事件驱动架构

  • 使用 RabbitMQ 作为消息代理

  • 使用 Spring Cloud Function 实现函数

  • 使用 Spring Cloud Stream 处理事件

  • 使用 Spring Cloud Stream 产生和消费事件

在前面的章节中,我们构建了一个分布式应用程序系统,该系统根据请求/响应模式进行交互,这是一种同步通信类型。你看到了如何以命令式和反应式的方式设计交互。在前一种情况下,处理线程会阻塞,等待 I/O 操作的响应。在后一种情况下,线程不会等待。一旦收到响应,任何可用的线程都会异步处理响应。

即使反应式编程范式允许你订阅生产者并异步处理传入的数据,但两个应用程序之间的交互仍然是同步的。第一个应用程序(客户端)向第二个应用程序(服务器)发送请求,并期望在短时间内收到响应。客户端如何处理响应(命令式或反应式)是实现的细节,不影响交互本身。无论如何,都期望收到响应。

云原生应用程序应该是松耦合的。微服务专家山姆·纽曼(Sam Newman)识别了几种不同的耦合类型,包括实现部署时间耦合。¹ 让我们考虑我们迄今为止一直在工作的 Polar Bookshop 系统。

我们可以更改任何应用程序的实现,而无需更改其他应用程序。例如,我们可以使用反应式范式重新实现目录服务(Catalog Service),而不会影响订单服务(Order Service)。使用像 REST API 这样的服务接口,我们可以隐藏实现细节,提高松耦合。所有应用程序都可以独立部署。它们不是耦合的,这降低了风险并提高了敏捷性。

然而,如果你思考一下我们迄今为止构建的应用程序之间的交互,你会注意到它们需要系统中的其他组件可用。订单服务(Order Service)需要目录服务(Catalog Service)以确保用户可以成功订购书籍。我们知道失败是经常发生的,因此我们采用了几种策略来确保即使在逆境中也能保证弹性,或者至少确保功能的优雅降级。这是时间耦合的后果:订单服务(Order Service)和目录服务(Catalog Service)需要同时可用,以满足系统要求。

事件驱动架构描述了通过 产生消费 事件进行交互的分布式系统。这种交互是异步的,解决了时间耦合问题。本章将涵盖事件驱动架构和事件代理的基础知识。然后,你将学习如何使用函数式编程范式和 Spring Cloud Function 实现业务逻辑。最后,你将使用 Spring Cloud Stream 通过 RabbitMQ 将函数作为消息通道暴露出来,通过发布/订阅(pub/sub)模型构建事件驱动应用程序。

注意:本章示例的源代码可在 Chapter10/10-begin、Chapter10/10-intermediate 和 Chapter10/10-end 文件夹中找到,包含项目的初始、中间和最终状态(github.com/ThomasVitale/cloud-native-spring-in-action)。

10.1 事件驱动架构

事件是一个发生的事件。它是在系统中发生的相关事情,比如状态变化,并且可能有多个事件来源。本章将专注于应用程序,但事件也可能在物联网设备、传感器或网络中发生。当事件发生时,感兴趣的各方可以被通知。事件通知通常通过消息来完成,这些消息是事件的数据表示。

在事件驱动架构中,我们识别 事件生产者事件消费者。生产者是一个检测事件并发送通知的组件。消费者是一个在特定事件发生时被通知的组件。生产者和消费者彼此不了解,独立工作。生产者通过向由事件代理操作的消息通道发布消息来发送事件通知,该代理负责收集和路由消息到消费者。当事件发生时,代理会通知消费者,并可以对其采取行动。

当使用自行处理和分发事件的代理时,生产者和消费者之间的耦合最小。特别是,它们在时间上是解耦的,因为交互是异步的。消费者可以在任何时间获取和处理消息,而不会对生产者产生任何影响。

在本节中,你将学习事件驱动模型的基本知识以及它们如何帮助在云中构建更健壮和松散耦合的应用程序。

10.1.1 理解事件驱动模型

事件驱动架构可以基于两种主要模型:

  • 发布/订阅(pub/sub)—此模型基于订阅。生产者发布事件,这些事件被发送到所有订阅者进行消费。事件在接收后不能重放,因此新加入的消费者将无法获取过去的事件。

  • 事件流—在这个模型中,事件被写入日志。生产者按事件发生时发布事件,并且它们以有序的方式存储。消费者不需要订阅它们,但可以从事件流的任何部分读取。在这个模型中,事件可以被重放。客户端可以随时加入并接收所有过去的事件。

在基本场景中,消费者接收并处理到达的事件。对于像模式匹配这样的特定用例,他们也可以在时间窗口内处理一系列事件。在事件流模型中,消费者有额外的可能性处理事件流。事件驱动架构的核心是能够处理和路由事件的平台。例如,RabbitMQ 是常用于 pub/sub 模型的常见选择。Apache Kafka 是事件流处理的一个强大平台。

事件流模型非常吸引人,并且越来越受欢迎,这得益于过去几年中开发出的许多技术,允许你构建实时数据管道。然而,这是一个复杂的模型,它值得有一本自己的书来有效地教授。在本章中,我将介绍 pub/sub 模型。

在我们更详细地分析这个模型之前,我将为 Polar 书店系统定义一些需求,并将它们用作探索使用 pub/sub 模型的事件驱动架构的手段。

10.1.2 使用 pub/sub 模型

在 Polar 书店系统中,我们需要实现一个事件驱动解决方案,以允许不同的应用程序以异步方式相互通信,同时减少它们的耦合。以下是这些需求:

  • 当一个订单被接受时:

    • 订单服务应该通知对事件感兴趣的消费者。

    • 分发服务应该执行一些逻辑来分发订单。

  • 当一个订单被分发时:

    • 分发服务应该通知对这类事件感兴趣的消费者。

    • 订单服务应该更新数据库中的订单状态。

如果你注意到了,你可能已经注意到需求没有指定在订单创建时 Order Service 应该通知哪些应用程序。在我们的例子中,只有新的分发服务应用程序会对这些事件感兴趣。然而,未来可能有更多的应用程序订阅订单创建事件。这种设计的美丽之处在于,你可以演进软件系统并添加更多应用程序,而不会对现有的应用程序造成任何影响。例如,你可以添加一个邮件服务,当用户创建的订单被接受时,它会向用户发送电子邮件,而订单服务甚至不会意识到这一点。

这种交互应该是异步的,可以用 pub/sub 模型来建模。图 10.1 展示了这种交互,并描述了接受、调度和更新订单的三个流程。它们在时间上是解耦的,并且异步执行。你可能注意到将数据持久化到数据库的操作和产生事件的操作具有相同的编号步骤。这是因为它们属于同一个工作单元(一个 事务),正如我将在本章后面解释的那样。

10-01

图 10.1 订单服务和调度服务通过产生和消费由事件代理(RabbitMQ)收集和分发的事件,以异步和间接的方式通信。

在本章的剩余部分,你将学习一些可以用于实现 Polar Bookshop 事件驱动设计的技伎和模式。RabbitMQ 将作为负责收集、路由和向消费者分发消息的事件处理平台。图 10.2 在我们介绍了 Dispatcher Service 应用和 RabbitMQ 之后,突出了 Polar Bookshop 系统的事件驱动部分。

10-02

图 10.2 在 Polar Bookshop 系统中,订单服务和调度服务基于 RabbitMQ 分发的事件进行异步通信。

下一个部分将介绍 RabbitMQ 的基本概念、其协议以及如何在本地环境中运行它。

10.2 基于 RabbitMQ 的消息代理

一个消息系统需要两个主要的东西:一个消息代理和一个协议。高级消息队列协议 (AMQP) 确保了跨平台的互操作性和可靠的消息传递。它已成为现代架构中广泛使用的一种协议,非常适合云环境,在那里我们需要弹性、松散耦合和可伸缩性。RabbitMQ 是一个流行的开源消息代理,它依赖于 AMQP,并提供灵活的异步消息、分布式部署和监控。最近的 RabbitMQ 版本还引入了事件流功能。

Spring 为最常用的消息解决方案提供了广泛的支持。Spring 框架本身内置了对 Java 消息服务 (JMS) API 的支持。Spring AMQP 项目 (spring.io/projects/spring-amqp) 为此消息协议添加了支持,并提供了与 RabbitMQ 的集成。Apache Kafka 是另一种在最近几年越来越受欢迎的技术,例如用于实现事件源模式或实时流处理。Spring for Apache Kafka 项目 (spring.io/projects/spring-kafka) 提供了这种集成。

本节将涵盖 AMQP 协议和 RabbitMQ 的基本方面,我们将使用它来实现 Polar Bookshop 系统的消息传递。在应用层面,我们将使用 Spring Cloud Stream,它通过依赖 Spring AMQP 项目提供了与 RabbitMQ 的便捷且健壮的集成。

10.2.1 理解 AMQP 消息系统

当使用基于 AMQP 的解决方案如 RabbitMQ 时,参与交互的参与者可以分为以下类别:

  • 生产者——发送消息的实体(发布者)

  • 消费者——接收消息的实体(订阅者)

  • 消息代理——接受生产者消息并将它们路由到消费者的中间件

图 10.3 说明了参与者之间的交互。从协议的角度来看,我们也可以说代理是服务器,而生产者和消费者是客户端

10-03

图 10.3 在 AMQP 中,代理接受生产者的消息并将它们路由到消费者。

注意,RabbitMQ 最初是为了支持 AMQP 而开发的,但它也支持其他协议,包括 STOMP、MQTT,甚至 WebSocket,用于通过 HTTP 传递消息。从版本 3.9 开始,它还支持事件流。

AMQP 消息模型基于交换队列,如图 10.4 所示。生产者向交换发送消息。RabbitMQ 根据给定的路由规则计算哪些队列应该接收消息的副本。消费者从队列中读取消息。

10-04

图 10.4 生产者向交换发布消息。消费者订阅队列。交换根据路由算法将消息路由到队列。

该协议规定消息由属性和有效负载组成,如图 10.5 所示。AMQP 定义了一些属性,但你可以添加自己的属性来传递正确路由消息所需的信息。有效负载必须是二进制类型,除此之外没有约束。

10-05

图 10.5 AMQP 消息由属性和有效负载组成。

现在你已经了解了 AMQP 的基础知识,让我们启动 RabbitMQ。

10.2.2 使用 RabbitMQ 进行发布/订阅通信

RabbitMQ 在 AMQP 之上提供了一个简单而有效的解决方案,用于实现我们希望在订单服务和调度服务之间建立的发布/订阅交互。除了功能本身之外,寻找我在前几章中提到的云系统和数据服务的属性也很重要,包括弹性、高可用性和数据复制。RabbitMQ 提供了所有这些。例如,它提供交付确认、集群、监控、队列持久性和复制。此外,几个云服务提供商还提供与托管 RabbitMQ 服务的集成。

目前,你将在本地机器上以容器形式运行 RabbitMQ。首先,确保你的 Docker 引擎正在运行。然后打开位于你的 polar-deployment 仓库中的 docker-compose.yml 文件。

注意:如果你没有跟随示例进行操作,你可以使用书中附带的源代码中的 Chapter10/10-begin/polar-deployment/docker/docker-compose.yml 作为起点。

在你的 docker-compose.yml 文件中,添加一个新的服务定义,使用 RabbitMQ 官方镜像(包括管理插件),并通过端口 5672(用于 AMQP)和 15672(用于管理控制台)暴露它。RabbitMQ 管理插件通过基于浏览器的用户界面检查交换和队列非常方便。

列表 10.1 定义 RabbitMQ 容器

version: "3.8"
services:
  ...
  polar-rabbitmq: 
    image: rabbitmq:3.10-management     ❶
    container_name: polar-rabbitmq 
    ports: 
      - 5672:5672                       ❷
      - 15672:15672                     ❸
    volumes:                            ❹
      - ./rabbitmq/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf 

❶ 启用管理插件的官方 RabbitMQ 镜像

❷ RabbitMQ 监听 AMQP 请求的端口

❸ 暴露管理 GUI 的端口

❹ 作为卷挂载的配置文件

配置基于一个作为卷挂载的文件,类似于我们配置 PostgreSQL 的方式。在你的 polar-deployment 仓库中创建一个 docker/rabbitmq 文件夹,并添加一个新的 rabbitmq.conf 文件来配置默认账户。

列表 10.2 配置 RabbitMQ 默认账户

default_user = user
default_pass = password

接下来,打开一个终端窗口,导航到你的 docker-compose.yml 文件所在的文件夹,并运行以下命令以启动 RabbitMQ:

$ docker-compose up -d polar-rabbitmq

最后,打开一个浏览器窗口,导航到 http://localhost:15672 以访问 RabbitMQ 管理控制台。使用我们在配置文件中定义的凭据(用户/密码)登录,并四处看看。在接下来的章节中,你将能够在管理控制台的交换和队列区域中跟踪订单服务与调度服务之间的消息流。

当你完成对 RabbitMQ 管理控制台的探索后,你可以按照以下方式关闭它:

$ docker-compose down

Spring Cloud Stream 帮助应用程序与像 RabbitMQ 这样的事件代理无缝集成。但在我们深入之前,我们需要定义将处理消息的逻辑。在下一节中,你将了解 Spring Cloud Function 以及如何用供应商、函数和消费者来实现新订单流业务逻辑。

10.3 使用 Spring Cloud Function 的函数

Spring Cloud Function 和 Spring Cloud Stream 的项目负责人 Oleg Zhurakousky 经常向会议听众提出这个问题:有没有任何业务功能是你不能用供应商、函数和消费者来定义的?这是一个有趣且具有挑战性的问题。你能想到什么吗?大多数软件需求都可以用函数来表示。

为什么一开始就要使用函数呢?它们是一个简单、统一且可移植的编程模型,非常适合基于这些概念的事件驱动架构。

Spring Cloud Function 推崇通过基于 Java 8 引入的标准接口实现业务逻辑的函数化实现:Supplier、Function 和 Consumer。

  • 供应商—供应商是一个只有输出没有输入的函数。它也被称为生产者发布者

  • 函数—函数既有输入也有输出。它也被称为处理器

  • 消费者—消费者是一个有输入但没有输出的函数。它也被称为订阅者

在本节中,你将了解 Spring Cloud Function 是如何工作的,以及如何通过函数实现业务逻辑。

10.3.1 在 Spring Cloud Function 中使用函数范式

让我们从考虑 Dispatcher Service 应用程序之前列出的业务需求开始,了解函数。每当接受订单时,Dispatcher Service 应负责打包和标记订单,并在订单派发后通知相关方(在这种情况下,是 Order Service)。为了简单起见,让我们假设打包标记动作都由应用程序本身执行,我们将在考虑框架之前先考虑如何通过函数实现业务逻辑。

作为派发订单的一部分要执行的两个动作可以表示为函数:

  • 打包函数以接受的订单标识符作为输入,打包订单(在示例中,处理通过日志消息表示),并返回订单标识符作为输出,准备进行标记。

  • 标签函数以打包订单的标识符作为输入,标记订单(在示例中,处理通过日志消息表示),并返回订单标识符作为输出,完成派发。

这两个函数按顺序组合给出了 Dispatcher Service 的业务逻辑的完整实现,如图 10.6 所示。

10-06

图 10.6 Dispatcher Service 的业务逻辑是通过两个函数:pack 和 label 的组合来实现的。

让我们看看我们如何实现这些功能,以及 Spring Cloud Function 带来了哪些功能。

初始化 Spring Cloud Function 项目

你可以从 Spring Initializr (start.spring.io)初始化 Dispatcher Service 项目,并将结果存储在一个新的 dispatcher-service Git 仓库中。初始化的参数如图 10.7 所示。

10-07

图 10.7 初始化 Dispatcher Service 项目的参数

提示:在本章的开始文件夹中,你可以找到一个可以在终端窗口中运行的 curl 命令。它下载一个包含所有启动所需代码的 zip 文件,无需通过 Spring Initializr 网站上的手动生成。

build.gradle 文件的最终依赖项部分看起来像这样:

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter'
  implementation 'org.springframework.cloud:spring-cloud-function-context'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

主要依赖项包括

  • Spring Boot (org.springframework.boot:spring-boot-starter)—提供基本的 Spring Boot 库和自动配置功能。

  • Spring Cloud Function (org.springframework.cloud:spring-cloud-function-context)—提供促进和支持通过函数实现业务逻辑的 Spring Cloud Function 库。

  • Spring Boot Test (org.springframework.boot:spring-boot-starter-test)—提供了一些用于测试应用程序的库和实用工具,包括 Spring Test、JUnit、AssertJ 和 Mockito。它自动包含在每一个 Spring Boot 项目中。

接下来,将自动生成的 application.properties 文件重命名为 application.yml,并配置服务器端口和应用程序名称。目前,应用程序不包含 Web 服务器。尽管如此,我们仍将配置服务器端口号,因为当我们在第十三章中向应用程序添加监控功能时,它将被使用。

列表 10.3 配置服务器和应用程序名称

server:
  port: 9003                     ❶
spring:
  application:
    name: dispatcher-service     ❷

❶ 将由嵌入式 Web 服务器使用的端口

❷ 应用程序名称

接下来,让我们看看如何使用函数来实现业务逻辑。

通过函数实现业务逻辑

业务逻辑可以通过使用 Java Function 接口以标准方式实现。不需要 Spring。

让我们先考虑 pack 函数。函数的输入应提供先前已接受的订单的标识符。我们可以通过简单的 DTO 来模拟这些数据。

在 com.polarbookshop.dispatcherservice 包中,创建一个 OrderAcceptedMessage 记录来保存订单标识符。

列表 10.4 表示接受订单事件的 DTO

package com.polarbookshop.dispatcherservice;

public record OrderAcceptedMessage (     ❶
  Long orderId
){}

❶ 包含订单标识符作为 Long 字段的 DTO

注意,建模事件是一个有趣的话题,它超越了 Spring,需要几章才能正确地涵盖。如果您想了解更多关于这个主题的信息,我建议阅读 Martin Fowler 在他的 MartinFowler.com 博客上的这些文章:“关注事件” (martinfowler.com/eaaDev/EventNarrative.html);“领域事件” (martinfowler.com/eaaDev/DomainEvent.html);“你说的‘事件驱动’是什么意思?” (martinfowler.com/articles/201701-event-driven.html)。

函数的输出可以是表示为 Long 对象的打包订单的简单标识符。

现在输入和输出都明确了,是时候定义函数了。创建一个新的 DispatchingFunctions 类,并添加一个 pack() 方法来实现订单打包作为函数。

列表 10.5 将“pack”操作作为函数实现

package com.polarbookshop.dispatcherservice;

import java.util.function.Function;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class DispatchingFunctions {
  private static final Logger log =
    LoggerFactory.getLogger(DispatchingFunctions.class);

  public Function<OrderAcceptedMessage, Long> pack() {    ❶
    return orderAcceptedMessage -> {                      ❷
      log.info("The order with id {} is packed.",
        orderAcceptedMessage.orderId());
      return orderAcceptedMessage.orderId();              ❸
    };
  }
}

❶ 实现订单打包业务逻辑的函数

❷ 它接受一个 OrderAcceptedMessage 对象作为输入。

❸ 返回一个订单标识符(Long)

您可以看到这个列表中只有标准的 Java 代码。我努力在这个书中提供真实世界的例子,所以您可能会想知道这里发生了什么。在这种情况下,我决定专注于在事件驱动应用程序的上下文中使用函数式编程范式的基本方面。在函数内部,您可以添加任何喜欢的处理逻辑。这里重要的是函数提供的契约,其签名:输入和输出。在定义了这些之后,您可以自由地按需实现函数。我本可以提供这个函数的更真实世界的实现,但考虑到本章的目标,这并不会增加任何有价值的见解。甚至它不必是基于 Spring 的代码。在这个例子中,它不是:它是纯 Java 代码。

Spring Cloud Function 能够管理以不同方式定义的函数,只要它们遵循标准的 Java 接口 Function、Supplier 和 Consumer。您可以通过将函数注册为 bean 来让 Spring Cloud Function 了解您的函数。现在就为 pack() 函数做这件事,通过将 DispatchingFunctions 类标注为 @Configuration 和方法标注为 @Bean。

列表 10.6 将函数配置为 bean

@Configuration                                            ❶
public class DispatchingFunctions {
  private static final Logger log =
    LoggerFactory.getLogger(DispatchingFunctions.class);

  @Bean                                                   ❷
  public Function<OrderAcceptedMessage, Long> pack() {
    return orderAcceptedMessage -> {
      log.info("The order with id {} is packed.",
        orderAcceptedMessage.orderId());
      return orderAcceptedMessage.orderId();
    };
  }
}

❶ 函数在配置类中定义。

❷ 定义为 bean 的函数可以被 Spring Cloud Function 发现和管理。

正如您稍后看到的,注册为 bean 的函数通过 Spring Cloud Function 框架增强了额外的功能。这种美妙的特性在于业务逻辑本身并不了解周围的框架。您可以独立地对其进行演进和测试,而无需担心框架相关的问题。

使用命令式和反应式函数

Spring Cloud Function 支持命令式和反应式代码,因此您可以自由地使用像 Mono 和 Flux 这样的反应式 API 来实现函数。您也可以混合使用。为了举例,让我们使用 Project Reactor 实现标签 label 函数。函数的输入将是已打包订单的标识符,表示为一个 Long 对象。函数的输出将是已标签化的订单标识符,从而完成派送过程。我们可以通过一个简单的 DTO 来模拟此类数据,就像我们对 OrderAcceptedMessage 所做的那样。

在 com.polarbookshop.dispatcherservice 包中,创建一个 OrderDispatchedMessage 记录来保存已派送订单的标识符。

列表 10.7 表示订单派送事件的 DTO

package com.polarbookshop.dispatcherservice;

public record OrderDispatchedMessage (      ❶
  Long orderId
){}

❶ 包含订单标识符作为 Long 字段的 DTO

既然输入和输出都明确了,是时候定义函数了。打开 DispatchingFunctions 类,并添加一个 label() 方法来实现订单标签化作为函数。由于我们希望它是反应式的,输入和输出都被包装在一个 Flux 发布者中。

列表 10.8 将“标签”操作实现为函数

package com.polarbookshop.dispatcherservice;

import java.util.function.Function;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux; 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DispatchingFunctions {
  private static final Logger log =
    LoggerFactory.getLogger(DispatchingFunctions.class);

  ...

  @Bean                                                                 ❶
  public Function<Flux<Long>, Flux<OrderDispatchedMessage>> label() { 
    return orderFlux -> orderFlux.map(orderId -> {                      ❷
      log.info("The order with id {} is labeled.", orderId); 
      return new OrderDispatchedMessage(orderId);                       ❸
    }); 
  } 
}

❶ 实现订单标签化业务逻辑的函数

❷ 它接受一个订单标识符(Long)作为输入。

❸ 返回 OrderDispatchedMessage 作为输出

我们已经实现了这两个函数,现在让我们看看我们如何将它们组合并使用。

10.3.2 组合和集成函数:REST、无服务器、数据流

Dispatcher Service 的业务逻辑实现几乎完成。我们仍然需要一种方法来组合这两个函数。根据我们的要求,派发订单包括两个按顺序执行的步骤:首先打包(pack()),然后贴标签(label())。

Java 提供了使用 andThen() 或 compose() 操作符按顺序组合 Function 对象的功能。问题是您只能在第一个函数的输出类型与第二个函数的输入类型相同时使用它们。Spring Cloud Function 提供了解决这个问题的方案,并允许您通过透明类型转换无缝地组合函数,即使在像我们之前定义的命令式和响应式函数之间也是如此。

使用 Spring Cloud 组合函数就像在 application.yml(或 application.properties)文件中定义一个属性一样简单。在您的 Dispatcher Service 项目中打开 application.yml 文件,并配置 Spring Cloud Function 以管理并组合 pack() 和 label() 函数,如下所示。

列表 10.9 声明由 Spring Cloud 管理的函数

spring:
  cloud: 
    function: 
      definition: pack|label    ❶

❶ Spring Cloud Function 管理的函数定义

spring.cloud.function.definition 属性允许您声明您希望 Spring Cloud Function 管理和集成的函数,从而产生特定的数据流。在前一节中,我们实现了基本的 pack() 和 label() 函数。现在我们可以指示 Spring Cloud Function 将它们用作构建块,并生成一个由这两个函数组合而成的新函数。

在像 AWS Lambda、Azure Functions、Google Cloud Functions 或 Knative 这样的无服务器应用程序中,您通常为每个应用程序定义一个函数。云函数定义可以一对一地映射到您的应用程序中声明的函数,或者您可以使用 pipe(|)操作符在数据流中将函数组合在一起。如果您需要定义多个函数,可以使用分号(;)字符作为分隔符而不是管道(|)。

总结来说,您只需要实现标准的 Java 函数,然后您可以配置 Spring Cloud Function 使用它们,或者在使用前将它们组合起来。框架将完成其余工作,包括透明地转换输入和输出类型,以便组合成为可能。图 10.8 阐述了函数组合。

10-08

图 10.8 您可以组合具有不同输入和输出类型的函数,并且可以混合命令式和响应式类型。Spring Cloud Function 将透明地处理任何类型转换。

到目前为止,你可能想知道如何使用这些函数。那是我最喜欢的一部分。一旦你定义了函数,框架可以根据你的需求以不同的方式暴露它们。例如,Spring Cloud Function 可以自动将定义在 spring.cloud.function.definition 中的函数暴露为 REST 端点。然后你可以直接打包应用程序,部署到 Knative 这样的 FaaS 平台,然后 voilà:你就得到了你的第一个无服务器 Spring Boot 应用程序。这就是我们在第十六章构建无服务器应用程序时将要做的。或者,你可以使用框架提供的适配器之一来打包应用程序,并在 AWS Lambda、Azure Functions 或 Google Cloud Functions 上部署它。或者,你可以将其与 Spring Cloud Stream 结合使用,并将函数绑定到事件代理(如 RabbitMQ 或 Kafka)中的消息通道。

在我们探索使用 Spring Cloud Stream 与 RabbitMQ 集成之前,我想向你展示如何单独测试函数及其组合。一旦业务逻辑被实现为函数并经过测试,我们可以确信它将以相同的方式工作,无论是通过 REST 端点触发还是通过事件通知。

10.3.3 使用 @FunctionalSpringBootTest 编写集成测试

使用函数式编程范式,我们可以在标准 Java 中实现业务逻辑,并使用 JUnit 编写单元测试,而不会受到框架的影响。在那个层面,没有 Spring 代码,只有纯 Java。一旦你确保每个函数都正常工作,你将想要编写一些集成测试来验证当你的函数由 Spring Cloud Function 处理并以你配置的方式暴露时,应用程序的整体行为。

Spring Cloud Function 提供了一个 @FunctionalSpringBootTest 注解,你可以使用它来设置集成测试的上下文。与单元测试不同,你不想直接调用函数,而是要求框架为你提供。框架管理的所有函数都通过 FunctionCatalog 对象可用,该对象充当函数注册表。当框架提供函数时,它不仅包含你编写的实现,还增加了 Spring Cloud Function 提供的额外功能,如透明类型转换和函数组合。让我们看看它是如何工作的。

首先,你需要在 build.gradle 文件中添加 Reactor Test 的测试依赖,因为部分业务逻辑是使用 Reactor 实现的。记得在添加新依赖后刷新或重新导入 Gradle 依赖。

列表 10.10 在 Dispatcher Service 中添加 Reactor Test 依赖

dependencies {
  ...
  testImplementation 'io.projectreactor:reactor-test' 
}

然后,在 Dispatcher Service 项目的 src/test/java 文件夹中,创建一个新的 DispatchingFunctionsIntegrationTests 类。你可以为两个函数分别编写集成测试,但验证由 Spring Cloud Function 提供的组合函数 pack() + label() 的行为更有趣。

列表 10.11 函数组合的集成测试

package com.polarbookshop.dispatcherservice;

import java.util.function.Function;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.test.StepVerifier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.function.context.FunctionCatalog;
import org.springframework.cloud.function.context.test
➥ .FunctionalSpringBootTest;

@FunctionalSpringBootTest
class DispatchingFunctionsIntegrationTests {

  @Autowired
  private FunctionCatalog catalog;

  @Test
  void packAndLabelOrder() {
    Function<OrderAcceptedMessage, Flux<OrderDispatchedMessage>>
      packAndLabel = catalog.lookup(
       Function.class,
        "pack|label");                          ❶
    long orderId = 121;

    StepVerifier.create(packAndLabel.apply(
       new OrderAcceptedMessage(orderId)        ❷
      ))
      .expectNextMatches(dispatchedOrder ->     ❸
        dispatchedOrder.equals(new OrderDispatchedMessage(orderId)))
      .verifyComplete();
  }
}

❶ 从 FunctionCatalog 获取复合函数

❷ 定义一个 OrderAccepted-Message,它是函数的输入

❸ 断言函数的输出是预期的 OrderDispatchedMessage 对象

最后,打开一个终端窗口,导航到 Dispatcher Service 项目的根目录,并运行测试:

$ ./gradlew test --tests DispatchingFunctionsIntegrationTests

这种集成测试确保了定义的云函数的正确行为,而不管它将以何种方式公开。在本书的源代码中,你可以找到一个更广泛的自动测试集(第十章/10-intermediate/dispatcher-service)。

函数是实现业务逻辑和将基础设施关注点委托给框架的简单而有效的方法。在下一节中,你将学习如何使用 Spring Cloud Stream 将函数绑定到 RabbitMQ 上的消息通道。

10.4 使用 Spring Cloud Stream 处理消息

驱动 Spring Cloud Function 框架的原则也可以在 Spring Cloud Stream 中找到。想法是,作为开发者,你负责业务逻辑,而框架处理基础设施关注点,比如如何集成消息代理。

Spring Cloud Stream 是一个用于构建可扩展、事件驱动和流式应用程序的框架。它建立在 Spring Integration 之上,该集成提供了与消息代理的通信层;Spring Boot,它为中间件集成提供自动配置;以及 Spring Cloud Function,它产生、处理和消费事件。Spring Cloud Stream 依赖于每个消息代理的本地功能,但它还提供了一个抽象层,以确保无论底层中间件如何,都能提供无缝的体验。例如,像消费者组和分区(Apache Kafka 中是本地的)这样的功能在 RabbitMQ 中不存在,但你可以通过框架为你提供它们来使用它们。

我最喜欢的 Spring Cloud Stream 功能是,你可以在项目中删除对 Dispatcher Service 的依赖,并自动将功能绑定到外部消息代理。最好的部分是?你不需要在应用程序中更改任何代码,只需更改 application.yml 或 application.properties 中的配置。在框架的先前版本中,必须使用专用注解来匹配业务逻辑与 Spring Cloud Stream 组件。现在它完全透明。

框架支持与 RabbitMQ、Apache Kafka、Kafka Streams 和 Amazon Kinesis 的集成。还有合作伙伴维护的集成,包括 Google PubSub、Solace PubSub+、Azure Event Hubs 和 Apache RocketMQ。

本节将介绍如何通过 RabbitMQ 的消息通道公开我们在 Dispatcher Service 中定义的复合函数。

10.4.1 配置与 RabbitMQ 的集成

Spring Cloud Stream 基于几个基本概念:

  • 目标绑定器——提供与外部消息系统(如 RabbitMQ 或 Kafka)集成的组件

  • 目标绑定——外部消息系统实体(如队列和主题)与应用程序提供的生产者和消费者之间的桥梁

  • 消息——应用程序生产者和消费者用于与目标绑定器以及外部消息系统通信的数据结构

这三项都由框架本身处理。您应用程序的核心,即业务逻辑,并不知道外部消息系统。目标绑定器负责让应用程序能够与外部消息代理进行通信,包括任何供应商特定的关注点。绑定由框架自动配置,但您仍然可以提供自己的配置来适应您的需求,就像我们对 Dispatcher 服务所做的那样。图 10.9 展示了一个使用 Spring Cloud Stream 的 Spring Boot 应用程序模型。

10-09

图 10.9 在 Spring Cloud Stream 中,目标绑定器提供与外部消息系统的集成,并与之建立消息通道。

一旦您将应用程序的业务逻辑定义为函数,并且您已配置 Spring Cloud Function 来管理它们(就像我们对 Dispatcher 服务所做的那样),您可以通过添加特定于您想要使用的代理的 Spring Cloud Stream 绑定器项目依赖项来通过消息代理公开这些函数。我将向您展示如何处理 RabbitMQ 的输入和输出消息通道,但您也可以在同一个应用程序中将绑定到多个消息系统。

将 RabbitMQ 集成到 Spring 中

首先,打开 Dispatcher 服务项目(dispatcher-service)的 build.gradle 文件,并将 Spring Cloud Function 依赖项替换为 Spring Cloud Stream 的 RabbitMQ 绑定器。由于 Spring Cloud Function 已经包含在 Spring Cloud Stream 中,因此您不需要显式添加它。您还可以删除对 Spring Boot Starter 的依赖,因为它也包含在 Spring Cloud Stream 依赖中。请记住,在添加新依赖后,刷新或重新导入 Gradle 依赖项。

列表 10.12 更新 Dispatcher 服务中的依赖项

dependencies {
  implementation 
  ➥ 'org.springframework.cloud:spring-cloud-stream-binder-rabbit' 
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
  testImplementation 'io.projectreactor:reactor-test'
}

接下来,打开 application.yml 文件,并添加以下配置以实现 RabbitMQ 集成。端口、用户名和密码与我们在 Docker Compose 中之前定义的相同(列表 10.1 和 10.2)。

列表 10.13 配置 RabbitMQ 集成

spring:
  rabbitmq: 
    host: localhost 
    port: 5672 
    username: user 
    password: password 
    connection-timeout: 5s 

就这样。如果您运行 Dispatcher 服务,您会注意到它已经完美运行,无需进一步配置。Spring Cloud Stream 将自动生成并配置 RabbitMQ 中的绑定到交换机和队列。

这对于快速启动和运行非常不错,但你可能希望添加自己的配置来定制生产场景的行为。接下来的部分将向您展示如何做到这一点,而且无需更改您的业务逻辑中的任何代码。这有多棒?

10.4.2 将绑定函数绑定到消息通道

开始使用 Spring Cloud Stream 非常简单,但可能会因为相似名称的概念混淆。在消息代理和 Spring Cloud Stream 的上下文中,术语 binding 及其变体被大量使用,可能会导致误解。图 10.10 展示了所有实体。

10-10

图 10.10 在 Spring Cloud Stream 中,绑定在应用程序和消息代理之间建立了消息通道。

Spring Cloud Stream 提供了一个带有 目标绑定器 的 Spring Boot 应用程序,该绑定器与外部消息系统集成。绑定器还负责在应用程序生产者和消费者与消息系统实体(RabbitMQ 的交换和队列)之间建立通信通道。这些通信通道被称为 目标绑定,它们是应用程序和代理之间的桥梁。

目标绑定 可以是输入通道或输出通道。默认情况下,Spring Cloud Stream 将每个绑定(输入和输出)映射到 RabbitMQ 中的一个交换(更确切地说,是一个 主题交换)。此外,对于每个输入绑定,它将一个队列绑定到相关的交换。这就是消费者从中接收和处理事件的队列。这种设置提供了基于发布/订阅模型实现事件驱动架构的所有管道。

在接下来的部分中,我将向您介绍 Spring Cloud Stream 中的目标绑定以及它们如何与 RabbitMQ 中的交换和队列相关联。

理解目标绑定

如图 10.10 所示,目标绑定是一个抽象,表示应用程序和代理之间的桥梁。在使用函数式编程模型时,Spring Cloud Stream 为每个接受输入数据的函数生成一个输入绑定,并为每个返回输出数据的函数生成一个输出绑定。每个绑定都按照以下约定分配一个逻辑名称:

  • 输入绑定: + -in- +

  • 输出绑定: + -out- +

除非你使用分区(例如,与 Kafka 一起使用),否则名称中的 部分始终为 0。《functionName》是从 spring.cloud.function.definition 属性的值计算得出的。对于单个函数,存在一对一的映射。例如,如果在 Dispatcher Service 中我们只有一个名为 dispatch 的函数,相关的绑定将被命名为 dispatch-in-0 和 dispatch-out-0。我们实际上使用了一个组合函数(pack|label),因此绑定名称是通过组合组成中涉及的所有函数的名称生成的:

  • 输入绑定:packlabel-in-0

  • 输出绑定:packlabel-out-0

这些名称仅与在应用程序中配置绑定本身相关。它们就像唯一的标识符,让您能够引用特定的绑定并应用自定义配置。请注意,这些名称仅在 Spring Cloud Stream 中存在——它们是逻辑名称。RabbitMQ 不了解它们。

配置目的地绑定

默认情况下,Spring Cloud Stream 使用绑定名称来生成 RabbitMQ 中交换和队列的名称,但在生产环境中,你可能出于几个原因而希望显式地管理它们。例如,交换和队列很可能已经在生产环境中存在。你还将想要控制交换和队列的不同选项,如持久性或路由算法。

对于 Dispatcher Service,我将向您展示如何配置输入和输出绑定。在启动时,Spring Cloud Stream 将检查相关的交换和队列是否已经在 RabbitMQ 中存在。如果它们不存在,它将根据您的配置创建它们。

让我们先定义将要用于在 RabbitMQ 中命名交换和队列的目的地名称。在你的 Dispatcher Service 项目中,按照以下方式更新 application.yml 文件。

列表 10.14 配置 Cloud Stream 绑定和 RabbitMQ 目的地

spring:
  cloud:
    function:
      definition: pack|label
    stream: 
      bindings:                               ❶
        packlabel-in-0:                       ❷
          destination: order-accepted         ❸
          group: ${spring.application.name}   ❹
        packlabel-out-0:                      ❺
          destination: order-dispatched       ❻

❶ 配置目的地绑定的部分

❷ 输入绑定

❸ 在代理中实际绑定的名称(RabbitMQ 中的交换)

❹ 对目的地(与应用程序名称相同)感兴趣的消费者组

❺ 输出绑定

❻ 在代理中实际绑定的名称(RabbitMQ 中的交换)

输出绑定(packlabel-out-0)将被映射到 RabbitMQ 中的 order-dispatched 交换。输入绑定(packlabel-in-0)将被映射到 order-accepted 交换和 order-accepted.dispatcher-service 队列在 RabbitMQ 中。如果它们在 RabbitMQ 中尚未存在,绑定器将创建它们。队列命名策略(.)包括一个名为消费者组的参数。

消费者组 的概念是从 Kafka 借用的,非常有用。在标准的发布/订阅模型中,所有消费者都会接收到发送到他们订阅队列的消息副本。当不同的应用程序需要处理消息时,这很方便。但在云原生环境中,由于为了扩展和弹性,应用程序的多个实例同时运行,这可能会成为一个问题。如果你有大量的 Dispatcher Service 实例,你不想所有实例都从它们那里分发订单。这会导致错误和不一致的状态。

消费者组解决了这个问题。同一组中的所有消费者共享一个单独的订阅。因此,到达他们订阅的队列的每条消息都只由一个消费者处理。假设我们有两个应用程序(Dispatcher Service 和 Mail Service)对接收已接受订单的事件感兴趣,并且以复制的方式部署。使用应用程序名称来配置消费者组,我们可以确保每个事件都由 Dispatcher Service 的单个实例和 Mail Service 的单个实例接收和处理,如图 10.11 所示。

10-11

图 10.11 消费者组确保同一组内只有单个消费者接收和处理每条消息。

探索 RabbitMQ 中的交换机和队列

在通过 Spring Cloud Stream 配置了与 RabbitMQ 的集成之后,现在是时候尝试运行 Dispatcher Service 了。

首先,启动一个 RabbitMQ 容器。打开一个终端窗口,导航到你的 polar-deployment 仓库中保存 docker-compose.yml 文件的文件夹(polar-deployment/docker),并运行以下命令:

$ docker-compose up -d polar-rabbitmq

然后打开另一个终端窗口,导航到 Dispatcher Service 项目的根目录(dispatcher-service),并按以下方式运行应用程序:

$ ./gradlew bootRun

应用程序日志已经为你提供了发生事件的线索,但为了更清晰地理解,让我们检查 RabbitMQ 管理控制台(通过端口 15672 暴露)。

打开一个浏览器窗口,导航到 http://localhost:15672。凭证与我们在 Docker Compose 中定义的相同(用户/密码)。然后转到交换机部分。图 10.12 显示了 RabbitMQ 提供的默认交换机以及我们应用程序生成的两个交换机:order-accepted 和 order-dispatched。Spring Cloud Stream 将它们分别映射到 packlabel-in-0 和 packlabel-out-0 绑定。交换机是持久的(在管理控制台中用 D 图标表示),这意味着它们将在代理重启后继续存在。

10-12

图 10.12 Spring Cloud Stream 将两个目标绑定映射到 RabbitMQ 中的两个交换机。

接下来,让我们看看队列。在 Dispatcher Service 中,我们配置了一个 packlabel-in-0 绑定和一个消费者组。这是应用程序的唯一输入通道,因此应该只有一个队列。让我们来验证一下。在 RabbitMQ 管理控制台中,如图 10.13 所示,你可以在队列部分看到一个持久的 order-accepted.dispatcher-service 队列。

10-13

图 10.13 Spring Cloud Stream 将每个输入绑定映射到一个队列,队列名称根据配置的消费者组命名。

注意:由于没有消费者订阅,尚未为 packlabel-out-0 绑定创建队列。稍后你将看到在配置 Order Service 以监听它之后,将创建一个队列。

我们可以通过手动向订单接受交换机发送消息来验证集成是否正常工作。如果一切配置正确,调度服务将从 order-accepted.dispatcher-service 队列中读取消息,通过组合函数 pack|label 进行处理,最终将其发送到订单调度交换机。

再次转到交换机部分,选择订单接受交换机,在发布消息面板中,插入一个 JSON 格式的 OrderAcceptedMessage 对象,如图 10.14 所示。完成操作后,点击发布消息按钮。

10-14

图 10.14 你可以通过向订单接受交换机发送消息来触发调度服务中的数据流。

在应用程序日志中,你应该看到以下消息,表明数据流发生正确:

...c.p.d.DispatchingFunctions: The order with id 394 is packed.
...c.p.d.DispatchingFunctions: The order with id 394 is labeled.

输出消息已经发送到订单调度交换机,但由于没有消费者订阅,它尚未被路由到任何队列。在本章的最后部分,我们将通过在订单服务中定义一个供应商来发布消息到订单接受交换机,以及定义一个消费者来读取订单调度队列中的消息,从而完成流程。但在那之前,让我们添加一些测试来验证与 Spring Cloud Stream 绑定器的集成。

在继续之前,使用 Ctrl-C 停止应用程序进程,并使用 docker-compose down 停止 RabbitMQ 容器。

10.4.3 使用测试绑定器编写集成测试

如我多次强调的那样,Spring Cloud Function 和 Spring Cloud Stream 的整个哲学是保持应用程序的业务逻辑基础设施和中间件的中立性。在定义了原始的 pack()和 label()函数之后,我们所做的就是更新 Gradle 中的依赖关系和修改 application.yml 中的配置。

有一个很好的主意是编写覆盖业务逻辑的单元测试,与框架无关。但添加一些集成测试来覆盖 Spring Cloud Stream 上下文中应用程序的行为也是值得的。你应该禁用之前在 DispatchingFunctionsIntegrationTests 类中编写的集成测试,因为你现在想测试与外部消息系统的集成。

该框架提供了一个专门用于实现集成测试的绑定器,该测试侧重于业务逻辑而不是中间件。让我们看看它是如何工作的,以调度服务为例。

注意:Spring Cloud Stream 提供的测试绑定器旨在验证与一个技术无关的目标绑定器的正确配置和集成。如果你想针对特定的代理(在我们的例子中,将是 RabbitMQ)测试应用程序,你可以依赖 Testcontainers,正如你在上一章中学到的。我将把这个留给你作为练习。

首先,在 Dispatcher Service 项目的 build.gradle 文件中添加对测试绑定的依赖项。与迄今为止我们一直在工作的其他依赖项不同,测试绑定需要更复杂的语法来包含。有关更多信息,请参阅 Spring Cloud Stream 文档(spring.io/projects/spring-cloud-stream)。请记住,在添加新依赖项后刷新或重新导入 Gradle 依赖项。

列表 10.15 在 Dispatcher Service 中添加测试绑定的依赖项

dependencies {
  ...
  testImplementation("org.springframework.cloud:spring-cloud-stream") { 
    artifact { 
      name = "spring-cloud-stream" 
      extension = "jar" 
      type ="test-jar" 
      classifier = "test-binder" 
    } 
  } 
}

接下来,创建一个新的 FunctionsStreamIntegrationTests 类进行测试。测试设置包括三个步骤:

  1. 导入提供测试绑定配置的 TestChannelBinderConfiguration 类。

  2. 注入一个表示输入绑定包标签-in-0 的 InputDestination Bean(默认情况下,因为它只有一个)。

  3. 注入一个表示输出绑定包标签-out-0 的 OutputDestination Bean(默认情况下,因为它只有一个)。

数据流基于 Message 对象(来自 org.springframework.messaging 包)。当运行应用程序时,框架会为您透明地处理类型转换。然而,在这种类型的测试中,您需要明确提供 Message 对象。您可以使用 MessageBuilder 创建输入消息,并使用 ObjectMapper 实用工具执行用于在代理中存储消息有效载荷的二进制格式与类型之间的转换。

列表 10.16 测试与外部消息系统的集成

package com.polarbookshop.dispatcherservice;

import java.io.IOException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cloud.stream.binder.test.InputDestination;
import org.springframework.cloud.stream.binder.test.OutputDestination;
import org.springframework.cloud.stream.binder.test.
➥TestChannelBinderConfiguration;
import org.springframework.context.annotation.Import;
import org.springframework.integration.support.MessageBuilder;
import org.springframework.messaging.Message;
import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@Import(TestChannelBinderConfiguration.class)              ❶
class FunctionsStreamIntegrationTests {

  @Autowired
  private InputDestination input;                          ❷

  @Autowired
  private OutputDestination output;                        ❸

  @Autowired
  private ObjectMapper objectMapper;                       ❹

  @Test
  void whenOrderAcceptedThenDispatched() throws IOException {
    long orderId = 121;
    Message<OrderAcceptedMessage> inputMessage = MessageBuilder
      .withPayload(new OrderAcceptedMessage(orderId)).build();
    Message<OrderDispatchedMessage> expectedOutputMessage = MessageBuilder
      .withPayload(new OrderDispatchedMessage(orderId)).build();

    this.input.send(inputMessage);                         ❺
    assertThat(objectMapper.readValue(output.receive().getPayload(),
      OrderDispatchedMessage.class))
      .isEqualTo(expectedOutputMessage.getPayload());      ❻
  }
}

❶ 配置测试绑定

❷ 表示输入绑定包标签-in-0

❸ 表示输出绑定包标签-out-0

❹ 使用 Jackson 将 JSON 消息有效载荷反序列化为 Java 对象

❺ 向输入通道发送消息

❻ 接收并断言来自输出通道的消息

警告:如果您使用 IntelliJ IDEA,可能会收到一个警告,指出 InputDestination、OutputDestination 和 ObjectMapper 无法自动装配。不要担心,这是一个误报。您可以通过在字段上注解@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")来消除警告。

消息代理如 RabbitMQ 处理二进制数据,因此通过它们流动的任何数据在 Java 中都被映射到 byte[]。字节和 DTO 之间的转换由 Spring Cloud Stream 透明处理。但是,就像消息一样,在这个测试场景中,我们需要明确处理从输出通道接收到的消息的内容。

在编写集成测试后,打开一个终端窗口,导航到 Dispatcher Service 项目的根目录,并运行测试:

$ ./gradlew test --tests FunctionsStreamIntegrationTests

下一个部分将讨论关于与消息系统进行弹性集成时需要考虑的一些要点。

10.4.4 使消息对失败具有弹性

事件驱动的架构解决了影响同步请求/响应交互的一些问题。例如,如果你消除了应用程序之间的时间耦合,你就不需要采用像断路器这样的模式,因为通信将是异步的。如果消费者在生产者发送消息时暂时不可用,这无关紧要。消费者一旦恢复运行,就会收到消息。

在软件工程中,没有银弹。每件事都有代价。一方面,解耦的应用程序可以更独立地运行。另一方面,你在系统中引入了一个新的组件,需要部署和维护:消息代理。

假设这部分由平台处理,作为应用程序开发者,你仍然有一些事情要做。当发生事件并且你的应用程序想要发布消息时,可能会出错。重试和超时仍然很有帮助,但这次我们将使用它们来使应用程序和代理之间的交互更具弹性。Spring Cloud Stream 默认使用指数退避策略的 retry 模式,依赖于 Spring Retry 库来处理命令式消费者,以及 retryWhen() Reactor 操作符来处理响应式消费者(你在第八章中学到的)。像往常一样,你可以通过配置属性来自定义它。

Spring Cloud Stream 定义了几个默认值来提高交互的弹性,包括错误通道和优雅关闭。你可以配置消息处理的各个方面,包括死信队列、确认流和错误时的消息重新发布。

RabbitMQ 本身就有几个功能来提高可靠性和弹性。其中之一是保证每条消息至少被投递一次。请注意,你的应用程序中的消费者可能会收到相同的消息两次,因此你的业务逻辑应该知道如何识别和处理重复项。

我不会进一步深入细节,因为这是一个广泛的主题,需要几个专门的章节才能充分涵盖。相反,我鼓励你阅读涉及你事件驱动架构的不同项目的文档:RabbitMQ (rabbitmq.com)、Spring AMQP (spring.io/projects/spring-amqp) 和 Spring Cloud Stream (spring.io/projects/spring-cloud-stream)。你还可以查看 Sam Newman 在《Building Microservices》(O’Reilly,2021)和 Chris Richardson 的《Microservices Patterns》(Manning,2018)中描述的事件驱动模式。

在本章的最后部分,你将与供应商和消费者合作,完成 Polar 书店系统的订单流程。

10.5 使用 Spring Cloud Stream 生产和消费消息

在前面的章节中,你学习了函数式编程范式以及它是如何适应 Spring 生态系统的,使用了 Spring Cloud Function 和 Spring Cloud Stream。本节最后将指导你实现生产者和消费者。

正如你所见,消费者与你在分发服务中编写的函数并没有太大的不同。另一方面,生产者略有不同,因为与函数和消费者不同,它们不是自然激活的。我将向你展示如何在订单服务中同时使用它们,以实现 Polar Bookshop 系统订单流程的最后部分。

10.5.1 实现事件消费者,以及幂等性问题

我们之前构建的分发服务应用程序在订单派发时会产生消息。当这种情况发生时,订单服务应该被通知,以便它可以更新数据库中的订单状态。

首先,打开你的订单服务项目(order-service),并在 build.gradle 文件中添加对 Spring Cloud Stream 和测试绑定的依赖。记得在添加新依赖后刷新或重新导入 Gradle 依赖。

列表 10.17 为 Spring Cloud Stream 和测试绑定添加依赖

dependencies {
  ...
  implementation 'org.springframework.cloud: 
  ➥ spring-cloud-stream-binder-rabbit' 
  testImplementation("org.springframework.cloud:spring-cloud-stream") { 
    artifact { 
      name = "spring-cloud-stream" 
      extension = "jar" 
      type ="test-jar" 
      classifier = "test-binder" 
    } 
  } 
}

接下来,我们需要为 Order Service 想要监听的事件建模。创建一个新的 com.polarbookshop.orderservice.order.event 包,并添加一个 OrderDispatchedMessage 类来保存派发订单的标识符。

列表 10.18 表示订单派发事件的 DTO

package com.polarbookshop.orderservice.order.event;

public record OrderDispatchedMessage (
  Long orderId
){}

现在我们将使用函数式方法实现业务逻辑。创建一个 OrderFunctions 类(com.polarbookshop.orderservice.order.event 包),并实现一个函数来消费分发服务应用程序在订单派发时产生的消息。该函数将是一个 Consumer,负责监听传入的消息并相应地更新数据库实体。消费者对象是带有输入但没有输出的函数。为了保持函数的简洁和可读性,我们将 OrderDispatchedMessage 对象的处理移动到 OrderService 类(我们将在下一分钟实现)。

列表 10.19 从 RabbitMQ 消费消息

package com.polarbookshop.orderservice.order.event;

import java.util.function.Consumer;
import com.polarbookshop.orderservice.order.domain.OrderService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class OrderFunctions {

  private static final Logger log =
    LoggerFactory.getLogger(OrderFunctions.class);

  @Bean
  public Consumer<Flux<OrderDispatchedMessage>> dispatchOrder(
    OrderService orderService
  ) {
    return flux ->
      orderService.consumeOrderDispatchedEvent(flux)                      ❶
        .doOnNext(order -> log.info("The order with id {} is dispatched",
          order.id()))                                                    ❷
        .subscribe();                                                     ❸
  }
}

❶ 对于每个派发的消息,它会在数据库中更新相关的订单。

❷ 对于数据库中更新的每个订单,它会记录一条消息。

❸ 订阅反应式流以激活它。如果没有订阅者,则没有数据通过流传输。

Order Service 是一个响应式应用程序,因此 dispatchOrder 函数将作为响应式流(OrderDispatchedMessage 的 Flux)消费消息。响应式流仅在存在订阅者感兴趣接收数据时才会被激活。因此,我们通过订阅它来结束响应式流至关重要,否则将不会处理任何数据。在之前的示例中,订阅部分由框架透明处理(例如,当使用响应式流通过 REST 端点返回数据或向后端服务发送数据时)。在这种情况下,我们必须使用 subscribe() 子句显式地执行此操作。

接下来,让我们在 OrderService 类中实现 consumeOrderDispatchedMessageEvent() 方法,以便在订单派发后更新数据库中现有订单的状态。

列表 10.20 实现更新订单为派发状态的逻辑

@Service
public class OrderService {
  ...

  public Flux<Order> consumeOrderDispatchedEvent(
    Flux<OrderDispatchedMessage> flux
  ) {
    return flux                                       ❶
      .flatMap(message ->
        orderRepository.findById(message.orderId()))  ❷
      .map(this::buildDispatchedOrder)                ❸
      .flatMap(orderRepository::save);                ❹
  }

  private Order buildDispatchedOrder(Order existingOrder) {
    return new Order(                                 ❺
      existingOrder.id(),
      existingOrder.bookIsbn(),
      existingOrder.bookName(),
      existingOrder.bookPrice(),
      existingOrder.quantity(),
      OrderStatus.DISPATCHED,
      existingOrder.createdDate(),
      existingOrder.lastModifiedDate(),
      existingOrder.version()
    );
  }
}

❶ 接受一个包含 OrderDispatchedMessage 对象的响应式流作为输入

❷ 对于流中发出的每个对象,它从数据库中读取相关的订单。

❸ 更新订单为“已派发”状态

❹ 将更新后的订单保存到数据库中

❺ 给定一个订单,它返回一个具有“已派发”状态的新记录。

当消息到达队列时,消费者会被触发。RabbitMQ 提供了至少一次投递的保证,因此你需要注意可能的重复。我们实现的代码更新特定订单的状态为 DISPATCHED,这个操作可以多次执行并得到相同的结果。由于该操作是幂等的,代码对重复具有容错性。进一步的优化是检查状态,如果已经派发则跳过更新操作。

最后,我们需要在 application.yml 文件中配置 Spring Cloud Stream,以便将 dispatchOrder-in-0 绑定(从 dispatchOrder 函数名称推断)映射到 RabbitMQ 中的 order-dispatched 交换机。同时,请记住将 dispatchOrder 定义为 Spring Cloud Function 应该管理的函数,以及与 RabbitMQ 的集成。

列表 10.21 配置 Cloud Stream 绑定和 RabbitMQ 集成

spring:
  cloud: 
    function: 
      definition: dispatchOrder               ❶
    stream: 
      bindings: 
        dispatchOrder-in-0:                   ❷
          destination: order-dispatched       ❸
          group: {spring.application.name}    ❹
  rabbitmq:                                   ❺
    host: localhost 
    port: 5672 
    username: user 
    password: password 
    connection-timeout: 5s 

❶ Spring Cloud Function 管理的函数定义

❷ 输入绑定

❸ 绑定器绑定到代理的实际名称(RabbitMQ 中的交换机)

❹ 对该目的地感兴趣的消费群体(与应用程序名称相同)

❺ 配置与 RabbitMQ 的集成

如你所见,它的工作方式与 Dispatcher Service 中的函数相同。Order Service 中的消费者将成为 order-service 消费者群体的一部分,Spring Cloud Stream 将在它们之间定义一个消息通道,以及 RabbitMQ 中的 order-dispatched.order-service 队列。

接下来,我们将通过定义一个负责触发整个流程的供应商来完成订单流程。

10.5.2 实现事件生产者,以及原子性问题

Suppliers 是消息源。当事件发生时,它们产生消息。在 Order Service 中,供应商应在订单被接受时通知感兴趣的各方(在这种情况下,Dispatcher Service)。与函数和消费者不同,供应商需要被激活。它们仅在调用时才起作用。

Spring Cloud Stream 提供了几种定义供应商的方法,以覆盖不同的场景。在我们的案例中,事件源不是一个消息代理,而是一个 REST 端点。当用户向 Order Service 发送 POST 请求以购买书籍时,我们希望发布一个事件,指示订单是否已被接受。

让我们先以 DTO 的形式建模这个事件。它将与我们在 Dispatcher Service 中使用的 OrderAcceptedMessage 记录相同。将记录添加到您的 Order Service 项目(order-service)中的 com.polarbookshop.orderservice.order.event 包中。

列表 10.22 代表订单接受事件的 DTO

package com.polarbookshop.orderservice.order.event;

public record OrderAcceptedMessage (
  Long orderId
){}

我们可以使用允许我们强制性地将数据发送到特定目标的 StreamBridge 对象,将 REST 层与应用程序的流部分桥接起来。让我们分解这个新功能。首先,我们可以实现一个方法,该方法接受一个 Order 对象作为输入,验证它是否已被接受,构建一个 OrderAcceptedMessage 对象,并使用 StreamBridge 将其发送到 RabbitMQ 目标。

打开 OrderService 类,自动装配一个 StreamBridge 对象,并定义一个新的 publishOrderAcceptedEvent 方法。

列表 10.23 实现将事件发布到目标地的逻辑

package com.polarbookshop.orderservice.order.domain;

import com.polarbookshop.orderservice.book.BookClient;
import com.polarbookshop.orderservice.order.event.OrderAcceptedMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.stream.function.StreamBridge;
import org.springframework.stereotype.Service;
...

@Service
public class OrderService {
  private static final Logger log = 
    LoggerFactory.getLogger(OrderService.class); 

  private final BookClient bookClient;
  private final OrderRepository orderRepository;
  private final StreamBridge streamBridge; 

  public OrderService(BookClient bookClient,
    StreamBridge streamBridge, OrderRepository orderRepository
  ) {
    this.bookClient = bookClient;
    this.orderRepository = orderRepository;
    this.streamBridge = streamBridge; 
  }

  ...

 private void publishOrderAcceptedEvent(Order order) {
 if (!order.status().equals(OrderStatus.ACCEPTED)) {
 return; ❶
 }
 var orderAcceptedMessage =
 new OrderAcceptedMessage(order.id()); ❷
 log.info("Sending order accepted event with id: {}", order.id());
 var result = streamBridge.send("acceptOrder-out-0",
 orderAcceptedMessage); ❸
 log.info("Result of sending data for order with id {}: {}",
 order.id(), result);
 }
}

❶ 如果订单未被接受,它将不执行任何操作。

❷ 构建一条消息以通知订单已被接受

❸ 明确发送消息到 acceptOrder-out-0 绑定

由于数据源是一个 REST 端点,我们无法在 Spring Cloud Function 中注册 Supplier bean,因此没有触发框架创建与 RabbitMQ 所需绑定的框架。然而,在列表 10.23 中,StreamBridge 被用来将数据发送到 acceptOrder-out-0 绑定。它从哪里来?没有 acceptOrder 函数!

在启动时,Spring Cloud Stream 会注意到 StreamBridge 想要通过 acceptOrder-out-0 绑定发布消息,并且它会自动创建一个。类似于从函数创建的绑定,我们可以在 RabbitMQ 中配置目标名称。打开 application.yml 文件,并按以下方式配置绑定。

列表 10.24 配置 Cloud Stream 输出绑定

spring:
  cloud:
    function:
      definition: dispatchOrder
    stream:
      bindings:
        dispatchOrder-in-0:
          destination: order-dispatched
          group: ${spring.application.name}
        acceptOrder-out-0:                     ❶
          destination: order-accepted          ❷

❶ 由 StreamBridge 创建和管理输出绑定

❷ 绑定器绑定的实际名称(RabbitMQ 中的交换机)

现在剩下的只是当提交的订单被接受时调用该方法。这是一个关键点,也是表征 saga 模式 的一个方面,saga 模式是微服务架构中分布式事务的一个流行替代方案。为了确保系统的一致性,必须在数据库中持久化订单并发送关于它的消息,这两个操作必须原子性地完成。要么两个操作都成功,要么它们都必须失败。确保原子性的简单而有效的方法是将这两个操作包装在一个本地事务中。为此,我们可以依赖内置的 Spring 事务管理功能。

注意:saga 模式在 Chris Richardson 的书《微服务模式》的第四章中进行了详细描述(Manning,2018;livebook.manning.com/book/microservices-patterns/chapter-4)。如果您对设计跨多个应用程序的业务事务感兴趣,我建议您查看它。

在 OrderService 类中,修改 submitOrder() 方法以调用 publishOrderAcceptedEvent 方法,并使用 @Transactional 注解。

列表 10.25 使用数据库和事件代理定义 saga 事务

@Service
public class OrderService {
  ...

  @Transactional                                               ❶
  public Mono<Order> submitOrder(String isbn, int quantity) {
    return bookClient.getBookByIsbn(isbn)
      .map(book -> buildAcceptedOrder(book, quantity))
      .defaultIfEmpty(buildRejectedOrder(isbn, quantity))
      .flatMap(orderRepository::save)                          ❷
      .doOnNext(this::publishOrderAcceptedEvent);              ❸
  }

  private void publishOrderAcceptedEvent(Order order) {
    if (!order.status().equals(OrderStatus.ACCEPTED)) {
      return;
    }
    var orderAcceptedMessage = new OrderAcceptedMessage(order.id());
    log.info("Sending order accepted event with id: {}", order.id());
    var result = streamBridge.send("acceptOrder-out-0",
      orderAcceptedMessage);
    log.info("Result of sending data for order with id {}: {}",
      order.id(), result);
  }
}

❶ 在本地事务中执行方法

❷ 在数据库中保存订单

❸ 如果订单被接受则发布事件

Spring Boot 默认配置了事务管理功能,可以处理涉及关系数据库的事务性操作(如您在第五章中学到的)。然而,与消息生产者建立的 RabbitMQ 通道默认不是事务性的。为了使事件发布操作加入现有事务,我们需要在 application.yml 文件中启用 RabbitMQ 的消息生产者的事务性支持。

列表 10.26 配置输出绑定为事务性

spring:
  cloud:
    function:
      definition: dispatchOrder
    stream:
      bindings:
        dispatchOrder-in-0:
          destination: order-dispatched
          group: ${spring.application.name}
        acceptOrder-out-0:
          destination: order-accepted
      rabbit:                            ❶
        bindings: 
          acceptOrder-out-0: 
            producer: 
              transacted: true           ❷

❶ RabbitMQ 特定的 Spring Cloud Stream 绑定配置

❷ 使 acceptOrder-out-0 绑定事务性

现在,您可以编写针对供应商和消费者的新集成测试,就像我们在调度服务的函数中做的那样。我将自动测试留给您,因为您现在有了必要的工具。如果您需要灵感,请查看本书附带源代码(第十章/10-end/order-service)。

您还需要在现有的 OrderServiceApplicationTests 类中导入测试绑定器的配置(@Import(TestChannelBinderConfiguration.class))以使其工作。

我们已经愉快地穿越了事件驱动模型、函数和消息系统。在结束之前,让我们看看订单流程的实际操作。首先,启动 RabbitMQ、PostgreSQL(docker-compose up -d polar-rabbitmq polar-postgres)和调度服务(./gradlew bootRun)。然后运行目录服务和订单服务(./gradlew bootRun 或在构建镜像后从 Docker Compose 运行)。

一旦所有这些服务都启动并运行,向目录中添加一本新书:

$ http POST :9001/books author="Jon Snow" \
    title="All I don't know about the Arctic" isbn="1234567897" \
    price=9.90 publisher="Polarsophia"

然后订购三本该书:

$ http POST :9002/orders isbn=1234567897 quantity=3

如果您订购了存在的书籍,订单将被接受,并且订单服务将发布一个 OrderAcceptedEvent 消息。订阅了相同事件的分发服务将处理订单并发布一个 OrderDispatchedEvent 消息。订单服务将收到通知并更新数据库中的订单状态。

提示:您可以通过检查订单服务和分发服务的应用程序日志来跟踪消息流。

现在是检验真伪的时刻。从订单服务获取订单:

$ http :9002/orders

状态应为已分发:

{
  "bookIsbn": "1234567897",
  "bookName": "All I don't know about the Arctic - Jon Snow",
  "bookPrice": 9.9,
  "createdDate": "2022-06-06T19:40:33.426610Z",
  "id": 1,
  "lastModifiedDate": "2022-06-06T19:40:33.866588Z",
  "quantity": 3,
  "status": "DISPATCHED",
  "version": 2
}

确实如此。做得好!当您完成系统测试后,停止所有应用程序(Ctrl-C)和 Docker 容器(docker-compose down)。

这就完成了 Polar 书店系统业务逻辑的主要实现。下一章将介绍使用 Spring Security、OAuth 2.1 和 OpenID Connect 为云原生应用程序提供的安全性。

Polar Labs

随意应用您在前几章中学到的知识,并为部署准备分发服务应用程序。

  1. 将 Spring Cloud Config Client 添加到分发服务中,使其能够从配置服务获取配置数据。

  2. 配置云原生构建包集成,容器化应用程序,并定义部署管道的提交阶段。

  3. 编写部署和服务清单,以便将分发服务部署到 Kubernetes 集群。

  4. 配置 Tilt 以自动化将分发服务部署到使用 minikube 初始化的本地 Kubernetes 集群。

然后更新 Docker Compose 规范和 Kubernetes 清单,以配置订单服务的 RabbitMQ 集成。

您可以参考书中附带的代码仓库中的 Chapter10/10-end 文件夹来检查最终结果 (github.com/ThomasVitale/cloud-native-spring-in-action)。使用 kubectl apply -f services 从 Chapter10/10-end/polar-deployment/kubernetes/platform/development 文件夹中的清单部署支持服务。

摘要

  • 事件驱动架构是相互交互的分布式系统,通过产生和消费事件进行交互。

  • 事件是在系统中发生的相关事情。

  • 在 pub/sub 模型中,生产者发布事件,这些事件被发送到所有订阅者进行消费。

  • 事件处理平台,如 RabbitMQ 和 Kafka,负责从生产者收集事件,路由并将它们分发到感兴趣的消费者。

  • 在 AMQP 协议中,生产者将消息发送到代理中的交换机,该交换机根据特定的路由算法将它们转发到队列。

  • 在 AMQP 协议中,消费者从代理中的队列接收消息。

  • 在 AMQP 协议中,消息是由键/值属性和二进制有效负载组成的数据结构。

  • RabbitMQ 是一个基于 AMQP 协议的消息代理,您可以使用它来实现基于 pub/sub 模型的事件驱动架构。

  • RabbitMQ 提供了高可用性、弹性和数据复制。

  • Spring Cloud Function 允许你使用标准的 Java Function、Supplier 和 Consumer 接口实现你的业务逻辑。

  • Spring Cloud Function 将你的函数封装并提供了一些令人兴奋的功能,如透明的类型转换和函数组合。

  • 在 Spring Cloud Function 的上下文中实现的函数可以通过不同的方式公开和集成到外部系统中。

  • 函数可以作为 REST 端点公开,打包并在 FaaS 平台上作为无服务器应用程序(Knative、AWS Lambda、Azure Function、Google Cloud Functions)部署,或者它们可以绑定到消息通道。

  • 基于 Spring Cloud Function 构建的 Spring Cloud Stream 为你提供了所有必要的管道,以将你的函数与外部消息系统(如 RabbitMQ 或 Kafka)集成。

  • 一旦你实现了你的函数,你不需要对你的代码进行任何更改。你只需要添加对 Spring Cloud Stream 的依赖,并配置它以适应你的需求。

  • 在 Spring Cloud Stream 中,目标绑定器提供了与外部消息系统的集成。

  • 在 Spring Cloud Stream 中,目标绑定(输入和输出)通过消息代理(如 RabbitMQ)中的交换和队列将你的应用程序中的生产者和消费者连接起来。

  • 当新消息到达时,函数和消费者会自动激活。

  • 供应商需要被显式激活,例如通过显式地向目标绑定发送消息。


^(1.)参见 Sam Newman 的《单体到微服务》(O’Reilly,2019)。

11 安全:身份验证和 SPA

本章涵盖

  • 理解 Spring Security 基础知识

  • 使用 Keycloak 管理用户账户

  • 与 OpenID Connect、JWT 和 Keycloak 一起工作

  • 使用 Spring Security 和 OpenID Connect 验证用户

  • 测试 Spring Security 和 OpenID Connect

安全性是 Web 应用程序中最关键的因素之一,而且如果处理不当,可能产生最灾难性的影响。出于教育目的,我现在才介绍这个主题。在现实世界的场景中,我建议在每个新项目或特性的开始时就考虑安全性,并且直到应用程序退役都不要放弃。

访问控制系统允许用户仅在证明其身份并拥有所需权限时访问资源。为了实现这一点,我们需要遵循三个关键步骤:识别、身份验证和授权。

  1. 识别发生在用户(人类或机器)声称一个身份的时候。在物理世界中,那是我通过说出我的名字来介绍自己的时候。在数字世界中,我会通过提供我的用户名或电子邮件地址来做这件事。

  2. 身份验证是通过护照、驾照、密码、证书或令牌等要素来验证用户声明的身份。当使用多个要素来验证用户身份时,我们谈论的是 多因素身份验证

  3. 授权总是在身份验证之后发生,并检查用户在特定上下文中被允许做什么。

本章和下一章将涵盖在云原生应用程序中实现访问控制系统。您将了解如何向类似 Polar Bookshop 这样的系统添加身份验证,并使用像 Keycloak 这样的专用身份和访问管理解决方案。我将向您展示如何使用 Spring Security 来保护应用程序并采用 JWT、OAuth2 和 OpenID Connect 等标准。在这个过程中,您还将向系统中添加一个 Angular 前端,并学习涉及单页应用程序(SPA)时的安全最佳实践。

注意:本章示例的源代码可在 Chapter11/11-begin 和 Chapter11/11-end 文件夹中找到,这些文件夹包含项目的初始状态和最终状态 (github.com/ThomasVitale/cloud-native-spring-in-action)。

11.1 理解 Spring Security 基础知识

Spring Security (spring.io/projects/spring-security) 是保护 Spring 应用程序的既定标准,支持命令式和响应式堆栈。它提供身份验证和授权功能,以及防止最常见的攻击。

该框架通过依赖过滤器提供其主要功能。让我们考虑一个为 Spring Boot 应用程序添加身份验证的可能需求。用户应能够通过登录表单使用用户名和密码进行身份验证。当我们配置 Spring Security 以启用此功能时,框架会添加一个拦截任何传入 HTTP 请求的过滤器。如果用户已经通过验证,它将请求发送到给定的 Web 处理器,例如一个@RestController 类。如果用户未通过验证,它将用户转发到登录页面并提示输入用户名和密码。

注意:在命令式 Spring 应用程序中,过滤器实现为一个 Servlet Filter 类。在反应式应用程序中,使用 WebFilter 类。

大多数 Spring Security 功能在启用时都通过过滤器处理。框架建立了一个按良好定义和合理顺序执行的过滤器链。例如,处理身份验证的过滤器在检查授权的过滤器之前运行,因为我们不能在知道用户是谁之前验证用户的权限。

让我们从基本示例开始,以更好地理解 Spring Security 的工作原理。我们希望向 Polar Bookshop 系统添加身份验证。由于 Edge Service 是入口点,因此在那里处理像安全这样的横切关注点是有意义的。用户应能够通过登录表单使用用户名和密码进行身份验证。

首先,在 Edge Service 项目的 build.gradle 文件中添加一个新的 Spring Security 依赖项(edge-service)。请记住,在添加新依赖项后刷新或重新导入 Gradle 依赖项。

列表 11.1 在 Edge Service 中添加 Spring Security 依赖项

dependencies {
  ...
  implementation 'org.springframework.boot:spring-boot-starter-security' 
}

在 Spring Security 中定义和配置安全策略的中心位置是一个 SecurityWebFilterChain bean。该对象告诉框架哪些过滤器应该启用。您可以通过 ServerHttpSecurity 提供的 DSL 构建 SecurityWebFilterChain bean。

目前,我们希望遵守以下要求:

  • Edge Service 公开的所有端点都必须需要用户身份验证。

  • 身份验证必须通过登录表单页面进行。

要收集所有与安全相关的配置,在新的 SecurityConfig 类(com.polarbookshop.edgeservice.config 包)中创建一个 SecurityWebFilterChain bean:

@Bean         ❶
SecurityWebFilterChain springSecurityFilterChain(
  ServerHttpSecurity http
) {}

❶ 使用 SecurityWebFilterChain bean 定义和配置应用程序的安全策略。

由 Spring 自动注入的 ServerHttpSecurity 对象提供了一个方便的 DSL(领域特定语言),用于配置 Spring Security 并构建 SecurityWebFilterChain bean。使用 authorizeExchange(),您可以定义任何请求(在反应式 Spring 中称为exchange)的访问策略。在这种情况下,我们希望所有请求都需要身份验证(authenticated()):

@Bean
SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
  return http 
    .authorizeExchange(exchange -> 
      exchange.anyExchange().authenticated())    ❶
    .build(); 
}

❶ 所有请求都需要身份验证。

Spring Security 提供了多种身份验证策略,包括 HTTP 基本身份验证、登录表单、SAML 和 OpenID Connect。对于这个示例,我们想使用登录表单策略,我们可以通过 ServerHttpSecurity 对象公开的 formLogin()方法来启用它。我们将使用默认配置(通过 Spring Security Customizer 接口可用),该配置包括一个由框架提供的登录页面,并在请求未进行身份验证时自动重定向到该页面:

@Bean
SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
  return http
    .authorizeExchange(exchange -> exchange.anyExchange().authenticated())
    .formLogin(Customizer.withDefaults())      ❶
    .build();
}

❶ 通过登录表单启用用户身份验证

接下来,使用@EnableWebFluxSecurity 注解 SecurityConfig 类以启用 Spring Security WebFlux 支持。最终的安全配置如下所示。

列表 11.2 通过登录表单要求所有端点进行身份验证

package com.polarbookshop.edgeservice.config;

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.reactive.
➥EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;

@EnableWebFluxSecurity
public class SecurityConfig {

  @Bean
  SecurityWebFilterChain springSecurityFilterChain(
    ServerHttpSecurity http
  ) {
    return http
      .authorizeExchange(exchange ->
        exchange.anyExchange().authenticated())    ❶
      .formLogin(Customizer.withDefaults())        ❷
      .build();
  }
}

❶ 所有请求都需要身份验证。

❷ 通过登录表单启用用户身份验证

让我们验证它是否正确工作。首先,启动边缘服务所需的 Redis 容器。打开一个终端窗口,导航到您保存 Docker Compose 文件(polar-deployment/docker/docker-compose.yml)的文件夹,并运行以下命令:

$ docker-compose up -d polar-redis

然后运行边缘服务应用程序(./gradlew bootRun),打开一个浏览器窗口,并转到 http://localhost:9000/books。您应该被重定向到由 Spring Security 提供的登录页面,在那里您可以进行身份验证。

等一下!我们如何在系统中没有定义用户的情况下进行身份验证?默认情况下,Spring Security 在内存中定义了一个用户账户,用户名为 user,密码是随机生成的,并在应用程序日志中打印出来。您应该查找如下日志条目:

Using generated security password: ee60bdf6-fb82-439a-8ed0-8eb9d47bae08

您可以使用 Spring Security 创建的预定义用户账户进行身份验证。身份验证成功后,您将被重定向到/books 端点。由于目录服务不可用,并且边缘服务在查询书籍时有一个回退方法来返回空列表(在第九章中实现),您将看到一个空白页面。这是预期的。

注意:我建议您从现在开始每次测试应用程序时都打开一个新的无痕浏览器窗口。由于您将尝试不同的安全场景,无痕模式将防止您遇到与浏览器缓存和之前会话的 cookie 相关的问题。

这个测试的关键点是用户尝试访问 Edge Service 提供的受保护端点。应用程序将用户重定向到登录页面,显示登录表单,并要求用户提供用户名和密码。然后 Edge Service 验证凭证与其内部用户数据库(在启动时自动生成)的匹配,并在发现它们有效时,通过浏览器启动一个认证会话。由于 HTTP 是一种无状态协议,用户会话通过一个 cookie 保持活跃,该 cookie 的值由浏览器在每次 HTTP 请求时提供(一个 会话 cookie)。内部,Edge Service 维护会话标识符和用户标识符之间的映射,如图 11.1 所示。

11-01

图 11.1 登录步骤之后,用户会话通过会话 cookie 保持活跃。

当您完成应用程序的测试后,使用 Ctrl-C 终止进程。然后导航到您保存 Docker Compose 文件(polar-deployment/docker/docker-compose.yml)的文件夹,并运行以下命令来停止 Redis 容器:

$ docker-compose down

当将之前的方法应用于云原生系统时,存在一些问题。在本章的剩余部分,我们将分析这些问题,确定适用于云原生应用的可行解决方案,并在我们刚刚实现的基础上使用它们。

11.2 使用 Keycloak 管理用户账户

在上一节中,我们基于登录表单向 Edge Service 添加了用户认证。您尝试通过启动时在内存中自动生成的用户账户进行登录。这对于第一次尝试 Spring Security 来说是可以的,但在生产环境中您不会想要这样做。

作为最低要求,我们需要为用户账户提供持久存储,并有一个注册新用户的选择。应特别关注使用强大的加密算法存储密码,并防止对数据库的未授权访问。鉴于这一功能的至关重要性,将其委托给一个专用应用程序是有意义的。

Keycloak (www.keycloak.org) 是由 Red Hat 社区开发和维护的开源身份和访问管理解决方案。它提供了一系列广泛的功能,包括单点登录(SSO)、社交登录、用户联合、多因素认证和集中式用户管理。Keycloak 依赖于 OAuth2、OpenID Connect 和 SAML 2.0 等标准。目前,我们将使用 Keycloak 来管理 Polar Bookshop 的用户账户。稍后我会向您展示如何使用其 OpenID Connect 和 OAuth2 功能。

注意,Spring Security 提供了实现用户管理服务所需的所有功能。如果您想了解更多关于这个主题的信息,可以参考 Laurențiu Spilcă 所著的 Spring Security in Action 一书的第三章和第四章(Manning, 2020)。

您可以在本地作为独立的 Java 应用程序或容器运行 Keycloak。对于生产环境,有几种在 Kubernetes 上运行 Keycloak 的解决方案。Keycloak 还需要一个关系型数据库来持久化。它自带嵌入式 H2 数据库,但在生产环境中,您希望将其替换为外部数据库。

对于 Polar Bookshop,我们将本地运行 Keycloak 作为 Docker 容器,依赖于嵌入式 H2 数据库。在生产环境中,我们将使用 PostgreSQL。这可能会似乎与环境一致性原则相矛盾,但既然它是一个第三方应用程序,测试其与数据源的交互不是您的责任。

本节将逐步指导您完成 Polar Bookshop 用例的 Keycloak 配置。首先,打开您的 polar-deployment 仓库。然后在 docker/docker-compose.yml 中定义一个新的 polar-keycloak 容器。

列表 11.3 在 Docker Compose 中定义 Keycloak 容器

version: "3.8"
services:
  ...

  polar-keycloak:                           ❶
    image: quay.io/keycloak/keycloak:19.0
    container_name: "polar-keycloak"
    command: start-dev                      ❷
    environment:                            ❸
      - KEYCLOAK_ADMIN=user
      - KEYCLOAK_ADMIN_PASSWORD=password
    ports:
      - 8080:8080

❶ 描述 Keycloak 容器的部分

❷ 以开发模式启动 Keycloak(使用嵌入式数据库)

❸ 将管理员凭据定义为环境变量

注意:稍后我将为您提供可以用于启动 Keycloak 容器时加载整个配置的 JSON 文件,这样您就不必担心容器的持久性。

您可以通过打开终端窗口,导航到保存 docker-compose.yml 文件的文件夹,并运行以下命令来启动 Keycloak 容器:

$ docker-compose up -d polar-keycloak

在我们开始管理用户账户之前,我们需要定义一个安全域。我们将在下一步进行定义。

11.2.1 定义安全域

在 Keycloak 中,应用程序或系统的任何安全方面都是在 的上下文中定义的,这是一个我们应用特定安全策略的逻辑域。默认情况下,Keycloak 预配置了一个 Master 域,但您可能希望为每个构建的产品创建一个专用的域。让我们创建一个新的 PolarBookshop 域来托管 Polar Bookshop 系统的任何安全相关方面。

确保您之前启动的 Keycloak 容器仍在运行。然后打开一个终端窗口,并在 Keycloak 容器内进入 bash 控制台:

$ docker exec -it polar-keycloak bash

提示:Keycloak 启动可能需要几秒钟。如果您在容器启动后立即尝试访问它,可能会收到错误,因为它尚未准备好接受连接。如果发生这种情况,请等待几秒钟再试。您可以使用 docker logs -f polar-keycloak 检查 Keycloak 日志。当打印出“以开发模式运行服务器”的消息后,Keycloak 即可使用。

我们将通过 Keycloak 的 Admin CLI 配置 Keycloak,但您也可以通过使用位于 http://localhost:8080 的 GUI 实现相同的结果。首先,导航到 Keycloak Admin CLI 脚本所在的文件夹:

$ cd /opt/keycloak/bin

Admin CLI 由我们在 Docker Compose 中为 Keycloak 容器定义的用户名和密码保护。在运行任何其他命令之前,我们需要启动一个认证会话:

$ ./kcadm.sh config credentials \
    --server http://localhost:8080 \     ❶
    --realm master \                     ❷
    --user user \                        ❸
    --password password                  ❹

❶ Keycloak 在容器内运行在 8080 端口。

❷ 默认的 Keycloak 域配置

❸ 在 Docker Compose 中定义的用户名

❹ 在 Docker Compose 中定义的密码

提示:你应该在配置完 Keycloak 之前保持当前终端窗口打开。如果在任何时刻认证会话过期,你都可以通过运行之前的命令来启动一个新的会话。

到目前为止,你可以继续创建一个新的安全域,其中将存储与 Polar Bookshop 相关的所有策略:

$ ./kcadm.sh create realms -s realm=PolarBookshop -s enabled=true

11.2.2 管理用户和角色

我们需要一些用户来测试不同的身份验证场景。正如第二章所预料的,Polar Bookshop 有两种类型的用户:客户和员工。

  • 客户可以浏览书籍并购买它们。

  • 员工也可以向目录添加新书,修改现有书籍,并删除它们。

为了管理与每种用户类型相关的不同权限,让我们创建两个角色:客户员工。稍后你将根据这些角色保护应用程序端点。这是一种称为基于角色的访问控制(RBAC)的授权策略。

首先,从你迄今为止使用的 Keycloak Admin CLI 控制台创建两个角色:

$ ./kcadm.sh create roles -r PolarBookshop -s name=employee
$ ./kcadm.sh create roles -r PolarBookshop -s name=customer

然后创建两个用户。伊莎贝尔·达尔(用户名:isabelle)将是书店的员工和客户。你可以按照以下方式为她创建一个账户:

$ ./kcadm.sh create users -r PolarBookshop \
    -s username=isabelle \                    ❶
    -s firstName=Isabelle \
    -s lastName=Dahl \
    -s enabled=true                           ❷

$ ./kcadm.sh add-roles -r PolarBookshop \
    --uusername isabelle \                    ❸
    --rolename employee \
    --rolename customer

❶ 新用户的用户名。它将用于登录。

❷ 用户应该是活跃的。

❸ 伊莎贝尔既是员工也是客户。

然后为Bjorn Vinterberg(用户名:bjorn),书店的客户执行相同的操作:

$ ./kcadm.sh create users -r PolarBookshop \
    -s username=bjorn \                       ❶
    -s firstName=Bjorn \
    -s lastName=Vinterberg \
    -s enabled=true                           ❷

$ ./kcadm.sh add-roles -r PolarBookshop \
    --uusername bjorn \                       ❸
    --rolename customer

❶ 新用户的用户名。它将用于登录。

❷ 用户应该是活跃的。

❸ Bjorn 是客户。

在实际场景中,用户会自己选择密码,并最好启用双因素身份验证。伊莎贝尔和 Bjorn 是测试用户,因此分配一个明确的密码(密码)是可以的。你可以从 Keycloak Admin CLI 按照以下方式执行:

$ ./kcadm.sh set-password -r PolarBookshop \
    --username isabelle --new-password password
$ ./kcadm.sh set-password -r PolarBookshop \
    --username bjorn --new-password password

用户管理到此结束。你可以使用 exit 命令从 Keycloak 容器内的 bash 控制台退出,但请保持 Keycloak 运行。

接下来,让我们探索如何改进 Edge Service 的身份验证策略。

11.3 使用 OpenID Connect、JWT 和 Keycloak 进行身份验证

目前,用户必须通过浏览器使用用户名和密码登录。由于 Keycloak 现在管理用户账户,我们可以继续更新 Edge Service 以使用 Keycloak 本身来检查用户凭据,而不是使用其内部存储。但是,如果我们向 Polar Bookshop 系统引入不同的客户端,例如移动应用程序和物联网设备,会发生什么?用户应该如何进行身份验证?如果书店员工已经在公司的 Active Directory(AD)中注册并想通过 SAML 登录,怎么办?我们能否在不同应用程序之间提供单一登录(SSO)体验?用户能否通过他们的 GitHub 或 Twitter 账户(社交登录)登录?

当我们获得新的需求时,我们可以考虑在边缘服务中支持所有这些认证策略。然而,这不是一个可扩展的方法。更好的解决方案是委托一个专门的身份提供者来根据任何支持的策略验证用户。然后边缘服务将使用该服务来验证用户的身份,而无需关心执行实际的认证步骤。该专用服务可以让用户以各种方式认证,例如使用系统中注册的凭据,通过社交登录,或通过 SAML 来依赖公司 AD 中定义的身份。

使用专门的服务来验证用户会导致我们需要解决两个方面的系统问题,以便系统能够正常工作。首先,我们需要为边缘服务建立一个协议,以便将用户身份验证委托给身份提供者,并让后者提供关于身份验证结果的信息。其次,我们需要定义一个数据格式,身份提供者可以使用它来在用户成功验证后安全地通知边缘服务用户的身份。本节将使用 OpenID Connect 和 JSON Web Token 来解决这个问题。

11.3.1 使用 OpenID Connect 验证用户

OpenID Connect (OIDC) 是一个协议,它允许一个应用程序(称为客户端)根据由受信任的第三方(称为授权服务器)执行的认证来验证用户的身份,并检索用户配置文件信息。授权服务器通过一个ID 令牌通知客户端应用程序身份验证步骤的结果。

OIDC 是 OAuth2 之上的一个身份层,OAuth2 是一个授权框架,它解决了使用令牌进行授权的委托问题,但没有处理身份验证。正如你所知,授权只能在身份验证之后发生。这就是为什么我决定首先介绍 OIDC;OAuth2 将在下一章中进一步探讨。这不是介绍这些主题的典型方式,但我觉得在我们为 Polar Bookshop 设计访问控制系统时,这样做是有意义的。

注意:本书将仅涵盖 OAuth2 和 OIDC 的一些基本方面。如果您想了解更多关于它们的信息,Manning 在其目录中有一两本关于该主题的书:Justin Richer 和 Antonio Sanso 的OAuth 2 in Action(Manning,2017)和 Prabath Siriwardena 的OpenID Connect in Action(Manning,2022)。

当涉及到处理用户身份验证时,我们可以识别 OIDC 协议中 OAuth2 框架使用的三个主要参与者:

  • 授权服务器——负责验证用户并颁发令牌的实体。在 Polar Bookshop 中,这将是由 Keycloak 执行的。

  • 用户——也称为资源所有者,这是通过授权服务器登录以获取客户端应用程序认证访问权限的人类。在 Polar Bookshop 中,它可以是客户或员工。

  • 客户端——需要用户进行身份验证的应用程序。这可以是一个移动应用程序、基于浏览器的应用程序、服务器端应用程序,甚至是智能电视应用程序。在 Polar Bookshop 中,它是 Edge 服务。

图 11.2 展示了三个参与者如何映射到 Polar Bookshop 架构。

11-02

图 11.2 Polar Bookshop 架构中如何将 OIDC/OAuth2 角色分配给用户认证的实体

注意:OAuth2 框架定义的角色在 OpenID Connect 的上下文中也有不同的名称。OAuth2 授权服务器也称为 OIDC 提供者。依赖于授权服务器进行身份验证和令牌发行的 OAuth2 客户端也称为 依赖方 (RP)。OAuth2 用户也称为 最终用户。我们将坚持使用 OAuth2 命名法以保持一致性,但了解 OIDC 中使用的替代术语是有帮助的。

在 Polar Bookshop 中,Edge 服务将启动用户登录流程,但随后将通过 OIDC 协议(由 Spring Security 内置支持)将实际的认证步骤委托给 Keycloak。Keycloak 提供了多种认证策略,包括传统的登录表单、通过 GitHub 或 Twitter 等提供者进行的社会登录,以及 SAML。它还支持双因素认证(2FA)。在接下来的章节中,我们将使用登录表单策略作为示例。由于用户将直接与 Keycloak 进行交互以登录,因此他们的凭据永远不会暴露给系统中的任何组件,除了 Keycloak,这是采用此类解决方案的一个好处。

当未经认证的用户调用 Edge 服务公开的受保护端点时,以下情况会发生:

  1. Edge 服务(客户端)将浏览器重定向到 Keycloak(授权服务器)进行身份验证。

  2. Keycloak 通过登录表单(例如,要求用户输入用户名和密码)验证用户身份,然后将浏览器重定向回 Edge 服务,并附带一个 授权码

  3. Edge 服务调用 Keycloak 以交换授权码和包含有关已验证用户信息的 ID 令牌。

  4. Edge 服务根据会话 cookie 初始化基于浏览器的已验证用户会话。内部,Edge 服务维护会话标识符和 ID 令牌(用户身份)之间的映射。

注意:OIDC 支持的认证流程基于 OAuth2 的 授权码流程。第二步可能看起来是多余的,但授权码对于确保只有合法的客户端才能将其与令牌交换是至关重要的。

图 11.3 描述了 OIDC 协议支持的认证流程的基本部分。即使 Spring Security 支持它,并且你不需要自己实现任何部分,但了解流程的概述仍然是有益的。

11-03

图 11.3 OIDC 协议支持的认证流程

当采用图 11.3 中所示的认证流程时,边缘服务不受特定认证策略的影响。我们可以配置 Keycloak 使用 Active Directory 或通过 GitHub 进行社交登录,而边缘服务无需任何更改。它只需要支持 OIDC 来验证认证是否正确发生,并通过 ID 令牌获取用户信息。什么是 ID 令牌?它是一个包含用户认证事件信息的JSON Web 令牌 (JWT)。我们将在下一节中更详细地了解 JWT。

注意:每当提到 OIDC 时,我指的是 OpenID Connect Core 1.0 规范(openid.net/specs/openid-connect-core-1_0.html)。每当提到 OAuth2 时,除非另有说明,我指的是目前正在标准化中的 OAuth 2.1 规范(oauth.net/2.1),旨在取代 RFC 6749 中描述的 OAuth 2.0 标准(tools.ietf.org/html/rfc6749)。

11.3.2 使用 JWT 交换用户信息

在分布式系统中,包括微服务和云原生应用,用于交换已认证用户及其授权信息的最常用策略是通过令牌。

JSON Web Token (JWT)是表示要在两个实体之间传输的声明的行业标准。它是在分布式系统中,在不同实体之间安全地传播有关已认证用户及其权限信息的广泛使用的格式。JWT 本身不单独使用,但它包含在一个更大的结构中,即 JSON Web 签名(JWS),通过数字签名 JWT 对象来确保声明的完整性。

一个数字签名的 JWT(JWS)是由三个部分组成的字符串,这些部分使用 Base64 编码,并由点(.)字符分隔:

<header>.<payload>.<signature>

注意:为了调试目的,你可以使用jwt.io上提供的工具来编码和解码令牌。

正如你所见,一个数字签名的 JWT 有三个部分:

  • 头部——一个包含对有效载荷执行的加密操作信息的 JSON 对象(称为JOSE 头部)。这些操作遵循来自 JavaScript 对象签名和加密(JOSE)框架的标准。解码后的头部看起来如下:

  • {
      "alg": "HS256",     ❶
      "typ": "JWT"        ❷
    }
    

    ❶ 用于数字签名令牌的算法

    ❷ 令牌的类型

  • 有效载荷——一个包含令牌传达的声明的 JSON 对象(称为声明集)。JWT 规范定义了一些标准声明名称,但您也可以定义自己的。解码后的有效载荷看起来如下:

  • {
      "iss": "https://sso.polarbookshop.com",    ❶
      "sub": "isabelle",                         ❷
      "exp": 1626439022                          ❸
    }
    

    ❶ 发布 JWT 的实体(发布者)

    ❷ JWT 的主题实体(终端用户)

    ❸ JWT 过期时间(时间戳)

  • 签名——JWT 的签名,确保声明没有被篡改。使用 JWS 结构的先决条件是我们信任发行令牌的实体(发行者),并且我们有检查其有效性的方法。

当 JWT 需要完整性和机密性时,它首先被作为 JWS 签名,然后使用 JSON Web Encryption (JWE) 加密。在这本书中,我们将只使用 JWS。

注意:如果您想了解更多关于 JWT 及其相关方面的信息,可以参考 IETF 标准规范。JSON Web Token (JWT) 记录在 RFC 7519 (tools.ietf.org/html/rfc7519) 中,JSON Web Signature (JWS) 描述在 RFC 7515 (tools.ietf.org/html/rfc7515) 中,而 JSON Web Encryption (JWE) 则在 RFC 7516 (tools.ietf.org/html/rfc7516) 中展示。您可能还对 JSON Web Algorithms (JWA) 感兴趣,它定义了 JWT 可用的加密操作,并在 RFC 7518 (tools.ietf.org/html/rfc7518) 中详细说明。

在 Polar Bookshop 的情况下,边缘服务可以将认证步骤委托给 Keycloak。认证用户成功后,Keycloak 将包含有关新认证用户信息的 JWT 发送给边缘服务(ID Token)。边缘服务将通过其签名验证 JWT,并检查它以检索有关用户的数据(声明)。最后,它将根据会话 cookie 与用户的浏览器建立认证会话,该 cookie 的标识符映射到 JWT。

为了委托认证并安全地检索令牌,边缘服务必须在 Keycloak 中注册为 OAuth2 客户端。让我们看看如何操作。

11.3.3 在 Keycloak 中注册应用程序

如您在上一节所学,OAuth2 客户端是一个可以请求用户认证并最终从授权服务器接收令牌的应用程序。在 Polar Bookshop 架构中,这个角色由边缘服务扮演。当使用 OIDC/OAuth2 时,您需要在使用它进行用户认证之前,将每个 OAuth2 客户端注册到授权服务器。

客户端可以是 公开机密 的。如果我们无法保持秘密,我们将应用程序注册为公开客户端。例如,移动应用程序将被注册为公开客户端。另一方面,机密客户端是可以保持秘密的客户端,通常是像边缘服务这样的后端应用程序。无论哪种方式,注册过程都是相似的。主要区别在于,机密客户端需要通过授权服务器(例如,通过依赖共享秘密)进行自身认证。这是我们不能用于公开客户端的额外保护层,因为它们没有安全存储共享秘密的方法。

OAuth2 中的客户端困境

客户端角色可以分配给前端或后端应用程序。主要区别在于解决方案的安全级别。客户端是将从授权服务器接收令牌的实体。客户端必须将它们存储在某个地方,以便在来自同一用户的后续请求中使用。令牌是敏感数据,应该得到保护,而后端应用程序是做这件事的最佳地点。但这并不总是可能的。

这里是我的经验法则。如果前端是一个移动或桌面应用程序,如 iOS 或 Android,那么它将是 OAuth2 客户端,并将被分类为公共客户端。您可以使用 AppAuth (appauth.io)等库来添加对 OIDC/OAuth2 的支持,并在设备上尽可能安全地存储令牌。如果前端是 Web 应用程序(如极地书店),那么后端服务应该是客户端。在这种情况下,它将被分类为机密客户端。

这种区分的原因是,无论你如何尝试在浏览器中隐藏 OIDC/OAuth2 令牌(cookie、本地存储、会话存储),它们总是存在被暴露和滥用的风险。“从安全角度来看,在前端 Web 应用程序中保护令牌几乎是不可能的。”这是应用安全专家 Philippe De Ryck 的观点^a,他建议工程师依赖后端-for-前端模式,并让后端应用程序处理令牌。

我建议基于会话 cookie(就像你在单体应用中做的那样)在浏览器和后端之间建立交互,并让后端应用程序负责控制身份验证流程和使用授权服务器颁发的令牌,即使在单页应用(SPAs)的情况下。这是安全专家推荐的最佳实践。

^(a )P. De Ryck,“单页应用中刷新令牌轮换的批判性分析”,Ping Identity博客,2021 年 3 月 18 日,mng.bz/QWG6

由于边缘服务将是极地书店系统中的 OAuth2 客户端,让我们使用 Keycloak 来注册它。我们可以再次依赖 Keycloak Admin CLI。

确保您之前启动的 Keycloak 容器仍在运行。然后打开一个终端窗口,并在 Keycloak 容器内进入 bash 控制台:

$ docker exec -it polar-keycloak bash

接下来,导航到 Keycloak Admin CLI 脚本所在的文件夹:

$ cd /opt/keycloak/bin

如您之前所学的,Admin CLI 受我们在 Docker Compose 中为 Keycloak 容器定义的用户名和密码保护,因此我们需要在运行任何其他命令之前启动一个认证会话:

$ ./kcadm.sh config credentials --server http://localhost:8080 \
    --realm master --user user --password password

最后,在 PolarBookshop 域中将边缘服务注册为 OAuth2 客户端:

$ ./kcadm.sh create clients -r PolarBookshop \
    -s clientId=edge-service \                       ❶
    -s enabled=true \                                ❷
    -s publicClient=false \                          ❸
    -s secret=polar-keycloak-secret \                ❹
    -s 'redirectUris=["http://localhost:9000",
    ➥"http://localhost:9000/login/oauth2/code/*"]'  ❺

❶ OAuth2 客户端标识符

❷ 它必须被启用。

❸ 边缘服务是一个机密客户端,不是公开的。

❹ 由于它是一个机密客户端,它需要一个秘密来与 Keycloak 进行身份验证。

❺ Keycloak 授权重定向请求的应用程序 URL,在用户登录或登出后

有效的重定向 URL 是 OAuth2 客户端应用程序(边缘服务)公开的端点,Keycloak 将将身份验证请求重定向到这些端点。由于 Keycloak 可以在重定向请求中包含敏感信息,我们希望限制哪些应用程序和端点被授权接收此类信息。正如您稍后将要了解的,身份验证请求的重定向 URL 将是 http://localhost:9000/login/oauth2/code/*,遵循 Spring Security 提供的默认格式。为了支持登出操作后的重定向,我们还需要添加 http://localhost:9000 作为有效的重定向 URL。

这一节的内容就到这里。在本书附带的源代码仓库中,我包含了一个 JSON 文件,您可以在将来启动 Keycloak 容器时使用它来加载整个配置(Chapter11/11-end/polar-deployment/docker/keycloak/realm-config.json)。现在您已经熟悉了 Keycloak,您可以更新容器定义以确保在启动时始终拥有所需的配置。将 JSON 文件复制到您自己的项目中相同的路径,并按照以下方式更新您的 docker-compose.yml 文件中的 polar-keycloak 服务:

列表 11.4 在 Keycloak 容器中导入领域配置

version: "3.8"
services:
  ...

  polar-keycloak:
    image: quay.io/keycloak/keycloak:19.0
    container_name: "polar-keycloak"
    command: start-dev --import-realm          ❶
    volumes:                                   ❷
      - ./keycloak:/opt/keycloak/data/import 
    environment:
      - KEYCLOAK_ADMIN=user
      - KEYCLOAK_ADMIN_PASSWORD=password
    ports:
      - 8080:8080

❶ 在启动时导入提供的配置

❷ 配置一个卷将配置文件加载到容器中

为什么选择 Keycloak

我决定使用 Keycloak,因为它是一个成熟的、开源的解决方案,可以自己运行授权服务器。在社区需求增加之后,Spring 开始了一个新的 Spring Authorization Server 项目 (github.com/spring-projects/spring-authorization-server)。从版本 0.2.0 开始,它已经成为设置 OAuth2 授权服务器的生产就绪解决方案。在撰写本文时,该项目提供了最常见的 OAuth2 功能的实现,并且目前正在扩展对 OIDC 特定功能的支持。您可以在 GitHub 上跟踪项目的进展并为其做出贡献。

另一个选择是使用像 Okta (www.okta.com) 或 Auth0 (auth0.com) 这样的 SaaS 解决方案。它们都是获取 OIDC/OAuth2 作为托管服务的优秀解决方案,我鼓励您尝试使用它们。对于这本书,我希望使用一个您可以在本地环境中运行并可靠复制的解决方案,而不依赖于可能随时间变化的其他服务,这样我的说明在这里就不再有效。

在继续之前,让我们停止任何正在运行的容器。打开一个终端窗口,导航到您保存 Docker Compose 文件(polar-deployment/docker/docker-compose.yml)的文件夹,并运行以下命令:

$ docker-compose down

现在我们已经拥有了重构 Edge Service 的所有组件,使其能够使用依赖于 OIDC/OAuth2、JWT 和 Keycloak 的认证策略。最好的部分是,它基于标准,并得到所有主流语言和框架(前端、后端、移动、物联网)的支持,包括 Spring Security。

11.4 使用 Spring Security 和 OpenID Connect 认证用户

如前所述,Spring Security 支持多种认证策略。Edge Service 当前的安全设置通过应用程序本身提供的登录表单处理用户账户和认证。现在你已经了解了 OpenID Connect,我们可以重构应用程序,通过 OIDC 协议将用户认证委托给 Keycloak。

OAuth2 的支持曾经在一个名为 Spring Security OAuth 的独立项目中,你会在 Spring Cloud Security 中使用它来在云原生应用程序中采用 OAuth2。这两个项目现在都已弃用,转而支持 Spring Security 主项目中引入的本地、更全面的 OAuth2 和 OpenID Connect 支持,从版本 5 开始。本章重点介绍如何使用 Spring Security 5 中的新 OIDC/OAuth2 支持来认证 Polar Bookshop 的用户。

注意:如果你发现自己正在使用已弃用的 Spring Security OAuth 和 Spring Cloud Security 项目进行项目开发,你可能想查看 Laurențiu Spilcǎ所著的《Spring Security in Action》(Manning, 2020)的第十二章至第十五章,其中对这些项目进行了详细的解释。

使用 Spring Security 及其 OAuth2/OIDC 支持,本节将展示如何为 Edge Service 执行以下操作:

  • 使用 OpenID Connect 进行用户认证。

  • 配置用户注销。

  • 提取关于认证用户的详细信息。

让我们开始吧!

11.4.1 添加新的依赖项

首先,我们需要更新 Edge Service 的依赖项。我们可以用更具体的 OAuth2 客户端依赖项替换现有的 Spring Security 启动器依赖项,这增加了对 OIDC/OAuth2 客户端功能的支持。此外,我们还可以添加 Spring Security Test 依赖项,它为在 Spring 中测试安全场景提供额外的支持。

打开 Edge Service 项目(edge-service)的 build.gradle 文件,并添加新的依赖项。记得在添加新依赖项后刷新或重新导入 Gradle 依赖项。

列表 11.5 添加 Spring Security OAuth2 客户端依赖项

dependencies {
  ...
  implementation 
  ➥ 'org.springframework.boot:spring-boot-starter-oauth2-client' 
  testImplementation 'org.springframework.security:spring-security-test' 
}

Spring 与 Keycloak 集成

当选择 Keycloak 作为授权服务器时,Spring Security 提供的原生 OpenID Connect/OAuth2 支持的替代方案是 Keycloak Spring Adapter。这是一个由 Keycloak 项目本身提供的库,用于与 Spring Boot 和 Spring Security 集成,但在 Keycloak 17 发布后已退役。

如果你正在使用 Keycloak Spring 适配器的项目上工作,你可能想查看我关于这个主题的文章(www.thomasvitale.com/tag/keycloak)或者 John Carnell 和 Illary Huaylupo Sánchez(Manning,2021 年)所著的《Spring Microservices in Action》第二版的第九章。

11.4.2 配置 Spring Security 与 Keycloak 之间的集成

在添加了 Spring Security 的相关依赖后,我们需要配置与 Keycloak 的集成。在上一节中,我们在 Keycloak 中注册了 Edge Service 作为 OAuth2 客户端,定义了客户端标识符(edge-service)和共享密钥(polar-keycloak-secret)。现在我们将使用这些信息来告诉 Spring Security 如何与 Keycloak 交互。

打开 Edge Service 项目的 application.yml 文件,并添加以下配置。

列表 11.6 配置 Edge Service 作为 OAuth2 客户端

spring:
  security:
    oauth2:
      client:
        registration:
          keycloak:                                 ❶
            client-id: edge-service                 ❷
            client-secret: polar-keycloak-secret    ❸
            scope: openid                           ❹
        provider:
          keycloak:                                 ❺
            issuer-uri:
➥http://localhost:8080/realms/PolarBookshop        ❻

❶ 识别 Spring Security 中客户端注册的名称(称为“registrationId”)。它可以任何字符串。

❷ 在 Keycloak 中定义的 OAuth2 客户端标识符

❸ 客户端用于与 Keycloak 进行身份验证的共享密钥

❹ 客户端希望访问的权限范围列表。openid 权限范围在 OAuth2 之上触发 OIDC 身份验证。

❺ 几行之上用作“registrationId”的相同名称

❻ 提供有关特定领域所有相关 OAuth2 和 OIDC 端点信息的 Keycloak URL

Spring Security 中的每个客户端注册都必须有一个标识符(registrationId)。在这个例子中,它是 keycloak。注册标识符用于构建 Spring Security 接收 Keycloak 授权码的 URL。默认 URL 模板是/login/oauth2/code/{registrationId}。对于 Edge Service,完整的 URL 是 http://localhost:9000/login/oauth2/code/keycloak,这已经在 Keycloak 中配置为有效的重定向 URL。

权限范围是 OAuth2 的一个概念,用于限制应用程序对用户资源的访问。你可以将其视为角色,但针对应用程序而不是用户。当我们使用 OAuth2 之上的 OpenID Connect 扩展来验证用户身份时,我们需要包含 openid 权限范围来通知授权服务器并接收包含用户认证数据的 ID 令牌。下一章将更详细地解释在授权上下文中的权限范围。

现在我们已经定义了与 Keycloak 的集成,让我们配置 Spring Security 以应用所需的策略。

11.4.3 基本 Spring Security 配置

在 Spring Security 中定义和配置安全策略的中心位置是 SecurityWebFilterChain 类。Edge Service 目前配置为要求对所有端点进行用户身份验证,并使用基于登录表单的认证策略。让我们将其更改为使用 OIDC 认证。

ServerHttpSecurity 对象提供了两种在 Spring Security 中配置 OAuth2 客户端的方式。使用 oauth2Login(),您可以配置一个应用程序作为 OAuth2 客户端并通过 OpenID Connect 认证用户。使用 oauth2Client(),应用程序将不会认证用户,因此您需要定义另一种认证机制。我们想使用 OIDC 认证,所以我们将使用 oauth2Login() 和默认配置。按照以下方式更新 SecurityConfig 类。

列表 11.7 通过 OIDC 对所有端点要求认证

@EnableWebFluxSecurity
public class SecurityConfig {

  @Bean
  SecurityWebFilterChain springSecurityFilterChain(
   ServerHttpSecurity http
  ) {
    return http
      .authorizeExchange(exchange ->
        exchange.anyExchange().authenticated())
      .oauth2Login(Customizer.withDefaults())      ❶
      .build();
  }
}

❶ 启用 OAuth2/OpenID Connect 用户认证

让我们验证这是否正确工作。首先,启动 Redis 和 Keycloak 容器。打开一个终端窗口,导航到您保存 Docker Compose 文件(polar-deployment/docker/docker-compose.yml)的文件夹,并运行以下命令:

$ docker-compose up -d polar-redis polar-keycloak

然后运行 Edge Service 应用程序(./gradlew bootRun),打开一个浏览器窗口,并转到 http://localhost:9000。您应该会被重定向到由 Keycloak 提供的登录页面,在那里您可以作为我们之前创建的用户之一进行认证(图 11.4)。

11-04

图 11.4 Polar Bookshop 域的关键 cloak 登录页面,显示在 Edge Service 触发 OIDC 认证流程之后

例如,以 Isabelle(isabelle/password)的身份登录,并注意 Keycloak 在验证提供的凭据后如何将您重定向回 Edge Service。由于 Edge Service 通过根端点不公开任何内容,您将看到一个错误消息(“Whitelabel Error Page”)。但别担心!那正是我们将集成 Angular 前端的地方。这个测试的关键点是 Edge Service 要求您在访问其任何端点之前进行认证,并触发了 OIDC 认证流程。

在尝试完 OIDC 认证流程后,使用 Ctrl-C 停止应用程序。

如果认证成功,Spring Security 将启动一个与浏览器的认证会话并保存有关用户的信息。在下一节中,您将了解我们如何检索和使用这些信息。

11.4.4 检查认证用户上下文

作为认证过程的一部分,Spring Security 定义了一个上下文来保存有关用户的信息并将用户会话映射到一个 ID Token。在本节中,您将了解更多关于这个上下文的信息,涉及哪些类,以及如何在 Edge Service 中的新 /user 端点检索和公开这些数据。

首先,让我们定义一个 User 模型来收集已认证用户的用户名、名、姓和角色。这与我们在 Keycloak 中注册两个用户时提供的信息相同,也是 ID Token 中返回的信息。在新的 com.polarbookshop.edgeservice.user 包中创建一个 User 记录,如下所示。

列表 11.8 创建用户记录以保存有关已认证用户的信息

package com.polarbookshop.edgeservice.user;

import java.util.List;

public record User(     ❶
  String username,
  String firstName,
  String lastName,
  List<String> roles
){}

❶ 不可变数据类,包含用户数据

不论采用哪种认证策略(无论是用户名/密码、OpenID Connect/OAuth2 还是 SAML2),Spring Security 都会将有关已认证用户的信息(也称为 主体)保存在一个 Authentication 对象中。在 OIDC 的情况下,主体对象是 OidcUser 类型,并且 Spring Security 将 ID Token 存储在那里。反过来,Authentication 被保存在一个 SecurityContext 对象中。

获取当前登录用户的 Authentication 对象的一种方法是从 ReactiveSecurityContextHolder(或对于命令式应用程序的 SecurityContextHolder)检索到的相关 SecurityContext 中提取它。图 11.5 展示了所有这些对象之间的关系。

11-05

图 11.5 存储当前认证用户信息的主体类

你可以通过以下方式使其工作:

  1. 在 com.polarbookshop.edgeservice.user 包中创建一个带有 @RestController 注解的 UserController 类。

  2. 定义一个方法来处理对新的 /user 端点的 GET 请求。

  3. 返回一个包含从 OidcUser 中检索到的必要信息的 User 对象,以获取正确数据,我们可以使用图 11.5 中所示的调用层次结构。

在 UserController 类中生成的结果方法将如下所示:

@GetMapping("user")
public Mono<User> getUser() {
  return ReactiveSecurityContextHolder.getContext()   ❶
    .map(SecurityContext::getAuthentication)          ❷
    .map(authentication ->
      (OidcUser) authentication.getPrincipal())       ❸
    .map(oidcUser ->                                  ❹
      new User(
        oidcUser.getPreferredUsername(),
        oidcUser.getGivenName(),
        oidcUser.getFamilyName(),
        List.of("employee", "customer")
      )
  );
}

❶ 从 ReactiveSecurityContextHolder 获取当前认证用户的 SecurityContext

❷ 从 SecurityContext 获取认证信息

❸ 从 Authentication 获取主体。对于 OIDC,它是 OidcUser 类型。

❹ 使用从 ID Token 中提取的 OidcUser 数据构建一个 User 对象

在下一章中,我们将专注于授权策略,我们将配置 Keycloak 以在 ID Token 中包含一个自定义的角色声明,并使用该值在 UserController 类中构建 User 对象。在此之前,我们将使用一个固定的值列表。

对于 Spring Web MVC 和 WebFlux 控制器,除了直接使用 ReactiveSecurityContextHolder,我们还可以使用 @CurrentSecurityContext 和 @AuthenticationPrincipal 注解分别注入 SecurityContext 和主体(在这种情况下,OidcUser)。

让我们通过直接将 OidcUser 对象作为参数注入来简化 getUser() 方法的实现。UserController 类的最终结果如下所示。

列表 11.9 返回当前认证用户的信息

package com.polarbookshop.edgeservice.user;

import java.util.List;
import reactor.core.publisher.Mono;
import org.springframework.security.core.annotation.
➥AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class UserController {

  @GetMapping("user")
  public Mono<User> getUser(
   @AuthenticationPrincipal OidcUser oidcUser    ❶
  ) {
    var user = new User(                         ❷
      oidcUser.getPreferredUsername(),
      oidcUser.getGivenName(),
      oidcUser.getFamilyName(),
      List.of("employee", "customer")
    );
    return Mono.just(user);                      ❸
  }
}

❶ 注入一个包含当前认证用户信息的 OidcUser 对象

❷ 从 OidcUser 中包含的相关声明构建一个 User 对象

❸ 将 User 对象包装在一个响应式发布者中,因为 Edge Service 是一个响应式应用程序

确保从上一节中 Keycloak 和 Redis 仍在运行,运行边缘服务应用程序(./gradlew bootRun),打开一个隐身浏览器窗口,并导航到 http://localhost:9000/user。Spring Security 将您重定向到 Keycloak,您将需要使用用户名和密码登录。例如,以 Bjorn(bjorn/password)的身份进行认证。认证成功后,您将被重定向回 /user 端点。结果是以下内容:

{
  "username": "bjorn",
  "firstName": "Bjorn",
  "lastName": "Vinterberg",
  "roles": [
    "employee",
    "customer"
  ]
}

注意:角色列表包含硬编码的值。在下一章中,我们将将其更改为返回 Keycloak 中每个用户分配的实际角色。

当你完成对新端点的尝试后,使用 Ctrl-C 停止应用程序,并使用 docker-compose down 停止容器。

考虑当你尝试访问 /user 端点并被重定向到 Keycloak 时发生的情况。在成功验证用户的凭据后,Keycloak 回调边缘服务并发送新认证用户的 ID Token。然后边缘服务存储令牌并将浏览器重定向到所需的端点,同时附带一个会话 cookie。从那时起,浏览器和边缘服务之间的任何通信都将使用该会话 cookie 来识别该用户的认证上下文。没有令牌暴露给浏览器。

ID Token 存储在 OidcUser 中,它是认证的一部分,最终包含在 SecurityContext 中。在第九章中,我们使用了 Spring Session 项目,使边缘服务将会话数据存储在外部数据服务(Redis)中,这样它就可以保持无状态并且能够扩展。SecurityContext 对象包含在会话数据中,因此会自动存储在 Redis 中,使得边缘服务可以无问题地扩展。

获取当前认证用户(主体)的另一种选项是从与特定 HTTP 请求关联的上下文(称为交换)中获取。我们将使用该选项来更新速率限制器配置。在第九章中,我们使用 Spring Cloud Gateway 和 Redis 实现了速率限制。目前,速率限制是基于每秒接收到的总请求数量计算的。我们应该更新它,以便独立地为每个用户应用速率限制。

打开 RateLimiterConfig 类,并配置如何从请求中提取当前认证主体的用户名。如果没有定义用户(即请求未认证,匿名),我们使用默认密钥将速率限制应用于所有未认证请求的整体。

列表 11.10 为每个用户配置速率限制

@Configuration
public class RateLimiterConfig {

  @Bean
  KeyResolver keyResolver() {
    return exchange -> exchange.getPrincipal()    ❶
      .map(Principal::getName)                    ❷
      .defaultIfEmpty("anonymous");               ❸
  }
}

❶ 从当前请求(交换)中获取当前认证用户(主体)

❷ 从主体中提取用户名

❸ 如果请求未认证,它使用“匿名”作为默认密钥来应用速率限制。

这就完成了使用 OpenID Connect 对 Polar Bookshop 用户进行身份验证的基本配置。下一节将介绍在 Spring Security 中注销的工作原理以及我们如何为 OAuth2/OIDC 场景进行自定义。

11.4.5 在 Spring Security 和 Keycloak 中配置用户注销

到目前为止,我们已经讨论了在分布式系统中对用户进行身份验证的挑战和解决方案。然而,我们还应该考虑用户注销时会发生什么。

在 Spring Security 中,注销会导致与用户关联的所有会话数据被删除。当使用 OpenID Connect/OAuth2 时,Spring Security 为该用户存储的令牌也会被删除。然而,用户在 Keycloak 中仍将有一个活跃的会话。正如身份验证过程涉及 Keycloak 和 Edge Service 一样,完全注销用户需要将注销请求传播到这两个组件。

默认情况下,针对由 Spring Security 保护的应用程序执行的注销操作不会影响 Keycloak。幸运的是,Spring Security 提供了“OpenID Connect RP-Initiated Logout”规范的实现,该规范定义了如何从 OAuth2 客户端(即依赖方)将注销请求传播到授权服务器。您将很快看到如何为 Edge Service 配置它。

注意:OpenID Connect 规范包括了一些不同的场景用于会话管理和注销。如果您想了解更多信息,我建议您查看 OIDC 会话管理(openid.net/specs/openid-connect-session-1_0.html)、OIDC 前端通道注销(openid.net/specs/openid-connect-frontchannel-1_0.html)、OIDC 后端通道注销(openid.net/specs/openid-connect-backchannel-1_0.html)和 OIDC RP-Initiated Logout(openid.net/specs/openid-connect-rpinitiated-1_0.html)的官方文档。

Spring Security 支持通过向框架默认实现的 /logout 端点发送 POST 请求来注销。我们希望启用 RP-Initiated Logout 场景,以便当用户从应用程序注销时,他们也会从授权服务器注销。Spring Security 对此场景提供全面支持,并提供了一个 OidcClientInitiatedServerLogoutSuccessHandler 对象,您可以使用它来配置如何将注销请求传播到 Keycloak。

假设 RP-Initiated Logout 功能已被启用。在这种情况下,用户在 Spring Security 中成功注销后,Edge Service 将通过浏览器(使用重定向)向 Keycloak 发送注销请求。接下来,您可能还希望用户在授权服务器上完成注销操作后,被重定向回应用程序。

您可以使用 setPostLogoutRedirectUri()方法配置用户注销后应重定向到的位置,该方法是 OidcClientInitiatedServerLogoutSuccessHandler 类公开的。您可能指定一个直接 URL,但在云环境中由于许多变量(如主机名、服务名称和协议(http 与 https))的原因,这不会很好地工作。Spring Security 团队知道这一点,因此他们添加了对在运行时动态解析的占位符的支持。您可以使用{baseUrl}占位符而不是硬编码 URL 值。当您在本地运行 Edge Service 时,占位符将被解析为 http://localhost:9000。如果您在云环境中通过 TLS 终止代理并通过 DNS 名称 polarbookshop.com 访问它,它将自动替换为 https://polarbookshop.com

然而,Keycloak 中的客户端配置需要一个确切的 URL。这就是为什么我们在 Keycloak 中注册 Edge Service 时将 http://localhost:9000 添加到有效重定向 URL 列表中。在生产环境中,您必须更新 Keycloak 中的有效重定向 URL 列表以匹配实际使用的 URL。

图 11.6 展示了我刚刚描述的注销场景。

11-06

图 11.6 当用户注销时,请求首先由 Spring Security 处理,然后转发到 Keycloak,用户最终被重定向到应用程序。

由于应用程序的注销功能已经在 Spring Security 中默认提供,您只需要启用并配置 Edge Service 的 RP-Initiated Logout:

  1. 在 SecurityConfig 类中,定义一个 oidcLogoutSuccessHandler()方法来构建 OidcClientInitiatedServerLogoutSuccessHandler 对象。

  2. 使用 setPostLogoutRedirectUri()方法配置注销后的重定向 URL。

  3. 从 SecurityWebFilterChain bean 中定义的 logout()配置中调用 oidcLogoutSuccessHandler()方法。

SecurityConfig 类中的配置结果如下。

列表 11.11 配置 RP-Initiated Logout 和注销后的重定向

package com.polarbookshop.edgeservice.config;

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.reactive.
➥ EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.client.oidc.web.server.logout. 
➥ OidcClientInitiatedServerLogoutSuccessHandler; 
import org.springframework.security.oauth2.client.registration. 
➥ ReactiveClientRegistrationRepository; 
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.authentication.logout. 
➥ ServerLogoutSuccessHandler; 

@EnableWebFluxSecurity
public class SecurityConfig {

  @Bean
  SecurityWebFilterChain springSecurityFilterChain(
    ServerHttpSecurity http,
    ReactiveClientRegistrationRepository clientRegistrationRepository 
  ) {
    return http
      .authorizeExchange(exchange ->
        exchange.anyExchange().authenticated())
      .oauth2Login(Customizer.withDefaults())
      .logout(logout -> logout.logoutSuccessHandler(               ❶
        oidcLogoutSuccessHandler(clientRegistrationRepository))) 
      .build();
  }

  private ServerLogoutSuccessHandler oidcLogoutSuccessHandler( 
    ReactiveClientRegistrationRepository clientRegistrationRepository 
  ) { 
    var oidcLogoutSuccessHandler = 
        new OidcClientInitiatedServerLogoutSuccessHandler( 
          clientRegistrationRepository); 
    oidcLogoutSuccessHandler 
      .setPostLogoutRedirectUri("{baseUrl}");                      ❷
    return oidcLogoutSuccessHandler; 
  } 
}

❶ 定义了一个自定义处理程序,用于注销操作成功完成的场景

❷ 从 OIDC 提供者注销后,Keycloak 将用户重定向到由 Spring 动态计算的应用程序基本 URL(本地为 http://localhost:9000)。

注意:ReactiveClientRegistrationRepository bean 由 Spring Boot 自动配置,用于存储与 Keycloak 注册的客户端信息,并且它被 Spring Security 用于认证/授权目的。在我们的例子中,只有一个客户端:我们在 application.yml 文件中之前配置的那个。

我现在不会要求您测试注销功能。原因将在我们介绍 Polar Bookshop 系统的 Angular 前端之后变得明显。

基于 OpenID Connect/OAuth2 的用户身份验证功能现在已经完成,包括注销和可伸缩性问题。如果 Edge Service 使用模板引擎如 Thymeleaf 来构建前端,我们到目前为止所做的工作就足够了。然而,当您将受保护的后端应用与像 Angular 这样的 SPA 集成时,还有几个方面需要考虑。这将是下一节的重点。

11.5 将 Spring Security 与 SPA 集成

微服务架构和其他分布式系统的网络前端通常构建为一个或多个单页应用,使用 Angular、React 或 Vue 等框架。分析 SPA 的创建不在本书的范围内,但查看为了支持这样的前端客户端需要哪些更改是至关重要的。

到目前为止,您已经通过终端窗口与组成 Polar Bookshop 系统的服务进行了交互。在本节中,我们将添加一个 Angular 应用,它将成为系统的前端。它将由 NGINX 容器提供,并通过 Edge Service 提供的网关进行访问。支持 SPA 需要在 Spring Security 中进行一些额外的配置,以解决诸如跨源请求共享(CORS)和跨站请求伪造(CSRF)等问题。本节将展示如何进行这些操作。

11.5.1 运行 Angular 应用

Polar Bookshop 系统将使用 Angular 应用作为前端。由于这本书没有涵盖前端技术和模式,我已经准备了一个。我们只需要决定如何将其包含在 Polar Bookshop 系统中。

一个选项是让 Edge Service 提供 SPA 静态资源。通常,提供前端服务的 Spring Boot 应用会将源代码托管在 src/main/resources 中。当使用 Thymeleaf 等模板引擎时,这是一个方便的策略,但对于像 Angular 这样的 SPA,我更喜欢将代码保存在一个单独的模块中。SPA 有其自己的开发、构建和发布工具,因此拥有一个专门的文件夹更干净、更易于维护。然后你可以配置 Spring Boot 在构建时处理 SPA 的静态资源,并将它们包含在最终发布中。

另一个选项是让一个专门的服务来处理 Angular 静态资源的提供。这就是 Polar Bookshop 将使用的策略。我已经将 Angular 应用打包在一个 NGINX 容器中。NGINX(nginx.org)提供了 HTTP 服务器功能,对于提供由 HTML、CSS 和 JavaScript 文件组成的 Angular 应用的静态资源来说非常方便。

让我们继续在 Docker 中运行 Polar Bookshop 前端(polar-ui)。首先,前往您的 polar-deployment 仓库,并打开您的 Docker Compose 文件(docker/docker-compose.yml)。然后添加配置以运行 polar-ui 并通过端口 9004 暴露它。

列表 11.12 以容器形式运行 Angular 应用

version: "3.8"
services:
  ...

  polar-ui:
    image: "ghcr.io/polarbookshop/polar-ui:v1"   ❶
    container_name: "polar-ui"
    ports:
      - 9004:9004                                ❷
    environment:
      - PORT=9004                                ❸

❶ 我构建的用于打包 Angular 应用的容器镜像

❷ NGINX 将在端口 9004 上提供 SPA 服务。

❸ 配置 NGINX 服务器端口

与 Polar Bookshop 系统中的其他应用程序一样,我们不希望 Angular 应用程序可以直接从外部访问。相反,我们希望通过 Edge Service 提供的网关使其可访问。我们可以通过为 Spring Cloud Gateway 添加一个新的路由来实现这一点,以便将任何对静态资源的请求转发到 Polar UI 应用程序。

前往您的 Edge Service 项目(edge-service),打开 application.yml 文件,并按以下方式配置新路由。

列表 11.13 配置 SPA 静态资源的新网关路由

spring:
  gateway:
    routes:
      - id: spa-route                             ❶
        uri: ${SPA_URL:http://localhost:9004}     ❷
        predicates:                               ❸
          - Path=/,/*.css,/*.js,/favicon.ico 

❶ 路由 ID

❷ URI 值来自环境变量,否则使用指定的默认值。

❸ 断言是一个匹配根端点和 SPA 静态资源的路径列表。

Polar UI 应用程序的 URI 是通过环境变量(SPA_URL)的值计算得出的。如果没有定义,则使用第一个冒号(:)符号后面的默认值。

注意:当以容器形式运行 Edge Service 时,请记住配置 SPA_URL 环境变量。在 Docker 中,您可以使用容器名称和端口作为值,结果为 http://polar-ui:9004

让我们测试一下。首先,一起运行 Polar UI 容器、Redis 和 Keycloak。打开一个终端窗口,导航到您保存 Docker Compose 文件(polar-deployment/docker/docker-compose.yml)的文件夹,并运行以下命令:

$ docker-compose up -d polar-ui polar-redis polar-keycloak

然后,再次构建 Edge Service 项目,并运行应用程序(./gradlew bootRun)。最后,打开一个无痕浏览器窗口,并导航到 http://localhost:9000

Spring Security 被配置为保护所有端点和资源,因此您将自动重定向到 Keycloak 登录页面。在您以 Isabelle 或 Bjorn 身份进行身份验证后,您将被重定向回提供 Angular 前端的 Edge Service 根端点。

目前,您能做的事情不多。当 Spring Security 收到未经身份验证的请求时,会触发身份验证流程,但由于 CORS 问题,如果是 AJAX 请求则不会工作。此外,由于 Spring Security 启用了 CSRF 保护,POST 请求(包括注销操作)将失败。在接下来的章节中,我将向您展示如何更新 Spring Security 配置以克服这些问题。

在继续之前,使用 Ctrl-C 停止应用程序(但保持容器运行——您将需要它们)。

11.5.2 控制身份验证流程

在上一节中,你尝试访问 Edge Service 主页,并体验了自动重定向到 Keycloak 以提供用户名和密码的情况。当前端由服务器端渲染的页面组成(例如使用 Thymeleaf 时),这种行为运行良好,并且很方便,因为它不需要任何额外的配置。如果你尚未认证,或者你的会话已过期,Spring Security 将自动触发认证流程并将你的浏览器重定向到 Keycloak。

对于单页应用,事情的工作方式略有不同。Angular 应用程序是在浏览器通过执行标准 HTTP GET 请求访问根端点时由后端返回的。在此第一步之后,SPA 通过 AJAX 请求与后端交互。当 SPA 向受保护的端点发送未经认证的 AJAX 请求时,你不希望 Spring Security 返回一个 HTTP 302 响应重定向到 Keycloak。相反,你希望它返回一个带有错误状态的响应,如 HTTP 401 未授权。

不使用重定向与单页应用(SPAs)结合使用的主要原因是你可能会遇到跨源请求共享(CORS)问题。考虑以下场景:一个 SPA 从 https://client.polarbookshop.com 提供服务,并通过 AJAX 向 https://server.polarbookshop.com 的后端进行 HTTP 调用。由于这两个 URL 没有相同的源(相同的协议、域名和端口),通信被阻止。这是所有网络浏览器强制执行的标准的同源策略。

CORS 是一种机制,允许服务器接受来自基于浏览器的客户端(如 SPA)的 AJAX HTTP 调用,即使这两个客户端有不同的源。在 Polar Bookshop 中,我们通过 Edge Service 中实现的网关提供 Angular 前端(相同源)。因此,这两个组件之间没有 CORS 问题。然而,如果 Spring Security 被配置为对未经认证的 AJAX 调用返回重定向到 Keycloak(具有不同的源),则请求将被阻止,因为在 AJAX 请求期间不允许重定向到不同的源。

注意:要了解更多关于 Spring Security 中 CORS 的信息,你可以查看 Laurențiu Spilcă 所著的 Spring Security in Action(Manning,2020)的第十章,其中详细解释了该主题。对于 CORS 的全面解释,请参阅 Monsur Hossain 所著的 CORS in Action(Manning,2014)。

当将 Spring Security 配置更改为对未经认证的请求返回 HTTP 401 响应时,处理错误并调用后端以启动认证流程的责任就落在 SPA 身上。重定向仅在 AJAX 请求期间是问题。这里的关键部分是调用后端以启动用户认证的调用不是 Angular 发送的 AJAX 请求。相反,它是一个来自浏览器的标准 HTTP 调用,如下所示:

login(): void {
  window.open('/oauth2/authorization/keycloak', '_self');
}

我想强调,登录调用不是从 Angular HttpClient 发送的 AJAX 请求。相反,它指示浏览器调用登录 URL。Spring Security 公开了一个/oauth2/authorization/{registrationId}端点,您可以使用它根据 OAuth2/OIDC 启动身份验证流程。由于 Edge 服务的客户端注册标识符是 keycloak,因此登录端点将是/oauth2/authorization/keycloak。

要实现这一点,我们需要定义一个自定义 AuthenticationEntryPoint 来指示 Spring Security 在收到对受保护资源的未认证请求时回复 HTTP 401 状态。框架已经提供了一个 HttpStatusServerEntryPoint 实现,它非常适合这个场景,因为它允许您指定在用户需要认证时返回哪个 HTTP 状态。

列表 11.14 当用户未认证时返回 401

@EnableWebFluxSecurity
public class SecurityConfig {
  ...

  @Bean
  SecurityWebFilterChain springSecurityFilterChain(
    ServerHttpSecurity http,
    ReactiveClientRegistrationRepository clientRegistrationRepository
  ) {
    return http
      .authorizeExchange(exchange -> exchange.anyExchange().authenticated())
      .exceptionHandling(exceptionHandling -> 
        exceptionHandling.authenticationEntryPoint(                 ❶
          new HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED))) 
      .oauth2Login(Customizer.withDefaults())
      .logout(logout -> logout.logoutSuccessHandler(
      oidcLogoutSuccessHandler(clientRegistrationRepository)))
      .build();
  }
}

❶ 当由于用户未认证而抛出异常时,它回复一个 HTTP 401 响应。

在这一点上,Angular 应用程序可以明确拦截 HTTP 401 响应并触发身份验证流程。然而,由于 SPA 现在负责启动流程,我们需要允许对其静态资源的未认证访问。我们还想在不进行认证的情况下检索目录中的书籍,因此让我们允许对/books/**端点的 GET 请求。请更新 SecurityConfig 类中的 SecurityWebFilterChain bean,如下所示。

列表 11.15 允许未认证的 GET 请求访问 SPA 和书籍

@EnableWebFluxSecurity
public class SecurityConfig {
  ...

  @Bean
  SecurityWebFilterChain springSecurityFilterChain(
    ServerHttpSecurity http,
    ReactiveClientRegistrationRepository clientRegistrationRepository
  ) {
    return http
      .authorizeExchange(exchange -> exchange
 .pathMatchers("/", "/*.css", "/*.js", "/favicon.ico")
 .permitAll() ❶
 .pathMatchers(HttpMethod.GET, "/books/**")
 .permitAll() ❷
        .anyExchange().authenticated()                         ❸
      )
      .exceptionHandling(exceptionHandling -> exceptionHandling
        .authenticationEntryPoint(
        new HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED)))
      .oauth2Login(Customizer.withDefaults())
      .logout(logout -> logout.logoutSuccessHandler(
        oidcLogoutSuccessHandler(clientRegistrationRepository)))
      .build();
  }
}

❶ 允许未认证访问 SPA 静态资源

❷ 允许未认证读取访问目录中的书籍

❸ 其他任何请求都需要用户认证。

让我们测试一下 Edge Service 现在的工作情况。确保 Polar UI、Redis 和 Keycloak 容器仍在运行。接下来,构建并运行 Edge Service 应用程序(./gradlew bootRun),然后从一个隐身浏览器窗口中访问 http://localhost:9000。首先要注意的是,您不会重定向到登录页面,而是立即显示 Angular 前端应用程序。您可以通过点击右上角的登录按钮开始身份验证流程。

登录后,右上角菜单将包含一个注销按钮,只有当当前用户成功认证时才会显示。点击按钮注销。它应该触发注销流程,但由于 CSRF 问题而无法工作。您将在下一节中学习如何修复它。同时,使用 Ctrl-C 停止应用程序。

11.5.3 防止跨站请求伪造

前端和后端的交互基于会话 cookie。用户成功通过 OIDC/OAuth2 策略认证后,Spring 将生成一个会话标识符以匹配认证上下文,并将其作为 cookie 发送到浏览器。任何随后的对后端的请求都必须包含会话 cookie,Spring Security 可以从中检索与特定用户关联的令牌并验证请求。

然而,会话 cookie 并不足以验证请求,这些请求容易受到跨站请求伪造(CSRF)攻击的影响。CSRF 影响修改 HTTP 请求,如 POST、PUT 和 DELETE。攻击者可能诱导用户执行他们无意为之的请求,伪造的请求可能造成诸如从您的银行账户转账或损害关键数据等后果。

警告:许多在线教程和指南在配置 Spring Security 时首先展示如何禁用 CSRF 保护。如果不解释原因或考虑后果,这样做是危险的。我建议除非有充分的理由不启用保护(您将在第十二章中看到一个很好的理由),否则请保持保护启用状态。作为一个一般性指南,面向浏览器的应用程序,如 Edge Service,应该受到 CSRF 攻击的保护。

幸运的是,Spring Security 内置了对这种攻击的保护。这种保护基于框架生成的所谓 CSRF 令牌,该令牌在会话开始时提供给客户端,并要求与任何更改状态的请求一起发送。

注意:要了解更多关于 Spring Security 中 CSRF 保护的信息,您可以查看 Laurențiu Spilcă 所著的 Spring Security in Action 一书的第十章(Manning,2020 年),其中详细解释了该主题。

在上一节中,您尝试注销,但请求失败了。由于注销操作是通过向 /logout 端点发送 POST 请求来提供的,应用程序期望接收 Spring Security 为该用户会话生成的 CSRF 令牌。默认情况下,生成的 CSRF 令牌作为 HTTP 头发送到浏览器。然而,Angular 应用程序无法与这种做法协同工作,并期望接收作为 cookie 的令牌值。Spring Security 支持这一特定要求,但默认情况下并未启用。

您可以指导 Spring Security 通过 ServerHttpSecurity 和 CookieServerCsrfTokenRepository 类提供的 csrf() DSL 将 CSRF 令牌作为 cookies 提供给客户端。对于命令式应用程序,这已经足够了。然而,对于像 Edge Service 这样的反应式应用程序,您需要额外采取一步来确保 CsrfToken 值确实被提供。

在第八章中,你了解到需要订阅反应式流才能激活它们。目前,CookieServerCsrfTokenRepository 没有确保对 CsrfToken 的订阅,因此你必须显式地在 WebFilter bean 中提供一个解决方案。这个问题应该在 Spring Security 的未来版本中得到解决(请参阅 GitHub 上的问题 5766:mng.bz/XW89)。目前,请按以下方式更新 SecurityConfig 类。

列表 11.16 配置 CSRF 以支持基于 cookie 的策略用于单页应用(SPAs)

@EnableWebFluxSecurity
public class SecurityConfig {
  ...

  @Bean
  SecurityWebFilterChain springSecurityFilterChain(
    ServerHttpSecurity http,
    ReactiveClientRegistrationRepository clientRegistrationRepository
  ) {
    return http
      ...
      .csrf(csrf -> csrf.csrfTokenRepository(                   ❶
        CookieServerCsrfTokenRepository.withHttpOnlyFalse())) 
      .build();
  }

  @Bean 
  WebFilter csrfWebFilter() {                                   ❷
    return (exchange, chain) -> { 
      exchange.getResponse().beforeCommit(() -> Mono.defer(() -> { 
        Mono<CsrfToken> csrfToken = 
          exchange.getAttribute(CsrfToken.class.getName()); 
        return csrfToken != null ? csrfToken.then() : Mono.empty(); 
      })); 
      return chain.filter(exchange); 
    }; 
  } 
}

❶ 使用基于 cookie 的策略与 Angular 前端交换 CSRF 令牌

❷ 具有仅订阅 CsrfToken 反应式流并确保其值正确提取的唯一目的的过滤器

让我们验证登出流程现在是否工作。确保 Polar UI、Redis 和 Keycloak 容器仍在运行。接下来,构建并运行应用程序(./gradlew bootRun),然后从隐身浏览器窗口访问 http://localhost:9000。通过点击右上角的登录按钮开始身份验证流程。然后点击登出按钮。在底层,Spring Security 现在将接受你的登出请求(Angular 将 CSRF 令牌值从 cookie 作为 HTTP 头部添加),终止你的网络会话,将请求传播到 Keycloak,并最终将你重定向到主页,未认证。

多亏了这个改动,你现在也可以执行任何 POST、PUT 和 DELETE 请求,而不会收到 CSRF 错误。请随意探索 Angular 应用程序。如果你启动了目录服务和订单服务,你可以尝试向目录添加新书、修改它们或下订单。

目前,Isabelle 和 Bjorn 可以执行任何操作,这不是我们想要的,因为客户(如 Bjorn)不应被允许管理图书目录。下一章将介绍授权,你将看到如何使用不同的访问策略保护每个端点。然而,在处理授权之前,我们需要编写 autotests 来覆盖新的功能。这将在下一节中介绍。

在继续之前,使用 Ctrl-C 停止应用程序,并使用 docker-compose down 停止所有容器(从 polar-deployment/docker)。

11.6 测试 Spring Security 和 OpenID Connect

编写自动测试的重要性通常对开发者来说很明显。然而,当涉及到安全性时,事情可能会变得具有挑战性,并且由于其复杂性,有时它最终没有被自动化测试覆盖。幸运的是,Spring Security 提供了几个实用工具,以帮助您以简单的方式将安全性包含在您的切片和集成测试中。

在本节中,你将学习如何使用 Spring Security 的 WebTestClient 支持来测试 OIDC 认证和 CSRF 保护。让我们开始吧。

11.6.1 测试 OIDC 认证

在第八章中,我们通过依赖@SpringWebFlux 注解和 WebTestClient 测试了 Spring WebFlux 暴露的 REST 控制器。在本章中,我们添加了一个新的控制器(UserController),因此让我们为它编写一些不同安全设置下的自动测试。

首先,打开您的 Edge Service 项目,在 src/test/java 中创建一个带有@WebFluxTest(UserController.class)注解的 UserControllerTests 类,并自动装配一个 WebTestClient Bean。到目前为止,设置与第八章中使用的类似:一个针对 Web 层的切片测试。但是,我们需要一些额外的设置来覆盖安全场景,如下所示。

列表 11.17 定义一个类来测试 UserController 的安全策略

@WebFluxTest(UserController.class)
@Import(SecurityConfig.class)        ❶
class UserControllerTests {

  @Autowired
  WebTestClient webClient;

  @MockBean                          ❷
  ReactiveClientRegistrationRepository clientRegistrationRepository;
}

❶ 导入应用程序的安全配置

❷ 一个模拟的 Bean,用于在检索客户端注册信息时跳过与 Keycloak 的交互

由于我们已将 Edge Service 配置为在请求未认证时返回 HTTP 401 响应,因此让我们验证在调用/user 端点之前未进行认证时会发生这种情况:

@Test
void whenNotAuthenticatedThen401() {
  webClient
    .get()
    .uri("/user")
    .exchange()
    .expectStatus().isUnauthorized();
}

要测试用户已认证的场景,我们可以使用 SecurityMockServerConfigurers 提供的 mockOidcLogin()配置对象来模拟 OIDC 登录,合成一个 ID Token,并相应地修改 WebTestClient 的请求上下文。

/user 端点通过 OidcUser 对象从 ID Token 中读取声明,因此我们需要构建一个包含用户名、名和姓的 ID Token(目前控制器中的角色是硬编码的)。以下代码展示了如何实现这一点:

@Test
void whenAuthenticatedThenReturnUser() {
  var expectedUser = new User("jon.snow", "Jon", "Snow",
    List.of("employee", "customer"));                            ❶

  webClient
    .mutateWith(configureMockOidcLogin(expectedUser))            ❷
    .get()
    .uri("/user")
    .exchange()
    .expectStatus().is2xxSuccessful()
    .expectBody(User.class)                                      ❸
    .value(user -> assertThat(user).isEqualTo(expectedUser));
}

private SecurityMockServerConfigurers.OidcLoginMutator
➥ configureMockOidcLogin(User expectedUser) {
  return SecurityMockServerConfigurers.mockOidcLogin().idToken(
   builder -> {                                                  ❹
      builder.claim(StandardClaimNames.PREFERRED_USERNAME,
        expectedUser.username());
      builder.claim(StandardClaimNames.GIVEN_NAME,
        expectedUser.firstName());
      builder.claim(StandardClaimNames.FAMILY_NAME,
        expectedUser.lastName());
    });
}

❶ 预期的已认证用户

❷ 基于 OIDC 定义一个认证上下文并使用预期的用户

❸ 期望一个与当前已认证用户信息相同的 User 对象

❹ 构建一个模拟的 ID Token

最后,按照以下方式运行测试:

$ ./gradlew test --tests UserControllerTests

Spring Security 提供的测试实用工具覆盖了广泛的场景,并且与 WebTestClient 很好地集成。在下一节中,您将看到如何使用类似的方法来测试 CSRF 保护。

11.6.2 测试 CSRF

在 Spring Security 中,CSRF 保护默认应用于所有修改性 HTTP 请求(如 POST、PUT 和 DELETE)。正如您在前面章节中看到的,Edge Service 接受对/logout 端点的 POST 请求以启动注销流程,此类请求需要有效的 CSRF 令牌才能执行。此外,我们还配置了 OIDC 的 RP-Initiated Logout 功能,因此对/logout 的 POST 请求实际上会导致 HTTP 302 响应,将浏览器重定向到 Keycloak 以注销用户。

创建一个新的 SecurityConfigTests 类,并使用您在上一节中学到的相同策略来设置一个带有安全支持的 Spring WebFlux 测试,如下所示。

列表 11.18 定义一个用于测试认证流程的类

@WebFluxTest
@Import(SecurityConfig.class)   ❶
class SecurityConfigTests {

  @Autowired
  WebTestClient webClient;

  @MockBean                     ❷
  ReactiveClientRegistrationRepository clientRegistrationRepository;
}

❶ 导入应用程序的安全配置

❷ 一个模拟的 Bean,用于在检索客户端注册信息时跳过与 Keycloak 的交互

然后添加一个测试用例来检查在向 /logout 发送带有正确 OIDC 登录和 CSRF 上下文的 HTTP POST 请求后,应用程序是否返回 HTTP 302 响应。

@Test
void whenLogoutAuthenticatedAndWithCsrfTokenThen302() {
  when(clientRegistrationRepository.findByRegistrationId("test"))
    .thenReturn(Mono.just(testClientRegistration()));

  webClient
    .mutateWith(
     SecurityMockServerConfigurers.mockOidcLogin())                 ❶
    .mutateWith(SecurityMockServerConfigurers.csrf())               ❷
    .post()
    .uri("/logout")
    .exchange()
    .expectStatus().isFound();                                      ❸
}

private ClientRegistration testClientRegistration() {
  return ClientRegistration.withRegistrationId("test")              ❹
    .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
    .clientId("test")
    .authorizationUri("https://sso.polarbookshop.com/auth")
    .tokenUri("https://sso.polarbookshop.com/token")
    .redirectUri("https://polarbookshop.com")
    .build();
}

❶ 使用模拟的 ID Token 来验证用户

❷ 增强请求以提供所需的 CSRF 令牌

❸ 响应是重定向到 Keycloak 以传播注销操作。

❹ Spring Security 使用的模拟 ClientRegistration 来获取联系 Keycloak 的 URL

最后,按照以下方式运行测试:

$ ./gradlew test --tests SecurityConfigTests

总是可以在本书附带的源代码仓库中找到更多测试示例。当涉及到安全问题时,单元测试和集成测试对于确保应用程序的正确性至关重要,但它们并不足够。这些测试覆盖了默认的安全配置,而在生产环境中可能有所不同。这就是为什么我们还需要在部署管道的验收阶段进行以安全为导向的自动测试(如第三章所述),以测试在类似生产环境中部署的应用程序。

Polar Labs

到目前为止,唯一一个用户可以直接访问的应用程序是 Edge 服务。所有其他 Spring Boot 应用程序都从它们部署的环境内部相互交互。

在同一 Docker 网络或 Kubernetes 集群内的服务间交互可以通过容器名称或服务名称分别配置。例如,Edge 服务通过 Docker 上的 http://polar-ui:9004 URL(<容器名称>:<容器端口>)和 Kubernetes 上的 http://polar-ui URL(服务名称)将请求转发到 Polar UI。

Keycloak 与其他不同,因为它涉及到服务间交互(目前只是与 Edge 服务的交互)以及通过网页浏览器与最终用户的交互。在生产环境中,Keycloak 将通过一个公开的 URL 可用,该 URL 供应用程序和用户使用,因此不会有问题。那么在本地环境中呢?

由于我们在本地工作时并不处理公开的 URL,我们需要进行不同的配置。在 Docker 上,我们可以通过使用在安装软件时自动配置的特殊 URL http://host.docker.internal 来解决这个问题。它解析为您的本地主机 IP 地址,并且可以在 Docker 网络外部和内部使用。

在 Kubernetes 上,我们没有通用的 URL 来让集群内的 Pods 访问本地主机。这意味着 Edge 服务将通过其服务名称(http://polar-keycloak)与 Keycloak 交互。当 Spring Security 将用户重定向到 Keycloak 进行登录时,浏览器将返回一个错误,因为 http://polar-keycloak URL 无法在集群外部解析。为了实现这一点,我们可以更新本地 DNS 配置,将 polar-keycloak 主机名解析为集群 IP 地址。然后,一个专门的 Ingress 将使得在请求被定向到 polar-keycloak 主机名时可以访问 Keycloak。

如果你使用 Linux 或 macOS,你可以在/etc/hosts 文件中将 polar-keycloak 主机名映射到 minikube 本地 IP 地址。在 Linux 上,IP 地址是 minikube ip --profile polar 命令返回的 IP 地址(如第九章所述)。在 macOS 上,它将是 127.0.0.1。打开终端窗口,并运行以下命令(确保用你的操作系统根据操作系统替换占位符,集群 IP 地址):

$ echo "<ip-address> polar-keycloak" | sudo tee -a /etc/hosts

在 Windows 上,你必须将 polar-keycloak 主机名映射到 hosts 文件中的 127.0.0.1。以管理员身份打开 PowerShell 窗口,并运行以下命令:

$ Add-Content C:\Windows\System32\drivers\etc\hosts "127.0.0.1 polar-keycloak"

我已更新了部署 Polar Bookshop 所有支持服务的脚本,包括 Keycloak 和 Polar UI。你可以从书中附带的代码仓库的/Chapter11/11-end/polar-deployment/kubernetes/platform/development 文件夹中获取它们(github.com/ThomasVitale/cloud-native-spring-in-action),并将它们复制到你的 polar-deployment 仓库中的相同路径。部署还包括为 Keycloak 配置专用 Ingress,接受指向 polar-keycloak 主机名的请求。

到目前为止,你可以运行./create-cluster.sh 脚本(polar-deployment/kubernetes/platform/development)来启动一个 minikube 集群并部署 Polar Bookshop 的所有支持服务。如果你使用 Linux,你将能够直接访问 Keycloak。如果你使用 macOS 或 Windows,请记住首先运行 minikube tunnel --profile polar 命令。无论哪种方式,你都可以打开一个浏览器窗口,并在 polar-keycloak/(包括最后的斜杠)访问 Keycloak。

最后,在更新 Edge Service 的部署脚本以配置 Polar UI 和 Keycloak 的 URL 之后,尝试在 Kubernetes 上运行整个系统。你可以参考书中附带的代码仓库中的 Chapter11/11-end 文件夹以检查最终结果(github.com/ThomasVitale/cloud-native-spring-in-action)。

下一章将扩展关于安全性的主题。它将涵盖如何从 Edge Service 传播身份验证上下文到下游应用程序,以及如何配置授权。

摘要

  • 访问控制系统需要识别(你是谁?),身份验证(你能证明是你自己吗?),以及授权(你被允许做什么?)。

  • 在云原生应用中实现身份验证和授权的常见策略是基于 JWT 作为数据格式,OAuth2 作为授权框架,以及 OpenID Connect 作为身份验证协议。

  • 当使用 OIDC 身份验证时,客户端应用程序启动流程并将实际身份验证的授权服务器委托给授权服务器。然后授权服务器向客户端颁发 ID 令牌。

  • ID 令牌包含有关用户身份验证的信息。

  • Keycloak 是一个支持 OAuth2 和 OpenID Connect 的身份和访问管理解决方案,可以用作授权服务器。

  • Spring Security 提供了对 OAuth2 和 OpenID Connect 的原生支持,你可以用它将 Spring Boot 应用程序转换为 OAuth2 客户端。

  • 在 Spring Security 中,你可以在 SecurityWebFilterChain 实例中配置认证和授权。要启用 OIDC 认证流程,你可以使用 oauth2Login() DSL。

  • 默认情况下,Spring Security 提供了一个 /logout 端点用于用户登出。

  • 在 OIDC/OAuth2 的上下文中,我们还需要将登出请求传播到授权服务器(如 Keycloak),以从那里登出用户。我们可以通过 Spring Security 提供的 OidcClientInitiatedServerLogoutSuccessHandler 类支持的 RP-Initiated Logout 流程来实现这一点。

  • 当一个安全的 Spring Boot 应用程序作为单页应用(SPA)的后端时,我们需要通过 cookies 配置 CSRF 保护,并实现一个认证入口点,当请求未认证时返回 HTTP 401 响应(而不是默认的 HTTP 302 响应自动重定向到授权服务器)。

  • Spring Security 测试依赖提供了几个方便的实用工具用于安全测试。

  • WebTestClient 实例可以通过特定的配置来增强其请求上下文,以支持 OIDC 登录和 CSRF 保护。

12 安全:授权和审计

本章涵盖

  • 使用 Spring Cloud Gateway 和 OAuth2 进行授权和角色

  • 使用 Spring Security 和 OAuth2 保护 API(强制)

  • 使用 Spring Security 和 OAuth2 保护 API(响应式)

  • 使用 Spring Security 和 Spring Data 保护并审计数据

在上一章中,我介绍了云原生应用的访问控制系统。你看到了如何使用 Spring Security 和 OpenID Connect 向边缘服务添加认证,管理用户会话生命周期,并在将 Angular 前端与 Spring Boot 集成时解决 CORS 和 CSRF 问题。

通过将认证步骤委托给 Keycloak,边缘服务不受特定认证策略的影响。例如,我们使用了 Keycloak 提供的登录表单功能,但我们也可以启用通过 GitHub 的社会登录或依赖于现有的 Active Directory 来认证用户。边缘服务只需要支持 OIDC 来验证认证是否正确发生,并通过 ID 令牌获取有关用户的信息。

仍然有一些问题我们没有解决。极地书店是一个分布式系统,用户在成功通过 Keycloak 认证后,边缘服务应该代表用户与目录服务和订单服务交互。我们如何安全地将认证上下文传播到其他系统应用中?本章将帮助你使用 OAuth2 和访问令牌解决这个问题。

在处理认证之后,我们将解决授权步骤。目前,极地书店的客户和员工都可以在系统中执行任何操作。本章将指导你通过 OAuth2、Spring Security 和 Spring Data 处理几个授权场景:

  • 我们将使用基于角色的访问控制(RBAC)策略来保护 Spring Boot 暴露的 REST 端点,根据用户是否是书店的客户或员工。

  • 我们将配置数据审计以跟踪哪个用户做了什么更改。

  • 我们将强制实施数据保护规则,以确保只有数据的所有者才能访问它。

最后,你将探索如何使用 Spring Boot、Spring Security 和 Testcontainers 测试这些更改。

注意:本章示例的源代码可在 Chapter12/12-begin 和 Chapter12/12-end 文件夹中找到,包含项目的初始和最终状态(github.com/ThomasVitale/cloud-native-spring-in-action)。

12.1 使用 Spring Cloud Gateway 和 OAuth2 进行授权和角色

在上一章中,我们向 Polar Bookshop 添加了用户认证功能。边缘服务是系统的访问点,因此它是解决诸如安全等横切关注点的绝佳候选者。因此,我们让它负责用户认证。边缘服务启动认证流程,但使用 OpenID Connect 协议将实际的认证步骤委托给 Keycloak。

一旦用户通过 Keycloak 成功认证,边缘服务从 Keycloak 接收一个包含认证事件信息的 ID 令牌,并使用户的浏览器启动一个认证会话。同时,Keycloak 还发放一个访问令牌,该令牌用于根据 OAuth2 协议代表用户授予边缘服务访问下游应用程序的权限。

OAuth2 是一个授权框架,它使一个应用程序(称为客户端)能够代表用户获取对另一个应用程序(称为资源服务器)提供的受保护资源的有限访问权限。当用户通过边缘服务认证并请求访问其书籍订单时,OAuth2 为边缘服务提供了一种解决方案,以便代表该用户从订单服务检索订单。此解决方案依赖于一个可信的第三方(称为授权服务器),该授权服务器向边缘服务发放访问令牌,并授予从订单服务访问用户书籍订单的权限。

你可能已经从我们在上一章中采用的 OIDC 认证流程中认出了这些角色。正如预期的那样,OIDC 是建立在 OAuth2 之上的一个身份层,并依赖于相同的基本概念:

  • 授权服务器—负责认证用户并发放、刷新和撤销访问令牌的实体。在 Polar Bookshop 中,这是 Keycloak。

  • 用户—也称为资源所有者,这是通过授权服务器登录以获取对客户端应用程序认证访问权限的人类。它也是授予客户端访问由资源服务器提供的受保护资源的人类或服务。在 Polar Bookshop 中,它可以是客户或员工。

  • 客户端—需要用户认证并请求用户代表其访问受保护资源的应用程序。它可以是一个移动应用程序、基于浏览器的应用程序、服务器端应用程序,甚至是智能电视应用程序。在 Polar Bookshop 中,它是边缘服务。

  • 资源服务器—这是托管客户端代表用户想要访问的受保护资源的应用程序。在 Polar Bookshop 中,目录服务和订单服务是资源服务器。调度服务与其他应用程序解耦,不会代表用户访问。因此,它不会参与 OAuth2 的设置。

图 12.1 显示了四个参与者如何在 Polar Bookshop 架构中映射。

12-01

图 12.1 Polar Bookshop 架构中 OIDC/OAuth2 角色分配给实体的方式

边缘服务可以通过 Keycloak 在 OIDC 身份验证阶段颁发的访问令牌代表用户访问下游应用程序。在本节中,你将了解如何在边缘服务中配置 Spring Cloud Gateway 以在请求被路由到目录服务和订单服务时使用访问令牌。

在上一章中,我们定义了两个用户:伊莎贝尔同时拥有员工和客户角色,而比约恩只有客户角色。在本节中,你还将学习如何将此信息包含在 ID 令牌和访问令牌中,以便 Spring Security 可以读取它并设置基于角色的访问控制(RBAC)机制。

注意:在 Polar Bookshop 中,OAuth2 客户端(边缘服务)和 OAuth2 资源服务器(目录服务和订单服务)属于同一系统,但当 OAuth2 客户端是第三方应用程序时,可以使用相同的框架。事实上,这正是 OAuth2 的原始用例,也是它为什么如此受欢迎的原因。使用 OAuth2,像 GitHub 或 Twitter 这样的服务允许你授权第三方应用程序对你的账户进行有限的访问。例如,你可以授权一个调度应用程序代表你发布推文,而不必公开你的 Twitter 凭证。

12.1.1 从 Spring Cloud Gateway 到其他服务的令牌中继

用户成功通过 Keycloak 进行身份验证后,边缘服务(OAuth2 客户端)会收到一个 ID 令牌和一个访问令牌:

  • ID 令牌——这代表一个成功的身份验证事件,并包含有关已认证用户的信息。

  • 访问令牌——这代表授予 OAuth2 客户端代表用户访问由 OAuth2 资源服务器提供的受保护数据的授权。

在边缘服务中,Spring Security 使用 ID 令牌提取有关已认证用户的信息,为当前用户会话设置上下文,并通过 OidcUser 对象使数据可用。这正是你在上一章中看到的。

访问令牌授予边缘服务代表用户对目录服务和订单服务(OAuth2 资源服务器)进行授权访问。在我们安全地保护了这两个应用程序之后,边缘服务必须在所有路由到它们的请求中包含访问令牌作为授权 HTTP 头。与 ID 令牌不同,边缘服务不会读取访问令牌的内容,因为它不是预期的受众。它存储从 Keycloak 接收到的访问令牌,并将其原样包含在任何请求到受保护端点的请求中。

这种模式被称为令牌中继,Spring Cloud Gateway 作为内置过滤器支持这种模式,因此你不需要自己实现任何内容。当过滤器启用时,访问令牌会自动包含在发送到下游应用程序之一的请求中。图 12.2 说明了令牌中继模式的工作原理。

12-02

图 12.2 用户认证后,边缘服务将访问令牌中继给订单服务,代表用户调用其受保护的端点。

让我们看看如何在 Edge Service 中配置访问令牌中继。

注意:访问令牌在 Keycloak 中配置了有效期,并且应该尽可能短,以减少令牌泄露时的利用时间窗口。可接受的长度是 5 分钟。当令牌过期时,OAuth2 客户端可以使用一种称为刷新令牌的第三种令牌请求授权服务器的新令牌(该令牌也有有效期)。刷新机制由 Spring Security 透明地处理,这里不再进一步描述。

在 Spring Cloud Gateway 中采用令牌中继模式

Spring Cloud Gateway 将令牌中继模式实现为一个过滤器。在 Edge Service 项目(edge-service)中,打开 application.yml 文件,将 TokenRelay 添加为默认过滤器,因为我们希望它应用于所有路由。

列表 12.1 在 Spring Cloud Gateway 中启用令牌中继模式

spring:
  cloud:
    gateway:
      default-filters:
        - SaveSession
        - TokenRelay      ❶

❶ 启用在调用下游服务时传播访问令牌的功能

启用过滤器后,Spring Cloud Gateway 负责在所有发出的请求中将正确的访问令牌作为授权头传递给目录服务和订单服务。例如:

GET /orders
Authorization: Bearer <access_token>

注意:与 ID Tokens 不同,ID Tokens 是 JWT,OAuth2 框架不对访问令牌的数据格式进行强制要求。它们可以是任何基于字符串的形式。尽管如此,最流行的格式是 JWT,因此我们将在消费者端(目录服务和订单服务)以这种方式解析访问令牌。

默认情况下,Spring Security 将当前认证用户的访问令牌存储在内存中。当你有多个边缘服务实例运行时(在云生产环境中始终如此,以确保高可用性),你将遇到由于应用程序的有状态性而产生的问题。云原生应用程序应该是无状态的。让我们解决这个问题。

在 Redis 中存储访问令牌

Spring Security 将访问令牌存储在 OAuth2AuthorizedClient 对象中,该对象可以通过 ServerOAuth2AuthorizedClientRepository bean 访问。该存储库的默认实现采用内存策略进行持久化。这就是使边缘服务成为有状态应用程序的原因。我们如何使其无状态且可扩展?

做这件事的一个简单方法是将 OAuth2AuthorizedClient 对象存储在 Web 会话中而不是内存中,这样 Spring Session 就会自动将其拾取并存储在 Redis 中,就像它处理 ID Tokens 一样。幸运的是,框架已经提供了一个实现 ServerOAuth2AuthorizedClientRepository 接口的 WebSessionServerOAuth2AuthorizedClientRepository 实现。图 12.3 说明了所有提到的对象是如何相互关联的。

12-03

图 12.3 存储当前认证用户访问令牌的 Spring Security 中涉及的主要类

在 Edge Service 项目中,打开 SecurityConfig 类,并使用将访问令牌存储在 Web 会话中的实现来定义一个类型为 ServerOAuth2AuthorizedClientRepository 的 bean。

列表 12.2 在 Web 会话中保存 OAuth2AuthorizedClient 对象

@EnableWebFluxSecurity
public class SecurityConfig {

  @Bean                         ❶
  ServerOAuth2AuthorizedClientRepository authorizedClientRepository() { 
    return new WebSessionServerOAuth2AuthorizedClientRepository(); 
  } 

  ...
}

❶ 定义一个存储访问令牌的 Web 会话的仓库

警告:作为 JWT 定义的访问令牌应谨慎处理。它们是携带令牌,这意味着任何应用程序都可以在 HTTP 请求中使用它们,并访问 OAuth2 资源服务器。在后端而不是在单页应用(SPA)中处理 OIDC/OAuth2 流程提供了更好的安全性,因为我们不会向浏览器暴露任何令牌。然而,可能还有其他风险需要管理,因此请仔细考虑您系统的信任边界

在下一节中,您将看到如何增强 ID 令牌和访问令牌,以传播有关用户角色的信息。

12.1.2 自定义令牌和传播用户角色

ID 令牌和访问令牌都可以包含有关用户的不同信息,这些信息以 JWT 中的声明格式呈现。声明是 JSON 格式中的简单键/值对。例如,OpenID Connect 定义了几个标准声明来携带有关用户的信息,如 given_name、family_name、preferred_username 和 email。

对此类声明的访问通过作用域进行控制,这是 OAuth2 提供的一种机制,用于限制 OAuth2 客户端可以访问的数据。您可以将作用域视为分配给应用程序而不是用户的角色。在前一章中,我们使用 Spring Security 将 Edge Service 配置为 OAuth2 客户端,并使用 openid 作用域进行配置。该作用域授予 Edge Service 访问认证用户身份(在 sub 声明中提供)的权限。

可能您已经使用 GitHub 或 Google(基于 OAuth2 的社会登录)登录到第三方网站。如果您这样做了,您可能已经注意到在认证步骤之后,服务向您提出了一个关于您同意第三方访问的 GitHub 或 Google 账户信息的第二个请求。这个同意功能基于作用域,根据分配的作用域授予第三方(OAuth2 客户端)特定的权限。

关于 Edge Service,我们可以提前决定它应该被授予的作用域。本节将向您展示如何配置一个带有分配给认证用户的角色列表的角色声明。然后您将使用角色作用域来授予 Edge Service 访问该声明的权限,并指示 Keycloak 将其包含在 ID 令牌和访问令牌中。

在继续之前,您需要启动并运行一个 Keycloak 容器。打开一个终端窗口,导航到您保存 Docker Compose 文件的文件夹,并运行以下命令:

$ docker-compose up -d polar-keycloak

如果您没有跟上,可以参考配套仓库中的 Chapter12/12-begin/polar-deployment/docker/docker-compose.yml。

注意:稍后我会提供一个 JSON 文件,您可以在启动 Keycloak 容器时使用它来加载整个配置,而无需担心其持久性(正如我在上一章中所做的那样)。如果您想直接遵循这个第二个选项,我仍然邀请您阅读这一部分,因为它提供了您在进入本章的 Spring Security 部分时所需的必要信息。

在 Keycloak 中配置用户角色的访问

Keycloak 预先配置了一个作用域,您可以使用它来授予应用程序访问包含在角色声明中的用户角色的权限。然而,默认的角色列表表示方式不太方便使用,因为它被定义为嵌套对象。让我们改变这一点。

一旦 Keycloak 启动并运行,打开一个浏览器窗口,转到 http://localhost:8080,使用 Docker Compose 文件中定义的相同凭据(用户/密码)登录到管理控制台,并选择 PolarBookshop 领域。然后从左侧菜单中选择客户端作用域。在新页面上(图 12.4),您将找到 Keycloak 中所有预配置的作用域列表,并且您可以选择创建新的作用域。在我们的情况下,我们想要自定义现有的角色作用域,因此点击它以打开其设置。

12-04

图 12.4 创建和管理客户端作用域

在角色作用域页面中,打开映射器选项卡。在那里,您可以定义给定作用域提供的访问权限的声明集(即映射)。默认情况下,Keycloak 已经定义了一些映射器来将声明映射到角色作用域。我们感兴趣的是领域角色映射器,它将用户领域角色(包括员工和客户)映射到一个 JWT 声明。选择该映射器。

领域角色映射器的设置页面提供了一些自定义选项。我们想要改变两件事:

  • 令牌声明名称应该是 roles 而不是 realm_access.roles(因此我们将删除嵌套对象)。

  • 角色声明应包含在 ID 令牌和访问令牌中,因此我们必须确保这两个选项都已启用。我们需要两者,因为边缘服务从 ID 令牌中读取声明,而目录服务和订单服务从访问令牌中读取声明。边缘服务不是访问令牌的目标受众,它将原始状态转发给下游应用程序。

图 12.5 显示了最终设置。完成操作后,请点击保存。

12-05

图 12.5 配置映射器以将用户的领域角色包含在角色 JWT 声明中

注意:在本书附带的源代码仓库中,我包含了一个 JSON 文件,您可以在将来启动 Keycloak 容器时使用它来加载整个配置,包括有关角色的最新更改(Chapter12/12-end/polar-deployment/docker/keycloak/full-realm-config.json)。我建议更新您的 polar-keycloak 容器定义在 Docker Compose 中使用此新的 JSON 文件。

在继续下一节之前,停止任何正在运行的容器(docker-compose down)。

在 Spring Security 中配置对用户角色的访问

Keycloak 现在已配置为在 ID Token 和访问令牌中都返回认证用户角色,但只有当 OAuth2 客户端(边缘服务)请求角色范围时,才会返回角色声明。

在边缘服务项目中,打开 application.yml 文件,并更新客户端注册配置以包括角色范围。

列表 12.3 将角色范围分配给边缘服务

spring:
  security:
    oauth2:
      client:
        registration:
          keycloak:
            client-id: edge-service
            client-secret: polar-keycloak-secret
            scope: openid,roles                     ❶
        provider:
          keycloak:
            issuer-uri: http://localhost:8080/realms/PolarBookshop

❶ 将“roles”添加到作用域列表中,以便边缘服务可以访问用户角色

接下来,您将了解如何从 ID Token 中提取当前认证用户的角色。

从 ID Token 中提取用户角色

在上一章中,我们在边缘服务项目的 UserController 类中硬编码了用户角色列表,因为我们还没有在 ID Token 中获得它们。现在我们有了,让我们重构实现,从 OidcUser 类中获取当前认证用户的角色,该类使我们能够访问 ID Token 中的声明,包括全新的角色声明。

列表 12.4 通过 OidcUser 从 ID Token 中提取用户角色列表

@RestController
public class UserController {

  @GetMapping("user")
  public Mono<User> getUser(@AuthenticationPrincipal OidcUser oidcUser) {
    var user = new User(
      oidcUser.getPreferredUsername(),
      oidcUser.getGivenName(),
      oidcUser.getFamilyName(),
      oidcUser.getClaimAsStringList("roles")    ❶
    );
    return Mono.just(user);
  }
}

❶ 获取“roles”声明并将其提取为字符串列表

最后,请记得更新 UserControllerTests 中的测试设置,以便模拟 ID Token 包含角色声明。

列表 12.5 将角色列表添加到模拟 ID Token

@WebFluxTest(UserController.class)
@Import(SecurityConfig.class)
class UserControllerTests {

  ...

  private SecurityMockServerConfigurers.OidcLoginMutator
    configureMockOidcLogin(User expectedUser)
  {
    return mockOidcLogin().idToken(builder -> {
      builder.claim(StandardClaimNames.PREFERRED_USERNAME,
        expectedUser.username());
      builder.claim(StandardClaimNames.GIVEN_NAME,
        expectedUser.firstName());
      builder.claim(StandardClaimNames.FAMILY_NAME,
        expectedUser.lastName());
      builder.claim("roles", expectedUser.roles());    ❶
    });
  }
}

❶ 向模拟 ID Token 添加“roles”声明

您可以通过运行以下命令来验证更改是否正确:

$ ./gradlew test --tests UserControllerTests

注意:Keycloak 中配置的角色声明将包括我们的自定义角色(员工和客户)以及一些由 Keycloak 本身管理和分配的额外角色。

到目前为止,我们已经配置了 Keycloak 将用户角色包含在令牌中,并更新了边缘服务以将访问令牌转发给下游应用程序。我们现在可以开始使用 Spring Security 和 OAuth2 保护目录服务和订单服务了。

12.2 使用 Spring Security 和 OAuth2 保护 API(强制)

当用户访问 Polar Bookshop 应用程序时,边缘服务通过 Keycloak 启动 OpenID Connect 认证流程,并最终收到一个代表该用户访问下游服务的访问令牌。

在本节和下一节中,您将了解如何通过要求有效的访问令牌来访问受保护端点来保护目录服务和订单服务。在 OAuth2 授权框架中,它们扮演 OAuth2 资源服务器的角色:托管受保护数据的应用程序,用户可以通过第三方(在我们的例子中是边缘服务)访问这些数据。

OAuth2 资源服务器不处理用户认证。它们在每个 HTTP 请求的授权头中接收一个访问令牌。然后它们验证签名并根据令牌的内容授权请求。我们已经配置了边缘服务在路由请求到下游时发送访问令牌。现在您将看到如何在接收端使用该令牌。本节将指导您如何保护基于强制式 Spring 栈的目录服务。下一节将向您展示如何在基于反应式 Spring 栈的订单服务中实现相同的结果。

12.2.1 将 Spring Boot 作为 OAuth2 资源服务器进行保护

利用 OAuth2 保护 Spring Boot 应用程序的第一步是添加一个依赖项,该依赖项包括 Spring Security 和 OAuth2 对资源服务器的支持。

在目录服务项目(catalog-service)中打开 build.gradle 文件,并添加新的依赖项。请记住,在添加新依赖项后刷新或重新导入 Gradle 依赖项。

列表 12.6 为 Spring Security OAuth2 资源服务器添加依赖项

dependencies {
  ...
  implementation 'org.springframework.boot: 
  ➥ spring-boot-starter-oauth2-resource-server' 
}

接下来,让我们配置 Spring Security 和 Keycloak 之间的集成。

配置 Spring Security 和 Keycloak 之间的集成

Spring Security 支持使用两种数据格式保护端点使用访问令牌:JWT 和不透明令牌。我们将使用定义为 JWT 的访问令牌,类似于我们为 ID 令牌所做的那样。使用访问令牌,Keycloak 代表用户授予边缘服务对下游应用程序的访问权限。当访问令牌是 JWT 时,我们还可以包括有关已认证用户的相关信息作为声明,并轻松地将此上下文传播到目录服务和订单服务。相比之下,不透明令牌将要求下游应用程序每次都联系 Keycloak 以获取与令牌关联的信息。

将 Spring Security 配置为与 Keycloak 集成作为 OAuth2 资源服务器比 OAuth2 客户端场景更简单。当处理 JWT 时,应用程序将主要联系 Keycloak 以获取验证令牌签名所需的公钥。使用与边缘服务类似的方式,我们使用 issuer-uri 属性让应用程序自动发现可以找到公钥的 Keycloak 端点。

默认行为是应用程序在首次收到 HTTP 请求时懒加载公钥,而不是在启动时,这既是为了性能也是为了耦合原因(在启动应用程序时不需要 Keycloak 运行)。OAuth2 授权服务器使用 JSON Web Key(JWK)格式提供其公钥。公钥的集合称为JWK Set。Keycloak 公开其公钥的端点称为JWK Set URI。Spring Security 将在 Keycloak 提供新的公钥时自动轮换公钥。

对于每个包含在 Authorization 头中的访问令牌的传入请求,Spring Security 将自动使用 Keycloak 提供的公钥验证令牌的签名,并通过 JwtDecoder 对象解码其声明,该对象在幕后自动配置。

在 Catalog Service 项目(catalog-service)中,打开 application.yml 文件,并添加以下配置。

列表 12.7 配置 Catalog Service 为 OAuth2 资源服务器

spring:
  security:
    oauth2:
      resourceserver:
        jwt:                                     ❶
          issuer-uri: 
➥http://localhost:8080/realms/PolarBookshop     ❷

❶ OAuth2 不强制执行访问令牌的数据格式,因此我们必须明确我们的选择。在这种情况下,我们想使用 JWT。

❷ 提供有关特定领域所有相关 OAuth2 端点信息的 Keycloak URL

注意:解释用于签名访问令牌的加密算法超出了本书的范围。如果您想了解更多关于密码学的信息,您可能想查阅 David Wong 的《Real-World Cryptography》(Manning,2021)。

Catalog Service 与 Keycloak 的集成现在已经建立。接下来,你将定义一些基本的安全策略来保护应用程序端点。

定义 JWT 身份验证的安全策略

对于 Catalog Service 应用程序,我们想强制执行以下安全策略:

  • 获取书籍的 GET 请求应允许无需身份验证。

  • 所有其他请求都应该需要身份验证。

  • 应用程序应该配置为 OAuth2 资源服务器并使用 JWT 身份验证。

  • 处理 JWT 身份验证的流程应该是无状态的。

让我们进一步探讨最后一个策略。Edge Service 触发用户身份验证流程并利用 Web 会话存储数据,如 ID 令牌和访问令牌,否则这些数据将在每个 HTTP 请求结束时丢失,迫使用户在每次请求时进行身份验证。为了使应用程序能够扩展,我们使用了 Spring Session 将 Web 会话数据存储在 Redis 中,并保持应用程序无状态。

与 Edge Service 不同,Catalog Service 只需要访问令牌来验证请求。由于令牌始终在每个 HTTP 请求中提供给受保护端点,因此 Catalog Service 不需要在请求之间存储任何数据。我们称这种策略为无状态身份验证基于令牌的身份验证。我们使用 JWT 作为访问令牌,因此我们也可以将其称为JWT 身份验证

现在转到代码。在 Catalog Service 项目中,在 com.polarbookshop.catalogservice.config 包中创建一个新的 SecurityConfig 类。类似于我们对 Edge Service 所做的那样,我们可以使用 HttpSecurity 提供的 DSL 来构建配置了所需安全策略的 SecurityFilterChain。

列表 12.8 配置安全策略和 JWT 身份验证

@EnableWebSecurity                                          ❶
public class SecurityConfig {

  @Bean
  SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
      .authorizeHttpRequests(authorize -> authorize
        .mvcMatchers(HttpMethod.GET, "/", "/books/**")
          .permitAll()                                      ❷
        .anyRequest().authenticated()                       ❸
      )
      .oauth2ResourceServer(
       OAuth2ResourceServerConfigurer::jwt                  ❹
      )
      .sessionManagement(sessionManagement ->               ❺
        sessionManagement
          .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
      .csrf(AbstractHttpConfigurer::disable)                ❻
      .build();
  }
}

❶ 启用 Spring MVC 对 Spring Security 的支持

❷ 允许用户在不进行认证的情况下获取问候语和书籍

❸ 任何其他请求都需要进行认证。

❹ 启用基于 JWT(JWT 身份验证)的默认配置的 OAuth2 资源服务器支持

❺ 每个请求都必须包含一个访问令牌,因此无需在请求之间保持用户会话活跃。我们希望它是无状态的。

❻ 由于认证策略是无状态的,并且不涉及基于浏览器的客户端,我们可以安全地禁用 CSRF 保护。

让我们检查它是否工作。首先,应该启动 Polar UI、Keycloak、Redis 和 PostgreSQL 容器。打开一个终端窗口,导航到您保存 Docker Compose 配置的文件夹(polar-deployment/docker),并运行以下命令:

$ docker-compose up -d polar-ui polar-keycloak polar-redis polar-postgres

然后,运行边缘服务和目录服务(从每个项目执行./gradlew bootRun)。最后,打开一个浏览器窗口,转到 http://localhost:9000

确保您可以在未经认证的情况下查看目录中的书籍列表,但不能添加、更新或删除它们。然后以 Isabelle(isabelle/password)的身份登录。她是书店的员工,因此她应该被允许修改目录中的书籍。接下来,以 Bjorn(bjorn/password)的身份登录。他是一位客户,因此他不应该能够更改目录中的任何内容。

在底层,Angular 应用程序从 Edge Service 公开的/user 端点获取用户角色,并使用它们来阻止部分功能。这提高了用户体验,但并不安全。目录服务公开的实际端点不考虑角色。我们需要强制执行基于角色的授权。这是下一节的主题。

12.2.2 使用 Spring Security 和 JWT 进行基于角色的访问控制

到目前为止,在讨论授权时,我们提到了代表用户授予 OAuth2 客户端(边缘服务)对 OAuth2 资源服务器(如目录服务)的访问权限。现在我们将从应用程序授权转移到用户授权。认证用户在系统中能做什么?

Spring Security 将每个认证用户与一个 GrantedAuthority 对象列表关联,这些对象表示用户已被授予的权限。已授予的权限可以用来表示细粒度权限、角色,甚至范围,并且根据认证策略的不同来源而异。这些权限通过代表认证用户的 Authentication 对象和存储在 SecurityContext 中的权限可用。

由于目录服务配置为 OAuth2 资源服务器并使用 JWT 认证,Spring Security 从访问令牌的 scope 声明中提取作用域列表,并将其用作给定用户的自动授权。这样构建的每个 GrantedAuthority 对象都将使用 SCOPE_ 前缀和作用域值命名。

在许多使用作用域来表示权限的场景中,默认行为是可以接受的,但它不适合我们依赖用户角色来了解每个用户有哪些权限的情况。我们希望设置一个基于角色的访问控制(RBAC)策略,使用访问令牌中角色声明提供的用户角色(见图 12.6)。在本节中,我将向您展示如何定义一个自定义转换器,用于从角色声明中的值和 ROLE_ 前缀构建 GrantedAuthority 对象列表。然后我们将使用这些权限来定义目录服务端点的授权规则。

12-06

图 12.6 如何将访问令牌(JWT)中列出的用户角色转换为 Spring Security 用于 RBAC 的 GrantedAuthority 对象

注意:您可能想知道为什么我们使用 SCOPE_ 或 ROLE_ 前缀。由于授权可以用来表示不同的项目(角色、作用域、权限),Spring Security 使用前缀来分组它们。对于极地书店示例,我们将依赖此默认命名约定,但也可以使用不同的前缀,甚至完全不使用前缀。有关更多信息,请参阅 Spring Security 文档(spring.io/projects/spring-security)。

从访问令牌中提取用户角色

Spring Security 提供了一个 JwtAuthenticationConverter 类,我们可以用它来定义一个自定义策略,从 JWT 中提取信息。在我们的情况下,JWT 是一个访问令牌,我们希望配置如何从角色声明中的值构建 GrantedAuthority 对象。在目录服务项目(catalog-service)中,打开 SecurityConfig 类并定义一个新的 JwtAuthenticationConverter Bean。

列表 12.9 将 JWT 中的角色映射到授权

@EnableWebSecurity
public class SecurityConfig {
  ...

  @Bean 
  public JwtAuthenticationConverter jwtAuthenticationConverter() { 
    var jwtGrantedAuthoritiesConverter = 
      new JwtGrantedAuthoritiesConverter();     ❶
    jwtGrantedAuthoritiesConverter 
      .setAuthorityPrefix("ROLE_");             ❷
    jwtGrantedAuthoritiesConverter 
      .setAuthoritiesClaimName("roles");        ❸

    var jwtAuthenticationConverter = 
      new JwtAuthenticationConverter();         ❹
    jwtAuthenticationConverter 
      .setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter); 
    return jwtAuthenticationConverter; 
  } 
}

❶ 定义一个将声明映射到 GrantedAuthority 对象的转换器

❷ 为每个用户角色应用“ROLE_”前缀

❸ 从角色声明中提取角色列表

❹ 定义一个转换 JWT 的策略。我们只会自定义如何从它构建授权。

使用此 Bean,Spring Security 将为每个已认证用户关联一个 GrantedAuthority 对象列表,我们可以使用它们来定义授权策略。

根据用户角色定义授权策略

目录服务端点应根据以下策略进行保护:

  • 发送到/、/books 或/books/{isbn}端点的所有 GET 请求都应被允许,即使没有认证。

  • 任何其他请求都应该需要用户认证和员工角色。

Spring Security 提供了一种基于表达式的 DSL,用于定义授权策略。其中最通用的是 hasAuthority("ROLE_employee"),您可以使用它来检查任何类型的权限。在我们的案例中,权限是角色,因此我们可以使用最描述性的 hasRole("employee") 并删除前缀(这是 Spring Security 在底层添加的)。

列表 12.10 将 RBAC 应用于限制具有员工角色的用户的写访问

@EnableWebSecurity
public class SecurityConfig {

  @Bean
  SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
      .authorizeHttpRequests(authorize -> authorize
        .mvcMatchers(HttpMethod.GET, "/", "/books/**")
          .permitAll()                                    ❶
          .anyRequest().hasRole("employee")               ❷
      )
      .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
      .sessionManagement(sessionManagement -> sessionManagement
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
      .csrf(AbstractHttpConfigurer::disable)
      .build();
  }
  ...
}

❶ 允许用户在不进行身份验证的情况下获取问候语和书籍

❷ 任何其他请求不仅需要身份验证,还需要员工角色(这与 ROLE_employee 权限相同)。

现在,您可以重新构建并运行 Catalog Service(./gradlew bootRun),并按照之前的流程进行。这次 Catalog Service 将确保只有书店员工可以添加、更新和删除书籍。

最后,停止运行中的应用程序(Ctrl-C)和容器(docker-compose down)。

注意:要了解更多关于 Spring Security 中的授权架构和可用于访问控制的不同策略,您可以参考 Laurențiu Spilcǎ 所著的 Spring Security in Action 一书的第七章和第八章(Manning,2020),其中它们被详细解释。

接下来,我将指导您了解一些测试命令式 Spring Boot 应用程序(配置为 OAuth2 资源服务器)安全性的技术。

12.2.3 使用 Spring Security 和 Testcontainers 测试 OAuth2

当涉及到安全性时,编写自动测试通常具有挑战性。幸运的是,Spring Security 为我们提供了方便的工具,用于在切片测试中验证安全性设置。

本节将向您展示如何使用模拟访问令牌编写针对 Web 切片的切片测试,以及依赖于通过 Testcontainers 运行的实际 Keycloak 容器的完整集成测试。

在开始之前,我们需要添加对 Spring Security Test 和 Testcontainers Keycloak 的新依赖项。打开 Catalog Service 项目(catalog-service)的 build.gradle 文件,并按照以下方式更新。记得在添加新依赖项后刷新或重新导入 Gradle 依赖项。

列表 12.11 添加测试 Spring Security 和 Keycloak 的依赖项

ext {
  ...
  set('testKeycloakVersion', "2.3.0")                     ❶
}

dependencies {
  ...
  testImplementation 'org.springframework.security:spring-security-test' 
  testImplementation 'org.testcontainers:junit-jupiter' 
  testImplementation "com.github.dasniko: 
  ➥ testcontainers-keycloak:${testKeycloakVersion}"      ❷
}

❶ Testcontainers Keycloak 的版本

❷ 在 Testcontainers 上提供 Keycloak 测试工具

使用 @WebMvcTest 和 Spring Security 测试受保护的 REST 控制器

首先,让我们更新 BookControllerMvcTests 类以覆盖新的场景,这些场景取决于用户身份验证和授权。例如,我们可以为以下情况编写测试用例:

  • 用户已进行身份验证并且具有员工角色。

  • 用户已进行身份验证但没有员工角色。

  • 用户未进行身份验证。

删除操作仅允许书店员工进行,因此只有第一个请求将返回成功答案。

作为 OAuth2 访问令牌验证的一部分,Spring Security 依赖于 Keycloak 提供的公钥来验证 JWT 签名。内部,框架配置了一个 JwtDecoder 实例来使用这些密钥解码和验证 JWT。在 Web Slice 测试的上下文中,我们可以提供一个模拟的 JwtDecoder 实例,这样 Spring Security 就会跳过与 Keycloak 的交互(我们将在完整的集成测试中稍后验证)。

列表 12.12 使用切片测试验证网络层的安全策略

@WebMvcTest(BookController.class)
@Import(SecurityConfig.class)                                     ❶
class BookControllerMvcTests {

  @Autowired
  MockMvc mockMvc;

  @MockBean 
  JwtDecoder jwtDecoder;                                          ❷

  ...

  @Test 
  void whenDeleteBookWithEmployeeRoleThenShouldReturn204() 
    throws Exception 
  { 
    var isbn = "7373731394"; 
    mockMvc                                                       ❸
      .perform(MockMvcRequestBuilders.delete("/books/" + isbn) 
        .with(SecurityMockMvcRequestPostProcessors.jwt() 
           .authorities(new SimpleGrantedAuthority("ROLE_employee")))) 
      .andExpect(MockMvcResultMatchers.status().isNoContent()); 
  } 

  @Test 
  void whenDeleteBookWithCustomerRoleThenShouldReturn403() 
    throws Exception 
  { 
    var isbn = "7373731394"; 
    mockMvc                                                       ❹
      .perform(MockMvcRequestBuilders.delete("/books/" + isbn) 
        .with(SecurityMockMvcRequestPostProcessors.jwt() 
          .authorities(new SimpleGrantedAuthority("ROLE_customer")))) 
      .andExpect(MockMvcResultMatchers.status().isForbidden()); 
  } 

  @Test 
  void whenDeleteBookNotAuthenticatedThenShouldReturn401() 
    throws Exception 
  { 
    var isbn = "7373731394"; 
    mockMvc 
      .perform(MockMvcRequestBuilders.delete("/books/" + isbn)) 
      .andExpect(MockMvcResultMatchers.status().isUnauthorized()); 
  } 
}

❶ 导入应用程序的安全配置

❷ 模拟 JwtDecoder,使应用程序不尝试调用 Keycloak 并获取用于解码访问令牌的公钥

❸ 使用模拟的 JWT 格式访问令牌,对具有“员工”角色的用户进行 HTTP 请求的修改

❹ 使用模拟的 JWT 格式访问令牌,对具有“客户”角色的用户进行 HTTP 请求的修改

打开一个终端窗口,导航到 Catalog Service 根目录,并按照以下方式运行新添加的测试:

$ ./gradlew test --tests BookControllerMvcTests

随意添加更多用于覆盖 GET、POST 和 PUT 请求的 Web Slice 自动测试。为了获取灵感,您可以参考书中附带源代码(第十二章/12-end/catalog-service)。

使用 @SpringBootTest、Spring Security 和 Testcontainers 进行集成测试

在前几章中编写的集成测试将不再适用,原因有两个。首先,所有 POST、PUT 和 DELETE 请求都将失败,因为我们没有提供任何有效的 OAuth2 访问令牌。即使我们有,也没有运行中的 Keycloak,Spring Security 需要它来获取用于验证访问令牌的公钥。

您可以通过从 Catalog Service 根目录运行以下命令来验证失败:

$ ./gradlew test --tests CatalogServiceApplicationTests

我们已经看到如何使用 Testcontainers 来编写针对数据服务(如 PostgreSQL 数据库)的集成测试,使我们的测试更加可靠并确保环境一致性。在本节中,我们将对 Keycloak 执行相同的操作。

让我们从通过 Testcontainers 配置 Keycloak 容器开始。打开 CatalogServiceApplicationTests 类并添加以下设置。

列表 12.13 Keycloak 测试容器的设置

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("integration")
@Testcontainers                                                  ❶
class CatalogServiceApplicationTests {

  @Autowired
  private WebTestClient webTestClient;

  @Container                                                     ❷
  private static final KeycloakContainer keycloakContainer = 
    new KeycloakContainer("quay.io/keycloak/keycloak:19.0") 
      .withRealmImportFile("test-realm-config.json"); 

  @DynamicPropertySource                                         ❸
  static void dynamicProperties(DynamicPropertyRegistry registry) { 
    registry.add("spring.security.oauth2.resourceserver.jwt.issuer-uri", 
      () -> keycloakContainer.getAuthServerUrl() + "realms/PolarBookshop"); 
  } 

  ...
}

❶ 激活测试容器的自动启动和清理

❷ 定义一个用于测试的 Keycloak 容器

❸ 覆盖 Keycloak Issuer URI 配置,使其指向测试 Keycloak 实例

Keycloak 测试容器通过我包含在本书附带代码库中的配置文件(第十二章/12-end/catalog-service/src/test/resources/test-realm-config.json)进行初始化。请将其复制到您的 Catalog Service 项目(catalog-service)的 src/test/resources 文件夹中。

在生产环境中,我们会通过边缘服务调用目录服务,该服务负责对用户进行身份验证并将访问令牌中继到下游应用程序。现在我们希望单独测试目录服务并验证不同的授权场景。因此,我们需要首先生成一些访问令牌,以便我们可以使用它们来调用正在测试的目录服务端点。

我在 JSON 文件中提供的 Keycloak 配置包括测试客户端(polar-test)的定义,我们可以使用它通过用户名和密码直接对用户进行身份验证,而不是通过我们在边缘服务中实现的基于浏览器的流程。在 OAuth2 中,这种流程称为 密码授权,并且不建议在生产环境中使用。在下一节中,我们仅将其用于测试目的。

让我们设置 CatalogServiceApplicationTests 以 Isabelle 和 Bjorn 身份进行身份验证,这样我们就可以获取调用目录服务受保护端点所需的访问令牌。请记住,Isabelle 既是客户也是员工,而 Bjorn 只是一个客户。

列表 12.14 获取测试访问令牌的设置

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("integration")
@Testcontainers
class CatalogServiceApplicationTests {
  private static KeycloakToken bjornTokens; 
  private static KeycloakToken isabelleTokens; 
  ...

  @BeforeAll 
  static void generateAccessTokens() { 
    WebClient webClient = WebClient.builder()                   ❶
      .baseUrl(keycloakContainer.getAuthServerUrl() 
        + "realms/PolarBookshop/protocol/openid-connect/token") 
      .defaultHeader(HttpHeaders.CONTENT_TYPE, 
        MediaType.APPLICATION_FORM_URLENCODED_VALUE) 
      .build(); 

      isabelleTokens = authenticateWith(                        ❷
        "isabelle", "password", webClient); 
      bjornTokens = authenticateWith(                           ❸
        "bjorn", "password", webClient); 
  } 

  private static KeycloakToken authenticateWith( 
    String username, String password, WebClient webClient 
  ) { 
    return webClient 
      .post() 
      .body(                                                    ❹
        BodyInserters.fromFormData("grant_type", "password") 
        .with("client_id", "polar-test") 
        .with("username", username) 
        .with("password", password) 
      ) 
      .retrieve() 
      .bodyToMono(KeycloakToken.class) 
      .block();                                                 ❺
  } 

  private record KeycloakToken(String accessToken) { 
    @JsonCreator                                                ❻
    private KeycloakToken( 
      @JsonProperty("access_token") final String accessToken 
    ) { 
      this.accessToken = accessToken; 
    } 
  } 
} 

❶ 使用 WebClient 调用 Keycloak

❷ 以 Isabelle 身份进行身份验证并获取访问令牌

❸ 以 Bjorn 身份进行身份验证并获取访问令牌

❹ 使用密码授权流程直接通过 Keycloak 进行身份验证

❺ 等待直到有结果可用。这就是我们强制使用 WebClient 而不是反应式使用的方式。

❻ 指示 Jackson 在反序列化 JSON 到 KeycloakToken 对象时使用此构造函数

最后,我们可以更新 CatalogServiceApplicationTests 中的测试用例以涵盖几个身份验证和授权场景。例如,我们可以在以下情况下编写 POST 操作的测试用例:

  • 用户已进行身份验证并具有员工角色(扩展现有测试用例)。

  • 用户已进行身份验证,但没有员工角色(新测试用例)。

  • 用户未进行身份验证(新测试用例)。

注意:在 OAuth2 资源服务器上下文中,身份验证意味着令牌身份验证。在这种情况下,它通过在每个 HTTP 请求的授权头中提供访问令牌来实现。

创建操作仅允许书店员工进行,因此只有第一个请求将返回成功答案。

列表 12.15 集成测试中验证安全场景

@Test
void whenPostRequestThenBookCreated() {
  var expectedBook = Book.of("1231231231", "Title", "Author",
    9.90, "Polarsophia");

  webTestClient.post().uri("/books")
    .headers(headers ->                                     ❶
      headers.setBearerAuth(isabelleTokens.accessToken())) 
    .bodyValue(expectedBook)
    .exchange()
    .expectStatus().isCreated()                             ❷
    .expectBody(Book.class).value(actualBook -> {
      assertThat(actualBook).isNotNull();
      assertThat(actualBook.isbn()).isEqualTo(expectedBook.isbn());
    });
}

 @Test 
 void whenPostRequestUnauthorizedThen403() { 
  var expectedBook = Book.of("1231231231", "Title", "Author", 
    9.90, "Polarsophia"); 

  webTestClient.post().uri("/books") 
    .headers(headers ->                                     ❸
      headers.setBearerAuth(bjornTokens.accessToken())) 
    .bodyValue(expectedBook) 
    .exchange() 
    .expectStatus().isForbidden();                          ❹
} 

@Test 
void whenPostRequestUnauthenticatedThen401() { 
  var expectedBook = Book.of("1231231231", "Title", "Author", 
    9.90, "Polarsophia"); 

  webTestClient.post().uri("/books")                        ❺
    .bodyValue(expectedBook) 
    .exchange() 
    .expectStatus().isUnauthorized();                       ❻
} 

❶ 以已验证的员工用户(Isabelle)的身份发送请求以将书籍添加到目录中

❷ 书籍已成功创建(201)。

❸ 以已验证的客户用户(Bjorn)的身份发送请求以将书籍添加到目录中

❹ 由于用户没有正确的授权,没有“员工”角色(403),因此尚未创建书籍。

❺ 以未验证用户身份发送请求以将书籍添加到目录中

❻ 由于用户未进行身份验证(401),因此尚未创建书籍。

打开终端窗口,导航到目录服务根目录,并按以下方式运行新添加的测试:

$ ./gradlew test --tests CatalogServiceApplicationTests

仍然有一些测试没有通过。按照之前示例中的方法,在任意的 POST、PUT 或 DELETE 请求中包含正确的访问令牌(Isabelle 的或 Bjorn 的),然后继续更新它们。完成后,重新运行测试并验证它们是否全部成功。为了获得灵感,您可以参考本书附带源代码(第十二章/12-end/catalog-service)。

12.3 使用 Spring Security 和 OAuth2 保护 API(响应式)

保护像订单服务这样的响应式 Spring Boot 应用程序与我们在目录服务中做的是相似的。Spring Security 在这两个堆栈中提供了直观且一致的抽象,这使得从一个堆栈移动到另一个堆栈变得容易。

在本节中,我将指导您配置订单服务作为 OAuth2 资源服务器,启用 JWT 身份验证,并为 Web 端点定义安全策略。

12.3.1 将 Spring Boot 作为 OAuth2 资源服务器进行保护

包含 Spring Security 和 OAuth2 对资源服务器支持的 Spring Boot 启动器依赖项对于命令式和响应式应用程序都是相同的。在订单服务项目(order-service)中,打开 build.gradle 文件,并添加新的依赖项。请记住,在添加新依赖项后,刷新或重新导入 Gradle 依赖项。

列表 12.16 添加 Spring Security OAuth2 资源服务器依赖项

dependencies {
  ...
  implementation 'org.springframework.boot: 
  ➥ spring-boot-starter-oauth2-resource-server' 
}

接下来,我们将配置 Spring Security 与 Keycloak 之间的集成。

配置 Spring Security 与 Keycloak 之间的集成

将 Spring Security 与 Keycloak 集成的策略将与我们在目录服务中做的一样。打开订单服务项目(order-service),并使用以下配置更新 application.yml 文件。

列表 12.17 将订单服务配置为 OAuth2 资源服务器

spring:
  security:
    oauth2:
      resourceserver:
        jwt:                                     ❶
          issuer-uri:
➥http://localhost:8080/realms/PolarBookshop     ❷

❶ OAuth2 不强制执行访问令牌的数据格式,因此我们必须明确我们的选择。在这种情况下,我们想使用 JWT。

❷ 提供特定领域所有相关 OAuth2 端点信息的 Keycloak URL

订单服务与 Keycloak 的集成现在已经建立。接下来,我们将定义必要的保护应用程序端点的安全策略。

定义 JWT 身份验证的安全策略

对于订单服务应用程序,我们想强制执行以下安全策略:

  • 所有请求都应需要身份验证。

  • 应用程序应配置为 OAuth2 资源服务器并使用 JWT 身份验证。

  • 处理 JWT 身份验证的流程应该是无状态的。

与我们在目录服务中做的不太一样的地方有两个:

  • 响应式语法与其命令式对应物略有不同,尤其是在强制执行 JWT 身份验证(无状态)的部分。

  • 我们没有从访问令牌中提取用户角色,因为端点没有根据用户角色有特殊要求。

在订单服务项目中,在新的 com.polarbookshop.orderservice.config 包中创建一个 SecurityConfig 类。然后使用 ServerHttpSecurity 提供的 DSL 构建一个配置了所需安全策略的 SecurityWebFilterChain。

列表 12.18 为订单服务配置安全策略和 JWT 认证

@EnableWebFluxSecurity                                           ❶
public class SecurityConfig {

  @Bean
  SecurityWebFilterChain filterChain(ServerHttpSecurity http) {
    return http
      .authorizeExchange(exchange -> exchange 
        .anyExchange().authenticated()                           ❷
      )
      .oauth2ResourceServer(                                     ❸
        ServerHttpSecurity.OAuth2ResourceServerSpec::jwt)
      .requestCache(requestCacheSpec ->                          ❹
        requestCacheSpec.requestCache(NoOpServerRequestCache.getInstance()))
      .csrf(ServerHttpSecurity.CsrfSpec::disable)                ❺
      .build();
  }
}

❶ 启用 Spring WebFlux 对 Spring Security 的支持

❷ 所有请求都需要进行认证。

❸ 使用基于 JWT(JWT 认证)的默认配置启用 OAuth2 资源服务器支持

❹ 每个请求都必须包含一个访问令牌,因此不需要在请求之间保持会话缓存活跃。我们希望它是无状态的。

❺ 由于认证策略是无状态的,并且不涉及基于浏览器的客户端,我们可以安全地禁用 CSRF 保护。

让我们检查它是否工作。首先,我们需要运行后端服务(Polar UI、Keycloak、Redis、RabbitMQ 和 PostgreSQL)。打开一个终端窗口,导航到您保存 Docker Compose 配置的文件夹(polar-deployment/docker)并运行以下命令:

$ docker-compose up -d polar-ui polar-keycloak polar-redis \
    polar-rabbitmq polar-postgres

然后在 JVM 上运行 Edge Service、目录服务和订单服务(从每个项目运行./gradlew bootRun)。最后,打开一个浏览器窗口,并转到 http://localhost:9000

由于订单服务没有根据用户角色特定的要求,您可以使用 Isabelle(isabelle/password)或 Bjorn(bjorn/password)登录。然后从目录中选择一本书,并提交订单。由于您已认证,您被允许创建订单。完成操作后,您可以去订单页面查看所有提交的订单。

“等等!你说的所有提交的订单是什么意思?”我很高兴你问了。目前,每个人都可以看到所有用户提交的订单。别担心!在本章的后面部分,我们会解决这个问题。

然而,在我们这样做之前,我们需要讨论如何测试新的订单服务安全策略。停止运行中的应用程序(Ctrl-C)和容器(docker-compose down)。下一节将向您展示如何在响应式应用程序中测试安全性。

12.3.2 使用 Spring Security 和 Testcontainers 测试 OAuth2

测试受保护的响应式 Spring Boot 应用程序类似于测试命令式应用程序。在开始之前,我们需要在 Spring Security Test 和 Testcontainers Keycloak 上添加新的依赖项。Testcontainers 的 JUnit5 支持依赖项已经存在。打开 build.gradle 文件,并按以下方式更新它。记得在添加新依赖项后刷新并重新导入 Gradle 依赖项。

列表 12.19 向测试 Spring Security 和 Keycloak 添加依赖项

ext {
  ...
  set('testKeycloakVersion', "2.3.0")                       ❶
}

dependencies {
  ...
  testImplementation 'org.springframework.security:spring-security-test' 
  testImplementation 'org.testcontainers:junit-jupiter' 
  testImplementation "com.github.dasniko: 
  ➥ testcontainers-keycloak:${testKeycloakVersion}"        ❷
}

❶ Testcontainers Keycloak 的版本

❷ 在 Testcontainers 之上提供 Keycloak 测试工具

我们可以使用 @SpringBootTest 和 Testcontainers Keycloak 实现完整的集成测试。由于设置与 Catalog Service 相同,这里不会介绍这些测试,但你可以在本书附带的存储库中找到它们(第十二章/12-end/order-service/src/test)。确保你更新了这些集成测试,否则应用程序构建将失败。

在本节中,我们将测试当端点受保护时反应式应用程序的 Web Slice,这与我们对 Catalog Service 所做的一样。

使用 @WebFluxTest 和 Spring Security 测试受保护的 REST 控制器

我们已经在 OrderControllerWebFluxTests 中为 Web Slice 编写了 autotests,使用 @WebFluxTest。现在让我们看看如何更新它们以考虑安全性。

作为 OAuth2 访问令牌验证的一部分,Spring Security 依赖于 Keycloak 提供的公钥来验证 JWT 签名。内部,框架配置了一个 ReactiveJwtDecoder 实例来使用这些密钥解码和验证 JWT。在 Web Slice 测试的上下文中,我们可以提供一个模拟的 ReactiveJwtDecoder 实例,这样 Spring Security 就会跳过与 Keycloak 的交互(这些交互将由完整的集成测试进行验证)。

列表 12.20 使用切片测试验证 Web 层的安全策略

@WebFluxTest(OrderController.class)
@Import(SecurityConfig.class)                               ❶
class OrderControllerWebFluxTests {

  @Autowired
  WebTestClient webClient;

  @MockBean
  OrderService orderService;

  @MockBean 
  ReactiveJwtDecoder reactiveJwtDecoder;                    ❷

  @Test
  void whenBookNotAvailableThenRejectOrder() {
    var orderRequest = new OrderRequest("1234567890", 3);
    var expectedOrder = OrderService.buildRejectedOrder(
     orderRequest.isbn(), orderRequest.quantity());
    given(orderService.submitOrder(
     orderRequest.isbn(), orderRequest.quantity()))
        .willReturn(Mono.just(expectedOrder));

    webClient
      .mutateWith(SecurityMockServerConfigurers 
        .mockJwt()                                          ❸
        .authorities(new SimpleGrantedAuthority("ROLE_customer"))) 
      .post()
      .uri("/orders/")
      .bodyValue(orderRequest)
      .exchange()
      .expectStatus().is2xxSuccessful()
      .expectBody(Order.class).value(actualOrder -> {
        assertThat(actualOrder).isNotNull();
        assertThat(actualOrder.status()).isEqualTo(OrderStatus.REJECTED);
      });
   }
}

❶ 导入应用程序安全配置

❷ 模拟 ReactiveJwtDecoder,这样应用程序就不会尝试调用 Keycloak 并获取解码访问令牌的公钥

❸ 使用模拟的、JWT 格式的访问令牌,以“customer”角色的用户对 HTTP 请求进行修改

打开一个终端窗口,导航到 Order Service 根目录,并按照以下方式运行新添加的测试:

$ ./gradlew test --tests OrderControllerWebFluxTests

与往常一样,你可以在本书附带的源代码存储库中找到更多测试示例(第十二章/12-end/order-service)。

12.4 使用 Spring Security 和 Spring Data 保护和审计数据

到目前为止,我们已经探讨了 Spring Boot 应用程序暴露的 API 的安全性以及处理认证和授权等问题。那么数据呢?一旦你设置了 Spring Security,你还可以保护业务和数据层。

关于业务逻辑,你可以启用方法安全特性,直接在业务方法上检查用户认证或授权,利用 @PreAuthorize 等注解。在 Polar Bookshop 系统中,业务层并不复杂,不需要额外的安全策略,所以这里不会描述这一点。

注意:想了解更多关于如何使用方法认证和授权的信息,请参阅 Laurențiu Spilcǎ 所著的 Spring Security in Action(Manning,2020)的第八章,其中对这些主题进行了详细的解释。

另一方面,数据层需要一些额外的工作来解决两个主要问题:

  • 我们如何知道哪些用户创建了哪些数据?谁最后修改了它?

  • 我们如何确保每个用户只能访问他们自己的书籍订单?

本节将解决这两个问题。首先,我将解释如何在 Catalog 服务和 Order 服务中为用户对数据的行为启用审计。然后,我将指导你了解 Order 服务需要进行的更改以保持数据私有。

12.4.1 使用 Spring Security 和 Spring Data JDBC 进行数据审计

让我们从考虑 Catalog 服务开始,其中数据层使用 Spring Data JDBC 实现。在第五章中,你学习了如何启用 JDBC 数据审计,并将其配置为为每个数据实体保存创建日期和最后修改日期。在此基础上,我们现在可以扩展审计范围,包括创建实体的用户和最后修改实体的用户的用户名。

首先,我们需要告诉 Spring Data 如何获取当前认证用户的信息。在上一章中,你了解到 Spring Security 将认证用户的信息存储在 Authentication 对象中,该对象存储在通过 SecurityContextHolder 可用的 SecurityContext 对象中。我们可以使用这个对象层次结构来指定如何为 Spring Data 提取主体。

定义审计员以捕获谁创建了或更新了 JDBC 数据实体

在 Catalog 服务项目(catalog-service)中,打开 DataConfig 类。那里我们使用了 @EnableJdbcAuditing 注解来启用数据审计。现在,我们还将定义一个 AuditorAware bean,它应该返回主体——当前认证的用户。

列表 12.21 在 Spring Data JDBC 中配置用户审计

@Configuration
@EnableJdbcAuditing                                      ❶
public class DataConfig {

  @Bean 
  AuditorAware<String> auditorAware() {                  ❷
    return () -> Optional 
      .ofNullable(SecurityContextHolder.getContext())    ❸
      .map(SecurityContext::getAuthentication)           ❹
      .filter(Authentication::isAuthenticated)           ❺
      .map(Authentication::getName);                     ❻
  } 
}

❶ 在 Spring Data JDBC 中启用实体审计

❷ 返回当前认证用户以进行审计目的

❸ 从 SecurityContextHolder 中提取当前认证用户的 SecurityContext 对象

❹ 从 SecurityContext 中提取当前认证用户的 Authentication 对象

❺ 处理用户未认证但操作数据的情况。由于我们保护了所有端点,这种情况不应该发生,但我们将其包括在内以确保完整性。

❻ 从 Authentication 对象中提取当前认证用户的用户名

为创建或更新 JDBC 数据实体的用户添加审计元数据

当定义了 AuditorAware bean 并启用了审计时,Spring Data 将使用它来提取主体。在我们的情况下,它是当前认证用户的用户名,表示为一个字符串。然后我们可以使用 @CreatedBy 和 @LastModifiedBy 注解在 Book 记录中的两个新字段上。每当对实体执行创建或更新操作时,Spring Data 将自动填充这些字段。

列表 12.22 在 JDBC 实体中捕获用户审计元数据的字段

public record Book (
  ...

  @CreatedBy              ❶
  String createdBy, 

  @LastModifiedBy         ❷
  String lastModifiedBy, 

){
  public static Book of(String isbn, String title, String author,
    Double price, String publisher
  ) {
    return new Book(null, isbn, title, author, price, publisher,
      null, null, null, null, 0);
  }
}

❶ 谁创建了实体

❷ 谁是最后一次修改实体的用户

在添加新字段后,我们需要使用 Book 全参构造函数更新几个类,现在它需要传递 createdBy 和 lastModifiedBy 的值。

BookService 类包含更新图书的逻辑。打开它并将 editBookDetails()方法更改为确保在调用数据层时正确传递审计元数据。

列表 12.23 更新图书时包含现有的审计元数据

@Service
public class BookService {
  ...

  public Book editBookDetails(String isbn, Book book) {
    return bookRepository.findByIsbn(isbn)
      .map(existingBook -> {
        var bookToUpdate = new Book(
          existingBook.id(),
          existingBook.isbn(),
          book.title(),
          book.author(),
          book.price(),
          book.publisher(),
          existingBook.createdDate(),
          existingBook.lastModifiedDate(),
          existingBook.createdBy(),           ❶
          existingBook.lastModifiedBy(),      ❷
          existingBook.version());
          return bookRepository.save(bookToUpdate);
      })
      .orElseGet(() -> addBookToCatalog(book));
  }
}

❶ 谁创建了实体

❷ 谁最后更新了实体

我将留给你以类似的方式更新自动测试。你还可以扩展 BookJsonTests 中的测试,以验证新字段的序列化和反序列化。作为一个参考,你可以在伴随本书的代码仓库中的 Chapter12/12-end/catalog-service 检查。确保你更新了使用 Book()构造函数的测试,否则应用程序构建将失败。

编写 Flyway 迁移以将新的审计元数据添加到模式中

由于我们更改了实体模型,我们需要相应地更新数据库模式。假设目录服务已经在生产中,因此我们需要一个 Flyway 迁移来更新下一个版本的方案。在第五章中,我们介绍了 Flyway 来为我们的数据库添加版本控制。对模式的任何更改都必须注册为迁移,以确保稳健的模式演变和可重复性。

任何对数据库模式的更改都应该向后兼容,以支持云原生应用的常见部署策略,如滚动升级、蓝绿部署或金丝雀发布(我们将在第十五章中讨论这个主题)。在这种情况下,我们需要向图书表添加新列。只要我们不使它们成为强制性的,更改将是向后兼容的。在更改模式后,任何运行的前一个版本的目录服务实例将继续无错误地工作,简单地忽略新列。

在目录服务项目的 src/main/resources/db/migration 文件夹中,创建一个新的 V3__Add_user_audit.sql 迁移脚本,向图书表添加两个新列。确保在版本号后输入两个下划线。

列表 12.24 向图书表添加新的审计元数据

ALTER TABLE book
  ADD COLUMN created_by varchar(255);          ❶
ALTER TABLE book
  ADD COLUMN last_modified_by varchar(255);    ❷

❶ 添加一个列来存储创建行的用户名。

❷ 添加一个列来存储最后更新行的用户名。

在应用程序启动期间,Flyway 将自动遍历所有迁移脚本并应用尚未应用的脚本。

强制向后兼容更改的权衡是,我们现在必须将两个我们需要始终填写且可能在没有它们的情况下失败验证的字段视为可选的。这是一个可以通过应用程序的两个后续版本解决的问题的常见问题:

  1. 在第一个版本中,你将新列作为可选添加,并实现数据迁移以填充所有现有数据的新列。对于目录服务,你可以使用传统值来表示我们不知道谁创建了或更新了实体,例如 unknown 或 anonymous。

  2. 在第二个版本中,你可以创建一个新的迁移来安全地更新模式并使新列成为必需的。

如果您想这么做,那就由您决定。如果您对实现数据迁移感兴趣,我建议您查看 Flyway 的官方文档(flywaydb.org)。

在下一节中,您将看到如何在 Spring Data JDBC 中测试与用户相关的审计。

12.4.2 使用 Spring Data 和@WithMockUser 测试数据审计

当我们在数据层测试安全性时,我们并不关心采用了哪种认证策略。我们唯一需要知道的是操作是否在认证请求的上下文中执行。

Spring Security Test 项目为我们提供了一个方便的@WithMockUser 注解,我们可以在测试用例中使用它,使它们在认证上下文中运行。您还可以添加有关模拟用户的信息。由于我们正在测试审计,我们至少需要定义一个可以用作 principal 的用户名。

让我们扩展 BookRepositoryJdbcTests 类,添加新的测试用例以覆盖用户的数据审计。

列表 12.25 测试用户认证或未认证时的数据审计

@DataJdbcTest
@Import(DataConfig.class)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("integration")
class BookRepositoryJdbcTests {

  ...

  @Test                                                       ❶
  void whenCreateBookNotAuthenticatedThenNoAuditMetadata() { 
    var bookToCreate = Book.of("1232343456", "Title", 
      "Author", 12.90, "Polarsophia"); 
    var createdBook = bookRepository.save(bookToCreate); 

    assertThat(createdBook.createdBy()).isNull();             ❷
    assertThat(createdBook.lastModifiedBy()).isNull(); 
  } 

  @Test 
  @WithMockUser("john")                                       ❸
  void whenCreateBookAuthenticatedThenAuditMetadata() { 
    var bookToCreate = Book.of("1232343457", "Title", 
      "Author", 12.90, "Polarsophia"); 
    var createdBook = bookRepository.save(bookToCreate); 

    assertThat(createdBook.createdBy()) 
      .isEqualTo("john");                                     ❹
    assertThat(createdBook.lastModifiedBy()) 
      .isEqualTo("john"); 
  } 
}

❶ 此测试用例是在未认证的上下文中执行的。

❷ 当没有经过身份验证的用户时没有审计数据

❸ 此测试用例是在用户“john”的认证上下文中执行的。

❹ 当有经过身份验证的用户时审计数据

打开一个终端窗口,导航到目录服务根目录,并按照以下方式运行新添加的测试:

$ ./gradlew test --tests BookRepositoryJdbcTests

如果您遇到任何失败,可能是因为您没有更新使用 Book()构造函数的测试用例。我们已经向领域模型添加了新字段,所以请记得更新这些测试用例。

12.4.3 使用 Spring Security 和 Spring Data R2DBC 保护用户数据

与我们在目录服务中所做的一样,本节将向您展示如何在订单服务中添加用户的数据审计。多亏了 Spring Data 和 Spring Security 提供的抽象,即使我们使用 Spring Data R2DBC 和响应式 Spring,实现也不会有很大不同。

除了数据审计外,订单服务还有一个额外的关键要求。用户应该只能访问他们自己的订单。我们需要确保所有这些数据的隐私。本节还将向您介绍实现该结果所需进行的更改。

定义一个审计器以捕获谁创建了或更新了 R2DBC 数据实体

即使在这种情况下,我们也需要告诉 Spring Data 从哪里获取当前经过身份验证的用户的信息。由于这是一个响应式应用程序,这次我们将从 ReactiveSecurityContextHolder 获取 principal 的 SecurityContext 对象。

在订单服务项目(order-service)中,打开 DataConfig 类,并添加一个 ReactiveAuditorAware bean 以返回当前经过身份验证用户的用户名。

列表 12.26 配置 Spring Data R2DBC 中的用户审计

@Configuration
@EnableR2dbcAuditing                                  ❶
public class DataConfig {

  @Bean 
  ReactiveAuditorAware<String> auditorAware() {       ❷
    return () -> 
      ReactiveSecurityContextHolder.getContext()      ❸
        .map(SecurityContext::getAuthentication)      ❹
        .filter(Authentication::isAuthenticated)      ❺
        .map(Authentication::getName);                ❻
  } 
}

❶ 在 Spring Data R2DBC 中启用实体审计

❷ 返回当前经过身份验证的用户以供审计目的使用

❸ 从 ReactiveSecurityContextHolder 中提取当前已认证用户的 SecurityContext 对象

❹ 从 SecurityContext 中提取当前已认证用户的 Authentication 对象

❺ 处理用户未认证但正在操作数据的情况。由于我们保护了所有端点,这种情况不应该发生,但我们仍将其包括在内以示完整。

❻ 从 Authentication 对象中提取当前已认证用户的用户名

为创建或更新 R2DBC 数据实体的用户添加审计元数据

当定义了 ReactiveAuditorAware bean 并启用了审计功能时,Spring Data 将使用它来提取当前已认证用户的名字,表示为 String。即使在这种情况下,我们也可以使用 @CreatedBy 和 @LastModifiedBy 注解 Order 记录中的两个新字段。每当对实体执行创建或更新操作时,Spring Data 将自动填充这些字段。

列表 12.27 在 R2DBC 实体中捕获用户审计元数据的字段

@Table("orders")
public record Order (
  ...

  @CreatedBy               ❶
  String createdBy, 

  @LastModifiedBy          ❷
  String lastModifiedBy, 

){
  public static Order of(String bookIsbn, String bookName,
    Double bookPrice, Integer quantity, OrderStatus status
  ) {
    return new Order(null, bookIsbn, bookName, bookPrice, quantity, status,
      null, null, null, null, 0);
  }
}

❶ 谁创建了实体

❷ 上次修改实体的用户是谁

在添加新字段后,我们需要使用 Order all-args 构造函数更新几个类,现在它要求你传递 createdBy 和 lastModifiedBy 的值。

OrderService 类包含更新已派发订单的逻辑。打开它,并将 buildDispatchedOrder() 方法更改为确保在调用数据层时正确传递审计元数据。

列表 12.28 更新订单时包含现有的审计元数据

@Service
public class OrderService {
  ...

  private Order buildDispatchedOrder(Order existingOrder) {
    return new Order(
      existingOrder.id(),
      existingOrder.bookIsbn(),
      existingOrder.bookName(),
      existingOrder.bookPrice(),
      existingOrder.quantity(),
      OrderStatus.DISPATCHED,
      existingOrder.createdDate(),
      existingOrder.lastModifiedDate(),
      existingOrder.createdBy(),         ❶
      existingOrder.lastModifiedBy(),    ❷
      existingOrder.version()
    );
  }
}

❶ 谁创建了实体

❷ 上次更新实体的用户是谁

我将留给你以类似的方式更新自动测试。你还可以扩展 OrderJsonTests 中的测试以验证新字段的序列化。作为参考,你可以检查随本书附带的代码库中的 Chapter12/12-end/order-service。确保使用 Order() 构造函数或更新测试,否则应用程序构建将失败。

编写 Flyway 迁移以向模式添加新的审计元数据

与我们对 Catalog Service 所做的工作类似,我们需要编写一个迁移来更新数据库模式,添加两个新字段,用于存储创建实体和最后修改实体的用户名。

在 Order Service 项目的 src/main/resources/db/migration 文件夹中,创建一个新的 V2__Add_user_audit.sql 迁移脚本,向订单表添加两个新列。确保在版本号后输入两个下划线。

列表 12.29 向订单表添加新的审计元数据

ALTER TABLE orders
  ADD COLUMN created_by varchar(255);        ❶
ALTER TABLE orders
  ADD COLUMN last_modified_by varchar(255);  ❷

❶ 为创建行的用户名添加一个列。

❷ 为最后更新行的用户名添加一个列。

确保用户数据隐私

我们还有一个尚未涵盖的最后要求:确保订单数据只能由创建订单的用户访问。任何用户都不应该能够看到其他人的订单。

在 Spring 中实现此要求有几种不同的解决方案。我们将遵循以下步骤:

  1. 向 OrderRepository 添加一个自定义查询,根据创建订单的用户过滤订单。

  2. 更新 OrderService 以使用新的查询而不是默认的 findAll()。

  3. 更新 OrderController 以从安全上下文中提取当前认证用户的用户名,并在请求订单时将其传递给 OrderService。

警告 我们将依赖于一个特定的解决方案,确保每个用户只能通过 /orders 端点访问他们自己的订单。然而,这并不能阻止开发者在未来使用 OrderRepository 提供的其他方法并泄露私人数据。如果你想了解如何改进这个解决方案,请参阅 Laurențiu Spilcă 所著的 Spring Security in Action 一书的第十七章(Manning,2020 年)。

让我们从 OrderRepository 开始。使用你在第五章中学到的约定,定义一个方法来查找由指定用户创建的所有订单。Spring Data 将在运行时为它生成实现。

列表 12.30 定义一个返回由用户创建的订单的方法

public interface OrderRepository
  extends ReactiveCrudRepository<Order,Long> 
{
 Flux<Order> findAllByCreatedBy(String userId); ❶
}

❶ 查询仅由给定用户创建的订单的自定义方法

接下来,我们需要更新 OrderService 中的 getAllOrders() 方法,使其接受一个用户名作为输入并使用 OrderRepository 提供的新查询方法。

列表 12.31 仅返回指定用户的订单

@Service
public class OrderService {
  private final OrderRepository orderRepository;

  public Flux<Order> getAllOrders(String userId) {       ❶
    return orderRepository.findAllByCreatedBy(userId); 
  } 

  ... 
}

❶ 当请求所有订单时,响应中只包括属于给定用户的订单。

最后,让我们更新 OrderController 中的 getAllOrders() 方法。正如你在上一章所学,你可以通过 @AuthenticationPrincipal 注解自动装配代表当前认证用户的对象。在 Edge Service 中,该对象是 OidcUser 类型,因为它基于 OpenID Connect 认证。由于 Order Service 配置了 JWT 认证,主体将是 Jwt 类型。我们可以使用 JWT(访问令牌)来读取包含生成访问令牌的用户名(主题)的 sub 断言。

列表 12.32 获取用户名并仅返回他们创建的订单

@RestController
@RequestMapping("orders")
public class OrderController {
  private final OrderService orderService;

  @GetMapping
  public Flux<Order> getAllOrders(
    @AuthenticationPrincipal Jwt jwt                       ❶
  ) {
    return orderService.getAllOrders(jwt.getSubject());    ❷
  }

  ...
}

❶ 自动装配代表当前认证用户的 JWT

❷ 提取 JWT 的主题并将其用作用户标识

Order Service 的更新就到这里。在下一节中,你将编写一些自动测试来验证数据审计和保护要求。

12.4.4 使用 @WithMockUser 和 Spring Data R2DBC 测试数据审计和保护

在上一节中,我们为用户配置了数据审计并强制执行了一个策略,只返回当前认证用户的订单。本节将向您展示如何作为切片测试来测试数据审计。为了验证数据保护要求,您可以参考书中附带的存储库,并检查它在 OrderServiceApplicationTests 类(第十二章/12-end/order-service/src/test/java)中的集成测试中是如何被覆盖的。

数据审计在存储库级别应用。我们可以通过添加额外的测试用例来扩展 OrderRepositoryR2dbcTests 类,覆盖用户认证和不认证的场景。

与我们在目录服务中做的一样,我们可以使用 Spring Security 的 @WithMockUser 注解在一个认证的上下文中执行测试方法,依赖于一个模拟用户表示。

列表 12.33 测试用户认证与否时的数据审计

@DataR2dbcTest
@Import(DataConfig.class)
@Testcontainers
class OrderRepositoryR2dbcTests {
  ...

  @Test 
  void whenCreateOrderNotAuthenticatedThenNoAuditMetadata() { 
    var rejectedOrder = OrderService.buildRejectedOrder( "1234567890", 3); 
    StepVerifier.create(orderRepository.save(rejectedOrder)) 
      .expectNextMatches(order -> Objects.isNull(order.createdBy()) && 
        Objects.isNull(order.lastModifiedBy()))                           ❶
      .verifyComplete(); 
  } 

  @Test 
  @WithMockUser("marlena") 
  void whenCreateOrderAuthenticatedThenAuditMetadata() { 
    var rejectedOrder = OrderService.buildRejectedOrder( "1234567890", 3); 
    StepVerifier.create(orderRepository.save(rejectedOrder)) 
      .expectNextMatches(order -> order.createdBy().equals("marlena") && 
        order.lastModifiedBy().equals("marlena"))                         ❷
      .verifyComplete(); 
  } 
}

❶ 当用户未认证时,不保存任何审计元数据。

❷ 当用户认证时,关于谁创建或更新了实体的信息被正确地包含在数据中。

打开一个终端窗口,导航到目录服务根目录,并按照以下方式运行新添加的测试:

$ ./gradlew test --tests OrderRepositoryR2dbcTests

如果你遇到任何失败,可能是因为你没有更新使用 Order() 构造函数的测试用例。我们已经添加了新的字段到领域模型中,所以记得也要更新这些测试用例。

这就结束了我们关于使用 Spring Boot、Spring Security、Spring Data 和 Keycloak 对命令式和响应式云原生应用程序进行认证、授权和审计的讨论。

Polar Labs

随意应用你在前几章中学到的知识,并更新目录服务和订单服务以进行部署。

  1. 更新两个应用程序的 Docker Compose 定义以配置 Keycloak URL。你可以使用容器名称(polar-keycloak:8080),它由内置的 Docker DNS 解析。

  2. 更新两个应用程序的 Kubernetes 清单以配置 Keycloak URL。你可以使用 Keycloak 服务名称(polar-keycloak)作为 URL,因为所有交互都在集群内部进行。

你可以参考代码仓库中与本书配套的 Chapter12/12-end 文件夹以检查最终结果(github.com/ThomasVitale/cloud-native-spring-in-action)。你可以使用 kubectl apply -f services 从 Chapter12/12-end/polar-deployment/kubernetes/platform/development 文件夹中的清单部署支持服务,或者使用 ./create-cluster.sh 部署整个集群。

摘要

  • 在 OIDC/OAuth2 设置中,客户端(边缘服务)代表用户通过访问令牌获得对资源服务器(目录服务和订单服务)的访问权限。

  • Spring Cloud Gateway 提供了一个 TokenRelay 过滤器,可以自动将访问令牌添加到任何路由到下游的请求中。

  • 遵循 JWT 格式,ID 令牌和访问令牌可以作为关于认证用户的声明传播相关信息。例如,你可以添加一个角色声明,并根据用户角色配置 Spring Security 的授权策略。

  • Spring Boot 应用程序可以使用 Spring Security 配置为 OAuth2 资源服务器。

  • 在 OAuth2 资源服务器中,用户认证的策略完全基于每个请求的授权头中提供的有效访问令牌。我们称之为 JWT 认证。

  • 在 OAuth2 资源服务器中,安全策略仍然通过 SecurityFilterChain(命令式)或 SecurityWebFilterChain(响应式)bean 来强制执行。

  • Spring Security 将权限、角色和作用域表示为 GrantedAuthority 对象。

  • 您可以提供一个自定义 JwtAuthenticationConverter bean 来定义如何从 JWT 中提取授权权限,例如,使用角色声明。

  • 授权权限可用于采用 RBAC 策略并保护端点,具体取决于用户角色。

  • Spring Data 库支持审计功能,以追踪谁创建了实体以及最后更新它的人。您可以通过配置一个 AuditorAware(或 ReactiveAuditorAware)bean 来返回当前认证用户的用户名,在 Spring Data JDBC 和 Spring Data R2DBC 中启用此功能。

  • 当启用数据审计时,您可以使用 @CreatedBy 和 @LastModifiedBy 注解,在创建或更新操作发生时自动注入正确的值。

  • 测试安全性具有挑战性,但 Spring Security 提供了方便的工具来简化这一过程,包括表达式,可以修改 HTTP 请求以包含 JWT 访问令牌(.with(jwt()) 或 .mutateWith(mockJwt()))或在一个特定安全上下文中为给定用户运行测试用例(@WithMockUser)。

  • Testcontainers 可以通过使用实际的 Keycloak 容器来帮助编写完整的集成测试,以验证与 Spring Security 的交互。

第四部分 云原生生产

到目前为止,这已经是一次令人难以置信的旅程。一章节又一章节,我们探讨了与云原生应用程序一起工作的模式、原则和最佳实践,并使用 Spring Boot 和 Kubernetes 构建了一个书店系统。现在是准备生产的时候了。第四部分将引导你完成最后几个步骤,使你的云原生应用程序准备好投入生产,解决诸如可观察性、配置管理、密钥管理和部署策略等问题。它还涵盖了无服务器和原生镜像。

第十三章描述了如何使用 Spring Boot Actuator、OpenTelemetry 和 Grafana 可观察性堆栈使你的云原生应用程序具有可观察性。你将学习如何配置 Spring Boot 应用程序以生成相关的遥测数据,例如日志、健康、指标、跟踪等。第十四章涵盖了高级配置和密钥管理策略,包括 Kubernetes 原生选项,如 ConfigMaps、Secrets 和 Kustomize。第十五章将引导你完成云原生旅程的最后一步,并教你如何为生产配置 Spring Boot。然后,你将为应用程序设置持续部署并将它们部署到公共云中的 Kubernetes 集群,采用 GitOps 策略。最后,第十六章涵盖了使用 Spring Native 和 Spring Cloud Function 的无服务器架构和函数。你还将了解 Knative 及其强大的功能,这些功能在 Kubernetes 之上提供了卓越的开发者体验。

13 可观测性和监控

本章涵盖

  • 使用 Spring Boot、Loki 和 Fluent Bit 进行日志记录

  • 使用 Spring Boot Actuator 和 Kubernetes 中的健康检查

  • 使用 Spring Boot Actuator、Prometheus 和 Grafana 生成指标

  • 使用 OpenTelemetry 和 Tempo 配置分布式跟踪

  • 使用 Spring Boot Actuator 管理应用程序

在前面的章节中,您学习了可以使用的一些模式和技术的几个示例,以构建安全、可扩展和有弹性的应用程序。然而,我们仍然缺乏对 Polar Bookshop 系统的可见性,尤其是在出现问题的时候。在投入生产之前,我们应该确保我们的应用程序是可观测的,并且部署平台提供了所有必要的工具来监控和深入了解系统。

监控 涉及检查应用程序可用的遥测数据并定义已知故障状态的通知。可观测性 超越了这一点,旨在达到一种状态,我们可以对系统提出任意问题,而无需事先知道问题。产品团队应确保他们的应用程序暴露相关信息;平台团队应提供基础设施以消费这些信息并对其操作提出问题。

如您从第一章中记得的,可观测性 是云原生应用程序的特性之一。可观测性是衡量我们能够从应用程序的输出中推断其内部状态的程度。在第二章中,您学习了 15-Factor 方法论,其中包含两个有助于构建可观测应用程序的因素。第 14 个因素建议将您的应用程序视为太空探测器,并思考您需要什么样的遥测数据来远程监控和控制应用程序,例如日志、指标和跟踪。第 6 个因素建议将日志视为事件流,而不是处理日志文件。

在本章中,您将学习如何确保您的 Spring Boot 应用程序暴露相关信息以推断其内部状态,例如日志、健康检查、指标、跟踪以及有关模式迁移和构建的额外有价值的数据。我还会向您展示如何使用 Grafana 开源可观测性堆栈来验证您对应用程序所做的更改。然而,我不会过多地深入细节,因为这通常是平台团队部署和运营的内容。

注意:本章示例的源代码可在 Chapter13/13-begin 和 Chapter13/13-end 文件夹中找到,其中包含项目的初始状态和最终状态 (github.com/ThomasVitale/cloud-native-spring-in-action)。

13.1 使用 Spring Boot、Loki 和 Fluent Bit 进行日志记录

日志(或事件日志)是软件应用程序中随时间发生的事件的离散记录。它们由一个时间戳组成,用于回答“事件何时发生?”的问题,以及一些提供事件及其上下文详细信息的其他信息,这使我们能够回答诸如“此时发生了什么?”、“哪个线程正在处理该事件?”或“哪个用户/租户处于上下文中?”等问题。

在故障排除和调试任务期间,日志是我们可以使用的基本工具之一,用于重建单个应用程序实例在特定时间点发生的情况。它们通常根据事件类型或严重性进行分类,如跟踪调试信息警告错误。这是一个灵活的机制,允许我们在生产环境中仅记录最严重的事件,同时仍然在调试期间有机会临时更改日志级别。

日志记录的格式可能有所不同,从简单的纯文本到更组织化的键/值对集合,再到以 JSON 格式产生的完全结构化记录。

传统上,我们配置日志输出到宿主机上的文件,这导致应用程序需要处理文件命名约定、文件轮转和文件大小。在云环境中,我们遵循 15-Factor 方法,该方法建议将日志视为流式传输到标准输出的事件。云原生应用程序会流式传输日志,并且不关心它们是如何被处理或存储的。

本节将教会你如何在 Spring Boot 应用程序中添加和配置日志。然后我将解释如何在云原生基础设施中收集和聚合日志。最后,你将运行 Fluent Bit 进行日志收集,运行 Loki 进行日志聚合,并使用 Grafana 查询由你的 Spring Boot 应用程序产生的日志。

13.1.1 使用 Spring Boot 进行日志记录

Spring Boot 自带了对最常见日志框架的支持和自动配置,包括 Logback、Log4J2、Commons Logging 和 Java Util Logging。默认情况下,使用 Logback (logback.qos.ch),但你可以利用 Java 简单日志门面(SLF4J)提供的抽象轻松地替换它。

使用 SLF4J(www.slf4j.org)的接口,你可以自由地更改日志库,而无需更改 Java 代码。此外,云原生应用程序应将日志视为事件并将它们流式传输到标准输出。这正是 Spring Boot 默认所做的。方便,对吧?

配置 Spring Boot 中的日志

事件日志按级别分类,细节逐渐减少,重要性逐渐增加:跟踪(trace)、调试(debug)、信息(info)、警告(warn)、错误(error)。默认情况下,Spring Boot 从info级别开始记录所有内容。

记录器是一个产生日志事件的类。你可以通过配置属性设置记录器级别,可以选择应用全局配置或针对特定的包或类。例如,在第九章中,我们设置了一个调试记录器以获取使用 Resilience4J 实现的断路器的更多详细信息(在 Edge Service 项目的 application.yml 文件中):

logging:
  level:
    io.github.resilience4j: debug     ❶

❶ 为 Resilience4J 库设置了一个调试记录器

你可能需要同时配置多个记录器。在这种情况下,你可以将它们收集到一个日志组中,并直接对该组应用配置。Spring Boot 提供了两个预定义的日志组,web 和 sql,但你也可以定义自己的。例如,为了更好地分析 Edge Service 应用程序中定义的断路器的行为,你可以定义一个日志组并为 Resilience4J 和 Spring Cloud Circuit Breaker 配置日志级别。

在 Edge Service 项目(edge-service)中,你可以在 application.yml 文件中按照以下方式配置新的日志组。

列表 13.1 配置一个组以控制断路器日志

logging:
  group:
    circuitbreaker: io.github.resilience4j,
➥org.springframework.cloud.circuitbreaker     ❶
  level:
    circuitbreaker: info                       ❷

❶ 将多个记录器收集到一个组中以应用相同的配置

❷ 为 Resilience4J 和 Spring Cloud Circuit Breaker 设置了一个“info”记录器,如果需要调试断路器,则很容易更改

默认情况下,每个事件日志都提供基本的信息,包括事件发生的日期和时间、日志级别、进程标识符(PID)、触发事件的线程名称、记录器名称以及日志消息。如果你检查支持 ANSI 的终端中的应用程序日志,日志消息也会被着色以提高可读性(图 13.1)。可以通过 logging.pattern 配置属性组自定义日志格式。

13-01

图 13.1 事件日志包括时间戳、上下文信息和关于发生的事情的消息。

注意 Spring Boot 提供了广泛的配置日志到文件的选项。由于这对于云原生应用程序没有太大用处,因此本书不会涉及。如果你对这个主题感兴趣,请参阅官方文档以了解更多关于日志文件的信息(spring.io/projects/spring-boot)。

将日志添加到 Spring Boot 应用程序中

除了为项目中使用的框架和库配置记录器外,在适用的情况下,你应该在代码中定义事件日志。多少日志才算足够?这取决于上下文。一般来说,我认为日志过多比过少更好。我见过许多部署只是包含添加更多日志的更改,而看到相反的情况则非常罕见。

多亏了 SLF4J 外观,无论使用哪个日志库,在 Java 中定义新事件日志的语法都是相同的:从 LoggerFactory 创建的 Logger 实例。让我们通过向 Catalog Service 的 Web 控制器添加新的日志消息来查看它是如何工作的。

在 Catalog Service 项目(catalog-service)中,转到 BookController 类,从 SLF4J 定义一个 Logger 实例,并在客户端调用应用程序的 REST API 时添加要打印的消息。

列表 13.2 使用 SL4FJ 定义日志事件

package com.polarbookshop.catalogservice.web;

import org.slf4j.Logger; 
import org.slf4j.LoggerFactory; 
...

@RestController
@RequestMapping("books")
public class BookController {
  private static final Logger log = 
    LoggerFactory.getLogger(BookController.class);     ❶
  private final BookService bookService;

  @GetMapping
  public Iterable<Book> get() {
    log.info(                                          ❷
      "Fetching the list of books in the catalog" 
    ); 
    return bookService.viewBookList();
  }

  ...
}

❶ 为 BookController 类定义一个日志记录器

❷ 在“info”级别记录给定的消息

注意:在 Polar Bookshop 系统的任何合适的地方定义新的日志记录器和日志事件。作为一个参考,你可以查看本书附带的源代码仓库(第十三章/13-end)。

Mapped Diagnostic Context(MDC)

你可能需要在日志消息中添加一些常见信息,例如当前认证用户的标识符、当前上下文的租户或请求 URI。你可以直接将那些信息添加到你的日志消息中,就像你在前面的列表中所做的那样,它会起作用,但数据将不会是结构化的。相反,我更喜欢处理结构化数据。

SLF4J 和常见的日志库,如 Logback 和 Log4J2,通过一个名为 Mapped Diagnostic Context(MDC)的工具支持根据请求上下文(身份验证、租户、线程)添加结构化信息。如果你想了解更多关于 MDC 的信息,我建议查看你使用的特定日志库的官方文档。

现在我们将应用程序的日志消息作为事件流记录,我们需要将它们收集并存储在一个中央位置,我们可以查询它。下一节将提供一个解决方案来完成这个任务。

13.1.2 使用 Loki、Fluent Bit 和 Grafana 管理日志

当你迁移到分布式系统,如微服务和复杂环境,如云时,日志管理变得具有挑战性,需要比在更传统的应用程序中不同的解决方案。如果出现问题,我们可以在哪里找到关于故障的数据?传统的应用程序会依赖于存储在主机上的日志文件。云原生应用程序部署在动态环境中,是复制的,并且有不同的生命周期。我们需要收集环境中运行的所有应用程序的日志,并将它们发送到一个中央组件,在那里它们可以被聚合、存储和搜索。

在云中管理日志有很多选择。云服务提供商都有自己的产品,例如 Azure Monitor 日志和 Google Cloud Logging。市场上也有许多企业级解决方案,例如 Honeycomb、Humio、New Relic、Datadog 和 Elastic。

对于 Polar Bookshop,我们将使用基于 Grafana 可观察性堆栈的解决方案(grafana.com)。它由开源技术组成,你可以在任何环境中自行运行它。它还作为 Grafana Labs 提供的托管服务(Grafana Cloud)提供。

我们将使用 Grafana 堆栈的以下组件来管理日志:Loki 用于日志存储和搜索,Fluent Bit 用于日志收集和聚合,以及 Grafana 用于日志数据可视化和查询。

注意:您用于管理日志的技术是平台选择,不应影响应用程序。例如,您应该能够在不修改 Polar Bookshop 应用程序的情况下用 Humio 替换 Grafana 堆栈。

我们需要一个日志收集器来从所有运行的应用程序的标准输出中获取日志消息。使用 Grafana 堆栈,你可以从多个选项中选择一个日志收集器。对于 Polar Bookshop 系统,我们将使用 Fluent Bit,这是一个开源的 CNCF 毕业项目,它“使您能够从多个来源收集日志和指标,通过过滤器丰富它们,并将它们分发到任何定义的目的地”(fluentbit.io)。Fluent Bit 是 Fluentd 的一个子项目,“一个用于统一日志层的开源数据收集器”(www.fluentd.org)。

Fluent Bit 将从所有运行容器中收集日志并将它们转发到 Loki,Loki 将存储它们并使它们可搜索。Loki 是一个“专为存储和查询来自您所有应用程序和基础设施的日志而设计的日志聚合系统”(grafana.com/oss/loki)。

最后,Grafana 将使用 Loki 作为数据源并提供日志可视化功能。Grafana“允许您查询、可视化、警报并理解”无论存储在何处您的遥测数据(grafana.com/oss/grafana)。图 13.2 展示了这种日志架构。

13-02

图 13.2 基于 Grafana 堆栈的云原生应用程序的日志架构

让我们先运行 Grafana、Loki 和 Fluent Bit 作为容器。在你的 Polar Deployment 项目(polar-deployment)中,更新 Docker Compose 配置(docker/docker-compose.yml),以包括新的服务。它们通过我包含在本书源代码库中的文件进行配置(Chapter13/13-end/polar-deployment/docker/observability)。将可观察性文件夹复制到你的项目中相同的路径。

列表 13.3 定义 Grafana、Loki 和 Fluent Bit 的容器

version: "3.8"
services:
  ...

  grafana:
    image: grafana/grafana:9.1.2
    container_name: grafana
    depends_on:
      - loki
    ports:
      - "3000:3000"
    environment:                                               ❶
      - GF_SECURITY_ADMIN_USER=user
      - GF_SECURITY_ADMIN_PASSWORD=password
    volumes:                                                   ❷
      - ./observability/grafana/datasource.yml:/etc/grafana/provisioning/
➥datasources/datasource.yml
      - ./observability/grafana/dashboards:/etc/grafana/provisioning/
➥dashboards
      - ./observability/grafana/grafana.ini:/etc/grafana/grafana.ini
  loki:
    image: grafana/loki:2.6.1
    container_name: loki
    depends_on:
      - fluent-bit
    ports:
      - "3100:3100"

  fluent-bit:
    image: grafana/fluent-bit-plugin-loki:2.6.1-amd64
    container_name: fluent-bit
    ports:
      - "24224:24224"
    environment:
      - LOKI_URL=http://loki:3100/loki/api/v1/push           ❸
    volumes:                                                 ❹
      - ./observability/fluent-bit/fluent-bit.conf:/fluent-bit/etc/
➥fluent-bit.conf

❶ 访问 Grafana 的用户名和密码

❷ 体积用于加载数据源和仪表板的配置。

❸ 定义用于转发日志消息的 Loki URL

❹ 体积用于加载数据收集和交付的配置。

接下来,使用以下命令启动所有三个容器:

$ docker-compose up -d grafana

感谢 Docker Compose 在容器之间定义的依赖关系,启动 Grafana 也将运行 Loki 和 Fluent Bit。

Fluent Bit 可以配置为从不同的来源收集日志。对于 Polar Bookshop,我们将依赖 Docker 中可用的 Fluentd 驱动程序来自动收集运行容器的日志。Docker 平台本身会监听每个容器的日志事件并将它们路由到指定的服务。在 Docker 中,可以直接在容器上配置日志驱动程序。例如,更新 Docker Compose 中的目录服务配置以使用 Fluentd 日志驱动程序,这将把日志发送到 Fluent Bit 容器。

列表 13.4 使用 Fluentd 驱动程序将容器日志路由到 Fluent Bit

version: "3.8"
services:
  ...

  catalog-service:
    depends_on:
      - fluent-bit                                        ❶
      - polar-keycloak
      - polar-postgres
    image: "catalog-service"
    container_name: "catalog-service"
    ports:
      - 9001:9001
      - 8001:8001
    environment:
      - BPL_JVM_THREAD_COUNT=50
      - BPL_DEBUG_ENABLED=true
      - BPL_DEBUG_PORT=8001
      - SPRING_CLOUD_CONFIG_URI=http://config-service:8888
      - SPRING_DATASOURCE_URL=
➥jdbc:postgresql://polar-postgres:5432/polardb_catalog
      - SPRING_PROFILES_ACTIVE=testdata
      - SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI=
➥http://host.docker.internal:8080/realms/PolarBookshop
    logging:                                             ❷
      driver: fluentd                                    ❸
      options: 
        fluentd-address: 127.0.0.1:24224                 ❹

❶ 确保在启动目录服务之前启动 Fluent Bit 容器

❷ 配置容器日志驱动程序的章节

❸ 使用哪个日志驱动程序

❹ 应将日志路由到的 Fluent Bit 实例的地址

接下来,将目录服务打包为容器镜像(./gradlew bootBuildImage),并按以下方式运行应用程序容器:

$ docker-compose up -d catalog-service

多亏了容器之间在 Docker Compose 中定义的依赖关系,Keycloak 和 PostgreSQL 将会自动启动。

现在我们已经准备好测试日志设置。首先,向目录服务发送几个请求以触发生成一些日志消息:

$ http :9001/books

接下来,打开一个浏览器窗口,转到 Grafana(http://localhost:3000),并使用 Docker Compose 中配置的凭据登录(用户/密码)。然后从左侧菜单选择“探索”页面,选择 Loki 作为数据源,从时间下拉菜单中选择“过去 1 小时”,并运行以下查询以搜索由 catalog-service 容器产生的所有日志:

{container_name="/catalog-service"}

结果应类似于图 13.3 所示,显示应用程序启动的日志以及您添加到 BookController 类中的自定义日志消息。

13-03

图 13.3 在 Grafana 中,您可以浏览和搜索由 Loki 聚合和存储的日志消息。

测试完日志设置后,使用 docker-compose down 停止所有容器。

注意:采用相同的方法,更新 Polar Bookshop 系统中所有其他 Spring Boot 应用程序的 Docker Compose 配置,以使用 Fluentd 日志驱动程序并依赖 Fluent Bit 收集日志。作为参考,您可以查看本书附带的源代码仓库(第十三章/13-end/polar-deployment/docker)。

日志提供了一些关于应用程序行为的信息,但不足以推断其内部状态。下一节将介绍如何使应用程序更多地暴露其健康状态的数据。

13.2 使用 Spring Boot Actuator 和 Kubernetes 的健康检查

一旦应用程序部署,我们如何判断它是否健康?它是否能够处理新的请求?它是否进入了故障状态?云原生应用程序应提供有关其健康状态的信息,以便监控工具和部署平台能够检测到有问题并相应地采取行动。我们需要专门的健康端点来检查应用程序的状态以及它可能使用的任何组件或服务。

部署平台可以定期调用应用程序暴露的健康端点。当应用程序实例不健康时,监控工具可以触发警报或通知。在 Kubernetes 的情况下,平台将检查健康端点,并自动替换故障实例或暂时停止向其发送流量,直到它准备好再次处理新请求。

对于 Spring Boot 应用程序,您可以使用 Actuator 库通过 /actuator/health HTTP 端点公开有关其健康状态的信息,包括应用程序状态和使用组件的详细信息,如数据库、事件代理和配置服务器。

Spring Boot Actuator 是一个有用的库,提供了许多用于监控和管理 Spring Boot 应用程序的端点。这些端点可以通过 HTTP 或 JMX 暴露,但无论哪种方式,我们都必须保护它们免受未经授权的访问。我们将限制自己使用 HTTP 端点,因此我们可以使用 Spring Security 来定义访问策略,就像我们迄今为止所使用的任何其他端点一样。

本节将介绍如何使用 Actuator 在 Spring Boot 应用程序中配置健康端点。然后您将了解如何定义存活性和就绪性探针,以便 Kubernetes 可以使用其自我修复功能。

13.2.1 使用 Actuator 为 Spring Boot 应用程序定义健康探针

首先,打开 Catalog 服务项目(catalog-service)中的 build.gradle 文件,并确保它包含对 Spring Boot Actuator 的依赖(我们在第四章中使用它来刷新运行时配置)。

列表 13.5 在 Catalog 服务中添加 Spring Boot Actuator 依赖项

dependencies {
  ...
  implementation 'org.springframework.boot:spring-boot-starter-actuator' 
}

有几种可行的方案可以保护 Spring Boot Actuator 端点。例如,您可以只为 Actuator 端点启用 HTTP Basic 认证,而所有其他端点将继续使用 OpenID Connect 和 OAuth2。为了简单起见,在 Polar Bookshop 系统中,我们将保持 Actuator 端点在 Kubernetes 集群内部未认证,并阻止从外部对其的任何访问(您将在第十五章中看到)。

警告 在实际的生产场景中,我建议即使是在集群内部也要保护 Actuator 端点的访问。

前往您的 Catalog 服务项目的 SecurityConfig 类,并更新 Spring Security 配置以允许对 Spring Boot Actuator 端点进行未认证访问。

列表 13.6 允许对 Actuator 端点进行未认证访问

@EnableWebSecurity
public class SecurityConfig {

  @Bean
  SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
     .authorizeHttpRequests(authorize -> authorize
       .mvcMatchers("/actuator/**").permitAll()                     ❶
       .mvcMatchers(HttpMethod.GET, "/", "/books/**").permitAll()
       .anyRequest().hasRole("employee")
     )
     .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
     .sessionManagement(sessionManagement -> sessionManagement
       .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
     .csrf(AbstractHttpConfigurer::disable)
     .build();
  }
}

❶ 允许对任何 Spring Boot Actuator 端点进行未认证访问

最后,打开您的 Catalog Service 项目(catalog-service)中的 application.yml 文件,并配置 Actuator 以暴露健康 HTTP 端点。如果您遵循了第四章中的示例,您可能已经有了刷新端点的现有配置。在这种情况下,请将其替换为健康端点。

列表 13.7 暴露健康 Actuator 端点

management:
  endpoints:
    web:
      exposure:
        include: health      ❶

❶ 通过 HTTP 暴露 /actuator/health 端点

让我们检查结果。首先,我们需要运行 Catalog Service 所使用的所有支持服务:Config Service、Keycloak 和 PostgreSQL。我们将以容器形式运行它们。将 Config Service 打包为容器镜像(./gradlew bootBuildImage)。然后打开一个终端窗口,导航到您保存 Docker Compose 文件(polar-deployment/docker)的文件夹,并运行以下命令:

$ docker-compose up -d config-service polar-postgres polar-keycloak

确保所有容器都准备就绪后,在 JVM 上运行 Catalog Service(./gradlew bootRun),打开一个终端窗口,并向健康端点发送 HTTP GET 请求:

$ http :9001/actuator/health

该端点将返回 Catalog Service 应用程序的整体健康状态,可以是 UP、OUT_OF_SERVICE、DOWN 或 UNKNOWN 之一。当健康状态为 UP 时,端点返回 200 OK 响应。如果不是,它将产生 503 服务不可用响应。

{
  "status": "UP"
}

默认情况下,Spring Boot Actuator 只返回整体健康状态。然而,通过应用程序属性,您可以使其提供有关应用程序使用的几个组件的更具体信息。为了更好地保护此类信息的访问,您可以选择始终显示健康细节和组件(always)或仅在请求被授权时显示(when_authorized)。由于我们不在应用程序级别保护 Actuator 端点,让我们使额外信息始终可用。

列表 13.8 配置健康端点以暴露更多信息

management:
  endpoints:
    web:
      exposure:
        include: health
  endpoint: 
    health: 
      show-details: always      ❶
      show-components: always   ❷

❶ 总是显示应用程序的健康细节

❷ 总是显示应用程序使用的组件信息

再次运行 Catalog Service(./gradlew bootRun),并向 http://localhost:9001/actuator/health 发送 HTTP GET 请求。这次,结果 JSON 对象包含有关应用程序健康状态的更详细信息。以下是一个部分结果的示例。

{
  "components": {              ❶
    "clientConfigServer": {
      "details": {
        "propertySources": [
          "configserver:https://github.com/PolarBookshop/
➥config-repo/catalog-service.yml",
          "configClient"
        ]
      },
      "status": "UP"
    },
    "db": {
      "details": {
        "database": "PostgreSQL",
        "validationQuery": "isValid()"
      },
      "status": "UP"
    },
    ...
  },
  "status": "UP"               ❷
}

❶ 应用程序使用的组件和功能的详细健康信息

❷ 应用程序的整体健康状态

Spring Boot Actuator 提供的通用健康端点对于监控和配置警报或通知很有用,因为它包含有关应用程序及其支持服务的详细信息的细节。在下一节中,您将了解如何暴露更具体的信息,这些信息被像 Kubernetes 这样的部署平台用于管理容器。

在继续之前,停止应用程序进程(Ctrl-C),但保留所有当前容器运行。您很快就会需要它们!

13.2.2 在 Spring Boot 和 Kubernetes 中配置健康检查

除了显示有关应用程序健康状态的详细信息外,Spring Boot Actuator 还会自动检测应用程序是否在 Kubernetes 环境上运行,并启用 健康探针 返回存活状态(/actuator/health/liveness)和就绪状态(/actuator/health/readiness),如图 13.4 所示:

  • 存活状态——当应用未存活时,这意味着它已进入一个无法恢复的故障内部状态。默认情况下,Kubernetes 将尝试重启它以解决问题。

  • 就绪状态——当应用未就绪时,这意味着它无法处理新请求,要么是因为它仍在初始化所有组件(在启动阶段),要么是因为它过载。Kubernetes 将停止将请求转发到该实例,直到它准备好再次接受新请求。

13-04

图 13.4 Kubernetes 使用存活性和就绪性探针在出现故障时实现其自我修复功能。

自定义存活性和就绪性探针

要在任何环境中扩展对健康探针的支持,您可以通过专用属性配置 Spring Boot Actuator。打开 Catalog Service 项目(catalog-service),并按以下方式更新 application.yml 文件。

列表 13.9 在任何环境中启用存活性和就绪性探针

management:
  endpoints:
    web:
      exposure:
        include: health
  endpoint:
    health:
      show-details: always
      show-components: always
      probes: 
        enabled: true    ❶

❶ 启用对健康探针的支持

让我们检查结果。上一节中所有为 Catalog Service 提供支持的后台服务都应该在 Docker 上运行。如果不是这样,请返回并按照说明启动它们所有(docker-compose up -d config-service polar-postgres polar-keycloak)。然后,在 JVM 上运行 Catalog Service(./gradlew bootRun),并调用存活性探针的端点:

$ http :9001/actuator/health/liveness
{
  "status": "UP"
}

Spring Boot 应用的存活状态表示它处于正确或损坏的内部状态。如果 Spring 应用程序上下文已成功启动,则内部状态有效。它不依赖于任何外部组件。否则,它将导致级联故障,因为 Kubernetes 将尝试重启损坏的实例。

最后,检查就绪性探针端点的结果:

$ http :9001/actuator/health/readiness
{
  "status": "UP"
}

Spring Boot 应用的就绪状态表示它是否准备好接受流量并处理新请求。在启动阶段或优雅关闭期间,应用未就绪并将拒绝任何请求。如果它在某个时刻过载,它也可能暂时不就绪。当它不就绪时,Kubernetes 不会向应用实例发送任何流量。

在测试完健康端点后,停止应用(Ctrl-C)和容器(docker-compose down)。

注意:请继续将 Spring Boot Actuator 添加到组成 Polar Bookshop 系统的所有应用程序中。在 Order Service 和 Edge Service 中,记得在 SecurityConfig 类中配置对 Actuator 端点的未认证访问,就像我们对 Catalog Service 所做的那样。在 Dispatcher Service 中,您还需要添加对 Spring WebFlux(org.springframework.boot:spring-boot-starter-webflux)的依赖,因为 Actuator 需要一个配置好的 Web 服务器来通过 HTTP 提供服务其端点。然后配置所有应用程序的健康端点,就像您在本节中学到的那样。作为一个参考,您可以查看本书附带的源代码存储库(第十三章/13-end)。

默认情况下,Spring Boot 中的就绪探针不依赖于任何外部组件。您可以决定是否将任何外部系统包含在就绪探针中。

例如,Catalog Service 是 Order Service 的外部系统。您是否应该将其包含在就绪探针中?由于 Order Service 采用弹性模式来处理 Catalog Service 不可用的情况,因此您应该将 Catalog Service 排除在就绪探针之外。当它不可用时,Order Service 将继续正常工作,但会进行优雅的功能降级。

让我们考虑另一个例子。Edge Service 依赖于 Redis 来存储和检索 Web 会话数据。您是否应该将其包含在就绪探针中?由于 Edge Service 在未访问 Redis 的情况下无法处理任何新请求,因此将 Redis 包含在就绪探针中可能是个好主意。Spring Boot Actuator 将考虑应用程序的内部状态以及与 Redis 的集成,以确定应用程序是否准备好接受新请求。

在 Edge Service 项目(edge-service)中,打开 application.yml 文件,并定义在就绪探针中使用哪些指标:应用程序的标准就绪状态和 Redis 健康状态。我将假设您已经将 Spring Boot Actuator 添加到 Edge Service 中,并按照前面描述的方式配置了健康端点。

列表 13.10 在计算就绪状态时包含 Redis

management:
  endpoints:
    web:
      exposure:
        include: health
  endpoint:
    health:
      show-details: always
      show-components: always
      probes:
        enabled: true
      group: 
        readiness: 
          include: readinessState,redis     ❶

❶ 就绪探针将结合应用程序的就绪状态和 Redis 的可用性。

在 Kubernetes 中配置存活性和就绪探针

Kubernetes 依赖于健康探针(存活性和就绪性)来完成其作为容器编排器的任务。例如,当应用程序期望的状态是拥有三个副本时,Kubernetes 确保始终有三个应用程序实例在运行。如果其中任何一个实例没有从存活探针返回 200 响应,Kubernetes 将重新启动它。当启动或升级应用程序实例时,我们希望这个过程对用户来说没有停机时间。因此,Kubernetes 将不会在负载均衡器中启用实例,直到它准备好接受新请求(当 Kubernetes 从就绪探针获得 200 响应时)。

由于活动状态和就绪状态信息是特定于应用的,Kubernetes 需要应用本身声明如何检索这些信息。依赖 Actuator,Spring Boot 应用提供作为 HTTP 端点的活动状态和就绪状态探针。让我们看看我们如何配置 Kubernetes 使用这些端点进行健康探针。

在您的目录服务项目(catalog-service)中,打开部署清单(k8s/deployment.yml),并更新配置以包含活动状态和就绪状态探针,如下所示。

列表 13.11 配置目录服务的活动状态和就绪状态探针

apiVersion: apps/v1
kind: Deployment
metadata:
  name: catalog-service
  ...
spec:
  ...
  template:
    ...
    spec:
      containers:
        - name: catalog-service
          image: catalog-service
          ...
          livenessProbe:                          ❶
            httpGet:                              ❷
              path: /actuator/health/liveness     ❸
              port: 9001                          ❹
            initialDelaySeconds: 10               ❺
            periodSeconds: 5                      ❻
          readinessProbe:                         ❼
            httpGet:  
              path: /actuator/health/readiness 
              port: 9001 
            initialDelaySeconds: 5 
            periodSeconds: 15 

❶ 活动探针的配置

❷ 使用 HTTP GET 请求获取活动状态

❸ 调用以获取活动状态的端点

❹ 使用该端口来获取活动状态

❺ 在开始检查活动状态之前有一个初始延迟

❻ 检查活动状态的频率

❼ 就绪探针的配置

这两个探针都可以配置,以便 Kubernetes 在初始延迟(initialDelaySeconds)后开始使用它们,您还可以定义调用它们的频率(periodSeconds)。初始延迟应考虑应用需要几秒钟才能启动,并且它将取决于可用的计算资源。轮询周期不应太长,以减少应用实例进入故障状态与平台采取自我修复措施之间的时间。

警告:如果您在资源受限的环境中运行这些示例,您可能需要调整初始延迟和轮询频率,以便给应用更多时间启动并准备好接受请求。当在运行这些示例的 Apple Silicon 计算机上运行时,您可能也需要这样做,直到 ARM64 支持成为 Paketo Buildpacks 的一部分(您可以在此处跟踪更新:github.com/paketo-buildpacks/stacks/issues/51)。这是因为 AMD64 容器镜像通过基于 Rosetta 的兼容层在 Apple Silicon 计算机(ARM64)上运行,这影响了应用的启动时间。

请继续为组成 Polar Bookshop 系统的所有应用配置活动状态和就绪状态探针。作为一个参考,您可以查看本书附带的源代码仓库(第十三章/13-end)。

在事件日志之上,健康信息提高了我们可以推断的应用程序内部状态的信息,但这不足以实现完全的可见性。接下来的部分将介绍指标的概念以及我们如何在 Spring Boot 中配置它们。

13.3 使用 Spring Boot Actuator、Prometheus 和 Grafana 进行指标和监控

为了正确监控、管理和调试在生产环境中运行的应用程序,我们需要能够回答诸如“应用程序消耗了多少 CPU 和 RAM?”,“随着时间的推移使用了多少线程?”,以及“失败的请求数量是多少?”等问题。事件日志和健康检查无法帮助我们回答这些问题。我们需要更多数据。

指标是关于应用程序的数值数据,在常规的时间间隔内进行测量和汇总。我们使用指标来跟踪事件的发生(例如收到 HTTP 请求),计数项目(例如分配的 JVM 线程数量),测量执行任务所需的时间(例如数据库查询的延迟),或获取资源的当前值(例如当前的 CPU 和 RAM 消耗)。这些都是理解应用程序为何以某种方式行为的有价值信息。您可以监控指标并为它们设置警报或通知。

Spring Boot Actuator 通过利用 Micrometer 库(micrometer.io)默认收集应用程序指标。Micrometer 包含用于从基于 JVM 的应用程序中的常见组件收集有价值指标的仪器代码。它提供了一个供应商中立的界面,因此您可以使用不同的格式(如 Prometheus/Open Metrics、Humio、Datadog 和 VMware Tanzu Observability)导出从 Micrometer 收集的指标。正如 SLF4J 为日志库提供了一个供应商中立的界面一样,Micrometer 也为指标导出器做了同样的事情。

在 Spring Boot 配置的默认 Micrometer 仪器库之上,您可以导入额外的仪器来收集来自特定库(如 Resilience4J)的指标,甚至可以定义自己的而不受供应商锁定。

导出指标最常用的格式是 Prometheus 使用的格式,它是一个“开源的系统监控和警报工具包”(prometheus.io)。正如 Loki 聚合和存储事件日志一样,Prometheus 也对指标进行同样的处理。

在本节中,您将了解如何在 Spring Boot 中配置指标。然后您将使用 Prometheus 来汇总指标,并使用 Grafana 在仪表板中可视化它们。

13.3.1 使用 Spring Boot Actuator 和 Micrometer 配置指标

Spring Boot Actuator 默认自动配置 Micrometer 来收集关于 Java 应用程序的指标。暴露此类指标的一种方法是通过启用 Actuator 实现的/actuator/metrics HTTP 端点。让我们看看如何做到这一点。

在您的目录服务项目(catalog-service)中,更新 application.yml 文件以通过 HTTP 暴露指标端点。

列表 13.12 暴露指标 Actuator 端点

management:
  endpoints:
    web:
      exposure:
        include: health, metrics      ❶

❶ 暴露健康和指标端点

确保目录服务所需的支撑服务通过以下命令启动并运行:

$ docker-compose up -d polar-keycloak polar-postgres

然后运行应用程序(./gradlew bootRun),并调用/actuator/metrics 端点:

$ http :9001/actuator/metrics

结果是您可以进一步探索的指标集合,通过向端点添加指标名称(例如,/actuator/metrics/jvm.memory.used)。

Micrometer 提供了生成这些指标的仪表化工具,但您可能希望以不同的格式导出它们。在决定您想使用哪种监控解决方案来收集和存储指标之后,您需要添加对该工具的特定依赖项。在 Grafana 可观察性堆栈中,该工具是 Prometheus。

在 Catalog Service 项目(catalog-service)中,更新 build.gradle 文件,添加对提供 Prometheus 集成的 Micrometer 库的依赖项。请记住,在添加新依赖项后,刷新或重新导入 Gradle 依赖项。

列表 13.13 为 Micrometer Prometheus 添加依赖项

dependencies {
  ...
  runtimeOnly 'io.micrometer:micrometer-registry-prometheus' 
}

然后更新 application.yml 文件,通过 HTTP 暴露 Prometheus Actuator 端点。您还可以删除更通用的指标端点,因为我们不再使用它了。

列表 13.14 暴露 Prometheus Actuator 端点

management:
  endpoints:
    web:
      exposure:
        include: health, prometheus      ❶

❶ 暴露健康和 Prometheus 端点

Prometheus 使用的默认策略是拉取式,这意味着 Prometheus 实例通过专用端点(在 Spring Boot 场景中为 /actuator/prometheus)以固定的时间间隔从应用程序中抓取(拉取)指标。重新运行应用程序(./gradlew bootRun),并调用 Prometheus 端点以检查结果:

$ http :9001/actuator/prometheus

结果是与指标端点获取的相同指标集合,但这次它们使用 Prometheus 理解的格式导出。以下代码片段显示了完整响应的摘录,突出显示与当前线程数相关的指标:

# HELP jvm_threads_states_threads The current number of threads
# TYPE jvm_threads_states_threads gauge
jvm_threads_states_threads{state="terminated",} 0.0
jvm_threads_states_threads{state="blocked",} 0.0
jvm_threads_states_threads{state="waiting",} 13.0
jvm_threads_states_threads{state="timed-waiting",} 7.0
jvm_threads_states_threads{state="new",} 0.0
jvm_threads_states_threads{state="runnable",} 11.0

此格式基于纯文本,称为 Prometheus 展示格式。鉴于 Prometheus 在生成和导出指标方面得到广泛采用,此格式已在 OpenMetrics(openmetrics.io)中经过打磨和标准化,OpenMetrics 是一个 CNCF 孵化项目。Spring Boot 支持原始 Prometheus 格式(默认行为)和 OpenMetrics,具体取决于 HTTP 请求的 Accept 标头。如果您想根据 OpenMetrics 格式获取指标,您需要明确请求:

$ http :9001/actuator/prometheus \
    'Accept:application/openmetrics-text; version=1.0.0; charset=utf-8'

当您完成分析 Prometheus 指标后,停止应用程序(Ctrl-C)和所有容器(docker-compose down)。

注意:您可能会遇到需要从短暂运行的应用程序或批处理作业中收集指标的情况,这些作业运行时间不够长,无法被拉取。在这种情况下,Spring Boot 允许您采用基于推送的策略,以便应用程序本身将指标发送到 Prometheus 服务器。官方文档解释了如何配置这种行为(spring.io/projects/spring-boot)。

Spring Boot Actuator 依赖于 Micrometer 仪表化,并为可能用于应用程序的各种技术提供自动配置以生成指标:JVM、日志记录器、Spring MVC、Spring WebFlux、RestTemplate、WebClient、数据源、Hibernate、Spring Data、RabbitMQ 等。

当 Spring Cloud Gateway 在类路径中,如 Edge Service 的情况时,将导出有关网关路由的附加指标。一些库,如 Resilience4J,通过特定的依赖项贡献了专门的 Micrometer 仪表化,以注册额外的指标。

打开 Edge Service 项目(edge-service)中的 build.gradle 文件,并添加以下依赖项以包含 Resilience4J 的 Micrometer 仪表化。记住在添加新内容后刷新或重新导入 Gradle 依赖项。

列表 13.15 为 Micrometer Resilience4J 添加依赖项

dependencies {
  ...
  runtimeOnly 'io.github.resilience4j:resilience4j-micrometer' 
}

现在我们已经配置了 Spring Boot 以公开指标,让我们看看如何配置 Prometheus 以抓取它们以及如何配置 Grafana 以可视化它们。

13.3.2 使用 Prometheus 和 Grafana 监控指标

与 Loki 类似,Prometheus 收集并存储指标。它甚至提供了一个用于可视化和定义警报的 GUI,但我们将使用 Grafana,因为它是一个更全面的工具。

指标以时间序列数据的形式存储,包含它们被注册的时间戳以及可选的标签。在 Prometheus 中,标签是键/值对,为记录的指标添加更多信息。例如,一个记录应用程序使用的线程数量的指标可以通过标签来增强,以说明线程的状态(如阻塞、等待或空闲)。标签有助于聚合和查询指标。

Micrometer 提供了 标签 的概念,与 Prometheus 的 标签 相当。在 Spring Boot 中,你可以利用配置属性来定义所有由应用程序产生的指标的共同标签。例如,添加一个应用程序标签,将每个指标标记为生成它的应用程序的名称是有用的。

打开 Catalog Service 项目(catalog-service),转到 application.yml 文件,并定义一个带有应用程序名称的 Micrometer 标签,这将导致应用于所有指标的标签。由于应用程序名称已在 spring.application.name 属性中定义,让我们重用它而不是重复值。

列表 13.16 使用应用程序名称标记所有指标

management:
  endpoints:
    web:
      exposure:
        include: health, prometheus
  endpoint:
    health:
      show-details: always
      show-components: always
      probes:
        enabled: true
  metrics:  
    tags: 
      application: ${spring.application.name}     ❶

❶ 添加一个带有应用程序名称的 Micrometer 通用标签。这会导致 Prometheus 标签应用于所有指标。

通过这次更改,所有指标都将具有一个应用程序标签,包含应用程序名称,这在查询指标和构建用于在 Grafana 中可视化的仪表板时非常有用:

jvm_threads_states_threads{application="catalog-service",
➥state="waiting",} 13.0

当您处理日志时已经遇到了 Grafana。就像您使用 Loki 作为 Grafana 的数据源来浏览日志一样,您可以使用 Prometheus 作为数据源来查询指标。此外,您可以使用 Prometheus 存储的指标来定义仪表板、图形化可视化数据,并在某些指标返回已知关键值时设置警报或通知。例如,当每分钟失败的 HTTP 请求率超过某个阈值时,您可能希望收到警报或通知,以便您可以采取行动。图 13.5 阐述了监控架构。

13-05

图 13.5 基于 Grafana 堆栈的云原生应用程序监控架构

在您的 Polar 部署项目(polar-deployment)中,更新 Docker Compose 配置(docker/docker-compose.yml)以包含 Prometheus。Grafana 已经配置为使用您之前从第十三章/13-end/polar-deployment/docker/observability 导入到项目中的配置文件中的 Prometheus 作为数据源。

列表 13.17 定义 Prometheus 容器以收集指标

version: "3.8"
services:
  ...

  grafana:
    image: grafana/grafana:9.1.2
    container_name: grafana
    depends_on:
      - loki
      - prometheus                   ❶
    ...

  prometheus: 
    image: prom/prometheus:v2.38.0 
    container_name: prometheus 
    ports: 
      - "9090:9090" 
    volumes:                         ❷
      - ./observability/prometheus/prometheus.yml:/etc/prometheus/ 
      ➥ prometheus.yml 

❶ 确保 Prometheus 在 Grafana 之前启动

❷ 使用卷来加载 Prometheus 抓取的配置。

与 Loki 不同,我们不需要一个专门的组件来从应用程序中收集指标。Prometheus 服务器容器既可以收集也可以存储指标。

接下来,打开一个终端窗口,导航到您保存 Docker Compose 文件(polar-deployment/docker)的文件夹,并使用以下命令运行完整的监控堆栈:

$ docker-compose up -d grafana

Prometheus 容器配置为每 2 秒从 Polar Bookshop 中的所有以容器形式运行的 Spring Boot 应用程序中轮询指标。将目录服务打包为容器镜像(./gradlew bootBuildImage),并使用 Docker Compose 运行:

$ docker-compose up -d catalog-service

向目录服务(http :9001/books)发送几个请求,然后打开一个浏览器窗口并转到 Grafana(http://localhost:3000)(用户/密码)。在探索部分,您可以查询像浏览日志一样的指标。选择 Prometheus 作为数据源,从时间下拉菜单中选择过去 5 分钟,并查询应用程序使用的 JVM 内存相关的指标,如下所示(图 13.6):

jvm_memory_used_bytes{application="catalog-service"}

13-06

图 13.6 在 Grafana 中,您可以浏览和查询 Prometheus 聚合和存储的指标。

指标数据可用于绘制用于监控不同应用程序方面的仪表板。从左侧菜单中选择仪表板 > 管理,并探索我在 Grafana 中包含在应用程序文件夹内的仪表板。

例如,打开 JVM 仪表板(图 13.7)。它可视化 Spring Boot 应用程序运行的 JVM 的不同指标,例如 CPU 使用率、堆内存、非堆内存、垃圾回收和线程。

13-07

图 13.7 在 Grafana 中,可以使用仪表板可视化 Prometheus 指标。

在仪表板页面,探索我配置的其他仪表板,以获取更多关于 Polar Bookshop 应用程序的可视性。每个仪表板都增加了关于其目标和如何使用它的额外信息。

当你在 Grafana 中检查完应用程序指标后,停止所有容器(docker-compose down)。

13.3.3 在 Kubernetes 中配置 Prometheus 指标

在 Kubernetes 中运行应用程序时,我们可以使用专用注释来标记 Prometheus 服务器应该抓取哪些容器,并通知它要调用的 HTTP 端点和端口号。

您将在本书的后面有机会测试这个设置,我们将在一个生产 Kubernetes 集群中部署完整的 Grafana 可观察性堆栈。现在,让我们为 Polar Bookshop 中的所有 Spring Boot 应用程序准备部署清单。例如,以下列表显示了如何更改目录服务清单(catalog-service/k8s/deployment.yml)。

列表 13.18 为 Prometheus 指标抓取注释目录服务

apiVersion: apps/v1
kind: Deployment
metadata:
  name: catalog-service
  labels:
    app: catalog-service
spec:
  replicas: 1
  selector:
    matchLabels:
      app: catalog-service
  template:
    metadata:
      labels:
        app: catalog-service
      annotations: 
        prometheus.io/scrape: "true"                 ❶
        prometheus.io/path: /actuator/prometheus     ❷
        prometheus.io/port: "9001"                   ❸
  ...

❶ Prometheus 应在此 Pod 中抓取容器的信号

❷ 识别暴露 Prometheus 指标的 HTTP 端点

❸ 指定指标端点可用的端口号

Kubernetes 清单中的注释应该是 String 类型,这就是为什么在可能被错误解析为数字或布尔值的值的情况下需要引号。

继续配置 Polar Bookshop 系统中所有剩余应用程序的指标和 Prometheus,包括 Kubernetes 清单的配置。作为参考,您可以查看本书附带的源代码存储库(第十三章/13-end)。

下一个部分将介绍另一种我们需要用于监控应用程序和使它们可观察的遥测类型:跟踪。

13.4 使用 OpenTelemetry 和 Tempo 进行分布式跟踪

事件日志、健康检查和指标为推断应用程序的内部状态提供了广泛的有价值数据。然而,它们都没有考虑到云原生应用程序是分布式系统。用户请求可能被多个应用程序处理,但到目前为止,我们还没有一种方法可以在应用程序边界之间关联数据。

解决该问题的简单方法可能是为系统边缘的每个请求生成一个标识符(一个关联 ID),在事件日志中使用它,并将其传递给其他相关服务。通过使用该关联 ID,我们可以从多个应用程序中检索与特定事务相关的所有日志消息。

如果我们进一步遵循这个想法,我们将得到分布式跟踪,这是一种跟踪请求在分布式系统中流动的技术,使我们能够定位错误发生的位置并解决性能问题。分布式跟踪有三个主要概念:

  • 跟踪代表与请求或事务相关的活动,由一个唯一的跟踪 ID来标识。它由一个或多个跨越一个或多个服务的跨度组成。

  • 请求处理的每一步都称为一个跨度,它由开始和结束时间戳组成,并由跟踪 ID 和跨度 ID的唯一对来标识。

  • 标签是元数据,提供了有关跨度上下文的额外信息,例如请求 URI、当前登录用户的用户名或租户标识符。

让我们考虑一个例子。在极地书店,你可以通过网关(边缘服务)获取书籍,然后请求被转发到目录服务。处理此类请求的跟踪涉及这两个应用程序和至少三个跨度:

  • 第一个跨度是边缘服务接受初始 HTTP 请求所执行的步骤。

  • 第二个跨度是边缘服务将请求路由到目录服务所执行的步骤。

  • 第三个跨度是目录服务处理路由请求所执行的步骤。

与分布式跟踪系统相关的选择有很多。首先,我们必须选择我们将用于生成和传播跟踪的格式和协议。为此,我们将使用 OpenTelemetry(也称为OTel),这是一个 CNCF 孵化项目,正在迅速成为分布式跟踪的事实标准,旨在统一遥测数据的收集(opentelemetry.io)。

接下来,我们需要选择是否直接使用 OpenTelemetry(使用 OpenTelemetry Java 工具)或依赖一个以供应商中立的方式对代码进行配置并集成到不同的分布式跟踪系统(如 Spring Cloud Sleuth)的代理。我们将选择第一种选项。

一旦应用程序为分布式跟踪进行了配置,我们就需要一个工具来收集和存储跟踪。在 Grafana 可观察性堆栈中,首选的分布式跟踪后端是 Tempo,这是一个“让你以尽可能低的操作成本和比以往任何时候都少的复杂性来扩展跟踪”的项目(grafana.com/oss/tempo)。与我们在 Prometheus 中使用的方式不同,Tempo 遵循基于推送的策略,其中应用程序本身将数据推送到分布式跟踪后端。

本节将向您展示如何使用 Tempo 完成 Grafana 可观察性设置,并使用它来收集和存储跟踪。然后,我将向您展示如何在 Spring Boot 应用程序中使用 OpenTelemetry Java 工具生成并发送跟踪到 Tempo。最后,您将学习如何从 Grafana 查询跟踪。

OpenTelemetry、Spring Cloud Sleuth 和 Micrometer 跟踪

一些标准已经出现,用于实现分布式跟踪和定义生成和传播跟踪和跨度指南。OpenZipkin 是更成熟的项目(zipkin.io)。OpenTracing 和 OpenCensus 是更近期的项目,它们试图标准化支持分布式跟踪的应用程序代码的仪器化方式。现在它们都已经弃用,因为它们已经联合起来致力于 OpenTelemetry:一个“仪器化、生成、收集和导出遥测数据(指标、日志和跟踪)”的终极框架。Tempo 支持所有这些选项。

Spring Cloud Sleuth (spring.io/projects/spring-cloud-sleuth)是一个项目,为 Spring Boot 应用程序提供分布式跟踪的自动配置。它负责对 Spring 应用程序中常用的库进行仪器化,并在特定的分布式跟踪库之上提供抽象层。OpenZipkin 是默认选择。

在本书中,我决定向您展示如何直接使用 OpenTelemetry Java 仪器化,主要有两个原因。首先,Spring Cloud Sleuth 对 OpenTelemetry 的支持仍然是实验性的,并且在撰写本文时尚未准备好投入生产(github.com/spring-projects-experimental/spring-cloud-sleuth-otel)。

其次,一旦 Spring Framework 6 和 Spring Boot 3 发布,Spring Cloud Sleuth 将不再进一步开发。Spring 项目将 Sleuth 核心框架捐赠给了 Micrometer,并创建了一个新的 Micrometer Tracing 子项目,旨在为跟踪提供一个供应商中立的界面,类似于 Micrometer 已经为指标所做的那样。Micrometer Tracing 将为 OpenZipkin 和 OpenTelemetry 提供支持。基于 Micrometer Tracing,代码仪器化将成为所有 Spring 库的核心方面,作为 Spring Observability 倡议的一部分。

13.4.1 使用 Tempo 和 Grafana 管理跟踪

分布式跟踪后端负责聚合、存储并使跟踪可搜索。Tempo 是 Grafana 可观察性堆栈中的解决方案。图 13.8 说明了跟踪架构。

13-08

图 13.8 基于 Grafana 堆栈的云原生应用程序的分布式跟踪架构

注意:大多数供应商支持 OpenTelemetry,因此您可以在不更改应用程序中的任何内容的情况下轻松地交换您的分布式跟踪后端。例如,您可以将跟踪发送到其他平台,如 Honeycomb、Lightstep 或 VMware Tanzu Observability,而不是使用 Tempo。

首先,让我们更新 Polar Bookshop 的 Docker Compose 文件以包括 Tempo(polar-deployment/docker/docker-compose.yml)。Grafana 已经配置为在您之前导入到项目中的配置文件中使用 Tempo 作为数据源,这些配置文件来自第十三章/13-end/polar-deployment/docker/observability。

列表 13.19 定义用于收集和存储跟踪的 Tempo 容器

version: "3.8"
services:
  ...

  grafana:
    image: grafana/grafana:9.1.2
    container_name: grafana
    depends_on:
      - loki
      - prometheus
      - tempo                                        ❶
    ...

  tempo: 
    image: grafana/tempo:1.5.0
    container_name: tempo 
    command: -config.file /etc/tempo-config.yml      ❷
    ports: 
      - "4317:4317"                                  ❸
    volumes:                                         ❹
      - ./observability/tempo/tempo.yml:/etc/tempo-config.yml 

❶ 确保在 Grafana 之前启动 Tempo

❷ 在启动阶段加载自定义配置

❸ 通过 gRPC 协议将 OpenTelemetry 协议用于接受跟踪的端口

❹ 使用卷加载 Tempo 的配置。

接下来,让我们在 Docker 上运行完整的 Grafana 可观察性堆栈。打开一个终端窗口,导航到您保存 Docker Compose 文件的文件夹,并运行以下命令:

$ docker-compose up -d grafana

Tempo 现在已准备好在端口 4317 上接受通过 gRPC 的 OpenTelemetry 跟踪。在下一节中,您将看到如何更新 Spring Boot 应用程序以生成跟踪并将它们发送到 Tempo。

13.4.2 使用 OpenTelemetry 配置 Spring Boot 中的跟踪

OpenTelemetry 项目包括为最常见的 Java 库生成跟踪和范围的仪器,包括 Spring、Tomcat、Netty、Reactor、JDBC、Hibernate 和 Logback。OpenTelemetry Java Agent 是由项目提供的 JAR 工件,可以附加到任何 Java 应用程序。它动态注入必要的字节码以捕获所有这些库的跟踪和范围,并且可以以不同的格式导出它们,而无需更改您的 Java 源代码。

Java 代理通常在运行时从外部提供给应用程序。为了更好地管理依赖关系,在这种情况下,我更倾向于使用 Gradle(或 Maven)将代理 JAR 文件包含在最终应用程序工件中。让我们看看如何操作。

打开您的目录服务项目(catalog-service)。然后在您的 build.gradle 文件中添加对 OpenTelemetry Java Agent 的依赖项。记得在添加新内容后刷新或重新导入 Gradle 依赖项。

列表 13.20 在目录服务中添加 OpenTelemetry Java Agent 的依赖项

ext {
  ...
  set('otelVersion', "1.17.0")                   ❶
}
dependencies {
  ...
  runtimeOnly "io.opentelemetry.javaagent: 
  ➥ opentelemetry-javaagent:${otelVersion}"     ❷
}

❶ OpenTelemetry 版本

❷ 通过字节码动态对 Java 代码进行仪器化的 OpenTelemetry 代理

除了对 Java 代码进行仪器化以捕获跟踪外,OpenTelemetry Java Agent 还与 SLF4J(及其实现)集成。它提供跟踪和范围标识符作为上下文信息,可以通过 SLF4J 提供的 MDC 抽象注入到日志消息中。这使得从日志消息导航到跟踪以及反之亦然变得极其简单,比单独查询遥测提供了更好的应用程序可见性。

让我们扩展 Spring Boot 默认的日志格式,并添加以下上下文信息:

  • 应用程序名称(来自我们为所有应用程序配置的 spring.application.name 属性的值)

  • 跟踪标识符(当启用时,由 OpenTelemetry 代理填充的 trace_id 字段的值)

  • 范围标识符(当启用时,由 OpenTelemetry 代理填充的 span_id 字段的值)

在您的目录服务项目中,打开 application.yml 文件,并在日志级别(由%5p 表示)旁边添加三个新的信息项,遵循 Logback 语法。这是 Spring Cloud Sleuth 使用的相同格式。

列表 13.21 在日志级别字段旁边添加上下文信息

logging:
  pattern:
    level: "%5p [${spring.application.name},%X{trace_id},%X{span_id}]"   ❶

❶ 在日志级别(%5p)旁边包含应用程序名称、跟踪 ID 和跨度 ID

接下来,打开一个终端窗口,导航到目录服务根目录,并运行./gradlew bootBuildImage 将应用程序打包为容器镜像。

最后一步是配置和启用 OpenTelemetry Java 代理。为了简单起见,我们将在容器中运行应用程序时启用 OpenTelemetry,并依赖环境变量来配置它。

我们需要三块配置才能成功启用跟踪:

  • 指示 JVM 加载 OpenTelemetry Java 代理。 我们可以通过 OpenJDK 支持的 JAVA_TOOL_OPTIONS 标准环境变量来实现,为 JVM 提供额外的配置。

  • 使用应用程序名称来标记和分类跟踪。 我们将使用 OpenTelemetry Java 代理支持的 OTEL_SERVICE_NAME 环境变量。

  • 定义分布式跟踪后端的 URL。 在我们的案例中,它是端口 4317 的 Tempo,可以通过 OpenTelemetry Java 代理支持的 OTEL_EXPORTER_OTLP_ENDPOINT 环境变量进行配置。默认情况下,跟踪通过 gRPC 发送。

前往您的 Polar 部署项目(polar-deployment),并打开 Docker Compose 文件(docker/docker-compose.yml)。然后添加必要的配置以支持目录服务的跟踪。

列表 13.22 为目录服务容器定义 OpenTelemetry

version: "3.8"
services:
  ...

  catalog-service:
    depends_on:
      - fluent-bit
      - polar-keycloak
      - polar-postgres
      - tempo                                                 ❶
    image: "catalog-service"
    container_name: "catalog-service"
    ports:
      - 9001:9001
      - 8001:8001
    environment:
      - JAVA_TOOL_OPTIONS=-javaagent:/workspace/BOOT-INF/lib/ 
      ➥ opentelemetry-javaagent-1.17.0.jar                   ❷
      - OTEL_SERVICE_NAME=catalog-service                     ❸
      - OTEL_EXPORTER_OTLP_ENDPOINT=http://tempo:4317         ❹
 - OTEL_METRICS_EXPORTER=none
    ...

❶ 确保在目录服务之前启动节拍(Tempo)

❷ 指示 JVM 从云原生构建包放置应用程序依赖项的路径运行 OpenTelemetry Java 代理

❸ 用于标记目录服务生成的跟踪的应用程序名称

❹ 支持 OpenTelemetry 协议(OTLP)的分布式跟踪后端 URL

最后,从同一文件夹中运行目录服务作为容器:

$ docker-compose up -d catalog-service

一旦应用程序启动并运行,发送一些请求以触发生成一些关于您的 HTTP 请求的日志和跟踪:

$ http :9001/books

然后检查容器中的日志(docker logs catalog-service)。您会看到每条日志消息现在都有一个新部分,包含应用程序名称,当可用时,还包括跟踪和跨度标识符:

[catalog-service,d9e61c8cf853fe7fdf953422c5ff567a,eef9e08caea9e32a] 

分布式跟踪帮助我们跟踪请求通过多个服务,因此我们需要另一个应用程序来测试它是否正确工作。继续对边缘服务进行相同的更改以支持 OpenTelemetry。然后根据您的 Docker Compose 文件运行应用程序作为容器:

$ docker-compose up -d edge-service

再次发送一些请求以触发生成一些关于您的 HTTP 请求的日志和跟踪。这次您应该通过网关进行:

$ http :9000/books

使用目录服务记录的跟踪 ID,我们可以检索(关联)处理 Edge Service 中启动的/books 端点的 HTTP 请求所涉及的所有步骤。能够从日志导航到跟踪(以及相反)对于深入了解分布式系统中处理请求的所有步骤非常有用。让我们看看它在 Grafana 堆栈中的工作方式。

打开浏览器窗口,访问 Grafana(http://localhost:3000),并使用 Docker Compose 中配置的凭据(用户/密码)登录。在探索页面,检查目录服务({container_name="/catalog-service")的日志,就像我们之前做的那样。接下来,点击最近的日志消息以获取更多详细信息。你会在与该日志消息关联的跟踪标识符旁边看到一个 Tempo 按钮。如果你点击它,Grafana 会使用 Tempo 的数据将你重定向到相关的跟踪,所有这些都在同一个视图中(图 13.9)。

13-09

图 13.9 在 Grafana 中,你可以使用日志(Loki)中的跟踪 ID 导航到跟踪(Tempo)。

当你完成检查日志和跟踪后,停止所有容器(docker-compose down)。在继续之前,请为 Polar Bookshop 系统中剩余的所有应用程序配置 OpenTelemetry。作为一个参考,你可以查看本书附带的源代码存储库(第十三章/13-end)。

到目前为止,我们已经处理了三种主要的遥测数据类型:日志、指标和跟踪。我们还启用了健康端点,以提供有关应用程序状态的其他信息。下一节将介绍您如何从应用程序中检索更多信息,并更好地了解其操作。

13.5 使用 Spring Boot Actuator 进行应用程序管理和监控

在前面的章节中,我向您展示了所有云原生应用程序为了实现更好的可观察性应该提供的核心遥测数据。本节的最后将专门介绍您可以从应用程序中检索的一些特定信息,以进一步增强您对其操作的推断。

Spring Boot Actuator 提供了许多功能,使您的应用程序准备好投入生产。您已经了解了健康和度量端点,但还有更多。表 13.1 列出了 Actuator 实现的一些最有用的管理和监控端点。本节将向您展示如何使用其中的一些。

表 13.1 Spring Boot Actuator 公开的一些最有用的管理和监控端点。

端点 描述
/beans 显示应用程序管理的所有 Spring bean 的列表
/configprops 显示所有使用@ConfigurationProperties 注解的 bean 的列表
/env 显示 Spring 环境可用的所有属性的列表
/flyway 列出 Flyway 运行的所有迁移及其状态
/health 显示有关应用程序健康状态的信息
/heapdump 返回堆转储文件
/info 显示任意应用程序信息
/loggers 显示应用程序中所有日志记录器的配置,并允许你修改它们
/metrics 返回应用程序的指标
/mappings 列出在 web 控制器中定义的所有路径
/prometheus 返回应用程序的指标,格式为 Prometheus 或 OpenMetrics
/sessions 列出由 Spring Session 管理的所有活动会话,并允许你删除它们
/threaddump 返回 JSON 格式的线程转储

13.5.1 在 Spring Boot 中监控 Flyway 迁移

在第五章和第八章中,你看到了如何使用 Flyway 迁移来版本控制数据库模式,并将其与 Spring Boot 集成,无论是在命令式还是响应式堆栈中。Flyway 将应用程序上运行的所有迁移的历史记录保存在数据库中的一个专用表中。提取此类信息并对其进行监控将非常方便,这样你就可以在任何迁移失败时收到警报。

Spring Boot Actuator 提供了一个专门的端点 (/actuator/flyway),用于显示 Flyway 运行的所有迁移信息,包括其状态、日期、类型和版本。正如你在前面的章节中学到的,你可以通过 management.endpoints.web.exposure.include 属性启用 Actuator 实现新的 HTTP 端点。让我们看看它是如何工作的。

注意:如果你使用 Liquibase 而不是 Flyway,Spring Boot Actuator 提供了一个 /actuator/liquibase 端点。

打开 Catalog Service 项目(catalog-service),进入 application.yml 文件,并配置 Flyway 端点以通过 Spring Boot Actuator 暴露。

列表 13.23 暴露 flyway Actuator 端点

management:
  endpoints:
    web:
      exposure:
        include: flyway, health, prometheus     ❶

❶ 将 flyway 添加到通过 HTTP 暴露的 Actuator 端点列表中

然后,以容器形式运行 Catalog Service 所需的后备服务。从你的 Docker Compose 文件中,执行以下命令:

$ docker-compose up -d polar-keycloak polar-postgres

接下来,运行 Catalog Service(./gradlew bootRun),并调用 Flyway 端点:

$ http :9001/actuator/flyway

结果是一个包含 Flyway 运行的所有迁移及其详细信息的 JSON 文件。以下片段显示了完整响应的摘录:

{
  "contexts": {
    "catalog-service": {
      "flywayBeans": {
        "flyway": {
          "migrations": [
            {
              "checksum": -567578088,                         ❶
              "description": "Initial schema",                ❷
              "executionTime": 66,
              "installedBy": "user",
              "installedOn": "2022-03-19T17:06:54Z",          ❸
              "installedRank": 1,
              "script": "V1__Initial_schema.sql",             ❹
              "state": "SUCCESS",                             ❺
              "type": "SQL",                                  ❻
              "version": "1"                                  ❼
            },
            ...
          ]
        }
      }
    }
  }
}

❶ 迁移脚本的校验和,用于确保文件未被更改

❷ 迁移的描述

❸ 迁移执行的时间

❹ 包含迁移代码的脚本名称

❺ 迁移执行的状态

❻ 迁移的类型(SQL 或 Java)

❼ 迁移版本(在脚本文件名中定义)

13.5.2 暴露应用程序信息

在 Spring Boot Actuator 实现的所有端点中,/actuator/info 是最独特的一个,因为它不返回任何数据。相反,定义你认为是有用的数据取决于你。

为端点贡献数据的一种方式是通过配置属性。例如,转到您的 Catalog Service 项目(catalog-service),打开 application.yml 文件,并添加以下属性以包含 Catalog Service 所属系统的名称。您还需要启用 info 端点通过 HTTP 暴露(类似于我们对其他端点所做的那样),并启用负责解析所有以 info. 前缀开头的属性的 env 贡献者。

列表 13.24 暴露和配置 info Actuator 端点

info: 
  system: Polar Bookshop                             ❶

management:
  endpoints:
    web:
      exposure:
        include: flyway, health, info, prometheus    ❷
  info: 
    env: 
      enabled: true                                  ❸

❶ 以“info.”前缀开始的任何属性都将由 info 端点返回。

❷ 将信息添加到要暴露在 HTTP 上的 Actuator 端点列表中

❸ 启用从“info.”属性获取的环境信息

您还可以包括 Gradle 或 Maven 自动生成的有关应用程序构建或最后 Git 提交的信息。让我们看看我们如何添加有关应用程序构建配置的详细信息。在您的 Catalog Service 项目中,转到 build.gradle 文件,并配置 springBoot 任务以生成将被解析到 BuildProperties 对象中的构建信息,并将其包含在 info 端点的结果中。

列表 13.25 配置 Spring Boot 以包含构建信息

springBoot {
  buildInfo()     ❶
}

❶ 将构建信息存储在由 BuildProperties 对象解析的 META-INF/build-info.properties 文件中。

让我们来测试一下。重新运行 Catalog Service(./gradlew bootRun)。然后调用 info 端点:

$ http :9001/actuator/info

结果将是一个包含构建信息和显式定义的 custom info .系统属性的 JSON 对象:

{
  "build": {
    "artifact": "catalog-service",
    "group": "com.polarbookshop",
    "name": "catalog-service",
    "time": "2021-08-06T12:56:25.035Z",
    "version": "0.0.1-SNAPSHOT"
  },
  "system": "Polar Bookshop"
}

您可以暴露有关正在使用的操作系统和 Java 版本的附加信息。这两个都可以通过配置属性启用。让我们更新 Catalog Service 项目的 application.yml 文件,如下所示。

列表 13.26 将 Java 和 OS 详细信息添加到 info Actuator 端点

management:
  ...
  info:
    env:
      enabled: true
    java: 
      enabled: true     ❶
    os: 
      enabled: true     ❷

❶ 启用 info 端点中的 Java 信息

❷ 启用 info 端点中的 OS 信息

让我们来测试一下。重新运行 Catalog Service(./gradlew bootRun)。然后调用 info 端点:

$ http :9001/actuator/info

结果现在包括有关正在使用的 Java 版本和操作系统的附加信息,这取决于您在哪里运行应用程序:

{
  ...
  "java": {
    "version": "17.0.3",
    "vendor": {
      "name": "Eclipse Adoptium",
      "version": "Temurin-17.0.3+7"
    },
    "runtime": {
      "name": "OpenJDK Runtime Environment",
      "version": "17.0.3+7"
    },
    "jvm": {
      "name": "OpenJDK 64-Bit Server VM",
      "vendor": "Eclipse Adoptium",
      "version": "17.0.3+7"
    }
  },
  "os": {
    "name": "Mac OS X",
    "version": "12.3.1",
    "arch": "aarch64"
  }
}

13.5.3 生成和分析堆转储

在 Java 应用程序中调试时最令人烦恼的错误可能首先是内存泄漏。监控工具应在检测到内存泄漏模式时提醒您,通常可以通过 JVM 堆使用量随时间持续增加来推断。如果您事先没有捕捉到内存泄漏,应用程序将抛出可怕的 OutOfMemoryError 错误并崩溃。

一旦怀疑应用程序可能存在内存泄漏,就必须找出哪些对象被保留在内存中,并阻止垃圾回收。有不同方法可以找到有问题的对象。例如,您可以为运行中的应用程序启用 Java Flight Recorder 或将 jProfiler 之类的分析器附加到应用程序上。另一种方法是捕获 JVM 堆内存中所有 Java 对象的快照(一个heap dump),并使用专用工具分析它,以找到内存泄漏的根本原因。

Spring Boot Actuator 提供了一个方便的端点(/actuator/heapdump),您可以通过它来生成 heap dump。让我们看看它是如何工作的。转到您的目录服务项目(catalog-service),打开 application.yml 文件,并配置 Actuator 以公开 heapdump 端点。

列表 13.27 公开 heapdump Actuator 端点

management:
  endpoints:
    web:
      exposure:
        include: flyway, health, heapdump, info, prometheus     ❶

❶ 将 heapdump 添加到要通过 HTTP 公开的 Actuator 端点列表中

接下来,构建并运行目录服务(./gradlew bootRun)。最后,调用 heapdump 端点:

$ http --download :9001/actuator/heapdump

命令将在当前目录中保存 heapdump.bin 文件。然后您可以在像 VisualVM (visualvm.github.io) 或 JDK Mission Control (adoptopenjdk.net/jmc.html) 这样的专用工具中打开它,进行 heap 分析。图 13.10 显示了 VisualVM 中 heap 分析的一个示例。

13-10

图 13.10 VisualVM 提供了分析 Java 应用程序 heap dump 的工具。

最后,停止应用程序进程(Ctrl-C)和所有容器(docker-compose down)。

我鼓励您查看 Spring Boot Actuator 官方文档,尝试所有支持的端点,并使 Polar Bookshop 系统的应用更具可观察性。为了获得灵感,请参考书中附带的源代码存储库,以查看我在每个应用程序上启用了哪些端点(第十三章/ 13-end)。它们是强大的工具,您可能会在现实世界的生产环境中运行的应用程序中找到它们非常有帮助和方便。

摘要

  • 可观察性是云原生应用程序的一个属性,它衡量了我们从应用程序的输出中推断其内部状态的能力。

  • 监控是关于控制已知故障状态。可观察性超越了这一点,并允许我们询问关于未知的问题。

  • 日志(或事件日志)是软件应用程序中随时间发生的事件的离散记录。

  • Spring Boot 支持通过 SLF4J 进行日志记录,它为最常见的日志库提供了一个门面。

  • 默认情况下,日志通过标准输出打印,这是 15-Factor 方法推荐的。

  • 使用 Grafana 可观察性堆栈,Fluent Bit 收集所有应用程序产生的日志,并将它们转发到 Loki,Loki 存储它们并使它们可搜索。然后您可以使用 Grafana 导航日志。

  • 应用程序应公开健康端点以检查其状态。

  • Spring Boot Actuator 提供了一个整体健康端点,显示应用程序的状态以及它可能使用的所有组件或服务。它还提供了专门的端点,供 Kubernetes 用作存活性和就绪性探测。

  • 当存活性探测失败时,这意味着应用程序已进入一个无法恢复的错误状态,因此 Kubernetes 将尝试重启它。

  • 当就绪性探测失败时,应用程序尚未准备好处理请求,因此 Kubernetes 将停止指向该实例的所有流量。

  • 指标是关于应用程序的数值数据,在固定的时间间隔内进行测量。

  • Spring Boot Actuator 利用 Micrometer 外观来对 Java 代码进行仪器化,生成指标并通过专用端点公开它们。

  • 当 Prometheus 客户端在类路径上时,Spring Boot 可以以 Prometheus 或 OpenMetrics 格式公开指标。

  • 使用 Grafana 可观察性堆栈,Prometheus 从所有应用程序中聚合和存储指标。然后您可以使用 Grafana 查询指标、设计仪表板并设置警报。

  • 分布式跟踪是一种跟踪请求在分布式系统中流动的技术,使我们能够定位分布式系统中错误发生的位置,并解决性能问题。

  • 跟踪由跟踪 ID 特征,并由多个跨度组成,代表事务中的步骤。

  • OpenTelemetry 项目包括生成最常见 Java 库跟踪和跨度的 API 和仪器。

  • OpenTelemetry Java Agent 是由项目提供的一个 JAR 艺术品,可以附加到任何 Java 应用程序上。它动态地注入必要的字节码,以捕获所有这些库的跟踪和跨度,并以不同的格式导出,而无需显式更改您的 Java 源代码。

  • 使用 Grafana 可观察性堆栈,Tempo 从所有应用程序中聚合和存储指标。然后您可以使用 Grafana 查询跟踪并将它们与日志相关联。

  • Spring Boot Actuator 提供了管理和监控端点,以满足您使应用程序生产就绪可能需要的任何要求。

14 配置和秘密管理

本章涵盖

  • 在 Kubernetes 上配置应用程序

  • 在 Kubernetes 中使用 ConfigMaps 和 Secrets

  • 使用 Kustomize 管理部署和配置

将应用程序发布到生产环境涉及两个重要方面:可执行工件及其配置。可执行工件可以是 JAR 文件或容器镜像。前几章涵盖了构建松散耦合、弹性、可扩展、安全且可观察的应用程序的原则、模式和工具。您看到了如何将应用程序打包为可执行 JAR 工件或容器镜像。我还指导您实现了部署管道的提交阶段,这最终产生了一个发布候选。

准备生产环境的其他方面是配置。第四章介绍了云原生应用程序外部化配置的重要性,并涵盖了配置 Spring Boot 应用程序的几种技术。本章将继续这一讨论,为将整个云原生系统部署到 Kubernetes 生产环境做准备。

首先,我将描述一些在 Kubernetes 上配置 Spring Boot 应用程序的选择,并描述在生产环境中使用 Spring Cloud Config 缺少的部分。然后,您将学习如何使用 ConfigMaps 和 Secrets,这是在 Kubernetes 上处理配置的原生机制。作为讨论的一部分,您将了解 Spring Cloud Kubernetes 及其主要用例。最后,我将扩展 Kubernetes 上生产工作负载的配置和秘密管理,您将学习如何使用 Kustomize 来实现这一点。

注意:本章示例的源代码可在 Chapter14/14-begin 和 Chapter14/14-end 文件夹中找到,包含项目的初始状态和最终状态 (github.com/ThomasVitale/cloud-native-spring-in-action)。

14.1 在 Kubernetes 上配置应用程序

根据 15-Factor 方法论,配置是部署环境之间发生变化的一切。我们从第四章开始处理配置,并自那时起使用了不同的配置策略:

  • 与应用程序打包的属性文件—这些文件可以作为应用程序支持的配置数据的规范,它们对于定义合理的默认值非常有用,主要面向开发环境。

  • 环境变量—任何操作系统都支持这些变量,因此它们非常适合便携性。它们对于根据应用程序部署的基础设施或平台定义配置数据非常有用,例如活动配置文件、主机名、服务名称和端口号。我们在 Docker 和 Kubernetes 中使用了它们。

  • 配置服务——这提供了配置数据持久化、审计和问责制。它对于定义特定于应用程序的配置数据很有用,例如功能标志、线程池、连接池、超时和第三方服务的 URL。我们采用了 Spring Cloud Config 的这种策略。

这三种策略足够通用,我们可以使用它们来配置任何云环境和服务模型(CaaS、PaaS、FaaS)中的应用程序。当涉及到 Kubernetes 时,平台还提供了一个额外的配置策略,这是平台原生提供的:ConfigMaps 和 Secrets。

这些是非常方便的方式来定义依赖于应用程序部署的基础设施和平台上的配置数据:服务名称(由 Kubernetes Service 对象定义)、访问平台上运行的其他服务的凭据和证书、优雅关闭、日志记录和监控。您可以使用 ConfigMaps 和 Secrets 来补充或完全替代配置服务所做的工作。您选择哪种取决于上下文。在任何情况下,Spring Boot 都为所有这些选项提供了原生支持。

对于 Polar Bookshop 系统,我们将使用 ConfigMaps 和 Secrets 而不是 Config Service 来配置 Kubernetes 环境中的应用程序。尽管如此,我们迄今为止在 Config Service 上所做的工作将使得将其包含在 Polar Bookshop 在 Kubernetes 上的整体部署中变得简单直接。在本节中,我将分享一些使 Config Service 适用于生产的最终考虑因素,以防您想扩展示例并将其包含在生产环境中的最终部署中。

14.1.1 使用 Spring Security 保护配置服务器

在前面的章节中,我们花费了大量时间确保 Polar Bookshop 中的 Spring Boot 应用程序具有高安全级别。然而,Config Service 并不是其中之一,它仍然没有受到保护。即使它是一个配置服务器,本质上它仍然是一个 Spring Boot 应用程序。因此,我们可以使用 Spring Security 提供的任何策略来保护它。

Config Service 通过 HTTP 被架构中的其他 Spring Boot 应用程序访问。在生产中使用它之前,我们必须确保只有经过身份验证和授权的各方才能检索配置数据。一个选择是使用 OAuth2 客户端凭据流来保护 Config Service 与应用程序之间的交互,基于访问令牌。这是一个专门用于保护服务间交互的 OAuth2 流程。

假设应用将通过 HTTPS 进行通信,HTTP Basic认证策略将是另一个可行的选项。当使用此策略时,可以通过 Spring Cloud Config Client 公开的属性配置用户名和密码:spring.cloud.config.username 和 spring.cloud.config.password。有关更多信息,请参阅 Spring Security(spring.io/projects/spring-security)和 Spring Cloud Config(spring.io/projects/spring-cloud-config)的官方文档。

14.1.2 使用 Spring Cloud Bus 在运行时刷新配置

假设你已经在 Kubernetes 这样的云环境中部署了 Spring Boot 应用。在启动阶段,每个应用都从外部配置服务器加载了配置,但在某个时候你决定在配置仓库中做出更改。你如何让应用意识到配置更改并重新加载它?

在第四章中,你了解到可以通过向 Spring Boot Actuator 提供的/actuator/refresh 端点发送 POST 请求来触发配置刷新操作。对该端点的请求会在应用程序上下文中引发 RefreshScopeRefreshedEvent 事件。所有标记有@ConfigurationProperties 或@RefreshScope 的 bean 都会监听该事件,并在事件发生时重新加载。

你在目录服务上尝试了刷新机制,由于它只是一个应用,并且没有复制,所以它运行得很好。那么在生产环境中呢?考虑到云原生应用的分布和规模,向每个应用的每个实例发送 HTTP 请求可能是个问题。自动化是任何云原生策略的关键部分,因此我们需要一种方法,一次性触发所有应用的 RefreshScopeRefreshedEvent 事件。有几个可行的解决方案。使用 Spring Cloud Bus 就是其中之一。

Spring Cloud Bus(spring.io/projects/spring-cloud-bus)为所有与其链接的应用实例之间广播事件提供了一个便捷的通信通道。它为 AMQP 代理(如 RabbitMQ)和 Kafka 提供了实现,依赖于你在第十章中了解到的 Spring Cloud Stream 项目。

任何配置更改都包括向配置仓库推送一个提交。当仓库中推送新的提交时,设置一些自动化来使配置服务刷新配置将非常方便,这样可以完全消除手动干预的需要。Spring Cloud Config 提供了一个 Monitor 库,使其成为可能。它公开了一个/monitor 端点,可以触发配置服务中的配置更改事件,然后将其通过总线发送到所有监听的应用程序。它还接受描述哪些文件已更改的参数,并支持从最常用的代码仓库提供商(如 GitHub、GitLab 和 Bitbucket)接收推送通知。您可以在这些服务中设置 webhook,在每次向配置仓库推送新内容后自动向配置服务发送 POST 请求。

Spring Cloud Bus 解决了向所有连接的应用程序广播配置更改事件的问题。通过 Spring Cloud Config Monitor,我们可以进一步自动化刷新,并在将配置更改推送到配置服务器背后的仓库后执行。这种解决方案在图 14.1 中得到了说明。

14-01

图 14.1 在配置服务接收到每个配置仓库变更的推送通知后,通过 Spring Cloud Bus 广播配置更改。

注意:即使您使用其他选项,如 Consul(与 Spring Cloud Consul)、Azure Key Vault(Spring Cloud Azure)、AWS Parameter Store 或 AWS Secrets Manager(Spring Cloud AWS),或 Google Cloud Secret Manager(Spring Cloud GCP),您也可以依赖 Spring Cloud Bus 来广播配置更改。与 Spring Cloud Config 不同,它们没有内置的推送通知功能,因此您需要手动触发配置更改或实现监控功能。

14.1.3 使用 Spring Cloud Config 管理密钥

对于任何软件系统来说,管理密钥是一项关键任务,如果出错则非常危险。到目前为止,我们已经在属性文件或环境变量中包含了密码,但在两种情况下它们都是未加密的。未加密的一个后果是我们无法安全地对其进行版本控制。我们希望将所有内容都置于版本控制之下,并使用 Git 仓库作为单一的真实来源,这是我在第十五章中将要介绍的 GitOps 策略背后的原则之一。

Spring Cloud Config 项目配备了处理云原生应用程序配置(包括密钥管理)的功能。主要目标是包括密钥在属性文件中,并将它们置于版本控制之下,这只有在它们被加密的情况下才能完成。

Spring Cloud Config 服务器支持加密和解密,并公开了两个专用端点:/encrypt 和/decrypt。加密可以基于对称密钥或非对称密钥对。

当使用对称密钥时,Spring Cloud Config 服务器会在本地解密密钥并将其解密后发送给客户端应用程序。在生产环境中,所有应用程序之间的通信都将通过 HTTPS 进行,因此即使配置属性未加密,从配置服务发送的响应也将被加密,这使得这种方法对于实际使用足够安全。

您还可以选择发送加密的属性值,并让应用程序自行解密它们,但这将需要您为所有应用程序配置对称密钥。您还应考虑解密操作并不便宜。

Spring Cloud Config 还支持通过非对称密钥进行加密和解密。此选项比对称密钥提供更强大的安全性,但也会由于密钥管理任务而增加复杂性和维护成本。在这种情况下,您可能希望考虑依赖专门的密钥管理解决方案。例如,您可以使用云提供商提供的一种解决方案,并依赖 Spring Cloud 实现的 Spring Boot 集成,如 Azure Key Vault(Spring Cloud Azure)、AWS Parameter Store 或 AWS Secrets Manager(Spring Cloud AWS),或 Google Cloud Secret Manager(Spring Cloud GCP)。

如果您更喜欢开源解决方案,HashiCorp Vault (www.vaultproject.io) 可能适合您。这是一个您可以使用它来管理所有凭证、令牌和证书的工具,无论是通过 CLI 还是方便的 GUI。您可以直接使用 Spring Vault 项目将其集成到 Spring Boot 应用程序中,或者将其添加为 Spring Cloud Config 服务器的一个额外后端。

有关 Spring 中密钥管理的更多信息,请参阅 Spring Vault 的官方文档(spring.io/projects/spring-vault)和 Spring Cloud Config 的官方文档(spring.io/projects/spring-cloud-config)。

14.1.4 禁用 Spring Cloud Config

下一个部分将介绍一种不同的配置 Spring Boot 应用程序的方法,该方法基于 Kubernetes 通过 ConfigMaps 和 Secrets 提供的本地功能。这就是我们在生产中将要使用的方法。

即使我们在本书的其余部分不再使用配置服务,我们也会保留到目前为止所做的所有工作。然而,为了简化操作,我们将默认关闭 Spring Cloud Config 客户端集成。

打开您的目录服务项目(catalog-service),并更新 application.yml 文件以停止从配置服务导入配置数据并禁用 Spring Cloud Config 客户端集成。其他所有内容都将保持不变。无论何时您想再次使用 Spring Cloud Config,都可以轻松启用它(例如,在 Docker 上运行应用程序时)。

列表 14.1 在目录服务中禁用 Spring Cloud Config

spring:
  config:
    import: ""                     ❶
  cloud:
    config:
      enabled: false               ❷
      uri: http://localhost:8888
      request-connect-timeout: 5000
      request-read-timeout: 5000
      fail-fast: false
      retry:
        max-attempts: 6
        initial-interval: 1000
        max-interval: 2000
        multiplier: 1.1

❶ 停止从配置服务导入配置数据

❷ 禁用 Spring Cloud Config Client 集成

在下一节中,您将使用 ConfigMaps 和 Secrets 来配置 Spring Boot 应用程序,而不是使用 Config Service。

14.2 在 Kubernetes 中使用 ConfigMaps 和 Secrets

15-Factor 方法建议始终将代码、配置和凭证保持分离。Kubernetes 完全接受这一原则,并定义了两个 API 来独立处理配置和凭证:ConfigMaps 和 Secrets。本节将介绍这种新的配置策略,这是 Kubernetes 本地提供的。

Spring Boot 为 ConfigMaps 和 Secrets 提供了原生和灵活的支持。我将向您展示如何使用 ConfigMaps 以及它们与环境变量的关系,这些仍然是 Kubernetes 中的一个有效配置选项。您将看到 Secrets 并非真正保密,您将学习如何使它们真正保密。最后,我将介绍一些处理配置更改并将其传播到应用程序的选项。

在继续前进之前,让我们设定场景并启动一个本地 Kubernetes 集群。转到您的 Polar Deployment 项目(polar-deployment),导航到 kubernetes/platform/development 文件夹,并运行以下命令以启动 minikube 集群并部署 Polar 书店使用的后端服务:

$ ./create-cluster.sh

注意:如果您没有跟随前几章中实现的示例,您可以参考书籍附带的存储库 (github.com/ThomasVitale/cloud-native-spring-in-action),并以第 14/14-begin 中的项目作为起点。

命令将需要几分钟才能完成。完成后,您可以使用以下命令验证所有后端服务都已准备好并可用:

$ kubectl get deploy

NAME             READY   UP-TO-DATE   AVAILABLE   AGE
polar-keycloak   1/1     1            1           3m94s
polar-postgres   1/1     1            1           3m94s
polar-rabbitmq   1/1     1            1           3m94s
polar-redis      1/1     1            1           3m94s
polar-ui         1/1     1            1           3m94s

让我们先介绍 ConfigMaps。

14.2.1 使用 ConfigMaps 配置 Spring Boot

在第七章,我们使用环境变量将硬编码的配置传递给在 Kubernetes 中运行的容器,但它们缺乏可维护性和结构。ConfigMaps 允许您以结构化、可维护的方式存储配置数据。它们可以与 Kubernetes 部署清单的其他部分一起进行版本控制,并具有专用配置存储库的相同良好属性,包括数据持久性、审计和问责制。

ConfigMap 是一个“用于在键值对中存储非机密数据的 API 对象。Pod 可以将 ConfigMap 作为环境变量、命令行参数或作为卷中的配置文件来消费” (kubernetes.io/docs/concepts/configuration/configmap)。

您可以从一个字面值键值对字符串、一个文件(例如,.properties 或 .yml)或甚至一个二进制对象开始构建 ConfigMap。当与 Spring Boot 应用程序一起工作时,构建 ConfigMap 最直接的方法是从属性文件开始。

让我们看看一个例子。在前几章中,我们通过环境变量配置了目录服务。为了更好的可维护性和结构,让我们将这些值存储在 ConfigMap 中。

打开目录服务项目(catalog-service),在 k8s 文件夹中创建一个新的 configmap.yml 文件。我们将使用它应用以下配置,这将覆盖应用程序.yml 文件中包含的默认值:

  • 配置自定义问候语。

  • 配置 PostgreSQL 数据源的 URL。

  • 配置 Keycloak 的 URL。

列表 14.2 定义 ConfigMap 以配置目录服务

apiVersion: v1                  ❶
kind: ConfigMap                 ❷
metadata:
  name: catalog-config          ❸
  labels:                       ❹
    app: catalog-service
data:                           ❺
  application.yml: |            ❻
    polar:
      greeting: Welcome to the book catalog from Kubernetes!
    spring:
      datasource:
        url: jdbc:postgresql://polar-postgres/polardb_catalog
      security:
        oauth2:
          resourceserver:
            jwt:
              issuer-uri: http://polar-keycloak/realms/PolarBookshop

❶ ConfigMap 对象的 API 版本

❷ 要创建的对象类型

❸ ConfigMap 的名称

❹ 附属于 ConfigMap 的一组标签

❺ 包含配置数据的部分

❻ 一个键/值对,其中键是 YAML 配置文件的名称,值是它的内容

与我们迄今为止使用的其他 Kubernetes 对象类似,ConfigMap 的配置清单可以通过 Kubernetes CLI 应用到集群中。打开一个终端窗口,导航到您的目录服务项目(catalog-service),并运行以下命令:

$ kubectl apply -f k8s/configmap.yml

您可以使用以下命令验证 ConfigMap 是否已正确创建:

$ kubectl get cm -l app=catalog-service

NAME             DATA   AGE
catalog-config   1      7s

存储在 ConfigMap 中的值可以通过几种不同的方式用于配置运行在容器中的容器:

  • 使用 ConfigMap 作为配置数据源,将命令行参数传递给容器。

  • 使用 ConfigMap 作为配置数据源,为容器填充环境变量。

  • 在容器中挂载 ConfigMap 作为卷。

正如您在第四章中学到的,并从那时起实践的那样,Spring Boot 支持多种外部化配置方式,包括通过命令行参数和环境变量。即使配置数据存储在 ConfigMap 中,将配置数据作为命令行参数或环境变量传递给容器也有其缺点。例如,每次您向 ConfigMap 添加属性时,都必须更新 Deployment 清单。当 ConfigMap 发生变化时,Pod 并未收到通知,必须重新创建以读取新的配置。这两个问题都通过将 ConfigMap 挂载为卷来解决。

当 ConfigMap 作为卷挂载到容器时,会产生两种可能的结果(图 14.2):

  • 如果 ConfigMap 包含一个 内嵌属性文件,将其作为卷挂载会导致属性文件在挂载路径中创建。Spring Boot 自动查找并包含位于与应用程序可执行文件相同的根目录或子目录中的 /config 文件夹中的任何属性文件,因此这是挂载 ConfigMap 的完美路径。您还可以通过 spring.config.additional-location= 配置属性指定其他要搜索属性文件的位置。

  • 如果 ConfigMap 包含 键/值对,将其挂载为卷会在挂载路径中创建一个 配置树。对于每个键/值对,会创建一个文件,文件名与键相同,并包含相应的值。Spring Boot 支持从配置树中读取配置属性。您可以通过指定 spring.config.import=configtree: 属性来指定配置树应该从哪里加载。

14-02

图 14.2 将 ConfigMap 挂载为卷后,可以被 Spring Boot 作为属性文件或配置树使用。

当配置 Spring Boot 应用程序时,第一个选项是最方便的,因为它使用与应用程序内部默认配置相同的属性文件格式。让我们看看我们如何将之前创建的 ConfigMap 挂载到 Catalog 服务容器中。

打开 Catalog 服务项目(catalog-service),并转到 k8s 文件夹中的 deployment.yml 文件。我们需要进行三项更改:

  • 删除我们在 ConfigMap 中声明的值的环境变量。

  • 声明由 catalog-config ConfigMap 生成的卷。

  • 为 catalog-service 容器指定一个卷挂载,以便从 /workspace/config 加载 ConfigMap 作为 application.yml 文件。/workspace 文件夹是由 Cloud Native Buildpacks 创建并用于托管应用程序可执行文件的,因此 Spring Boot 将自动在相同路径下查找 /config 文件夹并加载其中包含的任何属性文件。无需配置其他位置。

列表 14.3 将 ConfigMap 作为卷挂载到应用程序容器

apiVersion: apps/v1
kind: Deployment
metadata:
  name: catalog-service
  labels:
    app: catalog-service
spec:
  ...
  template:
    ...
    spec:
      containers:
        - name: catalog-service
          image: catalog-service
          imagePullPolicy: IfNotPresent
          ...
          env:                                    ❶
            - name: BPL_JVM_THREAD_COUNT
              value: "50"
            - name: SPRING_PROFILES_ACTIVE
              value: testdata
          ...
          volumeMounts:                           ❷
            - name: catalog-config-volume 
              mountPath: /workspace/config        ❸
      volumes:                                    ❹
        - name: catalog-config-volume             ❺
          configMap:                              ❻
            name: catalog-config 

❶ JVM 线程和 Spring 配置文件仍然通过环境变量进行配置。

❷ 在容器中将 ConfigMap 挂载为卷

❸ Spring Boot 将自动查找并包含此文件夹中的属性文件。

❹ 定义 Pod 的卷

❺ 卷的名称

❻ 用于创建卷的 ConfigMap

我们之前已将 ConfigMap 应用到集群中。让我们对 Deployment 和 Service 清单做同样的操作,以便我们可以验证 Catalog 服务是否正确地从 ConfigMap 中读取配置数据。

首先,我们必须将应用程序打包成容器镜像并将其加载到集群中。打开一个终端窗口,导航到 Catalog 服务项目(catalog-service)的根文件夹,并运行以下命令:

$ ./gradlew bootBuildImage
$ minikube image load catalog-service --profile polar

现在我们已经准备好通过应用 Deployment 和 Service 清单在本地集群中部署应用程序:

$ kubectl apply -f k8s/deployment.yml -f k8s/service.yml

您可以使用此命令验证 Catalog 服务是否可用并准备好接受请求:

$ kubectl get deploy -l app=catalog-service

NAME              READY   UP-TO-DATE   AVAILABLE   AGE
catalog-service   1/1     1            1           21s

在内部,Kubernetes 使用我们在上一章配置的存活性和就绪性探针来推断应用程序的健康状态。

接下来,通过运行以下命令将您本地机器的流量转发到 Kubernetes 集群:

$ kubectl port-forward service/catalog-service 9001:80
Forwarding from 127.0.0.1:9001 -> 9001
Forwarding from [::1]:9001 -> 9001

注意:由 kubectl port-forward 命令启动的过程将一直运行,直到您使用 Ctrl-C 明确停止它。

现在,你可以从你的本地机器上通过端口 9001 调用目录服务,请求将被转发到 Kubernetes 集群内部的 Service 对象。打开一个新的终端窗口,调用应用程序公开的根端点以验证在 ConfigMap 中指定的 polar.greeting 值是否被使用而不是默认值:

$ http :9001/
Welcome to the book catalog from Kubernetes!

还可以尝试从目录中检索书籍以验证在 ConfigMap 中指定的 PostgreSQL URL 是否正确使用:

$ http :9001/books

当你完成应用程序的测试后,停止端口转发进程(Ctrl-C)并删除迄今为止创建的 Kubernetes 对象。打开一个终端窗口,导航到你的目录服务项目(catalog-service),并运行以下命令,但保持集群运行,因为我们很快还会使用它:

$ kubectl delete -f k8s

ConfigMaps 对于向在 Kubernetes 上运行的应用程序提供配置数据来说很方便。但如果我们不得不传递敏感数据怎么办?在下一节中,你将看到如何在 Kubernetes 中使用 Secrets。

14.2.2 使用 Secrets(或不是)存储敏感信息

配置应用程序最关键的部分是管理像密码、证书、令牌和密钥这样的机密信息。Kubernetes 提供了一个 Secret 对象来存储此类数据并将其传递给容器。

Secret 是一个 API 对象,用于存储和管理敏感信息,例如密码、OAuth 令牌和 ssh 密钥。Pod 可以作为环境变量或卷中的配置文件来消费 Secrets (kubernetes.io/docs/concepts/configuration/secret)。

使这个对象成为“机密”的是管理它的过程。单独来看,Secrets 就像 ConfigMaps 一样。唯一的区别是 Secret 中的数据通常是 Base64 编码的,这是一个为了支持二进制文件所做的技术选择。一个常见的错误是认为 Base64 是一种加密。如果你只记得关于 Secrets 的一件事,让它成为以下内容:Secrets 并不是秘密!

我们一直在使用的配置,用于在本地 Kubernetes 集群上运行极地书店,依赖于开发中使用的相同默认凭据,所以我们暂时不需要 Secrets。我们将在下一章中开始使用它们,当我们在生产中部署应用程序时。现在,我想向你展示如何创建 Secrets。然后我会介绍一些你可以确保它们得到充分保护的选项。

创建 Secret 的一种方式是使用 Kubernetes CLI 以命令式方法。打开一个终端窗口,为一些虚构的测试凭据(用户/密码)生成一个 test-credentials Secret 对象。

$ kubectl create secret generic \             ❶
    test-credentials \                        ❷
    --from-literal=test.username=user \       ❸
    --from-literal=test.password=password     ❹

❶ 创建一个带有 Base64 编码值的通用 Secret

❷ Secret 的名称

❸ 为测试用户名添加一个机密值

❹ 为测试密码添加一个机密值

我们可以使用以下命令验证 Secret 是否已成功创建:

$ kubectl get secret test-credentials

NAME               TYPE     DATA   AGE
test-credentials   Opaque   2      73s

我们还可以使用以下命令检索 Secret 的内部表示,以熟悉的 YAML 格式:

$ kubectl get secret test-credentials -o yaml

apiVersion: v1                 ❶
kind: Secret                   ❷
metadata:
  name: test-credentials       ❸
type: Opaque
data:                          ❹
  test.username: dXNlcg==
  test.password: cGFzc3dvcmQ=

❶ Secret 对象的 API 版本

❷ 需要创建的对象类型

❸ Secret 的名称

❹ 包含 Base64 编码值的秘密数据部分

注意,我已经重新排列了前面的 YAML 以提高其可读性,并省略了与我们的讨论无关的额外字段。

我想重复一遍:Secrets 并非秘密!我可以使用简单的命令解码存储在 test-credentials Secret 中的值:

$ echo 'cGFzc3dvcmQ=' | base64 --decode
password

与 ConfigMaps 类似,Secrets 可以作为环境变量传递给容器,或者通过卷挂载。在后一种情况下,您可以将它们挂载为属性文件或配置树。例如,test-credentials Secret 将作为配置树挂载,因为它由键/值对组成,而不是一个文件。

由于 Secrets 没有加密,我们无法将其包含在版本控制系统之中。确保 Secrets 获得充分保护的责任在于平台工程师。例如,Kubernetes 可以配置为以加密的形式将其 Secrets 存储在其内部的 etcd 存储中。这将有助于确保静态安全,但它并不能解决在版本控制系统中管理它们的问题。

Bitnami 推出了一个名为 Sealed Secrets 的项目 (github.com/bitnami-labs/sealed-secrets),旨在加密 Secrets 并将其置于版本控制之下。首先,您将生成一个加密的 SealedSecret 对象,从字面值开始,类似于我们为普通 Secret 所做的操作。然后,您将将其包含在您的仓库中,并安全地将其置于版本控制之下。当 SealedSecret 清单应用于 Kubernetes 集群时,Sealed Secrets 控制器会解密其内容,并生成一个标准 Secret 对象,该对象可以在 Pod 内部使用。

如果您的 Secrets 存储在专门的后端,如 HashiCorp Vault 或 Azure Key Vault,怎么办?在这种情况下,您可以使用像 External Secrets (github.com/external-secrets/kubernetes-external-secrets) 这样的项目。正如其名称所暗示的,该项目允许您从外部源生成一个 Secret。ExternalSecret 对象可以安全地存储在您的仓库中,并置于版本控制之下。当 ExternalSecret 清单应用于 Kubernetes 集群时,External Secrets 控制器会从配置的外部源获取值,并生成一个标准 Secret 对象,该对象可以在 Pod 内部使用。

注意:如果你对如何安全地管理 Kubernetes Secrets 感兴趣,可以查看 Billy Yuen、Alexander Matyushentsev、Todd Ekenstam 和 Jesse Suen 所著的 GitOps 和 Kubernetes 的第七章(Manning,2021)以及 Alex Soto Bueno 和 Andrew Block 所著的 Kubernetes Secrets Management(Manning,2022)。在此处我不会提供更多信息,因为这通常是一项平台团队的任务,而不是开发者的任务。

当我们开始使用 ConfigMaps 和 Secrets 时,我们必须决定使用哪种策略来更新配置数据以及如何使应用程序使用新值。这是下一节的主题。

14.2.3 在运行时使用 Spring Cloud Kubernetes 刷新配置

当使用外部配置服务时,你可能需要一个机制在配置更改时重新加载应用程序。例如,当使用 Spring Cloud Config 时,我们可以使用 Spring Cloud Bus 实现这样的机制。

在 Kubernetes 中,我们需要不同的方法。当你更新 ConfigMap 或 Secrets 时,Kubernetes 会负责在它们作为卷挂载时为容器提供新版本。如果你使用环境变量,它们不会被新值替换。这就是我们通常更喜欢卷解决方案的原因。

当 ConfigMaps 或 Secrets 作为卷挂载时,它们被提供给 Pod,但具体应用程序负责刷新配置。默认情况下,Spring Boot 应用程序仅在启动时读取配置数据。当配置通过 ConfigMaps 和 Secrets 提供时,有三种主要选项用于刷新配置:

  • 滚动重启—更改 ConfigMap 或 Secrets 后,可以跟随所有受影响的 Pods 的滚动重启,使应用程序重新加载所有配置数据。使用此选项,Kubernetes Pods 将保持不可变。

  • Spring Cloud Kubernetes 配置监视器—Spring Cloud Kubernetes 提供了一个名为配置监视器的 Kubernetes 控制器,该控制器监视作为卷挂载到 Spring Boot 应用程序的 ConfigMaps 和 Secrets。利用 Spring Boot Actuator 的 /actuator/refresh 端点或 Spring Cloud Bus,当任何 ConfigMaps 或 Secrets 被更新时,配置监视器将触发受影响应用程序的配置刷新。

  • Spring Cloud Kubernetes 配置服务器—Spring Cloud Kubernetes 提供了一个配置服务器,支持将 ConfigMaps 和 Secrets 作为 Spring Cloud Config 的配置数据源选项之一。你可以使用这样的服务器从 Git 仓库和 Kubernetes 对象中加载配置,并且可以使用相同的配置刷新机制来处理两者。

对于 Polar Bookshop,我们将使用第一种选项,并依赖 Kustomize 在对 ConfigMap 或 Secret 应用新更改时触发应用的重启。我将在本章的下一节中进一步描述该策略。在这里,我们将关注 Spring Cloud Kubernetes 和其子项目提供的功能。

Spring Cloud Kubernetes (spring.io/projects/spring-cloud-kubernetes) 是一个令人兴奋的项目,它提供了 Spring Boot 与 Kubernetes API 的集成。它的原始目标是使从基于 Spring Cloud 的微服务架构迁移到 Kubernetes 更加容易。它为用于服务发现和负载均衡的标准 Spring Cloud 接口提供了实现,以便与 Kubernetes 集成,并增加了从 ConfigMaps 和 Secrets 加载配置的支持。

如果你在一个绿色地带项目中工作,你不需要 Spring Cloud Kubernetes。Kubernetes 本地提供服务发现和负载均衡,正如你在第七章中所体验的那样。此外,Spring Boot 本地支持通过 ConfigMaps 和 Secrets 进行配置,因此在这种情况下也不需要 Spring Cloud Kubernetes。

当将一个棕色地带项目迁移到 Kubernetes 时,如果它使用像 Spring Cloud Netflix Eureka 这样的库进行服务发现,或者使用 Spring Cloud Netflix Ribbon 或 Spring Cloud Load Balancer 进行负载均衡,你可能可以使用 Spring Cloud Kubernetes 来实现更平滑的过渡。然而,我建议重构你的代码以利用 Kubernetes 的原生服务发现和负载均衡功能,而不是将 Spring Cloud Kubernetes 添加到你的项目中。

我建议不在标准应用程序中使用 Spring Cloud Kubernetes 的主要原因是因为它需要访问 Kubernetes API 服务器来管理 Pods、Services、ConfigMaps 和 Secrets。除了授予应用程序访问 Kubernetes 内部对象相关的安全顾虑之外,它还会不必要地将应用程序耦合到 Kubernetes 上,并影响解决方案的可维护性。

在什么情况下使用 Spring Cloud Kubernetes 是合理的?以一个例子来说,Spring Cloud Gateway 可以通过 Spring Cloud Kubernetes 进行增强,以获得对服务发现和负载均衡的更多控制,包括基于服务元数据的自动注册新路由以及选择负载均衡策略。在这种情况下,你可以依赖 Spring Cloud Kubernetes Discovery Server 组件,从而减少对发现服务器的 Kubernetes API 访问需求。

当涉及到在集群内实现 Kubernetes 控制器应用程序以完成管理任务时,Spring Cloud Kubernetes 真正大放异彩。例如,您可以实现一个控制器,用于监控 ConfigMaps 或 Secrets 的更改,然后触发使用它们的应用程序的配置刷新。事实上,Spring 团队就是使用 Spring Cloud Kubernetes 构建了一个执行此精确操作的控制器:配置监视器。

注意:Spring Cloud Kubernetes 配置监视器作为容器镜像可在 Docker Hub 上获取。如果您想了解更多关于其工作原理和部署方法的信息,请参阅官方文档(spring.io/projects/spring-cloud-kubernetes)。

除了配置监视器,Spring Cloud Kubernetes 还提供了其他方便的现成应用程序,用于解决 Kubernetes 中分布式系统的常见问题。其中之一是基于 Spring Cloud Config 构建的配置服务器,并扩展其功能以支持从 ConfigMaps 和 Secrets 读取配置数据。它被称为 Spring Cloud Kubernetes 配置服务器。

您可以直接使用此应用程序(容器镜像已发布在 Docker Hub 上)并按照官方文档中提供的说明在 Kubernetes 上部署它(spring.io/projects/spring-cloud-kubernetes)。

作为一种替代方案,您可以使用其在 GitHub 上的源代码作为构建自己的 Kubernetes 感知配置服务器的基石。例如,正如我在本章前面所解释的,您可能希望通过 HTTP Basic 认证来保护它。在这种情况下,您可以使用您与 Spring Cloud Config 的合作经验,并在 Spring Cloud Kubernetes 配置服务器之上构建 Config 服务的增强版本。

在下一节中,我将介绍 Kustomize,用于管理 Kubernetes 中的部署配置。

14.3 使用 Kustomize 进行配置管理

Kubernetes 为运行云原生应用程序提供了许多有用的功能。然而,它需要编写多个 YAML 清单,这些清单有时是冗余的,并且在现实场景中不易管理。在收集部署应用程序所需的多个清单之后,我们面临额外的挑战。我们如何根据环境更改 ConfigMap 中的值?我们如何更改容器镜像版本?关于 Secrets 和卷呢?是否可以更新健康检查的配置?

在过去几年中,已经推出了许多工具来改善我们在 Kubernetes 中配置和部署工作负载的方式。对于 Polar Bookshop 系统,我们希望有一个工具,允许我们将多个 Kubernetes 清单作为一个单一实体来处理,并根据应用程序部署的环境定制配置的部分。

Kustomize (kustomize.io) 是一个声明式工具,它通过分层方法帮助配置不同环境下的部署。它生成标准的 Kubernetes 清单,并且它是在 Kubernetes CLI(kubectl)中本地构建的,因此您不需要安装任何其他东西。

注意:Kubernetes 中管理部署配置的其他流行选项包括 Carvel 套件中的 ytt (carvel.dev/ytt) 和 Helm (helm.sh)。

本节将向您展示 Kustomize 提供的关键特性。首先,您将了解如何 组合 相关的 Kubernetes 清单并将其作为一个单一单元来处理。然后,我将向您展示 Kustomize 如何从属性文件中为您生成 ConfigMap。最后,我将指导您通过一系列自定义操作,这些操作将在将工作负载部署到预发布环境中之前应用于基本清单。下一章将在此基础上扩展,并涵盖生产场景。

在继续之前,请确保您的本地 minikube 集群仍然运行,并且 Polar Bookshop 后端服务已经正确部署。如果没有,请从 polar-deployment/kubernetes/platform/development 运行 ./create-cluster.sh。

注意:平台服务仅在集群内部暴露。如果您想从您的本地机器访问其中任何一个,您可以使用在第七章中了解到的端口转发功能。您可以使用 Octant 提供的 GUI,或者使用 CLI(kubectl port-forward service/polar-postgres 5432:5432)。

现在我们已经拥有了所有后端服务,让我们看看如何使用 Kustomize 来管理和配置 Spring Boot 应用程序。

14.3.1 使用 Kustomize 管理和配置 Spring Boot 应用程序

到目前为止,我们一直是通过应用多个 Kubernetes 清单将应用程序部署到 Kubernetes。例如,部署目录服务需要将 ConfigMap、Deployment 和 Service 清单应用到集群中。当使用 Kustomize 时,第一步是将相关的清单组合在一起,以便我们可以将它们作为一个单一单元来处理。Kustomize 通过 Kustomization 资源来实现这一点。最终,我们希望让 Kustomize 为我们管理、处理和生成 Kubernetes 清单。

让我们看看它是如何工作的。打开您的目录服务项目(catalog-service),在 k8s 文件夹内创建一个 kustomization.yml 文件。它将是 Kustomize 的入口点。

我们首先将指导 Kustomize 使用哪些 Kubernetes 清单作为未来自定义的基础。目前,我们将使用现有的 Deployment 和 Service 清单。

列表 14.4 定义 Kustomize 的基本 Kubernetes 清单

apiVersion: kustomize.config.k8s.io/v1beta1     ❶
kind: Kustomization                             ❷

resources:                                      ❸
  - deployment.yml
  - service.yml

❶ Kustomize 的 API 版本

❷ 清单定义的资源类型

❸ Kustomize 应该管理和处理的 Kubernetes 清单

你可能想知道为什么我们没有包含 ConfigMap。我很高兴你问了!我们本来可以包含本章中创建的 configmap.yml 文件,但 Kustomize 提供了一种更好的方法。我们不是直接引用 ConfigMap,而是可以提供一个属性文件,并让 Kustomize 使用它来生成 ConfigMap。让我们看看它是如何工作的。

首先,让我们将之前创建的 ConfigMap 的主体(configmap.yml)移动到 k8s 文件夹中的一个新的 application.yml 文件中。

列表 14.5 通过 ConfigMap 提供的配置属性

polar:
  greeting: Welcome to the book catalog from Kubernetes!
spring:
  datasource:
    url: jdbc:postgresql://polar-postgres/polardb_catalog
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://polar-keycloak/realms/PolarBookshop

然后,删除 configmap.yml 文件。我们不再需要它了。最后,更新 kustomization.yml 文件,从我们刚刚创建的应用程序.yml 文件开始生成 catalog-config ConfigMap。

列表 14.6 让 Kustomize 从属性文件生成 ConfigMap

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - deployment.yml
  - service.yml

configMapGenerator:             ❶
  - name: catalog-config 
    files:                      ❷
      - application.yml 
    options: 
      labels:                   ❸
        app: catalog-service 

❶ 包含生成 ConfigMap 所需信息的部分

❷ 使用属性文件作为 ConfigMap 的源

❸ 定义要分配给生成的 ConfigMap 的标签

注意:以类似的方式,Kustomize 也可以从文本值或文件开始生成 Secrets。

让我们暂停一下,验证到目前为止我们所做的是否正确。您的本地集群应该已经包含了之前的目录服务容器镜像。如果不是这样,构建容器镜像(./gradlew bootBuildImage),并将其加载到 minikube 中(minikube image load catalog-service --profile polar)。

接下来,打开一个终端窗口,导航到您的目录服务项目(catalog-service),并使用熟悉的 Kubernetes CLI 部署应用程序。当应用标准 Kubernetes 清单时,我们使用 -f 标志。当应用 Kustomization 时,我们使用 -k 标志:

$ kubectl apply -k k8s

最终结果应该与我们之前直接应用 Kubernetes 清单时得到的结果相同,但这次 Kustomize 通过 Kustomization 资源处理了所有操作。

为了完成验证,使用端口转发策略将目录服务应用程序暴露到您的本地机器(kubectl port-forward service/catalog-service 9001:80)。然后打开一个新的终端窗口,并确保根端点返回通过 Kustomize 生成的 ConfigMap 配置的消息:

$ http :9001/
Welcome to the book catalog from Kubernetes!

Kustomize 生成的 ConfigMaps 和 Secrets 在部署时都会添加一个唯一的后缀(一个 哈希值)。您可以使用以下命令验证分配给 catalog-config ConfigMap 的实际名称:

$ kubectl get cm -l app=catalog-service

NAME                        DATA   AGE
catalog-config-btcmff5d78   1      7m58s

每次更新生成器的输入时,Kustomize 都会创建一个新的清单,具有不同的哈希值,这会触发将更新后的 ConfigMaps 或 Secrets 作为卷挂载的容器进行 滚动重启。这是一个非常方便的方法,可以在不实现或配置任何附加组件的情况下实现自动配置刷新。

让我们来验证这是否正确。首先,更新用于由 Kustomize 生成 ConfigMap 的 application.yml 文件中 polar.greeting 属性的值。

列表 14.7 更新 ConfigMap 生成器的配置输入

polar:
  greeting: Welcome to the book catalog from a development 
  ➥ Kubernetes environment! 
...

然后再次应用 Kustomization(kubectl apply -k k8s)。Kustomize 将生成一个新的 ConfigMap,具有不同的后缀哈希,触发所有 Catalog Service 实例的滚动重启。在这种情况下只有一个实例正在运行。在生产环境中会有更多。实例逐个重启的事实意味着更新以零停机时间发生,这是我们云环境中的目标。现在 Catalog Service 的根端点应该返回新的消息:

$ http :9001/
Welcome to the book catalog from a development Kubernetes environment!

如果你好奇,可以比较一下在没有 Kustomize 更新 ConfigMap 时会发生什么。Kubernetes 会更新挂载到 Catalog Service 容器的卷,但应用程序不会重启,并且仍然会返回旧值。

注意:根据你的需求,你可能需要避免滚动重启,并在运行时让应用程序重新加载其配置。在这种情况下,你可以使用 disableNameSuffixHash: true 生成器选项禁用哈希后缀策略,并可能依赖于像 Spring Cloud Kubernetes Configuration Watcher 这样的工具,以便在 ConfigMap 或 Secret 发生更改时通知应用程序。

当你完成对 Kustomize 设置的实验后,你可以停止端口转发过程(Ctrl-C)并卸载 Catalog Service(kubectl delete -k k8s)。

由于我们从纯 Kubernetes 清单迁移到了 Kustomize,我们仍然需要更新一些内容。在第七章中,我们使用 Tilt 在本地使用 Kubernetes 进行开发时实现了一个更好的开发工作流程。Tilt 支持 Kustomize,因此我们可以配置它通过 Kustomization 资源而不是通过纯 Kubernetes 清单来部署应用程序。继续更新你的 Catalog Service 项目的 Tiltfile,如下所示。

列表 14.8 配置 Tilt 使用 Kustomize 部署 Catalog Service

custom_build(
    ref = 'catalog-service',
    command = './gradlew bootBuildImage --imageName $EXPECTED_REF',
    deps = ['build.gradle', 'src']
)

k8s_yaml(kustomize('k8s'))      ❶

k8s_resource('catalog-service', port_forwards=['9001'])

❶ 从位于 k8s 文件夹中的 Kustomization 运行应用程序

最后,我们需要更新 Catalog Service 的提交阶段工作流程中的清单验证步骤,否则在下次我们将更改推送到 GitHub 时将会失败。在你的 Catalog Service 项目中,打开 commit-stage.yml 文件 (.github/workflows) 并按照以下方式更新。

列表 14.9 使用 Kubeval 验证 Kustomize 生成的清单

name: Commit Stage
on: push
...
jobs:
  build:
    name: Build and Test
    ...
    steps:
      ...
      - name: Validate Kubernetes manifests
        uses: stefanprodan/kube-tools@v1
        with:
          kubectl: 1.24.3
          kubeval: 0.16.1
          command: |
            kustomize build k8s | kubeval --strict -       ❶

❶ 使用 Kustomize 生成清单,然后使用 Kubeval 验证它们

到目前为止,我们从 Kustomize 获得的最重要的好处是当 ConfigMap 或 Secret 更新时应用程序的自动滚动重启。在下一节中,你将了解更多关于 Kustomize 的内容,并探索其强大的功能,以根据部署环境管理不同的 Kubernetes 配置。

14.3.2 使用 Kustomize 管理多个环境下的 Kubernetes 配置

在开发过程中,我们遵循了 15-Factor 方法论,并将应用程序的每个可能在不同环境部署中变化的方面的配置外部化。你看到了如何使用属性文件、环境变量、配置服务和 ConfigMaps。我还向你展示了如何使用 Spring 配置文件根据部署环境来定制应用程序配置。现在我们需要进一步定义一个策略,根据我们部署应用程序的位置来定制整个部署配置。

在上一节中,你学习了如何通过 Kustomization 资源一起组合和处理 Kubernetes 清单。对于每个环境,我们可以指定补丁来应用更改或在这些基本清单之上添加额外的配置。本节中你将看到的所有定制步骤都将应用,而不会更改应用程序源代码中的任何内容,但使用之前产生的相同发布工件。这是一个相当强大的概念,也是云原生应用程序的主要特性之一。

Kustomize 配置定制的做法基于基础覆盖的概念。我们在目录服务项目中创建的 k8s 文件夹可以被认为是一个基础:一个包含 kustomization.yml 文件的目录,该文件结合了 Kubernetes 清单和定制。覆盖是另一个包含 kustomization.yml 文件的目录。它之所以特殊,是因为它定义了与一个或多个基础相关的定制,并将它们组合起来。从同一个基础开始,你可以为每个部署环境(如开发、测试、预生产和生产)指定一个覆盖。

如图 14.3 所示,每个 Kustomization 都包含一个 kustomization.yml 文件。作为基础的那个文件组合了多个 Kubernetes 资源,如 Deployments、Services 和 ConfigMaps。它还不知道覆盖,因此与它们完全独立。覆盖使用一个或多个基础作为基础,并通过补丁提供额外的配置。

14-03

图 14.3 Kustomize 基础可以用来作为进一步定制(覆盖)的基础,具体取决于部署环境。

基础和覆盖可以在同一个仓库或不同的仓库中定义。对于极地书店系统,我们将使用每个应用程序项目的 k8s 文件夹作为基础,并在 polar-deployment 仓库中定义覆盖。类似于你在第三章中学到的关于应用程序代码库的内容,你可以决定是否将部署配置保存在与你的应用程序相同的仓库中。我决定使用单独的仓库,出于以下几个原因:

  • 它使得从单一地点控制所有系统组件的部署成为可能。

  • 它允许在部署任何内容到生产之前进行集中的版本控制、审计和合规性检查。

  • 它符合 GitOps 方法,其中交付和部署任务是解耦的。

例如,图 14.4 展示了在目录服务的情况下,Kustomize 清单可以如何结构化,拥有基础和覆盖层在两个独立的仓库中。

14-04

图 14.4 Kustomize 的基础和覆盖层可以存储在同一个仓库中或两个独立的仓库中。覆盖层可以用来定制不同环境下的部署。

另一个需要做出的决定是是否将基 Kubernetes 清单与应用程序源代码一起保留,或者将它们移动到部署仓库中。对于 Polar Bookshop 示例,我决定采用第一种方法,类似于我们处理默认配置属性的方式。其中一个好处是它使得在开发期间在本地 Kubernetes 集群上运行每个应用程序变得简单,无论是直接运行还是使用 Tilt。根据你的需求,你可能会决定使用其中一种方法。两种方法都是有效的,并且在现实场景中被使用。

补丁与模板

Kustomize 定制配置的方法是基于应用补丁。这与 Helm 的工作方式正好相反 (helm.sh)。Helm 要求你为想要更改的清单的每一部分进行模板化(导致非有效的 YAML)。之后,你可以在每个环境中为这些模板提供不同的值。如果一个字段没有进行模板化,你无法自定义其值。因此,使用 Helm 和 Kustomize 的顺序使用并不罕见,以克服彼此的缺点。两种方法都有优点和缺点。

在这本书中,我决定使用 Kustomize,因为它在 Kubernetes CLI 中是原生可用的,它与有效的 YAML 文件一起工作,并且它是纯声明式的。Helm 功能更强大,也可以处理 Kubernetes 原生不支持的应用程序部署和升级。另一方面,它有一个陡峭的学习曲线,其模板解决方案有一些缺点,并且它不是声明式的。

另一个选项是来自 Carvel 套件的 ytt (carvel.dev/ytt)。它提供了一种更优越的体验,支持补丁和模板,它适用于有效的 YAML 文件,并且其模板策略更加稳健。熟悉 ytt 比 Kustomize 需要更多的努力,但这是值得的。因为它将 YAML 视为一等公民,ytt 可以用来配置和定制任何 YAML 文件,甚至是在 Kubernetes 之外。你使用 GitHub Actions 工作流吗?Ansible 剧本?Jenkins 管道?你可以在所有这些场景中使用 ytt。

让我们考虑目录服务。我们已经有了一个由 Kustomize 组成的基部署配置。它位于项目仓库中的一个专用文件夹中(catalog-service/k8s)。现在让我们定义一个覆盖层来定制用于预发布的部署。

14.3.3 定义预发布的配置覆盖

在前面的章节中,我们使用了 Kustomize 来管理本地开发环境中 Catalog Service 的配置。这些清单将代表 基础,为每个环境应用多个自定义设置作为 覆盖。由于我们将在 polar-deployment 仓库中定义覆盖,而基础在 catalog-service 仓库中,因此所有 Catalog Service 清单都必须在主远程分支中可用。如果您还没有这样做,请将您迄今为止应用到 Catalog Service 项目的所有更改推送到 GitHub 上的远程仓库。

注意:正如我在第二章中解释的,我期望您为 Polar Bookshop 系统中的每个项目在 GitHub 上创建了一个不同的仓库。在本章中,我们只使用 polar-deployment 和 catalog-service 仓库,但您也应该为 edge-service、order-service 和 dispatcher-service 创建了仓库。

如预期的那样,我们将任何配置覆盖存储在 polar-deployment 仓库中。在本节和接下来的几节中,我们将定义一个用于预发布环境的覆盖。下一章将涵盖生产环境。

在您的 polar-deployment 仓库中创建一个新的 kubernetes/applications 文件夹。我们将使用它来保存 Polar Bookshop 系统中所有应用程序的自定义设置。在新建的路径中,添加一个 catalog-service 文件夹,该文件夹将包含用于在不同环境中自定义 Catalog Service 部署的任何覆盖。特别是,我们希望准备预发布环境的部署,因此为 Catalog Service 创建一个“staging”文件夹。

任何自定义(基础或覆盖)都需要一个 kustomization.yml 文件。让我们为 Catalog Service 的预发布覆盖(polar-deployment/kubernetes/applications/catalog-service/staging)创建一个。首先需要配置的是对基础清单的引用。

如果您一直跟随,您应该在 GitHub 上的 catalog-service 仓库中跟踪 Catalog Service 的源代码。对远程基础的引用需要指向包含 kustomization.yml 文件的文件夹,在我们的例子中是 k8s。此外,我们应该引用我们想要部署的特定标签或摘要版本。我们将在下一章中讨论发布策略和版本控制,所以现在我们只需指向主分支即可。最终的 URL 应该类似于 github.com/<你的 GitHub 用户名>/catalog-service/k8s?ref=main。例如,在我的情况下,它将是 github.com/polarbookshop/catalog-service/k8s?ref=main。

列表 14.10 在远程基础之上定义预发布覆盖

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:                                            ❶
  - github.com/<your_github_username>/catalog-service/k8s?ref=main

❶ 使用您在 GitHub 上的 Catalog Service 仓库中的清单作为进一步自定义的基础

注意,我将假设您为 Polar Bookshop 创建的所有 GitHub 仓库都是公开可访问的。如果不是这样,您可以去 GitHub 上特定仓库的页面,并访问该仓库的设置部分。然后滚动到设置页面的底部,通过点击“更改可见性”按钮将包设置为公开。

我们现在可以使用 Kubernetes CLI 从预发布覆盖部署目录服务,但结果与直接使用基础没有区别。让我们开始应用一些专门针对预发布部署的自定义设置。

14.3.4 自定义环境变量

我们可以应用的第一种自定义是设置一个环境变量以激活目录服务的预发布 Spring 配置文件。大多数自定义可以通过遵循合并策略的补丁来应用。与 Git 合并来自不同分支的更改类似,Kustomize 生成最终 Kubernetes 清单,其中包含来自不同 Kustomization 文件(一个或多个基础和覆盖)的更改。

定义 Kustomize 补丁时的最佳实践是保持它们小而专注。要自定义环境变量,请在目录服务的预发布覆盖(kubernetes/applications/catalog-service/staging)中创建一个 patch-env.yml 文件。我们需要指定一些上下文信息,以便 Kustomize 能够确定补丁的应用位置以及如何合并更改。当补丁用于自定义容器时,Kustomize 要求我们指定 Kubernetes 资源(即 Deployment)的类型和名称以及容器的名称。这种自定义选项称为 战略合并补丁

列表 14.11 自定义环境变量的补丁

apiVersion: apps/v1
kind: Deployment
metadata:
  name: catalog-service
spec:
  template:
    spec:
      containers:
        - name: catalog-service
          env:
            - name: SPRING_PROFILES_ACTIVE      ❶
              value: prod

❶ 定义应激活哪些 Spring 配置文件

接下来,我们需要指示 Kustomize 应用补丁。在目录服务的预发布覆盖的 kustomization.yml 文件中,按照以下方式列出 patch-env.yml 文件。

列表 14.12 让 Kustomize 应用环境变量补丁

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - github.com/<your_github_username>/catalog-service/k8s?ref=main

 patchesStrategicMerge:      ❶
  - patch-env.yml            ❷

❶ 包含要按照战略合并策略应用于基础清单的补丁列表

❷ 为自定义传递给目录服务容器的环境变量补丁

您可以使用这种方法来自定义许多方面,例如 Deployment 的副本数量、存活探针、就绪探针、优雅关闭超时、环境变量、卷等。在下一节中,我将向您展示如何自定义 ConfigMaps。

14.3.5 自定义 ConfigMaps

目录服务的基 Kustomization 指示 Kustomize 从 application.yml 文件开始生成一个 catalog-config ConfigMap。为了自定义该 ConfigMap 中的值,我们有两个主要选项:替换整个 ConfigMap 或仅覆盖在预发布阶段应该不同的值。在后一种情况下,我们可以一般地依赖于一些高级 Kustomize 补丁策略来覆盖 ConfigMap 中的特定值。

当使用 Spring Boot 时,我们可以利用 Spring 配置文件的力量。我们不必更新现有 ConfigMap 中的值,而是可以添加一个 application-staging.yml 文件,我们知道当 staging 配置文件激活时,该文件比 application.yml 具有优先级。最终结果将是一个包含两个文件的 ConfigMap。

首先,让我们在目录服务的 staging overlay 中创建一个 application-staging.yml 文件。我们将使用这个属性文件来定义 polar .greeting 属性的不同的值。由于我们将使用之前相同的 minikube 集群作为 staging 环境,因此支持服务的 URL 和凭证将与开发环境中的相同。在现实世界的场景中,这个阶段将涉及更多的定制。

列表 14.13 目录服务的特定配置

polar:
  greeting: Welcome to the book catalog from a staging
➥Kubernetes environment!

接下来,我们可以依赖 Kustomize 提供的 ConfigMap 生成器,将定义在 staging overlay 中的应用程序-staging.yml 文件(定义在基本 Kustomization 中)与同一 catalog-config ConfigMap 中的应用程序.yml 文件合并。请更新 staging overlay 的 kustomization.yml 文件,如下所示。

列表 14.14 在同一 ConfigMap 中合并属性文件

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - github.com/<your_github_username>/catalog-service/k8s?ref=main

patchesStrategicMerge:
  - patch-env.yml

configMapGenerator: 
  - behavior: merge                ❶
    files: 
      - application-staging.yml    ❷
    name: catalog-config           ❸

❶ 将此 ConfigMap 与基本 Kustomization 中定义的 ConfigMap 合并

❷ 添加到 ConfigMap 中的附加属性文件

❸ 在基本 Kustomization 中使用的相同 ConfigMap 名称

ConfigMap 的内容到此结束。下一节将介绍如何配置要部署的镜像名称和版本。

14.3.6 自定义镜像名称和版本

在目录服务仓库(catalog-service/k8s/deployment.yml)中定义的基本部署清单配置为使用本地容器镜像,并且没有指定版本号(这意味着使用最新标签)。这在开发阶段很方便,但不适于其他部署环境。

如果你一直跟随,你应该已经在 GitHub 上的 catalog-service 仓库中跟踪了目录服务源代码,并且 ghcr.io/<你的 GitHub 用户名>/catalog-service:latest 容器镜像已发布到 GitHub 容器注册库(按照提交阶段工作流程)。下一章将涵盖发布策略和版本控制。在此之前,我们仍将使用最新标签。然而,关于镜像名称,现在是时候从注册库中拉取容器镜像,而不是使用本地镜像了。

注意发布到 GitHub 容器注册库的镜像将与相关的 GitHub 代码仓库具有相同的可见性。我将假设我们为 Polar Bookshop 构建的所有镜像都可通过 GitHub 容器注册库公开访问。如果不是这样,你可以访问 GitHub 上的特定仓库页面,并进入该仓库的“包”部分。然后从侧边栏菜单中选择“包设置”,滚动到设置页面的底部,通过点击“更改可见性”按钮将包设置为公开。

与我们为环境变量所做的一样,我们可以使用补丁来更改 Catalog 服务部署资源所使用的镜像。然而,由于这是一个非常常见的定制化操作,并且每次我们交付新版本的应用程序时都需要更改,因此 Kustomize 提供了一种更方便的方式来声明我们想要为每个容器使用哪个镜像名称和版本。此外,我们可以直接更新 kustomization.yml 文件,或者依赖于 Kustomize CLI(作为 Kubernetes CLI 的一部分安装)。让我们尝试后者。

打开一个终端窗口,导航到 Catalog 服务(kubernetes/applications/catalog-service/staging)的预发布覆盖,并运行以下命令来定义要用于 catalog-service 容器的镜像和版本。请记住用你的 GitHub 用户名的小写形式替换 <your_github_username>:

$ kustomize edit set image \
    catalog-service=ghcr.io/<your_github_username>/catalog-service:latest

此命令将自动更新 kustomization.yml 文件,如以下列表所示。

列表 14.15 配置容器的镜像名称和版本

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - github.com/<your_github_username>/catalog-service/k8s?ref=main

patchesStrategicMerge:
  - patch-env.yml

configMapGenerator:
  - behavior: merge
    files:
      - application-staging.yml
    name: catalog-config

images: 
  - name: catalog-service                                       ❶
    newName: ghcr.io/<your_github_username>/catalog-service     ❷
    newTag: latest                                              ❸

❶ 在 Deployment 清单中定义的容器名称

❷ 容器的新镜像名称(使用你的 GitHub 用户名的小写形式)

❸ 容器的新标签

在下一节中,我将向你展示如何配置要部署的副本数量。

14.3.7 自定义副本数量

云原生应用程序应该具有高可用性,而 Catalog 服务则不是。到目前为止,我们一直在部署单个应用程序实例。如果它崩溃或由于高负载而暂时不可用,会发生什么?我们将无法再使用该应用程序。这不太具有弹性,对吧?除了其他方面,预发布环境是性能和可用性测试的良好目标。至少,我们应该有两个实例正在运行。Kustomize 提供了一种方便的方式来更新特定 Pod 的副本数量。

打开 Catalog 服务(kubernetes/applications/catalog-service/staging)预发布覆盖中的 kustomization.yml 文件,并配置应用程序的两个副本。

列表 14.16 配置 Catalog 服务容器的副本

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - github.com/<your_github_username>/catalog-service/k8s?ref=main

patchesStrategicMerge:
  - patch-env.yml

configMapGenerator:
  - behavior: merge
    files:
      - application-staging.yml
    name: catalog-config

images:
  - name: catalog-service
    newName: ghcr.io/<your_github_username>/catalog-service
    newTag: latest

replicas: 
  - name: catalog-service    ❶
    count: 2                 ❷

❶ 要定义副本数量的 Deployment 的名称

❷ 副本数量

终于到了部署 Catalog 服务并测试由预发布覆盖提供的配置的时候了。为了简单起见,我们将使用迄今为止一直在使用的相同 minikube 本地集群作为预发布环境。如果你之前还有正在运行的 minikube 集群,那么你可以直接开始。否则,你可以通过在 polar-deployment/kubernetes/platform/development 中运行 ./create-cluster.sh 来启动它。该脚本将启动一个 Kubernetes 集群并部署 Polar 书店所需的底层服务。

然后打开一个终端窗口,导航到 Catalog 服务(applications/catalog-service/staging)的预发布覆盖文件夹,并运行以下命令通过 Kustomize 部署应用程序:

$ kubectl apply -k .

你可以通过 Kubernetes CLI(kubectl get pod -l app=catalog-service)或 Octant GUI(参考第七章了解更多信息)监控操作结果。一旦应用可用并准备就绪,我们可以使用 CLI 检查应用日志:

$ kubectl logs deployment/catalog-service

Spring Boot 的第一个日志事件之一会告诉你预发布配置文件已启用,就像我们在预发布覆盖中通过补丁配置的那样。

应用没有在集群外部暴露,但你可以使用端口转发功能将本地环境上的端口 9001 的流量转发到集群中运行的端口 80 的服务:

$ kubectl port-forward service/catalog-service 9001:80

接下来,打开一个新的终端窗口并调用应用的根端点:

$ http :9001
Welcome to the book catalog from a staging Kubernetes environment!

结果是我们定义在 application-staging.yml 文件中的自定义消息,用于 polar.greeting 属性。这正是我们预期的。

注意:值得注意的是,如果你向 :9001/books 发送 GET 请求,你会得到一个空列表。在预发布环境中,我们还没有启用 testdata 配置文件,该配置文件控制启动时书籍的生成。我们希望在开发或测试环境中才启用。

我们对预发布覆盖所做的最后一个自定义是部署副本的数量。让我们使用以下命令来验证:

$ kubectl get pod -l app=catalog-service

NAME                               READY   STATUS    RESTARTS   AGE
catalog-service-6c5fc7b955-9kvgf   1/1     Running   0          3m94s
catalog-service-6c5fc7b955-n7rgl   1/1     Running   0          3m94s

Kubernetes 设计目的是确保每个应用的可用性。如果资源足够,它将尝试在两个不同的节点上部署两个副本。如果一个节点崩溃,应用仍然可以在另一个节点上可用。同时,Kubernetes 会负责在其他地方部署第二个实例,以确保始终有两个副本在运行。你可以使用 kubectl get pod -o wide 检查每个 Pod 分配到了哪个节点。在我们的例子中,minikube 集群只有一个节点,所以两个实例将一起部署。

如果你好奇,你也可以尝试更新 application-staging.yml 文件,再次将 Kustomization 应用到集群中(kubectl apply -k .),并观察 Catalog Service Pods 是如何一个接一个地重启(滚动重启)以加载新的 ConfigMap,实现零停机时间。为了可视化事件序列,你可以使用 Octant 或在应用 Kustomization 之前在另一个终端窗口中运行此命令:kubectl get pods -l app=catalog-service --watch。

当你完成应用的测试后,你可以使用 Ctrl-C 终止端口转发过程,并从 polar-deployment/kubernetes/platform/development 目录下使用 ./destroy-cluster.sh 删除集群。

现在你已经学会了在 Kustomize 中配置和部署 Spring Boot 应用的基础知识,是时候进入生产环境了。这正是下一章要讲的内容。

Polar Labs

随意将本章学到的知识应用到 Polar 书店系统中的所有应用。你将在下一章需要这些更新后的应用,我们将把所有内容部署到生产环境中。

  1. 禁用 Spring Cloud Config 客户端。

  2. 定义一个基本的 Kustomization 清单,并更新 Tilt 和提交阶段工作流程。

  3. 使用 Kustomize 生成 ConfigMap。

  4. 配置预发布覆盖层。

您可以参考书中附带的代码存储库中的 Chapter14/14-end 文件夹来检查最终结果 (github.com/ThomasVitale/cloud-native-spring-in-action)。

摘要

  • 使用 Spring Cloud Config Server 构建的配置服务器可以通过 Spring Security 提供的任何功能进行保护。例如,您可以要求客户端使用 HTTP Basic 认证来访问服务器暴露的配置端点。

  • Spring Boot 应用程序中的配置数据可以通过调用 Spring Boot Actuator 暴露的 /actuator/refresh 端点来重新加载。

  • 要将配置刷新操作传播到系统中的其他应用程序,您可以使用 Spring Cloud Bus。

  • Spring Cloud Config Server 提供了一个监控模块,该模块暴露了一个 /monitor 端点,代码存储库提供商可以通过 webhook 在将新更改推送到配置存储库时调用该端点。结果是,所有受配置更改影响的应用程序都将由 Spring Cloud Bus 触发重新加载配置。整个过程是自动发生的。

  • 管理秘密是任何软件系统的关键任务,出错时非常危险。

  • Spring Cloud Config 提供了使用对称或非对称密钥对配置存储库中的秘密进行加密和解密的特性,以确保秘密的安全处理。

  • 您还可以使用云提供商(如 Azure、AWS 和 Google Cloud)提供的秘密管理解决方案,并利用 Spring Cloud Azure、Spring Cloud AWS 和 Spring Cloud GCP 提供的 Spring Boot 集成。

  • HashiCorp Vault 是另一个选择。您可以使用它通过 Spring Vault 项目直接配置所有 Spring Boot 应用程序,或者将其作为 Spring Cloud Config Server 的后端。

  • 当 Spring Boot 应用程序部署到 Kubernetes 集群时,您还可以通过 ConfigMaps(用于非敏感配置数据)和 Secrets(用于敏感配置数据)来配置它们。

  • 您可以使用 ConfigMaps 和 Secrets 作为环境变量的值源,或将它们作为卷挂载到容器中。后一种方法更受欢迎,并且 Spring Boot 本地支持。

  • 秘密并非真正保密。它们包含的数据默认情况下不会被加密,因此您不应将它们放入版本控制中,也不应包含在您的存储库中。

  • 平台团队负责保护秘密,例如,可以使用 Sealed Secrets 项目加密秘密,使其能够放入版本控制中。

  • 管理多个 Kubernetes 清单以部署应用程序并不直观。Kustomize 提供了一种方便的方式来管理、部署、配置和升级 Kubernetes 中的应用程序。

  • 在其他方面,Kustomize 提供了生成器来构建 ConfigMaps 和 Secrets,并在它们更新时触发滚动重启。

  • Kustomize 的配置自定义方法基于基础和覆盖的概念。

  • Overlays 是基于基础清单构建的,任何自定义都是通过补丁来应用的。您已经看到了如何定义补丁以自定义环境变量、ConfigMaps、容器镜像和副本。

15 持续交付和 GitOps

本章涵盖

  • 理解持续交付和发布管理

  • 使用 Kustomize 配置 Spring Boot 以供生产使用

  • 使用 GitOps 和 Kubernetes 进行生产部署

一章又一章,我们已经探讨了与云原生应用一起工作的模式、原则和最佳实践,并使用 Spring Boot 和 Kubernetes 构建了一个书店系统。现在是时候将 Polar 书店部署到生产环境中了。

我预计您已经将 Polar 书店系统的项目存储在 GitHub 上的单独 Git 仓库中。如果您没有跟随前面的章节,可以参考书中源代码的 Chapter15/15-begin 文件夹,并以此为基础定义这些仓库。

本章将指导您了解准备生产应用的一些最终方面。首先,我将讨论发布候选人的版本控制策略以及如何设计部署管道的验收阶段。然后,您将了解如何配置 Spring Boot 应用以供生产使用,并在公共云中的 Kubernetes 集群上部署它们。接下来,我将向您展示如何通过实现生产阶段来完善部署管道。最后,您将使用 Argo CD 根据 GitOps 原则实现持续部署。

注意:本章示例的源代码可在 Chapter15/15-begin 和 Chapter15/15-end 文件夹中找到,包含项目的初始和最终状态(github.com/ThomasVitale/cloud-native-spring-in-action)。

15.1 部署管道:验收阶段

持续交付是我们确定的支持我们实现云原生目标(速度、弹性、可扩展性和成本优化)的基本实践之一。这是一种全面的方法,用于快速、可靠和安全地交付高质量软件。持续交付背后的主要思想是,应用程序始终处于可发布状态。采用持续交付的主要模式是部署管道,它从代码提交到可发布软件。应尽可能自动化,并代表通往生产的唯一路径。

第三章解释说,部署管道可以由三个关键阶段组成:提交阶段、验收阶段和生产阶段。在整个书中,我们已经将提交阶段自动化为 GitHub Actions 中的一个工作流程。开发者在主线提交新代码后,此阶段将经历构建、单元测试、集成测试、静态代码分析和打包。在这个阶段的最后,一个可执行的应用程序工件被发布到工件仓库。这被称为发布候选

本节将介绍我们如何为持续交付版本发布候选版本进行版本控制。然后你将了解更多关于接受阶段、其目的和结果。最后,我将向你展示如何在 GitHub Actions 中实现接受阶段的简化工作流程。在这个阶段结束时,发布候选版本将准备好部署到生产环境。

15.1.1 为持续交付版本发布候选版本

部署管道的提交阶段输出是一个发布候选版本。这是应用程序的可部署工件。在我们的例子中,它是一个容器镜像。管道中的所有后续步骤将通过不同的测试来评估该容器镜像的质量。如果没有发现任何问题,发布候选版本最终将被部署到生产环境并发布给用户。

发布候选版本存储在工件存储库中。如果它是一个 JAR 文件,它将被存储在 Maven 存储库中。在我们的例子中,它是一个容器镜像,并将存储在容器注册库中。特别是,我们将使用 GitHub 容器注册库。

每个发布候选版本都必须具有唯一标识。到目前为止,我们已经为所有容器镜像版本使用了隐式的最新标签。此外,我们还忽略了 Gradle 中每个 Spring Boot 项目默认配置的 0.0.1-SNAPSHOT 版本。我们应该如何对发布候选版本进行版本控制?

一个流行的策略是语义版本控制 (semver.org)。它由形式为 .. 的标识符组成。可选地,你还可以在末尾添加一个连字符,后跟一个字符串,标记为预发布版本。默认情况下,从 Spring Initializr (start.spring.io) 生成的 Spring Boot 项目初始化为版本 0.0.1-SNAPSHOT,这标识了一个快照发布。这种策略的变体是日历版本控制 (calver.org),它将语义版本控制的概念与日期和时间相结合。

这两种策略都广泛用于开源项目和作为产品发布给客户的软件,因为它们提供了关于新发布包含内容的隐含信息。例如,我们期望新的大版本包含新的功能和对前一个大版本不兼容的 API 变更。另一方面,我们预计补丁将具有有限的范围并保证向后兼容性。

注意:如果你正在从事适合语义版本控制的软件项目,我建议查看 JReleaser,一个发布自动化工具。“其目标是简化创建发布并将工件发布到多个包管理器的过程,同时提供可定制的选项” (jreleaser.org)。

语义版本控制将需要某种形式的手动步骤来根据发布实物的内容分配版本号:它是否包含破坏性更改?它是否仅包含错误修复?当我们有一个数字时,新发布实物的具体内容仍然不清楚,因此我们需要使用 Git 标签并定义 Git 提交标识符和版本号之间的映射。

对于快照工件来说,情况变得更加复杂。让我们以 Spring Boot 项目为例。默认情况下,我们以版本 0.0.1-SNAPSHOT 开始。直到我们准备好发布 0.0.1 版本,每次我们将新更改推送到主分支时,都会触发提交阶段,并发布一个新的带有编号 0.0.1-SNAPSHOT 的发布候选。所有发布候选都将具有相同的编号,直到版本 0.0.1 发布。这种方法并不能确保变更的可追溯性。哪些提交包含在发布候选 0.0.1-SNAPSHOT 中?我们无法得知。此外,它还受到与使用最新版本相同的不可靠性影响。每次我们检索工件时,它可能与上次不同。

当涉及到持续交付时,使用类似于语义版本控制的方法来唯一标识发布候选并不理想。当我们遵循持续集成的原则时,我们每天都会构建许多发布候选。而且每个发布候选都有可能被提升到生产环境。我们是否需要为每个新的代码提交更新语义版本,根据其内容(主要、次要、补丁)采用不同的方法?从代码提交到生产的路径应该尽可能地自动化,试图消除人工干预。如果我们采用持续部署,甚至提升到生产的过程也将自动完成。我们应该怎么做?

一种解决方案是使用 Git 提交哈希来版本发布候选——这将自动化、可追踪且可靠,而且你不需要 Git 标签。你可以直接使用提交哈希(例如,486105e261cb346b87920aaa4ea6dce6eebd6223)或者将其作为生成更易于人类阅读的数字的基础。例如,你可以在其前面加上时间戳或递增的序列号,目的是使人们能够判断哪个发布候选是最新的(例如,20220731210356-486105e261cb346b87920aaa4ea6dce6eebd6223)。

尽管如此,语义版本控制和类似策略在持续交付中仍有其位置。除了唯一的标识符外,它们还可以作为显示名称使用,正如戴夫·法雷利在其著作《持续交付管道》(2021 年)中建议的那样。这将是一种在提供有关发布候选信息的同时,仍然能够从持续交付中受益的方法。

对于极地书店(Polar Bookshop),我们将采用简单的解决方案,直接使用 Git 提交哈希来识别我们的发布候选版本。因此,我们将忽略在 Gradle 项目中配置的版本号(它可以用作显示版本名称)。例如,目录服务的发布候选版本将是 ghcr.io/<你的 GitHub 用户名>/catalog-service:

既然我们已经有了策略,让我们看看我们如何为目录服务(Catalog Service)实现它。前往你的目录服务项目(catalog-service),并在 .github/workflows 文件夹中打开 commit-stage.yml 文件。我们之前定义了一个 VERSION 环境变量来保存发布候选版本的唯一标识符。目前,它被静态设置为最新版本。让我们将其替换为 ${{ github.sha }},这将由 GitHub Actions 动态解析为当前的 Git 提交哈希。为了方便,我们还将最新的标签添加到最新的发布候选版本中,这在本地开发场景中非常有用。

列表 15.1 使用 Git 提交哈希来版本发布候选版本

name: Commit Stage
on: push

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: polarbookshop/catalog-service
  VERSION: ${{ github.sha }}                                             ❶

build:
  name: Build and Test
  ...

package:
  name: Package and Publish
  ...
  steps:
    ...
    - name: Publish container image                                      ❶
      run: docker push \
             ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}
    - name: Publish container image (latest)                             ❷
      run: |
        docker tag \
          ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }} \
          ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
        docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

❶ 发布一个版本等于 Git 提交哈希的发布候选版本

❷ 为最新的发布候选版本添加“latest”标签

更新工作流程后,提交你的更改并将其推送到 GitHub。这将触发提交阶段工作流程的执行(图 15.1)。结果将是一个发布到 GitHub 容器注册表的容器镜像,版本号为当前的 Git 提交哈希和额外的最新标签。

15-01

图 15.1 提交阶段从代码提交到发布候选版本发布到工件存储库。

一旦管道成功执行,你将能够在 GitHub 上你的 catalog-service 仓库主页上看到新发布的容器镜像。在侧边栏中,你会找到一个包含“catalog-service”项目的“包”部分。点击它,你将被引导到目录服务的容器存储库(图 15.2)。当使用 GitHub 容器注册表时,容器镜像存储在源代码旁边,这非常方便。

15-02

图 15.2 在我们的情况下,发布候选版本是发布到 GitHub 容器注册表的容器镜像。

在这个阶段,容器镜像(我们的发布候选版本)已经具有唯一标识并准备进入验收阶段。这就是下一节的主题。

15.1.2 理解部署管道的验收阶段

部署管道的验收阶段在提交阶段结束时,每当新的发布候选版本发布到工件存储库时都会被触发。它包括将应用程序部署到类似生产的环境并运行额外的测试,以提高其可发布性的信心。在验收阶段运行的测试通常很慢,但我们应努力将整个部署管道的执行时间控制在一个小时以内。

在第三章中,你学习了敏捷测试象限(图 15.3)提供的软件测试分类。象限根据测试是否面向技术或业务,以及是否支持开发团队或用于批评项目来对软件测试进行分类。

15-03

图 15.3 敏捷测试象限是规划软件测试策略的有用分类法。

在提交阶段,我们主要关注第一象限,包括单元和集成测试。它们是面向技术的测试,支持团队,确保他们构建的软件正确。另一方面,接受阶段关注第二和第四象限,并试图消除手动回归测试的需求。这一阶段包括功能和非功能性接受测试。

功能接受测试是面向业务的测试,支持开发团队,确保他们正在构建正确的软件。它们从用户的角度出发,通常通过使用高级领域特定语言(DSL)的可执行规范来实现,然后将其翻译成低级编程语言。例如,您可以使用 Cucumber (cucumber.io)用人类友好的纯文本编写“浏览图书目录”或“下订单”等场景。然后,可以使用 Java 等编程语言执行和验证这些场景。

在接受阶段,我们还可以通过非功能性接受测试来验证候选版本的质量属性。例如,我们可以使用 Gatling (gatling.io)等工具运行性能和负载测试,安全性和合规性测试,以及弹性测试。在最后一种情况下,我们可以采用混沌工程,这是一种由 Netflix 推广的学科,包括使系统的一部分失败,以验证其余部分如何反应以及系统对失败的弹性。对于 Java 应用程序,您可以查看 Spring Boot 的 Chaos Monkey (codecentric.github.io/chaos-monkey-spring-boot)。

注意关于第三象限的问题?遵循持续交付的原则,我们努力不在部署管道中包含手动测试。然而,我们通常需要它们。对于面向最终用户(如网页和移动应用)的软件产品尤其重要。因此,我们以探索性测试可用性测试的形式在旁边运行它们,以确保测试人员有更多的自由度,并对持续集成和部署管道所需的节奏和时间有更少的限制。

接受阶段的一个基本特征是,所有测试都是在类似生产环境的情况下运行的,以确保最佳可靠性。部署将遵循与生产相同的程序和脚本,并且可以通过专门的系统测试(第一象限)进行测试。

如果候选发布版本在接受阶段通过所有测试,这意味着它处于可发布状态,可以被交付并部署到生产环境中。图 15.4 说明了部署管道中提交和接受阶段的输入和输出。

15-04

图 15.4 提交阶段从代码提交到候选发布版本,然后通过接受阶段。如果它通过所有测试,它就准备好投入生产。

15.1.3 使用 GitHub Actions 实现接受阶段

在本节中,您将了解如何使用 GitHub Actions 实现接受阶段的流程框架。在本书中,我们一直关注单元和集成测试,这些测试在提交阶段运行。对于接受阶段,我们需要编写功能性和非功能性接受测试。这超出了本书的范围,但我仍然想通过以目录服务为例,向您展示一些设计工作流程的原则。

打开您的目录服务项目(catalog-service),在 .github/workflows 文件夹中创建一个新的 acceptance-stage.yml 文件。每当新发布候选版本发布到工件存储库时,就会触发接受阶段。定义此类触发器的一个选项是监听 GitHub 在提交阶段工作流程完成运行时发布的事件。

列表 15.2 在提交阶段完成后触发接受阶段

name: Acceptance Stage            ❶
on:
  workflow_run:                   ❷
    workflows: ['Commit Stage']
    types: [completed]
    branches: main                ❸

❶ 工作流程的名称

❷ 当提交阶段工作流程完成运行时,此工作流程被触发。

❸ 此工作流程仅在主分支上运行。

然而,这还不够。遵循持续集成原则,开发者一天中会频繁提交,并反复触发提交阶段。由于提交阶段比接受阶段快得多,我们可能会创建一个瓶颈。当接受阶段运行完成后,我们并不感兴趣验证在此期间排队的所有候选发布版本。我们只对最新的一个感兴趣,所以其他的可以丢弃。GitHub Actions 通过并发控制机制提供了一个处理这种场景的方法。

列表 15.3 配置工作流程执行的并发性

name: Acceptance Stage
on:
  workflow_run:
    workflows: ['Commit Stage']
    types: [completed]
    branches: main
concurrency: acceptance    ❶

❶ 确保一次只运行一个工作流程

接下来,您将定义几个并行运行在类似生产环境中的作业,以完成功能性和非功能性接受测试。在我们的例子中,我们只是简单地打印一条消息,因为我们还没有实现此阶段的自动测试。

列表 15.4 运行功能性和非功能性接受测试

name: Acceptance Stage
on:
  workflow_run:
    workflows: ['Commit Stage']
    types: [completed]
    branches: main
concurrency: acceptance

jobs: 
  functional:                                                         ❶
    name: Functional Acceptance Tests 
    if: ${{ github.event.workflow_run.conclusion == 'success' }} 
    runs-on: ubuntu-22.04 
    steps: 
      - run: echo "Running functional acceptance tests" 
  performance:                                                        ❶
    name: Performance Tests 
    if: ${{ github.event.workflow_run.conclusion == 'success' }} 
    runs-on: ubuntu-22.04 
    steps: 
      - run: echo "Running performance tests" 
  security:                                                           ❶
    name: Security Tests 
    if: ${{ github.event.workflow_run.conclusion == 'success' }} 
    runs-on: ubuntu-22.04 
    steps: 
      - run: echo "Running security tests" 

❶ 只有当提交阶段成功完成后,作业才会运行。

注意:接受测试可以针对与生产环境非常相似的阶段环境运行。可以使用我们在上一章中配置的阶段覆盖来部署应用程序。

在这一点上,将你的更改推送到你的 GitHub catalog-service 仓库,并查看 GitHub 首先如何运行提交阶段的工作流程(由你的代码提交触发),然后是验收阶段的工作流程(由提交阶段工作流程成功完成触发)。图 15.5 显示了验收阶段工作流程的执行结果。

15-05

图 15.5 提交阶段从代码提交到发布候选者,然后通过验收阶段。如果它通过了所有测试,它就准备好投入生产了。

Polar Labs

是时候将你在本节中学到的知识应用到边缘服务、调度服务和订单服务上了。

  1. 更新提交阶段的工作流程,以便每个发布候选者都能唯一标识。

  2. 将你的更改推送到 GitHub,确保工作流程成功完成,并检查是否已将容器镜像发布到 GitHub 容器注册库。

  3. 创建一个验收阶段的工作流程,将你的更改推送到 GitHub,并验证在提交阶段的工作流程完成后是否正确触发。

在本书附带的源代码仓库中,你可以在 Chapter15/15-end 文件夹中检查最终结果(github.com/ThomasVitale/cloud-native-spring-in-action)。

部署到生产环境需要发布候选者及其配置的组合。现在我们已经验证了发布候选者已准备好投入生产,是时候定制其配置了。

15.2 为生产配置 Spring Boot

我们越来越接近在生产环境中将云原生应用程序部署到 Kubernetes 集群。到目前为止,我们一直在使用 minikube 进行本地集群的工作。现在我们需要一个完整的 Kubernetes 集群用于我们的生产环境。在你继续阅读本节之前,请按照附录 B(B.1 至 B.6 节)中的说明在 DigitalOcean 公共云上初始化一个 Kubernetes 集群。如果你想使用不同的云提供商,你也会找到一些提示。

一旦你在云中启动并运行了一个 Kubernetes 集群,你就可以继续阅读本节,它将涵盖在将 Spring Boot 应用程序部署到生产环境之前我们需要提供的额外配置。

在上一章中,你学习了 Kustomize 和 overlay 技术,用于在公共基础之上管理不同部署环境的自定义配置。你还尝试了为预发布环境定制目录服务部署。在本节中,我们将为生产环境做类似的事情。扩展第十四章中你看到的内容,我将向你展示如何为 ConfigMaps 和 Secrets 自定义卷挂载。你还将了解如何为在 Kubernetes 中运行的容器配置 CPU 和内存,并了解 Paketo Buildpacks 如何管理每个容器内 Java 虚拟机(JVM)的资源。

15.2.1 定义生产环境的配置覆盖

首先,我们需要定义一个新的覆盖层以自定义生产环境中的目录服务部署。正如你可能从上一章中记得的那样,目录服务的 Kustomization 基础存储在 catalog-service 仓库中。我们将覆盖层保存在 polar-deployment 仓库中。

在 kubernetes/applications/catalog-service(在 polar-deployment 仓库中)内创建一个新的“production”文件夹。我们将使用它来存储与生产环境相关的所有自定义设置。任何基础或覆盖都需要一个 kustomization.yml 文件,因此让我们为生产覆盖创建一个。记住,在以下列表中,将<your_github_username>替换为你的 GitHub 用户名(小写)。同时,将<release_sha>替换与你的目录服务最新发布候选版本关联的唯一标识符。你可以从你的 catalog-service GitHub 仓库主页的软件包部分检索该版本。

列表 15.5 在远程基础之上定义生产环境的覆盖

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:                                                                 ❶
  - github.com/<your_github_username>/catalog-service/k8s?ref=<release_sha>

❶ 识别你最新发布候选的 git 提交哈希(sha)

注意:我将假设你为 Polar Bookshop 创建的所有 GitHub 仓库都是公开可访问的。如果不是这样,你可以转到 GitHub 上的特定仓库页面,并访问该仓库的设置部分。滚动到设置页面的底部,通过点击更改可见性按钮使包公开。

定制环境变量

我们将应用的第一项自定义设置是一个环境变量,用于激活目录服务的 prod Spring 配置文件。遵循与上一章相同的方法,在目录服务的生产覆盖层(kubernetes/applications/catalog-service/production)内创建一个 patch-env.yml 文件。

列表 15.6 在容器中定制环境变量的补丁

apiVersion: apps/v1
kind: Deployment
metadata:
  name: catalog-service
spec:
  template:
    spec:
      containers:
        - name: catalog-service
          env:
            - name: SPRING_PROFILES_ACTIVE    ❶
              value: prod

❶ 定义应激活哪些 Spring 配置文件

接下来,我们需要指导 Kustomize 应用补丁。在目录服务的生产覆盖层的 kustomization.yml 文件中,如下列出 patch-env.yml 文件。

列表 15.7 让 Kustomize 应用环境变量补丁

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - github.com/<your_github_username>/catalog-service/k8s?ref=<release_sha>

 patchesStrategicMerge:     ❶
  - patch-env.yml           ❷

❶ 包含要应用补丁列表的章节,根据战略合并策略

❷ 为目录服务容器传递的环境变量定制的补丁

定制密钥和卷

在上一章中,你学习了如何定义 ConfigMaps 和 Secrets,并看到了如何将它们作为卷挂载到 Spring Boot 容器中。在基础 Kustomization 中,我们没有配置任何 Secrets,因为我们依赖于开发中的相同默认值。在生产中,我们需要传递不同的 URL 和凭证,以便目录服务能够访问 PostgreSQL 数据库和 Keycloak。

当您之前在 DigitalOcean 上设置生产环境时,您还创建了一个包含访问 PostgreSQL 数据库凭据的秘密(polar-postgres-catalog-credentials)以及另一个用于 Keycloak 的秘密(keycloak-issuer-resourceserver-secret)。现在我们可以将它们作为卷挂载到目录服务容器上,类似于我们在第十四章中处理 ConfigMaps 的方式。我们将在一个专门的补丁中这样做。

在目录服务(kubernetes/applications/catalog-service/production)的生产覆盖层中创建一个 patch-volumes.yml 文件,并按照清单 15.8 中的配置设置补丁。当 Kustomize 将此补丁应用到基础部署清单时,它将合并基础中定义的 ConfigMap 卷和补丁中定义的秘密卷。

将秘密作为卷挂载到目录服务容器的清单 15.8

apiVersion: apps/v1
kind: Deployment
metadata:
  name: catalog-service
spec:
  template:
    spec:
      containers:
        - name: catalog-service
          volumeMounts:
            - name: postgres-credentials-volume
              mountPath: /workspace/secrets/postgres              ❶
            - name: keycloak-issuer-resourceserver-secret-volume
              mountPath: /workspace/secrets/keycloak              ❷
      volumes:
        - name: postgres-credentials-volume
          secret:                                                 ❸
            secretName: polar-postgres-catalog-credentials
        - name: keycloak-issuer-resourceserver-secret-volume
          secret:                                                 ❹
            secretName: keycloak-issuer-resourceserver-secret

❶ 将包含 PostgreSQL 凭据的秘密挂载到卷上

❷ 将包含 Keycloak 发起者 URL 的秘密挂载到卷上

❸ 定义一个包含 PostgreSQL 凭据的秘密卷

❹ 定义一个包含 Keycloak 发起者 URL 的密钥的卷

然后,就像您在上一节中学到的,我们需要在用于生产覆盖层的 kustomization.yml 文件中引用该补丁。

清单 15.9 让 Kustomize 应用挂载秘密的补丁

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - github.com/<your_github_username>/catalog-service/k8s?ref=<release_sha>

patchesStrategicMerge:
  - patch-env.yml
  - patch-volumes.yml     ❶

❶ 定义一个挂载秘密为卷的补丁

目前,秘密被配置为提供给容器,但 Spring Boot 还没有意识到它们。在下一节中,我将向您展示如何指导 Spring Boot 加载这些秘密作为配置树。

自定义 ConfigMaps

目录服务的基础 Kustomization 指导 Kustomize 从应用程序.yml 文件开始生成一个 catalog-config ConfigMap。正如您在上一章中学到的,我们可以要求 Kustomize 向同一个 ConfigMap 添加一个额外的文件,即 application-prod.yml,我们知道它优先于基础应用程序.yml 文件。这就是我们将如何为生产定制应用程序配置。

首先,在目录服务(kubernetes/applications/catalog-service/production)的生产覆盖层中创建一个 application-prod.yml 文件。我们将使用这个属性文件来配置自定义问候语。我们还需要指导 Spring Boot 使用 spring.config.import 属性加载秘密作为配置树。有关配置树的更多信息,请参阅第十四章。

清单 15.10 目录服务特定的生产配置

polar:
  greeting: Welcome to our book catalog from a production
➥Kubernetes environment!
spring:
  config:
    import: configtree:/workspace/secrets/*/     ❶

❶ 从挂载秘密卷的路径导入配置。确保包含最后的斜杠,否则导入将失败。

接下来,我们可以依赖 Kustomize 提供的 ConfigMap 生成器,将生产覆盖层中定义的应用程序-prod.yml 文件(定义在基础 Kustomization 中)与应用程序.yml 文件(定义在基础 Kustomization 中)结合到同一个 catalog-config ConfigMap 中。请更新生产覆盖层的 kustomization.yml 文件如下。

列表 15.11 在同一 ConfigMap 内合并属性文件

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - github.com/<your_github_username>/catalog-service/k8s?ref=<release_sha>

patchesStrategicMerge:
  - patch-env.yml
  - patch-volumes.yml

configMapGenerator:
  - behavior: merge             ❶
    files: 
      - application-prod.yml    ❷
    name: catalog-config        ❸

❶ 将此 ConfigMap 与在基本 Kustomization 中定义的 ConfigMap 合并

❷ 添加到 ConfigMap 的附加属性文件

❸ 在基本 Kustomization 中使用的相同 ConfigMap 名称

自定义镜像名称和版本

下一步是更新镜像名称和版本,按照我们在上一章中使用的相同程序。这次我们将能够为容器镜像使用适当的版本号(我们的发布候选)。

首先,请确保您已在计算机上安装了 kustomize CLI。您可以参考kustomize.io上的说明。如果您使用的是 macOS 或 Linux,可以使用以下命令安装 kustomize:brew install kustomize。

然后,打开一个终端窗口,导航到目录服务(kubernetes/applications/catalog-service/production)的生产覆盖层,并运行以下命令以定义用于目录服务容器的镜像和版本。请记住用小写替换 <your_github_username> 为您的 GitHub 用户名。同时,用 替换与您最新的目录服务发布候选关联的唯一标识符。您可以从 catalog-service GitHub 仓库主页的“软件包”部分检索该版本:

$ kustomize edit set image \
    catalog-service=ghcr.io/<your_github_username>/catalog-service:<sha>

此命令将自动更新 kustomization.yml 文件,以包含新的配置,如下所示。

列表 15.12 配置容器镜像名称和版本

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - github.com/<your_github_username>/catalog-service/k8s?ref=<release_sha>

patchesStrategicMerge:
  - patch-env.yml
  - patch-volumes.yml

configMapGenerator:
  - behavior: merge
    files:
      - application-prod.yml
    name: catalog-config

images: 
  - name: catalog-service                                ❶
    newName: 
    ➥ ghcr.io/<your_github_username>/catalog-service    ❷
    newTag: <release_sha>                                ❸

❶ 在 Deployment 清单中定义的容器名称

❷ 容器的新镜像名称(包含您的小写 GitHub 用户名)

❸ 容器的新标签(包含您的发布候选的唯一标识符)

注意:发布到 GitHub Container Registry 的镜像将具有与相关 GitHub 代码仓库相同的可见性。我将假设您为 Polar Bookshop 构建的镜像都可通过 GitHub Container Registry 公共访问。如果不是这样,您可以访问 GitHub 上的特定仓库页面,并进入该仓库的“软件包”部分。然后从侧边栏菜单中选择“软件包设置”,滚动到设置页面的底部,通过点击“更改可见性”按钮使软件包公开。

目前我们在两个地方使用发布候选的唯一标识符:远程基本 URL 和镜像标签。每当新的发布候选被提升到生产环境时,我们需要记住更新这两个标识符。更好的是,我们应该自动化更新。我将在实现部署管道的生产阶段时描述这一点。

自定义副本数量

云原生应用应该具有高可用性,但默认情况下只部署了 Catalog Service 的一个实例。类似于我们对预发布环境所做的那样,让我们自定义应用程序的副本数量。

打开 Catalog Service(kubernetes/applications/catalog-service/production)的生产覆盖层中的 kustomization.yml 文件,并为 catalog-service 容器定义两个副本。

列表 15.13 配置容器的副本数量

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - github.com/<your_github_username>/catalog-service/k8s?ref=<release_sha>

patchesStrategicMerge:
  - patch-env.yml
  - patch-volumes.yml

configMapGenerator:
  - behavior: merge
    files:
      - application-prod.yml
    name: catalog-config
images:
  - name: catalog-service
    newName: ghcr.io/<your_github_username>/catalog-service
    newTag: <release_sha>

replicas: 
  - name: catalog-service    ❶
    count: 2                 ❷

❶ 你正在定义副本数量的部署的名称

❷ 副本的数量

注意:在实际场景中,你可能希望 Kubernetes 根据当前的工作负载动态地扩展和缩减应用程序,而不是提供一个固定的数量。动态扩展是任何云平台的关键特性。在 Kubernetes 中,它通过一个名为水平 Pod 自动缩放器(Horizontal Pod Autoscaler)的专用组件来实现,该组件基于定义良好的指标,例如每个容器的 CPU 消耗。有关更多信息,请参阅 Kubernetes 文档(kubernetes.io/docs)。

下一个部分将介绍如何在 Kubernetes 中为运行 Spring Boot 容器配置 CPU 和内存。

15.2.2 配置 Spring Boot 容器的 CPU 和内存

在处理容器化应用程序时,最好明确分配 资源限制。在第一章中,你了解到容器利用 Linux 功能,如命名空间和 cgroups,来隔离进程的上下文,并在进程之间划分和限制资源。然而,如果你没有指定任何资源限制,那么每个容器都将能够访问主机机器上可用的全部 CPU 集和内存,这可能导致其中一些容器占用比应有的更多资源,并导致其他容器由于资源不足而崩溃。

对于基于 JVM 的应用程序,如 Spring Boot,定义 CPU 和内存限制尤为重要,因为它们将被用来正确地调整 JVM 线程池、堆内存和非堆内存等项目的大小。配置这些值一直是 Java 开发者的挑战,并且由于它们直接影响应用程序性能,因此至关重要。幸运的是,如果你使用包含在 Spring Boot 中的 Paketo 实现的 Cloud Native Buildpacks,你就不必担心这个问题。当你使用 Paketo 在第六章中打包 Catalog Service 应用程序时,会自动包含一个 Java 内存计算器 组件。当你运行容器化应用程序时,该组件将根据分配给容器的资源限制配置 JVM 内存。如果你没有指定任何限制,结果将是不可预测的,这并不是你想要的。

还有一个经济因素需要考虑。如果你在公有云中运行应用程序,你通常会被根据你消耗的资源数量来收费。因此,你可能希望控制每个容器可以使用的 CPU 和内存量,以避免账单到来时的意外。

当涉及到像 Kubernetes 这样的编排器时,还有一个与资源相关的关键问题,您应该考虑。Kubernetes 会调度 Pod 部署到集群的任何节点。但如果一个 Pod 被分配到一个资源不足的节点,无法正确运行容器怎么办?解决方案是声明容器运行所需的最小 CPU 和内存(资源请求)。只有当 Kubernetes 可以保证容器至少获得请求的资源时,它才会将 Pod 部署到特定的节点。

资源请求和限制是按容器定义的。您可以在部署清单中指定请求和限制。由于我们一直在本地环境中运行,并且不想在资源需求方面过度限制它,所以我们尚未为目录服务的基础清单定义任何限制。然而,生产工作负载应始终包含资源配置。让我们看看我们如何为目录服务生产部署执行此操作。

为容器分配资源请求和限制

并不令人惊讶,我们将使用补丁来应用 CPU 和内存配置到目录服务。在目录服务的生产覆盖层(kubernetes/applications/catalog-service/production)中创建一个 patch-resources.yml 文件,并定义容器资源的请求和限制。尽管我们考虑的是生产场景,但我们会使用较低的值来优化您集群中的资源使用,并避免产生额外的成本。在现实世界的场景中,您可能需要仔细分析哪些请求和限制适合您的用例。

列表 15.14 为容器配置资源请求和限制

apiVersion: apps/v1
kind: Deployment
metadata:
  name: catalog-service
spec:
  template:
    spec:
      containers:
        - name: catalog-service
          resources:
            requests:           ❶
              memory: 756Mi     ❷
              cpu: 0.1          ❸
            limits:             ❹
              memory: 756Mi     ❺
              cpu: 2            ❻

❶ 容器运行所需的最小资源量

❷ 容器保证获得 756 MiB。

❸ 容器保证获得相当于 0.1 个 CPU 的 CPU 周期。

❹ 容器允许消耗的最大资源量

❺ 容器最多可以消耗 756 MiB。

❻ 容器最多可以消耗相当于 2 个 CPU 的 CPU 周期。

接下来,打开目录服务的生产覆盖层中的 kustomization.yml 文件,并配置 Kustomize 应用补丁。

列表 15.15 应用定义资源请求和限制的补丁

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - github.com/<your_github_username>/catalog-service/k8s?ref=<release_sha>

patchesStrategicMerge:
  - patch-env.yml
  - patch-resources.yml     ❶
  - patch-volumes.yml

configMapGenerator:
  - behavior: merge
    files:
      - application-prod.yml
    name: catalog-config

images:
  - name: catalog-service
    newName: ghcr.io/<your_github_username>/catalog-service
    newTag: <release_sha>

replicas:
  - name: catalog-service
    count: 2

❶ 配置资源请求和限制

在列表 15.14 中,内存请求和限制是相同的,但 CPU 并不是这样。接下来的部分将解释这些选择的理由。

优化 Spring Boot 应用程序的 CPU 和内存

容器可用的 CPU 数量直接影响基于 JVM 的应用程序(如 Spring Boot)的启动时间。实际上,JVM 利用所有可用的 CPU 来并行执行初始化任务,从而减少启动时间。启动阶段之后,应用程序将使用远低于 CPU 资源。

一种常见的策略是定义 CPU 请求(resources.requests.cpu)为应用程序在正常条件下将使用的数量,以确保始终保证有足够的资源来正确运行。然后,根据系统,你可以决定指定更高的 CPU 限制或完全省略它(resources.limits.cpu),以优化启动时的性能,使应用程序能够使用节点上当时可用的所有 CPU。

CPU 是一种 可压缩资源,这意味着容器可以消耗其可用量的任何部分。当它达到限制(无论是由于 resources.limits.cpu 还是节点上没有更多的 CPU 可用),操作系统开始限制容器进程,使其继续运行,但可能性能会降低。由于它是可压缩的,因此有时不指定 CPU 限制可以是一个有效的选项,以获得性能提升。然而,你可能会想考虑特定的场景并评估这种决定的后果。

与 CPU 不同,内存 是一种 不可压缩资源。如果一个容器达到限制(无论是由于 resources.limits.memory 还是节点上没有更多的内存可用),基于 JVM 的应用程序将抛出可怕的 OutOfMemoryError,操作系统将以 OOMKilled(OutOfMemory killed)状态终止容器进程。没有限制。因此,设置正确的内存值尤为重要。没有捷径可以推断出正确的配置;你必须监控在正常条件下运行的应用程序。这对 CPU 和内存都适用。

一旦你找到了适合你应用程序所需内存的合适值,我建议你将其同时用作请求(resources.requests.memory)和限制(resources.limits.memory)。这样做的原因与 JVM 的工作方式密切相关,尤其是 JVM 堆内存的行为。动态地增加和减少容器内存将影响应用程序的性能,因为堆内存是基于容器可用的内存动态分配的。使用相同的值作为请求和限制确保始终保证固定数量的内存,从而提高 JVM 性能。此外,它允许 Paketo Buildpacks 提供的 Java 内存计算器以最有效的方式配置 JVM 内存。

我已经多次提到了 Java 内存计算器。接下来的部分将扩展这个主题。

配置 JVM 资源

Spring Boot 插件用于 Gradle/Maven 的 Paketo Buildpacks 在构建 Java 应用程序的容器镜像时提供了一个 Java 内存计算器组件。这个组件实现了一个经过多年精炼和改进的算法,这得益于 Pivotal(现在是 VMware Tanzu)在云中运行容器化 Java 工作负载的经验。

在生产场景中,默认配置对于大多数应用程序来说是一个好的起点。然而,对于本地开发或演示来说,它可能过于资源密集。减少 JVM 消耗资源的一种方法是将强制应用程序的默认 250 JVM 线程数降低。因此,我们一直在使用 BPL_JVM_THREAD_COUNT 环境变量来为极地书店的两个基于 Servlet 的应用程序(目录服务和配置服务)配置较少的线程数。反应式应用程序已经配置了较少的线程,因为它们比它们的强制对应物更加资源高效。因此,我们没有为边缘服务、订单服务或调度服务定制线程数。

注意 Paketo 团队正在努力扩展 Java 内存计算器以提供低配置模式,这对于在本地工作或处理低流量应用程序时非常有帮助。将来,将能够通过一个标志来控制内存配置模式,而不是必须调整单个参数。你可以在 Paketo Buildpacks 的 GitHub 项目上找到更多关于此功能的信息(mng.bz/5Q87)。

JVM 有两个主要的内存区域:堆和非堆。计算器专注于根据特定公式计算不同非堆内存部分的值。剩余的内存资源分配给堆。如果默认配置不够好,你可以按自己的喜好进行自定义。例如,我在处理 Redis 会话管理的强制应用程序中遇到了一些内存问题。它需要的直接内存比默认配置的多。在这种情况下,我通过 JAVA_TOOL_OPTIONS 环境变量使用了标准的-XX:MaxDirectMemorySize=50M JVM 设置,并将直接内存的最大大小从 10 MB 增加到 50 MB。如果你自定义了特定内存区域的大小,计算器将相应地调整剩余区域的分配。

注意在 JVM 中内存处理是一个引人入胜的话题,要完全涵盖它可能需要一本自己的书。因此,我不会深入讲解如何配置它。

由于我们正在为生产环境配置部署,让我们使用一个更合适的数字,例如 100,来更新目录服务的线程数。在实际场景中,我建议从默认值 250 作为基准开始。对于极地书店,我正在尝试在展示实际生产部署的样子和最小化你在公共云平台上需要消耗(或许需要支付)的资源之间取得妥协。

我们可以在之前定义的补丁中更新目录服务的线程数以自定义环境变量。打开目录服务(kubernetes/applications/catalog-service/production)的生产覆盖层中的 patch-env.yml 文件,并按以下方式更新 JVM 线程数。

列表 15.16 Java 内存计算器使用的 JVM 线程数

apiVersion: apps/v1
kind: Deployment
metadata:
  name: catalog-service
spec:
  template:
    spec:
      containers:
        - name: catalog-service
          env:
            - name: BPL_JVM_THREAD_COUNT    ❶
              value: "100" 
            - name: SPRING_PROFILES_ACTIVE
              value: prod

❶ 在内存计算中考虑的线程数

那是我们部署应用程序到生产之前需要做的最后一个配置更改。我们将在下一步进行。

15.2.3 在生产中部署 Spring Boot

我们的目标是自动化从代码提交到生产的整个流程。在查看部署管道的生产阶段之前,让我们通过手动在生产中部署目录服务来验证我们迄今为止定义的自定义设置是否正确。

如您在前一章所学,我们可以使用 Kubernetes CLI 从 Kustomization 覆盖中部署 Kubernetes 上的应用程序。打开一个终端窗口,导航到目录服务的生产覆盖文件夹(polar-deployment/kubernetes/applications/catalog-service/production),并运行以下命令通过 Kustomize 部署应用程序:

$ kubectl apply -k .

您可以通过运行此命令来跟踪它们的进度,并查看两个应用实例何时准备好接受请求:

$ kubectl get pods -l app=catalog-service --watch

关于部署的更多信息,您可以使用 Kubernetes CLI 或依赖 Octant,这是一个允许您通过方便的 GUI 可视化 Kubernetes 工作负载的工具。如第七章所述,您可以使用命令 octant 启动 Octant。此外,应用程序日志可能对验证目录服务是否正确运行很有趣:

$ kubectl logs deployment/catalog-service

应用程序尚未对外集群公开(为此,我们需要边缘服务),但您可以使用端口转发功能将本地环境上的端口 9001 的流量转发到集群中运行的端口 80 上的服务:

$ kubectl port-forward service/catalog-service 9001:80

注意:由 kubectl port-forward 命令启动的过程将一直运行,直到您使用 Ctrl-C 显式停止它。

现在,您可以从本地机器上的端口 9001 调用目录服务,请求将被转发到 Kubernetes 集群内的服务对象。打开一个新的终端窗口,调用应用程序公开的根端点,以验证 prod Spring 配置文件中指定的 polar.greeting 值被用于默认值:

$ http :9001/
Welcome to our book catalog from a production Kubernetes environment!

恭喜!您正式进入生产阶段!完成操作后,您可以使用 Ctrl-C 终止端口转发。最后,从目录服务的生产覆盖文件夹运行以下命令删除部署:

$ kubectl delete -k .

Kubernetes 为实施不同类型的部署策略提供了基础设施。当我们使用新版本更新应用程序清单并将其应用到集群中时,Kubernetes 执行一个滚动更新。此策略通过增量更新 Pod 实例来保证使用新实例,并确保用户零停机时间。您在前一章中已经看到了这一过程。

默认情况下,Kubernetes 采用滚动更新策略,但你也可以根据标准的 Kubernetes 资源使用其他技术,或者依赖 Knative 这样的工具。例如,你可能想使用蓝/绿部署,即在第二个生产环境中部署软件的新版本。通过这样做,你可以最后一次测试确保一切运行正确。当环境准备就绪时,你将流量从第一个(蓝色)生产环境转移到第二个(绿色)生产环境。¹

另一种部署技术是金丝雀发布。它与蓝/绿部署类似,但蓝色到绿色环境的流量会随着时间的推移逐渐移动。目标是首先将更改部署给一小部分用户,进行一些验证,然后为越来越多的用户重复此过程,直到所有人都使用新版本。² 蓝色/绿色部署和金丝雀发布都提供了一种简单的方法来回滚更改。

注意:如果你对在 Kubernetes 上的部署和发布策略感兴趣,我建议阅读 Mauricio Salatino 所著的《Kubernetes 的持续交付》第五章,由 Manning 出版(livebook.manning.com/book/continuous-delivery-for-kubernetes/chapter-5)。

目前,每次提交更改时,都会发布一个新的候选版本,如果它成功通过提交和接受阶段,最终会被批准。然后你需要复制新候选版本的版本号,并将其粘贴到 Kubernetes 清单中,这样你才能手动更新生产环境中的应用程序。在下一节中,你将看到如何通过实现部署管道的最后一部分:生产阶段来自动化这个过程。

15.3 部署管道:生产阶段

我们从第三章开始实施部署管道,从那时起我们已经走了很长的路。我们已经自动化了从代码提交到准备好生产环境候选版本的所有步骤。到目前为止,我们仍然有两个手动操作:使用新应用程序版本更新生产脚本,并将其部署到 Kubernetes。

在本节中,我们将开始探讨部署管道的最后一部分,即生产阶段,我将向你展示如何在 GitHub Actions 中将其实现为一个工作流程。

15.3.1 理解部署管道的生产阶段

在候选版本经过提交和接受阶段之后,我们对其部署到生产环境有足够的信心。生产阶段可以手动或自动触发,这取决于你是否希望实现持续部署

持续交付 是“一种软件开发学科,你以这种方式构建软件,使得软件可以随时发布到生产。”³ 关键部分是理解软件 可以 发布到生产,但它 不必。这是持续交付和持续部署之间常见的混淆来源。如果你还想自动将最新发布候选版本部署到生产,那么你将拥有 持续部署

生产阶段由两个主要步骤组成:

  1. 使用新版本更新部署脚本(在我们的案例中,是 Kubernetes 清单)。

  2. 将应用程序部署到生产环境。

注意:可选的第三步是运行一些最终自动化测试,以验证部署是否成功。也许你可以重用你将在验收阶段包含的系统测试,以验证在预发布环境中的部署。

下一个部分将向您展示如何使用 GitHub Actions 实现生产阶段的第一步,并且我们将讨论第二步的一些实现策略。我们的目标是自动化从代码提交到生产的整个流程,并实现持续部署。

15.3.2 使用 GitHub Actions 实现生产阶段

与之前阶段相比,实现部署管道的生产阶段可能因几个因素而大不相同。让我们首先关注生产阶段的第一步。

在验收阶段结束时,我们有一个经过验证的发布候选版本,表明其已准备好投入生产。之后,我们需要更新生产叠加中的 Kubernetes 清单以包含新版本。当我们保持应用程序源代码和部署脚本在同一个仓库中时,生产阶段可以监听 GitHub 在验收阶段成功完成后发布的特定事件,就像我们配置提交和验收阶段之间的流程一样。

在我们的案例中,我们将部署脚本保存在一个单独的仓库中,这意味着每当应用程序仓库中的验收阶段工作流程完成执行后,我们需要通知部署仓库中的生产阶段工作流程。GitHub Actions 提供了通过自定义事件实现此通知过程的选项。让我们看看它是如何工作的。

打开您的目录服务项目(catalog-service),进入 .github/workflows 文件夹中的 acceptance-stage.yml 文件。在所有验收测试成功运行后,我们必须定义一个最终步骤,该步骤将向 polar-deployment 仓库发送通知,并要求它使用新版本更新目录服务生产清单。这将触发生产阶段,我们将在稍后实现它。

列表 15.17 在部署仓库中触发生产阶段

name: Acceptance Stage
on:
  workflow_run:
    workflows: ['Commit Stage']
    types: [completed]
    branches: main
concurrency: acceptance

env:                                                                 ❶
  OWNER: <your_github_username> 
  REGISTRY: ghcr.io 
  APP_REPO: catalog-service 
  DEPLOY_REPO: polar-deployment 
  VERSION: ${{ github.sha }} 

jobs:
  functional:
    ...
  performance:
    ...
  security:
    ...
  deliver: 
    name: Deliver release candidate to production 
    needs: [ functional, performance, security ]                     ❷
    runs-on: ubuntu-22.04 
    steps: 
      - name: Deliver application to production 
        uses: peter-evans/repository-dispatch@v2                     ❸
        with: 
          token: ${{ secrets.DISPATCH_TOKEN }}                       ❹
          repository: 
          ➥ ${{ env.OWNER }}/${{ env.DEPLOY_REPO }}                 ❺
          event-type: app_delivery                                   ❻
          client-payload: '{                                         ❼
            "app_image": 
              "${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.APP_REPO }}", 
            "app_name": "${{ env.APP_REPO }}", 
            "app_version": "${{ env.VERSION }}" 
          }' 

❶ 定义相关数据作为环境变量

❷ 仅当所有功能性和非功能性验收测试都成功完成后运行

❸ 一个向另一个仓库发送事件并触发工作流程的操作

❹ 一个令牌,授予操作向另一个仓库发送事件的权限

❺ 通知的仓库

❻ 用于识别事件的名称(这取决于您)

❼ 发送到其他仓库的消息的有效负载。添加其他仓库可能需要执行其操作的信息。

通过这一新步骤,如果在执行验收测试期间没有发现错误,则会向 polar-deployment 仓库发送通知以触发目录服务的更新。

默认情况下,GitHub Actions 不允许您触发位于其他仓库中的工作流程,即使它们都属于您或您的组织。因此,我们需要向 repository-dispatch 动作提供一个访问令牌,以授予其这样的权限。该令牌可以是一个个人访问令牌(PAT),这是我们第六章中使用的 GitHub 工具。

请访问您的 GitHub 账户,导航到设置 > 开发者设置 > 个人访问令牌,并选择生成新令牌。输入一个有意义的名称,并分配工作流程范围以授予令牌在其他仓库中触发工作流程的权限(图 15.6)。最后,生成令牌并复制其值。GitHub 只会向您展示一次令牌值。请确保您保存它,因为您很快就会需要它。

15-06

图 15.6 授予在其他仓库中触发工作流程权限的个人访问令牌(PAT)

接下来,前往您的 GitHub 上的 Catalog Service 仓库,导航到设置选项卡,然后选择 Secrets > Actions。在该页面上,选择新建仓库密钥,命名为 DISPATCH_TOKEN(与我们在列表 15.17 中使用的名称相同),并输入您之前生成的 PAT 的值。通过 GitHub 提供的密钥功能,我们可以安全地将 PAT 提供给验收阶段的工作流程。

警告 如第三章所述,当使用 GitHub 市场上的操作时,您应像处理任何其他第三方应用程序一样处理它们,并相应地管理安全风险。在验收阶段,我们向一个具有操作仓库和工作流程权限的第三方操作提供了访问令牌。您不应轻率地这样做。在这种情况下,我信任该操作的开发者,并决定信任该操作与令牌。

请勿将您的更改提交到 catalog-service 仓库。我们稍后再做。到目前为止,我们已经实现了生产阶段的触发器,但尚未初始化最终阶段。让我们继续转到 Polar Deployment 仓库并完成它。

打开你的 Polar 部署项目(polar-deployment),在新的 .github/workflows 文件夹内创建一个 production-stage.yml 文件。每当应用存储库中的接受阶段触发 app_delivery 事件时,就会触发生产阶段。该事件本身包含有关应用名称、镜像和最新发布候选版本的相关信息。由于应用特定信息是参数化的,因此我们可以使用此工作流程为 Polar 书店系统的所有应用提供服务,而不仅仅是目录服务。

生产阶段的第一项任务是使用新的发布版本更新生产 Kubernetes 清单。这项工作将包括三个步骤:

  1. 检出 polar-deployment 源代码。

  2. 使用给定应用的最新版本更新生产 Kustomization。

  3. 将更改提交到 polar-deployment 存储库。

我们可以将这三个步骤实现如下。

列表 15.18 在新的应用交付时更新镜像版本

name: Production Stage

on:
  repository_dispatch:                                               ❶
    types: [app_delivery]

jobs:
  update:
    name: Update application version
    runs-on: ubuntu-22.04
    permissions:
      contents: write
    env:                                                             ❷
      APP_IMAGE: ${{ github.event.client_payload.app_image }}
      APP_NAME: ${{ github.event.client_payload.app_name }}
      APP_VERSION: ${{ github.event.client_payload.app_version }}
    steps:
      - name: Checkout source code
        uses: actions/checkout@v3                                    ❸
      - name: Update image version
        run: |
          cd \                                                       ❹
            kubernetes/applications/${{ env.APP_NAME }}/production
          kustomize edit set image \                                 ❺
            ${{ env.APP_NAME }}=${{ env.APP_IMAGE }}:${{ env.APP_VERSION }}
          sed -i 's/ref=[\w+]/${{ env.APP_VERSION }}/' \
            kustomization.yml                                        ❻
      - name: Commit updated manifests
        uses: stefanzweifel/git-auto-commit-action@v4                ❼
        with:                                                        ❽
          commit_message: "Update ${{ env.APP_NAME }}
➥to version ${{ env.APP_VERSION }}"
          branch: main

❶ 仅在接收到来自另一个存储库的新 app_delivery 事件时执行工作流程

❷ 将事件有效负载数据保存为环境变量以方便使用

❸ 检出存储库

❹ 导航到给定应用的生产覆盖层

❺ 通过 Kustomize 更新给定应用的镜像名称和版本

❻ 更新 Kustomize 使用的标签,以访问存储在应用存储库中的正确基本清单

❼ 从上一步骤应用更改并提交和推送当前存储库的更改的操作

❽ 提交操作的详细信息

目前我们需要的就这些了。将更改提交并推送到 GitHub 上的远程 polar-deployment。然后回到你的目录服务项目,将之前的更改提交到接受阶段,并将它们推送到 GitHub 上的远程 catalog-service。

对 catalog-service 存储库的新提交将触发部署管道。首先,提交阶段将生成一个容器镜像(我们的发布候选版本)并将其发布到 GitHub 容器注册库。然后,接受阶段将对应用进行进一步的测试,并最终向 polar-deployment 存储库发送通知(自定义的 app_delivery 事件)。该事件触发生产阶段,将更新目录服务的生产 Kubernetes 清单并将更改提交到 polar-deployment 存储库。图 15.7 说明了部署管道三个阶段的输入和输出。

15-07

图 15.7 提交阶段从代码提交到发布候选版本,然后进入接受阶段。如果它通过了所有测试,生产阶段将更新部署清单。

前往你的 GitHub 项目,并跟踪三个阶段的执行过程。最后,你会在 polar-deployment 仓库中找到一个新提交,这是由 GitHub Actions 提交的,其中包含对 Catalog Service 生产叠加层的更改,使其使用最新的发布版本。

完美!我们刚刚消除了剩余的两个手动步骤中的第一个:使用最新发布版本更新部署脚本。我们仍然需要手动使用 Kubernetes CLI 将 Kubernetes 清单应用到集群中。生产阶段第二步将负责在将新版本提升到生产时自动部署应用程序。这是下一节的主题。

Polar Labs

是时候将你在这节中学到的知识应用到 Edge Service、Dispatcher Service 和 Order Service 中了。

  1. 为每个应用程序生成具有工作流程范围的 PAT。不要重复使用令牌进行多个目的,这是一种安全最佳实践。

  2. 对于每个应用程序,将 PAT 作为秘密从 GitHub 仓库页面保存。

  3. 使用一个最终步骤更新验收阶段工作流程,该步骤将发送通知到生产阶段,其中包含有关最新发布候选版本的信息。

  4. 将你的更改推送到 GitHub,确保工作流程成功完成,并检查 polar-deployment 仓库中的生产阶段工作流程是否正确触发。

Edge Service 是唯一可以通过公共互联网访问的应用程序,并且需要额外的补丁来配置 Ingress 以阻止对集群外 Actuator 端点的请求。你可以从 applications/edge-service/production 文件夹中的 Chapter15/15-end/polar-deployment 获取额外的补丁。

为了简单起见,我们假设 Actuator 端点在集群内无需身份验证即可访问。像 Catalog Service 这样的内部应用程序不受影响,因为它们的 Actuator 端点无法通过 Spring Cloud Gateway 访问。另一方面,Edge Service 的那些目前可以通过公共互联网访问。

这在生产环境中并不安全。一种简单的修复方法是配置 Ingress 以阻止对集群外 /actuator/** 端点的任何请求。它们仍然可以从集群内部访问,以便健康检查可以工作。我们使用基于 NGINX 的 Ingress Controller,因此我们可以使用其配置语言来为 Actuator 端点表达一个 拒绝规则

在伴随书籍的源代码仓库中,你可以在 Chapter15/15-end 文件夹中检查最终结果 (github.com/ThomasVitale/cloud-native-spring-in-action)。

15.4 使用 GitOps 进行持续部署

传统上,持续部署是通过在部署管道的生产阶段添加额外步骤来实现的。这个额外的步骤会与目标平台(如虚拟机或 Kubernetes 集群)进行认证并部署应用程序的新版本。近年来,一种不同的方法越来越受欢迎:GitOps。这个术语是由 Weaveworks 的首席执行官和创始人 Alexis Richardson 提出的(www.weave.works)。

GitOps 是一套操作和管理软件系统的实践,它使持续交付和部署成为可能,同时确保敏捷性和可靠性。与传统方法相比,GitOps 更倾向于交付和部署之间的解耦。不是管道推送部署到平台,而是平台本身从源代码库拉取期望状态并执行部署。在前一种情况下,部署步骤是在生产阶段工作流程中实现的。在后一种情况下,我们将重点关注,部署在理论上仍被视为生产阶段的一部分,但其实现方式不同。

GitOps 不强制使用特定技术,但最好与 Git 和 Kubernetes 一起实现。这将是我们的重点。

CNCF 的一部分,GitOps 工作组将 GitOps 定义为四个原则(opengitops.dev):

  1. 声明式—“由 GitOps 管理的系统必须以声明式表达其期望状态。”

    • 与 Kubernetes 一起工作,我们可以通过 YAML 文件(清单)表达期望状态。

    • Kubernetes 清单声明了我们想要实现的内容,而不是如何实现。平台负责找到实现期望状态的方法。

  2. 版本化和不可变—“期望状态以强制不可变、版本化和保留完整版本历史记录的方式存储。”

    • Git 是确保期望状态版本化和保留整个历史记录的首选选择。这使得轻松回滚到先前状态成为可能。

    • 存储在 Git 中的期望状态是不可变的,并且代表单一的真实来源。

  3. 自动拉取—“软件代理自动从源代码中拉取期望状态声明。”

    • 软件代理(GitOps 代理)的例子包括 Flux (fluxcd.io)、Argo CD (argoproj.github.io/cd)和 kapp-controller (carvel.dev/kapp-controller)。

    • 我们不是授予 CI/CD 工具(如 GitHub Actions)对集群的完全访问权限或手动运行命令,而是授予 GitOps 代理对 Git 等源代码的访问权限,以便它自动拉取更改。

  4. 持续协调—“软件代理持续观察实际系统状态并尝试应用期望状态。”

    • Kubernetes 由控制器组成,它们持续观察系统并确保集群的实际状态与期望状态相匹配。

    • 在此基础上,GitOps 确保集群中考虑的期望状态是正确的。每当 Git 源检测到变化时,代理就会介入并与集群协调期望状态。

图 15.8 展示了应用 GitOps 原则的结果。

15-08

图 15.8 每当生产阶段工作流程更新部署仓库时,GitOps 控制器都会对期望状态和实际状态进行协调。

如果你考虑这四个原则,你会注意到我们已经应用了前两个。我们使用 Kubernetes 清单和 Kustomize 声明性地表达了我们应用程序的期望状态,并将其存储在 GitHub 上的 Git 仓库(polar-deployment)中,使其版本化和不可变。我们仍然缺少一个软件代理,它会自动从 Git 源拉取期望状态声明,并在 Kubernetes 集群内部持续协调它们,从而实现持续部署。

我们将首先安装 Argo CD(argo-cd.readthedocs.io),这是一个 GitOps 软件代理。然后我们将配置它以完成部署管道的最后一步,并让它监控我们的 polar-deployment 仓库。每当应用程序清单发生变化时,Argo CD 都会将更改应用到我们的生产 Kubernetes 集群。

15.4.1 使用 Argo CD 实现 GitOps

让我们先安装 Argo CD CLI。请参考项目网站上的安装说明(argo-cd.readthedocs.io)。如果你使用的是 macOS 或 Linux,你可以使用 Homebrew 如下操作:

$ brew install argocd

我们将使用 CLI 来指示 Argo CD 监控哪个 Git 仓库,并将其配置为自动将更改应用到集群中,以实现持续部署。但首先我们需要将 Argo CD 部署到生产 Kubernetes 集群。

注意,我将假设你的 Kubernetes CLI 仍然配置为访问 DigitalOcean 上的生产集群。你可以使用 kubectl config current-context 来检查这一点。如果你需要更改上下文,你可以运行 kubectl config use-context 。可以通过 kubectl config get-contexts 获取所有可用上下文的列表。

打开一个终端窗口,进入你的 Polar 部署项目(polar-deployment),然后导航到 kubernetes/platform/production/argocd 文件夹。当你设置生产集群时,你应该已经将此文件夹复制到你的仓库中。如果不是这种情况,请现在从本书附带源代码仓库(Chapter15/15-end/polar-deployment/platform/production/argocd)中执行此操作。

然后运行以下脚本将 Argo CD 安装到生产集群中。在运行之前,你可以自由地打开文件并查看说明:

$ ./deploy.sh

提示:你可能需要首先使用命令 chmod +x deploy.sh 使脚本可执行。

Argo CD 的部署由几个组件组成,包括一个方便的 Web 界面,你可以在这里可视化并控制所有由 Argo CD 控制的部署。目前,我们将使用 CLI。在安装过程中,Argo CD 将为管理员账户(用户名为 admin)自动生成密码。运行以下命令以获取密码值(在值可用之前可能需要几秒钟):

$ kubectl -n argocd get secret argocd-initial-admin-secret \
    -o jsonpath="{.data.password}" | base64 -d; echo

接下来,让我们确定分配给 Argo CD 服务器的公网 IP 地址:

$ kubectl -n argocd get service argocd-server

NAME            TYPE           CLUSTER-IP     EXTERNAL-IP
argocd-server   LoadBalancer   10.245.16.74   <external-ip>

平台可能需要几分钟来为 Argo CD 配置负载均衡器。在配置过程中,EXTERNAL-IP 列将显示 <待处理> 状态。等待并重试,直到显示 IP 地址。注意记录下来,因为我们很快就会用到它。

由于 Argo CD 服务器现在通过公共负载均衡器公开,我们可以使用外部 IP 地址来访问其服务。对于此示例,我们将使用 CLI,但你也可以通过在浏览器窗口中打开 (分配给你的 Argo CD 服务器的 IP 地址)来实现相同的结果。无论如何,你都需要使用自动生成的管理员账户登录。用户名是 admin,密码是之前获取的密码。请注意,你可能会收到警告,因为你没有使用 HTTPS:

$ argocd login <argocd-external-ip>

现在是时候通过 GitOps 看一下持续部署的实际操作了。我将假设你已经阅读了本章的所有前几节。到目前为止,你的 GitHub(catalog-service)目录服务仓库的提交阶段应该已经构建了一个容器镜像,验收阶段应该已经触发了 GitHub(polar-deployment)上的 Polar Deployment 仓库,生产阶段应该已经使用最新的发布版本(polar-deployment/kubernetes/applications/catalog-service/production)更新了目录服务的生产覆盖层。现在我们将配置 Argo CD 监控目录服务的生产覆盖层,并在检测到存储库中的更改时与生产集群同步。换句话说,Argo CD 将持续部署由部署管道提供的目录服务的新版本。

$ argocd app create catalog-service \                                   ❶
  --repo \                                                              ❷
    https://github.com/<your_github_username>/polar-deployment.git \
  --path kubernetes/applications/catalog-service/production \           ❸
  --dest-server https://kubernetes.default.svc \                        ❹
  --dest-namespace default \                                            ❺
  --sync-policy auto \                                                  ❻
  --auto-prune                                                          ❼

❶ 在 Argo CD 中创建 catalog-service 应用程序

❷ 监控更改的 Git 仓库。插入你的 GitHub 用户名。

❸ 监控配置的存储库内更改的文件夹

❹ 应该部署应用程序的 Kubernetes 集群。我们正在使用 kubectl 上下文中配置的默认集群。

❺ 应该部署应用程序的命名空间。我们正在使用“默认”命名空间。

❻ 配置 Argo CD 自动将 Git 仓库中期望的状态与集群中的实际状态进行协调

❼ 配置 Argo CD 在同步后自动删除旧资源

您可以使用以下命令验证 Catalog Service 的持续部署状态(为了清晰起见,我已经过滤了结果):

$ argocd app get catalog-service

GROUP  KIND        NAMESPACE  NAME                       STATUS  HEALTH
       ConfigMap   default    catalog-config-6d5dkt7577  Synced
       Service     default    catalog-service            Synced  Healthy
apps   Deployment  default    catalog-service            Synced  Healthy

Argo CD 已自动将 Catalog Service(polar-deployment/kubernetes/applications/catalog-service/production)的生产叠加应用到集群中。

一旦前一个命令列出的所有资源都达到同步状态,我们就可以验证应用程序是否运行正确。应用程序尚未在集群外部暴露,但您可以使用端口转发功能将本地环境上的端口 9001 的流量转发到集群中运行在端口 80 的服务:

$ kubectl port-forward service/catalog-service 9001:80

接下来,调用应用程序暴露的根点。我们预计会得到我们在 Catalog Service 生产叠加中配置的 polar.greeting 属性的值。

$ http :9001/
Welcome to our book catalog from a production Kubernetes environment!

太棒了!我们一步就自动化了不仅第一次部署,还包括任何未来的更新。Argo CD 将检测 Catalog Service 生产叠加中的任何更改,并立即将新的清单应用到集群中。可能会有新的发布版本需要部署,但也可能是生产叠加的更改。例如,让我们尝试为 polar.greeting 属性配置不同的值。

打开你的 Polar Deployment 项目(polar-deployment),转到 Catalog 服务的生产叠加(kubernetes/applications/catalog-service/production),并在 application-prod.yml 文件中更新 polar.greeting 属性的值。

列表 15.19 更新应用程序的生产特定配置

polar:
  greeting: Welcome to our production book catalog 
  ➥ synchronized with Argo CD! 
spring:
  config:
    import: configtree:/workspace/secrets/*/

然后,将更改提交并推送到你在 GitHub 上的远程 polar-deployment 仓库。默认情况下,Argo CD 每三分钟检查 Git 仓库中的更改。它会注意到更改并重新应用 Kustomization,这将导致 Kustomize 生成新的 ConfigMap,并滚动重启 Pod 以刷新配置。一旦集群中的部署与 Git 仓库中的所需状态同步(你可以使用 argocd app get catalog-service 检查这一点),再次调用 Catalog Service 暴露的根端点。我们预计会得到我们刚刚更新的值。如果你得到网络错误,可能是端口转发过程被中断了。再次运行 kubectl port-forward service/catalog-service 9001:80 来修复它:

$ http :9001/
Welcome to our production book catalog synchronized with Argo CD!

太好了!我们终于实现了持续部署!暂停一分钟,用你喜欢的饮料庆祝一下。你应得的!

Polar Labs

是时候将本节中学到的知识应用到 Edge 服务、Dispatcher 服务和 Order 服务上了。

  1. 使用 Argo CD CLI,像为 Catalog Service 那样注册剩余的每个应用程序。记住,首先需要按照前面解释的进行认证。

  2. 对于每个应用程序,验证 Argo CD 是否已将 polar-deployment 仓库中的所需状态与集群中的实际状态同步。

如果 Argo CD 出现问题,您可以使用 argocd app get catalog-service 命令来验证同步状态,或者直接使用在上可用的 Web 界面。对于 Kubernetes 资源的故障排除,您可以利用 Octant 或使用第七章最后部分中解释的技术之一。

15.4.2 整合所有内容

如果您跟随步骤并完成了所有 Polar Labs,那么现在您已经在公共云中的生产 Kubernetes 集群中运行了整个 Polar Bookshop 系统。这是一个巨大的成就!在本节中,我们将尝试并完善一些最后要点。图 15.9 显示了通过之前发现的地址可访问的 Argo CD GUI 中的应用程序状态。

15-09

图 15.9 Argo CD GUI 显示了通过 GitOps 流程管理的所有应用的概览。

到目前为止,我们与目录服务一起工作,这是一个不暴露在集群外部的内部应用程序。因此,我们依赖端口转发功能来测试它。现在整个系统已部署,我们可以按照预期访问应用程序:通过 Edge 服务。每次我们部署 Ingress 资源时,平台都会自动配置一个带有外部 IP 地址的负载均衡器。让我们发现 Edge 服务前面的 Ingress 的外部 IP 地址:

$ kubectl get ingress

NAME            CLASS   HOSTS   ADDRESS           PORTS   AGE
polar-ingress   nginx   *       <ip-address>      80      31m

使用 Ingress 的外部 IP 地址,您可以从公共互联网使用 Polar Bookshop。打开浏览器窗口并导航到

尝试以 Isabelle 的身份登录。您可以随意添加一些书籍并浏览目录。然后登出并再次登录,这次以 Bjorn 的身份。验证您无法创建或编辑书籍,但您可以下订单。

当您使用两个账户测试完应用程序后,请登出并确保您不能通过访问/actuator/health 等来访问 Actuator 端点。作为 Ingress Controller 的驱动技术,NGINX 将回复 403 响应。

注意:如果您想部署 Grafana 可观察性堆栈,请参阅本书附带的源代码存储库中的说明。

干得好!当您完成生产集群的使用后,请按照附录 B 的最后部分操作,从 DigitalOcean 删除所有云资源。这是避免意外费用的基本做法。

摘要

  • 持续交付背后的理念是应用程序始终处于可发布状态。

  • 当交付管道完成执行时,您将获得一个工件(容器镜像),您可以使用它来在生产环境中部署应用程序。

  • 在持续交付方面,每个发布候选版本都应该具有唯一标识。

  • 使用 Git 提交哈希,您可以确保唯一性、可追溯性和自动化。语义版本控制可以用作传达给用户和客户的显示名称

  • 在提交阶段结束时,一个发布候选版本会被提交到工件存储库。接下来,验收阶段会在类似生产的环境中部署应用程序,并运行功能性和非功能性测试。如果所有测试都通过,则发布候选版本就准备好投入生产。

  • Kustomize 对配置定制的处理方法基于基础和覆盖的概念。覆盖是在基础清单之上构建的,并通过补丁进行定制。

  • 您已经看到了如何定义补丁来定制环境变量、作为卷挂载的 Secrets、CPU 和内存资源、ConfigMaps 以及 Ingress。

  • 部署管道的最后一部分是生产阶段,在这个阶段,部署清单会更新为最新的发布版本,并最终部署。

  • 部署可以是基于推送的或基于拉取的。

  • GitOps 是一套用于操作和管理软件系统的实践。

  • GitOps 基于以下四个原则:系统部署应该是声明性的、版本化的、不可变的、自动拉取的,并且持续进行协调。

  • Argo CD 是一个在集群中运行的软件代理,它自动从源代码库拉取所需状态,并在两个状态发生分歧时将其应用到集群中。这就是我们实现持续部署的方式。


(1.) 请参阅 M. Fowler 的文章“BlueGreenDeployment”,发表于 MartinFowler.com,2010 年 3 月 1 日,mng.bz/WxOl.

(2.) 请参阅 D. Sato 的文章“CanaryRelease”,发表于 MartinFowler.com,2014 年 6 月 25 日,mng.bz/8Mz5.

(3.) 请参阅 M. Fowler 的文章“ContinuousDelivery”,发表于 MartinFowler.com,2013 年 5 月 30 日,mng.bz/7yXV.

16 无服务器、GraalVM 和 Knative

本章涵盖

  • 使用 Spring Native 和 GraalVM 生成原生镜像

  • 使用 Spring Cloud Function 构建无服务器应用

  • 使用 Knative 和 Kubernetes 部署无服务器应用

在上一章中,你完成了一次从开发到生产的漫长旅程。你使用 Spring 构建了云原生应用,并将它们部署在公共云中的 Kubernetes 集群上。本章的目的是为你提供一些额外的工具,以便从你的云原生应用中获得更多。

云基础设施的一个显著优势是你可以按需增加或减少资源,并且只需为使用的资源付费。Java 应用程序传统上非常资源密集,比 Go 等其他堆栈消耗更多的 CPU 和内存。现在不再是这样了。使用 GraalVM 和 Spring Native,你可以将你的 Spring Boot 应用程序编译成原生可执行文件,这些可执行文件比它们的 JVM 对应物性能更好、效率更高。本章的第一部分将指导你利用这项新技术。

本章的第二部分将扩展无服务器架构。与 CaaS 和 PaaS 基础设施相比,无服务器架构将大多数操作任务转移到平台,让开发者专注于应用程序。有些应用程序是自然的事件驱动的,并不总是忙于处理请求。或者它们可能会有突然的峰值,需要更多的计算资源。无服务器平台提供完全管理的自动扩展功能,可以将应用程序实例扩展到零,这样如果你没有要处理的内容,就不需要支付任何费用。你将了解更多关于无服务器模型的信息,并使用 Spring Native 和 Spring Cloud Function 构建一个无服务器应用程序。最后,你将了解如何使用基于 Kubernetes 的无服务器平台 Knative 来部署应用程序。

注意:本章中示例的源代码可在 Chapter16/16-begin 和 Chapter16/16-end 文件夹中找到,包含项目的初始和最终状态(github.com/ThomasVitale/cloud-native-spring-in-action)。

16.1 使用 Spring Native 和 GraalVM 生成原生镜像

Java 应用程序之所以变得非常流行,其中一个原因是它们有一个共同的平台(Java 运行时环境,或称 JRE),允许开发者“一次编写,到处运行”,无论操作系统如何。这源于应用程序的编译方式。Java 编译器不是直接将应用程序代码编译成机器代码(操作系统理解的代码),而是生成字节码,由一个专用组件(Java 虚拟机,或称 JVM)运行。在执行过程中,JRE 会动态地将字节码解释成机器代码,使得相同的应用程序可执行文件可以在任何有 JVM 的机器和操作系统上运行。这被称为 即时编译(JIT)

在 JVM 上运行的应用程序会受到启动和足迹成本的影响。传统应用程序的启动阶段曾经相当长,甚至可能需要几分钟。标准的云原生应用程序具有更快的启动阶段:几秒钟而不是几分钟。这对于大多数场景来说已经足够好了,但对于需要几乎瞬间启动的无服务器工作负载来说,这可以成为一个严重的问题。

标准的 Java 应用程序比 Go 等其他堆栈的足迹成本更高。云服务通常基于按使用付费的模式,因此减少 CPU 和内存足迹意味着降低成本。本节将向您展示如何使用 GraalVM 和 Spring Native 解决此问题。

16.1.1 理解 GraalVM 和原生图像

到目前为止,您已经使用了 JVM 和 OpenJDK 提供的工具,OpenJDK 有许多发行版,例如 Eclipse Adoptium(之前称为 AdoptOpenJDK)、BellSoft Liberica JDK 和 Microsoft OpenJDK。GraalVM 是 Oracle 基于 OpenJDK 的新发行版,它“旨在加速用 Java 和其他 JVM 语言编写的应用程序的执行”(www.graalvm.org)。

通过将标准 OpenJDK 发行版替换为 GraalVM 作为 Java 应用程序的运行环境,您可以通过 GraalVM 编译器(一种新的优化技术)进行 JIT 编译来提高它们的性能和效率。GraalVM 还提供了运行 JavaScript、Python 和 R 等其他语言代码的运行时。您甚至可以编写多语言应用程序,例如在 Java 代码中包含 Python 脚本。

GraalVM 提供两种主要的操作模式。JVM 运行时模式允许您像运行任何其他 OpenJDK 发行版一样运行您的 Java 应用程序,同时通过 GraalVM 编译器提高性能和效率。使 GraalVM 在无服务器环境中如此创新和受欢迎的是原生图像模式。与将 Java 代码编译成字节码并依赖 JVM 解释它并将其转换为机器码不同,GraalVM 提供了一种新技术(原生图像构建器),它将 Java 应用程序直接编译成机器码,获得包含执行所需全部机器码的原生可执行文件原生图像

将 Java 应用程序编译为原生镜像具有更快的启动时间、优化的内存消耗和与 JVM 选项相比的即时峰值性能。GraalVM 通过改变应用程序的编译方式来构建它们。与在运行时优化并生成机器代码的 JIT 编译器不同,原生镜像 模式基于 提前编译 (AOT)。从 main() 方法开始,所有在应用程序执行期间可到达的类和方法都会在构建时进行静态分析,并编译成一个独立的二进制可执行文件,包括任何依赖项和库。这样的可执行文件不在 JVM 上运行,而是直接在机器上运行,就像 C 或 C++ 应用程序一样。

当使用原生镜像时,以前由 JVM 在运行时执行的大部分工作现在在构建时完成。因此,将应用程序构建为原生可执行文件需要更长的时间,并且比 JVM 选项需要更多的计算资源。GraalVM AOT 编译器不支持一些 Java 功能。例如,反射、动态代理、序列化和动态类加载需要额外的配置来帮助 AOT 编译器了解如何静态分析它们。

我们如何将现有的 Java 应用程序调整为以原生镜像运行?支持框架和库需要多少配置?我们如何为 AOT 编译器提供必要的配置?这就是 Spring Native 出现的地方。

16.1.2 介绍 GraalVM 对 Spring Boot 的支持(通过 Spring Native)

Spring Native 是一个新项目,旨在支持使用 GraalVM 编译 Spring Boot 应用程序。Spring Native 的主要目标是使任何 Spring 应用程序都能使用 GraalVM 编译成原生可执行文件,而无需任何代码更改。为了实现这一目标,该项目提供了一个 AOT 基础设施(从专门的 Gradle/Maven 插件调用),为 GraalVM 提供了编译 Spring 类所需的所有配置。该项目是 Spring 产品组合的最新补充,目前处于测试版。在撰写本文时,大多数 Spring 库都得到了支持,以及像 Hibernate、Lombok 和 gRPC 这样的常用库。

对于尚未支持 Spring 库或您自己的代码,Spring Native 提供了配置 GraalVM 编译器的有用工具。例如,如果您在代码中使用反射或动态代理,GraalVM 将需要专门的配置来了解如何 AOT 编译它。Spring Native 提供了方便的注解,如 @NativeHints 和 @TypedHint,可以直接从您的 Java 代码中指导 GraalVM 编译器,利用 IDE 自动完成功能和类型检查。

注意,Spring Native 将从测试版阶段退出,并从 Spring Framework 6 和 Spring Boot 3 开始成为核心 Spring 库的一部分,预计将于 2022 年 12 月发布。

在本节中,我们将通过构建 Quote Service(一个暴露 API 以从书籍中获取引文的 Web 应用程序)来探索 Spring Native 的功能。

使用 Spring Native 和 Spring Reactive Web 启动新项目

您可以从 Spring Initializr(start.spring.io)初始化 Quote Service 项目,将结果存储在一个新的 quote-service Git 仓库中,并将其推送到 GitHub。初始化的参数如图 16.1 所示。

16-01

图 16.1 初始化 Quote Service 项目的参数

项目包含以下主要依赖项:

  • Spring Reactive Web 提供了构建使用 Spring WebFlux 的响应式 Web 应用程序所需的库,并且它包括 Netty 作为默认的嵌入式服务器。

  • Spring Native 支持使用 GraalVM native-image 编译器将 Spring 应用程序编译成原生可执行文件。

build.gradle 文件的最终依赖项部分如下:

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-webflux'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
  testImplementation 'io.projectreactor:reactor-test'
}

到目前为止,您可能会问:Spring Native 的依赖项在哪里?没有。Spring Native 在哪里?答案可以在 build.gradle 文件的插件部分找到:

plugins {
   id 'org.springframework.boot' version '2.7.3'
   id 'io.spring.dependency-management' version '1.0.13.RELEASE'
   id 'java'
   id 'org.springframework.experimental.aot' version '0.12.1'     ❶
}

❶ Spring Native 提供的 Spring AOT 插件

当您将 Spring Native 添加到项目中时,您将获得 Spring AOT 插件,该插件为 GraalVM 编译 Spring 类提供所需的配置,以及从 Gradle(或 Maven)构建原生可执行文件时的便利功能。

如果您从 Spring Initializr 启动新项目,HELP.md 文件还将提供有关如何使用 Spring Native 的额外信息。如果您选择了任何不受支持的依赖项,您将看到一个警告消息。例如,Spring Cloud Stream 在撰写本文时并不完全受支持。如果您使用 Spring Native 和 Spring Cloud Stream 初始化项目,HELP.md 文件将显示如下消息:

以下依赖项与 Spring Native 不兼容:'Cloud Stream'。因此,您的应用程序可能无法按预期工作。

注意:您可以在 Spring Native 官方文档(docs.spring.io/spring-native/docs/current/reference/htmlsingle)中查看哪些 Spring 库受支持。

接下来,让我们实现 Quote Service 的业务逻辑。

实现业务逻辑

Quote Service 将通过 REST API 返回随机的书籍引文。首先,创建一个新的 com.polarbookshop.quoteservice.domain 包,并定义一个 Quote 记录来表示领域实体。

列表 16.1 定义表示书籍引文的领域实体

public record Quote (
  String content,
  String author,
  Genre genre
){}

引文根据提取它们的书籍类型进行分类。添加一个 Genre 枚举来表示这种分类。

列表 16.2 定义表示书籍类型的枚举

public enum Genre {
  ADVENTURE,
  FANTASY,
  SCIENCE_FICTION
}

最后,在新的 QuoteService 类中实现检索书摘的业务逻辑。引言将被定义并存储在静态内存列表中。

列表 16.3 查询书摘业务逻辑

@Service
public class QuoteService {
  private static final Random random = new Random();
  private static final List<Quote> quotes = List.of(      ❶
    new Quote("Content A", "Abigail", Genre.ADVENTURE),
    new Quote("Content B", "Beatrix", Genre.ADVENTURE),
    new Quote("Content C", "Casper", Genre.FANTASY),
    new Quote("Content D", "Dobby", Genre.FANTASY),
    new Quote("Content E", "Eileen", Genre.SCIENCE_FICTION),
    new Quote("Content F", "Flora", Genre.SCIENCE_FICTION)
  );

  public Flux<Quote> getAllQuotes() {
    return Flux.fromIterable(quotes);                     ❷
  }

  public Mono<Quote> getRandomQuote() {
    return Mono.just(quotes.get(random.nextInt(quotes.size() - 1)));
  }

  public Mono<Quote> getRandomQuoteByGenre(Genre genre) {
    var quotesForGenre = quotes.stream()
      .filter(q -> q.genre().equals(genre))
      .toList();
    return Mono.just(quotesForGenre.get(
     random.nextInt(quotesForGenre.size() - 1)));
  }
}

❶ 在内存中存储引言列表

❷ 返回所有引言作为反应式数据流

注意:由于本例的重点是使用 GraalVM 和 Spring Native 进行原生图像编译,我们将保持简单,并跳过持久层。请随意自行扩展。例如,您可以添加 Spring Data R2DBC 和 Spring Security,它们都受 Spring Native 支持。

业务逻辑到此结束。接下来,我们将通过 HTTP API 暴露功能。

实现网络控制器

创建一个新的 com.polarbookshop.quoteservice.web 包,并添加一个 QuoteController 类来公开以下三个端点:

  • 返回所有引言

  • 返回一个随机引言

  • 返回给定类别的随机引言

列表 16.4 定义 HTTP 端点的处理器

@RestController
public class QuoteController {
  private final QuoteService quoteService;

  public QuoteController(QuoteService quoteService) {
    this.quoteService = quoteService;
  }

  @GetMapping("/quotes")
  public Flux<Quote> getAllQuotes() {
    return quoteService.getAllQuotes();
  }

  @GetMapping("/quotes/random")
  public Mono<Quote> getRandomQuote() {
    return quoteService.getRandomQuote();
  }

  @GetMapping("/quotes/random/{genre}")
  public Mono<Quote> getRandomQuote(@PathVariable Genre genre) {
    return quoteService.getRandomQuoteByGenre(genre);
  }
}

然后配置嵌入的 Netty 服务器以监听端口 9101,并定义应用程序名称。打开 application.yml 文件并添加以下配置。

列表 16.5 配置 Netty 服务器端口和应用程序名称

server:
  port: 9101

spring:
  application:
    name: quote-service

最后,让我们使用在第八章中学到的相同技术编写一些集成测试。

编写集成测试

当我们从 Spring Initializr 启动项目时,我们得到了一个自动生成的 QuoteServiceApplicationTests 类。让我们用一些集成测试来更新它,以检查 Quote 服务暴露的 REST API。

列表 16.6 Quote 服务的集成测试

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class QuoteServiceApplicationTests {

  @Autowired
  WebTestClient webTestClient;

  @Test
  void whenAllQuotesThenReturn() {
    webTestClient.get().uri("/quotes")
      .exchange()
      .expectStatus().is2xxSuccessful()
      .expectBodyList(Quote.class);
  }

  @Test
  void whenRandomQuoteThenReturn() {
    webTestClient.get().uri("/quotes/random")
      .exchange()
      .expectStatus().is2xxSuccessful()
      .expectBody(Quote.class);
  }

  @Test
  void whenRandomQuoteByGenreThenReturn() {
    webTestClient.get().uri("/quotes/random/FANTASY")
      .exchange()
      .expectStatus().is2xxSuccessful()
      .expectBody(Quote.class)
      .value(quote -> assertThat(quote.genre()).isEqualTo(Genre.FANTASY));
  }
}

实现到此结束。接下来,我们将执行自动测试并在 JVM 上运行应用程序。

在 JVM 上运行和测试

到目前为止,Quote 服务是一个标准的 Spring Boot 应用程序,与我们之前章节中构建的任何其他应用程序没有区别。例如,我们可以使用 Gradle 运行自动测试并确保其行为正确。打开一个终端窗口,导航到项目的根文件夹,并执行以下命令:

$ ./gradlew test

我们也可以在 JVM 上运行它或将其打包成 JAR 艺术品。在同一个终端窗口中,执行以下命令来运行应用程序:

$ ./gradlew bootRun

请随意验证应用程序是否通过调用 Quote 服务公开的端点正确工作:

$ http :9101/quotes
$ http :9101/quotes/random
$ http :9101/quotes/random/FANTASY

当你完成应用程序的测试后,使用 Ctrl-C 停止进程。

我们如何将其编译成原生可执行文件并利用即时启动时间、即时峰值性能和降低内存消耗?这是下一节的主题。

16.1.3 将 Spring Boot 应用程序编译为原生镜像

有两种方法可以将您的 Spring Boot 应用程序编译成原生可执行文件。第一种选项明确使用 GraalVM 并生成特定于操作系统的可执行文件,该文件可以直接在机器上运行。第二种选项依赖于云原生构建包来容器化原生可执行文件,并在容器运行时(如 Docker)上运行它。我们将使用这两种方法。

使用 GraalVM 编译原生可执行文件

第一个选项要求你的机器上必须有 GraalVM 运行时。你可以直接从网站(www.graalvm.org)安装,或者使用像 sdkman 这样的工具。你可以在附录 A 的 A.1 节中找到如何安装 sdkman 的说明。

对于本章中的示例,我将使用在写作时最新的 GraalVM 22.1 分发版,该分发版基于 OpenJDK 17。使用 sdkman,你可以按照以下步骤安装 GraalVM:

$ sdk install java 22.2.r17-grl

在安装过程结束时,sdkman 会询问你是否想将该分发版设置为默认版本。我建议你说不,因为我们将在需要使用 GraalVM 而不是标准 OpenJDK 时明确指出。

然后打开一个终端窗口,导航到你的 Quote Service 项目(quote-service),配置 shell 以使用 GraalVM,并按照以下步骤安装 native-image GraalVM 组件:

$ sdk use java 22.2.r17-grl    ❶
$ gu install native-image      ❷

❶ 配置当前 shell 以使用指定的 Java 运行时

❷ 使用 GraalVM 提供的 gu 工具安装 native-image 组件

当你初始化 Quote Service 项目时,自动包含了 GraalVM Gradle/Maven 官方插件。这就是提供使用 GraalVM Native Image 模式编译应用程序功能的插件。

注意:以下 Gradle 任务要求 GraalVM 是当前 Java 运行时。当使用 sdkman 时,你可以在想要使用 GraalVM 的终端窗口中运行 sdk use java 22.2.r17-grl 来实现这一点。

请注意,编译 GraalVM 应用程序的步骤会更长,根据你机器上可用的计算资源,可能需要几分钟。这是使用原生图像的一个缺点。另外,由于 Spring Native 仍然处于实验阶段,你可能会收到几个调试日志和警告,但如果过程成功完成,那应该没问题。

从你切换到 GraalVM 作为当前 Java 运行时的同一个终端窗口中,运行以下命令以将应用程序编译为原生图像:

$ ./gradlew nativeCompile

命令的结果是一个独立的二进制文件。由于它是一个原生可执行文件,它在 macOS、Linux 和 Windows 上会有所不同。你可以在你的机器上原生运行它,无需 JVM。在 Gradle 的情况下,原生可执行文件是在 build/native/nativeCompile 文件夹中生成的。现在就运行它吧。

$ build/native/nativeCompile/quote-service

首先要注意的是启动时间,通常小于 100 毫秒,与 JVM 选项相比,后者需要几秒钟。最好的部分是,我们不需要编写任何代码就能实现这一点!让我们发送一个请求以确保应用程序正在正确运行:

$ http :9101/quotes/random

当你完成应用程序的测试后,使用 Ctrl-C 停止进程。

你还可以将自动测试作为原生可执行文件运行,以使它们更加可靠,因为它们将使用实际的生产运行环境。然而,编译步骤仍然比在 JVM 上运行要长:

$ ./gradlew nativeTest

最后,你可以直接从 Gradle/Maven 运行 Spring Boot 应用程序作为原生图像:

$ ./gradlew nativeRun

在进入下一节之前,请记得使用 Ctrl-C 停止应用程序进程,下一节将展示另一种将你的 Spring Boot 应用程序编译为原生可执行文件的方法。这不需要在计算机上安装 GraalVM,并且将使用云原生 Buildpacks 生成容器化的原生可执行文件。

使用 Buildpacks 容器化原生图像

将 Spring Boot 应用程序编译为原生可执行文件的第二种方法依赖于云原生 Buildpacks。类似于我们在第六章中将 Spring Boot 应用程序打包为容器图像的方式,我们可以使用 Buildpacks 从由 GraalVM 编译的应用程序原生可执行文件构建容器图像。这种方法的好处是不需要在你的机器上安装 GraalVM。

当你启动 Quote Service 项目时,Spring Initializr 不仅包括了 Spring AOT 插件;它还提供了 Spring Boot 中可用的 Buildpacks 集成的额外配置。如果你再次检查 build.gradle 文件,你可以看到 bootBuildImage 任务被配置为通过 BP_NATIVE_IMAGE 环境变量生成容器化的原生图像。同时,配置图像名称和容器注册表身份验证,就像我们为其他 Polar Bookshop 应用程序所做的那样。

列表 16.7 容器化 Quote 服务的配置

tasks.named('bootBuildImage') {
  builder = 'paketobuildpacks/builder:tiny'    ❶
  environment = ['BP_NATIVE_IMAGE': 'true']    ❷
  imageName = "${project.name}" 

  docker { 
   publishRegistry { 
     username = project.findProperty("registryUsername") 
     password = project.findProperty("registryToken") 
     url = project.findProperty("registryUrl") 
   } 
  } 
} 

❶ 使用“小巧”版本的 Paketo Buildpacks 以最小化容器图像大小

❷ 启用 GraalVM 支持并生成容器化的原生图像

注意:当你可能在你的机器上运行原生图像编译过程时,你可能已经注意到了,这不仅需要时间,而且比通常需要更多的计算资源。当使用 Buildpacks 时,确保你的计算机上至少有 16 GB 的 RAM。如果你使用 Docker Desktop,请将 Docker 虚拟机配置为至少 8 GB 的 RAM。在 Windows 上,建议你使用 Docker Desktop on WSL2 而不是 Hyper-V。有关设置的更多建议,请参阅 Spring Native 文档(docs.spring.io/spring-native/docs/current/reference/htmlsingle)。

使用 Buildpacks 并生成容器化原生图像的命令与用于 JVM 图像的命令相同。打开一个终端窗口,导航到你的 Quote Service 项目(quote-service),并运行以下命令:

$ ./gradlew bootBuildImage

完成后,尝试运行生成的容器图像:

$ docker run --rm -p 9101:9101 quote-service

启动时间应该再次小于 100 毫秒。继续发送一些请求以测试应用程序是否正常工作:

$ http :9101/quotes/random

当你完成应用程序的测试后,使用 Ctrl-C 停止容器进程。

16.2 使用 Spring Cloud Function 的无服务器应用程序

如第一章所述,无服务器是在虚拟机和容器之上的进一步抽象层,将更多责任从产品团队转移到平台。遵循无服务器计算模型,开发者专注于实现应用程序的业务逻辑。使用像 Kubernetes 这样的编排器仍然需要基础设施配置、容量规划和扩展。相比之下,无服务器平台负责设置应用程序运行所需的底层基础设施,包括虚拟机、容器和动态扩展。

无服务器应用程序通常仅在存在事件要处理时运行,例如 HTTP 请求(请求驱动)或消息(事件驱动)。事件可以是外部的,也可以由另一个函数产生。例如,每当消息被添加到队列中时,可能会触发一个函数,处理消息,然后退出执行。当没有要处理的内容时,平台会关闭与该函数相关的所有资源,这样你实际上只需为实际使用付费。

在其他云原生拓扑结构,如 CaaS 或 PaaS 中,始终有一个服务器在 24/7 运行。与传统系统相比,你获得了动态可伸缩性的优势,减少了在任何给定时间配置的资源数量。然而,始终有某些内容在运行,这会产生成本。然而,在无服务器模型中,资源仅在必要时配置。如果没有要处理的内容,一切都会关闭。这就是我们所说的扩展到零,这是无服务器平台提供的主要功能之一。

将应用程序扩展到零的后果是,当最终有请求需要处理时,会启动一个新的应用程序实例,并且它必须能够非常快速地处理请求。标准的 JVM 应用程序不适合无服务器应用程序,因为很难实现低于几秒的启动时间。这就是为什么 GraalVM 原生镜像变得流行。它们的即时启动时间和减少的内存消耗使它们非常适合无服务器模型。即时启动时间对于扩展是必需的。减少的内存消耗有助于降低成本,这是无服务器和云原生的一般目标之一。

除了成本优化外,无服务器技术还将一些额外的责任从应用程序转移到平台。这可能是一个优势,因为它允许开发者专注于业务逻辑。但考虑您希望控制的程度以及如何处理供应商锁定也是至关重要的。每个无服务器平台都有自己的特性和 API。一旦您开始为特定平台编写函数,您就不能像处理容器那样轻松地将它们转移到另一个平台。您可能需要妥协以获得责任和范围,但可能会在控制性和可移植性方面失去更多,这就是为什么Knative迅速流行起来的原因:它是基于 Kubernetes 构建的,这意味着您可以轻松地在平台和供应商之间移动您的无服务器工作负载。

本节将指导您开发并部署一个无服务器应用程序。您将使用 Spring Native 将其编译为 GraalVM 原生镜像,并使用 Spring Cloud Function 将业务逻辑实现为函数,这是一个非常好的选择,因为无服务器应用程序是事件驱动的。

16.2.1 使用 Spring Cloud Function 构建无服务器应用程序

您已经在第十章中学习了如何使用 Spring Cloud Function。正如您所了解的,这是一个旨在通过基于 Java 8 引入的标准接口(Supplier、Function 和 Consumer)实现业务逻辑的项目。

Spring Cloud Function 非常灵活。您已经看到了它如何透明地与外部消息系统(如 RabbitMQ 和 Kafka)集成,这对于构建由消息触发的无服务器应用程序来说是一个实用的功能。在本节中,我想向您展示 Spring Cloud Function 提供的另一个功能,它允许您将函数作为由 HTTP 请求和 CloudEvents 触发的端点公开,CloudEvents 是一种规范,用于标准化云架构中事件格式和分发。

我们将使用与之前构建的 Quote Service 应用程序相同的规范,但这次我们将业务逻辑实现为函数,并让框架处理将它们作为 HTTP 端点公开。

使用 Spring Native 和 Spring Cloud Function 启动新项目

您可以从 Spring Initializr(start.spring.io)初始化 Quote Function 项目,将结果存储在一个新的 quote-function Git 仓库中,并将其推送到 GitHub。初始化的参数如图 16.2 所示。

16-02

图 16.2 初始化 Quote Function 项目的参数

该项目包含以下依赖项:

  • Spring Reactive Web 提供了构建基于 Spring WebFlux 的响应式 Web 应用程序所需的库,并且它包括 Netty 作为默认的嵌入服务器。

  • Spring Cloud Function提供了必要的库来支持通过函数实现业务逻辑,通过多个通信渠道导出它们,并将它们与无服务器平台集成。

  • Spring Native支持使用 GraalVM native-image 编译器将 Spring 应用程序编译为原生可执行文件。

build.gradle 文件的结果依赖部分看起来如下。

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-webflux'
  implementation
➥'org.springframework.cloud:spring-cloud-starter-function-web'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
  testImplementation 'io.projectreactor:reactor-test'
}

然后你可以在 build.gradle 中更新 Cloud Native Buildpacks 配置,就像我们为 Quote Service 所做的那样。

列表 16.8 容器化 Quote Function 的配置

tasks.named('bootBuildImage') {
  builder = 'paketobuildpacks/builder:tiny'    ❶
  environment = ['BP_NATIVE_IMAGE': 'true']    ❷
  imageName = "${project.name}" 

  docker { 
   publishRegistry { 
     username = project.findProperty("registryUsername") 
     password = project.findProperty("registryToken") 
     url = project.findProperty("registryUrl") 
   } 
  } 
}

❶ 使用“微型”版本的 Paketo Buildpacks 以最小化容器镜像大小

❷ 启用 GraalVM 支持并生成容器化原生镜像

接下来,将 Quote Service 中 com.polarbookshop.quoteservice.domain 包下的所有类复制到 Quote Function 中一个新的 com.polarbookshop.quotefunction.domain 包中。在下一节中,我们将实现业务逻辑作为函数。

将业务逻辑作为函数实现

正如你在第十章中学到的,Spring Cloud Function 增强了当它们作为 bean 注册时的标准 Java 函数。让我们首先为 Quote Function 项目在新的 com.polarbookshop.quotefunction.functions 包中添加一个 QuoteFunctions 类。

应用程序应提供与 Quote Service 类似的功能:

  • 返回所有报价可以表示为一个供应商,因为它不需要输入。

  • 返回随机报价也可以表示为一个供应商,因为它不需要输入。

  • 对于给定流派返回一个随机报价可以表示为一个函数,因为它既有输入也有输出。

  • 将报价记录到标准输出可以表示为一个消费者,因为它有输入但没有输出。

列表 16.9 将业务逻辑作为函数实现

@Configuration                                           ❶
public class QuoteFunctions {
  private static final Logger log =
    LoggerFactory.getLogger(QuoteFunctions.class);       ❷

  @Bean                                                  ❸
  Supplier<Flux<Quote>> allQuotes(QuoteService quoteService) {
    return () -> {
      log.info("Getting all quotes");
      return Flux.fromIterable(quoteService.getAllQuotes())
        .delaySequence(Duration.ofSeconds(1));           ❹
    };
  }

  @Bean                                                  ❺
  Supplier<Quote> randomQuote(QuoteService quoteService) {
    return () -> {
      log.info("Getting random quote");
      return quoteService.getRandomQuote();
    };
  }

  @Bean                                                  ❻
  Consumer<Quote> logQuote() {
    return quote -> log.info("Quote: '{}' by {}",
      quote.content(), quote.author());
  }
}

❶ 函数在 Spring 配置类中声明为 bean。

❷ 函数使用的记录器

❸ 生成所有报价的供应商

❹ 报价逐个流式传输,它们之间有 1 秒的暂停。

❺ 生成随机报价的供应商

❻ 记录接收到的报价作为输入的函数

当 Spring web 依赖项在类路径上时,Spring Cloud Function 会自动将所有注册的函数作为 HTTP 端点暴露。每个端点使用与函数相同的名称。通常,供应商可以通过 GET 请求调用,函数和消费者可以通过 POST 请求调用。

Quote Function 包含 Spring Reactive Web 依赖项,因此 Netty 将是处理 HTTP 请求的服务器。让我们让它监听端口 9102 并配置应用程序名称。打开 application.yml 文件,并添加以下配置。

列表 16.10 配置 Netty 服务器端口和应用程序名称

server:
  port: 9102

spring:
  application:
    name: quote-function

然后运行 Quote Function 应用程序(./gradlew bootRun)并打开一个终端窗口。首先,你可以通过发送 GET 请求来测试两个供应商:

$ http :9102/allQuotes
$ http :9102/randomQuote

要通过流派获取随机报价,你需要在 POST 请求的正文提供流派字符串:

$ echo 'FANTASY' | http :9102/genreQuote

当只有一个函数注册为 bean 时,Spring Cloud Function 将自动通过根端点公开它。在多个函数的情况下,您可以通过 spring.cloud.function.definition 配置属性选择函数。

例如,我们可以通过根端点公开 allQuotes 函数。在 Quote Function 项目中,打开 application.yml 文件并按以下方式更新它。

列表 16.11 定义由 Spring Cloud Function 管理的主体函数

server:
  port: 9102

spring:
  application:
    name: quote-function
  cloud: 
    function: 
      definition: allQuotes 

重新运行应用程序并向根端点发送 GET 请求。由于 allQuotes 函数是一个返回 Quote Flux 的 Supplier,您可以利用 Project Reactor 的流式处理能力,要求应用程序在可用时返回引用。当使用 Accept:text/event-stream 头部时(例如,curl -H 'Accept:text/event-stream' localhost:9102),这会自动完成。当使用 httpie 实用程序时,您还需要使用--stream 参数来启用数据流:

$ http :9102 Accept:text/event-stream --stream

与第十章中您所做的一样,您可以通过组合函数来构建管道。当函数作为 HTTP 端点公开时,您可以使用逗号(,)字符即时组合函数。例如,您可以将 genreQuote 函数与 logQuote 组合如下:

$ echo 'FANTASY' | http :9102/genreQuote,logQuote

由于 logQuote 是一个消费者,HTTP 响应具有 202 状态且没有主体。如果您检查应用程序日志,您将看到已打印出按流派随机选择的引用。

Spring Cloud Function 与多个通信渠道集成。您已经看到了如何利用 Spring Cloud Stream 通过交换和队列公开函数,以及如何将它们公开为 HTTP 端点。该框架还支持 RSocket,这是一种二进制响应式协议,以及 CloudEvents,这是一个标准化云架构中事件格式和分发的规范(cloudevents.io)。

CloudEvents可以通过 HTTP、消息通道如 AMPQ(RabbitMQ)和 RSocket 进行消费。它们确保以标准方式描述事件,从而使得它们可以在包括应用程序、消息系统、构建工具和平台在内的广泛技术中移植。

由于 Quote Function 已经配置为以 HTTP 端点公开函数,因此您可以在不更改任何代码的情况下使其消费 CloudEvents。确保应用程序正在运行,然后发送一个带有 CloudEvents 规范定义的额外头部的 HTTP 请求:

$ echo 'FANTASY' | http :9102/genreQuote \
    ce-specversion:1.0 \           ❶
    ce-type:quote \                ❷
    ce-id:394                      ❸

❶ CloudEvents 规范版本

❷ 事件类型(特定领域)

❸ 事件的 ID

当您完成应用程序的测试后,使用 Ctrl-C 停止进程。

注意:您可以参考 Spring Cloud Function 官方文档以获取有关如何支持 HTTP、CloudEvents 和 RSocket 的更多详细信息(spring.io/projects/spring-cloud-function)。

16.2.2 部署管道:构建和发布

遵循本书中解释的持续交付原则和技术,我们可以为 Quote 服务和 Quote 函数实现一个部署管道。由于这些项目的发布候选是容器镜像,大部分操作将与标准 JVM 应用程序相同。

当本地工作时,由于构建时间较短且资源需求较低,使用 JVM 运行和测试无服务器应用程序比使用 GraalVM 更方便。然而,为了实现更好的质量和尽早捕捉错误,我们应该尽可能早地在交付过程中以原生模式运行和验证应用程序。提交阶段是我们编译和测试应用程序的地方,因此可能是一个添加这些额外步骤的好地方。

在您的 Quote 函数项目(quote-function)中,添加一个新的 .github/workflows 文件夹,并创建一个 commit-stage.yml 文件。作为一个起点,您可以从我们之前章节中构建的其他应用程序中复制提交阶段的实现,例如 Catalog 服务。我们迄今为止使用的提交阶段工作流程由两个作业组成:“构建 & 测试”和“打包和发布”。我们将重用其他应用程序的实现,但我们将添加一个负责测试原生模式的中间作业。

列表 16.12 构建和测试原生模式应用程序的作业

name: Commit Stage
on: push

env:
  REGISTRY: ghcr.io                                    ❶
  IMAGE_NAME: <your_github_username>/quote-function    ❷
  VERSION: ${{ github.sha }}                           ❸

jobs:
  build:
    name: Build and Test
    ...

  native:                                              ❹
    name: Build and Test (Native)                      ❺
    runs-on: ubuntu-22.04                              ❻
    permissions:                                       ❼
      contents: read                                   ❽
    steps: 
      - name: Checkout source code 
        uses: actions/checkout@v3                      ❾
      - name: Set up GraalVM 
        uses: graalvm/setup-graalvm@v1                 ❿
        with: 
          version: '22.1.0' 
          java-version: '17' 
          components: 'native-image' 
          github-token: ${{ secrets.GITHUB_TOKEN }} 
      - name: Build, unit tests and integration tests (native) 
        run: | 
          chmod +x gradlew 
          ./gradlew nativeBuild                        ⓫

package:
    name: Package and Publish
    if: ${{ github.ref == 'refs/heads/main' }}
    needs: [ build, native ]                           ⓬
    ...

❶ 使用 GitHub 容器注册库

❷ 镜像的名称。请记住,将您的 GitHub 用户名全部转换为小写。

❸ 为了简单起见,任何新的镜像都将标记为“latest。”

❹ 作业的唯一标识符

❺ 作业的人类友好名称

❻ 作业将运行的机器类型

❼ 授予作业的权限

❽ 检出当前 Git 仓库的权限

❾ 检出当前 Git 仓库(quote-function)

❿ 安装和配置 GraalVM,使用 Java 17 和原生镜像组件

⓫ 将应用程序编译为原生可执行文件并运行单元和集成测试

⓬ 只有在之前的两个作业都成功完成后,“打包和发布”作业才会运行。

注意:在本书附带的源代码仓库中,您可以在 Chapter16/16-end/quote-function 文件夹中检查最终结果。

完成后,提交所有更改并将它们推送到您的 GitHub quote-function 仓库以触发提交阶段工作流程。我们将在本章后面使用该工作流程发布的容器镜像,所以请确保它运行成功。

你会注意到 Quote Function 的提交阶段执行时间比本书中其他应用程序的执行时间要长得多。在第三章中,我写道提交阶段应该是快速的,可能不到五分钟,以便为开发者提供关于他们更改的快速反馈,并允许他们继续进行下一项任务,这与持续集成的精神相符。我们刚刚添加的 GraalVM 的额外步骤可能会使工作流程变得过于缓慢。在这种情况下,你可能考虑将此检查移至验收阶段,在那里我们允许整个过程运行得更长。

以下部分将介绍使用 Spring Cloud Function 实现的无服务器应用程序的一些部署选项。

16.2.3 在云上部署无服务器应用程序

使用 Spring Cloud Function 的应用程序可以以几种不同的方式部署。首先,由于它们仍然是 Spring Boot 应用程序,你可以将它们打包成 JAR 艺术品或容器镜像,并分别部署到服务器或 Docker 或 Kubernetes 等容器运行时上,就像你在前面的章节中所做的那样。

然后,当包含 Spring Native 时,你还可以选择将它们编译成原生镜像并在服务器或容器运行时上运行。得益于即时启动时间和减少的内存消耗,你还可以无缝地将此类应用程序部署到无服务器平台上。下一节将介绍如何使用 Knative 在 Kubernetes 上运行你的无服务器工作负载。

Spring Cloud Function 还支持在 AWS Lambda、Azure Functions 和 Google Cloud Functions 等特定供应商的 FaaS 平台上部署应用程序。一旦选择了一个平台,你就可以添加框架提供的相关适配器以完成集成。每个适配器的工作方式略有不同,具体取决于特定平台以及将函数与底层基础设施集成所需的配置。Spring Cloud Function 提供的适配器不需要对您的业务逻辑进行任何更改,但可能需要一些额外的代码来配置集成。

当你使用这些适配器之一时,你必须选择要集成到平台中的哪个函数。如果只有一个函数注册为 bean,那么就使用这个函数。如果有多个(例如在 Quote Function 中),你需要使用 spring.cloud .function.definition 属性来声明 FaaS 平台将管理的函数。

注意:你可以参考 Spring Cloud Function 的官方文档,了解更多关于 AWS Lambda、Azure Functions 和 Google Cloud Functions 的 Spring Cloud Function 适配器的详细信息(spring.io/projects/spring-cloud-function)。

以下部分将介绍如何在基于 Kubernetes 的平台上使用 Knative 部署类似于 Quote Function 的无服务器应用程序。

16.3 使用 Knative 部署无服务器应用程序

在前面的章节中,你学习了 Spring Native 及其如何与 Spring Cloud Function 一起使用来构建无服务器应用程序。本节将指导你如何使用 Knative 项目将 Quote Function 部署到 Kubernetes 之上的无服务器平台。

Knative 是一个“基于 Kubernetes 的用于部署和管理现代无服务器工作负载的平台”(knative.dev)。它是一个 CNCF 项目,你可以用它来部署标准容器化工作负载和事件驱动的应用程序。该项目为开发者提供了卓越的用户体验,以及更高的抽象层次,使得在 Kubernetes 上部署应用程序变得更加简单。

你可以选择在自己的 Kubernetes 集群上运行自己的 Knative 平台,或者选择云提供商提供的托管服务,例如 VMware Tanzu Application Platform、Google Cloud Run 或 Red Hat OpenShift Serverless。由于它们都基于开源软件和标准,你可以从 Google Cloud Run 迁移到 VMware Tanzu Application Platform,而无需更改应用程序代码,并且对部署管道的更改最小。

Knative 项目由两个主要组件组成:Serving 和 Eventing。

  • Knative Serving用于在 Kubernetes 上运行无服务器工作负载。它负责自动扩展、网络、修订和部署策略,同时让工程师专注于应用程序的业务逻辑。

  • Knative Eventing提供了基于 CloudEvents 规范的应用程序与事件源和接收器集成的管理,抽象了像 RabbitMQ 或 Kafka 这样的后端。

我们的重点将在于使用 Knative Serving 来运行无服务器工作负载,同时避免供应商锁定。

注意:最初,Knative 由一个名为“Build”的第三个组件组成,后来成为了一个独立的产品,更名为 Tekton (tekton.dev),并捐赠给了 Continuous Delivery Foundation (cd.foundation)。Tekton 是一个支持持续交付的 Kubernetes 原生框架,用于构建部署管道。例如,你可以使用 Tekton 代替 GitHub Actions。

本节将展示如何设置一个包含 Kubernetes 和 Knative 的本地开发环境。然后我将介绍 Knative 清单,你可以使用它来声明无服务器应用程序的期望状态,并展示如何将它们应用到 Kubernetes 集群中。

16.3.1 设置本地 Knative 平台

由于 Knative 运行在 Kubernetes 之上,我们首先需要一个集群。让我们按照本书中一直使用的方法使用 minikube 创建一个集群。打开一个终端窗口并运行以下命令:

$ minikube start --profile knative

接下来,我们可以安装 Knative。为了简单起见,我已经将必要的命令收集到一个脚本中,您可以在本书附带的源代码仓库中找到它。从 Chapter16/16-end/polar-deployment/kubernetes/development 文件夹中,将 install-knative.sh 文件复制到 Polar Deployment 仓库(polar-deployment)中的相同路径。

然后打开一个终端窗口,导航到您刚刚复制脚本的文件夹,并运行以下命令以在您的本地 Kubernetes 集群上安装 Knative:

$ ./install-knative.sh

在运行它之前,您可以自由打开文件并查看说明。有关在项目网站上安装 Knative 的更多信息,请参阅(knative.dev/docs/install)。

注意:在 macOS 和 Linux 上,您可能需要通过 chmod +x install-knative.sh 命令使脚本可执行。

Knative 项目提供了一个方便的 CLI 工具,您可以使用它与 Kubernetes 集群中的 Knative 资源进行交互。您可以在附录 A 的 A.4 节中找到如何安装它的说明。在下一节中,我将向您展示如何使用 Knative CLI 部署 Quote Function。

16.3.2 使用 Knative CLI 部署应用程序

Knative 提供了多种部署应用程序的选项。在生产环境中,我们将坚持使用声明式配置,就像我们为标准 Kubernetes 部署所做的那样,并依赖于 GitOps 流来协调所需状态(在 Git 仓库中)和实际状态(在 Kubernetes 集群中)。

当进行实验或本地工作时,我们还可以利用 Knative CLI 以命令式方式部署应用程序。从终端窗口运行以下命令以部署 Quote Function。容器镜像是我们之前定义的提交阶段工作流发布的。请记住将 <your_github_username> 替换为您的小写 GitHub 用户名:

$ kn service create quote-function \
    --image ghcr.io/<your_github_username>/quote-function \
    --port 9102

您可以参考图 16.3 了解该命令的描述。

16-03

图 16.3 从容器镜像创建服务的 Knative 命令。Knative 将负责创建在 Kubernetes 上部署应用程序所需的所有资源。

该命令将在 Kubernetes 的默认命名空间中初始化一个新的 quote-function 服务。它将通过类似以下的消息返回应用程序公开的公共 URL:

Creating service 'quote-function' in namespace 'default':

  0.045s The Route is still working to reflect the latest desired
  ➥specification.
  0.096s Configuration "quote-function" is waiting for a Revision
  ➥to become ready.
  3.337s ...
  3.377s Ingress has not yet been reconciled.
  3.480s Waiting for load balancer to be ready
  3.660s Ready to serve.

Service 'quote-function' created to latest revision 'quote-function-00001'
➥is available at URL:
http://quote-function.default.127.0.0.1.sslip.io

让我们来测试一下!首先,我们需要使用 minikube 打开到集群的隧道。您第一次运行此命令时,可能会被要求输入您的机器密码以授权隧道到集群:

$ minikube tunnel --profile knative

然后,打开一个新的终端窗口,在根端点调用应用程序以获取引用的完整列表。要调用的 URL 与之前命令返回的相同(http:/ /quote-function.default.127.0.0.1.sslip.io),其格式为 ..

$ http http://quote-function.default.127.0.0.1.sslip.io

由于我们在本地工作,我已将 Knative 配置为使用 sslip.io,这是一个 DNS 服务,其功能是“当查询包含嵌入式 IP 地址的主机名时,返回该 IP 地址”。例如,127.0.0.1.sslip.io 主机名将被解析为 127.0.0.1 IP 地址。由于我们已经向集群打开了隧道,对 127.0.0.1 的请求将由集群处理,Knative 将将它们路由到正确的服务。

Knative 会自动处理应用程序的扩展,无需进一步配置。对于每个请求,它都会确定是否需要更多实例。当一个实例空闲一段时间(默认为 30 秒)后,Knative 将关闭它。如果超过 30 秒没有收到请求,Knative 将将应用程序扩展到零,这意味着不会有 Quote Function 的实例在运行。

当最终收到新的请求时,Knative 会启动一个新的实例并使用它来处理请求。得益于 Spring Native,Quote Function 的启动时间几乎是瞬间的,因此用户和客户端不需要处理长时间的等待,就像标准 JVM 应用程序那样。这个强大的功能让您能够优化成本,只为您使用和需要的东西付费。

使用像 Knative 这样的开源平台的优势在于,您可以无需代码更改就将应用程序迁移到另一个云服务提供商。但这还不是全部!您甚至可以使用现有的部署管道,或者进行一些小的修改。下一节将向您展示如何通过 YAML 清单以声明方式定义 Knative 服务,这是生产场景中推荐的方法。

在继续之前,请确保您已删除之前创建的 Quote Function 实例:

$ kn service delete quote-function

16.3.3 使用 Knative 清单部署应用程序

Kubernetes 是一个可扩展的系统。除了使用内置对象如 Deployments 和 Pods 之外,我们还可以通过自定义资源定义(CRDs)来定义自己的对象。这正是许多基于 Kubernetes 构建的工具,包括 Knative 所使用的策略。

使用 Knative 的一个好处是更好的开发者体验,以及以更简单、更简洁的方式声明我们应用程序的期望状态。我们不必处理 Deployments、Services 和 Ingresses,而可以与单一类型的资源一起工作:Knative 服务。

注意:在本书中,我始终将应用程序称为 服务。Knative 提供了一种在单个资源声明中建模应用程序的方法:Knative 服务。起初,命名可能不是很清晰,因为 Kubernetes 已经有一个内置的服务类型。实际上,Knative 的选择非常直观,因为它将架构概念与部署概念一一对应。

让我们看看 Knative 服务是什么样的。打开你的 Quote Function 项目(quote-function),创建一个新的“knative”文件夹。然后,在内部定义一个新的 kservice.yml 文件来声明 Quote Function 的 Knative 服务所需的状态。请记住用你的 GitHub 用户名的小写形式替换 <your_github_username>。

列表 16.13 Quote Function 的 Knative 服务清单

apiVersion: serving.knative.dev/v1           ❶
kind: Service                                ❷
metadata:
  name: quote-function                       ❸
spec:
  template:
    spec:
      containers:
        - name: quote-function               ❹
          image:                             ❺
➥ghcr.io/<your_github_username>/quote-function
          ports:
            - containerPort: 9102            ❻
          resources:                         ❼
            requests:
              cpu: '0.1'
              memory: '128Mi'
            limits:
              cpu: '2'
              memory: '512Mi'

❶ Knative Serving 对象的 API 版本

❷ 要创建的对象类型

❸ 服务的名称

❹ 容器的名称

❺ 运行容器的镜像。请记住插入你的 GitHub 用户名。

❻ 容器暴露的端口

❼ 容器的 CPU 和内存配置

与任何其他 Kubernetes 资源一样,你可以使用 kubectl apply -f 或通过像我们在上一章中与 Argo CD 一起做的自动化流程将 Knative 服务清单应用到集群中。对于这个例子,我们将使用 Kubernetes CLI。

打开一个终端窗口,导航到你的 Quote Function 项目(quote-function),并运行以下命令从 Knative 服务清单部署 Quote Function:

$ kubectl apply -f knative/kservice.yml

使用 Kubernetes CLI,你可以通过运行以下命令来获取所有创建的 Knative 服务及其 URL 的信息(显示的结果是部分,以适应页面):

$ kubectl get ksvc

NAME             URL                                                READY
quote-function   http://quote-function.default.127.0.0.1.sslip.io   True

让我们通过向其根端点发送 HTTP 请求来验证应用程序是否正确部署。如果你之前打开的隧道不再活跃,请在调用应用程序之前运行 minikube tunnel --profile knative:

$ http http://quote-function.default.127.0.0.1.sslip.io

Knative 在 Kubernetes 之上提供了一个抽象层。然而,它仍然在底层运行 Deployments、ReplicaSets、Pods、Services 和 Ingresses。这意味着你可以使用你在前几章中学到的所有技术。例如,你可以通过 ConfigMaps 和 Secrets 配置 Quote Function:

$ kubectl get pod

NAME                                                  READY   STATUS
pod/quote-function-00001-deployment-c6978b588-llf9w   2/2     Running

如果你等待 30 秒后检查你本地 Kubernetes 集群中的运行中的 Pods,你会发现没有,因为 Knative 由于不活跃而将应用程序缩放到零:

$ kubectl get pod
No resources found in default namespace.

现在尝试向 http://quote-function.default.127.0.0.1.sslip.io 发送一个新的请求。Knative 将立即启动一个新的 Pod 来处理 Quote Function 的请求:

$ kubectl get pod

NAME                                              READY   STATUS
quote-function-00001-deployment-c6978b588-f49x8   2/2     Running

当你完成应用程序的测试后,你可以使用 kubectl delete -f knative/kservice.yml 删除它。最后,你可以使用以下命令停止并删除本地集群:

$ minikube stop --profile knative
$ minikube delete --profile knative

Knative 服务资源代表了一个应用程序服务的整体。多亏了这个抽象,我们不再需要直接处理 Deployments、Services 和 Ingresses。Knative 负责所有这些。它在底层创建和管理它们,同时让我们摆脱处理 Kubernetes 提供的底层资源。默认情况下,Knative 甚至可以在不配置 Ingress 资源的情况下将应用程序暴露在集群之外,直接为你提供调用应用程序的 URL。

多亏了其专注于开发者体验和生产力的功能,Knative 可以用于在 Kubernetes 上运行和管理任何类型的工作负载,仅将零扩展功能限制为支持该功能的应用程序(例如,使用 Spring Native)。我们可以在 Knative 上轻松运行整个 Polar Bookshop 系统。我们可以使用 autoscaling.knative.dev/minScale 注解来标记我们不希望扩展到零的应用程序:

apiVersion: serving.knative.dev/v1
kind: Service
metadata:
  name: catalog-service
  annotations:
    autoscaling.knative.dev/minScale: "1"     ❶
...

❶ 确保此服务永远不会扩展到零

Knative 提供了如此出色的开发者体验,以至于它正在成为在 Kubernetes 上部署工作负载的事实上的抽象,不仅适用于无服务器,也适用于更标准的容器化应用程序。每次我配置一个新的 Kubernetes 集群时,我都会首先安装 Knative。它也是 Tanzu Community Edition、Tanzu Application Platform、Red Hat OpenShift 和 Google Cloud Run 等平台的基础部分。

注意:Tanzu Community Edition (tanzucommunityedition.io) 是一个在 Knative 之上提供出色开发者体验的 Kubernetes 平台。它是开源的,并且免费使用。

Knative 提供的另一个伟大功能是,它提供了一个直观且对开发者友好的选项,用于采用部署策略,如蓝绿部署、金丝雀部署或 A/B 测试部署,所有这些都可以通过相同的 Knative Service 资源来实现。在纯 Kubernetes 中实现这些策略需要大量的手动工作。相反,Knative 支持这些功能开箱即用。

注意:要获取有关无服务器应用程序和 Knative 的更多信息,您可以参考官方文档(knative.dev)。此外,我建议您查看 Manning 目录中关于此主题的一些书籍:Jacques Chester 的《Knative in Action》(Manning,2021;www.manning.com/books/knative-in-action)和 Mauricio Salatino 的《Continuous Delivery for Kubernetes》(www.manning.com/books/continuous-delivery-for-kubernetes)。

Polar Labs

随意将上一节学到的内容应用到 Quote 服务上。

  1. 定义一个提交阶段工作流程,包括编译和测试应用程序作为原生可执行文件所需的步骤。

  2. 将您的更改推送到 GitHub,并确保工作流程成功完成并发布您的应用程序的容器镜像。

  3. 通过 Knative CLI 在 Kubernetes 上部署 Quote 服务。

  4. 通过 Knative Service 清单使用 Kubernetes CLI 在 Kubernetes 上部署 Quote 服务。

您可以参考书中附带的代码存储库中的 Chapter16/16-end 文件夹,以检查最终结果(github.com/ThomasVitale/cloud-native-spring-in-action)。

摘要

  • 通过将 GraalVM 作为 Java 应用程序的运行环境替换标准 OpenJDK 发行版,您可以通过新的优化 JIT(即时编译)技术(GraalVM 编译器)来提高它们的性能和效率。

  • 使 GraalVM 在无服务器环境中如此创新和受欢迎的是其原生镜像模式。

  • 与将 Java 代码编译成字节码并依赖于 JVM 在运行时解释它并将其转换为机器码不同,GraalVM 提供了一种新技术(原生镜像构建器),可以将 Java 应用程序直接编译成机器码,从而获得原生可执行文件或原生镜像。

  • 作为原生镜像编译的 Java 应用程序具有更快的启动时间、优化的内存消耗和即时峰值性能,这与 JVM 选项不同。

  • Spring Native 的主要目标是使任何 Spring 应用程序都能使用 GraalVM 编译成原生可执行文件,而无需进行任何代码更改。

  • Spring Native 提供了一个 AOT(Ahead-of-Time)基础设施(通过一个专门的 Gradle/Maven 插件调用),用于向 GraalVM 提供所有必要的配置以 AOT 编译 Spring 类。

  • 将您的 Spring Boot 应用程序编译成原生可执行文件有两种方式。第一种选项生成一个特定操作系统的可执行文件,并在机器上直接运行应用程序。第二种选项依赖于 Buildpacks 将原生可执行文件容器化,并在像 Docker 这样的容器运行时上运行。

  • 无服务器是在虚拟机和容器之上的另一个抽象层,它将更多的责任从产品团队转移到平台。

  • 遵循无服务器计算模型,开发者专注于实现应用程序的业务逻辑。

  • 无服务器应用程序由一个传入请求或特定事件触发。我们称这样的应用程序为请求驱动或事件驱动。

  • 使用 Spring Cloud Function 的应用程序可以以几种不同的方式部署。

  • 当包含 Spring Native 时,您还可以将应用程序编译成原生镜像并在服务器或容器运行时上运行。得益于即时启动时间和减少的内存消耗,您可以在 Knative 上无缝部署此类应用程序。

  • Knative 是一个“基于 Kubernetes 的平台,用于部署和管理现代无服务器工作负载”(knative.dev)。您可以使用它来部署标准容器化工作负载和事件驱动应用程序。

  • Knative 项目为开发者和用户提供了更优越的体验,以及更高的抽象级别,这使得在 Kubernetes 上部署应用程序变得更加简单。

  • Knative 提供了如此出色的开发者体验,以至于它正在成为在 Kubernetes 上部署工作负载的事实上的抽象,不仅适用于无服务器,也适用于更标准的容器化应用程序。

附录 A 设置你的开发环境

本附录涵盖了

  • 设置 Java

  • 设置 Docker

  • 设置 Kubernetes

  • 设置其他工具

在本附录中,你将找到设置你的开发环境以及安装我们将在整本书中用于构建、管理和部署云原生应用的工具的说明。

A.1 Java

本书中的所有示例都是基于 Java 17,这是写作时的最新长期发布版本。你可以安装任何 OpenJDK 17 发行版。我将使用来自 Adoptium 项目 (adoptium.net) 的 Eclipse Temurin,之前被称为 AdoptOpenJDK,但你可以自由选择另一个版本。

在你的机器上管理不同的 Java 版本和发行版可能会很痛苦。我建议使用像 sdkman (sdkman.io) 这样的工具来轻松安装、更新和切换不同的 JDK。在 macOS 和 Linux 上,你可以按照以下方式安装 sdkman:

$ curl -s "https://get.sdkman.io" | bash

请参阅官方文档以获取 Windows 的安装说明。

安装完成后,通过运行以下命令检查所有可用的 OpenJDK 发行版和版本:

$ sdk list java

然后选择一个发行版并安装它。例如,我可以在写作时安装最新的 17 版本的 Eclipse Temurin,如下所示:

$ sdk install java 17.0.3-tem

到你阅读本节时,可能已有更新的版本可用,所以请检查列表命令返回的内容以确定最新版本。

在安装过程结束时,sdkman 将询问你是否想将该发行版设置为默认版本。我建议你回答“是”,以确保你可以从你构建的整个书中的所有项目中访问 Java 17。你可以使用以下命令随时更改默认版本:

$ sdk default java 17.0.3-tem

让我们现在验证 OpenJDK 的安装:

$ java --version
openjdk 17.0.3 2022-04-19
OpenJDK Runtime Environment Temurin-17.0.3+7 (build 17.0.3+7)
OpenJDK 64-Bit Server VM Temurin-17.0.3+7 (build 17.0.3+7, mixed mode)

你还可以选择仅在本 shell 的上下文中更改 Java 版本:

$ sdk use java 17.0.3-tem

最后,如果你想检查当前 shell 中配置的 Java 版本,你可以这样做:

$ sdk current java
Using java version 17.0.3-tem

A.2 Docker

Open Container Initiative (OCI),一个 Linux 基金会项目,为与容器一起工作定义了行业标准 (opencontainers.org)。特别是,OCI 图像规范定义了如何构建容器镜像,OCI 运行时规范定义了如何运行这些容器镜像,而 OCI 发行版规范定义了如何分发它们。我们全书用于与容器一起工作的工具是 Docker,它符合 OCI 规范。

在 Docker 网站 (www.docker.com) 上,你可以找到在本地环境中设置 Docker 的说明。我将使用写作时提供的最新版本:Docker 20.10 和 Docker Desktop 4.11。

  • 在 Linux 上,你可以直接安装 Docker 开源平台。它也被称为 Docker 社区版(Docker CE)。

  • 在 macOS 和 Windows 上,您可以选择使用 Docker Desktop,这是一个基于 Docker 构建的商业产品,它使得从这些操作系统运行 Linux 容器成为可能。在撰写本文时,Docker Desktop 对个人使用、教育、非商业开源项目和中小企业是免费的。在安装软件之前,请仔细阅读 Docker 订阅服务协议,并确保您符合其规定(www.docker.com/legal)。

Docker Desktop 支持 ARM64 和 AMD64 架构,这意味着您可以在配备苹果硅处理器的苹果新电脑上运行本书中的所有示例。

如果您在 Windows 上工作,Docker Desktop 提供两种类型的设置:Hyper-V 或 WSL2。我建议您选择后者,因为它提供更好的性能,并且更稳定。

Docker 默认配置为从 Docker Hub 下载 OCI 镜像,Docker Hub 是一个容器注册库,托管着许多流行开源项目的镜像,如 Ubuntu、PostgreSQL 和 Redis。使用它是免费的,但如果您匿名使用,它将受到严格的速率限制政策。因此,我建议您在 Docker 网站上创建一个免费账户(www.docker.com)。

创建账户后,打开一个终端窗口,并使用 Docker Hub 进行身份验证(确保您的 Docker 引擎正在运行)。由于它是默认的容器注册库,您不需要指定其 URL:

$ docker login

当被要求时,请输入您的用户名和密码。

使用 Docker CLI,您现在可以与 Docker Hub 交互,下载镜像(pull)或上传您自己的(push)。例如,尝试从 Docker Hub 拉取官方 Ubuntu 镜像:

$ docker pull ubuntu:22.04

在本书中,您将学习更多关于使用 Docker 的知识。在此之前,如果您想尝试容器,我将为您留下一份控制容器生命周期的有用命令列表(表 A.1)。

表 A.1 管理镜像和容器的有用 Docker CLI 命令

Docker CLI 命令 它的作用
docker images 显示所有镜像
docker ps 显示正在运行的容器
docker ps -a 显示所有创建、启动和停止的容器
docker run 从给定镜像运行一个容器
docker start 启动一个现有的容器
docker stop 停止一个正在运行的容器
docker logs 显示给定容器的日志
docker rm 删除一个已停止的容器
docker rmi 删除一个镜像

本书构建的所有容器都符合 OCI 标准,并将与任何其他 OCI 容器运行时一起工作,例如 Podman (podman.io)。如果您决定使用除 Docker 之外的平台,请注意,我们用于本地开发和集成测试的一些工具可能需要额外的配置才能正确工作。

A.3 Kubernetes

在您的本地环境中安装 Kubernetes 有几种方法。以下是一些最常用的选项:

  • minikube (minikube.sigs.k8s.io) 允许您在任何操作系统上运行本地 Kubernetes 集群。它由 Kubernetes 社区维护。

  • kind (kind.sigs.k8s.io) 允许您将本地 Kubernetes 集群作为 Docker 容器运行。它最初是为了测试 Kubernetes 本身而开发的,但您也可以用它来进行 Kubernetes 的本地开发。它由 Kubernetes 社区维护。

  • k3d (k3d.io) 允许您基于 Rancher Labs 实现的最小化 Kubernetes 发行版 k3s 运行本地 Kubernetes 集群。它由 Rancher 社区维护。

随意选择最适合您需求的工具。由于它的稳定性和与所有操作系统和架构的兼容性,包括新的苹果硅电脑,本书将使用 minikube。您至少需要两个 CPU 和 4 GB 的空闲内存来使用 minikube 运行本书中的所有示例。

您可以在项目的网站上找到安装指南 (minikube.sigs.k8s.io)。本书将使用写作时提供的最新版本:Kubernetes 1.24 和 minikube 1.26。在 macOS 上,您可以使用 Homebrew 如下安装 minikube:

$ brew install minikube

使用 minikube 运行本地 Kubernetes 集群需要一个容器运行时或虚拟机管理器。由于我们已经在使用 Docker,所以我们将使用它。在底层,任何 minikube 集群都将作为一个 Docker 容器运行。

安装 minikube 后,您可以使用 Docker 驱动程序启动一个新的本地 Kubernetes 集群。您第一次运行此命令时,将需要几分钟来下载运行集群所需的所有组件:

$ minikube start --driver=docker

我建议通过运行以下命令将 Docker 设置为 minikube 的默认驱动程序:

$ minikube config set driver docker

要与新建的 Kubernetes 集群交互,您需要安装 kubectl,Kubernetes 的 CLI。安装说明可在官方网站 (kubernetes.io/docs/tasks/tools) 上找到。在 macOS 和 Linux 上,您可以使用 Homebrew 如下安装它:

$ brew install kubectl

然后,您可以验证 minikube 集群是否已正确启动,并检查您的本地集群中是否有一个节点正在运行:

$ kubectl get nodes
NAME       STATUS   ROLES                  AGE     VERSION
minikube   Ready    control-plane,master   2m20s   v1.24.3

我建议在不需要 minikube 时停止它,以释放您本地环境中的资源:

$ minikube stop

在本书中,您将学习更多关于使用 Kubernetes 和 minikube 的知识。在此之前,如果您想尝试 Kubernetes 资源,我将为您提供一些有用的命令(表 A.2)。

表 A.2 管理 Pods、Deployments 和 Services 的有用 Kubernetes CLI 命令

Kubernetes CLI 命令 执行的操作
kubectl get deployment 显示所有部署
kubectl get pod 显示所有 Pods
kubectl get svc 显示所有服务
kubectl logs <pod_id> 显示指定 Pod 的日志
kubectl delete deployment 删除指定的部署
kubectl delete pod 删除指定的 Pod
kubectl delete svc 删除指定的服务
kubectl port-forward svc : 将本地机器的流量转发到集群内部

A.4 其他工具

本节将介绍本书中用于执行特定任务的一系列有用工具,例如安全漏洞扫描或 HTTP 交互。

A.4.1 HTTPie

HTTPie 是一个方便的“命令行 HTTP 和 API 测试客户端”(httpie.org)。它专为人类设计,并提供卓越的用户体验。请参考官方文档获取安装说明和更多关于该工具的信息。

在 macOS 和 Linux 上,您可以使用 Homebrew 进行安装,如下所示:

$ brew install httpie

作为安装的一部分,您将获得两个可以从终端窗口使用的工具:http 和 https。例如,您可以发送以下 GET 请求:

$ http pie.dev/get

A.4.2 Grype

在供应链安全背景下,我们使用 Grype 扫描 Java 代码库和容器镜像中的漏洞(github.com/anchore/grype)。扫描是在您运行它的机器上本地进行的,这意味着您的任何文件或工件都不会发送到外部服务。这使得它在更受监管的环境或断网场景中非常适合。请参考官方文档获取更多信息。

在 macOS 和 Linux 上,您可以使用 Homebrew 进行安装,如下所示:

$ brew tap anchore/grype
$ brew install grype

该工具目前尚不支持 Windows。如果您是 Windows 用户,我建议您利用 Windows Subsystem for Linux 2 (WSL2)并在其中安装 Grype。有关 WSL2 的更多信息,您可以参考官方文档(docs.microsoft.com/en-us/windows/wsl/)。

A.4.3 Tilt

Tilt(tilt.dev)旨在在 Kubernetes 上工作时提供良好的开发者体验。它是一个开源工具,提供在本地环境中构建、部署和管理容器化工作负载的功能。请参考官方文档获取安装说明(docs.tilt.dev/install.html)。

在 macOS 和 Linux 上,您可以使用 Homebrew 进行安装,如下所示:

$ brew install tilt-dev/tap/tilt

A.4.4 八分仪

八分仪(octant.dev)是一个“面向开发者的开源 Kubernetes Web 界面,允许您检查 Kubernetes 集群及其应用程序。”请参考官方文档获取安装说明(reference.octant.dev)。

在 macOS 和 Linux 上,您可以使用 Homebrew 进行安装,如下所示:

$ brew install octant

A.4.5 Kubeval

Kubeval (www.kubeval.com) 是一个方便的工具,当你需要“验证一个或多个 Kubernetes 配置文件”时。我们将在部署管道中使用它,以确保所有我们的 Kubernetes 清单都格式正确且符合 Kubernetes API。请参阅官方文档以获取安装说明 (www.kubeval.com/installation/)。

在 macOS 和 Linux 上,你可以使用 Homebrew 如下安装:

$ brew tap instrumenta/instrumenta
$ brew install kubeval

A.4.6 Knative CLI

Knative 是一个“基于 Kubernetes 的平台,用于部署和管理现代无服务器工作负载” (knative.dev)。该项目提供了一个方便的 CLI 工具,你可以使用它来与 Kubernetes 集群中的 Knative 资源进行交互。请参阅官方文档以获取安装说明 (knative.dev/docs/install/quickstart-install)。

在 macOS 和 Linux 上,你可以使用 Homebrew 如下安装:

$ brew install kn

附录 B:使用 DigitalOcean 在生产环境中部署 Kubernetes

本附录涵盖

  • 在 DigitalOcean 上运行 Kubernetes 集群

  • 在 DigitalOcean 上运行 PostgreSQL 数据库

  • 在 DigitalOcean 上运行 Redis

  • 使用 Kubernetes Operator 运行 RabbitMQ

  • 使用 Helm 图表运行 Keycloak

Kubernetes 是部署和管理容器化工作负载的事实标准。我们在整本书中一直依赖本地 Kubernetes 集群来部署 Polar Bookshop 系统中的应用程序和服务。对于生产环境,我们需要其他东西。

所有主要云服务提供商都提供托管 Kubernetes 服务。在本附录中,您将了解如何使用 DigitalOcean 启动 Kubernetes 集群。我们还将依赖平台提供的其他托管服务,包括 PostgreSQL 和 Redis。最后,本附录将指导您在 Kubernetes 中直接部署 RabbitMQ 和 Keycloak。

在继续之前,您需要确保您有一个 DigitalOcean 账户。当您注册时,DigitalOcean 提供 60 天的免费试用,并附带 100 美元的信用额度,这足以完成第十五章中的示例。按照官方网站上的说明创建账户并开始免费试用(try.digitalocean.com/freetrialoffer)。

注意:本书附带的源代码存储库包含在几个不同的云平台上设置 Kubernetes 集群的额外说明,以防您想使用除 DigitalOcean 以外的其他服务。

与 DigitalOcean 平台交互有两种主要选项。第一个是通过 Web 门户(cloud.digitalocean.com),这对于探索可用服务和其功能非常方便。第二个选项是通过 doctl,DigitalOcean 的 CLI。这就是我们将在以下部分使用的方法。

您可以在官方网站上找到安装 doctl 的说明(docs .digitalocean.com/reference/doctl/how-to/install)。如果您使用的是 macOS 或 Linux,您可以使用 Homebrew 轻松安装它:

$ brew install doctl

您可以遵循同一 doctl 页面上的后续说明来生成 API 令牌并授予 doctl 对您的 DigitalOcean 账户的访问权限。

注意:在实际的生产场景中,您会使用像 Terraform 或 Crossplane 这样的工具来自动化平台管理任务。这通常是平台团队的职责,而不是应用开发者的职责,因此我不会通过引入另一个工具来增加额外的复杂性。相反,我们将直接使用 DigitalOcean CLI。如果您对 Terraform 感兴趣,Manning 在其目录中有一本关于该主题的书:Scott Winkler 的《Terraform in Action》(Manning,2021;www.manning.com/books/terraform-in-action)。对于 Crossplane,我建议阅读 Mauricio Salatino 的《Continuous Delivery for Kubernetes》的第四章(livebook.manning.com/book/continuous-delivery-for-kubernetes/chapter-4)。

B.1 在 DigitalOcean 上运行 Kubernetes 集群

我们在 DigitalOcean 上需要创建的第一个资源是一个 Kubernetes 集群。您可以选择依赖平台提供的 IaaS 能力,在虚拟机之上手动安装一个 Kubernetes 集群。相反,我们将提升抽象层次,选择由平台管理的解决方案。当我们使用 DigitalOcean Kubernetes (docs.digitalocean.com/products/kubernetes)时,平台将负责许多基础设施问题,这样我们开发者就可以更多地专注于应用开发。

您可以使用 doctl 直接创建一个新的 Kubernetes 集群。我承诺我们将在一个真实的生产环境中部署 Polar Bookshop,这就是我们将要做的,尽管我不会要求您像在真实场景中那样对集群进行规模和配置。

首先,设置 Kubernetes 集群不是开发者的责任——这是平台团队的工作。其次,要完全理解配置,需要比本书提供的更深入地了解 Kubernetes。第三,我不想您在 DigitalOcean 上使用大量计算资源和服务的额外成本。成本优化是适用于真实应用的云属性。然而,如果您在尝试新事物或运行演示应用时,这可能会变得很昂贵。请密切关注您的 DigitalOcean 账户,以监控您的免费试用和$100 信用额度何时到期。

每个云资源都可以在特定地理区域内的数据中心中创建。为了获得更好的性能,我建议您选择离您较近的一个。我将使用“Amsterdam 3”(ams3),但您可以使用以下命令获取完整的区域列表:

$ doctl k8s options regions

让我们继续使用 DigitalOcean Kubernetes(DOKS)初始化一个 Kubernetes 集群。它将由三个工作节点组成,您可以为它们决定技术规格。您可以在 CPU、内存和架构方面选择不同的选项。我将使用具有 2 个 vCPU 和 4GB 内存的节点:

$ doctl k8s cluster create polar-cluster \                                 ❶
    --node-pool "name=basicnp;size=s-2vcpu-4gb;count=3;label=type=basic;" \❷
    --region <your_region>                                                 ❸

❶ 定义要创建的集群名称

❷ 为工作节点提供所需的规格

❸ 您选择的数据中心区域,例如“ams3”

注意:如果您想了解更多关于不同计算选项及其价格的信息,您可以使用 doctl compute size list 命令。

集群配置需要几分钟。最后,它将打印出分配给集群的唯一 ID。请注意,因为您稍后需要它。您可以通过运行以下命令在任何时候获取集群 ID(我已经为了清晰起见过滤了结果):

$ doctl k8s cluster list

ID              Name             Region    Status     Node Pools
<cluster-id>    polar-cluster    ams3      running    basicnp

在集群配置完成之后,doctl 还会为您配置 Kubernetes CLI 的上下文,以便您可以从您的计算机上与 DigitalOcean 上运行的集群进行交互,类似于您迄今为止与本地集群所做的那样。您可以通过运行以下命令来验证当前的 kubectl 上下文:

$ kubectl config current-context

注意:如果您想更改上下文,您可以通过运行 kubectl config use-context 命令来实现。

一旦集群配置完成,您可以通过以下方式获取关于工作节点的信息:

$ kubectl get nodes

NAME       STATUS   ROLES    AGE     VERSION
<node-1>   Ready    <none>   2m34s   v1.24.3
<node-2>   Ready    <none>   2m36s   v1.24.3
<node-3>   Ready    <none>   2m26s   v1.24.3

您还记得您用来可视化本地 Kubernetes 集群工作负载的 Octant 仪表板吗?现在您可以使用它来获取关于 DigitalOcean 上集群的信息。打开一个终端窗口,并使用以下命令启动 Octant:

$ octant

Octant 将在您的浏览器中打开并显示您当前 Kubernetes 上下文的数据,这应该是 DigitalOcean 上的集群。从右上角的菜单中,您可以通过下拉框在上下文之间切换,如图 B.1 所示。

B-1

图 B.1 Octant 允许您通过切换上下文来可视化来自不同 Kubernetes 集群的工作负载。

正如我在第九章中提到的,Kubernetes 并没有打包 Ingress 控制器;安装一个 Ingress 控制器取决于您。由于我们将依赖 Ingress 资源来允许来自公共互联网到集群的流量,我们需要安装一个 Ingress 控制器。让我们安装我们在本地使用的同一个:ingress-nginx。

在您的 polar-deployment 仓库中,创建一个新的 kubernetes/platform/production 文件夹,并将源代码仓库中附带的书中的 Chapter15/15-end/polar-deployment/kubernetes/platform/production 文件夹的内容复制过来。

然后打开一个终端窗口,导航到您的 polar-deployment 项目中的 kubernetes/platform/production/ingress-nginx 文件夹,并运行以下命令将 ingress-nginx 部署到您的生产 Kubernetes 集群:

$ ./deploy.sh

在运行它之前,请随意打开文件并查看说明。

注意:您可能需要先使用命令 chmod +x deploy.sh 使脚本可执行。

在下一节中,您将了解如何在 DigitalOcean 上初始化一个 PostgreSQL 数据库。

B.2 在 DigitalOcean 上运行 PostgreSQL 数据库

在本书的大部分内容中,您都已经在 Docker 和本地 Kubernetes 集群中以容器形式运行 PostgreSQL 数据库实例。在生产环境中,我们希望利用平台优势,并使用 DigitalOcean 提供的托管 PostgreSQL 服务(docs.digitalocean.com/products/databases/postgresql)。

本书开发的应用程序是云原生,遵循 15-Factor 方法。因此,它们将后端服务视为可以替换而不更改应用程序代码的附加资源。此外,我们遵循环境等价原则,在开发和测试中使用了真实的 PostgreSQL 数据库,并且这是我们希望在生产中使用的相同数据库。

从在本地环境中运行的 PostgreSQL 容器迁移到具有高可用性、可扩展性和弹性的托管服务,只需更改 Spring Boot 的几个配置属性值即可。这有多么方便?

首先,创建一个名为 polar-postgres 的新 PostgreSQL 服务器,如下面的代码片段所示。我们将使用 PostgreSQL 14,这与我们用于开发和测试的版本相同。请记住用 <your_region> 替换您希望使用的地理位置区域。它应该与您用于 Kubernetes 集群的区域相同。在我的情况下,它是 ams3:

$ doctl databases create polar-db \
    --engine pg \
    --region <your_region> \
    --version 14

数据库服务器配置将需要几分钟。您可以使用以下命令验证安装状态(我已经为了清晰起见过滤了结果):

$ doctl databases list

ID               Name        Engine    Version    Region    Status
<polar-db-id>    polar-db    pg        14         ams3      online

当数据库上线时,您的数据库服务器就准备好了。请注意数据库服务器 ID。您稍后需要用到它。

为了减轻不必要的攻击向量,您可以配置防火墙,以便 PostgreSQL 服务器只能从之前创建的 Kubernetes 集群访问。请记住,我要求您记录 PostgreSQL 和 Kubernetes 的资源 ID?在以下命令中使用它们来配置防火墙并确保数据库服务器的安全访问:

$ doctl databases firewalls append <postgres_id> --rule k8s:<cluster_id>

接下来,让我们为目录服务(polardb_catalog)和订单服务(polardb_order)创建两个数据库。请记住用 <postgres_id> 替换您的 PostgreSQL 资源 ID:

$ doctl databases db create <postgres_id> polardb_catalog
$ doctl databases db create <postgres_id> polardb_order

最后,让我们检索连接到 PostgreSQL 的详细信息。请记住用 <postgres_id> 替换您的 PostgreSQL 资源 ID:

$ doctl databases connection <postgres_id> --format Host,Port,User,Password

Host         Port         User         Password
<db-host>    <db-port>    <db-user>    <db-password>

在结束本节之前,让我们在 Kubernetes 集群中为两个应用程序所需的 PostgreSQL 凭据创建一些秘密。在实际场景中,我们应该为两个应用程序创建专用用户并授予有限的权限。为了简化,我们将使用管理员账户为两者服务。

首先,使用前一个 doctl 命令返回的信息为目录服务创建一个秘密:

$ kubectl create secret generic polar-postgres-catalog-credentials \
    --from-literal=spring.datasource.url=
➥jdbc:postgresql://<postgres_host>:<postgres_port>/polardb_catalog \
    --from-literal=spring.datasource.username=<postgres_username> \
    --from-literal=spring.datasource.password=<postgres_password>

类似地,为订单服务创建一个秘密。请注意,Spring Data R2DBC 对 URL 的语法要求略有不同:

$ kubectl create secret generic polar-postgres-order-credentials \
    --from-literal="spring.flyway.url=
➥jdbc:postgresql://<postgres_host>:<postgres_port>/polardb_order" \
    --from-literal="spring.r2dbc.url=
➥r2dbc:postgresql://<postgres_host>:<postgres_port>/polardb_order?
➥ssl=true&sslMode=require" \
    --from-literal=spring.r2dbc.username=<postgres_username> \
    --from-literal=spring.r2dbc.password=<postgres_password>

PostgreSQL 的部分就到这里。在下一节中,您将看到如何使用 DigitalOcean 初始化 Redis。

B.3 在 DigitalOcean 上运行 Redis

在本书的大部分内容中,您都已经在 Docker 和您本地的 Kubernetes 集群中以容器形式运行 Redis 实例。在生产环境中,我们希望利用平台优势,使用由 DigitalOcean 提供的托管 Redis 服务(docs.digitalocean.com/products/databases/redis/)。

再次强调,由于我们遵循了 15-Factor 方法论,我们可以在不更改应用程序代码的情况下替换 Edge Service 使用的 Redis 后端服务。我们只需要更改 Spring Boot 的一些配置属性。

首先,创建一个名为 polar-redis 的新 Redis 服务器,如下面的代码片段所示。我们将使用 Redis 7,这与我们用于开发和测试的版本相同。请记住将<your_region>替换为您希望使用的地理位置区域。它应该与您用于 Kubernetes 集群的区域相同。在我的情况下,它是 ams3:

$ doctl databases create polar-redis \
    --engine redis \
    --region <your_region> \
    --version 7

Redis 服务器的配置将需要几分钟。您可以使用以下命令验证安装状态(为了清晰起见,我已经过滤了结果):

$ doctl databases list

ID               Name           Engine    Version    Region    Status
<redis-db-id>    polar-redis    redis     7          ams3      creating

当服务器在线时,您的 Redis 服务器就绪。请注意 Redis 资源 ID。您稍后需要用到它。

为了减轻不必要的攻击向量,我们可以配置防火墙,使得 Redis 服务器只能从之前创建的 Kubernetes 集群访问。记得我让您记录 Redis 和 Kubernetes 的资源 ID 了吗?在以下命令中使用它们来配置防火墙并确保 Redis 服务器的安全访问:

$ doctl databases firewalls append <redis_id> --rule k8s:<cluster_id>

最后,让我们获取连接到 Redis 的详细信息。请记住将<redis_id>替换为您的 Redis 资源 ID:

$ doctl databases connection <redis_id> --format Host,Port,User,Password

Host            Port            User            Password
<redis-host>    <redis-port>    <redis-user>    <redis-password>

在结束本节之前,让我们在 Kubernetes 集群中创建一个 Secret,用于存储 Edge Service 所需的 Redis 凭证。在现实场景中,我们应该为应用程序创建一个专用用户并授予有限的权限。为了简化,我们将使用默认账户。使用上一个 doctl 命令返回的信息填充 Secret:

$ kubectl create secret generic polar-redis-credentials \
    --from-literal=spring.redis.host=<redis_host> \
    --from-literal=spring.redis.port=<redis_port> \
    --from-literal=spring.redis.username=<redis_username> \
    --from-literal=spring.redis.password=<redis_password> \
    --from-literal=spring.redis.ssl=true

Redis 的部分就到这里。下一节将介绍如何使用 Kubernetes Operator 部署 RabbitMQ。

B.4 使用 Kubernetes Operator 运行 RabbitMQ

在前面的章节中,我们初始化并配置了由平台提供和管理的 PostgreSQL 和 Redis 服务器。我们无法对 RabbitMQ 做同样的事情,因为 DigitalOcean 没有提供类似 Azure 或 GCP 等其他云服务提供商的 RabbitMQ 服务。

在 Kubernetes 集群中部署和管理像 RabbitMQ 这样的服务的一种流行且方便的方法是使用 operator 模式。Operators 是“Kubernetes 的软件扩展,它使用自定义资源来管理应用程序及其组件”(kubernetes.io/docs/concepts/extend-kubernetes/operator)。

考虑一下 RabbitMQ。为了在生产环境中使用它,你需要对其进行高可用性和弹性的配置。根据工作负载,你可能希望动态地对其进行扩展。当软件的新版本可用时,你需要一种可靠的方式来升级服务并迁移现有的结构和数据。你可以手动执行所有这些任务。或者,你可以使用 Operator 来捕获所有这些操作需求,并指导 Kubernetes 自动处理它们。实际上,Operator 是一个在 Kubernetes 上运行的应用程序,它与 Kubernetes API 交互以实现其功能。

RabbitMQ 项目提供了一个官方的 Operator,用于在 Kubernetes 集群上运行事件代理(www.rabbitmq.com)。我已经配置了所有必要的资源来使用 RabbitMQ Kubernetes Operator,并准备了一个脚本来部署它。

打开一个终端窗口,转到你的 Polar Deployment 项目(polar-deployment),然后导航到 kubernetes/platform/production/rabbitmq 文件夹。当你配置 Kubernetes 集群时,你应该已经将此文件夹复制到你的仓库中。如果不是这种情况,请现在从本书附带源代码仓库(第十五章/15-end/polar-deployment/platform/production/rabbitmq)中执行此操作。

然后运行以下命令将 RabbitMQ 部署到你的生产 Kubernetes 集群:

$ ./deploy.sh

在运行之前,你可以自由地打开文件并查看说明。

注意:你可能需要先使用命令 chmod +x deploy.sh 使脚本可执行。

脚本将输出有关部署 RabbitMQ 所执行的所有操作的详细信息。最后,它将创建一个 polar-rabbitmq-credentials Secret,其中包含订单服务和调度服务访问 RabbitMQ 所需的凭据。你可以按照以下方式验证 Secret 是否已成功创建:

$ kubectl get secrets polar-rabbitmq-credentials

RabbitMQ 代理部署在专门的 rabbitmq-system 命名空间中。应用程序可以在 polar-rabbitmq.rabbitmq-system.svc.cluster.local 的 5672 端口上与之交互。

RabbitMQ 的部署就到这里了。在下一节中,你将了解如何将 Keycloak 服务器部署到生产 Kubernetes 集群。

B.5 使用 Helm 图表运行 Keycloak

与 RabbitMQ 一样,DigitalOcean 不提供托管 Keycloak 服务。Keycloak 项目正在开发一个 Operator,但在撰写本文时,它仍处于测试阶段,因此我们将使用不同的方法:Helm 图表。

将 Helm 视为一个包管理器。要在您的计算机上安装软件,您会使用操作系统包管理器之一,例如 apt(Ubuntu)、Homebrew(macOS)或 Chocolatey(Windows)。在 Kubernetes 中,您可以使用 Helm,但它们被称为charts而不是packages

好吧,请在您的计算机上安装 Helm。您可以在官方网站上找到说明(helm.sh)。如果您使用的是 macOS 或 Linux,可以使用 Homebrew 安装 Helm:

$ brew install helm

我已经配置了所有必要的资源来使用 Bitnami 提供的 Keycloak Helm 图表(bitnami.com),并准备了一个脚本用于部署它。打开一个终端窗口,转到您的 Polar Deployment 项目(polar-deployment),然后导航到 kubernetes/platform/production/keycloak 文件夹。您应该在配置 Kubernetes 集群时将此文件夹复制到您的仓库中。如果不是这种情况,请现在从本书附带源代码仓库(Chapter15/15-end/polar-deployment/platform/production/keycloak)中执行此操作。

然后运行以下命令将 Keycloak 部署到您的生产 Kubernetes 集群:

$ ./deploy.sh

在运行之前,您可以自由地打开文件并查看说明。

注意:您可能需要首先使用命令 chmod +x deploy.sh 使脚本可执行。

脚本将输出所有部署 Keycloak 的操作的详细信息,并打印出您可以使用它访问 Keycloak 管理控制台的管理员用户名和密码。首次登录后,您可以自由更改密码。请注意记录凭证,因为您可能以后还需要它们。部署可能需要几分钟才能完成,所以这是一个休息和享用您选择的饮料作为对迄今为止所做一切奖励的好时机。干得好!

最后,脚本将创建一个包含客户端密钥的 polar-keycloak-client-credentials Secret,这是 Edge 服务用于与 Keycloak 进行身份验证所需的。您可以按照以下方式验证 Secret 是否已成功创建。该值由脚本随机生成:

$ kubectl get secrets polar-keycloak-client-credentials

注意:Keycloak Helm 图表在集群内部启动一个 PostgreSQL 实例,并使用它来持久化 Keycloak 使用的数据。我们本来可以将其与 DigitalOcean 管理的 PostgreSQL 服务集成,但 Keycloak 这一侧的配置将会相当复杂。如果您想使用外部 PostgreSQL 数据库,可以参考 Keycloak Helm 图表文档(bitnami.com/stack/keycloak/helm)。

Keycloak 服务器部署在专用的 keycloak-system 命名空间中。应用程序可以在集群内部通过 polar-keycloak.keycloak-system.svc.cluster.local 地址的 8080 端口与之交互。它也通过公网 IP 地址暴露在集群外部。您可以使用以下命令找到外部 IP 地址:

$ kubectl get service polar-keycloak -n keycloak-system

NAME             TYPE           CLUSTER-IP       EXTERNAL-IP
polar-keycloak   LoadBalancer   10.245.191.181   <external-ip>

平台可能需要几分钟来配置负载均衡器。在配置过程中,EXTERNAL-IP 列将显示 状态。等待并重试,直到显示 IP 地址。请注意记录,因为我们将在多个场景中使用它。

由于 Keycloak 通过公共负载均衡器暴露,你可以使用外部 IP 地址来访问管理控制台。打开浏览器窗口,导航到 http:///admin,并使用之前部署脚本返回的凭据登录。

现在,你有了 Keycloak 的公共 DNS 名称,可以定义几个 Secret 来配置边缘服务(OAuth2 客户端)、目录服务、订单服务(OAuth2 资源服务器)中的 Keycloak 集成。打开终端窗口,导航到你的 polar-deployment 项目中的 kubernetes/platform/production/keycloak 文件夹,并运行以下命令来创建应用程序将用于与 Keycloak 集成的 Secrets。在运行之前,你可以自由打开文件查看说明。请记住将 替换为你 Keycloak 服务器分配的外部 IP 地址:

$ ./create-secrets.sh http://<external-ip>/realms/PolarBookshop

关于 Keycloak 的内容到此结束。下一节将展示如何将 Polar UI 部署到生产集群。

B.6 运行 Polar UI

Polar UI 是一个使用 Angular 构建、由 NGINX 提供服务的单页应用程序。正如你在第十一章中看到的,我已经准备了一个容器镜像,你可以使用它来部署此应用程序,因为前端开发不在此书的范围之内。

打开终端窗口,进入你的 Polar 部署项目(polar-deployment),然后导航到 kubernetes/platform/production/polar-ui 文件夹。在配置 Kubernetes 集群时,你应该已经将此文件夹复制到你的仓库中。如果不是这种情况,请现在从本书附带源代码仓库(Chapter15/15-end/polar-deployment/platform/production/polar-ui)中执行此操作。

然后运行以下命令将 Polar UI 部署到你的生产 Kubernetes 集群。在运行之前,你可以自由打开文件查看说明:

$ ./deploy.sh

注意:你可能需要先使用命令 chmod +x deploy.sh 使脚本可执行。

现在,Polar UI 和所有主要平台服务都已启动并运行,你可以继续阅读第十五章,并完成 Polar Bookshop 中所有 Spring Boot 应用程序的生产部署配置。

B.7 删除所有云资源

当你完成对 Polar Bookshop 项目的实验后,请按照本节中的说明删除在 DigitalOcean 上创建的所有云资源。这是避免产生意外费用的基本要求。

首先,删除 Kubernetes 集群:

$ doctl k8s cluster delete polar-cluster

接下来,删除 PostgreSQL 和 Redis 数据库。首先你需要知道它们的 ID,所以运行以下命令来提取该信息:

$ doctl databases list

ID               Name           Engine    Version    Region    Status
<polar-db-id>    polar-db       pg        14         ams3      online
<redis-db-id>    polar-redis    redis     7          ams3      creating

然后继续使用之前命令返回的资源标识符删除这两个资源:

$ doctl databases delete <polar-db-id>
$ doctl databases delete <redis-db-id>

最后,打开浏览器窗口,导航到 DigitalOcean 网络界面 (cloud.digitalocean.com),并检查您账户中的不同云资源类别,以确认没有未结清的服务。如果有,请删除它们。创建集群或数据库时可能会作为副作用创建负载均衡器或持久卷,而这些可能没有被之前的命令删除。

posted @ 2025-11-24 09:16  绝不原创的飞龙  阅读(29)  评论(0)    收藏  举报