Reactive Spring實戰 -- 理解Reactor的設計與實現

binecy發表於2021-02-28

Reactor是Spring提供的非阻塞式響應式程式設計框架,實現了Reactive Streams規範。 它提供了可組合的非同步序列API,例如Flux(用於[N]個元素)和Mono(用於[0 | 1]個元素)。

Reactor Netty專案還支援非阻塞式網路通訊,非常適用於微服務架構,為HTTP(包括Websockets),TCP和UDP提供了響應式程式設計基礎。

本文通過例子展示和原始碼閱讀,分析Reactor中核心設計與實現機制。

文字Reactor原始碼基於Reactor 3.3

名詞解析

響應式程式設計,維基百科解析為

reactive programming is an asynchronous programming paradigm concerned with data streams and the propagation of change. This means that it becomes possible to express static (e.g. arrays) or dynamic (e.g. event emitters) data streams with ease via the employed programming language(s)

響應式程式設計是一個專注於資料流和變化傳遞的非同步程式設計正規化。 這意味著使用程式語言可以很容易地表示靜態(例如陣列)或動態(例如事件發射器)資料流。

下面簡單解釋一下相關名詞。
資料流與變化傳遞,我的理解,資料流就如同一條車間流水線,資料在上面傳遞,經過不同的操作檯(我們定義的操作方法),可以被觀測,被過濾,被調整,或者與另外一條資料流合併為一條新的流,而操作檯對資料做的改變會一直向下傳遞給其他操作檯。
java 8 lambda表示式就是一種資料流形式

lists.stream().filter(i -> i%2==0).sorted().forEach(handler);

lists.stream(),構建一個資料流,負責生產資料。
filter,sorted方法以及handler匿名類,都可以視為操作檯,他們負責處 理資料。

這裡還涉及兩個概念
宣告式程式設計,通過表示式直接告訴計算機我們要的結果,具體操作由底層實現,我們並不關心,如sql,html,spring spel。

對應的指令式程式設計,一步一步告訴計算機先做什麼再做什麼。我們平時編寫java,c等程式碼就是指令式程式設計。
上例中通過filter,sorted等方法直接告訴計算機(Spring)執行過濾,排序操作,可以理解為宣告式程式設計。
注意,我的理解是,宣告式,指令式程式設計並沒有明確的界限。
越是可以直接通過宣告表達我們要什麼,就越接近宣告式程式設計,反之,越是需要我們編寫操作過程的,就越接近指令式程式設計。
如Spring中的宣告式事務和程式設計式事務。
可參考:https://www.zhihu.com/question/22285830

函數語言程式設計,就是將函式當做一個資料型別,函式作為引數,返回值,屬性。
Java不支援該模式,通過匿名類實現,如上例中forEach方法。
注意,函數語言程式設計還有很多學術性,專業性的概念,感興趣的同學可以自行了解。

響應式程式設計,主要是在上面概念加了非同步支援。
這個非同步支援非常有用,它可以跟Netty這些基於事件模型的非同步網路框架很好地結合,下一篇文章我們通過WebFlux來說明這一點。

資料流轉

下面我們來簡單看一下Reactor的設計與實現吧。
首先通過一個小用例,來看一個Reactor中如何生產資料,又如何傳遞給訂閱者。

@Test
public void range() {
    // [1]
    Flux flux = Flux.range(1, 10);
    // [2]
    Subscriber subscriber = new BaseSubscriber<Integer>() {
        protected void hookOnNext(Integer value) {
            System.out.println(Thread.currentThread().getName() + " -> " + value);
            request(1);
        }
    };
    // [3]
    flux.subscribe(subscriber);
}

Reactor中,釋出者Publisher負責生產資料,有兩種釋出者,Flux可以生產N個資料,Mono可以生產0~1個資料。
訂閱者Subscriber負責處理,消費資料。
1 構建一個釋出者Flux
注意,這時釋出者還沒開始生產資料。
2 構建一個訂閱者Subscriber
3 建立訂閱關係,這時,生產者開始生產資料,並傳遞給訂閱者。

