相信有的小伙伴有这样的业务场景,服务端时区跟客户端(如浏览器)不一致,接口处理时需要转换掉。
场景一:服务端查询出来的时间是UTC时间,客户端在中国,需要展示为东八区时间;
场景二:客户端在中国,时间是东八区时间,服务端使用的UTC时间,存入时需要转换客户端时间为UTC时间;
场景三:客户端用户在UTC时区、东八区都有分布。
我们知道SpringMVC下,经常会配置HttpMessageConverter进行消息转换,如常用的ByteArrayHttpMessageConverter、StringHttpMessageConverter、MappingJackson2HttpMessageConverter、FastJsonHttpMessageConverter等。
以FastJsonHttpMessageConverter为例,发现fastjson序列化、反序列化时支持指定自定义序列化、反序列化类型,使用 @JSONField(serializeUsing = xxx.class)指定,那么我们尝试自定义序列化、反序列化类来解决时差问题。
首先,假设如果知道客户端跟服务端的时差,那么就可以解决java.util.Date格式化成String或将String解析成java.util.Date的问题
1 定义格式化函数
private static final String DEFAULT_FORMAT = "yyyy-MM-dd HH:mm:ss";
private static final TimeZone GMT = TimeZone.getTimeZone("GMT");
/**
* formatDate 格式化时间
*
* @param date 时间
* @param targetFormat 目标格式
* @param timezoneOffsetMillis 目标时区与标准时区的时间差(毫秒)
*
* @return java.lang.String
*/
private String formatDate(Date date, String targetFormat, Long timezoneOffsetMillis) {
String format = targetFormat == null ? DEFAULT_FORMAT : targetFormat;
if (timezoneOffsetMillis == null) {
return new SimpleDateFormat(format).format(date);
}
Calendar calendar = Calendar.getInstance();
calendar.setTimeInMillis(date.getTime() + timezoneOffsetMillis);
final SimpleDateFormat dateFormat = new SimpleDateFormat(format);
dateFormat.setTimeZone(GMT);
return dateFormat.format(calendar.getTime());
}
2 定义解析函数
private static final List<String> NUMERIC_AUTO_FORMATS = Arrays.asList("yyyyMMddHHmmss",
"yyyyMMddHH", "yyyyMMdd");
private static final List<String> STRING_AUTO_FORMATS = Arrays.asList("yyyy-MM-dd HH:mm:ss.SSS",
"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM-dd HH", "yyyy-MM-dd");
/**
* smartParse 智能解析(尝试按目标格式以及预定义格式解析)
*
* @param dateStr 时间字符串
* @param targetFormat 目标格式
* @param timezoneOffsetMillis 目标时区与标准时区的时间差(毫秒)
*
* @return java.util.Date
*/
private Date smartParse(String dateStr, String targetFormat, Long timezoneOffsetMillis) {
if (dateStr == null || dateStr.length() == 0) {
return null;
}
TimeZone timeZone = timezoneOffsetMillis == null ? null : GMT;
Date date = multiParse(dateStr, Arrays.asList(targetFormat), timeZone);
if (date == null) {
date = multiParse(dateStr, isNumeric(dateStr) ? NUMERIC_AUTO_FORMATS : STRING_AUTO_FORMATS, timeZone);
}
if (date == null) {
return null;
}
if (timezoneOffsetMillis == null) {
return date;
}
Calendar calendar = Calendar.getInstance();
calendar.setTimeInMillis(date.getTime() - timezoneOffsetMillis);
return calendar.getTime();
}
/**
* multiParse 多格式解析(尝试用多种格式解析字符串为时间)
*
* @param dateStr 时间字符串
* @param formats 格式
* @param timeZone 时区
*
* @return java.util.Date
*/
private Date multiParse(String dateStr, @NotNull Collection<String> formats, TimeZone timeZone) {
for (String format : formats) {
try {
final SimpleDateFormat dateFormat = new SimpleDateFormat(format);
if (timeZone != null) {
dateFormat.setTimeZone(timeZone);
}
Date date = dateFormat.parse(dateStr);
if (date != null) {
return date;
}
} catch (Exception e) {
}
}
return null;
}
/**
* isNumeric 判读一个字符串是否是数字
*
* @param str 输入字符串
*
* @return boolean
*/
private static boolean isNumeric(String str) {
if (str == null) {
return false;
}
int sz = str.length();
for (int i = 0; i < sz; i++) {
if (Character.isDigit(str.charAt(i)) == false) {
return false;
}
}
return true;
}
3 定义时差偏移获取描述
那么剩下的问题就是客户端与服务端交互时获取这个时差了。客户端与服务端通过接口交互时,可将时差放置在上下文中,如HTTP请求,可放在header中或其它传参位置。如前端可以通过js代码new Date().getTimezoneOffset()获取标准时区与当前时区相差的分钟数,稍加转化下即可得到与标准时区时差毫秒数,传递给服务端。
服务端收到时差后可以放在ThreadLocal中,这样执行json序列化与反序列化可以从ThreadLocal中取时差。为更具通用性,我们先定义一个注解
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER})
public @interface JsonFeature {
/**
* 时间格式
*/
String timeFormat() default "yyyy-MM-dd HH:mm:ss";
/**
* 时间偏移函数
*/
Class<? extends Supplier<Long>> timezoneOffsetSup() default NoneSupplier.class;
/**
* 无偏移函数
*/
final class NoneSupplier implements Supplier<Long> {
@Override
public Long get() {
return null;
}
}
}
4 实现序列化与反序列化
然后实现自定义序列化与反序列化的工具类
@Slf4j
public class OffsetTimeJsonCodec implements ObjectSerializer, ObjectDeserializer {
private static ConcurrentMap<Class<? extends Supplier<Long>>, Supplier<Long>> supplierCache =
new ConcurrentHashMap<>();
@Override
public <T> T deserialze(DefaultJSONParser parser, Type type, Object fieldName) {
final Object fieldValue = parser.parse(fieldName);
if (fieldValue == null) {
return null;
}
final String input = String.valueOf(fieldValue);
JsonFeature annotation = parseJsonFeature(parser.getContext().object, fieldName);
if (annotation == null) {
return (T) smartParse(input, DEFAULT_FORMAT, null);
} else {
final Class<? extends Supplier<Long>> clz = annotation.timezoneOffsetSup();
if (supplierCache.get(clz) == null) {
try {
supplierCache.put(clz, clz.newInstance());
} catch (Exception e) {
throw new RuntimeException(clz.getName() + "can not new instance");
}
}
String format = annotation.timeFormat() == null || annotation.timeFormat().length() == 0 ? DEFAULT_FORMAT :
annotation.timeFormat();
return (T) smartParse(input, format, supplierCache.get(clz).get()); // 使用注解提供的偏移量提供函数得到时差偏移量解析
}
}
@Override
public int getFastMatchToken() {
return 0;
}
@Override
public void write(JSONSerializer serializer, Object object, Object fieldName, Type fieldType, int features)
throws IOException {
if (object == null) {
serializer.write(null);
return;
}
JsonFeature annotation = parseJsonFeature(serializer.getContext().object, fieldName);
if (annotation == null) {
serializer.write(formatDate((Date) object, DEFAULT_FORMAT, null));
} else {
final Class<? extends Supplier<Long>> clz = annotation.timezoneOffsetSup();
if (supplierCache.get(clz) == null) {
try {
supplierCache.put(clz, clz.newInstance());
} catch (Exception e) {
throw new IOException(clz.getName() + "can not new instance");
}
}
String format = annotation.timeFormat() == null || annotation.timeFormat().length() == 0 ? DEFAULT_FORMAT :
annotation.timeFormat();
serializer.write(formatDate((Date) object, format, supplierCache.get(clz).get())); // 使用注解提供的偏移量提供函数得到时差偏移量格式化
}
}
/**
* parseJsonFeature 解析字段上的JsonFeature注解
*
* @param object 目标对象
* @param fieldName 字段名
*
* @return JsonFeature
*/
private JsonFeature parseJsonFeature(Object object, Object fieldName) {
try {
final Field field = object.getClass().getDeclaredField((String) fieldName);
if (field.isAnnotationPresent(JsonFeature.class)) {
return field.getAnnotation(JsonFeature.class);
}
} catch (Exception e) {
log.warn("parse JsonFeature fail, fieldName={}, msg={}", fieldName, e.getMessage());
}
return null;
}
}
5 举例:ThreadLocal偏移量
5.1 定义偏移量获取方式
定义获取时区偏移量的一种方式
public class ThreadLocalTimeOffsetSupplier implements Supplier<Long> {
private static final String TIMEZONE_OFFSET_MILLIS = "_OFFSET_MILLIS_";
public static void put(Long timezoneOffsetMillis) {
MapThreadLocalAdaptor.put(TIMEZONE_OFFSET_MILLIS, timezoneOffsetMillis);
}
public static void clear() {
MapThreadLocalAdaptor.remove(TIMEZONE_OFFSET_MILLIS);
}
@Override
public Long get() {
final Object value = MapThreadLocalAdaptor.get(TIMEZONE_OFFSET_MILLIS);
return value == null ? null : Long.valueOf(String.valueOf(value));
}
}
MapThreadLocalAdaptor是ThreadLocal的一个工具
5.2 接口拦截,设置时区偏移
如HTTP请求,自定义filter,获取传输时间偏移参数,写入到ThreadLocal中
6 VO字段上指定序列化类
服务端时间序列化成字符串到客户端展示
@Data
@Accessors(chain = true)
public class SysUserVo implements Serializable {
private String account;
private String email;
@JsonFeature(timezoneOffsetSup = ThreadLocalTimeOffsetSupplier.class)
@JSONField(serializeUsing = OffsetTimeJsonCodec.class) // 使用serializeUsing属性
private Date createTime;
@JsonFeature(timezoneOffsetSup = ThreadLocalTimeOffsetSupplier.class)
@JSONField(serializeUsing = OffsetTimeJsonCodec.class)
private Date updateTime;
}
接收客户端时间字符串转成服务端时间
@Data
@Accessors(chain = true)
public class UpdateUserReq implements Serializable {
private String account;
private String email;
@JsonFeature(timezoneOffsetSup = ThreadLocalTimeOffsetSupplier.class)
@JSONField(deserializeUsing = OffsetTimeJsonCodec.class) // 使用deserializeUsing属性
private Date updateTime;
}