pcp-tgu-service: толерантность времени на Jackson-3 пути (REST-кодеки WebFlux)
This commit is contained in:
@@ -67,11 +67,15 @@
|
|||||||
|
|
||||||
**Особый случай — pcp-tgu-service.** Он не зависит от pcp-types-lib и работает на WebFlux
|
**Особый случай — pcp-tgu-service.** Он не зависит от pcp-types-lib и работает на WebFlux
|
||||||
(подключение либы притянуло бы `spring-boot-starter-web`/MVC и переключило тип приложения на
|
(подключение либы притянуло бы `spring-boot-starter-web`/MVC и переключило тип приложения на
|
||||||
сервлетный). Поэтому в tgu лежит **локальное зеркало** `config/PcpTimeModule.kt` (поведение обязано
|
сервлетный). Поэтому в tgu лежат **два локальных зеркала** (поведение обязано совпадать с
|
||||||
совпадать с библиотечным), зарегистрированное в Jackson-2 бине `CamundaJacksonConfig` — покрывает
|
библиотечным, правки синхронизировать), покрывающих обе Jackson-среды сервиса:
|
||||||
Camunda/внутренний парсинг. Остаётся под-задача: REST-codec WebFlux на **Jackson 3**
|
- `config/PcpTimeModule.kt` (Jackson 2) — зарегистрирован в `CamundaJacksonConfig`, покрывает
|
||||||
(`tools.jackson.*`, иной API) ещё не толерантен — нужен Jackson-3 вариант модуля + регистрация в
|
Camunda/внутренний парсинг;
|
||||||
кодеках.
|
- `config/PcpTimeModuleV3.kt` (**Jackson 3**, `tools.jackson.*`) — REST-кодеки WebFlux, которые
|
||||||
|
Boot 4 авто-конфигурирует на Jackson 3 `JsonMapper`. Регистрируется бином
|
||||||
|
`tools.jackson.databind.JacksonModule` (`WebFluxJacksonConfig`); `JacksonAutoConfiguration`
|
||||||
|
собирает все такие бины в общий `JsonMapper` (`builder.addModules(...)`). Тем самым входящий
|
||||||
|
REST-JSON tgu тоже читается толерантно к обоим форматам.
|
||||||
|
|
||||||
Контракт слоя закреплён `PcpTimeModuleTest` (pcp-types-lib): оба формата → один и тот же UTC-момент,
|
Контракт слоя закреплён `PcpTimeModuleTest` (pcp-types-lib): оба формата → один и тот же UTC-момент,
|
||||||
плюс проверка авто-подхвата через SPI.
|
плюс проверка авто-подхвата через SPI.
|
||||||
@@ -135,8 +139,9 @@ mission-planing) используют тот же дефолтный Jackson-к
|
|||||||
синхронно; фронт `parseUtc` совместим. Контракт закреплён тестами выше.
|
синхронно; фронт `parseUtc` совместим. Контракт закреплён тестами выше.
|
||||||
|
|
||||||
- **Слой толерантности (`PcpTimeModule`) — раскатан по флоту.** 12 сервисов на pcp-types-lib
|
- **Слой толерантности (`PcpTimeModule`) — раскатан по флоту.** 12 сервисов на pcp-types-lib
|
||||||
получают его авто-конфигурацией/SPI; dynamic-plan — точечно; tgu — локальным зеркалом (Jackson-2
|
получают его авто-конфигурацией/SPI; dynamic-plan — точечно; tgu — двумя локальными зеркалами
|
||||||
путь). Это включатель: дальнейший флип продюсеров на `Z` больше не требует синхронного деплоя.
|
(Jackson-2 для Camunda и Jackson-3 для REST-кодеков WebFlux). Это включатель: дальнейший флип
|
||||||
|
продюсеров на `Z` больше не требует синхронного деплоя.
|
||||||
|
|
||||||
После толерантности продюсеров можно флипать **в любом порядке**. Кандидаты: остальные naive-DTO
|
После толерантности продюсеров можно флипать **в любом порядке**. Кандидаты: остальные naive-DTO
|
||||||
ui-service, ballistics, complex-mission, dynamic-plan, slots, coverage-scheme.
|
ui-service, ballistics, complex-mission, dynamic-plan, slots, coverage-scheme.
|
||||||
@@ -150,10 +155,17 @@ ui-service, ballistics, complex-mission, dynamic-plan, slots, coverage-scheme.
|
|||||||
`…+00:00`. **Инстант не изменился** (тот же момент), но стенные часы теперь совпадают с UTC, поэтому
|
`…+00:00`. **Инстант не изменился** (тот же момент), но стенные часы теперь совпадают с UTC, поэтому
|
||||||
потребитель, дропающий зону, получает корректное время. Закреплено `LocalDateTimeTimeZoneSerializerTest`.
|
потребитель, дропающий зону, получает корректное время. Закреплено `LocalDateTimeTimeZoneSerializerTest`.
|
||||||
|
|
||||||
|
### Исправленные баги формата
|
||||||
|
|
||||||
|
- **pcp-tgu-service REST-codec на Jackson 3** — теперь толерантен (см. 2bis). Добавлен Jackson-3
|
||||||
|
вариант модуля `config/PcpTimeModuleV3.kt` и его регистрация бином `JacksonModule`
|
||||||
|
(`config/WebFluxJacksonConfig.kt`); входящий REST-JSON WebFlux читается в обоих форматах
|
||||||
|
(naive=UTC и `Z`). Закреплено `PcpTimeModuleV3Test`.
|
||||||
|
|
||||||
### Остаток (под-задачи)
|
### Остаток (под-задачи)
|
||||||
|
|
||||||
- **pcp-tgu-service REST-codec на Jackson 3** — ещё не толерантен (см. 2bis); под-задача на Jackson-3
|
- Обе Jackson-среды tgu теперь толерантны; отдельных остатков по чтению времени нет. При миграции
|
||||||
вариант модуля + регистрация в кодеках WebFlux.
|
оставшихся продьюсеров на `Z` (раздел 4) добавлять сериализационные тесты контракта.
|
||||||
|
|
||||||
## 5. Правило для нового кода
|
## 5. Правило для нового кода
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ class CamundaJacksonConfig {
|
|||||||
.addModule(JavaTimeModule())
|
.addModule(JavaTimeModule())
|
||||||
// Слой толерантности времени (читать naive=UTC и Z) — см. docs/standards/DATETIME_UTC.md.
|
// Слой толерантности времени (читать naive=UTC и Z) — см. docs/standards/DATETIME_UTC.md.
|
||||||
// Покрывает Jackson-2 путь (Camunda/внутренний парсинг). REST-codec на Jackson 3
|
// Покрывает Jackson-2 путь (Camunda/внутренний парсинг). REST-codec на Jackson 3
|
||||||
// (WebFlux auto-config) — отдельный tgu-follow-up в DATETIME_UTC.md.
|
// (WebFlux auto-config) покрыт близнецом PcpTimeModuleV3 (см. WebFluxJacksonConfig).
|
||||||
.addModule(PcpTimeModule())
|
.addModule(PcpTimeModule())
|
||||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
package space.nstart.pcp_tgu_service.config
|
||||||
|
|
||||||
|
import tools.jackson.core.JsonParser
|
||||||
|
import tools.jackson.core.JsonToken
|
||||||
|
import tools.jackson.databind.DeserializationContext
|
||||||
|
import tools.jackson.databind.ValueDeserializer
|
||||||
|
import tools.jackson.databind.ext.javatime.deser.InstantDeserializer
|
||||||
|
import tools.jackson.databind.ext.javatime.deser.LocalDateTimeDeserializer
|
||||||
|
import tools.jackson.databind.module.SimpleModule
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.format.DateTimeParseException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Слой толерантности времени для **Jackson 3** (`tools.jackson.*`).
|
||||||
|
*
|
||||||
|
* Близнец [PcpTimeModule] (Jackson 2): то же поведение — читать время в обоих форматах на проводе
|
||||||
|
* (naive=UTC и `Z`/офсет), нормализуя к UTC; трогает только десериализацию. Нужен отдельно, потому
|
||||||
|
* что Boot 4 авто-конфигурирует Jackson 3 `JsonMapper` для REST-кодеков WebFlux, а он не видит
|
||||||
|
* Jackson-2 модули. Регистрируется как бин `tools.jackson.databind.JacksonModule`
|
||||||
|
* ([WebFluxJacksonConfig]) — `JacksonAutoConfiguration` собирает такие бины в общий `JsonMapper`.
|
||||||
|
*
|
||||||
|
* Канонический источник поведения — `space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule`;
|
||||||
|
* правки синхронизировать. См. docs/standards/DATETIME_UTC.md.
|
||||||
|
*/
|
||||||
|
class PcpTimeModuleV3 : SimpleModule(NAME) {
|
||||||
|
|
||||||
|
init {
|
||||||
|
addDeserializer(LocalDateTime::class.java, TolerantLocalDateTimeDeserializerV3())
|
||||||
|
addDeserializer(OffsetDateTime::class.java, TolerantOffsetDateTimeDeserializerV3())
|
||||||
|
addDeserializer(Instant::class.java, TolerantInstantDeserializerV3())
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val NAME = "PcpTimeModuleV3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TolerantLocalDateTimeDeserializerV3 : ValueDeserializer<LocalDateTime>() {
|
||||||
|
|
||||||
|
private val default: ValueDeserializer<LocalDateTime> = LocalDateTimeDeserializer.INSTANCE
|
||||||
|
|
||||||
|
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): LocalDateTime? {
|
||||||
|
if (p.currentToken() == JsonToken.VALUE_STRING) {
|
||||||
|
val text = p.string.trim()
|
||||||
|
if (text.isEmpty()) return null
|
||||||
|
try {
|
||||||
|
return OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime()
|
||||||
|
} catch (_: DateTimeParseException) {
|
||||||
|
// naive-строка без зоны — трактуем как UTC
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return default.deserialize(p, ctxt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TolerantOffsetDateTimeDeserializerV3 : ValueDeserializer<OffsetDateTime>() {
|
||||||
|
|
||||||
|
private val default: ValueDeserializer<OffsetDateTime> = InstantDeserializer.OFFSET_DATE_TIME
|
||||||
|
|
||||||
|
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): OffsetDateTime? {
|
||||||
|
if (p.currentToken() == JsonToken.VALUE_STRING) {
|
||||||
|
val text = p.string.trim()
|
||||||
|
if (text.isEmpty()) return null
|
||||||
|
try {
|
||||||
|
return OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC)
|
||||||
|
} catch (_: DateTimeParseException) {
|
||||||
|
return LocalDateTime.parse(text).atOffset(ZoneOffset.UTC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return default.deserialize(p, ctxt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TolerantInstantDeserializerV3 : ValueDeserializer<Instant>() {
|
||||||
|
|
||||||
|
private val default: ValueDeserializer<Instant> = InstantDeserializer.INSTANT
|
||||||
|
|
||||||
|
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Instant? {
|
||||||
|
if (p.currentToken() == JsonToken.VALUE_STRING) {
|
||||||
|
val text = p.string.trim()
|
||||||
|
if (text.isEmpty()) return null
|
||||||
|
try {
|
||||||
|
return OffsetDateTime.parse(text).toInstant()
|
||||||
|
} catch (_: DateTimeParseException) {
|
||||||
|
return LocalDateTime.parse(text).toInstant(ZoneOffset.UTC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return default.deserialize(p, ctxt)
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package space.nstart.pcp_tgu_service.config
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
import tools.jackson.databind.JacksonModule
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Регистрирует слой толерантности времени ([PcpTimeModuleV3]) в авто-конфигурируемом Jackson 3
|
||||||
|
* `JsonMapper`, который Boot 4 использует для REST-кодеков WebFlux.
|
||||||
|
*
|
||||||
|
* `JacksonAutoConfiguration` собирает все бины `tools.jackson.databind.JacksonModule` из контекста
|
||||||
|
* и добавляет их в общий `JsonMapper` (`builder.addModules(...)`), поэтому достаточно объявить бин
|
||||||
|
* этого типа. Закрывает tgu-follow-up из docs/standards/DATETIME_UTC.md: входящий REST-JSON теперь
|
||||||
|
* читается толерантно к обоим форматам времени (naive=UTC и `Z`), как и Jackson-2 путь
|
||||||
|
* ([CamundaJacksonConfig]).
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
class WebFluxJacksonConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun pcpTimeModuleV3(): JacksonModule = PcpTimeModuleV3()
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package space.nstart.pcp_tgu_service.config
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import tools.jackson.databind.json.JsonMapper
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Слой толерантности времени на Jackson-3 пути (REST-кодеки WebFlux): [PcpTimeModuleV3] читает
|
||||||
|
* время в обоих форматах на проводе (naive=UTC и `Z`/офсет), нормализуя к UTC. Близнец
|
||||||
|
* библиотечного PcpTimeModuleTest на Jackson 2. См. docs/standards/DATETIME_UTC.md.
|
||||||
|
*/
|
||||||
|
class PcpTimeModuleV3Test {
|
||||||
|
|
||||||
|
private val mapper = JsonMapper.builder().addModule(PcpTimeModuleV3()).build()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `LocalDateTime reads naive and Z as the same UTC wall-clock`() {
|
||||||
|
val fromNaive = mapper.readValue("\"2026-05-29T01:00:00\"", LocalDateTime::class.java)
|
||||||
|
val fromZ = mapper.readValue("\"2026-05-29T01:00:00Z\"", LocalDateTime::class.java)
|
||||||
|
|
||||||
|
assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0, 0), fromNaive)
|
||||||
|
assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0, 0), fromZ)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `LocalDateTime normalizes an explicit offset to UTC`() {
|
||||||
|
// 01:00+03:00 == 28-го 22:00 UTC
|
||||||
|
val fromOffset = mapper.readValue("\"2026-05-29T01:00:00+03:00\"", LocalDateTime::class.java)
|
||||||
|
|
||||||
|
assertEquals(LocalDateTime.of(2026, 5, 28, 22, 0, 0), fromOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `OffsetDateTime reads naive as UTC and normalizes offset to UTC`() {
|
||||||
|
val fromNaive = mapper.readValue("\"2026-05-29T01:00:00\"", OffsetDateTime::class.java)
|
||||||
|
val fromOffset = mapper.readValue("\"2026-05-29T01:00:00+03:00\"", OffsetDateTime::class.java)
|
||||||
|
|
||||||
|
assertEquals(OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), fromNaive)
|
||||||
|
assertEquals(OffsetDateTime.of(2026, 5, 28, 22, 0, 0, 0, ZoneOffset.UTC), fromOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Instant reads naive(=UTC) and Z to the same moment`() {
|
||||||
|
val fromNaive = mapper.readValue("\"2026-05-29T01:00:00\"", Instant::class.java)
|
||||||
|
val fromZ = mapper.readValue("\"2026-05-29T01:00:00Z\"", Instant::class.java)
|
||||||
|
|
||||||
|
val expected = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC).toInstant()
|
||||||
|
assertEquals(expected, fromNaive)
|
||||||
|
assertEquals(expected, fromZ)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user