Loading

基于Spring Boot 构建一个无侵入的多时区 Web 应用

一、前言

在全球化的今天,我们的应用系统很可能服务于世界各地的用户。一个身在纽约的用户和一个身在上海的用户,当他们看到"2024-01-01 09:00:00"这个时间时,他们期望的是各自本地时间的上午9点。如果后端服务不做处理,直接返回数据库存储的时间,很可能给用户带来巨大的困扰。

传统的多时区处理方案往往充满挑战:

  • 业务代码侵入:时区转换逻辑散落在各个业务代码中,难以维护。
  • 手动转换繁琐:每个时间字段都需要手动处理,容易遗漏。
  • 前后端约定复杂:需要严格约定时间戳、UTC字符串等格式,增加了沟通成本。

本文将介绍一种基于 Spring Boot 和 Jackson 的优雅、无侵入、可配置的多时区解决方案。其核心思想是:后端统一时区,前端传递时区,通过AOP和Jackson在JSON序列化/反序列化层面自动完成时区转换,让业务代码完全不感知时区的存在。

二、核心设计思路

  1. 后端时区统一:设定服务器JVM的默认时区为一个固定时区(如GMT+8UTC),数据库也使用该时区存储时间,确保后端环境的一致性。
  2. 用户时区传递:前端(浏览器)通过HTTP请求的Cookie或Header将用户的本地时区ID(如America/New_York)传递给后端。
  3. 拦截器捕获时区:使用Spring MVC的拦截器(Interceptor)在请求处理前捕获用户时区,并将其设置到Spring的LocaleContextHolder中,使其在当前请求线程中全局可用。
  4. Jackson全局转换:通过自定义Jackson Module,在不修改任何DTO/VO对象的前提下,全局地为DateLocalDateTime等时间类型注册自定义的序列化器和反序列化器。
    • 序列化(后端 -> 前端):将从数据库取出的、基于服务器时区的时间,自动转换为用户时区的时间字符串。
    • 反序列化(前端 -> 后端):将前端传来的、基于用户时区的时间字符串,自动转换成服务器时区的时间对象,以便存储和处理。
  5. 配置化与可插拔:整个时区转换功能通过配置文件可开启或关闭,方便在不同环境中部署。

三、实现步骤

第1步:拦截器 - 捕获并设置用户时区

我们需要一个HTTP拦截器,它的首要任务是在每个请求到达Controller之前,从Cookie或Header中读取timezone参数,并将其设置到当前线程的上下文中。LocaleContextHolder是Spring提供的理想工具。

TimeZoneInterceptor.java

import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import cn.hutool.extra.servlet.ServletUtil; // 引入Hutool工具类,简化操作
import javax.annotation.Nonnull;
import javax.servlet.DispatcherType;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Optional;
import java.util.TimeZone;

@Component
public class TimeZoneInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(@Nonnull HttpServletRequest request,
                             @Nonnull HttpServletResponse response,
                             @Nonnull Object handler) {
        // 优先从Cookie获取,其次从Header获取
        String timezone = Optional.ofNullable(ServletUtil.getCookie(request, "timezone"))
                .map(Cookie::getValue)
                .orElse(ServletUtil.getHeader(request, "timezone", StandardCharsets.UTF_8));

        // 校验时区ID的有效性,无效则使用系统默认时区
        TimeZone userTimeZone = Arrays.asList(TimeZone.getAvailableIDs()).contains(timezone)
                ? TimeZone.getTimeZone(timezone) : TimeZone.getDefault();

        // 将时区信息存入LocaleContextHolder,使其在当前请求线程中可用
        // 第二个参数'true'表示子线程也可以继承该时区设置
        LocaleContextHolder.setTimeZone(userTimeZone, true);

        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
        LocaleContextHolder.resetLocaleContext();
    }
}

注册拦截器 WebMvcConfig.java

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Autowired
    private TimeZoneInterceptor timeZoneInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(timeZoneInterceptor).addPathPatterns("/**");
    }
}

第2步:工具类 - 时区转换的核心逻辑

创建一个工具类,封装系统时区与用户时区之间转换的核心算法。

TimeZoneUtil.java

import cn.hutool.core.date.DatePattern;
import java.text.SimpleDateFormat;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Calendar;
import java.util.Date;
import java.util.TimeZone;
import org.springframework.context.i18n.LocaleContextHolder;

public class TimeZoneUtil {
    /**
     * LocalDateTime  user-》系统时区
     *
     * @param value 时间
     * @return {@link LocalDateTime}
     */
    public static LocalDateTime convertUser2SystemTimeZone(LocalDateTime value) {
        if (null == value) {
            return null;
        }
        TimeZone timeZone = LocaleContextHolder.getTimeZone();
        if (TimeZone.getDefault().equals(timeZone)) {
            return value;
        }
        return convertTimeZone(value, timeZone, TimeZone.getDefault());
    }

