ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Cloud Gateway局部过滤器实战:用GatewayFilterFactory实现灵活权限校验

Spring Cloud Gateway局部过滤器实战:用GatewayFilterFactory实现灵活权限校验

做网关这块时间不短了,前面几篇把Gateway的基础路由、全局过滤器和注册中心接入都捋了一遍。这篇我们专门聊GatewayFilterFactory,也就是局部过滤器,并且拿权限校验这个最常见的业务场景开刀。不是泛泛讲API怎么用,而是从“为什么要有局部过滤器”到“怎么用它把登录校验做得又稳又灵活”,一遍过完。

如果你正在做微服务网关的权限控制,或者对Gateway的过滤器链模型一知半解,看完这篇应该能直接上手落地。内容里所有代码都是我在模拟项目X里经过压测和线上问题复盘后沉淀出来的,不是官方文档的翻译版,踩过的坑也都标注了。

1. 过滤器工厂体系拆解:为什么局部过滤器这么设计?

1.1 先搞懂“工厂”这两个字的含义

GatewayFilterFactory这个命名很容易劝退新手,但拆开看其实特别直白。它就是一个“生产过滤器实例的工厂”,Spring Cloud Gateway利用Spring Boot的自动配置机制,把每一个实现了GatewayFilterFactory接口的Bean都注册成一个“过滤器工厂”,而这个工厂的Bean名称去掉GatewayFilterFactory后缀之后,就是你在配置里写的那个过滤器名字。

举个例子,官方自带的AddRequestHeaderGatewayFilterFactory,在配置里写的就是AddRequestHeader。你写- AddRequestHeader=X-Request-Foo, bar,它就会创建一个带有指定参数的GatewayFilter实例。这种“配置驱动”的设计,让我在调整过滤规则时不用改代码重发版,改改配置就完事,这对运维来说太关键了。

搞清楚这一点,你才能理解为什么我们自定义局部过滤器时必须用@Component把工厂注册到Spring容器里。因为Gateway在启动阶段会扫描所有GatewayFilterFactory类型的Bean,建立“工厂名到工厂类”的映射表,如果Bean没注册,配置里写的名字就解析不出来,直接启动报错。

1.2 局部过滤器与全局过滤器、拦截器的三角关系

我们常说的“局部过滤器”,对应GatewayFilter接口,它绑定在某一条具体的路由上,通过RouteDefinition里的filters字段配置。而全局过滤器对应GlobalFilter接口,不绑定路由,自动对所有经过网关的请求生效,内部通过实现GatewayFilterChain来调用局部过滤器链。

实际开发中这两个经常被人混用。我见过有人把所有逻辑都塞进GlobalFilter,结果项目里一个全局过滤器上千行,改一个接口的权限就要小心翼翼怕影响别的服务。全局过滤器帮我们解决的是“横切关注点”,比如接口耗时统计、统一的请求日志。而局部过滤器帮我们解决的是“某条路由的个性化处理”,比如订单服务需要校验签名、商品服务只需要登录态,这种场景如果用全局过滤就不是“能不能做”,而是“每次发版都要祈祷别误伤别人”。

拿一棵树来类比:全局过滤器是树干,所有请求都必须从这儿走;局部过滤器是树枝,只有走到某个分叉才会触发。这个设计的一致性在于,两者最终都会被包装成GatewayFilter实例放进过滤器链里,执行模型完全统一。

2. 编写第一个局部过滤器工厂:5分钟跑通全流程

2.1 继承抽象类:需要实现哪些方法

Spring Cloud Gateway提供了AbstractGatewayFilterFactory<T>这个抽象模板类,我们自定义过滤器的时候,只需要做三件事:定义一个配置类、指定泛型类型、实现apply()方法。下面是我在项目X里用过的登录校验过滤器精简版,保留了核心链路:

