Spring Boot 通過 ThreadLocal、HandlerInterceptor、RequestBodyAdvice 優雅解決項目公共參數問題

因爲公司是做境外第三方支付,所以涉及到國際化問題。頁面不變的值可以由前端進行處理,對於後端如果遇到異常信息需要產品經理提供對應的顯示信息。這個顯示信息可以根據不同的異常定義不同的異常碼,然異常信息顯示國際化信息保存到數據庫。根據用戶不同的國家請求動態的去數據庫獲取這個值。

今天在項目中遇到一個問題,項目提供的都是 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 進行接口調用:

在這裏插入圖片描述
控制檯打印:

在這裏插入圖片描述
我們只傳遞了調用接口版本號信息,然後打印出來了。對於國際化語言是使用的默認的英文。

參考文章:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章