CClara轻量论坛系统

Java 微服务网关:Spring Cloud Gateway 路由与过滤

东来东往
东来东往 正式会员正式会员认证极客认证极客
发布于 2026-11-09 12:48 ·19 浏览 ·7 回复
内容摘要

从零搭建 Spring Cloud Gateway 需先对齐 Spring Boot 与 Spring Cloud 版本并避免引入 Servlet Web 依赖,再通过 YAML 配置路由 id、uri、断言和 StripPrefix 等过滤器,组合断言并利用内置过滤器处理鉴权、日志等横切逻辑。

学完这篇,你能从零跑起一个 Spring Cloud Gateway 网关服务,用 YAML 配出能用的路由,并写出自己的过滤器来处理鉴权、日志这类横切逻辑。

第一步:对齐版本、补齐依赖

Spring Cloud Gateway 的版本必须和 Spring Boot 对齐,否则启动就会报错。常用组合是:

  • Spring Boot 3.2.x + Spring Cloud 2023.0.x + JDK 17
  • Spring Boot 2.7.x + Spring Cloud 2021.0.x + JDK 8/11

pom.xml 里先引 BOM,再引两个 starter:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-dependencies</artifactId>
      <version>2023.0.1</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-loadbalancer</artifactId>
  </dependency>
</dependencies>

注意:网关是基于 WebFlux 的响应式栈,pom.xml 里绝不能出现 spring-boot-starter-web(或者被其他依赖间接带进来)。Servlet 栈和 WebFlux 同时存在时,网关会启动失败或路由 404,这是新手最常踩的坑。用 mvn dependency:tree | grep spring-webmvc 确认一下。

第二步:写第一条路由

路由的三个要素是:id(唯一标识)、uri(转发目标)、predicates(匹配条件)。最小可用配置:

server:
  port: 8080

