Статьи

Поля даты и времени Spring WebClient и Java

WebClient — это реактивный клиент Spring Framework для обслуживания вызовов служб.

WebClient стал для меня полезной утилитой, однако недавно я неожиданно столкнулся с проблемой, связанной с обработкой временных полей Java 8, которая привела меня в замешательство, и этот пост углубляется в детали.

Счастливый путь

Сначала счастливый путь. При использовании WebClient Spring Boot рекомендует вводить в класс «WebClient.Builder» вместо самого «WebClient», а WebClient.Builder уже настроен автоматически и доступен для внедрения.

Рассмотрим фиктивный домен «Город» и клиента для создания «Город». «Город» имеет простую структуру, обратите внимание, что creationDate является типом Java8 «Instant»:

1
2
3
4
5
6
7
8
9
import java.time.Instant
 
data class City(
    val id: Long,
    val name: String,
    val country: String,
    val pop: Long,
    val creationDate: Instant = Instant.now()
)

Клиент для создания экземпляра этого типа выглядит так:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class CitiesClient(
    private val webClientBuilder: WebClient.Builder,
    private val citiesBaseUrl: String
) {
    fun createCity(city: City): Mono<City> {
        val uri: URI = UriComponentsBuilder
            .fromUriString(citiesBaseUrl)
            .path("/cities")
            .build()
            .encode()
            .toUri()
 
        val webClient: WebClient = this.webClientBuilder.build()
 
        return webClient.post()
            .uri(uri)
            .contentType(MediaType.APPLICATION_JSON)
            .accept(MediaType.APPLICATION_JSON)
            .bodyValue(city)
            .exchange()
            .flatMap { clientResponse ->
                clientResponse.bodyToMono(City::class.java)
            }
    }
}

Посмотрите, как намерение выражается бегло. Сначала устанавливаются URI и заголовки, затем создается тело запроса, и ответ возвращается в тип ответа «Город».

Все хорошо. Теперь, как выглядит тест.

Я использую отличный Wiremock, чтобы вызвать фиктивную удаленную службу, и использую этот CitiesClient для отправки запроса следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
@SpringBootTest
@AutoConfigureJson
class WebClientConfigurationTest {
 
    @Autowired
    private lateinit var webClientBuilder: WebClient.Builder
 
    @Autowired
    private lateinit var objectMapper: ObjectMapper
 
    @Test
    fun testAPost() {
        val dateAsString = "1985-02-01T10:10:10Z"
 
        val city = City(
            id = 1L, name = "some city",
            country = "some country",
            pop = 1000L,
            creationDate = Instant.parse(dateAsString)
        )
        WIREMOCK_SERVER.stubFor(
            post(urlMatching("/cities"))
                .withHeader("Accept", equalTo("application/json"))
                .withHeader("Content-Type", equalTo("application/json"))
                .willReturn(
                    aResponse()
                        .withHeader("Content-Type", "application/json")
                        .withStatus(HttpStatus.CREATED.value())
                        .withBody(objectMapper.writeValueAsString(city))
                )
        )
 
        val citiesClient = CitiesClient(webClientBuilder, "http://localhost:${WIREMOCK_SERVER.port()}")
 
        val citiesMono: Mono<City> = citiesClient.createCity(city)
 
        StepVerifier
            .create(citiesMono)
            .expectNext(city)
            .expectComplete()
            .verify()
 
 
        //Ensure that date field is in ISO-8601 format..
        WIREMOCK_SERVER.verify(
            postRequestedFor(urlPathMatching("/cities"))
                .withRequestBody(matchingJsonPath("$.creationDate", equalTo(dateAsString)))
        )
    }
 
    companion object {
        private val WIREMOCK_SERVER =
            WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort().notifier(ConsoleNotifier(true)))
 
        @BeforeAll
        @JvmStatic
        fun beforeAll() {
            WIREMOCK_SERVER.start()
        }
 
