Reactor 反应式编程介绍,框架中Mono、Flux 的使用

2,877 阅读16分钟

「这是我参与2022首次更文挑战的第21天,活动详情查看:2022首次更文挑战」。

www.jianshu.com/p/7ee89f70d…

最近在开发的过程中,看到代码中有Mono 的写法,下意识以为是类似Optional 这种功能,具体查了一下才知道,这是reactor 框架中的知识。而Reactor 框架是以反应式编程为基础发明出来的。

这篇文章就以Reactive Programming (反应式编程)的概念为切入点,讲一下相关概念。

函数式编程 & 反应式编程

Reactor 框架其实是以反应式编程的一个框架,主打异步的功能。与之对应并且耳熟能详的概念就是函数式编程。

我们都知道,在java 8 中引入了一个新的概念“函数式编程”,函数式编程可以简单地理解为可以使用函数作为参数(输入),函数作为返回值(输出)

反应式编程就是可以理解为一种基于数据流变化传递的声明式的编程范式。这句话刚接触的时候可能不太好理解,开始的时候就脑海里有个印象,反应式编程的主角是“事件”。之后会使用具体的例子做解释。

反应式编程的特点

通过事件驱动

事件驱动的意思就是程序流转的过程中,起“桥梁”作用的是“事件”。存在事件,程序才会被“驱动”。

而作为“桥梁”的事件的两端就是生产者消费者。生产者和消费者是通过事件以松耦合的方式进行交互的。

这些事件是以异步非阻塞的方式发送和接收的。

实时响应

程序发起执行以后,应该快速返回存储结果的上下文(通常命名为:xxxContext),然后把具体的执行操作交给后台的线程处理。线程处理结束之后,异步将真实的返回值封装到此上下文中,整个过程不会阻塞程序的执行。

注意:实时响应是通过异步编程实现的。

弹性机制

事件驱动的松散耦合的特点就提供了一种功能,在组件(如生产者、消费者)失败的情况下,可以得到完全隔离的上下文,作为消息封装,并将其发送到下游组件中去。

发送过后,我们可以针对此上下文做一些具体的判断,按照我们的业务要求做对应的处理。比如说可以通过上下文判断消息是否接收到,接收到的命令是否有效、是否可执行等。

Reactor 简介

首先我们知道,Reactor 框架是基于Reactive Programming 思想实现的。

所以我们可以得出一个结论,Reactor 框架集合了反应式编程的所有特点。

这不是一句废话,而是要读者明白,使用这个框架进行编程的时候,不要带着平时编程的思维去看代码、编写代码,否则理解起来可能会绕弯路。

Reactor 中需要知道的几个概念

Reactive Programming

上一篇文章我们讲了Reative Programming 的一些基础概念,它的最主要的特点就是非阻塞事件驱动数据流的开发方案。

开发的过程中,会使用函数式编程来操作数据流。

由于是事件驱动,所以系统中的部分数据,在经过某个事件的变动之后,会自动更新其他部分,其成本极低。

对于上反应式编程的理解,这里用一个对比表格来做举例:

事件Observable(push)Iterable(pull)
获取数据onNext(T)T next()
出现异常onError(Exception)throws Exception
执行完成onCompleted()hasNext()

第一列是反应式编程的API 使用方式,它类似于观察者模式,其实也可以看做是观察者模式的一种延伸。

第二列就是迭代器模式的编程API 的使用方式,它的的一个特点就是“拉数据”,而观察者模式是“推数据”。

“拉数据”的意思就是生产者产生数据之后没有其他操作,而消费者会不断轮询,进行数据的消费。

推数据的意思和上面相反,其实就是生产者产生数据之后,将数据推到消费者。

所以二者一个是消费者主动,一个是生产者主动。

反应式编程可以记住,它处理数据的方式就是“推数据”。

