做在线教育系统的时候,最烦的一件事就是前端每调一个服务,就得处理一遍登录校验、跨域、权限、请求转发这些事。后来把 Spring Cloud Gateway 网关整合进项目里,让 Vue 前端只认一个入口,所有请求统一交给 Gateway 去路由、过滤、限流,整个项目一下就清晰了。这篇文章把我这次整合 Gateway 的完整过程记录下来,包括路由怎么设计、过滤器怎么写、前端怎么配合,以及那些我踩过以后才彻底想明白的坑。
这篇文章适合两类人看:一类是正在用 SpringCloud 写在线教育、电商这类微服务项目的开发者;另一类是刚开始接触 Gateway、想知道它在真实项目中怎么落地、不想只看官方文档的人。我会把每一步背后的“为什么”也尽量讲清楚,而不是直接甩一段配置。
1. 在线教育项目的微服务拆分与网关角色定位
1.1 在线教育系统的典型微服务划分
我做的在线教育网站不是那种纯静态展示页,它包含了用户注册登录、课程浏览、课程购买、在线视频播放、学习进度记录、订单支付这一整条业务链路。如果全部塞进一个 SpringBoot 单体应用里,前期开发确实快,但一旦课程量、用户量上来,光是一个视频播放服务把 CPU 占满,就可能拖垮整个下单流程。
所以项目是照着微服务来拆的。大致分成了这几个服务:
| 服务名 | 核心职责 | 典型端口 |
|---|---|---|
| 用户服务 | 注册、登录、JWT签发、用户信息管理 | 8100 |
| 课程服务 | 课程分类、课程列表、课程详情 | 8200 |
| 订单服务 | 下单、订单状态流转、退款申请 | 8300 |
| 支付服务 | 对接第三方支付、支付回调处理 | 8400 |
| 视频服务 | 视频上传、转码信息查询、播放凭证 | 8500 |
| 学习服务 | 学习进度记录、收藏、评论 | 8600 |
服务拆开之后,挺明显的一个问题是:Vue 前端到底怎么访问?如果让前端直接请求各个服务的地址,那 axios 里就得维护五六个 baseURL,后端一旦调整部署端口,前端也得跟着改,线上排查问题的时候,看请求日志都费劲。
更麻烦的是,用户服务里要做 JWT 校验,课程服务和学习服务也要校验登录状态,如果每个服务都自己写一套过滤器,代码重复不说,改一个校验逻辑得同时改五六个服务。网关就是为这个问题存在的:把公共的东西统一放到入口层处理。
1.2 为什么选择 Gateway 而不是 Zuul
做方案选型的时候,我特意对比过 Zuul 和 Spring Cloud Gateway。这里说下最终结论:Gateway 是当前 SpringCloud 生态下的默认选择,Zuul 1.x 已经是维护状态,Zuul 2.x 的落地成本又比较高。我选 Gateway 更看重的是它基于 WebFlux 和 Netty,用的是异步非阻塞模型,对于视频播放这类高并发长连接的请求场景,线程资源利用率更高。
网关本质上就是所有流量的集散地,它最怕的是线程被慢接口拖住。如果用的是传统 Servlet 模型,每个请求占一个线程,请求一多、某个下游服务一慢,线程池很快就满了。Gateway 的 Netty 模型在应对这种“IO 密集、等待时间长”的场景时,占用的系统资源会小很多。
另外一个原因是 Gateway 和 Nacos 配合得很好。我的服务注册中心用的就是 Nacos,Gateway 可以通过lb://服务名这样的方式直接做负载均衡,服务实例上下线以后网关能自动感知到,不用重启网关,这一点在后面动态路由里会详细讲。
1.3 网关在整体请求链路中的位置
加网关以后,线上请求路径是这样的:
浏览器 → Vue静态资源(Nginx) → Gateway网关 → 各个微服务前端请求全部打到网关统一地址,网关层做完登录校验、跨域处理、限流之后,再按路由规则转发到对应的业务服务。这样做的好处很直观:
- 前端只维护一个网关地址,路径前缀根据模块区分;
- 业务服务不需要再重复写登录校验逻辑;
- 跨域只发生在浏览器到网关这一层,服务与服务之间用内网地址通信,不受跨域限制;
- 限流、灰度、日志记录这类横切能力,都可以在网关层集中实现。
当然,这也意味着网关是链路上的关键节点,所以部署的时候我做了多实例,前面再用 Nginx 做一层负载均衡。后面我会专门讲高可用部署的注意事项。
2. 整合 Gateway 网关的基础依赖与第一步配置
2.1 添加依赖与启动类改造
项目用的 SpringBoot 版本是 2.7.x,对应的 SpringCloud 版本是 2021.0.x,SpringCloud Alibaba 版本是 2021.0.5.0。这几个版本号看着不起眼,但真的很容易配错,我一开始就用错了组合导致启动报错,排查了半天。
新建一个微服务工程,pom.xml里的核心依赖如下:
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>这里有一个特别容易踩的坑:Gateway 工程里不要加spring-boot-starter-web依赖。Gateway 底层是 WebFlux,如果你同时把 SpringMVC 的 starter-web 引进来,启动会直接报错,提示 Spring MVC 和 WebFlux 冲突。很多新手第一次搭网关就是在这里卡住的。
启动类的写法跟普通 SpringBoot 服务一样:
@SpringBootApplication @EnableDiscoveryClient public class GatewayApplication { public static void main(String[] args) { SpringApplication.run(GatewayApplication.class, args); } }正常启动以后,网关会把自己注册到 Nacos,同时从 Nacos 拉取其他服务的实例列表。但是要注意,这时候它还只是个“空壳”,没有任何路由规则,前端请求打过来是 404。
2.2 基于 Nacos 服务发现实现动态路由
路由是网关的核心。我的做法是给每个微服务都定义一条路由规则,统一以/api/模块名/**作为前缀,这样前端只需要记住一个网关地址,再加上模块前缀即可。
核心配置如下:
spring: application: name: gateway-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 namespace: education-dev gateway: routes: - id: user-service-route uri: lb://user-service predicates: - Path=/api/user/** filters: - StripPrefix=1 - id: course-service-route uri: lb://course-service predicates: - Path=/api/course/** filters: - StripPrefix=1 - id: order-service-route uri: lb://order-service predicates: - Path=/api/order/** filters: - StripPrefix=1 - id: video-service-route uri: lb://video-service predicates: - Path=/api/video/** filters: - StripPrefix=1 - id: learn-service-route uri: lb://learn-service predicates: - Path=/api/learn/** filters: - StripPrefix=1 - id: payment-service-route uri: lb://payment-service predicates: - Path=/api/payment/** filters: - StripPrefix=1 discovery: locator: enabled: true lower-case-service-id: true配置里uri: lb://user-service这一段是关键。lb://前缀表示 Gateway 会从 Nacos 拿到user-service的所有实例,再通过负载均衡选择一个实例转发。服务实例新增、减少的时候,网关会自动感知,不需要手动改配置文件,这就是动态路由的价值。
2.3 路由匹配规则与断言配置
再细说一下Path断言和StripPrefix的关系。很多初学者会在这块对不上号。
比如我有一个请求:
GET /api/course/1它经过网关路由course-service-route时,Path=/api/course/**匹配成功。此时请求路径里有三段:api、course、1。StripPrefix=1的意思是去掉第一段api,转发给下游服务的路径变成:
/course/1如果课程服务的 Controller 是这样写的:
@RestController @RequestMapping("/course") public class CourseController { @GetMapping("/{id}") public Result getCourse(@PathVariable Long id) { ... } }那这个请求就能正确落到对应方法上。如果StripPrefix=2,路径会被去掉api和course两段,变成/1,那就要匹配@RequestMapping("/{id}")这类写法了。
我习惯把模块前缀放在路径里,再用StripPrefix=1把/api剥掉,这样下游每个服务里的接口路径不需要为网关做特殊改造,Controller 里怎么写,网关就怎么转发,前后端对接口的时候心智负担小。
注意:如果你用
context-path给服务设置了前缀,那就得结合实际情况调整 StripPrefix 的数值,最好的办法是转发后自己打印一下下游收到的路径。
除了 Path 断言,Gateway 还有其他断言可以用,比如Method限制请求方法、Header检查请求头、Query校验参数。我目前用得最多的就是 Path 和 Method,其他场景等真遇到了再添加,不要一开始就把配置搞得很复杂。
3. 网关路由设计如何配合 Vue 前端
3.1 Vue 前端的请求封装与服务地址切换
网关这边的路由规则定好以后,前端就得跟着调整。我的 Vue 项目用的 axios 做网络请求,一般会在src/utils/request.js里统一封装。
开发环境的时候,本地 Vue 服务和网关不在同一个端口,直接发/api/user/login这种相对路径会请求到 Vue 的开发服务器上,然后报 404。所以前端需要配代理:
// vue.config.js module.exports = { devServer: { port: 8080, proxy: { '/api': { target: 'http://localhost:8899', // Gateway 网关地址 changeOrigin: true } } } };生产环境更简单,前端构建成静态文件以后由 Nginx 托管,Nginx 里把/api/开头的请求反向代理到网关地址,前端代码里统一用/api/模块名/**就够了。
我在 request.js 里只维护一个 baseURL:
const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, // 例如 /api timeout: 10000 });然后每个功能模块再去拼接自己的路径:
- 登录:
service.post('/user/login', data) - 获取课程列表:
service.get('/course/list', params) - 创建订单:
service.post('/order/create', data) - 查询学习进度:
service.get('/learn/progress', params)
这样前端代码里不会出现具体的 IP 地址和端口,环境切换时只需要改环境变量或者 Nginx 配置,非常省心。
3.2 统一前缀与路径重写的完整对应关系
我整理了一份前端、网关、后端服务三者之间的路径对照表,方便团队内部对接口时对齐:
| 前端请求路径 | 网关匹配路由 | 网关转发到服务路径 | 后端接口 |
|---|---|---|---|
| /api/user/login | /api/user/** | /user/login | 用户服务 /user/login |
| /api/course/list | /api/course/** | /course/list | 课程服务 /course/list |
| /api/order/create | /api/order/** | /order/create | 订单服务 /order/create |
| /api/video/play | /api/video/** | /video/play | 视频服务 /video/play |
| /api/learn/progress | /api/learn/** | /learn/progress | 学习服务 /learn/progress |
| /api/payment/callback | /api/payment/** | /payment/callback | 支付服务 /payment/callback |
这张表的核心逻辑是:网关负责把统一前缀/api/模块名拆开,只把真正属于后端服务的路径转发过去。这样设计有很强的扩展性,以后如果新增一个“优惠券服务”,网关里加一条 route,前端直接请求/api/coupon/**,两边都不需要大改。
3.3 前后端联调时常见的匹配问题
联调阶段最容易出问题的不是后端逻辑,而是路径对不上。
第一个常见问题:前端写了/api/user/login,网关也匹配了/api/user/**,但后端返回 404,查日志发现 Gateway 转发过去的是/user/login,而后端 Controller 写的是/api/user/login。这就是后端又多写了一层/api前缀,导致重复。解决办法是后端去掉这个前缀,或者网关里StripPrefix=0。
第二个常见问题:前端如果直接从某个页面发请求,没有走 axios 封装,写的是http://localhost:8080/api/course/list,在开发环境下这个请求会被 Vue 代理到网关,看起来没问题,但到了生产环境,这个地址还需要改成网关的公网地址。所以我在项目里强制要求所有请求必须走 request.js,不允许页面里直接写完整地址。
第三个常见问题:登录接口返回的 token 是放在响应体里的,前端拿到以后手动存到 localStorage,然后每次请求的时候在 axios 请求拦截器里加上Authorization: Bearer <token>这个头。网关过滤器拿到这个头做校验,校验通过放行。这套流程看着简单,但最容易漏的是“图片上传、文件下载”这种用表单或 iframe 发起的请求,它们不会自动带上 Authorization 头,导致 401。我当时的处理是给这些接口单独走白名单或者用签名参数,后面过滤器那一节会细讲。
4. 网关上的统一鉴权、跨域与限流
4.1 全局过滤器实现 JWT 令牌校验
登录鉴权是网关层最有价值的功能。之前每个服务都要写一套校验,现在统一收敛到 Gateway 的全局过滤器里。
我的过滤器逻辑分两步:
@Component public class AuthGlobalFilter implements GlobalFilter, Ordered { private static final String WHITE_LIST = "/api/user/login,/api/user/register,/api/course/list,/api/course/detail"; @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String path = request.getURI().getPath(); // 白名单直接放行 if (WHITE_LIST.contains(path)) { return chain.filter(exchange); } // 从请求头获取 token String token = request.getHeaders().getFirst("Authorization"); if (token == null || !token.startsWith("Bearer ")) { return unauthorizedResponse(exchange); } // 解析 token try { Claims claims = JwtUtil.parseToken(token.replace("Bearer ", "")); // 把用户信息放进请求头,传递给下游服务 ServerHttpRequest mutatedRequest = request.mutate() .header("X-User-Id", claims.get("userId").toString()) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } catch (Exception e) { return unauthorizedResponse(exchange); } } @Override public int getOrder() { return -100; // 优先级最高,最先执行 } private Mono<Void> unauthorizedResponse(ServerWebExchange exchange) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); exchange.getResponse().getHeaders().add("Content-Type", "application/json;charset=UTF-8"); byte[] bytes = "{\"code\":401,\"message\":\"登录状态已失效\"}".getBytes(StandardCharsets.UTF_8); return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(bytes))); } }关键点我标注一下:
- 白名单要动态维护。如果所有接口都强制校验,那么登录接口本身就会被拦,因为用户还没有 token。我先写了静态字符串,实际项目里我把它放到了 Nacos 配置里,改白名单不用重启网关。
- 解析 token 后要把用户信息传递下去。我用的是
X-User-Id请求头,下游服务只需要从请求头里拿这个字段,就可以知道当前用户是谁,不需要再解析一次 token。 - 返回体要保持格式统一。前端 axios 响应拦截器统一判断
code === 200,如果网关返回的格式跟后端 Result 不一致,前端就要写两套逻辑。所以我规定网关的 401 返回体也必须包含code、message字段。
4.2 跨域配置的正确写法
跨域在微服务架构下很头大。如果是 Vue 直接请求多个服务地址,那每个服务都要配@CrossOrigin,或者写一个 CORS 配置类。有了网关以后,我只在网关配置跨域就可以了,因为浏览器只跟网关交互。
我的跨域配置是 Java 配置类:
@Configuration public class CorsConfig { @Bean public CorsWebFilter corsWebFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOrigin("http://localhost:8080"); config.addAllowedOrigin("https://edu.example.com"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsWebFilter(source); } }这里有两个容易出错的地方。
第一,不要直接写config.addAllowedOrigin("*")然后又把setAllowCredentials(true)打开。浏览器规范里,Allow-Credentials和*不能同时使用。如果你在本地调试时前端要带 Cookie,就必须指定具体的 Origin 地址,或者用 pattern 来匹配。
第二,预检请求 OPTIONS 会被我自己写的鉴权过滤器拦住。浏览器跨域请求在正式请求之前,会先发一个携带Access-Control-Request-Method头的 OPTIONS 请求,如果鉴权过滤器把它当作正常请求校验 token,那就会返回 401,前端显示跨域失败。我的 AuthGlobalFilter 里专门加了一段:
// 放行预检请求 if (request.getMethod() == HttpMethod.OPTIONS) { return chain.filter(exchange); }加了这段之后,跨域才算真正顺畅。
4.3 基于令牌桶的请求限流
在线教育场景里,限流主要针对两个场景:一是获取课程列表、课程详情这类高频读接口,防止恶意脚本频繁刷;二是视频播放接口,防止有人循环拉流把带宽打满。
Gateway 内置的RequestRateLimiter过滤器可以配合 Redis 做令牌桶限流。引入依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis-reactive</artifactId> </dependency>然后在路由上增加限流过滤器:
- id: course-service-route uri: lb://course-service predicates: - Path=/api/course/** filters: - StripPrefix=1 - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 20 redis-rate-limiter.burstCapacity: 40 key-resolver: "#{@userKeyResolver}"replenishRate表示每秒补充多少个令牌,burstCapacity表示令牌桶最大容量。形象点说,就像景区门票,每秒补 20 张票,桶里最多存 40 张,所以瞬时最多能放行 40 个请求,但持续请求时是每秒 20 个。
KeyResolver用来区分不同的限流对象,我把它定为“用户 ID + 请求路径”的组合:
@Bean public KeyResolver userKeyResolver() { return exchange -> { String userId = exchange.getRequest().getHeaders().getFirst("X-User-Id"); String path = exchange.getRequest().getURI().getPath(); if (userId == null) { userId = "anonymous"; } return Mono.just(userId + ":" + path); }; }这样用户 A 刷爆了课程列表接口,用户 B 的请求不受影响,不同接口也各自独立计数。限流触发后,Gateway 默认返回 429 状态码,前端 axios 的响应拦截器里做一下提示:
if (error.response && error.response.status === 429) { ElMessage.error('请求太频繁,请稍后再试'); }5. 网关整合过程中的问题排查与经验总结
5.1 路由 404:被忽略的 StripPrefix
我在前面反复提到 StripPrefix,是因为它真是坑点高发区。
有一次前端调用/api/user/info,网关返回 404,后端说日志里根本没收到请求。我用curl直接试了一下网关,发现网关倒是能匹配到路由,但请求最后转发了两次,路径变成了/api/user/info,后端用户服务根本没有这个路径。
问题出在用户服务自己也加了server.servlet.context-path=/api/user,这就导致网关转发的路径和后端 context-path 重复叠加。我后来把网关的 StripPrefix 改成 0,直接透传整段路径,后端的 context-path 保留了完整前缀,两边就对上了。
所以排查 404 的时候,第一件事不是改代码,而是看网关日志里转发的最终路径是什么。日志打出来,问题基本就一目了然。
5.2 网关与 Nacos 服务发现的版本坑
Gateway 整合 Nacos 的过程中,我遇到过一次很奇怪的现象:网关启动正常,路由也配置了,但请求一直报Unable to load instance for user-service。查了半天,发现是 Nacos 的namespace不一致。
我的 Nacos 上有 dev、test、prod 几个命名空间,微服务都在education-dev这个命名空间下注册,但网关配置里没指定 namespace,用的默认 public,所以网关在 public 里找不到user-service的实例。
解决方式就是在网关的 yml 里明确指定:
spring: cloud: nacos: discovery: namespace: education-dev group: EDUCATION_GROUP同时确保所有微服务注册时用的group也一致。Nacos 的命名空间和分组,本质上是把一个注册中心隔离成多个逻辑区域,一旦服务注册和网关订阅的区域不一致,就会出现“服务注册了但网关找不到”的诡异情况。
5.3 过滤器生效顺序与白名单设计
网关如果有多个过滤器,执行顺序是由getOrder()决定的,数值越小优先级越高。我把 JWT 鉴权过滤器的 order 设为 -100,保证它在业务过滤器之前执行。因为一旦先经过了路由转发,再去校验登录就没意义了。
白名单的设计也需要反复斟酌。我的原则是:凡是用户未登录也可以访问的接口,都必须进白名单;凡是涉及用户私有数据的接口,一律校验。在线教育项目里,典型的白名单包括:
- 登录、注册、发送验证码
- 课程分类、课程列表、课程详情
- 视频播放凭证(需要签名,但不依赖登录态)
- 支付回调(由第三方支付服务器调用,没有用户 token)
支付回调这个点很容易被忽略。如果网关要求所有请求都带 Bearer token,那支付服务收到的回调请求因为没带 token,就被网关挡住了。我的处理是把/api/payment/callback/**加入白名单,同时再用 IP 校验的方式做一层保护。
5.4 网关高可用与部署注意点
网关是无状态的,这一点特别重要。因为它把所有状态都放到了请求头、token 和 Redis 里,所以可以水平扩展。
我在部署时起了两个网关实例,前面用 Nginx 做负载均衡:
upstream gateway_cluster { server 10.0.0.11:8899; server 10.0.0.12:8899; } server { listen 80; location /api/ { proxy_pass http://gateway_cluster; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里有个小细节:proxy_pass http://gateway_cluster;末尾没有带路径,这样 Nginx 会把完整的/api/...路径透传给网关。如果写成proxy_pass http://gateway_cluster/;,URL 里的/api会被吃掉,网关就匹配不到路由了。
生产环境一般还会在网关外层加上 CDN 或者 WAF,但这属于部署架构层面的扩展,不在这次整合的核心范围内。我的经验是先保证网关这一层能稳定运行,再逐步加外围防护。
5.5 从单体改造到网关的心得
这次整合 Gateway 最大的感受是:网关不是加了就完事,它倒逼你把接口设计规范起来。
以前单体应用里,接口路径随便写,/login 也有,/userLogin 也有,集成网关以后,如果路径不统一,路由规则就会变得异常臃肿。我花了整整一天时间,把后端所有接口都梳理了一遍,统一成/模块/功能的格式,前端和网关的对接才顺畅起来。
另外,日志很重要。Gateway 作为入口,最好在过滤器里记录每次请求的完整信息:请求路径、转发目标、耗时、状态码。我封装过一个简单的日志过滤器,后面排查问题基本全靠它。
结尾的一点经验
把网关整合到在线教育项目里,我用了一个小技巧:开发环境直接关掉网关的限流,免得本地调试的时候接口莫名其妙 429;到了测试环境和生产环境再把限流参数调出来,按照真实压测数据慢慢调整。
如果你正在做的也是 Vue + SpringCloud 的在线教育或者电商项目,我建议尽早引入网关,最好从项目搭建的第一天就放进来。我见过很多项目是服务拆好了、接口也写完了,最后才想起来加网关,结果改前端路径、改后端 context-path、调过滤器,折腾得比重新写一个还累。
还有一个实在的建议:网关和 Nacos 的版本组合一定要提前查好对应关系。SpringBoot、SpringCloud、SpringCloud Alibaba 这三个版本号是绑在一起的,稍微对不上,启动时的报错信息又长又抽象,排查起来极其浪费时间。最好直接用一个经过验证的统一 BOM 版本,省下的时间用来多写几条路由多舒服。