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 Boot2.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消息轉換器實例,不過它有很多前提條件:

  1. 導入了Jackson核心包,並且容器內存在ObjectMapper這個Bean
  2. spring.http.converters.preferred-json-mapper這個key對應的值不能是false(缺少此key默認也是true)
  3. 你自己木有定義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生效,它通過@ImportJacksonHttpMessageConvertersConfiguration配置生效。
  • 默認情況下容器內通過@Bean方式配置了兩個消息轉換器:MappingJackson2HttpMessageConverterStringHttpMessageConverter,最後都封裝進HttpMessageConverters實例里,此實例也放進了容器。

所以,其它組件若要使用消息轉換器,只需要「引入」HttpMessageConverters這個Bean來使用即可。有兩個地方使用到了它:WebMvcAutoConfigurationWebClientAutoConfiguration,分別對應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也能加入進來。最終擁有的消息轉換器我截圖如下:

可以看見:MappingJackson2HttpMessageConverterStringHttpMessageConverter均出現了兩次(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本身不是問題,但是如果你想要向下兼容這便成了問題。 定位到了問題所在,從來不缺解決方案。若你仍舊像保持之前的序列化數據格式,你可以這麼做(提供兩種方案以供參考):

  1. 增加屬性spring.jackson.serialization.write-dates-as-timestamps=true
    1. [享學Jackson] 專欄里有講述,此屬性值的優先級高於靜態代碼塊,所以這麼做是有效的
  2. 自定義一個Jackson2ObjectMapperBuilderCustomizer(保證在默認的定製器之後執行即可)

總結

本篇文章作為采坑指導系列具有很強的現實意義,如果你現正處在1.x升到2.x的狀態,那麼本文應該能對你有些幫助。這次遇到的問題,作為程序員我們應該能得出如下總結:

  1. 一定要有版本意識,一定要有版本意識,一定要有版本意識
  2. 序列化/反序列化是特別敏感的一個知識點,平時很少人關注所以容易導致出了問題就摸瞎,建議團隊內有專人研究
  3. 小小改動,往往具有大大能量。對未知應具有敬畏之心,小心為之。

聲明

原創不易,碼字不易,多謝你的點贊、收藏、關注。把本文分享到你的朋友圈是被允許的,但拒絕抄襲