关于“生产者、消费者”有很多同义词,如“发布者、订阅者”,“被订阅者,订阅者”等等,这里我们统一称呼为“发布者、订阅者”。

  1. 发布者主动推送数据给消费者,触发onNext()方法。
  2. 发布者发生异常,触发订阅者的onError()方法。
  3. 发布者每次推送都会触发一次onNext()方法,完成所有推送,没有异常情况发生,onComplete()方法会在最后触发一次。

如果发布者(Publisher)发布消息太快,超过了订阅者 (Subscriber)的处理速度,那就会由背压机制来解决。从而使得订阅者可以控制消费消息的速度。

Reactive Streams

Reactive Streams是由几个公司共同制定的一个项目(规范),用于制定反应式编程相关的规范以及接口。

Reactive Streams 由以下几个组件组成:

  • 发布者:发布元素,给到订阅者
  • 订阅者:消费来自发布者的消息
  • 订阅:发布者中创建订阅之后,将与订阅此订阅的订阅者共享
  • 处理器:用于发布者与订阅者之间处理数据

主要的接口:

  • Publisher(发布者)
  • Subscriber(订阅者)
  • Subscription(订阅) 其中Subscriber 接口中包含了上面表格中的三个接口onNext(),onError(),onCompleted()

对于Reactive Streams,只要理解这些基本概念和思路即可。包括背压机制

Reactor 的主要模块

主要有两个模块:reactor-core / reactor-ipc

reactor-core 主要负责Reactive Programming 相关的核心API 的实现。

后者负责高性能网络通信的实现,目前是基于Netty。

Reactor 的核心类

常用的主要有以下几个:

  • Mono Mono 这个词本身有”单声道“ 的意思。

Mono 实现了 org.reactivestreams.Publisher 接口,所以是有着“发布者”的功能。代表 0 到 1 个元素的 发布者

  • Flux Flux 有“不断变化、波动”的意思。

Flux 同样实现了 org.reactivestreams.Publisher,所以也是有着“发布者”的功能 接口。Flux 代表 0 到 N 个元素的发布者

  • Scheduler

代表背后驱动反应式流的调度器,通常由各种线程池实现。

WebFlux

Spring 5 引入的一个高性能框架。虽然看介绍是高性能,但是对于我们使用者来说却没有太大的改变。依旧可以按照使用Spring MVC 的思路来使用它。WebFlux 是基于Netty 而不是传统的Servlet 实现的。

WebFlux的概念对我们目前使用来说并不需要过多的在意。使用WebFlux 的MVC 接口示例如下:

@RequestMapping("/webflux")
@RestController
public class WebFluxExampleController {
    @GetMapping("/info")
    public Mono<Person> info() {
        return Mono.just(new Person());
    }
}

其中的变化就是日常我们开发中,接口返回值可能就是一个自定义的POJO,但是使用了WebFlux 之后,就是要将自定义返回类包在Mono中或者Flux中,也就是说从一个单独的POJO 对象变成了Mono<Person> 或者Flux<Person>

Reactive Streams/Reactor/WebFlux 三者的关系

对于Reactive Streams 的概念,之前在这篇文章中介绍过,现在对比一下标题中三个概念的关系。

  • Reactive Streams 是一套规范,为反应式编程做了标准化。
  • Reactor 是基于Reactive Streams 的一套反应式编程框架。
  • WebFlux 是以Reactor 为基础,是一个“反应式编程”的web 框架。

对于我们日常的开发,目前业界主流的还是基于Servlet的MVC 框架,所以业务开发中,如果使用到了反应式编程的思想,通常只会接触到Publisher接口,而对应到Reactor框架即使实现了这个接口的MonoFlux

对于“订阅者Subscriber”和“订阅Subscription”这两个接口,Reactor框架也有对应的实现,但是这些都是Spring WebFluxSpring Data Reactive这种框架用到的。加入不是开发中间件,对于这两个框架,我们目前还接触不到。所以我们主要把精力集中在MonoFlux上面就好。


