在网关类框架里,过滤器链的最后一棒往往最容易被忽略。拦路由、改请求头、做鉴权,前面的过滤器都抢着表现,真正负责把用户请求转发给下游服务的,是藏在链尾的WebClientHttpRoutingFilter。这篇文章把它的源码翻出来逐行拆解,顺带讲清楚它和NettyRoutingFilter的关系、请求体和响应体是怎么在响应式链路里透传的,以及我在实际项目里踩过的几个坑。
整篇内容适合刚接触 Spring Cloud Gateway 源码、想搞懂过滤器链工作原理的同学,也适合已经在用网关但遇到转发问题、想深入排查的人。只要你用过响应式编程,哪怕只是写过几个Mono,这篇文章就不会难懂。
1. 整体设计与定位:为什么需要这个“兜底”过滤器
1.1 路由过滤器家族的分工
Spring Cloud Gateway 的过滤器分两类:GlobalFilter和GatewayFilter。GlobalFilter是全局的,对每个路由都生效,其实质是构建了一条责任链。在这条责任链上,有三个特别重要的“转发三兄弟”:
RouteToRequestUrlFilter:给请求设置目标 URI,塞到GATEWAY_REQUEST_URL_ATTR里。NettyRoutingFilter:用 Netty HttpClient 转发请求。WebClientHttpRoutingFilter:用 WebClient 转发请求。
NettyRoutingFilter非WebClient的 http/https 协议。也就是说,如果你的路由 URI 是http://或https://开头,默认走的是基于 Reactor Netty 的NettyRoutingFilter,WebClientHttpRoutingFilter只对非http、https的协议(比如lb://、ws://)或者某些自定义协议做兜底处理。
为了让读者有个直观印象,我把两个过滤器的主要区别整理成了下面的表格:
| 对比维度 | NettyRoutingFilter | WebClientHttpRoutingFilter |
|---|---|---|
| 底层客户端 | Reactor Netty HttpClient | Spring WebClient(默认也走 Netty) |
| 适用协议 | http / https | 非 http / https 或自定义场景 |
| 在链中的顺序 | 默认顺序值较大,执行靠后 | Integer.MIN_VALUE,几乎最后 |
| 是否参与负载均衡 | 配合 LoadBalancer 转换后的 URI | 可直接使用lb://协议 |
| 依赖配置 | 由 Gateway 自动配置 | 由WebClient与相关属性决定 |
看到这里你可能会问:既然NettyRoutingFilter已经能处理绝大多数场景,那为什么还要有WebClientHttpRoutingFilter?答案藏在“扩展性”三个字里。网关不可能预见所有协议,lb://、ws://、grpc://这类非标准 HTTP 协议,网关想做到通用,就必须留一个口子给开发者自定义。WebClientHttpRoutingFilter就是那个兜底口子。
1.2 顺序值背后的设计逻辑
WebClientHttpRoutingFilter实现了 Spring 的Ordered接口,getOrder()直接返回Integer.MIN_VALUE。这是 Java 里能表示的最小整数。
这个值的设计意图很明确:它要让自身成为过滤器链中优先级最低的 GlobalFilter。通俗地理解,Integer.MIN_VALUE排在最后,前面所有过滤器都可以先处理请求,到最后再真正发请求。
有个细节容易忽略:GatewayFilterChain的执行顺序是从Ordered值小到大。值越小优先级越高,越先执行。而WebClientHttpRoutingFilter取Integer.MIN_VALUE,所以它被排到“最后”,意味着其他过滤器都处理完之后,它才接管请求进行转发。
那为什么不是Integer.MAX_VALUE而是MIN_VALUE呢?这个问题我在看 Spring Cloud Gateway 源码注释时也琢磨过。最终我得到的结论是:源码想表达的是“我是最后一道工序”,但面向路由过滤器的排序逻辑,它又希望自己的顺序号足够小、足够极致,从而在其他所有 GlobalFilter 的对比中都稳稳垫底。再加上它还要被NettyRoutingFilter的判断逻辑承接,顺序值越极端越不容易被别的过滤器插队干扰。
2. 源码逐段拆解:从入口到转发链路
2.1 类声明、构造函数与订单值
先看类签名:
public class WebClientHttpRoutingFilter implements GlobalFilter, Ordered { private final WebClient webClient; private final HttpClientProperties httpClientProperties; private final ServerCodecConfigurer serverCodecConfigurer; private final HttpServerRequestDecoderSpec decoderSpec; // ... }构造函数注入四个依赖:
WebClient:真正发请求的客户端,承担 HTTP 调用的核心职责。HttpClientProperties:网关 HTTP 客户端配置,比如连接超时、响应超时、线程池大小等,都从这里读取。ServerCodecConfigurer:服务端编解码器的配置器,用于读写请求/响应体的编码器集合。HttpServerRequestDecoderSpec:用于构建响应解码器的规格对象,是响应式网关响应处理链路里比较底层的一块。
getOrder()方法不用多解释,直接返回Integer.MIN_VALUE。这段代码在刚接触时可能会让人产生一个误区:以为这个过滤器是最后执行的“善后过滤器”,实际上它是“发请求的过滤器”。所以澄清一下:它排在最后,是为了让所有其他过滤器先把请求加工好,然后它才负责把请求真正发出去。
2.2 filter() 方法入口逻辑
filter()是整个过滤器的核心方法:
@Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { URI requestUrl = exchange.getRequiredAttribute(GATEWAY_REQUEST_URL_ATTR); String scheme = requestUrl.getScheme(); if (isAlreadyRouted(exchange) || !("http".equals(scheme) || "https".equals(scheme))) { return chain.filter(exchange); } setAlreadyRouted(exchange); // 构造 WebClient 请求并进行转发 // ... }第一步是从exchange的属性中取GATEWAY_REQUEST_URL_ATTR,这个属性里面保存了本次请求要转发的目标地址。由于前面的RouteToRequestUrlFilter已经将 URI 填充好,这里直接取出来用即可。
第二步是判断:
- 是否已经路由过;
- scheme 是否不是
http或https。
这里用了一个常见的短路判断:如果已经路由过,说明请求已经被其他过滤器转发,或者是带着网关路由标记回来的,就不需要再处理;如果 scheme 不是http或https,也不是它关心的范围,直接交付给过滤器链继续执行。
第三步是调用setAlreadyRouted(exchange),把“已路由”的标记写入 exchange 属性中。这个标记的作用是防止过滤器链回调或后续逻辑中重复转发。
整个入口逻辑简洁、清晰,典型的“快速失败 + 状态标记”模式。源码设计者在入口处就把不需要处理的请求拦截住,避免了不必要的 WebClient 调用,也防止路由循环。
2.3 构建请求:把 ServerHttpRequest 翻译成 WebClient 调用
当入口判断通过后,WebClientHttpRoutingFilter会进入真正的请求构建逻辑。其核心思路可以总结为:将ServerHttpRequest转换成 WebClient 的RequestHeadersSpec。这一步的思想很重要:网关层接收到的请求对象和真正发出的 HTTP 请求对象不是同一个,中间需要一个“翻译”过程。
我摘一段核心代码伪码来说明:
ServerHttpRequest request = exchange.getRequest(); HttpMethod method = request.getMethod(); HttpHeaders headers = request.getHeaders(); RequestBodyDecorator body = new RequestBodyDecorator(exchange, serverCodecConfigurer); // 使用 WebClient 发起调用 return webClient.method(method) .uri(requestUrl) .headers(httpHeaders -> httpHeaders.addAll(headers)) .body(body, ...) .exchange() .flatMap(clientResponse -> { // 处理响应 });这段代码的精髓在于:
method和headers直接从原始请求里拿。- 请求体并不是直接拿原始请求的
Flux<DataBuffer>,而是通过一个RequestBodyDecorator包装。 - 使用
.exchange()而不是.retrieve(),因为exchange()允许获取完整的ClientResponse,方便后续把状态码、响应头、响应体全部透传给下游。
为什么要用RequestBodyDecorator?因为你不能直接把接收到的请求体Flux<DataBuffer>原封不动地转发出去。原始请求体属于ServerHttpRequest的 body,流是一次性的,如果在转发之前已经被读取过,再传给下游就是空流。RequestBodyDecorator的作用就是在保留请求体读取能力的同时,避免流被提前消费。这块比较底层,但理解了它,你就不会再犯“读了一次 request body 导致转发空 body”的低级错误。
2.4 响应适配器:ClientResponse 转 ServerHttpResponse
转发出去之后,下游响应回来,flatMap里的代码会处理ClientResponse:
.flatMap(clientResponse -> { exchange.getAttributes().put(GATEWAY_RESPONSE_STATUS_ATTR, clientResponse.statusCode().value()); exchange.getAttributes().put(GATEWAY_ORIGINAL_REQUEST_URL_ATTR, request.getURI()); // 将 WebClient 的响应适配为网关的 ServerHttpResponse ResponseAdapter response = new ResponseAdapter(clientResponse); return chain.filter(exchange.mutate().response(response).build()); })这一步有两个关键动作:
第一,把响应状态码和原始请求 URL 记录到 exchange 的属性中,这样后置的过滤器或日志采集器能拿到这些信息。
第二,通过ResponseAdapter将 WebClient 的ClientResponse适配成ServerHttpResponse,然后通过exchange.mutate().response(response)生成一个新的 exchange 实例,继续交给过滤器链。
如果你对适配器模式比较熟,会发现这里的ResponseAdapter是一个典型的适配器实现。它实现了ServerHttpResponse接口,内部持有ClientResponse,在writeWith/setComplete等方法里,将 WebClient 返回的 body 流写入到 gateway 的下游响应中。
整个响应处理的巧妙之处在于:网关不是把响应拿回来再手动写回去,而是通过exchange.mutate()替换了响应对象。后续所有过滤器看到的是一个真正的、可写的ServerHttpResponse,完全感知不到背后是 WebClient 在转发。
3. 从配置到落地:这个过滤器如何跑起来
3.1 自动配置中的 WebClient
知道源码逻辑还不够,关键是要搞清楚WebClient是从哪来的。Spring Cloud Gateway 的自动配置类里,会默认创建一个WebClient.Builder,这个 Builder 可以通过spring.cloud.gateway.httpclient的前缀配置来定制底层连接。
很多人在自定义 WebClient 时踩坑:他们自己写了一个WebClientBean,结果网关核心链路却不用自己的 Bean,导致自定义的过滤器、拦截器、超时配置不生效。原因是WebClientHttpRoutingFilter直接注入的并不是从容器中任选的WebClient,而是在自动配置阶段绑定好的 Builder 构建出来的、与 Gateway 内部组件配套的实例。
如果你需要自定义,正确姿势是覆盖GatewayAutoConfiguration里的相关 Bean,或者在配置类中显式声明自己的WebClient并保证类型和优先级正确。比较推荐的做法是直接修改HttpClientProperties里的超时、连接池参数,而不是重新创建WebClient。
3.2 URI 属性从哪来
很多初学者在调试时会发现,GATEWAY_REQUEST_URL_ATTR里面的值跟预期不一样。这个属性是由RouteToRequestUrlFilter设置的。
路由匹配完成后,RouteToRequestUrlFilter把路由 URI 与请求路径相结合,生成完整的转发 URI,写入属性。如果有负载均衡,比如lb://service-name,它会把服务名保留下来,后续交给负载均衡过滤器或ReactorLoadBalancer去解析。
这里有个容易混淆的地方:WebClientHttpRoutingFilter里用getRequiredAttribute获取该属性,如果前面的过滤器没有设置这个属性,会直接抛异常。所以,如果你自定义了一个 GlobalFilter 放在更前面,并且没有正确传递或覆盖这个 URI 属性,转发就挂了。排查这类问题时,先看路由是否正确匹配,再看GATEWAY_REQUEST_URL_ATTR的值。
3.3 自定义 WebClient 的几种姿势
如果你确实需要自定义WebClient,我在实践中总结出三条路:
第一,通过spring.cloud.gateway.httpclient属性调整底层连接。这适合大多数超时、连接池调优场景,不需要改代码。
第二,直接替换WebClient实例。写一个@Bean返回自己的WebClient,注意让自动配置失效或者显示的依赖注入覆盖。这个方式灵活,但会导致网关内部使用的底层客户端属性与全局配置脱节。
第三,给WebClient增加 ExchangeFilterFunction,用于统一加签名、加 traceId、记录日志。这种方式侵入最小,且能保留网关原有行为,是我在项目中用得最多的方案。
我自己在一个对 upstream 加密逻辑较强的项目里,就选择了第三条路。用ExchangeFilterFunction给所有转发出去的请求补充签名头,既不用改 Gateway 内部代码,又能让所有经过 WebClient 的请求统一带上签名,排查问题也方便。
4. 常见问题与排查技巧实录
4.1 响应体中文乱码或编码问题
这类问题在网关场景里非常典型。原因往往是转发时没有保留原始请求的Content-Type或者缺少必要的编码声明。
WebClient 在解析响应时,会依据响应头里的Content-Type选择解码器。如果下游服务返回的Content-Type缺少charset信息,默认可能会按 ISO-8859-1 解码,导致中文乱码。
排查思路:
- 先看下游返回的
Content-Type,确认是否带charset=utf-8。 - 再看网关是否在转发时重写了
Content-Type头。如果某个过滤器修改了该头,WebClient 的编码解码就会错位。 - 使用
WireMock或 curl 绕过网关直连下游,对比响应体是否正常。
如果你的下游服务无法修改响应头,可以在网关层通过GlobalFilter统一设置响应头编码,位置要在WebClientHttpRoutingFilter之前。通常我会加一个自定义过滤器,在chain.filter之前给exchange的响应头预置contentType默认值。
4.2 响应超时的配置位置
很多人以为WebClientHttpRoutingFilter的超时就是 WebClient 的超时,其实不完全是。底层连接的超时控制主要看HttpClientProperties中的配置:
spring: cloud: gateway: httpclient: connect-timeout: 1000 response-timeout: 5sconnect-timeout控制建立连接的超时时间,response-timeout控制响应超时。如果你在代码里手动创建了WebClient,并且没有复用自动配置的底层 HttpClient,就会发现上面的配置不生效,超时被写成默认值或者无限等待。
还有一个特别隐蔽的坑:网关默认的 WebClient 底层采用事件循环线程,如果某个下游服务响应特别慢,占用了连接池中的连接,后续请求就会排队等待。表现为“响应超时”或“连接池获取超时”。此时要检查max-connections、pending-acquire-timeout等参数。我踩过一次这个坑,服务本身响应能力没问题,就是连接池不够,调大连接数后立刻恢复。
4.3 isAlreadyRouted 状态被误改
isAlreadyRouted和setAlreadyRouted都是ServerWebExchangeUtils提供的静态方法。它读写的 attribute 是GATEWAY_ALREADY_ROUTED_ATTR。
有些自定义过滤器中,如果不小心调用了一次setAlreadyRouted(exchange),那么WebClientHttpRoutingFilter入口处的判断就会短路,请求不会转发,直接返回空响应。这种问题表现为:控制台没有任何报错,下游也没有收到请求,但前端收到的是 200 或者异常结果。
排查技巧:在地铁里打开调试日志,搜索alreadyRouted相关的输出,或者直接检查代码里是否重复调用了路由标记方法。如果某个过滤器顺序号比较靠前,且调用了setAlreadyRouted,那大概率就是元凶。
4.4 与 NettyRoutingFilter 共存时的坑
由于NettyRoutingFilter负责http和https,如果你为了某些特殊需求,想让WebClientHttpRoutingFilter也接管http请求,就要修改它的入口判断逻辑。最直接的做法是自己继承或者重写过滤器。
如果你不改源码,而只是注册一个自定义过滤器把 URI 的 scheme 改成非http、https,比如改成lb://,那NettyRoutingFilter会忽略,而WebClientHttpRoutingFilter会接管。这种做法在负载均衡场景下有效,但是注意,此时 URI 不再是标准 HTTP scheme,WebClient 能否正确解析不同 Provider,取决于你是否配置了对应的ReactiveLoadBalancer。
在实际项目里,我不建议绕过原生 scheme 来“骗”过滤器。更稳妥的做法是:看清楚WebClientHttpRoutingFilter的定位,自定义一套转发过滤链,明确自己的扩展点,而不是模仿标准实现。
4.5 请求体重复读问题
这是转发类过滤器最常见的坑。之前提到RequestBodyDecorator,它的存在就是为了避免 body 流被读取一次之后无法再给下游。
如果你自己写过类似的转发过滤器,并且直接用request.getBody()传给下游,调试时会发现下游收到的请求体是空的,或者报错 “body is already consumed”。这是 Flux 流的“一次性”特性造成的,不是网关 bug。
解决办法有两种:
- 参考官方实现,用装饰器模式把 body 流缓存或者重新包装。
- 在需要读取 body 做校验、签名等操作时,把 body 数据缓存到 exchange 属性或内存缓存,然后再构造一个新的
ServerHttpRequest。
我个人的经验是:缓存 body 要谨慎,大文件上传场景下容易撑爆内存;如果只是做签名校验,可以将 body 解析后重新包装成DataBuffer,但一定要记得设置Content-Length。
5. 写在最后
说实话,翻 Spring Cloud Gateway 源码这件事,最难的不是代码看不懂,而是看不明白“为什么这么设计”。WebClientHttpRoutingFilter这个类,代码量不大,逻辑也不算复杂,但它在网关整个响应式转发链路里承担的“最后一棒”角色,恰恰体现了一个成熟框架对扩展性和可维护性的平衡。
我个人在实际操作中的体会是:遇到网关转发异常,先别急着怀疑框架,按顺序排查“URI 属性是否正确 -> 路由标记是否被误改 -> 编解码器是否匹配 -> 超时参数是否生效”,这几个方向能覆盖绝大多数问题。如果你想深入定制,先读一遍这个过滤器源码再动手,比在网上搜各种零散经验要高效得多。
以后你再看 Spring Cloud Gateway 里的其他过滤器,可以带着同样的思路去拆:看它的顺序值、看它操作哪些 exchange 属性、看它在哪里设置状态标记。把这三条线理清了,源码就没那么可怕了。