全时区转换问题

说明:该方法为切面处理,不需要修改原有业务;不存在跨时区场景时不需要考虑,国内因使用时间为统一时区,所以也不会出现这种问题。

一. 情景:当客户端用户处在不同时区,公用同一个服务端,在存储数据时不对时间进行处理,一旦用户更换时区,就会出现与当前时区时间不符的问题。

  • 案例:小明在东2区2021-11-11 08:00:00创建了一笔订单,届时服务端并没有加入全时区转换工具,那在东2区看这个订单还是2021-11-11 08:00:00,这个时候没有问题,但是如果小明到了其他时区,比如零时区(UTC),那么东2区2021-11-11 08:00:00对应UTC时间应该是2021-11-11 06:00:00,但因为服务端存储数据时并没有对跨时区时间进行处理,所以小明看到订单时间还是原始的08:00:00就是错误的,所以要加入跨时区处理工具。这种问题同样适用于跨时区数据统计等场景问题。

二. 解决方案:

2.1 在客户端在请求数据时,(Header)携带用户所在时区及时间格式
2.2 在服务端接到客户端请求时,如果消息体中存在时间,则把时间转换为统一时区(建议零时区-UTC)进行存储
  • 客户端请求分为2种,Body传参(@RequestBody)、Url传参(@RequestParam,不加注解等)
2.3 返回给客户端数据时,根据客户端携带的时区和时间格式进行处理,转换时间后返回给客户端

三. 服务端(Java)处理方案:

3.1 Maven打包依赖
<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-jar-plugin</artifactId>
        <version>3.2.0</version>
        </plugin>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.8.1</version>
            <configuration>
                <source>1.8</source>
                <target>1.8</target>
            </configuration>
        </plugin>
    </plugins>
</build>
3.2 其他依赖
<!-- spring 依赖 -->
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-webmvc</artifactId>
</dependency>
3.3 处理Body传参(@RequestBody)方式,实际上就是在服务器接收到请求时,对Date类型的数据进行反序列化操作,并转换为存储时统一的时区及格式,在出服务时进行序列化操作
  • 序列化日期:Date -> String
  • 反序列化日期:String -> Date
3.3.1 新建“字符串日期格式解析成日期”工具类
package com.vevor.mall.common.timezone.converter;

import com.vevor.mall.common.timezone.exception.TimeZoneException;
import com.vevor.mall.common.timezone.exception.TimeZoneExceptionCode;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.TimeZone;
import static com.vevor.mall.common.core.constant.MallConstants.DATE_TIME_PATTERN;
import static com.vevor.mall.common.core.constant.MallConstants.TIME_ZONE_HEADER;

/**
 * @author :Murphy ZhangSun
 * @version :M1.1
 * @description :字符串日期格式解析成日期
 * @program :timezone
 * @date :Created in 2021/2/23 下午12:56
 * @since :M1.1
 */
class TimeZoneConverter {

    private TimeZoneConverter() {}

    /**
     * 根据请求参数对日期格式进行解析
     *
     * @param dateString 字符串日期,可以是任何格式的日期字符串,有可能是时间戳
     * @param reqTimeZone 时区
     * @param pattern 日期格式
     * @return 日期对象
     */
    static Date parser(String dateString, String reqTimeZone, String pattern) {
        try {
            // 1. 时间戳处理
            if (dateString.length() == 13) {
                Calendar calendar = Calendar.getInstance();
                reqTimeZone = (reqTimeZone == null) ? TimeZone.getDefault().getID() : reqTimeZone;
                calendar.setTimeZone(TimeZone.getTimeZone(reqTimeZone));
                long timeMillis = Long.parseLong(dateString);
                calendar.setTimeInMillis(timeMillis);
                return calendar.getTime();
            }

            // 3. 浏览器等客户端调用
            // 3.1. 未带请求头
            if (StringUtils.isAnyBlank(reqTimeZone, pattern)) {
                throw new TimeZoneException(TimeZoneExceptionCode.CLI_TIMEZONE_HEADER_EMPTY);
            }
            // 3.2. 创建客户端时区日期格式转换器
            DateFormat simpleDateFormat = new SimpleDateFormat(pattern);
            simpleDateFormat.setTimeZone(TimeZone.getTimeZone(reqTimeZone));

            // 3.3. 将客户端传过来的字符串时间转换成日期格式(自动将客户端传递的时间跟设定的时区关联起来,同时会转换成服务器所在的时区)
            return simpleDateFormat.parse(dateString);
        } catch (ParseException e) {
            throw new TimeZoneException(TimeZoneExceptionCode.CLI_TIME_DOES_NOT_MATCH_PATTERN.getCode(),
                    String.format(TimeZoneExceptionCode.CLI_TIME_DOES_NOT_MATCH_PATTERN.getEngMsg(), dateString, pattern), e);
        }
    }

