反应式编程

反应式编程

  • 前言
  • 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)是 NetflixLightbendPivotal( Spring 背后的公司)的工程师于2013 年底开始制定的一种规范。反应式流旨在提供无阻塞回压的异步流处理标准。我们已经接触到反应式编程的异步特性,它使我们能够并行执行任务从而实现更高的可伸缩性。通过回压,数据消费者可以限制它们想要处理的数据量,避免被过快的数据源产生的数据淹没。

Java的流和反应式流

Java的流和反应式流之间有着很许多相似之处。它们的名字中都有流(stream)这个词它们也都提供了用于处理数据的函数式API。事实上,正如我们会在Reactor 项目中看到的那样,它们甚至可以共享许多操作。
然而,Java的流通常都是同步的,并且只能处理有限的数据集。本质上来说,它们只是使用函数来对集合进行迭代的一种方式。
反应式流支持异步处理任意大小的数据集,包括无限的数据集。只要数据就绪,它们就能 实时地处理数据,并且通过回压来避免压垮数据消费者。
JDK 9中的 Flow API 对应反应式流,其中的Flow.PublisherFlow.SubscriberFlowSubscriptonFlow.Processor 类型分别直接映射到反应式流中的PublisherSubscriberSubscriptionProcessor
也就是说,JDK9Flow API 并不是反应式流的实际实现。

反应式流规范可以总结为 4 个接口,即 PublisherSubscribeSubscriptionProcessorPublisher 负责生成数据,并将数据发送给 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。通过 SubscriptionSubscriber 可以管理其订阅情况:

    public interface Subscription {void request(long n);void cancel();}

Subscriber 可以通过调用 request()方法来请求 Publisher 发送数据,也可以通过调用cancel()方法来表明它不再对数据感兴趣并且取消订阅。当调用 request()时,Subscriber 可以传人一个 long 类型的值以表明它愿意接受多少数据。这也是回压能够发挥作用的地方——避免Publisher 发送超过 Subscriber 处理能力的数据量。在 Publisher 发送完所请求数量的数据项之后,Subscriber 可以再次调用 request()方法来请求更多的数据。

Subscriber 请求数据之后,数据就会开始流经反应式流。Publisher 发布的每个数据项都会通过调用 SubscriberonNext()方法递交给 Subscriber。如果有任何的错误,则会调用 onError()方法。如果 Publisher 目前没有更多的数据,而且也不会继续产生更多的数据那么它将会调用SubscriberonComplete()方法来告知 Subscriber 它已经结束。
至于 Processor 接口,它是 SubscriberPublisher 的组合:

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()方法。现在,重要的是要理解,虽然这个反应式的例子看起来依然保持着按步骤执行的模型,但实际上数据会流经处理管线。在处理管线的每一步,数据都进行了某种形式的加工,但是我们不能判断数据会在哪个线程上执行这些操作。它们可能在同一个线程,也可能在不同的线程。

这个例子中的 MonoReactor 的两种核心类型之一,另一个类型是 Flux。两者都实现了反应式流的 Publisher 接口。Flux 代表具有零个、一个或者多个(可能是无限个)数据项的管道,而 Mono。是一种特的反应式类型,针对数据项不超过一个的场景进行了忧化。

ReactorRxJava( ReactiveX)的对比 如果你熟悉 RxJava 或者 RectiveX,可能认为
MonoFlux 类似于 Observable
Single。事实上它们不仅在语义上大致相同,还共享了很多相同的操作符。 最然我们主要介绍 Reactor,但是
ReactorRxJava 的类型可以互相转换,相信你会对这一点感到开心。接下来我们还会看到,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 已经位于项目的构建文件中,我们可以开始使用 MonoFlux 来创建反应式的处理管线了。在本文的剩余部分,我们将介绍 Mono 和 Flux 提供的一些操作。

3.使用常见的反应式操作

FluxMonoReactor 提供的最基础的构建块,这两种反应式类型所提供的操作就像黏合剂,使我们能够据此创建数据流的管道。Flux 和 Mono 共有超过 500个操作这些操作大致可以归类为:

  • 创建操作;
  • 组合操作;
  • 转换操作;
  • 逻辑操作。

虽然逐一介绍这 500 多个操作会非常有趣,但是本章的篇幅有限,所以我在本节中选择了一些相对实用的操作来进行说明。让我们从创建操作开始吧。

