Spring Webclient 自定义 JSON 反序列化

1、概览

本文将带你了解如何在 Spring WebClient 中自定义 JSON 的反序列化(Deserialization)。

2、为什么需要自定义反序列化?

Spring WebFlux 模块中的 Spring WebClient 通过 EncoderDecoder 组件处理序列化和反序列化。编码器(Encoder)和解码器(Decoder)是表示读取和写入内容的接口。默认情况下,spring-core 模块提供 byte[]ByteBufferDataBufferResourceString 编码器和解码器实现。

Jackson 是一个 JSON 库,它通过 ObjectMapper 将 Java 对象序列化为 JSON,并将 JSON 字符串反序列化为 Java 对象。ObjectMapper 包含内置的配置选项,可以使用 Deserialization Feature 进行开启或关闭。

Jackson 库提供的默认行为无法满足我们的特定需求时,就有必要定制反序列化过程。为了在序列化和反序列化过程中修改行为,ObjectMapper 提供了一系列配置选项供我们设置。因此,我们需要将这个自定义的 ObjectMapper 注册到 Spring WebClient 中,以便在序列化和反序列化中使用。

3、如何自定义 ObjectMapper?

自定义 ObjectMapper 可以在全局应用程序级别与 WebClient 关联,也可以与特定请求关联。

以一个获取客户订单详细信息的 GET 端点为例。

OrderResponse Model 如下:

{
  "orderId": "a1b2c3d4-e5f6-4a5b-8c9d-0123456789ab",
  "address": [
    "123 Main St",
    "Apt 456",
    "Cityville"
  ],
  "orderNotes": [
    "Special request: Handle with care",
    "Gift wrapping required"
  ],
  "orderDateTime": "2024-01-20T12:34:56"
}

对于上述响应的一些反序列化规则如下:

  • 如果响应包含未知属性,应该让反序列化失败,需要在 ObjectMapper 中把 FAIL_ON_UNKNOWN_PROPERTIES 属性设置为 true
  • 由于 OrderDateTime 是一个 LocalDateTime 对象,还要在 Mapper 中添加 JavaTimeModule 以实现反序列化。

应用了上述规则的 ObjectMapper 如下:

@Bean
public ObjectMapper objectMapper() {
    return new ObjectMapper()
      .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true)
      .registerModule(new JavaTimeModule());
}

4、使用全局配置自定义反序列化

要使用全局配置进行反序列化,需要向 CodecCustomizer 注册自定义 ObjectMapper,以自定义与 WebClient 关联的编码器(Encoder)和解码器(Decoder):

@Bean
public CodecCustomizer codecCustomizer(ObjectMapper customObjectMapper) {
    return configurer -> {
      MimeType mimeType = MimeType.valueOf(MediaType.APPLICATION_JSON_VALUE);
      CodecConfigurer.CustomCodecs customCodecs = configurer.customCodecs();
      customCodecs.register(new Jackson2JsonDecoder(customObjectMapper, mimeType));
      customCodecs.register(new Jackson2JsonEncoder(customObjectMapper, mimeType));
    };
}

该 Bean(即 CodecCustomizer)可为 Application Context 配置 ObjectMapper。因此,它能确保应用中的任何请求或响应都能进行相应的序列化和反序列化。

定义一个带有 GET 端点的 Controller,调用外部服务来获取订单详细信息:

