最近在做框架迁移工作,前后端联调时发现,前端同事本地调用后端服务,提示跨域问题,之前也没接触过,在网上搜了很多解决方案,最后找到以下 CORS 方案,解决了跨域问题,与大家分享:
在 Spring Cloud 项目中,前后端分离目前很常见,在调试时,会遇到两种情况的跨域:
- 前端页面通过不同域名或 IP 访问微服务的后台,例如前端人员会在本地起 HttpServer 直连后台开发本地起的服务,此时,如果不加任何配置,前端页面的请求会被浏览器跨域限制拦截,所以,业务服务常常会添加如下代码设置全局跨域:
@Bean
public CorsFilter corsFilter() {
logger.debug("CORS限制打开");
CorsConfiguration config = new CorsConfiguration();
# 仅在开发环境设置为*
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
configSource.registerCorsConfiguration("/**", config);
return new CorsFilter(configSource);
}
- 前端页面通过不同域名或 IP 访问 SpringCloud Gateway,例如前端人员在本地起 HttpServer 直连服务器的 Gateway 进行调试。此时,同样会遇到跨域。需要在 Gateway 的配置文件中增加:
spring:
cloud:
gateway:
globalcors:
cors-configurations:
# 仅在开发环境设置为*
'[/**]':
allowedOrigins: "*"
allowedHeaders: "*"
allowedMethods: "*"
那么,此时直连微服务和网关的跨域问题都解决了,是不是很完美?
No~ 问题来了,前端仍然会报错:“不允许有多个’Access-Control-Allow-Origin’ CORS 头”。
Access to XMLHttpRequest at 'http://192.168.2.137:8088/api/two' from origin 'http://localhost:3200' has been blocked by CORS policy:
The 'Access-Control-Allow-Origin' header contains multiple values '*, http://localhost:3200', but only one is allowed.
仔细查看返回的响应头,里面包含了两份 Access-Control-Allow-Origin 头。
我们用客户端版的 PostMan 做一个模拟,在请求里设置头:Origin : *
,查看返回结果的头:
发现问题了:
Vary
和 Access-Control-Allow-Origin
两个头重复了两次,其中浏览器对后者有唯一性限制!
分析
- Spring Cloud Gateway 是基于
SpringWebFlux
的,所有 web 请求首先是交给DispatcherHandler
进行处理的,将 HTTP 请求交给具体注册的 handler 去处理。
我们知道 Spring Cloud Gateway 进行请求转发,是在配置文件里配置路由信息,一般都是用 url predicates 模式,对应的就是RoutePredicateHandlerMapping
。所以,DispatcherHandler
会把请求交给 RoutePredicateHandlerMapping.
- 那么,接下来看下
RoutePredicateHandlerMapping.getHandler(ServerWebExchange exchange)
方法,默认提供者是其父类AbstractHandlerMapping
:
@Override
public Mono<Object> getHandler(ServerWebExchange exchange) {
return getHandlerInternal(exchange).map(handler -> {
if (logger.isDebugEnabled()) {
logger.debug(exchange.getLogPrefix() + "Mapped to " + handler);
}
ServerHttpRequest request = exchange.getRequest();
// 可以看到是在这一行就进行CORS判断,两个条件:
// 1. 是否配置了CORS,如果不配的话,默认是返回false的
// 2. 或者当前请求是OPTIONS请求,且头里包含ORIGIN和ACCESS_CONTROL_REQUEST_METHOD
if (hasCorsConfigurationSource(handler) || CorsUtils.isPreFlightRequest(request)) {
CorsConfiguration config = (this.corsConfigurationSource != null ? this.corsConfigurationSource.getCorsConfiguration(exchange) : null);
CorsConfiguration handlerConfig = getCorsConfiguration(handler, exchange);
config = (config != null ? config.combine(handlerConfig) : handlerConfig);
//此处交给DefaultCorsProcessor去处理了
if (!this.corsProcessor.process(config, exchange) || CorsUtils.isPreFlightRequest(request)) {
return REQUEST_HANDLED_HANDLER;
}
}
return handler;
});
}
注:
网上有些关于修改 Gateway 的 CORS 设定的方式,是跟前面 SpringBoot 一样,实现一个CorsWebFilter
的 Bean,靠写代码提供 CorsConfiguration
,而不是修改 Gateway 的配置文件。其实本质,都是将配置交给 corsProcessor 去处理,殊途同归。但靠配置解决永远比 hard code 来的优雅。
该方法把 Gateway 里定义的所有的 GlobalFilter
加载进来,作为 handler 返回,但在返回前,先进行 CORS 校验,获取配置后,交给 corsProcessor 去处理,即DefaultCorsProcessor
类
- 看下
DefaultCorsProcessor
的 process 方法:
可以看到,在DefaultCorsProcessor
中,根据我们在appliation.yml
中的配置,给 Response 添加了 Vary
和 Access-Control-Allow-Origin
的头。
- 再接下来就是进入各个 GlobalFilter 进行处理了,其中
NettyRoutingFilter
是负责实际将请求转发给后台微服务,并获取 Response 的,重点看下代码中 filter 的处理结果的部分:
其中以下几种 header 会被过滤掉的:
很明显,在图里的第 3 步中,如果后台服务返回的 header 里有 Vary
和 Access-Control-Allow-Origin
,这时由于是 putAll,没有做任何去重就加进去了,必然会重复,看看 DEBUG 结果验证一下:
验证了前面的发现。
解决
解决的方案有两种:
1. 利用 DedupeResponseHeader
配置:
cloud:\
gateway:\
globalcors:\
cors-configurations:\
'[/**]':\
allowedOrigins: "*"\
allowedHeaders: "*"\
allowedMethods: "*"\
default-filters:\
- DedupeResponseHeader=Vary Access-Control-Allow-Origin Access-Control-Allow-Credentials, RETAIN_FIRST\
DedupeResponseHeader
加上以后会启用DedupeResponseHeaderGatewayFilterFactory
在其中,dedupe
方法可以按照给定策略处理值
private void dedupe(HttpHeaders headers, String name, Strategy strategy) {
List<String> values = headers.get(name);
if (values == null || values.size() <= 1) {
return;
}
switch (strategy) {
// 只保留第一个
case RETAIN_FIRST:
headers.set(name, values.get(0));
break;
// 保留最后一个
case RETAIN_LAST:
headers.set(name, values.get(values.size() - 1));
break;
// 去除值相同的
case RETAIN_UNIQUE:
headers.put(name, values.stream().distinct().collect(Collectors.toList()));
break;
default:
break;
}
}
- 如果请求中设置的 Origin 的值与我们自己设置的是同一个,例如生产环境设置的都是自己的域名 xxx.com 或者开发测试环境设置的都是 *(浏览器中是无法设置 Origin 的值,设置了也不起作用,浏览器默认是当前访问地址),那么可以选用
RETAIN_UNIQUE
策略,去重后返回到前端。 - 如果请求中设置的 Oringin 的值与我们自己设置的不是同一个,
RETAIN_UNIQUE
策略就无法生效,比如 ”*“和”xxx.com“是两个不一样的 Origin, 最终还是会返回两个Access-Control-Allow-Origin
的头。此时,看代码里,response 的 header 里,先加入的是我们自己配置的Access-Control-Allow-Origin
的值,所以,我们可以将策略设置为RETAIN_FIRST
,只保留我们自己设置的。
大多数情况下,我们想要返回的是我们自己设置的规则,所以直接使用RETAIN_FIRST
即可。实际上,DedupeResponseHeader
可以针对所有头,做重复的处理。
2. 手动写一个 CorsResponseHeaderFilter
的 GlobalFilter
去修改 Response 中的头。
@Component
public class CorsResponseHeaderFilter implements GlobalFilter, Ordered {
private static final Logger logger = LoggerFactory.getLogger(CorsResponseHeaderFilter.class);
private static final String ANY = "*";
@Override
public int getOrder() {
// 指定此过滤器位于NettyWriteResponseFilter之后
// 即待处理完响应体后接着处理响应头
return NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER + 1;
}
@Override
@SuppressWarnings("serial")
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
exchange.getResponse().getHeaders().entrySet().stream()
.filter(kv -> (kv.getValue() != null && kv.getValue().size() > 1))
.filter(kv -> (kv.getKey().equals(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)
|| kv.getKey().equals(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS)
|| kv.getKey().equals(HttpHeaders.VARY)))
.forEach(kv ->
{
// Vary只需要去重即可
if(kv.getKey().equals(HttpHeaders.VARY))
kv.setValue(kv.getValue().stream().distinct().collect(Collectors.toList()));
else{
List<String> value = new ArrayList<>();
if(kv.getValue().contains(ANY)){ //如果包含*,则取*
value.add(ANY);
kv.setValue(value);
}else{
value.add(kv.getValue().get(0)); // 否则默认取第一个
kv.setValue(value);
}
}
});
}));
}
}
此处有两个地方要注意:
- 根据下图可以看到,在取得返回值后,Filter 的
Order
值越大,越先处理 Response,而真正将 Response 返回到前端的,是NettyWriteResponseFilter
, 我们要想在它之前修改 Response,则Order
的值必须比NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER
大。
spring-cloud-gateway-fliter-order.png
- 修改后置 filter 时,网上有些文字使用的是
Mono.defer
去做的,这种做法,会从此 filter 开始,重新执行一遍它后面的其他 filter,一般我们会添加一些认证或鉴权的GlobalFilter
,就需要在这些 filter 里用ServerWebExchangeUtils.isAlreadyRouted(exchange)
方法去判断是否重复执行,否则可能会执行二次重复操作,所以建议使用fromRunnable
避免这种情况。
转载
SpringCloudGateway CORS 方案看这篇就够了
- 时刻保持危机感,才能进步。
- 天赋是上帝给予的,要谦虚。名声是别人给予的,要感激。自负是自己给的,要小心。——约翰 • 伍登
- 比你优秀的人比你更努力,芽儿哟,这是最骚的