基于Spring的流量拷贝框架实现

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

你可能感兴趣的:(基于Spring的流量拷贝框架实现)