聊聊Spring Reactor反應式程式設計

零壹技術棧發表於2019-03-04

前言

為了應對 高併發環境下 的服務端程式設計,微軟提出了一個實現 非同步程式設計 的方案 - Reactive Programming,中文名稱 反應式程式設計。隨後,其它技術也迅速地跟上了腳步,像 ES6 通過 Promise 引入了類似的非同步程式設計方式。Java 社群也沒有落後很多,NetflixTypeSafe 公司提供了 RxJavaAkka Stream 技術,讓 Java 平臺也有了能夠實現反應式程式設計的框架。

聊聊Spring Reactor反應式程式設計

正文

函數語言程式設計

函數語言程式設計是種程式設計方式,它將計算機的運算視為函式的計算。函式程式語言最重要的基礎是 λ演算 (lambda calculus),而λ演算的函式可以接受函式當作 輸入(引數)輸出(返回值)lambda 表示式對與大多數程式設計師已經很熟悉了,jdk8 以及 es6都是引入的 lambda

函數語言程式設計的特點

  • 惰性計算
  • 函式是“第一等公民”
  • 只使用表示式而不使用語句
  • 沒有副作用

反應式程式設計

反應式程式設計 (reactive programming) 是一種基於 資料流 (data stream)變化傳遞 (propagation of change)宣告式 (declarative) 的程式設計正規化。

反應式程式設計的特點

1. 事件驅動

在一個 事件驅動 的應用程式中,元件之間的互動是通過鬆耦合的 生產者 (production)消費者 (consumption) 來實現的。這些事件是以 非同步非阻塞 的方式傳送和接收的。

事件驅動 的系統依靠 推模式 而不是 拉模式投票表決,即 生產者 是在有訊息時才推送資料給 消費者,而不是通過一種浪費資源方式:讓 消費者 不斷地 輪詢等待資料

2. 實時響應

程式發起執行以後,應該 快速 返回儲存 結果的上下文,把具體執行交給 後臺執行緒。待處理完成以後,非同步地將 真實返回值 封裝在此 上下文 中,而不是 阻塞 程式的執行。實時響應是通過 非同步 程式設計實現的,例如:發起呼叫後,快速返回類似 java8CompletableFuture 物件。

3. 彈性機制

事件驅動的 鬆散耦合 提供了元件在失敗下,可以抓獲 完全隔離 的上下文場景,作為 訊息封裝,傳送到下游元件。在具體程式設計時可以 檢查錯誤 ,比如:是否接收到,接收的命令是否可執行等,並決定如何應對。

Reactor簡介

Reactor 框架是 Pivotal 基於 Reactive Programming 思想實現的。它符合 Reactive Streams 規範 (Reactive Streams 是由 NetflixTypeSafePivotal 等公司發起的) 的一項技術。其名字有 反應堆 之意,反映了其背後的強大的 效能

1. Reactive Programming

Reactive Programming,中文稱 反應式程式設計Reactive Programming 是一種 非阻塞事件驅動資料流 的開發方案,使用 函數語言程式設計 的概念來運算元據流,系統中某部分的資料變動後會自動更新其他部分,而且成本極低。

其最早是由微軟提出並引入到 .NET 平臺中,隨後 ES6 也引入了類似的技術。在 Java 平臺上,較早採用反應式程式設計技術的是 Netflix 公司開源的 RxJava 框架。Hystrix 就是以 RxJava 為基礎開發的。

反應式程式設計其實並不神祕,通過與我們熟悉的 迭代器模式 對比,便可瞭解其基本思想:

事件 Iterable (pull) Observable (push)
獲取資料 T next() onNext(T)
發現異常 throws Exception onError(Exception)
處理完成 hasNext() onCompleted()

上面表格的中的 Observable 那一列便代表 反應式程式設計API 的使用方式。它其實是 觀察者模式 的一種延伸。

如果將 迭代器模式 看作是 拉模式,那 觀察者模式 便是 推模式

  1. 被訂閱者 (Publisher) 主動推送資料給 訂閱者 (Subscriber),觸發 onNext() 方法。異常和完成時觸發另外兩個方法。

  2. 被訂閱者 (Publisher) 發生異常,則觸發 訂閱者 (Subscriber)onError() 方法進行異常捕獲處理。

  3. 被訂閱者 (Publisher) 每次推送都會觸發一次 onNext() 方法。所有的推送完成且無異常時,onCompleted() 方法將 在最後 觸發一次。

