Java-中的反应式系统-全-

Java 中的反应式系统(全)

原文:zh.annas-archive.org/md5/6fc7f4b7f6fa457d8f096a9ff53b154b

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在 IT 世界中,今天的限制是明天的门户。在过去的 50 年中,IT 世界不知疲倦地不断发展,始终在突破界限。这些变化不仅源于技术进步,还因为我们——消费者。作为消费者,我们每天与之互动的软件要求越来越多。此外,我们与软件互动的方式已完全改变。我们无法没有移动应用和设备,现在也接受全天候接收通知。物联网(IoT)是一个新兴市场,承诺更多创新,不断增加处理的事件和数据量。云计算和 Kubernetes 不仅改变了我们的使用方式,还彻底改变了我们设计、开发、部署和维护应用程序的方式。

但不要误解;所有这些革命都是有代价的。虽然它们使新的用途和应用成为可能,但也引入了巨大的复杂性。今天大多数软件系统都是分布式系统。而分布式系统很难设计、构建和操作,尤其是在我们需要实现这些新的现代应用的规模上。我们需要处理故障、异步通信、不断变化的拓扑结构、动态可用性的资源等等。虽然云计算承诺无限资源,但资金是一个限制因素,增加部署密度,即在较少资源上运行更多内容,成为一个严峻的问题。

那么,什么是Reactive?它不是你在代码中使用的库,也不是魔法框架。Reactive是一套原则、工具、方法和框架,有助于构建更好的分布式系统。究竟好?这取决于系统,但是遵循 Reactive 原则的应用程序可以应对分布式系统的挑战,并专注于弹性、韧性和响应性,正如在响应式宣言中所解释的那样。

在本书中,我们使用大写R的名词Reactive来涵盖响应式领域的各种方面,例如响应式编程、响应式系统、响应式流等。通过本书,您将了解 Reactive 如何帮助我们应对这些新的关注点,并在云环境中的适应性。阅读完本书后,您将能够构建响应式系统——弹性、适应性、事件驱动的分布式系统。

谁应该阅读本书?

本书的目标读者是中级和高级 Java 开发人员。您应该对 Java 相当熟悉;但不需要有响应式编程或响应式的先验知识。本书中的许多概念涉及分布式系统,但您也不需要对其熟悉。

反应式系统通常依赖于诸如 Apache Kafka 或高级消息队列协议(AMQP)之类的消息代理。本书介绍了您理解这些代理如何帮助设计和实现反应式系统所需的基本知识。

本书可以使三个不同的群体受益:

  • 正在构建云原生应用程序或分布式系统的开发人员

  • 寻求理解反应式和事件驱动架构角色的架构师

  • 对反应式感兴趣并希望更好地理解的好奇开发者

通过本书,您将开始了解、设计、构建和实施反应式架构的旅程。您不仅将学习如何帮助构建更好的分布式系统和云应用程序,还将了解如何使用反应式模式改进现有系统。

Quarkus 到底是什么?

细心的读者可能已经注意到本书副标题中提到了 Quarkus。但是,到目前为止,我们还没有提到它。Quarkus 是专为云而设计的 Java 堆栈。它使用构建时技术来减少应用程序使用的内存量,并提供快速的启动时间。

但是 Quarkus 也是一个反应式堆栈。在其核心,反应式引擎使得创建并发和具有弹性的应用程序成为可能。Quarkus 还提供了构建分布式系统所需的所有功能,这些系统能够适应波动的负载和不可避免的故障。

在本书中,我们使用 Quarkus 来演示反应式方法的优势,并介绍各种模式和最佳实践。如果您对此不熟悉或缺乏经验,不要担心。我们将在旅程中陪伴您,指导您每一步。

本书专注于创建利用 Quarkus 能力的反应式应用程序和系统,并提供了构建此类系统所需的所有知识。我们不涵盖完整的 Quarkus 生态系统,因为本书专注于帮助构建反应式系统的 Quarkus 组件。

导读本书

如果您刚刚接触反应式并希望了解更多信息,全书阅读将带给您对反应式及其如何帮助您的理解。如果您是一位经验丰富的反应式开发人员,对 Quarkus 及其反应式特性感兴趣,您可能想跳过本书的第一部分,直接阅读最感兴趣的章节。

第一部分 是一个简短的介绍,设定了背景:

  • 第一章 提供了反应式领域的简要概述,包括其优点和缺点。

  • 第二章 介绍了 Quarkus 及其通过构建时方法减少启动时间和内存使用的方法。

第二部分 概述了一般的反应式内容:

  • 第三章 解释了分布式系统的复杂性和对反应式的误解;这些是成为反应式的原因。

  • 第四章 展示了响应式系统的特性。

  • 第五章 讨论了各种形式的异步开发模型,重点介绍了响应式编程。

第 III 部分 解释了如何使用 Quarkus 构建响应式应用程序:

  • 第六章 探讨了响应式引擎以及如何在命令式和响应式编程之间桥接。

  • 第七章 深入讲解了 SmallRye Mutiny,即 Quarkus 中使用的响应式编程库。

  • 第八章 解释了 HTTP 请求的特性以及我们如何在响应式中进行处理。

  • 第九章 解释了如何使用 Quarkus 构建与数据库交互的高并发高效应用程序。

最后部分,第 IV 部分,连接了各个点,展示了如何使用 Quarkus 构建响应式系统:

  • 第十章 深入探讨了 Quarkus 应用程序与消息传递技术的集成,这是响应式系统的重要组成部分。

  • 第十一章 着重介绍了与 Apache Kafka 和 AMQP 的集成,以及如何利用它们构建响应式系统。

  • 第十二章 探索了从 Quarkus 应用程序消费 HTTP 端点的各种方式,以及如何实施弹性和响应性。

  • 第十三章 涵盖了响应式系统中的可观察性问题,如自愈、追踪和监控。

为您做好准备

在本书中,您将看到许多代码示例。这些示例说明了本书涵盖的概念。有些示例很基础,可以在 IDE 中运行,而其他一些则需要一些先决条件。

我们逐一覆盖这些示例,遍布整本书。也许你对悬念不感兴趣。或者更可能的是,也许你已经厌倦了我们啰嗦地长篇大论,只是想看到它如何运作。如果是这样,只需将您的浏览器指向https://github.com/cescoffier/reactive-systems-in-java,随意试探一下。您可以使用git clone https://github.com/cescoffier/reactive-systems-in-java.git命令从 Git 获取代码。或者,您可以下载一个ZIP 文件,并解压缩它。

本书的代码按章节组织。例如,与第二章相关的代码位于chapter-2目录(见表 P-1)。根据章节的不同,代码可能会分成多个模块。书中的代码示例在代码库中的位置可以从书中的代码片段标题找到。

表 P-1. 各章节的代码位置

代码存储库中的示例使用 Java 11,所以请确保你的机器上安装了合适的 Java 开发工具包(JDK)。它们还使用 Apache Maven 作为构建工具。你不需要安装 Maven,因为代码库使用了 Maven Wrapper(自动提供 Maven)。但是,如果你更喜欢手动安装,可以从 Apache Maven 项目网站 下载它,并按照 安装 Apache Maven 页面 上的说明进行操作。

要构建代码,请从项目根目录运行mvn verify。 Maven 将下载一组构建工件,所以请确保有互联网连接。

本书介绍了 Quarkus,一种 Kubernetes 原生的 Java 堆栈。只要你有 Java 和 Maven,你就不需要安装任何东西来使用 Quarkus。它会自动下载其他所有内容。

你将需要 Docker。Docker 用于为我们的应用程序创建容器。请按照 获取 Docker 页面 上的说明安装 Docker。

最后,本书的几章介绍了我们在 Kubernetes 中部署反应式应用程序的过程。要部署到 Kubernetes,你首先需要kubectl,这是一个与 Kubernetes 交互的命令行工具。按照 Kubernetes 安装工具页面 的说明安装它。除非你有一个方便的 Kubernetes 集群,否则我们还建议在你的机器上安装 minikube,以提供 Kubernetes 环境。请按照 minikube 网站 上的说明安装它。

为什么我们需要所有这些工具?本书中你会看到,反应式既会给你的应用程序带来约束,也会给你的基础设施带来约束。Kubernetes 提供了我们部署应用程序、创建副本并保持系统运行的原语。另一方面,Quarkus 提供了我们实现反应式应用程序所需的一系列功能,包括非阻塞 I/O、反应式编程、反应式 API 和消息传递能力。Quarkus 还提供了与 Kubernetes 的集成,以便轻松部署和配置应用程序。

表 P-2 列出了本书中要使用的工具。

表 P-2. 本书中使用的工具

工具 网站 描述
Java 11 https://adoptopenjdk.net Java 虚拟机(JVM)和 Java 开发工具包(JDK)
Apache Maven https://maven.apache.org/download.cgi 基于项目对象模型(POM)的构建自动化工具
Quarkus https://quarkus.io 一种优化 Java 用于容器的 Kubernetes 原生堆栈
Docker https://www.docker.com/get-started 容器创建和执行
Kubernetes https://kubernetes.io 一个容器编排平台,也被称为 K8s
minikube https://minikube.sigs.k8s.io/docs/start Kubernetes 的本地分发
GraalVM https://www.graalvm.org 提供多种工具,包括从 Java 代码创建本机可执行文件的编译器
Node.js https://nodejs.org/en 一个 JavaScript 运行时引擎

本书中使用的约定

本书使用以下排版约定:

斜体

指示新术语、网址、电子邮件地址、文件名和文件扩展名。

常量宽度

用于程序列表,以及段落内引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

常量宽度粗体

显示用户应该逐字输入的命令或其他文本。

常量宽度斜体

显示应由用户提供的值或由上下文确定的值替换的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般注释。

警告

此元素表示警告或注意事项。

致谢

写一本书从未是一件容易的事。这是一项漫长而艰巨的任务,耗费了大量精力,也消耗了大量家庭时间。因此,我们首先感谢在这场马拉松中支持我们的家人。

我们也非常感激能与红帽公司的出色人才合作。在这段旅程中,无数人给予了我们帮助;不可能一一列举。特别感谢 Georgios Andrianakis、Roberto Cortez、Stuart Douglas、Stéphane Epardaud、Jason Greene、Sanne Grinovero、Gavin King、Martin Kouba、Julien Ponge、Erin Schnabel、Guillaume Smet、Michal Szynkiewicz、Ladislav Thon 和 Julien Viet。他们的工作不仅非常出色,而且令人惊叹。能与这些顶尖开发者一同工作,对我们来说是一种特权。

最后,我们感谢所有提供了出色和建设性反馈的审阅者们:Mary Grygleski、Adam Bellemare、Antonio Goncalves、Mark Little、Scott Morrison、Nick Keune 和 Chris Mayfield。

第一部分:Reactive 和 Quarkus 简介

第一章:简介反应式

Reactive是一个负载词。您可能已经通过搜索引擎搜索reactive以了解其含义。如果没有,也没关系——您省下了很多困惑。有许多reactive things:反应式系统、反应式编程、反应式扩展、反应式消息传递。每天都会有新的东西出现。所有这些“反应式”都是同一种反应式吗?它们有所不同吗?

这些是我们将在本章回答的问题。我们将偷窥反应式景观,以识别并帮助您理解reactive的各种细微差别,它们的含义,与它们相关的概念以及它们之间的关系。因为是的,不要透露太多,所有这些“反应式”都是相关的。

如前言所述,我们使用大写字母R的名词Reactive来汇集反应式景观的各种方面,如反应式编程、反应式系统、反应式流等等。

我们所说的反应式是什么?

让我们从头开始。暂时忘记软件和信息技术,使用一种老式方法。如果我们在牛津英语词典中查找reactive,我们会找到以下定义:

reactive(形容词)

显示对刺激的反应。1.1 根据情况作出反应,而不是创造或控制它。1.2 有化学反应的倾向。1.3 (生理学)对特定抗原显示免疫反应。1.4 (疾病或疾病)由对某物质的反应引起。1.5 (物理学)与电抗相关。

在这些定义中,有两个与我们的语境相关。第一个定义,显示对刺激的反应,指的是某种反应。子定义 1.1 指出,反应性还涉及面对意外和不受控制的情况。你将在本书中看到,云原生应用程序和分布式系统总体上会遇到大量这类情况。虽然这些定义很有趣,但并不适用于软件。但我们可以考虑这些定义,以制定一个特定于软件的新定义:

1.6 (软件) 对刺激(如用户事件、请求和失败)作出反应的应用程序。

然而,正如你将在本书中看到的,今天的reactive已经超越了这一点。反应式是一种设计、实现和推理你的系统的方法,涉及事件和流的概念。反应式涉及构建响应式弹性健壮的应用程序。反应式还涉及通过资源的有效管理和通信来利用资源。换句话说:反应式是设计和构建更强大和更高效的分布式系统的方法。我们称它们为反应式系统

反应式软件并不新颖

但等等,我们刚刚给出的定义(1.6)并不是开创性的。 相反,您可能会感觉有些似曾相识,不是吗? 如果软件的本质是对用户输入和操作系统信号做出反应,那么软件在按下按键时会如何行为? 它会做出反应。 那么,如果反应式只是常规软件,为什么会有这么多关于反应式的书籍、讨论和辩论呢?¹ 请耐心等待;这还有更多内容。

但您说得对; 反应式并不新。 它实际上相当古老。 我们可以追溯反应式软件背后思想的基础,这些思想在 50 年代计算机出现后不久就已存在。 DYSEAC,一台第一代计算机(1954 年投入使用),已经使用硬件中断作为优化,消除了轮询循环中的等待时间。 这台计算机是最早使用反应式和事件驱动架构的系统之一!

对事件的响应意味着是事件驱动的。 事件驱动软件接收并生成事件。 收到的事件决定程序的流程。 事件驱动的一个基本方面是异步性:您不知道何时会收到事件。² 这恰恰是前一节中定义 1.1 的内容。 您不能计划何时会收到事件,也无法控制将收到哪些事件,并且需要准备好处理它们。 这就是反应式的本质:异步性。

反应式景观

从这种异步和事件驱动的思想中,许多形式的反应式应运而生。 反应式的领域广阔而拥挤。 图 1-1 描述了这一景观的一部分以及主要反应式事物之间的关系。

反应式景观

图 1-1. 反应式景观

但不要忘记我们的目标:构建更好的分布式系统 —— 反应式系统。 其他的“反应式”在这里帮助我们实现这些系统。

反应式的原因,尤其是反应式系统,源于分布式系统。 正如您将在第三章中看到的那样,构建分布式系统是困难的。 2013 年,分布式系统专家撰写了“反应式宣言”的第一个版本,并引入了反应式系统的概念。

是的,您可以在不应用反应式原则的情况下构建分布式系统。 反应式提供了一个蓝图,确保在设计和开发系统时没有忽视任何重要的已知问题。 另一方面,您可以将这些原则应用于非分布式系统。

反应式系统首先是响应性的。即使在负载或面对故障时,它也必须及时处理请求。为了实现这种响应性,宣言提议使用异步消息传递作为系统中形成组件之间通信的主要方式。你将在第四章中看到,这种通信方法如何实现弹性和韧性,这是坚固分布式系统的两个基本属性。本书的目标是向你展示如何使用 Quarkus 构建这样的反应式系统。因此,构建反应式系统是我们的主要目标。

将异步消息传递注入到分布式系统的核心并不是没有后果的。你的应用程序需要使用异步代码和非阻塞 I/O,这是操作系统提供的一种能力,可以在不必积极等待完成的情况下排队 I/O 交互。 (我们在第四章中介绍了非阻塞 I/O)。后者对于改善资源利用率非常重要,比如 CPU 和内存,这是 Reactive 的另一个重要方面。今天,许多工具包和框架,如 Quarkus、Eclipse Vert.xMicronautHelidonNetty,正是基于这个原因使用非阻塞 I/O:用有限资源做更多事情。

然而,拥有利用非阻塞 I/O 的运行时并不足以成为反应式。你还需要编写异步代码,以拥抱非阻塞 I/O 机制。否则,资源利用的好处将会消失。编写异步代码是一种范式转变。从传统的(命令式)做 x; 做 y;,你现在将要塑造你的代码为当事件(e)发生时做 x; 当事件(f)发生时做 y;。换句话说,要想成为反应式,不仅你的系统是一个事件驱动的架构,而且你的代码也将变成事件驱动的。实现这种代码的最直接方法之一是回调:你注册函数,在收到事件时调用这些函数。像 futures、promises 和协程一样,每种其他方法都基于回调并提供了更高级别的 API。

注意

你可能会想为什么电子表格会出现在这个领域。电子表格是一种反应性工具。当你在一个单元格中写入一个公式,并改变由该公式读取的值(在另一个单元格中),该公式的结果会被更新。这个单元格对值的更新(事件)做出反应,结果(反应)是新的结果。是的,你的经理可能比你更擅长反应式开发!但不用担心,这本书会改变这种情况。

反应式编程,见第五章,也是一种编写异步代码的方法。它使用数据流来组织你的代码。你观察这些流中传输的数据,并对其做出反应。反应式编程提供了一个强大的抽象和 API,用于形成事件驱动的代码。

但是,使用数据流也存在问题。如果您有一个快速生产者直接连接到一个慢速消费者,您可能会淹没消费者。正如您将看到的,我们可以在中间使用缓冲区或消息代理,但想象一下在没有它们的情况下淹没消费者。这与反应式所推广的响应性和抗脆弱性思想背道而驰。为了帮助我们解决这个特定问题,Reactive Streams 提出了一种异步和非阻塞的背压协议,其中消费者向生产者发出其可用性的信号。正如您可以想象的那样,这可能并不适用于所有情况,因为某些数据源不能减速。

过去几年里,Reactive Streams 的流行度不断增加。例如,RSocket是一种基于 Reactive Streams 的网络协议。R2DBC 提议使用 Reactive Streams 进行异步数据库访问。此外,RxJavaProject ReactorSmallRye Mutiny 采用了反应式流来处理背压。最后,Vert.x 允许将 Vert.x 背压模型映射到 Reactive Streams。³

这就是我们对反应式景观的快速介绍。正如我们所说,它充斥着许多术语和工具。但是永远不要忘记反应式的总体目标:构建更好的分布式系统。这是本书的主要重点。

为什么反应式架构非常适合云原生应用程序?

云——无论是公有云、私有云还是混合云——都将反应式置于聚光灯下。云是一个分布式系统。当您在云上运行应用程序时,该应用程序面临着很高的不确定性。您的应用程序的供应可能会慢,也可能会快,甚至可能失败。由于网络故障或分区,通信中断很常见。您可能会遇到配额限制、资源短缺和硬件故障。您正在使用的某些服务有时可能不可用,或者会被移动到其他位置。

虽然云为基础设施层提供了出色的设施,但它只覆盖了故事的一半。第二部分是您的应用程序。它需要被设计成分布式系统的一部分。它需要理解作为这样一个系统的一部分所面临的挑战。

我们在本书中涵盖的反应式原则有助于拥抱分布式系统和云应用的固有不确定性和挑战。它不会隐藏它们——相反,它拥抱它们。

随着微服务和无服务器计算日益成为主流架构风格,反应式原则变得更加重要。它们可以帮助确保您在坚实的基础上设计系统。

反应式不是万能药

就像一切事物一样,反应式有利有弊。它不是一种魔法武器。没有解决方案适用于所有情况。

记得 2010 年代末的微服务吗?它们迅速变得极为流行,许多组织在可能不适合的领域实施了它们。这往往是一种问题的交换。就像微服务架构一样,响应式架构在某些方面非常适合。它们适用于分布式和云应用程序,但在更为单片和计算密集型的系统上可能会带来灾难。如果您的系统依赖于远程通信、事件处理或高效性,响应式将会很有趣。如果您的系统主要使用进程内交互,每天处理的请求很少,或者计算密集型,则响应式除了复杂性之外不会带来任何好处。

使用响应式,您将事件的概念置于系统核心。如果您习惯于传统的同步和命令式应用程序构建方式,那么转向响应式可能会很陡峭。需要变为异步会打破大多数传统框架。我们正在远离众所周知的远程过程调用(RPC)和 HTTP 端点。因此,在此免责声明之后,是时候开始我们的旅程了!

¹ 您可以在 YouTube 上找到大量关于响应式的演讲,链接在这里

² 异步是同步的反义词。异步意味着在不同的时间点发生,而同步意味着同时发生。

³ 请参阅Vert.x 响应式流集成以获取更多详细信息。

第二章:Quarkus 简介

在继续理解响应式之前,让我们花点时间了解一下 Quarkus。那么,什么是 Quarkus?

Quarkus是一个基于 Kubernetes 的 Java 堆栈。它专为 Kubernetes、容器和云环境进行了定制,但在裸金属和虚拟机上也能完美运行。¹ Quarkus 应用程序需要比使用传统框架的应用程序更少的内存,并且启动更快。它们还可以编译成本地可执行文件,从而消耗更少的内存并实现即时启动。

Quarkus 一个令人兴奋且核心的方面是其响应式引擎。在容器或虚拟化环境中运行时,响应式引擎对于减少内存和 CPU 消耗至关重要。该引擎使得任何 Quarkus 应用程序都能高效运行,并支持响应式应用程序和系统的创建。

在本章中,您将看到 Quarkus 的主要特点,并学会创建应用程序、将其部署到 Kubernetes,并创建本地构建。在第六章中,我们详细介绍了响应式引擎,并展示了如何在 Quarkus 上开发统一的响应式和命令式编程模型。

Java 云上的运行

Java 现在已经 25 岁了!有时很难想象。从三层架构和客户端/服务器架构的时代开始,Java 随着多年来架构的许多变化而发展。然而,当一门语言已经 25 岁时,可能会留下一些不适合现代开发的部分内容。

这是什么意思?当 Java 的最初版本发布时,容器微服务无服务器以及今天与计算相关的任何其他术语都还没有被想象出来。我们无法指望 Java 语言在三层架构和客户端/服务器架构时代创建的版本在今天的容器中表现得符合我们的需求。

是的,Java 在多年来确实取得了许多进展,尤其是在最近几年的更快发布周期下。同时,Java 自豪地宣称没有打破开发人员和用户的向后兼容性。这种方法的一个巨大成本是,Java 仍然保留了在没有容器和其提供的知识和好处的情况下构思的部分内容。

对于许多应用程序来说,Java 将继续正常工作,并且如现在这样运行很多年。然而,在过去几年中,随着微服务的爆发,以及最近的向无服务器的演变,Java 并不自然适应这些部署模型。

几年前,Java 在容器中的适应性不足问题显而易见,因为我们发现Java 忽视了 cgroups。对于容器来说,这造成了一个巨大的问题。Java 无法看到分配给容器的内存量,只能看到整个物理机的内存。

在每个容器需要在受限内存量内工作的环境中,Java 并没有友好。Java 很贪婪。此外,Java 会根据 CPU 核心数创建应用程序线程。这导致在内存和 CPU 受限的容器中分配了更多的线程。

是否是一个大问题?如果你的 Java 应用程序部署在容器中,并且在 Kubernetes 节点上部署时,其他容器的内存限制良好控制,那么你可能会运气好。然后有一天,内存消耗量由 Java 虚拟机(JVM)引起的负载激增,然后!Kubernetes 会因为使用过多内存而终止该容器。

关于 Java 和 cgroups 的特定问题自 Java 10 以来已经修复,自 Java 开发工具包(JDK)8u131 起,已有可用于启用相同行为的选项。请参阅 Rafael Benevides 在红帽开发者网站上的“Java Inside Docker”文章,了解所有细节。

你可能认为现在 Java 在容器或云中应该表现良好了,对吧?尽管使用适当的 JDK 版本可以解决此问题,但许多企业仍在使用 JDK 8 或更早版本,并且很可能没有使用可用的标志来配置 JDK 8。而 Java 在云中的问题不仅仅是 cgroups。

容器不会因为抓取比预期内存更多而被杀掉是很好的。然而,Java 在容器中引发了关于应用程序开始接收请求速度和运行时内存消耗的担忧。与其他语言在容器中运行相比,这对 Java 应用程序来说都不是一个好消息。也许对许多当前运行的应用程序来说,启动速度并不是一个问题,但对于需要迅速扩展以处理大流量峰值或服务器无服务应用程序的冷启动时间来说,它确实有影响。

我们所说的开始接收请求是什么意思?虽然在构建应用程序中使用的框架通常会记录它们的启动时间,但它指的是框架启动所花费的时间。这个时间并不代表应用程序在能够开始接收请求之前所花费的时间。这段时间对于容器和云环境非常关键!

开始接收请求的时间也可以称为首次请求时间。如果一个框架可以在半秒钟内启动,但在应用程序能够开始接收和处理流量之前还需要额外的 2 到 3 秒钟,那么这并不多。在这样的例子中,新的应用程序实例在可以开始接收用户请求之前可能需要 2.5 到 3.5 秒钟。

诚然,对于具有少量内部用户的单体应用程序,启动接收请求的时间和内存消耗可能并不是问题。虽然可以使用 Quarkus 开发单体应用程序,但我们谈论 Quarkus 的好处时,在开发单体应用程序时不会像在微服务中那样显著。但是,对于微服务,尤其是无服务器架构,这两个因素都会影响运行服务的成本和向用户提供的可用性。

警告

框架通常可以通过延迟工作直到接收到第一个请求来实现较低的启动时间。任何剩余的启动任务在处理第一个请求之前完成。惰性初始化 是这种行为的另一个名称,它提供了一个关于应用真正准备好的虚假指示。开始接收请求 的时间是衡量应用启动时间的最佳指标。在无服务器工作负载以及使用 规模至零 方法的任何机制中,具有较低的 首个请求时间 是至关重要的,其中服务仅在需要时启动。在更常见的架构中,这种快速启动时间可以减少崩溃后的恢复时间。

如何测量启动时间?有许多方法可行,包括修改端点以在访问时输出时间戳。为了让生活更简单,我们将使用由 Red Hat 的 John O’Hara 开发的 Node.js 脚本。² 此脚本使用应用程序启动命令和访问它的 URL,在另一个进程中启动应用程序。脚本会等待 URL 返回 200,表示成功,然后计算首个请求的时间。

注意

为了方便使用,我们将 GitHub 仓库的内容chapter-2/startup-measurement 目录中的代码一并包含在内。确保你已安装了 Node.js,并运行 npm install request 来安装脚本依赖。

现在你可能会认为,关于启动速度和内存消耗的讨论是一个非常 手摇摆 的主题,太主观了。我们完全同意这一点,这就是为什么我们现在将使用传统的 Java EE 技术栈,在本例中使用 Thorntail 来实践这些概念。我们选择 Thorntail 进行比较,因为它是 Red Hat 的第一个微服务框架,而 Quarkus 则是最新的。尽管 Thorntail 项目已不再维护,但好消息是 Quarkus 吸收了许多 Thorntail 的想法。

在我们开始编码和运行应用程序之前的最后一点要说明的是 内存 可能是一个比较模糊的术语,因为有许多类型的内存。当我们谈论内存时,我们指的是 常驻集大小 (RSS),而不是 JVM 堆大小,因为堆只是 Java 应用程序消耗的总内存的一部分。在 JVM 上运行应用程序时,总分配内存可以包括以下内容:

  • 堆空间

  • 类元数据

  • 线程堆栈

  • 编译后的代码

  • 垃圾收集

RSS 表示进程从主内存(RAM)占用的内存量。 RSS 包括 JVM 运行应用程序所需的所有内存,提供了实际占用内存量的更精确值。由于我们在单个 JVM 进程中运行单个应用程序,因此可以轻松确保我们未测量非应用程序进程的内存消耗。

注意

所有性能数字均来自我们的 MacBook 计算机。因此,在本章中看到的结果可能因您的特定硬件配置而略有不同。如果您拥有 Apple M1,您可能会看到更好的结果!

好的,是时候运行一些代码,看看我们在启动速度和内存消耗方面的讨论。

Thorntail 示例

我们首先通过使用 Thorntail 创建一个 传统 应用程序来进行比较内存或 RSS 和首次请求时间指标。对于不熟悉 Thorntail 的人来说,该项目专注于可定制的WildFly 服务器的概念。Thorntail 只获取特定应用程序所需的部分,删除其他所有内容。

Thorntail 应用程序需要一个Java API for RESTful Web Services (JAX-RS) 应用程序,并提供一个简单的资源端点用于我们发送请求。Thorntail 示例需要一个 JAX-RS 应用程序,以及一个 JAX-RS 资源,其中包含一个方法,用于对 HTTP GET 请求返回问候语。Thorntail 示例的所有源代码可以在 /chapter-2/thorntail-hello 目录中找到。

类并没有什么特别之处。它们是提供使用 JAX-RS 提供 HTTP 端点进行请求的最低要求。让我们构建 Thorntail 应用程序,然后像 示例 2-1 中所示启动它。

示例 2-1. 构建和运行 Thorntail Hello World 应用程序
> mvn verify
> java -jar target/thorntail-hello-world-thorntail.jar

应用程序启动后,请使用 curl 或浏览器访问http://localhost:8080/hello端点。在进行测量 RSS 之前访问端点非常重要,因为应用程序可能在启动时未加载所有类,这意味着我们可能会看到误导性的数字。

要查找 Thorntail 应用程序所使用的内存,我们需要其运行的进程 ID。在基于 Linux 的系统中(包括 Mac),我们可以使用 ps -e | grep thorntail 命令列出所有活动进程,并将结果限制为名称中包含 thorntail 的进程。有了进程 ID,现在我们可以查找进程使用的 RSS 量(如 示例 2-2 中所示)。

示例 2-2. 测量 Thorntail 应用程序的 RSS 使用情况
> ps -o pid,rss,command -p 4529 | awk '{$2=int($2/1024)"M";}{ print;}'     ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/1.png)

PID   RSS COMMAND
4529 441M java -jar target/thorntail-hello-world-thorntail.jar

1

ps 命令检索 RSS 和命令,而 awk 将 RSS 值转换为兆字节。

您将看到类似上述终端输出的内容,显示了进程 ID、转换为兆字节(M)的 RSS 和命令。有关如何为进程查找 RSS 的完整详细信息,请参阅 Quarkus 网站[³]。

我们可以看到一个类似“Hello World”风格的应用程序,具有返回字符串的单个端点,使用了 441 兆字节(MB)。哇!对于返回固定字符串的单个 JAX-RS 端点来说,这是很多内存!

我们应该注意,我们在 OpenJDK 11 上运行这些测试,没有对 JVM 进行任何限制内存捕获量或 JVM 提供的任何其他调优的自定义。我们可以限制 JVM 能够抓取的内容,看看这如何影响整体 RSS (示例 2-3)。

示例 2-3. 启动 Thorntail 应用程序以配置堆大小
> java -Xmx48m -XX:MinHeapFreeRatio=10 -XX:MaxHeapFreeRatio=20 \
    -jar target/thorntail-hello-world-thorntail.jar

现在我们在 示例 2-4 中获得输出。

示例 2-4. 测量 RSS 使用量
> ps -o pid,rss,command -p 5433 | awk '{$2=int($2/1024)"M";}{ print;}'
PID   RSS COMMAND
5433 265M java -Xmx48m -XX:MinHeapFreeRatio=10 -XX:MaxHeapFreeRatio=20 \
    -jar target/thorntail-hello-world-thorntail.jar

那将内存使用量降至 265 MB!通过将 JVM 抓取的堆大小限制为 48 MB,我们节省了将近 200 MB 的 RSS。也许在吞吐量方面,48 MB 不是最佳选择,但这是需要通过您自己的应用程序来验证的,以找到减少内存消耗和增加吞吐量之间的平衡点。

我们已经展示了 RSS 使用情况,现在我们需要计算首次请求时间。在继续之前,请确保停止所有先前的 Thorntail 应用程序实例。让我们来看看首次请求时间,如 示例 2-5 所示。

示例 2-5. 测量 Thorntail 应用程序的首次请求时间
> node time.js "java \
 -jar [fullPathToDir]/thorntail-hello/target/
 thorntail-hello-world-thorntail.jar" \
    "http://localhost:8080/hello"

我们看到一堆404消息在控制台中飞过,直到应用程序返回200响应,然后我们看到花费的时间。在我们的情况下,这是 6,810 毫秒(ms)!在微服务和函数的世界中,这并不算快。您可以运行几次以查看时间是否有很大变化或根本没有变化。由于启动时间为 7 秒,扩展微服务无法快速满足流量峰值,导致用户延迟和可能的错误。从无服务器的角度来看,我们更糟糕,因为我们期望无服务器函数在 7 秒之前就能启动、运行和停止。

注意

使用 time.js 捕获的首次请求时间可能比实际时间略长,因为在子进程生成但 JVM 启动之前,将包含一个非常小的时间量。我们对这种微小影响并不担心,因为影响适用于我们以相同方式测试的每个运行时。

所以,我们已经看到了传统应用程序为 RSS 消耗的情况,以及达到首次请求时间需要多长时间。现在是时候看看 Quarkus 的比较了。

Quarkus 示例

我们将创建一个相同的 Hello World 端点,尽管它不会说“Hello from Thorntail!”对于 Quarkus,我们不需要 JAX-RS 应用程序类;我们只需要具有与 Thorntail 版本相同内容的 JAX-RS 资源,除了消息[⁴]。Quarkus 示例的源代码可以在/chapter-2/quarkus-hello目录中找到。

在“创建您的第一个 Quarkus 应用程序”中,我们介绍了如何创建 Quarkus 应用程序。现在按照示例 2-6 中所示构建并运行 Quarkus Hello World 应用程序。

示例 2-6. 构建并启动 Quarkus Hello World 应用程序
> mvn verify
> java -jar target/quarkus-hello-world-1.0-SNAPSHOT-runner.jar

与 Thorntail 一样,我们不是优化 JVM 来查看我们看到的原始 RSS 使用情况。像我们在 Thorntail 中那样多次点击http://localhost:8080/hello。希望你看到了“Hello from Quarkus!”的消息。否则,你仍在运行 Thorntail 应用程序。

找到 Quarkus 应用程序的进程 ID,并检查 RSS(示例 2-7)。

示例 2-7. 测量 Quarkus Hello World 应用程序的 RSS 使用情况
> ps -o pid,rss,command -p 6439 | awk '{$2=int($2/1024)"M";}{ print;}'
PID    0M COMMAND
6439 133M java -jar target/quarkus-hello-world-1.0-SNAPSHOT-runner.jar

在这里,我们看到 Quarkus 使用了 133 MB 的 RSS,比 Thorntail 的 441 MB 少了 300 多 MB!这对于本质上相同的应用程序来说是惊人的改进。

如果我们将最大堆大小限制为 48 MB,就像我们为 Thorntail 所做的那样,我们会得到多大的改进?查看示例 2-8。不要忘记一旦应用程序启动就使用端点。

示例 2-8. 限制堆使用并测量 RSS 使用情况
> java -Xmx48m -XX:MinHeapFreeRatio=10 -XX:MaxHeapFreeRatio=20 \
    -jar target/quarkus-hello-world-1.0-SNAPSHOT-runner.jar
> ps -o pid,rss,command -p 7194 | awk '{$2=int($2/1024)"M";}{ print;}'
PID    0M COMMAND
7194 114M java -Xmx48m -XX:MinHeapFreeRatio=10 -XX:MaxHeapFreeRatio=20 \
    -jar target/quarkus-hello-world-1.0-SNAPSHOT-runner.jar

这将其降低到 114 MB,但让我们看看我们能将 Quarkus 推向多大的堆大小!参考示例 2-9。再次,一旦启动,请不要忘记使用端点。

示例 2-9. 限制 Quarkus 应用程序的堆使用,并进一步测量 RSS 使用情况
> java -Xmx24m -XX:MinHeapFreeRatio=10 -XX:MaxHeapFreeRatio=20 \
    -jar target/quarkus-hello-world-1.0-SNAPSHOT-runner.jar
> ps -o pid,rss,command -p 19981 | awk '{$2=int($2/1024)"M";}{ print;}'
PID    0M COMMAND
19981 98M java -Xmx24m -XX:MinHeapFreeRatio=10 -XX:MaxHeapFreeRatio=20 \
    -jar target/quarkus-hello-world-1.0-SNAPSHOT-runner.jar

使用 24 MB 的最大堆,我们将 RSS 降至 98 MB!更重要的是,应用程序仍然可用!看看在 Quarkus 无法启动之前你能将最大堆降低到多低。尽管对于常规应用程序,甚至是微服务,你不会将最大堆设置得这么低,但能够将其设置得这么低对于无服务器环境至关重要。

提示

将堆大小设置为非常低的值可能会惩罚应用程序的性能,特别是如果应用程序进行了大量的分配。不要追求尽可能小的值,而是根据您预期的性能和部署密度增益来验证该值。请注意,Quarkus 架构试图避免出现这种惩罚。然而,我们强烈建议您与您的应用程序进行核实。

时间来测试启动速度(参见示例 2-10)。

示例 2-10. 测量 Quarkus 应用程序的首次请求时间
> node time.js "java \
 -jar [fullPathToDir]/quarkus-hello/target/
 quarkus-hello-world-1.0-SNAPSHOT-runner.jar" \
    "http://localhost:8080/hello"

如果您的硬件与我们的相似,您应该能看到大约 1,001 毫秒的第一次请求时间!这几乎比传统应用程序快了七倍!

这一切都非常棒,但是意义何在?回顾我们之前关于 Java 在容器中的讨论,让我们来看看在容器中运行时的影响。假设我们有一个可用内存为 2 GB 的节点,每种应用程序可以容纳多少个容器?参考 图 2-1。

Java 内存中的容器

图 2-1. 容器中的 Java 内存:Quarkus 允许增加部署密度

容器密度 是使用 Kubernetes 进行云部署的关键特征。给定特定节点大小,例如 2 GB 的 RAM,能够在单个节点中运行的容器越多,我们就能提供更大的容器密度。从 图 2-1 的例子中可以看出,使用 4 个实例还是 14 个实例可以实现更高的吞吐量?即使每个 14 个容器支持的吞吐量或请求每秒较少,与传统容器中的一个相比,这也无关紧要。在容器中轻微减少吞吐量比支持 14 个容器而不是 4 个容器更为重要。

容器密度是确定所需实例数量的重要指标。开发人员需要确定的是他们期望的或需要支持的吞吐量。也许今天的需求可以接受少量具有较大内存需求的容器,但请记住事情是会变化的,您可能很快就需要超过四个容器来支持您的用户!

您现在已经看到了传统应用程序在 JVM 上的 RSS 内存量和第一次请求的时间,以及 Quarkus 如何显著减少这些方面。Quarkus 想要应对改善 Java 在容器中的挑战,提出了一种新的方法。这种方法提高了 Java 在容器中的启动速度和内存消耗。

接下来的部分将详细解释 Quarkus 如何实现这一点,更重要的是,它与传统框架方法的不同之处。

Quarkus 的方式

您肯定想知道 Quarkus 比传统框架启动更快,内存消耗更少的复杂细节,对吧?我们需要先放慢速度,解释传统框架的工作方式,这样您才能理解 Quarkus 带来的变化。

传统框架的一些著名特性展示在 图 2-2 中:

  • 在代码中定义预期行为的注解,多年来我们都使用了许多示例。典型的例子包括 @Entity@Autowired@Inject 等等。

  • 各种类型的配置文件。这些文件做的事情从定义类如何被连接到配置持久数据源,无所不包。

  • 仅在启动期间使用的用于创建运行时元数据和应用程序功能的类。

  • 利用反射确定要调用的方法,将值设置到对象中,并仅通过名称动态加载类。

Quarkus 构建时间方法

图 2-2. Quarkus 的方式

我们当然并不是说 Quarkus 没有注解、配置文件或传统框架的任何其他特性。我们确实是说 Quarkus 以非常不同的方式处理它们。

为什么传统框架的这些特性会被认为是“不好”的呢?这是一个很好的问题,回答需要对这些框架如何处理前述特性有一定的理解。当需要解析任何配置或发现注解时,框架类需要执行这项工作。根据过程的复杂程度,可能需要从几十到几百个类来执行任务。此外,每个类通常会在自身内部保存状态,代表启动过程中的中间状态,或者在处理完成后表示最终期望的状态。

对此并没有什么特别之处;框架多年来甚至几十年来都是这样工作的。然而,您可能没有意识到的是,任何用于执行这些启动任务的类仍然存在,即使 JVM 进程已经运行了六个月而没有重启!虽然这些类抓取的任何内存应该最终被垃圾回收,只要这些类在工作完成时正确释放了它们对内存的控制,但它们的类元数据仍然存在于 JVM 中,即使在最新的 Java 版本上也是如此。这可能看起来不是很多,但是几百个不再需要的类可能会影响 JVM 所需的内存量。

这个问题今天影响所有的 JVM,没有框架的特殊处理。只有当类的所有对象可以被垃圾回收、对类的所有引用被删除,并且最重要的是,同一个类加载器中的所有其他类也不再被引用时,JVM 才能够对启动期间使用的所有类进行垃圾回收。为了促进启动类的垃圾回收,框架需要为启动类使用一个类加载器,而为运行时类使用另一个类加载器。在使用线程池(特别是 ForkJoinPool)和在启动期间设置线程局部变量时,启动类的垃圾回收可能会变得困难。

如果永远不会再次使用类,为什么要在 JVM 内存中保留它们?理想情况下,我们不应该这样做,因为这是浪费的。这就是 Quarkus 的优势所在。Quarkus 扩展被设计和构建成将传统框架启动处理的各个部分分解为更小的工作块。这样做使得 Maven 或 Gradle 的构建过程能够利用这些较小的块,并在构建期间执行它们,而不是等到运行时启动。在构建时利用启动类意味着这些类在运行时不需要包含在 JVM 中!这样就节省了内存和启动时间。

如何在构建时完成这些工作,并且在运行时需要的输出放在哪里?扩展使用字节码记录器来完成所有工作,从在运行时为类设置静态值到创建新类以保存运行时需要的元数据。我们是什么意思呢?早些时候我们讨论过框架在启动时做了很多工作,而 Quarkus 能够在构建时创建该工作的输出,并编写字节码以达到与传统框架在启动时相同的结果。在运行时,JVM 加载由 Quarkus 扩展编写的类到内存中,就好像所有这些启动工作都刚刚发生过一样,但没有了内存和类的成本。

查看传统框架在启动时执行的一些步骤,我们可以在 图 2-3 中看到 Quarkus 如何以不同的方式处理它们。

传统框架与 Quarkus 中的框架启动阶段

图 2-3. 传统框架与 Quarkus 中的框架启动阶段比较

虽然 Quarkus 在构建时读取配置,但某些属性,例如位置和凭据,仍然在运行时配置和读取。然而,一切应用程序相关的可以在构建时决定的内容都在构建过程中处理。到目前为止,我们一直在使用构建时来描述 Quarkus 完成这些通常与启动相关的任务的时间,但还有另一个术语:提前编译(AOT)。您已经看到 Quarkus 在优化应用程序代码和依赖项的方法上与传统框架有所不同。是的,这种方法减少了通常在运行时处理的可变性。

然而,在云端或容器中部署的现代工作负载并不需要这种可变性,因为几乎所有内容在构建时都是已知的。我们希望您现在对 Quarkus 通过这种创新方式提供的优势有了更清晰的理解,以及为什么它再次为 Java 在云端开发带来了兴奋。

创建您的第一个 Quarkus 应用程序

创建 Quarkus 应用程序的方法有很多种:

  • 手动创建项目的 pom.xmlbuild.gradle 文件,添加 Quarkus 依赖项,设置和配置插件,并定义源文件夹。在我们看来,相当混乱和乏味!

  • 使用 Maven 和 Quarkus 插件构建项目框架。

  • 浏览至https://code.quarkus.io,并选择所需的依赖项。这是最简单、最快速的入门方法,也是我们将要使用的方法。

是时候着手创建项目了!前往https://code.quarkus.io,您将看到图 2-4 中的页面。我们已圈出一些关键部分,以便详细解释它们。

在页面的最顶部是生成项目的 Quarkus 版本。稍低左侧是可以自定义的项目组和构件名称。如果需要,稍后也可以更改这些;如果忘记自定义,也不用担心一直使用org.acme

在右侧,用户可以决定是否向项目添加起始代码。默认情况下是是,因此如果您选择了任何带有CODE标记的扩展,例如RESTEasy JAX-RS,则会为该扩展生成项目的起始代码。在页面的顶部以下是所有可用的 Quarkus 扩展列表。提供了大量扩展;屏幕截图仅显示适合单页的扩展。使用每个复选框选择要包含在项目中的特定扩展。

Quarkus 项目选择

图 2-4. Quarkus 项目选择

最后,如果您不想浏览所有扩展,可以在所有扩展上方的搜索框中开始输入术语。随着您的输入,下方的扩展列表将进行过滤,仅显示与您搜索条件匹配的扩展。在选择扩展后,它们将显示在“已选扩展”区域下方,旁边是“生成您的应用程序”。

图 2-5 展示了当我们即将生成应用程序时屏幕的样子。

Quarkus 项目生成

图 2-5. Quarkus 项目生成

可以看到,我们选择不生成任何起始代码,并选择了 RESTEasy JAX-RS 扩展。目前我们坚持使用普通的 JAX-RS。我们将在第八章中探索更响应式的 JAX-RS。

当我们悬停在“生成您的应用程序”上时,我们可以决定将项目下载为 ZIP 文件或发布到 GitHub 存储库。目前,我们将下载为 ZIP 文件。文件将自动下载,名称与构件名称匹配。下载完成后,将 ZIP 文件解压缩到一个目录中。

完成后,我们打开一个终端窗口并切换到生成项目的目录。让我们深入使用实时重载(示例 2-11)并体验真正的开发者乐趣!

示例 2-11. 在开发模式下运行应用程序
> mvn quarkus:dev

上述命令启动了 Quarkus 的实时重新加载,使我们能够快速迭代代码并即时看到影响。如果成功启动,终端输出如 示例 2-12 所示。

示例 2-12. 输出 Quarkus 应用程序
__  ____  __  _____   ___  __ ____  ______
 --/ __ \/ / / / _ | / _ \/ //_/ / / / __/
 -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/
INFO  [io.quarkus] (Quarkus Main Thread) code-with-quarkus 1.0.0-SNAPSHOT on JVM \
    (powered by Quarkus 2.2.0.Final) started in 0.937s. \
    Listening on: http://localhost:8080
INFO  [io.quarkus] (Quarkus Main Thread) Profile dev activated.
  Live Coding activated.
INFO  [io.quarkus] (Quarkus Main Thread) Installed features: [cdi]

我们已经开始并且正常运行了。在浏览器中打开 http://localhost:8080,你将看到 资源未找到。哦,不!出了什么问题?

实际上,没有任何问题。敏锐的读者可能已经注意到启动日志只列出了cdi作为已安装的功能。那么 RESTEasy 呢?我们在创建项目时选择了其扩展。打开 pom.xml,你会看到这些依赖项(示例 2-13)。

示例 2-13. 生成项目的 Quarkus 扩展依赖项 (chapter-2/code-with-quarkus/pom.xml)
<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-resteasy</artifactId>
</dependency>
<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-arc</artifactId>
</dependency>
<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-junit5</artifactId>
  <scope>test</scope>
</dependency>

RESTEasy 明确作为一个依赖项存在,那么出了什么问题呢?在构建过程中,Quarkus 发现实际上没有任何使用 RESTEasy 的代码,因此卸载了该功能,并可用于释放内存。现在让我们来修复这个问题。

当 Quarkus 仍在运行时,在 /src/main/java 内创建 org.acme 包。然后在该包内创建名为 MyResource 的类,并参考 示例 2-14 中的内容。

示例 2-14. JAX-RS MyResource (chapter-2/code-with-quarkus/src/main/java/org/acme/MyResource.java)
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

@Path("/")
@Produces({MediaType.TEXT_PLAIN})
public class MyResource {
  @GET
  public String message() {
    return "Hi";
  }
}
注意

你可能会想为什么上述片段中没有任何 import 行是 Quarkus 特定的。Quarkus 通过利用超过 50 个最佳库提供了一个具有内聚性的全栈框架。在上述示例中,我们使用了 JAX-RS,这是构建 HTTP 和 REST API 的简单而高效的方法。

刷新 http://localhost:8080。哇,现在我们在浏览器中看到 Hi 了;发生了什么?查看终端窗口(示例 2-15)。

示例 2-15. 代码更改后应用程序的自动重启
INFO  [io.qua.dep.dev.RuntimeUpdatesProcessor] (vert.x-worker-thread-7) \
    Changed source files detected, recompiling \
    [{pathToProject}/code-with-quarkus/src/main/java/org/acme/MyResource.java]
INFO  [io.quarkus] (Quarkus Main Thread) code-with-quarkus stopped in 0.037s
__  ____  __  _____   ___  __ ____  ______
 --/ __ \/ / / / _ | / _ \/ //_/ / / / __/
 -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/
INFO  [io.quarkus] (Quarkus Main Thread) code-with-quarkus 1.0.0-SNAPSHOT on JVM \
    (powered by Quarkus 1.11.1.Final) started in 0.195s. \
    Listening on: http://localhost:8080
INFO  [io.quarkus] (Quarkus Main Thread) Profile dev activated. Live Coding activated
INFO  [io.quarkus] (Quarkus Main Thread) Installed features: [cdi, resteasy]
INFO  [io.qua.dep.dev.RuntimeUpdatesProcessor] (vert.x-worker-thread-7) \
    Hot replace total time: 0.291s

我们可以看到 Quarkus 察觉到了对 MyResource.java 的修改,停止并重新启动了自身。查看已安装的功能,我们现在看到它包括resteasy。这是多么酷啊!更好的是,服务器仅在 300 毫秒内停止并重新启动。

我们为什么不更深入地探索实时重新加载,以更好地体验真正的开发者乐趣呢!保持 mvn quarkus:dev 运行,然后在浏览器中打开 http://localhost:8080/welcome(图 2-6)。

资源未找到

图 2-6. 资源未找到

我们遇到了一个错误。哦,不!

不要太害怕,我们是预料到的,因为我们没有任何响应 /welcome 终点的内容。然而,Quarkus 提供了一些链接来帮助诊断问题,基于它所知道的应用程序信息。它显示了有效终点的列表——在这种情况下,只有一个 / 上的 HTTP GET。

在“附加端点”下,有一些用于开发应用程序的端点。在这个例子中,我们有与ArC相关的端点,它是基于上下文和依赖注入(CDI)的 Quarkus 的 bean 容器,还有一个链接到开发者控制台。点击开发者控制台链接将带你到其主页(图 2-7)。

Quarkus 开发控制台

图 2-7. Quarkus 开发控制台

现在那里并没有太多内容,但我们需要记住,我们唯一添加的扩展是 RESTEasy。随着我们用更多扩展增强应用程序,将从开发者控制台获得更多选项和能力。我们在一旁岔开了话题,所以让我们回到解决加载页面失败的问题上吧!在浏览器中打开 /welcome 页面并在失败页的源代码处,返回并创建一个名为WelcomeResource的新类(示例 2-16)。

示例 2-16. JAX-RS WelcomeResource (chapter-2/code-with-quarkus/src/main/java/org/acme/WelcomeResource.java)
@Path("/welcome")
public class WelcomeResource {
  @GET
  public String welcomeMessage() {
    return "Welcome to Quarkus!";
  }
}

类写好后,回到浏览器并点击刷新。

触发 HTTP 请求会导致 Quarkus 检查自上次请求以来是否修改了任何文件,因为我们正在使用实时重载。Quarkus 注意到了 WelcomeResource 的存在,对其进行编译,然后重新启动服务器。如果你和我们一样,你可能没有意识到在幕后发生的一切,因为浏览器几乎立即给出了预期的响应。

你已经筋疲力尽了吗?我们是。

这是第一次使用 https://code.quarkus.io 创建 Quarkus 项目,并体验到与 Quarkus 实时重载一起带来的开发便利。它确实有缺点,包括编译和重启期间较少喝咖啡的机会。随着我们逐步深入章节,我们将继续探索实时重载的一切可能性,但是你也可以自己尝试,添加新扩展并查看在不停止服务的情况下可以完成什么!

用 Quarkus 在 10 分钟内部署 Kubernetes

在前一节中,我们乐在其中改变代码并看到应用程序实时更新。我们希望你玩得开心;我们知道我们玩得很开心!

尽管这对于开发代码非常棒,但我们可以在生产中使用实时编码吗?嗯,也许你可以,但我们真的不认为你想那样做!

对于部署到生产环境,我们想使用不可变容器,这需要容器编排,对于大多数人来说意味着 Kubernetes。“新贵:云原生和 Kubernetes 原生应用”详细介绍了云原生和 Kubernetes 应用。

为什么 Quarkus 非常适合云端,尤其是 Kubernetes?Quarkus 应用程序设计成在容器中高效运行,并具备内置的健康检查和监控能力。此外,Quarkus 还提供出色的用户体验,包括能够仅需一条命令在 Kubernetes 集群中部署,而无需编写 Kubernetes 资源描述符。

Kubernetes 引入了自己特定的术语,可能会令人困惑。本节介绍其主要概念。

如何将前一节的 Quarkus 应用程序部署到 Kubernetes?让我们扩展在前一节生成的应用程序。我们首先要做的事情是向我们的应用程序添加 Kubernetes 扩展,如示例 2-17 所示。

示例 2-17. Kubernetes 扩展依赖 (chapter-2/code-with-quarkus/pom.xml)
<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-kubernetes</artifactId>
</dependency>

有了这个新的依赖,构建过程可以生成必要的资源文件,用于将应用程序部署到 Kubernetes,并使我们能够部署应用程序。真是省时!让我们看看它是如何工作的!

在我们看它如何工作之前,我们需要选择首选的容器化机制。使用 Quarkus,我们可以选择 Docker、Jib和 Source-to-Image(S2I)之间的任一种。我们将选择 Jib,因为所有依赖项都缓存在与应用程序分开的层中,使得后续的容器构建速度更快。让我们按照示例 2-18 中所示添加 Jib 容器依赖。

示例 2-18. Jib 容器扩展依赖 (chapter-2/code-with-quarkus/pom.xml)
<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-container-image-jib</artifactId>
</dependency>

我们快要完成了!但首先,我们需要一个 Kubernetes 集群!最简单的方法是使用 minikube,但你也可以使用 Docker Desktop,或者MicroK8s。在本书中,我们将使用 minikube,因为它是最直接的解决方案之一。minikube 并不是完整的 Kubernetes 集群,但提供了足够的功能供我们使用。

按照minikube 文档中的说明下载和安装 minikube。安装好 minikube 后,启动它(见示例 2-19)。

示例 2-19. 启动 minikube
> minikube start

除非我们有特定的配置选项设置,否则将使用 minikube 的默认配置。目前,默认配置是一个虚拟机使用两个 CPU 和 4GB RAM。如果这是第一次运行 minikube,minikube 将会下载必要的镜像,这会有短暂的延迟。

Quarkus 为 minikube 提供了一个额外的扩展,专门用于定制 minikube 的 Kubernetes 资源。这种方法的一个重大优势是不需要 Kubernetes Ingress 来访问 Kubernetes 内部的服务;相反,我们可以通过 NodePort 服务来访问它们,从而在运行 minikube services list 时可以看到本地主机可访问的 URL。为了激活本地主机可访问的 URL,我们需要另一个依赖项(见示例 2-20)。

示例 2-20. Minikube 扩展依赖 (chapter-2/code-with-quarkus/pom.xml)
<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-minikube</artifactId>
</dependency>

在部署我们的应用程序之前,让我们稍微玩一下 Kubernetes,以了解一些概念。你可以使用kubectl命令与 Kubernetes 集群交互;参见示例 2-21。

示例 2-21. 检索节点
> kubectl get nodes
NAME       STATUS   ROLES    AGE     VERSION
minikube   Ready    master   2m45s   v1.18.3

此命令打印由 Kubernetes 管理的 节点。你看到我们这里只有一个节点,名为master,不应感到惊讶。那就是你的机器,或者是你的操作系统的虚拟机,具体取决于你的操作系统。

与 Docker 等其他系统不同,Kubernetes 不直接运行容器。相反,它将一个或多个容器封装到称为 pod 的更高级结构中。Pod 用作复制的单位。如果你的应用程序收到了太多的请求,单个 pod 实例无法承载负载,你可以要求 Kubernetes 实例化新的副本。即使在不受重负载的情况下,拥有一个 pod 的多个副本也是个好主意,以允许负载平衡和容错。你可以使用kubectl get pods获取 pod 的列表(参见示例 2-22)。

示例 2-22. 使用kubectl命令列出运行中的 pods
> kubectl get pods
No resources found in default namespace.

不出意外,我们的集群是空的。

在“Java on the Cloud”中,我们谈到了希望减少容器中使用 Java 编写的服务的内存量。为了能够在 minikube 中确定这一点,我们需要在部署我们的服务之前安装一个插件(参见示例 2-23)。

示例 2-23. 向 minikube 集群添加度量服务器
> minikube addons enable metrics-server

要创建 pods,我们需要一个 deployment。部署有两个主要目的:

  • 指示哪些容器需要在 pod 中运行

  • 指示应该同时运行的 pod 实例数

通常,要创建一个部署,你需要以下内容:

  • 一个可供你的 Kubernetes 集群访问的容器镜像

  • 一个描述你的部署的 YAML 文档⁵

Quarkus 提供了一些工具来避免手动创建镜像和编写部署,比如我们之前提到的 Kubernetes、minikube 和 Jib 容器扩展。

所有组件就位后,现在是我们构建并部署应用程序到 Kubernetes 中的 minikube 的时候了!打开一个终端窗口并切换到项目目录。因为我们不想运行自己的 Docker 守护程序来构建容器,我们可以运行eval $(minikube -p minikube docker-env)来将 Docker 守护程序从 minikube 暴露到本地终端环境。

提示

eval $(minikube -p minikube docker-env)必须在我们用来访问 minikube 的每个终端窗口中运行。如果没有,我们执行的任何 Docker 命令都将使用本地 Docker 守护程序,这是我们不想要的。

接下来,我们构建并部署容器(如示例 2-24 所示)。

示例 2-24. 将 Quarkus 应用程序部署到 Kubernetes
> mvn verify -Dquarkus.kubernetes.deploy=true ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/1.png)

1

打包应用程序,创建容器镜像,创建部署描述符,并将其部署到我们的集群。

执行kubeclt get pods命令进行验证(参见示例 2-25)。

示例 2-25. 使用kubectl列出正在运行的 Pod。
> kubectl get pods
code-with-quarkus-66769bd48f-l65ff   1/1     Running   0          88s

是的!我们的应用程序正在运行!

Quarkus 为我们创建了一个部署,如示例 2-26 所示。

示例 2-26. 列出已安装的部署
> kubectl get deployments
NAME               READY   UP-TO-DATE   AVAILABLE   AGE
code-with-quarkus  1/1     1            1           6m23s

您可以检查target/kubernetes/minikube.yml中创建的部署,或者查看示例 2-27。

示例 2-27. 生成的部署
apiVersion: apps/v1
kind: Deployment
metadata:
  # ...
  name: code-with-quarkus
spec:
  replicas: 1 ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/1.png)
  #...
  template:
    metadata:
      # ...
    spec:
      containers:
        image: your_name/code-with-quarkus:1.0.0-SNAPSHOT ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/2.png)
        imagePullPolicy: IfNotPresent
        name: code-with-quarkus
        ports:
        - containerPort: 8080
          name: http
          protocol: TCP
        # ...

1

副本数量

2

容器镜像名称

正如您所见,部署的 YAML 文件指示了副本的数量以及在 Pod 中运行的容器集(这里是一个)。

如果您仔细查看生成的描述符,您会看到service

apiVersion: v1
kind: Service
metadata:
  # ...
  name: code-with-quarkus
spec:
  ports:
  - name: http
    nodePort: 31995       ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/1.png)
    port: 8080
    targetPort: 8080
  selector:
    app.kubernetes.io/name: code-with-quarkus
    app.kubernetes.io/version: 1.0.0-SNAPSHOT
  type: NodePort

1

随机的本地端口号,我们可以访问服务。

服务是一种通信渠道,委托给一组 Pod(使用标签选择)。在我们的示例中,服务被命名为code-with-quarkus。其他应用程序可以使用这个名称来发现我们公开的功能。该服务将端口 8080 委托给具有匹配标签(app.kubernetes.io/nameapp.kubernetes.io/version)的 Pod。好消息是我们的 Pod 配置了这些标签。因此,调用此code-with-quarkus在容器的 8080 端口上委托给我们的 8080 端口。

注意

Quarkus 生成了多个描述符。minikube.yml描述符专为 minikube 定制。kubernetes.yml描述符更通用。主要区别在于创建的服务类型。

那么,让我们调用我们的服务!我们需要请求 minikube 提供服务的 URL,如示例 2-28 所示。

示例 2-28. 检索服务 URL
> minikube service code-with-quarkus --url

🏃  Starting tunnel for service code-with-quarkus.
|-----------|-------------------|-------------|------------------------|
| NAMESPACE |      NAME         | TARGET PORT |          URL           |
|-----------|-------------------|-------------|------------------------|
| default   | code-with-quarkus |             | http://127.0.0.1:31995 |
|-----------|-------------------|-------------|------------------------|
http://127.0.0.1:31995
❗  Because you are using a Docker driver on darwin, the terminal needs
  to be open to run it.

打开浏览器并使用服务的 URL 访问,或者如果您喜欢,使用curl。如果部署成功,我们在根路径上会看到Hi作为响应。添加/welcome可以看到Welcome to Quarkus!我们已经将一个 Quarkus 服务部署到了 Kubernetes!

我们已经验证了我们的 Quarkus 服务部署并正常工作,但内存情况如何呢?让我们在示例 2-29 中检查一下。

示例 2-29. 使用kubectl top来测量资源使用情况。
> kubectl top pods
NAME                                 CPU(cores)   MEMORY(bytes)
code-with-quarkus-66769bd48f-l65ff   1m           80Mi

哇,只有 80 MB,确实非常好而且紧凑!这比容器中传统框架有了很大的改进。

刚才我们看到如何将一个 Quarkus 应用程序添加到 Kubernetes 或者在这种情况下,minikube 的部署能力。在定义部署所需的 Kubernetes 资源时,肯定会遇到一些潜在问题,但这就是为什么我们使用 Quarkus 的 Kubernetes 扩展来处理这一切。我们宁愿不手动编写 YAML 或 JSON,无意中在缩进上犯错,然后看到部署失败!

走本地

“走本地”是什么意思?我们指的是能够为特定环境构建本地可执行文件的能力。我们每天在计算机上使用的许多应用程序都是本地可执行文件,这意味着应用程序的代码已经编译成针对特定操作系统(在我们的例子中是 macOS)的低级指令。

开发 Java 应用程序一直需要 JVM 执行。然而,最近通过 GraalVM 项目的发布,使得从 Java 代码构建本地可执行文件成为可能。在本节中,我们解释了如何利用 GraalVM 项目与 Quarkus 一起为您的 Java 代码生成本地可执行文件!

在 “Quarkus 的方式” 中,我们讨论了 Quarkus 如何利用 AOT 编译在构建时执行操作,而不是在应用程序启动时执行。

Quarkus 扩展通过将所有工作分为三个独立阶段来实现这一点:

增强

构建步骤处理描述符和注解,并通过生成包含任何必需元数据的字节码来增强应用程序类。这个阶段总是在 JVM 的构建过程中执行。

静态初始化

运行任何旨在捕获其结果输出的步骤到字节码中。这些步骤有一些限制,不应打开监听端口或启动线程。

运行时初始化

这些步骤作为应用程序主方法的一部分在启动时运行。任务应尽可能保持最小化,以充分利用 AOT 的优势。

静态和运行时初始化都发生在 JVM 上的启动过程中。然而,使用本地可执行文件,我们有额外的好处。通过将初始化分为两个阶段,我们能够在本地可执行文件构建过程中执行静态初始化。这允许静态初始化阶段的输出直接序列化到本地可执行文件中,从而允许在本阶段使用的任何类在之后不再需要时从本地可执行文件中删除。这在本地可执行文件的启动时间和内存需求减少方面带来了好处。

作为使用 GraalVM 构建本机可执行文件过程的一部分,将评估所有执行路径。任何不在执行路径上的类、方法或字段都将从生成的本机可执行文件中删除。这就是为什么在不使用特殊标志的情况下禁止反射、动态类加载和 JVM 使用的其他特性,因为目标是在本机可执行文件中保留每一行代码。如果我们尝试为早期的 Thorntail 示例构建本机可执行文件,将需要设置标志以允许反射、动态类加载和可能的其他内容。Thorntail 的设计不适合构建本机可执行文件,而 Quarkus 从一开始就考虑了代码精简的目标。

现在让我们看看实际构建本机可执行文件所需的内容。用https://code.quarkus.io创建项目意味着一个 Maven 配置文件已经为我们的项目添加。Example 2-30 展示了它的样子。

示例 2-30. 本机镜像生成 Maven 配置文件(chapter-2/code-with-quarkus/pom.xml
<profile>
  <id>native</id>
  <activation>
    <property>
      <name>native</name>
    </property>
  </activation>
  <properties>
    <quarkus.package.type>native</quarkus.package.type>
  </properties>
</profile>

现在我们可以为 Quarkus 构建一个本机可执行文件,但如果没有安装 GraalVM,我们就无法走得很远!查看“构建本机可执行文件”指南了解有关安装 GraalVM 以构建本机可执行文件的详细信息。

安装了 GraalVM 后,让我们来构建一个本机可执行文件;查看 Example 2-31。

示例 2-31. 编译 Quarkus 应用程序为本机可执行文件
> mvn verify -Pnative

不幸的是,构建本机可执行文件比通常的 JVM 构建时间更长。因此,我们建议不要定期构建本机可执行文件,并建议将这些构建作为 CI 流水线的一部分进行。

运行这些构建是我们新的喝咖啡休息的机会!

注意

随着应用程序中类的数量增加,本机可执行文件的构建时间也变长。这是因为需要评估更多的执行路径。

构建了本机可执行文件后,我们可以用./target/code-with-quarkus-1.0.0-SNAPSHOT-runner来运行它。享受它启动的速度,并确保我们创建的两个端点仍然可用。

我们已经为本地环境构建了一个本机可执行文件,但除非我们使用的是 Linux 操作系统,否则我们的本机可执行文件无法在容器内运行!由于本机可执行文件特定于操作系统,我们需要专门为 Linux 容器构建一个。

要为容器构建本机可执行文件,我们需要利用Docker。安装 Docker 后,请确保它已启动。由于当前终端已切换为使用 minikube 内的 Docker 守护程序,因此我们需要打开一个新终端,以便我们可以使用本地 Docker 进行构建。导航到项目目录并运行 Example 2-32。

Example 2-32. 将 Quarkus 应用编译为 Linux 64 位本地可执行文件
> mvn verify -Pnative -Dquarkus.native.container-build=true

我们所做的是利用我们的本地 Docker 环境为 Linux 操作系统构建一个本地可执行文件。如果我们尝试运行本地可执行文件,而我们的本地操作系统不是 Linux,我们会看到一个错误(Example 2-33)。

Example 2-33. 启动未为主机操作系统编译的应用程序时出现格式错误
zsh: exec format error: ./target/code-with-quarkus-1.0.0-SNAPSHOT-runner

现在我们需要回到之前的终端,因为我们想要与 minikube 中的 Docker 守护程序交互。让我们在 minikube 中运行一个 Docker 构建,就像 Example 2-34 中所示的那样。

Example 2-34. 构建一个运行 Quarkus 应用的本地可执行容器
> docker build -f src/main/docker/Dockerfile.native \
    -t <_your_docker_username_>/code-with-quarkus:1.0.0-SNAPSHOT .
提示

不要忘记用你的本地 Docker 用户名替换*<your_docker_username>*

现在我们在 minikube 内有一个可用的容器了,让我们创建应用程序部署;参见 Example 2-35。

Example 2-35. 将 Quarkus 应用部署到 minikube
> kubectl apply -f target/kubernetes/minikube.yml

我们使用了之前构建的 minikube 特定的 Kubernetes YAML 文件来创建部署。这个版本创建了我们从本地环境访问服务所需的NodePort服务,同时将容器的imagePullPolicy修改为IfNotPresent而不是Always。这个最后的改变防止 minikube 尝试从 Docker Hub 检查更新的容器镜像,这很好因为那里找不到更新的镜像!

部署完成后,从minikube service list中获取 URL 并再次测试端点。一切都应该很好,我们会得到与之前相同的消息。

现在来看有趣的部分!之前我们将指标服务器安装到 minikube 中以跟踪内存使用情况,现在是时候看看我们的本地可执行文件是什么样子了。虽然我们已经发出了请求并部署存在,但要等几分钟才能获取到指标数据。持续尝试直到它们出现。你应该看到类似于 Example 2-36 的内容。

Example 2-36. 在 Kubernetes 中测量资源使用情况
> kubectl top pods
NAME                                CPU(cores)   MEMORY(bytes)
code-with-quarkus-fd76c594b-48b98   0m           7Mi

太棒了!只用了 7 MB 的 RAM!

这就是 Quarkus 和本地可执行文件结合的亮点所在。我们还可以查看 pod 的日志,看看容器启动的速度;我们预期应该在 10 到 20 毫秒之间。

注意

我们尝试为 Thorntail 构建一个本地镜像以进行比较。然而,我们在构建可用的本地镜像时遇到了问题,并且被UnsatisfiedLinkError阻止。

摘要

我们在短时间内涵盖了大量关于 Quarkus 的内容,但在本书的剩余部分仍有很多内容需要探讨。Quarkus 是一个针对 Kubernetes 的本地化 Java 栈,专注于通过 AOT 最小化内存需求,并在需要本地可执行文件时进一步增加内存减少。通过 Kubernetes 和容器扩展,Quarkus 消除了手工编写 YAML 部署文件的麻烦,为我们完成了所有工作!

在本章中,您学习了以下内容:

  • 理解在容器中使用 Java 遇到的问题

  • 理解 Quarkus 如何通过 AOT 将运行时启动任务移动到构建时与传统框架不同

  • 使用 https://code.quarkus.io 创建 Quarkus 项目

  • 使用 Kubernetes 和 minikube 扩展生成所需的部署配置

  • 使用 GraalVM 为 Quarkus 构建本地可执行文件

  • 使用 Kubernetes 扩展部署 Quarkus 应用到容器环境中

在接下来的章节中,我们将简要回顾详细分布式系统、响应式系统、响应式编程及其相互关系。

¹ 本书中,容器 指的是操作系统虚拟化形式,而不是 Java EE 容器。

² 您可以在 GitHub 上找到 用于测量启动时间的脚本

³ 查看 Quarkus 网站上特定平台的内存报告

⁴ Quarkus 提供多种实现 HTTP 端点的方式。JAX-RS 是其中之一。您还可以使用带有 Spring MVC 注解的控制器类或者如果更喜欢程序化方法,可以使用响应式路由。

⁵ YAML(Yet Another Markup Language)是描述 Kubernetes 资源最常用的格式。Wikipedia 提供了一个简明介绍

⁶ GraalVM 并非第一个用于从 Java 代码构建本地可执行文件的工具。Dalvik、Avian、GNU Compiler for Java (GCJ) 和 Excelsior JET 在 GraalVM 之前就存在了。

第二部分:响应式和事件驱动应用

第三章:分布式系统的黑暗面

现在您对反应式有了更好的理解,并简要了解了 Quarkus,让我们专注于为什么您想要使用它们,更具体地构建反应式系统。理由源于云,更一般地说,需要构建更好的分布式系统。云已经改变了游戏规则。它使得构建分布式系统变得更容易。您可以动态创建虚拟资源并使用现成的服务。然而,“更容易”并不意味着“直截了当”。构建这样的系统是一个巨大的挑战。为什么?因为云是一个分布式系统,而分布式系统是复杂的。我们需要了解我们试图驯服的动物的种类。

什么是分布式系统?

有许多关于分布式系统的定义。但让我们从一位名誉教授安德鲁·塔能鲍姆的宽泛定义开始,看看我们能学到什么:

分布式系统是一组独立的计算机,对其用户呈现为一个单一的一致系统。

此定义突出了分布式系统的两个重要方面:

  • 分布式系统由独立的机器组成,这些机器是自主的。它们可以随时启动和停止。这些机器并行操作,并且可以独立失败而不影响整个系统的正常运行时间(至少在理论上是这样)。

  • 消费者(用户)不应该意识到系统的结构。它应提供一致的体验。通常情况下,您可以使用由 API 网关提供的 HTTP 服务(Figure 3-1),将请求委托给各种机器。对于您作为调用者而言,分布式系统表现为一个单一的一致系统:您只有一个入口点,忽略系统的底层结构。

一个 HTTP 服务将调用委托给其他机器/服务的示例

图 3-1. 一个 HTTP 服务将调用委托给其他机器/服务的示例

要实现这种一致性水平,这些自主机器必须以某种方式进行协作。这种协作以及由此产生的良好通信需求是分布式系统的核心,但也是它们的主要挑战。但是这个定义并没有解释为什么我们要构建分布式系统。最初,分布式系统是一种变通方法。每台机器的资源太有限了。连接多台机器是扩展整个系统容量的一种聪明方式,使资源对网络中的其他成员可用。如今,动机略有不同。使用一组分布式机器使我们在业务灵活性上更有优势,促进了演进,缩短了上市时间,并且从运营角度来看,允许我们更快速地扩展,通过复制提高了弹性,等等。

分布式系统已经从一种权宜之计演变为常态。为什么?我们无法构建一个足够强大且 同时 又负担得起的单一机器来处理一个大型公司的所有需求。如果可以的话,我们都会使用这个巨型机器并在其上部署独立的应用程序。但这种分布的必要性根据物理系统边界绘制了新的操作和业务边界。微服务、无服务器架构、面向服务的架构(SOA)、REST 端点、移动应用程序,所有这些都是分布式系统。

这种分布更加强调了系统中所有组件之间合作的需求。当一个应用程序(例如,用 Java 实现)需要本地交互时,它只需使用方法调用。例如,要与暴露 hello 方法的 service 合作,你使用 service.hello。我们保持在同一进程中。调用可以是同步的;不涉及网络 I/O。

然而,分布式系统分散的特性意味着进程间通信,大部分时间都是通过网络进行跨越(图 3-2)。处理 I/O 并穿越网络使得这些交互显著不同。许多中间件试图使分布透明化,但不要误解,完全透明是一个谎言,正如 Jim Waldo 等人在 “关于分布式计算的一则说明” 中所解释的那样,它总会以某种方式失败。你需要理解远程通信的独特性质,并认识到它们在构建健壮分布式系统中有多么独特。

远程交互通过网络连接离开一个进程空间,进入另一个进程空间。

图 3-2. 远程交互通过网络连接离开一个进程空间,进入另一个进程空间

第一个区别在于持续时间。远程调用比本地调用需要花费更多的时间。这段时间高出几个数量级。当一切正常时,从纽约市到洛杉矶的请求发送大约需要 72 毫秒。¹ 而调用本地方法则少于一纳秒。

远程调用同样会离开进程空间,因此我们需要一个交换协议。该协议定义了交换的所有方面,例如谁发起通信,信息如何写入到网络中(序列化和反序列化),消息如何路由到目标等等。

在开发应用程序时,大多数这些选择对你是隐藏的,但在幕后是存在的。我们来看一个你想调用的 REST 端点。你将使用 HTTP 并且很可能使用 JSON 表示来发送数据和解释响应。你的代码相对简单,就像在 示例 3-1 中所看到的。

示例 3-1. 使用 Java 内置客户端调用 HTTP 服务(chapter-3/http-client-example/src/main/java/http/Main.java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://httpbin.org/anything"))
        .build();

HttpResponse<String> response = client.send(request,
        HttpResponse.BodyHandlers.ofString());

System.out.println(response.body());

让我们描述一下您执行它时发生的情况:

  1. 您的应用程序创建 HTTP 请求(request)。

  2. 它与远程服务器建立 HTTP 连接。

  3. 它按照协议编写 HTTP 请求。

  4. 请求发送到服务器。

  5. 服务器解释请求并查找资源。

  6. 服务器使用 JSON 表示创建 HTTP 响应资源状态。

  7. 它按照协议编写响应。

  8. 应用程序接收响应并提取正文(在本例中为String)。

中间件(HTTP 服务器和客户端,JSON 映射器等)的角色是使我们开发者可以轻松进行这些交互。在我们之前的例子中,步骤 2 到 8 都隐藏在send方法中。但我们需要意识到这些。特别是在今天,随着云计算、分布式系统和分布式通信无处不在,构建非分布式系统的应用程序变得越来越少。一旦您调用远程 Web 服务、打印文档或使用在线协作工具,您就在创建分布式系统。

云原生和 Kubernetes 原生应用的新秀

云的作用不可言喻,它是推广分布式系统的一个重要因素。如果您需要新的机器、数据库、API 网关或持久存储,云服务可以实现这些按需计算服务的交付。但请记住,尽管云服务提高了效率,您绝不能忘记在云上运行您的应用程序等同于在别人的机器上运行。无论何处都有用于执行您的应用程序的 CPU、磁盘和内存,尽管云服务提供商负责维护这些系统并以可靠性著称,但硬件设备不在您的控制之下。

云服务提供商提供了出色的基础设施设施,使得运行应用程序变得更加简单。由于动态资源,您可以创建许多应用程序实例,甚至根据当前负载自动调整这个数量。它还提供了故障转移机制,例如在另一个实例崩溃时将请求路由到健康实例。云服务有助于通过使您的服务始终可用、重新启动系统中不健康的部分等方式达到高可用性。这是通向弹性和具有韧性系统的第一步。

话虽如此,你的应用程序能在云中运行并不意味着它会从中受益。你需要定制你的应用程序以高效利用云,并且云的分布式特性是其中很大的一部分。云原生是一种构建和运行利用云计算交付模型的应用程序的方法。云原生应用程序应该易于部署在虚拟资源上,通过应用程序实例支持弹性,依赖位置透明性,强制执行容错性等。十二因素应用程序列出了一些成为良好的云公民的特征:

代码库

一个代码库在版本控制中跟踪,多个部署。

依赖关系

明确声明和隔离依赖关系。

配置

将配置存储在环境中。

支持服务

将支持服务视为附加资源。

构建、发布、运行

严格区分构建和运行阶段。

进程

将应用程序作为一个或多个无状态进程执行。

端口绑定

通过端口绑定导出服务。

并发性

通过进程模型扩展。

可处置性

通过快速启动和优雅关闭来最大化鲁棒性。

开发/生产对等性

使开发、分级和生产尽可能相似。

日志

将您的日志视为事件流。

管理进程

将管理/管理任务作为一次性进程运行。

实现这些因素有助于拥抱云原生理念。但实现云原生并不是一件容易的事情。每个因素都伴随着技术挑战和架构约束。

此外,每个云提供商都提供其自己的一套设施和 API。这种异构性使得云原生应用程序无法从一个云提供商迁移到另一个云提供商。很快,由于特定的 API、服务、工具或甚至描述格式,您最终会陷入某种供应商锁定,这可能不是您目前面临的问题,但是拥有移动和结合多个云的可能性可以提高您的敏捷性、可用性和用户体验。混合云应用程序,例如,在多个云上运行,混合私有云和公共云,以减少响应时间并防止全球不可用。

幸运的是,公共云和私有云都倾向于围绕 Kubernetes 收敛,这是一个容器编排平台。Kubernetes 使用标准部署和运行时设施来抽象提供商之间的差异。

要使用 Kubernetes,您需要将应用程序打包并在容器中运行。容器是一个盒子,您的应用程序将在其中运行。因此,您的应用程序在某种程度上与在其自己的盒子中运行的其他应用程序隔离开来。

要创建容器,您需要一个镜像。容器镜像是一个轻量级的、可执行的软件包。当您部署一个容器时,您实际上部署了一个镜像,并且此镜像被实例化以创建容器。

图像包含运行应用所需的一切:代码、运行时、系统库和配置。您可以使用诸如Dockerfile之类的工具和描述符来创建容器镜像。正如您在第二章中看到的,Quarkus 提供了无需编写一行代码即可创建镜像的功能。

要分发您的镜像,您将其推送到像Docker Hub这样的镜像注册表。然后,您可以拉取它并最终实例化它以启动您的应用程序(图 3-3)。

容器的创建、分发和执行

图 3-3. 容器的创建、分发和执行

虽然容器化是一种众所周知的技术,但当您开始拥有数十个容器时,它们的管理变得复杂。Kubernetes 提供了设施来减少这种负担。它实例化容器并监控它们,确保您的应用仍在运行。² 可以想象,这对于实现反应式系统的响应性和弹性特征是非常有用的。

尽管 Kubernetes 通过响应性和弹性促进了反应式系统,这并不意味着您不能在 Kubernetes 之外实现反应式系统。这是完全可能的。在本书中,我们使用 Kubernetes 来避免实现部署、复制和故障检测等基础设施特性。

Kubernetes 在底层拉取容器镜像、实例化容器并监控它们。为了实现这一点,Kubernetes 需要访问节点以运行这些容器。这些节点的集合形成一个集群。将机器视为节点允许我们插入一层抽象。这些机器可以是亚马逊弹性计算云(EC2)实例、数据中心的物理硬件或虚拟化都不重要。Kubernetes 控制这些节点,并决定系统的哪个部分在哪里运行。

一旦 Kubernetes 可以访问您的容器镜像,您可以指示 Kubernetes 实例化镜像,使其成为一个运行中的容器。Kubernetes 决定在哪个节点上执行容器。它甚至可能稍后将其移动以优化资源利用率,这与反应式架构的特性相契合。

就像应用程序需要云原生才能从云中受益一样,它们需要成为 Kubernetes 原生才能从 Kubernetes 中受益。这包括支持 Kubernetes 服务发现、暴露用于监控的健康检查,更重要的是,在容器中高效运行。您将在下一章中看到,这三个特征对于反应式视角来说是至关重要的。几乎可以将任何应用程序封装在容器中。但这可能不是一个好主意。

在容器中运行时,你的应用程序存在于一个共享环境中。多个容器共享来自执行它们的 主机 的资源。它们共享 CPU、内存等。如果一个容器过于贪婪,会惩罚其他容器,可能会饿死。当然,你可以使用配额,但在资源限制下贪婪的容器会如何行事?因此,是的,容器提供隔离,并且 允许 资源共享。

容器和 Kubernetes 的一个作用是增加部署密度:利用有限的可用资源运行更多应用程序。由于经济效益,部署密度对许多组织变得至关重要。它可以通过减少每月的云账单或在当前内部基础设施上运行更多应用程序来降低成本。

表 3-1 总结了迄今为止关于容器和 Kubernetes 的概念。

表 3-1. 关于容器和 Kubernetes 的重要概念

名称 描述 关联命令
容器镜像 轻量级、可执行的软件包 docker build -f my-docker-file -t my-image:version
容器 在其中运行你的应用程序的盒子 docker run my-image:version
Pod Kubernetes 中复制的单位,由一个或多个容器组成 kubectl get pods
部署 描述 pod 内容及我们需要的 pod 实例数 kubectl get deployments
服务 一个通信通道,委托给一组通过标签选择的 pod kubectl get services

如果你错过了它,请查看“在 10 分钟内使用 Quarkus 部署 Kubernetes”,我们在其中将一个 Quarkus 服务部署到 Kubernetes!

分布式系统的暗面

我们的系统很简单,但即使是这样一个基本系统也能说明分布式系统的艰辛现实。云提供商和 Kubernetes 提供了优秀的基础设施设施,但分布式系统的法则仍然统治着你正在构建的系统。围绕配置和交付的技术复杂性已被分布式系统的本质问题所取代。现代应用程序的规模和复杂性使它们不可否认。

在本章的开头,你看到了对分布式系统的第一个定义。它捕捉到了提供一致体验的协作和通信的需求。计算机科学家和图灵奖获得者莱斯利·兰波特给出了另一个定义,描述了分布式系统的黑暗本质:“分布式系统是一种其中你甚至不知道存在的计算机的故障可以使你自己的计算机无法使用。”

换句话说,故障是不可避免的。它们是分布式系统的固有组成部分。无论你的系统如何构建,它都会失败。作为推论,分布式系统越大,动态性(周围服务的波动可用性)越高,失败的机会就越大。

我们可能会遇到哪些故障?有三种类型:

临时故障

发生一次,然后消失,就像临时网络中断

间歇性故障

发生后消失,然后重新出现,就像偶尔会出现的故障,原因不明

永久故障

直到故障组件(无论是软件还是硬件)被修复之前一直存在。

每种故障类型都可能有两种后果。首先,它可能会使应用程序崩溃。我们称这些为停止故障。当然,它们是不好的,但我们可以轻松检测并修复系统。其次,故障可能会在随机时间引入不可预测的响应。我们称之为拜占庭故障。检测和规避它们要困难得多。

Kubernetes 世界中的分布式计算谬论

作为开发者,想象和规划所有类型的故障及其后果可能是一项挑战。你如何检测它们?你如何优雅地处理它们?如果任何东西都可能出问题,你如何继续提供一致的体验和服务?构建和维护分布式系统是一个充满陷阱和雷区的复杂主题。由 L. Peter Deutsch 与 Sun Microsystems 的其他人员共同创作的"分布式计算的八大谬论"列出了分布式系统中的许多错误假设:

  1. 网络是可靠的。

  2. 延迟为零。

  3. 带宽是无限的。

  4. 网络是安全的。

  5. 拓扑结构不会改变。

  6. 只有一个管理员。

  7. 传输成本为零。

  8. 网络是同质的。

这些谬误是 1997 年发表的,早于云和 Kubernetes 时代。但这些谬误今天仍然相关——甚至更加相关。我们不会讨论所有谬误,而是专注于与云和 Kubernetes 相关的内容:

网络是可靠的

开发者通常假设云或 Kubernetes 上的网络是可靠的。确实,基础设施的角色是处理网络并确保事物正常运行。健康检查、心跳、复制、自动重启——基础设施层面有很多机制。网络会尽力而为,但有时候,坏事情发生了,你需要为此做好准备。数据中心可能会崩溃;系统的部分可能会变得无法访问,等等。³

延迟为零

第二个谬误似乎很明显:网络调用比本地调用慢,并且每次调用的延迟可能会显著变化,我们已经讨论过了。延迟不仅仅限于这个方面;它会因多种原因随时间而变化。

带宽是无限的并且网络是同质化的

你可能会达到带宽限制,或者系统的某些部分可能使用比其他部分更快的网络,因为它们运行在同一个节点上。估算延迟并不是一件简单的事情。许多容量规划技术和超时计算都基于网络延迟。

拓扑结构不会改变

在云上或 Kubernetes 上,服务、应用程序和容器可以移动。Kubernetes 可以随时将容器从一个节点移动到另一个节点。由于部署新应用程序、更新、重新调度、优化等原因,容器经常移动。移动性是一个巨大的好处,因为它允许优化整个系统,但是与总是移动的服务进行交互可能是具有挑战性的。你可能会与多个实例的服务进行交互,对你来说,它们就像一个服务。某些实例可能离你更近(响应时间更好),而另一些可能因为资源有限而较慢。

有一个管理员

过去几年中,管理系统发生了巨大变化。老派的系统管理流程和维护停机时间变得不那么常见。DevOps 的理念和技术,比如持续交付和持续部署,正在重新定义我们在生产中管理应用程序的方式。开发人员可以在一天中轻松部署小的增量变更。DevOps 工具和站点可靠性工程师(SRE)努力提供几乎恒定的可用性,而持续的更新提供新功能和错误修复。管理角色由 SRE、软件工程师和软件共享。例如,Kubernetes operators 是部署在 Kubernetes 上的程序,负责自动安装、更新、监控和修复系统的各个部分。

运输成本为零

认为网络是免费的不仅是错误的,而且是一种经济错误。你必须注意网络调用的成本并寻找优化方法。例如,跨越云区域、传输大量数据或(特别是)与不同的云提供商通信可能很昂贵。

因此,不是那么简单,对吧?当你构建分布式系统时,要考虑所有这些问题,并在你的架构和应用代码中加以考虑。这些只是其中的一些问题。另一个问题是无法达成共识。

一致性

每次读操作都会收到最近的写操作。

可用性

每个请求都会收到响应。

分区容忍性

系统可以继续运行,即使网络丢失(或延迟)了任意数量的消息。

事情会变得更糟吗?哦是的,分布式系统可以非常有创造力地让我们发狂。

时间问题:同步通信的缺点

时间经常是一个被误解的问题。当两台计算机进行通信并交换消息时,我们自然会假设这两台机器都是可用和可访问的。我们经常信任它们之间的网络。为什么它不能完全正常运行呢?为什么我们不能像调用本地服务一样调用远程服务呢?

但情况可能并非如此,不考虑这种可能性会导致脆弱性。如果你要交互的机器无法访问会发生什么?你准备好处理这种故障了吗?是否应该传播这个失败?重试吗?

在一个假设的基于微服务的示例中,通常使用同步 HTTP 作为服务之间的主要通信协议。你发送一个请求,并期望从调用的服务获取响应。你的代码是同步的,等待响应后再继续执行。同步调用更容易理解。你按顺序结构化你的代码,一件事做完再做下一件事,依此类推。这导致了时间耦合,这是一种较少考虑和常被误解的耦合形式。让我们来说明一下这种耦合及由此引发的不确定性。

在 GitHub 仓库的chapter-3/quarkus-simple-service 目录中,你会找到一个简单的 Hello World Quarkus 应用程序。这个应用程序类似于第二章中构建的应用程序。它包含一个单一的 HTTP 端点,如示例 3-2 所示。

示例 3-2. JAX-RS 简单服务(chapter-3/quarkus-simple-service/src/main/java/org/acme/reactive/SimpleService.java
package org.acme.reactive;

import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

@Path("/")
@Produces(MediaType.TEXT_PLAIN)
public class SimpleService {

    @GET
    public String hello() {
        return "hello";
    }
}

没有比这更简单的代码了吧?让我们将这个应用程序部署到 Kubernetes 上。确保 minikube 已启动。如果没有,请按照示例 3-3 中的步骤启动它。

示例 3-3. 启动 minikube
> minikube start
...
> eval $(minikube docker-env) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/1.png)

1

别忘了将 Docker 套接字连接到 minikube。

通过运行kubectl get nodes命令(见示例 3-4)来验证一切是否正常。

示例 3-4. 获取节点名称和角色
> kubectl get nodes
NAME       STATUS   ROLES                  AGE   VERSION
minikube   Ready    control-plane,master   30s   v1.20.2

现在,在chapter-3/simple-service目录中导航并运行示例 3-5。

示例 3-5. 将一个 Quarkus 应用部署到 Kubernetes 上
> mvn verify -Dquarkus.kubernetes.deploy=true

等待 pod 处于就绪状态,如示例 3-6 中所示。

示例 3-6. 获取运行中的 pod 列表
> kubectl get pods
NAME                                      READY   STATUS    RESTARTS   AGE
quarkus-simple-service-7f9dd6ddbf-vtdsg   1/1     Running   0          42s

然后使用示例 3-7 来暴露服务,获取服务的 URL。

示例 3-7. 检索服务的 URL
> minikube service quarkus-simple-service --url
🏃  Starting tunnel for service quarkus-simple-service.
|-----------|------------------------|-------------|------------------------|
| NAMESPACE |          NAME          | TARGET PORT |          URL           |
|-----------|------------------------|-------------|------------------------|
| default   | quarkus-simple-service |             | http://127.0.0.1:63905 |
|-----------|------------------------|-------------|------------------------|
http://127.0.0.1:63905
❗  Because you are using a Docker driver on darwin, the terminal needs to be open to
    run it.

别忘了端口是随机分配的,所以你需要用以下命令替换端口。

最后,让我们在另一个终端上运行示例 3-8 来调用我们的服务。

示例 3-8. 调用服务
> curl http://127.0.0.1:63905
hello%

到目前为止,一切顺利。但是,这个应用程序包含一个 机制,用于模拟分布式系统的失败,以说明同步通信的问题。你可以在 chapter-3/quarkus-simple-service/src/main/java/org/acme/reactive/fault/FaultInjector.java 中查看其实现。它基本上是一个 Quarkus 路由(一种拦截器),用于监视 HTTP 流量并允许模拟各种故障。它拦截传入的 HTTP 请求和传出的 HTTP 响应,并引入延迟、丢失或应用程序故障。

当我们以同步方式调用我们的服务(期望得到响应,比如使用 curl 或浏览器)时,会出现三种类型的失败:

  • 调用者与服务之间的请求可能会丢失。这导致服务未被调用。调用者会等待直到超时。这模拟了短暂的网络分区。这种类型的失败可以通过使用 INBOUND_REQUEST_LOSS 模式来启用。

  • 服务接收到请求但未能正确处理它。它可能返回一个错误的响应,或者根本没有响应。在最好的情况下,调用者会收到失败信息或等待超时。这模拟了被调用服务中断性错误。这种类型的失败可以通过使用 SERVICE_FAILURE 模式来启用。

  • 服务接收到请求,处理它,并写入响应,但响应在返回途中丢失,或者在响应到达调用者之前连接关闭。服务收到了请求,处理了它,并生成了响应。调用者只是没有收到。正如之前提到的第一种失败类型一样,响应在一个短暂的网络分区中,但发生在服务调用之后。这种类型的失败可以使用 OUTBOUND_RESPONSE_LOSS 模式启用。

提示

别忘了更新上一个和下一个命令中的端口,因为 minikube 会随机选择一个端口。

为了说明系统在面对失败时的行为,让我们注入一些请求丢失(示例 3-9)。

示例 3-9. 配置系统使其丢失 50% 的传入请求
> curl http://127.0.0.1:63905/fault?mode=INBOUND_REQUEST_LOSS
Fault injection enabled: mode=INBOUND_REQUEST_LOSS, ratio=0.5

此命令配置 FaultInjector 以随机丢失 50% 的传入请求。调用者等待一个永远不会到达的响应,并最终超时。尝试在 示例 3-10 中执行该命令,直到遇到超时。

示例 3-10. 使用配置的超时调用服务
> curl --max-time 5 http://127.0.0.1:63905/  ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/1.png)
hello%
> curl --max-time 5 http://127.0.0.1:63905/
curl: (28) Operation timed out after 5004 milliseconds with 0 bytes received

1

--max-time 5 配置了 5 秒的超时时间。同样,别忘了更新端口。

要模拟第二种失败类型,请执行 示例 3-11 中的命令。

示例 3-11. 配置系统以注入错误响应
> curl http://127.0.0.1:63905/fault?mode=SERVICE_FAILURE

现在你有 50% 的机会收到错误响应;参见 示例 3-12。

示例 3-12. 调用有故障的应用程序
> curl http://127.0.0.1:63905
hello%
> curl http://127.0.0.1:63905
FAULTY RESPONSE!%

最后,让我们模拟最后一种类型的故障。执行示例 3-13 中的命令。

示例 3-13. 配置系统以丢失响应
> curl http://127.0.0.1:63905/fault?mode=OUTBOUND_RESPONSE_LOSS
> curl http://127.0.0.1:63905
curl: (52) Empty reply from server

现在,调用方有 50%的概率得不到响应。在响应到达调用方之前连接突然关闭。你得不到有效的 HTTP 响应。

这些示例的目的是说明由同步通信引起的强耦合和不确定性。这种通信类型通常因为简单性而被使用,但它隐藏了交互的分布式特性。然而,它假设一切都正常运行(包括服务和网络)。但事实并非总是如此。作为使用同步通信的调用方,你必须优雅地处理错误响应和无响应。

所以,我们能做什么?我们立即考虑超时和重试。使用curl,你可以指定超时(-max-time)和重试(--retry),如示例 3-14](#invoke-app-3-14)所示。

示例 3-14. 使用超时和retry调用应用
> curl --max-time 5 --retry 100 --retry-all-errors http://127.0.0.1:63905/
curl: (28) Operation timed out after 5003 milliseconds with 0 bytes received
Warning: Transient problem: time-out Will retry in 1 seconds. 100 retries left.
hello%

我们有很大的机会在 100 次尝试内到达我们的服务。然而,倒霉和随机数可能会决定另一种方式,甚至 100 次也不足够。请注意,在调用方(你)等待的时间内,这是一个相当糟糕的用户体验。

然而,我们是否确定如果我们遇到超时,服务就没有被调用?也许服务或网络只是慢了。超时的理想持续时间是多少?这取决于许多因素:服务的位置、网络的延迟和服务的负载。也许这个服务不是单个实例,而是有几个,每个都具有不同的特性。

重试甚至更加狡猾。因为你无法确定服务是否已被调用,所以也无法假设它没有。重试可能会多次重新处理相同的请求。但是,只有调用的服务是幂等的情况下,你才能安全地重试。

所以,我们能做什么?理解时间的影响并解耦我们的通信是至关重要的。涉及多个服务的复杂交换不能指望所有参与者和网络在整个交换期间都处于可操作状态。云和 Kubernetes 的动态特性对同步通信的限制产生了压力。糟糕的事情会发生:分区、数据丢失、崩溃⋯⋯

在第四章中,您将看到响应式是如何解决这个问题的。通过使用消息传递、空间和时间解耦,响应式系统不仅更具弹性和韧性,而且提高了整体响应能力。换句话说,响应式系统是正确构建的分布式系统。此外,在第五章中,您将看到响应式提出了哪些方法来拥抱分布式系统的异步特性以及我们如何优雅地开发事件驱动和异步代码。结果不仅是并发和高效的应用程序,还铺平了通往新类别应用程序的道路,如数据流处理、API 网关等等。

总结

分布式系统具有挑战性。要构建分布式系统,您需要了解它们的本质,并始终为最坏情况做计划。隐藏分布式系统的本质以追求简单并不起作用。它会导致脆弱的系统。

本章涵盖了以下内容:

  • 分布式系统的不稳定性质

  • 从权宜之计到常态的分布式系统演变

  • 使用云和 Kubernetes 简化分布式系统的构建

  • 由网络中断或缓慢引起的分布式通信潜在故障

但我们不会因为失败而止步!是时候反弹了!让我们更深入地了解一下响应式,并看看它是如何解决这些问题的。

¹ 你可以在当前网络延迟网站查看美国主要城市之间的延迟。

² Kubernetes 提供健康检查,不断验证应用程序的状态。此外,Prometheus 正成为度量收集的标准框架。

³ 2018 年,AWS US-East-1 发生电力故障事件,导致许多 Amazon 服务中断。

⁴ Kubernetes 可以移动容器以实现更高的部署密度,但也可以被指示将相互作用的应用程序移动到同一节点上以减少响应时间。

⁵ 参见《重新审视分布式一致性》,由 Heidi Howard 讨论现代分布式系统中的一致性问题。

《对 CAP 定理的观点》,由 Seth Gilbert 和 Nancy A. Lynch 解释了 未来 分布式系统中 CAP 定理的技术含义。

第四章:设计响应式系统的原则

在第三章中,我们探讨了分布式系统面临的挑战。现在是时候看看响应式系统能为我们提供什么了。响应式可以被看作是构建分布式系统的一套原则,一种检查清单,以确保在架构和构建系统时没有忽略任何主要已知的关注点。这些原则专注于以下内容:

响应性

在面对故障或负载高峰时处理请求的能力

效率

能够在资源较少的情况下完成更多任务

本章我们将讨论响应式系统推广的原则。

响应式系统 101

2013 年,一群分布式系统专家聚集在一起,撰写了第一版“响应式宣言”。在这份白皮书中,他们汇集了构建分布式系统和云应用的经验。虽然在 2013 年,云并不像今天这样具体,但短暂资源的动态创建已经是一个众所周知的机制。

“响应式宣言”将响应式系统定义为具有四个特性的分布式系统:

响应性

能够及时处理请求

韧性

能够优雅地处理故障

弹性

能够根据负载和资源进行动态扩展和收缩

消息驱动

在系统中组件之间使用异步基于消息的通信

这四个特性在图 4-1 中有所体现。

响应式系统特性

图 4-1 响应式系统特性

如果你第一次看到这幅图片,可能会被所有箭头弄得一头雾水。它看起来像是一个精心策划的营销活动。但事实并非如此,让我们解释一下为什么在构建云原生和 Kubernetes 原生应用程序时,这些支柱理念非常合理。让我们从图的底部开始。

响应式系统并不试图简化分布式系统,而是接受其异步性质。它们使用异步消息传递来建立组件之间的连接。异步消息传递确保松耦合、隔离和位置透明性。在响应式系统中,交互依赖于发送到抽象目的地的消息。这些消息不仅携带数据,还携带失败信息。异步消息传递还提高了资源利用率。采用非阻塞通信(我们稍后在本章中详细讨论)允许空闲组件几乎不消耗 CPU 和内存。异步消息传递实现了弹性和韧性,如在图 4-1 中所示的两个底部箭头。

弹性 意味着系统可以自适应或其部分可以自适应以处理波动的负载。通过观察组件之间流动的消息,系统可以确定哪些部分达到了其极限,并创建更多实例或将消息路由到其他地方。云基础设施可以在运行时快速创建这些实例。但弹性不仅仅是扩展;它还涉及缩减。系统可以决定缩减未充分使用的部分以节省资源。在运行时,系统会自我调整,始终满足当前的需求,避免瓶颈、溢出和过度分配资源。正如你所想象的,弹性需要可观察性、复制和路由功能。可观察性在 第十三章 中有所涵盖。总的来说,最后两者由基础设施如 Kubernetes 或云提供者提供。

韧性 意味着优雅地处理故障。正如 第三章 所解释的,分布式系统中的故障是不可避免的。与其隐藏故障,响应式系统将故障视为一等公民。系统应能够处理和响应这些故障。故障被隔离在每个组件内部,使各组件相互隔离。这种隔离确保系统的各部分可以失败和恢复,而不会危及整个系统。例如,通过复制组件(弹性),即使某些元素失败,系统也可以继续处理传入的消息。韧性的实现由应用程序(需要意识到故障、隔离它们并在可能时优雅地处理它们)和基础设施(监控系统并重新启动失败的组件)共同分享。

最后一个特性是响应式系统的整体目的:响应性。你的系统需要保持响应能力——即使在波动的负载(弹性)和面对故障(韧性)时也要能够及时响应。依赖消息传递可以实现这些特性,以及更多,例如通过监控系统中的消息并在必要时施加反压力来实现流量控制。

简而言之,响应式系统正是我们想要构建的东西:能够有效处理不确定性、故障和负载的分布式系统。它们的特性完美符合云原生和 Kubernetes 原生应用程序的要求。但不要误解;构建响应式系统仍然是在构建分布式系统。这是具有挑战性的。然而,通过遵循这些原则,得到的系统将更加响应、更加健壮和更加高效。本书的其余部分详细说明了如何使用 Quarkus 和消息传递技术轻松实现这样的系统。

命令与事件

现在我们已经涵盖了许多基础原则,您可能会感到困惑。在第一章中,我们说反应性与事件驱动有关,但在前一节中,我们明确提到了异步消息传递。这意味着相同的吗?并非完全如此。

但首先,我们需要讨论命令和事件之间的区别。分布式系统设计虽然复杂,但命令和事件的概念是基础的。几乎所有个体组件之间的互动都涉及其中之一。

命令

每个系统都会发布命令。命令 是用户希望执行的操作。大多数基于 HTTP 的 API 传递命令:客户端要求执行某项操作。重要的是要理解该操作尚未发生。它可能会在未来发生,也可能不会;它可能成功完成,也可能失败。通常,命令被发送到特定的接收者,并将结果发送回客户端。

以我们在第三章使用的简单 HTTP 应用为例。您发出了一个简单的 HTTP 请求。正如我们所说,那是一个命令。应用程序接收到该命令,处理它,并产生一个结果。

事件

事件 是成功完成的操作。事件代表了一个事实,即发生的事情:一个按键、一个失败、一个订单,或者对组织或系统至关重要的任何事情。事件可能是由命令执行产生的结果。

让我们回到之前的 HTTP 请求示例。一旦响应被写入,它就成为一个事件。我们已经看到了一个 HTTP 请求及其响应。该事件可以被写入日志或广播给感兴趣的各方,以便他们了解发生了什么。

事件是不可变的。您无法删除事件。诚然,您无法改变过去。如果要反驳先前发送的事实,您需要触发另一个事件,使该事实失效。只有通过另一个确立当前知识的事实,才能使携带的事实变得无关紧要。

消息

但是,如何发布这些事件呢?有许多方法。像 Apache Kafka 或 Apache ActiveMQ 这样的解决方案现在很流行(我们在第十一章中涵盖了两者)。它们充当生产者和消费者之间的代理。本质上,我们的事件被写入主题队列中。要写入这些事件,应用程序向代理发送消息,指定特定的目标(队列或主题)。

消息 是描述事件及其相关细节的独立数据结构,比如谁发出的、发出时间以及可能的唯一标识符。通常最好将事件本身保持业务中心化,使用额外的元数据处理技术细节。

另一方面,为了消费事件,您可以订阅包含您感兴趣的事件的队列或主题,并接收消息。您可以解封事件,并获取相关的元数据(例如事件发生的时间、地点等)。事件的处理可能会导致发布其他事件(再次封装为消息并发送到已知目的地)或执行命令。

代理和消息也可以传递命令。在这种情况下,消息包含要执行的动作描述,另一个消息(可能是多个消息)将携带必要的结果。

命令与事件:一个例子

让我们来看一个例子,突出命令和事件之间的区别。想象一个电子商务店铺,就像图 4-2 所示的那样。用户选择一组产品并完成订单(处理支付、获取送货日期等)。

电子商务店铺简化架构

图 4-2. 电子商务店铺简化架构

用户通过命令(例如使用 HTTP 请求)向商店服务发送希望接收的商品。在传统应用程序中,一旦ShopService接收到命令,它将调用OrderService并调用一个order方法,传递用户名、商品列表(购物篮)等信息。调用order方法属于命令。这使得ShopService依赖于OrderService,并降低了组件的自主性:ShopService无法在没有OrderService的情况下运行。我们正在创建一个分布式单体,一个分布式应用程序,一旦其中一个部分失败就会崩溃。¹

如果我们不使用命令而是发布一个事件,让我们看看其中的区别。一旦用户完成订单,应用程序仍然会向ShopService发送一个命令。但是,这次,ShopService会将该命令转换成一个事件:已下订单。该事件包含用户、购物篮等信息。事件是写入日志中的事实,或者被包装成消息发送到代理。

在另一方面,OrderService通过读取存储事件的位置来观察已下订单事件。当ShopService发出该事件时,它接收并可以处理它。

使用这种架构,ShopService不依赖于OrderService。此外,OrderService也不依赖于ShopService,并且它会处理任何观察到的事件,无论是谁发出的。例如,移动应用程序在用户从手机验证订单时可以发出相同的事件。

多个组件可以消费事件(图 4-3)。例如,除了OrderServiceStatisticsService也跟踪最常订购的商品。它消费相同的事件,而无需修改ShopService以接收它们。

一个观察事件的组件可以从中派生新的事件。例如,StatisticsService可以分析订单并计算推荐。这些推荐可以被视为另一个事实,并作为事件进行通信。ShopService可以观察这些事件并处理它们以影响商品选择。然而,StatisticsServiceShopService是彼此独立的。知识是累积的,并且通过接收新事件并从中派生新事实(就像StatisticsService所做的那样)来实现。

如 图 4-3 所示,我们可以使用消息队列来传输我们的事件。这些事件被包装成消息,发送到已知的目的地(ordersrecommendations)。OrderServiceStatisticsService 独立地消费和处理这些消息。

基于事件和消息代理的电子商务店铺架构

图 4-3. 带有事件和消息队列的电子商务店铺架构

对于这些目的地来说,持久化事件作为一个有序序列是很重要的。通过保持这个顺序,系统可以回溯并重新处理事件。这样的重放机制,在 Kafka 世界中很受欢迎,有多种好处。例如,在灾难后通过重新处理所有存储的事件可以重新启动一个干净的状态。然后,如果我们从统计服务改变推荐算法,它将能够重新累积所有知识并派生新的推荐。

虽然在这个例子中事件的发射听起来很明确,但情况并非总是如此。例如,事件可以从数据库写入中创建。²

命令和事件是大多数交互的基础。虽然我们主要使用命令,但事件带来了显著的好处。事件是事实。事件讲述一个故事,你系统的故事,描述你系统演变的叙述。在反应系统中,事件被包装成消息,并且这些消息被发送到目的地,通过消息代理如 AMQP 或 Kafka(图 4-4)。这种方法解决了分布式系统中出现的两个重要的架构问题。首先,它自然地处理现实世界的异步性。其次,它在不依赖强耦合的情况下将服务绑定在一起。在系统的边缘,这种方法大多数时候使用命令,通常依赖于 HTTP。

反应系统概述

图 4-4. 反应系统概述

反应系统中这种异步消息传递的特性形成了连接组织。它不仅赋予了构成系统的应用更多的自治和独立性,还能实现弹性和弹性。你可能会想知道如何做到这一点,在下一节中你将找到我们的开头回应。

目的地和空间解耦

响应式应用程序形成响应式系统,它们使用消息进行通信。它们订阅目的地并接收其他组件发送到这些目的地的消息。这些消息可以携带命令或事件,正如前一节所述,事件提供了一些有趣的好处。这些目的地不绑定到特定的组件或实例。它们是虚拟的。组件只需知道目的地的名称(通常是业务相关的,如 orders),而不需要知道是谁在生产或消费。这使得位置透明性成为可能。

如果您正在使用 Kubernetes,您可能已经考虑到位置透明性由系统自动管理。实际上,您可以使用 Kubernetes 服务 来实现位置透明性。您只需使用一个端点来委派给一组选定的 pod。但这种位置透明性在某种程度上是有限的,并且通常与 HTTP 或请求/回复协议相关联。其他环境可以使用诸如 HashiCorp ConsulNetflix Eureka 等服务发现基础设施。

作为发送者,通过发送消息到目的地,您可以忽略具体是谁会接收该消息。您不知道当前是否有人可用,或者多个组件或实例是否在等待您的消息。这些消费者的数量可以在运行时发生变化;可以创建、移动或销毁更多实例,并部署新的组件。但作为发送者,您不需要知道这些。您只需使用指定的目的地。让我们通过前一节的示例来说明这种 可寻址性 的优势。ShopService 发出 order placed 事件,这些事件被包含在发送到 orders 目的地的消息中(参见 Figure 4-3)。在安静期间,可能只有一个 OrderService 实例正在运行。如果订单不多,为什么要费心多部署呢?我们甚至可以想象没有任何实例,在收到订单时实例化一个。无服务器平台提供了这种 从零扩展 的能力。然而,随着时间的推移,您的商店会吸引更多客户,单个实例可能不够用。由于位置透明性,我们可以启动其他 OrderService 实例来分担负载(参见 Figure 4-5)。ShopService 不会修改,也不会关心这种新的拓扑结构。

通过消息传递提供的弹性

图 4-5. 通过消息传递提供的弹性

消费者之间的负载共享方式对于发送者来说也是无关紧要的。可以是循环轮询、基于负载的选择,或者更聪明的方式。当负载恢复正常时,系统可以减少实例数量并节省资源。请注意,这种弹性对于无状态服务非常有效。对于有状态服务,可能更为困难,因为实例可能需要共享状态。不过,已存在解决方案(尽管存在一些注意事项),如Kubernetes StatefulSet或者in-memory data grid,用于协调同一服务实例之间的状态。消息传递还能实现复制。遵循相同原则,我们可以影子化活跃的OrderService实例,并在主实例失败时接管(参见图 4-6)。这种方法避免了服务中断。此类故障转移可能还需要共享状态。

使用消息传递提供的弹性

图 4-6. 使用消息传递提供的弹性

使用消息传递,我们的系统不仅变得异步,还变得具有弹性和弹性。在架构设计系统时,您会列出实现所需通信模式的目标。通常情况下,每种事件类型使用一个目标,但并非一定如此。但是,务必尽量避免每个组件实例使用一个目标。这会增加发送方和接收方之间的耦合,丧失优势。它还会降低可扩展性。最后,保持目标集合稳定非常重要。更改目标会打破使用它的组件或迫使您处理重定向问题。

时间解耦

透明定位并非唯一的好处。异步消息传递还能实现时间解耦。

现代消息骨干,例如AMQP 1.0Apache Kafka,甚至 Java 消息服务(JMS),都能实现时间解耦。使用这些事件代理,如果没有消费者,事件不会丢失。事件将被存储并稍后传递。每个代理都有自己的方式。例如,AMQP 1.0 使用持久消息和持久订阅者来确保消息传递。Kafka 将记录存储在持久、容错、有序的日志中。只要它们保持存储在主题中,就可以检索记录。

如果我们的ShopService作为事件发出最终订单,它不需要知道OrderService是否可用。它知道订单最终会被处理。例如,当ShopService发出事件时,如果没有OrderService实例可用,订单不会丢失。当实例准备好时,它会接收待处理订单并进行处理。然后通过电子邮件异步通知用户。

当然,消息代理必须是可用和可达的。大多数消息代理都具备复制能力,以防止不可用问题和消息丢失。

注意

将事件存储在事件日志中正变得越来越常见。这样有序且追加式的结构代表了系统的完整历史。每当状态变化时,系统都将新状态追加到日志中。

时间解耦增加了我们组件的独立性。时间解耦,结合异步消息传递启用的其他特性,实现了我们组件之间的高度独立性,并将耦合降至最低。

非阻塞输入/输出的作用

此时,你可能会想知道使用 Kafka 或 AMQP 的应用与响应式系统之间的区别是什么。消息传递是响应式系统的核心,大多数系统依赖某种消息代理。消息传递使得系统具备了弹性和响应能力。它促进了空间和时间的解耦,使我们的系统更加健壮。

但是响应式系统不仅仅是在交换消息。发送和接收消息必须高效完成。为了实现这一点,响应式系统推广使用非阻塞 I/O。

阻塞网络 I/O、线程和并发

要理解非阻塞 I/O 的好处,我们需要了解阻塞 I/O 的工作原理。让我们使用客户端/服务器交互来说明。当客户端向服务器发送请求时,服务器处理它并发送回响应。例如,HTTP 遵循这一原则。为了实现这一点,客户端和服务器在交互开始之前都需要建立连接。我们不会深入讨论七层模型及其涉及的协议栈;你可以在网上找到许多关于这个主题的文章。

注意

本节的示例可以直接从你的 IDE 中运行。使用chapter-4/non-blocking-io/src/main/java/org/acme/client/EchoClient.java来调用已启动的服务器。请确保避免并发运行多个服务器,因为它们都使用相同的端口(9999)。

为了建立客户端和服务器之间的连接,我们使用sockets,如示例 4-1 所示。

示例 4-1。使用阻塞 I/O 的单线程回显服务器(chapter-4/non-blocking-io/src/main/java/org/acme/blocking/BlockingEchoServer.java)
int port = 9999;

// Create a server socket
try (ServerSocket server = new ServerSocket(port)) {
    while (true) {

        // Wait for the next connection from a client
        Socket client = server.accept();

        PrintWriter response = new PrintWriter(client.getOutputStream(), true);
        BufferedReader request = new BufferedReader(
                new InputStreamReader(client.getInputStream()));

        String line;
        while ((line = request.readLine()) != null) {
            System.out.println("Server received message from client: " + line);
            // Echo the request
            response.println(line);

            // Add a way to stop the application.
            if ("done".equalsIgnoreCase(line)) {
                break;
            }
        }
        client.close();
    }
}

客户端和服务器必须将自己绑定到一个套接字来建立连接。服务器监听它的套接字,等待客户端连接。一旦建立,客户端和服务器都可以在与该连接绑定的套接字上写入和读取数据。

传统上,因为它更简单,应用程序是使用同步开发模式开发的。这种开发模式依次执行指令,一个接一个。因此,当这些应用程序通过网络进行交互时,它们期望继续使用同步开发模式进行 I/O。这种模型使用同步通信并阻塞执行,直到操作完成。在示例 4-1 中,我们等待连接并同步处理它。我们使用同步 API 进行读写。这样做更简单,但会导致使用阻塞 I/O。

使用阻塞 I/O 时,当客户端向服务器发送请求时,处理该连接的套接字以及从中读取数据的相应线程将被阻塞,直到出现一些可读数据。字节会在网络缓冲区中累积,直到所有数据都被读取并准备好进行处理。在操作完成之前,服务器除了等待之外什么也做不了。

这种模型的结果是我们无法在单个线程中服务多个连接。当服务器接收到连接时,它会使用该线程读取请求、处理请求并写入响应。该线程会被阻塞,直到响应的最后一个字节被写入线路。单个客户端连接会阻塞服务器!效率不高,对吧?

使用这种方法执行并发请求的唯一方法是使用多个线程。我们需要为每个客户端连接分配一个新线程。为了处理更多客户端,您需要使用更多线程并在不同的工作线程上处理每个请求;参见示例 4-2。

示例 4-2. 使用阻塞 I/O 的多线程服务器背后的原理
while (listening) {
    accept a connection;
    create a worker thread to process the client request;
}

要实现这一原则,我们需要一个线程池(工作池)。当客户端连接时,我们接受连接并将处理分派到单独的线程中。因此,服务器线程仍然可以接受其他连接,如示例 4-3 所示。

示例 4-3. 使用阻塞 I/O 的多线程回显服务器(chapter-4/non-blocking-io/src/main/java/org/acme/blocking/BlockingWithWorkerEchoServer.java
int port = 9999;
ExecutorService executors = Executors.newFixedThreadPool(10); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/1.png)

// Create a server socket try (ServerSocket server = new ServerSocket(port)) {
    while (true) {

        // Wait for the next connection from a client
        Socket client = server.accept();

        executors.submit(() -> {                                    ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/2.png)
            try {
                PrintWriter response =
                new PrintWriter(client.getOutputStream(), true);
                BufferedReader request = new BufferedReader(
                        new InputStreamReader(client.getInputStream()));

                String line;
                while ((line = request.readLine()) != null) {
                    System.out.println(Thread.currentThread().getName() +
                            " - Server received message from client: " + line);
                    // Echo the request
                    response.println(line);

                    // Add a way to stop the application.
                    if ("done".equalsIgnoreCase(line)) {
                        break;
                    }
                }
                client.close();
            } catch (Exception e) {
                System.err.println("Couldn't serve I/O: " + e.toString());

            }
        });
    }
}

1

创建一个工作线程池来处理请求。

2

将请求的处理分派到线程池中的一个线程。其余代码保持不变。

这是传统 Java 框架(如 Jakarta EE 或 Spring)默认使用的模型。即使这些框架可能在内部使用非阻塞 I/O,它们仍然使用工作线程来处理请求。但这种方法有许多缺点,包括:

  • 每个线程都需要分配给它的内存栈。随着连接数量的增加,产生多个线程并在它们之间进行切换将消耗内存和 CPU 周期。

  • 在任何给定时间点,可能有多个线程在等待客户端请求。这是资源的巨大浪费。

  • 你的并发性(在给定时间内能处理的请求数量——如前面示例中的 10)受到可以创建的线程数的限制。

在公共云上,阻塞 I/O 方法会增加你的月度账单;在私有云上,它会减少部署密度。因此,如果需要处理多个连接或实现涉及大量 I/O 的应用程序,则此方法并不理想。在分布式系统领域,这种情况经常发生。幸运的是,有一个替代方案。

非阻塞 I/O 是如何工作的?

替代方案是 非阻塞 I/O。其差异从名称中就能看出来。与等待传输完成不同,调用者不会被阻塞,可以继续其处理过程。这种魔法发生在操作系统中。使用非阻塞 I/O,操作系统将请求排队。系统在未来处理实际的 I/O。当 I/O 完成并且响应准备好时,会发生一个 continuation,通常实现为回调函数,调用者接收结果。

要更好地理解其优势并看看这些 continuation 是如何工作的,我们需要深入了解一下:非阻塞 I/O 是如何实现的?我们已经提到了一个队列。系统将 I/O 操作入队并立即返回,因此调用者在等待 I/O 操作完成时不会被阻塞。当响应返回时,系统将结果存储在一个结构中。当调用者需要结果时,它询问系统是否完成操作(示例 4-4)。

示例 4-4. 使用非阻塞 I/O 的回显服务器(chapter-4/non-blocking-io/src/main/java/org/acme/nio/NonBlockingServer.java
InetSocketAddress address = new InetSocketAddress("localhost", 9999);
Selector selector = Selector.open();
ServerSocketChannel channel = ServerSocketChannel.open();
channel.configureBlocking(false);

channel.socket().bind(address);
// Server socket supports only ACCEPT
channel.register(selector, SelectionKey.OP_ACCEPT);

while (true) {
    int available = selector.select(); // wait for events
    if (available == 0) {
        continue;  // Nothing ready yet.
    }

    // We have the request ready to be processed.
    Set<SelectionKey> keys = selector.selectedKeys();
    Iterator<SelectionKey> iterator = keys.iterator();
    while (iterator.hasNext()) {
        SelectionKey key = iterator.next();
        if (key.isAcceptable()) {
            // --  New connection --
            SocketChannel client = channel.accept();
            client.configureBlocking(false);
            client.register(selector, SelectionKey.OP_READ);
            System.out.println("Client connection accepted: "
                + client.getLocalAddress());
        } else if (key.isReadable()) {
            // --  A client sent data ready to be read and we can write --
            SocketChannel client = (SocketChannel) key.channel();
            // Read the data assuming the size is sufficient for reading.
            ByteBuffer payload = ByteBuffer.allocate(256);
            int size = client.read(payload);
            if (size == -1 ) { // Handle disconnection
                System.out.println("Disconnection from "
                    + client.getRemoteAddress());
                channel.close();
                key.cancel();
            } else {
                String result = new String(payload.array(),
                    StandardCharsets.UTF_8).trim();
                System.out.println("Received message: " + result);
                if (result.equals("done")) {
                    client.close();
                }
                payload.rewind(); // Echo
                client.write(payload);
            }
        }
        // Be sure not to handle it twice.
        iterator.remove();
    }
}

非阻塞 I/O 引入了一些新概念:

  • 我们不使用 InputStreamOutputStream(它们天生是阻塞的),而是使用 Buffer,这是一个临时存储。

  • Channel 可以被视为开放连接的端点。

  • Selector 是 Java 中非阻塞 I/O 的基石。

Selector 管理多个通道,可以是服务器通道或客户端通道。当您使用非阻塞 I/O 时,您会创建 Selector。每当处理新通道时,您都会将此通道注册到选择器中,并指定您感兴趣的事件(接受、准备读取、准备写入)。

然后,你的代码使用单个线程轮询 Selector,查看通道是否准备就绪。当通道准备好读取或写入时,可以开始读取和写入。我们根本不需要为每个通道都创建一个线程,一个单独的线程可以处理多个通道。

选择器是底层操作系统提供的非阻塞 I/O 实现的抽象。根据操作系统的不同,有多种方法可供选择。

首先,select是在上世纪 80 年代实现的。它支持注册 1,024 个套接字。在 80 年代这当然足够了,但现在不再是这样了。

poll是 1997 年引入的select的替代品。最大的区别是,poll不再限制套接字的数量。但是,与select一样,系统仅告诉您有多少通道准备就绪,而不是哪些通道准备就绪。您需要迭代通道集合以检查哪些通道已准备就绪。当通道较少时,这不是一个大问题。一旦通道数量超过数十万,迭代时间就会相当可观。

然后,epoll在 2002 年出现在 Linux 内核 2.5.44 中。Kqueue在 2000 年出现在 FreeBSD 中,而/dev/poll在同一时间左右出现在 Solaris 中。这些机制返回准备处理的通道集合——不再需要迭代处理每个通道!最后,Windows 系统提供了 IOCP,这是select的优化实现。

重要的是要记住,无论操作系统如何实现,使用非阻塞 I/O,您只需要一个线程来处理多个请求。这个模型比阻塞 I/O 更高效,因为您不需要创建线程来处理并发请求。消除这些额外的线程使您的应用程序在内存消耗(每个线程约为 1 MB)方面更高效,并避免因上下文切换而浪费 CPU 周期(每次切换 1-2 微秒)⁴

响应式系统建议使用非阻塞 I/O 来接收和发送消息。因此,您的应用程序可以使用更少的资源处理更多的消息。另一个优点是,空闲应用程序几乎不会消耗内存或 CPU。您不必预先保留资源。

反应器模式和事件循环

非阻塞 I/O 使我们有可能使用单个线程处理多个并发请求或消息。我们如何处理这些并发请求?在使用非阻塞 I/O 时如何构建我们的代码结构?前一节中给出的示例的性能不佳;我们很快就会看到,使用这种模型实现 REST API 将是一场噩梦。此外,我们希望避免使用工作线程,因为这将抛弃非阻塞 I/O 的优势。我们需要不同的东西:反应器模式。

反应器模式,如图 4-7 所示,允许将 I/O 事件与事件处理程序关联起来。反应器,这个机制的基石,当接收到预期的事件时调用事件处理程序。

反应器模式的目的是避免为每个消息、请求和连接创建线程。该模式从多个通道接收事件,并将它们顺序地分配给相应的事件处理程序。

反应器模式

图 4-7。反应器模式

反应器模式的实现使用了一个 事件循环(图 4-7)。它是一个线程,迭代遍历通道集,并在数据准备就绪时按顺序、以单线程方式调用相关的事件处理程序。

当您将非阻塞 I/O 与反应器模式相结合时,您会将代码组织为一组事件处理程序。这种方法与反应式代码非常契合,因为它暴露了事件的概念,这是反应式的本质。

反应器模式有两个变种:

  • multireactor 模式使用多个事件循环(通常每个 CPU 内核一个或两个),这增加了应用程序的并发性。多反应器模式的实现,如 Eclipse Vert.x,以单线程方式调用事件处理程序,以避免死锁或状态可见性问题。

  • proactor 模式可以看作是反应器模式的异步版本。长时间运行的事件处理程序在完成时调用续集。这种机制允许混合非阻塞和阻塞 I/O(图 4-8)。

proactor 模式

图 4-8. proactor 模式

您可以通过将其执行分派到单独的线程来集成非阻塞事件处理程序以及阻塞事件处理程序,当不可避免地需要时。当它们的执行完成时,proactor 模式将调用续集。正如您将在第六章中看到的,这是 Quarkus 使用的模式。

反应式应用程序的解剖

在过去几年中,许多框架已经涌现,提供反应式应用程序支持。它们的目标是简化反应式应用程序的实现。它们通过提供更高级的原语和 API 来处理事件和抽象非阻塞 I/O 来实现这一目标。

实际上,您可能已经意识到了,使用非阻塞 I/O 并不是那么简单。将其与反应器模式(或变体)结合使用可能会很复杂。幸运的是,随着框架的出现,库和工具包正在承担繁重的工作。Netty 是一个异步事件驱动的网络应用程序框架,利用非阻塞 I/O 构建高并发应用程序。它是处理 Java 世界中非阻塞 I/O 的最常用库。但是 Netty 可能会很具有挑战性。示例 4-5 使用 Netty 实现了 echo TCP 服务器。

示例 4-5. 使用 Netty 的回显服务器(第四章 / 非阻塞 I/O / src / main / java / org / acme / netty / NettyEchoServer.java
public static void main(String[] args) throws Exception {
    new NettyServer(9999).run();
}

private final int port;

public NettyServer(int port) {
    this.port = port;
}

public void run() throws Exception {
    // NioEventLoopGroup is a multithreaded event loop that handles I/O operation.
    // The first one, often called 'boss', accepts an incoming connection.
    // The second one, often called 'worker', handles the traffic of the accepted
    // connection once the boss accepts the connection and registers the
    // accepted connection to the worker.
    EventLoopGroup bossGroup = new NioEventLoopGroup();

    EventLoopGroup workerGroup = new NioEventLoopGroup();
    try {
        // ServerBootstrap is a helper class that sets up a server.
        ServerBootstrap b = new ServerBootstrap();
        b.group(bossGroup, workerGroup)
                // the NioServerSocketChannel class is used to instantiate a
                // new Channel to accept incoming connections.
                .channel(NioServerSocketChannel.class)
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    // This handler is called for each accepted channel and
                    // allows customizing the processing. In this case, we
                    // just append the echo handler.
                    @Override
                    public void initChannel(SocketChannel ch) {
                        ch.pipeline().addLast(new EchoServerHandler());
                    }
                });

        // Bind and start to accept incoming connections.
        ChannelFuture f = b.bind(port).sync();

        // Wait until the server socket is closed.
        f.channel().closeFuture().sync();
    } finally {
        workerGroup.shutdownGracefully();
        bossGroup.shutdownGracefully();
    }
}

private static class EchoServerHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        // Write the received object, and flush
        ctx.writeAndFlush(msg);
    }
}

基于 Netty 的 Vert.x 工具包提供了构建反应式应用程序所需的更高级功能,例如 HTTP 客户端和服务器、消息客户端等。通常,使用 Vert.x 的相同 echo TCP 服务器看起来像示例 4-6。

示例 4-6. 使用 Vert.x 的回显服务器(第四章 / 非阻塞 I/O / src / main / java / org / acme / vertx / VertxEchoServer.java
Vertx vertx = Vertx.vertx();
// Create a TCP server
vertx.createNetServer()
        // Invoke the given function for each connection
        .connectHandler(socket -> {
            // Just write the content back
            socket.handler(buffer -> socket.write(buffer));
        })
        .listen(9999);

大多数提供响应能力的 Java 框架基于 Netty 或 Vert.x。如图 4-9 所示,它们都遵循相同类型的蓝图。

响应式框架的常见架构

图 4-9. 响应式框架的常见架构

底层是非阻塞 I/O。通常,框架使用 Netty 或 Vert.x。这一层处理客户端连接、出站请求和响应写入。换句话说,它管理 I/O 部分。大多数情况下,这一层实现反应器模式(或其变体),因此提供基于事件循环的模型。

然后,在第二层,你有本身的响应式框架。这一层的作用是提供易于使用的高级 API。你可以使用这些 API 编写应用程序代码。与处理非阻塞 I/O 通道不同,这一层提供高级对象,如 HTTP 请求、响应、Kafka 消息等。要简单得多!

最后,在顶层,你有你的应用程序。由于响应式框架的帮助,你的代码不需要涉及非阻塞 I/O 概念。它可以专注于接收事件并处理它们。你的代码只是一组事件处理器。它可以使用响应式框架提供的功能与其他服务或中间件交互。

但是有一个问题。来自你代码中的事件处理器是使用事件循环线程(即 I/O 线程)调用的。如果你的代码阻塞了这个线程,那么没有其他并发事件可以被处理。这在响应性和并发性方面将是一场灾难。这种架构的后果显而易见:你的代码必须是非阻塞的。它绝不能阻塞 I/O 线程,因为它们是稀有的,并且用于处理多个并发请求。为了实现这一点,你可以将某些事件的处理卸载到工作线程中(使用 proactor 模式)。虽然这可能会丢弃非阻塞 I/O 的一些好处,但有时这是最明智的选择(图 4-10)。然而,我们不应滥用这一点,因为这将丢弃响应式的优势并使应用程序变慢。在工作线程上处理事件所需的多次上下文切换会影响响应时间。

在工作线程上运行一些事件处理器

图 4-10. 在工作线程上运行一些事件处理器

典型地,我们在第二章和第三章中的应用程序依赖于这样的机制。

另一种可能性是仅依赖非阻塞代码,依赖响应式框架提供的异步 API。这些 API 将是非阻塞的,如果业务逻辑涉及 I/O,则使用非阻塞 I/O。每当一个事件处理程序执行异步操作时,将注册另一个处理程序(继续执行),并且当预期的事件到达时,事件循环将调用它。因此,处理被分成更小的异步运行处理程序。这种模式是最高效的,并完全接纳了反应式背后的概念。

总结

反应式系统是关于构建更好的分布式系统。它们的目标不是隐藏分布式系统的本质,而是相反地接受它。

在本章中,您学到了以下内容:

  • 反应式系统的四大支柱(异步消息传递、弹性、弹性和响应能力)

  • 异步消息传递如何实现弹性和弹性,并增加每个单独组件的自治性

  • 在分布式系统中命令和事件的角色

  • 非阻塞 I/O 如何提升反应式应用程序中的资源利用率

但是这最后一点有一个显著的缺点,因为我们需要编写非阻塞代码。多么巧合!下一章正好讲述这个!

¹ “不要构建分布式单体” 由 Ben Christensen 是一个关于分布式单体的有趣演讲,讲述了为什么应该避免它们。

² 这种模式称为变更数据捕获。像Debezium这样的框架在使用数据库时是反应式系统的关键组成部分,因为这些事件会在不影响应用程序代码的情况下发出。

³ 我们正在指的是传统的 Spring Framework。响应式 Spring 基于非阻塞 I/O。

“测量 Linux 线程上的上下文切换和内存开销” 由 Eli Bendersky 提供了有关 Linux 线程成本的有趣数据。

第五章:响应式编程:驯服异步性

在前一章中,我们介绍了响应式系统及其如何优雅地处理分布式系统的挑战。但永远不要忘记,在 IT 世界中没有免费的午餐。响应式系统的特征之一是使用非阻塞 I/O。非阻塞 I/O 改进了响应式应用的并发性、响应性和资源利用率。要充分利用非阻塞 I/O 的优势,必须以非阻塞方式设计和开发代码,这是一个并不那么容易的挑战。

本章探讨了编写非阻塞和异步 Java 代码的方法,例如回调和响应式编程。我们还涵盖了流控制和响应式流,这是现代响应式应用的重要部分。

异步代码与模式

非阻塞如何导致异步代码?回想一下上一章的非阻塞 I/O 设计。它允许使用少量线程处理并发网络交互。这种特定的架构减少了内存消耗,同时也减少了 CPU 使用率。因此,应用代码由其中一个 I/O 线程执行,资源稀缺。如果你的代码无意中阻塞了其中一个线程,将降低应用程序的并发性,并增加响应时间,因为可用于处理请求的线程更少。在最坏的情况下,所有 I/O 线程都被阻塞,应用程序无法处理请求。换句话说,非阻塞 I/O 的好处会消失。

让我们通过一个例子来说明这一点。想象一个greeting服务,它以一个名字作为参数,并生成一个问候消息。在同步模型中,你会像在示例 5-1 中展示的那样调用该服务。

示例 5-1. 同步代码示例
String greetings = service.greeting("Luke");
System.out.println(greetings);

你调用服务,同步获取结果,并在下一行使用它。

现在,假设greeting服务是一个远程服务。你仍然可以同步调用它,但在这种情况下,你将阻塞线程,直到接收到响应,就像图 5-1 中所示。

同步调用

图 5-1. 同步调用示意图

如果你的代码运行在 I/O 线程上,你会阻塞该线程。因此,服务在等待响应时无法处理任何其他请求。阻塞 I/O 线程会丢弃非阻塞 I/O 的所有优势。

我们可以做什么?很简单:我们不能阻塞线程。我们调用方法,它立即返回,而不是等待响应。但是,这种方法有一个小问题:你如何获取这个响应?你需要传递一些延续,在接收到响应时调用,就像在示例 5-2 中展示的那样。

示例 5-2. 异步代码示例
service.greeting("Luke", greeting -> {
    System.out.println(greeting);
});

在这段代码片段中,我们传递了一个使用回调实现的延续,即收到结果后调用的函数。它很好地包含了此代码的事件驱动特性:在结果上,调用该函数。通过这种异步模型,我们释放了 I/O 线程。当接收到响应时,它将用该响应调用函数并继续执行。在此期间,该 I/O 线程可以用来处理更多的请求(图 5-2)。

异步调用

图 5-2. 异步调用

让我们深入看一下前面的代码片段,并通过使用老式的System.out语句添加一些跟踪(示例 5-3)。

示例 5-3. 异步代码和排序
System.out.println("Before");
service.greeting("Luke", greeting -> {
    System.out.println(greeting);
});
System.out.println("After");

这个程序的输出会是什么?肯定会先打印Before,但问候消息和After呢?哪个会先打印?After很有可能会先打印,因为调用greeting服务至少需要几毫秒(记住,它是一个远程服务)。这意味着使用异步代码时,下一行通常在延续之前执行。

在实践中意味着什么?让我们想象一下,你想调用greeting服务两次,一次是为 Luke,一次是为 Leia;参见示例 5-4。

示例 5-4. 两次调用异步方法
service.greeting("Leia", greeting -> {
    System.out.println("Leia: " + greeting);
});
service.greeting("Luke", greeting -> {
    System.out.println("Luke: " + greeting);
});

在这段代码中,我们无法预料哪条消息会首先出现。这取决于许多因素,如延迟、速度和greeting服务的实例数量。然而,两次调用都是并发运行的,这是一个吸引人的好处。

如果你想要或需要严格的顺序(例如,先为 Leia 调用服务,然后再为 Luke 调用),我们需要组合异步调用(示例 5-5)。

示例 5-5. 顺序组合模式
service.greeting("Leia", greeting1 -> {
    System.out.println("Leia: " + greeting1);
    service.greeting("Luke", greeting2 -> {
        System.out.println("Luke: " + greeting2);
    });
});

使用这段代码,我们首先使用Leia调用服务,当我们收到响应后,再使用Luke调用服务。调用不再并发运行,但至少我们知道顺序。我们称这种模式为顺序组合。正如你可以想象的那样,这是相当常见的。

让我们继续我们的调查,使用另一种有用的组合类型:并行组合。这次我们想同时执行这些调用,但我们需要传递一个在两次调用都完成时调用的延续函数(示例 5-6)。

示例 5-6. 简化的并行组合模式
String resultForLeia = null;
String resultForLuke = null;
BiConsumer<String, String> continuation = ...;

service.greeting("Leia", greeting -> {
    resultForLeia = greeting;
    if (resultForLuke != null) {
        continuation.accept(resultForLeia, resultForLuke);
    }
});
service.greeting("Luke", greeting -> {
    resultForLuke = greeting;
    if (resultForLeia != null) {
        continuation.accept(resultForLeia, resultForLuke);
    }
    });
});

开始变得有些复杂,这段代码并不完全正确,因为如果两个回调同时被调用,你可能会遇到竞态条件。我们需要存储结果,检查它们是否非空,并调用延续函数。

我们稍微忘记了另一个方面:失败。这不是因为它是一个异步 API 就不会发生失败。你不能再使用 try/catch 块了,因为失败也可能是异步的;参见 示例 5-7。

示例 5-7. 这个 try/catch 能正常工作吗?
try {
    service.greeting("Luke", greeting -> {
        System.out.println(greeting);
    });
} catch (Exception e) {
    // does not handle the exception thrown by the remote service
}

catch 块只能捕获同步异常。如果服务异步产生失败,例如无法生成问候消息,这个 try/catch 就毫无用处。为了处理失败,我们需要一个适当的结构。我们可以想象两种简单的方式:

  • 使用封装了结果和失败的异步结果结构。

  • 对于失败,有第二个回调。

对于第一种方法,你需要类似于 示例 5-8 的东西。

示例 5-8. 使用封装了结果和失败的异步结果。
service.greeting("Luke", greeting -> {
    if (greeting.failed()) {
        System.out.println("D'oh! " + greeting.failure().getMessage());
    } else {
        System.out.println(greeting.result());
    }
});

greeting 不再是一个 String,而是一个封装了操作结果的类型。¹ 您需要检查操作是失败还是成功,并据此采取行动。您可以很快想象到这将如何影响我们之前的组合示例。在这个层次上,这不是挑战,而是一场噩梦!

第二种方法使用了两个回调函数:第一个在操作成功时调用,第二个在操作失败时调用(示例 5-9)。

示例 5-9. 对于每个结果使用不同的延续
service.greeting("Luke",
        greeting -> {
            System.out.println(greeting);
        },
        failure -> {
            System.out.println("D'oh! " + failure.getMessage());
        }
);

这种方法清楚地区分了这两种情况,但同时也使得组合变得更加困难(示例 5-10)。

示例 5-10. 使用多个延续和组合动作
service.greeting("Leia",
        greeting1 -> {
            System.out.println(greeting1);
            service.greeting("Luke",
                    greeting2 -> System.out.println(greeting2),
                    failure2 -> System.out.println("D'oh! " + failure2.getMessage())
            );
        },
        failure1 -> {
            System.out.println("D'oh! " + failure1.getMessage());
        }
);

不简单吧?不过,这第二种方法有一个优点。如果我们设想一个接受多个名称的 greeting 服务,它非常适合处理顺序响应(示例 5-11)。

示例 5-11. 单个操作的多个结果
service.greeting(Arrays.asList("Leia", "Luke"),
        greeting -> {    // Called once for Leia, and once for Luke
            System.out.println(greeting);
        },
        failure -> {
            System.out.println("D'oh! " + failure.getMessage());
        }
);

这个例子开始展示一种新的结构:数据流。你将在本书中看到更多这样的内容。这些流可以是应用程序内部的,也可以是来自消息代理的消息传递。在本章中,我们只考虑与响应式应用程序内部相关的流。我们将在第 11 章中介绍如何将这些流连接到各种消息代理。

在 Java 中,为了使用回调表达您的延续,我们经常使用 Java 8 Lambdas。它们在语言中很好地集成了,但我们也看到了这种方法的局限性。回调不太容易组合。因此,我们需要一个更高级别的构造。任何经验丰富的开发人员都会说:未来!

使用 Futures

Future 是一个稍后解析值的占位符。它是异步的;你不知道未来何时获取这个值。只是稍后。当值被设置时,future 允许对其进行反应,例如转换值、实现副作用等等。

它如何帮助我们的异步代码关注?在 Java 中,CompletableFutureCompletionStage,关联的接口,可以表示异步操作的结果。您的 API 返回一个CompletionStage对象,当操作完成时获取结果。返回CompletionStage对象的方法会立即返回,因此不会阻塞调用线程,可以附加后续操作到返回的CompletionStage上;参见 Example 5-12。

Example 5-12. CompletionStage示例 (chapter-5/reactive-programming-examples/src/main/java/org/acme/future/Futures.java)
CompletionStage<String> future = service.greeting("Luke");
注意

此节的完整示例位于chapter-5/reactive-programming-examples/src/main/java/org/acme/future/Futures.java中。

继续可以分成一组阶段来处理、消耗和转换结果,如 Example 5-13 所示。

Example 5-13. 使用CompletionStage进行链式操作 (chapter-5/reactive-programming-examples/src/main/java/org/acme/future/Futures.java)
service.greeting("Luke")
        .thenApply(response -> response.toUpperCase())
        .thenAccept(greeting -> System.out.println(greeting));

Futures 还简化了顺序组合的实现。使用CompletionStage API,您可以使用thenCompose来调用第二个操作(如 Example 5-14 中所示)。

Example 5-14. 使用CompletionStage进行顺序组合 (chapter-5/reactive-programming-examples/src/main/java/org/acme/future/Futures.java)
service.greeting("Luke")
    .thenCompose(greetingForLuke -> {
        return service.greeting("Leia")
                .thenApply(greetingForLeia ->
                        Tuple2.of(greetingForLuke, greetingForLeia)
                );
    })
    .thenAccept(tuple ->
            System.out.println(tuple.getItem1() + " " + tuple.getItem2())
    );

allOf方法允许实现并行组合;参见 Example 5-15。

Example 5-15. 使用CompletableFuture进行并行组合 (chapter-5/reactive-programming-examples/src/main/java/org/acme/future/Futures.java)
CompletableFuture<String> luke = service.greeting("Luke").toCompletableFuture();
CompletableFuture<String> leia = service.greeting("Leia").toCompletableFuture();

CompletableFuture.allOf(luke, leia)
        .thenAccept(ignored -> {
            System.out.println(luke.join() + " " + leia.join());
        });

Futures 使得组合异步操作比回调函数更加简单。此外,futures 封装了结果和失败情况。在CompletionStage中,特定的方法处理失败和恢复,正如你在 Example 5-16 中看到的那样。

Example 5-16. 使用CompletionStage API 从失败中恢复 (chapter-5/reactive-programming-examples/src/main/java/org/acme/future/Futures.java)
service.greeting("Leia")
        .exceptionally(exception -> "Hello");

当使用CompletionStage时,我们开始看到管道的创建:一系列处理事件和异步结果的操作。

你可能想知道,缺少了什么? Futures 似乎完成了所有任务。但是还缺少一项:流。 Futures 无法很好地处理数据流。它们可用于返回单个值的操作,但无法处理像 Example 5-11 中返回序列的函数。

Project Loom:虚拟线程和载体线程

如果你关注 Java 相关的新闻,可能已经听说过 Project Loom。Loom 在 Java 中引入了 虚拟线程 的概念。与常规线程不同,虚拟线程轻量级。一个常规操作系统线程可以执行许多虚拟线程,甚至可以达到百万级。Loom(JVM)管理这些虚拟线程的调度,而操作系统管理载体线程的调度。

其一好处是,你可以在虚拟线程中执行阻塞代码;它不会阻塞载体线程。当虚拟线程执行阻塞调用(如 I/O 调用)时,管理虚拟线程的 Loom 调度器会将该虚拟线程挂起,并运行另一个虚拟线程。因此,载体线程不会被阻塞,可以用来执行另一个虚拟线程。这听起来不错,对吧?

换句话说,你可以使用同步语法编写阻塞代码,而无需关注续行。Loom 为你处理!更棒的是:由于虚拟线程轻量级,你不再需要线程池;可以随时创建新线程。

然而,在撰写本文时,Loom 项目仍在孵化中。² 由于某些并发或阻塞结构尚未得到支持,你可能会意外地阻塞载体线程,而这可能是灾难性的。

不过,为了给出一些想法,让我们看看如何在 Loom 的世界中使用我们的 greeting 服务。首先,greeting 服务的实现可能是阻塞的,并使用阻塞 I/O 与远程服务进行交互。如果调用在虚拟线程上执行,则不会阻塞载体线程,载体线程可以执行另一个虚拟线程。Loom 将阻塞 I/O 调用替换为非阻塞 I/O,并将虚拟线程挂起,直到接收到响应。当响应可用时,挂起的虚拟线程可以继续执行并处理结果。从开发者的角度来看,这一切都是同步的,但在底层并非如此;参见 Example 5-17。

示例 5-17. 创建一个虚拟线程
Thread.startVirtualThread(() -> {
    // We are running on a virtual thread.
    // The service may use blocking I/O, the virtual thread would be parked.
    String response = service.greeting("Luke");
    // Once the response is received, the virtual thread can continue its execution.
    // The carrier thread has not been blocked.
    System.out.println(response);
});

如你所见,这是纯粹的同步代码。因此,顺序组合非常简单(参见 Example 5-18)。

示例 5-18. 使用 Loom 进行顺序组合
Thread.startVirtualThread(() -> {
    String response1 = service.greeting("Luke");
    String response2 = service.greeting("Leia");
    System.out.println("Luke: " + response1);
    System.out.println("Leia: " + response2);
});

这与传统应用程序中使用的方式没有什么不同。不要忘记,虚拟线程会挂起并多次恢复。但是,载体线程不会。

失败管理可以使用 try/catch,因为再次使用同步代码。如果服务调用失败,则作为常规异常抛出(参见 Example 5-19)。

示例 5-19. 使用 Loom 进行异常处理
Thread.startVirtualThread(() -> {
    try {
        String response = service.greeting("Luke");
        System.out.println("Luke: " + response);
    } catch (Exception e) {
        System.out.println("Failed");
    }
});

不幸的是,Loom 并没有为并行组合提供任何特定的构造。你需要像对待 CompletableFuture 一样使用相同的方法,如 例子 5-20 所示。

例子 5-20. 使用 Loom 进行并行组合
ExecutorService executor = Executors.newUnboundedVirtualThreadExecutor();
CompletableFuture<String> future1 = executor.submitTask(()
    -> service.greeting("Luke"));
CompletableFuture<String> future2 = executor.submitTask(()
    -> service.greeting("Leia"));

Thread.startVirtualThread(() -> {
    CompletableFuture.allOf(future1, future2).thenAccept(v -> {
        System.out.println("Luke: " + future1.join());
        System.out.println("Leia: " + future2.join());
    });
});

听起来像魔法,对吧?但是,你看到了;有个小问题……

当您编写同步代码且不阻塞载体线程时,I/O 仍然发生在 I/O 线程上。载体线程不是 I/O 线程(参见 图 5-3)。因此,即使经过优化,多次线程切换也不是免费的。

在幕后发生的线程切换

图 5-3. 在幕后发生的线程切换

此外,诱惑创建大量虚拟线程可能会导致复杂的执行。即使虚拟线程很轻量级,将它们的堆栈存储在内存中可能会导致意外的内存消耗。就像使用许多线程的任何软件一样,它们可能难以理解和调优。话虽如此,Loom 是有前途的。这是否使得响应式变得无意义呢?恰恰相反。Loom 仅解决了开发模型,而不是响应式系统背后的架构概念。此外,同步模型看起来很有吸引力,但并不适用于每种情况,特别是当你需要分组事件或实现基于流的逻辑时。这就是我们下一节要讨论的内容:响应式编程。

响应式编程

首先,什么是 响应式编程?一个常见的定义是:

响应式编程结合了函数式编程、观察者模式和可迭代模式。

ReactiveX 网站

我们从未发现那个定义有帮助——太多模式了,很难清楚地表达响应式编程到底是什么。让我们来做一个更简单的定义:“响应式编程是关于使用异步流进行编程。”

就是这样。响应式编程是关于流,特别是观察它们。它将这一理念推向极限:一切皆为流。这些流可以看作是一个管道,事件在其中流动。我们观察流动的事件——比如项目、失败、完成、取消——并实现副作用(参见 图 5-4)。

响应式编程是关于观察流的

图 5-4. 响应式编程是关于观察流的

响应式编程在某种程度上是观察者模式的一个特化,你观察一个对象(流)并作出反应。由于其异步性质,你不知道事件何时会被看到。然而,响应式编程超越了这一点。它提供了一个工具箱来组合流和处理事件。

使用响应式编程时,一切——是的,一切——都是项目的流。股票市场、用户点击、按键、步骤、节拍……所有这些都是流,并且很容易理解:它们是单个事件的序列。因此,流携带这些事件的每次发生,观察者可以做出反应。

但是响应式编程也考虑异步操作,如 HTTP 请求、RPC 方法调用和数据库插入或查询作为流。因此,一个流不需要携带多个项目;它可以包含一个单一的项目,甚至可能没有!这有点难以想象,但它确实很强大。

使用响应式编程时,你会围绕流结构化你的代码,并构建转换链,也称为管道。事件从上游源流向下游订阅者,经过每个操作符并进行转换、处理、过滤等。每个操作符观察上游并生成一个新的流。但在这个链中有一个重要的点不容忽视。你需要一个最终的订阅者订阅最后的流,并触发整个计算。当这个订阅发生时,最终观察者的直接上游订阅其自身的上游,依次类推,直到达到根源。

让我们回到流的概念。正如我们所提到的,我们将流仅视为响应式应用程序内部的内容。这些流是按时间顺序排列的事件序列。顺序很重要。你按照它们发出的顺序观察它们。

一个流可以发出三种类型的事件(图 5-5):

项目

类型取决于流;它可以是一个步骤、一个点击或来自远程服务的响应。

失败

表示发生了不好的事情,不会再发出更多项目。

完成

表示没有更多项目要发出。

流可以发出三种类型的事件:项目、失败和完成

图 5-5. 流可以发出三种类型的事件:项目、失败和完成。

项目是事件中最频繁的类型。作为观察者,每当流中传输新项目时,你都会收到通知。你可以对其做出反应、转换它、实施副作用等等。

失败是一个错误信号。它表明发生了可怕的事情,并且观察的流无法从中恢复。如果未能正确处理,失败将是一个终端事件,在失败后不会再发出更多项目。你可能会想,为什么我们需要处理失败?因为流是异步的,如果某些事情打断了项目的来源,你应该意识到,并且不再等待额外的项目,因为它们不会到来。至于其他异步开发模型,你无法使用try/catch块,因此你需要观察失败并对其做出反应。例如,你可以记录错误或使用回退项目。

最终,只有在观察有界流时才会发出完成事件,因为无界流永远不会终止。该事件表示流的结束;源(上游)不会再发送任何项目。

每当这些事件在观察的流中传递时,作为观察者的你会收到通知。你会附加处理每个事件的函数,如示例 5-21 所示。

示例 5-21. 订阅流以接收事件(chapter-5/reactive-programming-examples/src/main/java/org/acme/reactive/StreamsExample.java
stream
        .subscribe().with(
            item -> System.out.println("Received an item: " + item),
            failure -> System.out.println("Oh no! Received a failure: " + failure),
            () -> System.out.println("Received the completion signal")
);

要观察流,你需要订阅它。这是响应式编程中的一个关键概念,因为流默认是惰性的。订阅表示你对事件的兴趣。没有订阅:

  • 你不会收到这些项目

  • 你不会告诉流它需要操作

第二点很重要。这意味着一般情况下,如果没有人订阅流,流将不会执行任何操作。这可能看起来很奇怪,但可以帮助你节省资源,并且只在一切准备就绪并且确实需要事件时才开始计算。

操作符

尽管响应式编程是关于流的,但如果没有一个工具箱来操作这些流,它将毫无用处。响应式编程库提供无数的操作符,让你可以创建、组合、过滤和转换流发出的对象。如图 5-6(#image:rp-stream-transform)所示,一个流可以作为另一个流的输入。

转换操作符示例

图 5-6. 转换操作符示例

重要的是要理解,操作符会返回新的流。操作符观察先前的流(称为upstream)并通过结合它们的逻辑和接收到的事件创建一个新的流。例如,来自图 5-6(#image:rp-stream-transform)的transform操作符对每个接收到的项目应用一个函数^(3)并将结果发送给它的下游订阅者(示例 5-22,#reactive-programming::transform)。

示例 5-22. 转换项目(chapter-5/reactive-programming-examples/src/main/java/org/acme/reactive/StreamsExample.java
stream
        .onItem().transform(circle -> toSquare(circle))
        .subscribe().with(
            item -> System.out.println("Received a square: " + item),
            failure -> System.out.println("Oh no! Received a failure: " + failure),
            () -> System.out.println("Received the completion signal")
);

如图 5-7(#image-rp-stream-recover)和示例 5-23(#reactive-programming::recover)所示,操作符也可以处理故障;例如,进行恢复或重试。

从故障中恢复

图 5-7. 从故障中恢复
示例 5-23. 从故障中恢复(chapter-5/reactive-programming-examples/src/main/java/org/acme/reactive/StreamsExample.java
stream
        .onFailure().recoverWithItem(failure -> getFallbackForFailure(failure))
        .subscribe().with(
            item -> System.out.println("Received a square: " + item),
            failure -> System.out.println("Oh no! Received a failure: " + failure),
            () -> System.out.println("Received the completion signal")
);

你可能会想知道为什么recover操作符在恢复后会发出完成事件,如图 5-7 所示。当操作符接收到失败事件时,它知道源不会再发出任何项目,因为失败是终端的。因此,在发出fallback项目后,操作符会发出完成事件。对于下游订阅者来说,就像失败没有发生过,流顺利完成了。

操作符不仅限于同步或单输入单输出类型的转换。操作符可以将单个项目转换为流,或者反过来,丢弃项目,如图 5-8 所示。

操作符发出多个项目或丢弃某些项目的示例

图 5-8. 操作符发出多个项目或丢弃某些项目的示例

此外,操作符可以观察多个上游,将它们合并,例如在图 5-9 中所示,并在示例 5-24 中演示。

合并多个流

图 5-9. 合并多个流
示例 5-24. 合并多个流(chapter-5/reactive-programming-examples/src/main/java/org/acme/reactive/StreamsExample.java
Multi.createBy().merging().streams(circles, squares)
        .subscribe().with(
        item -> System.out.println("Received a square or circle: " + item),
        failure -> System.out.println("Oh no! Received a failure: " + failure),
        () -> System.out.println("Received the completion signal")
);
注意

在上面的示例中,请注意观察者接收到完成事件的时间点。合并操作符在发送完成事件之前会等待所有合并的流都完成,因为此时不会再发出任何项目。这说明了操作符的协调作用。

响应式编程库

Java 拥有许多响应式编程库。在本书中,我们使用的是 SmallRye Mutiny,这是集成在 Quarkus 中的响应式编程库。我们将在第七章深入了解 Mutiny。Project Reactor 和 RxJava 是两个流行的替代方案,提出了类似的概念。

响应式编程不局限于 Java。RX-JS 是 JavaScript 中的一个响应式编程库,经常与 Angular 一起使用。RxPYRxGo 分别为 Python 和 Go 应用程序提供了相同类型的构造。

响应式流和流量控制的需求

使用数据流作为主要构建块并非没有问题。其中一个主要问题是需要流量控制。让我们想象一个快速的生产者和一个慢速的消费者。生产者每 10 毫秒发送一个事件,而下游消费者每秒只能消费一个。运行示例 5-25 中的代码,你会看到它的结局:很糟糕。

示例 5-25. 回压失败示例(chapter-5/reactive-programming-examples/src/main/java/org/acme/streams/BackPressureExample.java
// Ticks is a stream emitting an item periodically (every 10 ms)
Multi<Long> ticks = Multi.createFrom().ticks().every(Duration.ofMillis(10))
        .emitOn(Infrastructure.getDefaultExecutor());

ticks
    .onItem().transform(BackPressureExample::canOnlyConsumeOneItemPerSecond)
    .subscribe().with(
        item -> System.out.println("Got item: " + item),
        failure -> System.out.println("Got failure: " + failure)
);

如果你运行那段代码,你会看到订阅者收到MissingBackPressureFailure,表明下游无法跟上(见示例 5-26)。

示例 5-26. 订阅者收到BackPressureFailure
Got item: 0
Got failure: io.smallrye.mutiny.subscription.BackPressureFailure: Could not
emit tick 16 due to lack of requests

在示例 5-25 中,你可能会对emitOn感到疑惑。这个操作符控制何时使用线程来发出事件。⁴ 当涉及多个线程时,需要反压力,因为在单个线程中,阻塞线程会阻塞源。

那么,我们可以怎么处理这种情况呢?

缓冲项目

第一个自然的解决方案是使用缓冲区。消费者可以缓冲事件,这样就不会失败(见图 5-10)。

缓冲以避免淹没下游消费者

图 5-10. 缓冲以避免淹没下游消费者

缓冲区允许处理小的颠簸,但它们不是长期解决方案。如果你更新你的代码以使用缓冲区,就像示例 5-27 中那样,消费者可以处理更多事件,但最终会失败。

示例 5-27. 使用缓冲区处理溢出(chapter-5/reactive-programming-examples/src/main/java/org/acme/streams/BufferingExample.java
Multi<Long> ticks = Multi.createFrom().ticks().every(Duration.ofMillis(10))
    .onOverflow().buffer(250)
    .emitOn(Infrastructure.getDefaultExecutor());

ticks
    .onItem().transform(BufferingExample::canOnlyConsumeOneItemPerSecond)
    .subscribe().with(
        item -> System.out.println("Got item: " + item),
        failure -> System.out.println("Got failure: " + failure)
);

这里是输出:

Got item: 0
Got item: 1
Got item: 2
Got failure: io.smallrye.mutiny.subscription.BackPressureFailure:
Buffer is full due to lack of downstream consumption

你可以想象增加缓冲区的大小,但很难预测最佳值。这些缓冲区是应用程序本地的,因此使用大缓冲区也会增加内存消耗并降低资源利用效率。更不用说无界缓冲区是个糟糕的主意,因为可能会耗尽内存。

丢弃项目

另一种解决方案是丢弃项目。我们可以丢弃最新接收到的项目或最旧的项目;参见示例 5-28。

示例 5-28. 通过丢弃项目来处理溢出(chapter-5/reactive-programming-examples/src/main/java/org/acme/streams/DropExample.java
Multi<Long> ticks = Multi.createFrom().ticks().every(Duration.ofMillis(10))
        .onOverflow().drop(x -> System.out.println("Dropping item " + x))
        .emitOn(Infrastructure.getDefaultExecutor());

ticks
        .onItem().transform(DropExample::canOnlyConsumeOneItemPerSecond)
        .transform().byTakingFirstItems(10)
        .subscribe().with(
            item -> System.out.println("Got item: " + item),
            failure -> System.out.println("Got failure: " + failure)
);

这里是输出:

// ....
Dropping item 997
Dropping item 998
Dropping item 999
Dropping item 1000
Dropping item 1001
Dropping item 1002
Dropping item 1003
Got item: 9

丢弃项目提供了一个可持续的解决方案,但我们正在失去项目!正如我们在前面的输出中看到的那样,我们可能会丢弃大部分项目。在许多情况下,这是不可接受的。

我们需要另一个解决方案,一个可以调整整体速度以满足管道最慢元素的反压力协议。

什么是反压力?

在力学中,反压是控制液体通过管道流动的一种方法,导致压力降低。这种控制可以使用减压器或弯头。如果你是一名管道工,这非常棒,但目前不清楚它如何对我们有所帮助。

我们可以把我们的流想象成流体的流动,而阶段的集合(操作者或订阅者)形成管道。我们希望尽可能使流体流动起来没有摩擦和波浪。

流体力学的一个有趣特性是下游吞吐量的降低会如何影响上游。基本上,这就是我们需要的:一种让下游操作符和订阅者减少吞吐量的方式,不仅仅在本地,还有在上游。

不要误解;背压并不是 IT 世界的新事物,也不仅限于响应式。背压在 TCP 中有着非常出色的运用。⁵ 数据接收者可以阻塞另一端的写入者,如果它不读取发送的数据。这样,数据接收者永远不会被压倒。但是,需要理解其后果:阻塞写入者可能不是没有副作用的。

引入响应式流

现在让我们专注于另一个背压协议:响应式流。这种异步和背压协议非常适合我们的快速生产者/慢速消费者问题。使用响应式流,消费者,称为Subscriber,从生产者,称为Publisher,请求项目。如图 Figure 5-11 所示,Publisher 不能发送超过请求的项目数量。

使用流控制来避免消费者过载

图 5-11. 使用流控制来避免消费者过载

当接收并处理项目时,消费者可以请求更多项目,依此类推。因此,消费者控制着流量。

注意,响应式流引入了消费者和生产者之间的强耦合。生产者必须监听来自消费者的请求。

为了实现该协议,响应式流定义了一组实体。首先,Subscriber 是一个消费者。它订阅一个称为Publisher的流,该流产生项目(见图 Figure 5-12)。然后,Publisher 异步地发送一个 Subscription 对象给 Subscriber。这个 Subscription 对象是一个合约。通过 SubscriptionSubscriber 可以请求项目,然后在不想要更多项目时取消订阅。每个订阅者订阅一个发布者都会得到一个不同的 Subscription,因此发出独立的请求。发布者的实现负责协调各种请求并向多个订阅者发出项目。

一个和之间交互的示例

图 5-12. SubscriberPublisher 之间交互的示例

Publisher 不能发送超过 Subscriber 请求的项目数量,并且 Subscriber 随时可以请求更多项目。

需要理解的关键是请求和发射不一定是同步发生的。Subscriber 可以请求三个项目,Publisher 将在可用时逐个发送它们。

Reactive Streams 引入了另一个名为Processor的实体。Processor同时是订阅者和发布者。换句话说,它是我们管道中的一个链接,如图 5-13 所示。

展示了、和之间相互作用的示例

图 5-13. 交互示例,展示了SubscriberProcessorPublisher之间的相互作用。

SubscriberProcessor上调用subscribe。在接收到Subscription之前,Processor订阅其自身的上游源(在图 5-13 中称为Publisher)。当上游为我们的Processor提供Subscription时,它可以将Subscription提供给Subscriber。所有这些交互都是异步的。当这个握手完成时,Subscriber可以开始请求项目。Processor负责在其上游和下游之间调解Subscriber的请求。例如,如图 5-13 所示,如果Subscriber需要两个项目,Processor也会向其自身的上游请求两个项目。当然,根据Processor的代码,情况可能并不那么简单。重要的是,每个PublisherProcessor都强制执行流动请求,以防止过载下游订阅者。

警告:这是一个陷阱!

如果你查看 Reactive Streams API⁶,你会发现它看起来很简单:几个类,几个方法。这是一个陷阱!在这种表面简单背后,自己实现 Reactive Streams 实体是一场噩梦。问题不在于接口,而在于协议。Reactive Streams 带来了广泛的规则,以及一个严格的技术兼容性测试套件(TCK),用于验证你的实现是否符合协议。

幸运的是,你不需要自己实现发布者、订阅者或处理器。最近的响应式编程库已经为你实现了协议。例如,Mutiny 的Multi是遵循 Reactive Streams 协议的发布者。所有订阅握手和请求协商都已为你完成。

另外,由于所有这些库都使用相同的核心概念和 API,它允许平滑集成:你可以通过使用 Mutiny 的Subscriber来消费 Reactor 的Flux,反之亦然!除了作为背压协议外,Reactive Streams 还是各种响应式编程库之间的集成层。

分布式系统中的背压

Reactive Streams 在本地节点内运行得很完美,但在分布式系统中呢?在这样的系统中,重要的是事件生产者不要溢出消费者。我们需要流量控制。幸运的是,我们有很多替代方案。

首先,RSocket 提出了一种分布式的响应式流变体。然而,由于分布式系统面临的挑战和潜在的通信中断,协议需要进行一些适应。

AMQP 1.0 使用基于信用的流控制协议。作为生产者,你会得到一定数量的信用。当你的信用用完时,你就无法再发送消息了。代理根据消费者的速度重新填充你的信用。

Apache Kafka 消费者也可以通过使用暂停/恢复周期和显式轮询来实现背压。在这种情况下,Kafka 不会阻止消息的产生。它将消息存储在代理中,并将其用作大型缓冲区。消费者根据其容量轮询消息。

所呈现的 AMQP 1.0 和 Apache Kafka 的机制与反应式流不同。像 Quarkus 这样的框架使用反应式流协议在这些机制之间创建桥梁。

摘要

在本章中,你学到了以下内容:

  • 异步代码很难,但是为了避免丢弃非阻塞 I/O 的好处,这是必需的。

  • 反应式编程是编写异步代码的一种可能性。

  • 反应式编程以数据流作为主要构造。你编写一个处理管道来对来自上游的事件做出反应。

  • 反应式流是反应式的一个重要方面。它避免了你系统中脆弱部分的不堪重负。

在你的系统中出现的细微裂缝可能导致可怕的后果。

现在,你已经有足够的了解来构建自己的反应式系统并体会到其好处。等等!你可能需要一些更具体的细节,不是吗?这就是我们将在第三部分中介绍的内容,我们将探讨如何使用 Quarkus 轻松构建反应式系统。

¹ Vert.x 3 的主要开发模型使用回调。许多操作通过接收 AsyncResult 的回调传递。在 Vert.x 4 中,引入了一个使用 futures 的备用模型。

² 查看Project Loom 网站以获取一般可用性的更新。

³ 在函数式编程中,transform 常被称为 map

⁴ 可以在Mutiny 在线指南中找到有关 emitOn 的更多详细信息。

⁵ 我们建议阅读 Carlos M. Pazos 等人的“使用背压提高具有多个流的 TCP 性能”,该文解释了如何使用 TCP 背压来提高性能。

⁶ 欲知更多信息,请参阅反应式流 Javadoc

第三部分:使用 Quarkus 构建响应式应用程序和系统

第六章:Quarkus:响应式引擎

在第 II 部分中,你学到了关于反应式的许多内容,以及它的各种形式、含义和变化!我知道,你现在可能有点厌倦听到 reactive 这个词了,但这是准确描述 Quarkus 的关键。Quarkus 的核心是其响应式引擎,我们在 “一个响应式引擎” 中对其进行了介绍。没有其响应式引擎核心,Quarkus 将无法实现响应式应用并提供响应式编程的无缝集成。

Quarkus 统一了两种开发模型:命令式和响应式。在本章中,我们将回顾主要区别,并展示 Quarkus 如何处理这种统一。Quarkus 的目标是使它们尽可能相似。如果 API 感觉 相似,那么理解诸如响应式这样的复杂模型就会变得轻松。

在我们深入了解响应式引擎之前,我们需要重新审视命令式和响应式模型。这样做可以让我们有机会欣赏它们如何与 Quarkus 统一。对于那些已经熟悉命令式和响应式模型、它们的工作原理以及各自的优缺点的人来说,可以直接跳到 “命令式和响应式的统一”。

你可能担心我们在重复之前涵盖过的信息。我们可能有点重复,但这都是为了加强这两种模型如何影响应用程序开发方式以及因此框架在提供的模型上有何不同。

首先是命令式模型,大多数 Java 开发人员可能是从这个模型开始他们的职业生涯的。

命令式模型

使用 命令式模型 时,你甚至可能不知道它的名字。那么什么是命令式模型?它通过一系列定义好的命令来改变程序的状态。一个命令在另一个命令之后执行,直到所有命令都执行完毕。

图 6-1 展示了一系列数学命令的顺序执行,直到产生结果(在本例中,如果我们从 0 开始,结果为 10)。正如你在命令式模型中所看到的,定义正确的顺序对于实现所需的结果 10 至关重要。

带有结果 10 的命令

图 6-1. 带有结果 10 的命令

图 6-2 展示了完全相同的命令,但顺序不同。

带有结果 7.5 的命令

图 6-2. 带有结果 7.5 的命令

正如你所见,在命令式模式下,命令的顺序与命令本身一样重要。修改顺序会导致完全不同的程序输出。命令式程序可以被视为从 A 到 B 的过程,当我们已经知道 A 和 B 需要什么时。开发人员只需按照正确的顺序定义从 A 到 B 的步骤,就能实现所需的结果。

在命令式程序中,我们有一个明确的输入和输出,并且我们知道从 A 到 B 所需的步骤。因此,命令式程序很容易理解。当我们有一个明确的输入,知道输出应该是什么,并且知道到达那里的定义步骤时,编写测试就变得更容易,因为可能发生的情况有限且可确定。

命令式编程模型的其他一些方面是什么?由于命令式依赖于一系列命令,资源利用率将始终是主要关注点。在 图 6-1 中显示的示例中,我们不需要大量资源来执行基本的数学计算。但是,如果我们将所有这些操作替换为检索几百条记录的数据库调用,那么影响将很快累积起来。

我们谈论的影响与命令式编程的线程模型有关。如果我们的数据库操作序列使用单个 I/O 线程,同一个 I/O 线程处理 HTTP 请求(虽然不现实但对说明有用),那么在任何时候只能处理一个请求。我们在 第五章 中介绍了 I/O 线程。此外,由于命令式程序的序列是固定的,每个命令必须在下一个命令开始之前完成。这是什么样子?

虽然是人为的,但 图 6-3 说明了数据库程序中每个步骤必须在下一个步骤开始之前完成。更重要的是,只有处理的请求完成时,才能开始后续的请求。在这种情况下,我们能够处理的并发请求数量受到我们为应用程序提供的 I/O 线程数量的限制。

单个 I/O 线程上的数据库程序

图 6-3. 单个 I/O 线程上的数据库程序

现在,如 图 6-4 所示,我们会慷慨地为同一个应用提供两个 I/O 线程!

多个 I/O 线程上的数据库程序

图 6-4. 多个 I/O 线程上的数据库程序

我们可以处理两个并发请求,但仅有两个 I/O 线程,不会有更多。只能处理每个 I/O 线程的单个请求并不理想,所以让我们深入了解内部发生了什么。

检索 DB 记录写入新的 DB 记录 命令都有一段时间的空闲,在 图 6-5 中显示为较浅的部分。在发送请求到数据库和接收响应之间,I/O 线程在做什么?在这种情况下,绝对什么都不做!I/O 线程就坐在那里等待数据库的响应。

I/O 线程延迟的数据库程序

图 6-5. I/O 线程延迟的数据库程序

为什么它什么也没做?I/O 线程在等待时能否执行其他工作?正如我们之前提到的,命令式编程需要按顺序执行命令。因为在等待期间仍在运行检索数据库记录,所以 I/O 线程不知道是否有时间执行其他工作。这就是为什么命令式编程通常与同步执行结合,并且默认情况下同步是命令式编程的执行模型的原因。

有些人可能会想知道 I/O 线程等待是否很重要。I/O 线程等待命令完成的时间可能是几秒钟甚至更长。一个命令式程序大约需要一秒钟完成所有步骤可能还可以接受,但是如果 I/O 线程等待的周期增多,将会显著增加总响应时间。

增加命令式程序完成时间会产生几个影响。I/O 线程上的增加执行时间导致在给定时间段内处理的请求数量减少。还会对内存中缓冲任何等待 I/O 线程可用的传入请求的资源产生额外影响。这些资源影响可能会对应用程序的整体性能造成显著问题。如果一个应用程序处理数百甚至数千个用户,特别是并发用户较少时,可能不会引起注意。然而,处理成千上万个并发用户时会在用户端显示出问题,如连接失败、超时、错误以及各种可能的问题。

要打破命令式程序的同步和阻塞特性,还有其他方法。我们可以使用ExecutorService将工作从 I/O 线程移动到单独的工作池线程中。或者我们可以使用@SuspendedAsyncResponse与 JAX-RS 资源来将工作委托给工作池的线程,使得 HTTP 请求可以从 I/O 线程中暂停,直到在AsyncResponse上设置了响应。暂停 HTTP 请求等待响应有助于在其他请求等待处理响应时在 I/O 线程上处理额外的 HTTP 请求。

尽管这些方法可行,但是代码复杂度增加了,而吞吐量的显著增加并未体现出来,因为我们仍然受制于 I/O 线程的限制——虽然使用@Suspended时不完全是每个线程一个请求的级别,但也没有显著提升。那么,响应式模型有何不同呢?

响应式模型

响应式模型围绕着延续和非阻塞 I/O 的概念构建,正如我们在 “异步代码和模式” 中详细说明的那样。正如前文提到的,这种方法显著增加了并发水平,可以同时处理更多的请求。然而,这并不是免费的,因为它需要开发人员在这些原则基础上开发应用程序时进行额外的思考。

以我们之前的数据库示例为例,如果要删除 I/O 线程的等待时间以提高并发性能,会是什么样子?看看 图 6-6。

I/O 线程上的反应式数据库程序

图 6-6. I/O 线程上的反应式数据库程序

在这里,我们可以看到,I/O 线程不再等待,而是开始处理另一个传入的请求。直到收到数据库响应就会继续这样做。我们如何实现这种分离?我们提供一个延续来处理数据库响应。在接收到数据库响应后,将延续添加到要在 I/O 线程上执行的方法队列中。同样地,处理数据库记录的单个命令被分割成更小的方法,以帮助处理并发性。

图 6-6 展示了利用延续实现的反应式模型如何减少 I/O 线程等待时间并增加同时处理的请求数量。正如您所见,作为开发者,我们需要调整程序开发方式,以反应式模型保持一致。我们需要将工作分解为较小的块,但更重要的是,修改与应用程序外部的任何交互为单独的请求和响应处理。

在 图 6-6 中,我们近似展示了程序的各个部分如何被分割,以防止 I/O 线程等待或被阻塞。Quarkus 使用了事件循环,如“Reactor Pattern and Event Loop”中讨论的那样,来实现反应式模型。事件循环可以像之前在 图 4-7 中展示的那样被视觉化表示。

我们讨论了反应式模型的一些极其有益的方面,但没有任何东西是免费的。随着反应式模型需要将代码执行分离,与命令式模型相反,其中一切都是顺序执行,会在理解程序整体性方面引入复杂性。

程序不再是一系列顺序步骤,而是一系列在不同时间点执行的处理程序,没有预定的顺序。虽然延续可以保证在触发后发生,但在单个请求内的各种异步调用之间,或在多个请求之间,没有任何排序。这种转变要求开发者改变对事件传递的思考方式,并触发相关事件处理程序。代码中不再是一系列依次调用的命令序列。

反应式与命令式的统一

什么是 Quarkus 统一反应式和命令式的含义?我们不是指能够忽略反应式的复杂性或期望命令式提供高并发性能。我们确实指的是以下内容:

  • Quarkus 的反应式核心非阻塞 I/O 对于任何构建在其上的扩展都至关重要。

  • Quarkus 基于 Eclipse Vert.x 工具包的性能提供了一个框架扩展,这是响应式引擎。

  • 开发者选择命令式还是响应式是一个 API 选择,而不是框架选择。

在选择开发应用程序时,通常需要做一个前期选择,是使用响应式还是命令式编程。这个决策需要开发人员和架构师在团队技能、当前业务需求以及最终应用架构方面进行深思熟虑。我们开发者发现选择特定技术栈是最困难的决定之一。即使我们不知道具体的未来需求,我们总是希望考虑应用的未来需求。无论我们如何努力,总会出现新的需求或未预见的问题,需要改变架构甚至设计。

当一个应用需要在需要变更时,提供不受限制的选择方式来改变应用的工作方式时,我们感到更加自在。这是使用 Quarkus 的一个巨大优势。当我们选择 Quarkus 及其命令式和响应式模型的统一时,我们可以自由选择其中之一,两者混合,甚至随时间变更应用的部分模型。

Quarkus 如何无缝支持响应式或命令式模型?支持两种模型的无缝集成是 Quarkus 提供一切的关键基础。建立在 Vert.x 基础上,Quarkus 具有路由层,可以启用任一模型。这是我们部署响应式代码时各层如何协同工作的方式,假设正在处理 HTTP 请求(图 6-7)。

Quarkus 响应式模型

图 6-7. Quarkus 响应式模型

我们可以在图 6-7 中看到请求如何被 Vert.x HTTP 服务器接收,通过路由层,并执行我们的响应式代码。所有这些交互都发生在 I/O 线程上;不需要工作线程。正如前面提到的,使代码在 I/O 线程上执行提供了最高级别的并发性。

注意

在图 6-7 中,只有一个单一的 HTTP 请求正在处理。如果有多个请求,则这些执行将在 I/O 线程上交错执行。

或许你会好奇,执行命令式代码如何改变行为—查看图 6-8。

Quarkus 命令式模型

图 6-8. Quarkus 命令式模型

你可以看到模型没有显著的不同。最大的变化在于我们的代码现在是命令式的性质,会在工作线程上执行,而不是 I/O 线程上。这样,Quarkus 可以执行命令式代码,即一系列顺序命令,而不影响 I/O 线程的并发性。Quarkus 已经转移了命令式执行到工作线程上。

然而,将任务转移到工作线程的过程是有代价的。每当我们在工作线程上执行时,都需要进行一次上下文切换,在执行之前和之后都是如此。在图 6-8 中,我们将这种切换表示为 I/O 线程和工作线程之间边界上的一个圆圈。这些上下文切换会消耗时间和资源,用于执行切换并在新线程中存储信息。

我们已经看到这两种模型在 Quarkus 上的运行方式,但是当我们将它们统一起来时会怎样呢?例如,如果我们有一个需要执行一段阻塞代码的响应式应用程序,我们如何在不阻塞 I/O 线程的情况下做到这一点?在图 6-9 中,我们看到我们的代码同时在 I/O 线程和工作线程上执行!

Quarkus 响应式和命令式模型

图 6-9. Quarkus 响应式和命令式模型

当执行响应式代码时,它在 I/O 线程上运行,但任何命令式代码都在工作线程上执行。Quarkus 为开发人员处理所有这些,而无需他们创建ExecutorsThreads,也无需管理它们。

图 6-9 是我们在“Reactor Pattern and Event Loop”中定义的proactor模式的可视化展示。非阻塞和阻塞处理程序可以共存,只要我们将阻塞执行委托给工作线程,并在阻塞处理程序完成时调用继续执行。

proactor 模式在 Quarkus 中统一了命令式和响应式代码。熟悉开发响应式应用程序的人都知道,有时需要以阻塞或顺序的方式编写代码。Quarkus 的统一允许我们将这样的执行委托给工作线程,通过使用@Blocking来处理 HTTP 中的内容(我们在第八章中介绍),以及在 Reactive Messaging 中的内容(我们在第十章中介绍)。

尽可能利用响应式模型,因此利用 I/O 线程完成尽可能多的工作具有额外的好处。当我们将执行委托给工作线程时,尽可能减少上下文切换的次数。任何时候同一请求的执行从一个线程(如 I/O 线程)移动到另一个线程(如工作线程),或反之,都会带来一定的成本。与请求相关联的任何对象需要从新线程中可用,这会消耗时间和资源来移动它们,还需要为额外的线程分配资源成本。

我们已经详细讨论了 Quarkus 中这些模型的统一方式,但是有哪些扩展使用了这些模型呢?在第八章中涵盖的 RESTEasy Reactive 和第十章中的 Reactive Messaging 都使用了响应式模型。经典的 RESTEasy 和 Spring 控制器则使用命令式模型。

一个响应式引擎

如果您编写过响应式程序或对 Reactive 进行过任何研究,您可能已经了解到了 Vert.x 工具包。如前所述,Quarkus 响应式引擎利用了 Vert.x。除了 Vert.x 和 Netty 外,Quarkus 的路由层形成了响应式引擎的外层。它是扩展的集成部分,协调着将阻塞处理程序卸载到工作线程上,以及执行它们的延续。

此外,所有的响应式客户端都是建立在响应式引擎之上的,以利用非阻塞处理。一旦使用阻塞客户端,响应式应用程序就不再是响应式的,这是开发人员经常忽视的一个关键方面。Quarkus 努力确保应用程序可能需要的所有客户端都建立在响应式引擎上,以实现真正的响应式集成。

注意

默认情况下,Quarkus 中的所有内容都是响应式的。开发人员必须决定他们想要响应式还是命令式。我们所说的 所有 是什么意思?它包括 HTTP 处理、使用 AMQP 和 Kafka 的事件驱动应用程序,以及 Quarkus 提供的 所有 内容。

一种响应式编程模型

SmallRye Mutiny 是 Quarkus 的响应式编程库。您已经在 “Reactive Programming” 中了解了它,我们将在 第七章 中学到更多内容,因此我们不会在这里详细介绍。

简而言之,Mutiny 围绕三个关键方面构建:

事件驱动

监听来自流的事件并适当处理它们。

易于导航的 API

API 的导航由事件类型和该事件的可用选项驱动。

仅两种类型

MultiUni 可以处理任何所需的异步操作。

有一点需要注意的是 Mutiny 类型的惰性。除非订阅者请求它们,否则事件不会开始流经数据流。这是一个很棒的功能,可以防止流在没有人监听时消耗资源,但开发人员需要注意这一点,以免忘记订阅!

所有 Quarkus 响应式 API 都使用 MultiUni。这种方法有助于将 Quarkus 扩展与响应式编程和 Mutiny 无缝集成。让我们看看使用 Mutiny 的示例。

使用 PostgreSQL 响应式客户端的 Quarkus 响应式应用程序从数据库中使用 Multi 检索 Fruit 对象,如 示例 6-1 所示。

示例 6-1. 响应式 Mutiny 客户端
client.query("SELECT id, name FROM fruits ORDER BY name ASC").execute()        ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/1.png)
    .onItem().transformToMulti(rowSet -> Multi.createFrom().iterable(rowSet))  ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/2.png)
    .onItem().transform(row -> convertRowToFruit(row));                        ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/3.png)

1

clientPgPool 的一个实例,是使用 Mutiny 和 Vert.x 构建的 PostgreSQL 响应式客户端。

2

当收到一个 RowSet 项时,将单个 RowSet 转换为 Multi<Row>

3

Multi 中的每个 Row 转换为一个 Fruit 实例。执行的结果是 Multi<Fruit>

鉴于我们在本书中讨论响应式,所有剩余章节都有使用 Mutiny 在多种情况下的示例。我们在第八章中介绍了响应式 HTTP 端点及其在第十二章中的消费。我们还包括了 Quarkus 和 Mutiny 在第九章中的响应式数据访问,包括许多示例。

使用 Quarkus 的事件驱动架构

尽管使用 Quarkus 构建响应式应用程序很棒、高效且有趣,但我们希望不仅仅是构建单个应用程序。我们需要一个响应式系统,正如在第四章中所述,将小型应用程序组合成一个协调的分布式系统。为了支持这样的架构,Quarkus 必须接收和产生事件,这就是事件驱动架构!Quarkus 通过使用响应式消息来实现这一点,如示例 6-2 所示。响应式消息集成了各种消息传递技术,如 Apache Kafka、AMQP 等,开发人员可以通过注解指定方法是接收还是产生事件。

示例 6-2. 响应式消息
@Incoming("prices")
@Outgoing("quotes")                                         ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/1.png)
public Quote generatePrice(Price p) {                       ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/2.png)
    return new Quote(p, "USD");
}

1

prices通道中读取消息。

2

将每个Price转换为一个Quote

提供的开发模型允许轻松消费、转换和生成消息。@Incoming注解表示消费通道中传递的每个Price。响应式消息在配置的通道中为每个通过的Price调用方法。@Outgoing注解指示将结果写入的通道。

有关响应式消息的完整详细信息,请参阅第十章。

摘要

本章介绍了命令式模型,一系列顺序命令,以及响应式模型,利用续体和非阻塞 I/O。

我们已经看到了以下内容:

  • 图 6-8、6-7 和 6-9 展示了两种模型如何使用线程,通过响应式模型提供了改进的并发性能。

  • Quarkus 如何统一这些模型,允许开发人员在不切换框架的情况下增强应用程序,引入响应式方面,随着应用程序的增长和扩展。

  • 我们如何在 Quarkus 中使用响应式编程。

在接下来的章节中,我们将探讨 Quarkus 的各种响应式方面,如第八章中的 HTTP 和 RESTEasy Reactive,以及第九章中的响应式数据访问。但首先,让我们深入了解 Mutiny 响应式编程 API。

第七章:Mutiny:一个基于事件驱动的响应式编程 API

在第五章中,我们介绍了响应式编程及其如何帮助实现响应式应用程序。然后,在第六章中,我们讨论了 Quarkus 如何使用 Mutiny 来实现响应式应用程序。本章重点介绍 Mutiny 本身。¹

本章介绍了 Mutiny 的概念和常见模式,这将帮助您理解接下来的几章。Mutiny 是 Quarkus 中用于每个与响应式相关的功能的 API。当深入学习使用 Quarkus 构建响应式应用程序和系统时,您将看到更多关于 Mutiny 的内容。

为什么需要另一个响应式编程库?

这是一个很好的问题!正如您在第五章中所看到的,其他流行的响应式编程库已经存在。那么为什么还要另一个呢?

过去几年中,我们观察到开发人员如何开发响应式系统并使用响应式编程库。通过这些经验,我们发现了开发人员所面临的挑战。简而言之,响应式编程难以学习和理解。编写和维护响应式代码会带来重大负担,从而减缓采用响应式方法的速度。

当我们观察响应式编程的使用时,立即可以看到其陡峭的学习曲线,这使得响应式编程局限于顶尖开发人员。事实上,响应式编程的功能编程根基既优雅又限制。并非每个开发人员都具备函数背景。我们看到开发人员在mapflatMap的迷宫中迷失,试图找到出路。

即使对经验丰富的开发人员来说,某些概念仍然抽象且令人困惑。例如,在传统的响应式编程库中,flatMapconcatMap之间的区别导致了许多错误,包括生产故障。这些响应式编程库需要具备函数背景和对可用操作符的良好理解。掌握数百个操作符需要时间。

另一个方面是 API 建模。现有的库通常实现响应式扩展 (ReactX)并提供具有数百个方法的 Java 类。即使使用现代 IDE,找到正确的方法也像大海捞针。通常需要滚动查看方法列表以找到合适的方法,甚至不看方法名称而查看签名,希望能找到最佳方法。

最后,这是一个更为哲学性的方面,现有的响应式编程库并不反映响应式原则的事件驱动性质。虽然它使用数据流,这些是异步构造,但 API 并未传达事件的概念。响应式架构应该有助于实现基于事件的过程,而这些方法存在不足,需要在业务过程和其实现之间进行额外的心智映射。

为了解决这些问题,我们决定创建 Mutiny,一个新的响应式编程 API,专注于可读性、维护性,并将 事件 的概念置于中心位置。

Mutiny 的独特之处在哪里?

Mutiny 是一个直观的、事件驱动的 Java 响应式编程库。Mutiny 使用 事件 的概念来传达某事发生了。这种事件驱动的特性完美适配了分布式系统的异步性质,正如在 第三章 中所述。使用 Mutiny,当事件发生时,你会收到通知并对其作出反应。因此,Mutiny 围绕 onItemonFailureon 方法组织自己。每个方法让你表达当接收到事件时想要执行的操作。例如,onItem.transform 接收一个项事件并对其进行转换,或者 onFailure.recoverWithItem 在失败事件后使用备用项进行恢复。

我们希望解决 API 导航问题,避免 一个类包含数百个方法 的模式。我们引入了方法 的概念。每个组处理特定类型的事件。在 onItem 组中,你可以找到处理单个项事件的所有方法,比如转换它 (onItem.transform) 或调用方法 (onItem.invoke)。在 onFailure 组中,你可以找到处理失败和恢复的方法,比如 onFailure.recoverWithItemonFailure.retry。结果的 API 更易读、易懂和易于导航。作为用户,你可以选择组并跨有限数量的方法进行导航。

Mutiny 的 API 不是简洁的。我们更看重可读性和理解性,而不是简洁性。多年来,我们多次听到响应式编程很难,只有高级开发人员或架构师才能使用。这是响应式采纳的一个关键障碍。在设计 Mutiny 时,我们希望避免创建一个精英主义的库。这并不是要“降低”响应式编程背后的思想,而是剥离数学术语的目标。

在 Quarkus 中使用 Mutiny

为了说明在使用 Mutiny 时可能看到的常见模式,我们需要一个例子。该应用是一个简单的商店,处理用户、产品和订单(图 7-1)。用户创建包含产品列表的订单。应用的实现方式对本章来说并不重要。第 8 和第九章将介绍 Mutiny 在 Quarkus 的 HTTP 和数据部分的集成。

购物应用架构

图 7-1. 店铺应用架构

应用程序使用非阻塞数据库客户端以避免与数据库集成时阻塞。因此,OrderServiceUserServiceProductService 的 API 都是异步的,并使用 Mutiny 类型。实现 HTTP API 的 ShopResource 也使用 Mutiny 来查询服务并组成响应。

代码位于 chapter-7/order-example 目录中,可以使用 mvn quarkus:dev 启动。代码使用端口 8080 提供 HTTP API。

Uni 和 Multi

Mutiny 提供两个主要类:UniMulti。² Uni 表示异步操作或任务。它可以发出单个项目或失败,如果所表示的操作失败。Multi 表示项目流。它可以传输多个项目,以及终端失败或完成事件。

让我们看两个使用案例,以更好地理解它们的差异。假设您想从数据库中检索单个用户(由 UserProfile 类表示)。您将使用 Uni<UserProfile>,如 示例 7-1 所示。

示例 7-1. Uni 的示例 (chapter-7/order-example/src/main/java/org/acme/ShopResource.java)
Uni<UserProfile> uni = users.getUserByName(name);
return uni
        .onItem().transform(user -> user.name)
        .onFailure().recoverWithItem("anonymous");

您可以将逻辑附加到 Uni,以便在其发出事件时做出反应。在前面的片段中,当 UserProfile 可用时,我们提取用户的名称。如果发生故障,我们使用备用值恢复。onItemonFailure 组形成了 Uni API 的核心。

Multi 可以发出 0、1、n 或无限多个项目。它还可以发出失败,这是一个终端事件。最后,当没有更多项目要发出时,Multi 发出完成事件。因此,API 稍有不同。现在让我们想象我们需要所有的用户。对于这种情况,您将使用 Multi,如 示例 7-2 所示。

示例 7-2. Multi 的示例 (chapter-7/order-example/src/main/java/org/acme/ShopResource.java)
Multi<UserProfile> users = this.users.getAllUsers();
return users
        .onItem().transform(user -> user.name);

对于 Uni,您可以处理事件。在片段中,您提取每个用户的名称。因此,与 示例 7-1 不同,代码可以多次调用转换。虽然 API 类似,但 Multi 提议特定的组来选择、丢弃和收集项目;请参阅 示例 7-3。

示例 7-3. 使用 Multi 的代码示例 (chapter-7/mutiny-examples/src/main/java/org/acme/MultiApi.java)
Multi<UserProfile> multi = users.getAllUsers();
multi
        .onItem().transform(user -> user.name.toLowerCase())
        .select().where(name -> name.startsWith("l"))
        .collect().asList()
        .subscribe().with(
                list -> System.out.println("User names starting with `l`" + list)
);

如您所见,这两种类型都是事件驱动的。但它们处理的事件集合不同。API 反映了这些差异。UniMulti 不提供相同的组集,因为有些组是特定于每种情况的(表 7-1)。

表 7-1. UniMulti 使用场景

事件 使用场景 实现响应式流
Uni 项目和失败 远程调用,返回单一结果的异步计算
Multi 项,失败,完成 数据流,潜在无限(发出无限数量的项)

UniMulti的一个重要特性是它们的惰性。持有UniMulti实例的引用并不会产生任何效果。在示例 7-3 中,直到有人显式订阅(表达了兴趣)才会发生任何事情,就像在示例 7-4 中展示的那样。

示例 7-4. 订阅UniMultichapter-7/order-example/src/main/java/org/acme/UniMultiExample.java
Uni<UserProfile> uni = users.getUserByName("leia");
Multi<UserProfile> multi = users.getAllUsers();

uni.subscribe().with(
        user -> System.out.println("User is " + user.name),
        failure -> System.out.println("D'oh! " + failure)
);

multi.subscribe().with(
        user -> System.out.println("User is " + user.name),
        failure -> System.out.println("D'oh! " + failure),
        () -> System.out.println("No more user")
);

要订阅(从而触发操作),你需要使用subscribe组。在 Quarkus 中,你可能不需要订阅,因为如果你返回UniMulti给 Quarkus,它会自动为你订阅。例如,你可以在示例 7-5 中看到 HTTP 方法的展示。

示例 7-5. Quarkus 处理 HTTP 方法的订阅(chapter-7/order-example/src/main/java/org/acme/ShopResource.java
@GET
@Path("/user/{name}")
public Uni<String> getUser(@PathParam("name") String name) {
    //tag::uni[]
    Uni<UserProfile> uni = users.getUserByName(name);
    return uni
            .onItem().transform(user -> user.name)
            .onFailure().recoverWithItem("anonymous");
    //end::uni[]
}

当匹配的 HTTP 请求到达时,Quarkus 调用此方法并订阅生成的Uni。只有当Uni发出项或失败时,Quarkus 才会编写 HTTP 响应。

Mutiny 和流控制

正如表 7-1 所示,Multi实现了响应式流背压协议。换句话说,它实现了响应式流的Publisher接口,而Multi的消费者是响应式流的Subscriber。但Uni不是这种情况。

处理发送多个项的流(如Multi)时,支持背压是有意义的。在底层,订阅者可以通过在可以处理时请求项来控制流和节奏。这样可以避免向订阅者发送过多的项。

处理Uni时,订阅它足以表达你对处理发出项的兴趣和能力。无需发送另一个请求信号来表达你的兴趣。

但是,正如你在第五章中看到的,不是每个流都支持流控制。表示来自物理世界事件的流,如用户点击或时间,无法减慢。在这种情况下,向订阅者发送太多事件的风险很高。这就是为什么Multi提供了onOverflow组。该组监控上游源发出的项数以及下游订阅者请求的项数。当进入项比请求多时,Multi会发出溢出事件。onOverflow组允许配置发生此情况时的期望行为。

为了说明这一点,让我们想象一下一个产生产品推荐的流。每秒钟,它发送一个新的推荐产品。但时间无法减慢,所以我们无法应用背压。如果下游无法跟上,我们可能会发生溢出。为了避免这种情况,如果由于请求不足而无法发出下一个项,我们会直接丢弃它,就像在示例 7-6 中展示的那样。

示例 7-6. 使用 Multi 处理溢出 (chapter-7/order-example/src/main/java/org/acme/ShopResource.java)
public Multi<Product> getRecommendations() {
    return Multi.createFrom().ticks().every(Duration.ofSeconds(1))
            .onOverflow().drop()
            .onItem().transformToUniAndConcatenate(
                x -> products.getRecommendedProduct());
}

onOverflow 组提供其他可能性,例如缓冲项。

观察事件

一旦你有 UniMulti 实例,观察这些实例发出的事件是很自然的。对于每种类型的事件,当它看到匹配的事件时会调用一个 invoke 方法;参见 示例 7-7。

示例 7-7. 观察事件 (chapter-7/mutiny-examples/src/main/java/org/acme/MultiObserve.java)
multi
        .onSubscribe().invoke(sub -> System.out.println("Subscribed!"))
        .onCancellation().invoke(() -> System.out.println("Cancelled"))
        .onItem().invoke(s -> System.out.println("Item: " + s))
        .onFailure().invoke(f -> System.out.println("Failure: " + f))
        .onCompletion().invoke(() -> System.out.println("Completed!"))
        .subscribe().with(
                item -> System.out.println("Received: " + item)
);

invoke 方法不修改事件;你观察它而不改变它。下游接收与你相同的事件。需要在实现副作用或跟踪代码时非常有用。例如,我们可以用它来记录创建新用户或创建失败的情况(见 示例 7-8)。

示例 7-8. 观察 Uni 事件 (chapter-7/order-example/src/main/java/org/acme/ShopResource.java)
@POST
@Path("/users/{name}")
public Uni<Long> createUser(@QueryParam("name") String name) {
    return users.createUser(name)
            .onItem().invoke(
                l -> System.out.println("User created: " + name + ", id: " + l))
            .onFailure().invoke(t -> System.out.println(
                    "Cannot create user " + name + ": " + t.getMessage())
            );
}

如果应用程序正在运行,可以使用 示例 7-9 来运行此代码。

示例 7-9. 调用 users 端点
> curl -X POST http://localhost:8080/shop/users?name=neo

转换事件

大多数情况下,我们需要转换事件。让我们首先看看如何同步转换事件。你接收事件,进行转换,并作为新事件产生结果。对于每种类型的事件,在看到匹配事件时调用 transform 方法,如 示例 7-10 所示。

示例 7-10. 转换事件 (chapter-7/mutiny-examples/src/main/java/org/acme/MultiTransform.java)
Multi<String> transformed = multi
        .onItem().transform(String::toUpperCase)
        .onFailure().transform(MyBusinessException::new);

invoke 不同,transform 生成一个新的事件。它调用传递的函数并将结果发送给下游订阅者。

transform 的同步性质很重要。收到事件后,transform 调用转换逻辑并向下游发出结果。如果转换逻辑花费了很长时间,transform 会等待直到逻辑终止。因此,当转换足够快时使用 transform

例如,以下方法检索产品列表,一致大写它们的名称,并构建表示 (ProductModel)。对于每个产品,transform 提取名称并应用转换。这个同步过程很快。它立即向下游发出结果,如 示例 7-11 所示。

示例 7-11. 转换产品 (chapter-7/order-example/src/main/java/org/acme/ShopResource.java)
@GET
@Path("/products")
public Multi<ProductModel> products() {
    return products.getAllProducts()
            .onItem().transform(p -> captializeAllFirstLetter(p.name))
            .onItem().transform(ProductModel::new);
}

链接异步操作

transform 方法可以同步处理事件,但如果我们需要调用异步过程怎么办?想象一下,你收到一个事件,需要调用远程服务或与数据库交互。你不能使用 transform,因为这些方法应该是异步的(否则会阻塞,这违反了响应式原则)。你需要等待异步计算完成。

我们如何用 Mutiny 的术语来表达这个?这意味着转换一个事件,但不像transform返回一个普通结果那样,它返回一个异步结构:另一个UniMulti。看一下示例 7-12 是什么样子。

示例 7-12. 链式异步操作(chapter-7/mutiny-examples/src/main/java/org/acme/UniTransformAsync.java
uni
    .onItem().transformToUni(item -> callMyRemoteService(item))
    .subscribe().with(s -> System.out.println("Received: " + s));

uni
    .onItem().transformToMulti(s -> getAMulti(s))
    .subscribe().with(
        s -> System.out.println("Received item: " + s),
        () -> System.out.println("Done!")
);

transformToUnitransformToMulti提供了生成UniMulti实例的能力。³ 当你接收到一个项时,Mutiny 会调用返回UniMulti的函数。然后,从这个UniMulti向下游发出事件。在示例 7-13 中,我们检索特定用户(通过其名称)的订单列表。

示例 7-13. 检索特定用户的订单(chapter-7/order-example/src/main/java/org/acme/ShopResource.java
@GET
@Path("/orders/{user}")
public Multi<Order> getOrdersForUser(@PathParam("user") String username) {
    return users.getUserByName(username)
            .onItem().transformToMulti(user -> orders.getOrderForUser(user));
}

这段代码检索用户,当接收到用户时,然后代码检索订单。getOrderForUser方法返回Multi<Order>,因此结果是Multi<Order>

如果你仔细看前面的代码,你会说:“嘿!你忘了一些东西!当我们从Multi而不是Uni链式调用时,它是如何工作的?” 你是对的;我们需要讨论Multi情况。你如何将Multi的每个项转换为另一个UniMulti

举个例子来说明这个问题。假设你需要检索所有用户的订单。因此,不像示例 7-13 中那样只需要用户名,我们需要检索所有用户,并且对于每个用户检索订单。示例 7-14 展示了生成的代码。

示例 7-14. 使用concatenate检索每个用户的订单(chapter-7/order-example/src/main/java/org/acme/ShopResource.java
@GET
@Path("/orders")
public Multi<Order> getOrdersPerUser() {
    return users.getAllUsers()
            .onItem().transformToMultiAndConcatenate(
                user -> orders.getOrderForUser(user));

}

你可以立即发现一个不同点。不是transformToMulti,而是transformToMultiAndConcatenate。但为什么AndConcatenate?这与向下游发送的项的顺序有关。它获取第一个用户的Multi,将项发送到下游,然后处理下一个用户的Multi,依此类推。换句话说,它逐个获取Multi实例并将它们连接起来。这种方法保留了顺序,但同时也限制了并发性,因为我们一次只检索一个用户的订单。

如果你不需要保留顺序,可以使用transformToMultiAndMerge方法。⁴ 在这种情况下,它并发地调用getOrderForUser。它将从生成的Multi中合并项目,因此可能会交错来自不同用户的订单(示例 7-15)。

示例 7-15. 使用合并检索每个用户的订单
@GET
@Path("/orders")
public Multi<Order> getOrdersPerUser() {
    return users.getAllUsers()
        .onItem().transformToMultiAndMerge(user -> orders.getOrderForUser(user));
}
注意

chapter-7/mutiny-examples/src/main/java/org/acme/MultiTransformAsync.java 让您执行这些示例。它们突出了订单差异。

从失败中恢复

正如我们所说的,失败是不可避免的。我们必须处理它们。

在这种不幸的情况下可以做什么呢?在 Mutiny 的世界中,失败是一种事件。因此,您可以观察并处理它们。您可以像处理任何其他事件一样使用invoketransform。但您也可以优雅地处理失败并进行恢复。

最常见的方法之一是使用特定的备用项目进行恢复。假设我们想创建一个新用户,但由于名称需要唯一,插入操作失败了。我们可以返回一个指示失败的消息,如示例 7-16 所示。

Example 7-16. 使用备用项目从失败中恢复 (chapter-7/order-example/src/main/java/org/acme/ShopResource.java)
public Uni<String> addUser(String name) {
    return users.createUser(name)
            .onItem().transform(id -> "New User " + name + " inserted")
            .onFailure().recoverWithItem(
                failure -> "User not inserted: " + failure.getMessage());
}

失败虽然是一个事件,但它是终端事件。如果你正在处理Uni,你将得到的不是项目,而是失败。所以,Uni会用备用项目替换失败。而对于Multi,在失败后你将不再得到任何项目。恢复操作会发出备用项目,然后是完成事件。

另一个常见的可能性是重试。请记住,只有在您的系统可以忍受时才能重试。在这种情况下(只有在这种情况下),您可以重试。重新订阅上游源以重试,如 Example 7-17 所示。

Example 7-17. 失败时重试
public Uni<String> addUser(String name) {
    return users.createUser(name)
            .onItem().transform(id -> "New User " + name + " inserted")
            .onFailure().retry().atMost(3);
}

您可以通过使用atMost限制重试次数或在它们之间引入延迟,并配置后退;参见 Example 7-18。

Example 7-18. 在失败时最多重试 n 次,并在尝试之间延迟 (chapter-7/mutiny-examples/src/main/java/org/acme/UniFailure.java)
Uni<String> retryAtMost = uni
        .onFailure().retry()
            .withBackOff(Duration.ofSeconds(3))
            .atMost(5);
注意

chapter-7/mutiny-examples/src/main/java/org/acme/UniFailure.java 让您执行这些示例以帮助您理解各种可能性。

onFailure组中包含了更多的可能性。

结合和连接项目

下一个常见的模式是从多个上游源组合项目。例如,想象一下我们想要生成推荐。我们将选择一个随机用户和一个推荐的产品。我们可以顺序执行两者,但它们是独立的,所以我们可以并行执行它们,并在获得两个结果时生成推荐(Example 7-19)。

Example 7-19. 结合 Uni 实例 (chapter-7/order-example/src/main/java/org/acme/ShopResource.java)
@GET
@Path("/random-recommendation")
public Uni<String> getRecommendation() {
    Uni<UserProfile> uni1 = users.getRandomUser();
    Uni<Product> uni2 = products.getRecommendedProduct();
    return Uni.combine().all().unis(uni1, uni2).asTuple()
            .onItem().transform(tuple -> "Hello " + tuple.getItem1().name +
                    ", we recommend you "
                    + tuple.getItem2().name);
}

此片段获取两个Uni。第一个检索一个随机用户,第二个获取一个推荐的产品。然后我们将两者组合并将它们的结果聚合成一个元组。当两个操作都完成时,Mutiny 将项目收集到一个元组中,并将此元组向下游传播。如果Uni失败,则向下游传播失败。

在我们希望并发执行操作并连接其结果时,结合Uni操作是很常见的。但我们也可以对Multi执行相同操作。在这种情况下,我们关联几个Multi操作的项目。例如,我们可以生成一个推荐流,关联随机用户和推荐产品,如示例 7-20 所示。

示例 7-20. 连接Multichapter-7/order-example/src/main/java/org/acme/ShopResource.java
@GET
@Path("/random-recommendations")
public Multi<String> getRandomRecommendations() {
    Multi<UserProfile> u = Multi.createFrom().
        ticks().every(Duration.ofSeconds(1)).onOverflow().drop()
        .onItem().transformToUniAndConcatenate(
            x -> users.getRandomUser());
    Multi<Product> p = Multi.createFrom().ticks().every(
        Duration.ofSeconds(1)).onOverflow().drop()
        .onItem().transformToUniAndConcatenate(
            x -> products.getRecommendedProduct());

    return Multi.createBy().combining().streams(u, p).asTuple()
            .onItem().transform(tuple -> "Hello "
                    + tuple.getItem1().name
                        + ", we recommend you "
                    + tuple.getItem2().name);
}

当连接Multi时,只要连接的一个Multi发送完成事件,结果流就会完成。实际上,不能再组合项目了。

选择项目

处理Multi时,您可能希望选择要向下传播的项目并丢弃其他项目。例如,我们可以检索所有订单,然后仅选择包含超过三个产品的订单(参见示例 7-21)。

示例 7-21. 选择项目(chapter-7/order-example/src/main/java/org/acme/OrderService.java
public Multi<Order> getLargeOrders() {
    return getAllOrders()
            .select().where(order -> order.products.size() > 3);
}

select.where操作允许您选择项目。对于每个项目,该操作调用谓词并决定是否应将项目传播到下游。它会丢弃未通过谓词的项目。

select.when操作的异步变体也可用。它允许您选择要保留的项目,使用异步谓词。示例 7-22 展示了如何选择特定用户名的订单。对于每个订单,代码检索关联的用户,并且仅当用户名匹配时才选择。

示例 7-22. 使用异步谓词选择项目(chapter-7/order-example/src/main/java/org/acme/OrderService.java
public Multi<Order> getOrdersForUsername(String username) {
    return getAllOrders()
            .select().when(order ->
                    users.getUserByName(username)
                        .onItem().transform(u -> u.name.equalsIgnoreCase(username))
            );
}

选择也可以删除任何重复的项目。在示例 7-23 中,我们列出了订购的产品。

示例 7-23. 选择不同的项目(chapter-7/order-example/src/main/java/org/acme/ProductService.java
public Multi<Product> getAllOrderedProducts() {
    return orders.getAllOrders()
            .onItem().transformToIterable(order -> order.products)
            .select().distinct();
}

对于每个订单,我们检索产品并生成Multi<Product>。然后,我们仅选择不同的项目,丢弃重复项。请注意,我们无法在无界流上使用distinct,因为它需要在内存中保留所有已经看到的项目。

除了选择之外,Mutiny 还提供了一个skip组。它提供相反的功能,因此允许跳过与谓词和重复匹配的项目。

收集项目

最后,在处理有界的Multi时,您可能希望将项目累积到列表或集合中。当Multi完成时,结果结构会被发出。

让我们重复使用前面的示例。我们知道订购产品的集合是有界的,因此,我们可以将产品收集到列表中,如示例 7-24 所示。

示例 7-24. 将项目收集到列表中(chapter-7/order-example/src/main/java/org/acme/ProductService.java
public Uni<List<Product>> getAllOrderedProductsAsList() {
    return getAllOrderedProducts()
            .collect().asList();
}

请注意,该方法返回Uni<List<Product>>。当由getAllOrderedProducts返回的Multi完成时,方法会发出包含产品的列表。

collect 组提供其他方法来将项目聚合到映射、集合或事件中,使用您自己的收集器(示例 7-25)。

示例 7-25. 其他收集方法
Uni<List<String>> itemsAsList = multi.collect().asList();
Uni<Map<String, String>> itemsAsMap = multi.collect().asMap(item ->
    getKeyForItem(item));
Uni<Long> count = multi.collect().with(Collectors.counting());

总结

本章是对 Mutiny API 的简要介绍。它没有提供完整的概述,但介绍了我们将在本书后续使用的关键模式。记住:

  • Mutiny 是一个事件驱动的响应式编程 API。

  • 您可以观察和转换事件。

  • Mutiny 提供了两个主要的类:UniMulti

  • Mutiny API 提供了导航功能,并为您选择正确的操作符提供了指导。

有了这个理解,我们现在可以开始使用 Quarkus 提供的响应式服务和设施。

¹ Quarkus 集成了 Mutiny,这是一个可以嵌入到任何地方的独立项目。

² Mutiny 的名称来源于 MultiUni 的缩写。

³ transformToUnitransformToMulti 操作在传统的响应式编程库中通常被称为 flatMap

transformToMultiAndConcatenate 在传统的响应式编程库中称为 concatMaptransformToMultiAndMerge 通常被称为 flatMap

第八章:反应式 HTTP

即使构建反应式系统,HTTP 也是不可避免的。HTTP 是一种普遍的协议,例如 REST 是一种设计服务和 API 的众所周知的方法。关于 HTTP 的问题,如 第四章 所述,是导致不期望的时间耦合的请求/响应交互方案。此外,要实现空间解耦,通常需要代理来路由请求或先进的服务发现和负载平衡机制。

但让我们面对现实:我们需要务实,而且 HTTP 有很多强大的特性。我们建议在系统的边缘使用 HTTP(与外部实体交互的地方),如 图 8-1 所示。例如,HTTP 经常用于前端层,以便轻松地向其他外部服务提供可消费的 API。此外,我们经常在与其他外部服务的各种集成点使用 HTTP,例如使用 REST API 消费服务。

集成 HTTP 不应该阻止或限制您正在构建的反应式系统的响应能力。因此,我们需要仔细实现这种集成。看到使用所谓的异步 HTTP 客户端的系统并不罕见,但这可能比提供好处更有害,因为它可能依赖于隐藏的线程池。

在反应式系统的边缘使用 HTTP

图 8-1. 在反应式系统的边缘使用 HTTP

本章探讨了 Quarkus 提供的功能来公开 HTTP 端点以及我们可以实现这些端点的方法。在 图 8-1 中,这一部分用虚线圈出;右侧的 HTTP 服务消费在 第十二章 中有所涵盖。

HTTP 请求的旅程

要理解使用 Quarkus 以反应式方式处理 HTTP 的好处,我们需要深入了解。如 第六章 所示,Quarkus 基于反应式引擎,因此 Quarkus 的每个方面都从该引擎中受益,提供异步和非阻塞特性。当然,这也包括 HTTP。然而,虽然我们在以前的 Quarkus 应用程序中实现了 HTTP 端点,但代码并没有从引擎提供的所有功能中受益。让我们看看 Quarkus 如何处理 HTTP 请求,以及我们在哪里可以释放反应式引擎的力量。

要处理 HTTP 请求,您需要一个 HTTP 服务器。该服务器监听特定端口(例如 Quarkus 中的 8080)并等待传入连接。当服务器接收到新连接时,它读取帧并组装 HTTP 请求。通常,服务器解析 HTTP 方法(例如 GETPOST)、调用的路径、正文等。多个帧可以组成一个 HTTP 请求,大型正文则分割成多个帧。

一旦 HTTP 请求被组装,Quarkus 就确定如何处理它。它检查 拦截器(处理安全或日志记录问题)并寻找可以处理请求的端点。此查找基于路径,但也可以包括内容类型协商。一旦找到端点方法,Quarkus 调用该方法,方法负责处理请求。

假设我们调用一个同步方法,并且方法的结果是 HTTP 响应的有效载荷。Quarkus 捕获该结果并构建 HTTP 响应。然后将响应写入适当的 HTTP 连接,根据内容进行编码。

到目前为止,一切都很好,但是不够响应式,对吧?在这个交换中,一个重要的部分是 HTTP 服务。Quarkus 使用的 HTTP 服务器是非阻塞的、高效的和并发的。它由 Vert.x 提供支持,并通过使用 I/O 线程处理 HTTP 交互。因此,它遵循我们之前在 图 4-1 中解释的反应式方法,并且可以使用少量线程处理多个 HTTP 连接。

一旦这个 HTTP 服务器接收到请求,服务器将请求委托给 Quarkus 处理查找。这个 路由 层构建处理请求的责任链(通常是拦截器和端点),并调用它。在 JAX-RS 端点的情况下,路由层会将查找委托给 JAX-RS 框架,并等待计算 JAX-RS 响应。

但是,等等——我们还在 I/O 线程上吗?如果是这样,我们如何防止用户端点无意中阻塞 I/O 线程?幸运的是,Quarkus 有一个路由层,决定如何处理请求(图 8-2)。

Quarkus 路由层

图 8-2. Quarkus 路由层

在我们在 第二章 中使用的前一个 Quarkus 应用程序中,请求总是在工作线程上分派,避免任何阻塞的风险。从反应式原则的角度来看并不理想。让我们看看 Quarkus 能做什么来改善这种情况。

与 RESTEasy Reactive 打个招呼!

在我们在 第二章 中使用的前一个应用程序中,我们依赖于经典 RESTEasy,它遵循将线程与每个请求关联的老式模型。然而,正如我们之前所见,该模型不具备可伸缩性和响应性。幸运的是,Quarkus 提供了一种替代方案:RESTEasy Reactive。这是相同的开发模型,但此变体意识到响应式引擎并依赖于它。

让我们来看看并尝试使用 RESTEasy Reactive 提供的功能。请访问 https://code.quarkus.io 并选择以下扩展:

  • RESTEasy Reactive

  • RESTEasy Reactive Jackson

然后,点击“生成您的应用程序”并解压它。

响应式版本与经典的 RESTEasy 版本非常相似。Example 8-1 展示了生成的 HTTP 端点。您可能会注意到引入了 @NonBlocking 注解。这是与经典 RESTEasy 的一个重要区别;RESTEasy Reactive 可以在 I/O 线程上分派请求。

示例 8-1. 使用 RESTEasy Reactive 的 HTTP 端点。
package org.acme;

import io.smallrye.common.annotation.NonBlocking;

import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

@Path("/hello-resteasy-reactive")
public class ReactiveGreetingResource {

    @GET
    @Produces(MediaType.TEXT_PLAIN)
    @NonBlocking
    public String hello() {
        return "Hello RESTEasy Reactive";
    }
}

让我们使用 mvn quarkus:dev 运行这个应用程序,并将浏览器指向 http://localhost:8080/hello-resteasy-reactive。您应该会看到这个:

Hello RESTEasy Reactive

好吧,那还算不上什么花哨的东西,到目前为止也不是很吸引人。

首先,让我们增强我们的端点,并且除了 Hello RESTEasy Reactive 外,还要添加处理请求的线程名称(Example 8-2)。

示例 8-2. 请求在 I/O 线程上处理。
@GET
@Produces(MediaType.TEXT_PLAIN)
@NonBlocking
public String hello() {
    return "Hello RESTEasy Reactive from " + Thread.currentThread().getName();
}

因为 Quarkus 在开发模式下运行,所以无需重新启动应用程序,它会自动更新自身。刷新您的浏览器,您应该会看到类似于 Example 8-3 的内容。

示例 8-3. 应用程序输出,指示处理的线程。
Hello RESTEasy Reactive vert.x-eventloop-thread-5

端点方法是从 I/O 线程调用的!¹ 更具响应性,但是...等等...现在如何处理阻塞逻辑?使用 RESTEasy Reactive,您可以使用 @NonBlocking@Blocking 注解指示希望请求在哪个线程上处理。² 让我们来举个例子。创建另一个端点方法,与 hello 方法相同的代码,但是针对不同的路径,并且没有 @NonBlocking 注解,如 Example 8-4 所示。

示例 8-4. 当使用 @Blocking 时,请求会在工作线程上处理。
@GET
@Produces(MediaType.TEXT_PLAIN)
@Path("/blocking")
public String helloBlocking() {
    return "Hello RESTEasy Reactive from " + Thread.currentThread().getName();
}

再次刷新您的浏览器,voilà:

Hello RESTEasy Reactive executor-thread-198

如果无意中阻塞了 I/O 线程会发生什么?

如果您尝试在 I/O 线程中阻塞时间过长,或者尝试从 I/O 线程执行阻塞操作,Quarkus 将会警告您。

RESTEasy Reactive 提供了一组默认值,以避免必须使用 @NonBlocking 注解:

  • 返回对象的方法,比如前面例子中的 String,会在工作线程上执行,除非使用了 @NonBlocking 注解。在这种情况下,方法会使用 I/O 线程。

  • 返回 Uni 的方法在 I/O 线程上执行,除非方法标注有 @Blocking。在这种情况下,方法会使用工作线程。

  • 返回 Multi 的方法在 I/O 线程上执行,除非方法标注有 @Blocking。在这种情况下,方法会使用工作线程。

这有什么好处呢?

通过在 I/O 线程上分派请求,您可以以响应式方式处理请求。这不仅是在采纳响应式原则,还可以增加应用程序的吞吐量。

让我们更深入地看一下吞吐量的差异。我们将通过使用wrk比较经典响应式RESTEasy。这个基准测试远非无懈可击(我们在同一台机器上运行所有内容);它只是为了说明好处。还请注意,结果可能因机器而异。该基准测试只是同时调用一个hello端点并测量响应时间。在chapter-8/simple-benchmark/classic中,你会得到使用 RESTEasy classic的版本。在chapter-8/simple-benchmark/reactive中,你会得到 RESTEasy reactive的变体。

首先,进入chapter-8/simple-benchmark/classic,使用mvn package构建应用程序,然后使用java -jar target/quarkus-app/quarkus-run.jar运行它。一旦应用程序在另一个终端中启动,请运行示例 8-5。

示例 8-5. 使用wrk来压力测试应用程序端点
> wrk -t 10 -c50 -d40s http://localhost:8080/hello
Running 40s test @ http://localhost:8080/hello

这个命令在 40 秒内使用 10 个线程和 50 个连接锤击hello端点。这是一个简单的测试,但它会给我们一个好处的概念。你应该在终端中得到一个报告。对我们来说,我们在示例 8-6 中得到了结果。

示例 8-6. 基准测试结果
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    49.35ms   83.26ms 643.82ms   84.52%
    Req/Sec     2.97k     1.81k   10.66k    64.59%
  1167359 requests in 40.07s, 92.40MB read
Requests/sec:  29132.34
Transfer/sec:      2.31MB

关闭应用程序,并使用 RESTEasy Reactive 构建和运行版本,如示例 8-7 所示。

示例 8-7. 构建和运行响应式应用程序
> cd chapter-8/simple-benchmark/reactive
> mvn package
> java -jar target/quarkus-app/quarkus-run.jar

在另一个终端中运行相同的wrk命令(示例 8-8)。

示例 8-8. 使用wrk来压力测试应用程序端点
> wrk -t 10 -c50 -d40s http://localhost:8080/hello
Running 40s test @ http://localhost:8080/hello
  10 threads and 50 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   600.42us  357.14us  26.17ms   98.24%
    Req/Sec     8.44k   606.47    15.90k    93.71%
  3364365 requests in 40.10s, 221.39MB read
Requests/sec:  83895.54
Transfer/sec:      5.52MB

现在,让我们比较每秒请求数:经典 RESTEasy 为 29,000,而 RESTEasy Reactive 为 84,000。RESTEasy Reactive 提供了几乎三倍的吞吐量。

到目前为止,我们比较了一个响应式框架与一个阻塞框架。但是关于@Blocking注解呢?它指示 Quarkus 使用工作线程调用端点。@Blocking会减少性能增益吗?好吧,让我们来测试一下。在chapter-8/simple-benchmark/reactive-blocking中,应用程序的变体使用 RESTEasy Reactive,但没有@NonBlocking注解。因此,它在工作线程上调用方法。让我们针对该版本运行我们的基准测试,如示例 8-9 所示。

示例 8-9. 构建和运行响应式阻塞应用程序
> cd chapter-8/simple-benchmark/reactive-blocking
> mvn package
> java -jar target/quarkus-app/quarkus-run.jar

在另一个终端中运行wrk命令(示例 8-10)。

示例 8-10. 使用wrk来压力测试应用程序端点
> wrk -t 10 -c50 -d40s http://localhost:8080/hello
Running 40s test @ http://localhost:8080/hello
  10 threads and 50 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    35.99ms   66.23ms 783.62ms   85.87%
    Req/Sec     5.22k     3.53k   22.22k    71.81%
  2016035 requests in 40.05s, 132.66MB read
Requests/sec:  50339.41
Transfer/sec:      3.31MB

即使使用工作线程,该应用程序每秒服务 50,000 个请求。这比 RESTEasy 经典的吞吐量高出 1.5 倍。

RESTEasy Reactive 提供了一个坚实且高度并发的替代传统的一线程每请求方法。 并且,由于 @Blocking@NonBlocking 注解,即使在处理异步和同步逻辑时,您也可以使用它。 在本章的最后,您将看到 RESTEasy Reactive 如何生成端点的响应分数。 接下来,我们将查看此集成,因为仅返回 Hello 是不够的。

异步端点返回 Uni

避免写阻塞代码的一种方法是设计您的 HTTP 端点方法以返回 Uni 实例。 Uni 表示一个可能尚未生成结果的异步计算。 当端点返回 Uni 实例时,Quarkus 订阅它,当 Uni 发出结果时,将此结果写入 HTTP 响应中。 如果不幸的是,Uni 发出失败,HTTP 响应会传达该失败作为 HTTP 内部服务器错误、错误请求或未找到的错误,具体取决于失败原因。 在等待 Uni 结果的同时,线程可以用于处理其他请求。

返回 Uni 时无需使用 @NonBlocking。 RESTEasy Reactive 会识别它并自动将其视为非阻塞。 让我们看看实际情况如何。 在此示例中,我们将使用 Vert.x 文件系统异步 API。 当然,Quarkus 提供了其他更便捷的文件服务方式,但这只是为了说明目的。

注意

您可以在 chapter-8/mutiny-integration-examples 目录中找到相关的代码。

正如我们在 Chapter 6 中所说,Quarkus 基于 Vert.x。 如果添加 quarkus-vertx 扩展,您将访问 managed Vert.x 实例,如 Example 8-11 所示。

示例 8-11. 注入 Vert.x 实例
@Inject Vertx vertx;

确保导入 io.vertx.mutiny.core.Vertx。 注意,我们注入了 Mutiny 版本的 Vert.x。 此变体使用 Mutiny 暴露所有 Vert.x API,在 Quarkus 中非常方便。 因此,读取文件可以像在 Example 8-12 中一样完成。

示例 8-12. 使用 Vert.x 文件系统 API 读取文件
Uni<String> uni = vertx.fileSystem().readFile(path)
        .onItem().transform(buffer -> buffer.toString("UTF-8"));

访问文件系统在大多数情况下是一个阻塞操作。 但是,由于 Vert.x API,我们获得了一个非阻塞的变体,已经提供了 Uni 实例! 但它是 Uni<Buffer>,为了获得 String,我们需要转换发出的结果。³ 换句话说,Example 8-12 读取指定路径的文件。 此操作返回 Uni。 当内容准备好被消耗时,Uni 发出 Buffer 作为项目,并且我们将 Buffer 转换为 String 对象。 所有这些都不会阻塞线程!

但这还不是全部! 我们可以直接返回该 Uni 并让 Quarkus 订阅和处理我们的重活,正如在 Example 8-13 中所示。

示例 8-13. 使用 Vert.x 文件系统 API 返回已读取的文件(chapter-8/mutiny-integration-examples/src/main/java/org/acme/MutinyExampleResource.java
package org.acme.reactive;

import io.smallrye.mutiny.Uni;
import io.vertx.core.file.FileSystemException;
import io.vertx.mutiny.core.Vertx;
import org.jboss.resteasy.reactive.server.ServerExceptionMapper;

import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.core.Response;
import java.time.Duration;

@Path("/")
public class MutinyExampleResource {

    @Inject
    Vertx vertx;

    @GET
    @Path("/lorem")
    public Uni<String> getLoremIpsum() {
        return vertx.fileSystem().readFile("lorem.txt")
                .onItem().transform(buffer -> buffer.toString("UTF-8"));
    }

}

Quarkus 订阅返回的Uni并将发出的项发送到 HTTP 响应。如果Uni发出故障,则发送 HTTP 错误。

看看它在实际中是如何运作的。使用mvn quarkus:dev启动位于chapter-8/mutiny-integration-examples中的应用程序,并使用示例 8-14 调用端点。

示例 8-14. 检索 lorem 文件
> curl http://localhost:8080/lorem
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis
nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore
eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident,
sunt in culpa qui officia deserunt mollit anim id est laborum.

大多数 Quarkus API 都有使用 Mutiny 的响应式变体,如邮件服务、数据库访问(我们将在第九章中查看 Hibernate Reactive)、消息传递、模板化、gRPC 等。此外,Vert.x 的 Mutiny 变体还使您能够访问广泛的响应式生态系统,涵盖网络协议(DNS、TCP、UDP、HTTP)、消息传递(Apache Kafka、AMQP、RabbitMQ、MQTT)、数据访问和 Web 实用程序等。

处理故障和自定义响应

就因为一个方法是异步的并不意味着它不能失败。例如,我们要服务的文件可能不可用,所以我们需要处理这样的故障。但首先,让我们看看 Quarkus 默认做了什么。

让我们添加一个带有失败操作的示例,使用以下端点(如示例 8-15 所示)。

示例 8-15. 使用 Vert.x 文件系统 API 读取丢失的文件(chapter-8/mutiny-integration-examples/src/main/java/org/acme/MutinyExampleResource.java
@GET
@Path("/missing")
public Uni<String> getMissingFile() {
    return vertx.fileSystem().readFile("Oops.txt")
            .onItem().transform(buffer -> buffer.toString("UTF-8"));
}

使用示例 8-16 调用端点。

示例 8-16. 失败传播
> curl -f -v http://localhost:8080/missing
*   Trying ::1...
* TCP_NODELAY set
* Connection failed
* connect to ::1 port 8080 failed: Connection refused
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /missing HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.64.1
> Accept: */*
>
* The requested URL returned error: 500 Internal Server Error
* Closing connection 0
curl: (22) The requested URL returned error: 500 Internal Server Error

Quarkus 返回500 Internal Server Error。这是有道理的;我们的代码中明显存在错误。

让我们看看我们能做些什么。正如您在第七章中看到的那样,Uni提供了我们可以在这里使用的故障处理能力。示例 8-17 显示了我们如何通过简单的消息进行恢复。

示例 8-17. 失败恢复(chapter-8/mutiny-integration-examples/src/main/java/org/acme/MutinyExampleResource.java
@GET
@Path("/recover")
public Uni<String> getMissingFileAndRecover() {
    return vertx.fileSystem().readFile("Oops.txt")
            .onItem().transform(buffer -> buffer.toString("UTF-8"))
            .onFailure().recoverWithItem("Oops!");
}

此返回oops,如您在示例 8-18 中所见。

示例 8-18. 失败恢复
> curl http://localhost:8080/recover
oops!

我们还可以自定义 HTTP 响应并返回合适的404 Not Found错误(示例 8-19)。

示例 8-19. 响应定制(chapter-8/mutiny-integration-examples/src/main/java/org/acme/MutinyExampleResource.java
@GET
@Path("/404")
public Uni<Response> get404() {
    return vertx.fileSystem().readFile("Oops.txt")
            .onItem().transform(buffer -> buffer.toString("UTF-8"))
            .onItem().transform(content -> Response.ok(content).build())
            .onFailure().recoverWithItem(
                    Response.status(Response.Status.NOT_FOUND).build());
}

端点的签名有些不同。我们不再返回Uni<String>,而是返回Uni<Response>。发出的项(Response)代表我们要发送回的 HTTP 响应。在示例 8-20 中,我们设置任何故障时返回404 Not Found

示例 8-20. 自定义 HTTP 响应
 curl -v http://localhost:8080/404
*   Trying ::1...
* TCP_NODELAY set
* Connection failed
* connect to ::1 port 8080 failed: Connection refused
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /404 HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 404 Not Found
< content-length: 0
<
* Connection #0 to host localhost left intact
* Closing connection 0
注意

您可以使用Response来自定义响应,例如添加头部。

另一种选择是为FileSystemException注册异常映射器,如示例 8-21 所示。

示例 8-21. 声明异常映射器 (chapter-8/mutiny-integration-examples/src/main/java/org/acme/MutinyExampleResource.java)
@ServerExceptionMapper
public Response mapFileSystemException(FileSystemException ex) {
    return Response.status(Response.Status.NOT_FOUND)
            .entity(ex.getMessage())
            .build();
}

定义了这样的映射器后,Quarkus 会捕获由 Uni 发出的失败,并调用映射器生成相应的 Response

那么超时呢?当从文件系统读取时出现超时的可能性相对较低时,但当处理远程服务时,它变得更加关键。如何处理超时请参见 示例 8-22。

示例 8-22. 处理超时
return vertx.fileSystem().readFile("slow.txt")
        .onItem().transform(buffer -> buffer.toString("UTF-8"))
        .ifNoItem().after(Duration.ofSeconds(1)).fail();

你可以指定在这种情况下要发出的异常,并且如果需要,注册异常映射器。

当使用 RESTEasy Reactive 实现 HTTP 端点时,请问自己是否可以使用 Mutiny 集成来组合异步操作,并充分利用 Quarkus 的响应式引擎的性能和效率。当然,你可以使用 @Blocking,但需要考虑成本。

流式数据

当我们有单个数据要发送到响应中时,返回 Uni 是完美的。但是对于 呢?

除了 Uni,Quarkus 还允许你返回一个 Multi 实例。Quarkus 订阅返回的 Multi,并将该 Multi 发出的项目逐个写入 HTTP 响应中。这是一种处理流并限制应用程序内存消耗的有效方式,因为你无需将整个内容缓冲到内存中。确实,当处理 Multi 时,Quarkus 使用 HTTP 分块 响应通过设置 Transfer-Encoding header 写入响应的块。

Uni 一样,返回 Multi 的方法默认被认为是非阻塞的。无需使用 @NonBlocking

但是,当返回 Multi 时,我们需要问自己:我们想要什么封装?我们想要流字节吗?我们想要发送 JSON 数组吗?或者使用服务器发送事件发送单个事件?Quarkus 支持所有这些,这就是我们将在本节中看到的内容。

原始流

让我们从 原始 流开始,基本上没有封装。这种模型非常适合在响应中写入大量有效负载,因为我们可以逐块逐块地写入它们。

使用 Quarkus 和 RESTEasy Reactive 进行原始流非常简单:只需返回 Multi。让我们来看一个例子。你可能听说过书 战争与和平。它是我们所说的一本厚书,有超过 1200 页!假设我们想要累积 战争与和平 的全部内容,并将其作为单个批次在 HTTP 响应中返回。这是可行的,但让我们通过流式传输内容使书籍易于理解 (示例 8-23)。

示例 8-23. 流响应 (chapter-8/mutiny-integration-examples/src/main/java/org/acme/StreamResource.java)
@GET
@Path("/book")
@Produces(MediaType.TEXT_PLAIN)
public Multi<String> book() {
    return vertx.fileSystem().open("war-and-peace.txt",
                new OpenOptions().setRead(true))
            .onItem().transformToMulti(AsyncFile::toMulti)
            .onItem().transform(b -> b.toString("UTF-8"));
}

此代码使用 Vert.x 文件系统 API 从文件系统打开书籍文本,并逐块读取。AsyncFile::toMulti 负责读取文件(和AsyncFile),逐块发出内容。就像我们之前在示例 8-13 中所做的那样,我们将内容转换为 UTF-8 字符串。

您可以在chapter-8/mutiny-integration-examples中找到此代码。使用mvn quarkus:dev运行应用程序,然后使用示例 8-24 测试它。

示例 8-24. 消耗分块响应
> curl http://localhost:8080/book -N ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/1.png)

1

-N 指示curl逐块读取响应(禁用缓冲)。

我们得到了内容,但很难看出它是作为一组块发送的。让我们更新端点,每秒发送一个块 (示例 8-25)。

示例 8-25. 以节奏流式传输响应 (chapter-8/mutiny-integration-examples/src/main/java/org/acme/StreamResource.java)
@GET
@Path("/book")
@Produces(MediaType.TEXT_PLAIN)
public Multi<String> bookWithTicks() {
    Multi<Long> ticks = Multi.createFrom().ticks().every(Duration.ofSeconds(1));
    Multi<String> book = vertx.fileSystem().open("war-and-peace.txt",
        new OpenOptions().setRead(true))
            .onItem().transformToMulti(AsyncFile::toMulti)
            .onItem().transform(b -> b.toString("UTF-8"));
    return
            Multi.createBy().combining().streams(ticks, book).asTuple()
                    .onItem().transform(Tuple2::getItem2);
}

示例 8-25 结合了两个流。首先,它创建一个周期性流,每秒发出一个 tick (ticks)。然后,它检索读取书籍的流 (book)。这种组合创建了一个元组流,每秒发出一次。每个元组包含一个 tick (getItem1) 和一个块 (getItem2)。我们只需转发块,忽略 tick。

现在,重新运行curl命令,您将看到每秒按块出现的内容。不要等到结束,因为有很多块;只需按 Ctrl-C 中断即可。

流式 JSON 数组

《战争与和平》 的示例对于二进制内容或简单文本非常有趣,但您可能希望发送更结构化的响应,例如 JSON 数组。想象一下,您正在构建一个响应,它是一个 JSON 数组,但可能很大。每个项都是一个 JSON 对象。您可以在内存中构建该结构并一次性刷新所有内容,但逐个推送 JSON 对象可能更有效。首先,这种方法可以节省一些内存,而接收数据的客户端可能能够立即开始处理项目。要流式传输 JSON 数组,您需要调整生成的内容类型。在前面的示例中,我们只使用了text/plain。要创建 JSON 数组,我们需要将其设置为application/json

提示

我们建议使用MediaType类,该类为最常见的内容类型提供常量。一个拼写错误很快就会变成调试的噩梦。

假设我们有一堆书。每本Book都有一个 ID、一个标题和一个作者列表(示例 8-26)。

示例 8-26. Book 结构 (chapter-8/mutiny-integration-examples/src/main/java/org/acme/StreamResource.java)
public static class Book {
    public final long id;
    public final String title;
    public final List<String> authors;

    public Book(long id, String title, List<String> authors) {
        this.id = id;
        this.title = title;
        this.authors = authors;
    }
}

假设我们有一个服务,可以让我们将我们的书籍集合作为Multi检索出来。换句话说,我们有一个在示例 8-27 中提供 API 的服务。

示例 8-27. 流书籍 API
Multi<Book> getBooks();

要从此方法构建 JSON 数组,我们可以返回由getBooks方法生成的Multi实例(示例 8-28)。

示例 8-28. 流书籍(chapter-8/mutiny-integration-examples/src/main/java/org/acme/StreamResource.java
@Inject BookService service;

@GET
@Path("/books")
@Produces(MediaType.APPLICATION_JSON)
public Multi<Book> books() {
    return service.getBooks();
}

如果您使用示例 8-29 中的命令调用此端点,您将获取所有的书籍。

示例 8-29. 消费书籍流
>  curl -N http://localhost:8080/books
[{"id":0,"title":"Fundamentals of Software Architecture","authors":["Mark
Richards","Neal Ford"]},{"id":1,"title":"Domain-Driven Design","authors":
["Eric Evans"]},{"id":2,"title":"Designing Distributed Systems",
"authors":["Brendan Burns"]},{"id":3,"title":"Building Evolutionary
Architectures","authors":["Neal Ford","Rebecca Parsons","Patrick Kua"]},
{"id":4,"title":"Principles of Concurrent and Distributed Programming",
"authors":["M. Ben-Ari"]},{"id":5,"title":"Distributed Systems Observability",
"authors":["Cindy Sridharan"]},{"id":6,"title":"Event Streams in Action",
"authors":["Alexander Dean","Valentin Crettaz"]},{"id":7,"title":"Designing
Data-Intensive Applications","authors":["Martin Kleppman"]},{"id":8,
"title":"Building Microservices","authors":["Sam Newman"]},{"id":9,
"title":"Kubernetes in Action","authors":["Marko Luksa"]},{"id":10,
"title":"Kafka - the definitive guide","authors":["Gwenn Shapira","Todd Palino",
"Rajini Sivaram","Krit Petty"]},{"id":11,"title":"Effective Java",
"authors":["Joshua Bloch"]},{"id":12,"title":"Building Event-Driven
Microservices","authors":["Adam Bellemare"]}]

结果是一个格式良好的 JSON 数组,包含我们的书籍,以 JSON 对象的形式进行序列化。但是,再次看到它是如何流式传输的却很难。我们可以像之前限制每秒发射一次的方法一样使用相同的方法来限制发射,例如示例 8-30。

示例 8-30. 每秒产生一本书(chapter-8/mutiny-integration-examples/src/main/java/org/acme/StreamResource.java
@GET
@Path("/books")
@Produces(MediaType.APPLICATION_JSON)
public Multi<Book> booksWithTicks() {
    Multi<Long> ticks = Multi.createFrom().ticks().every(Duration.ofSeconds(1));
    Multi<Book> books = service.getBooks();

    return
            Multi.createBy().combining().streams(ticks, books).asTuple()
                    .onItem().transform(Tuple2::getItem2);
}

使用这段代码,如果您重新运行curl命令,您将看到逐个显示项目。

使用服务器发送事件(Server-Sent-Events)

原始流和 JSON 数组对有界流很有帮助。但有时,我们必须处理无界流。

服务器发送事件(SSE)是为此用例设计的。它提供了一种使用 HTTP 流式传输可能无界结构化数据的方式。

要生成 SSE 响应,您需要将生成的内容类型设置为text/event-stream。让我们尝试一下。假设我们要从金融市场流事件。每个事件都是包含公司名称和新股票价值的Quote(示例 8-31)。

示例 8-31. Quote结构(chapter-8/mutiny-integration-examples/src/main/java/org/acme/StreamResource.java
public static class Quote {
    public final String company;
    public final double value;

    public Quote(String company, double value) {
        this.company = company;
        this.value = value;
    }
}

现在,让我们想象一个服务每秒发出一个Quote以代表市场的波动。我们可以通过直接返回该流来生成一个 SSE 响应(示例 8-32)。

示例 8-32. 流式引用(chapter-8/mutiny-integration-examples/src/main/java/org/acme/StreamResource.java
@Inject Market market;

@GET
@Path("/market")
@Produces(MediaType.SERVER_SENT_EVENTS)
public Multi<Quote> market() {
    return market.getEventStream();
}

通过将生成的内容设置为 SSE,Quarkus 相应地编写响应。每个单独的Quote都会自动编码为 JSON(示例 8-33)。

示例 8-33. 消费 SSE 响应
> curl -N http://localhost:8080/market
data:{"company":"MacroHard","value":0.9920107877590033}

data:{"company":"Divinator","value":16.086577691515345}

data:{"company":"Divinator","value":6.739227006693276}

data:{"company":"MacroHard","value":1.9383421237456742}

data:{"company":"MacroHard","value":38.723702725212156}

data:{"company":"Divinator","value":44.23789420202483}

data:{"company":"Black Coat","value":171.42142746079418}

data:{"company":"MacroHard","value":44.37699080288775}

data:{"company":"Black Coat","value":37.33849006264873}
...

读取 SSE 的客户端,例如JavaScript EventSource,可以逐个处理事件。

响应式评分

到目前为止,我们已经看过 RESTEasy Reactive 和 Quarkus 的各种功能。但是反应式周围的工具呢?

我们已经体验过开发模式,这使我们非常高效,但这还不是全部。RESTEasy Reactive 为您的端点生成一个响应得分,指示端点的响应性如何。

要计算此分数,RESTEasy Reactive 查看执行模型(通常,使用工作线程的端点将获得较低的分数)、实例化方案(偏好单例而不是基于请求的实例化)、以及使用的编组器和基于反射的机制(例如对象映射器)等。

让我们通过一个示例来看一下分数。在chapter-8/reactive-scores中,一个应用程序包含一堆使用各种功能的端点。通过使用mvn quarkus:dev在开发模式下启动应用程序,然后打开浏览器。

提示

这个响应式分数页面是 Quarkus dev 控制台的一部分。每个扩展都可以向 dev 控制台做出贡献。在开发模式下,使用http://localhost:8080/q/dev访问 dev 控制台。在我们的示例中,您可以导航到http://localhost:8080/q/swagger-ui/来尝试所有定义的端点。

您可以看到在我们的应用程序中分数从 50/100(相当差)到 100/100(优秀!)的变化(图 8-3)。您可以单击每个方法来了解给定的分数。当尝试提高应用程序的并发性和效率时,此功能非常有用。如果您意识到存在瓶颈,请检查分数并尝试改进。对您的应用程序的影响将是立竿见影的。

端点分数

图 8-3. 端点分数

概要

HTTP 是不可避免的。虽然它不强制执行响应式原则,但 Quarkus 提供了一种在不放弃这些响应式原则的情况下公开 HTTP API 的方法。

多亏了 RESTEasy Reactive,您获得了一个熟悉的声明式开发模型,这个模型更高效且性能更好。我们只是触及了表面。RESTEasy Reactive 还支持 Bean Validation 以自动验证传入的有效负载或 OpenAPI 来描述您的 API。

您可能想知道如何消费 HTTP 端点。这在第 12 章中有所涵盖。但是,有一个方面我们还没有讨论:数据以及如何反应性地访问数据存储。这是下一章的主题。

¹ Vert.x 事件循环线程是 I/O 线程。

² 如果未另有说明,则返回 MultiUni 实例的方法自动被视为非阻塞的。

³ Buffer 是在 Vert.x 中表示字节包的一种方便方式。

第九章:反应式访问数据

在第五章中,我们解释了在应用程序中使用阻塞 I/O 的可伸缩性和鲁棒性问题。本章重点讨论与数据库交互以及 Quarkus 如何确保应用程序堆栈的数据层可以是异步的,并且也能利用非阻塞 I/O。

数据访问的问题

以前访问关系数据涉及使用阻塞 I/O,同时与数据库通信。正如在第五章中已讨论的那样,我们希望在应用程序的任何堆栈层次上都避免使用阻塞 I/O。与数据库的交互通常需要相当长的时间才能完成,具体取决于涉及的记录数量,这对我们的应用程序产生了更大的影响,使用阻塞 I/O 访问数据库!这是什么意思?让我们说我们开发了一个小型数据库应用程序;多年来我们开发了许多这样的应用程序。我们经常将它们称为CRUD应用程序,因为它们为数据库中的记录提供了创建、读取、更新和删除操作。

我们 API 中的每个暴露的端点都需要与数据库进行交互。我们将忽略缓存以及它们在某些情况下减少向数据库发出的请求次数的方式。每个端点方法调用数据库时,每次执行都执行阻塞 I/O,从而降低并发性。

当与数据库交互时,为什么我们被迫使用阻塞 I/O?与数据库交互的 API,如开放数据库连接 (ODBC) 和 Java 数据库连接 (JDBC),设计时采用了同步和阻塞的方法。而 Java 持久化 API (JPA),虽然在许多年后统一了对象关系映射 (ORM) 的 API,但仍然基于 JDBC 现有的同步和阻塞行为设计。

没有采用反应式编程方法来访问数据,整个应用程序堆栈永远无法真正做到反应式。应用程序可能仅在某一点上是反应式的。尽管仍然有益,但并发性和吞吐量仍然无法充分发挥其潜力。

这里有很多话解释了 JDBC 和 JPA 访问数据库不是反应式的,因此是阻塞的,但实际访问是什么样子呢?正如您在“命令模型”中看到的应用程序逻辑一样,对于数据库交互也是一个类似的问题,如图 9-1 所示。

阻塞数据库客户端

图 9-1。阻塞数据库客户端

当我们通过 JDBC 或更高抽象的 JPA 与数据库通信时,JDBC 驱动使用请求和响应交互。然而,如图 9-1 所示,JDBC 驱动程序在收到任何响应之前会阻塞线程。这种阻塞方式占用了每个数据库交互的一个完整线程。根据你如何配置数据库连接池,应用程序在达到最大数据库连接数之前可能会用尽线程。

当处理大量数据库记录的搜索或检索,以及应用程序和数据库之间的网络延迟时,线程饥饿或资源利用的问题可能会发生。

与关系数据库的非阻塞交互

随着各项目的最新工作,例如为 PostgreSQL、MySQL、IBM Db2、Oracle 和 Microsoft SQL Server 的Vert.x 客户端 API,Java 应用程序现在能够以异步方式与数据库进行非阻塞 I/O 交互。

这些新客户端与 JDBC 相比有什么不同?使用非阻塞数据库客户端时,我们能够避免线程阻塞,如图 9-2 所示。

非阻塞数据库客户端

图 9-2. 非阻塞数据库客户端

此外,现在数据库客户端可以在 I/O 线程上执行,而不是工作线程。我们现在使用这些新非阻塞客户端获得了一个复合的好处:减少应用程序可能需要的工作线程数量,因为这些新客户端的数据库通信可以在与任何反应式应用程序代码相同的线程上进行!

在图 9-2 中,我们看到一个单一的数据库连接用于数据库客户端通信。然而,当客户端 API 和数据库支持时,我们可以利用管道技术,将单一数据库连接用于多个请求。图 9-3 展示了数据库客户端中的管道技术是如何工作的。

使用管道的非阻塞数据库客户端

图 9-3. 使用管道的非阻塞数据库客户端

图 9-3 中的每种颜色代表一个独立的数据库请求。尽管我们有不同的反应式处理程序调用数据库,但数据库客户端能够利用单一连接而不是在这种情况下的三个连接。我们希望在可能的情况下利用像这样的非阻塞技术,以便从相同数量的资源中挤出越来越多的应用程序性能。

使用反应式 ORM:Hibernate Reactive

Hibernate ORM 使开发人员更容易编写那些数据超出应用程序进程生命周期的应用程序。作为一个 ORM 框架,Hibernate 关注的是与关系数据库相关的数据持久性。Hibernate 提供了命令式和反应式的 API。

这些 API 支持两个方面:非阻塞数据库客户端,前一节已经涵盖,以及响应式编程作为与关系数据库交互的手段。大多数现有的 Hibernate 内部机制仍在使用,但是 Hibernate Reactive 引入了一个新的层,用于利用响应式和非阻塞 API 与数据库客户端通信。响应式 API 与 JPA 注解、Hibernate 注解和 Bean Validation 协同工作。

是时候深入使用带有响应式 API 的 Hibernate 了!在 Quarkus 中,由于我们可以选择使用带有Panache的 Hibernate Reactive,因此它变得更加强大。这个薄层简化了 Hibernate ORM 的使用。它提供了两种模型。您的实体可以被管理为活动记录,因为实体类提供了检索、更新和查询该实体类实例的方法。您还可以使用仓库模型,其中仓库类提供这些功能,保持实体结构的纯洁性。请查看chapter-9/hibernate-reactive目录以获取所有 Hibernate Reactive 项目代码。首先,我们需要 Hibernate Reactive 的依赖项(示例 9-1)。

示例 9-1. Hibernate Reactive 依赖项(chapter-9/hibernate-reactive/pom.xml
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-hibernate-reactive-panache</artifactId>
</dependency>

注意,我们使用了 Hibernate Reactive 的 Panache 版本。如果我们不想使用 Panache,我们可以改用quarkus-hibernate-reactive依赖项。如前所述,我们还需要一个响应式数据库客户端。在这个例子中,我们将使用 PostgreSQL 客户端(示例 9-2)。

示例 9-2. PostgreSQL 数据库客户端依赖项(chapter-9/hibernate-reactive/pom.xml
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-reactive-pg-client</artifactId>
</dependency>

使用Dev Services,这是 Quarkus 的一个功能,可以自动启动所需的基础设施部件,我们不再需要 Docker Maven 插件来启动运行测试所需的数据库。Quarkus 将自动为我们启动数据库!为了利用 Dev Services,我们需要一个数据库驱动程序,我们刚刚在示例 9-2 中添加了它,并设置db.kind以告知 Quarkus 正在使用的数据库类型。现在让我们在application.properties中设置它(示例 9-3)。

示例 9-3. PostgreSQL 数据库客户端配置(chapter-9/hibernate-reactive/src/main/resources/application.properties
quarkus.datasource.db-kind=postgresql
%prod.quarkus.datasource.username=quarkus_test
%prod.quarkus.datasource.password=quarkus_test
%prod.quarkus.datasource.reactive.url=vertx-reactive:postgresql://
    localhost/quarkus_test

使用 Dev Services 时,除了db.kind以外的所有属性都在prod配置文件中指定。我们也可以完全从prod配置文件中删除这些属性,而是选择使用环境变量或 Kubernetes 中的 ConfigMap 进行设置。

我们有一个Customer实体,扩展了PanacheEntity。我们在这里不会详细介绍Customer,因为它使用了来自 JPA、Bean Validation 和 Hibernate Validator 的通常注解。完整的源代码可以在chapter-9/hibernate-reactive/src/main/java/org/acme/data/Customer查看。

让我们看一下使用 Hibernate Reactive 和 RESTEasy Reactive 实现 CRUD 应用程序的实现。首先是从数据库检索所有客户的方法(Example 9-4)。

Example 9-4. 检索所有客户 (chapter-9/hibernate-reactive/src/main/java/org/acme/data/CustomerResource.java)
public Multi<Customer> findAll() {
  return Customer.streamAll(Sort.by("name"));
}

我们使用 Customer 上的 streamAll,从 Panache 中检索所有实例到 Multi。每个客户可能有与之关联的订单,当我们检索单个客户时,我们也想检索他们的订单。虽然我们有一个单一的应用程序,但我们将考虑订单来自外部服务。

首先,我们定义 Uni 来检索 Customer,并在未找到时抛出异常,如 Example 9-5 所示。

Example 9-5. 查找客户 (chapter-9/hibernate-reactive/src/main/java/org/acme/data/CustomerResource.java)
Uni<Customer> customerUni = Customer.<Customer>findById(id)
    .onItem().ifNull().failWith(
        new WebApplicationException("Failed to find customer",
        Response.Status.NOT_FOUND)
    );

接下来,客户的订单作为 List 单独检索到另一个 Uni(见 Example 9-6)。

Example 9-6. 获取客户订单 (chapter-9/hibernate-reactive/src/main/java/org/acme/data/CustomerResource.java)
Uni<List<Order>> customerOrdersUni = orderService.getOrdersForCustomer(id);

最后,这两者通过一个映射器组合在一起,每个 Uni 的结果设置为客户的订单。最终的 Uni 转换为 JAX-RS Response 来完成端点的执行(Example 9-7)。

Example 9-7. 结合 customerorders (chapter-9/hibernate-reactive/src/main/java/org/acme/data/CustomerResource.java)
return Uni.combine()
    .all().unis(customerUni, customerOrdersUni)
    .combinedWith((customer, orders) -> {
      customer.orders = orders;
      return customer;
    })
    .onItem().transform(customer -> Response.ok(customer).build());

到目前为止,我们所做的一切都不需要事务,因为我们只是在读取数据库记录。Example 9-8 展示了如何使用事务来存储新客户。

Example 9-8. 创建客户 (chapter-9/hibernate-reactive/src/main/java/org/acme/data/CustomerResource.java)
return Panache
    .withTransaction(customer::persist)
    .replaceWith(Response.ok(customer).status(Response.Status.CREATED).build());

我们使用 Panache.withTransaction 来通知 Panache 我们想要一个事务来包装我们传递给它的 Uni Supplier。在这个例子中,我们使用 customer.persist 作为要用事务包装的代码。尽管成功时返回 Uni<Void>,但我们可以使用 replaceWith 来创建必要的 Uni<Response>

接下来,我们使用 withTransaction 更新客户名称。首先,我们通过 ID 检索客户。然后,当我们接收到一个非 null 的项目时,我们会 invoke 一个可运行对象来更新检索到的实体的名称(Example 9-9)。

Example 9-9. 更新客户 (chapter-9/hibernate-reactive/src/main/java/org/acme/data/CustomerResource.java)
return Panache
    .withTransaction(
        () -> Customer.<Customer>findById(id)
            .onItem().ifNotNull().invoke(entity -> entity.name = customer.name)
    )

然后,我们利用 onItem 来生成成功响应的结果,或者如果项目为 null 则返回 not found 响应。

使用 Hibernate Reactive 创建 CRUD 应用程序的最后一个方法提供了删除客户的功能。再次使用 withTransaction,传递 Panache 方法按其 ID 删除客户。删除实体返回 Uni<Boolean>。我们需要使用 map 根据其成功来将其转换为 JAX-RS 响应 (示例 9-10)。

示例 9-10. 删除一个客户 (chapter-9/hibernate-reactive/src/main/java/org/acme/data/CustomerResource.java)
return Panache
    .withTransaction(() -> Customer.deleteById(id))
    .map(deleted -> deleted
        ? Response.ok().status(Response.Status.NO_CONTENT).build()
        : Response.ok().status(Response.Status.NOT_FOUND).build());

现在你已经看到如何使用 Panache 和 Hibernate Reactive 创建、检索、更新和删除实体!要了解端点如何进行测试,请查看书中代码示例中的 /chapter-9/hibernate-reactive/src/test/java/org/acme/data/CustomerEndpointTest

NoSQL 怎么样?

我们已经展示了如何在传统 ORM(如 Hibernate)中具有反应式 API,那么在需要 NoSQL 数据库而不是关系数据库时,我们能否利用反应式 API?答案是肯定的!

Quarkus 有几个扩展可用于与 NoSQL 数据库通信,包括 MongoDB、Redis 和 Apache Cassandra。所有这些扩展是否支持反应式 API?目前,MongoDB、Redis 和 Cassandra 客户端都支持反应式 API。

在下一节中,我们将开发一个与上一节中 Hibernate Reactive 示例具有相同功能的 CRUD 应用程序。

与 Redis 的交互

让我们开发一个与 Redis 一起使用的客户 CRUD 应用程序!对于这个示例,我们将交互提取到一个可以注入到 REST 资源中的单独服务中。查看代码示例中的 /chapter-9/redis/src/main/java/org/acme/data/CustomerResource,了解服务的使用方式。

首先,我们需要项目中的 Redis 客户端依赖项;请参阅 示例 9-11。

示例 9-11. Redis 客户端依赖项 (chapter-9/redis/pom.xml)
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-redis-client</artifactId>
</dependency>

就像我们在 Hibernate Reactive 示例中所做的那样,我们将利用 docker-maven-plugin 运行一个 Redis 容器以进行测试执行。有关详细信息,请查看书籍源码中的 chapter-9/redis/pom.xml

接下来,我们配置 Redis 客户端的服务器主机位置。将配置包含在 application.properties 中的 示例 9-12 中。

示例 9-12. Redis 客户端配置 (chapter-9/redis/src/main/resources/application.properties)
quarkus.redis.hosts=redis://localhost:6379

要能够使用 Redis 客户端,我们需要进行 @Inject,如 示例 9-13 中所示。

示例 9-13. 注入 Redis 客户端 (chapter-9/redis/src/main/java/org/acme/data/CustomerService.java)
@Inject
ReactiveRedisClient reactiveRedisClient;

为了避免在 Redis 中意外创建键冲突,我们将给客户 ID 添加前缀,如 示例 9-14 所示。

示例 9-14. 键前缀 (chapter-9/redis/src/main/java/org/acme/data/CustomerService.java)
private static final String CUSTOMER_HASH_PREFIX = "cust:";

让我们从 Redis 中检索所有客户的列表开始 (示例 9-15)。

示例 9-15. 检索所有客户 (chapter-9/redis/src/main/java/org/acme/data/CustomerService.java)
public Multi<Customer> allCustomers() {
  return reactiveRedisClient.keys("*")
      .onItem().transformToMulti(response -> {
        return Multi.createFrom().iterable(response).map(Response::toString);
      })
      .onItem().transformToUniAndMerge(key ->
          reactiveRedisClient.hgetall(key)
              .map(resp ->
                  constructCustomer(
                      Long.parseLong(
                          key.substring(CUSTOMER_HASH_PREFIX.length())),
                      resp)
              )
      );
}

ReactiveRedisClient提供了与 Redis 可用命令对齐的 API,在 Java 中如果您已经熟悉使用 Redis 命令,则更容易使用。在示例 9-15 中,我们使用带有通配符的keys检索所有键,这将返回Uni<Response>。这个特定的Response类表示来自 Redis 的响应。

在从 Redis 接收响应(项)后,我们使用transformToMulti将单个响应分隔成单个键。在 lambda 中,我们直接从响应中创建一个字符串键的Multi,因为它是Iterable,并将值映射到键的字符串。执行的结果是Multi<String>

我们还没有完成;我们需要将键流转换为客户流。阅读代码可以很好地了解发生了什么。从Multi<String>开始,对每个生成的项调用transformToUniAndMerge。我们使用它们的键或项与 Redis 客户端一起检索与键或哈希匹配的所有字段和值。从hgetall的响应映射到Customer实例,使用constructCustomer。最后,客户Uni实例合并成一个Multi以返回。

要检索单个客户,我们调用hgetall,根据响应的大小,要么返回null,要么使用constructCustomer创建客户(示例 9-16)。我们需要检查响应的大小,以确定是否返回了任何字段和值。如果大小为零,则响应为空,因为找不到键。

示例 9-16. 检索客户 (chapter-9/redis/src/main/java/org/acme/data/CustomerService.java)
public Uni<Customer> getCustomer(Long id) {
  return reactiveRedisClient.hgetall(CUSTOMER_HASH_PREFIX + id)
      .map(resp -> resp.size() > 0
          ? constructCustomer(id, resp)
          : null
      );
}

要将客户记录存储到 Redis 中,我们使用hmset为单个键存储多个字段和值。从 Redis 的角度看,无论我们是存储新客户还是更新现有客户,我们都使用hmset。我们应该将行为拆分为一个单独的方法,在两个地方重用它,如示例 9-17 所示。

示例 9-17. 存储客户 (chapter-9/redis/src/main/java/org/acme/data/CustomerService.java)
return reactiveRedisClient.hmset(
    Arrays.asList(CUSTOMER_HASH_PREFIX + customer.id, "name", customer.name)
)
    .onItem().transform(resp -> {
      if (resp.toString().equals("OK")) {
        return customer;
      } else {
        throw new NoSuchElementException();
      }
    });

使用hmset时,我们需要确保传递给它的参数数量是奇数。第一个参数是记录的哈希,然后是成对匹配的字段和值,用于设置尽可能多的字段。如果成功,我们收到一个简单的OK回复,使用transform在成功时返回客户或抛出异常。

使用storeCustomer后,让我们看看createCustomer;参见示例 9-18。

示例 9-18. 创建客户 (chapter-9/redis/src/main/java/org/acme/data/CustomerService.java)
public Uni<Customer> createCustomer(Customer customer) {
  return storeCustomer(customer);
}

我们有一个非常干净的方法createCustomer,响应Uni<Customer>!对此没有太多要说的,所以让我们看看updateCustomer在示例 9-19 中。

示例 9-19. 更新客户(chapter-9/redis/src/main/java/org/acme/data/CustomerService.java
public Uni<Customer> updateCustomer(Customer customer) {
  return getCustomer(customer.id)
      .onItem().transformToUni((cust) -> {
        if (cust == null) {
          return Uni.createFrom().failure(new NotFoundException());
        }
        cust.name = customer.name;
        return storeCustomer(cust);
      });
}

首先,我们从服务中重用getCustomer以从 Redis 中检索现有的客户。当从getCustomer返回一个项目时,我们将其转换为另一个带有映射器的Uni。映射器首先检查我们接收到的项目,即客户是否为 null,如果是,则返回包含异常的Uni失败。然后我们设置新的名称到客户上,然后调用storeCustomer,创建映射器返回的Uni

最后,我们需要一种方法来删除客户。为此,我们在 Redis 客户端上使用hdel,它返回删除的字段数或者如果找不到键则返回0(示例 9-20)。我们将Uni<Response>映射到Uni<Boolean>,检查是否删除了一个字段(在本例中是客户名称),如果项目为 null,则返回NotFoundException,或者成功并将项目转换为 null 项目。

示例 9-20. 删除客户(chapter-9/redis/src/main/java/org/acme/data/CustomerService.java
public Uni<Void> deleteCustomer(Long id) {
  return reactiveRedisClient.hdel(Arrays.asList(CUSTOMER_HASH_PREFIX + id, "name"))
      .map(resp -> resp.toInteger() == 1 ? true : null)
      .onItem().ifNull().failWith(new NotFoundException())
      .onItem().ifNotNull().transformToUni(r -> Uni.createFrom().nullItem());
}

本节简要介绍了利用 Redis 的响应式客户端的一些方法。我们没有涵盖许多其他方法,但本节提供了它们如何通常使用的指导。

数据相关事件和变更数据捕获

变更数据捕获,或CDC,是一种从通常不使用事件和消息的来源(如数据库)提取事件的集成模式。CDC 具有许多优点,包括能够从不修改应用程序的传统应用程序中产生变更事件。

另一个好处是 CDC 不关心应用程序开发的语言,因为它与数据库交互。这种方法可以大大简化从具有多语言应用程序写入的数据库中产生一致外观的变更事件的工作量。必须更新可能有数十个以不同语言编写的应用程序以从它们所有的应用程序中生成一致外观的变更事件可能是具有挑战性和耗时的。

写入数据库涉及事务,通常应该这样,并在将事件写入消息系统时增加了额外的复杂性。在图 9-4 中,我们需要确保如果数据库更新失败或生成消息失败,一切都会回滚并撤消任何更改。

写入数据库和消息代理

图 9-4. 写入数据库和消息代理

当事务回滚可能发生在我们的应用代码之外时,情况可能会特别复杂,例如由于未返回 HTTP 响应而导致。使用 CDC,这种担忧消失了,因为我们只关心写入数据库本身。

任何更改事件都可以通过 CDC 从更新后的数据库流出,确保我们不会发送任何不应该发送的事件,因为 CDC 在看到更改之前事务已经提交,如图 9-5 所示。

写入数据库,CDC 触发消息创建

第 9-5 图:写入数据库,CDC 触发消息创建

开发人员需要注意的一个影响是 CDC 不提供强一致性。强一致性意味着在更新后立即查看的任何数据对于所有数据观察者都是一致的,无论观察者是否在并行或分布式进程中。对于关系数据库,这是有保证的,因为它是数据库设计的一部分。使用 CDC,数据库中发生更新和最远的下游系统接收并处理消息的时间之间存在一段时间。

缺乏强一致性,或最终一致性,并不妨碍使用 CDC。我们希望开发人员了解 CDC 模式的一致性保证,并在应用程序设计中牢记这一点。

使用 Debezium 捕获更改

Debezium是一个用于 CDC 的分布式平台。Debezium 是耐用且快速的,使应用能够迅速响应并且不会错过任何事件!

图 9-6 显示了 Debezium 在使用 CDC 的应用程序架构中的位置。Debezium 为几个数据库提供了 Kafka Connect 源连接器,包括 MySQL、MongoDB、PostgreSQL、Oracle、Db2 和 SQL Server。

CDC 与 Debezium

第 9-6 图:CDC 与 Debezium

我们将简要展示如何使用 Debezium 增强上一节中的 Hibernate Reactive 示例。完整的细节可以在书的源代码中找到。

尽管此示例包含来自 Hibernate Reactive 的代码副本,但也可以直接使用示例,因为引入 Debezium 不会影响应用程序代码。要理解的主要部分是docker-compose.yml文件。该文件启动 Kafka 容器,ZooKeeper 作为 Kafka 的依赖项,PostgreSQL 数据库和 Kafka Connect。我们将使用 Debezium 项目中的容器映像来简化部署过程。例如,PostgreSQL 容器映像已经包含了向 Kafka Connect 通信更改事件所需的逻辑解码插件。

使用 docker compose up 启动所有容器,然后使用 java -jar target/quarkus-app/quarkus-run.jar 构建并启动应用程序。一旦所有容器启动完成,我们就可以为 PostgreSQL 安装 Debezium 源连接器到 Kafka Connect 中(Example 9-21)。

Example 9-21. 安装 Debezium 源连接器
curl -i -X POST -H "Accept:application/json" -H "Content-Type:application/json" \
    http://localhost:8083/connectors/ -d @register.json

在这里,register.json 是我们传递给 Kafka Connect 端点的数据。该文件提供了要连接的数据库的详细信息和要使用的 Debezium 连接器,如 Example 9-22 所示。

Example 9-22. Debezium 源连接器定义
{
  "name": "customer-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "tasks.max": "1",
    "database.hostname": "postgres",
    "database.port": "5432",
    "database.user": "quarkus_test",
    "database.password": "quarkus_test",
    "database.dbname": "quarkus_test",
    "database.server.name": "quarkus-db-server"
  }
}

安装源连接器将触发为连接器发现的表创建 Kafka 主题。我们可以通过运行 docker exec -ti kafka bin/kafka-topics.sh --list --zookeeper zookeeper:2181 来验证创建了哪些主题。

接下来我们在 Kafka 容器中运行一个 exec shell,以消费来自客户数据库主题的消息,即 quarkus-db-server.public.customer(Example 9-23)。

Example 9-23. 从 Kafka 消费消息
docker-compose exec kafka /kafka/bin/kafka-console-consumer.sh \
    --bootstrap-server kafka:9092 \
    --from-beginning \                              ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/1.png)
    --property print.key=true \
    --topic quarkus-db-server.public.customer

1

删除此设置以跳过应用程序启动时创建的初始四条消息。

当 Example 9-23 完成后,在单独的终端窗口中创建一个新的客户,如 Example 9-24 所示。

Example 9-24. 创建一个客户
curl -X POST -H "Content-Type:application/json" http://localhost:8080/customer \
    -d '{"name" : "Harry Houdini"}'

在运行 Example 9-23 的终端中,我们可以看到连接器创建的 JSON 消息(Example 9-25)。

Example 9-25. 创建客户的 CDC 消息
{
  "schema": {
    "type":"struct",
    "fields": [{
      "type":"int64",
      "optional":false,
      "field":"id"
    }],
    "optional":false,
    "name":"quarkus_db_server.public.customer.Key"
  },
  "payload": {
    "id":9                    ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/1.png)
  }
}
{
  "schema": {
    // JSON defining the schema of the payload removed for brevity

    "optional": false,
    "name": "quarkus_db_server.public.customer.Envelope"
  },
  "payload": {
    "before": null,
    "after": {
      "id": 9,                       ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/1.png)
      "name": "Harry Houdini"        ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/2.png)
    },
    "source": {
      "version": "1.5.0.Final",
      "connector": "postgresql",
      "name": "quarkus-db-server",
      "ts_ms": 1627865571265,
      "snapshot": "false",
      "db": "quarkus_test",
      "sequence": "[null,\"23870800\"]",
      "schema": "public",
      "table": "customer",
      "txId": 499,
      "lsn": 23871232,
      "xmin": null
    },
    "op": "c",
    "ts_ms": 1627865571454,
    "transaction": null
  }
}

1

使用 POST 创建记录的 ID

2

创建的客户名称

尝试使用其他 HTTP 命令进行实验,比如更新客户姓名,以比较在 Kafka 主题中收到的 JSON。

摘要

近年来,使用响应式应用程序处理数据一直受到限制,因为缺乏响应式数据库客户端 API。随着 Vert.x 为 PostgreSQL 等数据库提供的客户端 API 的引入,我们现在可以创建一个完全响应式的应用程序栈。

我们并不总是想直接使用数据库客户端 API。我们喜欢像 Hibernate ORM 提供的简化 API。Hibernate Reactive 给了我们这样的能力,它在 Hibernate ORM 的基础上添加了响应式特定的 API。

关系型数据库不是唯一的选择。我们还为 Redis 和 MongoDB 提供了响应式客户端。在事件驱动架构中,我们希望能够从数据库交互中创建事件。这就是 CDC 的优势所在,它能够从数据库表中提取变更并创建变更事件以供输入 Kafka。

我们现在已经到达了第三部分的结尾!我们深入探讨了 Quarkus,发现它统一了命令式和反应式编程模型,为开发人员在选择应用程序堆栈时提供了更大的灵活性。然后,我们通过UniMulti深入了解了 Quarkus 中首选的反应式编程库 Mutiny。继续探索 Quarkus 中的新创新,我们探索了 RESTEasy Reactive,以完全非阻塞的方式开发 JAX-RS 资源,同时仍然提供需要时进行阻塞的能力,然后通过 Hibernate Reactive 和 Redis 完成了反应式数据库客户端的介绍。

在第四部分,我们聚焦于开发反应式应用程序时需要的典型模式,例如使用 Kafka 和 AMQP 进行消息传递。然后,我们深入系统底层消息传递的各个方面,以更好地理解权衡和它们的能力。我们查看了通过 HTTP 客户端与外部服务通信的方式,同时利用非阻塞 I/O。最后,虽然不一定是应用程序模式本身,但我们将研究可观察性,因为对于分布式系统而言,理解和实现它至关重要。

第四部分:连接各个点

第十章:反应式消息传递:连接的纽带

在 第 III 部分 中,您已经看到了许多使用 Quarkus 开发反应式应用程序的特性。但是,正如您从 第 II 部分 中记得的那样,我们不想局限于反应式应用程序;我们想要构建反应式系统。这就是我们现在要做的。

反应式系统使用其组件之间的异步消息传递。然而,尽管中间件和框架有时可以隐藏这种消息传递的细节,但我们认为将其显露出来效果更好。这不仅有助于编写事件驱动的代码(在事件 x 上执行 y),还有助于将您的应用程序分解为接收和生成消息的一组组件。因此,Quarkus 提供了一个简单而强大的消息驱动开发模型,用于消费、处理和创建消息。本章重点介绍这种模型,以及它与反应式流的关系,以及如何简化构建基于消息驱动和事件驱动的应用程序。

从反应式应用程序到反应式系统

当您与 Java 开发人员讨论消息传递时,您会感受到他们的挫败感。多年来,JMS 一直是消息传递的事实标准。然而,该 API 的老化问题日益严重,而新的消息传递技术如 Kafka 和 Pulsar 使用的概念与 JMS 不兼容。此外,JMS 是一个阻塞式 API,这阻碍了我们实现反应式原则。

虽然 Quarkus 可以使用 JMS,但我们将看看另一种称为 反应式消息传递 的方法。这个 MicroProfile 规范构建了反应式和事件驱动的应用程序。Quarkus 实现了规范的 2.x 版本,同时还提供了许多扩展功能。

使用反应式消息传递的应用程序可以以与协议无关的方式发送、消费和处理消息。例如,正如您将在 第十一章 中看到的那样,您可以使用 Apache Kafka 或 AMQP,甚至可以将两者结合使用。反应式消息传递还为习惯于上下文和依赖注入(CDI)的开发人员提供了一种自然的开发模型,这是一个标准的依赖注入框架。通常,您可以用几个注解来总结反应式消息传递。但在看它实际运作之前,让我们先描述一些它依赖的概念。

通道和消息

在使用反应式消息传递时,您的应用程序和组件通过 消息 进行交互,这些消息由 o⁠r⁠g.e⁠c⁠l⁠i⁠p⁠s⁠e.m⁠i⁠c⁠r⁠o⁠p⁠r⁠o⁠f⁠i⁠l⁠e⁠.​r⁠e⁠a⁠c⁠t⁠i⁠v⁠e⁠.m⁠e⁠s⁠s⁠a⁠g⁠i⁠n⁠g⁠.M⁠e⁠s⁠s⁠a⁠g⁠e⁠<T> 类来表示。消息 是一个信封,其负载类型为 T。此外,消息可以具有元数据,并提供确认方法来通知框架消息的成功或失败处理。

消息在 通道 上传输。您可以将通道想象成管道。通道可以是应用程序内部的,也可以通过连接器映射到外部消息队列或主题(图 10-1)。

基于消息的应用架构

图 10-1. 基于消息的应用架构

你的应用程序从通道读取并写入通道。你可以将你的应用程序分割成一组组件,它们都从不同的通道读取和写入。就是这样。响应式消息传递将所有内容绑在一起,从而构建消息流。

生成消息

响应式消息传递提供了多种生成消息的方式,但让我们从最简单且可能更符合熟悉命令式编程的开发者的方式开始:发射器。Emitter是一个附加到通道并将消息发射到该通道的对象。¹ 示例 10-1 说明了使用MutinyEmitter发送消息的方法。

示例 10-1. 使用发射器发送消息
@Channel("my-channel")
MutinyEmitter<Person> personEmitter;

public Uni<Void> send(Person p) {
    return personEmitter.send(p);
}

要访问一个发射器,只需在你的 CDI bean 中注入它。目标通道使用@Channel注解指示。

提示

不需要使用@Inject注解,因为 Quarkus 会自动检测注入。

在 示例 10-1 中,我们不会生成一个Message实例;我们只发送一个被自动包装成消息的负载(Person)。请注意,send方法返回Uni<Void>。这个 Uni 在消息处理成功完成时生成一个null项。否则,它会生成一个指示处理失败原因的故障。

发射器也可以直接发送Message的实例,就像 示例 10-2 中展示的那样。

示例 10-2. 注入和使用发射器
@Channel("my-second-channel")
MutinyEmitter<Person> messageEmitter;

public void sendMessage(Person p) {
    messageEmitter.send(
            Message.of(p,
                    () -> {
                        // Acknowledgment callback
                        return CompletableFuture.completedFuture(null);
                    },
                    failure -> {
                        // Negative-acknowledgment callback
                        return CompletableFuture.completedFuture(null);
                    })
    );
}

当发送Message时,你可以直接传递确认回调。我们在 “确认” 中讨论确认。

当你希望决定何时发送消息时,发射器就非常方便。它们允许命令式代码发射消息,这些消息将以反应式方式处理。例如,你可以在 HTTP 端点中使用发射器,在收到请求时发送消息。

另一种生成消息的方式是使用@Outgoing注解。它指示响应式消息传递将方法的输出发送到指定的通道。请注意,使用@Outgoing注解的方法不能从你的代码中调用;响应式消息传递会为你调用它们。也许你会对它的好处感到好奇。它看起来比发射器灵活性稍逊。但有一个技巧:@Outgoing允许直接生成流(Multi)(示例 10-3)。

示例 10-3. 使用@Outgoing
@Outgoing("my-channel")
Multi<Person> produceAStreamOfPersons() {
    return Multi.createFrom().items(
            new Person("Luke"),
            new Person("Leia"),
            new Person("Obiwan")
    );
}

你可以每秒产生无限流,正如 示例 10-4 中演示的那样。

示例 10-4. 使用@Outgoing生成无限流
@Outgoing("ticks")
Multi<Long> ticks() {
    return Multi.createFrom().ticks()
            .every(Duration.ofSeconds(1))
            .onOverflow().drop();
}

当应用程序启动时,响应式消息连接所有元素到通道。在幕后,它创建了响应式流(在第 5 章中讨论)。因此,您的应用程序是一组响应式流,强制执行背压协议。消费速率由下游订阅者控制。这就是为什么在前面的示例中我们需要onOverflow.drop。否则,如果下游订阅者不能及时消费,它将失败(您无法及时应用背压)。

至于Emitter,您可以生成消息流(示例 10-5)。

示例 10-5. 通过@Outgoing生成消息流
@Outgoing("my-channel")
Multi<Message<Person>> produceAStreamOfMessagesOfPersons() {
    return Multi.createFrom().items(
            Message.of(new Person("Luke")),
            Message.of(new Person("Leia")),
            Message.of(new Person("Obiwan"))
    );
}

在这里,我们的消息只是包装了有效负载。正如我们在示例中看到的,您可以传递确认回调。

细心的读者可能已经观察到发射器和@Outgoing方法之间的显著差异。响应式消息完全处理@Outgoing方法(调用它),因此强制执行背压协议没有问题。但是对于发射器,响应式消息无法做到这一点。如果您的代码使用发射器发送消息速度快于消费率,您可能会遇到麻烦!

幸运的是,为了避免这种情况,在使用发射器时,您可以配置溢出策略。此策略描述了当下游消费不够快时会发生什么。@OnOverflow提供了六种策略。最常见的示例在示例 10-6 中演示了使用缓冲区。

示例 10-6. 使用@OnOverflow注解的用法
@Channel("channel")
@OnOverflow(value = OnOverflow.Strategy.BUFFER, bufferSize = 100)
MutinyEmitter<Person> emitterUsingABufferOnOverflow;

@Channel("channel")
@OnOverflow(value = OnOverflow.Strategy.UNBOUNDED_BUFFER)
MutinyEmitter<Person> emitterUsingAnUnboundedOnOverflow;

@Channel("channel")
@OnOverflow(value = OnOverflow.Strategy.DROP)
MutinyEmitter<Person> emitterDroppingMessageOnOverflow;

@Channel("channel")
@OnOverflow(value = OnOverflow.Strategy.LATEST)
MutinyEmitter<Person> emitterDroppingOlderMessagesOnOverflow;

@Channel("channel")
@OnOverflow(value = OnOverflow.Strategy.FAIL)
MutinyEmitter<Person> emitterSendingAFailureDownstreamOnOverflow;

@Channel("channel")
@OnOverflow(value = OnOverflow.Strategy.THROW_EXCEPTION)
MutinyEmitter<Person> emitterThrowingExceptionUpstreamOnOverflow;

OnOverflow策略与 Mutiny 中的策略类似:

  • BUFFER是默认选项,使用缓冲区来存储消息。可以配置大小;默认大小为 256。

  • UNBOUNDED_BUFFERBUFFER类似,但使用无限缓冲区。使用此策略时要小心,因为可能会导致内存问题。

  • DROPLATEST分别丢弃最新和最旧的消息。

  • FAIL向下游发送失败。请记住,对于响应式流来说,失败是终端的。因此,在此之后您可以使用发射器。

  • THROW_EXCEPTION向上游调用send方法时抛出异常。调用者可以对此作出反应;例如,它可能等不及下游订阅者赶上。

消费消息

让我们看看消息管道的另一侧。要消耗消息,您可以通过使用@Channel注解来注入流,如示例 10-7 所示。

示例 10-7. 注入通道
@Channel("my-channel")
Multi<Person> streamOfPersons;

// ...

void init() {
    streamOfPersons
            .subscribe().with(
                    person -> { /* ... */ },
                    failure -> { /* ... */ }
    );
}

正如您所见,您必须订阅注入的流。请记住,如果您不订阅,什么都不会发生,您将不会收到任何消息。请注意,您的代码可以注入多个流并对其进行消费。

您还可以注入消息流。在这种情况下,您必须手动确认消息(示例 10-8)。

示例 10-8. 注入消息流
@Channel("my-channel")
Multi<Message<Person>> streamOfPersons;

// ...

void init() {
    streamOfPersons
            .subscribe().with(
                    message -> {
                        Person person = message.getPayload();
                        try {
                            // do something
                            // acknowledge
                            message.ack();
                        } catch (Exception e) {
                            message.nack(e);
                        }
                    },
                    failure -> { /* ... */ }
    );
}

当你注入一组负载流时,确认会自动为你完成。消息让你在确认方面拥有更多控制权,同时还能通过使用nack拒绝消息。此外,你还可以访问消息的元数据。但是,请记住,更多的权力意味着更大的责任。

当你想直接访问流或者控制订阅时,注入@Channel非常方便。

响应式消息还提供了一种更声明式的消息消费方式:@Incoming注解。这个注解与@Outgoing相对应。响应式消息会调用在指定通道上每个消息通过的方法(Example 10-9)。

Example 10-9. 使用@Incoming的方法示例
@Incoming("my-channel")
void consume(Person person) {
    // ...
}

Example 10-9 提供了一种便捷的方式来处理每个传入的Person。你无需担心确认;这已经为你完成。你也可以接收Message,就像在 Example 10-10 中展示的那样。

Example 10-10. 使用@Incoming接收消息的方法示例
@Incoming("my-channel")
CompletionStage<Void> consume(Message<Person> person) {
    // ...
    return person.ack();
}

在这种情况下,就像对于注入消息流的@Channel一样,你需要自行处理确认。记住:更多的控制权,也意味着更大的责任。

处理消息

现在你已经看到了两端,让我们来看看管道的中间部分:处理。为了处理消息,你需要结合@Incoming@Outgoing,就像 Example 10-11 中展示的那样。

Example 10-11. 使用@Incoming@Outgoing的方法
@Incoming("from")
@Outgoing("to")
Person process(String name) {
    return new Person(name);
}

在这个片段中,我们从from通道读取字符串。对于每个接收到的字符串,我们创建一个Person实例,然后将其发送到to通道。这种方法是同步的,接受一个个体负载并返回一个个体负载。这并不是唯一支持的签名方式。响应式消息支持超过 30 种签名,允许异步处理(例如返回Uni),甚至流处理(其中你接收并返回Multi),²参见 Example 10-12。

Example 10-12. 流操作示例
@Incoming("from")
@Outgoing("to")
Multi<Person> processStream(Multi<String> inputStream) {
    return inputStream
            .onItem().transform(Person::new);
}

除了负载外,你还可以处理消息。但是对于消息的消费,你需要更加注意。确实,你经常需要链式消息,或者将传入的消息与你产生的消息链接起来,就像在 Example 10-13 中展示的那样。

Example 10-13. 处理消息
@Incoming("from")
@Outgoing("to")
Message<Person> processMessage(Message<String> msg) {
    return msg.withPayload(new Person(msg.getPayload()));
}

在这个片段中,看看withPayload方法。Message接口提供了多个with方法来链接消息。你可能会想为什么将它们链接起来这么重要。这关乎确认。你已经多次见过这个词,现在是解释它的时候了。

确认

确认 是任何消息系统的重要组成部分。当您使用消息代理时,消费者接收并处理消息时必须向代理指示消息处理完成(成功或失败)。然后代理可以决定是否调度下一个消息,或者在处理失败时重新投递相同的消息。虽然代理策略依赖于代理本身,确认是一个众所周知的概念,大多数消息传递协议都以一种方式或另一种方式使用它。

在响应式消息处理中,每条消息必须被 ack(成功确认)或 nack(否定确认或未确认)。Message 接口提供了 acknack 方法来指示消息的成功或失败处理。这两个方法都是异步的,并返回 CompletionStage<Void>。实际上,当使用远程代理时,确认消息意味着告诉代理消息是否已成功处理。您将在 第十一章 中看到这些确认如何与 Apache Kafka 和 AMQP 1.0 集成。

在使用单个有效载荷时,响应式消息处理确认(积极或消极)已为您处理。但是,当您接收消息时,您需要调用这些方法或生成一个链接到传入消息的消息。这种链接很重要。当下游消费者接收您的消息并对其进行确认时,该消费者还会确认链接的消息。这些链接形成一系列消息,并且确认沿着链条向上传递,直到达到顶部(通常是由发射器产生的消息或来自外部目的地)。

如图 10-2 图 所示,即使处理包含多步骤(可能是异步的)时,链条也允许指示处理结果。

确认链

图 10-2. 确认链

让我们举个例子来说明行为。想象一下从代理接收消息,转换内容,然后将此消息发送到远程服务。对于每个来自代理的消息,此过程都会创建一个链:

[(a) message from broker] -> [(b) message with the transformed content]

当一切正常时,框架会确认消息 (b),从而确认消息 (a)。消息的成功确认逻辑得以执行。但是,如果与远程服务交互失败,则在消息 (b) 上调用 nack 方法,这也会在消息 (a) 上调用 nack 方法。因此,执行与消息 (a) 关联的负面确认逻辑。

在更高级的场景中,这个链可能太死板,你会希望获得更多控制。通常情况下,你可能希望决定何时承认特定的消息,或者在处理之前而不是之后决定承认。当使用Message时,你有完全的控制权,可以决定故意不链接消息或等待某个条件承认。例如,当从单个消息产生多个消息时,你将在所有生成的消息都被确认后才确认该消息。无论使用情况如何,当使用Message时,不要忘记调用acknack。或者,你可以使用@Acknowledgment注解以更声明性的方式决定在哪里拆分链。

承认至关重要,而且在响应式消息传递中,所有消息必须被承认或未被承认。从响应式系统实施弹性和恢复模式至关重要。但是我们如何连接应用程序和消息代理?这就是你将在下一节中看到的内容。

连接器

连接器是将通道映射到外部受控组件(如队列或主题)的特定组件。它们特定于特定的协议或技术。有两种类型的连接器:

入站连接器

这些接收消息并将其传送到通道。它们必须执行响应式流背压协议,并创建具有适当的acknack逻辑的消息。

出站连接器

这些接收来自应用程序内部的消息并将它们发送到外部目的地。因此,它们将内部消息映射到外部格式,并跟踪结果以在传入消息上调用acknack方法。

Quarkus 提供了多个连接器。第十一章详细介绍了 Kafka 和 AMQP 连接器。HTTP 连接器允许将 HTTP 和 WebSockets 与消息处理绑定在一起。Camel 连接器允许集成遗留系统。在应用程序配置中,你需要指定用于每个映射到外部目的地的通道的连接器。

构建基于消息的应用程序

足够说了;是时候看看响应式消息传递的实际效果了。此示例位于chapter-10/hello-messaging目录中。要使用响应式消息传递,你需要在pom.xml文件中具有对quarkus-smallrye-reactive-messaging的依赖;参见 Example 10-14。

Example 10-14. Reactive Messaging 扩展的依赖项(chapter-10/hello-messaging/pom.xml)
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-smallrye-reactive-messaging</artifactId>
</dependency>

正如你将在下一章中看到的,你还需要为连接器添加依赖项。然而,在本章中,我们不会使用连接器。

响应式消息传递应用程序包含带有@Incoming@Outgoing注解的方法的 bean。Example 10-15 包含一个具有三个方法的单个 bean。

Example 10-15. Hello messaging application (chapter-10/hello-messaging/src/main/java/org/acme/HelloMessaging.java)
package org.acme;

import io.smallrye.mutiny.Multi;
import org.eclipse.microprofile.reactive.messaging.Incoming;
import org.eclipse.microprofile.reactive.messaging.Outgoing;

import javax.enterprise.context.ApplicationScoped;
import java.time.Duration;

@ApplicationScoped
public class HelloMessaging {

    @Outgoing("ticks")
    public Multi<Long> ticks() {
        return Multi.createFrom().ticks()
                .every(Duration.ofSeconds(1))
                .onOverflow().drop();
    }

    @Incoming("ticks")
    @Outgoing("hello")
    public String hello(long tick) {
        return "Hello - " + tick;
    }

    @Incoming("hello")
    public void print(String msg) {
        System.out.println(msg);
    }

}

这些方法形成一个处理管道。第一个方法ticksticks通道上生成消息。该方法返回一个Multi,每秒发出一个数字。这个数字被自动包装成一个简单的消息。然后,hello方法消费这些 ticks 并产生一个String,发送到hello通道。最后,print方法接收这些消息并在控制台上显示它们。我们在示例 10-16 中得到了这个管道。

示例 10-16. 处理管道
ticks() ---> [ticks] ---> hello() ----> [hello] ----> print()

如果你进入chapter-10/hello-messaging目录并运行mvn quarkus:dev,你会看到示例 10-17。

示例 10-17. Hello 消息
Hello - 1
Hello - 2
Hello - 3

如您所见,构建消息处理管道非常简单。在幕后,响应式消息创建了一个响应式流并创建了确认链。这将在下一节中进行详细说明。

消息和确认

为了更好地理解消息链,让我们看一下 chapter-10/messages-example 目录。在这个模块中,我们创建了Message的特定实现(MyMessage),当消息被ackednacked时在控制台上显示(示例 10-18)。

示例 10-18. Message的一个实现 (chapter-10/messages-example/src/main/java/org/acme/MyMessage.java)
public class MyMessage implements Message<String> {

    private final String payload;

    public MyMessage(String payload) {
        this.payload = payload;
    }

    public MyMessage(long l) {
        this(Long.toString(l));
    }

    @Override
    public String getPayload() {
        return payload;
    }

    @Override
    public Supplier<CompletionStage<Void>> getAck() {
        return () -> {
            System.out.println("Acknowledgment for " + payload);
            return CompletableFuture.completedFuture(null);
        };
    }

    @Override
    public Function<Throwable, CompletionStage<Void>> getNack() {
        return reason -> {
            System.out.println("Negative acknowledgment for "
                    + payload + ", the reason is " + reason);
            return CompletableFuture.completedFuture(null);
        };
    }
}

应用程序本身,如示例 10-19 所示,类似于上一节中的一个应用程序。我们每秒生成一条消息,但这次是MyMessage的实例,而不是自动包装成消息的有效载荷。hello方法接收这些消息并创建一个带有不同有效载荷的新消息。print方法保持不变。

示例 10-19. 消息使用 (chapter-10/messages-example/src/main/java/org/acme/MessageExample.java)
@Outgoing("ticks")
public Multi<MyMessage> ticks() {
    return Multi.createFrom().ticks().every(Duration.ofSeconds(1))
            .onOverflow().drop()
            .onItem().transform(MyMessage::new);
}

@Incoming("ticks")
@Outgoing("hello")
public Message<String> hello(Message<String> tick) {
    return tick.withPayload("Hello " + tick.getPayload());
}

@Incoming("hello")
public void print(String msg) {
    System.out.println(msg);
}

查看hello方法。它返回从接收到的消息构建的新消息。with方法将这两条消息链接起来形成链条。当返回的消息被确认时,接收到的消息也被确认。

如果你从chapter-10/messages-example目录运行mvn quarkus:dev,你应该看到这个:

Hello 1
Acknowledgment for 1
Hello 2
Acknowledgment for 2
Hello 3
Acknowledgment for 3

print方法调用完成特定消息时,它确认这条消息(在hello方法中创建),这也确认了由tick方法发出的消息。这就是为什么你会在控制台上看到“Acknowledgment for…”。

失败和负面确认

消息处理可能会失败,在这种情况下,我们期望失败的消息会nacked。为了说明这一点,让我们更新代码,当处理第三条消息时抛出异常,如示例 10-20 所示。

示例 10-20. 抛出异常会否定确认消息
@Incoming("hello")
public void print(String msg) {
    if (msg.contains("3")) {
        throw new IllegalArgumentException("boom");
    }
    System.out.println(msg);
}

重新启动应用程序。现在第三条消息被 nacked 了:

Hello 0
Acknowledgment for 0
Hello 1
Acknowledgment for 1
Hello 2
Acknowledgment for 2
2021-05-14 14:49:54,052 ERROR [io.sma.rea.mes.provider]
(executor-thread-1) SRMSG00200:
The method HelloMessaging#print has thrown an exception:
java.lang.IllegalArgumentException: boom
	at HelloMessaging.print(HelloMessaging.java:28)
	// ....
Negative acknowledgment for 3,
the reason is java.lang.IllegalArgumentException: boom
Hello 4
Acknowledgment for 4

抛出异常在消息上调用nack方法,并沿着链条调用MyMessage实例上的nack方法。

在这个示例中,我们的 acknack 实现是简单的。但它们展示了 acknack 如何通知消息代理有关处理结果。

流操作

逐个操纵消息很简单,但有时我们需要进行更复杂的处理。为了实现这一点,Reactive Messaging 允许直接操纵消息流。方法不再接收单个消息或负载,而是接收 Multi 并产生另一个 Multi(见 示例 10-21)。

示例 10-21. 使用 Reactive Messaging 进行流操作 (chapter-10/stream-example/src/main/java/org/acme/StreamingExample.java)
@ApplicationScoped
public class StreamingExample {

    @Outgoing("ticks")
    public Multi<Long> ticks() {
        return Multi.createFrom().ticks().every(Duration.ofSeconds(1))
                .onOverflow().drop();
    }

    @Incoming("ticks")
    @Outgoing("groups")
    public Multi<List<String>> group(Multi<Long> stream) {
        // Group the incoming messages into groups of 5.
        return stream
                .onItem().transform(l -> Long.toString(l))
                .group().intoLists().of(5);
    }

    @Incoming("groups")
    @Outgoing("hello")
    public String processGroup(List<String> list) {
        return "Hello " + String.join(",", list);
    }

    @Incoming("hello")
    public void print(String msg) {
        System.out.println(msg);
    }

}

你可以在 chapter-10/stream-example 目录中找到完整的代码。group 方法接收滴答声流作为输入,并将项目分组成五个元素的列表。processGroup 方法接收每个组并处理它们:

Hello 0,1,2,3,4
Hello 5,6,7,8,9
Hello 10,11,12,13,14
...

尽管此示例仅使用了 group 操作符,但你可以使用整个 Mutiny API 来编排异步调用、跳过消息、处理故障恢复或应用复杂的操作。

阻塞处理

Reactive Messaging 实现了反应式原则。它避免阻塞调用者线程,但有时无法做到这一点。想象一下冗长的处理或使用阻塞式 API。

面对这种情况时,你可以使用 @Blocking 注解,它会自动将处理切换到工作线程,然后再切换回 I/O 线程(见 示例 10-22)。

示例 10-22. 使用 @Blocking 注解方法 (chapter-10/blocking-example/src/main/java/org/acme/BlockingExample.java)
@Incoming("ticks")
@Outgoing("hello")
@Blocking
public String hello(long tick) {
    // Simulate a long operation
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
    return "Hello - " + tick;
}

blocking-example 目录中,你可以找到我们简单管道的修改版本,模拟在 hello 方法中进行长时间操作。使用 Thread.sleep 是阻塞的,所以不能在 I/O 线程上执行。幸运的是,由于 @Blocking 注解,该方法在工作线程上被调用。当与阻塞式 API 集成时,@Blocking 注解特别有趣。但请不要滥用它,因为它会降低应用程序的并发性。

重试处理

偶发性失败是常有的事。网络中断或临时不可用是任何分布式系统生活的一部分。

要处理这种情况,你可以使用 Mutiny API 并使用 onFailure.retry,但也可以使用 SmallRye Fault-Tolerance 及其 @Retry 注解。首先,你需要声明对 Fault-Tolerance 的依赖,如 示例 10-23 所示。

示例 10-23. 容错支持的依赖 (chapter-10/fault-tolerance-example/pom.xml)
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-smallrye-fault-tolerance</artifactId>
</dependency>

然后,你可以使用 @Retry 注解,它会自动捕获异常并重试调用。在 chapter-10/fault-tolerance-example 中,你可以在 示例 10-24 中看到代码。

示例 10-24. 重试消息处理 (chapter-10/fault-tolerance-example/src/main/java/org/acme/FaultToleranceExample.java)
@Incoming("ticks")
@Outgoing("hello")
@Retry(maxRetries = 10, delay = 1, delayUnit = ChronoUnit.SECONDS)
public String hello(long tick) {
    maybeFaulty(); // Randomly throws an exception
    return "Hello - " + tick;
}

maybeFaulty 方法会随机抛出异常。因此,使用 @Retry 注解重试消息的处理,希望能获得更好的结果。请记住,如果您的处理不是幂等的,请不要重试!这可能会产生可怕的后果。最好将有故障的消息存储在死信队列中(这将在下一章中讨论)。

将所有内容整合在一起

最近的几节演示了反应式消息传递提供的一些特性。这些示例是故意简化的。现在让我们来处理一个更加现实的流水线,我们将接收 HTTP 请求,操作请求体,并将其写入数据库。我们将使用 RESTEasy Reactive 和 Hibernate Reactive,这些内容我们在第 8 和第九章节已经看到。尽管可以完全不使用反应式消息传递来实现应用程序,但我们使用它来演示如何构建更复杂的流水线。

该应用程序的代码位于 chapter-10/database-example 目录中。该应用程序由四个类组成。首先是 Person 类,它是一个 Hibernate Reactive Panache 实体。此实体包含两个字段:名称(唯一)和年龄。在此应用程序中,用户以 JSON 形式发布 Person 实例,这些实例将发送到反应式消息传递流水线(如 示例 10-25 所示)。

示例 10-25. Person 结构 (chapter-10/database-example/src/main/java/org/acme/Person.java)
package org.acme;

import io.quarkus.hibernate.reactive.panache.PanacheEntity;

import javax.persistence.Column;
import javax.persistence.Entity;

@Entity
public class Person extends PanacheEntity {

    @Column(unique = true)
    public String name;

    public int age;

}

HTTPEndpoint 类使用发射器将接收到的 Person 实例发送到 upload 通道。此外,此类还有两个方法。upload 方法接收用户发送的 Person 并发出它。getAll 方法从数据库返回存储的 Person 实例列表。在 示例 10-26 中,我们使用此方法来验证一切是否按预期工作。upload 方法返回 Uni<Response>。在正面确认消息被积极接受后,它异步创建 HTTP 响应(然后返回 202 - Accepted 响应),或者在否定确认消息时(然后返回带有错误消息的 400 - Bad Request 响应)。因此,当处理成功时,用户在完成数据库插入后收到其响应。

示例 10-26. HTTP 端点 (chapter-10/database-example/src/main/java/org/acme/HttpEndpoint.java)
package org.acme;

import io.smallrye.mutiny.Uni;
import io.smallrye.reactive.messaging.MutinyEmitter;
import org.eclipse.microprofile.reactive.messaging.Channel;

import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.core.Response;
import java.util.List;

@Path("/")
public class HttpEndpoint {

    @Channel("upload")
    MutinyEmitter<Person> emitter;

    @POST
    public Uni<Response> upload(Person person) {
        return emitter.send(person)
                .replaceWith(Response.accepted().build())
                .onFailure()
                .recoverWithItem(t ->
                        Response.status(Response.Status.BAD_REQUEST)
                                .entity(t.getMessage()).build());
    }

    @GET
    public Uni<List<Person>> getAll() {
        return Person.listAll();
    }

}

Processing Bean 接收上传的 Person 实例,并验证和格式化输入(参见 示例 10-27)。

示例 10-27. 处理 Person 实例 (chapter-10/database-example/src/main/java/org/acme/Processing.java)
package org.acme;

import org.eclipse.microprofile.reactive.messaging.Incoming;
import org.eclipse.microprofile.reactive.messaging.Outgoing;

import javax.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class Processing {

    @Incoming("upload")
    @Outgoing("database")
    public Person validate(Person person) {
        if (person.age <= 0) {
            throw new IllegalArgumentException("Invalid age");
        }

        person.name = capitalize(person.name);

        return person;
    }

    public static String capitalize(String name) {
        char[] chars = name.toLowerCase().toCharArray();
        boolean found = false;
        for (int i = 0; i < chars.length; i++) {
            if (!found && Character.isLetter(chars[i])) {
                chars[i] = Character.toUpperCase(chars[i]);
                found = true;
            } else if (Character.isWhitespace(chars[i])) {
                found = false;
            }
        }
        return String.valueOf(chars);
    }

}

它将结果转发到 database 通道。Database 类读取此通道,并将接收到的 Person 写入数据库。为实现此目的,我们使用 Panache 提供的 withTransactionpersist 方法,正如 示例 10-28 中所示。

示例 10-28. 将实体持久化到数据库 (chapter-10/database-example/src/main/java/org/acme/Database.java)
package org.acme;

import io.quarkus.hibernate.reactive.panache.Panache;
import io.smallrye.mutiny.Uni;
import org.eclipse.microprofile.reactive.messaging.Incoming;

import javax.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class Database {

    @Incoming("database")
    public Uni<Void> write(Person person) {
        return Panache.withTransaction(person::persist)
                .replaceWithVoid();
    }

}

此流水线仅传递有效负载。因此,当最后一步完成时,它确认消息,这通过消息链通知了发射器。

chapter-10/database-example 目录中使用 mvn quarkus:dev 运行应用程序。无需准备数据库;Quarkus 为您启动了一个测试数据库。然后,在终端中,通过使用 示例 10-29 发送一个 Person 实例。

示例 10-29. 上传一个新的人员
>  curl -v --header "Content-Type: application/json"  \
  POST --data '{"name":"Luke", "age":19}' \
  http://localhost:8080

您应该收到202 - Accepted的响应。如果尝试发送无效负载,就像 示例 10-30 中显示的那样,您将收到400的响应。

示例 10-30. 上传一个无效的人员
>  curl -v --header "Content-Type: application/json" \
  POST --data '{"name":"Leia"}' \
  http://localhost:8080

您可以使用curl来检查存储的实例。

摘要

除了具有响应式引擎并提供与 HTTP 和数据库交互的异步非阻塞方式外,Quarkus 还提供了基于消息的模型称为响应式消息。

请记住:

  • 响应式消息允许接收、处理和消耗通过通道传输的消息。

  • 通道可以是应用程序内部的,正如您在本章中看到的那样,也可以映射到外部目标,正如您将在下一章中看到的那样。

  • 响应式消息支持肯定和否定确认。您可以决定需要的控制量。

  • 响应式消息允许单独处理消息,或支持 Mutiny API 来实现更复杂的转换。

在下一章中,我们将探讨两个连接器,它们允许与 Apache Kafka 和 AMQP 交互,以构建响应式系统。

¹ 在本书中,我们使用 MutinyEmitter,但您可以使用普通的 Emitter,提供略有不同的 API。

² 您可以在 GitHub 上找到支持的签名列表

第十一章:事件总线:骨干

在第十章中,我们讨论了反应式消息传递,并利用其注解来生成、消费和处理消息,以及桥接命令式和反应式编程。本章将更深入地探讨使用反应式消息传递构建反应式系统的骨架,重点介绍 Apache Kafka 和高级消息队列协议(AMQP)的使用。¹

Kafka 还是 AMQP:选择合适的工具

许多消息传递解决方案让您能够实现事件驱动架构、事件流处理以及反应式系统。最近,Apache Kafka 在这一领域中成为了一个显赫的角色。AMQP 是另一种消息传递方法,也不应立即被排除在外。两者各有优缺点。您的选择完全取决于您的用例,以及团队现有的技能和经验。

本节不偏爱某个事件总线,而是详细介绍了每个事件总线的特性和行为,以及它们的优势和劣势。我们希望为您提供关于每个系统的充足信息,以帮助您确定它们如何适合特定系统的用例。

在高层次上,可以将 Kafka 描述为具有聪明消费者的愚蠢代理,而 AMQP 则具有聪明代理但愚蠢消费者。有时候选择的关键在于在实施解决方案时所需的灵活性。

当然,这可能有些陈词滥调,但确实没有一种事件总线适合所有情况。每种情况都有特定的需求和用例需要满足,需要仔细评估每个事件总线的优缺点。请注意,对于您的用例,其他消息解决方案可能更合适,如Solace PubSub+ PlatformMicrosoft Azure Event HubsRabbitMQNATS

使用 Kafka 构建反应式系统

自 2011 年 LinkedIn 开源 Apache Kafka 以来,它已迅速成为事件驱动领域中最重要的角色之一。在微服务、无服务器架构以及分布式系统普及的推动下,Kafka 成为开发人员需要的消息传递支柱之一。

在第十章中已经给出了使用 Kafka 的例子,但我们没有解释它在底层的重要细节,这些细节对于深入理解它是必要的。本章将深入探讨,解释如何在反应式系统中使用 Kafka 作为连接组织。我们不打算详尽覆盖 Kafka 的所有细节,但会介绍足够量的内容,让您能够理解 Kafka 运作的方式,以便有效地开发反应式系统。首先,我们需要了解 Kafka 的基础知识。

Apache Kafka

Kafka 是一个强大的分布式提交日志,如果你是开发者,你可能熟悉另一个分布式提交日志,Git!在与 Kafka 通信时,我们使用一个记录或事件作为我们希望写入日志的信息片段,然后稍后从日志中读取。每个记录分组在日志中有一个主题,用于跟踪(图 11-1)。

Kafka 主题

图 11-1. Kafka 主题

记录只能包含四个信息片段:

关键

当将记录写入日志时由 Kafka 分配,但也可以在分区中使用,我们稍后会涵盖这一点

我们希望存储在日志中以供消费者检索的实际值或负载

时间戳

在创建记录时可选设置,或者在将记录写入日志时由 Kafka 设置

标头

提供给 Kafka 的关于记录的可选元数据,或者供下游消费者使用的额外信息

图 11-2 概述了与日志交互的过程。

生产和消费记录

图 11-2. 生产和消费记录

创建记录后,我们使用生产者将其写入 Kafka 中的日志。我们可以有一个或多个生产者实例将相同类型的记录写入日志,因为我们写入日志的方式与消费的方式是解耦的。一旦记录写入日志,我们就使用消费者从日志中读取记录,并进行需要的处理。

当将记录写入日志时,生产者始终进行追加。生产者无法插入或删除记录。追加方式意味着 Kafka 可以提供高可伸缩性的写入能力。因为对现有记录没有争用或锁定,每次写入都是一个新记录。

在 Kafka 中,生产者和消费者的分离是一个关键概念。这种记录写入和消费时间的解耦对于响应式系统至关重要。当然,只有当日志保留策略足够长,以防止任何生产的记录在被消费之前被 Kafka 删除时,才能完全实现这一点!我们不希望将所有记录记录下来,然后在多年后消费之前被 Kafka 删除。

在第十章中,您看到如何使用 @Outgoing 生产消息。让我们稍微修改该示例,还设置一个记录的键,如示例 11-1 所示。

示例 11-1. 配置 Kafka 出站元数据
@Outgoing("my-channel")
Multi<Message<Person>> produceAStreamOfMessagesOfPersons() {
    return Multi.createFrom().items(
            Message.of(new Person("Luke"))
                .addMetadata(OutgoingKafkaRecordMetadata.builder()
                        .withKey("light").build()),
            Message.of(new Person("Leia"))
                .addMetadata(OutgoingKafkaRecordMetadata.builder()
                        .withKey("light").build()),
            Message.of(new Person("Obiwan"))
                .addMetadata(OutgoingKafkaRecordMetadata.builder()
                        .withKey("light").build()),
            Message.of(new Person("Palpatine"))
                .addMetadata(OutgoingKafkaRecordMetadata.builder()
                        .withKey("dark").build())
    );
}

在这里,我们有一个键,指示该人是光明还是黑暗面的一部分。要切换到将消息发送到 Kafka,我们需要进行两个更改。首先,修改 pom.xml 以包括 SmallRye Reactive Messaging for Kafka 依赖项(示例 11-2)。

示例 11-2. Kafka 连接器依赖项(/Users/clement/Documents/book/code-repository/chapter-11/processor/pom.xml
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-smallrye-reactive-messaging-kafka</artifactId>
</dependency>

最后,按照示例 11-3 配置依赖项。

示例 11-3. 配置 Kafka 连接器以写入记录(chapter-11/processor/src/main/resources/application.properties
mp.messaging.outgoing.my-channel.connector=smallrye-kafka
mp.messaging.outgoing.my-channel.topic=starwars
mp.messaging.outgoing.my-channel.value.serializer=\
 org.apache.kafka.common.serialization.StringSerializer

配置指示我们正在使用的连接器smallrye-kafka,通道应该写入的主题名称,以及用于转换负载内容的序列化器。如果要写入的主题与通道名称匹配,我们将不需要topic配置,因为通道名称是默认值。

在消费方面,我们可以通过示例 11-4 读取键。

示例 11-4. 提取传入的 Kafka 元数据
@Incoming("my-channel")
CompletionStage<Void> consume(Message<Person> person) {
    String msgKey = (String) person
            .getMetadata(IncomingKafkaRecordMetadata.class).get()
            .getKey();
    // ...
    return person.ack();
}

我们还需要与我们使用 Kafka 的出站配置相似的配置;参见示例 11-5。

示例 11-5. 配置 Kafka 连接器以轮询记录(chapter-11/processor/src/main/resources/application.properties
mp.messaging.incoming.my-channel.connector=smallrye-kafka
mp.messaging.incoming.my-channel.topic=starwars
mp.messaging.incoming.my-channel.value.deserializer=\
 org.apache.kafka.common.serialization.StringDeserializer

到目前为止,我们已经泛泛地提到了写入记录到日志和消费记录从日志。正如您在图 11-1 中看到的那样,主题是一个日志,用于组织和持久存储记录。此日志是特定类型记录或一组记录的日志,使我们能够根据这些记录的特定需求定制行为。例如,如果记录的数量非常大并且对应用程序长达一周的时间没有意义,我们可以更改一个主题的保留策略,仅保留该期间内的记录,即使它们没有被消费。我们还可以有另一个主题,其记录保留时间长达六个月甚至无限期。

同样在图 11-1 中,您可以看到每个记录都表示为带有编号的框;这代表了记录在主题中写入的偏移量或索引。在这个示例中,已经写入了六条记录,并且生产者即将写入第七条记录,其偏移量为 6。我们还看到一个消费者正在读取偏移量为 0 的记录,即主题中的第一条记录。虽然默认情况下新消费者从第一个偏移量开始读取记录,但我们可以决定从任何偏移量开始。

另一种考虑主题的方式是将其视为表示外部目标的虚拟地址。当生产者将记录写入主题时,它不知道记录将何时、是否或甚至在何处被消费者读取。使用虚拟地址或主题提供了在空间和时间上解耦我们的反应式系统组件的方式。

消费者可以与其他消费者结合形成一个消费者组。任何使用相同消费者组名称或标识符创建的消费者都将放置在同一个消费者组中。当创建一个没有设置消费者组标识符的消费者时,默认情况下会得到一个包含单个消费者的消费者组。

到目前为止,我们描述的是具有单个记录日志的主题。分区是我们解决单一记录方法所带来问题的方式。分区有助于提高主题的读写性能,因为我们将单个主题分割为多个分区。

我们不再使用单个分区和单个消费者,而是使用三个分区,并且每个分区都有一个独立的消费者读取记录。从非科学的角度看,我们可以预期使用三个分区和三个消费者的吞吐量是单个分区和消费者的三倍。

尽管我们提到了三个消费者对应三个分区,但在 图 11-3 中,我们有两个消费者在同一个消费者组内。一个消费者负责从两个分区读取记录,以确保所有分区都有消费者。通过对主题进行分区,我们现在通过增加消费者来提高吞吐量。

主题分区

图 11-3. 主题分区

在 图 11-3 中所示的情况下,生产者可以将记录写入主题,由代理决定将记录实际写入到哪个分区。另外,生产者也可以明确指定记录应写入的分区。如果记录具有唯一于其内容的键,例如 Person 记录的用户名,使用 Kafka 键哈希算法可以高效地确定适当的分区。这将确保具有相同键的所有记录都写入同一分区。然而,我们需要小心确保任何键都合理分布。否则,我们可能会产生热分区(例如,按国家分区可能会导致美国分区的记录数量达到数万亿条,而安道尔分区只有几千条记录)。

目前,我们在弹性方面存在问题,因为所有分区都在同一个代理实例上。在 图 11-4 中,我们已经将主题复制到了三个分区上。

主题分区复制

图 11-4. 主题分区复制

为了支持弹性,并确保消费者不会从不同的代理读取同一分区中的相同记录,为消费者选择了一个领导分区进行读取。在这个示例中,第一个代理中的分区 0,第二个代理中的分区 1,第三个代理中的分区 2 是领导分区。

根据我们在 图 11-4 中的设计,Kafka 确保我们的消费者不能在分区中的记录成功复制之前读取。它通过跟踪 高水位偏移量 来实现这一点,即所有分区中最后一条成功复制的消息的偏移量。经纪人阻止消费者读取高水位偏移量之后的记录,以防止读取未复制的记录。

点对点通信

Kafka 不是传统的消息传递系统。用它实现标准的传递模式可能会令人困惑。

通过 点对点通信,我们希望同一条消息只被一个消费者消费一次,无论是同一消费者还是同一消费者组中的其他消费者。请注意,面对网络故障时,无法保证记录仅由消费者组中的一次消费。您需要准备好看到重复的消息。

我们使用 消费者组 来扩展 Kafka 中的消费者,以执行相同的处理以提高吞吐量。在 图 11-5 中,仅有一个消费者可以在单个主题分区中读取记录,符合点对点通信的需求。在这种情况下,我们看到消费者 2 无法读取记录,因为它与消费者 1 属于同一消费者组,实际上使得消费者 2 处于空闲状态。

消费者组

图 11-5. 消费者组

为什么我们不能让同一组中的两个消费者从同一个分区中读取?Kafka 为给定消费者组跟踪每个分区的最后提交偏移量,并使用该偏移量重新启动处理。然而,消费者在完全完成记录处理之前不会提交偏移量。这就创建了一个窗口,在这个窗口中,同一组中的多个消费者可能读取同一条记录,从而重复处理消息。

当一个新的消费者订阅一个消费者组时,如果 Kafka 不知道分区的最后提交偏移量,有两种策略。这些策略分别是 Earliest,消费者从分区的第一个偏移量开始读取事件,以及 Latest,它只消费消费者订阅后收到的事件。

发布/订阅

点对点确保消息只被消费一次。另一种流行的模式是将消息分发给多个消费者。使用 发布/订阅 模型,我们可以有许多订阅者或消费者读取同一条消息,通常是为了不同的目的。

图 11-6 展示了两个消费者组从同一个主题消费消息。一个消费者组有三个消费者,而另一个有两个消费者。我们看到每个分区只被同一消费者组中的单个消费者读取,但跨消费者组有多个消费者。虽然这两个消费者组连接到同一主题及其分区,但每个消费者并无需在相同的偏移量处。这种要求将取消不同组能够消费记录的优势。

多个消费者组

图 11-6. 多个消费者组

弹性模式

弹性 是反应式系统的支柱之一。Kafka 提供的分区机制允许我们实现弹性模式。图 11-6 也突出了 Kafka 中消费者组的弹性模式。消费者组 1 有三个消费者,每个从不同的分区消费。如果由于任何原因消费者失败,另一个消费者将接管从现在无消费者的分区读取的负载。消费者弹性确保只要至少有一个消费者存在,所有分区都在被消费。当然,这种情况确实会降低吞吐量,但比不消费任何记录要好。消费者组 2 可能代表这种情况。

然而,消费者组的弹性是有限的。正如我们之前提到的,同一组内多个消费者不可能从同一分区读取。在 图 11-6 中,有三个分区,我们仅限于单个消费者组内的三个消费者。同一组中的任何额外消费者将处于空闲状态,因为我们不能让同一组中的多个消费者连接到同一分区。

在确定主题所需的分区数时,弹性是一个重要因素。分区过少会限制处理记录的吞吐量,而过多则可能导致消费者空闲,如果记录在分区间分布不均匀的话。

处理故障

故障在分布式系统中时常发生!这是其性质,即使在开发反应式系统时也无法避免。然而,Kafka 为我们提供了适当处理故障的机制。

提交策略

每个消费者定期通知代理其最新的 偏移量提交。该数字代表消费者从主题分区成功处理的最后一条消息。当当前消费者失败或崩溃时,偏移量提交将成为分区的新消费者的起点。

提交偏移量不是一项廉价的操作。出于性能考虑,我们建议不要在处理每个记录后都提交偏移量。Quarkus 提供了几种用于与 Kafka 一起使用的提交策略选项:

  • 受限

  • 忽略

  • 最新

Throttled策略是默认选项,跟踪消费者接收的记录并监控它们的确认。当处理完一个位置之前的所有记录时,该位置将作为该消费者组的新偏移量提交到代理。如果任何记录既没有确认也没有拒绝确认,就不再可能提交新的偏移量位置,并且记录将继续排队。如果没有能力退出,最终会导致内存不足错误。通过向连接器报告故障,Throttled 策略可以检测到这个问题,使应用标记为不健康状态。请注意,这种情况通常是应用程序错误导致消息“丢失”。

Ignore策略利用 Kafka 消费者的默认偏移提交,该提交周期性地在轮询新记录时发生。此策略忽略消息确认,并依赖于记录处理为同步。当使用enabled.auto.commit=true时,此策略是默认的。任何失败的异步处理将未知于轮询新记录以消费的过程。

如果我们将commit-strategy设置为ignore,并将enable.auto.commit设置为false,如示例 11-6 所示,将永远不会提交任何偏移量。每次新消费者从主题读取消息时,它将始终从偏移量 0 开始。在某些情况下,这种方法是可取的,但需要有意识地选择。

示例 11-6. 配置提交策略
mp.messaging.incoming.my-channel.connector=smallrye-kafka
mp.messaging.incoming.my-channel.enable.auto.commit=false
mp.messaging.incoming.my-channel.commit-strategy=ignore

Latest将在每个消息确认后提交偏移量,正如我们之前描述的那样,这会影响消费者的性能。在低吞吐量场景中,这种策略可能更可取,以提高偏移量的准确性。

确认策略

在“致谢”中,您了解了响应式消息如何利用acknack通知上游响应式流记录处理状态。这些确认方法是我们为 Kafka 提供的故障处理策略的一部分。应用程序使用其中一种策略配置 Kafka 连接器。

最简单且默认的策略是Fail Fast。当应用程序拒绝消息时,连接器会被通知失败,并停止应用程序。如果失败是短暂性的,如网络问题,重新启动应用程序应该能够使处理继续进行。但是,如果特定记录导致消费者失败,应用程序将处于失败→停止→重新启动的永久循环中,因为它将不断尝试处理导致失败的记录。

另一个简单的策略是 忽略。任何未确认的消息都会被记录,然后在消费者继续处理新记录时被忽略。当我们的应用程序在内部处理任何故障时,忽略策略非常有用,因此我们不需要通知消息生产者有故障发生,或者由于正在处理的消息类型允许偶尔忽略某些消息。但是,如果大量消息被忽略,值得调查其根本原因,因为这可能不是预期的后果。

最后一个故障处理策略是 死信队列。它将失败的记录发送到特定主题,以便稍后自动或手动处理。

死信队列

这种策略在消息系统存在的时间内一直是消息系统的一部分!与其立即失败或忽略任何失败,这种策略将无法处理的消息存储到单独的目标或主题中。存储失败的消息使得管理过程(人工或自动化)能够确定正确的操作以解决处理失败。

需要注意的是,只有当所有消息的顺序不重要时,死信队列策略才能起作用,因为我们不会因为等待死信队列中的消息处理失败而停止处理新消息。

当选择这种策略时,默认主题被命名为 dead-letter-topic-*[topic-name]*。对于我们之前的示例,将是 dead-letter-topic-my-channel。可以按照 示例 11-7 中所示的方式配置主题名称。

示例 11-7. 配置故障策略以使用 DLQ
mp.messaging.incoming.my-channel.failure-strategy=dead-letter-queue
mp.messaging.incoming.my-channel.dead-letter-queue.topic=my-dlq

我们甚至可以从 dead-letter-reason 头部检索与消息关联的失败原因(示例 11-8)。

示例 11-8. 检索失败原因
@Incoming("my-dlq")
public CompletionStage<Void> dlq(Message<String> rejected) {
  IncomingKafkaRecordMetadata<String, String> metadata =
      rejected.getMetadata(IncomingKafkaRecordMetadata.class);
  String reason = new String(metadata.getHeaders()
    .lastHeader("dead-letter-reason").value());
}

使用 DLQ 需要另一个应用程序或人工操作员来处理发送到 DLQ 的记录。这些记录可以重新引入到初始主题(但顺序会丢失),也可以丢弃,或者需要进行一些减轻逻辑。

背压和性能考虑

没有适当的背压来避免过载组件,就无法实现真正的反应式系统。那么我们如何处理 Kafka 的背压?

与 Kafka 一起使用的出站连接器,用于 @OutgoingEmitter,使用等待从代理收到确认的飞行消息数。飞行消息 是连接器已发送到 Kafka 代理以写入主题的消息,但是尚未收到成功存储记录的确认。

我们调整在出站 Kafka 连接器中的流控以调整背压。默认的最大并发消息数为 1,024。如果数字太高,可能会导致更高的内存使用,根据负载大小可能会出现内存不足错误,而数字太低会降低吞吐量。我们可以通过属性 max-inflight-messages 来自定义连接器中的最大并发消息数。

在消费者端,Kafka 将根据 Reactive Streams 请求暂停和恢复消费者。我们已经讨论了很多关于 Kafka 的内容,所以下一节我们将在 Kubernetes 中探索它!

Kubernetes 上的 Kafka

要在 Kubernetes 上使用 Kafka,我们需要安装 Kafka。我们将使用 Strimzi 项目来安装 Kafka。该项目有一个用于在 Kubernetes 中管理 Kafka 部署的操作员。

在 Kubernetes 中设置 Kafka 之前,我们需要一个 Kubernetes 环境。如果您已经有了一个,那太棒了!如果没有,我们建议您使用 minikube,详细信息请参见《新生代:云原生和 Kubernetes 原生应用》。

注意

在 minikube 上运行 Kafka 可能需要比常规部署更多的内存,因此我们建议至少启动它时配置 4 GB 的 RAM:

minikube start --memory=4096

在运行 Kubernetes 环境时,我们需要安装 Strimzi,如示例 11-9 所示。确保已安装 Helm,因为我们将使用它来安装 Strimzi。

示例 11-9. 安装 Strimzi
kubectl create ns strimzi             ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/1.png)
kubectl create ns kafka               ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/2.png)

helm repo add strimzi https://strimzi.io/charts            ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/3.png)
helm install strimzi strimzi/strimzi-kafka-operator -n strimzi \
    --set watchNamespaces={kafka} --wait --timeout 300s      ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/4.png)

1

为 Kubernetes 操作员创建 strimzi 命名空间。

2

Kafka 集群的命名空间。

3

将 Strimzi 图表库添加到 Helm 中。

4

将 Strimzi 操作员安装到 strimzi 命名空间中。

安装成功后,请验证运行操作员(如示例 11-10 所示)。

示例 11-10. Strimzi 操作员状态
kubectl get pods -n strimzi
NAME                                        READY   STATUS    RESTARTS   AGE
strimzi-cluster-operator-58fcdbfc8f-mjdxg   1/1     Running   0          46s

现在是创建 Kafka 集群的时候了!首先我们需要定义要创建的集群,如示例 11-11 所示。

示例 11-11. Kafka 集群定义
apiVersion: kafka.strimzi.io/v1beta2
kind: Kafka
metadata:
  name: my-cluster                      ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/1.png)
spec:
  kafka:
    replicas: 1                         ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/2.png)
    listeners:
      - name: plain
        port: 9092
        type: internal
        tls: false
      - name: tls
        port: 9093
        type: internal
        tls: true config:
      offsets.topic.replication.factor: 1
      transaction.state.log.replication.factor: 1
      transaction.state.log.min.isr: 1
    storage:
      type: ephemeral                  ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/3.png)
  zookeeper:
    replicas: 1
    storage:
      type: ephemeral
  entityOperator:
    topicOperator: {}
    userOperator: {}

1

集群名称。

2

在集群中创建 Kafka 副本的数量。在生产环境中,我们希望有多个副本,但为了测试,这可以减少内存需求。

3

我们选择临时存储,以减少测试需求。

现在我们使用 示例 11-11 来创建一个符合请求定义的 Kafka 集群,如示例 11-12 所示。

示例 11-12. 创建 Kafka 集群
kubectl apply -f deploy/kafka/kafka-cluster.yaml -n kafka

验证我们想要的集群是否已创建(示例 11-13)。

示例 11-13. Kafka 集群状态
kubectl get pods -n kafka
NAME                                          READY   STATUS    RESTARTS   AGE
my-cluster-entity-operator-765f64f4fd-2t8mk   3/3     Running   0          90s
my-cluster-kafka-0                            1/1     Running   0          113s
my-cluster-zookeeper-0                        1/1     Running   0          2m12s

集群运行后,我们创建我们需要的 Kafka 主题(示例 11-14)。

示例 11-14. 创建 Kafka 主题
kubectl apply -f deploy/kafka/ticks.yaml
kubectl apply -f deploy/kafka/processed.yaml

为了向您展示 Kafka 如何与 Kubernetes 协同工作,我们将使用一个示例,该示例由三个服务组成:每两秒钟产生一个 tick,接收消息并添加处理它的消费者详细信息,并通过 SSE 公开所有消息。这三个服务将用于展示 Kafka 的消费者处理。请按照 /chapter-11/README.md应用程序部署 部分的说明来构建所需的 Docker 镜像并使用 Helm 安装服务。

服务运行后,现在是测试的时候!在浏览器中打开 SSE 终端点,您将看到类似于 示例 11-15 的数据。

示例 11-15. SSE 输出:所有消息由同一 Pod 消费
data:1 consumed in pod (processor-d44564db5-48n97)
data:2 consumed in pod (processor-d44564db5-48n97)
data:3 consumed in pod (processor-d44564db5-48n97)
data:4 consumed in pod (processor-d44564db5-48n97)
data:5 consumed in pod (processor-d44564db5-48n97)

尽管我们的主题有三个分区,我们可以看到单个消费者消费的所有 ticks。让我们扩展 processor 来向同一组添加更多消费者(示例 11-16)。

示例 11-16. 增加应用程序实例的数量
kubectl scale deployment/processor -n event-bus --replicas=3

在浏览器中,我们现在看到了同一组的三个消费者处理的消息,增加了吞吐量和并发性能(示例 11-17)。

示例 11-17. SSE 输出:消息由三个 Pod 消费
data:11 consumed in pod (processor-d44564db5-2cklg)
data:12 consumed in pod (processor-d44564db5-48n97)
data:13 consumed in pod (processor-d44564db5-s6rx9)
data:14 consumed in pod (processor-d44564db5-2cklg)
data:15 consumed in pod (processor-d44564db5-s6rx9)
data:16 consumed in pod (processor-d44564db5-48n97)
data:17 consumed in pod (processor-d44564db5-2cklg)

如果我们启动了另一个 processor 实例,但没有设置 mp.messaging.incoming.ticks.group. id=tick-consumer,我们将看到来自新消费者的消息编号重复,因为它们有自己的消费者组和偏移位置。

使用 AMQP 构建响应式系统

高级消息队列协议Advanced Message Queuing Protocol),或 AMQP,是自 2002 年以来存在的面向消息的中间件应用层协议。AMQP Broker 是一个高度先进的消息代理,具有极大的灵活性和根据应用需求的可定制性。

我们这里没有涵盖 AMQP Broker 的所有可能用途。有多种经纪人拓扑结构支持多种不同的用例,简单地试图将所有信息都压缩到本节中是不现实的!Robert Godfrey on InfoQ 展示了 AMQP 1.0 的核心功能并介绍了一些可能性。

与 Kafka 不同,所有的 智能 都在 AMQP Broker 内部,它了解拓扑结构、客户端、消息状态、已交付和尚未交付的内容。

AMQP 1.0

AMQP 1.0是在应用程序或组织之间传递业务消息的开放标准。它由几个层次组成,最低层是用于在两个进程之间传输消息的二进制线级协议。在线级协议之上是消息传递层,它定义了抽象消息格式和编码。线级协议使得不同类型的客户端能够与 AMQP 代理发送和接收消息,只要它们支持 AMQP 规范的相同 1.0 版本。

在 Quarkus 中使用 AMQP 1.0 连接器需要依赖项示例 11-18。

示例 11-18. AMQP 连接器的依赖项
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-smallrye-reactive-messaging-amqp</artifactId>
</dependency>

点对点通信

使用 AMQP,点对点通信是通过队列而不是主题来实现的。在 AMQP 术语中,队列被称为单播,意味着任何消费者都可以读取消息,但其中只有一个(图 11-7)。我们添加到队列的消息可以是持久的,就像 Kafka 一样,但它们也可以是非持久的。当消息是非持久的时候,在消息被消费之前,如果代理在此之前重新启动,消息将会丢失。

AMQP 队列消费者

图 11-7. AMQP 队列消费者

Kafka 和 AMQP 在点对点通信方面的一个关键区别是,在 AMQP 中,一旦消息被消费者读取,消息将从队列中移除,并且不以任何方式保留。AMQP 暂时存储消息直到它们被消费,而 Kafka 保留日志中的所有消息,至少直到日志保留策略开始删除旧消息为止。这使得 AMQP 不适合需要在反应系统中某个时刻重新播放消息的用例。

我们还可以有多个消费者从同一个队列中读取消息,但代理确保只有其中一个消费者读取单个消息。与 Kafka 相比,AMQP 在缩放消费者方面没有相同的吞吐量限制。我们可以有几十个消费者从单个队列中读取消息,只要顺序不重要。

发送消息到 AMQP!在添加我们之前提到的依赖项(示例 11-18)后,我们需要按照示例 11-19 中展示的方式配置代理属性,以便连接器知道 AMQP 代理的位置。

示例 11-19. 配置 AMQP 代理位置和凭据
amqp-host=amqp
amqp-port=5672
amqp-username=username
amqp-password=password

mp.messaging.outgoing.data.connector=smallrye-amqp

我们已经为主机、端口、用户名和密码设置了全局的 AMQP 代理配置,这意味着我们定义的任何通道都将使用相同的 AMQP 代理配置。如果需要,配置可以基于每个通道设置。我们还指示使用smallrye-amqp连接器作为data输出通道。

默认情况下,通道使用队列的持久消息,或者我们可以通过mp.messaging.outgoing.data.durable=false使它们成为非持久消息。我们还可以在发送消息时直接覆盖消息的持久性,就像在示例 11-20 中展示的那样。

示例 11-20. 使用出站元数据发送持久消息
@Outgoing("data")
Multi<Message<Person>> produceAStreamOfMessagesOfPersons() {
  return Multi.createFrom().items(
      Message.of(new Person("Luke"))
          .addMetadata(OutgoingAmqpMetadata.builder().withDurable(false).build()),
      Message.of(new Person("Leia"))
          .addMetadata(OutgoingAmqpMetadata.builder().withDurable(false).build()),
      Message.of(new Person("Obiwan"))
          .addMetadata(OutgoingAmqpMetadata.builder().withDurable(false).build()),
      Message.of(new Person("Palpatine"))
          .addMetadata(OutgoingAmqpMetadata.builder().withDurable(false).build())
  );
}

我们可以类似于 Kafka 消费消息,但使用 AMQP 元数据对象来检索关于消息的更详细信息(详见 示例 11-21)。

示例 11-21. 从传入消息中提取 AMQP 元数据
@Incoming("data")
CompletionStage<Void> consume(Message<Person> person) {
    Optional<IncomingAmqpMetadata> metadata = person
            .getMetadata(IncomingAmqpMetadata.class);
    metadata.ifPresent(meta -> {
        String address = meta.getAddress();
        String subject = meta.getSubject();
        });
    // ...
    return person.ack();
}

成功接收和处理消息会导致连接器向 Broker 发送 accepted 确认。收到此确认后,Broker 将从队列中删除消息。

发布/订阅

AMQP 同样支持发布/订阅模型,类似于 Kafka,允许多个订阅者从单个队列中读取消息。在 AMQP 中,队列可以是 多播 类型(与 单播 相反),表示多个消费者可以接收相同的消息。

图 11-8 中有三个多播队列消费者读取消息,我们可以看到每个消费者处理消息的进度。与单播队列一样,默认情况下消息是持久的,但如果需要,也可以设置为非持久。

AMQP 多播队列消费者

图 11-8. AMQP 多播队列消费者

从多播队列发送和接收消息的代码与我们用于点对点通信的代码完全相同,详见 “点对点通信”。地址 默认为通道名称;可以在通道配置中自定义,或直接设置在消息元数据中。

弹性模式

AMQP 在点对点通信的弹性模式略有不同。在 Kafka 中,我们只能有一个消费者从单个分区读取消息。而在 AMQP 中,可以有任意数量的消费者从同一队列中读取消息,只要消息处理的顺序不重要。

当然,我们可能不希望很多消费者从同一 Broker 节点读取队列,但我们可以将 Broker 进行集群化,以便将负载分布到它们之间。在 Broker 集群中,如果注意到某个 Broker 上的队列消费者利用率低于其他 Broker,则 Broker 会智能地将消息从一个 Broker 转移到另一个 Broker。

确认和重发

当我们向 AMQP Broker 发送消息时,如果 Broker 成功提交了消息,则会进行确认。但是,在生产者和 Broker 之间使用路由器时,情况就不同了。在这种情况下,建议将 auto-acknowledgement 设置为 true,以确保生产者在将消息发送到路由器时收到确认。如果 Broker 返回 rejectedreleasedmodified,则消息会被拒绝。

消费端有几种更多的确认可能性。我们可以fail掉消息,导致应用程序进入失败状态并且不再处理更多的消息。处理失败的消息在代理中标记为rejected。这是 AMQP 连接器的默认行为。

接受、释放和拒绝策略都会导致失败被记录,并使应用程序继续处理额外的消息。它们之间唯一的区别在于 AMQP 消息在代理上的指定方式。接受策略将消息标记为accepted,释放策略将其标记为released,最后,拒绝策略将其标记为rejected。当我们希望在失败时继续处理消息时,你设置的三个选项中的哪一个取决于你希望 AMQP 代理如何处理消息。

关于重投递的问题?如果我们将 AMQP 消息标记为released,代理可以在稍后的时间将消息重新投递给相同或不同的消费者。将消息设置为modified时,我们有两种可用的策略。使用modified-failed策略会在消息上设置delivery-failed属性,使代理能够在处理下一条消息的同时尝试重新投递该消息。然而,使用modified-failed-undeliverable-here策略也会设置delivery-failed属性,尽管代理可以尝试重新投递消息,但不会针对当前消费者进行此操作。

如果消费者在任何时候与代理失去会话,任何正在进行中的工作都将被回滚。这允许其他消费者或当前消费者重新启动时接受在与代理会话断开连接时正在飞行中的任何消息的重新投递。

信用流回压协议

AMQP 通过信用系统在生产者中实现了回压。只有在生产者有可用的信用时,它们才能向代理发送消息,以防止生产者在短时间内向代理发送过多的消息。信用代表生产者可以发送的字节数量。例如,如果我们有 1,000 个信用,表示 1,000 字节,那么生产者可以在信用过期之前发送 1 条 1,000 字节的消息或者 10 条 100 字节的消息。

当生产者用尽所有信用时,它会以非阻塞方式等待从代理获取额外的信用。默认情况下,每 2,000 毫秒请求额外的信用,但此设置可以使用credit-retrieval-period配置属性进行配置。

当信用用尽时,连接器将应用标记为not ready。然后将此信息报告给应用程序健康检查。如果将应用程序部署到 Kubernetes,则可用性健康检查将失败,并且 Kubernetes 将停止向 pod 发送流量,直到其再次变为就绪状态。

Kubernetes 上的 AMQP

在 Kubernetes 上设置一个可用于生产的 AMQP Broker 并不是一件简单的任务,因此我们选择使用单个 Docker 镜像来简化。在运行 Kubernetes 环境的情况下,运行一个 AMQP Broker 容器,如 示例 11-22 所示。

示例 11-22. 启动 AMQP Broker 容器
kubectl run amqp --image=quay.io/artemiscloud/activemq-artemis-broker \
    --port=5672 --env="AMQ_USER=admin" --env="AMQ_PASSWORD=admin" \
    -n event-bus

在这里,我们在 Kubernetes pod 中启动了一个 AMQP Broker,但我们需要将 Broker 暴露为服务,以便让服务可以访问,如 示例 11-23 所示。

示例 11-23. 暴露 AMQP Broker 服务端口
kubectl expose pod amqp --port=5672 -n event-bus

要能够使用 AMQP,我们需要将我们的代码切换到使用不同的依赖项和配置,但大多数服务保持不变。对于每个服务,在每个 pom.xml 中注释掉 quarkus-smallrye-reactive-messaging-kafka 依赖项,并取消注释每个 application.properties 文件中的 smallrye-amqp 连接器配置。在 processor 服务中的两个连接器都要更改!在进行这些更改后,务必对所有服务运行 mvn clean package

所有 AMQP Broker 配置都在 Helm 图表中,实际值在 values.yaml 中。按照 /chapter-11/README.mdApplication deployment 下的说明构建所需的 Docker 镜像并安装服务。这些步骤与本章前面使用 Kafka 时相同。服务运行后,现在是测试的时候了!在浏览器中打开 SSE 端点,就像我们用 Kafka 时所做的那样(示例 11-24)。

示例 11-24. SSE 输出:所有消息由单个 pod 消耗
data:2 consumed in pod (processor-7558d76994-mq624)
data:3 consumed in pod (processor-7558d76994-mq624)
data:4 consumed in pod (processor-7558d76994-mq624)

让我们扩展 processor 以增加更多的消费者,如 示例 11-25 所示。

示例 11-25. 增加应用程序实例(pod)的数量
kubectl scale deployment/processor -n event-bus --replicas=3

使用 AMQP 进行扩展与使用 Kafka 进行扩展有不同的结果;请参见 示例 11-26。

示例 11-26. SSE 输出:消息由三个 pod 消耗
data:187 consumed in pod (processor-7558d76994-mq624)
data:187 consumed in pod (processor-7558d76994-hbp6j)
data:187 consumed in pod (processor-7558d76994-q2vcc)
data:188 consumed in pod (processor-7558d76994-q2vcc)
data:188 consumed in pod (processor-7558d76994-hbp6j)
data:188 consumed in pod (processor-7558d76994-mq624)
data:189 consumed in pod (processor-7558d76994-mq624)
data:189 consumed in pod (processor-7558d76994-hbp6j)
data:189 consumed in pod (processor-7558d76994-q2vcc)

现在我们看到同一消息被所有三个生产者消耗,而不是一条消息仅消耗一次!

摘要

本章深入探讨了在使用 AMQP 或 Kafka 与 Reactive Messaging 时理解事件总线的更深层次。如果我们不需要特定 Kafka 或 AMQP 行为的元数据类,我们可以轻松地通过依赖变更和修改配置在两者之间切换。我们介绍了每个事件总线选项如何支持点对点通信、发布/订阅、确认、故障处理和反压。这些都是理解反应式系统及其组件整体的关键概念。

Kafka 是许多事件驱动反应系统当前流行的选择。Kafka 能处理大量消息,并且使得顺序成为一个重要特性。AMQP 在配置和定制化方面比 Kafka 更灵活。在点对点的场景中,AMQP 的弹性更高,因为其限制不受分区数量的约束。

在下一章中,我们将讨论如何使用 Java 接口表示外部服务的 HTTP 客户端,以及如何使用低级别的 Web 客户端以及为什么它仍然有用。

¹ 在本章中,我们将讨论 AMQP 1.0。

第十二章:反应式 REST 客户端:与 HTTP 端点连接

前两章专注于消息传递,这是反应式系统的连接组件。现代消息代理提供了完美的功能集来实现反应式系统的内部通信。然而,在系统的前沿,当您需要集成远程服务时,您很有可能需要使用 HTTP。因此,让我们务实一点,看看如何在不违反反应式原则的情况下消费 HTTP 服务。

在 第八章 中,您看到如何 公开 反应式 HTTP 端点。本章介绍另一方面:如何 消费 HTTP 端点。Quarkus 提供了一种非阻塞方式来消费 HTTP 端点。此外,它提供了可靠性特性,以保护集成点免受故障和缓慢的影响。重要的是要注意,被调用的服务不一定是反应式应用程序,这取决于该服务的实现。

让我们看看 Quarkus 提供了哪些功能来消费 HTTP 端点。

与 HTTP 端点交互

Quarkus 提供了多种消费 HTTP 端点的方式:

Vert.x Web 客户端

这种低级别的 HTTP 客户端是基于 Vert.x 和 Netty 实现的(因此本质上是异步的,基于非阻塞 I/O)。

反应式消息连接器

此连接器为每个处理的消息发送 HTTP 请求。

REST 客户端

这种类型安全的方法简化了消费基于 HTTP 的 API。

当您不想被暴露于底层 HTTP 细节(如动词、头部、主体和响应状态)时,Vert.x Web 客户端是非常方便的选择。Web 客户端灵活,并且您可以完全控制 HTTP 请求和响应处理。

要使用 Vert.x Web 客户端,您需要在项目中添加 示例 12-1 中显示的依赖项。

示例 12-1. Mutiny Vert.x Web 客户端的依赖项
<dependency>
    <groupId>io.smallrye.reactive</groupId>
    <artifactId>smallrye-mutiny-vertx-web-client</artifactId>
</dependency>

然后您可以按照 示例 12-2 中所示使用它。

示例 12-2. Vert.x Web 客户端示例
@ApplicationScoped
public class WebClientExample {

    private final WebClient client;

    @Inject
    public WebClientExample(Vertx vertx) {
        client = WebClient.create(vertx);
    }

    @PreDestroy
    public void close() {
        client.close();
    }

    public Uni<JsonObject> invokeService() {
        return client
            .getAbs("https://httpbin.org/json").send()
            .onItem().transform(response -> {
                if (response.statusCode() == 200) {
                    return response.bodyAsJsonObject();
                } else {
                    return new JsonObject()
                            .put("error", response.statusMessage());
                }
            });
    }
}

正如您所见,您需要自己创建并关闭 WebClient。它暴露了 Mutiny API,因此它完美地集成在您的 Quarkus 应用程序中。

HTTP 反应式连接器集成了反应式消息传递(见 第十章),允许为每条消息发送 HTTP 请求。当您设计一个消息处理流水线,其中出站是 HTTP 端点时,这非常方便。它处理背压并控制并发量(正在处理的请求数量),但不允许处理响应。

要使用此 HTTP 连接器,您需要 示例 12-3 中显示的依赖项。

示例 12-3. HTTP 连接器的依赖项
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-reactive-messaging-http</artifactId>
</dependency>

然后,您可以按照 示例 12-4 中所示配置连接器。

示例 12-4. 使用 HTTP 连接器通过 HTTP POST 请求发送消息
mp.messaging.outgoing.my-http-endpoint.connector=quarkus-http ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/1.png)
mp.messaging.outgoing.my-http-endpoint.method=POST ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/2.png)
mp.messaging.outgoing.my-http-endpoint.url=https://httpbin.org/anything ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/3.png)

1

指示 Quarkus 使用 quarkus-http 连接器管理 my-http-endpoint 通道。

2

配置要使用的 HTTP 方法。

3

配置要调用的服务的 URL。

默认情况下,连接器将接收到的消息编码为 JSON。你还可以配置自定义序列化器。连接器还支持重试和超时,但正如前面所述,不允许处理响应。任何非 2XX HTTP 响应都被视为失败,并将 NACK 消息。

REST 客户端提供了一种声明性的方式来调用 HTTP 服务。它实现了MicroProfile REST Client 规范。你无需处理 HTTP 请求和响应,只需将 HTTP API 映射到一个 Java 接口中。通过几个注解,你可以表达如何发送请求和处理响应。客户端使用与服务器端相同的 JAX-RS 注解;详见示例 12-5。

示例 12-5. REST 客户端示例
@Path("/v2")
@RegisterRestClient
public interface CountriesService {

    @GET
    @Path("/name/{name}")
    Set<Country> getByName(@PathParam String name);
}

应用程序直接使用接口中的方法。本章的其余部分重点介绍 REST 客户端以及如何在响应式应用程序中集成它,包括如何优雅地处理故障。

响应式 REST 客户端

我们需要小心。许多异步 HTTP 和 REST 客户端并未使用非阻塞 I/O,而是将 HTTP 请求委托给内部线程池。但这不适用于 Quarkus 的响应式 REST 客户端。它依赖于 Quarkus 的响应式架构。请注意,尽管它是响应式的,但你仍然可以以阻塞的方式使用它。与大多数 Quarkus 特性一样,你可以选择使用方式。即使你决定使用阻塞方式,Quarkus 仍会继续使用 I/O 线程,并将调用委托给应用程序的工作线程。

要在 Quarkus 中使用响应式 REST 客户端,请将依赖项添加到示例 12-6 所述的项目中。

示例 12-6. 用于响应式 REST 客户端的依赖(chapter-12/rest-client-example/pom.xml
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-rest-client-reactive</artifactId>
</dependency>

如果计划使用 JSON(在使用 HTTP 服务时通常如此),还需在示例 12-7 中添加依赖项。

示例 12-7. 用于支持响应式 REST 客户端的 Jackson 依赖(chapter-12/rest-client-example/pom.xml
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-rest-client-reactive-jackson</artifactId>
</dependency>

此依赖项增加了将请求体序列化为 JSON 并将 JSON 载荷反序列化为对象的能力。

将 HTTP API 映射到 Java 接口

REST 客户端的基石是一个 Java 接口,代表被消耗的 HTTP 端点。这个接口表示您的应用程序消耗的 HTTP API。它充当一个门面,使您的应用程序避免直接处理 HTTP。在接口的每个方法上,您使用 JAX-RS 注解来描述如何处理 HTTP 请求和响应。让我们考虑一个例子。想象一下,您需要集成httpbin服务。要调用/uuid端点(返回 UUID),您需要创建一个 Java 接口,该方法代表调用;参见示例 12-8。

示例 12-8。httpbin服务的 REST 客户端(chapter-12/rest-client-example/src/main/java/org/acme/restclient/HttpApi.java
@RegisterRestClient(configKey = "httpbin")
public interface HttpBinService {

    @GET
    @Path("/uuid")
    String getUUID();
}

这个接口的第一个重要事实是@RegisterRestClient注解。它表示这个接口代表一个 HTTP 端点。configKey属性定义了我们将用来配置 HTTP 端点的键,比如位置。

目前,这个接口只有一个方法:getUUID。当应用程序调用此方法时,它发送一个GET请求到/uuid,等待响应,并读取响应主体作为String。我们使用@GET@Path注解定义了这种行为。

让我们通过使用另一种 HTTP 方法添加一个方法,如示例 12-9 所示。

示例 12-9。发送 JSON 负载
class Person {
    public String name;
}

@POST
@Path("/anything")
String anything(Person someone);

调用此方法将发送一个带有 JSON 载荷的POST请求到/anything。响应式 REST 客户端将Person的实例映射到 JSON。您还可以使用@Consume注解来显式设置content-type

REST 客户端还允许您配置请求标头。这些标头可以是常量,并且可以通过使用@HeaderParam注解作为方法参数传递;参见示例 12-10。

示例 12-10。传递头信息
@POST
@Path("/anything")
@ClientHeaderParam(name = "X-header", value = "constant value")
String anythingWithConstantHeader(Person someone);

@POST
@Path("/anything")
String anythingWithHeader(Person someone,
                          @HeaderParam("X-header") String value);

要传递查询参数,请使用@QueryParameter注解(示例 12-11)。

示例 12-11。传递查询参数
@POST
@Path("/anything")
String anythingWithQuery(Person someone,
                         @QueryParam("param1") String p1,
                         @QueryParam("param2") String p2);

现在让我们来看看响应。到目前为止,我们只使用了String,但是 REST 客户端可以将响应映射到对象。如果我们回到最初的例子(/uuid),它返回一个具有一个字段uuid的 JSON 对象。我们可以将此响应映射到一个对象,如示例 12-12 所示。

示例 12-12。接收 JSON 对象
class UUID {
    public String uuid;
}

@GET
@Path("/uuid")
UUID uuid();

默认情况下,REST 客户端使用 JSON 将响应映射到对象。您可以使用@Produces注解来配置Accept头。

如果您想自己处理 HTTP 响应以检索状态代码或头信息,可以返回一个Response,如示例 12-13 所示。

示例 12-13。使用响应
@GET
@Path("/uuid")
Response getResponse();

它与“处理故障和自定义响应”中的Response相同。

将 HTTP API 映射到 Java 接口引入了一个清晰的契约,并避免了在应用程序代码中到处使用 HTTP 代码。它还提高了可测试性,因为你可以快速模拟远程服务。最后,请注意,你不必映射远程服务的所有端点,只需映射你在应用程序中使用的端点即可。

调用服务

要在应用程序中使用我们在前一节中定义的接口,我们需要执行以下步骤:

  1. 在应用程序代码中注入 REST 客户端。

  2. 配置服务的 URL。

第一步很简单;我们只需要像 示例 12-14 中所示注入客户端即可。

示例 12-14. 使用 HTTPBin REST 客户端 (chapter-12/rest-client-example/src/main/java/org/acme/restclient/HttpEndpoint.java)
@Inject
@RestClient HttpBinService service;

public HttpBinService.UUID invoke() {
    return service.uuid();
}

@RestClient 限定符表示注入的对象是一个 REST 客户端。一旦它被注入,你就可以使用你在接口上定义的任何方法。

要配置客户端,打开 application.properties 文件并添加以下属性:

httpbin/mp-rest/url=https://httpbin.org

httpbin 部分是在 @RegisterRestClient 接口中使用的配置键。在这里我们只配置了位置,但你可以配置更多

当然,URL 也可以在运行时通过使用 httpbin/mp-rest/url 系统属性或 HTTPBIN_MP_REST_URL 环境属性传递。

阻塞和非阻塞

如前所述,Quarkus 的响应式 REST 客户端支持即时(阻塞)方法和反应式(非阻塞)方法。通过返回类型来区分。在 “将 HTTP API 映射到 Java 接口” 中,我们所有的返回类型都是同步的。因此,Quarkus 会阻塞调用线程,直到接收到 HTTP 响应。这在响应式方面不是很好。幸运的是,你可以通过将返回类型更改为 Uni 来避免这种情况,就像 示例 12-15 中所示的那样。

示例 12-15. 在 REST 客户端接口中使用 Mutiny
@RegisterRestClient(configKey = "reactive-httpbin")
public interface ReactiveHttpBinService {

    @GET
    @Path("/uuid")
    Uni<String> getUUID();

    class Person {
        public String name;
    }

    @POST
    @Path("/anything")
    Uni<String> anything(Person someone);

    class UUID {
        public String uuid;
    }

    @GET
    @Path("/uuid")
    Uni<UUID> uuid();

    @GET
    @Path("/uuid")
    Uni<Response> getResponse();

}

通过返回 Uni 而不是直接的结果类型,你指示 REST 客户端不要阻塞调用线程。更好的是,它将使用当前的 I/O 线程,采用异步执行模型,并避免额外的线程切换。

在消费者端,你只需使用 Uni 并将响应的处理附加到你的管道中(示例 12-16)。

示例 12-16. 使用 REST 客户端的响应式 API
@Inject
@RestClient ReactiveHttpBinService service;

public void invoke() {
    service.uuid()
            .onItem().transform(u -> u.uuid)
            .subscribe().with(
                    s -> System.out.println("Received " + s),
                    f -> System.out.println("Failed with " + f)
    );
}

在这个例子中,我们自己处理订阅。不要忘记,你通常依赖于 Quarkus 来处理这个。例如,从 RESTEasy 反应式端点返回 Uni 将订阅返回的 Uni

处理失败

如果你查看 示例 12-16,你会发现调用 REST 客户端可能会导致失败,你需要处理它。Quarkus 提供多种方法来优雅地处理失败。你可以使用 Mutiny API 处理失败,执行重试或优雅地恢复,如 第七章 所示。此外,Quarkus 提供了一种声明性的方法来表达如何处理失败。当集成远程系统(如 REST 客户端)时,这种方法特别方便,因为它将集成点和失败管理结合在一个位置。

quarkus-smallrye-fault-tolerance 扩展提供了一组注解来配置:

  • 回退方法

  • 重试

  • 断路器

  • 隔离舱

quarkus-smallrye-fault-tolerance 适用于命令式和响应式 API。在本节中,我们仅关注后者。

首先,要使用容错扩展,在你的项目中添加依赖项 示例 12-17。

示例 12-17. 容错支持的依赖项(chapter-12/api-gateway-example/api-gateway/pom.xml
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-smallrye-fault-tolerance</artifactId>
</dependency>

回退

回退 是在原始调用失败时提供替代结果的方法。让我们重用之前看过的示例,再次在 示例 12-18 中展示。

示例 12-18. uuid 方法
@GET
@Path("/uuid")
Uni<UUID> uuid();

如果与远程服务的交互失败,我们可以生成一个回退(本地)UUID,如示例 12-19 所示。

示例 12-19. 声明 uuid 方法的回退
import io.smallrye.mutiny.Uni;
import org.eclipse.microprofile.faulttolerance.Fallback;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;

import javax.ws.rs.GET;
import javax.ws.rs.Path;

@RegisterRestClient(configKey = "reactive-httpbin")
public interface ReactiveHttpBinServiceWithFallbackMethod {

    class UUID {
        public String uuid;
    }

    @GET
    @Path("/uuid")
    @Fallback(fallbackMethod = "fallback")
    Uni<UUID> uuid();

    default Uni<UUID> fallback() {
        UUID u = new UUID();
        u.uuid = java.util.UUID.randomUUID().toString();
        return Uni.createFrom().item(u);
    }

}

@Fallback 注解指示调用的方法名称。此方法必须与原始方法具有相同的签名。因此,在我们的情况下,它必须返回 Uni<UUID>。我们的回退实现很简单,但你可以想象更复杂的场景,如调用备用服务。

如果你需要更多对回退的控制,还可以提供 FallbackHandler;参见 示例 12-20。

示例 12-20. 使用回退处理程序
import io.smallrye.common.annotation.NonBlocking;
import io.smallrye.mutiny.Uni;
import org.eclipse.microprofile.faulttolerance.ExecutionContext;
import org.eclipse.microprofile.faulttolerance.Fallback;
import org.eclipse.microprofile.faulttolerance.FallbackHandler;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;

import javax.ws.rs.GET;
import javax.ws.rs.Path;

@RegisterRestClient(configKey = "reactive-httpbin")
public interface ReactiveHttpBinServiceWithFallbackHandler {

    class UUID {
        public String uuid;
    }

    @GET
    @Path("/uuid")
    @Fallback(value = MyFallbackHandler.class)
    Uni<UUID> uuid();

    class MyFallbackHandler implements FallbackHandler<Uni<UUID>> {

        @Override
        public Uni<UUID> handle(ExecutionContext context) {
            UUID u = new UUID();
            u.uuid = java.util.UUID.randomUUID().toString();
            return Uni.createFrom().item(u);
        }
    }

}

配置的回退处理程序通过封装原始方法和异常的 ExecutionContext 被调用。

重试

回退允许以本地值优雅地恢复。容错扩展还允许重试。请记住,只有在调用服务是幂等的情况下才应使用重试功能。因此,在使用此功能之前,请确保它不会损害你的系统。

@Retry 注解指示 Quarkus 多次重试调用,希望下一次调用会成功(示例 12-21)。

示例 12-21. 声明重试策略
@GET
@Path("/uuid")
@Retry(maxRetries = 10, delay = 1000, jitter = 100)
Uni<UUID> uuid();

正如你所见,你可以配置重试次数,以及延迟和抖动,以避免立即重试。你可以结合 @Retry@Fallback,如果所有尝试都失败,调用回退方法。

超时

快速失败总比让用户长时间等待好。@Timeout注解强制调用在及时完成;参见示例 12-22。

示例 12-22. 配置超时
@GET
@Path("/uuid")
@Timeout(value = 3, unit = ChronoUnit.SECONDS)
Uni<UUID> uuid();

如果调用在配置的超时时间内未产生结果,则调用失败。将@Timeout@Fallback结合使用允许在原始调用无法按预期时间完成时使用备用结果。

舱壁和断路器

Quarkus 的容错特性还提供了断路器和舱壁功能。虽然其他模式保护您的应用免受远程故障和缓慢的影响,但这两个模式避免了对不健康或脆弱服务的过度调用。

第一个模式几年前由HystrixResilience4j等库广泛推广,它检测到失败的服务并给予其恢复的时间(而不是不断地调用它们)。断路器允许您的应用立即失败,以防止可能会失败的重复调用。断路器的操作类似于电气断路器。闭合电路表示完全功能的系统,开放电路意味着不完整的系统。如果发生故障,断路器将触发打开电路,从系统中删除故障点。

软件断路器有一个额外的状态:半开放状态。在断路器打开后,它周期性地转换到半开放状态。它检查失败的组件是否已恢复,并在被认为安全和功能正常后关闭电路。在 Quarkus 中使用断路器,只需使用@CircuitBreaker注解,如示例 12-23 所示。

示例 12-23. 使用断路器
@GET
@Path("/uuid")
@CircuitBreaker
Uni<UUID> uuid();

您还可以配置断路器;参见示例 12-24。

示例 12-24. 配置断路器
@GET
@Path("/uuid")
@CircuitBreaker(
    // Delay before switching to the half-open state
    delay = 10, delayUnit = ChronoUnit.SECONDS,
    // The number of successful executions,
    // before a half-open circuit is closed again
    successThreshold = 2,
    // The ratio of failures within the rolling
    // window that will trip the circuit to open
    failureRatio = 0.75,
    // The number of consecutive requests in a
    // rolling window
    requestVolumeThreshold = 10
)
Uni<UUID> uuidWithConfiguredCircuitBreaker();

通过断路器保护您的集成点,不仅可以防止应用程序中的响应缓慢和故障,还可以让故障服务有时间恢复。当您与的服务处于维护或高负载状态时,使用断路器非常方便。

舱壁模式的理念是限制故障的传播。您保护应用程序免受远程服务中的故障,并避免将其级联到整个系统中,从而导致广泛的问题。@Bulkhead注解限制并发请求的数量,并节省无响应的远程服务浪费系统资源的情况。

使用该注解可帮助您避免处理过多的失败,并避免向远程服务发送大量并发请求。 后者很重要。 由于反应式原则使高并发成为可能,您永远不应忘记您调用的服务可能无法承受这种负载。 因此,使用 @Bulkhead 注解允许控制出站并发性。 是的,这会增加您的响应时间并减少您的并发性,但这是为了全局系统状态的利益。

您可以使用最大并发性 (value) 和等待轮次的最大请求数来配置批处理隔离,如 示例 12-25 所示。 如果队列已满,它将立即拒绝任何新的调用,避免响应时间过慢。

示例 12-25. 声明一个批处理隔离
@GET
@Path("/uuid")
@Bulkhead(value = 5, waitingTaskQueue = 1000)
Uni<UUID> uuid();

使用 RESTEasy Reactive 客户端构建 API 网关

在 第八章 中,我们展示了如何实现依赖非阻塞 I/O 的 HTTP 端点,以及此实现如何使用 Mutiny(在 第七章 中介绍)来编排异步任务并避免阻塞。 此方法实现了高并发和有效的资源使用。 这是构建 API 网关的完美组合。

API 网关 是位于一组后端服务前面的服务。 网关处理外部请求并编排其他服务。 例如,它可以将请求委托给单个服务,并实现涉及多个后端服务的更复杂的流程。

通过结合 Mutiny、RESTEasy Reactive、反应式 REST 客户端和容错注解,我们可以构建高并发、响应迅速、弹性的 API 网关。 在本节中,我们通过探索一个示例来解释实现这样一个网关的基础知识。 我们的系统由三个应用程序组成:

问候服务

暴露一个 HTTP API 返回 Hello *{name}*,其中名称是查询参数。

报价服务

暴露另一个 HTTP API 返回有关咖啡的随机有趣引用。

API 网关

将 HTTP API 暴露,将 /quote 上的请求委托给报价服务。 发送到 / 的请求将调用问候和报价服务,并构建一个封装两者的 JSON 对象。

您可以在 chapter-12/api-gateway-example 目录中找到系统的完整代码。 本节重点介绍 API 网关组件。 我们在这里不涵盖问候和报价服务的代码,因为它们很简单。 这些组件的源代码位于 chapter-12/api-gateway-example/greeting-servicechapter-12/api-gateway-example/quote-service 目录中。 您可以在 chapter-12/api-gateway-example/api-gateway 中找到的 API 网关应用程序更有趣,我们一起来探索它。

首先,让我们构建并运行此系统。 要构建此示例,请在终端中导航到 chapter-12/api-gateway-example,然后运行 mvn package。 然后您将需要三个终端:

  1. 在第一个中,从chapter-12/api-gateway-example/greeting-service目录运行java -jar target/quarkus-app/quarkus-run.jar

  2. 在第二个中,从chapter-12/api-gateway-example/quote-service目录运行java -jar target/quarkus-app/quarkus-run.jar

  3. 最后,在第三个中,从chapter-12/api-gateway-example/api-gateway目录运行java -jar target/quarkus-app/quarkus-run.jar

注意

确保你的系统上没有使用端口 9010(问候服务)和 9020(报价服务)。API 网关使用端口 8080。

一旦所有服务都运行起来,你可以使用curl来调用 API 网关,它会编排其他后端服务(示例 12-26)。

示例 12-26. 调用问候端点
> curl http://localhost:8080/
{"greeting":"Hello anonymous","quote":"I never drink coffee
at lunch. I find it keeps me awake for the afternoon."}

API 网关的核心是Gateway类,如示例 12-27 所示。

示例 12-27. Gateway类(chapter-12/api-gateway-example/api-gateway/src/main/java/org/acme/gateway/Gateway.java
package org.acme.gateway;

import io.smallrye.mutiny.Uni;
import org.eclipse.microprofile.rest.client.inject.RestClient;

import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.QueryParam;

@Path("/")
public class Gateway {

    @RestClient
    GreetingService greetingService;

    @RestClient
    QuoteService quoteService;

    @GET
    @Path("/quote")
    public Uni<String> getQuote() {
        return quoteService.getQuote();
    }

    @GET
    @Path("/")
    public Uni<Greeting> getBoth(
            @QueryParam("name")
            @DefaultValue("anonymous") String name) {
        Uni<String> greeting = greetingService.greeting(name);
        Uni<String> quote = quoteService.getQuote()
                .onFailure()
                    .recoverWithItem("No coffee - no quote");

        return Uni.combine().all().unis(greeting, quote).asTuple()
                .onItem().transform(tuple ->
                        new Greeting(tuple.getItem1(),
                                tuple.getItem2())
                );
    }

    public static class Greeting {
        public final String greeting;
        public final String quote;

        public Greeting(String greeting, String quote) {
            this.greeting = greeting;
            this.quote = quote;
        }
    }
}

Gateway类检索两个 REST 客户端并使用它们处理 HTTP 请求。由于 API 网关可能会承受大量负载和高并发,我们使用 RESTEasy Reactive 及其 Mutiny 集成,因此不需要工作线程。

getQuote方法很简单。它将调用委托给QuoteService

getBoth方法更加有趣。它需要调用两个服务并聚合响应。由于这两个服务是无关的,我们可以同时调用它们。正如你在第七章中看到的,通过 Mutiny 的Uni.combine结构可以轻松实现这一点。一旦我们将两个响应封装在元组中,我们就构建Greeting结构并发出它。

让我们看看 REST 客户端。GreetingService使用容错注解以确保我们适当处理失败或慢响应;参见示例 12-28。

示例 12-28. GreetingService REST 客户端(chapter-12/api-gateway-example/api-gateway/src/main/java/org/acme/gateway/GreetingService.java
package org.acme.gateway;

import io.smallrye.mutiny.Uni;
import org.eclipse.microprofile.faulttolerance.*;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;

import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.QueryParam;

@RegisterRestClient(configKey = "greeting-service")
public interface GreetingService {

    @GET
    @Path("/")
    @CircuitBreaker
    @Timeout(2000)
    @Fallback(GreetingFallback.class)
    Uni<String> greeting(@QueryParam("name") String name);

    class GreetingFallback implements FallbackHandler<Uni<String>> {
        @Override
        public Uni<String> handle(ExecutionContext context) {
            return Uni.createFrom().item("Hello fallback");
        }
    }
}

注意问候方法结合了断路器、超时和回退注解。QuoteService类似,但不使用回退注解,正如你在示例 12-29 中所见。

示例 12-29. QuoteService REST 客户端(chapter-12/api-gateway-example/api-gateway/src/main/java/org/acme/gateway/QuoteService.java
package org.acme.gateway;

import io.smallrye.mutiny.Uni;
import org.eclipse.microprofile.faulttolerance.CircuitBreaker;
import org.eclipse.microprofile.faulttolerance.Timeout;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;

import javax.ws.rs.GET;
import javax.ws.rs.Path;

@RegisterRestClient(configKey = "quote-service")
public interface QuoteService {

    @GET
    @Path("/")
    @CircuitBreaker
    @Timeout(2000)
    Uni<String> getQuote();

}

相反,正如你可能已经注意到的那样,我们在Gateway类中使用 Mutiny API 来处理失败(示例 12-30)。

示例 12-30. 使用 Mutiny API 从失败中恢复(chapter-12/api-gateway-example/api-gateway/src/main/java/org/acme/gateway/Gateway.java
Uni<String> quote = quoteService.getQuote()
   .onFailure().recoverWithItem("No coffee - no quote");

通常情况下,我们在使用容错注解或使用 Mutiny API 之间进行选择,我们想要强调您可以选择并且可以轻松地将两者结合起来。然而,getQuote 方法不处理失败并传播错误。因此,在使用 Mutiny 处理失败时,请确保覆盖所有入口点。现在,如果停止报价服务(通过在第二个终端中按 Ctrl-C),您将在 Example 12-31 中获得输出。

示例 12-31. 调用端点
> curl http://localhost:8080\?name\=luke
{"greeting":"Hello luke","quote":"No coffee - no quote"}

如果您也停止了问候服务,通过在第一个终端中按 Ctrl-C,您将获得 Example 12-32。

示例 12-32. 在没有问候服务时调用问候端点
> curl http://localhost:8080\?name\=luke
{"greeting":"Hello fallback","quote":"No coffee - no quote"}

本节探讨了构建高度并发 API 网关的基本构造。结合 RESTEasy Reactive、反应式 REST 客户端、容错和 Mutiny 提供了所有您需要向用户公开健壮 API 并优雅处理失败的功能。下一节说明了如何在消息应用程序中也可以使用 REST 客户端。

在消息应用程序中使用 REST 客户端

REST 客户端在消息应用程序中也很有用。例如,消息处理管道可以为每个消息调用远程 HTTP API,或将消息转发到远程 HTTP 端点。我们可以在使用 Reactive Messaging 建模的处理管道中使用 REST 客户端来实现这一点。在本节中,我们探讨了一个从 HTTP 端点接收简单订单、处理它们并将其持久化到数据库中的应用程序:

  1. OrderEndpoint 接收 Order 并将其发送到 new-orders 通道中。

  2. OrderProcessing 组件从 new-order 通道消费订单,并调用远程验证服务。如果订单验证成功,则将其发送到 validated-orders 通道。否则,将否定确认订单。

  3. OrderStorage 接收来自 validated-orders 通道的订单并将其存储在数据库中。

为了简单起见,验证端点在同一进程中运行,但调用仍然使用 REST 客户端。您可以在 chapter-12/http-messaging-example 找到应用程序的完整代码,但让我们快速浏览一下。

如您在 Example 12-33 中所见,Order 结构很简单。它仅包含产品名称和数量。请注意,Order 类是 Panache 实体。我们将使用它来在数据库中存储验证通过的订单。

示例 12-33. Order 结构 (chapter-12/http-messaging-example/src/main/java/org/acme/http/model/Order.java)
package org.acme.http.model;

import io.quarkus.hibernate.reactive.panache.PanacheEntity;

import javax.persistence.Entity;
import javax.persistence.Table;

@Entity
@Table(name = "orders")
public class Order extends PanacheEntity {

    public String name;
    public int quantity;

}

OrderEndpoint 同样简单,如您在 Example 12-34 中所见。

示例 12-34. OrderEndpoint 类 (chapter-12/http-messaging-example/src/main/java/org/acme/http/OrderEndpoint.java)
package org.acme.http;

import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.Uni;
import io.smallrye.reactive.messaging.MutinyEmitter;
import org.acme.http.model.Order;
import org.eclipse.microprofile.reactive.messaging.Channel;

import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.core.Response;

@Path("/order")
public class OrderEndpoint {

    @Channel("new-orders")
    MutinyEmitter<Order> emitter;

    @POST
    public Uni<Response> order(Order order) {
        return emitter.send(order)
                .log()
                .onItem().transform(x -> Response.accepted().build())
                .onFailure().recoverWithItem(
                        Response.status(Response.Status.BAD_REQUEST)
                                .build()
                );
    }

    @GET
    public Multi<Order> getAllValidatedOrders() {
        return Order.streamAll();
    }

}

order 方法将收到的订单发送到 new-orders 通道。当消息被确认时,它会产生 202 - ACCEPTED 响应。如果消息被拒绝,它会创建一个 400 - BAD REQUEST 响应。

getAllValidatedOrders 方法允许我们检查数据库中的写入情况。OrderProcessing 组件从 new-orders 通道中消费订单,并调用验证服务,如示例 12-35 所示。

示例 12-35. OrderProcessing 类 (chapter-12/http-messaging-example/src/main/java/org/acme/http/OrderProcessing.java)
package org.acme.http;

import io.smallrye.mutiny.Uni;
import org.acme.http.model.Order;
import org.eclipse.microprofile.reactive.messaging.Incoming;
import org.eclipse.microprofile.reactive.messaging.Outgoing;
import org.eclipse.microprofile.rest.client.inject.RestClient;

import javax.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class OrderProcessing {

    @RestClient
    ValidationService validation;

    @Incoming("new-orders")
    @Outgoing("validated-orders")
    Uni<Order> validate(Order order) {
        return validation.validate(order)
                .onItem().transform(x -> order);
    }

}

如果订单无效,则验证服务失败。结果,消息被拒绝。如果订单有效,则它会将订单发送到 validated-orders 通道。OrderStorage 组件消费此通道,并将每个订单写入数据库(示例 12-36)。

示例 12-36. 在数据库中持久化订单(chapter-12/http-messaging-example/src/main/java/org/acme/http/OrderStorage.java)
package org.acme.http;

import io.quarkus.hibernate.reactive.panache.Panache;
import io.smallrye.mutiny.Uni;
import org.acme.http.model.Order;
import org.eclipse.microprofile.reactive.messaging.Incoming;

import javax.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class OrderStorage {

    @Incoming("validated-orders")
    public Uni<Void> store(Order order) {
        return Panache.withTransaction(order::persist)
                .replaceWithVoid();
    }

}

chapter-12/http-messaging-example 目录运行应用程序使用 mvn quarkus:dev 命令。由于它使用 Quarkus Dev 服务,在开发模式下不需要为数据库提供服务;Quarkus 会为您完成这一切。

应用程序运行后,按照示例 12-37 的方法添加一个订单。

示例 12-37. 调用端点以添加新订单
> curl -v  --header "Content-Type: application/json" \
  --request POST \
  --data '{"name":"coffee", "quantity":2}' \
  http://localhost:8080/order

你可以通过使用示例 12-38 来验证订单是否已被处理。

示例 12-38. 调用端点以检索持久化的订单
> curl http://localhost:8080/order
[{"id":1,"name":"coffee","quantity":2}]

现在尝试插入一个无效订单(使用负数量),如示例 12-39 所示。

示例 12-39. 调用端点以引入无效订单
> curl -v  --header "Content-Type: application/json" \
  --request POST \
  --data '{"name":"book", "quantity":-1}' \
  http://localhost:8080/order

响应是 400。您可以使用示例 12-40 中的代码验证它是否未被插入到数据库中。

示例 12-40. 调用端点以检索持久化的订单:无效订单未列出
> curl http://localhost:8080/order
[{"id":1,"name":"coffee","quantity":2}]

处理的验证步骤可以使用容错注解来提高应用程序的可靠性(示例 12-41)。

示例 12-41. 使用容错注解
@Incoming("new-orders")
@Outgoing("validated-orders")
@Timeout(2000)
Uni<Order> validate(Order order) {
    return validation.validate(order)
            .onItem().transform(x -> order);
}

在消息应用程序中使用 REST 客户端可以让您在处理工作流程中顺利集成远程服务。

总结

本章重点介绍了如何将远程 HTTP API 集成到您的响应式应用程序中,而不会违反响应式原则。您学会了如何执行以下操作:

  • 通过使用响应式 REST 客户端,将远程 HTTP 终端集成到您的响应式应用程序中

  • 使用容错注解保护你的集成点

  • 通过结合 Mutiny、RESTEasy Reactive 和 REST 客户端构建 API 网关

  • 在消息处理管道中集成远程 HTTP API

下一章重点介绍可观察性,以及如何在负载波动和面对故障时保持响应式系统的运行。

第十三章:观察反应式和事件驱动架构

到目前为止,我们专注于如何开发反应式系统。但我们还没有讨论的是如何确保我们的反应式系统的所有组件都按照我们期望的方式运行。这是本章的重点:我们如何监视和观察我们的反应式和事件驱动架构。

为什么可观测性很重要?

当应用程序是单一部署或单体应用时,我们很容易观察应用程序的运行情况。我们需要观察的一切都在一个地方。无论是检查错误日志,监控 CPU 和内存的利用率,还是其他任何方面,都可以轻松访问。

具有反应式和事件驱动架构时,通常不再只有一个部署,而是几个、数十个,甚至数百个。我们不再处理单一地查看需要监控和观察的信息的地方,而是有许多地方!可观测性工具提供了一种方法,使我们能够收集这些信息,并提供一个单一的查看位置。

然而,我们需要从事件驱动架构的组件中收集必要的信息或遥测数据,以实现单一视图。遥测包括我们从过程中收集的任何信息,用于观察系统。最常见的遥测类型如下:

日志

文本消息通常写入控制台输出、日志文件或导出到特定的日志处理系统。我们还可以提供以 JSON 格式呈现的更结构化的日志记录,以便更精确地提取数据。

指标

单个度量衡量特定的信息片段,例如 HTTP 服务器请求。有各种类型的度量指标可用:计数器、仪表、计时器和直方图等。

跟踪

代表系统中的单个请求,分解为特定操作。

在运行利用反应式或事件驱动架构的分布式系统时,我们需要从组件产生可靠的遥测数据,以支持对系统的足够推理。如果无法根据从外部观察到的信息对系统进行推理,那么我们的反应式系统就无法真正地被观察到。

让我们澄清一些术语。监控可观测性有时可以混为一谈,但它们实际上有不同的含义。监控侧重于特定的度量指标,并根据特定目标、服务级别目标(SLOs)进行测量,并在未达到这些目标时发出警报。监控也被称为已知未知,因为我们知道要测量什么数据或指标来发现问题,但我们不知道可能导致特定问题的原因。未知未知指的是可观测性,因为我们不知道什么会导致问题,当问题发生时,需要观察系统的输出以确定原因。

Kubernetes 是运行反应式系统的好地方,因为它提供了监视、扩展和优雅修复系统的机制。然而,我们需要为 Kubernetes 提供适当的信息,例如健康检查。健康检查可以有多种用途;对于我们的需求,Kubernetes 中的准备探针和存活探针可以利用它们。准备探针让 Kubernetes 知道容器已准备好开始接受请求,而存活探针让 Kubernetes 知道是否需要重启容器,因为与 Kafka 通信时出现不可恢复的故障。

在本章的其余部分,我们将解释如何有效地监控和观察反应式系统。

使用消息进行健康检查

Kubernetes 利用健康检查来确定容器的状态。如果容器未提供健康检查,Kubernetes 将无法确定容器的状态。这可能导致用户因死锁容器或未准备好处理请求的容器而遇到错误。

我们可以为容器实现三种类型的健康检查:

存活探针

此探针让 Kubernetes 知道应重启容器。如果我们能编写一个有意义的健康检查,这是捕获应用程序死锁或与外部系统连接问题的好方法。我们可能通过重新启动容器来解决间歇性问题。该探针基于我们定义的频率定期运行。我们希望确保频率不要太大,以避免容器长时间被卡住,但也不要太小,以避免增加资源消耗。

准备探针

这个探针告知 Kubernetes,容器已准备好接收来自服务的流量。我们可以利用这种健康检查来为 HTTP 服务器和与外部系统的连接提供足够的时间,在开始接受请求之前等待它们可用。这可以防止用户因为容器未准备好处理请求而遇到错误。这个探针在容器生命周期中只执行一次。准备探针对于有效地允许扩展而不引起不必要的用户错误是必要的。

启动探针

最近添加的健康检查,此探针与存活探针具有相似的目的。然而,此探针允许我们在声明容器不健康之前设置不同的等待时间。这在容器可能需要很长时间才能启动,可能是由于与遗留系统连接时尤为有益。我们可以为存活探针设置较短的超时,同时允许启动探针设置更长的超时。

每个探针都支持 HTTP、TCP 或在容器内运行的命令。目前 Kubernetes 尚未实现其他协议用于探针。我们选择应用程序使用哪种探针将取决于是否有可用于探测的 HTTP 端点,或者是否需要在容器内部使用自定义命令。Quarkus 有一个扩展支持 SmallRye 健康检查,可通过 HTTP 开发健康检查。

这些探针与响应式应用程序有什么关系?就绪性表示响应式消息连接器(如 Kafka)已成功连接到代理或后端,并且没有失败,并且可选地我们打算使用的主题在代理中存在。在此状态下,连接器准备好开始发送或接收消息。默认情况下,验证任何主题的存在是禁用的,因为这是一个耗时的操作,需要使用管理员客户端。启用主题验证通过设置 health-readiness-topic-verification: true 来完成。

当响应式消息连接器遇到不可恢复的故障或与代理断开连接时,存活性应该失败。这些类型的瞬态故障可以在容器重新启动后消失。例如,应用程序可能连接到另一个代理。

如我们在 “Apache Kafka” 中介绍的,Kafka 具有内置的韧性。只有在消费者成功处理记录后,最后提交的偏移量才会更新,确保记录不会因消费者在处理过程中失败而被遗忘。此外,如果任何消费者失败,Kafka 能够重新平衡消费者组内的消费者。当使用 Kubernetes 健康检查时,当容器停止时,消费者将重新平衡,并在 Kubernetes 启动新容器实例时再次重新平衡。

现在是时候通过一个示例看看它是如何工作的了。我们将使用 第十一章 中的示例并进行扩展。我们想要自定义消费者以突出健康检查的行为。我们将有一个特定的进程服务,名为 processor-health。你可以在 chapter-13 目录下找到完整的代码。

首先,我们需要在每个服务的 pom.xml 中添加 SmallRye 健康扩展,如 示例 13-1 所示。

示例 13-1. 健康支持的依赖项 (chapter-13/processor-health/pom.xml)
<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-smallrye-health</artifactId>
</dependency>

要生成必要的 Kubernetes 部署 YAML 文件,包括存活检测和就绪检测,我们需要 Kubernetes 扩展。在这种情况下,我们使用 minikube 扩展进行部署;请参阅 示例 13-2。

示例 13-2. minikube 部署功能的依赖项 (chapter-13/processor-health/pom.xml)
<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-minikube</artifactId>
</dependency>

/chapter-13 目录中运行 mvn clean package 以生成部署 YAML。在任何一个模块的 /target/kubernetes 目录中查看生成的 YAML。我们看到所需的活跃探测和准备探测已添加到部署规范中。

默认情况下,每个活跃探测请求之间的间隔为 30 秒。让我们将其减少到 10 秒,以便在问题发生时使 Kubernetes 更早地重新启动我们的消费者processor-health,方法是修改application.properties(参见示例 13-3)。

示例 13-3. 配置活跃探测 (chapter-13/processor-health/src/main/resources/application.properties)
quarkus.kubernetes.liveness-probe.period=10s

示例 13-4 展示了如何修改 Processor 以模拟故障。

示例 13-4. 处理器每接收八条消息否定一次(chapter-13/processor-health/src/main/java/org/acme/Processor.java
@Incoming("ticks")
@Outgoing("processed")
@Acknowledgment(Acknowledgment.Strategy.MANUAL)                            ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/1.png)
Message<String> process(Message<Long> message) throws Exception {          ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/2.png)
    if (count++ % 8 == 0) {                                                ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/3.png)
      message.nack(new Throwable("Random failure to process a record."))
          .toCompletableFuture().join();
      return null;
    }
    String value = String.valueOf(message.getPayload());
    value += " consumed in pod (" + InetAddress.getLocalHost().getHostName() + ")";
    message.ack().toCompletableFuture().join();                            ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/4.png)
    return message.withPayload(value);
}

1

使用手动确认,这样我们可以显式地否定消息。

2

需要将方法签名更改为使用Message而不是Long以使用手动确认。

3

每接收八条消息应否定一次,并返回null

4

显式确认一条消息。

由于默认的failure-strategyfail,当我们否定一个消息时,消息处理失败。此消息失败将导致消费者的健康检查也失败,一旦下一个活跃探测运行,容器将重新启动。请参考“Kubernetes 上的 Kafka”,或 /chapter-13README,启动 minikube 并部署 Kafka。然后,对三个服务(ticker、viewer、processor)分别运行 mvn verify -Dquarkus.kubernetes.deploy=true。使用 kubectl get pods 验证所有三个服务正在运行。

部署服务后,可以通过访问服务的 /q/health 查看整体健康检查状态。对于 processor-health 服务,我们得到如 示例 13-5 所示的响应。

示例 13-5. 反应式应用程序健康检查,没有错误
{
    "status": "UP",
    "checks": [
        {
            "name": "SmallRye Reactive Messaging - liveness check",
            "status": "UP",
            "data": {
                "ticks": "[OK]",                     ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/1.png)
                "processed": "[OK]"
            }
        },
        {
            "name": "SmallRye Reactive Messaging - readiness check",
            "status": "UP",
            "data": {
                "ticks": "[OK]",
                "processed": "[OK]"
            }
        }
    ]
}

1

检查中的数据显示我们连接的通道及其各自的状态。

当查看生成的部署 YAML 时,我们看到还有 /q/health/live/q/health/ready 端点。它们分别代表存活探针和就绪探针。在浏览器中或通过 curl 访问它们,以查看每个探针的具体检查。

从终端中在浏览器中打开 VIEWER_URL。基于我们定义的生产者,我们会看到带有相同处理器 pod 名称的七条消息,然后再命中我们 nacked 的消息。在 Kubernetes 重新启动容器时会有一个暂停,然后我们会看到另外七条消息,这个序列会重复。

如果我们查看 Kubernetes 中的 pods,我们可以看到处理器服务的容器已经重启,如 示例 13-6 所示。

示例 13-6. 使用 kubectl 列出 pods
> kubectl get pods
NAME                                       READY   STATUS    RESTARTS   AGE
observability-processor-5cffd8c755-d5578   1/1     Running   2          84s
observability-ticker-bd8f6f5bb-hqtpj       1/1     Running   0          2m
observability-viewer-786dd8bc84-zbjp4      1/1     Running   0          3m
注意

在短时间内多次重启后,pod 将处于 CrashLoopBackoff 状态,这将逐渐增加 pod 重启之间的延迟。由于至少 10 分钟没有“happy”容器,我们最终处于 pod 将暂时不会重启的状态。对于这些示例来说,这并不是问题,但值得注意。

当在 /q/health 查看健康检查时,在容器重新启动之前“捕捉”到失败的健康检查可能会有些困难。为了更容易些,我们可以将处理器服务的 quarkus.kubernetes.liveness-probe.period 修改为较长的时间,例如 100s。通过延长周期,我们有机会在容器重新启动之前查看到失败的健康检查,如 示例 13-7 所示。

示例 13-7. 带有错误的响应式应用健康检查
{
    "status": "DOWN",
    "checks": 
        {
            "name": "SmallRye Reactive Messaging - liveness check",
            "status": "DOWN",                                               ![1
            "data": {
                "ticks": "[KO] - Random failure to process a record.",      ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/2.png)
                "processed": "[KO] - Multiple exceptions caught:
                    [Exception 0] java.util.concurrent.CompletionException:
                      java.lang.Throwable: Random failure to process a record.
                    [Exception 1] io.smallrye.reactive.messaging.ProcessingException:
                      SRMSG00103: Exception thrown when calling the method
                      org.acme.Processor#process"
            }
        },
        {
            "name": "SmallRye Reactive Messaging - readiness check",
            "status": "UP",
            "data": {
                "ticks": "[OK] - no subscription yet,
                    so no connection to the Kafka broker yet"   ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/rct-sys-java/img/3.png)
                "processed": "[OK]"
            }
        }
    ]
}

1

存活检查是 DOWN,导致整个健康检查为 DOWN

2

ticks 通道出现问题,显示了从 nack 发送的异常的失败。

3

没有订阅,因为 process 方法失败且不再订阅。ticks 通道仍然正常,只是等待订阅。

现在我们可以检查我们应用的健康,并在 Kubernetes 的容器编排中利用它们。接下来,我们看看我们的响应式应用如何生成用于监控的度量,并利用这些度量进行自动扩展。

带有消息的度量

度量是我们应用的关键部分,尤其是在响应式应用中更为重要。度量可以向监控系统提供信息,用于警报操作和 SRE 发现应用中的问题。在深入讨论如何做到这一点之前,让我们解释一些与监控相关的术语:

SLA(服务级别协议)

服务提供商与其客户之间关于服务可用性、性能等的合同。

SLO(服务水平目标)

服务提供商的目标是达到的。SLO 是内部目标,用于帮助防止服务提供商违反与客户的 SLA。开发人员为服务的 SLO 定义规则或阈值,以便在有风险违反 SLA 时向运维或 SRE 发出警报。

SLI(服务水平指标)

用于衡量 SLO 的特定测量。这些是从响应式应用生成的指标。

如果组织没有定义 SLA、SLO 和 SLI,那也没关系。从响应式应用中收集指标仍然有益,至少可以定义指示一切正常和不正常时的阈值。对于特定用例,一个“好”的指标可能会有所不同。

然而,所有的响应式系统都应该收集和监控某些指标:

队列长度

如果等待处理的消息队列过大,将影响消息在系统中流动的速度。如果消息流动不够快,例如股票交易这样对时间敏感的响应式应用将会因此而出现延迟和问题。高队列长度表明我们需要增加消费者组中的消费者数量。如果我们已经达到最大消费者数量,这也可能表明我们需要增加主题的分区数量。

处理时间

当消费者处理消息花费太长时间时,很可能会导致队列长度增加。长时间的处理也可能表明响应式应用存在其他问题,这取决于消费者的工作内容。由于与另一个服务的交互,数据库争用或其他可能的问题,我们可能会看到网络延迟问题。

在时间窗口内处理的消息

此指标提供对响应式应用吞吐量的整体理解。了解实际处理的消息数量可能不如监控变化重要。显著下降可能表明存在消息未被接收的问题,或者大量客户提前离开应用。

Ack-to-nack 比率

我们希望这个指标尽可能高,因为这意味着我们在处理的消息中看不到太多故障。如果发生太多故障,我们需要调查是由于上游系统提供了无效数据,还是处理器未能正确处理不同数据类型引起的故障。

我们讨论的所有这些度量都非常适合检测反应式应用程序中可能存在的瓶颈。如果同时看到几个这些度量朝着不利方向发展,这无疑是我们在处理消息中遇到问题的迹象!我们还可以定义检测瓶颈的基本规则。在使用 HTTP 或请求/响应时,应检查响应时间和成功率。高响应时间或低成功率可能表明需要调查的问题。对于消息传递应用程序,尚未处理的 in-flight 消息数量是一个关键的跟踪指标。

我们已经涵盖了许多理论知识,但是我们需要做什么来捕获这些度量标准呢?关键的变化是为 Micrometer 添加依赖项¹,在本例中我们希望以 Prometheus 格式提供度量标准。

Micrometer 是 Quarkus 的首选度量解决方案,因为它为开发者提供了关键的优势:

  • 能够在不需要修改现有代码创建度量的情况下,从 Prometheus 切换监控后端到 Datadog、Splunk、New Relic 等等。只需更改依赖项以使用不同的注册表!

  • 提供了许多 Quarkus 中使用的框架的 MeterBinder 实现。这些为诸如 JAX-RS、Vert.x 和 Hibernate 等框架提供度量标准,而无需开发者专门编写度量代码。

要以 Prometheus 格式公开度量标准,请向您的应用程序添加 示例 13-8 中的依赖项。

示例 13-8. Micrometer Prometheus 支持的依赖项 (chapter-13/viewer/pom.xml)
<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-micrometer-registry-prometheus</artifactId>
</dependency>

使用这个依赖项,我们有一个显示应用程序所有度量的端点位于 /q/metrics。在 Kubernetes 中使用 Prometheus 时,我们只需要一个 ServiceMonitor 告知 Prometheus 这个端点以便它抓取度量。对于本示例,我们不会使用 Prometheus 和 Grafana,这两个常见的监控工具。有很多在线文档可以解释如何在 Kubernetes 中设置它们,以便读者查看这些工具中的度量。

如果 minikube 尚未从早期健康示例中运行,请按照 /chapter-13README 中的说明启动它,部署 Kafka,并构建和部署三个服务。使用 kubectl get pods 验证它们是否正在运行,然后打开查看器的 URL。一旦看到消息出现,请打开处理器的指标端点。可以使用 minikube service --url observability-processor 找到该 URL,然后在末尾添加 /q/metrics

您将看到类似于 示例 13-9 中显示的度量标准。

示例 13-9. 反应式应用程序度量的示例
# HELP kafka_consumer_fetch_manager_records_consumed_total The total number of
# records consumed
kafka_consumer_fetch_manager_records_consumed_total \
  {client_id="kafka-consumer-ticks",kafka_version="2.8.0",} 726.0
# HELP kafka_consumer_response_total The total number of responses received
kafka_consumer_response_total
  {client_id="kafka-consumer-ticks",kafka_version="2.8.0",} 123.0
# HELP kafka_consumer_fetch_manager_fetch_latency_avg The average time taken for
# a fetch request.
kafka_consumer_fetch_manager_fetch_latency_avg \
  {client_id="kafka-consumer-ticks",kafka_version="2.8.0",} 485.6222222222222
# HELP kafka_consumer_fetch_manager_records_consumed_rate
# The average number of records consumed per second
kafka_consumer_fetch_manager_records_consumed_rate \
  {client_id="kafka-consumer-ticks",kafka_version="2.8.0",} 15.203870076019351
# HELP kafka_consumer_coordinator_assigned_partitions
# The number of partitions currently assigned to this consumer
kafka_consumer_coordinator_assigned_partitions \
  {client_id="kafka-consumer-ticks",kafka_version="2.8.0",} 3.0
# HELP kafka_producer_response_rate The number of responses received per second
kafka_producer_response_rate \
  {client_id="kafka-producer-processed",kafka_version="2.8.0",} 3.8208002687156233
# HELP kafka_producer_request_rate The number of requests sent per second
kafka_producer_request_rate \
  {client_id="kafka-producer-processed",kafka_version="2.8.0",} 3.820639852212612
# HELP kafka_producer_record_send_rate The average number of records sent per second.
kafka_producer_record_send_rate \
  {client_id="kafka-producer-processed",kafka_version="2.8.0",} 15.230982251500022
# HELP kafka_producer_record_send_total The total number of records sent.
kafka_producer_record_send_total \
  {client_id="kafka-producer-processed",kafka_version="2.8.0",}\
 726.0
# HELP kafka_producer_response_total The total number of responses received
kafka_producer_response_total \
  {client_id="kafka-producer-processed",kafka_version="2.8.0",} \
 182.0
# HELP kafka_producer_request_total The total number of requests sent
kafka_producer_request_total \
  {client_id="kafka-producer-processed",kafka_version="2.8.0",} \
 182.0
# HELP kafka_producer_request_latency_avg The average request latency in ms
kafka_producer_request_latency_avg \
  {client_id="kafka-producer-processed",kafka_version="2.8.0",} 10.561797752808989

示例 13-9 显示了从处理器服务生成的度量标准的简化版本,完整版本将需要更多空间!

指标还将使我们能够开发一个 Kubernetes 运算符,以自动缩放反应式系统的消费者。该运算符可以使用 Kafka 管理 API 来测量未被消费的消息数量。如果消费者少于分区数,则运算符可以增加一个消费者的副本数量,以在相同的时间内处理更多的消息。当未被消费的消息数量降低到阈值以下时,运算符可以从消费者组内缩减消费者数量。

使用消息传递的分布式跟踪

分布式跟踪 是反应式系统可观测性的一个极其重要的部分。当我们有一个单一的应用部署时,所有的交互通常发生在同一个进程中。此外,反应式系统具有一个服务不知道另一个服务将在何时何地消耗它们创建的消息的复杂性。在非反应式系统中,我们通常可以通过阅读代码来推断连接,以查看出站 HTTP 调用的位置。在围绕消息构建的反应式系统中,这是不可能的。

这就是分布式跟踪的优点所在,它能够连接系统中许多点(服务),跨越空间和时间,从而提供消息流的整体视角。在本示例中,我们将使用 OpenTelemetry 扩展,配合一个导出器将捕获的跟踪发送到 Jaeger。²

首先,让我们来了解一些术语:

Span

在追踪中的单个操作(在下面定义)。根据您想要收集的详细程度,可以在单个服务中创建许多 span。一个 span 可以有与之关联的父 span 或子 span,代表一系列执行。

Trace

一组操作或 span,代表一个应用及其组件处理的单个请求。

当在 Quarkus 中使用 Kafka 或 AMQP 的反应式消息传递时,当消息被消费和产生时,会自动创建 span。这是通过扩展将现有的跟踪和 span 传播到任何生成的消息的标头中完成的,在消费时进行提取。此过程允许 OpenTelemetry 跨多个进程中的多个服务链接 span,以提供 Jaeger 中流程的单一视图。

让我们更新分布式跟踪的示例!我们在 pom.xml 中为每个服务添加 Quarkus 对 OpenTelemetry 的扩展,如 示例 13-10 中所示。

示例 13-10. OpenTelemetry 依赖的 Jaeger 导出器
<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-opentelemetry-exporter-jaeger</artifactId>
</dependency>

为了使每个服务能够将收集到的 spans 发送到 Jaeger,我们还需要更新每个服务的 application.properties,其中包含收集器的 URL(参见 示例 13-11)。

示例 13-11. Jaeger 收集器端点
quarkus.opentelemetry.tracer.exporter.jaeger.endpoint=
http://simplest-collector.jaeger:14250

为了简化 Jaeger 的部署,我们将部署 all-in-one 映像,如 示例 13-12 中所示。

示例 13-12. 安装 Jaeger 全功能版
kubectl create ns jaeger
kubectl apply -f deploy/jaeger/jaeger-simplest.yaml -n jaeger

可以通过查看 /deploy/jaeger/jaeger-simplest.yaml 来检查 Jaeger 的 Kubernetes 部署和服务的详细信息。需要注意的关键点是,服务公开了端口 14250 用于收集 span,这是我们在 示例 13-11 中设置的端口。

检索 Jaeger UI 的 URL,minikube service --url jaeger-ui -n jaeger,在浏览器中打开它。我们看到了用于搜索跟踪的初始页面,但是在下拉菜单中找不到任何服务,因为尚未运行任何服务。

按照 /chapter-13README 重建和重新部署三个服务:ticker、processor、viewer。部署完成后,在浏览器中打开 viewer 的 URL,minikube service --url observability-viewer,开始接收消息。

一旦消息开始出现,返回到 Jaeger UI 并刷新页面。现在将会有四个服务可供选择;选择 observability-ticker,这是反应式流中的第一个服务。点击“查找跟踪”按钮以检索该服务的跟踪。从列表中选择一个跟踪以打开包含所有 spans 详细信息的视图(图 13-1)。

Jaeger UI showing reactive system trace

图 13-1. Jaeger UI showing reactive system trace

在这个示例中,我们在单个跟踪中有四个 spans。每个步骤都有一个 span,首先从 ticker 服务生成一个消息,然后在 processor 服务中消费并生成一个消息,最后在 viewer 服务中消费该消息。在 Jaeger UI 中,探索每个步骤中 spans 中捕获的数据。在 图 13-1 中,我们看到了 ticks send span 的详细信息,包括 span 的类型、生产者以及消息发送的详细信息。

注意

虽然 ticks send 在语法上是过去时不正确的,但 span 的名称由 OpenTelemetry 的语义约定所决定,详见 blob/main/specification/trace/semantic_conventions/messaging.md#span-name. Span 名称是目的地 ticks 和操作类型 send 的组合。

目前,您已经看到了如何在消息流几乎瞬间传输的情况下利用跟踪。然而,反应式系统的一个好处是能够根据时间解耦系统内的组件。换句话说,消息流可能需要数小时、数天、数周、数月甚至数年才能完成,在主题中等待,例如,在消费之前可能需要很长时间。同一条消息也可能在最初消费后的一段时间内由不同的消费者(如审计过程)处理。让我们模拟一个延迟的场景,看看跟踪是如何工作的。

首先,让我们使用 kubectl delete all --all -n default 清除现有的服务。为了确保我们从一个干净的状态开始,我们还应该删除并重新创建现有的 Kafka 主题,如 示例 13-13 所示。

示例 13-13. 更新应用部署
kubectl delete kafkatopics -n kafka processed
kubectl delete kafkatopics -n kafka ticks
kubectl apply -f deploy/kafka/ticks.yaml
kubectl apply -f deploy/kafka/processed.yaml

为了模拟延迟处理,让我们部署 ticker 服务,然后在 20 到 30 秒后再将其删除,以生成合理数量的消息(示例 13-14)。

示例 13-14. 部署和移除 ticker 应用程序
cd ticker
mvn verify -Dquarkus.kubernetes.deploy=true
# After 20-30 seconds
kubectl delete all --all -n default

再次搜索 observability-ticker 服务的跟踪,您将看到仅有一个跨度的跟踪。每个跟踪中仅有一个跨度,即来自 ticker 服务的跨度。为了处理在它运行之前的消息,我们需要更新 application.properties 以指示我们需要最早的消息;参见 示例 13-15。

示例 13-15. 配置新消费者组读取的第一个偏移量
mp.messaging.incoming.ticks.auto.offset.reset=earliest

更改后,部署观察者和处理器服务,并在浏览器中打开观察者 URL 以接收消息。一旦观察者接收到消息,返回 Jaeger UI 并再次搜索跟踪。我们看到以前仅有一个跨度的跟踪现在有了全部四个跨度!我们成功地在一段时间后处理了消息,并且 Jaeger 能够将这些跨度与正确的跟踪关联起来。

在实际生产系统中,前述过程是否奏效将取决于跟踪数据的保留。如果我们保留一年的跟踪数据,但希望处理比那更早的消息,Jaeger 将把它们视为仅包含今天跨度的跟踪。对于 Jaeger 来说,来自同一跟踪的任何跨度都将不再存在,以便正确地将它们关联到可视化。

总结

本章详细介绍了 Kubernetes 中反应式系统的可观察性的重要性。可观察性是确保反应式系统弹性和弹性的关键。健康检查通过触发重启不健康的服务来帮助系统具有弹性。反应式系统的特定指标可用于提供弹性,例如根据消息队列大小缩放消费者的数量。

我们讨论了 Kubernetes 中的可观察性,包括健康检查、指标和分布式跟踪。我们所讨论的只是可观察性在反应式系统中的冰山一角,但为开发人员提供了足够的细节,让他们自己深入探究。虽然我们可以提供反应式系统可观察性的一般准则,但具体的期望将严重依赖于系统的用例。

我们已经到达了第四部分的末尾,在这里我们讨论了反应式消息传递的模式以及其支持事件总线、连接消息到/从 HTTP 端点和观察反应式系统。

¹ 微米计 提供了对流行监控系统(如 Prometheus)的仪表客户端的外观封装。

² OpenTelemetry 是一个 CNCF 项目,将 OpenCensus 和 OpenTracing 结合为一个项目,用于收集遥测信号。Jaeger 是一个 CNCF 项目,用于收集和可视化跟踪数据。

第十五章:结论

我们已经来到本书的尾声。我们介绍了反应式架构的原则以及使用 Quarkus 实现这些原则的技术实践。

简短总结

在第二部分,我们探讨了反应式架构。反应式系统(第四章)提出了一种不同的构建分布式系统(第三章)的方式。系统中各个组件之间的消息传递实现了弹性和韧性,这是现代应用在云端或容器中部署的两个关键特性。但这还不是全部。反应式应用还必须及时处理工作负载并有效使用资源。最后一点推动反应式应用使用非阻塞 I/O,并避免创建过多的操作系统线程(“非阻塞输入/输出的角色”)。由此产生的执行模型提供了更好的响应时间并改善了内存消耗。然而,这并非免费的。要编写这样的应用程序,您必须改变编码方式。您不能阻塞 I/O 线程,因此必须使用延续传递风格编写代码。在本书中,我们研究了反应式编程和 Mutiny(第五章,第七章)。

我们还介绍了 Quarkus,这是一个专为云和容器中的 Java 应用程序设计的堆栈(第二章)。Quarkus 运行在处理网络和非阻塞 I/O 的反应式引擎之上。此外,Quarkus 提供了大量的反应式 API。引擎和 API 的结合为构建反应式应用程序提供了肥沃的土壤(第六章)。Quarkus 提供了用于服务 HTTP 端点(第八章),以及与数据源交互(第九章)和消费 HTTP 服务(第十二章)的反应式 API。

Quarkus 还提供了构建反应式系统的连贯性基础(第十章)。本书涵盖了 Kafka 和 AMQP 1.0,但还有更多可能性可供选择(第十一章)。

Quarkus 让您设计、构建和操作反应式系统。可观察性是分布式系统的关键组成部分,而不是开发结束时要添加的功能(第十三章)。反应式系统是分布式系统,故障是不可避免的。能够观察、检测问题、发出警报并做出反应对于保持系统运行并发挥其作用至关重要。

这就是全部吗?

本书没有提供构建响应式系统的灵丹妙药。我们涵盖了构建它们的原则和构建块。但是,就像软件中的一切一样,理想的解决方案总是取决于问题本身。我们向您展示了一个工具箱,但选择最适合您的应用程序的最佳工具、按照响应式原则组装系统并获利,还需您自行决定。

在整本书中,我们展示了许多使用 Quarkus 实现响应式应用和系统的特性,但我们只是浅尝辄止。Quarkus 提供了许多更多的响应式特性。

我们解释了如何以响应式方式处理 HTTP。但 HTTP 也有替代方案。例如,gRPC 是一种安全、多语言且高性能的 RPC 协议,可以替代大多数 HTTP 交互。它采用基于合同的方法(使用 Protobuf 编写),支持单向和双向流。Quarkus 允许您实现 gRPC 服务并消费它们。它依赖于响应式引擎,因此提供了优秀的性能和资源利用率。此外,它还集成了 Mutiny API。

我们还涵盖了数据空间,解释了如何在 Quarkus 应用程序中与各种数据库进行交互。Quarkus 提供了对诸如 PostgreSQL、MySQL、Db2、SQL Server 和 Oracle 等数据库的响应式访问。Quarkus 还提供了与许多 NoSQL 数据库(如 Neo4j、Cassandra、Redis 和 MongoDB)进行交互的响应式 API。

最后,要构建响应式系统,通常需要消息代理或异步消息交换方式。在本书中,我们使用了 Apache Kafka 和 Apache ActiveMQ。Quarkus 提供了更多选择。你可以集成 MQTT、RabbitMQ 或 JMS。Quarkus 还可以与 Apache Camel 结合,与几乎所有现有系统进行交互,而不影响响应性

换句话说,Quarkus 提供了一个完整的工具箱,让您可以为多种环境和用例构建响应式应用程序。您有无尽的可能性。

响应式系统的未来

高度确定未来是不可能的。我们能做的最好的事情是追踪有意义的趋势并为变化做好准备。以下是一些我们认为值得关注的趋势。

HTTP 正在发展。HTTP/3 引入了更好的流控制方法和并行请求传输,改善了系统间的整体通信。

使用消息代理的情况正在迅速增长。新的代理正在出现,如 Apache Pulsar、NATS 和 KubeMQ。最后两个是以 Kubernetes 为核心构建的,并在这样的环境中很好地集成。一些革新性的项目正在改变如何处理消息并从事件流中获取知识。以 Apache Pinot 为例,允许查询来自事件流(如 Apache Kafka)的数据。

就像在许多其他领域一样,机器学习和人工智能的兴起也影响了反应式系统的构建。机器学习算法可以帮助理解系统并使其适应处理故障或需求高峰。今天,您已经可以看到 Kubernetes 运算符收集系统的指标并使其适应当前的工作负载。

在代码层面,Project Loom 很有前景。它将大大简化编写高效反应式应用程序的复杂性。像 Ballerina 和 Joli 这样表达结构化并发的方法仍然是小众的,但可能很快会变得更流行。

这里还有许多趋势。请关注那些采纳了我们在本书中解释的反应式原理的技术。

开端的结束

您现在拥有构建更好的分布式系统所需的所有工具,这些系统更加健壮和高效。Quarkus,一个专为云环境量身定制的 Java 栈,将让您顺利采纳反应式系统的范式,一步一步来吧!

希望您享受了这段旅程。现在是时候利用所学开始新的旅程了。

posted @   绝不原创的飞龙  阅读(56)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek “源神”启动!「GitHub 热点速览」
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
· DeepSeek R1 简明指南:架构、训练、本地部署及硬件要求
· NetPad:一个.NET开源、跨平台的C#编辑器
历史上的今天:
2020-06-15 布客&#183;ApacheCN 编程/后端/大数据/人工智能学习资源 2020.6
2020-06-15 布客·ApacheCN 编程/大数据/数据科学/人工智能学习资源 2020.1
2020-06-15 ApacheCN 编程/大数据/数据科学/人工智能学习资源 2019.11
2020-06-15 ApacheCN 公众号文章汇总 2019.9
点击右上角即可分享
微信分享提示