封装 Jackson 配置 Starter:统一解决 LocalDateTime 请求解析和响应格式化问题

封装 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 提供 LocalDateTimeSerializerLocalDateTimeDeserializer 这些日期处理类
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 代码块了。

默认情况下,JsonUtilsJacksonUtils.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 上追加规则。

相关推荐
码哥字节1 小时前
把 Cursor 的烂输出变成好代码,这 10 个 Prompt 改造让我省了 80% 改稿时间
java·ai编程工具·cursor 配置·prompt 技巧
2503_931712481 小时前
10m/s超高速电梯:西奥XO-NEWIII如何树立行业速度标杆
java·大数据·数据库
折哥的程序人生 · 物流技术专研1 小时前
第4篇:抽象工厂模式——产品族一键创建
java·设计模式·抽象工厂模式·创建型模式·编程进阶·产品族·扩充系列
正儿八经的少年1 小时前
Spring Boot
java·spring boot·后端
未秃头的程序猿1 小时前
虚拟线程用了一年,我真香了——10倍吞吐量是真实存在的
java·后端·面试
xiaogai_gai1 小时前
钉钉数据集成到金蝶云星空案例分享:传给钉钉后,回传金蝶字段
java·前端·钉钉
摇滚侠1 小时前
云原生 Java 架构师的第一课 K8s+Docker+KubeSphere+DevOps 19-25
java·云原生·kubernetes
标致的钢铁侠2 小时前
c# 温故而知新: 线程篇(一)
java·jvm·c#
宠友信息2 小时前
资源权限与钱包流水在即时通讯源码后端架构中的设计
java·spring boot·redis·websocket·缓存