1. 跨域这件事,为什么在网关层解决最省心
做过前后端分离项目的人,几乎都被浏览器控制台里那行红字折磨过:Access to XMLHttpRequest at '...' from origin '...' has been blocked by CORS policy。这个报错本身不复杂,但它出现的位置往往很尴尬——前端同学说"我代码没问题,是后端没配",后端同学说"我接口用 Postman 测都通,是浏览器的问题",最后两边扯皮半天,问题还挂在那里。
跨域(CORS,Cross-Origin Resource Sharing)本质上是浏览器的一套安全策略:只要请求的协议、域名、端口三者中任意一个和当前页面不一致,浏览器就会拦截这个请求的响应,除非服务端明确通过响应头告诉浏览器"我允许这个来源访问"。注意这里的关键点——拦截动作是浏览器做的,服务端其实已经正常处理并返回了数据,只是浏览器不给前端 JS 读取而已。这也是为什么 Postman、curl 这类工具测起来一切正常,一到浏览器就翻车。
那为什么我强烈建议在 SpringCloudGateway 这一层统一处理跨域,而不是在每个微服务里各配各的?原因很实在:
- 微服务数量一多,重复配置就是灾难。假设你有用户服务、订单服务、商品服务、支付服务……十几个服务,每个都写一遍 CORS 配置,改一个允许域名要改十几处,漏一个就出问题。
- 网关是所有流量的唯一入口。前端请求先到网关,再由网关转发到具体服务。在入口处统一加上跨域响应头,后面的服务完全不用关心这件事,职责清晰。
- 预检请求(OPTIONS)能被集中拦截。浏览器发复杂请求前会先发一个 OPTIONS 预检,如果网关能直接响应掉,就不用把这个请求再转发到后端服务,省了一次无谓的转发开销。
所以这篇内容就是围绕"在 SpringCloudGateway 里怎么把跨域配好"展开的。我会把几种主流配置方式都拆开讲清楚,包括它们各自的适用场景、踩过的坑,以及为什么有些看起来能用的写法其实埋着雷。不管你是刚接触网关的新手,还是已经配过但总感觉哪里不对的老手,应该都能从里面找到点有用的东西。
2. 先把跨域请求的两种类型搞清楚,不然配置全是瞎猜
在动手写配置之前,有个基础认知必须先建立起来:浏览器发出的跨域请求分两种,简单请求和非简单请求,它们触发的浏览器行为完全不同。很多人配跨域配不明白,根源就在于没分清这两者。
2.1 简单请求:一次请求就完事
满足以下所有条件的请求,属于简单请求:
- 请求方法是
GET、HEAD、POST三者之一 - 请求头只包含安全字段,比如
Accept、Accept-Language、Content-Language、Content-Type(且值限于text/plain、multipart/form-data、application/x-www-form-urlencoded) - 没有自定义请求头
简单请求的处理流程很直接:浏览器直接发出请求,服务端返回响应,浏览器检查响应头里有没有Access-Control-Allow-Origin,有且匹配当前来源就放行,没有就拦截。整个过程只有一次网络往返。
2.2 非简单请求:先探路再通行
只要不满足上面任何一条,比如用了PUT、DELETE方法,或者带了Authorization、Content-Type: application/json这类头,浏览器就会先发一个OPTIONS 预检请求(preflight)。这个预检请求会带上:
Origin:当前页面来源Access-Control-Request-Method:真正请求要用的方法Access-Control-Request-Headers:真正请求要带的自定义头
服务端收到预检后,必须返回正确的响应头告诉浏览器"这个来源、这个方法、这些头我都允许",浏览器才会发真正的请求。如果预检没通过,真正的请求根本不会发出去。
这里有个特别容易踩的坑:很多后端框架默认不处理 OPTIONS 请求,或者网关转发 OPTIONS 到后端时后端返回 405,导致预检失败。而在网关层统一处理跨域,就能在网关直接把 OPTIONS 请求响应掉,压根不往后端转发,从根上避免这个问题。
| 对比项 | 简单请求 | 非简单请求 |
|---|---|---|
| 请求方法 | GET/HEAD/POST | PUT/DELETE/PATCH 等 |
| Content-Type | 三种表单类型 | application/json 等 |
| 自定义头 | 不允许 | 允许 |
| 是否预检 | 否 | 是,先发 OPTIONS |
| 网络往返 | 1 次 | 2 次(预检+真实请求) |
| 网关处理重点 | 响应头加 Allow-Origin | 拦截 OPTIONS 并返回预检响应头 |
理解了这套机制,你再看后面的配置就知道每一行在干什么了。简单说,网关要干两件事:给真实请求的响应加上跨域头,以及把预检请求在网关层直接处理掉。
3. 配置文件方式:最省事,但有几个参数必须配对
SpringCloudGateway 基于 Spring WebFlux,跨域配置可以直接写在application.yml里,这是最推荐、也最不容易出错的方式。核心配置项挂在spring.cloud.gateway.globalcors下面。
3.1 一份可以直接抄的完整配置
spring: cloud: gateway: globalcors: cors-configurations: '[/**]': allowedOriginPatterns: "*" allowedMethods: - GET - POST - PUT - DELETE - OPTIONS allowedHeaders: "*" allowCredentials: true maxAge: 3600这段配置的含义逐条拆解:
'[/**]':匹配所有路径的跨域规则,网关会对所有路由生效。allowedOriginPatterns:允许的来源。这里用*表示允许所有来源。allowedMethods:允许的 HTTP 方法,把 OPTIONS 也列进去,因为预检请求用的就是 OPTIONS。allowedHeaders:允许的请求头,*表示不限制。allowCredentials:是否允许携带凭证(Cookie、Authorization 头等)。maxAge:预检结果缓存时间,单位秒。3600 表示浏览器 1 小时内对同一接口不再重复发预检,能明显减少 OPTIONS 请求数量。
3.2 allowedOrigins 和 allowedOriginPatterns 的区别,这是最大的坑
上面配置里我用的是allowedOriginPatterns而不是allowedOrigins,这不是随便选的。当allowCredentials为true时,allowedOrigins不能设置为*,否则启动时直接报错:
When allowCredentials is true, allowedOrigins cannot contain the special value "*" since that cannot be set on the "Access-Control-Allow-Origin" response header.这是 CORS 规范的限制:如果允许携带凭证,服务端必须明确告诉浏览器具体是哪个来源,不能用通配符。而allowedOriginPatterns是 Spring 提供的增强版,它支持通配符写法,同时又能配合allowCredentials: true使用。所以只要你的项目需要带 Cookie 或 Token,就老老实实用allowedOriginPatterns。
如果你确实不需要携带凭证,那用allowedOrigins: "*"也行,但实际项目里带 Token 的场景太常见了,我建议直接统一用allowedOriginPatterns,省得以后改。
3.3 生产环境不要图省事写通配符
开发阶段用*没问题,但上线前一定要收紧。把允许的来源列成白名单:
allowedOriginPatterns: - "https://www.example-front.com" - "https://admin.example-front.com"这样只有指定的前端域名能跨域访问,其他来源一律被浏览器拦截。有人觉得"反正有 Token 校验,来源放开也没事",但来源白名单是一道额外的防线,能挡掉一部分 CSRF 类的攻击尝试,没理由不加。
注意:
allowedOriginPatterns里的域名不要带末尾斜杠,写成https://www.example-front.com/是匹配不上的,这个细节坑过不少人。
4. Java 代码方式:需要动态逻辑时再上
配置文件能满足 90% 的场景,但有些时候你需要更灵活的控制,比如根据请求头动态决定允许哪些来源、或者要在跨域处理里加点日志。这时候就得用 Java 配置类。
4.1 基于 CorsWebFilter 的全局配置
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.reactive.CorsWebFilter; import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource; import org.springframework.web.util.pattern.PathPatternParser; @Configuration public class GatewayCorsConfig { @Bean public CorsWebFilter corsWebFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(new PathPatternParser()); source.registerCorsConfiguration("/**", config); return new CorsWebFilter(source); } }这段代码和 YAML 配置的效果基本等价,但它是编程式的,你可以在里面加判断逻辑。比如根据请求的Origin头查数据库或配置中心,动态决定是否放行。
4.2 为什么这里用 PathPatternParser
注意UrlBasedCorsConfigurationSource的构造函数传了new PathPatternParser()。Spring WebFlux 里路径匹配有新旧两套解析器,老的是AntPathMatcher,新的是PathPatternParser。SpringCloudGateway 内部用的是新的PathPatternParser,如果你这里不显式指定,某些版本下会因为解析器不一致导致路径匹配不上,跨域配置看起来写了却不生效。这个坑比较隐蔽,表现是"配置明明在,就是不生效",排查起来很费劲。
4.3 代码方式和配置文件的取舍
我的经验是:能用配置文件就别写代码。配置文件改起来不用重新编译,运维也能直接调,出问题一眼能看出来。只有当配置项需要动态计算、或者要复用已有的业务逻辑时,才考虑用 Java 配置类。两者不要同时配,否则可能出现配置叠加导致的行为不一致,比如一个允许所有来源、另一个只允许特定来源,最终以哪个为准会让人很迷惑。
5. 过滤器方式:最灵活,但也最容易写出 bug
除了上面两种,还可以自己写一个全局过滤器GlobalFilter来手动添加跨域响应头。这种方式控制粒度最细,但我不太推荐新手用,因为很容易漏掉细节。
5.1 手写过滤器的典型实现
import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; @Component public class CustomCorsFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpResponse response = exchange.getResponse(); HttpHeaders headers = response.getHeaders(); headers.add("Access-Control-Allow-Origin", exchange.getRequest().getHeaders().getOrigin()); headers.add("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS"); headers.add("Access-Control-Allow-Headers", "*"); headers.add("Access-Control-Allow-Credentials", "true"); headers.add("Access-Control-Max-Age", "3600"); // 预检请求直接返回,不转发到后端 if (HttpMethod.OPTIONS.equals(exchange.getRequest().getMethod())) { return response.setComplete(); } return chain.filter(exchange); } @Override public int getOrder() { return Ordered.HIGHEST_PRECEDENCE; } }5.2 手写过滤器最容易翻车的三个点
第一个坑:Access-Control-Allow-Origin不能是*同时带凭证。上面代码里我用了exchange.getRequest().getHeaders().getOrigin()动态取来源,这样每个请求返回的都是具体来源,配合Allow-Credentials: true才合法。如果图省事写死*,带 Cookie 的请求照样被拦。
第二个坑:响应头重复添加。如果网关和后端服务都加了跨域头,浏览器会看到两个Access-Control-Allow-Origin,直接报错。手写过滤器时要么确保后端不再加,要么在过滤器里先headers.remove再add。我见过最离谱的情况是网关加一遍、Nginx 加一遍、后端框架再加一遍,三个头叠在一起,前端一脸懵。
第三个坑:过滤器顺序。getOrder()返回的值越小优先级越高。跨域过滤器必须排在很靠前的位置,否则请求可能已经被其他过滤器处理甚至拦截了,跨域头还没加上。用Ordered.HIGHEST_PRECEDENCE是最稳妥的。
5.3 三种方式怎么选
| 方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 配置文件 | 绝大多数常规场景 | 简单、直观、易维护 | 动态能力弱 |
| Java 配置类 | 需要动态判断来源 | 灵活、可编程 | 需重新编译 |
| 自定义过滤器 | 需要精细控制响应头 | 控制力最强 | 易出错、维护成本高 |
我的建议很明确:优先配置文件,不够用再上 Java 配置类,自定义过滤器作为最后手段。绝大多数项目的跨域需求,一份 YAML 就搞定了,没必要把简单问题复杂化。
6. 配完之后不生效?按这个顺序排查
跨域配置最让人抓狂的地方在于:它不报错,就是不生效。你改了配置、重启了服务,浏览器还是那行红字。下面这套排查顺序是我踩了无数次坑之后总结出来的,基本能覆盖 95% 的情况。
6.1 第一步:确认请求到底有没有到网关
打开浏览器开发者工具的 Network 面板,看那个失败的请求。如果状态是(failed)或者CORS error,先看它有没有 Response。如果连响应都没有,可能是网关根本没收到请求,问题出在网络层或路由配置,跟跨域无关。如果有响应但被拦,那才是跨域头的问题。
6.2 第二步:看预检请求的响应头
对于非简单请求,Network 里会有一个 OPTIONS 请求。点开它看 Response Headers,重点检查:
- 有没有
Access-Control-Allow-Origin,值是不是当前来源 - 有没有
Access-Control-Allow-Methods,里面有没有你实际用的方法 - 有没有
Access-Control-Allow-Headers,里面有没有你实际带的头
缺哪个补哪个。特别注意Access-Control-Allow-Headers,如果你前端带了自定义头(比如X-Token),这里必须明确列出来或者用*,否则预检直接失败。
6.3 第三步:检查是否有重复的跨域头
在 Response Headers 里搜一下Access-Control-Allow-Origin,如果出现两次,说明有多处在加跨域头。常见组合是网关加了一次、后端服务的@CrossOrigin注解又加了一次。解决办法是只保留一处,通常保留网关的,把后端的跨域配置去掉。
6.4 第四步:确认配置的加载顺序和优先级
如果你同时用了配置文件和 Java 配置类,可能会出现覆盖问题。Spring 的加载顺序可能导致你以为生效的配置其实被另一个覆盖了。排查方法是把其中一种先注释掉,只留一种,看是否生效,以此定位冲突源。
6.5 第五步:留意网关自身的路由断言
有时候跨域配置没问题,但请求被网关的路由断言拦下了,返回 404 或 503,浏览器同样会报跨域错误(因为错误响应里没有跨域头)。这时候要看网关日志,确认请求有没有匹配到正确的路由。跨域和路由是两个独立的问题,别混在一起排查。
一个实用技巧:用 curl 直接请求网关,加上
-H "Origin: https://your-front.com" -H "Access-Control-Request-Method: POST" -X OPTIONS,看返回的响应头。这样能排除浏览器的干扰,直接看到服务端到底返回了什么,比在浏览器里猜快得多。
7. 几个真实项目里踩出来的经验
配置本身讲完了,最后分享几个只有真正在项目里趟过才知道的细节,这些在官方文档里基本不会写。
关于 maxAge 的设置。maxAge设太短,浏览器频繁发预检,接口响应时间翻倍;设太长,改了跨域配置后浏览器还缓存着旧结果,你以为没生效其实是缓存。我的经验是开发环境设 0 或者干脆不设,方便调试;生产环境设 3600 到 7200 之间,平衡性能和灵活性。
关于多环境配置。开发、测试、生产的前端域名往往不一样,别把域名写死在配置里。用 Spring 的 profile 机制,application-dev.yml、application-prod.yml各配各的,或者用占位符从环境变量注入。我见过有人把生产域名写进了开发配置,结果测试环境跨域一直失败,查了半天。
关于网关和 Nginx 的分工。如果网关前面还有一层 Nginx,跨域到底在哪层处理要想清楚。我的做法是统一在网关处理,Nginx 只做转发和负载均衡,不碰跨域头。这样职责单一,出问题好定位。如果两层都加,就会出现前面说的重复响应头问题。
关于 WebSocket 的跨域。WebSocket 握手阶段也会涉及跨域,但它的机制和普通 HTTP 请求不一样,globalcors配置对 WebSocket 不一定生效。如果项目里用了 WebSocket,需要单独确认握手请求的Origin校验逻辑,别想当然地以为 HTTP 跨域配好了 WebSocket 就没事。
关于安全审计。上线前用安全扫描工具跑一遍,重点看Access-Control-Allow-Origin是不是被设成了*且带凭证,这是典型的安全隐患。正确的做法永远是白名单加动态回显来源,而不是无脑通配。
这套东西我在好几个项目里反复用过,从单体到微服务,从简单的前后端分离到带网关的复杂架构,核心逻辑始终没变:搞清楚浏览器要什么头,在网关这一层给全,别让请求带着问题往后走。配置方式选哪种不重要,重要的是理解每种方式背后的机制,这样出了问题你才知道从哪下手。