SpringBoot+MyBatis Plus對Map中Date格式轉換的處理
- 2022 年 10 月 10 日
- 筆記
- jackson, JAVA, mybatis, springboot
在 SpringBoot 項目中, 如何統一 JSON 格式化中的日期格式
問題
現在的關係型數據庫例如PostgreSQL/MySQL, 都已經對 JSON 類型提供相當豐富的功能, 項目中對於不需要檢索但是又需要結構化的存儲, 會在數據庫中產生很多 JSON 類型的字段, 與 Jackson 做對象的序列化和反序列化配合非常方便.
如果 JSON 都是類定義的, 這個序列化和反序列化就非常透明 — 不需要任何干預, 寫進去是什麼, 讀出來就是什麼. 但是如果 JSON 在 Java 代碼中是定義為一個 Map, 例如 Map<String, Object> 那麼就有問題了, 對於 Date 類型的數據, 在存入之前是 Date, 取出來之後就變成 Long 了.
SomePO po = new SomePO();
//...
Map<String, Object> map = new HashMap<>();
map.put("k1", new Date());
po.setProperties(map);
//...
mapper.insert(po);
//...
SomePO dummy = mapper.select(po.id);
// 這裡的k1已經變成了 Long 類型
Object k1 = dummy.getProperties().get("k1");
原因
不管是使用原生的 MyBatis 還是包裝後的 MyBatis Plus, 在對 JSON 類型字段進行序列化和反序列化時, 都需要藉助類型判斷, 調用對應的處理邏輯, 大部分情況, 使用的是默認的 Jackson 的 ObjectMapper, 而 ObjectMapper 對 Date 類型默認的序列化方式就是取時間戳, 對於早於1970年之前的日期, 生成的是一個負的長整數, 對於1970年之後的日期, 生成的是一個正的長整數.
查看 ObjectMapper 的源碼, 可以看到其對Date格式的序列化和反序列化方式設置於_serializationConfig 和 _deserializationConfig 這兩個成員變量中, 可以通過 setDateFormat() 進行修改
public class ObjectMapper extends ObjectCodec implements Versioned, Serializable {
//...
protected SerializationConfig _serializationConfig;
protected DeserializationConfig _deserializationConfig;
//...
public ObjectMapper setDateFormat(DateFormat dateFormat) {
this._deserializationConfig = (DeserializationConfig)this._deserializationConfig.with(dateFormat);
this._serializationConfig = this._serializationConfig.with(dateFormat);
return this;
}
public DateFormat getDateFormat() {
return this._serializationConfig.getDateFormat();
}
}
默認的序列化反序列化選項, 使用了一個常量 WRITE_DATES_AS_TIMESTAMPS, 在類 SerializationConfig 中進行判斷, 未指定時使用的是時間戳
public SerializationConfig with(DateFormat df) {
SerializationConfig cfg = (SerializationConfig)super.with(df);
return df == null ? cfg.with(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) : cfg.without(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}
實際的轉換工作在 SerializerProvider 類中, 轉換方法為
public final void defaultSerializeDateValue(long timestamp, JsonGenerator gen) throws IOException {
if (this.isEnabled(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)) {
gen.writeNumber(timestamp);
} else {
gen.writeString(this._dateFormat().format(new Date(timestamp)));
}
}
public final void defaultSerializeDateValue(Date date, JsonGenerator gen) throws IOException {
if (this.isEnabled(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)) {
gen.writeNumber(date.getTime());
} else {
gen.writeString(this._dateFormat().format(date));
}
}
解決
局部方案
1. 字段註解
這種方式可以用在固定的類成員變量上, 不改變整體行為
public class Event {
public String name;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "dd-MM-yyyy hh:mm:ss")
public Date eventDate;
}
另外還可以自定義序列化反序列化方法, 實現 StdSerializer
public class CustomDateSerializer extends StdSerializer<Date> {
//...
}
就可以在 @JsonSerialize 註解中使用
public class Event {
public String name;
@JsonSerialize(using = CustomDateSerializer.class)
public Date eventDate;
}
2. 修改 ObjectMapper
通過 ObjectMapper.setDateFormat() 設置日期格式, 改變默認的日期序列化反序列化行為. 這種方式只對調用此ObjectMapper的場景有效
private static ObjectMapper createObjectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
objectMapper.setDateFormat(df);
return objectMapper;
}
因為 ObjectMapper 一般是當作線程安全使用的, 而 SimpleDateFormat 並非線程安全, 在這裡使用是否會有問題? 關於這個疑慮, 可以查看 這個鏈接
@StaxMan: I am a bit concerned if ObjectMapper is still thread-safe after ObjectMapper#setDateFormat() is called. It is known that SimpleDateFormat is not thread safe, thus ObjectMapper won’t be unless it clones e.g. SerializationConfig before each writeValue() (I doubt). Could you debunk my fear? – dma_k Aug 2, 2013 at 12:09
DateFormat is indeed cloned under the hood. Good suspicion there, but you are covered. 😃 – StaxMan Aug 2, 2013 at 19:43
3. 修改 SpringBoot 配置
增加配置 spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
, 這種配置, 只對 Spring BeanFactory 中創建的 Jackson ObjectMapper有效, 例如 HTTP 請求和響應中對 Date 類型的轉換
spring:
...
jackson:
date-format: yyyy-MM-dd HH:mm:ss
整體方案
國內項目, 幾乎都會希望落庫時日期就是日期的樣子(方便看數據庫表), 所謂日期的樣子就是yyyy-MM-dd HH:mm:ss
格式的字符串. 如果怕麻煩, 就通通都用這個格式了.
這樣統一存在的隱患是丟失毫秒部分. 這個問題業務人員基本上是不會關心的. 如果需要, 就在格式中加上.
第一是 Spring 配置, 這樣所有的請求響應都統一了
spring:
...
jackson:
date-format: yyyy-MM-dd HH:mm:ss
第二是定義一個工具類, 把 ObjectMapper 自定義一下, 這樣所有手工轉換的地方也統一了, 注意留一個getObjectMapper()方法
public class JacksonUtil {
private static final Logger log = LoggerFactory.getLogger(JacksonUtil.class);
private static final ObjectMapper MAPPER = createObjectMapper();
private JacksonUtil() {}
private static ObjectMapper createObjectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.configure(JsonGenerator.Feature.AUTO_CLOSE_TARGET, false);
objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
objectMapper.setDateFormat(df);
return objectMapper;
}
public static ObjectMapper getObjectMapper() {
return MAPPER;
}
}
第三是啟動後修改 MyBatisPlus 的設置, 即下面的 changeObjectMapper() 這個方法, 直接換成了上面工具類中定義的 ObjectMapper, 這樣在 MyBatis 讀寫數據庫時的格式也統一了.
@Configuration
@MapperScan(basePackages = {"com.somewhere.commons.impl.mapper"})
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.POSTGRE_SQL));
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
@PostConstruct
public void changeObjectMapper() {
// This will unify the date format with util methods
JacksonTypeHandler.setObjectMapper(JacksonUtil.getObjectMapper());
}
}