使用 WebClient 執行同步請求
一、簡介
在本教程中,我們將學習如何使用WebClient
執行同步請求。
雖然反應式程式設計繼續變得更加普遍,但我們將研究此類阻塞請求仍然適當且必要的場景。
2.Spring中HTTP客戶端庫概述
我們首先簡要回顧一下目前可用的客戶端程式庫,看看我們的WebClient
適合什麼地方。
當 Spring Framework 3.0 中引入時, RestTemplate
因其用於 HTTP 請求的簡單模板方法 API 而變得流行。然而,其同步特性和許多重載方法導致高流量應用程式的複雜性和效能瓶頸。
在 Spring 5.0 中,引入了WebClient
,作為非阻塞請求的更有效率、反應式的替代方案。儘管它是反應式堆疊 Web 框架的一部分,但它支援用於同步和非同步通訊的流暢 API。
在 Spring Framework 6.1 中, RestClient
提供了另一個執行 REST 呼叫的選項。 它將WebClient
的流暢 API 與RestTemplate
的基礎設施結合在一起,包括訊息轉換器、請求工廠和攔截器。
雖然RestClient
針對同步請求進行了最佳化,但如果我們的應用程式也需要非同步或串流處理功能,則WebClient
會更好。使用WebClient
進行阻塞和非阻塞 API 呼叫,我們可以保持程式碼庫的一致性,並避免混合不同的客戶端程式庫。
3. 阻塞與非阻塞 API 呼叫
在討論各種 HTTP 用戶端時,我們使用了同步和非同步、阻塞和非阻塞等術語。這些術語是上下文相關的,有時可能代表同一想法的不同名稱。
在方法呼叫的上下文中, WebClient
會根據其傳送和接收 HTTP 請求和回應的方式支援同步和非同步互動。如果它等待前一個請求完成後再繼續處理後續請求,那麼它會以阻塞方式執行此操作,並且結果會同步返回。
另一方面,我們可以透過執行立即傳回的非阻塞呼叫來實現非同步互動。在等待另一個系統的回應時,可以繼續其他處理,一旦準備好就非同步提供結果。
4. 何時使用同步請求
如前所述, WebClient
是 Spring Webflux
框架的一部分,預設情況下,該框架中的所有內容都是響應式的。但是,該庫提供非同步和同步操作支持,使其適合反應式和 servlet 堆疊 Web 應用程式。
當需要立即回饋時(例如在測試或原型設計期間),以阻塞方式使用WebClient
是合適的。這種方法使我們能夠在考慮效能優化之前先專注於功能。
許多現有應用程式仍然使用像RestTemplate
這樣的阻塞客戶端。由於RestTemplate
從 Spring 5.0 開始就處於維護模式,因此重構遺留程式碼庫將需要更新依賴項,並可能需要過渡到非阻塞架構。在這種情況下,我們可以暫時以阻塞的方式使用WebClient
。
即使在新專案中,某些應用程式部分也可以設計為同步工作流程。這可以包括諸如對各種外部系統的順序 API 呼叫之類的場景,其中一個呼叫的結果對於進行下一個呼叫是必要的。 WebClient 可以處理阻塞和非阻塞調用,而不用使用不同的客戶端。
正如我們稍後將看到的,同步和非同步執行之間的切換相對簡單。只要有可能,我們就應該避免使用阻塞調用,特別是當我們使用反應式堆疊時。
5. 使用WebClient同步API調用
當傳送 HTTP 請求時, WebClient
從 Reactor Core 庫傳回兩種反應式資料類型之一 - Mono
或Flux
。這些傳回類型表示資料流,其中Mono
對應於單一值或空結果,而Flux
指的是零個或多個值的流。擁有非同步和非阻塞的 API 可以讓呼叫者決定何時以及如何訂閱,從而保持程式碼的反應性。
但是,如果我們想模擬同步行為,我們可以呼叫可用的block()
方法。它將阻止當前操作以獲取結果。
更準確地說, block()
方法觸發對反應流的新訂閱,啟動從來源到消費者的資料流。在內部,它使用CountDownLatch
等待流完成,這會暫停當前線程,直到操作完成,即直到Mono
或Flux
發出結果。 block()
方法將非阻塞操作轉換為傳統的阻塞操作,導致呼叫執行緒等待結果。
6. 實際例子
讓我們看看實際情況。想像一下客戶端應用程式和兩個後端應用程式(客戶和計費系統)之間有一個簡單的 API 網關應用程式。第一個保存客戶信息,第二個提供帳單詳細資訊。不同的客戶端透過北向 API 與我們的 API 網關交互,該 API 是向客戶端公開的用於檢索客戶資訊(包括其帳單詳細資訊)的介面:
@GetMapping("/{id}")
CustomerInfo getCustomerInfo(@PathVariable("id") Long customerId) {
return customerInfoService.getCustomerInfo(customerId);
}
模型類別如下圖所示:
public class CustomerInfo {
private Long customerId;
private String customerName;
private Double balance;
// standard getters and setters
}
API 閘道透過提供與客戶和計費應用程式進行內部通訊的單一端點來簡化流程。然後它會聚合來自兩個系統的資料。
考慮我們在整個系統中使用同步 API 的場景。但是,我們最近升級了客戶和計費系統以處理非同步和非阻塞操作。現在讓我們來看看這兩個南向 API 是什麼樣子的。
客戶API:
@GetMapping("/{id}")
Mono<Customer> getCustomer(@PathVariable("id") Long customerId) throws InterruptedException {
TimeUnit.SECONDS.sleep(SLEEP_DURATION.getSeconds());
return Mono.just(customerService.getBy(customerId));
}
計費API:
@GetMapping("/{id}")
Mono<Billing> getBilling(@PathVariable("id") Long customerId) throws InterruptedException {
TimeUnit.SECONDS.sleep(SLEEP_DURATION.getSeconds());
return Mono.just(billingService.getBy(customerId));
}
在現實場景中,這些 API 將是單獨元件的一部分。但是,為了簡單起見,我們將它們組織到程式碼中的不同套件中。此外,為了測試,我們引入了延遲來模擬網路延遲:
public static final Duration SLEEP_DURATION = Duration.ofSeconds(2);
與兩個後端系統不同,我們的 API 閘道應用程式必須公開同步、阻塞的 API,以避免破壞客戶端合約。因此,那裡沒有任何變化。
業務邏輯駐留在CustomerInfoService
中。首先,我們將使用WebClient
從客戶系統檢索資料:
Customer customer = webClient.get()
.uri(uriBuilder -> uriBuilder.path(CustomerController.PATH_CUSTOMER)
.pathSegment(String.valueOf(customerId))
.build())
.retrieve()
.onStatus(status -> status.is5xxServerError() || status.is4xxClientError(), response -> response.bodyToMono(String.class)
.map(ApiGatewayException::new))
.bodyToMono(Customer.class)
.block();
接下來是計費系統:
Billing billing = webClient.get()
.uri(uriBuilder -> uriBuilder.path(BillingController.PATH_BILLING)
.pathSegment(String.valueOf(customerId))
.build())
.retrieve()
.onStatus(status -> status.is5xxServerError() || status.is4xxClientError(), response -> response.bodyToMono(String.class)
.map(ApiGatewayException::new))
.bodyToMono(Billing.class)
.block();
最後,使用兩個元件的回應,我們將建立一個回應:
new CustomerInfo(customer.getId(), customer.getName(), billing.getBalance());
如果其中一個 API 呼叫失敗, onStatus()
方法內定義的錯誤處理會將 HTTP 錯誤狀態對應到ApiGatewayException
。在這裡,我們使用傳統方法,而不是透過Mono.error()
方法進行響應式替代。由於我們的客戶期望同步 API,因此我們拋出會傳播到呼叫者的異常。
儘管客戶和計費系統具有非同步性質,但WebClient
的block()
方法使我們能夠聚合來自兩個來源的資料並將組合結果透明地傳回給我們的客戶。
6.1.優化多個API調用
此外,由於我們對不同的系統進行兩次連續調用,因此我們可以透過避免單獨阻止每個響應來優化流程。我們可以執行以下操作:
private CustomerInfo getCustomerInfoBlockCombined(Long customerId) {
Mono<Customer> customerMono = webClient.get()
.uri(uriBuilder -> uriBuilder.path(CustomerController.PATH_CUSTOMER)
.pathSegment(String.valueOf(customerId))
.build())
.retrieve()
.onStatus(status -> status.is5xxServerError() || status.is4xxClientError(), response -> response.bodyToMono(String.class)
.map(ApiGatewayException::new))
.bodyToMono(Customer.class);
Mono<Billing> billingMono = webClient.get()
.uri(uriBuilder -> uriBuilder.path(BillingController.PATH_BILLING)
.pathSegment(String.valueOf(customerId))
.build())
.retrieve()
.onStatus(status -> status.is5xxServerError() || status.is4xxClientError(), response -> response.bodyToMono(String.class)
.map(ApiGatewayException::new))
.bodyToMono(Billing.class);
return Mono.zip(customerMono, billingMono, (customer, billing) -> new CustomerInfo(customer.getId(), customer.getName(), billing.getBalance()))
.block();
}
zip()
是一種將多個Mono
實例組合成一個Mono
方法。當所有給定的Mono
都產生了它們的值時,一個新的Mono
就完成了,然後根據指定的函數聚合這些值 - 在我們的例子中,建立一個CustomerInfo
物件。這種方法更有效,因為它允許我們同時等待兩個服務的組合結果。
為了驗證我們是否提高了效能,讓我們在兩種情況下執行測試:
@Autowired
private WebTestClient testClient;
@Test
void givenApiGatewayClient_whenBlockingCall_thenResponseReceivedWithinDefinedTimeout() {
Long customerId = 10L;
assertTimeout(Duration.ofSeconds(CustomerController.SLEEP_DURATION.getSeconds() + BillingController.SLEEP_DURATION.getSeconds()), () -> {
testClient.get()
.uri(uriBuilder -> uriBuilder.path(ApiGatewayController.PATH_CUSTOMER_INFO)
.pathSegment(String.valueOf(customerId))
.build())
.exchange()
.expectStatus()
.isOk();
});
}
最初,測試失敗了。但是,在切換到等待合併結果後,測試會在客戶和計費系統呼叫的合併持續時間內完成。這表明我們透過聚合兩個服務的回應來提高了效能。即使我們使用阻塞同步方法,我們仍然可以遵循最佳實踐來優化效能。這有助於確保系統保持高效可靠。
七、結論
在本教程中,我們示範如何使用WebClient
管理同步通信,WebClient 是一種專為反應式程式設計但能夠進行阻塞呼叫的工具。
總而言之,我們討論了使用WebClient
相對於RestClient
等其他函式庫的優勢,尤其是在反應式堆疊中,以保持一致性並避免混合不同的客戶端程式庫。最後,我們探索了透過聚合來自多個服務的回應而不阻塞每個呼叫來優化效能。
與往常一樣,完整的源代碼可以在 GitHub 上取得。