@Component public class AuthGatewayFilterFactory extends AbstractGatewayFilterFactory<AuthGatewayFilterFactory.Config> { public AuthGatewayFilterFactory() { super(Config.class); } /** * 通过配置项指定需要跳过的URL白名单 */ @Override public List<String> shortcutFieldOrder() { return Collections.singletonList("whitelist"); } @Override public GatewayFilter apply(Config config) { return (exchange, chain) -> { String path = exchange.getRequest().getURI().getPath(); // 白名单直接放行 if (config.getWhitelist() != null) { String[] whitelist = config.getWhitelist().split(","); for (String prefix : whitelist) { if (path.startsWith(prefix.trim())) { return chain.filter(exchange); } } } // 1. 从请求头拿token List<String> tokens = exchange.getRequest().getHeaders().getOrDefault("Authorization", Collections.emptyList()); if (tokens.isEmpty()) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } // 2. 进入校验逻辑,通过则放行,不通过则返回401 if (!checkToken(tokens.get(0))) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } return chain.filter(exchange); }; } private boolean checkToken(String token) { // 这里按实际项目接入Redis、JWT或远程校验服务 return !"invalid-token".equalsIgnoreCase(token); } public static class Config { private String whitelist; public String getWhitelist() { return whitelist; } public void setWhitelist(String whitelist) { this.whitelist = whitelist; } } }

看到这里,你可能会问:为什么apply()方法返回的是GatewayFilter,而不是直接写过滤逻辑?

这恰恰是Gateway设计里非常值得一提的点。GatewayFilterFactory接口的实现类只负责把配置类转换成具体的过滤器实例,转换过程可以在请求到达之前完成,因此过滤器实例可以在启动阶段就预先创建好。请求真正进来时直接执行filter()方法,省掉了每次请求动态解析配置、反射生成实例的开销。这种“配置预解析、实例复用”的模式,在高并发下对性能非常友好。

2.2 把过滤器注册到路由上:两种配置方式

过滤器工厂写好之后,接下来就是把干活的过滤器挂到路由上。支持两种写法,一种是在application.yml里的spring.cloud.gateway.routes.filters段配置,一种是使用RouteLocatorBuilder的时候用代码指定。

YAML方式:

spring: cloud: gateway: routes: - id: order-service uri: lb://order-service predicates: - Path=/order/** filters: - Auth=whitelist=/order/list,/order/detail

代码方式:

@Bean public RouteLocator customRouteLocator(RouteLocatorBuilder builder) { return builder.routes() .route("order-service", r -> r.path("/order/**") .filters(f -> f.filter(new AuthGatewayFilterFactory().apply( new AuthGatewayFilterFactory.Config() {{ setWhitelist("/order/list,/order/detail"); }} ))) .uri("lb://order-service")) .build(); }

YAML方式明显更适合交给运维调整,代码方式因为可以做动态路由和更复杂的拼接逻辑,所以常用于测试或者路由规则需要程序化生成的场景。我个人在生产环境更倾向YAML,理由下面说。

提示:如果你配置了过滤器但是路由不生效,先确认过滤器工厂类名。AuthGatewayFilterFactory在配置里要写Auth,而不是AuthGatewayFilterFactory,也不要写auth,大小写必须严格匹配,前缀匹配不区分大小写,但后缀匹配严格区分。

3. 核心实现细节与原理解读:权限校验的完整链路

3.1 请求拦截的关键:“短路”机制如何运作

权限校验过滤器里,最核心的就是“放行”和“拦截”两个分支。放行就是调chain.filter(exchange)继续往后走,而拦截有两种常见的写法,我刚接触Gateway的时候经常弄混。

第一种是改状态码然后直接返回:

exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete();

第二种是直接抛异常:

throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unauthorized");

这两者的核心区别在于:setComplete()方法会直接告诉WebFlux当前请求已经处理完成,返回一个已完成的Mono<Void>,后续的过滤器链将不再执行。而throw抛异常还得靠全局异常处理器兜底,一旦你的全局异常处理器没接住,返回给客户端的就不是你预期的JSON,而是默认的HTML错误页。

我在一个项目里遇到过的情况是:网关返回的错误信息到了前端,再由前端弹出一条很粗糙的“服务异常”提示,排查到后面发现就是过滤器里抛了RuntimeException但没人处理。所以我现在校验失败统一走setComplete()+自定义响应体,既保证链路干净,又能精准控制错误返回的格式。

如果你想让返回体带上JSON提示,可以这么写:

return exchange.getResponse().writeWith(Mono.just(exchange.getResponse() .bufferFactory() .wrap("{\"code\":401,\"message\":\"未登录或登录已过期\"}".getBytes(StandardCharsets.UTF_8))));

但是别忘了设置响应头Content-Type为application/json;charset=UTF-8,否则前端拿到的还是会乱码。

3.2 放行后的身份传递:改造请求体往下游传

校验通过之后,网关还要干一件事:把解析出的用户身份信息传递给下游服务。因为下游服务不会再重新解析token(在大多数设计里这样做是为了避免重复解析的耗时),网关直接把身份塞进请求头,下游放心信任网关传过来的字段。

具体做法是修改ServerHttpRequest的header:

ServerHttpRequest mutatedRequest = exchange.getRequest().mutate() .header("X-User-Id", userId) .header("X-User-Role", role) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build());

这里的mutate()方法值得停下来多看两眼。它不是一个修改原对象的操作,而是基于原请求克隆出一个新请求对象,加上新的请求头之后再放回exchange里。这种不可变对象的修改方式,避免了并发场景下多个过滤器同时修改同一个请求实例导致的数据竞争。

需要注意的是:如果有多个过滤器都有修改请求头的需求,它们会按照配置顺序依次对exchange进行mutate,后面的过滤器能看到前面过滤器加的头。如果配置顺序不对,比如你在Auth过滤器后面又配了别的过滤器想读取X-User-Id读取不到,那就是顺序问题。

还有一点,很多同学在传递身份头时下意识把原始token也透传下去。我建议除非下游明确需要校验原始凭证,否则在网关就把Authorization头消费掉、换新的身份头往下游传。这样能减少token泄露面,也让下游更安全。

3.3 白名单设计:一组配置解决大部分场景

一个成熟系统的权限校验,一定有部分接口是匿名的,比如登录接口、验证码接口、回调接口。把这些URL做成白名单数组,是网关上最轻量的做法。

白名单的设计有几个层次。第一层是我上面示例里简单的前缀匹配,只要路径以某个前缀开头就放行,适合粗粒度控制。第二层是精确路径匹配,第三层是正则匹配。项目里如果对权限要求比较高,一般用AntPathMatcher之类的工具做模式匹配,支持/order/**这样的写法。

我常用的配置结构是这样的:

filters: - Auth=whitelist=/auth/login,/auth/captcha,/public/**,/callback/**

注意这里的逗号分隔在YAML里会被当成一个字符串,所以你在代码里要用split(",")拆开。如果你用了shortcutFieldOrder(),还要注意一个细节:Gateway的快捷配置写法是按字符串解析的,值里有逗号没问题,但如果某个白名单路径本身带逗号,那就有坑了。我在项目里就遇到过一次回调URL带参数被截断的情况,后来干脆改成完整配置方式:

filters: - name: Auth args: whitelist: /auth/login,/auth/captcha

完整配置方式容错率更高,推荐后面维护的人读代码也更直观。

4. 实战复盘:权限改造的真实案例

4.1 场景设定与方案选型

为了说得更具体,就拿模拟项目X来说。这是一个典型的微服务商城系统,主要服务有用户服务、商品服务、订单服务、支付回调服务。网关层之前做的是全局限流和日志,权限校验分散在各业务服务里自己写。

改造的诱因很直接:新来的同事在订单服务的过滤器里写漏了一个白名单,结果用户查订单列表的时候一直拿不到数据,排查了一个多小时才发现是校验服务里把查询接口也给拦了。当时项目组长拍板,把权限校验整体上移到网关,统一收口。

技术选型的时候,摆在我们面前的就是文章开头说的岔路口:是用全局过滤器还是局部过滤器?

全局过滤器方案:写一个GlobalFilter,拦截所有请求、做权限校验。优点是“一处配置,处处生效”,新接入的服务不需要关心校验逻辑。缺点也很明显:有些服务是给内部调用的,有些接口是开放的,全局展开后就得维护一张不断增长的排除列表,最后排除列表比业务代码还长。

局部过滤器方案:可以针对不同路径的路由挂上不同的过滤器实例,不同服务可以有不同的校验规则。比如订单服务校验用户身份和订单归属,支付服务多校验一个签名参数。缺点是接入新服务时得想着配过滤器,容易漏。

最后我们的结论是:全局过滤器只做“通用”的事情,比如记录日志、链路标识,而权限校验用局部过滤器。每个服务路由上明明白白写着Auth过滤器,一眼就知道这个服务有没有挂校验,出了问题也好排查。

4.2 完整配置与单元测试跑通

改造后的路由配置长这样:

spring: cloud: gateway: routes: - id: user-service uri: lb://user-service predicates: - Path=/user/** filters: - Auth=whitelist=/user/login,/user/register - id: order-service uri: lb://order-service predicates: - Path=/order/** filters: - Auth=whitelist=/order/list,/order/detail - id: pay-service uri: lb://pay-service predicates: - Path=/pay/** filters: - Auth=whitelist=/pay/notify

单元测试这块,用WebTestClient或者MockServerWebExchange都可以。如果你没用过MockServerWebExchange,我强烈推荐掌握,它能在不启动完整Gateway的情况下单独测试过滤器逻辑,测试速度非常快:

@Test void testAuthFilterBlockRequestWithoutToken() { AuthGatewayFilterFactory factory = new AuthGatewayFilterFactory(); AuthGatewayFilterFactory.Config config = new AuthGatewayFilterFactory.Config(); config.setWhitelist("/public/**"); GatewayFilter filter = factory.apply(config); MockServerWebExchange exchange = MockServerWebExchange.builder( MockServerHttpRequest.get("/order/create").build() ).build(); StepVerifier.create(filter.filter(exchange, (e) -> Mono.empty())) .verifyComplete(); assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); }

这里用StepVerifier是WebFlux测试的常规操作,因为我们处理的是Reactive流,不能像命令式代码那样直接断言后马上看结果,verifyComplete()会阻塞直到整个流执行完。

4.3 压测表现与性能心得

改造完成后跑了一轮压测,10个并发线程持续压5分钟,网关CPU占用和之前相比几乎没有变化。这是符合预期的,因为我之前担心的性能瓶颈不是过滤器本身,而是过滤器里有没有做同步阻塞操作。

我在代码里注释过checkToken方法“按实际项目接入Redis或JWT”,这里补充一个重要经验:如果在Reactive环境下你用了String token = redisTemplate.opsForValue().get(key),这会阻塞Event Loop线程,压测一上去CPU立刻飙到90%以上,而用了ReactiveStringRedisTemplate的非阻塞调用,CPU基本平稳。

不光是Redis,跟数据库打交道也一样。网关层做校验时尽量用异步客户端,实在没法异步的(比如调远程接口),可以扔到boundedElastic()线程池去执行,避免阻塞Netty的Event Loop线程:

return Mono.fromCallable(() -> checkTokenSync(token)) .subscribeOn(Schedulers.boundedElastic()) .flatMap(isValid -> { if (!isValid) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } return chain.filter(exchange); });

之前在测试环境没见过问题,是因为并发量不够。一旦线上流量涨起来了,同步阻塞调用带来的后果就是线程池耗尽、请求排队、接口超时雪崩。这块真的要在设计阶段就绷紧弦。

5. 常见问题与排查技巧实录

5.1 过滤器没生效

这个算是我被问得最多的问题。写了Auth过滤器,启动Gateway也不报错,但请求就是不走校验逻辑。

排查步骤一般是:先看路由ID是不是和请求路径对应上了。Gateway的路由匹配是predicates决定的,如果请求没匹配到这条路由,那过滤器链根本就不会触发,这种情况下你看不到任何日志。打开logging.level.org.springframework.cloud.gateway=DEBUG,看请求命中的是哪个RouteDefinition。

第二常见的场景是过滤器配在了一个永远不会执行到的分支上。比如你同时配了Path=/order/**和Path=/user/**两条路由,但请求路径是/order/1,你却在user-service的路由上挂了Auth,那自然不会执行。

第三,检查你是不是不小心把GatewayFilterFactory的Bean定义成了多个。如果一个工厂类被注册了两次,Gateway按名称查找时会抛出异常或者找到错误的实例。

5.2 配置不识别、类转换异常

如果启动时看到类似Unable to find GatewayFilterFactory with name Auth,八成是类名和配置名不匹配。记住规则:类名是AuthGatewayFilterFactory,配置名是Auth,两者要严格对应。还有一种情况是@Component注解没加,工厂没进容器。

另一种隐蔽问题:配置类的属性名和配置项对不上。Config类里必须有对应的setter,因为Gateway在做配置绑定时用的是JavaBean规范。如果你写的是private List<String> whitelist;,但配置里传的是逗号分隔的字符串,启动时就会因为类型转换失败而报错。所以我示例里的Config.whitelist用的是String,配合shortcutFieldOrder()的字符串切割方案。

5.3 让你少走弯路的排错小技巧

给过滤器加上阶段日志。在apply()里加一行log.info("Auth filter applied for route: {}", config),在filter()里加“进入校验、校验通过、校验失败”三个关键节点日志。权限类的故障定位,日志永远是最快的路径。

还有一个小技巧:给过滤器工厂定义一个order字段。GatewayFilter可以通过实现Ordered接口来指定执行优先级,如果一条路由上挂了多个过滤器,它们的执行顺序就由这个order决定。我一般把权限校验设为最高优先级,放在路由转发和限流之前,先拦住非法请求再做后续处理。

最后聊一个Optional的坑。exchange.getRequest().getHeaders().getOrDefault("Authorization", Collections.emptyList())里,Header的值是一个List<String>,你取的时候一定不能直接get(0),因为请求头可能没有或者有单值。很多初学者在token为空时会抛IndexOutOfBoundsException,过滤器直接500。养成先判空再取值的习惯,哪怕代码看起来啰嗦一点,安全系数高很多。

6. 一些踩坑后才能总结出的思路

回到开头那句“权限校验”,如果你准备在网关层落地,我强烈建议别一上来就照着网上的代码抄。先盘一下自己的服务结构,把接口分成三类:绝对公开、登录可访问、必须鉴权。再根据分类设计白名单、身份头透传规则和错误返回结构。这步想清楚了,写过滤器就是体力活。

过滤器这块,我踩过最大的坑是:局部过滤器和全局过滤器的边界没想清楚。一开始图省事全写全局过滤,后来维护成本飙升。后来统一规则:全局过滤器只做平台级横切逻辑,局部过滤器做业务级个性化逻辑,两者通过order协调,互不干扰。这次改造之后,新服务接入网关只需要在路由上加一行Auth配置,测试同学找我问“这个接口为什么被拦”的工单数量明显下降了。

最后再分享一个小技巧:写权限过滤器时,把“拿token解析用户信息”和“判断用户是否有权限访问”拆成两个独立的方法。前者可以复用,后者跟具体业务绑定。这样将来你要调整某个接口的访问策略,不需要动过滤器主体,只改权限判断逻辑就行。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进