SpringBoot 接口返回日期格式化这件事,说大不大,说小不小。项目里十有八九会碰上一次,要么前端同事跑来问“这个时间字段怎么变成一串数字了”,要么测试提 bug 说“时间对不上,差了 8 个小时”。折腾过几次之后就会发现,只要把 SpringBoot 里日期格式化的几种方式捋清楚,这类问题基本能一次性根治。
这篇文章就把我实际项目里用过的几种方案完整拆开讲一遍,从最简单的单字段注解,到全局配置,再到 Jackson 自定义序列化器,以及容易踩坑的时区、LocalDateTime 兼容问题,最后附上线上问题排查实录。不管是刚入门的同学还是写过几年的老手,都能从中找到能直接用的东西。
1. 日期格式化问题到底出在哪
1.1 前后端联调时最常见的三种异常现象
我在不同项目里见过太多次类似的问题,基本逃不出下面这三种表现。
第一种,后端返回的日期字段是一长串数字,比如1694313600000,前端拿到之后直接渲染在页面上,用户看到的就是一串莫名其妙的天文数字。这个数字其实是标准的时间戳,也就是从 1970 年 1 月 1 日 00:00:00 UTC 开始计算出的毫秒数。Jackson 在默认情况下,会把java.util.Date序列化成这种格式,前端如果不做处理,展示出来就是这种效果。
第二种,返回的日期格式倒是字符串,但是格式不对,比如2023-09-10T08:00:00.000+00:00。这种带 T 和时区信息的 ISO 格式,是 Jackson 对java.time包下日期类型的一种默认处理方式。如果前端没有对应的格式化逻辑,直接展示,用户看到的依然不是想要的效果。
第三种,更隐蔽一些,前端传过来的日期字符串后端解析失败,接口直接报 400 错误。这种情况通常发生在 POST 请求的 JSON body 中,比如前端传"2023-09-10 10:30:00",后端接收字段类型是Date,默认情况下 Jackson 无法把这种带横杠和空格的字符串转换成Date对象,自然就报错了。
这三种现象的根源,本质上都是同一个问题:后端没有明确告诉 Jackson 应该用什么样的格式去序列化和反序列化日期字段。
1.2 为什么默认配置无法满足真实项目需求
SpringBoot 的spring-boot-starter-web会自动配置好 Jackson,但默认配置对日期的处理非常“原始”。如果不做任何干预,Jackson 对java.util.Date输出的是时间戳,对LocalDateTime输出的是 ISO 格式字符串,对LocalDate输出的是yyyy-MM-dd。
问题在于,真实业务场景中日期格式的规范往往是由前端页面或接口文档决定的。比如管理后台的表格列通常展示yyyy-MM-dd HH:mm:ss,移动端界面上可能只需要yyyy-MM-dd,而某些报表导出场景需要yyyyMMdd这种紧凑格式。一个项目里不同接口返回不同格式的需求非常常见,默认配置根本不可能覆盖。
所以,作为开发人员,我们需要掌握至少几种不同维度的日期格式化手段,才能在面对各种需求时做到游刃有余。
2. 全局配置与局部注解的选型对比
2.1 全局配置一:application.yml 中的 spring.jackson.date-format
SpringBoot 支持在配置文件中直接设置全局日期格式,最简单的写法如下。
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8这段配置里的date-format只对java.util.Date类型的序列化和反序列化生效,time-zone则指定了时区。这里有个非常关键的点:如果你配置了time-zone: GMT+8,Jackson 在处理日期时就会基于东八区进行转换,否则默认使用的是服务器本地时区。很多时候测试环境服务器时区是 UTC,本地开发环境是东八区,就会导致同一个接口在不同环境返回的时间不一样,这类问题排查起来相当耗时间。
这套配置的体积最小,维护成本最低,适合整个项目所有日期字段格式统一的情况。但它的局限性也很明显,如果同一个项目里既需要yyyy-MM-dd HH:mm:ss,又需要yyyy-MM-dd,全局配置就搞不定了。
2.2 局部注解一:@JsonFormat 单字段格式化
@JsonFormat是 Jackson 提供的注解,可以直接标注在实体类的字段上,对单个字段进行格式化控制。这是最常用、最容易理解的方式。
public class OrderVO { @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private Date createTime; }加了注解之后,这个字段在序列化返回给前端时,就会输出成2023-09-10 10:30:00这样的字符串。反向来说,当前端 POST 请求携带"2023-09-10 10:30:00"这样的字符串,后端在反序列化时也能正确解析成Date对象。
@JsonFormat的好处是精准控制,字段级的格式化不会影响其他字段。但坏处也很直接,如果实体类里日期字段非常多,比如createTime、updateTime、payTime、publishTime一大排,每个字段都要重复写一遍注解,看起来非常冗余,而且容易漏掉。
2.3 局部注解二:@DateTimeFormat 表单参数格式化
@DateTimeFormat是 Spring 框架自己的注解,注意不是 Jackson 的,它的核心作用是处理表单提交和 URL 请求参数中的日期字符串。
@GetMapping("/query") public Result query(@RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") Date date) { // ... }这种场景适用于 GET 请求参数,比如/query?date=2023-09-10。如果不加@DateTimeFormat,Spring 在把字符串2023-09-10转换成Date的时候,使用的默认转换器只能识别yyyy/MM/dd这种斜杠格式,一旦遇到横杠格式就会报错。
@DateTimeFormat也能标注在实体类的字段上,用于表单绑定场景。它和@JsonFormat的适用场景是有区别的,前者管的是 SpringMVC 的参数绑定,后者管的是 Jackson 的 JSON 序列化反序列化。很多初学者会把这两个注解混用,导致问题出现时找不到头绪。
2.4 局部注解三:@JsonFormat 与 Java 8 时间类型的配合
如果实体类使用的是LocalDateTime而不是Date,@JsonFormat同样支持,只是有一点细节需要注意。
public class OrderVO { @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTime; }对于LocalDateTime类型,@JsonFormat里的timezone属性实际上没有意义,因为LocalDateTime本身不带时区信息。只需要指定pattern就够了。
这里顺带提一个经常被忽略的坑:如果你用的是LocalDate,那 pattern 只能是yyyy-MM-dd,如果写成了yyyy-MM-dd HH:mm:ss,序列化的时候会直接抛出异常,因为LocalDate根本没有时分秒的概念。
3. 全局统一配置的进阶方案
3.1 自定义 Jackson 配置类实现全局格式化
当项目里的日期格式统一,但你又想对Date、LocalDateTime、LocalDate同时生效时,application.yml 里的spring.jackson.date-format就不够用了。这时候可以自定义一个 Jackson 配置类。
@Configuration public class JacksonConfig { private static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss"; private static final String DATE_PATTERN = "yyyy-MM-dd"; @Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder -> { builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer( DateTimeFormatter.ofPattern(DATE_TIME_PATTERN))); builder.serializerByType(LocalDate.class, new LocalDateSerializer( DateTimeFormatter.ofPattern(DATE_PATTERN))); builder.deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer( DateTimeFormatter.ofPattern(DATE_TIME_PATTERN))); builder.deserializerByType(LocalDate.class, new LocalDateDeserializer( DateTimeFormatter.ofPattern(DATE_PATTERN))); }; } }这里注册了Jackson2ObjectMapperBuilderCustomizer,它会在 SpringBoot 自动配置的 ObjectMapper 基础上进行定制,既保留了默认配置,又覆盖了我们关心的日期类型。
这套方案的优点是:所有LocalDateTime字段统一输出成yyyy-MM-dd HH:mm:ss,所有LocalDate字段统一输出成yyyy-MM-dd,不再需要在每个字段上重复加注解。注意LocalDateTime序列化和反序列化的 pattern 要保持一致,否则会出现“序列化正常返回,但前端传同样格式的数据却解析失败”这种不对称问题。
3.2 全局配置与 @JsonFormat 的优先级问题
在实际项目中,全局配置和字段注解往往同时存在。如果不清楚它们的优先级,就会遇到“明明配置了全局格式,但某个字段就是不生效”的奇怪现象。
结论很简单:@JsonFormat注解的优先级高于全局配置。只要字段上写了@JsonFormat,不管全局配置里设置了什么格式,都以注解为准。
这个优先级设计其实很合理,全局配置管的是统一默认值,注解管的是特殊定制。比如全局统一为yyyy-MM-dd HH:mm:ss,但有一个比分秒都不需要的生日字段,就可以单独加注解@JsonFormat(pattern = "yyyy-MM-dd"),而不用再单独建一套序列化逻辑。
理解了这一点之后,遇到个别字段格式不一致的问题,就不用再怀疑是配置没生效了,直接检查字段上有没有注解即可。
3.3 利用配置文件上的格式定义与时间戳返回模式
有时候,我们需要在某些接口里返回时间戳,而不是格式化字符串。比如签名校验、客户端兼容、第三方对接等场景,时间戳反而比字符串更合适。
在全局配置了日期格式的情况下,如果单独某个字段想返回时间戳,可以在字段上加@JsonSerialize(using = ToStringSerializer.class)之类的注解,或者直接使用 Jackson 的@JsonFormat(shape = Shape.NUMBER)。
public class CallbackVO { @JsonFormat(shape = JsonFormat.Shape.NUMBER) private Date timestamp; }这样处理之后,该字段仍然不受全局日期格式影响,会以毫秒时间戳的形式输出。这个技巧在对接外部系统时尤其好用,避免了为了个别字段再单独建 DTO 的麻烦。
4. 底层原理与核心机制拆解
4.1 Jackson 序列化器与反序列化器的工作流程
要真正理解日期格式化,不能只停留在注解和配置层面,还是要看 Jackson 底层的序列化流程。
当 SpringBoot 把 Controller 方法的返回值通过MappingJackson2HttpMessageConverter转成 JSON 时,会调用ObjectMapper.writeValueAsString()。这个过程中,Jackson 会根据每个字段的运行时类型,找到对应的JsonSerializer。如果字段是java.util.Date,默认对应DateSerializer;如果字段是LocalDateTime,默认对应LocalDateTimeSerializer。整个选择链路是:先看字段上有无注解,再看 ObjectMapper 里有没有注册该类型的自定义序列化器,最后才会走内置默认序列化器。
反序列化流程则是反向的。请求体中的 JSON 字符串进来后,Jackson 会根据目标类型找到对应的JsonDeserializer。DateDeserializer可以解析时间戳和多种 date 格式字符串,但解析范围有限,当遇到2023-09-10 10:30:00这种格式时,就必须显式指定 pattern。
4.2 时区问题的本质:UTC、东八区与本地时间的转换
时区问题在日期格式化中非常容易出问题,也最让人头疼。简单来说,java.util.Date内部存的是一个绝对时间点,不携带时区信息。当我们用yyyy-MM-dd HH:mm:ss这种格式去格式化它时,必须基于某个时区来展示,同一个Date在 UTC 时区显示的时间和在东八区显示的时间能差出 8 个小时。
举个例子,一个Date的内部毫秒数是1694313600000,这个值在任何时区都相同。但在 UTC 时区格式化出来是2023-09-10 00:00:00,在东八区格式化出来是2023-09-10 08:00:00。
所以,如果服务器时区是 UTC,而你期望返回给前端的时间是东八区的时间,就必须在配置或注解中显式指定 timezone。最稳妥的做法是后端所有时间统一存储为标准 UTC 时间,对外输出时统一转换到指定时区,并且明确告诉前端这个时间是什么时区的。
4.3 LocalDateTime 与 Date 的行为差异
不少项目从 JDK 8 开始就把实体类里的Date替换成了LocalDateTime。这两种类型在 Jackson 中的默认序列化行为差异很大。
Date默认输出时间戳,LocalDateTime默认输出带 T 的 ISO 格式字符串,比如2023-09-10T10:30:00。如果你在代码里混用了这两种类型,又没有统一配置,前端拿到的数据就会有两种截然不同的格式,页面展示会很乱。
从设计角度看,Date是一个具有时区概念的瞬时时间点,更适合表示“某一时刻”;LocalDateTime不带时区,更适合表示“本地墙上时间”。在多数业务系统里,存储字段时用LocalDateTime很直观,但在需要跨时区计算的场景下,Date或Instant会更合适。理解了这一点,再选择格式化方案时就会有更清晰的依据。
5. 多种格式化方式的实操对比
5.1 四种方案适用场景与优劣势对照
我之前在项目里把这几种方案做过横向对比,直接整理成了一张表,方便大家按需求快速选型。
| 方案 | 配置位置 | 适用场景 | 优势 | 劣势 |
|---|---|---|---|---|
| application.yml 全局配置 | 配置文件 | 全项目统一格式,无特殊定制 | 成本最低,一行配置搞定 | 只对 Date 生效,LocalDateTime 无效 |
| @JsonFormat 字段注解 | 实体类字段 | 个别字段格式特殊 | 精准控制,优先级最高 | 字段多时冗余,容易漏写 |
| @DateTimeFormat 参数注解 | Controller 参数或表单对象 | GET 请求参数、表单提交 | 解决参数绑定问题 | 不管 JSON 序列化输出 |
| 自定义 Jackson 配置 | 配置类 | 全局统一 LocalDate / LocalDateTime | 统一管理 Java 8 时间类型 | 需要写 Java 代码,维护成本略高 |
从我的实践来看,最常用的组合是“全局自定义 Jackson 配置 + 个别字段 @JsonFormat 覆盖”。这样既能保证大多数日期字段输出格式一致,又给特殊字段留了口子。
5.2 常见请求场景下的配置对照
针对不同请求方式,日期处理的渠道也不同,我画了下面对照。
| 请求方式 | 日期数据来源 | 生效机制 | 推荐配置 |
|---|---|---|---|
| GET 请求参数 | URL query string | SpringMVC 参数转换器 | @DateTimeFormat |
| POST JSON body | Jackson 反序列化 | @JsonFormat 或自定义 Deserializer | @JsonFormat / 全局配置 |
| 响应返回 JSON | Jackson 序列化 | @JsonFormat 或自定义 Serializer | @JsonFormat / 全局配置 |
| 表单提交 | SpringMVC 参数绑定 | @DateTimeFormat | @DateTimeFormat |
这里容易出问题的是 GET 请求加 @JsonFormat 不生效。因为 GET 请求的 query string 走的是 SpringMVC 的 ConversionService 机制,根本不经过 Jackson。很多同学在 GET 请求参数上写了 @JsonFormat,发现没反应,原因就在这里。
5.3 不同日期类型的完整示例代码
考虑到很多同学想直接复制代码,我写了一个完整的示例,把Date、LocalDateTime、LocalDate三类字段分别展示出来。
public class UserVO { @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private Date createDate; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTime; @JsonFormat(pattern = "yyyy-MM-dd") private LocalDate birthday; }当前端 POST 请求向/user接口提交如下 JSON 时:
{ "createDate": "2023-09-10 10:30:00", "createTime": "2023-09-10 10:30:00", "birthday": "2000-01-01" }三个字段都能被正确解析。接口返回时,也会按照各自 pattern 输出,不会互相干扰。注意Date的 timezone 不能省略,而LocalDateTime、LocalDate写了也没意义。
6. 实战案例:从零搭建统一日期格式化配置
6.1 步骤一:引入依赖并确认默认行为
新建一个 SpringBoot 项目,引入spring-boot-starter-web即可,不需要额外添加 Jackson 依赖,因为 starter 里已经包含了。
先写一个简单的接口,返回一个包含Date、LocalDateTime的 VO,不配置任何日期格式化,直接跑起来看返回结果。
@RestController @RequestMapping("/demo") public class DemoController { @GetMapping("/date") public DemoVO getDate() { DemoVO vo = new DemoVO(); vo.setNow(new Date()); vo.setLocalNow(LocalDateTime.now()); return vo; } }接口返回的内容大致是这个样子。
{ "now": 1694313600000, "localNow": "2023-09-10T10:30:00.000" }这就是默认行为,接下来我们的目标就是改变这个返回结果。
6.2 步骤二:通过配置文件快速验证 Date 类型格式化
在application.yml中加入下列配置。
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8重启之后,再次访问接口,now字段会变为2023-09-10 10:30:00,但localNow仍然是 ISO 格式。这个实验能让我们直观地确认:配置文件里的date-format只对Date生效。
如果此时前端同事反馈 LocalDateTime 字段格式不对,不要犹豫,直接进入第三步。
6.3 步骤三:自定义配置类覆盖 Java 8 时间类型
引入一个配置类,注册针对LocalDateTime和LocalDate的序列化器与反序列化器。
@Configuration public class JacksonConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer dateTimeCustomizer() { return builder -> { DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(dateTimeFormatter)); builder.serializerByType(LocalDate.class, new LocalDateSerializer(dateFormatter)); builder.deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer(dateTimeFormatter)); builder.deserializerByType(LocalDate.class, new LocalDateDeserializer(dateFormatter)); }; } }再次重启服务,now和localNow都会以yyyy-MM-dd HH:mm:ss格式输出。
6.4 步骤四:验证 POST 请求体的反序列化效果
光验证返回还不够,反序列化同样需要验证。写一个 POST 接口,接收上面的DemoVO。
@PostMapping("/date") public DemoVO createDate(@RequestBody DemoVO vo) { return vo; }POST 请求体传入 JSON 字符串,比如{"now":"2023-09-10 10:30:00","localNow":"2023-09-10 10:30:00"},分别测试以下场景:
- 只配置
spring.jackson.date-format时,now可以解析成功,localNow解析失败。 - 加入自定义配置类之后,
localNow也能解析成功。 - 如果传入
2023-09-10T10:30:00,在配置了反序列化 pattern 后反而解析失败,因为格式不匹配。
这里要特别注意:反序列化 pattern 和序列化 pattern 必须一致,否则就会出现“前端能接收你返回的数据,但后端却解析不了前端传回来的数据”的故障。
6.5 步骤五:特殊字段单独覆盖的完整示例
假设某个页面需要一个yyyy/MM/dd格式的日期字段,其他字段全部使用默认的yyyy-MM-dd HH:mm:ss。这时只要在对应字段上加注解即可。
public class SpecialVO { @JsonFormat(pattern = "yyyy/MM/dd") private Date specialDate; }全局配置依然保持yyyy-MM-dd HH:mm:ss,其他字段不会受影响,只有这个标注过的字段输出yyyy/MM/dd。这就是注解优先级高于全局配置的好处。
7. 线上问题排查与避坑实录
7.1 前端收到一串数字:Date 字段未走格式化配置
有一次线上反馈,列表页的时间列一直显示数字。排查下来发现,该字段所在的实体类是一个继承出来的子类,子类没有单独对Date字段做处理,而全局配置文件中只配置了spring.jackson.date-format。按道理应该对Date生效,结果却不生效。
最终定位是项目里同事在某个配置类中手动 new 了一个ObjectMapper,并把它注入到了 SpringMVC 的HttpMessageConverter里。这个手动创建的ObjectMapper没有读取application.yml中的 Jackson 配置项,所以全局配置全部失效。
这个问题非常隐蔽。遇到“配置了却不生效”的情况,优先检查是否存在手动创建的 ObjectMapper 或自定义的MappingJackson2HttpMessageConverter。如果有,请确认它们是否应用了 SpringBoot 的自动配置,比如Jackson2ObjectMapperBuilder。
7.2 日期少 8 小时:时区配置缺失
另一个常见问题是,接口返回的时间总是比数据库里的时间少 8 个小时。这个现象的成因是:服务器 JVM 默认时区为 UTC,数据库连接时区为东八区,JDBC 把数据库时间读出来转换成了 UTC 时间,Jackson 又是基于 UTC 时区序列化出的字符串,最终前端看到的时间自然就少了 8 个小时。
针对这种情况,最直接的解决方式是在连接数据库的 URL 中加入serverTimezone=Asia/Shanghai,同时在spring.jackson.time-zone中显式指定GMT+8。不要依赖服务器操作系统默认时区,因为部署环境的时区不可控,显式配置才能保证所有环境行为一致。
7.3 LocalDateTime 反序列化报错:格式不匹配
在一次 POST 接口调试过程中,前端传过来的是2023-09-10 10:30:00,后端接口报了类似下面这样的错误。
Cannot deserialize value of type java.time.LocalDateTime from String "2023-09-10 10:30:00"报错的原因很简单,后端没有为LocalDateTime注册对应的反序列化器,或者注册的 pattern 是yyyy-MM-dd,无法解析带时分秒的字符串。解决方式就是给LocalDateTime注册 pattern 为yyyy-MM-dd HH:mm:ss的反序列化器,并保证序列化与反序列化 pattern 一致。
这里有一个特别容易忽略的细节:LocalDateTime和LocalDate的反序列化器必须是同一个 pattern 下配套注册。只注册序列化器,不注册反序列化器,接口返回正常,但接收参数时就会报错。
7.4 常见问题速查表
把上面这些常见问题整理成一张速查表,方便后续排查直接对照。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| Date 字段返回时间戳 | 未配置 date-format,或 ObjectMapper 被手动覆盖 | 配置全局 date-format,或检查自定义 ObjectMapper |
| LocalDateTime 返回 ISO 格式 | 未配置 LocalDateTime 序列化器 | 注册 LocalDateTimeSerializer |
| 日期少 8 小时 | 时区未指定为 GMT+8 | 配置 time-zone 和 JDBC serverTimezone |
| GET 参数解析日期报错 | 缺 @DateTimeFormat | Controller 参数或表单对象加注解 |
| POST JSON 日期解析报错 | 缺 @JsonFormat 或对应反序列化器 | 配置注解或注册 Deserializer |
| @JsonFormat 不生效 | 字段类型不是 Jackson 处理的类型,或注解位置错误 | 确认类型、注解放在 getter/setter 或字段上 |
7.5 一个小技巧:利用 ObjectMapper 直接做单元测试
调试日期格式化问题时,不一定要启动整个 SpringBoot 项目。可以直接写一个单元测试,注入 ObjectMapper 来验证序列化和反序列化行为。
@SpringBootTest class DateFormatTest { @Autowired private ObjectMapper objectMapper; @Test void testSerialize() throws Exception { DemoVO vo = new DemoVO(); vo.setNow(new Date()); String json = objectMapper.writeValueAsString(vo); System.out.println(json); } }这种方式能快速验证配置是否生效,定位问题速度非常快。我一般在修改日期格式配置之后,都会先跑一遍这类测试,再启动项目做集成验证,能省下不少调试时间。
8. 我个人在实际项目中的最终建议
做了这么多年后端,日期格式化这块给我的感受是:方案都不复杂,但坑特别多。最主要的原因不是技术难,而是“默认行为”和“期望行为”之间的落差太大,而且不同版本、不同依赖组合下表现还会有差异。
我个人比较推荐的做法是:新项目直接从最开始就用自定义 Jackson 配置类统一管理LocalDateTime和LocalDate的序列化与反序列化,Date类型尽量不要再出现在 VO 中。业务实体里存储可以用LocalDateTime,对外 VO 也保持LocalDateTime,统一格式为yyyy-MM-dd HH:mm:ss,特殊字段再加@JsonFormat覆盖。这样整个项目的日期处理链路清晰,排查问题也方便。
最后分享一个小技巧:团队协作时,把日期格式规范写进接口文档或团队规范里,明确“接口入参和出参全部使用yyyy-MM-dd HH:mm:ss,所有时间统一为东八区”,比任何代码配置都更能避免扯皮。格式统一了,技术方案反而成了最简单的一环。