前言
前后端分離的開發模式越來越流行,目前絕大多數的公司與項目都采取這種方式來開發,它的好處是前端可以只專注于頁面實現,而后端則主要負責接口開發,前后端分工明確,彼此職責分離,不再高度耦合,但是由于這種開發模式將前后端項目分開來獨立部署,所以將必不可免的會碰到跨域問題.
什么是跨域
跨域指的是瀏覽器不能執行其他網站的腳本.它是由瀏覽器的同源策略造成的,是瀏覽器對javascript施加的安全限制.目前所有的瀏覽器都實行同源策略,所謂的同源指的是
- 協議相同
- 域名相同
- 端口相同
是否跨域判定如下:
http://www.123.com/index.html 調用 http://www.123.com/server.php(非跨域)
http://www.123.com/index.html 調用 http://www.456.com/server.php(主域名不同:123/456,跨域)
http://abc.123.com/index.html 調用 http://def.123.com/server.php(子域名不同:abc/def,跨域)
http://www.123.com:8080/index.html 調用 http://www.123.com:8081/server.php(端口不同:8080/8081,跨域)
http://www.123.com/index.html 調用 https://www.123.com/server.php(協議不同:http/https,跨域)
請注意:localhost和127.0.0.1雖然都指向本機,但也屬于跨域.
瀏覽器執行javascript腳本時,會檢查這個腳本屬于哪個頁面,如果不是同源頁面,就不會被執行.
如何解決
常見的解決方案有JSONP和CORS等多種方式,這里我們采用了CORS的方式來統一處理項目中的跨域問題.
cors是一種w3c標準,全稱是"跨域資源共享(Cross-origin resource sharing)".它允許瀏覽器向跨源服務器發出XMLHttpRequest請求,從而克服了同源使用的限制.下面重點來看下我們在SpringBoot項目中使用cors處理跨域時所遇到的問題.
首先創建一個WebMvcConfig類繼承自WebMvcConfigurerAdapter類并覆寫其中的addCorsMappings方法
/**
* 允許跨域訪問
*
* @param registry
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**").allowedOrigins("*")
.allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("Accept", "Origin", "X-Requested-With", "Content-Type",
"Last-Modified", "device", "token")
.exposedHeaders("Set-Cookie")
.allowCredentials(true).maxAge(3600);
}
接著我們在之前搭建好的Vue項目環境中創建一個vue文件,用來向springboot項目發起跨域請求.
<template>
<div>
<input v-model="message" placeholder="edit me"/>
<p>Message is: {{ message }}</p>
<input type="button" @click="get()" value="測試">
</div>
</template>
<script>
export default {
data () {
return {
message: 'This is my from'
}
},
methods:{
get:function(){
$.ajax({
url:'http://localhost:8080/win/api/test/cors',//測試接口
type:'post',
beforeSend:(xhr) => {
xhr.setRequestHeader("token", "web_session_key-082ba2a3-7d8e-407c-8bd0-2fbc430b0dbf");
xhr.setRequestHeader("device","APP");
},
success:function(data){
console.log(data);
}
});
}
}
}
</script>
然后啟動前端vue項目
npm run dev
打開瀏覽器輸入http://localhost:8081/點擊頁面中的測試按鈕發起接口調用,注意vue工程的端口為8081,springboot工程的端口為8080,不符合同源規則所以訪問后端接口時會出現跨域.此時打開chrome控制臺會發現
通過chrome控制臺查看接口請求的headers信息是這樣的
可以看到這個接口的Request Method為OPTIONS,而不是我們在ajax代碼中所設置的POST,仔細看控制臺報的異常Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.可以發現其中的關鍵字preflight(預檢請求),這個其實就是問題的所在了,再次google了一下cors的相關知識,才知道原來瀏覽器將cors請求分為兩類即簡單請求和非簡單請求.
簡單請求
只要同時滿足以下條件就屬于簡單請求
請求方法是以下三種方法之一:GET POST HEAD
Http的頭信息不超出以下幾種字段:Accept Accept-Language Content-Language Last-Event-ID Content-Type 只限于三個值application/x-www-form-urlencoded、multipart/form-data、text/plain
非簡單請求
其他的請求皆屬于非簡單請求.
注意在cors定義中,如果頭信息中的Content-Type不設置,則默認值為json/application,如果Content-Type值不為application/x-www-form-urlencoded,multipart/form-data或text/plain,都被視為非簡單請求,即預檢請求.
主要問題描述
瀏覽器對這兩種請求的處理是不一樣的.
如果是簡單請求的話,一次完整的請求過程是不需要服務端預檢的,直接響應回客戶端,而非簡單請求則瀏覽器會在發送真正請求之前先用OPTIONS發送一次預檢請求preflight request,從而獲知服務端是否允許該跨域請求,當服務器確認允許之后,才會發起真正的請求.那么前面所出現的異常很明顯就是由這個preflight request導致的了,回頭看代碼可發現我們在發起ajax調用時往請求頭里面塞了兩個自定義的header參數token和device,所以此次調用屬于非簡單請求,并觸發了一次預檢請求,由于我們服務端使用了shiro權限認證框架,通過攔截用戶請求頭中傳過來的token信息來判定客戶端是否為非法調用,而Preflight請求過程中并不會攜帶我們自定義的token信息到服務器,這樣服務器校驗就永遠也無法通過了,就算是合法的登錄用戶也會被攔截.
解決的辦法
既然已經知道原因了,那么自然解決這個問題的辦法就是交給后端了,在后端檢測到該請求為預檢請求時,不讓它繼續往下走(也可以返回一個2xx的狀態碼),直接告訴瀏覽器此次跨域請求可以繼續,很明顯過濾器符合我們的要求,我們來把之前的addCorsMappings方法去掉,重寫這塊代碼,在網上查了很久,嘗試了很多種方法,得出來的結論大致有如下兩種方式:
第一種方案采用過濾器的機制
@Bean
public FilterRegistrationBean corsFilter() {
return new FilterRegistrationBean(new Filter() {
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
String method = request.getMethod();
// this origin value could just as easily have come from a database
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Max-Age", "3600");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Allow-Headers", "Accept, Origin, X-Requested-With, Content-Type,Last-Modified,device,token");
if ("OPTIONS".equals(method)) {//檢測是options方法則直接返回200
response.setStatus(HttpStatus.OK.value());
} else {
chain.doFilter(req, res);
}
}
public void init(FilterConfig filterConfig) {
}
public void destroy() {
}
});
}
第二種方案
1.創建一個類MyCorsRegistration繼承自CorsRegistration
public class MyCorsRegistration extends CorsRegistration {
public MyCorsRegistration(String pathPattern) {
super(pathPattern);
}
@Override
public CorsConfiguration getCorsConfiguration() {
return super.getCorsConfiguration();
}
}
2.然后在WebMvcConfig類中增加一個方法里來處理跨域問題.
@Bean
public FilterRegistrationBean filterRegistrationBean() {
// 對響應頭進行CORS授權
MyCorsRegistration corsRegistration = new MyCorsRegistration("/**");
corsRegistration.allowedOrigins("*")
.allowedMethods(HttpMethod.GET.name(), HttpMethod.HEAD.name(), HttpMethod.POST.name(),
HttpMethod.PUT.name(), HttpMethod.OPTIONS.name())
.allowedHeaders("Accept", "Origin", "X-Requested-With", "Content-Type",
"Last-Modified", "device", "token")
.exposedHeaders(HttpHeaders.SET_COOKIE)
.allowCredentials(true)
.maxAge(3600);
// 注冊CORS過濾器
UrlBasedCorsConfigurationSource configurationSource = new UrlBasedCorsConfigurationSource();
configurationSource.registerCorsConfiguration("/**", corsRegistration.getCorsConfiguration());
CorsFilter corsFilter = new CorsFilter(configurationSource);
return new FilterRegistrationBean(corsFilter);
}
其實第二種方案本質上也是過濾器的機制,看源碼可知CorsFilter繼承自spring的過濾器OncePerRequestFilter,來看看它的doFilterInternal方法
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (CorsUtils.isCorsRequest(request)) {
CorsConfiguration corsConfiguration = this.configSource.getCorsConfiguration(request);
if (corsConfiguration != null) {
boolean isValid = this.processor.processRequest(corsConfiguration, request, response);
if (!isValid || CorsUtils.isPreFlightRequest(request)) {
return;
}
}
}
filterChain.doFilter(request, response);
}
代碼大致意思是先判斷是否為cors請求再判斷是否預檢請求,符合條件則直接return了.
我們來看看最后的請求結果,控制臺再也沒有報錯了,瀏覽器一共發送了兩次接口請求,第一次是OPTIONS請求,第二次是POST請求.
OPTIONS請求:
POST請求:
從上面第一個圖中可以看到這個預檢請求主要攜帶的請求header信息如下(并沒有攜帶我們自定義的header信息,第二個圖中可以清楚的看到我們自定義的header信息了):
Access-Control-Request-Headers:device,token 真實請求攜帶的Header中的信息
Access-Control-Request-Method:POST 我真實請求的方法是什么
Origin:http://localhost:8081 告訴服務器我的域名
然后是服務器端給我們返回的Preflight Response:
Access-Control-Allow-Credentials:true
Access-Control-Allow-Headers:Accept, Origin, X-Requested-With, Content-Type,Last-Modified,device,token
Access-Control-Allow-Methods:GET, HEAD, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Origin:*
Access-Control-Max-Age:3600
值得注意的是上面的Access-Control-Max-Age這個header,它告訴了服務器在多少秒內,不需要再發送預檢請求,可以緩存該結果,即只發送真正的請求,不同瀏覽器有不同的上限.在Firefox中,上限是24h(即86400秒),而在Chrome中則是10min(即600秒).Chrome同時規定了一個默認值5秒.如果值為-1,則表示禁用緩存,每一次請求都需要提供預檢請求,即用OPTIONS請求進行檢測.它對完全一樣的url的緩存設置生效,多一個參數也視為不同url.也就是說,如果設置了10分鐘的緩存,在10分鐘內,所有請求第一次會產生options請求,第二次以及第二次以后就只發送真正的請求了,這也是一個很有效的性能優化手段(另外注意下在chrome瀏覽器中如果開啟了Disable cache,則表示本地不緩存,會導致每次請求都發一次預檢測).