    /**
     * 字符串转日期
     *
     * @param dateString 日期字符串
     * @return 日期对象
     */
    static Date dateParse(String dateString) {
        ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        assert requestAttributes != null;
        HttpServletRequest request = requestAttributes.getRequest();
        // 获取header日期格式
        String pattern = request.getHeader(DATE_TIME_PATTERN);
        // 获取header时区
        String reqTimeZone = request.getHeader(TIME_ZONE_HEADER);

        return TimeZoneConverter.parser(dateString, reqTimeZone, pattern);
    }
}
3.3.2 新建“序列化、反序列化”转换器
package com.vevor.mall.common.timezone.converter;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.vevor.mall.common.timezone.exception.TimeZoneException;
import com.vevor.mall.common.timezone.exception.TimeZoneExceptionCode;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.jackson.JsonComponent;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.TimeZone;

import static com.vevor.mall.common.core.constant.MallConstants.DATE_TIME_PATTERN;
import static com.vevor.mall.common.core.constant.MallConstants.TIME_ZONE_HEADER;
import static com.vevor.mall.common.timezone.converter.TimeZoneConverter.dateParse;

/**
 * @author :Murphy ZhangSun
 * @version :
 * @description :
 * @program :vevor-mall
 * @date :Created in 2021/3/1 下午5:34
 * @since :
 */
@JsonComponent
public class BodyDateConverter {

    private BodyDateConverter() {}

    /**
     * 自定义日期序列化对象
     */
    public static class Serializer extends JsonSerializer<Date> {
        @Override
        public void serialize(Date date, JsonGenerator jsonGenerator,
                                SerializerProvider serializerProvider) throws IOException {
            ServletRequestAttributes requestAttributes =
                        (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            assert requestAttributes != null;
            HttpServletRequest request = requestAttributes.getRequest();
            // 获取header日期格式
            String pattern = request.getHeader(DATE_TIME_PATTERN);
            // 获取header时区
            String reqTimeZone = request.getHeader(TIME_ZONE_HEADER);

            // 未带请求头
            if (StringUtils.isAnyBlank(reqTimeZone, pattern)) {
                // 该异常为自定义异常
                throw new TimeZoneException(TimeZoneExceptionCode.CLI_TIMEZONE_HEADER_EMPTY);
            }

            // 携带请求头
            SimpleDateFormat dateFormat = new SimpleDateFormat(pattern);
            dateFormat.setTimeZone(TimeZone.getTimeZone(reqTimeZone));

            String format = dateFormat.format(date);
            jsonGenerator.writeString(format);
        }
    }

    /**
     * 自定义日期反序列化对象
     */
    public static class Deserializer extends JsonDeserializer<Date> {
        @Override
        public Date deserialize(JsonParser jsonParser,
                        DeserializationContext deserializationContext) throws IOException {
            String dateString = jsonParser.getValueAsString();
            if (StringUtils.isBlank(dateString)) {
                throw new TimeZoneException(TimeZoneExceptionCode.CLI_EMPTY_DATE_STRING);
            }
            return dateParse(dateString);
        }
    }
}
3.3 创建字符串转换日期转换器,处理Url传参(@RequestParam,不加注解等)
package com.vevor.mall.common.timezone.converter;

import cn.hutool.core.date.DateUtil;
import com.vevor.mall.common.core.constant.MallConstants;
import com.vevor.mall.common.timezone.exception.TimeZoneException;
import com.vevor.mall.common.timezone.exception.TimeZoneExceptionCode;
import lombok.SneakyThrows;
import org.apache.commons.lang3.StringUtils;
import org.springframework.core.convert.converter.Converter;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.*;
import static com.vevor.mall.common.core.constant.MallConstants.DATE_TIME_PATTERN;
import static com.vevor.mall.common.core.constant.MallConstants.TIME_ZONE_HEADER;

/**
 * @author :Murphy ZhangSun
 * @version :
 * @description :
 * @program :vevor-mall
 * @date :Created in 2021/3/1 下午2:17
 * @since :
 */
@Component
public class String2DateConverter implements Converter<String, Date> {

