使用反應式程式設計替換Java自動資源管理 - Arvind

banq發表於2021-11-19

自動資源管理(Automatic resource management 簡稱ARM)在 Java 7 中首次引入時是一個受歡迎的特性,也就是通常說的無需finally的try()用法。

然後ARM 繼續以意想不到的方式汙染程式碼。這樣做的一個重要原因是try塊的結尾代表了需要釋放資源的時刻,該塊的結束也代表異常處理程式、區域性變數作用域等的結束。

因此,雖然 ARM 確保可靠地釋放事物,但它在資源有資格被釋放時失去了一些控制權. 試圖改變釋出的時間會干擾整個程式碼流。

以下是 ARM 如何引入非預期程式碼塊的示例。

try (val client = HttpClientBuilder.create().build()) {
    HttpGet request = new HttpGet(urlPath);
    request.setHeader("content-type", "application/json");
    try (val httpResponse = client.execute(request)) {
        if (httpResponse.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
            String entityString = EntityUtils.toString(httpResponse.getEntity());
            PaginatedResponse<City> siloResponse =
                    gson.fromJson(entityString, new TypeToken<PaginatedResponse<City>>(){}.getType());
            cities.addAll(siloResponse.getResults());
            if (siloResponse.getNext() != null) {
                recursiveRead(cities, siloResponse.getNext());
            } else {
                log.info("Fetched:", cities.size());
            }
        } else {
            log.error("Failed to get Cities");
        }
    }
}

請注意,物件client和httpResponse物件都在 A​​RM 下。這引入的縮排和變數作用域實際上是一種眼中釘並損害了易用性。這讓我想到了下一點……

 

有一個非常微妙的效能錯誤

注意以下幾點

  • 頁面的遍歷使用遞迴實現
  • 僅當方法本身完成時,託管資源如client和httpResponse才有資格被釋放

這意味著如果一個呼叫跨越 200 個頁面,那麼有 200 個client物件同時處於活動狀態,並且在整個結果集被迭代之前它們沒有資格被釋放。事實證明,這個模組嵌入在一個微服務中,可以擴充套件到超過一百個例項。

所以,如果有一個查詢確實返回了 200 頁的結果,這段程式碼就會在遠端伺服器上建立一個 200 倍的網路放大攻擊!

即使在命令式世界中,也可以直接解決此問題。困難的部分是首先知道存在問題。那麼這在反應式世界中是如何實現的呢?

Mono<PaginatedResponse<City>> siloResponse = WebClient.create().get()
    .uri(urlPath)
    .accept(MediaType.APPLICATION_JSON)
    .retrieve()
    .bodyToMono(new ParameterizedTypeReference<PaginatedResponse<City>>() {
    });

這是使用流暢的功能風格不僅僅是語法糖的部分。Spring webclient 的建立者做了一些聰明的事情,其中bodyToMono充當隱式訊號,表示您已完成與該 HTTP 請求相關的所有網路互動,並且我們已完成根據需要提取響應。

程式碼:https://github.com/anomalizer/rx-stream-blog

 

取消檢查異常和異常型別

多年來,人們普遍認為 Java 中最大的語言設計錯誤之一是檢查異常的概念。

幾十年前,錯誤在軟體世界中很少見。原因是多方面的,與本次討論無關。相關的是,當時的程式設計師在執行理論上可能出錯的操作時不會檢查潛在的錯誤。隨著時間的推移,程式遇到錯誤的機率增加,並且選擇忽略錯誤導致難以在應用程式級別除錯問題。Java 的第一個版本是在那個時代設計的,因此,語言設計者希望提供一種能力,其中任何庫/模組的建立者都能夠傳達預期的錯誤,並且這些庫/模組的呼叫者更好地準備處理他們。檢查異常是強制這種行為的好方法。

