1 什么是响应式编程?
回答这个问题之前,可以先看看这一系列问题:
- 什么是命令式编程?
- 什么是声明式编程?
- 什么是函数式编程?
- ······
- 什么是响应式编程?
上面列举了几种常见的「编程范式」,「范式」是面对/解决一类问题时的思考模式、标准模型,编程范式自然就是编程时的思考模式与标准模型。这么说还是太抽象,举个常见的例子来帮助大家理解——上楼,如何看待上楼这件事?又怎么实现上楼这个行为?可以尝试站在上述范式的角度来思考:
- 命令式上楼:本质上就是面向过程,旨在描述整个流程中每一步需要达成的目的、以及具体的控制逻辑。也是最常见的编程范式,因为机器指令本身就是命令式的,编程语言本质上也是在调用机器指令。
上楼的过程是在一阶一阶上台阶,循环往复上台阶这个过程,就达成了上楼的目的。那么就可以描述为如下的伪代码:
//台阶初始化状态为第0阶
Ladder ladder = 0;
for (int i = 0; i < 10; i++) {
//阶级每次+1
ladder = ladder + 1;
}
- 声明式上楼:更关注问题的本质以及如何解决问题,是一种屏蔽、且无需关心底层实现的范式。例如SQL语句,描述“我要对哪张表的哪些数据做如何的操作”,并不关心底层如何实现这样的操作。
上楼可以被描述为向上跳台阶N次,而不在意如何向上跳。因此有了如下伪代码:
//当台阶小于10阶时,往上爬台阶
step up Ladder where Ladder < 10
- 函数式上楼:认为程序是在将无副作用、无状态的函数进行组合,不允许对变量进行赋值,而是数学意义上真正的等式。函数式编程通常配合链式调用使用,通过调用函数来达成目的,例如Java中极为常见的Stream流 + 函数式接口。
函数式编程是一门博大精深的学问,例如haskell/scala、curry化、lambda推演、尾递归,笔者绞尽脑汁学习了许久,还是觉得不得精髓,就不在这里班门弄斧了。
//初始化台阶状态
Ladder ladder = 0;
int cur = 0;
int max = 10;
//调用函数,函数内部进行递归调用(函数式编程不存在for循环)
ladder = stepUpFunc(ladder, cur, max);
public static int stepUpFunc(int ladder, int cur, int max) {
if (ladder < max) {
return stepUpFunc(ladder + 1, ++cur, max);
} else {
return ladder;
}
}
再回到一开始提出的问题,响应式编程(Reactive programming)是一种事件驱动的、面向数据流的、异步化的编程范式,并且提供了大量声明式/函数式操作符来帮助开发者简化响应式编排。区别于上文提到的几种编程范式,响应式编程杂糅了多种范式,还提供了大量的操作符和基础能力,所以将其描述为最佳实践或许更合适。
相信同学们看完这串解释,应该还是云里雾里的,我们继续往下看。
2 为何选择响应式编程?
首先来解构响应式编程的几个核心概念。
事件驱动(也叫观察者模式)不必多说,是一种被广泛应用的同/异步松散耦合模型。
面向数据流可以认为是以数据的变更、迭代为主视角,每一步都在描述如何操作数据流。说起来还是有点抽象,可以想象一下Stream流的运行模式;没错,两者的出发点都是面向数据流,且都采用了函数式编程的结构,因此有着极为相似的结构和API(操作符)和代码风格(链式调用)。
List<String> list = Lists.newArrayList("aaa", "bbbb", "ccccc", "dddddd");
List<String> collect = list.stream()
.map(one -> one.concat("+map"))
.filter(one -> one.length() <= 9)
.collect(Collectors.toList());
Flux.fromIterable(list)
.map(one -> one.concat("+map"))
.filter(one -> one.length() <= 9)
.subscribe();
异步化,也是响应式最核心的能力,他使得异步编程变得十分简单灵活。例如大数据处理pipeline通常集结了纯异步、高吞吐、数据驱动等特性,因此在其中经常会看到这样的操作:
- Server先同步阻塞响应Client的请求、任务成功后回应Client、最终再异步处理任务。例如下单操作,先同步创建临时订单,创建成功响应用户后再进行后续的异步操作。
- 不同类型的处理作业需要使用不同的调度策略和线程池(操作复杂度、IO/CPU密集差异所带来的耗时不同),需要在处理作业中灵活切换。
- 仅依靠线程池无法轻松构建一个能最大化吞吐量、并行处理任务的纯异步系统。
而响应式编程的声明式调度器 + 串行流/并行流完美契合了上述需求,这使得你只需要短短几行代码,就能初步地实现一个纯异步的系统。再加上其天生支持背压(Back Pressure)的高级特性,使得构建一个高并发、高吞吐、纯异步系统的门槛大幅降低。
Flux.range(1, 10)
.doOnNext(i -> System.out.println("Current value: " + i + ", Thread: " + Thread.currentThread().getName()))
//指定后续操作的订阅调度器为「单线程调度器」
.publishOn(Schedulers.single())
.map(i -> i + 1)
.doOnNext(i -> System.out.println("After map value: " + i + ", Thread: " + Thread.currentThread().getName()))
//指定流的订阅调度器为「并发调度器」
.subscribeOn(Schedulers.parallel())
.subscribe();
尽管有如此多的好处,但这并不意味着响应式编程是一颗银弹,你需要谨慎评估你的系统是否存在着需要响应式来解决的问题,以及是否能接受使用他带来的成本和弊端。
例如异步导致的堆栈上下文丢失,高并发场景下任务堆积的内存占用,以及并不是所有人都愿意以响应式的风格来编写代码、也不是所有人都能写出真正意义上的函数。再加上高度封装和便利性这把双刃剑,在不够了解响应式编程的前提下使用他,这意味着所有事物都变成了充满未知的黑盒子,你完全不知道自己使用的方式是否正确、可能会出现怎样的问题、以及其是否会带来不可估量的后果。
因此,如果你真的需要、并且愿意花点心思学习他,响应式编程绝对是一门非常优秀且极为现代的编程范式,即使用不上也不妨碍学习其中的思想。
3 如何使用响应式编程?
我们花了相当一部分篇幅来简要介绍响应式编程到底是什么、能做什么、以及可能会出现的风险,接下来将会深入探讨其中的部分内容,并给出核心功能的示例。
本文并不会事无巨细地讲解响应式的每个操作符,首先是因为确实太多了,需要用到时再去查阅即可;其次本文更着重于介绍核心思想,希望能真正引发你对响应式的兴趣,走进这门优秀的编程范式。
本文将以Java Reactor为例,介绍响应式编程的一些重要组成和核心用法,以及笔者在使用过程中遇到的一些问题。废话就到这里,进入正题!
3.1 发布者 Publisher:响应式流的创建
响应式编程是面向数据流的,因此创建数据流是首要任务,对应观察者模式中的「发布者」。
Reactor提供了两种发布者:Flux和Mono,两者的区别不大,Flux代表含有0 ~ N个元素的响应式流,Mono则代表含有0 ~ 1元素。
有同学可能会问,有Flux还要Mono干啥?这不多余弄一个吗?其实是很简单的语义差别,Mono代表Object对象,只有有或者无两种场景;Flux代表List<Object>列表,有空列表或不定长列表两种场景。处理对象是一次性的操作,处理列表是迭代的操作,两者缺一不可。
3.1.1 just/from***
响应式流的创建方式有许多,例如常见的just方法,直接传入元素;以及各式各样的from方法,fromIterable/fromStream/fromArray从迭代器/流/数组获取元素并创建流。
但无一例外他们都有个特点:随用随创建。怎么理解这个“随用随创建”,类似于你调一次方法、来一批数据、建一次响应式流并处理一波。而显然对于同一个响应式流,数据的处理逻辑是永远不会变的,因此最好的做法应该是:复用同一个响应式流,用不断往他里面塞数据的方式、代替每次都重新创建。而且在我看来这也是一种很科学的方式,不会有反复创建流的开销,元素消费的语义也更加明确。
3.1.2 create
Reactor自然也提供了这样的方法,这种“非一次性”的方式在Reactor中被称为sink,类似于Flink中的sink,将数据汇聚到流中。操作并复用这个sink对象,就可以实现一次创建、终生使用。
官方文档给出了一个最佳实践,在最小程度暴露sink能力的同时实现动态提交元素。“最小程度暴露sink能力”的原因是,sink不止能提交元素,还能取消流、使流异常,这暴露出去还得了?因此我们可以采用官方给出的折中方案,用事件监听器的回调来操作sink。
//事件监听器
interface MyEventListener<T> {
void onDataChunk(List<T> chunk);
void processComplete();
}
//利用事件监听器的回调来操作sink
Flux<String> bridge = Flux.create(sink -> {
//调用processor的提交、processor调用listener的onDataChunk
myEventProcessor.register(
new MyEventListener<String>() {
public void onDataChunk(List<String> chunk) {
for(String s : chunk) {
sink.next(s);
}
}
public void processComplete() {
sink.complete();
}
});
});
要注意的是,调用sink的complete后该响应式流就会被销毁,并是传统意义上的来一批、完成一批、再来下一批的用法哦。
3.2 订阅者 Subscriber:响应式流的订阅
有了发布者自然就要有订阅者,Reactor提供了毛毛多的方法来处理元素,我们捡几个常用且好用的来讲讲。
3.2.1 map/flatMap
Reactor提供了大部分函数式api,例如熟悉的filter/limitRate/map/distinct/sort等。单独提一下map和flatMap是因为在接触Reactor的初期,我有过这样一个疑惑:“map/flatMap是在发布流还是消费流?”这个问题的答案会影响很多东西,如果是发布流,那么是否意味着订阅时期的各种操作符(如异步、异常处理等)对其不生效?如果是消费流,他们又与传统的数据订阅不太一样,会改变数据本身,不符合直觉。
通过不断实验和探索,最终的答案是消费流,只是一种能将数据改变并传递给下游的消费方式;传统的响应式流是不需要返回值的,只需要订阅并消费即可,map/flatMap更多作为消费的中间环节使用。用法自然是不必多说,map就是单纯的从元素A转换到元素B,而flatMap就有一些更高级的用法了。
map一定是串行的,即一个元素向下消费完才会去消费另一个。注意,这里说的是串行,这与异步并不冲突。Reactor默认使用的是串行流,如果不将流转换为并行流(ParallelFlux),即使开1000个线程,同时也只会有一个线程工作。
而flatMap就不一样了,他能将一个元素转换为一个流;这个流里可以有无数个元素,可以有自己的订阅逻辑、自己的同异步逻辑和调度器。flatMap也是赋予响应式流并行能力的重要方式之一,我们卖个关子后面再介绍~
3.2.2 doOn***
一系列doOn***操作符,如doOnNext/doOnComplete/doOnSubscribe,使其可以在响应式流处理的各个阶段做不同的操作。几个最常用的如:
- doOnNext,每个元素被消费时都会被触发,通常作为元素消费的操作使用;
- doOnSubscribe,响应式流被订阅时会触发一次,一般作为订阅初始化操作使用;
- doOnComplete,响应式流正常消费完毕时会触发一次,一般作为订阅结束操作使用;
- doOnTerminal,响应式流结束时会触发一次,一般作为无论如何、必须要在响应式流订阅完后被触发的操作使用;
还有一个非常重要、且非常容易误用的doOnError,看其字面意思是:元素消费出现异常时被调用,且他的入参是一个throwable对象,就好像帮我们catch住了异常等待处理一般。但稍微试验一下就会发现:doOnError被调用的同时,整个响应式流也被中断了;这意味着他并没有帮你catch住异常,异常被传递到了下游并影响了整个响应式流——即流被中断了。
这说明doOnError无法作为广义的“异常处理”使用,他只是出现异常被中断时的一个通知。那么具体要如何进行异常处理,这又是一门学问,我们一会儿整体介绍。
3.2.3 subscribe
响应式流有一个特点:在未被订阅前什么都不会发生。意为在发起对响应式的正式订阅之前,所有的操作都是只是对流操作的声明和编排,不会有任何实质性的操作。
调用subscribe方法就是在对流发起订阅,这个方法有许多种重载,能看到几个比较眼熟的参数名称:onNext/onError/onComplete,不难发现其本质上也是在定义元素的处理逻辑,实现Consumer接口、接收流中的元素、最终进行处理。
或者传入Subscriber/CoreSubscriber,也能够实现元素的消费逻辑。
subscribe方法内传入Subscriber与使用前两节介绍的操作符基本是等价的,只是Subscriber有着更精细的控制能力。这得益于Subscriber内部的Subscription对象,可以将其视为这次订阅的上下文,关联了这个流的生产者和消费者。他提供了两个接口,对应了两种能力:
- request(Long value):从流中请求value个元素进行消费。
- cancel():取消流的订阅。
这两种能力使我们能够自主控制从流中拉取元素的速率,每次消费完毕可以自行决定再拉取多少元素(或是不拉取也可以)。这与前面提到过的“背压”概念一致,由下游消费者的消费情况决定上游生产者元素生产速率,后面会单独介绍。
3.2.4 小Demo
说到这里最基本的使用算是介绍完了,我们已经能够利用Reactor做一点简单的应用。
例如现在有100个人来找工作,分别对应序号1 ~ 100,序号就是他们的ID,我们要做这几件事:
- 去数据库里根据序号查到每个人的年龄
- 剔除掉35岁以上的人
- 为每个人准备好对应的工号(前缀 + 序号)和工牌
- 将这些人的信息录入OA系统
- 为每个人发送一封入职通知
- 全部完成后,通知HR部门可以招下一批人了
这个需求并不复杂,甚至可以说是非常简单,用传统的实现方式相信大家脑子里都已经写好了。但对掌握了响应式编程的我们来说,命令式的实现方式显然不够优雅、不够清晰,面条式的代码通常会使得整个逻辑看起来没有那么连贯。
下面用Reactor实现一把看看,为了图方便所有和IO有关的操作都采用了伪代码。
String name = "Worker-";
Random ageQuerier = new Random();
List<Integer> employees = IntStream.range(1, 101).boxed().collect(Collectors.toList());
Flux.fromIterable(employees)
.map(one -> {
int i = ageQuerier.nextInt();
int age = i % 50;
return Tuples.of(age, one);
})
.filter(one -> 35 < one.getT1())
.map(one -> name + one.getT2())
.doOnNext(one -> System.out.println("Save employee success: " + one))
.doOnNext(one -> System.out.println("Send msg success: " + one))
.doOnComplete(() -> System.out.println("Notify HR success"))
.subscribe();
通过fromIterable + 列表创建流、给每人生成随机年龄并组装返回、过滤大于35岁的人、拼接工号、将这些工号信息保存下来、为每人发送一条信息、最终通知HR。
整个处理过程非常清晰且合理,每一行、每个操作符都代表一种操作,而且这些操作的对象都是元素本身。即使你不用响应式编程其他的高级特性,单是这种流式与函数式结合的代码风格也是非常值得学习的。
3.2.5 异常处理
试想这样一个问题:元素处理过程中出现异常,整个流会发生什么?
如果Java程序中发生异常、异常又没有被catch,应用程序就会立刻停止(或者说正在运行的逻辑会立刻被中断)。流中出现异常也类似,并且整个流都会被销毁。那有没有什么手段可以像Java语言trycatch一下?从异常的影响从大到小,我们来逐个分析Reactor的异常处理机制。
首先排除前面提到过的doOnError,他只是异常时的通知,基本等于啥也没干。
一种常见的异常处理方式是:将运行时异常包装成业务异常并再次抛出,对流的影响仍然存在,但变成了一种大家都认识的形式。Reactor提供了onErrorMap来实现这种方式,是一种很简单的转换,将Throwable A包装成Throwable B并重新抛出。
显然这种方式并不理想,下游仍然感知到了异常(只不过是一种熟悉的异常)。现在我希望用一个默认值给下游,代替粗暴的抛出异常。onErrorReturn/onErrorResume意为在异常时直接返回一个默认值/流,这俩区别不大,前者是固定的默认值、后者是动态构建一个新的流传给下游。
需求升级,我希望在异常发生时继续处理剩下的元素,不要对流产生任何影响。而上述方式都会中断流,肯定是不适用的。笔者最常用的onErrorContinue出现了,他表示在异常出现时继续向下处理,仿佛什么都没有发生过——这也是唯一可以不中断流的处理方式,我们可以在onErrorContinue的方法中获取到异常及元素本身,并做一些特殊处理,例如将元素投进重试队列、报个警、回滚一些已经成功的操作等。
值得一提的是,Reactor的操作符都是有作用域的,在不同的地方声明操作符会有不同的作用范围。异常处理操作符的作用域就是之前的所有操作,例如先声明doOnNext、再声明onErrorContinue,doOnNext中的异常就会被处理,反之则不会。
3.2.6 BackPressure 反压
先放下晦涩的概念,来看看响应式系统的架构。
前面提到响应式是一种事件驱动的编程范式,由发布/订阅者两种角色组成,发布者负责构建流、订阅者负责消费流中的元素。具体来说,发布者负责构建流、并将流中的元素推送给订阅者,订阅者负责消费被推送的元素。
插个题外话,为啥选择「推」而不是「拉」?这里有个权衡的过程,推模式有个好处就是数据实时性会更高,但代价就是完全不顾订阅者的死活,我不管你想不想要、能不能要我都扔给你。
拉模式就不存在这个问题,发布者把元素囤在自己那,订阅者有空了就去拉点过来、消费完了再拉下一批。但消费的实时性就完全取决于订阅者拉取的频率;以及如果一直不拉取,元素就会一直囤积在发布者手里,占用大量的存储空间。以及一个非常严重的问题,如何决定拉取的频率?频率太低,订阅者可能会大部分时间都处于空闲状态;频率太高,订阅者和发布者都顶不住。
到这里问题也浮现了,传统的推/拉模式其实都不太合理,推容易把发布者推死、拉容易把订阅者拉死。因此Reactor采用了一种折中的架构,保留了推模式高吞吐同时,又避免了拉模式下可能出现的低实时性或频率过高。
大体的结构还是推,但是发布者会结合订阅者上报的消费情况,控制自身的推送速率。在这种模式下,订阅者也有了控制的权利,而非发布者一人说了算。
而这种机制,就叫「反压」。原本发布者只负责推送,所有的压力都施加到订阅者身上;而现在订阅者在意识到自身消费能力不足时,可以阻止上游再推送元素,反对上游再施加压力。具体的实现方式就是3.2.2中提到Subscription的request(Long value)方法,只有订阅者调用request向上游请求元素时,发布者才会向下游推送元素;我们可以在每次消费完一个/批元素后调用request,再消费下一批。而Subscription对象只有Subscriber里有,这也是为什么上文说“Subscriber有着更精细的控制能力”。
Flux.range(1, 100)
.onErrorContinue((throwable, obj) -> System.out.println("sink error" + throwable.getMessage()))
.subscribeOn(Schedulers.immediate())
.onBackpressureBuffer(10000)
.subscribe(new BaseSubscriber<Integer>() {
@Override
protected void hookOnSubscribe(Subscription subscription) {
// initializing request 10
subscription.request(10L);
}
@Override
protected void hookOnNext(Integer value) {
System.out.println("on next: " + value);
request(1L);
}
});
例如上面的代码块,流内共有100个元素,初始化时拉取10个,再于每次处理完元素后调用request拉取1个;把hookOnNext里的request(1L)注释掉,就不会再拉取元素进行消费了。
当生产速率 < 消费速率时,那自然是极好的,订阅者消费完就向上请求,谁也不耽误;生产速率 > 消费速率时,订阅者也只会按照自己的节奏来请求元素,此时就产生了背压,那么发生背压时未被及时消费的元素该如何处理?
Reactor也为我们提供了几种背压策略,可以将未消费元素缓存起来(当然是有长度限制的,类似于线程池的任务队列)/直接丢掉/只保留最新的/直接抛异常。
3.3 异步化
异步是响应式编程的核心能力,他提供给使用者最大程度灵活调度的能力,甚至可以细化到每一步操作是否异步、每个异步操作使用什么线程模型。
3.3.1 异步
提到异步就很难绕开线程池,Reactor中也有类似的概念:Scheduler,中文译为调度器,是Reactor消费模式的调度者;而消费模式无非同/异步两种,Scheduler就是在帮助我们在同异步间切换,所以也可以简单地类比为线程池。
Reactor针对不同的订阅逻辑(大类可以分为IO密集型、CPU密集型),也提供了不同类型的Scheduler:
- Schedulers.immediate:当前线程。
- Schedulers.single:只有一个线程的线程池。
- Schedulers.boundedElastic:可伸缩的弹性线程池,可以理解为一个普通的有coreSize、maxSize、workQueue的线程池,最大线程数为CPU核心数 * 10,超过TTL的线程池会被回收。
- Schedulers.parallel:有CPU核数个线程的线程池,应用会共用一个parallel线程池。
- Schedulers.fromExecutorService:从已有的线程池创建一个线程池,不是很推荐这种做法,因为不会被Reactor底层的调度策略优化。
显然,不同的Scheduler有着不同的应用场景,例如boundedElastic就非常适合IO密集型任务,线程池共用且空闲时还会自动回收;而parallel就适合CPU密集型作业,线程数为CPU核数会最小化资源的浪费(详情可以参考ForkJoinPool)。
在Reactor中使用异步十分简单,只需要用两种操作符指定Scheduler即可:
- publishOn:之后的操作都使用该Scheduler。
- subscribeOn:全局操作都使用该Scheduler。
值得注意的是,publishOn只会影响之后的操作,因此声明的顺序会有影响;而subscribeOn不存在这个问题,在哪里声明都会使整个订阅采用同一Scheduler。
这两个操作符也有优先级之分,如两者同时声明,publishOn会拥有更高的优先级。比如这样:
Flux.range(100, 100)
.map(one -> one * 100)
.publishOn(Schedulers.single())
.doOnNext(x -> {
try {
TimeUnit.SECONDS.sleep(1L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(x + ":" + Thread.currentThread().getName());
})
.publishOn(Schedulers.parallel())
.doOnNext(x -> {
try {
TimeUnit.SECONDS.sleep(1L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(x + "::::::::" + Thread.currentThread().getName());
})
.subscribeOn(Schedulers.boundedElastic())
.subscribe();
由于声明了subscribeOn,因此第一个map操作会采用boundedElastic;在第一个doOnNext之前声明了publishOn,因此他会使用single;第二个doOnNext前又声明了publishOn,因此他又会使用parallel。
可以看到调度模型的切换是非常灵活的,可以细化到哪步操作适合哪种模型、再逐步进行切换,不过需要注意切换的作用域哦。
3.3.2 并行
利用publishOn/subscribeOn我们初步实现了异步,但是系统的吞吐量并没有显著提升,这是为什么呢?我们用parallel调度器来写个小demo:
Flux.range(100, 100)
.publishOn(Schedulers.parallel())
.map(one -> one * 100)
.doOnNext(x -> System.out.println(x + ":" + Thread.currentThread().getName()))
.subscribe();
// output
10000:parallel-1
10100:parallel-1
10200:parallel-1
10300:parallel-1
10400:parallel-1
10500:parallel-1
10600:parallel-1
10700:parallel-1
10800:parallel-1
10900:parallel-1
11000:parallel-1
11100:parallel-1
11200:parallel-1
11300:parallel-1
11400:parallel-1
11500:parallel-1
11600:parallel-1
11700:parallel-1
11800:parallel-1
11900:parallel-1
12000:parallel-1
所有线程名称都是parallel-1,说明订阅的并发度始终为1,显然对吞吐量不会有任何帮助。这是由于响应式流默认的并发度就为1,相当于这个流只有一个出口,即使开100个线程,同一时间也只会有一个线程获取到元素。因此我们首先要将普通的响应式流转化为并行的响应式流,再配合多线程的调度器。
3.3.2.1 parallel实现
第一种方式是Reactor内置的parallel操作符,非常直接地将普通流转换成并行流,并且可以手动指定并发数(缺省为CPU核数),此时就有了并行度为N的并行流。
但仍然没有结束,只是并行度提上来了,相当于从1条队列变成了N条,我们还需要与之对应的订阅者。通过runOn操作符 + Scheduler(本质与subscribeOn是一个含义),就真正实现了异步 + 并行(但注意不能用Schedulers.immediate,否则还是main线程在干活)。
Flux.range(1, 100)
.parallel()
.runOn(Schedulers.parallel())
.map(one -> one * 100)
.doOnNext(x -> System.out.println(x + ":" + Thread.currentThread().getName()))
.doOnComplete(() -> System.out.println("Complete: " + Thread.currentThread().getName()))
.doOnError(throwable -> System.out.println("Error: " + Thread.currentThread().getName()))
.subscribe();
System.out.println("okay");
TimeUnit.SECONDS.sleep(100);
你以为到这里就结束了?感兴趣的同学可以去尝试运行一下这段代码,你会发现最下面一行的“okay”先被打印、且元素消费时打印的线程名显示有多个线程,这说明我们成功的实现了异步 + 并行,但恐怖的是doOnComplete/doOnError里的逻辑也被运行了N次(其中N为并行度)。
这说明:并行流中的订阅者是相对独立存在的,每个订阅者都会完整地履行自己的职责,这就包括了运行自己的doOnComplete等等等等。
这会出现一个什么情况呢,拿我们上面的小Demo举例子,我们用doOnComplete实现了“全部完成后通知HR部门招下一批人”;有一天我们将其优化成了并行流,每一批次的求职者都会通知HR部门N次,HR认为我们在骚扰他们发起了投诉,最终我们部门全年325。
实际上引发的后果可能比上述例子更严重,因此这可以算ParallelFlux一个不小的隐藏问题。
但这只是问题不是bug,这意味着官方肯定给出了解法!如果doOnComplete是明确语义的整个流消费完再执行、且仅执行一次,那我们再把他变回串行流不就行了?
没错,Reactor提供了这样的能力,sequential操作符可以将并行流再变回串行流,这就实现了只在需要并发的操作前用并行、不需要的用串行。这就是为何说Reactor给了我们无比强大、完备、自主的控制能力,是真正意义上的想怎么切就怎么切!
例如下面这样,可以看到sequential前是多线程、之后又成了单线程,只被运行了一次。
Flux.range(1, 100)
.parallel()
.runOn(Schedulers.parallel())
.map(one -> one * 100)
.doOnNext(x -> System.out.println(x + ":" + Thread.currentThread().getName()))
.sequential()
.doOnComplete(() -> System.out.println("Complete: " + Thread.currentThread().getName()))
.doOnError(throwable -> System.out.println("Error: " + Thread.currentThread().getName()))
.subscribe();
System.out.println("okay");
TimeUnit.SECONDS.sleep(100);
3.3.2.2 flatMap实现
假如我现在又有个牛逼的需求,流里有一堆不同任务类型的元素(可以理解为事件类型不同,有计算事件和非结算事件),有IO密集和CPU密集,要同时满足异步 + 并发 + 动态决定调度器。
上面介绍的parallel并行流显然没法做到这点,因为不论是runOn还是publishOn、subscribeOn都是针对某一步操作的,无法针对某一种元素动态决定操作和调度模型。
再分析一下我们的诉求,我们要根据「元素」动态决定使用的调度模型,本质上是在转化元素,啥操作符可以转化元素?map和flatMap,一个是将元素值A转化成元素值B、一个是将元素值A转化成流A。那我们能不能用flatMap,将元素值A转化成使用Schedulers.single的流A、将元素值B转化成使用Schedulers.parallel的流B?
可以!太可以!非常可以!只需要在flatMap中将元素转换为Mono/Flux,并配上喜欢的调度器,便可以轻松实现需求,并且不会再出现doOnComplete执行多次之类的问题,因为自始至终我们只有一个串行流,只不过是其中的元素被分解成单独的流、又合并成了一个流。
Flux.range(1, 100)
.flatMap(one -> {
Mono<Integer> just = Mono.just(one);
if (one < 50) {
just = just.subscribeOn(Schedulers.single());
} else {
just = just.subscribeOn(Schedulers.parallel());
}
return just;
})
.map(one -> one * 100)
.doOnNext(x -> System.out.println(x + ":" + Thread.currentThread().getName()))
.doOnComplete(() -> System.out.println("Complete: " + Thread.currentThread().getName()))
.doOnError(throwable -> System.out.println("Error: " + Thread.currentThread().getName()))
.subscribe();
System.out.println("okay");
TimeUnit.SECONDS.sleep(10000);
到这里基本介绍完毕了,实际Reactor的强大之处远不止我介绍的这些,他提供了许多十分实用的功能,基本能覆盖绝大部分应用场景;而且不乏以时间窗口为维度聚合并批量发起处理、合并多个流等牛逼的功能。但他本身太过复杂、逻辑上存在很多黑盒、并且十分十分难以debug。因此公司的前辈也多次告知我,这是一柄完全的双刃剑,要谨慎使用。
但毋庸置疑他是一门极为优秀和全面的编程范式,希望有一天能得其要领、真正意义上掌握这门手艺。