对于Reactor 的使用,基本分为三个步骤:

  • 创建阶段(开始)
  • 处理阶段(中间)
  • 消费阶段(结束)

但是对于我们日常开发,创建和消费的流程使用的不太多,但是我们也要了解这些阶段是如何开发的。

创建Mono 和Flux(开始)

如果想使用Reactor,那么必然要先以创建出Mono或者Flux开始。

有时候可能不需要我们自己创建,而是实现某些框架或者接口得到一个Mono或者Flux。但是有的时候,需要我们手动创建一个Mono或者Flux

可以这么创建

Mono<String> study1 = Mono.just("Good Good Study");
Flux<String> study2 = Flux.just("Good", "Good", "Study");
FLux<String> study3 = Flux.fromIterable(words);

可能有人要问了,我为什么要把String 包到Mono 或者Flux 中呢?

其实这个是为了我们使用Reactor 进行高性能IO 操作而准备的。

使用这种方式,将之前经过一系列非IO 型操作得到的对象转换为Mono 或者Flux。

也可以这么创建: 上面其实是通过一个同步调用得到的结果创建出的Mono 或者Flux,但是有的时候需要从一个不是使用Reactive 框架的异步调用的结果创建出Mono 或者Flux。

比如某个异步方法返回一个CompletableFuture,那可以基于这个CompletableFuture创建一个Mono:

Mono.fromFuture(completableFuture);

但是假如这个 异步调用 不会返回  CompletableFuture呢?假如有自己的 回调方法,那怎么创建 Mono呢?

这个时候可以使用 static <T> Mono<T> create(Consumer<MonoSink<T>> callback) 方法:

Mono.create(sink -> {
    ListenableFuture<ResponseEntity<String>> entity = asyncRestTemplate.getForEntity(url, String.class);
    entity.addCallback(new ListenableFutureCallback<ResponseEntity<String>>() {
        @Override
        public void onSuccess(ResponseEntity<String> result) {
            sink.success(result.getBody());
        }

        @Override
        public void onFailure(Throwable ex) {
            sink.error(ex);
        }
    });
});

在使用 WebFlux 之后,AsyncRestTemplate 已经不推荐使用,这里只是做演示。

处理阶段(中间)

在中间的处理阶段,对于MonoFlux的方法主要有:

  1. filter
  2. map
  3. flatmap
  4. then
  5. zip
  6. reduce 当我们看到这些方法的时候,会发现有点眼熟,其实Stream 中的方法类似。

接下来举几个例子,来解释这些方法的使用场景。

map、flatMap、then 的使用场景

MonoFlux中间环节的处理过程中,有三个比较类似的方法,分别是map(),flatMap()then()。这三个方法在日常的开发过程中,使用频率很高。

这里我们对比传统编程方式(命令式)和反应式编程方式。

传统的编程方式如下,每一步执行的结果,作为下一步执行的参数

Object firstResult = doStep1(params);
Object secondResult = doStep2(firstResult);
Object finalResult = doStep3(secondResult);

上述代码对应的反应式编程可以写为

Mono.just(params)
    .flatMap(v -> doStep1(v))
    .flatMap(v -> doStep2(v))
    .flatMap(v -> doStep3(v));

then()

then()的函数签名是:

then(Mono other);

then() 看上去是下一步的意思,但它只表示执行顺序的下一步,不表示下一步依赖于上一步。其实这个也比较好理解,只要记住它是“然后”的意思,并没有依赖的关系。

从上面的函数签名可以看出来,then() 方法的参数只是一个 Mono,无从接受上一步的执行结果。

flatMap() 和 map()

这两个函数的签名分别为:

flatMap(Function<? super T, ? extends Mono<? extends R>> transformer)
map(Function<? super T, ? extends R> mapper)

flatMap() 和 map() 两个函数的区别在于, flatMap() 中的入参 Function 的返回值要求是一个 Mono 对象,而 map 的入参 Function 只要求返回一个 普通对象

而在业务处理中,如果常需要调用 WebClient 或 ReactiveXxxRepository 中的方法,这些方法的 返回值 都是 Mono(或 Flux)。所以要将这些调用串联为一个整体 链式调用,就必须使用 flatMap(),而不是 map()

对比于then(),我们会发现,对于 flatMap() 和 map() 的参数,其都是一个 Function,入参是上一步的执行结果。

处理阶段的高级实用方法

上篇文章已经介绍了Mono 和Flux 的基本用法,以及几个常用函数的作用,本篇将继续寻找应用场景,根据不同的应用场景进行框架的讲解。

怎么实用Reactor 进行并发执行

本段内容将涉及到如下类和方法:

  • 方法Mono.zip()
  • Tuple2
  • BiFunction

对于并发执行我们并不陌生,开发中常常会遇到并发执行的场景。反应式编程虽然是一种 异步编程 方式,但是异步并不代表就是并发的。

在我们未接触过反应式编程的时候,我们实现“并发执行”往往是采取线程池的手段来完成的。配合Future,来实现功能。

Future<Result1> result1 = threadPoolExecutor.submit(() -> doStep1(params));
Future<Result2> result2 = threadPoolExecutor.submit(() -> doStep2(params));
// 查询结果
Result1 result1 = result1.get();
Result2 result2 = result2.get();
// 合并结果;
return mergeResult;

以上代码实现了 异步调用,但是最后获取结果的代码: Future.get() 方法却是阻塞的。在使用 Reactor 开发有并发执行场景的反应式编程的代码时,不能用上面的方法。

对于这个场景,我们应该使用 Mono 和 Flux 中的 zip() 方法。

使用 Mono 做说明,代码如下:

Mono<Result1> mono1 = someFunc1();
Mono<Result1> mono2 = someFunc2();
Mono.zip(items -> {
    Result1 r1 = Result1.class.cast(items[0]);
    Result1 r2 = Result1.class.cast(items[1]);
    // 合并结果
    return mergeResult;
}, item1Mono, item2Mono);

我们假设,上述举例的代码中,产生 mono1 和 mono2 的过程是并行执行的。比如,调用一个请求数据接口的同时去执行一个数据库查询操作。这种互不影响的操作并发执行就可以加快操作效率。

虽然上述实现看起来可能比容易一些,看起来也不错,但上述实现过程存在一个最大的问题就是对于 zip() 方法需要做 强制类型转换。但是我们都知道,强制类型转换是不安全的。

但是好在 zip() 方法存在一种机制: 多种重载 。除了最基本的形式以外,还有多种 类型安全 的形式。使用以下代码举例:

static <T1, T2> Mono<Tuple2<T1, T2>> zip(Mono<? extends T1> p1, Mono<? extends T2> p2);
static <T1, T2, O> Mono<O> zip(Mono<? extends T1> p1, Mono<? extends T2> p2, BiFunction<? super T1, ? super T2, ? extends O> combinator); 
static <T1, T2, T3> Mono<Tuple3<T1, T2, T3>> zip(Mono<? extends T1> p1, Mono<? extends T2> p2, Mono<? extends T3> p3);

对于不超过 7 个元素的合并操作,框架都会有 类型安全 的 zip() 方法可以选择使用。

下面就以最简单的两个元素的合并为例,实际操作一下,更便于理解,同时更容易记忆,以如下代码进行讲解:

Mono.zip(mono1, mono2).map(tuple -> {
    Result1 r1 = tuple.getT1();
    Result2 r2 = tuple.getT2();
    // 合并
    return mergeResult;
});

在上面的代码中,map() 方法使用的参数是一个 Tuple2,表示一个 二元数组,相应的还有 Tuple3Tuple4 等,分别表3 元数组,4元数组。

同时,对于两个元素的并发执行,我们也可以通过 zip(Mono<? extends T1> p1, Mono<? extends T2> p2, BiFunction<? super T1, ? super T2, ? extends O> combinator) 这个方法,将结果直接合并。这个方法是传递 BiFunction 实现合并算法的。

