时间字段按时区进行序列化

1. 什么是时区问题

在一个在纽约的同事,提了一个请假单,此纽约的同事电脑上的时间为2023-11-17 09:00:00,提交成功后,在上海的领导进行审批,此时上海的领导电脑上的时间为2023-11-17 22:00:00,看到审批单上的请假时间是2023-11-17 09:00:00,还以为是从今天上午的请的假。当领导审批完成后,纽约的同事会看到审批单的审批时间是2023-11-17 22:00:00,领导在未来的时间里进行了审批。

出现这个问题的原因就在因为纽约的同事,和上海的领导所在的时区不一样,在展示的时间上纽约的时间要比上海的时间慢13个小时。虽然都在同一时刻,但是显示在不同地方的时间是不一样的

所以要解决该问题,就需要在当前使用系统的用户设备上,展示根据时区转换后的时间

2. Java的时区表示

在Java中,时区的表示方式有两种:

  • 时区偏移量的形式:例如”GMT+8”。
    • 这表示的是格林尼治标准时间加上8小时的时区,也就是东八区的时间。
  • 区域的形式:例如”Asia/Shanghai”。
    • 这表示的是亚洲/上海的时区,这种形式的好处是可以自动处理夏令时的问题。

在Java 8中,推荐使用ZoneId来表示时区,它是一个不可变的类,提供了获取当前日期和时间、解析和格式化日期和时间的方法。

  • 什么是夏令时
    • 夏令时,又称“日光节约时制”和“夏令时间”,是一种为节约能源而人为规定地方时间的制度,在这一制度实行期间所采用的统一时间称为“夏令时间”。一般在天亮早的夏季人为将时间提前一小时,可以使人早起早睡,减少照明量,以充分利用光照资源,从而节约照明用电。
    • 美国,加拿大,澳大利亚,大多数欧洲国家都实行夏令时

3. 项目中会出现时区问题的原因

  • 基础项目框架中,时间字段前端与后端交互方式大多就是String字符串,格式为yyyy-MM-dd HH:mm:ss
  • 时间字段在接口接收的字段类型通常为Date
  • 时间字段在数据库中存储的类型为DateTime
  • 时间在服务器和数据库使用的的是北京时间”Asia/Shanghai”
  • 前端传递给后端时间字符串,通过框架的通过序列化框架,转为北京时间,然后存到数据库,所以即使在其他时区,都会当成北京时间来处理,丢失了时区
  • 后端返回给前端的时间字符串,也是通过序列化框架,将Date类型,转为北京时间的时区时间展示

4. 项目中哪些地方需要做时区处理

  • 所有的服务页面上涉及到时间展示的
    • 列表中的时间
    • 表单中的时间字段
  • 导入导出的时间展示
    • 导出为Excel时,有时间字段的导出
  • 静态消息类
    • 站内信,邮件,短信,移动推送等无法改变的内容里
  • 统计分析的时区问题,例如:统计昨天的提的jira数量,这种因为时区不同,统计的区间也不同,统计结果会有差异的问题,这种场景先暂时不考虑了,统计先统一按照北京时间去统计

