记一次 MyBatis 与 JdbcTemplate 日期类型混用导致的 Jackson 格式化“失效”问题

0 阅读5分钟

记一次 MyBatis 与 JdbcTemplate 日期类型混用导致的 Jackson 格式化“失效”问题

背景

最近在项目里遇到了一个让人有些头疼的问题:同一个接口返回的 JSON 数据中,日期字段的格式居然不一样。排查之后发现,原因是项目中同时使用了 MyBatis 和 JdbcTemplate 两种方式查询数据库,而两者返回的日期类型不一致,加上 Jackson 在处理不同日期类型时的默认行为不同,最终导致了格式化“部分生效”的现象。我把整个过程记录下来,希望能帮助遇到同样问题的朋友。

现象描述

我们的项目在序列化对象时会统一配置 Jackson 的日期格式,大致如下:

ObjectMapper mapper = new ObjectMapper();
mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
  • 使用 MyBatis 的 Mapper 接口查询数据,实体类中时间字段用的是 java.util.Date。序列化后,日期能够成功按照 yyyy-MM-dd HH:mm:ss 格式输出。
  • 使用 JdbcTemplatequeryForList 方法查询数据,结果用 Map<String, Object> 接收。此时发现,Map 中的日期字段输出格式变成了类似 2025-12-17 的纯日期字符串,秒和时分部分完全不见了,并没有按照我们设置的 yyyy-MM-dd HH:mm:ss 来展示。

明明都是同一个 ObjectMapper 实例,为什么只有 java.util.Date 类型被正常格式化了,而 JdbcTemplate 返回的日期却“不听话”呢?

原因分析

1. 两种 Date 类型本身就有差异

首先需要明确,java.util.Datejava.sql.Date 虽然看着很像,但用途和设计有本质区别。

  • java.util.Date:通用的日期时间类,同时包含日期和时间信息(精确到毫秒),大多数业务实体都使用它。
  • java.sql.Date:继承自 java.util.Date,是 JDBC 专门用来映射数据库 DATE 类型的类。为了符合 SQL 标准的 DATE(只包含年、月、日),它的时间部分会被规整为零(即 00:00:00),并且其 toString() 方法也只输出 yyyy-MM-dd 格式。

当使用 JdbcTemplate.queryForList 时,JDBC 驱动会按照标准映射关系,将数据库的 DATE 类型字段自动映射为 java.sql.Date 对象放入 Map 中。而 MyBatis 默认会将日期映射为 java.util.Date(我们也可以在实体类中显式使用 java.util.Date),这就导致两种方式产出的日期对象类型不完全相同。

2. Jackson 对不同 Date 类型的处理并不一致

Jackson 在序列化时,对不同日期类型使用不同的内置序列化器。关键点在于:

  • java.util.Date 类型,Jackson 使用 DateSerializer。通过 ObjectMapper.setDateFormat() 设置的 SimpleDateFormat 会被该序列化器采纳,所以我们可以很容易地实现全局格式化。
  • java.sql.Date 类型,Jackson 则使用专门的 SqlDateSerializer。这个序列化器为了准确表达 SQL 日期的含义(即只包含日期部分),会直接忽略全局设置的 DateFormat,而是调用 java.sql.DatetoString() 方法或仅输出日期部分,因此结果总是 yyyy-MM-dd 这样的格式。

这就是为什么我们明明配置了全局日期格式,java.util.Date 可以生效,而 java.sql.Date 却纹丝不动的原因——根本不是“失效”,而是 Jackson 压根就没打算对 java.sql.Date 使用我们设置的 SimpleDateFormat

解决方案:自定义序列化器并全局注册

既然默认的 SqlDateSerializer 不买账,我们就只能自己动手,通过自定义序列化器来接管 java.sql.Date 的格式化逻辑,并将它全局注册到 ObjectMapper 中。这样无论 java.sql.Date 出现在实体类、Map 还是任何地方,都能按照我们指定的格式输出。

思路很直接:创建一个继承 JsonSerializer<java.sql.Date> 的序列化类,在 serialize 方法中用 SimpleDateFormat 格式化日期,然后通过 SimpleModule 注册。

自定义序列化器代码如下:

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;

import java.io.IOException;
import java.sql.Date;
import java.text.SimpleDateFormat;

public class CustomSqlDateSerializer extends JsonSerializer<Date> {

    private static final SimpleDateFormat FORMAT =
            new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    @Override
    public void serialize(Date value, JsonGenerator gen, 
                          SerializerProvider serializers) throws IOException {
        if (value == null) {
            gen.writeNull();
        } else {
            gen.writeString(FORMAT.format(value));
        }
    }
}

注册到 ObjectMapper 的方式也很简单:

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import java.sql.Date;

ObjectMapper mapper = new ObjectMapper();
// 如果还有针对 java.util.Date 的全局格式,可继续保留
// mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

SimpleModule module = new SimpleModule();
module.addSerializer(Date.class, new CustomSqlDateSerializer());
mapper.registerModule(module);

这样一来,所有 java.sql.Date 类型字段的序列化都会走我们自定义的逻辑,输出 yyyy-MM-dd HH:mm:ss 格式的字符串,与 MyBatis 实体中 java.util.Date 的表现达成一致。问题顺利解决。

补充说明:因为 JdbcTemplate.queryForList 返回的是 Map,我们无法像处理实体类那样在字段上加注解,所以全局注册才是真正有效的方案。这也正是题目中所说的“只能自定义序列化类指定 java.sql.Date 类型字段的格式化方式才生效”的含义。

总结

这次问题的根因可以归纳为两点:

  1. JDBC 规范导致类型差异:JdbcTemplate 返回的 Map 中日期字段默认是 java.sql.Date,而 MyBatis 实体常使用 java.util.Date
  2. Jackson 对 SQL 日期类型的特殊照顾SqlDateSerializer 有自己独立的格式输出逻辑,无视全局 DateFormat 设置。

通过自定义 JsonSerializer 并全局注册到 ObjectMapper,我们成功让 java.sql.Date 也听从了统一的日期格式配置。这件事也提醒我,在混用多种数据访问方式时,要格外注意框架的默认行为,小小的类型差异就可能引发令人困惑的序列化问题。

希望这篇记录对你有用。如果你也有类似的踩坑经历,欢迎交流。