优秀的编程知识分享平台

网站首页 > 技术文章 正文

Spring云原生实战指南:8 弹性和可扩展性

nanyue 2024-10-17 11:15:59 技术文章 7 ℃


本章涵盖

  • 了解反应器和弹簧的反应式编程
  • 使用 Spring WebFlux 和 Spring Data R2DBC 构建响应式服务器
  • 使用 Web 客户端构建响应式客户端
  • 使用反应器提高应用的弹性
  • 使用 Spring 和 Testcontainers 测试反应式应用程序

Polarsophia是Polar Bookshop业务背后的组织,对其新软件产品的进展非常满意。它的使命是传播有关北极和北极的知识和意识,在全球范围内提供其图书目录是其中的重要组成部分。

到目前为止,您构建的目录服务应用程序是一个很好的起点。它满足浏览和管理书籍的要求,并在遵循云原生模式和实践的同时做到这一点。它是自包含和无状态的。它使用数据库作为后备服务来存储状态。它可以通过环境变量或配置服务器在外部进行配置。它尊重环境平等。它通过自动执行测试作为部署管道的一部分进行验证,并遵循持续交付实践。为了获得最大的可移植性,它还被容器化,可以使用服务发现、负载平衡和复制等本机功能部署到 Kubernetes 集群。

该系统的另一个基本特征是可以购买书籍。在本章中,您将开始处理订单服务应用程序。此新组件不仅与数据库交互,还与目录服务交互。当应用程序广泛依赖 I/O 操作(如数据库调用)或与其他服务(如 HTTP 请求/响应通信)的交互时,目录服务中使用的每个请求线程模型开始暴露其技术限制。

在每请求线程模型中,每个请求都绑定到专门分配给其处理的线程。如果数据库或服务调用是处理的一部分,则线程将发出请求,然后阻塞,等待响应。在空闲期间,为该线程分配的资源将被浪费,因为它们不能用于其他任何用途。响应式编程范例解决了这个问题,并提高了所有 I/O 绑定应用程序的可伸缩性、弹性和成本效益。

反应式应用程序以异步和非阻塞方式运行,这意味着计算资源得到更有效的使用。这在云中是一个巨大的优势,因为您需要为使用的内容付费。当线程向后备服务发送调用时,它不会等待空闲,而是会继续执行其他操作。这消除了线程数和并发请求数之间的线性依赖关系,从而实现了更具可扩展性的应用程序。使用相同数量的计算资源,反应式应用程序可以比非反应式应用程序为更多的用户提供服务。

云原生应用程序是部署在动态环境中的高度分布式系统,在动态环境中,变化是恒定的,故障可能而且将会发生。如果服务不可用怎么办?如果请求在到达目标服务的途中丢失,会发生什么情况?如果响应在返回呼叫者的途中丢失怎么办?在这种情况下,我们能否保证高可用性?

弹性是迁移到云的目标之一,也是云原生应用程序的特征之一。我们的系统应该能够抵御故障,并且足够稳定,以确保为用户提供一定的服务水平。网络上服务之间的集成点是实现稳定和弹性的生产系统的最关键领域之一。这一点非常重要,以至于迈克尔·T·尼加德(Michael T. Nygard)在他的书《释放它!设计和部署生产就绪软件》(Pragmatic Bookshelf,2018)中花费了很大一部分时间讨论这个主题。

本章将重点介绍如何使用响应式范式为云构建弹性、可扩展且高效的应用程序。首先,我将介绍事件循环模型以及反应式流、项目反应器和 Spring 反应式堆栈的主要功能。然后,您将使用 Spring WebFlux 和 Spring Data R2DBC 构建一个响应式订单服务应用程序。

订单服务将与目录服务交互以检查书籍的可用性及其详细信息,因此您将了解如何使用 Spring WebClient 实现反应式 REST 客户端。两个服务之间的集成点是一个关键区域,需要格外小心以实现健壮性和容错能力。依靠 Reactor 项目,您将采用重试、超时和故障转移等稳定性模式。最后,您将编写自动测试,以使用 Spring 引导和测试容器验证反应式应用程序的行为。

