WebClient — это реактивный клиент Spring Framework для выполнения вызовов между сервисами. WebClient стал для меня полезной утилитой; однако недавно я неожиданно столкнулся с проблемой, связанной с обработкой временных полей Java 8, которая привела меня в замешательство. Этот пост будет посвящен деталям полей даты и времени в Java.
Вам также может понравиться:
Java 8 Дата и время
Счастливый путь
Во-первых, счастливого пути. При использовании WebClient
, Spring Загрузочный советуетWebClient.Builder
, который будет введен в класс вместо WebClient
себя и WebClient.Builder
уже автоматически настроен и доступен для инъекций.
Рассмотрим фиктивный домен «Город» и клиента для создания «Город». «Город» имеет простую структуру — обратите внимание, что creationDate
это тип Java 8 «Instant»:
Джава
xxxxxxxxxx
1
import java.time.Instant
2
data class City(
4
val id: Long,
5
val name: String,
6
val country: String,
7
val pop: Long,
8
val creationDate: Instant = Instant.now()
9
)
Клиент для создания экземпляра этого типа выглядит так:
Джава
1
class CitiesClient(
2
private val webClientBuilder: WebClient.Builder,
3
private val citiesBaseUrl: String
4
) {
5
fun createCity(city: City): Mono<City> {
6
val uri: URI = UriComponentsBuilder
7
.fromUriString(citiesBaseUrl)
8
.path("/cities")
9
.build()
10
.encode()
11
.toUri()
12
val webClient: WebClient = this.webClientBuilder.build()
14
return webClient.post()
16
.uri(uri)
17
.contentType(MediaType.APPLICATION_JSON)
18
.accept(MediaType.APPLICATION_JSON)
19
.bodyValue(city)
20
.exchange()
21
.flatMap { clientResponse ->
22
clientResponse.bodyToMono(City::class.java)
23
}
24
}
25
}
Посмотрите, как намерение выражается бегло. URI и заголовки сначала устанавливаются; тело запроса затем помещается на место, и ответ отправляется обратно в тип ответа "Город".
Все хорошо. Теперь, как выглядит тест?
Я использую отличный Wiremock для вызова фиктивного удаленного сервиса и использую его CitiesClient
для отправки запроса по следующим направлениям:
Джава
xxxxxxxxxx
1
2
3
class WebClientConfigurationTest {
4
6
private lateinit var webClientBuilder: WebClient.Builder
7
9
private lateinit var objectMapper: ObjectMapper
10
12
fun testAPost() {
13
val dateAsString = "1985-02-01T10:10:10Z"
14
val city = City(
16
id = 1L, name = "some city",
17
country = "some country",
18
pop = 1000L,
19
creationDate = Instant.parse(dateAsString)
20
)
21
WIREMOCK_SERVER.stubFor(
22
post(urlMatching("/cities"))
23
.withHeader("Accept", equalTo("application/json"))
24
.withHeader("Content-Type", equalTo("application/json"))
25
.willReturn(
26
aResponse()
27
.withHeader("Content-Type", "application/json")
28
.withStatus(HttpStatus.CREATED.value())
29
.withBody(objectMapper.writeValueAsString(city))
30
)
31
)
32
val citiesClient = CitiesClient(webClientBuilder, "http://localhost:${WIREMOCK_SERVER.port()}")
34
val citiesMono: Mono<City> = citiesClient.createCity(city)
36
StepVerifier
38
.create(citiesMono)
39
.expectNext(city)
40
.expectComplete()
41
.verify()
42
//Ensure that date field is in ISO-8601 format..
45
WIREMOCK_SERVER.verify(
46
postRequestedFor(urlPathMatching("/cities"))
47
.withRequestBody(matchingJsonPath("$.creationDate", equalTo(dateAsString)))
48
)
49
}
50
companion object {
52
private val WIREMOCK_SERVER =
53
WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort().notifier(ConsoleNotifier(true)))
54
56
57
fun beforeAll() {
58
WIREMOCK_SERVER.start()
59
}
60
62
63
fun afterAll() {
64
WIREMOCK_SERVER.stop()
65
}
66
}
67
}
В выделенных строках я хочу убедиться, что удаленная служба получает дату в формате ISO-8601 как «1985-02-01T10: 10: 10Z». В этом случае все работает чисто, и тест проходит.
Не такой счастливый путь
Теперь рассмотрим случай, когда я настроил его WebClient.Builder
в некоторой форме. Вот пример. Скажем, я использую службу реестра, и я хочу найти удаленную службу через этот реестр, а затем сделать звонок. Затем WebClient
его необходимо настроить, чтобы добавить к нему @LoadBalanced
аннотацию. Более подробную информацию можно найти здесь .
Скажем так, я настроил WebClient.Builder
это так:
Джава
xxxxxxxxxx
1
2
class WebClientConfiguration {
3
5
fun webClientBuilder(): WebClient.Builder {
6
return WebClient.builder().filter { req, next ->
7
LOGGER.error("Custom filter invoked..")
8
next.exchange(req)
9
}
10
}
11
companion object {
13
val LOGGER = loggerFor<WebClientConfiguration>()
14
}
15
}
Это выглядит просто. Однако сейчас предыдущий тест не пройден. В частности, формат даты creationDate
по проводам больше не соответствует ISO-8601. Необработанный запрос выглядит так:
Джава
xxxxxxxxxx
1
{
2
"id": 1,
3
"name": "some city",
4
"country": "some country",
5
"pop": 1000,
6
"creationDate": 476100610.000000000
7
}
И вот как это выглядит для рабочего запроса:
Джава
xxxxxxxxxx
1
{
2
"id": 1,
3
"name": "some city",
4
"country": "some country",
5
"pop": 1000,
6
"creationDate": "1985-02-01T10:10:10Z"
7
}
Видите, чем отличается формат даты?
проблема
Основная причина этой проблемы проста: Spring Boot добавляет кучу конфигурации, WebClient.Builder
которая теряется, когда я сам явно создал компонент. В частности, в этом случае ObjectMapper
под обложками создается Джексон , который по умолчанию записывает даты в виде меток времени. Более подробную информацию можно найти здесь .
Решение
Итак, как нам получить настройки, сделанные в Spring Boot? Я по сути повторил поведение автоконфигурации в Spring WebClientAutoConfiguration
, и это выглядит так:
Джава
xxxxxxxxxx
1
2
class WebClientConfiguration {
3
5
fun webClientBuilder(customizerProvider: ObjectProvider<WebClientCustomizer>): WebClient.Builder {
6
val webClientBuilder: WebClient.Builder = WebClient
7
.builder()
8
.filter { req, next ->
9
LOGGER.error("Custom filter invoked..")
10
next.exchange(req)
11
}
12
customizerProvider.orderedStream()
14
.forEach { customizer -> customizer.customize(webClientBuilder) }
15
return webClientBuilder;
17
}
18
companion object {
20
val LOGGER = loggerFor<WebClientConfiguration>()
21
}
22
}
23
Вероятно, есть лучший подход, чем просто репликация этого поведения, но этот подход работает для меня.
Размещенный контент теперь выглядит так:
xxxxxxxxxx
1
{
2
"id": 1,
3
"name": "some city",
4
"country": "some country",
5
"pop": 1000,
6
"creationDate": "1985-02-01T10:10:10Z"
7
}
... с датой в правильном формате.
Заключение
Автоконфигурации Spring Boot для WebClient
предоставляют продуманный набор значений по умолчанию. Если по какой-либо причине WebClient
необходимо явно настроить его и его сборщик, то остерегайтесь некоторых настроек, которые добавляет Spring Boot, и реплицируйте их для настроенного компонента. В моем случае настройка Джексона для дат Java 8 отсутствовала в моем обычае WebClient.Builder
и должна была быть явно учтена.
Образец теста и настройки доступны здесь .
Спасибо за прочтение!
Дальнейшее чтение
Spring Boot WebClient и модульное тестирование
Реактивное программирование в Java: использование класса WebClient