Spring Boot升級到2.x,Jackson對Date時間類型序列化的變化差點讓項目暴雷【享學Spring Boot】
- 2020 年 2 月 21 日
- 筆記
學習時一定要開啟學霸模式。這樣你就可以裝X的說:數學考146分其實也挺容易的,故意做錯一道選擇題就可以了啊。
前言
在閱讀本文之前,建議你已經掌握了Jackson
的知識以及它的Spring、Spring Boot
下的集成和運用。
說明:若不熟悉Jackson,請務必參閱我的專欄
[享學Jackson]
(單擊這裡電梯直達),該專欄有可能是全網最好、最全的完整教程。
本文講述的是本人在生產上的一個真實案例,分享給大家,避免你采坑。它的大背景是項目需要從Spring Boot1.x升級到2.x版本,升上去之後由於Jackson對時間類型序列化的變化,使得多個項目險些暴雷,幸好本人對Jackson很了解所以迅速定位並且解決問題,及時止損。
說明:因為我寫這個是個腳手架,供給多個團隊使用。在Jackson這點上沒有考慮好向下兼容性導致多個項目差點暴雷,幸好及時止損。
正文
大家都知道,Spring Boot
2.x對1.x版本是不向下兼容的,如果你曾經做過升級、或者Spring MVC -> Spring Boot2.x
的遷移,相信你或多或少遇到過些麻煩。確實,Spring Boot
的API設計者、代碼編寫者的「實力」是不如Spring Framework
的,所以即使是同體系的1.x -> 2.x都會遇到不少問題(這裡不包括編譯問題)。
本文的關注點是Spring Boot
不同大版本下Jackson對日期/時間類型的序列化問題。據我調查和了解,該問題也是很多同學的痛點,所以相信本文能幫助到你避免采坑。
Spring Boot 1.x和2.x差異
Spring Boot
因它經常升級而不具有向下兼容性而向來「臭名昭著」,其中大版本號升級1.x升級到2.x尤為凸顯,本文將採用這兩個不同大版本,對其對日期/時間類型
序列化表現作出對比。使用的Spring Boot版本號公式如下:
- 1.x版本號是:
1.5.22.RELEASE
(1.x版本的最後一個版本,並且在2019.8.1宣布停止維護) - 2.x版本號是:
2.0.0.RELEASE
(2018.3.1發佈)
說明:本文使用
2.0.0.RELEASE
版本,而非使用和享學Jackson 專欄一致的版本號,是想強調說明:這個差異是發生在1.x和2.x交替之時,而非2.x之後的變化。
Jar包差異
不同的Spring Boot導入的Jar版本是不一樣的,這個差異在大版本號之間也不容忽略。
1.x版本:

2.x版本:

