問題
在Spring Cloud項目中,前后端分離目前很常見,在調(diào)試時,會遇到兩種情況的跨域:
前端頁面通過不同域名或IP訪問微服務的后臺,例如前端人員會在本地起HttpServer 直連后臺開發(fā)本地起的服務,此時,如果不加任何配置,前端頁面的請求會被瀏覽器跨域限制攔截,所以,業(yè)務服務常常會添加如下代碼設置全局跨域:
@Bean
public CorsFilter corsFilter() {
logger.debug("CORS限制打開");
CorsConfiguration config = new CorsConfiguration();
# 僅在開發(fā)環(huán)境設置為*
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進行調(diào)試。此時,同樣會遇到跨域。需要在Gateway的配置文件中增加:
spring:
cloud:
gateway:
globalcors:
cors-configurations:
# 僅在開發(fā)環(huán)境設置為*
'[/**]':
allowedOrigins: "*"
allowedHeaders: "*"
allowedMethods: "*"
那么,此時直連微服務和網(wǎng)關(guān)的跨域問題都解決了,是不是很完美?
Spring Cloud 教程推薦:https://www.javastack.cn/categories/Spring-Cloud/
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 : *
,查看返回結(jié)果的頭:
不能用Chrome插件版,由于瀏覽器的限制,插件版設置Origin的Header是無效的
發(fā)現(xiàn)問題了:
Vary
和 Access-Control-Allow-Origin
兩個頭重復了兩次,其中瀏覽器對后者有唯一性限制!
分析
Spring Cloud Gateway是基于SpringWebFlux
的,所有web請求首先是交給DispatcherHandler
進行處理的,將HTTP請求交給具體注冊的handler去處理。
我們知道Spring Cloud Gateway進行請求轉(zhuǎn)發(fā),是在配置文件里配置路由信息,一般都是用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;
});
}
注:
網(wǎng)上有些關(guān)于修改Gateway的CORS設定的方式,是跟前面SpringBoot一樣,實現(xiàn)一個
CorsWebFilter
的Bean,靠寫代碼提供CorsConfiguration
,而不是修改Gateway的配置文件。其實本質(zhì),都是將配置交給corsProcessor去處理,殊途同歸。但靠配置解決永遠比hard code來的優(yōu)雅。
該方法把Gateway里定義的所有的 GlobalFilter
加載進來,作為handler返回,但在返回前,先進行CORS校驗,獲取配置后,交給corsProcessor去處理,即DefaultCorsProcessor
類
看下DefaultCorsProcessor
的process方法:
@Override
public boolean process(@Nullable CorsConfiguration config, ServerWebExchange exchange) {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
HttpHeaders responseHeaders = response.getHeaders();
List<String> varyHeaders = responseHeaders.get(HttpHeaders.VARY);
if (varyHeaders == null) {
// 第一次進來時,肯定是空,所以加了一次VERY的頭,包含ORIGIN, ACCESS_CONTROL_REQUEST_METHOD和ACCESS_CONTROL_REQUEST_HEADERS
responseHeaders.addAll(HttpHeaders.VARY, VARY_HEADERS);
}
else {
for (String header : VARY_HEADERS) {
if (!varyHeaders.contains(header)) {
responseHeaders.add(HttpHeaders.VARY, header);
}
}
}
if (!CorsUtils.isCorsRequest(request)) {
return true;
}
if (responseHeaders.getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN) != null) {
logger.trace("Skip: response already contains \"Access-Control-Allow-Origin\"");
return true;
}
boolean preFlightRequest = CorsUtils.isPreFlightRequest(request);
if (config == null) {
if (preFlightRequest) {
rejectRequest(response);
return false;
}
else {
return true;
}
}
return handleInternal(exchange, config, preFlightRequest);
}
// 在這個類里進行實際的CORS校驗和處理
protected boolean handleInternal(ServerWebExchange exchange,
CorsConfiguration config, boolean preFlightRequest) {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
HttpHeaders responseHeaders = response.getHeaders();
String requestOrigin = request.getHeaders().getOrigin();
String allowOrigin = checkOrigin(config, requestOrigin);
if (allowOrigin == null) {
logger.debug("Reject: '" + requestOrigin + "' origin is not allowed");
rejectRequest(response);
return false;
}
HttpMethod requestMethod = getMethodToUse(request, preFlightRequest);
List<HttpMethod> allowMethods = checkMethods(config, requestMethod);
if (allowMethods == null) {
logger.debug("Reject: HTTP '" + requestMethod + "' is not allowed");
rejectRequest(response);
return false;
}
List<String> requestHeaders = getHeadersToUse(request, preFlightRequest);
List<String> allowHeaders = checkHeaders(config, requestHeaders);
if (preFlightRequest && allowHeaders == null) {
logger.debug("Reject: headers '" + requestHeaders + "' are not allowed");
rejectRequest(response);
return false;
}
//此處添加了AccessControllAllowOrigin的頭
responseHeaders.setAccessControlAllowOrigin(allowOrigin);
if (preFlightRequest) {
responseHeaders.setAccessControlAllowMethods(allowMethods);
}
if (preFlightRequest && !allowHeaders.isEmpty()) {
responseHeaders.setAccessControlAllowHeaders(allowHeaders);
}
if (!CollectionUtils.isEmpty(config.getExposedHeaders())) {
responseHeaders.setAccessControlExposeHeaders(config.getExposedHeaders());
}
if (Boolean.TRUE.equals(config.getAllowCredentials())) {
responseHeaders.setAccessControlAllowCredentials(true);
}
if (preFlightRequest && config.getMaxAge() != null) {
responseHeaders.setAccessControlMaxAge(config.getMaxAge());
}
return true;
}
可以看到,在DefaultCorsProcessor
中,根據(jù)我們在appliation.yml
中的配置,給Response添加了 Vary
和 Access-Control-Allow-Origin
的頭。
再接下來就是進入各個GlobalFilter進行處理了,其中NettyRoutingFilter
是負責實際將請求轉(zhuǎn)發(fā)給后臺微服務,并獲取Response的,重點看下代碼中filter的處理結(jié)果的部分:
其中以下幾種header會被過濾掉的:
很明顯,在圖里的第3步中,如果后臺服務返回的header里有 Vary
和 Access-Control-Allow-Origin
,這時由于是putAll,沒有做任何去重就加進去了,必然會重復,看看DEBUG結(jié)果驗證一下:
驗證了前面的發(fā)現(xiàn)。
解決
解決的方案有兩種:
1. 利用 DedupeResponseHeader
配置:
spring:
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的值與我們自己設置的是同一個,例如生產(chǎn)環(huán)境設置的都是自己的域名xxx.com或者開發(fā)測試環(huán)境設置的都是*(瀏覽器中是無法設置Origin的值,設置了也不起作用,瀏覽器默認是當前訪問地址),那么可以選用
RETAIN_UNIQUE
策略,去重后返回到前端。 - 如果請求中設置的Oringin的值與我們自己設置的不是同一個,
RETAIN_UNIQUE
策略就無法生效,比如 ”*“ 和 ”xxx.com“是兩個不一樣的Origin,最終還是會返回兩個Access-Control-Allow-Origin
的頭。此時,看代碼里,response的header里,先加入的是我們自己配置的Access-Control-Allow-Origin
的值,所以,我們可以將策略設置為RETAIN_FIRST
,只保留我們自己設置的。
大多數(shù)情況下,我們想要返回的是我們自己設置的規(guī)則,所以直接使用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);
}
}
});
}));
}
}
此處有兩個地方要注意:
1)根據(jù)下圖可以看到,在取得返回值后,F(xiàn)ilter的Order
值越大,越先處理Response,而真正將Response返回到前端的,是 NettyWriteResponseFilter
, 我們要想在它之前修改Response,則Order
的值必須比NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER
大。
2)修改后置filter時,網(wǎng)上有些文字使用的是 Mono.defer
去做的,這種做法,會從此filter開始,重新執(zhí)行一遍它后面的其他filter,一般我們會添加一些認證或鑒權(quán)的 GlobalFilter
,就需要在這些filter里用ServerWebExchangeUtils.isAlreadyRouted(exchange)
方法去判斷是否重復執(zhí)行,否則可能會執(zhí)行二次重復操作,所以建議使用fromRunnable
避免這種情況。
作者:EdisonXu - 徐焱飛
來源:http://edisonxu.com/2020/10/14/spring-cloud-gateway-cors.html
近期熱文推薦:
1.1,000+ 道 Java面試題及答案整理(2022最新版)
2.勁爆!Java 協(xié)程要來了。。。
3.Spring Boot 2.x 教程,太全了!
4.別再寫滿屏的爆爆爆炸類了,試試裝飾器模式,這才是優(yōu)雅的方式?。?/p>
5.《Java開發(fā)手冊(嵩山版)》最新發(fā)布,速速下載!文章來源:http://www.zghlxwxcb.cn/news/detail-709739.html
覺得不錯,別忘了隨手點贊+轉(zhuǎn)發(fā)哦!文章來源地址http://www.zghlxwxcb.cn/news/detail-709739.html
到了這里,關(guān)于Spring Cloud 輕松解決跨域,別再亂用了!的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!