走一遍SCG常用内置功能,竟意外发现bug了

280 阅读10分钟

       走一遍SCG常用内置功能,竟意外发现bug了 前几篇我们就SCG构建基础、启动原理、路由规则以及如何接管webflux请求做了分析,本篇我们通过一个demo来实践一遍SCG常用的内置功能。demo体验过程并没有那么顺利,因为竟意外发现SCG源码的一个隐秘bug,它不仅使得启动慢,而且当配置某个predicate之后竟然调用会因超时而失败,神雕探寻了很久才定位到框架中那行“罪魁祸首”的代码,当然避开它的方案还是很简单的。 

 既然是demo,我们先展示下demo的工程结构 

 项目主体结构主要三部分: 

  • gateway-service: 当然是提供网关服务,所有功能配置都在application-full.yml里,另外Predicate包里两个类主要是来解决开篇说到的SCG的bug 
  • mockserver: 一个mock upstream的服务,在RouteTestController里mock我们需要的一些api 
  • mockclient: 主要是用来模拟发请求的,有些场景需要快速的发请求,这在后续限流相关的文章分享中还会有用。

另外,项目还依赖: 

  • redis: 我们demo中引入了基于redis的限流组件,可以本地起个容器 
  • postman: 主要发请求时方便修改header、param、host、method等等

代码地址:gitee.com/wlscode/scg…

ok,现在开始demo的第一部分,验证各类型的RoutePreidcate,组合成一个复杂的路由规则,当然这只是为了体验一遍这些规则的工作效果,在实际项目中不太可能有这么复杂的规则集中在一个路由配置上 :),再附一次SCG内置的Predicate图:

如上图,大概有9种类型的内置predicate,通过名字前缀(后缀都是PredicateFactory结果) 基本也能明白大概功能。根据SCG的约定,我们以静态配置的方式写在application-full.yml文件里,只需要配前缀即可生效 

 1)Datetime 类型 

        主要根据时间条件来匹配路由规则,比如618期间搞活动才有效的一个api,可以配置一个between,不在这个Between范围内的时间无法正常路由过去 - Between=2022-06-01T00:00:00.020+08:00[Asia/Shanghai],2022-06-18T23:59:59.020+08:00[Asia/Shanghai] PS: 这里的配置需要带上时区 

 2)Cookie 类型 

        根据配置的Cookie来路由(支持正则表达式),可以配置多个,比如 - Cookie=c,c_\d+,只有当请求Cookie中带有name为c值以”c_“为前缀带有若干数字才符合路由匹配。 

 3)Header 类型 

        根据配置的header来路由(支持正则表达式),可以配置多个,比如 - Header=h,\d+,只有当请求的Header中带有name为h 值为数字才符合路由匹配 

 4)Host 类型 

       根据配置的Host来路由,支持通配符,也支持配置多个值用逗号隔开。比如 - Host=testhost.com,只有当请求带有Host为testhost.com时候才符合路由匹配。PS: 这个case会涉及到跨域问题,可以通过SCG的跨域配置或者前置一个proxy(比如envoy)来解决。 

 5)Method 类型 

       根据配置的http方法来路由,可以配置多个值用逗号隔开。比如 - Method=GET,POST, 只有GET和POST方法请求才符合路由匹配。 

 6)Path 类型 

        根据配置的Path来路由,支持通配符(或者说支持PathMatcher规则,类似AntPathMatcher的功能,这个在”Spring Cloud GW路由规则“一文中有详细叙述),可以配置多个用逗号隔开即可。多说一句,这是使用最多的一个路由匹配规则,绝大部分场景下系统都以不同的Path来路由就够了,相反,很少有路由不需要配置Path规则的。比如:- Path=/p1/**, /p2/** 表示以/p1或者/p2为前缀的请求才符合路由匹配。 

 7)Query 类型 

       根据配置的Query参数匹配,支持正则表达式,也支持配置多个。比如 - Query=q,1,只有当请求参数中带有name为q值为1才符合路由匹配 

 8)RemoteAddr 

       根据请求的来源地址来判断是否满足路由规则,可配置多个ipv4或者ipv6,支持子网掩码,比如 - RemoteAddr=192.168.0.1/16(其中192.168.0.1是IP地址,16是子网掩码)。这里需要特别注意,很多时候,特别是当网关前面有proxy时(比如前置envoy或者nginx),网关拿到remoteAddr往往是proxy的地址,而不是真正请求来源地址,所以实际场景中我们会使用”x-forwarded-for“作为判断依据. 

 9)Weight 

       权重路由判断,两个路由配置同一个组分别设置不同的比值,那么会按比例进行路由,在某些场景下比较有用的,马上有demo演示。

首先针对前八个类型的Predicate,我们配置这样一个路由:

- id: p-route
  uri: http://localhost:8080
  predicates:
    - Path=/r1/**
    - Method=GET
    - Header=h,1
    - Cookie=c,c\d+
    - Query=q,1
    - Between=2022-05-01T11:08:08.020+08:00[Asia/Shanghai],2022-05-30T11:15:08.020+08:00[Asia/Shanghai]
    - XForwardedRemoteAddr=192.168.1.1/16
    - Host=testhost.com

有了前面的介绍,很好理解其含义,当一个请求满足以下条件时才匹配到这个路由规则:

  • path以 /p1 开头 
  • 方法为GET 
  • 带有名字为h值为1的Header 
  • 带有名字为c值为c_开头多个数字结尾的Cookie 
  • 带有名字为q值为1的参数 
  • 时间在2022-05-01T00:00:00.020+08:00[Asia/Shanghai],2022-05-30T23:59:59.020+08:00[Asia/Shanghai]之间 
  • x-forwarded-for获取到满足”192.168.1.1/16“的来源地址 
  • 请求中带有源Host为testhost.com

另外,因为有了Host条件,我们还需要加下跨域配置:

globalcors:
	cors-configurations:
	  '[/**]':
	    allowedOrigins: "*"
	    allowedMethods: "*"

看运行效果: 

  • 启动redis: docker run --name csg-demo-redis -p6379:6379 -d redis:latest redis-server 
  • 启动 MockserverApplication(端口 8080) 
  • 启动 GatewayServiceApplication(-Dspring.profiles.active=full,端口为10001) 
  • 启动postman,请求:

有了以上红框的匹配条件,以及隐含条件(今天是2022.05.29),请求是可以正确路由的,修改任何一个条件不满足规则的话 比如Header或者Cookie或者Method,请求就会失败。 正确路由之后,我们可以看到mock server打印的参数:

        这里有个小插曲,眼尖的您可能已经发现 XForwardedRemoteAddr 这个Predicate并不是SCG自带的。之前说过,我们经常会用x-forwarded-for代替remote addr的判断,所以神雕本来是想再SCG内置的 RemoteAddrRoutePredicateFactory 中使用框架自带的 XForwardedRemoteAddressResolver 来替换默认的RemoteAddressResolver。然而”卡壳“了好久,先是启动超慢,启动之后呢,请求超时失败,曲折的过程就不浪费您时间了,直接说原因和解决方案。 

        SCG 内置的 XForwardedRemoteAddressResolver 解析Address时,第一步提取x-forwarded-for的ip地址写死了用", "作为分割获取ip列表,很多时候一个请求经过多个server跳转(比如从外网到proxy再到网关),每一跳的ip地址之间直接逗号分割,并不会有空格,于是上面的extract就不正确。

         这个错误会导致后续获取Address超级慢,结果还不正确,有兴趣可自行debug研究下 XForwardedRemoteAddressResolver。 解决方案也是很简单的,基于内置的 RemoteAddrRoutePredicateFactory 扩展了一个XForwardedRemoteAddrRoutePredicateFactory,并且内部使用修正后的Resolver

https://gitee.com/wlscode/scg/blob/route_sample/gateway-service/src/main/java/ws/atc/gatewayservice/predicate/ScgXForwardedRemoteAddressResolver.java

@Bean("remoteAddressResolver")
public ScgXForwardedRemoteAddressResolver xForwardedRemoteAddressResolver(){
        return ScgXForwardedRemoteAddressResolver.trustAll();
    }

public class XForwardedRemoteAddrRoutePredicateFactory extends RemoteAddrRoutePredicateFactory {

    @Resource(name = "remoteAddressResolver")
    private RemoteAddressResolver xForwardedRemoteAddressResolver;

    @Override
    public Predicate<ServerWebExchange> apply(Config config) {
        config.setRemoteAddressResolver(xForwardedRemoteAddressResolver);
        return super.apply(config);
    }
}

接下来演示下 WeightRoutePredicateFactory ,直接上配置:

- id: weightRoute1
  uri: http://localhost:8080
  predicates:
    - Path=/w/**
    - Weight=group1,9
  filters:
    - RewritePath=/w/(?<remaining>.*), /w9/$\{remaining}
- id: weightRoute2
  uri: http://localhost:8080
  predicates:
    - Path=/w/**
    - Weight=group1,1
  filters:
    - RewritePath=/w/(?<remaining>.*), /w1/$\{remaining}

解释一下, weightRoute1 和 weightRoute2 都是对请求 /w 为前缀的 Path 进行路由,针对 group1 进行权重分配,其中 9/10 分配给 weightRoute1,1/10 分配给 weightRoute2。 另外,配合一个 RewritePath 的组件进行重写请求路径,weightRoute1 会把请求指向 upstream 的 /w9/**, weightRoute2 会把请求指向 upstream 的 /w1/**. 当然,upstream(即我们的mockserver)是有两个 api 的,可以看做是不同算法的验证,或者abtest

@RequestMapping("/w1/hello")
    public String w1(){
        return "/w1/hello";
    }

    @RequestMapping("/w9/hello")
    public String w9(){
        return "/w9/hello";
    }

运行效果: 我们运行mockclient,模拟发起100次请求 /w/hello, 按权重分配会有 90% 左右的请求打到mockeserver的 /w9/hello:

实际运行如上图,87打到/w9,13打到了/w1,也接近理论比例。

到此,常用的 SCG 内置 predicate 体验结束,相信您有比较直观的感受,在实际场景中能够选择使用。其中我们定制了 XForwardedRemoteAddr,看看代码,从中也能 get 到扩展 SCG 的便捷性。 

 最后,我们来体验 SCG 另一趴:Filter。从流程上来说,当 Predicate 匹配到了一个路由,接下来请求就会开始走到匹配路由的 Fitler 链,这才是 SCG 能力插拔的体现。Filter 分为 Global 和 非 Global两种,global 类型的filter无论什么请求都会经过,非 global 的fitler 配置才会起作用。本文我们主要针对非 global 的,内置的 Fitler 种类非常丰富: 

从上图 Fitler 的名字前缀可以直观的了解到,我们不仅可以从请求Path,请求头、请求参数、请求host、请求 body 来干预流程,也能从 响应头、响应参数、响应 body 、响应状态码等进行干预,甚至对请求做 熔断、限流等等干预。神雕选择从 Path,Request,Response,Rate Limit 四个方面来演示下内置 Fitler 的功能。 

 直接上配置:

- id: f-route
  uri: http://localhost:8080
  predicates:
    - Path=/other/**
  filters:
    - StripPrefix=1
    - PrefixPath=/prefix
    - RewritePath=/rr/(?<remaining>.*), /$\{remaining}

    - AddRequestHeader=x-req-id,abc
    - AddRequestParameter=req_of,p1
    - MapRequestHeader=x-id, uname
    - RemoveRequestHeader=rrHeader
    - PreserveHostHeader

    - AddResponseHeader=x-resp-id,123
    - RemoveResponseHeader=rrHeader
    - DedupeResponseHeader=drh,RETAIN_UNIQUE
    

    - name: RequestRateLimiter
      args:
        key-resolver: "#{@pathKeyResolver}"
        redis-rate-limiter.replenishRate: 10 #令牌每秒填充速度
        redis-rate-limiter.burstCapacity: 20 #桶大小
        redis-rate-limiter.requestedTokens: 1 #默认是1,每次请求消耗的令牌数

这是一个新的路由,处理 /other/** 匹配的请求,它有 12 个 Fitler

  1. Path 相关的 3 个: 
  •  StripPrefix: 值是一个数字,用来移除 Path 的前缀部分,比如/a/b/c,当值为1时或把/a移除,值为2时会把 /a/b移除 
  • PrefixPath: 值为前缀字符串,会加到请求 Path 的前缀里,比如配置了 /prefix1, 那么 /hello 请求会改写为 /prefix1/hello 
  • RewritePath: 直接重写 Path,可以把 Path A 重写为 PathB 另外,顺序很重要,因为 filter 链是依次执行,上个执行的输出会影响下一个执行的输入。

按照我们上面 demo 里的配置为例,假设请求 /other/rr/hello 进来,首先经过 StripPrefix(=1)会把/other删除,请求 Path 就变成了 /rr/hello, 然后在经过 PrefixPath(=prefix)之后 Path 就变成了 /prefix/rr/hello,最后经过 RewritePath 重写之后变成了 /prefix/hello 

 2) Request 相关的 5 个 

  • - AddRequestHeader 添加请求头 x-req-id=abc 
  • - AddRequestParameter 添加请求参数 req_of=p1 
  • - MapRequestHeader 把请求头 x-id 映射为 uname  
  • - RemoveRequestHeader 删除请求头 rrHeader (如果存在的话) 
  • - PreserveHostHeader 保留请求头 Host,不用配置默认是保留的 

 3)Response 相关的 3 个 

  • - AddResponseHeader 添加响应头 x-resp-id=123 
  • - RemoveResponseHeader 删除响应头 rrHeader(如果存在的话) 
  • - DedupeResponseHeader 保证响应头 drh 没有重复值 

 4)熔断限流相关 1 个 

  • 配置了一个基于 redis 的请求限流组件,基于 pathKeyResover 进行 10 QPS 限流,应对突发流量 20 QPS 

运行: postman 发出请求,带上测试 header

mockserver 收到请求 Path 已经被改为 /prefix/hello,并且带有 Fitler 加上的 Header 和 param

postman 收到的 Response Header