如果 Publisher 釋出訊息太快了,超過了 Subscriber 的處理速度,那怎麼辦?這就是 Backpressure 的由來。Reactive Programming 框架需要提供 背壓機制,使得 Subscriber 能夠控制 消費訊息 的速度。

2. Reactive Streams

Java 平臺上,Netflix(開發了 RxJava)、TypeSafe(開發了 ScalaAkka)、Pivatol(開發了 SpringReactor)共同制定了一個被稱為 Reactive Streams 專案(規範),用於制定反應式程式設計相關的規範以及介面。

Reactive Streams 由以下幾個元件組成:

  • 釋出者:釋出元素到訂閱者
  • 訂閱者:消費元素
  • 訂閱:在釋出者中,訂閱被建立時,將與訂閱者共享
  • 處理器:釋出者與訂閱者之間處理資料

其主要的介面有這三個:

  • Publisher
  • Subscriber
  • Subcription

其中,Subcriber 中便包含了上面表格提到的 onNextonErroronCompleted 這三個方法。對於 Reactive Streams,只需要理解其思想就可以,包括基本思想以及 Backpressure 等思想即可。

3. Reactor的主要模組

Reactor 框架主要有兩個主要的模組:

  • reactor-core
  • reactor-ipc

前者主要負責 Reactive Programming 相關的 核心 API 的實現,後者負責 高效能網路通訊 的實現,目前是基於 Netty 實現的。

4. Reactor的核心類

Reactor 中,經常使用的類並不是很多,主要有以下兩個:

  • Mono

Mono 實現了 org.reactivestreams.Publisher 介面,代表 01 個元素的 釋出者

  • Flux

Flux 同樣實現了 org.reactivestreams.Publisher 介面,代表 0N 個元素的發表者。

  • Scheduler

代表背後驅動反應式流的排程器,通常由各種執行緒池實現。

5. WebFlux

Spring 5 引入的一個基於 Netty 而不是 Servlet 的高效能的 Web 框架 - Spring WebFlux ,但是使用方式並沒有同傳統的基於 ServletSpring MVC 有什麼大的不同。

WebFluxMVC 介面的示例:

@RequestMapping("/webflux")
@RestController
public class WebFluxTestController {
    @GetMapping("/mono")
    public Mono<Foobar> foobar() {
        return Mono.just(new Foobar());
    }
}
複製程式碼

最大的變化就是返回值從 Foobar 所表示的一個物件變為 Mono<Foobar>Flux<Foobar>

6. Reactive Streams、Reactor和WebFlux

上面介紹了 反應式程式設計 的一些概念。可能讀者看到這裡有些亂,梳理一下三者的關係:

  1. Reactive Streams 是一套反應式程式設計 標準規範
  2. Reactor 是基於 Reactive Streams 一套 反應式程式設計框架
  3. WebFluxReactor 為基礎,實現 Web 領域的 反應式程式設計框架

其實,對於業務開發人員來說,當編寫反應式程式碼時,通常只會接觸到 Publisher 這個介面,對應到 Reactor 便是 MonoFlux

對於 SubscriberSubcription 這兩個介面,Reactor 也有相應的實現。這些都是 Spring WebFluxSpring Data Reactive 這樣的框架用到的。如果 不開發中介軟體,開發人員是不會接觸到的。

Reactor入門

接下來介紹一下 ReactorMonoFlux 這兩個類中的主要方法的使用。

如同 Java 8 所引入的 Stream 一樣,Reactor 的使用方式基本上也是分三步:

  • 開始階段的建立
  • 中間階段的處理
  • 最終階段的消費

只不過建立和消費可能是通過像 Spring 5 這樣框架完成的(比如通過 WebFlux 中的 WebClient 呼叫 HTTP 介面,返回值便是一個 Mono)。但我們還是需要基本瞭解這些階段的開發方式。

1. 建立 Mono 和 Flux(開始階段)