处理阶段的高级使用方法

本段主要涉及的内容为:

  • Flux.fromIterable()
  • Flux.reduce()
  • BiFunction

在我们日常开发中,常常会遇到一个这样的场景:得到一个集合类对象(如List/Set/Map),然后对其中的元素进行处理。

我们的解决方案有很多,常见的迭代写法如下:

List<Object> list = xxx.getList();
for (int i = 0; i < list.size(); i++) {
    Object o = list.get(i);
    // 可以做相关的业务逻辑处理
}

或者使用函数式编程:

List<Object> list = xxx.getList();
list.forEach(ele -> {
    // 做相关业务逻辑处理
});

但是到了反应式编程中,就不是这样简单了。我们可以使用Fluxreduce()方法,其函数签名如下:

<A> Mono<A> reduce(A initial, BiFunction<A, ? super T, A> accumulator);

从这个函数签名我们可以得到一个信息,reduce()方法就是把一个Flux聚合成一个Mono,然后返回。

这里对这个函数签名中的参数进行介绍:

  • 第一个参数:是返回值Mono中元素的初始值。
  • 第二个参数:是BiFunction类,其功能是用来实现聚合操作。

我们可以看到对于第二个参数BiFunction类,它后面有个泛型参数<A, ? super T, A>,这个泛型参数中的参数的意思为:

  • 第一个A表示每次聚合之后的结果的类型,它是BiFunction.apply()方法的第一个入参。
  • 第二个? super T 表示集合中的每个元素的类型,它作为BiFunction.apply()方法的第二个入参。
  • 第三个A表示聚合操作之后的结果,它作为BiFunction.apply()方法的返回值类型。

上述场景,我们现在改用反应式编程来实现:

Data initData = getInitData();
List<SubData> list = getSubDataList();
Flux.fromIterable(list)
    .reduce(initData, (data, itemInList) -> {
        // 对于data 和list 中的item 做相关的操作
        return data;
    });

我们看到上述反应式编程的代码应该都比较难以理解,但是这个会随着我们的使用会变得更加熟练。

代码中的initDatadata的类型相同,执行完了上述代码之后,reduce()方法就会返回Mono<Data>

有的同学可能看到BiFunction不知道是什么,这个可以去学习一下Java 8 引入的函数式编程的用法,这里不再叙述。

消费阶段(消费Mono 和Flux)

其实对于Reactor 的前两步:创建阶段和处理阶段,我们明显可以根据名称推断出来,其实它们就是创建Mono(或者Flux),然后中间的逻辑处理,最后肯定就是要把处理好的Mono(或者Flux)进行消费,结束整个流程。

所以顺势就可以引出最后一个阶段:结束的消费阶段。

对于Reactor 框架中的Mono 和Flux 的消费方法其实很简单,就是调用subscribe()方法。

如果在WebFlux接口中使用了Recator 的这个框架,那么在接口返回的时候,直接返回Mono 或者Flux 即可:

@RequestMapping("/test")
@RestController
public class WebFluxTestController {
    @GetMapping("/getMono")
    public Mono<Response> helloworld() {
        return Mono.just(new Response());
    }
}

对于WebFlux框架,它会完成最后的响应输出的工作,就不用程序员关系了。

总结

这一系列文章其实主要从反应式编程的编程风格入手,我们要记住一个最根本的点就是,反应式编程是以“事件为驱动”,常常以某个命名的context为参数,走完整个调用链路,请求和响应都放在这个context 里面,处理这个context 对象,完成业务逻辑。

之后又介绍了反应式编程的一个框架:Reactor。这个框架以反应式编程为基础,我们日常中使用的最多的就是MonoFlux

学会使用它们两个,同时了解一些常用的方法,在我们的日常开发中就可以得心应手了,不仅可以看得懂别人的代码,也可以理解清楚他们的编程风格,更重要的是自己也可以写出反应式编程的程序。