为了进一步加强网络安全防范工作,近期对网关服务做了相关的安全升级,其中变化最大的一点是,网关不再提供 URI 模糊匹配的模式,形如 /api/v1/app/order/** 这样的配置已经不在支持,相信很多小伙伴已经感受到了日常开发上线的不便,但是需要理解的是,随着公司的体量的迅速发展,各方面越来越规范化,安全方面加强管控显然还是非常必要的。
首先看下得物流量传递的基本路径:
APP 网关流量路径:四层高防 --> 阿里云 SLB --> Gateway --> 业务服务/业务网关(提供协议转化 &接口聚合)
通常来说与业务方打交道的最多的是 gateway 服务,很多萌新可能不是很理解网关具体在干啥,这里做个简要说明,网关最大的作用是提供流量分发,同时具备流量管控,防爬,黑白名单,基本鉴权,接口超时,灰度等常见功能;小伙伴们日常开发中最长用到的就是流量转发,比如新起一个服务需要对外网暴露接口,此时就需要在网关的路由管理上进行配置。
所以 Spring gateway 的路由匹配就成了一个非常核心的关键功能,这里我们翻阅一下 Spring gateway 的源码。
由于 Spring gateway 使用 webflux 技术,整体的代码风格较为诡异。
这里简单介绍下 webflux 的基本概念:
flux 表示 1~N 数据元素
mono ,表示 0~1 个数据元素,
针对数据流的所有操作,在没有订阅之前都不会被触发,只有调用了 subscribe 方法后才会实际触发。
图一*
这里我们看下 DispatcherHandler 的 handle 方法,该方法会进行 webHandler 的适配,对于网关来说这里主要匹配的是 RoutePredicateHandlerMapping 这一对象,我们可以从 hadlerMappings 对象中看到:
RoutePredicateHandlerMapping 中的 webHandle 为 FilteringWebHandler 该 handle 中包含了 gateway 自带的以及网关自定义的共 28 个 GolobalFilter
讲到这里很多小伙伴可能会好奇,这个路由匹配到底是在哪儿做的呢,别急,我们慢慢开趴!
按照图一所示,选中 mapping 后会获取 Handler,而获取 handler 后优会调用 invokeHandler 方法,那么我么不妨先到 getHandler 方法中看看,点开 RoutePredicateHandlerMapping 源码,我们郁闷的发现并没有 getHandler 方法,而只有 getHandlerInternal 方法,仔细看下 RoutePredicateHandlerMapping 的继承关系发现该类继承了 AbstractHandlerMapping, 而 AbstractHandlerMapping 中 getHandler 方法早已存在,实现了 HandlerMapping 接口同时也做了部分实现 ;废话不多说,源码底下无内鬼!!
原来 getHandlerInternal 是在 getHandler 方法中被调用的。这就解释得通了,
仔细观察了 getHandler 中的逻辑,并没有路由匹配的逻辑,此时嫌疑最大的当属 RoutePredicateHandlerMapping 的 getHandlerInternal 了!
不出所料,lookupRoute 没跑了!!
lookupRoute 的代码很简单,核心逻辑为简单的匹配,同时添加错误处理,在匹配成功的情况下会把路由信息添加到 ServerWebExchange 中的 attributes 中,代码如下:
观察 filterWhen,我们会发现这是一个 for 循环匹配,也就是说,效率为 O n, 在路由信息比较多的情况下非常糟糕,当然这不能怪 Spring,毕竟 gateway 设计之初,是支持各种正则,模糊匹配的,这种要求下,做到 O 1 的效率并不现实 ,但是结合得物当前的使用场景,我们可以做进一步的优化:
由于新的路由添加为精确模式,也就是每个接口对应一个路由,这种前提下,我们很显然的想到了 HASH 算法,由于对于 pathVariable 模式的 path 也不再支持(小伙伴们可以思考下,这种接口有什么缺点) ,在收到请求的时候直接提取 path 部分,通过 hash 的方式获取到对应的路由信息,改造后的路由查找逻辑如下所示:
findRoute()方法中的逻辑非常简单的:
为了保证并发安全,这里的 pathRouteMap 为 ConcurrentHashMap ,其实修改为 HashMap 也是可以的,因为路由匹配时,对 map 是只读操作,更新时候是整体 map 引用替换:这里附上刷新路由缓存信息的代码
由于更新路由信息的操作属于高危且核心的操作,对于一个批次的更新最好能够原子性完成,这里我们引入了 Copy on write 的思路,修改的时候,先修改 bakMap ,等到 bakMap 中的全部路由信息更新完成后,我们将实际使用的 map 引用指向 bakMap, 同时将 bakMap 设置为空。此外更新路由的操作一般来说都是事件触发异步完成,因此对于性能要求并不高,这里加上锁进一步保证路由更新完整性,防止在多个线程调用时,map 与 bakMap 之间出现不匹配的情况!
需要指出的是 gateway 的路由查找逻辑依赖于 CachingRouteLocator, 该类监听路由更新事件,实际的路由刷新通过发布事件的方式完成。观察源码,我们发现处理路由刷新事件时调用了 fetch 方法;
同时在初始化阶段以及缓存命中失效阶段时也调用了 fetch 方法(这里缓存是 gateway 自带的缓存机制,而非我们添加的 Map 缓存)
因此我们可以在 fetch 方法中加入 refreshPathRouteMap() 方法;
在 lookupRoute 方法中的 this.routeLocator.getRoutes() 实际调用的是 CachingRouteLocator#getRoutes()方法。此方法直接返回被缓存的的信息,这里的缓存指的是 gateway 自带的
routes = CacheFlux.lookup(cache, CACHE_KEY, Route.class).onCacheMissResume(this::fetch);
逻辑简单翻译一下,就是如果缓存命中失败会调用 fetch 方法重新加载路由信息。
至此,路由匹配的逻辑大致分析完成!其实对于之前的 /api/v1/app/order/** 这种模式的路由也可以通过 hash 方式进行加速,只需要将 * * 去掉,作为 map 的 key,在处理请求的时候,尝试获取请求的前缀进行匹配即可!
最终我们实战观察一下改进的实际效果:
可以发现,实际的 CPU 占用从原来的平均 24% --> 12% ,比原先下降了一半左右!
文/簌语
关注得物技术,携手走向技术的云端