本章涵盖
云原生环境如此广泛,以至于入门可能会让人不知所措。在本书的第 1 部分中,您从理论上介绍了云原生应用程序及其支持流程,并且您有第一次实践经验构建最小的 Spring Boot 应用程序并将其作为容器部署到 Kubernetes。所有这些都将帮助您更好地理解整个云原生图片,并正确地将我将介绍的主题放在本书的其余部分。
云为我们可以通过多种类型的应用程序实现目标开辟了无限的可能性。在本章中,我将从最常见的类型之一开始:通过 REST API 通过 HTTP 公开其功能的 Web 应用程序。我将指导您完成后续所有章节中将遵循的开发过程,解决传统和云原生 Web 应用程序之间的显着差异,整合 Spring Boot 和 Spring MVC 的一些必要方面,并重点介绍基本的测试和生产注意事项。我还将解释 15 因素方法推荐的一些准则,包括依赖项管理、并发性和 API 优先。
在此过程中,您将实现在上一章中初始化的目录服务应用程序。它将负责管理Polar书店系统中的图书目录。
注意本章中示例的源代码位于第 03/03 章-begin 和 Chapter03/03-end 文件夹中,其中包含项目的初始和最终状态 (https://Github.com/ThomasVitale/cloud-native-spring-in-action)。
3.1 引导云原生项目开始一个新的开发项目总是令人兴奋的。15 因素方法包含一些引导云原生应用程序的实用指南。
在本节中,我将提供有关这两个原则的更多详细信息,并解释如何将它们应用于目录服务,这是 Polar Bookshop 系统中的第一个云原生应用程序。
3.1.1 一个代码库,一个应用程序云原生应用程序应由在版本控制系统(如 Git)中跟踪的单个代码库组成。每个代码库必须生成不可变的项目(称为生成),这些项目可以部署到多个环境。图 3.1 显示了代码库、生成和部署之间的关系。
图 3.1 每个应用程序都有自己的代码库,从中生成不可变的构建,然后部署到适当的环境中,而无需更改代码。
正如您将在下一章中看到的,任何特定于环境的内容(如配置)都必须在应用程序代码库之外。如果多个应用程序需要代码,则应将其转换为独立服务或可作为依赖项导入到项目中的库。应仔细评估后一个选项,以防止系统成为分布式整体。
注意考虑如何将代码组织到代码库和存储库中可以帮助您更多地关注系统体系结构,并确定那些实际上可能独立存在的部分作为独立服务。如果正确完成此操作,代码库的组织可以支持模块化和松散耦合。
根据 15 因素方法,每个代码库都应映射到一个应用程序,但对存储库没有任何说明。您可以决定在单独的存储库或同一存储库中跟踪每个代码库。这两个选项都用于云原生业务。在整本书中,您将构建多个应用程序,我建议您在自己的 Git 存储库中跟踪每个代码库,因为它将提高可维护性和可部署性。
在上一章中,您初始化了 Polar 书店系统中的第一个应用程序 Catalog Service,并将其放置在目录服务 Git 存储库中。我建议使用 GitHub 来存储存储库,因为稍后我们将使用 GitHub Actions 作为工作流引擎来定义部署管道以支持持续交付。
3.1.2 使用 Gradle 和 Maven 进行依赖管理如何管理应用程序的依赖项非常重要,因为它会影响应用程序的可靠性和可移植性。在Java生态系统中,两个最常用的依赖管理工具是gradle和Maven。两者都提供了在清单中声明依赖项并从中央存储库下载依赖项的功能。列出项目所需的所有依赖项的原因是为了确保您不依赖于从周围环境泄漏的任何隐式库。
注意除了依赖关系管理之外,Gradle 和 Maven 还提供了用于构建、测试和配置 Java 项目的附加功能,这些功能是应用程序开发的基础。书中的所有示例都将使用 Gradle,但请随意使用 Maven。
即使你有一个依赖项清单,你仍然需要提供依赖项管理器本身。Gradle 和 Maven 都提供了从名为 gradlew 或 mvnw 的包装器脚本运行该工具的功能,您可以将其包含在代码库中。例如,与其运行像 gradle build 这样的 Gradle 命令(假设您的计算机上安装了 Gradle),不如运行 ./gradlew build。该脚本调用项目中定义的生成工具的特定版本。如果构建工具尚不存在,包装器脚本将首先下载它,然后运行命令。使用包装器,您可以确保构建项目的所有团队成员和自动化工具都使用相同的 Gradle 或 Maven 版本。当你从Spring Initializr生成一个新项目时,你还会得到一个随时可以使用的包装脚本,所以你不需要下载或配置任何东西。
注意无论如何,您通常至少有一个外部依赖项:运行时。在我们的例子中,这就是Java运行时环境(JRE)。如果将应用程序打包为容器映像,则 Java 运行时将包含在映像本身中,从而授予您对其的更多控制。另一方面,最终的应用程序项目将取决于运行映像所需的容器运行时。您将在第 6 章中了解有关容器化过程的更多信息。
现在,进入代码。Polar 书店系统有一个目录服务应用程序,负责管理目录中可用的书籍。在上一章中,我们初始化了项目。系统的体系结构再次显示在图 3.2 中。
图3.2 Polar Bookshop系统的架构,目前只包含一个应用服务
应用程序所需的所有依赖项都列在自动生成的build.gradle文件(catalog-service/build.gradle)中。
dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' testImplementation 'org.springframework.boot:spring-boot-starter-test'}
这些是主要的依赖项:
Spring Boot 的一大特性是它处理依赖关系管理的方式。像 spring-boot-starter-web 这样的入门依赖项可以让你管理更多的依赖关系,并验证你导入的特定版本是否相互兼容。这是另一个 Spring 启动功能,它将让您以简单而高效的方式入门。
在下一节中,您将了解有关 Spring Boot 中嵌入的服务器如何工作以及如何配置它的更多信息。
3.2 使用嵌入式服务器使用 Spring Boot,您可以构建不同类型的应用程序(例如,Web、事件驱动、无服务器、批处理和任务应用程序),这些应用程序具有各种用例和模式。在云原生环境中,它们都具有一些共同的方面:
考虑一个 Web 应用程序。传统上,您将它打包为 WAR 或 EAR 文件(用于打包 Java 应用程序的存档格式),并将其部署到 Web 服务器(如 Tomcat)或应用程序服务器(如 WildFly)。对服务器的外部依赖将限制应用程序本身的可移植性和演变,并增加维护成本。
在本节中,您将了解如何使用 Spring Boot、Spring MVC 和嵌入式服务器在云原生 Web 应用程序中解决这些问题,但类似的原则也适用于其他类型的应用程序。您将了解传统应用程序和云原生应用程序之间的差异,像Tomcat这样的嵌入式服务器如何工作以及如何配置它。我还将详细阐述 15 因素方法中有关服务器、端口绑定和并发性的一些准则:
遵循这些原则,我们将继续开发目录服务,以确保它是独立的,并打包为可执行的 JAR。
服务器!服务器无处不在!
到目前为止,我已经使用了应用程序服务器和 Web 服务器这两个术语。稍后,我还将提到 Servlet 容器。有什么区别?
传统方法和云原生方法之间的区别之一是如何打包和部署应用程序。传统上,我们曾经有应用程序服务器或独立的Web服务器。它们在生产中的设置和维护成本很高,因此它们用于部署多个应用程序,为了提高效率而打包为 EAR 或 WAR 工件。这样的场景在应用程序之间创建了耦合。如果他们中的任何一个人想在服务器级别更改某些内容,则必须与其他团队协调更改并应用于所有应用程序,从而限制敏捷性和应用程序演变。除此之外,应用程序的部署取决于计算机上可用的服务器,从而限制了应用程序在不同环境中的可移植性。
当您使用云原生时,情况会有所不同。云原生应用程序应该是独立的,不依赖于执行环境中可用的服务器。相反,必要的服务器功能包含在应用程序本身中。Spring 引导提供了内置的服务器功能,可帮助您消除外部依赖项并使应用程序独立。Spring Boot与预配置的Tomcat服务器捆绑在一起,但可以用Undertow,Jetty或Netty替换它。
解决了服务器依赖问题后,我们需要相应地更改打包应用程序的方式。在 JVM 生态系统中,云原生应用程序被打包为 JAR 工件。由于它们是自包含的,因此它们可以作为独立的 Java 应用程序运行,除了 JVM 之外没有外部依赖关系。Spring Boot 足够灵活,可以同时进行 JAR 和 WAR 类型的打包。尽管如此,对于云原生应用程序,您仍然需要使用自包含的 JAR,也称为 fat-JAR 或 uber-JAR,因为它们包含应用程序本身、依赖项和嵌入式服务器。图 3.3 比较了打包和运行 Web 应用程序的传统和云原生方式。
图 3.3 传统上,应用程序被打包为 WAR,并且需要服务器在执行环境中可用才能运行。云原生应用程序打包为 JAR,是独立的,并使用嵌入式服务器。
用于云原生应用程序的嵌入式服务器通常包括 Web 服务器组件和执行上下文,以使 Java Web 应用程序与 Web 服务器进行交互。例如,Tomcat包含一个Web服务器组件(Coyote)和一个基于Java Servlet API的执行上下文,通常称为Servlet容器(Catalina)。我将交替使用 Web 服务器和 Servlet 容器。另一方面,不建议将应用程序服务器用于云原生应用程序。
在上一章中,在生成目录服务项目时,我们选择了 JAR 打包选项。然后,我们使用 bootRun Gradle 任务运行应用程序。这是在开发过程中构建项目并将其作为独立应用程序运行的便捷方法。但是现在您对嵌入式服务器和 JAR 打包有了更多的了解,我将向您展示另一种方法。
首先,让我们将应用程序打包为 JAR 文件。打开终端窗口,导航到目录服务项目(目录服务)的根文件夹,然后运行以下命令。
$ ./gradlew bootJar
bootJar Gradle 任务编译代码并将应用程序打包为 JAR 文件。默认情况下,JAR 在 build/libs 文件夹中生成。您应该获得一个名为 catalog-service-0.0.1-SNAPSHOT.jar 的可执行 JAR 文件。获得JAR工件后,您可以像任何标准Java应用程序一样继续运行它。
$ java -jar build/libs/catalog-service-0.0.1-SNAPSHOT.jar
注意另一个实用的 Gradle 任务是构建,它结合了 bootJar 的操作和测试任务。
由于该项目包含 spring-boot-starter-web 依赖项,Spring Boot 会自动配置嵌入式 Tomcat 服务器。通过查看图 3.4 中的日志,您可以看到第一个执行步骤之一是初始化嵌入在应用程序本身中的 Tomcat 服务器实例。
图 3.4 目录服务应用程序的启动日志
在下一节中,您将了解有关嵌入式服务器在 Spring Boot 中如何工作的更多信息。但是,在继续之前,您可以使用 Ctrl-C 停止应用程序。
3.2.2 了解每个请求线程模型让我们考虑一下 Web 应用程序中常用的请求/响应模式,以通过 HTTP 建立同步交互。客户端向执行某些计算的服务器发送 HTTP 请求,然后使用 HTTP 响应进行回复。
在像Tomcat这样的Servlet容器中运行的Web应用程序中,请求基于称为每请求线程的模型进行处理。对于每个请求,应用程序专用于处理该特定请求的线程;在将响应返回到客户端之前,线程不会用于任何其他内容。当请求处理涉及密集型操作(如 I/O)时,线程将阻塞,直到操作完成。例如,如果需要读取数据库,线程将等待,直到从数据库返回数据。这就是为什么我们说这种类型的处理是同步和阻塞的。
Tomcat 使用用于管理所有传入 HTTP 请求的线程池进行初始化。当所有线程都在使用中时,新请求将排队,等待线程变为空闲状态。换句话说,Tomcat 中的线程数定义了并发支持的请求数的上限。在调试性能问题时记住这一点非常有用。如果连续达到线程并发限制,则始终可以调整线程池配置以接受更多工作负载。对于传统应用程序,我们将向特定实例添加更多计算资源。对于云原生应用,我们依赖于水平扩展和部署更多副本。
注意在某些必须响应高需求的应用程序中,每个请求的线程模型可能并不理想,因为它由于阻塞而没有以最有效的方式使用可用的计算资源。在第 8 章中,我将介绍 Spring WebFlux 和 Project Reactor 的异步和非阻塞替代方案,采用响应式编程范式。
Spring MVC是Spring框架中包含的库,用于实现Web应用程序,无论是完整的MVC还是基于REST。无论哪种方式,该功能都基于像Tomcat这样的服务器,该服务器提供与Java Servlet API兼容的Servlet容器。图 3.5 显示了基于 REST 的请求/响应交互在 Spring Web 应用程序中的工作原理。
图 3.5 DispatcherServlet 组件是 Servlet 容器 (Tomcat) 的入口点。它将实际的 HTTP 请求处理委托给由 HandlerMapping 标识为负责给定终结点的控制器。
DispatcherServlet 组件为请求处理提供了一个中心入口点。当客户端为特定 URL 模式发送新的 HTTP 请求时,DispatcherServlet 会向 HandlerMapping 组件请求负责该端点的控制器,最后它将请求的实际处理委托给指定的控制器。控制器处理请求,可能通过调用一些其他服务,然后向 DispatcherServlet 返回响应,后者最终使用 HTTP 响应回复客户端。
请注意 Tomcat 服务器是如何嵌入到 Spring Boot 应用程序中的。Spring MVC依靠Web服务器来完成其功能。对于任何实现 Servlet API 的 Web 服务器也是如此,但由于我们显式使用 Tomcat,让我们继续探索一些配置它的选项。
3.2.3 配置嵌入式雄猫Tomcat是预配置任何Spring Boot Web应用程序的默认服务器。有时,默认配置可能就足够了,但对于生产中的应用程序,您可能需要自定义其行为以满足特定要求。
注意在传统的 Spring 应用程序中,您将在专用文件(如 server.xml 和 context.xml)中配置像 Tomcat 这样的服务器。使用 Spring Boot,您可以通过两种方式配置嵌入式 Web 服务器:通过属性或在 WebserverFactoryCustomizer bean 中配置。
本节将介绍如何通过属性配置 Tomcat。您将在下一章中了解有关配置应用程序的更多信息。现在,只要知道您可以在位于项目的 src/main/resources 文件夹中的 application.properties 或 application.yml 文件中定义属性就足够了。您可以自由选择要使用的格式:.properties 文件依赖于键/值对,而 .yml 文件使用 YAML 格式。在本书中,我将使用 YAML 定义属性。Spring Initializr 默认生成一个空的 application.properties 文件,因此请记住在继续之前将其扩展名从 .properties 更改为 .yml。
让我们继续为目录服务应用程序(目录服务)配置嵌入式服务器。所有配置属性都将放在 application.yml 文件中。
HTTP端口
默认情况下,嵌入式服务器正在侦听端口 8080。只要您只使用一个应用程序,这很好。如果在开发过程中运行更多 Spring 应用程序(云原生系统通常就是这种情况),则需要使用 server.port 属性为每个应用程序指定不同的端口号。
3.1 配置 Web 服务器端口
server: port: 9001
连接超时
server.tomcat.connection-timeout 属性定义了 Tomcat 在接受来自客户端的 TCP 连接和实际接收 HTTP 请求之间应等待的时间限制。它有助于防止拒绝服务 (DoS) 攻击,其中建立了连接,Tomcat 保留了一个线程来处理请求,并且请求永远不会出现。相同的超时用于限制读取 HTTP 请求正文所花费的时间(如果有)。
默认值为 20 秒(20 秒),这对于标准云原生应用程序来说可能太多了。在云中高度分布式系统的上下文中,我们可能不希望等待超过几秒钟,并且由于Tomcat实例挂起太长时间而导致级联故障的风险。像 2s 这样的东西会更好。还可以使用 server.tomcat.keep-alive-timeout 属性来配置在等待新的 HTTP 请求时保持连接打开的时间。
示例 3.2 配置 Tomcat 的超时
server: port: 9001 tomcat: connection-timeout: 2s keep-alive-timeout: 15s
线程池
Tomcat 有一个处理请求的线程池,遵循每个请求的线程模型。可用线程的数量将决定可以同时处理多少个请求。您可以通过 server.tomcat.threads.max 属性配置请求处理线程的最大数量。您还可以定义应始终保持运行的最小线程数(server.tomcat .threads.min-spare),这也是在启动时创建的线程数。
确定线程池的最佳配置很复杂,并且没有计算它的神奇公式。通常需要资源分析、监控和许多试验才能找到合适的配置。默认线程池最多可以增长到 200 个线程,并且始终运行 10 个工作线程,这是生产中的良好起始值。在本地环境中,您可能希望降低这些值以优化资源消耗,因为它随线程数线性增加。
3.3 配置 Tomcat 线程池
server: port: 9001 tomcat: connection-timeout: 2s keep-alive-timeout: 15s threads: max: 50 min-spare: 5
到目前为止,您已经看到带有 Spring Boot 的云原生应用程序被打包为 JAR 文件,并依靠嵌入式服务器来消除对执行环境的额外依赖并实现敏捷性。您了解了每个请求线程模型的工作原理,熟悉了Tomcat和Spring MVC的请求处理流程,并配置了Tomcat。在下一节中,我们将继续讨论目录服务的业务逻辑以及使用 Spring MVC 实现 REST API。
3.3 使用 Spring MVC 构建 RESTful 应用程序如果您正在构建云原生应用程序,则很可能您正在使用由多个服务(如微服务)组成的分布式系统,这些服务相互交互以实现产品的整体功能。您的应用程序可能由组织中的另一个团队开发的服务使用,或者您可能正在向第三方公开其功能。无论哪种方式,任何服务间通信都有一个基本元素:API。
15 因素方法促进了 API 优先模式。它鼓励您先建立服务接口,然后再进行实现。API 表示应用程序与其使用者之间的公共协定,首先定义它符合您的最佳利益。
假设您同意合约并首先定义 API。在这种情况下,其他团队可以开始处理他们的解决方案,并针对您的 API 进行开发,以实现与您的应用程序的集成。如果你不先开发API,就会有一个瓶颈,其他团队将不得不等到你完成你的应用程序。预先讨论 API 还可以与利益干系人进行富有成效的讨论,这可能会导致您澄清应用程序的范围,甚至定义要实现的用户情景。
在云中,任何应用程序都可以成为另一个应用程序的后备服务。采用 API 优先的心态将帮助您发展应用程序并使其适应未来的需求。
本节将指导您将目录服务的协定定义为 REST API,这是云原生应用程序最常用的服务接口模型。你将使用Spring MVC来实现REST API,验证它,并测试它。我还将概述根据未来需求改进 API 的一些注意事项,这是云原生应用程序等高度分布式系统中的常见问题。
3.3.1 先做 REST API,后业务逻辑首先设计 API 时,假设您已经定义了需求,因此让我们从这些需求开始。目录服务将负责支持以下用例:
换句话说,我们可以说应用程序应该提供一个 API 来对书籍执行 CRUD 操作。该格式将遵循应用于 HTTP 的 REST 样式。有几种方法可以设计 API 来满足这些用例。在本章中,我们将使用表 3.1 中描述的方法。
表 3.1 目录服务将公开的 REST API 规范
端点 | HTTP方法 | 请求正文 | 地位 | 响应正文 | 描述 |
/书 | 获取 | 200 | 书[] | 获取目录中的所有书籍。 | |
/书 | 发布 | 书 | 201 | 书 | 将新图书添加到目录中。 |
422 | 具有相同 ISBN 的书籍已存在。 | ||||
/书籍/{国际标准书号} | 获取 | 200 | 书 | 获取带有给定 ISBN 的图书。 | |
404 | 不存在具有给定 ISBN 的书籍。 | ||||
/书籍/{国际标准书号} | 放 | 书 | 200 | 书 | 使用给定的 ISBN 更新图书。 |
201 | 书 | 使用给定的 ISBN 创建图书。 | |||
/书籍/{国际标准书号} | 删除 | 204 | 删除具有给定 ISBN 的图书。 |
记录 API
在遵循 API 优先方法时,记录 API 是一项基本任务。在Spring生态系统中,有两个主要选项:
合约是通过 REST API 建立的,所以让我们继续看一下业务逻辑。该解决方案围绕三个概念:
让我们从域实体开始。
定义域实体
表 3.1 中定义的 REST API 应该可以在书籍上进行操作。这就是域实体。在目录服务项目中,为业务逻辑创建一个新的 com.polarbookshop.catalogservice.domain 包,并创建一个 Book Java 记录来表示域实体。
3.4 使用Book记录为应用程序定义域实体
package com.polarbookshop.catalogservice.domain; public record Book ( ❶ String isbn, ❷ String title, String author, Double price){}
❶ 域模型作为记录实现,一个不可变的对象。
❷ 唯一标识一本书
实现用例
应用程序需求枚举的用例可以在@Service类中实现。在 com.polarbookshop.catalogservice.domain 包中,创建一个 BookService 类,如下面的清单所示。该服务依赖于您将在一分钟内创建的某些类。
清单 3.5 实现应用程序的用例
package com.polarbookshop.catalogservice.domain; import org.springframework.stereotype.Service; @Service ❶public class BookService { private final BookRepository bookRepository; public BookService(BookRepository bookRepository) { this.bookRepository = bookRepository; ❷ } public Iterable<Book> viewBookList() { return bookRepository.findAll(); } public Book viewBookDetails(String isbn) { return bookRepository.findByIsbn(isbn) ❸ .orElseThrow(() -> new BookNotFoundException(isbn)); } public Book addBookToCatalog(Book book) { if (bookRepository.existsByIsbn(book.isbn())) { ❹ throw new BookAlreadyExistsException(book.isbn()); } return bookRepository.save(book); } public void removeBookFromCatalog(String isbn) { bookRepository.deleteByIsbn(isbn); } public Book editBookDetails(String isbn, Book book) { return bookRepository.findByIsbn(isbn) .map(existingBook -> { var bookToUpdate = new Book( ❺ existingBook.isbn(), book.title(), book.author(), book.price()); return bookRepository.save(bookToUpdate); }) .orElseGet(() -> addBookToCatalog(book)); ❻ }}
❶ 将类标记为由 Spring 管理的服务的构造型注释
❷ BookRepository 是通过构造函数自动布线提供的。
❸ 尝试查看不存在的书籍时,会抛出专门的异常。
❹ 多次将同一本书添加到目录时,会引发专用异常。
❺ 编辑图书时,可以更新除 ISBN 代码之外的所有图书字段,因为它是实体标识符。
❻ 更改目录中尚未包含的图书的详细信息时,请创建新图书。
注意Spring 框架提供了两种类型的依赖注入:基于构造函数的和基于setter的。我们将在任何生产代码中使用基于构造函数的依赖注入,正如 Spring 团队所倡导的那样,因为它确保所需的依赖关系始终完全初始化且永远不会返回 null。此外,它鼓励构建不可变的对象并提高其可测试性。有关更多信息,请参阅 Spring 框架文档 (https://spring.io/projects/spring-framework)。
使用存储库抽象进行数据访问
类依赖于 BookRepository 对象来检索和保存书籍。域层应该不知道数据是如何持久化的,所以 BookRepository 应该是一个接口,将抽象与实际实现分离。在 com.polarbookshop.catalogservice.domain 包中创建 BookRepository 接口,以定义访问书籍数据的抽象。
3.6 域层用于访问数据的抽象
package com.polarbookshop.catalogservice.domain; import java.util.Optional; public interface BookRepository { Iterable<Book> findAll(); Optional<Book> findByIsbn(String isbn); boolean existsByIsbn(String isbn); Book save(Book book); void deleteByIsbn(String isbn);}
虽然存储库接口属于域,但其实现是持久性层的一部分。我们将在第 5 章中使用关系数据库添加数据持久性层。现在,添加一个简单的内存地图来检索和保存书籍就足够了。您可以在位于新 com.polarbookshop.catalogservice.persistence 包中的 InMemoryBookRepository 类中定义实现。
示例 3.7 BookRepository 接口的内存中实现
package com.polarbookshop.catalogservice.persistence; import java.util.Map;import java.util.Optional;import java.util.concurrent.ConcurrentHashMap;import com.polarbookshop.catalogservice.domain.Book;import com.polarbookshop.catalogservice.domain.BookRepository;import org.springframework.stereotype.Repository; @Repository ❶public class InMemoryBookRepository implements BookRepository { private static final Map<String, Book> books = ❷ new ConcurrentHashMap<>(); @Override public Iterable<Book> findAll() { return books.values(); } @Override public Optional<Book> findByIsbn(String isbn) { return existsByIsbn(isbn) ? Optional.of(books.get(isbn)) : Optional.empty(); } @Override public boolean existsByIsbn(String isbn) { return books.get(isbn) != null; } @Override public Book save(Book book) { books.put(book.isbn(), book); return book; } @Override public void deleteByIsbn(String isbn) { books.remove(isbn); }}
❶ 构造型注释,将类标记为由 Spring 管理的存储库
❷ 内存地图,用于存储书籍以进行测试
对域中的信号错误使用异常
让我们通过实现清单 3.5 中使用的两个异常来完成目录服务的业务逻辑。
BookAlreadyExistsException 是当我们尝试将一本书添加到已经存在的目录中时引发的运行时异常。它可以防止目录中的重复条目。
示例 3.8 添加已存在的书籍时引发的异常
package com.polarbookshop.catalogservice.domain; public class BookAlreadyExistsException extends RuntimeException { public BookAlreadyExistsException(String isbn) { super("A book with ISBN " isbn " already exists."); }}
BookNotFoundException 是我们尝试获取不在目录中的书籍时引发的运行时异常。
示例 3.9 找不到一本书时抛出的异常
package com.polarbookshop.catalogservice.domain; public class BookNotFoundException extends RuntimeException { public BookNotFoundException(String isbn) { super("The book with ISBN " isbn " was not found."); }}
这样就完成了目录服务的业务逻辑。它相对简单,但建议不要受到数据持久化或与客户端交换方式的影响。业务逻辑应独立于其他任何内容,包括 API。如果你对这个主题感兴趣,我建议你探索领域驱动设计和六边形架构的概念。
3.3.2 使用 Spring MVC 实现 REST API实现业务逻辑后,我们可以通过 REST API 公开用例。Spring MVC提供了@RestController类来定义处理特定HTTP方法和资源端点的传入HTTP请求的方法。
正如您在上一节中看到的,DispatcherServlet 组件将为每个请求调用正确的控制器。图 3.6 显示了客户端发送 HTTP GET 请求以查看特定书籍详细信息的场景。
图 3.6 到达 /books/<isbn> 端点的 HTTP GET 请求的处理流程
我们希望为应用程序需求中定义的每个用例实现一个方法处理程序,因为我们希望所有这些用例都可供客户端使用。为 Web 图层 (com.polarbookshop.catalogservice.web) 创建一个包,并添加一个 BookController 类,该类负责处理发送到 /books 基本端点的 HTTP 请求。
清单 3.10 定义 REST 端点的处理程序
package com.polarbookshop.catalogservice.web; import com.polarbookshop.catalogservice.domain.Book;import com.polarbookshop.catalogservice.domain.BookService;import org.springframework.http.HttpStatus;import org.springframework.web.bind.annotation.*; @RestController ❶@RequestMapping("books") ❷public class BookController { private final BookService bookService; public BookController(BookService bookService) { this.bookService = bookService; } @GetMapping ❸ public Iterable<Book> get() { return bookService.viewBookList(); } @GetMapping("{isbn}") ❹ public Book getByIsbn(@PathVariable String isbn) { ❺ return bookService.viewBookDetails(isbn); } @PostMapping ❻ @ResponseStatus(HttpStatus.CREATED) ❼ public Book post(@RequestBody Book book) { ❽ return bookService.addBookToCatalog(book); } @DeleteMapping("{isbn}") ❾ @ResponseStatus(HttpStatus.NO_CONTENT) ❿ public void delete(@PathVariable String isbn) { bookService.removeBookFromCatalog(isbn); } @PutMapping("{isbn}") ⓫ public Book put(@PathVariable String isbn, @RequestBody Book book) { return bookService.editBookDetails(isbn, book); }}
❶ 构造型注释,将类标记为 Spring 组件和 REST 端点的处理程序源
❷ 标识类为其提供处理程序的根路径映射 URI(“/books”)
❸ 将 HTTP GET 请求映射到特定的处理程序方法
❹ 附加到根路径映射 URI 的 URI 模板变量 (“/books/{isbn}”)
❺ @PathVariable将方法参数绑定到 URI 模板变量 ({isbn})。
❻ 将 HTTP POST 请求映射到特定的处理程序方法
❼ 如果图书创建成功,则返回 201 状态
❽ @RequestBody将方法参数绑定到 Web 请求的正文。
❾ 将 HTTP DELETE 请求映射到特定的处理程序方法
❿ 如果书籍删除成功,则返回 204 状态
⓫ 将 HTTP PUT 请求映射到特定的处理程序方法
继续运行应用程序(./gradlew bootRun)。在验证与应用程序的 HTTP 交互时,您可以使用命令行工具(如 curl)或具有图形用户界面的软件(如 Insomnia)。我将使用一个名为HTTPie(https://httpie.org)的便捷命令行工具。您可以在附录 A 的 A.4 节中找到有关如何安装它的信息。
打开“终端”窗口并执行 HTTP POST 请求以将图书添加到目录中:
$ http POST :9001/books author="Lyra Silverstar" \ title="Northern Lights" isbn="1234567891" price=9.90
结果应该是带有 201 代码的 HTTP 响应,这意味着该书已成功创建。让我们通过提交 HTTP GET 请求来仔细检查,以使用我们在创建时使用的 ISBN 代码获取图书。
$ http :9001/books/1234567891 HTTP/1.1 200Content-Type: application/json { "author": "Lyra Silverstar", "isbn": "1234567891", "price": 9.9, "title": "Northern Lights"}
试用完应用程序后,使用 Ctrl-C 停止其执行。
关于内容协商
BookController 中的所有处理程序方法都适用于 Book Java 对象。但是,当您执行请求时,您会返回一个 JSON 对象。这怎么可能?
Spring MVC 依赖于 HttpMessageConverter bean 将返回的对象转换为客户端支持的特定表示形式。有关内容类型的决策由称为内容协商的过程驱动,在此过程中,客户端和服务器就双方都能理解的表示形式达成一致。客户端可以通过 HTTP 请求中的 Accept 标头通知服务器它支持的内容类型。
默认情况下,Spring Boot 配置一组 HttpMessageConverter bean 以返回表示为 JSON 的对象,并且默认情况下将 HTTPie 工具配置为接受任何内容类型。结果是客户端和服务器都支持 JSON 内容类型,因此它们同意使用它进行通信
到目前为止,我们实现的应用程序仍未完成。例如,没有什么能阻止您以错误的格式发布带有 ISBN 的新书或未指定标题。我们需要验证输入。
3.3.3 数据验证和错误处理作为一般规则,在保存任何数据之前,出于数据一致性和安全原因,应始终验证内容。一本没有标题的书在我们的应用程序中是没有用的,它可能会让它失败。
对于 Book 类,我们可能会考虑使用以下验证约束:
Java Bean 验证是一种流行的规范,用于通过注解来表达 Java 对象的约束和验证规则。Spring Boot 提供了一个方便的启动器依赖项,其中包含 Java Bean Validation API 及其实现。在目录服务项目的 build.gradle 文件中添加新依赖项。请记住在新添加后刷新或重新导入 Gradle 依赖项。
3.11 为Spring Boot Validation 添加依赖项
dependencies { ... implementation 'org.springframework.boot:spring-boot-starter-validation' }
现在,您可以使用 Java Bean 验证 API 直接在“书籍记录”字段上将验证约束定义为注释。
3.12 为每个字段定义的验证约束
package com.polarbookshop.catalogservice.domain; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; import javax.validation.constraints.Pattern; import javax.validation.constraints.Positive; public record Book ( @NotBlank(message = "The book ISBN must be defined.") @Pattern( ❶ regexp = "^([0-9]{10}|[0-9]{13})$", message = "The ISBN format must be valid." ) String isbn, @NotBlank( ❷ message = "The book title must be defined." ) String title, @NotBlank(message = "The book author must be defined.") String author, @NotNull(message = "The book price must be defined.") @Positive( ❸ message = "The book price must be greater than zero." ) Double price){}
❶ 带注释的元素必须与指定的正则表达式匹配(标准 ISBN 格式)。
❷ 带批注的元素不得为 null,并且必须至少包含一个非空格字符。
❸ 带批注的元素不得为 null,并且必须大于零。
注意图书由其 ISBN(国际标准书号)唯一标识。ISBN 过去由 10 位数字组成,但现在由 13 位数字组成。为简单起见,我们将限制为使用正则表达式检查它们的长度以及是否所有元素都是数字。
来自 Java Bean Validation API 的注释定义了约束,但它们尚未强制执行。我们可以指示 Spring 在将@RequestBody指定为方法参数时,使用 @Valid 注释来验证 BookController 类中的 Book 对象。这样,每当我们创建或更新一本书时,Spring 都会运行验证,并在违反任何约束时抛出错误。我们可以更新 BookController 类中的 post() 和 put() 方法,如下所示。
示例 3.13 验证在请求正文中传递的书籍
...@PostMapping@ResponseStatus(HttpStatus.CREATED)public Book post(@Valid @RequestBody Book book) { return bookService.addBookToCatalog(book);}@PutMapping("{isbn}")public Book put(@PathVariable String isbn, @Valid @RequestBody Book book) { return bookService.editBookDetails(isbn, book);}...
Spring 允许您以不同的方式处理错误消息。在构建 API 时,最好考虑它可能引发哪些类型的错误,因为它们与域数据一样重要。如果是 REST API,则需要确保 HTTP 响应使用最适合用途的状态代码,并包含有意义的消息来帮助客户端识别问题。
当我们刚刚定义的验证约束不满足时,会抛出 MethodArgumentNotValidException。如果我们试图拿一本不存在的书怎么办?我们之前实现的业务逻辑抛出专用异常(BookAlreadyExistsException 和 BookNotFoundException)。所有这些异常都应在 REST API 上下文中处理,以返回原始规范中定义的错误代码。
为了处理 REST API 的错误,我们可以使用标准的 Java 异常,并依靠 @RestControllerAdvice 类来定义在抛出给定异常时要执行的操作。这是一种集中式方法,允许我们将异常处理与引发异常的代码分离。在 com.polarbookshop.catalogservice .web 包中,创建一个 BookControllerAdvice 类,如下所示。
示例 3.14 定义如何处理异常的建议类
package com.polarbookshop.catalogservice.web; import java.util.HashMap;import java.util.Map;import com.polarbookshop.catalogservice.domain.BookAlreadyExistsException;import com.polarbookshop.catalogservice.domain.BookNotFoundException;import org.springframework.http.HttpStatus;import org.springframework.validation.FieldError;import org.springframework.web.bind.MethodArgumentNotValidException;import org.springframework.web.bind.annotation.ExceptionHandler;import org.springframework.web.bind.annotation.ResponseStatus;import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice ❶public class BookControllerAdvice { @ExceptionHandler(BookNotFoundException.class) ❷ @ResponseStatus(HttpStatus.NOT_FOUND) String bookNotFoundHandler(BookNotFoundException ex) { return ex.getMessage(); ❸ } @ExceptionHandler(BookAlreadyExistsException.class) @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) ❹ String bookAlreadyExistsHandler(BookAlreadyExistsException ex) { return ex.getMessage(); } @ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public Map<String, String> handleValidationExceptions( MethodArgumentNotValidException ex ❺ ) { var errors = new HashMap<String, String>(); ex.getBindingResult().getAllErrors().forEach(error -> { String fieldName = ((FieldError) error).getField(); String errorMessage = error.getDefaultMessage(); errors.put(fieldName, errorMessage); ❻ }); return errors; }}
❶ 将类标记为集中式异常处理程序
❷ 定义必须执行处理程序的异常
❸ 将包含在 HTTP 响应正文中的消息
❹ 定义引发异常时创建的 HTTP 响应的状态代码
❺ 处理书籍验证失败时引发的异常
❻ 收集有关哪些 Book 字段无效的有意义的错误消息,而不是返回空消息
@RestControllerAdvice类中提供的映射使得当我们尝试创建目录中已存在的书籍时,可以获得状态为 422(无法处理的实体)的 HTTP 响应,当我们尝试阅读不存在的书籍时,可以获得状态为 404(未找到)的响应,当书籍中的一个或多个字段时,可以获得状态为 400(错误请求)的响应对象无效。每个响应都将包含一条有意义的消息,我们将其定义为验证约束或自定义异常的一部分。
构建并重新运行应用程序 (./gradlew bootRun):如果您现在尝试创建没有标题且格式错误的 ISBN 的图书,则请求将失败。
$ http POST :9001/books author="Jon Snow" title="" isbn="123ABC456Z" \ price=9.90
结果将是一条状态为“400 错误请求”的错误消息,这意味着服务器无法处理 HTTP 请求,因为它不正确。响应正文包含一条详细的消息,说明请求的哪一部分不正确以及如何修复它,正如我们在清单 3.12 中所定义的那样。
HTTP/1.1 400Content-Type: application/json { "isbn": "The ISBN format must be valid.", "title": "The book title must be defined."}
试用完应用程序后,使用 Ctrl-C 停止其执行。
我们对 REST API 的实现到此结束,该 API 公开了目录服务的书籍管理功能。接下来,我将讨论如何改进 API 以适应新需求的几个方面。
3.3.4 为未来需求而演进的 API在分布式系统中,我们需要一个发展API的计划,这样我们就不会破坏其他应用程序的功能。这是一项具有挑战性的任务,因为我们想要独立的应用程序,但它们的存在可能是为了向其他应用程序提供服务,因此我们可以独立于客户端进行的更改数量受到一定限制。
最佳方法是对 API 进行向后兼容的更改。例如,我们可以向 Book 对象添加一个可选字段,而不会影响目录服务应用程序的客户端。
有时,中断性更改是必要的。在这种情况下,您可以使用 API 版本控制。例如,如果您决定对目录服务应用程序的 REST API 进行重大更改,则可以为端点引入版本控制系统。版本可能是终结点本身的一部分,如 /v2/books。或者它可能被指定为 HTTP 标头。该系统有助于防止现有客户端中断,但它们迟早必须更新其接口以匹配新的 API 版本,这意味着需要协调。
另一种方法侧重于使 REST API 客户端尽可能灵活地应对 API 更改。解决方案是使用REST架构的超媒体方面,正如Roy Fielding博士在他的博士论文“架构风格和基于网络的软件架构的设计”(www.ics.uci.edu/~fielding/pubs/dissertation/top.htm)中所描述的那样。REST API 可以返回请求的对象,以及有关下一步要去哪里的信息以及用于执行相关操作的链接。此功能的美妙之处在于,链接仅在有意义时才显示,提供有关何时访问的信息。
这种超媒体方面也称为HATEOAS(超媒体作为应用程序状态的引擎),根据Richardson的成熟度模型,它代表了API成熟度的最高级别。Spring 提供了 Spring HATEOAS 项目,用于向 REST API 添加超媒体支持。我不会在本书中使用它,但我鼓励你在 https://spring.io/projects/spring-hateoas 查看该项目的在线文档。
这些考虑结束了我们对使用 Spring 构建 RESTful 应用程序的讨论。在下一节中,你将了解如何编写自动测试来验证应用程序的行为。
3.4 用 Spring 测试 RESTful 应用程序自动化测试对于生成高质量软件至关重要。采用云原生方法的目标之一是速度。如果没有以自动化方式对代码进行充分测试,就不可能快速移动,更不用说实现持续交付流程了。
作为开发人员,您通常会实现一个功能,交付它,然后继续使用新功能,可能会重构现有代码。重构代码是有风险的,因为您可能会破坏某些现有功能。自动化测试可以降低风险并鼓励重构,因为您知道,如果您破坏某些内容,测试将失败。您可能还希望缩短反馈周期,以便尽快知道是否犯了任何错误。这将引导您以最大化其有用性和效率的方式设计测试。您不应该以达到最大的测试覆盖率为目标,而应该编写有意义的测试。例如,为标准 getter 和 setter 编写测试是没有意义的。
持续交付的一个基本实践是测试驱动开发 (TDD),它有助于实现快速、可靠、安全地交付软件的目标。这个想法是通过在实现生产代码之前编写测试来推动软件开发。我建议在实际场景中采用TDD。但是,在书中教授新技术和框架时,它不是很合适,所以我在这里不会遵循它的原则。
自动测试断言新功能按预期工作,并且您没有破坏任何现有功能。这意味着自动测试用作回归测试。你应该写测试来保护你的同事和你自己不犯错误。要测试的内容和测试的深度如何取决于与特定代码段相关的风险。编写测试也是一种学习体验,可以提高您的技能,尤其是在您开始软件开发之旅时。
对软件测试进行分类的一种方法是敏捷测试象限模型,该模型最初由Brian Marick引入,后来由Lisa Crispin和Janet Gregory在他们的书中描述和扩展敏捷测试(Addison-Wesley Professional,2008),更多敏捷测试(Addison-Wesley Professional,2014)和敏捷测试浓缩(加拿大图书馆和档案馆,2019)。他们的模型也被Jez Humble和Dave Farley在持续交付中接受(Addison-Wesley Professional,2010)。象限根据软件测试是面向技术还是面向业务,以及它们是否支持开发团队或用于批评产品来对软件测试进行分类。图 3.7 显示了我将在本书中提到的一些测试类型示例,这些示例基于敏捷测试精简版中介绍的模型。
图 3.7 敏捷测试象限模型有助于规划软件测试策略。
遵循持续交付实践,我们的目标是在四个象限中的三个象限中实现全自动测试,如图 3.7 所示。在整本书中,我们将主要关注左下象限。在本节中,我们将使用单元测试和集成测试(有时称为组件测试)。我们编写单元测试来单独验证单个应用程序组件的行为,而集成测试则断言应用程序的不同部分相互交互的整体功能。
在 Gradle 或 Maven 项目中,测试类通常放在 src/test/java 文件夹中。在 Spring 中,加载 Spring 应用程序上下文不需要单元测试,它们也不依赖于任何 Spring 库。另一方面,集成测试需要一个 Spring 应用程序上下文才能运行。本节将介绍如何使用单元测试和集成测试来测试 RESTful 应用程序(如目录服务)。
3.4.1 使用 JUnit 5 进行单元测试单元测试不知道 Spring,也不依赖于任何 Spring 库。它们旨在测试单个组件作为隔离单元的行为。模拟单元边缘的任何依赖项,以保持测试与外部组件隔离。
为 Spring 应用程序编写单元测试与为任何其他 Java 应用程序编写单元测试没有什么不同,所以我不会详细介绍它们。默认情况下,从Spring Initializr创建的任何Spring项目都包含spring-boot-starter-test依赖项,它将JUnit 5,Mockito和AssertJ等测试库导入到项目中。因此,我们已经准备好编写单元测试了。
应用程序的业务逻辑通常是单元测试涵盖的合理区域。在目录服务应用程序中,单元测试的一个很好的候选项可能是 Book 类的验证逻辑。验证约束是使用 Java 验证 API 注释定义的,我们有兴趣测试它们是否正确应用于 Book 类。我们可以在新的 BookValidationTests 类中检查这一点,如下面的列表所示。
3.15 用于验证书籍验证约束的单元测试
package com.polarbookshop.catalogservice.domain; import java.util.Set;import javax.validation.ConstraintViolation;import javax.validation.Validation;import javax.validation.Validator;import javax.validation.ValidatorFactory;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.Test;import static org.assertj.core.api.Assertions.assertThat; class BookValidationTests { private static Validator validator; @BeforeAll ❶ static void setUp() { ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); validator = factory.getValidator(); } @Test ❷ void whenAllFieldsCorrectThenValidationSucceeds() { var book = ❸ new Book("1234567890", "Title", "Author", 9.90); Set<ConstraintViolation<Book>> violations = validator.validate(book); assertThat(violations).isEmpty(); ❹ } @Test void whenIsbnDefinedButIncorrectThenValidationFails() { var book = ❺ new Book("a234567890", "Title", "Author", 9.90); Set<ConstraintViolation<Book>> violations = validator.validate(book); assertThat(violations).hasSize(1); assertThat(violations.iterator().next().getMessage()) .isEqualTo("The ISBN format must be valid."); ❻ }}
❶ 标识在类中的所有测试之前执行的代码块
❷ 标识测试用例
❸ 使用有效的 ISBN 创建图书
❹ 断言没有验证错误
❺ 使用无效的 ISBN 代码创建图书
❻ 断言违反的验证约束与不正确的 ISBN 有关
然后我们可以使用以下命令运行测试:
$ ./gradlew test --tests BookValidationTests
3.4.2 与@SpringBootTest的集成测试
集成测试涵盖了软件组件之间的交互,在Spring中,它们需要定义应用程序上下文。spring-boot-starter-test 依赖项也从 Spring Framework 和 Spring Boot 导入测试实用程序。
Spring Boot 提供了一个强大的@SpringBootTest注释,您可以在测试类上使用它在运行测试时自动引导应用程序上下文。如果需要,可以自定义用于创建上下文的配置。否则,用 @SpringBootApplication 注释的类将成为组件扫描和属性的配置源,包括 Spring Boot 提供的通常的自动配置。
使用 Web 应用程序时,可以在模拟 Web 环境或正在运行的服务器上运行测试。您可以通过定义 @SpringBootTest 注释提供的 webEnvironment 属性的值来配置它,如表 3.2 所示。
使用模拟 Web 环境时,可以依赖 MockMvc 对象向应用程序发送 HTTP 请求并检查其结果。对于具有正在运行的服务器的环境,TestRestTemplate 实用程序允许您对在实际服务器上运行的应用程序执行 REST 调用。通过检查 HTTP 响应,您可以验证 API 是否按预期工作。
表 3.2 Spring 引导集成测试可以使用模拟 Web 环境或正在运行的服务器进行初始化。
网络环境选项 | 描述 |
模拟 | 使用模拟 Servlet 容器创建 Web 应用程序上下文。这是默认选项。 |
RANDOM_PORT | 创建一个 Web 应用程序上下文,其中包含侦听随机端口的 Servlet 容器。 |
DEFINED_PORT | 创建一个 Web 应用程序上下文,其中包含一个 Servlet 容器,侦听通过 server.port 属性定义的端口。 |
没有 | 创建没有 Servlet 容器的应用程序上下文。 |
最新版本的 Spring Framework 和 Spring Boot 扩展了测试 Web 应用程序的功能。现在,您可以使用 WebTestClient 类在模拟环境和正在运行的服务器上测试 REST API。与MockMvc和TestRestTemplate相比,WebTestClient提供了一个现代和流畅的API和附加功能。此外,您可以将其用于命令式(例如目录服务)和响应式应用程序,从而优化学习和生产力。
由于WebTestClient是Spring WebFlux项目的一部分,因此您需要在Catalog Service项目(build.gradle)中添加一个新的依赖项。请记住在新添加后刷新或重新导入 Gradle 依赖项。
3.16 为 Spring 响应式 Web 添加测试依赖项
dependencies { ... testImplementation 'org.springframework.boot:spring-boot-starter-webflux' }
第8章将介绍Spring WebFlux和响应式应用程序。目前,我们只对使用 WebTestClient 对象来测试目录服务公开的 API 感兴趣。
在上一章中,您看到 Spring Initializr 生成了一个空的 CatalogServiceApplicationTests 类。让我们用集成测试填充它。对于此设置,我们将使用配置为提供完整 Spring 应用程序上下文的@SpringBootTest注释,包括通过随机端口公开其服务的正在运行的服务器(因为哪个端口无关紧要)。
清单 3.17 目录服务的集成测试
package com.polarbookshop.catalogservice; import com.polarbookshop.catalogservice.domain.Book;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.test.web.reactive.server.WebTestClient;import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest( ❶ webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)class CatalogServiceApplicationTests { @Autowired private WebTestClient webTestClient; ❷ @Test void whenPostRequestThenBookCreated() { var expectedBook = new Book("1231231231", "Title", "Author", 9.90); webTestClient .post() ❸ .uri("/books") ❹ .bodyValue(expectedBook) ❺ .exchange() ❻ .expectStatus().isCreated() ❼ .expectBody(Book.class).value(actualBook -> { assertThat(actualBook).isNotNull(); ❽ assertThat(actualBook.isbn()) .isEqualTo(expectedBook.isbn()); ❾ }); }}
❶ 加载完整的 Spring Web 应用程序上下文和侦听随机端口的 Servlet 容器
❷ 用于执行 REST 调用以进行测试的实用程序
❸ 发送 HTTP POST 请求
❹ 将请求发送到“/books”端点
❺ 在请求正文中添加书籍
❻ 发送请求
❼ 验证 HTTP 响应的状态是否为“201 已创建”
❽ 验证 HTTP 响应是否具有非空正文
❾ 验证创建的对象是否符合预期
注意您可能想知道为什么我在清单 3.17 中没有使用基于构造函数的依赖注入,考虑到我之前说过这是推荐的选项。在生产代码中使用基于字段的依赖项注入已被弃用,强烈建议不要这样做,但在测试类中自动连接依赖项仍然是可以接受的。在所有其他情况下,出于我之前解释的原因,我建议坚持使用基于构造函数的依赖注入。有关更多信息,您可以参考官方 Spring 框架文档 (https://spring.io/projects/spring-framework)。
然后,您可以使用以下命令运行测试:
$ ./gradlew test --tests CatalogServiceApplicationTests
根据应用程序的大小,加载具有所有集成测试自动配置的完整应用程序上下文可能太多。Spring Boot 有一个方便的功能(默认启用)来缓存上下文,以便在所有用 @SpringBootTest 和相同配置进行注释的测试类中重复使用它。有时这还不够。
测试执行时间很重要,因此 Spring Boot 完全有能力通过仅加载应用程序所需的部分来运行集成测试。让我们看看它是如何工作的。
3.4.3 用@WebMvcTest测试 REST 控制器某些集成测试可能不需要完全初始化的应用程序上下文。例如,在测试数据持久性层时,无需加载 Web 组件。如果要测试 Web 组件,则无需加载数据持久性层。
Spring Boot 允许您使用仅通过组件(bean )子组初始化的上下文,以特定的应用程序片为目标。Slice测试不使用@SpringBootTest注释,而是一组专用于应用程序特定部分的注释之一:Web MVC,Web Flux,REST客户端,JDBC,JPA,Mongo,Redis,JSON等。这些注释中的每一个都初始化一个应用程序上下文,过滤掉该切片之外的所有 bean。
我们可以通过使用@WebMvcTest注释来测试Spring MVC控制器是否按预期工作,该注释在模拟Web环境(没有正在运行的服务器)中加载Spring应用程序上下文,配置Spring MVC基础架构,并且仅包含MVC层使用的bean,如@RestController和@RestControllerAdvice.将上下文限制为受测特定控制器使用的 bean 也是一个好主意。为此,我们可以在新的 BookControllerMvcTests 类中提供控制器类作为@WebMvcTest注释的参数。
3.18 Web MVC 切片的集成测试
package com.polarbookshop.catalogservice.web; import com.polarbookshop.catalogservice.domain.BookNotFoundException;import com.polarbookshop.catalogservice.domain.BookService;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;import org.springframework.boot.test.mock.mockito.MockBean;import org.springframework.test.web.servlet.MockMvc;import static org.mockito.BDDMockito.given;import static org.springframework.test.web.servlet.request➥.MockMvcRequestBuilders.get;import static org.springframework.test.web.servlet.result➥.MockMvcResultMatchers.status; @WebMvcTest(BookController.class) ❶class BookControllerMvcTests { @Autowired private MockMvc mockMvc; ❷ @MockBean ❸ private BookService bookService; @Test void whenGetBookNotExistingThenShouldReturn404() throws Exception { String isbn = "73737313940"; given(bookService.viewBookDetails(isbn)) .willThrow(BookNotFoundException.class); ❹ mockMvc .perform(get("/books/" isbn)) ❺ .andExpect(status().isNotFound()); ❻ }}
❶ 识别一个专注于Spring MVC组件的测试类,明确针对BookController
❷ 用于在模拟环境中测试 Web 层的实用程序类
❸ 将 BookService 的模拟添加到 Spring 应用程序上下文中
❹ 定义 BookService 模拟 Bean 的预期行为
❺ MockMvc 用于执行 HTTP GET 请求并验证结果。
❻ 预计响应具有“404 未找到”状态
警告如果您使用 IntelliJ IDEA,您可能会收到一条警告,指出 MockMvc 无法自动连线。不用担心。这是一个误报。您可以通过用@SuppressWarnings(“SpringJavaInjectionPointsAutoWiringInspection”)注释字段来消除警告。
然后,您可以使用以下命令运行测试:
$ ./gradlew test --tests BookControllerMvcTests
MockMvc 是一个实用程序类,可让您在不加载 Tomcat 等服务器的情况下测试 Web 端点。这样的测试自然比我们在上一节中编写的测试轻,在上一节中,需要嵌入式服务器来运行测试。
切片测试针对仅包含该应用程序切片请求的配置部分的应用程序上下文运行。在切片之外协作 bean 的情况下,例如 BookService 类,我们使用模拟。
使用 @MockBean 注释创建的模拟与标准模拟(例如,使用 Mockito 创建的模拟)不同,因为类不仅被模拟,而且模拟也包含在应用程序上下文中。每当要求上下文自动连接该 Bean 时,它都会自动注入模拟而不是实际实现。
3.4.4 使用 @JsonTest 测试 JSON 序列化BookController 中的方法返回的 Book 对象被解析为 JSON 对象。默认情况下,Spring Boot 会自动配置 Jackson 库以将 Java 对象解析为 JSON(序列化),反之亦然(反序列化)。
使用 @JsonTest 注释,您可以测试域对象的 JSON 序列化和反序列化。@JsonTest加载一个 Spring 应用程序上下文,并为正在使用的特定库(默认情况下,它是 Jackson)自动配置 JSON 映射器。此外,它还配置了 JacksonTester 实用程序,您可以使用该实用程序来检查 JSON 映射是否按预期工作,依赖于 JsonPath 和 JSONAssert 库。
注意JsonPath 提供了可用于导航 JSON 对象并从中提取数据的表达式。例如,如果我想从 Book 对象的 JSON 表示形式中获取 isbn 字段,我可以使用以下 JsonPath 表达式:@.isbn。有关 JsonPath 库的更多信息,可以参考项目文档:https://github.com/json-path/JsonPath。
下面的清单显示了在新的 BookJsonTests 类中实现的序列化和反序列化测试的示例。
示例 3.19 JSON 切片的集成测试
package com.polarbookshop.catalogservice.web; import com.polarbookshop.catalogservice.domain.Book;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.autoconfigure.json.JsonTest;import org.springframework.boot.test.json.JacksonTester;import static org.assertj.core.api.Assertions.assertThat; @JsonTest ❶class BookJsonTests { @Autowired private JacksonTester<Book> json; ❷ @Test void testSerialize() throws Exception { var book = new Book("1234567890", "Title", "Author", 9.90); var jsonContent = json.write(book); ❸ assertThat(jsonContent).extractingJsonPathStringValue("@.isbn") .isEqualTo(book.isbn()); assertThat(jsonContent).extractingJsonPathStringValue("@.title") .isEqualTo(book.title()); assertThat(jsonContent).extractingJsonPathStringValue("@.author") .isEqualTo(book.author()); assertThat(jsonContent).extractingJsonPathNumberValue("@.price") .isEqualTo(book.price()); } @Test void testDeserialize() throws Exception { var content = """ ❹ { "isbn": "1234567890", "title": "Title", "author": "Author", "price": 9.90 } """; assertThat(json.parse(content)) ❺ .usingRecursiveComparison() .isEqualTo(new Book("1234567890", "Title", "Author", 9.90)); }}
❶ 标识专注于 JSON 序列化的测试类
❷ 用于断言 JSON 序列化和反序列化的实用程序类
❸ 验证从 Java 到 JSON 的解析,使用 JsonPath 格式导航 JSON 对象
❹ 使用 Java 文本块功能定义 JSON 对象
❺ 验证从 JSON 到 Java 的解析
警告如果您使用 IntelliJ IDEA,您可能会收到一条警告,指出 JacksonTester 无法自动连线。不用担心。这是一个误报。您可以通过用@SuppressWarnings(“SpringJavaInjectionPointsAutoWiringInspection”)注释字段来消除警告。
可以使用以下命令运行测试:
$ ./gradlew test --tests BookJsonTests
在本书随附的代码存储库中,您可以找到目录服务项目的单元测试和集成测试的更多示例。
自动执行应用程序的测试后,只要交付新功能或错误修复,就可以自动执行应用程序了。以下部分将介绍持续交付的关键模式:部署管道。
3.5 部署管道:构建和测试持续交付是快速、可靠和安全地交付高质量软件的整体方法,正如我在第 1 章中所解释的。采用这种方法的主要模式是部署管道,它从代码提交到可发布的软件。它应该尽可能地自动化,并且它应该是生产的唯一途径。
根据 Jez Humble 和 Dave Farley 在他们的持续交付书(Addison-Wesley Professional,2010 年)和 Dave Farley 在他的持续交付管道书(2021 年)中描述的概念,我们可以确定部署管道中的几个关键阶段:
本节将指导您引导目录服务的部署管道,并在提交阶段定义第一步。然后,我将向您展示如何使用 GitHub 操作自动执行这些步骤。
3.5.1 了解部署管道的提交阶段持续集成是持续交付的基本实践。成功采用后,开发人员会分小步工作,每天多次提交到主线(主分支)。每次代码提交后,部署管道的提交阶段负责使用新更改生成和测试应用程序。
此阶段应该很快,因为开发人员将等到成功完成后再继续执行下一个任务。这是一个关键点。如果提交阶段失败,负责它的开发人员应立即提供修复或还原其更改,以免使主线处于中断状态并阻止所有其他开发人员集成其代码。
让我们开始为云原生应用程序(如目录服务)设计部署管道。现在,我们将重点介绍提交阶段的前几个步骤(图 3.8)。
图 3.8 部署管道中提交阶段的第一部分
开发人员将新代码推送到主线后,提交阶段首先从存储库中签出源代码。起点始终是提交到主分支。遵循持续集成实践,我们的目标是分小步工作,每天(连续)多次将我们的更改与主分支集成。
接下来,管道可以执行多种类型的静态代码分析。在本例中,我们将重点介绍漏洞扫描。在实际项目中,您可能希望包含其他步骤,例如运行静态代码分析以识别安全问题并检查是否符合特定编码标准(代码检查)。
最后,管道生成应用程序并运行自动测试。在提交阶段,我们包括以技术为中心的测试,不需要部署整个应用程序。这些是单元测试,通常是集成测试。如果集成测试花费的时间太长,最好将它们移动到验收阶段,以保持提交阶段的快速。
我们将在 Polar Bookshop 项目中使用的漏洞扫描程序是 grype (https://github.com/anchore/grype),这是一个功能强大的开源工具,越来越多地用于云原生世界。例如,它是VMware Tanzu Application Platform提供的供应链安全解决方案的一部分。您可以在附录 A 的 A.4 节中找到有关如何安装它的说明。
让我们看看 grype 是如何工作的。打开终端窗口,导航到目录服务项目(目录服务)的根文件夹,然后使用 ./gradlew build 构建应用程序。然后使用 grype 扫描您的 Java 代码库以查找漏洞。该工具将下载已知漏洞列表(漏洞数据库)并针对它们扫描您的项目。扫描在计算机上本地进行,这意味着不会将任何文件或项目发送到外部服务。这使得它非常适合更受监管的环境或气隙场景。
$ grype . ✔ Vulnerability DB [updated] ✔ Indexed . ✔ Cataloged packages [35 packages] ✔ Scanned image [0 vulnerabilities] No vulnerabilities found
注意请记住,安全性不是系统的静态属性。在撰写本文时,目录服务使用的依赖项没有已知的漏洞,但这并不意味着这将永远存在。您应该持续扫描您的项目,并在发布后立即应用安全补丁,以修复新发现的漏洞。
第6章和第7章将介绍提交阶段的其余步骤。现在,让我们看看如何使用 GitHub 操作自动执行部署管道。这是下一节的主题。
3.5.2 使用 GitHub 操作实现提交阶段在自动化部署管道时,有许多解决方案可供选择。在本书中,我将使用 GitHub Actions (https://github.com/features/actions)。它是一个托管解决方案,它提供了我们项目所需的所有功能,并且已经方便地为所有 GitHub 存储库进行了配置。我将在本书的前面介绍这个主题,以便您可以在整本书中处理项目时使用部署管道来验证您的更改。
注意在云原生生态系统中,Tekton (https://tekton.dev) 是定义部署管道和其他软件工作流的常用选择。它是一个开源和 Kubernetes 原生解决方案,托管在持续交付基金会 (https://cd.foundation)。它直接在集群上运行,并允许您将管道和任务声明为 Kubernetes 自定义资源。
GitHub Actions 是 GitHub 内置的平台,可让您直接从代码存储库自动执行软件工作流程。工作流是一个自动化过程。我们将使用工作流对部署管道的提交阶段进行建模。每个工作流侦听触发其执行的特定事件。
工作流应在 GitHub 存储库根目录的 .github/workflows 文件夹中定义,并且应按照 GitHub Actions 提供的 YAML 格式进行描述。在目录服务项目(目录服务)中,在新的 .github/workflows 文件夹下创建一个 commit-stage.yml 文件。每当将新代码推送到存储库时,都会触发此工作流。
3.20 定义工作流名称和触发器
name: Commit Stage ❶on: push ❷
❶ 工作流的名称
❷ 当新代码推送到存储库时,将触发工作流。
每个工作流都组织到并行运行的作业中。现在,我们将定义单个作业来收集前面图 3.8 中描述的步骤。每个作业都在运行器实例上执行,该运行器实例是 GitHub 提供的服务器。您可以在Ubuntu,Windows和macOS之间进行选择。对于目录服务,我们将在 GitHub 提供的 Ubuntu 运行器上运行所有内容。我们还希望具体说明每个作业应具有哪些权限。向 GitHub 提交漏洞报告时,“生成和测试”作业将需要对 Git 存储库的读取访问权限和对安全事件的写入访问权限。
3.21 配置用于构建和测试应用程序的作业
name: Commit Stageon: push jobs: build: ❶ name: Build and Test ❷ runs-on: ubuntu-22.04 ❸ permissions: ❹ contents: read ❺ security-events: write ❻
❶ 作业的唯一标识符
❷ 应运行作业的计算机类型
❸ 一个人性化的工作名称
❹ 授予作业的权限
❺ 签出当前 Git 存储库的权限
❻ 向 GitHub 提交安全事件的权限
每个作业都由按顺序执行的步骤组成。步骤可以是 shell 命令或操作。操作是自定义应用程序,用于以更结构化和可重现的方式执行复杂任务。例如,可以执行将应用程序打包到可执行文件、运行测试、创建容器映像或将映像推送到容器注册表的操作。GitHub 组织提供了一组基本操作,但也有一个市场,其中包含社区开发的更多操作。
警告使用 GitHub 市场中的操作时,请像处理任何其他第三方应用程序一样处理它们,并相应地管理安全风险。与其他第三方选项相比,更喜欢使用 GitHub 或经过验证的组织提供的受信任操作。
让我们通过描述“生成和测试”作业应运行的步骤来完成提交阶段的第一部分。最终结果显示在下面的清单中。
示例 3.22 实现构建和测试应用程序的步骤
name: Commit Stageon: push jobs: build: name: Build and Test runs-on: ubuntu-22.04 permissions: contents: read security-events: write steps: - name: Checkout source code uses: actions/checkout@v3 ❶ - name: Set up JDK uses: actions/setup-java@v3 ❷ with: ❸ distribution: temurin java-version: 17 cache: gradle - name: Code vulnerability scanning uses: anchore/scan-action@v3 ❹ id: scan ❺ with: path: "${{ github.workspace }}" ❻ fail-build: false ❼ severity-cutoff: high ❽ acs-report-enable: true ❾ - name: Upload vulnerability report uses: github/codeql-action/upload-sarif@v2 ❿ if: success() || failure() ⓫ with: sarif_file: ${{ steps.scan.outputs.sarif }} ⓬ - name: Build, unit tests and integration tests run: | chmod x gradlew ⓭ ./gradlew build ⓮
❶ 签出当前的 Git 存储库(目录服务)
❷ 安装和配置 Java 运行时
❸ 定义要使用的版本、分发和缓存类型
❹ 使用 grype 扫描代码库以查找漏洞
❺ 为当前步骤分配标识符,以便可以从后续步骤中引用它
❻ 签出存储库的路径
❼ 在出现安全漏洞时是否使构建失败
❽ 被视为错误的最低安全类别(低、中、高、严重)
❾ 扫描完成后是否启用报告生成
❿ 将安全漏洞报告上传到 GitHub(SARIF 格式)
⓫ 即使上一步失败,也上传报告
⓬ 从上一步的输出中获取报告
⓭ 确保 Gradle 包装器可执行,解决 Windows 不兼容问题
⓮ 运行 Gradle 构建任务,该任务编译代码库并运行单元测试和集成测试
警告上传漏洞报告的操作要求 GitHub 存储库是公开的。仅当您拥有企业订阅时,它才适用于私有存储库。如果您希望将存储库保持私有,则需要跳过“上传漏洞报告”步骤。在整本书中,我将假设我们在 GitHub 上为 Polar Bookshop 项目创建的所有存储库都是公开的。
完成部署管道的初始提交阶段的声明后,提交更改并将其推送到远程 GitHub 存储库。新创建的工作流将立即触发。您可以在 GitHub 存储库页面上的“操作”选项卡上查看执行结果。 图 3.9 显示了运行清单 3.22 中的工作流后的结果示例。通过将提交阶段的结果保持绿色,您可以非常确定您没有破坏任何东西或引入新的回归(假设您有适当的测试)。
图 3.9 提交阶段工作流在将新更改推送到远程存储库后执行。
运行漏洞扫描的步骤基于grype背后的公司Anchore提供的操作。在示例 3.22 中,如果发现严重的漏洞,我们的工作流不会失败。但是,您可以在目录服务 GitHub 存储库的“安全性”部分找到扫描结果。
在撰写本文时,目录服务项目中没有高漏洞或严重漏洞,但将来情况可能会有所不同。如果是这种情况,请考虑对受影响的依赖项使用最新的可用安全修补程序。为了这个例子,因为我不想打扰你的学习之旅,我决定不让构建在发现漏洞时失败。但是,在实际方案中,我建议您根据公司有关供应链安全的策略仔细配置和调整 grype,并在结果不合规时使工作流失败(将 fail-build 属性设置为 true)。有关更多信息,请参阅官方 grype 文档 (https://github.com/anchore/grype)。
在扫描 Java 项目的漏洞后,我们还包含一个步骤来获取 grype 生成的安全报告并将其上传到 GitHub,而与构建是否成功无关。如果发现任何安全漏洞,您可以在 GitHub 存储库页面的“安全”选项卡中查看结果(图 3.10)。
图 3.10 由 grype 生成并发布到 GitHub 的安全漏洞报告
注意在撰写本文时,grype在本书提供的代码库中没有发现任何漏洞。为了向您展示漏洞报告的示例,图 3.10 显示了 grype 扫描不同版本的项目的结果,故意充满了已知的漏洞。
本章就讲到这里。接下来,我将介绍一个主要的云原生开发实践:外部化配置。
总结