✨糟糕的✨响应式编程

3,815 阅读17分钟

你的名字-001.jpg

大家好,我是半夏之沫 😁😁 一名金融科技领域的JAVA系统研发😊😊
我希望将自己工作和学习中的经验以最朴实,最严谨的方式分享给大家,共同进步👉💓👈
👉👉👉👉👉👉👉👉💓写作不易,期待大家的关注和点赞💓👈👈👈👈👈👈👈👈
👉👉👉👉👉👉👉👉💓关注微信公众号【技术探界】 💓👈👈👈👈👈👈👈👈


前言

响应式编程绝对是 最糟糕 的编程范式,没有之一,特别是在JDK21虚拟线程出现后,响应式编程框架以及WebFlux则彻底沦为小丑。

也许到这里你以为本文会长篇大论的诉说响应式编程为何那么 糟糕,但恰恰相反,本文的核心目标是以最简单明了的方式介绍响应式编程以及让你明白响应式编程到底想解决什么问题,只有深刻理解了响应式编程,品味了响应式编程的味道后,你才会发现这就是 一坨。

响应式编程的资料建议全部看官方文档。

Reactive Streams
Project Reactor
WebFlux Framework

正文

一. 响应式编程概念

响应式编程是基于 数据流 和 变化传播 的 异步 编程范式。

Reactive programming is an asynchronous programming paradigm concerned with data streams and the propagation of change.

数据流可以用下图进行示意。

响应式编程-数据流示意图

数据流是在时间维度上有先后次序的可流动的数据,也就是同一个数据流中的数据彼此之间的产生时间是有先后的。

变化传播可以用下图进行示意。

响应式编程-变化传播示意图

数据流中的数据可以经过Operate操作,操作后的数据会发生变化,变化后的数据会进入下一个操作,此时变化就传播到了下一个操作,这就是变化的传播。

响应式编程的概念比较抽象,各种响应式编程框架例如RxJava,Project Reactor和VertX等都有各自对响应式编程的理解和实现,这很不利于后续的响应式编程框架的切换,所以就有了Reactive Streams规范来 统一 响应式编程的概念,后续各家响应式编程框架都遵循该规范以方便上层响应式应用进行灵活切换。

Reactive Streams规范定义了响应式编程中的如下四个组件。

  1. Publisher,生产者。作为数据源生产数据流。
public static interface Publisher<T> {
    // 绑定订阅者以向订阅者推送数据流
    void subscribe(Subscriber<? super T> var1);
}
  1. Subscriber,订阅者。数据流的消费方。
public static interface Subscriber<T> {
    // 成功与发布者绑定时由发布者调用该方法
    public void onSubscribe(Subscription s);

    // 数据流中下一个数据发布时由发布者调用该方法
    public void onNext(T t);

    // 发布流数据抛出异常时由发布者调用该方法
    public void onError(Throwable t);

    // 数据流所有数据发布完成后由发布者调用该方法
    public void onComplete();
}
  1. Subscription,订阅。保存发布者和订阅者的订阅关系。
public static interface Subscription {
    // 请求n次数据
    public void request(long n);
    // 取消数据流
    public void cancel();
}

特别关注一下Subscription接口中的request() 方法,该方法用于实现Reactive Streams的 背压机制,该机制是Reactive Streams的关键机制但却是大部分时候用不到的一个机制,即订阅者通过调用Subscription的request(n) 方法来实现向发布者请求n次数据,用于处理发布者数据发布过快而导致订阅者处理不过来的情况,即处理得过来就多请求点数据,处理不过来就少请求点数据或者不请求。

  1. Processor,处理器。处理流中的数据。
public interface Processor<T, R> extends Subscriber<T>, Publisher<R> {}

Processor对于上游来说是数据流的订阅者,同时Processor对于下游来说是数据流的发布者,整体的一个关系可以用下图进行示意。

响应式编程-ReactiveStreams四大组件示意图

最后重温一下响应式编程的概念,基于 数据流 和 变化传播 的 异步 编程范式。前面对数据流和变化传播都进行了解释,那么异步是体现在哪里呢,就是体现在无论是发布者发布数据,或者处理器处理数据,再或者订阅者订阅数据,都是完全异步的,并且力求达到让少量线程 一直忙 的效果,这其实就是响应式编程想要解决的问题,用一直忙的少量线程来承载更多的IO密集型处理,而不是使用大量线程。

