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 进行接口调用:

在这里插入图片描述
控制台打印:

在这里插入图片描述
我们只传递了调用接口版本号信息,然后打印出来了。对于国际化语言是使用的默认的英文。

参考文章:

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