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 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, являются их собственными. |