你可能会有疑问,怎么才能实现让少量线程一直忙呢,毕竟没有这个特性,那么响应式编程就没有任何优越性可言,其实这个问题不重要,因为各种响应式框架自然会在底层帮我们实现这个特性,如果一定要深究,这里就浅浅的提一下,少量线程一直忙,其实就是这些少量的线程没有被阻塞并且一直有事情干,这些线程是 非阻塞 的,而实现这个非阻塞就是基于 缓冲区 加上 回调 来实现。

关于响应式编程的概念,知道本节的内容就足够了,请不要再去纠结什么所谓的 观察者模式,什么所谓的 事件驱动,什么所谓的 编程范式 等等,这些都一点不重要,对你理解响应式编程是一点帮助没有。

二. Reactor响应式编程库

响应式编程的实现库有很多,Reactor是其中一种,选择Reactor来学习,主要就是因为WebFlux用的就是Reactor,但是学习Reactor并不是想说这个有多好,也并 不倡导 大家用,只是为了能看得懂相关的代码,毕竟现在Springboot3已经充斥着大量响应式写法的源码,一点不了解的话源码会看得一脸懵逼。

Reactor的官方文档对每一个API都介绍得极其详细,文档也是写得非常好,所以本小节不会对每个API做说明,而是对关键的API及其相关概念进行演示阐述,力求对Reactor入个门,后续什么API不懂就直接翻官方文档即可。

首先介绍Reactor中的Flux和Mono,这两个都是数据流的发布者,区别是Flux发布的数据流中的数据个数是0到N个,而Mono是0到1个,并且既然是发布者,那么每发布一个流中的数据就会调用下游订阅者的onNext方法,流中数据全部发布完成就会调用下游订阅者的onComplete方法,流中数据发布时出现异常就会调用下游订阅者的onError方法,这一点是完全遵循Reactive Streams规范的。

本小节后续全部以Flux作为数据流发布者,但在继续介绍之前,先用下面几个小例子说明一下Reactor中的弹珠图怎么看,如果看懂 弹珠图,Reactor中所有API其实就都能看懂。

假如我用如下代码基于Flux生成数据流。

Flux.just(1, 2, 3, 4, 5);

那么just对应的弹珠图表示如下。

Just弹珠图

表示通过just传入固定个数的数据就能生成对应的数据流,并且我们知道在响应式编程概念中数据流是在时间维度上有先后次序的可流动的数据,这里也是一样的,是有时间上的先后次序的,并且在所有数据发布完成后,还会产生一个 信号,就是弹珠图中的黑色竖线,信号可以是完成信号(黑色竖线),也可以是异常信号(红色叉叉)。

如果我又用如下代码生成数据流。

Flux.just(1, 2, 3, 0, 5)
        .map(String::valueOf);

注意到新引入了map,map的弹珠图表示如下。

Map弹珠图.jpg

map本质就是处理器Processor,向上游订阅数据流,然后处理数据流,最后向下游发布数据流。

希望到这里你已经明白弹珠图怎么看了,以及也知道了在Reactor中通过Flux和Mono可以发布数据流,最重要的是明白Reactor中数据流就是 数据 加 信号。

继续学习其他关键API吧,先看下面这个示例代码。

Flux<String> flux = Flux.just(1, 2, 3, 4, 5)
        .map(String::valueOf);

我们得到了flux,是一个有5个字符串数据的数据流,思考一下此时这个数据流中的数据会发布吗,答案是不会发布,数据流只有被订阅后才会开始发布数据,我们基于Reactor提供的BaseSubscriber创建一个订阅者,后续所有演示都用这个订阅者来打印日志,如下所示。

public class ReactorSubscriber extends BaseSubscriber<String> {

    private Subscription subscription;

    /**
     * 订阅者与发布者绑定时触发该hook。
     */
    @Override
    protected void hookOnSubscribe(Subscription subscription) {
        System.out.println(Thread.currentThread().getName() + " 订阅者完成绑定");
        this.subscription = subscription;
        // 绑定关系时请求一个数据
        subscription.request(1);
    }

    /**
     * 数据流的数据达到时触发该hook。
     */
    @Override
    protected void hookOnNext(String value) {
        System.out.println(Thread.currentThread().getName() + " 收到数据流的数据:" + value);
        // 模拟数据处理耗费500毫秒
        LockSupport.parkNanos(1000 * 1000 * 500);
        // 请求一个数据
        subscription.request(1);
    }

    /**
     * 数据流全部发送完毕时触发该hook。
     */
    @Override
    protected void hookOnComplete() {
        System.out.println(Thread.currentThread().getName() + " 数据流的数据全部发送完毕");
    }

    /**
     * 数据发布异常时触发该hook。
     */
    @Override
    protected void hookOnError(Throwable throwable) {
        System.out.println(Thread.currentThread().getName() + " 数据流发布数据时异常:" + throwable);
    }