小總結
從截圖方面可看出,Jar包導入方面差異還是挺大的:
- 1.x只自動給你導入了三大核心包,三個常用三方包一個都木有幫你導入
- 1.x版本最低基於JDK6構建的,所以默認其它三方包就沒導入。但若你是基於JDK8構建的,強烈建議你手動導入常用三方包
- 2.x通過web帶入了
spring-boot-starter-json
這個啟動器,該啟動器管理着「所有」有用的Jackson相關Jar包,不僅僅是核心包- 2.x版本對JDK的最低要求是JDK8,所以默認就給你帶上這三個常用模塊是完全合理的
- 1.x使用的Jackson版本號是:
2.8.11.3
;2.x使用的Jackson版本號是2.9.4
;版本差異上並不大,可忽略
ObjectMapper表現
我們知道Spring Boot默認情況下是向容器內放置了一個ObjectMapper
實例的,因此我們可以直接使用,下面案例就是這樣做的。
公用代碼:
@Autowired ObjectMapper objectMapper; @Test public void contextLoads() throws JsonProcessingException { Map<String, Object> map = new LinkedHashMap<>(); map.put("date", new Date()); map.put("timestamp", new Timestamp(System.currentTimeMillis())); map.put("localDateTime", LocalDateTime.now()); map.put("localDate", LocalDate.now()); map.put("localTime", LocalTime.now()); map.put("instant", Instant.now()); System.out.println(objectMapper.writeValueAsString(map)); }
在不同的Spring Boot
版本上的輸出,表現如下:
1.x版本:
{ "date":1580897613003, "timestamp":1580897613003, "localDateTime":{ "dayOfMonth":5, "dayOfWeek":"WEDNESDAY", "month":"FEBRUARY", "year":2020, "hour":18, "minute":13, "nano":9000000, "second":33, "dayOfYear":36, "monthValue":2, "chronology":{ "id":"ISO", "calendarType":"iso8601" } }, "localDate":{ "year":2020, "month":"FEBRUARY", "dayOfMonth":5, "dayOfWeek":"WEDNESDAY", "era":"CE", "chronology":{ "id":"ISO", "calendarType":"iso8601" }, "dayOfYear":36, "leapYear":true, "monthValue":2 }, "localTime":{ "hour":18, "minute":13, "second":33, "nano":9000000 }, "instant":{ "epochSecond":1580897613, "nano":9000000 } }
2.x版本:
{ "date":"2020-02-05T10:15:36.520+0000", "timestamp":"2020-02-05T10:15:36.520+0000", "localDateTime":"2020-02-05T18:15:36.527", "localDate":"2020-02-05", "localTime":"18:15:36.527", "instant":"2020-02-05T10:15:36.527Z" }
小總結
1.x的執行效果同:
@Test public void fun1() throws JsonProcessingException { ObjectMapper mapper = Jackson2ObjectMapperBuilder.json().build(); Map<String, Object> map = new LinkedHashMap<>(); map.put("date", new Date()); map.put("timestamp", new Timestamp(System.currentTimeMillis())); map.put("localDateTime", LocalDateTime.now()); map.put("localDate", LocalDate.now()); map.put("localTime", LocalTime.now()); map.put("instant", Instant.now()); System.out.println(mapper.writeValueAsString(map)); }
2.x的執行效果同:
@Test public void fun1() throws JsonProcessingException { ObjectMapper mapper = Jackson2ObjectMapperBuilder.json().build(); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); ... // 省略map(同上) System.out.println(mapper.writeValueAsString(map)); }
可以看到,他們的差異僅在一個特徵值SerializationFeature.WRITE_DATES_AS_TIMESTAMPS
是否開啟。然後Spring Boot不同版本上對此值有差異:
- 1.x下此特徵開啟(這是Jackson的默認行為,是開啟的)
- 2.x下此特徵關閉
Rest表現(@ResponseBody)
在web層(其實為Spring MVC
),對於Rest接口,默認會使用Jackson
進行消息的序列化。那麼它在不同版本的表現也會存在差異:
公用代碼:
@RestController @RequestMapping("/demo") public class DemoController { @GetMapping("/get") public Object get() { Map<String, Object> map = new LinkedHashMap<>(); map.put("date", new Date()); map.put("timestamp", new Timestamp(System.currentTimeMillis())); map.put("localDateTime", LocalDateTime.now()); map.put("localDate", LocalDate.now()); map.put("localTime", LocalTime.now()); map.put("instant", Instant.now()); return map; } }
1.x版本:
{ "date":1580897613003, "timestamp":1580897613003, "localDateTime":{ "dayOfMonth":5, "dayOfWeek":"WEDNESDAY", "month":"FEBRUARY", "year":2020, "hour":18, "minute":13, "nano":9000000, "second":33, "dayOfYear":36, "monthValue":2, "chronology":{ "id":"ISO", "calendarType":"iso8601" } }, "localDate":{ "year":2020, "month":"FEBRUARY", "dayOfMonth":5, "dayOfWeek":"WEDNESDAY", "era":"CE", "chronology":{ "id":"ISO", "calendarType":"iso8601" }, "dayOfYear":36, "leapYear":true, "monthValue":2 }, "localTime":{ "hour":18, "minute":13, "second":33, "nano":9000000 }, "instant":{ "epochSecond":1580897613, "nano":9000000 } }
2.x版本:
{ "date":"2020-02-02T13:26:07.116+0000", "timestamp":"2020-02-02T13:26:07.116+0000", "localDateTime":"2020-02-02T21:26:07.12", "localDate":"2020-02-02", "localTime":"21:26:07.12", "instant":"2020-02-02T13:26:07.120Z" }
小總結
Rest表現處理啊的差異,完全同容器內的ObjectMapper
的差異。 根據前面掌握的知識:Spring MVC
消息轉換器使用的ObjectMapper
實例是自己新構建的,和容器內的無關,但為何Spring Boot
里的表現是如此呢?詳細緣由,接下來會做出解答。
Spring Boot消息轉換器配置與Jackson
從現象上看,Spring Boot
使用的ObjectMapper
是從容器中拿的,而傳統Spring MVC
使用的是自己新構建的。此處存在差異,需要一探究竟。同樣的逆推法,一切還是從MappingJackson2HttpMessageConverter
出發,Spring Boot
使用了一個JacksonHttpMessageConvertersConfiguration
配置類來配置Jackson的消息轉換器。
JacksonHttpMessageConvertersConfiguration
Configuration for HTTP message converters that use Jackson.
@Configuration class JacksonHttpMessageConvertersConfiguration { // 目的:向容器內扔一個MappingJackson2HttpMessageConverter實例,但是有很多約束條件 @Configuration @ConditionalOnClass(ObjectMapper.class) @ConditionalOnBean(ObjectMapper.class) @ConditionalOnProperty(name = HttpMessageConvertersAutoConfiguration.PREFERRED_MAPPER_PROPERTY, havingValue = "jackson", matchIfMissing = true) protected static class MappingJackson2HttpMessageConverterConfiguration { @Bean @ConditionalOnMissingBean(value = MappingJackson2HttpMessageConverter.class, ignoredType = { "org.springframework.hateoas.mvc.TypeConstrainedMappingJackson2HttpMessageConverter", "org.springframework.data.rest.webmvc.alps.AlpsJsonHttpMessageConverter" }) public MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter(ObjectMapper objectMapper) { return new MappingJackson2HttpMessageConverter(objectMapper); } } ... // 支持xml,略 }
該配置的目的是向Spring容器內放置一個Jackson消息轉換器實例,不過它有很多前提條件:
- 導入了Jackson核心包,並且容器內存在
ObjectMapper
這個Bean spring.http.converters.preferred-json-mapper
這個key對應的值不能是false(缺少此key默認也是true)- 你自己木有定義
MappingJackson2HttpMessageConverter
這個Bean,這個內置的會生效
這些條件在Spring Boot下只要導入了Jackson核心包就自然而然的成立了。從源碼處很清楚了:MappingJackson2HttpMessageConverter
它使用的是Spring容器內的ObjectMapper
完成的構建。
那麼JacksonHttpMessageConvertersConfiguration
此配置類如何被最終使用的呢?這個很關鍵,因此這裡大體倒退一下,列出如下:
@Configuration @ConditionalOnClass(HttpMessageConverter.class) @AutoConfigureAfter({ GsonAutoConfiguration.class, JacksonAutoConfiguration.class }) @Import({ JacksonHttpMessageConvertersConfiguration.class, GsonHttpMessageConvertersConfiguration.class }) public class HttpMessageConvertersAutoConfiguration { // 把容器內所有的消息轉換器注入、管理起來 private final List<HttpMessageConverter<?>> converters; public HttpMessageConvertersAutoConfiguration(ObjectProvider<List<HttpMessageConverter<?>>> convertersProvider) { this.converters = convertersProvider.getIfAvailable(); } // 向容器內扔一個`HttpMessageConverters`實例,管理所有的HttpMessageConverter // HttpMessageConverters它實現了接口:Iterable // 本處:converters的值有兩個(size為2):`MappingJackson2HttpMessageConverter`和`StringHttpMessageConverter` @Bean @ConditionalOnMissingBean public HttpMessageConverters messageConverters() { return new HttpMessageConverters((this.converters != null) ? this.converters : Collections.<HttpMessageConverter<?>>emptyList()); } ... // 略。 -> 向容器內定義一個StringHttpMessageConverter,用於處理字符串消息 }
由以上源碼可知:
EnableAutoConfiguration
驅動HttpMessageConvertersAutoConfiguration
生效,它通過@Import
讓JacksonHttpMessageConvertersConfiguration
配置生效。- 默認情況下容器內通過@Bean方式配置了兩個消息轉換器:
MappingJackson2HttpMessageConverter
和StringHttpMessageConverter
,最後都封裝進HttpMessageConverters
實例里,此實例也放進了容器。
所以,其它組件若要使用消息轉換器,只需要「引入」HttpMessageConverters
這個Bean來使用即可。有兩個地方使用到了它:WebMvcAutoConfiguration
和WebClientAutoConfiguration
,分別對應Servlet和Reactive模式。
WebMvcAutoConfiguration
對該自動配置類一句話解釋:通過EnableAutoConfiguration
方式代替@EnableWebMvc
。
說明:在
Spring Boot
環境下,強烈不建議你啟用@EnableWebMvc
註解
@Configuration @ConditionalOnWebApplication ... // 若你開啟了`@EnableWebMvc`,該自動配置類就不生效啦 @ConditionalOnMissingBean(WebMvcConfigurationSupport.class) public class WebMvcAutoConfiguration { // 對WebMvcConfigurerAdapter你肯定不陌生:它是你定製MVC的鉤子(WebMvcConfigurer接口) // EnableWebMvcConfiguration繼承自DelegatingWebMvcConfiguration,效果同@EnableWebMvc呀 @Configuration @Import(EnableWebMvcConfiguration.class) @EnableConfigurationProperties({ WebMvcProperties.class, ResourceProperties.class }) public static class WebMvcAutoConfigurationAdapter extends WebMvcConfigurerAdapter { ... // 通過構造器注入(請注意使用了@Lazy,這是很重要的一個技巧) private final HttpMessageConverters messageConverters; ... // 把容器內所有的消息轉換器,全部添加進去,讓它生效 @Override public void configureMessageConverters(List<HttpMessageConverter<?>> converters) { converters.addAll(this.messageConverters.getConverters()); } ... } }
容器內的所有的消息轉換器,最終通過WebMvcConfigurer#configureMessageConverters()
這個API被放進去,為了更方便同學們理解,再回顧下這段代碼:
WebMvcConfigurationSupport: protected final List<HttpMessageConverter<?>> getMessageConverters() { if (this.messageConverters == null) { this.messageConverters = new ArrayList<HttpMessageConverter<?>>(); configureMessageConverters(this.messageConverters); if (this.messageConverters.isEmpty()) { addDefaultHttpMessageConverters(this.messageConverters); } extendMessageConverters(this.messageConverters); } return this.messageConverters; }
so,如果你自己定義了消息轉換器,那麼messageConverters
將不再是empty,所以默認的那些轉換器們(包括默認會裝配的MappingJackson2HttpMessageConverter
)也就不會再執行了。
但是,你可千萬不要輕易得出結論:Spring Boot
下默認只有兩個消息轉換器。請看如下代碼:
HttpMessageConverters構造器: // 這裡的addDefaultConverters()就是把默認註冊的那些註冊好 // 並且最終還有個非常有意思的Combined動作 public HttpMessageConverters(boolean addDefaultConverters, Collection<HttpMessageConverter<?>> converters) { List<HttpMessageConverter<?>> combined = getCombinedConverters(converters, addDefaultConverters ? getDefaultConverters() : Collections.<HttpMessageConverter<?>>emptyList()); combined = postProcessConverters(combined); this.converters = Collections.unmodifiableList(combined); }
Spring Boot
它不僅保留了默認的消息轉換器們,保持最大的向下兼容能力,同時還讓你定義的Bean也能加入進來。最終擁有的消息轉換器我截圖如下:

可以看見:MappingJackson2HttpMessageConverter
和StringHttpMessageConverter
均出現了兩次(HttpMessageConverters
內部有個順序的協商,有興趣的可自行了解),但是@Bean方式進來的均在前面,所以會覆蓋默認行為。
出現差異的根本原因
最後的最後,終於輪到解答如標題"險些暴雷"疑問的根本原因了。解答這個原因本身其實非常簡單,展示不同版本JacksonAutoConfiguration
的源碼對比一看便知:
1.5.22.RELEASE
版本:
@Configuration @ConditionalOnClass(ObjectMapper.class) public class JacksonAutoConfiguration { ... // 無static靜態代碼塊 }
2.0.0.RELEASE
版本:
@Configuration @ConditionalOnClass(ObjectMapper.class) public class JacksonAutoConfiguration { private static final Map<?, Boolean> FEATURE_DEFAULTS; static { Map<Object, Boolean> featureDefaults = new HashMap<>(); featureDefaults.put(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); FEATURE_DEFAULTS = Collections.unmodifiableMap(featureDefaults); } ... }
Jackson默認是開啟SerializationFeature.WRITE_DATES_AS_TIMESTAMPS
這個特徵值的,所以它對時間類型的序列化方式是用時間戳方式。 1.x並沒有對Jackson默認行為做更改,而自2.0.0.RELEASE
版本起,Spring Boot
默認把此特徵值給置為fasle了。小小改動,巨大能量,險些讓我項目暴雷。
說明:因我寫的腳手架多個團隊使用,因此向下兼容能力及其重要
解決方案
雖然說這對Spring Boot本身不是問題,但是如果你想要向下兼容這便成了問題。 定位到了問題所在,從來不缺解決方案。若你仍舊像保持之前的序列化數據格式,你可以這麼做(提供兩種方案以供參考):
- 增加屬性
spring.jackson.serialization.write-dates-as-timestamps=true
- [享學Jackson] 專欄里有講述,此屬性值的優先級高於靜態代碼塊,所以這麼做是有效的
- 自定義一個
Jackson2ObjectMapperBuilderCustomizer
(保證在默認的定製器之後執行即可)
總結
本篇文章作為采坑指導系列具有很強的現實意義,如果你現正處在1.x升到2.x的狀態,那麼本文應該能對你有些幫助。這次遇到的問題,作為程序員我們應該能得出如下總結:
- 一定要有版本意識,一定要有版本意識,一定要有版本意識
- 序列化/反序列化是特別敏感的一個知識點,平時很少人關注所以容易導致出了問題就摸瞎,建議團隊內有專人研究
- 小小改動,往往具有大大能量。對未知應具有敬畏之心,小心為之。
聲明
原創不易,碼字不易,多謝你的點贊、收藏、關注。把本文分享到你的朋友圈是被允許的,但拒絕抄襲
。