Flux.range,fromArray等靜態方法都會返回一個Flux子類,如FluxRange,FluxArray。

Publisher#subscribe,該方法很重要,它負責建立釋出者與訂閱者的訂閱關係。
Flux#subscribe

public final void subscribe(Subscriber<? super T> actual) {
    CorePublisher publisher = Operators.onLastAssembly(this);
    CoreSubscriber subscriber = Operators.toCoreSubscriber(actual);

    try {
        ...

        publisher.subscribe(subscriber);
    }
    catch (Throwable e) {
        Operators.reportThrowInSubscribe(subscriber, e);
        return;
    }
}

獲取內部的CorePublisher,CoreSubscriber。
Flux子類都是一個CorePublisher。
我們編寫的訂閱者,都會轉化為一個CoreSubscriber。

CorePublisher也有一個內部的subscribe方法,由Flux子類實現。
FluxRange#subscribe

public void subscribe(CoreSubscriber<? super Integer> actual) {
    ...
    actual.onSubscribe(new RangeSubscription(actual, st, en));
}

Subscription代表了釋出者與訂閱者之間的一個訂閱關係,由Publisher端實現。
Flux子類subscribe方法中通常會使用CoreSubscriber建立為Subscription,並呼叫訂閱者的onSubscribe方法,這時訂閱關係已完成。

下面來看一下Subscriber端的onSubscribe方法
BaseSubscriber#onSubscribe -> hookOnSubscribe

protected void hookOnSubscribe(Subscription subscription) {
    subscription.request(9223372036854775807L);
}

Subscription#request由Publisher端實現,也是核心方法,訂閱者通過該方法向釋出者拉取特定數量的資料。
注意,這時釋出者才開始生產資料。

RangeSubscription#request -> RangeSubscription#slowPath -> Subscriber#onNext

void slowPath(long n) {
    Subscriber<? super Integer> a = this.actual;
    long f = this.end;
    long e = 0L;
    long i = this.index;

    while(!this.cancelled) {
        // [1]
        while(e != n && i != f) {
            a.onNext((int)i);
            if (this.cancelled) {
                return;
            }

            ++e;
            ++i;
        }

        ...
    }
}

1 RangeSubscription負責生產指定範圍內的整數,並呼叫Subscriber#onNext將資料推送到訂閱者。

可以看到,
Publisher#subscribe完成訂閱操作,生成Subscription訂閱關係,並觸發訂閱者鉤子方法onSubscribe。
訂閱者的onSubscribe方法中,訂閱者開始呼叫Subscription#request請求資料,這時釋出者才開始生產資料,並將資料推給訂閱者。

操作符方法

跟java 8 lambda表示式一樣,Reactor提供了很多的宣告式方法,這些方法類似於操作符,直接運算元據(下文稱為操作符方法)。
合理利用這些方法,可以大量簡化我們的工作。

資料處理,如skip,distinct,sort,filter
鉤子方法,如doOnNext,doOnSuccess
組合操作,flatMap,zipWhen
阻塞等待,blockLast
流量控制,limitRate
資料快取,buffer,cache
可參考官方文件:https://projectreactor.io/docs/core/release/reference/#which-operator

注意,這些操作符方法雖然是新增到Publisher端,但Reactor會將邏輯會轉移到Subscriber端。

看一個簡單例子

Flux.range(1, 3)
    .doOnNext(i -> {
        System.out.println(Thread.currentThread().getName() + " doOnNext:" + i);
    })
    .skip(1)
    .subscribe(myHandler);

myHandler即我們實現的Subscriber。
每呼叫一次操作符方法,Flux都會生成一個新的Flux子類(裝飾模式),最後Flux類為FluxSkip[FluxPeek[FluxRange]]

我們來看一下完整的Flux#subscribe方法程式碼

