CORS 是一個 W3C 標准,全稱是"跨域資源共享"(Cross-origin resource sharing)。它允許瀏覽器向跨源服務器,發出 XMLHttpRequest 請求,從而克服了 AJAX 只能同源使用的限制。本文主要介紹 CORS 的基本使用,文中所使用到的軟件版本:Chrome 90.0.4430.212、jquery 1.12.4,Spring Boot 2.4.4、jdk1.8.0_181。
1、CORS 簡介
CORS 需要瀏覽器和服務器同時支持。目前,所有瀏覽器都支持該功能,IE瀏覽器不能低於 IE10。整個 CORS 通信過程,都是瀏覽器自動完成,不需要用戶參與。對於開發者來說,CORS 通信與同源的 AJAX 通信沒有差別,代碼完全一樣。瀏覽器一旦發現 AJAX 請求跨源,就會自動添加一些附加的頭信息,有時還會多出一次附加的請求,但用戶不會有感覺。因此,實現 CORS 通信的關鍵是服務器。只要服務器實現了 CORS 接口,就可以跨源通信。
1.1、兩種請求
瀏覽器將 CORS 請求分成兩類:簡單請求(simple request)和非簡單請求(not-so-simple request)。只要同時滿足以下兩大條件,就屬於簡單請求。
(1)請求方法是以下三種方法之一:
HEAD
GET
POST
(2)HTTP的頭信息不超出以下幾種字段:
Accept
Accept-Language
Content-Language
Last-Event-ID
Content-Type:只限於三個值 application/x-www-form-urlencoded、multipart/form-data、text/plain
這是為了兼容表單(form),因為歷史上表單一直可以發出跨域請求。AJAX 的跨域設計就是,只要表單可以發,AJAX 就可以直接發。凡是不同時滿足上面兩個條件,就屬於非簡單請求。瀏覽器對這兩種請求的處理,是不一樣的。
1.2、簡單請求
1.2.1、基本流程
對於簡單請求,瀏覽器直接發出 CORS 請求。瀏覽器發現跨源 AJAX 請求是簡單請求,就自動在頭信息之中,添加一個 Origin 字段。
Host: localhost:8081
Origin: http://localhost:8080
Pragma: no-cache
User-Agent: Mozilla/5.0 ...
Connection: keep-alive
上面的頭信息中,Origin
字段用來說明本次請求來自哪個源(協議 + 域名 + 端口)。服務器根據這個值,決定是否同意這次請求。如果 Origin
指定的源,不在許可范圍內,服務器會返回一個正常的HTTP 響應。瀏覽器發現,這個響應的頭信息沒有包含 Access-Control-Allow-Origin
字段,就知道出錯了,從而拋出一個錯誤,被 XMLHttpRequest
的 onerror
回調函數捕獲。注意,這種錯誤無法通過狀態碼識別,因為 HTTP 響應的狀態碼有可能是 200。如果 Origin
指定的域名在許可范圍內,服務器返回的響應,會多出幾個頭信息字段。
Access-Control-Allow-Credentials: true Access-Control-Allow-Origin: http://localhost:8080 Access-Control-Expose-Headers: my-response-header
Connection: keep-alive
Content-Length: 12
Content-Type: text/plain;charset=UTF-8
上面的頭信息之中,有三個與 CORS 請求相關的字段,都以 Access-Control-
開頭。
(1) Access-Control-Allow-Origin
該字段是必須的。它的值要么是請求時 Origin 字段的值,要么是一個 *,表示接受任意域名的請求。
(2) Access-Control-Allow-Credentials
該字段可選。它的值是一個布爾值,表示是否允許發送 Cookie。默認情況下,Cookie 不包括在 CORS 請求之中。設為 true,即表示服務器明確許可,Cookie 可以包含在請求中一起發給服務器。這個值也只能設為 true,如果服務器不要瀏覽器發送 Cookie,刪除該字段即可。
(3) Access-Control-Expose-Headers
該字段可選。CORS 請求時,XMLHttpRequest 對象的 getResponseHeader() 方法只能拿到6個基本字段:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。如果想拿到其他字段,就必須在 Access-Control-Expose-Headers 里面指定。上面的例子指定,getResponseHeader('myResponseHeader') 可以返回 myResponseHeader 字段的值。
1.2.2、withCredentials 屬性
CORS 請求默認不發送 Cookie 和HTTP認證信息。如果要把 Cookie 發到服務器,一方面要服務器同意,指定 Access-Control-Allow-Credentials
字段。
Access-Control-Allow-Credentials: true
另一方面,必須在 AJAX 請求中打開 withCredentials
屬性。
let xhr = new XMLHttpRequest(); xhr.withCredentials = true
否則,即使服務器同意發送 Cookie,瀏覽器也不會發送。或者,服務器要求設置 Cookie,瀏覽器也不會處理。如果省略 withCredentials 設置,有的瀏覽器還是會一起發送 Cookie;可以顯式關閉withCredentials。
xhr.withCredentials = false;
需要注意的是,如果要發送 Cookie,Access-Control-Allow-Origin 就不能設為星號,必須指定明確的、與請求網頁一致的域名。同時,Cookie 依然遵循同源政策,只有用服務器域名設置的 Cookie 才會上傳,其他域名的 Cookie 並不會上傳,且(跨源)原網頁代碼中的 document.cookie 也無法讀取服務器域名下的 Cookie。
1.3、非簡單請求
1.3.1、預檢請求
非簡單請求是那種對服務器有特殊要求的請求,比如請求方法是 PUT 或 DELETE,或者 Content-Type 字段的類型是 application/json。非簡單請求的 CORS 請求,會在正式通信之前,增加一次 HTTP 查詢請求,稱為"預檢"請求(preflight)。瀏覽器先詢問服務器,當前網頁所在的域名是否在服務器的許可名單之中,以及可以使用哪些 HTTP 動詞和頭信息字段。只有得到肯定答復,瀏覽器才會發出正式的 XMLHttpRequest 請求,否則就報錯。
下面是預檢請求的頭信息:
Access-Control-Request-Headers: my-request-header Access-Control-Request-Method: POST Cache-Control: no-cache Connection: keep-alive Host: localhost:8081 Origin: http://localhost:8080
除了 Origin 字段,預檢請求的頭信息包括兩個特殊字段。
(1)Access-Control-Request-Method
該字段是必須的,用來列出瀏覽器的 CORS 請求會用到哪些HTTP方法,上例是 POST。
(2)Access-Control-Request-Headers
該字段是一個逗號分隔的字符串,指定瀏覽器 CORS 請求會額外發送的頭信息字段,上例是 my-request-header
1.3.2、預檢請求的響應
服務器收到預檢請求以后,檢查了 Origin、Access-Control-Request-Method 和 Access-Control-Request-Headers 字段以后,確認允許跨源請求,就可以做出回應。
Access-Control-Allow-Credentials: true Access-Control-Allow-Headers: my-request-header Access-Control-Allow-Methods: GET,POST Access-Control-Allow-Origin: http://localhost:8080 Access-Control-Expose-Headers: my-response-header Access-Control-Max-Age: 1800 Allow: GET, HEAD, POST, PUT, DELETE, OPTIONS, PATCH Connection: keep-alive Content-Length: 0 Date: Fri, 04 Jun 2021 02:55:51 GMT Keep-Alive: timeout=60
上面的 HTTP 回應中,關鍵的是 Access-Control-Allow-Origin
字段,表示 http://localhost:8080 可以請求數據。該字段也可以設為星號,表示同意任意跨源請求。
如果服務器否定了預檢請求,會返回一個正常的 HTTP 回應,但是沒有任何 CORS 相關的頭信息字段。這時,瀏覽器就會認定,服務器不同意預檢請求,因此觸發一個錯誤,被 XMLHttpRequest 對象的 onerror 回調函數捕獲。
預檢請求響應其他 CORS 相關字段:
(1)Access-Control-Allow-Methods
該字段必需,它的值是逗號分隔的一個字符串,表明服務器支持的所有跨域請求的方法。注意,返回的是所有支持的方法,而不單是瀏覽器請求的那個方法。這是為了避免多次"預檢"請求。
(2)Access-Control-Allow-Headers
如果瀏覽器請求包括 Access-Control-Request-Headers 字段,則 Access-Control-Allow-Headers 字段是必需的。它也是一個逗號分隔的字符串,表明服務器支持的所有頭信息字段,不限於瀏覽器在預檢中請求的字段。
(3)Access-Control-Max-Age
該字段可選,用來指定本次預檢請求的有效期,單位為秒。上面結果中,有效期是 1800 秒,即允許緩存該條響應 1800 秒,在此期間,不用發出另一條預檢請求。
(4)Access-Control-Allow-Credentials
該字段與簡單請求時的含義相同。
(5)Access-Control-Expose-Headers
該字段與簡單請求時的含義相同。
1.3.3、預檢請求后簡單請求
一旦服務器通過了預檢請求,以后每次瀏覽器正常的 CORS 請求,就都跟簡單請求一樣。下面是預檢請求之后,正常的 CORS 請求:
Cache-Control: no-cache Connection: keep-alive Content-Length: 11 Content-Type: application/x-www-form-urlencoded Host: localhost:8081 my-request-header: 123 Origin: http://localhost:8080
服務器正常請求的響應:
Access-Control-Allow-Credentials: true Access-Control-Allow-Origin: http://localhost:8080 Access-Control-Expose-Headers: my-response-header Connection: keep-alive Content-Length: 12 Content-Type: text/plain;charset=UTF-8
2、樣例
2.1、后台實現(SpringBoot 版)
先編寫后台 Controller:
package com.abc.demo.controller; import org.springframework.web.bind.annotation.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @RequestMapping("/corstest") @RestController public class CorsTestController { @RequestMapping("/hello") public String hello(String name, HttpServletRequest request, HttpServletResponse response) { System.out.println("my-request-header=" + request.getHeader("my-request-header")); response.addHeader("my-response-header","abc"); return "hello," + name; } }
使用 SpringBoot 來實現 CORS,有三種實現方式,選其中一種即可。
2.1.1、使用 @CrossOrigin 注解
在 Controller 中使用 @CrossOrigin 注解。
package com.abc.demo.controller; import org.springframework.web.bind.annotation.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @RequestMapping("/corstest") @RestController public class CorsTestController { @CrossOrigin(origins = "http://localhost:8080", allowCredentials = "true", methods = {RequestMethod.GET, RequestMethod.POST}, allowedHeaders = {"my-request-header", "my-request-header2", "content-type"}, exposedHeaders = {"my-response-header"}) @RequestMapping("/hello") public String hello(String name, HttpServletRequest request, HttpServletResponse response) { System.out.println("my-request-header=" + request.getHeader("my-request-header")); response.addHeader("my-response-header","abc"); return "hello," + name; } }
2.1.2、實現 WebMvcConfigurer 接口
package com.abc.demo.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedMethods("GET", "POST") .allowedOrigins("http://localhost:8080") .allowedHeaders("my-request-header", "my-request-header2", "content-type") .exposedHeaders("my-response-header") .allowCredentials(true); } }
2.1.3、實現 Filter 接口
package com.abc.demo.filter; import org.springframework.stereotype.Component; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @Component @WebFilter(filterName = "coreFilter", urlPatterns = "/*") public class CoreFilter implements Filter { @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { HttpServletResponse response = (HttpServletResponse) servletResponse; response.addHeader("Access-Control-Allow-Origin", "http://localhost:8080"); response.addHeader("Access-Control-Allow-Credentials", "true"); response.addHeader("Access-Control-Expose-Headers", "my-response-header"); if (((HttpServletRequest) servletRequest).getMethod().equals("OPTIONS")) { response.addHeader("Access-Control-Allow-Methods", "GET,POST"); response.addHeader("Access-Control-Allow-Headers", "my-request-header,my-request-header2,content-type"); response.addHeader("Access-Control-Max-Age", "1800"); } filterChain.doFilter(servletRequest, servletResponse); } }
2.2、前台實現
2.2.1、原生寫法(corstest.html)
<!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <title>cors 測試</title> </head> <body> </body> <script type="text/javascript"> // IE8/9需用window.XDomainRequest兼容 let xhr = new XMLHttpRequest(); xhr.withCredentials = true; xhr.open('post', 'http://localhost:8081/corstest/hello', true); xhr.setRequestHeader('Content-Type','application/x-www-form-urlencoded'); //xhr.setRequestHeader('Content-Type', 'application/json'); xhr.setRequestHeader('my-request-header', '123'); xhr.send('name=李白'); xhr.onreadystatechange = function() { if (xhr.readyState == 4 && xhr.status == 200) { alert(xhr.responseText + "|" + xhr.getResponseHeader('my-response-header')); } } </script> </html>
2.2.2、Jquery寫法(corstestJquery.html)
<!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <title>cors 測試</title> </head> <body> </body> <script type="text/javascript" src="./jquery-1.12.4.min.js"></script> <script type="text/javascript"> $(function(){ $.ajax({ url: 'http://localhost:8081/corstest/hello', type: 'post', contentType: 'application/x-www-form-urlencoded', headers: { 'my-request-header': '123' }, data: { name: '李白' }, xhrFields: { withCredentials: true }, crossDomain: true, success: function(data, status, xhr) { alert(data + '|' + xhr.getResponseHeader('my-response-header')); } }); }); </script> </html>
3、測試
把 corstest.html 和 corstestJquery.html 放到 tomcat(端口:8080) 的 webapps\ROOT 下,並啟動 SpringBoot 應用(端口:8081)。
參考:http://www.ruanyifeng.com/blog/2016/04/cors.html