1,概述#
1.1,需求场景#
海外部署服务,用户界面展示当地时间,后端及数据库采用北京时间GMT+8时区,前端采用浏览器定位当地时区,前后端交互时需对时间进行时区转换。
1.2,实现原理#
前端增加请求头 ISrmTimeZone,传入当前浏览器获取时区; 后端自定义消息转换器 HttpMessageConverter,修改 Jackson 序列化配置,根据请求头 ISrmTimeZone 设置时区。
2,代码实现#
2.1,自定义 HttpMessageConverter#
继承 MappingJackson2HttpMessageConverter,原逻辑基础上修改 objectMapper 部分,从请求头/响应头上获取 ISrmTimeZone 设置时区。
package com.midea.srm.core.timezone;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.lang.reflect.Type;
import java.text.SimpleDateFormat;
import java.util.List;
import java.util.Objects;
import java.util.TimeZone;
import com.fasterxml.jackson.core.JsonEncoding;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.util.DefaultIndenter;
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.fasterxml.jackson.databind.SerializationConfig;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.exc.InvalidDefinitionException;
import com.fasterxml.jackson.databind.ser.FilterProvider;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConversionException;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.http.converter.json.MappingJacksonInputMessage;
import org.springframework.http.converter.json.MappingJacksonValue;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.util.TypeUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* 国际时区消息转换器
* 请求头/响应头包含 ISrmTimeZone 时生效
* 根据 ISrmTimeZone 动态设置 objectMapper 时区
* {@link org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter}
*
* @author liwf106
* @since 2023/12/7
*/
@Slf4j
@SuppressWarnings("all")
public class TimeZoneMessageConverter extends MappingJackson2HttpMessageConverter {
public static final String TIME_ZONE_HEADER = "ISrmTimeZone";
public TimeZoneMessageConverter() {
super();
}
@NonNull
@Override
protected Object readInternal(@NonNull Class<?> clazz, HttpInputMessage inputMessage) throws IOException,
HttpMessageNotReadableException {
List<String> timeZone = inputMessage.getHeaders().get(TIME_ZONE_HEADER);
ObjectMapper objectMapper = getObjectMapper(timeZone);
JavaType javaType = getJavaType(clazz, null);
return readJavaType(objectMapper, javaType, inputMessage);
}
@NonNull
@Override
public Object read(@NonNull Type type, Class<?> contextClass, HttpInputMessage inputMessage)
throws IOException, HttpMessageNotReadableException {
List<String> timeZone = inputMessage.getHeaders().get(TIME_ZONE_HEADER);
ObjectMapper objectMapper = getObjectMapper(timeZone);
JavaType javaType = getJavaType(type, contextClass);
return readJavaType(objectMapper, javaType, inputMessage);
}
@Override
public boolean canRead(Class<?> clazz, MediaType mediaType) {
HttpServletResponse response =
((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();
String timezone = response.getHeader(TimeZoneMessageConverter.TIME_ZONE_HEADER);
return super.canRead(clazz, mediaType) && StringUtils.isNotBlank(timezone);
}
@Override
public boolean canWrite(Class<?> clazz, MediaType mediaType) {
HttpServletResponse response =
((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();
String timezone = response.getHeader(TimeZoneMessageConverter.TIME_ZONE_HEADER);
return super.canWrite(clazz, mediaType) && StringUtils.isNotBlank(timezone);
}
@Override
protected void writeInternal(@NonNull Object object, @Nullable Type type, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
List<String> timeZone = outputMessage.getHeaders().get(TIME_ZONE_HEADER);
ObjectMapper objectMapper = getObjectMapper(timeZone);
MediaType contentType = outputMessage.getHeaders().getContentType();
JsonEncoding encoding = getJsonEncoding(contentType);
JsonGenerator generator = objectMapper.getFactory().createGenerator(outputMessage.getBody(), encoding);
try {
Class<?> serializationView = null;
FilterProvider filters = null;
Object value = object;
JavaType javaType = null;
if (object instanceof MappingJacksonValue) {
MappingJacksonValue container = (MappingJacksonValue) object;
value = container.getValue();
serializationView = container.getSerializationView();
filters = container.getFilters();
}
if (type != null && TypeUtils.isAssignable(type, value.getClass())) {
javaType = getJavaType(type, null);
}
ObjectWriter objectWriter;
if (serializationView != null) {
objectWriter = objectMapper.writerWithView(serializationView);
} else if (filters != null) {
objectWriter = objectMapper.writer(filters);
} else {
objectWriter = objectMapper.writer();
}
if (javaType != null && javaType.isContainerType()) {
objectWriter = objectWriter.forType(javaType);
}
SerializationConfig config = objectWriter.getConfig();
if (contentType != null && contentType.isCompatibleWith(MediaType.TEXT_EVENT_STREAM) &&
config.isEnabled(SerializationFeature.INDENT_OUTPUT)) {
DefaultPrettyPrinter prettyPrinter = new DefaultPrettyPrinter();
prettyPrinter.indentObjectsWith(new DefaultIndenter(" ", "\ndata:"));
objectWriter = objectWriter.with(prettyPrinter);
}
objectWriter.writeValue(generator, value);
generator.flush();
} catch (InvalidDefinitionException ex) {
throw new HttpMessageConversionException("Type definition error: " + ex.getType(), ex);
} catch (JsonProcessingException ex) {
throw new HttpMessageNotWritableException("Could not write JSON: " + ex.getOriginalMessage(), ex);
}
}
// 修改 objectMapper 时区
private ObjectMapper getObjectMapper(List<String> timeZone) {
ObjectMapper timeZoneObjectMapper = objectMapper.copy();
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
timeZoneObjectMapper.setDateFormat(dateFormat);
if (Objects.nonNull(timeZone) && !timeZone.isEmpty()) {
timeZoneObjectMapper.setTimeZone(TimeZone.getTimeZone(timeZone.get(0)));
log.info("set srm timezone {}", TimeZone.getTimeZone(timeZone.get(0)));
} else {
timeZoneObjectMapper.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
}
return timeZoneObjectMapper;
}
private Object readJavaType(ObjectMapper objectMapper, JavaType javaType, HttpInputMessage inputMessage)
throws IOException {
try {
if (inputMessage instanceof MappingJacksonInputMessage) {
Class<?> deserializationView = ((MappingJacksonInputMessage) inputMessage).getDeserializationView();
if (deserializationView != null) {
return objectMapper.readerWithView(deserializationView).forType(javaType).
readValue(inputMessage.getBody());
}
}
return objectMapper.readValue(inputMessage.getBody(), javaType);
} catch (InvalidDefinitionException ex) {
throw new HttpMessageConversionException("Type definition error: " + ex.getType(), ex);
} catch (JsonProcessingException ex) {
throw new HttpMessageNotReadableException("JSON parse error: " + ex.getOriginalMessage(), ex);
}
}
}
2.2,自定义 ResponseBodyAdvice#
为兼容 mcomponent 的 JsonResponseBodyAdvice,仅修改 supports 方法以适配自定义 MessageConverter。
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package com.midea.srm.core.timezone;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import com.midea.mcomponent.core.config.Settings;
import com.midea.mcomponent.core.response.Response;
import com.midea.mcomponent.core.response.SuccessResponse;
import com.midea.mcomponent.core.response.SuccessResponseData;
import com.midea.mcomponent.core.util.IPUtils;
import com.midea.mcomponent.core.util.TraceContext;
import org.apache.tomcat.util.buf.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
/**
* {@link com.midea.mcomponent.web.common.advice.JsonResponseBodyAdvice}
*
* @author liwf106
* @since 2023/12/7
*/
@Order(-1)
@ControllerAdvice
@SuppressWarnings("all")
public class TJsonResponseBodyAdvice implements ResponseBodyAdvice<Object> {
protected final Logger logger = LoggerFactory.getLogger(TJsonResponseBodyAdvice.class);
private static final List<String> DEFAULT_UN_WARP_PATHS =
Arrays.asList("/**/configuration/ui", "/**/swagger-resources", "/**/v2/api-docs", "/**/configuration/security");
@Autowired
private Settings settings;
public TJsonResponseBodyAdvice() {
}
public Object beforeBodyWrite(Object object, MethodParameter methodParameter, MediaType mediaType, Class clazz,
ServerHttpRequest request, ServerHttpResponse response) {
((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse()
.setHeader("Cache-Control", "no-cache,no-store");
String uri = request.getURI().getPath();
boolean isRecord = new Boolean(TraceContext.getLocaleWeb());
if (this.isUnWarpPath(uri)) {
return object;
} else {
if (object instanceof SuccessResponseData) {
((SuccessResponseData) object).setProviderSpans(
isRecord ? StringUtils.join(TraceContext.getSpans()) : null);
((SuccessResponseData) object).setWebIpAddress(IPUtils.getLocalIpAddress());
((SuccessResponseData) object).setTraceId(TraceContext.getTraceId());
}
if (object instanceof Response) {
return object;
} else if (object == null) {
return SuccessResponse.newInstance();
} else {
SuccessResponseData data = SuccessResponseData.newInstance(object);
data.setProviderSpans(isRecord ? StringUtils.join(TraceContext.getSpans()) : null);
data.setWebIpAddress(IPUtils.getLocalIpAddress());
data.setTraceId(TraceContext.getTraceId());
return data;
}
}
}
private boolean isUnWarpPath(String path) {
AntPathMatcher matcher = new AntPathMatcher();
Iterator var4 = DEFAULT_UN_WARP_PATHS.iterator();
String pattern;
while (var4.hasNext()) {
pattern = (String) var4.next();
if (org.apache.commons.lang.StringUtils.isNotEmpty(pattern) && matcher.match(pattern, path)) {
return true;
}
}
if (this.settings.getUnwarpUrls() != null && this.settings.getUnwarpUrls().size() > 0) {
var4 = this.settings.getUnwarpUrls().iterator();
while (var4.hasNext()) {
pattern = (String) var4.next();
if (org.apache.commons.lang.StringUtils.isNotEmpty(pattern) && matcher.match(pattern, path)) {
return true;
}
}
}
return false;
}
public boolean supports(MethodParameter methodParameter, Class clazz) {
return clazz.equals(TimeZoneMessageConverter.class);
}
}
2.3,替换 MappingJackson2HttpMessageConverter#
使用自定义 MessageConverter 替换掉项目原来默认处理 json 类型报文的 MappingJackson2HttpMessageConverter。 若不确定项目默认消息转换器可在此方法下断点判断:
org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport#addDefaultHttpMessageConverters
package com.midea.srm.core.timezone;
import java.util.List;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 配置国际时区转换器
*
* @author liwf106
* @since 2023/12/7
*/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
int index = converters.size();
for (int i = 0; i < converters.size(); i++) {
if (converters.get(i) instanceof MappingJackson2HttpMessageConverter) {
index = i;
break;
}
}
converters.add(index, new TimeZoneMessageConverter());
}
}
2.4,响应头设置 ISrmTimeZone & 特殊传参处理#
使用 AOP 拦截需要进行时区转换的接口,将请求头 ISrmTimeZone 设置到响应头中以供自定义 messageConverter 获取时区。 修改 objectMapper 时区仅适用于 Java 对象传参,若使用形如 Map<String, String>的方式传参则无法获取到 Date 类型而无法转换时区,因此可在 AOP 内酌情判断并手动转换时区。
package com.midea.srm.core.timezone;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Map;
import java.util.TimeZone;
import cn.hutool.json.JSONUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* 国际时区转换处理
* 处理 Map 等特殊入参形式
*
* @author liwf106
* @since 2023/12/6
*/
@Slf4j
@Aspect
@Component
@SuppressWarnings("all")
public class TimeZoneConvertAspect {
// 国内时区
public static final SimpleDateFormat SH_SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
// 国内时区-日期
public static final SimpleDateFormat SH_SDF_DATE = new SimpleDateFormat("yyyy-MM-dd");
// 国际时区
public static final SimpleDateFormat GLOBAL_SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
// 国际时区-日期
public static final SimpleDateFormat GLOBAL_SDF_DATE = new SimpleDateFormat("yyyy-MM-dd");
@Autowired
private ObjectMapper objectMapper;
public TimeZoneConvertAspect(ObjectMapper objectMapper) {
SH_SDF.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
SH_SDF_DATE.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
this.objectMapper = objectMapper;
}
@Pointcut("@within(org.springframework.web.bind.annotation.RestController) && within(com.midea.srm..*)")
public void point() {
}
@Around("point()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
try {
HttpServletRequest request =
((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String timeZone = request.getHeader(TimeZoneMessageConverter.TIME_ZONE_HEADER);
if (StringUtils.isNotBlank(timeZone)) {
HttpServletResponse response =
((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();
response.addHeader(TimeZoneMessageConverter.TIME_ZONE_HEADER, timeZone);
// 根据请求头设置国际时区
GLOBAL_SDF.setTimeZone(TimeZone.getTimeZone(timeZone));
GLOBAL_SDF_DATE.setTimeZone(TimeZone.getTimeZone(timeZone));
// 入参
Object[] args = joinPoint.getArgs();
log.info("原始请求参数为{}", args);
for (Object arg : args) {
log.info("参数:{}", JSONUtil.toJsonStr(arg));
// 仅处理 Map 形式入参
if (arg instanceof Map) {
((Map) arg).forEach((k, v) -> {
if (v instanceof String && StringUtils.isNotBlank((String) v) && isDateFieldName((String) k)) {
try {
log.info("传参含date字段:[{}],[{}]", k, v);
Date date;
String format;
// 仅年月日
if ((((String) v)).length() == 10) {
date = GLOBAL_SDF_DATE.parse((String) v);
format = SH_SDF_DATE.format(date);
} else {
date = GLOBAL_SDF.parse((String) v);
format = SH_SDF.format(date);
}
((Map) arg).put(k, format);
log.info("传参含date字段转换结果:[{}],[{}]", k, format);
} catch (ParseException e) {
log.error("解析失败", e);
}
}
});
}
}
}
} catch (Exception e) {
log.error("时区转换失败", e);
}
return joinPoint.proceed();
}
/**
* 根据字段名猜测是否为日期
*
* @param fieldName 字段名
* @return isDateFieldName
*/
private boolean isDateFieldName(String fieldName) {
return fieldName.contains("date") || fieldName.contains("Date");
}
}
3,逻辑验证#
- 前端验证可修改浏览器位置信息模拟海外场景
- 后端验证手动添加请求头,若不生效可关注所有已注册的 HttpMessageConverter 响应的 contentType 和注册顺序
附:注意事项#
- 以上方案仅处理前后端交互报文,未处理 excel 导入导出场景
- 以上方案会替换处理 json 的默认 HttpMessageConverter,建议根据实际情况评估影响范围(例如本例中影响了 com.midea.mcomponent.web.common.advice.JsonResponseBodyAdvice)
- 2.4 中特殊传参处理仅考虑了传参为 Map 的场景