public final void subscribe(Subscriber<? super T> actual) {
    CorePublisher publisher = Operators.onLastAssembly(this);
    CoreSubscriber subscriber = Operators.toCoreSubscriber(actual);

    try {
        // [1]
        if (publisher instanceof OptimizableOperator) {
            
            OptimizableOperator operator = (OptimizableOperator)publisher;

            while(true) {
                // [2]
                subscriber = operator.subscribeOrReturn(subscriber);
                if (subscriber == null) {
                    return;
                }
                
                // [3]
                OptimizableOperator newSource = operator.nextOptimizableSource();
                if (newSource == null) {
                    publisher = operator.source();
                    break;
                }

                operator = newSource;
            }
        }
        // [4]
        publisher.subscribe(subscriber);
    } catch (Throwable var6) {
        Operators.reportThrowInSubscribe(subscriber, var6);
    }
}

1 判斷Flux是否由操作符方法產生。
2 OptimizableOperator#subscribeOrReturn會生成新的Subscriber,以執行操作符邏輯。如上面例子中,FluxPeek會生成PeekSubscriber,FluxSkip生成SkipSubscriber。這裡將操作符邏輯轉移到Subscriber端。
OptimizableOperator#subscribeOrReturn也可以直接呼叫被裝飾Publisher的subscribe方法,從而改變流程。如下面說的FluxSubscribeOn。
3 取出上一層被裝飾的Publisher作為新的Publisher,如上例的FluxSkip[FluxPeek[FluxRange]],會依次取出FluxPeek,FluxRange。
這個操作一直執行,直到取出真正生產資料的Publisher。
4 使用真正生產資料的Publisher,和最後包裝好的Subscriber,再呼叫subscribe方法。

上面例子中,流程如下

push/pull

Reactor提供了push和pull兩種模式。

先看一下pull模式

Flux.generate(sink -> {
    int k = (int) (Math.random()*10);
    if(k > 8)
        sink.complete();
    sink.next(k);
})
.subscribe(i -> {
    System.out.println("receive:" + i);
});

Sink可以理解為資料池,負責儲存資料,根據功能不同劃分,如IgnoreSink,BufferAsyncSink,LatestAsyncSink。
Sink#next會將資料放入池中,由Sink快取或直接傳送給訂閱者。

Flux#generate(Consumer<SynchronousSink<T>> generator),可以理解為pull模式,
訂閱者每呼叫一次request方法,Publisher就會呼叫一次generator來生產資料,而且generator每次執行中只能呼叫一次Sink.next。
generator是單執行緒執行,生成資料後直接同步傳送到訂閱者。

push模式可以使用create方法

Flux.create(sink -> {
    System.out.println("please entry data");
    BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
    while (true) {
        try {
            sink.next(br.readLine());
        } catch (IOException e) {
        }
    }
}).subscribe(i -> {
    System.out.println("receive:" + i);
});

Flux#create(Consumer<? super FluxSink<T>> emitter),可以理解為push模式。
注意,Publisher只在Flux#subscribe操作時呼叫一次emitter,後續request不再呼叫emitter。
我們可以將Sink繫結到其他的資料來源,如上例的控制檯,或其他事件監聽器。
當資料來了,Sink就會將它推送給訂閱者。
Flux#create生成的Flux可以多執行緒同時呼叫Sink.next推送資料,並且資料會先快取到Sink,後續Sink推送給訂閱者。

push方法與create類似,只是它只允許一個執行緒呼叫Sink.next推送資料。
上例中create方法使用push方法更合適,因為只有一個執行緒推送資料。

混合模式
假如一個訊息處理器MessageProcessor需要會將普通訊息直接推送給訂閱者,而低階別訊息由訂閱者拉取。
我們可以FluxSink#onRequest實現混合模式

Flux.create(sink -> {
    // [1]
    messageProcessor.setHandler((msg) -> {
        sink.next(msg);
    });
    // [2]
    sink.onRequest(n -> {
        List<String> messages = messageProcessor.getLowMsg();
        for(String s : messages) {
            sink.next(s);
        }
    });
})

1 普通訊息直接推送
2 低階別訊息由訂閱者拉取