    /**
     * LocalDateTime    系统-》user 时区
     *
     * @param value 时间
     * @return {@link LocalDateTime}
     */
    public static LocalDateTime convertSystem2UserTimeZone(LocalDateTime value) {
        if (null == value) {
            return null;
        }
        TimeZone timeZone = LocaleContextHolder.getTimeZone();
        if (TimeZone.getDefault().equals(timeZone)) {
            return value;
        }
        return convertTimeZone(value, TimeZone.getDefault(), timeZone);
    }

    /**
     * LocalDateTime  user-》系统时区
     *
     * @param value 时间
     * @return {@link Date}
     */
    public static Date convertUser2SystemTimeZone(Date value) {
        if (null == value) {
            return null;
        }
        TimeZone timeZone = LocaleContextHolder.getTimeZone();
        if (TimeZone.getDefault().equals(timeZone)) {
            return value;
        }
        return convertTimeZone(value, timeZone, TimeZone.getDefault());
    }

    /**
     * LocalDateTime    系统-》user 时区
     *
     * @param value 时间
     * @return {@link Date}
     */
    public static Date convertSystem2UserTimeZone(Date value) {
        if (null == value) {
            return null;
        }
        TimeZone timeZone = LocaleContextHolder.getTimeZone();
        if (TimeZone.getDefault().equals(timeZone)) {
            return value;
        }
        return convertTimeZone(value, TimeZone.getDefault(), timeZone);
    }

    /**
     * 从源时区转换为目标时区
     *
     * @param fromDate     来源时间
     * @param fromTimezone 来源时区
     * @param toTimezone   目标时区
     * @return 目标时区时间
     */
    public static LocalDateTime convertTimeZone(LocalDateTime fromDate, TimeZone fromTimezone, TimeZone toTimezone) {
        return fromDate.atZone(fromTimezone.toZoneId()).withZoneSameInstant(toTimezone.toZoneId()).toLocalDateTime();
    }


    /**
     * 从源时区转换为目标时区
     *
     * @param fromDate     来源时间
     * @param fromTimezone 来源时区
     * @param toTimezone   目标时区
     * @return 目标时区时间
     */
    public static Date convertTimeZone(Date fromDate, TimeZone fromTimezone, TimeZone toTimezone) {
        Calendar calendar = Calendar.getInstance();
        calendar.setTime(fromDate);
        calendar.setTimeZone(fromTimezone);
        // 获取原始时区总偏移量(基础偏移 + 夏令时偏移)
        int originalOffset = calendar.get(Calendar.ZONE_OFFSET) + calendar.get(Calendar.DST_OFFSET);
        calendar.setTimeZone(toTimezone);
        // 获取目标时区总偏移量(基础偏移 + 夏令时偏移)
        int targetOffset = calendar.get(Calendar.ZONE_OFFSET) + calendar.get(Calendar.DST_OFFSET);
        Date convertedDate = calendar.getTime();
        convertedDate.setTime(convertedDate.getTime() + (targetOffset - originalOffset));
        return convertedDate;
    }

    /**
     * 时间戳转LocalDateTime
     *
     * @param timestamp 时间戳
     * @return {@link LocalDateTime}
     */
    public static LocalDateTime timestampToDateTime(Long timestamp) {
        if (timestamp == null) {
            return null;
        }
        Instant instant = Instant.ofEpochMilli(timestamp);
        ZoneId zoneId = ZoneId.systemDefault();
        return instant.atZone(zoneId).toLocalDateTime();
    }

    /**
     * LocalDateTime转时间戳
     *
     * @param localDateTime 本地日期时间
     * @return {@link Long}
     */
    public static Long dateTimeToTimestamp(LocalDateTime localDateTime) {
        if (localDateTime == null) {
            return null;
        }
        ZoneId zoneId = ZoneId.systemDefault();
        return localDateTime.atZone(zoneId).toInstant().toEpochMilli();
    }

    /**
     * Date转时间戳
     *
     * @param date 日期
     * @return 时间戳
     */
    public static Long dateToTimestamp(Date date) {
        return date == null ? null : date.getTime();
    }

    /**
     * 时间戳转Date
     *
     * @param timestamp 时间戳
     * @return 日期
     */
    public static Date timestampToDate(Long timestamp) {
        return timestamp == null ? null : new Date(timestamp);
    }