@GetMapping(value = "v1/order/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
public Mono<OrderResponse> searchOrderV1(@PathVariable(value = "id") int id) {
    return externalServiceV1.findById(id)
      .bodyToMono(OrderResponse.class);
}

使用 WebClient.Builder 检索订单详细信息的外部服务。

public ExternalServiceV1(WebClient.Builder webclientBuilder) {
    this.webclientBuilder = webclientBuilder;
}

public WebClient.ResponseSpec findById(int id) {
    return webclientBuilder.baseUrl("http://localhost:8090/")
      .build()
      .get()
      .uri("external/order/" + id)
      .retrieve();
}

Spring Reactive 会自动使用自定义 ObjectMapper 来解析检索到的 JSON 响应。

添加一个简单的测试,使用 MockWebServer 来模拟带有额外属性的外部服务响应,这会导致请求失败:

@Test
void givenMockedExternalResponse_whenSearchByIdV1_thenOrderResponseShouldFailBecauseOfUnknownProperty() {

    mockExternalService.enqueue(new MockResponse().addHeader("Content-Type", "application/json; charset=utf-8")
      .setBody("""
        {
          "orderId": "a1b2c3d4-e5f6-4a5b-8c9d-0123456789ab",
          "orderDateTime": "2024-01-20T12:34:56",
          "address": [
            "123 Main St",
            "Apt 456",
            "Cityville"
          ],
          "orderNotes": [
            "Special request: Handle with care",
            "Gift wrapping required"
          ],
          "customerName": "John Doe",
          "totalAmount": 99.99,
          "paymentMethod": "Credit Card"
        }
        """)
      .setResponseCode(HttpStatus.OK.value()));

    webTestClient.get()
      .uri("v1/order/1")
      .exchange()
      .expectStatus()
      .is5xxServerError();
}

外部服务的响应包含额外属性(customerNametotalAmountpaymentMethod),导致测试失败。

5、使用 WebClient ExchangeStrategies 配置自定义反序列化

在某些情况下,我们可能只想为特定请求配置 ObjectMapper,在这种情况下,我们需要向 ExchangeStrategies 注册 mapper。

假设在上述示例中接收到的日期格式不同,并且包含时区偏移量。我们需要添加一个 CustomDeserializer,它将解析接收到的 OffsetDateTime,并将其转换为以 UTC 为单位的 LocalDateTime

public class CustomDeserializer extends LocalDateTimeDeserializer {
    @Override
    public LocalDateTime deserialize(JsonParser jsonParser, DeserializationContext ctxt) throws IOException {
      try {
        return OffsetDateTime.parse(jsonParser.getText())
        .atZoneSameInstant(ZoneOffset.UTC)
        .toLocalDateTime();
      } catch (Exception e) {
          return super.deserialize(jsonParser, ctxt);
      }
    }
}

ExternalServiceV2 的新实现中,声明一个新的 ObjectMapper,与上述 CustomDeserializer 相链接,并使用 ExchangeStrategies 将其注册到一个新的 WebClient

public WebClient.ResponseSpec findById(int id) {

    ObjectMapper objectMapper = new ObjectMapper().registerModule(new SimpleModule().addDeserializer(LocalDateTime.class, new CustomDeserializer()));

    WebClient webClient = WebClient.builder()
      .baseUrl("http://localhost:8090/")
      .exchangeStrategies(ExchangeStrategies.builder()
      .codecs(clientDefaultCodecsConfigurer -> {
        clientDefaultCodecsConfigurer.defaultCodecs()
        .jackson2JsonEncoder(new Jackson2JsonEncoder(objectMapper, MediaType.APPLICATION_JSON));
        clientDefaultCodecsConfigurer.defaultCodecs()
        .jackson2JsonDecoder(new Jackson2JsonDecoder(objectMapper, MediaType.APPLICATION_JSON));
      })
      .build())
    .build();

    return webClient.get().uri("external/order/" + id).retrieve();
}

我们将这个 ObjectMapper 与特定的 API 请求进行了关联,它不会应用于应用中的其他请求。

接下来,添加一个 GET /v2 端点,使用上述 findById 实现和特定 ObjectMapper 调用外部服务:

@GetMapping(value = "v2/order/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
public final Mono<OrderResponse> searchOrderV2(@PathVariable(value = "id") int id) {
    return externalServiceV2.findById(id)
      .bodyToMono(OrderResponse.class);
}

最后,添加一个测试,传递一个带有时区偏移量的模拟 orderDateTime,并验证它是否使用 CustomDeserializer 将其转换为 UTC 时间:

@Test
void givenMockedExternalResponse_whenSearchByIdV2_thenOrderResponseShouldBeReceivedSuccessfully() {

    mockExternalService.enqueue(new MockResponse().addHeader("Content-Type", "application/json; charset=utf-8")
      .setBody("""
      {
        "orderId": "a1b2c3d4-e5f6-4a5b-8c9d-0123456789ab",
        "orderDateTime": "2024-01-20T14:34:56+01:00",
        "address": [
          "123 Main St",
          "Apt 456",
          "Cityville"
        ],
        "orderNotes": [
          "Special request: Handle with care",
          "Gift wrapping required"
        ]
      }
      """)
      .setResponseCode(HttpStatus.OK.value()));

    OrderResponse orderResponse = webTestClient.get()
      .uri("v2/order/1")
      .exchange()
      .expectStatus()
      .isOk()
      .expectBody(OrderResponse.class)
      .returnResult()
      .getResponseBody();
    assertEquals(UUID.fromString("a1b2c3d4-e5f6-4a5b-8c9d-0123456789ab"), orderResponse.getOrderId());
    assertEquals(LocalDateTime.of(2024, 1, 20, 13, 34, 56), orderResponse.getOrderDateTime());
    assertThat(orderResponse.getAddress()).hasSize(3);
    assertThat(orderResponse.getOrderNotes()).hasSize(2);
}

该测试调用 /v2 端点,它使用特定的 ObjectMapperCustomDeserializer 来解析从外部服务接收到的订单详细信息响应。

6、总结

本文介绍了在 Spring WebClient 中自定义 JSON 反序列化的需求以及不同的实现方式。


Ref:https://www.baeldung.com/spring-webclient-json-custom-deserialization