注意: Mono的例子呢? Mono和Flux 的很多操作都是相同的,我们没有必要分别针对 Mono 和Flux 进行介绍。此外,虽然
Mono 的操作也很有用,但是相比而言,Flux 上的操作更有趣。我们的大多数示例都会使用 Flux,你只需要知道,Mono
通常都具有相同的名称的操作。

3.1 创建反应式类型

在Spring中使用反应式类型时我们通常可以从存储库或服务中获得FluxMono并不需要自行创建。但偶尔,我们可能需要创建一个新的反应式Publisher
Reactor 提供了多种创建 Flux 和Mono 的操作。本节将介绍一些创建操作。

根据对象创建

如果我们有一个或多个对象,并想据此创建 FluxMono,那么可以使用 Flux 或Mono上的静态 just()方法来创建一个反应式类型,它们的数据会由这些对象来驱动。例如,下面的测试方法基于5个 String 对象创建了 Flux:

    @Testpublic 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 直接流向订阅者。

将来自 FluxMono 的数据项打印到控制台是观察反应式类型运行方式的好办法但实际测试 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(),并为其传入一个源数组:

    @Testpublic 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.Listjava.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()方法:

    @Testpublic 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:

    @Testpublic 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()方法如下所示:

    @Testpublic 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 拆分为多种反应式类型。在本小节,我们将研究组合及拆分 ReactorFluxMono 的操作。

合并反应式类型

假设我们有两个 Flux 流,并且需要据此创建一个能在任意一个上游 Flux 流可用时产生相同数据的 Flux。要将一个 Flux 与另一个 Flux 合并,可以使用 mergeWith()方法,如图所示。

在这里插入图片描述

例如,假设有一个以影视作品中的角色名为值的 Flux,还有一个以这些角色喜欢的食物为值的 Flux。如下所示的测试方法展示了如何使用 mergeWith()方法合并两个 Flux 对象:

    @Testpublic 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合并在了一起:

    @Testpublic 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:

    @Testpublic 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 的值:

    @Testpublic 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()操作(如图所示)就能完成这样的工作。

在这里插入图片描述

针对具有多个数据项的 Fluxskip()操作将创建一个新的 Flux,首先跳过指定数量的前几个数据项,然后从源 Flux 中发布剩余的数据项。下面的测试方法展示了如何使用 skip()方法:

    @Testpublic 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()操作 ),所以它只会发布出最后两个数据项:

    @Testpublic 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() 操作只发布指定数量的前几个数据项(如图所示):

    @Testpublic 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 秒内发布数据条目。

    @Testpublic 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()的实际效果,可以参考下面的测试方法:

    @Testpublic 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 值:

    @Testpublic 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 的反应式类型(FluxMono)为此提供了map()flatMap() 操作。

map() 操作会创建一个新的 Flux,该 Flux 在重新发布它所接收到的每个对象之前会对其执行由给定 Function 预先定义的转换。图说明了 map() 操作的工作原理。

在这里插入图片描述

在下面的测试方法中,包含篮球运动员名字的 String 值的 Flux 被转换为一个包含 Player 对象的新 Flux。

    @Testpublic 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();}@Dataprivate 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() 操作那样简单地将一个对象转换到另一个对象,而是将对象转换为新的 MonoFlux。结果形成的 Mono 或 Flux 会扁平化为新的 Flux。当与 subscribeOn() 方法结合使用时, flatMap() 操作可以释放 Reactor 反应式的异步能力。

在这里插入图片描述

下面的测试方法展示了如何使用 flatMap() 方法和 subscribeOn() 方法:

    @Testpublic 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 包含不超过指定数量的元素:

    @Testpublic 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 集合都可以被并行处理:

    @Testpublic 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 集合。下面的测试方法展示了它的用法:

    @Testpublic 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()操作将会产生一个发布 MapMono。Map 中会填充一些数据项,数据项的键会由给定的 Function 计算得出。

在这里插入图片描述
要查看collectMap() 的效果,请参考下面的测试方法:

    @Testpublic 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()方法来检查这个条件:

    @Testpublic 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 :

    @Testpublic 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 客户端其他反应式框架的支持。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/465948.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

BYTEVALUE 百为流控路由器远程命令执行漏洞

免责声明&#xff1a;文章来源互联网收集整理&#xff0c;请勿利用文章内的相关技术从事非法测试&#xff0c;由于传播、利用此文所提供的信息或者工具而造成的任何直接或者间接的后果及损失&#xff0c;均由使用者本人负责&#xff0c;所产生的一切不良后果与文章作者无关。该…

JavaWeb:SpringBootWeb登录认证 --黑马笔记