5. 时区问题修复方案

  • 时区标识从哪来

    • 要处理时区,首先要获取到当前用户操作所在的时区是什么
    • 前端在请求头中统一添加时区字段timeZone,通过浏览器获取当前所在的时区,统一使用区域的时区形式,timeZone=”Asia/Shanghai”
  • 接口层面的时间字段接收与返回

    • 此场景为大部分业务的通用场景,需要框架统一处理
    • 本来这个场景使用网关做比较合适,但是由于网关只能拿到时间的字符串类型字段,无法确认哪些字段是需要做时区转换的时间字段,所以在网关处理不太合理
    • 在后端框架的序列化框架上做自定义时间时区序列化,通过获取request请求头中的timeZone,再通过工具类将时区进行转换
    • 方案优点
      • 后端框架统一处理,不需要前后端动任何业务代码
      • 改动范围较小,能解决大部分场景的时区问题
    • 方案缺点
      • 需要涉及时区的服务重启服务
      • 无法处理导入导出,静态时间类的这些特殊场景
    • 方案风险点
      • 由于是在后端框架序列化的地方统一处理的,所以feign调用也同样会被统一处理
      • 如果请求头中的字段timeZone,被feign传递到下游服务,请求和返回参数中又有时间字段,时间就被转错了
      • 因为服务器中的所有时间都是北京时间,feign调用前后已经是北京时间了,不需要再次转换,多次转换就会出错
    • 风险点的解决方案
      • 目前暂时没有需要将timeZone作为请求头向下传递的需求,所以只要不再feign调用时带这个请求头就没有问题
      • 如果后期有需要向下传递的需求,那需要借助网关层辅助进行处理
      • 具体操作:
        • 网关层拦截请求头timeZone
        • 额外添加一个请求头gatewayTimeZone,复制timeZone的值
        • 在后端序列化框架的地方,使用gatewayTimeZone请求头来确认是从浏览器网关层请求过来的数据
  • 导入导出的时间展示

    • 由于导入导出是的序列化逻辑是业务代码或者excel工具包来实现的,框架无法做统一转换,需要各个业务系统根据实际业务来特殊处理
    • Date与字符串之前的转换,可以通过框架包中的DateSerializerUtil类实现,该类提供了字符串转时间,和时间转字符串, 和获取当前请求的时区的三个方法
    • Date date = DateSerializerUtil.parseDate(dateStr);
    • String str = DateSerializerUtil.formatDate(date);
    • String timeZone = getTimeZone();
  • 静态消息类时间处理

    • 统一在时间的字段后面带上当前时间的时区

    • 时间 (Meeting Time): 	     2023-11-10 19:30 - 19:45 GMT+8 
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63

      ## 6. 代码实现

      - 序列化bean

      - 主要通过Date.class的序列化和反序列化自定义解析类来实现
      - 这里不能在Jackson2ObjectMapperBuilderCustomizer的build里加时区自定义序列化类,因为在这类里改的是全局的,feign的调用也会使用这个序列化,就会导致时间会被序列化两次,feign返回一次,接口返回一次,导致时间不对
      - 这样写只会对Controller生效

      ```java
      package com.xxx.web.cloud.common.base.configuration;

      import com.fasterxml.jackson.databind.ObjectMapper;
      import com.fasterxml.jackson.databind.module.SimpleModule;
      import com.xxx.web.cloud.common.base.core.CustomDateDeserializer;
      import com.xxx.web.cloud.common.base.core.CustomDateSerializer;
      import org.springframework.context.annotation.Configuration;
      import org.springframework.http.converter.HttpMessageConverter;
      import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
      import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
      import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

      import javax.annotation.Resource;
      import java.util.Date;
      import java.util.List;

      /**
      * @author iseven.yang
      * @date 2023/11/30 19:33
      */
      @Configuration
      public class DateSerializerConfig implements WebMvcConfigurer {

      @Resource
      private Jackson2ObjectMapperBuilder jacksonObjectMapperBuilder;

      /**
      * 在 Spring Boot 配置中注册自定义消息转换器,但只将其应用于 Controller
      * 不重写的话,会导致把其他的序列化也修改了,比如feign调用就会多次执行时区的序列化
      * 需要替换原来MappingJackson2HttpMessageConverter,不能直接删了再加到最后,因为有其他类型json的序列化,顺序不对会报错
      * @param converters the list of configured converters to extend.
      */
      @Override
      public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
      int firstConverter = -1;
      for (int i = 0; i < converters.size(); i++) {
      if (converters.get(i) instanceof MappingJackson2HttpMessageConverter) {
      firstConverter = i;
      break;
      }
      }
      if (firstConverter > -1) {
      // 添加自定义的日期转换器
      MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(jacksonObjectMapperBuilder.build());
      ObjectMapper objectMapper = converter.getObjectMapper();
      objectMapper.registerModule(new SimpleModule()
      .addSerializer(Date.class, new CustomDateSerializer())
      .addDeserializer(Date.class, new CustomDateDeserializer()));
      converters.set(firstConverter, converter);
      }
      }
      }

  • DateSerializerUtil:时间序列化工具

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    package com.xxx.web.cloud.common.base.util;

    import cn.hutool.core.util.NumberUtil;
    import cn.hutool.core.util.StrUtil;
    import com.fasterxml.jackson.annotation.JsonFormat;
    import org.springframework.web.context.request.RequestContextHolder;
    import org.springframework.web.context.request.ServletRequestAttributes;

    import javax.servlet.http.HttpServletRequest;
    import java.time.Instant;
    import java.time.LocalDateTime;
    import java.time.ZoneId;
    import java.time.ZonedDateTime;
    import java.time.format.DateTimeFormatter;
    import java.util.Date;
    import java.util.HashMap;
    import java.util.Locale;
    import java.util.Map;

    /**
    * @author iseven.yang
    * @date 2023/11/6 22:23
    * @since 1.1.1-SNAPSHOT
    */
    public class DateSerializerUtil {

    private static final String DEFAULT_PATTERN = "yyyy-MM-dd HH:mm:ss";
    // 因为之前用SimpleDateFormat可以自定补充缺失的0,所以需要兼容这种格式
    private static final String DEFAULT_COMPATIBLE_PATTERN = "yyyy-M-d H:m:s";

    // 缓存一下DateTimeFormatter对象
    private static final Map<String, DateTimeFormatter> FORMATTER = new HashMap<>();

    /**
    * 根据pattern获取DateTimeFormatter对象
    */
    private static DateTimeFormatter getFormatter(String pattern) {
    DateTimeFormatter dateTimeFormatter = FORMATTER.get(pattern);
    if (dateTimeFormatter == null) {
    // 不能直接用DateTimeFormatter.ofPattern,需要设置默认值,不然LocalDateTime无法解析"yyyy-MM-dd"的格式
    dateTimeFormatter = new DateTimeFormatterBuilder()
    .appendPattern(pattern)
    .parseDefaulting(ChronoField.MONTH_OF_YEAR, 1)
    .parseDefaulting(ChronoField.DAY_OF_MONTH, 1)
    .parseDefaulting(ChronoField.HOUR_OF_DAY, 0)
    .parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0)
    .parseDefaulting(ChronoField.SECOND_OF_MINUTE, 0)
    .parseDefaulting(ChronoField.MILLI_OF_SECOND, 0)
    .toFormatter();
    FORMATTER.put(pattern, dateTimeFormatter);
    }
    return dateTimeFormatter;
    }

    /**
    * 将字符串转为时间(带时区)
    * @param dateStr
    * @return
    */
    public static Date parseDate(String dateStr) {
    return parseDate(dateStr, null, null);
    }

    public static Date parseDate(String dateStr, String pattern, String zoneId) {
    if (StrUtil.isBlank(dateStr)) {
    return null;
    }
    if (StrUtil.isBlank(pattern) && NumberUtil.isLong(dateStr)) {
    // 如果没有指定序列化格式,并且可以转为long类型,就当成时间戳处理
    // 时间戳没有时区的概念,可以直接返回
    try {
    return new Date(Long.parseLong(dateStr));
    } catch (Exception e) {
    throw new RuntimeException("时间字符串解析时间戳错误:" + dateStr);
    }
    } else {
    if (StrUtil.isBlank(pattern)) {
    if (dateStr.length() == DEFAULT_PATTERN.length()) {
    pattern = DEFAULT_PATTERN;
    } else {
    // 使用兼容的格式
    pattern = DEFAULT_COMPATIBLE_PATTERN;
    }
    }
    if (StrUtil.isBlank(zoneId) || StrUtil.equals(zoneId, JsonFormat.DEFAULT_TIMEZONE)) {
    zoneId = getTimeZone();
    }
    DateTimeFormatter dateTimeFormatter = getFormatter(pattern);
    try {
    LocalDateTime parse = LocalDateTime.parse(dateStr, dateTimeFormatter);
    ZonedDateTime zonedDateTime = parse.atZone(ZoneId.of(zoneId));
    return Date.from(zonedDateTime.toInstant());
    } catch (Exception e) {
    throw new RuntimeException("时间字符串解析时间错误:" + dateStr + ", pattern: " + pattern
    + ", zoneId: " + zoneId + ", message: " + e.getMessage());
    }
    }
    }

    /**
    * 按照时区将时间转为字符串
    * @param date
    * @return
    */
    public static String formatDate(Date date) {
    return formatDate(date, null, null);
    }

    public static String formatDate(Date date, String pattern, String zoneId) {
    if (date == null) {
    return null;
    }
    if (StrUtil.isBlank(pattern)) {
    pattern = DEFAULT_PATTERN;
    }
    if (StrUtil.isBlank(zoneId) || StrUtil.equals(zoneId, JsonFormat.DEFAULT_TIMEZONE)) {
    zoneId = getTimeZone();
    }
    try {
    // 这里不能直接用date.toInstant(),如果是java.sql.Date会报错UnsupportedOperationException
    ZonedDateTime zonedDateTime = Instant.ofEpochMilli(date.getTime())
    .atZone(ZoneId.of(zoneId));
    DateTimeFormatter formatter = getFormatter(pattern);
    return zonedDateTime.format(formatter);
    } catch (Exception e) {
    throw new RuntimeException("时间转字符串错误:" + date + ", pattern: " + pattern
    + ", zoneId: " + zoneId + ", message: " + e.getMessage());
    }
    }

    public static String getTimeZone() {
    String timeZone = null;
    try {
    ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    if (requestAttributes != null) {
    HttpServletRequest request = requestAttributes.getRequest();
    timeZone = request.getHeader("timeZone");
    }
    } catch (Exception e) {
    // do nothing
    }
    if (StrUtil.isBlank(timeZone)) {
    timeZone = ZoneId.systemDefault().getId();
    }
    return timeZone;
    }
    }

  • CustomDateSerializer:自定义时间序列化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    package com.xxx.web.cloud.common.base.core;

    import com.fasterxml.jackson.annotation.JsonFormat;
    import com.fasterxml.jackson.core.JsonGenerator;
    import com.fasterxml.jackson.databind.BeanProperty;
    import com.fasterxml.jackson.databind.JsonMappingException;
    import com.fasterxml.jackson.databind.JsonSerializer;
    import com.fasterxml.jackson.databind.SerializerProvider;
    import com.fasterxml.jackson.databind.ser.ContextualSerializer;
    import com.xxx.web.cloud.common.base.util.DateSerializerUtil;

    import java.io.IOException;
    import java.util.Date;

    /**
    * @author iseven.yang
    * @date 2023/11/6 21:10
    */
    public class CustomDateSerializer extends JsonSerializer<Date> implements ContextualSerializer {

    private JsonFormat jsonFormat;

    public JsonFormat getJsonFormat() {
    return jsonFormat;
    }

    public void setJsonFormat(JsonFormat jsonFormat) {
    this.jsonFormat = jsonFormat;
    }

    @Override
    public void serialize(Date value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
    String str;
    if (jsonFormat != null) {
    str = DateSerializerUtil.formatDate(value, jsonFormat.pattern(), jsonFormat.timezone());
    } else {
    str = DateSerializerUtil.formatDate(value);
    }
    gen.writeString(str);
    }

    @Override
    public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException {
    if (property != null) {
    JsonFormat annotation = property.getAnnotation(JsonFormat.class);
    if (annotation != null) {
    CustomDateSerializer customDateSerializer = new CustomDateSerializer();
    customDateSerializer.setJsonFormat(annotation);
    return customDateSerializer;
    }
    }
    return this;
    }
    }

  • CustomDateDeserializer:自定义事件反序列化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    package com.xxx.web.cloud.common.base.core;

    import com.fasterxml.jackson.annotation.JsonFormat;
    import com.fasterxml.jackson.core.JsonParser;
    import com.fasterxml.jackson.core.JsonProcessingException;
    import com.fasterxml.jackson.databind.BeanProperty;
    import com.fasterxml.jackson.databind.DeserializationContext;
    import com.fasterxml.jackson.databind.JsonDeserializer;
    import com.fasterxml.jackson.databind.JsonMappingException;
    import com.fasterxml.jackson.databind.deser.ContextualDeserializer;
    import com.xxx.web.cloud.common.base.util.DateSerializerUtil;

    import java.io.IOException;
    import java.util.Date;

    /**
    * @author iseven.yang
    * @date 2023/11/6 21:23
    */
    public class CustomDateDeserializer extends JsonDeserializer<Date> implements ContextualDeserializer {

    private JsonFormat jsonFormat;

    public JsonFormat getJsonFormat() {
    return jsonFormat;
    }

    public void setJsonFormat(JsonFormat jsonFormat) {
    this.jsonFormat = jsonFormat;
    }

    @Override
    public Date deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException {
    String text = p.getText();
    Date date;
    if (jsonFormat != null) {
    date = DateSerializerUtil.parseDate(text, jsonFormat.pattern(), jsonFormat.timezone());
    } else {
    date = DateSerializerUtil.parseDate(text);
    }
    return date;
    }

    @Override
    public JsonDeserializer<?> createContextual(DeserializationContext ctxt, BeanProperty property) throws JsonMappingException {
    if (property != null) {
    JsonFormat annotation = property.getAnnotation(JsonFormat.class);
    if (annotation != null) {
    CustomDateDeserializer customDateDeserializer = new CustomDateDeserializer();
    customDateDeserializer.setJsonFormat(annotation);
    return customDateDeserializer;
    }
    }
    return this;
    }
    }