在SpringBoot接口中正確地序列化時間字段的方法
在 Java 項目中處理時間序列化從來就不是容易的事。一方面要面臨多變的時間格式,年月日時分秒,毫秒,納秒,周,還有討厭的時區(qū),稍不注意就可能收獲一堆異常,另一方面,Java 又提供了 Date
和 LocalDateTime
兩個版本的時間類型,二者分別對應著不同的序列化配置,光是弄清楚這些配置,就是一件麻煩事。但是在大多數時候,我們又不得不和這一堆配置打交道。
作為開始,讓我們來了解一下可用的配置和相應的效果。
時間字符串配置
準備一個簡單接口來展示效果。
@Slf4j @RestController @RequestMapping("/boom") public class BoomController { @Operation(summary = "boom") @GetMapping public BoomData getBoomData() { return new BoomData(Clock.systemDefaultZone()); } @Operation(summary = "boom") @PostMapping public BoomData postBoomData(@RequestBody BoomData boomData) { log.info("boomData: {}", boomData); return boomData; } } @Data @NoArgsConstructor @AllArgsConstructor public class BoomData { private Date date; private LocalDateTime localDateTime; private LocalDate localDate; private LocalTime localTime; public BoomData(Clock clock) { this.date = new Date(clock.millis()); this.localDateTime = LocalDateTime.now(clock); this.localDate = LocalDate.now(clock); this.localTime = LocalTime.now(clock); } }
上面涉及兩種時間類型:
Date
代表老版本日期類型,類似的還有Calendar
,陪著 Java 度過了漫長歲月,使用面極廣。但相對而言,設計不太跟得上時代了,比如值可變導致線程不安全,月份從 0 開始有點不正常。LocalDateTime
代表java.time
包的新版時間類型,JDK 8 中引入。新的時間類型解決老版本類型的設計缺陷,同時增加了豐富的 API 來提高易用性。
兩種類型在記錄的信息方面有一點區(qū)別:
Date
的時間精度為毫秒,內部實際是一個 long 類型時間戳。此外還記錄了時區(qū)信息,簡單記錄為Date = timestamp + timezone
。如果沒有提供時區(qū),默認使用系統時區(qū)。LocalDateTime
時間精度為納秒,內部用 7 個整數來記錄時間:- int year
- short month
- short day
- byte hour
- byte minute
- byte second
- int nano
可以簡單記錄為
LocalDateTime = year + month + day + hour + minute + second + nano
。(實際上應該是LocalDateTime = LocalDate + LocalTime
,LocalDate = year + month + day
,LocalTime = hour + minute + second + nano
。)LocalDateTime 沒有時區(qū)信息,這也是類名中 Local 的含義,代表使用本地時區(qū)。如果需要時區(qū)信息,可以用
ZonedDateTime
類型,ZonedDateTime = LocalDateTime + tz
。
了解了兩個版本時間類型的區(qū)別,再看它們的序列化差異。
JSON 序列化
調用 GET 接口,得到默認的序列化結果。
{ "date": "2024-10-10T21:07:08.781+08:00", "localDateTime": "2024-10-10T21:07:08.781283", "localDate": "2024-10-10", "localTime": "21:07:08.781263" }
默認配置下,時間字段被序列化為時間字符串,但格式不盡相同。Spring Boot 使用 Jackson 進行 JSON 序列化,對不同的時間類型有不同的格式化規(guī)則:
Date
默認按照 ISO 標準格式化LocalDateTime
也按照 ISO 標準處理,精確到微秒,少了時區(qū)LocalDate
和LocalTime
與 LocalDateTime 處理方式相似
所謂 ISO 標準,指的是 ISO 8601 標準,一種專門處理日期時間格式的國際標準。將時間日期組合按 yyyy-MM-dd'T'HH:mm:ss.SSSXXX
格式處理,比如 2024-10-10T21:07:08.781+08:00
,其中字母 T 為日期和時間分隔符,日期表示為年-月-日,時間表示為時:分:秒.毫秒。格式中的 XXX
指的是時區(qū)信息,對于東 8 區(qū),表示為 +08:00
。
默認情況下,調用 POST 接口,也需要保證 body 中的 JSON 串按照 ISO 8601 的格式處理時間字段,才能正常反序列化,否則 Spring Boot 會拋出異常。當然,時間格式的要求也沒那么嚴格,可以省略時區(qū)、微秒、毫秒、秒,都能正常反序列化,但 T 不能省略,年月日時分不能省略。
在接口調用兩端統一標準時,ISO 8601 表現不壞,但是,碰到國內互聯網偏愛的 yyyy-MM-dd HH:mm:ss
格式,就會收獲一個 HttpMessageNotReadableException
,JVM 會提示你 JSON parse error: Cannot deserialize value of type XXX ...
。
如果想要加入 yyyy-MM-dd HH:mm:ss
大家庭,最簡單的辦法是使用 @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
。@JsonFormat 注解用于指定時間類型的序列格式,對 Date 類型和 LocalDateTime 類型都有效。
public class BoomData { @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date date; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime localDateTime; @JsonFormat(pattern = "yyyy-MM-dd") private LocalDate localDate; @JsonFormat(pattern = "HH:mm:ss") private LocalTime localTime; }
此時能 GET 到滿足格式的時間字符串
{ "date": "2024-11-20 15:15:57", "localDateTime": "2024-11-20 23:15:57", "localDate": "2024-11-20", "localTime": "23:15:57" }
POST 請求也正常處理。
看樣子 @JsonFormat 效果不壞。問題是,稍微有點繁瑣,每個時間字段都要配置一遍。幸運的是,spring boot 支持全局設置 Jackson 時間序列化格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss # 全局時間格式 time-zone: GMT+8 # 指定默認時區(qū),除非時間字段已指定時區(qū),否則 JSON 序列化時都會使用此時區(qū)
更加幸運的是,@JsonFormat 優(yōu)先級比全局配置更高,讓我們可以實現某些要求特殊格式的需求。
似乎只要組合 spring.jackson.date-format
和 @JsonFormat,我們就可以無所不能了。沒有人比我更懂時間序列化!
可惜的是,spring.jackson.date-format
不支持新版時間類型。是的,在 2024 年,距離 java.time
包發(fā)布已經十年了,Spring 的序列化配置仍然不支持 LocalDateTime 類型。如果你要序列化 LocalDateTime 類型,最簡單的辦法就是使用 @JsonFormat。因為 @JsonFormat 是 Jackson 提供的注解。Spring 對此毫無作為。
發(fā)完牢騷,考慮如何全局配置 LocalDateTime 的格式化規(guī)則。方案有很多種,最簡單的就是明確地告訴 Jackson,LocalDateTime 等類型按照某某格式序列化和反序列化。
// 大概是 JacksonConfig 之類的類 @Bean public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() { return builder -> { // formatter DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss"); // deserializers builder.deserializers(new LocalDateDeserializer(dateFormatter)); builder.deserializers(new LocalDateTimeDeserializer(dateTimeFormatter)); builder.deserializers(new LocalTimeDeserializer(timeFormatter)); // serializers builder.serializers(new LocalDateSerializer(dateFormatter)); builder.serializers(new LocalDateTimeSerializer(dateTimeFormatter)); builder.serializers(new LocalTimeSerializer(timeFormatter)); }; }
上述代碼為三種類型構建了不同的 DateTimeFormatter
(java.time 包提供的格式化工具,線程安全),然后為每種類型綁定序列化器(Serializer)和反序列化器(Deserializer)。
現在 Local 系統的日期類型就跟 Date 表現一致了。
總結一下,在 JSON 序列化時:
- 如果使用了 Date 類型,可以用
spring.jackson.date-format
和 @JsonFormat 的組合來適應不同格式化要求 - 如果使用了 LocalDateTime 等類型,需要配置 Jackson,綁定序列化器和反序列化器,再結合 @JsonFormat 方能從心所欲
但此時還沒結束,也并非結束的開始,只是開始的結束~
請求參數
除了 JSON 序列化,還有一種場景,也會涉及時間序列化。那就是請求參數中的時間字段,最常見的就是 Controller 方法中沒有用 @RequestBody
標記的對象參數,比如 GET 請求,比如表單提交(application/x-www-form-urlencoded
)的 POST 請求。
為了便于展示,在 BoomController 中添加一個新的接口方法。
@GetMapping("query") public BoomData queryBoomData(BoomData boomData) { log.info("boomData: {}", boomData); return boomData; }
一個比較常用的 Query 接口的寫法。試著調用一下。
GET http://localhost:8080/boom/query?localDateTime=2024-10-10T21:07:08.781283&date=2024-10-10T21:07:08.781+08:00
報錯,field 'date': rejected value [2024-10-10T21:07:08.781+08:00]。
再試試
GET http://localhost:8080/boom/query?localDateTime=2024-10-10T21:07:08.781283&date=2024-10-10 21:07:08
還是報錯,field 'date': rejected value [2024-10-10 21:07:08]。
什么格式才能不報錯?
GET http://localhost:8080/boom/query?localDateTime=2024-10-10T21:07:08.781283&date=10/10/2024 21:07:08
沒錯,要用 dd/MM/yyyy
的格式。因為請求參數不歸 JSON 序列化管,而是由 Spring MVC 處理。Spring MVC 默認的 Date 類型格式就是 dd/MM/yyyy
。
要修改也簡單,@DateTimeFormat
,Spring 提供,專門處理時間參數格式化。
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date date; @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime localDateTime; @DateTimeFormat(pattern = "yyyy-MM-dd") private LocalDate localDate; @DateTimeFormat(pattern = "yyyy-MM-dd") private LocalTime localTime;
現在,正常處理請求 http://localhost:8080/boom/query?localDateTime=2024-10-10 21:07:08&date=2024-10-10 21:07:08&localDate=2024-10-10&localTime=11:09:15
。
又到了尋找全局配置的時間了。
spring: mvc: format: date: yyyy-MM-dd HH:mm:ss # 對 Date 和 LocalDate 類型有效,LocalDate 會忽略時間部分 time: HH:mm:ss # 對 LocalTime 和 OffsetTime 有效 date-time: yyyy-MM-dd HH:mm:ss # LocalDateTime, OffsetDateTime, and ZonedDateTime
按需選擇即可。
總結一下,對于 GET 請求參數中的時間字段,和表單提交 POST 請求中的時間字段,可以通過 spring.mvc.format.date/time/date-time
來配置全局格式。
- 請求中只使用了 Date 類型,只需要配置
spring.mvc.format.date
- 如果使用了
java.time
包中的類型,需要根據類型選擇不同配置項
對于不使用全局配置的場景,用 @DateTimeFormat 指定單獨的時間格式。
一起來用時間戳吧
以上是使用時間字符串傳遞時間的情況,接下來,我們討論一下用時間戳格式。
先理解一下有關時間戳的概念:
GMT 時間,用來表示時區(qū),比如 GMT+8,就是指東 8 區(qū)的時間。單獨的 GMT 也可以看作 GMT+0,即 0 時區(qū)的時間,這個時區(qū)位于英國格林威治
UTC 時間,與 GMT 是相同的概念,也用來表示時區(qū),只不過 UTC 更精確一些。同樣,UTC+8 可以表示東 8 區(qū),單獨 UTC 表示 0 時區(qū)
Unix 紀 元(Unix Epoch),一個特定的時間點,1970 年 1 月 1 日 00:00:00 UTC(+0),也就是 0 時區(qū)中 1970 年元旦。這個時間點常用于計算機系統的時間起點,如同坐標軸上的 0。
指導了上述 3 個概念,時間戳的含義就容易解釋了,從 Unix 紀 元開始經過的毫秒數(或秒數,計算機常用毫秒)。把時間想象為一條長長的坐標軸,0 的位置是 Unix 紀 元,在那之后,真實世界的每一毫秒,都對應時間軸上的一個點。
時間戳用整數表示,一個長整數,具備時間字符串一樣的功能。因此,也可以用時間戳來傳遞時間信息。
如果我信誓旦旦地宣稱時間戳優(yōu)于時間字符串,肯定是十分主觀的判斷,但在接口中使用時間戳確實有一些亮晶晶的優(yōu)點。
- 時區(qū)無關性,時間戳的值固定為 UTC+0 時區(qū),無論位于哪個時區(qū),同一時刻,同一時間戳。這樣一來,就可以僅展示時考慮時區(qū),其他時候都不需要考慮時區(qū)
- 體積小,一個 long 值足矣,比時間字符串更簡短
- 兼容性好,不必考慮復雜的格式化規(guī)則
一些不可忽視的缺點:
- 可讀性差,時間戳沒有時間字符串直觀,需要一些輔助轉換工具,比如瀏覽器控制臺
- 秒級時間戳和毫秒時間戳可能混淆,使用前要約定好
用 long 型時間戳也不需要考慮序列化問題,大多數平臺都可以妥善處理 long 類型的序列化。但有些時候,在代碼中用 Date 和 LocalDateTime 等明確的類型還是比 long 更方便。所以可能有這么一個需求:在代碼中使用時間類型,在序列化時使用時間戳。也就是在 DTO 類中用 Date,在 JSON 字符串中用 long。
和使用時間字符串類型,這個需求也分為兩種情況:
- JSON 序列化轉換
- 請求參數轉換
二者要分開處理。
JSON 序列化中的時間戳
Spring 提供了一個配置項,控制 Jackson 在序列化時將時間類型處理為時間戳。
spring.jackson.serialization.write-dates-as-timestamps=true
此時,GET 請求中的 date 就會變成了 "date": 1728572627475
,POST 時也能正確地識別時間戳。
但是,只有 Date 才有這種優(yōu)渥的待遇,java.time
包的類型仍然面臨自己動手豐衣足食的窘境。
開啟 write-dates-as-timestamps 后,LocalDateTime 等類型會被序列化為整形數組(回憶一下 LocalDateTime 的簡單公式)。
{ "date": 1728572627475, "localDateTime": [ 2024, 10, 10, 23, 3, 47, 475519000 ], "localDate": [ 2024, 10, 10 ], "localTime": [ 23, 3, 47, 475564000 ] }
也不能說有問題,畢竟 LocalDateTime 精確到納秒,直接轉換為毫秒時間戳,會丟失精度??傊?,要實現和諧轉換,需要設置 Jackson。
// 仍然是 JacksonConfig 之類的什么地方 @Bean public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() { return builder -> { // deserializers builder.deserializers(new LocalDateDeserializer()); builder.deserializers(new LocalDateTimeDeserializer()); // serializers builder.serializers(new LocalDateSerializer()); builder.serializers(new LocalDateTimeSerializer()); }; } public static class LocalDateTimeSerializer extends JsonSerializer<LocalDateTime> { /** * 如果沒有重寫 handledType() 方法,會報錯 * @return LocalDateTime.class */ @Override public Class<LocalDateTime> handledType() { return LocalDateTime.class; } @Override public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (value != null) { gen.writeNumber(value.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); } } } public static class LocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> { @Override public Class<?> handledType() { return LocalDateTime.class; } @Override public LocalDateTime deserialize(JsonParser parser, DeserializationContext deserializationContext) throws IOException { long timestamp = parser.getValueAsLong(); return Instant.ofEpochMilli(timestamp).atZone(ZoneId.systemDefault()).toLocalDateTime(); } } public static class LocalDateSerializer extends JsonSerializer<LocalDate> { @Override public Class<LocalDate> handledType() { return LocalDate.class; } @Override public void serialize(LocalDate value, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (value != null) { gen.writeNumber(value.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()); } } } public static class LocalDateDeserializer extends JsonDeserializer<LocalDate> { @Override public Class<?> handledType() { return LocalDate.class; } @Override public LocalDate deserialize(JsonParser parser, DeserializationContext deserializationContext) throws IOException { long timestamp = parser.getValueAsLong(); return Instant.ofEpochMilli(timestamp).atZone(ZoneId.systemDefault()).toLocalDate(); } }
這里我們進行了一些生硬的強制措施,定義了一系列 Deserializer 和 Serializer,實現了 LocalDateTime 和 long 之間的序列化規(guī)則。
沒有處理 LocalTime,因為單獨的時間轉換為時間戳不那么契合,時間戳有明確地年月日,這部分對于 LocalTime 顯得多余,而且時間通常與時區(qū)有關,處理時要更謹慎一些??梢愿鶕枨筮x擇,如果明確需要使用時間戳來表示 LocalTime,可以采用類似的方法,注冊 Deserializer 和 Serializer。
以上是在 JSON 序列化時將 Date、LocalDateTime 轉化為時間戳需要的配置:
- 如果只使用 Date,使用 Spring 提供的配置項
spring.jackson.serialization.write-dates-as-timestamps=true
即可 - 如果使用了 LocalDateTime,需要進行額外的配置,明確地指定 Jackson 將 LocalDateTime 轉換為時間戳
請求參數中的時間戳
在請求參數中使用時間戳復雜一些,因為不像時間字符串一樣有現成的配置,需要手動實現轉換規(guī)則。
可以利用 Converter 接口來解決這個問題。
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addFormatters(FormatterRegistry registry) { registry.addConverter(new LongStringToDateConverter()); registry.addConverter(new LongStringToLocalDateTimeConverter()); registry.addConverter(new LongStringToLocalDateConverter()); // registry.addConverter(new LongStringToLocalTimeConverter()); // 按需 } private static class LongStringToDateConverter implements Converter<String, Date> { @Override public Date convert(String source) { try { long timestamp = Long.parseLong(source); return new Date(timestamp); } catch (NumberFormatException e) { return null; } } } private static class LongStringToLocalDateTimeConverter implements Converter<String, LocalDateTime> { @Override public LocalDateTime convert(String source) { try { long timestamp = Long.parseLong(source); return Instant.ofEpochMilli(timestamp).atZone(ZoneId.systemDefault()).toLocalDateTime(); } catch (NumberFormatException e) { return null; } } } private static class LongStringToLocalDateConverter implements Converter<String, LocalDate> { @Override public LocalDate convert(String source) { try { long timestamp = Long.parseLong(source); return Instant.ofEpochMilli(timestamp).atZone(ZoneId.systemDefault()).toLocalDate(); } catch (NumberFormatException e) { return null; } } } private static class LongStringToLocalTimeConverter implements Converter<String, LocalTime> { @Override public LocalTime convert(String source) { try { long timestamp = Long.parseLong(source); return Instant.ofEpochMilli(timestamp).atZone(ZoneId.systemDefault()).toLocalTime(); } catch (NumberFormatException e) { return null; } } } }
注意 Source 類型為 String 而不是 Long,因為 Spring MVC 會將所有的接口請求參數類型統一視為 String,然后調用 Converter 轉換為其他類型。有許多內置的 Converter,比如轉換為 long 類型時,就使用了內置的 StringToNumber 轉換類。我們定義的 LongStringToDateConverter 與 StringToNumber 是平級的關系。
以上是在接口參數中將 Date、LocalDateTime 轉化為時間戳需要的處理:很簡單,注冊 Converter 即可。
Swagger UI 中的類型
使用 SwaggerUI 時,默認會使用 DTO 字段類型作為請求參數類型,也就是接收時間字符串。序列化時改為時間戳后,還需要在 Swagger UI 中統一。
Java 項目有兩種集成 Swagger 的方式,Springdoc 和 Spring Fox。Springdoc 更新,對應的配置如下:
@Bean public OpenAPI customOpenAPI() { // 關鍵是要調用這個靜態(tài)方法進行 replace SpringDocUtils.getConfig() .replaceWithClass(Date.class, Long.class) .replaceWithClass(LocalDateTime.class, Long.class) .replaceWithClass(LocalDate.class, Long.class); return new OpenAPI(); }
如果使用 Spring Fox,則需要使用另一種配置:
@Bean public Docket createRestApi() { return new Docket(DocumentationType.OAS_30) ... .build() // 重點是這句 .directModelSubstitute(LocalDateTime.class, Long.class); }
此時,在 Swagger UI 頁面調試接口時,時間類型的參數就顯示為整數了。
The Only Neat Thing to Do
回顧一下,在 Spring Boot 接口中處理時間字段序列化,涉及兩個場景:
- JSON 序列化
- GET 請求和表單提交請求中的參數
兩種情況要分開設置。
在 Java 類型選擇方面,Spring 對 Date 類型的支持比 LocalDateTime 好,有很多內置的配置,能省去很多麻煩。
如果要使用 LocalDateTime 等類型,在 JSON 序列化時要指定時間格式,在請求參數中也要指定時間格式。前者需要手動配置,后者可以使用 Spring 提供的配置項。
如果想要用時間戳傳遞數據,也需要分別設置,在 JSON 序列化時指定序列化器和反序列化器,在請求參數中綁定對應的 Converter 實現類。此外,統一 Swagger UI 的類型體驗更佳。
以上就是在Spring Boot接口中正確地序列化時間字段的方法的詳細內容,更多關于Spring Boot序列化時間字段的資料請關注腳本之家其它相關文章!