    /**
     * 字符串 -> 日期类型转换
     *
     * @param dateString 日期字符串
     * @return 日期对象
     * @throws IllegalArgumentException 如果字符串日期格式存在问题,无法转换
     */
    @SneakyThrows
    @Override
    public Date convert(@NonNull String dateString) {
        // 1. 获取请求头信息
        ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        assert requestAttributes != null;
        HttpServletRequest request = requestAttributes.getRequest();
        String reqTimeZone = request.getHeader(TIME_ZONE_HEADER);
        String pattern = request.getHeader(DATE_TIME_PATTERN);
        String header = request.getHeader(MallConstants.FROM);

        // 2. 时区和格式不可为空
        if (StringUtils.isAnyBlank(reqTimeZone, pattern)) {
            throw new TimeZoneException(TimeZoneExceptionCode.CLI_TIMEZONE_HEADER_EMPTY);
        }

        // 3. 对日期字符串的格式进行校验,防止Date CST格式转换失败
        DateFormat simpleDateFormat = new SimpleDateFormat(pattern);
        try {
            // 3.1 尝试进行日期格式解析, 如果可以解析成功,则表示日期格式正确
            simpleDateFormat.parse(dateString);
        }catch (ParseException e){
            // 3.2 如果日期格式解析异常, 尝试使用工具进行格式转化,如果转换失败,表示传递的日期格式存在问题,会抛出解析异常
            Date parseDate = DateUtil.parse(dateString);
            dateString = simpleDateFormat.format(parseDate);
        }

        // 4. 是不是服务间调用, 如果是服务间调用,URL传参不需要对日期进行时区转换, 直接解析时间,返回
        if (StringUtils.isNotBlank(header)) {
            return simpleDateFormat.parse(dateString);
        }

        // 5. 如果不是服务间调用,进行时区转换
        simpleDateFormat.setTimeZone(TimeZone.getTimeZone(reqTimeZone));
        return simpleDateFormat.parse(dateString);
    }
}

四. 测试参数


提醒:不管是客户端与服务端交互,还是服务端与服务端交互,必须携带Header内的时区和日期格式,否则就会抛出异常,如果服务端与服务端交互时获取不到Header内的时区和日期格式,则需要设置拦截器,设置Header参数,案例如下:

package com.vevor.mall.common.core.component;

import com.vevor.mall.common.core.constant.MallConstants;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.util.Enumeration;

/**
 * @author :zhangzhixuan
 * @version :M1.0
 * @program :vevor-mall
 * @date :Created in 2020/11/24 16:05
 * @description : 服务间调用忽略鉴权 拦截器
 */
public class InnerFeignInterceptor implements RequestInterceptor {

    /**
     * 添加内部调用标识
     * <p>
     * 给RequestTemplate 统一加个header(from-inner)内部调用标识,具体用法异步
     * com.vevor.mall.auth.api.annotation.Inner
     * </p>
     *
     * @param requestTemplate 请求
     */
    @Override
    public void apply(RequestTemplate requestTemplate) {
        ServletRequestAttributes attributes 
                  = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        // 1. 添加内部服务标识请求头
        requestTemplate.header(MallConstants.FROM, MallConstants.INNER);

        // 2. 复制所有的请求头
        assert attributes != null;
        HttpServletRequest request = attributes.getRequest();
        Enumeration<String> headerNames = request.getHeaderNames();
        while (headerNames.hasMoreElements()) {
            String headerName = headerNames.nextElement();
            // 复制Header会导致实际content-length与复制后的不一致
            if ("content-length".equalsIgnoreCase(headerName)) {
                continue;
            }
            String header = request.getHeader(headerName);
            requestTemplate.header(headerName, header);
        }
    }
}