
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.00000: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-formatSpringBoot 支持在配置文件中直接设置全局日期格式最简单的写法如下。spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8这段配置里的date-format只对java.util.Date类型的序列化和反序列化生效time-zone则指定了时区。这里有个非常关键的点如果你配置了time-zone: GMT8Jackson 在处理日期时就会基于东八区进行转换否则默认使用的是服务器本地时区。很多时候测试环境服务器时区是 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 GMT8) 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?date2023-09-10。如果不加DateTimeFormatSpring 在把字符串2023-09-10转换成Date的时候使用的默认转换器只能识别yyyy/MM/dd这种斜杠格式一旦遇到横杠格式就会报错。DateTimeFormat也能标注在实体类的字段上用于表单绑定场景。它和JsonFormat的适用场景是有区别的前者管的是 SpringMVC 的参数绑定后者管的是 Jackson 的 JSON 序列化反序列化。很多初学者会把这两个注解混用导致问题出现时找不到头绪。2.4 局部注解三JsonFormat 与 Java 8 时间类型的配合如果实体类使用的是LocalDateTime而不是DateJsonFormat同样支持只是有一点细节需要注意。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 stringSpringMVC 参数转换器DateTimeFormatPOST JSON bodyJackson 反序列化JsonFormat 或自定义 DeserializerJsonFormat / 全局配置响应返回 JSONJackson 序列化JsonFormat 或自定义 SerializerJsonFormat / 全局配置表单提交SpringMVC 参数绑定DateTimeFormatDateTimeFormat这里容易出问题的是 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 GMT8) 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: GMT8重启之后再次访问接口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 中加入serverTimezoneAsia/Shanghai同时在spring.jackson.time-zone中显式指定GMT8。不要依赖服务器操作系统默认时区因为部署环境的时区不可控显式配置才能保证所有环境行为一致。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或检查自定义 ObjectMapperLocalDateTime 返回 ISO 格式未配置 LocalDateTime 序列化器注册 LocalDateTimeSerializer日期少 8 小时时区未指定为 GMT8配置 time-zone 和 JDBC serverTimezoneGET 参数解析日期报错缺 DateTimeFormatController 参数或表单对象加注解POST JSON 日期解析报错缺 JsonFormat 或对应反序列化器配置注解或注册 DeserializerJsonFormat 不生效字段类型不是 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所有时间统一为东八区”比任何代码配置都更能避免扯皮。格式统一了技术方案反而成了最简单的一环。