响应式编程:面向数据/事件流的编程范式

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,我们要做这几件事:

  1. 去数据库里根据序号查到每个人的年龄
  2. 剔除掉35岁以上的人
  3. 为每个人准备好对应的工号(前缀 + 序号)和工牌
  4. 将这些人的信息录入OA系统
  5. 为每个人发送一封入职通知
  6. 全部完成后,通知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。因此公司的前辈也多次告知我,这是一柄完全的双刃剑,要谨慎使用。

        但毋庸置疑他是一门极为优秀和全面的编程范式,希望有一天能得其要领、真正意义上掌握这门手艺。

  • 7
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值