1.登录认证 登录认证&#xff0c;如果只是简单的判断用户名和密码在数据库中是否对应相等来实现这个需求是有问题的&#xff0c;它只是徒有其表&#xff0c;我们不登陆也可以访问后端系统页面&#xff0c;真正的登录功能应该是&#xff1a;登陆后才能访问后端系统页面&#xf…

Python - 面向对象编程 - 类变量、实例变量/类属性、实例属性

什么是对象和类 什么是 Python 类、类对象、实例对象 类变量、实例变量/类属性、实例属性 前言 只是叫法不一样 实例属性 实例变量 类属性 类变量 个人认为叫属性更恰当 类属性和实例属性区别 类属性&#xff0c;所有实例对象共享该属性实例属性&#xff0c;属于某一…

数据库恢复

文章目录 前言一、事务1.概念2.定义语句3.ACID特性 二、数据库恢复的必要性1.为什么要进行数据库恢复2.数据库恢复机制的作用 三、数据恢复使用的技术1.数据转储2.登记日志文件 四 、不同故障的数据恢复策略1.事务内部的故障2.系统故障3.介质故障 五、具有检查点的恢复技术1.检…

跟着pink老师前端入门教程-day24

四、移动端WEB开发之响应式布局 1、响应式开发 1.1 响应式开发原理 就是使用媒体查询针对不同宽度的设备进行布局和样式的设置&#xff0c;从而适配不同设备的目的。 1.2 响应式布局容器 响应式需要一个父级做为布局容器&#xff0c;来配合子级元素来实现变化效果。 原理…

springboot181基于springboot的乐享田园系统

简介 【毕设源码推荐 javaweb 项目】基于springbootvue 的 适用于计算机类毕业设计&#xff0c;课程设计参考与学习用途。仅供学习参考&#xff0c; 不得用于商业或者非法用途&#xff0c;否则&#xff0c;一切后果请用户自负。 看运行截图看 第五章 第四章 获取资料方式 **项…

CSS基础---新手入门级详解

CSS:层叠样式表 CSS&#xff08;Cascading Style Sheets,层叠样式表&#xff09;&#xff0c;是一种用来为结构化文档添加样式&#xff08;字体、间距和颜色&#xff09;的计算机语言&#xff0c;css扩展名为.css。 实例: <!DOCTYPE html><html> <head><…

通过容器化释放云的力量

NCSC (英国国家网络安全中心) 经常被问到的一个问题是是否在云中使用容器。这是一个简单的问题&#xff0c;但答案非常微妙&#xff0c;因为容器化的使用方式有很多种&#xff0c;其中一些方法比其他方法效果更好。 今天&#xff0c;我们发布了有关使用容器化的安全指南&#…

C++ //练习 5.19 编写一段程序,使用do while循环重复地执行下述任务:首先提示用户输入两个string对象,然后挑出较短的那个并输出它。

C Primer&#xff08;第5版&#xff09; 练习 5.19 练习 5.19 编写一段程序&#xff0c;使用do while循环重复地执行下述任务&#xff1a;首先提示用户输入两个string对象&#xff0c;然后挑出较短的那个并输出它。 环境&#xff1a;Linux Ubuntu&#xff08;云服务器&#x…

没更新的日子也在努力呀,布局2024!

文章目录 ⭐ 没更新的日子也在努力呀⭐ 近期的一个状态 - 已圆满⭐ 又到了2024的许愿时间了⭐ 开发者要如何去 "创富" ⭐ 没更新的日子也在努力呀 感觉很久没有更新视频了&#xff0c;好吧&#xff0c;其实真的很久没有更新短视频了。最近的一两个月真的太忙了&#…

SpringBoot3整合Knife4j

前置&#xff1a; 官网&#xff1a;快速开始 | Knife4j gitee&#xff1a;swagger-bootstrap-ui-demo: knife4j 以及swagger-bootstrap-ui 集成框架示例项目 - Gitee.com 1.依赖引入&#xff1a; ps&#xff1a;json处理需要引入相关包 <dependency><groupId>c…

【PTA|期末复习|编程题】数组相关编程题(一)

目录 7-1 乘法口诀数列 (20分) 输入格式&#xff1a; 输出格式&#xff1a; 输入样例&#xff1a; 输出样例&#xff1a; 样例解释&#xff1a; 代码 7-2 矩阵列平移(20分) 输入格式&#xff1a; 输出格式&#xff1a; 输入样例&#xff1a; 输出样例&#xff1a; …