精通-Java-REST-Wen-服务-全-
精通 Java REST Wen 服务(全)
原文:
zh.annas-archive.org/md5/e09c9b8374beaccf3bc19d2fd0e3cb9b
译者:飞龙
前言
这本书是使用现代 Java 框架设计、构建、记录、测试和部署 RESTful API 的实用指南。REST API 已成为实现系统间通信的标准方式,尤其是在微服务和其它分布式架构中。Java 是后端开发中最广泛使用的语言之一,了解如何使用 Java 构建有效的 API 是当今开发者的一项关键技能。
在整个章节中,您将探索使用 Spring Boot、OpenAPI 和其他流行工具和库的实战技巧。您还将接触到容器化、可观察性、API 安全和性能调优——这些都是创建健壮、可扩展和可维护 API 所需的一切。无论您是在初创公司还是企业工作,这里涵盖的技能都将帮助您提供符合现代开发标准的优质后端服务。
本书面向对象
这本书非常适合有几年经验的 Java 后端开发者,他们希望提高自己 REST API 的设计、性能和可扩展性。它也是参与设计分布式系统的技术负责人和软件架构师的有价值资源。
本书涵盖内容
第一章 ,理解 RESTful 核心概念,介绍了 REST 架构的基础原则,并解释了 RESTful API 的不同成熟度水平。它还涵盖了 JSON 作为标准数据格式的作用,概述了设计有效 API 的实用指南,并探讨了 REST 在实际系统中的应用的常见用例。
第二章 ,使用 Spring 公开 RESTful API,专注于使用 Spring Boot 设计和实现 RESTful API。您将从设计一个简单的产品 API 开始,然后逐步使用 Spring Boot(Java 基于 Web 应用中最受欢迎的框架之一)构建它。本章提供了将 REST 概念转化为工作 API 的实战经验。
第三章 ,有效记录您的 API,探讨了在构建可靠和可维护的服务中,清晰和准确的 API 文档的重要性。您将学习如何使用 OpenAPI 标准化您的 API 文档,并理解规范优先和代码优先方法之间的区别。本章还将指导您记录之前介绍的产品 API,确保它对其他开发者和系统既可理解又可使用。
第四章 ,从 OpenAPI 生成代码,指导您基于 OpenAPI 规范创建完整的 API。您将从定义订单管理 API 规范开始,然后生成必要的代码以启动开发。本章还涵盖了组织项目的包结构、实现 API 控制器以及集成前面介绍的订单管理 API 和产品 API。
第五章 ,管理 API 演变,展示了如何处理 API 的更改和更新,而不会干扰现有用户。您将了解不同的版本策略,了解如何在产品 API 中实现版本控制,并探索管理 API 演变的最佳实践,以确保您的服务保持可靠和向后兼容。
第六章 ,高级 API 概念和实现,深入探讨了构建稳健且用户友好的 API 的关键技术。您将探索高级数据处理方法,如分页、过滤和通过 REST API 的文件上传/下载。本章还涵盖了 HATEOAS 以改善 API 导航,并介绍了弹性模式,使您的 API 在现实世界场景中更加可靠和容错。
第七章 ,保护您的 RESTful API,专注于保护您的 API 免受未经授权的访问和安全威胁。您将了解身份验证和授权技术,探索 OWASP 的关键安全原则,并了解如何通过管理常见漏洞和暴露(CVEs)来应对常见漏洞,以确保您的 API 安全可靠。
第八章 ,稳健 API 的测试策略,探讨了传统 API 测试方法和生成式 AI 对测试过程的影响。您将了解 AI 工具如 ChatGPT 如何改变测试创建过程,使其更快、更智能、更有效,同时仍涵盖构建可靠 API 所必需的基本测试类型、工具和最佳实践。
第九章 ,监控和可观察性,深入探讨了在 RESTful API 中进行日志记录和跟踪的最佳实践。您将了解结构化日志、关联 ID 和集中式过滤器如何帮助进行有效的故障排除,而像 Micrometer 和 OpenTelemetry 这样的分布式跟踪工具则提供了对分布式系统中服务性能和请求流的深入可见性。
第十章,扩展和性能优化技术,涵盖了提高 RESTful API 速度和可扩展性的基本策略。您将了解以性能为重点的设计、利用 Java 的虚拟线程、有效使用基础设施以及通过负载测试验证改进,以确保您的 API 能够高效地处理不断增长的需求。
第十一章,构建 RESTful API 的替代 Java 框架,提供了对 Spring Boot 之外的流行 Java 框架的高级概述。您将探索标准化的好处以及命令式和响应式编程之间的选择,并了解 Java EE、Jakarta EE 和 MicroProfile。本章还包括使用 Quarkus 和 Helidon 的实用示例,展示 REST 原则如何应用于不同的工具。
第十二章,部署 API,指导您将 Java RESTful API 从开发阶段迁移到生产阶段。您将探索部署准备、使用 Docker 进行容器化以及 PaaS 平台的使用。本章侧重于实用、适合初学者的工作流程,为未来更高级的 DevOps 实践奠定坚实基础。
要充分利用本书
本书主要面向使用或希望使用 REST API 的后端开发者。它也对希望深入了解 API 监控、部署、以健康方式管理 API 增长、避免破坏性更改以及提高弹性和安全性的架构师和技术负责人有所帮助。
下载示例代码文件
本书代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Mastering-RESTful-Web-Services-with-Java
。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing
找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/gbp/9781835466100
。
使用的约定
本书使用了多种文本约定。
CodeInText
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如:“要添加 Spring Data 依赖项,请在您的pom.xml
文件中包含以下条目:”。
代码块应如下设置:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
任何命令行输入或输出都应如下编写:
curl -X 'GET' \
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中会像这样显示。例如:“为了遵循**不要重复自己(DRY)**原则,我们必须更新从版本 1 开始的代码。”
警告或重要注意事项会像这样显示。
小贴士和技巧会像这样显示。
联系我们
我们欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问或有任何一般性反馈,请通过电子邮件发送给我们customercare@packt.com,并在邮件主题中提及本书的标题。
勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在此书中发现错误,我们将不胜感激,如果您能向我们报告,请访问www.packt.com/submit-errata
,点击提交勘误,并填写表格。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过电子邮件联系copyright@packt.com,并提供材料的链接。
如果您想成为一名作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packt.com/
。
分享您的想法
一旦您阅读了《精通 Java RESTful Web 服务》,我们很乐意听听您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢在路上阅读,但无法携带您的印刷书籍到处走吗?
您选择的设备是否不兼容您的电子书购买?
别担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠不会就此停止,您还可以获得独家折扣、时事通讯以及每天收件箱中的优质免费内容。
按照以下简单步骤获取好处:
- 扫描下面的二维码或访问以下链接:
packt.link/free-ebook/9781835466100
提交您的购买证明。
就这些!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件。
分享您的想法
一旦您阅读了 《精通 Java RESTful Web 服务》,我们非常期待听到您的想法!扫描下面的二维码直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区都非常重要,并将帮助我们确保我们提供高质量的内容。
第一部分
构建优秀 API 的步骤
本部分为使用 Java 构建健壮的RESTful API奠定基础。它介绍了REST架构的关键概念,通过 Spring Boot 展示了如何公开一个现实世界的 API,并涵盖了使用OpenAPI的必要文档技术。你还将学习如何从规范生成 API 代码,以简化开发并确保一致性。
本部分将涵盖以下章节:
第一章 ,理解 RESTful 核心概念
第二章 ,使用 Spring 公开 RESTful API
第三章 ,有效地记录您的 API
第四章 ,从 OpenAPI 生成代码
第一章:理解 RESTful 核心概念
Web 服务的历史是一段迷人的旅程,它记录了系统构建、分发和扩展方式的演变。
最初,将所有内容开发在单个应用程序和机器中的单体方法,多年来一直是许多人的标准架构方法。然而,将软件划分为具有单独责任的不同组件的需求,已经改变了我们设计和实现系统的方式。
自从计算机的早期阶段以来,就需要将运行在不同机器上的软件组成的分布式系统进行集成,其重要性正在增加,因为我们开发软件的方式正从单体架构转向更分布式的架构。
互联网的全球普及,尤其是万维网的兴起,推动了通信协议的标准化,例如互联网协议(IP),传输控制协议(TCP)和超文本传输协议(HTTP)。万维网的成功及其在不同设备、操作系统和应用程序上的支持,使得人们开始认为网络基础设施可以被用于连接各种应用程序。这就是采用Web 服务这一术语来使用 Web 技术创建 API 的时候。
在本书中,您将获得开发并精通创建 RESTful Web 服务所需的知识,从概念到完整实现,遵循市场上的最佳实践。
本章将为您在本书的剩余旅程中做好准备,帮助您理解表征状态转移(REST)架构和 RESTful API 服务是什么,这些术语之间的区别,原则、成熟度水平和指南,以及您将在本章中创建的项目,以实现这些知识。
本章将涵盖以下主题:
为什么选择 REST?
REST 架构原则
RESTful API 的层级
使用JavaScript 对象表示法(JSON)表示数据
开发 REST API 的指南的重要性
常见的 REST API 用例
架构对 REST API 设计的影响
REST 的替代方案
为什么选择 REST?
要理解为什么 REST 是大多数 Web 服务的占主导地位的架构风格,我们需要了解 REST 兴起之前可用的技术,以及使这种变化在分布式服务构建方式上变得如此重要的挑战。
REST 之前的时代
在 REST 之前,Web 服务领域主要由简单对象访问协议(SOAP)和可扩展标记语言-远程过程调用(XML-RPC)等协议主导。这些协议功能强大但复杂,允许客户端和服务器之间进行详细的通信。然而,由于它们的冗长性质和对开发者严格的约束,它们通常被认为很繁琐。
例如,SOAP 需要开发者编写大量的 XML 文档,包含特定的调用和响应。它因其复杂性以及调试困难而臭名昭著。同样,XML-RPC 虽然比 SOAP 简单,但对于简单的请求和响应仍然涉及显著的开销(例如,冗长和复杂的 XML 格式化、序列化和反序列化、基于文本的协议以及解析复杂性)。SOAP 和 XML-RPC 只使用 HTTP 协议作为 传输,并以各自的方式重复了协议提供的某些功能和现有网络基础设施所支持的功能。
为了克服这些挑战并改善系统之间通信的方式,REST 被创造出来,并且自其诞生以来已被广泛实施。
理解 REST
REST 在 2000 年由 Roy Fielding 博士在其博士论文《架构风格和网络软件架构设计》中提出。
这种架构风格被提出作为一种更高效、更灵活、更可扩展的替代方案,以替代当时现有的标准,如 SOAP 和 XML-RPC。
Fielding 博士的论文旨在简化创建和消费网络服务的方式,利用 HTTP 协议的现有功能。
REST 的关键原则 – 无状态、可缓存性、统一接口和客户端-服务器架构 – 被设计成使网络服务更直观,并与网络本身的设计保持一致。
我们将在 REST 架构原则 部分详细讨论这些原则中的每一个。
当我们将 REST 架构应用于网络服务,并应用所有这些关键原则时,我们可以说我们有一个 RESTful API。让我们在下一节中更好地理解这种差异。
解构 RESTful
RESTful API 代表了一种设计网络服务的方法,它遵循 REST 原则,因此它们并不相同。
虽然 REST 为构建可扩展和互操作的系统提供了理论框架,但 RESTful API 将这些原则付诸实践,使开发者能够创建易于理解、维护和扩展的强大且灵活的 API。
RESTful API 的引入标志着网络服务的一个重大转变,因为开发者由于 REST 的简单性和它促进可扩展和性能良好的网络应用开发的方式而迅速采用 REST。RESTful API 成为网络通信的骨干,为从社交媒体平台到电子商务网站的一切提供动力。
现在,我们已经清楚地理解了 REST 和 RESTful,让我们深入探讨 REST 架构的原则。这将使我们对其关键原则和如何实现它们有更清晰的理解。
REST 架构原则
到目前为止,我们只提到了 REST 的关键原则。让我们进一步深入,更详细地了解这些原则。
统一接口
统一接口是任何 REST 设计的基石,它促进了与一组给定资源交互的标准化方式。这一原则包括四个关键约束:
资源识别:每个资源,无论是文档、图像还是服务,都使用唯一的统一资源标识符(URI)进行识别。
通过表示形式操作资源:当客户端拥有资源的表示形式以及任何附加的元数据时,如果它拥有必要的权限,它可以修改或删除服务器上的资源。
自描述性消息:每条消息都包含足够的信息来描述如何处理它,这可能包括表示格式和期望的状态转换。
超媒体作为应用程序状态引擎(HATEOAS):客户端完全通过由应用程序服务器动态提供的超媒体与 RESTful 服务交互,这一概念被称为 HATEOAS。
示例:想象一个图书馆系统,其中每本书都是一个资源,通过 ISBN 号码(URI)进行标识。当你想借一本书时,你会得到一个表示形式(一张包含书籍详细信息的卡片),你用它来借阅书籍。图书馆的借阅系统会告诉你如何进行(使用自描述性消息),目录会引导你找到相关的资源(HATEOAS),例如作者的其它书籍。
客户端-服务器分离
这一原则通过将用户界面关注点与数据存储关注点分离,强制执行关注点分离。这种分离允许客户端和服务器组件独立发展,从而实现更灵活和可扩展的应用程序架构。
图 1.1 – 客户端-服务器图
示例:用户在网站上提交一个包含其数据的表单以完成借阅(客户端发送请求);这将使用POST
HTTP 动词发送 JSON 格式,并由服务器接收。服务器将接收数据,创建包含客户数据的这个新资源,存储客户数据,放置新的订单,并向用户返回适当的响应。
无状态
在 RESTful 服务中,客户端向服务器发出的每个请求都必须包含服务器完成请求所需的所有信息。服务器不存储任何关于客户端的会话状态,这意味着每个请求都是独立和隔离的。
示例:每次你在咖啡馆点咖啡时,你都会提供你完整的订单详情。咖啡师不需要记住你之前的订单;他们只需根据当前的订单制作咖啡。
缓存性
响应必须隐式或显式地定义自己为可缓存的或不可缓存的。这有助于通过减少频繁请求的资源与客户端-服务器交互来提高网络的效率。
注意,缓存可能会带来风险,即客户端可能看到资源的过时版本,尤其是如果资源频繁更改。然而,我们通常为了更好的性能而接受这种风险。
示例:每天请求数千次资源有资格进行缓存,因为这将大大减少数据库(DB)资源的使用并提高响应时间。
分层系统
RESTful 架构可能由分层层次结构组成,这可能包括负载均衡器、缓存或身份验证网关。这种分层系统确保客户端通常无法判断它是直接连接到端服务器还是连接到途中的中介。分层系统是 RESTful 架构的关键约束之一,并提供了几个好处:
模块化:分层系统允许你将应用程序组织成逻辑层,每个层都有特定的角色和责任。这使得系统更容易理解、开发和维护。
关注点分离:每一层可以专注于自己的任务。例如,一个身份验证层可以处理所有身份验证方面,而无需处理其他任何事情。这种关注点分离导致代码更清晰、更易于维护。
可互换性:如果一个层以模块化的方式设计和实现,它可以被替换或升级而不会影响其他层。这在你想更新或改进系统的特定部分时特别有用。
可伸缩性:你可以根据各自的负载和性能要求独立地扩展不同的层。例如,如果你的应用层正在经历高负载,你可以向该层添加更多服务器,而无需扩展你的数据库层。
安全性:通过将系统分层,你可以对每一层应用适当的控制措施。例如,你可以在层之间放置防火墙来控制流量并保护敏感层免受潜在攻击。
示例:当你发送一封信时,它会在到达目的地之前通过各个邮政局(层)。同样,一个 RESTful 请求可能会在客户端不知情的情况下通过安全检查和负载均衡器。
需求代码
服务器可以通过传输可执行代码来扩展客户端功能。这是一个可选功能,在 API 的上下文中很少使用,因为它难以可靠和安全地实现。
示例:一个 Web 应用程序可以要求浏览器下载并执行日历小部件。这个小部件是一段可执行代码,它扩展了浏览器的功能。
既然你已经了解了 REST 架构的原则,让我们学习如何通过 Richardson 成熟度模型解释的级别来实现 RESTful API 的辉煌。
RESTful API 的级别
理解 RESTful 概念的一种方式是通过Richardson 成熟度模型,它概述了 API 设计中遵循 REST 原则的各个级别。
该模型以 Leonard Richardson 的名字命名,他在 2008 年引入了它,该模型由四个级别组成,每个级别代表着向更 RESTful 设计的进步:
图 1.2 – Richardson 成熟度模型图
第 0 级 – 纯旧 XML (POX) 的泥潭
在这一级别,API 的特点是所有操作使用单个 URI,通常使用 HTTP POST
请求。有效载荷通常是基于 XML 的,并且没有区分不同的资源类型或 HTTP 方法。这一级别缺乏 REST 的基本原则,如资源标识和关注点分离。
第 1 级 – 资源
在这一级别,引入了资源的概念,其中系统中的每个实体都表示为一个唯一的 URI。然而,HTTP 方法并没有得到充分利用,操作通常是通过 HTTP POST
和GET
请求的组合来执行的。
虽然资源得到了标识,但 API 缺乏与 RESTful 设计相关的统一接口和可预测性。
第 2 级 – HTTP 动词
在这一级别,API 开始利用 HTTP 方法 – GET
、POST
、PUT
、DELETE
– 来对资源执行CRUD(创建、读取、更新、删除)操作。
每个 HTTP 方法对应一个特定的动作,为与 API 交互提供了一个更直观和标准化的接口。然而,超媒体链接仍然缺失,限制了 API 的可发现性和灵活性。
第 3 级 – 超媒体控件(HATEOAS)
最高级别的 RESTful,第 3 级引入了 HATEOAS。除了利用 HTTP 方法外,这一级别的 RESTful API 在响应中包括超媒体链接,允许客户端动态地导航 API。通过提供相关资源及其操作的链接,HATEOAS 减少了客户端和服务器之间的耦合,增强了灵活性和可扩展性。
Richardson 成熟度模型的每一级都代表着向更 RESTful 设计的进步,其中第 3 级是理想的终点,在这一级 API 完全拥抱 REST 原则,包括资源标识、统一接口和超媒体驱动的导航。
大多数现实生活中的 API 没有达到第 3 级,但当他们尽可能遵循较低级别的 REST 原则时,他们从标准化中受益匪浅,并利用了 HTTP 协议的功能。
许多 API,而不是实现超媒体控件,使用 OpenAPI 标准发布其支持的操作的机器可读(但静态)规范。
现在,让我们来考察 REST 服务最显著的特征之一:请求和响应是如何表示的(通常在 JSON 中),以及为什么 JSON 比 XML 更受欢迎。
使用 JSON 表示数据
JSON是一种简单的数据格式,它旨在使人类易于阅读和编写,同时也便于机器处理和创建。一个人类用户可能不会注意到的一个小特点是,JSON 不支持注释。
它基于 JavaScript 编程语言的一个子集,并且完全与语言无关,每种编程语言都有可用的解析器。
一个 JSON 文档由两种结构组成:
一组键值对:在不同的语言中,这通常实现为一个对象、记录、结构、字典、哈希表、键列表或关联数组
值的有序列表:在大多数语言中,这通常实现为一个数组、向量、列表或序列
下面是一个简单的 JSON 文档示例,用于book
对象:
{
"book": {
"title": "Mastering RESTful JSON Essentials",
"author": "Multiple Authors",
"published": true,
"edition": 1,
"tags": ["programming", "web", "json"]
}
}
"book"
字段的值是一个包含键值对的对象结构。"tags"
字段的值是一个有序的无名值列表。
那么,为什么 JSON 被首选用于 RESTful API?
JSON 和 XML 都是用于数据交换的格式,但它们具有不同的特性,这可以使一个格式比另一个更适合某些应用,尤其是 RESTful API。以下是为什么 JSON 通常比 XML 更适合 RESTful 服务的一个简要概述:
性能:JSON 轻量级的特性意味着它比 XML 解析得更快,这对于 RESTful API 的性能至关重要
简洁性:JSON 的简单语法使其更容易阅读和编写,也更容易程序化解析和生成
网络友好:鉴于其与 JavaScript 的兼容性以及现代 Web 开发对 JavaScript 的依赖性,JSON 与 Web 技术非常契合
虽然 JSON 和 XML 都有自己的应用场景,但 JSON 的效率、简洁性和网络友好性使其成为许多开发者在开发 RESTful API 时的首选选择。此外,还有一些二进制结构化数据交换格式,如 ProtoBuf,它们比 JSON 提供了更好的性能,因为它们消除了文本解析的需要,但这些二进制格式并不像 JSON 那样广泛和普遍地得到支持。
如前所述,在 RESTful 服务中,JSON 作为数据交换的媒介发挥着至关重要的作用。REST 没有规定用于表示资源数据的特定语言。然而,得益于其易用性和普遍支持,JSON 成为大多数 RESTful API 的自然选择。当客户端向服务器发送请求时,它们可以在请求体中发送 JSON。然后服务器处理这个 JSON,执行所需的操作,也可以在响应体中发送 JSON。这种一致的格式允许进行清晰和结构化的数据交换。
JSON 在 REST 服务中的功能包括以下内容:
序列化:将对象转换为 JSON 字符串以便通过网络发送。例如,请参见以下内容:
String serializedJson = objectMapper.writeValueAsString( bookDetails);
反序列化:将通过网络接收到的 JSON 字符串转换为对象。例如,请参阅以下内容:
BookDetails bookDetails = objectMapper.readValue( json, BookDetails.class);
数据交换:作为客户端和服务器之间交换数据的媒介。
这些示例展示了 Jackson 库中ObjectMapper
的使用,用于在前面本节中提到的 JSON 书籍示例中序列化和反序列化 JSON。这个库已经包含在 Spring Web 框架中,但在需要的情况下也可以单独导入。
我们已经看到了 JSON 的重要性以及与之前使用的 XML 相比带来的变化。现在,你将学习掌握开发优秀 REST API 的关键步骤之一,那就是遵循经过验证的指南,如下一节所述。
指南在 REST API 中的重要性
指南为开发者提供路线图,概述了在 API 开发过程中应遵循的最佳实践和标准。它们是一套旨在防止常见陷阱并促进 API 设计卓越的规则。以下是遵循既定指南的一些最重要的方面:
一致性和可预测性:开发者确保了一致性水平,使 API 可预测且易于理解。这种统一性对于创建 API 的开发者和使用 API 的用户都是必不可少的。
互操作性:指南通常强调使用标准协议和数据格式,这促进了不同平台和技术之间的互操作性。在 API 作为连接各种软件组件的桥梁的多样化生态系统中,这一点尤为重要。
可扩展性:遵循指南精心设计的 API 更有可能具有可扩展性,能够处理增加的负载并适应增长,而无需进行重大重新设计或重构。
安全性:这是 API 开发的基本要求,指南提供了策略来保护 API 免受常见威胁,确保敏感数据和服务的保护。
遵循指南的市场相关性
遵循指南对市场有直接影响。遵循指南精心设计的 API 可能导致以下结果:
增加采用率:易于理解和集成的 API 可以迅速在开发者中受到欢迎,导致广泛采用和更大的用户基础。
增强互操作性:在医疗保健等数据共享至关重要的市场中,指南确保不同的系统可以有效地进行通信,从而提高患者护理和运营效率。
合规性:特别是在受监管的行业中,遵循指南有助于组织遵守法律标准,避免处罚并增强利益相关者之间的信任。
指南驱动的成功案例
这里有一些你可以作为参考的最成功的指南:
微软 Azure REST API 指南,请访问
github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md
。fhir.org/
上的 HL7 FHIR 医疗保健系统指南aep.dev/
上的社区驱动的 API 增强提案
更多设计指南可在 API 风格手册设计指南页面找到,例如 PayPal、Adidas、Heroku 等公司,请访问apistylebook.com/design/guidelines/
。
在本书中,我们将遵循微软 Azure REST API 指南。
接下来,我们将探讨一些最常见的使用 REST API 的用例和实现,以示例说明其实际应用。
常见的 REST API 用例
在这里,我们将探讨一些常见的 REST API 用例,并通过示例说明其实际应用。
与第三方服务的集成:在不同平台之间同步数据,例如客户关系管理(CRM)和企业资源计划(ERP)系统
社交媒体服务:在各个平台上启用社交互动和内容共享
电子商务交易:管理产品列表、订单和支付
物联网(IoT):远程连接和控制智能设备
健康和健身跟踪:从各种健康和健身设备汇总数据
在下一节中,我们将讨论 REST API 的架构设计对完整系统和其客户端的影响。
架构对 REST API 设计的影响
需要设计和实现 API 的情况可能出现在许多不同的环境中。我们希望通过 API 实现的目标以及它在开发的应用程序或系统架构中的作用会影响本书各章节中描述的各种 API 方面的相对重要性。独特的架构方面组合也会影响选择实现 API 的方法和技术。
以下部分将介绍最常见的模式。
跨组织公共 API
跨组织边界使用的 API 最接近原始的 Web 技术应用。
控制所有 API 客户端是不切实际的。具有众多独立消费者的公共 API 通常优先考虑向后兼容性,而不是其他设计目标。引入不兼容的变更会带来支持多个 API 版本的同时产生的重大成本。
最后,公共 API 相比内部 API 需要更高的安全标准。
由单一团队开发的客户端到服务端 API
大多数当代应用程序由一个在终端用户设备(网页浏览器或移动应用程序)上运行的前端组件和一个通常部署在云或其他服务器基础设施上的后端组件组成。
在许多情况下,后端及其相应的客户端都是在同一个敏捷开发团队内开发的。在这种情况下,API 变更可以迅速地在两端进行镜像,向后兼容性的重要性可能较低。
尽管如此,即使在这种情况之下,也可能存在团队无法控制的客户端使用较旧的 API 版本,例如,缺乏最近更新的移动应用程序。
前端到后端的 API 也是公共 API,因为客户端超出了我们的控制,从安全角度出发,需要将其视为此类处理。
连接微服务的 API
微服务开发团队应保持自主权以快速进步。他们需要关注其 API 的文档和向后兼容性,以避免干扰与之连接的其他服务。与公共 API 不同,在组织内部,可以跟踪内部微服务 API 及其版本的使用情况。
管理组织内部使用的各种 API 的正式规范,称为 模式注册表,可以帮助平衡开发速度和整体业务解决方案的稳定性。
在一个组织内,多个服务实现多个 API 的情况下,将一些 API 责任委托给基础设施组件,如服务网格,通常是有益的。
在下一节中,你将了解在某些情况下可能比 REST 更可取的 API 风格。记住,如果你在不适当的地方使用 RESTful API,即使你实现了很好的 RESTful API,也没有意义。
REST 的替代方案
本节介绍了市场上现有的替代 REST API 的解决方案及其比较。了解 REST 并非在所有情况下都是最佳选择,能够区分现有选项是明智的。以下是一些例子:
远程过程调用 (RPC)
RPC 是一种隐藏 API 存在的 API 风格,使其在特定编程语言中类似于标准过程调用(或面向对象语言中的方法)。这种风格包括 SOAP 网络服务和 XML-RPC。
其他值得注意的代表包括 CORBA(由企业 JavaBeans 使用)和 gRPC(一个支持多种编程语言的新协议)。gRPC 通过使用高效的二进制消息编码提供了性能优势。
RPC API 适用于客户端数量有限且 API 提供商可以控制它们的集成。
它们的缺点如下:
客户端与服务器之间的高耦合
不同 API 版本之间有限或没有兼容性
客户端被迫使用较少见且更复杂的技术
无法利用对知名 HTTP 协议的熟悉来理解操作的语义
GraphQL
与 SQL 类似,GraphQL 定义了一种数据查询和突变语言,使客户端能够指定服务器应返回哪些图结构数据源的性质和节点。
与 SOAP 类似,GraphQL 专门使用 HTTP POST
方法来隧道所有请求。GraphQL 允许客户端使用数据元素的不同组合来执行操作,这可能导致客户端逻辑过多,因为通常一个服务器会有多个客户端。
如果需要为不同的客户端提供不同结构和详细程度的数据,使用 REST API,可以通过以下方式解决这个问题:
在请求中包含参数(例如,查询参数)
通过使用称为 前后端分离(BFF)的架构,在服务器端创建多个针对客户端类型的 API
消息(事件)API
与 Web API 的同步请求-响应风格相比,通过消息平台(如 Kafka 或 Java 消息系统(JMS))进行异步通信,通常用于连接组织内的应用程序和服务,并与 REST API 一起使用。
虽然异步通信提供了诸如松散耦合、低延迟等好处,但它也更复杂和间接。
事件接口,如 Web API,需要良好的文档,并且面临 API 进化和兼容性的挑战。AsyncAPI,用于异步 API 的规范标准,受到了更成熟的 OpenAPI 的启发,并在本书的后续部分进行讨论。
在下一节中,我们将介绍我们将在这本书中创建的项目,以深入应用这些 REST 概念。
关于项目
在这本书中,我们将开发一个项目,以应用本章中提到的概念,进行实践和经验积累。我们将构建两个将相互通信的 API。
第一个 API 将是产品 API,它将在 第二章 中介绍。这个 API,正如其名称所暗示的,专注于产品操作,例如读取产品数据和添加新产品。
第二个 API 将是订单管理 API。它将在 第四章 中介绍,并将包含存储在第一个 API 中的产品订单,因此它们将相互通信。
在整个章节中,您将能够实现这些 API,应用本章中提到的大多数最佳实践,进化这些实践,进行文档编制、测试和版本控制,以确保向后兼容性,实现跟踪工具,并将它们部署到云端。
摘要
在本章中,您学习了 REST 和 RESTful 之间的区别,以及为什么与之前使用的方法相比,REST 方法是构建 API Web 服务的首选方法。
我们已经研究了 REST 架构的原则、使用 Richardson 成熟度模型的级别以及现有的 REST 替代方案。
然后,我们探讨了 JSON 在 REST 服务中的作用以及遵循经过众多项目验证的良好定义指南在开发 REST 网络服务中的重要性。
最后,我们在以下章节中调查了 REST API 设计对架构的影响以及本书中将要构建的项目。
本章为您提供了基础知识,以便您开始学习本书将要呈现和构建的内容。
在下一章中,我们将探讨使用 Spring Boot 设计和实现 RESTful API 的实践。
第二章:使用 Spring 暴露 RESTful API
为了实现 REST API,在本书的大部分章节中,我们将使用 Spring Boot。由于它是一个知名且流行的框架,你很可能熟悉它或之前使用过它。
然而,通用的 API 设计原则很容易转移到其他 Java 技术。你将了解我们如何将这些原则应用到其他技术中,请参阅第十一章。
在本章中,我们将讨论设计 RESTful API 的过程。我们还将专注于使用 Spring 框架(构建 RESTful API 的流行选择)的实际 API 实现。到本章结束时,你将具备根据最佳实践设计和创建 RESTful API 的知识。
在本章中,我们将涵盖以下主题:
设计产品 API
使用 Spring Boot 实现 API
技术要求
在本章中,我们将实现一个示例产品 API。为了能够跟随并使用书中打印的代码示例,你应该具备以下条件:
对 Java 语言和平台有中级知识
至少对 Spring Boot 或类似框架有基本了解
Java 21 和 Maven 3.9.0 已安装
推荐对调用 REST API 的工具(如
curl
)有基本了解
在本章中,我们将应用 REST 原则来创建我们的 API;你可以在 GitHub 上找到本章的代码,网址为 github.com/PacktPublishing/Mastering-RESTful-Web-Services-with-Java/tree/main/chapter2
。
设计产品 API
我们将开发的产品 API 是用于管理产品的 API。我们的 API 将提供各种操作,这些操作将在设计阶段详细说明。
在开始编写代码之前,花足够的时间正确设计 API 可以在以后节省大量时间,防止昂贵的和风险大的重构。
设计阶段应包括以下内容:
定义需求:了解用例和谁将使用 API 对于创建包含所需一切且不包含多余内容的 API 至关重要。对需求有良好的理解可以使你尽可能长时间避免破坏性更改。在第 5 章 中,我们将讨论你如何演进你的 API 并确保向后兼容性。
识别资源:资源通常是领域实体,如用户或产品。多个实体之间的关系通常通过分层 URI 结构表示。
定义资源结构:在识别资源之后,有必要定义资源字段和关系。
设计端点:在定义了资源和域之后,下一步是确定应该公开哪些端点,如何公开它们,以及应该使用哪些 HTTP 方法来达到什么目的。
错误处理:具有标准错误代码的清晰错误响应有助于客户端正确地响应错误。
安全性:防止恶意行为者访问他们未经授权的资源是至关重要的。你将在第七章中了解更多关于 API 安全性的内容。
端点是一个特定的 URI,它使客户端能够通过 API 与服务器交互以执行特定功能。它代表了一个可以使用唯一路径和相应的 HTTP 方法执行的操作。
根据这些设计步骤,您可以直接使用 Java 在代码中实现 API。这被称为先代码方法,通常代表通往工作 API 的最快途径。
一种称为先规范的不同方法将在第三章中解释。
定义需求
API 需求可以分为功能性和非功能性:
功能性需求:这些描述了软件必须执行的具体功能和特性。例如,数据验证、数据处理和系统交互。
非功能性需求:这些也被称为质量属性或软件质量要求,它们指定了软件必须具备的品质或特性。例如,包括性能(响应时间和吞吐量)、可靠性、安全性、可伸缩性、可用性和可维护性。
在本章中,我们只关注功能性需求。我们将在第七章和第十章中了解到一些非功能性需求。
如前所述,我们的示例 REST API 将是一个产品 API,并将具有以下要求:
产品创建:API 应该允许用户通过提供必要的信息(如 SKU(库存单位,在本书中是指唯一的商品 ID)、名称、描述和价格)来创建新产品
产品检索:用户应该能够检索有关系统中所有产品的信息
产品详情:API 应该提供端点以检索有关特定产品的详细信息,该产品通过其 SKU(库存单位,在本书中是指唯一的商品 ID)进行标识
产品更新:用户应该能够更新现有产品信息,例如名称、描述或价格
产品描述更新:API 应该提供一个端点以仅更新描述
产品删除:API 应该允许用户通过其 SKU 从系统中删除产品
唯一的 SKU 约束:API 应该强制执行约束以确保每个产品都有一个唯一的 SKU,防止具有相同标识符的重复产品
既然我们已经确定了需求,下一步就是确定我们的资源。
识别资源
REST API 可以有一个或多个资源,也可以有一个分层 URI 结构。分层 URI 结构是一种在 URL 分层中组织资源的方式,反映了这些资源之间的关系。在分层 URI 结构中,资源以树状格式排列,其中 URL 的每个部分代表层次结构的一个级别。
URI 还是 URL?
在 REST API 的上下文中,我们经常互换使用缩写URI(代表统一资源标识符)和URL(代表统一资源定位符)。为了精确起见,URL 是 URI 的一个子集。URI 的第二个子集称为URNs(代表统一资源名称)。与 URN 相比,URL 的优势在于,除了是资源的唯一标识符外,它还包含可用于定位和访问资源的信息:协议、主机名和路径。URL 的路径部分支持类似于从文件系统路径中知道的一系列文件夹和文件名的分层结构。
让我们以下列为例:
[`example.com/blog/posts/12`](https://example.com/blog/posts/12 )
在这个 URI 中,我们有以下内容:
example.com
是基本 URL/blog
代表顶级资源,表示我们正在访问网站的博客部分/posts
是/blog
资源下的子资源,代表一系列博客文章/12
是一个通过其唯一标识符识别的具体博客文章
在我们的案例中,我们只有一个资源,那就是产品,所以我们只有/products
。
资源应该是名词;它们不应该是动词。例如,不应使用/products/create
;相反,应使用与此动作对应的 HTTP 方法。这对于遵守 Richardson 成熟度模型的第 2 级至关重要。
在英语中,许多动词也可以用作名词,例如,/products/quote
。当我们向这个资源发送POST
请求时,意味着创建报价。句子的动词部分由正确的 HTTP 方法表示。
资源映射到业务实体,API 通常与同一类型的多个实体一起工作。当创建或列出特定类型的资源(实体)时,我们正在处理一个对象集合。对于集合资源,我们使用名词的复数形式——例如,products
而不是product
,因为我们希望支持对产品集合的操作。
RFC 3986 定义 URI 为一个字符序列,用于标识一个抽象或物理资源。根据 RFC 1738,URL 是 URI 的一种特定类型,表示可通过互联网访问的资源的位置。因此,URI 可以进一步分类为定位符、名称或两者兼具,其中术语 URL 指的是 URI 的子集。这意味着所有 URL 都是 URI,但并非所有 URI 都是 URL。RFC 2141 进一步定义 URN 为另一种 URI 类型,以持久、位置无关的方式命名资源。
现在我们已经确定了资源,下一步是定义资源结构。
定义资源结构
在确定资源后,我们应该确定资源的属性以及 API 中任何重要的关系。属性代表与资源相关的数据字段。考虑数据类型、约束以及每个资源所需的或可选的属性。关系表示资源之间的关系以及它们是否有层次或嵌套关系。例如,一个用户可能有多个与之关联的订单,或者一个产品可能属于特定的类别。
在我们的案例中,我们只有一个资源,即产品,它应该具有以下属性:
名称
:产品的名称SKU
:产品的唯一键描述
:产品的描述价格
:产品的价格
我们也可以在这一步定义这些属性的规则:
名称
:必填字段,长度在 3 到 255 个字符之间的字符串SKU
:必填字段,格式为 AA99999描述
:必填字段,长度在 10 到 255 个字符之间的字符串价格
:必填字段,值必须大于 0
在这一步之后,我们可以设计端点。
设计端点
在讨论 HTTP 方法和 HTTP 状态码之前,我们应该回顾一步,了解它是如何开始的。
我们将要分享的 HTTP 方法和原则遵循上一章中提到的微软的指南。任何 REST 指南都应该遵守互联网标准 RFC 9110,即 RFC 2616 的更新版本。RFC代表请求评论。RFC 指定了 HTTP、TCP、IP、SMTP 以及许多其他重要的互联网协议。Fielding 的论文,题为《架构风格和网络软件架构设计》是 RFC 2616 的重要来源,定义了 HTTP/1.1 协议。
HTTP 方法(动词)
HTTP 定义了几个方法(也称为动词),用于指示对资源要执行的操作。在 RESTful API 中最常用的 HTTP 方法是以下这些:
GET
:GET
方法请求用于检索资源表示,它们不应改变服务器状态。它们是安全的和幂等的,这意味着它们对服务器没有副作用,并且可以重复执行而不改变服务器状态。(想想 Java 中Map
的get
方法。)POST
:POST
方法请求用于创建新资源或通常提交数据由服务器处理。它们是非幂等的,这意味着每个请求都是唯一的,并且可能对服务器有副作用。(想想 Java 中的++
操作符;如果多次评估,结果将不同。)PUT
:这些请求通常用于创建或更新资源的整个表示。它们是幂等的,这意味着发送相同的请求多次应该与发送一次的效果相同。(想想 Java 中Map
的put
方法。)PATCH
:这与PUT
类似,但用于对资源应用部分修改。它通常用于只想更新资源的一定字段。(想想 Java Bean/POJO 的 setter 方法。)DELETE
:此方法用于从服务器删除资源。方法是幂等的,这意味着发送相同的请求多次应该导致服务器上相同的状态。
HTTP 状态码
HTTP 状态码是服务器在收到请求后发送给客户端的三位数。它们表示请求的结果,并提供有关服务器或请求资源状态的信息。所有 HTTP 状态码都被分为以下类别:
1xx 信息性
:这表示请求已被接收并正在处理。2xx 成功
:这表示请求已被接收、理解并成功处理。3xx 重定向
:这表示需要进一步的操作来完成请求。客户端可能需要重定向到不同的 URI。4xx 客户端错误
:这表示客户端请求中存在错误。5xx 服务器错误
:这表示在处理请求时服务器端出现错误。
现在我们已经了解了 HTTP 方法和服务状态,我们可以定义 API 端点。
定义我们 API 的端点
让我们定义上一步中确定的资源的端点。我们将使用 products
资源上的 HTTP 方法来确保我们可以执行需求阶段概述的所有必要操作。
GET /products
此端点将负责返回产品列表。我们应该返回带有产品的 200 (ok)
HTTP 状态。
PUT /products/
此端点将负责创建或更新产品。PUT
是幂等的,因此我们可以多次调用它,结果将相同。在成功的情况下,如果产品不存在,我们应该返回 201 (created)
,如果产品已存在,则返回 200
。
如果产品将在未来作为异步任务处理,我们可以返回 202 (已接受)
,但这在这里不是情况。
如果我们决定使用 POST
方法,我们需要在抛出异常(这个异常可能是 409 (冲突)
)如果产品已经存在,或者创建另一个产品之间做出选择。在我们的情况下,因为 SKU 不是由应用程序生成的,它是一个传递给 API 的属性,我们不能有两个具有相同 SKU 的产品,所以我们需要返回 409
并有一个负责更新资源的另一个端点,因为这是一个要求。而不是这样做,我们可以使用 PUT
方法,它可以创建或更新资源。
根据 RFC 9110,第 9.3.4 节,“POST 和 PUT 方法之间的基本区别在于对所包含表示的不同意图。POST 请求中的目标资源旨在根据资源自身的语义处理所包含的表示,而 PUT 请求中的所包含的表示被定义为替换目标资源的状态。因此,PUT 的意图是幂等的,并且对中间代理可见,尽管确切的效果只有原始服务器才知道”。
正确解释 PUT 请求假设用户代理知道所需的哪个目标资源。为了获得更好的洞察,请参阅 www.rfceditor.org/rfc/rfc9110.html#section-9.3.4
。
DELETE /products/
此端点应删除产品。如果产品被删除,我们可以返回 204 (无内容)
。即使产品不存在,我们也可以返回 204
,因为该方法具有幂等性,意味着发送相同的请求多次应该在服务器上产生相同的状态。
PATCH /products/
应使用此端点来更新产品的描述。由于这是一个资源的部分更新,建议使用 PATCH
。PATCH
方法在 RFC 5789 中被引入,作为不需要幂等的部分更新。如果产品不存在,我们可以返回 404 (未找到)
错误。在成功的情况下,我们可以返回更新后的产品数据和 200
HTTP 状态码。
GET /products/
此端点负责通过 ID 返回单个产品的详细信息。如果产品不存在,我们可以返回 404 (未找到)
错误。
在成功的情况下,我们应该返回产品表示和 200
HTTP 状态码。
现在我们已经定义了我们的端点,让我们看看我们可能会遇到的可能错误以及我们如何有效地为它们做准备。
错误处理
通过定义可能的错误来准备常见问题可以帮助我们实现更可靠的 API。我们已经在之前的步骤中讨论了一些错误;然而,让我们深入探讨并看看 4xx
范围内最常见的 HTTP 状态码。
4xx
范围内的最常见 HTTP 状态码,表示客户端错误,如下所示:
400 请求错误
:此状态码表示服务器无法理解客户端的请求,因为请求语法无效或请求消息格式不正确。401 未授权
:此状态码表示客户端需要验证自身才能访问请求的资源。通常发生在客户端未能提供适当的认证凭据或访问令牌时。403 禁止访问
:此状态码表示客户端已通过认证,但没有权限访问请求的资源。可能是因为权限不足或服务器施加的访问控制限制。404 未找到
:此状态码表示服务器找不到请求的资源。它通常用于表示客户端提供的 URI 不对应于服务器上的任何已知资源。405 方法不允许
:此状态码表示客户端使用的 HTTP 方法不支持请求的资源。例如,尝试对一个只支持GET
请求的资源使用POST
请求将导致405 方法不允许
响应。409 冲突
:此状态码表示由于与资源的当前状态冲突,请求无法完成。通常发生在客户端尝试创建或更新资源,但服务器检测到与资源当前状态的冲突;例如,当两个请求同时尝试更新同一产品的详细信息时,如果由于版本不匹配,一个请求的更改与另一个请求冲突,则可能会返回409
状态码。422 无法处理实体
:此状态码表示服务器理解了请求,但由于请求负载中的语义错误或验证失败而无法处理它。它通常用于指示请求数据中的验证错误,例如,尝试购买缺货的产品。429 请求过多
:此状态码表示客户端在特定时间段内超过了服务器对允许请求数量的限制或配额。它通常用于通过限制单个客户端或 IP 地址的请求速率来防止滥用或过度使用服务器资源。此错误通常由使用速率限制策略的 API 网关处理,正如我们将在第六章中介绍的那样。
在我们的 API 实现中,我们应该能够处理一些错误:
当产品不存在时,我们应该返回
404 未找到
当有效载荷不符合要求时,例如,如果产品价格提供了负数,我们应该返回带有详细信息的
400 Bad Request
一些错误条件(例如,导致405 方法不允许
的不支持的方法)由框架自动处理。
与安全相关的 401
和 403
HTTP 状态码将在 第七章 中介绍。
在接下来的章节中,我们将把安全和文档作为单独的主题进行讨论,并直接解决它们。然而,对于本章所代表的开发迭代,我们已经完成了设计阶段。向前推进,我们将专注于实现我们的产品 API,并确保我们能够满足我们定义的要求。
使用 Spring Boot 实现 API
Spring Boot 是最受欢迎的用于微服务应用的 Java 框架。它提供了嵌入式 Servlet 容器,如 Tomcat、Jetty 和 Undertow。嵌入容器允许您将应用程序打包为可执行的 JAR 文件,可以直接运行,无需将应用程序部署到单独的应用服务器。我们将使用 Tomcat,因为它是默认的。
我们的应用程序将提供一些端点并使用 SQL 数据库。我们将使用 H2,这是一个轻量级和开源的数据库。它既可以作为内存数据库,也可以作为文件系统数据库,这使得它在开发环境中易于使用,无需额外的基础设施。
我们将使用 Spring Data 依赖项来集成持久层,但由于这不是本书的重点,我们不会深入探讨。我们推荐由 Otávio Santana 和 Karina Varela 编写的书籍《Java 应用程序持久化最佳实践》,Packt 出版,该书更多地讨论了持久层。要添加 Spring Data 依赖项,请在您的 pom.xml
文件中包含以下条目:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
由于我们的 API 是一个 Web 应用程序,我们还将使用 Spring Web 来创建我们的 REST 端点,并使用 Bean Validation 来验证用户输入,我们将在本章后面讨论这一点。我们将使用 清洁架构(blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
)设计我们的 API。
清洁架构是由 Robert C. Martin 创建的,他受到了其他知名架构的影响,例如 Jeffrey Palermo 的洋葱架构和 Alistair Cockburn 的六边形架构(也称为端口和适配器)。
清洁架构与其他这些架构有相似的目标:通过将软件划分为层来分离关注点。清洁架构的关键区别在于其对层的清晰定义。虽然其他架构提供了不太精确的层定义,但清洁架构明确地定义了四个层,每个层都有特定的角色和责任。
我们将使用清洁架构,因为其多层结构允许清晰的关注点分离,从而促进更好的组织和可理解性。图 2.1显示了清洁架构定义的层:
图 2.1 – 清洁架构层
让我们逐一分析这些层:
实体:这一层包含应用程序的业务实体或域对象。
用例:这一层包含特定于应用程序的业务逻辑或用例。这一层从用户可以执行的动作或操作的角度来表示应用程序的行为。
接口适配器:这一层负责将应用程序的内部表示适配到外部接口,如用户界面(UIs)、REST API、数据库或第三方服务。
框架和驱动程序:这一层包括处理外部关注点(如 UI 渲染、数据库访问、Web 服务器和外部 API)的库、框架和基础设施代码。
在我们的应用程序中,我们将只使用三个层:实体、用例和接口适配器。这是因为对于我们的应用程序,我们决定我们不想完全从 Spring 框架中解耦。在我们的情况下,我们更喜欢直接访问 Spring 框架提供的许多有用功能。我们接受与框架的耦合,同时减少创建一个层来将其分离的复杂性。
层的定义旨在实现松散耦合、易于维护和可重用。罗伯特·C·马丁说,我们可以有更多的层,这些层只是示意图。分层架构的主要原则是确保依赖关系只在一个方向上跨越边界;内层不应依赖于外层。
既然我们已经定义了架构和所用的技术,让我们构建我们的产品 API。
创建我们产品 API 的端点
如前所述,为了创建我们的产品 API,我们将使用 Spring Boot。我们建议使用最新的 Spring 和 Java(JDK)版本;我们将使用以下版本:
Java 21
Spring Boot 3.2.5
在开始创建我们的 API 之前,我们将定义一个包含所有预期端点的接口:
public interface ProductsApi {
ResponseEntity<ProductOutput> createOrUpdateProduct(String productId,
@Valid ProductInput productInput);
ResponseEntity<Void> deleteProduct(String productId);
ResponseEntity<ProductOutput> editProductDescription(String productId,
@Valid ProductDescriptionInput input);
ResponseEntity<ProductOutput> getProductById(String productId);
ResponseEntity<List<ProductOutput>> getProducts();
}
这个“ProductsAPI
”接口包含了在设计步骤中定义的所有预期端点。
由于我们遵循了清洁架构指南,我们为输入和输出定义了三个数据传输对象(DTOs):ProductOutput
、ProductInput
和ProductDescriptionInput
。利用这些 DTO,我们可以修改它们而不会影响我们的域类。
为了创建我们的产品 API,我们需要定义一个 Spring 控制器。这个类将由 Spring 管理,以处理对端点的请求。我们的 API 类将实现ProductsApi
接口,并且我们需要添加两个注解,@RestController
和@RequestMapping("/api/products")
:
@RestController
@RequestMapping("/api/products")
public class ProductsApiController implements ProductsApi {
private final ProductsQueryUseCase productsQueryUseCase;
private final ProductsCommandUseCase productsCommandUseCase;
private final ProductMapper productMapper;
public ProductsApiController(
ProductsQueryUseCase productsQueryUseCase,
ProductsCommandUseCase productsCommandUseCase,
ProductMapper productMapper) {
this.productsQueryUseCase = productsQueryUseCase;
this.productsCommandUseCase = productsCommandUseCase;
this.productMapper = productMapper;
}
...
}
@RestController
用于创建由 Spring 管理的 bean。这个控制器将处理由@RequestMapping("/api/products")
注解映射的端点请求,这意味着所有 URI 匹配/api/products
模式的请求都将由我们的控制器处理。
使用@RestController
推荐用于使用 Spring 框架创建 RESTful API。它是@Controller
和@ResponseBody
注解的组合的快捷方式。
ProductsQueryUseCase
和ProductsCommandUseCase
是我们用例的实现。我们正在使用命令查询责任分离(CQRS)模式来分离查询和命令责任。该模式在软件架构中常用,用于将读取数据(查询)的责任与写入数据(命令)的责任分开。
CQRS 在读取和写入操作具有不同特性或性能要求的系统中很有用。它促进了关注点的清晰分离,从而导致了更易于维护和可扩展的架构。通过分离这些责任,我们可以在以后决定将 API 拆分为两个微服务并分别对其进行扩展。
ProductMapper
类使用MapStruct
库将一个对象转换成另一个对象;在我们的案例中,我们正在将领域数据映射到我们的 DTO。让我们看看我们如何实现它:
@Mapper(componentModel = "spring")
public interface ProductMapper {
ProductOutput toProductOutput(Product product);
}
ProductMapper
的实现是在编译阶段生成的。
实现产品 API 端点
在本节中,我们将实现ProductsApiController
类。这包括添加我们在前几节中定义的所有端点,使我们的 API 能够实现创建、检索、更新和删除产品的全部功能。
创建或更新产品端点
要实现此端点,我们需要使用 Spring 注解。对于创建操作,建议使用@PutMapping
或@PostMapping
:
@PutMapping(value = "/{productId}")
@Override
public ResponseEntity<ProductOutput> createOrUpdateProduct(
@PathVariable("productId") @ValidSku String productId,
@Valid @RequestBody ProductInput productInput) {
final var product = productsCommandUseCase.createProduct(
productInput.toProduct(productId));
HttpStatus status = product.isNewProduct() ? HttpStatus.CREATED :
HttpStatus.OK;
return ResponseEntity.status(status)
.body(productMapper.toProductOutput(product.product()));
}
在我们的案例中,我们决定使用PUT
注解;这个决定是因为我们的方法是幂等的,因此我们可以使用这个方法来更新或创建产品。一个要求是不允许两个产品具有相同的 SKU;在我们的案例中,如果产品不存在,我们可以更新或创建产品。
然后,我们有两个输入注解,@Valid
和@RequestBody
。@Valid
注解将在Bean Validation部分进行讨论。@RequestBody
表示我们应该通过将输入传递到请求体中来发送输入。
遵循最佳实践,此方法可以返回200 (ok)
或201 (created)
,具体取决于产品是否已存在。
所有 API 调用示例都将使用curl
命令进行演示,但您也可以轻松地使用各种工具,如Postman或UseBruno,直接从您的 IDE 或使用您选择的任何其他 API 测试工具执行相同的请求。
要调用此端点,我们可以使用以下请求:
curl -X 'PUT' \
'http://localhost:8080/api/products/AK21101' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"name": "Keyboard",
"description": "Ergonomic Keyboard",
"price": 60
}'
我们将收到以下输出:
HTTP/1.1 201
Content-Type: application/json
{
"name": "Keyboard",
"sku": "AK21101",
"description": "Ergonomic Keyboard",
"price": 60.00
}
获取所有产品端点
要返回所有产品,我们需要使用 HTTP GET
方法:
@GetMapping
@Override
public ResponseEntity<List<ProductOutput>> getProducts() {
final var products = productsQueryUseCase.getAllProducts()
.stream()
.map(productMapper::toProductOutput)
.toList();
return ResponseEntity.status(HttpStatus.OK)
.body(products);
}
要调用此端点,我们可以使用以下请求:
curl -X 'GET' \
'http://localhost:8080/api/products' \
-H 'accept: application/json'
我们将收到以下输出:
HTTP/1.1 200
Content-Type: application/json
[
{
"name": "Keyboard",
"sku": "AK21101",
"description": "Ergonomic Keyboard",
"price": 60.00
}
]
通过 ID 获取产品端点
要通过 ID 返回产品,我们需要使用 HTTP GET
方法并将 ID 作为路径参数传递:
@GetMapping(value = "/{productId}")
@Override
public ResponseEntity<ProductOutput> getProductById(@PathVariable("productId") String productId) {
final var product = productsQueryUseCase.getProductById(productId);
return ResponseEntity.status(HttpStatus.OK)
.body(productMapper.toProductOutput(product));
}
此端点使用@PathVariable("productId")
注解,并期望在 URI 中具有此值。我们还需要将此路径变量 ID 添加到@GetMapping
映射注解中。
在成功的情况下,我们应该返回 HTTP 状态码200
并返回产品内容。否则,我们可以返回404
,表示产品不存在。我们将在下一节中看到如何处理错误。
调用此端点,我们可以使用以下请求:
curl -X 'GET' \
'http://localhost:8080/api/products/AK21101' \
-H 'accept: application/json'
我们将收到以下输出:
HTTP/1.1 200
Content-Type: application/json
{
"name": "Keyboard",
"sku": "AK21101",
"description": "Ergonomic Keyboard",
"price": 60.00
}
通过 ID 删除产品端点
通过 ID 删除产品与通过 ID 获取产品非常相似,主要区别在于 HTTP 方法;在这种情况下,我们应该使用DELETE
方法:
@DeleteMapping(value = "/{productId}")
@Override
public ResponseEntity<Void> deleteProduct(@PathVariable("productId") String productId) {
productsCommandUseCase.deleteProduct(productId);
return ResponseEntity.noContent().build();
}
此方法与上一个方法非常相似。主要区别在于@DeleteMapping
注解和return
。在这种情况下,我们返回204 (no content)
HTTP 状态。这意味着我们没有提供任何内容,请求已成功完成。
DELETE
方法是幂等的;因此,无论产品是否存在,我们都可以返回204
。由于预期的行为将相同,数据库中的产品将不存在。
调用此端点,我们可以使用以下请求:
curl -X 'DELETE' \
'http://localhost:8080/api/products/AK21101' \
-H 'accept: */*'
我们将收到以下输出:
HTTP/1.1 204
通过 ID 更新产品描述端点
更新产品描述的端点通过 ID 与检索产品的端点非常相似。在这种情况下,我们将使用PATCH
方法,因为它是一个部分更新,所以客户端将只传递包含更改内容的主体:
@PatchMapping(value = "/{productId}")
@Override
public ResponseEntity<ProductOutput> editProductDescription(@PathVariable("productId") String productId,
@RequestBody @Valid ProductDescriptionInput input) {
final var product = productsCommandUseCase.updateProductDescription(
productId, input.description());
return ResponseEntity.status(HttpStatus.OK)
.body(productMapper.toProductOutput(product));
}
此方法接收产品 ID 作为@PathVariable
,并使用@PatchMapping
注解将其映射到PATCH
方法。此方法还有一个类型为ProductDescriptionInput
的输入参数,带有@RequestBody
和@Valid
注解。输入参数表示包含客户希望更改的产品描述的请求体。
调用此端点,我们可以使用以下请求:
curl -X 'PATCH' \
'http://localhost:8080/api/products/AK21101' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"description": "Ergonomic Keyboard 2.0"
}'
我们将收到以下输出:
HTTP/1.1 200
Content-Type: application/json
{
"name": "Keyboard",
"sku": "AK21101",
"description": "Ergonomic Keyboard 2.0",
"price": 60.00
}
在设置好我们的端点后,我们的 API 类现在就完整了。
我们的 API 代码已完成;现在我们可以调用端点并测试它们。然而,如果产品不存在,或者我们发送了无效数据,会发生什么?我们需要涵盖负面情况。
异常处理
异常处理是实现中响应程序执行期间发生的错误或异常情况的部分。
在 RESTful API 中,可能会发生许多类型的错误,通常属于 HTTP 状态码的 4xx
或 5xx
组。5xx
状态码表示服务器应用程序中存在错误。它们可能是由服务器端的外部因素(例如,数据库服务器的不可用)或应用程序中的错误(例如,NullPointerException
)引起的。另一方面,4xx
状态码表示由客户端引起的错误。例如,当客户端尝试使用 API 不期望的 HTTP 方法时,Spring 框架会自动生成 405 (Method Not Allowed)
。
Spring 和 Java 提供了许多处理异常的方法。我们可以在每个端点中创建一个 try-catch
块并逐个处理它们;我们也可以为每个控制器有一个自定义的 ExceptionHandler
,或者有一个全局异常处理器。
我们将在我们的 API 中实现全局异常处理。这种方法有助于防止代码重复,并确保所有端点之间的一致行为,特别是如果 API 公开了多个资源。
Spring 框架支持 HTTP APIs 的 Problem Details
规范,RFC 9457。这个 RFC 是一个基于 RFC 7807 的更新标准,其主要目标是通过对五个字段进行定义,提供有关 HTTP API 中错误的详细信息:
类型
:这应该包含一个标识问题类型的 URI状态
:这表示 HTTP 状态码标题
:标题是对问题的简洁总结详细信息
:此字段应包含有关问题的更详细信息实例
:这应包含一个标识端点调用的 URI
在春季,我们可以通过扩展 ResponseEntityExceptionHandler
类或启用 spring.mvc.problemdetails.enabled
属性来遵循 RFC 指南。我们将扩展 ResponseEntityExceptionHandler
类,它为 ProblemDetail
类提供了 RFC 9457 定义的五个元素。我们还可以添加一些扩展,我们将这样做以在 400 (Bad Request)
中提供更好的响应错误:
@ControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<Object> handleNotFound(
EntityNotFoundException exception, WebRequest request) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, exception.getMessage());
return this.handleExceptionInternal(exception, problemDetail,
new HttpHeaders(), HttpStatus.NOT_FOUND, request);
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<Object> handleBadRequestDueToBeanValidation(
ConstraintViolationException ex, WebRequest request) {
final Set<String> errors = ex.getConstraintViolations().stream()
.map(e -> ((PathImpl) e.getPropertyPath())
.getLeafNode() + ": " + e.getMessage())
.collect(Collectors.toSet());
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "Invalid request content.");
problemDetail.setProperty("errors", errors);
return this.handleExceptionInternal(ex, problemDetail,
new HttpHeaders(), HttpStatus.BAD_REQUEST, request);
}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
final List<String> errors = new ArrayList<>();
for (final FieldError error :
ex.getBindingResult().getFieldErrors()) {
errors.add(
error.getField() + ": " + error.getDefaultMessage());
}
for (final ObjectError error :
ex.getBindingResult().getGlobalErrors()) {
errors.add(
error.getObjectName() + ": " + error.getDefaultMessage());
}
ProblemDetail problemDetail = ex.getBody();
problemDetail.setProperty("errors", errors);
return handleExceptionInternal(ex, problemDetail, headers, status,
request);
}
}
我们的 GlobalExceptionHandler
扩展了 ResponseEntityExceptionHandler
Spring 类,它提供了 handleMethodArgumentNotValid
方法;我们正在重写此方法并更新 ProblemDetail
类,以便在 400 (Bad Request)
的情况下提供关于错误输入数据的清晰信息。这种错误可以在用户未发送正确输入时通过 Bean Validation 检测到。
我们的这个类还有两个自定义的 ExceptionHandler
:第一个用于处理我们的自定义异常 EntityNotFoundException
,第二个用于处理 ConstraintViolationException
,这可能是由于 PathVariable
的验证引起的。
多亏了我们的 GlobalExceptionHandler
,每当抛出 EntityNotFoundException
异常时,客户端将收到一个 404 错误。
通过创建这个类,我们已经实现了全局异常处理,并定义了一个一致和标准化的错误格式,这使得 API 的客户端能够更容易地理解和处理不同端点上的错误。此外,它还提供了更多的清晰度,帮助客户端理解错误的本质以及如何解决它。这提高了开发者的体验,并减少了诊断和修复问题所需的时间。
现在我们已经有了异常处理器,我们需要确保用户不会发送无效的输入。让我们看看如何使用 Bean Validation 来实现这一点。
Bean Validation
Bean Validation 是基于 Java 规范请求(JSRs)380、349 和 303 的 Java 规范。这些请求提供了一种标准化的方法来验证 Java Bean。Bean Validation 对于验证用户请求非常有用,它提供了一些预定义的规则以及创建自定义 Bean Validation 的方法,这使得在 Java 应用程序中强制数据完整性和验证输入数据变得更加容易。
Bean Validation 可以通过以下各种注解来应用:
@NotNull
: 指定被注解的元素不能为 null@Size
: 指定字符串、集合、映射或数组的大小约束@Min
: 指定数值元素的最小值@Max
: 指定数值元素的最大值@DecimalMin
: 指定具有小数精度的数值元素的最小值@DecimalMax
: 指定具有小数精度的数值元素的最大值@Digits
: 指定数值元素的精确或最大位数@Pattern
: 指定被注解的元素必须匹配的正则表达式模式@Email
: 指定被注解的元素必须是一个格式良好的电子邮件地址@NotBlank
: 指定被注解的字符串不能为空或空字符串,并且必须包含至少一个非空白字符@NotEmpty
: 指定被注解的字符串不能为空或空字符串@Positive
: 指定被注解的数值元素必须是正数(大于 0)@Negative
: 指定被注解的数值元素必须是负数(小于 0)@PositiveOrZero
: 指定被注解的数值元素必须是正数或 0@NegativeOrZero
: 指定被注解的数值元素必须是负数或 0@Past
: 指定被注解的日期或时间必须是过去@PastOrPresent
: 指定被注解的日期或时间必须是过去或现在@Future
: 指定被注解的日期或时间必须是未来@FutureOrPresent
: 指定被注解的日期或时间必须是未来或现在
除了这些,Bean Validation 规范还提供了@Valid
注解,该注解用于触发嵌套对象或集合内元素的有效性验证。当应用于表示复杂对象或对象集合的字段或参数时,@Valid
告诉 Bean Validation 框架根据其验证约束来验证嵌套对象。
让我们为我们的 SKU 字段实现自定义的 Bean Validation,并在我们的请求中应用所讨论的注解。
我们的 API 有一个 SKU 字段,该字段在需求阶段应遵循指定的模式(AA99999)。我们可以通过创建自定义的 Bean Validation 规则来防止客户端使用无效的 SKU。
我们可以使用以下代码:
@Constraint(validatedBy = {})
@NotNull
@Pattern(regexp = "[A-Za-z]{2}[0-9]{5}",
message = "SKU must follow the pattern AA99999")
@Target({PARAMETER, FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidSku {
String message() default "Invalid SKU";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
此代码创建了一个名为@ValidSku
的新注解,并检查字段是否遵循特定的模式:两个字母字符后跟五个数字(例如,AA12345
)。如果注解的字段不匹配定义的模式,将生成一个验证错误消息,SKU 必须遵循模式 AA99999
。
ValidSku
注解被设计为应用于字段和参数,如@Target
注解所示。它还指示验证约束应在运行时保留以供反射使用。此注解简化了 Java 应用程序中验证 SKU 的过程,增强了数据完整性并确保符合特定的 SKU 格式要求。
ValidSku
还使用@NotNull
注解验证字段是否不为空。
在创建我们的注解后,我们可以通过向我们的PathVariable
添加@ValidSku
来更新我们的 API。我们还应该向我们的ProductsApiController
添加 Spring 的@Validated
注解。验证方法注解非常重要,而不仅仅是字段:
public interface ProductsApi {
ResponseEntity<ProductOutput> createOrUpdateProduct(
@ValidSku String productId, @Valid ProductInput productInput);
ResponseEntity<Void> deleteProduct(@ValidSku String productId);
ResponseEntity<ProductOutput> editProductDescription(
@ValidSku String productId, @Valid ProductDescriptionInput input);
ResponseEntity<ProductOutput> getProductById(
@ValidSku String productId);
ResponseEntity<List<ProductOutput>> getProducts();
}
现在我们已经创建了 SKU 验证和ExceptionHandler
,我们可以通过传递错误的 SKU 来测试它,以检查结果:
curl -X 'PUT' \
'http://localhost:8080/api/products/AAAA' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"name": "Keyboard",
"description": "Ergonomic Keyboard",
"price": 60
}'
我们将收到以下输出:
HTTP/1.1 400
Content-Type: application/json
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Invalid request content.",
"instance": "/api/products/123",
"errors": [
"productId: SKU must follow the pattern AA99999"
]
}
我们的结果遵循 RFC 9457 标准,我们还有一个额外的字段来详细说明 Bean Validation 的错误。
现在,我们可以通过添加 Bean Validation 注解来增强我们的ProductInput
和ProductDescriptionInput
类,以确保输入有效。对于ProductInput
类,我们可以使用以下注解:
public record ProductInput(
@NotBlank
@Size(min = 3, max = 255)
@JsonProperty("name")
String name,
@NotBlank
@Size(min = 10, max = 255)
@JsonProperty("description")
String description,
@NotNull
@Positive
@JsonProperty("price")
BigDecimal price) {
}
通过添加这些注解,我们保证输入符合我们 API 的预期。有了这些注解,我们提供了一个更安全的 API,因为我们控制用户输入并确保数据完整性。
我们的代码保证了以下行为:
名称不应为空,且长度应在 3 到 255 个字符之间。
描述不应为空,且长度应在 10 到 255 个字符之间。
价格不应为空,且应为正数。
如果 API 的用户不遵守这些规则,客户端将收到一个400
错误,错误信息是我们通过 Bean Validation 定义的。
验证豆(Bean Validation)允许我们的 API 在发生验证错误时向客户端提供标准化的反馈,这对于维护数据完整性至关重要。它确保无效或格式不正确的数据不会被处理,从而显著降低了应用程序内部数据损坏或状态不一致的风险。
摘要
在本章中,我们涵盖了创建 RESTful API 所必需的许多主题。你学习了如何遵循最佳实践使用 HTTP 方法和 HTTP 状态码来设计和实现 RESTful API,以及如何应用 Bean Validation 来保证数据完整性。不仅如此,你还学习了如何使用 Spring 框架来处理异常。
在下一章中,我们将看到如何为我们的 API 创建定义良好的文档,以便客户端可以使用。
进一步阅读
Jakarta Bean Validation 规范(版本 3.0):
jakarta.ee/specifications/bean-validation/3.0/jakarta-bean-validation-spec-3.0.html
RFC 9110 :
www.rfc-editor.org/rfc/rfc9110.html
RFC 9457 :
www.rfc-editor.org/rfc/rfc9457.html
第三章:高效地记录您的 API
完整的 文档 对于确保客户能够有效使用您的 API 至关重要。在本章中,我们将探讨使用 Swagger 注解和最佳实践来记录 REST API 的过程。我们将深入研究几个关键主题,以提供全面的理解。
首先,我们将通过检查 OpenAPI 规范 和 JSON Schema 来讨论 API 规范的重要性,这些是定义 API 文档结构和格式的关键标准。
我们还将探讨 API 开发中 先编码 和 先规范 方法之间的辩论,讨论它们各自的优缺点和考虑因素。在此之后,我们将记录产品 API,展示如何有效地使用 Swagger 注解来描述端点、参数、响应和其他关键细节。
最后,我们将展示 Swagger 用户界面(UI)的实用价值,这是一个用于可视化并交互 API 文档的强大工具,它能够提升开发者的体验并促进 API 的无缝使用。通过这些讨论和示例,您将深入了解创建良好文档、标准化的 REST API,这些 API 能够促进互操作性和易用性。
在本章中,我们将涵盖以下主题:
API 规范的重要性
介绍 OpenAPI 和 JSON Schema
在规范优先和编码优先之间进行选择
记录产品 API
使用 Swagger UI
技术要求
在本章中,我们将实现产品 API 的文档。为了能够跟随并使用本书中打印的代码示例,您应该拥有上一章中创建的产品 API 代码。您可以在 GitHub 上访问本章的代码:github.com/PacktPublishing/Mastering-RESTful-Web-Services-with-Java/tree/main/chapter3
。本章添加的代码不会改变 API 的实际功能;它只会提供元数据以生成 API 的文档。
API 规范的重要性
让我们先讨论 API 规范是什么以及为什么它们很重要。
从 Java 我们了解到面向对象编程的原则。其中之一是 封装,它涉及尽可能多地使类的成员私有。只有那些明确打算对外部访问的成员(通常是方法)应该被公开。这些公共成员的集合构成了类的 API。与公共成员伴随的 Javadoc 可以确保其使用清晰,无需查看实现类的代码。通过限制公共 API 的大小,我们保留了对更改或删除类外部的(私有)成员的自由,而不会破坏类外部的代码。
在定义公共方法的签名时,我们也应该谨慎。通过参数或返回值传递不必要的数据可能导致不必要的耦合,增加处理数据的代码(例如,验证或确保不可变性)的复杂性,并限制性能优化的可能性。
一个设计不良的 API,其结构更多地由技术实现的便利性驱动,而不是业务需求,可能会暴露应该隐藏的元素。一旦这样的 API 开始使用,就很难更改,因为可能存在我们无法控制的客户端依赖于它。
如果这对于单个 Java 程序中的 API 是正确的,那么对于跨越单个程序边界的 REST API 来说,具有清晰分离的接口和详尽的文档就更加关键。
虽然可能需要使用散文提供一些额外的信息给 API 用户,但我们可以通过尽可能多地使用正式和机器可读的规范语言来描述我们的 API 来大大受益。对于 REST API,最广泛使用和最先进的标准是 OpenAPI 。
介绍 OpenAPI 和 JSON Schema
由 OpenAPI 倡议(www.openapis.org/
)支持的 OpenAPI 规范 定义了一种人类和计算机都能阅读的正式语言,以了解服务的功能,而无需以下:
访问服务实现代码
使用另一种格式(语言)的单独文档
分析网络流量
OpenAPI 规范最初基于 Swagger 规范。OpenAPI 可以使用 HTTP 协议描述任何 API,包括任何成熟度的 RESTful API。
OpenAPI 规范文档可以用 JSON 和 YAML 格式编写。YAML 语法(yaml.org/
)用缩进来代替了大量的 JSON 标点符号;因此,YAML 对于许多人来说更简洁,更容易阅读。在这本书中,我们将使用 YAML 格式来编写所有手动编写的 OpenAPI 规范(不是由工具生成的)。
典型 OpenAPI 规范的两个最重要的(通常也是最长的)部分如下:
Paths : 这些描述了 API 支持的操作,由资源、HTTP 方法、URI 参数等指定。
Schemas : 这些描述了请求和响应体(有效载荷)的复杂数据结构
我们的产品 API 使用两条路径:
/api/products/{productId}
: 这支持GET
、PUT
、DELETE
和PATCH
方法/api/products
: 这只支持GET
方法
以下是用到的模式:
ProductInput
: 这用于请求中的完整产品数据ProductDescriptionInput
: 这用于仅更新产品描述ProductOutput
: 这用于响应中的产品数据ProblemDetail
: 这用于错误响应
我们通过 API 传输复杂数据使用的格式是 JSON,这就是为什么 OpenAPI 规范的架构部分使用 JSON Schema 语言的方言。
JSON Schema 是一个独立的标准,可以用于在 OpenAPI 规范范围之外验证 JSON 文档。OpenAPI 规范每个版本的架构部分都是基于 JSON Schema 标准的一个版本,并添加了一些 OpenAPI 特定的修改。这就是为什么它被称为 JSON Schema 的 方言。使用我们想要使用的 OpenAPI 规范版本的架构部分支持的功能是很重要的。
让我们看看我们的产品 API 的 OpenAPI 规范会是什么样子。我们将使用 YAML 语法。在以下子节中,为了简洁起见,省略了一些 API 规范的属性。您可以在附带的 GitHub 仓库中看到完整的规范:github.com/PacktPublishing/Mastering-RESTful-Web-Services-with-Java/blob/main/chapter3/product-api/src/main/resources/Product_Catalogue_API.yml
。
常见 API 元数据
除了前面提到的规范的两个主要部分之外,还有提供有关 API 和其规范的元数据的头部部分。它们定义了 OpenAPI 标准的版本、我们 API 的版本、API 的基本 URL 以及一些可读性强的名称和描述:
openapi: 3.0.1
info:
title: Product Catalogue API
description: API for managing product catalog
version: 0.0.1
servers:
- url: http://localhost:8080
description: Generated server url
tags:
- name: products
description: the products API
产品 API 路径
产品 API 规范的以下部分是路径,从 /api/products/{productId}
的 GET
方法开始。使用 $ref
符号来引用架构。对于 200
、400
和 404
HTTP 响应码有单独的响应规范。我们还可以看到必需的 productId
URI 参数的规范:
paths:
/api/products/{productId}:
get:
parameters:
- name: productId
in: path
required: true
schema:
type: string
example: AK21109
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/ProductOutput'
'400':
content:
'*/*':
schema:
$ref: '#/components/schemas/ProblemDetail'
'404':c
content:
'*/*':
schema:
$ref: '#/components/schemas/ProblemDetail'
相同的路径继续使用需要请求体的 PUT
方法的规范:
put:
parameters:
- name: productId
in: path
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ProductInput'
required: true
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/ProductOutput'
'201':
(省略了其他状态码的响应体。)
让我们再看看一个操作:返回产品列表(由 JSON 数组表示)的操作:
/api/products:
get:
tags:
- products
summary: Retrieve all products
operationId: getProducts
responses:
'200':
description: A list of products
content:
application/json:
schema:
type: array
items:
$ref:
'#/components/schemas/ProductOutput'
产品 API 架构
将命名架构放在规范的一个独立部分,让我们可以在多个操作中重用它们,例如,ProductOutput
架构。
使用 example
属性,我们可以提供有关数据元素的信息,这些信息既可以由人类使用,也可以由自动化工具(测试客户端或模拟服务器生成器)使用。如果没有 example
属性,任何想要测试我们 API 的人将不得不仅根据它们的类型猜测有意义的值。生成随机字符串会导致不切实际的示例:
components:
schemas:
ProductOutput:
type: object
properties:
name:
type: string
example: Keyboard
productId:
type: string
description: ID of the product to delete
example: AK21109
description:
type: string
example: Ergonomic Keyboard
price:
type: number
example: 60
用于错误响应的ProblemDetail
架构非常通用,我们可能会希望在不同 API(微服务)之间重用它。OpenAPI 规范可以分成多个文件。我们还可以在 Web 服务器上托管可重用的架构,并使用 HTTP URL 引用它们。
然而,我们应该记住,它会在不同的 API 之间引入耦合,因此这种重用应该仅用于很少改变的架构(ProblemDetail
架构满足这一标准)。
我们已经介绍了 OpenAPI 规范的基本结构和最重要的属性。我们将在下一章中介绍更高级的 OpenAPI 功能。
在下一节中,你将了解你必须做出的一个重要决定:这是否想要从一个明确的抽象规范文档开始,例如前面展示的,或者从 Java 代码中推导出来。
在规范优先和代码优先之间进行选择
我们明确指出,为了我们 API 的成功,我们需要一个具体的实现和一个接口的抽象规范。在创建 API 时,有两种基本方法:规范优先(也称为设计优先或API 优先)和代码优先。
规范优先
从规范开始迫使 API 开发者提前决定 API 公共部分需要包含什么。有意隐藏消费者不需要知道的具体细节使 API 尽可能小,因此更容易维护和演进。
这里是规范优先的优点:
独立的规范通常较小,因为它们只定义了满足业务需求的 API 元素;由单独的规范文档定义的 API 更容易维护,规范变更也更可控。
规范优先确保 API 规范不对任何实现语言有偏见,支持用多种语言编写的 API 提供者和消费者。
你可以并行开发 API 提供者和消费者。连接各方之间的接口可以在不深入了解实现细节的情况下协商。
在 API 实现之前可以创建测试。
手写(非生成)的规范更容易被人阅读。
这里是采用规范优先的缺点:
开发团队需要掌握 API 规范语言,除了实现编程语言。
为了保持规范和代码之间的一致性,我们需要工具从规范中生成代码占位符。可用的代码生成器可能不支持规范和实现语言的所有功能。
代码优先
代码优先 优先考虑实现的速度,而不是干净的接口设计。API 规范实际上是反向工程自实现代码的。代码元素的注释和额外的元数据被手动添加,以帮助生成规范的工具。
以下是代码优先的优点:
跳过规范步骤可以更快地得到一个可工作的 API;这对于快速原型设计或影响较小的 API(其客户端受我们控制)是有意义的。
实现和接口都使用一种语言编写(通常需要一些注释或元数据的帮助)
可用实现代码可能有助于在 API 规范中包含性能或其他非功能性考虑因素。
以下是代码优先的缺点:
需要明确防止不希望的实施细节泄漏到生成的规范中。
需要掌握针对每种实现语言具有特定注释的规范生成工具,而不是使用标准的实现无关规范语言。
生成精致且易于阅读的规范很困难,尤其是对于更复杂的 API。
没有经过有意识的 API 设计步骤开发的 API 结构可能更多地反映了(首次)技术实现,而不是业务领域。这就是为什么它们往往难以维护和演进。
在本章中,我们将演示 Product API 的代码优先方法,因为我们已经从前一章获得了其实现代码。
下一章将展示规范优先的方法。
记录 Product API
为了记录我们的 API,我们需要添加一些来自 Swagger 的依赖项。在 Spring 框架的情况下,我们有 springdoc-openapi-starter-webmvc-ui
依赖项,它涵盖了记录 Spring 应用程序 API 所需的一切:
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version>
</dependency>
在 pom.xml
中添加此依赖项允许我们使用 Swagger 注释和 Swagger UI。在访问 http://localhost:8080/swagger-ui/index.html
后,我们可以看到在 图 3.1 中表示的 Swagger UI。
图 3.1 – Swagger UI
Swagger UI 是基于 RestController
和我们的端点生成的。为了使我们的 API 对客户端更加友好和易于理解,我们应该使用 Swagger 注释来增强它。这些注释位于 io.swagger.v3.oas.annotations
包中。让我们探索一些它们,并看看它们如何应用于改进我们的 API 文档。
Swagger 注释
Swagger 注释 是由用于生成交互式 API 文档的 Swagger 库提供的一组注释。当这些注释添加到您的代码中时,有助于定义和描述您的 API 端点、请求和响应模型以及整体 API 元数据的结构和行为。
@Tag
@Tag
注解用于将一组操作分组;我们可以在我们的 API 类中使用它来告知每个方法都应该属于这个标签:
@Tag(name = "products", description = "the products API")
public interface ProductsApi {
....
}
@Operation
@Operation
注解用于方法声明中命名 API 端点。这个注解可以与其他注解结合使用,以提供端点的全面描述,包括可能的输出和 HTTP 状态码:
@Operation(
operationId = "deleteProduct",
summary = "Logical remove a product by ID",
responses = {
@ApiResponse(responseCode = "204",
description = "Product removed successfully"),
}
)
ResponseEntity<Void> deleteProduct(
@Parameter(name = "productId",
description = "ID of the product to delete", required = true,
in = ParameterIn.PATH, example = "AK21109") @ValidSku
String productId
);
在前面的代码中,我们使用@Operation
注解来定义delete
端点的描述。@ApiResponse
注解用于添加可能的响应状态,在这种情况下,204
表示产品已成功删除。
@Parameter
在我们之前的例子中,我们使用了@Parameter
注解。这个注解专门设计用于应用于方法参数,允许定义清晰的描述、示例和有关每个参数的附加信息。这些细节对于 API 消费者非常有用,因为它们提供了清晰的示例和必要的信息,增强了 API 的可用性。
@ApiResponse
在我们之前的例子中,我们应用了@ApiResponse
注解。这个注解用于指定 API 端点的潜在响应,详细说明 HTTP 状态和响应类型,并在适用的情况下提供示例。在我们之前的案例中,没有返回体,因为它是204
;让我们看看GET
操作的示例:
@Operation(
operationId = "getProductById",
summary = "Retrieve a product by ID",
responses = {
@ApiResponse(responseCode = "200", description = "Product found",
content = {
@Content(mediaType = "application/json",
schema = @Schema(implementation = ProductOutput.class))}),
@ApiResponse(responseCode = "404",
description = "Product not found",content = @Content(
schema = @Schema(implementation = ProblemDetail.class),
examples = {
@ExampleObject(name = "Validation Error",
summary = "Example of validation error",
value = """
{"type":"about:blank",
"title": "Not Found",
"status": 404,
"detail": "Product not found with id AK21102",
"instance": "/api/products/AK21102"
}
""")}))})
ResponseEntity<ProductOutput> getProductById(
@Parameter(name = "productId",
description = "ID of the product to delete", required = true,
in = ParameterIn.PATH, example = "AK21109")
@ValidSku String productId
);
在我们的例子中,我们有两个@ApiResponse
实例,第一个是成功响应,状态码为200
。在这种情况下,我们使用@Schema
注解来引用实现类,特别是ProductOutput.class
。
第二个实例涉及一个404
错误,使用的是我们领域之外的架构,即 Spring 中的ProblemDetail.class
。在这种情况下,我们使用 Swagger 的另一个注解@ExampleObject
注解,通过 Java 的文本块功能来增强可读性,创建预期的错误响应的结构化 JSON 表示。
@Schema
在我们之前的例子中,我们使用了@Schema
注解来标识 API 响应的内容。这个注解也用于描述请求和响应模型的字段。让我们通过使用ProductInput
来查看一个例子:
public record ProductInput(
@NotBlank
@Size(min = 3, max = 255)
**@****Schema****(name =** **"name"****,**
**requiredMode =** **Schema****.RequiredMode.REQUIRED,**
**example =** **"Keyboard"****)**
@JsonProperty("name")
String name,
@NotBlank
@Size(min = 10, max = 255)
**@Schema****(name =** **"description"****,**
**requiredMode =** **Schema****.RequiredMode.REQUIRED,**
**example =** **"Ergonomic Keyboard")**
@JsonProperty("description")
String description,
@NotNull
@Positive
**@Schema****(name =** **"price",**
**requiredMode =** **Schema****.RequiredMode.REQUIRED,**
**example =** **"60.0"****)**
@JsonProperty("price")
BigDecimal price) {
}
在这个例子中,我们使用@Schema
来定义每个字段的名称,指示字段是否必需,并提供示例值。示例值对于新 API 用户特别有用,因为它提供了关于预期输入和输出的清晰信息。
通过结合 bean 验证,我们增强了我们的 API,提供了有关字段的其他信息。ProductInput
生成的代码将类似于以下内容:
"ProductInput": {
"required": [
"description",
"name",
"price"
],
"type": "object",
"properties": {
"name": {
"maxLength": 255,
"minLength": 3,
"type": "string",
"example": "Keyboard"
},
"description": {
"maxLength": 255,
"minLength": 10,
"type": "string",
"example": "Ergonomic Keyboard"
},
"price": {
"type": "number",
"example": 60
}
}
}
通过在整个 API 中应用这些注解,我们实现了清晰且定义良好的文档。现在,我们可以重新访问 Swagger UI 来审查和探索更新的文档。
注意
许多模拟服务器使用 Swagger 实现来生成模拟;对于输入和输出的清晰定义和示例对于生成更好的模拟至关重要。
使用 Swagger UI
Swagger UI是一个基于 Web 的界面,它为 API 提供交互式文档。它允许开发者可视化并执行 API 端点,使其更容易理解和实验 API。
在访问 Swagger UI 之前,让我们添加一个来自 Swagger 的额外注解:OpenAPI
注解。这个注解包含了我们 API 的一些元数据。我们可以在 Spring 中通过定义OpenAPI
bean 来实现这一点:
@Configuration
public class SpringDocConfiguration {
@Bean
OpenAPI apiInfo(
@Value("${application.version}") String version) {
return new OpenAPI().info(
new Info()
.title("Product Catalogue API")
.description("API for managing product catalog")
.version(version));
}
}
在此代码中,我们使用 Swagger 的OpenAPI
bean 来定义元数据信息。具体来说,我们设置了 API 的标题、描述和版本。版本信息是从应用程序属性中检索的,这使得我们可以将其与pom.xml
中指定的版本同步。
这个注解支持以下元数据项:
openapi
:OpenAPI 规范的版本。info
:这提供了关于 API 的元数据:title
:API 的标题version
:API 的版本description
:API 的简要描述termsOfService
:API 服务条款的 URLcontact
:API 的联系方式license
:API 的许可信息servers
:这指定了 API 可以访问的服务器:url
:服务器的 URLdescription
:服务器的描述paths
:API 的可用端点;这通常是从之前显示的注解生成的。components
:组件是可以重复使用的模式或其他对象,可以在 API 规范中的多个地方引用。这也是基于之前显示的注解生成的。security
:这定义了 API 的安全机制。它也是从@SecurityRequirement
注解生成的。tags
:这提供了由规范使用的标签列表以及附加的元数据。它也是从@Tag
注解生成的。externalDocs
:这提供了关于 API 的外部文档:description
:外部文档的简短描述url
:外部文档的 URL
所有的前述注解和配置参数都可以用来丰富 API 文档。现在我们的 API 已经在 Java 代码中进行了文档化,我们可以通过访问http://localhost:8080/swagger-ui/index.html
的 Swagger UI 来查看结果,如图 3.2 所示:
图 3.2 – Swagger UI
如前图所示,我们的 API 具有更多细节。它包含了每个端点的描述以及OpenAPI
bean 中提供的所有元数据。
此外,我们还有提供关于我们的数据模型详细信息的模式。这些模式包括每个字段的详细信息,指示字段是否必需,以及示例值,如图 3.3 所示。
图 3.3 – Swagger 架构
通过这些信息,API 用户可以更好地理解 API,简化创建客户端的过程。在第四章中,我们将探讨如何使用生成的文档来帮助生成 HTTP 客户端。
生成文档文件很简单。在 Swagger UI 中,有一个链接可以打开 JSON 格式的 OpenAPI 规范,如图 3.4 所示:
图 3.4 – 文档文件链接
此规范文件可供任何希望集成我们 API 的人使用。许多工具,如 Postman、IntelliJ IDEA 等,与 OpenAPI 标准无缝集成,提供了众多好处。
OpenAPI 规范的广泛用途是我们应该优先分享良好的 OpenAPI 规范而不是以库(JAR 文件)形式提供的现成 Java 客户端的原因。即使我们希望仅限于 Java 实现的客户端,现成的库也可能引起问题:
使用第三方库或其版本的不兼容性
代码风格与客户端应用程序的其他部分不匹配(例如,响应式编程与命令式编程)
缺少或不兼容的注释
Swagger UI 还提供了一种方便的方式与 API 进行交互并查看结果。如图 3.5 所示,这一功能特别有用,因为它允许开发者轻松测试端点并直接在界面中观察响应。
图 3.5 – 尝试使用 – Swagger UI
在前面的图中,我们可以看到一个“尝试使用”按钮,它允许用户直接从文档中与 API 进行交互。这一功能使得开发者能够发送真实请求并在实时查看响应,如图 3.6 所示。这是一种无需编写外部脚本或工具即可验证 API 行为的简单方法。
图 3.6 – 执行 Swagger UI
Swagger UI 提供的这些功能通过简化测试、理解和集成 API 的过程,大大提升了开发者的体验。交互式文档允许开发者探索 API 的功能,确保他们可以快速掌握其工作原理,并有效地将其集成到自己的应用程序中。
摘要
在本章中,你学习了 OpenAPI 规范以及如何利用 Swagger 注释生成文档。在下一章中,我们将探讨如何使用此文档来开发客户端,并深入探讨规范优先的方法。
第四章:使用 OpenAPI 生成代码
在本章中,我们将开始开发一个全新的 API,称为订单管理,该 API 将与在第二章中开发的 Product API 以及在第第三章中演化的 API 进行交互。
在上一章中,产品 API 是按照代码优先的方法开发的,即在文档之前实现 API。
在本章中,我们将采用规范优先的方法,也称为 API-first,即首先完成整个 API 的规范,然后实现 API。
首先,我们将为订单管理 API 构建 OpenAPI 规范,声明其路径、方法和请求及响应模式(其中一些使用多态)。
然后,我们将使用 OpenAPI 工具从 API 规范生成 Java 代码存根。此生成的代码加快了实现过程,减少了样板代码的手动维护,最重要的是,使得在 API 规范和服务实现之间保持一致性更容易,以便于未来的更改。
接下来,我们将介绍服务的包结构和 API 构建的代码实现要点,将其交付为可工作的状态。
最后,我们将探讨在订单管理和产品 API 之间建立通信,通过调用外部 API 并根据接收到的条件相应地做出响应,以验证在此服务中创建的订单中将要注册的产品。
到本章结束时,您将构建出开始设计、开发和集成规范优先 API 所需的所有知识,这将极大地增强您的实力,并以令人难以置信的方式加快您的设计和开发阶段。
在本章中,我们将涵盖以下主题:
订单管理 API 规范
从规范生成代码
订单管理 API 的包结构
实现订单管理 API 控制器
与产品 API 通信
技术要求
在本章中,我们将实现一个示例订单管理 API。为了能够跟随本书中的代码示例进行操作,您应该具备以下条件:
对 Java 语言和平台有中级知识
至少需要了解 Spring Boot 或类似框架的基本知识
已安装 Java 21 和 Maven 3.9.0
在本章中,我们将应用 API-first REST 原则来创建我们的 API;您可以在 GitHub 上找到本章的代码,网址为github.com/PacktPublishing/Mastering-RESTful-Web-Services-with-Java/tree/main/chapter4
。
规范订单管理 API
在本节中,您将开发订单管理 API 的操作和数据结构的规范。订单管理 API 作为我们在本书中构建的项目的一部分,将处理由产品 API 管理的产品的订单。
如前一章所述,规范优先意味着在 API 的实际实现之前创建一个抽象的 API 规范。您不是从编程语言中的可执行代码开始,而是首先定义 API 的结构、行为和功能。这就是为什么这也被称为 API 优先开发。
在第一章中,我们探讨了在设计 RESTful API 时应考虑的许多关键原则。让我们看看如何使用 API 优先的方法应用这些原则。
在 API 优先开发中实施 HTTP 原则
当采用 API 优先的方法时,从设计阶段开始就考虑 HTTP 术语至关重要。让我们看看一些原则:
首先定义 API 合约:使用 OpenAPI/Swagger 等工具定义您的 API 结构,包括端点、请求/响应格式和数据模型。此合约作为开发者的蓝图,有助于确保一致性和清晰性。在下一节中,我们将使用 OpenAPI 规范文件为订单管理 API 构建 API 合约。
一致使用 HTTP 方法和状态码:遵循如第二章中看到的 HTTP 方法和状态码的约定。例如,使用
GET
检索资源,POST
创建,PUT
更新,DELETE
删除资源。使用适当的状态码来传达请求的结果。这些也应在规范文件中描述。强调资源建模:关注准确地将您的领域建模为资源。这包括识别关键实体及其关系,通过可以使用 HTTP 方法访问的 URL 来识别它们。例如,在我们的订单管理 API 中,资源是
Customers
、Products
和Orders
。规划版本控制:实施版本控制策略以处理 API 的演变。这可以通过 URL 路径版本控制(例如,
/v2/books
)和标准或自定义请求头来完成。版本控制支持向后兼容性和随着 API 的增长和变化而平稳过渡。这将在我们的规范文件的通用 API 元数据中定义。考虑安全性:定义认证和授权机制,例如 OAuth、API 密钥或 JSON Web Tokens (JWTs),以保护您的 API。我们将简要介绍如何将安全方案添加到我们的规范中,您将学习如何在第七章中深入实现它。
现在我们对通过此定义要实现的原则有了更清晰的了解,让我们开始编写规范本身。
设计 OpenAPI 规范
我们将使用 YAML 语法编写订单管理 API 规范。您可以使用您个人偏好的任何编辑器来跟随:您可以使用在线 Swagger 编辑器 editor.swagger.io/
,或者如果您使用 JetBrains IntelliJ 或 Microsoft VS Code 等 IDE 工作,您可以使用插件来获得类似 Swagger 网站的经验,并在 IDE 内部编写规范时验证您的规范。
我们将创建的完整规范可在 GitHub 上找到,链接为 github.com/PacktPublishing/Mastering-RESTful-Web-Services-with-Java/tree/main/chapter4
。
让我们从定义 API 的公共元数据开始编写规范文件。
公共 API 元数据
我们将首先使用 API 的元数据定义规范的最高层上下文,包括 API 的标题、描述和版本。此外,我们在这里描述应用程序将运行的服务器。在我们的案例中,因为我们想本地运行它,所以我们将使用 localhost
进行测试目的:
openapi: 3.0.0
info:
title: Order Management API
description: API for managing orders
version: 1.0.0-SNAPSHOT
servers:
- url: http://localhost:8080/api/v1
订单管理 API 路径
此 API 将有三个路径:
/orders
使用POST
和GET
方法分别用于创建和检索订单/orders/{orderId}
使用GET
、PUT
和DELETE
方法分别用于访问、编辑和删除特定订单/orders/{orderId}/status
使用PATCH
方法来更改现有订单的状态
/orders
路径的方法
让我们先定义 POST
方法及其请求和响应。记住,$ref
符号用于引用规范文档中单独部分的规范定义。这允许规范可重用,并且使 paths
部分更短、更易读:
paths:
/orders:
post:
tags:
- OrderManagement
summary: Create Order
description: Creates a new order
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/OrderRequestBody'
responses:
'201':
description: Created
content:
application/json:
schema:
$ref: '#/components/schemas/OrderResponse'
'400':
description: Bad Request
在这个 POST
定义中,我们定义了当控制器接收到带有 /orders
路径的 POST
请求时,请求应包含 OrderRequestBody
规范定义中的主体。如果一切顺利,API 将以 201 Created
HTTP 状态码响应,主体由 OrderResponse
规范定义。如果请求主体不匹配 OrderRequestBody
,API 将以 400 Bad Request
HTTP 响应代码响应。我们将在本章后面定义请求和响应主体。
此外,为了帮助 OpenAPI 生成器命名从我们的规范中生成的类,我们在每个方法操作中指定了一个标签。在这种情况下,标签将被命名为 OrderManagement
。如果我们省略标签,生成的类将被命名为 DefaultApi
。我们将在配置生成器并开始从规范生成代码的 从规范生成代码 部分详细说明这一点。
接下来,我们将为 /orders
路径端点定义 GET
方法:
get:
tags:
- OrderManagement
summary: List Orders
description: Retrieves a list of orders
responses:
'200':
description: OK
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/OrderResponse'
这是一个 GET
方法,当调用时,它返回订单列表(如果有),HTTP 状态为 200
,以及一个包含系统中所有订单的对象数组 OrderResponse
。如果没有订单,返回的列表仍然为空,HTTP 状态为 200
,这意味着请求已成功完成。
我们将在下一个路径中指定一个端点来检索系统中的单个订单。
/orders/
现在,我们将定义另一个路径来访问特定的订单,并且我们将为这个路径定义一个 GET
、一个 PUT
和一个 DELETE
方法,从 GET
方法开始:
/orders/{orderId}:
get:
tags:
- OrderManagement
summary: Get Order
description: Retrieves a single order by its ID
parameters:
- name: orderId
in: path
required: true
description: The ID of the order to retrieve
schema:
type: string
example: 123456
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/OrderResponse'
'404':
description: Not Found
在 GET
方法中,我们正在定义一个路径来从单个订单中检索数据。在这里,我们正在路径中接收一个参数,以便能够从特定的订单中获取数据,该订单定义为 /orders/{orderId}
路径,包含在 {orderId}
占位符中的订单 ID。我们还需要在 parameters
下定义该参数,在 in: path
标志下。这就是我们指定 orderId
是要接收的参数的地方。
在成功的情况下,将返回一个包含订单本身的 OrderResponse
对象,HTTP 响应状态为 200 OK
。如果没有给定 ID 的订单,将返回 404 Not Found
HTTP 状态。
现在,让我们看看 PUT
方法:
put:
tags:
- OrderManagement
summary: Update Order
description: Updates an existing order
parameters:
- name: orderId
in: path
required: true
description: The ID of the order to be updated
schema:
type: string
example: 123456
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/OrderRequestBody'
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/OrderResponse'
'400':
description: Bad Request
'404':
description: Not Found
与 GET
方法类似,使用 PUT
方法,我们正在定义一个更新单个订单的操作。请注意,我们在这里展示的方法与构建 Product API 时采用的方法不同,在那里我们使用了 PUT
方法来创建实体。在订单管理 API 中,我们使用一个单独的 POST
方法(请求中不带 ID)来创建订单。
PUT
是一个幂等方法,这意味着它可以多次调用(使用相同的订单 ID)而不会影响结果。另一方面,POST
不是幂等的,所以每次调用它都会在我们的服务中创建一个新的订单。
对于 PUT
方法,我们在 /orders/{orderId}
路径中接收订单 ID,通过在参数列表中定义的 orderId
参数和 OrderRequestBody
接收订单 ID,这与在 POST
方法中看到的方式相同,包含要更新的订单的所有详细信息。
如果操作成功,它将返回一个 200 OK
HTTP 状态码和包含持久化订单详细信息的 OrderResponse
。如果找不到订单,将返回 404 Not Found
。如果请求格式错误,将返回 400 Bad Request
。
最后,让我们看看用于取消特定订单的 DELETE
方法:
delete:
tags:
- OrderManagement
summary: Cancel Order
description: Cancels an existing order
parameters:
- name: orderId
in: path
description: ID of the order to be cancelled
required: true
schema:
type: string
example: 123456
responses:
'204':
description: No Content
'403':
description: Forbidden
'404':
description: Not Found
在这个方法中,我们需要传递一个名为 orderId
的参数,它是要取消的订单的 ID,这与我们在 GET
和 PUT
方法中所做的方式相同。
如果操作成功,将向调用者返回一个 204 No Content
HTTP 状态码,因为没有响应体。如果没有指定 ID 的订单,将返回 404 Not Found
。
/orders/
最后,让我们看看最后一个定义的端点路径,使用 PATCH
方法来更改订单的状态:
/orders/{orderId}/status:
patch:
tags:
- OrderManagement
summary: Change Order Status
description: Change an Order Status
parameters:
- name: orderId
in: path
required: true
description: The ID of the order to be updated
schema:
type: string
example: 123456
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/OrderStatus'
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/OrderResponse'
'404':
description: Not Found
此端点的目标是更新订单的状态。在现实世界的生产环境中,订单的状态取决于由其他服务在业务逻辑中定义的外部情况,例如,由支付提供商验证的支付批准、仓库中物品的可用性,甚至欺诈检测;有一个方法来通过外部调用更改订单的状态是很重要的,而这个端点正是为了完成这个任务。
注意,此请求的正文是通过引用来定义的,使用 $ref
指向下一节将要定义的 OrderStatus
架构。这展示了规范优先设计的可重用性,其中你定义一个可重用的架构,可以在 OpenAPI 架构定义文件中的多个路径定义、参数和端点中引用,从而消除重复定义的需要。下一节将涵盖这个主题。
现在,我们将定义用于请求和响应正文以及作为我们刚刚定义的操作参数的架构。
定义 API 架构
现在我们已经完成了定义识别我们 API 资源路径的路径,我们将定义我们在上一节中定义的操作中使用的架构。这些架构描述了这些操作的请求和响应正文的结构,也可以像之前看到的那样用作操作的参数。
图 4.1:从定义的 API 架构生成的实体-关系图
在前面的图中,你可以看到将要由我们即将编写的架构定义扩展而创建的实体之间的关系。这就是为什么它们在名称定义上有所不同。这样,你可以从 OpenAPI 架构中定义整个实体结构,从而加强 API 优先原则。
架构定义位于 OpenAPI 规范的 components
部分下。
OpenAPI 架构使用一种支持定义 JSON 数据的复杂结构和结构之间关系的语言。架构位于 schemas
部分的条目中,首先是 Product
和 Customer
:
components:
schemas:
Product:
type: object
properties:
productSKU:
type: string
quantity:
type: integer
Customer:
type: object
properties:
customerType:
type: string
streetAddress:
type: string
city:
type: string
postalCode:
type: string
discriminator:
propertyName: customerType
mapping:
person: '#/components/schemas/PersonCustomer'
company: '#/components/schemas/CompanyCustomer'
Product
是我们规范中最简单的架构。它仅仅定义了其类型是一个具有两个属性的 JSON 对象:productSKU
类型为 string
,它将成为我们产品的 ID,以及 quantity
类型为 integer
,它将表示该产品的可用性。
转到 Customer
架构,它是一个包含四个属性的对象:customerType
、streetAddress
、city
和 postalCode
。所有属性都是 string
类型。但在这里,我们也开始引入继承和多态性的概念。
Customer
架构将作为一个父(基础)架构,它将由另外两个架构扩展——PersonCustomer
和 CompanyCustomer
——这两个架构我们将在后面介绍。这种设置展示了继承,有一个父架构和两个子架构,以及多态性,允许 Customer
代表 PersonCustomer
或 CompanyCustomer
。
正是在这里,discriminator
属性开始发挥作用。当 JSON 解析器尝试在从 JSON 加载并转换为运行时对象的过程中反序列化 JSON 数据时,discriminator
属性被用来指示它正在从哪个子架构继承。在我们的例子中,判别器将是 customerType
属性。这个属性将定义 Customer
是 PersonCustomer
类型还是 CompanyCustomer
类型。
当你下订单时,这个属性会被使用,作为之前定义的 POST
请求体的一部分。有了判别器,单个端点可以接受两个不同的体,一个包含 Customer
类型为 PersonCustomer
的个人客户,另一个包含 Customer
类型为 CompanyCustomer
的公司。
现在,让我们定义子架构 PersonCustomer
和 CompanyCustomer
,并将它们与父 Customer
架构相关联:
PersonCustomer:
type: object
allOf:
- $ref: '#/components/schemas/Customer'
properties:
firstName:
type: string
lastName:
type: string
CompanyCustomer:
type: object
allOf:
- $ref: '#/components/schemas/Customer'
properties:
companyName:
type: string
vatId:
type: string
这两个架构定义看起来很相似;它们都使用了 allOf
关键字,该关键字引用 Customer
架构。它定义子架构应该包含(继承)Customer
架构的所有属性。
由于我们在规范中实现了层次结构,让我们简要地看看如何使用 OrderRequestBody
创建新订单,该 OrderRequestBody
将用于 POST
和 PUT
方法来创建和更新应用程序中的资源。
图 4.2 – OpenAPI Swagger UI 中的 OrderRequestBody 架构
POST
方法期望在请求体中包含 OrderRequestBody
。由于我们在定义中使用了 discriminator
,因此在这里我们需要在请求中选择一个体,如前图中的 oneOf->
所指定:PersonCustomer
、CompanyCustomer
或 Customer
。customerType
字段的值需要是 person
、company
或 customer
中的一个,以匹配定义的体之一。单独使用 Customer
不被鼓励,因为它没有从层次结构和多态性中受益,但重要的是要知道,如果需要,你也可以单独使用它,而不需要 PersonCustomer
或 CompanyCustomer
的额外字段。
然后,你可以添加这些每个对应的字段,所以如果是一个公司,你将在请求体中拥有vatId
和companyName
等字段,如果是个人,你将在请求中拥有firstName
和lastName
等字段。在Customer
规范中提到的字段对两种客户类型都是通用的。
为了完成我们 API 规范中的模式部分,我们将定义由订单管理 API 使用的请求和响应体:
OrderRequestBody:
type: object
properties:
customer:
$ref: '#/components/schemas/Customer'
products:
type: array
items:
$ref: '#/components/schemas/Product'
OrderResponse:
type: object
properties:
id:
type: string
customer:
$ref: '#/components/schemas/Customer'
products:
type: array
items:
$ref: '#/components/schemas/Product'
totalAmount:
type: number
orderCreatedDate:
type: string
format: date-time
orderUpdatedDate:
type: string
format: date-time
status:
$ref: '#/components/schemas/Status'
OrderStatus:
type: object
properties:
status:
$ref: '#/components/schemas/Status'
Status:
type: string
enum: [ Pending, Approved, Cancelled ]
在OrderRequestBody
对象定义中,有两个属性——一个是上面定义的Customer
类型,使用$ref
来引用其定义,以及products
属性,它是一个 JSON 数组,将在OrderRequestBody
中表示一个Product
JSON 对象的列表。
在响应模式定义中,OrderResponse
包含一个Product
JSON 对象的数组,包含特定订单的每个产品,customer
属性引用Customer
模式并返回此订单的客户数据,orderCreatedDate
和orderUpdatedDate
属性包含订单创建和更新的日期和时间(如果有),以及status
属性,它引用定义的OrderStatus
,该OrderStatus
引用Status
enum
,包含订单的所有可能状态:Pending
、Approved
或Cancelled
。由于我们可以在规范中的任何地方重用这些定义,因此OrderStatus
模式也在订单状态更改端点作为请求体定义中引用,以允许请求中的值。
在 API 之间共享模式
使用 Java 和其他编程语言,我们习惯于定义可重用的库以避免在多个项目中重复相同的代码。OpenAPI 规范也可以引用外部文件,因此可以在多个 API 之间共享公共部分。
然而,你应该意识到这种方法会在 API 之间创建耦合。如果公共部分发生变化,可能会导致意外的不一致。因此,建议尽量保持单个 API 规范的自包含。如果你想要重用模式,你应该确保它们非常稳定。
这样的一个例子可能是用于报告错误的结构。但在我们的示例 API 中,我们使用基于 RFC 7807 互联网标准的ProblemDetail
模式。这样,我们避免了维护共享模式。
定义安全方案
除了模式之外,OpenAPI 规范的组件部分也可以用来指定我们 API 的安全相关要求。在这里,我们将指定要使用的身份验证机制:OpenID Connect 配合 JWT。你将在第七章中了解更多关于安全的信息。
securitySchemes:
JWTAuth:
type: http
scheme: bearer
bearerFormat: JWT
securitySchemes
定义了一个名为JWTAuth
的安全方案,并将其全局应用于 API 中的所有操作。以下是其组件及其影响的分析:
JWTAuth
:这是安全方案的标识符。type: http
:这指定了安全方案是基于 HTTP 的。scheme: bearer
:这表示该方案使用携带令牌。bearerFormat: JWT
:这指定了携带令牌的格式为 JWT。
在定义了所有这些之后,我们可以从components
关键字移开,了解这个定义中的最后一个关键字,即security
关键字:
security:
- JWTAuth: []
这个安全定义处于根级别,它表明在 JWT 中访问 API 不需要特定的作用域或权限。因此,所有 API 调用都必须在授权头中提供有效的 JWT,但令牌本身不需要指定任何特定的作用域。
如果你不想使用 JWT 进行认证,可以考虑使用会话状态 ID 作为替代方案。在 JWT 出现之前,它们被广泛使用,并且由于许多原因,JWT 取代了会话状态 ID。
在查看如何使用会话状态 ID 之前,让我们看看 JWT 成为微服务首选认证方法的关键原因:
无状态:JWT 是无状态的,这意味着它们不需要服务器维护会话状态。这对微服务有益,因为微服务被设计成无状态和可扩展的。
可扩展性:由于 JWT 是自包含的,并且不需要服务器端存储,与需要集中式会话存储的会话状态 ID 相比,它们在分布式系统中具有更高的可扩展性。
去中心化认证:JWT 可以被任何拥有公钥的服务验证,允许在多个微服务之间进行去中心化认证,而无需中央会话存储。
互操作性:JWT 是一个标准(RFC 7519),并且在不同的平台和语言中得到广泛支持,这使得它们适合异构微服务环境。
安全性:JWT 可以进行签名和可选加密,提供完整性和机密性。它们还可以包含提供有关用户或会话的额外上下文的声明。
但在特定情况下,例如处理遗留服务和与已使用会话状态 ID 的现有服务的集成时,这种方法可能是必要的。
会话状态 ID 涉及在服务器端维护会话,其中每个会话由一个唯一的会话 ID 标识。这个会话 ID 存储在客户端,通常在 cookie 中,并随每个请求发送到服务器。然后服务器验证会话 ID 并检索相关的会话数据。
会话状态 ID 的一些优点是服务器对会话拥有完全控制权,包括在任何时候使会话失效的能力,并且与 JWT 相比,无需处理令牌过期和续订。
另一方面,它需要在服务器上维护会话状态,这在分布式环境中可能具有挑战性,并且与无状态的 JWT 不同,会话状态 ID 需要服务器维护状态。
下面是一个示例,说明您如何在 OpenAPI 架构中定义会话状态 ID:
components:
securitySchemes:
SessionIDAuth:
type: apiKey
in: cookie
name: SESSIONID
security:
- SessionIDAuth: []
在此示例中,SessionIDAuth
安全方案指定会话 ID 将以名为 SESSIONID
的 cookie 发送。然后服务器将验证此会话 ID 以验证用户身份。
在我们的服务中,我们不会涵盖会话状态 ID,因为它们破坏了无状态目标 RESTful 微服务,并且有了上面解释的所有 JWT 优点,但了解如何使用它们以规范优先的方法是很好的。
此外,如前所述,我们将更深入地探讨第 7 章 的安全主题。
这样,我们就完成了规范。您可以将文件保存为表示您的 API 的名称,并以 .yml
扩展名结尾(YAML 的简称)。在我们的示例中,文件将命名为 Order_Management_API.yml
。
如前所述,您可以在 GitHub 仓库中查看完整文件,网址为 github.com/PacktPublishing/Mastering-RESTful-Web-Services-with-Java/tree/main/chapter4
。
接下来,我们将使用 openapi-generator-maven-plugin
从此规范生成代码。让我们看看如何操作。
从规范生成代码
为了从规范生成代码,应用程序需要为此做好准备。在本节中,您将配置一个插件,并从我们在规范文件中指定的内容生成代码。
以下 XML 片段配置了 openapi-generator-maven-plugin
,这是为我们的 Maven 项目设计的,专门用于从上一节中定义的 OpenAPI 规范生成基于 Spring 的 Java 代码。此插件简化了 API 端点、模型和配置类的自动创建。
我们将在项目中的 pom.xml
文件中配置插件。您可以参考 GitHub 仓库以查看完整文件,但在这里我们将关注实现我们目标的具体更改。现在让我们看看这个配置:
<plugin>
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId>
<version>7.5.0</version>
<executions>
<execution>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<inputSpec>${project.basedir}/your_spec_API.yml
</inputSpec>
<generatorName>spring</generatorName>
<apiPackage>[package where generated code is added]
</apiPackage>
<modelPackage>[new package where DTOs are added]
</modelPackage>
<modelNameSuffix>Dto</modelNameSuffix>
<configOptions>
<documentationProvider>springdoc
</documentationProvider>
<interfaceOnly>true</interfaceOnly>
<oas3>true</oas3>
<openApiNullable>false</openApiNullable>
<serializableModel>true</serializableModel>
<useBeanValidation>true</useBeanValidation>
<useSpringBoot3>true</useSpringBoot3>
<useTags>true</useTags>
</configOptions>
</configuration>
</execution>
</executions>
</plugin>
下面是其关键组件的分解:
插件识别:
groupId
和artifactId
在 Maven 生态系统中标识插件:<groupId>org.openapitools</groupId> <artifactId>openapi-generator-maven-plugin</artifactId>
插件版本:指定要使用的插件版本,确保兼容性并访问此版本中可用的特定功能:
<version>7.5.0</version>
执行配置:
executions
块定义了插件何时以及如何运行。名为generate
的目标触发代码生成过程:<goals> <goal>generate</goal> </goals>
规范输入 :
inputSpec
配置指向 OpenAPI 规范文件。此路径相对于项目的基本目录,表示插件应在何处查找上一节中详细说明的 API 定义:<inputSpec>${project.basedir}/src/main/resources/Order_Management_API.yml</inputSpec>
生成器配置 :
generatorName
指定生成的代码应针对 Spring 进行定制,这会影响输出中使用的结构和注解:<generatorName>spring</generatorName>
包名 :
apiPackage
和modelPackage
配置分别定义了生成的 API 接口(API 操作)和模型类(数据结构)的 Java 包名。这有助于在项目结构中组织生成的代码。此外,通过modelNameSuffix
添加的后缀有助于生成具有此后缀名称的 DTO:<apiPackage>com.packt.ordermanagementapi.adapter.inbound.rest </apiPackage> <modelPackage>com.packt.ordermanagementapi.adapter.inbound.rest.dto </modelPackage> <modelNameSuffix>Dto</modelNameSuffix>
附加选项 :
configOptions
部分提供了对生成的代码的进一步自定义。让我们简要描述我们在这里使用的每个选项及其功能:documentationProvider
: 这指定了要使用的文档提供程序。在这种情况下,使用springdoc
生成 API 文档。interfaceOnly
: 当设置为true
时,仅生成 API 的接口,而不生成任何实现。oas3
: 这表示正在使用 OpenAPI 3.0 规范。openApiNullable
: 当设置为false
时,生成器将不会为可选字段使用@Nullable
注解。serializableModel
: 当设置为true
时,生成的模型将实现Serializable
接口。useBeanValidation
: 当设置为true
时,生成的模型将包括用于 bean 验证的注解(例如,@NotNull
,@Size
)。useSpringBoot3
: 当设置为true
时,生成器将生成与 Spring Boot 3 兼容的代码。useTags
: 当设置为true
时,生成器将使用 OpenAPI 规范中定义的标签来分组 API 操作。
<configOptions> <documentationProvider>springdoc</documentationProvider> <interfaceOnly>true</interfaceOnly> <oas3>true</oas3> <openApiNullable>false</openApiNullable> <serializableModel>true</serializableModel> <useBeanValidation>true</useBeanValidation> <useSpringBoot3>true</useSpringBoot3> <useTags>true</useTags> </configOptions>
有了这些,我们已经覆盖了 pom.xml
文件中插件配置的各个方面。
接下来,让我们执行构建并生成实现:
mvn clean install
如果你的构建成功,你会注意到在你的目标文件夹中生成了 DTO 和接口。
图 4.3 – 在目标文件夹中生成的实现
如果你使用 IntelliJ IDEA,切换到 包 视图,这样你就可以看到生成的源代码以及项目的结构,并与生成的源代码和你的实现无缝工作,如下面的截图所示:
图 4.4 – 与项目结构一起生成的实现
通过在target
文件夹中生成代码,我们清楚地将其与src
文件夹中手动编写的代码分开。生成的代码不应存储在版本控制系统(Git 或其他)中,以避免与应从中生成的源规范不一致。相反,每次 Maven 构建都会生成新的代码。
让我们看看生成的PersonCustomerDto
类中的结果:
@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2024-07-21T20:59:33.440034506-03:00[America/Sao_Paulo]", comments = "Generator version: 7.5.0")
public class PersonCustomerDto extends CustomerDto implements Serializable {
private static final long serialVersionUID = 1L;
private String firstName;
private String lastName;
public PersonCustomerDto firstName(String firstName) {
this.firstName = firstName;
return this;
}
...getters and setters omitted for brevity
在这里,我们可以看到,对于规范模式中指定的每个字段,都生成了一个相应的 Java 类字段,包括与其他模式的关系,例如从Customer
模式继承,这证明了如果我们正确地在规范文件中定义关系,我们也可以使用 API-first 方法来建模面向对象编程概念。其他类看起来很相似,每个类都是根据其自己的模式定义生成的。
在继续实现 API 规范中列出的操作之前,让我们看看我们的 API 路径和方法是如何被转换的。例如,让我们看看DELETE
方法:
/**
* DELETE /orders : Cancel Order
* Cancels an existing order
*
* @param orderId ID of the order to cancel (required)
* @return No Content (status code 204)
* or Not Found (status code 404)
*/
@Operation(
operationId = "ordersDelete",
summary = "Cancel Order",
description = "Cancels an existing order",
tags = { "OrderManagement" },
responses = {
@ApiResponse(responseCode = "204", description = "No Content"),
@ApiResponse(responseCode = "404", description = "Not Found")
},
security = {
@SecurityRequirement(name = "JWTAuth")
}
)
@RequestMapping(
method = RequestMethod.DELETE,
value = "/orders"
)
default ResponseEntity<Void> ordersDelete(
@NotNull @Parameter(name = "orderId",
description = "ID of the order to cancel", required = true,
in = ParameterIn.QUERY) @Valid @RequestParam(value = "orderId",
required = true) String orderId
) {
return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
}
在这里,我们可以看到生成的默认实现代码没有任何作用;它只返回一个状态为NOT_IMPLEMENTED
的响应实体。如果我们尝试使用这个默认代码调用 API,客户端会看到一个501 Not Implemented
的 HTTP 状态。
默认实现预期将被实际实现所覆盖。路径、其参数和响应已经定义和文档化。运行中的 API 应用程序的 API 文档可以通过 Swagger UI 界面访问,或者通过自动生成的/v3/api-docs
路径下载 OpenAPI JSON 定义,就像我们在第二章中开发的先定义 API 一样。
请记住,您需要将springdoc-openapi-starter-webmvc-ui
依赖项添加到您的pom.xml
文件中,以便能够使用 Swagger UI 图形界面。请参考第四章的 GitHub 仓库以获取运行项目所需的完整依赖项列表。
这就是为什么先定义规范的方法很方便,因为一旦设计被定义,开发者就可以专注于实现,而消费者甚至可以在实际开发完成之前就开始与系统进行集成。
现在我们已经生成了规范,让我们概述一下这个服务的包结构。
订单管理 API 的包结构
让我们看看订单管理 API 的包结构是什么样的:
图 4.5 – 实现的订单管理 API 的包结构
在这里,我们可以看到订单管理 API 遵循与第二章中描述的相同架构方法,遵循清洁架构的原则。让我们看看包含的包:
adapter
: 这个包包含以下四个子包:exception
: 这包含所有类异常和异常处理器。inbound.rest
: 这负责处理所有输入、转换和由规范生成的数据。mapper
: 这包含所有对象之间的映射转换,例如从OrderRequestBodyDto
到toOrderRequest
的转换,以及自定义定义的映射器。outbound
: 这个包包含所有与外部服务交互的元素,如数据库和其他服务。在这个服务中,我们将有两个子包:outbound.database
: 这包含所有将与数据库本身交互的元素,包含如createOrder
和updateOrderStatus
等操作的用例实现,以及仓库也在这里。它包含一个内部的包。outbound.database.entity
: 这包含服务中声明的表和与数据库交互的所有定义的实体。
outbound.rest
: 这个包包含所有通过 REST 调用与外部服务交互所需的元素。在这个服务中,我们有与在 第二章 中创建的产品 API 交互所需的元素。domain
: 这个包包含定义每个由该服务包含的域的基础结构的 POJOs。其结构将主要继承自实体,但它们本身不是实体(那些在adapter.outbound.database.entity
包中定义),因此它包含来自Order
、Customer
(也包括PersonCustomer
和CompanyCustomer
)以及Product
定义,以及StatusEnum
定义。usecase
: 这包含用例的接口定义,包含将要实现的动作。在这个服务中,我们有两个用例:OrdersCommandUseCase
: 这包含如createOrder
和updateOrder
等动作的定义,这些动作由adapter.outbound.database
中的OrdersCommandUseCaseImpl
类实现。OrdersQueryUseCase
: 这包含通过查询与数据库发生的动作的定义,并由adapter.inbound.rest
包中的OrdersQueryUseCaseImpl
实现。
既然我们已经了解了服务的包结构,让我们跳入控制器的实现本身。
实现订单管理 API 控制器
订单管理 API 将遵循与产品 API 相同的架构和包结构,因此控制器将在 adapter.inbound.rest
包内创建。
第一步是创建 OrderManagementApiController
类,实现 OrderManagementApi
接口的方法。以下是代码的示例:
@Controller
public class OrderManagementApiController implements OrderManagementApi {
@Override
public ResponseEntity<List<OrderResponse>> ordersGet() {
//Add your own concrete implementation
return OrderManagementApi.super.ordersGet();
}
@Override
public ResponseEntity<Void> ordersDelete(String orderId) {
//Add your own concrete implementation
return OrderManagementApi.super.ordersDelete(orderId);
}
@Override
public ResponseEntity<OrderResponse> ordersOrderIdGet(String orderId)
{
//Add your own concrete implementation
return OrderManagementApi.super.ordersOrderIdGet(orderId);
}
@Override
public ResponseEntity<OrderResponse> ordersOrderIdPut(String orderId,
OrderRequestBody orderRequestBody) {
//Add your own concrete implementation
return OrderManagementApi
.super.ordersOrderIdPut(orderId, orderRequestBody);
}
@Override
public ResponseEntity<OrderResponse> ordersPost(
OrderRequestBody orderRequestBody) {
//Add your own concrete implementation
return OrderManagementApi.super.ordersPost(orderRequestBody);
}
}
在前面的代码片段中,还没有具体的实现,但我们正在定义在规范文件中指定并在OrderManagementApi
接口中生成的先前方法。如果我们保留super
调用(调用父接口的默认实现),服务器将返回501 Not Implemented
HTTP 状态码给调用者。当您想部分部署应用程序并让用户知道此功能尚不可用时,这可能很有用。
OpenAPI 规范文件中的所有定义都作为 API 的文档。如果您将规范粘贴到在线editor.swagger.io工具中,或者运行应用程序并使用浏览器在 URLlocalhost:8080/swagger-ui/index.html
打开 Swagger UI,您将能够看到迄今为止定义的每个操作和模式。这份文档代表了 API 合约,描述了所有可用的资源、与它们交互的方法,以及 API 预期接收和返回的数据。
图 4.6 – 来自订单管理 API 规范的 Swagger UI
这是实现我们 API 规范中定义的操作的第一步。
为了简洁起见,我们在这里不会查看实现过程的每一步,因为本章的目标是向您展示如何从规范中生成代码,正如我们在前面的章节中看到的那样。您可以自由地参考 GitHub 仓库中第四章的源代码,以深入了解实现细节,因为这将比本节中将要描述的深入得多。
这就是实现后的控制器看起来像什么:
@RestController
public class OrderManagementApiController implements OrderManagementApi {
private final OrdersCommandUseCase ordersCommandUseCase;
private final OrdersQueryUseCase ordersQueryUseCase;
private final OrderMapper orderMapper;
public OrderManagementApiController(
OrdersCommandUseCase ordersCommandUseCase,
OrdersQueryUseCase ordersQueryUseCase,
OrderMapper orderMapper) {
this.ordersCommandUseCase = ordersCommandUseCase;
this.ordersQueryUseCase = ordersQueryUseCase;
this.orderMapper = orderMapper;
}
@Override
public ResponseEntity<List<OrderResponseDto>> ordersGet() {
final var orders = ordersQueryUseCase.getAllOrders()
.stream()
.map(orderMapper::toOrderResponse)
.toList();
return ResponseEntity.status(HttpStatus.OK)
.body(orders);
}
@Override
public ResponseEntity<OrderResponseDto> ordersOrderIdGet(
String orderId) {
final var order = ordersQueryUseCase.getOrder(orderId);
return ResponseEntity.status(HttpStatus.OK)
body(orderMapper.toOrderResponse(order));
}
@Override
public ResponseEntity<OrderResponseDto> ordersPost(
OrderRequestBodyDto orderRequestBody) {
final var order = ordersCommandUseCase.createOrder(
orderMapper.toOrderRequest(orderRequestBody));
return ResponseEntity.status(HttpStatus.CREATED).body(
orderMapper.toOrderResponse(order));
}
@Override
public ResponseEntity<OrderResponseDto> ordersOrderIdStatusPatch(
String orderId, OrderStatusDto orderStatus) {
final var order = ordersCommandUseCase.updateOrderStatus(orderId,
StatusEnum.valueOf(orderStatus.getStatus().name()));
return ResponseEntity.status(HttpStatus.OK).body(
orderMapper.toOrderResponse(order));
}
@Override
public ResponseEntity<OrderResponseDto> ordersOrderIdPut(
String orderId, OrderRequestBodyDto orderRequestBody) {
var orderRequest = orderMapper.toOrderRequest(orderRequestBody);
final var order = ordersCommandUseCase.updateOrder(
orderId, orderRequest);
return ResponseEntity.status(HttpStatus.OK).body(
orderMapper.toOrderResponse(order));
}
@Override
public ResponseEntity<Void> ordersOrderIdDelete(String orderId) {
ordersCommandUseCase.deleteOrder(orderId);
return ResponseEntity.noContent().build();
}
}
通过查看控制器的实现,我们可以注意到一些与在产品 API 中实现的模式相似,特别是使用用例和映射器的使用。
但与产品 API 不同,在这里我们覆盖了OpenManagementAPI
接口的生成代码,其中定义了所有路径和操作。与代码优先的方法不同,之前在 OpenAPI 规范中已经定义的文档在这里不需要手动重复,因为它包含在生成的OpenManagementAPI
中。这就是规范优先(API 优先)实现的力量。
在这个控制器中,我们注入了三个主要接口,用于处理所有业务逻辑、持久性和数据层转换:
OrdersCommandUseCase
OrdersQueryUseCase
OrderMapper
让我们首先关注这个服务的用例实现,即OrdersCommandUseCase
和OrdersQueryUseCase
,以及它们在订单管理 API 中的作用,从OrdersCommandUseCaseImpl
开始。
实现 OrdersCommandUseCase
让我们从使用OrdersCommandUseCaseImpl
类实现OrdersCommandUseCase
开始:
@Transactional
@Service
public class OrdersCommandUseCaseImpl
implements OrdersCommandUseCase {
private final OrdersRepository ordersRepository;
private final OrderMapper orderMapper;
private final ProductsApi productsApi;
public OrdersCommandUseCaseImpl(OrdersRepository ordersRepository,
OrderMapper orderMapper,
ProductsApi productsApi) {
this.ordersRepository = ordersRepository;
this.orderMapper = orderMapper;
this.productsApi = productsApi;
}
@Override
public Order createOrder(OrderRequest orderRequest) {
BigDecimal total = getProductPrice(orderRequest);
Order order = orderMapper.toOrder(orderRequest);
order.setTotalAmount(total);
return ordersRepository.save(OrderEntity.fromOrder(order, null));
}
@Override
public Order updateOrder(String orderId, OrderRequest orderRequest) {
Optional<OrderEntity> optionalOrderEntity
= ordersRepository.findById(orderId);
if (optionalOrderEntity.isPresent()) {
BigDecimal total = getProductPrice(orderRequest);
Order order = orderMapper.toOrder(orderRequest);
order.setTotalAmount(total);
return ordersRepository
.save(OrderEntity.fromOrder(order,
optionalOrderEntity.get()));
}
throw new EntityNotFoundException("Order not found with id " +
orderId);
}
private BigDecimal getProductPrice(OrderRequest orderRequest) {
return orderRequest.products().stream().map(orderProduct -> {
ProductOutputDto catalogueProduct =
productsApi.getProductById(orderProduct.getProductSKU());
return catalogueProduct
.getPrice()
.multiply(BigDecimal.valueOf(
orderProduct.getQuantity()));
}).reduce(BigDecimal.ZERO, BigDecimal::add);
}
@Override
public Order updateOrderStatus(String orderId, StatusEnum status) {
Optional<OrderEntity> optionalOrderEntity = ordersRepository.findById(orderId);
if (optionalOrderEntity.isPresent()) {
return ordersRepository
.save(OrderEntity
.updateOrderStatus(optionalOrderEntity.get(),
status));
}
throw new EntityNotFoundException("Order not found with id " +
orderId);
}
@Override
public void deleteOrder(String orderId) {
ordersRepository.deleteById(orderId);
}
}
如前所述,在包结构中,OrdersCommandUseCaseImpl
类包含了服务本身的业务逻辑、操作以及与其他组件(如仓库和映射器)以及其他服务(如产品 API)的交互,正如我们将在与产品 API 通信部分详细看到的那样。这就是为什么这个类被 Spring 的@Service
注解标注,表示它是一个 Bean 的Service
类型。
实现 OrdersQueryUseCase
转到OrdersQueryUseCase
的实现,我们将有OrdersQueryUseCaseImpl
:
@Service
public class OrdersQueryUseCaseImpl implements OrdersQueryUseCase {
private final OrdersRepository ordersRepository;
public OrdersQueryUseCaseImpl(OrdersRepository ordersRepository) {
this.ordersRepository = ordersRepository;
}
@Override
public List<? extends Order> getAllOrders() {
return ordersRepository.findAll();
}
@Override
public Order getOrder(String orderId) {
return ordersRepository.findById(orderId)
.orElseThrow(
() -> new EntityNotFoundException(
"Order not found with id " + orderId)
);
}
}
在OrdersQueryUseCaseImpl
中,我们的实现要小得多,因为这里的重点是与数据库的交互。任何你可能会有的自定义查询或请求都直接进入这个类。
使用 MapStruct 配置 OrderMapper
最后,有OrderMapper
接口。与用例类不同,我们在这里没有实现任何类;相反,我们定义了一个接口,其中包含了我们想在项目中拥有的映射。由于本项目使用 MapStruct 进行对象映射,我们在该接口内定义映射,并将任何自定义映射单独定义。
MapStruct
MapStruct 是一个代码生成器,通过依赖约定优于配置的策略简化了 Java bean 类型之间的映射过程。这就是我们定义这个接口的原因;它包含了 MapStruct 生成映射代码所需的配置。此外,我们还将为基本生成的映射代码不足以应对的情况定义一个自定义映射。
让我们从接口定义开始:
@Mapper(uses = CustomerCustomMapper.class, componentModel = "spring")
public interface OrderMapper {
Order toOrder(OrderRequest orderRequestBody);
@Mapping(target = "customer", source = "customer",
qualifiedByName = "customerDtoToCustomer")
OrderRequest toOrderRequest(OrderRequestBodyDto orderRequestBody);
@Mapping(target = "customer", source = "customer",
qualifiedByName = "customerToCustomerDto")
OrderResponseDto toOrderResponse(Order order);
}
这就是OrderMapper
接口的样子。我们正在定义 MapStruct 要执行映射操作,例如toOrderRequest
操作,这是一个从OrderRequestBodyDto
对象到OrderRequest
对象的映射。
使用Mapstruct
进行简单映射很简单,但当事情变得复杂时,就需要引入一个自定义映射实现。这就是CustomerCustomMapper
类进入我们的映射定义的地方。
要使用 MapStruct 的定制映射,你需要创建一个自定义映射类,并告诉接口使用它。
在这里,我们在类级别的@Mapper
注解的uses
属性中添加了CustomerCustomMapper
类。这样,MapStruct 就知道有一个自定义实现映射器需要引用。
这种方法允许根据你的需求进行部分或完整的对象映射。在这种情况下,我们将通过使用 MapStruct 生成的映射的部分来执行部分映射,并应用自定义映射来处理简单映射无法正确处理的特定复杂对象。
对于每个映射操作,例如toOrder
、toOrderRequest
和toOrderResponse
,如果您有一个自定义映射,它改变了 MapStruct 的默认映射行为,您需要在该方法级别使用@Mapping
注解来告诉 MapStruct 您有一个自定义映射。
在我们的自定义映射示例中,target
和source
属性都指定了接收参数(toOrderRequest
方法中的orderRequestBody
和toOrderResponse
方法中的order
)中的customer
属性将以自定义方式映射,需要引用由CustomerCustomMapper
类实现的自定义映射器实现。
最后,qualifiedByName
属性告诉 MapStruct 要引用哪个方法进行映射到自定义映射器,该映射器由@Named
注解标识。
由于引入了复杂性和多态性,以区分具有继承的不同类型的客户,因此需要这个自定义映射。
因此,让我们看看这个自定义映射规范:
@Component
public class CustomerCustomMapper {
@Named("customerDtoToCustomer")
public Customer customerDtoToCustomer(CustomerDto customerDto) {
if (customerDto == null) {
return null;
}
Customer customer = switch (customerDto.getCustomerType()) {
case "person" -> {
PersonCustomer personCustomer = new PersonCustomer();
var personCustomerDto = (PersonCustomerDto) customerDto;
personCustomer.setFirstName(
personCustomerDto.getFirstName());
personCustomer.setLastName(
personCustomerDto.getLastName());
yield personCustomer;
}
case "company" -> {
CompanyCustomer companyCustomer = new CompanyCustomer();
var companyCustomerDto = (CompanyCustomerDto) customerDto;
companyCustomer.setCompanyName(
companyCustomerDto.getCompanyName());
companyCustomer.setVatId(companyCustomerDto.getVatId());
yield companyCustomer;
}
default -> new Customer();
};
customer.setStreetAddress(customerDto.getStreetAddress());
customer.setCity(customerDto.getCity());
customer.setPostalCode(customerDto.getPostalCode());
return customer;
}
@Named("customerToCustomerDto")
public CustomerDto customerToCustomerDto(Customer customer) {
if (customer == null) {
return null;
}
CustomerDto customerDto = switch (customer) {
case PersonCustomerEntity personCustomer -> {
PersonCustomerDto personCustomerDto = new
PersonCustomerDto();
personCustomerDto.setFirstName(
personCustomer.getFirstName());
personCustomerDto.setLastName(
personCustomer.getLastName());
personCustomerDto.setCustomerType("person");
yield personCustomerDto;
}
case CompanyCustomerEntity companyCustomer -> {
CompanyCustomerDto companyCustomerDto = new
CompanyCustomerDto();
companyCustomerDto.setCompanyName(companyCustomer.
getCompanyName());
companyCustomerDto.setVatId(companyCustomer.getVatId());
companyCustomerDto.setCustomerType("company");
yield companyCustomerDto;
}
default -> new CustomerDto();
};
customerDto.setStreetAddress(customer.getStreetAddress());
customerDto.setCity(customer.getCity());
customerDto.setPostalCode(customer.getPostalCode());
return customerDto;
}
}
由于这是一个 MapStruct 组件,它被注解为@Component
,因此它可以作为一个 Bean 在 Spring 上下文中创建,并在构建过程中被映射生成器检测到。
但其关键方面是前面提到的@Named
方法级注解。它指定了我们在qualifiedByName
MapStruct 接口中提到的唯一限定符。customer
对象是CustomerCustomMapper
实现中唯一被自定义映射的对象。
使用 MapStruct 生成项目时,它将生成OrderMapper
接口中指定的映射,但它将调用这个自定义映射实现,正如我们可以在以下屏幕截图中的生成代码片段中看到:
图 4.7 – 生成映射到 OrderRequest 调用客户自定义映射
您可以在构建项目后,在target
文件夹中的classes
文件夹内的mapper
包中找到前一个屏幕截图中的生成代码,其中OrderMapper
接口和CustomerCustomMapper
类也被定义。但如果您在 IntelliJ 中使用包视图,生成的类将自动显示在mapper
包中,与您之前在本章中看到的自己的实现类一起。
现在,让我们看看订单管理 API 如何与产品 API 通信以验证接收到的订单中的产品。
使用产品 API 进行通信
为了验证订单中的产品是否存在,订单管理 API 通过 API 调用验证插入的产品 SKU 与产品 API,无论是在创建新订单时还是在订单更新包含将新产品插入订单时。
在现实世界的生产应用中,Product API 可能需要验证库存中是否有足够的产品可以添加到订单中,并在没有足够产品时正确响应。为了展示如何创建 API 的目的,我们将展示如何验证订单中的产品是否存在于 Product API 服务数据中,并检索它们的定价以添加到我们的订单中。如果找不到产品,订单管理 API 将返回一个带有404 Not Found
HTTP 状态码和错误消息Product not found
的正确消息,解释说由于该产品不存在,无法下订单,需要在 Product API 中创建它。
为了进行这个调用,我们将从 Product API 规范生成客户端。由于本章是关于生成代码的,为什么不生成从 Product API 本身检索数据的客户端呢?
你在前面的部分中看到了,在OrdersCommandUseCaseImpl
实现中,updateProductPrice
方法。这个私有方法使用productsQueryUseCase.getProductById
通过生成的客户端检索产品的数据,并根据它包含的产品计算订单的总价值。
要生成这个客户端,我们需要更新我们的生成器插件,向其中添加 Product API 规范,并生成一个新的构建。
让我们从更新 OpenAPI 生成器插件开始。我们将在本章开头创建的第一个执行下面添加第二个执行。它看起来是这样的:
<execution>
<id>generate-client</id>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<inputSpec>.../src/main/resources/Product_API.json</inputSpec>
<generatorName>java</generatorName>
<library>restclient</library>
<apiPackage>[package where generated code is added]</apiPackage>
<modelPackage>[new package where DTOs are added]</modelPackage>
<modelNameSuffix>Dto</modelNameSuffix>
<generateApiTests>false</generateApiTests>
<generateModelTests>false</generateModelTests>
<configOptions>
<interfaceOnly>true</interfaceOnly>
<useJakartaEe>true</useJakartaEe>
<openApiNullable>false</openApiNullable>
</configOptions>
</configuration>
</execution>
这里的配置与我们在使用订单管理 API 规范的第一个生成器中做的类似,在从规范生成代码部分,但现在我们将指向 Product API 规范。现在我们将详细说明只有几个差异,就像我们对第一个生成器所做的那样,让我们逐一查看每个项目:
<execution>
:这定义了插件的特定执行。每个执行都可以有自己的配置和目标。在这里,我们添加了一个新的。<id>
:这是这个执行的唯一标识符。在这种情况下,它是generate-client
。在前一个执行中,我们使用generate-server
作为 ID。<goals>
:这指定了要执行的目标。在这里,目标是generate
,它触发了代码生成过程。<configuration>
:这包含了这个执行的配置选项。<inputSpec>
:这指定了 OpenAPI 规范文件的路径。在这里,它是${project.basedir}/src/main/resources/Product_API.json
,这是 Product API 规范包含在这个服务中的位置。规范是 JSON 格式,我们可以使用 Product API Swagger UI 下载。<generatorName>
:这定义了要使用的生成器。在这种情况下,它是java
,表示将生成 Java 客户端代码。<library>
: 这指定了用于生成代码的库。在这里,它是restclient
,它生成一个 REST 客户端。<apiPackage>
: 这定义了生成 API 类的包。在这里,它是com.packt.ordermanagementapi.adapter.outbound.rest
。<modelPackage>
: 这定义了生成模型类的包。在这里,它是com.packt.ordermanagementapi.adapter.outbound.rest.dto
。<modelNameSuffix>
: 这将为生成的模型类名称添加后缀。在这里,它是Dto
。<generateApiTests>
: 当设置为false
时,不会生成 API 测试类。<generateModelTests>
: 当设置为false
时,不会生成模型测试类。<configOptions>
: 这包含生成器的附加配置选项。<interfaceOnly>
: 当设置为true
时,将仅生成 API 的接口,而不生成任何实现。<useJakartaEe>
: 当设置为true
时,生成的代码将使用 Jakarta EE 而不是 Java EE。<openApiNullable>
: 当设置为false
时,生成器将不会为可选字段使用@Nullable
注解。
在将插件配置好并指向产品 API 规范后,我们可以继续生成一个新的构建,该构建将从规范生成 REST 客户端,以及所需的 DTO。同样,我们将运行 Maven 命令:
mvn clean install
在构建成功后,这将在插件中配置的指定包中生成 DTO 和 API 类,如下面的截图所示:
图 4.8 – 生成的产品 API 客户端和 DTO 类
这种方法非常强大,因为我们已经有了我们 API 将要消费的服务的规范。这可以通过快速为你生成代码和客户端来节省数小时的开发时间。
你可以想象一个替代方案,其中客户端代码将由提供(产品)API 的服务生成作为一个模块,而感兴趣的客户端只需将代码作为 JAR 文件形式的库来使用。
这可能看起来让消费者的生活变得更简单,但它有严重的缺点。它将迫使消费者与 JAR 文件内使用的库(及其特定版本)兼容。它不支持非 Java 消费者。
微服务架构的一个优点是服务之间的松散耦合。依赖于 JAR 文件来消费不同微服务的 API 会导致不希望的耦合,并违背了使应用程序分布化的目的。
如果你注意到了我们在上一节查看 OrdersCommandUseCaseImpl
类时,有一个使用此生成的产品 API 客户端的私有方法,名为 updateProductPrice
。让我们看看它是如何工作的,以及如何在订单管理 API 中充分利用它:
private void updateProductPrice(List<Product> products) {
products.forEach(orderProduct -> {
ProductDetails catalogueProduct = productsQueryUseCase.
getProductById(orderProduct.getProductSKU());
orderProduct.setPrice(catalogueProduct.price());
});
}
在这里,productsQueryUseCase
被注入到 OrdersCommandUseCaseImpl
中,处理所有对外部产品 API 的相关调用。在 updateProductPrice
方法中,对订单请求中的每个产品,都会调用 ProductsQueryUseCase
中的 getProductById
方法,传递唯一的商品标识符(产品 SKU)以检索产品详情。
我们的目标是验证这些产品是否存在于产品 API 中,并获取它们的值以添加到订单总额中。
如果找到产品,它将被返回并分配给名为 catalogueProduct
的 ProductDetails
变量,并且单个产品的价格将被设置在产品列表中。
如果订单中的任何产品在产品 API 中找不到,则此外部 API 调用将返回 404 Not Found
,并被应用程序的 GlobalExceptionHandler
捕获,中断订单创建。这是预期的行为,因为我们不希望允许创建任何不存在产品的订单。
如果你对插件生成的代码以及如何构建 REST 客户端感兴趣,我们鼓励你按照这些步骤生成自己的代码以查看其实际效果。你也可以始终参考本书官方仓库中的章节代码,网址为 github.com/PacktPublishing/Mastering-RESTful-Web-Services-with-Java/tree/main/chapter4
。
摘要
在本章中,你了解了规范优先(API-first)、代码优先开发之间的主要区别。我们从头开始详细开发了一个 OpenAPI 规范,逐部分介绍了其结构,创建了带有参数的路径和方法,定义了用于请求、响应和作为参数的 DTO 生成模式,并定义了一个安全方案。
然后,我们使用 Maven 的 OpenAPI 代码生成器插件配置了订单管理 API 服务,并查看每个必要的参数以确保代码按预期正确生成,并使用 OpenAPI 插件成功构建。
之后,我们开始查看生成的代码,它的位置以及如何与自己的代码实现一起查看。
我们概述了项目的架构包结构以及结构中每个包的目标。
接着,我们实现了我们的控制器,使用规范生成的接口,最初是通过覆盖方法,然后通过创建一个具有所有预期功能的实际工作实现,深入代码。
最后,我们学习了如何通过向 OpenAPI 插件添加新的执行来扩展我们的代码生成器,并生成了一个用于调用产品 API 的 REST 客户端。
我们使用了生成的产品 API 客户端来调用和验证订单创建或更新流程中的产品,检查它是否存在于产品 API 中,并计算订单总额。如果任何产品未找到,它将直接终止订单的创建或更新。
在下一章中,你将了解到一些高级 API 概念——如何处理超时、重试等,这些概念可以在产品 API 在订单管理 API 的某个请求不可用时使用,以及如何正确处理这种情况。
第二部分
提升您的 API
本部分在 RESTful API 开发的基础上,通过专注于提高 API 的可维护性、安全性和可靠性来构建。您将探索如何管理 API 的版本控制和演进,实现高级功能,如分页和弹性模式,有效保护端点,并采用包括人工智能辅助技术在内的现代测试策略。
本部分将涵盖以下章节:
第五章 ,管理 API 演进
第六章 ,高级 API 概念与实现
第七章 ,保护您的 RESTful API
第八章 ,稳健 API 的测试策略
第五章:管理 API 演变
随着业务需求的变化和新功能的引入,更新 API 是一个频繁且必要的流程。然而,为了避免破坏现有的客户端集成,这需要谨慎的决策。即使是 API 结构、行为或响应格式的微小更改,也可能对依赖于稳定、可预测界面的消费者产生重大影响。
在本章中,我们将向您展示如何在确保向后兼容性的同时演变您的 API。我们还将讨论在必要时进行破坏性变更时对 API 进行版本控制的策略,以我们产品 API 为例。
我们将在本章开始时探讨可以应用于 RESTful API 的不同版本控制策略。版本控制是 API 设计的关键方面,因为它确保您的应用程序可以在不破坏现有客户端集成的情况下随时间演变。我们将检查几种版本控制技术,如 URL 版本控制、查询参数版本控制、HTTP 头版本控制以及内容协商,同时讨论每种方法所面临的挑战,包括维护、兼容性和复杂性。
接下来,我们将对我们的现有产品 API 进行一次破坏性变更,这将需要我们实施一个版本控制策略。通过演变我们的 API,我们将确保使用早期版本的客户端不受影响,同时为采用最新版本的客户端启用新功能。这个过程突出了向后兼容性和最小化对用户干扰的重要性,即使 API 持续演变。
最后,我们将探讨管理 API 演变过程中的最佳实践。我们将讨论如何优雅地弃用旧版本,与客户端有效沟通变更,以及在不增加不必要复杂性的情况下维护 API 的多个版本。到本章结束时,您将深入了解如何实施支持 API 长期增长和稳定的版本控制策略。
在本章中,我们将涵盖以下主题:
版本控制策略
在产品 API 中实现版本控制
管理 API 演变
技术要求
在本章中,我们将演变我们的产品 API。为了能够跟随并使用书中打印的代码示例,您应该拥有在第二章中创建的产品 API 代码。
您可以在 GitHub 上访问本章的代码:github.com/PacktPublishing/Mastering-RESTful-Web-Services-with-Java/tree/main/chapter5
。
版本控制策略
版本控制是指通过为 API 生命周期的不同阶段分配不同的版本号来管理应用程序随时间的变化。它确保 API 的更新(如新功能或破坏性变更)可以引入,而不会破坏依赖于早期版本的现有客户端。
随着软件系统架构从单体架构转向分布式系统,版本控制对 REST API 变得至关重要。在单体架构中,服务的消费者和提供者通常位于同一应用程序中,可以对通信的两端进行直接控制。当进行更改时,它们会统一应用于整个系统,确保消费者始终与提供者保持同步。因此,在单体应用程序中接口的版本控制不是必要的,因为消费者与提供者紧密耦合,更新同时部署而不会引起兼容性问题。
然而,随着分布式系统的兴起和 REST API 的采用,这些 API 服务于多个独立的客户端(如网页浏览器、移动应用和第三方服务),情况发生了巨大变化。REST API 将服务暴露给外部世界,不同客户端可能同时使用 API 的不同版本。这引入了一个挑战——如何更新 API 以引入新功能或修复问题,同时又不破坏现有客户端的功能?版本控制通过允许 API 的多个版本共存,确保向后兼容性,并使 API 能够随着时间的推移而演进,同时仍然支持旧客户端。
让我们看看如何应用版本控制策略来解决这个问题。
在探索版本控制策略之前,区分向后兼容的变更和破坏性变更至关重要。理解这种区别非常重要,因为对于破坏性变更,更新 API 版本是必要的,以确保现有客户端可以继续平稳地使用 API。
破坏性变更是指那些会中断现有客户端与 API 交互的修改。这些变更通常需要一个新的 API 版本来防止破坏当前的集成。以下是一些破坏性变更的例子:
删除端点:删除客户端依赖的现有 API 端点
更改响应数据结构:更改响应数据的格式,例如在 JSON 响应中重命名或删除字段
修改必需参数:更改端点的必需参数,这可能导致现有请求失败
更改认证方法:更改或删除现有的认证机制或安全协议
改变端点行为:修改端点的行为,使其不再像之前那样执行
相比之下,向后兼容的变更增强了或扩展了 API,而不会破坏现有客户端。以下是一些向后兼容变更的例子:
添加新的可选字段:向响应负载中引入新的字段,这些字段对于现有客户端不是必需的
添加新的端点:创建新的 API 端点,提供额外的功能,而不会影响现有的端点
扩展响应数据:向响应数据中添加额外的信息,而不会改变现有的结构
添加新的查询参数:向现有端点引入可选的查询参数,以提供额外的过滤或功能
增强文档:改进或扩展 API 文档,以提供更多细节而不改变 API 本身
现在我们已经了解了使用版本控制进行重大变更的需求,让我们来探讨各种版本控制策略,以及它们的优缺点。最常见的方法包括以下几种:
URL 路径版本控制
查询参数版本控制
HTTP 头版本控制
内容协商
让我们深入探讨每种版本控制策略,并了解何时使用一种策略而不是另一种策略。
URL 路径版本控制
URL 路径版本控制是 REST API 版本控制中最常见且最直接的方法之一。许多公司,如 X、Google、GitLab 等,都使用这种方法。在这种策略中,版本号直接包含在 API 的 URL 路径中,例如https://api.product.com/v1/products.
。
这种方法使得开发者和客户端都能轻松地识别和访问 API 的具体版本,而不会产生任何歧义。通过在 URL 路径中嵌入版本号,API 消费者可以明确地请求所需的版本,确保任何集成在 API 演变过程中都能保持稳定。此外,服务器可以轻松地将传入的请求路由到正确的代码库版本,简化维护和部署。
URL 路径版本控制为 API 管理提供了几个明显的优势。其主要好处之一是版本号直接显示在 URI 中,使得访问的 API 版本一目了然。此外,URL 版本控制与 HTTP 缓存机制很好地集成,因为 API 的不同版本被视为不同的资源,允许对每个版本进行有效且独立的缓存。它还促进了书签和分享,使用户能够轻松保存和引用特定版本的端点,确保交互的一致性。
然而,URL 版本控制也存在一些缺点。随着时间的推移,维护多个版本可能会导致 URI 杂乱无章,尤其是在同时维护多个 API 版本的情况下。此外,一旦版本嵌入到 URL 路径中,它就成为 API 公共契约的一部分。此外,管理众多 URL 相关的开销可能会增加开发和文档流程的复杂性。
当实现新功能时,破坏性更改通常只会影响少数特定的端点,而不是整个 API。如果您更新未更改的端点的版本号,这可能会给客户端和维护者造成不必要的混淆。这种方法使得理解不同版本的各个端点应该如何一起使用变得更加困难,可能会使集成过程复杂化,并阻碍 API 的整体清晰度。
使用 URL 路径进行版本控制非常适合许多场景,尤其是在清晰和明确的版本控制至关重要时。对于稳定、预期变化最小且不会引入破坏性修改的 API 来说,这是一个极佳的选择。URL 路径版本控制提供了一种简单的方法来管理不同的版本,使得跟踪和维护随着时间演变的 API 变得更加容易。
查询参数版本控制
查询参数版本控制涉及在 URL 中将 API 版本指定为查询参数,例如 https://api.product.com/products?version=1
。这种方法在微软的指南中通常被推荐。这种策略使得在不修改 URL 路径的情况下实施和引入新版本变得更加容易。这在您希望保持一致的端点路径结构的同时,仍然提供特定版本的功能时尤其有用。它还允许在不创建大量 URL 路径的情况下进行版本控制。
然而,它也有一些缺点。客户端必须在每次请求中都包含版本参数,这增加了请求构建的额外步骤。此外,API 服务器必须根据查询参数处理版本逻辑,这可能会使实现复杂化并增加处理开销。此外,版本信息在 URL 中不太明显,这使得快速识别正在访问哪个版本变得更加困难。这种不明确性可能会导致混淆,尤其是在版本化影响 API 功能的关键部分时。
另一个关注点是查询参数版本控制如何与缓存交互。由于版本信息嵌入在查询字符串中而不是 URL 路径中,缓存机制可能无法有效地处理它。这可能导致不同版本的 API 缓存方式复杂化,可能会影响依赖于缓存来提高响应时间的客户端的性能和效率。
HTTP 头版本控制
HTTP 头版本控制涉及在请求的 HTTP 头中指定 API 版本。这种方法允许客户端通过在其请求中包含一个自定义头,如 X-API-Version
,来指示他们希望使用哪个版本的 API。GitHub 就是使用 HTTP 头的一个例子:
curl --header "X-GitHub-Api-Version:2022-11-28" https://api.github.com/zen
这种策略保持了 URL 的整洁,避免了在 URL 中添加版本信息。它还提供了一种灵活且不那么侵入性的版本管理方式,因为它不会更改端点路径或查询字符串。此外,HTTP 头版本控制可以促进版本之间的平稳过渡,因为版本信息与资源标识符 URL 分离,减少了与破坏性变更相关的风险。
然而,HTTP 头版本控制带来了一些挑战。与 URL 版本控制不同,版本信息并不直接体现在 URL 中,这可能会使调试和文档编写变得更加复杂。这种缺乏可见性可能会阻碍开发者和客户在故障排除或理解 API 版本时的工作。
此外,自定义头可能导致不同 API 之间实现实践的不一致。当 API 使用自定义头时,建立统一标准变得具有挑战性,这可能会让开发者和用户都感到困惑。例如,一个 API 可能使用X-Api-Version
,而另一个 API 可能使用X-Version
,这会导致歧义和潜在的集成问题。自 2012 年引入 RFC 6648 以来,使用X-
前缀作为自定义头已被弃用。作为替代,内容协商策略利用现有头,避免需要自定义头,并确保更好地遵循标准实践。
内容协商
内容协商是处理 RESTful API 版本控制的技术之一,允许客户端通过 HTTP 头来指定 API 版本,而不是通过 URL 路径。其理念是让客户端通过发送特定的Accept
头来请求特定的 API 版本。例如,一个客户端可能通过发送以下请求来请求 API 的 1.0 版本,格式为 JSON:
curl -X GET https://api.myproduct.com/resource \ -H "Accept: application/vnd.myapi.v1+json"
这种通过内容协商进行 API 版本控制的技巧与 HTTP 头版本控制密切相关,因为这两种策略都依赖于 HTTP 头来指定版本。关键区别在于内容协商避免了创建自定义 HTTP 头,遵循 RFC 6648,该规范不鼓励使用自定义头。相反,这种方法使用标准头,如Accept
和Content-Type
,来指定响应的格式和版本,从而促进了一种更干净、更标准化的方法。
这种策略被像阿迪达斯和 Mambu 这样的大型组织广泛采用。内容协商策略与 REST 标准保持一致,因为它遵循内容协商原则,即客户端和服务器通过头信息来协商表示格式和版本,保持接口的灵活性和符合 RESTful 最佳实践。
由于我们的 API 已经实现,我们将采用内容协商策略以实现无缝演进,允许引入新功能而不会干扰现有客户端或造成破坏性变更。
在产品 API 中实现版本控制
由于我们的产品 API 已经被其他应用程序使用,在继续演进 API 以满足新需求的同时保持稳定性至关重要。在 API 开发中,一个挑战是在增强需求的同时不破坏现有消费者。在本节中,我们将探讨如何在产品 API 中实现版本控制,以处理变更而不对客户端造成破坏性影响。
产品 API 目前返回不带分页的产品完整列表。为了提高性能和可扩展性,我们决定在产品列表响应中引入分页。这种修改将改变当前的行为,可能会干扰依赖于现有输出的用户。为了防止对其工作流程造成任何影响,我们将实施版本控制策略。这种方法将使我们能够支持未来客户端的新分页格式,同时保持对现有消费者的向后兼容性,确保 API 可以在不中断服务的情况下演进。
如前所述,我们将使用内容协商策略将分页引入我们的产品 API,而不会破坏现有的功能。这种策略将使我们能够在客户端通过请求头指定他们偏好的版本的同时,使 API 得以演进。通过利用内容协商,我们可以提供原始的产品列表格式和新的分页版本,确保向后兼容性,并给客户端在如何消费 API 方面提供灵活性。这种方法确保了现有用户的平稳过渡,同时满足了新的需求。
更新我们的产品 API
让我们通过向我们的列表端点添加新功能来增强我们的 API,同时保持向后兼容性。
在我们当前的 API 中,我们有以下代码:
@GetMapping
@Override
public ResponseEntity<List<ProductOutput>> getProducts() {
final var products = productsQueryUseCase.getAllProducts()
.stream()
.map(productMapper::toProductOutput)
.toList();
return ResponseEntity.status(HttpStatus.OK)
.body(products);
}
让我们创建另一种方法来处理我们 API 的版本 2,这将包括对分页的支持。为了实现这一点,我们将添加两个RequestParam
注解:一个用于指定当前页,另一个用于定义每页结果的数量限制。
设计策略以防止向后不兼容的更改
在我们的示例中添加分页而不造成破坏性更改是不可能的,因为响应是一个包含产品的 JSON 数组,因此没有地方放置适用于所有返回产品的分页信息(总页数)。防止这种情况的一个技巧是,而不是在顶层使用 JSON 数组,即使它只有一个属性,也要将列表(JSON 数组)包裹在一个 JSON 对象中。这种最小的额外开销将使我们能够在不引入破坏性更改的情况下演进 API。我们只需添加可选的分页请求参数,并将分页信息添加到响应根级别的对象中。
你可以在以下代码中看到这些RequestParam
注解:
@GetMapping(produces = "application/vnd.packt-v2+json")
@Override
public ResponseEntity<PaginatedProducts> getProductsV2(@RequestParam(
value = "page", defaultValue = "0") Integer page, @RequestParam(
value = "limit", defaultValue = "10") Integer limit) {
final var products = productsQueryUseCase.getAllProducts(
PageRequest.of(page, limit));
int totalPages = products.getTotalPages();
List<ProductOutput> output = products.stream()
.map(productMapper::toProductOutput)
.toList();
return ResponseEntity.status(HttpStatus.OK)
.body(new PaginatedProducts(totalPages, output));
}
通过添加produces = "application/vnd.packt-v2+json"
,我们使 Spring 能够将任何将Accept
头设置为application/vnd.packt-v2+json
的请求路由到适当的端点。这允许客户端通过在他们的 API 请求中指定Accept
头来接收针对他们版本的定制响应。
我们还更新了 API 响应,以返回一个PaginatedProducts
类,该类现在包括totalPages
属性,如下面的代码所示。这个添加对于客户端确定是否有更多资源可用至关重要:
public record PaginatedProducts(@Schema(name = "totalPages",
example = "10") Integer totalPages, List<ProductOutput> products) {}
我们选择修改响应体以包含此信息,尽管可以使用其他策略达到相同的目标,我们将在第六章中探讨。
随着新增了这个新的端点(getProductsV2
),我们需要更新ProductsQueryUseCase
类以包含必要的实现代码。在下面的示例中,getAllProducts
方法现在从 Spring 接受一个Pageable
对象,该对象处理分页细节。该方法返回一个包含产品列表以及如总页数等元数据的Page
对象:
public interface ProductsQueryUseCase {
Product getProductById(String productId);
Page<? extends Product> getAllProducts(Pageable pageRequest);
}
此外,添加此端点需要我们更改ProductsApi
接口,并添加端点的定义和文档,如下面的代码块所示:
public interface ProductsApi {
....
@Operation(
operationId = "getProducts_v2",
summary = "Retrieve all products",
responses = {
@ApiResponse(responseCode = "200",
description = "A list of products", content = {
@Content(mediaType = "application/json",
array = @ArraySchema(schema = @Schema(
implementation = PaginatedProducts.class)))
})
}
)
ResponseEntity<PaginatedProducts> getProductsV2(
@Parameter(name = "page", description = "Number of current page",
required = false, in = ParameterIn.QUERY, example = "0")
Integer page,
@Parameter(name = "limit",
description = "Size of elements per page", required = false,
in = ParameterIn.QUERY, example = "10") Integer limit);
....
}
现在我们有了文档代码,让我们看看我们的 Swagger 文档看起来像什么:
图 5.1 – Swagger UI
图 5.1 展示了我们的 API,展示了当媒体类型为application/vnd.packt-v2+json
时的/api/products
端点及其新的响应格式。
通过将媒体类型切换到application/json
,如图 5.2 所示,API 将响应更改为其之前的行性行为:
图 5.2 – Swagger UI
如所示,API 通过为旧客户端提供原始输出来保持向后兼容性。然而,我们的文档仍然强制执行参数,尽管它们没有被使用。不幸的是,OpenAPI 3.x 不支持根据内容类型变化参数。
注意
预计到 2024 年底发布的 OpenAPI 4.x 将解决根据内容类型支持不同查询参数的限制。由于我们目前使用的是 3.x,这个特性在我们的实现中不受支持。
在实现了新版本后,现在是时候验证我们的更改并确保一切按预期工作。
测试和验证我们的产品 API
现在我们已经使产品 API 能够支持多个版本,是时候测试和验证功能了。我们希望确保 API 的每个版本都按预期行为,提供正确的响应,而不破坏任何现有功能。为了实现这一点,我们可以使用 Swagger 进行图形界面,或者我们可以直接通过命令行使用curl
命令与 API 交互。通过测试原始和更新版本,我们可以确认 API 正确处理了不同的版本。
要使用 API 的第一个版本检索产品列表,您可以执行以下curl
命令:
curl -X 'GET' 'http://localhost:8080/api/products' -H 'accept: application/json'
此命令向 API 发送请求,但不指定产品 API 的版本。预期结果是原始格式的产品列表,不进行分页:
[
{
"name": "Keyboard",
"sku": "AK21109",
"description": "Ergonomic Keyboard",
"price": 60
},
{
"name": "Keyboard 08",
"sku": "AK21108",
"description": "Ergonomic Keyboard",
"price": 60
},
{
"name": "Keyboard 07",
"sku": "AK21107",
"description": "Ergonomic Keyboard",
"price": 60
}
]
现在,让我们尝试使用相同的请求传递页码和限制,使用以下curl
命令:
curl -X 'GET' 'http://localhost:8080/api/products?page=0&limit=2' -H 'accept: application/json'
即使我们已经指定了两个项目的限制,输出也将保持不变。这是因为我们 API 的第一个版本没有设计来处理分页查询参数。因此,API 忽略这些参数,并返回完整的产品列表,遵循版本 1 中定义的行为。
我们第一个版本的代码将返回所有可用元素。我们通过传递 Spring 的Pageable
对象实现了第二个版本。为了遵循不要重复自己(DRY)原则,我们必须更新版本 1 中的代码,其中默认返回所有项目,我们必须将大小设置为最大可能值。这确保了第一个版本继续按预期工作,而没有引入分页。
@GetMapping
@Override
public ResponseEntity<List<ProductOutput>> getProducts() {
final var products = productsQueryUseCase.getAllProducts(
`PageRequest.ofSize(Integer.MAX_VALUE))`
.stream()
.map(productMapper::toProductOutput)
.toList();
return ResponseEntity.status(HttpStatus.OK)
.body(products);
}
现在我们已经确认了 API 对先前版本的功能按预期工作,让我们继续测试最新版本。为此,我们将使用此处显示的curl
命令来验证更新后 API 的行为:
curl -X 'GET' 'http://localhost:8080/api/products?page=0&limit=2' -H 'accept: application/vnd.packt-v2+json'
与前一个命令相比,此命令的关键区别在于Accept
头,我们指定了application/vnd.packt-v2+json
。通过这样做,Spring 框架将请求路由到我们 API 的版本 2 端点。预期输出是分页的产品列表,反映了版本 2 中引入的新行为:
{
"totalPages": 2,
"products": [
{
"name": "Keyboard",
"sku": "AK21109",
"description": "Ergonomic Keyboard",
"price": 60
},
{
"name": "Keyboard 08",
"sku": "AK21108",
"description": "Ergonomic Keyboard",
"price": 60
}
]
}
如我们从输出中看到的,响应中只包含两个项目,totalPages
字段反映了分页,根据我们提供的限制参数调整结果。这表明最新版本按预期处理了分页。
现在我们已经确认了版本 1 和版本 2 的预期行为,让我们探索当我们尝试访问一个不存在的版本(让我们称它为版本 3)时会发生什么。
要做到这一点,我们可以执行以下curl
命令并观察结果:
curl -X 'GET' 'http://localhost:8080/api/products?page=0&limit=2' -H 'accept: application/vnd.packt-v3+json'
执行后,输出将与版本 1 相同:
[
{
"name": "Keyboard",
"sku": "AK21109",
"description": "Ergonomic Keyboard",
"price": 60
},
{
"name": "Keyboard 08",
"sku": "AK21108",
"description": "Ergonomic Keyboard",
"price": 60
},
{
"name": "Keyboard 07",
"sku": "AK21107",
"description": "Ergonomic Keyboard",
"price": 60
}
]
这种行为可能并不理想,因为它可能会误导客户端认为存在版本 3。这是因为 Spring 框架默认允许两种媒体类型:application/json
和application/*+json
。因此,任何带有application/*+json
媒体类型的请求都将由默认方法处理,除非显式映射到不同的版本,就像我们的第二个版本那样。因此,如果没有特定的处理,对版本 3 的请求可能仍然被路由到默认版本,从而给出意外的响应。
为了解决这个问题,我们可以将我们的端点从版本 1 更新,并强制它只接受application/json
,如下面的代码所示:
@GetMapping(**produces =** **MediaType****.****APPLICATION_JSON_VALUE**)
@Override
public ResponseEntity<List<ProductOutput>> getProducts() {
final var products = productsQueryUseCase.getAllProducts(
PageRequest.ofSize(Integer.MAX_VALUE))
.stream()
.map(productMapper::toProductOutput)
.toList();
return ResponseEntity.status(HttpStatus.OK)
.body(products);
}
在应用此解决方案后,当我们使用curl
命令并指定application/vnd.packt-v3+json
媒体类型时,我们会遇到如下错误:
{
"type": "about:blank",
"title": "Not Acceptable",
"status": 406,
"detail": "Acceptable representations: [application/json,
application/vnd.packt-v2+json].",
"instance": "/api/products"
}
这个错误响应帮助客户端轻松地识别端点的可用版本。当请求不支持的版本时,返回清晰的错误,我们提供即时反馈,引导客户端向正确的版本过渡,从而提高 API 的整体可用性。
现在我们已经测试了我们的解决方案,并确认它保持了版本 1 的预期行为,同时引入了版本 2 的新功能,并为不正确的版本返回406 Not Acceptable
错误,我们可以将我们的重点转移到有效管理这些版本上,因为 API 仍在演变。让我们探讨如何随着 API 的增长和适应未来需求来处理版本控制。
管理 API 演变
管理 API 的演变是长期 API 维护的关键方面。随着应用程序的增长和客户需求的变化,API 必须适应并引入新功能,同时确保现有服务的稳定性和连续性。有效管理 API 演变涉及规划变更、与 API 消费者进行清晰沟通,并实施避免破坏现有功能同时以结构化和可预测的方式引入改进的策略。
要成功管理 API 演变,我们应该考虑以下子节中描述的关键实践。
避免引入破坏性变更
如我们之前讨论的,使 API 演变最佳的方法是确保新更改不会通过保持向后兼容性来干扰现有客户端。这最小化了潜在的服务中断,并为用户提供平稳的过渡。
要实现这一点,明确区分破坏性变更和非破坏性变更至关重要。在引入破坏性变更时,应用版本控制对于保持当前功能的完整性同时允许新功能共存至关重要。
在你的 API 中定义和应用版本控制策略
当需要实施重大变更时,应用版本控制策略是至关重要的。有多种方法可以对 API 进行版本控制,正如之前讨论的,保持版本控制的连贯性对于确保客户能够轻松理解和交互 API 至关重要。一个清晰且可预测的版本控制策略可以提升用户体验,使客户更易于导航和集成 API。
拥有一个明确定义的 REST 指南,概述如何在所有 API 中一致地实施版本控制策略,如第一章中所述,这是关键,并建立一个独特的版本控制策略为 API 消费者提供了许多好处,包括改进的可使用性和透明度。
更新并通知客户关于新版本和截止日期
在多个 API 版本并存的情况下,建立明确的迁移指南和弃用时间表是必不可少的。提供有关如何在不同版本之间过渡的详细信息有助于客户理解流程并为变化做好准备。这种透明度对于平稳迁移和最小化潜在中断至关重要。
维护一个最新的变更日志同样重要。它为客户提供了对最近更新、错误修复和新功能的全面了解,确保他们了解任何修改。一个良好的变更日志有助于客户有效地适应变化,并促进积极的用户体验。
语义版本控制
语义版本控制(https://semver.org/)是一个广泛采用的系统,它清楚地传达了 API 中变化的范围和影响。它使用三部分格式:MAJOR.MINOR.PATCH
。例如,主版本变更(例如,从 1.x 到 2.0.0)会通知客户已引入重大变更,给他们时间来适应。相比之下,次要和补丁更新(例如,1.1.0 或 1.0.1)向客户保证已进行改进或错误修复,而不影响兼容性。这种结构化方法允许开发者快速评估更新的性质,最小化破坏现有功能的风险。
通过实施语义版本控制,我们可以提供清晰的指南和全面的变更日志,以保持客户对更新的了解。这种结构化方法有助于客户理解每个版本变更的重要性,使他们更容易为可能需要采取行动的重大更新做好准备。
对于次要或补丁更新,客户会被告知新功能或错误修复,而无需立即升级的压力。这种平衡确保客户保持了解,并可以在方便的时候选择更新,保持平滑和灵活的用户体验。
标记旧端点为弃用
作为 API 演变过程的一部分,明确标记计划弃用的端点是重要的,以给客户足够的时间过渡到新版本。弃用旧端点有助于向客户发出更新所需的信号。
OpenAPI 提供了一种标记任何端点为弃用的方式,这在 Swagger UI 中得到反映。这可以通过以下代码轻松完成:
@Operation(
deprecated = true,
....
)
当我们在 Swagger UI 中添加 deprecated
属性并查看时,输出将类似于 图 5.3。然而,我们选择在此处不使用此属性,因为 OpenAPI 3.x 目前不支持基于内容协商的版本控制的弃用端点。
图 5.3 – Swagger UI:监控已弃用端点的使用情况
监控已弃用端点的使用情况对于确保在淘汰 API 的旧版本时平稳过渡至关重要。通过跟踪对这些端点的请求,您可以确定仍在使用过时功能的有多少客户,并评估他们迁移到新版本的过程。这有助于您在何时正式移除旧版本时做出明智的决定,避免对客户运营造成意外中断。此外,它还提供了关于哪些客户在过渡期间可能需要额外支持或沟通的见解。
监控工具,如 Grafana、New Relic、Datadog 和 Splunk,不仅有助于顺利的弃用过程,还有助于确认新 API 版本的采用。这些洞察使您能够设定实际的时间表来弃用端点,确保其移除不会干扰客户,并增强对 API 开发的信任和可靠性。
移除旧端点
一旦您设定了最后期限,与您的客户进行了沟通,并提供了升级到最新版本的明确说明,下一步就是移除已弃用的端点。但在最终确定移除之前,验证客户是否已成功迁移到最新版本至关重要。这个过程不仅涉及删除旧代码,还要确保与这些端点相关的所有引用和依赖关系都得到彻底清理。
通过应用这些原则,您可以平稳有效地管理 API 的演变,最小化干扰现有客户的风险,同时为他们提供必要的工具和信息,以便无缝适应。
建立一个明确的版本控制策略,使用语义版本控制,并正确地弃用旧端点,可以让您的 API 在最小干扰的情况下增长。这种方法确保您的客户有足够的时间过渡,同时您继续改进和扩展 API 的功能。最终,它促进了长期稳定性,并通过允许 API 随时间可持续地发展来提高开发者体验。
摘要
在本章中,我们探讨了 REST API 的各种版本控制策略,并学习了如何在保持兼容性的同时演进我们的 API,并确保平滑过渡到新版本。我们还应用了内容协商版本控制到当前的 API 中,特别关注实现分页。
在下一章中,我们将深入探讨更高级的主题,包括增强的分页技术、过滤、文件上传以及更多功能,以进一步精炼您的 API。
第六章:高级 API 概念和实现
创建健壮且高性能的 RESTful API 不仅仅是设置端点和处理 CRUD 操作。随着应用程序的增长和用户需求的增加,在 API 中融入先进的策略,以保持 API 在各种条件下的效率、可靠性和弹性变得至关重要。本章探讨了这些策略,重点关注数据处理技术,如分页、过滤和文件的上传和下载,以及对于现代 API 开发至关重要的弹性机制。
在数据管理方面,我们将检查分页、过滤以及通过 REST API 高效上传和下载文件等关键实践。这些方法旨在提高 API 的响应性和可伸缩性,确保它们能够无缝地管理更高的负载和更复杂的数据交互。其中一些主题与性能相关,但我们将更详细地介绍性能优化在第十章。
本章将探讨关键的弹性机制,包括超时、重试策略、速率限制、节流、幂等键、断路器和防波堤。这些方法有助于保护您的 API 免受故障的影响,有效管理流量,并在不可预测或不利条件下保持稳定性。
从开发者的角度来看,预测系统在实际场景中的行为可能具有挑战性。在开发过程中,我们通常在理想的环境下操作:在本地运行 API、作为唯一用户,以及使用可能导致失败的变量最少的测试。这种受控环境可能会掩盖生产中出现的复杂性和潜在问题,在那里 API 必须支持多个用户、处理不同的负载并响应意外事件。通过结合本章讨论的先进概念和实现,您将更好地装备自己,以构建在任何环境中都能可靠且高效运行的 API。
我们将涵盖以下主题:
数据处理
弹性
技术要求
在本章中,您将找到几种不同的方法,这些方法将帮助您根据您的需求改进您的 API。对于本章,我们建议您复习上一章的代码;本章的一些小改进可在以下链接找到:github.com/PacktPublishing/Mastering-RESTful-Web-Services-with-Java/tree/main/chapter6
。我们使用curl
命令行工具作为客户端来测试 API。
数据处理
数据处理是构建健壮且用户友好的 API 的基石。在本节中,我们将探讨高级数据处理技术,这些技术针对现实世界 API 开发中面临的常见挑战。
确保 API 提供可靠的性能对于多个相互关联的原因至关重要,包括提升用户体验、实现系统可扩展性、优化资源效率以及影响 API 开发背后业务的成功。
一个高性能的 API 必须快速响应并与客户端进行清晰的沟通。这意味着返回适当的 HTTP 状态码(例如,200 表示成功或 404 表示未找到)以及描述性消息,说明发生了什么。低延迟至关重要,因为用户期望快速响应,而缓慢的 API 可能会创建系统瓶颈,让用户感到沮丧。
当 API 响应时间过长时,会导致整个应用程序出现延迟。现代系统通常需要实时数据更新,因此 API 必须高效地处理频繁的请求,同时保持一致的响应时间。清晰的沟通同样重要——当出现错误时,客户端需要提供解释问题的信息性消息并建议解决方案。
有效的 API 在速度和清晰度之间取得平衡。它们快速处理请求,同时通过适当的状态码和有用的消息提供有意义的反馈。这种方法确保了最终用户和开发者都能有效地使用 API,减少了困惑和调试时间。
优化响应速度和消息质量有助于 API 在不降低性能或增加运营成本的情况下处理增加的流量。这些改进既支持用户满意度,也支持业务增长。
在接下来的章节中,我们将探讨提高 API 性能的数据处理技术。我们将从管理大数据集的分页开始,然后介绍通过 REST API 进行过滤和高效的文件操作。
分页
从一个或多个数据库表中检索大量记录并将它们转换为与您的语言/框架兼容的数据结构,例如在 Java 中使用 Spring Boot,会消耗资源,包括内存和 CPU 处理能力。这个过程会一直持续到数据被转换为标准格式,如 JSON,用于通信并准备好发送给客户端。此外,即使在数据准备完毕后,仍然会使用资源通过网络将其传输到客户端。
通常,没有必要将资源表示的所有数据在一个大型的单一负载中发送给客户端。这样做会消耗更多资源,降低系统性能,并导致糟糕的用户体验。这可能会伴随着与云计算相关的增加成本,这些成本基于使用量来计费。处理和传输大量数据也需要更多时间。相反,可以将数据分成更小的块,这需要更少的时间和资源,从而使 API 更可靠且维护成本效益更高。这种方法被称为分页,它提高了 API 的响应速度并节省了资源。
考虑以下示例:我们的 API 包含 100,000 个产品,我们有一个单页应用程序(SPA),它消费此 API 以在产品页面上显示产品数据表。想象一下,每次用户打开这个页面时,API 都会被调用以访问数据库,将这些记录加载到内存中,将它们转换为 JSON,然后在用户每次访问页面时通过互联网请求传输大量数据。现在,想象一下,这个系统每天被众多用户使用。如果没有分页,每个用户操作都会触发资源密集型过程,导致成本增加、响应时间变慢,以及用户体验下降。通过实现分页,API 可以提供更小、更易于管理的数据集,从而提高性能和可扩展性,同时降低运营成本。所有这些都可以在不限制应用程序用户界面视图的情况下完成,因为用户永远不会同时看到所有产品。
不同的分页方法
以下是一些常见的 REST API 分页方法:
基于偏移量的分页
基于页面的分页
基于游标的分页
关键集分页
让我们逐一看看它们
基于偏移量的分页
基于偏移量的分页是一种简单且广泛采用的技术。它通过定义一个偏移量(起始位置)和一个限制(每页要检索的记录数)来在数据集中导航。这种做法可以在多个不同公司的 API 中找到,例如 OpenWeatherMap、Stripe、Adidas 和 Mailchimp。
以下是基于偏移量分页的优点:
简单性:该方法易于实现和理解
灵活性:通过调整偏移量,您可以直接跳转到您可能想要的位置
以下是基于偏移量分页的缺点:
性能问题:随着偏移量的增加,查询性能会下降,因为数据库必须扫描并跳过越来越多的记录。
数据一致性:请求之间的数据更改,如插入或删除,可能导致结果不一致,导致页面之间出现重复或缺失的记录。
客户可以操作业务逻辑,但这并不能保证所有客户都能获得相同的体验。然而,如果 API 旨在非常灵活,这也可以被视为一种优势。
很可能,这将被传递到数据库查询,如 SQL,如果没有适当的输入清理和参数化,可能会导致 SQL 注入。
以下是一个 API 调用的示例:
GET /api/products?offset=50&limit=25
GET https://<dc>.api.mailchimp.com/3.0/lists/{list_id}/members?count=100&offset=200
基于页面的分页
基于页面的分页将数据集分为页面,允许客户通过指定页码来导航数据。这种方法很简单,因此被 API 市场的公司广泛采用,例如 Salesforce 和 Microsoft。
要使用它,您需要指定要检索的页面(page
)和每页的记录数(page_size
)。
以下是基于页面分页的优点:
用户友好性:用户可以直观地导航到特定页面
简单性:易于实现和理解
以下是基于页面分页的缺点:
性能问题:与基于偏移量的分页类似,大页码可能导致性能下降。
灵活性较低:返回的记录列表必须从页面边界开始。这可能不适合使用滚动条而不是传统分页的用户界面。
以下是一个 API 调用的示例:
GET https://api.github.com/repos/{owner}/{repo}/issues?page=2&per_page=30
GET https://yoursubdomain.zendesk.com/api/v2/tickets.json?page=5&per_page=100
在演示 API 演变时,实现了示例产品 API 的GET /products
端点的基于页面的分页,见第五章。
基于光标分页
基于光标分页使用一个指针(光标)来跟踪数据集中的当前位置。客户端不是指定偏移量,而是使用光标请求下一组记录,并指定一个表示我们希望每块数据返回的记录数的限制。这正是 X(前身为 Twitter)API 实现分页数据的方法,包括推文、关注者和其他资源。
以下是基于光标分页的优点:
性能效率:由于不需要跳过记录,因此无论数据集大小如何,都能保持可靠的性能
数据一致性:相较于请求之间的数据不一致性,这种分页方式能确保更可靠的结果
以下是基于光标分页的缺点:
复杂性:与基于偏移量的分页相比,实现更为复杂
灵活性较低:适用于顺序导航,如果需要跳转到任意页面可能不是最佳选择
以下是一个 API 调用的示例:
GET /api/products?cursor=eyJpZCI6NzUsIm5hbWUiOiJQcm9kdWN0IDc1In0=&limit=25
GET https://api.twitter.com/2/tweets?pagination_token=XYZ123&max_results=20
键集分页
键集分页是基于光标分页的一种变体;它利用一个唯一键——通常是时间戳或通用唯一标识符(UUID)——来导航记录。与依赖于数值偏移量(在数据集较大时可能越来越低效)的基于偏移量的分页不同,键集分页使用唯一键来标记数据集中的位置。这种方法确保了更快的查询性能和更一致的响应时间,特别适合需要实时数据访问和可扩展性的应用。
以下是基于键集分页的优点:
性能:键集分页通过消除数据库计数或跳过大量记录的需求(这在基于偏移量的分页中是必要的),为大数据集提供了更好的性能
可扩展性:随着数据集的增长,键集分页能保持一致的响应时间,使其成为高流量应用的可扩展解决方案
可靠性:通过依赖唯一的键进行导航,键集分页降低了遇到缺失或重复记录的风险,确保分页结果中的数据完整性
以下是一些键集分页的缺点:
仅顺序导航:键集分页需要顺序数据访问。与基于偏移量的分页不同,后者允许用户通过指定偏移量直接跳转到任何页面,键集分页需要按线性顺序遍历记录。这种限制对于需要访问非顺序页面或在数据集中进行随机访问的应用程序可能具有约束性。
书签依赖:要访问特定页面,客户端必须保留前几页的唯一键(游标)。这种依赖关系可能会使客户端逻辑复杂化,尤其是在用户可能希望重新访问或共享特定页面而不维护游标历史的情况下。
以下是一个 API 调用的示例:
GET https://www.reddit.com/r/{subreddit}/comments.json?limit=25&after=t3_abcdef
GET https://api.linkedin.com/v2/connections?q=cursor&start=0&count=25&cursor=urn:li:person:123456789 Authorization: Bearer YOUR_ACCESS_TOKEN
在为所有端点和 API 选择要应用的分页策略后,我们还应该返回有关分页的信息。提供有关总项数、页数和当前页的信息可以帮助客户端更好地导航大型数据集。
返回分页信息的不同方法
有许多方法可以将分页信息返回给客户端。最常见的方法如下:
使用响应头
在响应体中包含分页信息
使用超媒体(HATEOAS)
使用响应头
这种策略涉及将分页信息直接嵌入到 HTTP 头中,例如关于总页数、当前页和其他相关信息。这种方法通常不单独使用,而是经常与其他策略结合使用以增强 API 功能。例如,GitLab 就采用这种方法与超媒体策略结合,以提供更全面的用户体验。我们可以将过滤与分页结合使用,以检索特定数据,通过将数据分成分页部分来管理大型数据集:
HTTP/2 200 OK
status: 200 OK
X-Total-Count: 150
X-Total-Pages: 15
X-Current-Page: 3
X-Page-Size: 10
在前面的示例中,我们可以看到四个自定义头,用于告知客户端如何有效地导航 API。
在响应体中包含分页信息
在这种方法中,API 将分页数据与相关的分页元数据(如总页数和当前页)一起包含在响应体中。例如,Stripe API 通过返回数据和has_more
标志(指示是否有更多结果可用)来采用这种策略。这种方法通过直接将分页细节与响应集成,使客户端能够理解其数据上下文。
一些 API 将响应数据与元数据分开,这也可以是有效的。以下是一个示例结构:
{
"data": [
{
"id": 1,
"name": "Item 1"
},
{
"id": 2,
"name": "Item 2"
}
],
"pagination": {
"totalItems": 150,
"totalPages": 15,
"currentPage": 3,
"pageSize": 10
}
}
在这种结构中,data
字段包含 API 的实际结果,而pagination
部分提供了关于分页响应的必要元数据。这种方法通过清晰地组织数据和相关的分页信息来提高可用性,使客户端更容易导航和处理结果。
使用超媒体(HATEOAS)
HATEOAS 通过嵌入导航链接增强了 API 响应,引导客户端根据服务器的响应进行导航和执行操作。正如我们在第一章中讨论的那样,HATEOAS 代表了 REST 架构的第四个成熟度级别,有助于将客户端与服务器解耦。我们将在本章后面的HATEOAS子节中深入探讨这个主题。
这种策略使客户端能够动态地与 API 交互,无需事先了解结构或额外的文档。例如,GitHub 等 API 通过采用 HATEOAS 使导航资源,如分页数据,变得简单直观。在下面的代码块中,我们可以看到使用这种策略帮助客户端通过数据的示例:
{
"_links": {
"self": { "href": "/orders?offset=200&limit=20" },
"prev": { "href": "/orders?offset=180&limit=20" },
"next": { "href": "/orders?offset=220&limit=20" },
"first": { "href": "/orders?limit=20" },
"last": { "href": "/orders?offset=1000&limit=20" }
},
"totalCount": 1020,
"_embedded": {
"orders": [
{ ... },
{ ... },
...
]
}
}
如前述代码所示,_links
属性提供了关键的导航细节,包括关于当前请求的信息以及到上一页和下一页的链接,以及第一页和最后一页的链接。有了这些综合链接,客户端可以轻松地导航 API,无需手动构造额外的请求。
这种结构简化了客户端与数据的交互,将必要的导航信息嵌入到每个响应中。通过提供这些链接,API 使开发者能够创建更直观和无缝的用户体验。
在 API 开发中,高效的数据处理和强大的分页策略不仅仅是辅助功能——它们是支撑应用程序效能、可扩展性和整体用户体验的基本要点。我们已经概述了一些不同的分页方法及其细节,以确保您的 API 提供可靠且高性能的服务。无论您选择哪种方法,关键在于在整个端点之间保持一致性。确保每个 API 遵循相同的分页标准有助于简化客户端的集成,并促进更一致的用户体验。
在下一节中,我们将探讨如何通过应用一些允许我们结合分页和过滤的策略来过滤 API 返回的数据。
虽然分页本身可以通过将数据分成可管理的页面来管理大量数据集,但它可能无法完全满足客户端在需要特定数据子集时的需求。为了进一步提高数据处理能力,我们可以引入过滤功能,这将允许客户端在每一页中仅检索他们所需的数据。通过结合分页和过滤,我们提供了一种更灵活和高效的数据检索方法。
过滤
过滤允许 API 客户端通知我们的 API 我们需要在该资源上的一些特定数据子集,这意味着它将不会浪费资源处理我们此时不感兴趣的数据;我们可以专注于我们所需的数据。我们可以将过滤与分页结合使用来检索特定数据,从而通过将数据集分成分页部分来管理大量数据。
以下是一些过滤原则:
无状态:REST API 应该是无状态的,这意味着客户端的每个请求都必须包含所有必要的信息,以便服务器能够满足请求。过滤是通过在请求 URL 的查询字符串中包含过滤参数来实现的,无需在 API 服务器端保存此状态。
一致性和可预测性:过滤语法应在所有 API 端点保持一致。这确保了不同端点之间的可预测性,并使得客户端通过查看请求就能轻松理解过滤操作。例如,如果我们在一个端点通过
?firstName=Peter
参数进行按名字过滤,那么在其他相关端点进行此类过滤时,其操作应类似。粒度和灵活性:有效的过滤机制允许客户端精确定义他们的需求,同时足够灵活以应对不同场景。例如,过滤器应适应不同的数据类型(如字符串、数字和日期)以及一系列运算符(如等于、不等于、大于、小于、在等)。
不同的过滤方法
过滤可以通过多种不同的方法实现。它可以采用以下方式实现:
基本字段过滤:这是最直接的方法,您可以传递字段名称和您想要过滤的值到资源中,这通常通过在 URL 上传递这些值作为查询参数来完成:
GET /customers?firstName=Peter or GET /products?category=electronics
多字段过滤:这与上一个例子相同,唯一的区别是我们将传递多个字段过滤器及其相应的值来过滤数据。为了分隔
field=value
集合,我们将使用'&'
字符:GET /customer?firstName=Peter&age=30
通过一系列值进行过滤是可能的,通过将min
和max
前缀与相同的字段名称结合,如下所示:
GET /customer?minAge=35&maxAge=45
使用比较运算符进行过滤:一些 API 还提供了处理比较运算符的能力;这为构建比简单的字段等于值更复杂的过滤组合提供了机会:
GET /products?price[gte]=100&price[lte]=500
列表过滤(包含/排除):使用这种方法,我们可以在列表上使用
in
或nin
(不在)运算符:GET /users?role[in]=admin,supervisor,user GET /products?category[nin]=books,phones
到现在为止,你应该已经意识到,分页和过滤是你在 API 实现中不能错过的两个概念。此外,它们需要结合起来,以充分利用我们 API 端点的优势,这些端点是返回数据列表的候选者。以下是一些最佳实践:
使用显著、清晰、直观的参数名称,以引导用户避免错误
验证和清理输入以避免攻击和错误
验证并实施默认限制,以避免用户请求大值,使服务器不得不传递大量数据
在整个系统中保持一致的命名、响应元数据和方法,以提供良好的用户/开发者体验,使结构和参数化标准化且易于消费
过滤是稳健 API 设计的关键方面,它使客户端能够获取所需数据,而不会过度负担服务器或网络。通过实施结构良好的过滤机制,API 可以实现更高的性能、可扩展性和用户满意度。
采用各种过滤方法——从基本的字段过滤器到高级的比较运算符和基于列表的标准——使 API 能够满足多样化的客户端需求和复杂的查询需求。此外,将过滤与分页集成不仅增强了数据管理,还确保了即使在处理大量数据集时,响应也能保持快速且易于管理。
让我们探索一个基于我们分页端点的实际例子。在这里,我们添加了过滤功能,允许客户端指定如类别或价格范围等标准。结合分页和过滤不仅增强了数据管理,而且是一种最佳实践,为客户端提供流畅的体验,即使在数据集增长的情况下,也能保持响应快速且相关。
考虑一个电子商务 API 端点,该端点返回产品列表。此端点允许客户端请求特定页面的数据并应用过滤器,仅检索他们需要的商品,从而减少客户端和服务器上的数据负载。
以下是一个使用分页和过滤的 API 调用示例:
GET /api/v1/products?page=2&limit=10&category=electronics&price_min=100&price_max=500&sort=price_asc
它应该返回如下内容:
{
"data": [
{
"id": 101,
"name": "Smartphone Model X",
"category": "electronics",
"price": 250.00,
"available": true
},
{
"id": 102,
"name": "Wireless Earbuds",
"category": "electronics",
"price": 120.00,
"available": true
}
// Additional products...
],
"pagination": {
"totalItems": 42,
"totalPages": 5,
"currentPage": 2,
"pageSize": 10
}
}
分页和过滤的最佳实践
让我们来看看在分页和过滤时应该遵循的一些最佳实践:
使用清晰直观的参数名称:参数名称,如
page
、limit
、category
、priceMin
和priceMax
,应该是简单、一目了然且符合常见的 API 设计惯例,使端点易于使用和理解。应用输入验证和清理:API 验证所有参数以确保它们格式正确。例如,
page
和limit
必须是整数,而priceMin
和priceMax
必须是十进制数。此外,sort
参数仅接受特定值,防止注入攻击和错误。应用默认限制和约束:在我们的示例中,每页默认限制为 10 项,且
limit
参数不能超过100
。这些措施可以保护服务器免受处理过大请求的影响,提高性能并防止滥用。采用一致的命名和元数据:响应包括元数据——
page
、limit
、totalPages
和totalItems
——这些元数据向客户端提供有关分页状态的必要信息,确保标准化和可预测的体验。结合分页和过滤:本例演示了分页和过滤如何协同工作。客户端可以同时指定页码和过滤器,允许他们检索所需的确切数据,而不会过载服务器或客户端,避免提供不必要的信息。
通过应用这些最佳实践,API 实现了灵活性、性能和用户友好性的平衡,确保客户端可以高效地访问数据,同时保持强大、可扩展的 API 设计。
通过分页和过滤,我们已经看到如何通过选择性检索信息来有效地管理大量数据。然而,在许多应用程序中,处理数据并不仅限于管理结构化信息。通常,API 需要支持上传和下载文件,允许客户端以图像、文档和报告等格式传输数据。
在下一节中,我们将深入探讨通过 REST API 处理文件上传和下载的最佳实践和技术。这些策略将有助于确保安全、可靠的文件传输,并与 API 的其余部分顺利集成。
通过 REST API 上传和下载文件
Web 应用通常需要支持文件上传和下载。此功能通常伴随着额外的要求,例如验证文件类型、名称和最大大小,确定最佳存储解决方案,提供有意义的响应,以及通过流式传输有效地处理大文件。在本节中,我们将详细讨论这些常见主题。
当设计用于处理文件上传的控制器时,确保使用 Spring Web 接口定义上传文件的参数至关重要,即表示多部分请求中上传文件的 Spring Boot 接口——org.springframework.web.multipart.MultipartFile
。此外,请求必须将其Content-Type
设置为multipart/form-data
,这是网络浏览器用于上传文件的常用格式,因此使用MultipartFile
的 API 服务也期望这种格式。
我们可以通过向我们的示例产品 API 添加一个子资源 /products/{productId}/photo
来演示上传和下载功能,这将代表产品的照片图像。
以下是一个上传单个文件的示例 HTTP 请求和方法:
curl -X PUT -F "file=@C:/path/of/your/file.jpeg" http://localhost:8080/api/products/AK12345/photo
// Single file upload Controller Method
@PutMapping("/{productId}/photo")
public ResponseEntity<String> uploadProductPhoto(
@PathVariable("productId") @ValidSku String productId,
@RequestParam("file") MultipartFile file) {
// Implementation logic
}
multipart/form-data
格式表示一个可以包含多个字段的表单。它也可以用于在单个请求中上传多个文件。这就是为什么在先前的示例中,"file"
参数名称对于识别代码想要处理的文件非常重要。
以下是一个示例 HTTP 请求:
curl -X POST -F "file=@C:/path/of/your/file1.txt" -F "file=@C:/path/of/your/file2.txt" http://localhost:8080/uploads
以下是多文件上传的方法:
// Multiple files upload Controller Method
@PostMapping("/uploads")
public ResponseEntity<String> uploadFiles(@RequestParam("files") MultipartFile[] files) {
// Implementation logic
}
我们的示例代码为了简单起见,将上传的文件内容作为关系数据库中的大型对象(LOB)存储。关系数据库没有针对这类数据进行优化,所以在大规模系统中,通常使用其他类型的存储。
文件可以保存在外部存储提供者,如 AWS S3。这种基于云的存储解决方案有效地管理文件,提供访问存储内容的链接。这种方法在行业中越来越受欢迎,因为它优化了资源管理并利用了云基础设施的可扩展性。
通过在云数据存储中保存文件并通过 API 仅暴露链接,开发者可以避免直接加载大量资源。这不仅提高了性能,还改善了客户端的响应时间,使他们能够按需检索文件,而不会过载 API。
在先前的示例中,API 在响应中返回一个字符串,因此返回的字符串可以用来传递云存储 URL。
建议验证上传的文件以确保安全性、数据完整性和性能。以下部分描述了一些可能的验证。
验证上传的文件
验证步骤对于 RESTful API 至关重要,对于您可能正在开发的任何系统也是如此,以维护应用程序的完整性和安全性。您接收到的输入可以使应用程序容易受到多种不同类型的攻击。让我们看看在上传文件时需要执行的一些重要验证示例。
验证文件内容类型
仅接受特定类型的文件是一个很好的措施,可以避免接收恶意文件或不符合您需要存储的数据的数据。在以下示例中,您确保只会收到 PNG 和 JPEG 文件:
private static final List<String> ALLOWED_CONTENT_TYPES = List.of("image/png", "image/jpeg");
String contentType = file.getContentType();
if (!ALLOWED_CONTENT_TYPES.contains(contentType)) {
// report error using the 415 HTTP status code (Unsupported Media Type)
}
请注意,整个 HTTP 请求体的内容类型将是 multipart/form-data
,但其中包含的每个文件也有其自己的内容类型。这就是为什么我们需要使用我们即将处理的特定 MultipartFile
对象的 getContentType
方法来检查内容类型。
除了文件内容之外,我们的示例 Product API 还存储上传文件的 MIME 类型。当文件被下载时,GET
端点返回相同的 MIME 类型。如果没有正确的Content-Type
头,在网页浏览器中打开 URL 将显示乱码,因为浏览器不知道如何解释下载的文件字节。
验证文件大小
验证文件大小是一种避免处理比预期更大的文件的措施,无论上传是否有意为之,这都可能因为系统因过大的文件而超载导致内存耗尽,从而造成服务拒绝。
我们可以使用MultipartFile
类的相应方法程序化地检查文件大小:
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
if (file.getSize() > MAX_FILE_SIZE) {
throw new FileSizeLimitExceededException(
"File size exceeds the allowable limit of 10 MB.");
}
文件上传也受到 Spring 配置属性的限制,这些属性指定了可接受的最大多部分请求大小和请求中单个文件的最大大小。您可以在application.yml
或application.properties
文件中设置这些属性:
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
验证文件名
为了提高服务器上的文件上传安全性,验证文件名对于防止诸如路径遍历等攻击至关重要。例如,攻击者可能会使用如../../etc/passwd
这样的文件名来尝试访问上传目录之外的敏感文件。
在文件上传中,路径遍历攻击尤其危险。通过操纵文件路径,攻击者可能利用薄弱的验证在服务器上保存文件到非预期位置,这可能导致敏感数据的泄露或甚至覆盖关键文件。例如,如果服务器直接将用户提供的文件名附加到上传路径上,攻击者可以使用../
序列在目录结构中向上导航,从而允许他们访问或修改受限文件。这就是为什么在处理文件路径时,尤其是在上传场景中,验证至关重要。
虽然有几种方法可以防止路径遍历攻击,以下是一些有效的策略:
仔细设置用户和文件权限:限制权限,仅允许必要的用户和目录访问。
单独存储文件:考虑将上传的文件存储在不同的服务器或安全的存储服务中,例如 AWS S3。通过将文件从主服务器上移除,可以降低对系统文件结构的未授权访问风险。
验证文件路径:确保文件路径指向预期发送文件的特定文件夹。
使用安全的文件上传解析器:在 Spring 中,如
CommonsMultipartResolver
和StandardServletMultipartResolver
之类的类作为解析器,负责解析或解释请求中的多部分数据,并帮助安全地管理文件上传。它们将文件部分与其他表单数据分开,并在 Spring 的请求处理框架中将它们作为MultipartFile
对象提供。这两个类都负责解析常见的多部分文件请求,这些请求通常用于文件上传。它们将文件数据与用户输入分开处理,这有助于防止注入攻击:CommonsMultipartResolver:这个类是 Apache Commons
FileUpload
库的一部分,允许您配置文件大小限制和临时存储目录。它为需要严格控制文件存储和性能的应用程序提供了灵活性。通过通过CommonsMultipartResolver
设置限制和约束,您可以减轻过度文件上传或未经授权访问临时存储等风险。StandardServletMultipartResolver:这是一个 Spring 框架中的解析器,它利用 Servlet 3.0 提供的内置多部分支持,使其成为处理 Spring 应用程序中文件上传的可靠且高效的选项。通过依赖 Servlet 容器本地的多部分处理,这个解析器避免了需要额外的库,简化了设置和维护。
这些解析器不仅简化了文件处理,而且通过以标准化的方式处理多部分文件请求,有助于减轻潜在的安全漏洞。这确保了文件将根据预定义的规则进行处理,从而降低了路径遍历和其他注入攻击的风险。
当处理存储在应用程序服务器上的文件时,请记住不要仅依赖StringUtils.cleanPath
等方法。尽管这些方法被广泛使用,但它们不应是唯一的安全措施,因为它们不能完全防止路径遍历。正如 Spring 文档(v6.1.12)中所述,“cleanPath
不应在安全环境中依赖。”
为了增强文件名的消毒,您可以使用 Apache Commons IO 库中的FilenameUtils
类。这个实用工具类提供了安全地操作和消毒文件名的各种方法。
以下是一些有助于覆盖一些文件上传验证的代码示例:
// Extract the base name of the file, removing any path information
String sanitizedFilename = FilenameUtils.getName(originalFilename);
// Enforce character restrictions
if (!sanitizedFilename.matches("^[a-zA-Z0-9._-]+$")) {
throw new SecurityException("Invalid characters in file name.");
}
// Limit file name length
if (sanitizedFilename.length() > 100) {
throw new SecurityException("File name too long.");
}
// Check allowed extensions
List<String> allowedExtensions = Arrays.asList("jpg", "png", "pdf");
String extension = FilenameUtils.getExtension(sanitizedFilename);
if (!allowedExtensions.contains(extension.toLowerCase())) {
throw new SecurityException("File type not allowed.");
}
// Avoid leading dots and double extensions
if (sanitizedFilename.startsWith(".") || sanitizedFilename.contains("..")) {
throw new SecurityException("Invalid file name format.");
}
// Ensure path is within the intended directory
Path targetPath = Paths.get("/uploads/")
.resolve(sanitizedFilename)
.normalize();
if (!targetPath.startsWith("/uploads/")) {
throw new SecurityException("Invalid file path.");
}
// Optionally, add a unique prefix to avoid conflicts
String uniqueFileName = UUID.randomUUID().toString() + "_" + sanitizedFilename;
这种方法提供了对文件名的彻底消毒和验证,确保它们执行以下操作:
符合预期的字符和格式
具有合理的长度限制
限制在允许的文件类型范围内
仅存储在预期的目录中
可选地分配一个唯一名称以防止冲突
通过结合这些安全措施并利用FilenameUtils
进行安全的文件名操作,您可以确保文件上传保持安全。这减少了路径遍历攻击的机会,并保护您的应用程序免受未经授权的访问。即使您的应用程序在 AWS S3 等服务上存储文件,验证文件名也有助于保持一致、安全且用户友好的体验。
使用 AWS S3 等存储服务可以通过将某些安全关注点从您的应用程序服务器卸载来简化文件上传处理。由于 S3 和类似服务在与管理您的服务器文件系统分离的管理环境中存储文件,它们减少了路径遍历攻击的风险,攻击者可能会尝试在未经授权的位置保存或访问文件。通过隔离存储,这些服务保护了您的核心基础设施免受与上传文件的直接交互,减少了不正确验证的文件路径或名称可能带来的潜在影响。
此外,AWS S3 自动为上传的文件生成唯一的 URL 或标识符,这最大限度地减少了名称冲突解决和额外文件名验证的需求。S3 强制执行安全的命名约定,并确保上传的文件安全存储,不影响应用程序的其他区域。这意味着您的应用程序可以专注于验证基本属性,如文件类型或大小,而不必过多担心路径遍历或目录限制,从而简化文件上传过程并增强整体安全性。
在实施彻底的验证以确保文件名安全并减轻如路径遍历攻击等风险之后,考虑我们的 API 如何传达文件上传过程的结果至关重要。提供清晰、有意义的响应确保客户了解其上传尝试的状态,无论成功与否。有效的响应不仅承认成功——当发生错误时,它们还指导用户了解出了什么问题以及如何纠正它。通过返回特定的 HTTP 状态码和相关的元数据,我们可以使文件上传过程更加透明、可靠和用户友好。
在我们的 API 服务上提供文件上传的有意义响应
通过使用适当的 HTTP 状态码,向客户端提供有关上传结果的明确反馈:
201 Created
:当文件成功上传时415 Unsupported Media Type
:对于无效的文件类型413 Payload Too Large
:如果文件大小超过允许的限制400 Bad Request
:对于其他验证错误
在响应中包含元数据,例如文件 URL 或标识符,以便将来下载。
在涵盖这些基本但至关重要的细节并强调处理 API 中文件的具体预防措施之后,很明显,即使是看似微小的实现也可能导致潜在的安全漏洞。这些弱点可能成为关键故障点,可能破坏整个 API 的稳定性。例如,单个格式错误的文件可能会干扰 API 操作,或者利用文件命名约定的攻击可能为恶意行为者提供入口点。介绍这个主题的目的是引起您对在处理 API 中的文件时可能遇到的这些场景的注意。
在本章早期,我们在讨论分页时介绍了 HATEOAS,以展示超媒体链接如何增强对大型数据集的导航。现在,我们将更详细地探讨 HATEOAS,因为它代表了一个更广泛的架构模式,它超越了分页,提供了一种更复杂的 API 交互和资源发现方法。
HATEOAS
如同在分页章节中提到的,HATEOAS 是 RESTful API 的关键原则之一,它通过在响应中包含导航链接来改善客户端的交互。这些超链接指导客户端如何访问相关资源,而无需事先了解 API 结构。正如我们之前所看到的,在分页响应中,HATEOAS 可以提供到下一页和上一页的链接,从而在不要求硬编码逻辑的情况下,实现数据的平滑导航。
通过使用 HATEOAS,API 变得更加自解释且易于适应变化。客户端可以直接从响应中根据提供的链接发现可用的操作,如编辑或删除。这简化了 API 的使用,并确保了灵活性,因为客户端不需要依赖外部文档来理解 API 的行为,从而在更新发生时减少了出错的机会。
让我们来看一个使用 HATEOAS 来展示资源上可用操作的例子:
{
"id": 123,
"name": "John Doe",
"email": "johndoe@example.com",
"links": [
{
"rel": "self",
"href": "/users/123",
"method": "GET"
},
{
"rel": "edit",
"href": "/users/123",
"method": "PUT"
},
{
"rel": "delete",
"href": "/users/123",
"method": "DELETE"
}
]
}
在前面的例子中,响应的links
部分包含三个元素。第一个元素,"rel": "self"
,表示指向当前资源的链接。这允许客户端使用GET
方法在href
(代表超媒体引用)属性中提供的 URL 检索或与特定用户交互。第二个元素,"rel": "edit"
,提供了一个使用PUT
方法更新用户详情的链接。最后,"rel": "delete"
元素允许客户端使用DELETE
方法删除用户。每个链接都指导客户端与资源进行交互,使 API 自解释且易于使用。
HATEOAS,如 RFC 8288 所述,正式化了在 API 中使用 Web 链接,确保了这些链接的结构和解释的一致性和清晰性。此 RFC 定义了关键组件,如rel
属性,它指定了链接的关系类型(例如,"self"
或"edit"
),以及href
,它定义了交互的目标 URL。通过遵循此标准,API 变得更加健壮,客户端能够导航资源并执行操作,而无需硬编码端点路径或参考外部文档。这种方法增强了灵活性并确保 API 交互的未来兼容性,因为服务器可以进化,同时仍然与现有客户端保持兼容性。
我们之前看到的相同示例也可以有不同的响应,其中某些操作(如删除元素)不可用。例如,在以下示例中,对用户"Jane Smith"
的响应在links
部分不包括"delete"
链接:
{
"id": 456,
"name": "Jane Smith",
"email": "janesmith@example.com",
"links": [
{
"rel": "self",
"href": "/users/456",
"method": "GET"
},
{
"rel": "edit",
"href": "/users/456",
"method": "PUT"
}
]
}
在这种情况下,delete
链接的缺失表明由于权限限制或业务规则,此资源不支持delete
操作。HATEOAS 有助于向客户端清晰地展示这些限制,确保客户端不会尝试执行不支持的操作。这允许 API 动态控制哪些操作是允许的,防止滥用并根据资源当前状态引导客户端的行为。
为了简化 HATEOAS 的实现,现代 API 框架提供了内置工具来支持此模式。例如,在 Spring Boot 中,你可以利用spring-boot-starter-hateoas
依赖项轻松地将 HATEOAS 集成到你的 API 中:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
HATEOAS 增强了 API 的动态性,通过结合我们之前提到的所有好处,将 API 提升到一个更高的成熟水平。然而,评估此资源是否适用于你的用例是很重要的,因为 HATEOAS 不是每个 API 的强制组件。它需要另一个依赖项、额外的开发和维护,并且需要关注是否值得拥有它。
我们已经通过采用高效的数据处理技术,介绍了构建可靠且用户友好的 API 的基本实践。现在,随着我们转向弹性模式,我们将探讨帮助 API 在面临意外挑战时保持稳定和健壮性的策略。弹性对于确保 API 在各种不利条件下继续可靠地运行至关重要,从网络问题到系统过载。在接下来的部分中,我们将深入研究使 API 能够优雅地处理此类场景的模式,从而增强其可靠性和用户体验。
弹性
在讨论如何使 API 具有弹性之前,让我们看看牛津高阶英文字典对弹性的定义:“(指人或动物)能够承受或迅速从困难条件下恢复过来。”如果我们尝试将同样的定义应用到我们的 RESTful API 或系统上,我们可以说,一个弹性的 API 是能够承受故障和中断,同时保持其功能性和性能,并从可能导致其关闭或性能下降的情况中恢复的 API。
为了确保 API 作为更大、更健壮系统的一个可靠组件,它必须有效地处理过载、依赖服务的性能下降和基础设施故障。为此,我们需要关注 API 设计的一些方面,我们将探讨实现这些功能的方法和技术,确保您的 API 能够提供一致的价值。
关于 API 设计,我们需要遵循的关键原则如下:
冗余:拥有我们组件/服务的多个实例,以避免单点故障。
解耦:在设计中最小化依赖性,旨在限制系统任何部分失败时的影响。
通过减少系统组件之间的互连,最小化系统设计中的直接依赖,这有助于限制单个组件失败的影响。当一个 API 与其他服务或组件紧密耦合时,一个部分的故障可能会在整个系统中级联,导致广泛的故障或性能下降。相比之下,解耦的系统允许组件更独立地运行,隔离故障并防止它们影响其他部分。
故障隔离:使用诸如桅杆和保险丝等技术将故障限制在系统的隔离部分,防止错误状态在整个系统中传播,就像“毒丸”一样。
优雅降级:即使在系统部分失败或性能不佳的情况下,也要保持有限的功能。这种方式优先考虑主要功能,防止整个系统停止。
让我们列出一些我们将讨论的概念和模式,以及每个概念可以帮助我们解决的问题:
超时
重试机制
速率限制和节流
幂等性键
保险丝
桅杆
超时
在设计 API 时建立适当超时设置至关重要,以确保系统弹性。API 交互通常涉及服务之间的同步远程调用,无论它们位于同一网络还是不同网络。这种同步通信意味着客户端无法了解服务器的处理状态,保持网络连接,并等待调用成功或失败。如果没有明确定义的超时,客户端可能会经历长时间的等待响应,这可能会降低用户体验,引入安全漏洞,并导致系统不稳定或停机。
超时可以从两个角度进行设置:
客户端配置
服务器配置
客户端配置
在客户端配置超时对于防止客户端无限期等待服务器响应至关重要。通过设置特定的超时持续时间,客户端确保它不会因为延迟的服务器回复而变得无响应。当服务器在指定的超时期间未能响应时,客户端应执行以下操作:
提醒用户:通知用户关于延迟的情况,以保持透明度并管理预期
记录事件:将超时发生记录在日志中,用于监控和故障排除
启动重试机制:根据应用程序的要求,客户端可能会尝试重新发送请求以从暂时性问题中恢复
让我们使用 Spring Boot 的RestClient
实现 API 客户端的超时。在我们的示例中,我们将配置一个六秒的超时,在允许正常操作足够的时间和防止过度等待之间取得平衡。以下是配置RestClient
以适当的超时设置的步骤:
@Configuration
public class ProductsApiConfiguration {
@Bean
public ProductsApi getProductsApi(ApiClient apiClient)
{
return new ProductsApi(apiClient);
}
@Bean
public ApiClient getApiClient(RestClient restClient) {
return new ApiClient(restClient);
}
@Bean
public RestClient getRestClient() {
return RestClient.builder()
.requestFactory(customClientHttpRequestFactory())
.build();
}
private ClientHttpRequestFactory customClientHttpRequestFactory() {
Duration duration = Duration.ofSeconds(6);
ClientHttpRequestFactorySettings settings =
ClientHttpRequestFactorySettings.DEFAULTS
.withConnectTimeout(duration)
.withReadTimeout(duration);
return ClientHttpRequestFactories.get(settings);
}
}
本例演示了如何配置RestClient
以适当的超时设置来建立连接并接收数据。让我们设置这些超时,以确保我们的客户端不会等待服务器响应过长时间。
服务器配置
当服务器收到请求时,它开始处理必要的信息,这可能涉及与其他服务器的交互、执行数据库查询和执行各种计算任务。如果这些操作超过了预定义的超时阈值,服务器应执行以下操作:
终止处理:停止与请求相关的任何正在进行的操作,以释放资源
释放已分配的资源:确保内存、线程和数据库连接等资源得到适当释放,以防止泄漏和瓶颈
通知客户端:使用适当的 HTTP 错误代码
408 Request Timeout
通知客户端已发生超时,允许客户端适当地处理这种情况(例如,通过重试请求或通知用户)
在设置超时之前,观察你服务的典型响应和网络条件非常重要;这将帮助你有一个正确的概念,即什么是“正常”的预期响应时间,因为设置非常短的超时可能导致不必要的失败,而非常长的超时可能导致用户体验不佳。最佳的应用实践需要持续监控超时指标并调整客户端和服务器配置,以保持健康、有弹性和可持续的 API。
为了说明 Spring Boot 应用程序中的客户端超时设置,你可以在ClientHttpRequestFactorySettings
中配置连接超时和读取超时。这个类负责使用特定的超时参数设置 HTTP 请求工厂。这些设置随后用于创建一个RestClient
实例,这是一个用于向其他服务执行 HTTP 请求的组件。有关此配置的更详细信息,请参阅书籍源代码仓库中chapter6
文件夹中的ProductsApiConfiguration
类。
虽然最后一个示例侧重于客户端配置,但值得注意的是,Spring Boot 中的服务器端超时配置通常涉及在application.properties
或application.yml
文件中设置属性。
以下是一个示例:
server:
tomcat:
connection-timeout: 5000
这将服务器的连接超时设置为五秒。具体的属性及其效果可能因所使用的服务器(例如,Tomcat 或 Jetty)而异。
之前的示例展示了基本的客户端和服务器端超时配置。然而,在更复杂的应用中,你可能需要更高级的超时管理。本书中的示例故意设计得简单,以便专注于关键概念。对于现实世界中的应用,特别是那些涉及复杂数据库操作或微服务的应用,你可能发现更复杂的库,如 Resilience4j,非常有用。
Resilience4j 提供了一个TimeLimiter
模块,它提供了对超时的详细控制,并且与 Spring Boot 应用程序配合良好。虽然这种复杂程度对于我们的基本示例不是必需的,但随着你的应用程序变得更加复杂,理解这些高级技术可能非常有价值。以下是一个使用 Resilience4j 版本 2.2.0 在更复杂场景中管理超时的示例:
将 Resilience4j 依赖项添加到你的项目中:
<dependency> <groupId>io.github.resilience4j</groupId> <artifactId>resilience4j-spring-boot3</artifactId> <version>2.2.0</version> </dependency>
在你的
application.yml
中配置TimeLimiter
:resilience4j: timelimiter: instances: productServiceGetById: timeout-duration: 5s cancel-running-future: true
使用
TimeLimiter
实现服务:@Service public class ProductsQueryUseCaseImpl implements ProductsQueryUseCase { private final ProductsRepository productsRepository; private final PhotoProductsRepository photoProductsRepository; private final TimeLimiter timeLimiter; public ProductsQueryUseCaseImpl( ProductsRepository productsRepository, PhotoProductsRepository photoProductsRepository, TimeLimiterRegistry timeLimiterRegistry) { this.productsRepository = productsRepository; this.photoProductsRepository = photoProductsRepository; this.timeLimiter = timeLimiterRegistry.timeLimiter( "productServiceGetById"); } @Override public CompletableFuture< Product> getProductByIdAsync(String productId) { return timeLimiter.executeCompletionStage( Executors.newSingleThreadScheduledExecutor(), () -> CompletableFuture.supplyAsync(() -> getProductById(productId))) .toCompletableFuture(); }
在控制器中使用该服务:
@RestController @RequestMapping("/api/products") @Validated public class ProductsApiController implements ProductsApi { @GetMapping(value = "/{productId}") @Override public ResponseEntity<ProductOutput> getProductById( @PathVariable("productId") @ValidSku String productId) { try { final var product = productsQueryUseCase .getProductByIdAsync(productId).get(); return ResponseEntity.status(HttpStatus.OK) .body(productMapper.toProductOutput(product)); } catch (ExecutionException ex) { if (ex.getCause() instanceof TimeoutException) { throw new ResponseStatusException( HttpStatus.REQUEST_TIMEOUT, "Timeout to access the product id: [%s]".formatted(productId), ex); } throw new UnexpectedServerError( "Error to access the product.", ex); } catch (Exception ex) { throw new UnexpectedServerError( "Error to access the product.", ex); } } }
在此示例中,请注意以下内容:
我们有一个由名为
TimeLimiterRegistry
的命名实例构建的TimeLimiter
,它有一个五秒超时的配置。ProductsQueryUseCaseImpl.getProductById()
方法使用timeLimiter.executeCompetionStage()
方法,该方法将仓库调用包装在CompletableFuture
中以实现异步。在实际场景中,如果复杂的数据库查询或高服务器负载导致延迟,它将通过这个
timeLimiter
调用被检测到,抛出一个异常。
这将由控制器的try/catch
检测到,并返回超时 HTTP 代码。
这个例子展示了如何将超时应用于特定的服务方法,例如涉及数据库操作的方法。尽管它比书中的主要示例更复杂,但它演示了随着应用程序需求增长,你如何改进超时策略。
在实践中,你会根据你的具体要求选择适当的超时管理级别。你可能从更简单的方法开始,然后转向更高级的解决方案,如 Resilience4j,随着你的系统变得更加复杂。
虽然超时对于保持系统响应性至关重要,但它们通常与另一个重要的弹性模式:重试机制协同工作。当发生超时时,可能是因为可以迅速解决的暂时性问题。在这种情况下,自动重试操作可以帮助在不干预用户的情况下保持服务连续性。让我们探讨重试机制如何补充超时策略并增强整体系统弹性。
重试机制
实施重试机制是防止系统组件暂时不可用而中断整个依赖过程链的有效策略。虽然重试可以减轻暂时性故障,但过度或不适当的重试可能会加剧问题,导致资源耗尽和性能下降。我们将在这里介绍如何正确处理这种情况。
一个 API 可能无法完成一个过程周期,影响请求内的各种任务的原因不计其数。以下是一些你可以通过重试来处理的案例:
超时
5xx 服务器错误(例如,
500 内部服务器错误
或503 服务不可用
)速率限制(例如,
429 请求过多
)
考虑一个电子商务平台,验证产品的库存数量是完成销售的关键步骤。假设产品库存验证 API 通常在 2 到 5 秒内响应。如果这个 API 出现暂时性延迟或故障,实施重试机制以确保交易仍然可以继续,而无需不必要的放弃。以下是一个例子:
初始请求:系统请求库存数量
首次失败:API 在预期时间内没有响应
重试尝试:系统在短暂延迟后重试请求
成功响应:在第二次尝试中,API 成功响应,允许销售继续进行
这种方法最小化了由于临时 API 不可用而丢失交易的风险。然而,如果产品库存 API 出现故障且恢复时间较长,重试可能会变得有问题。在没有与其他参与交易的系统通信的情况下不断重试可能会导致糟糕的客户体验,并给公司造成重大损失。
为了避免陷入我们提到的陷阱,在实现重试策略时,你需要考虑一些最佳实践:
设置最大重试限制
使用带抖动的指数退避
保持请求幂等
实现超时机制
记录重试尝试
设置最大重试限制
没有最大重试限制,你的系统可能会陷入尝试重发失败请求的无尽循环。想象一下服务器长时间宕机的情况。如果没有重试限制,系统将永远尝试重新发送请求,浪费资源并可能引起其他问题。
使用带抖动的指数退避
指数退避是在失败后管理重试尝试时间的方法。不是试图在固定间隔内重新发送消息,而是在每次尝试重新发送失败时等待更长的时间。等待时间呈指数增长,这意味着增长很快。
让我们看看一个例子:
第一次尝试:如果发送消息失败,在再次尝试之前等待一段时间(例如,一秒)。
第二次尝试:如果再次失败,等待时间加倍(例如,两秒)。
第三次尝试:将等待时间加倍,即四秒。
继续依赖于尝试次数的限制:在每次新的重试中,等待时间加倍(8 秒、16 秒等)。
这种方法有助于防止系统过于频繁地尝试发送消息,这可能会引起更多问题,例如服务器或网络过载。
抖动
抖动是用于重试失败请求的时间间隔的随机时间。我们不是让重试机制在固定时间或指数增长的情况下进行所有调用,而是为每次新的重试提供一个随机时间。例如,不是精确等待两秒,它可能在 1 到 3 秒之间等待。
结合指数退避和抖动
仅使用指数退避仍然可能引起问题。如果许多设备或系统试图同时重新发送消息,它们可能会等待相同的时间然后一起重试,从而产生突然的交通高峰。这可能会使网络或服务器更加不堪重负,这就是为什么将指数退避和抖动结合起来是最佳实践的原因。
添加抖动有助于分散重试尝试。每个设备等待的时间略有不同,减少了它们同时发送请求的可能性。这使得系统更加稳定,并增加了成功发送消息的机会。
保持请求幂等
在重试 API 调用时拥有幂等请求对于构建弹性和可靠的系统至关重要。它确保多次尝试执行相同操作不会导致意外的后果,例如重复操作或不一致的数据。
实现超时机制
您已经了解从上一个主题中超时的重要性。重试涉及向服务发送新的请求,而在采取下一步行动之前管理我们等待的时间是至关重要的。
记录重试尝试
记录重试尝试中的每次失败将帮助我们了解重复出现的模式或导致重试的频繁问题,并将简化系统故障排除。
一个良好的重试机制通过优雅地处理瞬态故障,显著增强了 API 的弹性。通过遵循最佳实践——例如限制重试尝试、采用指数退避、保证幂等重试和区分错误类型——开发者可以确保重试有助于系统稳定性,而不会引入新的漏洞。周到的重试策略有助于提升用户体验、提高系统可靠性和持续的操作效率。
尽管重试机制有助于处理暂时性故障,但无限制的重试可能会压倒您的系统。这引出了另一个关键的弹性模式:速率限制。通过控制请求的频率和数量,速率限制有助于在高负载情况下保持系统稳定性。
速率限制
速率限制是用于保护 API 免受滥用和过度资源访问的关键策略。通过限制 API 在指定时间段内从用户(用户、IP、应用、令牌等)处可以处理的请求数量,确保 API 系统的稳定性、可靠性和安全性。这种方法有助于防止拒绝服务(DoS)攻击,并确保服务在客户端之间公平分配,防止资源耗尽。
我们可以在不同的上下文级别上应用速率限制;它可以基于以下内容:
全局级别:在此上下文中,我们可以通过时间段限制 API 可以处理的总请求数量
IP 级别:通过 IP 地址,我们根据客户端的 IP 地址限制请求数量
用户级别:基于单个用户或客户端限制请求
应用级别:基于特定应用使用 API 的限制
在以下章节中,我们将探讨实现速率限制的一些方面,例如配额管理、时间窗口和速率限制头。
速率限制关键方面
为了有效地管理速率限制,了解其核心组件及其如何对整体 API 弹性做出贡献是至关重要的。速率限制不仅仅是限制请求;它关乎公平、透明和可扩展地控制访问,确保所有用户都能获得稳定的体验,同时保护资源。
在本节中,我们将探讨速率限制的三个关键方面:
配额管理:定义了在给定时间段内每个客户端允许的最大请求数量(例如,每小时 1,500 个请求)。
时间窗口:请求计数测量的时间段。常见的间隔包括每秒、每分钟、每小时或每天。
速率限制头:API 通常在响应中包含头信息,以告知客户端其当前的使用情况和剩余配额。以下是一些示例:
X-Rate-Limit-Limit
:允许的最大请求数量X-Rate-Limit-Remaining
:当前窗口中剩余的请求数量X-Rate-Limit-Reset
:速率限制重置的时间
当达到速率限制时提供适当的 HTTP 响应代码:根据 RFC 6585 的建议,建议返回
429 Too Many Requests
错误。
现在您已经了解了这些方面及其含义,让我们来谈谈在您的 API 上实现速率限制的不同方法。
速率限制实现策略
实施速率限制需要仔细考虑各种策略,每种策略都有其优势和权衡:
固定窗口:这种策略将时间分割成固定的时间间隔(窗口),例如一分钟。每个新窗口的开始重置请求数量。这是最简单的实现方式,尽管它可能容易在窗口边界发生突发(例如,客户端可能在短时间内发送双倍允许的请求,并有多条请求被拒绝)。根据应用的不同,可能可以采用这种简单的策略来实现,并偶尔出现故障。
滑动窗口:与固定窗口不同,滑动窗口持续移动。对于每个请求,系统都会检查过去定义时间段内的请求数量,以重新调整自身。这将导致允许的请求数量在时间上的分布更加平滑,减少过载时段和没有任何请求时段的可能性。它具有更复杂的实现方式,但能精确控制请求速率。
令牌桶:一个桶包含代表允许请求数量的令牌。根据配置时间内的允许请求数量,令牌被添加,以填充桶。每个请求消耗一个令牌。如果没有可用的令牌,则拒绝请求。它具有稍微复杂一些的逻辑,但将支持需要处理偶尔峰值而不影响整体速率限制的 API。
漏桶:这种策略类似于令牌桶,但以固定速率处理请求,在桶限制达到标记时确定是否接受新请求。如果队列/桶已满,额外的请求将被拒绝。每次从队列中处理请求时,它都会为新到达的请求腾出空间。这可能导致由于排队而增加延迟。然而,它将确保稳定的处理速率。这对于需要保持一致处理速率的系统来说是有利的。
速率限制实现将保护 API 免受恶意行为者通过压倒 API 而滥用,支持资源管理,确保资源在客户端之间公平分配,并通过控制流量来保持 API 的响应性。
虽然速率限制侧重于限制请求总数,但与之相关但不同的概念是节流。理解这两种方法之间的区别对于实施有效的请求管理策略至关重要。
网关级速率限制
虽然在单个 API 服务内部实施速率限制提供了良好的保护,但在网关级别实施速率限制为经验丰富的开发者提供了重要的好处,这些好处应考虑用于生产系统。
单点控制
API 网关是所有客户端请求的主要入口点。这使得它们成为设置速率限制规则的完美位置。您无需单独为每个服务添加速率限制代码,而可以从一个中央位置管理所有策略。这种方法减少了复杂性,并使整个系统的维护变得更加容易。
提前停止请求
网关级速率限制在请求到达您的应用程序服务之前阻止过多的请求。当客户端发送过多的请求时,网关立即返回 429 错误代码,而不使用任何后端资源,例如数据库连接或服务器处理能力。这种早期阻止在流量高峰期间保护了您的系统,并节省了计算资源。
系统级速率限制
在客户端使用多个服务的复杂系统中,网关速率限制允许您在整个 API 上设置限制。例如,您可以将用户的请求限制在每小时 5,000 次,而不仅仅是单个服务。这防止了客户端通过在不同端点分散请求来规避限制。
实现选项
流行网关工具,如 Kong、AWS API Gateway 和 Nginx,提供内置的速率限制功能。这些工具支持不同的策略,如令牌桶和滑动窗口方法。当使用多个网关服务器时,请考虑使用如 Redis 之类的共享存储来准确跟踪所有服务器上的请求计数。
网关级别的速率限制在结合服务级别的速率限制时效果最佳。使用网关进行一般限制和整体系统保护,同时将特定业务规则和详细限制保留在各个服务中。
节流
节流,另一方面,管理处理请求的速率。不是限制总请求数量,节流确保请求以稳定的速度处理。这通常通过排队或延迟请求来保持系统稳定。
对于实时数据,API 限制每个用户可以请求的频率,以防止服务器过载。在这种情况下,每个用户每秒只能发送一个请求。如果用户发送请求的速度超过这个速度,节流将减慢他们的请求,但不会完全阻止。
这就是它的工作原理:
用户 X 在一秒内发送了三个请求。
API 立即处理第一个请求。
API 延迟第二次和第三次请求,确保每个请求间隔一秒。
节流控制请求的节奏,允许服务器处理高频请求而不会超负荷。
那么,速率限制和节流之间的区别是什么?
速率限制:控制一段时间内的总请求数量(例如,每小时 500 个请求)
节流:控制请求的速度,如果请求来得太快(例如,每秒一个请求)就会减慢。
结合使用两者确保 API 在高需求下仍然保持响应和公平。
节流实现策略
以下是一些节流实现策略:
动态节流:根据实时系统性能和负载调整限制。这种实现方式对变化条件反应更灵敏,尽管它需要复杂的监控和调整机制。
基于优先级的节流:为不同类型的请求或客户端分配优先级。这样,它确保关键操作可以访问必要的资源;然而,这种方法管理起来比较复杂。
优雅降级:在高负载下以受控方式减少功能或性能。在高峰使用期间,此策略保持一定水平的服务。这种策略可能以牺牲一些不良用户体验为代价。
断路器模式:暂时停止某些特定路由的请求流动,以防止系统过载并允许恢复。它将保护系统免受级联故障的影响。作为副作用,可能需要仔细配置以避免不必要的关闭。
节流有助于确保 API 的可靠性,在变化条件下保持稳定性。它根据当前需求高效分配资源,防止减速和中断,并保持服务的连续性。
许多大型 API 提供者都采用这种策略,例如微软的 GitHub API,它有一个基于不同级别的详细速率限制系统。级别由你同时发出的请求数量决定。
为了简化问题,让我们假设我们处于最基本级别。在这种情况下,速率限制取决于我们的身份验证。例如,未经身份验证的请求可以访问公共数据,有它们特定的限制。另一方面,经过身份验证的请求有不同的速率限制(更大),这取决于身份验证的类型,例如使用令牌或 OAuth。
这并不意味着你需要实现如此复杂的东西。这是一个如何根据实现级别帮助管理 API 需求的示例。这完全取决于你的需求和创造力。
一个相当简单的例子,你可以在 API 提供者的市场上广泛看到速率限制的使用,就是限制免费用户计划的用量,例如,你可以在不同的 Google API 上看到。
速率限制和节流是 API 管理的基本机制,有助于维护服务的完整性和可靠性。速率限制侧重于控制特定时间内每个客户端的请求数量,而节流则采用策略来管理整体流量和系统性能。
对于 API 提供者来说,实施有效的速率限制和节流可以确保公平使用,防止滥用,并保持最佳性能。对于 API 消费者来说,理解这些机制可以设计出具有弹性的应用程序,这些应用程序可以与 API 顺畅交互,优雅地处理限制,并提供无缝的用户体验。
通过节流管理请求流非常重要,但确保请求一致性同样关键。这正是幂等性发挥作用的地方,尤其是在处理重试请求时,这些请求可能已经成功但未能传达其成功信息。
幂等性键
要理解为什么幂等性键是必要的,首先理解幂等性的概念是至关重要的。如果你不记得第二章中的内容,现在是一个回顾该部分的好时机。简单来说,幂等性意味着使用相同的参数多次调用 API,同时确保状态保持不变,并且不会发生意外的副作用。
根据幂等性键 RFC(目前尚未定义一个具体的数字)关于幂等性键头的内容,“HTTP 幂等性键请求头字段可以用来携带幂等性键,以便使非幂等 HTTP 方法,如POST
或PATCH
具有容错性。”
想象以下场景:我们有一个前端向我们的管理订单 API 发送订单,这发生在用户点击保存订单按钮时;这将触发代码,执行带有创建新订单所需所有数据的 POST
调用。让我们假设由于网络不稳定,用户没有像往常一样快速地在屏幕上看到结果,而是多次点击这个按钮。这意味着我们将接收到包含相同数据的多个 POST
调用。作为一个不希望出现的副作用,我们会重复相同的订单,这会导致我们系统的不一致性。
如果我们的前端在每次调用时都添加一个头信息 "Idempotency-Key"
,一个满足其值只有在且仅当它来自同一请求(订单)时才相同的业务要求的键,它将防止这种不希望出现的副作用,即多个错误记录存储在管理 API 上,因为 "Idempotency-Key"
将作为唯一键来避免这些重复。
这里是一个需要使用幂等性键的 API 调用的示例:
curl https://api.service.com/v1/orders \
-u Ao24N7La2PDTkdtegf553lJI: \
-H "Idempotency-Key: AF1GvyNVeriLWuDU" \
-d description="My Order Test"
在前面的例子中,我们有 "Idempotency-Key"
头信息,并且这个头信息是由请求客户端发送的。Stripe 文档建议使用 V4 UUIDs 或其他具有足够熵的随机字符串来生成键,以避免冲突。通过这样做,我们可以保证即使对于非幂等方法,也能保证幂等性。
总结来说,使用幂等性键对于维护 API 的可靠性和一致性至关重要,尤其是在处理非幂等请求,如 POST
或 PATCH
时。通过为每个请求分配一个唯一键,开发者可以防止重复操作,例如无意中创建多个订单。这种方法确保即使由于网络问题或重复点击,用户多次发送相同的请求,系统仍然保持稳定和准确。实现幂等性键不仅保护了数据的完整性,还通过避免错误和不一致性来提升用户体验。遵循最佳实践,如生成唯一且安全的键,进一步增强了 API 的弹性和容错能力。
UUID V4
UUID V4 是一个用于在计算机系统中唯一标识信息的 128 位值。它是随机生成的,并遵循特定的格式以确保唯一性。与其他版本不同,V4 UUID 依赖于随机数,这使得生成重复的可能性非常低。这种类型的标识符通常在需要唯一引用时使用,例如在数据库条目或 API 请求中,以确保数据一致性和防止冲突。
当幂等性确保请求一致性时,它并没有解决处理失败的下游服务的更广泛挑战。断路器模式通过防止分布式系统中的级联故障提供了一个解决方案。
断路器
受电气断路器的启发,电气断路器可以保护电气系统免受过载或短路造成的损坏,软件中的断路器模式工作原理类似。它通过停止重复尝试失败的操作直到系统恢复,帮助防止系统故障。就像在电气电路中一样,当开关关闭时,能量通过,但打开开关会中断流动。
当检测到一定数量或百分比的失败时,该模式会打开故障电路或组件,并将所有请求重定向到回退例程。经过一段时间后,它再次尝试,如果并非所有请求都成功,那么它假定半开状态。然后,根据后续请求的稳定性,它可以打开或关闭,依此类推。
一些框架也允许你忽略某些类型的失败(Java 异常)并只计算特定的失败,这提供了更多控制系统如何响应以避免问题的能力。根据你选择的库或框架,断路器配置可以有一系列广泛的参数来定制断路器需要如何表现。在下图中,我们展示了工作流程和 Resilience4j 断路器配置的示例。
图 6.1 – 断路器流程
下面的示例展示了使用 Resilience4j 的实现:
CircuitBreakerRegistry circuitBreakerRegistry = CircuitBreakerRegistry.ofDefaults();
// Configure the CircuitBreaker
CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.ofDefaults()
.failureRateThreshold(50) // 50% failure rate
.waitDurationInOpenState(Duration.ofSeconds(10))
.slowCallDurationThreshold(Duration.ofSeconds(5));
// Create a CircuitBreaker instance
CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(
"circuitService", circuitBreakerConfig);
// Use the CircuitBreaker to protect a downstream service call
try {
circuitBreaker.decorateSupplier(() -> downstreamService.call()).get();
} catch (CircuitBreakerOpenException e) {
// Fallback or other suitable action
}
此代码设置了一个 Resilience4j 断路器来监控下游服务调用。它执行以下操作:
在服务失败超过 50%时打开
在允许测试请求之前保持开启状态 10 秒
将超过五秒的调用视为失败
通过使用断路器模式,API 变得更加健壮,因为它限制了重复调用失败的服务,并在服务稳定后恢复。
在前面的例子中,我们看到了断路器如何帮助管理下游服务的故障,通过隔离和处理服务中断来保护 API。然而,在 API 服务的每个服务中实现断路器可能导致代码重复和维护挑战,尤其是在具有多个服务的较大系统中。
让我们进一步探讨这一点,通过了解在系统网关级别实现断路器的不同方式。
在网关中实现的断路器
网关作为管理不同服务或系统之间通信的中心控制点。这使得它成为实现断路器、速率限制和文件上传验证等模式的理想位置。通过在网关级别整合这些机制,操作问题与业务逻辑分离,允许隔离并促进系统配置的标准化,所有这些都通过网关进行管理。
在网关上实现断路器的以下是一些好处:
集中控制:一个单独的断路器可以保护多个下游服务,简化管理和监控
提高弹性:通过隔离失败的服务,断路器可以防止级联故障并保持整体系统稳定性
降低延迟:当一个服务被认为不健康时,断路器可以快速返回回退响应,减少客户端的延迟
增强可靠性:断路器可以帮助确保即使在临时故障的情况下,关键服务仍然可用
断路器模式是构建可靠和稳定 API 的关键工具,特别是那些依赖于多个服务或外部 API 的 API。你应该考虑在几个关键情况下使用此模式来增强弹性和稳定性。
首先,在构建分布式系统或微服务时,应用程序的不同部分通过 API 相互通信。在这种配置中,如果一个服务变得缓慢或失败,它可能会对整个系统产生负面影响。通过实现断路器,你可以监控这些 API 调用,并暂时停止与失败服务的通信尝试。这防止了一个服务的问题导致多米诺效应,确保系统的其他部分继续平稳运行。
另一个重要场景是当你的应用程序依赖于外部服务时。许多应用程序依赖于第三方 API,例如支付网关、社交媒体平台或数据提供商。这些外部服务有时可能会因为维护或高流量等原因出现停机或缓慢响应。断路器通过早期检测这些故障并避免等待可能永远不会到来的响应,从而提供帮助。相反,你的应用程序可以提供回退选项,如默认消息或替代操作,即使在外部服务不可靠的情况下也能保持良好的用户体验。
为了防止事务失败,系统通常会缓存频繁计算的价值,如配送税费,这可能依赖于外部服务。在需要快速响应的情况下,缓存值可以作为回退选项,避免丢失交易。断路器模式可以有效地用于管理这些场景。在电路打开的部分恢复正常状态之前,推广回退解决方案。
断路器通过停止重复尝试失败操作来帮助预防系统故障。然而,有时我们需要隔离系统的不同部分以防止资源耗尽。这就是舱壁模式变得有价值的地方。
舱壁
想象一下,你有一个系统,其中一个小部分失败了,但你不想因为这个原因而让整个系统崩溃。这就是舱壁模式发挥作用的地方。让我们探讨一下这个模式如何帮助提高容错能力并保持系统平稳运行。
舱壁模式灵感来源于船只。船只上有舱壁,这些舱壁将船分成几个独立的区域。如果一个区域进水,舱壁可以阻止水蔓延到船的其他部分。这可以防止船只完全沉没。
在软件中,舱壁模式通过将应用程序划分为独立操作的不同部分或服务而以类似的方式工作。如果一个部分失败或变慢,其他部分将继续无问题地运行。这种隔离确保了一个区域的问题不会影响整个系统。
如前所述,舱壁模式是一种结构设计模式,用于将系统划分为隔离的单元或模块。每个模块可以独立处理其自身的负载和故障,确保一个模块的问题不会级联到其他模块。这种隔离可以在多个层面上应用,例如以下所述:
服务级别:在架构中分离不同的微服务
资源级别:为不同的组件分配独立的线程池、内存或数据库连接
功能级别:在服务内划分功能,以防止一个失败的功能影响其他功能
该模式的关键特性包括以下内容:
隔离:确保一个隔间中的故障不会影响其他隔间
资源分配:为每个隔间分配单独的资源,以防止资源耗尽影响整个系统
容错性:增强系统处理部分故障的优雅性
使用舱壁模式的一个常见例子是管理系统中的“热点路径”。热点路径是系统接收比其他部分更多流量的部分。如果一个热点路径接收过多的请求,它可能会超载该系统部分,导致其变慢或崩溃。这可能会使整个系统无响应,影响未使用热点路径的用户。
为了理解舱壁模式如何增强弹性,我们将深入研究这种方法可以有效地应用的不同领域。通过隔离组件,我们可以防止系统一个部分中的故障影响整个应用程序。这种灵活性使我们能够在服务、资源和功能等多个层面上应用舱壁模式,使其成为容错性的多功能解决方案。
让我们探讨一些在不同系统架构和不同层面上实现舱壁的实际方法,从单体系统到微服务,以及通过数据库和队列管理。
在不同架构中实现舱壁
无论您是使用单体架构还是微服务,都可以应用舱壁模式:
单体系统:即使您的系统是一个单一的应用程序,您也可以通过将大量流量引导到特定实例来创建隔离。例如,您可以有一些实例处理热点路径,而其他实例管理系统的不同部分。这样,如果热点路径过载,系统的其他部分就不会受到影响。
微服务:在微服务架构中,每个服务都可以作为一个独立的隔离舱。如果一个服务失败,其他服务可以继续正常运作。
通过数据库隔离实现隔离舱模式
实现隔离舱模式的另一种方式是通过隔离数据库。您不必使用处理所有请求的单个数据库,可以为系统的一部分创建单独的数据库实例。例如,高流量功能可以有自己的数据库,防止它压倒主数据库并影响其他功能。
通过队列管理实现隔离舱模式
您还可以通过创建具有不同优先级的不同队列来对消息队列应用隔离。高优先级队列可以独立监控和扩展,以确保它们快速处理消息,而标准队列则正常运作。这可以防止一个队列的激增影响其他队列。
外部服务和节流
当您的系统调用外部服务时,延迟和超时可能会引起问题。通过使用隔离舱模式,您可以限制外部请求的数量,确保第三方服务的缓慢响应不会压倒您的系统。在您的代码中实现节流可以帮助有效地管理这些请求的流动。
使用隔离舱模式可以提高您系统的弹性和可用性。通过隔离不同的部分,它使系统更容易管理。然而,添加隔离舱也引入了一些复杂性。您需要仔细设计系统,以确保增加的复杂性是由改进的容错能力所证明的。
隔离舱弹性模式是构建健壮和可靠系统的一种强大技术。通过在您的架构的不同部分之间创建隔离,您可以防止小故障级联并影响整个系统。无论您是在处理单体应用还是微服务,隔离舱模式都可以增强您系统的稳定性和性能。
隔离舱模式是一种强大的架构方法,用于设计和构建 API。它有助于使系统更具弹性、可扩展性,并能够处理故障。将系统的不同部分分离出来可以限制故障的范围,高效使用资源,并确保即使某些部分出现故障,系统也能保持强大。然而,权衡这些好处与它可能带来的额外复杂性和资源使用量是很重要的。周密的规划、合理管理资源以及持续监控是成功在 API 设计中使用隔离舱模式的关键。
摘要
本章涵盖了构建强大的 RESTful API 的两个重要领域:数据处理和弹性。
数据处理部分专注于高效管理大量数据。关键技术包括分页,它将数据分成更小的页面以提高性能,以及过滤,允许客户端仅请求他们需要的数据。本章还解释了如何安全地处理文件上传和下载,确保 API 能够管理大文件而不会减慢速度。此外,我们介绍了 HATEOAS,它向 API 响应中添加导航链接,使客户端与 API 交互更加容易。
弹性部分展示了我们如何在各种条件下保持 API 的稳定性和可靠性。重要的策略包括设置超时以避免长时间等待,使用重试机制来处理暂时性故障,以及实施速率限制以防止滥用并管理流量。节流有助于控制请求流,而幂等性键确保重复请求不会导致错误。高级模式,如断路器,停止失败的请求以保护系统,而隔离舱将 API 的不同部分隔离开来,以防止故障扩散。通过应用这些弹性技术,API 即使在意外挑战下也能保持高性能和可靠性。
总的来说,通过关注有效的数据处理和强大的弹性策略,开发者可以创建既高效又可靠的 API,能够支持不断增长的应用程序并提供一致的用户体验。
现在你已经掌握了高级 API 概念和实现,接下来的旅程将专注于在生产环境中确保、测试和部署你的 API。第七章将指导你如何保护你的 RESTful API 免受常见漏洞和攻击,教你实现 HTTPS 加密、适当的身份验证和授权机制、JWT 令牌,以及防止 OWASP 漏洞和 CORS 问题。第八章将重点转向全面的测试策略,涵盖从 Spring MVC 单元测试到集成测试、GenAI 辅助测试创建以及合同测试,以确保 API 的可靠性。
这些即将到来的章节将把你的 API 从功能原型转变为安全、经过充分测试的服务,为生产部署做好准备。到第二部分结束时,你将具备构建不仅性能良好而且能够保护用户数据并在各种条件下保持可靠性的 API 的知识,为你准备本书的最后一部分,该部分涵盖部署和性能优化。
第七章:保护你的 RESTful API
欢迎来到本书的这一部分,它讨论的是软件安全,这是在更广泛的网络安全领域内,而你作为软件工程师,对此负有最大的责任。这可能与你所读过的内容有些不同,因为人们并不总是将其视为软件工程师的核心责任。这是一个令人遗憾的误解。随着我们社会的数字化,软件安全不再是网络安全专家的专属领域;它是软件开发的一个组成部分,并且应该在开发的各个层面都是一个主要关注点。
理解安全性可能很复杂,所以让我们从一个心理图像开始。隐喻有助于简化抽象概念,而在网络安全中最常见的隐喻之一就是洋葱。如果你之前阅读过关于安全性的内容,你很可能遇到过这个想法:安全性是分层的,就像洋葱一样,每一层都增加了一道防护屏障。
虽然这个隐喻很有用,但它也有其缺陷。它暗示每一层都是统一的,而实际上,网络安全防御差异很大。一个更准确的比较是受到围攻的堡垒,其中不同的防御层对不同的威胁做出反应。远程大炮针对远距离的攻击者,墙壁阻挡入侵者,而肉搏战是最后的防线。同样,在安全性中,每一层都发挥着独特的作用,使用不同的工具和技术来对抗特定的风险。
但这对你们这些软件工程师来说意味着什么呢?安全性不仅仅是保护系统——它是保护用户数据、确保可靠性和防止代价高昂的安全漏洞,这些漏洞可能会损害公司的声誉和底线。你的代码中的漏洞可能被利用来窃取敏感信息、中断服务,甚至破坏整个网络。作为软件工程师,我们是第一道防线。编写安全代码、执行适当的访问控制和了解常见的攻击向量不是可选技能——这是基本责任。在本章中,我们将探讨你负责的安全层以及如何有效地将安全性集成到你的开发过程中。
本章将涵盖以下主题:
HTTP API 调用的解剖结构
认证
OWASP API 安全性 Top 10 概述
理解常见漏洞和暴露
管理 CVE 的策略
HTTP API 调用的解剖结构
今天,几乎所有 API 调用都是通过 HTTP 进行的。那么,让我们看看在这样一个调用中会发生什么,以及参与其中的许多组件。
图 7.1 – 通过 HTTP 进行典型安全 API 调用
加密通信
尽管这个协议被称为 HTTP,我们通常说它是一个 HTTP 调用或 HTTP 连接,但事实是,所有现代 API 调用都是通过 HTTPS 进行的。
结尾的“ S ”代表“ secure ”,表示 HTTP 通信使用传输层安全(TLS)进行加密。TLS 在传输层(OSI 模型的第 4 层)运行,加密客户端和服务器之间的数据,以防止窃听和篡改。在 HTTP 调用中使用 TLS 确保只有预期的接收者才能正确读取消息,实际上消除了中间人攻击,因为即使消息被截获,也无法解密和读取。
因此,HTTPS 调用将强制执行以下操作:
数据机密性:所有通过网络传输的数据都是加密的,没有正确的密钥集,数据将没有意义
数据完整性:确保数据在传输过程中未被更改
服务器身份验证:验证服务器对客户端的身份
HTTPS(HTTP over TLS)通信的所有三个功能都是通过使用加密算法来执行加密、创建传输数据的数字签名和安全的摘要(散列)来实现的。加密算法的使用方式以及它们的输入和输出如何在网络上传输是由所使用的特定 TLS 协议版本指定的。
根据当前的科学技术知识和计算能力,过去使用的某些算法和协议被认为是脆弱的。被认为安全的算法集不断演变。今天已经有一些算法,预计在达到量子计算时代时仍将保持安全。
在实现 API 的 HTTPS 时,始终使用强大的加密算法和协议。避免使用已弃用的协议,如 SSL 和旧版本的 TLS。加密算法不断演变,旧算法由于容易被破解而变得过时。创建并执行一个政策,定期审查您的加密选择并更新您的 TLS 证书。我们将在稍后讨论这个问题。
在现代组织(中大型及以上),软件工程师不太可能负责任何 TLS 实现,但了解 HTTPS 的工作原理及其影响是很重要的。
HTTPS 通信中使用的必要元素是公钥加密。公钥私钥对中的公钥部分是 TLS 证书的一部分。TLS 证书从颁发到过期或失效都有一个生命周期。因此,我们需要管理这些证书。
管理您的 HTTPS 证书
使用 HTTPS/TLS 加密的一个后果是需要进行证书管理。管理安全证书是任何基于 API 的应用程序的一个复杂且关键的部分。有效的证书管理涉及处理续订和到期,并确保证书配置正确,以防止服务中断。此外,用于证书生命周期管理的自动化工具可以帮助简化合规性,特别是在 API 或外部服务之间频繁通信的环境中。这是一个如此重大的问题,以至于许多团体都试图创建机制来减轻它。Let’s Encrypt(letsencrypt.org/
)取得了巨大的成功,甚至在一些大型企业中也被使用。大多数云服务提供商也提供某种形式的自动证书管理,例如 AWS 证书管理器(aws.amazon.com/certificate-manager/
)。
第一道防线——Web 应用防火墙(WAF)
WAF(Web 应用防火墙)在客户端和服务器之间充当屏障,过滤掉恶意流量,并提供针对常见网络攻击(如 SQL 注入和跨站脚本攻击(XSS))的第一层保护。这通常不由软件工程师控制,但了解它很重要,因为 WAF 可能会对 HTTP 流量造成一些干扰。
这些是 WAF 的功能:
流量监控:检查可疑模式
基于规则的过滤:根据预定义的安全规则阻止流量
异常检测:识别可能表明攻击的不寻常行为
最佳实践
定期更新 WAF 规则对于确保其能够适应新的和新兴的威胁至关重要。此外,根据应用程序的具体需求和架构定制这些规则也很重要,以确保最佳保护。持续监控 WAF 日志可以提供有价值的见解,有助于识别模式、异常或潜在的攻击,从而实现主动防御。
API 网关
API 网关通常是所有客户端请求的入口点,处理诸如请求路由、组合和协议转换等任务。在安全方面,API 网关经常执行许多重要任务:
TLS 终止:解密传入的 HTTPS 流量
身份验证:验证凭证和权限
速率限制和节流:控制请求数量以防止滥用
好处
API 网关集中管理安全策略,允许对所有服务的一致执行。它通过提供一个单一入口点来简化客户端与 API 的交互,该入口点管理诸如路由和身份验证等任务。此外,它通过启用缓存和负载均衡来提高性能,确保高效资源利用和改进响应时间。
负载均衡器
负载均衡器通过将进入的网络流量分配到多个服务器上来确保可用性和可靠性。虽然它们不直接参与安全逻辑,但以下方式有助于整体安全:
健康监控:从池中移除不健康的服务器。
TLS 终止:如果 API 网关不存在,处理 TLS 加密以减少服务器负载和系统复杂性。
缓解拒绝服务(DoS)攻击:通过分配负载有助于缓解 DoS 攻击。这通常是 DoS 攻击的最后一招。预计 WAF 和 API 网关会在这个阶段最大限度地减少攻击的影响。
解密通话的时机——TLS 终止
如果一个安全通话被加密,它需要在某个时刻被解密,以便其内容可以被解释和处理。这被称为TLS 终止。它指的是解密 HTTPS 连接并将其转换回明文 HTTP 通信的过程。这通常发生在网络的边缘,例如在 API 网关、负载均衡器或反向代理服务器内部。通过在单个点终止 TLS 加密,组织可以将与加密和解密相关的计算开销从后端服务器卸载。这个过程通常在 API 调用的生命周期中只发生一次,确保数据在穿越不安全网络时保持加密状态,但可以在受信任的内部网络中更有效地处理。TLS 终止提高了性能,简化了证书管理,同时不会损害通过公共通道传输的数据的安全性。了解 TLS 终止发生的时间很重要,尤其是在调试 API 时遇到一些问题时。
我们迄今为止简要讨论的所有安全元素无疑是至关重要的,但在现代中型和大型组织中,它们通常不是软件工程师的直接责任。其他专业人员负责它们,因此它们不是本章的重点。相比之下,以下安全元素在很大程度上是软件工程师的责任,我们都应该对它们有清晰的理解。
正如我们讨论的那样,确保 API 通信的安全性是一个多层次的流程,涉及各种工具和技术,每个层次都在保护传输中的数据方面发挥着独特的作用。从 HTTPS 和 TLS 提供的加密到 WAF 的过滤和监控能力,每一层都对抵御网络威胁的强大防御做出了贡献。TLS 终止和证书管理进一步强调了在现代 API 生态系统中维护性能和安全的必要性。
虽然这些责任通常由大型组织中的专门团队负责,但对于软件工程师来说,了解这些组件的工作原理及其影响是至关重要的。拥有这些基础知识可以更好地与安全团队和 DevOps 工程师合作,有效地解决出现的问题,并在设计和开发过程中做出更好的决策。随着安全威胁的不断演变,保持对这些实践的认识确保 API 保持弹性和可靠性,为任何应用程序形成一个安全的骨干。
认证
认证是验证试图访问您的 API 的用户、设备或其他实体的身份的基础过程。认证是安全性的关键要素,因为它确保只有合法和公认的各方才能与您的服务交互。如果没有适当的认证机制,API 就会容易受到未经授权的访问、数据泄露和可能损害系统完整性和用户数据的恶意活动的威胁。
认证的主要目标是确立请求访问的客户端的身份。通过确认这一点,API 可以实施访问控制、应用速率限制,并在适当的情况下提供个性化体验。此外,强大的认证机制对于遵守法律和监管要求至关重要,例如 GDPR、HIPAA、LGPD 和其他数据保护法。
可以根据所需的安全级别、应用程序的性质和用户体验考虑因素采用不同的认证方法。接下来,我们将分解一些最常见的方法。
基于密码的认证
基于密码的认证是最传统的,仍然是认证方法中最广泛使用的一种。用户提供用户名和密码,然后这些信息与服务器上存储的凭证进行验证。根据其本质,这种方法主要用于个人与系统进行认证时。几乎从未在任何形式的机器到机器交互中使用。
工作原理
当用户尝试登录时,他们会将他们的凭证,通常是用户名和密码,发送到安全服务 API。在收到凭证后,服务器将密码进行散列,并将其与数据库中存储的散列进行比较。如果凭证匹配,用户就成功认证,服务器生成一个认证令牌,例如会话 cookie 或JSON Web Token(JWT),以方便未来的请求而无需重复提交凭证。
安全考虑
密码永远不应该以明文形式存储;相反,它们必须始终使用安全的算法进行散列。散列过程的效果取决于所使用的算法。两种现代且广泛使用的算法是Bcrypt和Argon2。关于散列算法的深入讨论不在此章的范围之内,但强烈建议查看它们是如何工作的。
另一个需要考虑的是暴力攻击。在一定的失败登录尝试次数后,实施账户锁定机制很重要。此外,应要求用户通过强制执行包括最小长度要求、使用特殊字符以及避免常见或容易被猜到的单词的密码策略来创建强密码。
这里是基于密码的身份验证的优点:
实现简单
用户容易理解
这里是 JWT 的缺点:
密码很容易被盗或猜测,尤其是如果它们很弱的话
它需要强大的安全措施,如速率限制和密码重置
在不同服务之间重复使用密码会使账户更加脆弱
基于令牌的身份验证
基于令牌的身份验证依赖于使用令牌——客户端和服务器之间传递的小数据片段,以验证身份。广泛采用的令牌格式是 JWT,这是我们将会讨论的格式。
理解 JWT 的工作原理很重要,特别是了解令牌的有效期。
JWT 的生命周期和利用
用户使用其凭据登录,服务器生成一个包含有关用户编码信息的令牌(通常是 JWT)。
服务器将创建并签名 JWT,确保其完整性,并将其发送回客户端。签名和创建 JWT 的内部机制超出了本书的范围。有关更完整的讨论,请参阅这篇文章:www.freecodecamp.org/news/how-to-sign-and-validate-json-web-tokens/
。
从那一刻起,客户端将把 JWT 包含在所有未来的 API 请求的头部中,通常是在 API 开发团队定义的预定义头部中。
服务器在每次请求时验证令牌,无需再次检查用户凭据。
一个重要的理解点是,所有令牌(JWT 或其他)都必须是临时的。它们必须在明确和定义良好的间隔内过期。作为 API 层工作的软件工程师,你通常不需要做任何特别的事情来处理这个问题,但前端应用程序将不得不定义令牌更新策略,你可能需要协作和支持这样的任务。
JWT 的结构
JWT 是一个遵循良好定义结构的加密记录。因此,让我们看看 JWT 内部的结构,并讨论其结构。
可以在jwt.io/#debugger-io
找到查看和操作 JWT 的好工具。
图 7.2 – 示例 JWT
JWT 由三个不同的部分组成,由点分隔:标题、有效载荷和签名。作为一名 API 开发者,你的重点将放在有效载荷上,因为它包含关于用户(例如,用户 ID、权限)的声明(字段)。签名确保令牌没有被篡改。
解密后,JWT 内部的数据看起来像这样:
部分 | 字段 | 描述 | 示例值 |
---|---|---|---|
标题 | alg |
用于签名或加密令牌的算法 | "HS256" |
标题 | typ |
令牌类型 | "JWT" |
标题 | cty |
JWT 有效载荷的内容类型 | "JWT" 或 "application/json" |
标题 | kid |
用于标识签名中使用的密钥的密钥 ID | "abc123" |
有效载荷 | iss |
令牌的发行者 | "auth.example.com" |
有效载荷 | sub |
令牌的主题(通常是用户 ID) | "1234567890" |
有效载荷 | aud |
令牌的目标受众 | "example.com" |
有效载荷 | exp |
过期时间(Unix 时间) | 1516239022 |
有效载荷 | nbf |
在此时间之前无效(Unix 时间) | 1516239022 |
有效载荷 | iat |
发布时间(Unix 时间) | 1516239022 |
有效载荷 | jti |
JWT ID;令牌的唯一标识符 | "unique-token-id-123" |
有效载荷 | name |
用户的完整姓名 | "John Doe" |
有效载荷 | email |
用户的电子邮件地址 | "john.doe@example.com" |
有效载荷 | role |
分配给用户的角色或权限 | "admin" 或 ["admin", "user"] |
签名 | N/A | 用于验证令牌完整性的数字签名 | (加密操作的结果) |
表 7.1 – JWT 的一个示例
标题可以根据每个组织的需要自定义。但至少,你应该看到 "alg"
和 "typ"
条目存在。
有效载荷也主要由组织定义。有效载荷中的每个条目都称为 声明,用于识别用户、他们的角色、唯一信息以及任何其他 API 可以用来最佳控制数据访问的数据元素。
应特别关注基于时间的三个声明 – exp
、nbf
和 iat
。它们对于验证 JWT 在 API 执行时的有效性至关重要。理想情况下,它们不应被省略,尽管 "nbf"
有时未定义。在这种情况下,假设 JWT 从创建的那一刻起就是有效的("iat"
)。
通常,对于声明有三种分类:
注册声明:在行业中广泛使用的预定义声明,例如
iss
、exp
和sub
公共声明:使用 JWT 的各方同意的自定义声明
私有声明:在特定于您应用程序的上下文中使用的自定义声明
无状态
基于令牌的身份验证的一个关键好处是服务器不需要存储会话状态验证信息。所有必要的信息都包含在令牌本身中,并在每次调用中发送。
通常,需要在服务器端存储会话状态,但不应存储凭据或用户标识,而只应存储用于令牌的引用。
安全考虑
JWT 是通用安全策略的一部分,因此应被视为需要保护的数据。因此,以下是关于 JWT 的一些具体安全考虑:
总是使用 HTTPS 来传输令牌,因为令牌容易在明文中被截获
设置令牌过期时间以限制漏洞窗口
使用短期令牌和刷新令牌以最小化令牌被盗的风险
在客户端安全地存储令牌,如果可能的话,避免本地存储
JWT 策略的优势如下:
无状态认证在分布式系统中具有良好的扩展性
无需在服务器上存储会话信息
它允许使用令牌声明进行细粒度的访问权限控制
以下是其中的一些缺点:
如果令牌被盗,可以用来冒充用户
管理令牌过期和安全的刷新周期将增加应用程序的复杂性,包括前端和后端
注意事项
有时会出现一个问题,即 JWT 膨胀,组织不断向 JWT 中添加更多的声明或长列表的值,以至于 JWT 的大小超过了服务器上定义的限制。即使 JWT 规范没有定义上限,也要记住,令牌将作为 HTTP 头的一部分发送,并且它们始终有一个上限。如果你开始收到HTTP 413 Content Too Large错误,一个可能的原因可能是你的 JWT 大小。
总结来说,使用 JWT 来管理认证和授权是一种经过验证的策略,它得到了良好的行业支持,并依赖于可靠的技术。然而,其有效性的大部分归因于它在应用程序中的使用方式。作为软件工程师,确保实施良好的和有效的策略是你的责任。
多因素认证
多因素认证(MFA)通过要求用户提供多种身份验证形式来增加一层额外的安全防护。这通常包括他们知道的信息(例如,密码)、他们拥有的物品(例如,手机或安全令牌),或者他们自身的特征(例如,指纹或面部识别)。这主要是一种客户端策略,对 API 开发的影响很小,但对于后端工程师理解其基本概念来说非常重要。
MFA 是认证对话的一部分,因此,在用户成功输入密码后,他们会收到提示提供第二个因素,例如由手机应用(例如,Google Authenticator)生成的代码或通过短信发送的代码。然后服务器在授予访问权限之前验证第二个因素,通常是通过生成一个 JWT 供客户端在访问 API 时使用。
MFA 可以使用什么?
一般而言,有三种类型的 MFA:
你知道的某物:密码、PIN 码或安全问题的答案
你拥有的某物:生成临时密码的物理设备,如手机或硬件令牌
你是的某物:指纹、面部识别或虹膜扫描等生物识别数据
安全考虑
让我们看看在使用 MFA 时我们应该考虑哪些安全因素:
即使密码被盗,MFA 也极大地降低了账户被破坏的可能性。
基于时间的单次密码(TOTPs)常用于 MFA。确保这些令牌正确的时间同步,并在短时间内过期。
基于短信的 MFA 容易受到 SIM 卡交换的影响,应尽可能避免。改用基于应用程序的身份验证器或硬件令牌。
让我们来看看多因素认证(MFA)的优势:
它通过要求多种验证形式显著增强了安全性
它可以防止常见的攻击,如密码钓鱼和暴力攻击
让我们看看一些缺点:
它可能对用户来说很麻烦,尤其是在需要频繁登录的环境中
它需要支持第二因素的基础设施,例如维护短信网关或与身份验证器应用程序集成
生物识别认证
生物识别认证使用指纹、面部识别或语音等物理特征来验证用户的身份。随着移动设备和笔记本电脑上生物识别传感器的日益普及,它正成为一种更常见的认证方法。
让我们看看它是如何工作的:
用户的生物识别数据(指纹、面部扫描等)在设备上安全捕获和存储。
当用户尝试进行身份验证时,API 会将捕获的数据与存储的生物识别模板进行比较,以验证其身份。
这里有一些常见的生物识别认证方法:
指纹扫描:在移动设备上广泛使用
面部识别:集成在许多智能手机和笔记本电脑中
语音识别:有时用于电话身份验证
让我们看看一些安全考虑因素:
生物识别数据对每个用户都是唯一的,这使得伪造变得困难
将生物识别数据本地存储,而不是在服务器上,以防止大规模泄露
确保生物识别系统在生物识别失败的情况下(例如,受伤或硬件故障)有后备选项(例如密码)
这些是生物识别认证的一些优势:
它提供了高级别的安全性
它对用户来说很方便——无需记住密码
这里有一些缺点:
生物识别数据是不可逆的——一旦泄露,就无法更改
在收集和存储生物识别数据方面存在潜在的隐私问题
在某些条件下(例如,湿指纹或面部识别的照明不良)可靠性较低
选择合适的认证方法取决于应用程序的需求以及它处理的数据的敏感性。虽然基于密码的认证仍然很普遍,但更安全的方法,如基于令牌的认证、多因素认证(MFA)和生物识别认证,正在迅速获得认可。结合这些方法,例如使用 JWT 与 MFA 结合,可以为 API 提供强大且灵活的认证机制,确保用户和数据的安全。
授权
一旦通过认证保护了 API 免受未知用户的影响,下一步就是应用授权机制。这是系统确定已认证实体(用户、设备或服务)是否有权访问特定资源或执行特定操作的地方。虽然认证验证身份,但授权管理访问,确保已认证实体只能访问他们被授予权限的资源和服务。
在 API 安全中,适当的授权对于执行业务规则、保护敏感数据以及确保每个用户或服务只执行他们被允许的操作和访问信息至关重要。因此,让我们看看我们可以使用的不同技术来实现授权。
基于角色的访问控制
最常见的授权机制之一是基于角色的访问控制(RBAC)。RBAC 通过将预定义的角色分配给用户或实体,并将权限与这些角色关联来实现。每个角色都被授予访问特定 API 端点或功能,具体取决于应用程序的安全要求。
让我们看看 RBAC 是如何工作的:
用户被分配一个或多个角色,例如管理员、用户或编辑。
API 端点或资源通过角色检查来保护。例如,只有具有管理员角色的用户可能有权访问/admin 端点。
当发起 API 请求时,服务器会检查用户分配的角色与请求的资源所需的角色的匹配情况。
这些是 RBAC 的一些优点:
它通过将权限分组到角色中来简化权限管理。
它易于实现和扩展,尤其是在企业应用程序中,角色可以集中定义
它被许多解决方案完全支持,例如 JWT 中的声明
这里有一些缺点:
在需要细粒度访问控制的复杂系统中,它可能不够灵活,可能导致角色爆炸或数据泄露。
它可能在复杂系统中导致意外的暴露,因为角色可能不一致或不兼容。
基于属性的访问控制
基于属性的访问控制(ABAC)通过使用属性(用户属性、环境属性、资源属性等)来确定访问权限,比 RBAC 提供了更细粒度的控制。属性是描述实体或动作的元数据,并针对策略进行评估,以决定是否允许访问。
让我们看看 ABAC 是如何工作的:
策略被编写来定义授予访问权限的条件。
考虑到用户角色、请求时间、资源类型或位置等属性。
访问决策是根据属性和策略的实时评估动态做出的。
这些是使用 ABAC 的一些优点:
它通过允许基于动态条件的细粒度控制来提供灵活性。
它适用于 RBAC 难以管理的复杂系统。
下面是一些缺点:
相比 RBAC,实现和管理更为复杂。
由于实时策略评估,可能会导致性能开销。
由于属性管理不当,可能会导致意外的安全问题。例如,员工在公司内更换职位,但系统仍然允许他们查看之前工作的数据。
OAuth 2.0
OAuth 2.0 是 API 中最广泛使用的授权框架之一。它允许用户授予第三方应用程序对其资源的有限访问权限,而无需暴露其凭据。OAuth 2.0 通常用于委派 API 访问,并为网络、移动和云应用程序之间的授权提供了一种标准化的方法。
让我们看看 OAuth 2.0 是如何工作的:
用户通过身份提供者(例如,Google、Facebook)进行身份验证,并授予第三方应用程序权限。
第三方应用程序接收访问令牌,并代表用户使用该令牌进行授权的 API 请求。
访问令牌包含作用域,这些作用域定义了第三方应用程序的访问级别(例如,只读、写入)。
让我们看看一些安全考虑因素:
总是使用 HTTPS 来传输 OAuth 令牌。
使用短期访问令牌和刷新令牌以最小化令牌被盗的风险。
实施令牌撤销机制,在必要时使令牌失效。
这里是使用 OAuth2.0 的优点:
它允许委派访问,而无需共享用户凭据。
它已被广泛采用,为许多服务提供了标准化。
下面是一些缺点:
它需要实现安全的令牌存储和传输机制。
它可能会增加复杂性,尤其是在管理刷新令牌和令牌过期时。
JWT 用于授权
在身份验证部分讨论的 JWT 在授权中也发挥着关键作用。在身份验证之后,可以颁发包含指定用户角色或权限的声明的 JWT。然后,API 可以使用这些声明来执行访问控制。
让我们看看 JWT 授权是如何工作的:
用户经过身份验证,并颁发包含用户角色或权限等声明的 JWT。
客户端在未来的 API 请求中在授权头中包含 JWT。
服务器解码 JWT 并检查声明,以确定用户是否有权访问请求的资源。
JWT 允许无状态授权,这意味着服务器不需要维护会话状态,因为必要的授权信息已经嵌入在令牌本身中。
让我们看看使用 JWT 授权的一些优点:
通过消除服务器端会话存储的需求,它在分布式系统中具有良好的可扩展性
它通过在 JWT 中使用声明来实现细粒度控制
至于缺点,它需要仔细管理令牌过期和吊销,以防止未经授权的访问。这是 JWT 和其他基于属性的授权的最大缺点。确保 JWT 中的声明反映了此用户的实际角色非常重要。再次提到,员工在公司中更换职位的情况可能会引起数据访问违规。
细粒度访问控制
在某些系统中,所需访问控制级别超出了 RBAC 或甚至 ABAC 的基本功能。细粒度访问控制(FGAC)允许根据用户身份、资源所有权和上下文数据等属性,对资源访问进行更细致的控制。
这些 FGAC 级别通常出现在复杂的商业领域;医疗保健和金融是两个非常常见的案例。医疗保健作为一个高度监管的行业,有许多不同的规则需要应用于任何数据访问。
这里有一些 FGAC 的例子:
用户只能访问他们自己的个人资料数据,不能访问其他用户的资料数据
服务只能修改它创建的资源
基于位置、一天中的时间或其他上下文信息,可能会限制对敏感资源的访问
在一个医疗保健保险系统中,客户可以看到他们账户的所有付款,但只能看到他们自己的索赔付款和其他他们依法允许的受抚养人的索赔付款
让我们更仔细地看看最后一个例子,因为它说明了 FGAC 的挑战。
案例:客户需要在其健康保险政策中保留前配偶一段时间。在这段时间内,主要客户无法看到配偶的任何医疗信息,但可以看到与治疗相关的费用。在这个案例中存在几个挑战。我们在哪里寻找保留配偶在政策中的强制时间?我们如何在家庭请求中隔离配偶的数据?我们如何允许某些财务数据被暴露,但不暴露医疗信息?
这些问题并不简单,回答它们可能会使该系统部分比原本更复杂。
此案例需要仔细的设计和政策执行。它可能会增加应用程序的复杂性,尤其是在大型系统中。
API 授权的最佳实践
总结来说,以下是一些确保您的 API 尽可能安全的指南:
使用最小权限原则:仅授予用户和服务执行其任务所需的最小权限
实施分层授权机制:结合 RBAC 与 ABAC 或 OAuth 以实现更灵活和安全的访问控制
定期审计权限:定期审查和更新角色、权限和访问策略,以确保它们与当前业务需求保持一致
安全令牌:始终使用安全的存储和传输方法来处理令牌(例如,OAuth 令牌、JWTs)以防止未经授权的访问
当设计和实施得当,授权确保您的 API 保持安全、响应,并符合监管要求。通过选择正确的授权策略——无论是 RBAC、ABAC、OAuth 还是基于 JWT 的——您可以根据系统特定需求定制访问控制,同时保护数据并确保用户只能执行他们有权执行的操作。
OWASP API 安全 Top 10 概述
OWASP代表开放网络应用安全项目,这是一个专注于网络应用安全的非营利组织。他们在记录已知安全问题和帮助开发者和组织确保网络应用安全方面做得非常出色。其主要贡献之一是OWASP API 安全 Top 10,它确定了 API 最关键的安全风险。
OWASP 是所有软件工程师的重要资源,你应该定期查看发布的关注列表和新安全白皮书。
最新列表于 2023 年发布,可在以下链接找到:owasp.org/API-Security/editions/2023/en/0x11-t10/
。
API1:2023 - 破坏对象级别授权
API2:2023 - 破坏认证
API3:2023 - 破坏对象属性级别授权
API4:2023 - 无限制资源消耗
API5:2023 - 破坏功能级别授权
API6:2023 - 无限制访问敏感业务流程
API7:2023 - 服务器端请求伪造
API8:2023 - 安全配置错误
API9:2023 - 不当的库存管理
API10:2023 - 不安全地使用 API
如您所见,所有顶级 OWASP 关注点都是我们迄今为止在本章中讨论的主题。随着 OWASP 列表的演变,熟悉 OWASP 列表非常重要,因此请经常访问之前提供的链接中的 OWASP Top Ten API 安全风险。
理解常见漏洞和暴露
“我们的社会依赖于软件”这个说法并不新鲜,它描述了大多数现代社会,因此,软件安全已经成为全球政府和组织的一个核心关注点。网络威胁正在以前所未有的速度发展,软件中的漏洞可能导致重大的经济损失、声誉损害和法律后果。就在本章撰写的那一周,历史上最大的 DDoS 攻击针对了 Cloudflare(你可以在这里找到详细讨论:www.linkedin.com/pulse/cloudflare-mitigates-historic-world-record-56-tbps-hoccc/
)。据推测,这次攻击的规模是通过利用许多物联网设备的漏洞实现的,但详细情况仍在调查中。因此,对于 Java 软件工程师来说,理解和管理工作中的这些漏洞对于开发安全且稳健的应用程序至关重要。
软件安全的一个基本方面是识别和缓解已知的漏洞,通常被称为通用漏洞和暴露(CVEs)。本章旨在从专业 Java 软件工程师的角度深入探讨 CVEs。我们将深入研究 CVEs 的定义、用于发现和记录它们的机制以及它们的结构组成。此外,我们将突出跟踪 CVEs 的最佳资源,并讨论帮助识别这些漏洞的软件扫描器。
此外,我们将探讨管理 CVE 的有效策略,包括持续依赖升级、依赖管理实践、主动依赖更新和基于扫描报告的响应式升级。通过理解和实施这些策略,软件工程师可以增强其应用程序的安全态势,并为更安全的数字生态系统做出贡献。
什么是 CVE?
CVE 系统是一种标准化的方法,用于识别和编目软件和固件中的安全漏洞和暴露。由 MITRE 公司管理,CVE 为每个公开的漏洞提供一个唯一的标识符,允许在各种安全工具、数据库和通信中保持一致的引用。CVE 条目通常包括一个识别号、对漏洞的简要描述以及相关警告、报告和补丁的引用。
对于 Java 软件工程师来说,CVE 尤其重要,因为 Java 库和框架的使用非常普遍,如果管理不当,可能会将漏洞引入应用程序。了解 CVE 使开发者能够做到以下几点:
识别漏洞:识别他们使用的库和组件中已知的网络安全问题
评估影响:评估漏洞对其应用程序可能产生的影响
实施缓解措施:应用补丁或更新以及时解决漏洞
维护合规性:通过管理已知漏洞来遵守安全政策和监管要求
如何发现和记录 CVE?
漏洞的发现是一个涉及安全研究人员、开发人员和自动化工具的协作过程。以下是一些常见的发现漏洞的机制:
安全研究:安全专业人士和研究人员积极分析软件以识别潜在漏洞。此过程可能涉及以下内容:
代码审计:手动审查源代码以查找不安全的编码实践
渗透测试:模拟攻击以发现运行中的应用程序的弱点
模糊测试:向程序提供无效或随机数据以触发意外行为
漏洞赏金计划:许多组织提供漏洞赏金计划,激励独立研究人员发现并报告漏洞。这些计划有助于发现内部团队可能错过的潜在问题。
自动化扫描工具:如静态代码分析器和动态应用程序安全测试(DAST)工具之类的工具自动扫描代码库和运行中的应用程序以查找已知漏洞模式。
用户和开发者报告:最终用户和开发者在常规使用或开发活动中可能会遇到安全问题,并将它们报告给维护者或安全团队。
文档处理流程
一旦发现漏洞,记录它涉及以下步骤:
漏洞披露:发现者按照负责任的披露实践向软件供应商或维护者报告漏洞,以便在公开披露之前有时间修复。
CVE 分配:授权的CVE 编号机构(CNA)为漏洞分配一个唯一的 CVE 标识符。此标识符允许一致的跟踪和引用。
描述和分析:创建漏洞的详细描述,包括以下内容:
摘要:对问题的简要概述
技术细节:关于漏洞的深入信息,包括受影响的版本和组件
影响评估:如果漏洞被利用,潜在的风险和后果
发布:CVE 条目发布在 CVE 数据库中,并与各种安全数据库(如国家漏洞数据库(NVD))共享。供应商也可能发布安全公告和补丁。
CVE 的结构
CVE 条目由几个关键组件组成,这些组件提供了有关漏洞的基本信息:
CVE 标识符:格式为 CVE-YYYY-NNNN 的唯一标识符,其中 YYYY 是 CVE ID 分配或公开的年份,NNNN 是顺序号,例如 CVE-2023-12345。
描述:对漏洞的简洁总结,包括以下内容:
漏洞类型 : 例如缓冲区溢出或注入漏洞
受影响的产品 : 受影响的特定软件、版本和组件
影响 : 利用漏洞可能产生的潜在影响,如未经授权的访问或拒绝服务
参考 : 链接到其他信息,包括以下内容:
供应商警告 : 软件供应商的官方声明或补丁
安全公告 : 安全组织的报告
技术分析 : 分析漏洞的深入文章或报告
最后,当导入到 NVD 时,会添加额外的元数据(如果有的话):
CVSS 评分 : 通用漏洞评分系统(CVSS)提供了对漏洞严重性的定量度量。
影响度量 : 关于机密性、完整性和可用性影响的详细信息。
受影响配置 : 易受攻击的特定环境或配置。
您需要理解,记录 CVE 是一个过程,并且可能已经识别出影响您代码的 CVE,但目前还没有解决方案。
跟踪 CVE 的最佳资源
了解最新的漏洞信息对于主动安全管理工作至关重要。以下是一些跟踪 CVE 的最佳资源。您应该将它们都加入书签,并熟悉它们的结构和搜索功能:
NVD
由国家标准与技术研究院(NIST)管理,NVD 提供了关于 CVE 的全面信息,包括详细分析、CVSS 评分和影响度量。它是 CVE 的主要资源:
MITRE CVE 列表
官方的 CVE 列表由 MITRE 维护。它提供了关于每个 CVE 的基本信息,包括描述和参考。通常,这里的信息比 NVD 更容易导航:
供应商的安全警告
软件供应商通常会发布自己的安全警告和补丁。监控这些来源确保您能够及时收到特定供应商的信息。有许多这样的资源,但对于 Java 软件工程师来说,Oracle 和 Apache 是其中最重要的两个:
Oracle Java SE 关键补丁更新 :
www.oracle.com/security-alerts/
Apache 安全 :
apache.org/security/
VulDB :
vuldb.com/
这些数据库从各种来源汇总漏洞信息,并经常提供额外的背景和分析。并非所有当前漏洞都可在其中找到。
帮助识别 CVE 的软件扫描器
软件扫描器是设计用来自动检测代码库、应用程序或系统中的漏洞的工具。对于 Java 开发者来说,这些工具在识别与依赖项和代码相关的 CVEs 方面非常有价值。它们以几种不同的形式工作,并且根据项目不同,你可能需要使用多个工具来覆盖你的代码。
静态应用程序安全测试(SAST)工具:这些工具在不执行代码的情况下分析源代码以查找安全漏洞。例如,SonarQube 和 Checkmarx。
软件组成分析(SCA):这些工具识别应用程序中的开源组件和依赖项,并检查已知漏洞。例如,OWASP Dependency-Check、Snyk、Black Duck 和 WhiteSource。
动态应用程序安全测试(DAST):这些工具通过模拟攻击来分析运行中的应用程序以检测漏洞。例如,OWASP ZAP 和 Burp Suite。
这些工具的工作原理
由于 CVEs 是与外部依赖项相关的安全问题,扫描器如何查看你的代码并找到问题呢?
首先,扫描器分析项目文件,例如 Maven 的pom.xml
和 Gradle 的build.gradle
,以识别所有直接和间接依赖项。然后,它提取每个依赖项的版本号,并将它们与包含 CVE 信息的漏洞数据库进行比较。基于这种比较,扫描器生成一份详细的报告,突出显示任何有漏洞的依赖项,并根据 CVSS 评分将它们按严重程度分类。此外,它还提供升级建议,建议解决已识别漏洞的版本。
下一步是将依赖项扫描器集成到开发工作流程中。一些扫描器为集成开发环境(IDE)(例如 Eclipse 和 IntelliJ IDEA)提供插件,允许开发者在编码环境中直接检测漏洞。此外,扫描器可以集成到持续集成/持续部署(CI/CD)管道中,在构建过程中自动检测漏洞。为了增强安全监控,它们还可以配置为在发现新的漏洞时发送自动警报。通过在开发周期的早期阶段识别和解决安全问题,这些集成增强了敏捷性,并有助于缩短软件开发生命周期(SDLC),减少由后期安全修复引起的延迟。
Java 流行的扫描器
在印刷格式中发布软件工具列表总是有点危险,因为一些项目可能会随着时间的推移而消失或过时,但截至 2025 年 4 月,这些是目前在 Java 项目中识别漏洞最广泛使用的工具:
OWASP Dependency-Check:这是一个开源工具,它识别项目的依赖项并检查是否存在与扫描的依赖项相关的已知、公开披露的漏洞(CVEs)。它支持使用 Maven、Gradle 和 Ant 的项目,并且可以集成到大多数构建工具和 CI/CD 解决方案中。
Snyk:这是一个商业工具,为开源依赖项提供漏洞扫描和修复。它提供实时扫描,允许持续监控依赖项。它还自动在拉取请求中提出代码修复建议。插件适用于大多数流行的 IDE(如 IntelliJ 和 VS Code)。
Black Duck:这是一个全面的 SCA 工具,用于扫描开源漏洞和许可证合规性。它强制执行有关在系统中使用开源组件的政策,并生成漏洞和风险的深入分析。
管理 CVEs 的策略
所有这些工具都非常重要,并且是管理项目中 CVEs 的关键部分。但如何管理这项工作量,需要考虑哪些因素?
有效管理 CVEs 不仅涉及检测,还包括战略规划和减轻风险的流程。
一个重要的理解点是编写安全软件的成本很高,特别是管理 CVEs 已经成为软件安全中困难和昂贵的部分。团队将 15%到 20%的产能分配给安全是很常见的。
考虑到这一点,让我们看看一些有助于 Java 开发者维护安全应用的策略。
持续升级依赖项
定期将依赖项更新到最新版本确保应用程序能够从库维护者做出的安全补丁和改进中受益。
这是一种对 CVEs 的积极应对方法。通过始终保持依赖项更新,你确保任何安全更新都将尽快在代码中实施和使用。
实施持续升级有多种方式:
自动更新:使用 Dependabot 等工具自动创建依赖项更新的拉取请求
定期维护计划:建立常规(例如,每周或每月)来审查和更新依赖项
测试:实施全面的自动化测试以确保更新不会引入回归
监控发布说明:跟踪依赖项的发布说明以获取任何安全相关的更新
这些是持续升级的好处:
安全性:它减少了暴露于已知漏洞的时间窗口
功能增强:它提供了访问新功能和性能改进的途径
社区支持:它与社区支持的活跃版本保持一致
成本和需求
这种方法的主要成本是必须投入在保持代码库当前的时间。记住,你将更改代码中的依赖项,你调用的 API 可能已经改变,有时是以不明显的方式改变,迫使你和你的团队重构那些本来不需要任何更改的代码部分。
要能够频繁地进行如此多的潜在破坏性更改,必须有一套全面的自动化测试,这样开发团队能够有信心首先进行这些更改。
将工具添加并集成到您组织使用的特定票务系统中的时间是需要考虑的另一个成本。对于测试覆盖不足和自动化构建管道不足的遗留代码库,初始投资可能相当大。
依赖管理
既然我们知道 CVE 是由我们作为软件工程师添加到代码库中的依赖项引起的,我们该如何限制我们对它们的暴露?
显然,一个适当的依赖项管理策略对于控制应用程序的质量、安全性和性能至关重要。这里的目的是尽可能防止暴露于 CVE。
有时候,软件工程师在没有分析后果的情况下将依赖项添加到项目中。说实话,这种情况比我们愿意承认的更常见!制定一项政策,要求验证新依赖项的需求并讨论更安全和维护得更好的替代方案,可以显著帮助解决安全漏洞。为了避免引入不必要的风险,在添加和管理依赖项时遵循以下最佳实践非常重要:
最小化依赖:仅包含必要的依赖项以减少攻击面。引入新的依赖项必须与团队讨论,并识别其影响。
版本锁定:指定依赖项的确切版本以确保构建的一致性。避免依赖“最新”版本。
使用可信来源:从信誉良好的仓库(例如,Maven Central)检索依赖项。选择那些积极维护且拥有良好安全更新历史的包和库。
传递依赖项控制:监控和管理由直接依赖项引入的传递依赖项。
许可证合规性:确保依赖项的许可证与您的项目兼容。
积极的依赖项升级
尽管我们可能希望避免依赖项,但几乎不可能在没有它们的情况下构建现代 API 系统。因此,在漏洞披露或它们过时之前积极升级依赖项是保持您的系统 CVE-free 的好方法。为了实现这一点,有必要做以下事情:
保持信息更新:订阅邮件列表并监控依赖项维护者的更新。
运行 beta 测试:在完全集成之前在受控环境中测试新版本
为依赖项做出贡献:参与您使用最多的开源项目,以了解即将到来的变化
自动化扫描:使用工具来通知您有关依赖项新版本的发布
通过执行所有这些操作,您可以领先于大多数 CVEs,这降低了受到新漏洞影响的可能性。作为次要收益,这些策略消除了可能需要大量重构您自己的代码以及所有新版本的性能改进和优化的依赖项版本的大跳跃。
缺点是成本。实施这些主动政策可能会造成频繁的中断,这可能很难向业务所有者和投资者证明其合理性,因为所有的工作都没有创造新的功能。
基于扫描结果的反应性升级
这是在扫描器或安全警报中的漏洞报告响应下升级依赖项的过程。这可能是开发团队使用最普遍的过程。为了有效地根据扫描结果管理升级,团队应遵循以下步骤:
定期扫描:使用自动化扫描器检测依赖项中的漏洞
评估严重性:根据严重性和影响优先处理漏洞
计划修复:安排升级或为受影响的依赖项应用补丁
验证:在升级后测试应用程序以确保功能完好
文档记录:记录发现的漏洞和采取的行动
风险管理:平衡升级的紧迫性与对应用程序可能产生的影响
沟通:与利益相关者协调以安排必要的更新
回退计划:在升级引入问题时制定策略
这个过程本身并没有固有的负面因素,但存在需要理解的风险。
同时缓解太多的 CVEs 是很常见的情况。当团队决定推迟安全任务时,它们可能会在发布前积累。这是一个非常普遍的情况,可能会对发布造成重要延误。
摘要
网络安全领域的不断演变要求 Java 软件工程师在管理其应用程序中的漏洞时保持警惕和主动。CVEs 在识别和传达已知的安全问题中发挥着关键作用,了解如何查找、记录和修复它们是至关重要的。
通过利用漏洞扫描器等工具以及实施基于扫描报告的持续依赖项升级、主动更新和反应性措施,开发者可以显著提高其应用程序的安全态势。有效的依赖项管理通过控制集成到软件中的组件进一步降低风险。
通过使用 NVD 和供应商通告等信誉良好的来源来保持信息更新,确保开发者了解最新的威胁并能够迅速采取行动。将安全实践整合到开发生命周期中,将安全从被动反应转变为软件开发中的主动基石。
总结来说,管理 CVE 是一个多方面的过程,需要勤奋、战略规划和合适的工具。通过采用这些实践,Java 软件工程师可以为创建更安全的应用程序做出贡献,保护他们的组织和依赖其软件的最终用户。在下一章中,我们将探讨生成式 AI 如何革命性地改变我们编写 API 测试的方式,提供见解和技术。
第八章:适用于健壮 API 的测试策略
在研究和撰写本章时,我们心中有一个完全不同的愿景。最初的计划是写一篇关于测试 API 的传统文章,这是一篇结构化、系统性的对既定实践的探索。重点是那些多年来一直是API 测试支柱的工具和技术,如 Postman、Bruno 和各种模拟框架。这些工具在帮助工程师确保 API 可靠性方面至关重要,并且仍然被广泛使用,尤其是在具有遗留系统的项目中。
然而,随着我们对这个话题的思考越来越多,我们越来越清楚地意识到,软件测试的格局已经发生了显著的变化。生成式 AI和大型语言模型(LLMs)——如 ChatGPT、Gemini 和类似工具——的出现,从根本上改变了我们对待软件开发(包括测试)的方式。曾经是缓慢且经常重复的过程,现在已经演变成一个动态的、AI 增强的工作流程。生成式 AI 不仅使编写测试更快,而且使整个过程更加引人入胜、高效和有效。
这种转变不仅仅关乎便利性。将生成式 AI 集成到 API 测试中,对软件开发生命周期中所有相关人员都有深远的好处。工程师现在可以专注于更高层次的解决问题,而不是被繁琐的测试创建所困扰。产品经理和所有者对 API 的质量更有信心,因为他们知道测试已经变得更加全面和适应性。结果是更有意义的,确保 API 满足现实世界的需求,同时保持最高的质量标准。
因此,本章并非最初计划要写的。我们已经完全转变了焦点,以反映这一新现实。在这里,我们将探讨生成式 AI 如何彻底改变我们编写 API 测试的方式,提供见解和技术,帮助您利用这一颠覆性技术。通过拥抱这些进步,我们可以创建更好的 API,促进协作,并交付超出预期的软件。
在本章中,我们将涵盖以下主题:
测试类型
测试格式和工具
测试的提示工程
准备开发环境
运行和演进代码
技术要求
专注于这些新的集成和回归测试想法的优势之一在于工具和安装。如果你能在本地机器上运行 JavaJUnit 测试,你已经拥有了运行测试所需的一切。
我们还将利用 Docker 在本地部署我们的应用程序和依赖项。
你还需要访问一个 LLM。任何免费可用的 LLM 都应该工作得很好(ChatGPT、Gemini、Claude 等)。
测试类型
通过几分钟的在线搜索,人们可以识别出行业内经常引用的 19 种不同类型的测试,我相信还有更多:
功能测试:通过检查其行为是否符合特定要求来验证 API 按预期工作
集成测试:确保 API 与其他软件组件、系统或 API 正确交互
单元测试:在隔离状态下测试 API 的各个单元或组件
性能测试:评估 API 在不同条件下的响应性、可扩展性和稳定性
负载测试:检查 API 如何处理预期的用户流量
压力测试:将 API 推向极限以找到破坏点
峰值测试:评估 API 对突然流量激增的反应
** soak 测试**:在持续负载下监控 API 的长时间性能
安全测试:确保 API 免受漏洞和未经授权的访问
验证测试:验证 API 作为一个整体满足预期的系统和企业需求
可用性测试:确保 API 易于开发者理解和使用
回归测试:确保 API 的新更改或更新不会破坏现有功能
合规性测试:验证 API 符合特定的法律、法规或行业标准
探索性测试:通过与非脚本化的 API 交互来发现意外问题
互操作性测试:验证 API 与不同平台、语言和环境正确工作
模糊测试:通过向 API 发送随机、格式错误或意外的数据来识别漏洞
端到端测试:验证涉及 API 的整个工作流程或用户场景
数据测试:确保 API 处理或存储的数据的完整性和正确性
模拟和沙箱测试:在隔离状态下测试 API 功能或模拟不影响实时系统的环境
显然,对所有这些测试类型的全面讨论需要一本书或更多。因此,在本章中,我们将重点关注几种特定的测试类型:功能测试、回归测试和验证测试。
为什么选择这些特定的测试类型?正如之前所述,主要目标是帮助软件工程师创建更好的 API。在各种类型的测试中,功能测试、回归测试和验证测试在实现这一目标方面提供了最大的价值。它们的主要好处是它们在做出更改时提供的信心。软件工程师面临的最大挑战之一是修改代码可能带来的不可预测的后果。为了解决一个问题而需要的更改可能会无意中破坏其他东西。这个问题既普遍又令人瘫痪。我们如何确保我们的更改不会破坏现有的功能?通过优先考虑功能测试和回归测试,我们可以直接应对这一担忧,确保 API 继续满足其要求,同时保护不受新更改引入的不预期的后果。这正是始于 2000 年代初的测试驱动开发(TDD)方法的核心。
然而,创建和维护全面的测试集是一个资源密集型的过程,无论是从时间还是成本上讲。编写有效的测试需要深入理解系统、周密的规划和细致的实施。一旦编写完成,这些测试必须定期更新和调整,以反映系统中的变化,这增加了持续维护的工作量。这种程度的投资可能对组织来说很难证明其合理性,尤其是在快节奏的环境中,那里通常有严格的截止日期和预算限制。因此,许多项目历史上都降低了测试的优先级,这往往阻碍了开发团队投入所需的时间和资源来构建健壮的测试套件。这种短视的方法可能在短期内节省了精力,但往往会导致后期出现缺陷时成本更高,影响系统的可靠性和客户满意度。坦白说——编写测试可能很无聊,这在业界是许多人的共同感受。
然后,在 2022 年 11 月,生成式 AI 的引入彻底改变了软件测试的创建。生成式 AI 在编写测试方面已成为一个难以置信的宝贵资产。虽然关于其对开发实际业务逻辑的影响的争论仍在进行中——这是一个留待以后讨论的话题——但对于测试目的而言,它是一个非凡的工具。我们将广泛利用生成式 AI 来编写我们的测试并协助其开发。这一创新不仅增强了功能测试和回归测试对软件工程师的价值,而且使这些测试的编写变得更加容易和便宜。
测试格式和工具
当涉及到测试 API 时,我们有各种工具和格式可供选择,每个都满足不同的需求和专业知识水平。例如,Postman 和 Bruno 等工具提供了用户友好的界面,用于构建、管理和运行 API 测试。这些工具特别适用于需要视觉表示和非技术协作的环境。然而,当我们关注于自动化、可扩展和管道就绪的测试时,JUnit 等直接集成到开发生态系统的工具提供了显著的优势。
例如,Postman 等工具允许用户以最少的编码设计 API 测试,使它们对更广泛的受众可访问。这些工具通常包括共享测试集合、生成测试文档和提供实时可视化结果等功能。尽管这些功能强大,但它们在集成到复杂的 CI/CD 管道时存在不足。从 Postman 或 Bruno 导出和运行测试通常需要额外的工具或手动步骤,并且它们缺乏与代码测试框架提供的源代码控制系统(如 Git)的无缝兼容性。
相比之下,JUnit 是一个广泛使用的 Java 测试框架,它采用代码优先的方法来测试 API。通过使用 JUnit 直接在 Java 中编写 API 测试,开发者可以确保他们的测试被视为源代码的一部分。这种方法带来了几个优点:
集成到部署管道中:JUnit 测试与 CI/CD 工具(如 Jenkins、GitHub Actions 和 GitLab CI)具有内在的兼容性。这使得 API 测试可以在构建或部署期间自动运行,确保没有经过彻底测试的更改被发布。
利用源代码基础设施:由于 JUnit 测试是用纯代码编写的,因此它们受益于与应用程序代码本身相同的工具和工作流程——版本控制、同行评审、静态分析等等。这种一致性降低了复杂性,并确保测试始终与应用程序保持同步。
可扩展性和可维护性:基于代码的测试更容易维护,因为它们随着应用程序的演变而演变。重构工具、IDE 功能和代码检查器帮助开发者快速适应 API 的变化,最大限度地减少过时或损坏测试的风险。
可重用性和定制性:JUnit 允许开发者编写可重用的测试辅助工具和实用程序,为复杂测试场景提供更大的灵活性。它还支持诸如参数化测试、模拟环境和依赖注入等高级功能。
此外,JUnit 能够直接调用真实 API——无论是本地部署还是在测试环境中——确保测试模仿真实世界的使用。这有助于捕捉到在抽象环境(如 Postman 或 Bruno)中可能被忽略的问题,例如不正确的请求格式、意外的响应或性能瓶颈。
虽然 Postman 和 Bruno 适用于快速的单次测试或与非开发者协作,但对于旨在实现稳健、自动化和管道集成测试的团队来说,JUnit(以及类似的代码优先工具)是更优的选择。通过将 API 测试视为代码库中的第一类公民,开发者可以最大化效率、可扩展性和对 API 的信心。
充分利用自动化测试的优势至关重要,然而人工智能正在开始完全重新定义测试,并出现了一种新的测试方法。
软件开发实践的演变始终围绕着提高效率、质量和协作。最近的一项进步是将生成式人工智能集成到敏捷软件开发中,特别是在 SCRUM 框架内(见 www.scrum.org/resources/what-scrum-module
)。一些团队已经在尝试一种新的实践,即 AI 驱动测试的提示成为开发过程的一个组成部分,从而改变团队定义、实施和验证新功能的方式。
在这个更新的工作流程中,技术负责人与产品负责人合作,在塑造开发和测试生命周期中扮演关键角色。当定义一个新的故事,引入 API 的功能或增强时,技术负责人会为生成式人工智能工具制定详细的提示。这些提示作为测试故事验收标准的蓝图。通过利用技术负责人和产品负责人的领域专业知识,这些提示不仅封装了技术要求,还包括了业务背景和功能的预期结果。
图 8.1 – 将人工智能驱动的提示集成到敏捷 SCRUM 工作流程中
例如,如果正在添加一个新的 API 端点以返回客户的交易历史,提示可能指定确切的输入参数、预期的 JSON 响应结构以及测试边缘情况,例如处理无效输入或空数据集。这些提示不仅仅是指令——它们是连接技术和业务视角的共享工件,确保了对齐和清晰。
将生成式人工智能集成到敏捷工作流程中提供了几个显著的优势:
一致性:标准化的提示确保了在故事之间测试方法的一致性,减少了可变性并提高了可靠性
效率:通过自动化测试用例的生成,开发者可以专注于编写功能本身的代码,减少重复性任务,并为创新腾出时间
协作:技术负责人和产品负责人共同创建提示,促进了技术团队和业务团队之间更好的沟通和对齐
质量保证:提示鼓励团队从一开始就批判性地思考边缘情况、错误处理和预期结果,从而减少后续的缺陷和返工。
将提示存储在代码库中
为了巩固这些提示的重要性,它们被存储在项目的 Git 仓库中,与源代码一起。这种集成将提示提升到开发生命周期中的第一公民地位,将它们与代码本身一样严格对待。将提示存储在 Git 中提供了几个关键好处:
版本控制:提示会随着功能的成熟或需求的变化而演变。将它们存储在 Git 中确保了修改的完整历史记录,使团队能够随着时间的推移追踪变化。
协作:开发者在代码审查过程中可以审查和细化提示,确保在实施开始之前它们的准确性和相关性。
可追溯性:将提示与特定的故事、提交和分支链接,创建了一个透明且可审计的过程,将测试直接与开发工作联系起来。
可重用性:随着时间的推移,类似功能或场景的提示可以被重新利用,减少冗余并加速未来的开发周期。
AI 生成的代码作为验收标准
一旦提示被最终确定并存储,它们就成为故事验收标准的一个组成部分。指定的软件工程师使用提示通过生成式 AI 工具生成测试用例或其他相关代码。这些生成的代码作为验证新特性的基准。关键要求是生成的代码必须成功通过——必须是绿色——故事才能被标记为完成。
这种方法为 SCRUM 中“完成”的定义引入了新的严谨性层次。它确保测试不是事后考虑的事情,而是一个与特性开发并行发展的过程。此外,通过 AI 自动化测试过程的大部分,团队可以更快地交付高质量的软件,减少手动测试瓶颈。
将 AI 驱动的测试集成到敏捷实践中不仅需要工具,还需要文化转变。
采用新的敏捷心态
这种实践代表了敏捷团队心态的转变。定义提示的行为将测试考虑提前到开发过程的早期,与 TDD 的原则相一致。它强调了测试不仅作为验证步骤的重要性,而且作为一个协作、迭代的流程,可以增强软件的整体质量。
通过将 AI 驱动的测试提示嵌入到敏捷工作流程中,团队不仅拥抱了生成式 AI 的力量,还将他们的开发实践提升到了新的精度和效率水平。这种方法完美地符合敏捷的目标:更快地交付价值、有效地适应变化,并在整个开发生命周期中保持高质量标准。
API 测试环境的结构
API 测试环境的目标是在本地复制应用程序的所有关键组件,以便对代码进行全面和现实的测试。以下图展示了此类环境的典型设置,强调了一个适合开发和测试的自包含系统。
图 8.2 – 使用 Docker 和云网络构建的本地 API 测试环境的典型结构
在此图中,API App代表你正在测试的主要应用程序。此应用程序直接连接到关键的后端组件,如数据库、缓存服务或消息队列,所有这些都在容器化(Docker)或基于云的非生产网络环境中部署。API Tests组件直接与API App交互,模拟真实世界场景,确保 API 功能的全面覆盖和验证。
这里的“本地”一词是灵活的:它可以指一个软件工程师的机器,通常使用 Docker 容器,或者任何为开发目的提供的非生产云环境。确保每个团队成员都能访问这种标准化的、隔离的环境至关重要,这允许在不同工作站之间进行一致和可重复的测试,并减少测试结果的变化性。
创建此本地或开发环境需要前期投入。然而,在开发生命周期早期进行这种投资可以显著提高效率,降低集成问题的风险,并加速整体开发。
“提示”LLMs
提示工程是设计和完善提供给生成式 AI 模型的提示,以产生精确、准确和相关的输出的实践。它涉及仔细措辞和迭代调整,以有效地实现期望的响应。现在让我们看看创建有效的测试生成提示所需的一些机制。
如前所述,使用生成式 AI 进行软件测试是这项技术的完美应用。具体来说,测试在知识产权和所有权方面的敏感性较低。测试主要是验证工具,确保你的主要代码按预期运行。由于测试的重点是功能、边缘情况和覆盖率,生成式 AI 可以快速生成各种测试用例,节省开发者的时间和精力。此外,测试通常是重复性和细节导向的——这是 AI 擅长的任务。在这里使用生成式 AI 可以让开发者专注于工作中更具创造性和复杂性的方面,而 AI 则负责处理繁琐的工作。这意味着更高的生产力、更快的迭代和更全面的测试覆盖率,同时手动工作量最小化。
相比之下,当使用生成式 AI 编写主要的生产代码时,情况变得稍微复杂一些。一个主要的问题是生成代码的所有权。根据 AI 服务的条款,你使用的代码可能仍然部分归 AI 提供商所有或受其影响。这可能会在知识产权是关键资产的行业中产生法律或合规问题。此外,还有担心你的代码——或者从它中提取的模式——可能被用来训练未来的 AI 模型。这可能会无意中将专有算法、业务逻辑或敏感方法暴露给外部实体。虽然这可能不是测试(通常更通用且不太机密)的主要问题,但它对生产代码来说可能是一个重大风险,因为组织的知识产权和安全受到威胁。在撰写本文时,已有几个法律判决确立,AI 生成的内容不能获得版权*[3][4]*。现在,我们将为我们的测试创建实际的提示。
测试 Products API
我们在这本书中使用的 Products API 是这种新测试策略的一个非常好的用例。让我们看看为该 API 生成测试的提示。
让我们先创建一个测试来验证使用 PUT
方法创建新产品:
Assuming the role of a Senior Software Engineer in charge of implementing a set of APIs to support the Products resource
Generate a JUnit 5 test case for the "Create or Update Product" API endpoint in a Spring Boot application.
The endpoint uses the HTTP PUT method at the path /api/products/{productId}, where {productId} is a path variable validated with the format AA99999.
The request body is a ProductInput object, and the response is a ProductOutput object.
The test should cover two scenarios:
1 - Creating a New Product: When a new productId is provided, the API should create a new product and return HTTP status 201 Created. Verify that the response body contains the expected ProductOutput.
2 - Updating an Existing Product: When an existing productId is provided, the API should update the product and return HTTP status 200 OK. Verify that the response body contains the updated ProductOutput.
Assume a running instance of this API running on http:8080
Include at least assertions for:
- HTTP status codes (201 Created and 200 OK)
- Response headers (if applicable)
- Response body content
- Validation that the productId follows the format AA99999
Structure the test methods clearly, using descriptive names for each scenario.
Product is defined by the following classes - assume the expected Get and Set methods are present.
public class Product {
private String name;
private String sku;
private String description;
private BigDecimal price;
public Product() {
super();
}
public Product(String name, String sku, String description,
BigDecimal price) {
this.name = name;
this.sku = sku;
this.description = description;
this.price = price;
}
}
public record ProductDescriptionInput(@NotBlank
@Size(min = 10, max = 255)
String description) {
}
public record ProductInput(
@NotBlank
@Size(min = 3, max = 255)
@JsonProperty("name")
String name,
@NotBlank
@Size(min = 10, max = 255)
@JsonProperty("description")
String description,
@NotNull
@Positive
@JsonProperty("price")
BigDecimal price) {
public Product toProduct(String productId) {
return new Product(name(),
productId,
description(),
price());
}
}
public record ProductOutput(String name, String sku, String description,
BigDecimal price) {
}
让我们更详细地看看这个提示的每个部分:
首先,我们定义 LLM 的视角。这有助于确定 LLM 应扮演的角色,并且它确实会影响答案。有时,当你告诉 LLM 以资深或初级专业人士的身份行事时,这种影响在生成的代码中是明显的:
"Assuming the role of a Senior Software Engineer in charge of implementing a set of APIs to support the Products resource"
然后,给出一个清晰的请求:
"Generate a JUnit 5 test case for the "Create or Update Product" API endpoint in a Spring Boot application."
"The test should cover two scenarios:… "
LLM 的输出受到你想要的结果的强烈影响。
接下来,尽可能清晰地解释 API 的行为。这很重要,因为如果底层行为不清楚,LLM 就会尝试填补空白,有时会基于无效的假设。你添加的细节越多,创建的测试用例就越好:
"The endpoint uses the HTTP PUT method at the path /api/products/{productId}, …."
这些细节的一部分是定义被测试数据的代码。要么展示使用的数据结构,要么展示预期的 HTTP 响应,但必须以某种形式提供明确的定义。
最后,添加任何我们希望测试验证的具体断言:
"Include at least assertions for:
- HTTP status codes (201 Created and 200 OK)
- Response headers (if applicable) …"
这个提示的结果是以下代码和 pom.xml
依赖元素:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.reactive.server.WebTestClient;
import java.math.BigDecimal;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ProductsApiControllerTest {
@Autowired
private WebTestClient webTestClient;
@Test
void shouldCreateNewProduct_whenValidInputProvided() {
// Arrange
String productId = "AA12345";
ProductInput productInput = new ProductInput(
"Test Product",
"A detailed description for the product",
BigDecimal.valueOf(99.99)
);
// Act & Assert
webTestClient.put()
.uri("/api/products/{productId}", productId)
.bodyValue(productInput)
.exchange()
.expectStatus().isCreated()
.expectHeader().exists("Location") // Optional: Check if Location header exists
.expectBody(ProductOutput.class)
.value(response -> {
// Assert response body content
assert response.name().equals(productInput.name());
assert response.sku().equals(productId);
assert response.description().equals(
productInput.description());
assert response.price().compareTo(
productInput.price()) == 0;
});
}
@Test
void shouldUpdateExistingProduct_whenValidInputProvided() {
// Arrange
String productId = "AA67890"; // Assume this product already exists in the system
ProductInput productInput = new ProductInput(
"Updated Product",
"An updated description for the product",
BigDecimal.valueOf(149.99)
);
// Act & Assert
webTestClient.put()
.uri("/api/products/{productId}", productId)
.bodyValue(productInput)
.exchange()
.expectStatus().isOk()
.expectBody(ProductOutput.class)
.value(response -> {
// Assert response body content
assert response.name().equals(productInput.name());
assert response.sku().equals(productId);
assert response.description().equals(
productInput.description());
assert response.price().compareTo(
productInput.price()) == 0;
});
}
@Test
void shouldRejectInvalidProductIdFormat() {
// Arrange
String invalidProductId = "INVALID123";
ProductInput productInput = new ProductInput(
"Invalid Product",
"An invalid description",
BigDecimal.valueOf(49.99)
);
// Act & Assert
webTestClient.put()
.uri("/api/products/{productId}", invalidProductId)
.bodyValue(productInput)
.exchange()
.expectStatus().isBadRequest(); // Assuming the API enforces validation on productId
}
}
这段代码本身并不复杂或难以编写,但它很耗时,大多数软件工程师都不太喜欢编写它。此外,使用 LLM 创建测试为这个过程增加了许多灵活性。例如,如果你想以不同的语言创建这个测试——例如,在 Go 中——我们只需要更改预期的结果来获得一个新版本。
"Generate a Go test case for the "Create or Update Product" API endpoint in a Spring Boot application."
尝试玩转代码生成选项。
在某些项目中,甚至有要求自动测试使用与原始代码不同的语言编写。可能没有真正的技术原因要求这样做,因为 HTTP 客户端与另一个完全一样,但它为项目中的非技术人员提供了额外的心理安慰,即有这一额外的隔离层。
一个更复杂的 API 进行测试
在之前的练习中,我们查看了一个 API 的测试,我们对其源代码有完全的访问权限,并且是从头开始开发该 API 的。此外,示例 API 故意很简单。但是,许多软件工程师面临另一个常见的情况:一个已经投入生产的 API,可用的自动化测试不多,更复杂,并返回复杂的响应。让我们看看这样一个场景。
对于本节,我们将使用 HAPI FHIR API 并为其编写一些测试。
HAPI FHIR 是 HL7 FHIR 标准在医疗保健互操作性方面的完整 Java 实现。我们选择这个例子是因为 API 很复杂,有可用的公共测试实现,并且在医疗保健市场中得到了广泛的应用。这个实现被全球许多组织使用,包括大型医院综合体、医疗保健组织和政府卫生机构。但从测试的角度来看,对我们来说有趣的是这些 API 的复杂性。在搜索的响应中收到数千行 JSON 并不罕见,这使我们能够创建更细致的提示和相应的测试。
要了解更多关于 HAPI FHIR 和 HL7 FHIR 的信息,请参阅以下链接:
由于医疗保健需求通常对每个组织都是独特的,并且依赖于许多地方性法规,因此任何 HAPI FHIR 实现都需要高度定制。在特定实现中找到数千行自定义代码并不罕见。在定制如此复杂的 API 时,有一套强大的测试至关重要。你不想破坏任何 HAPI FHIR 合约。
此外,HAPI FHIR 作为我们的测试示例的一个巨大优势是公共测试服务器的可用性:hapi.fhir.org/baseR4
在阅读本节之前,不需要事先了解 HAPI FHIR API。我们只关注查看 API 的行为,并准备测试以验证这种行为不会改变。以下是我们将使用的几个 HAPI FHIR 概念:
耐心:HAPI FHIR 是一个医疗保健系统;因此,PATIENT 是其中的一个一等元素。我们将编写一些测试来验证搜索患者时返回的元素是我们想要的。
资源类型 – BUNDLE:在 HAPI FHIR 中,所有搜索结果都是捆绑包。这只是用来定义一组数据的名称,但我们将验证此行为作为我们测试的一部分。
考虑到这一点,让我们假设您是一家大型医院 HAPI FHIR 支持团队的软件工程师,您刚刚接到了定制 HAPI FHIR API 的任务,以向Patient
记录添加一个新元素。这个新元素仅用于这家医院的系统。假设没有针对 Patient API 的测试用例。您能做什么?
好吧,您可以从查看源代码开始,尝试找到所有相关的类,但这是一项耗时的工作,会延迟测试的生成。可能需要更实际的解决方案。
因此,首先查看 API 响应并准备测试,以确保您的更改不会破坏当前合约。
这里是一个返回系统中所有患者的GET
请求,一次一个。所有数据都是合成的,与任何真实个人无关:
https://hapi.fhir.org/baseR4/Patient?_format=json&_count=1
让我们详细检查我们收到的响应(您在运行此示例时将不会得到相同的数据,但格式和结构将是相同的):
{ "resourceType": "Bundle",
"id": "241fc9a1-3d08-4c1c-95a9-6ac315d178a8",
"meta": {
"lastUpdated": "2024-11-18T13:05:22.536+00:00"
},
"type": "searchset",
"link": [
{
"relation": "self",
"url": "https://hapi.fhir.org/baseR4/Patient?_count=1&_format=json"
},
{
"relation": "next",
"url": "https://hapi.fhir.org/baseR4?_getpages=241fc9a1-3d08-4c1c-95a9-6ac315d178a8&_getpagesoffset=1&_count=1&_format=json&_pretty=true&_bundletype=searchset"
}
],
"entry": [
{
"fullUrl": "https://hapi.fhir.org/baseR4/Patient/596484",
"resource": {
"resourceType": "Patient",
"id": "596484",
"meta": {
"versionId": "1",
"lastUpdated": "2020-02-01T17:42:02.000+00:00",
"source": "#McyYCIRVGrcmfKSf"
},
"text": {
"status": "generated",
"div": "<div xmlns…div>"
},
"name": [
{
"family": "DelGrosso",
"given": [
"Joe"
]
}
],
"telecom": [
{
"system": "phone",
"value": "1(555)123-4567",
"use": "home"
}
],
"gender": "male",
"birthDate": "1985-09-11",
"address": [
{
"use": "home",
"line": [
"155 Quick Rd"
],
"city": "Fun City",
"state": "NY",
"postalCode": "10101",
"country": "US"
}
]
},
"search": {
"mode": "match"
}
}
]
}
这个结果已经超过 70 行,是我们 API 分析和设计测试的好起点。
让我们看看结果。我们可以看到GET
函数的结果有一个resourceType
,在这种情况下是Bundle
。Type
是searchset
。
有一个链接数组(“链接”)和一个条目数组(“条目”),包含实际患者的数据。
好的,这是一个开始。
准备测试提示
记住,您的目标是修改代码,而不破坏当前状态,因此首先明确您想通过测试验证的内容——例如,“测试所有GET
搜索将返回有效的分页 JSON。”现在,让我们编写一个提示来生成我们的第一个测试。以下是 ChatGPT 4 的结果:
Assuming the role of a Senior Software Engineer working on a HAPI FHIR customization project.
Prepare a test case that will make a call to the following URL: https://hapi.fhir.org/baseR4/Patient?_format=json&_count=1
Make sure to validate that the response is in a JSON format and contains:
- a single 'resourceType' of 'Bundle'
- a single 'type' of 'searchset'
- a single 'id' in the format of an UUID
Use JAVA Junit 5
Here is a sample result set. The actual values of each element will vary from each call.
{
"resourceType": "Bundle",
"id": "241fc9a1-3d08-4c1c-95a9-6ac315d178a8",
"meta": {
"lastUpdated": "2024-11-18T13:05:22.536+00:00"
….
将整个 JSON 示例与提示粘贴。
结果是以下测试和pom.xml
更改:
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.UUID;
class HapiFhirTest {
private static final String HAPI_FHIR_URL =
"https://hapi.fhir.org/baseR4/Patient?_format=json&_count=1";
@Test
void testHapiFhirResponse() {
// Set up RestTemplate for HTTP call
RestTemplate restTemplate = new RestTemplate();
// Make the HTTP GET call
ResponseEntity<String> response = restTemplate.getForEntity(
HAPI_FHIR_URL, String.class);
// Assert the response is not null and has a 200 status code
assertNotNull(response);
assertEquals(200, response.getStatusCodeValue());
// Assert the response body is not null
String responseBody = response.getBody();
assertNotNull(responseBody);
try {
// Parse the response body as JSON
ObjectMapper objectMapper = new ObjectMapper();
JsonNode jsonNode = objectMapper.readTree(responseBody);
// Assert 'resourceType' is present and equals 'Bundle'
assertTrue(jsonNode.has("resourceType"));
assertEquals("Bundle", jsonNode.get("resourceType").asText());
// Assert 'type' is present and equals 'searchset'
assertTrue(jsonNode.has("type"));
assertEquals("searchset", jsonNode.get("type").asText());
// Assert 'id' is present and is a valid UUID
assertTrue(jsonNode.has("id"));
String id = jsonNode.get("id").asText();
assertTrue(isValidUUID(id));
} catch (Exception e) {
fail("Exception occurred while processing the response: " +
e.getMessage());
}
}
/**
* Utility method to validate if a string is a valid UUID.
*
* @param uuid the string to validate
* @return true if valid UUID, false otherwise
*/
private boolean isValidUUID(String uuid) {
try {
UUID.fromString(uuid);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
}
With the accompanying dependencies in pom.xml
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<version>5.3.30</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.2</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
</dependency>
此代码立即运行,无需任何修改。这与前一代 LLM 的情况不同。如果您使用的是较旧的模型,例如 ChatGPT 3.5,例如,您可能需要手动调整代码。
整个测试由 LLM 在不到一分钟内完成,实际上快速编写和查看结果非常有趣——与手动编写测试完全不同的体验。当然,测试可以改进。例如,它可以改为从列表或属性文件中获取要测试的 URL,以增加灵活性,但代码的核心是现成的,可以立即使用。
现在,让我们继续进行更复杂的测试类型,看看我们的验证能走多远。
验证更复杂的行为
查看 API 的响应,我们可以看到“链接”数组在第一页上有两个条目:“self
”和“next
”。
"link": [
{
"relation": "self",
"url": "https://hapi.fhir.org/baseR4/Patient?_count=1&_format=json"
},
{
"relation": "next",
"url": "https://hapi.fhir.org/..."
}
],
一个好的测试是验证响应始终包含self
链接。让我们扩展提示以包括这一点。
这里是新的提示:
Assuming the role of a Senior Software Engineer working on a HAPI FHIR customization project.
Prepare a test case that will make a call to the following URL: https://hapi.fhir.org/baseR4/Patient?_format=json&_count=1
Make sure to validate that the response is in a JSON format and contains:
- a single 'resourceType' of 'Bundle'
- a single 'type' of 'searchset'
- a single 'id' in the format of an UUID
Validate that array "link" is present and that there is one element with "relation = self"
Use JAVA Junit 5
…
这将为我们的测试添加以下代码:
// Validate link array
assertTrue(jsonNode.has("link"), "Response does not contain 'link'");
JsonNode links = jsonNode.get("link");
assertTrue(links.isArray(), "link is not an array");
boolean hasSelfLink = false;
for (JsonNode link : links) {
if (link.has("relation") && "self".equals(
link.get("relation").asText())) {
hasSelfLink = true;
break;
}
}
assertTrue(hasSelfLink, "No link with 'relation' = 'self'");
再次强调,代码并不复杂,但它已经显示出难度增加的迹象。手动编写这个代码并不难,但需要一些时间和注意力。LLM 可以在 30 秒或更短的时间内完成它。
现在,让我们增加更多的复杂性。这是一个分页练习,所以让我们要求代码跟随几页的next
链接并验证该页面。然后,验证结果中的每个条目是否唯一,使用entry
数组中的fullUrl
作为候选键。
Assuming the role of a Senior Software Engineer working on a HAPI FHIR customization project.
Prepare a test case that will make a call to the following URL: https://hapi.fhir.org/baseR4/Patient?_format=json&_count=1
Make sure to validate that the response is in a JSON format and contains:
- a single 'resourceType' of 'Bundle'
- a single 'type' of 'searchset'
- an single 'id' in the format of an UUID
Validate that array "link" is present and that there is one element with "relation = self"
Now, follow the "next" link for at most 5 pages or until the last page. Validate each page.
In each "entry" there is a "fullUrl" attribute. Threat it as a key and validate that it is present in every "entry".
Also, make sure that the entries are unique and there is no repetition.
Use JAVA Junit 5
结果开始看起来更加复杂和有趣。
public class HapiFhirPaginationTest {
@Test
void testHapiFhirPatientPagination() throws Exception {
String baseUrl =
"https://hapi.fhir.org/baseR4/Patient?_format=json&_count=1";
ObjectMapper mapper = new ObjectMapper();
String nextUrl = baseUrl;
int pageCount = 0;
Set<String> uniqueFullUrls = new HashSet<>();
while (nextUrl != null && pageCount < 5) {
HttpURLConnection connection = createConnection(nextUrl);
int responseCode = connection.getResponseCode();
assertEquals(200, responseCode,
"Expected HTTP status code 200");
InputStream responseStream = connection.getInputStream();
JsonNode response = mapper.readTree(responseStream);
validatePage(response, uniqueFullUrls);
nextUrl = getNextLink(response);
pageCount++;
connection.disconnect();
}
}
private HttpURLConnection createConnection(String urlString) throws Exception {
URL url = new URL(urlString);
HttpURLConnection connection = (HttpURLConnection)
url.openConnection();
connection.setRequestMethod("GET");
connection.setRequestProperty("Accept", "application/json");
return connection;
}
private void validatePage(JsonNode response,
Set<String> uniqueFullUrls) {
// Validate resourceType
assertTrue(response.has("resourceType"),
"Response does not contain 'resourceType'");
assertEquals("Bundle", response.get("resourceType").asText(),
"resourceType is not 'Bundle'");
// Validate id is in UUID format
assertTrue(response.has("id"), "Response does not contain 'id'");
String id = response.get("id").asText();
assertTrue(isValidUUID(id), "id is not a valid UUID");
// Validate type
assertTrue(response.has("type"),
"Response does not contain 'type'");
assertEquals("searchset", response.get("type").asText(),
"type is not 'searchset'");
// Validate link array
assertTrue(response.has("link"),
"Response does not contain 'link'");
JsonNode links = response.get("link");
assertTrue(links.isArray(), "link is not an array");
boolean hasSelfLink = false;
for (JsonNode link : links) {
if (link.has("relation") && "self".equals(link.get(
"relation").asText())) {
hasSelfLink = true;
break;
}
}
assertTrue(hasSelfLink, "No link with 'relation' = 'self'");
// Validate entries
if (response.has("entry")) {
JsonNode entries = response.get("entry");
for (JsonNode entry : entries) {
assertTrue(entry.has("fullUrl"),
"Entry does not contain 'fullUrl'");
String fullUrl = entry.get("fullUrl").asText();
// Ensure fullUrl is unique
assertTrue(uniqueFullUrls.add(fullUrl),
"Duplicate fullUrl detected: " + fullUrl);
}
}
}
private String getNextLink(JsonNode response) {
JsonNode links = response.get("link");
for (JsonNode link : links) {
if (link.has("relation") && "next".equals(link.get(
"relation").asText())) {
return link.get("url").asText();
}
}
return null;
}
private boolean isValidUUID(String id) {
try {
UUID.fromString(id);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
}
测试现在变得更加复杂。看看一些辅助函数是如何创建的(isValidUUID()
,createConnection()
)。编写这个测试会花费更长的时间和更高的维护成本。但是,使用 LLM,整个测试只需几分钟就可以运行。
我希望您现在已经看到了这个模式。随着 API 变得更加复杂,测试也在不断发展。LLM 提示本身现在存储在 Git 仓库中,并且是代码库的组成部分,到了可以将其视为软件文档的一部分的程度。
摘要
生成式 AI 的引入具有重塑软件开发领域,特别是在测试领域的潜力。通过自动化测试创建、简化复杂流程和增强适应性,生成式 AI 有望将测试转变为开发生命周期中更高效、更易于访问和成本效益更高的部分。
通过本章讨论的策略和示例,我们希望您已经获得了探索和扩展 API 测试的技能和灵感。
测试是在我们 API 的开发和维护过程中验证行为的一种工具,但对于一个真正健壮的服务,我们需要在生产中监控这些行为。这就是我们在下一章将要探讨的内容。
进一步阅读
法律裁决:AI 生成艺术与版权 :
www.reuters.com/legal/ai-generated-art-cannot-receive-copyrights-us-court-says-2023-08-21/
美国版权局对 AI 提示的看法 :
www.theverge.com/news/602096/copyright-office-says-ai-prompting-doesnt-deserve-copyright-protection
HAPI FHIR:HL7 FHIR 的开源实现 :
hapifhir.io/
HL7 FHIR 官方规范
hl7.org/fhir/
第三部分
部署和性能
本部分将指导您如何部署和扩展您的 RESTful API,以确保它们在实际环境中表现良好。您将学习如何监控和追踪您的 API 以实现更好的可观察性,通过虚拟线程和负载测试等技术优化性能,探索用于 API 开发的替代 Java 框架,并使用容器和云平台实施实用的部署策略。
本部分将涵盖以下章节:
第九章 ,监控和可观察性
第十章 ,扩展和性能优化技术
第十一章 ,构建 RESTful API 的替代 Java 框架
第十二章 ,部署 API
第九章:监控和可观察性
监控和可观察性对于确保 RESTful 服务可靠、高效和安全地运行至关重要。在分布式系统中,请求通常需要通过多个服务和组件,因此实施适当的可观察性实践是至关重要的。没有这些实践,诊断性能瓶颈、识别错误的根本原因以及优化服务行为将变得极其困难。
在本章中,我们将指导你了解实现 RESTful 服务有效可观察性的基本实践和工具。我们将从讨论日志记录最佳实践开始,解释结构化日志和关联 ID 如何简化故障排除。然后我们将深入探讨分布式追踪,展示traceId
、spanId
和parentSpanId
等追踪信息如何帮助映射请求在多个服务间流动的生命周期。
我们将介绍Micrometer 追踪,这是一个强大的可观察性框架,集成在 Spring Boot 3.x 中,它能够自动对应用程序进行仪器化以捕获追踪数据。此外,我们还将探索 OpenTelemetry,这是一个供应商中立的框架,通过收集和关联日志、指标和追踪来扩展可观察性,以获得分布式系统的整体视图。到本章结束时,你将了解如何有效地实现日志记录和追踪,配置 Spring Boot 应用程序以实现可观察性,并在 Zipkin 和 Jaeger 等工具中可视化你的数据。
本章提供了实际示例、逐步指导和最佳实践,以确保你可以成功监控和调试你的 RESTful 服务。
在本章中,我们将涵盖以下主题:
在 REST API 中日志记录的重要性
API 故障排除的日志记录最佳实践
使用 SLF4J 的日志记录基础
实现中央日志过滤器
在分布式系统中实现服务追踪
使用 Micrometer 实现追踪
追踪数据的指标
OpenTelemetry 用于监控和可观察性
端到端可观察性的最佳实践
技术要求
在本章中,我们将增强现有的 API——产品 API 和订单管理 API,以便能够追踪它们之间的请求。为了能够跟随书中的示例代码进行操作,你应该具备以下条件:
对 Java 语言和平台有中级了解
至少对 Spring Boot 或类似框架有基本了解
安装了 Java 21 和 Maven 3.9.0
安装了 Docker 27.3.1 或更高版本
你可以在 GitHub 上访问本章的代码:github.com/PacktPublishing/Mastering-RESTful-Web-Services-with-Java/tree/main/chapter9
。
在 REST API 中日志记录的重要性
日志是任何系统中可观察性的基础元素。日志基本上是记录应用程序运行期间发生的事件的记录。它们是在任何环境中尝试解决任何问题时信息的第一来源,尤其是在生产环境中。
日志充当系统的记忆,提供有关发生错误或某些过程如何行为的见解。对于 REST API,日志可以显示每个请求的路径,这对于理解失败或缓慢的性能至关重要。
例如,假设您的 API 向用户返回了一个500
Internal Server Error
。没有日志,您将无法知道错误的原因。然而,有了日志,您可以看到数据库查询失败是因为服务器连接耗尽,这有助于您解决问题。
在接下来的章节中,我们将介绍一种有效的日志设计,当需要故障排除时,这将显著增强我们的应用程序。
常见日志陷阱
尽管日志的重要性不言而喻,但许多开发者在使用日志方面都感到困难。常见的错误包括:
过度记录日志:记录过多的信息可能会使得找到关键细节变得困难。
记录日志不足:日志记录不足可能会遗漏故障排除所需的关键数据。
记录敏感信息:错误地记录诸如用户密码或信用卡号码等信息,这些信息由于安全和合规性考虑(例如,GDPR 或 PCI DSS)绝不应该发生。
在记录足够的信息以进行故障排除和不过度占用系统的不必要数据之间需要找到平衡。
有效的日志设计
正确设计日志对于它们的有用性至关重要。每个日志条目都应该包含相关的元数据:
时间戳:日志条目创建的时间。
日志级别:日志的严重性(
INFO
,DEBUG
,ERROR
)。服务名称:生成日志的服务名称。
关联 ID:一个唯一的 ID,允许您跟踪通过多个服务(下面将详细介绍)的请求。
例如,一个结构良好的 JSON 日志条目可能看起来像这样:
{
"timestamp": "2024-10-23T10:20:30Z",
"level": "ERROR",
"service": "user-service",
"correlationId": "abc123",
"message": "Database connection timed out"
}
关联 ID
在微服务中,一个请求可能会通过多个服务。为了跟踪整个请求路径,将关联 ID 附加到每个请求是必不可少的。关联 ID 是一个唯一标识符,它在请求通过系统时保持不变。这允许您关联来自不同服务的日志,以查看单个请求是如何从头到尾处理的。例如,用户请求检索个人资料信息可能通过 API 网关,然后击中身份验证服务,最后查询用户数据库。使用关联 ID,您可以跟踪所有涉及服务中的每个步骤。Spring 使生成和传播关联 ID 变得简单。您可以在请求开始时生成关联 ID,并通过 HTTP 头在服务之间传递它。这将帮助您诊断服务链中出现问题的位置。
接下来,我们将学习最佳日志记录实践,了解不同的日志级别及其结构,以及应该和不应该记录的内容,以避免安全问题。
API 故障排除的日志最佳实践
有效的日志记录对于故障排除至关重要,尤其是在与分布式系统或云原生架构一起工作时。以下是一些最佳实践,以确保您的日志策略稳健。
选择合适的日志级别
应在适当的日志级别写入日志,这表示事件的严重性:
TRACE
:用于非常细粒度的细节,主要用于调试低级操作,如记录循环的内部状态、方法入口/出口点或组件之间的详细交互。由于在此级别生成的日志量巨大,因此在生产环境中应关闭。DEBUG
:用于帮助调试问题的低级信息,例如 HTTP 请求的详细信息。日志提供详细的信息,有助于开发或调试期间,专注于特定于应用程序的逻辑或操作。此外,由于在此级别生成的日志量巨大,因此应在生产环境中关闭此级别。INFO
:用于通用应用程序流程信息,例如当服务启动或关闭时。这些日志比DEBUG
或TRACE
更简洁,通常在生产环境中启用。WARN
:表示某些不一定是错误的不寻常情况。例如,服务可能会暂时耗尽资源但恢复。这些日志是对潜在未来问题的警告。例如:ERROR
:用于出现问题时,如抛出异常或数据库连接中的关键故障。这些日志通常表明系统需要关注或干预。FATAL
:表示导致应用程序或服务崩溃或变得不可用的关键错误。这些日志非常罕见,表明需要立即关注的严重问题。请注意,此级别并非在所有库(如 SLF4J 或 Logback)中都存在,通常由ERROR
级别表示;然而,它在 Log4J 和 Log4J2 日志库中存在,本章节不会涉及。
在 REST API 中,由于凭据错误而失败的登录尝试可能会记录在WARN
级别:
{
"timestamp": "2024-10-23T11:15:30Z",
"level": "WARN",
"service": "auth-service",
"message": "Failed login attempt for user john.doe@example.com",
"error": "AuthenticationException"
}
同时,由于数据库故障导致整个服务失败的情况应该记录在ERROR
级别,如果可用则记录为FATAL
:
{
"timestamp": "2024-10-23T11:20:30Z",
"level": "ERROR",
"service": "user-service",
"message": "Failed to connect to database",
"error": "TimeoutException" }
选择合适的级别可以确保你在故障排除时能够快速过滤掉非关键日志。
结构化日志
结构化日志是指以一致、机器可读的格式进行日志记录,例如 JSON。这使得日志可以轻松地被日志工具(如 ELK Stack 或 Splunk)解析和查询,使得过滤、聚合和分析日志变得更加容易。
而不是记录这样的简单消息:
User john.doe@example.com failed to log in
你应该以结构化格式记录事件:
{
"timestamp": "2024-10-23T12:00:00Z",
"level": "WARN",
"service": "auth-service",
"user": "john.doe@example.com",
"event": "login-failure",
"reason": "incorrect-password"
}
现在,你可以轻松地搜索所有登录失败或按user
字段分组。
避免在日志中包含敏感数据
像密码、信用卡号码或个人标识符这样的敏感信息绝不应该被记录。如果这些数据意外地在日志中暴露,可能会导致严重的安全漏洞。
例如,如果登录尝试失败,记录用户名是可以的,但绝不能记录密码。这样的日志消息:
{
"timestamp": "2024-10-23T12:05:00Z",
"level": "ERROR",
"message": "Login failed for user john.doe@example.com with password 'secretpassword'"
}
是危险的。相反,记录类似以下内容:
{
"timestamp": "2024-10-23T12:05:00Z",
"level": "ERROR",
"message": "Login failed for user john.doe@example.com"
}
你可以使用 Jackson 的@JsonIgnore
或@JsonProperty(access = Access.WRITE_ONLY)
注解来防止敏感数据被序列化到日志中。
Jackson 是一个广泛使用的 Java 库,用于处理 JSON 数据。它提供了将 Java 对象序列化为 JSON 和将 JSON 反序列化为 Java 对象的功能,使其在数据通常以 JSON 格式交换的 RESTful Web 服务中成为一项关键工具。
在 Spring Boot 应用程序中,Jackson 是默认的 JSON 处理器,通常用于自动转换请求和响应负载,使 API 交互无缝。
它允许开发者使用@JsonIgnore
、@JsonProperty
和@JsonInclude
等注解自定义 JSON 输出,确保只暴露必要的字段,同时排除敏感或不必要的数据。
这在记录请求或响应对象时尤其有用,因为它确保敏感信息(如密码或信用卡详情)不会在日志条目中暴露。
何时使用每个注解?
@JsonIgnore
:当你想防止字段永远包含在序列化输出中,例如响应和日志,以及从请求中反序列化的输入时使用。@JsonProperty(access = Access.WRITE_ONLY)
注解:当需要接受字段作为输入但希望将其排除在所有序列化输出之外时使用,这使得它适用于在日志记录或 API 响应中应保持私有的字段(例如密码)。
让我们通过一个示例来演示如何在 RESTful 服务中使用这些注解。
假设你有一个 User
类,包含 username
、email
和 password
等字段。当记录这个 User
对象时,我们希望确保密码字段不包含在序列化输出中。
完全排除字段在序列化和反序列化过程中。
@JsonIgnore
注解完全排除一个字段在反序列化和序列化过程中,这意味着它将完全不会包含在输入或输出 JSON 中。
当你在字段上使用 @JsonIgnore
注解时,Jackson 在序列化(将对象转换为 JSON)和反序列化(将 JSON 转换为对象)过程中都会完全忽略该字段。这意味着如果你用 @JsonIgnore
标记了一个字段,Jackson 将不会将其包含在输出 JSON 中,也不会允许它在 JSON 请求体中作为输入。
当你想确保敏感信息(例如密码或令牌)在任何序列化输出中都不会暴露时,这很有用,包括日志。
import com.fasterxml.jackson.annotation.JsonIgnore;
public class User {
private String username;
private String email;
@JsonIgnore // This will prevent the password from being
//serialized or deserialized into JSON
private String password;
// Constructors, getters, setters omitted for brevity
}
使用这种设置,如果你使用 Jackson 对 User 对象进行序列化记录,密码字段将被省略:
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class UserService {
private static final Logger logger = LoggerFactory.getLogger(
UserService.class);
private static final ObjectMapper objectMapper = new ObjectMapper();
public void logUserDetails(User user) {
try {
String userJson = objectMapper.writeValueAsString(user);
logger.info("User details: {}", userJson);
} catch (Exception e) {
logger.error("Error serializing user details", e);
}
}
}
输出日志(密码被排除) :
{ "username": "john.doe",
"email": "john.doe@example.com" }
在这个例子中,由于使用了 @JsonIgnore
注解,password
字段完全被省略在日志输出之外。
现在你已经知道了如何排除字段在序列化和反序列化过程中的情况,让我们看看如何允许一个字段从 JSON 输入中反序列化,但排除在序列化之外,例如在日志或 API 响应中。
允许数据输入反序列化,但排除输出序列化。
当你想一个字段被反序列化(例如,当从用户接收输入时)但不想被序列化(例如,当记录或发送数据作为响应时)时,@JsonProperty(access = Access.WRITE_ONLY)
注解非常有用。这在用户注册表单中的密码字段等字段中很常见:
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonProperty.Access;
public class User {
private String username;
private String email;
@JsonProperty(access = Access.WRITE_ONLY) // Allows input but
//prevents serialization into JSON
private String password;
// Constructors, getters, setters omitted for brevity
}
使用 @JsonProperty(access = Access.WRITE_ONLY)
注解,你仍然可以接受入站 JSON 请求中的 password
字段,但它将被排除在任何序列化输出之外,包括日志。
示例用法 :
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/users")
public class UserController {
private static final Logger logger = LoggerFactory.getLogger(
UserController.class);
private static final ObjectMapper objectMapper = new ObjectMapper();
@PostMapping("/register")
public String registerUser(@RequestBody User user) {
// Log user details without exposing password
try {
String userJson = objectMapper.writeValueAsString(user);
logger.info("Received user registration request: {}",
userJson);
} catch (Exception e) {
logger.error("Error serializing user details", e);
}
// Proceed with user registration logic
return "User registered successfully!";
}
}
当收到新的用户注册请求时,password
字段将可用于处理,但不会被记录。
入站请求 :
{
"username": "john.doe",
"email": "john.doe@example.com",
"password": "mysecretpassword"
}
输出日志(密码被排除) :
{
"username": "john.doe",
"email": "john.doe@example.com"
}
使用 @JsonProperty(access = Access.WRITE_ONLY)
注解,密码会被应用程序接收,但会从日志或其他序列化的 JSON 输出中排除,从而确保敏感数据得到保护。
通过有策略地使用这些注解,你可以控制日志中的敏感数据暴露,这是 RESTful API 安全最佳实践的重要组成部分。
现在,让我们回到日志记录的最佳实践。
捕获上下文信息
要使日志更有用,请包括每个请求的上下文信息,例如:
HTTP 方法(
GET
,POST
等)端点(例如,
/api/v1/users/123
)响应状态(
200
,404
,500
等)请求头和有效负载(但请注意排除敏感数据)
这些信息将帮助您更好地了解 API 请求期间发生的情况。
例如,一个请求日志可能看起来像这样:
{ "timestamp": "2024-10-23T12:10:00Z",
"level": "INFO", "service": "user-service",
"method": "GET",
"endpoint": "/api/v1/users/123",
"status": 200 }
通过捕获此类详细信息,您可以关联特定请求的问题,并快速确定问题出在哪里。
接下来,我们将深入了解市场上最著名的日志库之一,SLF4J,以及如何从我们的应用程序中创建日志。
使用 SLF4J 进行日志记录基础
本节介绍了如何使用 SLF4J 将日志记录点添加到自己的 Java 代码中。这对于您在整个应用程序中添加记录器以生成日志至关重要,这些日志可以帮助您在部署时调试它,尤其是在生产环境中。
在 Spring Boot 中,SLF4J(Java 简单日志门面)通常用作可以与不同日志框架(如 Logback、Log4j2 等)一起工作的日志 API。Spring Boot 默认使用 SLF4J 并将其无缝集成,因此我们所需做的就是注入记录器并开始记录消息。
此外,如果您不使用 Spring Boot,您只需将 SLF4J 依赖项添加到您的 Maven POM 依赖项文件或 Gradle 依赖项文件中,以便在使用框架时如果尚未包含它,就可以使用它。
让我们从如何使用 SLF4J 记录 Spring Boot 应用程序的简单示例开始。以下是一个使用 SLF4J 在不同日志级别(INFO
,WARN
,ERROR
)记录消息的用户创建服务:
@Service
public class UserService {
// Initialize the logger
private static final Logger logger = LoggerFactory.getLogger(
UserService.class);
public void createUser(String username) {
logger.info("Creating a new user with username: {}", username);
try {
// Simulate some business logic
if (username == null) {
throw new IllegalArgumentException(
"Username cannot be null");
}
// Simulate a successful operation
logger.info("User {} created successfully", username);
} catch (Exception e) {
// Log the exception as an error
logger.error("Error creating user: {}", e.getMessage(), e);
}
}
}
在此示例中:
我们使用
logger.info()
记录信息消息。我们使用
logger.error()
记录错误,包括异常堆栈跟踪。LoggerFactory.getLogger(UserService.class)
为UserService
类创建一个特定的记录器。
对于成功的用户创建,日志输出将如下所示:
2024-10-23T12:00:00.123 INFO [UserService] Creating a new user with username: john_doe
2024-10-23T12:00:00.456 INFO [UserService] User john_doe created successfully
或者,对于失败的用户创建,例如尝试创建一个 null 用户,输出将是:
2024-10-23T12:01:00.123 INFO [UserService] Creating a new user with username: null
2024-10-23T12:01:00.125 ERROR [UserService] Error creating user: Username cannot be null
java.lang.IllegalArgumentException: Username cannot be null
at com.example.service.UserService.createUser(UserService.java:12)...
使用不同日志级别的 SLF4J 有助于组织和过滤日志消息,使其更容易调试和调试。
在下一节中,让我们看看如何通过实现中央日志过滤器来自动化应用程序中的一些日志记录,以减少在代码中到处都有大量日志的负担。
实现中央日志过滤器
为了提高 RESTful 应用程序的可观察性,我们可以实现一个中央日志组件,记录所有传入的 HTTP 请求和响应。过滤器是一个有效的选择,因为它允许您在请求到达控制器层之前拦截请求,使您能够在一个地方记录关键请求和响应细节。
在此示例中,我们将实现一个RequestLoggingFilter
,该过滤器记录传入的请求,遵循最佳实践,如结构化日志记录、添加关联 ID 和避免敏感信息。此过滤器将以结构化的 JSON 格式记录必要请求元数据,例如 HTTP 方法、URL、状态码和处理时间:
@Component
public class RequestLoggingFilter extends OncePerRequestFilter {
private static final Logger logger = LoggerFactory.getLogger(
RequestLoggingFilter.class);
public static final String X_CORRELATION_ID_HEADER =
"X-Correlation-Id";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// Generate or retrieve a unique correlation ID
String correlationId = request.getHeader(X_CORRELATION_ID_HEADER);
if (correlationId == null) {
correlationId = UUID.randomUUID().toString();
}
MDC.put("correlationId", correlationId);
// Capture the request start time
long startTime = System.currentTimeMillis();
try {
// Proceed with the request
filterChain.doFilter(request, response);
} finally {
// Capture the request end time
long duration = System.currentTimeMillis() - startTime;
// Log the structured request information
logRequestDetails(request, response, duration);
// Clean up MDC after the request
MDC.clear();
}
}
private void logRequestDetails(HttpServletRequest request,
HttpServletResponse response, long duration) {
// Extracting useful metadata for structured logging
String httpMethod = request.getMethod();
String requestUri = request.getRequestURI();
String queryString = request.getQueryString() !=
null ? "?" + request.getQueryString() : "";
String correlationId = MDC.get("correlationId");
int statusCode = response.getStatus();
// Structured JSON log example
try {
String logEntryJson = new ObjectMapper().writeValueAsString(
createLogEntry(correlationId, httpMethod,
requestUri + queryString, statusCode, duration)
);
logger.info(logEntryJson);
} catch (JsonProcessingException e) {
logger.error("Failed to convert log entry to JSON", e);
}
}
private Map<String, Object> createLogEntry(
String correlationId, String method, String url, int status,
long duration) {
Map<String, Object> logEntry = new HashMap<>();
logEntry.put("timestamp", Instant.now().toString());
logEntry.put("level", "INFO");
logEntry.put("correlationId", correlationId);
logEntry.put("method", method);
logEntry.put("url", url);
logEntry.put("status", status);
logEntry.put("duration", duration + "ms");
return logEntry;
}
}
那么,这个过滤器到底在做什么呢?
选择合适的日志级别:过滤器以
INFO
级别记录完整的请求,这对于一般应用程序流程信息是合适的。如果请求遇到错误,它可以在其他组件(如异常处理器)中以ERROR
级别记录。结构化日志记录:过滤器使用结构化日志记录以 JSON 格式记录信息,包括
correlationId
、method
、url
、status
和duration
等字段。结构化日志记录使得在集中式日志工具中进行解析、搜索和聚合变得更加容易。避免敏感数据:过滤器避免直接记录请求体,因为请求体可能包含敏感信息,如密码。如果需要,进一步的过滤可以排除或屏蔽头或查询参数中的敏感数据。
捕获上下文信息:过滤器捕获每个请求的相关元数据,包括 HTTP 方法、URL、状态码和持续时间。这为调试和性能分析提供了有价值的上下文。
使用关联 ID:过滤器生成一个关联 ID(如果尚未存在)并将其存储在**映射诊断上下文(MDC)**中。这确保了关联 ID 被添加到请求生命周期内的所有后续日志中,从而实现了跨服务的端到端跟踪。
映射诊断上下文(MDC)
**映射诊断上下文(MDC)**是 SLF4J(与 Logback)和 Log4j 等日志框架中的功能,允许开发者按线程存储和检索上下文信息。此上下文信息自动包含在日志消息中,使得跟踪应用程序不同部分的关联日志变得更加容易。
在此日志过滤器设置到位后,以下是一个日志条目可能的样子:
{
"duration": "157ms",
"method": "POST",
"level": "INFO",
"correlationId": "71ef4140-f3a6-488f-ba30-b2a31ac507df",
"url": "/orders",
"timestamp": "2024-12-09T18:08:00.719019511Z",
"status": 201
}
但为了将correlationId
传播到其他服务并启用跨多个服务的请求跟踪功能,我们需要在发送请求之前更新头信息,添加从correlationId
生成的新值。我们将使用BeanPostProcessor
来完成这项工作:
@Component
public class ProductsApiBeanPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessBeforeInitialization(Object bean,
String beanName) throws BeansException {
if (bean instanceof ProductsApi) {
ProductsApi productsApi = (ProductsApi) bean;
ApiClient apiClient = productsApi.getApiClient();
String correlationId = MDC.get("correlationId");
if (correlationId != null) {
apiClient.addDefaultHeader("X-Correlation-Id",
correlationId);
}
productsApi.setApiClient(apiClient);
}
return bean;
}
}
例如,在订单管理 API 中,在调用productsApi
到ProductsQueryUseCaseImpl
实现之前,我们将调用beanPostProcessor
以将请求头中设置的correlationId
更新到ApiClient
bean 中。
ApiClient
是由 OpenAPI 插件使用产品 API 规范生成的 REST 客户端,并将用于从订单管理 API 对产品 API 的每次调用。以下是该类的更新版本:
@Service
public class ProductsQueryUseCaseImpl implements ProductsQueryUseCase {
Logger logger = LoggerFactory.getLogger(
ProductsQueryUseCaseImpl.class);
private final ProductsApi productsApi;
private final ProductsApiBeanPostProcessor beanPostProcessor;
public ProductsQueryUseCaseImpl(ProductsApi productsApi,
ProductsApiBeanPostProcessor beanPostProcessor) {
this.productsApi = productsApi;
this.beanPostProcessor = beanPostProcessor;
}
@Override
public ProductDetails getProductById(String productId) {
try {
beanPostProcessor.postProcessBeforeInitialization(productsApi,
"productsApi");
ProductOutputDto product = productsApi.getProductById(
productId);
return new ProductDetails(product.getSku(),
product.getPrice());
} catch (Exception ex) {
logger.error("Error getting product with id {}", productId,
ex);
throw new EntityNotFoundException(
"Product not found with id " + productId);
}
}
}
这确保了correlationId
与每个服务请求一起传播。被调用服务的过滤器将读取correlationId
并将其包含在日志中,使您能够跨服务唯一跟踪请求。
可以向过滤器添加更多功能,以捕获更详细的日志信息:
记录特定头信息:捕获像
User-Agent
或Authorization
这样的头信息,但排除或屏蔽敏感细节。条件日志记录错误响应:修改过滤器以在
WARN
或ERROR
级别记录4xx
和5xx
响应,以便更容易地进行错误跟踪。错误处理:将此过滤器与全局异常处理器结合使用,以捕获和记录未处理的异常,利用关联 ID 将错误日志与其原始请求关联起来。
此日志过滤器实现了最佳实践,并在整个应用程序中创建了一致的、结构化的日志,这使得监控、故障排除和分析传入的 API 请求变得更加容易。
而要有效地跨多个服务跟踪请求,实现追踪是必不可少的。它有助于保持清晰的追踪并简化分布式系统中的故障排除。在下一节中,我们将探讨如何实现这一点。
在分布式系统中实现服务追踪
在分布式系统中,一个请求可能跨越多个服务,分布式追踪提供了对请求如何通过各种组件的可见性。
让我们先了解我们所说的分布式追踪是什么意思。
什么是分布式追踪?
分布式追踪允许您跟踪请求的生命周期,从一项服务流向另一项服务。这有助于您看到延迟或错误发生的位置。在追踪术语中,请求旅程中的每一步称为一个跨度,而追踪是与请求相关联的所有跨度的集合。
例如,想象一个请求进入您的系统以创建新用户。这个请求可能会影响到以下服务:
API 网关
认证服务
用户服务(用于在数据库中创建用户)
通知服务(用于发送欢迎邮件)
每个步骤都是一个跨度,所有跨度共同构成一个追踪。
分布式追踪工具,如 Zipkin 或 Jaeger,可以可视化追踪并突出显示导致延迟的服务或步骤。
接下来,我们将通过为每个请求添加一个追踪 ID 来了解每个追踪是如何唯一标识的。
使用追踪 ID 进行端到端请求跟踪
就像关联 ID 有助于日志一样,追踪 ID 是附加到每个请求的唯一标识符,允许进行端到端请求跟踪,您可以在多个服务中跟踪该请求。不同之处在于,追踪 ID 由追踪系统自动管理,并包括时间信息。
在 Spring 中,Micrometer Tracing 库为每个请求自动生成跟踪 ID,并在服务边界之间传播它们。这些 ID 包含在日志和跟踪系统中,允许您关联日志和跟踪,以便进行详细的故障排除。
在 Spring Boot 应用程序中,Micrometer Tracing 生成以下日志消息:
{
"timestamp": "2024-10-23T13:00:00Z",
"traceId": "b8e3fbe5cd34fbe5",
"spanId": "af18fbe5cd34ab23",
"level": "INFO",
"service": "user-service",
"message": "Created new user with ID 123"
}
traceId
帮助您将此事件与其他服务中的相关事件关联起来。
现在,让我们学习如何使用 Micrometer 实现跟踪功能。
使用 Micrometer 实现跟踪
随着 Spring Boot 3.x 的发布,Spring Cloud Sleuth 已被 Micrometer 跟踪替换以提供跟踪支持。Micrometer 跟踪与 Spring Boot 3.x 完全兼容,并为在应用程序中实现分布式跟踪提供了一种更现代、更灵活的方法。
在本节中,我们将介绍如何使用 Micrometer 跟踪在 Spring Boot 应用程序中实现分布式跟踪,使您能够跟踪跨服务请求,并深入了解其性能。
在 Spring Boot 中设置 Micrometer 跟踪
要使用 Micrometer 跟踪在 Spring Boot 应用程序中实现跟踪,请按照以下步骤操作:
- 添加微米级跟踪依赖 :
Micrometer 跟踪是 Micrometer 生态系统的一部分,并且与 Spring Boot 3.x 集成轻松。要在项目中启用 Micrometer 跟踪,请将必要的依赖项添加到您的pom.xml
中(如果使用 Maven):
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-observation</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
这些依赖项的用途如下:
micrometer-observation
: 提供核心 Observation API,作为指标、日志和跟踪的代理。它允许您一次性仪表化代码,并获得多个可观察性好处,专注于您想要观察的内容,而不是如何实现它。micrometer-tracing-bridge-brave
: 将 Micrometer Observation API 桥接到 Brave,启用分布式跟踪功能。此依赖项负责在服务边界之间创建和传播跟踪和跨度 ID,这就是将跟踪上下文添加到您的日志中的原因。spring-boot-starter-actuator
: 提供了生产就绪功能,有助于监控和管理您的应用程序。它包括健康检查、指标和其他操作数据的端点。此启动器在结合 Micrometer 依赖项时自动配置可观察性基础设施。
这些依赖项共同作用,在 Spring Boot 微服务架构中实现了全面的可观察性,包括指标、跟踪和健康监控。
- 配置 Micrometer 跟踪 :
添加这些依赖项后,Micrometer Tracing 将在 Spring Boot 中自动配置。默认情况下,Micrometer 将对您的 HTTP 请求进行仪表化,为每个传入请求生成跟踪 ID 和跨度 ID。这些 ID 将在服务边界之间传播。
要完全启用跟踪,您可能需要配置跟踪的导出方式。例如,要将跟踪导出到 Zipkin,请将以下配置添加到您的application.yml
中:
management:
tracing:
enabled: true
sampling:
probability: 1.0 # Enables 100% sampling of traces
zipkin:
enabled: true
endpoint: http://localhost:9411/api/v2/spans
logging:
pattern:
level: '%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]'
在此配置中,我们有以下参数:
management.tracing.enabled=true
: 启用应用程序的跟踪功能。management.tracing.sampling.probability=1.0
: 确保所有请求都被跟踪(对于生产环境,您可能需要根据性能原因调整此设置)。management.tracing.zipkin.enabled=true
: 启用将跟踪导出到 Zipkin。management.tracing.zipkin.endpoint
: 指定用于跟踪收集的 Zipkin 服务器的 URL。logging.pattern.level
: 指定每个日志条目将展示的格式。在此,我们将其设置为以下日志格式:应用程序名称、traceId、spanId。请注意,获取跟踪 ID 和 span Id 的日志值格式可能因库而异。
- 配置 RestClient 以进行跟踪
为了保证生成的跟踪在调用服务中传播,您需要配置您的 HTTP 客户端以传播跟踪上下文。这是通过配置 RestClient
使用由 Spring 创建的实例来完成的,使用步骤 1 和 2 中完成的依赖项和配置。
由于我们在本书中使用 OpenAPI
生成从订单管理 API 查询产品 API 的客户端,因此您需要用 Spring 实例化的 RestClient
覆盖 OpenAPI 生成的 RestClient
。
要做到这一点,您需要在配置中正确设置 Bean。在订单管理 API 中,这些配置是在 ProductsApiConfiguration
类的 adapter.outbound.rest
包下完成的:
@Configuration
public class ProductsApiConfiguration {
@Bean
public ProductsApi getProductsApi(ApiClient apiClient) {
return new ProductsApi(apiClient);
}
@Bean
public ApiClient getApiClient(RestClient restClient) {
return new ApiClient(restClient);
}
@Bean
public RestClient getRestClient(
ObservationRegistry observationRegistry,
RestClientBuilderConfigurer configurer) {
return configurer.configure(RestClient.builder())
.requestFactory(customClientHttpRequestFactory())
.observationRegistry(observationRegistry)
.build();
}
private ClientHttpRequestFactory customClientHttpRequestFactory() {
Duration duration = Duration.ofSeconds(6);
ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS
.withConnectTimeout(duration)
.withReadTimeout(duration);
return ClientHttpRequestFactories.get(settings);
}
@Bean
public SpanHandler logSpanHandler(){
return new LogSpanHandler();
}
static final class LogSpanHandler extends SpanHandler {
final Logger logger = Logger.getLogger(
ProductsApiConfiguration.class.getName());
LogSpanHandler() {
}
public boolean end(TraceContext context, MutableSpan span,
SpanHandler.Cause cause) {
if (!this.logger.isLoggable(Level.INFO)) {
return false;
} else {
this.logger.info(span.toString());
return true;
}
}
public String toString() {
return "LogSpanHandler{name=" + this.logger.getName() + "}";
}
}
}
此代码配置了 RestClient
实例使用自定义的 ClientHttpRequestFactory
和来自导入的 micrometer-observation
依赖项的 ObservationRegistry
。
然后,它将配置的 RestClient
实例添加到 ApiClient
实例中,接着将配置的 ApiClient
实例添加到 ProductsApi
实例中。我们使用 ProductsApi
实例从订单管理 API 调用外部产品 API。
这就是确保 traceId
和 spanId
值被生成,并且 traceId
正确传播到我们从订单管理 API 调用的所有服务中的方法。
此外,之后我们还配置了一个自定义的 SpanHandler
与 LogSpanHandler
。它用于将跟踪上下文中的有用信息记录到应用程序日志中。例如持续时间、请求来源、时间戳和 traceId
等信息为我们提供了各种数据,用于监控和在生产环境中调试应用程序。在下一节中,您将看到由此 SpanHandler
记录的真实示例。
什么是 Bean?
在 Spring 中,bean 是由 Spring IoC(控制反转) 容器创建、管理和配置的简单 Java 对象。把它们想象成您应用程序的构建块。当您将一个类定义为 bean 时,您是在告诉 Spring 负责实例化它、处理其依赖关系以及管理其生命周期。您可以使用 @Component
、@Service
或 @Bean
等注解定义 bean,或通过 XML 配置。一旦注册,这些 bean 可以自动“连接”在一起,这意味着 Spring 将在它们之间注入依赖关系,而无需您手动创建和连接对象。这种方法使您的代码更加模块化,更容易测试,并且耦合度更低。
查看跟踪数据
一旦您的应用程序使用 Micrometer Tracing 进行了配置,您就可以在分布式跟踪工具(如 Zipkin 或 Jaeger)中查看跟踪数据。这些工具允许您可视化跟踪和跨度,帮助您诊断跨服务的性能瓶颈或故障。
仅为了理解跟踪和跨度的区别:
跟踪将包括多个跨度,代表请求中涉及的各个服务和操作。
每个跨度都包含时间数据,使您能够识别慢速服务或问题操作。
当 Micrometer Tracing 在 Spring Boot 应用程序中实现时,日志将包括额外的字段,如 traceId
和 spanId
。这些字段有助于您在分布式系统中关联跨服务的日志。traceId
在跨不同服务的整个请求生命周期中保持不变,而每个服务或服务内的操作都获得自己的 spanId
。
这里是一个示例,展示了启用并正确配置 Micrometer Tracing 后的日志外观,从订单管理 API 调用产品 API。它遵循我们在 applications.yml
文件中定义的日志模式:
2025-03-19T16:45:39.207-03:00 INFO [order-management-api,67db1edfd85f42d21368a69936519fd1,1368a69936519fd1] 24477 --- [order-management-api] [nio-8090-exec-1] [67db1edfd85f42d21368a69936519fd1-1368a69936519fd1] c.p.o.a.o.rest.ProductsQueryUseCaseImpl : Getting product with id AA00001
这条日志中包含以下元素:
2025-03-19T16:45:39.207-03:00
: 时间戳 – 此日志条目创建的时间INFO
: 日志级别 – 表示信息性消息(非错误)[order-management-api,67db1edfd85f42d21368a69936519fd1,1368a69936519fd1]
: 定义了模式日志,包含:服务名称 – 识别哪个微服务生成了日志
Trace Id – 跨所有服务跟踪请求的唯一标识符
Span Id – 识别在跟踪中此特定操作
24477
: 进程 ID – 操作系统为此应用程序实例的标识符---
: 分隔符 – 日志格式中的视觉分隔线[order-management-api]
: 应用程序名称 – 为了可读性重复服务名称[nio-8090-exec-1]
: 线程名称 – 处理此请求的特定执行线程[67db1edfd85f42d21368a69936519fd1-1368a69936519fd1]
: 关联 ID – 简化请求跟踪的traceId-spanId
组合c.p.o.a.o.rest.ProductsQueryUseCaseImpl
:记录器名称 – 生成此日志的类(已缩短)获取 ID 为 AA00001 的产品
:日志消息 – 对正在执行的操作的简单文本描述,显示请求的产品 ID
这就是 Micrometer Tracing 如何使应用程序中的日志能够跟踪应用程序之间的请求。
但如果你想在日志中获取更多详细信息,你将可以从我们配置的LogSpanHandler
以及配置部分中的 bean 中获取。
让我们看看由LogSpanHandler
生成的特定日志:
2025-03-19T16:33:01.897-03:00 INFO [order-management-api,67db1beb2f77ede88c04d7187d10b32c,8c04d7187d10b32c] 22620 --- [order-management-api] [nio-8090-exec-1] [67db1beb2f77ede88c04d7187d10b32c-8c04d7187d10b32c] c.p.o.a.o.rest.ProductsApiConfiguration :
{
"traceId": "67db1beb2f77ede88c04d7187d10b32c",
"parentId": "8c04d7187d10b32c",
"id": "b362a6315dcb8bd9",
"kind": "CLIENT",
"name": "http get",
"timestamp": 1742412781886860,
"duration": 9204,
"localEndpoint": {
"serviceName": "order-management-api",
"ipv4": "192.168.96.1"
},
"tags": {
"application": "order-management-api",
"client.name": "localhost",
"exception": "none",
"http.url": "http://localhost:8080/api/products/AA00001",
"method": "GET",
"outcome": "SUCCESS",
"status": "200",
"uri": "/api/products/{productId}"
}
}
这条特定的日志是由LogSpanHandler
配置生成的,它添加了来自跟踪上下文的所有信息。
这条日志的开头遵循之前显示的日志模式中定义的相同结构,但这里不同的是其体中的信息,它是以 JSON 格式表示的。
让我们了解由LogSpanHandler
生成的 JSON 体中的每个元素以及它们如何帮助我们观察应用程序:
traceId
:分布式跟踪标识符 – 将此请求的所有跨服务 span 链接起来parentId
:父 span ID – 此字段标识了当前 span 起源的父 span。如果当前服务或操作是由其他服务触发的,则父 span ID 有助于追踪服务之间的调用层次结构。id
:Span ID – 此特定操作的唯一标识符kind
:Span 类型 –"CLIENT"
表示对另一个服务的出站请求name
:操作名称 – 描述执行了什么动作timestamp
:开始时间 – 此操作开始的时间(以微秒为单位)duration
:执行时间 – 操作花费的时间(6.246ms)localEndpoint
:服务信息 – 关于起源服务的详细信息tags
:包含上下文元数据的应用程序名称 – 服务标识符
客户端名称 – 目标服务器主机名
异常 – 错误状态(无表示成功)
HTTP URL – 被调用的完整 URL
方法 – 使用的 HTTP 动词(
GET
)结果 – 结果类别(
SUCCESS
)状态 – HTTP 响应代码(
200 = OK
)URI – 带有路径变量的请求路径模式
如果你注意到了,我们在本例中提到了parentSpanId
。让我们更好地了解它与spanId
之间的关系以及它在监控分布式系统性能中的有用性。
理解 parentSpanId 和 spanId
在分布式跟踪中,每个请求都会生成一个跟踪,该跟踪由多个 span 组成。span 代表单个工作单元,例如服务调用、数据库查询或服务内部的具体业务流程。每个 span 都包含一个唯一的标识符,称为spanId
,以及一个parentSpanId
,它将其链接到其起源的 span。这种父子关系有助于可视化请求如何在分布式系统中的不同服务之间传播。
总的来说,spanId
是当前操作或服务的唯一标识符,而parentSpanId
是调用操作或服务的spanId
。此字段将 span 连接起来,显示哪个服务调用了另一个服务。
使用这些 ID,像 Zipkin 或 Jaeger 这样的跟踪工具可以显示跟踪的完整时间线,揭示每个请求在服务之间的结构和时间。
为了可视化spanId
和parentSpanId
之间的关系,让我们通过一个电子商务应用中用户注册请求的示例跟踪来进行分析,其中每个参与跟踪的服务都有自己的spanId
,如果适用,还有一个parentSpanId
。
API 网关接收初始请求并生成
traceId
和spanId
。用户
服务处理用户注册,其 span 与 API 网关的spanId
链接作为其parentSpanId
。
通知
服务向用户发送欢迎邮件。此服务的 span 的spanId
是用户
服务的parentSpanId
。
下面是这种跟踪在 Jaeger 等工具中可能呈现的简化可视化:
Span 名称 | 服务 | spanId | parentSpanId | 开始时间 | 持续时间 |
---|---|---|---|---|---|
register_user |
API 网关 | span1 |
- | 0ms | 15ms |
create_user_record |
用户 | span2 |
span1 |
5ms | 40ms |
send_email |
通知 | span3 |
span2 |
25ms | 30ms |
在这个可视化中:
register_user
span 来自 API 网关是跟踪的根。它没有parentSpanId
,因为它启动了跟踪。用户服务
span(create_user_record
)是API 网关
span 的子 span,因此它引用span1
作为其parentSpanId
。通知
服务 span(send_email
)是用户服务
span 的子 span,其parentSpanId
为span2
,表示它是由用户创建过程触发的。
跟踪工具将父子层级显示为时间线,以可视化请求的传播。下面是匹配这一层级的图表,显示每个服务在时间上的关系:
0ms 15ms 40ms 70ms
|-----------|------------|--------------|
| API | User | Notification |
| Gateway | Service | Service |
| register_ | create_ | send_ |
| user | user | email |
在跟踪可视化工具中:
API 网关
的 span(span1
)启动请求。用户服务
(span2
)在API 网关
之后不久开始,由于执行数据库插入等操作,它花费了更多时间。通知服务
(span3
)在用户服务
完成用户创建后开始。每个 span 的持续时间表示每个操作花费了多长时间。
父子层级洞察
这种跟踪层次结构对于:
识别瓶颈:如果
用户服务
运行时间异常长,它会在时间线上显示为一个更长的条形,从而引发调查。追踪错误:如果在
通知服务
中发生错误,你可以在跟踪中看到它,并快速将其追踪回来自用户服务
的原始请求。理解依赖关系:通过查看父子结构,你可以看到每个服务如何依赖于其他服务以及操作的顺序。
这种可视化和spanId
与parentSpanId
的关系允许软件工程师、架构师和系统分析师理解多个服务之间每个请求的流程和时间线,有助于优化性能、调试问题和深入了解系统的行为。
多个服务之间的日志
当请求在分布式系统中的多个服务之间流动时,每个服务都会独立地记录其请求的一部分。通过使用唯一的标识符,如traceId
和spanId
,我们可以将跨服务的单个日志关联起来,形成一个完整的请求旅程图。这种端到端的可见性对于理解服务之间的交互、识别瓶颈和调试错误至关重要。
在这个例子中,用户注册请求通过了三个服务:
API 网关:接收初始请求并将其路由到适当的后端服务。
用户服务:通过在数据库中创建用户记录来处理注册。
通知服务:在成功注册后向用户发送欢迎邮件。
每个服务使用相同的traceId
来关联其请求的一部分日志。spanId
在每个服务内是唯一的,而parentSpanId
将其链接回调用服务。
下面的图表显示了请求如何通过每个服务,相应的日志通过编号标识,这些编号与下面的示例日志相关联:
+------------------------+
| API Gateway |
| traceId: A, spanId: 1 |
+------------------------+
|
| parentSpanId: -
|
+----------v-------------+
| User Service |
| traceId: A, spanId: 2 |
| parentSpanId: 1 |
+------------------------+
|
| parentSpanId: 2
|
+----------v-------------+
| Notification Service |
| traceId: A, spanId: 3 |
| parentSpanId: 2 |
+------------------------+
下面显示的每个日志条目都标有与上图步骤相对应的编号。通过跟踪traceId
、spanId
和parentSpanId
,我们可以看到每个服务在跟踪中的连接方式,使我们能够重建请求的旅程。
接下来,让我们看看这个跟踪正在通过每个服务的日志示例。
API 网关日志
当 API 网关接收到请求时,它会生成一个新的traceId
(A)和它自己的spanId
(1)。
{
"timestamp": "2024-10-23T10:00:00Z",
"level": "INFO",
"service": "API Gateway",
"traceId": "A",
"spanId": "1",
"parentSpanId": null,
"message": "Received request for user registration"
}
用户服务日志
用户服务通过使用traceId (A)
将其与原始请求连接起来来处理注册。用户服务
的日志条目有一个唯一的spanId (2)
,并将其作为parentSpanId
引用 API 网关的spanId (1)
。
{
"timestamp": "2024-10-23T10:00:05Z",
"level": "INFO",
"service": "User Service",
"traceId": "A",
"spanId": "2",
"parentSpanId": "1",
"message": "Processing user registration"
}
通知服务日志
在用户服务完成用户创建后,它会触发通知服务发送欢迎邮件。通知服务
的日志条目包括traceId
(A)以保持连续性,生成它自己的spanId
(3),并使用用户服务的spanId
(2)作为其parentSpanId
。
{
"timestamp": "2024-10-23T10:00:10Z",
"level": "INFO",
"service": "Notification Service",
"traceId": "A",
"spanId": "3",
"parentSpanId": "2",
"message": "Sending welcome email"
}
在这个示例中,traceId
(A)在所有服务中保持不变,将日志链接在一起,以表示整个请求流程。每个日志的 spanId
和 parentSpanId
建立了父子关系,显示了每个服务在序列中的连接方式。以下是这些日志如何协同工作:
日志 1(API 网关):使用
traceId
(A)和spanId
(1)启动请求。日志 2(用户服务):继续请求,通过其
parentSpanId
引用 API 网关的spanId
(1),并为自身创建一个新的spanId
(2)。日志 3(通知服务):通过链接回用户服务的
spanId
(2)并创建自己的spanId
(3)来完成流程。
通过使用 traceId
、spanId
和 parentSpanId
的组合,我们可以跟踪用户注册请求的生命周期,随着它从服务到服务的移动,提供请求旅程的清晰和结构化视图。
日志在以下方面很有用:
端到端可追溯性:通过搜索具有相同
traceId
的日志,您可以在不同的服务(API 网关、用户服务和通知服务)之间跟踪请求,并查看每个步骤如何处理请求。服务依赖:
parentSpanId
帮助您理解服务之间的连接方式。在上面的示例中,通知服务被用户服务调用,而用户服务是由 API 网关触发的。日志显示了调用层次结构。性能洞察:比较跨度之间的时间戳可以为您提供性能瓶颈的见解。例如,您可以通过比较时间戳来衡量每个服务处理请求所需的时间。
Micrometer 跟踪通过添加必要的跟踪和跨度数据丰富您的日志,这些数据允许您跟踪分布式系统中的请求。这种可追溯性简化了故障排除,并帮助您可视化请求流程,使其更容易检测性能问题或服务故障。通过将此数据与 Zipkin 或 Jaeger 等工具集成,您还可以实时可视化跟踪,进一步增强您的可观察性策略。
使用 Zipkin 可视化跟踪
要在您的机器上运行本地 Zipkin 实例,创建以下 docker-compose.yml
文件,该文件将自动下载并配置您的本地 Zipkin 实例,内容如下:
services:
zipkin:
image: ghcr.io/openzipkin/zipkin-slim:${TAG:-latest}
container_name: zipkin
environment:
- STORAGE_TYPE=mem
- MYSQL_HOST=mysql
ports:
- 9411:9411
此文件定义了我们从哪里获取 Docker 镜像及其版本,即最新可用的版本。我们还将使用默认端口 9411
在内存中运行此实例。
要运行此容器,您需要在系统上安装 Docker。安装后,在保存此文件的同一目录中打开控制台,并运行以下命令:
docker compose up
应用程序运行后,前往您本地运行的 Zipkin 的 UI(http://localhost:9411
)以查看跟踪信息。您应该看到一个跟踪的图形表示,显示 traceId
、spanId
以及服务之间的父子关系。
图 9.1 – 使用 Micrometer 在 Zipkin 仪表板中完成完整的追踪旅程
添加自定义跨度
除了自动追踪 HTTP 请求外,您可能还想创建自定义跨度来追踪服务内的特定操作。例如,您可以追踪重要的业务逻辑或数据库查询。
要创建自定义跨度,将Tracer
注入到您的服务中,并使用它手动创建和管理跨度:
import io.micrometer.tracing.Tracer;
import io.micrometer.tracing.Span;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class UserService {
@Autowired
private Tracer tracer;
public void createUser(String userId) {
Span newUserSpan = tracer.nextSpan().name("createUser").start();
try (Tracer.SpanInScope ws = tracer.withSpan(newUserSpan.start())) {
// Business logic for creating a user
// Simulate a process that takes time, e.g., a database call
Thread.sleep(1000);
System.out.println("User created with ID: " + userId);
} catch (Exception e) {
newUserSpan.error(e);
} finally {
newUserSpan.end();
}
}
}
在此示例中,我们使用Tracer
创建一个名为createUser
的自定义跨度,该跨度跟踪用户创建过程的执行。跨度通过调用start()
手动启动,并通过调用end()
完成。我们还通过调用newUserSpan.error(e)
确保任何异常都被捕获在跨度中。
接下来,让我们了解如何从追踪数据中提取指标以及这如何帮助我们监控整个应用程序环境的行为。
追踪数据的指标
对于 RESTful 网络服务,指标对于评估和优化这些服务的性能至关重要。这些指标提供了关于 API 如何高效处理请求、处理数据和提供响应的见解。
这有助于确保 RESTful 网络服务平稳运行,提供更好的用户体验并满足业务目标。
在 Micrometer 追踪到位后,您可以从追踪数据中提取有意义的指标。指标为您提供了 API 性能和健康状况的定量洞察。
指标如延迟、吞吐量和错误率是理解您的 REST API 在负载下表现的关键。这些指标有助于检测缓慢的服务、过载的端点或需要解决的频繁错误。
要监控的指标类型
在 REST API 中跟踪的一些常见指标包括:
请求持续时间:API 响应需要多长时间?
请求计数:在一定时期内服务的请求数量。
成功/失败率:成功请求与失败请求的百分比。
HTTP 错误代码:
4xx
和5xx
响应的计数。
您可以配置 Micrometer 自动跟踪这些指标。例如,要跟踪请求持续时间,请添加以下配置:
management.metrics.web.server.request.autotime.enabled=true
指标示例:
延迟:请求完成所需平均时间(例如,200ms)。
吞吐量:高峰时段系统平均每秒处理 150 个请求。
错误率:失败请求的百分比(例如,5%的请求返回 500 错误)。
指标有助于识别性能瓶颈。例如,如果您的 API 端点之一始终比其他端点具有更高的延迟,这可能表明需要优化数据库查询、改进缓存或重构代码。
例如,如果/api/v1/users
端点显示平均响应时间为 500ms,而其他端点响应时间在 100ms 以下,您可以使用追踪数据找出延迟发生的位置(例如,在数据库查询或第三方 API 调用中)。
使用 Micrometer 查看指标
Micrometer 与 Prometheus 和 Grafana 集成,以实时仪表板可视化您的指标。这允许您根据 API 的性能创建自定义视图和警报。
例如,在 Grafana 中,您可以使用时间序列图创建一个仪表板,以可视化 API 端点随时间变化的延迟。这些图表有助于您发现趋势并通过突出显示延迟增加的时期来优化性能,这可能会表明瓶颈或资源限制。例如,您可以使用折线图显示特定端点的平均响应时间随时间的变化,从而更容易识别模式或异常。
此外,Grafana 还支持各种其他可视化,可用于显示指标,如请求数量、成功/失败率和 HTTP 错误代码。例如,您可以使用柱状图比较给定期间成功请求与失败请求的数量,或使用饼图显示 API 返回的不同 HTTP 状态代码的分布。
图 9.2 – 在 Grafana 仪表板中显示的指标
现在我们已经掌握了 Micrometer,让我们来看看另一个用于监控和可观察性的选项,它也是开源的,并在市场上广泛使用,那就是 OpenTelemetry。
OpenTelemetry 用于监控和可观察性
OpenTelemetry 是一个开源、供应商中立的可观察性框架,它为您的应用程序提供收集遥测数据(日志、指标和跟踪)的工具。这是一个全面的标准化设计,旨在为分布式系统提供深入洞察,并且跨语言和平台得到广泛支持。
OpenTelemetry 将日志、指标和跟踪统一到一个框架中,提供了一个标准化的方式来对你的服务进行仪表化。它与各种后端(如 Prometheus、Jaeger、Zipkin 和 Grafana)协同工作,并支持跨微服务的分布式跟踪。
OpenTelemetry 帮助您跟踪请求在其通过多个服务移动时的完整生命周期,提供了关于服务性能、延迟和瓶颈的宝贵见解。
OpenTelemetry 由以下组件组成:
跟踪:监控请求在多个服务之间的旅程。
指标:收集关于服务性能的定量数据,如响应时间和错误率。
日志:记录系统中的离散事件,例如错误或警告。
在 Spring Boot 中使用 OpenTelemetry
OpenTelemetry 提供了一个标准化的方式来收集、处理和导出应用程序的遥测数据(日志、指标和跟踪)。在 Spring Boot 应用程序中,OpenTelemetry 可以集成以自动捕获服务之间的跟踪数据。一旦集成,这些数据就可以导出到如 Jaeger、Zipkin 或 Grafana 等可观察性工具,以实时可视化并监控请求的流动。
在本节中,我们将介绍在 Spring Boot 应用程序中设置 OpenTelemetry 的步骤,验证跟踪是否正常工作,并查看实现 OpenTelemetry 后的示例日志输出。
- 添加 OpenTelemetry 依赖项:要将 OpenTelemetry 与 Spring Boot 集成,您需要 OpenTelemetry SDK 以及针对 Spring 和 HTTP 客户端的特定仪表依赖项。请将以下依赖项添加到您的
pom.xml
中:
在依赖项管理标签下:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.opentelemetry.instrumentation</groupId>
<artifactId>opentelemetry-instrumentation-bom</artifactId>
<version>2.14.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
然后,在依赖项标签下继续添加以下依赖项:
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry.instrumentation</groupId>
<artifactId>opentelemetry-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
让我们了解每个这些依赖项的用途:
opentelemetry-instrumentation-bom
:一个物料清单(BOM),确保所有 OpenTelemetry 依赖项的版本一致性。
帮助管理 OpenTelemetry 组件及其传递依赖项之间的兼容版本。
在使用 Maven(如我们在这里所做的那样)时,必须在其他 BOM(如 spring-boot-dependencies)之前导入。
micrometer-tracing-bridge-otel
:将 Micrometer 的 Observation API 桥接到 OpenTelemetry 的跟踪系统中。
促进 Micrometer 和 OpenTelemetry 之间跟踪上下文和跨度之间的传播。
在 Spring Boot 应用程序中启用分布式跟踪的 OpenTelemetry 的必要组件。
opentelemetry-spring-boot-starter
:为 Spring Boot 应用程序提供 OpenTelemetry 的自动配置。
包含许多 Spring Boot 功能的内置仪表
简化了使用最少配置对 Spring Boot 应用程序进行仪表化的过程。
特别适用于 Spring Boot 原生图像应用程序或与 Java 代理方法相比寻求减少启动开销的情况。
opentelemetry-exporter-otlp
:实现 OpenTelemetry 协议(OTLP)导出器以发送遥测数据。
允许应用程序将收集的跟踪数据导出到 OpenTelemetry 收集器或其他后端。
支持标准化遥测数据在可观察性工具之间的交付。
可以配置为使用 HTTP 或 gRPC 传输协议。
spring-boot-starter-actuator
:为 Spring Boot 应用程序添加生产级监控和管理功能。
为 Micrometer(指标和跟踪)提供依赖项管理和自动配置。
Spring Boot 中指标和跟踪能力的必要基础。这对于任何跟踪都是强制性的,无论是仅使用 Micrometer 还是与 OpenTelemetry 一起使用。
提供应用程序健康、指标和其他操作数据的端点。
将 OpenTelemetry 配置到应用程序中:让我们在
application.yml
中设置以下属性。首先,我们将定义 OpenTelemetry 将发送跟踪的 URL,在这种情况下,这是我们将在您接下来定义的
docker-compose
文件中运行的 Jaeger 的路径和暴露的端口。tracing: url: http://localhost:4318/v1/traces
在
otel
标签下,我们将 OpenTelemetry 设置为不导出日志或指标,只导出跟踪。在没有这些配置的情况下它也将正常工作,但会在应用程序日志中抛出多个异常,因为 Jaeger 只读取跟踪,不读取日志或指标。如果您打算使用像 Grafana 这样的其他后端工具来消费日志和指标,而不是将其设置为 none,您应添加您将要使用的后端的正确配置。
由于 OpenTelemetry 与多种后端兼容,您应参考文档了解如何配置它以适应您将要使用的日志和指标后端。
otel: logs: exporter: none metrics: exporter: none
接下来,在管理标签下,您有与 Micrometer 部分相同的配置。配置需要包含启用跟踪和采样概率为 1.0,以便为我们的测试目的生成尽可能多的跟踪。在生产环境中,您应配置一个较小的值以避免不必要的跟踪。
management: endpoints: web: exposure: exclude: "*" tracing: enabled: true sampling: probability: 1.0
最后,请注意,使用 OpenTelemetry 的日志模式会发生变化。在“使用 Micrometer 实现跟踪”部分所示,使用 Brave 与纯 Micrometer 一起注册跟踪和跨度为 MDC(映射诊断上下文)中的
traceId
和spanId
,即模式从中获取值的地方。但 OpenTelemetry 将它们注册为trace_id
和span_id
。这是一个细微的变化,但如果您不考虑这一点,您将无法在应用程序日志中看到跟踪。logging: pattern: level: '%5p [${spring.application.name:},%X{trace_id:-},%X{span_id:-}]'
配置 OpenTelemetry 导出器:在您的 Spring Boot 应用程序中创建一个 OpenTelemetry 配置类,以将跟踪导出到定义的跟踪 URL。当使用
opentelemetry-spring-boot-starter
时,此设置通常会被自动处理,但您可以在 Spring 中将其初始化为一个 bean,就像我们在这里所做的那样。
这将直接导出跟踪以便由后端收集,在我们的例子中这次将是 Jaeger。为了实现这一点,我们将使用之前导入的 OpenTelemetry 库中的OtlpHttpSpanExporter
类:
import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OtlpConfiguration {
@Bean
OtlpHttpSpanExporter otlpHttpSpanExporter(@Value(
"${tracing.url}") String url) {
return OtlpHttpSpanExporter.builder()
.setEndpoint(url)
.build();
}
}
使用此配置,OtlpHttpSpanExporter
将直接从applications.yml
将跟踪导出到定义的跟踪 URL,因此 Jaeger 可以直接读取我们的跟踪。默认情况下,OpenTelemetry 将自动检测 HTTP 和 Spring MVC 请求。
验证应用程序中的跟踪:为了验证 OpenTelemetry 跟踪是否正常工作,我们可以:
检查导出工具中的跟踪:启动您的 Spring Boot 应用程序,并使用 Jaeger(或选择的后端)查看跟踪。每个传入请求都应在跟踪工具中作为一个具有唯一
traceId
的新跟踪出现。观察带有跟踪和跨度 ID 的日志:在设置 OpenTelemetry 之后,日志应包含
traceId
和spanId
,这允许您在服务之间关联日志条目。
验证日志和跟踪可视化:实施 OpenTelemetry 后,您应该看到带有 traceId 和 spanId 的增强日志,每个请求都有一个。此外,跟踪后端(在本例中为 Zipkin)将提供跟踪的可视表示。
带有 OpenTelemetry 跟踪的日志
下面是实施 OpenTelemetry 后,order-management-api
调用 product-api
的示例日志输出。注意每个日志条目中添加的 traceId
和 spanId
,它们遵循定义的日志模式写入 application.yml
文件:
2025-04-01T19:10:56.485-03:00 INFO [order-management-api,e809af87a330f1ab03ccfa395e5d5864,d01a9ecf8f4e570b] 10441 --- [order-management-api] [io-8090-exec-10] [ ] c.p.o.a.o.rest.ProductsQueryUseCaseImpl : Getting product with id AA00001
它遵循在“查看跟踪数据”部分中描述的相同结构,但在这里这些跟踪和跨度是由 OpenTelemetry 生成的。
我们还可以在 product-api
端查看日志,该端从 order-management-api
接收 traceId
并生成自己的 spanId
:
2025-04-01T19:10:56.488-03:00 INFO [product-api,e809af87a330f1ab03ccfa395e5d5864,38cb8ed93e944e69] 10609 --- [product-api] [io-8080-exec-10] [ ] c.p.p.a.o.d.ProductsQueryUseCaseImpl : Getting product by id: AA00001
使用这些 ID,每个日志条目都可以关联到特定的请求及其跨多个服务的旅程。
接下来,我们将学习如何运行本地 Jaeger 实例以查看整个跟踪的实际操作。
使用 Jaeger 的可视化示例
要在您的机器上运行本地 Jaeger 实例,创建以下 docker-compose.yml
文件,它将自动下载并配置您的本地 Jaeger 实例,内容如下:
services:
jaeger:
image: jaegertracing/jaeger:${JAEGER_VERSION:-latest}
container_name: jaeger
environment:
- COLLECTOR_OTLP_ENABLED=true
ports:
- 4318:4318
- 16686:16686
此文件定义了获取 Jaeger 的最新 Docker 镜像。此外,在这里我们为跟踪分配端口 4318
,为 Jaeger UI 界面分配端口 16686
。
在 Jaeger v2 中,属性 COLLECTOR_OTLP_ENABLED=true
是可选的,因为其默认值始终为 true,而在 Jaeger v1 中则是强制性的。在撰写本文时,您应该在运行此 docker-compose.yml
文件时获取上述 v2 版本。
要运行此容器,您需要在您的系统上安装 Docker。一旦安装,请在此文件保存的同一目录中打开一个控制台,并运行以下命令:
docker compose up
一旦应用程序运行,请访问您本地运行的 Jaeger 的 UI(http://localhost:16686
)以查看跟踪。您应该看到一个跟踪的图形表示,显示 traceId
、spanId
以及跨服务之间的父子关系,甚至显示更详细的数据库 INSERT
和持续时间,从而提供一个广泛的跟踪视图。
图 9.3 – 使用 OpenTelemetry 在 Jaeger 仪表板中的完整跟踪旅程
在任何错误的情况下,它将突出显示发生的事情以及发生的位置:
图 9.4 – Jaeger 仪表板中的成功和错误跟踪
您可以随时参考本章的代码库以获取工作代码版本,并在您的本地机器上复制相同的行为。
通过检查应用程序中的日志和在 Jaeger 中的跟踪可视化,你可以验证 OpenTelemetry 是否成功捕获了跟踪,将日志与跟踪数据相关联,并提供了请求通过你的分布式系统的完整视图。这种设置不仅有助于解决问题,还能提供优化服务性能的见解,以监控分布式服务为目标。
让我们看看如何使用 OpenTelemetry 创建自定义跨度。
创建自定义跨度
虽然 OpenTelemetry 自动对 HTTP 请求进行仪表化,但你也可以创建自定义跨度来监控特定操作。以下是一个手动为用户注册过程创建跨度的示例:
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class UserService {
@Autowired
private Tracer tracer;
public void registerUser(String username) {
Span span = tracer.spanBuilder("UserService.registerUser")
.setAttribute("username", username)
.startSpan();
try {
// Simulate registration logic
Thread.sleep(100);
System.out.println("User registered: " + username);
} catch (InterruptedException e) {
span.recordException(e);
} finally {
span.end();
}
}
}
在这个示例中,创建了一个名为“ UserService.registerUser
"的自定义跨度,并添加了一个用户名属性。跨度通过.startSpan()
开始,通过.end()
结束。
现在你已经掌握了 OpenTelemetry 的使用,让我们来关注一些你应该知道的关于可观测性的最佳实践。
端到端可观测性的最佳实践
可观测性是一个持续的过程,涉及日志、指标和跟踪共同工作,以提供对 REST API 性能和行为的全面可见性。
准备你的环境以完全由可观测性和监控覆盖不是一件容易的事情,你必须考虑以下主题:
日志、指标和跟踪的结合
日志、指标和跟踪相互补充,为你提供应用程序的整体视图:
日志:提供关于特定事件的详细信息。
指标:提供随时间推移的性能定量总结。
跟踪:显示单个请求在服务之间的生命周期。
通过使用这些工具一起,你可以快速诊断问题并优化性能。例如,你可能使用跟踪来找到慢请求,使用日志来确定为什么它慢,以及使用指标来跟踪问题发生的频率。
警报和通知
在部署了可观测性工具之后,你可以设置警报,当出现问题时通知你的团队。例如,你可以配置 Prometheus 在错误率超过某个阈值或响应时间激增时发送警报。
在 RESTful 微服务的情况下,AWS CloudWatch 提供了全面的监控能力,将原始操作数据转换为可读的、近实时的指标,并存储长达 15 个月。
例如,当将 API 网关作为你的微服务架构的前门时,CloudWatch 可以监控关键性能指标,如 IntegrationLatency 来衡量后端响应速度,整体延迟来评估 API 调用效率,以及缓存性能指标来优化资源利用率。API 网关日志,它输入到 CloudWatch 日志中,提供了对消费者访问行为的宝贵可见性,使团队能够了解常见客户位置,分析可能影响数据库分区的请求模式,识别可能表明安全问题的异常行为,并通过跟踪错误、延迟和缓存性能来优化配置。这个监控框架创建了一个安全、易于维护的环境,随着业务需求的增长而扩展,同时提供可操作的智能,以持续改进服务交付。
持续改进
可观测性是一个持续的过程。随着你的系统不断发展,定期审查和优化你的日志记录、跟踪和指标收集,以确保你捕获到最有用的数据。使用 Prometheus、Grafana、Zipkin 和 OpenTelemetry 等工具持续监控和改进你系统的性能。
摘要
在本章中,我们探讨了实现 RESTful 服务有效监控和观测性的核心组件和最佳实践。从日志记录开始,我们讨论了结构化日志对于 API 故障排除的重要性,日志级别以指示严重性,以及关联 ID 以在服务之间链接请求。通过在 Spring Boot 中使用过滤器等中央实施这些日志记录实践,我们确保了应用程序中日志记录的一致性和安全性。
我们随后介绍了分布式跟踪,解释了traceId
、spanId
和parentSpanId
如何在服务之间创建父子关系,使开发者能够跟踪请求在系统中的旅程。
Micrometer Tracing 在 Spring Boot 3.x 中被作为启用和管理分布式跟踪的关键工具进行了介绍。它自动为 Spring Boot 应用程序进行仪器化,捕获每个请求的跟踪和跨度信息。
Micrometer Tracing 与多个导出器集成,包括 Prometheus、Zipkin 和 Jaeger,将跟踪数据发送到外部观测性平台。通过其可配置的采样和标记,Micrometer Tracing 为每个服务提供了细粒度的可见性,使高效的故障排除和性能优化成为可能。
在跟踪的基础上,我们探讨了 OpenTelemetry 作为一个供应商中立的观测性框架,它收集和关联分布式系统中的跟踪、指标和日志。
OpenTelemetry 与 Spring Boot 无缝集成,为 HTTP 和 Spring MVC 请求提供开箱即用的跟踪,并增加了创建自定义跨度的灵活性。我们介绍了如何配置 OpenTelemetry,通过在 Zipkin 等工具中的日志记录和可视化来验证其功能,并观察跨服务端到端的跟踪。
通过结合日志记录、跟踪和 OpenTelemetry,以及使用 Zipkin 或 Jaeger 等工具进行可视化,我们可以获得对每个服务间请求的全面视图。
本章提供了实施稳健可观测性的基础策略,允许进行有效的监控、快速的问题排查,以及优化复杂、分布式环境中 RESTful API 性能的见解。
在下一章中,你将学习关于扩展和性能优化技术,以便能够充分利用你的应用程序。
第十章:扩展和性能优化技术
第九章介绍了如定量测量等技能,以观察通过 API 连接的系统在运行时的行为。
那一章为本章关于性能和可伸缩性提供了良好的基础,这两个非功能性需求与时间、大小以及软件系统和它们处理的数据的其他定量方面密切相关。
我们将首先解释开发者需要了解的性能和可伸缩性的一般知识。然后,我们将深入具体细节,描述提高性能和可伸缩性的最常见策略和技术。我们将使用我们的商品和订单管理 API 演示一些这些技术。
我们强调 Java 虚拟线程在 API 开发环境中提高应用吞吐量、改善 CPU 使用效率的潜力。
我们还表明,为了支持性能和可伸缩性,您的 API 应该准备好与专门的基础设施组件一起工作。
最后,我们将展示如何通过负载测试帮助您避免在应用程序开始接收生产负载时出现不愉快的惊喜。通过性能测试,开发者可以获取对针对优化努力至关重要的信息。
到本章结束时,您将了解如何从分析阶段开始,通过适当的 API 设计,到由负载测试发现触发的后续优化,来防止性能和可伸缩性问题。
本章涵盖了以下主题:
理解 API 开发中的性能和可伸缩性
应用性能优化策略
使用虚拟线程提高吞吐量
使用基础设施支持
设计和执行有效的负载测试
技术要求
为了展示本章中描述的一些技术,我们将使用前几章中开发的商品和订单管理 API 的示例代码。本章对代码所做的更改可以在以下存储库中找到:github.com/PacktPublishing/Mastering-RESTful-Web-Services-with-Java/tree/main/chapter10
。
理解 API 开发中的性能和可伸缩性
计算中的性能指的是系统或应用程序在给定工作负载下执行任务的效率。效率有两个方面:
处理速度:这主要是在响应时间(延迟)和吞吐量(系统在单位时间内可以处理多少操作或数据)的术语下进行衡量的。
资源消耗:这指的是完成工作所需资源的数量,例如 CPU、内存和网络带宽利用率。
统计学是性能测量的一个重要组成部分,因为在系统处理大量不同请求时,涉及不同数据量的情况下,测量性能是有意义的。因此,满足一个请求所需的时间和资源不可避免地会波动,并取决于许多因素,使得性能特性的实际测量值在本质上几乎是随机的。
通常使用平均值来计算吞吐量是足够的。对于响应时间,平均值不太有用,因为它不能很好地捕捉用户对响应速度的感知。最大值通常特别有趣,因为它可以指示潜在问题,例如超时错误的发生。此外,超过合理限制的响应时间会阻碍用户继续使用应用程序。
比极值更有用的是百分位数。如果我们按最短到最长的顺序对所有响应时间进行排名,那么例如,95 百分位数是 95%的测量时间都更短的时间点。换句话说,随机选择的一个请求耗时更长的概率只有 5%。
可扩展性指的是系统在保持用户期望的性能限制内处理不断增加的负载(用户数量增加、数据量更高或额外的事务)的能力。你可以看到,如果不考虑性能,我们无法谈论可扩展性。
可扩展性是推动从单体架构迁移到分布式(微服务)架构的主要动机之一。我们可以假设我们的示例产品 API 将需要处理比订单管理 API 显著更多的请求,因为不是浏览的每个产品都会被购买。我们可以扩展产品 API 的部署,使用比订单管理 API 更多的服务实例。
在 RESTful API 中,我们感兴趣的任务是 HTTP 请求的响应时间和吞吐量。执行 HTTP 请求所需的时间和计算资源由两部分组成:
API 技术本身所需的处理,例如数据的序列化和反序列化、网络传输和协议开销
提供 API 的服务内部的处理(例如执行算法、访问数据库和下游 API 调用),这通常受 API 请求和响应负载设计的影响,例如一个请求中处理的数据量、执行并发处理和数据流的可能性以及响应的可缓存性。
在设计 API 时,你通常关注功能需求,因为应用程序应该做什么是用户心中的首要任务。应用程序预期如何表现和应对增加的流量通常被认为是显而易见或隐含的。
预测实际负载、负载随时间的变化以及系统哪些部分会受到最大影响也很困难。唐纳德·克努特(Donald Knuth)著名的说法“过早优化是万恶之源”警告我们,我们不应该试图优化一切。
另一方面,性能和可扩展性考虑应该是设计过程的一部分,你通常可以通过对 API 进行一些简单的调整来预防很多问题。理想情况下,你应该设计 API 以尽可能简单的方式满足功能需求,同时为未来的扩展和优化留出空间。
当有疑问时,让 REST 原则引导你走向更灵活和标准的解决方案,这可能会支持性能和可扩展性要求:
使用基于反映业务域的资源结构的 URL
使用正确的 HTTP 方法,并为
GET
、PUT
和DELETE
提供相应的幂等性支持使用正确的 HTTP 头部和状态码
使用清晰定义的参数,让客户端选择真正需要的数据和操作
为数据使用适当的
Content-Type
和Content-Encoding
(特别是对于大型二进制文档)
解释了性能和可扩展性在 API 开发中的含义后,我们可以继续描述那些当适当应用时,可以对使用 API 集成的应用程序的性能和可扩展性产生最大影响的策略。
应用性能优化和可扩展性改进策略
如果你检测到你的 API 存在或即将出现性能或可扩展性问题,你应该具备解决问题的工具。让我们在接下来的部分中更详细地看看一些优化性能的策略和技术。
了解性能要求
如前文所述,性能要求通常是由用户和客户隐含的,而不是明确指定的。
对于某些应用程序,性能可能不是关键因素,但如果我们以大多数电子商务网站为例,响应时间达到几秒的应用程序相当于根本不起作用的应用程序,因为它会立即阻止潜在客户购买。
因此,有必要至少包括一些关于要开发的系统的粗略性能预期。如果你可以更加严格,你应该要求客户定义服务级别协议(SLAs),指定每个操作的最多响应时间或请求处理的数量。SLAs 可能会因高峰时段和非高峰时段而有所不同。请记住,要记录大型数据对象的大小限制、大量并发用户及其地理分布。
收集的性能需求可以用来设计负载测试。负载测试将显示系统是否能够处理预期的负载,如果不能,哪些部分是瓶颈。关于用户地理位置分布和他们网络连接质量的信息应被用来在负载测试环境中添加模拟的网络延迟。
为了正确设计系统,了解系统是否应该优化响应时间(具有人类交互的系统)或吞吐量(批量处理)非常重要。为了实现更好的(感知)响应时间,可能有必要拆分 API 请求,使得一个请求只执行基本操作,只传递基本数据。这样,用户体验得到改善,而那些非基本请求可以稍后开始,或者运行更长的时间。
相反,将多个操作或数据项组合在一个请求中可能会通过减少 API 开销来提高吞吐量。然而,你必须确保请求不会变得太大,以至于因为超时而失败。
在许多情况下,你无法精确地估计或模拟负载和环境属性,因此你必须对生产中开始出现的性能问题做出反应。为了支持这一点,你的 API 应该准备好在不破坏现有客户端的情况下进行演变,这是第五章的主题。
只提供真正需要的内容
安全性,另一个非功能性需求,通常被认为与性能相冲突,但在许多情况下,它们的解决方案实际上可以重叠。限制我们在 API 中传输的数据量对安全性有益,也可能对性能有益,因为时间和资源不会浪费在 API 客户端不需要的项目上。
你应该识别那些大或需要很长时间才能获取的昂贵项目。如果一个昂贵项目不是所有请求都使用,你可以做以下任何一项:
向 API 端点添加一个参数,仅在客户端请求时才返回昂贵项目
为昂贵项目创建一个单独的端点(资源)
例如,如果我们的产品 API 存储产品的照片,我们就不需要在每个产品请求中返回照片。相反,我们可以返回一个图像 ID 列表,并为产品资源定义一个子资源,通过图像 ID 返回图像。
一个常见的错误是将 API 资源设计成数据库实体的副本。一些开发者甚至可能被诱惑直接在 API 中使用 JPA 实体类,而不定义单独的 API DTOs,这是一种明显的反模式。
应特别关注定义 JPA 实体之间关系的字段。在没有充分理由的情况下暴露由跟随关系产生的整个对象图,通常会导致 API 膨胀,并带来性能(除了安全和可维护性)问题。
例如,假设在某个时刻,我们扩展了我们的 Product API 服务的数据库模式,增加了包含有关库存可用性、位置、历史价格等信息的外部数据库表和 JPA 实体。这些额外的数据库表可以通过外键与产品表相关联,JPA 模型可以包含映射到相应 SQL 连接的关系。将所有额外信息自动包含在 Product API 资源的 JSON 表示中是错误的,除非在业务分析确认所有可能的读取产品的用例都将始终需要完整的数据集的情况下。相反,为了支持需要访问相关实体的用例,API 仅在设置参数或通过单独的端点时才发送额外的数据。
维护无状态
无状态(如第一章所述)是 REST 架构的关键原则之一。执行操作所需的所有输入数据都应该包含在 URL 路径和请求体中。请求处理不应依赖于某些隐含的会话数据。
无状态对于性能和可扩展性很重要,因为它允许 API 提供服务的服务进行水平扩展。负载均衡器(在即将到来的关于基础设施组件的部分中简要介绍)可以将任何请求路由到任何服务实例进行处理,因为实例不需要记住会话状态(属于同一用户旅程的先前请求的历史)。避免会话状态还可以帮助减少内存消耗。
当然,应用程序可能需要支持类似会话的用户体验。例如,你可能希望让用户逐个创建订单产品,因此应用程序需要记住一个不完整的订单,这在大多数电子商务网站上通常表示为购物车。为了满足这一要求,你有两个主要选项:
使用 JavaScript 代码、本地存储等在客户端(网页浏览器)上维护会话状态。
将购物车定义为后端服务的一等实体。这意味着为新的实体创建一个由 URL 标识的 RESTful 资源,并使用标准的 HTTP 方法来操作它。这使得存储在后端的数据是显式的,而不是一个没有明确结构且难以管理的隐含会话。
限制大型集合
当你的 API 传输一个项目集合(通常表示为 JSON 列表)时,你应该尝试找出集合中预期的项目数量。
如果项目的数量经常超过合理的限制,您应该考虑为集合实现分页解决方案(参见第六章)。集合中的项目数量和每页需要引入分页的项目数量取决于 API 操作的成本。对于快速检索的小项目,数量可以更高。
您还应该考虑在客户端同时处理多大的集合是有意义的。如果项目在屏幕上显示,用户通常一次只能看到几个项目。您可以将您的分页策略基于这个数字。
除了分页之外,您还可以通过确保查询足够具体来限制返回的项目数量。例如,在基于子串的搜索中,您可以强制执行一个规则,即搜索字符串必须至少有三个字符长,这有助于缩小结果并提高效率。
或者,在完整数据检索之前,您可以先执行一个更便宜的数据库查询,只返回匹配项的数量,如果它超过一定限制,您就可以停止并返回一个请求,要求客户端提供更具体的查询。在性能优化的背景下,更便宜意味着花费更少的时间和资源。
在决定集合大小限制时,您还应该考虑 API 操作需要访问的数据源的性质。在分布式(微服务)架构中,您通常需要调用另一个 API 来满足 API 请求。
如果您需要通过单独的 API 调用获取集合中每个项目的某些详细信息,您可以通过并行执行多个此类 API 调用来尝试缩短响应时间。但是,您应该限制并行 API 调用的数量,以避免压倒 API。本章后面的“使用虚拟线程提高吞吐量”部分可能有助于实现并发 API 调用。
另一个选择是检查您服务需要调用的 API 是否支持(或可以被修改为支持)批量操作,例如在一次请求中获取一系列 ID 的详细信息。但您必须对请求的大小保持合理,因为过大的请求可能会花费太长时间,并在客户端到服务器以及返回的路径上的各个点遇到超时。
- 例如,订单管理 API 为每个产品调用产品 API 以获取其价格。我们可以优化这个集成,使产品 API 提供一个批量“获取多个产品”端点。该 API 将产品 ID 列表作为其输入,并一次性返回所有产品的价格。
优化大型对象
我们已经提到,试图优化一切并不明智。帕累托法则表明,大多数(大约 80%)的性能问题是由 API 组成中的少数(大约 20%)的项目引起的。
一个相对常见的情况是,API 的一个小部分负责大部分的字节传输和时间消耗,当你想要传输内部结构对 API 不重要的大型数据块时,这种情况尤为明显,例如照片、视频或文档,这些通常使用二进制格式编码。这些项目对网络传输量、内存消耗和响应时间有显著影响。
避免在结构化(JSON)有效载荷中包含大型(二进制)对象是一种良好的做法。相反,你可以为大型对象定义单独的资源(端点)。这有以下优点:
有效载荷可以使用适合大型对象格式的编码。尝试在 JSON 中嵌入二进制数据通常会导致使用效率低下的编码,如 BASE64。
如果无法避免大型数据对象的基于文本的编码,可以通过结合压缩(如 gzip)来提高网络传输的效率。
大型对象可以独立于其他数据进行缓存。考虑到对象的大小,以及大型对象可能比结构化数据更改频率低,这是有意义的。
在可能的情况下,你也应该尝试限制大型对象的大小。当在客户端设备上显示图像时,通过在服务器上将图像缩放到较低的分辨率可以提高性能。
你可以在一开始就限制大型对象的大小。如果你的应用程序允许用户上传文件,你应该考虑限制上传文件的大小。
在我们的示例产品 API 中,我们在第六章的“通过 REST API 上传和下载文件”部分实现了一个单独的端点用于产品图片。
在那个示例代码中,我们使用MultipartFile
类的方法来获取文件内容作为字节数组。我们还在用于下载文件的方法中返回字节数组。这种方法允许我们的代码保持简单,但缺点是整个文件一次性存储在堆内存中,尽管我们没有进行需要整个文件在内存中的处理。
我们可以通过使用InputStream
/ OutputStream
而不是字节数组来优化内存使用。或者我们可以使用像 WebFlux 这样的响应式框架(更多内容请参阅第十一章)。然而,我们必须走到底,也就是说,也要使用支持流式访问的方法来持久化内容。
缓存
缓存可能是最受欢迎的性能改进策略。然而,缓存并非万能,设计正确的缓存设置并不容易,因此在采用此策略之前,你应该确保你已经考虑了通过应用适当的 API 设计来提高性能的选项,如前几节所述。
缓存涉及权衡:它以额外的内存来存储缓存数据以及缓存数据过时可能导致的潜在不一致性的风险为代价,提供了更短的响应时间。
缓存与缓存失效的挑战紧密相关——确定缓存中数据项何时应该被移除。这可能是因为缓存的数据已经过时,或者为了在不太可能再次访问缓存项时释放内存。缓存失效被认为是计算中最难的问题之一。检测需要从缓存中移除的项目最常见的两种方法如下:
最近最少使用,基于假设最近长时间未使用的资源不太可能再次被使用
最少使用,基于假设经常使用的资源可能会再次被使用
为了加快 API 的操作,我们可以考虑在 API 的消费者(客户端)端(缓存 HTTP 响应)和提供者(服务器)端(缓存执行操作和返回结果所需的数据)进行缓存。
在提供者(服务器)端进行缓存可以采取多种形式:可以是数据库中的数据,昂贵的计算结果,或者下游 API 调用的半处理结果。在服务提供者端进行缓存并不仅限于 API 实现,因此,它超出了本书的范围。对于详细指导,您可以参考 Ahmet Meric 的《精通 Spring Boot 3.0》,它涵盖了基于 Spring 的应用程序中的更广泛的缓存策略。对于使用 JPA/Hibernate 的数据库访问,建议了解一级和二级缓存的概念。对于 Spring 组件级别的通用缓存,您应该了解 Spring Cache(docs.spring.io/spring-boot/reference/io/caching.html
)和@Cacheable
注解。
为了支持数据的缓存能力,产生数据的操作应该是无状态的,这意味着如果输入相同,我们应该得到相同的数据输出。这使我们能够在多个服务实例或使用分布式缓存(如 Hazelcast 或 Redis)的多个服务之间共享缓存数据。这样,一个服务实例放入缓存中的数据可以被需要相同数据的另一个实例重用。
客户端缓存
现在,让我们专注于客户端缓存。由于客户端无法访问服务的实现细节,它必须使用一些提示来决定对于哪些 API 响应以及缓存响应可以使用多长时间,而不是再次调用 API。记住,客户端缓存不必在最终客户端完成;它也可以由位于服务器和最终客户端之间的代理服务器完成。
在我们深入了解缓存控制之前,验证 API 所包含的资源(端点)的结构和粒度非常重要。考虑将很少更改的数据项(它们是缓存的好候选)放置在与经常更改的其他项目分开的资源中。只有这样,缓存才能与数据的修改模式很好地对齐。
使用 GET
方法的请求默认被认为是可缓存的。这个规则被大多数网络浏览器所使用;因此,当页面内容变化速度超过浏览器预期时,用户有时需要强制浏览器重新加载网页(例如,在 Windows 操作系统上按 Ctrl + F5)。
相反,用于在服务器上修改数据的 PUT
、PATCH
和 DELETE
方法,如果没有联系服务器就无法实现,因此它们不适合缓存。
POST
方法可以用来修改服务器上的数据,例如在我们的示例订单管理 API 中创建新的订单。它也可以用于只读操作,通常用于复杂的查询,当你想使用请求体而不是将操作输入放在 URL 或请求标头中时。
不论使用哪种 HTTP 方法,都可以通过标准 HTTP 响应标头来实现更详细的缓存控制:
Cache-Control
:支持对谁(代理或终端客户端)可以缓存带有相同 URL 的响应以及何时(基于验证请求,持续多长时间)的详细指定。ETag
:简称为“实体标签”,一个可以用来检查资源是否已更改的值(与后续对同一 URL 的请求中的If-None-Match
请求标头结合使用)。
旧标头(Expires
、Last-Modified
、Pragma
),尽管为了向后兼容仍然得到支持,但已被 Cache-Control
标头取代。
示例 - 缓存产品照片
我们将通过在第六章中创建的产品照片下载端点上的标准 HTTP 标头来演示客户端缓存。
照片是相对较大的数据对象,预计不会非常频繁地更改,因此它们是缓存的好候选。
首先,我们将使用 Cache-Control
标头来指定客户端可以在缓存中存储照片的时间。在 ProductsApiController
类中,我们将修改 downloadProductPhoto
方法体以包含该标头,指定 20 秒作为缓存图像的时间:
return ResponseEntity.ok()
.contentType(
MediaType.valueOf(photo.getPhotoContentType()))
.cacheControl(
CacheControl.maxAge(20, TimeUnit.SECONDS))
.body(photo.getPhoto());
现在,我们可以启动产品 API 应用程序,通过调用 PUT
端点来添加一个测试产品和其图像:
curl -v -X PUT http://localhost:8080/api/products/AK12345 -d '{"name":"testprod235","description":"test description","price":123.45}' -H 'Content-Type: application/json'
curl -v -F "file=@/home/mv/Documents/image.jpeg" -X PUT http://localhost:8080/api/products/AK12345/photo
请注意,缓存标头告诉客户端它可以将其资源存储在缓存中,但客户端不需要这样做。简单的客户端可能会完全忽略缓存标头。
我们将使用网络浏览器,因为它理解并支持缓存头;然而,它不会从缓存中加载main
资源(在地址栏中输入的 URL 对应的资源)。这就是为什么我们将创建一个名为refer_img.html
的 HTML 文件,并将其用作main
资源,在 HTML 文件中我们将引用我们想要下载或缓存的图片:
<img src="img/photo"/>
现在,我们可以打开一个新的浏览器标签页并打开开发者工具(在 Windows/Linux 上按F12或在 Mac 上按Option + ⌘ + I)。在开发者工具中,我们切换到网络标签。
我们将通过在浏览器窗口中拖放文件来打开refer_img.html
文件。当 HTML 文件首次打开时,浏览器必须发出 HTTP 请求以下载图片:
图 10.1 – 下载缓存中尚不可用的新资源
我们可以看到响应头,包括Cache-Control: max-age=20。
如果我们在 20 秒内重新加载页面,我们应该看到以下内容:
图 10.2 – 浏览器使用缓存资源
状态码旁边的文本和在请求头部分的警告表明这次没有进行真正的网络传输,而是显示了缓存的图片。在响应头部分,我们可以看到访问时间(Date头)与之前相同,因为头部也被缓存了。
如果我们在 20 秒后重新加载页面,图片将新鲜地从服务器下载,并且我们会看到Date头部的值是一个新的时间。
让我们通过ETag
头使缓存更复杂。为了使头部的值仅在图片更改时改变,我们将计算实体标签作为图片字节的哈希值。以下方法使用 SHA-1 算法计算哈希,并使用 Base64 编码将其转换为可打印的字符串:
private String getHashString(byte[] bytes) {
try {
MessageDigest md =
MessageDigest.getInstance("SHA-1");
return Base64.getEncoder().encodeToString(
md.digest(bytes));
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
现在,我们可以将控制器方法更改为使用ETag
头:
return ResponseEntity.ok()
.contentType(MediaType.valueOf(
photo.getPhotoContentType()))
.eTag(getHashString(photo.getPhoto()))
.body(photo.getPhoto());
在重新启动应用程序后,我们可以再次开始测试。页面的第一次加载将正常下载图片:
图 10.3 – 浏览器下载带有新 ETag 的资源
响应头部分包含计算出的哈希字符串的Etag头。
之后,无论我们等待多久,当我们重新加载页面时,我们会看到以下内容:
图 10.4 – 当 ETag 值匹配时,服务器响应“304 Not Modified”
Date头部是新鲜的,这意味着浏览器确实向服务器发送了真实请求。然而,状态码是304 – Not Modified,没有内容被发送回来。
图 10.5 – “304 Not Modified”响应不返回任何内容
这是因为当浏览器发出请求时,它发送了包含之前请求中得到的ETag
值的If-None-Match
请求头。Spring 框架自动将值与新计算出的值进行比较,如果它们匹配,则在响应中返回304
状态码。
让我们检查当我们上传新图像时会发生什么:
curl -v -F "file=@/home/mv/Documents/image2.jpeg" -X PUT http://localhost:8080/api/products/AK12345/photo
在页面下次重新加载时,我们得到以下内容:
图 10.6 – 当 ETag 不匹配时,服务器返回“200 OK”和完整内容
由于Etag值已更改,返回了完整内容,状态码为200 OK。
命令查询责任分离(CQRS)
命令查询责任分离(CQRS)模式旨在分离读和写操作,提高应用程序的性能和可伸缩性。通过将数据修改(命令)与数据检索(查询)分离,每个操作都可以独立优化。这种分离在需要不同可伸缩策略的读和写操作的应用程序中特别有用。
例如,一个高读流量的应用程序可以在不影响写性能的情况下扩展读操作。这防止了在资源密集型的写过程发生时读操作变慢。在我们的例子中,第二章 ,我们在单个应用程序中应用了 CQRS 原则,但在实践中,CQRS 通常涉及部署两个独立的应用程序:一个处理读操作,另一个处理写操作。
通过分离读和写责任,CQRS 通过消除这些操作之间的依赖关系,实现独立扩展,从而提高应用程序的性能。为了确保两个应用程序共享相同的 API 端点,可以使用网关根据正在执行的操作将流量重定向到正确的应用程序。
不需要回显请求数据
当实现 POST 或 PUT 端点时,常用的约定是在 HTTP 响应体中返回创建或更新的实体。我们甚至在我们的书中使用这个模式。这个模式通常与数据模型的复用相关:我们已经有了一个用于请求和 GET 响应中的实体的模型,为什么不用它来处理 POST 和 PUT 响应呢?
HTTP 协议或 REST 架构中没有告诉我们需要在 POST 或 PUT 请求中回显我们得到的内容。相反,有一些反对它的论点:
浪费网络带宽并增加响应时间,尤其是对于大型实体。
客户端已经有了数据,因为它刚刚在请求中发送了它们。
它可能导致客户端产生错误假设,即返回的数据总是最新的,但如果在并行执行对同一实体的其他请求,这可能并不成立。
即使服务器上生成了一些额外的信息,为什么我们会自动假设客户端需要修改的部分或整个实体数据?这违反了单一职责原则:POST 或 PUT 操作预期执行创建/更新。如果客户端需要读取数据,它可以发送 GET 请求。读取和写入操作的不同模型也是应用本章先前解释的 CQRS 原则的自然结果。
异步处理
HTTP,RESTful Web 服务的核心,围绕同步请求-响应通信风格构建,使其易于理解和实现。当谈论异步 API 时,我们通常想到基于消息代理的系统,使用除 HTTP 之外的协议。基于消息的通信是本章第一部分中提到的 RESTful Web 服务的主要替代方案之一。然而,HTTP 也可以支持异步处理。
如果要通过 REST API 公开的操作涉及耗时较长的处理,在返回响应之前等待处理完成会阻塞 API 客户端,使 API 看起来无响应,并可能导致超时。可以通过在读取请求后立即返回响应并将输入数据保存到可靠存储中,来提高此类 API 的响应性。
在这种情况下,标准的响应状态码是202 - 已接受
。长时间运行的处理可以异步继续,而不会阻塞客户端。可以提供一个单独的 API 端点供客户端轮询处理状态,并在完成后接收其结果。
例如,假设我们想要改进示例产品 API 的图片上传端点,使其将图片缩放到标准尺寸或对其进行其他图形增强。这种处理可能耗时较长。在这种情况下,我们可以决定将上传端点更改为异步端点。我们将存储未处理的图片并返回202
状态码。图片增强将异步完成,并提供一个GET
端点以检查图片是否已准备好使用。
在解释了如何通过分析、设计和 HTTP 标准层面的通用方法确保性能和可伸缩性之后,在下一节中,我们将更多地转向实现层面,并关注 Java 平台当前版本的一个特别相关的特性,即与使用 API 进行通信的应用程序的性能和可伸缩性。
使用虚拟线程提高吞吐量
虚拟线程是 Java 的一个特性(自 Java 21 起为 final),与并发处理性能相关,这是一个与 API 实现高度相关的主题。所有服务器端应用都是并发的,因为它们必须处理并发传入的请求。然而,在使用 Spring Boot 等 Java 服务器端框架的情况下,代码可以仅关注一个请求,使用所谓的“每个请求一个线程”模型。更高级的应用可能需要每个请求多个线程。我们将在以下小节中探讨这两种并发用法。
线程垃圾收集器
在进行更精确和详细的描述之前,让我们用一个简单的比喻来解释虚拟线程的概念,即使对于可能觉得线程和并发难以理解的开发者来说也是如此。所有 Java 开发者都必须理解垃圾收集的概念,这是 Java 虚拟机提供的一种抽象,可以解放开发者从分配和释放内存的责任。
当然,你仍然可能会遇到OutOfMemoryError
,但如果你的应用以典型方式使用对象,那么在某个时刻,不再使用的对象不会被任何其他对象引用,因此运行时可以自动检测这些对象,从而释放并重用它们所占据的内存。这种抽象自 Java 的第一个版本以来就被程序员使用,简化了代码,并防止了许多与直接内存访问和分配相关的难以检测的错误,这些错误在 C 或其他没有垃圾收集器的语言编程中是众所周知的。
类似于垃圾收集器对内存的处理,虚拟线程提供了(几乎)无限数量的线程的错觉。实际上发生的情况是,在一个典型的应用中,每个传入的网络请求使用一个线程(更多内容将在下一节中介绍),JVM 与标准库一起可以自动检测平台线程会因为等待(例如,I/O)而被阻塞。为了防止这种情况,虚拟线程的状态被移动到堆中,平台线程被重用来处理其他请求。
对于大多数应用来说,依赖虚拟线程来高效利用平台线程,而不是直接使用平台线程,很可能会依赖于垃圾收集器在每个请求结束时清理内存对象。
图 10.7 – 虚拟线程工作原理的说明,分为层
每个请求一个线程模型
我们的示例 API 使用 Spring Web 实现,这是一个采用“每个请求一个线程”模型的框架。这意味着对于每个 HTTP 请求,框架所使用的 Web 服务器都会分配一个专用的 Java 线程,并在该线程中调用与请求路径和方法匹配的控制方法。
线程每请求模型的优势在于它允许使用经典的命令式编程风格,其中包含按照源代码中出现的顺序执行的代码块。这种代码风格易于阅读和理解。它也易于调试,因为当执行保持在单个线程内时,调试工具中显示的调用栈是完整的,让开发者能够很好地跟踪所有代码级别的函数调用。
然而,没有虚拟线程的线程每请求模型的可扩展性有限,因为操作系统提供的线程(所谓的平台线程)是一种昂贵的资源。每个平台线程都占用相当大的内存,因此 JVM 可以使用的平台线程数量是有限的。使用平台线程的 Web 服务器必须从有限的线程池中获取一个线程来处理传入的请求。如果池中没有剩余的线程,请求将等待直到有线程可用。
控制器方法和从控制器方法调用的任何方法(让我们称其为处理代码)会保留专用线程,直到控制器方法返回或抛出异常,将执行权交回 Spring Web 框架。处理代码中包含阻塞操作是很常见的情况。阻塞操作阻止 CPU 执行有用的工作,因为它在等待所需数据时停止执行。
为了说明这一点,让我们看看订单管理 API 的“创建订单”操作的执行。
首先让我们看看 OrderManagementApiController.java
:
public ResponseEntity<OrderResponseDto> ordersPost(
OrderRequestBodyDto orderRequestBody) {
// ^ involves deserialization from JSON (CPU usage)
final var order = ordersCommandUseCase.createOrder(
orderMapper.toOrderRequest(orderRequestBody));
return ResponseEntity.status(HttpStatus.CREATED)
.body(orderMapper.toOrderResponse(order));
// ^ involves serialization to JSON (CPU usage)
}
接下来,让我们看看 OrdersCommandUseCaseImpl.java
:
public Order createOrder(OrderRequest orderRequest) {
updateProductPrice(orderRequest.products());
// ^ involves I/O
Order order = orderMapper.toOrder(orderRequest);
return ordersRepository.save(// involves I/O
OrderEntity.fromOrder(order, null));
}
private void updateProductPrice(List<Product> products) {
products.forEach(orderProduct -> {
ProductDetails catalogueProduct =
productsQueryUseCase.getProductById(
// ^ involves I/O
orderProduct.getProductSKU());
orderProduct.setPrice(
catalogueProduct.price());
});
}
阻塞操作(通常由网络 I/O 通信的需求引起)可能会对性能产生灾难性的影响,因为在许多应用程序中,它们比周围的非阻塞(CPU 密集型)操作慢几个数量级。通信是分布式应用程序(微服务)处理过程中的一个基本部分。重要的是要理解,在远程 API(或从复杂的数据库查询)传输数据所需的时间通常是数百毫秒到数秒。相比之下,一旦所有数据都可用,执行大多数企业应用程序的业务逻辑所需的 CPU 处理时间要短得多——即使我们考虑更复杂的操作,如 JSON 解析和序列化——它以微秒计。
这意味着在线程每请求模型下,被输入/输出操作阻塞的 CPU 无法用于服务其他请求;在一个负载很重的服务中,超过 99%的时间 CPU 将是空闲的。
解决这个问题的方法之一是使用响应式编程框架(关于这一点将在第十一章中详细介绍),但这意味着放弃之前描述的具有优势的线程每请求模型:易于理解和调试的代码。
虚拟线程很聪明,因为它们允许开发者保持按请求分配线程的模型,同时允许高效地使用平台线程。当一个虚拟线程需要使用 CPU 时,它会被挂载到一个平台线程(也称为 载体线程)上。当一个虚拟线程遇到阻塞操作时,其执行状态会被存储在堆内存中,其载体线程被释放,以便它可以用来执行另一个虚拟线程。当阻塞操作完成时,虚拟线程可以挂载任何可用的载体线程(可能不同于它之前阻塞时使用的那个),从堆中恢复其状态,并继续执行。
与平台线程相比,虚拟线程成本低廉,因此应用程序可以使用数百万个虚拟线程。一个 HTTP 服务器和应用框架可以为每个传入请求提供一个新的虚拟线程,无论有多少请求正在等待 I/O 操作。
要使用虚拟线程来处理传入请求,你只需要配置框架以启用该功能。在 Spring Boot 3.2 或更高版本和 Java 21 或更高版本中,你可以使用以下配置属性:
spring.threads.virtual.enabled=true
你可以继续使用包含阻塞操作的代码风格,知道阻塞的线程将是虚拟线程,CPU 将会被高效地使用。
除了使用按请求分配线程的模型来处理许多传入请求外,一个应用程序可能需要显式地并行执行多个操作来处理单个传入请求。
单个请求内的并行处理
在集成密集型应用程序的上下文中,一个常见的情况是在处理请求时需要调用多个独立的远程 API 来优化延迟。
想象一个报价 API,它调用多个供应商的 API,比较他们的价格,并返回最佳报价。
Java 提供了 ExecutorService
类这样的有用抽象。每个应该并行执行的操作都是一个提交给 ExecutorService
的任务。
当创建 ExecutorService
类时,你指定它应该使用什么策略来管理执行任务的线程。如果没有虚拟线程,典型的策略将是使用固定大小的线程池。线程池允许一定数量的并行任务,但数量是有限的,因为平台线程的数量是有限的。
使用虚拟线程,我们可以让 ExecutorService
为每个任务使用一个新的虚拟线程,而不需要线程池,也不需要指定该池的大小:
try (ExecutorService es = Executors.newVirtualThreadPerTaskExecutor()) {
es.submit(()-> { /* task 1 */ });
es.submit(()-> { /* task 2 */ });
// ...
};
我们简要解释了,简而言之,虚拟线程通过最小化空闲线程并确保更多时间用于执行有用操作来提高 CPU 效率。这种直接效果是吞吐量的增加(单位时间内完成的工作量)。然而,吞吐量和响应时间通常是相关的。增加的吞吐量意味着给定的 CPU 功率可以处理更高的负载,而无需某些请求等待线程变得可用。因此,响应时间也可以得到改善。
到目前为止,我们讨论了可以在单个服务内应用的方式来提高性能和可伸缩性。然而,位于通过 API 连接的各个组件之间的基础设施组件通常也参与实现整个分布式系统(应用程序)的满意性能和可伸缩性。下一节将简要介绍这些基础设施组件。
虚拟线程固定
在某些情况下,执行阻塞操作的虚拟线程无法释放平台线程以供其他虚拟线程使用。使用官方术语描述这种情况,我们说虚拟线程无法从其载体卸载,因为虚拟线程被固定在载体线程上。
截至 Java 21,线程固定可以发生在两种情况下。
虚拟线程固定的第一个情况是当虚拟线程正在执行本地代码。这是不可避免的,因为 JVM 无法安全地从非 Java 代码中移除平台线程。
第二种情况是当虚拟线程在同步块或方法内运行代码。这是 JVM 内部对虚拟线程支持实现的一个限制。如果你的应用程序代码或其依赖项(如 Web 服务器或库)使用 Java 同步结构,这可能会限制虚拟线程的性能,与预期相比。在某些情况下,甚至可能导致死锁,其中所有可用的平台线程都被固定线程阻塞,等待同步锁。
这意味着你不应该盲目地开启虚拟线程。建议你在应用程序中启用和禁用虚拟线程的情况下进行性能测试。你应该了解你的应用程序依赖项,并尝试使用它们的最新版本。记住,Spring Boot 默认使用嵌入式 Tomcat 作为 Web 服务器(servlet 容器),但它也支持切换到其他 Web 服务器。
由于同步结构引起的虚拟线程的固定问题在 Java 24 中已被消除。因此,即使你使用的 Java 版本支持你应用程序需要的所有功能,尽快升级到新版本的 Java 也是一个好主意,以获取对使用的 Java 功能的实现进行的修复和改进。Java/OpenJDK 的新版本还带来了工具改进,可以帮助诊断和修复各种性能问题,可能包括虚拟线程固定问题。
使用基础设施支持
理想情况下,你希望设计和实现你的 API,以便它们可以在不依赖于特定基础设施设置的情况下良好工作。然而,软件开发者应该意识到,他们开发的服务之外的外部组件可以用来增强系统的性能和可伸缩性。
存在几种类型的基础设施组件:
负载均衡器:一个组件将请求分配给多个提供相同 API 的服务实例。负载均衡器对于水平扩展是不可避免的。
代理:一个同时是 HTTP 服务器和客户端的组件。它接收一个请求,通过向实际提供 API 的服务发送另一个请求来满足它。代理允许我们在客户端和服务器之间添加各种功能,其中缓存对于性能来说是最重要的:
一个接收带有公共 URL 的请求并将其转换为私有网络内的请求的代理被称为反向代理。
结合不同功能支持 API 的代理也被称为API 网关。
内容分发网络(CDN):这是一个地理分布的代理网络,客户端可以通过附近的代理访问 URL,从而缩短响应时间。
这里提到的组件通常结合性能和安全问题。一个重叠明显的例子是防御拒绝服务(DoS)和分布式拒绝服务(DDoS)攻击。这涉及到限制来自单一来源的过多流量,以确保服务对所有客户端都可用。
从这个意义上说,嵌入 API 提供服务的架构可能包含多个组件,这些组件通常超出了 API 开发者的控制。这也是为什么 API 设计的最佳方法是要遵循 HTTP 和其他互联网标准(RFCs)定义的标准。这将确保你的 API 与周围的基础设施良好工作。
我们已经讨论了几个可能出现的性能和可伸缩性问题,以及解决它们的方法。然而,在本章的最后部分,展示如何检查特定服务在特定负载下的性能是至关重要的,因为只有通过这种方式,才能检测并针对实际问题进行解决。
设计和执行有效的负载测试
第八章 解释了各种可以用来确保 API 正确性的测试,这意味着这些测试检查 API 及其背后的服务是否按照预期
处理输入数据,并以预期的输出数据响应。它们关注输入和输出数据的详细属性的确切值及其语义,以验证应用程序的功能需求。为此,它们尝试不同的测试数据组合,以覆盖最常见的用例以及负面场景和边缘情况。
在性能和可扩展性的背景下,我们从不同的角度测试应用程序。而不是按功能需求对测试用例进行分类并检查确切的数据值,我们通常尝试一组较小的用例,这些用例以它们对应用程序施加的预期负载为特征。
然而,发送到应用程序的相同或非常相似的请求的重复次数将显著增加。时间方面非常重要,因此请求的频率模拟了真实流量,包括负载波动。发送多个并发请求以模拟许多用户同时使用应用程序。
与其他类型的测试相比,负载测试中的这些差异意味着尽管可以使用相同的工具进行两者,但使用专门从事负载测试的工具进行负载测试更容易、更高效,并且提供更准确的结果,并以适当的形式呈现。
示例 - 对订单管理 API 进行负载测试
我们将使用名为 Gatling 的工具(其文档可在docs.gatling.io/
获取)对我们的示例订单管理 API 进行负载测试。Gatling 与 Java 集成得很好,因此我们可以将负载测试包含在我们的 Java 源代码中,并通过 Maven 插件运行它,而无需安装单独的程序。
首先,我们将添加 Gatling 依赖项(带有test
范围)到pom.xml
文件中:
<dependency>
<groupId>io.gatling</groupId>
<artifactId>gatling-app</artifactId>
<version>3.7.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.gatling.highcharts</groupId>
<artifactId>gatling-charts-highcharts</artifactId>
<version>3.7.2</version>
<scope>test</scope>
</dependency>
然后,我们可以开始编写测试类,也称为 Gatling 模拟。我们的模拟将尝试通过订单管理 API 创建许多新订单:
public class CreateOrderSimulation extends Simulation {
测试的最高级别位于类构造函数中:
public CreateOrderSimulation() {
setUp(POST_SCENARIO_BUILDER.injectOpen(
rampUsersPerSec(10).to(300)
.during(Duration.ofSeconds(10)),
constantUsersPerSec(300)
.during(Duration.ofSeconds(80)))
.protocols(HTTP_PROTOCOL_BUILDER))
.assertions(
global().responseTime().max().lte(5000),
global().successfulRequests().percent().gt(90d));
}
代码引用了HTTP_PROTOCOL_BUILDER
和POST_SCENARIO_BUILDER
常量,这些常量描述了应该发送到测试 API 的请求。injectOpen
方法指定了请求的时机和并行化:
在模拟的前 10 秒内,用户(请求)的速率从每秒 10 个增长到每秒 300 个。
在接下来的 80 秒内,请求的速率保持恒定,每秒 300 个。
最后,我们可以看到测试断言,预期最大响应时间为 5,000 毫秒,并且超过 90%的请求应该成功。
为了使性能差异更明显,我们将修改产品 API(订单管理 API 所依赖的 API)的代码,在ProductsQueryUseCaseImpl
类的getProductById
方法中添加 1 秒的人工延迟:
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
现在,我们可以启动产品 API 应用程序。我们的测试订单包含产品 ID AK21101,因此我们需要使用产品 API 来创建它。我们可以使用 Swagger UI,curl
命令,或者如果我们使用 IntelliJ IDEA,我们可以使用一个简单的文本文件,具有.http
扩展名来发送 HTTP 请求:
PUT http://localhost:8080/api/products/AK21101
Content-Type: application/json
{
"name": "Keyboard",
"description": "Ergonomic Keyboard",
"price": 60
}
接下来,我们禁用这两个配置属性并使用#
字符注释掉它们来启动产品管理 API:
hikari:
# maximum-pool-size: 500
threads:
virtual:
# enabled: true
通过运行以下 Maven 命令启动负载测试:
mvn gatling:test
在测试运行过程中,我们可以观察到当负载达到一定点后,许多请求失败(KO
计数,表示not OK
,正在增长)。
整个测试失败,因为响应时间和成功请求的百分比都没有满足。
测试的详细图形可视化可以在target/gatling
目录中找到:
图 10.8 – 由于服务无法处理负载,大多数请求失败
现在,让我们尝试通过在订单管理 API 中启用虚拟线程来提高性能:
threads:
virtual:
enabled: true
在重新启动应用程序和 Gatling 测试后,我们得到了不同的结果。
图 10.9 – 启用虚拟线程揭示了数据库连接池的瓶颈
成功响应的数量略有增加,但仍然,大多数都失败了。
查看订单管理 API 应用程序的控制台日志输出,我们发现许多以下错误的重复:
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms.
通过启用虚拟线程,我们消除了线程池对应用程序能够并行处理请求数量的限制。然而,应用程序的另一个部分现在已成为瓶颈,即数据库连接池。
Hikari 连接池的默认限制是 10 个数据库连接,考虑到产品 API 的 1 秒延迟,这很快就会被耗尽。订单管理 API 必须等待直到它可以完成数据库事务并释放连接。为了消除瓶颈,我们将最大连接数设置为500
:
hikari:
maximum-pool-size: 500
注意,这只是一个简单的例子,目的是使虚拟线程的影响非常明显。在实际应用程序中,如此高的连接数可能会引起数据库问题,我们可能不得不使用不同的解决方案,例如只在从产品 API 获取价格后开始事务,或者修复产品 API 的长时间响应。
在这个更改之后,我们可以重新启动订单管理 API 和 Gatling 测试。
现在,最终,我们的测试通过了,结果非常令人满意:
图 10.10 – 使用虚拟线程的服务可以处理高频率的请求
所有请求都正常,所有从产品 API 返回的响应时间都非常接近 1 秒。
摘要
在本章中,我们学习了在 RESTful API 的背景下性能和可扩展性的具体细节。我们探讨了各种设计方法和技巧来提高这两个非功能性要求。我们看到了一个使用缓存头来减少客户端和服务器之间网络流量体积的例子。我们深入探讨了虚拟线程,这是新 Java 版本中高度相关的性能特性。最后,我们通过负载测试验证了预期性能改进的实现。在下一章中,我们将展示本书中描述的原则如何应用于 Spring Boot 之外的 Java 后端框架,以及如何通过使用社区驱动的标准来使你的应用更具未来性和供应商中立性。
第十一章:构建 RESTful API 的替代 Java 框架
到本章为止,我们一直使用 Spring Boot 框架来演示真实代码中的 API 实现。通过使用非常流行的 Spring Boot,我们试图使本书对尽可能多的读者来说既可读又实用。
现在是时候解释为什么以及如何使用其他 Java 框架来实现 RESTful 网络服务了。
您将看到相同的设计原则和类似的代码结构仍然适用,因此您可以使用其他章节中的概念,以适应您可能想要用于特定情况的任何框架。
首先,我们将解释 jakarta EE 和 MicroProfile 等标准如何帮助您在众多框架和实现堆栈中不迷失方向,这些框架和实现堆栈可用于构建您的应用程序。我们将简要提及使用大多数当前框架中可用的反应式编程的可能性。
当前选择实现技术是历史上创新方法的历史结果,旨在帮助开发者创建满足当前架构和部署偏好的应用程序。我们将从 Java EE 逐步引导您到 Quarkus 和 MicroProfile 规范等框架。
为了提供详细的视角,我们将通过与我们之前章节中 Spring Boot 实现相比的 Quarkus 和 Helidon 框架的示例 API 实现中的相似性和差异性来展示。
这导致了以下章节列表:
理解标准的好处
选择命令式或反应式
Java EE 和 Jakarta EE
Spring 框架和 Spring Boot
新的微服务框架和 MicroProfile
使用 Quarkus 的示例实现
使用 Helidon 的示例实现
技术要求
在本章中,我们将演示除了 Spring Boot(在前面章节中使用)之外的其他框架的使用。然而,必要的依赖项将在项目构建过程中自动下载,基于 Maven pom.xml
文件,因此先决条件保持不变:
对 Java 语言和平台有中级了解
至少对 Spring Boot 或类似框架有基本了解
安装了 Java 21 和 Maven 3.9.x(或者,您可以使用示例代码中包含的
mvnw
命令,这将下载正确的 Maven 版本)
您可以在 GitHub 上访问本章的代码:github.com/PacktPublishing/Mastering-RESTful-Web-Services-with-Java/tree/main/chapter11
。
理解标准的好处
Java 是一种多才多艺的语言,拥有庞大的开源和商业框架和库生态系统。因此,您可以选择的选项比您能够完全评估的要多。
你甚至可以尝试实现自己的 HTTP 服务器或客户端和自定义框架,尽管除了教育目的之外不推荐这样做,因为使其生产就绪和维护所需的细节捕捉成本极高。
选择一个框架或任何你的软件将依赖的组件是困难的。这涉及到预测你的软件将需要的未来功能和框架(依赖)的未来发展和支持。
例如,基于 Java 企业版标准的应用程序需要部署到应用程序服务器。后来,频繁部署新应用程序版本、云部署和可伸缩性要求可能需要更快的启动时间、更小的容器大小,以及取消对应用程序服务器的依赖。或者,运维团队有经验的应用程序服务器版本可能已经过时,这将限制可以使用的 Java 版本。
一种常见的方法是在拥有大量用户和开发者的框架之间进行选择。但我们也希望新的创新框架进入市场,尽管它们的社区自然会从很小开始。
幸运的是,已经创建了供应商中立的规范和标准,以防止 Java 社区因互不兼容的框架而分裂。通过选择符合标准的框架,你可以保留切换到同一标准的不同实现的选择。如果开发者熟悉标准,即使没有特定于提供的框架的经验,他们也会更容易开始工作。
因此,在接下来的章节中,我们不仅关注单个框架,还关注它们支持的标准(规范)。
选择命令式或响应式
在前面的章节中,我们使用了 Spring Web 框架的命令式编程风格。
命令式编程风格通过一个简单的 Java 方法来模拟 API 操作,该方法将 HTTP 请求中的数据作为参数,并返回一个将用于生成 HTTP 响应的结果对象。方法体由按代码中出现的顺序顺序执行的语句组成,使用一个 Java 线程。
在下面的代码片段中,我们可以看到statement1
、statement2
等语句,这些语句将在一个线程中依次执行,访问相同的变量作用域和相同的调用栈。
@GetMapping // or other similar annotation
ResultType operationName(InputType1 input1,
InputType2 input2...) {
statement1;
statement2;
...
return ...;
}
一个类似的代码片段显示了当结果包含多个实体时具有相同的结构。这次,结果类型是一个众所周知的 Java 集合(List
)。
@GetMapping // or other similar annotation
List<ResultType> collectionReturningOperationName(
InputType1 input1, InputType2 input2...) {
statement1;
statement2;
...
return ...;
}
这种风格被称为命令式,因为程序被理解为一系列命令的序列,明确告诉计算机按何种顺序执行哪些步骤。在这个模型中,数据只是命令的参数。
除了“经典”的命令式风格外,在大多数现代框架中,您还可以选择使用反应式风格。例如,在 Spring 框架家族中,您可以使用Spring WebFlux。
反应式编程风格建立在异步处理数据流的思想之上。程序执行被理解为由数据流驱动。程序通过一系列函数(数据转换)对传入的数据项做出反应。
使用反应式风格处理单个请求的步骤是异步执行的,并且通常在不同的 Java 线程之间跳跃。
已创建了几个库来支持 Java 中的反应式编程,其中包括RxJava和Project Reactor。它们都基于可观察的设计模式共享共同特性。它们的接口在反应式流规范中得到了标准化(www.reactive-streams.org/
)。从 Java 版本 9 开始,反应式流规范中的接口已成为标准 Java API 的一部分,作为嵌入在java.util.concurrent.Flow
类中的接口。
现在我们将前面的示例与使用 Spring WebFlux 的类似代码进行比较:
@GetMapping // or other similar annotation
Mono<ResultType> operationName(InputType1 input1, InputType2 input2...) {
return reactiveDataSource()
.map (item -> transformation 1)
.map (transformation 2...);
}
@GetMapping // or other similar annotation
Flux<ResultType> collectionReturningOperationName(InputType1 input1,
InputType2 input2...) {
return reactiveDataSource()
.map (item -> transformation 1)
.map (transformation 2...);
}
使用反应式风格,处理程序方法返回一个特殊反应类型。对于 WebFlux(使用 Project Reactor 库作为反应式流实现),结果类型将是Mono
(最多一个数据项)或Flux
(一个可以包含任何数量项的集合的反应式类似物)。
反应式处理程序方法在甚至第一个请求开始处理之前就被调用并返回。它仅用于声明式地构建每个请求应执行的异步转换的管道。实际的请求处理由反应式框架控制,具体取决于可处理的数据的可用性。
通过将处理分解为单独的异步步骤(转换函数),反应式编程风格允许框架在数据可用时及时使用计算资源。
为了使反应式框架高效,期望管道步骤(即前面示例中 map 函数的参数函数)是非阻塞的:它们不应包含同步等待输入/输出完成的语句。在 WebFlux 的传入 API 请求层(Controller
类)上使用反应式编程风格并不构成正确的反应式实现。相反,一个反应式程序应该在所有层,包括数据库访问和下游 API 调用中都是反应式的。
反应式编程范式使得开发极其可扩展的服务和系统成为可能。在其他特性中,反应式流增强了实现单个处理步骤的代码部分之间的通信:
预期会发生故障,并且也可以通过响应式进行处理;响应式流为此有一个单独的错误通道。这与普通的 Java 流不同,在 Java 流中,故障(Java 异常)会中断整个管道,不允许在没有显式捕获子句的情况下继续处理。
数据消费者可以通过所谓的背压来控制生产者发送给他们的数据速率。
另一方面,在响应式编程中,我们必须付出将实现代码分解成小型的非阻塞函数的代价,这些函数能够在不同的线程上运行。由于响应式编程具有异步和多线程的特性,以及需要遵守避免阻塞输入/输出的纪律,因此它增加了复杂性。它给开发者带来了更大的认知负担,因为他们需要能够在管道声明和运行时执行之间进行转换。跨越多个线程的请求处理使得调试更加困难,因为导致特定执行点的步骤与当前线程的堆栈跟踪不一致。因此,建议确保正在开发的应用程序确实需要能够证明增加复杂性的可扩展性水平。
如果你唯一需要解决的问题是以高效的方式使用线程,以便 CPU 核心不会空闲等待阻塞操作,那么可以使用目前可用的 Java 版本来实现,而无需承担引入响应式编程的成本。这一点在第十章中,通过虚拟线程提高吞吐量这一节中已经解释过。
为了总结选择后端框架的命令式与响应式选项的标准,尝试回答以下问题。你的答案越接近“是”,就越有可能使用响应式实现:
可扩展性是否是你在优化的功能中的首要考虑?
团队是否准备好接受响应式编程带来的认知负担增加?
你是否需要高级流控制功能,如背压?
是否不能使用虚拟线程?
你的应用程序的所有层(数据库访问、下游 API 调用等)都可以是响应式的吗?
在接下来的几节中,我们将探讨各种 Java 服务器端实现框架,按相关标准分组,并简要介绍一些历史背景。在我们进入下一节之前,这里有一个表格,提供了主要 Java 标准和它们相关的服务器端框架实现的按时间顺序概述。
标准 | 年份 | 实现 |
---|---|---|
Java 2 EE | 1999 | |
2000 | ||
2001 | ||
2002 | ||
2003 | ||
2004 | Spring Framework 1.0 | |
2005 | ||
2006 | ||
2007 | ||
2008 | ||
JAX-RS in Java EE 6 | 2009 | |
2010 | ||
2011 | ||
2012 | ||
2013 | ||
2014 | Spring Boot 1.0 | |
2015 | ||
MicroProfile 1.0 | 2016 | |
Java EE 8 | 2017 | |
2018 | Micronaut 1.0 | |
2019 | Helidon 1.0, Quarkus 1.0 | |
Jakarta EE 9 | 2020 | |
2021 | ||
Jakarta EE 10 | 2022 | |
2023 | ||
MicroProfile 7.0 | 2024 | |
Jakarta EE 11 | 2025 |
表 11.1 – 标准和框架的时间线
Java EE 和 Jakarta EE
Java 语言和平台可以用来开发各种类型的应用程序;然而,Java 最成功和最受欢迎的领域是服务器端(后端)应用程序。
Java Enterprise Edition (Java EE) 是一组标准 API,它扩展了 Java Standard Edition (Java SE,通常简称为“Java”,由各种基于 OpenJDK 项目构建的产品实现) 以支持服务器端应用程序的开发。
从 Java 2 Platform, Enterprise Edition 到 Jakarta EE
Java EE 以 Java 2 Platform, Enterprise Edition (J2EE) 的名称开始,与 Sun Microsystems 发布的 Java (SE) 版本 2 一起。Java 商标和 Java EE 转移给了 Oracle,后者在 2010 年收购了 Sun Microsystems。Java EE 的最后一个版本是 Java EE 8,于 2017 年发布。
在 Java EE 8 之后,Java EE 代码和文档捐赠给了 Eclipse 基金会,规范从版本 9 开始命名为 Jakarta EE (jakarta.ee/
)。相应的 Java 包从 javax.*
重命名为 jakarta.*
。
JEE 容器的类型
Jakarta EE 规范的实现被称为 Jakarta EE 容器(也称为应用服务器),Jakarta EE 应用程序可以以 Web 归档 (WAR) 或 企业应用程序归档 (EAR) 的形式部署到其中。有各种商业和开源实现。
应用服务器可以是完整的 Jakarta EE 容器(例如,WebSphere、OpenLiberty、JBoss、GlassFish、Payara 或 TomEE),或者它只能是一个仅支持 Jakarta EE API 子集的 servlet (web) 容器(例如,Jetty 或 Apache Tomcat)。Servlet API 是 Jakarta EE 应用程序服务 HTTP 请求的标准方式。
从 Servlet API 到声明式端点处理方法
Servlet API 模拟了 HTTP 服务器如何看待通信:主要对象是 HTTP 请求和响应,封装了所有细节,如 HTTP 方法、URI 路径、头信息和请求参数。处理方法必须显式(命令式)实现读取它感兴趣的请求项并将其设置在响应中的逻辑。有效负载作为 Java I/O 流访问。处理方法必须在字节流和结构化数据对象(模型类)之间进行转换。
另一个 Jakarta EE API 规范,Jakarta RESTful Web Services(JAX-RS),基于 Servlet API 构建,通过提供一个声明式编程模型来简化 RESTful Web 服务的实现,在该模型中,开发者只需实现带有相应资源路径和 HTTP 方法注解的处理方法。JAX-RS 实现会自动将 HTTP 请求调度到匹配的处理方法。它还会反序列化请求体并序列化响应。在 Jakarta EE 中,JAX-RS 就像 Spring 生态系统中的 Spring Web MVC。
由于处理类提供 REST 资源,惯例是将它们称为“资源”,例如,一个提供操作产品的方法的类可以命名为ProductResource
。资源路径由@Path
注解指定,并且有用于指定 HTTP 方法的注解:@GET
、@POST
、@PUT
等。你可以看到资源类就像 Spring Web 中的控制器类。
Spring 框架和 Spring Boot
早期版本的 Java EE(最著名的是企业 JavaBeans)因对开发者来说难以使用且需要大量样板代码而臭名昭著。JAX-RS(如上所述)和 CDI(依赖注入标准)直到 2009 年发布的 Java EE 第 6 版才加入 Java EE。
那种情况促使Spring 框架(spring.io/
)的创建,其 1.0 版本于 2004 年发布。在众多特性和模块中,它提供了对依赖注入、面向方面编程和 Spring Web MVC 的支持,该模块通过控制器类实现 Web 服务的实现。
由于开发者体验的改进,包含Spring Web MVC(通常简称为Spring Web)和许多其他模块的 Spring 框架已经变得非常流行,并且是目前开发服务器端 Java 应用程序最常用的框架。
将应用程序部署到单独的软件产品,即 Java EE 容器,被视为开发者不必要的额外步骤。应用服务器的升级需要组织协调,这导致它们变得复杂且缓慢,阻碍了技术现代化,包括对新 Java(SE)版本的升级。
为了防止一个应用程序的 CPU 和内存使用或特定扩展影响其他应用程序,通常的做法是为每个应用程序使用单独的应用服务器实例。
Spring Boot,首次发布于 2014 年,带来了开发可以直接运行而无需单独应用服务器的独立 Spring 应用程序的可能性。该应用程序可以打包为一个简单的**Java 归档(JAR)**文件,其中包含一个嵌入的 Web 服务器(默认为 Tomcat)。简化部署,以及其他如易于配置和合理的默认值等特性,使得 Spring Boot 非常适合开发微服务。
虽然 Spring 框架和 Spring Boot 已成为后端 Java 开发的既定标准,但重要的是要理解 Spring Web 和许多其他 Spring 模块只是 Java/Jakarta EE 之上的一个层。当使用 Spring Web 时,你不仅使用 Spring 特定的抽象,还直接使用 Java/Jakarta EE 功能:Servlet API、Java Bean 验证 API 等。通过使用特定的 Spring 版本,你自动依赖于特定的 Java/Jakarta EE 版本。
新的微服务框架和 MicroProfile
由于其悠久的历史,Jakarta EE 和 Spring Boot 是最成熟和最知名的 Java 服务器端应用框架,为许多开发者提供了可靠性和稳定性。然而,在 2018-2019 年左右,新的框架如 Micronaut、Helidon 和 Quarkus 逐渐出现,它们不受向后兼容性的限制,可以选择创新的应用开发方法,更好地满足微服务和云部署的当前挑战。
高度可扩展的云原生应用需要能够做到以下事项:
快速启动新的服务实例以适应更高的负载(扩展)或失败后的重启
尽可能少地使用 CPU、内存和网络资源,以优化云服务成本
这样的应用应具备以下特点:
小的应用程序可执行代码大小,以最小化将应用程序传输到新服务实例应运行的云节点所需的时间延迟和网络流量
短的服务启动时间(服务在能够处理请求之前准备的时间)
小而稳定的内存消耗(即使服务在启动时需要更多内存,也只需要更大、更昂贵的云计算实例)
新的微服务框架通过以下功能来解决这些挑战:
限制库依赖项的数量和复杂性
在应用程序构建期间用静态代码生成替换应用程序启动期间的动态处理(应用程序配置、依赖注入和面向切面编程)
避免使用反射、动态类加载或动态(代理)类创建
结果应用程序非常适合进行预编译到平台原生代码(使用 GraalVM – see www.graalvm.org/
)
另一方面,新框架的出现带来了如何避免 Java 后端开发者社区碎片化的问题,以及如何克服由于缺乏熟悉其编程模型的开发者而导致的框架采用障碍的问题。
Micronaut 框架试图通过提供类似的抽象(控制器)、支持许多 Spring 注解以及其他兼容性功能来帮助从 Spring 和 Spring Boot 过渡,从而使过渡过程更加顺畅。
所有新的框架在某种程度上都试图利用 Java/Jakarta EE 标准周围的经验和广泛社区潜力。由于它们的轻量级特性,它们选择仅实现 Jakarta EE 规范的部分内容。相比之下,它们确实实现了分布式云原生应用所需的附加功能,例如声明式 REST 客户端、可观察性、容错性等,这些功能在 Jakarta EE 中并未标准化。
许多上述功能已在MicroProfile规范(microprofile.io/
)中标准化,其 1.0 版本于 2016 年创建。在其创建后不久,MicroProfile 项目加入了 Eclipse 基金会。本书撰写时,可用的最新 MicroProfile 版本是 7.0,它与 Jakarta EE 的 10.0 版本保持一致。
与 MicroProfile 重叠的 Jakarta EE 子集被称为Core Profile。在本书的上下文中,Core Profile 中最有趣的部分是 Jakarta RESTful Web Services、Jakarta JSON Binding 和 Jakarta JSON Processing。Core Profile 还包含名为 Jakarta CDI Lite 的轻量级依赖注入框架、Jakarta Interceptors 以及显然是作为公共依赖的 Jakarta Annotations。
MicroProfile 7.0 通过以下模块扩展了 Core Profile,这些模块对于 API 和微服务实现非常相关:
OpenAPI:从代码生成 OpenAPI 规范,对于代码优先方法,我们在第三章中展示了其 Spring 替代方案
REST 客户端:用于使用代码优先方法消费 REST API(我们在第四章中使用了规范优先方法,并生成了客户端代码)
容错性:也称为弹性,在第六章中讨论
JWT身份验证:API 安全性的重要部分,在第七章中讨论
遥测:提供可观察性,我们使用 Spring 展示了这一方面,在第九章中
健康:由 Kubernetes 等编排器用于探测服务实例的健康状况和准备就绪状态,这是 Spring Boot 中 Actuator 提供的一项功能,我们将在第十二章中使用 Spring 展示这一点
配置:灵活的配置选项,云原生服务的一个常见要求
在列出 MicroProfile 规范涵盖的抽象概念之后,在接下来的章节中,我们将展示使用两个完全实现 MicroProfile 规范的框架(Quarkus和Helidon)的 API 实现的小示例。
使用 Quarkus 的示例实现
Quarkus (quarkus.io/
) 是一个提供优化启动时间和内存使用的框架。Quarkus 以多种方式简化了部署到 Kubernetes 和云的过程。尽管相对较新,但由于其卓越的开发者体验,它已被迅速采用于许多企业级应用开发项目中。
在其他特性中,Quarkus 以其*开发模式(dev mode)*而自豪,该模式检测 Java 源文件或资源文件(例如,配置属性)中的更改,自动重新编译它们,并在可能的情况下将它们应用到正在运行的应用程序(执行所谓的热部署)而不需要显式重启。在开发模式下,开发者可以快速获得反馈,而不会因为长时间的应用程序重建和重启而失去对代码的关注。
我们将从实现产品 API 开始我们的 Quarkus 示例,就像我们在第二章中使用 Spring Boot 所做的那样。
暴露产品 API
我们将使用与第二章中相同的方式设计端点:
GET /products
用于列出所有产品PUT /products/{id}
用于创建或更新一个产品DELETE /products/{id}
用于删除一个产品PATCH /products/{id}
仅用于更新产品描述GET /products/{id}
用于获取一个产品的数据。
我们将使用相同的 Clean Architecture 结构,因此对于暴露 REST API,我们将关注*.adapter.inbound.rest
包。包中的主要类是资源类。
资源类
根据 Jakarta REST 术语,处理 REST 资源端点的类命名为ProductResource
。资源端点的基 URL 路径使用@Path
注解指定:
@Path("/api/products")
public class ProductResource {
每个端点都由一个处理方法实现。让我们从createOrUpdateProduct
操作开始:
@PUT
@Path("{productId}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response createOrUpdateProduct(
@PathParam("productId") @ValidSku String productId,
@Valid ProductInput productInput) {
方法和其参数上有多个注解:
@PUT
: HTTP 方法@Path
: 端点相对路径@Consumes
: 预期的请求内容类型@Produces
: 响应内容类型@PathParam
: 绑定到 URL 路径参数的参数
内置的@Valid
和自定义的@ValidSku
Bean 验证注解的使用方式与 Spring Boot 相同。
Response
方法的返回类型,类似于 Spring Boot 中的ResponseEntity
,允许方法体动态决定 HTTP 响应代码和额外的响应头。
列出所有产品的操作方法相当简单;所需的唯一注解是 HTTP 方法,并且方法直接返回 HTTP 响应体:
@GET
@Produces(MediaType.APPLICATION_JSON)
public List<ProductOutput> getProducts() {
通过 ID 获取一个产品的操作方法类似,但除此之外,它还需要productId
路径参数:
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("{productId}")
public ProductOutput getProductById(
@PathParam("productId") @ValidSku String productId) {
更新产品描述的方法如下:
@PATCH
@Path("{productId}")
public ProductOutput editProduct(
@PathParam("productId") @ValidSku String productId,
@Valid ProductDescriptionInput input) {
最后,删除一个产品的操作方法:
@DELETE
@Path("{productId}")
public Response deleteProduct(
@PathParam("productId") @ValidSku String productId) {
遵循标准
请注意,在资源类中,我们故意决定只使用以 jakarta.
前缀开始的包中的注解,以便我们的代码符合 MicroProfile 规范。我们可以使用 Quarkus 支持的其他注解(在某些教程中推荐)——例如,使用 org.jboss.resteasy.RestPath
而不是 jakarta.ws.rs.PathParam
——并且我们可以省略指定参数名称("productId"
),因为它可以从方法参数名称中推断出来。然而,这将使代码变得特定于 Quarkus。正如本章前面所述的原因,即使这意味着牺牲一些特定实现可能提供的便利,使用标准化 API 也是有意义的。
包的其余部分(DTOs、mappers 和 ValidSku 注解)是框架无关的,因此代码与 Spring Boot 相同。
为了通过将异常映射到正确的 HTTP 响应来处理异常,MicroProfile 支持以下章节中描述的 ExceptionMapper 接口。
异常映射器
对于我们的示例 API,我们希望为两种异常类型定义映射:
EntityNotFoundException
,表示未找到具有给定 ID 的产品ValidationException
,表示某些输入不符合 Bean 验证注解
两个映射类相似,所以我们只需展示其中的第一个:
package com.packt.productapi.adapter.exception;
import com.packt.productapi.adapter.inbound.rest.dto.ProblemDetail;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
@Provider
public class NotFoundExceptionMapper implements ExceptionMapper<EntityNotFoundException> {
@Override
public Response toResponse(EntityNotFoundException
entityNotFoundException) {
return Response.status(Response.Status.NOT_FOUND)
.entity(new ProblemDetail(
Response.Status.NOT_FOUND.getReasonPhrase(),
Response.Status.NOT_FOUND.getStatusCode(),
entityNotFoundException.getMessage()))
.build();
}
}
可以看到,ProblemDetail
DTO 是在我们的代码中定义的,因为它不是由 Jakarta REST 提供的。
在以下章节中,我们将简要介绍我们应用程序的其余部分并测试 REST API。
完成并测试 Quarkus 应用程序
使用 Clean Architecture 解耦通信层的 domain
和 usecase
包中的类,在从 Spring Boot 切换到 Quarkus 时不需要更改。
要创建一个可工作的 Quarkus 应用程序,我们需要实现持久性。我们使用 Jakarta Persistence 将数据库表映射到实体类。然而,为了能够使用 Repository 模式(就像我们使用 Spring Data 一样)访问数据库,我们必须使用 Panache,这是 Quarkus 特有的模块。
在 MicroProfile 中,持久性的仓库模式没有标准化;然而,在 Jakarta EE 11 中有新的 Jakarta Data 规范。
为了将我们应用程序的组件连接起来,我们使用 Jakarta Contexts and Dependency Injection (CDI) 功能的依赖注入,这些功能由 MicroProfile 支持(@ApplicationScoped
和 @Inject
注解)。
由于持久性和依赖注入不是特定于 RESTful Web 服务实现的,我们不会详细解释如何使用它们与 Quarkus、MicroProfile 或 Jakarta EE。
当我们完成应用程序代码后,我们可以使用方便的 Quarkus 开发模式启动应用程序:
./mvnw compile quarkus:dev
然后,我们可以使用 curl
命令尝试 API 端点,使用 PUT
方法创建产品:
curl -v -X PUT http://localhost:8080/api/products/AK12345 -d '{"name":"testprod235","description":"test description","price":123.45}' -H 'Content-Type: application/json'
我们列出所有产品:
curl -v http://localhost:8080/api/products/
在下一章中,我们将获取我们刚刚创建的 API 的 OpenAPI 标准文档。
从代码中获取 API 规范
在第三章中,我们看到了如何为 Spring Boot 应用生成一个与实现无关的 API 规范。
由于 MicroProfile OpenAPI 规范,对于 MicroProfile 应用程序,也提供了类似的代码优先方法。
我们将在 Quarkus 应用的pom.xml
文件中添加一个依赖项:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-openapi</artifactId>
</dependency>
现在,我们可以通过在网页浏览器中打开localhost:8080/q/swagger-ui/
URL 来进入 Swagger UI。
图 11.1 – 由 Quarkus 应用生成的 Swagger UI
可以使用localhost:8080/q/openapi
URL 下载 OpenAPI 规范(YAML 格式)。
我们展示了如何使用代码优先的方法使用 Quarkus 创建和记录 Product API。在下一章中,您将看到,即使使用 Quarkus,我们也可以使用 Java 代码生成从 OpenAPI 创建 API 规范。
从 OpenAPI 生成 MicroProfile 代码
我们将创建一个 Order Management API 的存根(不是完整的实现),该 API 在第四章中使用 Spring Boot 实现。
我们将使用与第四章中相同的Order_Management_API.yml
规范文件。
pom.xml
文件包含与暴露 Product API部分相同的 Quarkus 依赖项。此外,我们添加了插件配置以生成代码:
<plugin>
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId>
...
<configuration>
<inputSpec>${project.basedir}/
src/main/resources/Order_Management_API.yml</inputSpec>
<generatorName>jaxrs-spec</generatorName>
<apiPackage>
com.packt.ordermanagementapi.adapter.inbound.rest
</apiPackage>
<modelPackage>
com.packt.ordermanagementapi.adapter.inbound.rest.dto
</modelPackage>
<modelNameSuffix>Dto</modelNameSuffix>
<configOptions>
<useJakartaEe>true</useJakartaEe>
<interfaceOnly>true</interfaceOnly>
<useSwaggerAnnotations>false
...
一些配置参数与 Spring 相同:包名、模型名后缀。生成器的名称不同;我们使用符合 JAX-RS 规范的生成器。我们使用useJakartaEe
标志,因为当前版本的规范要求使用jakarta
而不是javax
作为包前缀。
我们使用interfaceOnly
标志,因为我们希望我们手动编写的类实现生成的接口。这样,如果我们的实现与生成的接口不匹配,编译器会自动报告。
现在,我们可以运行 Maven 的compile
目标:
./mvnw clean compile
我们可以在target/generated-sources/openapi
目录中看到生成的源代码。
如果我们现在尝试运行 Quarkus 应用程序,并在localhost:8080/q/swagger-ui/
打开 Swagger UI,我们会看到没有暴露任何 REST 端点(操作):
图 11.2 – 显示无操作的 Swagger UI
我们需要提供一个生成接口的实现。在这样做之前,我们需要添加一个额外的插件,以便从我们手动编写的代码中访问生成的源代码:
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<executions>
<execution>
<phase>generate-sources</phase>
<goals>
<goal>add-source</goal>
</goals>
<configuration>
<sources>${basedir}/target/
generated-sources/openapi/src</sources>
</configuration>
</execution>
</executions>
</plugin>
只为了演示这个概念,我们将创建一个生成的接口的存根实现:
public class OrderResource implements OrdersApi {
@Override
public OrderResponseDto ordersPost(OrderRequestBodyDto
orderRequestBodyDto) {
return null;
}
@Override
public List<OrderResponseDto> ordersGet() {
return List.of(new OrderResponseDto().id("1"));
}
...
再次打开 Swagger UI 显示,操作现在可用。
图 11.3 – 订单管理 API 的 Swagger UI
我们可以通过发送带有正文的 POST 请求来测试它。响应将为空(因为我们的实现尚未完成),但成功(HTTP 2xx 状态码)。
{
"customer": {
"customerType": "person",
"firstName": "John",
"lastName": "Doe",
"streetAddress": "1234, Rest Street",
"city": "Javatown",
"postalCode": "12345"
},
"products": [
{
"productSKU": "AK12345",
"quantity": 1
}
]
}
在本章的最后部分,我们将看到与我们在 Quarkus 中使用的类似代码也可以与另一个框架,Helidon,一起工作。
使用 Helidon 的示例实现
由于我们只使用了 MicroProfile 注解,因此与 Quarkus 一起使用的相同的 ProductResource
类代码也可以在 Helidon 中使用。
与在 Quarkus 中使用的基于 Panache 存储库的数据持久性实现不同,我们直接使用 Jakarta Persistence (JPA) 规范的 EntityManager
接口。
显然,与 Quarkus 的实现相比,我们不得不在 pom.xml
文件中放入不同的依赖项,我们也使用了不同的配置文件。
应用程序是用以下 Maven 命令构建的:
./mvnw package
然后,我们可以启动应用程序 JAR 文件:
java -jar target/product-api-helidon.jar
当然,运行中的应用程序的 API 将以与其他实现相同的方式表现,因此可以使用相同的 curl
命令进行测试。
多亏了 MicroProfile OpenAPI 兼容性和 Helidon OpenAPI UI 模块,当这些依赖项在我们的 pom.xml
文件中时,我们可以在网页浏览器中看到 REST API 的文档,网址为 localhost:8080/openapi/ui
。
图 11.5 – 由 Helidon 生成的 OpenAPI UI
我们可以看到,使用 MicroProfile 标准功能,当我们从 Quarkus 切换到 Helidon 时,我们得到相同的结果。
摘要
在本章中,我们从高层次的角度总结了可以用来实现暴露 REST API 的 Java 服务的不同框架。通过历史概述,我们解释了推动这些框架创建的不同目标和功能。我们解释了供应商中立标准如何帮助防止开发者社区碎片化和锁定到特定的实现技术。通过在我们的示例 API 中实现两个 MicroProfile 兼容框架(Quarkus 和 Helidon)的部分,我们证明了我们在前几章中讨论的各种 REST API 设计原则可以应用于任何框架,并导致具有相同基本结构的应用程序代码。
第十二章:部署 API
在我们掌握 Java RESTful API 的旅程中,我们已经探索了开发的各种关键方面。从编写高效的代码到实施稳健的设计原则和遵循行业最佳实践,我们为创建高质量的 API 奠定了坚实的基础。然而,交付生产就绪软件的道路并不仅限于开发本身。
虽然我们对 API 开发的积累知识非常有价值,但它仅代表完整软件交付生命周期的一部分。开发与生产部署之间的关键桥梁仍需跨越。这一最终阶段——部署过程——将我们精心打造的 API 转化为一个生产就绪的服务,为最终用户提供真实的价值。
为了这个目的,我们将涵盖以下主题:
准备 API 进行部署
容器化
平台即服务(PaaS)
部署最佳实践和模式
实际示例贯穿始终
准备 API 进行部署
在将您的 RESTful API 部署到生产环境之前,您需要完成几个重要的准备步骤。这些准备确保您的 API 将在生产环境中正常运行、安全可靠。
在本节中,我们将介绍使您的 Java API 为部署做好准备的一些关键要素。首先,我们将检查配置管理——如何构建您的应用程序设置以在不同环境中工作而无需更改代码。然后,我们将讨论实施健康检查,以监控 API 的状态并帮助维护系统可靠性。
这些准备步骤构成了成功部署流程的基础。它们有助于防止常见问题,并为您的 API 在生产环境中提供一个稳定的平台。通过遵循这些实践,您将减少部署问题,并使您的 API 在扩展时更容易维护。
现在,让我们详细探讨这些准备元素,从有效的配置管理开始。
配置管理
在现代 RESTful API 开发中,适当的配置管理对于维护灵活、安全和可维护的应用至关重要。在本节中,我们将探讨配置管理的各个方面及其益处。
外部化配置
外部化配置将应用程序的核心功能与其配置设置分离,避免了在代码中放置硬编码配置的必要性。这种方法允许您在不更改代码的情况下修改应用程序设置。通过将配置数据保留在代码库之外,您可以轻松调整特定于环境的设置,例如数据库 URL、安全密钥或 API 端点,而无需重新编译或重新部署您的应用程序。
例如,使用 Spring Boot,您可以通过在外部Properties
或 YAML 文件中定义属性来外部化配置,而不是在 Java 代码中硬编码它们。要在 Spring Boot 应用程序中外部化配置并将其绑定到 Java 类,请按照以下步骤操作:
创建一个外部配置文件(
application.yml
):server: port: 8080 spring: datasource: url: jdbc:postgresql://localhost:5432/mydatabase username: myuser password: mypassword api: timeout: 30
使用
application.yml
中的应用程序配置:@Configuration @ConfigurationProperties(prefix = "api") public class ApiProperties { @NotNull(message = "Timeout value is required") @Min(value = 1, message = "Timeout must be at least 1 second") private Integer timeout; // Getter and Setter public Integer getTimeout() { return timeout; } public void setTimeout(Integer timeout) { this.timeout = timeout; } }
在这个例子中,API 超时设置在application.yml
文件中进行了外部化。ApiProperties
类绑定到"api"
部分,确保对配置文件的任何更改(如调整超时)都将生效,而无需修改应用程序代码。
这种方法在部署期间促进了灵活性和敏捷性,使得可以根据部署环境快速调整配置值。
外部化配置的好处
将配置与您的应用程序代码分离是现代软件架构中的基础最佳实践,尤其是对于必须在各种环境中运行的 RESTful API。通过外部化配置,开发团队在应用程序的部署、安全性和维护整个生命周期中获得了巨大的灵活性。这种方法从根本上改变了我们管理应用程序设置的方式,从源代码中嵌入的硬编码值转变为动态的、特定于环境的变量,这些变量可以在不重新编译的情况下进行修改。这种方法的战略优势贯穿于您的开发和运营流程的多个维度:
环境独立性:开发者可以在不更改代码的情况下无缝地在不同环境之间进行切换。例如,您的订单 API 可以在开发期间连接到包含样本产品的测试产品目录,但在部署到生产环境时自动切换到包含真实库存的生产目录。这允许开发者使用测试数据本地创建和测试订单,而不会影响真实库存或向实际客户发送通知。
简化维护:运维团队获得在不需开发者干预的情况下修改应用程序行为的能力。例如,当您的产品 API 因新的高分辨率图片需要将产品图片缓存大小从 100 MB 增加到 500 MB 时,运维人员可以简单地更新外部化配置值,而不是请求代码更改和重新部署。同样,您的订单 API 中第三方物流提供商集成的超时设置可以根据其性能特征进行调整,而不需要开发团队的参与。
增强安全性:通过从源代码中移除敏感凭证,安全性得到了显著增强。例如,你可以在你的订单 API 代码中(它会在提交历史中保持可见)避免像这样硬编码支付网关 API 密钥
paymentGateway.setApiKey("sk_live_51MlkA...")
,而是使用paymentGateway.setApiKey(environment.getProperty("payment.api.key"))
并在环境变量中安全地存储实际密钥。这允许开发环境和生产环境使用不同的密钥,而不会暴露敏感凭证。更简单的 DevOps 集成:外部化配置使持续集成和部署管道更加强大。例如,你可以使用不同的设置将相同的订单 API Docker 容器部署到测试和生产环境:
docker run -e "SPRING_PROFILES_ACTIVE=production" -e "PRODUCT_API_URL=https://product-api.example.com/v1"https://product-api.example.com/v1" -e "INVENTORY_CHECK_ENABLED=true" order-api-image.
在测试环境中,你可能将 INVENTORY_CHECK_ENABLED=false
设置为跳过实际的库存验证,而在生产环境中启用它以防止缺货产品的订单。
正如我们所见,外部化配置为构建健壮、可维护和安全的 RESTful API 提供了关键优势。通过将配置与代码解耦,我们获得了在各个环境中部署、简化操作更新、增强安全性和简化 DevOps 流程的灵活性。现在让我们看看如何处理基于配置文件的配置,这将允许开发者具有灵活性和自动选择每个环境特定配置集的能力。
基于配置文件
当你在不同的环境中部署 RESTful API——从开发者的本地机器到测试服务器,最终到生产环境——管理特定环境的设置成为一个关键挑战。基于配置文件的配置提供了一个优雅的解决方案,它通过允许 API 根据其运行位置调整其行为,直接解决了部署复杂性,而无需更改任何代码行。
对于你的订单 API 和产品 API,这种方法意味着你可以在保持单一代码库和部署工件的同时,根据目标环境自动调整关键设置,如数据库连接、外部服务端点、安全参数和功能开关。这种能力简化了你的 CI/CD 管道,并大大减少了与配置相关的部署故障。
常见配置文件
假设你的订单 API 需要在不同的环境中使用不同的数据库连接和行为设置。你可以按照以下方式结构化你的配置文件:
开发:在开发环境(通常称为“开发”配置文件)中,开发者专注于在本地或共享开发服务器上编写和测试代码。这个软件生命周期阶段通常看到非常频繁的代码更改、持续的实验和快速部署。由于可靠性尚未成为关键,开发者通常使用轻量级数据库,如 H2 或其他嵌入式选项,并可能依赖模拟服务来替代外部依赖。启用了详尽的日志记录,以便可以快速诊断任何问题,并提高调试功能以获得对代码的更深入了解。由于没有涉及实际客户数据且不处理真实交易,这个阶段对业务的风险最小。一个实际的订单 API 示例可能包括运行内存数据库以加快本地测试,调用本地运行的产品 API 实例,并模拟支付和物流服务以加快速度,而无需等待真实集成。
测试:当需要验证所有组件无缝协作时,与“测试”配置文件对齐的测试环境就发挥作用了。这个环境通常比开发环境更稳定,因为变更引入的频率较低,且通常更加精细。它通过利用类似真实世界条件的数据库和外部服务配置来更接近生产环境,同时仍然使用测试数据。在整个这个阶段,团队执行集成测试、自动化用户界面测试、性能验证和其他检查,以确保系统按预期运行。在这里,订单 API 可能连接到一个专用的测试 MySQL 版本来管理订单,调用产品 API 的测试实例,并通过测试物流服务端点生成物流标签。为了防止实际收费,支付通常由模拟网关处理。这个受控环境允许质量保证团队和自动化管道验证完整的工作流程,而不会危及真实客户数据或产生不必要的成本。
生产环境:一旦应用程序通过了所有必要的检查,它就会升级到生产环境——通常被称为“prod”配置文件。在这个实时环境中,您的 API 为真实用户提供服务并处理现实世界的交互。上线时间、安全和性能变得至关重要。在这里运行的代码经过彻底测试,并且通常更改频率较低,因为稳定性比快速迭代更重要。生产环境中的订单 API 将连接到强大、安全且通常集群化的数据库,这些数据库旨在处理大规模交易,同时与真实的第三方服务(如真实支付提供商和运输承运人)集成。日志被精心管理,以收集必要的信息,同时不会对性能产生不利影响。鉴于高风险和真实客户数据,异步订单履行通常被采用,以确保在重负载下可靠的处理。这个环境需要密切监控和对任何问题的快速响应,以保持无缝的客户体验。
我们可以在 Spring Boot 中表达这种结构。为了利用这种方法,我们需要组织每个环境中使用的所有变量,这些变量以文件的环境名称后缀命名,如下所示:
图 12.1 – 基于 Spring 配置文件的文件结构
部署期间激活配置文件
当您将订单 API 部署到不同的环境时,您可以通过环境变量或命令行参数激活适当的配置文件:
本地开发 :
# Developer's local machine java -jar order-api.jar --spring.profiles.active=dev
自动化测试环境 :
# In your CI/CD pipeline's test stage export SPRING_PROFILES_ACTIVE=test java -jar order-api.jar
使用 Docker 进行生产部署 :
# In your production deployment script docker run -e "SPRING_PROFILES_ACTIVE=prod" -p 8080:8080 order-api:latest
这种方法确保当您的订单 API 部署到生产环境时,它会自动连接到生产数据库,使用实时支付网关,与生产产品 API 实例通信,并应用适当的性能和安全设置——所有这些都不需要任何代码更改或手动配置步骤。
基于配置文件的配置直接解决了在多个环境中部署相同 API 的挑战,使您的部署过程更加可靠、可维护,并能够适应不断变化的基础设施需求。
环境变量
环境变量提供了一种安全且灵活的方式来管理配置值,特别是对于敏感数据。当您部署 API 时,您可以将敏感配置,如数据库凭据、API 密钥和服务端点,作为服务器上的环境变量设置。这种方法将关键信息从代码库中移除,同时在运行时使应用程序能够访问这些信息。
在 Spring Boot 应用程序中,利用环境变量的最优雅方式是通过application.yml
文件,您可以直接在配置中引用这些变量:
# application.yml
spring:
datasource:
url: ${DB_URL:jdbc:mysql://localhost:3306/orderdb}
username: ${DB_USERNAME:dev_user}
password: ${DB_PASSWORD:dev_password}
payment:
gateway:
api-key: ${PAYMENT_API_KEY:sandbox_key}
timeout: ${PAYMENT_TIMEOUT:30}
product-service:
url: ${PRODUCT_SERVICE_URL:http://localhost:8081/api/products}
cache-size: ${PRODUCT_CACHE_SIZE:100}
在审查我们的示例时,您可能已经注意到,基于配置文件和环境变量部分都使用了application.yml
文件,但它们代表了不同的配置管理方法。在外部化配置示例中,我们专注于为不同的环境(如application-dev.yml
和application-prod.yml
)创建单独的物理配置文件,Spring Boot 根据活动配置文件加载这些文件。相比之下,环境变量方法在单个配置文件中使用占位符语法${VARIABLE_NAME:default_value}
,在运行时动态注入值。这种区别很重要:基于文件的外部化需要为每个环境管理完整的配置文件,并在部署应用程序时部署正确的文件,而环境变量允许您维护单个配置模板,其中仅在运行时通过您的部署平台覆盖特定值。环境变量方法在容器化环境(如 Docker 和云平台)中提供了更大的灵活性,简化了秘密管理集成,并允许在不替换整个文件的情况下进行细粒度的配置更新。许多生产系统同时利用这两种模式——使用特定配置文件的文件来处理环境之间的实质性配置差异,同时使用环境变量来处理敏感凭证和经常更改的值。这种混合方法使开发团队能够在统一框架内处理结构配置差异和动态值注入。
健康检查实施
健康检查对于监控您的 API 的运行状态至关重要。它们允许您验证在检查点之后,您的应用程序正在运行并准备好响应请求。实施健康检查可以帮助您在问题影响用户之前发现它们,从而实现主动维护和快速恢复。
考虑这个真实场景:一家电子商务公司在周五下午部署了其订单管理 API 的新版本。部署看起来很成功——所有服务都启动无误,初步的手动测试也通过了。然而,到了周一早上,客户服务团队收到了大量关于订单缺失和库存差异的投诉。调查发现,尽管 API 在技术上正在运行,但由于配置问题,它无法正确连接到产品库存数据库。
在没有适当的健康检查的情况下,尽管存在这种关键故障,部署仍被视为成功。健康检查充当您应用程序的生命体征监控系统——就像医生检查您的脉搏、血压和体温以评估您的整体健康状况一样。对于容器化应用程序尤其如此,这些检查至关重要,因为它们会告知编排工具您的容器是否应该接收流量、重启或完全替换。一个设计良好的健康检查系统可以区分暂时性故障和严重故障,防止不必要的重启,同时确保真正的问题得到及时解决。
对于我们的订单管理和产品 API,实施全面的健康检查意味着我们可以自信地自动化部署,因为我们知道我们的监控将捕获可能否则未被检测到的问题,直到客户受到影响。让我们探讨如何在我们的应用程序中实施这些关键的安全措施。
案例场景:周末系统维护
想象以下场景:您的团队在周六早上进行常规数据库维护。维护完成后,所有服务似乎都正常重启。然而,如果没有适当的健康检查,您可能直到周一才会发现,尽管应用程序正在运行,但它无法正确连接到数据库。这会导致订单丢失和客户不满。
通过有效的健康检查,您会立即知道维护后数据库连接是否已正确恢复,这样您就可以在这些问题影响业务运营之前修复它们。
使用 Spring Boot Actuator 实现基本健康检查
对于我们的订单管理和产品 API,让我们实施一个简单但有效的健康检查系统:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
将此依赖项添加到您的项目中后,Spring Boot 会自动公开一个/actuator/health
端点,该端点返回应用程序的状态。
订单管理的自定义健康指标
让我们为我们的订单存储库创建一个简单的健康检查:
@Component
public class OrderRepositoryHealthIndicator implements HealthIndicator {
private final OrderRepository orderRepository;
public OrderRepositoryHealthIndicator(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Override
public Health health() {
try {
// Simple query to check database connectivity
orderRepository.count();
return Health.up().build();
} catch (Exception e) {
return Health.down()
.withDetail("error", e.getMessage())
.build();
}
}
}
类似地,为产品创建一个健康检查:
@Component
public class ProductRepositoryHealthIndicator implements HealthIndicator {
private final ProductRepository productRepository;
// Similar implementation to check product repository connectivity
}
多级健康检查
对于我们的订单管理和产品 API,实施多级健康检查有助于区分不同的操作状态。
活跃性检查
活跃性检查回答一个简单的问题:“应用程序是否正在运行?”这些检查应该是轻量级的,并避免依赖于外部系统:
@Component
public class LivenessHealthIndicator implements HealthIndicator {
@Override
public Health health () {
// Simple check to verify application is running
// Should always succeed unless the application is truly dead
return Health.up().build();
}
}
在您的application.yml
文件中配置此内容:
management:
endpoint:
health:
group:
liveness:
include: livenessState,diskSpace
准备就绪检查
准备就绪检查回答:“应用程序能否接受和处理请求?”这些检查应验证外部依赖项:
@Component
public class ReadinessHealthIndicator implements ReadinessIndicator {
private final OrderRepositoryHealthIndicator orderRepoHealth;
private final ProductRepositoryHealthIndicator productRepoHealth;
public ReadinessHealthIndicator(
OrderRepositoryHealthIndicator orderRepoHealth,
ProductRepositoryHealthIndicator productRepoHealth) {
this.orderRepoHealth = orderRepoHealth;
this.productRepoHealth = productRepoHealth;
}
@Override
public Health health() {
Health orderHealth = orderRepoHealth.health();
Health productHealth = productRepoHealth.health();
if (Status.UP.equals(orderHealth.getStatus()) &&
Status.UP.equals(productHealth.getStatus())) {
return Health.up().build();
}
return Health.down()
.withDetail("orderRepository", orderHealth.getStatus())
.withDetail("productRepository",productHealth.getStatus())
.build();
}
}
在application.yml
中配置此内容:
management:
endpoint:
health:
group:
readiness:
include: readinessState,db,orderRepository,productRepository
组件健康检查
组件健康检查提供了您系统特定部分的详细状态:
@Component
public class InventoryHealthIndicator implements HealthIndicator {
private final ProductService productService;
public InventoryHealthIndicator(ProductService productService) {
this.productService = productService;
}
@Override
public Health health() {
try {
// Check if inventory system is functioning
boolean inventoryStatus = productService.
checkInventoryStatus();
if (inventoryStatus) {
return Health.up()
.withDetail("inventorySystem", "operational")
.withDetail("lastSyncTime", new Date())
.build();
} else {
return Health.down()
.withDetail("inventorySystem", "degraded")
.build();
}
} catch (Exception e) {
return Health.down()
.withDetail("inventorySystem", "error")
.withDetail("message", e.getMessage())
.build();
}
}
}
多级健康检查的实际应用
定义了这些级别后,您可以使用它们用于不同的目的:
活跃性检查:由 Docker 用于确定容器是否应该重启
就绪性检查:用于确定 API 是否可以接收流量
组件检查:由操作员用于诊断特定系统问题
更新您的 Docker Compose 配置以使用这些特定的端点:
version: ‘3’
services:
order-api:
build: .
ports:
- “8080:8080”
healthcheck:
test: [“CMD”, “curl”, “-f”,
“http://localhost:8080/actuator/health/liveness”]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
depends_on:
database:
condition: service_healthy
在 application.properties 中进行基本配置
配置健康检查端点:
# Enable health endpoint
management.endpoints.web.exposure.include=health,info
# Show health details
management.endpoint.health.show-details=when_authorized
# Enable health groups
management.endpoint.health.group.liveness.include=livenessState
management.endpoint.health.group.readiness.include=readinessState,db,orderRepository,productRepository
虽然健康检查提供了对应用程序操作状态的必要可见性,但当它们与现代部署方法集成时,它们变得更加强大。现在我们已经为我们的订单管理和产品 API 建立了稳健的健康监控,下一步合乎逻辑的步骤是以一种可以利用这些健康检查的有效方式打包我们的应用程序。
容器化是我们健康检查策略的完美补充。通过将我们的应用程序及其依赖项封装在轻量级、可移植的容器中,我们可以在不同环境中确保一致的行为,同时充分利用我们的监控能力。Docker 容器可以根据我们的健康检查结果自动重启,为服务中断提供第一道防线。
在下一节中,我们将探讨如何使用 Docker 容器化我们的订单管理和产品 API,创建一个确保我们的应用程序在任何环境中可靠运行的部署管道。我们将看到我们的健康检查如何与 Docker 内置的监控无缝集成,创建一个可以自动检测和从许多常见故障场景中恢复的自愈系统。
容器化
容器化通过提供一种打包应用程序及其所有依赖项的方法,彻底改变了应用程序的部署方式。这确保了应用程序在不同环境中的一致运行,无论是开发、测试还是生产。与传统虚拟机(VMs)不同,容器共享宿主操作系统的内核,使它们轻量级且高效。
容器化彻底改变了我们在现代软件开发中部署和管理 API 的方式。其核心是,容器化是将应用程序及其所有依赖项——库、配置文件和运行时环境——打包成一个标准化的、自包含的单元,称为容器。这种方法解决了软件部署中最持久的挑战之一:“在我的机器上它运行正常,为什么在生产环境中不工作?”
这个过程有几个关键优势:
可移植性:应用程序可以在各种环境中无缝移动而无需修改
可伸缩性:容器可以根据需求快速扩展或缩减
隔离:每个容器独立运行,防止应用程序之间的冲突
资源效率:容器共享操作系统资源,比虚拟机更轻量级
一致性:开发人员和运维团队使用相同的环境
快速部署:新版本可以在几秒钟内部署,而不是几个小时或几天
自愈:失败的容器可以根据健康检查自动重启
图 12.2 – 容器化与虚拟机
容器的工作原理
容器将应用程序及其依赖项封装成一个单一单元,可以在任何地方执行,为各种系统提供一致的环境。它们通过利用宿主操作系统的内核同时保持与其他容器的隔离来实现这一点。这种隔离是通过 Linux 操作系统的命名空间和控制组(cgroups)实现的,它们提供以下功能:
命名空间:这些为容器创建单独的环境。每个容器都有自己的文件系统、进程 ID(PID)空间、用户 ID 和网络接口,防止它们相互干扰。
控制组(cgroups):这些限制并优先处理容器的资源使用(CPU、内存和磁盘 I/O),确保没有单个容器可以垄断系统资源。
容器的层
容器是由镜像构建的,这些镜像由一系列层组成。每一层代表对基础镜像的更改或添加。这种分层架构提供了以下优势:
共享层:多个容器可以共享相同的底层层,减少磁盘空间使用并加快部署
版本控制:每个层都可以进行版本控制,允许轻松回滚到应用程序的先前版本
高效构建:当构建新的镜像时,Docker 使用缓存机制仅重建已更改的层,从而实现更快的构建时间
常见的容器化工具
可用于容器化的工具有多种,每种工具都有其独特的功能和优势。以下是一些最常见的工具:
Docker:目前最受欢迎的容器化平台,Docker 提供了一套全面的工具,用于构建、管理和运行容器。它简化了整个容器生命周期,并拥有庞大的社区支持和资源生态系统。
Kubernetes:虽然 Kubernetes 本身不是容器化工具,但它是一个强大的编排平台,可以大规模管理容器化应用程序,自动化应用程序的部署、扩展和操作。
OpenShift:基于 Kubernetes,OpenShift 为企业环境添加了额外的功能和工具,为开发者提供 PaaS 体验。
Podman:Docker 的替代品,允许在不需要守护进程的情况下进行容器管理。Podman 提供了与 Docker 类似的命令行界面,使用户过渡变得容易。
Rancher:一个完整的容器管理平台,使得部署和管理多个 Kubernetes 集群变得容易。
对于本章,我们将主要关注 Docker,因为它是目前最广泛采用的容器化解决方案。其易用性、广泛的文档和活跃的社区使其成为希望在其项目中实现容器化的开发人员的理想选择。
Docker 镜像的典型结构如下:
基础层:镜像的基础,例如操作系统或运行时
应用层:在基础层之上添加的实际应用程序代码和依赖项
配置层:在 Dockerfile 中指定的任何附加配置或环境变量
Docker 架构
要完全理解容器化,我们必须探索最广泛使用的容器化平台 Docker 的架构。Docker 在客户端-服务器架构上运行,该架构由几个关键组件组成:
Docker 引擎:这是允许用户构建、运行和管理容器的核心组件。它包括在后台运行的 Docker 守护进程( dockerd ),它管理镜像、容器、网络和存储,以及 Docker 的 命令行界面( CLI ),它允许用户与 Docker 守护进程交互。
Docker 组件:
镜像:用于创建容器的不可变模板
容器:Docker 镜像的运行实例
卷:容器的持久化存储
网络:使容器和外部服务之间能够通信
注册表:用于存储和分发 Docker 镜像的仓库,例如 Docker Hub
Docker 工作流程:工作流程从开发人员创建 Dockerfile 开始,该文件定义了应用程序环境。Docker 引擎从这个 Dockerfile 构建一个镜像,然后将其存储在注册表中。从镜像实例化一个容器,容器运行并提供应用程序服务。
图 12.3 – Docker 架构
图 12.3 中的图示展示了我们刚刚描述的 Docker 架构,展示了各种组件如何相互作用以创建我们的容器运行的环境。理解这个架构对于我们进一步将订单管理 API 容器化至关重要。该图显示了 Docker 的分层结构,从底部的宿主操作系统到顶部的单个容器,突出了 Docker 如何高效地管理资源,同时为应用程序提供隔离。让我们通过下一个主题中描述的方法,实现一个基于提供 JRE (Java 运行时环境) 的镜像创建 Docker 容器的示例。
示例:将 RESTful API 容器化
为了说明容器化过程,让我们考虑一个基于类似以下 GitHub 仓库的 RESTful API 的实际示例:RESTful API 书籍示例。此示例将指导你完成创建 Java/Maven 基础 RESTful API 的 Docker 容器的步骤。
第 1 步:项目结构
首先,确保你的项目结构正确。基于 Java/Maven 的 RESTful API 的典型结构可能如下所示:
图 12.4 – product-api 目录结构
关于结构,以下是一些关键点:
包含 JAR 文件的
target/
文件夹在
src/main/java/
下的主要源结构根目录下的 Dockerfile
在接下来的步骤中,我们将创建 Dockerfile 并运行需要我们在此处展示的 图 12.4 中的结构的命令。
第 2 步:创建 Dockerfile
在你的项目目录根目录下,创建一个名为 Dockerfile
的文件,内容如下:
# Use an official OpenJDK runtime as a base image
FROM openjdk:21-jdk-slim
# Set the working directory inside the container
WORKDIR /app
# Copy the Maven build artifact into the container
COPY target/product-api-0.0.1-SNAPSHOT.jar app.jar
# Expose the application port
EXPOSE 8080
# Define the command to run the application
ENTRYPOINT ["java", "-jar", "app.jar"]
第 3 步:构建 Docker 镜像
在构建镜像之前,确保你的项目已正确打包。如果你使用 Maven,可以通过运行以下命令来构建 JAR 文件:
mvn clean package
构建完成后,你可以使用以下命令创建 Docker 镜像:
docker build -t my-restful-api .
-t
标志将镜像标记为 my-restful-api
名称。
第 4 步:运行 Docker 容器
现在你已经构建了 Docker 镜像,你可以从它运行一个容器:
docker run -d -p 8080:8080 --name restful-api-container my-restful-api
让我们分解这个命令:
-d
以分离模式(在后台)运行容器-p 8080:8080
将主机上的端口8080
映射到容器中的端口8080
--name restful-api-container
为容器分配一个名称,以便更容易管理
第 5 步:测试 API
容器启动后,你可以通过向其发送请求来测试 API。打开你的网页浏览器或使用 curl
或 Postman 等工具发送请求:
curl http://localhost:8080/api/endpoint
将 /api/endpoint
替换为你想要测试的实际端点。如果一切设置正确,你应该会收到来自你的 API 的响应。
容器部署的最佳实践
为了确保有效的容器部署,遵循最佳实践至关重要:
优化镜像大小:使用轻量级的基础镜像,如
alpine
或openjdk:21-slim
,以最小化最终镜像大小最小化层:在 Dockerfile 中将多个命令合并为一个
RUN
命令,以减少镜像中的层数使用多阶段构建:在一个阶段构建应用程序,并将必要的工件复制到最终镜像中,以减少整体大小
实现健康检查:使用 Docker 的
HEALTHCHECK
功能来确保容器化应用程序正常运行确保镜像安全:使用 Trivy 或 Docker Scout 等工具定期扫描镜像以查找漏洞,以维护安全标准
挑战和考虑因素
虽然 Docker 简化了部署,但也带来了需要解决的问题:
安全漏洞:镜像可能包含需要主动管理的漏洞。
网络复杂性:理解和配置容器网络可能很复杂,需要周密规划。
存储管理:在容器化环境中,正确管理持久数据可能带来挑战。
监控和日志记录:集成监控和日志记录解决方案对于保持对容器性能的可见性至关重要。
云特定要求:每个云提供商对容器编排的实现方式不同,拥有独特的服务和配置。仅就 AWS 生态系统而言,有效的容器部署需要熟悉多个相互关联的服务,如 ECR、ECS/EKS、IAM 角色、VPC 网络和负载均衡。虽然本章介绍了容器化的基础知识,但针对特定提供商的部署工作流的全面覆盖将超出我们的范围。前面的示例提供了一个实用的起点,但生产部署通常需要更深入的平台特定知识和专业技能。组织通常开发专门的 DevOps 团队,专注于这些部署管道和基础设施管理问题。
容器化技术使得现代的、可扩展的以及可移植的 API 部署成为可能。通过理解 Docker 的架构、镜像生命周期和最佳实践,开发者可以高效地在云和本地环境中部署 RESTful API。这种基础性知识使你具备应对现代部署策略复杂性的技能。
在下一节中,我们将使用 AWS Elastic Beanstalk 探索平台即服务(PaaS),这是一个用于部署和管理容器化应用程序的企业级平台。
PaaS
PaaS 代表了一种云计算模型,它为开发者提供了一个完整的平台——包括硬件、软件和基础设施——以便在不涉及构建和维护通常与这些过程相关的基础设施复杂性的情况下开发、部署和管理应用程序。
在云计算服务的这一演变过程中,PaaS 位于仅提供基本计算基础设施的基础设施即服务(IaaS)和提供现成应用程序的软件即服务(SaaS)之间。这种便利性使得 PaaS 对于专注于创建和部署应用程序而不是管理服务器和基础设施的开发团队尤其有价值。
PaaS 的关键组件
一个典型的 PaaS 服务包括以下内容:
运行时环境:预配置的平台,你的应用程序在此运行
开发工具:集成开发环境、调试工具和版本控制系统
中间件:数据库管理系统、应用服务器和消息队列
操作系统:由提供商管理,并定期更新以保障安全
网络基础设施:负载均衡器、防火墙和安全功能
扩展能力:自动或手动扩展选项以处理流量波动
PaaS 在 API 部署中的优势
对于 RESTful API 开发者,PaaS 提供了几个重要的优势:
减少的开发时间:通过消除基础设施设置和管理任务,开发者可以专注于 API 开发,显著缩短上市时间
简化的部署管道:大多数 PaaS 提供商提供集成的 CI/CD 功能或与流行的 CI/CD 工具的无缝集成,简化了部署过程
内置的可扩展性:API 可以水平或垂直扩展,配置最小化,适应流量高峰而不会降低性能
增强的安全性:PaaS 提供商实施强大的安全措施,包括定期更新、加密和合规认证,减轻了开发团队的安全负担
全球可用性:领先的 PaaS 提供商在全球运营数据中心,使得 API 的部署更靠近终端用户,从而提高性能
Java 应用程序的常见 PaaS 提供商
几个 PaaS 提供商为基于 Java/Maven 的 RESTful API 提供出色的支持:
AWS Elastic Beanstalk:亚马逊的 PaaS 解决方案,简化了在 AWS 基础设施上部署 Java 应用程序的过程
Microsoft Azure App Service:提供构建、部署和扩展 Java Web 应用程序的托管平台
Google App Engine:谷歌的 PaaS 服务,针对 Java 应用程序提供特定支持
Heroku:支持多种编程语言(包括 Java)的云平台
Red Hat OpenShift:一个具有强大 Java 支持的 Kubernetes 企业平台
PaaS 与传统的部署方式比较
与传统的本地部署或基本 IaaS 解决方案相比,PaaS 提供以下优势:
功能 | 传统部署 | PaaS |
---|---|---|
基础设施管理 | 手动,耗时 | 自动,最小化努力 |
扩展 | 复杂,需要规划 | 简单,通常自动 |
更新和补丁 | 手动过程 | 由提供商管理 |
开发重点 | 代码和基础设施之间的分割 | 主要在应用代码上 |
上市时间 | 较长 | 显著缩短 |
初始成本 | 高 | 低 |
表 12.1 – 传统部署与 PaaS 对比
选择 PaaS 时的考虑因素
虽然 PaaS 提供了许多好处,但在采用之前,开发者应考虑几个因素:
供应商锁定:依赖供应商特定的功能可能会使未来的迁移复杂化。这一考虑因素至关重要,因为许多 PaaS 供应商提供专有服务和工具,这些服务和工具可以显著提高您的 API 开发效率——但代价不菲。例如,如果您与 AWS Elastic Beanstalk 的特定部署机制或 Azure App Service 的内置身份验证功能深度集成,您可能会发现您的应用程序变得与该平台紧密耦合。如果业务需求发生变化或定价结构变得不利,迁移到另一个供应商可能需要大量的代码重构和架构变更。为了降低这种风险,考虑实现针对特定供应商服务的抽象层,并维护基础设施即代码,以便适应不同的环境。
定制限制:对底层基础设施的控制较少可能会影响特定的优化需求。这对于具有独特性能配置文件或安全要求的 API 尤其相关。虽然 PaaS 平台自动处理大多数基础设施问题,但这种便利性伴随着灵活性的降低。例如,如果您的订单管理 API 需要特定的数据库配置来高效处理复杂的库存查询,或者需要自定义网络设置以与旧系统交互,PaaS 解决方案可能不允许这些细粒度的调整。组织应仔细评估其 API 需求是否在 PaaS 平台允许的参数范围内,或者是否需要通过 IaaS 或容器编排来获得更多控制。
合规要求:确保 PaaS 供应商符合您所在行业的监管标准对于许多行业来说是不可协商的。对于一个处理客户信息和支付数据的订单管理系统,遵守 PCI DSS、GDPR 或 HIPAA 等标准可能是强制性的。并非所有 PaaS 供应商都提供相同级别的合规认证或安全控制。一些可能提供全面的合规文档和内置控制,而另一些可能将更多责任留给开发者。在将您的业务关键 API 提交给其平台之前,彻底调查供应商的合规服务、数据驻留选项和安全实践。
成本结构:了解定价模型对于避免随着 API 使用量的增长而出现意外费用至关重要。PaaS 平台通常根据资源消耗——CPU 使用率、内存分配、存储和数据传输——来收费。对于可能经历季节性波动的订单管理 API(例如假日购物期间),成本可能会大幅波动。一些提供商提供自动扩展功能,可以根据需求自动调整资源,这可能是高效的,但如果配置不当,也可能非常昂贵。仔细分析定价结构,设置资源使用的监控和警报,并考虑实施成本优化策略,如非生产环境中的资源调度。
性能需求:评估 PaaS 是否能够满足您的特定性能要求对于维持用户满意度至关重要。在高峰期间,您的订单管理 API 可能需要处理数千个并发请求,同时保持库存检查和订单处理的低延迟。不同的 PaaS 提供商提供不同级别的性能能力,从基本的共享环境到专用的 premium 级别。考虑以下方面:数据中心的地域分布(以减少全球客户的延迟)、可用的实例大小、数据库性能选项和缓存能力。在完全承诺于某个提供商之前,对所选平台进行负载测试,以验证其是否能够处理您预期的流量模式。
通过在您特定的 API 需求背景下彻底评估这些考虑因素,您可以做出明智的决定,关于 PaaS 是否是您项目的正确方法,如果是的话,哪个提供商最适合您的需求。
经过仔细评估我们刚才讨论的考虑因素——供应商锁定、定制限制、合规要求、成本结构和性能需求——我们选择了 AWS Elastic Beanstalk 作为我们的实际示例。这个选择在抽象和控制之间提供了极佳的平衡,使其非常适合演示我们的订单管理和产品 API 的 PaaS 部署。
此外,我对 AWS 的深入了解使我能够分享超越理论实现的实用见解,帮助您导航在部署自己的订单管理 API 时可能出现的潜在挑战。
现在我们来详细说明如何在 AWS Elastic Beanstalk 上部署我们的 Java RESTful API,展示这些考虑因素如何转化为实际实施决策。
实际示例:在 AWS Elastic Beanstalk 上部署 RESTful API
让我们详细说明如何在 AWS Elastic Beanstalk 上部署一个 Java RESTful API。此示例假设您有一个准备就绪的 Spring Boot 应用程序等待部署。
第一步涉及准备您的应用程序。首先,确保您的应用程序已正确打包:
# Build your application with Maven
mvn clean package
这将在目标目录中创建一个 JAR 文件。对于 Elastic Beanstalk 部署,此 JAR 应该是一个包含嵌入式服务器的自包含文件。
接下来,您需要设置 AWS Elastic Beanstalk(EB)环境。安装 AWS CLI 和 EB CLI:
pip install awscli awsebcli
然后,在您的项目中初始化 EB CLI。导航到您的项目根目录并运行以下命令:
eb init
这将启动一个交互式设置,您需要执行以下操作:
选择区域
创建或选择 AWS 凭证配置文件
输入应用程序名称
选择 Java 作为平台
选择 Java 版本
设置实例访问的 SSH(可选)
要创建环境,请使用以下命令:
eb create my-api-environment
此命令会触发几个过程:
创建一个名为
my-api-environment
的环境设置必要的 AWS 资源(EC2 实例、负载均衡器等)
部署您的应用程序
配置健康监控
在创建过程中,您还可以指定以下内容:
环境类型(负载均衡或单实例)
实例类型
用于 SSH 访问的 EC2 密钥对
VPC 设置
下一步是应用程序配置。在您的项目根目录中创建一个名为.ebextensions/java-options.config
的文件:
option_settings:
aws:elasticbeanstalk:application:environment:
SPRING_PROFILES_ACTIVE: production
API_TIMEOUT: 30
LOGGING_LEVEL_ROOT: INFO
aws:elasticbeanstalk:container:java:
JVM Options: -Xms256m -Xmx1024m
aws:autoscaling:asg:
MinSize: 2
MaxSize: 4
aws:elasticbeanstalk:environment:
ServiceRole: aws-elasticbeanstalk-service-role
此配置设置以下内容:
应用程序的环境变量
JVM 内存设置
自动扩展参数
环境服务角色
在对您的应用程序进行更改后,使用以下命令部署更新:
mvn clean package
eb deploy
EB CLI 将自动上传新版本并执行滚动更新。
最后,您需要监控您的应用程序。通过以下方式访问监控数据:
eb health
或者通过 AWS 管理控制台查看详细的指标和日志:
导航到 Elastic Beanstalk。
选择您的环境。
点击监控选项卡。
通过日志部分查看日志。
AWS Elastic Beanstalk(EB)的优势和劣势
AWS Elastic Beanstalk 是一种服务,通过为您处理基础设施工作,使部署应用程序变得更加容易。当您上传代码时,Elastic Beanstalk 会自动设置服务器、负载均衡器和监控,而无需您手动配置这些组件。
这种方法介于两个极端之间:完全托管的服务,您几乎没有控制权,以及需要深入了解基础设施知识的手动服务器管理。Elastic Beanstalk 提供了一个平衡的解决方案,您可以专注于编写代码,同时当需要时仍可访问 AWS 功能。
然而,就像任何部署选项一样,Elastic Beanstalk 既有优点也有缺点。一些团队发现它非常适合他们的需求,而其他团队则发现它不符合他们的特定要求。了解这些优缺点将帮助您决定 Elastic Beanstalk 是否是您 RESTful API 项目的正确选择。
让我们探索 Elastic Beanstalk 的用途及其在 API 部署中可能存在的不足之处。
这里是优势:
简化操作:Elastic Beanstalk 将基础设施复杂性抽象化,自动处理配置、负载均衡、自动扩展和监控。这允许开发者专注于应用程序代码,而不是基础设施管理。
集成开发工作流程:EB CLI 为部署提供了一种简化的工作流程,使得您可以直接从开发环境轻松推送更新、监控健康状态和管理环境。
AWS 服务集成:Elastic Beanstalk 与以下 AWS 服务无缝集成:
RDS 用于数据库管理
CloudWatch 用于监控和警报
S3 用于存储
IAM 用于安全
CloudFormation 用于基础设施定义
平台灵活性:虽然抽象化了复杂性,但 Elastic Beanstalk 仍然允许通过配置文件(
.ebextensions
)进行自定义,从而实现对环境的精细控制。版本控制和回滚:平台维护已部署应用程序的版本,如果出现问题,可以轻松回滚到先前版本。
成本管理:您只需为底层资源(如 EC2 实例、负载均衡器等)付费,Elastic Beanstalk 本身无需额外费用。
现在,让我们看看其缺点:
某些方面的控制有限:虽然 Elastic Beanstalk 提供了自定义选项,但与直接 EC2 或基于容器的部署相比,您对某些基础设施方面的控制较少。
部署延迟:配置和部署过程可能需要几分钟,这可能比 ECS 或 EKS 等更专注于容器的服务要慢。
冷启动:新环境创建可能需要 5 到 10 分钟,因此对于临时或快速测试环境创建不太合适。
配置复杂性:随着应用程序变得更加复杂,管理
.ebextensions
文件可能会变得具有挑战性,可能导致配置漂移或不一致。供应商锁定风险:对 Elastic Beanstalk 特定配置的重度依赖可能会使得将来迁移到其他平台变得具有挑战性。
自定义容器限制:虽然支持 Docker,但与 ECS 或 EKS 等专用容器服务相比,Elastic Beanstalk 在容器编排方面提供的灵活性较低。
调试挑战:当出现问题时,抽象层有时会使故障排除变得更加困难,需要更深入的了解 AWS 来诊断问题。
选择 Elastic Beanstalk 的时机
Elastic Beanstalk 特别适合以下场景:
新接触 AWS 或云部署的团队
具有标准基础设施要求的应用程序
需要快速初始部署的项目
具有可预测扩展模式的 API
开发和测试环境
小型到中型开发团队
对于更复杂的架构、具有定制要求的微服务,或者需要精细粒度基础设施控制的应用程序,ECS、EKS 或直接 EC2 管理之类的解决方案可能更为合适。
Elastic Beanstalk 部署的最佳实践
为了最大化 Elastic Beanstalk 的益处同时缓解其局限性,请遵循以下最佳实践:
使用环境特定的配置文件:为开发、预发布和生产环境维护单独的配置文件对于确保部署管道中的一致性和可靠性至关重要。当您的订单管理 API 在这些环境中移动时,会遇到不同的基础设施要求、安全需求和性能预期。通过创建环境特定的配置,您可以防止诸如在生产环境中暴露开发调试工具或在低层开发环境中过度使用生产规模资源等问题。这种做法也有助于简化故障排除,因为您可以确定问题是否特定于环境或固有的应用程序代码。
实施蓝绿部署:使用 Elastic Beanstalk 的交换 URL功能进行蓝绿部署允许您维护两个相同的生产环境,在切换流量之前先将新代码部署到非活动环境。这种方法对于您的订单管理 API 非常有价值,因为它消除了部署期间的停机时间——客户可以不间断地继续下订单。它还提供了一个即时回滚机制;如果在部署后发现关键问题,您可以立即切换回先前的环境。对于高可用性应用程序来说,这种做法尤为重要,因为即使是几分钟的停机时间也可能导致收入损失或损害客户信任。
自动化部署:通过 AWS CodePipeline 和 CodeBuild 与 CI/CD 管道集成,将您的部署过程从易出错的手动步骤转变为一致、可重复的工作流程。对于订单管理 API,这种自动化确保每次部署都遵循相同的测试、验证和部署序列。这减少了人为错误,在生产部署前强制执行质量关卡,并创建所有更改的审计记录。自动化部署还允许更频繁、更小的更新,降低每次部署的风险,同时允许您的团队更快地交付新功能和修复。
监控资源使用情况:设置 CloudWatch 警报以在资源消耗异常时发出警报,可以在问题影响客户之前提供早期预警。您的订单管理 API 的资源需求会随着业务周期波动——假日购物期间可能比正常情况下产生显著更高的订单量。如果没有适当的监控,这些峰值可能导致性能下降甚至中断。CloudWatch 警报允许您检测到趋势,例如逐渐增加的内存使用(可能表明内存泄漏)或特定操作期间的 CPU 峰值。这种做法使您能够做出主动的扩展决策,并帮助识别应用程序中的优化机会。
实施适当的健康检查:为您的 API 设计全面的健康检查端点不仅限于确认应用程序正在运行,它还验证了所有关键组件是否正常工作。对于一个订单管理系统来说,这可能包括检查数据库连接性、验证库存服务是否响应,以及确保支付处理系统可用。设计良好的健康检查允许 Elastic Beanstalk 在何时替换失败的实例以及何时将流量路由到健康的实例时做出准确的决策。这种做法直接影响可用性和弹性,防止了技术上“运行”但功能受损的实例继续接收客户流量。
版本控制配置:将
.ebextensions
文件保存在您的源代码存储库中确保了配置的一致性,并提供了与代码更改一起的基础设施更改的历史记录。这种做法防止了环境之间的配置漂移,并使得将应用程序问题与特定的基础设施更改相关联成为可能。对于您的订单管理 API 来说,这意味着您可以轻松地追踪订单处理问题是否在代码更改或基础设施配置更改之后开始。这也有助于简化合规性审计,因为它提供了关于您的基础设施如何随时间演变的清晰文档。使用 Elastic Beanstalk 环境属性:利用环境属性进行配置而不是硬编码值,可以使您在不重新部署的情况下更改应用程序的行为。这在您需要在高流量期间调整节流限制、修改集成端点或暂时禁用功能时尤其有价值。环境属性还可以通过将敏感信息(如 API 密钥或数据库凭据)从源代码中排除来提高安全性。这种做法增加了操作灵活性,同时保持安全性并减少快速代码部署以进行配置更改的需求。
定期数据库备份:为您的订单管理 API 使用的 RDS 实例实施定期的自动备份可以防止数据丢失和损坏。订单历史、客户信息和库存数据是您业务的命脉——丢失这些数据可能是灾难性的。具有适当保留策略的定期备份确保您可以从数据库故障、意外数据删除甚至恶意攻击中恢复。这种做法还支持可能要求采取特定数据保护措施和保留期的合规性要求。
安全组管理:仔细配置安全组,仅允许必要的入站流量,为您的订单管理 API 形成了一个关键的安全边界。通过仅限制对所需端口和源 IP 范围的网络访问,您可以显著减少潜在入侵者可用的攻击面。例如,您的数据库应仅接受来自应用程序服务器的连接,而不是来自公共互联网。这种做法有助于防止未经授权访问敏感的客户和订单数据,同时确保合法流量顺畅流动。
日志轮转和管理:配置适当的日志轮转可以防止可能影响应用程序可用性的存储问题。如果没有适当的轮转,高流量期间的日志可能会消耗所有可用存储,可能导致您的订单管理 API 服务器崩溃。良好的日志管理还能在调查订单处理问题或性能问题时提供有价值的故障排除信息。这种做法在运营洞察的需求与资源限制之间取得平衡,确保您在不会影响稳定性的情况下保持对应用程序行为的可见性。
PaaS 的演变和未来趋势
随着云计算技术的快速发展和企业需求的日益复杂化,PaaS 领域正在经历一场变革性的演变,这将为 Java RESTful API 在数字生态系统中的开发、部署和管理带来革命性的变化:
基于 Kubernetes 的 PaaS:提供商越来越多地提供基于 Kubernetes 的 PaaS 解决方案,这些解决方案结合了传统 PaaS 的简单性和容器编排的灵活性。
无服务器 PaaS:PaaS 和无服务器计算之间的界限正在模糊,许多平台现在提供结合两者最佳特性的混合模型。
边缘计算集成:PaaS 提供商正在扩展到边缘位置,使 RESTful API 能够更靠近最终用户部署,从而降低延迟。
AI 驱动的操作:机器学习正在被整合到 PaaS 产品中,以提供预测性扩展、异常检测和自动化优化。
随着这些趋势的发展,Java 开发者应了解 PaaS 产品如何演变,以利用新的功能,从而增强 API 部署和性能。
摘要
在本章中,我们介绍了部署 Java RESTful API 的基础知识,从准备到配置管理、健康检查,再到使用 Docker 容器化和通过 AWS Elastic Beanstalk 的平台服务。这些方法提供了将 API 从开发转移到生产的实用途径。
这里介绍的部署方法故意设计得简单直接,适合寻求快速实施功能部署工作流程的个人开发者或小型团队。它们为那些刚开始 API 部署的新手提供了一个坚实的起点。
在涉及更大结构和团队开发的环境中,可能需要实施更复杂的部署流程,这需要更好地理解 DevOps 文化和实施 CI/CD 的管道。然而,这将需要对所有概念和技术进行研究,这些概念和技术将为实现这一目标铺平道路。可能需要专门为此主题编写一本新书。
随着您的应用程序规模扩大和组织需求增长,您可能需要采用更复杂的部署策略——包括自动化测试管道、审批工作流程、增强的安全扫描、云基础设施和全面的监控解决方案。这些高级实践建立在本章建立的基础之上。我们的目标是为您提供实用的、可实施的知识,以成功部署您的 Java RESTful API,为您在需求演变过程中继续探索部署实践提供一个跳板。
订阅我们的在线数字图书馆,全面访问超过 7000 本书籍和视频,以及领先的行业工具,帮助您规划个人发展并推进您的职业生涯。更多信息,请访问我们的网站。
为什么订阅?
通过来自 4000 多名行业专业人士的实用电子书和视频,节省学习时间,增加编码时间
通过为您量身定制的技能计划提高您的学习效果
每月免费获得一本电子书或视频
全文可搜索,便于快速获取关键信息
复制粘贴、打印和收藏内容
在 www.packtpub.com ,您还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。
您可能还会喜欢的其他书籍
如果您喜欢这本书,您可能对 Packt 出版的以下其他书籍也感兴趣:
Spring 软件架构
Wanderson Xesquevixos
ISBN: 978-1-83588-060-9
将复杂的企业需求转化为清晰且可实施的设计
使用常见的架构风格设计弹性系统
按照最佳实践将单体应用程序转换为微服务
使用 Kafka 实现事件驱动架构
监控、追踪并确保稳健的测试、安全和性能
使用模式、缓存和数据库策略识别瓶颈并优化性能
使用 CI/CD 管道自动化开发工作流程,使用 Jenkins 将应用程序部署到 Kubernetes
Spring 系统设计实践
Rodrigo Santiago
ISBN: 978-1-80324-901-8
实施微服务以实现可扩展、健壮的 Web 系统
将业务目标分解为结构良好的产品需求
权衡异步服务与同步服务以及 SQL 与 NoSQL 存储之间的权衡
通过采用测试驱动开发加速服务开发和可靠性
识别并消除隐藏的性能瓶颈,以最大化速度和效率
在分布式环境中实现实时处理和响应能力
Packt 正在寻找像您这样的作者
如果您有兴趣成为 Packt 的作者,请访问authors.packtpub.com并今天申请。我们已经与成千上万的开发者和技术专业人士合作,就像您一样,帮助他们将见解分享给全球技术社区。您可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交您自己的想法。
分享您的想法
您已经完成了《精通 Java RESTful Web 服务》,我们非常乐意听到您的想法!扫描下面的二维码直接转到该书的亚马逊评论页面并分享您的反馈或在该购买网站上留下评论。
您的评论对我们和整个技术社区都很重要,并将帮助我们确保我们提供高质量的内容。
分享您的想法
您已经完成了《精通 Java RESTful Web 服务》,我们非常乐意听到您的想法!如果您在亚马逊购买了这本书,请点击此处直接转到该书的亚马逊评论页面并分享您的反馈或在该购买网站上留下评论。
您的评论对我们和整个技术社区都很重要,并将帮助我们确保我们提供高质量的内容。