Spring 6 中的声明式 HTTP 接口

1、概览

在 Spring 6 和 Spring Boot 3 中,我们可以使用 Java 接口来定义声明式的远程 HTTP 服务。这种方法受到 Feign 等流行 HTTP 客户端库的启发,与在 Spring Data 中定义 Repository 的方法类似。

在本教程中,我们将首先了解如何定义 HTTP 接口,以及可用的 exchange 方法注解和支持的方法参数和返回值。接着,学习如何创建一个实际的 HTTP 接口实例,即执行所声明 HTTP exchange 的代理客户端。

最后,我们将介绍如何对声明式 HTTP 接口及其代理客户端进行异常处理和测试。

2、HTTP 接口

声明式 HTTP 接口包括用于 HTTP exchange 的注解方法。我们可以通过使用带注解的 Java 接口来简单地表达远程 API 的细节,然后让 Spring 生成实现该接口并执行 exchange 的代理。这有助于减少样板代码的编写。

2.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")

2.2、方法参数

在上述示例接口中,我们在方法参数中使用了 @PathVariable@RequestBody 注解。此外,我们还可以为 exchange 方法使用以下参数、注解:

  • URI: 动态设置请求的 URL,覆盖注解属性。
  • HttpMethod:动态设置请求的 HTTP 方法,覆盖注解属性。
  • @RequestHeader: 添加请求头信息,参数可以是 MapMultiValueMap
  • @PathVariable:替换请求 URL 中的占位符参数。
  • @RequestBody:提供的请求体可以是要序列化的对象,也可以是响应式流 publisher(如 Mono 或 Flux)。
  • @RequestParam:添加请求参数,参数可以是 MapMultiValueMap
  • @CookieValue:添加 cookie,参数可以是 MapMultiValueMap

注意,只有 Content Type 为 application/x-www-form-urlencoded 的请求才会在请求体中对请求参数进行编码。否则,请求参数将作为 URL 查询参数添加。

2.3、返回值

在我们的示例接口中,exchange 方法返回的是阻塞式的普通值。声明式 HTTP 接口 exchange 方法既支持阻塞式的返回值,也支持响应式返回值。

此外,我们可以选择只返回特定的响应信息,如状态码或响应头。如果我们对服务响应完全不感兴趣,也可以返回 void

总之,HTTP 接口 exchange 方法支持以下返回值:

  • voidMono<Void>:执行请求并丢弃响应内容。
  • HttpHeadersMono<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 中注册的任何其他异步或响应式类型。

3、客户端代理实现

既然我们已经定义了 HTTP 服务接口,就需要创建一个代理来实现该接口并执行 exchange。

3.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 服务。

3.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));

4、测试

让我们看看如何测试我们的示例中声明式 HTTP 接口,以及执行交互的客户端代理。

4.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());

4.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)
);

5、总结

在本文中,我们介绍了 Spring 6 中的声明式 HTTP 服务接口。我们了解了如何使用不同的 HTTP 方法注解来定义 exchange 接口方法,以及支持的方法参数和返回值。

此外,我们还了解了如何通过自定义 response status handler 来执行异常处理。最后,我们了解了如何使用 Mockito 和 MockServer 测试声明式接口及其客户端代理实现。


参考:https://www.baeldung.com/spring-6-http-interface