Spring6-和-SpringBoot3-现代-API-开发-全-

Spring6 和 SpringBoot3 现代 API 开发(全)

原文:zh.annas-archive.org/md5/bd1a05f8a0d5a538ce25512abec23206

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

这本书是深入使用 Spring 6 和 Spring Boot 3 进行 Web 开发的指南。Spring 是一个强大且广泛使用的框架,用于在 Java 中构建可扩展且可靠的 Web 应用程序。Spring Boot 是框架的一个流行扩展,简化了基于 Spring 的应用程序的设置和配置。本书将教你如何使用这些技术构建现代且健壮的 Web API 和服务。

本书涵盖了 API 开发所必需的广泛主题,例如 REST/GraphQL/gRPC 的基础、Spring 概念以及 API 规范和实现。此外,本书还涵盖了异步 API 设计、安全性、设计用户界面、测试 API 和 Web 服务的部署等主题。本书提供了一个高度情境化的真实世界示例应用程序,读者可以用它作为参考来构建针对真实世界应用程序的不同类型的 API,包括持久化数据库层。本书采用的方法是指导读者通过 API 开发的整个开发周期,包括设计规范、实现、测试和部署。

到这本书的结尾,你将学会如何使用 Spring 6 和 Spring Boot 3 设计、开发、测试和部署可扩展且易于维护的现代 API,同时了解确保应用程序安全性和可靠性的最佳实践,以及提高应用程序功能性的实用想法。

本书面向对象

本书面向初学者 Java 程序员、近期计算机科学毕业生、编码训练营校友以及新接触创建真实世界 Web API 和服务的专业人士。对于希望转向 Web 开发并寻求 Web 服务开发全面介绍的 Java 开发者来说,本书也是一个宝贵的资源。理想的读者应具备 Java 基础编程结构、数据结构和算法的知识,但缺乏作为 Web 开发者开始工作的实际 Web 开发经验。

本书涵盖内容

第一章RESTful Web 服务基础,将引导你了解 RESTful API 的基础知识,或简称为 REST API,以及它们的设计范式。这些基础知识将为你开发 RESTful Web 服务提供一个坚实的基础。你还将了解设计 API 的最佳实践。本章还将介绍本书将使用的示例电子商务应用程序,在学习 API 开发不同方面时将用到它。

第二章Spring 概念和 REST API,探讨了 Spring 基本原理和特性,这些是使用 Spring 框架实现 REST、gRPC 和 GraphQL API 所必需的。这将为你开发示例电子商务应用程序提供所需的技术视角。

第三章, API 规范和实现,利用 OpenAPI 和 Spring 实现 REST API。我们选择了先设计后实现的设计方法。您将使用 OpenAPI 规范首先设计 API,然后实现它们。您还将学习如何处理请求服务过程中发生的错误。在这里,示例电子商务应用的 API 将被设计和实现以供参考。

第四章, 为 API 编写业务逻辑,帮助您在 H2 数据库中实现 API 的代码,以及数据持久化。您将编写服务和存储库以进行实现。您还将向 API 响应添加超媒体和 ETag 头,以实现最佳性能和缓存。

第五章, 异步 API 设计,涵盖了异步或响应式 API 设计,其中调用将是异步和非阻塞的。我们将使用基于 Project Reactor(projectreactor.io)的 Spring WebFlux 来开发这些 API。首先,我们将了解响应式编程的基础知识,然后将现有的电子商务 REST API(上一章的代码)迁移到异步(响应式)API,通过关联和比较现有的(命令式)编程方式和响应式编程方式来简化事情。

第六章, 使用授权和身份验证保护 REST 端点,解释了您如何使用 Spring Security 来保护这些 REST 端点。您将为 REST 端点实现基于令牌的认证和授权。成功的认证将提供两种类型的令牌——AdminUser等。这些角色将用作授权,以确保只有用户拥有特定角色时才能访问 REST 端点。我们还将简要讨论跨站请求伪造CSRF)和跨源资源共享CORS)。

第七章, 设计用户界面,总结了在线购物应用不同层之间的端到端开发和通信。这个 UI 应用将包括登录产品列表产品详情购物车订单列表。到本章结束时,您将了解使用 React 进行 SPA 和 UI 组件开发,以及使用浏览器的内置 Fetch API 消费 REST API。

第八章, 测试 API,介绍了 API 的手动和自动化测试。您将了解单元和集成测试自动化。在本章学习自动化之后,您将能够使这两种测试成为构建的组成部分。您还将设置 Java 代码覆盖率工具来计算不同的代码覆盖率指标。

第九章Web 服务的部署,解释了容器化、Docker 和 Kubernetes 的基础知识。然后,你将使用这个概念使用 Docker 容器化示例电子商务应用程序。然后,这个容器将在 Kubernetes 集群中部署。你将使用 minikube 进行 Kubernetes,这使得学习和基于 Kubernetes 的开发更加容易。

第十章开始使用 gRPC,介绍了 gRPC 的基础知识。

第十一章gRPC API 开发与测试,实现了基于 gRPC 的 API。你将学习如何编写 gRPC 服务器和客户端,以及编写基于 gRPC 的 API。在第章的后半部分,你将介绍微服务以及它们如何帮助你设计现代、可扩展的架构。在这里,你将进行两个服务的实现——一个 gRPC 服务器和一个 gRPC 客户端。

第十二章向服务添加日志和跟踪,探讨了名为 Elasticsearch、Logstash、KibanaELK)堆栈和 Zipkin 的日志和监控工具。然后,这些工具将被用于实现 API 调用的请求/响应的分布式日志和跟踪。你将学习如何发布和分析不同请求的日志以及与响应相关的日志。你还将使用 Zipkin 来监控 API 调用的性能。

第十三章开始使用 GraphQL,讨论了 GraphQL 的基础知识——模式定义语言SDL)、查询、突变和订阅。这些知识将有助于你在下一章中实现基于 GraphQL 的 API。在本章的整个过程中,你将了解 GraphQL 模式的基础知识以及解决 N+1 问题。

第十四章GraphQL API 开发与测试,解释了基于 GraphQL 的 API 开发及其测试。在本章中,你将为示例应用程序实现基于 GraphQL 的 API。将基于设计优先的方法开发 GraphQL 服务器实现。

为了充分利用本书

确保你拥有以下硬件和软件:

本书涵盖的软件/硬件 操作系统要求
Java 17 Windows、macOS 或 Linux(任何)
任何 Java IDE,如 Netbeans、IntelliJ 或 Eclipse 连接到互联网以从 GitHub 克隆代码并下载依赖项和库
Docker
Kubernetes(minikube)
cURL 或任何 API 客户端,如 Insomnia
Node 18.x
VS Code
ELK 堆栈和 Zipkin

每章都将包含安装所需工具的特殊说明(如果适用)。

如果您使用的是本书的数字版,我们建议您亲自输入代码或从书籍的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件,网址为github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的书籍和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“如果我们使用模型中的Link,则生成的模型将使用映射的org.springframework.hateoas.Link类,而不是 YAML 文件中定义的模型。”

代码块设置为以下格式:

 const Footer = () => {   return (
     <div>
       <footer
         className="text-center p-2 border-t-2 bggray-
           200 border-gray-300 text-sm">
         No &copy; by Ecommerce App.{" "}
         <a href=https://github.com/PacktPublishing/Modern- 
           API-Development-with-Spring-and-Spring-Boot>
           Modern API development with Spring and Spring Boot
         </a>
       </footer>
     </div>
   );
 };
 export default Footer;

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

<Error>  <errorCode>PACKT-0001</errorCode>
  <message>The system is unable to complete the request. 
      Contact system support.</message>
  <status>500</status>
  <url>http://localhost:8080/api/v1/carts/1</url>
  <reqMethod>GET</reqMethod>
</Error>

任何命令行输入或输出都按以下方式编写:

$ curl --request POST 'http://localhost:8080/api/v1/carts/1/items' \ --header 'Content-Type: application/json' \ 
 --header 'Accept: application/json' \ 
 --data-raw '{ 
 "id": "1", 
 "quantity": 1, 
 "unitPrice": 2.5 
 }'
[]

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“从管理面板中选择系统信息。”

小贴士或重要注意事项

看起来是这样的。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过 mailto:customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书籍标题。

勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 mailto:copyright@packt.com 与我们联系,并在邮件主题中提及书籍标题。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享您的想法

一旦你阅读了《使用 Spring 6 和 Spring Boot 3 的现代 API 开发》,我们很乐意听听你的想法!请点击此处直接进入亚马逊评论页面并分享你的反馈。

你的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载这本书的免费 PDF 副本。

感谢您购买这本书!

你喜欢在路上阅读,但无法携带你的印刷书籍到处走吗?你的电子书购买是否与你的选择设备不兼容?

别担心,现在,每购买一本 Packt 书籍,你都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何时间、任何设备上阅读。从你最喜欢的技术书籍中搜索、复制和粘贴代码直接到你的应用程序中。

优惠远不止这些,你还可以获得独家折扣、时事通讯和每日免费内容的每日邮箱访问权限。

按照以下简单步骤获取福利:

  1. 扫描二维码或访问以下链接。

二维码

packt.link/free-ebook/9781804613276

  1. 提交你的购买证明

  2. 就这些了!我们将直接将你的免费 PDF 和其他福利发送到你的邮箱。

第一部分 – RESTful Web 服务

在这部分,你将开发和测试由 HATEOAS 和 ETags 支持的、适用于生产的不断发展的基于 REST 的 API。API 规范将使用 OpenAPI 规范(Swagger)编写。你将学习使用 Spring WebFlux 进行反应式 API 开发的基础知识。到这部分结束时,你将了解 REST API 的基础知识、最佳实践以及如何编写不断发展的 API。完成这部分后,你将能够开发同步和异步(反应式)的非阻塞 API。

本部分包含以下章节:

  • 第一章RESTful Web 服务基础

  • 第二章Spring 概念和 REST API

  • 第三章API 规范和实现

  • 第四章为 API 编写业务逻辑

  • 第五章异步 API 设计

第一章:RESTful Web 服务基础

在本章中,我们将探讨 RESTful API(或简称为 REST API)的基本原理及其设计范式。在继续探讨超媒体作为应用程序状态引擎HATEOAS)之前,我们将简要回顾 REST 的历史,了解资源是如何形成的,并理解方法和状态码。这些基础知识应该为您提供一个坚实的基础,以使您能够开发 RESTful Web 服务。您还将学习设计应用程序编程****接口APIs)的最佳实践。

本章还将介绍一个示例电子商务应用程序,该应用程序将在本书中作为您学习 API 开发不同方面的示例使用。

在本章中,我们将涵盖以下主题:

  • 介绍 REST API

  • 处理资源和统一资源标识符URIs

  • 探索超文本传输协议HTTP)方法和状态码

  • HATEOAS 是什么?

  • 设计 REST API 的最佳实践

  • 电子商务应用程序概述(我们的示例应用程序)

技术要求

本章不需要任何特定的软件。然而,了解 HTTP 是必要的。

介绍 REST API

API 是代码与代码之间通信的方式。您可能已经为您的程序编写和消费过 API;例如,Java 通过不同模块中的类提供 API,例如集合、输入/输出和流。

Java 的 SDK API 允许程序的一部分与程序的另一部分进行通信。您可以编写一个函数,然后通过公共访问修饰符公开它,以便其他类可以使用它。该函数签名是该类的 API。然而,使用这些类或库公开的 API 仅允许单个应用程序或单个服务内部的内部通信。那么,当两个或多个应用程序(或服务)想要相互通信时,或者换句话说,您想要集成两个或多个服务时,会发生什么呢?这就是系统级 API 帮助我们的时候。

从历史上看,有几种不同的方法可以将一个应用程序与另一个应用程序集成——RPC、基于简单对象访问协议SOAP)的服务等等。应用程序的集成已成为软件架构的一个基本组成部分,尤其是在云计算和移动电话的繁荣之后。现在您有了社交登录,如 Facebook、Google 和 GitHub,这意味着您可以在不编写独立的登录模块的情况下开发您的应用程序,并绕过诸如安全存储密码等问题。

这些社交登录提供了使用 REST 和 GraphQL 的 API。目前,REST 是最受欢迎的,并且已经成为编写集成和 Web 应用消费 API 的标准。我们还将详细讨论 GraphQL,这将在本书的最后一章(第十三章,开始使用 GraphQL)和第十四章,GraphQL API 开发和测试)中进行讨论。

REST代表表征状态转移,这是一种软件架构风格。遵循 REST 风格的 Web 服务被称为 RESTful Web 服务。在接下来的章节中,我们将简要回顾 REST 的历史,以了解其基本原理。

REST 的历史

在采用 REST 之前,当互联网刚开始广为人知,Yahoo 和 Hotmail 是流行的邮件和社交消息应用时,没有一种标准的软件架构能够提供一种统一的方式来与 Web 应用集成。人们使用基于 SOAP 的 Web 服务,讽刺的是,它们并不简单。

随后出现了曙光。Roy Fielding 在他的博士研究中,《网络软件架构的风格及其设计》(www.ics.uci.edu/~fielding/pubs/dissertation/top.htm),在 2000 年提出了 REST。REST 的架构风格允许任何服务器通过网络与任何其他服务器进行通信。它简化了通信,使集成更容易。REST 被设计在 HTTP 之上工作,这使得它可以在整个 Web 和内部网络上使用。

eBay 是第一个利用基于 REST 的 API 的公司。它在 2000 年 11 月与选定的合作伙伴推出了 REST API。后来,Amazon、Delicious(一个网站书签应用)和 Flickr(照片分享应用)开始提供基于 REST 的 API。然后,亚马逊网络服务AWS)利用 Web 2.0(随着 REST 的发明)在 2006 年为开发者提供了 AWS 云消费的 REST API。

之后,Facebook、Twitter、Google 和其他公司开始使用它。如今(2023 年),你几乎找不到没有开发 REST API 的 Web 应用。然而,基于 GraphQL 的移动应用 API 在受欢迎程度上正在接近。

REST 基本原理

REST 在 HTTP 协议之上工作。每个 URI 都作为一个 API 资源。因此,我们应该使用名词作为端点而不是动词。RPC 风格的端点使用动词,例如,/api/v1/getPersons。相比之下,在 REST 中,这个端点可以简单地写成 /api/v1/persons。你可能想知道,那么我们如何区分在 REST 资源上执行的不同操作。这就是 HTTP 方法帮我们的地方。我们可以让我们的 HTTP 方法充当动词,例如,GETDELETEPOST(用于创建)、PUT(用于修改)和 PATCH(用于部分更新)。我们将在稍后详细讨论这个问题。现在,getPerson RPC 风格的端点在 REST 中被翻译成 GET /api/v1/persons

注意

REST 端点是表示 REST 资源的唯一的 URI。例如,https://demo.app/api/v1/persons 是一个 REST 端点。此外,/api/v1/persons 是端点路径,persons 是 REST 资源。

这里存在客户端和服务器之间的通信。因此,REST 基于 客户端-服务器 概念。客户端调用 REST API,服务器响应。REST 允许客户端(即程序、Web 服务或 UI 应用程序)通过 HTTP 请求和响应与远程(或本地)运行的服务器(或 Web 服务)进行通信。客户端将 API 命令封装在 HTTP 请求中发送到 Web 服务。这个 HTTP 请求可能包含以查询参数、头或请求体形式的数据负载(或输入)。被调用的 Web 服务以成功/失败指示器和封装在 HTTP 响应中的响应数据作为响应。HTTP 状态码通常表示状态,响应体包含响应数据。例如,HTTP 状态码 200 OK 通常表示成功。

从 REST 的角度来看,HTTP 请求是自描述的,并且有足够的信息供服务器处理。因此,REST 调用是无状态的。状态要么在客户端管理,要么在服务器端管理。REST API 不维护其状态。它只从服务器传输状态到客户端或反之亦然。这就是为什么它被称为 REpresentational State Transfer,或简称 REST。

REST 还使用了 HTTP 缓存控制,这使得 REST API 可缓存。因此,客户端也可以缓存表示(即 HTTP 响应),因为每个表示都是自描述的。

REST 使用三个关键组件进行操作:

  • 资源和统一资源标识符(URIs)

  • HTTP 方法

  • HATEOAS

一个简单的 REST 调用以纯文本形式看起来如下所示:

GET /licenses HTTP/2Host: api.github.com

这里,/licenses 路径表示许可证资源。GET 是一个 HTTP 方法。第一行末尾的 2 表示 HTTP 协议版本。第二行共享了要调用的主机。

GitHub 以 JSON 对象的形式响应。状态是 200 OK,JSON 对象被封装在响应体中,如下所示:

HTTP/2 200 OKdate: Mon, 10 Jul 2023 17:44:04 GMT
content-type: application/json; charset=utf-8
server: GitHub.com
status: 200 OK
cache-control: public, max-age=60, s-maxage=60
vary: Accept, Accept-Encoding, Accept, X-Requested-With,
      Accept-Encoding  etag:W/"3cbb5a2e38ac6fc92b3d798667e
          828c7e3584af278aa314f6eb1857bbf2593ba"
… <bunch of other headers>
Accept-Ranges: bytes
Content-Length: 2037
X-GitHub-Request-Id: 1C03:5C22:640347:81F9C5:5F70D372
[
  {
    "key": "agpl-3.0",
    "name": "GNU Affero General Public License v3.0",
    "spdx_id": "AGPL-3.0",
    "url": "https://api.github.com/licenses/agpl-3.0",
    "node_id": "MDc6TGljZW5zZTE="
  },
  {
    "key": "apache-2.0",
    "name": "Apache License 2.0",
    "spdx_id": "Apache-2.0",
    "url": "https://api.github.com/licenses/apache-2.0",
    "node_id": "MDc6TGljZW5zZTI="
  },
  …
]

如果你注意这个响应的第三行,它告诉你内容类型的值。对于请求和响应,将 JSON 作为内容类型是一个好习惯。

现在我们已经熟悉了 REST 的基础知识,我们将更深入地探讨 REST 的第一个概念,资源和 URI,并了解它们是什么以及它们通常是如何使用的。

处理资源和 URI

万维网WWW)上的每个文档都作为 HTTP 术语下的资源表示。这个资源被表示为一个 URI,它是一个代表服务器上唯一资源的端点。

罗伊·菲尔丁在他的博士研究中指出,URI 有多个名称——WWW 地址、通用文档标识符UDI)、URI、统一资源定位符URL)和统一资源名称URN)。

那么,什么是 URI?URI 是一个字符串(即字符序列),通过其位置、名称或两者(在 WWW 世界中)来标识资源。URI 有两种类型:URL 和 URN。

URL 被广泛使用,甚至非开发用户也知道。URL 不仅限于 HTTP,还用于许多其他协议,如 FTP、JDBC 和 MAILTO。URL 是一个标识符,用于标识资源的网络位置。我们将在后面的章节中详细介绍。

URI 语法

URI 语法如下:

 scheme:[//authority]path[?query][#fragment]

根据语法,以下是一个 URI 组件的列表:

  • :)。scheme以字母开头,后跟任何组合的数字、字母、点(.)、连字符(-)或加号(+)。

方案示例包括 HTTP、HTTPS、MAILTO、FILE 和 FTP。URI 方案必须向互联网数字分配机构IANA)注册。

  • //)。它由以下可选子字段组成:

    • :)。
  • /)。在上面的 GitHub REST API 示例中,/licenses是路径。

  • ?)。查询组件包含非分层数据的查询字符串。查询组件中的每个参数由一个与号(&)分隔,参数值使用等号(=)运算符分配。

  • #)。片段组件包含一个片段标识符,为次要资源提供方向。

下面的列表包含了一些 URI 的示例:

  • www.packt.com: 这不包含方案。它只包含域名。也没有端口,这意味着它指向默认端口。

  • index.html: 这不包含方案和授权信息。它只包含路径。

  • www.packt.com/index.html: 这包含方案、授权信息和路径。

这里有一些不同方案 URI 的示例:

注意

从 REST 的角度来看,URI 的路径组件非常重要,因为它代表了资源路径,你的 API 端点路径就是基于它形成的。例如,看看以下内容:

GET https://www.domain.com/api/v1/order/1

这里,/api/v1/order/1代表路径,GET代表 HTTP 方法。

什么是 URL?

如果你仔细观察,前面提到的许多 URI 示例也可以称为 URL。URI 是一个标识符;另一方面,URL 不仅是一个标识符,它还告诉你如何到达它。

请求评论(RFC)

根据 RFC-3986 关于 URI 的说明(datatracker.ietf.org/doc/html/rfc3986),术语 URL 指的是 URI 的子集,除了标识资源外,还提供了一种通过描述其主要访问机制(例如,其网络位置)来定位资源的方法。

URL 代表资源的完整网络地址,包括协议名称(方案)、主机名端口(如果 HTTP 端口不是80;对于 HTTPS,默认端口是443)、授权组件的一部分、路径以及可选的查询和片段子组件。

什么是 URN?

URN 并不常用。它们也是一种以方案urn开头的 URI。以下 URN 示例直接取自 RFC-3986 关于 URI 的文档(www.ietf.org/rfc/rfc3986.txt):

 urn:oasis:names:specification:docbook:dtd:xml:4.1.2

此示例遵循"urn:" <NID> ":" <NSS>语法,其中<NID>是命名空间标识符,<NSS>是命名空间特定字符串。我们不会在我们的 REST 实现中使用 URN。然而,你可以在 RFC-2141 中了解更多关于它们的信息(tools.ietf.org/html/rfc2141)。

注意

根据 RFC-3986 关于 URI 的说明(datatracker.ietf.org/doc/html/rfc3986),术语 URN 在历史上被用来指代“urn”方案 RFC-2141 下的 URI,这些 URI 即使在资源不再存在或不可用的情况下也必须保持全球唯一性和持久性,以及任何具有名称属性的其他 URI。

现在你已经了解了 URI 和 URN 之间的区别以及它们如何构成 URI,让我们学习 REST 的第二个概念:HTTP 方法和状态码。

探索 HTTP 方法和状态码

HTTP 提供了各种 HTTP 方法。然而,你主要将只使用其中的五个。首先,你希望将创建读取更新删除CRUD)操作与 HTTP 方法相关联:

  • POST: 创建或搜索

  • GET: 读取

  • PUT: 更新

  • DELETE: 删除

  • PATCH: 部分更新

一些组织也提供了HEAD方法,用于仅想从 REST 端点检索头部响应的场景。你可以使用HEAD操作对任何 GitHub API 进行操作以仅检索头部;例如,curl --head https://api.github.com/users

注意

REST 没有规定应该使用哪种方法来进行哪种操作。然而,广泛使用的行业指南和实践建议遵循某些规则。

让我们详细讨论每种方法。

POST

HTTP POST 方法通常与创建资源操作相关联。然而,在某些情况下,你可能希望使用 POST 方法进行读取操作。但是,这应该在经过深思熟虑的过程后实施。一个这样的例外是搜索操作,其中过滤条件有太多参数,可能会超过 GET 调用的长度限制。

GET 查询字符串的长度限制为 256 个字符。此外,HTTP GET 方法在提交名称和值对时,其长度限制为最多 2,048 个字符减去实际路径中的字符数。另一方面,POST 方法在提交名称和值对时不受 URL 大小的限制。

如果提交的输入参数包含任何私有或安全信息,你也可以使用 HTTPS 与 POST 方法进行读取调用。

对于成功的创建操作,你可以响应 201 Created 状态码,而对于成功的搜索或读取操作,你应该使用 200 OK204 No Content 状态码,尽管调用是使用 HTTP POST 方法进行的。

对于失败的操作,REST 响应可能根据错误类型具有不同的错误状态码,我们将在本节稍后讨论。

GET

HTTP GET 方法通常与读取资源操作相关联。同样,你可能已经观察到 GitHub GET /licenses 调用,该调用返回 GitHub 系统中可用的许可证。此外,如果响应包含数据,成功的 GET 操作应与 200 OK 状态码相关联;如果响应不包含数据,则应与 204 No Content 状态码相关联。

PUT

HTTP PUT 方法通常与更新资源操作相关联。此外,如果响应包含数据,成功的更新操作应与 200 OK 状态码相关联;如果响应不包含数据,则应与 204 No Content 状态码相关联。一些开发者使用 PUT HTTP 方法来替换现有资源。例如,GitHub API v3 使用 PUT 来替换现有资源。

DELETE

HTTP DELETE 方法通常与资源删除操作相关联。GitHub 不在 licenses 资源上提供 DELETE 操作。然而,如果你假设它存在,它肯定会看起来非常类似于 DELETE / licenses/agpl-3.0。一个成功的 DELETE 调用应该删除与 agpl-3.0 键关联的资源。此外,成功的 DELETE 操作应与 204 No Content 状态码相关联。

PATCH

HTTP PATCH 方法是您想要与部分更新资源操作相关联的方法。此外,成功的PATCH操作应与200 OK状态代码相关联。与其它 HTTP 操作相比,PATCH相对较新。实际上,几年前,由于旧的 Java HTTP 库,Spring 在 REST 实现中并没有对这种方法提供最先进的支持。然而,目前,Spring 在 REST 实现中为PATCH方法提供了内置支持。

HTTP 状态码

HTTP 状态代码有五个类别,如下所示:

  • 信息性响应 (100199)

  • 成功响应 (200299)

  • 重定向 (300399)

  • 客户端错误 (400499)

  • 服务器错误 (500599)

您可以在 MDN Web Docs(developer.mozilla.org/en-US/docs/Web/HTTP/Status)或 RFC-7231(tools.ietf.org/html/rfc7231)中查看状态代码的完整列表。然而,您可以在以下表格中找到最常用的 REST 响应状态代码:

HTTP 状态码 描述
200 OK 对于除已创建之外的成功请求。
201 已创建 用于成功创建请求。
202 已接受 请求已接收但尚未处理。当服务器接受请求但不能立即发送响应时使用,例如在批量处理中。
204 无内容 用于响应中不包含数据的成功操作。
304 未修改 用于缓存。服务器向客户端响应资源未修改;因此,可以使用相同的缓存资源。
400 错误请求 当输入参数不正确或缺失或请求本身不完整时操作失败的错误。
401 未授权 由于未认证请求而失败的错误。规范称其为未授权,但从语义上讲,它表示未认证。
403 禁止 当调用者未授权执行操作时操作失败的错误。
404 未找到 当请求的资源不存在时操作失败的错误。
405 方法不允许 当请求的资源不允许使用该方法时操作失败的错误。
409 冲突 当尝试重复创建操作时,操作失败的错误。
429 请求过多 当用户在给定时间内发送过多请求时(速率限制)操作失败的错误。
500 内部服务器错误 由于服务器错误导致的操作失败。这是一个通用错误。
502 错误网关 当上游服务器调用失败时操作失败的错误,例如,当应用程序调用第三方支付服务但调用失败时。
503 Service Unavailable 这是在服务器发生意外情况时失败的操作,例如过载或服务失败。

我们已经讨论了 REST 的关键组件,例如以 URI 形式表示的端点、方法和状态码。让我们探索 HATEOAS,这是 REST 概念的核心,它将 REST 与 RPC 风格区分开来。

什么是 HATEOAS?

使用 HATEOAS,RESTful 网络服务通过超媒体动态提供信息。超媒体是您从 REST 调用响应中接收到的内容的一部分。此超媒体内容包含指向不同类型媒体的链接,例如文本、图像和视频。

超媒体链接可以包含在 HTTP 头或响应体中。如果您查看 GitHub API,您会发现 GitHub API 在头和响应体中都提供了超媒体链接。GitHub 使用名为Link的头部来包含分页相关的链接。此外,如果您查看 GitHub API 的响应,您还会找到其他与资源相关的链接,其键具有url后缀。让我们看一个例子。我们将调用GET /users资源并分析响应:

$ curl -v https://api.github.com/users

此命令执行会输出类似于以下内容:

*   Trying 20.207.73.85:443...* Connected to api.github.com (20.207.73.85) port 443 (#0)… < more info>
…
> GET /users HTTP/2
> Host: api.github.com
> user-agent: curl/7.78.0
… < more info >
< HTTP/2 200
< server: GitHub.com
< date: Sun, 28 Aug 2022 04:31:50 GMT status: 200 OK
< content-type: application/json; charset=utf-8
…
< link: <https://api.github.com/users?since=46>; rel="next", <https://api.github.com/users{?since}>; rel="first"
…
[
  {
    "login": "mojombo",
    "id": 1,
    "node_id": "MDQ6VXNlcjE=",
    "avatar_url":
        "https://avatars.githubusercontent.com/u/1?v=4",
    "gravatar_id": "",
    "url": "https://api.github.com/users/mojombo",
    "html_url": "https://github.com/mojombo",
    "followers_url":
        "https://api.github.com/users/mojombo/followers",
    "following_url":
"https://api.github.com/users
/mojombo/following{/other_user}",
    "gists_url": "https://api.github.com/users/mojombo/gists{/gist_        id}",
    "starred_url":
"https://api.github.com/users/mojombo/starred{/owner}{/repo}",
    "subscriptions_url":
        "https://api.github.com/users/mojombo/subscriptions",
    "organizations_url":
        "https://api.github.com/users/mojombo/orgs",
    "repos_url":
        "https://api.github.com/users/mojombo/repos",
    "events_url":    "https://api.github.com/users/mojombo/events{/        privacy}",
    "received_events_url":
       "https://api.github.com/users/mojombo/received_events",
    "type": "User",
    "site_admin": false
  },
  …
  … < more data >
]

在前面的输出中,您会发现Link头包含分页信息。next页和first页的链接作为响应的一部分给出。此外,您还可以在响应体中找到许多 URL,例如avatar_urlfollowers_url,它们提供了指向其他超媒体的链接。

REST 客户端应该具备对超媒体的一般理解,这样他们就可以与 RESTful 网络服务交互,而无需了解如何与服务器交互的任何特定知识。您只需调用任何静态 REST API 端点,就会收到作为响应一部分的动态链接,以便进一步交互。REST 允许客户端通过遍历链接动态导航到适当的资源。它赋予机器能力,因为 REST 客户端可以以类似于人类查看网页并点击任何链接的方式导航到不同的资源。简单来说,REST 客户端使用这些链接进行导航。

HATEOAS 是 REST 的一个非常重要的概念。它是将 REST 与 RPC 区分开来的概念之一。甚至 Roy Fielding 对某些 REST API 实现如此关注,以至于他在 2008 年在他的网站上发布了以下博客:REST API 必须是 超文本驱动的 (roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven)。

您一定想知道超文本和超媒体之间的区别。本质上,超媒体只是超文本的扩展版本。

超媒体和超文本之间的区别是什么?

正如 Roy Fielding 所说:“当我提到超文本时,我指的是信息与控制的同步呈现,使得信息成为用户(或自动机)获得选择和选择行动的便利性。超媒体只是对文本含义的扩展,包括在媒体流中的时间锚点;大多数研究人员已经放弃了这种区别。超文本不需要在浏览器上使用 HTML。当机器理解数据格式和关系类型时,它们可以跟随链接。”

既然你已经熟悉了 REST,那么让我们在下一节中探讨 REST 的最佳实践。

设计 REST API 的最佳实践

讨论实施 API 的最佳实践还为时尚早。API 首先被设计,然后才被实施。因此,你将在下一节中找到与设计相关的最佳实践。你也会找到在 REST API 实施过程中的前进最佳实践。

在端点路径中命名资源时使用名词而不是动词

我们之前讨论了 HTTP 方法。HTTP 方法使用动词。因此,自己使用动词将是多余的,并且会使你的调用看起来像 RPC 端点,例如,GET /getlicenses。在 REST 中,我们应该始终使用资源名称,因为根据 REST,你传递的是状态而不是指令。例如,让我们再次看看 GitHub 许可证 API,它检索许可证。它是GET /licenses。这是完美的。假设如果你为这个端点使用动词,那么它将是GET /getlicenses。它仍然可以工作,但从语义上讲,它不遵循 REST,因为它传达的是处理指令而不是状态传输。因此,只使用资源名称。

然而,GitHub 的公共 API 只提供了对licenses资源的read操作,在所有 CRUD 操作中。如果我们需要设计其余的操作,它们的路径应该看起来像以下这样:

  • POST /licenses:这是用于创建一个新的许可证。

  • PATCH /licenses/{license_key}:这是用于部分更新。在这里,路径有一个参数(即标识符),这使得路径是动态的。在这里,许可证密钥是许可证集合中的一个唯一值,并被用作标识符。每个许可证都将有一个唯一的密钥。这个调用应该在给定的许可证中进行更新。请记住,GitHub 使用PUT来替换资源。

  • DELETE /licenses/{license_key}:这是用于检索许可证信息。你可以尝试使用GET /licenses调用响应中收到的任何许可证。一个例子是GET /licenses/agpl-3.0

你可以看到在资源路径中包含名词以及 HTTP 方法是如何消除任何歧义的。

在端点路径中使用复数形式来命名集合资源

如果您观察 GitHub 许可证 API,您可能会发现资源名称以复数形式给出。如果资源表示一个集合,使用复数形式是一个好习惯。因此,我们可以使用/licenses而不是/license。一个GET调用返回许可证集合。GitHub 不允许在受许可的资源上执行创建、更新或删除公共操作。假设它允许这样做,那么一个POST调用将在现有的许可证集合中创建一个新的许可证。同样,对于DELETEPATCH调用,使用许可证密钥来识别执行删除和轻微更新操作的具体许可证。

使用超媒体(HATEOAS)

超媒体(即,指向其他资源的链接)使 REST 客户端的工作变得更简单。如果您在响应中提供显式的 URL 链接,有两个优点。首先,REST 客户端不需要自己构建 REST URL。其次,端点路径的任何升级都将自动处理,这使得客户端和开发者的升级更容易。

为您的 API 进行版本控制

API 的版本控制对于未来的升级至关重要。随着时间的推移,API 会不断变化,您可能会有仍在使用旧版本的客户。因此,您需要支持 API 的多个版本。

您可以使用不同的方式对 API 进行版本控制:

  • 使用头部:GitHub API 使用这种方法。您可以添加一个Accept头部,告诉您应该使用哪个 API 版本来处理请求;例如,考虑以下内容:

    Accept: application/vnd.github.v3+json
    

这种方法让您有设置默认版本的优点。如果没有Accept头部,它应该指向默认版本。然而,如果使用版本控制头部的 REST 客户端在 API 最近升级后没有更改,它可能会破坏功能。因此,建议您使用版本控制头部。

  • 使用端点路径:在这种方法中,您在端点路径本身中添加一个版本;例如,https://demo.app/api/v1/persons。在这里,v1表示正在将版本1添加到路径本身。

您无法直接设置默认版本控制。但是,您可以通过使用其他方法,例如请求转发,来克服这一限制。在这种方法中,客户端始终使用 API 的预期版本。

根据您的偏好和观点,您可以选择上述任何一种方法进行版本控制。然而,重要的是您应该始终使用版本控制。

资源嵌套

考虑这个非常有趣的问题:您将如何构建嵌套或具有特定关系的资源的端点?让我们从电子商务的角度来看一些客户资源的示例:

  • GET /customers/1/addresses:这返回客户1的地址集合

  • GET /customers/1/addresses/2:这返回客户1的第二个地址

  • POST /customers/1/addresses:这为客户1的地址添加一个新的地址

  • PUT /customers/1/addresses/2: 这将替换客户 1 的第二个地址

  • PATCH /customers/1/addresses/2: 这将部分更新客户 1 的第二个地址

  • DELETE /customers/1/addresses/2: 这将删除客户 1 的第二个地址

到目前为止一切顺利。现在,我们可以有一个完全独立的地址资源端点(GET /addresses/2)吗?这很有意义,如果您有需要这种关系的场景,您就可以这样做;例如,订单和支付。您可能会更喜欢一个单独的 /payments/1 端点,而不是 /orders/1/payments/1。在微服务世界中,这更有意义;例如,您将有两个独立的 RESTful 网络服务,分别用于订单和支付。

现在,如果您将这种方法与超媒体结合使用,会使事情变得更容易。当您向客户 1 发送 REST API 请求时,它将提供客户 1 的数据和地址链接作为超媒体(即链接)。对订单也是如此。对于订单,支付链接将以超媒体的形式提供。

然而,在某些情况下,您可能希望在一个请求中有一个完整的响应,而不是使用超媒体提供的 URL 来获取相关资源。这减少了您的网络请求。但是,没有固定的规则。对于标志操作,使用嵌套端点方法是有意义的;例如,GitHub API 中的 PUT /gist/2/star(添加星标)和 DELETE /gist/2/star(撤销星标)。

此外,在某些场景中,当涉及多个资源时,您可能找不到合适的资源名称,例如在搜索操作中。在这种情况下,您应使用 direct/search 端点。这是一个例外。

保护 API

保护您的 API 是另一个需要仔细注意的期望。以下是一些建议:

  • 总是使用 HTTPS 进行加密通信。

  • 总是寻找 OWASP 的顶级 API 安全威胁和漏洞。这些可以在他们的网站上找到(https://owasp.org/www-project-api-security/)或他们的 GitHub 仓库(https://github.com/OWASP/API-Security)。

  • 安全的 REST API 应该有身份验证。REST API 是无状态的;因此,REST API 不应使用 cookies 或会话。相反,它们应使用 JWT 或 OAuth 2.0 基于的令牌进行保护。

维护文档

文档应易于访问,并与其相应的版本保持最新。提供示例代码和示例总是很好的,这可以使开发者的集成工作更容易。

变更日志或发布日志应列出所有受影响的库,如果某些 API 已弃用,则应在文档中详细说明替代 API 或解决方案。

遵守推荐的 HTTP 状态码

我们已经在 探索 HTTP 方法与状态码 部分学习了状态码。请遵循那里讨论的相同指南。

确保缓存

HTTP 已经提供了一个缓存机制。你只需要在 REST API 响应中提供额外的头。然后,REST 客户端利用验证来确保是否进行调用或使用缓存响应。有两种方法可以做到这一点:

  • If-None-Match,其中包含ETag值。当服务器接收到这个请求并发现资源表示值的哈希或校验和与If-None-Match不同时,只有在这种情况下,它才应该返回带有新表示和此哈希值在ETag头中的响应。如果它发现它们相等,那么服务器应该简单地以304 (Not Modified)状态码响应。

  • ETag方式。然而,它不使用哈希或校验和,而是使用 RFC-1123 中的时间戳值(www.ietf.org/rfc/rfc1123.txt),格式为:Last-Modified: Wed, 21 Oct 2015 07:28:00 GMT。它比ETag不准确,应该仅作为后备使用。

Last-Modified方法中,客户端发送包含在Last-Modified响应头中的值的If-Modified-Since头。服务器将资源修改时间戳值与If-Modified-Since头值进行比较,如果匹配,则发送304状态码;否则,它发送带有新Last-Modified头的响应。

维持速率限制

维持速率限制对于防止 API 过度使用非常重要。当速率限制被违反时,使用 HTTP 状态码429 Too Many Requests。目前,没有标准在速率限制超过之前向客户端发出任何警告。然而,有一种流行的使用响应头来沟通的方式。以下是一些响应头:

  • X-Ratelimit-Limit:当前周期内允许的请求数量,例如,X-Ratelimit-Limit: 60

  • X-Ratelimit-Remaining:当前周期内剩余的请求数量,例如,X-Ratelimit-Remaining: 55

  • X-Ratelimit-Reset:当前周期内剩余的秒数,例如,X-Ratelimit-Reset: 1601299930

  • X-Ratelimit-Used:当前周期内使用的请求数量,例如,X-Ratelimit-Used: 5。然后,客户端可能会使用此信息来跟踪给定周期内可用的总 API 调用数。

到目前为止,我们已经讨论了与 REST 相关的各种概念。接下来,让我向您介绍本书中将使用这些概念构建的应用程序。

介绍我们的电子商务应用

我们将要构建的电子商务应用将是一个简单的在线购物应用程序,具有以下用户功能:

  • 浏览产品

  • 添加/删除/更新购物车中的产品

  • 下订单

  • 修改送货地址

  • 支持单一货币

电子商务是一个非常受欢迎的领域。如果我们看看功能,我们可以使用边界上下文将应用程序划分为以下子域:

  • users RESTful 网络服务,它为用户管理提供 REST API。

  • carts RESTful 网络服务,它为购物车管理提供 REST API。用户可以对购物车项目执行 CRUD 操作。

  • products RESTful 网络服务,它提供用于搜索和检索产品的 REST API。

  • orders RESTful 网络服务,它为用户下订单提供 REST API。

  • payments RESTful 网络服务,它提供用于支付处理的 REST API。

  • shippings RESTful 网络服务,它为订单跟踪和运输提供 REST API。

下面是我们应用程序架构的视觉表示:

图 1.1 – 电子商务应用程序架构

图 1.1 – 电子商务应用程序架构

我们将为每个子域实现一个 RESTful 网络服务。我们将保持实现简单,并在整本书中专注于学习这些概念。

摘要

在本章中,您学习了 REST 架构风格的基本概念及其关键概念——资源、URI、HTTP 方法和 HATEOAS。现在,您知道了基于 HTTP 的 REST 如何简化并使不同应用程序和服务的集成更容易。

我们还探讨了不同的 HTTP 概念,这些概念允许您以有意义的方式编写 REST API。我们还学习了为什么 HATEOAS 是 REST 实现的一个基本组成部分。此外,我们还学习了设计 REST API 的最佳实践。我们还概述了我们的电子商务应用程序。这个示例应用程序将在整本书中使用。

本章中学习的 REST 概念将为 REST 实现提供基础。现在,您可以使用本章学到的最佳实践来设计和实现最先进的 REST API。

在下一章中,您将学习 Spring 框架的基本知识。

问题

  1. 为什么 RESTful 网络服务变得如此流行,并且可以说是行业标准?

  2. RPC 和 REST 之间的区别是什么?

  3. 你会如何解释 HATEOAS?

  4. 应该使用哪些错误代码来处理与服务器相关的问题?

  5. 应该使用动词来形成 REST 端点,为什么?

答案

  1. RESTful 服务之所以流行,是因为它们建立在 HTTP 之上,而 HTTP 是互联网的骨干。您不需要单独的协议实现,如 SOAP。您可以使用现有的网络技术,与其他技术相比,通过简单的应用程序集成来实现 REST API。REST API 使应用程序集成比当时可用的其他技术更简单。

RESTful 服务基于 REST 架构,而 REST 架构又基于网络资源。资源代表领域模型。操作通过 HTTP 方法定义,并在网络资源上执行。REST 还允许客户端根据通过 HATEOAS 实现提供的链接执行操作,就像人类可以在浏览器中导航一样。

  1. RPC 更像是执行动作的函数。RPC 端点是直接基于动词形成的,每个动作都有自己的 URL。而 REST URL 代表名词,可能对不同操作相同,例如:

    RPC: GET localhost/orders/getAllOrdersREST: GET localhost/ordersRPC: POST localhost/orders/createOrderREST: POST localhost/orders
    
  2. 使用 HATEOAS,RESTful 网络服务通过超媒体动态提供信息。超媒体是你从 REST 调用响应中接收到的内容的一部分。这种超媒体内容包含指向不同类型媒体(如文本、图像和视频)的链接。机器,即 REST 客户端/浏览器,在理解数据格式和关系类型时可以跟随链接。

  3. 应使用状态码500表示通用服务器错误。当上游服务器失败时,应使用状态码502。状态码503用于意外的服务器事件,例如过载。

  4. 动词不应用于形成 REST 端点。相反,你应该使用代表领域模型的名词作为资源。HTTP 方法用于定义对资源执行的操作,例如POST用于创建和GET用于检索。

进一步阅读

第二章:API 规范和实现

在前面的章节中,我们学习了 REST API 的设计方面以及开发 RESTful Web 服务所需的 Spring 基础知识。在本章中,您将利用这两个领域来实现 REST API。

我们选择了先设计后实现的方法来使我们的开发过程对非技术利益相关者来说也是可理解的。为了使这种方法成为可能,我们将使用OpenAPI 规范OAS)首先设计一个 API,然后实现它。我们还将学习如何处理在处理请求时发生的错误。在本章中,我们将使用设计并实现一个示例电子商务应用程序 API 的例子。

到本章结束时,您应该能够设计 API 规范并使用 OpenAPI 代码生成器生成模型和 API Java 接口的代码。您还将了解如何编写伪 Spring 控制器以实现 API Java 接口和 Web 服务的全局异常处理器。

我们将在本章中涵盖以下主题:

  • 使用 OAS 设计 API

  • 理解 OAS 的基本结构

  • 将 OAS 转换为 Spring 代码

  • 实现 OAS 代码接口

  • 添加全局异常处理器

  • 测试控制器的实现

技术要求

您需要以下内容来执行本章和以下章节中的说明:

  • 任何 Java IDE,例如 NetBeans、IntelliJ 或 Eclipse

  • Java 开发工具包JDK)17

  • 互联网连接以下载依赖项和 Gradle

您可以在 GitHub 上找到本章的代码文件,网址为github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter03

使用 OAS 设计 API

您可以直接开始编写 API 的代码;然而,这种方法会导致许多问题,例如频繁的修改、API 管理的困难以及由非技术领域团队主导的审查困难。因此,您应该使用先设计后实现的方法

第一个出现在脑海中的问题是,我们如何设计 REST API?您在第一章RESTful Web 服务基础中了解到,目前没有现成的标准来规范 REST API 的实现。OAS 被引入来解决至少 REST API 规范和描述的方面。它允许您使用YAML Ain’t Markup LanguageYAML)或JavaScript Object NotationJSON)标记语言编写 REST API。

我们将使用 OAS 的 3.0 版本 (github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md) 来实现电子商务应用的 REST API。我们将使用 YAML(发音为 yamel,与 camel 同韵),它更简洁,更容易阅读。YAML 也是空格敏感的。它使用空格进行缩进;例如,它表示 key: value 对(注意冒号后面的空格——:)。您可以在 yaml.org/spec/ 上了解更多关于 YAML 的信息。

OAS 之前被称为 Swagger 规范。今天,OAS 支持的工具仍然被称为 Swagger 工具。Swagger 工具是开源项目,有助于 REST API 的整体开发周期。在本章中,我们将使用以下 Swagger 工具:

现在您已经了解了如何使用 OAS 支持的工具以设计优先的方法来开发 API,让我们了解 OAS 的基本结构。

理解 OAS 的基本结构

OpenAPI 定义结构可以分为以下部分(所有都是关键字,并且区分大小写):

  • openapi (版本)

  • info

  • externalDocs

  • servers

  • tags

  • paths

  • components

所有前面的术语都是 root 的一部分。前三个部分(openapiinfoexternalDocs)用于定义 API 的元数据。

您可以将 API 的定义放在单个文件中,也可以将其分成多个文件。OAS 支持这两种方式。我们将使用单个文件来定义示例电子商务 API。

我们不是先理论上讨论所有部分,然后编写电子商务 API 定义,而是将两者结合起来。首先,我们将涵盖电子商务 API 的每个部分定义,然后讨论为什么我们使用了它以及它意味着什么。

OAS 的元数据部分

让我们看看电子商务 API 定义的元数据部分:

openapi: 3.0.3info:
  title: Sample Ecommerce App
  description: >
    'This is a ***sample ecommerce app API***.
     You can find
    out more about Swagger at [swagger.io]
      (http://swagger.io).
    Description supports markdown markup. For example,
      you can
    use the `inline code` using back ticks.'
  termsOfService: https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-SpringBoot-3/blob/main/LICENSE
  contact:
    name: Packt Support
    url: https://www.packt.com
    email: support@packtpub.com
  license:
    name: MIT
    url: https://github.com/PacktPublishing/Modern-API-
         Development-with-Spring-6-and-Spring-Boot3/blob
         /main/LICENSE
  version: 1.0.0
externalDocs:
  description: Any document link you want to generate along
               with API.
  url: http://swagger.io

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter03/src/main/resources/api/openapi.yaml

现在,让我们详细讨论每个代码部分:

  • openapi: openapi部分告诉我们使用了哪个 OAS 来编写 API 的定义。OpenAPI 使用语义版本控制(semver.org/),这意味着版本将采用major:minor:patch的形式。如果您查看openapi元数据值,我们使用3.0.3。这表明我们使用了主版本3和补丁版本3(次要版本是0)。

  • info: info部分包含关于 API 的元数据。这些信息用于生成文档,并且可以被客户端使用。它包含以下字段,其中只有titleversion是必填字段,其余为可选字段:

    • title: API 的标题。

    • description: 这用于描述 API 的详细信息。如您所见,我们在这里可以使用 Markdown(spec.commonmark.org/)。使用>(尖括号)符号来添加多行值。

    • termsOfService: 这是一个链接到服务条款的 URL。请确保它遵循正确的 URL 格式。

    • contact: 这是 API 提供者的联系信息。email属性应该是联系人的电子邮件地址。其他属性是nameurlname属性代表联系人的名字或组织。url属性提供联系页面的链接。这是一个可选字段,并且所有属性都是可选的。

    • license: 这是许可信息。name属性是一个必填字段,代表正确的许可名称,例如 MIT。url是可选的,并提供指向许可文档的链接。

    • version: 这以字符串格式暴露 API 版本。

  • externalDocs: 这是一个可选字段,指向暴露的 API 的扩展文档。它有两个属性——descriptionurldescription属性是一个可选字段,用于定义外部文档的摘要。您可以使用 Markdown 语法来描述。url属性是必填的,并链接到外部文档。

让我们继续构建我们的 API 定义。我们已经完成了元数据部分,所以让我们讨论服务器和标签部分。

OAS 的服务器和标签部分

在元数据部分之后,我们现在可以描述服务器标签部分。让我们看看以下代码:

servers:  - url: https://ecommerce.swagger.io/v2
tags:
  - name: cart
    description: Everything about cart
    externalDocs:
      description: Find out more (extra document link)
      url: http://swagger.io
  - name: order
    description: Operation about orders

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter03/src/main/resources/api/openapi.yaml

现在,让我们详细讨论每个代码部分:

  • 服务器: 服务器部分是一个可选部分,包含托管 API 的服务器列表。如果托管 API 文档是交互式的,那么它可以通过 Swagger UI 直接调用 API 并显示响应。如果没有提供,则指向托管文档服务器的根(/)。服务器 URL 使用url属性显示。

  • 标签: 标签部分在根级别定义,包含标签及其元数据的集合。标签用于对资源执行的操作进行分组。标签元数据包含一个必填字段name,以及两个额外的可选属性:descriptionexternalDocs

name属性包含标签名称。我们已经在上一节关于元数据的讨论中讨论了描述和externalDocs字段。

让我们讨论 OAS 的最后两个部分。

OAS 的组件部分

如果我们按顺序遍历结构,我们首先会讨论路径。然而,从概念上讲,我们希望在路径部分使用它们之前先编写我们的模型。因此,我们将首先讨论组件部分,该部分用于定义模型。

下面是从示例电子商务应用的组件部分的代码片段:

components:  schemas:
    Cart:
      description: Shopping Cart of the user
      type: object
      properties:
        customerId:
          description: Id of the customer who possesses
          the cart
          type: string
        items:
          description: Collection of items in cart.
          type: array
          items:
            $ref: '#/components/schemas/Item'

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter03/src/main/resources/api/openapi.yaml

如果您是第一次使用 YAML,您可能会觉得它有点不舒服。然而,一旦您通过这一节,您会对 YAML 感到更加自在。

在这里,我们定义了一个名为Cart的模型。Cart模型是对象类型,包含两个字段,即customerId(一个字符串)和items(一个数组)。

对象数据类型

您可以将任何模型或字段定义为对象。一旦将类型标记为对象,下一个属性就是属性,它包含所有对象的字段。例如,前面代码中的Cart模型将有以下语法:

类型: 对象

属性:

<``字段名>:

类型: <``数据类型>

OAS 支持六个基本数据类型,如下所示(所有都是小写):

  • 字符串

  • 数字

  • 整数

  • 布尔

  • 对象

  • 数组

让我们讨论 Cart 模型,其中我们使用了 stringobjectarray 数据类型。其他数据类型有 numberintegerboolean。现在,你可能想知道如何定义 datetimefloat 类型等。你可以使用 format 属性来完成,你可以与 object 类型一起使用。例如,看看以下代码:

orderDate:  type: string
  format: date-time

在之前的代码中,orderDate 被定义为 type string,但 format 决定了它将包含什么字符串值。由于 format 被标记为 date-timeorderDate 字段将包含按照 RFC 3339第 5.6 节https://tools.ietf.org/html/rfc3339#section-5.6)定义的日期和时间格式 – 例如,2020-10-22T19:31:58Z

你可以使用一些其他常见的格式与类型一起使用,如下所示:

  • type: number with format: float: 这将包含浮点数

  • type: number with format: double: 这将包含双精度浮点数

  • type: integer with format: int32: 这将包含 int 类型(有符号 32 位整数)

  • type: integer with format: int64: 这将包含长类型(有符号 64 位整数)

  • type: string with format: date: 这将包含按照 RFC 3339 – 例如,2020-10-22 格式的日期

  • type: string with format: byte: 这将包含 Base64 编码的值

  • type: string with format: binary: 这将包含二进制数据(可用于文件)

我们的 Cart 模型的 items 字段是用户定义的 Item 类型的数组。在这里,Item 是另一个模型,并使用 $ref 进行引用。实际上,所有用户定义的类型都是使用 $ref 进行引用的。Item 模型也是 components/schema 部分的一部分。因此,$ref 的值包含用户定义类型的锚点,格式为 #/component/schemas/{type}

$ref 表示引用对象。它基于 JSON 引用 (tools.ietf.org/html/draft-pbryan-zyp-json-ref-03),并在 YAML 中遵循相同的语义。它可以引用同一文档中的对象或外部文档。因此,当你的 API 定义被分成多个文件时,它会用到。你已经在之前的代码中看到了它的一个使用示例。让我们再看一个示例:

# Relative Schema Document$ref: Cart.yaml
# Relative Document with embedded Schema
$ref: definitions.yaml#/Cart

之前的代码还有一个需要注意的地方。如果你仔细看,你会找到两个 items – 一个是 Cart 对象类型的属性,另一个是数组类型的属性。前者很简单 – Cart 对象的字段。然而,后者属于 array,是数组语法的组成部分。

数组语法

type: array

items:

type: <type of object>

i. 如果你将对象的类型设置为 array,你可以有一个嵌套数组

ii. 你也可以使用 $ref 来引用用户定义的类型,如代码所示
(然后,对于 items,不需要 type 属性)

让我们看看 Item 模型的样子:

Item:  description: Items in shopping cart
  type: object
  properties:
    id:
      description: Item Identifier
      type: string
    quantity:
      description: The item quantity
      type: integer
      format: int32
    unitPrice:
      description: The item's price per unit
      type: number
      format: double

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter03/src/main/resources/api/openapi.yaml

Item 模型也是 components/schema 部分的一部分。我们已经定义了电子商务应用 API 使用的几个模型。你可以在 GitHub 代码中找到它们,链接为 github.com/PacktPublishing/Modern-API-Development-with-Spring-and-Spring-Boot/tree/main/Chapter03/src/main/resources/api/openapi.yaml

现在,你已经学会了如何在 OAS 的 components/schema 部分定义模型。我们将现在讨论如何在 OAS 的 path 部分定义 API 的端点。

重要提示

schemas 类似,你还可以在 components 部分定义 requestBodies(请求有效载荷)和 responses。当你有常见的请求体和响应时,这很有用。

OAS 的路径部分

path 部分是 OAS 的最后一个部分(按顺序,它是倒数第二个,但我们已经在上一个子节中讨论了 components),在这里我们定义端点。这是我们形成 URI 并附加 HTTP 方法的位置。

让我们为 GET /api/v1/carts/{customerId}/items 定义这个 API 的定义。这个 API 获取与给定客户标识符关联的购物车中的项目:

paths:  /api/v1/carts/{customerId}:
    get:
      tags:
        - cart
      summary: Returns the shopping cart
      description: Returns the shopping cart of
      given customer
      operationId: getCartByCustomerId
      parameters:
        - name: customerId
          in: path
          description: Customer Identifier
          required: true
          schema:
            type: string
      responses:
        200:
          description: successful operation
          content:
            application/xml:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Cart'
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Cart'
        404:
          description: Given customer ID doesn't exist
          content: {}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter03/src/main/resources/api/openapi.yaml

如果你只是浏览之前的代码,你可以看到端点是什么,这个 API 使用什么 HTTP 方法以及参数,最重要的是,你可以期待什么响应。让我们更详细地讨论这个问题。在这里,v1 代表 API 的版本。每个端点路径(如 /api/v1/carts/{customerId}/items)都与一个 HTTP 方法(如 POST)相关联。端点路径始终以 / 开头。

每种方法都可以有七个字段 - tagssummarydescriptionoperationIdparametersresponsesrequestBody。让我们来了解它们每一个:

  • tags:标签用于对 API 进行分组,如下面的截图所示,标记为 cart 的 API。下面的截图中的 cart 端点将在 CartsApi.java 中:

图 3.1 – 购物车 API

图 3.1 – 购物车 API

  • summarydescriptionsummarydescription 部分与我们之前在 OAS 元数据部分 中讨论的相同。它们分别包含给定 API 的操作摘要和详细描述。通常,你可以在描述字段中使用 Markdown,因为它引用的是相同的模式。

  • operationId:这代表操作名称。正如你在之前的代码中所看到的,我们将其赋值为 getCartByCustomerId。这个相同的操作名称将被 Swagger Codegen 用作生成 API Java 接口中的方法名称。

  • Parameters:如果你仔细看,你会在名称字段前找到 -(一个连字符)。这用于将其声明为数组元素。parameters 字段可以包含多个参数——实际上,是 pathquery 参数的组合;因此,它被声明为数组。

对于 path 参数,你需要确保 parameters 下的 name 值与花括号内的 path 中给出的值相同。

parameters 字段包含 API 的 querypathheadercookie 参数。在之前的代码中,我们使用了 path 参数(in 字段的值)。如果你想将其声明为 query 参数,以及其他参数类型,你可以更改其值。

你可以使用 parameters 部分内的 required 字段标记一个字段为必填或可选,这是一个布尔参数。

最后,你必须声明参数的数据类型,这是使用 schema 字段的地方。

  • responses:对于所有 API 操作,responses 字段是一个必填字段。它定义了 API 操作在请求时可以发送的响应类型。它包含默认字段中的 HTTP 状态码。该字段必须至少有一个响应,可以是 default 响应或任何成功的 HTTP 状态码,如 200。正如其名所示,如果没有其他响应在 API 操作中定义或可用,将使用默认响应。

响应类型(如 200default)字段包含三种类型的字段——descriptioncontentheaders

  • description 字段用于描述响应。

  • headers 字段用于定义头和其值。以下是一个 headers 的示例:

    responses:  200:    description: operation successful      headers:        X-RateLimit-Limit:          schema:            type: integer
    
  • content 字段,就像我们在之前的代码中做的那样,定义了表示不同媒体类型的内容的类型。我们使用 application/json。同样,你可以定义其他媒体类型,如 application/xmlcontent 类型字段包含实际的响应对象,可以使用 schema 字段定义,就像我们在其中定义了 Item 模型的数组一样。

如前所述,你可以在 components 部分下创建一个可重用的响应,并直接使用 $ref

  • requestBodyrequestBody字段用于定义请求负载对象。与responses对象一样,requestBody也包含描述和内容字段。内容可以以与为responses对象定义的方式类似的方式进行定义。您可以参考POST /carts/{customerId}/items的先前代码以获取示例。作为响应,您还可以在components部分下创建可重用的请求体,并直接使用它们与$ref

现在,您知道如何使用 OAS 定义 API 规范了。太好了!在这里,我们只是描述了一个示例电子商务应用程序 API 的一部分。同样,您可以描述其他 API。您可以参考openapi.yaml(github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter03/src/main/resources/api/openapi.yaml)以获取我们电子商务 API 定义的完整代码。

我建议您将openapi.yaml中的代码复制并粘贴到editor.swagger.io编辑器中,以在友好的用户界面中查看 API 并进行操作。如果默认版本未设置为 3.0,请确保使用编辑菜单将 API 转换为 OpenAPI 版本 3。

我们已经完成了 API 的设计,现在让我们使用openapi.yaml生成代码,享受我们辛勤工作的果实。

将 OAS 转换为 Spring 代码

我相信您和我一样兴奋地开始实现 API。到目前为止,我们已经学习了 RESTful Web 服务理论和概念以及 Spring 基础知识,并且为示例电子商务应用程序设计了我们的第一个 API 规范。

对于本节,您可以选择克隆 Git 仓库(github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3),或者从零开始使用Spring Initializr(start.spring.io/)创建 Spring 项目,以下是一些选项:

  • Gradle - Groovy

  • Java

  • 3.0.8

或者使用可用的 3.X.X 版本。将项目元数据替换为您喜欢的值

  • Jar

  • 17

  • Spring Web

一旦您在您最喜欢的 IDE(IntelliJ、Eclipse 或 NetBeans)中打开项目,您可以在build.gradle文件的dependencies下添加以下额外的依赖项,这些依赖项对于 OpenAPI 支持是必需的:

swaggerCodegen 'org.openapitools:openapi-generator-cli:6.2.1'compileOnly 'io.swagger:swagger-annotations:1.6.4'
compileOnly 'org.springframework.boot:spring-boot-starter-
             validation'
compileOnly 'org.openapitools:jackson-databind-nullable:0.2.3'
implementation 'com.fasterxml.jackson.dataformat:jackson-
                dataformat-xml'
implementation 'org.springframework.boot:spring-boot-starter-
                hateoas'
implementation 'io.springfox:springfox-oas:3.0.0'

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter03/build.gradle

如前所述,我们将使用 Swagger 插件从我们刚刚编写的 API 定义中生成代码。按照以下七个步骤生成代码。

  1. build.gradle中的plugins {},如下所示:

    plugins {  …  …  id 'config.json (/src/main/resources/api/config.json):
    
    

    `{  "library": "spring-boot",  "dateLibrary": "java8",  "hideGenerationTimestamp": true,  "modelPackage": "com.packt.modern.api.model",  "apiPackage": "com.packt.modern.api",  "invokerPackage": "com.packt.modern.api",  "serializableModel": true,  "useTags": true,  "useGzipFeature" : true,  "hateoas": true,  "unhandledException": true,  "useSpringBoot3": true,  "useSwaggerUI": true,   …   …  "importMappings": {    "ResourceSupport":"org.springframework.hateoas.        RepresentationModel",    "Link": "org.springframework.hateoas.Link"  }}

    
    

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter03/src/main/resources/api/config.json

此配置将spring-boot设置为library——也就是说,Swagger Codegen 将生成与 Spring Boot 对齐的类。您可以看到useSpringBoot3被设置为true,以确保生成的类与 Spring Boot 3 对齐。

除了importMappings之外,所有其他属性都是不言自明的。它包含从 YAML 文件到 Java 或外部库中存在的类型的映射。因此,一旦为importMappings对象生成代码,它就会在生成的代码中使用映射的类。如果我们任何模型中使用Link,则生成的模型将使用映射的org.springframework.hateoas.Link类,而不是 YAML 文件中定义的模型。

hateoas配置属性允许我们使用 Spring HATEOAS 库并添加 HATEOAS 链接。

您可以在github.com/swagger-api/swagger-codegen#customizing-the-generator找到有关配置的更多信息。

  1. 类似于.gitignore的文件来忽略您不想生成的某些代码。将以下代码行添加到文件中(/src/main/resources/api/.openapi-generator-ignore):

    **/*Controller.java
    

我们不想生成控制器。在代码添加后,将只生成 API Java 接口和模型。我们将手动添加控制器。

  1. github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter03/src/main/resources/api/openapi.yaml文件从/src/main/resources/api复制过来。

  2. build.gradle文件中的swaggerSources任务:

    swaggerSources {  def typeMappings = 'URI=URI'  def importMappings = 'URI=java.net.URI'  eStore {    def apiYaml = "${rootDir}/src/main/resources/api/openapi.     yaml"    def configJson = "${rootDir}/src/main/resources/api/config.     json"    inputFile = file(apiYaml)    def ignoreFile = file("${rootDir}/src/main/resources/api/.openapi-generator-ignore")    code {      language = 'spring'      configFile = file(configJson)      rawOptions = ['--ignore-file-override',ignoreFile,                   '--type-mappings', typeMappings,                   '--import-mappings',                      importMappings]                   as List<String>      components = [models: true, apis: true,supportingFiles:        'ApiUtil.java']      dependsOn validation    }  }}
    

在这里,我们定义了eStore(用户定义的名称),其中包含inputFile,指向openapi.yaml文件的位置。在定义输入后,生成器需要生成输出,该输出在code中配置。

对于 code 块,language 设置为 Spring(它支持各种语言);configFile 指向 config.jsonrawOptions 包含一个 ignore 文件,类型映射和导入映射;components 包含您想要生成的文件标志 - 模型和 API Java 接口。除了 language 之外,我们所有的其他配置属性在 code 块中都是可选的。

我们只想生成模型和 API。您也可以生成其他文件,例如客户端或测试文件。在生成的 API Java 接口中需要 ApiUtil.java,否则,在构建时将给出编译错误。因此,它被添加到 components

  1. swaggerSources 作为依赖任务添加到 compileJava 任务。

此任务指向在 eStore 下定义的 code 块:

compileJava.dependsOn generateSwaggerCode task as a dependency to the processResources task:

processResources {  dependsOn(generateSwaggerCode)

}


You may get a warning in prior to Gradle 8 versions if you don’t define this dependency, and but it will still work. However, this code block is required for the Gradle 8 version.

1.  `sourceSets`. This makes the generated source code and resources available for development and build:

    ```

    sourceSets.main.java.srcDir "${swaggerSources.eStore.code.outputDir}/src/main/java"sourceSets.main.resources.srcDir "${swaggerSources.eStore.code  .outputDir}/src/main/resources"

    ```java

The source code will be generated in the `/build` directory of the project, such as `Chapter03\build\swagger-code-eStore`. This will append the generated source code and resources to Gradle `sourceSets`.
Important note
You have generated the API Java interfaces and models using the Swagger Codegen utility. Therefore, when you load the project for the first time in your IDE, you may get errors if you don’t run your build because IDE won’t find the generated Java files (models and API Java interfaces). You can run the build’s `gradlew clean build` command to generate these files.

1.  `build` path. The Java version should match the version defined in the property of `build.gradle` (`sourceCompatibility = '17'`) or in the IDE settings:

    ```

    $ gradlew clean build

    ```java

Once the build is executed successfully, you can find the generated code in the `build` directory, as shown in the following screenshot:
![Figure 3.2 – The OpenAPI-generated code](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/mdn-api-dev-spr6-spbt3/img/Figure_03.2_B19349.jpg)

Figure 3.2 – The OpenAPI-generated code
And that’s it. Once you follow all the aforementioned steps, you can successfully generate the API models and API Java interfaces code. In the next section, you’ll implement the API Java interfaces generated by OpenAPI Codegen.
Implementing the OAS code interfaces
So far, we have generated code that consists of e-commerce app models and API Java interfaces. These generated interfaces contain all the annotations as per the YAML description provided by us. For example, in `CartApi.java`, `@RequestMapping`, `@PathVariable`, and `@RequestBody` contain the endpoint path (`/api/v1/carts/{customerId}/items`), the value of the `path` variable (such as `{customerId}` in `path`), and the request payload (such as `Item`), respectively. Similarly, generated models contain all the mapping required to support the JSON and XML content types.
Swagger Codegen writes the Spring code for us. We just need to implement the interface and write the business logic inside it. Swagger Codegen generates the API Java interfaces for each of the provided tags. For example, it generates the `CartApi` and `PaymentAPI` Java interfaces for the `cart` and `payment` tags, respectively. All the paths are clubbed together into a single Java interface based on the given tag. For example, all the APIs with the `cart` tag will be clubbed together into a single Java interface, `CartApi.java`.
Now, we just need to create a class for each of the interfaces and implement it. We’ll create `CartController.java` in the `com.packt.modern.api.controllers` package and implement `CartApi`:

@RestControllerpublic class CartsController implements CartApi {

private static final Logger log = LoggerFactory.getLogger(CartsController.class);

@Override

public ResponseEntity<List> addCartItemsBy

CustomerId(String customerId, @Valid Item item) {

log.info("Request for customer ID: {}\nItem: {}",customerId, item);

返回 ok(Collections.EMPTY_LIST);

}

@Override

public ResponseEntity<List> getCartByCustomerId(String customerId) {

抛出运行时异常("手动异常抛出");

}

// 其他方法实现(省略)

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter03/src/main/java/com/packt/modern/api/controllers/CartsController.java`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter03/src/main/java/com/packt/modern/api/controllers/CartsController.java)
Here, we just implemented the two methods for demonstration purposes. We’ll implement the actual business logic in the next chapter.
To add an item (`POST /api/v1/carts/{customerId}/items`) request, we just log the incoming request payload and customer ID inside the `addCartItemsByCustomerId` method. Another method, `getCartByCustomerId`, simply throws an exception. This will allow us to demonstrate the Global Exception Handler in the next section.
Adding a Global Exception Handler
We have multiple controllers that consist of multiple methods. Each method may have checked exceptions or throw runtime exceptions. We should have a centralized place to handle all these errors for better maintainability and modularity and clean code.
Spring provides an AOP feature for this. We just need to write a single class annotated with `@ControllerAdvice`. Then, we just need to add `@ExceptionHandler` for each type of exception. This exception handler method will generate user-friendly error messages with other related information.
You can make use of the Project Lombok library if approved by your organization for third-party library usage. This will remove the verbosity of the code for getters, setters, constructors, and so on.
Let’s first write the `Error` class in the `exceptions` package that contains all the error information:

public class Error {  private static final long serialVersionUID = 1L;

private String errorCode;

private String message;

private Integer status;

private String url = "不可用";

private String reqMethod = "不可用";

// 获取器和设置器(省略)

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter03/src/main/java/com/packt/modern/api/exceptions/Error.java`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter03/src/main/java/com/packt/modern/api/exceptions/Error.java)
Here, we use the following properties:

*   `errorCode`: Application error code, which is different from HTTP error code
*   `message`: A short, human-readable summary of the problem
*   `status`: An HTTP status code for this occurrence of the problem, set by the origin server
*   `url`: A URL of the request that produced the error
*   `reqMethod`: A method of the request that produced the error

You can add other fields here if required. The `exceptions` package will contain all the code for user-defined exceptions and global exception handling.
After that, we’ll write an `enum` called `ErrorCode` that will contain all the exception keys, including user-defined errors and their respective error codes:

public enum ErrorCode {  GENERIC_ERROR("PACKT-0001","系统无法

complete the request. 联系系统支持。"),

HTTP 媒体类型不受支持("PACKT-0002","请求

媒体类型不受支持。请使用

application/json 或 application/xml 作为 'Content-

输入标题值"),

HTTP 消息不可写("PACKT-0003","根据 'Content-Type' 缺少 'Accept'

header. 请添加 'Accept' header."),

HTTP 媒体类型不可接受("PACKT-0004","请求

'Accept' header value is not supported. Please use

application/json 或 application/xml 作为 'Accept'

value"),

JSON 解析错误("PACKT-0005","确保请求负载

应该是一个有效的 JSON 对象。"),

HTTP 消息不可读("PACKT-0006","确保

请求负载应该是有效的 JSON 或 XML

对象。");

private String errCode;

private String errMsgKey;

ErrorCode(final String errCode, final String errMsgKey) {

this.errCode = errCode;

this.errMsgKey = errMsgKey;

}

public String getErrCode() {  return errCode;  }

public String getErrMsgKey() {  return errMsgKey;  }

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter03/src/main/java/com/packt/modern/api/exceptions/ErrorCode.java`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter03/src/main/java/com/packt/modern/api/exceptions/ErrorCode.java)
Here, we just added a few error code enums with their code and messages. We also just added actual error messages instead of message keys. You can add message keys and add the resource file to `src/main/resources` for internationalization.
Next, you’ll add a utility to create an `Error` object, as shown in the following code:

public class ErrorUtils {  private ErrorUtils() {}

public static Error createError(final String errMsgKey,

final String errorCode, final Integer httpStatusCode) {

Error error = new Error();

error.setMessage(errMsgKey);

error.setErrorCode(errorCode);

error.setStatus(httpStatusCode);

return error;

}

}


Finally, we’ll create a class to implement the Global Exception Handler, as shown here:

@ControllerAdvicepublic class RestApiErrorHandler {

private final MessageSource messageSource;

@Autowired

public RestApiErrorHandler(MessageSource messageSource) {

this.messageSource = messageSource;

}

@ExceptionHandler(Exception.class)

public ResponseEntity handleException(HttpServletRequest request, Exception ex,Locale locale) {

Error error = ErrorUtils

.createError(ErrorCode.GENERIC_ERROR.getErrMsgKey(),  ErrorCode.GENERIC_ERROR.getErrCode(),  HttpStatus.INTERNAL_SERVER_ERROR.value())     .setUrl(request.getRequestURL().toString())  .setReqMethod(request.getMethod());

return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);

}

@ExceptionHandler(HttpMediaTypeNotSupportedException.class)

public ResponseEntity

handleHttpMediaTypeNotSupportedException(

HttpServletRequest request,

HttpMediaTypeNotSupportedException ex, Locale locale){

Error error = ErrorUtils

.createError(ErrorCode.HTTP_MEDIATYPE_NOT_SUPPORTED

.getErrMsgKey(),

ErrorCode.HTTP_MEDIATYPE_NOT_SUPPORTED.getErrCode(),

HttpStatus.UNSUPPORTED_MEDIA_TYPE.value())

.setUrl(request.getRequestURL().toString())

.setReqMethod(request.getMethod());

return new ResponseEntity<>(

error, HttpStatus.INTERNAL_SERVER_ERROR);

}

// removed code for brevity

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter03/src/main/java/com/packt/modern/api/exceptions/RestApiErrorHandler.java`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter03/src/main/java/com/packt/modern/api/exceptions/RestApiErrorHandler.java)
As you can see, we marked the class with `@ControllerAdvice`, which enables this class to trace all the request and response processing by the REST controllers and allows us to handle exceptions using `@ExceptionHandler`.
In the previous code, we handle two exceptions – a generic internal server error exception and `HttpMediaTypeNotSupportException`. The handling method just populates the `Error` object using `ErrorCode`, `HttpServletRequest`, and `HttpStatus`. Finally, it returns the error wrapped inside `ResponseEntity` with the appropriate HTTP status.
In this code, you can add user-defined exceptions too. You can also make use of the `Locale` instance (a method parameter) and the `messageSource` class member to support internationalized messages.
Now that we have designed the API and generated the code and implementation, let’s now test the implementation in the following subsection.
Testing the implementation of the API
Once the code is ready to run, you can compile and build the artifact using the following command from the root folder of the project:

$ ./gradlew clean build


 The previous command removes the `build` folder and generates the artifact (the compiled classes and JAR). After the successful build, you can run the application using the following command:

$ java -jar build/libs/Chapter03-0.0.1-SNAPSHOT.jar


 Now, we can perform tests using the `curl` command:

$ curl --request GET 'http://localhost:8080/api/v1/carts/1' --header 'Accept: application/xml'


 This command calls the `GET` request for `/carts` with ID `1`. Here, we demand the XML response using the `Accept` header, and we get the following response:

  PACKT-0001

The system is unable to complete the request.

Contact system support.

500

http://localhost:8080/api/v1/carts/1

GET


If you change the `Accept` header from `application/xml` to `application/json`, you will get the following JSON response:

$ curl --request GET 'http://localhost:8080/api/v1/carts/1' --header 'Accept: application/json'{

"errorCode":"PACKT-0001",

"message":"The system is unable to complete the request.

Contact system support.",

"status":500,

"url":"http://localhost:8080/api/v1/carts/1",

"reqMethod":"GET"

}


Similarly, we can also call the API to add an item to the cart, as shown here:

$ curl --request POST 'http://localhost:8080/api/v1/carts/1/items' \ --header 'Content-Type: application/json' \

--header 'Accept: application/json' \

--data-raw '{

"id": "1",

"quantity": 1,

"unitPrice": 2.5

}'

[]


Here, we get `[]` (an empty array) as a response because, in the implementation, we just return the empty collection. You need to provide the `Content-Type` header in this request because we send the payload (item object) along with the request. You can change `Content-Type` to `application/xml` if the payload is written in XML. If the `Accept` header value is `application/xml`, it will return the `<List/>` value. You can remove/change the `Content-Type` and `Accept` headers or use the malformed JSON or XML to test the other error response.
This way, we can generate the API description using OpenAPI and then use the generated models and API Java interfaces to implement the APIs.
Summary
In this chapter, we opted for the design-first approach to writing RESTful web services. You learned how to write an API description using OAS and how to generate models and API Java interfaces using the Swagger Codegen tool (using the Gradle plugin). We also implemented a Global Exception Handler to centralize the handling of all the exceptions. Once you have the API Java interfaces, you can write their implementations for business logic. Now, you know how to use OAS and Swagger Codegen to write RESTful APIs. You also now know how to handle exceptions globally.
In the next chapter, we’ll implement fully fledged API Java interfaces with business logic with database persistence.
Questions

1.  What is OpenAPI and how does it help?
2.  How can you define a nested array in a model in a YAML OAS-based file?
3.  What annotations do we need to implement a Global Exception Handler?
4.  How can you use models or classes written in Java code in your OpenAPI description?
5.  Why do we only generate models and API Java interfaces using Swagger Codegen?

Answers

1.  OAS was introduced to solve at least a few aspects of a REST API’s specification and description. It allows you to write REST APIs in the YAML or JSON markup languages, which allows you to interact with all stakeholders, including those who are non-technical, for review and discussion in the development phase. It also allows you to generate documentation, models, interfaces, clients, and servers in different languages.
2.  The array is defined using the following code:

    ```

    type: arrayitems:  type: array  items:    type: string

    ```java

     3.  You need a class annotation, `@ControllerAdvice`, and a method annotation, `@ExceptionHandler`, to implement the Global Exception Handler.
4.  You can use `--type-mappings` and `--import-mappings` `rawOptions` in the `swaggerSources` task of the `build.gradle` file.
5.  We only generate the models and API Java interfaces using Swagger Codegen because this allows the complete implementation of controllers by developers only.

Further reading

*   OAS 3.0: [`github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md`](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md)
*   The Gradle plugin for OpenAPI Codegen: [`github.com/int128/gradle-swagger-generator-plugin`](https://github.com/int128/gradle-swagger-generator-plugin)
*   OAS Code Generator configuration options for Spring: [`openapi-generator.tech/docs/generators/spring/`](https://openapi-generator.tech/docs/generators/spring/)
*   YAML specifications: [`yaml.org/spec/`](https://yaml.org/spec/)
*   Semantic versioning: [`semver.org/`](https://semver.org/)

第三章:为 API 编写业务逻辑

在上一章中,您使用 OpenAPI 定义了 API 规范。API Java 接口和模型由 OpenAPI(Swagger Codegen)生成。在本章中,您将根据业务逻辑和数据持久化实现 API 的代码。在这里,业务逻辑指的是您为领域功能编写的实际代码,在我们的案例中,这包括电子商务操作,如结账。

您将为实现编写服务和存储库,并添加超媒体和"_links"字段。值得注意的是,提供的代码仅包含重要的行,而不是整个文件,以保持简洁。您可以通过代码后的链接查看完整文件。

本章涵盖以下主题:

  • 服务设计概述

  • 添加存储库组件

  • 添加服务组件

  • 实现超媒体

  • 使用服务和 HATEOAS 增强控制器

  • 在 API 响应中添加 ETags

  • 测试 API

技术要求

要执行本章及以下章节中的指令,您需要任何 REST API 客户端,例如InsomniaPostman

您可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter04

服务设计概述

我们将实现一个包含四个层——表示层、应用层、领域层和基础设施层的多层架构。多层架构是被称为领域驱动设计DDD)的架构风格的基本构建块。让我们简要地看看这些层:

  • 表示层:这一层代表用户界面UI)。在第七章设计用户界面中,您将为一个示例电子商务应用开发 UI。

  • 应用层:应用层包含应用逻辑并维护和协调整个应用流程。提醒一下,它只包含应用逻辑,不包括业务逻辑。RESTful Web 服务、异步 API、gRPC API 和 GraphQL API 都是这一层的一部分。

我们已经在第三章API 规范与实现中介绍了 REST API 和控制器,它们是应用层的一部分。在前一章中,我们为了演示目的实现了控制器。在本章中,我们将广泛实现一个控制器以服务真实数据。

  • 订单产品。它负责将这些对象读取/持久化到基础设施层。领域层也包含服务和存储库。我们也会在本章中介绍这些内容。

  • 基础设施层:基础设施层为所有其他层提供支持。它负责通信,例如与数据库、消息代理和文件系统的交互。Spring Boot 作为基础设施层,为与外部和内部系统(如数据库和消息代理)的通信和交互提供支持。

我们将采用自下而上的方法。让我们从使用@Repository组件实现领域层开始。

添加一个仓库组件

我们将采用自下而上的方法来添加@Repository组件。让我们从使用@Repository组件实现领域层开始。我们将在后续章节中相应地实现服务和增强@Controller组件。我们首先将实现@Repository组件,然后在@Service组件中使用构造函数注入使用它。@Controller组件将通过@Service组件进行增强,该组件也将通过构造函数注入到控制器中。

@Repository注解

仓库组件是带有@Repository注解的 Java 类。这是一个特殊的 Spring 组件,用于与数据库交互。

@Repository是一个通用 stereotypes,代表 DDD 的 Repository 和 @Repository

你将使用以下库作为数据库依赖项:

  • 用于持久化数据的 H2 数据库:我们将使用 H2 的内存实例;然而,你也可以使用基于文件的实例

  • Hibernate 对象关系映射(ORM):用于数据库对象映射

  • Flyway 数据库迁移:这有助于维护数据库,并保持数据库变更历史记录,允许回滚、版本升级等操作

让我们将这些依赖项添加到build.gradle文件中。org.springframework.boot:spring-boot-starter-data-jpa添加了所有必要的 JPA 依赖项,包括 Hibernate:

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'implementation 'org.flywaydb:flyway-core'
runtimeOnly    'com.h2database:h2'

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter04/build.gradle

添加依赖项后,我们可以添加与数据库相关的配置。

配置数据库和 JPA

我们还需要修改以下配置的application.properties文件。配置文件可在github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter04/src/main/resources/application.properties找到:

  • 数据源配置:以下为 Spring 数据源配置:

    spring.datasource.name=ecommspring.datasource.url=jdbc:h2:mem:ecomm;DB_CLOSE_DELAY=-1;IGNORECASE=TRUE;DATABASE_TO_UPPER=falsespring.datasource.driverClassName=org.h2.Driverspring.datasource.username=saspring.datasource.password=
    

我们需要向数据源添加 H2 特定的属性。URL 值表明将使用基于内存的 H2 数据库实例。

  • H2 数据库配置:以下有两个 H2 数据库配置:

    spring.h2.console.enabled=truespring.h2.console.settings.web-allow-others=false
    

这里,H2 控制台是 H2 网络客户端,允许您在 H2 上执行不同的操作,如查看表和执行查询。H2 控制台仅对本地访问启用;这意味着您只能在本地主机上访问 H2 控制台。此外,通过将web-allow-others设置为false,禁用了远程访问。

  • JPA 配置:以下是一些 JPA/Hibernate 配置:

    spring.jpa.properties.hibernate.default_schema=ecommspring.jpa.database-platform=org.hibernate.dialect.H2Dialectspring.jpa.show-sql=truespring.jpa.format_sql=truespring.jpa.generate-ddl=falsespring.jpa.hibernate.ddl-auto=none
    

我们不想生成 DDL 或处理 SQL 文件,因为我们想使用 Flyway 进行数据库迁移。因此,generate-ddl被标记为false,并且ddl-auto被设置为none

  • Flyway 配置:以下是一些 Flyway 配置:

    spring.flyway.url=jdbc:h2:mem:ecommspring.flyway.schemas=ecommspring.flyway.user=saspring.flyway.password=
    

在这里,已经设置了 Flyway 连接数据库所需的所有属性。

访问 H2 数据库

您可以使用/h2-console访问 H2 数据库控制台。例如,如果您的服务器在本地主机上运行,端口为8080,那么您可以通过localhost:8080/h2-console/访问它。

您已经完成了数据库配置的设置。让我们在下一小节中创建数据库模式和种子数据脚本。

数据库和种子数据脚本

现在,我们已经完成了build.gradleapplication.properties文件的配置,我们可以开始编写代码。首先,我们将添加 Flyway 数据库迁移脚本。此脚本只能用 SQL 编写。您可以将此文件放在src/main/resources目录内的db/migration目录中。我们将遵循 Flyway 命名约定(V<version>.<name>.sql),并在db/migration目录中创建V1.0.0__Init.sql文件。然后,您可以将以下脚本添加到此文件中:

create schema if not exists ecomm;create TABLE IF NOT EXISTS ecomm.product (
   id uuid NOT NULL DEFAULT random_uuid(),
   name varchar(56) NOT NULL,
   description varchar(200),
   price numeric(16, 4) DEFAULT 0 NOT NULL,
   count numeric(8, 0),
   image_url varchar(40),
   PRIMARY KEY(id)
);
create TABLE IF NOT EXISTS ecomm.tag (
   id uuid NOT NULL DEFAULT random_uuid(),
   name varchar(20),
   PRIMARY KEY(id)
);
-- Other code is removed for brevity

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter04/src/main/resources/db/migration/V1.0.0__Init.sql

此脚本创建ecomm模式,并添加了我们示例电子商务应用所需的所有表。它还添加了种子数据的insert语句。

添加实体

现在,我们可以添加实体。实体是一个特殊的对象,它使用 ORM 实现(如Hibernate)直接映射到数据库表,并带有@Entity注解。另一个流行的 ORM 是EclipseLink。您可以将所有实体对象放在com.packt.modern.api.entity包中。

让我们创建CartEntity.java文件:

@Entity@Table(name = "cart")
public class CartEntity {
 @Id
 @GeneratedValue
 @Column(name = "ID", updatable = false, nullable = false)
 private UUID id;
 @OneToOne
 @JoinColumn(name = "USER_ID", referencedColumnName = "ID")
 private UserEntity user;
 @ManyToMany( cascade = CascadeType.ALL )
 @JoinTable(
  name = "CART_ITEM",
  joinColumns = @JoinColumn(name = "CART_ID"),
  inverseJoinColumns = @JoinColumn(name = "ITEM_ID")
 )
 private List<ItemEntity> items = Collections.emptyList();
 // Getters/Setter and other codes are removed for brevity

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter04/src/main/java/com/packt/modern/api/entity/CartEntity.java

在这里,@Entity 注解是 jakarta.persistence 包的一部分,表示它是一个实体,应该映射到数据库表。默认情况下,它采用实体名称;然而,我们使用 @Table 注解来映射到数据库表。之前,javax.persistence 包是 Oracle 的一部分。一旦 Oracle 将 JEE 开源并移交给 Eclipse 基金会,就法律上要求将包名从 javax.persistence 更改为 jakarta.persistence

我们还使用一对一和一对多注解将 Cart 实体映射到 User 实体和 Item 实体,分别。ItemEntity 列表也与 @JoinTable 关联,因为我们使用 CART_ITEM 连接表根据它们各自表中的 CART_IDITEM_ID 列映射购物车和产品项。

UserEntity 中,也添加了 Cart 实体以维护关系,如下面的代码块所示。FetchType 被标记为 LAZY,这意味着只有当明确请求时才会加载用户的购物车。此外,如果你希望当购物车没有被用户引用时移除它,可以通过将 orphanRemoval 配置为 true 来实现:

@Entity@Table(name = "user")
public class UserEntity {
// other code
@OneToOne(mappedBy = "user", fetch = FetchType.LAZY, orphanRemoval = true)
private CartEntity cart;
// other code…

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter04/src/main/java/com/packt/modern/api/entity/UserEntity.java

所有其他实体都添加到位于 github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter04/src/main/java/com/packt/modern/api/entity 的实体包中。

现在,我们可以添加仓储。

添加仓储

所有仓储都已添加到 github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter04/src/main/java/com/packt/modern/api/repository

由于 Spring Data JPA 的支持,添加仓储(Repositories)对 CRUD 操作来说非常简单。你只需扩展具有默认实现的接口,例如 CrudRepository,它提供了所有 CRUD 操作的实现,如 savesaveAllfindByIdfindAllfindAllByIddeletedeleteByIdsave(Entity e) 方法用于创建和更新实体操作。

让我们创建 CartRepository.java:

public interface CartRepository extends    CrudRepository<CartEntity, UUID> {
  @Query("select c from CartEntity c join c.user u
    where u.id = :customerId")
  public Optional<CartEntity> findByCustomerId(
      @Param("customerId") UUID customerId);
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter04/src/main/java/com/packt/modern/api/repository/CartRepository.java

CartRepository 接口扩展了 org.springframework.data.repository 包中的 CrudRepository 部分。您还可以添加带有 @Query 注解(org.springframework.data.jpa.repository 包的一部分)支持的方法。@Query 注解内的查询使用 CartEntity 作为表名,而不是 Cart

在 JPQL 中选择列

类似地,对于列,您应该使用类中为字段提供的变量名,而不是使用数据库表字段。在任何情况下,如果您使用数据库表名或字段名,并且它与映射到实际表的类和类成员不匹配,您将得到一个错误。

您可能想知道,“如果我想要添加自己的自定义方法使用 JPQL 或原生态 SQL 会怎样?”好吧,让我告诉您,您也可以这样做。对于订单,我们添加了一个自定义接口来达到这个目的。首先,让我们看看 OrderRepository,它与 CartRepository 非常相似:

@Repositorypublic interface OrderRepository extends
    CrudRepository<OrderEntity, UUID>, OrderRepositoryExt {
  @Query("select o from OrderEntity o join o.userEntity u
    where u.id = :customerId")
  public Iterable<OrderEntity> findByCustomerId(
      @Param("customerId") UUID customerId);
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter04/src/main/java/com/packt/modern/api/repository/OrderRepository.java

如果您仔细观察,我们会扩展一个额外的接口——OrderRepositoryExt。这是我们为 Order 存储库提供的额外接口,由以下代码组成:

public interface OrderRepositoryExt {  Optional<OrderEntity> insert(NewOrder m);
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter04/src/main/java/com/packt/modern/api/repository/OrderRepositoryExt.java

我们已经在 CrudRepository 中有一个 save() 方法来达到这个目的;然而,我们想要使用不同的实现。为此,并展示您如何创建自己的存储库方法实现,我们添加了这个额外的存储库接口。

现在,让我们创建 OrderRepositoryExt 接口实现,如下所示:

@Repository@Transactional
public class OrderRepositoryImpl implements
  OrderRepositoryExt {
  @PersistenceContext
  private EntityManager em;
  private final ItemRepository itemRepo;
  private final CartRepository cRepo;
  private final OrderItemRepository oiRepo;
  public OrderRepositoryImpl(EntityManager em,CartRepository cRepo,       OrderItemRepository oiRepo) {
    this.em = em;
    this.cRepo = cRepo;
    this.oiRepo= oiRepo;
}
// rest of the code

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter04/src/main/java/com/packt/modern/api/repository/OrderRepositoryImpl.java

这样,我们也可以在我们的实现中使用 JPQL/@Repository注解告诉 Spring 容器这个特殊组件是一个存储库,应该用于使用底层 JPA 与数据库交互。

它也被标记为 @Transactional,这是一个特殊的注解,意味着这个类中的方法执行的事务将由 Spring 管理。它消除了添加提交和回滚的所有手动工作。你还可以将此注解添加到类中的特定方法上。

我们还在EntityManager类上使用了@PersistenceContext,这允许我们手动创建和执行查询,如下面的代码所示:

@Overridepublic Optional<OrderEntity> insert(NewOrder m) {
 Iterable<ItemEntity> dbItems = itemRepo.findByCustomerId(m.getCustomerId());
 List<ItemEntity> items = StreamSupport.stream(
            dbItems.spliterator(), false).collect
              (toList());
 if (items.size() < 1) {
  throw new ResourceNotFoundException(String.format("There
     is no item found in customer's (ID: %s) cart.",
        m.getCustomerId()));
 }
 BigDecimal total = BigDecimal.ZERO;
 for (ItemEntity i : items) {
   total = (BigDecimal.valueOf(i.getQuantity()).multiply(
      i.getPrice())).add(total);
 }
 Timestamp orderDate = Timestamp.from(Instant.now());
 em.createNativeQuery("""
  INSERT INTO ecomm.orders (address_id, card_id,
    customer_id
  order_date, total, status) VALUES(?, ?, ?, ?, ?, ?)
  """)
 .setParameter(1, m.getAddress().getId())
 .setParameter(2, m.getCard().getId())
 .setParameter(3, m.getCustomerId())
 .setParameter(4, orderDate)
 .setParameter(5, total)
 .setParameter(6, StatusEnum.CREATED.getValue())
 .executeUpdate();
 Optional<CartEntity> oCart =
  cRepo.findByCustomerId(UUID.fromString
   (m. getCustomerId()));
 CartEntity cart = oCart.orElseThrow(() -> new
  ResourceNotFoundException(String.format
("Cart not found for given customer (ID: %s)", m.getCustomerId())));
 itemRepo.deleteCartItemJoinById(cart.getItems().stream()
  .map(i -> i.getId()).collect(toList()), cart. getId());
 OrderEntity entity = (OrderEntity)
    em.createNativeQuery("""
 SELECT o.* FROM ecomm.orders o WHERE o.customer_id = ? AND
 o.order_date >= ?
 """, OrderEntity.class)
 .setParameter(1, m.getCustomerId())
 .setParameter(2, OffsetDateTime.ofInstant(orderDate.
   toInstant(),ZoneId.of("Z")).truncatedTo(ChronoUnit.MICROS))
    .getSingleResult();
 oiRepo.saveAll(cart.getItems().stream()
  .map(i -> new OrderItemEntity().setOrderId
    (entity. getId())
  .setItemId(i.getId())).collect(toList()));
 return Optional.of(entity);
}

这个方法基本上首先获取客户购物车中的项目。然后,它计算订单总额,创建一个新的订单,并将其保存到数据库中。接下来,它通过删除映射来从购物车中删除项目,因为购物车项目现在是订单的一部分。之后,它保存订单和购物车项目的映射。

订单创建是通过使用预处理语句的本地 SQL 查询完成的。

如果你仔细观察,你还会发现我们使用了官方的 Java 15 功能,文本 (docs.oracle.com/en/java/javase/15/text-blocks/index.html)。

同样,你可以为所有其他实体创建存储库。所有存储库都可以在github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter04/src/main/java/com/packt/modern/api/repository找到。

现在我们已经创建了存储库,我们可以继续添加服务。

添加服务组件

@Service组件是一个在控制器和存储库之间工作的接口,我们将在这里添加业务逻辑。尽管你可以直接从控制器中调用存储库,但这不是一种好的做法,因为存储库应该是数据检索和持久化功能的一部分。服务组件还有助于从各种来源获取数据,例如数据库和其他外部应用程序。

服务组件用@Service注解标记,这是一个专门的 Spring @Component,它允许通过类路径扫描自动检测实现类。服务类用于添加业务逻辑。像Repository一样,Service对象也代表了 DDD 的 Service 和 JEE 的业务服务外观模式。像Repository一样,它也是一个通用目的的构造型,可以根据底层方法使用。

首先,我们将创建服务接口,这是一个包含所有所需方法签名的普通 Java 接口。这个接口将公开CartService可以执行的所有操作:

public interface CartService {  public List<Item> addCartItemsByCustomerId(String customerId, @Valid Item item);
  public List<Item> addOrReplaceItemsByCustomerId(String customerId, @Valid Item item);
  public void deleteCart(String customerId);
  public void deleteItemFromCart(String customerId, String itemId);
  public CartEntity getCartByCustomerId(String customerId);
  public List<Item> getCartItemsByCustomerId(String customerId);
  public Item getCartItemsByItemId(String customerId, String itemId);
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter04/src/main/java/com/packt/modern/api/service/CartService.java

CartServiceImpl类被@Service注解,因此它将被自动检测并可用于注入。CartRepositoryUserRepositoryItemService类依赖项使用构造函数注入。

让我们看看CartService接口的一个更多方法实现。查看以下代码。它添加一个项目,或者如果项目已存在,则更新价格和数量:

@Overridepublic List<Item> addOrReplaceItemsByCustomerId(
  String customerId, @Valid Item item) {
  // 1
  CartEntity entity = getCartByCustomerId(customerId);
  List<ItemEntity> items = Objects.nonNull
    (entity.getItems())
               ? entity.getItems() : Collections.
                 emptyList();
  AtomicBoolean itemExists = new AtomicBoolean(false);
  // 2
  items.forEach(i -> {
    if(i.getProduct().getId().equals(UUID.fromString(
        item.getId()))) {
     i.setQuantity(item.getQuantity()).
       setPrice(i.getPrice());
     itemExists.set(true);
    }
  });
  if (!itemExists.get()) {
    items.add(itemService.toEntity(item));
  }
  // 3
  return itemService.toModelList(
       repository.save(entity).getItems());
}

在前面的代码中,我们不是管理应用程序状态,而是在编写查询数据库、设置实体对象、持久化对象,然后返回模型类的业务逻辑。让我们看看前面代码中按编号的语句块:

  1. 该方法只有一个customerId参数,没有Cart参数。因此,首先根据给定的customerId从数据库中获取CartEntity

  2. 程序控制遍历从CartEntity对象检索到的项目。如果给定的项目已经存在,则更改数量和价格。否则,它从给定的Item模型创建一个新的Item实体,并将其保存到CartEntity对象中。itemExists标志用于确定我们是否需要更新现有的Item或添加一个新的。

  3. 最后,将更新的CartEntity对象保存到数据库中。从数据库中检索最新的Item实体,然后将其转换为模型集合并返回给调用程序。

同样,你可以像为Cart实现的那样为其他人编写Service组件。在我们开始增强Controller类之前,我们需要将一个最终前沿添加到我们的整体功能中。

实现超媒体

我们在第一章,“RESTful Web 服务基础”中学习了超媒体和 HATEOAS。Spring 使用org.springframework.boot: spring-boot-starter-hateoas依赖项为 HATEOAS 提供最先进的支持。

首先,我们需要确保 API 响应中返回的所有模型都包含链接字段。将链接(即org.springframework.hateoas.Link类)与模型关联的方式有多种,可以是手动或通过自动生成。Spring HATEOAS 的链接和属性是根据RFC-8288tools.ietf.org/html/rfc8288)实现的。例如,你可以手动创建一个自链接,如下所示:

import static org.springframework.hateoas.server.mvc. WebMvcLinkBuilder.linkTo;import static org.springframework.hateoas.server.mvc. WebMvcLinkBuilder.methodOn;
// other code blocks…
responseModel.setSelf(linkTo(methodOn(CartController.class)  .getItemsByUserId(userId,item)).withSelfRel())

在这里,responseModel是一个由 API 返回的模型对象。它有一个名为_self的字段,该字段使用linkTomethodOn静态方法设置。linkTomethodOn方法由 Spring HATEOAS 库提供,允许我们为给定的控制器方法生成一个自链接。

这也可以通过使用 Spring HATEOAS 的RepresentationModelAssembler接口自动完成。此接口主要公开了两个方法——toModel(T model)toCollectionModel(Iterable<? extends T> entities)——分别将给定的实体/实体转换为模型和CollectionModel

Spring HATEOAS 提供了以下类来丰富用户定义的模型以包含超媒体。它基本上提供了一个包含链接和添加这些链接到模型的方法的类:

  • RepresentationModel:模型/DTO 可以扩展此功能以收集链接。

  • EntityModel:这扩展了RepresentationModel,并在其中使用内容私有字段包装了域对象(即模型)。因此,它包含域模型/DTO 和链接。

  • CollectionModelCollectionModel也扩展了RepresentationModel。它包装了模型集合并提供了一种维护和存储链接的方式。

  • PageModelPageModel扩展了CollectionModel,并提供了遍历页面、例如getNextLink()getPreviousLink(),以及通过getTotalPages()等页面元数据的方法。

使用 Spring HATEOAS 的默认方式是通过扩展RepresentationModel与域模型一起使用,如下面的代码片段所示:

public class Cart extends RepresentationModel<Cart>implements Serializable {  private static final long serialVersionUID = 1L;
  @JsonProperty("id")
  private String id;
  @JsonProperty("customerId")
  @JacksonXmlProperty(localName = "customerId")
  private String customerId;Implementing hypermedia 101
  @JsonProperty("items")
  @JacksonXmlProperty(localName = "items")
  @Valid
  private List<Item> items = null;
  // Rest of the code is removed for brevity

扩展RepresentationModel增强了模型,包括getLink()hasLink()add()等附加方法。

你知道所有这些模型都是由 OpenAPI Codegen 生成的;因此,我们需要配置 OpenAPI Codegen 以生成支持超媒体的新模型,这可以通过以下config.json文件完成:

{  // …
  "apiPackage": "com.packt.modern.api",
  "invokerPackage": "com.packt.modern.api",
  "serializableModel": true,
  "useTags": true,
  "useGzipFeature": true,
  "hateoas": true,
  "unhandledException": true,
  // …
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter04/src/main/resources/api/config.json

添加hateoas属性并将其设置为true将自动生成扩展RepresentationModel类的模型。

我们已经完成了实现 API 业务逻辑的一半。现在,我们需要确保链接将自动填充适当的 URL。为此,我们将扩展RepresentationModelAssemblerSupport抽象类,该类内部实现了RepresentationModelAssembler。让我们编写Cart的汇编器,如下面的代码块所示:

@Componentpublic class CartRepresentationModelAssembler extends
    RepresentationModelAssemblerSupport<CartEntity, Cart> {
  private final ItemService itemService;
  public CartRepresentationModelAssembler(ItemService itemService) {
    super(CartsController.class, Cart.class);
    this.itemService = itemService;
  }
  @Override
  public Cart toModel(CartEntity entity) {
    String uid = Objects.nonNull(entity.getUser()) ?
      entity.getUser().getId().toString() : null;
    String cid = Objects.nonNull(entity.getId()) ?
       entity.getId().toString() : null;
    Cart resource = new Cart();
    BeanUtils.copyProperties(entity, resource);
    resource.id(cid).customerId(uid)
      .items(itemService.toModelList(entity.getItems()));
    resource.add(linkTo(methodOn(CartsController.class)
      .getCartByCustomerId(uid)).withSelfRel());
    resource.add(linkTo(methodOn(CartsController.class)
     .getCartItemsByCustomerId(uid))
        .withRel("cart-items"));
    return resource;
  }
  public List<Cart> toListModel(
     Iterable<CartEntity> entities) {
    if (Objects.isNull(entities)){return List.of();}
    return StreamSupport.stream(
       entities.spliterator(), false).map(this::toModel)
       .collect(toList());
  }
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter04/src/main/java/com/packt/modern/api/hateoas/CartRepresentationModelAssembler.java

在之前的代码中,Cart 组装器中的重要部分是扩展 RepresentationModelAssemblerSupport 并重写 toModel() 方法。如果你仔细观察,你会看到 CartController.class 以及 Cart 模型也通过 super() 调用传递给了 Rep。这允许组装器根据之前共享的 methodOn 方法生成适当的链接。这样,你可以自动生成链接。

你可能还需要向其他资源控制器添加额外的链接。你可以通过编写一个实现 RepresentationModelProcessor 的 bean 并重写 process() 方法来实现这一点,如下所示:

@Overridepublic Order process(Order model) {
  model.add(Link.of("/payments/{orderId}").withRel(
    LinkRelation.of("payments")).expand(model.getOrderId()));
  return model;
}

你可以始终参考 docs.spring.io/spring-hateoas/docs/current/reference/html/ 获取更多信息。

让我们利用在控制器类中创建的服务和 HATEOAS 启用器,在下一节中进行使用。

通过服务和 HATEOAS 增强控制器

第三章API 规范和实现 中,我们创建了 Cart API 的 Controller 类——CartController——它仅实现了 OpenAPI Codegen 生成的 API 规范接口——CartApi。它只是一个没有业务逻辑或数据持久化调用的代码块。

现在,既然我们已经编写了存储库、服务和 HATEOAS 组装器,我们可以增强 API 控制器类,如下所示:

@RestControllerpublic class CartsController implements CartApi {
  private CartService service;
  private final CartRepresentationModelAssembler assembler;
  public CartsController(CartService service,
     CartRepresentationModelAssembler assembler) {
    this.service = service;
    this.assembler = assembler;
  }

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter04/src/main/java/com/packt/modern/api/controller/CartsController.java

你可以看到 CartServiceCartRepresentationModelAssembler 是通过构造函数注入的。Spring 容器在运行时注入这些依赖项。然后,它们可以像以下代码块中所示那样使用。

@Overridepublic ResponseEntity<Cart> getCartByCustomerId(  String customerId) {
  return ok(assembler.toModel(service.getCartByCustomerId
   (customerId)));
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter04/src/main/java/com/packt/modern/api/controller/CartsController.java

在前面的代码中,你可以看到服务根据customerId检索Cart实体(它从内部从存储库检索它)。然后,这个Cart实体被转换成一个模型,该模型还包含由 Spring HATEOAS 的RepresentationModelAssemblerSupport类提供的超媒体链接。

ResponseEntityok()静态方法用于包装返回的模型,该模型也包含200 OK状态。

这样,你还可以增强和实现其他控制器。现在,我们也可以向我们的 API 响应中添加 ETag。

向 API 响应添加 ETag

ETag 是一个包含响应实体计算出的哈希或等效值的 HTTP 响应头,实体中的任何微小变化都必须改变其值。HTTP 请求对象可以包含If-None-MatchIf-Match头部以接收条件响应。

让我们调用一个 API 以获取带有 ETag 的响应,如下所示:

$ curl -v --location --request GET 'http://localhost:8080/ api/v1/products/6d62d909-f957-430e-8689-b5129c0bb75e' –-header 'Content-Type: application/json' --header 'Accept: application/json'* … text trimmed
> GET /api/v1/products/6d62d909-f957-430e-8689-b5129c0bb75e HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.55.1
> Content-Type: application/json
> Accept: application/json
>
< HTTP/1.1 200
< ETag: "098e97de3b61db55286f5f2812785116f"
< Content-Type: application/json
< Content-Length: 339
<
{
  "_links": {
    "self": {
      "href": "http://localhost:8080/6d62d909-f957-430e
              -8689-b5129c0bb75e"
    }
  },
  "id": "6d62d909-f957-430e-8689-b5129c0bb75e",
  "name": "Antifragile",
  "description": "Antifragile - Things …",
  "imageUrl": "/images/Antifragile.jpg",
  "price": 17.1500,
  "count": 33,
  "tag": ["psychology", "book"]
}

然后,你可以从 ETag 头部复制值到If-None-Match头部,并带有If-None-Match头部再次发送相同的请求:

$ curl -v --location --request GET 'http://localhost:8080/ api/v1/products/6d62d909-f957-430e-8689-b5129c0bb75e' --header 'Content-Type: application/json' --header 'Accept: application/json' --header 'If-None-Match: "098e97de3b61db55286f5f2812785116f"'* … text trimmed
> GET /api/v1/products/6d62d909-f957-430e-8689-b5129c0bb75e HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.55.1
> Content-Type: application/json
> Accept: application/jsonAdding ETags to API responses 107
> If-None-Match: "098e97de3b61db55286f5f2812785116f"
>
< HTTP/1.1 304
< ETag: "098e97de3b61db55286f5f2812785116f"

你可以看到,由于数据库中的实体没有变化,并且包含相同的实体,它发送了一个304 (NOT MODIFIED)响应,而不是发送带有200 OK的正确响应。

实现 ETag 最简单的方法是使用 Spring 的ShallowEtagHeaderFilter,如下所示:

@Beanpublic ShallowEtagHeaderFilter shallowEtagHeaderFilter() {
  return new ShallowEtagHeaderFilter();
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter04/src/main/java/com/packt/modern/api/AppConfig.java

对于此实现,Spring 从写入响应的缓存内容中计算 MD5 哈希。下次当它收到带有If-None-Match头部的请求时,它再次从写入响应的缓存内容中创建 MD5 哈希,然后比较这两个哈希。如果两者相同,它发送304 NOT MODIFIED响应。这样,它将节省带宽,但仍然需要相同的 CPU 计算。

我们可以使用 HTTP 缓存控制(org.springframework.http.CacheControl)类,并使用每次更改时都会更新的版本或类似属性,如果有的话,以避免不必要的 CPU 计算,并更好地处理 ETag,如下所示:

return ResponseEntity.ok()       .cacheControl(CacheControl.maxAge(5, TimeUnit.DAYS))
       .eTag(prodcut.getModifiedDateInEpoch())
       .body(product);

向响应添加 ETag 也允许 UI 应用程序确定是否需要刷新页面/对象,或者需要触发事件,尤其是在应用程序中数据频繁变化的地方,例如提供实时比分或股票报价。

现在,你已经实现了完全功能的 API。接下来让我们测试它们。

测试 API

现在,你一定期待着测试。你可以在以下位置找到 API 客户端集合,这是一个 HTTP 存档文件,可以由 Insomnia 或 Postman API 客户端使用。你可以导入它,然后测试 API:

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter04/Chapter04-API-Collection.har

构建 Chapter 4 代码并运行

您可以通过在项目的根目录中运行 gradlew clean build 来构建代码,并使用 java -jar build/libs/Chapter04-0.0.1-SNAPSHOT.jar 运行服务。请确保在路径中使用 Java 17。

摘要

在本章中,我们学习了使用 Flyway 进行数据库迁移,使用仓库维护和持久化数据,以及将业务逻辑写入服务。我们还学习了如何使用 Spring HATEOAS 组装器自动将超媒体添加到 API 响应中。您现在已经了解了所有 RESTful API 开发实践,这使得您可以在涉及 RESTful API 开发的日常工作中使用这项技能。

到目前为止,我们已经编写了同步 API。在下一章中,您将学习关于异步 API 以及如何使用 Spring 来实现它们。

问题

  1. 为什么使用 @Repository 类?

  2. 是否可以向 Swagger 生成的类或模型添加额外的导入或注解?

  3. ETags 有什么用?

答案

  1. 仓库类使用 @Repository 标记,这是一个专门的 @Component,使得这些类可以通过包级别的自动扫描来自动检测,并使它们可用于注入。Spring 特别为 DDD 仓库和 JEE DAO 模式提供了这些类。这是应用程序用于与数据库交互的层——作为中心仓库进行检索和持久化。

  2. 可以更改模型和 API 生成的方式。您必须复制您想要修改的模板,并将其放置在资源文件夹中。然后,您需要在 build.gradle 文件中的 swaggerSources 块中添加一个额外的配置参数,以指向模板源,例如 templateDir = file("${rootDir}/src/main/resources/templates")。这是您保存修改后的模板,如 api.mustache 的地方。这将扩展 OpenAPI 代码生成模板。您可以在 OpenAPI 生成器 JAR 文件中找到所有模板,例如在 \JavaSpring 目录中的 openapi-generator-cli-4.3.1.jar。您可以将您想要修改的模板复制到 src/main/resource/templates 目录中,然后对其进行操作。您可以使用以下资源:

  3. ETags 通过仅在底层 API 响应更新时重新渲染页面/部分,有助于提高 REST/HTTP 客户端性能和用户体验。它们还通过仅在需要时携带响应体来节省带宽。如果 ETag 是基于从数据库检索的值(例如,版本或最后修改日期)生成的,则可以优化 CPU 利用率。

进一步阅读

第四章:异步 API 设计

到目前为止,我们已经基于命令式模型开发了 RESTful Web 服务,其中调用是同步的。如果你想要使代码异步和非阻塞,我们将在本章中介绍这一点。你将学习本章中的异步 API 设计,其中调用是异步和非阻塞的。我们将使用基于 Project Reactor (projectreactor.io)的 Spring WebFlux来开发这些 API。Reactor 是一个用于在Java 虚拟机JVM)上构建非阻塞应用程序的库。

首先,我们将介绍响应式编程的基础知识,然后我们将通过比较现有的(命令式)编程方式和响应式编程方式,将现有的电子商务 REST API(我们在第四章,*为 API 编写业务逻辑)迁移到异步(响应式)API,以简化事情。代码将使用支持响应式编程的 R2DBC 进行数据库持久化。

本章我们将讨论以下主题:

  • 理解响应式流

  • 探索 Spring WebFlux

  • 理解DispatcherHandler

  • 控制器

  • 功能端点

  • 为我们的电子商务应用实现响应式 API

到本章结束时,你将学会如何开发和实现响应式 API,并探索异步 API。你还将能够实现响应式控制器和功能端点,并利用 R2DBC 进行数据库持久化。

技术要求

本章的代码可在github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter05找到。

理解响应式流

正常的 Java 代码通过使用线程池来实现异步性。你的 Web 服务器使用线程池来处理请求——它为每个传入的请求分配一个线程。应用程序也使用线程池来处理数据库连接。每个数据库调用都使用一个单独的线程并等待结果。因此,每个 Web 请求和数据库调用都使用自己的线程。然而,这伴随着等待,因此这些是阻塞调用。线程等待并利用资源,直到从数据库收到响应或写入响应对象。当你进行扩展时,这是一个限制,因为你只能使用 JVM 可用的资源。通过使用带有服务其他实例的负载均衡器来克服这种限制,这是一种水平扩展的类型。

在过去十年中,客户端-服务器架构有所增长。大量的物联网设备、具有原生应用的智能手机、一流的网络应用和传统的网络应用纷纷涌现。应用不仅拥有第三方服务,还有各种数据来源,这导致了更高规模的应用。除此之外,基于微服务的架构增加了服务之间的通信。你需要大量的资源来满足这种更高的网络通信需求。这使得扩展成为必要。线程很昂贵,且不是无限的。你不想阻塞它们以实现有效利用。例如,假设你的代码正在调用数据库以获取数据。在这种情况下,调用会等待直到你在阻塞调用中收到响应。然而,非阻塞调用不会阻塞任何东西。它仅在从依赖代码(在这种情况下是数据库)收到响应时才响应。在这段时间内,系统可以服务其他调用。这就是异步性发挥作用的地方。在异步调用中,一旦调用完成,线程就会变得空闲,并使用回调实用工具(在 JavaScript 中很常见)。当数据在源处可用时,它会推送数据。Reactor 项目基于响应式流。响应式流使用发布者-订阅者模型,其中数据源,即发布者,将数据推送到订阅者。

你可能知道,另一方面,Node.js 使用单个线程来利用大多数资源。它基于异步非阻塞设计,称为事件循环

响应式 API 也基于事件循环设计,并使用推送式通知。如果你仔细观察,响应式流还支持 Java 流操作,如mapflatMapfilter。内部,响应式流使用推送式,而 Java 流则根据拉模型工作;也就是说,项目是从源(如 Java 集合)中拉取的。在响应式编程中,源(发布者)推送数据。

在响应式流中,数据流是异步和非阻塞的,并支持背压。(有关背压的解释,请参阅本章的订阅者子节。)

根据响应式流规范,有四种基本类型的接口:

  • 发布者

  • 订阅者

  • 订阅

  • 处理器

让我们来看看这些类型中的每一个。

发布者

发布者向一个或多个订阅者提供数据流。订阅者使用subscribe()方法订阅发布者。每个订阅者只能向一个发布者订阅一次。

最重要的是,发布者根据从订阅者收到的需求推送数据。响应式流是懒加载的;因此,只有当有订阅者时,发布者才会推送元素。

Publisher接口定义如下:

package org.reactivestreams;// T – type of element Publisher sends
public interface Publisher<T> {
  public void subscribe(Subscriber<? super T> s); }

在这里,发布者接口包含subscribe方法。让我们在下一小节中了解订阅者类型。

订阅者

订阅者消费发布者推送的数据。发布者-订阅者通信工作如下:

  1. 当一个订阅者实例传递给Publisher.subscribe()方法时,它会触发onSubscribe()方法。它包含一个Subscription参数,该参数控制背压,即订阅者从发布者那里请求的数据量。

  2. 在第一步之后,发布者等待Subscription.request(long)调用。它只有在Subscription.request()调用之后才会向订阅者推送数据。此方法指示发布者订阅者一次可以接收多少项。

通常,发布者将数据推送到订阅者,无论订阅者是否能够安全处理。然而,订阅者最清楚它能安全处理多少数据;因此,在 Reactive Streams 中,订阅者使用Subscription实例将元素数量的需求传达给发布者。这被称为背压流量控制

你可能正在想,如果发布者要求订阅者减速,但订阅者无法做到,那会怎样?在这种情况下,发布者必须决定是失败、放弃还是缓冲。

  1. 一旦在步骤 2中提出需求,发布者会发送数据通知,并使用onNext()方法来消费数据。此方法将在发布者根据Subscription.request()传达的需求推送数据通知之前被触发。

  2. 最后,无论是onError()还是onCompletion()都会被触发,作为终端状态。在这些调用之一被触发后,即使调用Subscription.request()也不会发送任何通知。以下是一些终端方法:

    • 一旦发生任何错误,onError()将被调用

    • 当所有元素都推送完毕时,onCompletion()将被调用

订阅者接口被定义为如下:

package org.reactivestreams;// T – type of element Publisher sends
public interface Subscriber<T> {
  public void onSubscribe(Subscription s);
  public void onNext(T t);
  public void onError(Throwable t);
  public void onComplete();
}

订阅

订阅是发布者和订阅者之间的调解者。订阅者的责任是调用Subscription.subscriber()方法,并让发布者知道需求。它可以根据订阅者的需要随时调用。

cancel()方法要求发布者停止发送数据通知并清理资源。

订阅被定义为如下:

package org.reactivestreams;public interface Subscription {
  public void request(long n);
  public void cancel();
}

处理器

处理器是发布者和订阅者之间的桥梁,代表处理阶段。它既作为发布者又作为订阅者工作,并遵守双方定义的合同。它被定义为如下:

package org.reactivestreams;public interface Processor<T, R>
  extends Subscriber<T>, Publisher<R> {
}

让我们看看以下示例。在这里,我们通过使用Flux.just()静态工厂方法创建FluxFlux是 Project Reactor 中的一个发布者类型。这个发布者包含四个整数元素。然后,我们使用reduce操作符(就像我们在 Java 流中做的那样)对它执行求和操作:

Flux<Integer> fluxInt = Flux.just(1, 10, 100, 1000).log();fluxInt.reduce(Integer::sum)
  .subscribe(sum ->
      System.out.printf("Sum is: %d", sum));

当你运行前面的代码时,它将打印以下输出:

11:00:38.074 [main] INFO reactor.Flux.Array.1 - | onSubscribe([Synchronous Fuseable] FluxArray.ArraySubscription)11:00:38.074 [main] INFO reactor.Flux.Array.1 - |request(unbounded)
11:00:38.084 [main] INFO reactor.Flux.Array.1 - | onNext(1)
11:00:38.084 [main] INFO reactor.Flux.Array.1 - | onNext(10)
11:00:38.084 [main] INFO reactor.Flux.Array.1 - | onNext(100)
11:00:38.084 [main] INFO reactor.Flux.Array.1 - | onNext(1000)
11:00:38.084 [main] INFO reactor.Flux.Array.1 - | onComplete() Sum is: 1111
Process finished with exit code 0

观察输出,当Publisher被订阅时,Subscriber发送无界的Subscription.request()。当第一个元素被通知时,调用onNext(),依此类推。最后,当发布者完成推送元素时,调用onComplete()事件。这就是响应式流的工作方式。

既然你已经了解了响应式流的工作原理,让我们看看 Spring WebFlux 模块是如何以及为什么使用响应式流的。

探索 Spring WebFlux

现有的 Servlet API 是阻塞 API。它们使用输入和输出流,这些是阻塞 API。Servlet 3.0 容器不断进化,并使用底层的事件循环。异步请求异步处理,但读写操作仍然使用阻塞的输入/输出流。Servlet 3.1容器进一步进化,支持异步性,并具有非阻塞 I/O 流 API。然而,某些 Servlet API,如request.getParameters(),解析阻塞请求体,并提供如Filter之类的同步合约。Spring MVC框架基于 Servlet API 和 Servlet 容器。

因此,Spring 提供了Spring WebFlux,这是一个完全非阻塞的,并提供背压功能的框架。它使用少量线程提供并发性,并且随着硬件资源的减少而扩展。WebFlux 提供了流畅的、函数式的和延续风格的 API,以支持声明式异步逻辑的组合。编写异步函数式代码比编写命令式代码更复杂。然而,一旦你上手了,你会爱上它,因为它允许你编写精确且易于阅读的代码。

Spring WebFlux 和 Spring MVC 可以共存;然而,为了确保响应式编程的有效使用,你绝不应该将响应式流程与阻塞调用混合。

Spring WebFlux 支持以下特性和架构:

  • 事件循环并发模型

  • 既有注解控制器也有功能端点

  • 响应式客户端

  • 基于 Netty 和 Servlet 3.1 容器(如 Tomcat、Undertow 和 Jetty)的 Web 服务器

既然你对 WebFlux 有了些了解,你可以通过理解响应式 API 和 Reactor Core 来深入了解 WebFlux 的工作原理。让我们首先探索响应式 API。你将在后续小节中探索 Reactor Core。

理解响应式 API

Spring WebFlux API 是响应式 API,接受Publisher作为普通输入。WebFlux 然后将其适配为响应式库(如 Reactor Core 或 RxJava)支持的类型。然后处理输入,并以响应式库支持的格式返回输出。这使得 WebFlux API 可以与其他响应式库互操作。

默认情况下,Spring WebFlux 使用 Reactor (projectreactor.io) 作为核心依赖。Project Reactor 提供了响应式流库。如前所述,WebFlux 接受输入作为 Publisher,然后将其适配为 Reactor 类型,然后作为 MonoFlux 输出返回。

你知道在响应式流中,Publisher 根据需求将数据推送到其订阅者。它可以推送一个或多个(可能是无限个)元素。Project Reactor 进一步扩展了这一点,并提供了两个 Publisher 实现,即 MonoFluxMono 可以返回 01 个元素给 Subscriber,而 Flux 返回 0N 个元素。这两个都是实现了 CorePublisher 接口的抽象类。CorePublisher 接口扩展了发布者。

通常,我们在仓库中有以下方法:

public Product findById(UUID id);public List<Product> getAll();

这些可以替换为 MonoFlux

Public Mono<Product> findById(UUID id);public Flux<Product> getAll();

根据源是否可以重新启动,流可以是热流或冷流。如果冷流有多个订阅者,则源会被重新启动,而在热流中,多个订阅者使用相同的源。Project Reactor 流默认是冷流。因此,一旦你消费了一个流,你无法重用它,直到它重新启动。然而,Project Reactor 允许你使用 cache() 方法将冷流转换为热流。MonoFlux 抽象类都支持冷流和热流。

让我们通过一些示例来理解冷流和热流的概念:

Flux<Integer> fluxInt = Flux.just(1, 10, 100).log();fluxInt.reduce(Integer::sum).subscribe(sum ->       System.out.printf("Sum is: %d\n", sum));
fluxInt.reduce(Integer::max).subscribe(max ->   System.out.printf("Maximum is: %d", max));

在这里,我们创建了一个包含三个数字的 Flux 对象,fluxInt。然后,我们分别执行两个操作——summax。你可以看到有两个订阅者。默认情况下,Project Reactor 流是冷流;因此,当第二个订阅者注册时,它会重新启动,如下面的输出所示:

11:23:35.060 [main] INFO reactor.Flux.Array.1 - | onSubscribe([Synchronous Fuseable] FluxArray.ArraySubscription)11:23:35.060 [main] INFO reactor.Flux.Array.1 - | request(unbounded)
11:23:35.060 [main] INFO reactor.Flux.Array.1 - | onNext(1)
11:23:35.060 [main] INFO reactor.Flux.Array.1 - | onNext(10)
11:23:35.060 [main] INFO reactor.Flux.Array.1 - | onNext(100)
11:23:35.060 [main] INFO reactor.Flux.Array.1 - | onComplete()
Sum is: 111
11:23:35.076 [main] INFO reactor.Flux.Array.1 - | onSubscribe([Synchronous Fuseable] FluxArray.ArraySubscription)
11:23:35.076 [main] INFO reactor.Flux.Array.1 - | request(unbounded)
11:23:35.076 [main] INFO reactor.Flux.Array.1 - | onNext(1)
11:23:35.076 [main] INFO reactor.Flux.Array.1 - | onNext(10)
11:23:35.076 [main] INFO reactor.Flux.Array.1 - | onNext(100)
11:23:35.076 [main] INFO reactor.Flux.Array.1 - | onComplete()
Maximum is: 100

源是在同一程序中创建的,但如果源在其他地方,比如在 HTTP 请求中,或者你不想重新启动源怎么办?在这些情况下,你可以使用 cache() 将冷流转换为热流,如下面的代码块所示。以下代码与之前代码的唯一区别是我们向 Flux.just() 添加了一个 cache() 调用:

Flux<Integer> fluxInt = Flux.just  (1, 10, 100).log().cache();
fluxInt.reduce(Integer::sum).subscribe(sum ->   System.out.printf("Sum is: %d\n", sum));
fluxInt.reduce(Integer::max).subscribe(max ->   System.out.printf("Maximum is: %d", max));

现在,看看输出。源没有重新启动;相反,再次使用了相同的源:

11:29:25.665 [main] INFO reactor.Flux.Array.1 - | onSubscribe([Synchronous Fuseable] FluxArray.ArraySubscription)11:29:25.665 [main] INFO reactor.Flux.Array.1 - | request(unbounded)
11:29:25.665 [main] INFO reactor.Flux.Array.1 - | onNext(1)
11:29:25.665 [main] INFO reactor.Flux.Array.1 - | onNext(10)
11:29:25.665 [main] INFO reactor.Flux.Array.1 - | onNext(100)
11:29:25.665 [main] INFO reactor.Flux.Array.1 - | onComplete()
Sum is: 111
Maximum is: 100

现在我们已经触及了响应式 API 的核心,让我们看看 Spring WebFlux 的响应式核心包含什么。

响应式核心

响应式核心为使用 Spring 开发响应式 Web 应用程序提供了一个基础。Web 应用程序需要三个级别的支持来服务 HTTP 网络请求:

  • 服务器使用以下方式处理网络请求:

    • HttpHandlerreactor.core.publisher.Mono 包中的一个接口,它是对不同 HTTP 服务器 API(如 Netty 或 Tomcat)上的请求/响应处理程序的抽象:

      public interface HttpHandler {  Mono<Void> handle(ServerHttpRequest request,     ServerHttpResponse response);}
      
    • WebHandlerorg.springframework.web.server包中的一个接口,它为用户会话、请求和会话属性、请求的本地化和主体、表单数据等提供支持。您可以在docs.spring.io/spring-framework/docs/current/reference/html/web-reactive.html#webflux-web-handler-api找到有关WebHandler的更多信息。

  • 客户端使用WebClient处理网络请求调用。

  • 用于在服务器和客户端级别对请求和响应内容进行序列化和反序列化的编解码器(EncoderDecoderHttpMessageWriterHttpMessageReaderDataBuffer)。

这些组件是 Spring WebFlux 的核心。WebFlux 应用程序配置还包含以下 bean - webHandler (DispatcherHandler)、WebFilterWebExceptionHandlerHandlerMappingHandlerAdapterHandlerResultHandler

对于 REST 服务实现,以下 Web 服务器有特定的HandlerAdapter实例 - Tomcat、Jetty、Netty 和 Undertow。支持反应式流的 Web 服务器,如 Netty,处理订阅者的需求。然而,如果服务器处理程序不支持反应式流,则使用org.springframework.http.server.reactive.ServletHttpHandlerAdapter HTTP HandlerAdapterHandlerAdapter处理反应式流和 Servlet 3.1 容器异步 I/O 之间的适配,并实现一个Subscriber类。HandlerAdapter使用 OS TCP 缓冲区。OS TCP 使用自己的背压(控制流);也就是说,当缓冲区满时,操作系统使用 TCP 背压来停止传入的元素。

浏览器或任何 HTTP 客户端都使用 HTTP 协议来消费 REST API。当网络服务器接收到请求时,它将其转发到 Spring WebFlux 应用程序。然后,WebFlux 构建通往控制器的反应式管道。HttpHandler是 WebFlux 与网络服务器之间的接口,它使用 HTTP 协议进行通信。如果底层服务器支持反应式流,例如 Netty,则服务器会原生地完成订阅。否则,WebFlux 使用ServletHttpHandlerAdapter来适配 Servlet 3.1 容器化服务器。ServletHttpHandlerAdapter随后将流适配到异步 I/O Servlet API,反之亦然。然后,通过ServletHttpHandlerAdapter进行反应式流的订阅。

因此,总结来说,Mono/Flux 流由 WebFlux 内部类订阅,当控制器发送 Mono/Flux 流时,这些类将其转换为 HTTP 数据包。HTTP 协议支持事件流。然而,对于其他媒体类型,如 JSON,Spring WebFlux 订阅 Mono/Flux 流,并等待触发 onComplete()onError()。然后,它将整个元素列表序列化,或者对于 Mono 的情况,在一个 HTTP 响应中序列化单个元素。

Spring WebFlux 需要一个类似于 Spring MVC 中的 DispatcherServlet 的组件——一个前端控制器。让我们在下一节中讨论这个问题。

理解 DispatcherHandler

DispatcherHandler 是 Spring WebFlux 中的前端控制器,相当于 Spring MVC 框架中的 DispatcherServletDispatcherHandler 包含一个算法,该算法利用特殊组件——HandlerMapping(将请求映射到处理器)、HandlerAdapterDispatcherHandler 的助手,用于调用映射到请求的处理器)和 HandlerResultHandler(用于处理结果并形成结果)——来处理请求。DispatcherHandler 组件由名为 webHandler 的 bean 标识。

它以以下方式处理请求:

  1. 网络请求由 DispatcherHandler 接收。

  2. DispatcherHandler 使用 HandlerMapping 来查找与请求匹配的处理器,并使用第一个匹配项。

  3. 然后它使用相应的 HandlerAdapter 来处理请求,该适配器在处理后会暴露 HandlerResultHandlerAdapter 处理请求后返回的值)。返回值可能是以下之一——ResponseEntityServerResponse,或者来自 @RestController 的值,或者来自视图解析器的值(CharSequenceviewmap 等等)。

  4. 然后,它利用相应的 HandlerResultHandler 根据从 步骤 2 收到的 HandlerResult 类型来写入响应或渲染视图。ResponseEntityResultHandler 用于 ResponseEntityServerResponseResultHandler 用于 ServerResponseResponseBodyResultHandler 用于由 @RestController@ResponseBody 注解的方法返回的值,而 ViewResolutionResultHandler 用于视图解析器返回的值。

  5. 请求完成。

你可以在 Spring WebFlux 中使用注解控制器(如 Spring MVC)或功能端点来创建 REST 端点。让我们在下一节中探讨这些内容。

控制器

Spring 团队为 Spring MVC 和 Spring WebFlux 保留了相同的注解,因为这些注解是非阻塞的。因此,你可以使用我们在前几章中使用过的相同注解来创建 REST 控制器。在 Spring WebFlux 中,注解运行在响应式核心上,并提供非阻塞流。然而,作为开发人员,你有责任维护一个完全非阻塞的流和响应式链(管道)。任何在响应式链中的阻塞调用都将将响应式链转换为阻塞调用。

让我们创建一个简单的支持非阻塞和响应式调用的 REST 控制器:

@RestControllerpublic class OrderController {
  @RequestMapping(value = "/api/v1/orders",  method =
    RequestMethod.POST)
  public ResponseEntity<Order> addOrder(
           @RequestBody NewOrder newOrder){
    // …
  }
  @RequestMapping(value = "/api/v1/orders/{id}", method =
     RequestMethod.GET)
  public ResponseEntity<Order>getOrderById(
    @PathVariable("id") String id){
    // …
  }
}

你可以看到,它使用了我们在 Spring MVC 中使用过的所有注解:

  • @RestController 用于标记一个类为 REST 控制器。如果没有这个注解,端点将不会注册,请求将返回 NOT FOUND 404

  • @RequestMapping 用于定义路径和 HTTP 方法。在这里,你也可以仅使用路径使用 @PostMapping。同样,对于每个 HTTP 方法,都有一个相应的映射,例如 @GetMapping

  • @RequestBody 注解将一个参数标记为请求体,并使用适当的编解码器进行转换。同样,@PathVariable@RequestParam 分别用于路径参数和查询参数。

我们将使用基于注解的模型来编写 REST 端点。当我们使用 WebFlux 实现电子商务应用控制器时,你将更详细地了解它。Spring WebFlux 还提供了一种使用函数式编程风格编写 REST 端点的方法,你将在下一节中探索。

函数式端点

我们使用 Spring MVC 编写的 REST 控制器是命令式编程风格的。另一方面,响应式编程是函数式编程风格。因此,Spring WebFlux 也允许使用函数式端点来定义 REST 端点。这些端点也使用相同的响应式核心基础。

让我们看看我们如何使用函数式端点编写示例电子商务应用的相同 Order REST 端点:

import static org.springframework.http.MediaType.  APPLICATION_JSON;
import static org.springframework.web.reactive.
  function.server. RequestPredicates.*;
import staticorg.springframework.
web.reactive.function.server. RouterFunctions.route;
// ...
  OrderRepository repository = ...
  OrderHandler handler = new OrderHandler(repository);
  RouterFunction<ServerResponse> route = route()
    .GET("/v1/api/orders/{id}",
          accept(APPLICATION_JSON),
          handler::getOrderById)
    .POST("/v1/api/orders", handler::addOrder)
    .build();
  public class OrderHandler {
    public Mono<ServerResponse> addOrder
       (ServerRequest req){
      // ...
    }
    public Mono<ServerResponse> getOrderById(
       ServerRequest req) {
    // ...
    }
  }

在前面的代码中,你可以看到 RouterFunctions.route() 构建器允许你使用函数式编程风格在一个语句中编写所有的 REST 路由。然后,它使用处理类的方法引用来处理请求,这与基于注解模型的 @RequestMapping 主体相同。

让我们在 OrderHandler 方法中添加以下代码:

public class OrderHandler {  public Mono<ServerResponse> addOrder(
     ServerRequest req){
    Mono<NewOrder> order = req.bodyToMono(NewOrder.class);
    return ok()
      .build(repository.save(toEntity(order)));
  }
  public Mono<ServerResponse> getOrderById(
      ServerRequest req) {
    String orderId = req.pathVariable("id");
    return repository
      .getOrderById(UUID.fromString(orderId))
      .flatMap(order -> ok()
          .contentType(APPLICATION_JSON)
          .bodyValue(toModel(order)))
      .switchIfEmpty(ServerResponse.notFound()
      .build());
  }
}

与 REST 控制器中的@RequestMapping()映射方法不同,处理方法没有多个参数,如 body、path 或查询参数。它们只有一个ServerRequest参数,可以用来提取 body、path 和查询参数。在addOrder方法中,使用request.bodyToMono()提取Order对象,它解析请求体并将其转换为Order对象。同样,getOrderById()方法通过调用request.pathVariable("id")从服务器请求对象中检索由给定 ID 标识的order对象。

现在,让我们讨论响应。处理方法使用ServerResponse对象,而不是 Spring MVC 中的ResponseEntity。因此,ok()静态方法看起来像是来自ResponseEntity,但实际上来自org.springframework.web.reactive.function.server.ServerResponse.ok。Spring 团队试图使 API 尽可能类似于 Spring MVC;然而,底层实现不同,提供了一个非阻塞的反应式接口。

关于这些处理方法最后一点是响应的编写方式。它使用函数式风格而不是命令式风格,并确保反应式链不会断裂。在两种情况下,仓库都返回Mono对象(一个发布者),作为包裹在ServerResponse中的响应。

你可以在getOrderById()处理方法中找到有趣的代码。它对从仓库接收到的Mono对象执行flatMap操作。它将其从实体转换为模型,然后将其包装在ServerResponse对象中,并返回响应。你可能想知道如果仓库返回 null 会发生什么。根据契约,仓库返回Mono,这与 Java 的Optional类性质相似。因此,根据契约,Mono对象可以是空的但不能为 null。如果仓库返回一个空的Mono,则将使用switchIfEmpty()操作符,并发送NOT FOUND 404响应。

在出错的情况下,可以使用不同的错误操作符,例如doOnError()onErrorReturn()

我们已经讨论了使用Mono类型进行逻辑流程;如果你用Flux类型代替Mono类型,同样的解释也适用。

我们已经讨论了在 Spring 环境中与反应式、异步和非阻塞编程相关的许多理论。现在让我们开始编码,将第第四章中开发的电子商务 API,为 API 编写业务逻辑,迁移到反应式 API。

为我们的电子商务应用实现反应式 API

既然你已经了解了 Reactive Streams 的工作原理,我们可以继续实现异步和非阻塞的 REST API。

你会记得我们正在遵循设计优先的方法,因此我们首先需要 API 设计规范。然而,我们可以重用我们在第三章中创建的电子商务 API 规范,API 规范和实现

OpenAPI Codegen 用于生成创建符合 Spring MVC 规范的 API Java 接口的 API 接口/合约。让我们看看我们需要进行哪些更改来生成 reactive API 接口。

修改 OpenAPI Codegen 以支持 reactive API

你需要调整一些 OpenAPI Codegen 配置以生成符合 Spring WebFlux 规范的 Java 接口,如下所示:

{  "library": "spring-boot",
  "dateLibrary": "java8",
  "hideGenerationTimestamp": true,
  "modelPackage": "com.packt.modern.api.model",
  "apiPackage": "com.packt.modern.api",
  "invokerPackage": "com.packt.modern.api",
  "serializableModel": true,
  "useTags": true,
  "useGzipFeature" : true,
  "reactive": true,
  "interfaceOnly": true,
  …
  …
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter05/src/main/resources/api/config.json

只有当你选择spring-boot作为库时,才会提供 Reactive API 支持。此外,你需要将reactive标志设置为true。默认情况下,reactive标志是false

现在,你可以运行以下命令:

$ gradlew clean generateSwaggerCode

这将生成符合 Reactive Streams 规范的 Java 接口,这些接口是基于注解的 REST 控制器接口。当你打开任何 API 接口时,你会在其中找到Mono/Flux反应器类型,如下面的OrderAPI接口代码块所示:

@Operation(  operationId = "addOrder",
  summary = "Creates a new order for the …",
  tags = { "order" },
  responses = {
    @ApiResponse(responseCode = "201",
      description = "Order added successfully",
      content = {
        @Content(mediaType = "application/xml",
          schema = @Schema(
            implementation = Order.class)),
        @Content(mediaType = "application/json",
          schema = @Schema(
            implementation = Order.class))
      }),
      @ApiResponse(responseCode = "406",
        description = "If payment not authorized")
  }
)
@RequestMapping(
  method = RequestMethod.POST,
  value = "/api/v1/orders",
  produces = { "application/xml",
               "application/json" },
  consumes = { "application/xml",
               "application/json" }
)
default Mono<ResponseEntity<Order>> addOrder(
 @Parameter(name = "NewOrder", description =
     "New Order Request object")
     @Valid @RequestBody(required = false)
          Mono<NewOrder> newOrder,
 @Parameter(hidden = true)
  final ServerWebExchange exg) throws Exception {

你会观察到另一个变化:对于 reactive 控制器,还需要一个额外的参数,ServerWebExchange

现在,当你编译你的代码时,你可能会发现编译错误,因为我们还没有添加所需的 reactive 支持依赖项。让我们在下一节中学习如何添加它们。

在 build.xml 中添加 Reactive 依赖

首先,我们将移除spring-boot-starter-web,因为我们现在不需要 Spring MVC。其次,我们将添加spring-boot-starter-webfluxreactor-test以支持 Spring WebFlux 和 Reactor 支持测试。一旦成功添加这些依赖项,你就不应该在 OpenAPI 生成的代码中看到任何编译错误。

你可以将所需的 reactive 依赖项添加到build.gradle中,如下所示:

implementation 'org.springframework.boot:  spring-boot-starter-webflux'
testImplementation('org.springframework.boot:
  spring-boot-starter-test')
testImplementation 'io.projectreactor:reactor-test'

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter05/build.gradle

我们需要从 REST 控制器到数据库的完整 reactive 管道。然而,现有的 JDBC 和 Hibernate 依赖项仅支持阻塞调用。JDBC 是一个完全阻塞的 API。Hibernate 也是阻塞的。因此,我们需要为数据库提供 reactive 依赖项。

Hibernate Reactive(github.com/hibernate/hibernate-reactive)是在本书第一版之后发布的。Hibernate Reactive 支持 PostgreSQL、MySQL/MariaDB、Db2 11.5+、CockroachDB 22.1+、MS SQL Server 2019+和 Oracle Database 21+。在撰写本文时,Hibernate Reactive 不支持 H2。因此,我们将简单地使用 Spring Data,这是一个提供spring-data-r2dbc库以处理响应式流的 Spring 框架。

许多 NoSQL 数据库,如 MongoDB,已经提供了响应式数据库驱动程序。对于关系数据库,应使用基于 R2DBC 的驱动程序来替代 JDBC,以实现完全非阻塞/响应式 API 调用。R2DBC代表响应式关系数据库连接。R2DBC 是一个响应式 API 开放规范,它为数据库驱动程序建立了一个服务提供者接口SPI)。几乎所有流行的关系数据库都支持 R2DBC 驱动程序——H2、Oracle Database、MySQL、MariaDB、SQL Server、PostgreSQL 和 R2DBC Proxy。

让我们在build.gradle文件中添加 Spring Data 和 H2 的 R2DBC 依赖项:

implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'implementation 'com.h2database:h2'
runtimeOnly 'io.r2dbc:r2dbc-h2'

现在,我们可以编写端到端(从控制器到仓库)的代码,而不会出现任何编译错误。在我们开始编写 API Java 接口的实现之前,让我们先添加全局异常处理。

处理异常

我们将以与在第三章中添加 Spring MVC 全局异常处理相同的方式添加全局异常处理器,API 规范和实现。在此之前,你可能想知道如何在响应式管道中处理异常。响应式管道是一系列流,你不能像在命令式代码中那样添加异常处理。你需要在管道流程中仅提出错误。

查看以下代码:

.flatMap(card -> {  if (Objects.isNull(card.getId())) {
    return service.registerCard(mono)
      .map(ce -> status(HttpStatus.CREATED)
        .body(assembler.entityToModel(
           ce, exchange)));
  } else {
    return Mono.error(() -> new
      CardAlreadyExistsException(
        " for user with ID - " + d.getId()));
  }
})

在这里,执行了一个flatMap操作。如果card无效,即card没有请求的ID,则应该抛出一个错误。在这里,使用了Mono.error(),因为管道期望返回Mono对象。同样,如果你期望返回类型为Flux,也可以使用Flux.error()

假设你期待从服务或仓库调用中获取一个对象,但结果却收到了一个空对象。这时,你可以使用switchIfEmpty()操作符,如下面的代码所示:

Mono<List<String>> monoIds =  itemRepo.findByCustomerId( customerId)
    .switchIfEmpty(Mono.error(new
       ResourceNotFoundException(". No items
         found in Cart of customer with Id - " +
           customerId)))
    .map(i -> i.getId().toString())
    .collectList().cache();

在这里,代码期望从item仓库中获取List类型的Mono对象。然而,如果返回的对象为空,则它将简单地抛出ResourceNotFoundException.switchIfEmpty()异常,并接受替代的Mono实例。

到现在为止,你可能对异常的类型有所疑问。它会抛出一个运行时异常。请在此处查看ResourceNotFoundException类的声明:

public class ResourceNotFoundException     extends RuntimeException

同样,你也可以使用来自响应式流的onErrorReturn()onErrorResume()或类似的错误操作符。看看下一个代码块中onErrorReturn()的使用:

return service.getCartByCustomerId(customerId)  .map(cart -> assembler
    .itemfromEntities(cart.getItems().stream()
      .filter(i -> i.getProductId().toString()
       .equals(itemId.trim())).collect(toList()))
      .get(0)).map(ResponseEntity::ok)
  .onErrorReturn(notFound().build())

所有异常都应该被处理,并且应该向用户发送错误响应。我们将在下一节中查看全局异常处理器。

处理控制器的全局异常

我们在 Spring MVC 中使用 @ControllerAdvice 创建了一个全局异常处理器。对于 Spring WebFlux 中的错误处理,我们将采取不同的路线。首先,我们将创建 ApiErrorAttributes 类,这个类也可以在 Spring MVC 中使用。这个类扩展了 DefaultErrorAttributes,它是 ErrorAttributes 接口的一个默认实现。ErrorAttributes 接口提供了一种处理映射、错误字段映射及其值的方式。这些错误属性可以用来向用户显示错误或用于日志记录。

DefaultErrorAttributes 类提供了以下属性:

  • timestamp: 错误被捕获的时间

  • status: 状态码

  • error: 错误描述

  • exception: 根异常的类名(如果已配置)

  • message: 异常消息(如果已配置)

  • errors: 来自 BindingResult 异常的任何 ObjectError(如果已配置)

  • trace: 异常堆栈跟踪(如果已配置)

  • path: 异常被抛出的 URL 路径

  • requestId: 与当前请求关联的唯一 ID

我们已经向状态和消息中添加了两个默认值——一个内部服务器错误和一个通用错误消息(系统无法完成请求。请联系系统支持。),分别添加到 ApiErrorAttributes 中,如下所示:

@Componentpublic class ApiErrorAttributes
  extends DefaultErrorAttributes {
  private HttpStatus status =
      HttpStatus.INTERNAL_SERVER_ERROR;
  private String message =
     ErrorCode.GENERIC_ERROR.getErrMsgKey();
  @Override
  public Map<String, Object>
    getErrorAttributes( ServerRequest request,
       ErrorAttributeOptions options) {
    var attributes =
      super.getErrorAttributes(request, options);
    attributes.put("status", status);
    attributes.put("message", message);
    attributes.put("code", ErrorCode.
      GENERIC_ERROR.getErrCode());
    return attributes;
  }
  // Getters and setters
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter05/src/main/java/com/packt/modern/api/exception/ApiErrorAttributes.java

现在,我们可以在自定义的全局异常处理器类中使用这个 ApiErrorAttributes 类。我们将创建 ApiErrorWebExceptionHandler 类,它扩展了 AbstractErrorWebExceptionHandler 抽象类。

AbstractErrorWebExceptionHandler 类实现了 ErrorWebExceptionHandlerInitializingBean 接口。ErrorWebExceptionHandler 是一个扩展了 WebExceptionHandler 接口的功能接口,这表明 WebExceptionHandler 用于渲染异常。WebExceptionHandler 是在服务器交换处理过程中处理异常的契约。

InitializingBean 接口是 Spring 核心框架的一部分。它被用于当所有属性被填充时做出反应的组件。它也可以用来检查是否设置了所有必需的属性。

现在我们已经学习了基础知识,让我们开始编写 ApiErrorAttributes 类:

@Component@Order(-2)
public class ApiErrorWebExceptionHandler extends
  AbstractErrorWebExceptionHandler {
 public ApiErrorWebExceptionHandler(
    ApiErrorAttributes errorAttributes,
    ApplicationContext appCon,
    ServerCodecConfigurer serverCodecConfigurer){
  super(errorAttributes,
     new WebProperties().getResources(),appCon);
  super.setMessageWriters(
     serverCodecConfigurer.getWriters());
  super.setMessageReaders(
     serverCodecConfigurer.getReaders());
 }
 @Override
 protected RouterFunction<ServerResponse>
   getRoutingFunction(ErrorAttributes errA) {
   return RouterFunctions.route(
     RequestPredicates.all(),
       this::renderErrorResponse);
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter05/src/main/java/com/packt/modern/api/exception/ApiErrorWebExceptionHandler.java

关于此代码的第一个重要观察是,我们添加了@Order注解,它告诉我们执行的优先级。Spring 框架将ResponseStatusExceptionHandler放置在0索引处,而DefaultErrorWebExceptionHandler则按-1索引排序。这两个都是像我们创建的那样异常处理器。如果你不给ApiErrorWebExceptionHandler设置优先级,以超过两者,那么它将永远不会执行。因此,优先级设置为-2

接下来,此类覆盖了getRoutingFunction()方法,它调用私有定义的renderErrorResponse()方法,其中我们有自己的自定义错误处理实现,如下所示:

private Mono<ServerResponse> renderErrorResponse(    ServerRequest request) {
  Map<String, Object> errorPropertiesMap =
     getErrorAttributes(request,
      ErrorAttributeOptions.defaults());
  Throwable throwable = (Throwable) request
     .attribute("org.springframework.boot.web
                .reactive.error
                .DefaultErrorAttributes.ERROR")
     .orElseThrow(() -> new IllegalStateException
     ("Missing exception attribute in ServerWebExchange"));
  ErrorCode errorCode = ErrorCode.GENERIC_ERROR;
  if (throwable instanceof
      IllegalArgumentException || throwable
      instanceof DataIntegrityViolationException
      || throwable instanceof
      ServerWebInputException) {
     errorCode = ILLEGAL_ARGUMENT_EXCEPTION;
  } else if (throwable instanceof
      CustomerNotFoundException) {
    errorCode = CUSTOMER_NOT_FOUND;
  } else if (throwable instanceof
      ResourceNotFoundException) {
    errorCode = RESOURCE_NOT_FOUND;
  } // other else-if
  // …
}

https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter05/src/main/java/com/packt/modern/api/exception/ApiErrorWebExceptionHandler.java

在这里,首先,我们从errorPropertiesMap中提取错误属性。这将在我们形成错误响应时使用。接下来,我们使用throwable捕获发生的异常。然后,我们检查异常的类型,并为其分配适当的代码。我们保留默认的GenericError,这不过是InternalServerError

接下来,我们使用switch语句根据引发的异常形成错误响应,如下所示:

switch (errorCode) {  case ILLEGAL_ARGUMENT_EXCEPTION ->{errorPropertiesMap.put
    ("status", HttpStatus.BAD_REQUEST);
    errorPropertiesMap.put("code",
       ILLEGAL_ARGUMENT_EXCEPTION.getErrCode());
    errorPropertiesMap.put("error",
       ILLEGAL_ARGUMENT_EXCEPTION);
    errorPropertiesMap.put("message", String
      .format("%s %s",
       ILLEGAL_ARGUMENT_EXCEPTION.getErrMsgKey(),
       throwable.getMessage()));
    return ServerResponse.status(
          HttpStatus.BAD_REQUEST)
        .contentType(MediaType.APPLICATION_JSON)
        .body(BodyInserters.fromValue(
          errorPropertiesMap));
  }
  case CUSTOMER_NOT_FOUND -> {
    errorPropertiesMap.put("status",
       HttpStatus.NOT_FOUND);
    errorPropertiesMap.put("code",
       CUSTOMER_NOT_FOUND.getErrCode());
    errorPropertiesMap.put("error",
       CUSTOMER_NOT_FOUND);
    errorPropertiesMap.put("message", String
       .format("%s %s",
        CUSTOMER_NOT_FOUND.getErrMsgKey(),
        throwable.getMessage()));
    return ServerResponse.status(
         HttpStatus.NOT_FOUND)
        .contentType(MediaType.APPLICATION_JSON)
        .body(BodyInserters.fromValue(
          errorPropertiesMap));
  }
  case RESOURCE_NOT_FOUND -> {
    // rest of the code …
}

在 Java 的下一个版本中,我们可能能够将if-elseswitch块结合起来,使代码更加简洁。你还可以创建一个单独的方法,该方法接受errorPropertiesMap作为参数,并根据它返回形成的服务器响应。然后,你可以使用switch

正在使用来自现有代码的自定义应用程序异常类,例如CustomerNotFoundException,以及其他异常处理支持的类,例如ErrorCodeError(来自第四章为 API 编写业务逻辑)。

现在我们已经研究了异常处理,我们可以专注于 HATEOAS。

在 API 响应中添加超媒体链接

对于反应式 API,存在 HATEOAS 支持,这与我们在上一章中使用 Spring MVC 所做的是类似的。我们再次创建这些组装器以支持 HATEOAS。我们还使用 HATEOAS 组装器类将模型转换为实体,反之亦然。

Spring WebFlux 提供了用于形成超媒体链接的ReactiveRepresentationModelAssembler接口。我们将覆盖其toModel()方法,向响应模型添加链接。

在这里,我们将做一些基础工作来填充链接。我们将创建一个具有单个默认方法的HateoasSupport接口,如下所示:

public interface HateoasSupport {  default UriComponentsBuilder
     getUriComponentBuilder(@Nullable
            ServerWebExchange exchange) {
    if (exchange == null) {
      return UriComponentsBuilder.fromPath("/");
    }
    ServerHttpRequest request = exchange.getRequest();
    PathContainer contextPath = request.getPath().
      contextPath();
    return UriComponentsBuilder
          .fromHttpRequest(request)
          .replacePath(contextPath.toString())
          .replaceQuery("");
  }
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter05/src/main/java/com/packt/modern/api/hateoas/HateoasSupport.java

在这里,这个类包含一个默认方法,getUriCompononentBuilder(),它接受ServerWebExchange作为参数,并返回UriComponentsBuilder实例。然后,可以使用此实例提取用于添加带有协议、主机和端口的链接的服务器 URI。如果您还记得,ServerWebExchange参数被添加到控制器方法中。此接口用于获取 HTTP 请求、响应和其他属性。

现在,我们可以使用这两个接口——HateoasSupportReactiveRepresentation ModelAssembler——来定义表示模型组装器。

让我们定义地址的表示模型组装器,如下所示:

@Componentpublic class AddressRepresentationModelAssembler
  implements ReactiveRepresentationModelAssembler
      <AddressEntity, Address>, HateoasSupport {
  private static String serverUri = null;
  private String getServerUri(
        @Nullable ServerWebExchange exch) {
    if (Strings.isBlank(serverUri)) {
      serverUri = getUriComponentBuilder
        (exch).toUriString();
    }
    return serverUri;
  }

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter05/src/main/java/com/packt/modern/api/hateoas/AddressRepresentationModelAssembler.java

在这里,我们定义了另一个私有方法,getServerUri(),它从UriComponentBuilder中提取服务器 URI,而UriComponentBuilder本身是由HateoasSupport接口的默认getUriComponentBuilder()方法返回的。

现在,我们可以覆盖toModel()方法,如下面的代码块所示:

AddressRepresentationModelAssembler.java

@Overridepublic Mono<Address> toModel(AddressEntity entity,
  ServerWebExchange exch) {
  return Mono.just(entityToModel(entity, exch));
}
public Address entityToModel(AddressEntity entity,
  ServerWebExchange exch) {
  Address resource = new Address();
  if(Objects.isNull(entity)) {
    return resource;
  }
  BeanUtils.copyProperties(entity, resource);
  resource.setId(entity.getId().toString());
  String serverUri = getServerUri(exchange);
  resource.add(Link.of(String.format(
      "%s/api/v1/addresses", serverUri))
      .withRel("addresses"));
  resource.add(Link.of(String.format(
      "%s/api/v1/addresses/%s",serverUri,
      entity.getId())).withSelfRel());
  return resource;
}

toModel()方法返回一个包含超媒体链接的Mono<Address>对象,这些链接是通过使用entityToModel()方法从AddressEntity实例形成的。

entityToModel()将实体实例的属性复制到模型实例。最重要的是,它使用resource.add()方法向模型添加超媒体链接。add()方法接受org.springframework.hateoas.Link实例作为参数。然后,我们使用Link类的of()静态工厂方法来形成链接。您可以看到这里使用服务器 URI 来添加到链接中。您可以形成尽可能多的链接,并使用add()方法将这些链接添加到资源中。

ReactiveRepresentationModelAssembler接口提供了toCollectionModel()方法,它有一个默认实现,返回Mono<CollectionModel<D>>集合模型。然而,我们也可以添加toListModel()方法,如下所示,它使用Flux返回地址列表:

AddressRepresentationModelAssembler.java

public Flux<Address> toListModel(         Flux<AddressEntity> ent,
         ServerWebExchange exchange) {
  if (Objects.isNull(ent)) {
    return Flux.empty();
  }
  return Flux.from(ent.map(e ->
            entityToModel(e, exchange)));
}

此方法内部使用entityToModel()方法。同样,你可以为其他 API 模型创建表示模型装配器。你可以在github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter05/src/main/java/com/packt/modern/api/hateoas找到所有这些模型。

现在我们已经完成了基本的代码基础设施,我们可以根据 OpenAPI Codegen 生成的接口开发 API 实现。在这里,我们首先将开发将被服务消费的仓库。最后,我们将编写控制器实现。让我们从仓库开始。

定义实体

实体的定义方式与我们在第四章,“为 API 编写业务逻辑”中定义和使用它们的方式大致相同。然而,我们不会使用 Hibernate 映射和 JPA,而是使用 Spring Data 注解,如下所示:

@Table("ecomm.orders")public class OrderEntity {
  @Id
  @Column("id")
  private UUID id;
  @Column("customer_id")
  private UUID customerId;
  @Column("address_id")
  private UUID addressId;
  @Column("card_id")
  private UUID cardId;
  @Column("order_date")
  private Timestamp orderDate;
  // other fields mapped to table columns
  private UUID cartId;
  private UserEntity userEntity;
  private AddressEntity addressEntity;
  private PaymentEntity paymentEntity;
  private List<ShipmentEntity> shipments = new ArrayList<>();
  // other entities fields and getters/setters

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter05/src/main/java/com/packt/modern/api/entity/OrderEntity.java

在这里,因为我们使用 Spring Data 代替 Hibernate,所以我们使用 Spring Data 注解,即@Table来关联实体类和表名,以及@Column来映射字段到表的列。显然,@Id用作标识列。同样,你可以定义其他实体。

在定义了实体之后,我们将在下一小节中添加仓库。

添加仓库

仓库是我们应用程序代码和数据库之间的接口。它与你在 Spring MVC 中使用的仓库相同。然而,我们正在使用反应式范式编写代码。因此,需要具有使用 R2DBC-/反应式驱动程序的仓库,并在 Reactive Streams 之上返回反应式类型实例。这就是为什么我们不能使用 JDBC 的原因。

Spring Data R2DBC 为 Reactor 和 RxJava 提供了不同的仓库,例如ReactiveCrudRepositoryReactiveSortingRepositoryRxJava2CrudRepositoryRxJava3CrudRepository。此外,你也可以编写自己的自定义实现。

我们将使用ReactiveCrudRepository并编写一个自定义实现。

我们将为Order实体编写仓库。对于其他实体,你可以在github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter05/src/main/java/com/packt/modern/api/repository找到仓库。

首先,让我们为Order实体编写 CRUD 仓库,如下所示:

@Repositorypublic interface OrderRepository extends
   ReactiveCrudRepository<OrderEntity, UUID>,
      OrderRepositoryExt {
  @Query("select o.* from ecomm.orders o join
           ecomm.\"user\" u on o.customer_id =
           u.id where u.id = :cusId")
  Flux<OrderEntity> findByCustomerId(UUID cusId);
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter05/src/main/java/com/packt/modern/api/repository/OrderRepository.java

这就像显示的那样简单。OrderRepository接口扩展了ReactiveCrudRepository和我们的自定义仓库接口OrderRepositoryExt

我们稍后会讨论OrderRepositoryExt;让我们先讨论OrderRepository。我们在OrderRepository接口中添加了一个额外的方法findByCustomerId(),通过给定的客户 ID 查找订单。ReactiveCrudRepository接口和Query()注解是 Spring Data R2DBC 库的一部分。Query()消耗原生 SQL 查询,与我们在上一章中创建的仓库不同。

我们也可以编写自己的自定义仓库。让我们为它编写一个简单的合约,如下所示:

public interface OrderRepositoryExt {  Mono<OrderEntity> insert(Mono<NewOrder> m);
  Mono<OrderEntity> updateMapping(OrderEntity e);
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter05/src/main/java/com/packt/modern/api/repository/OrderRepositoryExt.java

在这里,我们编写了两个方法签名——第一个将新订单记录插入数据库,第二个更新订单项和购物车项的映射。想法是,一旦下单,项目应该从购物车中移除并添加到订单中。如果您愿意,您也可以合并这两个操作。

让我们先定义OrderRepositoryExtImpl类,它扩展了OrderRepositoryExt接口,如下面的代码块所示:

@Repositorypublic class OrderRepositoryExtImpl implements OrderRepositoryExt {
  private ConnectionFactory connectionFactory;
  private DatabaseClient dbClient;
  private ItemRepository itemRepo;
  private CartRepository cartRepo;
  private OrderItemRepository oiRepo;
  public OrderRepositoryExtImpl(ConnectionFactory
     connectionFactory, ItemRepository itemRepo,
     OrderItemRepository oiRepo, CartRepository
     cartRepo, DatabaseClient dbClient) {
    this.itemRepo = itemRepo;
    this.connectionFactory = connectionFactory;
    this.oiRepo = oiRepo;
    this.cartRepo = cartRepo;
    this.dbClient = dbClient;
  }

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter05/src/main/java/com/packt/modern/api/repository/OrderRepositoryExtImpl.java

我们刚刚定义了一些类属性,并在构造函数中将这些属性作为参数添加,用于基于构造函数的依赖注入。

根据合同,它接收Mono<NewOrder>。因此,我们需要在OrderRepositoryExtImpl类中添加一个将模型转换为实体的方法。我们还需要一个额外的参数,因为CartEntity包含了购物车中的商品。下面是代码:

OrderRepositoryExtImpl.java

private OrderEntity toEntity(NewOrder order, CartEntity c) {  OrderEntity orderEntity = new OrderEntity();
  BeanUtils.copyProperties(order, orderEntity);
  orderEntity.setUserEntity(c.getUser());
  orderEntity.setCartId(c.getId());
  orderEntity.setItems(c.getItems())
     .setCustomerId(UUID.fromString(order.getCustomerId()))
     .setAddressId(UUID.fromString
       (order.getAddress().getId()))
     .setOrderDate(Timestamp.from(Instant.now()))
     .setTotal(c.getItems().stream()
     .collect(Collectors.toMap(k ->
       k.getProductId(), v ->
         BigDecimal.valueOf(v.getQuantity())
         .multiply(v.getPrice())))
     .values().stream().reduce(
      BigDecimal::add).orElse(BigDecimal.ZERO));
  return orderEntity;
}

这个方法很简单,除了设置总额的代码。总额是通过流计算的。让我们分解它以了解它:

  1. 首先,它从CartEntity中获取项目。

  2. 然后,它从项目中创建流。

  3. 它创建了一个以产品 ID 为键,以数量和价格的乘积为值的映射。

  4. 它从映射中获取值并将其转换为流。

  5. 它通过向BigDecimal添加一个方法来执行 reduce 操作。然后给出总金额。

  6. 如果没有值,它将简单地返回0

toEntity()方法之后,我们还需要另一个映射器,它从数据库中读取行并将它们转换为OrderEntity。为此,我们将编写BiFunction,它是java.util.function包的一部分:

OrderRepositoryExtImpl.java

class OrderMapper implements BiFunction<Row,Object,  OrderEntity> {
  @Override
  public OrderEntity apply(Row row, Object o) {
    OrderEntity oe = new OrderEntity();
    return oe.setId(row.get("id", UUID.class))
        .setCustomerId(
            row.get("customer_id", UUID.class))
        .setAddressId(
            row.get("address_id", UUID.class))
        .setCardId(
            row.get("card_id", UUID.class))
        .setOrderDate(Timestamp.from(
            ZonedDateTime.of(
           (LocalDateTime) row.get("order_date"),
            ZoneId.of("Z")).toInstant()))
        .setTotal(
            row.get("total", BigDecimal.class))
        .setPaymentId(
            row.get("payment_id", UUID.class))
        .setShipmentId(
            row.get("shipment_id", UUID.class))
        .setStatus(StatusEnum.fromValue(
            row.get("status", String.class)));
  }
}

在这里,我们通过将行属性映射到OrderEntity来覆盖了apply()方法,它返回OrderEntityapply()方法的第二个参数没有被使用,因为它包含我们不需要的元数据。

让我们先实现OrderRepositoryExt接口中的updateMapping()方法:

OrderRepositoryExtImpl.java

public Mono<OrderEntity> updateMapping(OrderEntity  orderEntity) {
  return oiRepo.saveAll(orderEntity.getItems()
    .stream().map(i -> new OrderItemEntity()
      .setOrderId(orderEntity.getId())
      .setItemId(i.getId())).collect(toList()))
      .then(
        itemRepo.deleteCartItemJoinById(
           orderEntity.getItems().stream()
             .map(i -> i.getId())
             .collect(toList()),
           orderEntity.getCartId())
             .then(Mono.just(orderEntity))
      );
}

在这里,我们创建了一个响应式流的管道并执行了两个连续的数据库操作。首先,它使用OrderItemRepository创建订单项映射,然后使用ItemRepository删除购物车项映射。

在第一个操作中,Java 流用于创建OrderItemEntity实例的输入列表,在第二个操作中创建项目 ID 列表。

到目前为止,我们已经使用了ReactiveCrudRepository方法。让我们使用实体模板实现一个自定义方法,如下所示:

OrderRepositoryExtImpl.java

@Overridepublic Mono<OrderEntity> insert(Mono<NewOrder> mdl) {
  AtomicReference<UUID> orderId =new AtomicReference<>();
  Mono<List<ItemEntity>> itemEntities =
       mdl.flatMap(m ->
          itemRepo.findByCustomerId(
            UUID.fromString(m.getCustomerId()))
          .collectList().cache());
  Mono<CartEntity> cartEntity =
       mdl.flatMap(m ->
          cartRepo.findByCustomerId(
            UUID.fromString(m.getCustomerId())))
         .cache();
  cartEntity = Mono.zip(cartEntity, itemEntities,
       (c, i) -> {
         if (i.size() < 1) {
          throw new ResourceNotFoundException(
          String.format("There is no item found
           in customer's (ID:%s) cart.",
             c.getUser().getId()));
       }
    return c.setItems(i);
  }).cache();

在这里,我们覆盖了OrderRepositoryExt接口中的insert()方法。insert()方法使用流畅的、函数式的和响应式的 API 填充。insert()方法接收一个包含创建新订单负载的NewOrder模型Mono实例作为参数。Spring Data R2DBC 不允许获取嵌套实体。然而,你可以像为Order编写的那样编写一个自定义的Cart存储库,它可以一起获取Cart及其项目。

我们正在使用ReactiveCrudRepositoryCartItem实体。因此,我们逐个获取它们。首先,我们使用项目存储库根据给定的客户 ID 获取购物车项目。CustomerCart有一个一对一的映射。然后,我们使用CartRepository通过客户 ID 获取Cart实体。

我们得到了两个单独的Mono对象 - Mono<List<ItemEntity>>Mono<CartEntity>。现在,我们需要将它们组合起来。Mono有一个zip()操作符,它允许你取两个Mono对象,然后使用 Java 的BiFunction来合并它们。zip()仅在给定的两个Mono对象都产生项目时返回一个新的Mono对象。zip()是多态的,因此也有其他形式可用。

我们有了购物车及其项目,以及NewOrder负载。让我们将这些项目插入到数据库中,如下所示:

OrderRepositoryExtImpl.java

R2dbcEntityTemplate template = new R2dbcEntityTemplate  (connectionFactory);
Mono<OrderEntity> orderEntity = Mono.zip(mdl,
   cartEntity, (m, c) -> toEntity(m, c)).cache();
return orderEntity.flatMap(oe -> dbClient.sql("""
    INSERT INTO ecomm.orders (address_id,
    card_id, customer_id, order_date, total,
    status) VALUES($1, $2, $3, $4, $5, $6)
    """)
    .bind("$1", Parameter.fromOrEmpty(
       oe.getAddressId(), UUID.class))
    .bind("$2", Parameter.fromOrEmpty(
       oe.getCardId(), UUID.class))
    .bind("$3", Parameter.fromOrEmpty(
       oe.getCustomerId(), UUID.class))
    .bind("$4",OffsetDateTime.ofInstant(
       oe.getOrderDate().toInstant(), ZoneId.of(
        "Z")).truncatedTo(ChronoUnit.MICROS))
    .bind("$5", oe.getTotal())
    .bind("$6", StatusEnum.CREATED.getValue())
      .map(new OrderMapper()::apply).one())
    .then(orderEntity.flatMap(x ->
       template.selectOne(
        query(where("customer_id").is(
           x.getCustomerId()).and("order_date")
            .greaterThanOrEquals(OffsetDateTime.
            ofInstant(x.getOrderDate().
              toInstant(),ZoneId.of("Z"))
              .truncatedTo(ChronoUnit.MICROS))),
        OrderEntity.class).map(t -> x.setId(
             t.getId()).setStatus(t.getStatus()))
    ));

在这里,我们再次使用Mono.zip()创建一个OrderEntity实例。现在,我们可以使用此实例的值插入到orders表中。

与数据库交互以运行 SQL 查询有两种方式——使用DatabaseClientR2dbcEntityTemplate。现在,DatabaseClient是一个轻量级实现,它使用sql()方法直接处理 SQL,而R2dbcEntityTemplate提供了一个用于 CRUD 操作的流畅 API。我们已经使用了这两个类来展示它们的用法。

首先,我们使用DatabaseClient.sql()将新订单插入到orders表中。我们使用OrderMapper将数据库返回的行映射到实体。然后,我们使用then()反应性操作符选择新插入的记录,然后使用R2dbcEntityTemplate.selectOne()方法将其映射回orderEntity

类似地,你可以为其他实体创建存储库。现在,我们可以在服务中使用这些存储库。让我们在下一小节中定义服务。

添加服务

让我们为Order添加一个服务。OrderService接口没有变化,如下所示。你只需要确保接口方法签名具有作为返回类型的反应性类型,以保持非阻塞流程:

public interface OrderService {  Mono<OrderEntity> addOrder(@Valid Mono<NewOrder>
    newOrder);
  Mono<OrderEntity> updateMapping(@Valid OrderEntity
    orderEntity);
  Flux<OrderEntity> getOrdersByCustomerId(@NotNull @Valid
    String customerId);
  Mono<OrderEntity> getByOrderId(String id);
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter05/src/main/java/com/packt/modern/api/service/OrderService.java

接下来,你将实现OrderService中描述的每个方法。让我们首先以下这种方式实现OrderService的前两个方法:

@Overridepublic Mono<OrderEntity> addOrder(@Valid Mono<NewOrder>
  newOrder) {
  return repository.insert(newOrder);
}
@Override
public Mono<OrderEntity> updateMapping(
  @Valid OrderEntity orderEntity) {
  return repository.updateMapping(orderEntity);
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter05/src/main/java/com/packt/modern/api/service/OrderServiceImpl.java

前两个方法很简单;我们只是使用OrderRepository实例来调用相应的方法。在空闲场景中,重写的updateMapping方法将在更新映射后触发其余过程:

  1. 启动支付。

  2. 一旦支付被授权,将状态更改为已支付

  3. 启动运输并将状态更改为运输启动已运输

由于我们的应用程序不是一个真实世界的应用程序,我们是为了学习目的而编写的代码,所以我们没有编写执行所有三个步骤的代码。为了简单起见,我们只是更新映射。

让我们实现第三个方法(getOrdersByCustomerId)。这有点棘手,如下所示:

OrderServiceImpl.java

private BiFunction<OrderEntity, List<ItemEntity>, OrderEntity> biOrderItems = (o, fi) -> o    .setItems(fi);
@Override
public Flux<OrderEntity> getOrdersByCustomerId(
   String customerId) {
 return repository.findByCustomerId(UUID
  .fromString(customerId)).flatMap(order ->
   Mono.just(order)
    .zipWith(userRepo.findById(order.getCustomerId()))
    .map(t -> t.getT1().setUserEntity(t.getT2()))
    .zipWith(addRepo.findById(order.getAddressId()))
    .map(t ->
          t.getT1().setAddressEntity(t.getT2()))
    .zipWith(cardRepo.findById(
       order.getCardId() != null
       ? order.getCardId() : UUID.fromString(
         "0a59ba9f-629e-4445-8129-b9bce1985d6a"))
              .defaultIfEmpty(new CardEntity()))
    .map(t -> t.getT1().setCardEntity(t.getT2()))
    .zipWith(itemRepo.findByCustomerId(
        order.getCustomerId()).collectList(),biOrderItems)
  );
}

前一个方法看起来很复杂,但实际上并不复杂。你在这里做的基本上是从多个仓库中获取数据,然后使用zipWith()操作符,通过与之并用的map()操作符或作为单独参数的BiFunction来填充OrderEntity内部的嵌套实体。

前一个方法首先使用客户 ID 获取订单,然后使用flatMap()操作符将订单扁平化以填充其嵌套实体,如CustomerOrderItems。因此,我们在flatMap()操作符内部使用zipWith()。如果你观察第一个zipWith(),它会获取用户实体,然后使用map()操作符设置嵌套用户实体的属性。同样,其他嵌套实体也被填充。

在最后一个zipWith()操作符中,我们使用BiFunction biOrderItems来设置OrderEntity实例中的item实体。

实现接口OrderService的最后一个方法(getOrderById)使用了相同的算法,如下面的代码所示:

OrderServiceImpl.java

@Overridepublic Mono<OrderEntity> getByOrderId(String id) {
  return repository.findById(UUID.fromString(id))
   .flatMap(order ->
     Mono.just(order)
      .zipWith(userRepo.findById(order.getCustomerId()))
      .map(t -> t.getT1().setUserEntity(t.getT2()))
      .zipWith(addRepo.findById(order.getAddressId()))
      .map(t -> t.getT1().setAddressEntity(t.getT2()))
      .zipWith(cardRepo.findById(order.getCardId()))
      .map(t -> t.getT1().setCardEntity(t.getT2()))
      .zipWith(itemRepo.findByCustomerId
         (order.getCustomerId()).collectList()
            ,biOrderItems)
  );
}

到目前为止,你已经使用了zipWith()操作符来合并不同的对象。你可能发现另一种使用Mono.zip()操作符合并两个Mono实例的方法,如下所示:

private BiFunction<CartEntity, List<ItemEntity>, CartEntity> cartItemBiFun = (c, i) -> c    .setItems(i);
@Override
public Mono<CartEntity> getCartByCustomerId(String
  customerId) {
  Mono<CartEntity> cart = repository.findByCustomerId(
     UUID.fromString(customerId))
      .subscribeOn(Schedulers.boundedElastic());
  Mono<UserEntity> user = userRepo.findById(
     UUID.fromString(customerId))
      .subscribeOn(Schedulers.boundedElastic());
  cart = Mono.zip(cart, user, cartUserBiFun);
  Flux<ItemEntity> items =
      itemRepo.findByCustomerId(
         UUID.fromString(customerId))
      .subscribeOn(Schedulers.boundedElastic());
  return Mono.zip(cart, items.collectList(),
     cartItemBiFun);
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter05/src/main/java/com/packt/modern/api/service/CardServiceImpl.java

这个例子是从CartServiceImpl类中提取的。在这里,我们进行了两次独立的调用——一次使用cart仓库,另一次使用item仓库。结果,这两个调用产生了两个Mono实例,并使用Mono.zip()操作符将它们合并。我们直接使用Mono来调用这个操作;上一个例子是在Mono/Flux实例上使用zipWith()操作符。

使用类似的技术,创建了剩余的服务。你可以在github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter05/src/main/java/com/packt/modern/api/service找到它们。

你已经实现了允许你执行异步操作(包括数据库调用)的异步服务。现在,你可以在控制器中消费这些服务类。让我们将我们的重点转移到我们的反应式 API 实现开发的最后一个子部分(控制器)。

添加控制器实现

REST 控制器接口已经由 OpenAPI Codegen 工具生成。我们现在可以创建这些接口的实现。在实现反应式控制器时,唯一的不同之处在于需要具有反应式管道来调用服务和组装器。您还应该仅根据生成的契约返回封装在MonoFlux中的ResponseEntity对象。

让我们实现OrderApi,这是Orders REST API 的控制接口:

@RestControllerpublic class OrderController implements OrderApi {
  private final OrderRepresentationModelAssembler
    assembler;
  private OrderService service;
  public OrderController(OrderService service,
   OrderRepresentationModelAssembler assembler) {
     this.service = service;
     this.assembler = assembler;
  }

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter05/src/main/java/com/packt/modern/api/controller/OrderController.java

在这里,@RestController是一个结合了@Controller@ResponseBody的技巧。这些是我们用于在第四章,“为 API 编写业务逻辑”中创建 REST 控制器所使用的相同注解。然而,现在方法有不同的签名,以便应用反应式管道。确保您不要打破调用链的反应式性或添加任何阻塞调用。如果您这样做,要么 REST 调用将不会完全非阻塞,或者您可能会看到不期望的结果。

我们使用基于构造函数的依赖注入来注入订单服务和组装器。让我们添加方法实现:

OrderController.java

@Overridepublic Mono<ResponseEntity<Order>>
   addOrder(@Valid Mono<NewOrder> newOrder,
      ServerWebExchange exchange) {
  return service.addOrder(newOrder.cache())
    .zipWhen(x -> service.updateMapping(x))
    .map(t -> status(HttpStatus.CREATED)
      .body(assembler.entityToModel(
         t.getT2(), exchange)))
    .defaultIfEmpty(notFound().build());
}

方法的参数和返回类型都是反应式类型(Mono),用作包装器。反应式控制器还有一个额外的参数,ServerWebExchange,我们之前讨论过。

在这个方法中,我们简单地将newOrder实例传递给服务。我们使用了cache(),因为我们需要多次订阅它。我们通过addOrder()调用获取新创建的EntityOrder。然后,我们使用zipWhen()运算符,它使用新创建的订单实体执行updateMapping操作。最后,我们通过将Order对象封装在ResponseEntity中发送它。如果返回一个空实例,它还会返回NOT FOUND 404

让我们看看order API 接口的其他方法实现:

OrderController.java

@Overridepublic Mono<ResponseEntity<Flux<Order>>>
   getOrdersByCustomerId(@NotNull
     @Valid String customerId, ServerWebExchange
       exchange) {
  return Mono
     .just(ok(assembler.toListModel(service
       .getOrdersByCustomerId(customerId),
         exchange)));
}
@Override
public Mono<ResponseEntity<Order>>
   getByOrderId(String id, ServerWebExchange
     exchange) {
  return service.getByOrderId(id).map(o ->
     assembler.entityToModel(o, exchange))
      .map(ResponseEntity::ok)
      .defaultIfEmpty(notFound().build());
}

在前面的代码中,两个方法在本质上有些相似;服务根据给定的客户 ID 和订单 ID 分别返回OrderEntity。然后它被转换成模型,并封装在ResponseEntityMono中。

类似地,其他 REST 控制器也是使用相同的方法实现的。您可以在github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter05/src/main/java/com/packt/modern/api/controller找到它们的其余部分。

我们几乎完成了反应式 API 的实现。让我们看看一些其他的细微变化。

将 H2 控制台添加到应用程序中

H2 控制台应用程序在 Spring WebFlux 中默认不可用,就像它在 Spring MVC 中可用一样。然而,你可以通过定义自己的 bean 来添加它,如下所示:

@Componentpublic class H2ConsoleComponent {
    private Server webServer;
    @Value("${modern.api.h2.console.port:8081}")
    Integer h2ConsolePort;
    @EventListener(ContextRefreshedEvent.class)
    public void start()
       throws java.sql.SQLException {
      this.webServer = org.h2.tools.Server
         .createWebServer("-webPort",
           h2ConsolePort.toString(), "-
             tcpAllowOthers").start();
    }
    @EventListener(ContextClosedEvent.class)
    public void stop() {
      this.webServer.stop();
    }
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter05/src/main/java/com/packt/modern/api/H2ConsoleComponent.java

之前的代码(H2ConsoleComponent)很简单;我们添加了start()stop()方法,它们分别在ContextRefreshEventContextStopEvent上执行。ContextRefreshEvent是一个应用程序事件,当ApplicationContext刷新或初始化时被触发。ContextStopEvent也是一个应用程序事件,当ApplicationContext关闭时被触发。

start()方法使用 H2 库创建网络服务器,并在指定的端口上启动它。stop()方法停止 H2 网络服务器,即 H2 控制台应用程序。

你需要一个不同的端口来执行 H2 控制台,这可以通过将modern.api.h2.console.port=8081属性添加到application.properties文件中来配置。h2ConsolePort属性被注解为@Value("${modern.api.h2.console.port:8081}");因此,当 Spring 框架初始化H2ConsoleComponentbean 时,将选择并分配application.properties中配置的值到h2ConsolePort。如果application.properties文件中没有定义该属性,则分配的值将是8081

由于我们正在讨论application.properties,让我们看看一些其他的更改。

添加应用程序配置

我们将使用 Flyway 进行数据库迁移。让我们添加所需的配置:

spring.flyway.url=jdbc:h2:file:./data/ecomm;AUTO_SERVER=TRUE;DB_CLOSE_DELAY=-1;IGNORECASE=TRUE;DATABASE_TO_UPPER=FALSEspring.flyway.schemas=ecomm
spring.flyway.user=
spring.flyway.password=

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter05/src/main/resources/application.properties

你可能想知道为什么我们在这里使用 JDBC,而不是 R2DBC。这是因为 Flyway 还没有开始支持 R2DBC(在撰写本文时)。一旦添加了支持,你可以将其更改为 R2DBC。

我们已经指定了ecomm模式,并设置了一个空的用户名和密码。

同样,你可以在application.properties文件中添加 Spring Data 配置:

spring.r2dbc.url=r2dbc:h2:file://././data/ecomm?options=AUTO_SERVER=TRUE;DB_CLOSE_DELAY=-1;IGNORECASE=TRUE;DATABASE_TO_UPPER=FALSE;;TRUNCATE_LARGE_LENGTH=TRUE;DB_CLOSE_ON_EXIT=FALSEspring.r2dbc.driver=io.r2dbc:r2dbc-h2
spring.r2dbc.name=
spring.r2dbc.password=

Spring Data 支持 R2DBC;因此,我们使用基于 R2DBC 的 URL。我们将io.r2dbc:r2dbc-h2设置为 H2 的驱动程序,并设置了一个空的用户名和密码。

同样,我们已经将以下日志属性添加到logback-spring.xml中,以向控制台添加 Spring R2DBC 和 H2 的调试语句:

<logger name="org.springframework.r2dbc"      level="debug" additivity="false">
   <appender-ref ref="STDOUT"/>
</logger>
<logger name="reactor.core" level="debug"
   additivity="false">
   <appender-ref ref="STDOUT"/>
</logger>
<logger name="io.r2dbc.h2" level="debug"
    additivity="false">
   <appender-ref ref="STDOUT"/>
</logger>

这标志着我们反应式 RESTful API 实现的结束。现在,您可以测试它们了。

测试反应式 API

现在,您一定期待着测试。您可以在以下位置找到 API 客户端集合。您可以导入它,然后使用支持 HAR 类型文件导入的任何 API 客户端来测试 API。

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter05/Chapter05-API-Collection.har

构建 05 章代码并运行

您可以通过在项目的根目录下运行gradlew clean build来构建代码,并使用java -jar build/libs/Chapter05-0.0.1-SNAPSHOT.jar来运行服务。请确保在路径中使用 Java 17。

概述

我希望您喜欢使用异步、非阻塞和函数式范式学习反应式 API 开发。乍一看,如果您不太熟悉流畅和函数式范式,可能会觉得它很复杂,但通过实践,您将开始只编写函数式风格的代码。当然,熟悉 Java 流和函数将帮助您轻松掌握这些概念。

现在您已经到达本章的结尾,您已经拥有了编写函数式和反应式代码的技能。您可以编写反应式、异步和非阻塞的代码以及 REST API。您还了解了 R2DBC,只要继续使用反应式编程,它将在未来变得更加稳固和增强。

在下一章中,我们将探讨 RESTful 服务开发的安全性方面。

问题

  1. 您真的需要反应式范式来进行应用程序开发吗?

  2. 使用反应式范式有什么缺点吗?

  3. 在 Spring WebFlux 中,对于 HTTP 请求的情况,谁扮演订阅者的角色?

答案

  1. 是的,只有在您需要垂直扩展时才需要。在云中,您为使用资源付费,反应式应用程序帮助您优化资源使用。这是一种实现扩展的新方法。与无反应式应用程序相比,您需要的线程数量较少。连接到数据库、I/O 或任何外部源的成本是一个回调;因此,基于反应式应用程序不需要太多内存。然而,尽管在垂直扩展方面反应式编程更优越,您应该继续使用现有的或非反应式应用程序。甚至 Spring 也建议这样做。没有新或旧的风格;两者可以共存。然而,当您需要扩展任何特殊组件或应用程序时,您可以选择反应式方法。几年前,Netflix 用反应式的 Zuul2 API 网关替换了 Zuul API 网关。这帮助他们实现了扩展。然而,他们仍然/使用非反应式应用程序。

  2. 任何事物都有其利弊。反应式编程也不例外。与命令式风格相比,反应式代码编写起来并不容易。由于它不使用单个线程,因此调试起来非常困难。然而,如果你有精通反应式范式的开发者,这并不是一个问题。

  3. WebFlux 内部类订阅由控制器发送的Mono/Flux流,并将它们转换为 HTTP 数据包。HTTP 协议确实支持事件流。然而,对于其他媒体类型,如 JSON,Spring WebFlux 订阅Mono/Flux流,并等待onComplete()onError()被触发。然后,它将整个元素列表序列化,或者对于Mono,将单个元素序列化到一个 HTTP 响应中。你可以在反应式 核心部分了解更多相关信息。

进一步阅读

第二部分 – 安全性、UI、测试和部署

在这部分,你将学习如何使用 JWT 和 Spring Security 保护 REST API。完成这部分后,你还将能够根据用户角色授权 REST 端点。你将了解 UI 应用如何消费 API,以及如何自动化 API 的单元测试和集成测试。到这部分结束时,你将能够将构建的应用容器化,然后在 Kubernetes 集群中部署它。

这一部分包含以下章节:

  • 第六章, 使用授权和认证保护 REST 端点

  • 第七章, 设计用户界面

  • 第八章, 测试 API

  • 第九章, Web 服务的部署

第五章:使用授权和认证保护 REST 端点

在前面的章节中,我们使用命令式和响应式编程风格开发了一个 RESTful Web 服务。现在,您将学习如何使用 Spring Security 保护这些 REST 端点。您将为 REST 端点实现基于令牌的认证和授权。成功的认证会提供两种类型的令牌——一个作为访问令牌的 JavaScript 对象表示法JSONWeb 令牌JWT),以及一个响应中的刷新令牌。这个基于 JWT 的访问令牌随后用于访问受保护的 统一资源定位符URL)。刷新令牌用于在现有 JWT 过期时请求新的 JWT,一个有效的请求令牌会提供一个新 JWT 以供使用。

您将把用户关联到如 adminuser 这样的角色。这些角色将用作授权,以确保只有当用户拥有某些角色时才能访问 REST 端点。我们还将简要讨论 跨站请求伪造CSRF)和 跨源资源共享CORS)。

本章的主题分为以下几节:

  • 使用 Spring Security 和 JWT 实现认证

  • 使用 JWT 保护 REST API

  • 配置 CORS 和 CSRF

  • 理解授权

  • 测试安全性

到本章结束时,您将了解如何使用 Spring Security 实现认证和授权,并保护您的 Web 服务免受 CORS 和 CSRF 攻击。

技术要求

本章的代码可在 github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter06 找到。

使用 Spring Security 和 JWT 实现认证

Spring Security 是一个由一系列库组成的框架,它允许您在不担心编写样板代码的情况下实现企业级应用的安全性。在本章中,我们将使用 Spring Security 框架来实现基于令牌(JWT)的认证和授权。在本章的整个过程中,您还将了解 CORS 和 CSRF 配置。

了解 Spring Security 也为不透明令牌提供支持,就像它为 JWT 提供支持一样。它们之间的主要区别在于从令牌中读取信息的方式。您不能像从 JWT 中那样读取不透明令牌的信息——只有发行者知道如何这样做。

注意

令牌是一系列字符,例如

5rm1tc1obfshrm2354lu9dlt5reqm1ddjchqh81 7rbk37q95b768bib0j``f44df6suk1638sf78cef7 hfolg4ap3bkighbnk7inr68ke780744fpej0gtd 9qflm999o8q

它允许您通过使用各种授权流程来调用受保护的无状态 HTTP 端点或资源。

You learned about DispatcherServletin *Chapter 2*, *Spring Concepts and REST APIs*. This is an interface between a client request and the REST controller. Therefore, if you want to place logic for token-based authentication and authorization, you will have to do this before a request reachesDispatcherServlet. Spring Security libraries provide the servlet with pre-filters (as a part of the filter chain), which are processed before the request reaches DispatcherServlet. A DispatcherServlet`. Similarly, post-filters get processed after a request has been processed by the servlet/controller.

There are two ways you can implement token-based (JWT) authentication – by using either spring-boot-starter-securityorspring-boot-starter-oauth2-resource-server. We will use the latter because it does the boilerplate configuration for us.

前者包含以下库:

  • ``spring-security-core```

  • ``spring-security-config```

  • ``spring-security-web```

``spring-boot-starter-oauth2-resource-server``` 提供以下内容,以及所有三个先前的 Java ARchive (JAR) 文件:

  • ``spring-security-oauth2-core```

  • ``spring-security-oauth2-jose```

  • ``spring-security-oauth2-resource-server```

When you start this chapter’s code, you will find the following log. You can see that, by default, DefaultSecurityFilterChainisauto-configured. The logstatement lists the configured filters inDefaultSecurityFilterChain, as shown in the following log block:

INFO [Chapter06,,,] [null] [null] [null] [null] 31975 --- [           main] o.s.s.web.DefaultSecurityFilterChain:

1.  `WebAsyncManagerIntegrationFilter`
2.  `SecurityContextPersistenceFilter`
3.  `HeaderWriterFilter`
4.  `CorsFilter`
5.  `CsrfFilter`
6.  `LogoutFilter`
7.  `BearerTokenAuthenticationFilter`
8.  `RequestCacheAwareFilter`
9.  `SecurityContextHolderAwareRequestFilter`
10.  `AnonymousAuthenticationFilter`
11.  `SessionManagementFilter`
12.  `ExceptionTranslationFilter`
13.  `FilterSecurityInterceptor`

This filter chain may change in future releases. Also, the security filter chain will be different if you just used `spring-boot-starter-security` or changed the configuration. You can find all the filters available in `springSecurityFilterChain` at [`docs.spring.io/spring-security/reference/servlet/architecture.html#servlet-security-filters`](https://docs.spring.io/spring-security/reference/servlet/architecture.html#servlet-security-filters).
Now, you know about the different filters and their order in the default security chain. Next, let’s add the required dependencies, making use of the Spring OAuth 2.0 resource server for authentication in the following subsections.
Adding the required Gradle dependencies
Let’s add the following dependencies to the `build.gradle` file, as shown next:

implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' implementation 'com.auth0:java-jwt:4.3.0'


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/build.gradle`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/build.gradle)
The Spring Boot Starter OAuth 2 resource server dependency will add the following JARs:

*   `spring-security-core`
*   `spring-security-config`
*   `spring-security-web`
*   `spring-security-cropto`
*   `spring-security-oauth2-core`
*   `spring-security-oauth2-jose`
*   `spring-security-oauth2-resource-server`

For JWT implementation, we will use the `java-jwt` library from [auth0.com](http://auth0.com).
We will now explore how to code these two filters – through login and token-based authentication.
Authentication using the OAuth 2.0 resource server
The Spring Security OAuth 2.0 resource server allows you to implement authentication and authorization using `BearerTokenAuthenticationFilter`. This contains the bearer token authentication logic. However, you still need to write the REST endpoint to generate the token. Let’s explore how the authentication flow works in the OAuth2.0 resource server. Take a look at the following diagram:
![Figure 6.1 – A token authentication flow using the OAuth 2.0 resource server](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/mdn-api-dev-spr6-spbt3/img/Figure_06.1_B19349.jpg)

Figure 6.1 – A token authentication flow using the OAuth 2.0 resource server
Let’s understand the flow depicted in *Figure 6**.1*:

1.  The client sends a `GET` HTTP request to `/api/v1/addresses`.
2.  `BearerTokenAuthenticationFilter` comes into action. If the request doesn’t contain the `Authorization` header, then `BearerTokenAuthenticationFilter` does not authenticate the request since it did not find the bearer token. It passes the call to `FilterSecurityInterceptor`, which does the authorization. It throws an `AccessDeniedException` exception (marked as `ExceptionTranslationFilter` springs into action. Control is moved to `BearerTokenAuthenticationEntryPoint`, which responds with a `401 Unauthorized` status and a `WWW-Authenticate` header, with a `Bearer` value. If the client receives a `WWW-Authenticate` header with a `Bearer` value in response, it means it must retry with the `Authorization` header that holds the valid bearer token. At this stage, the request cache is `NullRequestCache` (that is, empty) due to security reasons – the client can replay the request.
3.  Let’s assume the HTTP request contains an `Authorization` header. It extracts the `Authorization` header from the HTTP request and, apparently, the token from the `Authorization` header. It creates an instance of `BearerTokenAuthentication``
Token` using the token value. `BearerTokenAuthenticationToken` is a type of `AbstractAuthenticationToken` class that implements an `Authentication` interface, representing the token/principal for the authenticated request.
4.  The HTTP request is passed to `AuthenticationManagerResolver`, which provides the `AuthenticationManager` based on the configuration. `AuthenticationManager` verifies the `BearerTokenAuthenticationToken` token.
5.  If authentication is successful, then `Authentication` is set on the `SecurityContext` instance. This instance is then passed to `SecurityContextHolder.setContext()`. The request is passed to the remaining filters for processing, then routes to `DispatcherServlet`, and then, finally, to `AddressController`.
6.  If authentication fails, then `SecurityContextHolder.clearContext()` is called to clear the context value. `ExceptionTranslationFilter` springs into action. Control is moved to `BearerTokenAuthenticationEntryPoint`, which responds with a `401 Unauthorized` status and a `WWW-Authenticate` header, with a value that contains the appropriate error message, such as the following:

    ```

    `Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Jwt expired at 2022-12-14T17:23:30Z", error_uri="https://tools.ietf. org/html/rfc6750#section-3.1".`

    ```java

Now that you have learned about the complete authentication flow using the OAuth 2.0 resource server, let’s learn the fundamentals of JWT.
Exploring the structure of JWT
You need authority in the form of permissions or rights to carry out any activity or access any information in general. This authority is known as a claim with respect to JWT. A claim is represented as a key-value pair. The key contains the claim name and the value contains the claim, which can be a valid JSON value. A claim can also be metadata about the JWT.
How to pronounce JWT
As per [`tools.ietf.org/html/rfc7519`](https://tools.ietf.org/html/rfc7519), the suggested pronunciation of *JWT* is the same as the English word *jot*.
A JWT is an encoded string that contains a set of claims. These claims are either digitally signed by a **JSON Web Signature** (**JWS**) or encrypted by **JSON Web Encryption** (**JWE**). *JWT is a self-contained way to transmit claims securely between parties*. The links to these **Request for Comments** (**RFC**)-proposed standards are provided in the *Further reading* section of this chapter.
A JWT is an encoded string such as `aaa.bbb.ccc`, consisting of the following three parts, separated by dots (`.`):

*   Header
*   Payload
*   Signature

A few websites, such as [`jwt.io`](https://jwt.io), allow you to view the content of a JWT and generate one.
Let’s have a look at the following sample JWT string. You can paste it into one of the aforementioned websites to decode the content:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c


 This sample token demonstrates how a JWT is formed and divided into three parts using dots.
Header
A `typ` key) and a signing algorithm (with an `alg` key).
This sample JWT string contains the following header:

{  "alg": "HS256",

"typ": "JWT"

}


The preceding header contains the `typ` and `alg` fields, representing the type and algorithm, respectively.
Payload
A **payload** is the second part of the JWT, which contains the claims and also comprises a Base64URL-encoded JSON string. There are three types of claims – registered, public, and private. These are outlined as follows:

*   `iss` key): This claim identifies the principal who issued a token*   `sub` key): This should be a unique value that represents the subject of the JWT*   `exp` key): This is a numeric value representing the expiration time on or after which a JWT should be rejected*   `iat` key): This claim identifies the time at which a JWT is issued*   `jti` key): This claim represents the unique identifier for a JWT*   `aud` key): This claim identifies the recipients, which JWT is intended for*   `nbf` key): This represents the time before which a JWT must be rejected
**Public claims**: These are defined by JWT issuers and must not collide with registered claims. Therefore, these should either be registered with the IANA JWT Claims registry or defined as a URI with a collision-resistant namespace.
**Private claims**: These are custom claims defined and used by the issuer and audience. They are neither registered nor public.

Here is a sample JWT string containing a payload:

{  "sub": "scott2",

"roles": [

"ADMIN"

],

"iss": "Modern API Development with Spring and

Spring Boot

"exp": 1676526792,

"iat": 1676525892

}


The preceding payload contains `sub` (subject), `iss` (issuer), `roles` (custom claim roles), `exp` (expires), `iat` (issued at), and `jti` (JWT ID) fields.
Signature
A **signature** is also a Base64-encoded string and makes up the third part of a JWT-encoded string. A signature is there to protect the content of the JWT. The content is visible but cannot be modified if the token is signed. A Base64-encoded header and payload are passed to the signature’s algorithm, along with either a secret or a public key to make the token a signed token. If you wish to include any sensitive or secret information in the payload, then it’s better to encrypt it before assigning it to the payload.
A signature makes sure that the content is not modified once it is received. The use of a public/private key enhances the security step by verifying the sender.
You can use a combination of both a JWT and JWE. However, the recommended way is to first encrypt the payload using JWE and then sign it.
We’ll use public/private keys to sign the token in the next section. Let’s jump into coding in the next section.
Securing REST APIs with JWT
In this section, you’ll secure the REST endpoints exposed in *Chapter 4*, *Writing Business Logic for APIs*. Therefore, we’ll use the code from *Chapter 4* and enhance it to secure the APIs.
The REST APIs should be protected using the following techniques:

*   No secure API should be accessed without a JWT.
*   A JWT can be generated using sign-in/sign-up or a refresh token.
*   A JWT and a refresh token should only be provided for a valid user’s username/password combination or a valid user sign-up.
*   The password should be stored in an encoded format using a `bcrypt` strong hashing function.
*   The JWT should be signed with **Rivest-Shamir-Adleman** (**RSA**) keys with a strong algorithm.

RSA
RSA is an algorithm approved by the **Federal Information Processing Standards**(**FIPS**) (FIPS 186) for digital signatures and in **Special Publication** (**SP**) (SP800-56B) for key establishment.

*   Claims in the payload should not store sensitive or secured information. If they do, then they should be encrypted.
*   You should be able to authorize API access for certain roles.

We need to include new APIs for the authorization flow. Let’s add them first.
Adding new APIs
You will enhance the existing APIs by adding four new APIs – sign-up, sign-in, sign-out, and a refresh token. The sign-up, sign-in, and sign-out operations are self-explanatory.
The refresh token provides a new access token (JWT) once the existing token expires. This is the reason why the sign-up/sign-in API provides two types of tokens – an access token and a refresh token as a part of its response. The JWT access token self-expires; therefore, a sign-out operation would only remove the refresh token.
Let’s add these APIs to the `openapi.yaml` document next.
Apart from adding the new APIs, you also need to add a new user tag for these APIs that will expose all these APIs through the `UserApi` interface. Let’s first add a sign-up endpoint.
Sign-up endpoint
Add the following specification for the sign-up endpoint in `openapi.yaml`:

/api/v1/users:  post:

tags:

- user

summary: Signup the a new customer (user)

description: Creates a new customer (user)

operationId: signUp

requestBody:

content:

application/xml:

schema:

$ref: '#/components/schemas/User'

application/json:

schema:

$ref: '#/components/schemas/User'

responses:

201:

description: For successful user creation.

content:

application/xml:

schema:

$ref: '#/components/schemas/SignedInUser'

application/json:

schema:

$ref: '#/components/schemas/SignedInUser'


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/resources/api/openapi.yaml`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/resources/api/openapi.yaml)
The sign-up API call returns the new `SignedInUser` model. This contains `accessToken`, `refreshToken`, `username`, and user ID fields. The code to add the model is shown in the following snippet:

SignedInUser:  description: Signed-in user information

type: object

properties:

refreshToken:

description: Refresh Token

type: string

accessToken:

description: JWT Token aka access token

type: string

username:

description: User Name

type: string

userId:

description: User Identifier

type: string


Now, let’s add the sign-in endpoint.
Sign-in endpoint definition
Add the following specification for the sign-in endpoint to `openapi.yaml`:

/api/v1/auth/token:  post:

tags:

- user

summary: Signin the customer (user)

description: Generates the JWT and refresh token

operationId: signIn

requestBody:

content:

application/xml:

schema:

$ref: '#/components/schemas/SignInReq'

application/json:

schema:

$ref: '#/components/schemas/SignInReq'

responses:

200:

description: Returns the access and refresh token.

content:

application/xml:

schema:

$ref: '#/components/schemas/SignedInUser'

application/json:

schema:

$ref: '#/components/schemas/SignedInUser'


The sign-in API uses the new request payload object – `SignInReq`. The object just contains the username and password fields. Let’s add it, as follows:

SignInReq:  description: Request body for Sign-in

type: object

properties:

username:

description: username of the User

type: string

password:

description: password of the User

type: string


Now, let’s add the sign-out endpoint.
Sign-out endpoint
Add the following specification for the sign-out endpoint to `openapi.yaml`:

Under the /api/v1/auth/tokendelete:

tags:

  • user

summary: Signouts the customer (user)

description: Signouts the customer (user).

operationId: signOut

requestBody:

content:

application/xml:

schema:

$ref: '#/components/schemas/RefreshToken'

application/json:

schema:

$ref: '#/components/schemas/RefreshToken'

responses:

202:

description: Accepts the request for logout.


In an ideal scenario, you should remove the refresh token of a user received from the request. You can fetch the user ID from the token itself and then use that ID to remove the refresh token from the `USER_TOKEN` table. This endpoint requires you to send a valid access token.
We have opted for an easy way to remove the token, which is for it to be sent by the user as a payload. Therefore, this endpoint needs the following new model, `RefreshToken`. Here is the code to add the model:

RefreshToken:  description: Contains the refresh token

type: object

properties:

refreshToken:

description: Refresh Token

type: string


Finally, let’s add an endpoint to refresh the access token.
Refresh token endpoint
Add the following specification for the refresh token endpoint to `openapi.yaml`:

/api/v1/auth/token/refresh:  post:

tags:

  • user

summary: Provides new JWT based on valid refresh token.

description: Provides JWT based on valid refresh token.

operationId: getAccessToken

requestBody:

content:

application/json:

schema:

$ref: '#/components/schemas/RefreshToken'

responses:

200:

description: For successful operation.

content:

application/json:

schema:

$ref: '#/components/schemas/SignedInUser'


Here, we have used an exception by defining the refresh endpoint, in terms of forming a URI that represents the refresh token resources. Ideally, a `POST` call generates the new resource defined in URI. However, this endpoint generates the access token in place of the refresh token inside the response object, `SignedInUser`.
In the existing code, we don’t have a table to store the refresh token. Therefore, let’s add one.
Storing the refresh token using a database table
You can modify the Flyway database script to add a new table, as shown in the following code snippet:

create TABLE IF NOT EXISTS ecomm.user_token (   id uuid NOT NULL DEFAULT random_uuid(),

refresh_token varchar(128),

user_id uuid NOT NULL,

PRIMARY KEY(id),

FOREIGN KEY (user_id)

REFERENCES ecomm."user"(id)

);


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/resources/db/migration/V1.0.0__Init.sql`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/resources/db/migration/V1.0.0__Init.sql)
Here, the table contains three fields – `id`, `refresh_token`, and `user_id` – for storing the row identifier (primary key), the refresh token, and the user’s ID, respectively. Also, we have put the table name `user` in double quotation marks because the H2 database also makes use of the term `"user"`.
Now, you have completed the API specification modification for authentication and authorization. Next, let’s start writing the implementation code for JWT-based authentication.
Implementing the JWT manager
Let’s add a constant class that contains all the constants related to the security functionality before we implement the JWT manager class, as shown in the following code snippet:

public class Constants {  public static final String ENCODER_ID = "bcrypt";

public static final String API_URL_PREFIX = "/api/v1/**";

public static final String H2_URL_PREFIX = "/h2-console/**";

public static final String SIGNUP_URL = "/api/v1/users";

public static final String TOKEN_URL = "/api/v1/auth/token";

public static final String REFRESH_URL =

"/api/v1/auth/token/refresh";

public static final String PRODUCTS_URL =

"/api/v1/products/**";

public static final String AUTHORIZATION =

"Authorization";

public static final String TOKEN_PREFIX = "Bearer ";

public static final String SECRET_KEY = "SECRET_KEY";

public static final long EXPIRATION_TIME = 900_000;

public static final String ROLE_CLAIM = "roles";

public static final String AUTHORITY_PREFIX = "ROLE_";

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/security/Constants.java`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/security/Constants.java)
These constants are self-explanatory, except the `EXPIRATION_TIME` long value (`900_000`), which represents 15 minutes as a time unit.
Now, we can define the JWT manager class – `JwtManager`. `java-jwt` library from [auth0.com](http://auth0.com). We will use public/private keys to sign the token. Let’s define this class, as follows:

@Componentpublic class JwtManager {

private final RSAPrivateKey privateKey;

private final RSAPublicKey publicKey;

public JwtManager(@Lazy RSAPrivateKey privateKey,

@Lazy RSAPublicKey publicKey) {

this.privateKey = privateKey;

this.publicKey = publicKey;

}

public String create(UserDetails principal) {

final long now = System.currentTimeMillis();

return JWT.create()

.withIssuer("Modern API Development with Spring…")

.withSubject(principal.getUsername())

.withClaim(

ROLE_CLAIM,

principal.getAuthorities().stream()

.map(GrantedAuthority::getAuthority)

.collect(toList()))

.withIssuedAt(new Date(now))

.withExpiresAt(new Date(now + EXPIRATION_TIME))

.sign(Algorithm.RSA256(publicKey, privateKey));

}

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/security/JwtManager.java`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/security/JwtManager.java)
Here, `JWT` is a class from the `java-jwt` library that provides a fluent API to generate the token. It adds issuer (`iss`), subject (`sub`), issued at (`iat`), and expired at (`exp`) claims.
It also adds a custom claim, `ROLE_CLAIM` (`roles`), which is populated using authorities from `UserDetails`. This is an interface provided by Spring Security. You can use the `org.springframework.security. core.userdetails.User.builder()` method to create a `UserBuilder` class. `UserBuilder` is a final builder class that allows you to build an instance of `UserDetails`.
Finally, this method (`JwtManager.create()`) signs the JWT, using `SHA256` with the RSA algorithm by calling the `sign` operation, which uses the provided public and private RSA keys. The JWT header specifies an `HS256` value for the algorithm (`alg`) claim.
Signing is done using the public and private RSA keys. Let’s add the code for RSA key management in our sample e-commerce application.
Generating the public/private keys
You can use JDK’s keytool to create a key store and generate public/private keys, as shown in the following code snippet:

$ keytool -genkey -alias "jwt-sign-key" -keyalg RSA -keystore jwt-keystore.jks -keysize 4096 输入 keystore 密码:

重新输入新密码:

请输入您的姓名和姓氏?

Unknown: 现代 API 开发

请输入您组织单位的名称?

请输入您组织的名称?

请输入您城市或地区的名称?

请输入您州或省的名称?

请输入此单位的两位字母国家代码?

使用 JWT 保护 REST API 191

CN=现代 API 开发, OU=组织单位, O=Packt, L=城市, ST=州, C=IN 是否正确?

生成 4,096 位 RSA 密钥对和自签名证书(SHA384withRSA),有效期 90 天

for: CN=现代 API 开发, OU=组织单位, O=Packt, L=城市, ST=州, C=IN


The generated key store should be placed under the `src/main/resources` directory.
Important note
Public/private keys are valid only for 90 days from the time they are generated. Therefore, make sure that you create a new set of public/private keys before you run this chapter’s code.
Required values used in the `keytool` command should also be configured in the `application.properties` file, as shown here:

app.security.jwt.keystore-location=jwt-keystore.jksapp.security.jwt.keystore-password=password

app.security.jwt.key-alias=jwt-sign-key

app.security.jwt.private-key-passphrase=password


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/resources/application.properties`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/resources/application.properties)
Now, we can configure the key store and public/private keys in the security configuration class.
Configuring the key store and keys
Let’s add a `SecurityConfig` configuration class to configure the security relation configurations. This class extends the `WebSecurityConfigurerAdapter` class. Here’s the code to do this:

@Configuration@EnableWebSecurity

@EnableGlobalMethodSecurity(prePostEnabled = true)

public class SecurityConfig {

@Value("${app.security.jwt.keystore-location}")

private String keyStorePath;

@Value("${app.security.jwt.keystore-password}")

private String keyStorePassword;

@Value("${app.security.jwt.key-alias}")

private String keyAlias;

@Value("${app.security.jwt.private-key-passphrase}")

private String privateKeyPassphrase;

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/security/SecurityConfig.java`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/security/SecurityConfig.java)
Here, we have added all the properties defined in `application.properties`.
Now, we can make use of the properties defined in `application.properties` to configure the `KeyStore`, `RSAPrivateKey`, and `RSAPublicKey` beans in the security configuration class, as shown in the following few subsections.
KeyStore bean
You can create a new bean for KeyStore by adding the following method and annotating it with `@Bean` in `SecurityConfig.java`:

@Beanpublic KeyStore keyStore() {

try {

KeyStore keyStore =

KeyStore.getInstance(KeyStore.getDefaultType());

InputStream resStream = Thread.currentThread().

getContextClassLoader().getResourceAsStream

(keyStorePath);

keyStore.load(resStream, keyStorePassword.

toCharArray());

return keyStore;

} catch (IOException | CertificateException |

NoSuchAlgorithmException | KeyStoreException e) {

LOG.error("无法加载 keystore: {}", keyStorePath, e);

keyStorePath, e);

}

throw new IllegalArgumentException

("无法加载 keystore");

}


This creates a `KeyStore` instance, using the `KeyStore` class from the `java.security` package. It loads the key store from the `src/main/resources` package and uses the password configuration in the `application.properties` file.
Let’s define the `RSAPrivateKey` bean next.
RSAPrivateKey bean
You can create a new bean for `RSAPrivateKey` by adding the following method and annotating it with `@Bean` in `SecurityConfig.java`:

@Beanpublic RSAPrivateKey jwtSigningKey(KeyStore keyStore) {

try {

Key key = keyStore.getKey(keyAlias,

privateKeyPassphrase.toCharArray());

if (key instanceof RSAPrivateKey) {

return (RSAPrivateKey) key;

}

} catch (UnrecoverableKeyException |

NoSuchAlgorithmException | KeyStoreException e) {

LOG.error("keystore 中的密钥: {}", keyStorePath, e);

}

throw new IllegalArgumentException("无法加载

private key");

}


This method uses a key alias and a private key password to retrieve the private key, which is used to return the `RSAPrivateKey` bean.
Let’s define the `RSAPublicKey` bean next.
RSAPublicKey bean
You can create a new bean for `RSAPublicKey` by adding the following method and annotating it with `@Bean` in `SecurityConfig.java`:

@Beanpublic RSAPublicKey jwtValidationKey(KeyStore keyStore) {

try {

Certificate certificate = keyStore.getCertificate

(keyAlias);

PublicKey publicKey = certificate.getPublicKey();

if (publicKey instanceof RSAPublicKey) {

return (RSAPublicKey) publicKey;

}

} catch (KeyStoreException e) {

LOG.error("keystore 中的密钥: {}", keyStorePath, e);

}

throw new IllegalArgumentException("无法加载公钥");

}


Again, a key alias is used to retrieve the certificate from the key store. Then, the public key is retrieved from the certificate and returned.
As you know, `JwtManager` uses these public and private RSA keys to sign the JWT; therefore, a JWT decoder should use the same public key to decode the token. The Spring OAuth 2.0 resource server uses the `org.springframework.security.oauth2.jwt. JwtDecoder` interface to decode the token. Therefore, we need to create an instance of the `JwtDecoder` implementation and set the same public key in it to decode the token.
The Spring OAuth 2.0 resource server provides a `NimbusJwtDecoder` implementation class of `JwtDecoder`. Let’s now create a bean of it with the public key.
JwtDecoder bean
You can create a new bean for `JwtDecoder` by adding the following method and annotating it with `@Bean` in `SecurityConfig.java`:

@Beanpublic JwtDecoder jwtDecoder(RSAPublicKey rsaPublicKey) {

return NimbusJwtDecoder.withPublicKey(rsaPublicKey).build();

}


You have defined all the beans required to sign the JWT token. Now, you can implement the newly added REST APIs.
Implementing new APIs
Let’s implement the APIs exposed using `UserApi`. This is a code part that was autogenerated using OpenAPI Codegen. First, you need to define a new entity mapped to the `user_token` table.
Coding user token functionality
You can create `UserTokenEntity` based on the `user_token` table, as shown in the following code snippet:

@Entity@Table(name = "user_token")

public class UserTokenEntity {

@Id

@GeneratedValue

@Column(name = "ID", updatable = false, nullable = false)

private UUID id;

@NotNull(message = "刷新令牌是必需的。")

@Basic(optional = false)

@Column(name = "refresh_token")

private String refreshToken;

@ManyToOne(fetch = FetchType.LAZY)

private UserEntity user;

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/entity/UserTokenEntity.java`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/entity/UserTokenEntity.java)
Similarly, we can expose the following CRUD repository for `UserTokenEntity` with the following two methods – `deleteByUserId()`, which will remove the `UserToken` table record based on a given user ID, and `findByRefreshToken()`, which will find the `UserToken` table record based on a given refresh token. The code is illustrated in the following snippet:

public interface UserTokenRepository extends   CrudRepository<UserTokenEntity, UUID> {

Optional findByRefreshToken

(StringrefreshToken);

Optional deleteByUserId(UUID userId);

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/repository/UserTokenRepository.java`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/repository/UserTokenRepository.java)
You have defined both the entity and its repository. Now, you will add new operations in `UserService` that will consume these new classes.
Enhancing the UserService class
We also need to add new methods to `UserService` for the `UserApi` interface. Let’s add new methods to the service, as follows:

UserEntity findUserByUsername(String username);Optional createUser(User user);

SignedInUser getSignedInUser(UserEntity userEntity); Optional getAccessToken(RefreshToken refToken);

void removeRefreshToken(RefreshToken refreshToken);


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/service/UserService.java`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/service/UserService.java)
Here, each method performs a specific operation, as outlined here:

*   `findUserByUsername()`: This finds and returns a user based on a given username.
*   `createUser()`: This adds a new signed-up user to the database.
*   `getSignedInUser()`: This creates a new model instance of `SignedInUser` that holds the refresh token, access token (JWT), user ID, and username.
*   `getAccessToken()`: This generates and returns a new access token (JWT) for a given valid refresh token.
*   `removeRefreshToken()`: This removes the refresh token from the database. It is called when the user wants to sign out.

Let’s implement each of these methods in the `UserServiceImpl` class.
Implementing findUserByUsername()
First, you can add the implementation for `findUserByUsername()` in `UserServiceImpl` class, as shown in the following code snippet:

public UserEntity findUserByUsername(String username) {  if (Strings.isBlank(username)) {

throw new UsernameNotFoundException("无效用户。");

}

final String uname = username.trim();

Optional oUserEntity =

repository.findByUsername(uname);

UserEntity userEntity = oUserEntity.orElseThrow

(() -> new UsernameNotFoundException(String.format(

"给定用户(%s)未找到。", uname)));

return userEntity;

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/service/UserServiceImpl.java`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/service/UserServiceImpl.java)
This is a straightforward operation. You query the database based on a given username. If the username is found, then it returns the user; otherwise, it throws a `UsernameNotFoundException` exception.
createUser() implementation
Next, you can add the implementation for the `createUser()` method to the `UserServiceImpl` class, as shown in the following code snippet:

@Transactionalpublic Optional createUser(User user) {

Integer count = repository.findByUsernameOrEmail(

user.getUsername(), user.getEmail());

if (count > 0) {

throw new GenericAlreadyExistsException

("使用不同的用户名和电子邮件。");

}

UserEntity userEntity = repository.save(toEntity(user));

return Optional.of(createSignedUserWithRefreshToken(

userEntity));

}


Here, we first check whether an existing user was assigned the same username or email in the sign-up request. If there was, an exception is simply raised; otherwise, a new user is created in the database and a `SignedInUser` instance is returned with refresh and access tokens, using the `createSignedUserWithRefreshToken()` method.
First, we can add a private `createSignedUserWithRefreshToken()` method in the `UserServiceImpl` class, as shown in the following code snippet:

private SignedInUser createSignedUserWithRefreshToken(   UserEntity userEntity) {

return createSignedInUser(userEntity)

.refreshToken(createRefreshToken(userEntity));

}


This also uses another private method, `createSignedInUser()`, which returns `SignedInUser`; then, it adds the refresh token by calling the `createRefreshToken()` method.
Let’s define the two `createSignedInUser()` and `createRefreshToken()` private methods in the `UserServiceImpl` class, as shown in the following code snippet:

private SignedInUser createSignedInUser(UserEntity uEntity) {  String token = tokenManager.create(

org.springframework.security.core.userdetails

.User.builder()

.username(userEntity.getUsername())

.password(userEntity.getPassword())

.authorities(Objects.nonNull

(userEntity.getRole()) ?

userEntity.getRole().name() : "")

.build());

return new SignedInUser()

.username(userEntity.getUsername())

.accessToken(token)

.userId(userEntity.getId().toString());

}

private String createRefreshToken(UserEntity user) {

String token = RandomHolder.randomKey(128);

userTokenRepository.save(new UserTokenEntity().

setRefreshToken(token).setUser(user));

return token;

}


Here, `tokenManager` is used in the `createSignedIn()` method to create the JWT. `tokenManager` is an instance of `JwtManager`. The `User.builder()` method is used to create a `UserBuilder` class. `UserBuilder`, which is a final builder class, is used to create an instance of `UserDetails`. The `JwtManager.create()` method uses this `UserDetails` instance to create a token.
The `createRefreshToken()` method uses the `RandomHolder` private static class to generate a refresh token. This token is not a JWT; we can use a longer-lasting valid token, such as one valid for a day, as a refresh token. Saving a JWT as a refresh token in the database removes the sole purpose of using the JWT because it expires by the configured time, and it should not be stored in the database, as it automatically becomes invalid. Therefore, we should think carefully before using a JWT as a refresh token and then saving it in the database.
Let’s add the `RandomHolder` private static class to the `UserServiceImpl` class, as shown in the following code snippet:

// https://stackoverflow.com/a/31214709/109354private static class RandomHolder {

static final Random random = new SecureRandom();

public static String randomKey(int length) {

return String.format("%"+length+"s",new BigInteger

(length * 5 /base32, 2⁵/,random).toString(32)).

replace('\u0020', '0');

}

}


This class uses a `SecureRandom` instance to generate a random `BigInteger` instance. Then, this random `BigInteger` value is converted into a string with a radix size of `32`. Finally, the space is replaced with `0` if found in a converted string.
You can also use the `org.apache.commons.lang3.RandomStringUtils. randomAlphanumeric()` method, or use any other secured random key generator, to generate a refresh token.
We also need to modify the `UserRepository` class to add a new method that returns the count of users with a given username or email.
getSignedInUser() implementation
The implementation of the `getSignedInUser()` method is straightforward. Add it to the `UserServiceImpl` class, as shown in the following code snippet:

@Transactionalpublic SignedInUser getSignedInUser(UserEntity userEntity) {

userTokenRepository.deleteByUserId(userEntity.getId());

return createSignedUserWithRefreshToken(userEntity);

}


Here, this method first removes the existing token from the database associated with the given user, and then it returns the new instance of `SignedInUser` that was created using `createSignedUserWithRefreshToken()`, defined previously in the *createUser()* *implementation* subsection.
getAccessToken() implementation
The implementation of the `getAccessToken()` method is, again, straightforward. Add it to the `UserServiceImpl` class, as shown in the following code snippet:

public Optional getAccessToken(   RefreshToken refreshToken) {

return userTokenRepository

.findByRefreshToken(refreshToken.getRefreshToken())

.map(ut ->

Optional.of(createSignedInUser(ut.getUser())

.refreshToken(refreshToken.getRefreshToken())))

.orElseThrow(() ->

new InvalidRefreshTokenException

("Invalid token."));

}


First, the `getAccessToke()` method finds the user’s token entity using the `UserTokenRepository` instance. Then, it populates the `SignedInUser` POJO using the retrieved `UserToken` entity. The `createSignedInUser()` method does not populate the refresh token; therefore, we assign the same refresh token back. If it does find the user token entry in the database based on the refresh token, it throws an exception.
Also, you can add a validation for time that will remove/invalidate the refresh token, which has not been added here for simplicity.
You can also add a time validation logic for the refresh token – for example, storing the refresh token creation time in the database and using the configured valid time for refresh token validation, which is a kind of expiration logic for JWTs.
removeRefreshToken() implementation
You can add the `removeRefreshToken()` method to the `UserServiceImpl` class, as shown in the following code snippet:

public void removeRefreshToken(RefreshToken refreshToken) { userTokenRepository

.findByRefreshToken(refreshToken.getRefreshToken())

.ifPresentOrElse(

userTokenRepository::delete,

() -> {

throw new InvalidRefreshTokenException

("Invalid token.");

});

}


First, the method finds the given refresh token in the database. If this is not found, then it throws an exception. If the given refresh token is found in the database, then it deletes it.
You have implemented all the extra methods added to the `UserService` class. Now, you will add additional methods in `UserRespository` too in the following section.
Enhancing the UserRepository class
Let’s add the `findByUsername()` and `findByUsernameOrEmail()` methods to `UserRepository`, as follows:

public interface UserRepository extends   CrudRepository<UserEntity, UUID> {

Optional findByUsername(String username);

@Query( value = "select count(u.*) from ecomm."user" u

where u.username = :username or u.email = :email",

nativeQuery = true)

Integer findByUsernameOrEmail(String username,

String email);

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/repository/UserRepository.java`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/repository/UserRepository.java)
The `findByUsernameOrEmail` method returns a count of the records matching the given username or email.
You are now ready to implement the new APIs added to the `UserApi` interface to write the REST controllers. Let’s do that next.
Implementing the REST controllers
In the previous section, you developed and enhanced the services and repositories required to implement the APIs defined in the `UserApi` interface, generated by OpenAPI Codegen. The only pending dependency is `PasswordEncoder`. `PasswordEncoder` is required to encode the password before storing and matching the given password in the sign-in request.
Adding a bean for PasswordEncoder
You should expose the `PasswordEncoder` bean because Spring Security needs to know which encoding you want to use for password encoding, as well as for decoding the passwords. Let’s add a `PasswordEncoder` bean to `AppConfig`, as follows:

@Beanpublic PasswordEncoder passwordEncoder() {

Map<String, PasswordEncoder> encoders =

Map.of(

ENCODER_ID, new BCryptPasswordEncoder(),

"pbkdf2", Pbkdf2PasswordEncoder.

defaultsForSpringSecurity_v5_8(),

"scrypt", ScryptPasswordEncoder

.defaultsForSpringSecurity_v5_8());

return new DelegatingPasswordEncoder

(ENCODER_ID, encoders);

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/AppConfig.java`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/AppConfig.java)
Here, you can directly create a new instance of `BcryptPasswordEncoder` and return it for `bcrypt` encoding. However, the use of `DelegatingPasswordEncoder` not only allows you to support existing passwords but also facilitates migration to a new, better encoder if one is available in the future. This code uses `Bcrypt` as a default password encoder, which is the best among the currently available encoders.
For `DelegatingPasswordEncoder` to work, you need to add a hashing algorithm prefix such as `{bcrypt}` to encoded passwords – for example, add `{bcrypt}$2a$10$neR0EcYY5./tLVp4litNyuBy/ kfrTsqEv8hiyqEKX0TXIQQwC/5Rm` to the persistent store if you already have a hashed password in the database, or if you’re adding any seed/test users to the database script. The new password will store the password with a prefix anyway, as configured in the `DelegatingPasswordEncoder` constructor. You have passed `bcrypt` into the constructor; therefore, all new passwords will be stored with a `{``bcrypt}` prefix.
`PasswordEncoder` reads the password from the persistence store and removes the prefix before matching. It uses the same prefix to find out which encoder it needs to use for matching. Now, you can start implementing the new APIs based on `UserApi`.
Implementing the Controller class
First, create a new `AuthController` class, as shown in the following code snippet:

@RestControllerpublic class AuthController implements UserApi {

private final UserService service;

private final PasswordEncoder passwordEncoder;

public AuthController(UserService s, PasswordEncoder e) {

this.service = s;

this.passwordEncoder = e;

}

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/controller/AuthController.java`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/controller/AuthController.java)
The `AuthController` class is annotated with `@RestController` to mark it as a REST controller. Then, it uses two beans, `UserService` and `PasswordEncoder`, which will be injected at the time of the `AuthController` construction.
First, let’s add the sign-in operation, as follows:

public ResponseEntity signIn(@Valid SignInReq signInReq) {  UserEntity userEntity = service

.findUserByUsername(signInReq.getUsername());

if (passwordEncoder.matches(signInReq.getPassword(),

userEntity.getPassword())) {

return ok(service.getSignedInUser(userEntity));

}

throw new InsufficientAuthenticationException

("Unauthrzed");

}


The operation first finds the user and matches the password using the `PasswordEncoder` instance. If everything goes through successfully, it returns the `SignedInUser` instance with refresh and access tokens; otherwise, it throws an exception.
Let’s add other operations to `AuthController`, as follows:

public ResponseEntity signOut(  @Valid RefreshToken refreshToken) {

service.removeRefreshToken(refreshToken);

return accepted().build();

}

public ResponseEntity signUp

(@Valid User user) {

return status(HttpStatus.CREATED)

.body(service.createUser(user).get());

}

public ResponseEntity getAccessToken(

@Valid RefreshToken refreshToken) {

return ok(service.getAccessToken(refreshToken)

.orElseThrow(InvalidRefreshTokenException::new));

}


All operations such as `signOut()`, `signUp()`, and `getAccessToken()` are straightforward, as outlined here:

*   `signOut()` uses the user service to remove the given refresh token. Ideally, you would like to get the user ID from the logged-in user’s request and remove the refresh token, based on the retrieved user ID from the request.
*   `signUp()` creates a valid new user and returns the `SignedInUser` instance as a response. Here, we haven’t added the validation of the payload for simplicity. In a real-world application, you must validate the request payload.
*   `getAccessToken()` returns `SignedInUser` with a new access token if the given refresh token is valid.

We have finished coding the controllers. Let’s configure security in the next subsection.
Configuring web-based security
This is the last puzzle to sort out the authentication and authorization piece. The `SecurityConfig` class is also annotated with `@EnableWebSecurity`. With the new version of Spring Security, you now don’t need to extend `WebSecurityConfigurerAdapter` and override the `configure()` method, as we did in the last edition of this book. Instead, you now create a bean that returns the configured instance of the `SecurityFilterChain` class.
The method (`filterChain`) that returns `SecurityFilterChain` takes `HttpSecurity` as a parameter. `HttpSecurity` contains DSL (fluent methods). You can make use of these methods to configure web-based security, such as which web paths to allow and which method to allow. Let’s make the following configurations using these fluent methods to return the `SecurityFilterChain` instance, as shown in the following code snippet from `SecurityConfig.java`:

@Beanprotected SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

http.httpBasic().disable()

.formLogin().disable()

.csrf().ignoringRequestMatchers(API_URL_PREFIX)

.ignoringRequestMatchers(toH2Console())

.and()

.headers().frameOptions().sameOrigin()

.and()

.cors()

.and()

.authorizeHttpRequests(req ->

req.requestMatchers(toH2Console()).permitAll()

.requestMatchers(new AntPathRequestMatcher(

TOKEN_URL, HttpMethod.POST.name()))

.permitAll()

.requestMatchers(new AntPathRequestMatcher(

TOKEN_URL, HttpMethod.DELETE.name())).

permitAll()

.requestMatchers(new AntPathRequestMatcher(

SIGNUP_URL, HttpMethod.POST.name()))

.permitAll()

.requestMatchers(new AntPathRequestMatcher(

REFRESH_URL, HttpMethod.POST.name())).

permitAll()

.requestMatchers(new AntPathRequestMatcher(

PRODUCTS_URL, HttpMethod.GET.name())).

permitAll()

.requestMatchers("/api/v1/addresses/**")

.hasAuthority(RoleEnum.ADMIN.getAuthority())

.anyRequest().authenticated())

.oauth2ResourceServer(oauth2ResourceServer ->

oauth2ResourceServer.jwt(jwt ->

jwt.jwtAuthenticationConverter(

getJwtAuthenticationConverter())))

.sessionManagement().sessionCreationPolicy(

SessionCreationPolicy.STATELESS);

return http.build();

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/security/SecurityConfig.java`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/security/SecurityConfig.java)
Here, you configure the following security settings:

1.  First of all, you disable the basic authentication and form login using the `disable()` method.
2.  Then, you ignore the CSRF configuration for the API base path and H2 console URLs.
3.  Then, you set the headers setting for frame options that have the same origin to allow the H2 console application to work fine in the browser. The H2 console UI is based on HTML frames. The H2 console UI won’t display in browsers because, by default, the security header (`X-Frame-Options`) is not sent with permission to allow frames with the same origin. Therefore, you need to configure `headers().frameOptions().sameOrigin()`.
4.  Then, you enable the CORS setting. You’ll learn more about this in the next section.
5.  Then, you configure the authorization of the request, which takes the request object as a parameter. You use this request object to restrict access, based on URL patterns, by using the `requestMatchers()` method and an instance of the `AntPathRequestMatcher` class:
    *   Configure URL patterns and respective HTTP methods using `mvcMatchers()`, which uses the same pattern-matching style as a Spring `Static method toH2Console()` is a utility that provides a matcher that includes the H2 console location.
    *   The `/api/v1/addresses/** ` pattern has been configured to be accessed only by the user who has the `ADMIN` role, by calling `hasAuthority()` and passing the admin authority in it. You’ll learn more about it in the *Understanding authorization* section of this chapter.
6.  All URLs, except those configured explicitly by `authorizeHttpRequests()`, should be allowed by any authenticated user (by using `anyRequest(). authenticated()`).
    *   Enable JWT bearer token support for the OAuth 2.0 resource server (`oauth2ResourceServer.jwt()`).
    *   Enable the `STATELESS` session creation policy (that is, `sessionManagement().sessionCreationPolicy` won’t create any `HTTPSession`)
7.  Finally, the `filterChain` method returns `SecurityFilterChain` by building the instance from the configured `HttpSecurity` instance (the `http.build()` call).

In this section, you learned how to configure Spring security for authentication and authorization. Next, we will learn about the CORS and CSRF.
Configuring CORS and CSRF
Browsers restrict cross-origin requests from scripts for security reasons. For example, a call from `http://mydomain.com` to `http://mydomain-2.com` can’t be made using a script. Also, an origin not only indicates a domain but also includes a scheme and a port.
Before hitting any endpoint, the browser sends a pre-flight request using the HTTP method option to check whether the server will permit the actual request. This request contains the following headers:

*   The actual request’s headers (`Access-Control-Request-Headers`).
*   A header containing the actual request’s HTTP method (`Access-Control- Request-Method`).
*   An `Origin` header that contains the requesting origin (scheme, domain, and port).
*   If the response from the server is successful, then only the browser allows the actual request to fire. The server responds with other headers, such as `Access- Control-Allow-Origin`, which contains the allowed origins (an asterisk `*` value means any origin), `Access-Control-Allow-Methods` (allowed methods), `Access-Control-Allow-Headers` (allowed headers), and `Access-Control-Max-Age` (allowed time in seconds).

You can configure CORS to take care of cross-origin requests. For that, you need to make the following two changes:

*   Add a `CorsConfigurationSource` bean that takes care of the CORS configuration using a `CorsConfiguration` instance.
*   Add the `cors()` method to `HTTPSecurity` in the `configure()` method. The `cors()` method uses `CorsFilter` if a `corsFilter` bean is added; otherwise, it uses `CorsConfigurationSource`. If neither is configured, then it uses the Spring MVC pattern inspector handler.

Let’s now add the `CorsConfigurationSource` bean to the `SecurityConfig` class.
The default permitted values (`new CorsConfiguraton(). applyPermitDefaultValues()`) configure CORS for any origin (`*`), all headers, and simple methods (`GET`, `HEAD`, and `POST`), which have an allowed max age of `30` minutes.
You need to allow mostly all of the HTTP methods, including the `DELETE` method, and you need more custom configuration; therefore, we will use the following bean definition in `SecurityConfig.java`:

@BeanCorsConfigurationSource corsConfigurationSource() {

CorsConfiguration configuration = new CorsConfiguration();

configuration.setAllowedOrigins(List.of("*"));

configuration.setAllowedMethods(Arrays.asList("HEAD",

"GET", "PUT", "POST", "DELETE", "PATCH"));

// For CORS response headers

configuration.addAllowedOrigin("*");

configuration.addAllowedHeader("*");

configuration.addAllowedMethod("*");

UrlBasedCorsConfigurationSource source = new

UrlBasedCorsConfigurationSource();

source.registerCorsConfiguration("/**", configuration);

return source;

}


Here, you create a `CorsConfiguration` instance using the default constructor and then set the allowed origins, allowed methods, and response headers. Finally, you pass it as an argument while registering it to the `UrlBasedCorsConfigurationSource` instance and returning it.
In the previous section, inside the `SecurityChainFilter` method annotated with `@Bean`, you have configured CSRF using the `csrf()` DSL. We have applied CSRF protection to all URLs, except URLs starting with `/api/v1` and the `/h2-console` H2 database console URLs. You can change the configuration based on your requirement.
Let’s first understand what CSRF/XSRF is. **CSRF** or **XSRF** stands for **cross-site request forgery**, which is a web security vulnerability. To understand how this vulnerability comes into effect, let’s assume you are a bank customer and are currently signed in to your account online. While you are logged in, you may receive an email and click on a link in it, or on any other malicious website’s link, that contains a malicious script. This script can then send a request to your bank for a fund transfer. The bank then transfers the funds to a perpetrator’s account because the bank thinks that the request has been sent by you, as you are signed in. Hackers can use this vulnerability similarly for different hacking activities.
To prevent such attacks, the application sends new unique CSRF tokens associated with the signed-in user for each new request. These tokens are stored in hidden form fields. When a user submits a form, the same token should be sent back with the request. The application then verifies the CSRF token and only processes the request if the verification is successful. This works because malicious scripts can’t read the token due to the same origin policy.
However, if a perpetrator also tricks you into revealing the CSRF token, then it is very difficult to prevent such attacks. You can disable CSRF protection for this web service by using `csrf().disable()` because we only expose REST endpoints.
Now, let’s move on to the final section, where you will configure the authorization based on the user’s role.
Understanding authorization
Your valid username/password or access token for authentication gives you access to secure resources, such as URLs, web resources, or secure web pages. Authorization is one step ahead; it allows you to configure access security further with scopes such as read, write, or roles such as Admin, User, and Manager. Spring Security allows you to configure any custom authority.
We will configure three types of roles for our sample e-commerce app – namely, Customer (user), Admin, and Customer Support Representative (CSR). Obviously, each user will have their own specific authority. For example, a user can place an order and buy stuff online but should not be able to access the CSR or admin resources. Similarly, a CSR should not be able to have access to admin-only resources. A security configuration that allows authority or role-based access to resources is known as authorization. A failed authentication should return an HTTP `401` status (unauthorized), and a failed authorization should return an HTTP `403` status (forbidden), which means the user is authenticated but does not have the required authority/role to access the resource.
Let’s introduce these three roles in a sample e-commerce app, as shown in the following code snippet:

public enum RoleEnum implements GrantedAuthority {  USER(Const.USER), ADMIN(Const.ADMIN), CSR(Const.CSR);

private String authority;

RoleEnum(String authority) {

this.authority = authority;

}

@JsonCreator

public static RoleEnum fromAuthority(String authority) {

for (RoleEnum b : RoleEnum.values()) {

if (b.authority.equals(authority)) {

return b;

}

}

}

@Override

public String toString() {

return String.valueOf(authority);

}

@Override

@JsonValue

public String getAuthority() {

return authority;

}

public class Const {

public static final String ADMIN = "ROLE_ADMIN";

public static final String USER = "ROLE_USER";

public static final String CSR = "ROLE_CSR";

}

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/entity/RoleEnum.java`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/entity/RoleEnum.java)
Here, we declared an `enum` that implements Spring Security’s `GrantedAuthority` interface to override the `getAuthority()` method. `GrantedAuthority` is an authority granted to an `Authentication` (interface) object. As you know, `BearerTokenAuthenticationToken` is a type of `AbstractAuthenticationToken` class that implements the authentication interface, which represents the token/principal for an authenticated request. We have used the string constants for the user’s roles in this `enum`, as we need these when we configure the role-based restriction at a method level.
Let’s discuss the role and authority in detail.
Role and authority
An authority can be assigned for fine-grained control, whereas roles should be applied to large sets of permissions. A role is an authority that has the `ROLE_` prefix. This prefix is configurable in Spring Security.
Spring Security provides the `hasRole()` and `hasAuthority()` methods to apply role- and authority-based restrictions. `hasRole()` and `hasAuthority()` are almost identical, but the `hasRole()` method maps with `Authority` without the `ROLE_` prefix. If you use `hasRole` (`'ADMIN'`), your `Admin` enum must be `ROLE_ADMIN` instead of `ADMIN` because a role is an authority and should have a `ROLE_` prefix, whereas if you use `hasAuthority` (`'ADMIN'`), your `Admin` enum must be only `ADMIN`.
The OAuth 2.0 resource server, by default, populates authorities based on the scope (`scp`) claim. If you provide access to a user’s resources, such as order history for integration with another application, then you can limit an application’s access to a user’s account before granting access to other applications for third-party integration. Third-party applications can request one or more scopes; this information is then presented to the user on the consent screen, and the access token issued to the application will be limited to the scopes granted. However, in this chapter, we haven't provided OAuth 2.0 authorization flows and will limit security access to REST endpoints.
If the JWT contains a claim with the name *scope* (`scp`), then Spring Security will use the value in that claim to construct the authorities by prefixing each value with `SCOPE_`. For example, if a payload contains a `scp=["READ","WRITE"]` claim, this means that an `Authority` list will consist of `SCOPE_READ` and `SCOPE_WRITE`.
We need to change the default authority mapping behavior because a scope (`scp`) claim is the default authority for the OAuth2.0 resource server in Spring. We can do that by adding a custom authentication converter to `JwtConfigurer` in `OAuth2ResourceServer` in your security configuration. Let’s add a method that returns the converter, as follows:

private Converter<Jwt, AbstractAuthenticationToken>   getJwtAuthenticationConverter() {

JwtGrantedAuthoritiesConverter authorityConverter =

new JwtGrantedAuthoritiesConverter();

authorityConverter.setAuthorityPrefix(AUTHORITY_PREFIX);

authorityConverter.setAuthoritiesClaimName(ROLE_CLAIM);

JwtAuthenticationConverter converter =

new JwtAuthenticationConverter();

converter.setJwtGrantedAuthoritiesConverter(authorityConverter);

return converter;

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/security/SecurityConfig.java`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/src/main/java/com/packt/modern/api/security/SecurityConfig.java)
Here, we first create a new instance of `JwtGrantedAuthorityConverter` and then assign an authority prefix (`ROLE_`) and authority claim name (the key of the claim in JWT) as `roles`.
Now, we can use this private method to configure the OAuth 2.0 resource server. You can now modify the existing configuration with the following code. We can also add configuration to add role-based restrictions to the `POST` `/api/v1/addresses` API call, in the following code snippet in `SecurityConfig.java`:

.requestMatchers("/api/v1/addresses/**")     .hasAuthority(RoleEnum.ADMIN.getAuthority())

.anyRequest().authenticated())

.oauth2ResourceServer(oauth2ResourceServer ->

oauth2ResourceServer.jwt(jwt ->

jwt.jwtAuthenticationConverter(

getJwtAuthenticationConverter())))


After setting this configuration to add an address (`POST /api/v1/addresses`), it now requires both authentication and authorization. This means the logged-in user must have the `ADMIN` role to call this endpoint successfully. Also, we changed the default claim from scope to role.
Now, we can proceed further with method-level, role-based restrictions. Spring Security provides a feature that allows you to place authority- and role-based restrictions on public methods of Spring beans, using a set of annotations such as `@PreAuthorize`, `@Secured`, and `@RolesAllowed`. By default, these are disabled; therefore, you need to enable them explicitly.
Let’s enable these by adding the `@EnableGlobalMethodSecurity(prePostEnabled = true)` annotation to the Spring Security configuration class, as follows:

@Configuration@EnableWebSecurity

@EnableGlobalMethodSecurity(prePostEnabled = true)

public class SecurityConfig {


Now, you can use the `@PreAuthorize` (the given access-control expression would be evaluated before the method invocation) and `@PostAuthorize` (the given access-control expression would be evaluated after the method invocation) annotations to place restrictions on public methods of Spring beans because you have set the `prePostEnabled` property to `true` when enabling the global method-level security.
`@EnableGlobalMethodSecurity` also supports the following properties:

*   `securedEnabled`: This allows you to use `@Secured` annotation on public methods.
*   `jsr250Enabled`: This allows you to use JSR-250 annotations such as `@RolesAllowed`, which can be applied to both public classes and methods. As the name suggests, you can use a list of roles for access restrictions.

`@PreAuthorize` and `@PostAuthorize` are more powerful than the other security annotations because not only can they be configured for authorities/roles but also for any valid **Spring Expression Language** (**SpEL**) expression:

为了演示目的,让我们向 deleteAddressesById()方法添加@PreAuthorize 注解,该方法与 AddressController 中的 DELETE /v1/auth/addresses/{id}相关联,如下代码片段所示:@PreAuthorize("hasRole('" + Const.ADMIN + "')")

@Override

public ResponseEntity deleteAddressesById(String id) {

service.deleteAddressesById(id);

return accepted().build();

}


Let’s break down the preceding code snippet:

*   `hasRole()` is a built-in `SpEL` expression. We need to pass a valid `SpEL` expression, and it should be a string. Any variable used to form this `SpEL` expression should be final. Therefore, we have declared the final string constants in the `RoleEnum` enum (for example, `Const.ADMIN`).
*   Now, the `DELETE /api/v1/addresses/{id}` REST API can only be invoked if the user has the `ADMIN` role.
*   Spring Security provides various built-in `SpEL` expressions, such as `hasRole()`. Here are some others:
    *   `hasAnyRole(String… roles)`: This returns `true` if the principal’s role matches any of the given roles.
    *   `hasAuthority(String authority)`: This returns `true` if the principal has given authority. Similarly, you can also use `hasAnyAuthority(String… authorities)`.
    *   `permitAll`: This returns `true`.
    *   `denyAll`: This returns `false`.
*   `isAnonymous()`: This returns `true` if the current user is anonymous.
*   `isAuthenticated()`: This returns `true` if the current user is not anonymous.

A full list of these expressions is available at [`docs.spring.io/spring-security/site/docs/3.0.x/reference/el-access.html`](https://docs.spring.io/spring-security/site/docs/3.0.x/reference/el-access.html).
Similarly, you can apply access restrictions for other APIs. Let’s test security in the next section.
Testing security
By now, you must be looking forward to testing. You can find the API client collection at the following location. You can import it and then test the APIs, using any API client that supports the HAR type file import: [`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/Chapter06-API-Collection.har`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/Chapter06-API-Collection.har).
Important note
Make sure to generate the keys again, as keys generated by the JDK keytool are only valid for 90 days.
Building and running the Chapter 06 code
You can build the code by running `gradlew clean build` from the root of the project, and you can run the service using `java -jar build/libs/Chapter06-0.0.1-SNAPSHOT.jar`. Make sure to use Java 17 in the path.
Now, let’s test our first use case.
Let’s fire the `GET /api/vi/addresses` API without the `Authorization` header, as shown in the following command:

$ curl -v 'http://localhost:8080/api/v1/addresses' -H 'Content- Type: application/json' -H 'Accept: application/json'< HTTP/1.1 401

< Vary: Origin

< Vary: Access-Control-Request-Method

< Vary: Access-Control-Request-Headers

< WWW-Authenticate: Bearer

< X-Content-Type-Options: nosniff

< X-XSS-Protection: 1; mode=block

< Cache-Control: no-cache, no-store, max-age=0, must

revalidate

< 其他信息因简洁性而被删除


This returns the HTTP `401` status (`unauthorized`) and a `WWW-Authenticate: Bearer` response header, which suggests the request should be sent with an `Authorization` header.
Let’s send the request again with an invalid token, as shown in the following command:

$ curl -v 'http://localhost:8080/api/v1/addresses' -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9…rest of the JWT string removed for brevity'

< HTTP/1.1 401

< Vary: Origin

< Vary: Access-Control-Request-Method

< Vary: Access-Control-Request-Headers

< WWW-Authenticate: Bearer

< 其他信息因简洁性而被删除


Again, it returns the `401` response.
We have created two users using a Flyway database migration script – `scott/tiger` and `scott2/tiger`. Now, let’s perform a sign-in with the username `scott` to get the valid JWT, as follows:

$ curl -X POST http://localhost:8080/api/v1/auth/token -H  'Accept: application/json' -H  'Content-Type: application/json' -d '{    "username": "scott",

"password": "tiger"

}'

{"refreshToken":"9rdk5b35faafkneqg9519s6p4tbbqcdt412t7h5st9savkonqnnd 5e8ntr334m8rffgo6bio1vglng1hqqse3igdonoabc971lpdgt3bjoc3je3m81ldp2au vimts8p6","accessToken":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWI iOiJzY290dCIsInJvbGVzIjpbIlVTRVIiXSwiaXNzIjoiTW9kZXJuIEFQSSBEZXZlbG9 wbWVudCB3aXRoIFNwcmluZyBhbmQgU3ByaW5nIEJvb3QiLCJleHAiOjE2Nzc0OTYzMzk sImlhdCI6MTY3NzQ5NTQzOX0.a77O7ZbSAOw5v6Tb3w-MtBwotMEUvc1H1y2W0IU2QJh0m lSJxSBCfdrNBl0mVk1HnwX4kOpj4grbNasBjpIpHtyOLXdp-gngxdvVfaKSPuptrW4YzA3 ikxbUMWDdEtij_y2DRxJXQ6CrPTjA40L7yB_SXswnHT988Qq6ZALeGW-Lmz-vzAZiRcZUe 6bPPn7F-p4lK_qi1nsUJ1rnWmmffLWCH37ztllcgh6bB1UJuOn9Hw2A1nQExfUutRKgFK0 -LxBUOKOKdRESOnJR9hwOL6v10IFl9xNm53LVMIcuJrndCxvmv7mv0fUOxY63UwhO9kOT RCXViGKCa3H8RxUFwG52q2nZelle_4I8CUSeDDdmD2Rlax2NyQNe-HHEJb9c91JSzhFm0 K0-c34-kiNGqaB3jljndHoGXCBLM5prphlSdlV4U9PYhmL8ZCaDv8q6rCPSAEcRoiOBPPn dxEApHKulj9vrO_p7K1T9dLamJSFJKw9Yz8M3_ngiE3qtEBQ3tEUFkZsJpGop5HIxrkB0O e7L_oETir_wUe1qs8AIZcKSwP9X6fpUuOlONKDpDc-f-n5PjEAvts3BcxuM8Jrw80F6z6T OJrcikrMt8DGaIXs2WHNP7C605l-JgwCVuZz_8S4LLtaCFnqq4xLU1Gy2qj5CBbALVoFcB fjoVLN2fq4","username":"scott","userId":"a1b9b31d-e73c-4112-af7c-b68530f38222"}


This returns with both a refresh and an access token. Let’s use this access token to call the `GET /api/v1/addresses` API again (please note that the `Bearer` token value in the `Authorization` header is taken from the response of the previous `POST /api/v1/ auth/token` API call). The command is shown in the following block:

$ curl -v 'http://localhost:8080/api/v1/addresses' -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGci…rest of the JWT string removed for brevity'

< HTTP/1.1 403

< Vary: Origin

< Vary: Access-Control-Request-Method

< Vary: Access-Control-Request-Headers

< WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"


This command execution returns `403`. It means that the user was authenticated successfully. However, the user doesn’t contain the required role to access the endpoint.
Let’s try again, with user `scott2` this time, who has an ADMIN role:

$ curl -X POST http://localhost:8080/api/v1/auth/token -H 'Accept: application/json' -H 'Content-Type: application/json' -d '{    "username": "scott2",

"password": "tiger"

}'

{"refreshToken":"a6hidhaeb8scj3p6kei61g4a649dghcf5jit1v6rba2mn92o0dm0 g6gs6qfh7suiv68p2em0t0nnue8unm10bg079f39590jig0sccisecim5ep3ipuiu29ce aoc793h","accessToken":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9…","username":"scott2","userId":"a1b9b31d-e73c-4112-af7c-b68530f38223"}

Some of the output removed for brevity

$ curl -v 'http://localhost:8080/api/v1/addresses' -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9… rest of the token truncated for brevity'

[{"links":[{"rel":"self","href":"http://localhost:8080/a731fda1-aaad-42ea-bdbc-a27eeebe2cc0"},{"rel":"self","href":"http://localhost:8080/api/v1/addresses/a731fda1-aaad-42ea-bdbc-a27eeebe2cc0"}],"id":"a731fda1-aaad-42ea-bdbc-a27eeebe2cc0","number":"9I-999","residency":"Fraser Suites Le Claridge","street":"Champs-Elysees","city":"Paris","state":"Île-de-France","country":"France","pincode":"75008"}]


This time, the call is successful. Now, let’s use the refresh token to get a new access token, as follows:

$  curl -X POST 'http://localhost:8080/api/v1/auth/token/refresh' -H 'Content-Type: application/json' -H 'Accept: application/json' -d '{"refreshToken": "a6hidhaeb8scj3p6kei61g4a649dghcf5jit1v6rba2mn92o0dm0 g6gs6qfh7suiv68p2em0t0nnue8unm10bg079f39590jig0sccisecim5ep3ipuiu29ce aoc793h"'

}'

{"refreshToken":"a6hidhaeb8scj3p6kei61g4a649dghcf5jit1v6rba2mn92o0dm0g 6gs6qfh7suiv68p2em0t0nnue8unm10bg079f39590jig0sccisecim5ep3ipuiu29ceao c793h","accessToken":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9… rest of the token truncated for brevity","username":"scott2","userId":"a1b9b31d-e73c-4112-af7c-b68530f38223"}


This time, it returns a new access token with the same refresh token given in the payload.
If you pass an invalid refresh token while calling the refresh token API, it will provide the following response:

{  "errorCode":"PACKT-0010",

"message":"Requested resource not found. Invalidtoken.",

"status":404,

"url":"http://localhost:8080/api/v1/auth/token/refresh",

"reqMethod":"POST","timestamp":"2023-02-27T11:13:27.183172Z"

}


Similarly, you can call other API endpoints. Alternatively, you can import the following HAR file in an API client, such as Insomnia, and then test the remaining APIs: [`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/Chapter06-API-Collection.har`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter06/Chapter06-API-Collection.har).
Summary
In this chapter, you learned about JWTs, Spring Security, authentication using filters, and JWT token validation, using filters and authentication with the Spring OAuth 2.0 resource server. You also learned how you can add CORS and CSRF protection and why these are necessary.
You also learned about access protection based on roles and authorities. You have now the skills to implement JWTs, Spring Security, and the Spring Security OAuth 2.0 resource server to protect your web resources.
In the next chapter, you will develop a sample e-commerce app’s UI using the Spring Security framework and APIs used in this chapter. This integration will allow you to understand the UI flows and how to consume REST APIs using JavaScript.
Questions

1.  What is a security context and a principal?
2.  Which is the preferred way to secure a JWT – signing or encrypting a token?
3.  What are the best practices to use a JWT?

Answers

1.  The security context stores the principal using `SecurityContextHolder` and is always available in the same thread of execution. The security context allows you to extract the principal during the flow execution and use it wherever you want. This is where a security annotation such as `@PreAuthorize` makes use of it for validation. The principal is the currently logged-in user. It can either be an instance of `UserDetails` or a string carrying a username. You can use the following code to extract it:

    ```

    Object principal = SecurityContextHolder                   .getContext().getAuthentication()                   .getPrincipal();if (principal instanceof UserDetails) {  String username =              ((UserDetails)principal).getUsername();} else {  String username = principal.toString();}

    ```java

     2.  This is a subjective question. However, it is recommended to use the signing of tokens (JWS) if a JWT doesn’t contain sensitive and private information, such as date of birth or credit card information. In such cases, you should make use of JWE to encrypt the information. If you want to use both together, then the preferred way is to use encryption for information carried by the token and then sign it with keys.
3.  You can follow the following guidelines and add to them if you discover any new ones:
    *   Make sure that JWT always has issuer and audience validations.
    *   Make sure that a JWT validation does not allow a `none` algorithm (i.e., no algorithm mentioned in JWT or when the `alg` field in the JWT header contains a `none` value). Instead, make sure that you have verification in place that checks the specific algorithm (whatever you configured) and a key.
    *   Keep an eye on the **National Vulnerability** **Database** (**NVD**).
    *   Don’t use a weak key (secret). Instead, use the asymmetric private/public keys with SHA 256, SHA 384, and SHA 512.
    *   Use a minimum key size of 2,048 for normal cases and 3,072 for business cases.
    *   A private key should be used for authentication, and the verification server should use a public key.
    *   Make sure clients use the security guidelines to store the tokens, and web applications should use HTTPS for communication with servers.
    *   Make sure the web application is tested thoroughly for **cross-site scripting** (**XSS**) attacks. It is always best to use a **content security** **policy** (**CSP**).
    *   Keep a short expiration time, and use a refresh token to refresh an access token.
    *   Keep an eye on OWASP security guidelines and new threats.

Further reading

*   *Hands-On Spring Security 5.x* (video course): [`www.packtpub.com/product/hands-on-spring-security-5-x-video/9781789802931`](https://www.packtpub.com/product/hands-on-spring-security-5-x-video/9781789802931)
*   Spring Security documentation: [`docs.spring.io/spring-security/site/docs/current/reference/html5/`](https://docs.spring.io/spring-security/site/docs/current/reference/html5/)
*   JWT: [`tools.ietf.org/html/rfc7519`](https://tools.ietf.org/html/rfc7519)
*   JWS: [`www.rfc-editor.org/info/rfc7515`](https://www.rfc-editor.org/info/rfc7515)
*   JWE: [`www.rfc-editor.org/info/rfc7516`](https://www.rfc-editor.org/info/rfc7516)
*   Spring Security in-built `SpEL` expressions: [`docs.spring.io/spring-security/site/docs/3.0.x/refsseerence/el-access.html`](https://docs.spring.io/spring-security/site/docs/3.0.x/refsseerence/el-access.html)

第六章:设计用户界面

在上一章中,你使用了 Spring Security 实现了身份验证和授权;那一章还包含了所有示例电子商务应用的 API。在本章中,你将使用 React 库开发示例电子商务应用的前端。这个 UI 应用将消费上一章中开发的 API,第六章使用授权和身份验证保护 REST 端点。这个 UI 应用将是一个单页应用SPA),包括交互式组件,如登录产品列表产品详情购物车订单列表。本章将完成在线购物应用不同层之间的端到端开发和通信。到本章结束时,你将了解 SPA、使用 React 进行 UI 组件开发以及使用浏览器的内置Fetch API消费 REST API。

本章将涵盖以下主题:

  • 学习 React 基础知识

  • 探索 React 组件和其他功能

  • 设计电子商务应用组件

  • 使用 Fetch 消费 API

  • 实现身份验证

技术要求

开发和执行代码需要以下先决条件:

  • 你应该熟悉 JavaScript:数据类型变量函数循环以及数组方法,例如map()Promisesasync等。

  • Node.js 18.x 以及npm install yarn -g

  • Visual Studio CodeVS Code):这是一个免费的源代码编辑器。你可以使用任何其他你选择的源代码编辑器。

  • 当你使用create-react-app时将包含的 React 18 库。

让我们开始吧!

请访问以下链接以检查本章的代码:

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/main/Chapter07

学习 React 基础知识

React 是一个用于构建交互式和动态 UI 的声明式库,包括隔离的小组件。它有时也被称为框架,因为它与其他 JavaScript 框架(如 AngularJS)一样强大且具有可比性。然而,React 是一个库,可以与其他支持的库一起使用,包括 React Router、React Redux 等。你通常用它来开发 SPA,但它也可以用于开发全栈应用。

React 根据 MVC 架构用于构建应用视图层。你可以使用它们自己的状态构建可重用的 UI 组件。你可以使用纯 JavaScript 和 HTML 或JavaScript 语法扩展JSX)进行模板化。我们将在本章中使用 JSX,它使用虚拟文档对象模型VDOM)进行动态更改和交互。

让我们使用 create-react-app 工具创建一个新的 React 应用程序。此工具提供并构建了您将用于开发示例电子商务应用程序前端的基礎应用程序结构。

创建 React 应用程序

您可以从零开始配置和构建一个 React UI 应用程序。然而,正如所述,React 提供了一个 create-react-app 工具,用于引导和构建一个基本的运行应用程序模板。您可以将它进一步扩展以构建一个完整的 UI 应用程序。

其语法如下所示:

npm i npx. It executes the create-react-app React package directly.
Now, let’s create an `ecomm-ui` application using the following command:

$ npx create-react-app ecomm-ui 在 /Users/dev/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/Chapter07/ecomm-ui 创建新的 React 应用程序。

安装软件包。这可能需要几分钟。

使用 cra-template 安装 react、react-dom 和 react-scripts...

//… 省略输出以节省空间

在 50 秒内添加了 1418 个软件包

成功!在 /Users/sourabhsharma/dev/pws/java/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/Chapter07/ecomm-ui 创建了 ecomm-ui。

//… 省略输出以节省空间

在该目录内

我们建议您首先输入:

cd ecomm-ui

npm start


Once it has been installed successfully, you can go to the app directory and start the installed application using `create-react-app` by running the following command:

$ cd ecomm-ui$ code .


The `code .` command opens the `ecomm-ui` app project in VS Code. You can then use the following command in the terminal in VS Code to start the development server:

$ npm start


 Once the server has started successfully, it will open a new tab on your default browser at `localhost:3000`, as shown in the following screenshot:
![Figure 7.1 – Default UI app created by the create-react-app utility](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/mdn-api-dev-spr6-spbt3/img/Figure_07.1_B19349.jpg)

Figure 7.1 – Default UI app created by the create-react-app utility
Our bootstrapped React UI is up and running, but before you can build an e-commerce UI app on top of it, you need to understand the basic concepts and files generated by `create-react-app`.
Exploring the basic structures and files
A scaffolded React app contains the following directories and files inside the root project directory:

ecomm-ui├── README.md

├── node_modules

├── package.json

├── package-lock.json

├── .gitignore

├── public

│ ├── favicon.ico

│ ├── index.html

│ ├── logo192.png

│ ├── logo512.png

│ ├── manifest.json

│ └── robots.txt

└── src

├── App.css

├── App.js

├── App.test.js

├── index.css

├── index.js

├── logo.svg

├── reportWebVitals.js

└── setupTests.js


Let’s understand the main parts, as follows:

*   `node_modules`: You don’t need to make any changes here. Node-based applications keep a local copy of all the dependent packages here.
*   `public`: This directory contains all the static assets of an app, including `index.html`, images, favicon icon, and `robots.txt`.
*   `src`: This directory contains all the dynamic code, including React code and **Cascading Style Sheets** (**CSS**) (including **Syntactically Awesome Style Sheets** (**Sass**), **Leaner Style Sheets** (**Less**), and so on). It also contains the test code.
*   `package.json`: This `scripts`), and dependent packages (inside `dependencies` and `dev-dependencies`).

You can remove the `serviceWorker.js` file (if generated), the `logo.svg` file, and test files from the `src` directory for now as we are not going to use them in this chapter.
Let’s discuss the `package.json` file in the next subsection.
Understanding the package.json file
You can also view the `package.json` file that contains all the dependencies under the `dependencies` and `dev-dependencies` fields. It is similar in nature to the `build.gradle` file.
The main React libraries are `react` and `react-dom`, mentioned in the dependencies field; these are for React and the VDOM, respectively.
`package.json` also contains a script field that contains all the commands you can execute on this application. We have used the `yarn start` command to start the application in development mode. Similarly, you can execute other commands, as shown in the following code block, with `yarn` and `npm`:

"scripts": {    "start": "react-scripts start",

"build": "react-scripts build",

"test": "react-scripts test",

"eject": "react-scripts eject"

},


`react-scripts` is a CLI package installed by the `create-react-app` utility. It contains many dependencies, a few of the primary ones of which are listed here:

*   `webpack.config.js` configuration.
*   **Jest** ([`jestjs.io`](https://jestjs.io)): Jest is a JavaScript testing framework maintained by Facebook.
*   **ESLint** ([`eslint.org`](https://eslint.org)): ESLint is a linter that allows you to maintain code quality. It is very similar to *Checkstyle* in the Java world.
*   **Babel** ([`babeljs.io`](https://babeljs.io)): Babel is a JavaScript transcompiler tool that converts JavaScript code to backward-compatible JavaScript code. The latest JavaScript draft version is **ECMAScript 2020**, also referred to as **ES10**. The latest JavaScript stable version is **ECMAScript 2018** (**ES9**). Babel allows you to generate optimized backward-compatible code from JavaScript code written using the latest versions.

You can find `react-scripts` under the `dependencies` field in `package.json`. Let’s understand each of these commands, as follows:

*   `start`: This command allows you to start the development server in a node environment. It also provides the hot reload feature, which means any changes to the React code would be reflected in the application, without a restart being required. Therefore, if there are any linting or code issues, they will show up accordingly in the console (terminal window) and web browser.
*   `build`: This command packages the React application code for production deployment. It does the bundling of the JavaScript files in one, the CSS files into another, and then minifies and optimizes the code files. You can then deploy this bundle on any web server.
*   `test`: This command executes a test using the test runner (the Jest tool). It executes all test files with extensions such as `.test.js` or `.spec.js`.
*   `eject`: React comes with default build configurations such as webpack, Babel, and so on. The build configuration has the best practices already implemented to optimize the built app. This `eject` command allows you to eject the hidden configuration, after which you can override and customize the build configuration. However, you should do this with the utmost care because this is a one-way activity, and you can’t reverse it.

Let’s take a closer look at how React works in the next subsection.
Bootstrapping a React app
A web page is nothing but an HTML document. HTML documents contain the DOM, a tree-like structure of HTML elements. Any changes to the DOM are reflected in the rendering of the HTML document in the browser. Making changes in the actual DOM— and, specifically to the nth level—is a heavy operation in terms of traversal and rendering the DOM, because each change is done on the whole DOM, and this is a time- and memory-consuming operation.
React uses a VDOM to make these operations lightweight. A VDOM is an in-memory copy of the actual DOM. React maintains the VDOM using the `react-dom` package. Therefore, when you initialize the React app, you first pass the root HTML `ID` element to the `ReactDOM` object’s `render` function. React writes the VDOM under this root element after its first render.
After the first render, only the necessary changes are written to the actual DOM based on the changes in React components and their state. The React components’ `render` function returns the markup in JSX syntax. Then, React transforms it to HTML markup and compares the generated VDOM with the actual HTML DOM, and only makes the necessary changes to the actual DOM. This process then continues till the components get changed. Let’s explore how the first render takes place.
The `pubic/index.html` file contains the main HTML file. This is an application skeleton that contains the web app’s `title`, `meta` elements, and a `body` element. It also contains a `div` element (under `body`) with an `ID` as `root`. You pass this root element to the `render` function of `ReactDOM` in `index.js`, in the `src` directory. This is the entry point of the React app. Let’s have a look at its code, as follows:

import React from 'react';import ReactDOM from 'react-dom/client';

import './index.css';

import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root'));

root.render(

<React.StrictMode>

</React.StrictMode>

);


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/index.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/index.js)
Here, React uses the `ReactDOM` object from the `react-dom` package to render the page. First it creates the root object by calling the `createRoot` method and passing the `root` element. The `document.getElementById('root')` method fetches `<div id= "root">` from inside the `<body>` element of `index.html`.
The `render()` function of the `root` object contains an argument: element of type `ReactNode`. You pass an `<App />` tag component wrapped with React’s strict mode component as an element argument.
App components can be a single component or a parent component with single or multilayer child components. A single component won’t contain any other React component; it simply contains the JSX, and that’s it. However, parent components may contain one or more child components, and those child components may contain one or more child components, and so on. For example, an `App` component may have `header`, `footer`, and `content` components. A `content` component may have a `cart` component, and then the `cart` component may have items inside it.
A `<React.StrictMode>` component is a special React component that gets rendered twice in development mode to check for best practices, deprecated methods, and potential risks in your React components, and prints warnings and suggestions in the console log. It has no impact on the production build because it only works in development mode.
The `render()` function transforms the JSX of the `App` component to HTML and adds it inside the `<div id="root">` tag, then it compares the VDOM with the real DOM and makes the necessary changes in the real DOM. This is how React components get rendered on the browser.
You now understand that React components are key here. Let’s deep dive into them in the next section.
Exploring React components and other features
Each page in an app is built up using React components — for example, the **Product Listing** page of Amazon can broadly be divided into *Header*, *Footer*, *Content*, *Product List*, *Filter and Sorting options*, and *Product Card* components. You can create components in React in two ways: by using JavaScript classes or functions.
Let’s create an example header component in React with both a function and a class.
You can either write a plain old JavaScript function or an `Header` component using a JavaScript arrow function:

export default const Header = (props) => {  return (

{props.title}

)

}


Let’s create the same `Header` component using a JavaScript class, as follows:

export default class Header extends React.Component {  render() {

return (

{this.props.title}

)

}

}


Let’s understand both components point by point, as follows:

*   Both return JSX that looks like HTML, which gets rendered after transformation (from JSX to HTML).
*   Both export the function and class respectively so that they can be imported by other components.
*   Both have props—one as an argument and one bound with this scope, which is part of `React.Component`. Props represent the attributes and their values—for example, here, a `title` attribute is used. When it gets rendered, it is replaced by the `title` attribute’s value.
*   The class needs a `render()` function, whereas the function simply needs a `return` statement.

Let’s see how the `Header` component could be used. You can use this `Header` component as you would use any other HTML tag in your JSX code, as shown next:


 Here, `title` is the property of `Header` component. It describes how properties (props) of components are passed. When this `Header` component gets rendered, it will show the `title` value wrapped in an `<``H1>` element.
Let’s explore the JSX next. This is how you use the `props`: you add an attribute (such as `title`) to its value while using the component. Inside the component, you can access these attributes (properties) by using `props` directly or using the `{ title }` de-structuring form in functional components and by using `this.props` in the class components.
Exploring JSX
React components would return the JSX. You can write HTML code to design the components because JSX is very similar to HTML, except for the HTML attributes. Therefore, you need to make sure to update attributes such as `class` to `className`, `for` to `htmlFor`, `fill-rule` to `fillRule`, and so on. The advantage of using the `React.StrictMode` component is that you get a warning and a suggestion to use the correct JSX attribute names if you use HTML attributes or have a typo.
You can also put any JavaScript expressions inside JSX or an element’s attributes to make the component dynamic by using the expression wrapped in curly braces (`{}`).
Let’s have a look at some example code to understand both JSX and expressions. The following JSX code snippet has been taken from the `CartItem` component. Check out the highlighted code for expressions; the rest of the code is JSX, which is very similar to HTML:

作者:{author}

<button className="font-semibold hover:text-red-500

text-indigo-500 text-xs text-left"

onClick={() => removeItem(item.id)}>

删除


The preceding code fragment represents a cart item that shows the product image, product name, author, and `class` attribute name is changed to `className` because it is JSX. `Link` is a part of the `react-router-dom` library.
You are done with the cart item’s design part. Now, you need a mechanism to populate the values and add the event handling in it. This is where a JSX expression helps you.
You use `item` — an object that represents the cart item, and `author` — a variable that contains the author’s name. Both are part of the React component’s state. You will learn more about the state in the next subsection, but for the time being you can think of them as variables defined in the `CartItem` component. Once you write the JSX (read HTML), dynamic values (from variables) and interaction (for events) can be defined using the expressions wrapped inside curly braces (`{}`).
Let’s understand each of the expressions as follows:

*   `src={item?.imageUrl}`: You get the item (product) image URL as part of the API response. You simply assign it to the `src` attribute of the `img` tag. Note that the dot operator (`.`) allows you to access the property of an object. The code may throw an error if you try to read the property of any `null` or `undefined` object. You can avoid that by using the `?.` operator. Then, the property (in this case, `imageUrl`) will only be read if an object (in this case, `item`) is not `null` or `undefined`.
*   `to={"/products/" + item.id}`: Here, links to an attribute are formed by using the object item’s `id` property.
*   `{item?.name}`: Here, the name of the product is displayed using the name property of the `item` object.
*   `Author: {author}`: The author value is displayed using the `author` variable.
*   `onClick={() => removeItem(item.id)}`: This is the way you associate a user-defined function with an event. Here, `removeItem()` will be called by passing the item object’s `id` property on the click of a button. If you are not passing any argument or using multiple statements, then you can directly pass the function name instead of using the arrow function—for example, `onClick={removeItem}`.

Next, we will deep dive into the state of React components. Let’s see how this works.
Understanding React hooks
Components are dynamic and contain a state. The state represents the data and metadata held by the component at a given point in time. There are two levels of state: a global (app-level) state and a local (component-level) state.
Earlier (prior to React version 16.8), the state was only supported in components defined using classes. Now, React supports the state in both functional and class components. React supports the state in functional components using `useState()`, `useContext()`, and so on.
What are hooks?
Hooks are special React built-in functions or user-defined functions that can be stateful and are used to manage the side effects of React functional components. Popular and frequently used hooks are `useState()` and `useEffect()`.
React introduced hooks (a set of functions) in the 16.8 version, which introduced many features to functional components that were earlier not supported, including state and events such as `componentDidMount` (a lifecycle method in the class that indicates a component was mounted), and you can now perform certain operations such as loading data using APIs, among other things.
Let’s discuss React hooks next.
Each hook in React represents a special feature that you can use in functional components. Let’s discuss the most popular and common hooks one by one, as follows:

*   `useState`: `useState()` allows you to define and maintain the state. Let’s examine how you use this hook. First, you import the `useState()` hook at the top of the component code file, as follows:

    ```

    import {useState} from "react";

    ```java

Next, inside your component’s arrow function code, define the state before the `return` statement, as shown next:

const [total, setTotal] = useState(0);


 You need to define both state and state setter functions in an array while declaring the state. Here, the `total` state is defined with its setter function. You can use any type of state, such as an `object`, `array`, `string`, or `number`. The total state is of type `number`; therefore, it is initialized with `0`. `setTotal()` is a setter function. The setter function allows you to update the state (`total` here)—for example, you could update the total state by calling `setTotal(100)`, in which case the `total` state would be changed from `0` to `100`.
React tracks the state’s setter function and whenever it is called. React updates the state of the component and re-renders the component. The naming convention of the setter function is to prefix the state name with `set` and make the state’s first letter a capital letter. Therefore, we have used the `setTotal()` name for the `total` state. You’ll use `useState()` for local state management in most components.

*   `useEffect`: You use a `useEffect()` hook when you want to do something after rendering a component. This gets called after each render. You can also use it when you want to load the initial data from an API or add an event listener. However, if an API call should be made once, then you can pass the empty array (`[]`) dependency while calling it. You’ll find multiple instances of `useEffect` in the `ecomm-ui` code when an empty array is passed for a single call.

React recommends using multiple `useEffect` functions inside components for separating the concern. Also, make sure it returns an arrow function for cleanup. For example, when you add the event listener for any component, it should return an arrow function that removes the event listener.

*   `useContext`: You can pass props from one component to another. Sometimes, you must use props drilling to the nth level. React also provides an alternative way to define these props so that they can be used in any component in a tree without using prop drilling. You would use it for props that are common across components, such as `theme` or `isUserLoggedIn`.
*   React provides a `createContext()` function to create a context. It returns a provider and consumer to provide access to its values and changes respectively (see the next code block). However, `useContext` can easily make use of the context by removing the usage of the consumer, which is returned by `createContext()`. The following code snippet depicts `useContext` usage:

    ```

    import {createContext} from "react";import ReactDOM from "react-dom";const LoggedInContext = createContext();const App = () => {  return (    <LoggedInContext.Provider isUserLoggedIn=true>      <ProductList/>    <LoggedInContext.Provider/>  );}const ProductList = () => { return (   <LoggedInContext.Consumer> { isUserLoggedIn =>    <div>Is user logged-in: {isUserLoggedIn}</div>   } <LoggedInContext.Consumer> );}ReactDOM.render(<App/>,document.getElementById("root"));

    ```java

You can simplify the `ProductList` component’s `return` block in the previous code snippet (check the highlighted code) with `useContext`, as follows:

import {createContext, useContext} from "react";import ReactDOM from "react-dom";

const LoggedInContext = createContext();

const App = () => {

return (

<LoggedInContext.Provider isUserLoggedIn=true>

<LoggedInContext.Provider/>

);

}

const ProductList = () => {

const isUserLoggedIn = useContext(LoggedInContext);

return (

Is user logged-in: {isUserLoggedIn}

);

}

ReactDOM.render(, document.getElementById("root"));


This is how you can use `createContext` and `useContext` hooks.

*   `useReducer`: This is an advanced version of the `useState` hook that not only allows you to use a component’s state but also provides better controls to manage its state by taking the `reducer` function as a first argument. It takes the initial state as a second argument. Check out its syntax, as seen in the following code block:

    ```

    const [state, dispatch] = useReducer(reducer, initialState);

    ```java

The `reducer` function is a special function that takes state and action as arguments and returns a new state. We’ll explore this more when we build the `CartContext` component later in this chapter.
Now that you have learned the basic concepts of React, let’s add some styling to the `ecomm-ui` application using Tailwind CSS.
Styling components using Tailwind
`npm`, as shown in the following command (executing it from the project root directory):

$ npm install -D tailwindcss


 Here, version 3.2.7 of Tailwind CSS was installed.
Next, let’s configure Tailwind CSS. The `npx tailwindcss init` command generates the Tailwind CSS configuration file with the default empty values:

$ npx tailwindcss initCreated Tailwind CSS config file: tailwind.config.js


The previous command generates the following file:

/** @type {import('tailwindcss').Config} */module.exports = {

content: [],

theme: {

extend: {},

},

plugins: [],

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter07/ecomm-ui/tailwind.config.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter07/ecomm-ui/tailwind.config.js)
Now, we can modify the Tailwind CSS configuration to purge unused styles in production.
Configuration to remove unused styles in production
You would like to keep the style sheet size down in a production environment because this improves the performance of the application. You can purge unnecessary styles by adding the following filters to the `content` block of the `tailwind.config.js` file. Then, Tailwind can tree-shake unused styles while building the production build. Generated CSS files only contain the used styles in files matching the given filters. The code is illustrated in the following snippet:

module.exports = {  content: ["./src/**/*.{js,jsx,ts,tsx}",

"./public/index.html"],

theme: {

extend: {},

},

plugins: [],

}


Next, we will add Tailwind to React.
Including Tailwind in React
Open the `src/index.css` file that `create-react-app` generates for you by default and import Tailwind’s `base`, `components`, and `utilities` styles, replacing the original file contents as follows:

@tailwind base;@tailwind components;

@tailwind utilities;


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/index.css`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/index.css)
These statements import the styles generated by the build based on the Tailwind configuration when you execute the build.
Finally, make sure that the CSS file is imported in the `src/index.js` file, as shown in following code:

import React from 'react';import ReactDOM from 'react-dom/client';

import './index.css';

import App from './App';

import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(document.getElementById('root'));

// … rest of the code removed for brevity


Done! Next, execute `yarn start` to use Tailwind CSS in the `ecomm-ui` app.
However, before that, you are going to add the basic structure to your web app – the header, container (content), and footer.
Adding basic structural React components
Before adding the `Header`, `Footer`, and `Container` components, you need to remove the following files created by `create-react-app`:

*   `App.css`
*   `logo.svg`

Don’t forget to remove these file references from `/``src/App.js` too.
Then, create a `components` directory under `/src`. You will create all new components under this directory, as shown in *Figure 7**.2*. Let’s create three new components, as follows:

*   `Header`: This will be displayed at the top and contains header items such as the app name and **Login/Logout** button
*   `Container`: This will contain the main content, such as the product list
*   `Footer`: This will be displayed at the bottom and contains footer items such as the copyright information

The basic structure can be seen in the following screenshot:
![Figure 7.2 – Basic structure of the app containing the Header, Footer, and Container components](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/mdn-api-dev-spr6-spbt3/img/Figure_07.2_B19349.jpg)

Figure 7.2 – Basic structure of the app containing the Header, Footer, and Container components
Let’s add these basic components. First, let’s create a `Header` component, as shown in the following code snippet:

const Header = () => {  return (

Ecommerce App

);

};

export default Header;


Similarly, create a `Footer` component, as shown in the following code snippet:

const Footer = () => {  return (

className="text-center p-2 border-t-2 bggray-

200 border-gray-300 text-sm">

No © by Ecommerce App.{" "}

<a href=https://github.com/PacktPublishing/Modern-

API-Development-with-Spring-and-Spring-Boot>

Modern API development with Spring and

Spring Boot

);

};

export default Footer;


Next, create a `Container` component, as shown in the following code snippet:

const Container = () => {  return (

Hello, text/element would appear in container

);

};

export default Container;


And finally, modify the `/src/App.js` file as shown in the following code snippet:

import Header from "./components/Header";import Footer from "./components/Footer";

import Container from "./components/Container";

function App() {

return (

);

}

export default App;


This is how you can create and use new components. These components are in their simplest form and are kept as such to be understood more easily. However, you can find refined and improved versions of these components on GitHub, as follows:

*   *Header component* *source*: [`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/Header.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/Header.js)
*   *Footer component* *source*: [`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/Footer.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/Footer.js)
*   The `Container` component (which contains the actual content in the center) could be replaced with the `switch` component from `react-router-dom`, which would display the components based on a given `route`, such as `cart`, `orders`, and `login`.

Now, you can start writing the actual `ecomm-ui` components next.
Designing the e-commerce app components
Design is not only a key part of UX and UI work, but is also important for frontend developers. Based on the design, you can create reusable and maintainable components. Our example e-commerce app is a simple application that does not need much attention. You will create the following components in this application:

*   **Product listing component**: A component that displays all the products and acts as a home page. Each product in the list will be displayed as a card with the product name, price, and two buttons—**Buy now** and **Add to bag**. The following screenshot displays the **Product listing** page, which shows the product information along with an image of each product:

![Figure 7.3 – Product listing page (home page)](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/mdn-api-dev-spr6-spbt3/img/Figure_07.3_B19349.jpg)

Figure 7.3 – Product listing page (home page)

*   **Product detail component**: This component displays the details of a product when clicked. It displays the product image, product name, product description, tags, and the **Buy now** and **Add to bag** buttons, as shown next:

![Figure 7.4 – Product detail page](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/mdn-api-dev-spr6-spbt3/img/Figure_07.4_B19349.jpg)

Figure 7.4 – Product detail page

*   **Login component**: Login components allow a user to log in to an app by using their username and password, as illustrated in the following screenshot. This component displays an error message when a login attempt fails. Click on **Cancel** to go back to the **Product listing** page. The **Product listing** page shows a list of products a customer can buy.

![Figure 7.5 – Login page](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/mdn-api-dev-spr6-spbt3/img/Figure_07.5_B19349.jpg)

Figure 7.5 – Login page

*   `Cart` component lists all the items that have been added to the cart. Each item displays the given product image, name, description, price, quantity, and total. It also provides a button to decrease or increase the quantity, and a button to remove the item from the cart.

**Product name** is a link that takes the user back to the **Product detail** page. The **Continue** **shopping** button takes the user to the **Product listing** page. The **CHECKOUT** button performs the checkout process. On successful checkout, an order is generated and the user is redirected to the **Orders** page.
![Figure 7.6 – Cart page](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/mdn-api-dev-spr6-spbt3/img/Figure_07.6_B19349.jpg)

Figure 7.6 – Cart page

*   **Orders component**: The **Orders** page shows all orders placed by the user in a tabular form. The **Orders** table displays the order date, ordered items, order status, and order amount for each order.

The order date will be displayed in the user’s local time, but on the server it will be in **Universal Coordinated Time** (**UTC**) format. Order items will be displayed in an order list, with their quantity and unit price in brackets, as illustrated in the following screenshot:
![Figure 7.7 – Order page](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/mdn-api-dev-spr6-spbt3/img/Figure_07.7_B19349.jpg)

Figure 7.7 – Order page
Let’s start coding these components. First, you will code the **Product listing** page, which fetches the products from the backend server using the REST API.
Consuming APIs using Fetch
Let’s create the first component — that is, the `src/components` directory with the name `ProductList.js`. This is the parent component of the **Product** **Listing** page.
This component fetches the products from the backend server and passes them to the child component, `Products` (it creates a new `Products.js` file under the `src/components` directory).
`Products` contains the logic responsible for looping through the fetched product list. Each iteration renders the card UI of each product. This product card component is represented using `ProductCard`, another component. Therefore, let’s create a `ProductCard.js` file, under `src/components`.
You can write the product card code inside `products` (`product list` component), but to single out the responsibilities, it’s better to create a new component.
The `ProductCard` component has a **Buy now** button and an **Add to bag** link. These links should only work if the user is logged in, else it should redirect the user to the login page.
You now have an idea about the **Product Listing** page component tree structure. Now, our first task is to have an API client that fetches products we can render in these components.
Writing the product API client
You are going to use the `Fetch` browser built-in library as a REST API client. You can also use a third-party library such as `axios`. `Fetch` can do the job for this example app and reduce the number of third-party dependencies.
You can create a configuration file for API clients settings. Let’s name it `Config.js` and place it in the `src/api` directory.
`Config` is a JavaScript class that contains constants such as URLs and common methods such as `DefaultHeaders()` and `tokenExpired()`. Check out its code in the following snippet:

class Config {  SCHEME = process.env.SCHEME ? process.env.

SCHEME : "http";

HOST = process.env.HOST ? process.env.HOST : "localhost";

PORT = process.env.PORT ? process.env.PORT : "8080";

CART_URL = `\({this.SCHEME}://\){this.HOST}😒

{this.PORT}/api/v1/carts`;

// truncated code for brevity

defaultHeaders() {

return {

"Content-Type": "application/json",

Accept: "application/json",

};

}

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/api/Config.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/api/Config.js)
Here, you can see that we have created constants that are formed using environment variables. The `defaultHeaders()` function returns the common headers used in all API calls, and `headersWithAuthorization()` returns common headers with the `Authorization` header. `headersWithAuthorization()` uses object destruction to retrieve the default headers. The `Authorization` header is fetched from the local storage, which is set when a user is logged in successfully and is removed once the user logs out.
It also has a `tokenExpired()` function that simply checks the expiration time for a token stored in local storage. This expiration time is extracted from the access token (**JSON Web Token**, or **JWT**). It returns true if the expiration time is past the current time. Check out this function’s code in the following code snippet:

tokenExpired() {

const expDate = Number(localStorage.getItem

// src/api/Config.js

if (expDate > Date.now()) {

return false;

}

return true;

}


The `Config` class also contains a `storeAccessToken()` function that simply stores the access token and expiration time in local storage. It uses a `getExpiration()` function to extract the expiration time from the access token. Put simply, this function first extracts the payload from the token string and then decodes the payload and converts it to JSON. Finally, it returns the expiration time if a payload is a valid object, else it returns `0`. You can find these functions in the following code block:
Relevant code of src/api/Config.js

storeAccessToken(token) {  localStorage.setItem(this.ACCESS_TOKEN, Bearer ${token});

localStorage.setItem(this.EXPIRATION,

this.getExpiration(token));

}

getExpiration(token) {

let encodedPayload = token ? token.split(".")[1] : null;

if (encodedPayload) {

encodedPayload = encodedPayload.replace

(/-/g, "+").replace(/_/g, "/");

const payload = JSON.parse(window.atob

(encodedPayload));

return payload?.exp ? payload?.exp * 1000 : 0;

}

return 0;

}


Next, let’s make use of this `Config` class in the `src/api/ProductClient.js` file, as shown in the following code block. This file will act as the API client for product-related APIs:

import Config from "./Config";class ProductClient {

constructor() { this.config = new Config(); }

async fetchList() {

return fetch(this.config.PRODUCT_URL, {

method: "GET",

mode: "cors",

headers: { ...this.config.defaultHeaders(),},

})

.then((res) => Promise.all([res, res.json()]))

.then(([res, json]) => {

if (!res.ok) { return { success: false, error: json }; }

return { success: true, data: json };

}).catch((e) => {

return this.handleError(e);

});

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/api/ProductClient.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/api/ProductClient.js)
`ProductClient` is a class, and a `config` instance is instantiated in its constructor. This class contains two asynchronous functions for fetching the products: `fetchList()` and `fetch()`. The former fetches all products, and the latter is for fetching a single product based on its ID. `fetchList()` makes use of the `fetch` browser function to fetch the product list. You pass the URL as the first argument input and request an initialization object that contains the HTTP method, mode, and headers as the second argument. The `fetch` browser call returns a promise that you use to handle the request. First, you resolve both the promises – the response and response JSON and then check `response.ok`. `response.ok` returns true for a status in the `200` to `299` range. Upon a successful response, the `fetchList()` method returns an object with `data` and `success` fields as `true`. Upon an unsuccessful response, it returns success as `false` and shows an error response in the `data` field.
Similarly, you can write a `fetch` function to retrieve the product by `ID`. Everything will be the same except the URL, as you can see in the following code block:
Remaining code of src/api/ProductClient.js

async fetch(prodId) {   return fetch(this.config.PRODUCT_URL + "/" + prodId, {

if (auth?.token) fetchCart(auth);

mode: "cors",

headers: { ...this.config.defaultHeaders(),},

})

.then((res) => Promise.all([res,  res.json()]))

.then(([res, json]) => {

if (!res.ok) { return { success: false, error: json }; }

return { success: true, data: json };

}).catch((e) => {

this.handleError(e);

});

}

handleError(error) {

const err = new Map([

[TypeError, "获取响应时出现问题。"],

[SyntaxError, "解析响应时出现问题。"],

[Error, error.message],

]).get(error.constructor);

return err;

}

}

export default ProductClient;


Here, the `handleError()` function checks the type of the error (using `error. constructor`) and, based on that, returns the appropriate error message.
Please note that other API clients such as `CartClient`, `CustomerClient`, and `OrderClient` are developed in a similar fashion. The code is available at the following locations:

*   `CartClient`: [`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/api/CartClient.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/api/CartClient.js)
*   `CustomerClient`: [`github.com/PacktPublishing/Modern-API-Development-with-Spring-and-Spring-Boot/blob/main/Chapter07/ecomm-ui/src/api/CustomerClient.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-and-Spring-Boot/blob/main/Chapter07/ecomm-ui/src/api/CustomerClient.js)
*   `OrderClient`: [`github.com/PacktPublishing/Modern-API-Development-with-Spring-and-Spring-Boot/blob/main/Chapter07/ecomm-ui/src/api/OrderClient.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-and-Spring-Boot/blob/main/Chapter07/ecomm-ui/src/api/OrderClient.js)

Now, we can use `ProductClient` to fetch the products from the backend using REST APIs. Let’s code the `ProductList` component and its child components.
Coding the Product Listing page
`ProductList` is a straightforward component that loads the products after their first render using `ProductClient`. You know that for this purpose, `useEffect` hooks should be used. Let’s code it as follows:

// 其他导入 import Products from "./Products";

const ProductList = ({ auth }) => {

const [productList, setProductList] = useState();

const [noRecMsg, setNoRecMsg] = useState("正在加载...");

const { dispatch } = useCartContext();

useEffect(() => {

async function fetchProducts() {

const res = await new ProductClient().fetchList();

if (res && res.success) { setProductList(res.data); }

else { setNoRecMsg(res); }

}

async function fetchCart(auth) {

const res = await new CartClient(auth).fetch();

if (res && res.success) {

dispatch(updateCart(res.data.items));

if (res.data?.items && res.data.items?.length < 1)

{

setNoRecMsg("购物车为空。");

}

} else {

setNoRecMsg(res && typeof res === "string"?

res : res?.error?.message);

}

}

(this.EXPIRATION));

fetchProducts();

}, []);

// 代码的其他部分 …


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/ProductList.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/ProductList.js)
The `ProductList` component uses `auth` as a prop. It contains authentication information such as a token. The `ProductList` component is used as the main `App` component, and `auth` is passed to the `ProductList` component by it.
Please note that you have passed an empty array (`[]`) as a dependency to make sure that the API is called only once. You are using a `useState` hook to store the product list (`productList`) and message states (`noRecMsg`—no record) via setter methods.
Why does the cart need to be fetched in ProductList?
The `ProductList` component and its child components are available for non-authenticated users. Once the user clicks on the **Buy now** button or the **Add to bag** link, it will ask the user to log in. Once logged in, the user can add items to the cart. It is quite possible that the user might already have some items in the cart. Therefore, when you add an item to the cart, the quantity of existing products should be increased, and if a clicked item does not exist in the cart, then it should be added to the cart.
`Cart` is a separate component altogether; this means you can’t access the cart unless you do cart prop drilling from the `App` component to both the `Cart` and `ProductCard` components or have a `useContext` hook for the cart. We have built a custom store to maintain the cart state, very similar to **Redux** (a library used to maintain the state in React app). We’ll learn more about this library later in this chapter. Dispatch is an action that updates the cart items received from the backend server to the cart context.
Next, create a JSX template and pass the fetched `productList` component to the child component, `Products`, for further rendering, as illustrated in the following code snippet:
Remaining code of src/components/ProductList.js

return (   

{productList ? (

<Products auth={auth} productList={productList ?

productList : []} />

) : (

Here, it also passes the `auth` object as a prop to `Products`. Let’s have a look at the `Products` code, as follows:

从 "./ProductCard" 导入 ProductCard;const Products = ({ auth, productList }) => {

return ( <> {productList.map((item) => (

)}

</>

);

};

export default Products;


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/Products.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/Products.js)
Put simply, the code does the job of iterating the product list passed by the `ProductList` component and passes each item with the `product` props to the `ProductCard` component along with the `auth` object.
You can observe the usage of two React concepts here, as follows:

*   The code uses a `<></>` fragment, which is an empty tag. Ideally, this is used when a component returns more than one top-level tag because React needs only one top-level tag in each component. Then, you can wrap those tags with a fragment. You can also use `<React.Fragment>` in place of an empty tag after importing `React` from the `react` package.
*   Another usage is for the `key` props in the `ProductCard` component. When you generate components based on a collection, React requires the `key` props to uniquely identify them. This will allow React to identify which item is changed, removed, or added. We have used the item ID here. If you don’t have an ID in your collection, you can also use the `index`, as shown in the following code example:

    ```

    {productList.map((item, index) => (  <ProductCard key={index} product={item} auth={auth} />))}

    ```java

Now, let’s have a look at the last child component of the `ProductList` component: `ProductCard`. The `ProductCard` component simply passes `Product` values to JSX template expressions for rendering.
We have added some extra code to add the functionality associated with the **Add to bag** and **Buy now** click events.
Configuring routing
You are creating an SPA. Here, routing is not available by default. Routing is the mechanism that provides the routing to an SPA, which means that with each new page, the browser URL will reflect the change and allow you to bookmark the page. SPA routing also maintains the browser history. You are going to use the `react-router-dom` package for routing management. You need to add the `react-router-dom` package to use routing, as shown in the following code snippet. Make sure to execute it from the project root directory:

$ npm install react-router-dom


 The routing will be configured in the `App` component because it is the `root` component of the `ecomm-ui` application. In the `ProductList` component, you are going to use the `Link` component and the `useNavigate()` hook from the `react-router-dom` package. Let’s examine them, as follows:

*   `Link`: This is like a `<a>` HTML anchor tag. Instead of a `href` attribute, it uses a `to` attribute to link the URL. The `route` library maintains the links; therefore, it knows which component to render when a link is passed in with a `to` attribute when `Link` is clicked.
*   `useNavigate()`: This allows navigation inside the component and accesses the state of the router. You would use the `navigate("/path")` function if `navigate` is declared as `const navigate = useNavigate()` to navigate from one component to another, as shown in the `checkLogin()` function of the `ProductCard` component.

Let’s continue with the development of the next product-based component: `ProductCard`.
Developing the ProductCard component
First, let’s import the required packages. Then, declare the state (using `useCartContext` and `useState`) and variables. Please note that the following code snippet has `auth` and `product` as props:

import { useState } from "react";import { Link, useNavigate } from "react-router-dom";

import CartClient from "../api/CartClient";

import { updateCart, useCartContext } from "../hooks/CartContext";

const ProductCard = ({ auth, product }) => {

const navigate = new useNavigate();

const cartClient = new CartClient(auth);

const { cartItems, dispatch } = useCartContext();

const [msg, setMsg] = new useState("");

// continue …


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/ProductCard.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/ProductCard.js)
To begin, you write the `add()` asynchronous function that adds the product to the cart. It first checks whether the user is logged in or not. If not, it redirects the user to the login page. `checkLogin()` uses the `useNavigate` hook’s push method to redirect. The token property of `auth` is used to identify whether the user is logged in or not.
Once it has identified that the user is logged in, it calls the `callAddItemApi` function to add a product to the cart. The `callAddItemApi` function first finds out whether the product exists in the cart or not. If it exists, it finds out the quantity and adds one more to it. The `callAddItemApi` function then calls the REST API using the `CartClient` to add a new item or update the quantity in the existing cart item.
Finally, the `add` function calls `dispatch` to update the state of `cartItems` in the cart context.
The following code snippet contains the same logic:
// Continue code of src/components/ProductCard.js

const add = async () => {  const isLoggedIn = checkLogin();

if (isLoggedIn && product?.id) {

const res = await callAddItemApi();

if (res && res.success) {

if (res.data?.length > 0) {

setMsg("商品已添加到购物袋。");

dispatch(updateCart(res.data));

}

} else { setMsg(res && typeof res === "string" ? res :

res.error.message); }

}

};

const checkLogin = () => {

if (!auth.token) {

navigate("/login");

return false;

}

return true;

};

const callAddItemApi = async () => {

const qty = findQty(product.id);

return cartClient.addOrUpdate({

id: product.id,

数量:qty + 1,

单价:product.price,

});

};

const findQty = (id) => {

const idx = cartItems.findIndex((i) => i.id === id);

if (~idx) { return cartItems[idx].quantity; }

return 0;

};


Here, the `add` function is called when the `buy` function shown in the following code snippet will be called when the user clicks on the **Buy** **now** button:

// ProductCard.jsconst buy = async () => {

const isLoggedIn = checkLogin();

if (isLoggedIn && product?.id) {

const res = await callAddItemApi();

if (res && res.success) { navigate("/cart"); }

else { setMsg(res && typeof res === "string" ? res :

res.error.message); }

}

};


Here, the `buy` function is very similar to the `add` function. Contrary to the `add` function, on a successful response from `callAddItemApi`, the `buy` function redirects the user to the cart page using a `useNavigate` hook instance.
Let’s have a look at a JSX template. In the following code snippet, the `className` attribute values have been stripped for better readability:

return (

<img className="w-72 h-72 mx-auto"

src={product.imageUrl}alt={product.name} />

{product.name}

{"$"}

{product.price.toFixed(2)}

库存充足

<button className="w-1/2…"

type="button" onClick={buy}>

立即购买

<button className="flex…"

type="button" onClick={add}>

加入购物袋

全部本地订单免运费。

);

};

export default ProductCard;


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/ProductCard.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/ProductCard.js)
The `onClick` event has been bound to `buy` and `add` for the `Link`. The `to` attribute of `Link` contains the path that points to the `ProductDetail` component. This path also contains the path parameter ID. You can use this parameter to perform certain operations on it. Similarly, you can also pass the query parameters the way you do in the browser URL.
When the user clicks on the product name, the user is redirected to the `ProductDetail` component (`ProductDetail.js`). Let’s develop this next.
Developing the ProductDetail component
The `ProductDetail` component is like the `ProductCard` component, except that it loads the product details from the backend by using the ID from the path.
Let’s see how this is done. Only code related to the `Fetch` product has been shown in the following snippet. The rest of the code is the same as for the `ProductCard` component. However, you can refer to the full code in the GitHub repository:

import {Link, useParams, useNavigate} from "react-router-dom";import ProductClient from "../api/ProductClient";

// 其他导入省略以节省空间

const ProductDetail = ({ auth }) => {

const { id } = useParams();

// 其他声明省略以节省空间

// 其他函数省略以节省空间

useEffect(() => {

async function getProduct(id) {

const client = new ProductClient();

const res = await client.fetch(id);

if (res && res.success) { setProduct(res.data); }

}

async function fetchCart(auth) {

const res = await new CartClient(auth).fetch();

if (res && res.success) {

console.log(res.data);

dispatch(updateCart(res.data.items));

}

}

if (auth?.token) fetchCart(auth);

getProduct(id);

}, [id]);

return ( /* JSX 模板 */ );

};

export default ProductDetail;


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/ProductDetail.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/ProductDetail.js)
You have used `useParams()` from the `react-router-dom` package to retrieve the product ID passed from the `ProductCard` component. This `id` property is then used to fetch the product from the backend server using the `ProductClient` component. Upon a successful response, the retrieved product detail is set in the state product using the `setProduct` state function.
We are done with the development of product-based components such as `ProductList`, `Products`, `ProductCard`, and `ProductDetail`. We will now focus on authentication functionality so that we can later work on the `cart` and `orders` components, which require an authenticated user.
Implementing authentication
Before you jump into the `Login` component development, you will want to figure out how to manage a token received from a successful login response and how to make sure that if the access token has expired, then a refresh token request should be fired before making any call that requires authentication.
The browser allows you to store tokens or any other information in cookies, session storage, and local storage. From the server side, we haven’t opted for cookie or stateful communication, therefore we are left with the remaining two options. Session storage is preferable for more secure applications because it is specific to a given tab, and it gets cleared as soon as you click on the **Refresh** button or close the tab. We want to manage login persistence between different tabs and page refresh; therefore, we’ll opt for local storage of the browser.
On top of that, you can also store them in the state in the same way you will manage the cart state. However, this will be very similar to session storage. Let’s leave that option for now.
Creating a custom useToken hook
You have now used different React hooks. Let’s move a step forward and create a custom hook. First, create a new `hooks` directory under the `src` directory, and create a `useToken.js` file in it.
Then, add the following code to it:

import { useState } from "react";export default function useToken() {

const getToken = () => {

const tokenResponse = localStorage.getItem

("tokenResponse");

const info = tokenResponse ? JSON.parse

(tokenResponse) : "";

return info;

};

const [token, setToken] = useState(getToken());

const saveToken = (tokenResponse) => {

localStorage.setItem("tokenResponse", JSON.stringify

(tokenResponse));

setToken(tokenResponse);

};

return { setToken: saveToken, token, };

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/hooks/useToken.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/hooks/useToken.js)
Here, you are using a `useState` hook to maintain the token state. The token state is initialized while declaring the token state by calling the `getToken` function in the constructor of `useState`. Now, you need to provide a mechanism that should update the initial token state whenever there is a change in action, such as login or logout. You can create a new function, `saveToken`, for this purpose.
Both the `getToken` and `saveToken` functions use `localStorage` to retrieve and update the token respectively. Finally, both the token state and the `saveToken` function (in the form of `setToken`) are returned for their usage.
Next, you create another REST API client for authentication. Let’s add another client, `Auth.js` ([`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/api/Auth.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/api/Auth.js)), in the `src/api` directory.
This `Auth.js` client is very similar to other API clients. It has three functions that perform the `login`, `logout`, and `refresh` access token operations by using the backend server REST APIs, outlined as follows:

*   The login operation sets the access token, refresh token, user ID, and username in the `responseToken` key of the local storage by using the state arguments passed by the `App` component. The `App` component, as usual, uses the `useToken` custom hook. The login operation also sets the access token’s expiration time.
*   The refresh access token operation updates the access token and its expiration time.
*   The logout operation removes the tokens and sets the expiration time to zero.

You are done with the prerequisite work for implementing the login functionality and can now move on to creating the `Login` component.
Writing the Login component
Let’s create a new `Login.js` file under the `src/components` directory and then add the following code:

import { useNavigate } from "react-router-dom";import { useState } from "react";

import PropTypes from "prop-types";

Login.propTypes = { auth: PropTypes.object.isRequired, };

const Login = ({ uri, auth }) => {

const [username, setUserName] = useState();

const [password, setPassword] = useState();

const [errMsg, setErrMsg] = useState();

const navigate = useNavigate();

const cancel = () => {

const l = navigate.length;

l > 2 ? navigate.goBack() : navigate("/");

};

const handleSubmit = async € => {

e.preventDefault();

const res = await auth.loginUser({ username, password });

if (res && res.success) {

setErrMsg(null);

navigate(uri ? uri:"/");

} else { setErrMsg(

res && typeof res == "string" ?

res : "Unsuccessful");

}

};

return (/* JSX 模板 */ );

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/Login.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/Login.js)
Before you start understanding the code, it’s useful to know that `PropTypes` provides a way to check the type of passed props. Here, we have made sure that the auth prop is an object and a required prop. You may see messages in the console if typing fails during argument passing or assignments. Normally, you add this `props` check at the end of a file (see bottom of the source code), but here it has been added at the top for better readability.
This component contains two props: `auth` and `uri`. The `auth` prop represents the authentication client, and `uri` is a string that sends the user back to the appropriate page after a successful login.
`Login.js` has two functions: `handleSubmit` and `cancel`. The `cancel` function just sends the user back to the previous page or the home page. The `handleSubmit` function makes use of the authentication client and calls the login API with the username and password.
The `handleSubmit` function is called when a form is submitted (i.e., when the user clicks on the `cancel` function is called when the user clicks on the `username` and `password` states. These are set on `onChange` events respectively (refer to the `Login.js` code file on GitHub for truncated JSX content). The `e.target.value` argument represents the user input value in the input field. The `e` instance represents the event and `target` represents the target input field for the respective event.
So, now you know the complete flow: the user logs in and the app sets the required token and information in local storage. The API client uses this information to call the authenticated APIs. The `logout` operation, which is a part of the `Header` component ([`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/Header.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/Header.js)), calls the Auth client’s `logout` function, which calls the remove refresh token backend server’s REST API and removes the authentication information from the local storage.
After authentication implementation, you need to write one more piece of code before you jump to writing the `Cart` component: cart context. Let’s do that now.
Writing the custom cart context
You can use the Redux library for centralizing and maintaining an application’s global state. However, you are going to use a Redux-like custom hook to maintain the state for the cart. This uses the `createContext`, `useReducer`, and `useContext` hooks from the React library.
You already know that `createContext` returns `Provider` and `Consumer`. Therefore, when you create a `CartContext` using `createContext`, it will provide `CartContext.Provider`. You won’t use `Consumer`, as you are going to use a `useContext` hook.
Next, you need a cart state (`cartItems`) that you pass to the value in `CartContext.Provider` so that it will be available in the component that uses  `CartContext`. Now, we just need a `reducer` function. A `reducer` function accepts two arguments: `state` and `action`. Based on the provided action, it updates (mutates) the state and returns the updated state.
Now, let’s jump into the code and see how it turns out. Have a look at the following snippet:

import React, { createContext, useReducer, useContext } from "react";

export const CartContext = createContext();

function useCartContext() { return useContext(CartContext); }

export const UPDATE_CART = "UPDATE_CART";

export const ADD_ITEM = "ADD_ITEM";

export const REMOVE_ITEM = "REMOVE_ITEM";

export function updateCart(items) {

return { type: UPDATE_CART, items };

}

export function addItem(item) {

return { type: ADD_ITEM, item };

}

export function removeItem(index) {

return { type: REMOVE_ITEM, index };

}

export function cartReducer(state, action) {

switch (action.type) {

case UPDATE_CART:

return [...action?.items];

case ADD_ITEM:

return [...state, action.item];

case REMOVE_ITEM:

const list = [...state];

list.splice(action.index, 1);

return list;

default:

return state;

}

}

const CartContextProvider = (props) => {

const [cartItems, dispatch] = useReducer(cartReducer, []);

const cartData = { cartItems, dispatch };

return <CartContext.Provider value={cartData} {...props} />;

};

export { CartContextProvider, useCartContext };


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/hooks/CartContext.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/hooks/CartContext.js)
First, you have created a `CartContext` with a `createContext` hook. Then, you have declared a function that uses a `useContext` hook and returns the value field’s value declared in the `CartContext.Provider` tag.
Next, you need a `reducer` function that uses the action and state. Therefore, we first define action types such as `UPDATE_CART` and then write functions that return an action object that contains both the action type and argument value, such as `updateCart`. Finally, you can write a `reducer` function that takes `state` and `action` as arguments and, based on the passed action type, will mutate the state and return the updated state.
Next, you define a `CartContextProvider` function that returns the `CartContext.Provider` component. Here, you use the reducer function in `useReducer` hook, and in its second argument, you pass the empty array as an initial state. The `useReducer` hook returns to the `state` and `dispatch` functions. The `dispatch` function takes the action object as an argument. You can use the function that returns the action object, such `updateCart` and `addItem`. You wrap the state (`cartItems`) and dispatcher function (`dispatch`) in the `cartData` object and pass it to the value attribute in the `CartContext.Provider` component. Finally, it exports both the `CartContextProvider` and `useCartContext` functions.
You are going to use `CartContextProvider` as a component wrapper in the `App` component. This makes `cartData` (`cartItems` and `dispatch`) available to all components inside `CartContextProvider`, which can be accessed and used via `useCartContext`.
Now, finally, you can write the `Cart` component in the next subsection.
Writing the Cart component
The `Cart` component is a parent component because it can have multiple items (`CartItem` components) in it. Let’s create a new `cart.js` file in the `src/components` directory and add the following code to it:

// other importsimport { removeItem, updateCart, useCartContext }

from "../hooks/CartContext";

import CartItem from "./CartItem";

const Cart = ({ auth }) => {

const [grandTotal, setGrandTotal] = useState(0);

const [noRecMsg, setNoRecMsg] = useState("正在加载...");

const navigate = useNavigate();

const cartClient = new CartClient(auth);

const orderClient = new OrderClient(auth);

const customerClient = new CustomerClient(auth);

const { cartItems, dispatch } = useCartContext();

// continue …


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/Cart.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/Cart.js)
Here, you used the `useCartContext` that was created in the previous subsection. You also use import functions such as `updateCart` that return the action object (consumed by the `dispatch` function). Apart from the `CartClient` Fetch-based API client, you also used `OrderClient` and `CustomerClient` here for cart checkout operations.
Let’s add functions for calculating the total (`calTotal`) and increasing the quantity (`increaseQty`) of a given product ID, as shown next:

// continue src/components/Cart.jsconst calTotal = (items) => {

let total = 0;

items?.forEach((i) => (

total = total + i?.unitPrice * i?.quantity));

return total.toFixed(2);

};

const increaseQty = async (id) => {

const idx = cartItems.findIndex((i) => i.id === id);

if (~idx) {

cartItems[idx].quantity = cartItems[idx].quantity + 1;

const res = await cartClient.addOrUpdate

(cartItems[idx]);

if (res && res.success) {

refreshCart(res.data);

if (res.data?.length < 1) { setNoRecMsg

("Cart empty"); }

} else {

setNoRecMsg(res && typeof res === "string" ?

res : res.error.message);

}

}

}; // continue …


Here, the `increaseQty` function first finds whether the given ID exists among the cart items or not. If it exists, then it increases the quantity of a product by `1`. Finally, it calls the REST API to update the cart items and uses the response to update the cart by calling the `refreshCart` function.
Let’s add a `decreaseQty` function, which is like `increaseQty` but rather decreases the quantity by one. Also, the `deleteItem` function will remove a given cart item from the cart. The code is shown in the following snippet:

// continue src/components/Cart.jsconst decreaseQty = async (id) => {

const idx = cartItems.findIndex((i) => i.id === id);

if (~idx && cartItems[idx].quantity <= 1) {

return deleteItem(id);

} else if (cartItems[idx]?.quantity > 1) {

cartItems[idx].quantity = cartItems[idx].quantity - 1;

const res = await cartClient.addOrUpdate

(cartItems[idx]);

if (res && res.success) {

refreshCart(res.data);

if (res.data?.length < 1) { setNoRecMsg

("Empty cart"); }

return;

} else {

setNoRecMsg(res && typeof res === "string" ?

res : res?.error?.message);

}

}

};

const deleteItem = async (id) => {

const idx = cartItems.findIndex((i) => i.id === id);

if (~idx) {

const res = await cartClient.remove(cartItems[idx].id);

if (res && res.success) {

dispatch(removeItem(idx));

if (res.data?.length < 1) { setNoRecMsg

("Item removed");}

} else {

setNoRecMsg(

res && typeof res === "string" ? res:

"There is an error performing the remove.");

}

}

}; // continue …


Here, the `decreaseQty` function does one extra step in comparison to `increaseQty` — it removes the item if the existing quantity is `1` by calling the `deleteItem` function.
The `deleteItem` function first finds the product based on a given ID. If it exists, then it calls the REST API to remove the product from the cart and updates the cart item state by calling the `dispatch` function with the `action` object returned by the `removeItem` function.
Let’s define the `refreshCart` and `useEffect` functions, as shown in the following code snippet:

// continue src/components/Cart.jsconst refreshCart = (items) => {

setGrandTotal(calTotal(items));

dispatch(updateCart(items));

};

useEffect(() => {

async function fetch() {

const res = await cartClient.fetch();

if (res && res.success) {

refreshCart(res.data.items);

if (res.data?.items && res.data.items?.length < 1) {

setNoRecMsg("Cart is empty.");

}

} else {

setNoRecMsg(res && typeof res === "string" ?

res : res.error.message);

}

}

fetch();

}, []); // continue …


The `refreshCart` function updates the total and dispatches the `updateCart` action. `useEffect` loads the cart items from the backend server and calls `refreshCart` to update the `cartItems` global state.
Let’s add the last function of the `Cart` component to perform the checkout operation, as shown in the following code snippet:

// continue src/components/Cart.js  const checkout = async () => {

const res = await customerClient.fetch();

if (res && res.success) {

const payload = {

address: { id: res.data.addressId },

card: { id: res.data.cardId },

};

const orderRes = await orderClient.add(payload);

if (orderRes && orderRes.success) {

navigate("/orders");

} else {

setNoRecMsg(orderRes && typeof

orderRes === "string"

? orderRes : "Couldn't process checkout.");

}

} else {

setNoRecMsg(res && typeof res === "string" ?

res : "error retrieving customer");

}

};

return (/* JSX Template */ );

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/Cart.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/Cart.js)
Here, the `checkout` function first fetches the customer information and forms a payload for placing the order. On a successful `POST` order API response, the user is redirected to the `Orders` component.
Finally, the `Cart` component returns a JSX template, which is taken from CodePen user `abdelrhman` .
In the JSX template, you will find that when the `CartItem` component that you create next. You pass the `removeItem`, `increaseQty`, and `decreaseQty` functions as props to it.
Let’s write the `CartItem` component by creating a new file (`src/components/ CartItem.js`) and adding the following code:

// importsconst CartItem=({item, increaseQty, decreaseQty, removeItem }) => {

const d = item ? item.description?.split(".") : [];

const author = d && des.length > 0 ? d

[d.length - 1] : "";

const [total, setTotal] = useState();

const calTotal = (item) => {

setTotal((item?.unitPrice * item?.quantity)

?.toFixed(2));

};

const updateQty = (qty) => {

if (qty === -1) { decreaseQty(item?.id); }

else if (qty === 1) { increaseQty(item?.id); }

else { return false; }

calTotal(item);

};

useEffect(() => { calTotal(item); }, []);

return (/* JSX Template */ );

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/CartItem.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/CartItem.js)
Here, you maintain the state of the total that is a product of the quantity and the unit price (`calTotal` function), and use the `updateQty` helper function to perform the increase or decrease quantity operations. The `useEffect` hook also calls `calTotal` to update the total on the **Cart** page.
Now, you can write the last component (page) of this application in the next subsection: the `Order` component.
Writing the Order component
The `Order` component contains the order details fetched from the backend server. It shows the date, status, amount, and items in a tabular format. It loads the order details on the first render with the `useEffect` hook and then the `orders` state is used in the JSX expression to display it.
Let’s create a new file, `Orders.js`, in the `src/components` directory and add the following code to it:

// importsconst Orders = ({ auth }) => {

const [orders, setOrders] = useState([]);

const formatDate = (dt) => {

return dt && new Date(dt).toLocaleString();

};

useEffect(() => {

async function fetchOrders() {

const client = new OrderClient(auth);

const res = await client.fetch();

if (res && res.success) { setOrders(res.data); }

}

fetchOrders();

}, []);

return (/* JSX Template */ );

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/Orders.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/components/Orders.js)
Here, the code is straightforward. It simply displays the information fetched from the `orders` state.
Now, we can update the root component to complete the flow and test the application after starting it again with the `yarn` `start` command.
Writing the root (App) component
The `App` component is a root component of the React application. It contains routing information and the application layout with all the parent components, such as the product list and orders components.
Update the `App.js` file available in the project `src` directory with the following code:

import { BrowserRouter as Router, Route, Routes }   from "react-router-dom";

// other imports

function App() {

const { token, setToken } = useToken();

const auth = new Auth(token, setToken);

const LoginComponent = (props) => (

<Login {...props} uri="/login" auth={auth} />

);

const ProductListComponent = (props) =>

;

// continue …


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/App.js`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter07/ecomm-ui/src/App.js)
Here, the first `import` statement imports the required components from `react-router-dom`. There are other imports that you can check on the GitHub repository (the link is given at the bottom of the preceding code block). Then, the `useToken()` hook and the `Auth` authentication REST API client are used for authentication purposes. You create functions that return `loginComponent` and `productListComponent`.
The `App.js` JSX template is different from what we have used till now. It uses the `BrowserRouter` (`Router`), `Route`, and `Routes` components from the `react-router-dom` package. You define all the `Route` components inside the `BrowserRouter` component. Here, we are also using the `Routes` component because we want to render components exclusively. `Route` also allows you to render the `NotFound` component (the typical *404 – not found* page) if no path matches. The `Route` component allows you to define the path and component to be rendered. You used the `element` property to represent the component that you want to render based on the given `path`. The following code snippet contains the logic explained here:

// App.js continuereturn (

<Route path="/" exact

element={} />

<Route

path="/login"

element={token ? :

} />

<Route

path="/cart"

element={token ? :

} />

<Route

path="/orders"

element={token ? :

} />

<Route

path="/products/:id"

element={} />

<Route path="*" exact element={} />

);

}

export default App;


All components are wrapped inside `CartContextProvider` to allow `cartItems` and `dispatch` to be accessible in all components provided they use the `useCartContext` custom hook.
You are done with the major development work. Let’s run the code using the instructions given in the next section.
Running the application
You need a backend server for testing the UI because the UI fires REST APIs to get the data. You are going to use code from *Chapter 6*.
Go to the home directory of the *Chapter 6* code. You can build the code by running `gradlew clean build` from the root of the `Chapter06` project and run the backend using the following command:

$ java -jar build/libs/Chapter06-0.0.1-SNAPSHOT.jar.


 Make sure to use Java 17 in the path.
Once the backend is up and running, you can open another terminal and start the `ecomm-ui` app by executing the following command from the `Chapter07/ecomm` project root directory:

$ yarn start


 If the application starts successfully, the UI will be accessible at `http://localhost:3000`. You can open `http://localhost:3000` in your favorite browser.
Once the product listing page loads, you can log in to the example e-commerce UI app with the username/password (`scott/tiger`) and perform all the operations such as checkout, orders, and so on.
Let’s review what you have learned and summarize this chapter in the next section.
Summary
In this chapter, you have learned some basic concepts of React and created different types of components using them. You have also learned how to use the browser’s built-in Fetch API to consume the REST APIs. You acquired the following skills in React: developing a component-based UI, implementing routing, consuming REST APIs, implementing functional components with hooks, writing custom hooks, and building a global state store with a React context API and a `useReducer` hook.
The concepts and skills you acquired in this chapter have laid a solid foundation for modern frontend development and advance you toward gaining a 360-degree perspective of application development.
In the next chapter, you will learn about writing automated tests for REST-based web services.
Questions

1.  What is the difference between props and state?
2.  What is an event and how can you bind events in a React component?
3.  What is a higher-order component?

Answers

1.  Props are special objects that you use to pass the values/objects/functions from the parent component to a child component, whereas state belongs to a component – it could be global or local to the component. From a functional component perspective, you use the `useState` hook for local state and `useContext` for global state.
2.  In general, events are objects generated by the browser on input such as `keydown` or `onclick`. React uses `SyntheticEvent` to ensure that the browser’s native events work identically across all browsers. `SyntheticEvent` wraps on top of the native event. You used the `onChange={(e) => setUserName(e.target.value)}` code in the login component. Here, `e` is `SyntheticEvent` and `target` is one of its attributes. The `onChange` event is bound in JSX that calls `setUserName` when the input value is changed. You can also use the same JavaScript technique to bind events such as `window.` `addEventListener("click", handleClick)`.

Ideally, you would do this in the `useEffect` hook; however, the event should be removed as a part of the cleanup. That can also be done in `useEffect` when you return the arrow function that removes the binding, for example, `return () => { window. removeEventListener("click", handleClick); }`. You can find this example in the `Header.js` file in the `src/components` directory.

1.  In JavaScript, higher-order functions take a function as an argument and/or return a function, such as that of an array (map, filter, and so on). Similarly, in React, **higher-order components** (**HOCs**) are a pattern that involves the use of composition with an existing component and returns a new component. Basically, you write a new function that takes a component as an argument and returns it. An HOC allows you to reuse the existing component and its logic.

In the `ecomm-ui` application, the `ProductCard` and `ProductDetail` components are similar in nature, and you can use an HOC to reuse the logic.
Further reading

*   *React 18* *Design Patterns and Best Practices - Fourth* *Edition*: [`www.packtpub.com/product/react-18-design-patterns-and-best-practices-fourth-edition/9781803233109`](https://www.packtpub.com/product/react-18-design-patterns-and-best-practices-fourth-edition/9781803233109)
*   React documentation: [`reactjs.org/docs/getting-started.html`](https://reactjs.org/docs/getting-started.html)
*   React Router guide: [`reactrouter.com/en/main`](https://reactrouter.com/en/main)

第七章:测试 API

正确的自动化测试可以帮助您减少回归错误并保持应用程序稳定。它确保您所做的每个更改在构建或测试阶段都会失败,如果更改对现有代码有任何副作用。投资于测试自动化套件可以给您带来安心,并防止在生产中出现任何意外。

本章将通过向您展示如何实现单元和集成测试自动化来帮助您了解测试自动化。您将学习如何手动和自动测试 API。首先,您将学习如何自动化单元和集成测试。在了解这些自动化形式之后,您将能够使这两种类型的测试成为任何构建的组成部分。您还将学习如何设置Java 代码覆盖率JaCoCo)工具来计算不同的代码覆盖率指标。

在本章中,我们将涵盖以下主题:

  • 手动测试 API 和代码

  • 测试自动化

让我们开始吧!

技术要求

本章的代码可在github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter08找到。

手动测试 API 和代码

测试是软件开发和维护周期中的持续过程。您需要对每个更改的所有可能用例及其相应的代码进行全面测试。可以对 API 执行不同类型的测试,包括以下内容:

  • 单元测试:单元测试由开发者执行,以测试代码的最小单元(例如类方法)。

  • 集成测试:集成测试由开发者执行,以测试不同层组件的集成。

  • 契约测试:契约测试由开发者执行,以确保对 API 所做的任何更改都不会破坏消费者代码。消费者代码应始终符合生产者的契约(API)。这在基于微服务开发中是主要要求的。

  • 端到端E2E测试:端到端测试由质量保证QA)团队执行,以测试端到端场景,例如从 UI(消费者)到后端。

  • 用户验收测试UAT):UAT 由业务用户从业务角度进行,可能与端到端测试重叠。

您在本书早期使用 cURL 和 Postman 工具进行了手动 API 测试。每次更改都需要对 API 进行完全测试——不仅仅是受影响的 API。这有原因。您可能认为这只会影响某些 API,但您的潜在假设如果错了怎么办?它可能会影响您跳过的其他 API,这可能导致生产问题。这可能会引起恐慌,可能需要回滚发布或发布带有修复补丁的补丁。

您不希望处于这种情况下,因此产品有一个单独的 QA 团队,确保以最佳可能的质量交付发布。QA 团队执行单独的端到端和验收测试(包括业务/领域用户),除了开发团队进行的测试之外。

这种对高质量交付成果的额外保证需要更多的时间和精力。由于自动化测试,现在所需的时间要短得多。之前因为进行了手动测试,所以时间更长;因此,与今天相比,软件开发周期曾经非常庞大。"上市时间"(TTM)是当今竞争激烈的软件行业中的一个重要因素。今天,您需要更快的发布周期。此外,质量检查,也称为测试,是发布周期中的重要和主要部分。

您可以通过自动化测试流程并将其作为 CI/CD 管道的组成部分来减少测试时间。"CI"代表持续集成,意味着在代码仓库中的构建 > 测试 > 合并CD代表持续交付和/或持续部署,两者可以互换使用。持续交付是一个过程,其中代码会自动测试并发布(读取和上传)到工件存储库或容器注册库。然后,在手动批准后,它可以被选中并部署到生产环境。持续部署比持续交付更进一步,并自动化所有步骤。持续部署在所有测试通过后也会执行自动部署到生产环境。不向公众开放代码的产品,如 Facebook 和 Twitter,使用这种方法。另一方面,公开可用的产品/服务,如 Spring 框架和 Java,使用持续交付管道。

我们将在下一节中自动化到目前为止所进行的手动测试。

测试自动化

无论您正在手动执行什么测试,都可以自动化并将其作为构建的一部分。这意味着任何更改或代码提交都将作为构建的一部分运行测试套件。只有当所有测试都通过时,构建才会成功。

您可以为所有 API 添加自动化集成测试。因此,您不需要使用 cURL 或 Insomnia 手动触发每个 API,构建将触发它们,测试结果将在构建结束时可用。

在本节中,您将编写一个集成测试,该测试将复制 REST 客户端调用并测试所有应用程序层,从控制器开始,一直到底层的持久化层,包括数据库(H2)。

但在之前,您将添加必要的单元测试。理想情况下,这些单元测试应该在开发过程中添加,或者在测试驱动开发TDD)的情况下在开发过程之前添加。

单元测试是验证小代码单元(如类的方法)预期结果的测试。如果你有良好的代码(90% 或以上)和分支覆盖率(80% 和以上)的适当测试,你可以避免大多数错误。代码覆盖率是指测试执行时验证的指标,如行数和分支(如 if-else)。

一些类或方法依赖于其他类或基础设施服务。例如,控制器类依赖于服务和组装类,而仓库类依赖于 Hibernate API。你可以创建模拟来复制依赖行为,并假设它们按预期或按定义的测试行为工作。这种方法将允许你测试实际的代码单元(如方法)并验证其行为。

在下一节中,我们将探讨在编写集成测试之前如何添加单元测试。

单元测试

我建议你回到 第六章 作为本章代码的基础。你不需要为单元测试添加任何额外的依赖项。你已经在 build.gradle 中有了以下依赖项 (github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter08/build.gradle):

testImplementation('org.springframework.boot:spring-boot-starter-test')

在这里,spring-boot-starter-test 添加了所有必需的测试依赖项,不仅用于单元测试,还用于集成测试。你将主要使用以下库进行测试:

  • junit-platform-commons.

  • junit-jupiter-engine 允许你在 JUnit 平台上运行基于 Jupiter 的测试。它还提供了 junit-jupiterjunit-jupiter-apijunit-jupiter-params 库。

  • JUnit Vintage 支持 JUnit 的旧版本,如 3 和 4 版本。在这本书中,你将使用最新版本,即 5,因此你不需要这个包。

你可以在 junit.org/ 上了解更多关于 JUnit 的信息。

  • AssertJ: AssertJ 是一个测试断言库,通过提供流畅的 API 简化了断言的编写。它也是可扩展的。你可以为你的领域对象编写自定义断言。你可以在 assertj.github.io/doc/ 上了解更多信息。

  • Hamcrest: Hamcrest 是另一个基于匹配器的断言库,它还允许你编写自定义匹配器。你将在本章中找到这两个示例,尽管 AssertJ 更受欢迎,因为它具有流畅的 API。链式方法帮助 IDE 根据给定的对象建议适当的断言。你可以根据你的用例和喜好选择其中一个断言库或两者都使用。你可以在 hamcrest.org/ 上了解更多信息。

  • Mockito:Mockito 是一个允许你模拟对象(读取依赖项)和存根方法调用的模拟框架。你可以在site.mockito.org/了解更多信息。

你已经知道单元测试测试的是最小的可测试代码单元。但我们是怎样为控制器方法编写单元测试的呢?控制器在 Web 服务器上运行,并且拥有 Spring Web 应用上下文。如果你编写了一个使用WebApplicationContext并且运行在 Web 服务器之上的测试,那么你可以称它为集成测试而不是单元测试。

单元测试应该是轻量级的,并且必须快速执行。因此,你必须使用 Spring 测试库提供的特殊类MockMvc来测试控制器。你可以为单元测试使用MockMvc的独立设置。你也可以使用MockitoExtension在 JUnit 平台(JUnit 5 为运行器提供了扩展)上运行单元测试,它支持对象模拟和方法存根。你还将使用 Mockito 库来模拟所需的依赖项。这些测试速度快,有助于开发者更快地构建。

让我们使用 AssertJ 断言来编写我们的测试。

使用 AssertJ 断言进行测试

让我们编写第一个针对ShipmentController的单元测试。以下代码可以在src/test/java/com/packt/modern/api/controller/ShipmentControllerTest.java文件中找到:

@ExtendWith(MockitoExtension.class)public class ShipmentControllerTest {
  private static final String id =
      "a1b9b31d-e73c-4112-af7c-b68530f38222";
  private MockMvc mockMvc;
  @Mock
  private ShipmentService service;
  @Mock
  private ShipmentRepresentationModelAssembler assembler;
  @Mock
  private MessageSource msgSource;
  @InjectMocks
  private ShipmentController controller;
  private ShipmentEntity entity;
  private Shipment model = new Shipment();
  private JacksonTester<List<Shipment>> shipmentTester;
  // continue …

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter08/src/test/java/com/packt/modern/api/controller/ShipmentControllerTest.java

在这里,我们的测试使用了一个基于 Jupiter 的注解(ExtendWith),它注册了用于运行测试和支持基于 Mockito 的模拟和存根的扩展(MockitoExtension)。

Spring 测试库提供了MockMvc类,它允许你模拟 Spring MVC。因此,你可以通过调用相关 API 端点的 URI 来执行控制器方法。ShipmentController控制器类的依赖项,如服务和组装器,被标记为@Mock注解以创建其依赖项的模拟实例。你也可以使用Mockito.mock(classOrInterface)来创建模拟对象。

另一个值得注意的注解是控制器声明上的@InjectMocks。它会找出测试类所需的全部已声明模拟对象,并自动注入它们。ShipmentController使用ShipmentServiceShipmentRepresentation模型组装器实例,这些实例通过其构造函数注入。基于 Mockito 的InjectMocks注解在ShipmentController类中查找依赖(服务和组装器)。然后,它在测试类中寻找服务和组装器的模拟对象。一旦找到,它将这些模拟对象注入到ShipmentController类中。如果需要,你也可以使用构造函数创建测试类的实例,而不是使用@InjectMocks,如下所示:

controller = new ShipmentController(service, assembler);

RestApiHandler创建了一个MessageSource的模拟对象,该对象在设置方法中使用。你将在下面的代码块中进一步探索它。

声明书的最后一部分是JacksonTester,它是 Spring 测试库的一部分。JacksonTester是一个使用 AssertJ 和 Jackson 库创建的自定义 JSON 断言类。

JUnit Jupiter API 提供了@BeforeAll@BeforeEach方法注解,可以用来设置先决条件。正如它们的名称所暗示的,@BeforeAll在每个测试类中只运行一次,而@BeforeEach在每个测试执行前执行。@BeforeEach可以放置在公共非静态方法上,而@BeforeAll应该用来注解公共静态方法。

类似地,JUnit 提供了@AfterAll@AfterEach注解,分别用于在每个测试执行后和每个测试执行后执行相关的方法。

让我们使用@BeforeEach注解来设置ShipmentControllerTest类的先决条件,如下所示:

// continue ShipmentControllerTest.java@BeforeEach
public void setup() {
  ObjectMapper mapper = new AppConfig().objectMapper();
  JacksonTester.initFields(this, mapper);
  MappingJackson2HttpMessageConverter mappingConverter =
    new MappingJackson2HttpMessageConverter();
  mappingConverter.setObjectMapper(mapper);
  mockMvc = MockMvcBuilders.standaloneSetup(controller)
      .setControllerAdvice(new RestApiErrorHandler
        (msgSource))
      .setMessageConverters(mappingConverter).build();
  final Instant now = Instant.now();
  entity = // entity initialization code
  BeanUtils.copyProperties(entity, model);
  // extra model property initialization
}

首先,我们使用从AppConfig接收到的对象映射器实例初始化JacksonTester字段。这创建了一个自定义消息转换器实例(MappingJackson2HttpMessageConverter)。

接下来,你可以使用独立设置创建一个mockMvc实例,并使用其 setter 方法初始化控制器建议。RestApiErrorHandler实例使用MessageResource类的模拟对象。在构建之前,你也可以将消息转换器设置为mockMvc

最后,你初始化ShipmentEntityShipment(模型)的实例。

接下来,你将要编写针对GET /api/v1/shipping/{id}调用的测试,该调用使用ShipmentController类的getShipmentByOrderId()方法。测试用@Test标记。你也可以使用@DisplayName来自定义测试报告中的测试名称:

@Test@DisplayName("returns shipments by given order ID")
public void testGetShipmentByOrderId() throws Exception {
  // given
  given(service.getShipmentByOrderId(id))
      .willReturn(List.of(entity));
  given(assembler.toListModel(List.of(entity)))
      .willReturn(List.of(model));
  // when
  MockHttpServletResponse response = mockMvc.perform(
      get("/api/v1/shipping/" + id)
          .contentType(MediaType.APPLICATION_JSON)
          .accept(MediaType.APPLICATION_JSON))
      .andDo(print())
      .andReturn().getResponse();
  // then
  assertThat(response.getStatus())
       .isEqualTo(HttpStatus.OK.value());
  assertThat(response.getContentAsString())
      .isEqualTo(shipmentTester.write(
          List.of(model)).getJson());
}

在这里,你正在使用Given > When > Then语言(cucumber.io/docs/gherkin/),它可以定义为如下:

  • Given: 测试的上下文

  • When: 测试动作

  • Then: 测试结果,随后进行验证

让我们从 BDD(行为驱动开发)的角度来阅读这个测试:

  • Given: 服务可用,并根据提供的订单 ID 和一个将实体列表转换为模型列表的组装器返回运输列表。它还添加了 HATEOAS 链接。

  • When: 用户通过 GET /api/shipping/a1b9b31d-e73c-4112-af7c-b68530f38222 调用 API。

  • Then: 测试验证了与给定订单 ID 相关的接收到的运输。

Mockito 的 MockitoBDD 类提供了 given() 流畅 API 来存根模拟对象的方法。当调用 mockMvc.perform() 时,内部它会调用相应的服务和组装器模拟,这些模拟反过来调用存根方法并返回在存根中定义的值(使用 given())。

andDo(MockMvcResultHandlers.print()) 方法记录请求和响应跟踪,包括有效载荷和响应体。如果你想在测试类中跟踪所有的 mockMvc 日志,那么你可以在初始化 mockMvc 时直接配置它们,而不是在 mockMvc.perform() 调用中单独定义,如下所示(高亮代码):

mockMvc = MockMvcBuilders.standaloneSetup(controller)    .setControllerAdvice(new RestApiErrorHandler
      (msgSource))
    .setMessageConverters(mappingJackson2HttpMessageConverter)
    .alwaysDo(print())
    .build();

最后,你使用 AssertJ 流畅 API 执行断言(状态是否为 200 OK 以及返回的 JSON 对象是否与预期对象匹配)。首先,你使用 Asserts.assertThat() 函数,它接受实际对象并使用 isEqualTo() 方法将其与预期对象进行比较。

到目前为止,你已经使用了 AssertJ 断言。同样,你也可以使用 Spring 和 Hamcrest 断言。

使用 Spring 和 Hamcrest 断言进行测试

到这一点,你知道如何使用 MockitoExtension 编写 JUnit 5 测试。你将使用相同的方法编写单元测试,除了使用断言。这次,你将使用 Hamcrest 断言编写一个断言,如下所示:

@Test@DisplayName("returns address by given existing ID")
public void getAddressByOrderIdWhenExists() throws Exception {
  given(service.getAddressesById(id))
     .willReturn(Optional.of(entity));
  // when
  ResultActions result = mockMvc.perform(
      get("/api/v1/addresses/a1b9b31d-e73c-4112-af7c-
            b68530f38222")
          .contentType(MediaType.APPLICATION_JSON)
          .accept(MediaType.APPLICATION_JSON));
  // then
  result.andExpect(status().isOk());
  verifyJson(result);
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter08/src/test/java/com/packt/modern/api/controller/AddressControllerTest.java

你已经从之前的测试示例中的 mockMvc.perform() 调用中捕获了 MockHttpResponse 实例——即 testGetShipmentByOrderId()。这次,你将直接使用 mockMvc.perform() 调用的返回值,而不是在它上面调用额外的 andReturn().getResponse()

ResultAction 类提供了 andExpect() 断言方法,它接受 ResultMatcher 作为参数。StatusResultMatchers.status().isOk() 结果匹配器评估 perform() 调用返回的 HTTP 状态。VerifyJson() 方法评估 JSON 响应对象,如下面的代码所示:

// AddressControllerTest.javaprivate void verifyJson(final ResultActions result)
    throws Exception {
  final String BASE_PATH = "http://localhost";
  result
      .andExpect(jsonPath("id",
          is(entity.getId().toString())))
      .andExpect(jsonPath("number", is
          (entity.getNumber())))
      .andExpect(jsonPath("residency",
          is(entity.getResidency())))
      .andExpect(jsonPath("street", is
          (entity.getStreet())))
      .andExpect(jsonPath("city", is(entity.getCity())))
      .andExpect(jsonPath("state", is(entity.getState())))
      .andExpect(jsonPath("country", is
          (entity.getCountry())))
      .andExpect(jsonPath("pincode", is
           (entity.getPincode())))
      .andExpect(jsonPath("links[0].rel", is("self")))
      .andExpect(jsonPath("links[0].href",
          is(BASE_PATH + "/" + entity.getId())))
      .andExpect(jsonPath("links[1].rel", is("self")))
      .andExpect(jsonPath("links[1].href",
          is(BASE_PATH + URI + "/" + entity.getId())));
}

在这里,MockMvcResultMatchers.jsonPath()结果匹配器接受两个参数——一个 JSON 路径表达式和一个匹配器。因此,首先,你必须传递 JSON 字段名,然后是称为Is.is()的 Hamcrest 匹配器,它是Is.is(equalsTo(entity.getCity()))的快捷方式。

与编写控制器的单元测试相比,编写服务的单元测试要容易得多,因为你不需要处理MockMvc

你将在下一小节中学习如何测试私有方法。

测试私有方法

单元测试私有方法是一个挑战。Spring 测试库提供了ReflectionTestUtils类,它提供了一个名为invokeMethod的方法。这个方法允许你调用私有方法。invokeMethod方法接受三个参数——目标类、方法名称和方法参数(使用可变参数)。让我们使用它来测试AddressServiceImpl.toEntity()私有方法,如下面的代码块所示:

@Test@DisplayName("returns an AddressEntity when private method
    toEntity() is called with Address model")
public void convertModelToEntity() {
 // given
 AddressServiceImpl srvc = new AddressServiceImpl
    (repository);
 // when
 AddressEntity e = ReflectionTestUtils.invokeMethod(
    srvc, "toEntity", addAddressReq);
 // then
 then(e).as("Check address entity is returned & not null")
     .isNotNull();
 then(e.getNumber()).as("Check house/flat number is set")
     .isEqualTo(entity.getNumber());
 then(e.getResidency()).as("Check residency is set")
     .isEqualTo(entity.getResidency());
 then(e.getStreet()).as("Check street is set")
     .isEqualTo(entity.getStreet());
 then(e.getCity()).as("Check city is set")
     .isEqualTo(entity.getCity());
 then(e.getState()).as("Check state is set")
     .isEqualTo(entity.getState());
 then(e.getCountry()).as("Check country is set")
     .isEqualTo(entity.getCountry());
 then(e.getPincode()).as("Check pincode is set")
     .isEqualTo(entity.getPincode());
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter08/src/test/java/com/packt/modern/api/service/AddressServiceTest.java

在前面的代码中,你可以看到当你使用给定的参数调用ReflectionTestUtils.invokeMethod()时,它返回一个AddressEntity实例,该实例已经使用给定的参数的AddAddressReq模型实例进行了转换。

在这里,你正在使用 AssertJ 的BDDAssertions类提供的第三种断言方式。BDDAssertions.then()接受你想要验证的实际值。as()方法描述了断言,应该在执行断言之前添加。最后,你使用 AssertJ 的断言方法,如isEqualTo(),来执行验证。

你将在下一小节中学习如何测试无返回值的方法。

测试无返回值的方法

返回值的函数很容易模拟,但如何模拟一个不返回任何值的函数呢?Mockito 提供了doNothing()方法来处理这种情况。在BDDMockito类中有一个包装器willDoNothing()方法,它内部使用doNothing()

这非常方便,尤其是在你想要在监视时让这些方法什么都不做的时候,就像这里所示:

List linkedList = new LinkedList();List spyLinkedList = spy(linkedList);
doNothing().when(spyLinkedList).clear();

在这里,linkedList是一个真实对象,而不是模拟对象。然而,如果你想模拟一个特定的方法,那么你可以使用spy()。在这里,当在spyLinkedList上调用clear()方法时,它将不会做任何事情。

让我们使用willDoNothing来模拟无返回值的方法,看看它如何帮助测试无返回值的方法:

// AddressServiceTest.java@Test
@DisplayName("delete address by given existing id")
public void deleteAddressesByIdWhenExists() {
  given(repository.findById(UUID.fromString(nonExistId)))
     .willReturn(Optional.of(entity));
  willDoNothing().given(repository)
     .deleteById(UUID.fromString(nonExistId));
  // when
  service.deleteAddressesById(nonExistId);
  // then
  verify(repository, times(1))
     .findById(UUID.fromString(nonExistId));
  verify(repository, times(1))
     .deleteById(UUID.fromString(nonExistId));
}

在前面的代码中,AddressRepository.deleteById() 正在使用 Mockito 的 willDoNothing() 方法进行模拟。现在,你可以使用 Mockito 的 verify() 方法,它接受两个参数——模拟对象和其验证模式。在这里,使用的是 times() 验证模式,它确定一个方法被调用的次数。

在下一小节中,我们将学习如何对异常场景进行单元测试。

测试异常

Mockito 提供了 thenThrow() 用于模拟带有异常的方法。BDDMockito 的 willThrow() 是一个包装器,它内部使用它。你可以传递 Throwable 参数并像这样进行测试:

// AddressServiceTest.java@Test
@DisplayName("delete address by given non-existing id,
    should throw ResourceNotFoundException")
public void deleteAddressesByNonExistId() throws Exception {
  given(repository.findById(UUID.fromString(nonExistId)))
      .willReturn(Optional.empty())
      .willThrow(new ResourceNotFoundException(String.format(
  "No Address found with id %s.", nonExistId)));
  // when
  try { service.deleteAddressesById(nonExistId);
  } catch (Exception ex) {
  // then
    assertThat(ex)
      .isInstanceOf(ResourceNotFoundException.class);
    assertThat(ex.getMessage())
      .contains("No Address found with id " + nonExistId);
  }
  // then
  verify(repository, times(1))
      .findById(UUID.fromString(nonExistId));
  verify(repository, times(0))
      .deleteById(UUID.fromString(nonExistId));
}

在这里,你基本上是捕获异常并对它进行断言。

通过这样,你已经探索了可以对控制器和服务执行的单元测试。你可以使用这些示例并为其他类编写单元测试。

执行单元测试

你可以运行以下命令来执行单元测试:

$ ./gradlew clean test

这将在 Chapter08/build/reports/tests/test/index.html 生成单元测试报告。

生成的测试报告看起来像这样:

图 8.1 – 单元测试报告

图 8.1 – 单元测试报告

你可以点击链接进行进一步深入。如果测试失败,它也会显示错误的原因。

让我们继续到下一节,学习如何为单元测试配置代码覆盖率。

代码覆盖率

代码覆盖率提供了重要的指标,包括行和分支覆盖率。你将使用 JaCoCo 工具来执行和报告代码覆盖率。

首先,你需要将 jacoco Gradle 插件添加到 build.gradle 文件中,如下面的代码所示:

plugins {    id 'org.springframework.boot' version '3.0.4'
    id 'io.spring.dependency-management' version '1.1.0'
    id 'java'
    id 'org.hidetake.swagger.generator' version '2.19.2'
    id 'jacoco'
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter08/build.gradle

接下来,通过提供其版本和报告目录来配置 jacoco 插件:

// build.gradlejacoco {
    toolVersion = "0.8.8"
    reportsDirectory = layout.buildDirectory.dir(
        "$buildDir/jacoco")
}

接下来,创建一个名为 jacocoTestReport 的新任务,它依赖于 test 任务,因为只有在测试执行之后才能评估代码覆盖率。你不想为自动生成的代码计算覆盖率,所以添加 exclude 块。可以通过配置 afterEvaluate 来添加排除,如下面的代码块所示:

// build.gradlejacocoTestReport {
    dependsOn test
    afterEvaluate {
        classDirectories.setFrom(
          files(classDirectories.files.collect {
            fileTree(
                dir: it,
                exclude: [
                    'com/packt/modern/api/model/*',
                    'com/packt/modern/api/*Api.*',
                    'com/packt/modern/api/security
                        /UNUSED/*',
                ])
        }))
    }
}

接下来,你需要配置 jacocoTestCoverageVerification,它定义了违规规则。我们在下面的代码块中添加了关于覆盖率比率的说明。这将设置期望的比率至少为 90%。如果比率低于 0.9,则构建将失败。你可以在 https://docs.gradle.org/current/userguide/jacoco_plugin.html#sec:jacoco_report_violation_rules 了解更多此类规则:

// build.gradlejacocoTestCoverageVerification {
    violationRules {
        rule {
            limit { minimum = 0.9 }
        }
    }
}

接下来,将 finalizedBy(jacocoTestReport) 添加到测试任务中,这确保了 jacocoTestReport 任务将在执行测试后执行:

test {    useJUnitPlatform()
    finalizedBy(jacocoTestReport)
}

让我们运行以下命令来生成代码覆盖率报告:

$ ./gradlew clean build

之前的命令不仅会运行测试,还会生成代码覆盖率报告以及测试报告。代码覆盖率报告可在Chapter08/build/jacoco/test/html/index.html找到,如下所示:

图 8.2 – 代码覆盖率报告

图 8.2 – 代码覆盖率报告

在这里,你可以看到我们的指令覆盖率只有 29%,而我们的分支覆盖率只有 3%。你可以添加更多测试来提高这些百分比。

你将在下一节中了解集成测试。

集成测试

一旦你设置了自动化的集成测试,你可以确保你做的任何更改都不会产生错误,前提是你覆盖了所有测试场景。你不需要添加任何额外的插件或库来支持本章中的集成测试。Spring 测试库提供了编写和执行集成测试所需的所有库。

让我们在下一小节中添加集成测试的配置。

配置集成测试

首先,你需要为你的集成测试提供一个单独的位置。这可以在build.gradle中配置,如下面的代码块所示:

sourceSets {    integrationTest {
        java {
            compileClasspath += main.output + test.output
            runtimeClasspath += main.output + test.output
            srcDir file('src/integration/java')
        }
        resources.srcDir file('src/integration/resources')
    }
}

在这里,你可以将集成测试及其资源添加到源集中。当执行相关的 Gradle 命令(integrationTestbuild)时,Gradle 会选择这些测试。

接下来,你可以配置集成测试的实现和运行时,使其扩展自测试的实现和运行时,如下面的代码块所示:

configurations {    integrationTestImplementation.extendsFrom
         testImplementation
    integrationTestRuntime.extendsFrom testRuntime
}

最后,创建一个名为integrationTest的任务,该任务不仅将使用 JUnit 平台,还将使用我们的classpathsourceSets中的测试classpathintegrationTest

最后,配置检查任务,使其依赖于integrationTest任务,并在测试任务之后运行integrationTest。如果你想单独运行integrationTest,可以删除以下代码块中的最后一行:

tasks.register('integrationTest', Test) {    useJUnitPlatform()
    description = 'Runs the integration tests.'
    group = 'verification'
    testClassesDirs = sourceSets.integrationTest
         .output.classesDirs
    classpath = sourceSets.integrationTest.runtimeClasspath
}
check.dependsOn integrationTest
integrationTest.mustRunAfter test

现在,我们可以开始编写集成测试。在编写集成测试之前,首先,让我们在下一小节中编写支持 Java 类。首先,让我们创建TestUtils类。这个类将包含一个返回ObjectMapper实例的方法。它将包含一个检查 JWT 是否已过期的方法。

为集成测试编写支持类

AppConfig类中检索到的ObjectMapper实例添加了额外的配置,以便我们可以将单个值作为数组接受。例如,一个 JSON 字符串字段值可能是{[{…}, {…}]}。如果你仔细观察,你会发现它是一个被单个值包裹的数组。当你将此值转换为对象时,ObjectMapper将其视为数组。此类的完整代码如下:

public class TestUtils {  private static ObjectMapper objectMapper;
  public static boolean isTokenExpired(String jwt)
      throws JsonProcessingException {
    var encodedPayload = jwt.split("\\.")[1];
    var payload = new String(Base64.getDecoder()
        .decode(encodedPayload));
    JsonNode parent = new ObjectMapper().readTree(payload);
    String expiration = parent.path("exp").asText();
    Instant expTime = Instant.ofEpochMilli(
        Long.valueOf(expiration) * 1000);
    return Instant.now().compareTo(expTime) < 0;
  }
  public static ObjectMapper objectMapper() {
    if (Objects.isNull(objectMapper)) {
      objectMapper = new AppConfig().objectMapper();
      objectMapper.configure(DeserializationFeature
          .ACCEPT_SINGLE_VALUE_AS_ARRAY, true);
    }
    return objectMapper;
  }
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter08/src/integration/java/com/packt/modern/api/TestUtils.java

接下来,你需要一个客户端,允许你登录以便检索 JWT。RestTemplate是 Spring 中的一个 HTTP 客户端,它提供了发起 HTTP 调用的支持。AuthClient类利用TestRestTemplate,从测试的角度来看,它是RestTemplate的一个副本。

让我们按照以下方式编写这个AuthClient类:

public class AuthClient { private final TestRestTemplate restTemplate;
 private final ObjectMapper objectMapper;
 public AuthClient(TestRestTemplate restTemplate,
     ObjectMapper objectMapper) {
   this.restTemplate = restTemplate;
   this.objectMapper = objectMapper;
 }
 public SignedInUser login(String username,
     String password) {
   SignInReq signInReq = new SignInReq()
                     .username(username).password(password);
   return restTemplate
      .execute("/api/v1/auth/token",HttpMethod.POST,
        req -> {
          objectMapper.writeValue(req.getBody(),
              signInReq);
          req.getHeaders().add(HttpHeaders.CONTENT_TYPE,
                 MediaType.APPLICATION_JSON_VALUE);
          req.getHeaders().add(HttpHeaders.ACCEPT,
                 MediaType.APPLICATION_JSON_VALUE);
        },
        res -> objectMapper.readValue(res.getBody(),
            SignedInUser.class)
      );
   }
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter08/src/integration/java/com/packt/modern/api/AuthClient.java

Spring 测试库提供了MockMvcWebTestClientTestRestTemplate来执行集成测试。你已经在单元测试中使用了MockMvc。同样的方法也可以用于集成测试。然而,而不是使用模拟,你可以通过在测试类中添加@SpringBootTest注解来使用实际的对象。@SpringBootTest,连同SpringExtension一起,提供了所有必要的 Spring 上下文,例如实际的应用程序。

@TestPropertySource提供了测试属性文件的位置。

WebTestClient用于测试响应式应用程序。然而,为了测试 REST 服务,你必须使用TestRestTemplate,它是RestTemplate的一个副本。

你将要编写的集成测试是一个完整的测试,不包含任何模拟。它将使用与实际应用程序相同的 Flyway 脚本,我们将它们添加到了src/integration/resources/db/migration。集成测试还将拥有自己的application.properties文件,位于src/integration/resources

因此,集成测试将和直接从 REST 客户端(如 cURL 或 Postman)调用 REST 端点一样好。这些 Flyway 脚本在 H2 内存数据库中创建了所需的表和数据。然后,这些数据将被 RESTful Web 服务使用。你也可以使用其他数据库,如 Postgres 或 MySQL,使用它们的测试容器。

让我们在src/integration/java目录下的一个合适的包中创建一个新的集成测试,名为AddressControllerIT,并添加以下代码:

@ExtendWith(SpringExtension.class)@SpringBootTest( webEnvironment = WebEnvironment.RANDOM_PORT,
    properties = "spring.flyway.clean-disabled=false")
@TestPropertySource(locations =
    "classpath:application-it.properties")
@TestMethodOrder(OrderAnnotation.class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class AddressControllerIT {
  private static ObjectMapper objectMapper;
  private static AuthClient authClient;
  private static SignedInUser signedInUser;
  private static Address address;
  private static String idOfAddressToBeRemoved;
  @Autowired
  private AddressRepository repository;
  @Autowired
  private TestRestTemplate restTemplate;
  @BeforeAll
  public static void init(@Autowired Flyway flyway) {
    objectMapper = TestUtils.objectMapper();
    address = new Address().id(
     "a731fda1-aaad-42ea-bdbc-a27eeebe2cc0").
       number("9I-999")
     .residency("Fraser Suites Le Claridge")
     .street("Champs-Elysees").city("Paris").state(
       "Île-de-France").country("France").pincode("75008");
    flyway.clean();
    flyway.migrate();
  }
  @BeforeEach
  public void setup(TestInfo info)
      throws JsonProcessingException {
    if (Objects.isNull(signedInUser) ||
        Strings.isNullOrEmpty(signedInUser.getAccessToken())
        || isTokenExpired(signedInUser.getAccessToken())) {
      authClient = new AuthClient
        (restTemplate, objectMapper);
      if (if (info.getTags().contains("NonAdminUser")) {
        signedInUser = authClient.login("scott", "tiger");
      } else {
        signedInUser = authClient.login("scott2", "tiger");
      }
    }
  }

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter08/src/integration/java/com/packt/modern/api/controller/AddressControllerIT.java

在这里,SpringExtension现在被用来在 JUnit 平台上运行单元测试。SpringBootTest注解为测试类提供了所有依赖项和上下文。测试服务器正在使用随机端口运行。你还在使用@TestMethodOrder,结合@Order注解,以特定的顺序运行测试。你将按照特定的顺序执行测试,以确保在addresses资源上的POST HTTP 方法仅在addresses资源上的DELETE HTTP 方法之前调用。这是因为你在DELETE调用中传递了新创建的地址 ID。通常,测试以随机顺序运行。如果DELETE调用在POST调用之前进行,则构建将失败,而不会测试适当的场景。

@TestInstance将测试实例的生命周期设置为每个类(TestInstance.Lifecycle.PER_CLASS),因为我们希望在集成测试执行之前清理和迁移数据库。

静态的init()方法被注解为@BeforeAll,将在所有测试之前运行。在这个方法中,你设置了objectMapperaddress模型。你还在这个方法中使用了 Flyway 实例来清理数据库模式,并使用migrate命令重新创建模式。

该方法的设置将在每个测试执行之前运行,因为它被标记为@BeforeEach注解。在这里,你确保只有在signedInUser为 null 或令牌已过期时才会进行登录调用。TestInfo实例帮助我们为不同的测试分配不同的用户 – scott2(管理员)和scott(非管理员)。

让我们添加一个集成测试,该测试将验证GET /api/v1/addresses REST 端点,如下面的代码所示:

@Test@DisplayName("returns all addresses")
@Order(6)
public void getAllAddress() throws IOException {
  // given
  MultiValueMap<String, String> headers =
       new LinkedMultiValueMap<>();
  headers.add(HttpHeaders.CONTENT_TYPE,
                           MediaType.APPLICATION_JSON_VALUE);
  headers.add(HttpHeaders.ACCEPT,
                           MediaType.APPLICATION_JSON_VALUE);
  headers.add("Authorization", "Bearer " +
      signedInUser.getAccessToken());
  // when
  ResponseEntity<JsonNode> addressResponseEntity =
    restTemplate.exchange("/api/v1/addresses",
       HttpMethod.GET,
          new HttpEntity<>(headers), JsonNode.class);
  // then
  assertThat(addressResponseEntity.getStatusCode())
    .isEqualTo(HttpStatus.OK);
  JsonNode n = addressResponseEntity.getBody();
  List<Address> addressFromResponse = objectMapper
   .convertValue(n,new TypeReference
      <ArrayList<Address>>(){});
  assertThat(addressFromResponse).hasSizeGreaterThan(0);
  assertThat(addressFromResponse.get(0))
    .hasFieldOrProperty("links");
  assertThat(addressFromResponse.get(0))
    .isInstanceOf(Address.class);
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter08/src/integration/java/com/packt/modern/api/controller/AddressControllerIT.java

首先,你必须设置给定部分的标题。在这里,你使用signedInUser实例来设置 bearer 令牌。接下来,你必须调用TestRestTemplate的 exchange 方法,该方法接受四个参数 – URI、HTTP方法、HttpEntity(如果需要,包含标题和有效负载),以及返回值的类型。你也可以使用可选的第五个参数,如果模板被用来设置urlVariables,这将扩展模板。

然后,你必须使用断言来执行验证过程。在这里,你可以看到它复制了实际的调用。

使用以下命令运行测试:

 $ gradlew clean integrationTest # or
 $ gradlew clean build

然后,你可以在Chapter08/build/reports/tests/integrationTest中找到测试报告。测试报告应该看起来像这样:

图 8.3 – 集成测试报告

图 8.3 – 集成测试报告

你可以在AddressControllerIT.java中找到所有的测试地址资源,它包含了成功、错误、认证和授权的测试。它对所有的操作类型都有测试,包括创建读取删除操作。

现在,你已经学会了如何编写集成测试。你可以利用这项技能为其他 REST 资源编写集成测试。

摘要

在本章中,你探索了手动和自动测试。你学习了如何使用 JUnit、Spring 测试库、AssertJ 和 Hamcrest 编写单元和集成测试。你还学习了如何使用 Gherkin 的Given > When > Then语言使测试更易读。然后你学习了如何分离单元和集成测试。

最后,你通过自动化单元和集成测试学习了各种测试自动化技能。这将帮助你自动化测试,并在将代码交付给质量分析师或客户之前捕捉到错误和漏洞。

在下一章中,你将学习如何将应用程序容器化并在 Kubernetes 中部署。

问题

  1. 单元测试和集成测试之间的区别是什么?

  2. 分离单元和集成测试有什么优势?

  3. 对象模拟和间谍行为之间的区别是什么?

答案

  1. 单元测试是为了测试最小的代码单元,例如一个方法,而集成测试是在涉及不同层或多个模块的地方进行的。在本章中,对整个应用程序进行了集成测试,涉及应用程序的所有层,包括数据库,而单元测试是按类对每个方法进行的。在本章的上下文中,单元测试是白盒测试,而 API 集成测试是一种黑盒测试,因为你要验证 API 的功能需求。

  2. 将单元和集成测试(包括它们的源位置)分开,可以让你轻松地管理测试。你也可以有一个可配置的构建设置,在开发期间或按需执行单元测试,因为单元测试更快。你可以使用gradlew clean build –x integrationTest命令仅运行单元测试,而在合并请求构建中,你可以执行集成测试以验证合并请求。默认的构建(gradlew clean build)将执行单元和集成测试。

  3. 当你使用Mockito.mock()@Mock时,它创建了一个给定类的完整伪造对象,然后你可以根据测试需求对该方法进行存根,而Mockito.spy()@Spy创建了一个真实对象,你可以对其所需的方法进行存根。如果没有在spy对象上执行存根,那么在测试期间将调用其实际方法。

进一步阅读

第八章:Web 服务的部署

在这一章中,你将了解容器化、Docker 和 Kubernetes 的基础知识。然后,你将使用这些概念使用 Docker 容器化一个示例电子商务应用程序。然后,这个容器将被部署到 Kubernetes 集群中。你将使用 Minikube 进行 Kubernetes,这使得学习和基于 Kubernetes 的开发变得更加容易。

完成这一章后,你将能够在一个 Kubernetes 集群中执行容器化和容器部署。

在这一章中,你将探索以下主题:

  • 探索容器化的基础知识

  • 构建 Docker 镜像

  • 在 Kubernetes 中部署应用程序

技术要求

你需要以下内容来开发和执行本章中的代码:

让我们开始吧!

什么是容器化?

在开发大型、复杂系统时,团队经常遇到的一个问题是,在一台机器上运行正常的代码在另一台机器上却无法工作。这类情况的主要原因是不匹配的依赖项(如不同版本的 Java、特定的 Web 服务器或操作系统)、配置或文件。

此外,设置新的环境以部署新产品有时需要一天或更长时间。在当今的环境下这是不可接受的,并且会减慢你的开发周期。这些问题可以通过容器化应用程序来解决。

在容器化中,应用程序被捆绑、配置,并包装了所有必需的依赖项和文件。这个捆绑包可以在支持容器化过程的任何机器上运行。这种捆绑确保了应用程序在所有环境中表现出完全相同的行为。因此,与配置或依赖项相关的错误可以得到解决,部署时间可以缩短到几分钟或更少。

这个位于物理机器及其操作系统之上的捆绑包被称为容器。这个容器以只读模式共享宿主机的内核以及其宿主操作系统的库和二进制文件。因此,容器是轻量级的。在这一章中,你将使用 Docker 和 Kubernetes 进行容器化和容器部署。

一个相关的概念是虚拟化——通过将现有硬件系统分割成不同的部分来创建虚拟环境的过程。每个部分都作为一个独立的、独特的、个体系统运行。这些系统被称为虚拟机VM)。每个虚拟机都运行在其自己的独特操作系统上,拥有自己的二进制文件、库和应用程序。虚拟机是重量级的,大小可达多个千兆字节GB)。一个硬件系统可以拥有运行不同操作系统(如 Unix、Linux 和 Windows)的虚拟机。以下图表描述了虚拟机和容器之间的区别:

图 9.1 – 虚拟机与容器

图 9.1 – 虚拟机与容器

有时候,人们认为虚拟化和容器化是同一件事,但实际上并非如此。虚拟机是在宿主系统之上创建的,它与虚拟机共享硬件,而容器是在硬件及其操作系统之上作为隔离进程执行的。容器轻量级,大小只有几 MB,有时是 GB,而虚拟机则是重量级的,大小可达多个 GB。容器比虚拟机运行得更快,而且它们也更加便携。

在下一节中,我们将通过构建 Docker 镜像来更详细地探讨容器。

构建 Docker 镜像

到目前为止,你已经知道了容器化的好处以及为什么它越来越受欢迎——你创建一个应用程序、产品或服务,使用容器化将其打包,然后交给 QA 团队、客户或 DevOps 团队运行,而不会出现任何问题。

在本节中,你将学习如何将 Docker 用作容器化平台。在创建示例电子商务应用程序的 Docker 镜像之前,让我们先了解一下它。

Docker 是什么?

Docker 于 2013 年推出,是领先的容器平台和开源项目。2013 年 8 月推出交互式教程后,有 1 万名开发者尝试了它。到 2013 年 6 月 1.0 版本发布时,它已被下载了 275 万次。许多大型公司与 Docker Inc.签订了合作协议,包括微软、红帽、惠普和 OpenStack,以及服务提供商如 AWS、IBM 和谷歌。

Docker 利用 Linux 内核特性来确保资源隔离和应用程序及其依赖项(如cgroupsnamespaces)的打包。Docker 容器中的所有内容都在宿主机上原生执行,并直接使用宿主机内核。每个容器都有自己的用户命名空间——一个用于进程隔离的进程标识符PID),一个用于管理网络接口的网络NET),用于管理对 IPC 资源访问的进程间通信IPC),用于管理文件系统挂载点的挂载点MNT),以及用于隔离内核和版本标识符的Unix 时间共享UTS)命名空间。这种依赖项的打包使得应用程序能够在不同的 Linux 操作系统和发行版上按预期运行,从而支持一定程度的可移植性。

此外,这种可移植性允许开发人员在任何语言中开发应用程序,然后轻松地从任何计算机(如笔记本电脑)部署到不同的环境,如测试、预发布或生产。Docker 在 Linux 上原生运行。然而,你还可以在 Windows 和 macOS 上运行 Docker。

容器仅由一个应用程序及其依赖项组成,包括基本的操作系统。这使得应用程序在资源利用方面轻量级且高效。开发人员和系统管理员对容器的可移植性和高效资源利用感兴趣。

我们将在下一小节中探讨 Docker 的架构。

理解 Docker 的架构

如其文档所述,Docker 使用客户端-服务器架构。Docker 客户端(Docker)基本上是一个命令行界面CLI),由最终用户使用;客户端与 Docker 服务器(读作 Docker 守护进程)进行双向通信。Docker 守护进程承担了繁重的工作,即构建、运行和分发你的 Docker 容器。Docker 客户端和守护进程可以运行在同一系统上或不同的机器上。

Docker 客户端和守护进程通过套接字或通过 RESTful API 进行通信。Docker 注册是公共或私有 Docker 镜像仓库,你可以从中上传或下载镜像——例如,Docker Hub (hub.docker.com) 是一个公共 Docker 注册库。

Docker 的主要组件如下:

  • Docker 镜像:Docker 镜像是一个只读模板。例如,一个镜像可以包含安装有 Apache 网络服务器和你的 Web 应用程序的 Ubuntu 操作系统。Docker 镜像是 Docker 的构建组件,镜像用于创建 Docker 容器。Docker 提供了一种简单的方法来构建新镜像或更新现有镜像。你也可以使用其他人创建的镜像,或者扩展它们。

  • docker statsdocker events 用于容器使用统计,如 CPU 和内存使用,以及 Docker 守护进程执行的活动。这些命令有助于你在部署环境中监控 Docker。

Docker 容器生命周期

你还需要了解 Docker 的容器生命周期,如下所示:

  1. docker create 命令。

  2. docker run 命令。

  3. docker pause 命令。

  4. docker unpause 命令。

  5. docker start 命令。

  6. docker stop 命令。

  7. docker restart 命令。

  8. docker kill 命令。

  9. docker rm 命令。因此,这仅应针对已停止状态的容器执行。

到目前为止,你可能急于使用 Docker 容器生命周期,但首先,你需要通过访问docs.docker.com/get-docker/来安装 Docker。

一旦您安装了 Docker,请转到docs.docker.com/get-started/#start-the-tutorial以执行第一个 Docker 命令。您可以参考docs.docker.com/engine/reference/commandline/docker/来了解更多关于 Docker 命令的信息。

更多信息,您可以查看 Docker 提供的 Docker 概述(docs.docker.com/get-started/overview/)。

让我们进行必要的代码更改,以便我们可以为示例电子商务应用程序创建一个 Docker 镜像。

通过添加 Actuator 依赖项来构建镜像的编码

我建议您参考第八章测试 API,作为本章代码的基础。您不需要任何额外的库来创建 Docker 镜像。然而,您确实需要添加 Spring Boot Actuator 依赖项,它为我们将要创建的示例电子商务应用程序提供生产就绪功能。

依赖项的功能可以帮助您使用 HTTP REST API 和/actuator/health端点来监控和管理应用程序,该端点告诉我们应用程序的健康状态。为了本练习的目的,仅找出运行在 Docker 容器内的服务/应用程序的健康状况就足够了。

您可以通过以下步骤添加 Actuator:

  1. 将 Actuator 依赖项添加到build.gradlegithub.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter09/build.gradle):

    /actuator endpoints. Let’s add a constant to Constants.java (https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter09/src/main/java/com/packt/modern/api/security/Constants.java) for the Actuator URL, as shown here:
    
    

    SecurityConfig.java(github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter09/src/main/java/com/packt/modern/api/security/SecurityConfig.java),如下所示:

    // rest of the codereq.requestMatchers(toH2Console()).permitAll()    .requestMatchers(new AntPathRequestMatcher(       ACTUATOR_URL_PREFIX)).permitAll()    .requestMatchers(new AntPathRequestMatcher      (TOKEN_URL, HttpMethod.POST.name())).permitAll()// rest of the code
    
    
    

这样,您已经添加了一个带有 Actuator 端点的匹配器。这允许所有 Actuator 端点都可以带或不带身份验证和授权进行访问。

现在,您可以为名为bootBuildImage的 Spring Boot 插件任务配置,以自定义 Docker 镜像的名称。我们将在下一小节中这样做。

配置 Spring Boot 插件任务

Spring Boot Gradle 插件已经提供了一个命令(bootBuildImage)来构建 Docker 镜像。当在plugins部分应用 Java 插件时,它就可用。bootBuildImage任务仅适用于构建.jar文件,不适用于构建.war文件。

您可以通过在build.gradle文件中添加以下代码块来自定义镜像的名称:

bootBuildImage {   imageName = "192.168.1.2:5000/${project.name}:${
      project.version}"
}

在这里,更改本地 Docker 仓库的 IP 地址和端口号。Docker 仓库的配置将在下一节中解释。将基于您的项目名称和版本构建 Docker 镜像。项目版本已在 build.gradle 文件的顶部定义。另一方面,项目名称是从 settings.gradle 文件中选择的 (github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter09/settings.gradle)。让我们将其重命名,如下面的代码片段所示:

rootProject.name = 'packt-modern-api-development-chapter09'

第八章 中,测试 APIrootProject.name 的值包含一个字母大写,因此 Docker 镜像构建失败。这是因为插件有一个针对大写字母的验证检查。因此,Docker 镜像名称应仅使用小写字母。

更多信息和自定义选项,请参阅插件文档 (docs.spring.io/spring-boot/docs/current/gradle-plugin/reference/htmlsingle/#build-image)。

现在您已经配置了代码,您可以在配置 Docker 仓库后使用它来构建镜像。您将在下一小节中这样做。

配置 Docker 仓库

如果您已安装 Docker Desktop,默认情况下,当您构建镜像(gradlew bootBuildImage)时,它将被命名为 docker.io/library/packt-modern-api-development-chapter09:0.0.1-SNAPSHOT。在这里,名称指的是 docker.io/library/packt-modern-api-development-chapter09,版本指的是 0.0.1-SNAPSHOT。您可能想知道为什么名称前面有 docker.io/library 前缀。这是因为如果您没有指定 Docker 仓库,它将默认使用 docker.io 仓库。您需要一个可以从中拉取和推送镜像的 Docker 仓库。它就像一个工件仓库,您可以在其中推送和拉取工件,例如 Spring 库。

一旦构建了镜像,您可以通过应用您的 Docker Hub 登录凭证将其推送到 Docker Hub。然后,您可以从 Docker Hub 获取镜像以部署到您的 Kubernetes 环境。出于开发目的,这并不是一个理想的情况。最佳选项是配置本地 Docker 仓库,然后将其用于 Kubernetes 部署。

使用 Git Bash 在 Windows 上

您可以在 Windows 上使用 Git Bash 运行这些命令;它模拟 Linux 命令。

让我们执行以下命令来检查 Docker 是否正在运行:

$ docker versionClient:
 Cloud integration: v1.0.22
 Version:           20.10.11
 API version:       1.41
 Go version:        go1.16.10
 Git commit:        dea9396
 Built:             Thu Nov 18 00:36:09 2021
 OS/Arch:           darwin/amd64
 Context:           default
 Experimental:      true
Server: Docker Engine – Community
 Engine:
  Version:          20.10.11
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.16.9
// Output truncated for brevity

在这里,已安装 Docker。因此,当您运行 docker version 时,它会显示输出。没有错误输出的版本输出确认 Docker 正在运行。

现在,您可以使用以下命令拉取并启动 Docker 仓库:

$ docker run -d -p 5000:5000 -e REGISTRY_STORAGE_DELETE_ENABLED=true --restart=always --name registry registry:2Unable to find image 'registry:2' locally
2: Pulling from library/registry
ef5531b6e74e: Pull complete
a52704366974: Pull complete
dda5a8ba6f46: Pull complete
eb9a2e8a8f76: Pull complete
25bb6825962e: Pull complete
Digest: sha256:41f413c22d6156587e2a51f3e80c09808b8c70e82be149b82b5e01 96a88d49b4
Status: Downloaded newer image for registry:2
bca056bf9653abb14ee6c461612a999c7c61ab45ea8837ecfa1c4b1ec5e5f047

在这里,当您第一次运行 Docker 仓库时,它会先下载 Docker 仓库镜像然后再运行。Docker 仓库的执行会在端口 5000 上创建一个名为 registry 的容器。如果端口 5000 被您的机器上的其他服务使用,那么您可以使用不同的端口,例如 5001。有两个端口条目——一个是内部容器端口,另一个是公开的外部端口。两者都设置为 5000–restart=always 标志告诉 Docker 每次重启 Docker 时都启动仓库容器。REGISTRY_STORAGE_DELETE_ENABLED 标志,正如其名称所暗示的,用于从 registry 中删除任何镜像,因为它被设置为 true。此标志的默认值是 false

现在,让我们检查容器:

$ docker psCONTAINER ID   IMAGE        COMMAND
CREATED          STATUS          PORTS                    NAMES
bca056bf9653   registry:2   "/entrypoint.sh /etc…"
11 minutes ago   Up 11 minutes   0.0.0.0:5000->5000/tcp   registry

这表明 Docker 容器仓库正在运行,并且是使用 registry:2 镜像创建的。

当您使用容器时,主机名是必要的。因此,您将使用 IP 地址而不是本地主机名来指定仓库。这是因为当您使用 localhost 作为主机名时,容器将引用其自身的 localhost,而不是您系统的 localhost。在 Kubernetes 环境中,您需要提供一个仓库主机,因此您需要使用 IP 或适当的域名来代替 localhost

让我们通过运行以下命令来找出我们可以使用的 IP:

# For Mac$ echo $(osascript -e "Ipv4 address of (system info)")
192.168.1.2
# For Windows
$ ipconfig
Windows IP Configuration
Ethernet adapter Ethernet:
  Media State . . . . . . . . . . . : Media disconnected
  Connection-specific DNS Suffix . :
Ethernet adapter vEthernet (Default Switch):
  Connection-specific DNS Suffix . :
  Link-local Ipv6 Address . . . . . : ef80::2099:f848:8903:f996%81
  Ipv4 Address. . . . . . . . . . . : 192.168.1.2
  Subnet Mask . . . . . . . . . . . : 255.255.240.0
  Default Gateway . . . . . . . . . :

您可以在前面的输出中高亮显示的行中找到您系统的 IP 地址。您可以在 Linux 上使用类似的命令来查找您系统的 IP 地址。

我们尚未为系统主机配置传输层安全性TLS),因此这个仓库是不安全的。Docker 默认只支持安全的仓库。我们必须配置 Docker 以使其能够使用不安全的仓库。请参考 Docker 文档了解如何配置不安全的仓库(docs.docker.com/registry/insecure/-deploy-a-plain-http-registry)。

在 daemon.json 中添加不安全的仓库

daemon.json 在 Linux 上位于 /etc/docker/daemon.json

  1. 对于 Mac/Windows 上的 Docker Desktop,导航到 Docker 应用 | 设置 | Docker 引擎

  2. insecure-registries 条目添加到 JSON 中:

{

“``features”: {

“``buildkit”: true

},

“``insecure-registries”: [

“``192.168.1.2:5000”

],

}

  1. 重启 Docker。

注意,为了成功构建和发布镜像,Docker 配置必须使用本地仓库进行,如前所述。

注意

为了安全起见,不要在任何非本地或开发环境中使用不安全的仓库。

现在,让我们为示例电子商务应用创建一个 Docker 镜像。

执行 Gradle 任务来构建镜像

您需要修改bootBuildImage任务,以便镜像的名称包含本地 Docker 仓库的前缀。Spring Boot 的bootBuildImage使用 Paketo Buildpacks 构建 Docker 镜像。它支持长期支持LTS)Java 版本和仅当前的非 LTS Java 版本。这意味着,当非 LTS Java 20 发布时,它将移除对 Java 19 的支持。同样,当 Java 21 发布时,它将移除对 Java 20 的支持。但是,它不会移除对 Java 17 的支持,因为 Java 17 是一个 LTS 版本。我们可以这样进行更改:

bootBuildImage {   imageName = "192.168.1.2:5000/${project.name}:${
       project.version}"
   environment = ["BP_JVM_VERSION" : "17"]
}

在这里,您根据本地 Docker 仓库已自定义 Docker 镜像的名称。您应根据您的系统和配置更改 IP 地址和端口。您还使用了环境属性来设置 Paketo Buildpacks 变量。您已将 JVM 版本设置为 17。建议使用 Java 17(或任何未来的 LTS 版本)。您可以在github.com/paketo-buildpacks/bellsoft-liberica#configuration找到所有受支持的 Paketo Buildpacks 环境变量。在撰写本文时,Paketo Buildpacks 不提供官方支持为 ARM 构建镜像。然而,有可用的替代构建器,如github.com/dashaun/paketo-arm64,它支持在 ARM 上构建。

现在,您可以从项目的根目录执行以下命令来构建镜像:

$ ./gradlew clean build    # build the jar file of app after running the tests
$ ./gradlew bootBuildImage
> Task :bootBuildImage
Building image '192.168.1.2:5000/packt-modern-api-development-chapter09:0.0.1-SNAPSHOT'
 > Pulling builder image
'docker.io/paketobuildpacks/builder:base'
 ..................................................
 > Pulled builder image
'paketobuildpacks/builder@sha256:e2bf5f2355b0daddb61c6c7ed3e55e58ab581 900da63f892949ded8b772048ee'
 > Pulling run image 'docker.io/paketobuildpacks/run:base-cnb'
 ..................................................
 > Pulled run image
'paketobuildpacks/run@sha256:4a2fbf87a81964ef1a95445f343938ed19406fff da142586a35c9e20904a3315'
 > Executing lifecycle version v0.16.0
 > Using build cache volume 'pack-cache-2fdc28fe99dc.build'
// continue…

Spring Boot Gradle 插件使用Paketo BellSoft Liberica Buildpackdocker.io/paketobuildpacks)来构建应用程序镜像。首先,它从 Docker Hub 拉取镜像,然后运行其容器,如下所示:

> Running creator    [creator]  ===> ANALYZING
    [creator]  Previous image with name
               "192.168.1.2:5000/packt-modern-api-development-
               chapter09:0.0.1-SNAPSHOT" not found
    [creator]  ===> DETECTING
    // truncated output for brevity
    [creator]  ===> RESTORING
    [creator]  ===> BUILDING
    // truncated output for brevity
    [creator]
    [creator]  Paketo Buildpack for BellSoft Liberica 9.11.0
    [creator]  https://github.com/paketo-buildpacks/bellsoft-
               liberica
    // truncated output for brevity
    [creator]  Using Java version 17 from BP_JVM_VERSION
[creator] BellSoft Liberica JRE 17.0.6: Contributing to layer
    [creator]  Downloading from https://github.com/bell-sw/Liberica/releases/download/17.0.6+10/bellsoft-jre17.0.6+10-linux-amd64.tar.gz
    [creator]  Verifying checksum
    [creator]  Expanding to /layers/paketo-
               buildpacks_bellsoft-liberica/jre
    // truncated output for brevity

在这里,Spring Boot 插件使用 Bellsoft 的 JRE 17.0.6 和 Linux 作为基础镜像来构建镜像。它使用容器内部的细粒度文件系统层来完成此操作:

    [creator]  Launch Helper: Contributing to layer    [creator]  Creating /layers/paketo-buildpacks_bellsoft-
               liberica/helper/exec.d/active-processor-count
    [creator]  Creating /layers/paketo-buildpacks_bellsoft-
               liberica/helper/exec.d/java-opts
    // truncated output for brevity
    [creator]  Paketo Buildpack for Syft 1.26.0
    [creator]  https://github.com/paketo-buildpacks/syft
    [creator]  Downloading from
               https://github.com/anchore/syft/releases/
               download/v0.75.0/syft_0.75.0_linux_amd64.tar.gz
    // truncated output for brevity
    [creator]  Paketo Buildpack for Executable JAR 6.6.2
[creator] https://github.com/paketo-buildpacks/executable-jar
    [creator]       Class Path: Contributing to layer
    // truncated output for brevity

插件继续添加层和标签,最后创建 Docker 镜像:

  [creator]  Paketo Buildpack for Spring Boot 5.23.0  [creator]  https://github.com/paketo-buildpacks/spring-boot
  // truncated output for brevity
  [creator]     ===> EXPORTING
  [creator]  Adding layer 'paketo-buildpacks/ca-
             certificates:helper'
  // truncated output for brevity
  [creator]  Adding layer 'paketo-buildpacks/executable-
             jar:classpath'
  [creator]  Adding layer 'paketo-buildpacks/spring-
             boot:helper'
  [creator]  Adding layer 'paketo-buildpacks/spring-
             boot:spring-cloud-bindings'
  [creator]  Adding layer 'paketo-buildpacks/spring-boot:web-
             application-type'
  [creator]  Adding 5/5 app layer(s)
  [creator]  Adding layer 'buildpacksio/lifecycle:launcher'
  // truncated output for brevity
  [creator]  Adding label 'org.springframework.boot.version'
  [creator]  Setting default process type 'web'
  [creator]  *** Images (9cc6ef620b7c):
  [creator]  192.168.1.2:5000/packt-modern-api-development-
             chapter09:0.0.1-SNAPSHOT
Successfully built image '192.168.1.2:5000/packt-modern-api-development-chapter09:0.0.1-SNAPSHOT'
BUILD SUCCESSFUL in 1m 22s

您可以在github.com/dsyer/kubernetes-intro了解更多关于 Spring Boot、Docker 和 Kubernetes 及其配置的信息。

现在 Docker 镜像已经构建完成,您可以使用以下命令使用此镜像在本地运行示例电子商务应用:

$ docker run -p 8080:8080 192.168.1.2:5000/packt-modern-api-development-chapter09:0.0.1-SNAPSHOT

此命令将在容器内部运行端口8080上的应用程序。因为它已经在端口8080上暴露,所以一旦应用程序启动并运行,您也可以在容器外部通过8080访问示例电子商务应用。您可以在应用程序容器启动并运行后,在另一个终端标签页/窗口中运行以下命令来测试应用程序:

$ curl localhost:8080/actuator/health{"status":"UP"}
$ curl localhost:8080/actuator
{
 "_links": {
  "self": {
    "href": "http://localhost:8080/actuator",
    "templated": false },
  "health-path": {
    "href": "http://localhost:8080/actuator/ health/{*path}",
    "templated": true },
  "health": {
    "href": "http://localhost:8080/actuator/health",
    "templated": false }
  }
}

curl localhost:8080/actuator命令返回可用的 Actuator 端点,例如healthhealth-path

您也可以使用以下命令列出容器及其状态:

$ docker psCONTAINER ID   IMAGE                                                                    COMMAND                  CREATED          STATUS           PORTS                    NAMES
62255c54ab52   192.168.1.2:5000/packt-modern-api-development-chapter 09:0.0.1-SNAPSHOT   "/cnb/process/web"       7 minutes ago    Up 7 minutes    0.0.0.0:8080->8080/tcp   elated_ramanujan
bca056bf9653   registry:2                                                               "/entrypoint.sh /etc…"   58 minutes ago   Up 58 minutes   0.0.0.0:5000->5000/tcp   registry

接下来,让我们运行以下命令来查找可用的 Docker 镜像:

$ docker imagesREPOSITORY                                                          TAG              IMAGE ID       CREATED        SIZE
paketobuildpacks/run                                                base-cnb         68c538f4e078   5 hours ago    87MB
registry                                                            2                 0d153fadf70b   5 weeks ago    24.2MB
paketobuildpacks/builder                                            base             38446f68a5f8   43 years ago   1.26GB
192.168.1.2:5000/packt-modern-api-development-chapter09             0.0.1-SNAPSHOT   9cc6ef620b7c   43 years ago   311MB

现在,您可以使用以下命令标记并推送应用程序镜像:

$ docker tag 192.168.1.2:5000/packt-modern-api-development-chapter09:0.0.1-SNAPSHOT 192.168.1.2:5000/packt-modern-api-development-chapter09:0.0.1-SNAPSHOT$ docker push 192.168.1.2:5000/packt-modern-api-development-chapter09:0.0.1-SNAPSHOT
…
b7e0fa7bfe7f: Pushed
0.0.1-SNAPSHOT: digest: sha256:bde567c41e57b15886bd7108beb26b5de7b44c6 6cdd3500c70bd59b8d5c58ded size: 5327

同样,您也可以查询本地 Docker 注册表容器。首先,让我们运行以下命令以找到注册表中所有已发布的镜像(默认值为 100):

$ curl -X GET http://192.168.1.2:5000/v2/_catalog{"repositories":["packt-modern-api-development-chapter09"]}

同样,您可以使用以下命令找出任何特定镜像的所有可用标签:

$ curl -X GET http://192.168.1.2:5000/v2/packt-modern-api-development-chapter09/tags/list{"name":"packt-modern-api-development-chapter09","tags":["0.0.1-SNAPSHOT"]}

对于这些命令,如果您运行的是本地注册表容器,您也可以使用 localhost 而不是 IP。

在下一节中,我们将部署此镜像到 Kubernetes。

在 Kubernetes 中部署应用程序

Docker 容器在隔离状态下运行。您需要一个可以执行多个 Docker 容器并管理或扩展它们的平台。Docker Compose 为我们做了这件事。然而,这正是 Kubernetes 发挥作用的地方。它不仅管理容器,还帮助您动态地扩展已部署的容器。

您将使用 Minikube 在本地运行 Kubernetes。您可以在 Linux、macOS 和 Windows 上使用它。它运行一个单节点 Kubernetes 集群,用于学习和开发目的。您可以通过参考相应的指南(minikube.sigs.k8s.io/docs/start/)来安装它。

一旦 Minikube 安装完毕,您需要更新 Minikube 的本地不安全注册表,因为默认情况下,Minikube 的注册表使用 Docker Hub。将镜像添加到 Docker Hub 然后为本地使用获取它是开发中的繁琐工作。您可以通过将您的宿主 IP 和本地 Docker 注册表端口添加到 Minikube 的配置文件 ~/.minikube/machines/minikube/config.json 中的 HostOptions | EngineOptions | InsecureRegistry 来将本地不安全注册表添加到您的 Minikube 环境中(请注意,此文件仅在 Minikube 启动一次后生成;因此,在修改 config.json 之前启动 Minikube):

$ vi ~/.minikube/machines/minikube/config.json 41     …
 42     "DriverName": "qemu2",
 43     "HostOptions": {
 44         "Driver": "",
 45         "Memory": 0,
 46         "Disk": 0,
 47         "EngineOptions": {
 48             "ArbitraryFlags": null,
 49             "Dns": null,
 50             "GraphDir": "",
 51             "Env": null,
 52             "Ipv6": false,
 53             "InsecureRegistry": [
 54                 "10.96.0.0/12",
 55                 "192.168.1.2:5000"
 56             ],
 57     …

一旦不安全的注册表已更新,您可以使用以下命令启动 Minikube:

$ minikube start --insecure-registry="192.168.80.1:5000"😄  minikube v1.29.0 on Darwin 13.1
✨  Using the qemu2 driver based on existing profile
👍  Starting control plane node minikube in cluster minikube
🔄  Restarting existing qemu2 VM for "minikube" ...
🐳  Preparing Kubernetes v1.26.1 on Docker 20.10.23 ...
🔗  Configuring bridge CNI (Container Networking Interface) ...
    ▪ Using image gcr.io/k8s-minikube/storage-provisioner:v5
🔎  Verifying Kubernetes components...
🌟  Enabled addons: default-storageclass, storage-provisioner
🏄  Done! kubectl is now configured to use "minikube" cluster and "default" namespace by default

在这里,我们在启动 Minikube 时使用了 --insecure-registry 标志。这是很重要的,因为它使不安全注册表工作。Kubernetes 集群默认使用默认命名空间。

命名空间 是 Kubernetes 特殊对象,允许您将 Kubernetes 集群资源在用户或项目之间划分。然而,您不能有嵌套的命名空间。Kubernetes 资源只能属于单个命名空间。

一旦 Minikube 启动并运行,您可以通过执行以下命令来检查 Kubernetes 是否工作:

$ kubectl get po -ANAMESPACE    NAME                             READY   STATUS    RESTARTS        AGE
kube-system  coredns-787d4945fb-5hzc2         1/1     Running   3 (17m ago)     30m
kube-system  etcd-minikube                    1/1     Running   5 (17m ago)     32m
kube-system  kube-apiserver-minikube          1/1     Running   4 (17m ago)     32m
kube-system  kube-controller-manager-minikube 1/1     Running   5 (3m58s ago)   32m
kube-system  kube-proxy-z4n66                 1/1     Running   4 (17m ago)     31m
kube-system  kube-scheduler-minikube          1/1     Running   4 (17m ago)     32m
kube-system  storage-provisioner              1/1     Running   2 (3m25s ago)   18m

kubectl 命令是一个用于控制 Kubernetes 集群的命令行工具,类似于 Docker 的 docker 命令。它是一个 Kubernetes 客户端,使用 Kubernetes REST API 执行各种 Kubernetes 操作,例如部署应用程序、查看日志以及检查和管理集群资源。

get poget pod参数允许您从您的 Kubernetes 集群中检索 Pod。-A标志指示kubectl从所有命名空间检索对象。在这里,您可以看到所有 Pod 都来自kube-system命名空间。

这些 Pod 是由 Kubernetes 创建的,是它内部系统的一部分。

Minikube 将 Kubernetes 仪表板捆绑为 UI,以便对集群的状态有更深入的洞察。您可以通过以下命令启动它:

$ minikube dashboard🔌  Enabling dashboard ...
    ▪ Using image docker.io/kubernetesui/dashboard:v2.7.0
    ▪ Using image docker.io/kubernetesui/metrics-
      scraper:v1.0.8
💡  Some dashboard features require the metrics-server addon. To enable all features please run:
  minikube addons enable metrics-server
🤔  Verifying dashboard health ...
🚀  Launching proxy ...
🤔  Verifying proxy health ...
🎉  Opening http://127.0.0.1:56858/api/v1/namespaces/kubernetes-dashboard/services/http:kubernetes-dashboard:/proxy/ in your default browser...

运行仪表板允许您通过 UI 管理 Kubernetes 集群,其外观如下:

图 9.2 – Kubernetes 仪表板

图 9.2 – Kubernetes 仪表板

Kubernetes 使用 YAML 配置来创建对象。例如,您需要一个部署和服务对象来部署和访问示例电子商务应用程序。部署将在 Kubernetes 集群中创建一个 Pod,该 Pod 将运行应用程序容器,服务将允许访问它。您可以手动创建这些 YAML 文件,或者使用kubectl生成它们。通常,您应该使用kubectl,它会为您生成文件。如果需要,您可以修改文件的内容。

让我们在项目的根目录中创建一个新的目录(k8s),这样我们就可以存储 Kubernetes 部署配置。我们可以通过在新建的k8s目录中使用以下命令来生成部署 Kubernetes 配置文件:

$ kubectl create deployment chapter09--image=192.168.1.2:5000/packt-modern-api-developmentchapter09:0.0.1-SNAPSHOT --dry-run=client -o=yaml > deployment.yaml
$ echo --- >> deployment.yaml
$ kubectl create service clusterip chapter09 --tcp=8080:8080 --dry-run=client -o=yaml >> deployment.yaml

在这里,第一个命令使用create deployment命令在deployment.yaml文件中生成部署配置。Kubernetes 部署定义了您希望运行应用程序的规模。您可以看到副本被定义为1。因此,Kubernetes 将运行该部署的单个副本。在这里,您传递了部署的名称(chapter09)、要部署的应用程序的镜像名称、--dry-run=client标志以预览将发送到集群的对象,以及-o=yaml标志以生成 YAML 输出。

第二个命令将---追加到deployment.yaml文件的末尾。

最后,第三个命令在deployment.yaml中创建了服务配置,内部和外部端口都设置为8080

在这里,您使用了相同的文件来部署和服务对象。然而,您可以创建两个单独的文件——deployment.yamlservice.yaml。在这种情况下,您需要单独在您的 Kubernetes 集群中应用这些对象。

让我们看看deployment.yaml文件的内容,该文件是由前面的代码块生成的:

apiVersion: apps/v1kind: Deployment
metadata:
  creationTimestamp: null
  labels:
    app: chapter09
  name: chapter09
spec:
  replicas: 1
  selector:
    matchLabels:
      app: chapter09
  strategy: {}
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: chapter09
    spec:
      containers:
      - image: 192.168.1.2:5000/
       packt-modern-api-developmentchapter09:0.0.1-SNAPSHOT
        name: packt-modern-api-developmentchapter09
        resources: {}
status: {}
---
apiVersion: v1
kind: Service
metadata:
  creationTimestamp: null
  labels:
    app: chapter09
  name: chapter09
spec:
  ports:
  - name: 8080-8080
    port: 8080
    protocol: TCP
    targetPort: 8080
  selector:
    app: chapter09
  type: ClusterIP
status:
  loadBalancer: {}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter09/k8s/deployment.yaml

现在,您可以使用之前创建的deployment.yaml文件,从项目根目录运行以下命令来部署示例电子商务应用程序:

$ kubectl apply -f k8s/deployment.yamldeployment.apps/chapter09 created
service/chapter09 created

这将在成功创建后,在 Kubernetes 上部署一个示例电子商务应用程序(github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter09)。

或者,您可以执行以下步骤将 Docker 镜像发布到 Minikube。启动一个新的终端并执行以下命令(这里应使用相同的终端窗口,因为eval命令仅在活动终端中有效):

  1. 执行eval $(minikube docker-env)以将 Minikube 环境与您的 Docker 配置对齐。

  2. 执行gradle bootBuildImage以基于 Minikube 环境生成镜像。

  3. 执行以下命令:

    minikube stop and minikube start to ensure that the new configuration is applied.
    
  4. 您可以使用以下命令启动 Minikube 日志:

    kubectl apply -f deploymentTest.yaml command.
    

这将启动chapter09的应用程序部署。然后,您可以使用 Kubernetes 仪表板或kubectl get all命令来检查 Pod 和服务的状态。Pods是 Kubernetes 中最小且最可部署的对象。它们包含一个或多个容器,并代表 Kubernetes 集群中运行进程的单个实例。Pod 的 IP 地址和其他配置细节可能会更改,因为 Kubernetes 跟踪这些信息,如果 Pod 崩溃,它可能会替换它们。因此,Kubernetes 服务在其暴露的 Pod 的 IP 地址上添加了一个抽象层,并管理映射到内部 Pod。

让我们运行以下命令以找出 Pod 和服务的状态:

$ kubectl get allNAME  READY STATUS  RESTARTS   AGE
pod/chapter09-845f48cc7f-55zqr 1/1 Running 0   9m17s
NAME  TYPE CLUSTER-IP   EXTERNAL-IP   PORT(S)    AGE
service/chapter09   ClusterIP   10.100.135.86   <none>        8080/TCP   9m18s
service/kubernetes   ClusterIP   10.96.0.1  <none>        443/TCP    65m
NAME  READY UP-TO-DATE  AVAILABLE   AGE
deployment.apps/chapter09  1/1   1   1     9m18s
NAME   DESIRED CURRENT READY AGE
replicaset.apps/chapter09-845f48cc7f 1   1   1   9m17s

这将返回默认命名空间中的所有 Kubernetes 资源。在这里,您可以看到它返回了一个正在运行的 Pod、一个服务、一个部署资源以及chapter09的 ReplicaSet。您需要多次运行此命令,直到找到成功或错误的响应(例如“image is not pullable”)。

您无法直接访问运行在 Kubernetes 内部的程序,正如您可以从以下命令的响应中看到:

$ curl localhost:8080/actuator/healthcurl: (7) Failed to connect to localhost port 8080 after 0 ms: Connection refused

您必须使用某种类型的代理或 SSH 隧道来访问运行在 Kubernetes 集群内部的程序。让我们快速使用以下命令创建一个 SSH 隧道:

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

应用程序现在在 Kubernetes 集群内部运行于端口8080。它也被映射到本地机器的端口8080。由于这种端口映射,您可以在 Kubernetes 集群外部访问应用程序。

让我们在打开一个新的终端窗口后再次尝试访问应用程序:

$ curl localhost:8080/actuator/health{"status":"UP","groups":["liveness","readiness"]}

有了这些,应用程序已成功部署到我们的 Kubernetes 集群。现在,您可以使用 Postman 集合并运行所有可用的REST端点。

摘要

在本章中,你学习了容器化及其与虚拟化的不同之处。你还学习了 Docker 容器化平台以及如何使用 Spring Boot 插件为示例电子商务应用程序生成 Docker 镜像。

然后,你学习了 Docker 仓库以及如何配置本地不安全的仓库,以便你可以用它来本地推送和拉取镜像。相同的命令也可以用来从远程 Docker 仓库推送和拉取镜像。

你还通过使用 Minikube 学习了 Kubernetes 及其集群操作。你配置了它,以便可以从不安全的本地 Docker 仓库拉取 Docker 镜像。

现在,你拥有了构建 Spring Boot 应用程序的 Docker 镜像并将其部署到 Kubernetes 集群所必需的技能。

在下一章中,你将学习 gRPC API 的基础知识。

问题

  1. 虚拟化和容器化之间有什么区别?

  2. 什么是 Kubernetes 的用途?

  3. 什么是 kubectl

答案

  1. 虚拟化用于在宿主系统之上创建虚拟机,虚拟机共享其硬件,而容器化则创建在硬件及其操作系统之上执行的容器。容器轻量级,只需要几个 MB(偶尔需要 GB)。虚拟机重量级,需要多个 GB。容器运行速度更快,比虚拟机更易于携带。

  2. Kubernetes 是一个容器编排系统,用于管理应用程序容器。它跟踪运行中的容器。当容器未被使用时,它会关闭容器,并重启孤儿容器。Kubernetes 集群也用于扩展。当需要时,它可以自动配置资源,如 CPU、内存和存储。

  3. kubectl 是 Kubernetes 的 kubectl。在本章中,你使用了 kubectlapplycreate 命令。

进一步阅读

第三部分 – gRPC、日志记录和监控

在本部分,你将学习基于 gRPC 的 API 开发。完成本部分后,你将能够区分基于 gRPC 的 API 与 REST 和反应式 API。你将能够使用 Protobuf 架构构建服务器和客户端。最后,你将能够促进分布式日志记录和跟踪,将日志收集为 Elasticsearch 索引,该索引将用于在 Kibana 应用程序上进行调试和分析。

本部分包含以下章节:

  • 第十章开始使用 gRPC

  • 第十一章gRPC API 开发和测试

  • 第十二章为服务添加日志和跟踪

第九章:开始使用 gRPC

gRPC 是一个用于网络中通用远程过程调用(RPC)的开源框架。RPC 允许远程过程(托管在不同的机器上)像调用本地过程一样调用,而无需编写远程交互的详细信息。RPC 在gRPC缩写中具有恒定的意义。看起来很合理,gRPC 中的g代表Google,因为它最初是在那里开发的。但是,g的含义随着每个版本的发布而改变。对于其第一个版本 1.0,gRPC 中的g代表 gRPC 本身。也就是说,在版本 1.0 中,它代表gRPC 远程过程调用。在本章中,你将使用 gRPC 版本 1.54,其中g代表优雅。因此,你可以将 gRPC 称为优雅的远程过程调用(针对版本 1.54)。你可以在github.com/grpc/grpc/blob/master/doc/g_stands_for.md找到不同版本中g的所有含义。

在本章中,你将学习 gRPC 的基础知识,例如其架构、服务定义、生命周期、服务器和客户端。本章将为你提供一个基础,你可以用它来实现基于 gRPC 的 API。这些基础知识将帮助你在一个示例电子商务应用中实现服务间通信。

你将在下一章使用基于 gRPC 的 API 来开发一个基本的支付网关,用于处理电子商务应用中的支付。

注意

gRPC 发音为Jee-Arr-Pee-See

你将在本章中探索以下主题:

  • 介绍和 gRPC 架构

  • 理解服务定义

  • 探索 gRPC 生命周期

  • 理解 gRPC 服务器和 gRPC 存根

  • 处理错误

完成本章后,你将理解 gRPC 的基础知识,这将有助于你在下一章实现基于 gRPC 的 Web 服务。

技术要求

本章仅包含 gRPC 的理论。然而,在开发和使用基于 gRPC 的 Web 服务时,你通常需要一个 gRPC API 客户端,如 Insomnia。

你将在本章学习 gRPC 的基础知识,因此本章没有自己的代码仓库。然而,对于实际代码,你可以参考第十一章的代码,位于github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11

gRPC 是如何工作的?

gRPC 是一个用于网络中通用 RPC 的开源框架。gRPC 支持全双工流式传输,并且与 HTTP/2 语义大致一致。它支持不同的媒体格式,如协议缓冲区Protobuf)、JSON、XML 和 Thrift。Protobuf 是默认的媒体格式。由于性能更高,使用 Protobuf 优于其他格式。

gRPC 将REST(表示状态传输)和 RPC 的最佳之处带到了桌面上,非常适合通过 API 进行分布式网络通信。它提供了一些显著的功能,如下所示:

  • 它是为高度可扩展的分布式系统设计的,并提供了低延迟

  • 它提供了负载均衡和故障转移。

  • 由于其分层设计,它可以在应用层轻松集成,以进行与流控制的交互。

  • 它支持级联调用取消。

  • 它提供了广泛的通信——移动应用到服务器、Web 应用到服务器以及不同机器上的任何 gRPC 客户端应用到 gRPC 服务器应用。

你已经了解了 REST 及其实现。让我们在下一小节中找出 REST 和 gRPC 之间的差异,这为你提供了不同的视角,并允许你根据你的需求和用例在 REST 或 gRPC 之间进行选择。

REST 与 gRPC

gRPC 基于客户端-服务器架构,而 REST 则不是。

与 REST 相比,gRPC 和 REST 都利用了 HTTP 协议。gRPC 支持 HTTP/2 规范和全双工流通信,而 REST 在语音或视频通话等场景中表现良好。

在 REST 中,你可以通过查询参数、路径参数和请求体传递有效载荷。这意味着请求有效载荷/数据可以通过不同的来源传递,从而导致从不同来源解析有效载荷/数据,这增加了延迟和复杂性。另一方面,gRPC 由于使用静态路径和请求有效载荷的单个来源,在性能上优于 REST。

如你所知,REST 响应错误依赖于 HTTP 状态码,而 gRPC 已经将错误集合形式化,使其与 API 良好对齐。

由于 REST API 完全依赖于 HTTP,其实现更加灵活。这给了你灵活性,但你需要标准和规范来进行严格的验证和验证。但你是否知道为什么你需要这些严格的验证和验证?这是因为你可以以不同的方式实现 API。例如,你可以使用任何 HTTP 方法而不是仅使用HTTP DELETE方法来删除资源,这听起来很简单。

在所有这些之上,gRPC 还旨在支持和处理调用取消、负载均衡和故障转移。

REST 成熟且被广泛采用,但 gRPC 带来了其优势。因此,你可以根据它们的优缺点来选择它们。(请注意,我们尚未讨论 GraphQL,它带来了自己的特色。你将在第十三章“开始使用 GraphQL”和第十四章“GraphQL API 开发和测试”中了解 GraphQL。)

让我们在下一小节中找出我们是否可以使用 gRPC 像 REST 一样进行 Web 通信。

我能否从 Web 浏览器和移动应用中调用 gRPC 服务器?

当然可以。gRPC 框架是为分布式系统中的通信而设计的,并且主要与 HTTP/2 语义一致。您可以从移动应用程序中调用 gRPC API,就像调用任何本地对象一样。这就是 gRPC 的美妙之处!它支持互联网和内网中的跨服务通信,以及从移动应用程序和网页浏览器到 gRPC 服务器的调用。因此,您可以利用它进行各种通信。

gRPC for web(即 gRPC-web)在 2018 年相当新颖,但现在(在 2023 年),它获得了更多的认可,并且特别用于 物联网IoT)应用。理想情况下,您应该首先将其用于您的内部服务间通信,然后用于 Web/移动服务器通信。

让我们在下一小节中了解更多关于其架构的信息。

了解 gRPC 架构

gRPC 是一个通用的基于 RPC 的框架。它在 RPC 风格中工作得非常好,涉及以下步骤:

  1. 首先,您定义服务接口,包括方法签名,以及它们的参数和返回类型。

  2. 然后,您将定义的服务接口作为 gRPC 服务器的一部分进行实现。现在您已准备好提供远程调用服务。

  3. 接下来,您需要客户端的存根,您可以使用服务接口生成它。客户端应用程序调用存根,这是一个本地调用。然后,存根与 gRPC 服务器通信,并将返回值传递给 gRPC 客户端。这如图所示:

图 10.1 – gRPC 客户端-服务器架构

图 10.1 – gRPC 客户端-服务器架构

对于客户端应用程序,它只是对存根的本地调用以获取响应。您可以在同一台机器或不同机器上有一个服务器。这使得编写分布式服务变得更容易。它是编写微服务的理想工具。gRPC 是语言无关的。您可以使用不同的语言编写服务器和客户端。这为开发提供了很大的灵活性。

gRPC 是一种分层架构,具有以下层以实现远程调用:

  • 如果接口使用 Protobuf 定义,则为 .proto 扩展。

  • connectedidle

  • 传输层:这是最低层,使用 HTTP/2 作为其协议。因此,gRPC 提供了全双工通信和在同一网络连接上并行调用复用。

您可以通过以下步骤开发基于 gRPC 的服务:

  1. 使用 .proto 文件(Protobuf)定义服务接口。

  2. 编写在 步骤 1 中定义的服务接口的实现。

  3. 创建一个 gRPC 服务器并将其服务注册到其中。

  4. 生成服务存根并将其与 gRPC 客户端一起使用。

您将在下一章中实现实际的 gRPC 服务,第十一章gRPC API 开发测试

gRPC 存根

存根是一个暴露服务接口的对象。gRPC 客户端调用存根方法,将调用钩到服务器,并获取响应。

你需要了解 Protobuf 来定义服务接口。让我们在下一个小节中探讨它。

gRPC 如何使用 Protobuf

Protobuf 创建于 2001 年,并于 2008 年公开提供。它也被 Google 的基于微服务的系统 Stubby 所使用。

gRPC 也很好地与 JSON 和其他媒体类型协同工作。然而,你将使用 Protobuf 定义服务接口,因为它以其性能而闻名。它允许正式的合约、更好的带宽优化和代码生成。Protobuf 也是 gRPC 的默认格式。gRPC 不仅使用 Protobuf 进行数据序列化,还用于代码生成。Protobuf 序列化数据,与 JSON 不同,YAML 不可读。让我们看看它是如何构建的。

Protobuf 消息包含一系列键值对。键指定 message 字段及其类型。让我们检查以下 Employee 消息:

message Employee {  int64 id = 1;
  string firstName = 2;
}

让我们使用 Protobuf(id 值为 299firstName 值为 Scott)表示此消息,如下所示图所示:

图 10.2 – 使用 Protobuf 表示的员工消息

图 10.2 – 使用 Protobuf 表示的员工消息

IdfirstName 字段分别标记为数字,序列 12,这是序列化所必需的。线类型是另一个方面,它提供了查找值长度的信息。

下表包含线类型及其相应的含义:

线类型 含义 用途
0 可变长整数 int32int64uint32uint64sint32sint64boolenum
1 64 位 fixed64sfixed64double
2 长度分隔 stringbytes、嵌入的消息、打包的重复字段
3 开始组 groups(已弃用)
4 结束组 groups(已弃用)
5 32 位 fixed32sfixed32float

Protobuf 文件以 .proto 扩展名创建。你以方法签名和消息(对象)的形式定义服务接口,这些消息在方法签名中引用。这些消息可以是方法参数或返回类型。你可以使用 protoc 编译器编译定义的服务接口,它为接口和给定消息生成类。同样,你也可以为 gRPC 客户端生成存根。

让我们看看以下示例 .proto 文件:

员工的示例服务接口

syntax = "proto3";package com.packtpub;
option java_package = "com.packt.modern.api.proto";
option java_multiple_files = true;
message Employee {
  int64 id = 1;
  string firstName = 2;
  string lastName = 3;
  int64 deptId = 4;
  double salary = 5;
  message Address {
    string houseNo = 1;
    string street1 = 2;
    string street2 = 3;
    string city = 4;
    string state = 5;
    string country = 6;
    string pincode = 7;
  }
}
message EmployeeCreateResponse {
  int64 id = 1;
}
service EmployeeService {
  rpc Create(Employee) returns (EmployeeCreateResponse);
}

让我们逐行理解这段代码:

  1. 第一行表示由 syntax 关键字表示的 Protobuf 版本。syntax 的值(proto3)告诉编译器使用 Protobuf 的第 3 版。默认版本是 proto2。Protobuf 版本 3 提供了更多功能,语法简化,并支持更多语言。gRPC 推荐使用 Protobuf 版本 3。

  2. 接下来,您使用 package 关键字定义 proto 包名,后跟包名。它防止消息类型之间的名称冲突。

  3. 接下来,您使用 option 关键字通过 java_package 参数定义 Java 包名。

  4. 然后,您再次使用 option 关键字,通过 java_multiple_files 参数为每个根级消息类型生成一个单独的文件。

  5. 然后,您使用 messages 关键字定义消息,这些消息不过是对象。消息及其字段使用强类型定义,这些类型定义了具有精确规格的对象。您可以像在 Java 中定义嵌套类一样定义嵌套消息。最后一点包含了您可以使用来定义 message 字段类型的 Protobuf 类型表。

  6. 您可以使用 Employee.Address 在其他消息中定义 address 字段。

  7. 标记带有序列号的字段的标记是必需的,因为它用于序列化和解析二进制消息。

请注意,一旦消息结构被序列化,您就不能更改它。

  1. 服务定义使用 service 关键字进行定义。服务定义包含方法。您可以使用 rpc 关键字定义方法。请参考 EmployeeService 服务定义以获取示例。您将在下一小节中了解更多关于服务定义的内容。

  2. Protobuf 有预定义的类型(标量类型)。message 字段可以具有 Protobuf 标量类型之一。当我们编译 .proto 文件时,它将 message 字段转换为相应的语言类型。以下表格定义了 Protobuf 类型与 Java 类型之间的映射:

Protobuf types Java types 备注
Double Double 类似于 Java 类型 double
Float Float 类似于 Java 类型 float
int32 Int 如果字段包含负值,请使用 sint32,因为它使用可变长度编码,对于编码负数效率较低。
int64 Long 如果字段包含负值,请使用 sint64,因为它使用可变长度编码,对于编码负数效率较低。
uint32 Int 使用可变长度编码。如果值大于 228,请使用 fixed32
uint64 Long 使用可变长度编码。如果值大于 256,请使用 fixed64
sint32 Int 对于编码负数更有效,因为它包含一个有符号的 int 值。它使用可变长度编码。
sint64 Long 对于编码负数更有效,因为它包含一个有符号的 int 值。它使用可变长度编码。
fixed32 int 总共 4 字节。
fixed64 long 总共 8 字节。
sfixed32 int 总共 4 字节。对于编码大于 228 的值更有效。
sfixed64 long 总共 8 字节。对于编码大于 256 的值更有效。
Bool boolean truefalse
String String 包含 UTF-8 编码的字符串或 7 位 ASCII 文本,长度不应超过 232。
Bytes ByteString 包含任意字节序列,长度不应超过 232。

Protobuf 还允许您定义枚举类型(使用enum关键字)和映射(使用map<keytype, valuetype>关键字)。请参考以下代码以获取枚举和映射类型的示例:

… omittedmessage Employee {
  … omitted
  enum Grade {
    I_GRADE = 1;
    II_GRADE = 2;
    III_GRADE = 3;
    IV_GRADE = 4;
  }
  map<string, int32> nominees = 1;
  … omitted
}

以下示例代码创建了Employee消息,其中包含具有I_GRADE等值的Grade枚举字段。nominees字段是一个具有string类型键和int32类型值的映射。

在下一节中,我们将进一步探讨服务定义。

理解服务定义

您可以通过指定方法及其相应的参数和返回类型来定义一个服务。这些方法由服务器暴露,可以远程调用。您在上一小节中定义了EmployeeService定义,如下面的代码块所示:

service EmployeeService {  rpc Create(Employee) returns (EmployeeCreateResponse);
}

在这里,CreateEmployeeService服务定义中暴露的方法。在Create服务中使用的消息也应作为服务定义的一部分进行定义。Create服务方法是一个一元服务方法,因为客户端发送单个请求对象,并从服务器接收单个响应对象。

让我们进一步探讨 gRPC 提供的服务方法类型:

  • 一元:我们已经在之前的示例中讨论了一元服务方法。这将针对单个请求有一个单向响应。

  • 服务器流式传输:在这些类型的服务方法中,客户端向服务器发送单个对象,并接收流式响应。此流包含消息序列。流保持打开状态,直到客户端接收所有消息。gRPC 保证了消息序列的顺序。在以下示例中,客户端将一直接收实时比分消息,直到比赛结束:

    rpc LiveMatchScore(MatchId) returns (stream MatchScore);
    
  • 客户端流式传输:在这些类型的服务方法中,客户端向服务器发送一系列消息,并接收一个响应对象。流保持打开状态,直到客户端发送所有消息。gRPC 保证了消息序列的顺序。一旦客户端发送所有消息,它将等待服务器的响应。在以下示例中,客户端将发送数据消息到服务器,直到所有数据记录发送完毕,然后等待报告:

    rpc AnalyzeData(stream DataInput) returns (Report);
    
  • 双向流式:这是客户端和服务器流式同时执行。这意味着服务器和客户端都使用读写流发送一系列消息。在这里,序列的顺序被保留。然而,这两个流独立操作。因此,每个都可以按它们喜欢的顺序读取和写入。服务器可以逐个读取和回复消息,或者一次性回复,或者有任意组合。在以下示例中,处理过的记录可以立即逐个发送,也可以稍后以不同的批次发送:

    rpc BatchProcessing(stream InputRecords)    returns (stream Response);
    

现在你已经了解了 gRPC 服务定义,让我们在下一节中探索 RPC 的生命周期。

探索 RPC 生命周期

在上一节中,你了解了四种类型的服务定义。每种类型的服务定义都有自己的生命周期。让我们在本节中了解更多关于每种服务定义生命周期的信息:

  • 存根还提供了服务器客户端的元数据、方法名称以及如果适用的话,指定的截止日期,并带有通知。

元数据是以键值对形式存在的关于 RPC 的数据,例如超时和认证细节。

接下来,作为回应,服务器发送其初始元数据。服务器是立即发送初始元数据还是收到客户端请求消息后发送,取决于应用程序。但服务器必须在任何响应之前发送它。

服务器在收到客户端请求消息后处理请求并准备响应。服务器发送带有状态(代码和可选消息)以及可选尾部元数据的响应,对于成功的调用。

客户端收到响应并完成调用(对于OK状态,例如HTTP状态 200)。

  • 服务器流式 RPC 的生命周期:服务器流式 RPC 的生命周期几乎与单一 RPC 相同。它遵循相同的步骤。唯一的区别是由于流式响应,响应的发送方式不同。服务器以流的形式发送消息,直到所有消息都发送完毕。最后,服务器发送带有状态(代码和可选消息)以及可选的尾部元数据的响应,并完成服务器端处理。客户端在收到所有服务器的消息后完成生命周期。

  • 客户端流式 RPC 的生命周期:客户端流式 RPC 的生命周期几乎与单一 RPC 相同。它遵循相同的步骤。唯一的区别是由于流式请求,请求的发送方式不同。客户端以流的形式发送消息,直到所有消息都发送到服务器。服务器发送带有状态(代码和可选消息)以及可选尾部元数据的单个消息响应,对于成功的调用。在空闲场景下,服务器在收到所有客户端的消息后发送响应。客户端在收到服务器消息后完成生命周期。

  • 双向流式 RPC 的生命周期:双向流式 RPC 生命周期的前两个步骤与单一 RPC 相同。流式处理由双方的应用程序特定。服务器和客户端都可以按任何顺序读取和写入消息,因为这两个流相互独立。

服务器可以按任何顺序处理客户端发送的请求消息流。例如,服务器和客户端可以玩乒乓球:客户端发送请求消息,服务器处理它。再次,客户端发送请求消息,服务器处理它,这个过程,正如你所知,会继续进行。或者服务器等待接收到客户端的所有消息后,再发送自己的消息。

客户端在接收到所有服务器消息后完成生命周期。

影响生命周期的事件

以下事件可能会影响 RPC 的生命周期:

  • DEADLINE_EXCEEDED 错误。同样,服务器可以查询以确定特定的 RPC 是否超时,或者完成 RPC 剩余多少时间。

超时配置是语言特定的。一些语言 API 支持超时(时间长度),而一些支持截止日期(固定的时间点)。API 可能有一个默认的截止日期/超时值,而一些可能没有。

  • RPC 终止:有一些场景中,RPC 被终止是因为客户端和服务器各自独立且本地地确定调用的成功,他们的结论可能不匹配。例如,服务器可能通过发送所有消息来完成其部分,但它可能因为客户端超时而失败,因为响应在超时后到达。另一个场景是当服务器决定在客户端发送所有消息之前完成 RPC。

  • 取消 RPC:gRPC 提供了一种由服务器或客户端在任何时候取消 RPC 的规定。这会立即终止 RPC。然而,在取消之前所做的更改不会被回滚。

让我们在下一节中更深入地探讨 gRPC 服务器和模拟器。

理解 gRPC 服务器和 gRPC 模拟器

如果你仔细观察 图 10**.1,你会发现 gRPC 服务器和 gRPC 模拟器是实现的核心部分,因为 gRPC 基于客户端-服务器架构。一旦你定义了服务,你就可以使用带有 gRPC Java 插件的 Protobuf 编译器 protoc 生成服务接口和模拟器。你将在 第十一章 中找到一个实际示例。

编译器生成的以下类型的文件:

  • 模型:它生成在服务定义文件中定义的所有消息(即模型),该文件包含用于序列化、反序列化和获取请求和响应消息类型的 Protobuf 代码。

  • gRPC Java 文件:它包含服务基接口和存根。基接口被实现并用作 gRPC 服务器的一部分。存根被客户端用于与服务器通信。

首先,你需要实现接口,如下面的代码所示,这是 EmployeeService 的实现:

public class EmployeeService extends EmployeeServiceImplBase {  // some code
  @Override
  public void create(Employee request,
     io.grpc.stub.StreamObserver<Response> responseObserver) {
    // implementation
  }
}

一旦实现了接口,你就可以运行 gRPC 服务器来处理来自 gRPC 客户端的请求:

public class GrpcServer { public static void main(String[] arg) {
  try {
    Server server = ServerBuilder.forPort(8080)
        .addService(new EmployeeService()).build();
    System.out.println("Starting gRPC Server Service...");
    server.start();
    System.out.println("Server has started at port: 8080");
    System.out.println("Following services are
        available: ");
    server.getServices().stream().forEach( s ->
      System.out.println("Service Name: " +
      s.getServiceDescriptor().getName())
    );
    server.awaitTermination();
  } catch (Exception e) {
    // error handling
  }
 }
}

对于客户端,首先,你需要使用 ChannelBuilder 创建通道,然后你可以使用创建的通道来创建存根,如下面的代码所示:

public EmployeeServiceClient(ManagedChannelBuilder<?>  channelBuilder) {  channel = channelBuilder.build();
  blockingStub = EmployeeServiceGrpc.
      newBlockingStub(channel);
  asyncStub = EmployeeServiceGrpc.newStub(channel);
}

在这里,已经使用 ManageChannelBuilder 类构建的通道创建了阻塞和异步存根。

让我们在下一节中探索错误处理。

处理错误和错误状态码

与使用 HTTP 状态码的 REST 不同,gRPC 使用一个 Status 模型,它包含其错误代码和可选的错误消息(字符串)。

如果你还记得,你曾经使用了一个名为 Error 的特殊类来包含错误详情,因为 HTTP 错误代码包含有限的信息。同样,gRPC 错误 Status 模型仅限于代码和一个可选的消息(字符串)。你没有足够的错误详情供客户端用来处理错误或重试。你可以使用更丰富的错误模型,如cloud.google.com/apis/design/errors#error_model中所述,这允许你将详细的错误信息传回客户端。你还可以在下一个代码块中找到错误模型,以便快速参考:

package google.rpc;message Status {
  // actual error code is defined by `google.rpc.Code`.
  int32 code = 1;
  // A developer-facing human-readable error message
  string message = 2;
  // Additional error information that the client
     code can use
  // to handle the error, such as retry info or a
     help link.
  repeated google.protobuf.Any details = 3;
}

details 字段包含额外的信息,你可以使用它来传递相关信息,例如 RetryInfoDebugInfoQuotaFailureErrorInfoPreconditionFailureBadRequestRequestInfoResourceInfoHelpLocalizedMethod。所有这些消息类型都可以在github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto找到。

这些更丰富的错误模型使用 Protobuf 描述。如果你想使用更丰富的错误模型,你必须确保支持库与 API 的实际使用相匹配,如 Protobuf 中所述。

与 REST 类似,RPC 可以因各种原因引发错误,例如网络故障或数据验证。让我们看看以下 REST 错误代码及其相应的 gRPC 对应代码:

HTTP 状态码 gRPC 状态码 说明
400 INVALID_ARGUMENT 对于无效的参数。
400 FAILED_PRECONDITION 由于预条件失败,操作无法执行。
400 OUT_OF_RANGE 如果客户端指定了无效的范围。
401 UNAUTHENTICATED 如果客户端的请求未认证,例如缺少或过期的令牌。
403 PERMISSION_DENIED 客户端没有足够的权限。
404 NOT_FOUND 请求的资源未找到。
409 ABORTED 读写操作或任何并发冲突的冲突。
409 ALREADY_EXISTS 如果请求是创建已存在的资源。
429 RESOURCE_EXHAUSTED 如果请求达到 API 速率限制。
499 CANCELLED 如果请求被客户端取消。
500 DATA_LOSS 对于不可恢复的数据丢失或损坏。
500 UNKNOWN 对于服务器上的未知错误。
500 INTERNAL 对于内部服务器错误。
501 NOT_IMPLEMENTED 服务器未实现 API。
502 N/A 由于网络不可达或网络配置错误而导致的错误。
503 UNAVAILABLE 服务器因任何原因关闭或不可用。客户端可以在这些错误上执行重试。
504 DEADLINE_EXCEEDED 请求未在截止日期内完成。

gRPC 错误代码更易于阅读,因为您不需要映射来理解数字代码。

摘要

在本章中,您探讨了 Protobuf、IDL 和序列化实用工具。您还探讨了 gRPC 基础知识,如服务定义、消息、服务器接口和方法。您比较了 gRPC 与 REST。我希望这已经为您提供了足够的视角来理解 gRPC。

您还学习了 gRPC 的生命周期、带有存根的服务器和客户端。您还涵盖了 Protobuf、gRPC 架构和 gRPC 基础知识,这将使您能够开发基于 gRPC 的 API 和服务。

您将在下一章中使用本章学到的基本知识来实现 gRPC 服务器和客户端。

问题

  1. RPC 是什么?

  2. 与 REST 相比,gRPC 有何不同?应该使用哪一个?

  3. 当您想查看最新推文或执行类似类型的工作时,哪种类型的服务方法是有用的?

Answers

  1. RPC 代表远程过程调用。客户端可以调用远程服务器上公开的进程,这就像调用本地进程一样,但它是在远程服务器上执行的。RPC 非常适合连接系统中的跨服务通信。

  2. gRPC 基于客户端-服务器架构,而 REST 并非如此。与 REST 相比,gRPC 还支持全双工流式通信。由于使用静态路径和请求有效负载的单个来源,gRPC 的性能优于 REST。

REST 响应错误取决于 HTTP 状态码,而 gRPC 已经正式化了错误集,使其与 API 良好对齐。gRPC 还构建了支持和处理调用取消、负载均衡和故障转移的功能。有关更多信息,请参阅REST 与 gRPC子节。

  1. 您应该使用服务器流式 RPC 方法,因为您想接收来自服务器的最新消息,例如推文。

进一步阅读

您可以在以下链接中找到更多信息:

第十章:gRPC API 开发和测试

在本章中,您将学习如何实现基于 gRPC 的 API。您将学习如何编写 gRPC 服务器和客户端,以及基于 gRPC 编写 API。在本章的后期部分,您将介绍微服务,并了解它们如何帮助您设计现代、可扩展的架构。

您还将通过实现两个服务 – gRPC 服务器和 gRPC 客户端。基于 gRPC 的 API 在基于微服务的系统中比 REST API 更受欢迎和首选,因此 gRPC 开发技能在 API 领域非常重要。

完成本章后,您将精通 gRPC 服务器和客户端开发、基于 gRPC 的 API 测试自动化以及微服务概念。

在本章中,您将探索以下主题:

  • 编写 API

  • 开发 gRPC 服务器

  • 处理错误

  • 开发 gRPC 客户端

  • 学习微服务概念

技术要求

本章包含大量关于 gRPC 的理论。然而,您还将承担基于 gRPC 的 Web 服务的开发和测试,为此您需要以下内容:

  • 任何 Java IDE,例如 NetBeans、IntelliJ 或 Eclipse

  • Java 开发工具包JDK)17

  • 连接到互联网以克隆代码并下载依赖项和 Gradle

  • Postman/cURL(用于 API 测试)

请访问以下链接以检查代码:github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11

那么,让我们开始吧!

编写 API

在本节中,我们将使用 Protocol BufferProtobuf)为支付服务编写 API。如果您还记得,这是在示例电子商务应用中尚未实现的部分。

在编写 API 之前,让我们设置 Gradle 项目。

设置项目

本章的代码将在 Chapter11 目录下包含三个项目 – API、服务器和客户端:

  • .proto 文件及其打包在 JAR 文件中的生成的 Java 类。此项目将生成 payment-gateway-api-0.0.1.jar 库工件,您将在本地仓库中发布它。此库将在服务器和客户端项目中使用。

  • 服务器: 此项目代表 gRPC 服务器,它将实现 gRPC 服务并处理 gRPC 请求。

  • 客户: 此项目包含 gRPC 客户端,它将调用 gRPC 服务器。为了启动 gRPC 服务器和客户端应用程序之间的服务间通信,您将实现一个 REST 调用,该调用将内部调用 gRPC 服务器以处理 HTTP 请求。

让我们先创建服务器和客户端项目。

创建 gRPC 服务器和客户端项目

您可以选择从 Git 仓库克隆第十一章代码(github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11)或者您可以从创建单独的api库项目开始,从头创建新的 Spring 项目:

  • Gradle - Groovy

  • Java

  • 3.0.8.

推荐的版本是3.0+。请选择可用的版本。您也可以稍后在build.gradle文件中手动修改它。

  • com.packt.modern.api

  • chapter11

  • Chapter11

  • 《现代 Spring 和 Spring Boot API 开发》第十一章代码 第 2 版

  • com.packt.modern.api.* Jar.* 17.

您可以选择任何新版本,例如20。您也可以稍后在build.gradle文件中修改它,如下面的代码块所示:

sourceCompatibility = JavaVersion.VERSION_20
  • Spring Web.

然后,您可以点击生成并下载项目。

下载的项目可以用来创建服务器和客户端。然后,在Chapter11目录下创建单独的serverclient目录。创建目录后,将下载的压缩项目中的提取内容复制到它们中。

您可以稍后配置服务器和客户端项目。让我们首先创建 gRPC API 库项目,因为这个库将在服务器和客户端项目中使用。

创建 gRPC API 库项目

Chapter11目录下创建一个新的目录,命名为api。然后,使用 Gradle 从Chapter11目录执行以下命令来创建一个新的 Gradle 项目。它将要求选择一些选项。以下块是在设置JAVA_HOME环境变量为 Java 17 并将 Java 17 添加到路径后执行的。您可能会在某些系统中发现问题的顺序略有不同。您应该选择以下终端界面输出中突出显示的选项:

$ mkdir api$ cd api
(you can also use gradlew from other chapter's code)
$ ../server/gradlew init
Select type of project to generate:
  1: basic
  2: application
  3: library
  4: Gradle plugin
Enter selection (default: basic) [1..4] 3
Select implementation language:
  1: C++
  2: Groovy
  3: Java
  4: Kotlin
  5: Scala
  6: Swift
Enter selection (default: Java) [1..6] 3
Select build script DSL:
  1: Groovy
  2: Kotlin
Enter selection (default: Groovy) [1..2] 1
Generate build using new APIs and behavior (some features may change in the next minor release)? (default: no) [yes, no] no
Select test framework:
  1: JUnit 4
  2: TestNG
  3: Spock
  4: JUnit Jupiter
Enter selection (default: JUnit Jupiter) [1..4] 4
Project name (default: api): api
Source package (default: api): com.packt.modern.api
> Task :init
BUILD SUCCESSFUL in 1m 41s
2 actionable tasks: 2 executed

项目由 Gradle 引导。接下来,您将配置api项目。

配置 gRPC API 库项目

在这里,您将在api/libs/build.gradle中的plugins部分配置 Protobuf 和 Maven Publish 插件。这些插件及其配置是关键步骤。让我们按以下方式操作:

  1. 修改项目根目录下的api/settings.gradle

    rootProject.name = 'api/lib/build.gradle file. Add the Protobuf and Maven Publish Gradle plugins. Also, replace the java-library plugin with java, as shown next:
    
    

    plugins {    id 'java'    id 'maven-publish'    id "com.google.protobuf" version "0.9.2"}

    
    

将使用 Maven Publish 插件将生成的Jar工件发布到本地 Maven 仓库。

  1. api/libs/build.gradle中添加组名、版本和源兼容性,如下面的代码块所示。组和版本将由 Maven Publish 插件用于命名发布的工件:

    group = 'com.packt.modern.api'version = '0.0.1'sourceCompatibility = JavaVersion.VERSION_17
    
  2. 接下来,添加以下依赖项,这些依赖项对于 Protobuf 和 gRPC 是必需的(检查突出显示的部分)。你可以删除使用 gradlew init 命令生成项目时添加的现有依赖项,并保留下一节中提到的依赖项:

    def grpcVersion = '1.54.0'dependencies { implementation "protoc command-line compiler. The plugin searches for the protoc executable in the system path by default. However, you can add a Protobuf compiler artifact to the plugin, which will make the build file self-sufficient as far as the gRPC compile task is concerned. Let’s configure it as shown in the following code block by adding a protobuf section to the api/libs/build.gradle file:
    
    

    protobuf {  protoc {    artifact = "com.google.protobuf:protoc:3.22.2"  }  plugins {    grpc {      artifact = "io.grpc:protoc-gen-grpc-java:1.54.0"    }  }  generateProtoTasks {    all()*.plugins {      grpc { }    }  }}

    
    

在前面的代码中,你已配置了 Protobuf 编译器(protoc)及其 Java 插件(protoc-gen-grpc-java),它将根据 .proto 文件生成 Java 代码。

当你第一次运行 gradlew build 命令时,Gradle 将根据操作系统下载 protocprotoc-gen-grpc-java 可执行文件。

  1. Protobuf Gradle 插件与该子节中迄今为止共享的配置一起工作。当你从命令行运行 build 命令时,它会工作。然而,如果你不将以下块添加到 api/libs/build.gradle 文件中,以将生成的源文件添加到 sourceSets,IDE 可能会给出编译错误:

    sourceSets {  main {    proto {      // In addition to the default "src/main/proto"      srcDir "src/main/grpc"    }  }}task sourcesJar(type: Jar, dependsOn: classes) {    archiveClassifier = "sources"    from sourceSets.main.allSource}
    
  2. 最后,你需要在配置 Maven Publish 插件时添加以下块:

    publishing {  publications {    mavenJava(MavenPublication) {      artifactId = 'payment-gateway-api'      from components.java    }  }}
    

在这里,你已经配置了 api 项目。你可以在 github.com/google/protobuf-gradle-plugin 找到有关 Protobuf Gradle 插件更多信息。

现在已经完成了 api 项目的设置,我们准备在下一小节中使用 Protobuf 编写服务定义。你还没有实现我们示例电子商务应用的支付功能。这是因为它需要与 Stripe 或 PayPal 等支付网关服务集成。因此,你将在下一节中编写使用 gRPC 的示例支付网关服务定义。

编写支付网关功能

在编写支付网关服务定义之前,让我们首先以简单的方式了解支付网关系统的基本功能。

支付网关提供了一种从客户到在线卖家的支付捕获和转移方式,然后向客户返回接受/拒绝作为响应。它在此执行各种其他操作,例如验证、安全、加密以及与所有参与者的通信。

以下是在此交易中参与的参与者:

  • 支付网关:一个允许处理在线支付并与其他所有参与者协调的网页界面。这与物理 销售点POS)终端非常相似。

  • 商家:商家是在线卖家或服务提供商,例如亚马逊、优步和爱彼迎。

  • 客户:这是你,作为客户,为产品或服务执行购买/支付交易,并使用信用卡、数字钱包或在线银行。

  • 发卡行:提供执行在线货币转账功能的当事人,例如 Visa、Mastercard、AmEx、PayPal、Stripe 或传统银行。

  • 收购方或收购银行:持有商户账户的机构。它将交易传递给发卡行以接收付款。

您将创建两个 gRPC 服务——ChargeServiceSourceService——作为支付网关服务的一部分。不要与可执行/可部署的 web 服务混淆,它是可执行/可部署的工件。ChargeServiceSourceService是上一章(第十章如何使用 Protobuf部分,开始使用 gRPC)中 Protobuf 的EmployeeService示例的服务组件的一部分。这两个服务都受到 Stripe 公共 REST API 的启发。

在我们跳入创建基于 gRPC 的支付网关服务组件之前,让我们先了解交易流程。

在线支付工作流程步骤

在进行在线交易时,将执行以下步骤:

  1. 首先,客户应在启动付款之前创建一个支付源(读取方法)。如果没有,则客户将创建一个源,例如他们的卡详情。

  2. 通过对支付源创建收费(读取方法)来启动付款。

  3. 支付网关执行所有必要的验证和验证步骤,然后允许捕获收费。这些步骤触发了从发卡行到商户账户的资金转移。

您可以观察到在这个工作流程(即源和收费)中涉及两个对象(资源)。因此,您将编写两个围绕这两个对象工作的服务。支付网关还执行各种其他功能,例如争议、退款和支付。然而,在本章中,您将只实现两个服务,即收费和源。

编写支付网关服务定义

基于 Protobuf 编写的 IDL 编写方式与您定义 REST API 的 OpenAPI 规范非常相似。在 REST 中,您定义模型和 API 端点,而在 gRPC 中,您定义封装在服务中的消息和 RPC 过程。让我们按照以下步骤编写我们的支付网关服务 IDL:

  1. 首先,让我们在api项目的根目录下的api/lib/src/main/proto目录中创建一个新的文件,名为PaymentGatewayService.proto

  2. 在创建新文件后,您可以添加元数据,如下面的代码块所示:

    syntax = "proto3";                                   //1package com.packtpub.v1;                             //2option java_package = "com.packt.modern.api.grpc.v1";//3option java_multiple_files = true;                   //4
    

让我们详细理解前面的代码:

  • 行 1告诉编译器使用语法指定符使用 Protobuf 的版本 3。如果您不指定此信息,则编译器将使用版本 2 的 Protobuf。

  • 行 2使用可选的包指定符将命名空间附加到消息类型。这防止了消息类型之间的名称冲突。我们必须以允许我们创建具有向后兼容性的 API 新版本的包版本后缀。

  • 第 3 行使用了java_package选项指定符。这指定了在生成的 Java 文件中使用的 Java 包。如果您不使用此选项指定符并声明package指定符,则package的值将用作生成的 Java 文件中的 Java 包。

  • 第 4 行声明了java_multiple_files选项指定符,这是一个布尔选项。默认情况下设置为false。如果设置为true,则为每个顶级消息类型、枚举(enum)和服务生成单独的 Java 文件。

  1. 接下来,让我们添加包含所需收费功能的操作的ChargeService服务,这些操作由rpc表示(如下面的代码块所示)。为收费创建Charge对象,用于对卡、银行账户或数字钱包进行收费。让我们将收费服务添加到 Protobuf(.proto)文件中:

    service ChargeService { rpc Create(CreateChargeReq)     returns(CreateChargeReq.Response); rpc Retrieve(ChargeId)     returns (ChargeId.Response); rpc Update(UpdateChargeReq)     returns(UpdateChargeReq.Response); rpc Capture(CaptureChargeReq)     returns(CaptureChargeReq.Response); rpc RetrieveAll(CustomerId)     returns (CustomerId.Response);}
    

https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/api/lib/src/main/proto/PaymentGatewayService.proto

ChargeService中的每个这些过程都将执行以下操作:

  • Charge对象。

  • 基于先前创建的给定收费 ID 的Charge对象。

  • 通过设置传递的参数的值来识别给定收费 ID 的Charge对象。任何未提供的参数将保持不变。

  • capture选项设置为false。未捕获的支付在创建后恰好七天到期。如果在此时间点之前未被捕获,它们将被标记为已退款,并且将不再允许捕获。

  • RetrieveAll: 此过程返回属于给定客户 ID 的收费列表。

空请求或响应类型

您可以使用google.protobuf.Empty作为 void/empty 请求和响应类型。这可以在.proto文件中使用。只需在定义任何消息/服务之前放置以下import语句:

import "google/protobuf/timestamp.proto";

然后,您可以使用它,如下所示:

rpc delete(SourceId) returns (google.protobuf.Empty);

  1. 金额将记入源,这可能是一张卡、银行账户或数字钱包。客户可以使用Source对象使用各种支付方式。因此,你需要一个允许你对source资源执行操作的服务。让我们将Source服务及其操作添加到 Protobuf(.proto)文件中:

    service SourceService {  rpc Create(CreateSourceReq)      returns (CreateSourceReq.Response);  rpc Retrieve(SourceId)      returns (SourceId.Response);  rpc Update(UpdateSourceReq)      returns (UpdateSourceReq.Response);  rpc Attach(AttachOrDetachReq)      returns (AttachOrDetachReq.Response);  rpc Detach(AttachOrDetachReq)      returns (AttachOrDetachReq.Response);}
    

SourceService中的每个这些过程都将执行以下操作:

  • Source对象。

  • 根据给定的源 ID 创建Source对象。

  • 使用UpdateSourceReq对象传递的Source对象。任何不属于UpdateSourceReq的字段将保持不变。

  • Source对象分配给客户。AttachOrDetachReq参数包含源和客户的 ID。然而,Source对象必须处于CHARGEABLEPENDING状态才能执行附加操作。

  • 来自客户的Source对象。它还将改变Source对象的状态为consumed,并且它不能再用来创建费用。AttachOrDetachReq参数包含源和客户的 ID。

定义请求和响应类型的推荐方法

建议始终使用包装请求和响应类型。这允许您向请求或响应类型添加另一个字段。

  1. 现在服务定义已完成,您可以定义这些过程的给定参数和返回类型。首先,让我们定义ChargeService的参数和返回类型。首先,您将定义Charge消息类型,如下面的代码块所示:

    message Charge {  string id = 1;  uint32 amount = 2;  uint32 amountCaptured = 3;  uint32 amountRefunded = 4;  string balanceTransactionId = 5;  BillingDetails billingDetails = 6;  string calculatedStatementDescriptor = 7;  bool captured = 8;  uint64 created = 9;  string currency = 10;  string customerId = 11;  string description = 12;  bool disputed = 13;  uint32 failureCode = 14;  string failureMessage = 15;  string invoiceId = 16;  string orderId = 17;  bool paid = 18;  string paymentMethodId = 19;  PaymentMethodDetails paymentMethodDetails = 20;  string receiptEmail = 21;  string receiptNumber = 22;  bool refunded = 23;  repeated Refund refunds = 24;  string statementDescriptor = 25;  enum Status {    SUCCEEDED = 0;    PENDING = 1;    FAILED = 2;  }  Status status = 26;  string sourceId = 27;}
    

在这里,Charge消息包含以下字段:

  • id: Charge对象的唯一标识符。

  • amount: 金额是一个正数或零,指代支付金额。

  • amountCaptured: 这是已捕获的金额(一个正数或零)。如果进行了部分捕获,它可能小于amount字段的值。

  • amountRefunded: 已退还的金额(一个正数或零)。如果发出部分退款,它可能小于amount字段的值。

  • balanceTransactionId: 平衡交易的 ID,描述了此费用对您的账户余额的影响(不包括退款或争议)。

  • billingDetails: BillingDetails消息类型的对象,包含与交易时支付方式关联的账单信息。

  • calculatedStatementDescriptor: 传递给卡网络并显示在您的客户信用卡和银行对账单上的账单描述。

  • captured: 一个布尔字段,表示费用是否已被捕获。(可能创建一个不捕获费用详情的费用。因此,添加了此字段,以确定费用是否将被捕获。)

  • created: 对象创建的时间戳(以自 Unix 纪元以来的秒数衡量)。

  • currency: 三字母的 ISO 货币代码。

  • customerId: 拥有该费用的客户的 ID。

  • description: 显示给用户的费用描述。

  • disputed: 一个布尔字段,表示该费用是否已被争议。

  • failureCode: 失败的错误代码。

  • failureMessage: 失败的描述。如果此选项可用,可能还会说明原因。

  • invoiceId: 此费用对应的发票 ID。

  • orderId: 此费用对应的订单 ID。

  • paid: 布尔值表示费用是否成功或已成功授权进行后续捕获。

  • paymentMethodId: 支付方式的 ID。

  • paymentMethodDetails: 包含支付方式详细信息的对象。

  • receiptEmail: 费用收据将被发送的电子邮件地址。

  • receiptNumber:这代表通过电子邮件发送的费用收据中的交易号码。它应该在发送费用收据之前保持为 null。

  • refunded:一个布尔字段,表示费用是否已退款。

  • refunds:此列表包含已发放的退款。使用repeated关键字创建Refund对象的列表。

  • statementDescriptor:卡片费用的描述。

  • status:表示费用状态的Status枚举类型对象(SUCCEEDEDPENDINGFAILED)。

  • sourceIdSource对象的 ID。

在上一章的如何 gRPC 使用 Protobuf小节下如何 gRPC 工作?部分中讨论了UInt32和字符串标量类型(第十章开始使用 gRPC)。您可以参考它以获取更多信息。

预定义的已知类型

除了标量类型外,Protobuf 还提供了预定义类型,如Empty(我们在步骤 3中已看到)、TimestampDuration。您可以在 https://developers.google.com/protocol-buffers/docs/reference/google.protobuf 找到完整的列表。

  1. 现在,您可以定义其他参数的剩余消息类型(CreateChargeReqChargeIdUpdateChargeReqCaptureChargeReqCustomerId),并返回ChargeServiceChargeList类型,如下面的代码块所示:

    message CreateChargeReq {  uint32 amount = 1;  string currency = 2;  string customerId = 3;  string description = 4;  string receiptEmail = 5;  Source source Id = 6;  string statementDescriptor = 7;  message Response { Charge charge = 1; }}message UpdateChargeReq {  string chargeId = 1;  string customerId = 2;  string description = 3;  string receiptEmail = 4;  message Response { Charge charge = 1; }}message CaptureChargeReq {  string chargeId = 1;  uint32 amount = 2;  string receiptEmail = 3;  string statementDescriptor = 4;  message Response { Charge charge = 1; }}message ChargeId {  string id = 1;  message Response { Charge charge = 1; }}message CustomerId {  string id = 1;  message Response { repeated Charge charge = 1; }}
    

在这里,CreateChargeReq类型包含所需的属性费用金额(amount)和currency。它还包含几个可选属性——customerIdreceiptEmailsourcestatementDescriptor

UpdateChargeReq包含所有可选属性——customerIddescriptionreceiptEmail

CaptureChargeReq包含所有可选属性——amountreceiptEmailstatementDescriptor

较不为人知的 Google 常见类型

MoneyDate(不是Timestamp)是较少为人所知的类型,可以用来。但是,您必须复制定义而不是导入它们(与您对EmptyTimestamp所做的不一样)。您可以从以下链接复制定义:Money来自 https://github.com/googleapis/googleapis/blob/master/google/type/money.protoDate来自 https://github.com/googleapis/googleapis/blob/master/google/type/date.proto。您还可以在存储库中找到其他可用的常见类型。

  1. 现在,您可以定义参数并返回SourceService类型。首先,让我们定义Source消息类型,如下面的代码所示。

源使用Flow值,可以是REDIRECTRECEIVERCODEVERIFICATIONNONE。同样,Usage值可以是REUSABLESINGLEUSE。因此,让我们首先使用enum创建FlowUsage枚举:

enum Flow {  REDIRECT = 0;
  RECEIVER = 1;
  CODEVERIFICATION = 2;
  NONE = 3;
}
enum Usage {
  REUSABLE = 0;
  SINGLEUSE = 1;
}

现在,您可以在Source消息中使用此Flow枚举:

message Source {  string id = 1;
  uint32 amount = 2;
  string clientSecret = 3;
  uint64 created = 4;
  string currency = 5;
  Flow flow = 6;
  Owner owner = 7;
  Receiver receiver = 8;
  string statementDescriptor = 9;
  enum Status {
    CANCELLED = 0;
    CHARGEABLE = 1;
    CONSUMNED = 2;
    FAILED = 3;
    PENDING = 4;
  }
  Status status = 10;
  string type = 11;
  Usage usage = 12;
}
  1. 现在,你可以定义 SourceService 的其他参数的剩余消息类型,例如 CreateSourceReqUpdateSourceReqAttachOrDetachReqSourceId,如下所示:

    message CreateSourceReq {  string type = 1;  uint32 amount = 2;  string currency = 3;  Owner owner = 4;  string statementDescriptor = 5;  Flow flow = 6;  Receiver receiver = 7;  Usage usage = 8;  message Response { Source source = 1; }}message UpdateSourceReq {  string sourceId = 1;  uint32 amount = 2;  Owner owner = 3;  message Response { Source source = 1; }}message SourceId {  string id = 1;  message Response { Source source = 1; }}message AttachOrDetachReq {  string sourceId = 1;  string customerId = 2;  message Response { Source source = 1; }}
    

这些消息中使用的其他消息类型可以在支付网关定义文件中查阅,该文件位于 https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/api/lib/src/main/proto/PaymentGatewayService.proto。

多个 .proto 文件

你也可以为每个服务创建一个单独的定义文件,例如 ChargeService.protoSourceService.proto,以提高模块化。然后你可以使用 import "SourceService.proto"; 将这些文件导入另一个 Protobuf 文件中。

你可以在 https://protobuf.dev/programming-guides/proto3/#importing-definitions 找到更多关于导入的信息。

你现在已经完成了 Protobuf 文件中的支付网关服务定义。现在,你可以使用此文件来生成 gRPC 服务器接口和 gRPC 客户端的存根。

接下来,你将发布从打包在 Jar 文件中的 Protobuf 文件生成的 Java 类。

发布支付网关服务 gRPC 服务器、存根和模型

你可以使用以下命令,该命令应在 api 项目的根目录下执行:

# Make sure to enable UTF-8 for file encoding because# we are using UTF characters in Java files.
$ export JAVA_TOOL_OPTIONS="-Dfile.encoding=UTF8"
$ gradlew clean publishToMavenLocal

使用前面的命令,你首先设置文件编码为 UTF-8,因为我们正在使用 Java 文件中的 UTF 字符。然后,你执行清理、构建和发布操作。第二个命令将首先删除现有文件。然后,它将从 Protobuf 文件生成 Java 文件(generateProto Gradle 任务),构建它(build Gradle 任务),并将工件发布到你的本地 Maven 仓库(publishToMavenLocal Gradle 任务)。

generateProto Gradle 任务将在两个目录中生成两种类型的 Java 类,如下所示:

  • /api/lib/build/generated/source/proto/main/java 目录,例如 Card.javaAddress.java。此目录还将包含用于操作合同的请求和响应对象的 Java 文件,例如 CreateChargeReqCreateSourceReqCharge.javaSource.java

  • ChargeServiceGrpc.javaSourceServiceGrpc .java) 位于 /api/lib/build/generated/source/proto/main/grpc 目录。这些 gRPC Java 文件包含一个基类,以及为 ChargeSource 服务描述符中定义的每个操作的方法的存根类。

以下关键静态类在 ChargeServiceGrpc 中定义:

  • ChargeServiceImplBase (抽象基类)

  • Stubs: ChargeServiceStub, ChargeServiceBlockingStub, 和 ChargeServiceFutureStub

类似地,以下关键静态类在 SourceServiceGrpc 中定义:

  • SourceServiceImplBase (抽象基类)

  • Stubs: SourceServiceStub, SourceServiceBlockingStub, 和 SourceServiceFutureStub

之前描述的抽象基类包含了在 Protobuf 文件中的服务块中定义的操作。您可以使用这些基类来实现这些服务提供的业务逻辑,就像您从 Swagger 生成的 API Java 接口实现 REST 端点一样。

这些抽象类应该被实现,以向 gRPC 服务器提供业务逻辑实现。让我们接下来开发 gRPC 服务器。

开发 gRPC 服务器

在实现这些抽象类之前,您需要配置server项目。让我们首先配置服务器项目。

server项目目录结构将如下所示。项目根目录包含build.gradlesettings.gradle文件:

├── server    ├── build.gradle
    ├── gradle
    │   └── wrapper
    ├── gradlew
    ├── gradlew.bat
    ├── settings.gradle
    └── src
        ├── main
        │   ├── java
        │   │   └── com
        │   │       └── packt
        │   │           └── modern
        │   │               └── api
        │   └── resources
        └── test
            └── java

resources目录将包含application.properties文件。

使用 Spring Boot gRPC 启动器

您可以使用两个 Spring Boot 启动器项目。然而,我们将坚持使用 gRPC 提供的库来简化解决方案并帮助理解 gRPC 概念。这些库可在以下链接找到:https://github.com/LogNet/grpc-spring-boot-starterhttps://github.com/yidongnan/grpc-spring-boot-starter。

让我们执行以下步骤来配置项目:

  1. 首先,您需要修改Chapter11/server/settings.gradle文件中的项目名称,以表示服务器,如下所示:

    rootProject.name = 'chapter11-server'
    
  2. 接下来,您可以将server项目所需的依赖项添加到Chapter11/server/build.gradle文件中:

    def grpcVersion = '1.54.1'dependencies { implementation payment-gateway-api dependency is published in the local Maven repository. Therefore, you need to add the local Maven repository to the repositories section in Chapter11/server/build.gradle, as shown in the following code block:
    
    

    repositories {  mavenCentral()  mavenLocal()}

    
    

您已经完成了 Gradle 配置!现在,您可以编写 gRPC 服务器。然而,在编写服务器之前,您需要实现由 Protobuf 生成的基抽象类。一旦源服务和计费服务(使用基类)被实现,您就可以编写 gRPC 服务器代码。

gRPC 服务器实现

您将使用与 REST 实现中相同的分层架构 – 持久化存储 > 仓库层 > 服务层 > API 端点。

首先,您需要一个持久化存储,您可以在其中保存数据,也就是第一层。您将使用内存持久化(ConcurrentHashMap)来存储和检索数据。如果您愿意,您可以使用与 REST Web 服务中相同的方式使用外部数据库。这样做是为了保持对 gRPC 服务器实现的关注。

首先,为计费和源数据存储创建内存持久化存储。创建一个新文件,server/src/main/java/com/packt/modern/api/server/repository/DbStore.java,并添加如下代码块中的代码:

@Componentpublic class DbStore {
 private static final Map<String, Source> sourceEntities =
    new ConcurrentHashMap<>();
 private static final Map<String, Charge> chargeEntities =
    new ConcurrentHashMap<>();
 public DbStore() {
  Source source = Source.newBuilder().setId(
      RandomHolder.randomKey())
      .setType("card").setAmount(100)
      .setOwner(createOwner()).
      setReceiver(createReceiver())
      .setCurrency("USD").setStatementDescriptor("Statement")
      .setFlow(Flow.RECEIVER).setUsage(Usage.REUSABLE)
      .setCreated(Instant.now().getEpochSecond()).build();
  sourceEntities.put(source.getId(), source);
  Charge charge = Charge.newBuilder().setId(
      RandomHolder.randomKey()).setAmount(1000)
      .setCurrency("USD").setCustomerId("ab1ab2ab3ab4ab5")
      .setDescription("ChargeDescription")
      .setReceiptEmail("receipt@email.com")
      .setStatementDescriptor("Statement Descriptor")
      .setSourceId(source.getId())
      .setCreated(Instant.now().getEpochSecond()).build();
  chargeEntities.put(charge.getId(), charge);
 }
// continue …

https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/server/src/main/java/com/packt/modern/api/ server/repository/DbStore.java

在这里,您创建了两个ConcurrentHashMap对象,分别用于存储ChargeStore对象。您在每个构造函数中使用builder创建了这些对象的两个种子实例,并将它们存储在其各自的哈希表中。

根据服务合约中定义的操作,您在数据库存储中创建方法以执行这些操作。这些操作使用基本业务逻辑实现,以保持流程和逻辑简洁明了。

现在,让我们添加createSource()方法来实现 Protobuf 文件中定义的SourceServicecreate()合约,如下面的代码块所示:

public CreateSourceReq.Response       createSource(CreateSourceReq req) {
  Source source = Source.newBuilder().setId(
      RandomHolder.randomKey()).setType(req.getType())
      .setAmount(req.getAmount()).setOwner(createOwner())
      .setReceiver(createReceiver())
      .setCurrency(req.getCurrency())
      .setStatementDescriptor(req.getStatementDescriptor())
      .setFlow(req.getFlow()).setUsage(req.getUsage())
      .setCreated(Instant.now().getEpochSecond()).build();
  sourceEntities.put(source.getId(), source);
  return CreateSourceReq.Response.newBuilder()
         .setSource(source).build();
}

此方法从请求对象(CreateSourceReq)接收到的值创建一个source对象。然后,这个新创建的Source对象被保存在一个名为sourceEntities的哈希表中,并返回给调用者。您可以通过添加验证来增强此方法,该验证将验证请求对象(req)。所有者对象和接收者对象(在代码中突出显示)应从请求对象中检索。为了使程序简单,我们在这里硬编码了这些值。

类似地,您可以为sourcecharge及其持久化实现其他合约方法。您可以在以下链接找到该类的完整源代码:https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/server/src/main/java/com/packt/modern/api/server/repository/DbStore.java。

现在,您已经有了内存持久化存储——DbStore。接下来,让我们在仓库类中使用这个存储。

编写仓库类

现在,您可以实现下一层——仓库层。内存持久化存储(DbStore)可以在ChargeRepositoryImpl仓库类中被消费,如下所示:

@Repositorypublic class ChargeRepositoryImpl implements
    ChargeRepository {
  private DbStore dbStore;
  public ChargeRepositoryImpl(DbStore dbStore) {
    this.dbStore = dbStore;
  }
  @Override
  public CreateChargeReq.Response create(
      CreateChargeReq req) {
    return dbStore.createCharge(req);
  }
  // code truncated for brevity

https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/server/src/main/java/com/packt/modern/api/server/repository/ChargeRepositoryImpl.java

ChargeRepositoryImpl实现了ChargeRepository接口,并使用DbStore来执行操作。该仓库接口的代码可在以下链接找到:github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/server/src/main/java/com/packt/modern/api/server/repository/ChargeRepository.java

同样,您可以创建SourceRepositoryImpl类,该类实现了SourceRespository,如下所示:

@Repositorypublic class SourceRepositoryImpl
    implements SourceRepository {
 private DbStore dbStore;
 public SourceRepositoryImpl(DbStore dbStore) {
   this.dbStore = dbStore;
 }
 @Override
 public UpdateSourceReq.Response update
    (UpdateSourceReq req) {
   return dbStore.updateSource(req);
 }
 // Other methods removed for brevity

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/server/src/main/java/com/packt/modern/api/server/repository/SourceRepositoryImpl.java

ChangeRepositoryImpl类似,SourceRepositoryImpl也使用持久化存储来持久化数据。您可以在github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/server/src/main/java/com/packt/modern/api/server/repository/SourceRepository.java找到SourceRepository接口的代码。

SourceCharge存储库类的方法被服务类消费。服务基类由 gRPC(api项目的一部分)生成。服务类实现了这些抽象生成的基类(服务基类)。

接下来,让我们编写服务层。

实现服务类

现在您已经有了以存储库和数据库存储类形式存在的底层实现,可以用来实现 gRPC 服务的基础类。

让我们先实现Source服务,如下所示:

  1. server/src/main/com/packt/modern/api/server/service目录下创建一个新的文件,名为SourceService.java

  2. 将实现添加到定义在SourceService抽象基类中的操作中,如下所示:

    @Servicepublic class SourceService     extends SourceServiceImplBase {  private final SourceRepository repository;  public SourceService(SourceRepository repository) {    this.repository = repository;  }@Overridepublic void create(CreateSourceReq req, StreamObserver<CreateSourceReq.Response> resObserver) {  CreateSourceReq.Response resp = repository.create      (req);  resObserver.onNext(resp);  resObserver.onCompleted();}// Other methods removed for brevity
    

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/server/src/main/java/com/packt/modern/api/server/service/SourceService.java

在这里,SourceServiceImplBase抽象类是由 Protobuf 插件自动生成的,它包含了Source服务的合约方法。生成的方法签名中独特的一部分是第二个参数,StreamObserverStreamObserver接收可观察流的通知。在这里它被用于服务实现。同样,它也被用于客户端存根。gRPC 库为出站消息提供了StreamObserver参数。然而,您还必须为入站消息实现它。

StreamObserver参数不是线程安全的,因此您必须注意多线程问题,并应使用同步调用。

  1. StreamObserver有三个主要方法:

    • onNext():此方法接收来自流的值。它可以多次调用。然而,它不应在onCompleted()onError()之后调用。当向客户端发送多个数据集时,需要多个onNext()调用。

    • onCompleted():这标志着流的完成,之后不允许进行任何方法调用。它只能调用一次。

    • onError():此方法接收来自流的终止错误。与onCompleted()一样,它只能调用一次,之后不允许进行任何方法调用。

  2. 同样,你可以实现抽象类中的其他方法。

接下来,你可以像实现Source服务一样实现Charge服务。让我们来做这件事:

  1. server/src/main/com/packt/modern/api/server/service目录下创建一个新的文件ChargeService.java

  2. 将实现添加到ChargeService抽象基类中定义的操作,如下所示:

    @Servicepublic class ChargeService       extends ChargeServiceImplBase {  private final ChargeRepository repository;  public ChargeService(ChargeRepository repository) {    this.repository = repository;  }  @Override  public void create(CreateChargeReq req,  StreamObserver<CreateChargeReq.Response>     resObserver) {    CreateSourceReq.Response resp =                                 repository.create(req);    resObserver.onNext(resp);    resObserver.onCompleted();  }  // Other methods truncated for brevity
    

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/server/src/main/java/com/packt/modern/api/server/service/ChargeService.java

这与SourceServicecreate方法实现方式类似。

  1. 同样,你可以实现抽象类中的其他方法。请参考代码块后面的源代码链接以获取完整的代码实现。

现在,你已经准备好了服务层的实现。接下来,让我们实现 API 层(gRPC 服务器)。

gRPC 服务器类的实现

Spring Boot 应用程序在其自己的服务器上运行。然而,我们希望运行 gRPC 服务器,它内部使用 Netty 网络服务器。因此,我们首先需要修改 Spring Boot 配置以停止其网络服务器的运行。你可以通过修改server/src/main/resources/application.properties文件来实现,如下面的代码块所示:

spring.main.web-application-type=nonegrpc.port=8080

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/server/src/main/resources/application.properties

接下来,让我们创建 gRPC 服务器。它将包含三个方法——start()stop()block()——分别用于启动服务器、停止服务器以及接收终止请求前服务请求。

server/src/main/com/packt/ modern/api/server目录下创建一个新的文件GrpcServer.java,并编写如下代码块所示的代码:

@Componentpublic class GrpcServer {
  @Value("${grpc.port:8080}");
  private int port;
  private Server server;
  private final ChargeService chargeService;
  private final SourceService sourceService;
  private final ExceptionInterceptor exceptionInterceptor;
  public GrpcServer(…) { // code removed for brevity }
  public void start() throws IOException,
     InterruptedException {
    server = ServerBuilder.forPort(port)
        .addService(sourceService).
            addService(chargeService)
        .intercept(exceptionInterceptor).build().start();
        server.getServices().stream().forEach(s ->
           Systen.out.println("Service Name: {}",
           s.getServiceDescriptor().getName()));
    Runtime.getRuntime().addShutdownHook(new Thread(() -> {
       GrpcServer.this.stop();
    }));
  }
  private void stop() {
    if (server != null) { server.shutdown(); }
  }
  public void block() throws InterruptedException {
    if (server != null) {
      // received the request until application is
         terminated
      server.awaitTermination();
    }
  }
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/server/src/main/java/com/packt/modern/api/server/GrpcServer.java

gRPC 的服务器库提供了用于构建服务器的服务器构建器。您可以看到,两个服务都被添加到了服务器中。构建器还允许您添加拦截器,可以拦截传入的请求和响应。我们将在 编码处理 错误 部分使用拦截器。

GrpcServer start() 方法还增加了一个关闭钩子,该钩子调用 stop() 方法,该方法内部调用 server.shutdown() 方法。

服务器代码已经准备好了。现在,您需要一个接口来启动服务器。您将使用 CommandLineRunner 函数接口来运行服务器。

在您创建 GrpcServer.java 文件的同目录下创建一个新的文件,命名为 GrpcServerRunner.java,并添加以下代码:

@Profile("!test")@Component
public class GrpcServerRunner implements CommandLineRunner {
  private GrpcServer grpcServer;
  public GrpcServerRunner(GrpcServer grpcServer) {
    this.grpcServer = grpcServer;
  }
  @Override
  public void run(String... args) throws Exception {
    grpcServer.start();
    grpcServer.block();
  }
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/server/src/main/java/com/packt/modern/api/server/GrpcServerRunner.java

在这里,您重写了 CommandLineRunnerrun() 方法并调用了 startblock 方法。因此,当您执行 jar 文件时,GrpcServerRunner 将使用其 run() 方法执行并启动 gRPC 服务器。

另一个需要记住的事情是,您已经用 @Profile 注解标记了 GrpcServerRunner 类,并设置为 "!test" 值,这意味着当测试配置文件激活时,这个类不会被加载,因此也不会被执行。

现在您已经完成了服务和服务器实现,接下来让我们在下一小节测试 gRPC 服务器。

测试 gRPC 服务器

首先,您需要在 test 类中将活动配置文件设置为 test,因为这样做将禁用 GrpcServerRunner。让我们这样做并测试它,如下面的代码块所示:

@ActiveProfiles("test")@SpringBootTest
@TestMethodOrder(OrderAnnotation.class)
class ServerAppTests {
  @Autowired
  private ApplicationContext context;
  @Test
  @Order(1)
  void beanGrpcServerRunnerTest() {
    assertNotNull(context.getBean(GrpcServer.class));
    assertThrows(NoSuchBeanDefinitionException.class,
        () -> context.getBean(GrpcServerRunner.class),
        "GrpcServerRunner should not be loaded during
          test");
  }
  // continue …

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/server/src/test/java/com/packt/modern/api/ServerAppTests.java

beanGrpcServerRunnerTest() 方法测试 GrpcServer 类和 GrpcServerRunner 的加载,如果配置文件设置正确,测试应该通过。

现在,让我们继续测试 gRPC 服务。

gRPC 测试库提供了一个特殊的类,GrpcCleanupRule,它以优雅的方式管理注册的服务器和通道的关闭。你需要用 JUnit 的 @Rule 注解它以使其生效。gRPC 测试库还提供了一个 InProcessServerBuilder 构建类,它允许你构建服务器,以及一个 InProcessChannelBuilder 构建类,它允许你构建通道。这三个类就是你构建和管理服务器和通道所需的所有。

因此,你首先需要声明所需的实例,然后设置方法,以便在向 gRPC Source 服务发送请求之前,执行环境可用。

让我们添加所需的类实例,并在以下代码中测试 setup() 方法:

@Rulepublic final GrpcCleanupRule grpcCleanup = new
    GrpcCleanupRule();
private static
  SourceServiceGrpc.SourceServiceBlockingStub blockingStub;
@Autowired
private static String newlyCreatedSourceId = null;
@BeforeAll
public static void setup(@Autowired SourceService
      srcSrvc, @Autowired ChargeService chrgSrvc,
      @Autowired ExceptionInterceptor exceptionInterceptor)
      throws IOException {
 String sName = InProcessServerBuilder.generateName(); // 1
 grpcCleanup.register(InProcessServerBuilder
      .forName(sName).directExecutor().addService(srcSrvc)
      .intercept(exceptionInterceptor)
      .build().start());                               // 2
 blockingStub = SourceServiceGrpc.newBlockingStub(
      grpcCleanup.register(InProcessChannelBuilder
      .forName(sName).directExecutor().build()));      // 3
}

在这里,setup 方法使用 Source 服务创建服务器和通道。让我们理解 setup() 方法中提到的每一行:

  • 第 1 行 生成服务器的唯一名称。

  • 第 2 行 注册了新创建的服务器,并将 Source 服务和服务器拦截器添加到其中。我们将在 处理错误编码 部分讨论 ExceptionInterceptor。然后,它启动服务器以处理请求。

  • 第 3 行 创建阻塞存根,它将被用作向服务器发出调用的客户端。在这里,再次使用 GrpcCleanUpRule 创建客户端通道。

一旦设置执行,它为我们提供了执行测试的环境。让我们测试我们的第一个请求,如下面的代码块所示:

@Test@Order(2)
@DisplayName("Creates source object using create RPC call")
public void SourceService_Create() {
  CreateSourceReq.Response response = blockingStub.create(
     CreateSourceReq.newBuilder().setAmount(100)
     .setCurrency("USD").build());
  assertNotNull(response);
  assertNotNull(response.getSource());
  newlyCreatedSourceId = response.getSource().getId();
  assertEquals(100, response.getSource().getAmount());
  assertEquals("USD", response.getSource().getCurrency());
}

setup() 方法的所有复杂方面都已完成。现在这些测试看起来相当简单。你只需使用阻塞存根进行调用。你创建请求对象,并使用存根调用服务器。最后,验证服务器响应。

类似地,你可以测试验证错误,如下面的代码块所示:

@Test@Order(3)
@DisplayName("Throws exception when invalid source id is
     passed to retrieve RPC call")
public void SourceService_RetrieveForInvalidId() {
  Throwable throwable = assertThrows(
       StatusRuntimeException.class, () -> blockingStub
       .retrieve(SourceId.newBuilder().setId("").build()));
  assertEquals("INVALID_ARGUMENT: Invalid Source ID passed"
       , throwable.getMessage());
}

你还可以测试源检索的有效响应,如下面的代码块所示:

@Test@Order(4)
@DisplayName("Retrieves source obj created by
   createRPC call")
public void SourceService_Retrieve() {
  SourceId.Response response = blockingStub.retrieve
    (SourceId
      .newBuilder().setId(newlyCreatedSourceId).build());
  assertNotNull(response);
  assertNotNull(response.getSource());
  assertEquals(100, response.getSource().getAmount());
  assertEquals("USD", response.getSource().getCurrency());
}

这是你可以为 gRPC 服务器编写测试并测试公开的 RPC 调用的方式。你可以使用相同的方法编写其余的测试用例。在编写测试后,你可能会有一个想法,了解客户端将如何向服务器发送请求。

我们还没有讨论我们在服务器代码和测试中使用的异常拦截器。让我们在下节中讨论这个问题。

处理错误编码

你可能已经阅读了基于理论的 处理错误和错误状态码 部分,在 第十章开始使用 gRPC,其中讨论了 google.rpc.Status 和 gRPC 状态码。在阅读本节之前,你可能想回顾一下那个部分,因为在这里你将编写实际的代码。

io.grpc.ServerInterceptor 是一个线程安全的接口,用于拦截传入的调用,可用于跨切面调用,如身份验证和授权、日志记录和监控。让我们使用它来编写 ExceptionInterceptor,如下面的代码块所示:

@Componentpublic class ExceptionInterceptor implements ServerInterceptor {
 @Override
 public <RQT, RST> ServerCall.Listener<RQT> interceptCall(
    ServerCall<RQT, RST> serverCall, Metadata
        metadata,
    ServerCallHandler<RQT, RST> serverCallHandler) 
   ServerCall.Listener<RQT> listener = serverCallHandler
          .startCall(serverCall, metadata);
   return new ExceptionHandlingServerCallListener <>(
       listener, serverCall, metadata);
}
// continue …

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/server/src/main/java/com/packt/modern/api/server/interceptor/ExceptionInterceptor.java

在这里,RQT 代表请求类型,而 RST 代表响应类型。

我们将用它来进行异常拦截。拦截器将调用传递给服务器监听器(ExceptionHandlingServerCallListener)。ExceptionHandlingServerCallListenerExceptionInterceptor 中的一个私有类,它扩展了 ForwardingServerCallListener 抽象类 SimpleForwardingServerCallListener

私有监听器类已重写事件 onHalfClose()onReady(),这将捕获异常并将调用传递给 handleException() 方法。handleException() 方法将使用 ExceptionUtils 方法来追踪实际的异常并以错误详情响应。ExceptionUtils 返回 StatusRuntimeException,用于以错误状态关闭服务器调用。

让我们看看下一个代码块中的代码是如何展示这个流程的:

private class ExceptionHandlingServerCallListener<RQT, RST>    extends ForwardingServerCallListener
        .SimpleForwardingServerCallListener<RQT> {
  private final ServerCall<RQT, RST> serverCall;
  private final Metadata metadata;
  ExceptionHandlingServerCallListener
      (ServerCall.Listener<RQT>
     lsnr,ServerCall<RQT, RST> serverCall, Metadata mdata) {
    super(lstnr);
    this.serverCall = serverCall;
    this.metadata = mdata;
  }
  @Override
  public void onHalfClose() {
    try { super.onHalfClose();}
    catch (RuntimeException e) {
      handleException(e, serverCall, metadata);
      throw e;
    }
  }
  @Override
  public void onReady() {
    try { super.onReady();}
    catch (RuntimeException e) {
      handleException(e, serverCall, metadata);
      throw e;
    }
  }
  private void handleException(RuntimeException e,
       ServerCall<RQT, RST> serverCall, Metadata metadata) {
    StatusRuntimeException status = ExceptionUtils.traceException(e);
    serverCall.close(status.getStatus(), metadata);
  }
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/server/src/main/java/com/packt/modern/api/server/interceptor/ExceptionInterceptor.java

接下来,我们将编写 ExceptionUtils 类以完成异常处理的核心组件。然后,你可以在服务实现中使用这些组件来抛出异常。

ExceptionUtils 类将有两种类型的重载方法:

  • observerError(): 这个方法将使用 StreamObserver 来触发 onError() 事件

  • traceException(): 这个方法将追踪 Throwable 中的错误并返回 StatusRuntimeException 实例

你可以使用以下代码来编写 ExceptionUtils 类:

@Componentpublic class ExceptionUtils {
private static final Logger LOG = LoggerFactory.getLogger
    (ExceptionInterceptor.class);
  public static StatusRuntimeException
      traceException(Throwable e) {
    return traceException(e, null);
  }
  public static <T extends GeneratedMessageV3> void
    observeError(StreamObserver<T>
        responseObserver, Throwable e) {
    responseObserver.onError(traceException(e));
  }
  public static <T extends GeneratedMessageV3> void
    observeError(StreamObserver<T> responseObserver,
       Exception
      e, T defaultInstance) {
    responseObserver.onError(
        traceException(e, defaultInstance));
  }
  // Continue …

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/server/src/main/java/com/packt/modern/api/server/exception/ExceptionUtils.java

在这里,你可以看到 observerError() 方法也在内部为 onError 事件调用 traceException()。让我们接下来编写最后一个重载方法 traceException()

public static <T extends       com.google.protobuf.GeneratedMessageV3>
         StatusRuntimeException traceException(
            Throwable e, T defaultInstance) {
  com.google.rpc.Status status;
  StatusRuntimeException statusRuntimeException;
  if (e instanceof StatusRuntimeException) {
    statusRuntimeException = (StatusRuntimeException) e;
  } else {
    Throwable cause = e;
    if (cause != null && cause.getCause() != null &&
        cause.getCause() != cause) {
      cause = cause.getCause();
    }
    if (cause instanceof SocketException) {
      String errorMessage = "Sample exception message";
      status = com.google.rpc.Status.newBuilder()
          .setCode(com.google.rpc.Code.UNAVAILABLE_VALUE)
          .setMessage(errorMessage + cause.getMessage())
          .addDetails(Any.pack(defaultInstance))
          .build();
    } else {
      status = com.google.rpc.Status.newBuilder()
          .setCode(com.google.rpc.Code.INTERNAL_VALUE)
          .setMessage("Internal server error")
          .addDetails(Any.pack(defaultInstance))
          .build();
    }
    statusRuntimeException = StatusProto
         .toStatusRuntimeException(status);
  }
  return statusRuntimeException;
}

在这里,以 SocketException 为例。你可以在这里添加对另一种异常的检查。你可能注意到,在这里我们使用 com.google.rpc.Status 来构建状态。然后,将这个 Status 实例传递给 StatusPrototoStatusRuntimeException(),它将状态转换为 StatusRuntimeException

让我们在 DbStore 类中添加验证错误,以便使用这些异常处理组件,如下面的代码块所示:

public SourceId.Response retrieveSource(String sourceId) {  if (Strings.isBlank(sourceId)) {
    com.google.rpc.Status status =
         com.google.rpc.Status.newBuilder()
        .setCode(Code.INVALID_ARGUMENT.getNumber())
        .setMessage("Invalid Source ID is
           passed.").build();
    throw StatusProto.toStatusRuntimeException(status);
  }
  Source source = sourceEntities.get(sourceId);
  if (Objects.isNull(source)) {
    com.google.rpc.Status status =
         com.google.rpc.Status.newBuilder()
        .setCode(Code.INVALID_ARGUMENT.getNumber())
        .setMessage("Requested source is not available")
        .addDetails(Any.pack(
           SourceId.Response.getDefaultInstance())
              ).build();
    throw StatusProto.toStatusRuntimeException(status);
  }
  return SourceId.Response.newBuilder()
        .setSource(source).build();
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/server/src/main/java/com/packt/modern/api/server/repository/DbStore.java

你可以在服务实现的任何部分类似地引发 StatusRuntimeException。你还可以使用 com.google.rpc.StatusaddDetails() 方法向错误状态添加更多详细信息,如 traceException(Throwable e, T defaultInstance) 代码所示。

最后,你可以在 Service 实现类中捕获由 SourceServiceretrieve() 方法引发的错误,如下所示:

@Overridepublic void retrieve(SourceId sourceId, StreamObserver<SourceId.Response> resObserver) {
  try {
    SourceId.Response resp =
                      repository.retrieve(sourceId.getId());
    resObserver.onNext(resp);
    resObserver.onCompleted();
  } catch (Exception e) {
    ExceptionUtils.observeError(resObserver, e,
                      SourceId.Response.getDefaultInstance());
  }
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/server/src/main/java/com/packt/modern/api/server/service/SourceService.java

本章简单而建设性地解释了异常处理。你可以根据应用程序的需求进一步增强它。

现在,让我们在下一节中编写 gRPC 客户端。

开发 gRPC 客户端

客户端项目的目录结构将如下所示。项目根目录包含 build.gradlesettings.gradle 文件,如下面的目录树结构所示:

├── client   ├── build.gradle
   ├── gradle
   │   └── wrapper
   ├── gradlew
   ├── gradlew.bat
   ├── settings.gradle
   └── src
       ├── main
       │   ├── java
       │   │   └── com
       │   │       └── packt
       │   │           └── modern
       │   │               └── api
       │   └── resources
       └── test
           └── java

resources 目录将包含 application.properties 文件。

让我们执行以下步骤来配置项目:

  1. 首先,你需要修改 Chapter11/client/settings.gradle 文件中的项目名称,以表示服务器,如下所示:

    rootProject.name = 'chapter11-client'
    
  2. 接下来,你可以在 Chapter11/client/build.gradle 文件中添加客户端项目所需的依赖项。grpc-stub 库提供了与存根相关的 API,而 protobuf-java-util 提供了 Protobuf 和 JSON 转换的实用方法:

    def grpcVersion = '1.54.1'dependencies {    implementation 'com.packt.modern.api:payment-      gateway-api:0.0.1'    implementation "io.grpc:grpc-stub:${grpcVersion}"    implementation "com.google.protobuf:protobuf-java-      util:3.22.2"    implementation 'org.springframework.boot:      spring-boot-starter-web'    testImplementation 'org.springframework.boot:      spring-boot-starter-test'}
    

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/client/build.gradle

  1. payment-gateway-api依赖项已发布在本地 Maven 仓库中。因此,你需要将本地 Maven 仓库添加到repositories部分,如下面的代码块所示:

    repositories {  mavenCentral()  mavenLocal()}
    

你已经完成了 Gradle 配置。现在,你可以编写 gRPC 客户端。

实现 gRPC 客户端

如你所知,Spring Boot 应用程序在自己的服务器上运行。因此,客户端的应用程序端口应该与 gRPC 服务器端口不同。此外,我们还需要提供 gRPC 服务器的主机和端口。这些可以在application.properties中进行配置:

server.port=8081grpc.server.host=localhost
grpc.server.port=8080

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/client/src/main/resources/application.properties

接下来,让我们创建 gRPC 客户端。这个客户端将用于使用通道配置 gRPC 服务存根。通道负责提供虚拟连接到概念上的端点,以便执行 gRPC 调用。

client/src/main/com/packt/modern/api/client目录下创建一个新文件,命名为GrpcClient.java,并添加以下代码块中的代码:

@Componentpublic class GrpcClient {
  @Value("${grpc.server.host:localhost}")
  private String host;
  @Value("${grpc.server.port:8080}")
  private int port;
  private ManagedChannel channel;
  private SourceServiceBlockingStub sourceServiceStub;
  private ChargeServiceBlockingStub chargeServiceStub;
  public void start() {
    channel = ManagedChannelBuilder.forAddress(host, port)
        .usePlaintext().build();
    sourceServiceStub = SourceServiceGrpc
         .newBlockingStub(channel);
    chargeServiceStub = ChargeServiceGrpc
        .newBlockingStub(channel);
  }
  public void shutdown() throws InterruptedException {
    channel.shutdown().awaitTermination
      (1, TimeUnit.SECONDS);
  }
  public SourceServiceBlockingStub getSourceServiceStub() {
    return this.sourceServiceStub;
  }
  public ChargeServiceBlockingStub getChargeServiceStub() {
    return this.chargeServiceStub;
  }
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/client/src/main/java/com/packt/modern/api/client/GrpcClient.java

在这里,start()是初始化SourceCharge服务存根的关键。使用ManagedChannelBuilder构建ManagedChannelManagedChannel是一个提供生命周期管理的通道。这个管理通道被传递给存根。

你正在使用纯文本通信。然而,它也提供了加密通信。

我们现在已经完成了客户端的代码。现在,我们需要调用start()方法。你将按照为GrpcServerRunner类实现的方式实现CommandLineRunner

它可以按照以下方式实现:

@Profile("!test")@Component
public class GrpcClientRunner implements CommandLineRunner {
  private static final Logger LOG = LoggerFactory.getLogger
     (GrpcClient.class);
  @Autowired
  GrpcClient client;
  @Override
  public void run(String... args) {
    client.start();
    Runtime.getRuntime().addShutdownHook(new Thread(() -> {
      try {
        client.shutdown();
      } catch (InterruptedException e) {
        System.out.println("error: {}", e.getMessage());
      }
    }));
  }
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/client/src/main/java/com/packt/modern/api/client/GrpcClientRunner.java

这将在应用程序启动后启动存根实例化。然后你可以调用存根方法。

现在,为了调用存根方法,让我们添加一个简单的 REST 端点。这将演示如何使用费用服务存根调用其retrieve方法。

您可以在src/main/java/com/packts/modern/api/controller目录中创建一个新的ChargeController.java文件,并将代码添加如下所示:

@RestControllerpublic class ChargeController {
  private final GrpcClient client;
  public ChargeController(GrpcClient client) {
    this.client = client;
  }
  @GetMapping("/charges")
  public String getSources(@RequestParam(defaultValue =
      "ab1ab2ab3ab4ab5") String customerId)
        throws InvalidProtocolBufferException {
    var req = CustomerId.newBuilder()
        .setId(customerId).build();
    CustomerId.Response resp =
       client.getChargeServiceStub().retrieveAll(req);
    var printer = JsonFormat.printer()
         .includingDefaultValueFields();
      return printer.print(resp);
  }
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11/client/src/main/java/com/packt/modern/api/controller/ChargeController.java

在这里,我们创建了一个 REST 端点,/charges。它使用GrpcClient实例通过ChargeServiceStub调用Charge gRPC 服务的retrieveAll() RPC 方法。

然后,使用来自protobuf-java-util库的JsonFormat类将响应转换为 JSON 格式的字符串,并作为响应返回。生成的 JSON 格式的字符串也将包含具有默认值的字段。

我们的开发工作已经完成。现在让我们在下一小节中测试整个流程。

测试 gRPC 服务

在测试客户端之前,请确保您的 gRPC 服务器正在运行。假设api项目已经构建,并且其最新工件已发布到本地 Maven 仓库:

  1. 首先,请确保您的api项目库已发布到本地 Maven 仓库,因为它被serverclient项目都需要。如果您已经发布了库,请跳转到步骤 2。Java 应设置为版本 17。从api项目的根目录执行以下命令:

    server project’s root directory (Java should be set to version 17):
    
    

    客户端项目的根目录(Java 应设置为版本 17):

    server and client services are up and running, open a new terminal window and execute the following command (the output is truncated):
    
    

    // 调用客户端服务的费用 API 端点$ curl http://localhost:8081/charges{  "charge": [{    "id": "cle9e9oam6gajkkeivjof5pploq89ncp",    "amount": 1000,    "amountCaptured": 0,    …    "created": "1679924425",    "currency": "USD",    "customerId": "ab1ab2ab3ab4ab5",    "description": "费用描述",    …    "receiptEmail": "receipt@email.com",    …    "status": "SUCCEEDED",    "sourceId": "0ovjn4l6crgp9apr79bhpefme4dok3qf"  }]}

    
    
    
    

仅为了演示目的,使用了 REST 端点。同样,您可以使用 gRPC 客户端调用其他服务和它们的方法。gRPC 通常用于服务间通信,这对于基于微服务的应用程序至关重要。然而,它也可以用于基于 Web 的通信。

在下一节中,让我们了解一下微服务。

理解微服务概念

微服务是自包含的轻量级进程,通过网络进行通信。微服务为其消费者提供专注于特定功能的 API。这些 API 可以使用 REST、gRPC 或事件来实现。

微服务并不新鲜——它们已经存在很多年了。例如,基于 RPC 的通用基础设施Stubby,在 21 世纪初被用于谷歌数据中心,以连接几个服务和数据中心。

它们最近在流行度和可见度上有所上升。在微服务变得流行之前,单体架构主要用于开发本地和基于云的应用程序。

单体架构允许开发不同的组件,例如表示层、应用逻辑、业务逻辑和数据访问对象DAOs),然后你可以将它们打包在一起形成一个企业存档EAR)或Web 存档WAR),或者将它们存储在单个目录层次结构中(例如 Rails 或 Node.js)。

许多著名的应用程序,如 Netflix,都是使用微服务架构开发的。此外,eBay、Amazon 和 Groupon 已经从单体架构演变为微服务架构。如今,基于微服务的应用程序开发非常普遍。我们在本章中开发的 gRPC 服务器可以被称为微服务(显然,如果你将服务器的范围限制在Source服务或Charge服务器)。

让我们在下一小节中看看简单的单体和微服务应用设计。

在本节中,我们将探讨不同的系统设计,这些设计采用单体设计、SOA 单体设计和微服务设计。让我们依次讨论这些设计。

传统单体设计

以下图展示了传统的单体应用程序设计。这种设计在 SOA 变得流行之前被广泛使用:

图 11.1 – 传统单体应用设计

图 11.1 – 传统单体应用设计

在传统的单体设计中,所有内容都打包在同一个存档中(所有表示层代码都打包在表示层存档中,应用逻辑放入应用逻辑存档中,等等),无论它们如何与数据库文件或其他源进行交互。

带有服务的单体设计

在 SOA 之后,应用程序开始基于服务进行开发,其中每个组件为其他组件或外部实体提供服务。以下图展示了具有不同服务的单体应用程序;在这里,服务与表示组件一起使用。所有服务、表示组件或任何其他组件都打包在一起:

图 11.2 – 带有服务的单体设计

图 11.2 – 带有服务的单体设计

因此,所有内容都以模块化的方式打包成 EAR。一些 SOA 服务可能被单独部署,但总体上,它将是单体的。然而,数据库是在服务之间共享的。

微服务设计

下面的图展示了微服务设计。在这里,每个组件都是独立的。每个组件都可以独立开发、构建、测试和部署。在这里,应用程序的 UI 组件也可以是一个客户端并消费微服务。在我们的例子中,设计的层是在微服务中使用的。

图 11.3 – 微服务设计

图 11.3 – 微服务设计

API 网关提供了一个接口,不同的客户端可以访问单个服务并解决各种问题,例如当你想为同一服务向不同的客户端发送不同响应时应该怎么做。例如,预订服务可以向移动客户端(最少信息)和桌面客户端(详细信息)发送不同的响应,为每个客户端提供不同的详细信息,然后再向第三方客户端提供不同的内容。

响应可能需要从两个或更多服务中获取信息。

每个 API 服务都将作为一个独立的过程开发和部署,并且这些服务之间的通信将基于公开的 API 进行。

对于一个示例电子商务应用,你可以根据领域和边界上下文来划分应用程序,然后为每个领域开发一个独立的微服务。以下是一个提供的微服务的简要列表:

  • 客户

  • 订单

  • 计费

  • 发货

  • 开票

  • 库存

  • 收款

你可以分别开发这些功能,并使用进程间(服务间)通信来整合解决方案。

摘要

在本章中,你探索了基于 Protobuf 和 gRPC 的服务实现。你开发了 gRPC 服务器,然后通过开发 gRPC 客户端来消费其服务。你学习了如何对 gRPC 服务器进行单元测试,以及如何处理基于 gRPC 服务的异常,你还学习了微服务的基本概念。

现在,你有了使用 Protobuf 定义服务来开发基于 gRPC 的服务(服务器)和客户端的技能。

在下一章中,你将学习关于在 Web 服务中的分布式日志和跟踪。

问题

  1. 为什么你应该使用 gRPC 通过 HTTP/2 进行二进制大对象传输?

  2. 你已经使用com.google.rpc.Status实现了异常处理。你能否不使用它来完成?

  3. com.google.rpc.Statusio.grpc.Status之间的区别是什么?

答案

  1. 因为,与 HTTP 库不同,gRPC 库还提供了以下功能:

    • 与应用层流量控制的交互

    • 级联调用取消

    • 负载均衡和故障转移

  2. 是的,你可以。你可以使用以下代码块中显示的元数据。然而,使用com.google.rpc.Status允许你使用details(类型为Any)对象,它可以捕获更多信息:

    Metadata.Key<SourceId.Response> key = ProtoUtils    .keyForProto(SourceId.Response. getDefaultInstance);Metadata metadata = new Metadata();metadata.put(key, sourceIdResponse);respObserver.onError(Status.INVALID_ARGUMENT   .withDescription("Invalid Source ID")   .asRuntimeException(metadata));
    
  3. com.google.rpc.Status可以包含Any类型的详细信息,这可以用来提供更多的错误详情。io.grpc.Status没有包含错误详情的字段。你必须依赖另一个类的元数据来提供与错误相关的详情,这些详情可能或可能不包含仅错误特定的信息。

进一步阅读

第十一章:将日志和跟踪添加到服务中

在本章中,你将学习关于日志和跟踪工具的内容。我们将使用 Spring Micrometer、Brave、Elasticsearch、Logstash 和 KibanaELK)堆栈以及 Zipkin。ELK 和 Zipkin 将用于实现 API 调用的请求/响应的分布式日志和跟踪。Spring MicrometerActuator 将用于将跟踪信息注入 API 调用中。你将学习如何发布和分析不同请求的日志以及与响应相关的日志。

这些聚合日志将帮助你排查网络服务问题。你将调用一个服务(例如 gRPC 客户端),然后该服务将调用另一个服务(例如 gRPC 服务器),并通过跟踪标识符将它们链接起来。然后,使用这个跟踪标识符,你可以搜索集中式日志并调试请求流程。在本章中,我们将使用这个示例流程。然而,当服务调用需要更多内部调用时,也可以使用相同的跟踪。你还将使用 Zipkin 来确认每个 API 调用的性能。

然后,我们将使用 Spring Micrometer 探索日志和监控工具,包括 ELK 堆栈和 Zipkin。这些工具(ELK 和 Zipkin)随后将用于实现 API 请求和响应的分布式日志和跟踪。Spring Micrometer 将用于将跟踪信息注入 API 调用中。你将学习如何发布和分析不同请求的日志以及与响应相关的日志。

你将在本章中探索以下主题:

  • 使用 ELK 堆栈进行日志和跟踪

  • 在 gRPC 代码中实现日志和跟踪

  • 使用 Zipkin 和 Micrometer 进行分布式跟踪

技术要求

为了在本章中开发和执行代码,你需要以下内容:

  • 任何 Java 集成开发环境(IDE),例如 NetBeans、IntelliJ 或 Eclipse

  • Java 开发工具包JDK17

  • 一个互联网连接来克隆代码和下载依赖项以及 Gradle

  • Insomnia/cURL(用于 API 测试)

  • Docker 和 Docker Compose

你可以在github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter12找到本章中使用的代码。

那么,让我们开始吧!

使用 ELK 堆栈进行日志和跟踪

今天,产品和服务的划分已经变成了多个小部分,作为单独的过程执行或作为独立的服务部署,而不是作为一个单体系统。一个 API 调用可能会触发其他几个内部 API 调用。因此,您需要分布式和集中式日志来追踪跨越多个 Web 服务的请求。这种追踪可以使用跟踪标识符(traceId)来完成,这也可以被称为关联标识符(correlationId)。这个标识符是一组字符,形成一个唯一的字符串,它被填充并分配给需要多个服务间调用的 API 调用。然后,相同的跟踪标识符被传播到后续的 API 调用以进行跟踪。

在生产系统中,错误和问题随时可能发生。您需要执行调试以确定根本原因。与调试相关的一个关键工具是日志。如果系统设计为这样做,日志还可以提供与系统相关的警告。日志还提供吞吐量、容量和系统健康状况的监控。因此,您需要一个优秀的日志平台和策略,以实现有效的调试。

市场上提供了不同的开源和商业日志工具,包括 Splunk、Graylog 和 ELK 堆栈。ELK 堆栈是其中最受欢迎的,如果您不打算提供基于 ELK 的服务作为 SaaS,您可以使用它。我们将在本章中使用 ELK 堆栈进行日志记录。

让我们在下一小节中了解 ELK 堆栈。

理解 ELK 堆栈

ELK 堆栈由三个组件组成 – Elasticsearch、Logstash 和 Kibana。这三个产品都是 Elasticsearch B.V.的一部分(www.elastic.co/)。ELK 堆栈执行日志的聚合、分析、可视化和监控。ELK 堆栈提供了一个完整的日志平台,允许您分析、可视化和监控所有类型的日志,包括产品和系统日志。

您将使用以下工作流程来发布日志:

图 12.1 – ELK 堆栈中的日志流程

图 12.1 – ELK 堆栈中的日志流程

让我们了解这个图:

  • 服务/系统日志被推送到 Logstash 的 TCP 端口

  • Logstash 将日志推送到 Elasticsearch 进行索引

  • Kibana 随后使用 Elasticsearch 索引来查询和可视化日志

在一个理想的生产系统中,您应该使用一个额外的层。例如 Redis、Kafka 或 RabbitMQ 这样的代理层应该放在服务日志和 Logstash 之间。这可以防止数据丢失,并能够处理输入负载的突然增加。

ELK 堆栈配置的技巧

ELK 堆栈是完全可定制的,并附带默认配置。然而,如果您正在使用 Elasticsearch 集群(部署了多个 Elasticsearch 实例),最好使用奇数个 Elasticsearch 节点(实例)以避免脑裂问题。

建议为所有字段使用适当的数据类型(日志的输入以 JSON 格式)。这将允许你在查询日志数据时执行逻辑检查和比较。例如,http_status < 400 检查只有在 http_status 字段类型是数字时才会工作,如果 http_status 字段类型是字符串,可能会失败。

如果你已经熟悉 ELK 堆栈,你可以跳过这个介绍,直接进入下一节。在这里,你可以找到 ELK 堆栈中每个工具的简要介绍。

Elasticsearch

Elasticsearch 是最受欢迎的企业级全文搜索引擎之一。它基于 Apache Lucene,并使用 Java 开发。Elasticsearch 还是一个高性能、功能齐全的文本搜索引擎库。最近在许可条款中的变化使其成为受限制的开源软件,这阻止了你将 Elasticsearch 或 ELK 堆栈作为 SaaS 提供。它是可分发的,并支持多租户。单个 Elasticsearch 服务器可以存储多个索引(每个索引代表一个数据库),单个查询可以搜索多个索引中的数据。它是一个分布式搜索引擎,并支持集群。

它易于扩展,可以提供近实时的搜索,延迟为一秒。Elasticsearch API 非常广泛且非常详尽。Elasticsearch 提供基于 JSON 的无模式存储,并以 JSON 表示数据模型。Elasticsearch API 使用 JSON 文档进行 HTTP 请求和响应。

Logstash

Logstash 是一个开源的数据收集引擎,具有实时管道功能。它执行三个主要操作——收集数据、过滤信息,并将处理后的信息输出到数据存储,就像 Elasticsearch 所做的那样。由于其数据管道功能,它允许你处理任何事件数据,例如来自各种系统的日志。

Logstash 以代理的形式运行,收集数据、解析数据、过滤数据,并将输出发送到指定的数据存储,如 Elasticsearch,或作为控制台上的简单标准输出。

此外,它还拥有丰富的插件集。

Kibana

Kibana 是一个开源的 Web 应用程序,用于可视化执行信息分析。它与 Elasticsearch 交互,并提供与它的简单集成。你可以执行搜索、显示和与存储在 Elasticsearch 索引中的信息进行交互。

它是一个基于浏览器的 Web 应用程序,让你能够执行高级数据分析,并以各种图表、表格和地图的形式可视化你的数据。此外,它是一个零配置应用程序。因此,安装后不需要任何编码或额外的基础设施。

接下来,让我们学习如何安装 ELK 堆栈。

安装 ELK 堆栈

你可以使用各种方法来安装 ELK 堆栈,例如根据操作系统安装单个组件,下载 Docker 镜像并单独运行它们,或者使用 Docker Compose、Docker Swarm 或 Kubernetes 执行 Docker 镜像。你将在本章中使用 Docker Compose。

在我们创建 ELK 堆栈 Docker Compose 文件之前,让我们先了解 Docker Compose 文件的语法。Docker Compose 文件使用 YAML 定义。该文件包含四个重要的顶级键:

  • version:这表示 Docker Compose 文件格式的版本。你可以根据安装的 Docker Engine 使用适当的版本。你可以检查docs.docker.com/compose/compose-file/以确定 Docker Compose 文件版本和 Docker Engine 版本之间的映射关系。

  • services:这包含一个或多个服务定义。服务定义表示由容器执行的服务,并包含容器名称(container_name)、Docker 镜像(image)、环境变量(environment)、外部和内部端口(port)、运行容器时要执行的命令(command)、用于与其他服务通信的网络(networks)、将主机文件系统映射到运行容器的映射(volume),以及一旦依赖服务启动后要执行容器(depends_on)。

  • networks:这代表需要创建的(顶级)命名网络,用于在定义的服务之间建立通信通道。然后,该网络根据定义服务的networks键由服务用于通信。顶级网络键包含驱动器字段,对于单个主机可以是bridge,当在 Docker Swarm 中使用时可以是overlay。我们将使用bridge

  • volumes:顶级volumes键用于创建挂载主机路径的命名卷。确保仅在需要时使用它;否则,你可以在服务定义内部使用volumes键,这将使卷特定于服务。

现在,让我们在Chapter12目录中创建 Docker Compose 文件,名为docker-compose.yaml,以定义 ELK 堆栈。然后,你可以将以下代码添加到该文件中:

version: "3.2"services:
  elasticsearch:
    container_name: es-container
    image: docker.elastic.co/elasticsearch/
        elasticsearch:8.7.0
    environment:
      - xpack.security.enabled=false
      - "discovery.type=single-node"
    networks:
      - elk-net
    ports:
      - 19200:9200

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter12/docker-compose.yaml

首先,我们定义了 Docker Compose 文件的版本。然后,我们创建了services键部分,其中包含elasticsearch服务。该服务包含容器名称、Docker 镜像、环境变量和网络(因为你希望 ELK 组件相互通信)。最后,以external:internal格式定义端口。你将使用浏览器中的端口19200来访问它。然而,其他服务将使用端口9200与 Elasticsearch 通信。

类似地,你可以定义下一个logstash服务,如下面的代码块所示:

  logstash:    container_name: ls-container
    image: docker.elastic.co/logstash/logstash:8.7.0
    environment:
      - xpack.security.enabled=false
    command: logstash -e 'input { tcp { port => 5001 codec
     => "json" }} output { elasticsearch { hosts =>
    "elasticsearch:9200" index => "modern-api" }}'
    networks:
      - elk-net
    depends_on:
      - elasticsearch
    ports:
      - 5002:5001

Logstash 配置包含两个额外的服务键:

  • 首先,一个包含给定配置(使用-e)的logstash命令的命令键。Logstash 配置通常包含三个重要部分:

    • input:Logstash 输入通道,如tcpFile。我们将使用 TCP 输入通道。这意味着 gRPC 服务器和客户端服务将以 JSON 格式(使用 JSON 编码的插件)将日志推送到端口5001上的logstash

    • filterfilter键包含使用不同方式(如grok)的各种过滤器表达式。你不想从日志中过滤任何内容,因此应该选择不使用此键。

    • output:在过滤信息后,将输入数据发送到何处。在这里,我们使用 Elasticsearch。Logstash 将接收到的日志信息推送到端口9200上的 Elasticsearch,并使用modern-api Elasticsearch 索引。此索引随后在 Kibana 上用于查询、分析和可视化日志。

  • 第二个键depends_on告诉 Docker Compose 在执行logstash服务之前启动 Elasticsearch。

接下来,让我们添加最后一个服务kibana,如下面的代码块所示:

    kibana:      container_name: kb-container
      image: docker.elastic.co/kibana/kibana:8.7.0
      environment:
        - ELASTICSEARCH_HOSTS=http://es-container:9200
      networks:
        - elk-net
      depends_on:
        - elasticsearch
     ports:
        - 5600:5601
networks:
  elk-net:
    driver: bridge

该服务的kibana定义与其他定义的服务一致。它使用ELASTICSEARCH_HOSTS环境变量连接到 Elasticsearch。

在 Docker Compose 文件的末尾,你定义了elk-net网络,该网络使用bridge驱动程序。

你已经完成了 ELK 堆栈 Docker Compose 文件的配置。现在,让我们使用以下命令启动 Docker Compose。如果你是第一次运行此命令,Elasticsearch、Logstash 和 Kibana 的本地镜像也将被检索。如果你在以下命令中使用了除docker-compose.yamldocker-compose.yml之外的其他文件名,可以使用-f标志:

$ docker-compose up –dCreating network "chapter12_elk-net" with driver "bridge"
Creating es-container ... done
Creating ls-container ... done
Creating kb-container ... done

这里使用-d选项,这将使 Docker Compose 在后台运行。它首先根据依赖关系(depends_on键)启动es-container Elasticsearch 容器。

注意

Elasticsearch 默认使用 2 GB 的堆大小。在某些系统(如 Mac)中,Docker 也默认使用 2 GB 的内存。

这可能会导致如error-137之类的错误。因此,你应该将默认的 Docker 内存增加到至少 8 GB(越多越好),并将交换内存增加到至少 2 GB 以避免此类问题。

请参考 docs.docker.com/config/containers/resource_constraints/#memory 了解 Docker 内存配置。

你可以看到 Docker Compose 首先创建网络,然后创建服务容器并启动它们。一旦所有容器都启动,你可以在浏览器中访问 URL http://localhost:19200/(其中包含为 Elasticsearch 服务定义的外部端口)来检查 Elasticsearch 实例是否已启动。

一旦你访问了 URL,如果 Elasticsearch 服务已启动,你可能会看到以下类型的 JSON 响应:

{  "name" : "1bfa291e20b2",
  "cluster_name" : "docker-cluster",
  "cluster_uuid" : "Lua_MmozTS-grM0ZeJ5EBA",
  "version" : {
    "number" : "8.7.0",
    "build_flavor" : "default",
    "build_type" : "docker",
    "build_hash" :
        "09520b59b6bc1057340b55750186466ea715e30e",
    "build_date" : "2023-03-27T16:31:09.816451435Z",
    "build_snapshot" : false,
    "lucene_version" : "9.5.0",
    "minimum_wire_compatibility_version" : "7.17.0",
    "minimum_index_compatibility_version" : "7.0.0"
  },
  "tagline" : "You Know, for Search"
}

接下来,让我们通过在浏览器中访问 URL http://localhost:5600(其中包含为 kibana 服务定义的外部端口)来检查 Kibana 仪表板。这应该会加载 Kibana 的主页,如下面的截图所示:

图 12.2 – Kibana 主页

图 12.2 – Kibana 主页

你可能想知道如何查看日志,因为你使用了 -d 选项。你可以使用 docker-compose logs [服务名称] 命令。如果你不提供服务名称,它将显示所有服务的日志。你可以使用 --tail 标志来过滤行数。--tail="all" 标志将显示所有行:

// Don't use flag –t as it is a switch that turns on the timestamp$ docker-compose logs --tail="10" elasticsearch
$ docker-compose logs --tail="10" kibana

你可以使用以下命令停止 Docker Compose:

$ docker-compose downStopping ls-container ... done
Stopping kb-container ... done
Stopping es-container ... done
Removing ls-container ... done
Removing kb-container ... done
Removing es-container ... done
Removing network chapter12_elk-net

输出可能略有不同。然而,它应该停止并删除所有正在运行的容器。该命令根据 docker-compose.yaml 文件中提供的依赖关系,基于 depends_on 属性停止容器,然后删除它们。最后,它删除网络。

接下来,让我们对代码进行更改,以将应用程序集成到 ELK 堆栈中。

在 gRPC 代码中实现记录和跟踪

记录和跟踪是相辅相成的。在应用程序代码中,记录默认已经处理。你使用 Logback 进行记录。日志要么配置为在控制台上显示,要么推送到文件系统。然而,你还需要将日志推送到 ELK 堆栈以进行索引和分析。为此,你将对 Logback 配置文件 logback-spring.xml 进行一些更改,以将日志推送到 Logstash。此外,这些日志还应包含跟踪信息。

为了跟踪目的,相关/跟踪标识符应在分布式事务中填充和传播。分布式事务指的是内部调用其他服务以处理请求的主要 API 调用。在 Spring Boot 3 之前,Spring 通过 Spring Cloud Sleuth 库提供分布式跟踪支持;现在,跟踪支持由 Spring Micrometer 提供。它生成跟踪 ID 和跨度标识符。跟踪 ID 在分布式事务期间传播到所有参与服务。跨度 ID 也参与分布式事务。然而,跨度标识符的作用域属于其服务(即它填充的那个服务)。

如前所述,您将使用 Zipkin;因此,您将使用 Zipkin 的分布式跟踪仪表库 Brave。

您可以复制并增强来自第十一章的代码,即gRPC API 开发和测试,该章节位于github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter11,以实现日志记录和跟踪,或者参考该章节的代码在github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter12中的变更。

首先,您将在以下子节中对 gRPC 服务器代码进行修改。

修改 gRPC 服务器代码

要启用跟踪并将日志发布到 ELK 堆栈,您需要按照以下步骤进行以下代码更改:

  1. 将以下依赖项添加到 build.gradle 文件中:

    implementation 'net.logstash.logback:    logstash-logback-encoder:7.3'implementation 'io.micrometer:    micrometer-tracing-bridge-brave'implementation 'org.springframework.boot:    spring-boot-starter-actuator'
    

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter12/server/build.gradle

在这里,您正在添加以下四个依赖项:

  • spring-logback.xml 文件。

  • micrometer-tracing-bridge-brave:这个依赖项负责管理跟踪和跨度 ID。Micrometer Tracing Bridge Brave 是一个抽象库,用于 Zipkin Brave,它使用 Brave 将收集到的跟踪信息发布到 Zipkin。

  • spring-boot-starter-actuator:您在第九章Web 服务部署中使用了这个库来提供健康端点。它还提供了度量端点。除此之外,它还执行了度量跟踪的自动配置。

  1. 接下来,添加/修改 spring-logback.xml 文件,内容如下:

    <?xml version="1.0" encoding="UTF-8"?><configuration>  <springProperty scope="context"     name="applicationName" source="spring.application         .name"/>  <springProperty scope="context"     name="logstashDestination"     source="logstash.destination" />  <property name="LOG_PATTERN"    value="%d{yyyy-MM-dd HH:mm:ss.SSS}    %5p [${applicationName},%X{traceId:-},%X{spanId:-}]    ${PID:-} --- [%15.15t] %-40.40logger{39} :       %msg%n"/>  <property name="LOG_FILE" value="${chapter12-grpc-   server.service.logging.file:-chapter12-grpc-server-   logs}"/>  <property name="LOG_DIR" value="${chapter12-grpc-   server.service.logging.path:-chapter12-grpc-server-   logs}"/>  <property name="SERVICE_ENV" value="${service.env:-   dev}"/>  <property name="LOG_BASE_PATH"   value="${LOG_DIR}/${SERVICE_ENV}"/>  <property name="MAX_FILE_SIZE" value="${chapter12.service.   logging.rolling.maxFileSize:-  100MB}"/><!-- other configuration has been removed for brevity -->
    

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter12/server/src/main/resources/logback-spring.xml

在这里,您已经定义了属性。其中两个属性的值取自 Spring 配置文件(application.propertiesapplication.yaml)。

现在让我们添加 Logstash 编码器,如下所示代码块:

<appender name="STASH"   class="net.logstash.logback.appender
       .LogstashTcpSocketAppender">
  <destination>${logstashDestination}</destination>
  <encoder
  class="net.logstash.logback.encoder.LogstashEncoder" />
</appender>
<!-- other configuration has been removed for brevity -->

在这里,STASH 追加器被定义并使用 TCP 套接字将日志推送到 Logstash。它包含一个 destination 元素,用于分配 Logstash 的 <HOST>:<TCP Port> 值。另一个元素编码器包含完全限定的类名,LogstashEncoder

最后,您将向根元素添加 STASH 追加器,如下所示:

<!-- other configuration has been removed for brevity --><root level="INFO">
  <appender-ref ref="STDOUT"/>
  <appender-ref ref="STASH"/>
  <appender-ref ref="FILE"/>
</root>

根级别设置为 INFO,因为您只想简单地打印信息日志。

Logstash 配置测试

要禁用将日志发送到 Logstash 实例的 LOGSTASH (STASH) 追加器,请执行以下操作:

  1. 您可以将 logback-spring.xml 复制到 test/resources 目录。

  2. 将其重命名为 test/resources/logback-test.xml

  3. <ROOT> 中移除 LOGSTASH (STASH) 追加器和其条目。

  4. 接下来,让我们将在此 logback-spring.xml 文件中使用的 Spring 属性添加到 application.properties 中,如下所示代码块:

    spring.application.name=grpc-serverspring.main.web-application-type=nonegrpc.port=8080logstash.destination=localhost:5002management.tracing.sampling.probability=1.0
    

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter12/server/src/main/resources/application.properties

在这里,Logstash 目标主机设置为 localhost。如果您在远程机器上运行,请相应地更改主机。Logstash TCP 端口设置为与 Docker Composer 文件中设置的 Logstash 外部端口相同。

跟踪的默认概率采样仅为实际请求的 10%。因此,我们将 management.tracing.sampling.probability 属性设置为 1.0。现在,它收集 100% 请求的跟踪信息。因此,每个请求都将被跟踪。

  1. 所需的依赖项和配置现已设置。您可以将跟踪服务器拦截器添加到 gRPC 服务器。(注意:如果您使用的是 RESTful Web 服务,则不需要跟踪拦截器,因为 Spring 的自动配置机制会处理这一点。)

首先,让我们在一个配置文件中定义一个新的 bean,如下所示:

@Configurationpublic class Config {
  @Bean
  public ObservationGrpcServerInterceptor
         interceptor(ObservationRegistry registry) {
    return new
           ObservationGrpcServerInterceptor(registry);
  }
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/dev/Chapter12/server/src/main/java/com/packt/modern/api/server/Config.java

在这里,您正在创建一个 ObservationGrpcServerInterceptor bean,这是创建跟踪服务器拦截器所必需的。在 Spring Boot 3 之前,RpcTracing bean 由 Spring Sleuth 提供。现在,由于 Spring Boot 3 支持 Spring Micrometer 而不是 Sleuth,因此 RpcTracing bean 的自动配置不可用。您将在 gRPC 服务器中添加此 bean 作为拦截器。

  1. 让我们修改 gRPC 服务器 Java 文件(GrpcServer.java),向 gRPC 服务器添加跟踪服务器拦截器,如下代码块所示:

    @Componentpublic class GrpcServer {  // code truncated for brevity  private final ObservationGrpcServerInterceptor         oInterceptor;  public GrpcServer(SourceService sourceService,      ChargeService chargeService,      ExceptionInterceptor      exceptionInterceptor,      ObservationGrpcServerInterceptor oInterceptor) {    this.sourceService = sourceService;    this.chargeService = chargeService;    this.exceptionInterceptor = exceptionInterceptor;    this.oInterceptor = oInterceptor;  }  public void start()throws IOException,      InterruptedException {    server = ServerBuilder.forPort(port)        .addService(sourceService)        .addService(chargeService)        .intercept(exceptionInterceptor)        .intercept(oInterceptor)        .build().start();  // code truncated for brevity
    

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter12/server/src/main/java/com/packt/modern/api/server/GrpcServer.java

在这里,您可以看到在上一步骤中配置文件中创建的 bean 是通过构造函数注入的。之后,oInterceptor bean 已被用于创建 gRPC 服务器拦截器。

要启用将日志发布到 ELK 堆栈和跟踪,所需的更改是对 gRPC 服务器的修改。您可以重新构建 gRPC 服务器并运行其 JAR 文件以查看更改效果。以下命令行输出仅供参考:

// Commands from Chapter12/server directory$ ./gradlew clean build
// You may want to up docker-compose before running the server
// to avoid Logstash connect errors.
$ java -jar build/libs/chapter12-server-0.0.1-SNAPSHOT.jar
// Logs truncated for brevity
2023-04-23 21:30:42.120      INFO [grpc-server,,]     49296 --- [           main] com.packt.modern.api.server.GrpcServer   : gRPC server is starting on port: 8080.

您可以看到日志遵循 logback-spring.xml 中配置的模式。在 INFO 之后打印的日志块包含应用程序/服务名称,以及跟踪和跨度 ID。高亮显示的行显示一个 空白 的跟踪 ID 和跨度 ID,因为没有进行涉及分布式事务的外部调用。只有在调用分布式事务(服务间通信)时,跟踪和跨度 ID 才会添加到日志中。

同样,您可以在 gRPC 客户端中添加日志和跟踪实现。

修改 gRPC 客户端代码

要启用跟踪并将日志发布到 ELK 堆栈,您还需要在 gRPC 客户端进行代码更改,这些更改与 gRPC 服务器代码中实现的更改非常相似。有关更多信息,请参考以下步骤:

  1. 将以下依赖项添加到 build.gradle 文件中:

    implementation 'net.logstash.logback:    logstash-logback-encoder:7.3'implementation 'io.micrometer:micrometer-tracing-bridge-brave'implementation 'org.springframework.boot:spring-boot-starter-actuator'
    

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter12/client/build.gradle

这些是与您添加到 gRPC 服务器的相同依赖项。

  1. 接下来,您可以像添加到 gRPC 服务器代码中一样添加 logback-spring.xml 文件以配置日志。请确保在 XML 文件中使用 chapter12-grpc-client 替换 chapter12-grpc-server

  2. 接下来,让我们将以下 Spring 属性添加到 application.properties 中。其中一些属性也在 logback-spring.xml 文件中引用:

    spring.application.name=grpc-clientserver.port=8081grpc.server.host=localhostgrpc.server.port=8080logstash.destination=localhost:5002management.tracing.sampling.probability=1.0
    

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter12/client/src/main/resources/application.properties

  1. 所需的依赖项和配置现已设置。现在,你可以向 gRPC 客户端添加跟踪。由 Micrometer 库提供的ObservationGrpcClientInterceptor为 gRPC 客户端提供了拦截器。

注意

如果你使用的是 RESTful Web 服务,则不需要额外的跟踪更改;Spring 自动配置会处理这一点。

首先,让我们在一个配置文件中定义一个新的 bean,ObservationGrpcClientInterceptor,如下所示:

@Configurationpublic class Config {
 @Bean
 public ObservationGrpcClientInterceptor interceptor
      (ObservationRegistry registry) {
   return new ObservationGrpcClientInterceptor
      (registry);
 }
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter12/client/src/main/java/com/packt/modern/api/Config.java

  1. 现在,你可以修改 gRPC 客户端 Java 文件,向 gRPC 客户端添加ObservationGrpcClientInterceptor拦截器:

    @Componentpublic class GrpcClient {  @Autowired  private ObservationGrpcClientInterceptor      observationGrpcClientInterceptor;  // code truncated for brevity  public void start() {   channel = ManagedChannelBuilder.forAddress       (host, port)             .intercept(observationGrpcClientInterceptor)    .usePlaintext().build();   sourceServiceStub = SourceServiceGrpc       .newBlockingStub(channel);   chargeServiceStub = ChargeServiceGrpc       .newBlockingStub(channel);  }  // code truncated for brevity
    

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter12/client/src/main/java/com/packt/modern/api/client/GrpcClient.java

在这里,你可以看到在前面步骤中配置文件中创建的 bean 已自动装配。稍后,将observationGrpcClientInterceptor拦截器添加到客户端。

为了便于将日志发布到 ELK 堆栈和跟踪所做的更改也已应用于 gRPC 客户端。你现在可以重新构建 gRPC 客户端并运行其 JAR 文件以查看更改的效果。以下命令行输出仅供参考:

// Commands from Chapter12/client directory$ ./gradlew clean build
// You may want to up docker-compose before running the server
// to avoid Logstash connect errors.
$ java -jar build/libs/chapter12-client-0.0.1-SNAPSHOT.jar
// log truncated for brevity
2023-04-23 23:02:35.297      INFO [grpc-client,,]     51746 --- [           main] com.packt.modern.api.ClientApp           : Started ClientApp in 3.955 seconds (process running for 4.611)
2023-04-23 23:02:35.674      INFO [grpc-client,,]     51746 --- [           main] com.packt.modern.api.client.GrpcClient   : gRPC client connected to localhost:8080

你可以看到日志遵循在logback-spring.xml中配置的模式。在INFO之后打印的日志块包含应用程序/服务名称、跟踪 ID 和跨度 ID。跟踪和跨度 ID 为空,因为只有在调用分布式事务(服务间通信)时才会将它们添加到日志中。

启用 gRPC 服务器和客户端服务中的日志聚合和分布式跟踪所需的更改现已完成。

接下来,你将测试更改并在 Kibana 中查看日志。

测试日志和跟踪更改

在开始测试之前,请确保 ELK 堆栈正在运行。此外,请确保首先启动 gRPC 服务器,然后启动 gRPC 客户端服务。

你可以为你的服务添加适当的日志语句以生成详细日志。

在新的终端窗口中运行以下命令。这将调用 gRPC 客户端服务中的/charges REST 端点:

$ curl http://localhost:8081/charges

应该返回以下 JSON 输出:

{  "charge": [{
    "id": "aibn4f45m49bojd3u0p16erbi5lnelui",
    "amount": 1000,
    "amountCaptured": 0,
    "amountRefunded": 0,
    "balanceTransactionId": "",
    "calculatedStatementDescriptor": "",
    "receiptEmail": "receipt@email.com",
output truncated for brevity
    "refunded": false,
    "refunds": [],
    "statementDescriptor": "Statement Descriptor",
    "status": "SUCCEEDED",
    "sourceId": "inccsjg6gogsvi4rlprdbvvfq2ft2e6c"
  }]
}

之前的curl命令应该在 gRPC 客户端生成如下日志:

2023-04-23 23:10:37.882      INFO [grpc-client,64456d940c51e3e2baec07f 7448beee6,baec07f7448beee6]     51746 --- [nio-8081-exec-1] brave.Trac er                             : {"traceId":"64456d940c51e3e2baec07f 7448beee6","parentId":"baec07f7448beee6","id":"0645e686d86968b6","kind":"CLIENT","name":"com.packtpub.v1.ChargeService/RetrieveAll","timestamp":1682271636300866,"duration":1578184,"localEndpoint":{"serviceName":"unknown","ipv4":"192.168.1.2"},"tags":{"rpc.service":"com.packtpub.v1.ChargeService","rpc.method":"RetrieveAll"}}2023-04-23 23:10:37.886      INFO [grpc-client,64456d940c51e3e2baec07f 7448beee6,baec07f7448beee6]     51746 --- [nio-8081-exec-1] c.p.m.api.controller.ChargeController    : Server response received in Json Format: charge {
  id: "iivpc3i9el2dso9s2s2rqf9j3s2pomlm"
  amount: 1000
  created: 1682265641
  currency: "USD"
  customerId: "ab1ab2ab3ab4ab5"
  description: "Charge Description"
  receiptEmail: receipt@email.com
  statementDescriptor: "Statement Descriptor"
  sourceId: "6ufgh93stkjod1ih2vhkmamj9l1m0hvv"
}

在这里,高亮显示的块包括了应用名称(grpc-client)、跟踪 ID(64456d940c51e3e2baec07f7448beee6)和跨度 ID(baec07f7448beee6)。

命令还应在 gRPC 服务器生成以下日志:

2023-04-23 23:10:37.821      INFO [grpc-server), trace ID (64456d940c51e3e2baec07f7448beee6), and span ID (182159d509ce0714). The trace ID is the same as what is displayed in the gRPC client logs. The span IDs are different from the gRPC client service because span IDs belong to their respective individual services. This is how the trace/correlational ID helps you trace the call for requests across different services because it will be propagated to all the services it involves.
Tracing this request was simple as the logs contain just a few lines and are scattered across only two services. What if you have a few gigabytes of logs scattered across various services? Then, you can make use of the ELK stack to search the log index using different query criteria. However, we are going to use the trace ID for this purpose.
First, open the Kibana home page in the browser ([`localhost:5600/`](http://localhost:5600/)). Then, click on the hamburger menu in the top-left corner, as shown in the following screenshot. After that, click on the **Discover** option in the menu that appears:
![Figure 12.3 – Kibana hamburger menu](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/mdn-api-dev-spr6-spbt3/img/Figure_12.03_B19349.jpg)

Figure 12.3 – Kibana hamburger menu
This should open the **Discover** page, as shown in the following screenshot. If this your first time opening the page, you must create an index pattern that will filter out the indexes available in Elasticsearch:
![Figure 12.4 – Kibana’s Discover page](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/mdn-api-dev-spr6-spbt3/img/Figure_12.04_B19349.jpg)

Figure 12.4 – Kibana’s Discover page
Next, click on the `modern-api`) given in the Logstash configuration in the ELK stack’s Docker Compose file:
![Figure 12.5 – Kibana’s Create data view page](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/mdn-api-dev-spr6-spbt3/img/Figure_12.05_B19349.jpg)

Figure 12.5 – Kibana’s Create data view page
Here, you can also provide a name for the data view and an index pattern. You can keep the default value of `@timestamp` for **Timestamp field**.
Then, click on the **Save data view to Kibana** button. This action will create the data view and may show the following view (if you have called the client’s REST APIs recently; otherwise, it will show no data view):
![Figure 12.6 – Kibana’s saved data view page](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/mdn-api-dev-spr6-spbt3/img/Figure_12.06_B19349.jpg)

Figure 12.6 – Kibana’s saved data view page
You can add the filter query to the **Search** textbox and the **Date/Duration** menu at the top right of the **Discover** page.
Query criteria can be input using the **Kibana Query Language** (**KQL**), which allows you to add different comparator and logical operators. For more information, refer to [`www.elastic.co/guide/en/kibana/master/kuery-query.html`](https://www.elastic.co/guide/en/kibana/master/kuery-query.html).
![Figure 12.7 – Kibana’s Discover page – filtering](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/mdn-api-dev-spr6-spbt3/img/Figure_12.07_B19349.jpg)

Figure 12.7 – Kibana’s Discover page – filtering
As shown in the preceding figure, we have entered criteria (`traceId: 64457832c37c7ac9f957869d819bc0c6`) (*1*) and kept the **Duration** field as its default (the last 15 minutes) (*2*). The left-hand side also shows you how to select the Elasticsearch index and all the fields available in it (*3*).
Once you press the *Enter* key or click on the refresh button after entering the criteria, the search displays the available logs from all the services. The searched values are highlighted in yellow (*4*).
You can also observe that the searched trace ID shows logs from both server and client services (*5*).
The searched **Discovery** page also shows the graph that shows the number of calls made during a particular period. You can generate more logs and reveal any errors, and then you can use different criteria to filter the results and explore further.
You can also save the searches and perform more operations, such as customizing the dashboard. Please refer to [`www.elastic.co/guide/en/kibana/master/index.html`](https://www.elastic.co/guide/en/kibana/master/index.html) for more information.
The ELK stack is good for log aggregation, filtering, and debugging using the trace ID and other fields. However, it can’t check the performance of API calls – the time taken by the call. This is especially important when you have a microservice-based application.
This is where **Zipkin** (also known as **OpenZipkin**), along with Micrometer, comes in.
Distributed tracing with Zipkin and Micrometer
**Spring Micrometer** is a utility library that collects the metrics generated by the Spring Boot application. It provides vendor-neutral APIs that allow you to export the collected metrics to different systems, such as ELK. It collects different types of metrics. A few of them are the following:

*   Metrics related to the JVM, CPU, and cache
*   Latencies in Spring MVC, WebFlux, and the REST client
*   Metrics related to Datasource and HikariCP
*   Uptime and Tomcat usage
*   Events logged to Logback

Zipkin, along with Micrometer, helps you not only to trace transactions across multiple service invocations but also to capture the response time taken by each service involved in the distributed transaction. Zipkin also shows this information using nice graphs. It helps you to locate the performance bottlenecks and drill down into the specific API call that creates the latency issue. You can find out the total time taken by the main API call as well as its internal API call time.
Services developed with Spring Boot facilitate their integration with Zipkin. You just need to make two code changes – the addition of the `zipkin-reporter-brave` dependency and the addition of the Zipkin endpoint property.
You can make these two changes to both the gRPC server and client, as shown next:

1.  First, add the highlighted dependency to `build.gradle` (both the gRPC server and client projects):

    ```

    implementation 'net.logstash.logback:logstash-logback-encoder:7.3'

    ```java
    management.zipkin.tracing.endpoint=                      http://localhost:9411/api/v2/spansmanagement.tracing.sampling.probability=1.0
    ```

    ```java

The Zipkin `tracing.endpoint` property points to the Zipkin API endpoint.
You are done with the changes required in the code for publishing the tracing information to Zipkin. Rebuild both the server and client services after making these changes.
Now, let’s install and start Zipkin.
There are various ways to install and run Zipkin. Please refer to [`zipkin.io/pages/quickstart`](https://zipkin.io/pages/quickstart) to find out about these options. You can add it in `docker-compose` too. However, for development purposes, we are going to fetch the latest release as a self-contained executable JAR from [`search.maven.org/remote_content?g=io.zipkin&a=zipkin-server&v=LATEST&c=exec`](https://search.maven.org/remote_content?g=io.zipkin&a=zipkin-server&v=LATEST&c=exec) and then start it using the following command (make sure to change the version in the `JAR` file based on the downloaded file):

$ java -jar zipkin-server-2.24.0-exec.jar


 The previous command, when executed, will start Zipkin with an in-memory database. For production purposes, it is recommended to use a persistence store, such as Elasticsearch.
It should start with the default port `9411` at `http://127.0.0.1:9411/` if executed on `localhost`.
Once the Zipkin server and the ELK stack are up and running, you can start both the gRPC server and client services and execute the following command:

$ curl http://localhost:8081/charges


 This command should print a log like the following log statement in the gRPC client service:

2023-04-24 11:35:10.313      INFO [grpc-client,64461c16391707ee95478f957f3ccb1d,95478f957f3ccb1d]     62484 --- [nio-8081-exec-2] c.p.m.api.controller.ChargeController    : 客户端 ID : ab1ab2ab3ab4ab5


 Keep the trace ID (`64461c16391707ee95478f957f3ccb1d` in the previous output) handy as you are going to use it in the Zipkin UI. Open the Zipkin home page by accessing `http://localhost:9411`. It will look as follows:
![Figure 12.8 – Zipkin home page](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/mdn-api-dev-spr6-spbt3/img/Figure_12.08_B19349.jpg)

Figure 12.8 – Zipkin home page
You can observe that Zipkin also allows you to run queries. However, we’ll make use of the trace ID. Paste the copied trace ID in the **Search by trace ID** textbox in the top-right corner (highlighted in green) and then press *Enter*:
![Figure 12.9 – Zipkin search result page](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/mdn-api-dev-spr6-spbt3/img/Figure_12.09_B19349.jpg)

Figure 12.9 – Zipkin search result page
In the preceding figure, the Zipkin trace ID shows complete API call information at the top (*1* and *2*) if the trace ID is available. In the left-hand side section, it shows all the corresponding API calls with a hierarchy, which shows the individual call times in a graphical way (*3*). These API call rows are selectable. Selected call details are displayed on the right-hand side (*4*).
The `grpc-server` call:
![Figure 12.10 – Annotation details](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/mdn-api-dev-spr6-spbt3/img/Figure_12.10_B19349.jpg)

Figure 12.10 – Annotation details
Time tracking at a granular level for each distributed API call allows you to identify the latency issues and relative time tracking for performance tuning.
Summary
In this chapter, you learned how the trace/correlation ID is important and how it can be set up using Micrometer with Brave. You can use these generated IDs to find the relevant logs and API call durations. You integrated the Spring Boot services with the ELK stack and Zipkin.
You also implemented extra code and configurations, which are required for enabling distributed tracing for gRPC-based services.
You acquired log aggregation and distributed tracing skills using Micrometer, Brave, the ELK stack, and Zipkin.
In the next chapter, you are going to learn about the fundamentals of GraphQL APIs.
Questions

1.  What is the difference between the trace ID and span ID?
2.  Should you use a broker between services that generate the logs and the ELK stack? If yes, why?
3.  How does Zipkin work?

Answers

1.  Trace IDs and span IDs are created when the distributed transaction is initiated. A trace ID is generated for the main API call by the receiving service using Spring Cloud Sleuth. A trace ID is generated only once for each distributed call. Span IDs are generated by all the services participating in the distributed transaction. A trace ID is a correlation ID that will be common across the service for a call that requires a distributed transaction. Each service will have its own span ID for each of the API calls.
2.  Yes, a broker such as Kafka, RabbitMQ, or Redis allows robust persistence of logs and removes the risk of losing log data in unavoidable circumstances. It also performs better and can handle sudden spikes of data.
3.  A tracer such as Micrometer with Brave or Spring Cloud Sleuth (*which performs instrumentation*) does two jobs – records the time and metadata of the call being performed, and propagates the trace IDs to other services participating in the distributed transaction. Then, it pushes the tracing information to Zipkin using a *reporter* once the scan completes. The reporter uses *transport* such as HTTP and Kafka to publish the data in Zipkin.

The *collector* in Zipkin collects the data sent by the transporters from the running services and passes it to the storage layer. The storage persists the data. Persisted data is exposed by the Zipkin APIs. The Zipkin UI calls these APIs to show the information graphically:
![](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/mdn-api-dev-spr6-spbt3/img/Figure_12.11_B19349.jpg)

Further reading

*   Elasticsearch documentation: [`www.elastic.co/guide/en/elasticsearch/reference/current/index.html`](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html)
*   Kibana documentation: [`www.elastic.co/guide/en/kibana/master/index.html`](https://www.elastic.co/guide/en/kibana/master/index.html)
*   Kibana Query Language: [`www.elastic.co/guide/en/kibana/master/kuery-query.html`](https://www.elastic.co/guide/en/kibana/master/kuery-query.html)
*   Logstash documentation: [`www.elastic.co/guide/en/logstash/master/index.html`](https://www.elastic.co/guide/en/logstash/master/index.html)
*   *Elasticsearch 8.x Cookbook – Fifth* *Edition*: [`www.packtpub.com/product/elasticsearch-8x-cookbook-fifth-edition/9781801079815`](https://www.packtpub.com/product/elasticsearch-8x-cookbook-fifth-edition/9781801079815)
*   Zipkin documentation: [`zipkin.io/pages/quickstart`](https://zipkin.io/pages/quickstart)

第四部分 – GraphQL

在这部分,你将学习基于 GraphQL 的 API 开发。完成本节后,你将了解 GraphQL 的深入基础知识,能够区分 REST、响应式和 gRPC API,在 GraphQL 的背景下进行深入理解,并了解何时使用哪种 API 风格。你还将学习如何设计 GraphQL 模式,该模式将用于生成 Java 代码。最后,你将学习如何编写数据检索器和加载器,以解析查询字段以服务 GraphQL API 请求。

这一部分包含以下章节:

  • 第十三章开始使用 GraphQL

  • 第十四章GraphQL API 开发和测试

第十二章:开始使用 GraphQL

在本章中,你将了解 GraphQL 的基础知识,包括其 模式定义语言SDL)、查询、突变和订阅。GraphQL API 在基于手持设备的应用程序中很受欢迎,如移动应用,因为它在获取数据方面既快又高效,在某些情况下优于 REST。因此,了解 GraphQL 非常重要。你将在本章的 比较 GraphQL 与 REST 部分了解更多关于其与 REST 的比较。完成本章后,你将了解 GraphQL 的基础知识,包括其语义、模式设计以及使用 Spring 和 Spring Boot 开发基于 GraphQL 的 API 所需的一切。

本章将涵盖以下主题:

  • 了解 GraphQL

  • 学习 GraphQL 的基础知识

  • 设计 GraphQL 模式

  • 测试 GraphQL 查询和突变

  • 解决 N+1 问题

技术要求

本章涵盖了 GraphQL 及相关概念的理论基础。建议首先阅读本章,以便开发并测试下一章中展示的基于 GraphQL 的服务代码。

了解 GraphQL

你可能听说过或了解 GraphQL,它在过去几年中在 API 领域变得更加流行,并成为实现手持设备和 Web API 的首选方式。

GraphQL 是一种声明式查询和操作语言,也是 API 的服务器端运行时。GraphQL 使客户端能够查询他们确切想要的数据——不多也不少。

我们将在下一小节中讨论其简要历史。

GraphQL 的简要历史

2011 年,Facebook 在提高其网站在移动浏览器上的性能方面面临挑战。他们开始使用移动原生技术构建自己的移动应用。然而,由于层次结构和递归数据,API 并未达到预期。他们希望优化他们的网络调用。请注意,在那些日子里,世界某些地区的移动网络速度仅为 Kb/s。拥有快速、高质量的移动应用将成为他们成功的关键,因为他们的消费者已经开始转向移动设备。

2012 年,Facebook 上的一些工程师——Lee Byron、Dan Schafer 和 Nick Schrock——联合起来创建 GraphQL。最初,它被用来设计和开发 Facebook 的新闻源功能,但后来,它被用于其整个基础设施中,仅在 Facebook 内部使用,直到 2015 年开源,当时 GraphQL 规范及其 JavaScript 实现对公众开放。很快,GraphQL 规范的其他语言实现也开始推出,包括 Java。

我想你会喜欢观看这个关于 GraphQL 的纪录片,www.youtube.com/watch?v=783ccP__No8,它讲述了 GraphQL 从内部 Facebook 工具到目前成功的历程。

你知道吗?

Netflix 和 Coursera 也在研究一个类似的想法来构建高效且性能良好的 API。Coursera 没有继续推进,但 Netflix 开源了 Falcor。

比较 GraphQL 与 REST

你在本书的第一部分:RESTful Web 服务中使用了 REST API 来开发 API。实际上,一个示例电子商务 UI 应用也在本书的第一部分中消耗了 REST API 来实现其电子商务功能。我们将继续在本章中引用 REST,以便我们可以在适用的情况下理解必要的 GraphQL 概念。这种相关性应该有助于你轻松掌握 GraphQL 概念。

GraphQL 比 REST 更强大、更灵活、更高效。让我们了解原因。

让我们考虑一个例子,其中用户登录到电子商务 UI 应用并自动导航到产品列表页面。当这种情况发生时,UI 应用会消耗三个不同的端点,如下所示:

  • 用户端点,用于获取用户信息

  • 产品端点,用于获取产品列表

  • 购物车端点,用于从用户的购物车中获取购物车项目

因此,基本上,你必须在 REST 中进行三次调用,以从后端获取固定结构(你无法更改响应中发送的字段)所需的信息。

另一方面,GraphQL 可以在一个调用中获取用户信息、用户的购物车数据和产品列表。因此,它将网络调用次数从三个减少到一次。与 REST 不同,REST 必须为每个用例定义一个端点。你可能会说可以编写一个新的 REST 端点来解决这个问题。是的,这可能解决了这个特定的用例,但它不够灵活;它不会允许快速迭代。

此外,GraphQL 允许你在请求中描述你想要从后端获取的字段。服务器响应只包含作为请求一部分的字段,没有更多,也没有更少。

例如,你可能想添加用户评论到产品中。为此,你只需将评论字段添加到 GraphQL 查询中。同样,你不需要消耗额外的字段。你只需将需要的字段添加到 GraphQL 查询中。另一方面,REST 的响应包含预定义的字段,无论你是否需要在响应对象中获取某些字段。然后,你必须在客户端过滤所需的字段。因此,我们可以这样说,GraphQL 通过避免过度/不足获取问题,更有效地使用网络带宽。

GraphQL API 不需要像 REST 那样进行不断的变化,在 REST 中,你可能需要更改 API 或添加新的 API 以满足需求变化。这提高了开发速度和迭代。你可以轻松地添加新字段或标记那些已经被弃用的(不再被客户端使用的字段)。因此,你可以在客户端进行更改,而不会影响后端。简而言之,你可以编写不断发展的 API,而不需要任何版本控制和破坏性更改。

REST 使用内置的 HTTP 规范提供缓存。然而,GraphQL 不遵循 HTTP 规范;相反,它使用 Apollo/Relay 等库进行缓存。然而,REST 基于 HTTP,并且没有遵循任何实现规范,这可能导致不一致的实现,正如我们在比较 REST 与 gRPC 时讨论的那样。您可以使用 HTTP GET方法删除资源。

在移动客户端的使用方面,GraphQL API 优于 REST API。GraphQL API 的功能也使用强类型定义。这些类型是包含 API 定义的模式的一部分。这些类型使用SDL在模式中编写。

GraphQL 充当服务器和客户端之间的合约。您可以将 GraphQL 模式与 gRPC 接口定义语言IDL)文件和 OpenAPI 规范文件相关联。

我们将在下一节讨论 GraphQL 的基础知识。

学习 GraphQL 的基础知识

GraphQL API 包含三个重要的根类型查询突变订阅。这些都是在 GraphQL 模式中使用特殊 SDL 语法定义的。

GraphQL 提供了一个单一端点,根据请求返回 JSON 响应,该请求可以是查询、突变或订阅。

首先,让我们了解查询。

探索查询类型

Query类型用于读取操作,从服务器获取信息。单个Query类型可以包含多个查询。让我们使用 SDL 编写一个查询,以检索已登录用户,如下面的 GraphQL 模式所示:

type Query {  me: LogginInUser
  # You can add other queries here
}
type LoggedInUser {
  id: ID
  accessToken: String
  refreshToken: String
  username: String
}

在这里,您做了两件事:

  • 您已定义了 GraphQL 接口的查询根,其中包含您可以运行的查询。它只包含一个查询类型,me,它返回LoggedInUser类型的实例。

  • 您已指定用户定义的LoggedInUser对象类型,其中包含四个字段。这些字段后面跟着它们的类型。在前面的代码中,您使用了 GraphQL 的内置标量类型,称为IDString,来定义字段的类型。我们将在本章后面详细讨论这些类型时讨论这些类型。

一旦您在服务器上实现了此模式,并执行了以下 GraphQL 查询,您将只获得请求的字段及其值,作为 JSON 对象响应。

您可以在以下代码块中找到me查询及其 JSON 响应:

# Request input{
  me {
    id
    username
  }
}
#JSON response
{
  "data": {
    "me": {
      "id": "asdf90asdkqwe09kl",
      "username": "scott"
    }
  }
}

有趣的是,在这里 GraphQL 的请求输入不以query开头,因为Query是负载的默认值。这被称为Mutation。但是,如果您愿意,您也可以在查询请求输入前加上query前缀,如下所示:

query {  me {
    id
    username
  }
}

正如你所见,这允许你只查询你需要的字段。在这里,只从LoggedInUser类型请求了idusername字段,服务器只响应了这两个字段。请求负载被括号{}包围。你可以在模式中使用#进行注释。

现在,你知道如何在 GraphQL 模式中定义Queryobject类型。你还学习了如何根据查询类型和预期的 JSON 响应形成 GraphQL 请求负载。

我们将在下一小节中学习 GraphQL 突变。

探索 Mutation 类型

Mutation类型用于 GraphQL 请求中在服务器上执行的所有添加、更新和删除操作。一个Mutation类型可以包含多个突变。让我们定义一个addItemInCart突变,该突变将新项目添加到购物车中:

type Mutation {  addItemInCart(productId: ID, qty: Int): [Item]
  # You can add other mutations here
}
type Item {
  id: ID!
  productId: ID
  qty: Int
}

在这里,你已经定义了Mutation类型和一个名为Item的新对象类型。添加了一个名为addItemInCart的突变,并且QueryMutationSubscription类型都可以传递参数。为了定义必要的参数,你可以用()括号包围命名参数;参数之间用逗号分隔。addItemInCart的签名包含两个参数,并返回一个购物车项目列表。列表使用[]括号标记。

可选和必需参数

假设你声明了一个具有默认值的参数,如下面的突变所示:

pay(amount: Float, currency: String = "USD"): Payment

在这里,currency是一个可选参数。它包含默认值,而amount是一个必需字段,因为它不包含任何默认值。

请注意,Int是 GraphQL 中用于有符号 32 位整数的内置标量类型。默认值在 GraphQL 中为 null。如果你想强制任何字段为非 null 值,那么其类型应该用感叹号(!)标记。一旦它(!)被应用于模式中的任何字段,当客户端将其放在请求负载中时,GraphQL 服务器将始终提供值而不是 null。你还可以用感叹号声明一个列表;例如,items: [Item]!items: [Item!]!。这两种声明都将提供一个包含零个或多个项目的列表。然而,后者将只提供一个有效的Item对象(即非 null 值)。

一旦你在服务器上实现了这个模式实现,你就可以使用以下 GraphQL 查询。你将只得到你请求的字段及其值,作为一个 JSON 对象:

# Request inputmutation {
  addItemInCart(productId: "qwer90asdkqwe09kl", qty: 2) {
    id
    productId
  }
}

你可以看到,这次,GraphQL 请求输入以mutation关键字开头。如果你不以mutation关键字开始突变,那么你可能会得到一个错误消息,例如字段‘addItemInCart’在类型‘Query’上不存在。这是因为服务器将请求负载视为查询。

在这里,您必须向 addItemInCart 突变添加所需的参数,然后添加您想要检索的域(idproductId)。一旦请求成功处理,您将获得以下类似的 JSON 输出:

#JSON response{
  "data": {
    addItemInCart: [
      {
        "id": "zxcv90asdkqwe09kl",
        "productId": "qwer90asdkqwe09kl"
      }
    ]
  }
}

在这里,id 字段的值由服务器生成。同样,您可以在模式中编写其他突变,如删除和更新。然后,您可以使用 GraphQL 请求中的有效负载相应地处理突变。

我们将在下一小节中探讨 GraphQL Subscription 类型。

探索订阅类型

如果您只熟悉 REST,那么订阅的概念可能对您来说是新的。在没有 GraphQL 的情况下,您可能会使用轮询或 WebSocket 来实现类似的功能。有许多需要订阅功能的使用场景,包括以下内容:

  • 实时比分更新或选举结果

  • 批量处理更新

有许多需要立即更新事件的此类情况。GraphQL 为此用例提供了订阅功能。在这种情况下,客户端通过启动并保持稳定的连接来订阅事件。当订阅的事件发生时,服务器将结果事件数据推送到客户端。例如,假设您想了解电子商务应用中任何商品库存的任何变化。任何商品数量的变化都会触发事件,并且订阅将收到包含更新数量的响应。

此数据通过已启动的连接以流的形式发送,而不是通过请求/响应类型的通信(这在查询/突变的情况下使用过)。

推荐方法

建议仅在大型对象(如批量处理)发生少量更新时使用订阅,或者有低延迟的实时更新(如实时比分更新)时使用订阅。否则,您应该使用轮询(在指定间隔定期执行查询)。

让我们在模式中创建一个订阅,如下所示:

type Subscription {  orderShipped(customerID: ID!): Order
  # You can add other subscriptions here
}
# Order type contains order information and another object
# Shipping. Shipping contains id and estDeliveryDate and
# carrier fields
type Order {
  # other fields omitted for brevity
  shipping: Shipping
}
type Shipping {
  Id: ID!
  estDeliveryDate: String
  carrier: String
}

在这里,我们定义了一个接受 customer ID 作为参数的 orderShipped 订阅,并返回 Order。客户端订阅此事件,然后每当给定 customerId 的订单发货时,服务器将使用流将请求的订单详细信息推送到客户端。

您可以使用以下 GraphQL 请求来订阅 GraphQL 订阅:

# Request Inputsubscription {
  orderShipped(customerID: "customer90asdkqwe09kl") {
    shipping {
      estDeliveryDate
      trackingId
    }
  }
}
# JSON Output
{
  "data": {
    "orderShipped": {
      "estDeliveryDate": "13-Aug-2022",
      "trackingId": "tracking90asdkqwe09kl"
    }
  }
}

客户端将在任何属于给定客户的订单发货时请求 JSON 响应。服务器将这些更新推送到所有订阅此 GraphQL 订阅的客户端。

在本节中,您学习了如何在 GraphQL 模式中声明 QueryMutationSubscription 类型。

您在模式中定义了标量类型和用户定义的对象类型。您还探讨了如何为查询、突变或订阅编写 GraphQL 请求输入。

现在,您已经知道如何在根类型中定义操作参数,并在发送 GraphQL 请求时传递参数。请注意,模式中的非空字段可以用感叹号(!)标记。对于数组或对象列表,您必须使用方括号([])。

在下一节中,我们将深入探讨 GraphQL 模式。

设计 GraphQL 模式

模式是一个使用 DSL 语法编写的 GraphQL 文件。主要包含根类型(查询、突变和订阅),以及根类型中使用的相应类型,如对象类型、标量类型、接口、联合类型、输入类型和片段。

首先,让我们讨论这些类型。在前一节中,您已经学习了根类型(查询、突变和订阅)和对象类型。现在让我们更深入地了解标量类型。

理解标量类型

标量类型用于解析具体数据。标量类型共有三种——内置标量类型、自定义标量类型和枚举类型。我们先来讨论内置标量类型。GraphQL 提供了以下五种内置标量类型:

  • Int:这种类型存储整数,由一个有符号的 32 位整数表示。

  • Float:这种类型存储一个有符号的双精度浮点值。

  • String:这种类型存储一个 UTF-8 字符序列。

  • Boolean:这种类型存储布尔值——真或假。

  • ID:用于定义对象标识符字符串。这只能序列化为字符串,且不可读。

您还可以定义自己的标量类型,这些类型被称为自定义标量类型。例如,Date类型可以定义如下:

 scalar Date

您需要编写一个实现,以确定这些自定义标量类型的序列化、反序列化和验证。例如,日期可以被视为 Unix 时间戳,或者在一个自定义的Date类型中以特定日期格式的字符串。

另一个特殊的标量类型是枚举类型(enum),用于定义一组允许的值。让我们定义一个订单状态枚举,如下所示:

enum OrderStatus {  CREATED
  CONFIRMED
  SHIPPED
  DELIVERED
  CANCELLED
}

在这里,OrderStatus枚举类型表示在特定时间点的订单状态。

在探索其他类型之前,我们将先检查以下子节中的 GraphQL 片段。

理解片段

在客户端查询时,您可能会遇到冲突的场景。您可能有两个或多个查询返回相同的结果(相同的对象或字段集)。为了避免这种冲突,您可以给查询结果起一个名字。这个名字被称为别名

让我们在以下查询中使用一个别名:

query HomeAndBillingAddress {  home: getAddress(type: "home") {
    number
    residency
    street
    city
    pincode
  }
  billing: getAddress(type: "home") {
    number
    residency
    street
    city
    pincode
  }
}

在这里,HomeAndBillingAddress是一个包含getAddress查询操作的命名查询。getAddress被使用了两次,这导致它返回相同的字段集。因此,使用homebilling别名来区分结果对象。

getAddress 查询可能会返回 Address 对象。Address 对象可能有额外的字段,如 typestatecountrycontactNo。因此,当你有可能会使用相同字段集的查询时,你可以创建一个 片段 并在查询中使用它。片段逻辑上从 GraphQL 模式中的现有对象创建字段子集,可以在多个地方重复使用,如下面的代码片段所示。

让我们创建一个片段并替换前面代码块中的公共字段:

 query HomeAndBillingAddress {   home: getAddress(type: "home") {
     ...addressFragment
   }
   billing: getAddress(type: "home") {
     ...addressFragment
   }
 }
 fragment addressFragment on Address {
   number
   residency
   street
   city
   pincode
 }

在这里,addressFragment 片段已经被创建并用于查询中。

你也可以创建一个 contains the nested object and you just want a few fields of the nested object rather than all the object fields. Inline fragments can be used when a querying field returns an **Interface** or Union type. We will explore inline fragments in more detail later in the Understanding interfaces subsection under the Designing a GraphQL schema section.

我们将在下一小节中查看 GraphQL 接口。

理解接口

GraphQL 接口是抽象的。你可能有几个字段在多个对象中是通用的。你可以为这样的字段集创建一个 interface 类型。例如,产品可能有一些共同属性,如 ID、名称和描述。产品还可以根据其类型具有其他属性。例如,一本书可能有几页、作者和出版社,而书架可能有材料、宽度、高度和深度属性。

让我们使用接口关键字定义这三个对象(ProductBookBookcase):

interface Product {   id: ID!
   name: String!
   description: string
 }
 type Book implements Product {
   id: ID!
   name: String!
   description: string
   author: String!
   publisher: String
   noOfPages: Int
 }
 type Bookcase implements Product {
   id: ID!
   name: String!
   description: string
   material: [String!]!
   width: Int
   height: Int
   depth: Int
 }

在这里,使用 interface 关键字创建了一个名为 Product 的抽象类型。当我们希望创建新的对象类型 - BookBookcase 时,可以实现此接口。

现在,你可以简单地编写以下查询,它将返回所有产品(书籍和书架):

 type query {   allProducts: [Product]
 }

现在,你可以在客户端使用以下查询来检索所有产品:

 query getProducts {   allProducts {
     id
     name
     description
   }
 }

你可能已经注意到前面的代码只包含来自 Product 接口的属性。如果你想从 BookBookcase 获取属性,那么你必须使用 内联片段,如下所示:

 query getProducts {   allProducts {
     id
     name
     description
     ... on Book {
       author
       publisher
     }
     ... on BookCase {
       material
       height
     }
   }
 }

在这里,使用操作符()用于创建内联片段。这样,你可以从实现接口的类型中获取字段。

我们将在下一小节中理解 Union 类型。

理解联合类型

假设有两种对象类型 - BookAuthor。在这里,你想要编写一个 GraphQL 查询,它可以返回书籍和作者。请注意,接口不存在;那么我们如何在查询结果中结合这两个对象?在这种情况下,你可以使用 联合类型,它是由两个或多个对象组合而成的。

在创建 Union 类型之前,请考虑以下内容:

  • 你不需要有公共字段。

  • 联合成员应该是具体类型。因此,你不能使用 unioninterfaceinputscalar 类型。

让我们创建一个可以返回 union 类型中包含的任何对象的 union 类型 – 书籍和书架 – 如以下代码块所示:

union SearchResult = Book | Author type Book {
   id: ID!
   name: String!
   publisher: String
 }
 type Author {
   id: ID!
   name: String!
 }
 type Query {
  search(text: String): [SearchResult]
 }

在这里,使用 union 关键字为 BookAuthor 对象创建了一个 union 类型。使用管道符号(|)来分隔包含的对象。最后,定义了一个查询,返回包含给定文本的书籍或作者集合。

现在,让我们为客户编写这个查询,如下所示:

 # Request Input {
   search(text: "Malcolm Gladwell") {
     __typename
     ... on Book {
       name
       publisher
     }
     ... on Author {
       name
     }
   }
 }
 Response JSON
 {
   "data": {
     "search": [
       {
         "__typename": "Book",
         "name": "Blink",
         "publisher": "Back Bay Books"
       },
       {
         "__typename": "Author",
         "name": " Malcolm Gladwell ",
       }
     ]
   }
 }

如您所见,查询中使用了内联片段。另一个重要点是额外的字段,称为 __typename,它引用所属的对象并帮助您在客户端区分不同的对象。

我们将在下一小节中查看输入类型。

理解输入类型

到目前为止,您已经使用了标量类型作为参数。GraphQL 还允许您在突变中传递对象类型作为参数。唯一的区别是您必须使用 input 关键字来声明它们,而不是使用 type 关键字。

让我们创建一个接受输入类型作为参数的突变:

 type Mutation {   addProduct(prodInput: ProductInput): Product
 }
 input ProductInput {
   name: String!
   description: String
   price: Float!
   # other fields…
 }
 type Product {
   # Product Input fields. Truncated for brevity.
 }

在这里,addProduct 突变接受 ProductInput 作为参数并返回 Product

现在,让我们使用 GraphQL 请求向客户端添加产品,如下所示:

 # Request Input mutation AddProduct ($input: ProductInput) {
   addProduct(prodInput: $input) {
     name
   }
 }
 #---- Variable Section ----
 {
   "input": {
     name: "Blink",
     description: "a book",
     "price": 10.00
   }
 }
 # JSON Output
 {
   "data": {
     addProduct {
       "name": "Blink"
     }
   }
 }

在这里,您正在运行一个使用 input 变量的突变。您可能已经注意到这里使用了 Variable 来传递 ProductInput。命名突变用于变量。如果突变中定义了变量及其类型,则应在突变中使用它们。

变量的值应在变量部分(或在客户端之前)分配。变量的输入值使用一个映射到 ProductInput 的 JSON 对象来分配。

我们将在下一小节中查看在设计 GraphQL 模式时我们可以使用的工具。

使用 GraphQL 工具设计模式

您可以使用以下工具进行设计和与 GraphQL 一起工作,每个工具都有自己的特色:

  • GraphiQL:这个发音为 graphical。它是一个官方的 GraphQL 基金会项目,提供了一个基于网络的 GraphQL IDE。它使用了 语言服务器协议LSP),该协议在源代码编辑器和 IDE 之间使用基于 JSON-RPC 的协议。它可在 github.com/graphql/graphiql 获取。

  • GraphQL Playground:这是另一个流行的 GraphQL IDE,它曾经提供了比 GraphiQL 更好的功能。然而,现在 GraphiQL 与 Playground 具有功能一致性。在撰写本文时,GraphQL Playground 处于维护模式。有关更多详细信息,请查看 github.com/graphql/graphql-playground/issues/1366。它可在 github.com/graphql/graphql-playground 获取。

  • GraphQL Faker:这为你的 GraphQL API 提供模拟数据。它可在github.com/APIs-guru/graphql-faker找到。

  • GraphQL 编辑器:这允许你可视化地设计你的模式,然后将其转换为代码。它可在github.com/graphql-editor/graphql-editor找到。

  • GraphQL Voyager:这把你的模式转换为交互式图表,如实体图以及这些实体之间所有的关系。它可在github.com/APIs-guru/graphql-voyager找到。

在下一节中,你将测试本章所学到的知识。

测试 GraphQL 查询和突变

让我们在实际的 GraphQL 模式中编写查询和突变,以测试你使用 GitHub 的 GraphQL API 探索器学到的能力。让我们执行以下步骤:

  1. 首先,访问docs.github.com/en/graphql/overview/explorer

  2. 你可能需要使用你的 GitHub 账户授权它,这样你就可以执行 GraphQL 查询。

  3. GitHub 探索器基于 GraphiQL。它分为三个垂直部分(从左到右在图 13.1的灰色区域中):

    • 左侧部分分为两个子部分——一个用于编写查询的上部区域和一个用于定义变量的下部区域。

    • 中间的垂直部分显示了响应。

    • 通常,最右侧的部分是隐藏的。点击文档链接来显示它。它显示了相应的文档和模式,以及你可以探索的根类型。

图 13.1 – GraphQL API 探索器

图 13.1 – GraphQL API 探索器

  1. 让我们执行以下查询以找出你想要标记为星号的仓库的 ID:

    {  repository(    name: "Modern-API-Development-with-Spring-6-and-Spring-Boot-3"    owner: "PacktPublishing"  ) {    id    owner {      id      login    }    name    description    viewerHasStarred    stargazerCount  }}
    

在这里,你通过提供两个参数——仓库的name和其owner来查询这本书的上一版仓库。你正在获取一些字段。其中最重要的一个字段是stargazerCount,因为我们将要执行一个addStar突变。这个计数将告诉我们突变是否成功。

  1. 点击顶部栏上的执行查询按钮或按Ctrl + Enter键来执行查询。一旦查询成功执行,你应该会得到以下输出:

    {  "data": {    "repository": {      "id": "R_kgDOHzYNwg",      "owner": {        "id": "MDEyOk9yZ2FuaXphdGlvbjEwOTc0OTA2",        "login": "PacktPublishing"      },      "name": "Modern-API-Development-with-Spring-6-        and-Spring-Boot-3",      "description": "Modern API Development with      Spring 6 and Spring Boot 3, Published by Packt",      "viewerHasStarred": false,      "stargazerCount": 1    }  }}
    

在这里,你需要复制响应中的id值,因为它将用于标记这个仓库。

  1. 执行以下查询以执行addStar突变:

    mutation {  addStar(input: {starrableId: "R_kgDOHzYNwg"}) {    clientMutationId  }}
    

这执行了给定 ID 的仓库的addStar突变。

  1. 一旦之前的查询成功执行,你必须从步骤 4重新执行查询以了解更改情况。如果你遇到访问问题,你可以选择自己的 GitHub 仓库来执行这些步骤。

你也可以探索其他查询和突变,以深入了解 GraphQL。

最后,在我们跳到下一章的实现之前,让我们理解在 GraphQL 查询中 N+1 问题。

解决 N+1 问题

N+1 问题对于 Java 开发者来说并不陌生。在使用 Hibernate 时,你可能遇到过这个问题,这通常发生在你没有优化查询或正确编写实体时。

让我们看看 N+1 问题是什么。

什么是 N+1 问题?

N+1 问题通常在涉及关联时发生。客户和订单之间存在一对一的关系。一个客户可以有多个订单。如果你需要找到所有客户及其订单,你可以这样做:

  1. 首先,找到所有用户。这个查找操作返回用户对象列表。

  2. 然后,在步骤 1中找到属于每个用户的所有订单。userId字段充当OrderUser对象之间的关系。

因此,在这里,你执行了两个查询。如果你进一步优化实现,你可以在这两个实体(OrderUser)之间放置一个连接,并使用单个查询接收所有记录。

如果这很简单,那么为什么 GraphQL 会遇到 N+1 问题?你需要理解解析器函数来回答这个问题。

如果你参考你在第四章中创建的数据库模式,为 API 编写业务逻辑,你可以看到getUsersOrders查询将导致以下 SQL 语句被执行:

 SELECT * FROM ecomm.user; SELECT * FROM ecomm.orders WHERE customer_id in (1);
 SELECT * FROM ecomm.orders WHERE customer_id in (2);
 ...
 ...
 SELECT * FROM ecomm.orders WHERE customer_id in (n);

这里,为了执行getUsersOrders()操作,你将对用户执行查询以获取所有用户。然后,你对订单执行 N 次查询。这就是为什么它被称为 N+1 问题。这并不高效,因为理想情况下你应该执行单个查询,或者在最坏的情况下,执行两个查询。

由于解析器的存在,GraphQL 只能响应查询中请求的字段值。在 GraphQL 服务器的实现中,每个字段都有自己的解析函数,用于获取其对应字段的数據。让我们假设我们有以下模式:

 type Mutation {   getUsersOrders: [User]
 }
 type User {
   name: String
   orders: [Order]
 }
 type Order {
   id: Int
   status: Status
 }

这里,我们有一个返回用户集合的突变。每个User可能有一个订单集合。因此,你可以在客户端使用以下查询:

 {   getUsersOrders {
     name
     orders {
       id
       status
     }
   }
 }

让我们了解这个查询是如何被服务器处理的。

在服务器中,每个字段都将有自己的解析函数,用于获取对应的数据。第一个解析器将是用户解析器,将从数据存储中获取所有用户。接下来,解析器将为每个用户获取订单。它将根据给定的用户 ID 从数据存储中获取订单。因此,orders解析器将执行 n 次,其中 n 是从数据存储中获取的用户数量。

我们将在下一小节中学习如何解决 N+1 问题。

我们如何解决 N+1 问题?

所需的解决方案将等待直到所有订单都已加载。一旦检索到所有用户 ID,就应该在单个数据存储调用中调用数据库以获取所有订单。如果数据库的大小很大,可以使用批量操作。然后,执行者可以解决单个订单解析器。然而,说起来容易做起来难。GraphQL 提供了一个名为 DataLoader 的库 (github.com/graphql/dataloader),它可以为你完成这项工作。这个库主要执行查询的批量和缓存。

Java 提供了一个类似的库,名为 java-dataloader (github.com/graphql-java/java-dataloader),可以帮助你解决这个问题。你可以在 www.graphql-java.com/documentation/batching 上了解更多信息。

摘要

在本章中,你学习了 GraphQL、它的优势以及它与 REST 的比较。你学习了 GraphQL 如何解决过度获取和不足获取的问题。然后,你学习了 GraphQL 的根类型——查询、突变和订阅——以及不同的块如何帮助你设计 GraphQL 模式。最后,你了解了解析器的工作原理,它们如何导致 N+1 问题以及这个问题的解决方案。

现在你已经了解了 GraphQL 的基础知识,你可以开始设计 GraphQL 模式。你还学习了 GraphQL 的客户端查询以及如何使用别名、片段和变量来解决常见问题。

在下一章中,你将利用本章学到的 GraphQL 技能来实现 GraphQL API。

问题

  1. GraphQL 是否比 REST 更好?如果是的话,那是在哪些方面?

  2. 何时应该使用片段?

  3. 你如何在 GraphQL 查询中使用变量?

答案

  1. 这取决于用例。然而,GraphQL 对于移动应用和基于 Web 的 UI 应用程序性能更好,最适合 服务到服务s2s) 通信。

  2. 当响应包含接口或联合时,应该在从 GraphQL 客户端发送请求时使用片段。

  3. 你可以在 GraphQL 查询/突变中使用变量,如下面的代码所示。这段代码曾用于修改 测试 GraphQL 查询和突变 部分的 步骤 6 中发送的 GraphQL 请求:

    mutation {  addStar(input: {starrableId: $repoId }) {    clientMutationId  }}
    

在这里,你可以看到使用了 $repoId 变量。你必须在该命名的突变中声明该变量,然后它就可以在突变参数中使用,如下面的代码片段所示:

{  "repoId": "R_kgDOHzYNwg"
}

进一步阅读

第十三章:GraphQL API 开发和测试

在上一章中,我们学习了 GraphQL 的基本概念。您将利用这些知识在本章中开发和测试基于 GraphQL 的 API。您将在本章中为示例应用程序实现基于 GraphQL 的 API。GraphQL 服务器的实现将基于设计优先的方法,这与您在第三章中定义的 OpenAPI 规范的方式相同,在第十一章中设计了模式,gRPC API 开发和测试

完成本章学习后,您将学会如何实际应用在前一章中学到的 GraphQL 概念,以及使用 Java 和 Spring 实现 GraphQL 服务器及其测试。

本章将涵盖以下主要主题:

  • GraphQL 的工作流程和工具

  • 实现 GraphQL 服务器

  • 记录 API 文档

  • 测试自动化

技术要求

本章的代码可在以下位置找到

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14

GraphQL 的工作流程和工具

根据 GraphQL 中数据图(数据结构)的思维方式,数据通过由对象图组成的 API 进行暴露。这些对象通过关系相互连接。GraphQL 仅暴露一个 API 端点。客户端查询此端点,它使用一个单一数据图。在此基础上,数据图可以通过遵循 GraphQL 的OneGraph 原则从单一来源或多个来源解析数据。这些来源可以是数据库、遗留系统或使用 REST/gRPC/SOAP 暴露数据的服务。

GraphQL 服务器可以通过以下两种方式实现:

  • 独立 GraphQL 服务:一个独立的 GraphQL 服务包含一个单一的数据图。它可能是一个单体应用程序,或者基于微服务架构,从单一或多个来源(没有 GraphQL API)获取数据。

  • 联邦 GraphQL 服务:查询单一数据图以获取综合数据非常容易。然而,企业应用程序是由多个服务构建的;因此,除非构建一个单体系统,否则您无法拥有单一的数据图。如果您不构建单体系统,那么您将拥有多个特定于服务的独立数据图。

这就是您使用联邦 GraphQL 服务的地方。一个联邦 GraphQL 服务包含一个单个分布式图,通过网关暴露。客户端调用网关,它是进入系统的入口点。数据图分布在多个服务中,每个服务可以独立维护其自身的开发和发布周期。话虽如此,联邦 GraphQL 服务仍然遵循 OneGraph 原则。因此,客户端查询单个端点以获取图的任何部分。

假设一个示例电子商务应用是使用 GraphQL 联邦服务开发的。它有产品、订单、运输、库存、客户和其他服务,这些服务使用 GraphQL API 公开了特定领域的图数据。

让我们看一下 GraphQL 联邦电子商务服务的高级图示,如下所示:

图 14.1 – 联邦 GraphQL 服务

图 14.1 – 联邦 GraphQL 服务

假设 GraphQL 客户端通过调用Gateway端点查询最常订购且库存最少的产品的列表。此查询可能包含来自OrdersProductsInventory的字段。每个服务只负责解决数据图的相关部分。Orders将解决订单相关数据,Products将解决产品相关数据,Inventory将解决库存相关数据,等等。然后Gateway整合图数据并将其发送回客户端。

graphql-java库(www.graphql-java.com)提供了 GraphQL 规范的 Java 实现。其源代码可在github.com/graphql-java/graphql-java找到。

Spring 提供了一个基于graphql-java的 GraphQL Spring Boot Starter 项目,可在github.com/spring-projects/spring-graphql找到。然而,我们将使用基于 Spring 的 Netflix graphql-java库。

Netflix 在 2021 年 2 月将其在生产环境中使用的 DGS 框架开源。它正在由社区持续增强和支持。Netflix 在生产环境中使用相同的开源 DGS 框架代码库,这保证了代码的质量和未来的维护。OTT Disney+平台也是使用 Netflix DGS 框架构建的(webcache.googleusercontent.com/search?q=cache:ec4kC7jBjMQJ:https://help.apps.disneyplus.com/3rd-party-libs.html&cd=14&hl=en&ct=clnk&gl=in&client=firefox-b-d)).

它提供了以下功能:

  • Spring Boot 启动器和与 Spring Security 的集成

  • 全面的 WebFlux 支持

  • 用于从 GraphQL 模式生成代码的 Gradle 插件

  • 支持接口和联合类型,并提供自定义标量类型

  • 支持 WebSocket 和服务器发送事件使用 GraphQL 订阅

  • 错误处理

  • 可插拔的仪器和 Micrometer 集成

  • 使用 GraphQL 联邦与 GraphQL 联邦轻松集成的 GraphQL 联邦服务

  • 带有热加载模式的动态模式

  • 操作缓存

  • 文件上传

  • GraphQL Java 客户端

  • GraphQL 测试框架

在下一节中,我们将使用 Netflix 的 DGS 框架编写一个 GraphQL 服务器。

实现 GraphQL 服务器

在本章中,您将开发一个独立的 GraphQL 服务器。在开发独立 GraphQL 服务器时获得的知识可以用于实现联邦 GraphQL 服务。

在下一小节中,让我们首先创建 Gradle 项目。

创建 gRPC 服务器项目

您可以选择克隆 Git 仓库中的 第十四章 代码(github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14)或从零开始使用 Spring Initializr 创建服务器和客户端项目,以下为选项:

  • Gradle - Groovy

  • Java

  • 3.0.8

推荐的版本是 3.0+;如果不可用,您可以在稍后的 build.gradle 文件中手动修改。

  • com.packt.modern.api

  • 第十四章

  • 第十四章

  • 《使用 Spring 和 Spring Boot 开发现代 API》第十四章代码 第 2 版

  • com.packt.modern.api

  • JAR

  • 17

您可以在 build.gradle 文件中将其更改为其他版本,例如 17/20/21,如下面的代码块所示:

// update following build.gradle filesourceCompatibility = JavaVersion.VERSION_17
// or for Java 20
// sourceCompatibility = JavaVersion.VERSION_20
// or for Java 21
// sourceCompatibility = JavaVersion.VERSION_20
  • org.springframework.boot:spring-boot-starter-web

然后,您可以点击生成按钮并下载项目。下载的项目将用于创建 GraphQL 服务器。

接下来,让我们将 GraphQL DGS 依赖项添加到新创建的项目中。

添加 GraphQL DGS 依赖项

一旦 Gradle 项目可用,您就可以修改 build.gradle 文件以包含 GDS 依赖项和插件,如下面的代码所示:

 plugins {    id 'org.springframework.boot' version '3.0.6'
    id 'io.spring.dependency-management' version '1.1.0'
    id 'java'
    id 'com.netflix.dgs.codegen' version '5.7.1'
 }
 // code truncated for brevity
 def dgsVersion = '6.0.5'
 dependencies {
    implementation platform("com.netflix.graphql.
        dgs:graphql-dgs-platform-dependencies:${
        dgsVersion}")
    implementation 'com.netflix.graphql.dgs:graphql-dgs-
        spring-boot-starter'
    implementation 'com.netflix.graphql.dgs:graphql-dgs-
        extended-scalars'
    implementation 'com.netflix.graphql.dgs:graphql-dgs-
        spring-boot-micrometer'
    runtimeOnly 'com.netflix.graphql.dgs:graphql-dgs-
        subscriptions-websockets-autoconfigure'
    implementation 'org.springframework.boot:spring-boot-
        starter-web'
    implementation 'org.springframework.boot:spring-boot-
        starter-actuator'
    testImplementation 'org.springframework.boot:
        spring-boot-starter-test'
    implementation 'net.datafaker:datafaker:1.9.0'
 }

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/build.gradle

在这里,首先添加了 DGS Codegen 插件,它将从 GraphQL 模式文件生成代码。接下来,添加以下五个依赖项:

  • graphql-dgs-platform-dependencies: 用于 DGS 材料清单 (BOM) 的 DGS 平台依赖项

  • graphql-dgs-spring-boot-starter: 用于 DGS Spring 支持的 DGS Spring Boot Starter 库

  • graphql-dgs-extended-scalars: 用于自定义标量类型的 DGS 扩展标量库

  • graphql-dgs-spring-boot-micrometer: 提供与 Micrometer 集成的 DGS 库,以提供对指标和仪表化的支持,以及 Spring Actuator

  • graphql-dgs-subscriptions-websockets-autoconfigure: 提供对 GraphQL WebSocket 支持的自动配置

请注意,这里使用了 datafaker 库来生成领域种子数据。

接下来,让我们在相同的 build.gradle 文件中配置 DGS Codegen 插件,如下面的代码块所示:

 generateJava {     generateClient = true
     packageName = "com.packt.modern.api.generated"
 }

你已经使用 generateJava 任务配置了 DGS Codegen 的以下两个属性,该任务使用 Gradle 插件 com.netflix.graphql.dgs.codegen.GenerateJavaTask 类:

  • generateClient: 这确定你是否想要生成客户端

  • packageName: 生成的 Java 类的 Java 包名

DGS Codegen 插件默认从 src/main/resources/schema 目录中选取 GraphQL 模式文件。然而,你可以使用 schemaPaths 属性来修改它,该属性接受一个数组。如果你想更改默认的模式位置,可以将此属性添加到之前的 generateTask 代码中,包括 packageNamegenerateClient,如下所示:

org.hidetake.swagger. generator Gradle plugin while generating the Java code from OpenAPI specs in *step 4* of the *Converting OAS to Spring code* section in *Chapter 3*, *API Specifications and Implementation*. To add a custom type mapping, you can add the typeMapping property to the plugin task, as shown next:

typeMapping = ["GraphQLType": "mypackage.JavaType"]


 This property accepts an array; you can add one or more type mappings here. You can refer to the plugin documentation at [`netflix.github.io/dgs/generating-code-from-schema/`](https://netflix.github.io/dgs/generating-code-from-schema/) for more information.
Let’s add the GraphQL schema next.
Adding the GraphQL schema
Netflix’s DGS supports both the code-first and design-first approaches. However, we are going to use the design-first approach in this chapter as we have done throughout this book. Therefore, first, we’ll design the schema using the GraphQL schema language and then use the generated code to implement the GraphQL APIs.
We are going to keep the domain objects minimal to reduce the complexity of business logic and keep the focus on the GraphQL server implementation. Therefore, you’ll have just two domain objects – `Product` and `Tag`. The GraphQL schema allows the following operation using its endpoint as shown in the following schema definition:

type Query {    products(filter: ProductCriteria): [Product]!

product(id: ID!): Product

}

type Mutation {

addTag(productId: ID!, tags: [TagInput!]!): Product

addQuantity(productId: ID!, quantity: Int!): Product

}

type Subscription {

quantityChanged: Product

}


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/main/resources/schema/schema.graphqls`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/main/resources/schema/schema.graphqls)
You need to add the `schema.graphqls` GraphQL schema file at the `src/main/ resources/schema` location. You can have multiple schema files there to create the schema module-wise.
Here, the following root types have been exposed:

*   `Query`: The product and product queries to fetch a product by its ID, and a collection of products matched by the given criteria.
*   `Mutation`: The `addTag` mutation adds a tag to the product that matches the given ID. Another mutation, `addQuantity`, increases the product quantity. The `addQuantity` mutation can also be used as an event that triggers the subscription publication.
*   `Subscription`: The `quantityChanged` subscription publishes the product where the quantity has been updated. The event quantity change is captured through the `addQuantity` mutation.

Let’s add the object types and input types being used in these root types to `schema. graphqls` as shown in the next code block:

type Product {     id: 字符串

name: 字符串

description: 字符串

imageUrl: 字符串

price: BigDecimal

count: 整数

tags: [Tag]

}

input ProductCriteria {

tags: [TagInput] = []

name: 字符串 = ""

page: 整数 = 1

size: 整数 = 10

}

input TagInput {

name: 字符串

}

type Tag {

id: 字符串

name: 字符串

}


These are straightforward object and input types. All fields of the `ProductCriteria` input type have been kept optional.
We have also used a `BigDecimal` custom scalar type. Therefore, we need to first declare it in the schema. We can do that by adding `BigDecimal` to the end of the schema file, as shown next:

java.math.BigDecimal 在代码生成插件中。让我们将其添加到下一个 build.gradle 文件中,如下所示(检查高亮行):

generateJava {    generateClient = true
    packageName = "com.packt.modern.api.generated"
    typeMapping = ["BigDecimal": "java.math.BigDecimal"]
}

在这些更改之后,你的项目已准备好生成 GraphQL 对象和客户端。你可以从项目根目录运行以下命令来构建项目:

 $ ./gradlew clean build

此命令将在 build/generated 目录中生成 Java 类。

在你开始实现 GraphQL 根类型之前,让我们在下一小节中讨论自定义标量类型。

添加自定义标量类型

你将使用 BigDecimal 来捕获货币值。这是一个自定义标量类型;因此,你需要将此自定义标量添加到代码中,以便 DGS 框架可以将其用于序列化和反序列化。(你还需要将映射添加到 Gradle 代码生成插件中。)

添加自定义标量类型有两种方式——通过实现 Coercing 接口和利用 graphql-dgs-extended-scalars 库。我们将使用后者,因为它行数更少,并且实际的实现由 Netflix DGS 框架提供。

第一种,最原始的添加标量类型的方法是实现 graphql.schema.Coercing 接口,并用 @DgsScalar 注解进行标注。在这里,你需要自己编写样板代码。

相反,我们将选择第二种方法,该方法涉及使用 DGS 框架提供的标量类型,该类型已在生产系统上经过良好测试。graphql.schema.Coercing 接口由 graphql-java 库提供。DateTimeScalar 标量类型使用 Coercing 实现,如下面的代码所示:

@DgsScalar(name="DateTime")public class DateTimeScalar
               implements Coercing<LocalDateTime, String> {
  @Override
  public String serialize(Object dataFetcherResult)
                throws CoercingSerializeException {
    if (dataFetcherResult instanceof LocalDateTime) {
      return ((LocalDateTime) dataFetcherResult)
            .format(DateTimeFormatter.ISO_DATE_TIME);
    } else {
      throw new CoercingSerializeException
          ("Invalid Dt Tm");
    }
  }
  @Override
  public LocalDateTime parseValue(Object input)
        throws CoercingParseValueException {
    return LocalDateTime.parse(input.toString(),
        DateTimeFormatter.ISO_DATE_TIME);
  }
  @Override
  public LocalDateTime parseLiteral(Object input)
       throws CoercingParseLiteralException {
    if (input instanceof StringValue) {
      return LocalDateTime.parse(((StringValue) input)
          .getValue(), DateTimeFormatter.ISO_DATE_TIME);
    }
    throw new CoercingParseLiteralException
        ("Invalid Dt Tm");
  }
}

在这里,你重写了 Coercing 接口的三个方法——serialize()parseValue()parseLiteral()——以实现 DateTimeScalar 自定义标量类型的序列化和解析。

然而,你将使用第二种方法——graphql-dgs-extended-scalars 库——来注册新的标量类型。这个库已经在 build.gradle 文件中添加了。让我们利用 graphql-dgs-extended-scalars 库来注册 BigDecimalScaler 类型。

创建一个名为 BigDecimalScaler.java 的新 Java 文件,并将以下代码添加到其中:

@DgsComponentpublic class BigDecimalScalar {
  @DgsRuntimeWiring
  public RuntimeWiring.Builder addScalar(
      RuntimeWiring.Builder builder) {
    return builder.scalar(GraphQLBigDecimal);
  }
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/dev/Chapter14/src/main/java/com/packt/modern/api/scalar/BigDecimalScalar.java

在这里,你正在使用 DgsRuntimeWiring 来添加由 graphql-dgs-extended-scalars 库提供的自定义 GraphQLBigDecimal 标量。RuntimeWiring 类包含数据获取器、类型解析器和自定义标量,这些是连接一个功能性的 GraphQLSchema 类所必需的。DgsRuntimeWiring 注解将方法标记为运行时连接。因此,你可以在 RuntimeWiring 类执行之前进行自定义。基本上,你正在将 GraphQLBigDecimal 标量类型添加到 RuntimeWiring.Builder 以进行运行时连接执行。

BigDecimalScalar 类被标记为 @DgsComponent 注解。DGS 框架是一个基于注解的 Spring Boot 编程模型。DGS 框架为 Spring Boot 提供了这些类型的注解(如 @DgsComponent)。被 @DgsComponent 标记的类既是 DGS 组件也是常规的 Spring 组件。

同样,你已经添加了 DateTimeScalar 类型。DateTimeScalar 标量类型的代码可以在 github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/main/java/com/packt/modern/api/scalar/DateTimeScalar.java 找到。

所有模式细节及其文档都可以通过 GraphiQL 或类似工具中可用的 GraphQL 文档进行探索。让我们找出如何找到自动生成的文档。

记录 API

您可以使用 GraphiQL 或提供图形界面的 playground 工具来探索 GraphQL 模式和文档。

在 GraphiQL(http://localhost:8080/graphiql,可以通过运行本章代码构建的jar启动),您可以通过点击页面左上角的书本图标打开文档浏览器。一旦点击,它将显示文档。

然而,如果您正在寻找一个静态页面,那么您可以使用诸如graphdocgithub.com/2fd/graphdoc)之类的工具来生成 GraphQL API 的静态文档。

接下来,让我们开始实现 GraphQL 根类型。首先,您将实现 GraphQL 查询。

实现 GraphQL 查询

我们在上一节模式中引入的查询都很简单。您传递一个产品 ID 以找到由该 ID 标识的产品——这就是您的产品查询。接下来,您传递可选的产品标准以根据给定标准查找产品;否则,将根据产品标准字段默认值返回产品。

在 REST 中,您在第三章实现 OAS 代码接口部分实现了控制器类,API 规范和实现。您创建了一个控制器,将调用传递给服务,然后服务调用存储库从数据库中获取数据。您将使用相同的设计。但是,您将使用ConcurrentHashMap代替数据库以简化代码。这也可以用于您的自动化测试。

让我们创建一个存储库类,如下一个代码块所示:

public interface Repository {  Product getProduct(String id);
  List<Product> getProducts();
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/main/java/com/packt/modern/api/repository/Repository.java

这些是用于获取产品和产品集合的简单签名。

让我们使用ConcurrentHashMap实现新创建的存储库接口,如下一个代码块所示:

@org.springframework.stereotype.Repositorypublic class InMemRepository implements Repository {
  private static final Map<String, Product>
productEntities = new ConcurrentHashMap<>();
  private static final Map<String, Tag> tagEntities =
      new ConcurrentHashMap<>();
  // rest of the code is truncated

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/main/java/com/packt/modern/api/repository/InMemRepository.java

在这里,您已经创建了两个ConcurrentHashMap实例来存储产品和标签。让我们使用构造函数将这些种子数据添加到这些映射中:

public InMemRepository() {  Faker faker = new Faker();
  IntStream.range(0, faker.number()
      .numberBetween(20, 50)).forEach(number -> {
    String tag = faker.book().genre();
    tagEntities.putIfAbsent(tag,
       Tag.newBuilder().id(UUID.randomUUID().toString())
         .name(tag).build());
  });
  IntStream.range(0, faker.number().numberBetween(4, 20))
    .forEach(number -> {
      String id = String.format("a1s2d3f4-%d", number);
      String title = faker.book().title();
      List<Tag> tags = tagEntities.entrySet().stream()
        .filter(t -> t.getKey().startsWith(
          faker.book().genre().substring(0, 1)))
        .map(Entry::getValue).collect(toList());
    if (tags.isEmpty()) {
     tags.add(tagEntities.entrySet().stream()
       .findAny().get().getValue());
    }
    Product product = Product.newBuilder().id(id).
        name(title)
      .description(faker.lorem().sentence())
      .count(faker.number().numberBetween(10, 100))
      .price(BigDecimal.valueOf(faker.number()
         .randomDigitNotZero()))
      .imageUrl(String.format("/images/%s.jpeg",
         title.replace(" ", "")))
      .tags(tags).build();
    productEntities.put(id, product);
  });
  // rest of the code is truncated

此代码首先生成标签,然后将其存储在tagEntities映射中。代码还在将产品存储在productEntities映射之前将标签附加到新产品上。这只是为了开发目的而做的。在生产应用程序中,您应该使用数据库。

现在,getProductgetProducts方法很简单,如下一个代码块所示:

@Overridepublic Product getProduct(String id) {
  if (Strings.isBlank(id)) {
    throw new RuntimeException("Invalid Product ID.");
  }
  Product product = productEntities.get(id);
  if (Objects.isNull(product)) {
    throw new RuntimeException("Product not found.");
  }
  return product;
}
@Override
public List<Product> getProducts() {
  return productEntities.entrySet().stream()
    .map(e -> e.getValue()).collect(toList());
}

getProduct方法执行基本验证并返回产品。getProducts方法简单地返回从映射转换的产品集合。

现在,您可以添加服务和其实现。让我们添加下一个代码块中显示的服务接口:

public interface ProductService {  Product getProduct(String id);
  List<Product> getProducts(ProductCriteria criteria);
  Product addQuantity(String productId, int qty);
  Publisher<Product> gerProductPublisher();
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/main/java/com/packt/modern/api/services/ProductService.java

这些服务方法实现只是简单地调用存储库来获取数据。让我们添加下一个代码块中显示的实现:

@Servicepublic class ProductServiceImpl implements ProductService {
  private final Repository repository;
  public ProductServiceImpl(Repository repository) {
    this.repository = repository;
  }
  @Override
  public Product getProduct(String id) {
    return repository.getProduct(id);
  }
  // continue …

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/main/java/com/packt/modern/api/services/ProductServiceImpl.java

在这里,使用构造函数注入将存储库注入。

让我们添加getProducts()方法,它根据给定的过滤条件执行过滤,如下一个代码块所示:

@Overridepublic List<Product> getProducts(ProductCriteria criteria) {
  List<Predicate<Product>> predicates = new ArrayList<>(2);
  if (!Objects.isNull(criteria)) {
    if (Strings.isNotBlank(criteria.getName())) {
      Predicate<Product> namePredicate =
        p -> p.getName().contains(criteria.getName());
      predicates.add(namePredicate);
    }
    if (!Objects.isNull(criteria.getTags()) &&
        !criteria.getTags().isEmpty()) {
      List<String> tags = criteria.getTags().stream()
        .map(ti -> ti.getName()).collect(toList());
      Predicate<Product> tagsPredicate =
        p -> p.getTags().stream().filter(
          t -> tags.contains(t.getName())).count() > 0;
      predicates.add(tagsPredicate);
    }
  }
  if (predicates.isEmpty()) {
    return repository.getProducts();
  }
  return repository.getProducts().stream()
    .filter(p -> predicates.stream().allMatch(
      pre -> pre.test(p))).collect(toList());
}

此方法首先检查是否提供了条件。如果没有提供条件,则调用存储库并返回所有产品。

如果提供了条件,它将创建predicates列表。然后,这些predicates被用来过滤匹配的产品并返回给调用函数。

接下来是 GraphQL 查询实现中最关键的部分:编写数据检索器。首先,让我们编写product查询的数据检索器。

编写 GraphQL 查询的检索器

您将在本节中编写数据检索器。数据检索器,如名称所示,从持久存储源检索信息,例如数据库或第三方 API/文档存储。您将学习如何编写数据检索器以检索单个字段的数据、单个对象和对象集合。

编写产品的数据检索器

数据获取器是服务 GraphQL 请求的关键 DSG 组件,它获取数据,DSG 内部解析每个字段。您使用特殊的 @DgsComponent DGS 注解标记它们。这些是 DGS 框架扫描并用于服务请求的 Spring 组件类型。

让我们在 datafetchers 包中创建一个名为 ProductDatafetcher.java 的新文件,以表示 DGS 数据获取器组件。它将有一个用于服务 product 查询的数据获取器方法。您可以将以下代码添加到其中:

@DgsComponentpublic class ProductDatafetcher {
  private final ProductService productService;
  public ProductDatafetcher(
      ProductService productService) {
    this.productService = productService;
  }
  @DgsData(parentType = DgsConstants.QUERY_TYPE,
           field = QUERY.Product)
  public Product getProduct(@InputArgument("id") String id) {
    if (Strings.isBlank(id)) {
      new RuntimeException("Invalid Product ID.");
    }
    return productService.getProduct(id);
  }
  // continue …

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/main/java/com/packt/modern/api/datafetchers/ProductDatafetcher.java

在这里,您使用构造函数创建了一个产品服务 bean 注入。此服务 bean 帮助您根据给定的产品 ID 查找产品。

getProduct 方法中使用了两个其他重要的 DGS 框架注解。让我们了解它们的作用:

  • @DgsData:这是一个数据获取器注解,将方法标记为数据获取器。parentType 属性表示类型,field 属性表示类型(parentType)的字段。因此,可以说该方法将获取给定类型的字段。

您已将 Query 设置为 parentTypefield 属性被设置为 product 查询。因此,此方法作为 GraphQL 查询产品调用的入口点。@DsgData 注解属性使用 DgsConstants 常量类设置。

DgsConstants 由 DGS Gradle 插件生成,它包含模式的所有常量部分。

  • @InputArgument:此注解允许您捕获 GraphQL 请求传递的参数。在此,id 参数的值被捕获并分配给 id 字符串变量。

您可以在 测试 自动化 部分找到与此数据获取器方法相关的测试用例。

同样,您可以编写 products 查询的数据获取器方法。让我们在下一个小节中编写它。

编写产品集合的数据获取器

让我们在 datafetchers 包中创建一个名为 ProductsDatafetcher.java 的新文件,以表示 DGS 数据获取器组件。它将有一个用于服务 products 查询的数据获取器方法。您可以将以下代码添加到其中:

@DgsComponentpublic class ProductsDatafetcher {
  private ProductService service;
  public ProductsDatafetcher(ProductService service) {
    this.service = service;
  }
  @DgsData(
    parentType = DgsConstants.QUERY_TYPE,
    field = QUERY.Products
  )
  public List<Product> getProducts(@InputArgument("filter")
      ProductCriteria criteria) {
    return service.getProducts(criteria);
  }
 // continue …

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/main/java/com/packt/modern/api/datafetchers/ProductsDatafetcher.java

这个 getProducts() 方法看起来与为 getProduct() 返回的数据获取方法没有区别,它在倒数第二个代码块中。在这里,@DsgDataparentTypefield 属性表明这个方法将用于获取 products 查询的产品集合(注意我们在这里使用的是复数形式)。

您已经完成了 GraphQL 查询的实现。现在您可以测试您的更改了。在运行测试之前,您需要构建应用程序。让我们使用以下命令来构建应用程序:

 $ gradlew clean build

一旦构建成功,您可以使用以下命令来运行应用程序:

 $ java –jar build/libs/chapter14-0.0.1-SNAPSHOT.jar

如果您没有对端口号进行任何更改,应用程序应该运行在默认端口 8080

现在,您可以在浏览器窗口中打开 GraphiQL,使用以下 URL:http://localhost:8080/graphiql(DGS 框架的一部分)。如有必要,请相应地更改主机/端口。

您可以使用以下查询来获取产品集合:

{  products(
    filter: {name: "His Dark Materials",
       tags: [{name: "Fantasy"}, {name: "Legend"}]}
  ) {
    id
    name
    price
    description
    tags {
      id
      name
    }
  }
}

一旦运行前面的查询,它将获取与过滤器中给定标准匹配的产品。

图 14.2 – 在 GraphiQL 工具中执行 GraphQL 查询

图 14.2 – 在 GraphiQL 工具中执行 GraphQL 查询

这将工作得很好。但是,如果您想单独获取标签呢?对象中可能存在关系(例如带有账单信息的订单),这些关系可能来自不同的数据库或服务,或者来自两个不同的表。在这种情况下,您可能想添加一个字段解析器,使用数据获取方法。

让我们在下一小节中添加一个字段解析器,使用数据获取方法。

使用数据获取方法编写字段解析器

到目前为止,您还没有为获取标签单独的数据获取器。您获取产品,它也会为您获取标签,因为我们使用了一个并发映射,它一起存储了两个查询的数据。因此,首先,您需要为给定产品编写一个新的数据获取方法来获取标签。

让我们在 ProductsDatafetcher 类中添加 tags() 方法来获取标签,如下一个代码块所示:

@DgsData(    parentType = PRODUCT.TYPE_NAME,
    field = PRODUCT.Tags
)
public List<Tags> tags(String productId) {
  return tagService.fetch(productId);
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/main/java/com/packt/modern/api/datafetchers/ProductsDatafetcher.java

在这里,tags() 方法对于 @DsgData 属性有一组不同的值。parentType 属性没有设置为像早期数据获取方法中那样的根类型,例如设置为 Query。相反,它被设置为对象类型 – Productfield 属性被设置为 tags

这个方法将被调用以获取每个单独产品的标签,因为它是 Product 对象的 tags 字段的字段解析器。因此,如果你有 20 个产品,这个方法将被调用 20 次以获取 20 个产品的标签。这是一个 N+1 问题,我们在 第十三章Solving the N+1 problem 节中学习了,GraphQL 入门

在 N+1 问题中,为了获取关系数据,会进行额外的数据库调用。因此,给定一个产品集合,它可能需要单独为每个产品查询标签而访问数据库。

你知道你必须使用数据加载器来避免 N+1 问题。数据加载器在执行单个查询之前会缓存所有产品的 ID,然后获取它们对应的标签。

接下来,让我们学习如何实现一个数据加载器来解决此情况中的 N+1 问题。

编写数据加载器以解决 N+1 问题

你将使用 DataFetchingEnvironment 类作为数据获取方法中的参数。它由 graphql-java 库注入到数据获取方法中,以提供执行上下文。这个执行上下文包含有关解析器的信息,例如对象及其字段。你还可以在特殊用例中使用它们,例如加载数据加载器类。

让我们修改前面代码块中提到的 ProductsDatafetcher 类中的 tags() 方法,以无 N+1 问题地获取标签,如下一个代码块所示:

 @DgsData(   parentType = PRODUCT.TYPE_NAME,
   field = PRODUCT.Tags
 )
 public CompletableFuture<List<Tags>> tags(
     DgsDataFetchingEnvironment env) {
   DataLoader<String, List<Tags>> tagsDataLoader =
       env.getDataLoader(TagsDataloaderWithContext.class);
   Product product = env.getSource();
   return tagsDataLoader.load(product.getId());
 }

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/main/java/com/packt/modern/api/datafetchers/ProductsDatafetcher.java

在这里,修改后的 tags() 数据获取方法使用数据加载器执行 fetch 方法,并返回包含在 CompletableFuture 中的标签集合。即使产品数量超过 1,它也只会被调用一次。

什么是 CompletableFuture?

CompletableFuture 是一个表示异步计算结果的 Java 并发类,其完成状态是显式标记的。它可以异步地链式执行多个依赖任务,当当前任务的结果可用时,下一个任务将被触发。

你正在使用 DsgDataFetchingEnvironment 作为参数。它实现了 DataFetchingEnvironment 接口,并提供通过其类和名称加载数据加载器类的方法。在这里,你正在使用数据加载器类来加载数据加载器。

DsgDataFetchingEnvironmentgetSource() 方法返回 @DsgDataparentType 属性的值。因此,getSource() 返回 Product

此修改后的数据获取方法将单次调用中获取给定产品列表的标签。此方法将获取产品列表的标签,因为数据加载器类实现了MappedBatchLoader,它使用批处理执行操作。

数据加载器类使用批处理方式获取给定产品(通过 ID)的标签。这里的魔法在于返回CompletableFuture。因此,尽管你只传递了一个产品 ID 作为参数,但数据加载器以批量方式处理它。让我们接下来实现这个数据加载器类(TagsDataloaderWithContext),以便更深入地了解它。

你可以通过两种方式创建数据加载器类——带有上下文或不带有上下文。没有上下文的数据加载器实现了MappedBatchLoader,它具有以下方法签名:

 CompletionStage<Map<K, V>> load(Set<K> keys);

另一方面,具有上下文的数据加载器实现了MappedBatchLoaderWithContext接口,该接口具有以下方法签名:

 CompletionStage<Map<K, V>> load(Set<K> keys,                         BatchLoaderEnvironment environment);

在数据加载方面,两者是相同的。然而,具有上下文的数据加载器为你提供了额外的信息(通过BatchLoaderEnvironment),这些信息可用于各种附加功能,如身份验证、授权或传递数据库详细信息。

dataloaders包中创建一个名为TagsDataloaderWithContext.java的新 Java 文件,并包含以下代码:

@DgsDataLoader(name = "tagsWithContext")public class TagsDataloaderWithContext implements
          MappedBatchLoaderWithContext<String, List<Tag>> {
  private final TagService tagService;
  public TagsDataloaderWithContext(TagService tagService) {
    this.tagService = tagService;
  }
  @Override
  public CompletionStage<Map<String, List<Tag>>> load(
    Set<String> keys, BatchLoaderEnvironment environment) {
    return CompletableFuture.supplyAsync(() ->
        tagService.getTags(new ArrayList<>(keys)));
  }
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/main/java/com/packt/modern/api/dataloaders/TagsDataloaderWithContext.java

在这里,它实现了MappedBatchLoaderWithContext接口的load()方法。它包含BatchLoaderEnvironment参数,该参数提供环境上下文,可以包含用户身份验证和授权信息或数据库信息。然而,我们没有使用它,因为我们没有与身份验证、授权或数据库相关的任何附加信息要传递给存储库或底层数据访问层。如果你有,你可以使用environment参数。

你也可以在github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/main/java/com/packt/modern/api/dataloaders/TagDataloader.java中找到没有上下文的数据加载器。其代码与我们所写的具有上下文的数据加载器代码大致相同。唯一的区别是我们没有使用上下文。

你可以看到它使用了标签服务来获取标签。然后,它简单地通过提供从标签服务收到的标签来返回完成阶段。此操作由数据加载器批量执行。

你可以创建一个新的标签服务及其实现,如下所示:

public interface TagService {  Map<String, List<Tag>> getTags(List<String> productIds);
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/main/java/com/packt/modern/api/repository/Repository.java

这是getTags方法的签名,它返回产品 ID 与对应标签的映射。

让我们按照以下代码块实现此接口:

@Servicepublic class TagServiceImpl implements TagService {
  private final Repository repository;
  public TagServiceImpl(Repository repository) {
    this.repository = repository;
  }
  @Override
  public Map<String, List<Tag>> getTags(
       List<String> productIds) {
    return repository.getProductTagMappings(productIds);
  }
  @Override
  public Product addTags(
       String productId, List<TagInput> tags) {
    return repository.addTags(productId, tags);
  }
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/main/java/com/packt/modern/api/repository/InMemRepository.java

在这里,实现的方法很简单。它将调用传递给存储库的调用,根据传递的产品 ID 集合获取标签。

你可以将getProductTagMappings添加到src/main/java/com/packt/modern/api/repository/Repository.java接口中,如下所示:

Map<String, List<Tag>> getProductTagMappings(     List<String> productIds);

然后,你可以在src/main/java/com/packt/modern/api/repository/InMemRepository.java类中实现此方法,如下所示:

@Overridepublic Map<String, List<Tag>> getProductTagMappings(
     List<String> productIds) {
  return productEntities.entrySet().stream()
    .filter(e -> productIds.contains(e.getKey()))
    .collect(toMap(e -> e.getKey(),
       e -> e.getValue().getTags()));
}

在这里,代码首先创建了产品映射集合的流,然后过滤出与通过此方法传入的产品匹配的产品。最后,它将过滤后的产品转换为以KeyTags值为键的产品 ID 映射,然后返回map

现在,如果你调用product GraphQL 查询,即使产品是通过正确归一化的数据库获取的,它也会批量加载产品标签,而不存在N+1问题。

你已经完成了 GraphQL 查询的实现,并且应该能够独立实现查询。

接下来,你将实现 GraphQL 变异操作。

实现 GraphQL 变异操作

根据 GraphQL 模式,你将实现两个变异操作 - addTagaddQuantity

addTag变异操作接受productId和一组标签作为参数,并返回Product对象。addQuantity变异操作接受productId和要添加的数量,并返回Product

让我们将此实现添加到现有的ProductDatafetcher类中,如下所示:

// rest of the ProductDatafetcher class code@DgsMutation(field = MUTATION.AddTag)
public Product addTags(
    @InputArgument("productId") String productId,
    @InputArgument(value = "tags", collectionType =
         TagInput.class) List<TagInput> tags) {
  return tagService.addTags(productId, tags);
}
@DgsMutation(field = MUTATION.AddQuantity)
public Product addQuantity(
      @InputArgument("productId") String productId,
      @InputArgument(value = "quantity") int qty) {
  return productService.addQuantity(productId, qty);
}
// rest of the ProductDatafetcher class code

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/main/java/com/packt/modern/api/datafetchers/ProductsDatafetcher.java

在这里,这些签名遵循 GraphQL 模式中编写的相应突变(mutations)。您正在使用另一个 DGS 框架@DgsMutation注解,这是一种标记在方法上以表示它们为数据获取方法(data fetcher method)的@DgsData注解。默认情况下,@DgsMutation注解将Mutation值设置为parentType属性。您只需在此注解中设置field属性。两个方法都有其各自的值设置为@DgsMutation注解中的field属性。

注意,用于标签的@InputArgument注解使用另一个collectionType属性,该属性用于设置输入类型。当输入类型不是标量(scalar)时,它是必需的。如果不使用它,您将得到一个错误。因此,确保在具有非标量类型输入时始终使用collectionType属性。

这些方法使用标签和产品服务来执行请求的操作。到目前为止,您还没有将标签服务添加到ProductDatafetcher类中。因此,您需要首先添加TagService,如下面的代码块所示:

// rest of the ProductDatafetcher class codeprivate final TagService tagService;
public ProductDatafetcher(ProductService productService,
     TagService tagService) {
  this.productService = productService;
  this.tagService = tagService;
}
// rest of the ProductDatafetcher class code

在这里,TagService豆(bean)已经通过构造函数注入。

现在,您需要在TagService中实现addTag()方法,在ProductService中实现addQuantity方法。这两个接口及其实现都很直接,将调用传递给仓库以执行操作。您可以在 GitHub 代码仓库中找到TagServiceProductService类的完整源代码(github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/main/java/com/packt/modern/api/services)。

让我们也将这两个方法添加到Repository接口中,如下面的代码块所示:

 // rest of the Repository class code Product addTags(String productId, List<TagInput> tags);
 Product addQuantity(String productId, int qty);
 // rest of the Repository class code

src/main/java/com/packt/modern/api/repository/Repository.java接口中的这些签名也遵循 GraphQL 模式中编写的相应突变。

让我们首先在src/main/java/com/packt/modern/api/repository/InMemRepository.java类中实现addTags()方法,如下面的代码块所示:

@Overridepublic Product addTags(String productId, List<TagInput> tags) {
  if (Strings.isBlank(productId)) {
    throw new RuntimeException("Invalid Product ID.");
  }
  Product product = productEntities.get(productId);
  if (Objects.isNull(product)) {
    throw new RuntimeException("Product not found.");
  }
  if (tags != null && !tags.isEmpty()) {
    List<String> newTags = tags.stream().map(
         t -> t.getName()).collect(toList());
    List<String> existingTags = product.getTags().stream()
         .map(t -> t.getName())
         .collect(toList());
    newTags.stream().forEach(nt -> {
      if (!existingTags.contains(nt)) {
        product.getTags().add(Tag.newBuilder()
          .id(UUID.randomUUID().toString())
        .name(nt).build());
      }
    });
    productEntities.put(product.getId(), product);
  }
  return product;
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/main/java/com/packt/modern/api/repository/InMemRepository.java

在这里,你首先对productIdtags参数进行验证。如果一切顺利,那么你将标签添加到产品中,更新并发映射,并返回更新后的产品。

你已经完成了 GraphQL 突变的实现。你现在可以测试你的更改了。在运行测试之前,你需要构建应用程序。让我们使用以下命令来构建应用程序:

 $ gradlew clean build

一旦构建成功,你可以运行以下命令来运行应用程序:

 $ java –jar build/libs/chapter14-0.0.1-SNAPSHOT.jar

如果你没有对端口号进行任何更改,应用程序应该运行在默认端口8080

现在,你可以打开一个浏览器窗口,使用以下 URL 打开GraphiQLlocalhost:8080/graphiql(DGS 框架的一部分)。如果需要,相应地更改主机/端口。

你可以使用以下 GraphQL 请求来执行addTag突变:

mutation {   addTag(productId: "a1s2d3f4-0",
          tags: [{name: "new Tags..."}]) {
     id
     name
     price
     description
     tags {
       id
       name
     }
   }
 }

在这里,你正在给给定的productId添加标签;因此,你需要将productIdtags作为参数传递。你可以使用以下 GraphQL 请求来执行addQuantity突变:

mutation {   addQuantity(productId: "a1s2d3f4-0", quantity: 10) {
     id
     name
     description
     price
     count
     tags {
       id
       name
     }
   }
 }

在这里,你将productIdquantity作为参数传递。你已经学会了如何在 GraphQL 服务器中实现 GraphQL 突变。让我们在下一节中实现 GraphQL 订阅。

实现和测试 GraphQL 订阅

订阅是另一个 GraphQL 根类型,当特定事件发生时,它会向订阅者(客户端)发送对象。

假设一个在线商店在产品的库存达到一定水平时对产品提供折扣。你不能手动跟踪每个产品的数量,然后进行计算并触发折扣。为了更快地完成这些事情(或减少人工干预),这就是你可以使用订阅的地方。

通过addQuantity()突变对产品库存(数量)的每次更改都应触发事件,并且订阅者应接收到更新后的产品以及数量。然后,订阅者可以放置逻辑并自动化这个过程。

让我们编写一个订阅,它将发送更新后的产品对象给订阅者。你将使用响应式流和 WebSocket 来实现这个功能。

你需要启用 CORS。让我们通过在application.properties文件中添加以下属性来启用它:

management.endpoints.web.exposure.include=health,metricsgraphql.servlet.actuator-metrics=true
graphql.servlet.tracing-enabled=false
graphql.servlet.corsEnabled=true

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/main/resources/application.properties

在这里,你还将 GraphQL 的 actuator 指标和跟踪启用,并公开健康和指标 actuator 端点。

build.gradle中,你有graphql-dgs-subscriptions-websockets-autoconfigure来自动配置 WebSocket,这对于基于 WebSocket 的 GraphQL 订阅是必需的。

你可以将以下订阅数据获取器添加到ProductDatafetcher类中,如下面的代码所示:

// rest of the ProductDatafetcher class code @DgsSubscription(field = SUBSCRIPTION.QuantityChanged)public Publisher<Product> quantityChanged(
    @InputArgument("productId") String productId) {
  return productService.gerProductPublisher();
}
// rest of the ProductDatafetcher class code

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/main/java/com/packt/modern/api/datafetchers/ProductDatafetcher.java

这里,你正在使用另一个 DGS 框架注解@DgsSubscription,它是一种标记在方法上的@DgsData注解,表示它是一个数据获取方法。默认情况下,@DgsSubscription注解将Subscription值设置为parentType属性。你只需在此注解中设置field属性。通过将field设置为quantityChanged,你正在指示 DGS 框架在调用quantityChanged订阅请求时使用此方法。

Subscription方法返回Publisher实例,它可以向多个订阅者发送未绑定数量的对象(在这种情况下,Product实例)。因此,客户端只需订阅产品发布者。

你需要在ProductService接口中添加一个新的方法,并在ProductServiceImpl类中实现它。ProductService接口及其实现的方法签名很简单。它将调用传递给仓库以执行操作。你可以在书中 GitHub 代码仓库中查看源代码。

实际工作是由仓库执行的。因此,你需要在仓库中进行一些更改,如下面的步骤所示:

  1. 首先,将以下方法签名添加到仓库接口中:

    Publisher<Product> getProductPublisher();
    
    1. 接下来,你必须在InMemRepository类中实现getProductPublisher()方法。此方法返回产品发布者,如下面的代码所示:
    public Publisher<Product> getProductPublisher() {  return productPublisher;}
    
    1. 现在,我们需要 Reactive Streams 来完成所有魔法。首先,让我们声明FluxSink<Product>ConnectableFlux<Product>(由仓库返回)变量:
    private InMemRepository constructor, as shown in the following code:
    
    

    Flux<Product>是一个产品流发布者,它将接力棒传递给productsStreamFluxSink)以发射下一个信号,随后是onError()onComplete()事件。这意味着productsStream应该在产品数量发生变化时发射信号。当Flux<Product>调用publish()方法时,它返回一个connectableFlux实例,该实例被分配给productPublisher(由订阅返回的那个)。

    
    
  2. 你几乎完成了设置。你只需在产品发生变化时发射信号(产品)。让我们在addQuantity()方法返回产品之前添加以下突出显示的行,如下面的代码所示:

    product.setCount(product.getCount() + qty);productEntities.put(product.getId(), product);productsStream.next(product);return product;
    

你已经完成了quantityChanged订阅的实现。你可以接下来进行测试。

在运行测试之前,您需要构建应用程序。让我们使用以下命令来构建应用程序:

 $ gradlew clean build

一旦构建成功,您可以使用以下命令来运行应用程序:

 $ java –jar build/libs/chapter14-0.0.1-SNAPSHOT.jar

如果您没有更改端口号设置,应用程序应该运行在默认端口 8080

在测试 GraphQL 订阅之前,您需要了解 GraphQL 通过 WebSocket 的订阅协议。

理解 GraphQL 的 WebSocket 子协议

您在本章中实现了 GraphQL 通过 WebSocket 的订阅。在基于 WebSocket 的订阅实现中,网络套接字是 GraphQL 服务器和客户端之间的主要通信通道。

graphql-dgs-subscriptions-websockets-autoconfigure 依赖项的当前实现(版本 6.0.5)使用了 graphql-transport-ws 子协议规范。在这个子协议中,消息使用 JSON 格式表示,并且在网络中,这些 JSON 消息被 stringified。服务器和客户端都应该符合这种消息结构。

存在以下类型的消息(以下代码来自 DGS 框架的 Kotlin):

object MessageType {    const val CONNECTION_INIT = "connection_init"
    const val CONNECTION_ACK = "connection_ack"
    const val PING = "ping"
    const val PONG = "pong"
    const val SUBSCRIBE = "subscribe"
    const val NEXT = "next"
    const val ERROR = "error"
    const val COMPLETE = "complete"
}

通过查看消息类型,您可能已经对 GraphQL 通过 WebSocket 的订阅生命周期有了了解。让我们详细理解订阅的生命周期:

  1. CONNECTION_INIT):客户端通过发送此类消息来启动通信。连接初始化消息包含两个字段 – type'connection_init')和 payloadpayload 字段是可选字段。其结构(ConnectionInitMessage)表示如下:

    {  type: 'connection_init';  payload: Map<String, Object>; // optional}
    
    1. CONNECTION_ACK):服务器在成功初始化连接请求后发送连接确认。这意味着服务器已准备好进行订阅。其结构(ConnectionAckMessage)表示如下:
    {  type: 'connection_ack';  payload: Map<String, Any>; // optional}
    
    1. SUBSCRIBE):客户端现在可以发送 subscribe 请求。如果客户端在没有从服务器获得连接确认的情况下发送 subscribe 请求,客户端可能会收到错误 4401: Unauthorized

这个请求包含三个字段 – idtypepayload。在这里,每个新的订阅请求都应该包含一个唯一的 id;否则,服务器可能会抛出 4409: Subscriber for <unique-operation-id> already exists。服务器会跟踪 id,直到订阅处于活动状态。一旦订阅完成,客户端可以重新使用 id。此消息类型(SubscribeMessage)的结构如下:

{  id: '<unique-id>';
  type: 'subscribe';
  payload: {
    operationName: ''; // optional operation name
    query: '';  // Mandatory GraphQL subscription
    query
    variables?: Map<String, Any>; // optional
    variables
    extensions?: Map<String, Any>; // optional
  };
}
  1. NEXT):在成功的订阅操作之后,客户端从服务器接收类型为 NEXT 的消息,这些消息包含客户端订阅的操作相关的数据。数据是 payload 字段的一部分。服务器会持续向客户端发送这些消息类型,直到 GraphQL 订阅事件发生。一旦操作完成,服务器会将完整消息发送给客户端。其消息类型(NextMessage)由以下结构表示:

    {  id: '<unique-id>'; // one sent with subscribe  type: 'next';  payload: ExecutionResult;}
    
    1. COMPLETE):Complete 是一种双向消息,可以由服务器和客户端发送:
    • 客户端到服务器:当客户端想要停止监听服务器发送的消息时,客户端可以将完整消息发送给服务器。由于这是一个双向调用,当客户端发送完整请求时,客户端应该忽略正在传输中的消息。

    • 服务器到客户端:当服务器完成请求的操作时,服务器会将完整消息发送给客户端。当服务器为客户端的订阅请求发送错误消息时,服务器不会发送完整消息。

消息类型(CompleteMessage)由以下结构表示:

{  id: '<unique-id>'; // one sent with subscribe
  type: 'complete';
}
  1. ERROR):当服务器遇到任何操作执行错误时,服务器会发送错误消息。其类型(ErrorMessage)由以下结构表示:

    {  id: '<unique-id>';  type: 'error';  payload: GraphQLError[];}
    
    1. PINGPONG:这些是双向消息类型,由服务器和客户端发送。如果客户端发送 ping 消息,服务器应立即发送 pong 消息,反之亦然。这些消息对于检测网络问题和网络延迟很有用。ping (PingMessage) 和 pong (PongMessage) 都包含以下结构:
    {  type: String; // either 'ping' or 'pong'  payload: Map<String, Object>; // optional}
    

理解订阅生命周期将帮助您彻底测试订阅。

您可以使用任何支持 GraphQL 订阅测试的工具。我们将使用 Insomnia WebSocket 请求客户端进行测试 – 这是一种比较原始的方法,以便您理解 GraphQL 订阅的完整生命周期。

图 14.3 – Insomnia 客户端中的 GraphQL 订阅 connection_init 调用

图 14.3 – Insomnia 客户端中的 GraphQL 订阅 connection_init 调用

使用 Insomnia WebSocket 测试 GraphQL 订阅

让我们执行以下步骤以手动测试订阅:

  1. 首先,通过使用位于左上角顶部的(+)下拉菜单,使用 WebSocket 请求 添加一个新的请求。

  2. 然后在 URL 框中添加以下 URL:

    ws://localhost:8080/subscriptions
    
    1. 然后,在 Headers 选项卡中添加以下头部信息:
    Connection: UpgradeUpgrade: websocketdnt: 1accept: */*accept-encoding: gzip, deflate, brhost: localhost:8080origin: http://localhost:8080sec-fetch-dest: websocketsec-fetch-mode: websocketsec-fetch-site: same-originSec-WebSocket-Protocol: graphql-transport-wsSec-WebSocket-Version: 13Sec-WebSocket-Key: 3dcYr9va5icM8VcKuCr/KA==Sec-WebSocket-Extensions: permessage-deflate
    

在这里,通过头部信息,您将连接升级为 WebSocket;因此,服务器会发送 101 Switching Protocol 响应。您还可以看到您正在使用 graphql-transport-ws GraphQL 子协议。

  1. 然后,在 JSON 选项卡中添加以下用于连接初始化的负载(参见 图 14**.3):

    {  "type": "connection_init",  "payload": {     "variables": {},  "extensions": {},  "operationName": null,  "query":"subscription { quantityChanged { id name price count} }"}}
    
    1. 然后,点击发送按钮(不要点击连接按钮 – 如果你点击了,那么它需要随后再点击一次发送)。
  2. 在成功连接后,你将收到服务器发送的以下确认消息。这意味着服务器已准备好服务订阅请求(如图14.3所示):

    {  "payload": {},  "type": "connection_ack"}
    
    1. 然后,在JSON选项卡中使用以下有效载荷:
    {  "id": "b",  "type": "subscribe",  "payload": {     "variables": {},  "extensions": {},  "operationName": null,      "operationName": null,"query":"subscription { quantityChanged { id name price count} }"}}
    

在这里,你正在向消息添加一个唯一 ID。消息类型设置为subscribe。你可以发送一个subscribe消息,因为客户端收到了连接确认。query字段包含 GraphQL 订阅查询。

  1. 然后,再次点击发送按钮(不要点击连接按钮 – 如果你点击了,那么它需要随后再点击一次发送)。

  2. 在点击addQuantity突变以使用以下有效载荷触发事件的发布后:

    mutation {  addQuantity(productId: "a1s2d3f4-0", quantity: 10) {    id    name    price    count  }}
    
    1. 在成功调用突变后,你可以在 Insomnia 客户端中检查订阅输出。你将找到一个显示增加数量的传入 JSON 消息,如图14.4所示。
  3. 你可以重复步骤 9 和 10以获取(NEXT类型)消息。

  4. 一旦完成,你可以发送以下 JSON 有效载荷以完成调用,如图14.4所示:

    {  "id": "b",  "type": "complete"}
    

图 14.4 – 在 Insomnia 客户端中 GraphQL 订阅的下一个和完成调用

图 14.4 – 在 Insomnia 客户端中 GraphQL 订阅的下一个和完成调用

这就是你可以实现和测试 GraphQL WebSocket 订阅的方式。你将在本章的“使用自动化测试代码测试 GraphQL 订阅”小节中自动化测试 GraphQL 订阅。

接下来,你应该了解有助于实现跟踪、日志记录和指标收集的仪表。让我们在下一个小节中讨论这个问题。

仪表化 GraphQL API

GraphQL Java 库支持 GraphQL API 的仪表化。这可以用于支持指标、跟踪和日志记录。DGS 框架也使用它。你只需将仪表化类标记为 Spring 的@Component注解即可。

仪表化 Bean 可以使用graphql.execution.instrumentation.Instumentation接口实现。在这里,你必须编写样板代码,这可能会增加你的单元测试自动化代码。另一种更简单的方法是扩展SimpleInstrumentation类,它为你做了简单的实现。然而,你可以覆盖方法以进行自定义实现。

让我们添加记录数据获取器和完成 GraphQL 请求处理的耗时情况的仪表。这个指标可能有助于你微调性能并识别耗时较长的字段。

在添加跟踪之前,让我们在响应中添加自定义标题。

添加自定义标题

让我们在instrumentation包中创建DemoInstrumentation.java文件,并添加以下代码:

@Componentpublic class DemoInstrumentation
               extends SimpleInstrumentation {
  @NotNull
  @Override
  public CompletableFuture<ExecutionResult>
     instrumentExecutionResult(ExecutionResult exeResult,
               InstrumentationExecutionParameters params,
               InstrumentationState state) {
    HttpHeaders responseHeaders = new HttpHeaders();
    responseHeaders.add("myHeader", "hello");
    return super.instrumentExecutionResult(DgsExecutionResult
        .builder().executionResult(execResult)
        .headers(responseHeaders).build(),
        params,
        state
    );
  }
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/main/java/com/packt/modern/api/instrumentation/DemoInstrumentation.java

这里,这个类扩展了SimpleInstrumentation,并通过标记为@Component创建为 Spring bean。SimpleInstrumentation类允许你仪表化执行结果。在这里,你可以看到你已经在响应中添加了自定义头。让我们测试它。

在添加了之前的代码后,你可以构建并执行项目,然后执行以下突变:

mutation {  addQuantity(productId: "a1s2d3f4-0", quantity: 10) {
    id
    name
    price
    count
  }
}

你将在响应头中找到仪表化的myHeader头及其值。

现在,你可以通过向你的项目中添加以下 bean 来在你的响应中仪表化跟踪信息:

@Configurationpublic class InstrumentationConfig {
  @Bean
  @ConditionalOnProperty( prefix = "graphql.tracing",
        name = "enabled", matchIfMissing = true)
  public Instrumentation tracingInstrumentation(){
    return new TracingInstrumentation();
  }
}

github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/main/java/com/packt/modern/api/instrumentation/InstrumentationConfig.java

此配置完成了魔法。你必须记住,你需要在build.gradle文件中添加com.netflix.graphql.dgs:graphql-dgs-spring-boot-micrometer以及 Spring Actuator 依赖项才能使其工作。

之前的代码将 DGS 框架提供的执行结果指标添加到 GraphQL API 响应中。此指标包括跟踪时间和持续时间、验证时间和持续时间、解析器的信息等。

一旦你设置了这种仪器并执行任何查询或突变,结果将包括由之前代码中创建的Instrumentation bean(GraphQL Tracing)仪表化的扩展字段,即结果指标。

让我们在 GraphiQL(http://localhost:8080/graphiql)中执行以下突变:

mutation {  addQuantity(productId: "a1s2d3f4-0", quantity: 10) {
    id
    name
    price
    count
  }
}

之前的突变将提供以下带有仪表化指标的响应:

{  "data": {
    "addQuantity": {
      "id": "a1s2d3f4-0",
      // output truncated for brevity
    }
  },
  "extensions": {
    "tracing": {
      "version": 1,
      "startTime": "2023-05-07T19:04:42.032422Z",
      "endTime": "2023-05-07T19:04:42.170516Z",
      "duration": 138103974,
      "parsing": {
        "startOffset": 11023640,
        "duration": 7465319
      },
      "validation": {
        "startOffset": 31688145,
        "duration": 20146090
      },
      "execution": {
        "resolvers": [
          {
            "path": [
              "addQuantity"
            ],
            "parentType": "Mutation",
            "returnType": "Product",
            "fieldName": "addQuantity",
            "startOffset": 92045595,
            "duration": 24507328
          },
          // output truncated for brevity
        ]
     }
    }
  }
}

在这里,你可以看到它不仅返回data,还在extensions字段中提供了仪表化的指标。请注意,你应该只在开发环境中启用此仪表化,以微调 GraphQL 实现和基准测试,而在生产环境中应禁用。

让我们了解下一小节中关于仪表化指标的相关内容。

与 Micrometer 的集成

你已经在build.gradle中将graphql-dgs-spring-boot-micrometer作为依赖项之一添加。这个库提供了开箱即用的 GraphQL 指标,如gql.querygql-resolver等。

您可以通过在application.properties文件中添加以下行来暴露metrics端点:

management.endpoints.web.gql.error:

http://localhost:8080/actuator/metrics


 This endpoint displays the list of available metrics in your application, including GraphQL metrics.
The following four types of GraphQL metrics are provided by the DGS framework, which may help you to find out the code responsible for poor performance:

*   `gql.query`: This captures the time taken by the GraphQL query or mutation.
*   `gql.resolver`: This captures the time taken by each data fetcher invocation.
*   `gql.error`: A single GraphQL request can have multiple errors. This metric captures the number of errors encountered during the GraphQL request execution. It will only be available when there are errors in execution.
*   `gql.dataLoader`: This captures the time taken by the data loader invocation for the batch of queries.

The available GraphQL metrics from the actuator metrics endpoint output can be accessed using the following endpoint call:

http://localhost:8080/actuator/metrics/gql.query


 It may provide output as shown here:

{  "name": "gql.query",

"baseUnit": "seconds",

"measurements": [{

"statistic": "COUNT",  "value": 4.0

};

"statistic": "TOTAL_TIME", "value": 1.403888175

}, {

"statistic": "MAX", "value": 0.0

}];

"availableTags": [{

"tag": "gql.query.sig.hash",

"values": ["10e750742768cb7c428699…",

"a750f4b9bb5d40f2d23b01…"]

}, {

"tag": "gql.operation",

"values": ["SUBSCRIPTION", "MUTATION"]

}, {

"tag": "gql.query.complexity", "values": ["10"]

}, {

"tag": "gql.operation.name", "values": ["anonymous"]

}, {

"tag": "outcome", "values": ["success", "failure"]

}]

}


You can see that it provides the total elapsed time, a number of requests count, and the max time taken by the query/mutation. It also provides tags. These tags can be customized if required by implementing the following interfaces – `DgsContextualTagCustomizer` (to customize common tags such as application profile and version or deployment environment), `DgsExecutionTagCustomizer` (to customize the tags related to execution results), and `DgsFieldFetchTagCustomizer` (to customize the tags related to data fetchers).
You have learned how to instrument the GraphQL APIs in this section. Let’s explore automating the testing of GraphQL code in the next section.
Test automation
The DGS framework provides classes and utilities that facilitate the automation of GraphQL API tests.
Create a new file called `ProductDatafetcherTest.java` inside the `datafetchers` package in the `test` directory and add the following code:

@SpringBootTest(classes = { DgsAutoConfiguration.class, ProductDatafetcher.class,BigDecimalScalar.class })

public class ProductDatafetcherTest {

private final InMemRepository repo = new InMemRepository();

private final int TEN = 10;

@Autowired

private DgsQueryExecutor dgsQueryExecutor;

@MockBean

private ProductService productService;

@MockBean

private TagService tagService;

// continue …


[`github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/test/java/com/packt/modern/api/datafetchers/ProductDatafetcherTest.java`](https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/tree/dev/Chapter14/src/test/java/com/packt/modern/api/datafetchers/ProductDatafetcherTest.java)
Here, you are using the `@SpringBootTest` annotation to auto-configure a Spring Boot based test. You are limiting the Spring context by providing specific classes such as `DgsAutoConfiguration`, `ProductDatafetcher`, and `BigDecimalScalar`. You should add only those classes here that are required to perform the test.
Then, you are auto-wiring the `DgsQueryExecutor` class, which provides the query execution capability to your test. After that, you add two Spring-injected mock beans for the `Product` and `Tag` services.
You are ready with the configuration and instances you need to run the tests.
Let’s add the setup method that is required before running the tests. You can add the following method for this purpose in `ProductDatafetcherTest.java`:

@BeforeEachpublic void beforeEach() {

List tags = new ArrayList<>();

tags.add(Tag.newBuilder().id("tag1").name("Tag 1").build());

Product product = Product.newBuilder().id("any")

.name("mock title").description("mock description")

.price(BigDecimal.valueOf(20.20)).count(100)

.tags(tags).build();

given(productService.getProduct

("any")).willReturn(product);

tags.add(Tag.newBuilder().id("tag2")

.name("addTags").build());

product.setTags(tags);

given(tagService.addTags("any",

List.of(TagInput.newBuilder().name("addTags").build())))

.willAnswer(invocation -> product);

}


Here, you are using Mockito to stub the `productService.getProduct()` and `tagService.addTags()` calls.
You are done with the setup. Let’s run our first test, which will fetch the JSON object after running the GraphQL `product` query next.
Testing GraphQL queries
Let’s add the following code to `ProductDatafetcherTest.java` to test the `product` query:

@Test@DisplayName("验证查询'product'返回的 JSON")

public void product() {

String name = dgsQueryExecutor.executeAndExtractJsonPath(

"{product(id: "any"){ name }}",

"data.product.name");

assertThat(name).contains("mock title");

}


Here, the code is using the `DgsQueryExecutor` instance to execute the `product` query and extract the JSON property. Then, it validates the name extracted from the JSON and compares it with the value set in the `beforeEach()` method.
Next, you’ll test the `product` query again, but this time, to test the exception.
You can add the following code to `ProductDatafetcherTest.java` to test the exception thrown by the `product` query:

@Test@DisplayName("验证查询产品时的异常 - 无效 ID")

public void productWithException() {

given(productService.getProduct("any"))

.willThrow(new RuntimeException

("无效的产品 ID。"));

ExecutionResult res = dgsQueryExecutor.execute(

"{ product (id: "any") { name }}");

verify(productService, times(1)).getProduct("any");

assertThat(res.getErrors()).isNotEmpty();

assertThat(res.getErrors().get(0).getMessage()).isEqualTo(

"java.lang.RuntimeException:

"无效的产品 ID。");

}


Here, the `productService` method is stubbed to throw the exception. When `DgsQueryExecutor` runs, the Spring-injected mock bean uses the stubbed method to throw the exception that is being asserted here.
Next, let’s query `product` again, this time to explore `GraphQLQueryRequest`, which allows you to form the GraphQL query in a fluent way. The `GraphQLQueryRequest` construction takes two arguments – first, the instance of `GraphVQLQuery`, which can be a query/mutation or subscription, and second, the projection root type of `BaseProjectionNode`, which allows you to select the fields.
Let’s add the following code to `ProductDatafetcherTest.java` to test the `product` query using `GraphQLQueryRequest`:

@Test@DisplayName("验证使用 GraphQLQueryRequest 的 JSON")

void productsWithQueryApi() {

GraphQLQueryRequest gqlRequest = new GraphQLQueryRequest(

ProductGraphQLQuery.newRequest().id("any").build(),

new ProductProjectionRoot().id().name());

String name = dgsQueryExecutor.executeAndExtractJsonPath(

gqlRequest.serialize(), "data.product.name");

assertThat(name).contains("mock title");

}


Here, the `ProductGraphQLQuery` class is part of the auto-generated code by the DGS GraphQL Gradle plugin.
One thing we have not yet tested in previous tests is verifying the subfields in the `tags` field of `product`.
Let’s verify it in the next test case. Add the following code in `ProductDatafetcherTest.java` to verify the tags:

@Test@DisplayName("验证查询'product'返回的标签")

void productsWithTags() {

GraphQLQueryRequest gqlRequest = new GraphQLQueryRequest(

ProductGraphQLQuery.newRequest().id("任何").build(),

new ProductProjectionRoot().id().name().tags()

.id().name());

Product product = dgsQueryExecutor

.executeAndExtractJsonPathAsObject(gqlRequest.serialize(),

"data.product", new TypeRef<>() {});

断言 product.getId()等于"任何";

断言 product.getName())等于"模拟标题";

断言 product.getTags().的大小等于 2;

断言 product.getTags().get(0).getName())

.isEqualTo("标签 1");

}


Here, you can see that you have to use a third argument (`TypeRef`) in the `executeAndExtractJsonPathAsObject()` method if you want to query the subfields. If you don’t use it, you will get an error.
You are done with GraphQL query testing. Let’s move on to testing the mutations in the next subsection.
Testing GraphQL mutations
Testing a GraphQL mutation is no different from testing GraphQL queries.
Let’s test the `addTag` mutation to `ProductDatafetcherTest.java` as shown in the following code:

@Test@DisplayName("验证突变 'addTags'")

void addTagsMutation() {

GraphQLQueryRequest gqlRequest = new GraphQLQueryRequest(

AddTagGraphQLQuery.newRequest().productId("任何")

.tags(List.of(TagInput.newBuilder()

.name("addTags").build())).build(),

new AddTagProjectionRoot().name().count());

ExecutionResult exeResult = dgsQueryExecutor.execute(

gqlRequest.serialize());

断言 exeResult.getErrors()为空;

verify(tagService).addTags("任何", List.of(

TagInput.newBuilder().name("addTags").build()));

}


Here, the `AddTagGraphQLQuery` class is part of the code auto-generated by the DGS GraphQL Gradle plugin. You fire the request and then validate the results based on the existing configuration and setup.
Similarly, you can test the `addQuantity` mutation. Only the arguments and assertions will change; the core logic and classes will remain the same.
You can add the test to `ProductDatafetcherTest.java` as shown in the next code block to test the `addQuantity` mutation:

@Test@DisplayName("验证突变 'addQuantity'")

void addQuantityMutation() {

given(productService.addQuantity("a1s2d3f4-1", 十))

.willReturn(repo.addQuantity("a1s2d3f4-1", 十));

GraphQLQueryRequest gqlRequest = new GraphQLQueryRequest(

AddQuantityGraphQLQuery.newRequest()

.productId("a1s2d3f4-1").quantity(十).build(),

new AddQuantityProjectionRoot().name().count());

ExecutionResult exeResult = dgsQueryExecutor

.execute(gqlRequest.serialize());

断言 executionResult.getErrors()为空;

Object obj = executionResult.getData();

断言 obj 不为 null;

Map<String, Object> data = (Map)((Map

)exeResult.getData()).get(MUTATION.AddQuantity);

org.hamcrest.MatcherAssert.assertThat(

(Integer) data.get("count"), 大于(十));

}


You are done with GraphQL mutation testing. Let’s move on to testing subscriptions in the next subsection.
Testing GraphQL subscriptions using automated test code
Testing a subscription needs extra effort and care, as you can see in the following code, which performs the test for the `quantityChanged` subscription. It uses the existing `addQuantity` mutation to trigger the subscription publisher that sends a `product` object on each call. You capture the product of the first call and store the value of the `count` field. Then, use it to perform the assertion as shown in the following code:

@Test@DisplayName("验证订阅 'quantityChanged'")

void reviewSubscription() {

given(productService.gerProductPublisher())

.willReturn(repo.getProductPublisher());

ExecutionResult exeResult = dgsQueryExecutor.execute(

"订阅 {quantityChanged

{id name price count}}");

Publisher pub = exeResult.getData();

List products = new CopyOnWriteArrayList<>();

pub.subscribe(new Subscriber<>() {

@Override

public void onSubscribe(Subscription s) {s.request(2);}

@Override

public void onNext(ExecutionResult result) {

如果 result.getErrors().的大小大于 0 {

System.out.println(result.getErrors());

}

Map<String, Object> data = result.getData();

products.add(

new ObjectMapper().convertValue(data.get(

SUBSCRIPTION.QuantityChanged), Product.class));

}

@Override

public void onError(Throwable t) {}

@Override

public void onComplete() {}

});

addQuantityMutation();

Integer count = products.get(0).getCount();

addQuantityMutation();

断言 products.get(0).getId()

.isEqualTo(products.get(1).getId());

断言 products.get(1).getCount())

.isEqualTo(count + 十);

}

}


Here, the core logic lies in the subscription that is done by calling the `publisher.subscribe()` method (check the highlighted line). You know that the GraphQL `quantityChanged` subscription returns the publisher. This publisher is received from the `data` field of the execution result.
The publisher subscribes to the stream by passing a `Subscriber` object, which is created on the fly. The subscriber’s `onNext()` method is used to receive the product sent by the GraphQL server. These objects are pushed into the list. Then, you use this list to perform the assertion.
Summary
In this chapter, you learned about the different ways of implementing the GraphQL server, including federated GraphQL services. You have also explored the complete standalone GraphQL server implementation, which performs the following operations:

*   Writing the GraphQL schema
*   Implementing the GraphQL query APIs
*   Implementing the GraphQL mutation APIs
*   Implementing the GraphQL subscription APIs
*   Writing the data loaders to solve the N+1 problem
*   Adding custom scalar types
*   Adding the GraphQL API’s instrumentation
*   Writing the GraphQL API’s test automation using Netflix’s DGS framework

You learned about GraphQL API implementation using Spring and Spring Boot skills that will help you implement GraphQL APIs for your work assignments and personal projects.
Questions

1.  Why should you prefer frameworks such as Netflix’s DGS in place of the `graphql-java` library to implement GraphQL APIs?
2.  What are federated GraphQL services?

Answers

1.  You should prefer a framework such as Netflix DGS in place of the `graphql-java` library to implement GraphQL APIs because it bootstraps the development and avoids writing boilerplate code.

Apart from the ease of development, the framework uses `graphql-java` internally; therefore, it keeps itself in sync with the GraphQL specification’s Java implementation. It also supports developing federated GraphQL services.
It also provides plugins, the Java client, and testing utilities that help you to automate the development. The Netflix DGS framework is well tested and has been used by Netflix in production for quite some time.

1.  A federated GraphQL service contains a single distributed graph exposed using a gateway. Clients call the gateway, which is an entry point to the system. A data graph will be distributed among multiple services and each service can maintain its own development and release cycle independently. Having said that, federated GraphQL services still follow the OneGraph principle. Therefore, the client would query a single endpoint for fetching any part of the graph.

Further reading

*   GraphQL Java implementation: [`www.graphql-java.com/`](https://www.graphql-java.com/) and [`github.com/graphql-java/graphql-java`](https://github.com/graphql-java/graphql-java)
*   Netflix DGS documentation: [`netflix.github.io/dgs/getting-started/`](https://netflix.github.io/dgs/getting-started/)
*   *Full-Stack Web Development with GraphQL and* *React*: [`www.packtpub.com/product/full-stack-web-development-with-graphql-and-react-second-edition/9781801077880`](https://www.packtpub.com/product/full-stack-web-development-with-graphql-and-react-second-edition/9781801077880)


posted @ 2025-09-11 09:51  绝不原创的飞龙  阅读(25)  评论(0)    收藏  举报