當前圍繞檢查異常的遺憾反映了當前時代的問題。首先,我們生活在一個“萬事皆失敗”的時代。其次,任何現代應用程式堆疊都包含來自多個開源專案、多個商業供應商以及可能來自多個內部團隊的庫。最終應用程式層中的錯誤處理邏輯通常僅限於簡單區分成功條件與出現問題的情況。很少用一種程式碼來破譯異常型別、解開其中的欄位等,並且對錯誤進行復雜的條件處理。

Java 中的函數語言程式設計不允許存在已檢查的異常,而反應式程式設計在某種程度上鼓勵將編碼模式限制為 aonSuccess()和onError()處理程式。這在非同步處理的世界中成為絕對必須的,因為異常會發生在呼叫堆疊(即執行緒)上,而呼叫堆疊(即執行緒)與正在使用結果的呼叫堆疊完全不同。在檢查很久以前完成的操作的結果時引發異常會非常奇怪。

 

優雅的取消協作:殺手級功能

微服務世界中使用的一種近乎通用的構造是能夠“超時”未完成的操作並繼續前進。這些超時是跨程式內庫邊界和程式間邊界實現的。我們將通過考慮分頁需要遍歷 100 頁才能產生最終結果並且“呼叫者”在遍歷 2 頁後宣佈超時的情況來過度誇大超時模式。

至少需要兩個執行緒才能在命令式方面發生這種情況。第一個執行緒是執行庫的給定呼叫的地方,第二個執行緒是執行呼叫者的地方;等待計時器關閉或返回結果。當計時器響起時,呼叫者繼續做其他事情。如果您想知道如何實現這一點,請考慮Future. 然而,其中fetchCity() 方法正在執行的第一個執行緒沒有注意到呼叫者已經離開的事實。相反,它將繼續遍歷剩餘的 98 頁,組合結果並返回。

反應式版本隱式地理解超時並在儘可能早的時間停止未來的處理。這可能根本不明顯,所以讓我解釋一下這是如何發生的。為此,我們需要考慮觸發超時時程式碼可能處於的所有可能的執行狀態

Mono<PaginatedResponse<City>> siloResponse = WebClient.create().get()
        .uri(urlPath)
        .accept(MediaType.APPLICATION_JSON)
        .retrieve()
        .bodyToMono(new ParameterizedTypeReference<PaginatedResponse<City>>() {
        });
Flux<City> currentPage = siloResponse.map(PaginatedResponse::getResults).flatMapMany(Flux::fromIterable);
Flux<City> nextPage = siloResponse.map(PaginatedResponse::getNext)
        .filter(x -> !Strings.isNullOrEmpty(x))
        .flatMapMany(ReactiveApproach::recursiveRead2);
return Flux.concat(currentPage, nextPage);

取消流程需要從最後一行開始向後處理,我們將這樣做。

  • concat 方法將根據它接下來要處理的一個currentPage或一個發出取消nextPage
  • 如果currentPage正在執行,它將向siloResponseI/O 呼叫或map/flayMayMap呼叫發出取消,具體取決於它是哪個階段
  • 如果nextPage正在執行,則它正在等待遞迴呼叫的結果,即flatMapMany(ReactiveApproach::recursiveRead2)。取消被簡單地推入這個巢狀呼叫中。取消的處理隨後在巢狀呼叫中遵循相同的整體流程。
  • 如果siloResponse正在執行,Spring Boot 的WebClient(siloResponse 物件)將中止底層 HTTP 請求/響應處理。

之所以所有這一切都是可能的,是因為反應模型是基於拉動的模型。呼叫者可以通過各種機制發出訊號,表明它不再對結果感興趣。設定超時就是這樣一種機制。一旦發出這個“不感興趣”的訊號,反應器就會知道結果沒有消費者/訂閱者,因此它會停止整個流程。

試圖在命令式模型中完成這樣的事情是非常具有侵入性的。有兩種方法可以解決這個問題:使用執行緒中斷或顯式傳遞取消訊息持有者物件。然後必須if(notCancelled/notInterrupted){}在每個階段對程式碼進行檢查,以防止浪費計算。

 

相關文章