SpringBoot中关于DateTime的两个坑

最近在SpringBoot项目中遇到两个有时间相关的问题,一个是时间的序列化的问题,一个是Java8中的时区转换问题。这两个问题比较坑人,花了不少时间才定位到问题并得以解决,网上的方案也是眼花缭乱,我把测试成功的几种方案分享出来,希望能让大家少走弯路。

问题一:时间格式序列化问题

我们有两个项目,一个项目比较老,使用的是ServiceMix里内置的CXF Service,jackson序列化的版本也比较低,它使用的时间序列化格式是yyyy-MM-dd'T'HH:mm:ss.SSSZ,类似于2021-02-01T12:08:56.235+0800,而我们新项目中使用的SpringBoot 2.0的时间序列化格式为yyyy-MM-dd'T'HH:mm:ss.SSSXXX,类似于2021-02-01T12:08:56.235+08:00,"X" refer to the (ISO 8601 time zone)。因此这两个项目中的时间格式不一致,因为为了系统的兼容性,决定将系统中的时间格式统一,由于项目之前很多地方都是按照第一种格式(非ISO格式)来进行转换的,因此决定将SpringBoot 2.0项目中的时间格式也按照yyyy-MM-dd'T'HH:mm:ss.SSSZ格式进行处理。

实现方式一:在SpringBoot配置文件中设置时间格式

按照SpringBoot 2.0中时间格式的处理方式,只需要在application.properties中定义时间格式,并将write-dates-as-timestamps设为false,这样序列化后的时间格式就是我们需要的2021-02-01T12:08:56.235+0800这种格式,保持了统一。

spring.jackson.date-format=yyyy-MM-dd'T'hh:mm:ss.SSSZ
spring.jackson.time-zone=GMT+8
spring.jackson.serialization.write-dates-as-timestamps=false

但这么实现后,发现没能生效,我在实例中测试了一个很简单的测试服务,就是返回一个Map类型的对象,如下:

@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.APPLICATION_JSON_VALUE)
    public Map testDate() {
        Map result = new HashMap<>();
        result.put(new Date(), new Date());
        return result;
    }

期望返回的是如下的结果,

{"2021-02-02T09:48:05.991+0800":"2021-02-02T09:48:05.991+0800"}

但实际返回的结果是UTC时区,ISO格式的时间。

{"2021-02-02T01:44:51.971+00:00":"2021-02-02T01:44:51.971+00:00"}
方法二:注入ObjectMapper,并设置时间格式

我们都知道SpringBoot中使用的是Jackson库作为序列化和反序列化库,因此我们可以在Springboot中注入ObjectMapper,并为ObjectMapper设置时间格式来实现此功能。代码如下:

@Configuration
public class DateTimeConfig {
    @Bean
    @Primary
    public ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        JavaTimeModule timeModule = new JavaTimeModule();
        mapper.registerModule(timeModule);
        DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'hh:mm:ss.SSSZ");
        dateFormat.setTimeZone(TimeZone.getDefault());
        mapper.setDateFormat(dateFormat);
        mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
        mapper.configure(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS, false);
        return mapper;
    }
}

期望返回的是如下的结果,

{"2021-02-02T09:48:05.991+0800":"2021-02-02T09:48:05.991+0800"}

但实际返回的结果是key使用的是UTC时区,ISO格式的时间,而value对应的日期返回的是timestamp,也不是我们的结果。

{"2021-02-02T03:06:20.298+00:00":1612235180298}
原因追踪

什么问题导致这个问题呢?按照常理SpringBoot官网提供的第一种方法应该肯定生效,现在不生效,很有可能是某种原因覆盖了序列化和反序列化设置。经过查找,也有不少人遇到相同问题,是因为定义了WebMvcConfigurer或者WebMvcConfigurationSupport之后并增加了@EnableWebMvc注释,原有properties中的jackson配置会失效。所以必须在自定义实现类中再次对jackson的配置进行补充。
关于SpringMVC相关的配置类包括以下四个,这四个类有一些历史原因,也有一些注意事项。

  • WebMvcConfigurerAdapter
  • WebMvcConfigurer
  • WebMvcConfigurationSupport
  • @EnableWebMvc
