记一次 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格式输出。 - 使用 JdbcTemplate 的
queryForList方法查询数据,结果用Map<String, Object>接收。此时发现,Map 中的日期字段输出格式变成了类似2025-12-17的纯日期字符串,秒和时分部分完全不见了,并没有按照我们设置的yyyy-MM-dd HH:mm:ss来展示。
明明都是同一个 ObjectMapper 实例,为什么只有 java.util.Date 类型被正常格式化了,而 JdbcTemplate 返回的日期却“不听话”呢?
原因分析
1. 两种 Date 类型本身就有差异
首先需要明确,java.util.Date 和 java.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.Date的toString()方法或仅输出日期部分,因此结果总是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 类型字段的格式化方式才生效”的含义。
总结
这次问题的根因可以归纳为两点:
- JDBC 规范导致类型差异:JdbcTemplate 返回的
Map中日期字段默认是java.sql.Date,而 MyBatis 实体常使用java.util.Date。 - Jackson 对 SQL 日期类型的特殊照顾:
SqlDateSerializer有自己独立的格式输出逻辑,无视全局DateFormat设置。
通过自定义 JsonSerializer 并全局注册到 ObjectMapper,我们成功让 java.sql.Date 也听从了统一的日期格式配置。这件事也提醒我,在混用多种数据访问方式时,要格外注意框架的默认行为,小小的类型差异就可能引发令人困惑的序列化问题。
希望这篇记录对你有用。如果你也有类似的踩坑经历,欢迎交流。