1 背景
目前我们在开发一个大数据线上查询系统服务,该服务下面会支持多种数据库引擎的查询,比如Impala、Kylin和Druid等,并根据查询请求进行自动路由,选择最优的数据库引擎。因此路由算法是决定整体查询性能好坏的关键。原本线上采用的是按照经验的数据库默认排序规则,现在我们开发了一套基于神经网络的预测模型,想对这两种方式的性能指标进行对比。
由于我们必须要保证线上服务的稳定性,不能将一部分流量切到测试环境,因此这是一个非典型的A/B Test的问题。
我们最终考虑的方案,是仍然将全量请求发送到线上环境,同时将一定比例的请求拷贝到测试环境,再将这部分数据进行One on one的性能对比,以判断新模型的性能优化效果。
由于公司部署环境的限制,是不能采用类似tcpcopy或者gor之类的工具进行流量拷贝的。在考虑自己实现时,因为我们数据查询系统是基于Spring Cloud+Spring Boot的微服务框架开发的, 那么如何才能快速、方便、并对现有代码减少耦合的方式来实现本功能,是本文要讨论的内容。
2 实现思路
该功能的实现思路并不复杂。在Controller接收到请求后,新创建一个线程,向指定的测试环境地址发送一个参数完全相同的请求即可。
2.1 简单实现
根据上述的描述,可以简单的实现以下代码,将20%的请求发送到url-test:
@RequestMapping(value = "/test/post", method = RequestMethod.POST)
public String helloTest(@RequestParam String value, @RequestBody PostBody postBody) {
if (new Random().nextFloat() < 0.2F) {
new Thread(() -> restTemplate.postForObject("http://url-test?value=" + value, postBody, String.class)).start();
}
return "hello test, " + postBody;
}
但是该代码的可复用性极差,不仅需要侵入@Controller每个方法的代码,并且无法控制该功能是否关闭,同时由于无法获取@RequestParam的参数名称,还需要以字符串形式写在url中,很难维护。
2.2 优化实现
由于这里要实现的功能还是比较明确的,所以我们考虑能不能将这部分代码抽离出来,然后以一种统一的方式进行配置,这里就很自然考虑到Spring的AOP原理,那么需要使用到自定义注解。
3 注解设计
采用统一的注解,标注在需要执行流量拷贝的方法上,是一种优雅的不侵入现有代码的处理方式。注解需要定义的变量包含以下两个:
- 流量拷贝后发送的目的地址URL
- 流量拷贝的比例,默认为10%
该注解定义的代码如下:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestCopy {
String url();
float ratio() default 0.1F;
}
添加该注解后的Controller方法如下所示:
@RequestMapping(value = "/post", method = RequestMethod.POST)
@RequestCopy(url = "http://localhost:8080/test", ratio = 1F)
public String hello(@RequestBody PostBody postBody) {
System.out.println("hello, " + postBody);
return "hello, " + postBody;
}
4 注解处理
在定义好注解之后,选择合适的时机获取被注解的方法,并处理流量拷贝逻辑就是下一个问题。本项目是基于Spring Boot开发的,那Spring MVC的拦截器(interceptor)就是一个不错的选择。
拦截器:Spring MVC中的拦截器(Interceptor)类似于Servlet中的过滤器(Filter),它主要用于拦截用户请求并作相应的处理。例如通过拦截器可以进行权限验证、记录请求信息的日志、判断用户是否登录等。
也就是说,在拦截到被@RequestCopy注解修饰的方法后,通过获取注解中的URL和ratio参数,将请求发送到该地址,同时继续将请求发送到@Controller的方法。
这里不得不说,拦截器提供的输入参数非常有用,分别进行一下说明:
- handler:可通过强转转为Method类,从而获取method上的所有注解,并判断是否包含@RequestCopy注解,仅对包含@RequestCopy注解的方法进行后续处理。
- request:可通过getQueryString()方法将URL中root地址后的所有参数都获取到,解决了参数名获取的问题。
在获取到之后,进行流量发送的代码就是这里的业务逻辑代码,这里我们的场景是支持Get请求和Post json请求,那也就在代码中针对这两种情况进行处理。
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
RequestCopy requestCopy = method.getAnnotation(RequestCopy.class);
if (requestCopy == null) {
return true;
}
String url = requestCopy.url() + request.getRequestURI();
float ratio = requestCopy.ratio();
if (new Random().nextFloat() <= ratio) {
switch (request.getMethod()) {
case "GET": {
new Thread(() -> {
String fullUrl = url + (request.getQueryString() == null ? "" : "?" + request.getQueryString());
String result = restTemplate.getForObject(fullUrl, String.class);
logger.info("Send copied GET request to url: {}, and receive response: {}", fullUrl, result);
}).start();
break;
}
case "POST": {
switch (request.getHeader("Content-Type")) {
case "application/json": {
RequestBodyWrapper requestWrapper = new RequestBodyWrapper(request);
JSONObject jsonObject = JSON.parseObject(requestWrapper.getBody());
if (jsonObject != null) {
new Thread(() -> {
String fullUrl = url + (request.getQueryString() == null ? "" : "?" + request.getQueryString());
String result = restTemplate.postForObject(fullUrl, jsonObject, String.class);
logger.info("Send copied POST request to url: {}, body: {}, and receive response: {}", fullUrl,
jsonObject, result);
}).start();
}
break;
}
}
break;
}
}
}
return true;
}
5 Post方法的特殊处理
上述代码中,对Post方法的请求进行了一次包装。这主要是因为,Post方法中的RequestBody是以数据流的形式传输的,在interceptor中获取到RequestBody并发送请求后,在真正进入@Controller的方法中时,就没有RequestBody的值了。
因此这里通过Servlet的Filter方法来解决此问题。在服务接收到请求后,先将RequestBody的内容保存在Wrapper类的变量中,并重写对应的getInputStream等方法,以实现RequestBody内容的重复读取。
Filter也称之为过滤器,它是Servlet技术中最实用的技术,WEB开发人员通过Filter技术,对web服务器管理的所有web资源:例如Jsp, Servlet, 静态图片文件或静态 html 文件等进行拦截,从而实现一些特殊的功能。例如实现URL级别的权限访问控制、过滤敏感词汇、压缩响应信息等一些高级功能。
Servlet的Filter方法的执行时机,是一定会在Spring MVC的interceptor之前的,因此也可以满足在接收到请求的第一时间对该请求进行包装的需求。
public class RequestBodyWrapper extends HttpServletRequestWrapper {
private final String body;
public RequestBodyWrapper(HttpServletRequest request) throws IOException {
super(request);
StringBuilder stringBuilder = new StringBuilder();
BufferedReader bufferedReader = null;
try {
InputStream inputStream = request.getInputStream();
if (inputStream != null) {
bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
char[] charBuffer = new char[128];
int bytesRead;
while ((bytesRead = bufferedReader.read(charBuffer)) > 0) {
stringBuilder.append(charBuffer, 0, bytesRead);
}
}
} finally {
if (bufferedReader != null) {
bufferedReader.close();
}
}
body = stringBuilder.toString();
}
@Override
public ServletInputStream getInputStream() {
final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body.getBytes());
return new ServletInputStream() {
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener readListener) {
}
public int read() {
return byteArrayInputStream.read();
}
};
}
@Override
public BufferedReader getReader() {
return new BufferedReader(new InputStreamReader(this.getInputStream()));
}
public String getBody() {
return this.body;
}
}
6 配置开关
上述的代码开发完成后,还需要将interceptor和filter进行注册,注册的方式就是进行相对配置接口的重写,具体可参考最后的代码示例。
这里通过使用@ConditionOnProperty注解,可以在Spring Boot的application.yml配置文件中,通过进行配置项值的修改,来决定是否开启该功能。
7 使用说明
在该项目完成后,我们将之打包并上传到Maven中央仓库中。在使用时,需要先加载两个重写的配置类,然后在@Controller的方法上增加@RequestCopy注解,最后在application.yml中增加request-copy: true打开该功能,即完成了对该方法的流量拷贝的功能。
8 总结
在实际工作中遇到可以抽象化的功能时,要尽量实现功能的模块化。这里采用了Java的自定义注解、Spring MVC的拦截器和Servlet的过滤器三个特性,在实现此功能的同时,也加深了我们会这三个功能的理解,希望和大家一起学习和交流。
9 项目地址
- Github: https://github.com/meazza/spring-boot-request-copy
- Maven: https://mvnrepository.com/artifact/com.github.meazza/spring-boot-request-copy