SCG GlobalFilter实战--API协议

426 阅读8分钟

        上一篇我们详细的分享了 SCG Filter 的工作原理以及内置的十几个 GlobalFilter 的工作机制,接下来我们来实现自己的 GlobalFilter,一方面满足特定的功能需求,另一方面也作为我们对 GlobalFilter 理解之后的一个实战。本篇我们来实现私有API协议。神雕还是持一贯观点,要实现自己特定需求的 GlobalFilter, 不必着急“盲写”,先熟悉下SCG内置十几个的工作机制,这有助于我们确定正确的Order,再研究下是否有类似功能 GlobalFilter 可以借鉴,这有助于写好代码。我们先来回顾下内置GlobalFilter的工作机制:

        我们能够很清晰的定位到,实现私有的API协议,其实是来对标 NettyRoutingFilter(下文简写为NRF)的,先来研究一下他是怎么做的。NRF 的执行顺序非常靠后(Order值很大,大到Integer.MAX_VALUE),是在前面的Filter处理完 Request Body、Request Path、Request Url等工作之后才开始执行。换句话说,到 NRF 执行的时候,已经能够很容易拿到向 upstream 发请求的目标url 以及 请求体等信息了,NRF 要做的核心工作就是取出这些必要的信息向 upstream 发请求(调用对方接口),然后获得upstream返回的response,接着处理 upstream response,包括返回头和返回体。NRF 的代码可以解构为5部分:

1. 根据 gatewayAlreadyRouted 标识、以及是否是 http(s) 协议的请求,来判断是否需要我来执行。如果已经 gatewayAlreadyRouted ,或者不是 http(s) 协议类型的请求,则结束;否则,设置 gatewayAlreadyRouted,然后开始执行自己的逻辑

//之前filter已经处理并缓存在上下文属性里了
URI requestUrl = exchange.getRequiredAttribute(GATEWAY_REQUEST_URL_ATTR); 

String scheme = requestUrl.getScheme();
if (isAlreadyRouted(exchange) || (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme))) {
  return chain.filter(exchange);
}
setAlreadyRouted(exchange);

2. 从请求中获取url,method,根据route上配置的header相关的filters来预先得到headers,以供后续使用

final HttpMethod method = HttpMethod.valueOf(request.getMethodValue());
final String url = requestUrl.toASCIIString();

HttpHeaders filtered = filterRequest(getHeadersFilters(), exchange);

final DefaultHttpHeaders httpHeaders = new DefaultHttpHeaders();
filtered.forEach(httpHeaders::set);

boolean preserveHost = exchange.getAttributeOrDefault(PRESERVE_HOST_HEADER_ATTRIBUTE, false);
Route route = exchange.getAttribute(GATEWAY_ROUTE_ATTR);

3. 构建 Flux resposneFlux,这是最重要的一部分,分为5小步

    3.1 获取一个 Netty Http Client

    3.2 设置 client 的 headers。这里的 headers 包括从 downstream 过来请求头,以及 route      配置相关的 filter 处理的 header

    3.3 设置 client 的 url,method。这个 url 是之前 Fitler 处理过的,method 则是直接从 downstream 请求中获得

    3.4 调用 client 的 send 方法,该方法入参是一个BiFunction<req, nettyOutBound,Publisher>,具体执行(后续才触发)时会调用nettyOutBound的send方法把req的body发给upstream。最后得到一个ResponseReceiver

    3.5 最后,调用 responseConnection 方法得到一个 Flux,该方法体是缓存upstream返回的结果,处理返回头。 注意,这一阶段都只是在构建这个flux,send方法体以及send之后处理response的方法体并不会执行,需要在后续对这个flux处理时候才会真正执行。

