问题描述
当系统的面向用户为国外用户时,不同国家和地区的用户可能存在时区不同的问题。针对不同的时区,如何正确对用户展示当地时间,以及如何对时间进行统一有效存储,是本次学习解决的问题。
常用方案
首先是询问GPT得到的一些常用解决方案。
1. 统一时间标准
- 使用 UTC 时间:
- 在系统内部存储和传递时间时统一使用 UTC(Coordinated Universal Time)。
- 在显示或输出给用户时,根据用户所在的时区将 UTC 转换为本地时间。
2. 用户时区管理
- 用户设置时区:
- 允许用户在个人设置中指定其所在的时区。
- 后端根据用户时区动态转换时间,确保显示正确。
- 自动时区检测:
- 使用前端脚本(如 JavaScript 的
Intl.DateTimeFormat().resolvedOptions().timeZone
)自动检测用户时区。 - 后端根据检测到的时区调整时间。
- 使用前端脚本(如 JavaScript 的
动态时区调整方案
问题明确
首先,明确动态时区调整方案需要解决的问题。
1、前端操作的日期数据以String类型传递到后端时,由于用户来自多个国家日期所在的时区是多变的,后端需要一种手段来实现对日期的统一存储和根据时区动态展示。
2、SimpleDateFormat实例关于时间的序列化和反序列化方法parse与format是线程不安全的。
接下来让我们先解释下SimpleDateFormat的序列化方法:
parse():将字符串类型的时间,根据指定格式,解析转换成Date
format():将Date类型的时间,根据格式,转成对应格式的字符串
SimpleDateFormat序列化方法中的问题
线程安全问题
SimpleDateFormat类在其和parse
方法中都会使用共享的 字段,如Calendar
和TimeZone
,而这些字段在方法调用过程中会被修改。例如SimpleDateFormat
内部使用了Calendar
来解析和格式化日期。这个Calendar
对象并不是线程本地的,每个线程在调用parse
或format
时都会共享同一个Calendar
实例。
-
在
parse
方法中,SimpleDateFormat
通过Calendar
处理日期字符串,它会修改Calendar
实例的内部状态。 -
在
format
方法中,SimpleDateFormat
使用Calendar
来格式化日期,也会修改Calendar
实例的状态。
由于 Calendar
是共享的,如果多个线程同时调用 SimpleDateFormat
的 parse
或 format
方法,线程间的竞争可能会导致数据错误或不一致的结果。
时区问题
先上结论:SimpleDateFormat的
parse()
会自动地将时间转换为JVM默认时区的时间。
首先我们知道,Date类型的对象本身是无时区概念的,Date代表的就是一个时间戳的绝对值,这个变量的值为自1997-01-01 00:00:00(GMT)至Date对象记录时刻所经过的毫秒数。
但是,当Date涉及到一些格式化或者存储操作时,会被默认转化成JVM默认时区的日期,这个过程是有时区概念的。也就是说对于SimpleDateFormat的parse()和format()方法,内部默认的逻辑是:
根据SimpleDateFormat实例当中指定的时区,将字符串类型的时间解析为Date,或者将Date类型的时间转换成字符串类型。如果实例没有指定时区,默认为JVM所在时区。但是由于Date在格式化操作时会被JVM时区影响,所以此时如果打印或者存储Date,看到的时间都是基于JVM所在时区。
动态时区调整的实现
1、定义一个时区接口,用于获取当前请求中的时区。
规定日期格式、请求头当中时区对应的字段常量。定义一个Concurrenthashmap,用于缓存请求中用过的时区信息。提供一个获取请求当中时区的方法。
String DATE_FORMAT = "yyyy-MM-dd HH:mm:ss";
String TIME_ZONE_HEADER = "X-Req-Zone-Id";
/**
* TimeZone::getTimeZone有锁
* 缓存TimeZone
*/
ConcurrentHashMap<String, TimeZone> TIME_ZONE_CACHE = new ConcurrentHashMap<>(32);
default TimeZone currentTimeZone() {
TimeZone curZone = TimeZone.getDefault();
//从当前请求中获取TimeZone
RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
...
//使用Concurrenthashmap缓存
...
return curZone;
}
2、实现该接口,同时继承SimpleDateFormat类(假设为A)。
在实现类中,首先实现一个类的单例,用于提升频繁处理日期时的性能。
定义一个存放SimpleDateFormat实例的Threadlocal变量,保证每个请求线程拥有自己独立的SimpleDateFormat实例,解决了之前提到的线程安全问题。
public static final A INSTANCE = new RequestTimeZoneDateFormat();
static final ThreadLocal<SimpleDateFormat> CACHE = ThreadLocal.withInitial(() -> new SimpleDateFormat(xxx));
3、重写format和parse方法
获取请求中的时区后,赋值给SimpleDateFormat实例,再调用SimpleDateFormat的format和parse。
@Override
public StringBuffer format(Date date, StringBuffer toAppendTo, FieldPosition pos) {
SimpleDateFormat simpleDateFormat = currentSimpleDateFormat();
return simpleDateFormat.format(date, toAppendTo, pos);
}
@Override
public Date parse(String source) throws ParseException {
SimpleDateFormat simpleDateFormat = currentSimpleDateFormat();
return simpleDateFormat.parse(source);
}
private SimpleDateFormat currentSimpleDateFormat() {
SimpleDateFormat simpleDateFormat = CACHE.get();
TimeZone curZone = this.currentTimeZone();
simpleDateFormat.setTimeZone(curZone);
return simpleDateFormat;
}
总结:
-
每次请求来临时,通过
ThreadLocal
获取独立的SimpleDateFormat
实例。 -
调用
parse
或format
方法时,获取当前请求的时区。 -
根据当前请求的时区设置
SimpleDateFormat
实例的时区。 -
最后调用父类的
parse
或format
方法进行实际的日期解析或格式化操作。
因此,实现了根据接口请求中的时区,对日期进行动态处理的操作。