因爲公司是做境外第三方支付,所以涉及到國際化問題。頁面不變的值可以由前端進行處理,對於後端如果遇到異常信息需要產品經理提供對應的顯示信息。這個顯示信息可以根據不同的異常定義不同的異常碼,然異常信息顯示國際化信息保存到數據庫。根據用戶不同的國家請求動態的去數據庫獲取這個值。
今天在項目中遇到一個問題,項目提供的都是 Restful 接口,請求參數都繼承了基類。裏面定義了商戶必須傳遞的通用信息。一般情況下可以從請求對象裏面獲取國家語言信息。但是項目中也有一些情況獲取不到國家的語言信息,比如:
- 調用鏈過深請求對象丟失
- 特殊業務不需要請求對象(分庫分表規則定義)
其實我今天遇到的問題是調用鏈過深, Request 對象裏面的國際化語言沒有傳遞過來。當然可以通過改變調用方式,把語言一直往後透傳。對於我這種崇尚優雅代碼的人,這種方式顯示不適合我。我更希望能夠找到一種優雅,通用的方式來解決這個問題。
如何解決這個問題呢?
我想大家是不是很快就想到線程本地變量,也就是 ThreadLocal。本文就不科普 ThreadLocal 了,如果不瞭解可以自行百毒。
這裏可以把國家語言和 API 版本等信息理解成這次調用的公共信息。
定義一個服務上下文對象:
@Data
public class ServiceContext {
private String version;
private String language;
}
然後再定義一個本地線程對象持有這個對象:
public class ServiceContextHolder {
private static Logger logger = LoggerFactory.getLogger(ServiceContextHolder.class);
private static ThreadLocal<ServiceContext> tl = new ThreadLocal<>();
public static ServiceContext get() {
if (tl.get() == null) {
logger.error("service context not exist");
throw new RuntimeException("pay receipt context is null");
}
return tl.get();
}
public static void set(ServiceContext sc) {
if (tl.get() != null) {
logger.error("pay receipt context not null");
tl.remove();
}
tl.set(sc);
}
public static void cleanUp() {
try{
if (tl.get() != null) {
tl.remove();
}
}catch(Exception e){
logger.error(e.getMessage(),e);
}
}
}
那麼添加這些公共信息到本地線程中呢?可以通過以下幾點來進行攔截:
- Filter
- HandlerInterceptor
- Spring MVC 參數轉換
1、Filter
在使用Servlet進行Web開發的時候,有時候爲了增加必要的業務處理而又不想修改現有的程序,往往採用Filter。這樣在各個Filter中可能都要讀取ServletInputStream流的內容,而ServletInputStream卻只能讀一次,這時候必須備份HttpServleRequest。
class BufferedServletInputStream extends ServletInputStream {
private ByteArrayInputStream inputStream;
public BufferedServletInputStream(byte[] buffer) {
this.inputStream = new ByteArrayInputStream( buffer );
}
@Override
public int available() throws IOException {
return inputStream.available();
}
@Override
public int read() throws IOException {
return inputStream.read();
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
return inputStream.read( b, off, len );
}
}
class BufferedServletRequestWrapper extends HttpServletRequestWrapper {
private byte[] buffer;
public BufferedServletRequestWrapper(HttpServletRequest request) throws IOException {
super( request );
InputStream is = request.getInputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte buff[] = new byte[ 1024 ];
int read;
while( ( read = is.read( buff ) ) > 0 ) {
baos.write( buff, 0, read );
}
this.buffer = baos.toByteArray();
}
@Override
public ServletInputStream getInputStream() throws IOException {
return new BufferedServletInputStream( this.buffer );
}
}
在Filter的 doFilter() 向後面傳遞:
//備份HttpServletRequest
HttpServletRequest httpRequest = (HttpServletRequest)request;
httpRequest = new BufferedServletRequestWrapper( httpRequest );
//使用流
InputStream is = request.getInputStream();
//其他業務邏輯
//將request 傳到下一個Filter
chain.doFilter(request, response);`
這種方式有以下幾個問題:
- 離事件發生源,也就是 Spring MVC 框架處理比較遠,到了 Servlet 容器裏面
- 代碼不夠優雅,這裏需要讀取到 InputStream 裏面的數據, Spring MVC 還需要再次讀取
2、HandlerInterceptor
接着是通過 Spring MVC 裏面提供的攔截器機制,也就是 HandlerInterceptor。因爲是通過 JSON 傳遞數據到後端,所以需要讀取 HttpServletRequest 裏面的 InputStream。但是這個流只能讀取一次,可以參考 Filter 的處理方式把 HttpServletRequest 包裝類把流進行再次包裝傳遞下去。
理想很豐滿,現實很骨感!!!
查看 Spring MVC 的攔截器調用機制:
HandlerExecutionChain#applyPreHandle
boolean applyPreHandle(HttpServletRequest request, HttpServletResponse response) throws Exception {
HandlerInterceptor[] interceptors = getInterceptors();
if (!ObjectUtils.isEmpty(interceptors)) {
for (int i = 0; i < interceptors.length; i++) {
HandlerInterceptor interceptor = interceptors[i];
if (!interceptor.preHandle(request, response, this.handler)) {
triggerAfterCompletion(request, response, null);
return false;
}
this.interceptorIndex = i;
}
}
return true;
}
往後面傳遞的還是原來的 HttpServletRequest 對象,並不是像我們想像的那樣,可以改變成我們定義的包裝類。現實給我們狠狠的一擊,不過這個方式就算實現了也會有下面的問題:
- 同樣的在項目中需要兩次讀取 InputStream,重複操作,不夠優雅
3、Spring MVC 參數轉換
下面就是 Spring MVC 的參數轉換規則,我最開始想到的是通過 Spring MVC 的參數轉換來做。 Spring MVC 對 Restful 定義的 @RequestBody 請求參數是通過 org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor
進行處理的。我的思路通過重寫這個類然後在通過自定義註解來增強這個類.RequestResponseBodyMethodProcessor#resolveArgument
通過重寫這個方法,當參數解析完成之後,把公共參數添加到本地線程當中。這種處理方式需要業務方把 @RequestBody 替換成我自定義的註解,對業務方侵入太強,不夠優雅。
那麼有沒有更好的辦法呢?
答案是有的,在 Spring MVC 4.2 以後,引入了兩個接口:
- RequestBodyAdvice : 這個接口就是針對 @RequestBody 進行功能增強的
- ResponseBodyAdvice:這個接口就是針對 @ResponseBody 進行功能增強的
在這裏我們就需要用到 RequestBodyAdvice 對標註了 @RequestBody 進行功能增強。
4、RequestBodyAdvice
4.1 基礎請求對象
這個裏面定義了 API 接口裏面的通用參數,這裏列舉了版本號和國家語言這兩個屬性。
@Data
public class BaseRequest {
/** 版本號 */
private String version;
/** 語言 */
private String language;
}
4.2 HandlerInterceptor
Spring MVC 的 HandlerInterceptor 這裏主要的功能就是利用 HandlerInterceptorr#preHandle
在調用 Controller 方法之前生成服務上下文,保存到本地線程當中。最後並且利用它的 HandlerInterceptorr#afterCompletion
方法把本地線程中的數據清空。
public class ServiceContextInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
ServiceContext context = new ServiceContext();
ServiceContextHolder.set(context);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
ServiceContextHolder.cleanUp();
}
}
4.3 @RequestBody 增強
通過讀取 Spring MVC 參數解析器之後的對象,把它轉換成 Map,讀取 Map 中的公共參數寫入到線程上下文當中。給後續的接口調用這些公共的參數。
@ControllerAdvice
public class CommonRequestBodyAdvice extends RequestBodyAdviceAdapter {
@Override
public boolean supports(MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
return parameter.hasParameterAnnotation(RequestBody.class);
}
@Override
public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
if(body == null) {
ServiceContextHolder.get().setLanguage("en");
return null;
}
String jsonString = JSON.toJSONString(body);
Map<String, Object> params = Json2MapUtil.jsonToMap(jsonString);
Object language = params.get("language");
if(language != null) {
ServiceContextHolder.get().setLanguage(language.toString());
} else {
ServiceContextHolder.get().setLanguage("en");
}
Object version = params.get("version");
if(version != null){
ServiceContextHolder.get().setVersion(version.toString());
} else {
ServiceContextHolder.get().setVersion("1.0.0");
}
return super.afterBodyRead(body, inputMessage, parameter, targetType, converterType);
}
}
5、測試
編寫一個 Controller 對代碼進行測試:
@RestController
public class TestController {
@RequestMapping("common/test")
public void test(@RequestBody BaseRequest request){
System.out.println("invoke api service request is : " + JSON.toJSONString(request));
ServiceContext serviceContext = ServiceContextHolder.get();
System.out.println("invoke api service language is : " + serviceContext.getLanguage());
System.out.println("invoke api service version is : " + serviceContext.getVersion());
}
}
然後使用 postman 進行接口調用:
控制檯打印:
我們只傳遞了調用接口版本號信息,然後打印出來了。對於國際化語言是使用的默認的英文。
參考文章: