前言
為了應對 高併發環境下 的服務端程式設計,微軟提出了一個實現 非同步程式設計 的方案 - Reactive Programming
,中文名稱 反應式程式設計。隨後,其它技術也迅速地跟上了腳步,像 ES6
通過 Promise
引入了類似的非同步程式設計方式。Java
社群也沒有落後很多,Netflix
和 TypeSafe
公司提供了 RxJava
和 Akka Stream
技術,讓 Java
平臺也有了能夠實現反應式程式設計的框架。
正文
函數語言程式設計
函數語言程式設計是種程式設計方式,它將計算機的運算視為函式的計算。函式程式語言最重要的基礎是 λ演算 (lambda calculus)
,而λ演算的函式可以接受函式當作 輸入(引數) 和 輸出(返回值)。lambda
表示式對與大多數程式設計師已經很熟悉了,jdk8
以及 es6
都是引入的 lambda
。
函數語言程式設計的特點
- 惰性計算
- 函式是“第一等公民”
- 只使用表示式而不使用語句
- 沒有副作用
反應式程式設計
反應式程式設計 (reactive programming)
是一種基於 資料流 (data stream)
和 變化傳遞 (propagation of change)
的 宣告式 (declarative)
的程式設計正規化。
反應式程式設計的特點
1. 事件驅動
在一個 事件驅動 的應用程式中,元件之間的互動是通過鬆耦合的 生產者 (production)
和 消費者 (consumption)
來實現的。這些事件是以 非同步 和 非阻塞 的方式傳送和接收的。
事件驅動 的系統依靠 推模式 而不是 拉模式 或 投票表決,即 生產者 是在有訊息時才推送資料給 消費者,而不是通過一種浪費資源方式:讓 消費者 不斷地 輪詢 或 等待資料。
2. 實時響應
程式發起執行以後,應該 快速 返回儲存 結果的上下文,把具體執行交給 後臺執行緒。待處理完成以後,非同步地將 真實返回值 封裝在此 上下文 中,而不是 阻塞 程式的執行。實時響應是通過 非同步 程式設計實現的,例如:發起呼叫後,快速返回類似 java8
中 CompletableFuture
物件。
3. 彈性機制
事件驅動的 鬆散耦合 提供了元件在失敗下,可以抓獲 完全隔離 的上下文場景,作為 訊息封裝,傳送到下游元件。在具體程式設計時可以 檢查錯誤 ,比如:是否接收到,接收的命令是否可執行等,並決定如何應對。
Reactor簡介
Reactor
框架是 Pivotal
基於 Reactive Programming
思想實現的。它符合 Reactive Streams
規範 (Reactive Streams
是由 Netflix
、TypeSafe
、Pivotal
等公司發起的) 的一項技術。其名字有 反應堆 之意,反映了其背後的強大的 效能。
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
的使用方式。它其實是 觀察者模式 的一種延伸。
如果將 迭代器模式 看作是 拉模式,那 觀察者模式 便是 推模式。
-
被訂閱者
(Publisher)
主動推送資料給 訂閱者(Subscriber)
,觸發onNext()
方法。異常和完成時觸發另外兩個方法。 -
被訂閱者
(Publisher)
發生異常,則觸發 訂閱者(Subscriber)
的onError()
方法進行異常捕獲處理。 -
被訂閱者
(Publisher)
每次推送都會觸發一次onNext()
方法。所有的推送完成且無異常時,onCompleted()
方法將 在最後 觸發一次。
如果 Publisher
釋出訊息太快了,超過了 Subscriber
的處理速度,那怎麼辦?這就是 Backpressure
的由來。Reactive Programming
框架需要提供 背壓機制,使得 Subscriber
能夠控制 消費訊息 的速度。
2. Reactive Streams
在 Java
平臺上,Netflix
(開發了 RxJava
)、TypeSafe
(開發了 Scala
、Akka
)、Pivatol
(開發了 Spring
、Reactor
)共同制定了一個被稱為 Reactive Streams
專案(規範),用於制定反應式程式設計相關的規範以及介面。
Reactive Streams
由以下幾個元件組成:
- 釋出者:釋出元素到訂閱者
- 訂閱者:消費元素
- 訂閱:在釋出者中,訂閱被建立時,將與訂閱者共享
- 處理器:釋出者與訂閱者之間處理資料
其主要的介面有這三個:
- Publisher
- Subscriber
- Subcription
其中,Subcriber
中便包含了上面表格提到的 onNext
、onError
、onCompleted
這三個方法。對於 Reactive Streams
,只需要理解其思想就可以,包括基本思想以及 Backpressure
等思想即可。
3. Reactor的主要模組
Reactor
框架主要有兩個主要的模組:
- reactor-core
- reactor-ipc
前者主要負責 Reactive Programming
相關的 核心 API
的實現,後者負責 高效能網路通訊 的實現,目前是基於 Netty
實現的。
4. Reactor的核心類
在 Reactor
中,經常使用的類並不是很多,主要有以下兩個:
- Mono
Mono
實現了 org.reactivestreams.Publisher
介面,代表 0
到 1
個元素的 釋出者。
- Flux
Flux
同樣實現了 org.reactivestreams.Publisher
介面,代表 0
到 N
個元素的發表者。
- Scheduler
代表背後驅動反應式流的排程器,通常由各種執行緒池實現。
5. WebFlux
Spring 5
引入的一個基於 Netty
而不是 Servlet
的高效能的 Web
框架 - Spring WebFlux
,但是使用方式並沒有同傳統的基於 Servlet
的 Spring MVC
有什麼大的不同。
WebFlux
中 MVC
介面的示例:
@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
上面介紹了 反應式程式設計 的一些概念。可能讀者看到這裡有些亂,梳理一下三者的關係:
Reactive Streams
是一套反應式程式設計 標準 和 規範;Reactor
是基於Reactive Streams
一套 反應式程式設計框架;WebFlux
以Reactor
為基礎,實現Web
領域的 反應式程式設計框架。
其實,對於業務開發人員來說,當編寫反應式程式碼時,通常只會接觸到 Publisher
這個介面,對應到 Reactor
便是 Mono
和 Flux
。
對於 Subscriber
和 Subcription
這兩個介面,Reactor
也有相應的實現。這些都是 Spring WebFlux
和 Spring Data Reactive
這樣的框架用到的。如果 不開發中介軟體,開發人員是不會接觸到的。
Reactor入門
接下來介紹一下 Reactor
中 Mono
和 Flux
這兩個類中的主要方法的使用。
如同 Java 8
所引入的 Stream
一樣,Reactor
的使用方式基本上也是分三步:
- 開始階段的建立
- 中間階段的處理
- 最終階段的消費
只不過建立和消費可能是通過像 Spring 5
這樣框架完成的(比如通過 WebFlux
中的 WebClient
呼叫 HTTP
介面,返回值便是一個 Mono
)。但我們還是需要基本瞭解這些階段的開發方式。
1. 建立 Mono 和 Flux(開始階段)
使用 Reactor
程式設計的開始必然是先建立出 Mono
或 Flux
。有些時候不需要我們自己建立,而是實現例如 WebFlux
中的 WebClient
或 Spring Data Reactive
得到一個 Mono
或 Flux
。
- 使用 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);
}
複製程式碼
但有些時候,我們也需要主動地建立一個 Mono
或 Flux
。
普通的建立方式
Mono<String> helloWorld = Mono.just("Hello World");
Flux<String> fewWords = Flux.just("Hello", "World");
Flux<String> manyWords = Flux.fromIterable(words);
複製程式碼
這樣的建立方式在什麼時候用呢?一般是用在經過一系列 非IO型 操作之後,得到了一個物件。接下來要基於這個物件運用 Reactor
進行 高效能 的 IO
操作時,可以用這種方式將之前得到的物件轉換為 Mono
或 Flux
。
文藝的建立方式
上面是通過一個 同步呼叫 得到的結果建立出 Mono
或 Flux
,但有時需要從一個 非 Reactive
的 非同步呼叫 的結果建立出 Mono
或 Flux
。
如果這個 非同步方法 返回一個 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(中間階段)
中間階段的 Mono
和 Flux
的方法主要有 filter
、map
、flatMap
、then
、zip
、reduce
等。這些方法使用方法和 Stream
中的方法類似。
下面舉幾個 Reactor
開發實際專案的問題,幫大家理解這些方法的使用場景:
問題一: map、flatMap 和 then 在什麼時候使用
本段內容將涉及到如下類和方法:
- 方法:
Mono.map()
- 方法:
Mono.flatMap()
- 方法:
Mono.then()
- 類:
Function
在 Mono
和 Flux
中間環節的處理過程中,有三個有些類似的方法: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
只要求返回一個 普通物件。在業務處理中常需要呼叫 WebClient
或 ReactiveXxxRepository
中的方法,這些方法的 返回值 都是 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
開發有 併發 執行場景的 反應式程式碼 時,不能用上面的方式。
這時應該使用 Mono
和 Flux
中的 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);
複製程式碼
上述程式碼中,產生 item1Mono
和 item2Mono
的過程是 並行 的。比如,呼叫一個 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
,表示一個 二元陣列,相應的還有 Tuple3
、Tuple4
等。
對於兩個元素的併發執行,也可以通過 zip(Mono<? extends T1> p1, Mono<? extends T2> p2, BiFunction<? super T1, ? super T2, ? extends O> combinator)
方法直接將結果合併。方法是傳遞 BiFunction
實現 合併演算法。
問題三:集合迴圈之後的匯聚
本段內容將涉及到如下類和方法:
- 方法:
Flux.fromIterable()
- 方法:
Flux.reduce()
- 類:
BiFunction
另外一個稍微複雜的場景,對一個物件中的一個型別為集合類的(List
、Set
)進行處理之後,再對原本的物件進行處理。使用 迭代器模式 的程式碼很容易編寫:
List<SubData> subDataList = data.getSubDataList();
for (SubData item : subDataList) {
// Do something on data and item
}
// Do something on data
複製程式碼
當我們要用 Reactive
風格的程式碼實現上述邏輯時,就不是那麼簡單了。這裡會用到 Flux
的 reduce()
方法。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;
});
複製程式碼
上面的示例程式碼中,initData
和 data
的型別相同。執行完上述程式碼之後,reduce()
方法會返回 Mono<Data>
。
3. 消費 Mono 和 Flux(結束階段)
直接消費的 Mono
或 Flux
的方式就是呼叫 subscribe()
方法。如果在 WebFlux
介面中開發,直接返回 Mono
或 Flux 即可。WebFlux
框架會完成最後的 Response
輸出工作。
小結
本文介紹了反應式程式設計的一些概念和 Spring Reactor
框架的基本用法,還介紹瞭如何用 Reactor
解決一些稍微複雜一點的問題。Reactor
在 Spring 5
中有大量的應用,後面會給大家分享一些 Spring Reactor
實戰系列的部落格。
歡迎關注技術公眾號: 零壹技術棧
本帳號將持續分享後端技術乾貨,包括虛擬機器基礎,多執行緒程式設計,高效能框架,非同步、快取和訊息中介軟體,分散式和微服務,架構學習和進階等學習資料和文章。