1. 背景
做 java + spring + mysql 的项目时,遇到时间相关的字段,在 数据库存取 和 输入输出格式化 等方面存在一些需要注意的问题。
整个链条包括四个身份:
- 客户端(web 或者 app)
- jvm
- jdbc链接
- 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类型 |
|---|---|
| LocalDateTime | datetime |
| ZonedDateTime | timestamp |
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 就不再生效了