spring 中时间字段的时区一致性和格式化

190 阅读4分钟

1. 背景

做 java + spring + mysql 的项目时,遇到时间相关的字段,在 数据库存取 和 输入输出格式化 等方面存在一些需要注意的问题。

整个链条包括四个身份:

  1. 客户端(web 或者 app)
  2. jvm
  3. jdbc链接
  4. mysql session

需要解决掉 客户端 和 jvm 之间的传值的格式、时区,jvm 和 mysql 之间的传值时区 两个问题。

2. jvm 和 数据库存储和查询 的时区处理

2.1 jvm 时区处理

jvm 有默认时区,可以在启动 java 程序时传入参数 -Duser.timezone=Europe/London,也可以直接在代码里设置。可以如下设置

import java.util.TimeZone;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class MainApplication {
    public static void main(String[] args) {
        // 可以在main函数的里面作为第一句,确保整个应用程序内部都是此时区
        initTimeZone();
        SpringApplication.run(MainApplication.class, args);
    }
    public static void initTimeZone() {
        String zoneID = System.getProperty("user.timezone", "Asia/Shanghai");
        // 设置时区
        TimeZone.setDefault(TimeZone.getTimeZone(zoneID));
        // 获取时区
        // System.out.println(TimeZone.getDefault().getID());
        // System.out.println(java.time.ZoneId.systemDefault());
    }
}

2.2 java 存取数据库的时区处理

mysql 的 datetime 类型(包括 date)只是时间,不包含时区。mysql 的 timestamp 类型,底层存储的其实是 +0 时区的 int 时间戳,在进行数据库查询时按照当前 mysql session 的时区设置进行时区转化。 可以在数据库链接上设置一些参数,确保整个数据库链接(数据库session)保持和当前jvm一致的时区,来避免掉各种中间计算时区转化带来的问题。

# spring application.properties
spring.datasource.url=jdbc:mysql://yourserver:3306/your_db?connectionTimeZone=LOCAL&forceConnectionTimeZoneToSession=true

按照文档详见 dev.mysql.com/doc/connect… 描述:

  • connectionTimeZone: Connector/J 对 mysql 返回的时间字段当做什么时区。可选值三个: LOCAL(当成jvm时区)、SERVER(当做数据库会话的时区)、自定义时区(当做这个时区)
  • forceConnectionTimeZoneTo:强制将会话的时区设置为connectionTimeZone设置的时区
  • connectionTimeZone=LOCAL&forceConnectionTimeZoneToSession=true 可以确保 jvm、jdbc链接、数据库session 三者时区相同,都以jvm时区为准

可以按照如下关系映射 java 类型和 mysql 类型:

java类型mysql类型
LocalDateTimedatetime
ZonedDateTimetimestamp

2. 时间字段格式化

针对客户端 和 spring 请求和响应时间字段,有两种场景,对应不同的处理机制

2.1 json 请求

可以使用 @ResponseBody 注解,映射请求体为 DTO 对象或返回 json 响应。spring 内部是通过 jackson 框架来实现序列化和反序列化,如下介绍几种设计方法:

1. jackson 提供了 @JsonFormat 注解来接收参数以及该类对象返回如何被返回。

可以设置时间字段的格式。对 java.time 包下的类型也会生效。但这样的手动处理不全局。

import com.fasterxml.jackson.annotation.JsonFormat;
class XXDto {
    @JsonFormat(pattern="yyyy-MM-dd HH:mm:ss")
    LocalDateTime zonedDateTime;
    
    // 带时区的时间格式,可指定timezone,默认会按照jvm时区
    @JsonFormat(pattern="yyyy-MM-dd HH:mm:ss", timezone="Europe/London")
    ZonedDateTime zonedDateTime;
}

2. spring 提供了配置项来统一设置,但只能针对 java.util.Date 类型

详见 docs.spring.io/spring-boot…

spring.jackson.time-zone=Europe/London
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss

3. 可以按照 jackson 的要求,提供 Jackson2ObjectMapperBuilderCustomizer,手动指定格式化

import java.text.SimpleDateFormat;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.TimeZone;

import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import com.fasterxml.jackson.datatype.jsr310.deser.InstantDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.ZonedDateTimeSerializer;