注意本章中示例的源代码位于第 08/08 章-begin 和 Chapter08/08-end 文件夹中,其中包含项目的初始和最终状态 (https://github.com/ThomasVitale/cloud-native-spring-in-action)。

8.1 反应器和弹簧的异步和非阻塞架构

反应式宣言 (www.reactivemanifesto.org) 将反应式系统描述为响应式、弹性、弹性和消息驱动型系统。它的使命是构建松散耦合、可扩展、弹性且经济高效的应用程序,这与我们对云原生的定义完全兼容。新部分通过使用基于消息传递的异步和非阻塞通信范式来实现该目标。

在 Spring 中深入构建响应式应用程序之前,我们将探讨响应式编程的基础知识,为什么它对云原生应用程序很重要,以及它与命令式编程有何不同。我将介绍事件循环模型,该模型克服了每个请求线程模型的缺点。然后,您将学习由Project Actor和Spring反应式堆栈实现的Reactive Streams规范的基本概念。

8.1.1 从每个请求的线程到事件循环

正如您在第 3 章中看到的,非反应式应用程序为每个请求分配一个线程。在返回响应之前,线程不会用于任何操作。这就是每个请求的线程模型。当请求处理涉及密集型操作(如 I/O)时,线程将阻塞,直到这些操作完成。例如,如果需要读取数据库,线程将等待,直到从数据库返回数据。在等待期间,分配给处理线程的资源未得到有效利用。如果要支持更多并发用户,则必须确保有足够的可用线程和资源。最后,此范例对应用程序的可伸缩性设置了约束,并且不会以最有效的方式使用计算资源。图 8.1 显示了它的工作原理。


图 8.1 在每请求线程模型中,每个请求都由专用于其处理的线程处理。

反应式应用程序在设计上更具可扩展性和效率。在反应式应用程序中处理请求不涉及以独占方式分配给定线程 - 请求根据事件异步完成。例如,如果需要读取数据库,则处理该部分流的线程不会等到从数据库返回数据。相反,会注册一个回调,每当信息准备就绪时,都会发送通知,其中一个可用线程将执行回调。在此期间,请求数据的线程可用于处理其他请求,而不是等待空闲。

此范例称为事件循环,不会对应用程序的可伸缩性设置硬约束。它实际上使扩展更容易,因为并发请求数量的增加并不严格取决于线程的数量。事实上,Spring 中反应式应用程序的默认配置是每个 CPU 内核只使用一个线程。凭借非阻塞 I/O 功能和基于事件的通信范例,反应式应用程序允许更有效地利用计算资源。图 8.2 显示了它的工作原理。


图 8.2 在事件循环模型中,请求由线程处理,这些线程在等待密集型操作时不会阻塞,允许它们同时处理其他请求。

我想简要提及这两种范式之间的区别,因为它有助于解释响应式编程背后的推理。但是,您不需要知道这些范式的内部机制的细节,因为我们不必在如此低的级别上工作或实现事件循环。相反,我们将依赖于方便的更高级别的抽象,这将使我们能够专注于应用程序的业务逻辑,而不是花时间处理线程级别的处理。

规模和成本优化是迁移到云的两个关键原因,因此响应式范式非常适合云原生应用程序。扩展应用程序以支持工作负载增加的要求变得不那么高。通过更有效地使用资源,您可以节省云提供商提供的计算资源。迁移到云的另一个原因是弹性,反应式应用程序也有助于实现这一目标。

反应式应用的基本特征之一是它们提供非阻塞背压(也称为控制流)。这意味着消费者可以控制他们接收的数据量,从而降低生产者发送的数据超过消费者处理能力的风险,这可能导致 DoS 攻击,减慢应用程序速度,级联故障,甚至导致完全崩溃。

反应式范例是阻塞 I/O 操作问题的解决方案,这些操作需要更多线程来处理高并发性,这可能导致应用程序运行缓慢或完全无响应。有时,范例被误认为是提高应用程序速度的一种方式。反应式是关于提高可扩展性和弹性,而不是速度。

然而,权力越大,麻烦越大。当你期望使用较少的计算资源实现高流量和并发性时,或者在流式处理方案中,响应式是一个很好的选择。但是,您还应该意识到这种范式引入的额外复杂性。除了需要思维方式转变以事件驱动的方式思考之外,由于异步 I/O,反应式应用程序的调试和故障排除更具挑战性。在急于重写所有应用程序以使它们具有响应性之前,请三思而后行,考虑这是否必要,并考虑优点和缺点。

响应式编程并不是一个新概念。它已经使用了多年。最近范式在Java生态系统中取得成功的原因是由于Reactive Streams规范及其实现,如Project Reactor,RxJava和Vert.x,它们为开发人员提供了方便的高级接口,用于构建异步和非阻塞应用程序,而无需处理设计消息驱动流的底层细节。下一节将介绍Project Reactor,Spring使用的响应式框架。

8.1.2 项目反应器:单声道和通量的反应流

Reactive Spring 基于 Project Reactor,这是一个用于在 JVM 上构建异步、非阻塞应用程序的框架。Reactor 是 Reactive Streams 规范的实现,旨在提供“具有非阻塞背压的异步流处理标准”(www.reactive-streams.org)。

从概念上讲,反应式流类似于 Java Stream API,因为我们使用它们来构建数据管道。其中一个主要区别是 Java 流是基于拉取的:消费者以命令式和同步方式处理数据。相反,反应式流是基于推送的:当新数据可用时,生产者会通知使用者,因此处理是异步进行的。

反应式流根据生产者/消费者范式工作。生产者被称为发布者。它们产生可能最终可用的数据。Reactor 提供了两个中央 API,为类型 <T> 的对象实现 Producer<T> 接口,它们用于组合异步、可观察的数据流:单声道<T> 和 Flux<T>:

  • 单声道<T> - 表示单个异步值或空结果 (0..1)
  • Flux<T> - 表示零个或多个项目的异步序列 (0..N)

在 Java 流中,您将处理 Optional<Customer> 或 Collection <Customer> 等对象。在反应式流中,您将拥有Mono<Customer>或Flux<Customer>。反应流的可能结果是空结果、值或错误。所有这些都作为数据处理。当发布者返回所有数据时,我们说反应式流已成功完成

使用者之所以称为订阅者,是因为他们订阅发布,并在有新数据可用时收到通知。作为订阅的一部分,使用者还可以通过通知发布者他们一次只能处理一定数量的数据来定义背压。这是一个强大的功能,可以让消费者控制接收的数据量,防止他们不知所措和变得无响应。反应式流仅在有订阅者时激活。

您可以构建反应式流,将来自不同来源的数据组合在一起,并使用 Reactor 的大量运算符对其进行操作。在 Java 流中,您可以使用流畅的 API 通过 map、flatMap 或 filter 等运算符处理数据,每个运算符都会构建一个新的 Stream 对象,使上一步保持不可变。同样,您可以使用流畅的 API 和运算符构建反应式流,以异步处理接收的数据。

除了可用于 Java 流的标准运算符之外,您还可以使用更强大的运算符来应用背压、处理错误并提高应用程序弹性。例如,您将看到如何使用 retryWhen() 和 timeout() 运算符使订单服务和目录服务之间的交互更加可靠。操作员可以对发布者执行操作并返回新发布服务器,而无需修改原始发布者,因此您可以轻松构建功能性和不可变的数据流。

Project Reactor 是 Spring 反应式堆栈的基础,它允许您根据单声道<>和 Flux<T> 来实现您的业务逻辑。在下一节中,您将了解有关使用 Spring 构建响应式应用程序的选项的更多信息。

8.1.3 了解弹簧反应堆栈

使用 Spring 构建应用程序时,您可以在 servlet 堆栈和反应式堆栈之间进行选择。servlet 堆栈依赖于同步阻塞 I/O,并使用每个请求的线程模型来处理请求。另一方面,反应式堆栈依赖于异步、非阻塞 I/O,并使用事件循环模型来处理请求。

servlet 堆栈基于 Servlet API 和 Servlet 容器(如 Tomcat)。相比之下,响应式模型基于 Reactive Streams API(由 Project Reactor 实现)和 Netty 或 Servlet 容器(至少为 3.1 版)。这两个堆栈都允许您使用注释为 @RestController 的类(在第 3 章中使用)或称为路由器函数的功能端点(您将在第 9 章中了解)来构建 RESTful 应用程序。servlet 堆栈使用 Spring MVC,而响应式堆栈使用 Spring WebFlux。图 8.3 比较了两个堆栈。(有关更广泛的概述,您可以参考 https://spring.io/reactive。)


图 8.3 servlet 堆栈基于 Servlet API,支持同步和阻塞操作。反应式堆栈基于项目反应器,支持异步和非阻塞操作。

Tomcat 是基于 servlet 的应用程序(如目录服务)的默认选择。Netty 是反应式应用程序的首选,可提供最佳性能。

Spring生态系统中的所有主要框架都提供非响应式和响应式选项,包括Spring Security,Spring Data和Spring Cloud。总的来说,Spring 反应式堆栈为构建反应式应用程序提供了一个更高级别的接口,依赖于熟悉的 Spring 项目,而不关心反应式流的底层实现。

8.2 使用 Spring WebFlux 和 Spring Data R2DBC 的响应式服务器

到目前为止,我们已经开发了目录服务,这是一个非反应式(或命令式)应用程序,使用 Spring MVC 和 Spring Data JDBC。本节将教您如何使用Spring WebFlux和Spring Data R2DBC构建响应式Web应用程序(订单服务)。订购服务将提供购买书籍的功能。与目录服务一样,它将公开一个REST API并将数据存储在PostgreSQL数据库中。与目录服务不同,它将使用反应式编程范例来提高可伸缩性、弹性和成本效益。

您将看到在前面章节中学到的原则和模式也适用于响应式应用程序。主要区别在于,我们将从以命令式方式实现业务逻辑转向构建异步处理的反应式流。

订购服务还将通过其 REST API 与目录服务交互,以获取有关书籍的详细信息并检查其可用性。这将是第8.3节的重点。图 8.4 显示了系统的新组件。


图 8.4 订单服务应用程序公开了一个 API 来提交和检索图书订单,使用 PostgreSQL 数据库来存储数据,并与图书服务通信以获取图书详细信息。

正如您在第 3 章中学到的,我们应该首先从 API 开始。订单服务将公开一个 REST API,以检索现有图书订单并提交新订单。每个订单只能与一本书相关,最多可以与五本相关。表 8.1 中描述了该 API。

表 8.1 订单服务将公开的 REST API 规范

端点

HTTP方法

请求正文

地位

响应正文

描述

/订单

发布

订单请求

200

次序

为给定数量的给定图书提交新订单

/订单

获取


200

订单[]

检索所有订单

现在,进入代码。

注意如果您还没有按照前面章节中实现的示例进行操作,则可以参考本书随附的存储库,并使用 Chapter08/08-begin 文件夹中的项目作为起点 (https://github.com/ThomasVitale/cloud-native-spring-in-action)。

8.2.1 使用 Spring 引导引导反应式应用程序

您可以从 Spring Initializr (https://start.spring.io) 初始化订单服务项目,将结果存储在新的订单服务 Git 存储库中,并将其推送到 GitHub。初始化参数如图 8.5 所示。


图 8.5 从 Spring 初始化初始化订单服务项目的参数

提示如果您不想在Spring Initializr网站上进行手动生成,则可以在本章的begin文件夹中找到一个curl命令,您可以在终端窗口中运行该命令以下载zip文件。它包含入门所需的所有代码。

自动生成的 build.gradle 文件的依赖项部分如下所示:

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
  implementation 'org.springframework.boot:spring-boot-starter-validation'
  implementation 'org.springframework.boot:spring-boot-starter-webflux'
 
  runtimeOnly 'org.postgresql:r2dbc-postgresql'
 
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
  testImplementation 'io.projectreactor:reactor-test'
  testImplementation 'org.testcontainers:junit-jupiter'
  testImplementation 'org.testcontainers:postgresql'
  testImplementation 'org.testcontainers:r2dbc'
}

这些是主要的依赖项:

  • Spring Reactive Web (org.springframework.boot:spring-boot-starter-webflux) - 提供使用 Spring WebFlux 构建响应式 Web 应用程序所需的库,并将 Netty 作为默认嵌入式服务器。
  • Spring Data R2DBC (org.springframework.boot:spring-boot-starter-data-r2dbc) - 提供必要的库,以便在反应式应用程序中使用 Spring Data 使用 R2DBC 将数据持久化到关系数据库中。
  • Validation (org.springframework.boot:spring-boot-starter-validation) - 提供使用 Java Bean Validation API 进行对象验证所需的库。
  • PostgreSQL (org.postgresql:r2dbc-postgresql) - 提供 R2DBC 驱动程序,允许应用程序以响应方式连接到 PostgreSQL 数据库。
  • Spring Boot Test (org.springframework.boot:spring-boot-starter-test) - 提供多个库和实用程序来测试应用程序,包括 Spring Test、JUnit、AssertJ 和 Mockito。它会自动包含在每个 Spring Boot 项目中。
  • 反应器测试 (io.projectreactor:reactor-test) - 提供实用程序来测试基于项目反应器的反应式应用程序。它会自动包含在每个反应式 Spring Boot 项目中。
  • Testcontainers (org.testcontainers:junit-jupiter, org.testcontainers:postgresql, org.testcontainers:r2dbc) - 为使用轻量级 Docker 容器测试应用程序提供必要的库。特别是,它为支持R2DBC驱动程序的PostgreSQL提供了测试容器。

Spring Boot 中响应式应用程序的默认和推荐嵌入式服务器是 Reactor Netty,它建立在 Netty 之上,在 Project Reactor 中提供响应式功能。您可以通过属性或定义 WebServerFactoryCustomizer<NettyReactiveWebServerFactory> 组件来配置它。让我们使用第一种方法。

首先,将 Spring Initializr 生成的 application.properties 文件重命名为 application.yml,并使用 spring.application.name 属性定义应用程序名称。与 Tomcat 一样,您可以通过 server.port 属性定义服务器端口,通过 server.shutdown 配置正常关机,并使用 spring.lifecycle.timeout-per-shutdown-phase 设置宽限期。使用特定的 Netty 属性,可以进一步自定义服务器的行为。例如,您可以使用 server.netty .connection-timeout 和 server.netty.idle-timeout 属性为 Netty 定义连接和空闲超时。

清单 8.1 配置 Netty 服务器和正常关闭

server:
  port: 9002                           ?
  shutdown: graceful                   ?
  netty:
    connection-timeout: 2s             ?
    idle-timeout: 15s                  ?
 
spring:
  application:
    name: order-service
  lifecycle:
    timeout-per-shutdown-phase: 15s    ?

? 服务器将接受连接的端口

? 实现正常关机

? 等待与服务器建立TCP连接的时间

? 如果没有数据传输,关闭TCP连接之前要等待多长时间

? 定义 15 秒宽限期

完成此基本设置后,我们现在可以定义域实体及其持久性。

8.2.2 使用 Spring Data R2DBC 反应性地持久化数据

在第 5 章中,您了解了 Spring Boot 应用程序和数据库之间的交互涉及数据库驱动程序、实体和存储库。您在Spring Data JDBC上下文中学到的相同概念也适用于Spring Data R2DBC。Spring Data 提供了常见的抽象和模式,使导航不同的模块变得简单明了。

与目录服务相比,订单服务的主要区别在于数据库驱动程序的类型。JDBC 是 Java 应用程序用来与关系数据库通信的最常见驱动程序,但它不支持响应式编程。已经有一些尝试提供对关系数据库的反应性访问。一个突出并得到广泛支持的项目是由Pivotal(现在的VMware Tanzu)发起的Reactive Relational Database Connectivity(R2DBC)。R2DBC驱动程序可用于所有主要数据库(如PostgreSQL,MariaDB,MySQL,SQL Server和Oracle DB),并且有几个项目的客户端,包括Spring Boot with Spring Data R2DBC和Testcontainers。

本节将指导您使用Spring Data R2DBC和PostgreSQL为订单服务定义域实体和持久性层。让我们开始吧。

为订单服务运行 POSTGRESQL 数据库

首先,我们需要一个数据库。我们将采用按服务数据库的方法,使我们的应用程序松散耦合。在决定目录服务和订单服务各有一个数据库后,我们有两个实际存储选项。我们可以对两个数据库使用相同的数据库服务器,也可以对两个不同的数据库使用。为方便起见,我们将使用在第 5 章中设置的相同 PostgreSQL 服务器来托管目录服务使用的polardb_catalog数据库和订单服务使用的新polardb_order数据库。

转到您的 polar-deployment 存储库,并创建一个新的 docker/postgresql 文件夹。然后在文件夹中添加新的 init.sql 文件。将以下代码添加到 init.sql 文件中;它是PostgreSQL应该在启动阶段运行的初始化脚本。

示例 8.2 使用两个数据库初始化 PostgreSQL 服务器

CREATE DATABASE polardb_catalog;
CREATE DATABASE polardb_order;

接下来,打开 docker-compose.yml 文件并更新 PostgreSQL 容器定义以加载初始化脚本。请记住删除 POSTGRES_DB 环境变量的值,因为我们现在将数据库创建委托给脚本。在本书的源代码中,请参考 Chapter08/08-end/polar-deployment/docker 来检查最终结果。

示例 8.3 从 SQL 脚本初始化 PostgreSQL 服务器

version: "3.8"
services:
  ...
  polar-postgres:
    image: "postgres:14.4"
    container_name: "polar-postgres"
    ports:
      - 5432:5432
    environment:                       ?
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=password
    volumes:                           ?
      - ./postgresql/init.sql:/docker-entrypoint-initdb.d/init.sql 

? 不再为POSTGRES_DB定义任何值。

? 将初始化 SQL 脚本作为卷挂载到容器中

最后,根据新配置启动一个新的 PostgreSQL 容器。打开终端窗口,导航到定义 docker-compose.yml 文件的文件夹,然后运行以下命令:

$ docker-compose up -d polar-postgres

在本章的其余部分,我将假设您已经启动并运行了数据库。

使用 R2DBC 连接到数据库

Spring Boot 允许您通过 spring.r2dbc 属性配置反应式应用程序与关系数据库的集成。打开订单服务项目的 application.yml 文件,并使用 PostgreSQL 配置连接。缺省情况下启用连接池,您可以通过定义连接超时和大小来进一步配置它,就像在第 5 章中对 JDBC 所做的那样。由于它是一个反应式应用程序,因此连接池可能会比使用 JDBC 时小。您可以在监视在正常条件下运行的应用程序后调整这些值。

示例 8.4 通过 R2DBC 配置数据库集成

spring:
  r2dbc: 
    username: user                                         ?
    password: password                                     ?
    url: r2dbc:postgresql://localhost:5432/polardb_order   ?
    pool: 
      max-create-connection-time: 2s                       ?
      initial-size: 5                                      ?
      max-size: 10                                         ?

? 具有访问给定数据库的权限的用户

? 给定用户的密码

? 标识要与之建立连接的数据库的 R2DBC URL

? 等待从池获取连接的最长时间

? 连接池的初始大小

? 池中保留的最大连接数

现在,您已经通过R2DBC驱动程序将反应式Spring Boot应用程序连接到PostgreSQL数据库,您可以继续定义要持久化的数据。

定义持久实体

订单服务应用程序提供提交和检索订单的功能。这就是域实体。为业务逻辑添加新的 com.polarbookshop.orderservice.order .domain 包,并创建一个 Order Java 记录来表示域实体,就像在目录服务中定义 Book 一样。

按照第 5 章中使用的相同方法,使用 @Id 注释标记表示数据库中主键的字段,并使用 @Version 提供版本号,这对于处理并发更新和使用乐观锁定至关重要。您还可以添加必要的字段以使用@CreatedDate和@LastModifiedDate注释保存审核元数据。

将实体映射到关系表的缺省策略是将 Java 对象名称转换为小写。在此示例中,Spring Data 将尝试将订单记录映射到订单表。问题是顺序在SQL中是一个保留字。不建议将其用作表名,因为它需要特殊处理。您可以通过命名表顺序并通过@Table注释(来自 org.springframework.data.relational.core.mapping 包)配置对象关系映射来克服这个问题。

示例 8.5 Order 记录定义了域和持久实体

package com.polarbookshop.orderservice.order.domain;
 
import java.time.Instant;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.annotation.Version;
import org.springframework.data.relational.core.mapping.Table;
 
@Table("orders")                ?
public record Order (
 
  @Id
  Long id,                      ?
 
  String bookIsbn,
  String bookName,
  Double bookPrice,
  Integer quantity,
  OrderStatus status,
 
  @CreatedDate
  Instant createdDate,          ?
 
  @LastModifiedDate
  Instant lastModifiedDate,     ?
 
  @Version
  int version                   ?
){
  public static Order of(
    String bookIsbn, String bookName, Double bookPrice,
    Integer quantity, OrderStatus status
  ) {
    return new Order(
      null, bookIsbn, bookName, bookPrice, quantity, status, null, null, 0
    );
  }
}

? 配置“订单”对象和“订单”表之间的映射

? 实体的主键

? 实体的创建时间

? 上次修改实体的时间

? 实体的版本号

订单可以经历不同的阶段。如果目录中有所请求的书籍,则接受订单。如果不是,则被拒绝。一旦订单被接受,它就可以被发送,正如你将在第10章中看到的那样。您可以在 com.polarbookshop.orderservice.order.domain 包的 OrderStatus 枚举中定义这三个状态。

清单 8.6 枚举描述订单的状态

package com.polarbookshop.orderservice.order.domain;
 
public enum OrderStatus {
  ACCEPTED,
  REJECTED,
  DISPATCHED
}

可以使用@EnableR2dbcAuditing注释在配置类中启用 R2DBC 审计功能。在新的 com.polarbookshop.orderservice.config 包中创建 DataConfig 类,并在其中启用审核。

清单 8.7 通过注解配置启用 R2DBC 审计

package com.polarbookshop.orderservice.config;
 
import org.springframework.context.annotation.Configuration;
import org.springframework.data.r2dbc.config.EnableR2dbcAuditing;
 
@Configuration                 ?
@EnableR2dbcAuditing           ?
public class DataConfig {}

? 指示一个类作为 Spring 配置的源

? 为持久实体启用 R2DBC 审计

定义要保留的数据后,您可以继续探索如何访问它。

使用反应式存储库

Spring Data 为项目中的所有模块(包括 R2DBC)提供了存储库抽象。这与您在第 5 章中所做的唯一区别是您将使用响应式存储库。

在 com.polarbookshop.orderservice.order.domain 包中,创建一个新的 OrderRepository 接口并使其扩展 ReactiveCrudRepository,指定处理的数据类型(Order)和@Id注释字段的数据类型(Long)。

示例 8.8 用于访问订单的存储库接口

package com.polarbookshop.orderservice.order.domain;
 
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
 
public interface OrderRepository
  extends ReactiveCrudRepository<Order,Long> {}   ?

? 扩展提供 CRUD 操作的反应式存储库,指定托管实体的类型(订单)及其主键类型(长)

ReactiveCrudRepository 提供的 CRUD 操作足以满足订单服务应用程序的用例,因此您无需添加任何自定义方法。但是,我们仍然缺少数据库中的订单表。让我们使用 Flyway 来定义它。

使用 FLYWAY 管理数据库架构

Spring Data R2DBC 支持通过模式.sql和数据.sql文件初始化数据源,就像 Spring Data JDBC 一样。正如您在第 5 章中了解到的,该功能对于演示和实验来说很方便,但最好针对生产用例显式管理架构。

对于目录服务,我们使用 Flyway 来创建和发展其数据库模式。我们可以对订单服务做同样的事情。但是,Flyway 还不支持 R2DBC,因此我们需要提供一个 JDBC 驱动程序来与数据库通信。Flyway 迁移任务仅在应用程序启动时和单个线程中运行,因此对这种情况使用非反应式通信方法不会影响整个应用程序的可伸缩性和效率。

在订单服务项目的 build.gradle 文件中,向 Flyway、PostgreSQL JDBC 驱动程序和 Spring JDBC 添加新的依赖项。请记住在新添加后刷新或重新导入 Gradle 依赖项。

示例 8.9 在订单服务中添加 Flyway 和 JDBC 的依赖关系

dependencies {
  ...
  runtimeOnly 'org.flywaydb:flyway-core'             ?
  runtimeOnly 'org.postgresql:postgresql'            ?
  runtimeOnly 'org.springframework:spring-jdbc'      ?
}

? 提供通过迁移对数据库进行版本控制的功能

? 提供 JDBC 驱动程序,允许应用程序连接到 PostgreSQL 数据库

? 提供与 JDBC API 的 Spring 集成。它是 Spring 框架的一部分,不要与 Spring Data JDBC 混淆。

然后,您可以编写 SQL 脚本,用于在 src/main/resources/db/migration 下的V1__Initial_架构.sql文件中创建订单表。确保在版本号后键入两个下划线。

清单 8.10 用于模式初始化的 Flyway 迁移脚本

CREATE TABLE orders (                                   ?
  id                  BIGSERIAL PRIMARY KEY NOT NULL,   ?
  book_isbn           varchar(255) NOT NULL,
  book_name           varchar(255),
  book_price          float8,
  quantity            int NOT NULL,
  status              varchar(255) NOT NULL,
  created_date        timestamp NOT NULL,
  last_modified_date  timestamp NOT NULL,
  version             integer NOT NULL
);

? 订单表的定义

? 将 id 字段声明为主键

最后,打开 application.yml 文件,并将 Flyway 配置为使用由 Spring Data R2DBC 管理的相同数据库,但使用 JDBC 驱动程序。

示例 8.11 在 JDBC 上配置 Flyway 集成

spring:
  r2dbc:
    username: user
    password: password
    url: r2dbc:postgresql://localhost:5432/polardb_order
    pool:
      max-create-connection-time: 2s
      initial-size: 5
      max-size: 10
  flyway: 
    user: ${spring.r2dbc.username}                         ?
    password: ${spring.r2dbc.password}                     ?
    url: jdbc:postgresql://localhost:5432/polardb_order    ?

? 从为 R2DBC 配置的用户名中获取值

? 从为 R2DBC 配置的密码中获取值

? 为 R2DBC 配置的相同数据库,但使用 JDBC 驱动程序

您可能已经注意到,在反应式应用程序中定义域对象并添加持久性层类似于对命令式应用程序执行的操作。您在此会话中遇到的主要区别是使用 R2DBC 驱动程序而不是 JDBC,并且具有单独的 Flyway 配置(至少在将 R2DBC 支持添加到 Flyway 项目之前:https://github.com/flyway/flyway/issues/2502)。

在下一节中,您将学习如何在业务逻辑中使用单声道和通量。

8.2.3 使用反应式流实现业务逻辑

Spring 反应式堆栈使构建异步、非阻塞应用程序变得简单明了。在上一节中,我们使用了Spring Data R2DBC,并且不必处理任何潜在的反应性问题。对于 Spring 中的所有反应式模块来说,这通常是正确的。作为开发人员,您可以依靠熟悉、简单且高效的方法来构建反应式应用程序,而框架则负责所有繁重的工作。

默认情况下,Spring WebFlux 假定一切都是反应式的。此假设意味着您需要通过交换 Publisher<T> 对象(如 Mono<T> 和 Flux<T>)来与框架进行交互。例如,我们之前创建的 OrderRepository 将允许以 Mono<Order> 和 Flux<Order> 对象的形式访问订单,而不是像在非响应式上下文中那样返回 Optional<Order> 和 Collection<Order>。让我们看看它的实际效果。

在 com.polarbookshop.orderservice.order.domain 包中,创建一个新的 OrderService 类。首先,让我们实现通过存储库读取订单的逻辑。当涉及多个订单时,可以使用 Flux<Order> 对象来表示零个或多个订单的异步序列。

清单 8.12 通过反应流获取订单

package com.polarbookshop.orderservice.order.domain;
 
import reactor.core.publisher.Flux;
import org.springframework.stereotype.Service;
 
@Service                                         ?
public class OrderService {
  private final OrderRepository orderRepository;
  public OrderService(OrderRepository orderRepository) {
    this.orderRepository = orderRepository;
  }
  public Flux<Order> getAllOrders() {            ?
    return orderRepository.findAll();
  }
}

? 将类标记为由 Spring 管理的服务的构造型注释

? 助焊剂用于发布多个订单 (0..N)

接下来,我们需要一种方法来提交订单。在与目录服务集成之前,我们始终可以默认拒绝提交的订单。OrderRepository 公开了 ReactiveCrudRepository 提供的 save() 方法。您可以构建一个反应式流,将 Mono<Order> 类型的对象传递给 OrderRepository,该存储库将订单保存在数据库中。

给定一个标识一本书和要订购的份数的 ISBN,您可以使用 Mono.just() 构建 Mono 对象,就像使用 Stream.of() 构建 Java Stream 对象一样。区别在于反应行为。

您可以使用 Mono 对象启动反应式流,然后依靠 flatMap() 运算符将数据传递给 OrderRepository。将以下代码添加到 OrderService 类中,并完成业务逻辑实现。

示例 8.13 在提交订单请求时保留被拒绝的订单

...
public Mono<Order> submitOrder(String isbn, int quantity) {
  return Mono.just(buildRejectedOrder(isbn, quantity))        ?
    .flatMap(orderRepository::save);                          ?
}
 
public static Order buildRejectedOrder(
  String bookIsbn, int quantity
) {                                                           ?
  return Order.of(bookIsbn, null, null, quantity, OrderStatus.REJECTED);
}
...

? 从“订单”对象创建“单声道”

? 将反应流的上一步异步生成的 Order 对象保存到数据库中

? 当订单被拒绝时,我们只指定 ISBN、数量和状态。Spring 数据负责添加标识符、版本和审核元数据。

地图与平面地图

使用 Reactor 时,在 map() 和 flatMap() 运算符之间进行选择通常是混淆的根源。这两个运算符都返回一个响应式流(Mono<T> 或 Flux<T>),但是当 map() 在两个标准 Java 类型之间映射时,flatMap() 从一个 Java 类型映射到另一个响应式流。

在示例 8.13 中,我们从 Order 类型的对象映射到 Mono<Order>(由 OrderRepository 返回)。由于 map() 运算符期望目标类型不是反应式流,因此它仍然会将其包装在一个流中并返回一个 Mono<Mono<Order>> 对象。另一方面,flatMap() 运算符期望目标类型是反应式流,因此它知道如何处理 OrderRepository 生成的发布者并正确返回 Mono<Order> 对象。

在下一节中,您将通过公开用于提取和提交订单的 API 来完成订单服务的基本实现。

8.2.4 使用 Spring WebFlux 公开 REST API

在Spring WebFlux应用程序中定义RESTful端点有两个选项:@RestController类或函数bean(路由器函数)。对于订单服务应用程序,我们将使用第一个选项。与我们在第 3 章中所做的不同,方法处理程序将返回反应式对象。

对于 GET 端点,我们可以使用之前定义的 Order 域实体并返回 Flux<Order> 对象。提交订单时,用户必须提供所需图书的 ISBN 和他们想要购买的书数。我们可以在将充当数据传输对象 (DTO) 的 OrderRequest 记录中对该信息进行建模。验证输入也是一种很好的做法,正如您在第 3 章中学到的那样。

创建一个新的com.polarbookshop.orderservice.order.web软件包,并定义一个OrderRequest记录来保存提交的订单信息。

示例 8.14 具有验证约束的 OrderRequest DTO 类

package com.polarbookshop.orderservice.order.web;
 
import javax.validation.constraints.*;
 
public record OrderRequest (
 
  @NotBlank(message = "The book ISBN must be defined.")
  String isbn,                                                     ?
 
  @NotNull(message = "The book quantity must be defined.")
  @Min(value = 1, message = "You must order at least 1 item.")
  @Max(value = 5, message = "You cannot order more than 5 items.")
  Integer quantity                                                 ?
){}

? 不得为空,并且必须至少包含一个非空格字符

? 不得为 null,并且必须包含 1 到 5 之间的值

在同一包中,创建一个 OrderController 类来定义 Order Service 应用程序公开的两个 RESTful 终结点。由于您为 OrderRequest 对象定义了验证约束,因此还需要使用熟悉的@Valid注释在调用该方法时触发验证。

示例 8.15 定义处理程序来处理 REST 请求

package com.polarbookshop.orderservice.order.web;
 
import javax.validation.Valid;
import com.polarbookshop.orderservice.order.domain.Order;
import com.polarbookshop.orderservice.order.domain.OrderService;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.web.bind.annotation.*;
 
@RestController                                      ?
@RequestMapping("orders")                            ?
public class OrderController {
  private final OrderService orderService;
  public OrderController(OrderService orderService) {
    this.orderService = orderService;
  }
 
  @GetMapping
  public Flux<Order> getAllOrders() {                ?
    return orderService.getAllOrders();
  }
 
  @PostMapping
  public Mono<Order> submitOrder(
    @RequestBody @Valid OrderRequest orderRequest    ?
  ) {
    return orderService.submitOrder(
     orderRequest.isbn(), orderRequest.quantity()
    );
  }
}

? 构造型注释,将类标记为 Spring 组件和 REST 端点的处理程序源

? 标识类为其提供处理程序的根路径映射 URI(/orders)

? 助焊剂用于发布多个订单 (0..N)。

? 接受订单请求对象,经过验证并用于创建订单。创建的订单将作为单声道返回。

此 REST 控制器完成了订单服务应用程序的基本实现。让我们看看它的实际效果。首先,确保您之前创建的 PostgreSQL 容器仍在运行。然后打开终端窗口,导航到订单服务项目的根文件夹,并运行应用程序:

$ ./gradlew bootRun

您可以通过提交订单来试用 API。应用程序会将订单保存为已拒绝,并向客户端返回 200 响应:

$ http POST :9002/orders isbn=1234567890 quantity=3
 
HTTP/1.1 200 OK
{
  "bookIsbn": "1234567890",
  "bookName": null,
  "bookPrice": null,
  "createdDate": "2022-06-06T09:40:58.374348Z",
  "id": 1,
  "lastModifiedDate": "2022-06-06T09:40:58.374348Z",
  "quantity": 3,
  "status": "REJECTED",
  "version": 1
}

为了能够成功提交订单,我们需要让订单服务调用目录服务来检查图书的可用性并获取处理订单所需的信息。这是下一节的重点。在继续之前,请使用 Ctrl-C 停止应用程序。

8.3 使用 Spring WebClient 的响应式客户端

在云原生系统中,应用程序可以以不同的方式进行交互。本节重点介绍您将在订单服务和目录服务之间建立的通过 HTTP 建立的请求/响应交互。在这种交互中,发出请求的客户端期望收到响应。在命令式应用程序中,这将转换为线程阻塞,直到返回响应。相反,在反应式应用程序中,我们可以更有效地使用资源,这样就不会有线程等待响应,从而释放资源来处理其他处理。

Spring 框架捆绑了两个执行HTTP请求的客户端:RestTemplate和WebClient。RestTemplate 是最初的 Spring REST 客户端,它允许基于模板方法 API 阻止 HTTP 请求/响应交互。从 Spring Framework 5.0 开始,它处于维护模式并且实际上已被弃用。它仍然被广泛使用,但在将来的版本中不会获得任何新功能。

WebClient是RestTemplate的现代替代品。它提供阻塞和非阻塞 I/O,使其成为命令式和反应式应用程序的完美候选者。它可以通过功能样式、流畅的 API 进行操作,该 API 允许您配置 HTTP 交互的任何方面。

本节将教您如何使用 WebClient 建立非阻塞请求/响应交互。我还将解释如何通过使用 Reactor 运算符 timeout()、retryWhen() 和 onError() 采用超时、重试和故障转移等模式来使您的应用程序更具弹性。

8.3.1 春季的服务到服务通信

根据 15 因素方法,任何后备服务都应通过资源绑定附加到应用程序。对于数据库,您依靠 Spring 引导提供的配置属性来指定凭据和 URL。当后备服务是另一个应用程序时,您需要以类似的方式提供其 URL。遵循外部化配置原则,URL 应该是可配置的,而不是硬编码的。在春天,你可以通过@ConfigurationProperties豆来实现这一点,正如你在第4章中学到的那样。

在订单服务项目中,在 com.polarbookshop.orderservice.config 包中添加 ClientProperties 记录。在此处,定义自定义 polar.catalog-service-uri 属性以配置用于调用目录服务的 URI。

8.16 为目录服务 URI 定义自定义属性

package com.polarbookshop.orderservice.config;
 
import java.net.URI;
import javax.validation.constraints.NotNull;
import org.springframework.boot.context.properties.ConfigurationProperties;
 
@ConfigurationProperties(prefix = "polar")    ?
public record ClientProperties(
 
  @NotNull
  URI catalogServiceUri                       ?
){}

? 自定义属性的前缀

? 用于指定目录服务 URI 的属性。它不能为空。

注意要从 IDE 获取自动完成和类型验证检查,您需要在 build .gradle 文件中添加对 org.springframework.boot:spring-boot-configuration-processor 的依赖关系,并在 build .gradle 文件中添加作用域注释处理器,就像在第 4 章中所做的那样。您可以参考本书随附的代码库中的 Chapter08/08-end/order-service/build.gradle 文件来检查最终结果(https://github.com/ThomasVitale/cloud-native -spring-in-action)。

然后,使用 @ConfigurationPropertiesScan 注释在 OrderServiceApplication 类中启用自定义配置属性。

清单 8.17 启用自定义配置属性

package com.polarbookshop.orderservice;
 
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties 
? .ConfigurationPropertiesScan; 
 
@SpringBootApplication
@ConfigurationPropertiesScan             ?
public class OrderServiceApplication {
  public static void main(String[] args) {
    SpringApplication.run(OrderServiceApplication.class, args);
  }
}

? 在 Spring 上下文中加载配置数据 bean

最后,将新属性的值添加到 application.yml 文件中。默认情况下,您可以将 URI 用于在本地环境中运行的目录服务实例。

示例 8.18 为目录服务 (application.yml) 配置 URI

...
polar:
  catalog-service-uri: "http://localhost:9001"

注意使用 Docker Compose 或 Kubernetes 部署系统时,可以通过环境变量覆盖属性值,利用这两个平台提供的服务发现功能。

在下一部分中,您将使用通过此属性配置的值从订单服务调用目录服务。

8.3.2 了解如何交换数据

每当用户提交特定图书的订单时,订购服务都需要调用目录服务来检查所请求图书的可用性并获取其详细信息,例如书名、作者和价格。交互(HTTP 请求/响应)如图 8.6 所示。


图 8.6 提交订单后,订单服务通过 HTTP 调用目录服务来检查图书的可用性并获取其详细信息。

每个订单请求都是针对特定的 ISBN 提交的。订购服务需要知道图书的 ISBN、书名、作者和价格,才能正确处理订单。目前,目录服务公开一个 /books/{bookIsbn} 终结点,该终结点返回有关书籍的所有可用信息。在实际方案中,您可能会公开一个不同的终结点,该终结点返回仅包含所需信息的对象 (DTO)。在本例中,我们将重用现有终结点,因为我们现在的重点是构建反应式客户端。

确定要调用的终结点后,应如何对两个应用程序之间的交换进行建模?你刚刚走到一个十字路口:

  • 创建共享库 - 一种选择是使用两个应用程序使用的类创建共享库,并将其作为依赖项导入到两个项目中。根据 15 因素方法,这样的库将在其自己的代码库中进行跟踪。这样做可以确保两个应用程序使用的模型是一致的,并且永远不会不同步。但是,这意味着添加实现耦合。
  • 复制类 - 另一种选择是将类复制到上游应用程序中。通过这样做,您将没有实现耦合,但您必须注意随着原始模型在下游应用程序中的更改而发展复制的模型。有一些技术,如消费者驱动的合约,可以通过自动测试来识别被调用的API何时更改。除了检查数据模型外,这些测试还将验证公开的API的其他方面,例如HTTP方法,响应状态,标头,变量等。我不会在这里讨论这个主题,但如果你有兴趣,我建议查看Spring Cloud Contract项目(https:// spring.io/projects/spring-cloud-contract)。

两者都是可行的选择。采用哪种策略取决于您的项目要求和组织的结构。对于极地书店项目,我们将使用第二个选项。

在新的 com.polarbookshop.orderservice.book 包中,创建要用作 DTO 的书籍记录,并仅包含订单处理逻辑使用的字段。正如我之前指出的,在实际场景中,我将在目录服务中公开一个新终结点,返回建模为此 DTO 的书籍对象。为简单起见,我们将使用现有的 /books/{bookIsbn} 端点,因此在将收到的 JSON 反序列化为 Java 对象时,将丢弃任何未映射到此类中任何字段的信息。确保您定义的字段与目录服务中定义的 Book 对象中的名称相同,否则解析将失败。这是消费者驱动的合同测试可以自动为您验证的内容。

8.19 图书记录是用于存储图书信息的 DTO

package com.polarbookshop.orderservice.book;
 
public record Book(
  String isbn,
  String title,
  String author,
  Double price
){}

现在,您已经准备好了订单服务中的 DTO 来保存书籍信息,让我们看看如何从目录服务中检索它。

8.3.3 使用 Web 客户端实现 REST 客户端

Spring 中 REST 客户端的现代和响应式选择是 WebClient。该框架提供了几种实例化 WebClient 对象的方法 — 在本例中,我们将使用 WebClient.Builder。请参阅官方文档以探索其他选项 (https://spring.io/projects/spring-framework)。

在 com.polarbookshop.orderservice.config 包中,创建一个 ClientConfig 类,以使用 ClientProperties 提供的基本 URL 配置 WebClient Bean。

示例 8.20 配置 WebClient Bean 以调用目录服务

package com.polarbookshop.orderservice.config;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
 
@Configuration
public class ClientConfig {
 
  @Bean
  WebClient webClient(
    ClientProperties clientProperties,
    WebClient.Builder webClientBuilder                           ?
  ) {
    return webClientBuilder                                      ?
      .baseUrl(clientProperties.catalogServiceUri().toString())
      .build();
  }
}

? 由 Spring Boot 自动配置的对象,用于构建 WebClient bean

? 将 Web 客户端基本 URL 配置为定义为自定义属性的目录服务 URL

警告如果您使用 IntelliJ IDEA,您可能会收到 WebClient 的警告。生成器无法自动连线。不用担心。这是一个误报。您可以通过用@SuppressWarnings(“SpringJavaInjectionPointsAutoWiringInspection”)注释字段来消除警告。

接下来,在 com.polarbookshop.orderservice.book 包中创建一个 BookClient 类。这就是您将使用 WebClient bean 将 HTTP 调用发送到 GET /books/{bookIsbn} 端点的位置,该端点由 Catalog Service 通过其流畅的 API 公开。WebClient 最终将返回包装在 Mono 发布商中的 Book 对象。

8.21 使用 WebClient 定义反应式 REST 客户端

package com.polarbookshop.orderservice.book;
 
import reactor.core.publisher.Mono;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
 
@Component
public class BookClient {
  private static final String BOOKS_ROOT_API = "/books/";
  private final WebClient webClient;
 
  public BookClient(WebClient webClient) {
    this.webClient = webClient;                   ?
  }
 
  public Mono<Book> getBookByIsbn(String isbn) {
    return webClient
      .get()                                      ?
      .uri(BOOKS_ROOT_API + isbn)                 ?
      .retrieve()                                 ?
      .bodyToMono(Book.class);                    ?
  }
}

? 之前配置的 WebClient Bean

? 请求应使用 GET 方法。

? 请求的目标 URI 是 /books/{isbn}。

? 发送请求并检索响应

? 将检索到的对象作为单声道<书返回>

WebClient 是一个反应式 HTTP 客户端。您刚刚了解了它如何作为反应式发布者返回数据。特别是,调用目录服务以获取有关特定书籍的详细信息的结果是 Mono<Book> 对象。让我们看看如何将其包含在 OrderService 中实现的订单处理逻辑中。

类中的 submitOrder() 方法当前一直在拒绝订单。但时间不长。现在,您可以自动连接 BookClient 实例,并使用底层 Web 客户端启动反应式流来处理图书信息并创建订单。map() 运算符允许您将一本书映射到已接受的订单。如果 BookClient 返回一个空结果,您可以使用 defaultIfEmpty() 运算符定义被拒绝的订单。最后,通过调用 OrderRepository 来结束流以保存订单(接受或拒绝)。

8.22 在订购时调用 BookClient 获取书籍信息

package com.polarbookshop.orderservice.order.domain;
 
import com.polarbookshop.orderservice.book.Book; 
import com.polarbookshop.orderservice.book.BookClient; 
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.stereotype.Service;
 
@Service
public class OrderService {
  private final BookClient bookClient;
  private final OrderRepository orderRepository;
 
  public OrderService(
   BookClient bookClient, OrderRepository orderRepository
  ) {
    this.bookClient = bookClient; 
    this.orderRepository = orderRepository;
  }
 
  ...
 
  public Mono<Order> submitOrder(String isbn, int quantity) {
    return bookClient.getBookByIsbn(isbn)                                ?
      .map(book -> buildAcceptedOrder(book, quantity))                   ?
      .defaultIfEmpty(                                                   ?
        buildRejectedOrder(isbn, quantity) 
      ) 
      .flatMap(orderRepository::save);                                   ?
  }
 
  public static Order buildAcceptedOrder(Book book, int quantity) { 
    return Order.of(book.isbn(), book.title() + " - " + book.author(), 
      book.price(), quantity, OrderStatus.ACCEPTED);                     ?
  } 
 
  public static Order buildRejectedOrder(String bookIsbn, int quantity) {
    return Order.of(bookIsbn, null, null, quantity, OrderStatus.REJECTED);
  }
}

? 致电目录服务以检查图书的可用性

? 如果书有空,它接受订单。

? 如果该书不可用,它将拒绝订单。

? 保存订单(接受或拒绝)

? 接受订单时,我们会指定 ISBN、书名(书名 + 作者)、数量和状态。Spring 数据负责添加标识符、版本和审核元数据。

让我们试试看。首先,通过从保存 Docker Compose 配置(极地部署/docker)的文件夹中执行以下命令,确保 PostgreSQL 容器已启动并正在运行:

$ docker-compose up -d polar-postgres

然后构建并运行目录服务和订单服务(./gradlew bootRun)。

警告如果您使用的是 Apple Silicon 计算机,则来自订单服务的应用程序日志可能包含一些与 Netty 中的 DNS 解析相关的警告。在此特定情况下,应用程序仍应正常工作。如果遇到问题,可以将以下附加依赖项作为运行时添加到订单服务项目中,以解决此问题:io.netty:netty-resolver-dns-native-macos:4.1.79.Final:osx-aarch_64。

最后,在启动时发送在目录服务中创建的图书之一的订单。如果该书存在,则应接受订单:

$ http POST :9002/orders isbn=1234567891 quantity=3
 
HTTP/1.1 200 OK
{
  "bookIsbn": "1234567891",
  "bookName": "Northern Lights - Lyra Silverstar",
  "bookPrice": 9.9,
  "createdDate": "2022-06-06T09:59:32.961420Z",
  "id": 2,
  "lastModifiedDate": "2022-06-06T09:59:32.961420Z",
  "quantity": 3,
  "status": "ACCEPTED",
  "version": 1
}

验证完交互后,使用 Ctrl-C 停止应用程序,使用 docker-compose 关闭容器。

订单创建逻辑的实现到此结束。如果目录中存在该书,则将接受该订单。如果返回空结果,则会拒绝该结果。但是,如果目录服务需要太多时间来回复怎么办?如果它暂时不可用且无法处理任何新请求,该怎么办?如果它回复错误怎么办?以下部分将回答和处理所有这些问题。

8.4 反应弹簧的弹性应用

弹性是关于保持系统可用并提供其服务,即使发生故障也是如此。由于故障会发生,并且无法阻止所有故障,因此设计容错应用程序至关重要。目标是保持系统可用,而不会使用户注意到任何故障。在最坏的情况下,系统可能功能降级(正常降级),但它应该仍然可用。

实现弹性(或容错)的关键点是保持故障组件隔离,直到故障修复。通过这样做,你将防止Michael T. Nygard所说的裂纹传播。想想极地书店。如果目录服务进入故障状态并变得无响应,则您不希望订单服务也受到影响。应仔细保护应用程序服务之间的集成点,并使其能够抵御影响另一方的故障。

有几种模式可用于构建可复原的应用程序。在Java生态系统中,实现这种模式的流行库是由Netflix开发的Hystrix,但截至2018年,它进入维护模式,不会进一步开发。Resilience4J获得了很大的人气,填补了Hystrix留下的空白。项目反应堆,反应式弹簧堆栈基础,还提供了一些有用的弹性功能。

在本节中,您将使用 Reactive Spring 配置超时、重试和回退,使订单服务和目录服务之间的集成点更加健壮。在下一章中,您将了解有关使用 Resilience4J 和 Spring Cloud Circuit Breaker 构建弹性应用程序的更多信息。

8.4.1 超时

每当应用程序调用远程服务时,您都不知道是否以及何时会收到响应。超时(也称为时间限制器)是一种简单而有效的工具,用于在合理的时间段内未收到响应时保持应用程序的响应能力。

设置超时有两个主要原因:

  • 如果不限制客户端等待的时间,则计算资源可能会被阻塞太长时间(对于命令式应用程序)。在最坏的情况下,应用程序将完全无响应,因为所有可用线程都被阻塞,等待来自远程服务的响应,并且没有线程可用于处理新请求。
  • 如果您无法满足服务级别协议 (SLA),则没有理由继续等待答案。最好使请求失败。

下面是一些超时示例:

  • 连接超时 - 这是与远程资源建立通信通道的时间限制。之前,您配置了 server.netty.connection-timeout 属性来限制 Netty 等待建立 TCP 连接的时间。
  • 连接池超时 - 这是客户端从池获取连接的时间限制。在第 5 章中,您通过 spring.datasource.hikari.connection-timeout 属性为 Hikari 连接池配置了超时。
  • 读取超时 - 这是建立初始连接后从远程资源读取的时间限制。在以下部分中,您将为 BookClient 类执行的对目录服务的调用定义读取超时。

在本节中,您将为 BookClient 定义超时,以便在超时时,订单服务应用程序将引发异常。还可以指定故障转移,而不是向用户引发异常。图 8.7 详细介绍了定义超时和故障转移时请求/响应交互将如何工作。


图8.7 在时间限制内收到来自远程服务的响应时,请求成功。如果超时过期且未收到响应,则会执行回退行为(如果有)。否则,将引发异常。

定义 WEB 客户端的超时

Project Reactor 提供了一个 timeout() 运算符,可用于定义完成操作的时间限制。您可以将其与 WebClient 调用的结果链接在一起,以继续反应流。更新 BookClient 类中的 getBookByIsbn() 方法,如下所示,以定义 3 秒的超时。

示例 8.23 定义 HTTP 交互的超时

...
public Mono<Book> getBookByIsbn(String isbn) {
  return webClient
    .get()
    .uri(BOOKS_ROOT_API + isbn)
    .retrieve()
    .bodyToMono(Book.class)
    .timeout(Duration.ofSeconds(3));     ?
}
...

? 为 GET 请求设置 3 秒超时

您有机会提供回退行为,而不是在超时到期时引发异常。考虑到订单服务无法接受订单,如果图书的库存状况未经验证,您可以考虑返回空结果,以便订单被拒绝。您可以使用 Mono.empty() 定义一个反应性空结果。更新 BookClient 类中的 getBookByIsbn() 方法,如下所示。

示例 8.24 定义 HTTP 交互的超时和回退

...
public Mono<Book> getBookByIsbn(String isbn) {
  return webClient
    .get()
    .uri(BOOKS_ROOT_API + isbn)
    .retrieve()
    .bodyToMono(Book.class)
    .timeout(Duration.ofSeconds(3), Mono.empty())    ?
}
...

? 回退返回一个空的 Mono 对象。

注意在实际生产方案中,您可能希望通过向 ClientProperties 添加新字段来外部化超时配置。这样,您可以根据环境更改其值,而无需重新生成应用程序。监视任何超时并在必要时调整其值也很重要。

了解如何有效使用超时

超时可提高应用程序复原能力,并遵循快速失败的原则。但是,为超时设置一个好的值可能很棘手。您应该将系统架构视为一个整体。在前面的示例中,您定义了 3 秒超时。这意味着应在该时间限制内从目录服务到订单服务的响应。否则,将发生故障或回退。反过来,目录服务向PostgreSQL数据库发送请求,以获取有关特定书籍的数据并等待响应。连接超时保护该交互。您应该为系统中的所有集成点仔细设计一个限时策略,以满足软件的 SLA 并保证良好的用户体验。

如果目录服务可用,但无法在时间限制内向订单服务发送响应,则目录服务可能仍会处理该请求。这是配置超时时要考虑的关键点。对于读取或查询操作来说,这并不重要,因为它们是幂等的。对于写入或命令操作,您希望确保在超时到期时正确处理,包括为用户提供有关操作结果的正确状态。

当目录服务过载时,可能需要几秒钟才能从池中获取 JDBC 连接,从数据库中获取数据,并将响应发送回订单服务。在这种情况下,您可以考虑重试请求,而不是回退到默认行为或引发异常。

8.4.2 重试

当下游服务未在特定时间限制内响应或回复与其暂时无法处理请求相关的服务器错误时,可以将客户端配置为重试。当服务未正确响应时,可能是因为它遇到了一些问题,并且不太可能立即恢复。一个接一个地启动一系列重试尝试,可能会使系统更加不稳定。您不想对自己的应用程序发起 DoS 攻击!

更好的方法是使用指数退避策略来执行每次重试尝试,延迟不断增加。通过在一次尝试和下一次尝试之间等待越来越多的时间,您更有可能给后备服务时间来恢复并再次响应。可以配置计算延迟的策略。

在本部分中,您将为 BookClient 配置重试次数。图 8.8 详细说明了当重试配置指数退避时请求/响应交互将如何工作。例如,该图显示了一个场景,其中每次重试尝试的延迟计算为尝试次数乘以 100 毫秒(初始退避值)。


图 8.8 当目录服务未成功响应时,订单服务最多再尝试三次,延迟会越来越大。

定义 WEB 客户端的重试次数

Project Reactor 提供了一个 retryWhen() 运算符,用于在操作失败时重试操作。将其应用于反应式流的位置很重要。

  • 将 retryWhen() 运算符放在 timeout() 之后意味着超时将应用于每次重试尝试。
  • 将 retryWhen() 运算符放在 timeout() 之前意味着超时应用于整个操作(即,初始请求和重试的整个序列必须在给定的时间限制内发生)。

在 BookClient 中,我们希望超时应用于每次重试尝试,因此我们将使用第一个选项。首先应用时间限制器。如果超时过期,retryWhen() 运算符将启动并再次尝试请求。

更新 BookClient 类中的 getBookByIsbn() 方法以配置重试策略。您可以定义第一次退避的尝试次数和最短持续时间。每次重试的延迟计算方式为当前尝试次数乘以最小退避期。抖动因子可用于增加每个退避指数的随机性。默认情况下,使用最多为计算延迟 50% 的抖动。当您运行多个订单服务实例时,抖动因子可确保副本不会同时重试请求。

示例 8.25 定义 HTTP 调用的指数退避重试

public Mono<Book> getBookByIsbn(String isbn) {
  return webClient
    .get()
    .uri(BOOKS_ROOT_API + isbn)
    .retrieve()
    .bodyToMono(Book.class)
    .timeout(Duration.ofSeconds(3), Mono.empty())
    .retryWhen(                                    ?
      Retry.backoff(3, Duration.ofMillis(100)) 
    ); 
}

? 指数退避用作重试策略。允许三次尝试,初始退避时间为 100 毫秒。

了解如何有效地使用重试

重试会增加在远程服务暂时过载或无响应时从远程服务获取响应的机会。明智地使用它们。在超时的上下文中,我强调了以不同方式处理读取和写入操作的必要性。在重试方面,这一点更为关键。

幂等请求(如读取操作)可以重试而不会造成伤害。甚至某些写入请求也可能是幂等的。例如,将具有给定 ISBN 的书籍的作者从“S.L. Cooper”更改为“Sheldon Lee Cooper”的请求是幂等的。您可以执行几次,但结果不会改变。不应重试非幂等请求,否则可能会生成不一致的状态。当您订购一本书时,您不希望仅仅因为第一次尝试失败而多次收费,因为响应在网络中丢失并且从未收到。

在涉及用户的流中配置重试时,请记住平衡复原能力和用户体验。您不希望用户在后台重试请求时等待太久。如果无法避免这种情况,请确保通知用户并向他们提供有关请求状态的反馈。

每当下游服务由于过载而暂时不可用或速度变慢时,重试是一种有用的模式,但可能很快就会恢复。在这种情况下,应限制重试次数并使用指数退避,以防止在已过载的服务上增加额外负载。另一方面,如果服务失败并出现重复错误,例如,如果服务完全关闭或返回可接受的错误(如 404),则不应重试请求。以下部分将介绍如何在发生特定错误时定义回退。

8.4.3 回退和错误处理

如果系统在面对故障时继续提供服务而用户没有注意到,那么系统就是弹性的。有时这是不可能的,因此您至少可以确保服务级别的正常降级。指定回退行为可以帮助您将故障限制在较小的区域内,同时防止系统的其余部分行为异常或进入故障状态。

在前面关于超时的讨论中,您已经提供了在时间限制内未收到响应时的回退行为。您需要在常规策略中包含回退,以使系统具有弹性,而不仅仅是在超时等特定情况下。当发生某些错误或异常时,可以触发回退函数,但它们并不完全相同。

在业务逻辑的上下文中,某些错误是可以接受的,并且在语义上有意义。当订单服务调用目录服务以获取有关特定书籍的信息时,可能会返回 404 响应。这是一个可接受的响应,应该解决该问题,以通知用户由于目录中没有该书而无法提交订单。

您在上一节中定义的重试策略不受限制:只要收到错误响应(包括可接受的响应,如 404),它就会重试请求。但是,在这种情况下,您不希望重试请求。Project Reactor 提供了一个 onErrorResume() 运算符,用于定义发生特定错误时的回退。您可以将其添加到 timeout() 运算符之后和 retryWhen() 之前的反应流中,以便在收到 404 响应(WebClientResponseException.NotFound 异常)时,不会触发重试运算符。然后,您可以在流的末尾再次使用相同的运算符来捕获任何其他异常并回退到空的 Mono。更新 BookClient 类中的 getBookByIsbn() 方法,如下所示。

示例 8.26 定义 HTTP 调用的异常处理和回退

public Mono<Book> getBookByIsbn(String isbn) {
  return webClient
    .get()
    .uri(BOOKS_ROOT_API + isbn)
    .retrieve()
    .bodyToMono(Book.class)
    .timeout(Duration.ofSeconds(3), Mono.empty())
    .onErrorResume(WebClientResponseException.NotFound.class, 
      exception -> Mono.empty())                                ?
    .retryWhen(Retry.backoff(3, Duration.ofMillis(100)))
    .onErrorResume(Exception.class, 
      exception -> Mono.empty());                              ?
}

? 收到 404 响应时返回空对象

? 如果在 3 次重试尝试后发生任何错误,请捕获异常并返回一个空对象。

注意在实际方案中,您可能希望根据错误类型返回一些上下文信息,而不是始终返回空对象。例如,您可以向 Order 对象添加一个原因字段,以描述它被拒绝的原因。是因为该书在目录中不可用还是因为网络问题?在第二种情况下,您可以通知用户订单无法处理,因为它暂时无法检查图书的可用性。更好的选择是将订单保存在待处理状态,对订单提交请求进行排队,稍后再试一次,使用我将在第 10 章中介绍的策略之一。

关键目标是设计一个弹性系统,在最佳情况下,该系统可以在用户没有注意到故障的情况下提供服务。相比之下,在最坏的情况下,它应该仍然有效,但会正常降级。

注意Spring WebFlux和Project Reactor 是春季景观中令人兴奋的主题。如果你想了解更多关于反应式弹簧的工作原理,我建议你看看Josh Long(https://reactivespring.io)的Reactive Spring。在曼宁目录中,请参阅克雷格·沃尔斯(曼宁,3 年)的《春天在行动》第六版第 2022 部分。

在下一节中,您将编写自动测试来验证订单服务应用程序的不同方面。

8.5 使用 Spring、Reactor 和 Testcontainer 测试响应式应用程序

当应用程序依赖于下游服务时,应根据后者的 API 规范测试交互。在本节中,您将首先针对充当目录服务的模拟 Web 服务器尝试 BookClient 类,以确保客户端的正确性。然后,您将使用 @DataR2dbcTest 注释和 Testcontainers 使用切片测试来测试数据持久性层,就像您在第 5 章中对 @DataJdbcTest 所做的那样。最后,您将使用 @WebFluxTest 注记为 Web 图层编写切片测试,其工作方式与@WebMvcTest相同,但适用于反应式应用程序。

您已经对 Spring 引导测试库和测试容器具有必要的依赖关系。缺少的是对com.squareup.okhttp3:mockwebserver的依赖,它将提供运行模拟Web服务器的实用程序。打开订单服务项目的 build.gradle 文件并添加缺少的依赖项。

示例 8.27 为 OkHttp 模拟Web服务器添加测试依赖关系

dependencies {
  ...
  testImplementation 'com.squareup.okhttp3:mockwebserver' 
}

让我们从测试 BookClient 类开始。

8.5.1 使用模拟 Web 服务器测试 REST 客户端

OkHttp 项目提供了一个模拟 Web 服务器,可用于测试与下游服务的基于 HTTP 的请求/响应交互。BookClient 返回一个 Mono<Book> 对象,因此您可以使用 Project Reactor 提供的便捷实用程序来测试反应式应用程序。StepVerifier 对象允许您通过流畅的 API 处理反应式流并分步写入断言。

首先,让我们设置模拟Web服务器,并配置WebClient以在新的BookClientTests类中使用它。

清单 8.28 使用模拟 Web 服务器准备测试设置

package com.polarbookshop.orderservice.book;
 
import java.io.IOException;
import okhttp3.mockwebserver.MockWebServer;
import org.junit.jupiter.api.*;
import org.springframework.web.reactive.function.client.WebClient;
 
class BookClientTests {
  private MockWebServer mockWebServer;
  private BookClient bookClient;
 
  @BeforeEach
  void setup() throws IOException {
    this.mockWebServer = new MockWebServer();
    this.mockWebServer.start();                        ?
    var webClient = WebClient.builder()                ?
      .baseUrl(mockWebServer.url("/").uri().toString())
      .build();
    this.bookClient = new BookClient(webClient);
  }
 
  @AfterEach
  void clean() throws IOException {
    this.mockWebServer.shutdown();                     ?
  }
}

? 在运行测试用例之前启动模拟服务器

? 使用模拟服务器 URL 作为 WebClient 的基本 URL

? 完成测试用例后关闭模拟服务器

接下来,在 BookClientTests 类中,您可以定义一些测试用例来验证客户端在订单服务中的功能。

示例 8.29 测试与目录服务应用程序的交互

package com.polarbookshop.orderservice.book;
 
...
import okhttp3.mockwebserver.MockResponse;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
 
class BookClientTests {
  private MockWebServer mockWebServer;
  private BookClient bookClient;
 
  ...
 
  @Test
  void whenBookExistsThenReturnBook() {
    var bookIsbn = "1234567890";
 
    var mockResponse = new MockResponse()              ?
      .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
      .setBody("""
        {
          "isbn": %s,
          "title": "Title",
          "author": "Author",
          "price": 9.90,
          "publisher": "Polarsophia"
        }
        """.formatted(bookIsbn));
 
    mockWebServer.enqueue(mockResponse);               ?
 
    Mono<Book> book = bookClient.getBookByIsbn(bookIsbn);
 
    StepVerifier.create(book)                          ?
      .expectNextMatches(
        b -> b.isbn().equals(bookIsbn))                ?
      .verifyComplete();                               ?
  }
}

? 定义模拟服务器返回的响应

? 向模拟服务器处理的队列添加模拟响应

? 使用BookClient返回的对象初始化StepVerifier对象

? 断言返回的图书已请求 ISBN

? 验证反应流是否已成功完成

让我们运行测试并确保它们成功。打开终端窗口,导航到订单服务项目的根文件夹,然后运行以下命令:

$ ./gradlew test --tests BookClientTests

注意使用模拟时,可能会出现测试结果取决于测试用例执行顺序的情况,而测试用例在同一操作系统上往往是相同的。为了防止不需要的执行依赖关系,您可以使用 @TestMethodOrder(MethodOrderer.Random.class) 对测试类进行注释,以确保在每次执行时使用伪随机顺序。

测试 REST 客户端部分后,您可以继续验证订单服务的数据持久性层。

8.5.2 使用 @DataR2dbcTest 和测试容器测试数据持久性

您可能还记得前面的章节,Spring Boot 允许您通过仅加载特定应用程序切片使用的 Spring 组件来运行集成测试。对于 REST API,您将为 WebFlux 切片创建测试。在这里,我将向您展示如何使用@DataR2dbcTest注释为 R2DBC 切片编写测试。

该方法与第 5 章中用于在目录服务中测试数据层的方法相同,但有两个主要区别。首先,您将使用 StepVerifier 实用程序以被动方式测试 OrderRepository 行为。其次,您将显式定义 PostgreSQL 测试容器实例。

对于目录服务应用程序,我们依赖于测试容器自动配置。在本例中,我们将在测试类中定义一个测试容器,并将其标记为@Container。然后,类上的@Testcontainers注释将激活测试容器的自动启动和清理。最后,我们将使用 Spring Boot 提供的@DynamicProperties注释将测试数据库的凭据和 URL 传递给应用程序。这种定义测试容器和覆盖属性的方法很通用,可以应用于其他方案。

现在,进入代码。创建一个 OrderRepositoryR2dbcTests 类并实现自动测试来验证应用程序的数据持久性层。

清单 8.30 数据 R2DBC 切片的集成测试

package com.polarbookshop.orderservice.order.domain;
 
import com.polarbookshop.orderservice.config.DataConfig;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import reactor.test.StepVerifier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
 
@DataR2dbcTest                                                     ?
@Import(DataConfig.class)                                          ?
@Testcontainers                                                    ?
class OrderRepositoryR2dbcTests {
 
  @Container                                                       ?
  static PostgreSQLContainer<?> postgresql =
    new PostgreSQLContainer<>(DockerImageName.parse("postgres:14.4"));
 
  @Autowired
  private OrderRepository orderRepository;
 
  @DynamicPropertySource                                           ?
  static void postgresqlProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.r2dbc.url", OrderRepositoryR2dbcTests::r2dbcUrl);
    registry.add("spring.r2dbc.username", postgresql::getUsername);
    registry.add("spring.r2dbc.password", postgresql::getPassword);
    registry.add("spring.flyway.url", postgresql::getJdbcUrl);
  }
 
  private static String r2dbcUrl() {                               ?
    return String.format("r2dbc:postgresql://%s:%s/%s",
      postgresql.getContainerIpAddress(),
      postgresql.getMappedPort(PostgreSQLContainer.POSTGRESQL_PORT),
      postgresql.getDatabaseName());
  }
 
  @Test
  void createRejectedOrder() {
    var rejectedOrder = OrderService.buildRejectedOrder("1234567890", 3);
    StepVerifier
      .create(orderRepository.save(rejectedOrder))                 ?
      .expectNextMatches(                                          ?
        order -> order.status().equals(OrderStatus.REJECTED))
      .verifyComplete();                                           ?
  }
}

? 标识专注于 R2DBC 组件的测试类

? 导入启用审核所需的 R2DBC 配置

? 激活测试容器的自动启动和清理

? 标识用于测试的 PostgreSQL 容器

? 覆盖 R2DBC 和 Flyway 配置以指向测试 PostgreSQL 实例

? 构建一个 R2DBC 连接字符串,因为 Testcontainers 不像 JDBC 那样提供开箱即用的连接字符串

? 使用 OrderRepository 返回的对象初始化 StepVerifier 对象

? 断言返回的订单具有正确的状态

? 验证反应流是否已成功完成

由于这些切片测试基于 Testcontainers,因此请确保 Docker 引擎在本地环境中运行。然后运行测试:

$ ./gradlew test --tests OrderRepositoryR2dbcTests

在下一部分中,你将为网页快讯编写测试。

8.5.3 用@WebFluxTest测试 REST 控制器

WebFlux 切片的测试方式与第 3 章中测试 MVC 层的方式类似,并且使用用于集成测试的相同 WebTestClient 实用程序。它是标准 WebClient 对象的增强版本,包含用于简化测试的额外功能。

创建一个 OrderControllerWebFluxTests 类,并使用 @WebFluxTest(OrderController.class) 对其进行批注,以收集 OrderController 的切片测试。正如您在第 3 章中学到的,您可以使用 @MockBean Spring 注释来模拟 OrderService 类,并让 Spring 将其添加到测试中使用的 Spring 上下文中。这就是使它可注射的原因。

示例 8.31 WebFlux 切片的集成测试

package com.polarbookshop.orderservice.order.web;
 
import com.polarbookshop.orderservice.order.domain.Order;
import com.polarbookshop.orderservice.order.domain.OrderService;
import com.polarbookshop.orderservice.order.domain.OrderStatus;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.reactive.server.WebTestClient;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
 
@WebFluxTest(OrderController.class)                 ?
class OrderControllerWebFluxTests {
 
  @Autowired
  private WebTestClient webClient;                  ?
 
  @MockBean                                         ?
  private OrderService orderService;
 
  @Test
  void whenBookNotAvailableThenRejectOrder() {
    var orderRequest = new OrderRequest("1234567890", 3);
    var expectedOrder = OrderService.buildRejectedOrder(
     orderRequest.isbn(), orderRequest.quantity());
    given(orderService.submitOrder(
     orderRequest.isbn(), orderRequest.quantity())
    ).willReturn(Mono.just(expectedOrder));         ?
 
    webClient
      .post()
      .uri("/orders/")
      .bodyValue(orderRequest)
      .exchange()
      .expectStatus().is2xxSuccessful()             ?
      .expectBody(Order.class).value(actualOrder -> {
        assertThat(actualOrder).isNotNull();
        assertThat(actualOrder.status()).isEqualTo(OrderStatus.REJECTED);
      });
  }
}

? 识别一个专注于Spring WebFlux组件的测试类,目标是OrderController

? 具有额外功能的WebClient变体,使测试RESTful服务更容易

? 将 OrderService 的模拟添加到 Spring 应用程序上下文中

? 定义订单服务模拟 Bean 的预期行为

? 预计订单创建成功

接下来,运行 Web 图层的切片测试以确保它们通过:

$ ./gradlew test --tests OrderControllerWebFluxTests

干得好!您成功构建并测试了反应式应用程序,最大限度地提高了可伸缩性、弹性和成本效益。在本书随附的源代码中,您可以找到更多测试示例,包括使用 @SpringBootTest 注释的完整集成测试以及使用 @JsonTest JSON 层的切片测试,如第 3 章中所述。

极地实验室

请随意应用您在前面章节中学到的知识,并准备订单服务应用程序以进行部署。

  1. 将 Spring 云配置客户端添加到订单服务,使其从配置服务获取配置数据。
  2. 配置云原生构建包集成,容器化应用程序,并定义部署管道的提交阶段。
  3. 编写用于将订单服务部署到 Kubernetes 集群的部署和服务清单。
  4. 配置 Tilt 以自动将订单服务部署到使用 minikube 初始化的本地 Kubernetes 集群。

您可以参考本书随附的代码库中的 Chapter08/08-end 文件夹来检查最终结果 (https://github.com/ThomasVitale/cloud-native-spring-in-action)。您可以使用 kubectl apply -f 服务从 Chapter08/08-end/polar-deployment/kubernetes/platform/development 文件夹中的清单中部署支持服务。

下一章将继续讨论弹性,并介绍更多模式,如断路器和速率限制器,使用Spring Cloud Gateway,Spring Cloud Circuit Breaker和Resilience4J。

总结

  • 当您期望使用较少的计算资源实现高流量和并发性时,反应式范例可以提高应用程序的可伸缩性、弹性和成本效益,但代价是初始学习曲线更陡峭。
  • 根据您的要求在非反应式和反应式堆栈之间进行选择。
  • Spring WebFlux 基于 Project Reactor 的,是 Spring 中反应式堆栈的核心。它支持异步、非阻塞 I/O。
  • 反应式 RESTful 服务可以通过@RestController类或路由器函数来实现。
  • Spring WebFlux 切片可以通过@WebFluxTest注释进行测试。
  • Spring Data R2DBC 使用 R2DBC 驱动程序支持反应式数据持久性。该方法与任何 Spring 数据项目相同:数据库驱动程序、实体和存储库。
  • 可以使用 Flyway 管理数据库架构。
  • 反应式应用程序的持久性切片可以使用@DataR2dbcTest注释和 Testcontainers 进行测试。
  • 如果系统在面对故障时继续提供服务而用户没有注意到它,那么系统就是弹性的。有时这是不可能的,因此您至少可以做的是确保服务的正常降级。
  • WebClient基于Project Actor,并与Mono和Flux发布者合作。
  • 您可以使用 Reactor 运算符配置超时、重试、回退和错误处理,以使交互对下游服务中的任何故障或由于网络造成的任何故障更具弹性。

Tags:

最近发表
标签列表