    /**
     * 订阅者的cancel方法被调用时触发该hook。
     */
    @Override
    protected void hookOnCancel() {
        System.out.println(Thread.currentThread().getName() + " 订阅者的cancel被调用");
    }

    /**
     * 无论流成功发送还是发送异常均会触发该hook。
     */
    @Override
    protected void hookFinally(SignalType type) {
        System.out.println(Thread.currentThread().getName() + " 数据流数据发送完毕或者发送异常");
    }

}

测试代码如下所示。

@Test
public void Flux流要订阅后才会发布数据() {
    Flux<String> flux = Flux.just(1, 2, 3, 4, 5)
            .map(String::valueOf);
    // 绑定订阅者后才会开始发布数据
    flux.subscribe(new ReactorSubscriber());
}

打印如下。

main 订阅者完成绑定
main 收到数据流的数据:1
main 收到数据流的数据:2
main 收到数据流的数据:3
main 收到数据流的数据:4
main 收到数据流的数据:5
main 数据流的数据全部发送完毕
main 数据流数据发送完毕或者发送异常

所以需要时刻记住,只有绑定了订阅者后,发布者才会发布数据。

现在思考一下,数据流中的数据,其实通常都不是提前能准备好的,也许要去请求下游获取,也许要去数据库里面查询,这种时候就需要通过generate和create来 编程式 的创建流。

首先看一下generate的弹珠图。

Generate弹珠图

通过generate发布数据流时,下游每次request后,可以通过generate仅发布一个数据,就像弹珠图里面示意的一样,request(1) 之后发布了数据58,又一次request(1) 之后发布了数据59,下面是一个使用generate创建序列的示例代码。

@Test
public void Flux通过generate来创建数据流() {
    // 剩余发布数据次数
    AtomicInteger leftTimes = new AtomicInteger(5);
    Flux<String> flux = Flux.generate(synchronousSink -> {
        if (leftTimes.getAndDecrement() > 0) {
            // 模拟请求数据花费了1秒
            LockSupport.parkNanos(1000 * 1000 * 1000);
            // 通过synchronousSink发布数据
            String data = String.valueOf(new Random().nextInt(100));
            System.out.println("数据产生的线程是:" + Thread.currentThread().getName()
                    + " 数据是:" + data);
            synchronousSink.next(data);
            if (leftTimes.get() == 0) {
                // 所有数据发布完了
                synchronousSink.complete();
            }
        }
    });
    flux.subscribe(new ReactorSubscriber());
}

运行测试程序打印如下。

main 订阅者完成绑定
数据产生的线程是:main 数据是:25
main 收到数据流的数据:25
数据产生的线程是:main 数据是:97
main 收到数据流的数据:97
数据产生的线程是:main 数据是:19
main 收到数据流的数据:19
数据产生的线程是:main 数据是:83
main 收到数据流的数据:83
数据产生的线程是:main 数据是:62
main 收到数据流的数据:62
main 数据流的数据全部发送完毕
main 数据流数据发送完毕或者发送异常

generate的使用场景是当下游订阅者需要数据时,会回调到generate的回调函数中,在回调函数中可以通过SynchronousSink发布一个元素,这其实是同步的方式在发布数据,下游要数据了,然后generate来同步获取并发送数据,一次发一个数据,这其实不是我们想要的效果,我们真正想要的效果应该是异步并发的去获取数据,等到下游要数据了,就把获取到的数据发布给下游,正好create可以实现这个效果,先看一下create的弹珠图。

Create弹珠图.jpg

create的弹珠图有点抽象,但其实想表达的意思就是通过sink可以异步的把数据流的数据获取到,等到下游订阅者请求了一个数据时,create就发布一个数据,下面用一个直观的例子来演示一下。

@Test
public void Flux通过create来创建数据流() {
    Flux<String> flux = Flux.create(stringFluxSink -> {
        ExecutorService threadPool = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 5; i++) {
            // 模拟异步的获取数据并发送
            threadPool.execute(() -> {
                String data = String.valueOf(new Random().nextInt(100));
                System.out.println("数据产生的线程是:" + Thread.currentThread().getName()
                        + " 数据是:" + data);
                stringFluxSink.next(data);
            });
        }
    });
    flux.subscribe(new ReactorSubscriber());
    LockSupport.parkNanos(1000 * 1000 * 1000 * 5L);
}

stringFluxSink的next() 方法可不是真正的发送数据,数据是通过next() 方法加到了缓冲队列中,只有下游请求数据时,才是真正的发送数据,运行测试程序,效果如下。