@Configuration
public class JacksonDateTimeFormatConfig {
    private String dateTimePattern = "yyyy-MM-dd HH:mm:ss";
    private String datePattern = "yyyy-MM-dd";
    private String timePattern = "HH:mm:ss";

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jsonDateTimeCustomizer() {
        return builder -> {
            // 设置 java.util.date
            builder.timeZone(TimeZone.getDefault());
            builder.dateFormat(new SimpleDateFormat(dateTimePattern));

            // java.time 下类的 serializers
            builder.serializers(new ZonedDateTimeSerializer(DateTimeFormatter.ofPattern(dateTimePattern)));
            builder.serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(dateTimePattern)));
            builder.serializers(new LocalDateSerializer(DateTimeFormatter.ofPattern(datePattern)));
            builder.serializers(new LocalTimeSerializer(DateTimeFormatter.ofPattern(timePattern)));

            // java.time 下类的 deserializers
            builder.deserializers(new ZonedDateTimeDeserializer(DateTimeFormatter.ofPattern(dateTimePattern).withZone(ZoneId.systemDefault())));
            builder.deserializers(new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(dateTimePattern)));
            builder.deserializers(new LocalDateDeserializer(DateTimeFormatter.ofPattern(datePattern)));
            builder.deserializers(new LocalTimeDeserializer(DateTimeFormatter.ofPattern(timePattern)));
        };
    }

    /**
     * 解决 com.fasterxml.jackson.datatype.jsr310.deser.InstantDeserializer 的构造器私有,
     * 导致不能注入自定义 formatter 的问题
     */
    class ZonedDateTimeDeserializer extends InstantDeserializer<ZonedDateTime> {
        private static final long serialVersionUID = 1L;
        public ZonedDateTimeDeserializer(DateTimeFormatter formatter) {
            super(InstantDeserializer.ZONED_DATE_TIME, formatter);
        }
    }
}

4. 还可以按照 springboot 为 jackson 提供的 @JsonComponent

使用 @JsonComponent 可以自行写 jackson 的 JsonSerializer 和 JsonDeserializer 的子类,对泛型类提供序列化。详见 docs.spring.io/spring-boot…

2.2 get请求、表单请求

通过 @RequestParam、@ModelAttribute、@PathVariable 映射字段或者dto,是不会触发jackson的,而是走 spring 自己的类型转化

1. springframework 提供 @DateTimeFormat 注解来接收参数

同样这个处理不全局,且不能处理输出

XXController {
    @GetMapping({"/test"})
    String getAction(
        @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
        @RequestParam ZonedDateTime datetime
    ) {
        return "your-tpl-path";
    }
}

2. spring 提供了配置项来统一设置

详见 docs.spring.io/spring-boot…

spring.mvc.format.date=yyyy-MM-dd
spring.mvc.format.time=HH:mm:ss
spring.mvc.format.date-time=yyyy-MM-dd HH:mm:ss

相比 jackson 的时间字段,同时支持 java.util 和 java.time 下的时间类型。但带时区的类型(比如 ZonedDateTime)在输出时是好的,但不能解决请求输入(没指定时区信息,下文有解)。

3. 实现 WebMvcConfigurer 接口,手动在 addFormatters 方法内注册 formatter

import java.time.ZoneId;
import java.time.format.DateTimeFormatter;

import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.format.datetime.standard.DateTimeFormatterRegistrar;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class DateTimeFormatConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar();
        // registrar.setUseIsoFormat(true);
        // 设置你需要的日期时间格式
        registrar.setDateFormatter(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        registrar.setTimeFormatter(DateTimeFormatter.ofPattern("HH:mm:ss"));
        registrar.setDateTimeFormatter(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault())); // 手动设置了时区
        // 注册格式化器
        registrar.registerFormatters(registry);
    }
}

在注册了 formatter 时,@DateTimeFormat 还会生效,优先级更高。

4. 实现 WebMvcConfigurer 接口,手动在 addFormatters 方法内注册 converter

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        // 注册自定义的 ZonedDateTime 转换器
        registry.addConverter(new StringToZonedDateTimeConverter());
    }
}
class StringToZonedDateTimeConverter implements Converter<String, ZonedDateTime> {
    private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    @Override
    public ZonedDateTime convert(String source) {
        return ZonedDateTime.parse(source, formatter.withZone(ZoneId.of("Asia/Shanghai")));
    }
}

注意 Converter 的泛型参数,描述的是 String 如何转化为 ZonedDateTime,也就是还要提供 ZonedDateTime 转化为 String 的 Converter。注册了 converter 之后,@DateTimeFormat 就不再生效了