使用 Reactor 程式設計的開始必然是先建立出 MonoFlux。有些時候不需要我們自己建立,而是實現例如 WebFlux 中的 WebClientSpring Data Reactive 得到一個 MonoFlux

  • 使用 WebFlux WebClient 呼叫 HTTP 介面
WebClient webClient = WebClient.create("http://localhost:8080");
public Mono<User> findById(Long userId) {
    return webClient
            .get()
            .uri("/users/" + userId)
            .accept(MediaType.APPLICATION_JSON)
            .exchange()
            .flatMap(cr -> cr.bodyToMono(User.class));
}
複製程式碼
  • 使用 ReactiveMongoRepository 查詢 User
public interface UserRepository extends ReactiveMongoRepository<User, Long> {
    Mono<User> findByUsername(String username);
}
複製程式碼

但有些時候,我們也需要主動地建立一個 MonoFlux

普通的建立方式

Mono<String> helloWorld = Mono.just("Hello World");
Flux<String> fewWords = Flux.just("Hello", "World");
Flux<String> manyWords = Flux.fromIterable(words);
複製程式碼

這樣的建立方式在什麼時候用呢?一般是用在經過一系列 非IO型 操作之後,得到了一個物件。接下來要基於這個物件運用 Reactor 進行 高效能IO 操作時,可以用這種方式將之前得到的物件轉換為 MonoFlux

文藝的建立方式

上面是通過一個 同步呼叫 得到的結果建立出 MonoFlux,但有時需要從一個 Reactive非同步呼叫 的結果建立出 MonoFlux

如果這個 非同步方法 返回一個 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 已經不推薦使用,這裡只是做演示。

2. 處理 Mono 和 Flux(中間階段)

中間階段的 MonoFlux 的方法主要有 filtermapflatMapthenzipreduce 等。這些方法使用方法和 Stream 中的方法類似。

下面舉幾個 Reactor 開發實際專案的問題,幫大家理解這些方法的使用場景:

問題一: map、flatMap 和 then 在什麼時候使用

本段內容將涉及到如下類和方法:

  • 方法Mono.map()
  • 方法Mono.flatMap()
  • 方法Mono.then()
  • Function

MonoFlux 中間環節的處理過程中,有三個有些類似的方法:map()flatMap()then()。這三個方法的使用頻率很高。

  • 傳統的指令式程式設計
Object result1 = doStep1(params);
Object result2 = doStep2(result1);
Object result3 = doStep3(result2);
複製程式碼
  • 對應的反應式程式設計
Mono.just(params)
    .flatMap(v -> doStep1(v))
    .flatMap(v -> doStep2(v))
    .flatMap(v -> doStep3(v));
複製程式碼

從上面兩段程式碼的對比就可以看出來 flatMap() 方法在其中起到的作用,map()then() 方法也有類似的作用。但這些方法之間的區別是什麼呢?我們先來看看這三個方法的簽名(以 Mono 為例):

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

then() 看上去是下一步的意思,但它只表示執行順序的下一步,不表示下一步依賴於上一步。then() 方法的引數只是一個 Mono,無從接受上一步的執行結果。而 flatMap()map() 的引數都是一個 Function,入參是上一步的執行結果。

flatMap() 和 map()

flatMap()map() 的區別在於,flatMap() 中的入參 Function 的返回值要求是一個 Mono 物件,而 map 的入參 Function 只要求返回一個 普通物件。在業務處理中常需要呼叫 WebClientReactiveXxxRepository 中的方法,這些方法的 返回值 都是 Mono(或 Flux)。所以要將這些呼叫串聯為一個整體 鏈式呼叫,就必須使用 flatMap(),而不是 map()

問題二:如何實現併發執行

本段內容將涉及到如下類和方法:

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

併發執行 是常見的一個需求。Reactive Programming 雖然是一種 非同步程式設計 方式,但是 非同步 不代表就是 併發並行 的。

傳統的指令式程式設計 中,併發執行 是通過 執行緒池Future 的方式實現的。

Future<Result1> result1Future = threadPoolExecutor.submit(() -> doStep1(params));
Future<Result2> result2Future = threadPoolExecutor.submit(() -> doStep2(params));
// Retrive result
Result1 result1 = result1Future.get();
Result2 result2 = result2Future.get();
// Do merge;
return mergeResult;
複製程式碼

