封装 Jackson 配置 Starter
概述
之前做封装 API 请求日志切面的时候,我 common 模块里写了一个 JsonUtils。它里面自己维护了一个 ObjectMapper,专门给日志切面这种"代码里主动转 JSON"的场景用。
但是这里有一个很容易踩的坑:JsonUtils 配好了,不代表 Spring MVC 也用它。
比如前端请求 /test2:
json
{
"nickName": "二狗",
"createTime": "2024-05-14 12:00:00"
}
后端实体类里是:
java
private LocalDateTime createTime;
结果直接报错:
text
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 格式:
text
2024-05-14T12:00:00
也就是说,默认规则要的是 T,你传的是空格。
这篇文章就是解决这个问题:先让 JsonUtils 自己有一份能兜底的 Jackson 配置,再封装一个 Jackson Starter,让 Spring Boot 处理 @RequestBody 和 @ResponseBody 的时候,也统一使用我们的日期格式。
最终效果:
json
{
"nickName": "二狗",
"createTime": "2024-05-14 12:00:00"
}
能正常反序列化成 LocalDateTime,接口返回时也会输出成 "yyyy-MM-dd HH:mm:ss"。
先分清两套 ObjectMapper
项目里其实有两类 JSON 场景:
| 场景 | 谁来处理 | 例子 |
|---|---|---|
| 代码里主动转 JSON | JsonUtils 里的 ObjectMapper |
日志切面里 JsonUtils.toJsonString(result) |
| HTTP 请求/响应 JSON | Spring Boot 创建的 ObjectMapper |
@RequestBody、@ResponseBody |
JsonUtils 是我们手动调用的工具类。
@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 的 Web ObjectMapper 就管不到它。所以不要把配置全压在 Starter 里,JsonUtils 自己也要能独立处理 LocalDateTime。
第一步:补充日期格式常量
先在 idle-store-common 里统一日期格式,后面 JsonUtils、Jackson Starter 都用同一份常量,别到处手写字符串。
java
package com.lh.framework.common.constant;
public interface DateConstants {
/**
* 年-月-日 时:分:秒
*/
String Y_M_D_H_M_S_FORMAT = "yyyy-MM-dd HH:mm:ss";
/**
* 年-月-日
*/
String Y_M_D_FORMAT = "yyyy-MM-dd";
/**
* 时:分:秒
*/
String H_M_S_FORMAT = "HH:mm:ss";
}
这几个格式对应关系很清楚:
| Java 类型 | JSON 格式 |
|---|---|
LocalDateTime |
yyyy-MM-dd HH:mm:ss |
LocalDate |
yyyy-MM-dd |
LocalTime |
HH:mm:ss |
第二步:抽公共 Jackson 配置
在 common 模块里抽一个 JacksonUtils,专门负责创建和配置 ObjectMapper:
java
public class JacksonUtils {
public static ObjectMapper createObjectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
configure(objectMapper);
return objectMapper;
}
public static void configure(ObjectMapper objectMapper) {
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
objectMapper.registerModule(javaTimeModule());
}
public static JavaTimeModule javaTimeModule() {
JavaTimeModule javaTimeModule = new JavaTimeModule();
javaTimeModule.addSerializer(LocalDateTime.class,
new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DateConstants.Y_M_D_H_M_S_FORMAT)));
javaTimeModule.addDeserializer(LocalDateTime.class,
new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DateConstants.Y_M_D_H_M_S_FORMAT)));
javaTimeModule.addSerializer(LocalDate.class,
new LocalDateSerializer(DateTimeFormatter.ofPattern(DateConstants.Y_M_D_FORMAT)));
javaTimeModule.addDeserializer(LocalDate.class,
new LocalDateDeserializer(DateTimeFormatter.ofPattern(DateConstants.Y_M_D_FORMAT)));
javaTimeModule.addSerializer(LocalTime.class,
new LocalTimeSerializer(DateTimeFormatter.ofPattern(DateConstants.H_M_S_FORMAT)));
javaTimeModule.addDeserializer(LocalTime.class,
new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DateConstants.H_M_S_FORMAT)));
return javaTimeModule;
}
}
这样日期格式规则只写一份:JsonUtils 可以用它,后面的 Jackson Starter 也可以用它。
第三步:新建 Jackson Starter 模块
在 idle-store-framework 下新建模块:
text
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.xml
lh-spring-boot-starter-jackson/pom.xml:
xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lh</groupId>
<artifactId>idle-store-framework</artifactId>
<version>${revision}</version>
</parent>
<artifactId>lh-spring-boot-starter-jackson</artifactId>
<packaging>jar</packaging>
<name>${project.artifactId}</name>
<dependencies>
<!-- 依赖 common,拿 DateConstants 和 JsonUtils -->
<dependency>
<groupId>com.lh</groupId>
<artifactId>idle-store-common</artifactId>
</dependency>
<!-- Java 8 日期时间类型支持:LocalDateTime、LocalDate、LocalTime -->
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<!-- @AutoConfiguration、Jackson2ObjectMapperBuilderCustomizer -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<!-- Jackson2ObjectMapperBuilder 在 spring-web 里 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
</dependencies>
</project>
这里有两个点要注意:
| 依赖 | 作用 |
|---|---|
jackson-datatype-jsr310 |
提供 LocalDateTimeSerializer、LocalDateTimeDeserializer 这些日期处理类 |
spring-web |
提供 Jackson2ObjectMapperBuilder,Customizer 方法签名里会用到 |
如果不加 spring-web,Starter 模块单独编译时可能找不到 Jackson2ObjectMapperBuilder。
第五步:在 framework 父模块注册
编辑 idle-store-framework/pom.xml:
xml
<modules>
<module>idle-store-common</module>
<module>lh-spring-boot-starter-biz-operationlog</module>
<module>lh-spring-boot-starter-jackson</module>
</modules>
第六步:编写自动配置类
核心代码在 JacksonAutoConfiguration:
java
@AutoConfiguration
@ConditionalOnClass(ObjectMapper.class)
public class JacksonAutoConfiguration {
@Bean
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
return builder -> {
// 时区统一成东八区
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());
};
}
@Bean
public SmartInitializingSingleton jsonUtilsInitializer(ObjectMapper objectMapper) {
return () -> JsonUtils.init(objectMapper);
}
}
这段代码做了什么?
第一,定制 Spring Boot 的 ObjectMapper。
java
@Bean
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
...
}
Spring Boot 创建 ObjectMapper 时,会先拿到 Jackson2ObjectMapperBuilder,然后执行容器里所有 Jackson2ObjectMapperBuilderCustomizer。我们就在这个阶段把时区、反序列化规则、日期模块塞进去。
第二,指定 Java 8 日期类型的序列化和反序列化格式。
java
builder.modules(JacksonUtils.javaTimeModule());
这里复用了 common 中的 JacksonUtils.javaTimeModule(),不用在 Starter 里再写一遍 LocalDateTimeSerializer / LocalDateTimeDeserializer。
| 方法 | 方向 | 效果 |
|---|---|---|
| serializer | Java 对象 → JSON | LocalDateTime 输出成 "2024-05-14 12:00:00" |
| deserializer | JSON → Java 对象 | "2024-05-14 12:00:00" 解析成 LocalDateTime |
第三,把 Spring Boot 最终创建好的 ObjectMapper 同步给 JsonUtils。
java
@Bean
public SmartInitializingSingleton jsonUtilsInitializer(ObjectMapper objectMapper) {
return () -> JsonUtils.init(objectMapper);
}
这样做之后,项目里主动调用 JsonUtils.toJsonString(),和 HTTP 请求/响应使用的是同一个 ObjectMapper。同一套规则,不会这边是空格格式,那边又变成 ISO 格式。
第七步:修改 JsonUtils
java
public class JsonUtils {
private static ObjectMapper OBJECT_MAPPER = JacksonUtils.createObjectMapper();
/**
* 将对象转换为 JSON 字符串
*/
@SneakyThrows
public static String toJsonString(Object obj) {
return OBJECT_MAPPER.writeValueAsString(obj);
}
/**
* 初始化:统一使用 Spring Boot 个性化配置的 ObjectMapper
*
* @param objectMapper
*/
public static void init(ObjectMapper objectMapper) {
OBJECT_MAPPER = objectMapper;
}
}
这里就不用 static 代码块了。
默认情况下,JsonUtils 用 JacksonUtils.createObjectMapper(),所以没有 Web 的日志场景也能处理 LocalDateTime。
如果项目引入了 Web,并且加载了我们的 Jackson Starter,Spring Boot 创建好 ObjectMapper 后会调用 JsonUtils.init(objectMapper),把 HTTP 和工具类统一到同一个 ObjectMapper。
为什么不直接 new 一个 ObjectMapper?
最容易写成这样:
java
@Bean
public ObjectMapper objectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
// 一堆自定义配置...
return objectMapper;
}
这个写法不是完全不能用,但不推荐放在 Starter 里。
原因是:Spring Boot 本来会自动创建一个 ObjectMapper,并且帮你注册很多默认配置。你自己声明了一个 ObjectMapper Bean 之后,Spring Boot 的默认 ObjectMapper 创建逻辑会后退。
这就像什么呢?本来 Spring Boot 帮你装修好了房子,你只是想换个窗帘。结果你直接把房子推了重盖,那地板、水电、门窗都得你自己重新装。
我们现在只是想改日期格式,没必要重造整个 ObjectMapper。
所以更合适的方式是:
java
Jackson2ObjectMapperBuilderCustomizer
它是在 Spring Boot 原本配置的基础上追加规则,改动更小,也更稳。
第八步:注册自动配置
在这个文件里写入自动配置类全限定名:
text
src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
内容:
text
com.lh.framework.jackson.config.JacksonAutoConfiguration
Spring Boot 3.x 推荐用这个文件来注册自动配置。
第九步:业务模块引入 Starter
在 idle-store-auth/pom.xml 中引入:
xml
<dependency>
<groupId>com.lh</groupId>
<artifactId>lh-spring-boot-starter-jackson</artifactId>
</dependency>
引入之后不需要再写额外配置。idle-store-auth 启动时,Spring Boot 会读取 AutoConfiguration.imports,加载 JacksonAutoConfiguration,然后定制 HTTP 使用的 ObjectMapper。
第十步:验证接口
实体类:
java
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class User {
@NotBlank(message = "昵称不能为空")
private String nickName;
private LocalDateTime createTime;
}
Controller:
java
@PostMapping("/test2")
@ApiOperationLog(description = "测试接口2")
public Response<User> test2(@RequestBody @Validated User user) {
return Response.success(user);
}
请求体:
json
{
"nickName": "二狗",
"createTime": "2024-05-14 12:00:00"
}
如果 Starter 生效,createTime 能正常解析成 LocalDateTime,接口返回也会是:
json
{
"success": true,
"message": null,
"errorCode": null,
"data": {
"nickName": "二狗",
"createTime": "2024-05-14 12:00:00"
}
}
常见踩坑
坑一:只改 JsonUtils,HTTP 不生效
JsonUtils 只管你手动调用它的地方,比如日志切面:
java
JsonUtils.toJsonString(result)
前端传 JSON 到 Controller,走的是 Spring MVC 的消息转换器,不会自动走 JsonUtils。
所以只改 JsonUtils,@RequestBody 还是会继续报错。
坑二:在 Starter 里直接声明 ObjectMapper
直接声明:
java
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper();
}
会让 Spring Boot 的默认 ObjectMapper 自动配置后退。你只是想改日期格式,结果把整个对象都接管了,后续维护成本会变高。
更推荐:
java
@Bean
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
return builder -> {
// 只追加我们关心的规则
};
}
坑三:只配序列化,不配反序列化
无论你是在 JavaTimeModule 里配,还是在 builder 里配,都要注意:只配序列化是不够的。
只写这个:
java
builder.serializerByType(LocalDateTime.class,
new LocalDateTimeSerializer(dateTimeFormatter));
只能解决返回给前端时的格式。
前端传 "2024-05-14 12:00:00" 进来,还是可能解析失败。
所以必须成对写:
java
builder.serializerByType(LocalDateTime.class,
new LocalDateTimeSerializer(dateTimeFormatter));
builder.deserializerByType(LocalDateTime.class,
new LocalDateTimeDeserializer(dateTimeFormatter));
一个管出参,一个管入参。
不要让
JsonUtils背 HTTP 的锅,也不要为了改日期格式重造ObjectMapper。用 Starter 提供Jackson2ObjectMapperBuilderCustomizer,在 Spring Boot 原本的ObjectMapper上追加规则。