    /**
     * 根据特定格式格式化日期(使用用户时区展示,基于LocaleContextHolder.getTimeZone()取出)
     *
     * @param date   被格式化的日期
     * @param format 日期格式,常用格式见: {@link DatePattern} {@link DatePattern#NORM_DATETIME_PATTERN}
     * @return 格式化后的字符串
     */
    public static String format(Date date, String format) {
        if (date == null) {
            return null;
        }

        // 创建日期格式化对象
        SimpleDateFormat sdf = new SimpleDateFormat(format);

        // 设置时区为当前用户的时区
        TimeZone timeZone = LocaleContextHolder.getTimeZone();
        sdf.setTimeZone(timeZone);

        return sdf.format(date);
    }
}

注意java.util.Date 的时区处理比较微妙。Date对象内部只存储了自epoch以来的毫秒数(一个UTC值)。上述对Date的转换,是通过调整这个毫秒数,使得当同一个SimpleDateFormat实例(它持有用户时区)格式化这个新Date时,能够输出正确的墙上时间。对于java.timeAPI(如LocalDateTime),处理则更加清晰和推荐。

第3步:自定义Jackson序列化与反序列化器

现在,我们为DateLocalDateTime创建转换器。

DateZoneSerializer.java (序列化: 后端 -> 前端)

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.jsontype.TypeSerializer;
import com.fasterxml.jackson.databind.ser.std.DateSerializer;
import java.io.IOException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;

public class DateZoneSerializer extends DateSerializer {

    public DateZoneSerializer(SimpleDateFormat dateFormat) {
        this(null, dateFormat);
    }

    public DateZoneSerializer(Boolean timestamp, DateFormat dateFormat) {
        super(timestamp, dateFormat);
    }

    @Override
    public DateSerializer withFormat(Boolean timestamp, DateFormat customFormat) {
        return new DateZoneSerializer(timestamp, new SimpleDateFormat(((SimpleDateFormat) customFormat).toPattern()));
    }

    @Override
    public void serialize(Date value, JsonGenerator g, SerializerProvider provider) throws IOException {
        super.serialize(TimeZoneUtil.convertSystem2UserTimeZone(value), g, provider);
    }

    @Override
    public void serializeWithType(Date value, JsonGenerator g, SerializerProvider provider, TypeSerializer typeSer) throws IOException {
        super.serializeWithType(TimeZoneUtil.convertSystem2UserTimeZone(value), g, provider, typeSer);
    }
}

DateZoneDeserializer.java (反序列化: 前端 -> 后端)

import cn.hutool.core.date.DateUtil;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.deser.std.DateDeserializers;
import com.fasterxml.jackson.databind.jsontype.TypeDeserializer;
import org.apache.commons.lang3.StringUtils;

import java.io.IOException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;

public class DateZoneDeserializer extends DateDeserializers.DateDeserializer {

    public DateZoneDeserializer(SimpleDateFormat dateFormat) {
        super(instance, dateFormat, dateFormat.toPattern());
    }

    @Override
    protected DateDeserializers.DateDeserializer withDateFormat(DateFormat df, String formatString) {
        return new DateZoneDeserializer(new SimpleDateFormat(formatString));
    }

    @Override
    public Date deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        Date date;
        try {
            date = super.deserialize(p, ctxt);
        } catch (Exception e) {
            String dateStr = p.getText();
            if (StringUtils.isBlank(dateStr)) {
                return null;
            }
            date = DateUtil.parseDate(dateStr.trim());
        }
        return TimeZoneUtil.convertUser2SystemTimeZone(date);
    }

    @Override
    public Object deserializeWithType(JsonParser p, DeserializationContext ctxt, TypeDeserializer typeDeserializer) throws IOException {
        Object obj = super.deserializeWithType(p, ctxt, typeDeserializer);
        if (obj instanceof Date) {
            return TimeZoneUtil.convertUser2SystemTimeZone((Date) obj);
        }
        return obj;
    }
}

LocalDateTime 的转换器同理,但使用java.timeAPI。

LocalDateTimeZoneSerializer.java

import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.jsontype.TypeSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class LocalDateTimeZoneSerializer extends LocalDateTimeSerializer {


    public LocalDateTimeZoneSerializer(DateTimeFormatter formatter) {
        super(formatter);
    }

    @Override
    protected LocalDateTimeZoneSerializer withFormat(Boolean useTimestamp, DateTimeFormatter f, JsonFormat.Shape shape) {
        return new LocalDateTimeZoneSerializer(f);
    }

    @Override
    public void serialize(LocalDateTime value, JsonGenerator g, SerializerProvider provider) throws IOException {
        super.serialize(TimeZoneUtil.convertSystem2UserTimeZone(value), g, provider);
    }

    @Override
    public void serializeWithType(LocalDateTime value, JsonGenerator g, SerializerProvider provider, TypeSerializer typeSer) throws IOException {
        super.serializeWithType(TimeZoneUtil.convertSystem2UserTimeZone(value), g, provider, typeSer);
    }
}

