封装 Jackson 配置 Starter:统一解决 LocalDateTime 请求解析和响应格式化问题
封装 Jackson 配置 Starter概述之前做封装 API 请求日志切面的时候我common模块里写了一个JsonUtils。它里面自己维护了一个ObjectMapper专门给日志切面这种代码里主动转 JSON的场景用。但是这里有一个很容易踩的坑JsonUtils配好了不代表 Spring MVC 也用它。比如前端请求/test2{nickName:二狗,createTime:2024-05-14 12:00:00}后端实体类里是privateLocalDateTimecreateTime;结果直接报错Cannot deserialize value of type java.time.LocalDateTime from String 2024-05-14 12:00:00 Text 2024-05-14 12:00:00 could not be parsed at index 10为什么是 index 10因为第 10 位刚好是日期和时间中间那个空格。Jackson 默认更认 ISO 格式2024-05-14T12:00:00也就是说默认规则要的是T你传的是空格。这篇文章就是解决这个问题先让JsonUtils自己有一份能兜底的 Jackson 配置再封装一个 Jackson Starter让 Spring Boot 处理RequestBody和ResponseBody的时候也统一使用我们的日期格式。最终效果{nickName:二狗,createTime:2024-05-14 12:00:00}能正常反序列化成LocalDateTime接口返回时也会输出成yyyy-MM-dd HH:mm:ss。先分清两套 ObjectMapper项目里其实有两类 JSON 场景场景谁来处理例子代码里主动转 JSONJsonUtils里的ObjectMapper日志切面里JsonUtils.toJsonString(result)HTTP 请求/响应 JSONSpring Boot 创建的ObjectMapperRequestBody、ResponseBodyJsonUtils是我们手动调用的工具类。RequestBody和ResponseBody不是走JsonUtils而是走 Spring MVC 里的MappingJackson2HttpMessageConverter。这个转换器内部用的是 Spring Boot 自动配置出来的ObjectMapper。所以问题的根源不是JsonUtils写错了而是HTTP 这条链路上的 ObjectMapper 还没被我们定制。那怎么定制不要在 Starter 里直接new ObjectMapper()再塞进容器。这样会把 Spring Boot 原本帮你做好的默认配置顶掉后面很多东西都得自己补。更合适的方式是通过Jackson2ObjectMapperBuilderCustomizer参与 Spring Boot 创建ObjectMapper的过程。一句话理解不自己造一个新的ObjectMapper而是在 Spring Boot 造它的时候把我们的日期格式规则加进去。不过还有一个场景要注意如果项目没有 Web只是日志切面或者其他工具类在用JsonUtils那 Spring Boot 的 WebObjectMapper就管不到它。所以不要把配置全压在 Starter 里JsonUtils自己也要能独立处理LocalDateTime。第一步补充日期格式常量先在idle-store-common里统一日期格式后面JsonUtils、Jackson Starter 都用同一份常量别到处手写字符串。packagecom.lh.framework.common.constant;publicinterfaceDateConstants{/** * 年-月-日 时:分:秒 */StringY_M_D_H_M_S_FORMATyyyy-MM-dd HH:mm:ss;/** * 年-月-日 */StringY_M_D_FORMATyyyy-MM-dd;/** * 时:分:秒 */StringH_M_S_FORMATHH:mm:ss;}这几个格式对应关系很清楚Java 类型JSON 格式LocalDateTimeyyyy-MM-dd HH:mm:ssLocalDateyyyy-MM-ddLocalTimeHH:mm:ss第二步抽公共 Jackson 配置在common模块里抽一个JacksonUtils专门负责创建和配置ObjectMapperpublicclassJacksonUtils{publicstaticObjectMappercreateObjectMapper(){ObjectMapperobjectMappernewObjectMapper();configure(objectMapper);returnobjectMapper;}publicstaticvoidconfigure(ObjectMapperobjectMapper){objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,false);objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS,false);objectMapper.registerModule(javaTimeModule());}publicstaticJavaTimeModulejavaTimeModule(){JavaTimeModulejavaTimeModulenewJavaTimeModule();javaTimeModule.addSerializer(LocalDateTime.class,newLocalDateTimeSerializer(DateTimeFormatter.ofPattern(DateConstants.Y_M_D_H_M_S_FORMAT)));javaTimeModule.addDeserializer(LocalDateTime.class,newLocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DateConstants.Y_M_D_H_M_S_FORMAT)));javaTimeModule.addSerializer(LocalDate.class,newLocalDateSerializer(DateTimeFormatter.ofPattern(DateConstants.Y_M_D_FORMAT)));javaTimeModule.addDeserializer(LocalDate.class,newLocalDateDeserializer(DateTimeFormatter.ofPattern(DateConstants.Y_M_D_FORMAT)));javaTimeModule.addSerializer(LocalTime.class,newLocalTimeSerializer(DateTimeFormatter.ofPattern(DateConstants.H_M_S_FORMAT)));javaTimeModule.addDeserializer(LocalTime.class,newLocalTimeDeserializer(DateTimeFormatter.ofPattern(DateConstants.H_M_S_FORMAT)));returnjavaTimeModule;}}这样日期格式规则只写一份JsonUtils可以用它后面的 Jackson Starter 也可以用它。第三步新建 Jackson Starter 模块在idle-store-framework下新建模块lh-spring-boot-starter-jackson/ ├── pom.xml └── src/main/ ├── java/com/lh/framework/jackson/config/ │ └── JacksonAutoConfiguration.java └── resources/META-INF/spring/ └── org.springframework.boot.autoconfigure.AutoConfiguration.imports第四步编写 pom.xmllh-spring-boot-starter-jackson/pom.xmlprojectxmlnshttp://maven.apache.org/POM/4.0.0xmlns:xsihttp://www.w3.org/2001/XMLSchema-instancexsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsdmodelVersion4.0.0/modelVersionparentgroupIdcom.lh/groupIdartifactIdidle-store-framework/artifactIdversion${revision}/version/parentartifactIdlh-spring-boot-starter-jackson/artifactIdpackagingjar/packagingname${project.artifactId}/namedependencies!-- 依赖 common拿 DateConstants 和 JsonUtils --dependencygroupIdcom.lh/groupIdartifactIdidle-store-common/artifactId/dependency!-- Java 8 日期时间类型支持LocalDateTime、LocalDate、LocalTime --dependencygroupIdcom.fasterxml.jackson.datatype/groupIdartifactIdjackson-datatype-jsr310/artifactId/dependency!-- AutoConfiguration、Jackson2ObjectMapperBuilderCustomizer --dependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-autoconfigure/artifactId/dependency!-- Jackson2ObjectMapperBuilder 在 spring-web 里 --dependencygroupIdorg.springframework/groupIdartifactIdspring-web/artifactId/dependency/dependencies/project这里有两个点要注意依赖作用jackson-datatype-jsr310提供LocalDateTimeSerializer、LocalDateTimeDeserializer这些日期处理类spring-web提供Jackson2ObjectMapperBuilderCustomizer 方法签名里会用到如果不加spring-webStarter 模块单独编译时可能找不到Jackson2ObjectMapperBuilder。第五步在 framework 父模块注册编辑idle-store-framework/pom.xmlmodulesmoduleidle-store-common/modulemodulelh-spring-boot-starter-biz-operationlog/modulemodulelh-spring-boot-starter-jackson/module/modules第六步编写自动配置类核心代码在JacksonAutoConfigurationAutoConfigurationConditionalOnClass(ObjectMapper.class)publicclassJacksonAutoConfiguration{BeanpublicJackson2ObjectMapperBuilderCustomizerjackson2ObjectMapperBuilderCustomizer(){returnbuilder-{// 时区统一成东八区builder.timeZone(TimeZone.getTimeZone(Asia/Shanghai));// JSON 中有 Java 对象不存在的字段时不要直接报错builder.featuresToDisable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);// 遇到空 Bean 时不要直接报错builder.featuresToDisable(SerializationFeature.FAIL_ON_EMPTY_BEANS);// 复用 common 里的 Java 时间配置builder.modules(JacksonUtils.javaTimeModule());};}BeanpublicSmartInitializingSingletonjsonUtilsInitializer(ObjectMapperobjectMapper){return()-JsonUtils.init(objectMapper);}}这段代码做了什么第一定制 Spring Boot 的ObjectMapper。BeanpublicJackson2ObjectMapperBuilderCustomizerjackson2ObjectMapperBuilderCustomizer(){...}Spring Boot 创建ObjectMapper时会先拿到Jackson2ObjectMapperBuilder然后执行容器里所有Jackson2ObjectMapperBuilderCustomizer。我们就在这个阶段把时区、反序列化规则、日期模块塞进去。第二指定 Java 8 日期类型的序列化和反序列化格式。builder.modules(JacksonUtils.javaTimeModule());这里复用了common中的JacksonUtils.javaTimeModule()不用在 Starter 里再写一遍LocalDateTimeSerializer/LocalDateTimeDeserializer。方法方向效果serializerJava 对象 → JSONLocalDateTime输出成2024-05-14 12:00:00deserializerJSON → Java 对象2024-05-14 12:00:00解析成LocalDateTime第三把 Spring Boot 最终创建好的ObjectMapper同步给JsonUtils。BeanpublicSmartInitializingSingletonjsonUtilsInitializer(ObjectMapperobjectMapper){return()-JsonUtils.init(objectMapper);}这样做之后项目里主动调用JsonUtils.toJsonString()和 HTTP 请求/响应使用的是同一个ObjectMapper。同一套规则不会这边是空格格式那边又变成 ISO 格式。第七步修改 JsonUtilspublicclassJsonUtils{privatestaticObjectMapperOBJECT_MAPPERJacksonUtils.createObjectMapper();/** * 将对象转换为 JSON 字符串 */SneakyThrowspublicstaticStringtoJsonString(Objectobj){returnOBJECT_MAPPER.writeValueAsString(obj);}/** * 初始化统一使用 Spring Boot 个性化配置的 ObjectMapper * * param objectMapper */publicstaticvoidinit(ObjectMapperobjectMapper){OBJECT_MAPPERobjectMapper;}}这里就不用 static 代码块了。默认情况下JsonUtils用JacksonUtils.createObjectMapper()所以没有 Web 的日志场景也能处理LocalDateTime。如果项目引入了 Web并且加载了我们的 Jackson StarterSpring Boot 创建好ObjectMapper后会调用JsonUtils.init(objectMapper)把 HTTP 和工具类统一到同一个ObjectMapper。为什么不直接 new 一个 ObjectMapper最容易写成这样BeanpublicObjectMapperobjectMapper(){ObjectMapperobjectMappernewObjectMapper();// 一堆自定义配置...returnobjectMapper;}这个写法不是完全不能用但不推荐放在 Starter 里。原因是Spring Boot 本来会自动创建一个ObjectMapper并且帮你注册很多默认配置。你自己声明了一个ObjectMapperBean 之后Spring Boot 的默认ObjectMapper创建逻辑会后退。这就像什么呢本来 Spring Boot 帮你装修好了房子你只是想换个窗帘。结果你直接把房子推了重盖那地板、水电、门窗都得你自己重新装。我们现在只是想改日期格式没必要重造整个ObjectMapper。所以更合适的方式是Jackson2ObjectMapperBuilderCustomizer它是在 Spring Boot 原本配置的基础上追加规则改动更小也更稳。第八步注册自动配置在这个文件里写入自动配置类全限定名src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports内容com.lh.framework.jackson.config.JacksonAutoConfigurationSpring Boot 3.x 推荐用这个文件来注册自动配置。第九步业务模块引入 Starter在idle-store-auth/pom.xml中引入dependencygroupIdcom.lh/groupIdartifactIdlh-spring-boot-starter-jackson/artifactId/dependency引入之后不需要再写额外配置。idle-store-auth启动时Spring Boot 会读取AutoConfiguration.imports加载JacksonAutoConfiguration然后定制 HTTP 使用的ObjectMapper。第十步验证接口实体类DataAllArgsConstructorNoArgsConstructorBuilderpublicclassUser{NotBlank(message昵称不能为空)privateStringnickName;privateLocalDateTimecreateTime;}ControllerPostMapping(/test2)ApiOperationLog(description测试接口2)publicResponseUsertest2(RequestBodyValidatedUseruser){returnResponse.success(user);}请求体{nickName:二狗,createTime:2024-05-14 12:00:00}如果 Starter 生效createTime能正常解析成LocalDateTime接口返回也会是{success:true,message:null,errorCode:null,data:{nickName:二狗,createTime:2024-05-14 12:00:00}}常见踩坑坑一只改 JsonUtilsHTTP 不生效JsonUtils只管你手动调用它的地方比如日志切面JsonUtils.toJsonString(result)前端传 JSON 到 Controller走的是 Spring MVC 的消息转换器不会自动走JsonUtils。所以只改JsonUtilsRequestBody还是会继续报错。坑二在 Starter 里直接声明 ObjectMapper直接声明BeanpublicObjectMapperobjectMapper(){returnnewObjectMapper();}会让 Spring Boot 的默认ObjectMapper自动配置后退。你只是想改日期格式结果把整个对象都接管了后续维护成本会变高。更推荐BeanpublicJackson2ObjectMapperBuilderCustomizerjackson2ObjectMapperBuilderCustomizer(){returnbuilder-{// 只追加我们关心的规则};}坑三只配序列化不配反序列化无论你是在JavaTimeModule里配还是在builder里配都要注意只配序列化是不够的。只写这个builder.serializerByType(LocalDateTime.class,newLocalDateTimeSerializer(dateTimeFormatter));只能解决返回给前端时的格式。前端传2024-05-14 12:00:00进来还是可能解析失败。所以必须成对写builder.serializerByType(LocalDateTime.class,newLocalDateTimeSerializer(dateTimeFormatter));builder.deserializerByType(LocalDateTime.class,newLocalDateTimeDeserializer(dateTimeFormatter));一个管出参一个管入参。不要让JsonUtils背 HTTP 的锅也不要为了改日期格式重造ObjectMapper。用 Starter 提供Jackson2ObjectMapperBuilderCustomizer在 Spring Boot 原本的ObjectMapper上追加规则。