在 java 项目中处理时间序列化从来就不是容易的事。一方面要面临多变的时间格式,年月日时分秒,毫秒,纳秒,周,还有讨厌的时区,稍不注意就可能收获一堆异常,另一方面,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 来提高易用性。
两种类型在记录的信息方面有一点区别:
date
的时间精度为毫秒,内部实际是一个 long 类型时间戳。此外还记录了时区信息,简单记录为date = timestamp + timezone
。如果没有提供时区,默认使用系统时区。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 没有时区信息,这也是类名中 local 的含义,代表使用本地时区。如果需要时区信息,可以用
zoneddatetime
类型,zoneddatetime = localdatetime + tz
。
了解了两个版本时间类型的区别,再看它们的序列化差异。
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 序列化,对不同的时间类型有不同的格式化规则:
date
默认按照 iso 标准格式化localdatetime
也按照 iso 标准处理,精确到微秒,少了时区localdate
和localtime
与 localdatetime 处理方式相似
所谓 iso 标准,指的是 iso 8601 标准,一种专门处理日期时间格式的国际标准。将时间日期组合按 yyyy-mm-dd't'hh:mm:ss.sssxxx
格式处理,比如 2024-10-10t21:07:08.781+08:00
,其中字母 t 为日期和时间分隔符,日期表示为年-月-日,时间表示为时:分:秒.毫秒。格式中的 xxx
指的是时区信息,对于东 8 区,表示为 +08:00
。
默认情况下,调用 post 接口,也需要保证 body 中的 json 串按照 iso 8601 的格式处理时间字段,才能正常反序列化,否则 spring boot 会抛出异常。当然,时间格式的要求也没那么严格,可以省略时区、微秒、毫秒、秒,都能正常反序列化,但 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 # 指定默认时区,除非时间字段已指定时区,否则 json 序列化时都会使用此时区
更加幸运的是,@jsonformat 优先级比全局配置更高,让我们可以实现某些要求特殊格式的需求。
似乎只要组合 spring.jackson.date-format
和 @jsonformat,我们就可以无所不能了。没有人比我更懂时间序列化!
可惜的是,spring.jackson.date-format
不支持新版时间类型。是的,在 2024 年,距离 java.time
包发布已经十年了,spring 的序列化配置仍然不支持 localdatetime 类型。如果你要序列化 localdatetime 类型,最简单的办法就是使用 @jsonformat。因为 @jsonformat 是 jackson 提供的注解。spring 对此毫无作为。
发完牢骚,考虑如何全局配置 localdatetime 的格式化规则。方案有很多种,最简单的就是明确地告诉 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 时间,用来表示时区,比如 gmt+8,就是指东 8 区的时间。单独的 gmt 也可以看作 gmt+0,即 0 时区的时间,这个时区位于英国格林威治
utc 时间,与 gmt 是相同的概念,也用来表示时区,只不过 utc 更精确一些。同样,utc+8 可以表示东 8 区,单独 utc 表示 0 时区
unix 纪 元(unix epoch),一个特定的时间点,1970 年 1 月 1 日 00:00:00 utc(+0),也就是 0 时区中 1970 年元旦。这个时间点常用于计算机系统的时间起点,如同坐标轴上的 0。
指导了上述 3 个概念,时间戳的含义就容易解释了,从 unix 纪 元开始经过的毫秒数(或秒数,计算机常用毫秒)。把时间想象为一条长长的坐标轴,0 的位置是 unix 纪 元,在那之后,真实世界的每一毫秒,都对应时间轴上的一个点。
时间戳用整数表示,一个长整数,具备时间字符串一样的功能。因此,也可以用时间戳来传递时间信息。
如果我信誓旦旦地宣称时间戳优于时间字符串,肯定是十分主观的判断,但在接口中使用时间戳确实有一些亮晶晶的优点。
- 时区无关性,时间戳的值固定为 utc+0 时区,无论位于哪个时区,同一时刻,同一时间戳。这样一来,就可以仅展示时考虑时区,其他时候都不需要考虑时区
- 体积小,一个 long 值足矣,比时间字符串更简短
- 兼容性好,不必考虑复杂的格式化规则
一些不可忽视的缺点:
- 可读性差,时间戳没有时间字符串直观,需要一些辅助转换工具,比如浏览器控制台
- 秒级时间戳和毫秒时间戳可能混淆,使用前要约定好
用 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 才有这种优渥的待遇,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 之间的序列化规则。
没有处理 localtime,因为单独的时间转换为时间戳不那么契合,时间戳有明确地年月日,这部分对于 localtime 显得多余,而且时间通常与时区有关,处理时要更谨慎一些。可以根据需求选择,如果明确需要使用时间戳来表示 localtime,可以采用类似的方法,注册 deserializer 和 serializer。
以上是在 json 序列化时将 date、localdatetime 转化为时间戳需要的配置:
- 如果只使用 date,使用 spring 提供的配置项
spring.jackson.serialization.write-dates-as-timestamps=true
即可 - 如果使用了 localdatetime,需要进行额外的配置,明确地指定 jackson 将 localdatetime 转换为时间戳
请求参数中的时间戳
在请求参数中使用时间戳复杂一些,因为不像时间字符串一样有现成的配置,需要手动实现转换规则。
可以利用 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() { // 关键是要调用这个静态方法进行 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序列化时间字段的资料请关注代码网其它相关文章!
发表评论