基于Spring Boot 构建一个无侵入的多时区 Web 应用
一、前言
在全球化的今天,我们的应用系统很可能服务于世界各地的用户。一个身在纽约的用户和一个身在上海的用户,当他们看到"2024-01-01 09:00:00"这个时间时,他们期望的是各自本地时间的上午9点。如果后端服务不做处理,直接返回数据库存储的时间,很可能给用户带来巨大的困扰。
传统的多时区处理方案往往充满挑战:
- 业务代码侵入:时区转换逻辑散落在各个业务代码中,难以维护。
- 手动转换繁琐:每个时间字段都需要手动处理,容易遗漏。
- 前后端约定复杂:需要严格约定时间戳、UTC字符串等格式,增加了沟通成本。
本文将介绍一种基于 Spring Boot 和 Jackson 的优雅、无侵入、可配置的多时区解决方案。其核心思想是:后端统一时区,前端传递时区,通过AOP和Jackson在JSON序列化/反序列化层面自动完成时区转换,让业务代码完全不感知时区的存在。
二、核心设计思路
- 后端时区统一:设定服务器JVM的默认时区为一个固定时区(如
GMT+8或UTC),数据库也使用该时区存储时间,确保后端环境的一致性。 - 用户时区传递:前端(浏览器)通过HTTP请求的Cookie或Header将用户的本地时区ID(如
America/New_York)传递给后端。 - 拦截器捕获时区:使用Spring MVC的拦截器(Interceptor)在请求处理前捕获用户时区,并将其设置到Spring的
LocaleContextHolder中,使其在当前请求线程中全局可用。 - Jackson全局转换:通过自定义Jackson Module,在不修改任何DTO/VO对象的前提下,全局地为
Date、LocalDateTime等时间类型注册自定义的序列化器和反序列化器。- 序列化(后端 -> 前端):将从数据库取出的、基于服务器时区的时间,自动转换为用户时区的时间字符串。
- 反序列化(前端 -> 后端):将前端传来的、基于用户时区的时间字符串,自动转换成服务器时区的时间对象,以便存储和处理。
- 配置化与可插拔:整个时区转换功能通过配置文件可开启或关闭,方便在不同环境中部署。
三、实现步骤
第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序列化与反序列化器
现在,我们为Date和LocalDateTime创建转换器。
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.properties或application.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,时间字段的转换都是自动完成的,开发人员无需手动干预。
欢迎关注公众号"飞鸿影记(fhyblog)",探寻物件背后的逻辑,记录生活真实的影子。

作者:飞鸿影
出处:http://52fhy.cnblogs.com/
版权申明:没有标明转载或特殊申明均为作者原创。本文采用以下协议进行授权,自由转载 - 非商用 - 非衍生 - 保持署名 | Creative Commons BY-NC-ND 3.0,转载请注明作者及出处。


浙公网安备 33010602011771号