
1. 一次线上“时差8小时”事故日期格式化的本质被低估了去年冬天我接手过一个线上工单前端同事凌晨发来消息说订单列表接口返回的创建时间是“2080年5月20日”。我当时第一反应是数据库数据写坏了结果库里查出来是2024年5月20日一分不差。后来把返回的JSON拉到控制台看发现时间戳居然是1700000000000这种毫秒数前端自己套了一层格式化函数因为没传时区参数默认用了UTC就这么硬生生算出了2080年。这个事故的根源不是前端的问题也不是数据库的问题而是接口层对日期格式化的处理没有形成统一约定后端返回了时间戳或默认格式前端每个页面各写各的解析逻辑只要有一次格式不一致线上就开始出现莫名其妙的时间错乱。在SpringBoot项目里“接口日期格式化”说到底是序列化器的问题。一次HTTP请求进来SpringMVC调用ResponseBody时会把返回值交给MappingJackson2HttpMessageConverter再由Jackson把Java对象转成JSON字符串。Java里的java.util.Date和LocalDateTime到底输出成什么样完全取决于Jackson在序列化那一刻用了哪个JsonSerializer。SpringBoot默认装配的Jackson行为又比较特殊和许多老项目里大家以为的“默认输出yyyy-MM-dd HH:mm:ss”根本不是一回事。默认情况下SpringBoot里序列化一个java.util.Date得到的是自1970年1月1日以来的毫秒时间戳。序列化一个LocalDateTime呢得到的是ISO格式比如2024-05-20T10:30:00。前端拿到这两种东西基本都得再做一次处理。下面这张表是我之前整理过的SpringBoot默认输出行为大家可以对照着看。Java类型SpringBoot默认JSON输出前端直接展示的结果java.util.Date1716181800000没有格式化逻辑就是一堆数字LocalDateTime2024-05-20T10:30:00带有字母T不是常规展示格式LocalDate2024-05-20纯日期问题不大LocalTime10:30:00纯时间问题不大OffsetDateTime2024-05-20T02:30:00Z带时区和偏移量容易误解所以所谓“接口日期格式化方法”本质上就是对Jackson序列化器做定制要么在字段上改要么在全局改要么在更底层用自定义序列化器接管。下面我就按项目里常用的程度把几种方法完整拆开讲一遍。2. 方案一字段级JsonFormat注解——小而美但要防着几个坑2.1 注解的正确用法与timezone的真实作用最直观的做法是在实体类的日期字段上加Jackson的JsonFormat注解。JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private Date createTime;这段配置的意思是当Jackson序列化这个字段时不输出时间戳而是用SimpleDateFormat按pattern格式化再把字符串写进JSON。timezone参数很多人会忽略但它恰恰最容易埋雷。java.util.Date本质上就是一个绝对时间点内部存的是从1970年1月1日00:00:00 UTC开始算起的毫秒数本身不包含任何时区概念。格式化时如果不指定时区就会用当前JVM默认时区来算。开发机上你大概率是东八区所以看不出问题生产服务器如果系统时区被设置成UTC那同一个Date就会输出成“2024-05-20 02:30:00”比东八区少了整整8小时。所以我只要在字段上用timezone GMT8就相当于把这个字段的格式化固定在了东八区和服务器部署在哪个时区无关。如果字段类型是LocalDateTime情况又不一样了。LocalDateTime本身不携带时区它就是你眼睛看到的那一串日期时间。对它来说timezone参数是不需要的加了也不会生效。JsonFormat(pattern yyyy-MM-dd HH:mm:ss) private LocalDateTime createTime;2.2JsonFormat和DateTimeFormat不是一回事这里必须强调一个高频错误。很多刚用SpringBoot的同学会把DateTimeFormat也当成JSON格式化注解来用实际上它管的是另一个层面。JsonFormat由Jackson包提供作用于“Java对象转JSON的序列化”以及“JSON字符串转Java对象的反序列化”。DateTimeFormat由Spring框架提供作用于“HTTP请求参数的表单绑定”比如GET请求的?date2024-05-20 10:30:00或者表单提交字段把字符串转成Date对象时指定格式。一张表就能看明白注解所属包影响范围典型场景JsonFormatcom.fasterxml.jackson.annotationJSON序列化/反序列化RequestBody、ResponseBodyDateTimeFormatorg.springframework.format.annotationSpringMVC参数绑定GET参数、POST表单参数JSONFieldcom.alibaba.fastjson.annotationfastjson序列化/反序列化项目换成fastjson后使用如果项目里团队既有老代码用DateTimeFormat又有人往字段上加JsonFormat很容易造成一种现象接口入参绑定格式老出问题但返回结果又是对的。排查的时候先分清这两类注解分别作用在哪一层能省不少事。2.3 注解不生效的排查清单我遇到过不少“注解明明加了接口返回还是时间戳”的情况。总结下来主要有以下几种原因项目实际使用的JSON框架不是Jackson。如果引入的是fastjson或者GsonJsonFormat自然不生效。fastjson要换成JSONField(format yyyy-MM-dd HH:mm:ss)Gson则要自己写JsonSerializer。字段上同时有JsonIgnore或其他序列化注解。这个比较隐蔽有的实体继承父类或实现接口子类字段上的JsonFormat可能被父类序列化器覆盖。自定义了全局ObjectMapper但没保留默认配置。这一点在后文“方案二”里细讲这里先提醒一旦你用了new ObjectMapper()并把它注册成了HttpMessageConverterBoot对Jackson的自动化配置就不会再生效注解也可能被绕过。IDE的Lombok生成链式setter导致字段访问策略变化。正常情况下不影响但如果你同时配置了JsonProperty且访问权限是非public字段Jackson按字段访问和按getter访问会产生不同的注解叠加顺序结果就是注解不生效。建议JsonFormat写在getter方法上或者字段上二选一不要两边都写且格式不一致。排查时最快的验证方法写一个单元测试直接用ObjectMapper序列化这个实体看看输出结果。先排除SpringMVC那层再回来查配置。3. 方案二应用级全局Jackson日期配置——从源头统一格式3.1application.yml里那两行配置管不了所有日期类型最简单的全局配置是在application.yml里声明Jackson的日期格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8这两行的效果是对所有java.util.Date生效序列化和反序列化都会用这个格式。但它有一个非常容易踩的盲区——对LocalDateTime、LocalDate、LocalTime这些Java 8时间类型不生效。原因在SpringBoot对jackson-datatype-jsr310模块的自动配置中LocalDateTime的默认序列化器并不读取spring.jackson.date-format这个配置项。所以很多项目会出现一个怪象Date类型的字段返回“2024-05-20 10:30:00”而LocalDateTime类型的字段返回“2024-05-20T10:30:00”一个项目里两种风格并存。前端对接时每个接口都要看一遍响应示例开发效率被直线拉低。3.2 用Jackson2ObjectMapperBuilderCustomizer做真正意义的全局统一要覆盖所有日期类型推荐的做法是注册一个自定义Jackson2ObjectMapperBuilderCustomizer。这是SpringBoot预留的扩展点用来在自动装配的基础上追加Jackson定制不会把Boot原本注册的ObjectMapper整个替换掉。Configuration public class JacksonDateFormatConfig { private static final DateTimeFormatter DATETIME_FORMATTER DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); private static final DateTimeFormatter DATE_FORMATTER DateTimeFormatter.ofPattern(yyyy-MM-dd); private static final DateTimeFormatter TIME_FORMATTER DateTimeFormatter.ofPattern(HH:mm:ss); Bean public Jackson2ObjectMapperBuilderCustomizer globalDateFormatCustomizer() { return builder - { // 1. 处理 java.util.Date builder.simpleDateFormat(yyyy-MM-dd HH:mm:ss); // 2. 处理 Java 8 时间类型 builder.serializers(new LocalDateTimeSerializer(DATETIME_FORMATTER)); builder.deserializers(new LocalDateTimeDeserializer(DATETIME_FORMATTER)); builder.serializers(new LocalDateSerializer(DATE_FORMATTER)); builder.deserializers(new LocalDateDeserializer(DATE_FORMATTER)); builder.serializers(new LocalTimeSerializer(TIME_FORMATTER)); builder.deserializers(new LocalTimeDeserializer(TIME_FORMATTER)); }; } }这里有个细节值得解释。builder.simpleDateFormat(...)是给java.util.Date用的它会设置一个默认的DateFormat内部等效于new SimpleDateFormat(yyyy-MM-dd HH:mm:ss)同时会覆盖Boot自动配置里spring.jackson.date-format的值。builder.serializers(...)和builder.deserializers(...)则是把Java 8时间类型的序列化器和反序列化器注册进去。注意反序列化器不能省否则前端POST一个2024-05-20 10:30:00字符串后端实体类里的LocalDateTime字段会解析失败直接报400。配置完成之后整个应用的JSON输出就统一了Date、LocalDateTime都是yyyy-MM-dd HH:mm:ssLocalDate是yyyy-MM-ddLocalTime是HH:mm:ss。这种“一次配置全部生效”的方式最适合业务实体多、日期字段多、且前后端约定统一格式的中型项目。3.3 全局方案的最大代价某些对接方开始抱怨格式全局统一这件事听着很舒服但真上线前一定要和调用方确认。我遇到过比较典型的场景公司内部数据中台需要拉取订单流水对方明确要求ISO8601格式也就是2024-05-20T10:30:00Z这种带时区偏移的标准格式。结果全局配置一上线中台那边的解析程序因为无法识别自定义格式直接报警。后来只能单独为这个接口搞一个VO把日期字段改成字符串或者在这个接口的响应里覆盖日期序列化逻辑。所以如果你们项目外部对接方多接口格式需求不一致全局方案要慎重。如果只是对外提供一套标准REST接口且前后端都是自己人全局配置可以大大减少沟通成本。4. 方案三LocalDateTime专项序列化器——Java 8时间类型别拖后腿4.1 为什么SpringBoot默认LocalDateTime输出ISO字符串很多从旧版SpringMVC迁移过来的老工程师会有个思维惯性Date类型有问题换SimpleDateFormat解决LocalDateTime呢它没有date-format配置可用了。SpringBoot在JacksonAutoConfiguration里引入jackson-datatype-jsr310后LocalDateTime默认由LocalDateTimeSerializer处理输出格式是ISO_OFFSET_DATE_TIME或ISO_LOCAL_DATE_TIME也就是带T的标准格式。前端拿过去之后要么自己切字符串要么用moment.js之类的库再格式化一次非常被动。更麻烦的是反序列化。默认情况下把2024-05-20 10:30:00这个字符串POST给LocalDateTime字段Jackson会尝试用ISO格式解析带空格的字符串直接解析失败。这就是很多人觉得“SpringBoot接收日期参数麻烦”的根本原因。4.2 注册Java8时间序列化器/反序列化器彻底解决接收与返回我在上一章里已经写了一个Jackson2ObjectMapperBuilderCustomizer的完整配置这里再针对LocalDateTime单独给一个最小可用版本方便那些不想全局动Date行为、只想把Java 8时间类型管起来的项目Configuration public class LocalDateTimeConfig { Bean public Jackson2ObjectMapperBuilderCustomizer localDateTimeCustomizer() { return builder - { DateTimeFormatter formatter DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); builder.serializers(new LocalDateTimeSerializer(formatter)); builder.deserializers(new LocalDateTimeDeserializer(formatter)); }; } }加了LocalDateTimeDeserializer之后接口入参既支持2024-05-20 10:30:00也支持2024-05-20T10:30:00。如果想让解析更严格、不允许ISO格式混入可以把DateTimeFormatter设置为ResolverStyle.STRICT再配合DateTimeFormatterBuilder对年月日做校验那样类似“2月30日”这种非法日期也会被拦截下来。4.3 别忽略LocalDate和LocalTime在专项处理时很多项目把精力全花在LocalDateTime上忘了LocalDate和LocalTime。其实这两个类型在接收前端参数时同样会有格式问题。比如字段是LocalDate前端传2024-5-20这种非补零格式默认反序列化器也能解析但前端一旦传2024/05/20就会报错。把LocalDateSerializer、LocalDateDeserializer、LocalTimeSerializer、LocalTimeDeserializer一起注册进去再统一用yyyy-MM-dd和HH:mm:ss可以省掉很多边界问题。这里顺便提醒一个容易被忽略的点全局统一了LocalDateTime格式化后像DateTimeFormatter.ofPattern创建的Formatter默认是ResolverStyle.SMART它允许一些“看起来合理但不严格”的日期比如2月30日会被解析成2月28日或29日。生产环境做结算、排期这类业务最好改成严格模式宁可接口报错也不要默默吞掉非法数据。5. 方案四自定义注解ContextualSerializer——字段级动态日期的兜底方案5.1 全局统一格式解决不了“同一个实体要多种格式”的问题全局配置好用归好用但有个现实场景它搞不定一个实体里createTime要精确到秒birthday只需要到天。全局统一成秒级格式生日返回就多了一串00:00:00前端还要再切字符串全局统一成天级格式操作日志里看不见具体时间又不行。有人可能会说那就给生日字段单独加JsonFormat(pattern yyyy-MM-dd)。这个方法可行但有两个局限第一JsonFormat的pattern是编译期写死的如果你的格式化规则要按请求上下文动态变化它做不到第二它是Jackson内置注解如果想在上面叠加别的业务语义比如“脱敏字段时间也按此规则处理”这种元信息它表达不了。5.2 用ContextualSerializer读取自定义注解的pattern这里分享一个我项目里实际用过的进阶方案自定义一个DateTimePattern注解配合ContextualSerializer实现。ContextualSerializer是Jackson提供的一个接口它在序列化时会在字段上下文里被回调让你能拿到当前字段上的注解从而针对不同字段创建不同的序列化器实例。先定义注解Target({ElementType.FIELD, ElementType.METHOD}) Retention(RetentionPolicy.RUNTIME) public interface DateTimePattern { String pattern() default yyyy-MM-dd HH:mm:ss; String timezone() default GMT8; }然后是核心的序列化器public class DynamicDateSerializer extends JsonSerializerDate implements ContextualSerializer { private String pattern; private String timezone; public DynamicDateSerializer() { this(yyyy-MM-dd HH:mm:ss, GMT8); } public DynamicDateSerializer(String pattern, String timezone) { this.pattern pattern; this.timezone timezone; } Override public void serialize(Date date, JsonGenerator gen, SerializerProvider serializers) throws IOException { SimpleDateFormat sdf new SimpleDateFormat(pattern); sdf.setTimeZone(TimeZone.getTimeZone(timezone)); gen.writeString(sdf.format(date)); } Override public JsonSerializer? createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException { if (property null) { return this; } DateTimePattern annotation property.getAnnotation(DateTimePattern.class); if (annotation ! null) { return new DynamicDateSerializer(annotation.pattern(), annotation.timezone()); } return this; } }注册到全局Configuration public class DynamicDateSerializerConfig { Bean public Jackson2ObjectMapperBuilderCustomizer dynamicDateCustomizer() { return builder - builder.serializerByType(Date.class, new DynamicDateSerializer()); } }使用方式Data public class UserVO { DateTimePattern(pattern yyyy-MM-dd HH:mm:ss) private Date createTime; DateTimePattern(pattern yyyy-MM-dd) private Date birthday; }序列化时Jackson走到createTime字段createContextual会读取到字段上的DateTimePattern创建一个pattern为yyyy-MM-dd HH:mm:ss的序列化器实例走到birthday字段同样读取到yyyy-MM-dd的pattern。同一个全局配置不同字段输出不同格式完全动态。5.3 反序列化方向的处理思路上面只讲了序列化。如果接口入参也需要按不同字段解析不同格式可以再写一个ContextualDeserializer思路完全对称在createContextual里读取字段注解上的pattern构造对应的DateDeserializer用SimpleDateFormat解析字符串。生产环境里其实反序列化用到动态格式的需求并不高因为入参通常是前端传什么后端统一约定一种格式即可。所以我的建议是自定义注解方案主要解决响应端展示问题入参端还是保持全局统一格式减少解析分支。这个方案看上去有点“炫技”但深究下来它依赖的其实是Jackson最核心的ContextualSerializer机制理解之后对Jackson的扩展能力会有更直观的认知。推荐那些被“同一实体多头格式”折磨过、并且对Jackson源码有一定兴趣的同学使用。6. 时区、null值、序列化框架混用——这三个“旁边的问题”也要收拾干净6.1 “少了8小时”的排查链路三处时区逐个看接口日期格式化不只是pattern的问题时区问题引发的Bug往往更具迷惑性。线上出现过“数据库时间是2024-05-20 10:30:00接口返回2024-05-20 02:30:00”的现象排查三个环节就能定位数据库连接时区。MySQL的jdbcUrl里有没有设置serverTimezoneAsia/Shanghai。如果连接参数没配而数据库服务器时区是UTCJDBC驱动读取datetime字段时就会按服务器时区解释拿到的时间戳本身就不对。应用JVM默认时区。Date类型格式化时如果不指定timezone用的是JVM默认时区。Linux服务器上用date -R看一下当前时区如果不放心直接给启动参数加上-Duser.timezoneAsia/Shanghai。Jackson序列化时区。spring.jackson.time-zone: GMT8或者字段上JsonFormat的timezone参数要么不写要写就保持一致。我自己的习惯是三层都固定成Asia/Shanghai而不是依赖服务器环境。环境是变量配置在代码里才是常量。6.2 null日期不该被“格式化”但接口契约要提前约定另一个容易被忽略的点是null。日期字段为null时无论pattern配成什么样输出的都是null或直接不输出。SpringBoot默认的Jackson配置是JsonInclude.Include.NON_NULL这里要澄清一下SpringBoot默认并没有全局设置null字段不输出但很多项目会自己配置为NON_NULL来压缩响应体体积。如果前端需要日期字段总是存在即使为null也要返回占位那就要在接口级或字段级覆盖。比如JsonInclude(JsonInclude.Include.ALWAYS) JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private Date createTime;这样null时输出createTime: null前端拿到后可以做条件渲染。这个细节在实际联调时最容易吵起来后端的通用配置改了null过滤规则前端突然拿不到某些字段排查半天才发现是include策略变了。建议大版本改动前至少在接口文档里标注清楚。6.3 项目混用fastjson/Jackson时注解互相不认国内很多老项目是从fastjson迁移过来的迁移过程中可能同时存在两个JSON框架老的Controller用fastjson新写的用Jackson或者全局HttpMessageConverter配置被改成了fastjson。这种情况下JsonFormat会失效JSONField也不一定被Jackson处理最终结果就是接口日期格式五花八门。我的建议是要么全项目统一Jackson要么统一fastjson不要在同一个接口链路里混用。如果实在要保留fastjson就在项目启动日志里确认当前生效的HttpMessageConverter到底是哪一个。去年我带的一个项目就是因为在WebMvcConfigurer里注册了fastjson的FastJsonHttpMessageConverter导致所有JsonFormat失效排查了整整一天。6.4 前后端日期契约的最终建议做接口设计时日期格式最好在前端和后端之间提前约定成契约的一部分。对绝大多数国内业务系统统一yyyy-MM-dd HH:mm:ss就够了。如果有跨时区用户或者需要精确对齐UTC时间优先用ISO8601带偏移格式不要再自定义。日期格式是典型的“每个人都能改一行配置但改完影响所有人”的字段越早统一后期返工越少。我在实际项目中最终的落地方案通常是全局用Jackson2ObjectMapperBuilderCustomizer统一Date和Java 8时间类型格式为yyyy-MM-dd HH:mm:ss对少数需要不同格式的字段用自定义DateTimePattern注解做动态覆盖时区统一固定东八区不依赖服务器环境前后端接口文档里明确写明日期字段格式任何一方改格式都必须走评审。这套组合拳用到现在接口层因日期格式产生的线上问题已经很少了。你可以根据自己的项目复杂度挑其中一两种组合起来用稳定性和可维护性都会比“前端各自处理”强很多。