Flux<HttpClientResponse> responseFlux = getHttpClient(route, exchange).headers(headers -> {
    //headers 方法会即刻触发这个consumer方法体,逻辑比较清晰
      headers.add(httpHeaders);
      headers.remove(HttpHeaders.HOST);
      if (preserveHost) {
        String host = request.getHeaders().getFirst(HttpHeaders.HOST);
        headers.add(HttpHeaders.HOST, host);
      }
    }).request(method).uri(url) //设置method和url也很简单明了
    //send方法是一个BiFunction入参,点进去查看代码的话其实只是把这个入参设置给内部某个变量,并不会即刻执行,而是后续flux触发执行的时候才会真正执行到。
    .send((req, nettyOutbound) -> {
      if (log.isTraceEnabled()) {
        nettyOutbound.withConnection(connection -> log.trace("outbound route: "
            + connection.channel().id().asShortText() + ", inbound: " + exchange.getLogPrefix()));
      }
      //当flux被触发执行的时候这个send方法才会执行调用upstream
      return nettyOutbound.send(request.getBody().map(this::getByteBuf));
    }).responseConnection((res, connection) -> {
      //这是flux执行完send之后,拿到upstream的res和connection
      //缓存res和connection,便于后续把结果写入downstream的response里
      exchange.getAttributes().put(CLIENT_RESPONSE_ATTR, res);
      exchange.getAttributes().put(CLIENT_RESPONSE_CONN_ATTR, connection);

      ServerHttpResponse response = exchange.getResponse();
      //处理并设置response header,以及response status
      HttpHeaders headers = new HttpHeaders();

      res.responseHeaders().forEach(entry -> headers.add(entry.getKey(), entry.getValue()));

      String contentTypeValue = headers.getFirst(HttpHeaders.CONTENT_TYPE);
      if (StringUtils.hasLength(contentTypeValue)) {
        exchange.getAttributes().put(ORIGINAL_RESPONSE_CONTENT_TYPE_ATTR, contentTypeValue);
      }
      setResponseStatus(res, response);

      HttpHeaders filteredResponseHeaders = HttpHeadersFilter.filter(getHeadersFilters(), headers, exchange,
          Type.RESPONSE);

      if (!filteredResponseHeaders.containsKey(HttpHeaders.TRANSFER_ENCODING)
          && filteredResponseHeaders.containsKey(HttpHeaders.CONTENT_LENGTH)) {
        // It is not valid to have both the transfer-encoding header and
        // the content-length header.
        // Remove the transfer-encoding header in the response if the
        // content-length header is present.
        response.getHeaders().remove(HttpHeaders.TRANSFER_ENCODING);
      }

      exchange.getAttributes().put(CLIENT_RESPONSE_HEADER_NAMES, filteredResponseHeaders.keySet());

      response.getHeaders().putAll(filteredResponseHeaders);

      return Mono.just(res);
    });

小结一下,NRF 向 upstream 发请求其实是三要素,

  • url(包括了参数,问号后面的各个参数键值对)

  • 请求体body(这里包括post参数)

  • header

4. 超时判断,如果有超时设置,再问上面 repsonseFlux 设置 timeOut 和 onErrorMap 方法,这两个方法体此刻也不会触发

Duration responseTimeout = getResponseTimeout(route);
if (responseTimeout != null) {
  responseFlux = responseFlux
      .timeout(responseTimeout,
          Mono.error(new TimeoutException("Response took longer than timeout: " + responseTimeout)))
      .onErrorMap(TimeoutException.class,
          th -> new ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT, th.getMessage(), th));
}

5. 最后,用 resposneFlux.then(chain.filter(exchange)) 来触发执行之前定义的flux对象的一系列操作,执行完毕之后,再继续filter链执行 chain.filter。这也是经典的前置filter的代码套路,前面一篇文章有过分析,而 NRF 并没有把结果读出来写到 downstream ,这个工作由 NettyWriteResponseFilter 完成。

分析下来,这个模式是这样的:

1)NettyWriteResponseFilter 先触发,直接filter链走起,chain.filter(exchange)

