新同事接手网关,问我的第一个问题:Predicate 和 Filter 到底啥区别
上周把网关交给组里另一个同学维护。他看了半天配置问我:predicates 和 filters 都是配在 route 下面的,凭什么一个叫断言、一个叫过滤器,它们分别什么时候执行?
这个问题挺好,我当年也绕了一阵。这篇把 Spring Cloud Gateway 2.2.3 的用法从头理一遍,都是我们生产上在用的配置。
先说清楚两者的区别
一句话版本:Predicate 决定"这个请求走不走这条路由",Filter 决定"走了之后做什么处理"。
请求进来
↓
匹配所有路由的 Predicate(按 order 从小到大)
↓ 命中第一条匹配的路由
执行这条路由的 Filter 链(pre 逻辑)
↓
转发到 lb://xxx
↓
Filter 链的 post 逻辑(响应回来时)
↓
返回客户端
Predicate 的返回值是布尔,全部条件都满足才算匹配。Filter 是有副作用的,改请求头、改路径、限流、鉴权都在这里做。
最小配置
依赖,注意这里有个大坑:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
Gateway 不能引 spring-boot-starter-web。它基于 WebFlux,如果 classpath 里同时有 Servlet 容器和 WebFlux,启动会报:
org.springframework.context.ApplicationContextException: Unable to start ReactiveWebServerApplicationContext
due to missing ReactiveWebServerFactory bean
这个报错看起来像"缺依赖",其实是"依赖冲突"——Spring 检测到了 Servlet 相关的类,就以为你要跑 MVC,不给你创建响应式容器。我们的网关工程因为间接引了一个公共包(里面带 web 依赖)踩过一次,用 exclusions 排掉:
<dependency>
<groupId>com.xxx.shop</groupId>
<artifactId>shop-common-util</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</exclusion>
</exclusions>
</dependency>
路由配置:
spring:
cloud:
gateway:
discovery:
locator:
enabled: false # 不建议开,会把所有服务都暴露出去
routes:
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/order/**
filters:
- StripPrefix=1
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 200
redis-rate-limiter.burstCapacity: 400
key-resolver: "#{@ipKeyResolver}"
- id: user-service
uri: lb://user-service
predicates:
- Path=/api/user/**
- Method=GET,POST
filters:
- StripPrefix=1
lb:// 前缀表示走负载均衡从注册中心取实例,需要 Nacos 或者 Eureka 的客户端。如果写 http://10.0.0.5:8080 就是固定地址,生产上基本不会这么用。
常用的 Predicate
| Predicate | 作用 | 例子 |
|---|---|---|
| Path | 路径匹配,支持 Ant 语法 | Path=/api/order/** |
| Method | HTTP 方法 | Method=GET,POST |
| Header | 请求头存在且匹配正则 | Header=X-Request-Id, \d+ |
| Query | 查询参数 | Query=debug, true |
| Host | 按域名路由 | Host=**.example.com |
| RemoteAddr | 按来源 IP(CIDR) | RemoteAddr=192.168.1.0/24 |
| After / Before / Between | 时间窗口,用于定时上线 | After=2020-06-18T00:00:00+08:00 |
| Weight | 按权重分流,做灰度 | Weight=group1, 8 |
多个 Predicate 是与关系,全满足才匹配。Path 支持 {segment} 这种变量写法,可以在 Filter 里用。
Weight 那个我们做活动预热时用过:
- id: order-service-new
uri: lb://order-service-v2
predicates:
- Path=/api/order/**
- Weight=order-group, 10 # 10% 到新版本
- id: order-service-old
uri: lb://order-service
predicates:
- Path=/api/order/**
- Weight=order-group, 90 # 90% 到老版本
两个路由用同一个 Weight 的 group 名,Gateway 会按权重分配。注意权重值是整数,加起来不一定要等于 100,它是按比例算的。
两种 Filter:路由级和全局级
路由级 GatewayFilter
配在 route 下面,只作用于这一条路由。内置的常用的几个:
| Filter | 作用 |
|---|---|
| StripPrefix=n | 去掉前 n 级路径 |
| PrefixPath=/x | 加前缀 |
| RewritePath | 正则改写路径 |
| AddRequestHeader / AddResponseHeader | 加请求头 / 响应头 |
| RequestRateLimiter | 限流(基于 Redis + 令牌桶) |
| Retry | 失败重试 |
| CircuitBreaker | 熔断(Hoxton 里替换了 Hystrix) |
RewritePath 的写法比较绕,第一个参数是正则,第二个是替换($1 引用捕获组):
filters:
- RewritePath=/api/(?<segment>.*), /$\{segment}
yml 里 ${} 会被 Spring 当成占位符,所以要转义成 $\{}。这个坑很容易踩,报错是 Could not resolve placeholder 'segment'。
全局 GlobalFilter
写个 Java 类实现 GlobalFilter,对所有路由生效。我们的鉴权就是这么做的:
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {
private static final Set<String> WHITELIST = new HashSet<>(Arrays.asList(
"/api/user/login",
"/api/user/register",
"/actuator/health",
"/api/captcha/image"
));
@Autowired
private JwtUtil jwtUtil;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getPath().value();
if (WHITELIST.contains(path)) {
return chain.filter(exchange);
}
String token = exchange.getRequest().getHeaders().getFirst("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
return writeJson(exchange, 401, "{\"code\":401,\"msg\":\"未登录\"}");
}
Long userId;
try {
userId = jwtUtil.parse(token.substring(7));
} catch (Exception e) {
return writeJson(exchange, 401, "{\"code\":401,\"msg\":\"token 无效\"}");
}
// 把 userId 传给下游,注意 WebFlux 里不能用 ThreadLocal
ServerHttpRequest newReq = exchange.getRequest().mutate()
.header("X-User-Id", String.valueOf(userId))
.build();
return chain.filter(exchange.mutate().request(newReq).build());
}
private Mono<Void> writeJson(ServerWebExchange exchange, int status, String body) {
ServerHttpResponse resp = exchange.getResponse();
resp.setStatusCode(HttpStatus.valueOf(status));
resp.getHeaders().setContentType(MediaType.APPLICATION_JSON);
return resp.writeWith(Mono.just(
resp.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8))));
}
@Override
public int getOrder() {
return -100; // 越小越早执行
}
}
三个要点:
- 鉴权逻辑必须不阻塞。用 JWT 本地校验是最好的,如果要查数据库或者 Redis,一定要用异步客户端(Lettuce)。WebFlux 只有几个 event loop 线程,一个阻塞调用能让整个网关停摆。
- 请求对象是不可变的,要改请求头得用
mutate().build()生成新的,再通过exchange.mutate().request()塞回去。 getOrder()决定执行顺序,负数在前。全局 Filter 和路由 Filter 混在一个链里,按 order 排序。
跨域配置,以及一个必踩的坑
Gateway 层的 CORS 配置:
spring:
cloud:
gateway:
globalcors:
cors-configurations:
'[/**]':
allowedOrigins:
- "https://www.example.com"
- "https://m.example.com"
allowedMethods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
allowedHeaders: "*"
allowCredentials: true
maxAge: 3600
坑来了:如果下游服务自己也配了 CORS(比如用了 @CrossOrigin 或者自定义的 CORS 过滤器),响应头里的 Access-Control-Allow-Origin 会出现两次,浏览器直接报错:
Access to XMLHttpRequest at 'https://api.example.com/api/order/list' from origin
'https://www.example.com' has been blocked by CORS policy:
The 'Access-Control-Allow-Origin' header contains multiple values
'https://www.example.com, https://www.example.com', but only one is allowed.
这个错误我在测试环境排查过一次。原则:CORS 只在网关做,下游服务的跨域配置全部去掉。
还有一点,allowCredentials: true 时 allowedOrigins 不能写 *,必须列具体的域名。写了 * 浏览器同样会拒绝。
和 Zuul 的对比
| 维度 | Zuul 1.x | Spring Cloud Gateway |
|---|---|---|
| 底层模型 | Servlet 2.5,阻塞 IO | WebFlux + Netty,非阻塞 |
| 线程模型 | 每请求一线程 | event loop,默认核数 × 1 |
| 单机 QPS(我们实测) | 1820 | 5400 |
| P99 | 96 ms | 23 ms |
| 配置方式 | yml + 自定义 ZuulFilter | yml + Java DSL + 内置 Filter 丰富 |
| 长连接支持 | 差 | 好,支持 WebSocket |
| 限流 | 要自己写 | 内置 RequestRateLimiter(Redis 令牌桶) |
| 学习成本 | 低,跟 Servlet 一样 | 中,要理解响应式编程 |
Gateway 唯一的门槛是响应式编程。Mono 和 Flux 的组合子一开始写起来别扭,调试也麻烦——堆栈里全是 reactor.core.publisher 的调用帧。开 -Dreactor.tools.agent.ReactorDebugAgent.init 能让堆栈带出具体的组装位置,排查时很有用。
几个踩过的坑
路由顺序。Gateway 按 order 从小到大匹配,命中第一条就不再往下走。Path=/api/** 这种宽泛的规则要放在后面,否则会把具体路由全吃掉:
# 错误:/api/order/** 永远不会被匹配到
- id: catch-all
uri: lb://default-service
predicates:
- Path=/api/**
order: 0
# 正确
- id: order-service
predicates:
- Path=/api/order/**
order: 10
- id: catch-all
predicates:
- Path=/api/**
order: 100
超时配置。Gateway 底层的 HttpClient 默认不超时,下游卡死会把连接一直占着。必须配:
spring:
cloud:
gateway:
httpclient:
connect-timeout: 1000 # 毫秒
response-timeout: 3s # Duration 格式
pool:
max-idle-time: 30s
max-connections: 500
请求体只能读一次。要在 Filter 里读 body 做签名校验的话,需要把 body 缓存下来,用内置的 CacheRequestBodyFilter,读之前先缓存。
小结
- Predicate 决定路由是否命中(返回布尔,多个是"与"关系),Filter 决定命中后做什么处理。两者的执行顺序都靠
order控制。 - Gateway 工程里绝对不能引
spring-boot-starter-web,会和 WebFlux 冲突,报错信息还很具有误导性。 - 全局鉴权用
GlobalFilter,注意 WebFlux 下不能用 ThreadLocal,改请求头要用mutate().build(),鉴权逻辑不能阻塞。 - CORS 只在网关配,下游全去掉,否则响应头重复。
allowCredentials: true时 origins 不能写*。 - 必须配
httpclient的连接和响应超时,默认是不超时的。 - 相比 Zuul,Gateway 的 QPS 提升接近 3 倍,代价是要懂响应式编程。
我们网关现在跑 3 个实例,日均请求 1.2 亿,P99 稳定在 23 ms 左右。回头看,从 Zuul 迁过来的最大收益不是性能,而是限流、重试、熔断这些东西终于不用自己写了。