反应式编程
- 前言
- 1 反应式编程概览
- 2 初识 Reactor
- 2.1 绘制反应式流图
- 2.2 添加 Reactor 依赖
- 3.使用常见的反应式操作
- 3.1 创建反应式类型
- 3.2 组合反应式类型
- 3.3 转换和过滤反应式流
- 3.4 在反应式类型上执行逻辑操作
- 总结
前言
你有过订阅报纸或者杂志的经历吗?互联网的确从传统的出版发行商那儿分得了一杯羹,但是在过去,订阅报纸确实是了解时事的最佳方式。那时,我们每天早上都会收到一份最新的报纸,并在早饭时间或上班路上阅读。
现在假设一下,在支付完订阅费用之后,几天的时间过去了,你却没有收到任何报纸。又过了几天,你打电话给报社的销售部门询问为什么还没有收到报纸,他们告诉你因为你支付的是一整年的订阅费用,而现在这一年还没有结束,当这一年结束时,你肯定可以一次性完整地收到它们,你会觉得他们有多么不可理喻。值得庆幸的是,这并非订阅的真正运作方式。报纸都具有一定的时效性。在出版后报纸需要及时投递,以确保读者阅读到的内容仍然是新鲜的。此外,你在阅读最新一期的报纸时,记者们正在为未来的某一期报纸撰写内容,同时印刷机正在满速运转,印刷下一期的内容————一切都是并行的。
在开发应用程序代码时,我们可以编写两种风格的代码一一命令式和反应式。
- 命令式(
imperative
)的代码非常像上文所提到的那个荒谬的、假想的报纸订阅方式。它由一组串行的任务组成,每次只运行一项任务,每个任务又都依赖于前面的任务。教据会按批次进行处理,在前一项任务还没有完成对当前数据批次的处理时,不能将这些数据递交给下一项处理任务。 - 反应式(
reactive
) 的代码则很像真实的报纸订阅方式。它会定义一组用来处数据的任务,但是这些任务可以并行。每项任务处理数据的一个子集,并且在将结果交给处理流程中下一项任务的同时,继续处理数据的另一个子集。
在本文中,我们将探索 Reactor
项目。Reactor
是一个反应式编程库,同时也是 Spring
家族的一部分。由于它是 Spring
反应式编功能的基础,所以在学习使用 Spring 构建反应式控制器和存储库之前,理解 Reactor
是非重要的。现在,我们花点时间研究一下反应式编程的基本要素。
1 反应式编程概览
反应式编程是一种可以替代命令式编程的编程范式。这种可替代性得以存在的原因在于:反应式编程解决了命令式编程中的一些限制。理解这些限制,有助于你更好地理解反应式编程模型的优点。
注意:反应式编程不是万能的。我们不应该从这一章或者其他任何关于反应式编程的讨论中得出“命令式编程一无是处,反应式编程才是救星”的结论。如同我们作为开发者学习到的任何技术一样,反应式编程对于某些使用场景的确十分好用,但是在另一些场景中可能不那么适合。建议以实用主义为原则选择编程范式。
你如果和我及大量开发者一样,从命令式编程入行,那么你现在编写的大部分(或者所有)代码在将来很可能依然是命令式的。命令式编程相当直观,没有编程经验的学生可以在学校的 STEM 教育课程中轻松地学习它。命令式编程也足够强大,驱动大型企业的代码大部分都是命令式的。
它的理念很简单: 你可以按照顺序逐一将代码编写为需要遵循的指令列表。在某项任务开始执行之后,程序在开始下一项任务之前需要等待当前任务完成。在整个处理过程中的每一步,要处理的数据都必须是完全可用的,以便将它们作为一个整体处理。一开始一切都很美好,但我们最终会遇到问题:执行一项任务,特别是 I/O
任务(将数据写人到数据库或者从远程服务器获取数据时),触发这项任务的线程实际上是阻塞的,在任务完成之前不能做任何事情。坦白来说,阻塞线程是一种浪费。
大多数编程语言(包括 Java)都支持并发编程。在 Java 中创建另一个线程并让它执行某些操作相当容易,而此时调用线程则可以继续执行其他工作。虽然创建线程很简单,但是这些线程中,多半最终都会阻塞。管理多线程中的并发极具挑战,而更多线程则意味着更高的复杂性。
相比之下,反应式编程本质上是函数式和声明式的。反应式编程不再描述一组依次执行的步骤,而是描述数据会流经的管道成流。反应式流不再要求将被处理的数据作为一个整体进行处理,而能够在数据可用时立即开始处理。实际上,传人的数据可能是无限的(比如某个地理位置的实时度测量数据的恒定流)。
类比现实世界,可以将命令式编程看作水气球,而将反应式编程看作是花园里的软管在夏天,这两者都是捉弄毫无戒心的朋友的好方式。但是它们的运作方式却不同:
- 水气球只能一次性地填满有效载荷,并在撞到目标时将其打湿。水气球的客量也有限,如果想打湿更多人(或者将同一个人打得更湿一些), 就需要增加水气球的教量
- 软管的有效载荷则是从水龙头到喷嘴的水流。在特定的时间点,花园软管的容量可能是有限的,但是在打水仗的过程中它能供应的水流却是“无限”的。只要水源源不断地从龙头流入软管,那么水也会继续源源不断地从喷嘴喷出去。同一个软管也非常好扩展,你可以尽情和更多的朋友打水仗。
虽然使用水气球(或者应用命令式编程)没有什么固有的问题,但是持有软管(或者应用反应式编程)通常可以扩大伸缩性和性能方面的优势。
定义反应式流
反应式流(reactive streams)是 Netflix
、Lightbend
和 Pivotal
( Spring 背后的公司)的工程师于2013 年底开始制定的一种规范。反应式流旨在提供无阻塞回压的异步流处理标准。我们已经接触到反应式编程的异步特性,它使我们能够并行执行任务从而实现更高的可伸缩性。通过回压,数据消费者可以限制它们想要处理的数据量,避免被过快的数据源产生的数据淹没。
Java
的流和反应式流
Java
的流和反应式流之间有着很许多相似之处。它们的名字中都有流(stream
)这个词它们也都提供了用于处理数据的函数式API
。事实上,正如我们会在Reactor
项目中看到的那样,它们甚至可以共享许多操作。
然而,Java
的流通常都是同步的,并且只能处理有限的数据集。本质上来说,它们只是使用函数来对集合进行迭代的一种方式。
反应式流支持异步处理任意大小的数据集,包括无限的数据集。只要数据就绪,它们就能 实时地处理数据,并且通过回压来避免压垮数据消费者。
JDK 9中的Flow API
对应反应式流,其中的Flow.Publisher
、Flow.Subscriber
、FlowSubscripton
和Flow.Processor
类型分别直接映射到反应式流中的Publisher
、Subscriber
、Subscription
和Processor
。
也就是说,JDK9
的Flow API
并不是反应式流的实际实现。
反应式流规范可以总结为 4 个接口,即 Publisher
、 Subscribe
,Subscription
和Processor
。Publisher
负责生成数据,并将数据发送给 Subscription
(每个Subscriber
对应一个Subscription
)。Publisher
接口声明了一个方法 subscribe()
, Subscriber
可以通过该方法向 Publisher
发起订阅。
public interface Publisher<T> {
void subscribe(Subscriber<? super T> subscriber);
}
Subscriber
一旦订阅成功,就可以接收来自 Publisher
的事件。这事件是通Subscriber
接口上的方法发送的:
public interface Subscriber<T> {
void onSubscribe(Subscription sub);
void onNext(T item);
void onError(Throwable ex);
void onComplete();
}
Subscriber
收到的第一个事件是通过对 onSubsribe()
方法的调用接收的。Publisher
调用onSubscribe()
方法时,它将 Subscription
对象传递给 Subscriber
。通过 Subscription
,Subscriber
可以管理其订阅情况:
public interface Subscription {
void request(long n);
void cancel();
}
Subscriber
可以通过调用 request()
方法来请求 Publisher
发送数据,也可以通过调用cancel()
方法来表明它不再对数据感兴趣并且取消订阅。当调用 request()
时,Subscriber
可以传人一个 long
类型的值以表明它愿意接受多少数据。这也是回压能够发挥作用的地方——避免Publisher
发送超过 Subscriber
处理能力的数据量。在 Publisher
发送完所请求数量的数据项之后,Subscriber
可以再次调用 request()
方法来请求更多的数据。
Subscriber
请求数据之后,数据就会开始流经反应式流。Publisher
发布的每个数据项都会通过调用 Subscriber
的 onNext()
方法递交给 Subscriber
。如果有任何的错误,则会调用 onError()
方法。如果 Publisher
目前没有更多的数据,而且也不会继续产生更多的数据那么它将会调用Subscriber
的 onComplete()
方法来告知 Subscriber
它已经结束。
至于 Processor
接口,它是 Subscriber
和 Publisher
的组合:
public interface Processor<T,R>extends Subscriber<T>, Publisher<R> {}
当作为 Subscriber
时,Processor
会接收数据并以某种方式对数据进行处理。然后,它会将角色转变为 Publisher
,将处理的结果发布给它的 Subscriber
。
正如你所看到的,反应式流的规范非常简单。看起来,很容易就能构建一个以Publisher
作为开始的数据处理管道,并让数据通过零个或多个 Processor
,然后将最终结果投递给 Subscriber
。
然而,反应式流规范的接口本身并不支持以函数式的方式组成这样的流。Reactor
项目是反应式流规范的一个实现,它提供了一组用于组装反应式流的函数式 API。我们将会在后面的内容中看到,Reactor
构成了 Spring
反应式编程模型的基础。接下来,我们会探讨 Reactor
项目(并且,我敢说这个过程非常有意思 )。
2 初识 Reactor
反应式编程要求我们采取和命令式编程不同的思维方式。反应式编程意味着我们不再描述每一步要进行的步骤,而要构建数据将要流经的管道。当数据流经管道时,可以使用它们,也可以对它们进行某种形式的修改。
例如,假设我们想要接受一个英文人名,然后将所有的字母都转换为大写,用得到的结果创建一个问候消息,最终打印它。使用命令式编程模型,代码看起来如下所示:
String name ="Craig";
String capitalName = name.toUpperCase();
String greeting ="Hello,"+capitalName +"!";
System.out.println(greeting);
使用命令式编程模型,每行代码执行一个步骤,按部就班。各个步骤在同一个线程中执行,并且每一步在自身执行完成之前都会阻止执行线程执行下一步。与之不同,如下的函数式、反应式代码完成了相同的事情:
Mono.just("Craig")
.map(n -> n.toUpperCase())
.map(cn ->"Hello,"+cn +"!").
subscribe(System.out::println);
不用过度关心这个例子中的细节,因为我们很快将会详细讨论 just()
、map()
、和subscribe()
方法。现在,重要的是要理解,虽然这个反应式的例子看起来依然保持着按步骤执行的模型,但实际上数据会流经处理管线。在处理管线的每一步,数据都进行了某种形式的加工,但是我们不能判断数据会在哪个线程上执行这些操作。它们可能在同一个线程,也可能在不同的线程。
这个例子中的 Mono
是Reactor
的两种核心类型之一,另一个类型是 Flux
。两者都实现了反应式流的 Publisher
接口。Flux
代表具有零个、一个或者多个(可能是无限个)数据项的管道,而 Mono。是一种特的反应式类型,针对数据项不超过一个的场景进行了忧化。
Reactor
与RxJava
( ReactiveX)的对比 如果你熟悉RxJava
或者RectiveX
,可能认为
Mono
和Flux
类似于Observable
和
Single
。事实上它们不仅在语义上大致相同,还共享了很多相同的操作符。 最然我们主要介绍Reactor
,但是
Reactor
和RxJava
的类型可以互相转换,相信你会对这一点感到开心。接下来我们还会看到,Spring 甚至可以使用
RxJava
的类型。
实际上,在前面的例子中有 3个 Mono,其中 just()
操作创建了第一个。当该 Mono 发送一个值的时候,这个值传递给用于将字母转换为大写的 map()
操作,据此又创建另二个Mono
。当第二个Mono
发布它的数据时,数据传递给第二个 map()
操作,并且在此接受一些字符串连接操作,而结果将用于创建第三个 Mono
。最后,调用第三个 Mono
上的subscribe()
方法时,方法会接收数据并将数据打印出来。
2.1 绘制反应式流图
反应式流程通常使用弹珠图(marble diagrams
)表示。弹珠图的展现形式非常简单如图所示,顶部描述了数据流经 Flux
或者 Mono
的时间,在中间描述了要执行的操作,在底部描述了结果形成的 Flux 或者 Mono 的时间线。我们将会看到,当数据流经原始的 Flux 时,一些操作会对其进行处理,并产生一个新的 Flux,已经处理的数据将会在新 Flux 中流动。
下面的图展示了一个类似的弹珠图,但是针对的是 Mono
。我们可以看到,这里主要的不同是 Mono将会有零个或者一个数据项,或者一个错误。
2.2 添加 Reactor 依赖
要开始使用 Reactor
,我们首先要将下面的依赖项添加到项目的构建文件中:
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
</dependency>
Reactor 还提供了非常棒的测试支持。我们将会围绕 Reactor
代码编写大量的测试因此需要将下面的依赖添加到构建文件中:
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
如果你计划将这些依赖添加到一个 Spring Boot
工程中,那么Spring Boot 工程会替你管理依赖。但是,如果要在非 Spring Boot项目中使用Reactor
,则需要在构建文件中设置 Reactor的物料清单(Bill Of Materials,BOM
)。下面的赖管理条目将会把 Reactor 的“2020.0.4”版本添加到构建文件中:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-bom</artifactId>
<version>2020.0.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
创建一个在构建文件中包含 Reactor
依赖的 Spring
项目,并基于此开始接下来的工作。
现在 Reactor
已经位于项目的构建文件中,我们可以开始使用 Mono
和 Flux
来创建反应式的处理管线了。在本文的剩余部分,我们将介绍 Mono 和 Flux 提供的一些操作。
3.使用常见的反应式操作
Flux
和 Mono
是 Reactor
提供的最基础的构建块,这两种反应式类型所提供的操作就像黏合剂,使我们能够据此创建数据流的管道。Flux 和 Mono 共有超过 500个操作这些操作大致可以归类为:
- 创建操作;
- 组合操作;
- 转换操作;
- 逻辑操作。
虽然逐一介绍这 500 多个操作会非常有趣,但是本章的篇幅有限,所以我在本节中选择了一些相对实用的操作来进行说明。让我们从创建操作开始吧。
注意:
Mono
的例子呢? Mono和Flux
的很多操作都是相同的,我们没有必要分别针对 Mono 和Flux 进行介绍。此外,虽然
Mono 的操作也很有用,但是相比而言,Flux 上的操作更有趣。我们的大多数示例都会使用 Flux,你只需要知道,Mono
通常都具有相同的名称的操作。
3.1 创建反应式类型
在Spring中使用反应式类型时我们通常可以从存储库或服务中获得Flux
或Mono
并不需要自行创建。但偶尔,我们可能需要创建一个新的反应式Publisher
。
Reactor
提供了多种创建 Flux 和Mono 的操作。本节将介绍一些创建操作。
根据对象创建
如果我们有一个或多个对象,并想据此创建 Flux
或 Mono
,那么可以使用 Flux 或Mono上的静态 just()
方法来创建一个反应式类型,它们的数据会由这些对象来驱动。例如,下面的测试方法基于5个 String
对象创建了 Flux:
@Test
public void createAFlux_just() {
Flux<String> fruitFlux = Flux
.just("Apple", "Orange", "Grape", "Banana", "Strawberry");
}
现在,我们已经创建了 Flux
,但是它还没有订阅者。如果没有任何的订阅者,那么数据将不会流动。回想一下花园软管的比喻,假设我们已经将花园软管连接到水龙头上,水龙头的另一侧是来自水厂的水,但是在打开水龙头之前,水不会流动。订阅反应式类型就如同打开数据流的水龙头。
要添加一个订阅者,我们可以在 Flux
上调用 subscribe()
方法:
fruitFlux.subscribe(
f-> System.out.println("Here's some fruit: " + f));
这里传递给 subscribe()
方法的 lambda
表达式实际上是一个java.util.Consumer
,用来创建反应式流的 Subscriber
。在调用 subscribe()
之后,数据会开始流动。在这个例子中没有中间操作,所以数据从 Flux
直接流向订阅者。
将来自 Flux
或 Mono
的数据项打印到控制台是观察反应式类型运行方式的好办法但实际测试 Flux 或 Mono的更好的方法是使用 Reactor
提供的 StepVerifier
。 对于给定的Flux或 Mono,StepVerifier 将会订阅该反应式类型,在数据流过时对数据使用断言并在最后验证反应式流是否按预期完成。
例如,要验证预定义的数据流经 fuitFlux
,可以编写如下所示的测试代码:
StepVerifier.
create(fruitFlux)
.expectNext("Apple")
.expectNext("Orange")
.expectNext("Grape")
.expectNext("Banana")
.expectNext("Strawberry")
.verifyComplete();
在这个例子中,StepVerifier
订阅了 fuitFlux
,然后断言Flux
中的每个数据项是否与预期的水果名称相匹配。最后它验证 Flux 在发布完 “Strawberry” 之后整个 fuitFlux 正常完成。
对于本章的其他例子我们都可以使用StepVerifier
来编写测试验证Flux
或者Mono
行为,研究相应的工作原理,从而帮助我们学习和了解 Reactor
中最有用的操作。
根据集合创建
我们还可以根据数组、Iterable
或者 Java Stream
创建 Flux
。下图使用弹珠图展示了如何使用这种方式进行创建。
要根据数组创建 Flux
,可以调用 Flux 上的静态方法 fromArray()
,并为其传入一个源数组:
@Test
public void createAFlux_fromArray() {
String[] fruits = new String[]{
"Apple","Orange","Grape","Banana","Strawberry"};
Flux<String> fruitFlux = Flux.fromArray(fruits);
StepVerifier.create(fruitFlux)
.expectNext("Apple")
.expectNext("Orange")
.expectNext("Grape")
.expectNext("Banana")
.expectNext("Strawberry").
verifyComplete();
}
该源数组包含的水果名称与之前使用对象列表创建 Flux
时的水果名称是相同的所以该Flux 发布的数据会有相同的值。因此,我们可以使用和之前相同的 StepVerifier
来验证该Flux。
如果需要根据java.util.List
、java.util.Set
或者其他任意 java.lang.Iterable
的实现来创建Flux,那么可以将其传递给静态的 fromIterable()
方法:
public void createAFlux_fromIterable() {
List<String> fruitList = new ArrayList<>();
fruitList.add("Apple");
fruitList.add("Orange");
fruitList.add("Grape");
fruitList.add("Banana");
fruitList.add("Strawberry");
Flux<String> fruitFlux = Flux.fromIterable(fruitList);
StepVerifier.create(fruitFlux)
.expectNext("Apple")
.expectNext("Orange")
.expectNext("Grape")
.expectNext("Banana")
.expectNext("Strawberry").
verifyComplete();
}
如果我们有一个Java Stream
,并且希望将其用作 Flux
源,那么可以调用fromStream()
方法:
@Test
public void createAFlux_fromStream() {
Stream<String> fruitstream = Stream.of("Apple", "Orange", "Grape", "Banana", "Strawberry");
Flux<String> fruitFlux = Flux.fromStream(fruitstream);
StepVerifier.create(fruitFlux)
.expectNext("Apple")
.expectNext("Orange")
.expectNext("Grape")
.expectNext("Banana")
.expectNext("Strawberry")
.verifyComplete();
}
同样,我们可以使用和之前一样的StepVerifier
来验证该Flux
发布的数据。
生成Flux的数据
有时候我们根本没有可用的数据,只是想使用 Flux
作为一个计数器,使它每次发送新值时自增 1
。要创建计数器 Flux,我们可以使用静态方法 range()
。下图 说明了range()
方法的工作原理。
下面的测试方法展示了如何创建一个区间 Flux
:
@Test
public void createAFlux_range() {
Flux<Integer> intervalFlux =
Flux.range(1, 5);
StepVerifier.create(intervalFlux)
.expectNext(1)
.expectNext(2)
.expectNext(3)
.expectNext(4)
.expectNext(5)
.verifyComplete();
}
在这个例子中,我们创建了一个区间 Flux
,它的起始值为 1,结束值为 5。StepVerifer
证明了它将发布 5个条目,即整数1到5。
另一个与range()
方法类似的Flux
创建方法是interval()
。与range()
方法一样,interval()
方法会创建一个发布递增值的 Flux
。但是,interval()
的特殊之处在于,我们不是为它设置起始值和结束值,而是指定一个间隔时间,明确应该每隔多长时间发出值。下图展示了interval()
方法创建Flux
原理的弹珠图。
例如,要创建一个每秒发布一个值的 Flux
,可以使用 Flux 上的静态interval()
方法如下所示:
@Test
public void createAFlux_interval() {
Flux<Long> intervalFlux =
Flux.interval(Duration.ofSeconds(1)).take(5);
StepVerifier.create(intervalFlux)
.expectNext(0L)
.expectNext(1L)
.expectNext(2L)
.expectNext(3L)
.expectNext(4L)
.verifyComplete();
}
需要注意的是,通过interval()
方法创建的 Flux
会从0
开始发布值,并且后续的条目依次递增。此外,interval() 方法没有指定最大值,所以可能会永远运行。我们可以使用take()
方法来将结果限制为前 5 个条目。我们将在 3.3 小节中详细讨论 take()
方法。
3.2 组合反应式类型
有时候,我们可能需要操作两种反应式类型,并以某种方式合并它们。或者,在其他情况下,我们可能需要将 Flux
拆分为多种反应式类型。在本小节,我们将研究组合及拆分 Reactor
的 Flux
和 Mono
的操作。
合并反应式类型
假设我们有两个 Flux
流,并且需要据此创建一个能在任意一个上游 Flux
流可用时产生相同数据的 Flux。要将一个 Flux 与另一个 Flux 合并,可以使用 mergeWith()
方法,如图所示。
例如,假设有一个以影视作品中的角色名为值的 Flux
,还有一个以这些角色喜欢的食物为值的 Flux。如下所示的测试方法展示了如何使用 mergeWith()
方法合并两个 Flux 对象:
@Test
public void mergeFluxes() {
Flux<String> characterFlux = Flux
.just("Garfield","Kojak","Barbossa")
.delayElements(Duration.ofMillis(500));
Flux<String> foodFlux = Flux
.just("Lasagna","Lollipops","Apples")
.delaySubscription(Duration.ofMillis(250))
.delayElements(Duration.ofMillis(500));
Flux<String> mergedFlux = characterFlux.mergeWith(foodFlux);
StepVerifier.create(mergedFlux)
.expectNext("Garfield")
.expectNext("Lasagna")
.expectNext("Kojak")
.expectNext("Lollipops")
.expectNext("Barbossa")
.expectNext("Apples")
.verifyComplete();
}
通常,Flux
会尽可能快地发布数据。所以,在这里,我们在两个 Flux 流上使用delayElements()
方法来减慢它们的速度,使它们每 500 毫秒发布一个条目。 此外,为了使食物 Flux 开始流式传输的时间在角色名 Flux 之后,我们调用了食物 Flux 上的delaySubscription
方法,使它在订阅后250毫秒才开始发布数据。
在合并了两个 Flux
对象后,将会得到一个新的 Flux。StepVerifier
订阅这个合并后
的 Flux 时,它将依次订阅两个源 Flux 流并启动数据流。
对于合并后的 Flux
来说,其数据项的发布顺序与源 Flux 的发布时间一致。因为两个Flux 对象都设置为以固定速率发布数据,所以这些值在合并后的 Flux 中会交错在起,形成角色名和食物交替出现的结果。如果任何一个 Flux 的发布时机发生变化,那么就可能会看到 Flux 接连发布了两个角色名或者两个食物。
因为 mergeWith()
方法不能完美地保证源 Flux 之间的先后顺序,所以我们可以考虑使用 zip()
方法。当两个 Flux
对象压缩在一起的时候,它将会产生一个新的发布元组的Flux,其中每个元组中都包含了来自每个源 Flux 的数据项。下图说明了如何将两个 Flux 对象压缩在一起。
要查看 zip()
操作实际是如何运行的,可以考虑使用如下的测试方法,它将角色 Flux
和食物Flux
合并在了一起:
@Test
public void zipFluxes() {
Flux<String> characterFlux = Flux
.just("Garfield", "Kojak", "Barbossa");
Flux<String> foodFlux = Flux
.just("Lasagna", "Lollipops", "Apples");
Flux<Tuple2<String, String>> zippedFlux =
Flux.zip(characterFlux, foodFlux);
StepVerifier.create(zippedFlux)
.expectNextMatches(p ->
p.getT1().equals("Garfield") &&
p.getT2().equals("Lasagna"))
.expectNextMatches(p ->
p.getT1().equals("Kojak") &&
p.getT2().equals("Lollipops"))
.expectNextMatches(p ->
p.getT1().equals("Barbossa") &&
p.getT2().equals("Apples"))
.verifyComplete();
}
需要注意的是,与 mergeWith()
方法不同,zip()
方法是一个静态的创建操作。创建出来的 Flux
在角色名和角色喜欢的食物之间会完美对齐。从这个合并后的 FIux
发出的每个条目都是一个Tuple2
(一个容纳两个其他对象的容器对象)的实例,其中包含了来自每个源 Flux
的数据项,并保持着它们发布的顺序。
如果你不想使用 Tuple2
,而想使用其他类型,可以为 zip
方法提供一个合并函数来生成你想要的任何对象,合并函数会传人这两个数据项( 如图所示 )。
例如,下面的测试方法展示了角色名 Flux
与食物 Flux
如何合并在一起,并生成一个包含String
对象的 Flux:
@Test
public void zipFluxesToObject() {
Flux<String> characterFlux = Flux
.just("Garfield", "Kojak", "Barbossa");
Flux<String> foodFlux = Flux
.just("Lasagna", "Lollipops", "Apples");
Flux<String> zippedFlux = Flux.zip(characterFlux, foodFlux, (c, f) -> c + " eats " + f);
StepVerifier.create(zippedFlux)
.expectNext("Garfield eats Lasagna")
.expectNext("Kojak eats Lollipops")
.expectNext("Barbossa eats Apples")
.verifyComplete();
}
传递给 zip()
方法(在这里是一个 lambda
表达式)的 Function 会简单地将两个数据项组装成一个句子,然后通过合并后的 Flux 发布。
选择第一个反应式类型进行发布
假设有两个 Flux
对象,但我们并不想将它们合并在一起,而是想要创建一个新的Flux,将第一个产生数值的 Flux 中的数值发布出去。 如图所示,first
操作会在两个 Flux 对象中选择第一个发布值的 Flux,并再次发布它的值。
下面的测试方法创建了一个快速的 Flux
和一个“缓慢”的 Flux
(其中“缓慢”意味着它在被订阅后 100毫秒才会发布数据项)。使用frst
操作的相关方法,则会创建一个新的 Flux,只发布第一个发布值的源 Flux 的值:
@Test
public void firstWithSignalFlux() {
Flux<String> slowFlux = Flux.just("tortoise", "snail", "sloth")
.delaySubscription(Duration.ofMillis(100));
Flux<String> fastFlux = Flux.just("hare", "cheetah", "squirrel");
Flux<String> firstFlux = Flux.firstWithSignal (slowFlux, fastFlux);
StepVerifier.create(firstFlux)
.expectNext("hare")
.expectNext("cheetah")
.expectNext("squirrel")
.verifyComplete();
}
在这种情况下,因为慢速 Flux
会在快速 Flux
开始发布之后的 100
毫秒才发布值所以新创建的 Flux 将会简单地忽略慢的 Flux,并仅发布来自快速 Flux 的值。
3.3 转换和过滤反应式流
在数据流经一个流时,我们通常需要过滤掉某些值并对其他的值进行处理。在本小节,我们将介绍流经反应式流的数据转换和过滤操作。
从反应式类型中过滤数据
数据从 Flux 流出时,对其进行过滤的一个基本方法是简单地忽略指定数目的前几个数据项。skip()
操作(如图所示)就能完成这样的工作。
针对具有多个数据项的 Flux
,skip()
操作将创建一个新的 Flux,首先跳过指定数量的前几个数据项,然后从源 Flux 中发布剩余的数据项。下面的测试方法展示了如何使用 skip()
方法:
@Test
public void skipAFew() {
Flux<String> countFlux = Flux.just(
"one", "two", "skip a few", "ninety nine", "one hundred")
.skip(3);
StepVerifier.create(countFlux)
.expectNext("ninety nine", "one hundred")
.verifyComplete();
}
在本例中下,我们有一个包含5个 String
数据项的 Flux。在这个 Flux上调用 skip(3)
方法后会产生一个新的 Flux,跳过前3个数据项,只发布最后2个数据项。
但是,你可能并不想跳过特定数量的条目,而是想要跳过一段时间之内出现的数据这是 skip()
操作的另一种形式。如图所示,该操作会产生一个新 Flux,它会等待-段指定的时间后发布来自源 Flux 中的数据条目。
下面的测试方法使用skip()
操作创建了一个在发布值之前等待4秒的 Flux。因为该Flux 是基于一个在发布数据项之间有一秒间隔的 Flux 创建的(使用了 delayElements()
操作 ),所以它只会发布出最后两个数据项:
@Test
public void skipAFewSeconds() {
Flux<String> countFlux = Flux
.just("one", "two", "skip a few", "ninety nine", "one hundred")
//每一个推迟1s发布
.delayElements(Duration.ofSeconds(1))
//跳过前4 s
.skip(Duration.ofSeconds(4));
StepVerifier.create(countFlux)
.expectNext("ninety nine", "one hundred")
.verifyComplete();
}
我们已经看过了 take()
操作的示例,但是根据对skip()
操作的描述来看, take() 可以认为是与 skip() 相反的操作。skip() 操作会跳过前几个数据项,而 take() 操作只发布指定数量的前几个数据项(如图所示):
@Test
public void take() {
Flux<String> nationalParkFlux = Flux
.just("Yellowstone", "Yosemite", "Grand Canyon", "Zion", "Acadia")
.take(3);
StepVerifier.create(nationalParkFlux)
.expectNext("Yellowstone", "Yosemite", "Grand Canyon")
.verifyComplete();
}
与 skip()
方法一样,take()
方法也有另一种替代形式,基于间隔时间而不是数据项数量。它将在某段时间之内接受并发布与源 Flux 相同的数据项,之后 Flux 就会完成。如图所示。
下面的测试方法使用了这种形式的 take()
方法,它将会在订阅之后的 3.5 秒内发布数据条目。
@Test
public void takeForAwhile(){
Flux<String> nationalParkFlux=Flux
.just("Yellowstone","Yosemite","Grand Canyon", "zion","Grand Teton")
.delayElements(Duration.ofSeconds(1))
.take(Duration.ofMillis(3500));
StepVerifier.create(nationalParkFlux)
.expectNext("Yellowstone","Yosemite","Grand Canyon")
.verifyComplete();
}
skip()
操作和take()
操作都可以认为是过滤操作,其过滤条件基于计数或者持续时间。而 Flux
值的更通用过滤操作则是 filter()
。
在使用 filter()
操作时,我们需要指定一个 Predicate
,用于决定数据项是否能通过 Flux,该操作能够让我们根据任意条件进行选择性地发布消息。图展示了filter()
操作的工作原理。
要查看 filter()
的实际效果,可以参考下面的测试方法:
@Test
public void filter() {
Flux<String> nationalParkFlux = Flux
.just("Yellowstone", "Yosemite", "Grand Canyon", "Zion", "Grand Teton")
.filter(np -> !np.contains(" "));
StepVerifier.create(nationalParkFlux)
.expectNext("Yellowstone", "Yosemite", "Zion")
.verifyComplete();
}
在这里,我们将只接受不包含空格的字符串的Predicate
作为 lambda
表达式传给filter()
方法。因此在结果 Flux
中,“Grand Canyon”和“Grand Teton”被过滤掉了。
我们可能还想要过滤掉已经接收过的数据项。distinct()
操作生成的 Flux
只会发布源 Flux 中尚未发布过的数据项,如图所示。
在下面的测试中,调用 distinct()
方法产生的 Flux 只会发布不同 String
值:
@Test
public void distinct() {
Flux<String> animalFlux = Flux.just(
"dog", "cat", "bird", "dog", "bird", "anteater")
.distinct();
StepVerifier.create(animalFlux)
.expectNext("dog", "cat", "bird", "anteater")
.verifyComplete();
}
虽然 “dog” 和 “bird” 从源 Flux
中都发布了 2 次,但是在调用 distinct()
方法产生的Flux 中,它们只发布一次。
映射反应式数据
在 Flux 或 Mono 中的一个常见操作是将已发布的数据项转换为其他的形式或类型 Reactor
的反应式类型(Flux
和 Mono
)为此提供了map()
和 flatMap()
操作。
map()
操作会创建一个新的 Flux
,该 Flux
在重新发布它所接收到的每个对象之前会对其执行由给定 Function 预先定义的转换。图说明了 map()
操作的工作原理。
在下面的测试方法中,包含篮球运动员名字的 String
值的 Flux
被转换为一个包含 Player
对象的新 Flux。
@Test
public void map() {
Flux<Player> playerFlux = Flux
.just("Michael Jordan", "Scottie Pippen", "Steve Kerr")
.map(n -> {
String[] split = n.split("\\s");
return new Player(split[0], split[1]);
});
StepVerifier.create(playerFlux)
.expectNext(new Player("Michael", "Jordan"))
.expectNext(new Player("Scottie", "Pippen"))
.expectNext(new Player("Steve", "Kerr"))
.verifyComplete();
}
@Data
private static class Player {
private final String firstName;
private final String lastName;
}
以 lambda
表达式形式传递给 map()
方法的函数会将传人的 String
值按照空格拆分并使用生成的 String 数组来创建 Player
对象。用 just()
方法创建的 Flux
包含 String 对象但 map() 方法产生的 Flux 则包含 Player 对象。
其中重要的一点在于,在每个数据项被源 Flux 发布时,map() 操作是同步执行的如果想要执行异步的转换操作,那么应该考虑使用 flatMap()
操作。
对于 flatMap()
操作,我们可能需要一些思考和练习才能完全掌握。如图所示 flatMap() 并不像 map() 操作那样简单地将一个对象转换到另一个对象,而是将对象转换为新的 Mono
或 Flux
。结果形成的 Mono 或 Flux 会扁平化为新的 Flux。当与 subscribeOn()
方法结合使用时, flatMap() 操作可以释放 Reactor
反应式的异步能力。
下面的测试方法展示了如何使用 flatMap()
方法和 subscribeOn()
方法:
@Test
public void flatMap() {
Flux<Player> playerFlux = Flux
.just("Michael Jordan", "Scottie Pippen", "Steve Kerr")
.flatMap(n -> Mono.just(n)
.map(p -> {
String[] split = p.split("\\s");
return new Player(split[0], split[1]);
})
.subscribeOn(Schedulers.parallel())
);
List<Player> playerList = Arrays.asList(
new Player("Michael", "Jordan"),
new Player("Scottie", "Pippen")
, new Player("Steve", "Kerr"));
StepVerifier.create(playerFlux)
.expectNextMatches(p -> playerList.contains(p))
.expectNextMatches(p -> playerList.contains(p))
.expectNextMatches(p -> playerList.contains(p))
.verifyComplete();
}
需要注意的是,我们为 flatMap()
方法指定了一个 lambda
表达式,将传入的 String
转换为 Mono
类型的 String。然后,map()
操作在这个 Mono 上执行,将 String
转换为Player
。每个内部 Flux 上的 String 被映射到一个 Player 后,再被发布到由 flatMap() 返回的单一 Flux 中,从而完成结果的扁平化。
如果我们到此为止,那么产生的 Flux
将同样包含 Player
对象,与使用 map()
操作的例子相同,顺序同步地生成。但是我们对 Mono
做的最后一件事情是调用 subscribeOn()
方法声明每个订阅都应该在并行线程中进行,因此可以异步并行地执行多个 String 对象的转换操作。
尽管 subscribeOn()
方法的命名与 subscribe()
方法类似,但二者的含义却完全不同。subscribe() 方法更像一个动作,可以订阅并驱动反应式流,而 subscribeOn() 方法则更具描述性,用于指定如何并发地处理订阅。Reactor
本身并不强制使用特定的并发模型。
调用 subscribeOn()
方法时,我们可以使用 Schedulers
中的任意一个静态方法来指定并发模型。在这个例子中,我们使用了parallel()
方法,它使用来自固定线程池(大小与 CPU 核心数量相同)的工作线程。但是 Scheduler
支持多种并发模型,如表所示。
Schedulers 方法 | 描述 |
---|---|
.immediate() | 在当前的线程中执行订阅 |
.single() | 在一个可复用的线程中执行订阅。对所有的调用者复用相同的线程 |
.newSingle() | 针对每个调用,使用专用的线程执行订阅 |
.elastic() | 从无界弹性线程池中拉取的工作者线程中执行订阅。它会根据需要创建新的工作线程,并销毁空闲的工作者线程(默认情况下,会在线程空闲60秒后销毁) |
.parallel() | 从一个固定大小的线程池中拉取的工作者线程中执行订阅。该线程池的大小和CPU 的核心数一致 |
使用 flatMap()
和 subscribeOn()
的优势在于,我们可以在多个并行线程之间拆分工作,从而增加流的吞吐量。但是,鉴于工作是并行完成的,无法保证哪项工作首先完成,所以结果 Flux
中数据项的发布顺序是未知的。因此,StepVerifier
只能验证发出的每个数据项是否存在于预期的 Player
对象列表中,并且在 Flux 完成之前会有3个这样的数据项。
在反应式流上缓冲数据
在处理流经 Flux
的数据时,将数据流拆分为小块可能会带来一定的收益。如图所示的 buffer()
操作可以帮助我们实现这个目的。
假设给定一个包含多个 String
值的 Flux
,其中每个值代表一种水果。我们可以创建个新的由 List
集合组成的 Flux,其中每个 List 包含不超过指定数量的元素:
@Test
public void buffer() {
Flux<String> fruitFlux = Flux.just(
"apple", "orange", "banana", "kiwi", "strawberry");
Flux<List<String>> bufferedFlux = fruitFlux.buffer(3);
StepVerifier
.create(bufferedFlux)
.expectNext(Arrays.asList("apple", "orange", "banana"))
.expectNext(Arrays.asList("kiwi", "strawberry"))
.verifyComplete();
}
在本例中,String
元素的 Flux
被缓冲到一个新的由 List
集合组成的 Flux
中,其中每个集合的元素数量不超过 3 个。因此,发出5个 String
值的原始 Flux
会转换为新的 Flux
,这个新的 Flux
会发出 2 个 List
集合,其中一个包含 3 个水果,而另一个包含 2 个水果。
这有什么意义?将反应式的 Flux
缓冲到非反应式的 Flux
中看起来与本章的目的南辕北辙。但在组合使用 buffer()
方法和 flatMap()
方法时,这样做可以使每一个 List
集合都可以被并行处理:
@Test
public void bufferAndFlatMap() throws Exception {
Flux.just("apple", "orange", "banana", "kiwi", "strawberry")
.buffer(3)
.flatMap(x ->
Flux.fromIterable(x)
.map(y -> y.toUpperCase())
.subscribeOn(Schedulers.parallel())
.log()
).subscribe();
}
在这个新例子中,我们仍然将具有 5 个 String 值的 Flux 缓冲到一个新的由 List 集合组成的 Flux 中,但将 flatMap()
应用于包含 List 集合的 Flux。这样会为每个 List 缓冲区中的元素创建一个新的 Flux,然后对其应用 map()
操作。因此,每个 List 缓冲区都会在各个线程中被进一步并行处理。
为了观察实际效果,代码中还包含了一个 log()
操作,用于每个子 Flux。log()
操作记录了所有的反应式事件,以便观察实际发生了什么事情。日志将会记录如下的条目(为简洁起见,删除了时间戳):
正如日志记录所清晰展示的,第一个缓冲区中的水果( apple 、orange 和 banana )在 parallel-1
线程中处理。与此同时,第二个缓冲区中的水果( kiwi 和 strawberry )在 parallel-2
线程中处理。从缓冲区中交织的日志记录可以明显看出,对两个缓冲区的处理是并行执行的。
如果由于某些原因需要将 Flux
发布的所有数据项都收集到一个 List
中,那么可以使用不带参数的 bufer()
方法:
Flux<List<String>> bufferedFlux = fruitFlux.buffer();
这会产生一个新的 Flux
。这个 Flux 将会发布一个 List
,其中包含源 Flux 发布的所有数据项。我们也可以使用 collectList()
操作实现相同的功能,如图所示。
collectList()
方法会产生 Mono
而不是 Flux
,以发布 List
集合。下面的测试方法展示了它的用法:
@Test
public void collectList() {
Flux<String> fruitFlux = Flux.just(
"apple", "orange", "banana", "kiwi", "strawberry");
Mono<List<String>> fruitListMono = fruitFlux.collectList();
StepVerifier
.create(fruitListMono)
.expectNext(Arrays.asList("apple", "orange", "banana", "kiwi", "strawberry")).verifyComplete();
}
有一种更有趣的方法可以收集 Flux
所发出的数据项:将它们收集到 Map
中。如图所示,collectMap()
操作将会产生一个发布 Map
的 Mono
。Map 中会填充一些数据项,数据项的键会由给定的 Function
计算得出。
要查看collectMap()
的效果,请参考下面的测试方法:
@Test
public void collectMap() {
Flux<String> animalFlux = Flux.just(
"aardvark", "elephant", "koala", "eagle", "kangaroo");
Mono<Map<Character, String>> animalMapMono =
animalFlux.collectMap(a -> a.charAt(0));
StepVerifier
.create(animalMapMono).expectNextMatches(map -> {
return map.size() == 3 &&
map.get('a').equals("aardvark") &&
map.get('e').equals("eagle") &&
map.get('k').equals("kangaroo");
}).verifyComplete();
}
源 Flux
会发布一些动物名称。基于这个 Flux,我们使用 collectMap()
创建了一个发布 Map
的新 Mono
,其中键由动物名称的首字母确定,而值则为动物名称本身。如果两个动物名称以相同的字母开头(如 elephant 和 eagle 、koala 和 kangaroo ),那么最后一个流经该流的条目将会覆盖先前的条目。
3.4 在反应式类型上执行逻辑操作
有时候我们想要知道由 Mono
或者 Flux
发布的条目是否满足某些条件。all()
和 any()
操作可以实现这样的逻辑。下列两张图分别展示了 all()
和 any()
的工作方式。
all()
:
any()
:
假设我们想知道 Flux
发布的每个 String
中是否都包含了字母 a 和字母 k。下面的测试展示了如何使用 all()
方法来检查这个条件:
@Test
public void all() {
Flux<String> animalFlux = Flux.just(
"aardvark", "elephant", "koala", "eagle", "kangaroo");
Mono<Boolean> hasAMono = animalFlux.all(a -> a.contains("a"));
StepVerifier.create(hasAMono)
.expectNext(true)
.verifyComplete();
Mono<Boolean> hasKMono = animalFlux.all(a -> a.contains("k"));
StepVerifier.create(hasKMono)
.expectNext(false)
.verifyComplete();
}
在第一个 StepVerifier
中,我们检查了字母 a。all()
方法应用于源 Flux
,会产生布尔类型的 Mono
。在本例中,所有动物名称都包含了字母 a,所以从生成的 Mono
中会发布 true
。但是在第二个 StepVerifier
中,产生的 Mono
将会发出 false
,因为并非所有动物名称都包含了字母 k。
如果至少有一个元素匹配条件即可,而不是要求所有元素均满足条件。那么在这种情况下,我们所需的操作就是 any()
。下面这个新的测试用例使用 any()
来检查字母 t 和字母 z :
@Test
public void any() {
Flux<String> animalFlux = Flux.just(
"aardvark", "elephant", "koala", "eagle", "kangaroo");
Mono<Boolean> hasTMono = animalFlux.any(a -> a.contains("t"));
StepVerifier.create(hasTMono)
.expectNext(true)
.verifyComplete();
Mono<Boolean> hasZMono = animalFlux.any(a -> a.contains("z"));
StepVerifier.create(hasZMono)
.expectNext(false)
.verifyComplete();
}
在第一个 StepVerifier
中,我们会看到生成的 Mono 发布了 true
,因为有至少一种动物名称具有字母 t (具体来讲,就是 elephant )。而在第二个场景中,生成的 Mono 发布了 false
,因为用例中没有任何一种动物名称包含字母 z。
总结
- 反应式编程需要创建数据流过的处理管道。
- 反应式流规范定义了4种类型:Publisher、Subscriber、Subscription、Processor(即 Publisher 和 Subscriber 的组合)。
- Reactor 项目实现了反应式流规范,将反应式流的定义抽象为两个主要的类型即 Flux 和 Mono ,并为每种类型都提供数百个操作。
- Spring 借助 Reactor 项目提供了对反应式控制器、存储库、REST 客户端其他反应式框架的支持。