1.1)filter 链执行到 NRF, 执行前置逻辑,就是前文所述的构建responseFlux,执行获取返回结果,并把返回结果设置到上下文

1.2)继续filter 链,chain.filter(exchange)

  1. Filter链执行完,回到 NettyWriteResponseFilter 后置逻辑,获取NRF设置的response和connection,从connection读取返回数据并写给downstream

我们可以套用这种方式写一对Filter,也可以简化成一个Filter,既包括 NRF 的前置代码,也包含 NettyWriteResponseFilter 的后置代码:

//构建一个 upstreamResponse的Flux或者Mono
//然后执行套路代码:
upstreamResponse
.flatMap(resp -> {把resp设置上下文属性里})
.then( chain.filter(exchange) ) //继续filter链
.then( Mono.defer(() -> { 读取上下文里的 resp,解析并写入downstream }));

最后,神雕来说一个实际的需求的案例:

1)upstream 只有 RPC 接口的,网关需要通过内部的 lib来调用这些upstream api。

2)全公司配置了太多的route,我们需要实现二级路由表的功能。

ps:route 规则的膨胀会影响网关的性能,因为很多网关对请求的处理是按顺序遍历所有路由,进行一一匹配,直到找到第一个匹配的路由或者遍历完都没有找到结束。InfoQ上有人做过这方面的测试,SCG 的路由数量膨胀导致性能直线下降甚至不可用的地步:xie.infoq.cn/article/d39…

实现思路:

        对于第一点需求,上面仔细研究过 NRF 代码就变得很简单了,无非就是 把url 取出来、把header解析出来,甚至可以照搬,因为header的处理其实在scg里是通用的,每个route可能会配置增减header的配置,我们照搬就行。然后构建一个Flux或者Mono的upstreamResponse。这里要特别注意请求参数的处理,上文对 NRF 总结过参数是包括 url中的键值对参数以及body参数,但是 NRF 并没有解析 body,而是基于netty client 方式发送请求到 upstream 的,这是因为他已经支持了reactive的模式。而我们实际场景中,upstream 的 rpc 接口很多并不支持这种模式,往往需要我们对 post 请求解析 body 拿到参数,而这又要区分 content-type = application/x-www-form-urlencoded 的情况,处理方法如下:

  • get 请求:不需要处理body,直接拿url以及参数根据upstream具体的调用规范构建请求 然后发送调用即可。SCG 还专门提供了 getUriTemplateVariables 方法获取url 上的path variable,需要的时候调用即可。
  • post 请求 并且 content-type = application/x-www-form-urlencoded:解析请求体会得到 key1=val1&key2=val2 格式的键值对参数,然后再根据upstream具体的调用规范构建请求 然后发送调用即可
  • 其他 post 请求:直接把“读出”body 字节数组然后构建upstream的request进行发送调用

另外,body的数据类型是Flux ,如果稍不注意会写出flux的block操作,这是不太建议的方式,但很多网上很多示例代码都是这么做的。神雕认为最好的做法按照webFlux规范,一些会block的操作还是在封装在flux体,等真正触发的时候再执行。具体可以再去读一下 NRF 的代码做法。github.com/spring-clou…

        对于第二点需求,应该属于整体系统设计范畴,不仅涉及到二级路由数据结构的设计,怎么和route映射起来,以及如何动态实施更新。简单而高效的做法把二级路由存放在route下meta里的一个hashmap,一级路由是一个{prefix}/** 配置,匹配到了之后 根据具体的url再查找二级路由hashmap里的 api信息,拿到这个api之后再来构建对upstream的request进行调用,整体的时间复杂度是O(1)。这部分和今天内容比较紧密挂钩的是取二级路由的方法,实际上还是通过 requestUrl 进行hash查找。例如假设我们为设计一个通用的规则 /xxx/{uniq_code}/**,通过 path variables拿到这个 uniq_code 的值,并以此为key从二级路由hashmap里获取对应api。

示例代码:

gitee.com/wlscode/scg…