上面的程式碼雖然實現了 非同步呼叫,但 Future.get() 方法是 阻塞 的。在使用 Reactor 開發有 併發 執行場景的 反應式程式碼 時,不能用上面的方式。

這時應該使用 MonoFlux 中的 zip() 方法,以 Mono 為例,程式碼如下:

Mono<CustomType1> item1Mono = ...;
Mono<CustomType2> item2Mono = ...;
Mono.zip(items -> {
    CustomType1 item1 = CustomType1.class.cast(items[0]);
    CustomType2 item2 = CustomType2.class.cast(items[1]);
    // Do merge
    return mergeResult;
}, item1Mono, item2Mono);
複製程式碼

上述程式碼中,產生 item1Monoitem2Mono 的過程是 並行 的。比如,呼叫一個 HTTP 介面的同時,執行一個 資料庫查詢 操作。這樣就可以加快程式的執行。

但上述程式碼存在一個問題,就是 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(item1Mono, item2Mono).map(tuple -> {
    CustomType1 item1 = tuple.getT1();
    CustomType2 item2 = tuple.getT2();
    // Do merge
    return mergeResult;
});
複製程式碼

上述程式碼中,map() 方法的引數是一個 Tuple2,表示一個 二元陣列,相應的還有 Tuple3Tuple4 等。

對於兩個元素的併發執行,也可以通過 zip(Mono<? extends T1> p1, Mono<? extends T2> p2, BiFunction<? super T1, ? super T2, ? extends O> combinator) 方法直接將結果合併。方法是傳遞 BiFunction 實現 合併演算法

問題三:集合迴圈之後的匯聚

本段內容將涉及到如下類和方法:

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

另外一個稍微複雜的場景,對一個物件中的一個型別為集合類的(ListSet)進行處理之後,再對原本的物件進行處理。使用 迭代器模式 的程式碼很容易編寫:

List<SubData> subDataList = data.getSubDataList();
for (SubData item : subDataList) {
    // Do something on data and item
}
// Do something on data
複製程式碼

當我們要用 Reactive 風格的程式碼實現上述邏輯時,就不是那麼簡單了。這裡會用到 Fluxreduce() 方法。reduce() 方法的簽名如下:

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

可以看出,reduce() 方法的功能是將一個 Flux 聚合 成一個 Mono

  • 第一個引數: 返回值 Mono 中元素的 初始值

  • 第二個引數: 是一個 BiFunction,用來實現 聚合操作 的邏輯。對於泛型引數 <A, ? super T, A> 中:

    • 第一個 A: 表示每次 聚合操作 之後的 結果的型別,它作為 BiFunction.apply() 方法的 第一個入參
    • 第二個 ? super T: 表示集合中的每個元素的型別,它作為 BiFunction.apply() 方法的 第二個入參
    • 第三個 A: 表示聚合操作的 結果,它作為 BiFunction.apply() 方法的 返回值

接下來看一下示例:

Data initData = ...;
List<SubData> list = ...;
Flux.fromIterable(list)
    .reduce(initData, (data, itemInList) -> {
        // Do something on data and itemInList
        return data;
    });
複製程式碼

上面的示例程式碼中,initDatadata 的型別相同。執行完上述程式碼之後,reduce() 方法會返回 Mono<Data>

3. 消費 Mono 和 Flux(結束階段)

直接消費的 MonoFlux 的方式就是呼叫 subscribe() 方法。如果在 WebFlux 介面中開發,直接返回 Mono 或 Flux 即可。WebFlux 框架會完成最後的 Response 輸出工作。

小結

本文介紹了反應式程式設計的一些概念和 Spring Reactor 框架的基本用法,還介紹瞭如何用 Reactor 解決一些稍微複雜一點的問題。ReactorSpring 5 中有大量的應用,後面會給大家分享一些 Spring Reactor 實戰系列的部落格。


歡迎關注技術公眾號: 零壹技術棧

零壹技術棧

本帳號將持續分享後端技術乾貨,包括虛擬機器基礎,多執行緒程式設計,高效能框架,非同步、快取和訊息中介軟體,分散式和微服務,架構學習和進階等學習資料和文章。

相關文章