        @AfterAll
        @JvmStatic
        fun afterAll() {
            WIREMOCK_SERVER.stop()
        }
    }
}

В выделенных строках я хочу убедиться, что удаленная служба получает дату в формате ISO-8601 как «1985-02-01T10: 10: 10Z». В этом случае все работает чисто, и тест проходит.

Не очень счастливый путь

Теперь рассмотрим случай, когда я настроил WebClient.Builder в некоторой форме. Например, я говорю, что использую службу реестра, и я хочу найти удаленную службу через этот реестр, а затем сделать вызов, после чего необходимо настроить WebClient, добавив в него аннотацию «@LoadBalanced» — некоторые подробности здесь

Скажем так, я настроил WebClient.Builder следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
@Configuration
class WebClientConfiguration {
 
    @Bean
    fun webClientBuilder(): WebClient.Builder {
        return WebClient.builder().filter { req, next ->
            LOGGER.error("Custom filter invoked..")
            next.exchange(req)
        }
    }
 
    companion object {
        val LOGGER = loggerFor<WebClientConfiguration>()
    }
}

Это выглядит просто, однако сейчас предыдущий тест не пройден. В частности, формат даты creationDate по проводам больше не соответствует ISO-8601, необработанный запрос выглядит так:

1
2
3
4
5
6
7
{
    "id": 1,
    "name": "some city",
    "country": "some country",
    "pop": 1000,
    "creationDate": 476100610.000000000
}

против рабочего запроса:

1
2
3
4
5
6
7
{
    "id": 1,
    "name": "some city",
    "country": "some country",
    "pop": 1000,
    "creationDate": "1985-02-01T10:10:10Z"
}

Посмотрите, как формат даты отличается.

проблема

Основная причина этой проблемы проста: Spring Boot добавляет множество настроек в WebClient.Builder, которые теряются, когда я сам явно создал компонент. В частности, в этом случае под обложками создается Jackson ObjectMapper, который по умолчанию записывает даты в виде временных меток — некоторые подробности здесь .

Решение

Итак, как мы можем вернуть настройки, которые делает Spring Boot. Я по существу повторил поведение автоконфигурации в Spring под названием «WebClientAutoConfiguration», и это выглядит так:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
@Configuration
class WebClientConfiguration {
 
    @Bean
    fun webClientBuilder(customizerProvider: ObjectProvider<WebClientCustomizer>): WebClient.Builder {
        val webClientBuilder: WebClient.Builder = WebClient
            .builder()
            .filter { req, next ->
                LOGGER.error("Custom filter invoked..")
                next.exchange(req)
            }
 
        customizerProvider.orderedStream()
            .forEach { customizer -> customizer.customize(webClientBuilder) }
 
        return webClientBuilder;
    }
 
    companion object {
        val LOGGER = loggerFor<WebClientConfiguration>()
    }
}

Вероятно, есть лучший подход, чем просто репликация этого поведения, но этот подход работает для меня.

Размещенный контент теперь выглядит так:

1
2
3
4
5
6
7
{
    "id": 1,
    "name": "some city",
    "country": "some country",
    "pop": 1000,
    "creationDate": "1985-02-01T10:10:10Z"
}

с датой в правильном формате.

Вывод

Автоконфигурации Spring Boot для WebClient предоставляют упорядоченный набор значений по умолчанию. Если по какой-либо причине WebClient и его компоновщик необходимо явно настроить, то остерегайтесь некоторых настроек, которые добавляет Spring Boot, и реплицируйте их для настроенного компонента. В моем случае настройка Джексона для дат Java 8 отсутствовала в моем пользовательском «WebClient.Builder» и должна была быть явно учтена.

Образец теста и настройки доступны здесь

См. Оригинальную статью здесь: поля даты и времени Spring WebClient и Java

Мнения, высказанные участниками Java Code Geeks, являются их собственными.