反應性和非反應性程式碼的分離 - DZone

banq發表於2021-11-10

避免在使用 Project Reactor 時因混合反應性和非反應性邏輯而導致的意外行為。

在使用 Project Reactor 或任何其他反應式流實現時要記住的最重要區別之一是程式碼執行中組裝assembly時間與訂閱時間之間的區別:

換句話說,反應式釋出者(Flux和Mono)是惰性的,因此在有人訂閱之前不會發布或處理任何元素。

瞭解這種區別至關重要,因為在編寫實際應用程式時,我們希望所有(或大部分)業務邏輯在訂閱時執行。在這篇文章中,我們將展示不遵守此規則會出現什麼樣的問題以及如何緩解這些問題。

 

租車服務示例

為了舉例說明這一點,我們將使用一個非常簡單的虛擬汽車租賃服務實現。該服務接受包含客戶姓名、年齡和電子郵件地址以及汽車型號的輸入。它首先檢查客戶是否年滿 18 歲(因此在法律上允許租車),然後將租賃請求儲存到資料庫中,最後生成 PDF 收據並將其通過電子郵件傳送給客戶。

該流程由以下rentCar方法實現:

private static Mono<UUID> rentCar(CarRentalRequest request) {
    if (request.getCustomerAge() > 18) {
        UUID rentalId = UUID.randomUUID(); // Generate an ID for the new rental
        return saveCarRental(rentalId, request) // Save the rental entity to the database
            .then(buildAndSendPdfReceipt(rentalId, request)) // Generate and send PDF report
            .then(Mono.just(rentalId)); // Return the ID of the new rental
    } else {
        return Mono.error(new RuntimeException("Must be 18 to rent a car"));
    }
}

private static Mono<Void> buildAndSendPdfReceipt(UUID rentalId, CarRentalRequest carRentalRequest) {
    byte[] pdfReceipt = buildPdfReceipt(rentalId, carRentalRequest);
    return sendPdfReceipt(pdfReceipt, carRentalRequest.getCustomerEmail());
}

然後我們可以呼叫這個方法來建立釋出者。此外,我們希望確保將工作委託給單獨的排程程式,以便主執行緒可以繼續處理其他請求。我們可以使用subscribeOn操作符來實現這一點(它改變了整個管道的執行上下文,包括上文和下文,因此頂級釋出者將通過Scheduler生成集合上的元素)。最後,我們提供了一個訂閱者,它定義了成功和錯誤響應時要執行的邏輯(subscribe()分別是方法中的兩個 lambda 引數)。

下面我們提供了一個訂閱者,它定義了成功和錯誤響應時要執行的邏輯(subscribe()分別是方法中的兩個 lambda 引數)。

CarRentalRequest request = new CarRentalRequest("Alice", 30, "Hyundai i30", "alice@mail.com");

rentCar(request)
    .subscribeOn(Schedulers.boundedElastic())
    .subscribe(s -> log.info("Car rented successfully, rental ID: {}", s), 
        e -> log.error("Could not rent car: {}", e.getMessage(), e));

 

陷阱 1:不正確的執行上下文

通過仔細觀察第一段程式碼中buildAndSendPdfReceipt方法,人們很容易猜到buildPdfReceipt是一種同步的、非響應式的方法:它不返回任何響應式型別。

但是,如果我們執行這個例子,我們會得到以下輸出:

21:25:38.961 [main] INFO com.reactordemo.carrental.CarRentalService - Build PDF receipt
21:25:38.986 [boundedElastic-1] INFO com.reactordemo.carrental.CarRentalService - Car rented successfully, rental ID: d5b689dd-fa91-486c-b835-44bc2583d53a

如果我們注意顯示每個語句的當前執行緒的日誌部分(在方括號中),我們會注意到訂閱者邏輯在方括號boundedElastic-1中的執行緒上正確執行 ;然而,建立PDF的工作似乎是在main執行緒上執行的!那麼為什麼會這樣呢?

答案在於上述組裝assembly和訂閱之間的區別。

我們再來看看這個buildAndSendPdfReceipt方法:

private static Mono<Void> buildAndSendPdfReceipt(UUID rentalId, CarRentalRequest carRentalRequest) {
    byte[] pdfReceipt = buildPdfReceipt(rentalId, carRentalRequest);
    return sendPdfReceipt(pdfReceipt, carRentalRequest.getCustomerEmail());
}

執行此方法時,我們只需要組裝反應式管道,即以宣告方式定義要執行的步驟以建立 PDF 報告。在這個階段,我們不應該做生成這個報告的實際工作,這只是在有人訂閱這個釋出者時才會發生。

不幸的是,這裡的情況並非如此 :呼叫buildPdfReceipt是在這個方法的主體中進行的。這樣做的非常不幸的後果之一是我們在上面看到的不正確的執行上下文:

整個管道在main執行緒上組裝assembly,而釋出的元素在boundedElastic排程程式上處理。

解決此問題的一種方法是使用fromCallable,以下方法:

private static Mono<Void> buildAndSendPdfReceipt(UUID rentalId, CarRentalRequest carRentalRequest) {
    return Mono.fromCallable(() -> buildPdfReceipt(rentalId, carRentalRequest))
            .flatMap(pdfReceipt -> sendPdfReceipt(pdfReceipt, carRentalRequest.getCustomerEmail()));

正如我們所知,釋出者只會在有人訂閱時(即在訂閱時)開始生成元素,因此buildPdfReceipt現在在所需的排程程式上將呼叫作為整個管道的一部分進行。事實上,再次執行應用程式會產生以下結果:

21:54:49.955 [boundedElastic-1] INFO com.reactordemo.carrental.CarRentalService - Build PDF receipt
21:54:49.956 [boundedElastic-1] INFO com.reactordemo.carrental.CarRentalService - Car rented successfully, rental ID: a3bb873e-4943-407a-967f-9fa1c1d0d235

在許多複雜的現實生活應用程式中,這種問題很難發現。避免它們的一種好方法是確保反應式方法(即組裝管道的方法,通常具有反應式返回型別)不直接呼叫非反應式方法。相反,它們應該只組裝反應管道,優選地在單一的流利語句,以及非反應性的方法的所有呼叫應該從反應性運算子(內部進行fromCallable,fromRunnable,map,filter,等等)。

 

陷阱 2:不正確的異常處理

在設計和實現任何型別的應用程式時,我們總是希望通過嘗試恢復或以其他方式向使用者顯示正確的錯誤訊息來確保我們可以優雅地處理錯誤。在我們簡單的汽車租賃服務中,我們建立了一個帶有錯誤處理程式 lambda 的訂閱者,用於記錄上游的錯誤。預期管道中任何地方可能發生的任何錯誤都將導致描述問題的日誌語句。

為了測試這一點,讓我們考慮以下輸入:

CarRentalRequest request = new CarRentalRequest("Bob", null, "Hyundai i30", "bob@mail.com")

請注意,在這種情況下,客戶的年齡被錯誤地設定為null。即便如此,我們希望這可能導致的任何錯誤都將被正確攔截和記錄。不幸的是,現在執行此程式碼會產生以下輸出:

Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because the return value of "com.reactordemo.carrental.CarRentalService$CarRentalRequest.getCustomerAge()" is null
    at com.reactordemo.carrental.CarRentalService.rentCar(CarRentalService.java:27)
    at com.reactordemo.carrental.CarRentalService.entryPoint(CarRentalService.java:19)
    at com.reactordemo.carrental.ReactorDemoApplication.main(ReactorDemoApplication.java:10)

這表明我們的無效輸入產生了一個在任何地方都沒有被捕獲的 NPE。但為什麼?為什麼沒有為這個異常呼叫我們的錯誤處理程式?為了理解這一點,讓我們再看看我們的主要反應管道:

private static Mono<UUID> rentCar(CarRentalRequest request) {
    if (request.getCustomerAge() > 18) {
        UUID rentalId = UUID.randomUUID();
        return saveCarRental(rentalId, request)
            .then(buildAndSendPdfReceipt(rentalId, request))
            .then(Mono.just(rentalId));
    } else {
        return Mono.error(new RuntimeException("Must be 18 to rent a car"));
    }
}

很明顯異常發生在if語句的條件中,我們檢查年齡是否大於 18。但請注意,作為管道執行的一部分,此檢查不會正確發生。相反,檢查是作為組裝管道的一部分進行的。因此,這裡發生的任何錯誤都不會被視為處理管道中的元素失敗,而是組裝管道失敗。再一次,這個問題可以通過簡單地定義所有特定於反應管道內元素處理(包括檢查)的邏輯來避免。

private static Mono<UUID> rentCar(CarRentalRequest request) {
    return Mono.just(request)
        .<CarRentalRequest>handle((req, sink) -> {
            if (req.getCustomerAge() > 18) {
                sink.next(req);
            } else {
                sink.error(new RuntimeException("Must be 18 to rent a car"));
            }
        })
        .flatMap(req -> {
            UUID rentalId = UUID.randomUUID();
            return saveCarRental(rentalId, req)
                    .then(buildAndSendPdfReceipt(rentalId, req))
                    .then(Mono.just(rentalId));
        });
}

在最初的實現中,有兩個功能與處理在組裝時執行的請求相關:年齡檢查和 ID 生成。我們現在已經將它們分別移到管道中,分別在handle和flatMap運算子中。應用此修復程式後,執行會產生以下輸出:

12:48:46.627 [boundedElastic-1] ERROR com.reactordemo.carrental.CarRentalService - Could not rent car: Cannot invoke "java.lang.Integer.intValue()" because the return value of "com.reactordemo.carrental.CarRentalService$CarRentalRequest.getCustomerAge()" is null
java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because the return value of "com.reactordemo.carrental.CarRentalService$CarRentalRequest.getCustomerAge()" is null
    at com.reactordemo.carrental.CarRentalService.lambda$rentCarFixed$2(CarRentalService.java:40)

當然,丟擲 NPE 而不是驗證輸入併產生更有意義的錯誤是不理想的。儘管如此,我們仍然可以看到異常現在在管道內的訂閱時丟擲,這意味著它最終會被我們的錯誤處理程式捕獲,正如預期的那樣。

 

結論

在這篇博文中,我們分析了兩種情況,其中不正確地分離彙編時間和訂閱時間邏輯會導致我們的應用程式出現不良行為。為了減輕此類問題和其他問題,我們建議明確分離如下:

  • 作為反應式方法(組裝反應式管道的方法,即具有反應式返回型別)的一部分,避免執行除嚴格構建管道之外的任務。
  • 此類方法的一個好的做法是確保它們只組裝和返回反應式管道,最好在單個流利風格的語句中。
  • 任何重要的邏輯(通常與元素處理有關,而不是組裝管道),例如輸入驗證或對映、對其他同步方法的呼叫等,都應作為管道的一部分執行。這可以使用大量運算子來實現,本文舉例說明了其中的幾個,例如handle, flatMap, fromCallable。

 

相關文章