SpringBoot-微服务学习手册-全-
SpringBoot 微服务学习手册(全)
一、设置场景
微服务如今越来越受欢迎,应用也越来越广泛。这并不令人惊讶;这种软件架构风格有很多优点,比如灵活性和易于扩展。将微服务映射到一个组织中的小团队中也可以提高开发效率。然而,继续只知道好处的微服务冒险是一个错误的决定。分布式系统引入了额外的复杂性,因此您需要了解您所面临的情况,并提前做好准备。你可以从互联网上的许多书籍和文章中获得许多知识,但当你亲自动手编写代码时,情况就变了。
这本书以实用的方式涵盖了微服务的一些最重要的概念,但没有解释这些概念。首先,我们定义一个用例:要构建的应用。然后我们从一个小的整体开始,基于一些合理的推理。一旦我们有了最少的应用,我们就会评估是否值得迁移到微服务,以及迁移的好方法是什么。随着第二个微服务的引入,我们分析了他们通信的选项。然后,我们可以描述和实现事件驱动架构模式,通过通知系统的其他部分发生了什么,而不是显式地调用其他部分来采取行动,从而实现松散耦合。当我们达到这一点时,我们注意到一个设计糟糕的分布式系统有一些缺陷,我们必须用一些流行的模式来修复:服务发现、路由、负载平衡、可追溯性等等。将它们一个一个地添加到我们的代码库中,而不是将它们一起呈现,有助于我们理解这些模式。我们还使用 Docker 为云部署准备了这些微服务,并比较了运行应用的不同平台选择。
一步一步来的好处是,当需要解决概念的时候停下来,你会明白每个工具试图解决哪个问题。这就是为什么进化的例子是这本书的重要部分。您也可以不用编写一行代码就能理解这些概念,因为源代码在整个章节中都有介绍和解释。
本书包含的所有代码都可以在 GitHub 上的项目书-微服务-v2 中找到。有多个可用的存储库,分为章和节,这使您更容易看到应用是如何发展的。这本书包括每一部分所涉及的版本的注释。
你是谁?
让我们先从这个开始:这本书对你来说有多有趣?这本书很实用,我们来玩这个游戏吧。如果你认同这些陈述中的任何一个,这本书可能对你有好处:
-
“我想学习如何用 Spring Boot 构建微服务,以及如何使用相关工具。”
-
“每个人都在谈论微服务,但我还不知道什么是微服务。我只读过理论性的解释或炒作的文章。尽管我在 IT 行业工作,但我无法理解它的优势……”
-
“我想学习如何设计和开发 Spring Boot 应用,但我找到的要么是带有太简单示例的快速入门指南,要么是类似于官方文档的冗长书籍。我希望通过更现实的项目导向方法来学习这些概念。”
-
“我找到了一份新工作,他们正在使用微服务架构。我一直主要从事大型单体项目,所以我希望获得一些知识和指导,以了解那里的一切是如何工作的,以及这种架构的利弊。”
-
“每次我去自助餐厅,开发人员都在谈论微服务、网关、服务发现、容器、弹性模式等。如果我不明白同事们在说什么,我就无法再与他们交往。”(这个是个笑话;不要因为这个而读这本书,尤其是如果你对编程不感兴趣的话。)
关于阅读本书所需的知识,以下主题你应该很熟悉:
-
Java(我们用 Java 14)
-
Spring(你不需要很强的经验,但是你至少应该知道依赖注入是如何工作的)
-
Maven(如果你了解 Gradle,你也会很好)
这本书与其他书籍和指南有什么不同?
软件开发人员和架构师阅读许多技术书籍和指南,要么是因为我们对学习新技术感兴趣,要么是因为我们的工作需要它。我们无论如何都需要这样做,因为这是一个不断变化的世界。我们可以在那里找到各种各样的书籍和指南。好的通常是那些能让你快速学习的,不仅能教会你如何做事,还能教会你为什么要那样做。仅仅因为新技术是新的就使用新技术是错误的做法;你需要理解它们背后的原因,这样你才能以最好的方式使用它们。
这本书使用了这种哲学:它浏览了代码和设计模式,解释了遵循一种方法而不遵循其他方法的原因。
学习:一个渐进的过程
如果你看看网上的指南,你会很快注意到它们不是现实生活中的例子。通常,当你将这些案例应用到更复杂的场景时,它们并不适合。指南太肤浅,无法帮助你建立一些真实的东西。
另一方面,书在这方面做得更好。有很多好书围绕一个例子解释概念;它们很好,因为如果你看不到代码,将理论概念应用于代码并不总是容易的。这些书中有些书的问题是它们不如指南实用。您需要首先阅读它们来理解概念,然后编写(或查看)示例,这通常是作为一个整体给出的。当你直接看到最终版本时,很难将概念付诸实践。这本书停留在实践方面,从代码开始,通过章节发展,以便你一个一个地掌握概念。我们在暴露解决方案之前先掩盖问题。
由于这种增量式的概念呈现方式,这本书也允许你边学习边编码,并自己思考面临的挑战。
这是一本指南还是一本书?
你面前的这些页面不能被称为指南:你不会花 15 或 30 分钟来完成它们。此外,每章都介绍了所有必需的主题,为添加新代码奠定基础。但这也不是一本典型的书,在这本书中,你会经历一些孤立的概念,并用一些分散的代码片段来说明,这些代码片段是专门为这种情况而制作的。相反,您从一个现实生活中还不是最佳的应用开始,在了解了您可以从该过程中获得的好处之后,您将学习如何改进它。
这并不意味着你不能只是坐下来阅读它,但是如果你同时编码并使用所提供的选项和选择会更好。这是这本书的一部分,使它类似于指南。
无论如何,为了简单起见,从现在开始我们称它为一本书。
从基础到高级主题
这本书首先关注一些基本概念来理解其余的主题(第章第 2 ): Spring Boot、测试、日志等等。然后,它涵盖了如何使用众所周知的分层设计来设计和实现生产就绪的 Spring Boot 应用,并深入到如何实现 REST API、业务逻辑和数据库存储库(第 3 和 5 章)。在这样做的时候,你会看到 Spring Boot 内部是如何工作的,所以它对你来说不再是魔法了。您还将学习如何用 React 构建一个基本的前端应用(第四章),因为这将帮助您直观地了解后端架构如何影响前端。在那之后,这本书进入了微服务世界,在一个不同的 Spring Boot 应用中引入了第二个功能。这个实例可以帮助您分析在决定迁移到微服务之前应该考虑的因素(第六章)。然后,您将了解同步和异步通信微服务之间的区别,以及事件驱动架构如何帮助您保持系统组件的解耦(第七章)。从那里开始,这本书将带您经历适用于分布式系统的工具和框架之旅,以实现重要的非功能性需求:弹性、可伸缩性、可追溯性和部署到云中等等(第章第八部分)。
如果您已经熟悉了 Spring Boot 应用及其工作原理,您可以快速浏览前几章,将注意力更多地放在本书的第二部分。在那里,我们涵盖了更高级的主题,如事件驱动设计、服务发现、路由、分布式跟踪、Cucumber 测试等。然而,请注意我们在第一部分中建立的基础:测试驱动的开发,对最小可行产品(MVP)的关注,以及整体优先。
骷髅尖兵带着 Spring Boot,专业的道
首先,这本书指导你使用 Spring Boot 创建一个应用。所有内容主要集中在后端,但是您将使用 React 创建一个简单的 web 页面来演示如何将公开的功能用作 REST API。
需要指出的是,我们创建“快捷代码”并不仅仅是为了展示 Spring Boot 的特性:这不是本书的目的。我们使用 Spring Boot 作为教授概念的工具,但是我们可以使用任何其他的框架,这本书的思想仍然有效。
您将学习如何按照众所周知的三层模式设计和实现应用。您可以通过一个增量示例和实际操作代码来实现这一点。在编写应用时,我们还会暂停几次,深入了解 Spring Boot 如何用这么少的代码工作的细节(自动配置、启动器等)。).
测试驱动开发
在第一章中,我们使用测试驱动开发(TDD)来将前提条件映射到技术特性。这本书试图以一种你可以从一开始就看到好处的方式展示这种技术:为什么在编写代码之前考虑测试用例总是一个好主意。JUnit 5、AssertJ 和 Mockito 将帮助我们高效地构建有用的测试。
计划如下:您将学习如何首先创建测试,然后使它们失败,最后实现使它们工作的逻辑。
微服务
一旦你的第一个应用准备好了,我们将引入第二个应用,它将与现有的功能进行交互。从那时起,您将拥有一个微服务架构。如果你只有其中一项,那么试图去了解微服务的优势是没有任何意义的。现实生活中的场景总是功能被分割成不同服务的分布式系统。像往常一样,为了保持实用性,我们将为我们的案例研究分析具体情况,以便您了解迁移到微服务是否符合您的需求。
这本书不仅涵盖了拆分系统的原因,还涵盖了这种选择带来的弊端。一旦决定迁移到微服务,您将了解应该使用哪些模式来为分布式系统构建良好的架构:服务发现、路由、负载平衡、分布式跟踪、容器化和其他一些支持机制。
事件驱动系统
微服务不一定需要的另一个概念是事件驱动架构。本书使用它,因为它是一种非常适合微服务架构的模式,并且您将基于好的示例做出选择。您将看到同步和异步通信之间的区别,以及它们的主要优缺点。
这种异步的思维方式引入了新的代码设计方式,最终一致性是要接受的关键变化之一。您将在编写项目代码时看到它,使用 RabbitMQ 在微服务之间发送和接收消息。
非功能需求
当您在现实世界中构建应用时,您必须考虑一些与功能没有直接关系的需求,但是这些需求使您的系统更加健壮,在出现故障的情况下继续工作,或者确保数据完整性,例如。
许多这些非功能性需求都与软件可能出错的事情有关:网络故障导致部分系统无法访问,高流量导致后端容量崩溃,外部服务没有响应,等等。
在本书中,您将学习如何实现和验证模式,以使系统更具弹性和可伸缩性。此外,我们将讨论数据完整性的重要性以及帮助我们保证数据完整性的工具。
学习如何设计和解决所有这些非功能性需求的好处在于,它是适用于任何系统的知识,不管您使用的是什么编程语言和框架。
在线内容
对于这本书的第二版,我决定创建一个在线空间,在那里您可以不断学习与微服务架构相关的新主题。在这个网页上,您将找到新的指南,这些指南扩展了涵盖分布式系统其他重要方面的实际用例。此外,使用最新依赖项的新版本存储库将在那里发布。
你可以在网上找到的第一个指南是关于用 Cucumber 测试分布式系统的。这个框架帮助我们构建人类可读的测试脚本,以确保我们的功能端到端地工作。
请访问 https://tpd.io/book-extra
了解关于这本书的所有额外内容和新更新。
摘要
本章介绍了这本书的主要目标:通过简单的开始,然后通过开发一个示例项目来增长你的知识,来教你微服务架构的主要方面。
我们还简要介绍了这本书的主要内容:从 monolith-first 到 Spring Boot 的微服务,测试驱动开发,事件驱动系统,通用架构模式,非功能需求,以及 Cucumber 的端到端测试(在线)。
下一章将从我们学习道路的第一步开始:复习一些基本概念。
二、基本概念
这本书遵循一种实用的方法,因此所涉及的大多数工具都是在我们需要时介绍的。但是,我们将分别讨论一些核心概念,因为它们要么是我们不断发展的示例的基础,要么在代码示例中广泛使用,即 Spring、Spring Boot、测试库、Lombok 和日志记录。这些概念值得单独介绍,以避免我们学习过程中的长时间中断,这就是为什么本章对它们进行了概述。
请记住,接下来的部分并不打算为您提供这些框架和库的完整知识库。本章的主要目标是,要么你在头脑中刷新概念(如果你已经学过的话),要么你掌握基础知识,这样你就不需要在阅读其余章节之前查阅外部参考资料。
Spring
Spring 框架是简化软件开发的大量库和工具,即依赖注入、数据访问、验证、国际化、面向方面的编程等。对于 Java 项目来说,这是一个受欢迎的选择,它也可以与其他基于 JVM 的语言一起工作,比如 Kotlin 和 Groovy。
Spring 如此受欢迎的原因之一是,它为软件开发的许多方面提供了内置实现,从而节省了大量时间,例如:
-
Spring Data 简化了关系数据库和 NoSQL 数据库的数据访问。
-
Spring Batch 为大量记录提供强大的处理能力。
-
Spring Security 是一个安全框架,它将安全特性抽象到应用中。
-
Spring Cloud 为开发者提供工具,快速构建分布式系统中的一些常用模式。
-
Spring Integration 是企业集成模式的一种实现。它使用轻量级消息传递和声明性适配器促进了与其他企业应用的集成。
正如你所看到的,Spring 被分成不同的模块。所有模块都构建在核心 Spring 框架之上,为软件应用建立了一个通用的编程和配置模型。这个模型本身是选择框架的另一个重要原因,因为它促进了良好的编程技术,例如使用接口而不是类,通过依赖注入来分离应用层。
Spring 中的一个关键主题是反转控制(IoC)容器,它由ApplicationContext
接口支持。Spring 在您的应用中创建了这个“空间”,您和框架本身可以在其中放置一些对象实例,如数据库连接池、HTTP 客户端等。这些被称为bean的对象可以在应用的其他部分使用,通常通过它们的公共接口从特定的实现中抽象出代码。从其他类的应用上下文中引用这些 beans 之一的机制就是我们所说的依赖注入,在 Spring 中,这可以通过 XML 配置或代码注释来实现。
Spring Boot
Spring Boot 是一个利用 Spring 快速创建基于 Java 语言的独立应用的框架。它已经成为构建微服务的流行工具。
Spring 和其他相关的第三方库中有如此多的可用模块可以与框架结合,这对软件开发来说是非常强大的。然而,尽管做了很多努力来简化 Spring 配置,您仍然需要花一些时间来设置应用所需的一切。有时,您只是需要一遍又一遍地使用相同的配置。应用的引导,也就是配置 Spring 应用使其启动并运行的过程,有时会很乏味。Spring Boot 的优势在于,它通过提供自动为您设置的默认配置和工具,消除了大部分流程。主要的缺点是,如果你太依赖这些默认值,你可能会失去控制和意识到发生了什么。我们将在书中揭示一些 Spring Boot 的实现,以展示它在内部是如何工作的,这样您就可以随时掌控一切。
Spring Boot 提供了一些预定义的启动包,就像是 Spring 模块和一些第三方库和工具的集合。例如,spring-boot-starter-web
帮助您构建一个独立的 web 应用。它将 Spring 核心 Web 库与 Jackson (JSON 处理)、验证、日志、自动配置,甚至一个嵌入式 Tomcat 服务器以及其他工具组合在一起。
除了启动器之外,自动配置在 Spring Boot 中也起着关键作用。这个特性使得向应用添加功能变得极其容易。按照同样的例子,仅仅通过包含 web starter,您将得到一个嵌入式 Tomcat 服务器。不需要配置任何东西。这是因为 Spring Boot 自动配置类会扫描您的类路径、属性、组件等。,并基于此加载一些额外的 beans 和行为。
为了能够为您的 Spring Boot 应用管理不同的配置选项,框架引入了概要文件。例如,在开发环境和生产环境中使用数据库时,可以使用配置文件为要连接的主机设置不同的值。此外,您可以使用不同的概要文件进行测试,其中您可能需要公开应用的附加功能或模拟部分。我们将在第八章中更详细地介绍个人资料。
我们将使用 Spring Boot Web 和数据启动器来快速构建一个具有持久存储的 Web 应用。Test starter 将帮助我们编写测试,因为它包括一些有用的测试库,如 JUnit 和 AssertJ。然后,我们将通过添加 AMQP 启动器为我们的应用添加消息传递功能,它包括一个消息代理集成(RabbitMQ ),我们将使用它来实现一个事件驱动的架构。在第八章中,我们将包括不同类型的启动器,归入 Spring Cloud 家族。我们将利用其中的一些工具来实现分布式系统的通用模式:路由(Spring Cloud Gateway)、服务发现(Consul)和负载平衡(Spring Cloud Load Balancer)等等。现在不要担心所有这些新术语;当我们在实际例子上取得进展时,我们将详细解释它们。
下一章将基于一个实际的例子详细介绍这些启动器和 Spring Boot 自动配置是如何工作的。
龙目岛和爪哇
本书中的代码示例使用了 Project Lombok,这是一个基于注释生成 Java 代码的库。将 Lombok 包括在本书中的主要原因是教育意义:它保持了代码样本的简洁,减少了样板文件,因此读者可以专注于它所关注的内容。
让我们用第一个简单的类作为例子。我们想要创建一个具有两个因素的不可变乘法挑战类。参见清单 2-1 。
public final class Challenge {
// Both factors
private final int factorA;
private final int factorB;
public Challenge(int factorA, int factorB) {
this.factorA = factorA;
this.factorB = factorB;
}
public int getFactorA() {
return this.factorA;
}
public int getFactorB() {
return this.factorB;
}
public boolean equals(final Object o) {
if (o == this) return true;
if (!(o instanceof Challenge)) return false;
final Challenge other = (Challenge) o;
if (this.getFactorA() != other.getFactorA()) return false;
if (this.getFactorB() != other.getFactorB()) return false;
return true;
}
public int hashCode() {
final int PRIME = 59;
int result = 1;
result = result * PRIME + this.getFactorA();
result = result * PRIME + this.getFactorB();
return result;
}
public String toString() {
return "Challenge(factorA=" + this.getFactorA() + ", factorB=" + this.getFactorB() + ")";
}
}
Listing 2-1The Challenge Class in Plain Java
正如您所看到的,整个类都有一些经典的样板代码:构造函数、getters 以及equals
、hashCode
和toString
方法。它们并没有给这本书增加多少内容,但是我们需要它们来让代码工作。
同一个类可以用 Lombok 化简为它的最小表达式。参见清单 2-2 。
import lombok.Value;
@Value
public class Challenge {
// Both factors
int factorA;
int factorB;
}
Listing 2-2The Challenge Class Using Lombok
Lombok 提供的@Value
注释集合了这个库中的一些其他注释,我们也可以单独使用。以下每个注释都指示 Lombok 在 Java 构建阶段之前生成代码块:
-
用所有现有字段创建一个构造函数。
-
@FieldDefaults
使我们的领域private
和final
。 -
@Getter
为factorA
和factorB
生成 getters。 -
包含了一个简单的连接字段的实现。
-
默认情况下,
@EqualsAndHashCode
使用所有字段生成基本的equals()
和hashCode()
方法,但是我们也可以定制它。
Lombok 不仅将我们的代码减到最少,而且当您需要修改这些类时,它也会有所帮助。向 Lombok 中的Challenge
类添加一个新字段意味着添加一行(不包括该类的用法)。如果我们使用普通的 Java 版本,我们需要给构造函数添加新的参数,添加equals
和hashCode
方法,并添加一个新的 getter。这不仅意味着额外的工作,而且容易出错:例如,如果我们忘记了等于方法中的额外字段,我们就会在应用中引入一个 bug。
像许多工具一样,Lombok 也有批评者。不喜欢 Lombok 的主要原因是,由于向类中添加代码很容易,您可能最终会添加您并不真正需要的代码(例如 setters 或额外的构造函数)。此外,你可能会认为拥有一个好的代码生成 IDE 和一个重构助手或多或少会有帮助。请记住,要正确使用 Lombok,您需要您的 IDE 提供对它的支持。这可能是自然发生的,也可能是通过插件发生的。例如,在 IntelliJ 中,你必须下载并安装 Lombok 插件。项目中的所有开发人员都必须使他们的 IDE 适应 Lombok,所以尽管这很容易做到,但你可以看到这是一个额外的不便。
在接下来的章节中,我们将主要使用这些 Lombok 特性:
-
我们用
@Value
注释不可变的类。 -
对于数据实体,我们分别使用前面描述的一些注释。
-
我们为 Lombok 添加了
@Slfj4
注释,以使用 Java API (SLF4J)的标准简单日志外观创建一个日志记录器。本章中的“日志记录”一节给出了关于这些概念的更多背景知识。
在任何情况下,当我们查看代码示例时,我们将描述这些注释做什么,所以您不需要深入了解它们如何工作的更多细节。
如果您喜欢普通的 Java 代码,只需使用本书中 Lombok 的代码注释作为参考,就可以知道您的类中需要包含哪些额外的代码。
Java 记录
从 JDK 14 开始,Java 记录功能在预览模式下可用。如果我们使用这个特性,我们也可以用简洁的方式用纯 Java 编写我们的Challenge
类。
public
record Challenge(int factorA, int factorB) {}
然而,在撰写本书时,这个特性还没有与其他库和框架完全集成。此外,与 Java 记录相比,Lombok 增加了一些额外的特性和更好的选项粒度。由于这些原因,我们不会在本书中使用记录。
测试基础
在这一节中,我们将介绍一些重要的测试方法和库。我们将在下一章把它们付诸实践,所以先学习(或复习)基本概念是有好处的。
测试驱动开发
本书的第一个实用章节鼓励你使用测试驱动开发 (TDD)。这种技术可以帮助你首先关注你的需求和期望,然后再关注实现。作为一名开发人员,它让您思考在某些情况或用例下代码应该做什么。在现实生活中,TDD 也帮助你明确模糊的需求,丢弃无效的需求。
鉴于这本书是由实际案例驱动的,你会发现 TDD 非常适合主要目的。
行为驱动开发
除了先写测试再写逻辑的想法之外,行为驱动开发 (BDD)为测试带来了更好的结构和可读性。
在 BDD 中,我们根据 Given-When-Then 结构编写测试。这消除了开发人员和业务分析师在将用例映射到测试时的隔阂。分析师可以直接阅读代码,并识别出正在测试的内容。
请记住,像 TDD 一样,BDD 本身是一个开发过程,而不仅仅是编写测试的一种方式。它的主要目标是促进对话,以改进需求及其测试用例的定义。在本书中,我们关于 BDD 的重点将放在测试结构上。参见清单 2-3 中这些测试的例子。
@Test
public void getRandomMultiplicationTest() throws Exception {
// given
given(challengeGeneratorService.randomChallenge())
.willReturn(new Challenge(70, 20));
// when
MockHttpServletResponse response = mvc.perform(
get("/multiplications/random")
.accept(MediaType.APPLICATION_JSON))
.andReturn().getResponse();
// then
then(response.getStatus()).isEqualTo(HttpStatus.OK.value());
then(response.getContentAsString())
.isEqualTo(json.write(new Challenge(70, 20)).getJson());
}
Listing 2-3An Example of a BDD Test Case Using a Given-When-Then Structure
单元测试
本书中的代码使用 JUnit 5 进行单元测试。Spring Boot 测试入门包含了这些库,所以我们不需要在依赖项中包含它。
一般来说,单元测试背后的思想是你可以单独验证你的类(单元)的行为。在本书中,我们将为每个放置逻辑的类编写单元测试。
在 JUnit 5 的所有特性中,我们将主要使用下面列出的基本特性:
-
@BeforeEach
和@AfterEach
分别表示每次测试前后应该执行的代码。 -
对于代表我们想要执行的测试的每一个方法。
-
@ExtendsWith
在类级别添加 JUnit 5 扩展。我们将用它来将 Mockito 扩展和 Spring 扩展添加到我们的测试中。
莫基托
Mockito 是一个用于 Java 单元测试的模仿框架。当您模仿一个类时,您正在用一些预定义的指令覆盖该类的真实行为,这些指令指示它们的方法应该为它们的参数返回什么或做什么。这是编写单元测试的一个重要要求,因为您只想验证一个类的行为,并模拟它的所有交互。
用 Mockito 模仿一个类的最简单的方法是在 JUnit 5 的一个字段中使用与MockitoExtension
结合的@Mock
注释。参见清单 2-4 。
@ExtendWith(MockitoExtension.class)
public class MultiplicationServiceImplTest {
@Mock
private ChallengeAttemptRepository attemptRepository;
// [...] -> tests
}
Listing 2-4MockitoExtension
and Mock Annotation Usage
然后,我们可以使用静态方法Mockito.when
来定义定制行为。参见清单 2-5 。
import static org.mockito.Mockito.when;
// ...
when(attemptRepository.methodThatReturnsSomething())
.thenReturn(predefinedResponse);
Listing 2-5Defining Custom Behavior with Mockito’s when
然而,我们将使用来自BDDMockito
的替代方法,也包含在 Mockito 依赖项中。这给了我们一种可读性更好的、BDD 风格的编写单元测试的方法。参见清单 2-6 。
import static org.mockito.BDDMockito.given;
// ...
given(attemptRepository.methodThatReturnsSomething())
.willReturn(predefinedResponse);
Listing 2-6Using given to Define Custom Behavior
在某些情况下,我们还需要检查对模拟类的预期调用是否被调用。对于 Mockito,我们使用verify()
来表示。参见清单 2-7 。
import static org.mockito.Mockito.verify;
// ...
verify(attemptRepository).save(attempt);
Listing 2-7Verifying an Expected Call
作为一些额外的背景,很高兴知道还有一个verify()
的 BDD 变体,叫做then()
。不幸的是,当我们将来自 AssertJ 的BDDMockito
和BDDAssertions
结合起来时,这种替换可能会令人困惑(下一节将介绍)。由于在本书中我们将更广泛地使用断言而不是验证,我们将选择verify
来更好地区分它们。
清单 2-8 展示了一个使用 JUnit 5 和 Mockito 进行测试的完整示例,该测试基于我们将在本书后面实现的一个类。现在,您可以忽略then
断言;我们很快就会到达那里。
package microservices
.book.multiplication.challenge;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import microservices.book.multiplication.event.ChallengeSolvedEvent;
import microservices.book.multiplication.event.EventDispatcher;
import microservices.book.multiplication.user.User;
import microservices.book.multiplication.user.UserRepository;
import static org.assertj.core.api.BDDAssertions.then;
import static org.mockito.BDDMockito.*;
@ExtendWith(MockitoExtension.class)
public class ChallengeServiceImplTest {
private ChallengeServiceImpl challengeServiceImpl;
@Mock
private ChallengeAttemptRepository
attemptRepository;
@Mock
private UserRepository userRepository;
@Mock
private EventDispatcher eventDispatcher;
@BeforeEach
public void setUp() {
challengeServiceImpl = new ChallengeServiceImpl(attemptRepository,
userRepository, eventDispatcher);
}
@Test
public void checkCorrectAttemptTest() {
// given
long userId = 9L, attemptId = 1L;
User user = new User("john_doe");
User savedUser = new User(userId, "john_doe");
ChallengeAttemptDTO attemptDTO =
new ChallengeAttemptDTO(50, 60, "john_doe", 3000);
ChallengeAttempt attempt =
new ChallengeAttempt(null, savedUser, 50, 60, 3000, true);
ChallengeAttempt storedAttempt =
new ChallengeAttempt(attemptId, savedUser, 50, 60, 3000, true);
ChallengeSolvedEvent event = new ChallengeSolvedEvent(attemptId, true,
attempt.getFactorA(), attempt.getFactorB(), userId,
attempt.getUser().getAlias());
// user does not exist, should be created
given(userRepository.findByAlias("john_doe"))
.willReturn(Optional.empty());
given(userRepository.save(user))
.willReturn(savedUser);
given(attemptRepository.save(attempt))
.willReturn(storedAttempt);
// when
ChallengeAttempt resultAttempt =
challengeServiceImpl.checkAttempt(attemptDTO);
// then
then(resultAttempt.isCorrect()).isTrue();
verify(userRepository).save(user);
verify(attemptRepository).save(attempt);
verify(eventDispatcher).send(event);
}
}
Listing 2-8A Complete Unit Test with JUnit5 and Mockito
维护
用 JUnit 5 验证预期结果的标准方法是使用断言。
assertEquals("Hello, World!", actualGreeting);
不仅断言所有类型的对象相等,而且验证真/假、空、超时前执行、抛出异常等。你可以在断言 Javadoc ( https://tpd.io/junit-assert-docs
)中找到它们。
尽管 JUnit 断言在大多数情况下已经足够了,但是它们不像 AssertJ 提供的那样易于使用和阅读。这个库实现了编写断言的流畅方式,并提供了额外的功能,因此您可以编写更简洁的测试。
在其标准形式中,前面的示例如下所示:
assertThat(actualGreeting).isEqualTo("Hello, World!");
然而,正如我们在前面的章节中提到的,我们想要利用 BDD 语言方法。因此,我们将使用 AssertJ 中包含的BDDAssertions
类。这个类包含所有assertThat
案例的等价方法,重命名为then
。
then(actualGreeting).isEqualTo("Hello, World!");
在本书中,我们将主要从 AssertJ 的一些基本主张。如果你有兴趣扩展你关于 AssertJ 的知识,你可以从官方文档页面( https://tpd.io/assertj
)开始。
在 Spring Boot 测试
JUnit 5 和 AssertJ 都包含在spring-boot-starter-test
中,所以我们只需要在我们的 Spring Boot 应用中包含这个依赖项就可以使用它们。然后,我们可以使用不同的测试策略。
在 Spring Boot,编写测试最流行的方式之一是利用@SpringBootTest
注释。它将启动一个 Spring 上下文,并使所有已配置的 beans 可用于测试。如果您正在运行集成测试,并且想要验证应用的不同部分是如何协同工作的,这种方法很方便。
当测试应用的特定部分或单个类时,最好使用简单的单元测试(根本不用 Spring)或更细粒度的注释,如@WebMvcTest
,专注于控制器层测试。这是我们将在书中使用的方法,所以当我们到达那里时,我们将更详细地解释它。
现在,让我们只关注本章中描述的库和框架之间的集成。
-
Spring 测试库(包含在 Spring Boot 测试启动工具中)带有一个
SpringExtension
,因此您可以通过@ExtendWith
注释将 Spring Integration 到 JUnit 5 测试中。 -
Spring Boot 测试包引入了
@MockBean
注释,我们可以用它来替换或添加 Spring 上下文中的 bean,就像 Mockito 的@Mock
注释替换给定类的行为一样。这有助于单独测试应用层,这样您就不需要将 Spring 上下文中所有真正的类行为放在一起。在测试我们的应用控制器时,我们将看到一个实际的例子。
记录
在 Java 中,我们可以通过使用System.out
和System.err
打印流将消息记录到控制台。
System.out.println("Hello, standard output stream!");
这被认为对于一个 12 因素应用( https://tpd.io/12-logs
)来说已经足够好了,这是一组流行的编写云原生应用的最佳实践。原因是,最终,一些其他工具将从系统级的标准输出中收集它们,并在外部框架中聚合它们。
因此,我们将把日志写到标准和错误输出中。但这并不意味着我们必须坚持 Java 中简单丑陋的变体。
大多数专业的 Java 应用都使用 LogBack 之类的日志实现。而且,考虑到 Java 有多种日志框架,选择一个通用的抽象比如 SLF4J 就更好了。
好消息是 Spring Boot 已经为我们设置了所有的日志配置。默认实现是登录回退,Spring Boot 预配置的消息格式如下:
2020-03-22 10:19:59.556 INFO 93532 --- [main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path ''
也支持 SLF4J 记录器。要使用记录器,我们通过LoggerFactory
创建它。它需要的唯一参数是一个名称。默认情况下,通常使用工厂方法,该方法获取类本身并从中获取记录器名称。参见清单 2-9 。
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
class ChallengeServiceImpl {
private static final Logger log = LoggerFactory.getLogger(ChallengeServiceImpl.class);
public void dummyMethod() {
var name = "John";
log.info("Hello, {}!", name);
}
}
Listing 2-9Creating and Using a Logger with SLF4J
正如您在示例中看到的,记录器支持通过花括号占位符替换参数。
考虑到我们在本书中使用的是 Lombok,我们可以用一个简单的注释:@Slf4j
替换那行代码,在我们的类中创建一个日志记录器。这有助于保持我们的代码简洁。默认情况下,Lombok 会创建一个名为log
的静态变量。见清单 2-10 。
import lombok.extern.slf4j.Slf4j;
@Slf4j
class ChallengeServiceImpl {
public void dummyMethod() {
var name = "John";
log.info("Hello, {}!", name);
}
}
Listing 2-10Using a Logger with Lombok
总结和成就
在这一章中,我们回顾了将在书中用到的一些基本库和概念:Spring Boot、Lombok、JUnit 和 AssertJ 测试以及日志。这些只是您将在旅程中学到的一小部分,但它们是单独介绍的,以避免在主要学习路径中长时间停顿。所有其他的主题,更多的是与我们不断发展的架构相关,在我们浏览书页的时候会详细解释。
如果你仍然觉得你有一些知识差距,不要担心。下一章中的实用代码示例将通过提供额外的上下文帮助你理解这些概念。
章节成就:
-
你回顾了关于 Spring 和 Spring Boot 的核心观点。
-
您已经了解了我们将如何在书中使用 Lombok 来减少样板代码。
-
您学习了如何使用像 JUnit、Mockito 和 AssertJ 这样的工具来实现测试驱动的开发,以及如何在 Spring Boot 中集成这些工具。
-
您回顾了一些日志记录基础知识,以及如何在 Lombok 中使用日志记录器。
三、一个基本的 Spring Boot 应用
我们可以直接开始编写代码,但是,即使这样做很实用,也远远不能成为现实。相反,我们将定义一个我们想要构建的产品,并将它分成小块。这种面向需求的方法在整本书中都有使用,以使它更加实用。在现实生活中,您总是会有这些业务需求。
我们想要一个 web 应用来鼓励用户每天锻炼大脑。首先,我们将向用户展示两位数的乘法,每次用户访问页面时都会显示一次。他们将键入他们的别名(简称)和他们对操作结果的猜测。这个想法是他们应该只用心算。在他们发送数据后,网页会提示用户猜测是否正确。
此外,我们希望尽可能保持用户的积极性。为了实现这一点,我们将使用一些游戏化。对于每个正确的猜测,我们给用户分数,他们将在一个排名中看到自己的分数,这样他们就可以与其他人竞争。
这是我们将构建的完整应用的主要思想,我们的产品愿景。但我们不会一次建成。这本书将模拟一种敏捷的工作方式,在这种方式中,我们将需求分解成用户故事,即小块的功能,这些功能本身就有价值。我们将遵循这种方法,使本书尽可能贴近现实生活,因为绝大多数 IT 公司都使用敏捷。
先从简单的开始,先把重点放在乘法求解逻辑上。考虑这里的第一个用户故事。
用户故事 1
作为该应用的用户,我想使用心算解决一个随机乘法问题,所以我锻炼了我的大脑。
为了实现这一点,我们需要构建一个 web 应用的最小框架。因此,我们将把用户故事分成几个子任务。
-
创建具有业务逻辑的基本服务。
-
创建一个基本的 API 来访问这个服务(REST API)。
-
创建一个基本的网页,要求用户解决计算。
在这一章中,我们将关注 1 和 2。在创建了我们的第一个 Spring Boot 应用的框架之后,我们将使用测试驱动开发来构建这个组件的主要逻辑:生成乘法挑战并验证用户解决这些挑战的尝试。然后,我们将添加实现 REST API 的控制器层。您将了解这种分层设计的优势。
我们的学习路径包括一些关于 Spring Boot 最重要的特性之一的推理:自动配置。我们将使用我们的实际案例来看看,例如,应用如何包含它自己的嵌入式 web 服务器,仅仅是因为我们向我们的项目添加了一个特定的依赖项。
设置开发环境
我们将在本书中使用 Java 14。确保你至少从官方下载页面( https://tpd.io/jdk14
)获得那个版本的 JDK。按照操作系统的说明安装它。
好的 IDE 也方便开发 Java 代码。如果你有更喜欢的,就用它。否则,您可以下载例如 IntelliJ IDEA 或 Eclipse 的社区版本。
在本书中,我们还将使用 HTTPie 来快速测试我们的 web 应用。它是一个命令行工具,允许我们与 HTTP 服务器进行交互。您可以按照 https://tpd.io/httpie-install
处的说明下载适用于 Linux、Mac 或 Windows 的软件。或者,如果你是一个curl
用户,你也可以很容易地将这本书的http
命令映射到curl
命令。
框架网络应用
是时候写点代码了!Spring 提供了一种构建应用框架的奇妙方式:Spring Initializr。这是一个网页,允许我们选择要在我们的 Spring Boot 项目中包含哪些组件和库,它将结构和依赖项配置生成到一个我们可以下载的 zip 文件中。我们将在书中多次使用 Initializr,因为它节省了从头创建项目的时间,但是如果您喜欢,您也可以自己创建项目。
源代码:第三章
您可以在 GitHub 的chapter03
资源库中找到本章的所有源代码。
https://github.com/Book-Microservices-v2/chapter03
见。
我们导航到 https://start.spring.io/
,填写一些数据,如图 3-1 所示。
图 3-1
用 Spring Initializr 创建 Spring Boot 项目
本书中的所有代码都使用 Maven、Java 和 Spring Boot 版本 2.3.3,所以让我们坚持使用它们。如果 Spring Boot 版本不可用,您可以选择更新版本。在这种情况下,如果您想使用与书中相同的版本,记得稍后在生成的pom.xml
文件中更改它。您也可以继续使用其他 Java 和 Spring Boot 版本,但是本书中的一些代码示例可能不适合您。查看在线图书资源( https://tpd.io/book-extra
)了解关于兼容性和升级的最新消息。
给组(microservices.book
)和工件(multiplication
)一些值。选择 Java 14。不要忘记从列表或搜索工具中添加依赖项 Spring Web、Validation 和 Lombok。你已经知道了 Lombok 的用途,你将在本章看到其他两个依赖项的作用。这就是我们目前所需要的。
生成项目并提取 ZIP 内容。multiplication
文件夹包含运行应用所需的一切。现在您可以用您最喜欢的 IDE 打开它,通常是通过选择pom.xml
文件。
这些是我们将在自动生成的包中找到的主要元素:
-
Maven 的
pom.xml
文件包含应用元数据、配置和依赖项。这是 Maven 用来构建应用的主文件。我们将分别检查 Spring Boot 添加的一些依赖项。在这个文件中,您还可以找到使用 Spring Boot 的 Maven 插件构建应用的配置,该插件也知道如何将其所有依赖项打包到一个独立的.jar
文件中,以及如何从命令行运行这些应用。 -
有 Maven 包装器。这是 Maven 的独立版本,所以你不需要安装它来构建你的应用。这些是用于基于 Windows 和 UNIX 的系统的
.mvn
文件夹和mvnw
可执行文件。 -
我们会找到一个
HELP.md
文件,里面有一些 Spring Boot 文档的链接。 -
假设我们将使用 Git 作为版本控制系统,包含的
.gitignore
有一些预定义的排除,所以我们不会将编译的类或任何 IDE 生成的文件提交到存储库中。 -
src
文件夹遵循标准的 Maven 结构,将代码分成子文件夹main
和test
。两个文件夹都可能包含他们各自的java
和resources
孩子。在这种情况下,我们的主代码和测试都有一个源文件夹,主代码有一个资源文件夹。-
在我们的主源中有一个默认创建的类,
MultiplicationApplication
。它已经用@SpringBootApplication
进行了注释,并且包含了启动应用的main
方法。这是定义 Spring Boot 应用主类的标准方式,详见参考文档(https://tpd.io/sb-annotation
)。我们稍后会看一看这个类。 -
在 resources 文件夹中,我们找到两个空的子文件夹:
static
和templates
。您可以安全地删除它们,因为它们旨在包含我们不会使用的静态资源和 HTML 模板。 -
application.properties
文件是我们可以配置 Spring Boot 应用的地方。我们稍后将在这里添加一些配置参数。
-
既然我们已经了解了这个骨架的不同部分,让我们试着让它行走。要运行此应用,您可以使用您的 IDE 界面或使用项目根文件夹中的以下命令:
multiplication $ ./mvnw spring-boot:run
从终端运行命令
在本书中,我们使用$
字符来表示命令提示符。该字符之后的所有内容都是命令本身。有时,需要强调的是,您必须在工作区的给定文件夹中运行该命令。在这种情况下,您会在$
字符前找到文件夹名称(例如multiplication $
)。当然,你的工作空间的具体位置可能会有所不同。
还要注意,根据您使用的是基于 UNIX 的操作系统(如 Linux 或 Mac)还是 Windows,一些命令可能会有所不同。本书中显示的所有命令都使用基于 UNIX 的系统版本。
当我们运行这个命令时,我们使用了包含在项目主文件夹(mvnw
)中的 Maven 包装器,目标是(Maven 可执行文件旁边的内容)spring-boot:run
。这个目标是由 Spring Boot 的 Maven 插件提供的,也包含在 Initializr 网页生成的pom.xml
文件中。Spring Boot 应用应该会成功启动。日志中的最后一行应该是这样的:
INFO 4139 --- [main] m.b.m.MultiplicationApplication: Started MultiplicationApplication in 6.599 seconds (JVM running for 6.912)
太好了。我们不用写一行代码就能运行第一个 Spring Boot 应用!然而,我们还不能做太多的事情。这个应用在做什么?我们很快就会知道的。
Spring Boot 自动配置
在我们的 skeleton 应用的日志中,您还可以找到这样一行日志:
INFO 30593 --- [main] o.s.b.w.embedded.tomcat.TomcatWebServer: Tomcat initialized with port(s): 8080 (http)
由于 Spring 中的自动配置特性,当我们添加 web 依赖时,我们得到的是一个使用 Tomcat 的可独立部署的 web 应用。
正如我们在前一章介绍的,Spring Boot 自动设置库和默认配置。当我们依赖所有这些默认值时,这为我们节省了大量时间。其中一个约定是,当我们将 Web starter 添加到项目中时,添加一个现成的 Tomcat 服务器。
为了学习更多关于 Spring Boot 自动配置的知识,让我们一步一步地看看这个具体的例子是如何工作的。也可使用图 3-2 获得一些有用的视觉帮助。
我们自动生成的 Spring Boot 应用有一个用@SpringBootApplication
注释的主类。这是一个快捷方式注释,因为它集合了其他几个注释,其中就有@EnableAutoConfiguration
。顾名思义,通过这个,我们可以启用自动配置功能。因此,Spring 激活了这个智能机制,从您自己的代码和您的依赖项中找到并处理用@Configuration
注释进行了注释的类。
我们的项目包括依赖关系spring-boot-starter-web
。这是 Spring Boot 的主要组件之一,它拥有构建 web 应用的工具。在这个工件的依赖项中,Spring Boot 的开发人员添加了另一个启动器,spring-boot-starter-tomcat
。见清单 3-1 或网上来源( https://tpd.io/starter-web-deps
)。
plugins {
id "org.springframework.boot.starter"
}
description = "Starter for building web, including RESTful, applications using Spring MVC. Uses Tomcat as the default embedded container"
dependencies {
api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter"))
api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-json"))
api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-tomcat"))
api("org.springframework:spring-web")
api("org.springframework:spring-webmvc")
}
Listing 3-1Web Starter Dependencies
正如你所看到的,Spring Boot 工件使用 Gradle(从 2.3 版本开始),但是你不需要知道具体的语法来理解依赖关系是什么。如果我们现在检查spring-boot-starter-tomcat
工件的依赖项(在清单 3-2 或在线资源中,在 https://tpd.io/tomcat-starter-deps
),我们看到它包含一个不属于 Spring 家族的库tomcat-embed-core
。这是一个 Apache 库,我们可以用它来启动 Tomcat 嵌入式服务器。它的主要逻辑包含在一个名为Tomcat
的类中。
plugins {
id "org.springframework.boot.starter"
}
description = "Starter for using Tomcat as the embedded servlet container. Default servlet container starter used by spring-boot-starter-web"
dependencies {
api("jakarta.annotation:jakarta.annotation-api")
api("org.apache.tomcat.embed:tomcat-embed-core") {
exclude group: "org.apache.tomcat", module: "tomcat-annotations-api"
}
api("org.glassfish:jakarta.el")
api("org.apache.tomcat.embed:tomcat-embed-websocket") {
exclude group: "org.apache.tomcat", module: "tomcat-annotations-api"
}
}
Listing 3-2Tomcat Starter Dependencies
回到依赖关系的层次结构,spring-boot-starter-web
也依赖于spring-boot-starter
(参见清单 3-1 和图 3-2 获得一些上下文帮助)。那是核心 Spring Boot 启动器,其中包括神器spring-boot-autoconfigure
(见清单 3-3 或网上来源,在 https://tpd.io/sb-starter
)。那个 Spring Boot 工件有一整套注释有@Configuration
的类,它们负责整个 Spring Boot 魔法的很大一部分。有一些类用于配置 web 服务器、消息代理、错误处理程序、数据库等等。在 https://tpd.io/auto-conf-packages
查看完整的软件包列表,更好地了解支持的工具。
plugins {
id "org.springframework.boot.starter"
}
description = "Core starter, including auto-configuration support, logging and YAML"
dependencies {
api(project(":spring-boot-project:spring-boot"))
api(project(":spring-boot-project:spring-boot-autoconfigure"))
api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-logging"))
api("jakarta.annotation:jakarta.annotation-api")
api("org.springframework:spring-core")
api("org.yaml:snakeyaml")
}
Listing 3-3Spring Boot’s Main Starter
对我们来说,负责嵌入式 Tomcat 服务器自动配置的相关类是ServletWebServerFactoryConfiguration
。查看清单 3-4 显示其最相关的代码片段,或者查看在线提供的完整源代码( https://tpd.io/swsfc-source
)。
@Configuration(proxyBeanMethods = false)
class ServletWebServerFactoryConfiguration {
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ Servlet.class, Tomcat.class, UpgradeProtocol.class })
@ConditionalOnMissingBean(value = ServletWebServerFactory.class, search = SearchStrategy.CURRENT)
static class EmbeddedTomcat {
@Bean
TomcatServletWebServerFactory tomcatServletWebServerFactory(
ObjectProvider<TomcatConnectorCustomizer> connectorCustomizers,
ObjectProvider<TomcatContextCustomizer> contextCustomizers,
ObjectProvider<TomcatProtocolHandlerCustomizer<?>> protocolHandlerCustomizers) {
TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory();
factory.getTomcatConnectorCustomizers()
.addAll(connectorCustomizers.orderedStream().collect(Collectors.toList()));
factory.getTomcatContextCustomizers()
.addAll(contextCustomizers.orderedStream().collect(Collectors.toList()));
factory.getTomcatProtocolHandlerCustomizers()
.addAll(protocolHandlerCustomizers.orderedStream().collect(Collectors.toList()));
return factory;
}
}
// ...
}
Listing 3-4ServletWebServerFactoryConfiguration Fragment
这个类定义了一些内部类,其中一个是EmbeddedTomcat
。如你所见,这个注释是这样的:
@ConditionalOnClass({ Servlet.class, Tomcat.class, UpgradeProtocol.class })
Spring 处理@ConditionalOnClass
注释,如果在类路径中可以找到被链接的类,那么这个注释用于在上下文中加载 beans。在这种情况下,条件是匹配的,因为我们已经看到了Tomcat
类是如何通过 starter 层次结构进入我们的类路径的。因此,Spring 加载了在EmbeddedTomcat
中声明的 bean,结果是一个TomcatServletWebServerFactory
。
该工厂包含在 Spring Boot 的核心工件(spring-boot
,包含在spring-boot-starter
中的一个依赖项)中。它用一些默认配置设置了一个 Tomcat 嵌入式服务器。这是创建嵌入式 web 服务器的逻辑最终存在的地方。
图 3-2
自动配置示例:嵌入式 Tomcat
再次重述一下,Spring 扫描我们所有的类,假设满足了EmbeddedTomcat
中规定的条件(Tomcat 库是包含的依赖项),它在上下文中加载一个TomcatServletWebServerFactory
bean。这个 Spring Boot 类使用默认配置启动一个嵌入式 Tomcat 服务器,在端口 8080 上公开一个 HTTP 接口。
可以想象,这种相同的机制适用于数据库、web 服务器、消息代理、云原生模式、安全性等许多其他库。在 Spring Boot,您可以找到多个可以作为依赖项添加的启动器。当您这样做时,自动配置机制开始发挥作用,并且您获得了开箱即用的额外行为。许多配置类是以其他类的存在为条件的,就像我们分析的那些,但是还有其他条件类型,例如,application.properties
文件中的参数值。
自动配置是 Spring Boot 的一个关键概念。一旦你理解了它,许多开发者认为神奇的特性对你来说就不再是秘密了。我们浏览了这些细节,因为了解这种机制非常重要,这样您就可以根据自己的需要配置它,避免出现许多您不想要或根本不需要的行为。一个好的做法是,仔细阅读您正在使用的 Spring Boot 模块的文档,并熟悉它们允许的配置选项。
如果你没有完全理解这个概念,不要担心;在本书中,我们将多次回到自动配置机制。原因是我们将向我们的应用添加额外的特性,为此,我们需要向我们的项目添加额外的依赖项,并分析它们引入的新行为。
三层,三层架构
我们实践旅程的下一步是设计如何在不同的类中构建我们的应用和建模我们的业务逻辑。
多层架构将为我们的应用提供一个更适合生产的外观。大多数现实世界的应用都遵循这种架构模式。在 web 应用中,三层设计是最流行的一种,并且得到了广泛的扩展。这三层如下:
-
客户层:这一层负责用户界面。通常,这就是我们所说的前端。
-
应用层:这包含所有的业务逻辑,以及与之交互的接口和持久化的数据接口。这映射到我们所说的后端。
-
数据存储层:是数据库、文件系统等。,它保存应用的数据。
在本书中,我们主要关注应用层,尽管我们也会用到其他两层。如果我们现在放大,应用层通常使用三层来设计。
-
业务层:这包括对我们的领域和业务细节建模的类。这是应用的智能所在。有时这一层分为两部分:领域(实体)和提供业务逻辑的应用(服务)。
-
表示层:在我们的例子中,它将由
Controller
类来表示,这些类将向 web 客户端提供功能。我们的 REST API 实现将驻留在这里。 -
数据层:这一层将负责将我们的实体保存在数据存储中,通常是数据库。它通常可以包括数据访问对象 (DAO)类,它们处理直接映射到数据库中的行的对象,或者存储库类,它们是以域为中心的,因此它们可能需要从域表示转换到数据库结构。
我们现在的目标是将这个模式应用到乘法 web 应用中,如图 3-3 所示。
图 3-3
三层,三层架构应用于我们的 Spring Boot 项目
使用这种软件架构的优点都与实现松耦合有关。
-
所有层都是可互换的(例如,为文件存储解决方案更改数据库,或者从 REST API 更改为任何其他接口)。这是一项关键资产,因为它使得代码库的发展变得更加容易。此外,你可以用测试模拟来替换完整的层,这使得你的测试简单,正如我们将在本章后面看到的。
-
领域部分是孤立的,独立于其他任何东西。它没有混合接口或数据库细节。
-
有明确的职责划分:一个类处理对象的数据库存储,一个单独的类用于 REST API 实现,另一个类用于业务逻辑。
Spring 是构建这种类型架构的绝佳选择,它具有许多现成的特性,可以帮助我们轻松创建一个生产就绪的三层应用。它为我们的类提供了三个原型注释,映射到这个设计的每一层,所以我们可以使用它们来实现我们的架构。
-
@Controller
注释用于表示层。在我们的例子中,我们将使用控制器实现一个 REST 接口。 -
@Service
注释用于实现业务逻辑的类。 -
@Repository
注释用于数据层,即与数据库交互的类。
当我们用这些变体注释类时,它们变成了 Spring 管理的组件。当初始化 web 上下文时,Spring 扫描您的包,找到这些类,并将它们作为 beans 加载到上下文中。然后,我们可以使用依赖注入来连接(或注入)这些 beans,例如,使用来自我们的表示层(控制器)的服务。我们将很快在实践中看到这一点。
为我们的领域建模
让我们从建模我们的业务领域开始,因为这将帮助我们构建我们的项目。
领域定义和领域驱动设计
我们的第一个 web 应用负责生成乘法挑战并验证用户的后续尝试。让我们定义这三个业务实体。
-
挑战:包含乘法挑战的两个要素
-
用户:识别将尝试解决挑战的人
-
挑战尝试:代表用户尝试通过挑战解决操作
我们可以对这些域对象及其关系进行建模,如图 3-4 所示。
图 3-4
商业模式
这些对象之间的关系如下:
-
用户和挑战是独立的实体。他们没有任何证明。
-
挑战尝试总是针对给定的用户和给定的挑战。从概念上讲,如果生成的挑战数量有限,则同一挑战可能会有多次尝试。此外,同一个用户可以创建多次尝试,因为他们可以根据需要多次使用 web 应用。
在图 3-4 中,您还可以看到我们如何将这三个对象分成两个不同的域:用户和挑战。寻找域边界(也称为有界上下文;参见 https://tpd.io/bounded-ctx
)定义对象之间的关系是设计软件的基本任务。这种基于领域的设计方法被称为领域驱动设计 (DDD)。它帮助您构建一个模块化的、可伸缩的、松散耦合的架构。在我们的例子中,用户和挑战是完全不同的概念。挑战,以及他们的尝试,都与用户有关,但它们加在一起有足够的相关性,属于他们自己的领域。
为了让 DDD 更清晰,我们可以考虑这个小系统的一个进化版本,其中其他域与用户或挑战相关。例如,我们可以通过创建域朋友并对用户之间的关系和交互进行建模来引入社交网络功能。如果我们将域用户和挑战混为一谈,这种演变将更难完成,因为新的域与挑战无关。
关于 DDD 的额外阅读,你可以得到 Eric Evans 的书( https://tpd.io/ddd-book
)或者下载免费的 InfoQ 小册子( https://tpd.io/ddd-quickly
)。
微服务和领域驱动设计
设计微服务时的一个常见错误是认为每个域必须立即划分到不同的微服务中。然而,这可能导致过早的优化,并且从软件项目的开始就导致指数级的复杂性增加。
我们将深入了解关于微服务和整体优先方法的更多细节。目前,重要的是建模域是一项至关重要的任务,但是分割域并不需要将代码分割成微服务。在我们的第一个应用中,我们将两个域放在一起,但不会混淆。我们将使用一个简单的分割策略:根级包。
领域类别
是时候创建类Challenge
、ChallengeAttempt
和User
了。首先,我们将根包(microservices.book.multiplication
)分成两部分:users
和challenges
,遵循我们为乘法应用确定的域。然后,我们用这两个包中选择的名称创建三个空类。见清单 3-5 。
+- microservices.book.multiplication.user
| \- User.java
+- microservices.book.multiplication.challenge
| \- Challenge.java
| \- ChallengeAttempt.java
Listing 3-5Splitting Domains by Creating Different Root Packages
因为我们在创建 skeleton 应用时添加了 Lombok 作为依赖项,所以我们可以使用它来保持我们的域类非常小,正如上一章所描述的。请记住,您可能需要在您的 IDE 中添加一个插件,以获得与 Lombok 的完全集成;否则,你可能会从 linter 得到错误。例如,在 IntelliJ 中,您可以通过选择首选项➤插件并搜索 Lombok 来安装官方 Lombok 插件。
Challenge
类保存乘法的两个因子。我们添加了 getters,一个包含所有字段的构造函数,以及toString()
、equals()
和hashCode()
方法。见清单 3-6 。
package microservices.book.multiplication.challenge;
import lombok.*;
/**
* This class represents a Challenge to solve a Multiplication (a * b).
*/
@Getter
@ToString
@EqualsAndHashCode
@AllArgsConstructor
public class Challenge {
private int factorA;
private int factorB;
}
Listing 3-6The Challenge Class
User
类具有相同的 Lombok 注释、用户标识符和友好别名(例如,用户的名字)。参见清单 3-7 。
package microservices.book.multiplication.user;
import lombok.*;
/**
* Stores information to identify
the user.
*/
@Getter
@ToString
@EqualsAndHashCode
@AllArgsConstructor
public class User {
private Long id;
private String alias;
}
Listing 3-7The User Class
尝试也有一个id
,用户输入的值(resultAttempt
,以及是否正确。见清单 3-8 。我们通过userId
将它链接到用户。请注意,我们这里也有两个挑战因素。我们这样做是为了避免通过challengeId
引用一个挑战,因为我们可以简单地“动态”生成新的挑战,并将它们复制到这里以保持我们的数据结构简单。因此,正如你所看到的,我们有多种选择来实现我们在图 3-4 中描述的业务模型。为了对与用户的关系进行建模,我们使用一个引用;为了模拟挑战,我们将数据嵌入到尝试中。当我们讨论数据持久性时,我们将在第五章中更详细地分析这个决定。
package microservices.book.multiplication.challenge;
import lombok.*;
import microservices.book.multiplication.user.User;
/**
* Identifies the attempt from a {@link User} to solve a challenge.
*/
@Getter
@ToString
@EqualsAndHashCode
@AllArgsConstructor
public class ChallengeAttempt {
private Long id;
private Long userId;
private int factorA;
private int factorB;
private int resultAttempt;
private boolean correct;
}
Listing 3-8The ChallengeAttempt Class
业务逻辑
一旦我们定义了领域模型,就该考虑业务逻辑的另一部分了:应用服务。
我们需要什么
查看了我们的需求后,我们需要以下内容:
-
一种产生中等复杂度乘法问题的方法。让我们把 11 到 99 之间的所有因子都做出来。
-
一些检查尝试是否正确的功能。
随机挑战
让我们为我们的业务逻辑将测试驱动开发付诸实践。首先,我们编写一个基本接口来生成随机挑战。参见清单 3-9 。
package microservices.book.multiplication.challenge;
public interface ChallengeGeneratorService {
/**
* @return a randomly-generated challenge with factors between 11 and 99
*/
Challenge randomChallenge();
}
Listing 3-9The ChallengeGeneratorService Interface
我们也将这个接口放在challenge
包中。现在,我们编写这个接口的一个空实现,它包装了一个 Java 的Random
。见清单 3-10 。除了无参数构造函数之外,我们还通过第二个接受随机对象的构造函数使我们的类可测试。
package microservices.book.multiplication.challenge;
import org.springframework.stereotype.Service;
import java.util.Random;
@Service
public class ChallengeGeneratorServiceImpl implements ChallengeGeneratorService {
private final Random random;
ChallengeGeneratorServiceImpl() {
this.random = new Random();
}
protected ChallengeGeneratorServiceImpl(final Random random) {
this.random = random;
}
@Override
public Challenge randomChallenge() {
return null;
}
}
Listing 3-10An Empty Implementation of the ChallengeGeneratorService Interface
为了指示 Spring 在上下文中加载这个服务实现,我们用@Service
来注释这个类。我们可以稍后通过使用接口而不是实现将该服务注入到其他层中。这样,我们就保持了松耦合,因为我们可以交换实现,而不需要改变其他层中的任何东西。我们将很快将依赖注入付诸实践。现在,让我们关注 TDD,让randomChallenge()
实现保持空白。
下一步是为此编写一个测试。我们在同一个包中创建一个类,但是这次是在test
源文件夹中。参见清单 3-11 。
package microservices.book.multiplication.challenge;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Random;
import static org.assertj.core.api.BDDAssertions.then;
import static org.mockito.BDDMockito.given;
@ExtendWith(MockitoExtension.class)
public class ChallengeGeneratorServiceTest {
private ChallengeGeneratorService challengeGeneratorService;
@Spy
private Random random;
@BeforeEach
public void setUp() {
challengeGeneratorService = new ChallengeGeneratorServiceImpl(random);
}
@Test
public void generateRandomFactorIsBetweenExpectedLimits() {
// 89 is max - min range
given(random.nextInt(89)).willReturn(20, 30);
// when we generate a challenge
Challenge challenge = challengeGeneratorService.randomChallenge();
// then the challenge contains factors as expected
then(challenge).isEqualTo(new Challenge(31, 41));
}
}
Listing 3-11Creating the Unit Test Before the Real Implementation
在前一章中,我们回顾了如何使用 Mockito 用 JUnit 5 的@Mock
注释和MockitoExtension
类替换给定类的行为。在这个测试中,我们需要替换一个对象的行为,而不是一个类。我们用@Spy
来存根一个对象。Mockito 扩展将有助于使用空构造函数创建一个Random
实例,并为我们清除它以覆盖行为。这是让我们的测试工作的最简单的方法,因为实现随机生成器的基本 Java 类不能在接口上工作(我们可以简单地用模仿而不是窥探)。
通常,我们在一个用@BeforeEach
标注的方法中初始化所有测试所需的东西,所以这发生在每个测试开始之前。这里我们构造了传递这个存根对象的服务实现。
唯一的测试方法是按照 BDD 风格用given()
设置前提条件。生成 11 到 99 之间的随机数的方法是得到一个 0 到 89 之间的随机数,然后在上面加 11。因此,我们知道应该用89
调用random
来生成范围11, 100
内的数字,所以我们在第一次调用时覆盖该调用以返回20
,第二次调用时返回30
。然后,当我们调用randomChallenge()
时,我们期望它从random
(我们的存根对象)获得随机数 20 和 30,并因此返回一个带有31
和41
的Challenge
对象。
所以,我们做了一个测试,当你运行它的时候很明显会失败。我们来试试吧;您可以从项目的根文件夹中使用 IDE 或 Maven 命令。
multiplication$ ./mvnw test
不出所料,测试会失败。参见清单 3-12 中的结果。
Expecting:
<null>
to be equal to:
<Challenge(factorA=20, factorB=30)>
but was not.
Expected :Challenge(factorA=20, factorB=30)
Actual :null
Listing 3-12Error Output After Running the Test for the First Time
现在,我们只需要通过测试。在我们的例子中,解决方案非常简单,我们需要在实现测试的时候解决它。稍后,我们将看到更多有价值的 TDD 案例,但是这个案例已经帮助我们开始这种工作方式。参见清单 3-13 。
@Service
public class ChallengeGeneratorServiceImpl implements ChallengeGeneratorService {
private final static int MINIMUM_FACTOR = 11;
private final static int MAXIMUM_FACTOR = 100;
// ...
private int next() {
return random.nextInt(MAXIMUM_FACTOR - MINIMUM_FACTOR) + MINIMUM_FACTOR;
}
@Override
public Challenge randomChallenge() {
return new Challenge(next(), next());
}
}
Listing 3-13Implementing a Valid Logic to Generate Challenges
现在,我们再次运行测试,这次它通过了:
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
测试驱动开发就是这么简单。首先,您设计测试,这些测试在开始时会失败。然后,你实现你的逻辑让他们通过。在现实生活中,当您从定义需求的人那里获得构建测试用例的帮助时,您会得到最大的收获。您可以编写更好的测试,从而更好地实现您真正想要构建的东西。
尝试验证
为了满足业务需求的第二部分,我们实现了一个接口来验证用户的尝试。参见清单 3-14 。
package microservices.book.multiplication.challenge;
public interface ChallengeService {
/**
* Verifies if an attempt
coming from the presentation layer is correct or not.
*
* @return the resulting ChallengeAttempt object
*/
ChallengeAttempt verifyAttempt(ChallengeAttemptDTO resultAttempt);
}
Listing 3-14The ChallengeService Interface
正如您在代码中看到的,我们将一个ChallengeAttemptDTO
对象传递给了verifyAttempt
方法。这个类还不存在。数据传输对象(dto)在系统的不同部分之间传送数据。在这种情况下,我们使用 DTO 对表示层所需的数据进行建模,以创建一个尝试。见清单 3-15 。来自用户的尝试没有字段correct
,也不需要知道用户的 ID。我们还可以使用 dto 来验证数据,我们将在构建控制器时看到这一点。
package microservices.book.multiplication.challenge;
import lombok.Value;
/**
* Attempt coming from the user
*/
@Value
public class ChallengeAttemptDTO {
int factorA, factorB;
String userAlias;
int guess;
}
Listing 3-15The ChallengeAttemptDTO Class
这一次我们使用 Lombok 的@Value
,一个快捷方式注释,用一个全参数构造函数和toString
、equals
和hashCode
方法创建一个不可变的类。它还会将我们的字段设置为private final
;这就是为什么我们不需要添加这一点。继续使用 TDD 方法,我们在ChallengeServiceImpl
接口实现中创建无为逻辑。见清单 3-16 。
package microservices.book.multiplication.challenge;
import org.springframework.stereotype.Service;
@Service
public class ChallengeServiceImpl implements ChallengeService {
@Override
public ChallengeAttempt verifyAttempt(ChallengeAttemptDTO attemptDTO) {
return null;
}
}
Listing 3-16An Empty ChallengeService Interface Implementation
现在,我们为这个类编写一个单元测试,所以我们验证它对正确和错误的尝试都有效。参见清单 3-17 。
package microservices.book.multiplication.challenge;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.BDDAssertions.then;
public class ChallengeServiceTest {
private ChallengeService challengeService;
@BeforeEach
public void setUp() {
challengeService = new ChallengeServiceImpl();
}
@Test
public void checkCorrectAttemptTest() {
// given
ChallengeAttemptDTO attemptDTO =
new ChallengeAttemptDTO(50, 60, "john_doe", 3000);
// when
ChallengeAttempt resultAttempt =
challengeService.verifyAttempt(attemptDTO);
// then
then(resultAttempt.isCorrect()).isTrue();
}
@Test
public void checkWrongAttemptTest() {
// given
ChallengeAttemptDTO attemptDTO =
new ChallengeAttemptDTO(50, 60, "john_doe", 5000);
// when
ChallengeAttempt resultAttempt =
challengeService.verifyAttempt(attemptDTO);
// then
then(resultAttempt.isCorrect()).isFalse();
}
}
Listing 3-17Writing the Test to Verify Challenge Attempts
50 和 60 相乘的结果是 3,000,因此第一个测试用例的断言期望correct
字段为真,而第二个测试期望错误的猜测为假(5,000)。
让我们现在执行测试。您可以使用 IDE,或者使用 Maven 命令来指定要运行的测试的名称。
multiplication$ ./mvnw -Dtest=ChallengeServiceTest test
您将看到类似如下的输出:
[INFO] Results:
[INFO]
[ERROR] Errors:
[ERROR] ChallengeServiceTest.checkCorrectAttemptTest:28 NullPointer
[ERROR] ChallengeServiceTest.checkWrongAttemptTest:42 NullPointer
[INFO]
[ERROR] Tests run: 2, Failures: 0, Errors: 2, Skipped: 0
正如所预见的,两个测试都将抛出一个空指针异常。
然后,我们回到服务实现,并让它工作。参见清单 3-18 。
@Override
public ChallengeAttempt verifyAttempt(ChallengeAttemptDTO attemptDTO) {
// Check if the attempt is correct
boolean isCorrect = attemptDTO.getGuess() ==
attemptDTO.getFactorA() * attemptDTO.getFactorB();
// We don't use identifiers for now
User user = new User(null, attemptDTO.getUserAlias());
// Builds the domain object. Null id for now.
ChallengeAttempt checkedAttempt = new ChallengeAttempt(null,
user,
attemptDTO.getFactorA(),
attemptDTO.getFactorB(),
attemptDTO.getGuess(),
isCorrect
);
return checkedAttempt;
}
Listing 3-18Implementing the Logic to Verify Attempts
我们现在保持简单。后来,这个实现应该处理更多的任务。我们需要创建一个用户或找到一个现有的用户,将该用户连接到新的尝试,并将其存储在数据库中。
现在,再次运行测试以验证它是否通过:
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.083 s - in microservices.book.multiplication.challenge.ChallengeServiceTest
同样,我们成功地使用 TDD 构建了验证挑战尝试的逻辑。
Users
领域在第一个用户故事的范围内不需要任何业务逻辑,所以让我们进入下一层。
表示层
本节将介绍表示层。
休息
我们没有从服务器构建 HTML,而是决定像在真正的软件项目中通常所做的那样接近表示层:在它们之间有一个 API 层。通过这样做,我们不仅可以向其他后端服务公开我们的功能,还可以保持后端和前端完全隔离。通过这种方式,我们可以从一个简单的 HTML 页面和普通的 JavaScript 开始,然后在不改变后端代码的情况下迁移到一个完整的前端框架。
在所有可能的 API 选择中,现在最流行的是表述性状态转移(REST)。它通常构建在 HTTP 之上,所以它使用 HTTP 动词来执行 API 操作:GET、POST、PUT、DELETE 等。我们将在本书中构建 RESTful web 服务,它们只是符合 REST 架构风格的 web 服务。因此,我们遵循 URL 和 HTTP 动词的一些约定,这些约定已经成为事实上的标准。见表 3-1 。
表 3-1
REST APIs 的约定
|HTTP 动词
|
对集合的操作,例如/挑战
|
对项目的操作,例如挑战/3
|
| --- | --- | --- |
| 得到 | 获取项的完整列表 | 获取该项 |
| 邮政 | 创建新项目 | 不适用 |
| 放 | 不适用 | 更新项目 |
| 删除 | 删除整个集合 | 删除项目 |
编写 REST APIs 有几种不同的风格。表 3-1 显示了本书中最基本的操作和一些约定选择。通过 API 传输的内容还有多个方面:分页、空处理、格式(例如 JSON)、安全性、版本控制等。如果你对这些约定对于一个真实的组织来说可能变得多详细感到好奇,你可以看看 Zalando 的 API 指南( https://tpd.io/api-zalando
)。
用 Spring Boot 休息 API
用 Spring 构建 REST API 是一项简单的任务。不出所料,@Controller
原型有一个专门用于构建 REST 控制器的特性,叫做@RestController
。
为了对不同 HTTP 动词的资源和映射进行建模,我们使用了@RequestMapping
注释。它适用于类级别和方法级别,因此我们可以用简单的方式构建我们的 API 上下文。为了更简单,Spring 提供了类似于@PostMapping
、@GetMapping
等变体。,所以我们甚至不需要指定 HTTP 动词。
每当我们想要将请求的主体传递给我们的方法时,我们就使用@RequestBody
注释。如果我们使用自定义类,Spring Boot 将尝试反序列化它,使用传递给方法的类型。默认情况下,Spring Boot 使用 JSON 序列化格式,尽管当通过Accept
HTTP 头指定时,它也支持其他格式。在我们的 web 应用中,我们将使用所有的 Spring Boot 默认设置。
我们还可以用请求参数定制我们的 API,并从请求路径读取值。让我们以这个请求为例:
GET http://ourhost.com/challenges/5?factorA=40
这些是它不同的部分:
-
GET
是 HTTP 动词。 -
http://ourhost.com/
是运行 web 服务器的主机。在这个例子中,应用从根上下文、/
提供服务。 -
/challenges/
是由应用创建的 API 上下文,用于提供该领域的功能。 -
/5
被称为路径变量。在这种情况下,它用标识符5
表示Challenge
对象。 -
factorA=40
是一个请求参数及其值。
为了处理这个请求,我们可以创建一个控制器,将 5 作为路径变量challengeId
,40 作为请求参数factorA
。见清单 3-19 。
@RestController
@RequestMapping("/challenges")
class ChallengeAttemptController {
@GetMapping("/{challengeId}")
public Challenge getChallengeWithParam(@PathVariable("challengeId") Long challengeId,
@RequestParam("factorA") int factorA) {...}
}
Listing 3-19An Example of Using Annotations to Map REST API URLs
提供的功能不止于此。我们还可以验证 REST 控制器与javax.validation
API 集成的请求。这意味着我们可以对反序列化期间使用的类进行注释,以避免空值,或者当我们从客户端获得请求时,强制数字在给定的范围内,这只是一个例子。
不要担心引入的新概念的数量。我们将在接下来的章节中用实际的例子来介绍它们。
设计我们的 API
我们可以使用需求来设计我们需要在 REST API 中公开哪些功能。
-
一个接口来获得一个随机的,中等复杂度的乘法
-
从给定用户的别名发送给定乘法猜测值的端点
这些是挑战的读取操作和创建尝试的动作。请记住,乘法挑战和尝试是不同的资源,我们将 API 一分为二,并为这些动作分配相应的动词:
-
GET /challenges/random
将返回随机生成的挑战。 -
POST /attempts/
将是我们向端点发送解决挑战的尝试。
这两种资源都属于challenges
域。最终,我们还需要一个/users
映射来与我们的用户一起执行操作,但是我们将它留到以后,因为我们不需要它来完成第一个需求(用户故事)。
API 优先的方法
在实现 API 契约之前,在您的组织内定义和讨论它通常是一个好的实践。您应该包括端点、HTTP 动词、允许的参数以及请求和响应主体示例。这样,其他开发人员和客户可以验证公开的功能是否是他们需要的,并在您浪费时间实现错误的解决方案之前给出反馈。这种策略被称为 API First ,有行业标准来编写 API 规范,比如 OpenAPI。
如果想了解更多关于 API 第一和 OpenAPI 的内容,请看 https://tpd.io/apifirst
,来自 Swagger,规范的最初创造者。
我们的第一个控制器
让我们创建一个产生随机挑战的控制器。我们在服务层中已经有了那个操作,所以我们只需要从控制器中使用那个方法。这就是我们在表示层应该做的:保持它与任何业务逻辑隔离。我们将只使用它来建模 API 和验证传递的数据。参见清单 3-20 。
package microservices.book.multiplication.challenge;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
/**
* This class implements a REST API to get random challenges
*/
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/challenges")
class ChallengeController {
private final ChallengeGeneratorService challengeGeneratorService;
@GetMapping("/random")
Challenge getRandomChallenge() {
Challenge challenge = challengeGeneratorService.randomChallenge();
log.info("Generating a random challenge: {}", challenge);
return challenge;
}
}
Listing 3-20The ChallengeController Class
@RestController
注释告诉 Spring 这是一个建模 REST 控制器的专用组件。它是@Controller
和@ResponseBody
的组合,指示 Spring 将这个方法的结果作为 HTTP 响应体。在 Spring Boot,默认情况下,如果没有其他指示,响应将被序列化为 JSON 并包含在响应体中。
我们还在类级别添加了一个@RequestMapping("/challenges")
,所以所有的映射方法都会添加这个作为前缀。
在我们的控制器中还有两个 Lombok 注释。
-
@RequiredArgsConstructor
创建一个带有ChallengeGeneratorService
作为参数的构造函数,因为该字段是私有的和最终的,Lombok 认为这是必需的。Spring 使用依赖注入,所以它会尝试找到实现这个接口的 bean,并将它连接到控制器。在这种情况下,它将采用唯一的候选服务ChallengeGeneratorServiceImpl
。 -
Slf4j
创建一个名为log
的记录器。我们用它来打印一条消息,以控制台生成的挑战。
方法getRandomChallenge()
有@GetMapping("/random")
注释。这意味着该方法将处理对上下文/challenges/random
的 GET 请求,第一部分来自类级注释。它只是返回一个Challenge
对象。
现在让我们再次运行我们的 web 应用,并做一个快速的 API 测试。从 IDE 中运行MultiplicationApplication
类,或者从控制台中使用mvnw spring-boot:run
。
使用 HTTPie(参见第二章,我们通过在端口 8080 (Spring Boot 的默认端口)上对localhost
(我们的机器)做一个简单的 GET 请求来尝试我们的新端点。参见清单 3-21 。
$ http localhost:8080/challenges/random
HTTP/1.1 200
Connection: keep-alive
Content-Type: application/json
Date: Sun, 29 Mar 2020 07:59:00 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked
{
"factorA": 39,
"factorB": 36
}
Listing 3-21Making a Request to the Newly Created API
我们得到了一个 HTTP 响应,它包含头部和主体,这是一个 challenge 对象的良好序列化的 JSON 表示。我们做到了!我们的应用终于有所作为了。
自动序列化如何工作
在介绍自动配置在 Spring Boot 是如何工作的时候,我们看了一下 Tomcat 嵌入式服务器的例子,我们提到作为spring-boot-autoconfigure
依赖项的一部分,还包含了更多的自动配置类。因此,这另一个魔法负责将Challenge
序列化为正确的 JSON HTTP 响应,对您来说应该不再是一个谜了。无论如何,让我们看看这是如何工作的,因为它是 Spring Boot web 模块的核心概念。还有,现实生活中定制这种配置也挺常见的。
Spring Boot Web 模块的许多重要逻辑和默认值都在WebMvcAutoConfiguration
类中(参见 https://tpd.io/mvcauto-source
)。这个类将上下文中所有可用的 HTTP 消息转换器收集在一起供以后使用。在清单 3-22 中可以看到这个类的一个片段。
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
this.messageConvertersProvider
.ifAvailable((customConverters) -> converters.addAll(customConverters.getConverters()));
}
Listing 3-22A Fragment of WebMvcAutoConfiguration Class Provided by Spring Web
HttpMessageConverter
接口( https://tpd.io/hmc-source
)包含在核心的spring-web
工件中,它定义了转换器支持哪些媒体类型,哪些类可以相互转换,以及进行转换的read
和write
方法。
这些转换器来自哪里?更多自动配置类。Spring Boot 包含了一个JacksonHttpMessageConvertersConfiguration
类( https://tpd.io/jhmcc-source
),它有一些逻辑来加载一个MappingJackson2HttpMessageConverter
类型的 bean。这个逻辑以类路径中存在类ObjectMapper
为条件。该类是 Jackson 库的核心类,是 Java 最流行的 JSON 序列化实现。ObjectMapper
包含在jackson-databind
依赖项中。该类位于类路径中,因为它的工件是包含在spring-boot-starter-json
中的依赖项,而后者本身包含在spring-boot-starter-web
中。
还是那句话,最好用图表来理解这一切。见图 3-5 。
图 3-5
Spring Boot Web JSON 自动配置
默认的ObjectMapper
bean 是在JacksonAutoConfiguration
( https://tpd.io/jac-source
)类中配置的。那里的一切都是以灵活的方式设置的。如果我们想要定制一个特定的特性,我们不需要考虑整个层次结构。通常,这只是一个覆盖默认 beans 的问题。
例如,如果我们想将 JSON 属性命名改为 snake-case 而不是 camel-case,我们可以在应用配置中声明一个定制的ObjectMapper
,它将被加载而不是默认的。这就是我们在清单 3-23 中所做的。
@SpringBootApplication
public class MultiplicationApplication {
public static void main(String[] args) {
SpringApplication.run(MultiplicationApplication.class, args);
}
@Bean
public ObjectMapper objectMapper() {
var om = new ObjectMapper();
om.setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE);
return om;
}
}
Listing 3-23Injecting Beans in the Context to Override Defaults in Spring Boot
通常,我们会将这个 bean 声明添加到一个单独的用@Configuration
注释的类中,但是这段代码对于这个简单的例子来说已经足够好了。如果您再次运行应用并调用端点,您将获得 snake-case 中的因子属性。参见清单 3-24 。
$ http localhost:8080/challenges/random
HTTP/1.1 200
Connection: keep-alive
Content-Type: application/json
Date: Sun, 29 Mar 2020 10:05:00 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked
{
"factor_a": 39,
"factor_b": 36
}
Listing 3-24Verifying Spring Boot Configuration Changes with a New Request
如您所见,通过覆盖 beans 来定制 Spring Boot 配置非常容易。这种特殊情况是可行的,因为默认的ObjectMapper
用@ConditionalOnMissingBean
进行了注释,这使得 Spring Boot 只有在上下文中没有定义相同类型的其他 bean 时才加载该 bean。记住删除这个自定义ObjectMapper
,因为我们现在只使用 Spring Boot 的默认设置。
您可能已经错过了这些控制器的 TDD 方法。我们首先介绍一个简单的控制器实现的原因是,在开始测试策略之前,您更容易掌握控制器在 Spring Boot 如何工作的概念。
用 Spring Boot 测试控制器
我们的第二个控制器将实现 REST API 来接收来自前端的解决挑战的尝试。对于这一个,是时候回到测试驱动的方法了。首先,我们创建一个新控制器的空壳。参见清单 3-25 。
package microservices.book.multiplication.challenge;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* This class provides
a REST API to POST the attempts from users.
*/
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/attempts")
class ChallengeAttemptController {
private final ChallengeService challengeService;
}
Listing 3-25An Empty Implementation of the ChallengeAttemptController
与前面的实现类似,我们使用 Lombok 添加带有服务接口的构造函数。Spring 会注入相应的 bean ChallengeServiceImpl
。
现在让我们用预期的逻辑编写一个测试。请记住,测试控制器需要一个稍微不同的方法,因为中间有一个 web 层。有时,我们希望验证一些特性,如验证、请求映射或错误处理,这些特性由我们配置,但由 Spring Boot 提供。因此,我们通常希望单元测试不仅涵盖类本身,还涵盖它周围的所有特性。
在 Spring Boot,有多种实施控制器测试的方法:
-
而不运行嵌入式服务器。我们可以使用不带参数的
@SpringBootTest
,或者更好的是,@WebMvcTest
来指示 Spring 有选择地只加载所需的配置,而不是整个应用上下文。然后,我们使用 Spring 测试模块中包含的专用工具MockMvc
来模拟请求。 -
运行嵌入式服务器。在这种情况下,我们使用
@SpringBootTest
,其webEnvironment
参数设置为RANDOM_PORT
或DEFINED_PORT
。然后,我们必须对服务器进行真正的 HTTP 调用。Spring Boot 包含了一个类TestRestTemplate
,它有一些有用的特性来执行这些测试请求。当您想要测试一些您可能已经定制的 web 服务器配置(例如,定制 Tomcat 配置)时,此选项很有用。
最好的选择通常是第一个,并选择一个带有@WebMvcTest
的细粒度配置。我们获得了围绕控制器的所有配置,而无需为每次测试花费额外的时间来启动服务器。如果你想获得所有这些不同选项的额外知识,请查看 https://tpd.io/sb-test-guide
。
我们可以为一个有效请求和一个无效请求编写一个测试,如清单 3-26 所示。
package microservices.book.multiplication.challenge;
import microservices.book.multiplication.user.User;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.json.JacksonTester;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.BDDAssertions.then;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@ExtendWith(SpringExtension.class)
@AutoConfigureJsonTesters
@WebMvcTest(ChallengeAttemptController.class)
class ChallengeAttemptControllerTest {
@MockBean
private ChallengeService challengeService;
@Autowired
private MockMvc mvc;
@Autowired
private JacksonTester<ChallengeAttemptDTO> jsonRequestAttempt;
@Autowired
private JacksonTester<ChallengeAttempt> jsonResultAttempt;
@Test
void postValidResult() throws Exception {
// given
User user = new User(1L, "john");
long attemptId = 5L;
ChallengeAttemptDTO attemptDTO = new ChallengeAttemptDTO(50, 70, "john", 3500);
ChallengeAttempt expectedResponse = new ChallengeAttempt(attemptId, user, 50, 70, 3500, true);
given(challengeService
.verifyAttempt(eq(attemptDTO)))
.willReturn(expectedResponse);
// when
MockHttpServletResponse response = mvc.perform(
post("/attempts").contentType(MediaType.APPLICATION_JSON)
.content(jsonRequestAttempt.write(attemptDTO).getJson()))
.andReturn().getResponse();
// then
then(response.getStatus()).isEqualTo(HttpStatus.OK.value());
then(response.getContentAsString()).isEqualTo(
jsonResultAttempt.write(
expectedResponse
).getJson());
}
@Test
void postInvalidResult() throws Exception {
// given an attempt with invalid input data
ChallengeAttemptDTO attemptDTO = new ChallengeAttemptDTO(2000, -70, "john", 1);
// when
MockHttpServletResponse response = mvc.perform(
post("/attempts").contentType(MediaType.APPLICATION_JSON)
.content(jsonRequestAttempt.write(attemptDTO).getJson()))
.andReturn().getResponse();
// then
then(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
}
}
Listing 3-26Testing the Expected ChallengeAttemptController Logic
这段代码中有一些新的注释和助手类。让我们一个一个来复习。
-
确保我们的 JUnit 5 测试加载了 Spring 的扩展,这样我们就可以使用测试上下文。
-
@AutoConfigureJsonTesters
告诉 Spring 为我们在测试中声明的一些字段配置类型为JacksonTester
的 beans。在我们的例子中,我们使用@Autowired
从测试上下文中注入两个JacksonTester
bean。当通过这个注释得到指示时,Spring Boot 负责构建这些实用程序类。一个JacksonTester
可以用来序列化和反序列化对象,使用与应用在运行时相同的配置(即ObjectMapper
)。 -
@WebMvcTest
,以控制器类为参数,让 Spring 把这个当做表示层测试。因此,它将只加载控制器周围的相关配置:验证、序列化程序、安全性、错误处理程序等。(参见https://tpd.io/test-autoconf
获取包含的自动配置类的完整列表)。 -
附带 Spring Boot 测试模块,通过允许你模拟你没有测试的其他层和 beans 来帮助你开发合适的单元测试。在我们的例子中,我们用 mock 替换上下文中的服务 bean。我们使用
BDDMockito
的given()
在测试方法中设置预期的返回值。 -
你可能很熟悉。这是 Spring 中的一个基本注释,让它将上下文中的 bean 注入(或连接)到字段中。它过去在所有使用 Spring 的类中都很常见,但是从 4.3 版开始,如果字段是在构造函数中初始化的,并且类只有一个构造函数,那么它可以从字段中省略。
-
当我们做一个不加载真实服务器的测试时,
MockMvc
类是我们在 Spring 中用来模拟对表示层的请求的。它是由测试上下文提供的,所以我们可以在测试中注入它。
有效尝试测试
现在我们可以关注测试用例以及如何让它们通过。第一个测试为有效的尝试设置场景。它创建 DTO,作为从 API 客户端发送的数据,并带有有效的结果。它使用 BDDMockito 的given()
来指定,当使用与 DTO 相等的参数(Mockito 的eq
)调用服务(模拟 bean)时,它应该返回预期的ChallengeAttempt
响应。
我们用助手类MockMvcRequestBuilders
中包含的静态方法post
构建 POST 请求。我们的目标是预期路径/attempts
。内容类型设置为application/json
,其主体为 JSON 格式的序列化 DTO。我们使用连线的JacksonTester
进行序列化。然后,mvc
通过perform()
发出请求,我们得到调用.andReturn()
的响应。如果我们也将MockMvc
用于断言,我们也可以调用方法andExpect()
,但是最好使用像 AssertJ 这样的专用断言库来单独完成它们。
在测试的最后一部分,我们验证 HTTP 状态代码应该是 200 OK,并且结果必须是预期响应的序列化版本。同样,我们为此使用了一个JacksonTester
对象。
当我们执行测试时,测试失败,并显示 404 NOT FOUND。参见清单 3-27 。这个请求没有实现,所以服务器不能简单地找到一个逻辑来映射 POST 映射。
Expecting:
<404>
to be equal to:
<200>
but was not.
Listing 3-27The ChallengeAttemptControllerTest Fails
然后,我们回到ChallengeAttemptController
并实现这个映射。参见清单 3-28 。
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/attempts")
class ChallengeAttemptController {
private final ChallengeService challengeService;
@PostMapping
ResponseEntity<ChallengeAttempt> postResult(@RequestBody ChallengeAttemptDTO challengeAttemptDTO) {
return ResponseEntity.ok(challengeService.verifyAttempt(challengeAttemptDTO));
}
}
Listing 3-28Adding the Working Implementation to ChallengeAttemptController
这是一个简单的逻辑,只需要调用服务层。我们的方法用不带参数的@PostMapping
进行了注释,因此它将处理对已经在类级别设置的上下文路径的 POST 请求。注意,这里我们使用一个ResponseEntity
作为返回类型,而不是直接使用ChallengeAttempt
。另一个选择也可行。我们在这里使用这种新的方式来展示使用ResponseEntity
静态构建器构建不同类型的响应的方法。
就这样!第一个测试用例现在将通过。
验证控制器中的数据
第二个测试用例postInvalidResult()
,检查应用是否应该接受负数或超出范围的尝试。它期望我们的逻辑返回一个 400 错误请求,当错误发生在客户端时,这是一个很好的实践,就像这样。参见清单 3-29 。
// then
then(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
Listing 3-29Verifying That the Client Gets a BAD REQUEST Status Code
如果您在控制器中实现我们的 POST 映射之前运行它,它会失败,并显示一个 NOT FOUND 状态代码。有了实现,它也会失败。然而,在这种情况下,结果更糟。参见清单 3-30 。
org.opentest4j.AssertionFailedError:
Expecting:
<200>
to be equal to:
<400>
but was not.
Listing 3-30Posting
an Invalid Request Returns a 200 OK Status Code
我们的应用只是接受无效的尝试并返回一个 OK 状态。这是错误的;我们不应该将这种尝试传递给服务层,而应该在表示层拒绝它。为此,我们将使用与 Spring Integration 的 Java Bean 验证 API ( https://tpd.io/bean-validation
)。
在我们的 DTO 类中,我们添加了一些 Java 验证注释来指示什么是有效的输入。见清单 3-31 。所有这些注释都在jakarta.validation-api
库中实现,可以通过spring-boot-starter-validation
在我们的类路径中获得。该启动器是 Spring Boot Web 启动器(spring-boot-starter-web
)的一部分。
package microservices.book.multiplication.challenge;
import lombok.Value;
import javax.validation.constraints.*;
/**
* Attempt coming from the user
*/
@Value
public class ChallengeAttemptDTO {
@Min(1) @Max(99)
int factorA, factorB;
@NotBlank
String userAlias;
@Positive
int guess;
}
Listing 3-31Adding Validation Constraints to Our DTO Class
在那个包中有很多可用的约束( https://tpd.io/constraints-source
)。我们使用@Min
和@Max
来定义乘法因子的允许值的范围,@NotBlank
来确保我们总是得到一个别名,而@Positive
用于猜测,因为我们知道我们只处理肯定的结果(我们也可以在这里使用预定义的范围)。
让这些约束发挥作用的一个重要步骤是通过控制器方法参数中的@Valid
注释将它们与 Spring Integration。参见清单 3-32 。只有当我们添加这个时,Spring Boot 才会分析约束,如果不匹配,就会抛出一个异常。
@PostMapping
ResponseEntity<ChallengeAttempt> postResult(
@RequestBody @Valid ChallengeAttemptDTO challengeAttemptDTO) {
return ResponseEntity.ok(challengeService.verifyAttempt(challengeAttemptDTO));
}
Listing 3-32Using the @Valid Annotation to Validate Requests
您可能已经猜到,当对象无效时,会有自动配置来处理错误并构建预定义的响应。默认情况下,错误处理程序构造一个带有400 BAD_REQUEST
状态代码的响应。
从 Spring Boot 版本 2.3 开始,默认情况下,验证消息不再包含在错误响应中。这可能会让调用者感到困惑,因为他们不知道请求到底出了什么问题。不包括它们的原因是这些消息可能会将信息暴露给恶意的 API 客户端。出于我们的教育目的,我们希望启用验证消息,所以我们将向我们的application.properties
文件添加两个设置。见清单 3-33 。这些属性都列在了 Spring Boot 官方文档中( https://tpd.io/server-props
),我们很快就能看到他们是怎么做的。
server.error.include-message=always
server.error.include-binding-errors=always
Listing 3-33Adding Validation Logging Configuration to the application.properties File
为了验证我们所有的验证配置,现在让我们再次运行测试。这一次它会过去,您会看到一些额外的日志,如清单 3-34 所示。
[Field error in object 'challengeAttemptDTO' on field 'factorB': rejected value [-70];
[...]
[Field error in object 'challengeAttemptDTO' on field 'factorA': rejected value [2000];
[...]
Listing 3-34An Invalid Request Causes Now the Expected Result
处理用户发送尝试的 REST API 调用的控制器现在正在工作。如果我们再次启动应用,我们可以通过 HTTPie 命令使用这个新的端点。首先,我们像以前一样要求随机挑战。然后,我们尝试解决它。参见清单 3-35 。
$ http -b :8080/challenges/random
{
"factorA": 58,
"factorB": 92
}
$ http POST :8080/attempts factorA=58 factorB=92 userAlias=moises guess=5400
HTTP/1.1 200
Connection: keep-alive
Content-Type: application/json
Date: Fri, 03 Apr 2020 04:49:51 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked
{
"correct": false,
"factorA": 58,
"factorB": 92,
"id": null,
"resultAttempt": 5400,
"user": {
"alias": "moises",
"id": null
}
}
Listing 3-35Running a Standard Use Case for the Application Using HTTPie Commands
第一个命令使用参数-b
只打印响应的正文。如你所见,我们也可以省略localhost
,HTTPie 会默认使用它。
为了发送尝试,我们在 URL 前使用了POST
参数。JSON 是 HTTPie 中的默认内容类型,所以我们可以简单地传递key=value
参数,这个工具会将其转换成合适的 JSON。不出所料,我们得到了一个序列化的ChallengeAttempt
对象,表明结果不正确。
我们还可以尝试一个无效的请求,看看 Spring Boot 是如何处理验证错误的。参见清单 3-36 。
$ http POST :8080/attempts factorA=58 factorB=92 userAlias=moises guess=-400
HTTP/1.1 400
Connection: close
Content-Type: application/json
Date: Sun, 16 Aug 2020 07:30:10 GMT
Transfer-Encoding: chunked
{
"error": "Bad Request",
"errors": [
{
"arguments": [
{
"arguments": null,
"code": "guess",
"codes": [
"challengeAttemptDTO.guess",
"guess"
],
"defaultMessage": "guess"
}
],
"bindingFailure": false,
"code": "Positive",
"codes": [
"Positive.challengeAttemptDTO.guess",
"Positive.guess",
"Positive.int",
"Positive"
],
"defaultMessage": "must be greater than 0",
"field": "guess",
"objectName": "challengeAttemptDTO",
"rejectedValue": -400
}
],
"message": "Validation failed for object="challengeAttemptDTO". Error count: 1",
"path": "/attempts",
"status": 400,
"timestamp": "2020-08-16T07:30:10.212+00:00"
}
Listing 3-36Error Response Including Validation Messages
这是一个相当冗长的回答。主要原因是所有的绑定错误(那些由验证约束引起的)都被添加到错误响应中。这是我们用server.error.include-binding-errors=always
打开的。此外,root message
字段还为客户端提供了出错原因的总体描述。默认情况下省略了这个描述,但是我们使用属性server.error.include-message=always
启用了它。
如果这个响应进入用户界面,您需要在前端解析这个 JSON 响应,获取无效的字段,并可能显示defaultMessage
字段。更改这个默认消息非常简单,因为您可以用约束注释覆盖它。让我们修改ChallengeAttemptDTO
中的注释,然后用相同的无效请求再试一次。见清单 3-37 。
@Positive(message = "How could you possibly get a negative result here? Try again.")
int guess;
Listing 3-37Changing the Validation Message
在这种情况下,Spring Boot 处理错误的方式是偷偷在上下文中添加一个@Controller
:BasicErrorController
(参见 https://tpd.io/bec-source
)。这一个使用类DefaultErrorAttributes
( https://tpd.io/dea-source
)来编写错误响应。如果你想深入了解如何定制这个行为的更多细节,你可以看看 https://tpd.io/cust-err-handling
。
总结和成就
我们从我们将在本书中构建的应用的需求开始这一章。然后,我们划分了范围,选择了第一个开发项目:生成随机挑战并允许用户猜测结果的功能。
您了解了如何创建 Spring Boot 应用的框架,以及关于软件设计和架构的一些最佳实践:三层和三层架构、领域驱动设计、测试驱动/行为驱动开发、JUnit 5 的基本单元测试,以及 REST API 设计。在本章中,您关注了应用层,并以 REST API 的形式实现了域对象、业务层和表示层。见图 3-6 。
图 3-6
章节 3 后的应用状态
本章还介绍了 Spring Boot 的一个核心概念:自动配置。现在你知道大部分 Spring Boot 魔术师住在哪里了。将来,您应该能够在参考文档中找到自己的方法来覆盖任何其他配置类中的其他默认行为。
我们还在 Spring Boot 体验了其他特性,比如实现@Service
和@Controller
组件,用MockMvc
测试控制器,以及通过 Java Bean Validation API 验证输入。
为了完成我们的第一个 web 应用,我们需要构建一个用户界面。稍后,我们还将讨论数据层,以确保我们可以持久化用户和尝试。
章节成就:
-
您了解了如何按照三层设计构建一个结构合理的 Spring Boot 应用。
-
基于两个带有支持图的实际例子:Tomcat 嵌入式服务器和 JSON 序列化默认值,您理解了 Spring Boot 的自动配置是如何工作的,这是揭示其魔力的关键。
-
您按照领域驱动的设计技术建模了一个示例业务案例。
-
您使用测试驱动开发方法开发了第一个应用三层中的两层(服务、控制器)。
-
您使用了最重要的 Spring MVC 注释,通过 Spring Boot 实现了 REST API。
-
您学习了如何在 Spring 中使用 MockMVC 测试控制器层。
-
您向 API 添加了验证约束,以防止无效输入。
四、使用 React 的最小前端
一本号称实用的关于微服务的书也得提供前端。在现实生活中,用户不会通过 REST APIs 与应用进行交互。
由于这本书关注的是现实生活中使用的流行技术,我们将在 React 中构建我们的前端。这个 JavaScript 框架允许我们基于可重用的组件和服务轻松开发网页。根据 2020 年 StackOverflow 的开发者调查( https://tpd.io/js-fw-list
),与 Angular 或 Vue.js 等其他类似的替代框架相比,React 是最受欢迎的框架。这使得它已经是一个不错的选择。最重要的是,我认为这是一个对 Java 开发人员友好的框架:您可以使用 TypeScript,这是 JavaScript 的一个扩展,它向这种编程语言添加了类型,对于习惯于它们的人来说,这使得一切都变得更容易。此外,React 的编程风格允许我们创建类来构建组件和服务,这使得 Java 开发人员熟悉 React 项目的结构。
我们还将使用 Node,一个随npm
一起提供的 JavaScript 运行时,它是管理 JavaScript 依赖关系的工具。通过这种方式,您可以获得一些关于 UI 技术的实践经验,如果您还不是一名全栈开发人员,为什么不成为一名呢?
无论如何,请记住这条重要的免责声明:我们不会深入讨论如何用 React 构建 web 应用的细节。我们希望继续关注 Spring Boot 的微服务。因此,如果你没有完全掌握本章的所有概念,也不要难过,尤其是如果你从未见过 JavaScript 代码或 CSS。
考虑到您在 GitHub 资源库( https://github.com/Book-Microservices-v2/chapter04
)中拥有所有可用的源代码,您可以用几种方式来阅读本章。
-
照着读。您将获得一些基础知识,并将在 React 中尝试一些重要的概念。
-
暂停一会儿阅读官方网站上的主要概念指南(
https://tpd.io/react-mc
),然后回到本章。通过这种方式,你会对我们将要构建的内容有更多的背景知识。 -
如果您对前端技术完全不感兴趣,可以完全跳过这一章,使用存储库中的源代码。可以放心地跳到下一章,继续使用不断发展的应用方法。
快速介绍 React 和 Node
React 是一个用于构建用户界面的 JavaScript 库。它由脸书开发,在前端开发人员中很受欢迎。它在许多组织中被广泛使用,这也导致了活跃的就业市场。
和其他库一样,React 也是基于组件的。这对于后端开发人员来说是一个优势,因为您只需编写一次代码,就可以在任何地方重用,这个概念听起来很熟悉。
在 React 中,您可以使用 JSX,而不是在单独的文件中编写 HTML 和 JavaScript 源代码,这是 JavaScript 语法的一个扩展,允许我们组合这些语言。这很有用,因为您可以在单个文件中编写组件,并通过功能隔离它们,将所有行为和渲染逻辑放在一起。
设置开发环境
首先,您需要使用位于 nodejs.org
站点的一个可用安装包来安装 Node.js。在本书中,我们使用的是 Node v13.10 和npm
6.13.7。安装完成后,使用命令行工具进行验证,如清单 4-1 所示。
$ node --version
v13.10.1
$ npm --version
6.13.7
Listing 4-1Getting the Version of Node.js and npm
现在您可以使用npm
中包含的工具npx
来创建 React 的前端项目。确保从您的工作空间根目录运行此命令,而不是在乘法服务中运行。
$ npx create-react-app challenges-frontend
源代码
您可以在 GitHub 的chapter04
资源库中找到本章的所有源代码。
https://github.com/Book-Microservices-v2/chapter04
见。
下载并安装依赖项一段时间后,您将得到类似清单 4-2 所示的输出。
Success! Created challenges-frontend at /Users/moises/workspace/learn-microservices/challenges-frontend
Inside that directory, you can run several commands:
[...]
We suggest that you begin by typing:
cd challenges-frontend
npm start
Listing 4-2Console Output After Creating the React Project
如果您按照建议运行npm start
,节点服务器将在http://localhost:3000
启动,您甚至可以打开一个浏览器窗口,显示我们刚刚生成的应用中包含的预定义网页。如果你不知道,你可以从你的浏览器导航到http://localhost:3000
来快速浏览这个页面。
反应骨架
下一个任务是将 React 项目加载到我们的工作区中。例如,在 IntelliJ 中,可以使用现有源中的选项文件➤新➤模块将前端文件夹作为单独的模块加载。正如您将看到的,我们已经得到了许多由create-react-app
工具创建的文件。见图 4-1 。
图 4-1
反应项目框架
-
package.json
和package-lock.json
是npm
文件。它们包含项目的基本信息,还列出了它的依赖项。这些依赖关系存储在node_modules
文件夹中。 -
public
文件夹是您可以保存所有静态文件的地方,这些文件在构建完成后将保持不变。唯一的例外是index.html
,它将被处理成包含结果 JavaScript 源。 -
所有的 React 源代码及其相关资源都包含在
src
文件夹中。在这个框架应用中,您可以找到主入口点文件index.js
和一个 React 组件App
。这个示例组件带有自己的样式表App.css
和一个测试App.test.js
。当您构建 React 项目时,所有这些文件最终会合并成更大的文件,但是这种命名约定和结构对开发很有帮助。
在 React 中,这些文件是如何相互关联的?先说index.html
。移除注释行后的body
标签的内容见清单 4-3 。
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
Listing 4-3The root Div in HTML
清单 4-4 显示了index.js
文件内容的一部分。
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
Listing 4-4The Entrypoint to Render the React Content
这段代码展示了如何将 React 元素呈现到文档对象模型(DOM)中,DOM 是 HTML 元素的树型表示。这段代码将元素React.StrictMode
及其子组件App
呈现到 HTML 中。更具体地说,它们被渲染到 ID 为root
的元素中,标签div
被插入到index.html
中。因为App
是一个组件,并且它可能包含其他组件,所以它最终处理并呈现整个 React 应用。
JavaScript 客户端
在创建我们的第一个组件之前,让我们确保有一种方法可以从我们在前一章中创建的 REST API 中检索数据。我们将为此使用一个 JavaScript 类。正如你将在本章的其余部分看到的,我们将使用类和类型保持一种类似 Java 的编程风格来构建我们的前端。
JavaScript 中的类类似于 Java 类。对于我们的具体情况,我们可以用两个静态方法创建一个实用程序类。参见清单 4-5 。
class ApiClient {
static SERVER_URL = 'http://localhost:8080';
static GET_CHALLENGE = '/challenges/random';
static POST_RESULT = '/attempts';
static challenge(): Promise<Response> {
return fetch(ApiClient.SERVER_URL + ApiClient.GET_CHALLENGE);
}
static sendGuess(user: string,
a: number,
b: number,
guess: number): Promise<Response> {
return fetch(ApiClient.SERVER_URL + ApiClient.POST_RESULT,
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(
{
userAlias: user,
factorA: a,
factorB: b,
guess: guess
}
)
});
}
}
export default ApiClient;
Listing 4-5The ApiClient Class
两种方法都返回承诺。JavaScript 中的承诺类似于 Java 的Future
类:它表示异步操作的结果。我们的函数调用fetch
(参见 https://tpd.io/fetch-api
),这是 JavaScript 中的一个函数,我们可以用它来与 HTTP 服务器交互。
第一个方法challenge()
使用了基本形式的fetch
函数,因为它默认对传递的 URL 进行 GET 操作。这个方法返回一个Response
对象的承诺( https://tpd.io/js-response
)。
sendGuess
方法接受我们构建请求以解决挑战所需的参数。这一次,我们使用带有第二个参数的fetch
:定义 HTTP 方法(POST)的对象、请求(JSON)中主体的内容类型以及主体。为了构建 JSON 请求,我们使用实用方法JSON.stringify
,它序列化一个对象。
最后,为了使我们的类可以公开访问,我们在文件的末尾添加了export default ApiClient
。这使得在其他组件和类中导入完整的类成为可能。
挑战部分
让我们构建我们的第一个 React 组件。我们在前端也将遵循模块化,这意味着这个组件将负责Challenges
域。目前,这意味着以下几点:
-
呈现从后端检索的挑战
-
为用户显示一个表单以发送猜测
参见清单 4-6 获取ChallengeComponent
类的完整源代码。在接下来的几节中,我们将剖析这段代码,并使用它来学习如何在 React 中构造组件以及它的一些基本概念。
import * as React from "react";
import ApiClient from "../services/ApiClient";
class ChallengeComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
a: '', b: '',
user: '',
message: '',
guess: 0
};
this.handleSubmitResult = this.handleSubmitResult.bind(this);
this.handleChange = this.handleChange.bind(this);
}
componentDidMount(): void {
ApiClient.challenge().then(
res => {
if (res.ok) {
res.json().then(json => {
this.setState({
a: json.factorA,
b: json.factorB
});
});
} else {
this.updateMessage("Can't reach the server");
}
}
);
}
handleChange(event) {
const name = event.target.name;
this.setState({
[name]: event.target.value
});
}
handleSubmitResult(event) {
event.preventDefault();
ApiClient.sendGuess(this.state.user,
this.state.a, this.state.b,
this.state.guess)
.then(res => {
if (res.ok) {
res.json().then(json => {
if (json.correct) {
this.updateMessage("Congratulations! Your guess is correct");
} else {
this.updateMessage("Oops! Your guess " + json.resultAttempt +
" is wrong, but keep playing!");
}
});
} else {
this.updateMessage("Error: server error or not available");
}
});
}
updateMessage(m: string) {
this.setState({
message: m
});
}
render() {
return (
<div>
<div>
<h3>Your new challenge is</h3>
<h1>
{this.state.a} x {this.state.b}
</h1>
</div>
<form onSubmit={this.handleSubmitResult}>
<label>
Your alias:
<input type="text" maxLength="12"
name="user"
value={this.state.user}
onChange={this.handleChange}/>
</label>
<br/>
<label>
Your guess:
<input type="number" min="0"
name="guess"
value={this.state.guess}
onChange={this.handleChange}/>
</label>
<br/>
<input type="submit" value="Submit"/>
</form>
<h4>{this.state.message}</h4>
</div>
);
}
}
export default ChallengeComponent;
Listing 4-6Our First React Component: ChallengeComponent
组件的主要结构
我们的类扩展了React.Component
,这就是你如何在 React 中创建组件。唯一需要实现的方法是render()
,它必须返回 DOM 元素以显示在浏览器中。在我们的例子中,我们使用 JSX ( https://tpd.io/jsx
)构建这些元素。参见清单 4-7 ,它展示了我们组件类的主要结构。
class ChallengeComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
a: '', b: '',
user: '',
message: '',
guess: 0
};
this.handleSubmitResult = this.handleSubmitResult.bind(this);
this.handleChange = this.handleChange.bind(this);
}
componentDidMount(): void {
// ... Component initialization
}
render() {
return (
// ... HTML as JSX ...
)
}
Listing 4-7Main Structure of a Component in React
通常,我们还需要一个构造函数来初始化属性,以及组件的状态(如果需要的话)。在ChallengeComponent
中,我们创建一个状态来保存检索到的挑战和用户为解决一次尝试而输入的数据。参数props
是作为 HTML 属性传递给组件的输入。
<ChallengeComponent prop1="value"/>
对于我们的组件,我们不需要props
,但是我们需要接受它作为一个参数,并把它传递给父构造函数,如果我们使用一个构造函数,这是我们所期望的。
在构造函数中,两行绑定了类方法。如果我们想在事件处理程序中使用this
,这是必需的,事件处理程序是我们需要实现来处理用户输入数据的函数。如需了解更多详情,请参见处理事件( https://tpd.io/react-events
)。我们将在本章后面描述这些功能。
函数componentDidMount
是一个生命周期方法,我们可以在 React 中实现它,以便在组件第一次呈现后立即执行逻辑。参见清单 4-8 。
componentDidMount(): void {
ApiClient.challenge().then(
res => {
if (res.ok) {
res.json().then(json => {
this.setState({
a: json.factorA,
b: json.factorB
});
});
} else {
this.updateMessage("Can't reach the server");
}
}
);
}
Listing 4-8Running Logic After Rendering the Component
我们所做的是调用服务器检索一个挑战,使用我们之前构建的ApiClient
实用程序类。假设函数返回一个承诺,我们使用then()
来指定当我们获得响应时做什么。内部逻辑也很简单:如果响应是ok
(意味着一个2xx
状态代码),我们将主体解析为json()
。这也是一个异步方法,所以我们用then()
再次解析承诺,并将预期的factorA
和factorB
从 REST API 响应传递给setState()
。
在 React 中,setState
函数重新加载部分 DOM。这意味着浏览器将再次呈现 HTML 中发生变化的部分,因此在我们从服务器获得响应后,我们将在页面上看到我们的倍增因子。在我们的应用中,这应该是几毫秒的事情,因为我们正在调用我们自己的本地服务器。例如,在现实生活中的网页中,您可以设置一个微调器,以改善慢速连接情况下的用户体验。
翻译
JSX 允许我们混合 HTML 和 JavaScript。这是非常强大的,因为您可以从 HTML 语言的简单性中获益,但是您也可以添加占位符和 JavaScript 逻辑。参见清单 4-9 中render()
方法的完整源代码及其后续解释。
render() {
return (
<div>
<div>
<h3>Your new challenge is</h3>
<h1>
{this.state.a} x {this.state.b}
</h1>
</div>
<form onSubmit={this.handleSubmitResult}>
<label>
Your alias:
<input type="text" maxLength="12"
name="user"
value={this.state.user}
onChange={this.handleChange}/>
</label>
<br/>
<label>
Your guess:
<input type="number" min="0"
name="guess"
value={this.state.guess}
onChange={this.handleChange}/>
</label>
<br/>
<input type="submit" value="Submit"/>
</form>
<h4>{this.state.message}</h4>
</div>
);
}
Listing 4-9Using render() with JSX to Display the Component’s Elements
组件的根元素有三个主块。第一个通过显示状态中包含的两个因素来显示挑战。在渲染的时候它们是未定义的,但是当我们从服务器得到响应后,它们会立即被重新加载(?? 内部的逻辑)。类似的区块是最后一个;它显示了message
状态属性,该属性是我们在获得发送的尝试请求的响应时设置的。
为了让用户输入他们的猜测,我们添加了一个表单,在提交时调用handleSubmitResult
。这个表单有两个输入:一个是用户的别名,另一个是猜测。两者遵循相同的方法:它们的值是状态对象的属性,并且它们在每次击键时调用相同的函数handleChange
。这个函数使用我们输入的name
属性在组件状态中找到相应的属性来更新。注意event.target
指向事件发生的 HTML 元素。参见清单 4-10 获取这些处理函数的源代码。
handleChange(event) {
const name = event.target.name;
this.setState({
[name]: event.target.value
});
}
handleSubmitResult(event) {
event.preventDefault();
ApiClient.sendGuess(this.state.user,
this.state.a, this.state.b,
this.state.guess)
.then(res => {
if (res.ok) {
res.json().then(json => {
if (json.correct) {
this.updateMessage("Congratulations! Your guess is correct");
} else {
this.updateMessage("Oops! Your guess " + json.resultAttempt +
" is wrong, but keep playing!");
}
});
} else {
this.updateMessage("Error: server error or not available");
}
});
}
Listing 4-10Handling User’s Input
在表单提交时,我们调用服务器的 API 来发送一个猜测。当我们得到响应时,我们检查它是否正确,解析 JSON,然后更新状态中的消息。然后,HTML DOM 的这一部分被再次呈现。
与应用集成
现在我们已经完成了组件的代码,我们可以在我们的应用中使用它了。为此,让我们修改App.js
文件,它是 React 代码库中的主要(或根)组件。参见清单 4-11 。
import React from 'react';
import './App.css';
import ChallengeComponent from './components/ChallengeComponent';
function App() {
return (
<div className="App">
<header className="App-header">
<ChallengeComponent/>
</header>
</div>
);
}
export default App;
Listing 4-11Adding Our Component as a Child of App.js, the Root Component
如前所述,skeleton 应用在index.js
文件中使用这个App
组件。当我们构建代码时,生成的脚本包含在index.html
文件中。
我们还应该修改包含在App.test.js
中的测试,或者干脆删除它。我们不会深入 React 测试的细节,所以你现在可以删除它。如果您想了解更多关于为 React 组件编写测试的信息,请查看官方指南中的测试章节( https://tpd.io/r-testing
)。
首次运行我们的前端
我们修改了用create-react-app
构建的框架应用,以包含我们自定义的 React 组件。请注意,我们没有删除其他文件,如样式表,我们也可以自定义这些文件。正如你在App.js
的代码中看到的,我们实际上重用了其中的一些类。
是时候验证一下我们的前端和后端是否协同工作了。确保首先运行 Spring Boot 应用,然后使用前端应用根文件夹中的npm
执行 React 前端。
$ npm start
成功编译后,这个命令行工具应该会打开您的默认浏览器,并显示位于localhost:3000
的页面。这是开发服务器所在的地方。参见图 4-2 显示当我们从浏览器访问该 URL 时呈现的网页。
图 4-2
带有空白因子的应用
那里出了问题。这些因素是空白的,但是我们的代码在组件呈现之后检索它们。我们来看看如何调试这个问题。
排除故障
有时事情并不像预期的那样发展,你的应用根本无法工作。你在浏览器上运行应用,那么你怎么知道会发生什么呢?好消息是大多数流行的浏览器都为开发者提供了强大的工具。在 Chrome 中,你可以使用 Chrome DevTools(参见 https://tpd.io/devtools
)。使用 Ctrl+May+I (Windows)或 Cmd+Opt+I (Mac)在浏览器中打开一个区域,其中有几个选项卡和部分显示网络活动、JavaScript 控制台等。
打开开发模式并刷新浏览器。您可以检查的功能之一是您的前端是否与服务器正常交互。点击网络选项卡,在列表中,您会看到一个失败的对http://localhost:8080/challenges/random
的 HTTP 请求,如图 4-3 所示。
图 4-3
Chrome DevTools(铬 DevTools)
该控制台还显示一条描述性消息:
默认情况下,您的浏览器会阻止试图访问不同于前端所在域的资源的请求。这是为了避免浏览器中的恶意页面访问不同页面中的数据,这被称为同源策略。在我们的例子中,我们在localhost
中运行前端和后端,但是它们运行在不同的端口上,所以它们被认为是不同的来源。
有多种方法可以解决这个问题。在我们的例子中,我们将启用跨源资源共享(CORS),这是一个可以在服务器端启用的安全策略,允许我们的前端与来自不同源的 REST API 一起工作。
将 CORS 配置添加到 Spring Boot 应用
我们回到后端代码库,添加一个 Spring Boot @Configuration
类,它将覆盖一些默认值。根据参考文档( https://tpd.io/spring-cors
),我们可以实现接口WebMvcConfigurer
并覆盖方法addCorsMapping
来添加一个通用的 CORS 配置。为了保持类的有序,我们为这个类创建了一个名为configuration
的新包。参见清单 4-12 。
package microservices.book.multiplication.configuration;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
@Override
public void addCorsMappings(final CorsRegistry registry) {
registry.addMapping("/**").allowedOrigins("http://localhost:3000");
}
}
Listing 4-12Adding the CORS Configuration to the Back-End Application
这个方法使用我们可以定制的注入的CorsRegistry
实例。我们添加一个映射,允许前端的原点访问由/**
表示的任何路径。我们也可以省略这一行中的allowedOrigins
部分。然后,将允许所有来源,而不仅仅是http://localhost:3000
。
请记住,Spring Boot 扫描您的软件包寻找配置类。这是其中之一,所以这个 CORS 配置将在您下次启动应用时自动应用。
关于 CORS,有一点很重要,一般来说,你可能只在开发的时候需要它。如果您将应用的前端和后端部署到同一个主机上,您不会遇到任何问题,并且您不应该让 CORS 尽可能严格地保持安全策略。当您将后端和前端部署到不同的主机时,您仍然应该在 CORS 配置中非常有选择性,并避免添加对所有来源的完全访问。
使用应用
现在我们的前端和后端应该协同工作。如果您还没有重新启动 Spring Boot 应用,请重新启动它并刷新您的浏览器(图 4-4 )。
图 4-4
我们应用的第一个版本
激动人心的时刻!现在,您可以输入您的别名并进行一些尝试。记得尊重规则,只用脑子去猜测结果。
部署 React 应用
到目前为止,我们的前端一直使用开发模式。我们用npm start
启动了 web 服务器。当然,这在生产环境中是行不通的。
为了准备 React 应用进行部署,我们需要首先构建它。参见清单 4-13 。
$ npm run build
> challenges-frontend@0.1.0 build /Users/moises/dev/apress2/learn-microservices/challenges-frontend
> react-scripts build
Creating an optimized production build...
Compiled successfully.
File sizes after gzip:
39.92 KB (+540 B) build/static/js/2.548ff48a.chunk.js
1.32 KB (+701 B) build/static/js/main.3411a94e.chunk.js
782 B build/static/js/runtime-main.8b342bfc.js
547 B build/static/css/main.5f361e03.chunk.css
The project was built assuming it is hosted at /.
You can control this with the homepage field in your package.json.
The build folder is ready to be deployed.
You may serve it with a static server:
npm install -g serve
serve -s build
Find out more about deployment here:
bit.ly/CRA-deploy
Listing 4-13Building the React App for a Production Deployment
正如您所看到的,这个命令在build
文件夹下生成了所有的脚本和文件。我们还在那里找到了我们放在public
文件夹中的文件的副本。这些日志还告诉我们如何使用npm
安装静态 web 服务器。但实际上,我们已经有了一个嵌入在 Spring Boot 应用中的 web 服务器 Tomcat。我们不能用那个吗?我们当然可以。
对于我们的部署示例,我们将遵循最简单的方法,将整个应用(后端和前端)打包在同一个可部署单元中:由 Spring Boot 生成的 fat JAR 文件。
我们需要做的是将前端的build
文件夹中的所有文件复制到乘法代码库的src/main/resources
文件夹中的一个名为static
的文件夹中。见图 4-5 。Spring Boot 的默认服务器配置为静态 web 文件添加了一些预定义的位置,我们的类路径中的这个static
文件夹就是其中之一。这些文件将被映射到位于/
的应用的根上下文。
图 4-5
项目结构中的静态资源
通常,如果您愿意,您可以配置这些资源位置及其映射。您可以对此进行微调的地方之一实际上是我们用于 CORS 注册中心配置的同一个WebMvcConfigurer
接口实现。如果您想了解更多关于配置 web 服务器来提供静态页面( https://tpd.io/mvc-static
)的信息,请查看 Spring Boot 参考文档中的静态内容一节。
然后,我们重新启动乘法应用。这一次,使用./mvnw spring-boot:run
通过命令行(而不是通过您的 IDE)运行它是很重要的。原因是 ide 在运行应用时可能会以不同的方式使用类路径,在这种情况下,您可能会得到错误(例如,找不到页面)。
如果我们导航到http://localhost:8080
,我们的 Spring Boot 应用中的嵌入式 Tomcat 服务器将试图找到一个默认的index.html
页面,它的存在是因为我们从 React 构建中复制了它。现在,我们已经从用于后端的同一台嵌入式服务器上加载了 React 应用。见图 4-6 。
图 4-6
嵌入式 Tomcat 提供的 React 应用
假设现在前端和后端共享同一个原点,您可能想知道我们在上一节中添加的 CORS 配置现在会发生什么。当我们在同一个服务器中部署 React 应用时,就不再需要添加 CORS 了。您可以删除它,因为静态前端文件和后端 API 都位于原点http://localhost:8080
。无论如何,让我们保留这个配置,因为我们在开发 React 应用时将使用开发服务器。现在,您可以在我们的 Spring Boot 应用中再次删除static
文件夹中的内容。
总结和成就
是时候回顾一下我们在这一章中所取得的成就了。当我们开始时,我们有一个 REST API,我们通过命令行工具与之交互。现在,我们添加了一个与后端交互的用户界面,以检索挑战和发送尝试。我们为用户提供了真正的 web 应用。参见图 4-7 。
图 4-7
第四章末尾的应用的逻辑视图
我们使用create-react-app
工具创建了 React 应用的基础,并了解了它的结构。然后,我们用 JavaScript 开发了一个连接 API 的服务,以及一个使用该服务并呈现简单 HTML 代码块的 React 组件。
为了能够互连位于不同来源的后端和前端,我们向后端添加了 CORS 配置。
最后,我们看到了如何为生产构建 React 应用。我们还将生成的静态文件移动到后端项目代码库中,以说明如何从嵌入式 Tomcat 服务器提供静态内容。
理想情况下,本章帮助您理解前端应用的基础,并看到一个与 API 交互的实际例子。即使只是最基本的,这些知识也可能对你的职业生涯有所帮助。
我们将在接下来的章节中使用这个前端应用来说明微服务架构如何影响 REST API 客户端。
章节成就:
-
您学习了 React 的基础知识,React 是市场上最流行的 JavaScript 框架之一。
-
您使用
create-react-app
工具构建了 React 应用的框架。 -
您开发了一个带有基本用户界面的 React 组件,供用户发送尝试。
-
您了解了什么是 CORS,以及我们如何在后端添加例外来允许这些请求。
-
您已经快速了解了如何使用浏览器的开发工具调试前端。
-
您了解了如何打包 React 项目构建产生的 HTML 和 JavaScript,以及如何在与后端应用相同的 JAR 文件中分发它们。
-
您第一次看到了这个应用在它的最小版本中工作,包括后端和前端。
五、数据层
我们花了两章来完成我们的第一个用户故事。现在,我们有了一个可以试验的最小可行产品(MVP)。在敏捷中,以这种方式对需求进行切片是非常强大的。我们可以开始从一些测试用户那里收集反馈,并决定我们应该构建的下一个特性是什么。此外,如果我们的产品理念是错误的,改变一些东西还为时过早。
学习如何垂直地而不是水平地分割你的产品需求可以在构建软件时节省你很多时间。这意味着你不必等到你完成了一个完整的层再去下一层。取而代之的是,你在多个层次上开发作品,以便能够有一些作品。这也有助于你打造更好的产品或服务,因为当你能轻松做出反应时,你会得到反馈。如果你想了解更多关于故事分割的策略,请查看 http://tpd.io/story-splitting
。
假设我们的测试用户尝试了我们的应用。他们中的大多数人回来告诉我们,如果他们能够访问他们的统计数据,了解他们在一段时间内的表现,那就太好了。团队坐在一起,带回一个新的用户故事。
用户故事 2
作为该应用的用户,我想访问我最近的尝试,这样我就可以看到我是否随着时间的推移提高了我的大脑技能。
当将这个故事映射到技术解决方案时,我们很快注意到我们需要将尝试存储在某个地方。在这一章中,我们将介绍我们的三层应用架构中缺少的一层:数据层。这也意味着我们将使用三层架构的不同层:数据库。见图 5-1 。
图 5-1
我们的目标应用设计
我们还需要将这些新需求集成到其余的层中。总而言之,我们可以执行以下任务列表:
-
存储所有用户尝试,并有一种方法来查询每个用户。
-
公开一个新的 REST 端点来获取给定用户的最新尝试。
-
创建一个新的服务(业务逻辑)来检索这些尝试。
-
在用户发送新的尝试后,在网页上向用户显示尝试的历史记录。
数据模型
在我们在第三章中创建的概念模型中,有三个领域对象:用户、挑战和尝试。然后,我们决定打破挑战和尝试之间的联系。相反,为了保持我们的领域简单,我们在尝试中复制了这两个因素。这使得我们在对象之间只有一种关系需要建模:尝试属于一个特定的用户。
请注意,我们可以在简化的过程中更进一步,将用户数据(目前是别名)也包括在内。在这种情况下,我们现在需要存储的唯一对象就是尝试。然后,我们可以在同一个表中使用用户别名来查询我们的数据。但这是有代价的,比我们通过复制因素所假设的要高:我们认为用户是一个不同的领域,可能会随着时间的推移而演变,并与其他领域发生互动。在数据层中如此紧密地混合域不是一个好主意。
还有另一种设计方案。我们可以通过用三个独立的对象精确映射我们的概念域来创建我们的域类,并且在ChallengeAttempt
和Challenge
之间有一个链接。见图 5-2 。
图 5-2
概念模型的提醒
这可以用我们处理User
的方式来完成。见清单 5-1 。
@Getter
@ToString
@EqualsAndHashCode
@AllArgsConstructor
public class ChallengeAttempt {
private Long id;
private User user;
// We decided to include factors
// private final int factorA;
// private final int factorB;
// This is an alternative
private Challenge challenge;
private int resultAttempt;
private boolean correct;
}
Listing 5-1An Alternative Implementation of ChallengeAttempt
然后,在设计数据模型时,我们可以选择简化。在这种方法中,我们将拥有域类ChallengeAttempt
的新版本,如前面的代码片段所示,以及数据层中的一个不同的类。例如,我们可以将这个类命名为ChallengeAttemptDataObject
。其中将包括内部因素,因此我们需要在层之间实现映射器,以组合和拆分挑战和尝试。您可能已经发现,这种方法类似于我们对 DTO 模式所做的。当时,我们在表示层创建了一个新版本的Attempt
对象,在那里我们还添加了一些验证注释。
正如在软件设计的许多其他方面一样,对于完全隔离 dto、域类和数据类,有多种支持和反对的意见。正如我们在假设案例中已经看到的,主要优势之一是我们获得了更高水平的隔离。我们可以替换数据层的实现,而不必修改服务层的代码。然而,一个很大的缺点是我们在应用中引入了大量的代码重复和复杂性。
在这本书里,我们遵循一种实用的方法,在应用适当的设计模式的同时,尽量让事情变得简单。我们在前一章中选择了一个域模型,现在我们可以将它直接映射到我们的数据模型。因此,我们可以为域和数据表示重用相同的类。这是一个很好的折衷解决方案,因为我们仍然保持我们的领域隔离。见图 5-3 显示了我们必须保存在数据库中的对象和关系。
图 5-3
乘法应用的数据模型
选择数据库
这一节将讨论如何根据项目的需求和我们将使用的抽象层次为我们的项目选择一个数据库。
SQL vs. NoSQL
市场上有很多可用的数据库引擎。它们都有自己的特点,但是,大多数时候,每个人都把它们归为两类:SQL 和 NoSQL。SQL 数据库是关系型的,有固定的模式,它们允许我们进行复杂的查询。NoSQL 数据库面向非结构化数据,例如可以面向键值对、文档、图形或基于列的数据。
简而言之,我们也可以说 NoSQL 数据库更适合大量的记录,因为这些数据库是分布式的。我们可以部署多个节点(或实例),因此它们在写入和/或读取数据时都有良好的性能。我们付出的代价是这些数据库遵循 CAP 定理( https://en.wikipedia.org/wiki/CAP_theorem
)。当我们以分布式方式存储数据时,我们只需在可用性、一致性和分区容差保证中选择两个。我们通常需要分区容错,因为网络错误很容易发生,所以我们应该能够处理它们。因此,大多数情况下,我们必须在尽可能长时间提供数据和保持数据一致之间做出选择。
另一方面,关系数据库(SQL)遵循 ACID 保证:原子性(事务作为一个整体要么成功要么失败);一致性(数据总是在有效状态之间转换);隔离(确保并发性不会引起副作用),以及持久性(在一个事务之后,即使在系统失败的情况下,状态也是持久的)。这些都是很棒的特性,但是为了确保它们,这些数据库不能适当地处理水平可伸缩性(多个分布式节点),这意味着它们不能很好地伸缩。
仔细分析您的数据需求非常重要。您打算如何查询数据?您需要高可用性吗?你写了几百万张唱片吗?你需要非常快速的阅读吗?此外,请记住系统的非功能性需求。例如,在我们的特殊情况下,我们可以接受系统每年有几个小时(甚至几天)不可用。然而,如果我们为医疗保健部门开发一个 web 应用,在那里生命可能处于危险之中,我们将处于不同的情况。在接下来的章节中,我们将回到非功能性需求,对其中的一些进行更详细的分析。
我们的模型是关系型。此外,我们不打算处理数百万的并发读写。我们将为我们的 web 应用选择一个 SQL 数据库,以从 ACID 保证中获益。
在任何情况下,保持我们的应用(未来的微服务)足够小的一个优点是,我们可以在以后需要时更改数据库引擎,而不会对整个软件架构产生大的影响。
H2、Hibernate 和 JPA
下一步是决定我们从所有的可能性中选择什么关系数据库:MySQL、MariaDB、PostgreSQL、H2、Oracle SQL 等等。在本书中,我们选择 H2 数据库引擎,因为它小巧且易于安装。它非常简单,可以嵌入到我们的应用中。
在关系数据库之上,我们将使用对象/关系映射(ORM)框架:Hibernate ORM。我们将使用 Hibernate 将 Java 对象映射到 SQL 记录,而不是处理表格数据和普通查询。如果你想了解更多关于 ORM 技术的知识,请查看 http://tpd.io/what-is-orm
。
我们不使用 Hibernate 中的本机 API 将对象映射到数据库记录,而是使用一个抽象:Java 持久性 API (JPA)。
这就是我们的技术选择相互关联的方式:
-
在我们的 Java 代码中,我们将使用 Spring Boot JPA 注释和集成,因此我们保持代码与 Hibernate 细节的分离。
-
在实现方面,Hibernate 负责将我们的对象映射到数据库实体的所有逻辑。
-
Hibernate 支持针对不同数据库的多种 SQL 方言,H2 方言就是其中之一。
-
Spring Boot 自动配置为我们设置了 H2 和 Hibernate,但是我们也可以自定义行为。
规范和实现之间的这种松散耦合给了我们一个很大的优势:更改到不同的数据库引擎将是无缝的,因为它是由 Hibernate 和 Spring Boot 配置抽象的。
Spring Boot 数据 JPA
让我们分析一下 Spring Boot 数据 JPA 模块提供了什么。
依赖性和自动配置
Spring 框架有多个模块可用于处理数据库,这些模块被归入 Spring Data 家族:JDBC、Cassandra、Hadoop、Elasticsearch 等。其中之一是 Spring Data JPA,它以基于 Spring 的编程风格,使用 Java 持久性 API 来抽象对数据库的访问。
Spring Boot 采取了额外的步骤,用一个专用的启动器使用自动配置和一些额外的工具来快速引导数据库访问:spring-boot-starter-data-jpa
模块。它还可以自动配置嵌入式数据库,如 H2,我们的应用的选择。
我们在创建应用时没有添加这些依赖项,以尊重循序渐进的方法。现在是时候这么做了。在我们的pom.xml
文件中,我们添加了 Spring Boot 启动器和 H2 嵌入式数据库实现。见清单 5-2 。我们只需要运行时的 H2 工件,因为我们将在代码中使用 JPA 和 Hibernate 抽象。
<dependencies>
[...]
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
[...]
</dependencies>
Listing 5-2Adding the Data Layer Dependencies to Our Application
源代码
您可以在 GitHub 的chapter05
资源库中找到本章的所有源代码。
https://github.com/Book-Microservices-v2/chapter05
见。
Hibernate 是 JPA 在 Spring Boot 的参考实现。这意味着 starter 将 Hibernate 依赖项带了进来。它还包括核心 JPA 工件以及与其父模块 Spring Data JPA 的依赖关系。
我们已经提到,H2 可以作为一个嵌入式数据库。因此,我们不需要自己安装、启动或关闭数据库。我们的 Spring Boot 应用将控制它的生命周期。然而,出于教学目的,我们也想从外部访问数据库,所以让我们在application.properties
文件中添加一个属性来启用 H2 数据库控制台。
# Gives us access to the H2 database web console
spring.h2.console.enabled=true
H2 控制台是一个简单的 web 界面,我们可以使用它来管理和查询数据。让我们通过再次启动我们的应用来验证这个新配置是否有效。我们将看到一些新的日志行,来自 Spring Boot 数据 JPA 自动配置逻辑。参见清单 5-3 。
INFO 33617 --- [main] o.s.web.context.ContextLoader : Root WebApplicationContext: initialization completed in 1139 ms
INFO 33617 --- [main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting...
INFO 33617 --- [main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed.
INFO 33617 --- [main] o.s.b.a.h2.H2ConsoleAutoConfiguration : H2 console available at '/h2-console'. Database available at 'jdbc:h2:mem:testdb'
INFO 33617 --- [main] o.hibernate.jpa.internal.util.LogHelper : HHH000204: Processing PersistenceUnitInfo [name: default]
INFO 33617 --- [main] org.hibernate.Version : HHH000412: Hibernate ORM core version 5.4.12.Final
INFO 33617 --- [main] o.hibernate.annotations.common.Version : HCANN000001: Hibernate Commons Annotations {5.1.0.Final}
INFO 33617 --- [main] org.hibernate.dialect.Dialect : HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
INFO 33617 --- [main] o.h.e.t.j.p.i.JtaPlatformInitiator : HHH000490: Using JtaPlatform implementation: [org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform]
INFO 33617 --- [main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
Listing 5-3Application Logs Showing Database Autoconfiguration
Spring Boot 在类路径中检测 Hibernate,并配置一个数据源。由于 H2 也可用,Hibernate 连接到 H2 并选择 H2 方言。它还为我们初始化了一个EntityManagerFactory
;我们很快就会明白这意味着什么。还有一个日志行声称 H2 控制台在/h2-console
可用,并且有一个数据库在jdbc:h2:mem:testdb
可用。如果没有指定其他配置,Spring Boot 自动配置会创建一个名为testdb
的现成的内存数据库。
让我们导航到http://localhost:8080/h2-console
来看看控制台 UI。见图 5-4 。
图 5-4
H2 控制台,登录
我们可以复制并粘贴jdbc:h2:mem:testdb
作为 JDBC 网址,其他值保持不变。然后,我们单击 Connect,我们可以访问主控制台视图。见图 5-5 。
图 5-5
H2 控制台,连接
看起来我们确实有一个名为testdb
的内存数据库,并且我们能够使用 H2 默认的管理员凭证连接到它。这个数据库来自哪里?这是我们将很快分析的内容。
我们将在本章后面使用 H2 控制台界面来查询我们的数据。现在,让我们继续学习,探索 Spring Boot 和 Data JPA starter 附带的技术堆栈。
Spring Boot 数据 JPA 技术堆栈
让我们从最底层开始,使用图 5-6 作为视觉支持。在包java.sql
和javax.sql
中有一些核心的 Java APIs 来处理 SQL 数据库。在那里,我们可以找到接口DataSource
、Connection
,以及其他一些用于池化资源的接口,如PooledConnection
或ConnectionPoolDataSource
。我们可以找到不同供应商提供的这些 API 的多种实现。Spring Boot 附带了 HikariCP ( http://tpd.io/hikari
),这是最流行的DataSource
连接池实现之一,因为它是轻量级的,并且具有良好的性能。
Hibernate 使用这些 API(以及我们的应用中的 HikariCP 实现)来连接 H2 数据库。Hibernate 中用于管理数据库的 JPA 风格是SessionImpl
类( http://tpd.io/h-session
),它包含了大量用于执行语句、执行查询、处理会话连接等的代码。这个类通过它的层次树实现了 JPA 接口EntityManager
( http://tpd.io/jpa-em
)。这个接口是 JPA 规范的一部分。在 Hibernate 中,它的实现完成了 ORM。
图 5-6
Spring Data JPA 技术堆栈
在 JPA 的EntityManager
之上,Spring Data JPA 定义了一个JpaRepository
接口(在 Spring 中,SimpleJpaRepository
类(tpd.io/simple-jpa-repo
)是默认的实现,并在底层使用EntityManager
。这意味着我们不需要使用纯 JPA 标准或 Hibernate 来在代码中执行数据库操作,因为我们可以使用这些 Spring 抽象。
我们将在本章后面探索 Spring 为 JPA Repository
类提供的一些很酷的特性。
数据源(自动)配置
当我们使用新的依赖项再次运行我们的应用时,有些事情可能会让您感到惊讶。我们还没有配置数据源,那么为什么我们能够成功地打开与 H2 的连接呢?答案总是自动配置,但这一次它带来了一点额外的魔力。
通常,我们使用application.properties
中的一些值来配置数据源。这些属性由 Spring Boot 自动配置依赖项中的DataSourceProperties
类( http://tpd.io/dsprops
)定义,例如,它包含数据库的 URL、用户名和密码。像往常一样,还有一个DataSourceAutoConfiguration
类( http://tpd.io/ds-autoconfig
)使用这些属性在上下文中创建必要的 beans。在本例中,它创建了DataSource
bean 来连接数据库。
sa
用户名实际上来自 Spring 的DataSourceProperties
类中的一段代码。见清单 5-4 。
/**
* Determine the username to use based on this configuration and the environment.
* @return the username to use
* @since 1.4.0
*/
public String determineUsername() {
if (StringUtils.hasText(this.username)) {
return this.username;
}
if (EmbeddedDatabaseConnection.isEmbedded(determineDriverClassName())) {
return "sa";
}
return null;
}
Listing 5-4A Fragment of Spring Boot’s DataSourceProperties Class
由于 Spring Boot 开发人员知道这些惯例,他们可以准备 Spring Boot,这样我们就可以使用开箱即用的数据库。不需要传递任何配置,因为他们硬编码了用户名,默认情况下密码是空的String
。还有其他约定,如数据库名称;这就是我们如何得到testdb
数据库的。
我们不会使用 Spring Boot 创建的默认数据库。相反,我们在应用的名称后设置名称,并更改 URL 以创建存储在文件中的数据库。如果我们继续使用内存数据库,当我们关闭应用时,所有的尝试都将丢失。此外,我们必须添加参考文档中描述的参数DB_CLOSE_ON_EXIT=false
(参见此 http://tpd.io/sb-embed-db
),因此我们禁用自动关闭,并让 Spring Boot 决定何时关闭数据库。请参见清单 5-5 中的结果 URL,以及我们在application.properties
文件中包含的其他更改。之后还有一些额外的解释。
-
如前所述,我们将数据源更改为使用用户主目录
~
中名为multiplication
的文件。我们通过在 URL 中指定:file:
来做到这一点。要了解 H2 网址的所有配置可能性,请勾选http://tpd.io/h2url
。 -
为了简单起见,我们将让 Hibernate 为我们创建数据库模式。这个特性被称为自动数据定义语言(DDL)。我们将它设置为
update
,因为我们希望在创建或修改实体时同时创建和更新模式(正如我们将在下一节中所做的)。 -
最后,我们启用属性
spring.jpa.show-sql
,这样我们就可以在日志中看到查询。这对学习很有用。
# Gives us access to the H2 database web console
spring.h2.console.enabled=true
# Creates the database in a file
spring.datasource.url=jdbc:h2:file:~/multiplication;DB_CLOSE_ON_EXIT=FALSE
# Creates or updates the schema if needed
spring.jpa.hibernate.ddl-auto=update
# For educational purposes we will show the SQL in console
spring.jpa.show-sql=true
Listing 5-5application.properties File with
New Parameters for Database Configuration
实体
从数据的角度来看,JPA 将实体调用到 Java 对象。因此,假设我们打算存储用户和尝试,我们必须使User
和ChallengeAttempt
类成为实体。如前所述,我们可以为数据层创建新的类并使用映射器,但是我们希望保持代码库简单,所以我们重用了域定义。
首先,我们给User
添加一些 JPA 注释。参见清单 5-6 。
package microservices.book.multiplication.user;
import lombok.*;
import javax.persistence.*;
/**
* Stores information to identify the user.
*/
@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User {
@Id
@GeneratedValue
private Long id;
private String alias;
public User(final String userAlias) {
this(null, userAlias);
}
}
Listing 5-6The User Class After Adding JPA Annotations
让我们一个接一个地了解这个更新的User
类的特征:
-
我们添加了
@Entity
注释,将这个类标记为映射到数据库记录的对象。如果我们想用不同于缺省值的名称命名我们的表,我们可以在注释中添加一个值user
。同样,默认情况下,通过类中的 getters 公开的所有字段都将以默认的列名保存在映射表中。我们可以通过用 JPA 的@Transient
注释标记字段来排除它们。 -
Hibernate 的用户指南(
http://tpd.io/hib-pojos
)声明我们应该提供 setters 或者让我们的字段可以被 Hibernate 修改。幸运的是,Lombok 有一个快捷的注释,@Data
,它非常适合用作数据实体的类。该注释将equals
和hashCode
方法、toString
、getters 和 setters 分组。Hibernate 用户指南中的另一个章节告诉我们不要使用final
类。这样,我们允许 Hibernate 创建运行时代理,从而提高性能。我们将在本章的后面看到一个运行时代理如何工作的例子。 -
JPA 和 Hibernate 也要求我们的实体有一个默认的空构造函数(见
http://tpd.io/hib-constructor
)。我们可以用 Lombok 的@NoArgsConstructor
注释快速添加。 -
我们的
id
字段被标注为@Id
和@GeneratedValue
。这将是唯一标识每一行的列。我们使用一个生成的值,因此 Hibernate 将为我们填充该字段,从数据库中获取序列的下一个值。
对于ChallengeAttempt
类,我们使用了一些额外的特性。参见清单 5-7 。
package microservices.book.multiplication.challenge;
import lombok.*;
import microservices.book.multiplication.user.User;
import javax.persistence.*;
@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ChallengeAttempt {
@Id
@GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "USER_ID")
private User user;
private int factorA;
private int factorB;
private int resultAttempt;
private boolean correct;
}
Listing 5-7The ChallengeAttempt Class with JPA Annotations
与前面的类不同,我们的挑战尝试模型不仅有基本类型,还有一个嵌入的实体类型,User
。Hibernate 知道如何映射它,因为我们添加了 JPA 注释,但是它不知道这两个实体之间的关系。在数据库中,我们可以将这些关系建模为一对一、一对多、多对一和多对多。
我们在这里定义了一个多对一的关系,因为我们已经倾向于避免将用户与尝试相关联,而是将尝试与用户相关联。为了在我们的数据层做出这些决定,我们还应该考虑我们计划如何查询我们的数据。在我们的例子中,我们不需要从用户到尝试的链接。如果想了解 Hibernate 中实体关系的更多信息,可以查看 Hibernate 用户指南中的关联部分( http://tpd.io/hib-associations
)。
正如您在代码中看到的,我们正在向@ManyToOne
注释传递一个参数:fetch
类型。当从数据存储中收集我们的尝试时,我们必须告诉 Hibernate when 来收集嵌套用户的值,这些值存储在不同的表中。如果我们将它设置为EAGER
,用户数据将被收集。使用LAZY
,检索这些字段的查询将只在我们试图访问它们时执行。这是因为 Hibernate 为我们的实体类配置了代理类。参见图 5-7 。这些代理类扩展了我们的类;这就是为什么如果我们想让这个机制工作的话,我们不应该将它们声明为final
。对于我们的例子,Hibernate 将传递一个代理对象,该对象仅在第一次使用访问器(getter)时触发查询来获取用户。这就是懒惰这个术语的来源——它不到最后一刻是不会这么做的。
图 5-7
休眠,拦截类
一般来说,我们应该倾向于惰性关联,以避免触发对您可能不需要的数据的额外查询。在我们的例子中,当我们收集尝试时,我们不需要用户的数据。
@JoinColumn
注释让 Hibernate 用一个连接列链接两个表。为了保持一致性,我们传递给它的列名与代表用户索引的列名相同。这将转化为添加到CHALLENGE_ATTEMPT
表中的新列USER_ID
,它将存储对USER
表中相应用户的ID
记录的引用。
这是一个带有 JPA 和 Hibernate 的 ORM 的基本但有代表性的例子。如果您想扩展您对 JPA 和 Hibernate 所有可能性的了解,用户指南( http://tpd.io/hib-user-guide
)是一个很好的起点。
将域对象作为实体重用的后果
由于 JPA 和 Hibernate 的要求,我们需要向我们的类中添加 setters 和一个丑陋的空构造函数(Lombok 隐藏了它,但它仍然在那里)。这很不方便,因为它阻止我们按照良好的实践(比如不变性)来创建类。我们可以说我们的领域类被数据需求破坏了。
当您正在构建小型应用并且您知道这些决策背后的原因时,这不是一个大问题。您只需避免在代码中使用 setters 或空构造函数。然而,当与一个大团队或在一个中型或大型项目中工作时,这可能会成为一个问题,因为一个新的开发人员可能会因为类允许他们这样做而试图破坏好的实践。在这种情况下,您可以考虑像前面提到的那样分割域和实体类。这将带来一些代码重复,但是您可以更好地实施良好的实践。
仓库
当我们描述三层架构时,我们简要解释了数据层可能包含数据访问对象(Dao)和存储库。Dao 通常是耦合到数据库结构的类,而另一方面,存储库是以领域为中心的,所以这些类可以与聚合一起工作。
假设我们遵循领域驱动的设计,我们将使用存储库来连接数据库。更具体地说,我们将使用 JPA 存储库和 Spring Data JPA 中包含的特性。
在前面关于技术栈的部分,我们介绍了 Spring 的SimpleJpaRepository
类(参见 https://tpd.io/sjparepo-doc
),它使用 JPA 的EntityManager
(参见 https://tpd.io/em-javadoc
)来管理我们的数据库对象。Spring 抽象增加了一些特性,比如分页和排序,以及一些比普通 JPA 接口更方便使用的方法(例如,saveAll
、existsById
、count
等)。).
Spring Data JPA 还附带了一个普通 JPA 没有的强大功能:查询方法(参见 http://tpd.io/jpa-query-methods
)。
让我们使用我们的代码库来演示这个功能。我们需要一个查询来获取给定用户的最后一次尝试,这样我们就可以在网页上显示统计数据。除此之外,我们需要一些基本的实体管理来创建、读取和删除尝试。清单 5-8 中显示的接口提供了该功能。
package microservices.book.multiplication.challenge;
import org.springframework.data.repository.CrudRepository;
import java.util.List;
public interface ChallengeAttemptRepository extends CrudRepository<ChallengeAttempt, Long> {
/**
* @return the last 10 attempts for a given user, identified by their alias.
*/
List<ChallengeAttempt> findTop10ByUserAliasOrderByIdDesc(String userAlias);
}
Listing 5-8The ChallengeAttemptRepository Interface
我们创建的接口扩展了 Spring Data Commons 中的CrudRepository
接口( http://tpd.io/crud-repo
)。CrudRepository
定义了创建、读取、更新和删除(CRUD)对象的基本方法列表。Spring Data JPA 中的SimpleJpaRepository
类也实现了这个接口( http://tpd.io/simple-jpa-repo
)。除了CrudRepository
,我们还可以使用另外两种选择。
-
如果我们选择扩展普通的
Repository
,我们就得不到 CRUD 功能。然而,当我们想要微调我们想要从CrudRepository
中公开的方法时,该接口就像一个标记,而不是默认地获取它们。见http://tpd.io/repo-tuning
了解更多关于这种技术。 -
如果我们还需要分页和排序,我们可以扩展
PagingAndSortingRepository
。如果我们必须处理大的集合,那么这是很有帮助的,大的集合最好是以块或者页来查询。
当我们扩展这三个接口中的任何一个时,我们必须使用 Java 泛型,正如我们在这行中所做的:
... extends CrudRepository<ChallengeAttempt, Long> {
第一种类型指定返回实体的类,在我们的例子中是ChallengeAttempt
。第二个类必须匹配索引的类型,在我们的存储库中是一个Long
(id
字段)。
我们代码中最引人注目的部分是我们添加到接口中的方法名。在 Spring Data 中,我们可以通过在方法名中使用命名约定来创建定义查询的方法。在这个特殊的例子中,我们希望通过用户别名查询尝试,按照id
降序排列(最新的排在最前面),并选择列表中的前 10 个。按照方法结构,我们可以将查询描述如下:find Top 10(任何匹配的ChallengeAttempt
) by(字段userAlias
等于传递的参数)order by(字段id
)降序。
Spring Data 将处理您在接口中定义的方法,寻找那些没有明确定义查询的方法,并匹配创建查询方法的命名约定。这正是我们的情况。然后,它解析方法名,将其分解成块,并构建一个符合该定义的 JPA 查询(继续阅读示例查询)。
我们可以使用 JPA 查询方法定义构建许多其他查询;详见 http://tpd.io/jpa-qm-create
。
有时我们可能想要执行一些查询方法无法实现的查询。或者也许我们只是不习惯使用这个特性,因为方法名开始变得有点奇怪。不用担心,也可以定义我们自己的查询。在这种情况下,我们仍然可以通过用 Java 持久性查询语言(JPQL)编写查询来保持我们的实现从数据库引擎中抽象出来,JPQL 是一种 SQL 语言,也是 JPA 标准的一部分。参见清单 5-9 。
/**
* @return the last attempts for a given user, identified by their alias.
*/
@Query("SELECT a FROM ChallengeAttempt a WHERE a.user.alias = ?1 ORDER BY a.id DESC")
List<ChallengeAttempt> lastAttempts(String userAlias);
Listing 5-9A Defined Query as an Alternative to a Query Method
如您所见,它看起来像标准的 SQL。以下是不同之处:
-
我们不用表名,而是用类名(
ChallengeAttempt
)。 -
我们将字段称为对象字段,而不是列,使用点来遍历对象结构(
a.user.alias
)。 -
我们可以使用参数占位符,比如我们示例中的
?1
来引用第一个(也是唯一一个)传递的参数。
我们将坚持使用查询方法,因为它更短,也更具描述性,但是我们需要很快为我们的其他需求编写 JPQL 查询。
这就是我们管理数据库中的尝试实体所需的全部内容。现在,我们缺少管理User
实体的存储库。这个实现起来很简单,如清单 5-10 所示。
package microservices.book.multiplication.user;
import org.springframework.data.repository.CrudRepository;
import java.util.Optional;
public interface UserRepository extends CrudRepository<User, Long> {
Optional<User> findByAlias(final String alias);
}
Listing 5-10The UserRepository Interface
如果匹配的话,findByAlias
查询方法将返回一个包装在 Java Optional
中的用户,如果没有用户匹配传递的别名,则返回一个空的Optional
对象。这是 Spring Data 的 JPA 查询方法提供的另一个特性。
有了这两个存储库,我们就有了管理数据库实体所需的一切。我们不需要实现这些接口。我们甚至不需要添加 Spring 的@Repository
注释。使用数据模块,Spring 将找到扩展基本接口的接口,并将注入实现所需行为的 beans。这还包括处理方法名和创建相应的 JPA 查询。
存储用户和尝试
完成数据层之后,我们可以开始使用服务层的存储库。
首先,让我们用新的预期逻辑来扩展我们的测试用例:
-
该尝试应该被存储,不管它是否正确。
-
如果这是给定用户的第一次尝试,通过他们的别名识别,我们应该创建用户。如果别名存在,该尝试应该链接到该现有用户。
我们必须对我们的ChallengeServiceTest
类进行一些更新。首先,我们需要为两个存储库添加两个模拟。这样,我们将单元测试集中在服务层,而不包括来自其他层的任何真实行为。正如第二章所介绍的,这是 Mockito 的优势之一。
为了在 Mockito 中使用 mocks,我们可以用@Mock
注释对字段进行注释,并将MockitoExtension
添加到测试类中,使它们自动初始化。有了这个扩展,我们还得到了其他的 Mockito 特性,比如检测未使用的存根,如果我们指定了一个在测试用例中没有使用的 mock 行为,就会导致测试失败。见清单 5-11 。
@ExtendWith(MockitoExtension.class)
public class ChallengeServiceTest {
private ChallengeService challengeService;
@Mock
private UserRepository userRepository;
@Mock
private ChallengeAttemptRepository attemptRepository;
@BeforeEach
public void setUp() {
challengeService = new ChallengeServiceImpl(
userRepository,
attemptRepository
);
given(attemptRepository.save(any()))
.will(returnsFirstArg());
}
//...
}
Listing 5-11Using Mockito in the ChallengeServiceTest Class
此外,我们可以使用用 JUnit 的@BeforeEach
注释的方法向所有测试添加一些常见行为。在这种情况下,我们使用服务的构造函数来包含存储库(注意,这个构造函数还不存在)。我们也添加了这一行:
given(attemptRepository.save(any()))
.will(returnsFirstArg());
这条指令使用BDDMockito
的given
方法来定义当我们在测试期间调用特定方法时,模拟类应该做什么。请记住,我们不想使用真正的类的功能,所以我们必须定义,例如,在这个假对象(或存根)上调用函数时返回什么。我们想要覆盖的方法作为参数传递:attemptRepository.save(any())
。我们可以匹配传递给save()
的特定参数,但是我们也可以通过使用来自 Mockito 的参数匹配器的any()
为任何参数定义这个预定义的行为(查看 https://tpd.io/mock-am
以获得匹配器的完整列表)。指令的第二部分使用will()
,指定当先前定义的条件匹配时,Mockito 应该做什么。在 Mockito 的AdditionalAnswers
类中定义了returnsFirstArg()
实用方法,其中包含了一些我们可以使用的方便的预定义答案(参见 http://tpd.io/mockito-answers
)。如果需要实现更复杂的场景,您还可以声明自己的函数来提供定制的答案。在我们的例子中,我们希望save
方法什么也不做,只返回第一个(也是唯一一个)传递的参数。这对我们来说已经足够好了,不用调用真正的存储库就可以测试这一层。
现在,我们将额外的验证添加到现有的测试用例中。参见清单 5-12 ,其中包括正确尝试的测试用例作为示例。
@Test
public void checkCorrectAttemptTest() {
// given
ChallengeAttemptDTO attemptDTO =
new ChallengeAttemptDTO(50, 60, "john_doe", 3000);
// when
ChallengeAttempt resultAttempt =
challengeService.verifyAttempt(attemptDTO);
// then
then(resultAttempt.isCorrect()).isTrue();
// newly added lines
verify(userRepository).save(new User("john_doe"));
verify(attemptRepository).save(resultAttempt);
}
Listing 5-12Verifying Stub Calls in ChallengeServiceTest
我们使用 Mockito 的verify
来检查我们是否存储了一个具有空 ID 和预期别名的新用户。标识符将在数据库级别设置。我们还验证是否应该保存该尝试。验证错误尝试的测试用例也应该包含这两个新行。
为了使我们的测试更加完整,我们添加了一个新的案例来验证来自同一个用户的额外尝试不会创建新的用户实体,而是重用现有的用户实体。参见清单 5-13 。
@Test
public void checkExistingUserTest() {
// given
User existingUser = new User(1L, "john_doe");
given(userRepository.findByAlias("john_doe"))
.willReturn(Optional.of(existingUser));
ChallengeAttemptDTO attemptDTO =
new ChallengeAttemptDTO(50, 60, "john_doe", 5000);
// when
ChallengeAttempt resultAttempt =
challengeService.verifyAttempt(attemptDTO);
// then
then(resultAttempt.isCorrect()).isFalse();
then(resultAttempt.getUser()).isEqualTo(existingUser);
verify(userRepository, never()).save(any());
verify(attemptRepository).save(resultAttempt);
}
Listing 5-13Verifying That Only the First Attempt Creates the User Entity
在这种情况下,我们定义了userRepository
mock 的行为来返回一个现有的用户。因为挑战 DTO 包含相同的别名,所以逻辑应该找到我们的预定义用户,并且返回的尝试必须包括它,具有相同的别名和 ID。为了使测试更加详尽,我们检查了UserRepository
中的方法save()
从未被调用。
此时,我们有一个不编译的测试。我们的服务应该为两个存储库提供一个构造器。当我们启动应用时,Spring 将通过构造函数使用依赖注入来初始化存储库。这就是 Spring 帮助我们保持层松散耦合的方式。
然后,我们还需要主逻辑来存储尝试和用户(如果还不存在的话)。关于ChallengeServiceImpl
的新实现,请参见清单 5-14 。
package microservices.book.multiplication.challenge;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import microservices.book.multiplication.user.User;
import microservices.book.multiplication.user.UserRepository;
import org.springframework.stereotype.Service;
@Slf4j
@RequiredArgsConstructor
@Service
public class ChallengeServiceImpl implements ChallengeService {
private final UserRepository userRepository;
private final ChallengeAttemptRepository attemptRepository;
@Override
public ChallengeAttempt verifyAttempt(ChallengeAttemptDTO attemptDTO) {
// Check if the user already exists for that alias, otherwise create it
User user = userRepository.findByAlias(attemptDTO.getUserAlias())
.orElseGet(() -> {
log.info("Creating new user with alias {}",
attemptDTO.getUserAlias());
return userRepository.save(
new User(attemptDTO.getUserAlias())
);
});
// Check if the attempt is correct
boolean isCorrect = attemptDTO.getGuess() ==
attemptDTO.getFactorA() * attemptDTO.getFactorB();
// Builds the domain object. Null id since it'll be generated by the DB.
ChallengeAttempt checkedAttempt = new ChallengeAttempt(null,
user,
attemptDTO.getFactorA(),
attemptDTO.getFactorB(),
attemptDTO.getGuess(),
isCorrect
);
// Stores the attempt
ChallengeAttempt storedAttempt = attemptRepository.save(checkedAttempt);
return storedAttempt;
}
}
Listing 5-14The Updated ChallengeServiceImpl Class
Using the Repository Layer
verifyAttempt
中的第一个块使用存储库返回的Optional
来决定是否应该创建用户。只有当传递的函数为空时,Optional
中的方法orElseGet
才会调用它。因此,我们只在新用户还不存在时才创建它。
当我们构造一个尝试时,我们从存储库中传递返回的User
对象。当我们调用save()
来存储尝试实体时,Hibernate 会负责在数据库中正确地链接它们。我们返回结果,因此它包含数据库中的所有标识符。
现在所有的测试用例都应该通过了。同样,我们使用 TDD 来创建基于我们期望的逻辑。现在很清楚单元测试如何帮助我们验证特定层的行为,而不依赖于其他层。对于我们的服务类,我们用存根替换了两个存储库,我们为存根定义了预设值。
这些测试有另一种实现方式。我们可以对存储库类使用@SpringBootTest
风格和@MockBean
。然而,这并没有带来任何附加值,并且需要 Spring 上下文,所以测试需要更多的时间来完成。正如我们在前一章中所说的,我们更喜欢让我们的单元测试尽可能简单。
知识库测试
我们没有为应用的数据层创建测试。这些测试没有多大意义,因为我们没有编写任何实现。我们将最终验证 Spring Data 实现本身。
显示上次尝试
我们修改了现有的服务逻辑来存储用户和尝试,但是我们仍然缺少另一半功能:检索最后的尝试并在页面上显示它们。
服务层可以简单地使用存储库中的查询方法。在控制器层,我们将公开一个新的 REST 端点,通过用户别名获取尝试列表。
锻炼
继续遵循 TDD,并在继续实现之前完成一些任务。你会在本章的代码库中找到解决方案( https://github.com/Book-Microservices-v2/chapter05
)。
-
扩展
ChallengeServiceTest
并创建一个测试用例来验证我们可以检索最后的尝试。测试背后的逻辑是一行程序,但是最好在服务层增长时进行测试。注意,在这个测试用例中,您可能会从 Mockito 那里得到关于save
方法不必要的存根的抱怨。这是MockitoExtension
的特色之一。然后,您可以将这个存根移动到使用它的测试用例中。 -
更新
ChallengeAttemptController
类以包含对新端点GET /attempts?alias=john_doe
的测试。
服务层
让我们给ChallengeService
接口添加一个名为getStatsForUser
的方法。见清单 5-15 。
package microservices.book.multiplication.challenge;
import java.util.List;
public interface ChallengeService {
/**
* Verifies if an attempt coming from the presentation layer is correct or not.
*
* @return the resulting ChallengeAttempt object
*/
ChallengeAttempt verifyAttempt(ChallengeAttemptDTO attemptDTO);
/**
* Gets the statistics for a given user.
*
* @param userAlias the user's alias
* @return a list of the last 10 {@link ChallengeAttempt}
* objects created by the user.
*/
List<ChallengeAttempt> getStatsForUser(String userAlias);
}
Listing 5-15Adding the getStatsForUser Method to the ChallengeService Interface
清单 5-16 中的代码块展示了这个实现。正如预测的那样,这只是一行代码。
@Slf4j
@RequiredArgsConstructor
@Service
public class ChallengeServiceImpl implements ChallengeService {
// ...
@Override
public List<ChallengeAttempt> getStatsForUser(final String userAlias) {
return attemptRepository.findTop10ByUserAliasOrderByIdDesc(userAlias);
}
}
Listing 5-16Implementing the getStatsForUser Method
控制器层
让我们向上移动一层,看看我们如何从控制器连接服务层。这一次,我们使用了一个查询参数,但是这并没有给我们的 API 定义增加太多的复杂性。类似地,当我们在第一个方法中把请求体作为参数注入时,我们现在可以使用@RequestParam
来告诉 Spring 给我们传递一个 URL 参数。查看参考文档( http://tpd.io/mvc-ann
)中您可以定义的其他方法参数(例如,会话属性或 cookie 值)。见清单 5-17 。
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/attempts")
class ChallengeAttemptController {
private final ChallengeService challengeService;
@PostMapping
ResponseEntity<ChallengeAttempt> postResult(
@RequestBody @Valid ChallengeAttemptDTO challengeAttemptDTO) {
return ResponseEntity.ok(challengeService.verifyAttempt(challengeAttemptDTO));
}
@GetMapping
ResponseEntity<List<ChallengeAttempt>> getStatistics(@RequestParam("alias") String alias) {
return ResponseEntity.ok(
challengeService.getStatsForUser(alias)
);
}
}
Listing 5-17Adding New Endpoint in the Controller to Retrieve Statistics
如果您实现了测试,它们现在应该通过了。然而,如果我们使用 HTTPie 运行一个快速测试,我们会发现一个意想不到的结果。参见清单 5-18 。发送一次尝试,然后尝试检索列表会给我们一个错误。
$ http POST :8080/attempts factorA=58 factorB=92 userAlias=moises guess=5303
HTTP/1.1 200
...
$ http ":8080/attempts?alias=moises"
HTTP/1.1 500
...
{
"error": "Internal Server Error",
"message": "Type definition error: [simple type, class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor]; nested exception is com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: java.util.ArrayList[0]->microservices.book.multiplication.challenge.ChallengeAttempt[\"user\"]->microservices.book.multiplication.user.User$HibernateProxy$mk4Fwavp[\"hibernateLazyInitializer\"])",
"path": "/attempts",
"status": 500,
"timestamp": "2020-04-15T05:41:53.993+0000"
}
Listing 5-18Error During Serialization of the Attempt List
这是一个丑陋的服务器错误。我们还可以在后端日志中找到对应的异常。什么是 a ByteBuddyInterceptor
,为什么我们的ObjectMapper
要连载它?结果中应该只有ChallengeAttempt
对象,带有嵌套的User
实例,对吗?不完全是。
我们将嵌套的User
实体配置为在惰性模式下获取,因此不会从数据库中查询它们。我们还说过 Hibernate 在运行时为我们的类创建代理。这就是ByteBuddyInterceptor
类背后的原因。您可以尝试将获取模式切换到 EAGER,您将不会再收到此错误。但是这不是解决这个问题的正确方法,因为这样我们会触发很多对我们不需要的数据的查询。
让我们保持懒惰获取模式,并相应地解决这个问题。我们的第一个选择是定制我们的 JSON 序列化,以便它可以处理 Hibernate 对象。幸运的是,Jackson 库的提供者 FasterXML 有一个针对 Hibernate 的特定模块,我们可以在我们的ObjectMapper
对象中使用它:jackson-datatype-hibernate
( http://tpd.io/json-hib
)。要使用它,我们必须将这个依赖项添加到我们的项目中,因为 Spring Boot 初学者不包括它。见清单 5-19 。
<dependencies>
<!-- ... -->
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-hibernate5</artifactId>
</dependency>
<!-- ... -->
</dependencies>
Listing 5-19Adding the Jackson Module for Hibernate to Our Dependencies
然后我们按照 Spring Boot 有据可查的方式(参见 http://tpd.io/om-custom
)定制ObjectMapper
s:
"任何 com . faster XML . Jackson . databind . module 类型的 beans 都会自动向自动配置的 Jackson2ObjectMapperBuilder 注册,并应用于它创建的任何 ObjectMapper 实例。当您向应用添加新功能时,这提供了一种提供自定义模块的全局机制。
我们为 Jackson 的新 Hibernate 模块创建了一个 bean。Spring Boot 的Jackson2ObjectMapperBuilder
将通过自动配置使用它,我们所有的ObjectMapper
实例将使用 Spring Boot 的默认设置和我们自己的定制。见清单 5-20 展示这个新的JsonConfiguration
级。
package microservices.book.multiplication.configuration;
import com.fasterxml.jackson.databind.Module;
import com.fasterxml.jackson.datatype.hibernate5.Hibernate5Module;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JsonConfiguration {
@Bean
public Module hibernateModule() {
return new Hibernate5Module();
}
}
Listing 5-20Loading the Jackson’s Hibernate Module to Be Picked Up by Auto-Configuration
现在,我们启动我们的应用,并验证我们可以成功地检索尝试。嵌套的user
对象是null
,这是完美的,因为我们不需要它作为尝试列表。见清单 5-21 。我们避免了额外的询问。
$ http ":8080/attempts?alias=moises"
HTTP/1.1 200
...
[
{
"correct": false,
"factorA": 58,
"factorB": 92,
"id": 11,
"resultAttempt": 5303,
"user": null
},
...
]
Listing 5-21Correct Serialization of the Attempts After Adding the Hibernate Module
除了添加新的依赖项和新的配置之外,还有一种方法是遵循我们收到的异常消息中的建议:
...(to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS)[...]
让我们试试。我们可以在application.properties
文件中直接给 Jackson serializers 添加特性(参见 http://tpd.io/om-custom
)。这是通过一些命名约定实现的,在 Jackson 属性前面加上spring.jackson.serialization
。见清单 5-22 。
[...]
spring.jpa.show-sql=true
spring.jackson.serialization.fail_on_empty_beans=false
Listing 5-22Adding a Property to Avoid Serialization Errors on Empty Beans
如果您尝试这样做(在从以前的解决方案中删除代码之后),然后收集尝试,您会发现一个有趣的结果。参见清单 5-23 。
$ http ":8080/attempts?alias=moises"
HTTP/1.1 200
...
[
{
"correct": false,
"factorA": 58,
"factorB": 92,
"id": 11,
"resultAttempt": 5303,
"user": {
"alias": "moises",
"hibernateLazyInitializer": {},
"id": 1
}
},
...
]
Listing 5-23Retrieving Attempts with fail_on_empty_beans=false
有两个意想不到的结果。首先,代理对象的属性hibernateLazyInitializer
被序列化为 JSON,它是空的。这就是空 bean,它实际上是我们之前得到的错误的来源。我们可以通过忽略磁场的杰克逊配置来避免这种情况。但真正的问题是,用户的数据也在那里。序列化程序遍历代理来获取用户数据,这触发了来自 Hibernate 的额外查询来获取数据,这使得我们的惰性参数配置变得无用。我们还可以在日志中验证这一点,与之前的解决方案相比,我们得到了一个额外的查询。参见清单 5-24 。
Hibernate: select challengea0_.id as id1_0_, challengea0_.correct as correct2_0_, challengea0_.factora as factora3_0_, challengea0_.factorb as factorb4_0_, challengea0_.result_attempt as result_a5_0_, challengea0_.user_id as user_id6_0_ from challenge_attempt challengea0_ left outer join user user1_ on challengea0_.user_id=user1_.id where user1_.alias=? order by challengea0_.id desc limit ?
Hibernate: select user0_.id as id1_1_0_, user0_.alias as alias2_1_0_ from user user0_ where user0_.id=?
Listing 5-24Unwanted Query When Fetching Attempts with Suboptimal Configuration
对于 Jackson 的 Hibernate 模块,我们将坚持第一个选项,因为这是用 JSON 序列化处理延迟抓取的正确方法。
我们对这两种选择所做的分析得出的结论是,在 Spring Boot 有如此多的行为隐藏在幕后,你应该避免在没有真正理解其含义的情况下寻求快速解决方案。了解这些工具并阅读参考文档。
用户界面
我们堆栈中需要集成新功能来显示最后尝试的最后部分是在我们的 React 前端。和上一章一样,如果你不想深入了解 UI 的细节,可以跳过这一节。
现在让我们坚持使用一个基本的用户界面,并在页面中添加一个表格来显示用户的最后一次尝试。我们可以在发送新的尝试后发出请求,因为我们将获得用户的别名。
但是,在此之前,让我们替换预定义的 CSS,以确保所有内容都适合页面。
首先,我们直接移动ChallengeComponent
进行渲染,没有任何包装。参见清单 5-25 中的结果App.js
文件。
import React from 'react';
import './App.css';
import ChallengeComponent from './components/ChallengeComponent';
function App() {
return <ChallengeComponent/>;
}
export default App;
Listing 5-25App.js File After Moving the Component Up
然后,我们删除所有预定义的 CSS,并根据我们的需要进行调整。我们可以将这些基本样式分别添加到index.css
和App.css
文件中。参见列表 5-26 和 5-27 。
.display-column {
display: flex;
flex-direction: column;
align-items: center;
}
.challenge {
font-size: 4em;
}
th {
padding-right: 0.5em;
border-bottom: solid 1px;
}
Listing 5-27The Modified app.css File
body {
font-family: 'Segoe UI', Roboto, Arial, sans-serif;
}
Listing 5-26The Modified index.css File
我们将把display-column
应用到主 HTML 容器中,以垂直堆叠我们的组件,并使它们居中对齐。challenge
样式用于乘法,我们还定制了表格标题样式,使其具有一些填充并使用底线。
一旦我们为新表腾出了一些空间,我们就必须在 JavaScript 中扩展我们的ApiClient
来检索这些尝试。像以前一样,我们使用带有默认 GET 动词的fetch
,并构建 URL 以包含用户的别名作为查询参数。见清单 5-28 。
class ApiClient {
static SERVER_URL = 'http://localhost:8080';
static GET_CHALLENGE = '/challenges/random';
static POST_RESULT = '/attempts';
static GET_ATTEMPTS_BY_ALIAS = '/attempts?alias=';
static challenge(): Promise<Response> {
return fetch(ApiClient.SERVER_URL + ApiClient.GET_CHALLENGE);
}
static sendGuess(user: string,
a: number,
b: number,
guess: number): Promise<Response> {
// ...
}
static getAttempts(userAlias: string): Promise<Response> {
return fetch(ApiClient.SERVER_URL +
ApiClient.GET_ATTEMPTS_BY_ALIAS + userAlias);
}
}
export default ApiClient;
Listing 5-28ApiClient Class Update with the Method to Fetch Attempts
我们的下一个任务是为这个尝试列表创建一个新的React
组件。这样,我们可以保持我们的前端模块化。这个新组件不需要有状态,因为我们将使用父组件的状态来保存最后的尝试。
我们使用一个简单的 HTML table
来呈现通过props
对象传递的对象。作为 UI 级别的一个很好的补充,如果结果不正确,我们将显示正确的挑战结果。此外,我们将有一个有条件的style
属性,根据尝试是否正确,将文本颜色设置为绿色或红色。参见清单 5-29 。
import * as React from 'react';
class LastAttemptsComponent extends React.Component {
render() {
return (
<table>
<thead>
<tr>
<th>Challenge</th>
<th>Your guess</th>
<th>Correct</th>
</tr>
</thead>
<tbody>
{this.props.lastAttempts.map(a =>
<tr key={a.id}
style={{ color: a.correct ? 'green' : 'red' }}>
<td>{a.factorA} x {a.factorB}</td>
<td>{a.resultAttempt}</td>
<td>{a.correct ? "Correct" :
("Incorrect (" + a.factorA * a.factorB + ")")}</td>
</tr>
)}
</tbody>
</table>
);
}
}
export default LastAttemptsComponent;
Listing 5-29The New LastAttemptsComponent in React
如代码所示,我们可以在渲染 React 组件时使用map
来轻松迭代数组。数组的每个元素都应该使用一个key
属性来帮助框架识别变化的元素。参见 http://tpd.io/react-keys
了解更多关于使用唯一键渲染列表的细节。
现在我们需要将所有东西放在现有的ChallengeComponent
类中一起工作。添加一些修改后的代码见清单 5-30 。
-
一个新函数,它使用
ApiClient
来检索最后的尝试,检查 HTTP 响应是否正常,并将数组存储在状态中。 -
在我们收到对发送新尝试的请求的响应后,立即调用这个新函数。
-
该父组件的
render()
函数中组件的 HTML 标签。 -
作为改进,我们还提取了逻辑来刷新对新函数
refreshChallenge
的挑战(之前包含在componentDidMount
中)。我们将在用户发送尝试后为他们创建一个新的挑战。
import * as React from "react";
import ApiClient from "../services/ApiClient";
import LastAttemptsComponent from './LastAttemptsComponent';
class ChallengeComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
a: '', b: '',
user: '',
message: '',
guess: 0,
lastAttempts: [],
};
this.handleSubmitResult = this.handleSubmitResult.bind(this);
this.handleChange = this.handleChange.bind(this);
}
// ...
handleSubmitResult(event) {
event.preventDefault();
ApiClient.sendGuess(this.state.user,
this.state.a, this.state.b,
this.state.guess)
.then(res => {
if (res.ok) {
res.json().then(json => {
if (json.correct) {
this.updateMessage("Congratulations! Your guess is correct");
} else {
this.updateMessage("Oops! Your guess " + json.resultAttempt +
" is wrong, but keep playing!");
}
this.updateLastAttempts(this.state.user); // NEW!
this.refreshChallenge(); // NEW!
});
} else {
this.updateMessage("Error: server error or not available");
}
});
}
// ...
updateLastAttempts(userAlias: string) {
ApiClient.getAttempts(userAlias).then(res => {
if (res.ok) {
let attempts: Attempt[] = [];
res.json().then(data => {
data.forEach(item => {
attempts.push(item);
});
this.setState({
lastAttempts: attempts
});
})
}
})
}
render() {
return (
<div className="display-column">
<div>
<h3>Your new challenge is</h3>
<div className="challenge">
{this.state.a} x {this.state.b}
</div>
</div>
<form onSubmit={this.handleSubmitResult}>
{/* ... */}
</form>
<h4>{this.state.message}</h4>
{this.state.lastAttempts.length > 0 &&
<LastAttemptsComponent lastAttempts={this.state.lastAttempts}/>
}
</div>
);
}
}
export default ChallengeComponent
;
Listing 5-30The Updated ChallengeComponent
to Include the LastAttemptsComponent
在 React 中,如果我们只将属性传递给setState
方法,我们可以更新状态的一部分,然后该方法将合并内容。我们将新属性lastAttempts
添加到状态中,并用后端返回的数组内容更新它。假设数组的项是 JSON 对象,我们可以使用属性名在普通的 JavaScript 中访问它的属性。
我们还在这段代码中使用了一些新东西:带有&&
操作符的条件呈现。只有左边的条件为真,右边的内容才会被 React 渲染。参见 http://tpd.io/react-inline-if
了解不同的方法。添加到组件标签的lastAttempts
HTML 属性通过props
对象传递给子组件的代码。
注意,我们还使用了新的样式display-column
和challenge
。在 React 中,我们使用了className
属性,它将被映射到标准的 HTML class
。
使用新功能
在将新的数据层和所有其他层中的逻辑添加到 UI 之后,我们就可以开始使用整个应用了。无论是使用 IDE 还是使用两个不同的终端窗口,我们都用清单 5-31 中所示的命令运行后端和前端。
/multiplication $ mvnw spring-boot:run
...
/challenges-frontend $ npm start
...
Listing 5-31Commands to Start the Back End and the Front End, Respectively
然后,我们导航到http://localhost:3000
以访问在开发模式下运行的 React 前端。因为我们的新组件的呈现是有条件的,所以我们还没有看到新的表,所以让我们来玩几个挑战,看看表是如何被我们的尝试填充的。你应该会看到类似于图 5-8 的东西。
图 5-8
添加最后一次尝试功能后的应用
太好了,我们成功了!它不是最漂亮的前端,但很高兴看到我们的新功能启动并运行。Challenge
组件向后端执行请求,并呈现子组件,即最后一次尝试表。
如果您对数据的外观感兴趣,我们还可以导航到后端的 H2 控制台来访问表中的数据。记住控制台位于http://localhost:8080/h2-console
。您应该看到用户和尝试的两个表以及其中的一些内容。这个基本控制台允许您执行查询,甚至编辑数据。例如,您可以单击表的名称CHALLENGE_ATTEMPT
,在右边的面板中将会为您生成一个 SQL 查询。然后,你可以点击运行按钮来查询数据。见图 5-9 。
图 5-9
尝试 H2 控制台中的数据
总结和成就
在这一章中,我们看到了如何为数据的持久性建模,并且我们使用了对象关系映射来将我们的域对象转换成数据库记录。在旅程中,我们讲述了一些 Hibernate 和 JPA 的基础知识。请参见图 5-10 了解我们应用的当前状态。
图 5-10
第五章后的应用
基于挑战和尝试之间的多对一用例,我们学习了如何使用 JPA 注释来映射我们的 Java 类,以及简单关联是如何工作的。此外,我们使用 Spring Data 存储库获得了许多现成的功能。在所有提供的特性中,我们看到了查询方法是如何用一些方法的命名约定来编写简单查询的强大方法。
第二个用户故事完成了。我们浏览了服务和控制器层,并在那里添加了我们的新功能。我们还在 UI 中加入了一个新的组件来可视化我们的 web 应用的最后尝试。
我们完成了这本书的第一部分。到目前为止,我们已经详细了解了小型 web 应用是如何工作的。我们花时间去理解 Spring Boot 的核心概念和不同层的一些特定模块,比如 Spring Data 和 Spring Web。我们甚至构建了一个小型的 React 前端。
现在是时候开始微服务冒险了。
章节成就:
-
通过引入存储库类和数据库,您对三层、三层架构的工作原理有了全面的了解。
-
您了解了如何对数据建模,考虑了查询数据和适当的域隔离的需求。
-
您了解了 SQL 和 NoSQL 之间的主要区别,以及您可以用来做出未来选择的标准。
-
您了解了 JPA 及其与 Hibernate、Spring Data 和 Spring Boot 的集成。
-
您使用 JPA 注释和 Spring Data 存储库,使用查询方法和定义的查询为应用开发了一个真正的持久层。
-
您将新的尝试历史功能集成到前端,改进了我们的实际案例研究。
六、从微服务开始
这一章从我们的实践旅程的暂停开始。重要的是,我们要花时间分析我们到目前为止是如何构建我们的应用的,以及我们未来转向微服务的影响。
首先,我们将了解从单一代码库开始的优势。然后,我们将描述我们的新需求,包括基本游戏化技术的简短总结。之后,我们将了解在转向微服务时需要考虑哪些因素,以及在做出决定时应该考虑的利弊。
这一章的第二部分又都是实用的了。我们将使用我们在前面章节中学到的设计模式构建新的微服务,并将它连接到我们现有的应用,即第一个微服务。然后,我们将分析由于我们新的分布式系统我们将面临的一些新挑战。
小型整体式方法
上一章以一个包含所有必需功能的单一部署单元结束(如果我们愿意的话,甚至包括前端)。尽管已经确定了两个领域:挑战和用户,我们还是选择了这种单一应用策略。它们的域对象有关系,但是它们是松散耦合的。然而,我们从一开始就决定不将它们分成多个应用或微服务。
我们可以认为我们的乘法应用是一个小的整体。
为什么是小石块?
与微服务相比,从单个代码库开始简化了开发过程,因此我们减少了部署产品第一个版本所需的时间。此外,它使得在项目生命周期的开始阶段改变我们的架构和软件设计变得更加容易,这是在我们验证了我们的想法之后进行调整的关键。
如果你还没有在使用微服务的组织中工作过,你可能会低估微服务在软件项目中引入的技术复杂性。理想的情况是,当你读完这本书时,你会有一个更清晰的想法。接下来的章节重点关注当你转向微服务时应该采用的不同模式和技术要求,正如你将看到的,它们并不容易实现。
微服务从一开始就存在问题
作为我们采用的方法的替代方案,我们可以从一开始就选择微服务架构,并将用户和挑战分成两个独立的 Spring Boot 应用。
直接从拆分开始的一个原因是,我们的组织中可能有多个团队,他们可以并行工作而不会互相干扰。我们可以从一开始就利用将微服务映射到团队的优势。在我们的例子中,只有两个域,但是想象我们已经识别了十个不同的有界上下文。理论上,我们可以利用大组织的力量,这样我们可以更早地完成项目。
我们还可以通过拆分为微服务来实现我们的目标架构。这个计划通常被称为反康威。康威定律(参见 https://tpd.io/conway
)指出,系统设计倾向于类似于建造它的组织的结构。因此,我们尝试使用这种预测,并使我们的组织与我们想要实现的软件架构相似,这是有意义的。我们起草完整的软件架构,确定领域,并在团队之间进行划分。
能够并行工作并实现目标架构似乎是很大的优势。然而,过早拆分为微服务背后有两个问题。首先,当我们以敏捷的方式开发软件时,我们通常不会花几周的时间预先设计完整的系统。当试图识别松散耦合的域时,我们会犯错误。当我们意识到有缺陷时,就太晚了。基于错误的领域划分,很难对抗多个团队同时工作的惰性,特别是如果组织没有足够的灵活性来应对这些变化。在这种情况下,反向康威和早期分裂将对我们的目标不利。我们将创建一个反映我们最初架构的软件系统,但这可能不再是我们想要的。查看 https://tpd.io/reverse-conway
在那里我给出了更多关于这个话题的见解。
直接从微服务开始的第二个大问题是,这通常意味着我们不会将系统分成垂直的部分。我们从上一章开始,描述了为什么尽早交付软件是个好主意,这样我们就可以得到反馈。然后,我们解释了如何构建应用层的一小部分来交付价值,而不是一个接一个地设计和实现完整的层。如果我们从零开始使用多个微服务,我们将走向横向。微服务架构总是引入技术复杂性。它们更难设置、部署、编排和测试。这只会让我们花费更多的时间来开发一个最小可行的产品,这也可能会产生技术上的影响。在最坏的情况下,我们可能会基于错误的假设进行软件设计,因此在我们得到用户的反馈后,它们就会过时。
小独石是为小团队准备的
如果我们能在开始时保持团队的小规模,一个小的整体是一个好的计划。我们可以专注于领域定义和实验。当我们的产品想法得到验证,有了更清晰的软件架构,更多的人可以逐渐加入团队,我们可以考虑拆分代码库,整合新的团队。然后,根据我们的需求,我们可以转向微服务或选择另一种方法,如模块化系统(我们将在本章末尾详细介绍这两种方法)。
然而,有时候我们无法避免和一个大团队一起开始一个项目。在我们的组织里这是理所当然的。我们无法让合适的人相信这不是一个好主意。如果是这样的话,这个小的整体可能会很快变成一个大的整体,有一个意大利面条式的代码库,以后可能很难模块化。此外,很难一次只关注一个垂直领域,因为那样会有很多人无所事事。在这种组织约束下,小团队想法的小整体并不能很好地工作。我们需要做一些拆分。在这种情况下,我们必须付出额外的努力,不仅要定义有界的上下文,还要定义这些未来模块之间的通信接口。每当我们设计跨多个模块或微服务的功能时,我们都确保让相应的团队参与进来,以定义这些模块将产生和消费什么样的输入/输出。我们对这些合同定义得越好,团队就越独立。在敏捷环境中,这意味着特性的交付可能会比开始时预期的要慢,因为团队不仅需要定义这些合同,还需要定义许多公共的技术基础。
拥抱重构
另一个小的整体看起来有问题的情况是当我们的组织不接受代码变更的时候。如前所述,我们从一个小的整体开始,验证产品想法并获得反馈。然后,我们会在某个时间点看到开始将整体分割成微服务的需要。这种拆分带来了组织和技术上的优势,我们将在后面详述。技术人员和项目经理都应该在项目开始时进行对话,根据功能和技术需求来决定何时进行这种拆分。
然而,有时我们作为开发者认为这个时刻永远不会到来:如果我们从一个庞然大物开始,我们将永远被它束缚。我们担心在项目的路线图中永远不会有停顿来计划和完成所需的微服务重构。考虑到这一点,技术人员可能会尝试从一开始就推动组织和强制微服务。这是一个坏主意,因为它通常会让那些认为这样的技术复杂性会不必要地延迟项目的人感到沮丧。与其将销售微服务架构作为唯一的选择,不如改善与业务利益相关者和项目经理的沟通,以形成一个好的计划。
规划未来分裂的小块巨石
当你选择一个小块的时候,你可以遵循一些好的做法,然后不费吹灰之力就可以把它分割开来。
-
将代码划分到定义域上下文的根包中:这就是我们在应用中对挑战和用户根包所做的。然后,如果您开始处理许多类,您可以为分层创建子包(例如,控制器、存储库、域和服务),以确保层隔离。确保遵循类可见性的良好实践(例如,接口是公共的,但是它们的实现是包私有的)。使用这种结构的主要优点是,您可以保持跨域上下文的业务逻辑不可访问,并且如果您需要的话,以后您应该能够提取一个完整的根包作为微服务,只需较少的重构。
-
利用依赖注入的优势:让你的代码基于接口,让 Spring 完成注入实现的工作。使用这种模式进行重构要容易得多。例如,您可以更改一个实现,以便稍后调用不同的微服务,而不是使用本地类,而不会影响其余的逻辑。
-
一旦你确定了上下文(例如,挑战和用户),在你的应用中给它们起一个一致的名字:正确地命名概念在设计阶段的开始是至关重要的,以确保每个人都理解不同的领域边界。
-
在设计阶段,不要害怕移动类(对于小块来说更容易),直到边界变得清晰:之后,尊重边界。不要因为可以就走捷径,将业务逻辑与上下文混为一谈。永远记住,整块石头应该准备好进化。
-
找到共同的模式,并确定哪些可以在以后提取为共同的库,例如:将它们移动到不同的根包中。
-
使用同行评审来确保架构设计是合理的,并促进知识转移:最好在一个小组中完成,而不是遵循自上而下的方法,即所有设计都来自一个人。
-
清楚地向项目经理和/或业务代表传达 以计划稍后分割整块石头的时间:解释战略并创造文化。重构是必要的,这没有错。
至少在你第一次发布之前,尽量保持一个小的整体。不要害怕它;一个小的独石会给你带来一些好处。
-
在早期阶段更快的开发可以更好地获得产品的快速反馈。
-
您可以轻松地更改域边界。
-
人们习惯了相同的技术指南。这有助于实现未来的一致性。
-
常见的跨域功能可以作为库(或指南)来识别和共享。
-
团队将获得系统的完整视图,而不仅仅是部分视图。然后,这些人可以转移到其他团队,并带来有用的知识。
新需求和游戏化
想象一下,我们发布我们的应用,并将其连接到一个分析引擎。我们每天都有新用户,也有定期回来的老用户,这要感谢我们最近展示尝试历史的功能。然而,我们在我们的指标中看到,一周后,用户倾向于放弃用新的挑战训练他们大脑的惯例。
因此,我们根据我们的数据做出决定,试图改善这些数字。这个简单的过程被称为数据驱动决策 (DDDM),它对所有类型的项目都很重要。我们使用数据来选择我们的下一步行动,而不是基于直觉或仅仅是观察。如果你对 DDDM 感兴趣,网上有很多文章和课程。在 https://tpd.io/dddm
的文章是一个很好的开始。
在我们的例子中,我们计划引入一些游戏化来提高应用的参与度。请记住,我们将游戏化减少到点数、徽章和排行榜,以使本书专注于技术主题。如果你对这个领域感兴趣,那么《?? 现实被打破》和《?? 为了胜利》是很好的起点。在介绍游戏化以及它如何应用到我们的应用之前,让我们先介绍一下我们的新用户故事。
用户故事 3
作为应用的用户,我希望每天都有动力不断解决挑战,不要过一段时间就放弃。这样我就不断锻炼脑子,日积月累提高。
游戏化:点数、徽章和排行榜
游戏化是将游戏中使用的技术应用到另一个非游戏领域的设计过程。你这样做是因为你想从游戏中获得一些众所周知的好处,比如激发玩家的积极性,与你的进程、应用或者你正在游戏化的东西互动。
用其他东西制作游戏的一个基本想法是引入点:每次你完成一个动作,并且做得很好,你就会得到一些分数。如果你表现得不好,你甚至可以得到分数,但这应该是一个公平的机制:如果你做得更好,你会得到更多。赢得分数会让玩家觉得他们在进步,并给他们反馈。
排行榜让每个人都能看到分数,因此他们通过激发竞争的感觉来激励玩家。我们希望得到比我们上面的人更多的分数,排名更高。如果你和朋友一起玩,这就更有趣了。
最后但同样重要的是,徽章是获得地位的虚拟象征。我们喜欢徽章,因为它们不仅仅代表积分。此外,它们可以代表不同的事情:你可以与另一个玩家拥有相同的分数(例如,五个正确答案),但你可以用不同的方式赢得它们(例如,一分钟五个!).
一些不是游戏的软件应用很好地运用了这些元素。以 StackOverflow 为例。它充满了游戏元素,鼓励人们不断参与。
我们要做的是给用户提交的每个正确答案打分。为了简单起见,只有当他们发出一个正确的尝试,我们才会给分。我们每次给它 10 分。
页面上会显示得分最高的排行榜,这样玩家可以在排名中找到自己,并与其他人竞争。
我们还将创建一些基本徽章:铜牌(10 次正确尝试)、银牌(25 次正确尝试)和金牌(50 次正确尝试)。因为第一次正确的尝试值得一个好的反馈信息,我们还将引入第一次正确!徽章。此外,为了引入一个惊喜元素,我们将有一个徽章,只有当用户解决了一个数字 42 是其中一个因素的乘法时,他们才能获胜。
有了这些基础,我们相信我们会激励我们的用户回来继续玩,与他们的同龄人竞争。
转向微服务
在我们的新要求中,没有什么是我们用我们的小单体不能实现的。实际上,如果这是一个只有一个开发人员的项目,并且目标不是教育性的,那么最好的选择就是创建一个名为gamification
的新根包,并开始在同一个可部署单元中编写我们的类。
让我们把自己放在一个不同的场景中。想象一下,我们发现了可以帮助我们实现业务目标的其他新特性。其中一些改进可能如下:
-
根据用户的统计数据调整挑战的复杂性。
-
添加提示。
-
允许用户登录,而不是使用别名。
-
要求一些用户的个人信息,以收集更好的指标。
这些改进将影响现有的挑战和用户领域。此外,由于我们的第一次发布非常顺利,让我们想象一下我们也得到了一些资本投资。我们的团队可以成长。我们应用的开发不再需要按顺序进行。我们可以在游戏化领域工作,同时我们也在用额外的功能改进现有的领域。
我们还可以说,投资者带来了一些条件,现在我们希望扩大到每月 100,000 活跃用户。我们需要设计我们的架构来应对这种情况。我们可能很快就会意识到,我们计划构建的新游戏化组件不如主要功能(解决挑战)重要。如果游戏化功能在短时间内不可用,只要用户仍然可以解决挑战,我们就没事。
从我们的分析中,我们可以得出以下结论:
-
用户和挑战领域在我们的应用中至关重要。我们应该致力于保持它们的高可用性。水平可伸缩性非常适合这种情况:我们部署第一个应用的多个实例,如果其中一个实例出现故障,我们使用负载平衡并转移流量。此外,复制服务还会给我们更多的能力来处理许多并发用户。
-
新的游戏化领域在可用性方面有不同的要求。我们不需要以同样的速度扩大这一逻辑。我们可以接受它比其他域执行得慢,也可以允许它停止工作一段时间。
-
由于我们的团队在成长,我们可以从拥有可独立部署的单元中获益。如果我们保持游戏化模块松散耦合并独立发布,我们可以在我们的组织中与多个团队一起工作,并将干扰降至最低。
考虑到那些非功能性需求(例如,可伸缩性、可用性和可扩展性),转移到微服务似乎是个好主意。让我们更详细地介绍一下这些优势。
独立的工作流程
我们在前面的章节中已经看到了如何遵循 DDD 原则完成模块化架构。我们可以将得到的有界上下文分割成不同的代码库,这样多个团队可以更加独立地工作。
然而,如果这些模块是同一个可部署单元的一部分,我们在团队之间仍然有一些依赖。我们需要将所有这些模块集成在一起,确保它们能够相互协作,并将整个系统部署到我们的生产环境中。如果其他基础设施元素跨模块共享,如数据库,这些依赖性会变得更大。
微服务将模块化带到了一个新的高度,因为我们可以独立部署它们。团队不仅可以有不同的存储库,还可以有不同的工作流。
在我们的系统中,我们可以在不同的存储库中开发一些 Spring Boot 应用。它们都有自己的嵌入式 web 服务器,所以我们可以分别部署它们。这消除了在一个大的整体发布过程中产生的所有摩擦:测试、打包、相互依赖的数据库更新等等。
如果我们还考虑维护和支持方面,微服务有助于构建 DevOps 文化,因为每个应用可能拥有其相应的基础架构元素:web 服务器、数据库、指标、日志等。当我们使用像 Spring Boot 这样的框架时,系统可以被看作是一组相互交互的微型应用。如果其中一个部分出现问题,拥有该微服务的团队可以修复它。对于一块巨石,通常很难画出这些线。
水平可扩展性
当我们想要纵向扩展一个单一的应用时,我们可以选择使用更大的服务器/容器纵向扩展,或者使用更多的实例和负载平衡器横向扩展。水平可伸缩性通常是首选,因为多台小型机器比一台强大的机器便宜。此外,通过打开和关闭实例,我们可以更好地对不同的工作负载模式做出反应。**
**借助微服务,您可以选择更加灵活的可扩展性策略。在我们的实际例子中,我们认为乘法应用是我们系统的关键部分,必须处理大量的并发请求。因此,我们可以决定部署两个乘法微服务实例,但只部署一个游戏化微服务实例(尚未开发)。如果我们将所有的逻辑放在一个地方,我们也将复制游戏化逻辑,即使我们可能不需要那些资源。图 6-1 显示了一个这样的例子。
图 6-1
整体服务与微服务的水平可扩展性
细粒度的非功能需求
我们可以将水平可伸缩性的优势推广到其他非功能性需求。例如,我们说过,如果新的游戏化微服务在短时间内不可用,也没那么糟糕。如果我们正在运行一个单一的应用,整个系统可能会由于游戏化模块上的意外情况而崩溃。借助微服务,我们可以选择允许系统中的完整部分停机一段时间。我们将在本书的后面看到如何实现弹性模式来构建一个容错能力更强的后端。
例如,这同样适用于安全。我们可能需要对管理用户个人数据的微服务进行更多限制,但我们不需要处理游戏化领域的安全开销。作为独立的应用,微服务带来了更多的灵活性。
其他优势
我们选择微服务架构还有其他一些原因。然而,我把它们分开分组,因为我认为你不应该把它们当作做出决定的因素。
-
多种技术:例如,我们可能想用 Java 构建一些微服务,用 Golang 构建一些微服务。然而,这是有代价的,因为使用公共工件或框架或者跨团队共享知识并不容易。我们可能还想使用不同的数据库引擎,但这在模块化整体中也是可能的。
-
与组织结构的一致性:正如我们在本章前面所描述的,您可能会尝试使用康威法则,尝试按照您的组织结构来设计微服务,反之亦然。但是我们已经讨论了它的利弊,这也取决于您是直接在项目生命周期的开始还是后期进行拆分。
-
替换系统部件的能力:如果微服务给你的软件架构带来更多的隔离,逻辑上认为应该更容易替换它们,而不会对其他服务造成太大影响。但是,在现实生活中,当一些基本规则没有得到遵守时,微服务也可能变得相互强烈耦合。另一方面,你可以用一个好的模块化系统实现可替换性。因此,我也不认为这是变革的决定性驱动力。
不足之处
正如我们在前面章节中已经提到的,微服务架构也有很多缺点,所以它们不是解决整体架构可能出现的所有问题的灵丹妙药。我们讨论了这些缺点,同时分析了为什么从一个小的整体开始是个好主意。
-
您需要更多时间来交付第一个工作版本:由于微服务架构的复杂性,与单一服务相比,它需要更多时间来正确设置。
-
跨域移动功能变得更加困难:一旦您进行了第一次拆分,与单个代码库或部署单元相比,需要额外的工作来合并代码或跨微服务移动功能。
-
有一个新范式的隐含介绍:假设微服务架构使你的系统是分布式的,你将面临新的挑战,比如异步处理、分布式事务和最终的一致性。我们将在本章和下一章详细分析这些新的范例。
-
需要学习新的模式:当你有一个分布式系统时,你最好知道如何实现路由、服务发现、分布式跟踪和日志记录等。这些模式不容易实现和维护。我们将在第八章中讲述它们。
-
你可能需要采用新的工具:有一些框架和工具可以帮助你实现一个微服务架构:Spring Cloud、Docker、Message Brokers、Kubernetes 等。在单一架构中,您可能不需要它们,这意味着额外的维护、设置、潜在成本和学习所有这些新概念的时间。同样,本书将在接下来的章节中帮助你理解这些工具。
-
运行您的系统需要更多资源:在项目开始时,当系统流量还不高时,维护一个基于微服务的系统可能比一个整体系统要昂贵得多。拥有多个空闲服务比拥有一个空闲服务效率更低。此外,周围的工具和模式(我们将在本书中讨论)引入了额外的过载。只有当您从可伸缩性、容错和其他我们将在本书后面描述的特性中受益时,这种影响才开始变得积极。
** 可能会偏离标准 和通用实践:转移到微服务的原因之一可能是跨团队实现更多的独立性。然而,如果每个人都开始创建自己的解决方案来解决相同的问题,而不是重用公共模式,这也可能会产生负面影响。这可能会浪费时间,并使人们更难理解系统的其他部分。
* 架构要复杂得多*:用微服务架构解释你的系统如何工作可能会变得更加困难。这意味着新加入者需要额外的时间来理解整个系统是如何工作的。有人可能会说,只要人们了解他们工作的领域,就不需要这样做,但是了解所有部分如何相互作用总是更好的。*
** *你可能会被你不需要的新技术分散注意力*:一旦你登上一列周围都是花哨工具的微服务列车,一些人可能会被实施起来*酷*的新产品和模式所吸引。然而,你可能不需要它们,所以它们只是分散注意力。尽管这种情况在任何类型的架构中都可能发生,但在使用微服务时会发生得更频繁。**
**其中的一些要点你可能还不清楚。不要担心,在我们的旅程结束时,你将能够确切地理解它们的意思。本书对这些主题采取了务实和现实的方法,帮助您理解微服务架构的优势和劣势,以便您可以在未来做出最佳决策。
架构概述
在比较了我们现有的备选方案并分析了微服务的利弊之后,我们决定采取行动,为我们的游戏化需求创建一个新的 Spring Boot 应用。给定我们假设的场景,系统和组织的可伸缩性在这个决策中扮演着重要的角色。
现在我们可以把这两个应用称为乘法微服务和游戏化微服务。直到现在,称我们的第一个应用为微服务才有意义,因为它还不是微服务架构的一部分。
图 6-2 表示我们系统中的不同组件,以及在本章结束时它们将如何连接。
图 6-2
逻辑视图
让我们回顾一下这个设计中新增的内容。
-
会有新的微服务,游戏化。为了防止本地端口冲突,我们将它部署在端口 8081 上。
-
乘法微服务会将每次尝试发送到游戏化微服务,以处理新的分数、徽章和更新排行榜。
-
我们的 React UI 中将会有一个新组件,用于显示带有分数和徽章的排行榜。如图所示,我们的 UI 将调用这两个微服务。
关于此设计的一些注意事项:
-
我们还可以从一个嵌入式 web 服务器部署 UI。然而,最好将 UI 服务器视为不同的部署单元。同样的优势也适用于此:独立的工作流、灵活的可伸缩性等。
-
UI 需要调用两个服务,这看起来可能有点奇怪。你可以考虑将一个反向代理放在另外两个代理的前面,来完成路由并保持 API 客户端不知道后端的软件架构(参见
https://en.wikipedia.org/wiki/Reverse_proxy
)。这实际上是网关模式,我们将在本书后面详细讨论它。现在让我们保持简单。 -
如果你关注了这本书的总结,从乘法到游戏化的同步调用肯定会引起你的注意。这确实不是最好的设计,但是让我们保留演进方法的例子,并且首先了解为什么它不是最好的想法。
设计和实现新服务
在这一节中,我们将设计和实现游戏化微服务,采用与我们第一个 Spring Boot 服务 Multiplication 相似的方法。
接口
通常在使用模块化系统时,我们必须注意模块之间的契约。对于微服务,这一点更加重要,因为作为一个团队,我们希望尽快明确所有预期的依赖关系。
在我们的例子中,游戏化微服务需要公开一个接口来接受新的尝试。它需要这些数据来计算用户的统计数据。目前,这个接口将是一个 REST API。交换的 JSON 对象可以简单地包含与我们存储在乘法微服务上的尝试相同的字段:尝试的数据和用户的数据。在游戏化方面,我们将只使用我们需要的数据。
另一方面,UI 需要收集排行榜细节。我们还将在游戏化微服务中创建新的 REST 端点来访问这些数据。
信息
从这里开始,本章的小节将介绍新游戏化微服务的源代码。很高兴看一看它,因为我们将在不同的层中使用一些新的小功能。然而,我们在前面的章节中已经看到了主要的概念,所以你可以决定走捷径。那也有可能。如果你不想深入游戏化微服务的开发,你可以直接跳到“使用我们的服务”一节,使用本章的代码,可在 https://github.com/Book-Microservices-v2/chapter06
获得。
游戏化的 Spring Boot 框架
我们可以在 https://start.spring.io/
再次使用 Spring Initializr 为我们的新应用创建基本框架。这一次,我们预先知道我们将需要一些额外的依赖项,所以我们可以从这里直接添加它们:Lombok、Spring Web、Validation、Spring Data JPA 和 H2 数据库。如图 6-3 所示填写详细信息。
图 6-3
创建游戏化应用
下载 zip 文件,并将其解压缩为现有multiplication
文件夹旁边的gamification
文件夹。您可以将这个新项目作为一个单独的模块添加到同一个工作区中,以便将所有内容组织在同一个 IDE 实例中。
领域
让我们对我们的游戏化领域建模,尝试尊重上下文边界,并最小化与现有功能的耦合。
-
我们创建了一个记分卡对象,它保存用户在给定挑战尝试中获得的分数。
-
类似地,我们有一个徽章卡对象,代表用户在给定时间赢得的特定类型的徽章。它不需要绑定到记分卡,因为当你超过给定的分数阈值时,你可能会赢得徽章。
-
为了模拟排行榜,我们创建了一个排行榜位置。我们将显示这些域对象的有序列表,向用户显示排名。
在这个模型中,我们现有的领域对象和新的领域对象之间存在一些关系,如图 6-4 所示。
图 6-4
新的游戏化领域
如您所见,我们仍然保持这些域的松散耦合:
-
用户域保持完全隔离。它不保留对任何其他对象的任何引用。
-
挑战领域只需要了解用户。我们不需要将他们的对象与游戏化概念联系起来。
-
游戏化领域需要参考用户,挑战尝试。我们计划在发送一个尝试后获取这些数据,所以我们将在本地存储一些引用(用户和尝试的标识符)。
域对象可以很容易地映射到 Java 类。我们还将为这个服务使用 JPA/Hibernate,这样我们就可以添加 JPA 注释了。首先,清单 6-1 展示了ScoreCard
类,它有一个额外的构造函数来设置一些默认值。
源代码
您可以在 GitHub 的chapter06
资源库中找到本章的所有源代码。
https://github.com/Book-Microservices-v2/chapter06
见。
package microservices.book.gamification.game.domain;
import lombok.*;
import javax.persistence.*;
/**
* This class represents the Score linked to an attempt in the game,
* with an associated user and the timestamp in which the score
* is registered.
*/
@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ScoreCard {
// The default score assigned to this card, if not specified.
public static final int DEFAULT_SCORE = 10;
@Id
@GeneratedValue
private Long cardId;
private Long userId;
private Long attemptId;
@EqualsAndHashCode.Exclude
private long scoreTimestamp;
private int score;
public ScoreCard(final Long userId, final Long attemptId) {
this(null, userId, attemptId, System.currentTimeMillis(), DEFAULT_SCORE);
}
}
Listing 6-1The ScoreCard Domain/Data Class
我们这次用了一个新的 Lombok 注释,@EqualsAndHashCode.Exclude
。顾名思义,这将使 Lombok 不在生成的equals
和hashCode
方法中包含该字段。原因是,当我们比较对象时,这将使我们的测试更容易,事实上,我们不需要时间戳来确定两张卡是否相等。
不同的徽章在一个枚举中定义,BadgeType
。我们将添加一个description
字段,为每个字段取一个友好的名称。见清单 6-2 。
package microservices.book.gamification.game.domain;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* Enumeration with the different types of Badges that a user can win.
*/
@RequiredArgsConstructor
@Getter
public enum BadgeType {
// Badges depending on score
BRONZE("Bronze"),
SILVER("Silver"),
GOLD("Gold"),
// Other badges won for different conditions
FIRST_WON("First time"),
LUCKY_NUMBER("Lucky number");
private final String description;
}
Listing 6-2The BadgeType Enum
正如您在前面的代码中看到的,我们也从 enums 中的一些 Lombok 注释中受益。在这种情况下,我们使用它们为description
字段生成一个构造函数和 getter。
BadgeCard
类使用BadgeType
,它也是一个 JPA 实体。见清单 6-3 。
package microservices.book.gamification.game.domain;
import lombok.*;
import javax.persistence.*;
@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
public class BadgeCard {
@Id
@GeneratedValue
private Long badgeId;
private Long userId;
@EqualsAndHashCode.Exclude
private long badgeTimestamp;
private BadgeType badgeType;
public BadgeCard(final Long userId, final BadgeType badgeType) {
this(null, userId, System.currentTimeMillis(), badgeType);
}
}
Listing 6-3The BadgeCard Domain/Data Class
我们还添加了一个构造函数来设置一些默认值。注意,我们不需要向枚举类型添加任何特定的 JPA 注释。默认情况下,Hibernate 会将这些值映射到枚举的序数值(一个整数)。如果我们记住应该只在末尾追加新的枚举值,这就很好了,但是我们也可以配置映射器来使用字符串值。
为了模拟排行榜位置,我们创建了类LeaderBoardRow
。参见清单 6-4 。我们不需要在我们的数据库中保存这个对象,因为它将通过聚合来自我们用户的分数和徽章来动态创建。
package microservices.book.gamification.game.domain;
import lombok.*;
import java.util.List;
@Value
@AllArgsConstructor
public class LeaderBoardRow {
Long userId;
Long totalScore;
@With
List<String> badges;
public LeaderBoardRow(final Long userId, final Long totalScore) {
this.userId = userId;
this.totalScore = totalScore;
this.badges = List.of();
}
}
Listing 6-4The LeaderBoardRow Class
添加到badges
字段的@With
注释是由 Lombok 提供的,它为我们生成一个方法来克隆一个对象并向副本添加一个新的字段值(在本例中是withBadges
)。当我们使用不可变的类时,这是一个很好的实践,因为它们没有 setters。当我们创建业务逻辑来合并每个排行榜行的分数和徽章时,我们将使用这种方法。
服务
我们将把这个新的游戏化微服务中的业务逻辑一分为二。
-
游戏逻辑,负责处理尝试并生成结果分数和徽章
-
排行榜逻辑,汇总数据并根据分数建立排名
游戏逻辑将驻留在类GameServiceImpl
中,它实现了GameService
接口。规范很简单:基于一次尝试,它计算分数和徽章并存储它们。乘法微服务可以通过一个名为GameController
的控制器访问这个业务逻辑,这个控制器将公开一个 POST 端点来发送尝试。在持久层,我们的业务逻辑将需要一个ScoreRepository
来保存记分卡和一个BadgeRepository
来对工卡做同样的事情。图 6-5 显示了构建游戏逻辑功能所需的所有类的 UML 图。
图 6-5
UML:游戏逻辑
我们可以定义如清单 6-5 所示的GameService
接口。
package microservices.book.gamification.game;
import java.util.List;
import lombok.Value;
import microservices.book.gamification.challenge.ChallengeSolvedDTO;
import microservices.book.gamification.game.domain.BadgeCard;
import microservices.book.gamification.game.domain.BadgeType;
import microservices.book.gamification.game.domain.ScoreCard;
public interface GameService {
/**
* Process a new attempt from a given user.
*
* @param challenge the challenge
data with user details, factors, etc.
* @return a {@link GameResult} object containing the new score and badge cards obtained
*/
GameResult newAttemptForUser(ChallengeSolvedDTO challenge);
@Value
class GameResult {
int score;
List<BadgeType> badges;
}
}
Listing 6-5The GameService Interface
处理尝试后的输出是一个在接口中定义的GameResult
对象。它将从该尝试中获得的分数与用户可能获得的任何新徽章组合在一起。我们也可以考虑不返回任何内容,因为这将是显示结果的排行榜逻辑。然而,最好能从我们的方法得到一个响应,这样我们就可以测试它。
ChallengeSolvedDTO
类定义了倍增和游戏化微服务之间的契约,我们将在两个项目中创建它以保持它们的独立性。现在,让我们关注游戏化代码库。参见清单 6-6 。
package microservices.book.gamification.challenge;
import lombok.Value;
@Value
public class ChallengeSolvedDTO {
long attemptId;
boolean correct;
int factorA;
int factorB;
long userId;
String userAlias;
}
Listing 6-6The ChallengeSolvedDTO Class
既然我们已经定义了域类和服务层的框架,我们就可以使用 TDD 并为我们的业务逻辑创建一些测试用例,使用一个空的接口实现和 DTO 类。
锻炼
用两个测试用例创建GameServiceTest
:一个正确的尝试和一个错误的尝试。您将在本章的代码源中找到解决方案。
现在,只关注分数的计算,而不是徽章。我们将为该部分创建一个单独的接口和测试。
清单 6-7 中显示了一个有效的GameService
接口实现。仅当挑战被正确解决时,它才创建一个ScoreCard
对象,并存储它。徽章以单独的方式处理,以提高可读性。我们还需要一些存储库方法来保存分数和徽章,并检索以前创建的记录。现在,我们可以假设这些方法有效;我们将在“数据”部分详细解释它们。
package microservices.book.gamification.game;
import java.util.*;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import microservices.book.gamification.challenge.ChallengeSolvedDTO;
import microservices.book.gamification.game.badgeprocessors.BadgeProcessor;
import microservices.book.gamification.game.domain.BadgeCard;
import microservices.book.gamification.game.domain.BadgeType;
import microservices.book.gamification.game.domain.ScoreCard;
@Service
@Slf4j
@RequiredArgsConstructor
class GameServiceImpl implements GameService {
private final ScoreRepository scoreRepository;
private final BadgeRepository badgeRepository;
// Spring injects all the @Component beans in this list
private final List<BadgeProcessor> badgeProcessors;
@Override
public GameResult newAttemptForUser(final ChallengeSolvedDTO challenge) {
// We give points only if it's correct
if (challenge.isCorrect()) {
ScoreCard scoreCard = new ScoreCard(challenge.getUserId(),
challenge.getAttemptId());
scoreRepository.save(scoreCard);
log.info("User {} scored {} points for attempt id {}",
challenge.getUserAlias(), scoreCard.getScore(),
challenge.getAttemptId());
List<BadgeCard> badgeCards = processForBadges(challenge);
return new GameResult(scoreCard.getScore(),
badgeCards.stream().map(BadgeCard::getBadgeType)
.collect(Collectors.toList()));
} else {
log.info("Attempt id {} is not correct. " +
"User {} does not get score.",
challenge.getAttemptId(),
challenge.getUserAlias());
return new GameResult(0, List.of());
}
}
/**
* Checks the total score and the different score cards obtained
* to give new badges in case their conditions are met.
*/
private List<BadgeCard> processForBadges(
final ChallengeSolvedDTO solvedChallenge) {
Optional<Integer> optTotalScore = scoreRepository.
getTotalScoreForUser(solvedChallenge.getUserId());
if (optTotalScore.isEmpty()) return Collections.emptyList();
int totalScore = optTotalScore.get();
// Gets the total score and existing badges for that user
List<ScoreCard> scoreCardList = scoreRepository
.findByUserIdOrderByScoreTimestampDesc(solvedChallenge.getUserId());
Set<BadgeType> alreadyGotBadges = badgeRepository
.findByUserIdOrderByBadgeTimestampDesc(solvedChallenge.getUserId())
.stream()
.map(BadgeCard::getBadgeType)
.collect(Collectors.toSet());
// Calls the badge processors for badges that the user doesn't have yet
List<BadgeCard> newBadgeCards = badgeProcessors.stream()
.filter(bp -> !alreadyGotBadges.contains(bp.badgeType()))
.map(bp -> bp.processForOptionalBadge(totalScore,
scoreCardList, solvedChallenge)
).flatMap(Optional::stream) // returns an empty stream if empty
// maps the optionals if present to new BadgeCards
.map(badgeType ->
new BadgeCard(solvedChallenge.getUserId(), badgeType)
)
.collect(Collectors.toList());
badgeRepository.saveAll(newBadgeCards);
return newBadgeCards;
}
}
Listing 6-7Implementing the GameService Interface
in the GameServiceImpl Class
从这个实现中我们可以得出结论,BadgeProcessor
接口接受一些上下文数据和已解决的尝试,并决定是否分配给定类型的徽章。清单 6-8 显示了该接口的源代码。
package microservices.book.gamification.game.badgeprocessors;
import java.util.List;
import java.util.Optional;
import microservices.book.gamification.challenge.ChallengeSolvedDTO;
import microservices.book.gamification.game.domain.BadgeType;
import microservices.book.gamification.game.domain.ScoreCard;
public interface BadgeProcessor {
/**
* Processes some or all of the passed parameters and decides if the user
* is entitled to a badge.
*
* @return a BadgeType if the user is entitled to this badge, otherwise empty
*/
Optional<BadgeType> processForOptionalBadge(
int currentScore,
List<ScoreCard> scoreCardList,
ChallengeSolvedDTO solved);
/**
* @return the BadgeType object that this processor is handling. You can use
* it to filter processors according to your needs.
*/
BadgeType badgeType();
}
Listing 6-8The BadgeProcessor Interface
由于我们在GameServiceImpl
中使用带有一系列BadgeProcessor
对象的构造函数注入,Spring 将找到所有实现这个接口的 beans,并将它们传递给我们。这是一种灵活的方式来扩展我们的游戏,而不干扰其他现有的逻辑。我们只需要添加新的BadgeProcessor
实现并用@Component
注释它们,这样它们就可以加载到 Spring 上下文中了。
清单 6-9 和 6-10 是我们需要满足功能需求的五个徽章实现中的两个BronzeBadgeProcessor
和FirstWonBadgeProcessor
。
package microservices.book.gamification.game.badgeprocessors;
import microservices.book.gamification.challenge.ChallengeSolvedDTO;
import microservices.book.gamification.game.domain.BadgeType;
import microservices.book.gamification.game.domain.ScoreCard;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
@Component
class FirstWonBadgeProcessor implements BadgeProcessor {
@Override
public Optional<BadgeType> processForOptionalBadge(
int currentScore,
List<ScoreCard> scoreCardList,
ChallengeSolvedDTO solved) {
return scoreCardList.size() == 1 ?
Optional.of(BadgeType.FIRST_WON) : Optional.empty();
}
@Override
public BadgeType badgeType() {
return BadgeType.FIRST_WON;
}
}
Listing 6-10FirstWonBadgeProcessor Implementation
package microservices.book.gamification.game.badgeprocessors;
import microservices.book.gamification.challenge.ChallengeSolvedDTO;
import microservices.book.gamification.game.domain.BadgeType;
import microservices.book.gamification.game.domain.ScoreCard;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
@Component
class BronzeBadgeProcessor implements BadgeProcessor {
@Override
public Optional<BadgeType> processForOptionalBadge(
int currentScore,
List<ScoreCard> scoreCardList,
ChallengeSolvedDTO solved) {
return currentScore > 50 ?
Optional.of(BadgeType.BRONZE) :
Optional.empty();
}
@Override
public BadgeType badgeType() {
return BadgeType.BRONZE;
}
}
Listing 6-9BronzeBadgeProcessor Implementation
锻炼
实现其他三个 badge 处理器和所有单元测试,以验证它们是否按预期工作。如果需要帮助,可以查阅本章的源代码。
-
银色徽章。如果分数超过 150 就赢了。
-
金质徽章。如果分数超过 400 就赢了。
-
“幸运数字”徽章。如果尝试的任何因素为 42,则获胜。
一旦我们完成了业务逻辑的第一部分,我们就可以进入第二部分:排行榜功能。图 6-6 显示了我们将在本章中实现的构建排行榜的三个层的 UML 图。
图 6-6
排行榜,UML 图
接口LeaderBoardService
有一个方法来返回一个排序后的LeaderBoardRow
对象列表。见清单 6-11 。
package microservices.book.gamification.game;
import java.util.List;
import microservices.book.gamification.game.domain.LeaderBoardRow;
public interface LeaderBoardService {
/**
* @return the current leader board ranked from high to low score
*/
List<LeaderBoardRow> getCurrentLeaderBoard();
}
Listing 6-11The LeaderBoardService Interface
锻炼
创建LeaderBoardServiceImplTest
来验证该实现应该查询ScoreCardRepository
来查找具有最高分数的用户,并且应该查询BadgeCardRepository
来将分数与他们的徽章合并。和以前一样,存储库类还没有出现,但是您可以创建一些虚拟方法,并在测试中模拟它们。
如果我们能够聚合分数并对数据库中的结果行进行排序,那么排行榜服务的实现仍然很简单。我们将在下一节看到如何实现。现在,我们假设我们可以从ScoreRepository
(findFirst10
方法)获得分数排名。然后,我们查询数据库来检索包含在排名中的用户的徽章。见清单 6-12 。
package microservices.book.gamification.game;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import microservices.book.gamification.game.domain.LeaderBoardRow;
@Service
@RequiredArgsConstructor
class LeaderBoardServiceImpl implements LeaderBoardService {
private final ScoreRepository scoreRepository;
private final BadgeRepository badgeRepository;
@Override
public List<LeaderBoardRow> getCurrentLeaderBoard() {
// Get score only
List<LeaderBoardRow> scoreOnly = scoreRepository.findFirst10();
// Combine with badges
return scoreOnly.stream().map(row -> {
List<String> badges =
badgeRepository.findByUserIdOrderByBadgeTimestampDesc(
row.getUserId()).stream()
.map(b -> b.getBadgeType().getDescription())
.collect(Collectors.toList());
return row.withBadges(badges);
}).collect(Collectors.toList());
}
}
Listing 6-12The LeaderBoardService Implementation
注意,我们使用了方法withBadges
来复制一个具有新值的不可变对象。我们第一次生成排行榜时,所有行都有一个空白的徽章列表。当我们收集徽章时,我们可以用相应徽章列表的副本替换(使用 stream 的map
)每个对象。
数据
在业务逻辑层,我们对ScoreRepository
和BadgeRepository
方法做了一些假设。是时候构建这些存储库了。
请记住,我们只是通过扩展 Spring Data 的CrudRepository
来获得基本的 CRUD 功能,因此我们可以轻松地保存徽章和记分卡。对于其余的查询,我们将同时使用查询方法和 JPQL。
BadgeRepository
接口定义了一个查询方法来查找给定用户的徽章,按日期排序,最近的放在最上面。参见清单 6-13 。
package microservices.book.gamification.game;
import microservices.book.gamification.game.domain.BadgeCard;
import microservices.book.gamification.game.domain.BadgeType;
import org.springframework.data.repository.CrudRepository;
import java.util.List;
/**
* Handles data operations with BadgeCards
*/
public interface BadgeRepository extends CrudRepository<BadgeCard, Long> {
/**
* Retrieves all BadgeCards for a given user.
*
* @param userId the id of the user to look for BadgeCards
* @return the list of BadgeCards, ordered by most recent first.
*/
List<BadgeCard> findByUserIdOrderByBadgeTimestampDesc(Long userId);
}
Listing 6-13The BadgeRepository Interface with a Query Method
对于记分卡,我们需要其他查询类型。到目前为止,我们确定了三个需求。
-
计算一个用户的总分。
-
获得最高分的用户列表,作为
LeaderBoardRow
对象。 -
按用户 ID 读取所有记分卡记录。
清单 6-14 显示了ScoreRepository
的完整源代码。
package microservices.book.gamification.game;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;
import microservices.book.gamification.game.domain.LeaderBoardRow;
import microservices.book.gamification.game.domain.ScoreCard;
/**
* Handles CRUD operations with ScoreCards and other related score queries
*/
public interface ScoreRepository extends CrudRepository<ScoreCard, Long> {
/**
* Gets the total score for a given user: the sum of the scores of all
* their ScoreCards.
*
* @param userId the id of the user
* @return the total score for the user, empty if the user doesn't exist
*/
@Query("SELECT SUM(s.score) FROM ScoreCard s WHERE s.userId = :userId GROUP BY s.userId")
Optional<Integer> getTotalScoreForUser(@Param("userId") Long userId);
/**
* Retrieves a list of {@link LeaderBoardRow}s representing the Leader Board
* of users and their total score.
*
* @return the leader board, sorted by highest score first.
*/
@Query("SELECT NEW microservices.book.gamification.game.domain.LeaderBoardRow(s.userId, SUM(s.score)) " +
"FROM ScoreCard s " +
"GROUP BY s.userId ORDER BY SUM(s.score) DESC")
List<LeaderBoardRow> findFirst10();
/**
* Retrieves all the ScoreCards for a given user, identified by his user id.
*
* @param userId the id of the user
* @return a list containing all the ScoreCards for the given user,
* sorted by most recent.
*/
List<ScoreCard> findByUserIdOrderByScoreTimestampDesc(final Long userId);
}
Listing 6-14The ScoreRepository Interface, Using Query Methods and JPQL Queries
不幸的是,Spring Data JPA 的查询方法不支持聚合。好消息是 JPA 查询语言 JPQL 确实支持它们,所以我们可以使用标准语法使我们的代码尽可能与数据库无关。我们可以通过以下查询获得给定用户的总分:
SELECT SUM(s.score) FROM ScoreCard s WHERE s.userId = :userId GROUP BY s.userId
像在标准 SQL 中一样,GROUP BY
子句指示如何对值求和。我们可以用:param
符号定义参数。然后,我们用@Param
注释相应的方法参数。我们也可以使用我们在上一章中遵循的方法,使用像?1
这样的参数位置占位符。
第二个查询有点特殊。在 JPQL 中,我们可以使用 Java 类中可用的构造函数。我们在示例中所做的是基于总分的聚合,并且我们使用我们定义的双参数构造函数来构造LeaderBoardRow
对象(它设置了一个空白的徽章列表)。请记住,我们必须使用 JPQL 中类的完全限定名,如源代码所示。
控制器
在设计我们的游戏化领域时,我们与乘法服务达成了一个合同。它会将每个尝试发送到游戏化端的 REST 端点。是时候构建控制器了。见清单 6-15 。
package microservices.book.gamification.game;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import lombok.RequiredArgsConstructor;
import microservices.book.gamification.challenge.ChallengeSolvedDTO;
@RestController
@RequestMapping("/attempts")
@RequiredArgsConstructor
public class GameController {
private final GameService gameService;
@PostMapping
@ResponseStatus(HttpStatus.OK)
void postResult(@RequestBody ChallengeSolvedDTO dto) {
gameService.newAttemptForUser(dto);
}
}
Listing 6-15The GameController Class
在POST /attempts
上有一个 REST API,它接受一个 JSON 对象,该对象包含关于用户和挑战的数据。在这种情况下,我们不需要返回任何内容,所以我们利用ResponseStatus
注释来配置 Spring 返回一个200 OK
状态代码。实际上,这是当控制器的方法返回void
并且已经被正确处理时的默认行为。无论如何,为了更好的可读性,显式地添加它是有好处的。请记住,例如,如果出现抛出异常之类的错误,Spring Boot 的默认错误处理逻辑将拦截它,并返回一个带有不同状态代码的错误响应。
我们还可以向 DTO 类添加验证,以确保其他服务不会向游戏化微服务发送无效数据,但现在,让我们保持简单。无论如何,我们将在下一章修改这个 API。
锻炼
不要忘记为第一个控制器和下一个控制器添加测试。你可以在本章的源代码中找到这些测试。
我们的第二个控制器用于排行榜功能,并公开了一个返回序列化的LeaderBoardRow
对象的 JSON 数组的GET /leaders
方法。这些数据来自服务层,服务层使用徽章和分数存储库来合并用户的分数和徽章。因此,表示层保持简单。参见清单 6-16 中的代码。
package microservices.book.gamification.game;
import lombok.RequiredArgsConstructor;
import microservices.book.gamification.game.domain.LeaderBoardRow;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* This class implements a REST API for the Gamification LeaderBoard service.
*/
@RestController
@RequestMapping("/leaders")
@RequiredArgsConstructor
class LeaderBoardController {
private final LeaderBoardService leaderBoardService;
@GetMapping
public List<LeaderBoardRow> getLeaderBoard() {
return leaderBoardService.getCurrentLeaderBoard();
}
}
Listing 6-16The LeaderBoardController Class
配置
我们经历了应用的三个层次:业务逻辑、数据和表示。我们还缺少一些我们在乘法微服务中定义的 Spring Boot 配置。
首先,我们给游戏化微服务中的application.properties
文件添加一些值。参见清单 6-17 。
server.port=8081
# Gives us access to the H2 database web console
spring.h2.console.enabled=true
# Creates the database in a file
spring.datasource.url=jdbc:h2:file:~/gamification;DB_CLOSE_ON_EXIT=FALSE
# Creates or updates the schema if needed
spring.jpa.hibernate.ddl-auto=update
# For educational purposes we will show the SQL in console
spring.jpa.show-sql=true
Listing 6-17The application.properties File for the Gamification App
唯一新增的是server.port
属性。我们改变它,因为当我们在本地运行它们时,我们不能在我们的第二个应用中使用相同的缺省值8080
。我们还在datasource
URL 中设置了一个不同的 H2 文件名,为这个微服务创建一个单独的数据库,名为gamification
。
此外,我们还需要为这个微服务启用 CORS,因为用户界面需要能够访问排行榜 API。如果你不记得 CORS 做了什么,看看第四章中的“第一次运行我们的前端”一节。这个文件的内容与我们在乘法中添加的内容相同。参见清单 6-18 。
package microservices.book.gamification.configuration;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
@Override
public void addCorsMappings(final CorsRegistry registry) {
registry.addMapping("/**").allowedOrigins("http://localhost:3000");
}
}
Listing 6-18Adding CORS Configuration
to the Gamification App
鉴于我们还想使用 Hibernate 的 Jackson 模块,我们必须在 Maven 中添加这种依赖性。请记住,我们还需要将模块注入到上下文中,以便由自动配置来选择。参见清单 6-19 和 6-20 。
package microservices.book.gamification.configuration;
import com.fasterxml.jackson.databind.Module;
import com.fasterxml.jackson.datatype.hibernate5.Hibernate5Module;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JsonConfiguration {
@Bean
public Module hibernateModule() {
return new Hibernate5Module();
}
}
Listing 6-20Defining the Bean for JSON’s Hibernate Module to Be Used for Serialization
<dependencies>
<!-- ... -->
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-hibernate5</artifactId>
</dependency>
</dependencies>
Listing 6-19Adding the Jackson’s Hibernate
Module to Gamification’s pom.xml File
乘法微服务的变化
我们完成了游戏化微服务的第一个版本。现在,我们必须通过与新的微服务通信乘法,将两个微服务集成在一起。
之前,我们在服务器端创建了一些 REST APIs。这一次,我们必须构建一个 REST API 客户端。Spring Web 模块提供了一个用于这个目的的工具:RestTemplate
类。Spring Boot 在顶部提供了额外的一层:??。当我们使用 Spring Boot Web starter 时,这个构建器是默认注入的,我们可以使用它的方法通过多种配置选项流畅地创建RestTemplate
对象。如果需要访问服务器,我们可以添加特定的消息转换器、安全凭证、HTTP 拦截器等。在我们的例子中,我们可以使用默认设置,因为两个应用都使用 Spring Boot 的预定义配置。也就是说我们的RestTemplate
发送的序列化 JSON 对象在服务器端(游戏化微服务)可以无问题的反序列化。
为了保持我们的实现模块化,我们在一个单独的类中创建游戏化的 REST 客户端:GamificationServiceClient
。参见清单 6-21 。
package microservices.book.multiplication.serviceclients;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import lombok.extern.slf4j.Slf4j;
import microservices.book.multiplication.challenge.ChallengeAttempt;
import microservices.book.multiplication.challenge.ChallengeSolvedDTO;
@Slf4j
@Service
public class GamificationServiceClient {
private final RestTemplate restTemplate;
private final String gamificationHostUrl;
public GamificationServiceClient(final RestTemplateBuilder builder,
@Value("${service.gamification.host}") final String gamificationHostUrl) {
restTemplate = builder.build();
this.gamificationHostUrl = gamificationHostUrl;
}
public boolean sendAttempt(final ChallengeAttempt attempt) {
try {
ChallengeSolvedDTO dto = new ChallengeSolvedDTO(attempt.getId(),
attempt.isCorrect(), attempt.getFactorA(),
attempt.getFactorB(), attempt.getUser().getId(),
attempt.getUser().getAlias());
ResponseEntity<String> r = restTemplate.postForEntity(
gamificationHostUrl + "/attempts", dto,
String.class);
log.info("Gamification service response: {}", r.getStatusCode());
return r.getStatusCode().is2xxSuccessful();
} catch (Exception e) {
log.error("There was a problem sending the attempt.", e);
return false;
}
}
}
Listing 6-21The GamificationServiceClient Class, in the Multiplication App
这个新的 Spring@Service
可以注入到我们现有的 Spring 中。它使用构建器用默认值初始化RestTemplate
(只是调用build()
)。它还在构造函数中接受游戏化服务的主机 URL,我们希望提取它作为配置参数。
在 Spring Boot,我们可以在application.properties
文件中创建自己的配置选项,并用@Value
注释将它们的值注入到组件中。gamificationHostUrl
参数将被设置为这个新属性的值,我们必须将它添加到乘法的属性文件中。参见清单 6-22 。
# ... existing properties
# Gamification service URL
service.gamification.host=http://localhost:8081
Listing 6-22Adding the URL of the Gamification Microservice as a Property in Multiplication
服务客户端的其余实现很简单。它基于来自域对象ChallengeAttempt
的数据构建一个(新的)ChallengeSolvedDTO
。然后,它使用RestTemplate
中的postForEntity
方法将数据发送到游戏化中的/attempts
端点。我们不期望响应体,但是方法的签名需要它,所以我们可以将其设置为String
。
我们还将完整的逻辑包装在 try/catch 块中。原因是我们不希望一个试图到达游戏化微服务的错误最终打破了我们在乘法微服务中的主要业务逻辑。这一决定将在本章末尾进一步解释。
这个ChallengeSolvedDTO
类是我们在游戏化方面创建的一个类的副本。参见清单 6-23 。
package microservices.book.multiplication.challenge;
import lombok.Value;
@Value
public class ChallengeSolvedDTO {
long attemptId;
boolean correct;
int factorA;
int factorB;
long userId;
String userAlias;
}
Listing 6-23The ChallengeSolvedDTO Class Needs to Be Included in the Multiplication Microservice Too
现在我们可以在现有的ChallengeServiceImpl
类中注入这个服务,并在它被处理后使用它来发送尝试。参见清单 6-24 了解该级所需的修改。
@Slf4j
@RequiredArgsConstructor
@Service
public class ChallengeServiceImpl implements ChallengeService {
private final UserRepository userRepository;
private final ChallengeAttemptRepository attemptRepository;
private final GamificationServiceClient gameClient;
@Override
public ChallengeAttempt verifyAttempt(ChallengeAttemptDTO attemptDTO) {
// ... existing logic
// Stores the attempt
ChallengeAttempt storedAttempt = attemptRepository.save(checkedAttempt)
;
// Sends the attempt to gamification and prints the response
HttpStatus status = gameClient.sendAttempt(storedAttempt);
log.info("Gamification service response: {}", status);
return storedAttempt;
}
// ...
}
Listing 6-24Adding Logic to ChallengeServiceImpl
to Send an Attempt to the Gamification Microservice
我们的测试也应该更新,以检查每次尝试时调用是否发生。我们可以给ChallengeServiceTest
增加一个新的模拟职业。
@Mock private GamificationServiceClient gameClient;
然后,我们在测试用例中使用 Mockito 的verify
,以确保这个调用是使用存储在数据库中的相同数据来执行的。
verify(gameClient).sendAttempt(resultAttempt);
除了 REST API 客户端之外,我们还想为乘法微服务添加第二项更改:一个控制器,用于根据用户的标识符检索用户别名集合。我们需要这样做,因为我们在LeaderBoardController
类中实现的排行榜 API 根据用户 id 返回分数、徽章和位置。UI 需要一种方法将每个 ID 映射到一个用户别名,以更友好的方式呈现表格。参见清单 6-25 中的新UserController
级。
package microservices.book.multiplication.user;
import java.util.List;
import org.springframework.web.bind.annotation.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/users")
public class UserController {
private final UserRepository userRepository;
@GetMapping("/{idList}")
public List<User> getUsersByIdList(@PathVariable final List<Long> idList) {
return userRepository.findAllByIdIn(idList);
}
}
Listing 6-25The New UserController Class
这一次我们使用一个标识符列表作为路径变量,Spring 将它拆分并作为标准的List
传递给我们。实际上,这意味着 API 调用可以包含一个或多个用逗号分隔的数字,例如/users/1,2,3
。
如您所见,我们在控制器中注入了一个存储库,因此我们在这里没有遵循三层架构原则。原因是我们不需要这个特定用例的业务逻辑,所以,在这些情况下,最好保持我们的代码简单。如果我们在未来任何时候需要业务逻辑,我们可以从层之间的松散耦合中受益,并在这两者之间创建服务层。
存储库接口使用新的查询方法在users
表中执行选择,过滤那些标识符在传递列表中的。参见清单 6-26 中的源代码。
package microservices.book.multiplication.user;
import java.util.List;
import java.util.Optional;
import org.springframework.data.repository.CrudRepository;
public interface UserRepository extends CrudRepository<User, Long> {
Optional<User> findByAlias(final String alias);
List<User> findAllByIdIn(final List<Long> ids);
}
Listing 6-26The New Query Methods in the UserRepository Interface
锻炼
更新乘法微服务中的测试,以覆盖对 REST 客户端的新调用,并为UserController
创建一个新调用。你可以在本章的源代码中找到解决方案。
用户界面
后端逻辑已经准备好了,可以转到前端部分了。我们需要两个新的 JavaScript 类:
-
从游戏化微服务中检索排行榜数据的新 API 客户端
-
一个额外的 React 组件来呈现排行榜
我们还将向现有的 API 客户机添加一个新方法,根据用户的 id 检索用户列表。
清单 6-27 中的GameApiClient
类定义了一个不同的主机,并使用fetch
API 来检索 JSON 对象数组。为了清楚起见,我们也将现有的ApiClient
重命名为ChallengesApiClient
。然后,我们在这一个中包括一个新的方法来检索用户。参见清单 6-28 。
class ChallengesApiClient {
static SERVER_URL = 'http://localhost:8080';
// ...
static GET_USERS_BY_IDS = '/users';
// existing methods...
static getUsers(userIds: number[]): Promise<Response> {
return fetch(ChallengesApiClient.SERVER_URL +
ChallengesApiClient.GET_USERS_BY_IDS +
'/' + userIds.join(','));
}
}
export default ChallengesApiClient;
Listing 6-28Renaming the Former Apiclient Class and Including the New Call
class GameApiClient {
static SERVER_URL = 'http://localhost:8081';
static GET_LEADERBOARD = '/leaders';
static leaderBoard(): Promise<Response> {
return fetch(GameApiClient.SERVER_URL +
GameApiClient.GET_LEADERBOARD);
}
}
export default GameApiClient;
Listing 6-27The GamiApiClient Class
返回的承诺将用于新的LeaderBoardComponent
,它检索数据并更新其状态的leaderboard
属性。它的render()
方法应该将对象数组映射到一个 HTML 表中,每个位置一行。我们将使用 JavaScript 的定时事件(见 https://tpd.io/timing-events
)通过函数setInterval
每五秒刷新一次排行榜。
参见清单 6-29 中LeaderBoardComponent
的完整源代码。然后,我们将更深入地研究它的逻辑。
import * as React from 'react';
import GameApiClient from '../services/GameApiClient';
import ChallengesApiClient from '../services/ChallengesApiClient';
class LeaderBoardComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
leaderboard: [],
serverError: false
}
}
componentDidMount() {
this.refreshLeaderBoard();
// sets a timer to refresh the leaderboard every 5 seconds
setInterval(this.refreshLeaderBoard.bind(this), 5000);
}
getLeaderBoardData(): Promise {
return GameApiClient.leaderBoard().then(
lbRes => {
if (lbRes.ok) {
return lbRes.json();
} else {
return Promise.reject("Gamification: error response");
}
}
);
}
getUserAliasData(userIds: number[]): Promise {
return ChallengesApiClient.getUsers(userIds).then(
usRes => {
if(usRes.ok) {
return usRes.json();
} else {
return Promise.reject("Multiplication: error response");
}
}
)
}
updateLeaderBoard(lb) {
this.setState({
leaderboard: lb,
// reset the flag
serverError: false
});
}
refreshLeaderBoard() {
this.getLeaderBoardData().then(
lbData => {
let userIds = lbData.map(row => row.userId);
this.getUserAliasData(userIds).then(data => {
// build a map of id -> alias
let userMap = new Map();
data.forEach(idAlias => {
userMap.set(idAlias.id, idAlias.alias);
});
// add a property to existing lb data
lbData.forEach(row =>
row['alias'] = userMap.get(row.userId)
);
this.updateLeaderBoard(lbData);
}).catch(reason => {
console.log('Error mapping user ids', reason);
this.updateLeaderBoard(lbData);
});
}
).catch(reason => {
this.setState({ serverError: true });
console.log('Gamification server error', reason);
});
}
render() {
if (this.state.serverError) {
return (
<div>We're sorry, but we can't display game statistics at this
moment.</div>
);
}
return (
<div>
<h3>Leaderboard</h3>
<table>
<thead>
<tr>
<th>User</th>
<th>Score</th>
<th>Badges</th>
</tr>
</thead>
<tbody>
{this.state.leaderboard.map(row => <tr key={row.userId}>
<td>{row.alias ? row.alias : row.userId}</td>
<td>{row.totalScore}</td>
<td>{row.badges.map(
b => <span className="badge" key={b}>{b}</span>)}
</td>
</tr>)}
</tbody>
</table>
</div>
);
}
}
export default LeaderBoardComponent;
Listing 6-29The New LeaderBoardComponent in React
主逻辑包含在refreshLeaderBoard
功能中。首先,它试图从游戏化服务器获取排行榜行。如果不能(catch
子句),它会将serverError
标志设置为 true,所以我们将呈现一条消息而不是表格。如果数据被正常检索,逻辑执行第二次调用,这次是对乘法微服务的调用。如果我们得到适当的响应,我们会将数据中包含的用户标识符映射到其对应的别名,并为排行榜中的每个位置添加一个新字段alias
。如果第二次调用失败,我们仍然使用没有额外字段的原始数据。
render()
功能区分错误情况和标准情况。如果有错误,我们会显示一条消息而不是表格。通过这种方式,我们使我们的应用具有弹性,因为即使游戏化微服务失败,主要功能(解决挑战)仍在工作。排行榜数据与用户别名(或 ID,如果无法获取的话)、总分和徽章列表一起显示在各行中。
我们在渲染逻辑中使用了badge
CSS 类。让我们在App.css
样式表中创建这个定制样式。见清单 6-30 。
/* ... existing styles ... */
.badge {
font-size: x-small;
border: 2px solid dodgerblue;
border-radius: 4px;
padding: 0.2em;
margin: 0.1em;
}
Listing 6-30Adding the Badge Style to App.css
现在,我们应该在我们的根容器ChallengeComponent
类中包含排行榜组件。参见清单 6-31 中对源代码的修改。
import LeaderBoardComponent from './LeaderBoardComponent';
class ChallengeComponent extends React.Component {
// ...existing methods...
render() {
return (
<div className="display-column">
{/* we add this just before closing the main div */}
<LeaderBoardComponent />
</div>
);
}
}
export default ChallengeComponent
;
Listing 6-31Adding the LeaderBoardComponent Inside the ChallengeComponent
玩弄系统
我们实现了新的游戏化微服务,通过 REST API 客户端服务将乘法应用连接到它,并构建 UI 以获取排行榜并每五秒钟渲染一次。
是时候玩我们的完整系统了。使用 IDE 或命令行启动后端应用和 Node.js 服务器。如果您使用终端,打开三个单独的实例,并在每个实例中运行清单 6-32 中的一个命令,这样您就可以单独访问所有日志。
/multiplication $ mvnw spring-boot:run
...
/gamification $ mvnw spring-boot:run
...
/challenges-frontend $ npm start
...
Listing 6-32Starting the Apps from the Console
如果一切顺利,我们将看到 UI 在浏览器中运行。将会有一个空的排行榜(除非你在编码时已经尝试过一点)。如果我们发送一个正确的尝试,我们应该看到类似于图 6-7 的东西。
图 6-7
连接到两个微服务的 UI
当我们发送第一次正确尝试时,我们将获得 10 分和“第一次”徽章。游戏有效!您可以继续玩,看看您是否能获得任何金属徽章或幸运数字 1。由于每五秒钟自动渲染一次,您甚至可以在多个浏览器标签中玩游戏,排行榜将在每个标签中刷新。
现在让我们来看看日志。在乘法方面,当我们发送新的尝试时,我们会在日志中看到这一行:
INFO 36283 --- [nio-8080-exec-4] m.b.m.challenge.ChallengeServiceImpl : Gamification service response: 200 OK
游戏化应用会输出一行,说明尝试不正确,因此没有新的分数,或者如果你是正确的,会输出以下行:
INFO 36280 --- [nio-8081-exec-9] m.b.gamification.game.GameServiceImpl : User jane scored 10 points for attempt id 2
我们还会看到许多重复的日志行显示查询,因为我们将应用配置为显示所有 JPA 语句,并且 UI 会定期调用以检索排行榜和用户别名。
容错
在细化我们的需求时,我们确定游戏化特性并不重要,因此我们可以接受系统的这一部分出现一些停机时间。让我们来看看这个新的微服务会发生什么。如果你还在运行应用,停止游戏化应用。否则,只启动 UI 服务器和乘法。
我们将在屏幕上看到排行榜组件的回退消息,如图 6-8 所示。正如我们可以使用开发者工具中的网络选项卡来验证的那样,对游戏化服务的 HTTP 调用(在端口8081
上)失败了。
图 6-8
游戏化微服务宕机
此外,如果我们尝试发送一个尝试,它仍然会工作。该错误会导致一个在GamificationServiceClient
类中捕获的异常。
ERROR 36666 --- [nio-8080-exec-2] m.b.m.s.GamificationServiceClient : There was a problem sending the attempt.
即使有一半的后端宕机,核心功能仍能正常工作。但是请记住,在这种情况下,我们将丢失数据,因此用户将不会获得任何成功尝试的分数。
作为替代实现,我们可以使用重试逻辑。我们可以实现一个循环来不断尝试发布尝试,直到我们从游戏化微服务获得一个OK
响应,或者直到一定量的时间过去。但是,即使有我们可以用来实现这种模式的库,我们系统的复杂性也增加了。重试的时候乘法微服务也宕机了怎么办?我们是否应该跟踪数据库中尚未发送的尝试?在这种情况下,当游戏化应用在随机时刻复活时,我们应该按照发生的顺序发送尝试吗?如您所见,像我们的微服务架构这样的分布式系统带来了新的挑战。
未来的挑战
我们建立的系统正在运行,所以我们应该为此感到自豪。即使在游戏化微服务出现故障的情况下,应用也能保持响应。请参见图 6-9 了解我们系统的更新逻辑视图。
图 6-9
逻辑视图
我们的后端逻辑现在分布在这两个 Spring Boot 应用中。让我们回顾一下构建分布式系统的意义,重点关注我们的微服务架构和我们面临的新挑战。
紧密结合
当我们对我们的域建模时,我们认为它们是松散耦合的,因为我们在域对象之间只使用最少的引用。然而,我们在乘法微服务中引入了游戏化逻辑的意识。后者显式调用游戏化 API 来发送尝试,并负责传递消息。我们使用的命令式风格在 monolith 中还不错,但在微服务架构中可能会成为一个大问题,因为它在微服务之间引入了紧密耦合。
在我们当前的设计中,游戏化微服务由乘法微服务编排,乘法微服务主动触发动作。我们可以不使用这种编排模式,而是使用编排模式,让游戏化微服务决定何时触发其逻辑。我们将在下一章讲述事件驱动架构时,详细说明编排和编排之间的区别。
同步接口与最终一致性
正如我们前面所详述的,乘法微服务希望游戏化服务器在发送尝试时可用。如果不是,这个过程的这一部分仍然是不完整的。所有这些都发生在请求的生命周期中。当乘法服务器向 UI 发送响应时,分数和徽章要么被更新,要么出现了错误。我们构建了同步接口:请求保持阻塞状态,直到它们完全完成或者失败。
当您有许多微服务时,您将不可避免地有跨越它们的流程,就像在我们的例子中,即使它们有精心设计的上下文边界。为了描述这一点,让我们创建一个更复杂的场景,作为我们后端的假设性发展。作为第一个补充,我们想给用户发送一封电子邮件,当他们达到 1000 点。在不对领域边界进行判断的情况下,假设我们有一个专用的微服务,它需要在分配新分数后进行更新。我们还添加了一个微服务,收集数据进行报告,需要连接到乘法和游戏化。参见图 6-10 了解该假想系统的完整视图。
图 6-10
我们系统的假设进化
我们可以继续用 REST API 调用构建同步接口。然后,我们将有一个调用链,如图中的数字序列所示。来自浏览器的请求需要等待,直到所有的请求都完成。链中的服务越多,请求被阻塞的时间就越长。如果一个微服务慢了,整个链就慢了。系统的整体性能至少和链中最差的微服务一样差。
当我们在构建微服务时没有考虑容错时,同步依赖性甚至更差。在我们的示例中,从游戏化微服务到报告微服务的一个简单的失败更新操作可能会使整个流程崩溃。如果我们在同一个阻塞线程中实现重试机制,性能会下降得更多。如果我们让它们太容易失败,我们可能会以许多部分完成的操作而告终。
到目前为止有一个明确的结论:同步接口在微服务之间引入了强烈的依赖性。
作为一个优势,我们知道当用户得到响应时,报告已经在后端更新了。所以,是分数。我们甚至知道我们是否可以发送电子邮件,所以我们可以立即给出反馈。
在 monolith 中,我们不会面临这种挑战,因为所有这些模块都存在于同一个可部署单元中。如果我们只是调用其他方法,我们不会因为网络延迟或错误而遇到问题。此外,如果某个东西发生故障,它将是整个系统,因此我们不需要在设计它的同时考虑细粒度的容错。
所以,如果同步接口不好,重要的问题是:我们需要首先阻塞完整的请求吗?在返回响应之前,我们需要知道所有的事情都完成了吗?为了回答这个问题,让我们修改我们的假设案例,分离微服务之间的后续交互。见图 6-11 。
图 6-11
异步处理
这种新的设计在新的线程中发起一些请求,解除了主线程的阻塞。例如,我们可以使用 Java 期货。这将导致响应更早地传递给客户端,因此我们解决了前面描述的所有问题。但是,结果是,我们引入了最终的一致性。想象一下,在 API 客户端,有一个顺序线程等待发送尝试的响应。然后,这个客户端的进程将尝试收集分数和报告。在阻塞线程场景中,我们的 API 客户端(例如,UI)肯定知道,在从乘法得到响应后,游戏化中的分数与尝试一致。在这个新的异步环境中,我们无法保证这一点。如果我们的网络延迟很好,客户端可能会得到更新的分数。但可能需要一秒钟才能完成,或者我们的服务关闭了更长时间,只有在重试几次后才会更新。我们无法预测。
因此,在构建微服务架构时,我们面临的最大挑战之一就是实现最终的一致性。我们应该接受游戏化微服务的数据在给定时刻可能与倍增微服务的数据不一致。它只会最终保持一致。最后,通过使我们的系统健壮的适当设计,游戏化微服务将是最新的。同时,我们的 API 客户端不能假设不同 API 调用之间的一致性。这是关键:不仅仅是我们的后端系统;这也与我们的 API 客户有关。如果我们是唯一使用我们的 API 的人,那可能不是一个大问题:我们可以开发我们的 REST 客户端,并最终保持一致性。然而,如果我们提供 API 作为服务,我们也必须教育我们的客户。他们必须知道会发生什么。
因此,我们最初关于是否需要阻塞请求的问题可以被一个更重要的问题所取代:我们的系统最终能保持一致吗?当然,答案取决于我们的功能和技术需求。
例如,在某些情况下,系统的功能描述可能意味着很强的一致性,但是您可以对其进行调整,而不会产生很大的影响。作为一个实际案例,如果我们将电子邮件子流程分离为一个异步步骤,我们可以将提示用户的消息从“您应该已经收到一封带有说明的电子邮件”更改为“您将在几分钟后收到一封带有说明的电子邮件”。如果没有,请联系客户支持。”但是能够做出这样的改变总是依赖于组织接受最终一致性的需求和胃口。
微服务并不总是最好的解决方案(第一部分)
如果您的项目需求与跨域的最终一致性不兼容,那么模块化的单一应用可能更适合您。
另一方面,我们不需要处处完全异步。在某些情况下,微服务之间的同步调用是有意义的。这不是问题,也不是把我们的软件架构戏剧化的理由。我们只需要关注这些接口,因为有时这是域之间紧密耦合的征兆。在这种情况下,我们可以考虑将其合并到同一个微服务中。
回顾我们当前的系统状态,我们可以得出结论,它已经为最终的一致性做好了准备。由于我们不依赖响应来刷新排行榜,我们可以在微服务之间切换到异步调用,而不会产生任何影响。
可以想象,有一种比用重试模式调用 REST API 更好的方法来实现微服务之间的异步通信。我们将在下一章讨论它。
处理
在 monolith 中,我们可以使用相同的关系数据库来存储用户、尝试、分数和徽章。然后,我们可以从数据库事务中受益。我们将获得在前一章中简要介绍过的 ACID 保证:原子性、一致性、隔离性和持久性。在保存记分卡出错的情况下,我们可以恢复事务中所有以前的命令,这样尝试也不会被存储。该操作被称为回滚。因为我们可以避免部分更新,所以我们可以始终确保数据的完整性。
我们不能拥有跨微服务的 ACID 保证,因为我们无法在一个微服务架构中实现真正的事务。它们是独立部署的,所以它们生活在不同的进程中,它们的数据库也应该是解耦的。此外,为了避免相互依赖,我们还得出结论,我们应该接受最终的一致性。
原子性,或者确保所有相关数据被存储或者什么都不存储,很难在微服务之间实现。在我们的系统中,首先请求存储尝试,然后乘法微服务调用游戏化微服务做好自己的工作。即使我们保持请求同步,如果我们没有收到响应,我们也不知道分数和徽章是否被存储。那我们该怎么办?我们要回滚事务吗?不管游戏化中发生了什么,我们总是保存尝试吗(就像我们做的那样)?
事实上,在分布式系统中尝试实现事务回滚有一些富有想象力且复杂的方法。
-
两阶段提交(2PC) :在这种方法中,我们可以将乘法尝试发送到游戏化,但我们不会将数据存储在任何一端。然后,一旦我们得到指示数据准备好被存储的响应,我们发送第二个请求作为信号来存储游戏化的分数和徽章,并且我们存储乘法的尝试。通过这两个阶段(准备和提交),我们最大限度地减少了出错的时间。然而,我们没有排除这种可能性,因为第二阶段可能会失败。在我看来,这是一个可怕的想法,因为我们必须坚持同步接口,并且复杂性呈指数增长。
-
Sagas :这种设计模式涉及双向沟通。我们可以在两个微服务之间建立一个异步接口,如果游戏化方面出现问题,这个微服务应该能够联系乘法微服务,让它知道。在我们的例子中,乘法将删除刚刚保存的尝试。这样我们补偿一笔交易。就复杂性而言,这也带来了高昂的代价。
毫无疑问,最好的解决方案是尽量将必须使用数据库事务的功能流保持在同一个微服务中。如果我们不能分割一个事务,因为它在我们的系统中是关键的,那么看起来这个流程应该属于同一个域。对于其他流,我们可以尝试分割事务边界,并最终实现一致性。
我们还可以应用模式使我们的系统更加健壮,这样我们就可以最小化部分执行操作的风险。任何可以确保微服务间数据传递的设计模式都将有助于实现这一目标。这也将在下一章讨论。
我们的系统不使用分布式事务。它也不需要它们,因为我们不需要尝试和得分之间的即时一致性。但仍然有一个设计缺陷:乘法微服务忽略了游戏化的错误,因此我们可能会在没有相应分数和徽章的情况下成功解决尝试。我们将很快改进这一点,而不需要我们自己实现重试机制。
微服务并不总是最好的解决方案(第二部分)
如果您发现自己到处都在用 2PC 或 sagas 实现分布式事务,那么您应该花一些时间来反思您的需求和您的微服务边界。你可能想要合并其中的一些或者更好的分配功能。如果您不能用更简单的方法来解决这个问题,那么就考虑一个只有一个关系数据库的模块化整体应用。
API 暴露
我们在游戏化微服务中创建了一个 REST 端点,用于乘法微服务。但是用户界面也需要访问游戏化微服务,所以事实上,任何人都可以访问它。如果聪明的用户使用 HTTP 客户端(如 HTTPie),他们可以向游戏化微服务发送虚假数据。我们的处境会很糟糕,因为这会破坏我们的数据完整性。用户可以得分并获得徽章,而无需存储在乘法端的相应尝试。
解决这个问题有多种方法。我们可以考虑给我们的端点增加一个安全层,并确保内部 API 只对其他后端服务可用。一个更简单的选择是使用反向代理(带有网关模式)来确保我们只公开公共端点。我们将在第八章更详细地介绍这个选项。
总结和成就
在本章中,我们探讨了转向微服务架构的原因。我们开始详细介绍我们迄今为止所采用的方法,即小型整体架构,并分析了与过渡到微服务相比,继续我们的模块化整体应用之旅的利弊。
我们还研究了一个小型的 monolith 如何帮助你更好地定义你的领域,更快地完成我们产品的第一个版本,以获得用户的早期反馈。将代码组织成模块的良好实践列表应该有助于您在需要时进行拆分。但是我们也看到了有时候一个小的整体并不是最好的主意,特别是如果开发团队从一开始就很大的话。
决定迁移到微服务(或从微服务开始)需要对系统的功能和非功能特性进行深入分析,以确定在可伸缩性、容错性、事务性、最终一致性等方面的需求。该决策对于软件项目的成败至关重要。我希望本章中包含的所有考虑事项,以及实际案例的支持,能够帮助您仔细检查项目中存在的所有因素,并在您采取行动时做出合理的决定和良好的计划。
正如本书所料,我们决定采用微服务架构。在实践方面,我们浏览了新游戏化应用的各个层:服务、存储库、控制器和新的 React 组件。我们使用简单的命令式方法将乘法与游戏化联系起来,并且我们使用我们的微服务之间的接口来发现我们在微服务架构方面面临的一些新挑战。
到本章结束时,我们还得出结论,我们选择的用于通信两个微服务的同步接口是错误的决定。它引入了紧密耦合,并使我们的架构容易出错。这是下一章的完美基线,在下一章中,我们将介绍事件驱动架构的优势。
章节成就:
-
您看到了小型整体方法在启动新项目时如何帮助您。
-
您已经初步接触了微服务架构的优缺点(您将在接下来的章节中继续学习)。
-
您了解了分布式系统中同步和异步处理之间的差异,以及它们与最终一致性的关系。
-
您了解了为什么在微服务架构中采用这些新范式(异步流程、最终一致性)以避免紧密耦合和域污染非常重要。
-
您看到了为什么微服务不是所有情况下的最佳解决方案(例如,如果您需要事务性和即时数据一致性)。
-
您确定了我们在实际案例中面临的第一个挑战,并看到了当前的实施方式并不是实施微服务的正确方式。****
七、事件驱动架构
在前一章中,我们分析了微服务之间的接口如何在紧密耦合方面发挥关键作用。乘法微服务调用游戏化微服务,成为流程的编排者。如果有其他服务也需要为每次尝试检索数据,我们将需要从乘法应用向这些服务添加额外的调用,从而创建一个具有中央大脑的分布式整体。当我们检查一个假设的后端扩展时,我们详细讨论了这个问题。
在本章中,我们将基于发布-订阅模式,探讨设计这些接口的不同方式。该方法被称为事件驱动架构。发布者对事件进行分类和发送,而不知道系统中接收它们的部分,即订阅者,而不是将数据发送到特定的目的地。这些事件消费者也不需要知道发布者的逻辑。这种范式的改变使得我们的系统具有松耦合和可伸缩性,但也给我们的系统带来了新的挑战。
本章的目标是理解事件驱动架构的核心概念,它们的优势,以及使用它们的结果。像往常一样,我们将按照动手实践的方法将这些知识应用到我们的系统中。
核心概念
本节强调事件驱动架构的核心概念。
消息代理
事件驱动架构中的一个关键元素是消息代理。在这种类型的体系结构中,系统组件与代理通信,而不是直接相互连接。这就是我们如何保持它们之间的松散耦合。
消息代理通常包括路由功能。它们允许创建多个“通道”,因此我们可以根据我们的需求来分离消息。一个或多个发布者可以在这些通道的每一个中生成消息,并且这些消息可以被一个或多个订阅者(或者甚至没有订阅者)消费。在本章中,我们将更详细地了解什么是消息以及您可能想要使用的不同的消息传递拓扑。有关使用消息代理的一些典型场景的概念视图,请参见图 7-1 。
图 7-1
消息代理:高级视图
这些概念一点都不新鲜。已经活跃了一段时间的开发人员现在肯定能在企业服务总线(ESB)架构中发现类似的模式。总线模式促进了系统不同部分之间的通信,提供了数据转换和映射、消息排队和排序、路由等。
关于 ESB 体系结构和基于消息代理的体系结构之间的确切区别,仍然存在一些争议。一个被广泛接受的区别是,在 ESB 中,通道本身在系统中有更大的相关性。服务总线为通信设置协议标准,并将数据转换和路由到特定的目标。一些实现可以处理分布式事务。在某些情况下,他们甚至有一个复杂的 UI 来建模业务流程,并将这些规则转换成配置和代码。通常,ESB 架构倾向于将系统的大部分业务逻辑集中在总线内部,因此它们成为系统的编排层。见图 7-2 。
图 7-2
ESB 架构将业务逻辑集中在总线内部
将所有的业务逻辑转移到同一个组件中,并在系统中有一个中央编排器,这是容易失败的软件架构模式。遵循这一路线的系统会出现单点故障,并且随着时间的推移,它们的核心部分(在本例中是总线)变得更难维护和发展,因为整个组织都依赖于它。嵌入总线的逻辑往往会变得一塌糊涂。这是 ESB 体系结构在过去几年中名声如此之差的原因之一。
基于这些糟糕的经历,许多人现在倾向于放弃这种集中编排的、过于智能的消息传递通道,而使用消息代理实现一种更简单的方法,仅用于不同组件之间的通信。
在这一点上,您可能认为 ESB 是复杂的通道,而消息代理是简单的通道。但是,我之前提到过,有一点争议,所以并不是那么容易划那条线。一方面,您可以使用 ESB 平台,但保持业务逻辑适当隔离。另一方面,Kafka 等一些现代消息平台提供了一些工具,允许您在通道中嵌入一些逻辑。如果需要,您可以使用可能包含业务逻辑的函数来转换消息。您还可以像处理数据库一样查询通道中的数据,并且可以根据需要处理输出。例如,基于消息中包含的一些数据,您可以决定将它从特定的通道中取出,并将其移到另一个具有不同格式的通道中。因此,您可以在通常与不同架构模式(ESB/消息代理)相关联的工具之间进行切换,但仍然可以类似地使用它们。这个想法已经给了我们即将到来的章节的核心要点的早期介绍:首先你需要理解模式,然后你可以选择最适合你需求的工具。
我建议您尽可能避免在沟通渠道中包含业务逻辑。遵循领域驱动的设计方法,在分布式系统中保持逻辑的位置。这就是我们将在我们的系统中做的:我们将引入一个消息代理来保持我们的服务的松散耦合和可伸缩性,将业务流程保持在每个微服务内部。
事件和消息
在事件驱动的架构中,事件表明系统中发生了一些事情。事件由拥有发生这些事件的域的业务逻辑发布到消息通道(例如,消息代理)。架构中对给定事件类型感兴趣的其他组件订阅该通道以使用所有后续事件实例。如您所见,事件与发布-订阅模式相关,因此它们也链接到消息代理或总线。我们将使用消息代理实现一个事件驱动的架构,所以让我们把重点放在那个特定的案例上。
另一方面,消息是一个更通用的术语。许多人对消息和事件进行了区分,前者是直接寻址到系统组件的元素,后者是反映给定域中发生的事实的信息片段,没有特定的收件人。然而,当我们通过消息代理发送事件时,从技术角度来看,事件实际上是一条消息(因为没有事件代理这种东西)。为了简单起见,我们将在本书中使用术语消息来指代通过消息代理传递的一般信息,当我们指代遵循事件驱动设计的消息时,我们将使用事件。
请注意,没有什么可以阻止我们使用 REST APIs 对事件进行建模和发送(类似于我们在应用中所做的)。然而,这无助于减少紧耦合:生产者需要了解消费者,以便将事件指向他们。
当我们在消息代理中使用事件时,我们可以更好地隔离软件架构中的所有组件。发布者和订阅者不需要知道彼此。这非常适合微服务架构,因为我们希望尽可能保持微服务的独立性。通过这种策略,我们可以引入新的微服务来消费来自通道的事件,而无需修改发布这些事件的微服务或其他订阅者。
在事件中思考
请记住,消息代理和一些带后缀Event
的类的引入不会使我们的架构自动“事件驱动”。我们必须在事件中设计我们的软件思维,如果我们不习惯,这需要努力。让我们使用我们的应用对此进行更深入的分析。
在第一个场景中,假设我们已经创建了一个游戏化 API 来为给定的用户分配分数和徽章。见图 7-3 的上部。然后,乘法微服务将调用这个 API,updateScore
,不仅意识到这个微服务的存在,而且成为其部分业务逻辑的所有者(通过为解决的尝试分配分数)。这是人们从微服务架构开始,并来自命令式编程风格时,经常犯的错误。他们倾向于通过微服务之间的 API 调用来改变方法调用,实现远程过程调用(RPC)模式,有时甚至没有注意到这一点。为了改善微服务之间的耦合,我们可以引入一个消息代理。然后,我们将 REST API 调用替换为一条指向游戏化微服务的消息,即UpdateScore
消息。但是,我们会通过这种改变来改进系统吗?不多。该消息仍然有一个特定的目的地,因此它不能被任何新的微服务重用。此外,系统的两个部分保持紧密耦合,并且,作为副作用,我们用异步接口替换了同步接口,引入了额外的复杂性(正如我们在前一章中看到的,这一章也将进一步阐述)。
图 7-3
命令式方法:REST 与 message
第二个场景基于我们当前的实现。见图 7-4 。我们将一个ChallengeSolvedDTO
对象从乘法传递到游戏化,所以我们尊重我们的域边界。我们不在第一个服务中包含游戏化逻辑。然而,我们仍然需要直接解决游戏化,所以紧密耦合仍然存在。随着消息代理的引入,我们可以解决这个问题。乘法微服务可以向通用通道发送一个ChallengeSolvedDTO
,并继续执行其逻辑。我们的第二个微服务可以订阅这个频道并处理消息(在概念上已经是一个事件)来计算新的分数和徽章。添加到系统中的新微服务可以透明地订阅该频道,如果它们也对ChallengeSolvedDTO
消息感兴趣的话,例如,生成报告或向用户发送消息。
图 7-4
事件:休息与消息
我们的第一个场景实现了一个命令模式,其中乘法微服务指示对游戏化微服务做什么(也称为编排)。第二个场景通过发送关于已经发生的事情的通知以及上下文数据来实现事件模式。消费者将处理这些数据,这可能会触发他们的业务逻辑,结果可能会触发其他事件。这种方法有时被称为编排,与编排相对。当我们的软件架构基于这些事件驱动的设计时,我们称之为事件驱动架构。
如您所见,为了实现真正的事件驱动架构,我们必须重新思考可能以命令式表达的业务流程,并将它们定义为(重新)动作和事件。我们不仅应该使用 DDD 定义域,还应该将它们之间的交互建模为事件。如果您想了解更多有助于您开展这些设计会议的技术,请查看 https://tpd.io/event-storming
。
在继续之前,让我再次强调一个重要的观点:您不需要改变系统中的每一个通信接口来遵循事件驱动的风格。在某些事件不适合的情况下,您可能需要实现命令和请求/响应模式。不要试图强迫一个只适合作为命令的业务需求人为地表现为一个事件。在技术方面,不要害怕在更有意义的用例中使用 REST APIs,比如需要同步响应的命令。
微服务并不总是最佳解决方案(三)
当您构建一个主要使用命令式、目标接口的微服务架构时,所有这些系统组件之间有许多硬依赖。许多人将这种场景称为分布式单片,因为你仍然有单片应用的缺点:紧耦合,因此修改微服务的灵活性较低。
如果您需要一些时间在您的组织中建立一个事件驱动的思维模式,您可以建立一个模块化系统,并开始跨模块实现事件模式。然后,你从一次学习一件事情并保持可控的复杂性中受益。一旦实现了松散耦合,就可以将模块拆分成微服务。
异步消息传递
在前一章中,我们专门用了一节来分析将同步接口改为异步接口的影响。随着消息代理作为构建事件驱动架构的工具的引入,异步消息传递的采用是不言而喻的。发布者发送事件,不等待任何事件消费者的响应。这将使我们的架构保持松散耦合和可伸缩性。见图 7-5 。
图 7-5
使用消息代理的异步流程
然而,我们也可以使用消息代理并保持流程同步。让我们再次以我们的系统为例。我们计划用消息代理替换 REST API 接口。然而,我们可以创建两个通道来接收来自游戏化微服务的响应,而不是创建一个通道来发送我们的事件。参见图 7-6 。在我们的代码中,我们可以阻塞请求的线程,并在继续处理之前等待确认。
图 7-6
使用消息代理进行同步处理
这实际上是消息代理之上的请求/响应模式。这种组合在某些用例中很有用,但是在事件驱动的方法中不推荐使用。主要原因是我们再次获得了紧密耦合:乘法微服务需要了解订户及其数量,以确保它接收到所有响应。我们仍然有一些优势,如可伸缩性(我们将在后面详述),但是我们可以应用其他模式来提高同步接口的可伸缩性,如负载平衡器(我们将在下一章中看到)。因此,在我们的流程无论如何都需要同步的情况下,我们可以考虑使用一个更简单的同步接口,比如 REST API。参见表 7-1 总结如何结合模式和工具。请记住,这只是一个建议。正如我们已经分析过的,您可能有自己的偏好来使用不同的工具实现这些模式。
表 7-1
结合模式和工具
|模式
|
类型
|
履行
|
| --- | --- | --- |
| 请求/回应 | 同步的 | 应用接口 |
| 需要阻止的命令 | 同步的 | 应用接口 |
| 不需要阻止的命令 | 异步的 | 消息代理 |
| 事件 | 异步的 | 消息代理 |
值得注意的是,尽管端到端的通信可以是异步的,但是我们将从我们的应用中获得与消息代理的同步接口。这是一个重要的特征。当我们发布一个消息时,我们希望在继续做其他事情之前确保代理收到了它。这同样适用于订阅者,在订阅者那里,代理在消费消息之后要求确认,以将它们标记为已处理,并转移到下一个消息。这两个步骤对于保证我们数据的安全和系统的可靠性至关重要。我们将在本章的后面用我们的实际案例来解释这些概念。
反应系统
反应式这个词可以在多种上下文中使用,根据所指的技术层有不同的含义。反应式系统最广为接受的定义将其描述为一套应用于软件架构的设计原则,以使系统具有响应性(及时响应)、弹性(出现故障时保持响应)、弹性(适应不同工作负载下的响应)和消息驱动(确保松散耦合和边界隔离)。这些设计原则都列在了反应宣言里( https://tpd.io/rmanifesto
)。在构建我们的系统时,我们将遵循这些模式,因此我们可以宣称我们正在构建一个反应式系统。
另一方面,反应式编程指的是编程语言中围绕未来(或承诺)、反应流、反压力等模式使用的一套技术。有一些流行的库可以帮助你用 Java 实现这些模式,比如 Reactor 或 RxJava。使用反应式编程,您可以将您的逻辑分成一组更小的块,这些块可以异步运行,然后组合或转换结果。这带来了并发性的提高,因为当你并行处理任务时,你可以走得更快。
切换到反应式编程不会使您的架构反应式。它们在不同的层次上工作:反应式编程有助于实现组件内部和并发性方面的改进。反应式系统是组件之间更高层次的变化,有助于构建松散耦合、有弹性和可伸缩的系统。参见 https://tpd.io/react-sys-prg
了解两种技术之间差异的更多细节。
事件驱动的利与弊
在前一章中,我们讨论了迁移到微服务的利弊。我们获得了灵活性和可伸缩性,但我们面临着新的挑战,如最终的一致性、容错和部分更新。
采用事件驱动的消息代理模式有助于应对这些挑战。让我们用实际例子简单描述一下如何实现。
-
微服务之间的松散耦合:我们已经知道如何让乘法服务不知道游戏化服务。第一个向代理发送一个事件,游戏化订阅并响应该事件,为用户更新分数和徽章。
-
可伸缩性:正如我们将在本章中看到的,添加给定应用的新实例来横向扩展我们的系统是很容易的。此外,在我们的架构中引入新的微服务也很容易。他们可以订阅事件并独立工作,例如在我们分析的假设情况下:我们可以基于现有服务触发的事件生成报告或发送电子邮件。
-
容错和最终一致性:如果我们让消息代理足够可靠,我们可以用它来保证最终的一致性,即使系统组件出现故障。如果游戏化微服务宕机一段时间,它可以在稍后恢复时赶上事件,因为代理可以持久化消息。这给了我们一些灵活性。我们将在本章末尾看到这一点。
另一方面,采用基于事件的设计模式证实了我们对最终一致性的选择。我们避免创建阻塞的、强制性的流程。相反,我们使用简单通知其他组件的异步流程。正如我们所看到的,这需要一种不同的思维方式,所以我们(可能还有我们的 API 客户端)接受数据状态可能在所有微服务中不一致。
此外,随着消息代理的引入,我们正在向系统中添加一个新的组件。我们不能简单地说消息代理没有失败,所以我们必须让系统为新的潜在错误做好准备。
-
掉话:可能是
ChallengeSolvedEvent
永远达不到游戏化的情况。如果你正在构建一个不应该错过事件的系统,你应该配置代理来实现至少一次保证。该策略确保消息至少由代理传递一次,尽管它们可能是重复的。 -
重复消息:在某些情况下,消息代理可能不止一次地发送一些只发布一次的消息。在我们的系统中,如果我们得到事件两次,我们将错误地增加分数。因此,我们不得不考虑让事件消费幂等。在计算中,如果一个操作可以被调用多次而没有不同的结果,那么它就是幂等的。在我们的情况下,一个可能的解决方案是标记我们已经在游戏化端(例如,在数据库中)处理的事件,并忽略任何重复的事件。一些像 RabbitMQ 和 Kafka 这样的经纪人也提供一个很好的最多一次保证,如果我们正确配置他们,这有助于防止重复。
-
无序消息:即使代理可以尽最大努力避免无序消息,如果出现故障或者由于我们软件中的错误,这种情况仍然会发生。我们必须编写代码来应对这种情况。如果可能的话,尽量避免假设事件将按照它们发布的时间顺序被消费。
-
经纪人的停机时间:在最坏的情况下,经纪人也可能变得不可用。发布者和订阅者都应该尝试处理这种情况(例如,使用重试策略或缓存)。我们也可以将服务标记为不健康,并停止接受新的操作(我们将在下一章讨论)。这可能意味着整个系统停机,但可能是比接受部分更新和不一致数据更好的选择。
在前面的每一个要点中提出的这些示例解决方案是弹性模式。其中一些可以转化为编码任务,我们应该这样做,以使我们的系统即使在失败的情况下也能工作,例如等幂、重试或健康检查。正如我们已经提到的,良好的弹性在分布式系统(如微服务架构)中非常重要,因此在设计会议期间,了解这些模式以便为不愉快的流程带来解决方案总是很方便的。
事件驱动系统的另一个缺点是可追溯性变得更加困难。我们调用 REST API,这可能会触发事件;然后,可能会有组件对这些事件作出反应,随后发布一些其他事件,这个链继续下去。当我们只有几个分布式进程时,知道不同微服务中的什么事件导致什么动作可能不是问题。然而,当系统增长时,在事件驱动的微服务架构中,拥有这些事件和动作链的整体视图是一个很大的挑战。我们需要这个视图,因为我们希望能够调试出错的操作,并找出我们触发给定流程的原因。幸运的是,有工具可以实现分布式跟踪:一种我们链接事件和动作并将它们可视化为动作/反应链的方式。例如,Spring 家族有 Spring Cloud Sleuth,这是一个在日志中自动注入一些标识符(span IDs)并在我们发出/接收 HTTP 调用、通过 RabbitMQ 发布/消费消息等时传播这些标识符的工具。然后,如果我们使用集中式日志记录,我们可以使用标识符链接所有这些进程。我们将在下一章讨论这些策略。
消息模式
我们可以在消息传递平台中确定几种模式,我们可以根据我们想要实现的目标来应用这些模式。让我们从一个高层次的角度来详细描述它们,而不涉及任何特定平台的实现细节。您可以使用图 7-7 作为理解这些概念的指南,这些概念将在接下来的页面中详细介绍。
图 7-7
消息模式
发布-订阅
在这种模式中,不同的订阅者接收相同消息的副本。例如,我们的系统中可能有多个对ChallengeSolvedEvent
感兴趣的组件,比如游戏化微服务和假设的报告微服务。在这种情况下,重要的是配置这些订阅者,使它们接收相同的消息。每个订阅者将带着不同的目的处理事件,这样就不会导致重复的操作。
请注意,这种模式更适合事件,而不适合发送给特定服务的消息。
工作队列
这种模式也被称为竞争消费者模式。在这种情况下,我们希望在同一个应用的多个实例之间分割工作。
如图 7-7 所示,我们可以拥有同一个微服务的多个副本。然后,目的是平衡它们之间的负载。每个实例将使用不同的消息,处理它们,并可能将结果存储在数据库中。图中的数据库提醒我们,同一个组件的多个副本应该共享同一个数据层,所以拆分工作是安全的。
过滤
同样常见的是,有些订阅者对一个频道中发布的所有消息都感兴趣,而有些订阅者只对其中的某些消息感兴趣。这就是图 7-7 中第二个用户的情况。我们能想到的最简单的选择是,根据应用中包含的一些过滤逻辑,在它们被消费后立即丢弃它们。相反,一些消息代理还提供现成的过滤功能,因此组件可以使用给定的过滤器将自己注册为订阅者。
数据持久性
如果代理持久化消息,订阅者不需要一直运行来消耗所有数据。每个订阅者在代理中都有一个相关的标记,以了解他们消费的最后一条消息是什么。如果他们不能在给定的时间获得消息,数据流稍后可以从他们离开的地方继续。
即使在所有订户都检索到特定消息之后,您也可能希望将它存储在代理中一段时间。如果您希望新订户获得在他们存在之前发送的消息,这是很有用的。此外,如果您希望为订阅者“重置标记”,从而导致所有消息都被重新处理,则在给定时间段内保留所有消息会很有帮助。例如,这可以用于修复损坏的数据,但是当订阅者不是幂等的时,这也可能是一个有风险的操作。
在一个将所有操作建模为事件的系统中,您可以从事件持久性中获益更多。假设您清除了任何现有数据库中的所有数据。理论上,你可以从头开始重放所有事件,并重新创建相同的状态。因此,您根本不需要在数据库中保存给定实体的最后状态,因为您可以将它视为多个事件的“集合”。简而言之,这就是活动采购的核心理念。我们不会深入这项技术的细节,因为它增加了额外的复杂性,但是如果你想了解更多,请查看 https://tpd.io/eventsrc
。
消息代理协议、标准和工具
多年来,出现了一些与消息代理相关的消息传递协议和标准。这是一个包含一些流行示例的精简列表:
-
高级消息队列协议(AMQP) :这是一种有线级协议,将消息的数据格式定义为字节流。
-
消息队列遥测传输(MQTT) :这也是一种协议,由于它可以用很少的代码实现,并且可以在有限的带宽条件下工作,因此它已经成为物联网(IoT)设备的流行协议。
-
面向流文本的消息协议(STOMP) :这是一个基于文本的协议,类似于 HTTP,但面向消息中间件。
-
Java 消息服务(JMS) :和之前的不一样,JMS 是一个 API 标准。它关注于消息传递系统应该实现的行为。因此,我们可以找到使用不同底层协议连接到消息代理的不同 JMS 客户机实现。
下面是一些流行的软件工具,它们实现了其中的一些协议和标准,或者拥有自己的协议和标准:
-
RabbitMQ 是一个开源的消息代理实现,支持 AMQP、MQTT 和 STOMP 等协议。它还提供了 JMS API 客户端,并具有强大的路由配置。
-
Mosquitto 是一个实现 MQTT 协议的 Eclipse 消息代理,因此它是物联网系统的一个流行选择。
-
Kafka 最初是由 LinkedIn 设计的,它在 TCP 上使用自己的二进制协议。尽管 Kafka 核心特性没有提供与传统消息代理相同的功能(例如,路由),但当对消息中间件的需求很简单时,它是一个强大的消息平台。它通常用在处理大量数据流的应用中。
在任何需要在不同工具之间进行选择的情况下,您都应该熟悉它们的文档,并分析您的需求如何从其功能中受益:您计划处理的数据量、交付保证(最少一次,最多一次)、错误处理策略、分布式设置的可能性等。当用 Java 和 Spring Boot 构建事件驱动架构时,RabbitMQ 和 Kafka 都是流行的工具。此外,Spring 框架集成了这些工具,所以从编码的角度来看,使用它们很容易。
在本书中,我们使用 RabbitMQ 和 AMQP 协议。主要原因是这种组合提供了各种各样的配置可能性,因此您可以学习其中的大多数选项,并在以后选择的任何其他消息传递平台中重用这些知识。
AMQP 和 RabbitMQ
RabbitMQ 对 AMQP 协议版本 0.9.1 提供本地支持,并通过插件支持 AMQP 1.0 版本。我们将使用包含的 0.9.1 版本,因为它更简单,支持更好; https://tpd.io/amqp1
见。
我们现在来看看 AMQP 0.9.1 的主要概念。如果你想更详细地了解概念,我建议你参考 RabbitMQ 文档中的 https://tpd.io/amqp-c
。
总体描述
如本章前面所述,发布者是系统中向代理发布消息的组件或应用。消费者,也称为订户,接收并处理这些消息。
图 7-8
rabbitmq:概念
AMQP 还定义了交换、队列和绑定。参见图 7-8 以更好地理解这些概念。
-
交换机是发送消息的实体。它们按照交换类型和规则定义的逻辑路由到队列,称为绑定。如果在代理重新启动后交换仍然存在,则交换可以是持久的,如果不存在,则交换是暂时的。
-
队列是 AMQP 中存储要使用的消息的对象。队列可以有零个、一个或多个使用者。队列也可以是持久的或暂时的,但是请记住,持久的队列并不意味着它的所有消息都是持久的。为了使消息在代理重启后仍然存在,它们还必须作为持久性消息发布。
-
绑定是将发布到交易所的消息路由到特定队列的规则。因此,我们说一个队列被绑定到一个给定的交换。一些交换类型支持可选的绑定键,以确定发布到交换的哪些消息应该在给定的队列中结束。在这种意义上,您可以将绑定键视为过滤器。另一方面,发布者可以在发送消息时指定路由键,因此如果使用这些配置,可以根据绑定键对它们进行适当的过滤。路由关键字由点分隔的单词组成,如
attempt.correct
。绑定键具有类似的格式,但是它们可能包括模式匹配器,这取决于交换类型。
交换类型和路由
我们可以使用几种交换类型。图 7-9 显示了每种交换类型的示例,结合了由绑定关键字定义的不同路由策略,以及每条消息对应的路由关键字。
图 7-9
交换类型:示例
-
默认交易所由经纪人预先申报。所有创建的队列都通过与队列名称相同的绑定键绑定到此交换。从概念的角度来看,这意味着如果我们将目的地队列的名称用作路由关键字,就可以在考虑目的地队列的情况下发布消息。从技术上讲,这些消息仍然要经过交换。这种设置不常用,因为它破坏了整个路由目的。
-
直接交换通常用于单播路由。与默认交换的区别在于,您可以使用自己的绑定键,也可以使用相同的绑定键创建多个队列。然后,这些队列都将获得路由关键字与绑定关键字匹配的消息。从概念上讲,我们在发布消息时使用它,但我们不需要知道有多少个队列会收到消息。
-
扇出交换不使用路由键。它将所有消息路由到绑定到交换的所有队列,因此非常适合广播场景。
-
话题交换最灵活。我们可以使用一个模式,而不是使用给定的值将队列绑定到这个交换。这允许订阅者注册队列来使用一组经过过滤的消息。模式可以使用
#
匹配任何一组单词,或者使用*
只匹配一个单词。 -
报头交换使用消息报头作为路由关键字,以获得更好的灵活性,因为我们可以设置一个或多个报头的匹配条件,以及全匹配或任意匹配配置。因此,标准路由关键字被忽略。
正如我们所看到的,我们在本章前面描述的发布-订阅和过滤模式适用于这些场景。图中的直接交换示例可能看起来像工作队列模式,但它不是。这个例子是为了说明,在 AMQP 0.9.1 中,负载平衡发生在同一队列的使用者之间,而不是队列之间。为了实现工作队列模式,我们通常不止一次地订阅同一个队列。参见图 7-10 。
图 7-10
AMQP 的工作队列
消息确认和拒绝
AMQP 为消费者应用定义了两种不同的确认模式。理解它们很重要,因为在消费者发送确认后,消息会从队列中删除。
第一种选择是使用自动确认。使用这种策略,当消息被发送到应用时,它们被认为是已传递的。第二个选项叫做显式确认,它包括等待直到应用发送一个 ACK 信号。第二个选项更能保证所有消息都得到处理。消费者可以读取消息,运行一些业务逻辑,保存相关数据,甚至在向代理发送确认信号之前触发一个后续事件。在这种情况下,只有在消息被完全处理后,才会将其从队列中删除。如果消费者在发送信号之前死亡(或者有错误),代理将尝试将消息传递给另一个消费者,或者,如果没有,它将等到有可用的消费者。
消费者也可以拒绝消息。例如,假设一个消费者实例由于网络错误而无法访问数据库。在这种情况下,使用者可以拒绝该消息,指定是应该重新排队还是丢弃该消息。请注意,如果导致消息拒绝的错误持续一段时间,并且没有其他使用者可以成功处理它,我们可能会陷入 requeue-rejection 的无限循环中。
设置 RabbitMQ
现在我们已经学习了主要的 AMQP 概念,是时候下载并安装 RabbitMQ 代理了。
转到 RabbitMQ 下载页面( https://tpd.io/rabbit-dl
),选择适合您的操作系统的版本。在本书中,我们将使用 RabbitMQ 版本 3.8.3。RabbitMQ 是用 Erlang 编写的,所以如果您的系统的二进制安装中没有包含这个框架,您可能需要单独安装它。
一旦我们遵循下载页面上的所有说明,我们必须启动代理。您的操作系统的下载页面中也应该包括所需的步骤。例如,在 Windows 中,RabbitMQ 是作为一项服务安装的,您可以从“开始”菜单中启动/停止它。在 macOS 中,你必须从命令行运行命令。
RabbitMQ 包含了一些标准插件,但并不是所有的插件都是默认启用的。作为一个额外的步骤,我们将启用管理插件,这使我们能够访问一个 Web UI 和一个 API 来监控和管理代理。从代理安装文件夹中的sbin
文件夹,我们必须执行以下命令:
$ rabbitmq-plugins enable rabbitmq_management
然后,当我们重启代理时,我们应该能够导航到http://localhost:15672
并看到一个登录页面。因为我们在本地运行,所以我们可以使用默认的用户名和密码值:guest
/ guest
。RabbitMQ 支持定制对代理的访问控制;如果您想了解更多关于用户授权的细节,请勾选 https://tpd.io/rmq-ac
。图 7-11 显示了我们登录后的 RabbitMQ 管理插件 UI。
图 7-11
rabbitmq 管理插件 UI
从这个 UI 中,我们可以监控排队的消息、处理速率、关于不同注册节点的统计数据等。工具栏使我们能够访问许多其他功能,如队列和交换的监控和管理。我们甚至可以从这个界面创建或删除这些实体。我们将改为以编程方式创建交换和队列,但是这个工具对于理解我们的应用如何与 RabbitMQ 一起工作非常有用。
在主要部分“概述”中,我们可以看到节点列表。我们只是在本地安装了它,所以只有一个名为rabbit@localhost
的节点。我们可以通过网络添加更多的 RabbitMQ 代理实例,然后在不同的机器上建立一个分布式集群。这将为我们提供更好的可用性和容错能力,因为代理可以复制数据,所以如果节点宕机或出现网络分区,我们仍然可以运行。官方 RabbitMQ 文档中的集群指南( https://tpd.io/rmq-cluster
)描述了可能的配置选项。
Spring AMQP 和 Spring Boot
因为我们正在用 Spring Boot 构建我们的微服务,所以我们将使用 Spring 模块连接到 RabbitMQ 消息代理。在这种情况下,Spring AMQP 项目是我们正在寻找的。这个模块包含两个工件:spring-rabbit
,它是一组与 RabbitMQ 代理一起工作的实用程序,以及spring-amqp
,它包含所有的 AMQP 抽象,因此我们可以使我们的实现独立于供应商。目前,Spring 只提供了 AMQP 协议的 RabbitMQ 实现。
和其他模块一样,Spring Boot 为 AMQP 提供了额外的实用程序,比如自动配置:spring-boot-starter-amqp
。这个 starter 使用前面描述的两个构件,所以它隐含地假设我们将使用 RabbitMQ 代理(因为它是唯一可用的实现)。
我们将使用 Spring 来声明我们的交换、队列和绑定,并生成和消费消息。
解决方案设计
在描述本章中的概念时,我们已经快速预览了我们将要构建的内容。参见图 7-12 。该图仍然包括序列号,以表明乘法微服务对客户端的响应可能发生在游戏化微服务处理消息之前。这是一个异步的,最终一致的流程。
图 7-12
使用消息代理的异步流程
如图所示,我们将创建一个主题类型的尝试交换。在像我们这样的事件驱动架构中,这使我们能够灵活地发送带有特定路由关键字的事件,并允许消费者订阅所有事件或在其队列中设置自己的过滤器。
从概念上讲,乘法微服务拥有尝试交换。它将使用它来发布与来自用户的尝试相关的事件。原则上,它会发布正确和错误的条目,因为它不知道任何关于消费者逻辑的事情。另一方面,游戏化微服务用适合其要求的绑定键声明一个队列。在这种情况下,该路由关键字用作过滤器,只接收正确的尝试。如上图所示,我们可能有多个游戏化微服务实例在同一个队列中消费。在这种情况下,代理将在所有实例之间平衡负载。
假设有一个不同的微服务也对ChallengeSolvedEvent
感兴趣,这个微服务需要声明自己的队列来使用相同的消息。例如,我们可以引入 Reports 微服务,它创建一个“报告”队列,并使用绑定键attempt.*
(或#
)来消耗正确和错误的尝试。
如您所见,我们可以很好地结合发布-订阅和工作队列模式,以便多个微服务可以处理相同的消息,并且同一个微服务的多个实例可以在它们之间分担负载。此外,通过让发布者负责交换,订阅者负责队列,我们构建了一个事件驱动的微服务架构,通过引入消息代理实现了松散耦合。
让我们创建一个完成计划所需的任务列表:
-
将新的 starter 依赖项添加到我们的 Spring Boot 应用中。
-
移除向游戏化和相应控制器显式发送挑战的 REST API 客户端。
-
将
ChallengeSolvedDTO
重命名为ChallengeSolvedEvent
。 -
在乘法微服务上申报兑换。
-
改变乘法微服务的逻辑,发布一个事件,而不是调用 REST API。
-
在游戏化微服务上声明队列。
-
包括从队列中获取事件的消费者逻辑,并将其连接到现有的服务层,以处理分数和徽章的正确尝试。
-
相应地重构测试。
在本章的最后,我们还将尝试新的设置,体验 RabbitMQ 引入的负载平衡和容错优势。
添加 AMQP 启动器
为了在我们的 Spring Boot 应用中使用 AMQP 和 RabbitMQ 特性,让我们将相应的启动器添加到我们的pom.xml
文件中。清单 7-1 展示了这种新的依赖关系。
<dependencies>
<!-- ... existing dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
</dependencies>
Listing 7-1Adding the AMQP Starter to Both Spring Boot Projects
源代码
您可以在 GitHub 的chapter07
资源库中找到本章的所有源代码。
https://github.com/Book-Microservices-v2/chapter07
见。
这个启动器包括前面提到的spring-rabbit
和spring-amqp
库。传递依赖spring-boot-autoconfigure
,我们从前面的章节中知道,包括一些类,负责连接 RabbitMQ 和设置一些方便的缺省值。
在这种情况下,最有趣的一个类就是RabbitAutoConfiguration
(见 https://tpd.io/rabbitautocfg
)。它使用了在RabbitProperties
类中定义的一组属性(见 https://tpd.io/rabbitprops
),我们可以在application.properties
文件中覆盖这些属性。在那里,我们可以找到预定义的端口(15672
)、用户名(guest
)和密码(guest
)。自动配置类为RabbitTemplate
对象构建连接工厂和配置器,我们可以用它们向 RabbitMQ 发送(甚至接收)消息。我们将使用抽象接口AmqpTemplate
(参见 https://tpd.io/amqp-temp-doc
)。
自动配置包还包括一些默认配置,用于使用替代机制接收消息:RabbitListener
注释。我们将在编写 RabbitMQ 订户代码时更详细地介绍这一点。
来自乘法的事件发布
先来关注一下我们的发行商,乘法微服务。添加新的依赖项后,我们可以包含一些额外的配置。
-
交换名称:在配置中有它是很有用的,以防我们需要根据我们运行应用的环境来修改它,或者在应用之间共享它,我们将在下一章中看到。
-
日志设置:我们添加它们是为了在 app 与 RabbitMQ 交互时查看额外的日志。为此,我们将把
RabbitAdmin
类的日志级别改为DEBUG
。这个类与 RabbitMQ 代理交互,以声明交换、队列和绑定。
此外,我们可以删除指向游戏化服务的属性;我们不再需要直接调用它了。清单 7-2 显示了所有的属性变更。
# ... all properties above remain untouched
# For educational purposes we will show the SQL in console
# spring.jpa.show-sql=true <- it's time to remove this
# Gamification service URL <-- We remove this block
# service.gamification.host=http://localhost:8081
amqp.exchange.attempts=attempts.topic
# Shows declaration of exchanges, queues, bindings, etc.
logging.level.org.springframework.amqp.rabbit.core.RabbitAdmin = DEBUG
Listing 7-2Adjusting application.properties in the Multiplication Microservice
现在我们将交换声明添加到 AMQP 的一个单独的配置文件中。Spring 模块为此提供了一个方便的构建器ExchangeBuilder
。我们所做的是添加一个我们想要在代理中声明的主题类型的 bean。此外,我们将使用这个配置类将预定义的序列化格式切换到 JSON。在我们开始解释之前,请参见清单 7-3 。
package microservices.book.multiplication.configuration;
import org.springframework.amqp.core.ExchangeBuilder;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Configures RabbitMQ via AMQP
abstraction to use events in our application.
*/
@Configuration
public class AMQPConfiguration {
@Bean
public TopicExchange challengesTopicExchange(
@Value("${amqp.exchange.attempts}") final String exchangeName) {
return ExchangeBuilder.topicExchange(exchangeName).durable(true).build();
}
@Bean
public Jackson2JsonMessageConverter producerJackson2MessageConverter() {
return new Jackson2JsonMessageConverter();
}
}
Listing 7-3Adding AMQP Configuration Beans
我们使主题持久化,所以在 RabbitMQ 重启后,它将保留在代理中。此外,我们将其声明为主题交换,因为这是我们在事件驱动系统中设想的解决方案。由于已知的@Value
注释,该名称从配置中提取。
通过注入类型为Jackson2JsonMessageConverter
的 bean,我们用 JSON 对象序列化程序覆盖了默认的 Java 对象序列化程序。我们这样做是为了避免 Java 对象序列化的各种缺陷。
-
这不是一个我们可以在编程语言之间使用的标准。如果我们要引入一个不是用 Java 编写的消费者,我们必须寻找一个特定的库来执行跨语言反序列化。
-
它在消息头中使用硬编码的完全限定类型名。反序列化程序希望 Java bean 位于同一个包中,并且具有相同的名称和字段。这一点也不灵活,因为我们可能希望按照良好的域驱动设计实践,只反序列化一些属性,并保留我们自己的事件数据版本。
Jackson2JsonMessageConverter
使用了 AMQP Spring 预先配置的杰克逊的ObjectMapper
。然后,RabbitTemplate
实现将使用我们的 bean,这个类序列化对象并将对象作为 AMQP 消息发送给代理。在订户端,我们可以受益于 JSON 格式的流行,使用任何编程语言反序列化内容。我们也可以使用自己的对象表示,忽略消费者端不需要的属性,从而减少微服务之间的耦合。如果发布者在有效负载中包含新字段,订阅者不需要做任何更改。
JSON 不是 Spring AMQP 消息转换器支持的唯一标准。你也可以使用 XML 或者谷歌的协议缓冲区(又名 protobuf )。我们将在我们的系统中坚持使用 JSON,因为它是一个扩展的标准,而且它也有利于教育目的,因为有效载荷是可读的。在性能至关重要的实际系统中,您应该考虑高效的二进制格式(例如 protobuf)。数据序列化格式对比见 https://tpd.io/dataser
。
我们的下一步是移除GamificationServiceClient
类。然后,我们还想重命名现有的ChallengeSolvedDTO
,使其成为一个事件。我们不需要修改任何字段,只需要修改名称。见清单 7-4 。
package microservices.book.multiplication.challenge;
import lombok.Value;
@Value
public class ChallengeSolvedEvent {
long attemptId;
boolean correct;
int factorA;
int factorB;
long userId;
String userAlias;
}
Listing 7-4Renaming ChallengeSolvedDTO as ChallengeSolvedEvent
此处显示的命名约定是事件的良好实践。它们代表一个已经发生的事实,所以名字应该用过去式。此外,通过添加Event
后缀,很明显我们使用的是事件驱动的方法。
接下来,我们在服务层中创建一个新组件来发布事件。这相当于我们已经移除的 REST 客户端,但是这次我们与消息代理通信。我们用@Service
原型注释这个新类ChallengeEventPub
,并使用构造函数注入来连接一个AmqpTemplate
对象和交换的名称。完整的源代码见清单 7-5 。
package microservices.book.multiplication.challenge;
import org.springframework.amqp.core.AmqpTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class ChallengeEventPub {
private final AmqpTemplate amqpTemplate;
private final String challengesTopicExchange;
public ChallengeEventPub(final AmqpTemplate amqpTemplate,
@Value("${amqp.exchange.attempts}")
final String challengesTopicExchange) {
this.amqpTemplate = amqpTemplate;
this.challengesTopicExchange = challengesTopicExchange;
}
public void challengeSolved(final ChallengeAttempt challengeAttempt) {
ChallengeSolvedEvent event = buildEvent(challengeAttempt);
// Routing Key is 'attempt.correct' or 'attempt.wrong'
String routingKey = "attempt." + (event.isCorrect() ?
"correct" : "wrong");
amqpTemplate.convertAndSend(challengesTopicExchange,
routingKey,
event);
}
private ChallengeSolvedEvent buildEvent(final ChallengeAttempt attempt) {
return new ChallengeSolvedEvent(attempt.getId(),
attempt.isCorrect(), attempt.getFactorA(),
attempt.getFactorB(), attempt.getUser().getId(),
attempt.getUser().getAlias());
}
}
Listing 7-5The ChallengeSolvedEvent’s Publisher
仅仅是一个定义 AMQP 标准的接口。底层实现是RabbitTemplate
,它使用了我们之前配置的 JSON 转换器。我们计划在ChallengeServiceImpl
类中从主挑战服务逻辑调用challengeSolved
方法。该方法使用辅助方法buildEvent
将域对象转换为事件对象,并使用amqpTemplate
转换(到 JSON)和发送带有给定路由关键字的事件。这个是attempt.correct
还是attempt.wrong
取决于用户是否正确。
正如我们所看到的,由于提供了AmqpTemplate
/ RabbitTemplate
和默认配置,使用 Spring 和 Spring Boot 向代理发布消息很简单,默认配置抽象了到代理的连接、消息转换、交换声明等。
我们代码中唯一缺少的部分是将质询逻辑与这个发布者的类连接起来。我们只需要用新的ChallengeEventPub
替换我们在ChallengeServiceImpl
中使用的注入的GamificationServiceClient
服务,并使用新的方法调用。我们也可以重写注释来澄清我们不是在调用游戏化服务,而是为我们系统中任何可能感兴趣的组件发送一个事件。参见清单 7-6 。
@Slf4j
@RequiredArgsConstructor
@Service
public class ChallengeServiceImpl implements ChallengeService {
private final UserRepository userRepository;
private final ChallengeAttemptRepository attemptRepository;
private final ChallengeEventPub challengeEventPub; // replaced
@Override
public ChallengeAttempt verifyAttempt(ChallengeAttemptDTO attemptDTO) {
// ...
// Stores the attempt
ChallengeAttempt storedAttempt = attemptRepository.save(checkedAttempt);
// Publishes an event to notify potentially interested subscribers
challengeEventPub.challengeSolved(storedAttempt);
return storedAttempt;
}
// ...
}
Listing 7-6Modifying the ChallengeServiceImpl Class to Send the New Event
锻炼
修改现有的ChallengeServiceTest
来验证它使用新的服务,而不是移除的 REST 客户端。
与其把ChallengeEventPubTest
作为一个练习放在一边,不如把它写进书里,因为它提出了一个新的挑战。我们希望检查我们将要模拟的AmqpTemplate
是否是用期望的路由键和事件对象调用的,但是我们不能从方法外部访问这些数据。让方法返回一个带有这些值的对象看起来像是让代码过多地适应我们的测试。在这种情况下,我们可以使用 Mockito 的ArgumentCaptor
类(参见 https://tpd.io/argcap
)来捕获传递给 mock 的参数,这样我们可以在以后断言这些值。
此外,由于我们在访问测试的旅程中做了短暂的休息,我们将介绍 JUnit 的另一个特性:参数化测试(参见 https://tpd.io/param-tests
)。我们验证正确和错误尝试的测试用例是相似的,所以我们可以为这两种情况编写一个通用测试,并为断言使用一个参数。参见清单 7-7 中的ChallengeEventPubTest
源代码。
package microservices.book.multiplication.challenge;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.amqp.core.AmqpTemplate;
import microservices.book.multiplication.user.User;
import static org.assertj.core.api.BDDAssertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class ChallengeEventPubTest {
private ChallengeEventPub challengeEventPub;
@Mock
private AmqpTemplate amqpTemplate;
@BeforeEach
public void setUp() {
challengeEventPub = new ChallengeEventPub(amqpTemplate,
"test.topic");
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
public void sendsAttempt(boolean correct) {
// given
ChallengeAttempt attempt = createTestAttempt(correct);
// when
challengeEventPub.challengeSolved(attempt);
// then
var exchangeCaptor = ArgumentCaptor.forClass(String.class);
var routingKeyCaptor = ArgumentCaptor.forClass(String.class);
var eventCaptor = ArgumentCaptor.forClass(ChallengeSolvedEvent.class);
verify(amqpTemplate).convertAndSend(exchangeCaptor.capture(),
routingKeyCaptor.capture(), eventCaptor.capture());
then(exchangeCaptor.getValue()).isEqualTo("test.topic");
then(routingKeyCaptor.getValue()).isEqualTo("attempt." +
(correct ? "correct" : "wrong"));
then(eventCaptor.getValue()).isEqualTo(solvedEvent(correct));
}
private ChallengeAttempt createTestAttempt(boolean correct) {
return new ChallengeAttempt(1L, new User(10L, "john"), 30, 40,
correct ? 1200 : 1300, correct);
}
private ChallengeSolvedEvent solvedEvent(boolean correct) {
return new ChallengeSolvedEvent(1L, correct, 30, 40, 10L, "john");
}
}
Listing 7-7A Parameterized Test to Check
Behavior for Correct and Wrong Attempts
作为订阅者的游戏化
现在我们已经完成了发布者的代码,我们转到订阅者的代码:游戏化微服务。简而言之,我们需要替换现有的接受事件订阅者尝试的控制器。这意味着创建一个 AMQP 队列,并将其绑定到我们之前在乘法微服务中声明的主题交换。
首先,让我们填写配置设置。我们在这里还删除了显示查询的属性,并为 RabbitMQ 添加了额外的日志记录。然后,我们设置新队列和交换的名称,它与我们添加到先前服务中的值相匹配。参见清单 7-8 。
# ... all properties above remain untouched
amqp.exchange.attempts=attempts.topic
amqp.queue.gamification=gamification.queue
# Shows declaration of exchanges, queues, bindings, etc.
logging.level.org.springframework.amqp.rabbit.core.RabbitAdmin = DEBUG
Listing 7-8Defining Queue and Exchange Names in Gamification
为了声明新队列和绑定,我们还将使用一个名为AMQPConfiguration
的配置类。请记住,我们还应该在消费者一方申报交换。尽管订户在概念上并不拥有交换,但我们希望我们的微服务能够以任何给定的顺序启动。如果我们没有在游戏化微服务上声明交换,并且经纪人的实体还没有初始化,我们就被迫在之前启动乘法微服务。当我们声明队列时,交换必须在那里。自从我们使交换持久化以来,这只是第一次适用,但在代码中声明微服务需要的所有交换和队列是一个好的做法,因此它不依赖于任何其他交换和队列。注意,RabbitMQ 实体的声明是一个幂等运算;如果实体在那里,操作没有任何效果。
我们还需要在消费者端进行一些配置,以使用 JSON 反序列化消息,而不是默认的消息转换器提供的格式。让我们看看清单 7-9 中配置类的完整源代码,稍后我们将详细介绍一些部分。
package microservices.book.gamification.configuration;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
import org.springframework.amqp.core.*;
import org.springframework.amqp.rabbit.annotation.RabbitListenerConfigurer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.converter.MappingJackson2MessageConverter;
import org.springframework.messaging.handler.annotation.support.DefaultMessageHandlerMethodFactory;
import org.springframework.messaging.handler.annotation.support.MessageHandlerMethodFactory;
@Configuration
public class AMQPConfiguration {
@Bean
public TopicExchange challengesTopicExchange(
@Value("${amqp.exchange.attempts}") final String exchangeName) {
return ExchangeBuilder.topicExchange(exchangeName).durable(true).build();
}
@Bean
public Queue gamificationQueue(
@Value("${amqp.queue.gamification}") final String queueName) {
return QueueBuilder.durable(queueName).build();
}
@Bean
public Binding correctAttemptsBinding(final Queue gamificationQueue,
final TopicExchange attemptsExchange) {
return BindingBuilder.bind(gamificationQueue)
.to(attemptsExchange)
.with("attempt.correct");
}
@Bean
public MessageHandlerMethodFactory messageHandlerMethodFactory() {
DefaultMessageHandlerMethodFactory factory = new DefaultMessageHandlerMethodFactory();
final MappingJackson2MessageConverter jsonConverter =
new MappingJackson2MessageConverter();
jsonConverter.getObjectMapper().registerModule(
new ParameterNamesModule(JsonCreator.Mode.PROPERTIES));
factory.setMessageConverter(jsonConverter);
return factory;
}
@Bean
public RabbitListenerConfigurer rabbitListenerConfigurer(
final MessageHandlerMethodFactory messageHandlerMethodFactory) {
return (c) -> c.setMessageHandlerMethodFactory(messageHandlerMethodFactory);
}
}
Listing 7-9The AMQP Configuration for the Gamification Microservice
交换、队列和绑定的声明对于所提供的构建器来说很简单。我们声明一个持久队列,使其在代理重启后仍然存在,其名称来自配置值。Bean 对Binding
的声明方法使用了 Spring 注入的另外两个 Bean,并将它们与值attempt.correct
链接起来。如前所述,我们只对处理分数和徽章的正确尝试感兴趣。
接下来,我们设置了一个MessageHandlerMethodFactory
bean 来替换默认 bean。我们实际上使用默认工厂作为基线,但是用一个MappingJackson2MessageConverter
实例替换它的消息转换器,这个实例处理从 JSON 到 Java 类的消息反序列化。我们微调了它包含的ObjectMapper
,并添加了ParameterNamesModule
,以避免必须为我们的事件类使用空构造函数。注意,当通过 REST APIs 传递 d to 时(我们之前的实现),我们不需要这样做,因为 Spring Boot 在 web 层自动配置中配置这个模块。但是,它不会为 RabbitMQ 这样做,因为 JSON 不是默认选项;因此,我们需要明确地配置它。
这一次,我们不会使用AmqpTemplate
来接收消息,因为它是基于轮询的,这会不必要地消耗网络资源。相反,我们希望代理在有消息时通知订阅者,所以我们选择异步选项。AMQP 抽象不支持这一点,但是spring-rabbit
组件提供了两种异步使用消息的机制。最简单、最流行的是@RabbitListener
注释,我们将使用它从队列中获取事件。为了配置监听器使用 JSON 反序列化,我们必须用一个使用我们的自定义MessageHandlerMethodFactory
的实现来覆盖 bean RabbitListenerConfigurer
。
我们的下一个任务是将ChallengeSolvedDTO
重命名为ChallengeSolvedEvent
。参见清单 7-10 。从技术上讲,不需要使用相同的类名,因为 JSON 格式只指定了字段名和值。然而,这是一个很好的实践,因为这样你就可以很容易地在你的项目中找到相关的事件类。
package microservices.book.gamification.challenge;
import lombok.Value;
@Value
public class ChallengeSolvedEvent {
long attemptId;
boolean correct;
int factorA;
int factorB;
long userId;
String userAlias;
}
Listing 7-10Renaming ChallengeSolvedDTO as ChallengeSolvedEvent in Gamification
遵循域驱动的设计实践,我们可以调整该事件的反序列化字段。例如,对于游戏化的业务逻辑,我们不需要userAlias
,所以我们可以将它从消费的事件中移除。由于 Spring Boot 默认配置了ObjectMapper
来忽略未知属性,这种策略不需要配置任何其他东西就可以工作。不在微服务之间共享这个类的代码是一个好的实践,因为它还允许松散耦合、向后兼容和独立部署。想象一下,乘法微服务将发展并存储额外的数据,例如,更难挑战的第三个因素。这个额外的因素将被添加到发布事件的代码中。好消息是,通过对每个域使用不同的事件表示,并将映射器配置为忽略未知属性,游戏化微服务在这种变化后仍将工作,而无需更新其事件表示。
现在让我们编写事件消费者的代码。如前所述,我们将为此使用@RabbitListener
注释。我们可以将这个注释添加到方法中,使其在消息到达时充当消息的处理逻辑。在我们的例子中,我们只需要指定要订阅的队列名,因为我们已经在一个单独的配置文件中声明了所有的 RabbitMQ 实体。可以选择在这个注释中嵌入这些声明,但是代码看起来不再那么整洁了(如果你好奇的话,请看 https://tpd.io/rmq-listener
)。
检查清单 7-11 中消费者的来源,然后我们将涵盖最相关的部分。
package microservices.book.gamification.game;
import org.springframework.amqp.AmqpRejectAndDontRequeueException;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import microservices.book.gamification.challenge.ChallengeSolvedEvent;
@RequiredArgsConstructor
@Slf4j
@Service
public class GameEventHandler {
private final GameService gameService;
@RabbitListener(queues = "${amqp.queue.gamification}")
void handleMultiplicationSolved(final ChallengeSolvedEvent event) {
log.info("Challenge Solved Event received: {}", event.getAttemptId());
try {
gameService.newAttemptForUser(event);
} catch (final Exception e) {
log.error("Error when trying to process ChallengeSolvedEvent", e);
// Avoids the event to be re-queued and reprocessed.
throw new AmqpRejectAndDontRequeueException(e);
}
}
}
Listing 7-11The RabbitMQ Consumer’s Logic
如您所见,实现 RabbitMQ 订阅者所需的代码量很少。我们可以使用 configuration 属性将队列名称传递给RabbitListener
注释。Spring 处理这个方法并分析参数。假设我们指定了一个ChallengeSolvedEvent
类作为预期的输入,Spring 会自动配置一个反序列化器,将来自代理的消息转换成这个对象类型。它将使用 JSON,因为我们在AMQPConfiguration
类中覆盖了默认的RabbitListenerConfigurer
。
从消费者的代码中,你也可以推断出我们的错误处理策略是什么。默认情况下,Spring 基于RabbitListener
注释构建的逻辑将在方法无异常完成时向代理发送确认。在 Spring Rabbit 中,这被称为AUTO
确认模式。如果我们想在处理 ACK 信号之前就发送它,我们可以把它改成NONE
,或者如果我们想完全控制它,我们可以把它改成MANUAL
(然后我们必须注入一个额外的参数来发送这个信号)。我们可以在工厂级别(全局配置)或监听器级别(通过向RabbitListener
注释传递额外的参数)设置这个参数和其他配置值。这里我们的错误策略是使用默认值AUTO
,但是捕捉任何可能的异常,记录错误,然后重新抛出一个AmqpRejectAndDontRequeueException
。这是 Spring AMQP 提供的一个快捷方式,用于拒绝消息并告诉代理不要重新排队。这意味着,如果游戏化的消费者逻辑出现意外错误,我们将会丢失信息。这在我们的情况下是可以接受的。如果我们想要避免这种情况,我们也可以通过重新抛出一个含义相反的异常ImmediateRequeueAmqpException
来设置我们的代码重试几次,或者使用 Spring AMQP 中可用的一些工具,如错误处理程序或消息恢复器来处理这些失败的消息。更多详细信息,请参见 Spring AMQP 文档中的异常处理部分( https://tpd.io/spring-amqp-exc
)。
我们可以用RabbitListener
注释做很多事情。以下是一些包含的功能:
-
声明交换、队列和绑定。
-
用相同的方法从多个队列接收消息。
-
通过用
@Header
(对于单个值)或@Headers
(对于映射)注释额外的参数来处理消息头。 -
例如,注入一个
Channel
参数,这样我们就可以控制确认。 -
通过从侦听器返回值来实现请求-响应模式。
-
将注释移动到类级别,并对方法使用
@RabbitHandler
。这种方法允许我们配置多种方法来处理来自同一个队列的不同消息类型。
有关这些用例的详细信息,请查看 Spring AMQP 文档( https://tpd.io/samqp-docs
)。
锻炼
为新的GameEventHandler
类创建一个测试。检查服务是否被调用,以及其逻辑中的异常是否会导致再次引发预期的 AMQP 异常。该解决方案包含在为本章提供的源代码中。
现在我们有了订户的逻辑,我们可以安全地移除GameController
类。然后,我们重构现有的GameService
接口及其实现GameServiceImpl
,以接受重命名后的ChallengeSolvedEvent
。其余的逻辑可以保持不变。参见清单 7-12 中的结果newAttemptForUser
方法。
@Override
public GameResult newAttemptForUser(final ChallengeSolvedEvent challenge) {
// We give points only if it's correct
if (challenge.isCorrect()) {
ScoreCard scoreCard = new ScoreCard(challenge.getUserId(),
challenge.getAttemptId());
scoreRepository.save(scoreCard);
log.info("User {} scored {} points for attempt id {}",
challenge.getUserAlias(), scoreCard.getScore(),
challenge.getAttemptId());
List<BadgeCard> badgeCards = processForBadges(challenge);
return new GameResult(scoreCard.getScore(),
badgeCards.stream().map(BadgeCard::getBadgeType)
.collect(Collectors.toList()));
} else {
log.info("Attempt id {} is not correct. " +
"User {} does not get score.",
challenge.getAttemptId(),
challenge.getUserAlias());
return new GameResult(0, List.of());
}
}
Listing 7-12The Updated Newattemptforuser Method Using the Event Class
我们可以取消对正确尝试的检查,但这样我们会过于依赖乘法微服务上的正确路由。如果我们保留它,每个人都更容易阅读代码并知道它做什么,而不必弄清楚有一个基于路由键的过滤逻辑。我们可以从代理的路由中受益,但是请记住,我们不想在通道中嵌入太多的行为。
随着这些变化,我们完成了在微服务中切换到事件驱动架构所需的修改。请记住,更名为ChallengeSolvedEvent
的 DTO 会影响更多的职业。我们省略了它们,因为您的 IDE 应该会自动处理这些更改。让我们再次回顾一下我们对系统所做的更改列表:
-
我们将新的 AMQP 启动器依赖项添加到我们的 Spring Boot 应用中,以使用 AMQP 和 RabbitMQ。
-
我们移除了 REST API 客户端(乘法中)和控制器(游戏化中),因为我们使用 RabbitMQ 切换到了事件驱动的架构。
-
我们把
ChallengeSolvedDTO
改名为ChallengeSolvedEvent
。重命名导致了其他类和测试的修改,但是这些修改是不相关的。 -
我们在两个微服务中都声明了新的话题交换。
-
我们改变了乘法微服务的逻辑,发布一个事件,而不是调用 REST API。
-
我们在游戏化微服务上定义了新队列。
-
我们在游戏化微服务中实现了 RabbitMQ 消费者逻辑。
-
我们相应地重构了测试,以使它们适应新的界面。
请记住,您可以在本书的在线资源库中找到本章中显示的所有代码。
情景分析
让我们用新的事件驱动系统尝试几个不同的场景。我们的目标是证明通过消息代理引入新的架构设计带来了真正的优势。
概括地说,请参见图 7-13 了解我们系统的当前状态。
图 7-13
逻辑视图
本节中的所有场景都要求我们按照以下步骤启动整个系统:
-
请确保 RabbitMQ 服务正在运行。否则,启动它。
-
运行两个微服务应用:乘法和游戏化。
-
运行 React 的用户界面。
-
从浏览器进入 RabbitMQ 管理 UI 的
http://localhost:15672/
,使用guest
/guest
登录。
快乐之花
我们还没有看到我们的系统与新的消息代理一起工作。这是我们要尝试的第一件事。在此之前,我们先查看一下游戏化微服务的日志。您应该会看到一些新的日志行,如清单 7-13 所示。
INFO 11686 --- [main] o.s.a.r.c.CachingConnectionFactory: Attempting to connect to: [localhost:5672]
INFO 11686 --- [main] o.s.a.r.c.CachingConnectionFactory: Created new connection: rabbitConnectionFactory#7c7e73c5:0/SimpleConnection@2bf2d6eb [delegate=amqp://guest@127.0.0.1:5672/, localPort= 63651]
DEBUG 11686 --- [main] o.s.amqp.rabbit.core.RabbitAdmin : Initializing declarations
DEBUG 11686 --- [main] o.s.amqp.rabbit.core.RabbitAdmin : declaring Exchange 'attempts.topic'
DEBUG 11686 --- [main] o.s.amqp.rabbit.core.RabbitAdmin : declaring Queue 'gamification.queue'
DEBUG 11686 --- [main] o.s.amqp.rabbit.core.RabbitAdmin : Binding destination [gamification.queue (QUEUE)] to exchange [attempts.topic] with routing key [attempt.correct]
DEBUG 11686 --- [main] o.s.amqp.rabbit.core.RabbitAdmin : Declarations finished
Listing 7-13Spring Boot Application Logs Showing the Initialization for Rabbitmq
当我们使用 Spring AMQP 时,通常会记录前两行。它们表明与代理的连接是成功的。如前所述,我们不需要添加任何连接属性,如主机或凭证,因为我们使用的是默认值。因为我们将RabbitAdmin
类的日志级别更改为DEBUG
,所以剩下的日志行都在这里。这些是不言自明的,包括我们创建的交换、队列和绑定的值。
在乘法方面,还没有 RabbitMQ 日志。原因是只有当我们发布第一条消息时,连接和交换声明才会发生。这意味着话题交流是由游戏化微服务先声明的。还好我们准备了代码,不介意引导顺序。
我们现在可以看看 RabbitMQ UI,看看当前的状态。在“连接”选项卡上,我们将看到一个由游戏化微服务创建的连接。参见图 7-14 。
图 7-14
RabbitMQ UI:单一连接
如果我们切换到交换选项卡,我们将看到类型为topic
的attempts.topic
交换,并被声明为持久的 (D)。见图 7-15 。
图 7-15
rabbitmq ui:交换列表
现在,单击 exchange 名称会将我们带到详细页面,在这里我们甚至可以看到一个显示绑定队列和相应绑定键的基本图形。参见图 7-16 。
图 7-16
rabbitq ui:exchange 详细信息
Queues 选项卡显示最近创建的队列及其名称,也配置为 durable。参见图 7-17 。
图 7-17
rabbitmq ui:伫列清单
在我们看了所有东西是如何被初始化的之后,让我们导航到我们的 UI 并发送一些正确和不正确的尝试。如果您愿意,您可以稍微欺骗一下,至少运行这个命令十次,这将产生十次正确的尝试。
$ http POST :8080/attempts factorA=15 factorB=20 userAlias=test1 guess=300
在倍增日志中,我们现在应该看到它是如何连接到代理并声明交换的(由于它已经在那里了,所以没有任何效果)。
游戏化应用的日志应该反映事件的消耗和相应的更新分数。参见清单 7-14 。
INFO 11686 --- [ntContainer#0-1] m.b.gamification.game.GameEventHandler : Challenge Solved Event received: 50
INFO 11686 --- [ntContainer#0-1] m.b.gamification.game.GameServiceImpl : User test1 scored 10 points for attempt id 50
INFO 11686 --- [ntContainer#0-1] m.b.gamification.game.GameEventHandler : Challenge Solved Event received: 51
INFO 11686 --- [ntContainer#0-1] m.b.gamification.game.GameServiceImpl : User test1 scored 10 points for attempt id 51
INFO 11686 --- [ntContainer#0-1] m.b.gamification.game.GameEventHandler : Challenge Solved Event received: 52
INFO 11686 --- [ntContainer#0-1] m.b.gamification.game.GameServiceImpl : User test1 scored 10 points for attempt id 52
INFO 11686 --- [ntContainer#0-1] m.b.gamification.game.GameEventHandler : Challenge Solved Event received: 53
...
Listing 7-14Logs of the Gamification Microservice After Receiving New Events
RabbitMQ 管理器中的 Connections 选项卡此时显示来自两个应用的连接。参见图 7-18 。
图 7-18
RabbitMQ UI:两个连接
此外,如果我们转到 Queues 选项卡并单击队列名称,我们可以看到代理中发生的一些活动。您可以在 Overview 面板上将过滤器更改为最后 10 分钟,以确保捕获所有事件。参见图 7-19 。
图 7-19
rabbitmq ui:队列详细信息
太棒了。我们的系统与消息代理完美配合。正确的尝试被路由到游戏化应用所声明的队列。该微服务也订阅该队列,因此它获取发布到交换的事件,并处理它们以分配新的分数和徽章。之后,正如在新的变化之前已经发生的那样,我们的 UI 将在下一次请求游戏化的 REST 端点检索排行榜时获得更新的统计数据。参见图 7-20
图 7-20
UI:使用消息代理的应用
游戏化变得不可用
我们的系统之前的实现是有弹性的,因为我们在上一章之后离开了它,如果游戏化微服务不可用,它也不会失败。但是,在这种情况下,我们会错过事件期间发送的所有尝试。让我们看看引入消息代理后会发生什么。
首先,确保你停止了游戏化微服务。然后,我们可以使用 UI 或命令行技巧再发送十次尝试。让我们使用别名test-g-down
:
$ http POST :8080/attempts factorA=15 factorB=20 userAlias=test-g-down guess=300
RabbitMQ UI 中的 Queue detail 视图现在显示十个排队的消息。这个数字不会像以前一样归零。这是因为队列仍然在那里,但是没有消费者将这些消息分派到那里。见图 7-21
图 7-21
rabbitmq ui:消息已清除
我们还可以检查乘法微服务的日志,验证没有错误。它向代理发布消息,并向 API 客户机返回 OK 响应。我们实现了松散耦合。乘法 app 不需要知道消费者是否有空。现在整个过程是异步的,由事件驱动的。
当我们再次恢复游戏化服务时,我们将在日志中看到它是如何在启动后立即接收到来自代理的所有事件消息的。然后,这个服务只是触发它的逻辑,分数就相应更新了。这次我们没有遗漏任何数据。清单 7-15 显示了您再次启动游戏后游戏化日志的摘录。
INFO 24808 --- [ main] m.b.g.GamificationApplication : Started GamificationApplication in 3.446 seconds (JVM running for 3.989)
INFO 24808 --- [ntContainer#0-1] m.b.gamification.game.GameServiceImpl : User test-g-down scored 10 points for attempt id 61
INFO 24808 --- [ntContainer#0-1] m.b.gamification.game.GameEventHandler : Challenge Solved Event received: 62
INFO 24808 --- [ntContainer#0-1] m.b.gamification.game.GameServiceImpl : User test-g-down scored 10 points for attempt id 62
INFO 24808 --- [ntContainer#0-1] m.b.gamification.game.GameEventHandler : Challenge Solved Event received: 63
INFO 24808 --- [ntContainer#0-1] m.b.gamification.game.GameServiceImpl : User test-g-down scored 10 points for attempt id 63
INFO 24808 --- [ntContainer#0-1] m.b.gamification.game.GameEventHandler : Challenge Solved Event received: 64
...
Listing 7-15The Application Consumes the Pending Events After Becoming Available Again
您还可以验证排行榜如何再次显示用户test-g-down
的更新分数。我们使我们的系统不仅具有弹性,而且能够在出现故障后恢复。RabbitMQ 接口中的队列细节也显示了排队消息的零计数器,因为它们都已经被使用了。
可以想象,RabbitMQ 允许我们配置消息在丢弃之前可以在队列中保留多长时间(生存时间,TTL)。如果愿意,我们还可以为队列配置最大长度。默认情况下,没有设置这些参数,但是我们可以为每个消息(在发布时)或者在声明队列时启用它们。参见清单 7-16 中的示例,了解我们如何配置我们的队列,使其具有 6 小时的自定义 TTL 和 25000 条消息的最大长度。这只是一个例子,说明您熟悉代理的配置是多么重要,因此您可以根据自己的需要进行调整。
@Bean
public Queue gamificationQueue(
@Value("${amqp.queue.gamification}") final String queueName) {
return QueueBuilder.durable(queueName)
.ttl((int) Duration.ofHours(6).toMillis())
.maxLength(25000)
.build();
}
Listing 7-16An Example Queue Configuration Showing Some Extra Parameter Options
消息代理不可用
让我们更进一步,在队列中有消息等待交付时关闭代理。为了测试这个场景,我们应该遵循以下步骤:
-
停止游戏化微服务。
-
使用用户别名
test-rmq-down
发送几次正确的尝试,并在 RabbitMQ UI 中验证队列正在保存这些消息。 -
停止 RabbitMQ 代理。
-
发送一次额外的正确尝试。
-
启动游戏化微服务。
-
大约十秒钟后,再次启动 RabbitMQ 代理。
这个手动测试的结果是,只有我们在代理关闭时发送的尝试没有被处理。实际上,我们将从服务器得到一个 HTTP 错误响应,因为我们没有在发布者内部以及位于ChallengeServiceImpl
的主服务逻辑中捕捉到任何潜在的异常。我们可以添加一个 try/catch 子句,这样我们仍然能够做出响应。然后,策略将是无声地抑制错误。一个可能更好的方法是实现一个定制的 HTTP 错误处理程序来返回一个特定的错误响应,比如503 SERVICE UNAVAILABLE
,以表明当我们失去与代理的连接时,系统不可操作。如你所见,我们有多种选择。在一个真实的组织中,最好的方法是讨论这些备选方案,并选择一个更适合您的非功能性需求的方案,如可用性(我们希望挑战特性尽可能长时间可用)或数据完整性(我们希望每次发送尝试都有一个分数)。
我们测试的第二个观察结果是,当代理不可用时,两个微服务都不会崩溃。而是游戏化微服务每隔几秒就不断重试连接,乘法微服务在新的尝试请求到来时也是如此。当我们再次启动代理时,两个微服务都恢复了连接。这是 Spring AMQP 项目中包含的一个很好的特性,可以在连接不可用时尝试恢复连接。
如果您执行这些步骤,您还会看到即使在代理重新启动后,还有未完成的消息要发送时,使用者如何获得消息。游戏化微服务重新连接到 RabbitMQ,这个服务发送排队的事件。这不仅是因为我们声明了持久交换和队列,还因为 Spring 实现在发布所有消息时使用了持久交付模式。如果我们使用RabbitTemplate
(而不是AmqpTemplate
)来发布消息,这是我们也可以自己设置的消息属性之一。请参见清单 7-17 中的示例,了解我们如何更改交付模式,以使我们的消息在代理重启后无法存活。
MessageProperties properties = MessagePropertiesBuilder.newInstance()
.setDeliveryMode(MessageDeliveryMode.NON_PERSISTENT)
.build();
rabbitTemplate.getMessageConverter().toMessage(challengeAttempt, properties);
rabbitTemplate.convertAndSend(challengesTopicExchange,
routingKey,
event);
Listing 7-17Example of How to Change the Delivery Mode to Nonpersistent
这个例子也说明了为什么知道我们使用的工具的配置选项是重要的。将所有消息作为持久消息发送给我们带来了一个很好的优势,但是它在性能上有额外的代价。如果我们将 RabbitMQ 实例的集群配置为适当分布,那么整个集群宕机的可能性将会很小,因此我们可能更愿意接受潜在的消息丢失以提高性能。还是那句话,这个要看你的要求;例如,错过一些分数与错过网上商店的订单是不同的。
交易性
我们之前的测试暴露了一个不希望的情况,但是很难发现它。当我们在代理关闭时发送尝试时,我们得到一个带有 500 错误代码的服务器错误。这给 API 客户端留下了尝试没有被正确处理的印象。但是,它被部分处理了。
让我们再次测试这一部分,但是,这一次,我们将检查数据库条目。我们只需要乘法微服务运行,代理停止。然后,我们用一个用户别名test-tx
发送一个尝试,以再次获得错误响应。参见清单 7-18 。
$ http POST :8080/attempts factorA=15 factorB=20 userAlias=test-tx guess=300
HTTP/1.1 500
[...]
{
"error": "Internal Server Error",
"message": "",
"path": "/attempts",
"status": 500,
"timestamp": "2020-05-24T10:48:37.861+00:00"
}
Listing 7-18Error Response When the Broker Is Unreachable
现在,我们在http://localhost:8080/h2-console
导航到乘法数据库的 H2 控制台。确保您使用 URL jdbc:h2:file:~/multiplication
进行连接。然后,我们运行这个查询,从用户别名为test-tx
的两个表中获取所有数据:
SELECT * FROM USER u, CHALLENGE_ATTEMPT a WHERE u.ALIAS = 'test-tx' AND u.ID = a.USER_ID
查询给我们一个结果,如图 7-22 所示。这意味着即使我们得到了一个错误响应,尝试还是被存储了。这是一种不好的做法,因为 API 客户端不知道质询的结果,所以它不能显示正确的消息。然而,挑战被挽救了。然而,如果我们的代码在试图将消息发送给代理之前持久化该对象,这就是预期的结果。
图 7-22
H2 控制台:尽管出现故障,记录仍被存储
相反,我们可以将服务方法verifyAttempt
中包含的整个逻辑视为一个事务。数据库事务可以回滚(不执行)。如果我们在调用存储库中的save
方法后仍然得到一个错误,这就是我们想要的。使用 Spring 框架很容易做到这一点,因为我们只需要在代码中添加一个 Java 事务 API (JTA)注释javax.transaction.Transactional
。见清单 7-19 。
@Transactional
@Override
public ChallengeAttempt verifyAttempt(ChallengeAttemptDTO attemptDTO) {
// ...
}
Listing 7-19Adding the @Transactional Annotation to the Service Logic in Multiplication
如果用@Transactional
注释的方法中有异常,事务将被回滚。如果我们需要给定服务中的所有方法都是事务性的,我们可以在类级别添加这个注释。
应用此更改后,您可以再次尝试相同的场景步骤。构建并重启乘法微服务,并在代理关闭时发送新的尝试,这一次使用不同的别名。如果您运行相应的查询来查看尝试是否被存储,您会发现这次没有。由于抛出了异常,Spring 回滚了数据库操作,所以它永远不会被执行。
Spring 还支持 RabbitMQ 事务,包括发布者端和订阅者端。当我们在用@Transactional
注释的方法范围内用 AmqpTemplate 或它的 RabbitTemplate 实现发送消息时,并且当我们在通道(RabbitMQ)中启用事务性时,即使在发送消息的方法调用之后发生了异常,这些消息也不会到达代理。在消费者方面,也可以使用事务来拒绝已经处理过的消息。在这种情况下,需要设置队列来重新排队被拒绝的消息(这是默认行为)。Spring AMQP 文档中的事务部分详细解释了它们是如何工作的;https://tpd.io/rmq-tx
见。
在许多像我们这样的情况下,我们可以简化事务的策略,并将其仅限于数据库。
-
在发布时,如果我们只有一个代理操作,我们可以在流程结束时发布消息。在发送消息之前或发送消息时发生的任何错误都将导致数据库操作回滚。
-
在订户端,如果有异常,消息将被默认拒绝,如果我们需要,我们可以重新排队。然后,我们还可以在我们的
newAttemptForUser
的服务方法中使用Transactional
注释,这样数据库操作也会在出现故障时回滚。
微服务内的本地事务性对于保持数据一致性和避免域内部分完成的流程至关重要。因此,当需要多个步骤与外部组件(如数据库或消息代理)进行交互时,您应该考虑到业务逻辑中可能出错的地方。
锻炼
将@Transactional
注释添加到GameServiceImpl
服务中,这样要么同时存储记分卡和徽章,要么在出现问题时什么都不存储。我们已经决定如果不能处理消息就丢弃它们,所以我们不需要消息代理操作的事务性。
扩展微服务
到目前为止,我们一直在运行每个微服务的单个实例。正如我们在本书前面所描述的,微服务架构的主要优势之一是我们可以独立地扩展系统的各个部分。我们还将这一特性列为使用消息代理引入事件驱动方法的好处:我们可以透明地添加发布者和订阅者的更多实例。然而,我们还不能宣称我们的架构支持添加每个微服务的更多副本。
让我们关注一下为什么我们的应用不能处理多个实例的第一个原因:数据库。当我们横向扩展微服务时,所有副本应该共享数据层,并将数据存储在一个公共位置,而不是每个实例都是独立的。我们说微服务一定是无状态的。原因是不同的请求或消息可能在不同的微服务实例中结束。例如,我们不应该假设来自同一个用户的两次尝试将由同一个乘法实例来处理,因此我们不能在两次尝试之间保持任何内存状态。见图 7-23
图 7-23
向上扩展:界面问题
好消息是,我们的微服务已经是无状态的,我们独立处理每个请求或消息,结果最终保存在数据库中。然而,我们有一个技术问题。如果我们在端口 9080 上启动第二个乘法实例,它将无法启动,因为它试图创建一个新的数据库实例。这不是我们想要的,因为它应该连接到跨副本共享的公共数据库服务器。让我们重现这个错误。首先,照常运行乘法微服务(我们的第一个实例)。
要在本地启动给定服务的第二个实例,我们只需要覆盖server.port
参数,这样可以避免端口冲突。您可以从您的 IDE 或使用乘法微服务目录中的命令行来完成此操作。
$ ./mvnw spring-boot:run -Dspring-boot.run.arguments="--server.port=9080"
当您启动第二个复制副本时,日志会提示以下错误:
[...] Database may be already in use: null. Possible solutions: close all other connection(s); use the server mode [90020-200]
发生这个错误是因为我们使用了 H2 数据库引擎,默认情况下,它被设计为嵌入式进程,而不是服务器。无论如何,H2 支持服务器模式,正如错误消息所暗示的。我们唯一需要做的事情是向我们用来从微服务连接到数据库的两个 URL 添加一个参数。然后,引擎第一次启动时,它将允许其他实例使用同一个文件,从而使用同一个数据库。请记住将这一更改应用于乘法和游戏化微服务。见清单 7-20 。
# ... other properties
# Creates the database in a file (adding the server mode)
spring.datasource.url=jdbc:h2:file:~/multiplication;DB_CLOSE_ON_EXIT=FALSE;AUTO_SERVER=true;
Listing 7-20Enabling the Server Mode in H2 to Connect from Multiple Instances
现在,我们可以启动每个微服务的多个实例,它们将跨副本共享相同的数据层。第一个问题解决了。
我们面临的第二个挑战是负载平衡。如果我们启动每个应用的两个实例,我们如何从用户界面连接到它们?同样的问题也适用于我们在前一章结束时在两个微服务之间的 REST API 调用:我们将调用哪个游戏化实例来发送尝试?如果我们想在副本之间平衡系统的 HTTP 流量,我们需要别的东西。在下一章,我们将详细讨论 HTTP 负载平衡。
现在,让我们关注消息代理如何帮助我们实现 RabbitMQ 消息订阅者之间的负载平衡。见图 7-24
图 7-24
向上扩展:界面问题
图 7-24 中有四个编号接口。正如我们所说的,我们将在下一章看到如何实现 HTTP 负载平衡器模式,所以让我们来看看接口 3 和 4 如何处理多个副本。
像 RabbitMQ 这样的消息代理支持来自多个来源的消息发布。这意味着我们可以在同一个主题交换中发布多个倍增微服务事件。这是透明的:这些实例打开不同的连接,声明交换(只在第一次创建),发布数据,而不需要知道还有其他发布者。在订户端,我们已经了解了 RabbitMQ 队列如何被多个消费者共享。当我们启动游戏化微服务的多个实例时,所有实例都声明相同的队列和绑定,代理足够聪明,可以在它们之间进行负载平衡。
因此,我们在消息级别解决了负载平衡问题。原来我们什么都不需要做。现在,让我们看看这在实践中是如何工作的。
按照前面场景中相同的步骤启动每个微服务、UI 和 RabbitMQ 服务的一个实例。然后,在两个单独的终端中运行清单 7-21 中的命令,进行与上图 7-23 所示相同的设置,每个微服务有两个副本。请记住,您需要从每个相应的微服务的主文件夹中执行它们。
multiplication $ ./mvnw spring-boot:run -Dspring-boot.run.arguments="--server.port=9080"
[... logs ...]
gamification $ ./mvnw spring-boot:run -Dspring-boot.run.arguments="--server.port=9081"
[... logs ...]
Listing 7-21Starting a Second Instance of Each Microservice
一旦我们启动并运行了所有实例,在 UI 中使用相同的新别名输入四次正确的尝试。请注意,这些尝试只会击中我们的乘法微服务的第一个实例,但是事件消耗在两个游戏化副本之间是平衡的。检查日志以验证每个应用应该如何处理两个事件。此外,由于数据库是跨实例共享的,所以 UI 从运行在端口 8081 的实例请求排行榜并不重要。此实例将聚合所有副本存储的所有记分卡和徽章。参见图 7-25
图 7-25
向上扩展:第一次测试
如图 7-25 所示,我们还可以验证多个发布者协同工作使用命令行向乘法微服务的第二个实例发送正确的尝试。让我们向位于端口 9080 的实例发送几个调用,并检查它们是如何被处理的。正如所料,在这种情况下,消息在订阅者之间也是平衡的。参见清单 7-22 中调用第二个实例的例子。
$ http POST :9080/attempts factorA=15 factorB=20 userAlias=test-multi-pub guess=300
Listing 7-22Sending a Correct Attempt to the Second Instance of Multiplication
这是一个伟大的成就。我们演示了 message broker 如何帮助实现良好的系统可伸缩性,并实现了一个 worker 队列模式,其中多个订阅者实例在它们之间分担负载。
因此,我们也提高了弹性。在上一节“游戏化变得不可用”中,我们停止了游戏化实例,并看到当它再次变得活跃时,它将如何赶上未决事件。随着多个实例的引入,如果其中一个实例不可用,代理将自动将所有消息定向到其他实例。你可以通过现在停止游戏化的第一个实例(运行在端口 8081 上)来尝试。然后,发送两次正确的尝试,并在日志中检查第二个实例是如何成功处理它们的。通过这个测试,您还可以验证这种弹性的改进目前仅限于事件消费者接口。UI 无法平衡负载或检测到一个副本关闭。因此,UI 不会显示排行榜,因为浏览器正在尝试访问第一个游戏化实例。我们将在下一章解决这些问题。
总结和成就
本章介绍了一个通常与微服务架构相关的重要概念:事件驱动的软件模式。为了给你一个完整的背景,我们首先关注我们可以用来实现它的最流行的工具之一:消息代理。
我们了解了消息代理如何帮助我们实现微服务之间的松散耦合,就像过去几年类似的模式帮助其他面向服务的架构一样。事件模式通过对一种不指向任何特定目标的消息类型进行建模,向松散耦合迈进了一步,因为它只表示特定领域中发生的事实。然后,不同的消费者可以订阅这些事件流并对其做出反应,可能触发他们自己的业务逻辑,这可能会产生其他事件,等等。我们看到了如何将事件驱动的策略与发布-订阅和工作队列模式相结合,从而在域之间形成清晰的界限,并提高系统的可伸缩性。
RabbitMQ 及其 AMQP 实现提供了一些我们用来构建新架构的工具:发布事件消息的交换、订阅事件消息的队列以及用可选过滤器链接事件消息的绑定。我们不仅学习了关于这些消息传递实体的核心概念,还学习了关于消息确认、消息拒绝和持久性的一些配置选项。请记住,您可能需要微调 RabbitMQ 配置,以适应您的功能性和非功能性需求。
多亏了 Spring Boot 抽象,本章的编码部分仍然很简单。我们通过 Spring AMQP 将 RabbitMQ 集成到我们的 Spring Boot 应用中。我们将代理实体声明为 beans,利用AmqpTemplate
来发布消息,并使用@RabbitListener
注释来消费它们。乘法微服务已经不知道游戏化微服务了;它只是在尝试被处理时发布一个事件。我们最终实现了与新的事件驱动软件架构的松散耦合。
本章的相关部分是最后一节,我们通过不同的场景展示了我们实现的模式确实帮助我们提高了弹性和可伸缩性,前提是我们在构建代码时考虑了这些非功能性需求。
关于本章中的概念的好消息是,一旦你掌握了它们,你就可以将它们应用到使用不同技术的其他系统中。例如,基于 Scala 和 Kafka 的事件驱动软件架构面临着同样的挑战,并且通常需要类似的模式:同一个 Kafka 主题的多个订阅者,消费者之间的负载平衡(使用消费者组),配置交付保证,比如最少一次和最多一次,等等。请记住,使用不同的工具,您可能会得到不同的利弊。
在这个阶段,我希望您已经观察到构建一个好的软件架构的重要部分是理解设计模式以及它们如何与功能和非功能需求相关联。只有在您了解这些模式之后,您才能分析实现它们的工具和框架,比较它们提供的特征。
有时,我们可能希望构建一个事件驱动的架构,因为我们认为这是最好的技术解决方案,但它可能不是我们业务需求的最佳模式。我们应该避免这些情况,因为软件将倾向于发展以适应真实的商业案例,这可能会导致许多问题。我见过微服务架构被它们之间的同步调用所困扰,要么是因为需求没有适应最终的一致性,要么是因为根据功能需求,这甚至是不可能的。在跳入技术解决方案之前,花足够的时间分析您想要解决的问题,并且对承诺解决所有可能需求的新架构模式持怀疑态度。
当我们开发我们的系统时,我们遇到了一些我们还不能解决的新挑战。我们需要带有不可用实例检测的 HTTP 负载平衡。此外,我们的 UI 直接指向每个微服务,因此它知道后端结构。然后,我们也感觉到如何管理我们的系统在诸如启动它或在多个地方检查日志等方面变得越来越困难。微服务架构的复杂性开始变得更加明显。在下一章,我们将介绍一些帮助我们处理这种复杂性的模式和工具。
章节成就:
-
您学习了事件驱动架构的核心概念。为此,您已经对消息代理如何工作有了很好的了解。
-
您已经了解了事件驱动架构的优缺点,知道在未来的项目中什么时候应用这种模式是有意义的。
-
您了解了如何根据您的用例实现不同的消息传递模式。
-
在我们的实际案例中,您使用 RabbitMQ 消息代理应用了所有学到的概念。
-
您了解了 Spring Boot 如何抽象出 RabbitMQ 的许多功能,允许您只添加一些代码就可以做很多事情。
-
您重构了一个紧耦合的系统,并将其转换成一个合适的事件驱动架构。
-
您使用了这个应用来理解弹性是如何工作的,如何扩展您的消费者,以及如何处理事务性。
八、微服务架构中的常见模式
当然,您已经意识到,在前两章中,我们是如何从满足功能需求的解决方案过渡到不增加任何业务功能的模式实现的。然而,它们对于我们的系统更具可伸缩性、更具弹性或具有更好的性能是必不可少的。
当我们完成游戏化微服务并将其逻辑连接到 web 客户端时,我们完成了用户故事 3 中新请求的功能。然而,新的功能需求与其他非功能需求一起出现:系统容量、可用性和组织灵活性。
经过一些分析,我们决定转移到基于微服务的分布式系统架构,因为这种方法会给我们的案例研究带来优势。
我们尽可能简单地开始,通过 HTTP 同步连接服务,并将用户界面指向我们的两个微服务。然后,使用我们的实际例子,我们看到了这种方法如何破坏我们的计划,因为微服务之间的紧密耦合和无法扩展。为了解决这种情况,我们采用了异步通信和最终一致性,并引入了消息代理来实现事件驱动的架构设计。结果,我们解决了紧耦合的挑战,现在我们的微服务被很好地隔离了。我们甚至部分讨论了负载平衡,因为代理正在为事件消费者处理它。
当我们继续我们的架构时,我们简要地描述了有助于我们实现目标的其他模式,比如网关模式或 HTTP 接口的负载平衡器。除了这些明确的需求,我们还没有介绍微服务架构的其他一些基本实践:服务发现、健康检测、配置管理、日志记录、跟踪、端到端测试等。
在这一章中,我们将使用我们的系统作为例子来研究所有这些模式。这将有助于你在深入研究解决方案之前理解问题。我们对微服务常用模式和工具的探索才刚刚开始。
门
我们已经知道网关模式将在我们的架构中解决的一些问题。
-
我们的 React 应用需要指向多个后端微服务,以便与它们的 API 进行交互。这是错误的,因为前端应该将后端视为具有多个 API 的单个服务器。然后,我们不暴露我们的架构,从而使它更加灵活,以防我们将来想要做出改变。
-
如果我们引入后端服务的多个实例,我们的 UI 不知道如何平衡它们之间的负载。如果其中一个实例不可用,它也不知道如何将所有请求重定向到不同的后端实例。尽管在我们的 web 客户端中实现负载平衡和弹性模式在技术上是可能的,但这是我们应该放在后端的逻辑。我们只实现一次,它对任何客户端都有效。此外,我们尽可能保持前端的逻辑简单。
-
根据当前的设置,如果我们在系统中添加用户身份验证,我们将需要验证每个后端微服务中的安全凭证。将这个逻辑放在我们后端的边缘似乎更符合逻辑,在那里验证 API 调用,并将简单的请求传递给其他微服务。只要我们确保其余的后端服务不能从外部访问,它们就不需要考虑安全问题。
在本章的第一部分,我们将在系统中引入网关微服务来解决这些问题。网关模式集中了 HTTP 访问,并负责将请求代理到其他底层服务。通常,网关根据一些配置的规则(也称为谓词)来决定将请求路由到哪里。此外,这个路由服务可以在请求和响应通过时对其进行修改,其中包含一些被称为过滤器的逻辑。我们将很快在实现中使用规则和过滤器,以便更好地理解它们。请参见图 8-1 了解网关如何融入我们的系统。
图 8-1
网关:高级概述
有时人们将网关称为边缘服务,因为它是其他系统访问我们后端的方式,它将外部流量路由到相应的内部微服务。如前所述,网关的引入通常会限制对其他后端服务的访问。对于本章的第一部分,我们将跳过这个限制,因为我们直接在机器上运行所有的服务。当我们引进集装箱化时,我们将改变这一点。
SpringCloud 网关
Spring Cloud 是 Spring 家族中的一个独立项目组,它提供工具来快速构建分布式系统中所需的通用模式,比如我们的微服务案例。这些模式也被称为云模式,尽管即使您在自己的服务器中部署微服务,它们也是适用的。在本章中,我们将利用几个 Spring Cloud 项目。如果您想查看完整的列表,请查看参考文档中的概述页面( https://tpd.io/scloud
)。
对于网关模式,Spring Cloud 提供了两种选择。第一种选择是使用 Spring Cloud 长期支持的集成:Spring Cloud 网飞。这是一个包括几个工具的项目,这些工具是网飞开发者多年来作为开源软件(OSS)发布和维护的。如果你想了解更多关于这些工具的信息,你可以看看网飞 OSS 网站( https://tpd.io/noss
)。网飞 OSS 中实现网关模式的组件是 Zuul,它与 Spring 的集成通过 Spring Cloud 网飞 Zuul 模块实现。
在这本书的第二版中,我们不会使用 SpringCloud 网飞。主要原因是,Spring 似乎正在远离网飞 OSS 工具集成,并用集成了替代工具的其他模块,甚至是它们自己的实现来取代它们。对这种变化的一种可能的解释是,网飞将其一些项目置于维护模式,如 Hystrix(断路)和 Ribbon(负载平衡),因此它们不再处于积极的开发中。这个决定也会影响网飞堆栈中的其他工具,因为它们实现了经常一起使用的模式。一个例子是服务发现工具 Eureka,它依靠 Ribbon 来实现负载平衡。
我们将选择一个更新的替代方案来实现网关模式:Spring Cloud Gateway。在这种情况下,对 Zuul 的替换是一个独立的 Spring 项目,因此它不依赖于任何外部工具。
知道了模式,你就可以交换工具了
请记住,从这本书中学到的最重要的东西是微服务架构模式,以及从实用的角度介绍它们的原因。您可以使用市场上的任何其他替代产品,如 Nginx、HAProxy、Kong Gateway 等。
Spring Cloud Gateway 项目定义了一些核心概念(也如图 8-2 所示)。
图 8-2
网关:路由、谓词和过滤器
-
谓词:决定将请求路由到哪里的评估条件。云网关提供了一堆基于请求路径、请求头、时间、远程主机等的条件构建器。你甚至可以把它们组合成表达式。它们也被称为路由谓词,因为它们总是应用于一条路由。
-
Route :如果请求匹配指定的谓词,它将被代理到 URI。例如,它可以向内部微服务端点发送外部请求,我们将在后面的实践中看到这一点。
-
过滤器:一个可选的处理器,可以连接到一个路由(路由过滤器),也可以全局应用(全局过滤器)到所有的请求。过滤器允许修改请求(传入过滤器)和响应(传出过滤器)。Spring Cloud Gateway 中有许多内置的过滤器,因此您可以添加或删除请求中的头,限制来自给定主机的请求数量,或者在将响应返回给请求者之前转换来自代理服务的响应。
为了定义这个配置,我们使用 Spring Boot 的应用属性。然而,这一次我们将使用 YAML 格式,因为它在定义路线时可读性更强。云网关文档定义了谓词、路由和过滤器的特定符号。此外,在定义谓词和过滤器时,我们有两个选项:快捷方式和完全扩展的配置。他们两个工作一样;唯一的区别是,您可以使用单行表达式,并通过快捷方式版本避免额外的 YAML。如果你想知道它们之间的比较,可以查看文档中的“快捷符号”( http://tpd.io/gw-notation
)一节。见清单 8-1 中使用快捷符号定义两条路线的示例配置块。请继续阅读关于它们如何工作的详细解释。
spring:
cloud:
gateway:
routes:
- id: old-travel-conditions
uri: http://oldhost/travel
predicates:
- Before=2021-01-01T10:00:00.000+01:00[Europe/Madrid]
- Path=/travel-in-spain/**
- id: change-travel-conditions
uri: http://somehost/travel-new
predicates:
- After=2021-01-01T10:00:00.000+01:00[Europe/Madrid]
- Path=/travel-in-spain/**
filters:
- AddResponseHeader=X-New-Conditions-Apply, 2021-Jan
Listing 8-1An Example of Routing Configuration in Spring Cloud Gateway
假设在http://my.travel.gateway/
网关是外部可访问的。这个示例配置定义了共享一个路径路由断言(包含在网关中)的两个路由;见 https://tpd.io/pathpred
。任何以http://my.travel.gateway/travel-in-spain/
开头的请求都被这个谓词定义捕获。每个路由中的附加条件分别由一个 Before ( https://tpd.io/befpred
)和一个 After route ( https://tpd.io/aftpred
)谓词定义,它们决定将请求代理到哪里。
-
如果请求发生在西班牙时间 2021 年 1 月 1 日上午 10 点之前,它将被代理给
http://oldhost/travel-conditions/
。例如,请求http://my.travel.gateway/travel-in-spain/tapas
被代理给http://oldhost/travel/tapas
。 -
此后发生的任何请求都被
change-travel-conditions
路由捕获,因为它使用了对应的谓词After
。在这种情况下,前面显示的相同请求将被代理到http://somehost/travel-new/tapas
。另外,额外的filter
将添加一个响应头X-New-Conditions-Apply
,值为2021-Jan
。
请记住,本例中的http://oldhost
和http://somehost
不需要从外部访问;它们只对我们后端的网关和其他内部服务可见。
内置的谓词和过滤器允许我们满足对网关的各种需求。在我们的应用中,我们将主要使用路径路由谓词,根据它们调用的 API,将外部请求代理到相应的微服务。
如果您想扩展关于 Spring Cloud Gateway 功能的知识,请查看参考文档( https://tpd.io/gwdocs
)。
网关微服务
代码源
本章中的代码源被分成四个部分。通过这种方式,您可以更好地理解系统是如何逐步发展的。第一部分的源代码,包括网关实现,都在项目chapter08a
中。
可以想象,Spring Boot 为 Spring Cloud Gateway 提供了一个入门包。只有将这个启动器依赖项添加到一个空的 Spring Boot 应用中,我们才能获得一个随时可用的网关微服务。实际上,Gateway 项目是建立在 Spring Boot 之上的,所以它只能在 Spring Boot 应用中工作。出于这个原因,在这种情况下,自动配置逻辑位于核心 Spring Cloud Gateway 工件中,而不是位于 Spring Boot 的autoconfigure
包中。类名是GatewayAutoConfiguration
(参见 https://tpd.io/gwautocfg
),在其他任务中,它读取application.yml
配置并构建相应的路由过滤器、谓词等。
我们将像往常一样,通过 Spring Initializr 的网站构建这个新的微服务(参见 https://tpd.io/spring-start
)。选择网关依赖,将工件命名为网关,如图 8-3 所示。
图 8-3
创建网关微服务
下载 zip 文件后,我们将它的内容复制到我们的主工作区文件夹中,与乘法和游戏化微服务处于同一级别。将项目作为一个额外的模块加载到您的工作区中,并花点时间研究一下生成的pom.xml
文件的内容。与其他项目相比,您会看到一个新的dependencyManagement
节点和一个新的属性供 Spring Cloud 版本使用(Hoxton)。文件的主要变化见清单 8-2 。我们需要这个额外的 Maven 配置,因为 Spring Cloud 工件没有直接在 Spring Boot 的父项目中定义。
<?xml version="1.0" encoding="UTF-8"?>
<project>
<!-- ... -->
<name>gateway</name>
<properties>
<spring-cloud.version>Hoxton.SR7</spring-cloud.version>
<!-- ... -->
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!-- ... -->
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<!-- ... -->
</project>
Listing 8-2Spring Cloud Gateway Dependencies in Maven
下一步是将application.properties
文件的扩展名更改为application.yml
,并添加一些配置,以将属于乘法微服务的所有端点代理到该应用,对于游戏化的端点也是如此。我们还将这个新服务的服务器端口更改为 8000,以避免本地部署时的端口冲突。此外,我们将为 UI 添加一些 CORS 配置,以允许从其来源发出请求。Spring Cloud Gateway 有一个基于配置的风格( https://tpd.io/gwcors
)来使用globalcors
属性实现这一点。在清单 8-3 中可以看到所有这些变化。
server:
port: 8000
spring:
cloud:
gateway:
routes:
- id: multiplication
uri: http://localhost:8080/
predicates:
- Path=/challenges/**,/attempts,/attempts/**,/users/**
- id: gamification
uri: http://localhost:8081/
predicates:
- Path=/leaders
globalcors:
cors-configurations:
'[/**]':
allowedOrigins: "http://localhost:3000"
allowedHeaders:
- "*"
allowedMethods:
- "GET"
- "POST"
- "OPTIONS"
Listing 8-3Gateway Configuration: First Approach
该文件中的路由将使网关的行为如下:
-
任何到
http://localhost:8000/attempts
或在http://localhost:8000/attempts
下的请求将被代理到部署在本地http://localhost:8080/
的乘法微服务。位于同一微服务中的其他 API 上下文也会发生同样的情况,比如challenges
和users
。 -
对
http://localhost:8000/leaders
的请求将被转换为对游戏化微服务的请求,游戏化微服务使用相同的主机(localhost
)但端口是 8081。
或者,可以编写一个更简单的配置,不需要路由到每个微服务的端点的明确列表。我们可以通过使用网关的另一个允许捕获路径段的特性来做到这一点。如果我们得到一个 API 调用,比如http://localhost:8000/multiplication/attempts
,我们可以提取multiplication
作为一个值,并使用它映射到相应服务的主机和端口。然而,只有当每个微服务只包含一个 API 域时,这种方法才有效。在任何其他情况下,我们将向客户公开我们的内部架构。在我们的例子中,我们会要求客户端调用http://localhost:8000/multiplication/users
,而我们更希望它们指向http://localhost:8000/users
,并隐藏用户域仍然存在于乘法的可部署单元中的事实。
其他项目的变化
随着网关微服务的引入,我们可以将所有针对外部请求的配置保留在同一个服务中。这意味着我们不再需要向倍增和游戏化微服务添加 CORS 配置。我们可以在网关中保留这个配置,因为其他两个服务被放在这个新的代理服务之后。因此,我们可以从现有的项目文件夹中删除两个WebConfiguration
文件。清单 8-4 展示了游戏化微服务中的文件内容。记得删除的不仅是这个,还有乘法微服务里的等价类。
package microservices.book.gamification.configuration;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
/**
* Enables Cross-Origin Resource Sharing (CORS)
* More info: http://docs.spring.io/spring/docs/current/spring-framework-reference/html/cors.html
*/
@Override
public void addCorsMappings(final CorsRegistry registry) {
registry.addMapping("/**").allowedOrigins("http://localhost:3000");
}
}
Listing 8-4The WebConfiguration Class That We Can Remove
我们还需要更改 React 应用,使两个服务指向同一个主机/端口。参见清单 8-5 和 8-6 。我们还可以根据我们对 UI 结构的偏好重构GameApiClient
和ChallengesApiClient
类:一个服务调用所有端点,或者一个服务调用每个 API 上下文(挑战、用户等)。).我们不再需要两个不同的服务器 URL,因为 UI 现在将后端视为具有多个 API 的单个主机。
class GameApiClient {
static SERVER_URL = 'http://localhost:8000';
static GET_LEADERBOARD = '/leaders';
// ...
}
Listing 8-6Changing the Gamification API URL to Point to the Gateway
class ChallengesApiClient {
static SERVER_URL = 'http://localhost:8000';
static GET_CHALLENGE = '/challenges/random';
static POST_RESULT = '/attempts';
// ...
}
Listing 8-5Changing the Multiplication API URL to Point to the Gateway
运行网关微服务
要运行我们的整个应用,我们必须在列表中添加一个额外的步骤。记住,使用 Maven 包装器,所有 Spring Boot 应用都可以从您的 IDE 或命令行执行。
-
运行 RabbitMQ 服务器。
-
启动乘法微服务。
-
启动游戏化微服务。
-
启动新的网关微服务。
-
从
challenges-frontend
文件夹用npm start
运行前端 app。
在本章中,这个列表将继续增长。需要强调的是,您不需要遵循前面步骤的顺序,因为您甚至可以同时运行所有这些流程。在开始阶段,系统可能不稳定,但最终会准备好的。Spring Boot 将重新尝试连接 RabbitMQ,直到它正常工作。
当您访问 UI 时,您不会注意到任何变化。排行榜已加载,您可以像往常一样发送尝试。验证请求是否被代理的一种方法是查看浏览器的开发工具中的 Network 选项卡,并选择任何请求到后端,以查看 URL 现在如何以http://localhost:8000
开始。第二个选择是向网关添加一些跟踪日志配置,这样我们就可以看到发生了什么。请参见清单 8-7 了解您可以添加到网关项目中的application.yml
文件中的配置,以启用这些日志。
# ... route config
logging:
level:
org.springframework.cloud.gateway.handler.predicate: trace
Listing 8-7Adding Trace-Level Logs to the Gateway
如果您使用这个新配置重新启动网关,您将看到网关处理的每个请求的日志。这些日志是检查所有定义的路由以查看是否有匹配请求模式的路由的结果。参见清单 8-8 。
TRACE 48573 --- [ctor-http-nio-2] RoutePredicateFactory: Pattern "[/challenges/**, /attempts, /attempts/**, /users/**]" does not match against value "/leaders"
TRACE 48573 --- [ctor-http-nio-2] RoutePredicateFactory: Pattern "/leaders" matches against value "/leaders"
TRACE 48573 --- [ctor-http-nio-2] RoutePredicateFactory: Pattern "/users/**" matches against value "/users/72,49,60,101,96,107,1,45"
Listing 8-8Gateway Logs with Pattern-Matching Messages
现在,让我们再次删除这个日志配置,以避免过于冗长的输出。
后续步骤
我们已经有了一些新的优势。
-
前端仍然不知道后端的结构。
-
外部请求的通用配置保持不变。在我们的例子中,这是 CORS 设置,但也可能是其他常见问题,如用户身份验证、指标等。
接下来,我们将在网关中引入负载平衡,这样它可以在每个服务的所有可用实例之间分配流量。利用这种模式,我们将为系统增加可伸缩性和冗余性。然而,要使负载平衡正常工作,我们需要一些先决条件。
-
网关需要知道给定服务的可用实例。我们的初始配置直接指向一个特定的端口,因为我们假设只有一个实例。如果有多个副本,会是什么样子呢?我们不应该在路由配置中包含硬编码列表,因为实例的数量应该是动态的:我们希望透明地增加和减少新的实例。
-
我们需要实现后端组件健康的概念。只有这样,我们才能知道一个实例何时未准备好处理流量,并切换到任何其他正常的实例。
为了满足第一个先决条件,我们需要引入服务发现模式,使用一个公共注册表,不同的分布式组件可以访问该注册表以了解可用的服务以及在哪里可以找到它们。
对于第二个先决条件,我们将使用 Spring Boot 执行器。我们的微服务将公开一个指示它们是否健康的端点,因此其他组件将会知道。这是我们接下来要做的,因为这也是服务发现的一个要求。
健康
在生产环境中运行的系统永远不会安全地避免错误。网络连接可能会失败,或者微服务实例可能会由于代码中的错误导致的内存不足问题而崩溃。我们决心建立一个有弹性的系统,所以我们希望通过像冗余(同一微服务的多个副本)这样的机制来应对这些错误,以最大限度地减少这些事件的影响。
那么,我们如何知道微服务何时不工作呢?如果它公开了一个接口(比如 REST API 或 RabbitMQ),我们可以与一个样本探针进行交互,看看它是否对它做出反应。但是,我们在选择该探测器时应该小心,因为我们希望涵盖所有可能会使我们的微服务过渡到不健康状态(不起作用)的场景。与其将确定服务是否工作的逻辑泄露给调用者,不如提供一个标准的、简单的探测接口来判断服务是否健康。由服务的逻辑根据它使用的接口的可用性、它自己的可用性和错误的严重性来决定何时转换到不健康状态。如果服务甚至不能提供响应,调用者也可以认为它是不健康的。请参见图 8-4 了解该健康界面的高级概念视图。
图 8-4
健康:高级概述
许多工具和框架都需要这个简单的接口约定来确定服务的健康程度(不仅仅是微服务)。例如,负载平衡器可以暂时停止将流量转移到不响应健康探测或以非就绪状态响应的实例。如果实例不正常,服务发现工具可能会将其从注册表中删除。像 Kubernetes 这样的容器平台可以决定重启一个服务,如果它在一段配置的时间内不健康的话(我们将在本章后面解释什么是容器平台)。
Spring Boot 执行器
与我们应用的其他方面一样,Spring Boot 提供了一个开箱即用的解决方案来使我们的微服务报告其健康状态:Spring Boot 执行器。实际上,这不是 Actuator 包含的唯一特性;它还可以公开其他端点来访问关于我们的应用的不同数据,如配置的记录器、HTTP 跟踪、审计事件等。它甚至可以打开一个允许您关闭应用的管理端点。
执行器端点可以独立启用或禁用,它们不仅可以通过 web 界面使用,还可以通过 Java 管理扩展(JMX)使用。我们将把重点放在我们将用作 REST API 端点的 web 接口上。默认配置只公开两个端点:info
和health
。第一个目的是提供关于应用的一般信息,您可以使用贡献者( https://tpd.io/infocb
)来丰富这些信息。健康端点是我们现在感兴趣的。它输出我们的应用的状态,为了解决这个问题,它使用健康指示器( https://tpd.io/acthealth
)。
有多个内置的健康指示器可以帮助了解应用的整体健康状态。这些指示器中有许多是特定于某些工具的,因此只有当我们在应用中使用这些工具时,它们才可用。这是由 Spring Boot 的自动配置控制的,我们已经知道它可以检测我们是否在使用某些类并注入一些额外的逻辑。
让我们用一个实际的例子来看看它是如何工作的:包含在 Spring Boot 执行器工件中的RabbitHealthIndicator
类。参见清单 8-9 以获得其源代码的概述(也可在 http://tpd.io/rhi-source
在线获得)。健康检查实现使用了一个RabbitTemplate
对象,这是 Spring 与 RabbitMQ 服务器交互的方式。如果这段代码可以访问 RabbitMQ 服务器的版本,健康检查就通过了(它不会抛出异常)。
public class RabbitHealthIndicator extends AbstractHealthIndicator {
private final RabbitTemplate rabbitTemplate;
public RabbitHealthIndicator(RabbitTemplate rabbitTemplate) {
super("Rabbit health check failed");
Assert.notNull(rabbitTemplate, "RabbitTemplate must not be null");
this.rabbitTemplate = rabbitTemplate;
}
@Override
protected void doHealthCheck(Health.Builder builder) throws Exception {
builder.up().withDetail("version", getVersion());
}
private String getVersion() {
return this.rabbitTemplate
.execute((channel) -> channel.getConnection()
.getServerProperties().get("version").toString());
}
}
Listing 8-9The RabbitHealthIndicator Included in Spring Boot Actuator
如果我们使用 RabbitMQ,这个指示器会自动注入到上下文中。它有助于整体健康状况。工件spring-boot-actuator-autoconfigure
(Spring Boot 致动器依赖的一部分)中包含的RabbitHealthContributorAutoConfiguration
类负责这一点。见清单 8-10 (也可在 http://tpd.io/rhc-autoconfig
)。这个配置以一个RabbitTemplate
bean 的存在为条件,这意味着我们正在使用 RabbitMQ 模块。它创建了一个HealthContributor
bean,在本例中是一个RabbitHealthIndicator
,它将被整体健康自动配置检测和聚合。
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(RabbitTemplate.class)
@ConditionalOnBean(RabbitTemplate.class)
@ConditionalOnEnabledHealthIndicator("rabbit")
@AutoConfigureAfter(RabbitAutoConfiguration.class)
public class RabbitHealthContributorAutoConfiguration
extends CompositeHealthContributorConfiguration<RabbitHealthIndicator, RabbitTemplate> {
@Bean
@ConditionalOnMissingBean(name = { "rabbitHealthIndicator", "rabbitHealthContributor" })
public HealthContributor rabbitHealthContributor(Map<String, RabbitTemplate> rabbitTemplates) {
return createContributor(rabbitTemplates);
}
}
Listing 8-10How Spring Boot Autoconfigures the RabbitHealthContributor
我们将很快看到这在实践中是如何工作的,因为我们将在下一节将 Spring Boot 致动器添加到我们的微服务中。
请记住,您可以配置执行器端点的多个设置,也可以创建自己的健康指示器。如需完整的功能列表,请查阅 Spring Boot 致动器官方文档( https://tpd.io/sbactuator
)。
包括微服务中的致动器
代码源
引入健康端点、服务发现和负载平衡的代码源位于存储库chapter08b
中。
将健康端点添加到我们的应用就像将pom.xml
文件中的依赖项添加到我们的项目:spring-boot-starter-actuator
一样简单。参见清单 8-11 。我们将这个新的工件添加到我们所有的 Spring Boot 应用中:乘法、游戏化和网关微服务。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
Listing 8-11Adding Spring Boot Actuator to Our Microservices
默认配置在/actuator
上下文中公开了health
和info
web 端点。这对于我们来说已经足够了,但是如果需要的话,可以通过属性进行调整。重新构建并重启后端应用,以验证这一新功能。我们可以使用命令行或浏览器来轮询每个服务的健康状态,只需切换端口号即可。请注意,我们没有通过网关公开/health
端点,因为这不是我们想要向外部公开的特性,而是我们系统内部的特性。乘法微服务的请求和响应见清单 8-12 。
$ http :8080/actuator/health
HTTP/1.1 200
Content-Type: application/vnd.spring-boot.actuator.v3+json
{
"status": "UP"
}
Listing 8-12Testing the /health Endpoint for the First Time
如果我们的系统运行正常,我们将获得UP
值和一个 HTTP 状态代码200
。我们接下来要做的是停止 RabbitMQ 服务器并再次尝试同样的请求。我们已经看到,Actuator 项目包含一个健康指示器来检查 RabbitMQ 服务器,所以这个应该会失败,导致聚合健康状态切换到DOWN
。如果我们在 RabbitMQ 服务器停止时发出请求,这确实是我们将得到的结果。参见清单 8-13 。
$ http :8080/actuator/health
HTTP/1.1 503
Content-Type: application/vnd.spring-boot.actuator.v3+json
{
"status": "DOWN"
}
Listing 8-13The Status of Our App Switches to DOWN When RabbitMQ Is Unreachable
请注意,返回的 HTTP 状态代码也更改为 503,服务不可用。因此,调用者甚至不需要解析响应体;它可以只检查响应代码是否为 200,以确定应用是否正常。您还可以在乘法应用的输出中看到从RabbitHealthIndicator
检索服务器版本的失败尝试的日志。参见清单 8-14 。
2020-08-30 10:20:04.019 INFO 59277 --- [io-8080-exec-10] o.s.a.r.c.CachingConnectionFactory : Attempting to connect to: [localhost:5672]
2020-08-30 10:20:04.021 WARN 59277 --- [io-8080-exec-10] o.s.b.a.amqp.RabbitHealthIndicator : Rabbit health check failed
org.springframework.amqp.AmqpConnectException: java.net.ConnectException: Connection refused
at org.springframework.amqp.rabbit.support.RabbitExceptionTranslator.convertRabbitAccessException(RabbitExceptionTranslator.java:61) ~[spring-rabbit-2.2.10.RELEASE.jar:2.2.10.RELEASE]
[...]
Caused by: java.net.ConnectException: Connection refused
at java.base/sun.nio.ch.Net.pollConnect(Native Method) ~[na:na]
[...]
Listing 8-14Rabbit Health Check Failing in the Multiplication Microservice
Spring Boot 应用仍然存在,并且可以从错误中恢复。如果我们启动 RabbitMQ 服务器并再次检查健康状态,它将切换到UP
。应用不断尝试建立与服务器的连接,直到成功为止。这正是我们想要的健壮系统的行为:如果微服务有问题,它应该标记它,让其他组件知道;同时,它应该尝试从错误中恢复,并在可能的情况下再次切换到健康状态。
服务发现和负载平衡
既然我们有能力知道服务是否可用,我们就可以在系统中集成服务发现和负载平衡。
服务发现模式由两个主要概念组成。
图 8-5
服务发现:模式概述
- **服务注册中心:一个包含可用服务列表、服务所在地址和一些额外元数据(如名称)的中心位置。它可能包含不同服务的条目,但也可能包含同一服务的多个实例。在最后一种情况下,访问注册中心的客户机可以通过查询一个服务别名来获得可用实例的列表。例如,乘法微服务的各种实例可以用同一个别名
multiplication
注册。然后,当查询该值时,将返回所有实例。图 8-5 所示的例子就是如此。
** 注册器:负责在注册中心注册服务实例的逻辑。它可以是一个外部运行的进程,观察我们的微服务的状态,或者它可以作为一个库嵌入到服务本身中,就像我们的情况一样。*
*在图 8-5 中,我们看到了一个有三个服务的服务注册的例子。服务器的 DNS 地址host1
在端口 8080 有一个乘法实例,在端口 8081 有一个游戏化实例。另一台机器host2
有第二个乘法实例,位于端口 9080。所有这些实例都知道它们的位置,并使用注册器将它们相应的 URIs 发送到服务注册中心。然后,注册中心客户端可以使用服务的名称(例如,multiplication
)简单地询问服务的位置,注册中心返回实例及其位置的列表(例如,host1:8080
,host2:9080
)。我们将很快在实践中看到这一点。
负载平衡模式与服务发现密切相关。如果多个服务在注册时使用相同的名称,这意味着有多个副本可用。我们希望平衡它们之间的流量,这样我们就可以增加系统的容量,并且由于增加了冗余,在出现错误的情况下使系统更有弹性。
其他服务可以从注册表中查询给定的服务名,检索列表,然后决定调用哪个实例。这种技术被称为客户端发现,它意味着客户端知道服务注册并自己执行负载平衡。请注意,在此定义中,客户端是指应用、微服务、浏览器等。,它希望对另一个服务执行 HTTP 调用。见图 8-6 。
图 8-6
客户端服务发现
另一方面,服务器端发现通过提供一个预先知道的惟一地址,从客户端抽象出所有这些逻辑,调用者可以在这个地址找到给定的服务。当它们发出请求时,负载均衡器会截获它,它知道注册表。这个平衡器将把请求代理给其中一个副本。见图 8-7 。
图 8-7
服务器端服务发现
通常,在微服务架构中,您会看到两种方法的结合,或者只是服务器端发现。当 API 客户端在我们的系统之外时,客户端发现不能很好地工作,因为我们不应该要求外部客户端与服务注册中心交互并自己进行负载平衡。通常,网关承担这一责任。因此,我们的 API 网关将连接到服务注册中心,并将包含一个负载平衡器,以便在实例之间分配负载。
对于我们后端中的任何其他服务到服务的通信,我们可以将它们全部连接到注册表以进行客户端发现,或者将每个服务集群(及其所有实例)抽象为具有负载平衡器的唯一地址。后者是 Kubernetes 等一些平台选择的技术,其中每个服务都被分配一个唯一的地址,不管有多少副本以及它们位于哪个节点(我们将在本章稍后回到这一点)。
我们的微服务不再互相调用,但是如果需要的话,实现客户端发现会很简单。Spring Boot 具有连接到服务注册中心和实现负载平衡器的集成(类似于我们将在网关中做的)。
正如我们已经提到的,任何到我们后端的非内部 HTTP 通信都将使用服务器端发现方法。这意味着我们的网关不仅会路由流量,还会负责负载平衡。参见图 8-8 ,其中也包括我们在需要军种间通信时选择的解决方案。该图还介绍了我们将选择用来实现服务发现、Consul 的工具的名称,以及我们将添加到依赖项中的 Spring Cloud 项目,以集成该工具并包括一个简单的负载平衡器。我们很快就会了解它们。
图 8-8
网关和服务发现集成
领事
许多工具实现了服务发现模式:Consul、Eureka、Zookeeper 等。也有完整的平台将这种模式作为其特性之一,我们将在后面描述。
在 Spring 生态系统中,网飞的尤里卡一直是最受欢迎的选择。然而,由于前面提到的原因(组件处于维护模式,Spring 开发人员开发的新工具),这种偏好不再是一个合理的选择。我们将使用 Consul,这是一个提供服务发现和其他功能的工具,并且通过 Spring Cloud 模块进行了很好的集成。此外,我们将利用本章后面的其他 Consul 特性来实现微服务架构中的另一种模式,即集中式配置。
首先,让我们安装 Consul 工具,该工具在下载页面( https://tpd.io/dlconsul
)可用于多个平台。一旦你安装了它,你可以用清单 8-15 中的命令在开发模式下运行 Consul 代理。
$ consul agent -node=learnmicro -dev
==> Starting Consul agent...
Version: 'v1.7.3'
Node ID: '0a31db1f-edee-5b09-3fd2-bcc973867b65'
Node name: 'learnmicro'
Datacenter: 'dc1' (Segment: '<all>')
Server: true (Bootstrap: false)
Client Addr: [127.0.0.1] (HTTP: 8500, HTTPS: -1, gRPC: 8502, DNS: 8600)
Cluster Addr: 127.0.0.1 (LAN: 8301, WAN: 8302)
Encrypt: Gossip: false, TLS-Outgoing: false, TLS-Incoming: false, Auto-Encrypt-TLS: false
...
Listing 8-15Starting the Consul Agent in Development Mode
日志应该显示一些关于服务器和一些启动操作的信息。我们在开发模式下运行代理,因为我们在本地使用它,但是一个合适的 Consul 生产设置应该包括一个具有多个数据中心的集群。这些数据中心可以运行一个或多个代理,其中每个服务器只有一个代理充当服务器代理。代理使用协议在它们之间进行通信,以同步信息并通过一致意见选举领导者。所有这些设置确保了高可用性。如果数据中心变得不可达,代理会注意到这一点,并选举新的领导者。如果您想了解更多关于在生产中部署 Consul 的信息,请查看部署指南( https://tpd.io/consulprod
)。我们将坚持本书中的开发模式,使用独立的代理。
正如我们在输出中看到的,Consul 在端口 8500 上运行一个 HTTP 服务器。它提供了一个 RESTful API,我们可以用它来进行服务注册和发现,以及其他功能。此外,它提供了一个 UI,如果我们从浏览器导航到http://localhost:8500
,就可以访问这个 UI。参见图 8-9 。
图 8-9
领事 UI
“服务”部分显示已注册服务的列表。由于我们还没有做任何事情,唯一可用的服务就是 consul 服务器。其他选项卡向我们展示了可用的 Consul 节点、我们将在本章后面使用的键/值功能,以及一些额外的 Consul 特性,如 ACL 和 intentions,这些我们在本书中不会用到。
您还可以通过 REST API 访问可用服务的列表。例如,使用 HTTPie,我们可以请求一个可用服务的列表,这会暂时输出一个空的响应体。参见清单 8-16 。
$ http -b :8500/v1/agent/services
{}
Listing 8-16Requesting the List of Services from Consul
服务 API 允许我们列出服务、查询它们的信息、了解它们是否健康、注册它们以及取消注册它们。我们不会直接使用这个 API,因为 Spring Cloud Consul 模块会为我们做这件事,我们很快会谈到。
Consul 包括验证所有服务状态的功能:健康检查特性。它提供了多种选项,我们可以使用这些选项来确定健康状况:HTTP、TCP、脚本等。可以想象,我们的计划是让 Consul 通过 HTTP 接口联系我们的微服务,更具体地说是在/actuator/health
端点上。运行状况检查位置是在服务注册时配置的,Consul 会定期触发它们,也可以进行自定义。如果服务未能响应或以非正常状态响应(非 2XX),Consul 会将该服务标记为不健康。我们很快就会看到一个实际的例子。如果你想知道更多关于如何配置它们的信息,请阅读 Consul 文档中的检查页面( https://tpd.io/consul-checks
)。
SpringCloud 领事
我们不需要使用 Consul API 来注册服务、定义健康检查或访问注册表来查找服务地址。所有这些功能都被 Spring Cloud Consul 项目抽象了,所以我们所需要的就是在我们的 Spring Boot 应用中包含相应的启动器,并且如果我们选择不使用默认值,就配置一些设置。
我们将使用的 Spring Cloud Consul 版本仍然附带网飞的 Ribbon,作为实现负载平衡器模式的一个包含依赖项。正如我们前面提到的,这个工具处于维护模式,Spring 文档不鼓励使用它(参见 https://tpd.io/no-ribbon
)。我们将在下一节详细介绍我们将使用的替代方案。现在,为了保持我们的项目整洁,我们将使用 Maven 来排除 Ribbon 的 starter 上的传递性依赖。参见清单 8-17 。
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-discovery</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-ribbon</artifactId>
</exclusion>
</exclusions>
</dependency>
Listing 8-17Adding the Spring Cloud Consul Discovery Dependency in Maven
稍后,我们将把这种依赖性添加到网关项目中。对于另外两个微服务,我们首次添加了 Spring Cloud 依赖项。因此,我们需要将dependencyManagement
节点添加到我们的pom.xml
文件和 Spring Cloud 版本中。参见清单 8-18 了解所需的添加内容。
<project>
<!-- ... -->
<properties>
<!-- ... -->
<spring-cloud.version>Hoxton.SR7</spring-cloud.version>
</properties>
<dependencies>
<!-- ... -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-discovery</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-ribbon</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<!-- ... -->
</project>
Listing 8-18Adding Consul Discovery to Multiplication and Gamification
Consul 包含的 Spring Boot 自动配置默认值对我们来说很好:服务器位于http://localhost:8500
。如果您想检查这些默认值,请参见ConsulProperties
的源代码( https://tpd.io/consulprops
)。如果我们需要更改它们,我们可以使用这些和其他在spring.cloud.consul
前缀下可用的属性。关于您可以覆盖的设置的完整列表,请查看 Spring Cloud Consul 的参考文档( https://tpd.io/consulconfig
)。
然而,我们的应用中需要一个新的配置属性:应用名称,由spring.application.name
属性指定。到目前为止,我们还不需要它,但是 Spring Cloud Consul 使用它来注册具有该值的服务。这是我们必须添加到乘法项目内的application.properties
文件中的行:
spring.application.name=multiplication
确保将这一行也添加到游戏化微服务的配置中,这次使用值gamification
。在网关项目中,我们使用 YAML 属性,但是变化是相似的。
spring:
application:
name: gateway
现在,让我们开始乘法和游戏化微服务,看看他们如何注册自己相应的健康检查。记得启动 RabbitMQ 服务器和 Consul 代理。我们仍然需要对网关服务进行一些更改,所以我们还不需要启动它。在应用日志中,您应该会看到新的一行,如清单 8-19 所示(注意,这只有一行,但是非常冗长)。
INFO 53587 --- [main] o.s.c.c.s.ConsulServiceRegistry: Registering service with consul: NewService{id='gamification-8081', name="gamification", tags=[secure=false], address='192.168.1.133', meta={}, port=8081, enableTagOverride=null, check=Check{script='null', dockerContainerID="null", shell="null", interval="10s", ttl="null", http='http://192.168.1.133:8081/actuator/health', method="null", header={}, tcp="null", timeout="null", deregisterCriticalServiceAfter="null", tlsSkipVerify=null, status="null", grpc="null", grpcUseTLS=null}, checks=null}
Listing 8-19Gamification’s Log Line Showing the Consul Registration Details
这一行显示了应用启动时通过 Spring Cloud Consul 进行的服务注册。我们可以看到请求的内容:由服务名和端口组成的唯一 ID、可以对多个实例进行分组的服务名、本地地址,以及通过 HTTP 对 Spring Boot 执行器公开的服务健康端点的地址进行的配置健康检查。默认情况下,Consul 验证该检查的时间间隔设置为 10 秒。
在 Consul 服务器端(参见清单 8-20 ),日志在开发模式下被默认设置为调试级别,因此我们可以看到 Consul 是如何处理这些请求并触发检查的。
[DEBUG] agent.http: Request finished: method=PUT url=/v1/agent/service/register?token=<hidden> from=127.0.0.1:54172 latency=2.424765ms
[DEBUG] agent: Node info in sync
[DEBUG] agent: Service in sync: service=gamification-8081
[DEBUG] agent: Check in sync: check=service:gamification-8081
Listing 8-20Consul Agent Logs
一旦我们使用这个新配置启动了两个微服务,我们就可以访问 Consul 的 UI 来查看更新后的状态。参见图 8-10 。
图 8-10
领事中列出的服务
现在导航到服务,单击“乘法”,然后单击显示在那里的唯一的“乘法”行。您将看到服务的运行状况检查。我们可以验证 Consul 如何从 Spring Boot 应用获得 OK 状态(200)。参见图 8-11 。
图 8-11
服务运行状况检查
我们还可以启动其中一个微服务的第二个实例,看看注册表是如何管理它的。如果您覆盖了端口,您可以从您的 IDE 或者直接从命令行来实现。参见清单 8-21 中如何启动乘法微服务的第二个实例的示例。如你所见,我们可以使用 Spring Boot 的 Maven 插件覆盖服务器端口(更多细节见 https://tpd.io/mvn-sb-props
)。
multiplication $ ./mvnw spring-boot:run -Dspring-boot.run.arguments="--server.port=9080"
[... logs ...]
Listing 8-21Running a Second Instance of then Multiplication Microservice from the Command Line
在领事注册表中,仍然会有一个单独的multiplication
服务。如果我们单击此服务,我们将导航到 instances 选项卡。见图 8-12 。在这里,我们可以看到两个实例,每个实例都有相应的运行状况检查。请注意,当端口为默认值:8080
时,Spring Boot 不会在 ID 中使用该端口。
图 8-12
领事登记处有多个实例
从 Consul 获取服务列表的 API 请求现在检索这两个服务,包括关于乘法应用的两个实例的信息。请参见清单 8-22 以获得简短的回应。
$ http -b :8500/v1/agent/services
{
"gamification-8081": {
"Address": "192.168.1.133",
"EnableTagOverride": false,
"ID": "gamification-8081",
"Meta": {},
"Port": 8081,
"Service": "gamification",
...
"Weights": {
"Passing": 1,
"Warning": 1
}
},
"multiplication": {
"Address": "192.168.1.133",
"EnableTagOverride": false,
"ID": "multiplication",
"Meta": {},
"Port": 8080,
"Service": "multiplication",
...
"Weights": {
"Passing": 1,
"Warning": 1
}
},
"multiplication-9080": {
"Address": "192.168.1.133",
"EnableTagOverride": false,
"ID": "multiplication-9080",
"Meta": {},
"Port": 9080,
"Service": "multiplication",
...
"Weights": {
"Passing": 1,
"Warning": 1
}
}
}
Listing 8-22Retrieving Registered
Services Using the Consul API
你可以想象如果我们没有 Spring 抽象,我们将如何使用 Consul 作为客户端服务。首先,所有服务都需要知道 HTTP 主机和端口才能到达注册中心。然后,如果服务想要与游戏化 API 交互,它将使用 Consul 的服务 API 来获取可用实例的列表。API 还有一个端点来检索给定服务标识符的当前健康状态信息。遵循客户端发现方法,服务将应用负载平衡(例如,循环)并从列表中挑选一个健康的实例。然后,知道了请求的目标地址和端口,客户端服务就可以执行请求。我们不需要实现这个逻辑,因为 Spring Cloud Consul 已经为我们实现了,包括我们将在下一节讨论的负载平衡。
鉴于 Gateway 是我们系统中唯一调用其他服务的服务,我们将在这里实践 Consul 的服务发现逻辑。然而,在此之前,我们需要引入我们仍然缺少的模式:负载平衡器。
Spring Cloud 负载平衡器
我们将实现一种客户端发现方法,其中后端服务查询注册表,并在有多个可用实例时决定调用哪个实例。这最后一部分是我们可以自己构建的逻辑,但是依靠工具来为我们做这件事更容易。Spring Cloud load balancer 项目是 Spring Cloud Commons 的一个组件,它与服务发现集成(Consul 和 Eureka)相集成,以提供一个简单的负载平衡器实现。默认情况下,它会自动配置一个循环负载平衡器,反复检查所有实例。
正如我们前面提到的,网飞的 Ribbon 曾经是实现负载平衡器模式的首选。因为它处于维护模式,所以让我们放弃这个选项,选择 Spring 的负载平衡器实现。Ribbon 和 Spring Cloud 负载平衡器都作为依赖项包含在 Spring Cloud Consul starter 中,但是我们可以使用配置标志或显式排除其中一个依赖项来在两者之间切换(就像我们在添加 Consul starter 时所做的那样)。
为了在两个应用之间进行负载平衡调用,我们可以在创建一个RestTemplate
对象时简单地使用@LoadBalanced
注释。然后,当执行对服务的请求时,我们使用服务名作为 URL 中的主机名。Spring Cloud Consul 和负载平衡器组件将完成剩下的工作,查询注册表并按顺序选择下一个实例。
在我们转向事件驱动的方法之前,我们曾经有一个从乘法服务到游戏化服务的调用,所以让我们以那个为例。清单 8-23 展示了我们如何在客户端——乘法微服务中集成服务发现和负载平衡。这也在图 8-8 中进行了说明。如您所见,我们只需要声明一个用@LoadBalanced
注释配置的RestTemplate
bean,并使用 URL http://gamification/attempts
。注意,您不需要指定端口号,因为在联系注册表之后,它将包含在解析的实例 URL 中。
@Configuration
public class RestConfiguration {
@LoadBalanced
@Bean
RestTemplate restTemplate() {
return new RestTemplate();
}
}
@Slf4j
@Service
public class GamificationServiceClient {
private final RestTemplate restTemplate;
public GamificationServiceClient(final RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
public boolean sendAttempt(final ChallengeAttempt attempt) {
try {
ChallengeSolvedDTO dto = new ChallengeSolvedDTO(attempt.getId(),
attempt.isCorrect(), attempt.getFactorA(),
attempt.getFactorB(), attempt.getUser().getId(),
attempt.getUser().getAlias());
ResponseEntity<String> r = restTemplate.postForEntity(
"http://gamification/attempts", dto,
String.class);
log.info("Gamification service response: {}", r.getStatusCode());
return r.getStatusCode().is2xxSuccessful();
} catch (Exception e) {
log.error("There was a problem sending the attempt.", e);
return false;
}
}
}
Listing 8-23Example of How to Use a RestTemplate with Load Balancing Capabilities
我们不会走这条路,因为我们已经摆脱了微服务之间的 HTTP 调用,但是对于那些需要服务间 HTTP 交互的场景,这是一个很好的方法。通过服务发现和负载平衡,您可以降低失败的风险,因为您增加了至少有一个实例可用于处理这些同步请求的机会。
我们的计划是在网关中集成服务发现和负载平衡。参见图 8-13 。
图 8-13
我们系统中的网关、服务发现和负载平衡
网关中的服务发现和负载平衡
在我们的应用中包含了 Spring Cloud Consul starter 之后,他们正在联系注册中心来发布他们的信息。然而,我们仍然有网关使用显式地址/端口组合来代理请求。是时候在那里集成服务发现和负载平衡了。
首先,我们将 Spring Cloud Consul 依赖项添加到 Gateway 项目中。参见清单 8-24 。同样,我们排除 Ribbon,因为我们将使用 Spring Cloud 负载平衡器。
<dependencies>
<!-- ... -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-discovery</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-ribbon</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
Listing 8-24Adding Spring Cloud Consul Discovery to the Gateway
为了利用这些新模式,我们需要做的就是向application.yml
文件添加一些配置。我们可以将变化分为三组。
-
全局设置:我们给应用命名,并确保我们使用 Spring Cloud 负载平衡器实现。此外,我们将添加一个配置参数来指示服务发现客户机只检索健康的服务。
-
路由配置:我们不使用显式的主机和端口,而是切换到具有 URL 模式的服务名称,这也支持负载平衡。
-
弹性:万一网关无法将请求代理给服务,我们希望它重试几次。我们将详细阐述这个主题。
请参见清单 8-25 ,获取我们新网关配置(application.yml
)的完整源代码,包括这些更改。
server:
port: 8000
spring:
application:
name: gateway
cloud:
loadbalancer:
ribbon:
# Not needed since we excluded the dependency, but
# still good to add it here for better readability
enabled: false
consul:
enabled: true
discovery:
# Get only services that are passing the health check
query-passing: true
gateway:
routes:
- id: multiplication
uri: lb://multiplication/
predicates:
- Path=/challenges/**,/attempts,/attempts/**,/users/**
- id: gamification
uri: lb://gamification/
predicates:
- Path=/leaders
globalcors:
cors-configurations:
'[/**]':
allowedOrigins: "http://localhost:3000"
allowedHeaders:
- "*"
allowedMethods:
- "GET"
- "POST"
- "OPTIONS"
default-filters:
- name: Retry
args:
retries: 3
methods: GET,POST
Listing 8-25Gateway Configuration Including Load Balancing
当query-passing
参数设置为true
时,Spring 实现将使用带有过滤器的 Consul API 来检索那些通过健康检查的服务。我们只想将请求代理到健康的实例。在服务不经常轮询更新的服务列表的情况下,值false
可能有意义。在这种情况下,最好获得完整的列表,因为我们不知道它们的最新状态,并且有处理不健康实例的机制(例如重试,我们很快就会知道)。
最相关的更改是应用于 URL 的更改。如你所见,现在我们使用类似于lb://multiplication/
的 URL。由于我们添加了 Consul 客户端,应用将使用服务 API 将服务名multiplication
解析为可用的实例。特殊方案lb
告诉 Spring 应该使用负载均衡器。
除了基本配置之外,我们还添加了一个适用于所有请求的网关过滤器,因为它位于default-filters
节点下:重试GatewayFilter
(详见 https://tpd.io/gwretry
)。该过滤器拦截错误响应,并再次透明地重试请求。当与负载平衡器结合使用时,这意味着请求将被代理到下一个实例,因此我们很容易获得一个良好的弹性模式(重试)。我们配置这个过滤器,使我们使用的 HTTP 方法最多重试三次,这足以涵盖大多数失败情况。如果所有重试都失败,网关会向客户端返回一个错误响应(服务不可用),因为它无法代理请求。
您可能想知道为什么需要在服务发现客户机中包含重试,尽管我们配置它只获取健康的实例。理论上,如果他们都很健康,所有的呼叫都会成功。为了理解这一点,我们必须回顾一下 Consul(以及其他典型的服务发现工具)是如何工作的。每个服务都向自己注册一个配置好的健康检查,每十秒钟轮询一次(默认值,但我们也可以更改它)。注册中心无法实时了解服务何时未准备好处理流量。可能的情况是,Consul 成功地检查了一个给定实例的健康状况,然后一个实例立即停止运行。注册中心将这个实例列为健康状态几秒钟(对于我们的配置几乎是 10 秒钟),直到它在下一次检查中注意到它不可用。因为我们也想在这段时间内最小化请求错误,所以我们可以利用重试模式来处理这些情况。一旦注册表得到更新,网关将不会在服务列表中获得不健康的实例,因此不再需要重试。请注意,缩短检查之间的时间可以减少错误数量,但会增加网络流量。
断路器
在某些情况下,当您知道某个给定的服务失败后,您可能不想继续尝试该服务的未来请求。通过这样做,您可以节省响应超时所浪费的时间,并减轻目标服务的潜在拥塞。当没有其他合适的弹性机制(如带有健康检查的服务注册中心)时,这对于外部服务调用尤其有用。
对于这些情况,您可以使用断路器。当一切正常时,电路闭合。在可配置的请求失败次数后,电路变为打开。然后,甚至不尝试请求,断路器实现返回预定义的响应。有时,电路可以切换到半开以再次检查目标服务是否工作。在这种情况下,电路将转换到关闭。如果仍然失败,它返回到打开状态。查看 https://tpd.io/cbreak
了解更多关于此模式的信息。
*在应用新配置后,网关微服务连接到 Consul,以查找其他微服务的可用实例及其网络位置。然后,它基于 Spring Cloud 负载平衡器中包含的简单循环算法来平衡负载。再次检查图 8-8 以获得完整的概述。
鉴于我们添加了 Consul starter,网关服务也在 Consul 中注册了自己。这不是绝对必要的,因为其他服务不会调用网关,但是检查它的状态对我们来说仍然是有用的。或者,我们可以将配置参数spring.cloud.consul.discovery.register
设置为false
以继续使用服务发现客户端特性,但禁用网关服务的注册。
在我们的设置中,所有外部 HTTP 流量(不是微服务之间的)都通过localhost:8000
通过网关微服务。在生产环境中,我们通常会在端口 80(或者 443,如果我们使用 HTTPS)上公开这个 HTTP 接口,并使用一个 DNS 地址(例如,bookgame.tpd.io
)指向我们的服务器所在的 IP。然而,将有一个单一的入口供公众进入,这使得这项服务成为我们系统的一个关键部分。它必须尽可能地高度可用。如果网关服务瘫痪,我们的整个系统都会瘫痪。
为了降低风险,我们可以引入 DNS 负载平衡(指向多个 IP 地址的主机名)来增加网关的冗余。然而,当其中一台主机没有响应时,它依靠客户端(如浏览器)来管理 IP 地址列表和处理故障转移(参见 https://tpd.io/dnslbq
了解解释)。我们可以将其视为网关之上的额外一层,它增加了客户端发现(对 IP 地址列表的 DNS 解析)、负载平衡(从列表中选择一个 IP 地址)和容错(在超时或出错后尝试另一个 IP 地址)。这不是典型的方法。
亚马逊、微软或谷歌等云提供商提供路由和负载平衡模式作为具有高可用性保证的托管服务,因此这也是确保网关始终保持运行的一种替代方法。另一方面,Kubernetes 允许您在自己的网关上创建一个负载均衡器,因此您也可以向该层添加冗余。我们将在本章末尾看到更多关于平台实现的内容。
体验服务发现和负载平衡
让我们将服务发现和负载平衡特性付诸实践。
在运行我们的应用之前,我们将为UserController
(乘法)和LeaderBoardController
(游戏化)添加一个日志行,以便在日志中快速查看与它们的 API 的交互。参见清单 8-26 和 8-27 。
@Slf4j
@RestController
@RequestMapping("/leaders")
@RequiredArgsConstructor
class LeaderBoardController {
private final LeaderBoardService leaderBoardService;
@GetMapping
public List<LeaderBoardRow> getLeaderBoard() {
log.info("Retrieving leaderboard");
return leaderBoardService.getCurrentLeaderBoard();
}
}
Listing 8-27Adding
a Log Line to LeaderBoardController
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/users")
public class UserController {
private final UserRepository userRepository;
@GetMapping("/{idList}")
public List<User> getUsersByIdList(@PathVariable final List<Long> idList) {
log.info("Resolving aliases for users {}", idList);
return userRepository.findAllByIdIn(idList);
}
}
Listing 8-26Adding a Log Line to UserController
现在,让我们运行完整的系统。所需的步骤与之前相同,只是添加了运行服务注册中心的新命令:
-
运行 RabbitMQ 服务器。
-
在开发模式下运行 Consul 代理。
-
启动乘法微服务。
-
启动游戏化微服务。
-
启动新的网关微服务。
-
运行前端 app。
一旦我们运行了最小化设置,我们就为运行业务逻辑的每个服务添加了一个额外的实例:乘法和游戏化。请记住,您需要覆盖server.port
属性。从一个终端,你可以在两个独立的标签或窗口中使用清单 8-28 中显示的命令(注意你运行每个命令的文件夹是不同的)。
multiplication $ ./mvnw spring-boot:run -Dspring-boot.run.arguments="--server.port=9080"
[... logs ...]
gamification $ ./mvnw spring-boot:run -Dspring-boot.run.arguments="--server.port=9081"
[... logs ...]
Listing 8-28Running Two Additional Instances of Multiplication and Gamification
所有实例都将在注册表中发布它们的详细信息(Consul)。此外,它们都可以作为注册中心客户机来检索给定服务名的不同实例的详细信息以及它们的位置。在我们的系统中,这只能从网关完成。启动图 8-14 中的两个额外实例后,查看 Consul UI 中的服务概述(节点/服务部分)。
图 8-14
领事:多个实例
验证网关的负载平衡器是否工作很简单:检查两个游戏化服务实例的日志。通过新添加的日志行,您可以快速看到两者如何从 UI 获得与排行榜更新相关的交替请求。如果您刷新浏览器页面几次以强制新的质询请求,您会看到类似的行为。清单 8-29 、 8-30 、 8-31 和 8-32 显示了乘法实例和游戏化实例的日志的同一时间窗口的摘录。如您所见,请求每五秒钟在可用实例之间交替。
2020-08-29 09:04:58.107 INFO 10222 --- [nio-9081-exec-4] m.b.g.game.LeaderBoardController : Retrieving leaderboard
2020-08-29 09:05:06.927 INFO 10222 --- [nio-9081-exec-6] m.b.g.game.LeaderBoardController : Retrieving leaderboard
2020-08-29 09:05:14.010 INFO 10222 --- [nio-9081-exec-8] m.b.g.game.LeaderBoardController : Retrieving leaderboard
Listing 8-32Logs for Gamification, Second Instance (Port 9081)
2020-08-29 09:05:03.208 INFO 9928 --- [nio-8081-exec-6] m.b.g.game.LeaderBoardController : Retrieving leaderboard
2020-08-29 09:05:09.006 INFO 9928 --- [nio-8081-exec-8] m.b.g.game.LeaderBoardController : Retrieving leaderboard
2020-08-29 09:05:19.014 INFO 9928 --- [io-8081-exec-10] m.b.g.game.LeaderBoardController : Retrieving leaderboard
Listing 8-31Logs for Gamification, First Instance (Port 8081)
2020-08-29 09:05:09.009 INFO 10138 --- [nio-9080-exec-7] m.b.m.challenge.ChallengeController : Generating a random challenge: Challenge(factorA=58, factorB=96)
2020-08-29 09:05:14.040 INFO 10138 --- [nio-9080-exec-8] m.b.multiplication.user.UserController : Resolving aliases for users [125, 49, 72, 60, 101, 1, 96, 107, 3, 45, 6, 9, 14, 123]
2020-08-29 09:05:24.042 INFO 10138 --- [io-9080-exec-10] m.b.multiplication.user.UserController : Resolving aliases for users [125, 49, 72, 60, 101, 1, 96, 107, 3, 45, 6, 9, 14, 123]
Listing 8-30Logs for Multiplication, Second Instance (Port 9080)
2020-08-29 09:05:06.957 INFO 9999 --- [nio-8080-exec-6] m.b.multiplication.user.UserController : Resolving aliases for users [125, 49, 72, 60, 101, 1, 96, 107, 3, 45, 6, 9, 14, 123]
2020-08-29 09:05:09.090 INFO 9999 --- [nio-8080-exec-7] m.b.multiplication.user.UserController : Resolving aliases for users [125, 49, 72, 60, 101, 1, 96, 107, 3, 45, 6, 9, 14, 123]
2020-08-29 09:05:19.033 INFO 9999 --- [nio-8080-exec-9] m.b.multiplication.user.UserController : Resolving aliases for users [125, 49, 72, 60, 101, 1, 96, 107, 3, 45, 6, 9, 14, 123]
Listing 8-29Logs for Multiplication, First Instance (Port 8080)
这是一个伟大的成就:我们扩大了我们的系统,一切都像预期的那样工作。HTTP 流量现在在所有实例中均衡分布,类似于我们的 RabbitMQ 设置在消费者中分发消息的方式。我们刚刚顺利地将系统容量增加了一倍。
实际上,我们可以启动任意多的两个微服务实例,负载将透明地分布在所有微服务上。此外,通过网关,我们使我们的 API 客户端不知道我们的内部服务,我们可以轻松地实现跨领域的关注,如用户认证或监控。
我们还应该检查我们是否实现了其他非功能性需求:弹性、高可用性和容错。让我们开始制造一些混乱。
为了检查当服务变得意外不可用时会发生什么,我们可以通过 IDE 或者在终端中使用 Ctrl-C 信号来停止它们。然而,这并没有涵盖我们在现实生活中可能遇到的所有潜在事件。当我们这样做时,Spring Boot 应用会正常停止,因此它有机会从 Consul 中注销自己。我们想模拟一个重大事件,比如网络问题或服务突然终止。我们必须模仿的最佳选择是终止给定实例的 Java 进程。要知道要终止哪个进程,我们可以检查日志。默认的 Spring Boot 回退配置在每个日志行的日志级别(例如,INFO
)后打印进程 ID。例如,这一行表示我们正在运行游戏化微服务,进程 ID 为97817
:
2020-07-19 09:10:27.279 INFO 97817 --- [main] m.b.m.GamificationApplication : Started GamificationApplication in 5.371 seconds (JVM running for 11.054)
在 Linux 或 Mac 系统中,您可以使用kill
命令终止进程,传递参数-9
来强制立即终止。
$ kill -9 97817
如果您正在运行 Windows,您可以使用带有/F
标志的taskkill
命令来强制终止它。
> taskkill /PID 97817 /F
既然你知道了如何制造破坏,那就干掉游戏化微服务的一个实例进程。确保您在浏览器中打开了 UI,以便它不断向后端发出请求。您将看到在您杀死其中一个实例后,另一个实例如何接收所有请求并成功响应它们。用户甚至不会注意到这一点。在游戏化服务中调用 API 的排行榜仍然在工作。用户也可以发送新的挑战;所有尝试都将在唯一可用的实例中结束。这里发生的事情是,网关中的重试过滤器透明地执行第二个请求,由于负载均衡器,该请求被路由到健康的实例。参见图 8-15 。
图 8-15
弹性:重试模式
我们还想验证我们引入的模式是如何协作实现这一成功结果的。为此,让我们暂时删除网关中的重试过滤器配置。参见清单 8-33 。
# We can comment this block of the configuration
# default-filters:
# - name: Retry
# args:
# retries: 3
# methods: GET,POST
Listing 8-33Commenting a Block of Configuration in the Gateway
然后,重新构建并重启网关服务(以应用新的配置),并重复类似的场景。确保再次启动游戏化的第二个实例,并给网关服务一些时间来开始向它路由流量。然后,在查看 UI 时,杀死它的一个实例。这次您将看到的是,其他所有请求都无法完成,导致显示排行榜时出现交替错误。发生这种情况是因为 Consul 需要一些时间(配置的健康检查间隔)来检测服务是否关闭。同时,网关仍然得到两个健康的实例,并将一些请求代理到一个失效的服务器。参见图 8-16 。我们刚刚删除的重试机制透明地处理了这个错误,并向列表中的下一个实例发出第二个请求,这个实例仍然在工作。
图 8-16
弹性:无重试模式
它在我的机器上工作
请注意,如果您在运行状况检查之前意外地终止了进程,Consul 将会立即注意到这个问题。在这种情况下,您可能看不到 UI 中的错误。您可以再次尝试相同的场景,或者您可以在 Spring Cloud Consul 中配置一个更长的健康检查间隔(通过应用属性),这样您就有更大的机会重现这个错误场景。
如果我们导航到服务,Consul registry UI 也会反映出运行状况检查失败。参见图 8-17 。
图 8-17
领事 UI:终止服务后运行状况检查失败
我们完成了学习道路上的一个重要里程碑:我们在微服务架构中实现了可伸缩性。此外,我们通过使用服务发现注册表的负载平衡器实现了适当的容错,该服务发现注册表知道我们系统中不同组件的健康状况。我希望实用的方法能帮助你理解所有这些关键概念。
每个环境的配置
正如在第二章中介绍的,Spring Boot 的一个主要优点是能够配置概要文件。配置文件是一组您可以根据需要启用的配置属性。例如,在本地测试时,您可以在连接到本地 RabbitMQ 服务器和在生产环境中部署真正的 RabbitMQ 服务器之间进行切换。
为了引入一个新的rabbitprod
概要文件,我们可以创建一个名为application-rabbitprod.properties
的文件。Spring Boot 使用application-{profile}
命名约定(用于properties
和 YAML 格式)来允许我们在单独的文件中定义概要文件。参见清单 8-34 中我们可以包括的一些示例属性。如果我们将此配置文件用于生产环境,我们可能希望使用不同的凭证、要连接的节点集群、安全接口等。
spring.rabbitmq.addresses=rabbitserver1.tpd.network:5672,rabbitserver2.tpd.network:5672
spring.rabbitmq.connection-timeout=20s
spring.rabbitmq.ssl.enabled=true
spring.rabbitmq.username=produser1
Listing 8-34Example of a Separate Properties File to Override Default Values in Production
当我们在目标环境中启动应用时,我们必须确保启用这个概要文件。为此,我们使用属性spring.profiles.active
。Spring Boot 将基本配置(在application.properties
中)与该文件中的值聚合在一起。在我们的例子中,所有额外的属性都将被添加到最终的配置中。我们可以使用 Spring Boot 的 Maven 插件命令来为乘法微服务启用这个新的配置文件:
multiplication $ ./mvnw spring-boot:run -Dspring-boot.run.arguments="--spring.profiles.active=rabbitprod"
正如您所想象的,我们所有的微服务在每个环境中都有很多通用的配置值。不仅 RabbitMQ 的连接细节可能是相同的,而且我们还添加了一些额外的值,比如交换名(amqp.exchange.attempts
)。这同样适用于数据库的通用配置,或者我们希望应用于所有微服务的任何其他 Spring Boot 配置。
我们可以将这些值保存在每个微服务、每个环境和每个工具的单独文件中。例如,这四个文件可能包括在登台和生产环境中对 RabbitMQ 和 H2 数据库的不同配置。
-
application-rabbitprod.properties
-
application-databaseprod.properties
-
application-rabbitstaging.properties
-
application-databasestaging.properties
然后,我们可以在任何需要的地方跨微服务复制它们。通过将配置分组到单独的概要文件中,我们可以很容易地重用这些值。
然而,保留所有这些副本仍然需要大量的维护工作。如果我们想改变这些公共配置块中的一个值,我们必须替换每个项目文件夹中的相应文件。
一个更好的方法是将这个配置放在系统中的一个公共位置,让应用在启动前同步它的内容。然后,我们为每个环境保留一个集中的配置,所以我们只需要调整一次值。参见图 8-18 。好消息是,这是一种众所周知的模式,被称为外化(或集中式)配置,因此有现成的解决方案来构建集中式配置服务器。
图 8-18
集中式配置:概述
在为 Spring 寻找配置服务器模式时,通过简单的 web 搜索得出的第一个解决方案是 Spring Cloud Config Server 项目。这是 Spring Cloud 家族中包含的一个原生实现,它允许您将一组配置文件分布在文件夹中,并通过 REST API 公开。在客户端,使用这种依赖关系的项目访问配置服务器并请求相应的配置资源,这取决于它们的活动概要文件。对于我们的系统来说,这个解决方案的唯一缺点是我们需要创建另一个微服务来充当配置服务器并公开集中的文件。
另一种方法是使用 Consul KV,这是默认 Consul 包中包含的一个特性,我们还没有探索它。Spring Cloud 还集成了这个工具来实现一个集中式配置服务器。我们将选择这种方法来重用组件,并使我们的系统尽可能简单,Consul 结合了服务发现、健康检查和集中配置。
咨询中的配置
Consul KV 是随 Consul 代理一起安装的键/值存储。与服务发现特性一样,我们可以通过 REST API 和用户界面来访问该功能。当我们将 Consul 设置为集群时,该特性也受益于复制,因此由于服务无法获取其配置而导致数据丢失或停机的风险更小。
这个简单的功能也可以从浏览器访问,因为它包含在我们已经安装的 consul 代理中。代理运行时,导航到http://localhost:8500/ui/dc1/kv
(键/值选项卡)。现在,单击创建。您将看到编辑器创建了一个新的键/值对,如图 8-19 所示。
图 8-19
Consul:创建键/值对
我们可以使用 toggle 在代码和普通编辑器之间切换。代码编辑器在一些符号中支持语法着色,包括 YAML。注意,如文本字段下的图 8-19 所示,如果我们在键名末尾添加一个正斜杠字符,我们也可以创建文件夹。我们很快就会付诸实施。
Consul KV REST API 还允许我们通过 HTTP 调用创建键/值对和文件夹,并使用它们的键名检索它们。如果你想知道它是如何工作的,可以查看 http://tpd.io/kv-api
。与服务发现特性一样,我们不需要直接与这个 API 交互,因为我们将使用一个 Spring 抽象来与 Consul KV:Spring Cloud Consul Config 通信。
SpringCloud 领事配置
用 Consul KV 实现集中配置的 SpringCloud 项目是 SpringCloud Consul Config。要使用这个模块,我们需要向我们的项目添加一个新的 Spring Cloud 依赖项:spring-cloud-starter-consul-config
。这个工件包括自动配置类,这些类在启动我们的应用的早期阶段,即特殊的“引导”阶段,试图找到 Consul 代理并读取相应的 KV 值。它使用这个阶段是因为我们希望 Spring Boot 将集中配置值应用于其初始化的其余部分(例如,连接到 RabbitMQ)。
Spring Cloud Consul Config 期望每个配置文件映射到 KV store 中的一个给定键。它的值应该是一组 YAML 或普通格式的 Spring Boot 配置值(.properties
)。
我们可以配置一些设置来帮助我们的应用在服务器中找到相应的键。这些是最相关的:
-
前缀:这是领事 KV 中存储所有概要文件的根文件夹。默认值为
config
。 -
格式:指定值(Spring Boot 配置)是 YAML 还是属性语法。
-
默认上下文:这是所有应用作为公共属性使用的文件夹的名称。
-
配置文件分隔符:按键可以组合多个配置文件。在这种情况下,您可以指定想要用作分隔符的字符(例如,用逗号
prod,extra-logging
)。 -
数据键:这是保存属性或 YAML 内容的键的名称。
与配置服务器设置相关的所有配置值必须放在我们每个应用的单独文件中,文件名为bootstrap.yml
或bootstrap.properties
(取决于我们选择的格式)。见图 8-20 。
图 8-20
配置服务器属性:解释
请记住,如上图所示,连接到配置服务器的应用配置(在引导文件中)与合并本地属性(如application.properties
)和从配置服务器下载的属性所产生的应用配置是不同的。因为第一个是元配置,它不能从服务器下载,所以我们必须在相应的bootstrap
配置文件中跨项目复制这些值。
如果没有例子,所有这些概念都很难理解,让我们用我们的系统来解释 Consul Config 是如何工作的。
实施集中配置
代码源
来自 Consul 的集成集中式配置解决方案的代码源位于存储库chapter08c
中。
首先,我们需要将新的启动器添加到乘法、游戏化和网关微服务中。参见清单 8-35 。
<dependencies>
<!-- ... -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-config</artifactId>
</dependency>
</dependencies>
Listing 8-35Adding the Spring Cloud Consul Config Dependency to Our Microservices
通过这样做,我们的应用将在引导阶段尝试连接到 Consul,并使用 Consul KV 自动配置中提供的缺省值从 KV 存储中获取配置文件属性。但是,我们将覆盖其中的一些设置,而不是使用默认值,因为这样会使解释更加清晰。
在乘法和游戏化项目中我们使用的是properties
格式,所以让我们保持一致,在同一层创建一个单独的文件,命名为bootstrap.properties
。在这两个应用中,我们将设置相同的设置。见清单 8-36 。
spring.cloud.consul.config.prefix=config
spring.cloud.consul.config.format=yaml
spring.cloud.consul.config.default-context=defaults
spring.cloud.consul.config.data-key=application.yml
Listing 8-36The new bootstrap.properties File in Multiplication and Gamification
注意,我们选择 YAML 作为远程配置的格式,但是我们的本地文件是.properties
格式。那根本不是问题。Spring Cloud Consul Config 可以将包含在 remote application.yml
键中的值与以不同格式存储在本地的值进行合并。
然后,我们在 Gateway 项目的一个bootstrap.yml
文件中创建等效的设置,其中我们使用 YAML 进行应用配置。参见清单 8-37 。
spring:
cloud:
consul:
config:
data-key: application.yml
prefix: config
format: yaml
default-context: defaults
Listing 8-37The New bootstrap.yml File in the Gateway Project
通过这些设置,我们的目标是将所有配置存储在 Consul KV 中名为config
的根文件夹中。在里面,我们将有一个defaults
文件夹,其中可能包含一个名为application.yml
的密钥,其配置适用于我们所有的微服务。我们可以为每个应用或者我们想要使用的应用和配置文件的每个组合创建额外的文件夹,并且每个文件夹都可以包含带有应该添加或覆盖的属性的application.yml
键。为了避免在配置服务器中混淆格式,我们将坚持使用 YAML 语法。再次回顾之前的图 8-20 以更好地理解配置的整体结构。到目前为止,我们所做的是将bootstrap
文件添加到乘法、游戏化和网关中,这样它们就可以连接到配置服务器并找到外部化的配置(如果有的话)。为了支持这种行为,我们也向所有这些项目添加了 Spring Cloud Consul Config starter 依赖项。
举一个更有代表性的例子,我们可以创建清单 8-38 中所示的层次结构,作为 Consul KV 中的文件夹和键。
+- config
| +- defaults
| \- application.yml
| +- defaults,production
| \- application.yml
| +- defaults,rabbitmq-production
| \- application.yml
| +- defaults,database-production
| \- application.yml
| +- multiplication,production
| \- application.yml
| +- gamification,production
| \- application.yml
Listing 8-38An Example Configuration Structure in the Configuration Server
然后,如果我们使用等于production,rabbitmq-production,database-production
的活动配置文件列表运行乘法应用,处理顺序如下(从低到高的优先级):
-
基线值是那些包含在访问配置服务器的项目的本地
application.properties
中的值,在这个例子中是乘法。 -
然后,Spring Boot 合并并覆盖包含在
defaults
文件夹中的application.yml
键中的远程值,因为它适用于所有服务。 -
下一步是合并所有活动概要文件的默认值。这意味着所有符合
defaults,{profile}
模式的文件:defaults,production
、defaults,rabbitmq-production
、defaults,database-production
。请注意,如果指定了多个概要文件,则最后一个概要文件的值有效。 -
之后,它会按照模式
{application},{profile}
为相应的应用名称和活动配置文件寻找更具体的设置。在我们的例子中,键multiplication,production
匹配模式,所以它的配置值将被合并。优先顺序与之前相同:枚举中的最后一个配置文件胜出。
请参见图 8-21 中的直观表示,它肯定会帮助您理解所有配置文件是如何应用的。
图 8-21
配置堆栈示例
因此,结构化配置值的实用方法如下:
-
当您想要为所有环境的所有应用添加全局配置时,例如定制 JSON 序列化时,请使用
defaults
。 -
使用带有代表一个
{tool}-{environment}
对的概要文件名的defaults,{profile}
,为每个环境的给定工具设置公共值。例如,在我们的例子中,RabbitMQ 连接值可以包含在rabbitmq-production
中。 -
使用配置文件名为
{environment}
的{application},{profile}
,为给定环境中的应用设置特定设置。例如,我们可以使用multiplication,production
中的属性来减少生产中乘法微服务的日志记录。
实践中的集中配置
在上一节中,我们将新的 starter 依赖项添加到我们的项目中,并添加了额外的bootstrap
配置属性来覆盖一些 Consul 配置默认值。如果我们启动一个服务,它将连接到 Consul,并尝试使用 Consul 的键/值 API 检索配置。例如,在乘法应用的日志中,我们会看到一个新的行,其中包含了 Spring 试图在远程配置服务器(Consul)中找到的属性源的列表。参见清单 8-39 。
INFO 54256 --- [main] b.c.PropertySourceBootstrapConfiguration : Located property source: [BootstrapPropertySource {name='bootstrapProperties-config/multiplication/'}, BootstrapPropertySource {name='bootstrapProperties-config/defaults/'}]
Listing 8-39Multiplication Logs, Indicating the Default Configuration Sources
该日志行可能会产生误导,因为在打印该行时,那些属性源并没有真正定位。只是候选人名单。他们的名字符合我们之前描述的模式。假设我们在启动乘法应用时没有启用任何配置文件,它将尝试在config/defaults
和config/multiplication
下查找配置。正如本练习所证明的,我们不需要在 Consul 中创建匹配所有可能候选项的键。不存在的键将被忽略。
让我们开始在 Consul 中创建一些配置。在 UI 的 Key/Value 选项卡上,单击 Create 并输入config/
来创建根文件夹,其名称与我们在设置中配置的名称相同。由于我们在最后添加了/
字符,Consul 知道它必须创建一个文件夹。见图 8-22 。
图 8-22
Consul:创建配置根文件夹
现在,通过单击新创建的项目导航到config
文件夹,并创建一个名为defaults
的子文件夹。见图 8-23 。
图 8-23
Consul:创建默认文件夹
再次通过单击导航到新创建的文件夹。您将会看到config/defaults
的内容,目前是空的。在这个文件夹中,我们必须创建一个名为application.yml
的键,并将我们希望默认应用于所有应用的值放在那里。请注意,我们决定使用一个看起来像文件名的键名,以便更好地区分文件夹和配置内容。让我们添加一些日志配置来启用 Spring 包的DEBUG
级别,该包的类输出一些有用的环境信息。参见图 8-24 。
图 8-24
Consul:将配置添加到默认值
乘法应用现在应该选择这个新属性。为了验证它,我们可以重启它并检查日志,在这里我们将看到对org.springframework.core.env
包的额外日志记录,特别是来自PropertySourcesPropertyResolver
类的:
DEBUG 61279 --- [main] o.s.c.e.PropertySourcesPropertyResolver : Found key 'spring.h2.console.enabled' in PropertySource 'configurationProperties' with value of type String
这证明服务到达了集中配置服务器(Consul)并应用了包含在现有预期密钥中的设置,在本例中为config/defaults
。
为了更有趣,让我们为乘法应用启用一些配置文件。从命令行,您可以执行以下操作:
multiplication $ ./mvnw spring-boot:run -Dspring-boot.run.arguments="--spring.profiles.active=production,rabbitmq-production"
使用这个命令,我们用production
和rabbitmq-production
概要文件运行应用。日志显示了要查找的结果候选键。见清单 8-40 。
INFO 52274 --- [main] b.c.PropertySourceBootstrapConfiguration : Located property source: [BootstrapPropertySource {name='bootstrapProperties-config/multiplication,rabbitmq-production/'}, BootstrapPropertySource {name='bootstrapProperties-config/multiplication,production/'}, BootstrapPropertySource {name='bootstrapProperties-config/multiplication/'}, BootstrapPropertySource {name='bootstrapProperties-config/defaults,rabbitmq-production/'}, BootstrapPropertySource {name='bootstrapProperties-config/defaults,production/'}, BootstrapPropertySource {name='bootstrapProperties-config/defaults/'}]
Listing 8-40Multiplication Logs, Indicating All Candidate Property Sources After Enabling Extra Profiles
为了更好的可视化,让我们将属性源名称提取为一个列表。该列表遵循与日志中相同的顺序,从最高优先级到最低优先级。
-
config/multiplication,rabbitmq-production/
-
config/multiplication,production/
-
config/multiplication/
-
config/defaults,rabbitmq-production/
-
config/defaults,production/
-
config/defaults/
正如我们在上一节中所描述的,Spring 寻找由组合defaults
和每个概要文件产生的键,然后它寻找应用名称和每个概要文件的组合。到目前为止,我们只添加了一个键config/defaults
,所以这是服务选择的唯一一个键。
在现实生活中,我们可能不希望将日志添加到生产环境中的所有应用中。为了实现这一点,我们可以配置production
概要文件来恢复我们之前所做的。因为这个配置有更高的优先级,它将覆盖以前的值。转到 Consul UI,在config
文件夹中创建一个名为defaults,production
的密钥。在内部,您必须创建一个键application.yml
,它的值应该是 YAML 配置,以将包的日志级别设置回INFO
。见图 8-25 。
图 8-25
咨询:将配置添加到默认值,生产
当我们使用相同的最后一个命令(启用production
概要文件)重新启动应用时,我们将看到该包的调试日志记录是如何消失的。
请记住,与我们在这个简单的日志示例中所做的一样,我们可以添加 YAML 值来调整我们的 Spring Boot 应用中的任何其他配置参数,以使它们适应production
环境。另外,请注意我们如何使用前面列出的六种可能的组合中的任何一种来处理配置的范围,这是我们通过添加两个活动概要文件得到的。例如,我们可以在名为defaults,rabbitmq-production
的键中添加只适用于 RabbitMQ 的值。最具体的组合是multiplication,rabbitmq-production
和multiplication,production
。如果需要,再次查看图 8-21 以获得一些视觉帮助。
为了证明配置不限于日志记录,让我们假设我们希望在部署到生产时在不同的端口(例如 10080)上运行乘法微服务。为了让它工作,我们只需要在 Consul 的multiplication,production
键中添加一个application.yml
键,并更改server.port
属性。参见图 8-26 。
图 8-26
领事:增加配置到乘法,生产
下次我们在生产配置文件处于活动状态时启动乘法应用,我们将看到它是如何在这个新指定的端口上运行的:
INFO 29019 --- [main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 10080 (http) with context path ''
通过这个练习,我们完成了对集中式配置模式的概述。现在,我们知道了如何最大限度地减少公共配置的维护,以及如何使应用适应它们运行的环境。请参见图 8-27 了解我们系统的更新架构视图,包括新的配置服务器。
图 8-27
高级概述:配置服务器
请注意,应用现在在启动时依赖于配置服务器。幸运的是,我们可以将 Consul 配置为在生产中高度可用,正如我们在讨论服务发现模式时提到的(检查 https://tpd.io/consulprod
)。此外,Spring Cloud Consul 在默认情况下使用重试机制,所以我们的应用会在 consult 不可用时不断重试连接。这种依赖性仅在开始时存在;如果 Consul 在您的应用运行时关闭,它们会继续使用最初加载的配置。
注意:咨询配置和测试
默认情况下,我们项目中的集成测试将使用相同的应用配置。这意味着如果 Consul 没有运行,我们的控制器测试和 Initializr 创建的默认@SpringBootTest
将会失败,因为它们一直在等待配置服务器可用。您也可以很容易地禁用测试的 Consul Config 如果你好奇的话,可以查看一下 https://github.com/Book-Microservices-v2/chapter08c
。
集中式日志
我们的系统中已经有多个生成日志的组件(乘法、游戏化、网关、咨询和 RabbitMQ),其中一些可能运行多个实例。大量日志输出独立运行,这使得很难获得系统活动的整体视图。如果用户报告一个错误,很难找出哪个组件或实例失败了。在单个屏幕上安排多个日志窗口暂时会有帮助,但当您的微服务实例数量增加时,这不是一个可行的解决方案。
为了正确维护像我们的微服务架构这样的分布式系统,我们需要一个中心位置,在那里我们可以访问所有的聚合日志并在它们之间进行搜索。
日志聚合模式
基本上,我们的想法是将应用的所有日志输出发送到系统中的另一个组件,该组件将使用它们并将它们放在一起。此外,我们希望将这些日志保存一段时间,因此这个组件应该有一个数据存储。理想情况下,我们应该能够浏览这些日志,搜索并过滤出每个微服务、实例、类等的消息。为此,许多工具都提供了连接到聚合日志存储的用户界面。参见图 8-28 。
图 8-28
日志聚集:概述
在实现集中式日志记录方法时,一个常见的最佳实践是让应用逻辑不知道这种模式。服务应该使用公共接口(例如 Java 中的Logger
)输出消息。将这些日志传送到中央聚合器的日志代理独立工作,捕获应用产生的输出。
市场上有这种模式的多种实现,包括免费的和付费的解决方案。其中最受欢迎的是 ELK stack,这是 Elastic ( https://tpd.io/elastic
)产品组合的别名:Elasticsearch(具有强大文本搜索功能的存储系统)、Logstash(将来自多个来源的日志引导到 Elasticsearch 的代理)和 Kibana(管理和查询日志的 UI 工具)。
尽管随着时间的推移,设置 ELK 堆栈变得越来越容易,但这仍然不是一项简单的任务。因此,我们不会在本书中使用 ELK 实现,因为它很容易扩展到涵盖整个章节。无论如何,我建议您在阅读完这本书后查看 ELK 文档( https://tpd.io/elk
),这样您就可以学习如何建立一个生产就绪的日志系统。
日志集中化的简单解决方案
代码源
本章的其余代码源在资源库chapter08d
中。它包括添加集中式日志、分布式跟踪和容器化的变化。
我们要做的是建立一个新的微服务来聚合所有 Spring Boot 应用的日志。简单地说,它没有数据层来保存日志;它只是从其他服务接收日志行,并将它们一起打印到标准输出中。这个基本解决方案将帮助我们演示这个模式和下一个模式,分布式跟踪。
为了引导日志输出,我们将使用系统中已经有的工具:RabbitMQ。为了捕获应用中的每一行日志并作为 RabbitMQ 消息发送,我们将受益于 Logback,这是我们在 Spring Boot 使用的日志实现。假设这个工具是由外部配置文件驱动的,我们不需要修改应用中的代码。
在 Logback 中,将日志行写到特定目的地的逻辑部分被称为追加器。这个日志库包括一些内置的附加器,用于将消息打印到控制台(ConsoleAppender
)或文件(FileAppender
和RollingFileAppender
)。我们不需要配置它们,因为 Spring Boot 在其依赖项中包含了一些默认的回退配置,并且还设置了打印的消息模式。
对我们来说,好消息是 Spring AMQP 提供了一个 Logback AMQP 日志附加器,它完全满足了我们的需求:它接受每个日志行,并向 RabbitMQ 中的给定交换生成一条消息,带有我们可以定制的格式和一些额外选项。
首先,让我们准备需要添加到应用中的日志返回配置。Spring Boot 允许我们通过在应用资源文件夹(src/main/resources
)中创建一个名为logback-spring.xml
的文件来扩展默认设置,该文件将在应用初始化时被自动提取。参见清单 8-41 。在这个文件中,我们导入现有的默认值,并为所有级别为 INFO 或更高级别的消息创建和设置一个新的 appender。AMQP 附加器文档( https://tpd.io/amqp-appender
)列出了所有参数及其含义;让我们详细列出我们需要的。
-
applicationId
:我们将它设置为应用名,这样我们可以在聚合日志时区分来源。 -
host
:这是运行 RabbitMQ 的主机。由于每个环境都不同,我们将这个值连接到 Spring 属性spring.rabbitmq.host
。Spring 允许我们通过标签springProperty
来做到这一点。我们给这个回退属性命名为rabbitMQHost
,并且我们使用语法${rabbitMQHost:-localhost}
来使用属性值(如果设置了的话)或者使用默认值localhost
(默认值是用:-
分隔符设置的)。 -
routingKeyPattern
:这是每条消息的路由键,如果我们想在消费者端过滤,为了更大的灵活性,我们将它设置为 applicationId 和 level(用%p
表示)的连接。 -
exchangeName
:我们在 RabbitMQ 中指定交易所的名称来发布消息。默认会是话题交换,所以我们可以称之为logs.topic
。 -
declareExchange
:我们将它设置为true
来创建交换,如果它还不存在的话。 -
durable
:也将此设置为true
,这样交换就不会受到服务器重启的影响。 -
deliveryMode
:我们将它设为PERSISTENT
,这样日志消息会被存储起来,直到被聚合器使用。 -
generateId
:我们将它设置为true
,这样每条消息都有一个唯一的标识符。 -
charset
:将它设置为UTF-8
是一个很好的做法,以确保各方使用相同的编码。
清单 8-41 显示了游戏化项目中logback-spring.xml
文件的全部内容。请注意我们是如何将一个带有自定义pattern
的layout
添加到新的 appender 中的。这样,我们可以对我们的消息进行编码,不仅包括消息(%msg
),还包括一些额外的信息,比如时间(%d{HH:mm:ss.SSS}
)、线程名([%t]
)和日志类(%logger{36}
)。如果您对模式符号感兴趣,可以查看 Logback 的参考文档( https://tpd.io/logback-layout
)。文件的最后一部分配置根记录器(默认的)来使用在一个包含文件中定义的CONSOLE
appender 和新定义的AMQP
appender。
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml" />
<include resource="org/springframework/boot/logging/logback/console-appender.xml" />
<springProperty scope="context" name="rabbitMQHost" source="spring.rabbitmq.host"/>
<appender name="AMQP"
class="org.springframework.amqp.rabbit.logback.AmqpAppender">
<layout>
<pattern>%d{HH:mm:ss.SSS} [%t] %logger{36} - %msg</pattern>
</layout>
<applicationId>gamification</applicationId>
<host>${rabbitMQHost:-localhost}</host>
<routingKeyPattern>%property{applicationId}.%p</routingKeyPattern>
<exchangeName>logs.topic</exchangeName>
<declareExchange>true</declareExchange>
<durable>true</durable>
<deliveryMode>PERSISTENT</deliveryMode>
<generateId>true</generateId>
<charset>UTF-8</charset>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="AMQP" />
</root>
</configuration>
Listing 8-41New logback-spring.xml File in the Gamification Project
现在,我们必须确保将这个文件添加到我们的三个 Spring Boot 项目中:乘法、游戏化和网关。在其中的每一个中,我们必须相应地改变applicationId
值。
除了这个日志生成器的基本设置之外,我们可以调整 appender 用来连接 RabbitMQ 的类的日志级别,如WARN
。这是一个可选步骤,但是当 RabbitMQ 服务器不可用时(例如,在启动我们的系统时),它可以避免数百个日志。由于 appender 是在引导阶段配置的,我们将根据项目将这个配置设置添加到相应的bootstrap.properties
和boostrap.yml
文件中。参见清单 8-42 和 8-43 。
logging:
level:
org.springframework.amqp.rabbit.connection.CachingConnectionFactory: WARN
Listing 8-43Reducing RabbitMQ Logging Level in the Gateway
logging.level.org.springframework.amqp.rabbit.connection.CachingConnectionFactory = WARN
Listing 8-42Reducing RabbitMQ Logging Level in Multiplication and Gamification
下次我们启动应用时,所有日志不仅会输出到控制台,还会作为消息输出到 RabbitMQ 中的logs.topic
交换。您可以通过在localhost:15672
访问 RabbitMQ Web UI 来验证这一点。见图 8-29 。
图 8-29
RabbitMQ UI:日志交换
使用日志并打印它们
现在,我们已经将所有日志一起发布到了一个 exchange,我们将构建消费者端:一个新的微服务,它使用所有这些消息并将它们一起输出。
首先,导航到 Spring Initializr 站点 start.spring.io ( https://start.spring.io/
)并使用我们为其他应用选择的相同设置创建一个logs
项目:Maven 和 JDK 14。在依赖项列表中,我们添加了 Spring for RabbitMQ、Spring Web、验证、Spring Boot 执行器、Lombok 和 Consul 配置。注意,我们不需要使这个服务可被发现,所以我们不添加 Consul 发现。见图 8-30 。
图 8-30
创建日志微服务
一旦我们将这个项目导入到我们的工作空间中,我们就添加一些配置,以便能够连接到配置服务器。我们现在不打算添加任何特定的配置,但这样做可以使它与其他微服务保持一致。在main/src/resources
文件夹中,复制我们包含在其他项目中的bootstrap.properties
文件的内容。此外,让我们在application.properties
文件中设置应用名称和专用端口。参见清单 8-44 。
spring.application.name=logs
server.port=8580
Listing 8-44Adding Content to the application.properties File in the New Logs Application
我们需要一个 Spring Boot 配置类来声明交换、我们希望从中消费消息的队列,以及使用绑定键模式将队列附加到主题交换的绑定对象,以消费所有这些内容。见清单 8-45 。请记住,由于我们将日志记录级别添加到了路由关键字中,所以我们也可以调整这个值,例如,只获取错误。无论如何,在我们的情况下,我们订阅所有消息(#
)。
package microservices.book.logs;
import org.springframework.amqp.core.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AMQPConfiguration {
@Bean
public TopicExchange logsExchange() {
return ExchangeBuilder.topicExchange("logs.topic")
.durable(true)
.build();
}
@Bean
public Queue logsQueue() {
return QueueBuilder.durable("logs.queue").build();
}
@Bean
public Binding logsBinding(final Queue logsQueue,
final TopicExchange logsExchange) {
return BindingBuilder.bind(logsQueue)
.to(logsExchange).with("#");
}
}
Listing 8-45AMQPConfiguration Class in the Logs Application
下一步是使用@RabbitListener
创建一个简单的服务,该服务使用相应的 log.info
()
、log.error()
或log.warn()
,将作为 RabbitMQ 消息头传递的接收消息的日志记录级别映射到 Logs 微服务中的日志记录级别。注意,我们在这里使用了@Header
注释来提取 AMQP 头作为方法参数。我们还使用日志记录Marker
将应用名称(appId
)添加到日志行中,而不需要将其作为消息的一部分连接起来。这是 SLF4J 标准中向日志添加上下文值的一种灵活方式。参见清单 8-46 。
package microservices.book.logs;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.stereotype.Service;
import org.slf4j.Marker;
import org.slf4j.MarkerFactory;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Service
public class LogsConsumer {
@RabbitListener(queues = "logs.queue")
public void log(final String msg,
@Header("level") String level,
@Header("amqp_appId") String appId) {
Marker marker = MarkerFactory.getMarker(appId);
switch (level) {
case "INFO" -> log.info(marker, msg);
case "ERROR" -> log.error(marker, msg);
case "WARN" -> log.warn(marker, msg);
}
}
}
Listing 8-46The Consumer Class That Receives All Log Messages via RabbitMQ
最后,我们定制这个新的微服务产生的日志输出。因为它将聚合来自不同服务的多个日志,所以最相关的属性是应用名称。这次我们覆盖了 Spring Boot 的默认设置,在一个logback-spring.xml
文件中为输出标记、级别和消息的CONSOLE
appender 定义了一个简单的格式。参见清单 8-47 。
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<layout class="ch.qos.logback.classic.PatternLayout">
<Pattern>
[%-15marker] %highlight(%-5level) %msg%n
</Pattern>
</layout>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>
</configuration>
Listing 8-47The LogBack Configuration for the Logs Application
这就是我们在这个新项目中需要的所有代码。现在,我们可以构建源代码,并使用系统中的其余组件启动这个新的微服务。
-
运行 RabbitMQ 服务器。
-
在开发模式下运行 Consul 代理。
-
启动乘法微服务。
-
启动游戏化微服务。
-
启动网关微服务。
-
启动日志微服务。
-
运行前端 app。
一旦我们启动这个新的微服务,它将消耗其他应用产生的所有日志消息。在实践中,你可以解决一个挑战。您将在 Logs 微服务的控制台中看到清单 8-48 中所示的日志行。
[multiplication ] INFO 15:14:20.203 [http-nio-8080-exec-1] m.b.m.c.ChallengeAttemptController - Received new attempt from test1
[gamification ] INFO 15:14:20.357 [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] m.b.g.game.GameEventHandler - Challenge Solved Event received: 122
[gamification ] INFO 15:14:20.390 [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] m.b.g.game.GameServiceImpl - User test1 scored 10 points for attempt id 122
Listing 8-48Centralized Logs in the New Logs Application
这个简单的日志聚合器并没有花费我们太多时间,现在我们可以在同一个源中搜索日志,并看到来自我们所有服务的近乎实时的输出流。请参见图 8-31 了解包含这一新组件的高级架构图的更新版本。
图 8-31
高级概述:集中式日志
如果我们为日志聚合选择一个现有的解决方案,整个步骤将是相似的。许多这样的工具,比如 ELK stack,可以通过定制的附加器与 Logback 集成来获取日志。然后,在非基于云的日志聚合器的情况下,我们还需要在我们的系统中部署日志服务器,就像我们对我们创建的基本微服务所做的那样。
分布式跟踪
将所有的日志放在一个地方是一个伟大的成就,它提高了可观察性,但是我们还没有适当的 ?? 可追溯性。在前一章中,我们描述了一个成熟的事件驱动系统如何拥有跨越不同微服务的进程。了解许多并发用户和多个事件链的情况可能会成为一项不可能完成的任务,尤其是当这些事件链的分支包含触发相同操作的多种事件类型时。
为了解决这个问题,我们需要在同一个流程链中关联所有的操作和事件。一种简单的方法是在所有 HTTP 调用、RabbitMQ 消息和处理不同动作的 Java 线程中注入相同的标识符。然后,我们可以在所有相关的日志中打印这个标识符。
在我们的系统中,我们使用用户标识符。如果我们认为我们未来的所有功能都将围绕用户动作构建,我们可以在每个事件和调用中传播一个userId
字段。然后,我们可以在不同的服务中记录日志,这样我们就可以将日志与特定用户相关联。这肯定会提高可追溯性。然而,我们也可能在短时间内发生来自同一个用户的多个动作,例如,在一秒钟内两次尝试解一个乘法(一个快用户,但是你明白这个想法),分布在多个实例中。在这种情况下,将很难区分跨我们的微服务的单个流。理想情况下,我们应该为每个动作设置一个惟一的标识符,这个标识符是在链的起点生成的。此外,如果我们可以透明地传播它,而不必在我们的所有服务中显式地建模这种可追溯性问题,那就更好了。
正如在软件开发中多次发生的那样,我们不是第一个应对这一挑战的人。这又是一个好消息,因为这意味着我们可以毫不费力地使用解决方案。在这种情况下,在 Spring 中实现分布式跟踪的工具称为 Sleuth。
SpringCloud 侦探
Sleuth 是 Spring Cloud 家族的一部分,它使用 Brave 库( https://tpd.io/brave
)来实现分布式追踪。它通过关联被称为的工作单元跨越来构建跨不同组件的跟踪。例如,在我们的系统中,一个 span 检查乘法微服务中的尝试,另一个 span 根据 RabbitMQ 事件添加分数和徽章。每个区间都有一个不同的唯一标识符,但是两个区间都是同一个跟踪的一部分,所以它们有相同的跟踪标识符。此外,每个 span 都链接到其父级,除了根 span,因为它是原始动作。见图 8-32 。
图 8-32
分布式跟踪:简单的例子
在更进化的系统中,可能存在复杂的轨迹结构,其中多个跨度具有相同的父跨度。见图 8-33
图 8-33
分布式跟踪:树示例
为了透明地注入这些值,Sleuth 使用 SLF4J 的映射诊断上下文(MDC)对象,这是一个日志上下文,其生命周期仅限于当前线程。该项目还允许我们在这个上下文中注入我们自己的字段,因此我们可以传播它们并在日志中使用这些值。
Spring Boot 在 Sleuth 中自动配置了一些内置的拦截器来自动检查和修改 HTTP 调用和 RabbitMQ 消息。它还集成了 Kafka、gRPC 和其他通信接口。这些拦截器都以类似的方式工作:对于传入的通信,它们检查是否有跟踪头添加到调用或消息中,并将它们放入 MDC 当作为客户机进行调用或发布数据时,这些拦截器从 MDC 获取这些字段,并向请求或消息添加头。
Sleuth 有时会与 Zipkin 结合使用,Zipkin 是一种使用跟踪采样来测量每个跨度以及整个链中所花费的时间的工具。这些样本可以发送到 Zipkin 服务器,该服务器公开了一个 UI,您可以使用该 UI 来查看跟踪层次结构以及每个服务完成其工作所需的时间。我们不会在本书中使用 Zipkin,因为它不会给带有 trace 和 span 标识符的集中式日志记录系统增加太多价值,如果您检查记录的时间戳,还可以知道每个服务花费的时间。无论如何,您可以按照参考文档中的说明( http://tpd.io/spans-zipkin
)轻松地将 Zipkin 集成到我们的示例项目中。
实现分布式跟踪
如前所述,Spring Cloud Sleuth 为 REST APIs 和 RabbitMQ 消息提供了拦截器,Spring Boot 为我们自动配置了它们。在我们的系统中使用分布式跟踪并不难。
首先,让我们将相应的 Spring Cloud starter 添加到我们的网关、乘法、游戏化和日志微服务中。参见清单 8-49 了解我们必须添加到pom.xml
文件中的依赖关系。
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-sleuth</artifactId>
</dependency>
Listing 8-49Adding Spring Cloud Sleuth to All Our Spring Boot Projects
只有通过添加这种依赖关系,Sleuth 才会将跟踪和跨度标识符注入到每个受支持的通信通道和 MDC 对象中。默认的 Spring Boot 日志记录模式也会自动调整为在日志中打印跟踪和跨度值。
为了使我们的日志更详细,并查看运行中的跟踪标识符,让我们向ChallengeAttemptController
添加一个日志行,以便在每次用户发送尝试时打印一条消息。参见清单 8-50 中的变更。
@PostMapping
ResponseEntity<ChallengeAttempt> postResult(
@RequestBody @Valid ChallengeAttemptDTO challengeAttemptDTO) {
log.info("Received new attempt from {}", challengeAttemptDTO.getUserAlias());
return ResponseEntity.ok(challengeService.verifyAttempt(challengeAttemptDTO));
}
Listing 8-50Adding a Log Line to ChallengeAttemptController
此外,我们还希望在集中式日志中包含跟踪和父标识符。为此,让我们手动将 MDC 上下文中的属性X-B3-TraceId
和X-B3-SpanId
(由 Sleuth 使用 Brave 注入)添加到 Logs 项目的logback-spring.xml
文件中的模式中。这些头是 OpenZipkin 的 B3 传播规范的一部分(参见 http://tpd.io/b3-headers
了解更多细节),它们被 Sleuth 的拦截器包含在 MDC 中。我们需要为我们的 Logs 微服务手动执行此操作,因为我们没有在此日志配置文件中使用 Spring Boot 默认值。见清单 8-51 。
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<layout class="ch.qos.logback.classic.PatternLayout">
<Pattern>
[%-15marker] [%X{X-B3-TraceId:-},%X{X-B3-SpanId:-}] %highlight(%-5level) %msg%n
</Pattern>
</layout>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>
</configuration>
Listing 8-51Adding Trace Fields to Each Log Line Printed by the Logs Application
一旦我们重启所有的后端服务,Sleuth 将会尽自己的一份力量。让我们使用终端直接向后端发送一个正确的尝试。
$ http POST :8000/attempts factorA=15 factorB=20 userAlias=test-user-tracing guess=300
然后,我们检查日志服务的输出。我们将看到两个字段显示了跨乘法和游戏化微服务的公共跟踪标识符,fa114ad129920dc7
。每条线也有自己的跨度 ID。参见清单 8-52 。
[multiplication ] [fa114ad129920dc7,4cdc6ab33116ce2d] INFO 10:16:01.813 [http-nio-8080-exec-8] m.b.m.c.ChallengeAttemptController - Received new attempt from test-user-tracing
[multiplication ] [fa114ad129920dc7,f70ea1f6a1ff6cac] INFO 10:16:01.814 [http-nio-8080-exec-8] m.b.m.challenge.ChallengeServiceImpl - Creating new user with alias test-user-tracing
[gamification ] [fa114ad129920dc7,861cbac20a1f3b2c] INFO 10:16:01.818 [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] m.b.g.game.GameEventHandler - Challenge Solved Event received: 126
[gamification ] [fa114ad129920dc7,78ae53a82e49b770] INFO 10:16:01.819 [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] m.b.g.game.GameServiceImpl - User test-user-tracing scored 10 points for attempt id 126
Listing 8-52Centralized Logs with Trace Identifiers
正如您所看到的,只需很少的努力,我们就获得了一个强大的功能,允许我们在分布式系统中辨别不同的进程。可以想象,当我们将所有日志及其跟踪和跨度输出到更复杂的集中式日志工具(如 ELK)时,效果会更好,我们可以使用这些标识符来执行过滤文本搜索。
集装箱化
到目前为止,我们一直在本地执行我们所有的 Java 微服务,React 前端、RabbitMQ 和 Consul。为此,我们需要安装 JDK 来编译源代码和运行 JAR 包,Node.js 来构建和运行 UI,RabbitMQ 服务器(包括 Erlang)和 Consul 的代理。随着我们架构的发展,我们可能需要引入其他工具和服务,它们肯定有自己的安装过程,可能会因操作系统及其版本的不同而有所不同。
作为一个总体目标,我们希望能够在多种环境中运行我们的后端系统,不管它们运行的是什么操作系统版本。理想情况下,我们希望受益于“一次构建,随处部署”的策略,并避免在我们希望部署系统的每个环境中重复所有的配置和安装步骤。此外,部署过程应该尽可能简单。
过去,打包整个系统以便在任何地方运行的一种常见方法是创建一个虚拟机(VM)。有几种创建和运行虚拟机的解决方案,它们被称为虚拟机管理程序。虚拟机管理程序的一个优势是,一台物理机可以同时运行多个虚拟机,并且它们都共享硬件资源。每个虚拟机都需要自己的操作系统,然后通过虚拟机管理程序连接到主机的 CPU、RAM、硬盘等。
在我们的例子中,我们可以从 Linux 发行版开始创建一个 VM,并在那里设置和安装运行我们的系统所需的所有工具和服务:Consul、RabbitMQ、Java 运行时、JAR 应用等。一旦我们知道虚拟机工作了,我们就可以把它转移到任何其他运行虚拟机管理程序的计算机上。因为这个包包含了它所需要的一切,所以它在不同的主机上应该是一样的。见图 8-34 。
图 8-34
虚拟机部署:单一
然而,将所有东西都放在同一个虚拟机中并不太灵活。如果我们想要扩展我们的系统,我们必须进入虚拟机,添加新的实例,并确保我们分配更多的 CPU、内存等。我们需要知道一切是如何工作的,所以部署过程不再那么容易了。
更动态的方法是为每个服务和工具配备单独的虚拟机。然后,我们添加一些网络配置,以确保它们可以相互连接。由于我们使用服务发现和动态扩展,我们可以添加更多运行微服务的虚拟机实例(例如,乘法-VM),它们将被透明地使用。这些新实例只需要使用它们的地址(在 VM 网络中)在 Consul 中注册它们自己。见图 8-35 这比单个虚拟机要好,但考虑到每个虚拟机都需要自己的操作系统,这是一种巨大的资源浪费。此外,这将在虚拟机协调方面带来许多挑战:监控虚拟机、创建新实例、配置网络、存储等。
图 8-35
虚拟机部署:多个
随着容器化技术在 2010 年代初的发展,虚拟机已被淘汰,容器已成为最受欢迎的应用虚拟化方式。容器要小得多,因为不需要操作系统;它们运行在主机的 Linux 操作系统之上。
另一方面,像 Docker 这样的容器化平台的引入极大地促进了云和本地部署,使用易于使用的工具来打包应用,将它们作为容器运行,并在公共注册表中共享它们。让我们更详细地探索这个平台的特性。
码头工人
在本书中不可能涵盖 Docker 平台的所有概念,但是让我们尝试给出一个足够好的概述,以理解它如何促进分布式系统的部署。官网的入门页面( https://tpd.io/docker-start
)是从这里继续学习的好地方。
在 Docker 中,我们可以将我们的应用和它可能需要的任何支持组件打包成映像。这些可以基于你从 Docker 注册表中提取的其他现有图像,因此我们可以重用它们并节省大量时间。官方公开的图片注册中心是 Docker Hub ( https://tpd.io/docker-hub
)。
例如,倍增微服务的 Docker 映像可以基于现有的 JDK 14 映像。然后,我们可以在它上面添加由 Spring Boot 打包的 JAR 文件。为了创建图像,我们需要一个Dockerfile
,以及 Docker CLI 工具的一组指令。清单 8-53 展示了乘法微服务的示例Dockerfile
可能是什么样子。这个文件应该放在项目的根文件夹中。
FROM openjdk:14
COPY ./target/multiplication-0.0.1-SNAPSHOT.jar /usr/src/multiplication/
WORKDIR /usr/src/multiplication
EXPOSE 8080
CMD ["java", "-jar", "multiplication-0.0.1-SNAPSHOT.jar"]
Listing 8-53A Basic Dockerfile to Create a Docker Image for the Multiplication Microservice
这些指令告诉 Docker 使用公共注册中心(Docker Hub, https://tpd.io/docker-jdk
)中官方openjdk
镜像的版本14
作为基础(FROM
)。然后,它将可分发的.jar
文件从当前项目复制到图像中的/usr/src/multiplication/
文件夹(COPY
)。第三条指令WORKDIR
将图像的工作目录更改为这个新创建的文件夹。命令EXPOSE
通知 Docker 这个映像公开了一个端口 8080,我们在这里服务 REST API。最后,我们定义了用CMD
运行这个映像时要执行的命令。这只是运行一个.jar
文件的经典 Java 命令,按照预期的语法分成三部分。您可以在Dockerfile
中使用许多其他指令,如您在参考文档中所见( https://tpd.io/dockerfile-ref
)。
要构建映像,我们必须下载并安装标准 Docker 安装包附带的 Docker CLI 工具。按照 Docker 网站上的说明( https://tpd.io/getdocker
)获取适合您的操作系统的软件包。下载并启动后,Docker 守护程序应该作为后台服务运行。然后,您可以从终端使用 Docker 命令构建和部署映像。例如,清单 8-54 中所示的命令基于我们之前创建的Dockerfile
构建了multiplication
图像。注意,作为先决条件,你必须确保在一个.jar
文件中构建和打包应用,例如通过从项目的根文件夹运行./mvnw clean package
。
multiplication$ docker build -t multiplication:1.0.0 .
Sending build context to Docker daemon 59.31MB
Step 1/5 : FROM openjdk:14
---> 4fba8120f640
Step 2/5 : COPY ./target/multiplication-0.0.1-SNAPSHOT.jar /usr/src/multiplication/
---> 2e48612d3e40
Step 3/5 : WORKDIR /usr/src/multiplication
---> Running in c58cde6bda82
Removing intermediate container c58cde6bda82
---> 8d5457683f2c
Step 4/5 : EXPOSE 8080
---> Running in 7696319884c7
Removing intermediate container 7696319884c7
---> abc3a60b73b2
Step 5/5 : CMD ["java", "-jar", "multiplication-0.0.1-SNAPSHOT.jar"]
---> Running in 176cd53fe750
Removing intermediate container 176cd53fe750
---> a42cc81bab51
Successfully built a42cc81bab51
Successfully tagged multiplication:1.0.0
Listing 8-54Building
a Docker Image Manually
正如您在输出中看到的,Docker 处理文件中的每一行并创建一个名为multiplication:1.0.0
的图像。这张图片只在本地可用,但是如果我们希望其他人使用它,我们可以将图片推送到一个远程位置,我们将在后面解释。
一旦构建了 Docker 映像,就可以将其作为容器运行,容器是映像的运行实例。例如,这个命令将在我们的机器上运行一个 Docker 容器:
$ docker run -it -p 18080:8080 multiplication:1.0.0
如果图像在本地不可用,Docker 中的run
命令会提取图像,并作为容器在 Docker 平台上执行。-it
标志用于附加到容器的终端,所以我们可以看到命令行的输出,也可以用 Ctrl+C 信号停止容器。-p
选项是公开内部端口 8080,因此可以从端口 18080 中的主机访问它。这些只是我们在运行容器时可以使用的几个选项;您可以从命令行使用docker run --help
来查看它们。
当我们启动这个容器时,它将运行在 Docker 平台之上。如果您运行的是 Linux 操作系统,容器将使用主机的本地虚拟化功能。当在 Windows 或 Mac 上运行时,Docker 平台在两者之间设置了一个 Linux 虚拟化层,如果这些操作系统可用,它可能会使用这些操作系统的原生支持。
不幸的是,我们的容器不能正常工作。它不能连接 RabbitMQ 或 Consul,即使我们在码头工人的主机(我们的电脑)上安装并运行它们。清单 8-55 显示了容器日志中这些错误的摘录。记住,默认情况下,Spring Boot 试图在localhost
找到 RabbitMQ 主机,和 Consul 一样。在一个容器中,localhost
指的是自己的容器,除了 Spring Boot app,没有别的。此外,容器是运行在 Docker 平台网络上的孤立单元,因此它们无论如何都不应该连接到运行在主机上的服务。
2020-08-29 10:03:44.565 ERROR [,,,] 1 --- [ main] o.s.c.c.c.ConsulPropertySourceLocator : Fail fast is set and there was an error reading configuration from consul.
2020-08-29 10:03:45.572 ERROR [,,,] 1 --- [ main] o.s.c.c.c.ConsulPropertySourceLocator : Fail fast is set and there was an error reading configuration from consul.
2020-08-29 10:03:46.675 ERROR [,,,] 1 --- [ main] o.s.c.c.c.ConsulPropertySourceLocator : Fail fast is set and there was an error reading configuration from consul.
[...]
Listing 8-55The Multiplication Container Can’t Reach Consul at Localhost
为了正确设置我们的后端系统在 Docker 中运行,我们必须将 RabbitMQ 和 Consul 部署为容器,并使用 Docker 网络在它们之间连接所有这些不同的实例。见图 8-36
图 8-36
我们的码头集装箱后端
在学习如何实现这一点之前,让我们探索一下 Spring Boot 如何帮助我们建立 Docker 图像,这样我们就不需要自己准备Dockerfile
了。
Spring Boot 和构建包
从版本 2.3.0 开始,Spring Boot 的 Maven 和 Gradle 插件可以选择使用云原生构建包( https://tpd.io/buildpacks
)来构建开放容器倡议(OCI)映像,该项目旨在帮助打包您的应用,以便将其部署到任何云提供商。您可以在 Docker 和其他容器平台中运行生成的图像。
Buildpacks 插件的一个很好的特性是,它根据项目的 Maven 配置准备一个计划,然后打包一个 Docker 映像,准备好进行部署。此外,它以某种方式在层中构建映像,以便它们可以被您的应用的未来版本重用,甚至可以被使用相同工具构建的其他微服务映像重用(例如,具有所有 Spring Boot 核心库的层)。这有助于加快测试和部署。
如果我们从命令行运行build-image
目标,例如从游戏化的项目文件夹,我们可以看到构建包的运行:
gamification $ ./mvnw spring-boot:build-image
您应该从 Maven 插件中看到一些额外的日志,它现在正在下载一些所需的图像并构建应用图像。如果一切顺利,您应该在最后看到这一行:
[INFO] Successfully built image 'docker.io/library/gamification:0.0.1-SNAPSHOT'
Docker 标签被设置为我们在pom.xml
文件中指定的 Maven 工件的名称和版本:gamification:0.0.1-SNAPSHOT
。前缀docker.io/library/
是所有公共 Docker 图像的默认前缀。我们可以为这个插件定制多个选项,你可以查看参考文档( https://tpd.io/buildpack-doc
)了解所有细节。
就像我们之前为我们自己构建的图像运行容器一样,我们现在可以为这个由 Spring Boot 的 Maven 插件生成的新图像运行容器:
$ docker run -it -p 18081:8081 gamification:0.0.1-SNAPSHOT
不出所料,容器也会输出同样的错误。请记住,应用不能连接到 RabbitMQ 和 Consul,它需要这两个服务才能正常启动。我们会尽快解决这个问题。
对于您自己的项目,您应该考虑使用云原生构建包与维护您自己的 Docker 文件的利弊。如果您计划使用这些标准 OCI 映像部署到支持它们的公共云,这可能是一个好主意,因为您可以节省大量时间。Buildpacks 还负责在可重用层中组织您的图像,因此您可以避免自己这样做。此外,您可以定制插件使用的基本构建映像,因此您可以灵活地定制这个过程。然而,如果您想要完全控制您正在构建的东西以及您想要包含在您的图像中的工具和文件,那么自己定义Dockerfile
指令可能会更好。正如我们之前看到的,这对于一个基本的设置来说并不难。
在 Docker 中运行我们的系统
让我们为系统中的每个组件构建或找到一个 Docker 映像,这样我们就可以将它部署为一组容器。
-
乘法、游戏化、网关和日志微服务:我们将使用带有构建包的 Spring Boot Maven 插件来生成这些 Docker 映像。
-
RabbitMQ :我们可以使用包含管理插件(UI)的官方 RabbitMQ 镜像版本运行一个容器:
rabbitmq:3-management
(参见 Docker Hub )。 -
领事:也有官方码头工人形象。我们将使用 Docker Hub 中的标签
consul:1.7.2
(https://tpd.io/consul-docker
)。此外,我们将运行第二个容器来加载一些配置,作为集中式配置的键/值对。更多细节将在其特定部分给出。 -
前端:如果我们想在 Docker 中部署完整的系统,我们还需要一个 web 服务器来托管 React 构建中生成的 HTML/JavaScript 文件。我们可以用 Nginx 这样的轻量级静态服务器,用它的官方 Docker 镜像
nginx:1.19
(见 Docker Hub,https://tpd.io/nginx-docker
)。在这种情况下,我们将以nginx
为基础构建我们自己的映像,因为我们也需要复制生成的文件。
因此,我们的计划需要构建六个不同的 Docker 映像,并使用两个公共映像。见图 8-37 。
图 8-37
高级概述:集装箱化系统
对接微服务
首先,让我们为 Spring Boot 应用构建所有的图像。从每个项目文件夹中,我们需要运行以下命令:
$ ./mvnw spring-boot:build-image
注意,Docker 必须在本地运行,与 Consul 和 RabbitMQ 一样,这样测试才能通过。一旦您生成了所有的图像,您可以通过运行docker images
命令来验证它们在 Docker 中都是可用的。参见清单 8-56 。
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
logs 0.0.1-SNAPSHOT 2fae1d82cd5d 40 years ago 311MB
gamification 0.0.1-SNAPSHOT 5552940b9bfd 40 years ago 333MB
multiplication 0.0.1-SNAPSHOT 05a4d852fa2d 40 years ago 333MB
gateway 0.0.1-SNAPSHOT d50be5ba137a 40 years ago 313MB
Listing 8-56Listing Docker Images Generated with Cloud Native Buildpacks
如您所见,图像是用旧日期生成的。这是 Buildpacks 的一个特性,使构建可复制:每次构建这个映像时,它们都获得相同的创建日期,并且它们也位于列表的末尾。
将用户界面停驻
下一步是在challenges-frontend
文件夹中创建一个Dockerfile
,这是 React 应用的根目录。我们只需要两条指令,一条是基本图像(Nginx ),另一条是将所有 HTML/JavaScript 文件放入图像的COPY
命令。我们将它们复制到 Nginx web 服务器默认用来提供内容的文件夹中。参见清单 8-57 。
FROM nginx:1.19
COPY build /usr/share/nginx/html/
Listing 8-57A Simple Dockerfile to Create an Image for the front End’s Web Server
在创建 Docker 映像之前,让我们确保为前端生成最新的工件。为了编译 React 项目,我们必须执行以下命令:
challenges-frontend $ npm run build
一旦生成了build
文件夹,我们就可以创建 Docker 图像了。我们将分配一个名称和一个带有-t
标志的标签,并且我们使用.
来表示Dockerfile
位于当前文件夹中。
challenges-frontend $ docker build -t challenges-frontend:1.0 .
将配置导入程序归档
现在,让我们准备一个 Docker 映像来加载一些预定义的集中式配置。我们将有一个运行服务器的 Consul 容器,它可以直接使用官方图像。我们的计划是运行一个额外的容器来执行 Consul CLI 以加载一些 KV 数据:一个docker
概要文件。这样,当在 Docker 中运行我们的微服务时,我们可以使用这个预加载的配置文件配置,因为它们需要不同的 RabbitMQ 主机参数,例如。
为了获得我们想要以文件格式加载的配置,我们可以在本地 Consul 服务器中创建它,并通过 CLI 命令导出它。我们使用 UI 来创建config
根,以及一个名为defaults,docker
的子文件夹。在内部,我们创建一个名为application.yml
的键,其配置如清单 8-58 所示。此配置执行以下操作:
-
将 RabbitMQ 的主机设置为
rabbitmq
,这将覆盖默认值localhost
。稍后,我们将确保消息代理的容器在该地址可用。 -
覆盖分配给正在运行的服务的实例标识符,以便在服务注册中心使用。默认的 Spring Consul 配置将应用名和端口号连接在一起,但是这种方法对容器不再有效。当我们在 Docker 中运行同一个服务的多个实例(作为容器)时,它们都使用同一个内部端口,所以它们最终会有相同的标识符。为了解决这个问题,我们可以使用一个随机整数作为后缀。Spring Boot 通过特殊的
random
属性符号支持这一点(详见https://tpd.io/random-properties
的文档)。
spring:
rabbitmq:
host: rabbitmq
cloud:
consul:
discovery:
instance-id: ${spring.application.name}-${random.int(1000)}
Listing 8-58The YAML Configuration to Connect Our Apps in Docker Containers to RabbitMQ
图 8-38 显示了通过 Consul UI 添加的内容。
图 8-38
咨询用户界面:准备导出配置
下一步是使用不同的终端将配置导出到文件中。为此,请执行以下命令:
$ consul kv export config/ > consul-kv-docker.json
现在,让我们在工作区的根目录下创建一个名为docker
的新文件夹来放置我们所有的 Docker 配置。在里面,我们创建了一个名为consul
的子文件夹。应该将使用前面的命令生成的 JSON 文件复制到那里。然后,用清单 8-59 中的指令添加一个新的Dockerfile
。
FROM consul:1.7.2
COPY ./consul-kv-docker.json /usr/src/consul/
WORKDIR /usr/src/consul
ENV CONSUL_HTTP_ADDR=consul:8500
ENTRYPOINT until consul kv import @consul-kv-docker.json; do echo "Waiting for Consul"; sleep 2; done
Listing 8-59Dockerfile Contents for the Consul Configuration Loader
参见清单 8-60 以及docker
文件夹的结果文件结构。
+- docker
| +- consul
| \- consul-kv-docker.json
| \- Dockerfile
Listing 8-60Creating the Consul Docker Configuration in a Separate Folder
清单 8-59 中的 Dockerfile 步骤使用 Consul 作为基础映像,因此 CLI 工具是可用的。JSON 文件被复制到映像中,工作目录被设置到与文件相同的位置。然后,ENV
指令为 Consul CLI 设置一个新的环境变量,以使用远程主机访问服务器,在本例中位于consul:8500
。这将是 Consul 服务器容器(我们将很快看到主机如何获得consul
名称)。最后,这个容器的ENTRYPOINT
(启动时运行的命令)是一个遵循until [command]; do ...; sleep 2; done
模式的内联 shell 脚本。该脚本运行一个命令,直到成功为止,重试间隔为两秒钟。主命令是consul kv import @consul-kv-docker.json
,将文件内容导入 KV 存储器。我们需要在一个循环中执行它,因为当这个 Consul 配置导入器运行时,Consul 服务器可能还没有启动。
为了在注册表中获得导入程序映像,我们必须构建它并给它一个名称。
docker/consul$ docker build -t consul-importer:1.0 .
我们将很快详细介绍如何在 Docker 中运行这个导入器,以将配置加载到 Consul 中。
复合坞站
一旦我们构建了所有的映像,我们需要将我们的系统作为一组容器来运行,所以是时候学习如何一起启动所有这些容器并进行通信了。
我们可以使用单独的 Docker 命令来启动所有需要的容器,并为它们建立相互连接的网络。然而,如果我们想告诉其他人如何启动我们的系统,我们需要给他们一个脚本或文档,其中包含所有这些命令和指令。幸运的是,Docker 中有一种更好的方法来对容器配置和部署指令进行分组:Docker Compose。
使用 Compose,我们使用 YAML 文件来定义基于多个容器的应用。然后,我们使用命令docker-compose
运行所有服务。Docker Compose 默认与 Windows 和 Mac 版本的 Docker Desktop 一起安装。如果你运行的是 Linux 或者你在 Docker 发行版中找不到它,按照 Docker 网站上的说明( https://tpd.io/compose-install
)安装它。
作为第一个例子,参见清单 8-61 ,了解我们需要在系统中作为容器运行的 RabbitMQ 和 Consul 服务的 YAML 定义。这个 YAML 定义必须添加到一个新文件docker-compose.yml
中,我们可以在现有的docker
文件夹中创建这个文件。我们将使用版本 3 的组合语法;完整参考可在 https://tpd.io/compose3
获得。请继续阅读关于该语法如何工作的高级细节。
version: "3"
services:
consul-dev:
image: consul:1.7.2
container_name: consul
# The UDP port 8600 is used by Consul nodes to talk to each other, so
# it's good to add it here even though we're using a single-node setup.
ports:
- '8500:8500'
- '8600:8600/udp'
command: 'agent -dev -node=learnmicro -client=0.0.0.0 -log-level=INFO'
networks:
- microservices
rabbitmq-dev:
image: rabbitmq:3-management
container_name: rabbitmq
ports:
- '5672:5672'
- '15672:15672'
networks:
- microservices
networks:
microservices:
driver: bridge
Listing 8-61A First Version of the docker-compose.yml File with RabbitMQ and Consul
在services
部分,我们定义了其中的两个:consul-dev
和rabbitmq-dev
。我们可以为我们的服务使用任何名称,所以我们为这两个服务添加了-dev
后缀,以表明我们正在开发模式下运行它们(没有集群的独立节点)。这两个服务使用不是我们创建的 Docker 映像,但是它们在 Docker Hub 中作为公共映像可用。我们第一次检查集装箱时就会发现。如果我们不指定command
来启动容器,将使用图像中的默认。默认命令可以在用于构建图像的Dockerfile
中指定。RabbitMQ 服务就是这样,默认情况下启动服务器。对于领事图像,我们定义了自己的命令,它类似于我们目前使用的命令。不同之处在于它还包括一个降低日志级别的标志,以及代理在 Docker 网络中工作所需的参数client
。这些说明可以在 Docker 图像的文档中找到( https://tpd.io/consul-docker
)。
两个服务都定义了一个container_name
参数。这很有用,因为它设置了容器的 DNS 名称,所以其他容器可以通过这个别名找到它。在我们的例子中,这意味着应用可以使用地址rabbitmq:5672
连接到 RabbitMQ 服务器,而不是默认的地址localhost:5672
(它现在指向与我们在前面章节中看到的相同的容器)。每个服务中的ports
参数允许我们以host-port:container-port
的格式向主机系统公开端口。我们在这里包括两个服务器都使用的标准工具,所以我们仍然可以从我们的桌面访问它们(例如,分别在端口 8500 和 15672 上使用它们的 UI 工具)。请注意,我们映射到主机中的相同端口,这意味着我们不能让 RabbitMQ 和 Consul 服务器进程同时在本地运行(就像我们到目前为止一直在做的那样),因为这会导致端口冲突。
在这个文件中,我们还定义了一个名为microservices
的类型为bridge
的网络。该驱动程序类型是默认类型,用于连接独立容器。然后,我们使用每个服务定义中的参数networks
将microservices
网络设置为他们可以访问的网络。实际上,这意味着这些服务可以相互连接,因为它们属于同一个网络。Docker 网络与主机网络相隔离,所以我们不能访问任何服务,除了那些我们用ports
参数明确公开的服务。这很好,因为这是我们在引入网关模式时所缺少的良好实践之一。
现在,我们可以使用这个新的docker-compose.yml
文件来运行 Consul 和 RabbitMQ Docker 容器。我们只需要从终端执行docker-compose
命令:
docker $ docker-compose up
Docker Compose 自动获取docker-compose.yml
而不指定名称,因为这是它期望的默认文件名。所有集装箱的输出是附加到当前码头和集装箱。如果我们想让它们作为守护进程在后台运行,我们只需要在命令中添加-d
标志。在我们的例子中,对于consul
和rabbitmq
容器,我们将在终端输出中看到所有的日志。参见清单 8-62 中的示例。
Creating network "docker_microservices" with driver "bridge"
Creating consul ... done
Creating rabbitmq ... done
Attaching to consul, rabbitmq
consul | ==> Starting Consul agent...
consul | Version: 'v1.7.2'
consul | Node ID: 'a69c4c04-d1e7-6bdc-5903-c63934f01f6e'
consul | Node name: 'learnmicro'
consul | Datacenter: 'dc1' (Segment: '<all>')
consul | Server: true (Bootstrap: false)
consul | Client Addr: [0.0.0.0] (HTTP: 8500, HTTPS: -1, gRPC: 8502, DNS: 8600)
consul | Cluster Addr: 127.0.0.1 (LAN: 8301, WAN: 8302)
consul | Encrypt: Gossip: false, TLS-Outgoing: false, TLS-Incoming: false, Auto-Encrypt-TLS: false
consul |
consul | ==> Log data will now stream in as it occurs:
[...]
rabbitmq | 2020-07-30 05:36:28.785 [info] <0.8.0> Feature flags: list of feature flags found:
rabbitmq | 2020-07-30 05:36:28.785 [info] <0.8.0> Feature flags: [ ] drop_unroutable_metric
rabbitmq | 2020-07-30 05:36:28.785 [info] <0.8.0> Feature flags: [ ] empty_basic_get_metric
rabbitmq | 2020-07-30 05:36:28.785 [info] <0.8.0> Feature flags: [ ] implicit_default_bindings
rabbitmq | 2020-07-30 05:36:28.785 [info] <0.8.0> Feature flags: [ ] quorum_queue
rabbitmq | 2020-07-30 05:36:28.786 [info] <0.8.0> Feature flags: [ ] virtual_host_metadata
rabbitmq | 2020-07-30 05:36:28.786 [info] <0.8.0> Feature flags: feature flag states written to disk: yes
rabbitmq | 2020-07-30 05:36:28.830 [info] <0.268.0> ra: meta data store initialised. 0 record(s) recovered
rabbitmq | 2020-07-30 05:36:28.831 [info] <0.273.0> WAL: recovering []
rabbitmq | 2020-07-30 05:36:28.833 [info] <0.277.0>
rabbitmq | Starting RabbitMQ 3.8.2 on Erlang 22.2.8
[...]
Listing 8-62Docker Compose Logs, Showing the Initialization of Both Containers
我们还可以验证如何在localhost:8500
从您的浏览器访问 Consul UI。这一次,从容器中为网站提供服务。它的工作方式完全相同,因为我们将端口暴露给同一个主机的端口,并且它被 Docker 重定向。
要停止这些容器,我们可以按 Ctrl+C,但这可能会使 Docker 在两次执行之间保持某种状态。为了正确地关闭它们并删除它们在 Docker volumes (容器定义的存储数据的单元)中创建的任何潜在数据,我们可以从不同的终端运行清单 8-63 中的命令。
docker $ docker-compose down -v
Stopping consul ... done
Stopping rabbitmq ... done
Removing consul ... done
Removing rabbitmq ... done
Removing network docker_default
WARNING: Network docker_default not found.
Removing network docker_microservices
Listing 8-63Stopping Docker Containers and Removing Volumes with Docker Compose
我们的下一步是将我们为将配置加载到 Consul KV 中而创建的映像添加到 Docker Compose 文件中。参见清单 8-64 。
version: "3"
services:
consul-importer:
image: consul-importer:1.0
depends_on:
- consul-dev
networks:
- microservices
consul-dev:
# ... same as before
rabbitmq-dev:
# ... same as before
networks:
microservices:
driver: bridge
Listing 8-64Adding the Consul Importer Image to the docker-compose.yml File
这一次,图像consul-importer:1.0
不是公开的;Docker Hub 中没有。但是,它在本地 Docker 注册表中是可用的,因为我们之前构建了它,所以 Docker 可以通过我们之前定义的名称和标签找到它。
我们可以用参数depends_on
在组合文件中建立依赖关系。在这里,我们使用它使这个容器在运行 Consul 服务器的consul-dev
容器之后启动。无论如何,这并不能保证在consul-importer
运行时服务器已经准备好了。原因是 Docker 只知道容器何时启动,但不知道 consul 服务器何时启动并准备好接受请求。这就是我们向导入程序映像添加脚本的原因,该脚本会重试导入,直到成功为止(参见清单 8-59 )。
当您使用这个新配置再次运行docker-compose up
时,您也将看到这个新容器的输出。最后,您应该看到加载配置的行,然后 Docker 将通知这个容器成功退出(代码为 0)。参见清单 8-65 。
docker $ docker-compose up
[...]
consul-importer_1 | Imported: config/
consul-importer_1 | Imported: config/defaults,docker/
consul-importer_1 | Imported: config/defaults,docker/application.yml
docker_consul-importer_1 exited with code 0
[...]
Listing 8-65Running docker-compose for the Second Time to See the Importer’s Logs
新容器作为一个功能运行,而不是作为一个持续运行的服务。这是因为我们替换了consul
映像中的默认命令,该命令在它们的内部Dockerfile
中定义为将服务器作为一个进程运行,替换为一个简单加载配置然后结束的命令(它不是一个无限运行的进程)。Docker 知道容器没什么可做的了,因为命令已经退出了,所以没有必要让容器保持活动状态。
我们还可以知道一个docker-compose
配置的运行容器是什么。为了获得这个列表,我们可以从不同的终端执行docker-compose ps
。见清单 8-66 。
docker $ docker-compose ps
Name Command State Ports
--------------------------------------------------------------------
consul docker-e[...] Up 8300/tcp, [...]
docker_consul-importer_1 /bin/sh [...] Exit 0
rabbitmq docker-e[...] Up 15671/tcp, [...]
Listing 8-66Running docker-compose ps to See the Status of the Containers
输出(为了更好的可读性而进行了裁剪)还详细描述了容器使用的命令、其状态和公开的端口。
如果我们在http://localhost:8500/ui
用浏览器导航到 Consul UI,我们将看到配置是如何被正确加载的,并且我们有一个config
条目,它带有嵌套的defaults,docker
子文件夹和相应的application.yml
键。见图 8-39 。我们的进口商运作良好。
图 8-39
领事容器内的 Docker 配置
让我们继续 Docker Compose 中的前端定义。这个很容易;我们只需要添加我们基于 Nginx 构建的映像,并通过重定向到内部端口来公开端口 3000,在基本映像中默认为 80(根据 https://tpd.io/nginx-docker
)。参见清单 8-67 。您可以更改公开的端口,但是记得相应地调整网关中的 CORS 配置(或者重构它,以便它可以通过外部属性进行配置)。
version: "3"
services:
frontend:
image: challenges-frontend:1.0
ports:
- '3000:80'
consul-importer:
# ... same as before
consul-dev:
# ... same as before
rabbitmq-dev:
# ... same as before
networks:
microservices:
driver: bridge
Listing 8-67Adding the Web Server to the docker-compose.yml File
为了使整个系统工作,我们需要将 Spring Boot 微服务添加到 Docker 合成文件中。我们将配置它们使用我们创建的同一个网络。这些容器中的每一个都需要到达consul
和rabbitmq
容器才能正常工作。为此我们将使用两种不同的策略。
-
对于 Consul 设置,Spring 中的集中配置特性要求服务知道在引导阶段在哪里可以找到服务器。我们需要覆盖在本地
bootstrap.yml
中使用的属性spring.cloud.consul.host
,并将其指向consul
容器。我们将通过环境变量来做到这一点。在 Spring Boot,如果您设置了一个与现有属性匹配的环境变量,或者一个遵循特定命名约定的环境变量(比如SPRING_CLOUD_CONSUL_HOST
),那么它的值会覆盖本地配置。更多详情请参见 Spring Boot 文档中的https://tpd.io/binding-vars
。 -
对于 RabbitMQ 配置,我们将使用
docker
概要文件。假设微服务将连接到 Consul,配置服务器有一个针对defaults,docker
的预加载条目,所有微服务都将使用那里的属性。还记得我们在那个概要文件中将 RabbitMQ 主机更改为rabbitmq
,容器的 DNS 名称。为了激活每个微服务中的docker
概要文件,我们将使用 Spring Boot 属性来启用概要文件,通过环境变量:SPRING_PROFILES_ACTIVE=docker
传递。
此外,对于 Compose 中 Spring Boot 容器的配置,还有一些额外的注意事项:
-
在
localhost:8000
上,除了网关服务,我们不想将后端服务直接暴露给主机。因此,我们不会在乘法、游戏化和对数中添加ports
部分。 -
此外,我们将为后端容器使用
depends_on
参数来等待,直到consul-importer
运行,因此在 Spring Boot 应用启动时,docker
配置文件的 Consul 配置将可用。 -
我们还将包含
rabbitmq
作为这些服务的依赖项,但是请记住,这并不能保证 RabbitMQ 服务器在我们的应用启动之前就准备好了。Docker 只验证容器是否已经启动。幸运的是,作为一种恢复技术,Spring Boot 在默认情况下会重试连接到服务器,所以最终,系统会变得稳定。
请参见清单 8-68 了解启动我们的系统所需的完整 Docker Compose 配置。
version: "3"
services:
frontend:
image: challenges-frontend:1.0
ports:
- '3000:80'
multiplication:
image: multiplication:0.0.1-SNAPSHOT
environment:
- SPRING_PROFILES_ACTIVE=docker
- SPRING_CLOUD_CONSUL_HOST=consul
depends_on:
- rabbitmq-dev
- consul-importer
networks:
- microservices
gamification:
image: gamification:0.0.1-SNAPSHOT
environment:
- SPRING_PROFILES_ACTIVE=docker
- SPRING_CLOUD_CONSUL_HOST=consul
depends_on:
- rabbitmq-dev
- consul-importer
networks:
- microservices
gateway:
image: gateway:0.0.1-SNAPSHOT
ports:
- '8000:8000'
environment:
- SPRING_PROFILES_ACTIVE=docker
- SPRING_CLOUD_CONSUL_HOST=consul
depends_on:
- rabbitmq-dev
- consul-importer
networks:
- microservices
logs:
image: logs:0.0.1-SNAPSHOT
environment:
- SPRING_PROFILES_ACTIVE=docker
- SPRING_CLOUD_CONSUL_HOST=consul
depends_on:
- rabbitmq-dev
- consul-importer
networks:
- microservices
consul-importer:
image: consul-importer:1.0
depends_on:
- consul-dev
networks:
- microservices
consul-dev:
image: consul:1.7.2
container_name: consul
ports:
- '8500:8500'
- '8600:8600/udp'
command: 'agent -dev -node=learnmicro -client=0.0.0.0 -log-level=INFO'
networks:
- microservices
rabbitmq-dev:
image: rabbitmq:3-management
container_name: rabbitmq
ports:
- '5672:5672'
- '15672:15672'
networks:
- microservices
networks:
microservices:
driver: bridge
Listing 8-68docker-compose.yml File with Everything Needed to Run Our Complete System
是时候测试我们作为 Docker 容器运行的完整系统了。像以前一样,我们运行docker-compose up
命令。我们将在输出中看到许多日志,由同时启动的多个服务生成,或者紧接在被定义为依赖项的服务之后。
您可能注意到的第一件事是,一些后端服务在尝试连接 RabbitMQ 时会抛出异常。这是预料中的情况。如前所述,RabbitMQ 的启动时间可能比微服务应用长。在rabbitmq
容器准备就绪后,这个问题会自动修复。
您还可能遇到由于系统中没有足够的内存或 CPU 来一起运行所有容器而导致的错误。这并不罕见,因为每个微服务容器最多可以占用 1GB 的 RAM。如果你不能运行所有这些容器,我希望书中的解释仍然有助于你理解一切是如何一起工作的。
要了解系统的状态,我们可以使用 Docker 提供的聚合日志(附加的输出)或来自logs
容器的输出。要尝试第二个选项,我们可以从不同的终端使用另一个 Docker 命令,docker-compose logs [container_name]
。参见清单 8-69 。请注意,我们的服务名是logs
,这解释了单词 repetition。
docker $ docker-compose logs logs
[...]
logs_1 | [gamification ] [aadd7c03a8b161da,34c00bc3e3197ff2] INFO 07:24:52.386 [main] o.s.d.r.c.DeferredRepositoryInitializationListener - Triggering deferred initialization of Spring Data repositories?
logs_1 | [multiplication ] [33284735df2b2be1,bc998f237af7bebb] INFO 07:24:52.396 [main] o.s.d.r.c.DeferredRepositoryInitializationListener - Triggering deferred initialization of Spring Data repositories?
logs_1 | [multiplication ] [b87fc916703f6b56,fd729db4060c1c74] INFO 07:24:52.723 [main] o.s.d.r.c.DeferredRepositoryInitializationListener - Spring Data repositories initialized!
logs_1 | [multiplication ] [97f86da754679510,9fa61b768e26aeb5] INFO 07:24:52.760 [main] m.b.m.MultiplicationApplication - Started MultiplicationApplication in 44.974 seconds (JVM running for 47.993)
logs_1 | [gamification ] [5ec42be452ce0e04,03dfa6fc3656b7fe] INFO 07:24:53.017 [main] o.s.d.r.c.DeferredRepositoryInitializationListener - Spring Data repositories initialized!
logs_1 | [gamification ] [f90c5542963e7eea,a9f52df128ac5c7d] INFO 07:24:53.053 [main] m.b.g.GamificationApplication - Started GamificationApplication in 45.368 seconds (JVM running for 48.286)
logs_1 | [gateway ] [59c9f14c24b84b32,36219539a1a0d01b] WARN 07:24:53.762 [boundedElastic-1] o.s.c.l.core.RoundRobinLoadBalancer - No servers available for service: gamification
Listing 8-69Checking the Logs of the Logs Container
此外,您还可以通过查看 Consul UI 的服务列表来监控服务状态,该列表可在localhost:8500
获得。在那里,您将看到健康检查是否通过,这意味着服务已经在提供服务并连接到 RabbitMQ。见图 8-40 。
图 8-40
Consul UI:检查容器健康
如果您单击其中一个服务(例如gamification
,您将看到主机地址现在是 docker 网络中容器的地址。见图 8-41 。这是用于服务相互连接的容器名称的替代名称。实际上,Consul 中的这种动态主机地址注册允许我们拥有一个给定服务的多个实例。如果我们使用一个container_name
参数,我们不能启动多个实例,因为它们的地址会冲突。
在这种情况下,应用使用 Docker 的主机地址,因为 Spring Cloud 会检测应用何时在 Docker 容器上运行。然后,Consul Discovery 库在注册时使用这个值。
图 8-41
consular ui:dock container address(停靠容器地址)
一旦容器变成绿色,我们就可以用浏览器导航到localhost:3000
并开始玩我们的应用。它的工作原理和以前一样。当我们解决一个挑战时,我们会在日志中看到事件是如何被游戏化消耗的,游戏化会增加分数和徽章。前端通过网关访问,这是唯一向主机公开的微服务。见图 8-42 。
图 8-42
Docker 上运行的应用
我们没有添加任何持久性,所以当我们关闭容器时,所有的数据都将消失。如果您想扩展 Docker 和 Docker Compose 的知识,可以考虑添加卷来存储 DB 文件(参见 https://tpd.io/compose-volumes
)。此外,在执行docker-compose down
时,不要忘记移除-v
标志,这样卷在两次执行之间被保留。
使用 Docker 扩展系统
使用 Docker Compose,我们还可以通过一个命令来扩展和缩减服务。首先,让我们像以前一样启动系统。如果您已经关闭了它,请执行以下命令:
docker$ docker-compose up
然后,从一个不同的终端,我们再次运行带有scale
参数的命令,指示服务名和我们想要获得的实例数量。我们可以在一个命令中多次使用该参数。
docker$ docker-compose up --scale multiplication=2 --scale gamification=2
现在,检查这个新终端的日志,看看 Docker Compose 如何为multiplication
和gamification
服务启动一个额外的实例。你也可以在 Consul 中验证这一点。见图 8-43 。
图 8-43
咨询 UI:游戏化的两个容器
感谢 Consul Discovery、我们的网关模式、Spring Cloud 负载平衡器和 RabbitMQ 消费者的负载平衡,我们将再次让我们的系统在多个实例之间正确地平衡负载。您可以通过解决一些来自 UI 的问题或者直接执行一些对网关服务的 HTTP 调用来验证这一点。如果您选择终端选项,您可以多次运行这个 HTTPie 命令:
$ http POST :8000/attempts factorA=15 factorB=20 userAlias=test-docker-containers guess=300
在日志中,您将看到multiplication_1
和multiplication_2
如何处理来自 API 的请求。同样的情况也发生在gamification_1
和gamification_2
上,它们也从代理的队列中获取不同的消息。见清单 8-70 。
multiplication_1 | 2020-07-30 09:48:34.559 INFO [,85acf6d095516f55,956486d186a612dd,true] 1 --- [nio-8080-exec-8] m.b.m.c.ChallengeAttemptController : Received new attempt from test-docker-containers
logs_1 | [multiplication ] [85acf6d095516f55,31829523bbc1d6ea] INFO 09:48:34.559 [http-nio-8080-exec-8] m.b.m.c.ChallengeAttemptController - Received new attempt from test-docker-containers
gamification_1 | 2020-07-30 09:48:34.570 INFO [,85acf6d095516f55,44508dd6f09c83ba,true] 1 --- [ntContainer#0-1] m.b.gamification.game.GameEventHandler : Challenge Solved Event received: 7
gamification_1 | 2020-07-30 09:48:34.572 INFO [,85acf6d095516f55,44508dd6f09c83ba,true] 1 --- [ntContainer#0-1] m.b.gamification.game
.GameServiceImpl : User test-docker-containers scored 10 points for attempt id 7
logs_1 | [gamification ] [85acf6d095516f55,8bdd9b6febc1eda8] INFO 09:48:34.570 [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] m.b.g.game.GameEventHandler - Challenge Solved Event received: 7
logs_1 | [gamification ] [85acf6d095516f55,247a930d09b3b7e5] INFO 09:48:34.572 [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] m.b.g.game.GameServiceImpl - User test-docker-containers scored 10 points for attempt id 7
multiplication_2 | 2020-07-30 09:48:35.332 INFO [,fa0177a130683114,f2c2809dd9a6bc44,true] 1 --- [nio-8080-exec-1] m.b.m.c.ChallengeAttemptController : Received new attempt from test-docker-containers
logs_1 | [multiplication ] [fa0177a130683114,f5b7991f5b1518a6] INFO 09:48:35.332 [http-nio-8080-exec-1] m.b.m.c.ChallengeAttemptController - Received new attempt from test-docker-containers
gamification_2 | 2020-07-30 09:48:35.344 INFO [,fa0177a130683114,298af219a0741f96,true] 1 --- [ntContainer#0-1] m.b.gamification.game.GameEventHandler : Challenge Solved Event received: 7
gamification_2 | 2020-07-30 09:48:35.358 INFO [,fa0177a130683114,298af219a0741f96,true] 1 --- [ntContainer#0-1] m.b.gamification.game.GameServiceImpl : User test-docker-containers scored 10 points for attempt id 7
logs_1 | [gamification ] [fa0177a130683114,2b9ce6cab6366dfb] INFO 09:48:35.344 [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] m.b.g.game.GameEventHandler - Challenge Solved Event received: 7
logs_1 | [gamification ] [fa0177a130683114,536fbc8035a2e3a2] INFO 09:48:35.358 [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] m.b.g.game.GameServiceImpl - User test-docker-containers scored 10 points for attempt id 7
Listing 8-70Scalability in Action with Docker Containers
共享 Docker 图像
到目前为止,我们构建的所有图像都存储在本地机器中。这无助于我们实现“一次构建,随处部署”的战略目标。然而,我们很亲密。
我们已经知道 Docker Hub,这是一个公共注册中心,我们从这里下载了 RabbitMQ 和 Consul 的官方映像,以及我们的微服务的基础映像。因此,如果我们上传自己的图片,每个人都可以看到。如果你同意的话,你可以在hub.docker.com
创建一个免费账户,然后开始上传(用 Docker 的术语来说就是推送)你的自定义图片。如果你想限制对你的图像的访问,他们还提供建立私有存储库的计划,托管在他们的云中。实际上,Docker Hub 并不是你存储 Docker 图片的唯一选择。您也可以按照“部署注册服务器”页面上的说明( https://tpd.io/own-registry
)部署自己的注册中心,或者选择不同云供应商提供的在线解决方案之一,如亚马逊的 ECR 或谷歌云的容器注册中心。
在 Docker 注册表中,您可以使用标签保存图像的多个版本。例如,我们的 Spring Boot 映像从pom.xml
文件中获得了版本号,因此它们获得了由初始化器创建的默认版本(例如multiplication:0.0.1-SNAPSHOT
)。我们可以在 Maven 中保留我们的版本控制策略,但是我们也可以使用docker tag
命令手动设置标签。此外,我们可以使用多个标签来引用同一个 Docker 图像。一种常见的做法是给 Docker 图像添加标签latest
,以指向注册表中图像的最新版本。作为 Docker 图像版本控制的示例,请参见领事图像的可用标签列表( https://tpd.io/consul-tags
)。
为了将 Docker 的命令行工具与注册表连接起来,我们使用了docker login
命令。如果我们想连接到私有主机,我们必须添加主机地址。否则,如果我们连接到集线器,我们可以使用普通命令。参见清单 8-71 。
$ docker login
Login with your Docker ID to push and pull images from Docker Hub. If you don't have a Docker ID, head over to https://hub.docker.com to create one.
Username: [your username]
Password: [your password]
Login Succeeded
Listing 8-71Logging In to Docker Hub
登录后,您可以将图像推送至注册表。请记住,要使它工作,您必须用您的用户名作为前缀来标记它们,因为这是 Docker Hub 的命名约定。让我们按照预期的模式更改其中一个图像的名称。此外,我们将修改版本标识符为0.0.1
。在这个例子中,注册的用户名是learnmicro
。
$ docker tag multiplication:0.0.1-SNAPSHOT learnmicro/multiplication:0.0.1
现在,您可以使用docker push
命令将这个映像推送到注册表中。参见清单 8-72 中的示例。
$ docker push learnmicro/multiplication:0.0.1
The push refers to repository [docker.io/learnmicro/multiplication]
abf6a2c86136: Pushed
9474e9c2336c: Pushing [================================> ] 37.97MB/58.48MB
9474e9c2336c: Pushing [========> ] 10.44MB/58.48MB
5cd38b221a5e: Pushed
d12f80e4be7c: Pushed
c789281314b6: Pushed
2611af6e99a7: Pushing [==================================================>] 7.23MB
02a647e64beb: Pushed
1ca774f177fc: Pushed
9474e9c2336c: Pushing [=================================> ] 39.05MB/58.48MB
8713409436f4: Pushing [===> ] 10.55MB/154.9MB
8713409436f4: Pushing [===> ] 11.67MB/154.9MB
7fbc81c9d125: Waiting
8713409436f4: Pushing [====> ] 12.78MB/154.9MB
9474e9c2336c: Pushed
6c918f7851dc: Pushed
8682f9a74649: Pushed
d3a6da143c91: Pushed
83f4287e1f04: Pushed
7ef368776582: Pushed
0.0.1: digest: sha256:ef9bbed14b5e349f1ab05cffff92e60a8a99e01c412341a3232fcd93aeeccfdc size: 4716
Listing 8-72Pushing an Image to the Public Registry, the Docker Hub
从这一刻起,任何能够访问注册表的人都可以提取我们的图像,并将其用作一个容器。如果我们像示例中那样使用 hub 的公共注册中心,图像就可以公开使用了。如果你很好奇,你可以通过访问 Docker Hub 的链接( https://hub.docker.com/r/learnmicro/multiplication
)来验证这张图片是否真的在线。见图 8-44 。
图 8-44
坞站中心中的乘法坞站图像
实际上,我们之前描述的所有 Docker 图像都已经在我的帐户下的公共注册表中可用,前缀为learnmicro/
。所有这些第一个版本都被标记为0.0.1
。这使得任何 Docker 用户无需构建任何东西就可以获得完整系统的版本并运行。他们只需要使用我们在清单 8-68 中使用的同一个docker-compose.yml
文件的一个版本,图像名称替换为指向公共注册表中的现有图像。所需的更改见清单 8-73 。
version: "3"
services:
frontend:
image: learnmicro/challenges-frontend:0.0.1
# ...
multiplication:
image: learnmicro/multiplication:0.0.1
# ...
gamification:
image: learnmicro/gamification:0.0.1
# ...
gateway:
image: learnmicro/gateway:0.0.1
# ...
logs:
image: learnmicro/logs:0.0.1
# ...
consul-importer:
image: learnmicro/consul-importer:0.0.1
# ...
consul-dev:
# same as before
rabbitmq-dev:
# same as before
networks:
# ...
Listing 8-73Changing the docker-compose.yml File to Use Publicly Available Images
我们实现了这一部分的目标。部署我们的应用变得很容易,因为唯一的必要条件是有 Docker 支持。这为我们提供了很多可能性,因为大多数管理和编排分布式系统的平台都支持 Docker 容器部署。在下一部分,我们将学习一些关于平台的基础知识。
平台和云原生微服务
在这一章中,我们讨论了一些模式,这些模式是正确的微服务架构的基础:路由、服务发现、负载平衡、健康报告、集中日志记录、集中配置、分布式跟踪和容器化。
如果我们花一点时间来分析我们系统中的组件,我们会意识到支持三个主要功能部分变得多么复杂:Web UI 和多元化和游戏化后端域。即使我们为许多这些模式采用了流行的实现,我们仍然必须配置它们,甚至部署一些额外的组件,以使我们的系统工作。
此外,我们还没有涉及聚类策略。如果我们在一台机器上部署所有的应用,那么出现问题的风险很高。理想情况下,我们希望复制组件,并将它们分布在多个物理服务器上。幸运的是,我们有工具来管理和协调服务器集群中的不同组件,无论是在您自己的硬件中还是在云中。最流行的替代方法工作在容器级或应用级,我们将分别描述它们。
集装箱平台
首先,让我们关注像 Kubernetes、Apache Mesos 或 Docker Swarm 这样的容器平台。在这些平台中,我们要么直接部署容器,要么使用包装结构,为特定工具提供额外的配置。例如,Kubernetes 中的一个部署单元是一个 pod ,它的定义(为了简单起见,一个部署)可以定义一个要部署的 Docker 容器列表(通常只有一个)和一些额外的参数来设置分配的资源,将 pod 连接到网络,或者添加外部配置和存储。
此外,这些平台通常集成了我们应该已经熟悉的模式。同样,让我们以 Kubernetes 为例,因为它是最受欢迎的选项之一。该列表从较高的角度介绍了它的一些特性:
-
跨构成集群的多个节点的容器编排。当我们在 Kubernetes (pod)中部署一个工作单元时,平台决定在哪里实例化它。如果整个节点死亡或者我们正常地关闭它,Kubernetes 会根据我们对并发实例的配置,找到另一个地方来放置这个工作单元。
-
路由 : Kubernetes 使用入口控制器,允许我们将流量路由到部署的服务。
-
负载平衡:Kubernetes 中的所有 pod 实例通常被配置为使用相同的 DNS 地址。然后,有一个名为 kube-proxy 的组件负责平衡各 pod 之间的负载。其他服务只需要调用一个通用的 DNS 别名,例如
multiplication.myk8scluster.io
。这是一种服务器端发现和负载平衡策略,适用于每个服务器组件。 -
自我修复 : Kubernetes 使用 HTTP 探针来确定服务是否有效和就绪。如果它们不是,我们可以配置它来清除那些僵尸实例,并启动新的实例来满足我们的冗余配置。
-
联网:与 Docker Compose 类似,Kubernetes 使用暴露的端口,并提供我们可以配置的不同网络拓扑。
-
集中配置:容器平台提供了像 ConfigMaps 这样的解决方案,所以我们可以将配置层从应用中分离出来,因此可以根据环境进行更改。
除此之外,Kubernetes 在安全性、集群管理和分布式存储管理等方面还有其他内置功能。
因此,我们可以在 Kubernetes 中部署我们的系统,并从所有这些特性中受益。此外,我们可以去掉我们构建的一些模式,将这些责任留给 Kubernetes。
知道如何配置和管理 Kubernetes 集群的人可能永远不会建议您像我们使用 Docker Compose 那样部署裸容器;相反,他们会直接从 Kubernetes 设置开始。然而,容器编排平台引入的额外复杂性永远不应该被低估。如果我们非常了解工具,我们肯定可以快速地建立并运行我们的系统。否则,我们将不得不钻研大量带有自定义 YAML 语法定义的文档。
无论如何,我建议您学习这些容器平台中的一个是如何工作的,并尝试在那里部署我们的系统以进入实际方面。它们在许多组织中很受欢迎,因为它们从应用中抽象出了所有的基础结构层。开发人员可以专注于从编码到构建容器的过程,基础设施团队可以专注于管理不同环境中的 Kubernetes 集群。
应用平台
现在,让我们来介绍一种不同类型的平台:应用运行时平台。这些提供了更高层次的抽象。基本上,我们可以编写自己的代码,构建一个.jar
文件,然后将它直接推送到一个环境中,使其随时可供使用。应用平台负责其他一切:将应用容器化(如果需要),在集群节点上运行它,提供负载平衡和路由,保护访问等。这些平台甚至可以聚合日志,并提供其他工具,如消息代理和数据库即服务。
在这个层面上,我们可以找到类似 Heroku 或者 CloudFoundry 这样的解决方案。我们可以选择在自己的服务器中管理这些平台,但最受欢迎的选择是云提供的解决方案。原因是我们可以在几分钟内将我们的产品或服务投入使用,而无需考虑许多模式实现或基础设施方面。
云提供商
为了完成平台和工具的景观,我们不得不提到云解决方案,如 AWS,Google,Azure,OpenShift 等。其中许多还为我们在本章中涉及的模式提供了实现:网关、服务发现、集中式日志、容器化等。
此外,他们通常也提供托管 Kubernetes 服务。这意味着,如果我们喜欢在容器平台级别工作,我们可以不需要手动设置这个平台。当然,这意味着除了我们使用的云资源(机器实例、存储等)之外,我们还必须为这项服务付费。).
请参见图 8-45 中的第一个示例,了解我们如何在云提供商中部署像我们这样的系统。在第一种情况下,我们选择只为一些低级服务付费,比如存储和虚拟机,但是我们安装了自己的 Kubernetes、数据库和 Docker 注册表。这意味着我们避免支付额外的托管服务,但我们必须自己维护所有这些工具。
图 8-45
使用云提供商:示例 1
现在检查图 8-46 中的替代设置。在第二种情况下,我们可以使用云提供商提供的一些额外的托管服务:Kubernetes、网关、Docker 注册表等。例如,在 AWS 中,我们可以使用一个名为 Amazon API Gateway 的网关即服务解决方案将流量直接路由到我们的容器,或者我们也可以选择一个带有自己的路由实现的 Amazon Elastic Kubernetes 服务。在任何一种情况下,我们都避免了以支付更多的云计算服务为代价来实现这些模式和维护这些工具。然而,请考虑到,从长远来看,您可能会通过这种方法节省资金,因为如果您决定采用这种方法,您需要有人来维护所有的工具。
图 8-46
使用云提供商:示例 2
做决定
鉴于有大量的选择,我们应该针对我们的具体情况分析每个抽象层次的利弊。可以想象,高层次的抽象比我们自己在较低层次构建解决方案更昂贵。另一方面,如果我们选择最便宜的选项,我们可能会花更多的钱来设置、维护和改进它。此外,如果我们计划将我们的系统部署到云中,我们应该比较每个供应商的成本,因为可能会有很大的差异。
通常,一个好主意是使用高级解决方案开始一个项目,这转化为托管服务和/或应用平台。它们可能更贵,更难定制,但你可以更快地试用你的产品或服务。然后,如果项目进展顺利,如果在成本方面值得的话,您可以决定获得这些服务的所有权。
云原生微服务
无论我们选择什么选项来部署我们的微服务,我们知道我们应该尊重一些良好的实践,以确保它们在云中正常工作(嗯,理想情况下,在任何环境中):数据层隔离、无状态逻辑、可伸缩性、弹性、简单日志记录等。当我们在本书中学习新的主题时,我们已经考虑了所有这些方面。
我们遵循的许多模式通常包含在云原生微服务的不同定义中。因此,我们可以在应用上贴上这个标签。
然而,术语云原生的太过雄心勃勃,在我看来有时会令人困惑。它被用来包装软件开发的多个方面的一系列术语和技术:微服务、事件驱动、持续部署、基础设施即代码、自动化、容器、云解决方案等。
云原生作为应用的广泛分类的问题是,它可能会导致人们认为他们需要所有包含的模式和方法来实现预期的目标。微服务?当然,这是新标准。事件驱动?为什么不呢?基础设施作为代码?去吧。看起来只有当我们能勾选所有的框,我们才能说我们做的是云原生应用。所有这些模式和技术都有好处,但是您的服务或产品需要所有这些吗?也许不是。您可以构建一个结构良好的整体,用它制作一个容器,并在几分钟内将其部署到云中。最重要的是,你可以自动化所有的过程来建造整块石头,并把它投入生产。那是云原生的纳米石吗?您在任何地方都找不到这个定义,但这并不意味着它不是适合您的特定情况的解决方案。
结论
这一章引导我们经历了一次微服务模式和工具的奇妙之旅。在每一部分中,我们分析了当前实施所面临的问题。然后,我们了解了众所周知的模式,这些模式可以解决这些挑战,同时也有助于使我们的系统具有可伸缩性、弹性、更易于分析和部署等特性。
对于这些模式中的大多数,我们选择了可以很容易地与 Spring Boot 集成的解决方案,因为这是我们实际案例的选择。例如,它的自动配置特性帮助我们快速建立了与作为服务发现注册中心和集中配置服务器的 Consul 的连接。尽管如此,这些模式适用于许多不同的编程语言和框架来构建微服务,因此您可以重用所有学到的概念。
我们的微服务架构变得成熟,一切都开始协同工作:网关将流量透明地路由到我们的微服务的多个实例,这些实例可以根据我们的需求动态分配。所有的日志输出都被传送到一个中心位置,在那里我们还可以看到每个进程的完整轨迹。
我们还使用 Docker 引入了容器化,这有助于准备我们的服务,以便轻松地部署到多个环境中。此外,我们了解了诸如 Kubernetes 和基于云的服务这样的容器平台如何帮助实现我们的非功能性需求:可伸缩性、弹性等。
在这一点上,你可能会问自己,如果有更简单的方法通过容器和应用平台或云中的托管服务来实现相同的结果,我们为什么要花几乎一整章(很长的一章)来了解所有这些常见的微服务模式。原因很简单:您需要知道模式是如何工作的,以便完全理解您正在应用的解决方案。如果您直接从完整的平台或云解决方案开始,您只能获得特定于供应商的高级视图。
通过本章,我们最终完成了在第六章开始的微服务架构的实施。当时,我们决定停止在小块中包含额外的逻辑,并为游戏化领域创建一个新的微服务。这三章帮助我们理解了迁移到微服务的原因,如何正确地隔离和沟通它们,以及如果我们想要项目成功,我们应该考虑哪些模式。
章节成就:
-
您了解了如何使用网关将流量路由到您的微服务,并在它们的实例之间提供负载平衡。
-
您使用服务发现、HTTP 负载平衡器和 RabbitMQ 队列扩展了微服务架构。
-
您通过检查每个实例的健康状况来发现它们何时不工作,从而使系统具有弹性。此外,您引入了重试来避免丢失请求。
-
您看到了如何使用外部配置服务器覆盖每个环境的配置。
-
您实现了跨微服务分布式跟踪的集中式日志,因此您可以轻松地从头到尾跟踪一个流程。
-
您将我们的微服务架构中的所有模式与 Spring Cloud 家族的项目相集成:Spring Cloud Gateway、Spring Cloud Load Balancer、Spring Cloud Consul(发现和配置)和 Spring Cloud Sleuth。
-
您了解了如何使用 Spring Boot 2.3 和云原生构建包为我们的应用创建 Docker 映像。
-
您看到了 Docker 和 Compose 如何帮助我们在任何地方部署微服务架构。此外,您看到了使用 Docker 创建新实例是多么容易。
-
您将我们在书中遵循的方法与其他替代方法(如容器平台和应用平台)进行了比较,这些替代方法已经包含了一些分布式架构所需的模式。
-
您已经理解了为什么我们在本章的每一步中都引入了新的模式和工具。**
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· DeepSeek 开源周回顾「GitHub 热点速览」
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
2020-10-02 《线性代数》(同济版)——教科书中的耻辱柱