1)WebMvcConfigurerAdapter

WebMvcConfigurerAdapter,这个是在 Spring Boot 1.x 中我们自定义 SpringMVC 时继承的一个抽象类,这个抽象类本身是实现了 WebMvcConfigurer 接口。这个类已经在SpringBoot 2中已经标记为depreciated,因此不建议使用。

2)WebMvcConfigurer

WebMvcConfigurer 是我们在 Spring Boot 2.x 中实现自定义配置的方案。WebMvcConfigurer 是一个接口,接口中的方法和 WebMvcConfigurerAdapter 中定义的空方法其实一样,所以用法上来说,基本上没有差别,从 Spring Boot 1.x 切换到 Spring Boot 2.x ,只需要把继承类改成实现接口即可。因为java8中支持在接口中定义默认方法。

3)WebMvcConfigurationSupport

当我们使用java代码来替代配置文件时,就是通过继承 WebMvcConfigurationSupport 类来实现的。在 WebMvcConfigurationSupport 类中,提供了用 Java 配置 SpringMVC 所需要的所有方法。这个类里面的方法其实和前面两个类中的方法基本是一样的。
但在SpringBoot项目中,SpringMVC 相关的自动化配置是在 WebMvcAutoConfiguration 配置类中实现的,这个配置类生效的条件是如下:

@Configuration
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass({ Servlet.class, DispatcherServlet.class, WebMvcConfigurer.class })
@ConditionalOnMissingBean(WebMvcConfigurationSupport.class)
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE + 10)
@AutoConfigureAfter({ DispatcherServletAutoConfiguration.class, TaskExecutionAutoConfiguration.class,
        ValidationAutoConfiguration.class })
public class WebMvcAutoConfiguration {
}

我们从这个类的注解中可以看到,它的生效条件有一条,就是当不存在 WebMvcConfigurationSupport 的实例时,这个自动化配置才会生生效。因此,如果我们在 Spring Boot 中自定义 SpringMVC 配置时选择了继承 WebMvcConfigurationSupport,就会导致 Spring Boot 中 SpringMVC 的自动化配置失效。这就是上面为什么我们的几个方法都不能更改时间格式的原因。

特别注意:
Spring Boot 给我们提供了很多自动化配置,很多时候当我们修改这些配置的时候,并不是要全盘否定 Spring Boot 提供的自动化配置,我们可能只是针对某一个配置做出修改,其他的配置还是按照 Spring Boot 默认的自动化配置来,而继承 WebMvcConfigurationSupport 来实现对 SpringMVC 的配置会导致所有的 SpringMVC 自动化配置失效,因此,一般情况下我们不选择这种方案。

4)@EnableWebMvc

最后还有一个 @EnableWebMvc 注解,这个注解很好理解,它的作用就是启用 WebMvcConfigurationSupport。我们来看看这个注解的定义:

/**
 * Adding this annotation to an {@code @Configuration} class imports the Spring MVC
 * configuration from {@link WebMvcConfigurationSupport}
**/

加了这个注解,就会自动导入 WebMvcConfigurationSupport,所以在 Spring Boot 中,我们也不建议使用 @EnableWebMvc 注解,因为它一样会导致 Spring Boot 中的 SpringMVC 自动化配置失效。

水落石出

到这里终于明白为什么我那两种方案不生效了,是因为在我的项目中增加了如下的代码:

@Configuration
@EnableWebMvc
public class InterceptorConfig implements WebMvcConfigurer {
    private ResponseResultInterceptor responseResultInterceptor = null;

    @Autowired
    InterceptorConfig(ResponseResultInterceptor responseResultInterceptor){
        this.responseResultInterceptor = responseResultInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(responseResultInterceptor);
    }
}