完整程式碼可參考:https://gitee.com/binecy/bin-springreactive/blob/master/order-service/src/test/java/com/binecy/FluxPushPullTest.java

執行緒與排程器

前面說了reactor是支援非同步的,不過它並沒有預設開啟非同步,我們可以通過排程器開啟,如

public void parallel() throws InterruptedException {
    Flux.range(0, 100)
            .parallel()
            .runOn(Schedulers.parallel())
            .subscribe(i -> {
                System.out.println(Thread.currentThread().getName() + " -> " + i);
            });
    new CountDownLatch(1).await();
}

parallel 將資料分成指定份數,隨後呼叫runOn方法並行處理這些資料。
runOn 該方法引數指定的任務執行的執行緒環境。
最後的CountDownLatch用於阻塞主執行緒,以免程式停止看不到效果。

排程器相當於Reactor中的ExecutorService,不同的排程器定義不同的執行緒執行環境。
Schedulers提供的靜態方法可以建立不同的執行緒執行環境。
Schedulers.immediate() 直接在當前執行緒執行
Schedulers.single() 在一個重複利用的執行緒上執行
Schedulers.boundedElastic() 在由Reactor維護的執行緒池上執行,該執行緒池中閒置時間過長(預設值為60s)的執行緒也將被丟棄,建立執行緒數量上限預設為CPU核心數x 10。執行緒數達到上限後,最多可提交10萬個任務,這些任務線上程可用時會被執行。該執行緒池可以為阻塞操作提供很好的支援。阻塞操作可以執行在獨立的執行緒上,不會佔用其他資源。
Schedulers.parallel() 固定執行緒,對於非同步IO,可以使用該方案。

Reactor另外提供了兩個操作符方法來切換執行上下文,publishOn和subscribeOn。
publishOn影響當前操作符方法後面操作的執行緒執行環境,而subscribeOn則影響整個鏈路的執行緒執行環境。
(runOn與publishOn類似,影響該方法後續操作執行緒執行環境)

Flux.range(1, 3)
        .doOnNext(i -> {
            System.out.println(Thread.currentThread().getName() + " doOnNext:" + i);
        })
        .publishOn(Schedulers.newParallel("myParallel"))
        .skip(1)
        .subscribe(myHandler);

myHandler只是簡單列印執行緒和資料

Consumer myHandler = i -> {
    System.out.println(Thread.currentThread().getName() + " receive:" + i);
};

輸出結果為

main doOnNext:1
main doOnNext:2
main doOnNext:3
myParallel-1 receive:2
myParallel-1 receive:3

publishOn後面的操作(包括skip,myHandler)都已經切換到新的執行緒。

再來簡單看一下publishOn與subscribeOn的實現
前面說了,操作符方法的邏輯會移到Subscriber端,上例過程示意如下

執行緒切換是在PublishOnSubscriber中完成的,所以PublishOnSubscriber後面的操作都在新執行緒上。

將上面例子程式碼修改一下

Flux.range(1, 3)
        .doOnNext(i -> {
            System.out.println(Thread.currentThread().getName() + " doOnNext:" + i);
        })
        .subscribeOn(Schedulers.newParallel("myParallel"))
        .skip(1)
        .subscribe(myHandler);

輸出結果為

myParallel-1 doOnNext:1
myParallel-1 doOnNext:2
myParallel-1 receive:2
myParallel-1 doOnNext:3
myParallel-1 receive:3

從資料生產到消費,所有操作都在新的執行緒上。

示意圖如下

前面說了,Flux#subscribe中會呼叫OptimizableOperator#subscribeOrReturn方法,而在FluxSubscribeOn中,會直接切換任務執行緒,後面整個流程都執行在新執行緒上了。

使用publishOn還是subscribeOn,關鍵在於阻塞操作是在生產資料時還是消費資料時。
如果阻塞操作在生產資料時,如同步查詢資料庫,查詢下游系統,可以使用subscribeOn
如果阻塞操作在消費資料時,如同步儲存資料,可以使用publishOn。

