1 介紹
????????在 Spring 6 和 Spring Boot 3 中,我們可以使用 Java 接口來定義聲明式的遠程 HTTP 服務。這種方法受到 Feign 等流行 HTTP 客戶端庫的啟發,與在 Spring Data 中定義 Repository 的方法類似。
????????聲明式 HTTP 接口包括用于 HTTP exchange 的注解方法。我們可以通過使用帶注解的 Java 接口來簡單地表達遠程 API 的細節,然后讓 Spring 生成實現該接口并執行 exchange 的代理。這有助于減少樣板代碼的編寫。
1.1 Exchange 方法
? ?@HttpExchange
?是我們可以應用于 HTTP 接口及其 exchange 方法的根注解。如果我們將其應用于接口層,那么它就會應用于所有 exchange 方法。這對于指定所有接口方法的共同屬性(如 content type 或 URL 前綴)非常有用。所有 HTTP 方法都有對應的注解:
@GetExchange
?用于 HTTP GET 請求。@PostExchange
?用于 HTTP POST 請求。@PutExchange
?用于 HTTP PUT 請求。@PatchExchange
?用于 HTTP PATCH 請求。@DelectExchange
?用于 HTTP DELETE 請求。
????????讓我們使用不同的 HTTP 方法注解,來為遠程 API 定義一個聲明式的 HTTP 接口:
interface BooksService {@GetExchange("/books")List<Book> getBooks();@GetExchange("/books/{id}")Book getBook(@PathVariable long id);@PostExchange("/books")Book saveBook(@RequestBody Book book);@DeleteExchange("/books/{id}")ResponseEntity<Void> deleteBook(@PathVariable long id);
}
????????注意,所有 HTTP 方法注解都是用?@HttpExchange
?元注解的。因此,@GetExchange("/books")
?等同于?@HttpExchange(url = "/books",method = "GET")
。
1.2?方法參數
????????在上述示例接口中,我們在方法參數中使用了?@PathVariable
?和?@RequestBody
?注解。此外,我們還可以為 exchange 方法使用以下參數、注解:
URI
: 動態設置請求的 URL,覆蓋注解屬性。HttpMethod
:動態設置請求的 HTTP 方法,覆蓋注解屬性。@RequestHeader
: 添加請求頭信息,參數可以是?Map
?或?MultiValueMap
。@PathVariable
:替換請求 URL 中的占位符參數。@RequestBody
:提供的請求體可以是要序列化的對象,也可以是響應式流 publisher(如 Mono 或 Flux)。@RequestParam
:添加請求參數,參數可以是?Map
?或?MultiValueMap
。@CookieValue
:添加 cookie,參數可以是?Map
?或?MultiValueMap
。
????????注意,只有 Content Type 為?application/x-www-form-urlencoded
?的請求才會在請求體中對請求參數進行編碼。否則,請求參數將作為 URL 查詢參數添加。
1.3?返回值
????????在我們的示例接口中,exchange 方法返回的是阻塞式的普通值。聲明式 HTTP 接口 exchange 方法既支持阻塞式的返回值,也支持響應式返回值。此外,我們可以選擇只返回特定的響應信息,如狀態碼或響應頭。如果我們對服務響應完全不感興趣,也可以返回?void
。
????????HTTP 接口 exchange 方法支持以下返回值:
void
、Mono<Void>
:執行請求并丟棄響應內容。HttpHeaders
、Mono<HttpHeaders>
: 執行請求,丟棄響應體,返回響應頭。<T>
、Mono<T>
:執行請求,并將響應體解碼為所聲明的類型。<T>
、Flux<T>
:執行請求,并將響應體解碼為所聲明類型的數據流。ResponseEntity<Void>
、Mono<ResponseEntity<Void>>
:執行請求,丟棄響應體,并返回一個包含狀態和響應頭的?ResponseEntity
。ResponseEntity<T>
、Mono<ResponseEntity<T>>
:執行請求,并返回一個包含狀態、響應頭和解碼后的響應體?ResponseEntity
。Mono<ResponseEntity<Flux<T>>
:執行請求,并返回一個包含狀態、響應頭和解碼后的響應體?ResponseEntity
。
????????我們還可以使用?ReactiveAdapterRegistry
?中注冊的任何其他異步或響應式類型。
2?客戶端代理實現
????????既然我們已經定義了 HTTP 服務接口,就需要創建一個代理來實現該接口并執行 exchange。
2.1?Proxy Factory
????????Spring 為我們提供了一個?HttpServiceProxyFactory
,我們可以用它為 HTTP 接口生成一個客戶端代理:
HttpServiceProxyFactory httpServiceProxyFactory = HttpServiceProxyFactory.builder(WebClientAdapter.forClient(webClient)).build();
booksService = httpServiceProxyFactory.createClient(BooksService.class);
????????要使用提供的工廠創建代理,除了 HTTP 接口之外,我們還需要一個響應式 Web 客戶端的實例:
WebClient webClient = WebClient.builder().baseUrl(serviceUrl).build();
????????現在,我們可以將客戶端代理實例注冊為 Spring Bean 或組件,并用它請求 REST 服務。
2.2?異常處理
????????默認情況下,WebClient
?會對任何客戶端或服務器錯誤 HTTP 狀態代碼拋出?WebClientResponseException
。我們可以通過注冊一個默認的 response status handler 來自定義異常處理,該 handler 適用于通過客戶端執行的所有響應:
BooksClient booksClient = new BooksClient(WebClient.builder().defaultStatusHandler(HttpStatusCode::isError, resp ->Mono.just(new MyServiceException("Custom exception"))).baseUrl(serviceUrl).build());
????????如此一來,如果我們請求的 book 不存在,我們就會收到一個自定義異常:
BooksService booksService = booksClient.getBooksService();
assertThrows(MyServiceException.class, () -> booksService.getBook(9));
2.3 項目實戰基本配置
package com.ywz.framework.webClient;import com.ywz.common.SymmetricAlgorithmUtils;
import com.ywz.framework.property.FastAiProperties;
import com.ywz.framework.webClient.service.FastAiChatService;
import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.ExchangeStrategies;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.support.WebClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
import reactor.netty.http.client.HttpClient;/*** 類描述 -> WebFlux webClient配置類** @Author: ywz* @Date: 2025/04/25*/
@Configuration
@AllArgsConstructor
public class WebClientConfig {private final FastAiProperties fastAiProperties;/*** 方法描述 -> 獲取httpClient客戶端** @Author: ywz* @Date: 2025/04/25*/@Beanpublic HttpClient httpClient() {return HttpClient.create().option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 30000) //連接超時.doOnConnected(conn -> {conn.addHandlerLast(new ReadTimeoutHandler(10)); //讀超時conn.addHandlerLast(new WriteTimeoutHandler(10)); //寫超時});}/*** 方法描述 -> 獲取FastAi對話服務接口** @param httpClient httpClient客戶端* @Author: ywz* @Date: 2025/04/25*/@Beanpublic FastAiChatService fastAiChatService(HttpClient httpClient) {// 構建WebClient實例,設置基礎URL為解密后的地址WebClient client = WebClient.builder().baseUrl(SymmetricAlgorithmUtils.decrypt(fastAiProperties.baseUrl()))// 設置默認請求頭,包括內容類型和授權信息.defaultHeaders(headers -> {headers.add("Content-Type", "application/json");headers.add("Authorization", "Bearer " + SymmetricAlgorithmUtils.decrypt(fastAiProperties.yiZhouKey()));})// 使用傳入的httpClient進行請求.clientConnector(new ReactorClientHttpConnector(httpClient))// 配置交換策略,設置最大內存大小.exchangeStrategies(ExchangeStrategies.builder().codecs(clientCodecConfigurer ->clientCodecConfigurer.defaultCodecs().maxInMemorySize(1024 << 8)).build()).build();// 創建HttpServiceProxyFactory,用于生成服務接口的代理HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(WebClientAdapter.create(client)).build();// 返回FastAiChatService接口的實現return factory.createClient(FastAiChatService.class);}
}
3?測試
????????讓我們看看如何測試我們的示例中聲明式 HTTP 接口,以及執行交互的客戶端代理。
3.1?使用?Mockito
????????由于我們的目標是測試使用聲明式 HTTP 接口創建的客戶端代理,因此需要使用 Mockito 的 deep stubbing 功能來模擬底層?WebClient
?的 fluent API:
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private WebClient webClient;
????????現在,我們可以使用?Mockito
?的 BDD 方法鏈式調用?WebClient
?方法,并提供模擬響應:
given(webClient.method(HttpMethod.GET).uri(anyString(), anyMap()).retrieve().bodyToMono(new ParameterizedTypeReference<List<Book>>(){})).willReturn(Mono.just(List.of(new Book(1,"Book_1", "Author_1", 1998),new Book(2, "Book_2", "Author_2", 1999))));
????????模擬響應就緒后,我們就可以使用 HTTP 接口定義的方法調用我們的服務了:
BooksService booksService = booksClient.getBooksService();
Book book = booksService.getBook(1);
assertEquals("Book_1", book.title());
3.2?使用?MockServer
????????如果我們不想模擬?WebClient
,可以使用?MockServer
?這樣的庫生成并返回固定的 HTTP 響應:
new MockServerClient(SERVER_ADDRESS, serverPort).when(request().withPath(PATH + "/1").withMethod(HttpMethod.GET.name()),exactly(1)).respond(response().withStatusCode(HttpStatus.SC_OK).withContentType(MediaType.APPLICATION_JSON).withBody("{\"id\":1,\"title\":\"Book_1\",\"author\":\"Author_1\",\"year\":1998}"));
????????現在已經準備好了模擬的響應和正在運行的模擬服務器(mock server,),可以調用我們的服務了。
BooksClient booksClient = new BooksClient(WebClient.builder().baseUrl(serviceUrl).build());
BooksService booksService = booksClient.getBooksService();
Book book = booksService.getBook(1);
assertEquals("Book_1", book.title());
????????此外,還可以驗證我們的測試代碼是否調用了正確的模擬服務。
mockServer.verify(HttpRequest.request().withMethod(HttpMethod.GET.name()).withPath(PATH + "/1"),VerificationTimes.exactly(1)
);