做了这么多年微服务,我越来越觉得网关才是整个架构里最值得花心思的地方。SpringCloud 系列写到了第十四篇,前面注册中心、配置中心、负载均衡、熔断限流都聊过,这次专门讲网关上的登录校验。很多从单体应用转微服务架构的人都会有同一个困惑:登录状态到底在哪里校验?每个服务各自写一遍?代码冗余不说,光是维护密钥和认证逻辑就能把人绕晕,而 Spring Cloud Gateway 作为流量入口,天然适合承担这个“安检口”的角色。这篇文章的核心就是两个点:一个是用自定义 GlobalFilter 做全局登录校验,另一个是用 GatewayFilter 做指定路由的精准拦截,标题里的“GatawayFilter”是笔误,实际类名就叫 GatewayFilter。跟着实操下来,你能直接把这套过滤器逻辑搬到自己项目里,也能彻底搞清楚为什么鉴权要收敛在网关层,而不是散落在各个服务中。
这篇教程也顺便回答很多 SpringCloud 面试题里都会问的一个问题:网关和普通服务之间如何传递用户身份,多个过滤器之间的执行顺序怎么控制。我默认读者有 Spring Boot 的基础,知道 JWT 的基本用法,不要求你对 Spring Cloud Gateway 已经非常熟悉。因为我会从整体设计讲到代码实现,再到实际踩坑的记录,每一步都有对应的落地方案。
1. 从“重复登录校验”说起:网关这一层到底该干什么
先聊聊设计思路,因为如果方向错了,代码写得再漂亮也只是在错误的道路上狂奔。微服务架构下,一个用户请求往往需要经过多个服务协作才能完成,比如你打开一个订单页面,前端可能会同时调用用户服务、订单服务、商品服务。如果每个服务都自己做登录校验,事情就会变得很混乱:用户信息要从 token 里反复解析,每个服务都要维护一份相同的鉴权代码,一旦 token 规则升级,你得上线所有服务才能完成变更。这个成本在服务数量少的时候还能忍,到了几十个服务的规模就会变成灾难。
网关的作用就是把这个重复劳动统一收口。网关收到请求后,先校验 token、解密用户信息、判断是否有权限访问目标路由,校验通过后才把请求转发给下游服务。后面的服务不需要再关心“这个用户是谁”,它们只需要信任网关已经完成了身份验证。这个设计在大型项目里几乎成了标配,也是 Spring Cloud Gateway 能成为微服务基础设施里关键一环的原因。
1.1 登录校验在网关层的价值
把登录校验放到网关层,最直接的收益就是逻辑收敛。你只需要在一处维护好认证规则,所有下游服务都不需要关心认证细节。有一次我在一个项目里接手了三个服务,每个服务都自己写了一套从 header 里解析 token 的逻辑,有的从请求头里取 Authorization,有的从 token 里取,还有的直接把 token 放在 cookie,搞到后面连测试环境联调都对不上。后来统一收敛到网关,下游服务全部删掉这些重复逻辑,整个链路清爽了不少。
网关鉴权还有一个容易忽略的优势:安全响应的速度。如果非法请求没有 token,或者 token 已经过期,网关这一层直接返回 401,根本不会让请求进入业务服务,这样恶意请求对后端服务的冲击被限制在最小范围。你可以把网关理解为小区门口的保安,保安不放行,单元楼里的住户不需要每家都开门核对身份,这个类比放在微服务架构里非常贴切。
当然,网关登录校验不能解决所有安全问题。比如服务间直接调用的时候,如果走的是内网地址而非网关入口,那这些请求并没有经过认证。网关校验适用于外部请求统一收口,服务间调用还需要额外的信任机制,这个我后面会专门讲。
1.2 GlobalFilter 和 GatewayFilter 到底怎么选
Spring Cloud Gateway 提供了两类过滤器,刚开始接触很容易搞混。我用一句话帮你记住:GlobalFilter 是对所有路由生效的全局过滤器,GatewayFilter 是绑定到某个或某几个路由上的局部过滤器。前者适合做横切逻辑,比如登录校验、日志记录、请求链路追踪;后者适合做针对特定接口的处理,比如某个服务需要对请求体做特殊处理,或者某个接口需要单独走一套校验规则。
实际项目中,登录校验这种业务通常用 GlobalFilter 实现,因为你希望所有非公开接口都拦住检查一遍。但也不排除有些路由不需要登录就能访问,比如用户登录接口本身、注册接口、验证码接口,这时候我们就在全局过滤器里维护一个“公开路径”列表,直接放行这些请求。GatewayFilter 更多用在精细化控制场景,比如网关路由配置里面给特定路由单独挂一个过滤器实例,用来做灰度发布、接口限流之类的事情。
下面用一个简单的对比表格来总结,方便你快速定位该用哪种过滤器。
| 对比项 | GlobalFilter | GatewayFilter |
|---|---|---|
| 生效范围 | 所有经过网关的请求 | 仅绑定到配置的指定路由 |
| 配置方式 | 实现 GlobalFilter 接口并注册为 @Component |
实现 GatewayFilter 或 AbstractGatewayFilterFactory,在路由配置中引用 |
| 典型场景 | 全局登录校验、日志、跨域处理 | 对特定服务/特定路径做额外处理 |
| 执行顺序 | 通过 @Order 或 Ordered 接口控制 |
同样支持 Ordered,并且可以在 YAML 中调整顺序 |
“全局”并不意味着所有过滤器都毫无条件地放行所有路径,我们会通过白名单机制把需要公开访问的接口排除掉。这就是网关设计的核心技巧之一。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前的准备:Spring Cloud Gateway 项目基础搭建
要写网关登录校验,至少得有一个能跑起来的网关项目。如果已经有现成的网关工程,这部分可以快速过一遍,重点看路由配置和依赖版本,因为版本不对会引发很多奇奇怪怪的问题。
2.1 依赖选型与版本注意事项
Spring Cloud Gateway 是基于 WebFlux 的响应式框架,所以依赖里有两个核心:spring-cloud-starter-gateway 和 spring-boot-starter-webflux。不要引入 spring-boot-starter-web,否则会因为 Spring MVC 和 WebFlux 冲突导致网关启动直接报错。这个坑我见过太多次了,很多人把普通 Web 项目的习惯带过来,结果启动时报 Spring MVC found on classpath, which is incompatible with Spring Cloud Gateway,排查半天才发现是多了个 web 依赖。
以下是一个稳定的依赖组合,我按照 Spring Cloud 2021.0.x 和 Spring Boot 2.6.x 来写,这是目前生产环境使用相当广泛的版本组合:
xml复制<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>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
</dependencies>
如果你还需要服务发现,就加上 Nacos 或 Eureka 的注册中心依赖。注意 Spring Cloud Gateway 和负载均衡组件需要配合使用,否则 lb:// 开头的路由地址无法解析。上面的 loadbalancer 依赖就是干这个的。
JWT 的解析和生成,我建议用 jjwt 或者 java-jwt 这类成熟库,不要自己手写 Base64 解码和签名校验。生产环境的安全性要求高,成熟库经过了大量项目检验。下面是我常用的 jjwt 版本:
xml复制<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
2.2 路由与免校验路径配置
网关要转发请求,必须在 application.yml 里配置路由。路由配置里,Path 断言决定了这个路由匹配哪些路径,uri 决定了转发目标,可以用 lb://服务名 的方式从注册中心动态获取地址。我把示例配置写在下面,这里预留了三个路由:用户服务、订单服务、商品服务。
yaml复制server:
port: 8080
spring:
application:
name: gateway-server
cloud:
gateway:
routes:
- id: user-service
uri: lb://user-service
predicates:
- Path=/api/user/**
filters:
- StripPrefix=1
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/order/**
filters:
- StripPrefix=1
- id: product-service
uri: lb://product-service
predicates:
- Path=/api/product/**
filters:
- StripPrefix=1
StripPrefix=1 表示转发时去掉第一段前缀,也就是把 /api/user/xxx 转成 /xxx 再发给用户服务。这样做的好处是网关对外暴露的接口统一带 /api 前缀,而每个内部服务保持自己的业务路径。
公开路径我通常会单独写到一个配置项里,方便运维同事调整。可以用 gateway.auth.skip-urls 这样的自定义配置,然后在过滤器里读取。例如:
yaml复制gateway:
auth:
skip-urls:
- /api/user/login
- /api/user/register
- /api/product/list
把这些路径放到配置中的好处很明显:改公开路径不需要重新编译代码,刷新配置即可生效。如果你接入了 Nacos 配置中心,甚至可以把这块配置放到配置中心动态刷新。
2.3 共享密钥与 Redis 的预准备
网关登录校验需要跟认证服务配合。通常的流程是:登录成功后认证服务签发 JWT,并把 token 存入 Redis 作为会话记录,网关校验时先验签 JWT,再看看 Redis 里是否存在这个 token。这样用户注销后立即删除 Redis 中的记录,就能让 token 迅速失效,而不只是依赖 JWT 过期时间。
网关项目里准备一个 JWT 签名密钥,对称加密用 HS256 就够,配置如下:
yaml复制gateway:
auth:
secret: your-256-bit-secret-key-please-change-in-production
如果你用 Redis 做 token 状态校验,加一个 spring-boot-starter-data-redis-reactive 依赖,再用 ReactiveStringRedisTemplate 异步访问 Redis,避免阻塞网关线程。具体代码我放到后面过滤器实现里一起写。
3. 核心代码:自定义 GlobalFilter 做全局登录校验
现在进入到本文最关键的部分。我会先写一个完整的 GlobalFilter,把登录校验的逻辑跑通,再补充 GatewayFilter 的用法,最后统一讲执行顺序。每个步骤我都会解释为什么这么写,方便你遇到问题时知道从哪里下手。
3.1 从请求头中解析并验签 Token
GlobalFilter 实现的第一步是拿到请求,判断当前路径是否需要跳过校验,如果不需要,就从请求头里获取 token。习惯上 token 放在 Authorization 头里,值通常以 Bearer 开头,所以我们解析时要把前缀去掉。
下面是过滤器核心代码:
java复制@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {
@Value("${gateway.auth.secret}")
private String secret;
@Value("${gateway.auth.skip-urls:[]}")
private List<String> skipUrls;
@Autowired
private ReactiveStringRedisTemplate redisTemplate;
private static final String BEARER_PREFIX = "Bearer ";
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
// 公开路径直接放行
if (skipUrls.stream().anyMatch(url -> path.startsWith(url))) {
return chain.filter(exchange);
}
String token = resolveToken(exchange.getRequest());
if (token == null || !validateToken(token)) {
return unauthorized(exchange, "缺少Token或Token格式错误");
}
// 校验通过,把用户信息放入请求头,转发给下游服务
String userId = parseUserId(token);
ServerWebExchange mutatedExchange = exchange.mutate()
.request(r -> r.header("X-User-Id", userId))
.build();
return chain.filter(mutatedExchange);
}
private String resolveToken(ServerHttpRequest request) {
String authHeader = request.getHeaders().getFirst("Authorization");
if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
return authHeader.substring(BEARER_PREFIX.length());
}
return null;
}
private boolean validateToken(String token) {
try {
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
// 检查 Redis 中是否存在
return Boolean.TRUE.equals(redisTemplate.hasKey("login:token:" + token).block());
} catch (Exception e) {
return false;
}
}
private String parseUserId(String token) {
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
Claims claims = Jwts.parserBuilder().setSigningKey(key).build()
.parseClaimsJws(token).getBody();
return claims.get("userId", String.class);
}
private Mono<Void> unauthorized(ServerWebExchange exchange, String message) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
byte[] body = ("{\"code\":401,\"message\":\"" + message + "\"}").getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(body);
return exchange.getResponse().writeWith(Mono.just(buffer));
}
@Override
public int getOrder() {
return -100;
}
}
我解释几个关键点。
skipUrls 通过 @Value 注入,但是注意 @Value 注入 List 需要 SpEL 表达式,比较麻烦。我更推荐写一个单独的 @ConfigurationProperties 配置类,绑定得更清爽,比如:
java复制@Component
@ConfigurationProperties(prefix = "gateway.auth")
@Data
public class AuthProperties {
private String secret;
private List<String> skipUrls = new ArrayList<>();
}
这样配置更清晰,也不容易写错 SpEL。
校验 token 时我用 redisTemplate.hasKey(...).block(),这是为了简化示例。但是这里要注意,在响应式环境里 .block() 会把当前线程阻塞住,虽然网关的 Netty 线程池不会轻易被打满,但高并发场景有风险。更合理的写法是使用 flatMap 做异步判断:
java复制return redisTemplate.hasKey("login:token:" + token)
.flatMap(exists -> {
if (Boolean.TRUE.equals(exists)) {
// 继续放行
} else {
return unauthorized(exchange, "Token已失效");
}
});
后面的完整实现你可以根据项目情况调整。如果你刚开始做,用同步 .block() 也能跑起来,毕竟登录校验的 Redis 访问本身是微秒级的,风险可控,但生产环境还是建议改成响应式写法。
3.2 如何优雅地返回 401 统一响应体
网关返回的错误响应应该保持统一格式,不然前端处理起来非常痛苦。有的接口返回 {"code":401,"message":"未登录"},有的返回 "Unauthorized",前端得写一堆判断逻辑。我在网关里把认证失败统一封装成下面的 JSON:
json复制{
"code": 401,
"message": "Token已过期,请重新登录",
"path": "/api/order/list",
"timestamp": 1700000000000
}
统一响应体里带上 path 和 timestamp,排障的时候非常有用,可以直接看到哪个路径被拦截了。实现方法是封装一个返回 JSON 的工具方法,注意设置 Content-Type 为 application/json;charset=UTF-8,否则中文可能乱码。
一个很容易踩的坑是:在网关过滤器里直接写响应流的时候,Netty 默认会对 Response 做压缩或编码处理,有时候前端拿到的响应头里没有 Content-Length,导致长连接复用报错。解决方案是在写流之前调用 response.getHeaders().setContentLength(body.length),显式告诉客户端响应体大小,这样处理最稳。
3.3 局部拦截:用 GatewayFilter 做指定路由的登录校验
有时候需求并不是所有接口都需要登录,而是某个路由下的部分接口需要。比如 product-service 里,商品查询接口可以匿名访问,但商品上架接口必须登录。如果只有一个全局过滤器,我们可以在全局过滤器里判断路径,但这会让全局过滤器变得越来越臃肿。更适合的做法是使用 GatewayFilter,把校验逻辑绑定到具体路由上。
实现一个自定义的 GatewayFilterFactory,是 Spring Cloud Gateway 官方推荐的方式。下面是示例代码:
java复制@Component
public class AuthGatewayFilterFactory extends AbstractGatewayFilterFactory<AuthGatewayFilterFactory.Config> {
public AuthGatewayFilterFactory() {
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
if (!config.isCheckToken()) {
return chain.filter(exchange);
}
String token = resolveToken(exchange.getRequest());
if (token == null) {
return unauthorized(exchange, "缺少Token");
}
return chain.filter(exchange);
};
}
@Override
public List<String> shortcutFieldOrder() {
return Collections.singletonList("checkToken");
}
public static class Config {
private boolean checkToken = true;
public boolean isCheckToken() {
return checkToken;
}
public void setCheckToken(boolean checkToken) {
this.checkToken = checkToken;
}
}
}
然后在路由配置里引用:
yaml复制spring:
cloud:
gateway:
routes:
- id: product-admin
uri: lb://product-service
predicates:
- Path=/api/product/admin/**
filters:
- AuthGatewayFilter=true
在 YAML 里写 AuthGatewayFilter=true,就是通过简写方式给 Config 里的 checkToken 赋值,Spring Cloud Gateway 会自动按 shortcutFieldOrder 里的顺序进行配置绑定。如果不需要额外参数,直接写 - AuthGatewayFilter 即可。
这么做的好处是代码职责单一:全局过滤器负责大范围的拦截和放行,局部过滤器只负责特定场景,两者互不干扰。我在项目里习惯把“全局登录校验 + 局部细粒度权限”组合起来用,灵活度很高。
4. 过滤器执行顺序、异常处理与用户信息传递
写完了核心过滤器,紧接着就会遇到两个现实问题:多个过滤器之间的先后顺序怎么保证?校验通过后,下游服务怎么知道当前用户是谁?这两个问题如果处理不好,网关层做了校验,服务层却拿不到用户身份,最后还是要做二次解析,等于白折腾。
4.1 多个过滤器共存时的执行顺序规则
Spring Cloud Gateway 的过滤器都实现 Ordered 接口,通过 getOrder() 返回值决定执行顺序,数值越小,执行优先级越高。全局过滤器和路由过滤器混合在一起时,最终执行顺序会根据所有过滤器的 order 值统一排序。
我在实际项目里,习惯用几个固定的 order 区间来统一管理,避免不同团队之间随意书写 order 导致冲突:
| 过滤器用途 | 建议 order 范围 | 说明 |
|---|---|---|
| 跨域、全局日志 | -200 到 -150 | 最先执行,保证请求信息被完整记录 |
| 登录校验 | -100 左右 | 在业务过滤器之前校验身份 |
| 权限校验 | -50 左右 | 登录后的细粒度权限判断 |
| 响应包装、路由转发 | 0 以上 | 放在最后做通用逻辑 |
为什么登录校验和权限校验要分开?因为登录校验只需要验证“你是谁”,权限校验需要判断“你能干什么”,两者关注点不同。如果合在一起,权限规则的改动会破坏认证逻辑,职责耦合,维护起来非常糟糕。我自己就栽过跟头:一次为了给某个接口加权限判断,结果影响了全校验逻辑,导致所有请求都被误拦截,排查了大半天才发现是过滤器里抛了异常。
4.2 通过 Header 把用户身份传给下游服务
登录校验通过后,我们需要把当前登录用户的信息传递给下游服务。常见做法是往请求头里添加自定义 Header。下游服务通过拦截器或切面读取这个 Header,拿到 userId,不需要再去解析 JWT。这样既减少了重复解析的性能损耗,也统一了用户信息入口。
修改 ServerWebExchange 请求头的方法前面已经写过了,再强调一遍:
java复制ServerWebExchange mutatedExchange = exchange.mutate()
.request(r -> r.header("X-User-Id", userId))
.build();
return chain.filter(mutatedExchange);
这里有个安全细节值得注意:如果下游服务直接暴露在内网,或者可以被绕过网关直接访问,那么任何人都可以伪造一个 X-User-Id 头。所以不要在网关层无条件信任外部传入的 X-User-Id,建议在校验通过后使用 removeHeader 把原始值清掉,再写入可信值:
java复制.request(r -> r.headers(headers -> headers.remove("X-User-Id")))
这个细节经常在高要求的安全评审里被提到,我们提前处理好能省掉不少麻烦。
4.3 网关层的统一异常处理
过滤器里出现异常,如果不做处理,Spring Cloud Gateway 会返回默认的 500 错误页面,那体验很差。登录校验逻辑里可能出现的异常包括 JWT 解析失败、Redis 连接超时、参数缺失等。我们要在过滤器内部把异常捕获,并转成可读的响应。
可以这样做:把校验逻辑包在 try-catch 块里,捕获异常后记录日志,再返回 401 或 500。
java复制try {
// token 校验逻辑
} catch (ExpiredJwtException e) {
return unauthorized(exchange, "Token已过期");
} catch (JwtException e) {
return unauthorized(exchange, "Token不合法");
} catch (Exception e) {
return serverError(exchange, "认证服务异常,请稍后重试");
}
更好的方式是配合 Spring 的 @ControllerAdvice 做全局异常处理。但要注意,网关是基于 WebFlux 的,@RestControllerAdvice 在这里无法像 Spring MVC 那样直接处理过滤器链路里的异常,因为过滤器在 HandlerMapping 之前执行。所以我更推荐在过滤器内部做好异常捕获。实际项目中,我会封装一个 AuthFilterErrorHandler 组件,专门负责把认证相关的异常转成标准响应体,过滤器只负责业务逻辑,这样代码更清爽。
5. 实际踩坑与排查技巧:从 400 到 500 的那些烦恼
网关这个东西,跑起来不难,但出问题的时候特别隐蔽。很多问题在本地开发时不会出现,到了测试环境或者生产环境就爆发。下面我把高频坑点整理成表格,再挑几个典型的展开说。
5.1 高频问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 网关启动失败,提示 Spring MVC conflict | 引入了 spring-boot-starter-web |
移除 web 依赖,改用 webflux |
| 过滤器没生效,公开路径也被拦截 | getOrder() 值太大,或过滤器没有被 Spring 接管 |
确认类上有 @Component,order 使用负数 |
| 请求通过网关后下游收到 404 | StripPrefix 配置错误,路径前缀没去掉 |
调整 StripPrefix 数量,在日志里对比转发前后 Path |
| 中文响应乱码 | 响应头没有设置 charset=UTF-8 |
设置 Content-Type: application/json;charset=UTF-8 |
| token 校验总是失败 | JWT 密钥不一致,或密钥长度不够 256 位 | 统一认证服务和网关的密钥,HS256 密钥至少 32 字节 |
| Redis 校验导致请求变慢 | 同步 .block() 在高并发下阻塞了 Netty 线程 |
改为响应式 flatMap 异步判断 |
| 跨域请求被过滤器拦截 | 预检请求 OPTIONS 没放行 | 在过滤器中直接放行 HttpMethod.OPTIONS 请求 |
5.2 预检请求必须优雅放行
浏览器跨域时会先发一个 OPTIONS 预检请求,这个请求不带业务参数,也没有 Authorization 头。如果你在网关层做登录校验,势必会拦截掉 OPTIONS,导致前端出现跨域报错。这个问题排查起来非常迷惑,因为从浏览器控制台看,明明是 CORS 错误,但你配置了跨域却依然无效。
解决方法是,在过滤器判断路径之前,先判断请求方法,如果是 OPTIONS,直接放行。代码就一行:
java复制if (request.getMethod() == HttpMethod.OPTIONS) {
return chain.filter(exchange);
}
同时结合网关的全局跨域配置:
yaml复制spring:
cloud:
gateway:
globalcors:
cors-configurations:
'[/**]':
allowedOrigins: "*"
allowedMethods: "*"
allowedHeaders: "*"
allowCredentials: true
要注意 allowCredentials: true 时,allowedOrigins 不能配置为 "*",因为浏览器会拒绝包含凭证的跨域响应。如果前端需要携带 cookie 或凭证,请把域名具体写出来。
5.3 调试过滤器链路的实用技巧
排查网关问题,最直接的工具就是日志。你可以打开 Spring Cloud Gateway 的调试日志,观察每个过滤器执行的前后顺序:
yaml复制logging:
level:
org.springframework.cloud.gateway: debug
开启后,日志里会打印路由匹配情况和过滤器执行链,比如:
code复制Route matched: user-service
OrderedGatewayFilter{delegate=AuthGlobalFilter@..., order=-100}
看到顺序之后,你可以快速判断是不是自定义过滤器没生效,或者顺序不对。另外一个技巧是在自定义过滤器的 filter 方法里打印请求路径和当前时间戳,配合全局请求 ID 就能把一次请求在网关里的耗时给算出来。
我习惯在网关里加一个简单的耗时过滤器,作为所有过滤器的第一个环节,记录开始时间,最后在响应头里加上 X-Gateway-Cost-Time,下游排查时一眼就能看出网络耗时到底花在哪个环节。
6. 从网关登录校验到微服务全链路鉴权的延伸设计
写到这里,很多读者已经可以把代码跑通了,但如果你准备在生产环境使用,光有网关层登录校验是不够的。下面几个延伸方向是我在实际项目中逐步总结出来的,提前了解可以少走弯路。
6.1 服务间调用时的信任问题
网关层做登录校验,能拦截外部请求,但服务之间通过 Feign 内部调用时,通常不会经过网关。这种情况下,内部调用的请求是裸奔的,任何服务只要拿到内网地址,就能直接访问另一个服务。解决思路主要有两种:一种是在服务内部也做轻量级 token 校验,但这里不再是校验用户 token,而是校验服务间调用的身份凭证;另一种是使用注册中心的安全组策略,或者把服务部署在不同的网络命名空间,通过基础设施层隔离。
Feign 调用时,可以通过 RequestInterceptor 为每个内部请求添加一个服务间调用的凭证 Header,下游服务对该凭证做统一校验。这本质上是用另一个维度的凭证来解决服务间信任问题,而不是让下游服务重复解析用户 JWT。
6.2 动态路由与过滤器配置中心化
网关过滤器里的公开路径列表、授权规则等,如果是写死在项目里的,每改一次都要发布一次网关服务,成本太高。我在生产项目里会把这些配置全部放到 Nacos 配置中心,网关作为 Nacos 客户端监听配置变化。比如某天运营要做活动,某个接口需要临时匿名访问,运维人员在配置中心改了配置,网关不需要重启就立即生效。
Spring Cloud Gateway 配合 Nacos 实现动态路由,是很多“若依微服务 plus”这类脚手架项目里的常见设计。如果你还没接配置中心,建议尽早纳入规划,因为微服务架构下的配置管理是刚需,网关作为核心组件更应该具备动态调整能力。
6.3 与 Spring Cloud Alibaba 生态结合
这套网关登录校验逻辑和 Spring Cloud Alibaba 生态结合得非常自然。Nacos 做注册中心和配置中心,Sentinel 在网关层做流量控制和熔断降级,二者配合能做出一套相当完整的基础设施。网关过滤器里校验完身份之后,还可以根据用户维度做限流,比如某个普通用户每秒最多调用 10 次接口,用 Sentinel 的 RequestOriginParser 从请求头里取 X-User-Id 作为限流维度,这样比全局限流更精细,防止单个用户刷爆接口。
Sentinel 还支持给网关路由配置流控规则,我可以很明确地告诉你:网关层同时做“认证 + 限流 + 审计日志”,是微服务架构里性价比最高的一套组合拳,稳定性会有一个质的提升。
6.4 把登录状态从 JWT 换成 Session 时的过滤器改造
不是所有项目都适合 JWT。内部管理系统、后台管理端,很多还在用 Session 和 Cookie。如果你要在网关层统一处理 Session 登录态,思路也很简单:网关解析 Cookie 里的 SessionId,然后查 Redis 拿用户信息,再把用户信息写入请求头转发给下游服务。过滤器主体逻辑和 JWT 版本完全一致,唯一的变化是 resolveToken 这个方法改成了从 Cookie 里取 SessionId。
所以,你完全可以把“登录校验过滤器”抽象成两层:一层是认证方式无关的总体流程,另一层是具体的认证策略。这样以后切换认证方式,只需要替换策略实现,不用动整个过滤器链路。代码的可维护性就是靠这些细小的设计决策积累起来的。
7. 个人实操总结与几条收尾建议
写到这里,整条网关登录校验的链路已经完整跑通了。回头看,我最大的体会是:网关层做登录校验不是把代码写出来就结束了,真正重要的是设计好过滤器的职责边界。全局过滤器负责登录态校验,局部过滤器负责路由级别特殊控制,下游服务通过 Header 获取可信用户信息,这套模式在多个项目里验证过,扩展性和可维护性都很好。
如果你正打算在自己的项目里落地网关登录校验,我的建议是先搭一个最简版本,只做 JWT 验签和公开路径放行,跑通后再逐步加 Redis 校验、权限校验、配置中心动态调整。一开始就想着把所有功能都做完,反而容易被各种细节卡住。踩过几次坑之后你就会发现,网关这块的稳定性对整个微服务架构的影响远超想象,值得多花时间打磨。
还有一个小技巧值得分享:网关的过滤器不要只关注正常流程,异常分支的日志一定要打完整。我见过太多生产事故的排查无从下手,就是因为日志里只有“校验失败”四个字,连失败的具体原因都没有。在过滤器里把请求路径、token 前缀、校验失败原因都记录下来,看起来只是多写几行代码,关键时刻真的能救命。