流量控制

響應式程式設計中常常會出現Backpressure的概念,
它是指在push模式下,當釋出者生產資料的速度大於訂閱者消費資料的速度,導致出現了訂閱者傳遞給訂閱者的逆向壓力。

FluxSink.OverflowStrategy定義了在這種場景下的幾種處理策略。
IGNORE 完全忽略新的資料
ERROR Publisher丟擲異常
DROP 拋棄資料,觸發Flux#onBackpressureDrop方法
LATEST 訂閱者只能獲取最新的一個資料
BUFFER 快取所有的資料,注意,該快取沒有邊界,可能導致記憶體溢位
FluxSink.OverflowStrategy類似於執行緒池的任務拒絕策略。

下面來看一個例子

@Test
public void backpressure() throws InterruptedException {
    Flux.<Integer>create(sink -> {
        for (int i = 0; i < 50; i++) {
            System.out.println("push: " + i);
            sink.next(i);
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
            }
        }
    }, FluxSink.OverflowStrategy.ERROR)
    .publishOn(Schedulers.newSingle("receiver"), 10)
    .subscribe(new BaseSubscriber<Integer>() {
        protected void hookOnSubscribe(Subscription subscription) {
            subscription.request(1);
        }
        protected void hookOnNext(Integer value) {
            System.out.println("receive:" + value);
            try {
                Thread.sleep(12);
            } catch (InterruptedException e) {
            }
            request(1);
        }
        protected void hookOnError(Throwable throwable) {
            throwable.printStackTrace();
            System.exit(1);
        }
    });
    new CountDownLatch(1).await();
}

1 釋出者每隔10毫秒生產一個資料
注意,FluxSink.OverflowStrategy.ERROR引數指定了Backpressure處理策略
2 publishOn方法指定後續執行執行緒環境
注意下文解析的第二個引數。
3 訂閱者每隔20毫秒消費一個資料

Sink中有一個關鍵欄位,BaseSink#requested,代表訂閱者請求數量。
每次訂閱者呼叫Subscription#request(long n)方法,BaseSink#requested都會加上對應數值n。
而每次生產資料呼叫Sink#next時,BaseSink#requested都會減1。
當Sink#next執行,如果BaseSink#requested為0,就是執行FluxSink.OverflowStrategy指定策略。

publishOn(Scheduler scheduler, int prefetch)方法會將BaseSink#requested的值初始化為prefetch。
注意,這裡並不會生產prefetch個資料併傳送給訂閱者,只會修改BaseSink#requested。

另外,PublishOnSubscriber中會將Subscription#request操作快取,達到閥值後合併為一次request操作。

在上面的例子中閥值為prefetch - (prefetch >> 2),就是8了
所以我們會看到結果

receive:5
push: 10
push: 11
21:59:55.828 [Thread-0] DEBUG reactor.core.publisher.Operators - onNextDropped: 11

釋出者傳送10(prefetch)個資料後,儘管訂閱者已經消費5個資料,併發起5次request操作,但被PublishOnSubscriber快取了,並沒有傳送到釋出者那邊,這時BaseSink#requested已經為0了,丟擲OverflowException異常,Sink關閉,後面的資料被拋棄。

可以將訂閱者的休眠時間調整為12毫秒,這樣當釋出者傳送10(prefetch)個資料前,PublishOnSubscriber會發起一次request(8)的操作,可以看到

push: 19
22:03:33.779 [Thread-0] DEBUG reactor.core.publisher.Operators - onNextDropped: 19

也就是到19個資料才丟擲異常,拋棄資料。

到這裡,我們已經基本瞭解Reactor的概念,核心設計與實現機制。
下一篇文章,我們通過比較WebFlux和AsyncRestTemplate,看一下響應式程式設計會給我們帶來什麼驚奇。

如果您覺得本文不錯,歡迎關注我的微信公眾號,系列文章持續更新中。您的關注是我堅持的動力!

相關文章