搞了几年 SpringBoot 接口开发,我最大的感受是:真正让前后端在联调阶段反复拉扯的,往往不是什么高深的技术难题,而是接口日期格式化。前端同学问“为什么返回的 createTime 是一串数字”,后端同学反问“前端传的 2024-01-15 10:30:00 为什么到我这里成了 null”。两边对着日志查半天,最后定位到根因,基本都是接口日期格式化方法没统一。这篇内容我把自己实际用过的、以及在项目里和同事讨论比较多的几种 SpringBoot 日期处理方案整理出来,包括注解、全局配置、自定义反序列化器,以及绕不开的时区问题,希望给还在被日期折腾的同学一个可以直接抄作业的参考。
这个问题的适用范围很广:用 SpringBoot 写接口的 Java 开发、正在做前后端联调的前端同学、以及刚接触 SpringBoot 想做对接口的小白,都能从中找到对应自己场景的解法。下面从最基础的方案开始,一层层往深了讲。
1. 为什么接口里的日期这么难搞
1.1 一个日期字段的三段旅程
一个日期字段在接口生命周期里会经历三个阶段:前端把参数传给后端、后端做业务处理、后端把结果返回给前端。这三个阶段对应的数据形态完全不同,处理策略自然也要分开设计。
第一阶段是入参解析。前端传参的方式又分几种:URL 查询参数、表单提交、JSON body。URL 参数和表单提交是纯文本,SpringMVC 要把字符串“翻译”成 Java 日期对象;JSON body 则由 Jackson 完成反序列化。第二阶段是业务处理,这时候日期在内存里,就是一个普通的 Java 对象,基本不涉及格式化问题。第三阶段是响应输出,Jackson 要把日期对象序列化成字符串返回给前端。很多同学只盯着“输出格式”改,忽略了入参解析那一步,结果就是前端传进来的各种格式没法被正确识别。
这里的核心认知是:日期格式化不是一个单一动作,而是“反序列化”和“序列化”两个动作的组合。搞清楚当前配置作用于哪个阶段,是解决所有日期问题的第一步。
1.2 两种类型体系的行为差异
Java 生态里存在两套日期类型体系,这个大家基本都知道:一套是老的java.util.Date、java.sql.Date,另一套是 Java 8 引入的java.time包,比如LocalDate、LocalDateTime、Instant。
SpringBoot 默认集成 Jackson,并且会自动注册JavaTimeModule来处理 Java 8 日期类型。问题在于,这两套类型在 Jackson 里的默认行为完全不同:java.util.Date默认序列化成时间戳数字,而LocalDateTime在没有配置的情况下默认序列化成 ISO 格式字符串,比如2024-01-15T10:30:00,如果关闭了某些开关,甚至可能输出成 JSON 数组[2024, 1, 15, 10, 30, 0]。
这意味着,你在配置文件里写一个date-format,有可能只对Date生效,对LocalDateTime完全无效。很多人在这个坑里爬了半天,最后发现问题出在类型体系上。我建议先确认项目里日期的类型选择,再决定用哪一套方案。
2. 方法一:字段注解,单点精确控制
2.1 两个注解的分工逻辑
SpringBoot 里最常见的两个日期注解是@DateTimeFormat和@JsonFormat。它们看起来是“兄弟”,实际上分属两套框架,作用范围完全不同。
@DateTimeFormat是 Spring Framework 自己提供的注解,核心作用是在 SpringMVC 做参数绑定时,把字符串转换成日期对象。它管的是 URL 查询参数、表单参数这类非 JSON 场景。比如你在 Controller 方法上写一个@RequestParam参数,或者接收一个表单提交的 POJO,@DateTimeFormat就负责把里面的字符串按指定格式解析成日期。
@JsonFormat来自 Jackson 库,核心作用是在 JSON 序列化和反序列化时控制日期格式。它管的是@RequestBody接收的 JSON body,以及响应对象序列化成 JSON 返回给前端的过程。
我见过不少新人踩同一个坑:在接收 JSON body 的 DTO 字段上只写了@DateTimeFormat,结果前端传"2024-01-15 10:30:00"进来,后端字段依然是 null,或者直接报 400。因为 JSON body 的解析根本不走 SpringMVC 的绑定逻辑,是 Jackson 在做反序列化,这时必须用@JsonFormat。类似的,只在字段上写@JsonFormat,然后通过表单提交或者 URL 参数传日期,也可能不生效,因为那一步不是 Jackson 在处理。
正确的分工是这样的:请求参数是拼接在 URL 上或者来自表单提交,用@DateTimeFormat;请求参数是 JSON body、响应体需要控制格式,用@JsonFormat。一个字段如果既会被表单方式提交,又会被 JSON 方式提交,那就两个注解一起写,各管各的。
2.2 实操示例与重点细节
来个我能直接复制的完整示例。假设有一个创建订单的接口,入参 DTO 里有下单时间和备注,响应 VO 里有创建时间:
public class OrderCreateRequest { // 表单提交时,用 yyyy-MM-dd HH:mm:ss 解析 @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date orderTime; // JSON body 提交时,用 @JsonFormat 解析 @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private Date payTime; } public class OrderVO { @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private Date createTime; }这里有个关键细节:@JsonFormat里的timezone一定要写。为什么?因为java.util.Date本质上是“时间戳”,它本身没有时区概念。Jackson 在序列化Date时,会拿一个时区去计算字符串表示,默认用的是 JVM 的默认时区。如果服务器部署在云环境,镜像时区经常被设置成 UTC,那么前端的2024-01-15 10:30:00传进来,经过你业务处理再返回,前端看到的就是2024-01-15 02:30:00,少了整整 8 个小时。加上timezone = "GMT+8",就是明确告诉 Jackson:按东八区来算,别跟着服务器时区跑。
再说一个@JsonFormat的隐藏行为:如果不写pattern,只写@JsonFormat,对于LocalDateTime类型,Jackson 在某些配置下可能输出成数组格式,比如"createTime": [2024, 1, 15, 10, 30, 0],前端拿到这种数据完全不认识。所以,只要用了@JsonFormat,尽量把pattern写死。如果你需要输出成时间戳,可以显式指定shape = JsonFormat.Shape.NUMBER,比如@JsonFormat(shape = JsonFormat.Shape.NUMBER),此时 Jackson 就会把Date序列化成数字。
@JsonFormat(shape = JsonFormat.Shape.NUMBER) private Date eventTime;这种写法适合需要在前端自己控制展示格式的场景,前端拿一个时间戳,想怎么格式化就怎么格式化,不受后端字符串格式限制。
2.3 LocalDateTime 用注解时要注意的事
如果项目里用的是 Java 8 的LocalDateTime,字段注解这块有一些不一样的地方。@JsonFormat对LocalDateTime也是生效的,可以直接用:
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime publishTime;但这里不用像Date那样写timezone。为什么?因为LocalDateTime自身不带时区信息,它就是一个“本地日期时间”的抽象,不存在“按哪个时区算”的问题。你写2024-01-15 10:30:00,它就表示这个本地时间,序列化输出时也是原样输出。加了timezone反而容易让人误解,以为它有时区换算。
@DateTimeFormat对LocalDateTime同样生效,处理 URL 参数和表单提交时可以用:
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime startTime;如果你问“能不能用@DateTimeFormat处理 JSON body 里的LocalDateTime”,答案是:不能,跟前面说的逻辑一样,JSON body 归 Jackson 管。所以最稳妥的做法是:项目里统一优先使用@JsonFormat,因为它既能控制入参反序列化,也能控制出参序列化;@DateTimeFormat只在 Controller 层接收简单类型的@RequestParam日期参数时使用。
3. 方法二:全局配置,一条配置管全项目
3.1 yml 文件里的日期配置能做什么
依赖字段注解的方案,坏处很明显:每个字段都要写注解,漏一个就乱一个。如果项目里约定好了统一日期格式,我们完全可以用全局配置,让整个项目的序列化和反序列化默认都走同一个格式,个别特殊字段再用注解覆盖。
SpringBoot 的 yml 里最基本的配置是这样的:
spring: jackson: date-format: "yyyy-MM-dd HH:mm:ss" time-zone: "GMT+8"这里的date-format指定日期的默认输出格式,time-zone指定 Jackson 序列化时使用的时区。一个容易被忽略的细节是:yml 配置值里如果包含空格,必须用双引号包起来。写成date-format: yyyy-MM-dd HH:mm:ss在某些 YAML 解析器下会报错或者解析异常,这在团队里出现过不止一次。
但是,这段配置有一个很大的限制:它只对java.util.Date生效,对LocalDateTime、LocalDate这些 Java 8 时间类型基本无效。原因我在 1.2 小节说过,LocalDateTime的处理由JavaTimeModule负责,走的是格式化器的逻辑,不是DateFormat的逻辑。所以,如果项目里大量使用LocalDateTime,光写这行配置是远远不够的,必须往下看自定义ObjectMapper的做法。
3.2 用 Customizer 实现全局日期规则
SpringBoot 提供了一个非常优雅的扩展点:Jackson2ObjectMapperBuilderCustomizer。通过它,我们可以拿到 SpringBoot 内部配置好的Jackson2ObjectMapperBuilder,在里面针对 Java 8 时间类型设置自定义的序列化器和反序列化器。
一个完整的全局配置类长这样:
@Configuration public class DateTimeConfig { private static final String DEFAULT_DATETIME_PATTERN = "yyyy-MM-dd HH:mm:ss"; @Bean public Jackson2ObjectMapperBuilderCustomizer datetimeCustomizer() { return builder -> { DateTimeFormatter formatter = DateTimeFormatter.ofPattern(DEFAULT_DATETIME_PATTERN); // 全局序列化:LocalDateTime 输出 yyyy-MM-dd HH:mm:ss builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(formatter)); // 全局反序列化:只接受 yyyy-MM-dd HH:mm:ss 格式的字符串 builder.deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer(formatter)); // 同步处理 LocalDate,比如生日这种只精确到天的字段 DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); builder.serializerByType(LocalDate.class, new LocalDateSerializer(dateFormatter)); builder.deserializerByType(LocalDate.class, new LocalDateDeserializer(dateFormatter)); }; } }这样配置完之后,项目里所有LocalDateTime类型的字段,在序列化和反序列化时都默认走yyyy-MM-dd HH:mm:ss格式。不需要在 DTO 字段上再加@JsonFormat,代码会干净很多。
我在实际项目里比较推荐配合下面的 yml 配置一起用:
spring: jackson: date-format: "yyyy-MM-dd HH:mm:ss" time-zone: "GMT+8" serialization: write-dates-as-timestamps: falsewrite-dates-as-timestamps: false的作用是关闭日期序列化为时间戳的功能。如果项目里还有老的java.util.Date类型字段,这个开关就很重要,它确保Date也会输出成字符串格式,而不是默认的数字时间戳。这样就不用担心“返回的 createTime 是一串数字”的问题了。
3.3 全局配置和注解的优先级关系
既然有了全局配置,字段上还用不用@JsonFormat?用,但用途变了。全局配置是默认值,@JsonFormat是特殊覆盖。
比如某场活动的开始时间,业务上希望前端看到一个更简单的yyyy-MM-dd,不显示时分秒,那就在字段上加:
@JsonFormat(pattern = "yyyy-MM-dd") private LocalDate activityStartDate;这个字段的序列化和反序列化格式就会覆盖全局的yyyy-MM-dd HH:mm:ss,而其他没加注解的字段照常走全局配置。这种“全局配置兜底 + 局部注解覆盖”的组合,是我最推荐的日常开发模式,既避免到处写注解,又能对特殊场景精确控制。
需要注意:全局配置里的LocalDateTimeSerializer和LocalDateTimeDeserializer使用的是同一个DateTimeFormatter实例,而DateTimeFormatter是否是线程安全的?这个可以放心,DateTimeFormatter本身就是线程安全不可变的,可以全局共享,不需要每次解析都 new 一个。
4. 方法三:自定义反序列化器,兼容多种入参格式
4.1 为什么固定 pattern 不够用
全局配置方案的痛点在于:它假设所有上游接口都会按同一个格式传日期。现实情况往往不是这样。项目里对接第三方系统时,对方可能传2024-01-15,可能传2024/01/15 10:30:00,也可能直接传一个时间戳字符串1705386600000。如果全局反序列化器只认一种格式,那遇到其他格式的请求,接口就直接 400 或者字段置 null。
这种情况我经历得不少。特别是在一些需要兼容历史系统的内部接口上,老系统传的是2024-01-15,新前端按你定义的规范传2024-01-15 10:30:00,一个接口要同时吃下两种格式,光靠@JsonFormat的单一pattern是搞不定的。除了自定义反序列化器,没有别的办法。
4.2 完整实现一个多格式日期反序列化器
自定义反序列化器的思路其实很简单:从 JSON 里把原始字符串取出来,然后依次尝试常见解析方式,包括时间戳解析、多种字符串格式解析,都失败就报错,告诉调用方支持哪几种格式。
import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; import java.io.IOException; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.Date; public class FlexibleDateDeserializer extends JsonDeserializer<Date> { private static final DateTimeFormatter DATETIME_SLASH = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss"); private static final DateTimeFormatter DATETIME_LINE = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private static final DateTimeFormatter DATE_LINE = DateTimeFormatter.ofPattern("yyyy-MM-dd"); @Override public Date deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { String text = p.getValueAsString(); if (text == null || text.trim().isEmpty()) { return null; } text = text.trim(); // 处理纯数字时间戳,包含毫秒和秒两种 if (text.chars().allMatch(Character::isDigit)) { try { if (text.length() == 10) { return new Date(Long.parseLong(text) * 1000L); } return new Date(Long.parseLong(text)); } catch (NumberFormatException e) { throw new IllegalArgumentException("无法解析的时间戳: " + text); } } // 依次尝试各种字符串格式 try { return Date.from(LocalDateTime.parse(text, DATETIME_LINE) .atZone(ZoneId.systemDefault()).toInstant()); } catch (DateTimeParseException ignored) { } try { return Date.from(LocalDateTime.parse(text, DATETIME_SLASH) .atZone(ZoneId.systemDefault()).toInstant()); } catch (DateTimeParseException ignored) { } try { return Date.from(LocalDate.parse(text, DATE_LINE) .atStartOfDay(ZoneId.systemDefault()).toInstant()); } catch (DateTimeParseException ignored) { } throw new IllegalArgumentException("日期格式无法解析: " + text); } }这里面有两个值得留意的实现细节。第一,10 位时间戳是秒,13 位是毫秒,解析时要做区分,否则把一个 10 位秒级时间戳当成毫秒处理,日期会倒退到 1970 年附近。第二,字符串格式的尝试顺序要把最完整的格式放前面,比如yyyy-MM-dd HH:mm:ss比yyyy-MM-dd更长更精确,先试它,避免LocalDate把带时分秒的字符串截断吞掉。这里我用来兜底转换的是ZoneId.systemDefault(),把解析出的本地时间按系统默认时区转成Instant,从业务角度讲,一般要求用户输入的时间就是本地时间,这样转换是合理的。
如果希望兼容更多格式,在这个基础上往列表里加DateTimeFormatter就行。不少项目的实现是维护一个全局日期格式列表,按顺序逐个尝试。
4.3 注册方式和全局生效
有了反序列化器之后,还需要把它注册到 Jackson 的ObjectMapper里。我在项目里一般不会单独 new 一个ObjectMapper,因为那样容易破坏 SpringBoot 自动配置的其他功能,比如spring.jackson下的全局配置就不再生效了。更推荐的方式是继续使用Jackson2ObjectMapperBuilderCustomizer:
@Configuration public class FlexDateConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer flexDateCustomizer() { return builder -> { SimpleModule module = SimpleModule.builder() .addDeserializer(Date.class, new FlexibleDateDeserializer()) .build(); builder.modulesToInstall(module); }; } }这里用modulesToInstall把SimpleModule“添加”到原有的配置链路里,而不是“替换”掉整个ObjectMapper。注册完成后,所有 JSON body 里的Date字段都会走这个自定义反序列化器,前端传多种日期格式都能被正确解析。
如果想要接口Controller层的方法参数日期也走这套兼容逻辑,比如 URL 里的日期参数,还要配合@ControllerAdvice加@InitBinder,或者写一个 Spring 的Converter<String, Date>并注册到WebDataBinder。不过实际开发里,日期更多是通过 JSON body 传的,上面这套反序列化器已经够用了。
5. 几种方案的对比与组合建议
5.1 五种常见方案速查表
到这里,能用的方案已经差不多覆盖全了。我用一张表格总结一下各种方案的适用范围、统一程度和维护成本,方便大家在项目里快速选型。
| 方案 | 适用场景 | 统一度 | 灵活度 | 维护成本 |
|---|---|---|---|---|
@DateTimeFormat字段注解 | 表单提交、URL 参数绑定 | 低 | 高 | 低 |
@JsonFormat字段注解 | JSON 入参、响应输出,单字段特殊控制 | 低 | 高 | 中 |
spring.jackson.date-formatyml 配置 | 全局统一java.util.Date格式 | 高 | 低 | 极低 |
Jackson2ObjectMapperBuilderCustomizer全局定制 | 全局统一LocalDateTime、LocalDate格式 | 高 | 中 | 低 |
自定义JsonDeserializer | 入参兼容多种日期格式 | 高 | 高 | 中 |
实际项目里,很少只依赖某一种方案。每种方案都有自己的位置:全局配置保证默认行为一致,自定义反序列化器保证接口兼容性,字段注解解决个别的特殊展示需求。
5.2 我推荐的一个组合策略
从我的项目经验看,最常用的组合策略可以总结成一句话:内部模型统一用 Java 8 时间类型,全局配置固定输出格式,入参层做好多格式兼容,响应层用注解兜底特殊字段。
具体来说是四点。第一,DTO 和实体里的日期字段尽量用LocalDateTime或LocalDate,不要用java.util.Date。不是因为Date不行,而是因为Date在序列化时存在时区换算问题,处理起来更容易出错,而且方法也过时了,团队里新人容易踩坑。第二,在Jackson2ObjectMapperBuilderCustomizer里统一设置LocalDateTime和LocalDate的输出格式,保证所有响应都按yyyy-MM-dd HH:mm:ss输出,避免出现“这个接口正常、那个接口异常”的情况。第三,如果接口会对接外部系统或者历史老系统,注册一个多格式兼容的反序列化器,防止上游日期格式不规范导致请求 400 或字段 null。第四,对个别需要特殊日期格式的字段,用@JsonFormat覆盖全局默认值。
这套策略我在多个项目里验证过,修改的量相对可控,排查问题也方便,因为大部分日期行为是可预期的。如果出问题,基本上先看是不是外部传了预期之外的格式,再看有没有字段漏加注解覆盖到不该覆盖的格式。
6. 常见问题与排查实录
6.1 前端拿到的日期少了 8 小时
这是最典型、出现频率最高的日期问题。现象是:后端数据库存的2024-01-15 10:30:00,接口返回给前端变成了2024-01-15 02:30:00。
根因几乎都是时区设置。java.util.Date本身是一个绝对时间,序列化成字符串时,Jackson 要选一个时区来换算“当地的墙上时间”。如果 JVM 运行时的默认时区是系统时区,而系统时区又是 UTC,那么序列化结果跑到了别的时区,自然就少了 8 小时。
排查思路:先看部署环境的时间区设置,再检查 SpringBoot 的spring.jackson.time-zone配置,最后看字段上的@JsonFormat有没有显式写timezone。三层都得对上,只要有一层漂移,整体就会偏移。对于LocalDateTime类型,多数情况下不涉及时区换算,所以如果出现少 8 小时的问题,要多检查是不是代码里用了Instant转LocalDateTime的环节出错的。
6.2 LocalDateTime 配了 date-format 却不生效
另一个高频问题:在 yml 里写了spring.jackson.date-format: yyyy-MM-dd HH:mm:ss,但接口返回的LocalDateTime还是数组格式,或者还是 ISO 带 T 的格式。
根本原因是spring.jackson.date-format只作用于java.util.Date的DateFormat,而不作用于 Java 8 时间类型的JavaTimeModule。这个知识点从 1.2 小节提到现在,每次项目里有人踩到,我都会先问一句:这个字段是Date还是LocalDateTime?只要答案是LocalDateTime,问题基本就定位了。
解决办法就是在配置类里给LocalDateTime设置自定义序列化器,参考 3.2 小节的写法,没有别的捷径。
6.3 前端传了日期字符串接口却返回 400
这个问题一般是反序列化失败导致的。前端传了2024-01-15,DTP 字段用的是@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss"),两者格式不匹配,Spring 在参数解析阶段直接抛异常,最终返回 400。
排查步骤是这样:先看请求的 Content-Type,如果是application/json,检查字段上用的是不是@JsonFormat;如果是 URL 参数或表单提交,检查@DateTimeFormat的 pattern 与前端实际传的字符串是否一致。格式不一致时,优先考虑统一前端,或者在服务端加一个多格式兼容的反序列化器,不要让调用方去猜格式。
6.4 时间戳字符串解析失败
前端把时间戳当字符串传过来,比如"1705386600000",但后端字段类型是Date或LocalDateTime,并且只配置了字符串格式的pattern,此时 Jackson 不会自动识别它是否是时间戳,反而会把它当普通字符串按 pattern 解析,结果就是解析失败或产生一个错误日期。
如果希望兼容时间戳字符串,最直接的办法是注册一个自定义反序列化器,像 4.2 小节那样在解析逻辑里先判断纯数字,再判断长度是 10 位还是 13 位。这里有一个我自己踩过的坑:纯数字判断不能只依赖String.matches("\\d+")判断“是否都是数字”,还得处理前缀 0 的情况,比如"0001705386600000"这种数据在实际对接中真会出现,所以解析时可以先Long.parseLong,不要自己剥离前导零。
最后分享一点个人经验
日期格式化这件事,说起来是个细节,做起来却是前后端联调的常客。我在实际项目里最深的体会是:接口文档里一定要把日期格式当成一等公民去约定。只要文档里写清楚了,后端响应一律yyyy-MM-dd HH:mm:ss,前端传参一律支持yyyy-MM-dd HH:mm:ss和yyyy-MM-dd,大部分扯皮在第一轮联调就能避免。对于实在控制不住的外部系统传参,准备一套多格式兼容的反序列化器兜底,能救你于水火。另外,每次排查日期问题,我都建议按“类型体系 -> 时区 -> 注解与配置优先级”这个顺序来定位,顺序反了就容易在无关的地方打转。搞定了这套规则,后面再做多少接口,日期都不再是拦路虎。