LocalDateTimeZoneDeserializer.java

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.jsontype.TypeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class LocalDateTimeZoneDeserializer extends LocalDateTimeDeserializer {

    public LocalDateTimeZoneDeserializer(DateTimeFormatter formatter) {
        super(formatter);
    }

    @Override
    protected LocalDateTimeZoneDeserializer withDateFormat(DateTimeFormatter formatter) {
        return new LocalDateTimeZoneDeserializer(formatter);
    }

    @Override
    public LocalDateTime deserialize(JsonParser parser, DeserializationContext context) throws IOException {
        LocalDateTime localDateTime = super.deserialize(parser, context);
        return TimeZoneUtil.convertUser2SystemTimeZone(localDateTime);
    }

    @Override
    public Object deserializeWithType(JsonParser parser, DeserializationContext context, TypeDeserializer typeDeserializer) throws IOException {
        Object obj =  super.deserializeWithType(parser, context, typeDeserializer);
        if (obj instanceof LocalDateTime) {
            return TimeZoneUtil.convertUser2SystemTimeZone((LocalDateTime) obj);
        }
        return obj;
    }
}

第4步:组装Jackson模块

创建一个自定义的Jackson Module,将我们上面写的所有序列化/反序列化器注册进去。

TimeZoneModule.java

import cn.hutool.core.date.DatePattern;
import cn.hutool.core.util.StrUtil;
import com.fasterxml.jackson.core.Version;
import com.fasterxml.jackson.databind.Module;
import com.fasterxml.jackson.databind.module.SimpleDeserializers;
import com.fasterxml.jackson.databind.module.SimpleSerializers;
import java.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;

public class TimeZoneModule extends Module {

    private final String dataPattern;

    public TimeZoneModule(String dataPattern) {
        this.dataPattern = dataPattern;
    }

    @Override
    public String getModuleName() {
        return "TimeZoneModule";
    }

    @Override
    public Version version() {
        return new Version(0, 1, 0, "", null, null);
    }

    @Override
    public void setupModule(SetupContext context) {
        SimpleSerializers serializers = new SimpleSerializers();
        SimpleDeserializers deserializers = new SimpleDeserializers();

        // 统一日期格式
        String pattern = StrUtil.emptyToDefault(dataPattern, DatePattern.NORM_DATETIME_PATTERN);

        // LocalDateTime 转换器
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern);
        serializers.addSerializer(LocalDateTime.class, new LocalDateTimeZoneSerializer(formatter));
        deserializers.addDeserializer(LocalDateTime.class, new LocalDateTimeZoneDeserializer(formatter));

        // Date 转换器
        SimpleDateFormat dateFormat = new SimpleDateFormat(pattern);
        serializers.addSerializer(Date.class, new DateZoneSerializer(false, dateFormat));
        deserializers.addDeserializer(Date.class, new DateZoneDeserializer(dateFormat));

        context.addSerializers(serializers);
        context.addDeserializers(deserializers);
    }
}

第5步:全局注册与配置

最后一步,通过Jackson2ObjectMapperBuilderCustomizer将我们的TimeZoneModule注册到Spring Boot的ObjectMapper中,并使其可配置。

JacksonConfiguration.java

import com.fasterxml.jackson.databind.Module;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.ArrayList;
import java.util.List;

@Configuration
public class JacksonConfiguration {

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer customJackson2ObjectMapperBuilderCustomizer(
            @Value("${spring.jackson.time-zone-trans:true}") boolean timezoneTrans,
            @Value("${spring.jackson.date-format:yyyy-MM-dd HH:mm:ss}") String datePattern) {
        
        return builder -> {
            // 首先,添加对Java 8时间API的基础支持
            builder.modules(new JavaTimeModule());
            
            // 如果开启了时区转换功能
            if (timezoneTrans) {
                // 添加我们自定义的时区转换模块
                builder.modules(new TimeZoneModule(datePattern));
            }
        };
    }
}

第6步:配置文件

application.propertiesapplication.yml中添加配置项。

# application.properties
# Jackson默认的日期格式
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
# 是否开启时区自动转换功能,默认为true
spring.jackson.time-zone-trans=true

四、总结

至此,我们已经完成了一个强大且优雅的多时区解决方案。回顾一下它的优点:

  • 无侵入性:业务层的代码(Controller, Service, Mapper)完全不需要关心时区问题,它们处理的永远是基于服务器默认时区的时间对象。
  • 集中处理:所有时区转换逻辑都集中在拦截器、工具类和Jackson模块中,职责单一,易于维护和调试。
  • 高度可配:可以通过配置文件一键开启或关闭整个功能,并能灵活定义全局的日期时间格式。
  • 自动化:无论是返回给前端的JSON,还是从前端接收的JSON,时间字段的转换都是自动完成的,开发人员无需手动干预。
posted @ 2024-04-08 10:46  飞鸿影  阅读(537)  评论(0)    收藏  举报