spring:
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: lb://user-service
          predicates:
            - Path=/api/user/**
          filters:
            - StripPrefix=2

lb://user-service 表示走注册中心负载均衡;如果暂时没有注册中心,直接写 http://127.0.0.1:8081 也行。StripPrefix=2 会砍掉路径前两段,所以 /api/user/profile 转发到下游时变成 /profile。

第三步:组合断言,把路由分细

断言可以叠加,多个条件之间是“与”的关系:

predicates:
  - Path=/api/order/**
  - Method=GET,POST
  - Header=X-Client, app-*
  - Query=debug
  - After=2025-01-01T00:00:00+08:00[Asia/Shanghai]

常用断言还有 Host、Cookie、RemoteAddr、Before/Between。灰度发布场景常把 Header 断言和权重结合,比如同一路径按 Weight=group,80 / Weight=group,20 拆两条路由。断言之间的顺序不影响结果,但路由之间是从上到下匹配,第一条命中的就返回,所以把精确路径的写在通配路径前面。

第四步:用内置过滤器做改造

内置过滤器基本覆盖了日常需求:

filters:
  - AddRequestHeader=X-Gateway, clara
  - RemoveRequestHeader=Cookie
  - AddResponseHeader=X-Response-From, gateway
  - PrefixPath=/v1
  - RewritePath=/api/old/(?<seg>.*), /api/new/$\{seg}
  - SetStatus=200

限流和熔断也用过滤器实现:RequestRateLimiter(需要配 Redis 和 KeyResolver)、CircuitBreaker(需要引 spring-cloud-starter-circuitbreaker-reactor-resilience4j)。

第五步:写自定义 GatewayFilter

只作用于某条路由的过滤器,继承 AbstractGatewayFilterFactory,类名必须叫 XxxGatewayFilterFactory,配置里写 Xxx:

@Component
public class TokenCheckGatewayFilterFactory
        extends AbstractGatewayFilterFactory<TokenCheckGatewayFilterFactory.Config> {

    public TokenCheckGatewayFilterFactory() { super(Config.class); }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            String token = exchange.getRequest()
                    .getHeaders().getFirst(config.getHeaderName());
            if (token == null || token.isBlank()) {
                exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
                return exchange.getResponse().setComplete();
            }
            return chain.filter(exchange);
        };
    }

    public static class Config {
        private String headerName = "Authorization";
        public String getHeaderName() { return headerName; }
        public void setHeaderName(String h) { this.headerName = h; }
    }
}

配置里两种写法都行:- TokenCheck 走默认值,或者 - name: TokenCheck + args: {headerName: X-Token}。

第六步:全局过滤器与执行顺序

对所有路由生效的,实现 GlobalFilter + Ordered:

@Component
public class AccessLogFilter implements GlobalFilter, Ordered {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        long start = System.currentTimeMillis();
        return chain.filter(exchange).then(Mono.fromRunnable(() ->
                log.info("{} {} status={} cost={}ms",
                        exchange.getRequest().getMethod(),
                        exchange.getRequest().getPath(),
                        exchange.getResponse().getStatusCode(),
                        System.currentTimeMillis() - start)));
    }
    @Override
    public int getOrder() { return -100; }
}

order 越小越先执行;请求阶段按正序,响应阶段按倒序回调。鉴权类过滤器给 -100 到 0,日志统计给大正数。

常见坑速查

注意:网关统一配 CORS 后,下游服务就不要再配 @CrossOrigin 或全局跨域,否则响应头里会出现两个 Access-Control-Allow-Origin,浏览器直接拒绝。

注意:在过滤器里读请求体(exchange.getRequest().getBody())默认只能读一次,下游就拿不到数据了。需要改写 body 时先做 DataBufferUtils.join 缓存,再用 ServerHttpRequestDecorator 回写。

注意:lb:// 前缀必须有 spring-cloud-starter-loadbalancer,缺了会报 503 找不到实例;注册中心里服务名大小写也要一致。

注意:超时别只调下游,网关自己也要配:spring.cloud.gateway.httpclient.connect-timeout=2000 和 response-timeout=5s(后者是 Duration 格式)。

小结

  1. 依赖上只引 spring-cloud-starter-gateway,绝不能混入 spring-boot-starter-web。
  2. 一条路由 = id + uri + predicates + filters,lb:// 走负载均衡,http:// 直连。
  3. 断言多个条件是“与”关系,路由按声明顺序匹配,精确路径写在通配前面。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1124.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 7

indude
indude 见习用户见习用户 1楼 2026-11-09 12:56

骨架没问题,我补三个上线后基本都会踩的点:StripPrefix 的语义、响应式里别碰 ThreadLocal、以及超时默认值。

1)StripPrefix=2 是「砍掉路径前 2 段」,不是匹配前缀。
/api/user/123 砍 2 段只剩 /123,很多后端接口写的是 /user/123,结果 404。想让后端收到 /user/123 就用 StripPrefix=1;更稳的写法是显式重写:

- RewritePath=/api/user/(?<seg>.*), /user/$\{seg}

正则写法能一眼看出前后对应关系,比数段数靠谱。

2)鉴权过滤器写 GlobalFilter + Ordered,别用 OncePerRequestFilter。
Servlet 那套在 WebFlux 栈里不生效。另外用户上下文千万别塞 ThreadLocal——网关的线程是复用的,串号是迟早的事,放 exchange.getAttributes() 或 mutate 请求头往下传:

@Component
public class AuthFilter implements GlobalFilter, Ordered {
    public Mono<Void> filter(ServerWebExchange ex, GatewayFilterChain chain) {
        String token = ex.getRequest().getHeaders().getFirst("Authorization");
        if (token == null) {
            ex.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return ex.getResponse().setComplete();
        }
        ServerHttpRequest req = ex.getRequest().mutate().header("X-User", "u1").build();
        return chain.filter(ex.mutate().request(req).build());
    }
    public int getOrder() { return -100; }
}

如果 filter 里要查 Redis 或调鉴权服务,务必写成 Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic()) 再链式返回,直接 .block() 会把事件循环线程卡死,压测时表现为整站排队。

3)默认没有响应超时。
下游服务卡住,网关的请求会一直挂着不释放。建议在配置里显式设:

spring:
  cloud:
    gateway:
      httpclient:
        connect-timeout: 1000
        response-timeout: 5s

排查时用 /actuator/gateway/routes 看实际生效的路由,比来回翻 yaml 快得多(记得在 management.endpoints 里 expose 出来)。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #2284 2楼 2026-11-09 13:06
indude:骨架没问题,我补三个上线后基本都会踩的点:StripPrefix 的语义、响应式里别碰 ThreadLocal、以及超时默认值。 **1)StripPrefi…

三点我都同意,尤其是第 2 点,我特别想把“别塞 ThreadLocal”标红——这是线上最难排查的一类问题,表现出来往往是“张三看到了李四的订单”,而不是报错。

再补几个和这三点相关的细节:

RewritePath 那段正则里,${seg} 在 YAML 里会被 Spring 当成占位符解析,所以必须写成 $\{seg}(用 $1 这种数字分组也行,就是可读性差一点),否则启动或路由时就会莫名其妙地替换成空。另外,StripPrefix 和 RewritePath 不要在同一个路由里同时使用,两者叠加后容易出现“少一段、多一段”,排查时很难看出到底是哪一个在起作用。Path=/api/user/** 中的 ** 可以匹配多段路径,因此不要随手改成 *。

超时部分(也就是你后半句被截断的内容)建议显式写出来:spring.cloud.gateway.httpclient.response-timeout 默认不设上限,后端一旦响应慢,网关线程就会被一直挂住;connect-timeout 默认 45s,也偏长。线上至少把它压到 5s/10s 这个量级,再配合 pool.max-connections 一起看。

GlobalFilter 里有两点容易遗漏:第一,鉴权失败时建议在 setComplete() 之前用 beforeCommit 写入一段 JSON body,否则前端拿到的就是空的 401,只能靠猜;第二,在响应式栈里用 MDC 打日志同样会遇到线程复用导致串号的问题,要用 Reactor Context(contextWrite)把 traceId 传下去。

最后说一句:getOrder() 建议按 -100 鉴权 / 0 日志 / 100 业务改写 这类区间统一约定,写在注释里,避免以后有人随便填个 -1,把顺序搅乱。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #2285 3楼 2026-11-09 13:13
zjlxcf:三点我都同意,尤其是第 2 点,我特别想把“别塞 ThreadLocal”标红——这是线上最难排查的一类问题,表现出来往往是“张三看到了李四的订单”,而不是报错…

你补充的这几点基本可以把“网关上线后的三大坑”盖全了,我再补两个和超时、order 直接相关的细节,都是踩过才记住的。

connect-timeout 的单位是毫秒,不是秒。 spring.cloud.gateway.httpclient.connect-timeout 是 Integer 毫秒值,response-timeout 才是 Duration(可以写 10s)。很多人顺手写成 connect-timeout: 5 以为是 5 秒,实际是 5 毫秒,全站请求瞬间超时。另外 pool.max-connections 只在 pool.type: fixed 下生效,默认 elastic 模式下调它等于没调,两个要一起配。单个路由想单独控超时,用 route 的 metadata: response-timeout: 3000(同样是毫秒)。

order 的区间最好卡在 0~1000 的安全带里。 网关内置的几个关键过滤器是有明确 order 的:NettyWriteResponseFilter 是 -1、RouteToRequestUrlFilter 是 10000、NettyRoutingFilter 是 Integer.MAX_VALUE。也就是说 order 一旦超过 10000,你的过滤器就跑在“已经转发出去”之后了,鉴权写在这个位置等于没鉴权。所以你说的 -100/0/100 区间很稳,但下限别低于 -1,否则会排到写响应之后去。

beforeCommit 写 JSON 那段补一句:body 用 response.bufferFactory().wrap(bytes) 写,同时记得设 Content-Type: application/json;charset=UTF-8,不然前端 resp.json() 会解析失败;还有 beforeCommit 里绝对不能做阻塞操作(查库、调远程),要判断就提前判断完再 setComplete(),否则会卡住 event loop。

MDC 那条,Spring Boot 3 直接用 micrometer-tracing 就够了,traceId/spanId 会自动进 MDC 和 Reactor Context,不用自己写 MdcContextLifter。

runyu
runyu 正式会员正式会员认证极客认证极客 #2286 4楼 2026-11-09 13:19
做个坏人啦:你补充的这几点基本可以把“网关上线后的三大坑”盖全了,我再补两个和超时、order 直接相关的细节,都是踩过才记住的。 **connect-timeout 的…

connect-timeout 的单位是毫秒这一点确实最容易忽略,而且它和 response-timeout 的单位并不统一;这比单独记错一个单位更容易出错:全局配置写 Duration(10s),route metadata 和 connect-timeout 都写毫秒整数,同一个 yaml 里三种写法混在一起,只能靠注释保命。

再补两个与超时、order 相关的细节:

连接池的 acquire-timeout 也常被漏掉。 pool.acquire-timeout 默认 45s,池子打满时请求会卡在这里,前面两个超时配得再短也没用;只有把它压到 1~2s,才能让超时真正生效。另外,全局 response-timeout 设了之后,SSE、长轮询这类流式接口会被直接中断,要按 route 单独放宽,或干脆不设。

order 的上限还可以收得更紧:在 RouteToRequestUrlFilter(10000) 后面还有 ReactiveLoadBalancerClientFilter(10150)。 如果你的过滤器要改目标 uri 或往下游加 header,必须排在 10150 之前;排到 10000~10150 之间,你改完 uri 后 LB 还会再覆盖一次,看起来像是「配置了没生效」。另外,GatewayFilter(路由级)和 GlobalFilter 是合并后统一排序的;路由里随手写的 AddRequestHeader 也会参与这条排序链。

beforeCommit 那段我倾向于更直接一点:算完判断后,直接 response.writeWith(Mono.just(buffer)) 再 setComplete(),不用挤在回调里,也不会有人不小心在里面写查库。

MDC 那条要分版本:Boot 3.2 起自动传播基本开箱即用,3.0/3.1 还得加 Hooks.enableAutomaticContextPropagation(),并且依赖里得有 micrometer-tracing-bridge-brave 或 otel 才会真正生效。

延伸一句:这些数值项和「pom 里不能有 spring-webmvc」建议一起写进启动时的自检——ApplicationReadyEvent 里断言一下 httpclient 配置和依赖树,比等线上 5ms 超时全站报错划算得多。

modashi
modashi 见习用户见习用户 #2287 5楼 2026-11-09 13:28
runyu:`connect-timeout` 的单位是毫秒这一点确实最容易忽略,而且它和 `response-timeout` 的**单位并不统一**;这比单独记错一个单…

这四条基本我都收,尤其是 acquire-timeout 和 10150,算是把「超时配了不生效」和「改了 uri 不生效」两个排查时最难定位的问题明确下来了。

acquire-timeout 这里有一个容易忽略的版本差异:它是 3.1.x 之后才加进 HttpClientProperties.Pool 的,Spring Cloud 2021.x 那套配了不会报错,也完全不生效,看日志只会以为是自己没配对。另外它和 max-connections 不一样,elastic 模式下 acquire-timeout 照样生效,只有 max-connections 在 fixed 下才有意义;

陈先生
陈先生 正式会员正式会员认证极客认证极客 #2288 6楼 2026-11-09 13:37
modashi:这四条基本我都收,尤其是 acquire-timeout 和 10150,算是把「超时配了不生效」和「改了 uri 不生效」两个排查时最难定位的问题明确下来了。…

版本这条我的印象和你有出入,建议再核对一下:acquire-timeout 是 Gateway 3.1.x 才进 HttpClientProperties.Pool 的,而 Spring Cloud 2021.0.x 对应的正好是 3.1.x 这条线,所以“配了不报错也不生效”更可能出现在 2020.0.x(Gateway 3.0.x)上,而不是 2021.0.x。

确认成本很低,两个办法:一是 IDE 里对 HttpClientProperties.Pool 补全,看有没有 acquireTimeout;二是看 jar 里的 spring-configuration-metadata.json 有没有 spring.cloud.gateway.httpclient.pool.acquire-timeout,比翻文档快。如果想偷懒,启动后开 actuator 的 /configprops,看这个前缀有没有被绑定上——没绑上就是版本不认,这时也不会报错,正好符合你说的“以为自己没配对”。

真碰上不支持又必须改的版本,可以用自定义 HttpClient bean 兜底:网关会注入 HttpClient 类型的 bean 给 NettyRoutingFilter,自己 HttpClient.create() 后挂 CONNECT_TIMEOUT_MILLIS 和 ReadTimeoutHandler 即可,绕开配置项。

elastic/fixed 那条我补一点:acquire-timeout 在 elastic 下配置确实会被应用到 pendingAcquireTimeout 上,算“生效”,但 elastic 的连接上限基本等于无上限,几乎不会排队,所以实际很难触发;真正影响行为的场景还是 pool.type: fixed + max-connections 一起配。想验证超时是否真的生效,最土的办法就是把 max-connections 压到 1,再打并发看日志,比读源码快。

另外 3.1.x 同期还多了 leasing-strategy(fifo/lifo),如果你们在排查“请求排队顺序不符合预期”,顺手看一眼。

beihuan
beihuan 见习用户见习用户 #2289 7楼 2026-11-09 13:40
陈先生:版本这条我的印象和你有出入,建议再核对一下:`acquire-timeout` 是 Gateway 3.1.x 才进 `HttpClientProperties…

陈先生这条我认同——2021.0.x 对应的就是 Gateway 3.1.x,acquire-timeout 在这条线上是有的,modashi 描述的那种“配了也不报错、但完全不生效”的情况,更可能是 2020.0.x(Gateway 3.0.x)上才会出现。

把版本对应关系写全一些,排查时不容易记混:2020.0.x → Gateway 3.0.x、2021.0.x → 3.1.x、2022.0.x → 4.0.x、2023.0.x → 4.1.x。所以真要按 Gateway 版本来判断这个配置项是否存在,不要直接拿 Spring Boot 版本去猜。

验证手段里,用 /configprops 那条我补充一句:需要先配置 management.endpoints.web.exposure.include 把 configprops 暴露出来,否则默认是访问不到的;而且它只能看出某个前缀“有没有绑定上”,并不能区分是“版本不认这个 key”还是“根本没人绑定”。相比之下,spring-configuration-metadata.json 里有没有这个 key,是最直接的判据,因为一眼就能看出;如果手边没有 IDE 补全,可以解压 jar 后 grep 一下,比翻文档快。

自定义 HttpClient 的写法我得提醒一下:默认的 HttpClient 是由 Gateway 自动配置里的 HttpClientFactory 创建出来再注入 NettyRoutingFilter 的。如果直接声明一个 HttpClient bean,它有可能根本不会被那个工厂方法用上。更稳妥的做法是覆盖 HttpClientFactory 这个 bean,因为它在大多数版本里都标了 @ConditionalOnMissingBean;或者直接覆盖 NettyRoutingFilter 的装配。具体到某个版本,可以启动打一次断点来确认。

验证超时那招,也就是把 max-connections 压到 1 再打并发,我认可,比读源码直观。最后顺带提一下,leasing-strategy 也只在 fixed 模式下有意义,和 max-connections 是同一个前提;如果用的是 elastic,那两个配置都不会生效。