本文主要是介绍Spring Boot升级到2.x,Jackson对Date时间类型序列化的变化差点让项目暴雷【享学Spring Boot】,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
学习时一定要开启学霸模式。这样你就可以装X的说:数学考146分其实也挺容易的,故意做错一道选择题就可以了啊。
目录
- 前言
- 正文
- Spring Boot 1.x和2.x差异
- Jar包差异
- 1.x版本:
- 2.x版本:
- 小总结
- ObjectMapper表现
- 1.x版本:
- 2.x版本:
- 小总结
- Rest表现(@ResponseBody)
- 1.x版本:
- 2.x版本:
- 小总结
- Spring Boot消息转换器配置与Jackson
- JacksonHttpMessageConvertersConfiguration
- WebMvcAutoConfiguration
- 出现差异的根本原因
- 解决方案
- 小知识点:1.x和2.x动态代理方式的差异
- 总结
- 关注A哥
前言
在阅读本文之前,建议你已经掌握了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@ConditionalOnMissingBeanpublic 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;...// 把容器内所有的消息转换器,全部添加进去,让它生效@Overridepublic 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动态代理方式的差异
1.x默认使用的JDK动态代码方式
2.x默认使用的CGLIB的动态代理方式
需要注意的是这和Spring无差别,Spring一直默认的就是JDK代理方式,而是Spring在其基础上做了定制,详情请参见AopAutoConfiguration
自动配置类,它在1.x和2.x的表现是不一样的。
总结
本篇文章作为采坑指导系列具有很强的现实意义,如果你现正处在1.x升到2.x的状态,那么本文应该能对你有些帮助。这次遇到的问题,作为程序员我们应该能得出如下总结:
- 一定要有版本意识,一定要有版本意识,一定要有版本意识
- 序列化/反序列化是特别敏感的一个知识点,平时很少人关注所以容易导致出了问题就摸瞎,建议团队内有专人研究
- 小小改动,往往具有大大能量。对未知应具有敬畏之心,小心为之。
关注A哥
Author | A哥(YourBatman) |
---|---|
个人站点 | www.yourbatman.cn |
yourbatman@qq.com | |
微 信 | fsx641385712 |
活跃平台 | |
公众号 | BAT的乌托邦(ID:BAT-utopia) |
知识星球 | BAT的乌托邦 |
每日文章推荐 | 每日文章推荐 |
- [享学Jackson] 一、初识Jackson – 世界上最好的JSON库
- [享学Jackson] 二、jackson-core之流式API与JsonFactory、JsonGenerator、JsonParser
- [享学Jackson] 三、jackson-databind之ObjectMapper与数据绑定、树模型
- [享学Jackson] 四、控制Jackson行为的特征们之JsonFactory.Feature、JsonGenerator.Feature、JsonParser.Feature
- [享学Jackson] 五、控制Jackson行为的特征们之JsonWriteFeature、JsonReadFeature
- [享学Jackson] 六、控制Jackson行为的特征们之MapperFeature、SerializationFeature、DeserializationFeature
- [享学Jackson] 七、Jackson使用bit位运算来开启/禁用Feature的原理解析
- [享学Jackson] 八、jackson-databind数据绑定基础配置之BaseSettings、MapperConfig、MapperConfigBase
- [享学Jackson] 九、jackson-databind数据绑定序列化/反序列化配置之SerializationConfig、DeserializationConfig
- [享学Jackson] 十、jackson-databind序列化之ObjectMapper序列化原理、序列化器匹配原理
- [享学Jackson] 十一、jackson-databind之JsonSerializer序列化器全解析
- [享学Jackson] 十二、jackson-databind反序列化之ObjectMapper反序列化原理、JsonDeserializer反序列化器全解析
- [享学Jackson] 十三、jackson-annotation注解模块全解析及Jackson注解大全
- [享学Jackson] 十四、深入理解Jackson的Module模块化设计及原理分析
- [享学Jackson] 十五、第三方模块Module的深度实践:JavaTimeModule、JSR310Module、ParameterNamesModule、Jdk8Module
- [享学Jackson] 十六、Jackson在Spring MVC中的使用之Date、JSR310时间类型的处理
- [享学Jackson] 十七、spring-web整合Jackson源码解析之Jackson2ObjectMapperBuilder
- [享学Jackson] 十八、Spring容器深度整合Jackson的桥梁之SpringHandlerInstantiator
- [享学Jackson] 十九、Spring下使用ObjectMapper的正确姿势 — Jackson2ObjectMapperFactoryBean
- [享学Jackson] 二十、Spring MVC下的Jackson — MappingJackson2HttpMessageConverter
- [享学Jackson] 二十一、Spring Boot下的Jackson — JacksonAutoConfiguration自动配置
- [享学Jackson] 二十二、Jackson与Fastjson的恩怨情仇(完结篇)
这篇关于Spring Boot升级到2.x,Jackson对Date时间类型序列化的变化差点让项目暴雷【享学Spring Boot】的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!