main 订阅者完成绑定
数据产生的线程是:pool-1-thread-3 数据是:37
数据产生的线程是:pool-1-thread-2 数据是:60
数据产生的线程是:pool-1-thread-1 数据是:22
数据产生的线程是:pool-1-thread-5 数据是:10
数据产生的线程是:pool-1-thread-4 数据是:18
pool-1-thread-3 收到数据流的数据:37
pool-1-thread-3 收到数据流的数据:60
pool-1-thread-3 收到数据流的数据:22
pool-1-thread-3 收到数据流的数据:10
pool-1-thread-3 收到数据流的数据:18

还依稀记得响应式编程是 异步 编程范式,但通过上面所有的测试程序的运行结果来看,大部分时候好像都没有异步,是的没错,这就是Reactor框架给到你的自由。

Reactor, like RxJava, can be considered to be concurrency-agnostic. That is, it does not enforce a concurrency model. Rather, it leaves you, the developer, in command. However, that does not prevent the library from helping you with concurrency.

意思就是并不会强制要求使用异步,具体要不要使用,全由开发人员来指定,那么如何指定,就靠publishOn和subscribeOn这两个API,这两个API的弹珠图没啥参考意义,但是官方文档给的例子很好,我们这里就直接基于例子来说明。

首先publishOn的官方文档例子如下所示。

Scheduler s = Schedulers.newParallel("parallel-scheduler", 4);    // 1

final Flux<String> flux = Flux
    .range(1, 2)
    .map(i -> 10 + i)    // 2
    .publishOn(s)    // 3
    .map(i -> "value " + i);    // 4

new Thread(() -> flux.subscribe(System.out::println));    // 5

官方解释如下。

  1. 创建一个线程数为 4 的并行调度器;
  2. 第一个 map 运行在步骤 5 的匿名线程中;
  3. publishOn 将数据流切换到步骤 1 中的并行调度器里的任一线程上;
  4. 第二个 map 运行在步骤 1 的并行调度器里的任一线程上;
  5. 订阅发生在步骤 5 的匿名线程中,但是打印是发生在并行调度器里的任一线程上。

也就是一旦publishOn了,后续流就在我们指定的并行调度器里面了,处理流的也都是这个并行调度器里的线程。

再看subscribeOn的官方文档例子如下所示。

Scheduler s = Schedulers.newParallel("parallel-scheduler", 4);    // 1

final Flux<String> flux = Flux
    .range(1, 2)
    .map(i -> 10 + i)    // 2
    .subscribeOn(s)    // 3
    .map(i -> "value " + i);    // 4

new Thread(() -> flux.subscribe(System.out::println));    // 5

官方解释如下。

  1. 创建一个线程数为 4 的并行调度器;
  2. 第一个 map 运行在并行调度器的线程中;
  3. 之所以第一个 map 运行在并行调度器的线程中是因为在订阅发生的那一刻数据流就已经切换到并行调度器的线程中了;
  4. 第二个 map 也运行在并行调度器的线程中;
  5. 订阅虽然发生在匿名线程中,但是因为有 subscribeOn ,所以订阅发生的线程会立即切换到并行调度器的线程。

一旦subscribeOn了,那么订阅发生的线程就会更改为对应的并行调度器里的线程。

最后提一下,在Flux中还有一部分以doOn开头的API,这些是用于在数据流发布数据的时候,满足某些条件后进行回调,例如doOnError用于指定发布数据出错时需要调用的回调函数,doOnComplete用于指定数据流发布完数据后需要调用的回调函数。

至此Reactor响应式编程库就介绍到这里,还有一些API没有介绍,但是官方文档写得很详细,到时候看到了再去翻文档就行,其实API无所谓,只要理解了数据流是时间维度上有先后次序的数据加上信号,以及掌握Reactive Streams规范里面的发布者订阅者模型,响应式编程或者说Reactor库就算是入门了。

总结

本文暂且对响应式编程介绍到这里,算作对响应式编程的 入门,当入了门之后,我们应该做的事情就是 抨击 响应式编程,因为响应式编程写出来的代码,是既不好懂,又难调试,抛个异常还没有堆栈,怎么恶心怎么来,妥妥的 编程小丑。


大家好,我是半夏之沫 😁😁 一名金融科技领域的JAVA系统研发😊😊
我希望将自己工作和学习中的经验以最朴实,最严谨的方式分享给大家,共同进步👉💓👈
👉👉👉👉👉👉👉👉💓写作不易,期待大家的关注和点赞💓👈👈👈👈👈👈👈👈
👉👉👉👉👉👉👉👉💓关注微信公众号【技术探界】 💓👈👈👈👈👈👈👈👈

你的名字-002.jpeg