一、鉴权代码,每个服务写一遍
网关配好之后,请求能转发了。但没多久我发现一个问题:cart-service 里有一份 JWT 解析工具类,trade-service 里又写了一份,连密钥都配了两遍。五个服务拆下来,同样的逻辑抄了五份。
代码重复还只是表面问题,更麻烦的是密钥管理。JWT 密钥配在五个服务里,轮换一次要改五处,漏改一个就出现"部分服务 token 失效、部分还能用"的诡异现象。更严重的隐患是安全:鉴权靠自觉,某个服务忘了写拦截器,这个口子就是裸奔的,代码评审也很难盯住每一个。
这类"所有请求都要做、逻辑还完全一样"的事,重复五遍本身就是错误信号。合理的做法是把它提到最前面的统一入口:网关先过一遍鉴权,通过了再转发,顺便把"当前是谁"告诉下游。
这就引出了 Gateway 的第二个核心能力:过滤器。路由解决"请求发给谁",过滤器解决"请求能不能过、怎么过"。
二、过滤器分两种,什么时候用哪种
Gateway 的过滤器分两类,先分清楚,后面写鉴权才不会晕。
GatewayFilter(局部过滤器)
只对某一条路由生效,在 yaml 里声明式配置。内置的常用实现有 AddRequestHeader(加请求头)、StripPrefix(去掉路径前缀)这类小改造:
yaml
routes:
- id: item-route
uri: lb://item-service
predicates:
- Path=/items/**
filters:
- AddRequestHeader=X-Source, gateway
- StripPrefix=1
GlobalFilter(全局过滤器)
对所有路由生效,用 Java 代码实现。鉴权、统一日志、全局限流这种"每个请求都要过"的逻辑,属于这一类。
怎么选:加个头、去个前缀这类对请求的小修饰,局部过滤器 yaml 里两行搞定;要写判断逻辑、要访问缓存或配置的,用全局过滤器写代码。
执行顺序
一次请求可能穿过多个过滤器,局部和全局的混在一起,先后由 Ordered 接口的 getOrder() 决定,返回值越小越先执行。比如鉴权应该排在其他业务过滤器之前:先确认"能不能进",再做锦上添花的事。顺序排错,就会出现"还没鉴权就先改了请求"这类怪现象。
惯例上,鉴权这类"守门"过滤器返回负数(比如 -100),保证排在 order 为 0 或正数的业务过滤器之前。
局部过滤器最终也会被框架合并进整条过滤器链,和全局过滤器一起按 order 排序执行------所以"局部"只是配置维度的局部,运行时大家在同一条链上。
和 Servlet Filter 的区别
写过 Spring MVC 的人容易按 Servlet Filter 的直觉来写,这里有两处不同:
java
// Servlet Filter:void,同步阻塞
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) { ... }
// Gateway GlobalFilter:响应式,返回 Mono<Void>
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ... }
Gateway 底层是 WebFlux,过滤器是响应式的:不阻塞线程,处理完成的"信号"通过 Mono 往下传递。写法上要习惯 return chain.filter(exchange) 这种把控制权交还给链条的风格,而不是"做完自己手动往下调"。
三、JWT 统一鉴权:AuthGlobalFilter
把上节的概念落成一个鉴权过滤器,整体流程先画出来:
text
请求进来
│
▼
AuthGlobalFilter(GlobalFilter)
│ 路径在排除列表里?
├── 是 ──▶ 直接放行 chain.filter(exchange)
│
▼ 否
从请求头取 authorization,解析 JWT
│ 解析失败 ──▶ 返回 401
▼ 成功
userId 塞进请求头(user-info)
│
▼
放行,请求带着 userId 转发给下游
排除列表:哪些请求不用登录
商品查询、登录这些接口不需要 token,配在 excludePaths 里。黑马项目里这块放在配置文件中,通过 @ConfigurationProperties 绑定到 AuthProperties 类:
yaml
hm:
auth:
excludePaths:
- /search/**
- /users/login
- /items/**
- /hi
匹配用 AntPathMatcher,支持 /items/** 这种通配符。请求路径命中排除列表直接放行,后面的解析逻辑都不走。
为什么用 GlobalFilter,不用 GatewayFilter
鉴权是"所有请求都要过一遍"的事。用 GatewayFilter 得给每条路由都挂一遍配置,加一条路由就可能忘配一次;GlobalFilter 写一次,全部路由自动生效。原则还是那条:所有请求共性的逻辑,放全局。
getOrder() 里返回 -100 这类小值,让鉴权排在链条靠前------没通过鉴权的请求,不应该再往下走任何业务过滤器。
解析成功后,userId 怎么给下游
这是和上篇、和 Feign 篇都相关的关键点。Gateway 是 WebFlux,没有 ThreadLocal 这套同步上下文,userId 存不住,只能走请求头:
java
String userInfo = userId.toString();
ServerWebExchange swe = exchange.mutate()
.request(builder -> builder.header("user-info", userInfo))
.build();
return chain.filter(swe);
下游 cart-service 用一个普通 MVC 拦截器读这个头,存进自己的 ThreadLocal------从这一步开始,才回到熟悉的 Servlet 世界。
到这里,"网关鉴权 → 请求头透传 → 下游读取"的链路就通了。
四、踩坑:401、user-info 与一个写反的判断
401:看起来像 bug,其实是鉴权生效了
把排除列表里的 /carts/** 删掉之后,访问 http://localhost:8080/carts,返回:
json
{
"timestamp": "2026-10-09T07:37:36.947+00:00",
"path": "/carts",
"status": 401,
"error": "Unauthorized"
}
第一反应是"是不是哪里配错了",后来才意识到------401 是对的,因为我根本没带 token。
先看日志。发现 8080(网关)和 8082(cart-service)的控制台都没什么反应。当时以为是网关没转发,其实是网关直接返回了 401,请求压根没往下游走------8082 没反应才正常。
然后在 AuthGlobalFilter 里加了几行 log.debug:
java
log.debug("网关收到请求:{}", request.getPath());
log.debug("从请求头取到 token = {}", token);
log.debug("token 解析失败,返回 401。token = {}", token);
重启后访问 /carts,日志里确实出现了:
text
网关收到请求:/carts
从请求头取到 token = null
token 解析失败,返回 401。token = null
filter 确实跑了,只是 token 是 null。
想验证"带 token 能不能过",但问题来了------我没做 user-service,没有登录接口,拿不到 token。后来想了个办法:用 JwtTool 自己造一个 token(写个测试方法调 jwtTool.createToken(1L, ...)),复制到 Apifox 的 authorization 头里,再删掉排除列表里的 /carts/**。带 token 访问,200 通了。
401 这个"坑"其实不是坑------它是鉴权按预期工作的表现。401 不代表"有东西坏了",只代表"你没出示证件"。排查这类问题的关键判断:
- 401:鉴权没过,请求没到下游,去看网关的 filter 日志
- 404:路由匹配失败,请求没找到该去的地方,去看路由配置
- 500:请求到了下游,下游服务自己出错了,去看下游日志
三个状态码对应三个不同的排查方向,搞清楚了就能一步定位,不用到处翻。
user-info 透传:请求到了下游,但 userId 是空
这个坑出现在更早的阶段,当时用的是测试用的 MyGlobalFilter,里面打印了请求头:
text
headers:[User-Agent:"Apifox/1.0.0", Accept:"*/*", Host:"localhost:8080", ...]
问题:headers 里压根没有 user-info,说明网关没有透传 userId。
先去配置文件里查 /carts/** 是否还在排除列表。果然,之前为了验证路由临时加的那条忘了删------请求一进网关就被放行,压根没走鉴权逻辑,自然也不会写 user-info。删掉之后,情况变成了 401。这是上一条坑的入口------排除列表没删干净,是这两个坑共同的根源。
回到 401 的路径解决之后(带 token 访问),网关侧确实把 user-info 写进去了。但 cart-service 侧怎么确认它真收到了?在 CartController.queryMyCarts() 里加了一行:
java
@GetMapping
public List<CartVO> queryMyCarts(HttpServletRequest request) {
log.debug("cart-service 收到 user-info = {}", request.getHeader("user-info"));
// ...
}
重启,带 token 访问 /carts,日志里出现:
text
cart-service 收到 user-info = 1
透传真的通了。
请求头透传的排查思路可以用一句话概括:入口有没有写、中间有没有丢、出口有没有读。
- 入口:网关的 AuthGlobalFilter 有没有执行、有没有写 user-info
- 中间:有没有别的地方把它清掉了(排除列表、其他 filter、网络代理)
- 出口:下游服务的拦截器有没有被扫描到、有没有真的去读这个头
三处各加一行日志,一次请求跑下来,哪一环是空的立刻显形。这个思路不仅适用于 user-info,后面做链路追踪传 traceId、做多租户传 tenantId,排查方法完全一样。
顺带踩的坑:headers.isEmpty() 判断写反了
在 AuthGlobalFilter 里取 token 的那段代码,我一开始写成了:
java
List<String> headers = request.getHeaders().get("authorization");
if(headers != null && headers.isEmpty()){ // ← 逻辑反了
token = headers.get(0);
}
isEmpty() 为 true 表示列表是空的,空列表再去 get(0) 会直接抛 IndexOutOfBoundsException。
一开始带 token 访问,请求直接挂,日志里是 IndexOutOfBoundsException。看堆栈指向 token = headers.get(0) 这一行,才反应过来------isEmpty() 是"判空",不是"判非空"。正确写法:
java
if(headers != null && !headers.isEmpty()){ // 加感叹号
token = headers.get(0);
}
isEmpty() / isNotEmpty() 这类方法名,尤其是带 is 前缀的布尔判断,很容易在写的时候脑子里多转一道弯。"非空"要加 !,"非空"的反面才是"空"。写的时候可以顺便在旁边注释一下,或者用更直观的写法避开这类低级的逻辑反转 bug。
五、上下文透传:网关和 Feign 的分工
userId 要在整个调用链里活着,靠的是两段接力,各管一段:
text
外部请求
│ ① 网关:解析 JWT,写入 user-info 请求头
▼
cart-service ───────── ② Feign 调用 ─────────▶ trade-service
拦截器从 ThreadLocal 取 userId,
写进这个全新请求的头部
▼
trade-service 读 user-info → 存自己的 ThreadLocal
│
▼ (再调下游,重复 ② 的逻辑)
为什么两处都要做,缺一边都不行:
- 只做网关:cart-service 能拿到 userId,但它用 Feign 调 trade-service 时,Feign 发的是全新请求,头不会自动继承,userId 在第二跳就断了
- 只做 Feign 拦截器:请求刚进 cart-service 时,还没有任何人写过 user-info,拦截器从 ThreadLocal 里取到的只有 null
这个模式不只适用于 userId。链路追踪的 traceId、多租户的 tenantId、灰度发布的路由标签,走的都是同一套机制------入口写入、每跳接力、出口消费。以后接触 Sleuth、SkyWalking,看到的还是这个形状。"跨进程传上下文"是分布式系统的问题本身,不是某个框架的特性。
Feign 篇结尾说过"网关负责第一跳、Feign 负责后续跳",当时只是预告;到这里,两边真正拼上了。
六、动态路由:了解即可
网关的路由写在 application.yaml 里,改一条路径就得重启。学习阶段这不是问题------本地改配置重启,几秒钟的事。
生产环境有"不停机调整路由"的需求:灰度切流、临时屏蔽某个服务、紧急改转发规则。方案是把路由配置从本地文件搬到 Nacos:网关启动时从 Nacos 拉路由,之后监听配置变化,有变更就动态更新内存里的路由表。
涉及的技术点主要是 RouteDefinitionWriter------Gateway 提供的路由增删改接口,监听到 Nacos 变更事件后调用它刷新路由,全程不用重启。
这一节明确定位:加分项,不是必须项。本地开发为它折腾半天空槽配置,收益是负的。知道思路、面试能说清这段话就够了:
动态路由的核心是把路由配置从本地文件搬到 Nacos,Gateway 监听 Nacos 的配置变更事件,通过 RouteDefinitionWriter 动态更新路由表,不需要重启。
七、网关的边界与代价
两篇 Gateway 学完,好处可以列一串:统一入口、统一鉴权、统一跨域、统一限流。但架构上没有只占便宜的位置,网关的代价同样清楚。
多了一跳。 所有外部请求都要先到网关再转发,延迟叠加是物理事实。网关本身还成了吞吐瓶颈------它扛不住,后面的服务再强也没用。
单点风险。 网关挂了,整个系统对外不可用,不管内部服务有多健康。生产环境必须网关集群加高可用,这层"必须活着"的压力是架构赋予的。
职责膨胀。 这是最隐蔽的一条。鉴权放网关合理,慢慢地"顺便"做个参数校验、"顺便"编排一下聚合接口------每一步都看起来无害,最后网关里堆满业务逻辑,变成一个部署在入口处的分布式单体,改一个逻辑要重启全站入口。
边界怎么划,判断标准就一条:这个逻辑是不是所有请求都要做、且和业务无关。 是------鉴权、限流、日志、跨域,放网关;只有部分服务需要、或依赖业务规则的------订单怎么算、库存怎么扣,留在服务里。
把视野放到整个系列:Nacos 加了一层、Feign 加了一层、Gateway 又加了一层。每一层都在用"中间层"换解耦,也都在用复杂度和一跳延迟付账。架构设计说到底就是在解耦收益和层次成本之间做取舍------没有银弹,只有权衡。 这句话在网关这里体感最强,因为它就站在所有流量的必经之路上。
放到整个系列里,三篇各回答一个问题:Nacos------服务在哪;Feign------服务怎么调服务;Gateway------外部怎么调服务。 到这里,微服务的"通信骨架"基本齐了,剩下的问题------流量大了怎么扛、服务挂了怎么办------属于容错和治理的范畴,那是 Sentinel 的主场。