正是因为上面的@EnableWebMvc自动导入 WebMvcConfigurationSupport,进而覆盖了WebMvcAutoConfiguration的自动配置,导致在application.properties中的配置不能生效。因此为了解决这个问题。

方法一:移出@EnableWebMvc注解
方法二:重载configureMessageConverters方法
@Configuration
@EnableWebMvc
public class InterceptorConfig implements WebMvcConfigurer {
    private ResponseResultInterceptor responseResultInterceptor = null;

    @Autowired
    InterceptorConfig(ResponseResultInterceptor responseResultInterceptor){
        this.responseResultInterceptor = responseResultInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(responseResultInterceptor);
    }

    @Bean
    public MappingJackson2HttpMessageConverter jackson2HttpMessageConverter() {
        MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
        ObjectMapper mapper = new ObjectMapper();
        mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd'T'hh:mm:ss.SSSZ"));
        converter.setObjectMapper(mapper);
        return converter;
    }
    @Override
    public void configureMessageConverters(List> converters) {
        converters.add(jackson2HttpMessageConverter());
    }
}

第二个问题:时区转换问题

遇到的第二个问题是Java8中的LocalDateTime和ZonedDateTime两个时间类型的相互转换问题,下面这段代码是根据一个ISO格式的字符串转为Instant时间。Instant 表示一个 EPOCH 时间戳(即以 0 表示 1970-01-01T00:00:00Z),精确到纳秒。Instant 对象不包含时区信息,且值是不可变的。

protected Long getEpochMilliFromBpsApiString(String time){
        DateTimeFormatter formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
        ZoneId zone = ZoneOffset.UTC;
        LocalDateTime localDateTime = LocalDateTime.parse(time, formatter);
        ZonedDateTime zonedDateTime = ZonedDateTime.of(localDateTime, zone);
        Instant instant = zonedDateTime.toInstant() ;
        return  instant.toEpochMilli();
    }

上面这段代码当传入参数是ISO格式且时区是UTC时后,转换是正确的,比如:2020-02-01T12:00:00.344+00:00,这个转换是正确的,能得到正确的结果。但如果输入是2020-02-01T12:00:00.344+07:00,这个转换就会出现错误。原因是对LocalDateTime.parse方法和ZonedDateTime.off方法没有理解正确。

LocalDateTime localDateTime = LocalDateTime.parse(time, formatter);

LocalDateTime.parse方法输出的localDateTime是2020-02-01T12:00:00.344,没有做任何时区的转换,而是直接抛弃时区信息。

 ZoneId zone = ZoneOffset.UTC;
 ZonedDateTime zonedDateTime = ZonedDateTime.of(localDateTime, zone);

ZonedDateTime.of方法输出的是带时区信息,但只是把localDateTime和zone结合生成一个新的带时区的datetime,也没有做任何时区的转换,因此输出的是2020-02-01T12:00:00.344Z。
因此,上面的代码片段是没有正确掌握这两个方法而导致的误用。明白了这个道理,修改起来就比较简单,LocalDateTime在这段代码中没有任何作用,因此直接使用ZonedDateTime来进行转换即可。

 protected Long getEpochMilliFromBpsApiString(String time){
        DateTimeFormatter formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
        ZonedDateTime zonedDateTime = ZonedDateTime.parse(time, formatter);
        Instant instant = zonedDateTime.toInstant() ;
        return  instant.toEpochMilli();
 }

总结
LocalDateTime不带任何时区信息,ZonedDateTime是带有时区信息,LocalDateTime.parse方法会抛弃时区信息,ZonedDateTime中带有时区信息,ZonedDateTime.of方法是结合时间和zone生成带有时区的DateTime。

写在最后

上面这两个时间序列化和时区转换问题是比较有代表性的两个问题,应该是Java开发者都会遇到的问题,只有明白了原理,才能在开发中避免以及快速的解决问题,希望能帮助到大家。

你可能感兴趣的:(SpringBoot中关于DateTime的两个坑)