Talk is cheap. Show me the money.
前言
你好,我是YourBatman。
做Web開發的小夥伴對“跨域”定并不陌生,像狗皮膏藥一樣粘着幾乎每位同學,對它可謂既愛又恨。跨域請求之于創業、小型公司來講是個頭疼的問題,因為這類企業還未沉澱出一套行之有效的、統一的解決方案。
讓人擔憂的是,據我了解不少程式員同學(不乏有進階開發)碰到跨域問題大都一頭霧水:

然後很自然的 用谷歌去百度一下搜尋答案,但相關文章可能參差不齊、魚龍混雜。短則半天長則一天(包含改代碼、部署等流程)此問題才得以解決,一個“小小跨域”問題成功偷走你的寶貴時間。
既然跨域是個如此常見(特别是當下前後端分離的開發模式),是以深入了解CORS變得就異常的重要了(反倒前端工程師不用太了解),是以早在2019年我剛開始寫部落格那會就有過較為詳細的系列文章:
現在把它搬到公衆号形成技術專欄,并且加點料,讓它更深、更全面、更系統的幫助到你,希望可以助你從此不再怕Cors跨域資源共享問題。
本文提綱
版本約定
- JDK:8
- Servlet:4.x
正文
文章遵循一貫的風格,本文将采用概念 + 代碼示例的方式,層層遞進的進行展開叙述。那麼上菜,先來個示例預覽,模拟一下跨域請求,後面的一些的概念示例将以此作為抓手。
模拟跨域請求
要模拟跨域請求的根本是需要兩個源:讓請求的來源和目标源不一樣。這裡我就使用IDEA作為靜态Web伺服器(63342),Tomcat作為後端動态Servlet伺服器(8080)。
說明:伺服器都在本機,端口不一樣即可
前端代碼
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>CORS跨域請求</title>
<!--導入Jquery-->
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>
</head>
<body>
<button id="btn">跨域從服務端擷取内容</button>
<div id="content"></div>
<script>
$("#btn").click(function () {
// 跨域請求
$.get("http://localhost:8080/cors", function (result) {
$("#content").append(result).append("<br/>");
});
// 同域請求
$.get("http://localhost:63342");
$.post("http://localhost:63342");
});
</script>
</body>
</html>
複制
使用IDEA作為靜态web伺服器,浏覽器輸入位址即可通路(注:端口号為63342):
後端代碼
後端寫個Servlet來接收cors請求
/**
* 在此處添加備注資訊
*
* @author YourBatman. <a href=mailto:[email protected]>Send email to me</a>
* @site https://yourbatman.cn
* @date 2021/6/9 10:36
* @since 0.0.1
*/
@Slf4j
@WebServlet(urlPatterns = "/cors")
public class CorsServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String requestURI = req.getRequestURI();
String method = req.getMethod();
String originHeader = req.getHeader("Origin");
log.info("收到請求:{},方法:{}, Origin頭:{}", requestURI, method, originHeader);
resp.getWriter().write("hello cors...");
}
}
複制
啟動後端伺服器,點選頁面上的按鈕,結果如下:
服務端控制台輸出:
... INFO c.y.cors.servlet.CorsServlet - 收到請求:/cors,方法:GET, Origin頭:http://localhost:63342
複制
服務端輸出日志,說明即使前端的Http Status是error,但服務端還是收到并處理了這個請求的
下面以此代碼示例為基礎,普及一下和Cors跨域資源共享相關的概念。
Host、Referer、Origin的差別
這哥三看起來很是相似,下面對概念作出區分。
- Host:去哪裡。域名+端口。值為用戶端将要通路的遠端主機,浏覽器在發送Http請求時會帶有此Header
- Referer:來自哪裡。協定+域名+端口+路徑+參數。目前請求的來源頁面的位址,服務端一般使用 Referer 首部識别通路來源,可能會以此進行統計分析、日志記錄以及緩存優化等
- 常見應用場景:百度的搜尋廣告就會分析Referer來判斷打開站點是從百度搜尋跳轉的,還是直接URL輸入位址的
- 一般情況下浏覽器會帶有此Header,但這些case不會帶有Referer這個頭
- 來源頁面協定為File或者Data URI(如頁面從本地打開的)
- 來源頁面是Https,而目标URL是http
- 浏覽器位址欄直接輸入網址通路,或者通過浏覽器的書簽直接通路
- 使用JS的kk跳轉
- …
- Origin:來自哪裡(跨域)。協定+域名+端口。它用于Cors請求和同域POST請求
可以看到Referer與Origin功能相似,前者一般用于統計和阻止盜鍊,後者用于CORS請求。 但是還是有幾點不同:
- 隻有跨域請求,或者同域時發送post請求,才會攜帶Origin請求頭;而Referer隻要浏覽器能擷取到都會攜帶(除了上面說明的幾種case外)
- 若浏覽器不能擷取到請求源頁面位址(如上面的幾種case),Referer頭不會發送,但Origin依舊會發送,隻是值是null而已(注:雖然值為null,但此請求依舊屬于Cors請求哦),如下圖所示:
- Origin的值隻包括協定、域名和端口,而Rerferer不但包括協定、域名、端口還包括路徑,參數,注意不包括hash值
浏覽器的同源政策
浏覽器的職責是展示/渲染document、css、script腳本等,但是這些資源(将document、css、script統一稱為資源)可能來自不同的地方,如本地、遠端伺服器、甚至黑客的伺服器…浏覽器作為網際網路的入口,是我們接入網際網路最重要的軟體之一(甚至沒有之一),是以它的安全性顯得尤為重要,這就出現了浏覽器的同源政策。
同源政策是浏覽器一個重要的安全政策,它用于限制一個origin源的document或者它加載的腳本如何能與另一個origin源的資源進行互動。它能幫助阻隔惡意文檔,減少(并不是杜絕)可能被攻擊的媒介。
友善和安全往往是相悖的:安全性增高了,友善性就會有所降低
那麼問題來了,什麼才算同源?
同源的定義
URL被稱作:統一資源定位符,同源是針對URL而言的。一個完整的URL各部分如下圖所示:
Tips:域名和host是等同的概念,域名+端口号 = host+端口号(大部分情況下你看到域名并沒有端口号,那是采用了預設端口号80而已)
同源:隻和上圖的前兩部分(protocol + domain)有關,規則為:全部相同則為同源。這個定義不難了解,但有幾點需要再強調一下:
- 兩部分必須完全一樣才算同源
- 這裡的domain包含port端口号,是以總共是兩部分而非三部分
- 當然也有說三部分的(協定+host+port),了解其含義就成
下面通過舉例來徹底了解下。譬如,我的源URL為:
http://www.baidu.com/api/user
,下面表格描述了不同URL的各類情況:
URL | 是否同源 | 原因說明 |
---|---|---|
http://www.baidu.com/account | 是 | 前兩部分相同,path路徑不一樣而已 |
http://www.baidu.com/account?name=a | 是 | 前兩部分相同,path路徑、參數不同而已 |
https://www.baidu.com/api/user | 否 | 協定不同 |
http://www.baidu.com:8080/api/user | 否 | 端口不同(domain不同) |
http://map.baidu.com/api/user | 否 | host不同(domain不同) |
不同源的網絡通路
浏覽器同源政策的存在,限制了不同源之間的互動,實為不便。但是浏覽器也開了一些“綠燈”,讓其不受同源政策的限制。此種情況一般可分為如下三類:
- 跨域寫操作(Cross-origin writes):一般是被允許的。如連結(如a标簽)、重定向以及表單送出(如form表單的送出)
- 跨域資源嵌入(Cross-origin embedding):一般是允許的。比如下面這些例子:
-
标簽嵌入js腳本<script src="..."></script>
-
标簽嵌入CSS<link rel="stylesheet" href="...">
-
展示的圖檔<img>
-
和<video>
媒體資源<audio>
-
嵌入的插件<object>、 <embed> 、<applet>
- CSS中使用
引入字型@font-face
- 通過
載入資源<iframe>
-
- 跨域讀操作(Cross-origin reads):一般是不被允許的。比如我們的http接口請求等都屬于此範疇,也是本專欄關注的焦點
簡單總結成一句話:浏覽器自己是可以發起跨域請求的(比如a标簽、img标簽、form表單等),但是Javascript是不能去跨域擷取資源(如ajax)。
如何允許不同源的網絡通路
上面說到的第三種情況:跨域讀操作一般是不允許跨域通路的,而這種情況是我們開發過程中最關心、最常見的case,是以必須解決。
Tips:這裡的讀指的是廣義上的讀,指的是從伺服器擷取資源(有response)的都叫讀操作,而和具體是什麼Http Method無關。換句話講,所有的Http API接口請求都在這裡都指的是讀操作
可以使用 CORS 來允許跨源通路。CORS 是 HTTP 的一部分,它允許服務端來指定哪些主機可以從這個服務端加載資源。
什麼是Cors跨域
Cors(Cross-origin resource sharing):跨域資源共享,它是浏覽器的一個技術規範,由W3C規定,規範的wiki位址在此:https://www.w3.org/wiki/CORS_Enabled#What_is_CORS_about.3F
話外音:它是浏覽器的一種(自我保護)行為,并且已形成規範。也就是說:backend請求backend是不存在此現象的喽
若想實作Cors機制的跨域請求,是需要浏覽器和伺服器同時支援的。關于浏覽器對CORS的支援情況:現在都2021年了,so可以認為100%的浏覽器都是支援的,再加上CORS的整個過程都由浏覽器自動完成,前端無需做任何設定,是以前端工程師的ajax原來怎麼用現在還是怎麼用,它對前段開發人員是完全透明的。
為何需要Cors跨域通路?
浏覽器費盡心思的搞個同源政策來保護我們的安全,但為何又需要跨域來打破這種安全政策呢?其實啊,這一切都和網際網路的快速發展有關~
随着Web開放的程度越來越高,頁面的内容也是越來越豐富。是以頁面上出現的元素也就越來越多:圖檔、視訊、各種文字内容等。為了分而治之,一個頁面的内容可能來自不同地方,也就是不同的domain域,是以通過API跨域通路成了必然。
浏覽器作為進入Internet最大的入口,很長時間它是個大互聯公司的必争之地,是以市面上并存的浏覽器種類繁多且魚龍混紮:IE 7、8、9、10,Chrome、Safari、火狐,每個浏覽器對跨域的實作可能都不一樣。是以對開發者而言亟待需要一個規範的、統一方案,它就是
Cors
。
CORS(Cross-Origin Resource Sharing)由W3C組織于2009-03-17編寫工作草案,直到2014-01-16才正式畢業成為行業規範,所有浏覽器得以遵守。至此,程式員同學們在解決跨域問題上,隻需按照Cors規範實施即可。
Cors的工作原理
Web資源涉及到兩個角色:浏覽器(消費者)和伺服器(提供者),面向這兩個角色來了解Cors的原理非常簡單,如下圖所示:
- 若浏覽器發送的是個跨域請求,http請求中就會攜帶一個名為Origin的頭表明自己的“位置”,如
Origin: http://localhost:5432
- 服務端接到請求後,就可以根據傳過來的Origin頭做邏輯,決定是否要将資源共享給這個源喽。而這個決定通過響應頭Access-Control-Allow-Origin來承載,它的value值可以是任意值,有如下情況:
- 無此頭:不共享給此origin
- 有此頭:值有如下可能情況
- 值為
,通配符,允許所有的Origin共享此資源*
- 值為http://localhost:5432(也就是和Origin相同),共享給此Origin
- 值為非http://localhost:5432(也就是和Origin不相同),不共享給此Origin
- 值為
- 浏覽器接收到Response響應後,會去提取Access-Control-Allow-Origin這個頭。然後根據上述規則來決定要接收此響應内容還是拒絕
Tips:Access-Control-Allow-Origin響應頭隻能有1個,且value值就是個字元串。另外,value值即使寫為 http://aa.com,http://bb.com
這種也屬于一個而非兩個值
Cors細粒度控制:授權響應頭
在Cors規範中,除了可以通過Access-Control-Allow-Origin響應頭來對主體資源(URL級别)進行授權外,還提供了針對于具體響應頭更細粒度的控制,這個響應頭就是:Access-Control-Expose-Headers。換句話講,該頭用于規定哪些響應頭(們)可以暴露給前端,預設情況下這6個響應頭無需特别的顯示指定就支援:
- Cache-Control
- Content-Language
- Content-Type
- Expires
- Last-Modified
- Pragma
若不在此值裡面的頭将不會傳回給前端(其實傳回了,隻是浏覽器讓其對前端不可見了而已,對JavaScript也不可見哦)。
但是,但是,但是,這種細粒度控制header的機制對簡單請求是無效的,隻針對于非簡單請求(也叫複雜請求)。由此可見,将哪些類型的跨域資源請求劃分為簡單請求的範疇就顯得特備重要了。
何為簡單請求
Cors規範定義簡單請求的原則是:請求不是以更新(添加、修改和删除)資源為目的,服務端對請求的處理不會導緻自身維護資源的改變。對于簡單跨域資源請求來說,浏覽器将兩個步驟(取得授權和擷取資源)合二為一,由于不涉及到資源的改變,是以不會帶來任何副作用。
對于一個請求,必須同時符合如下要求才被劃為簡單請求:
- Http Method隻能為其一:
- GET
- POST
- HEAD
- 請求頭隻能在如下範圍:
- Accept
- Accept-Language
- Content-Language
- Content-Type,其中它的值必須如下其一:
- application/x-www-form-urlencoded
- multipart/form-data
- text/plain
除此之外的請求都為非簡單請求(也可稱為複雜請求)。非簡單請求可能對服務端資源改變,是以Cors規定浏覽器在發出此類請求之前必須有一個“預檢(Preflight)”機制,這也就是我們熟悉的
OPTIONS
請求。
什麼是Preflight預檢機制
顧名思義,它表示在浏覽器發出真正請求之前,先發送一個預檢請求,這個在Http裡就是OPTIONS請求方式。這個請求很特殊,它不包含主體(無請求參數、請求體等),主要就是将一些憑證、授權相關的輔助資訊放在請求頭裡交給伺服器去做決策。是以它除了攜帶Origin請求頭外,還會額外攜帶如下兩個請求頭:
- Access-Control-Request-Method:真正請求的方法
- Access-Control-Request-Headers:真正請求的自定義請求頭(若沒有自定義的就是空呗)
服務端在接收到此類請求後,就可以根據其值做邏輯決策啦。如果允許預檢請求通過,傳回個200即可,否則傳回400或者403呗。
如果預檢成功,在響應裡應該包含上文提到的響應頭Access-Control-Allow-Origin和Access-Control-Expose-Headers,除此之外,服務端還可以做更精細化的控制,這些精細化控制的響應頭為:
- Access-Control-Allow-Methods:允許實際請求的Http方法(們)
- Access-Control-Allow-Headers:允許實際請求的請求頭(們)
- Access-Control-Max-Age:允許浏覽器緩存此結果多久,機關:秒。有了緩存,以後就不用每次請求都發送預檢請求啦
說明:以上響應頭并不是必須的。若沒有此響應頭,代表接受所有
預檢請求完成後,有個關鍵點,便是浏覽器拿到預檢請求的響應後的處理邏輯,這裡描述如下:
- 先通過自己的Origin比對預檢響應中的Access-Control-Allow-Origin的值,若不比對就結束請求,若比對就繼續下一步驗證
- 關于Access-Control-Allow-Origin的驗證邏輯,請參考文上描述
- 拿到預檢響應中的Access-Control-Allow-Methods頭。若此頭不存在,則進行下一步,若存在則校驗預檢請求頭Access-Control-Request-Method的值是否在此清單中,在其内繼續下一步,否則失敗
- 拿到預檢響應中的Access-Control-Request-Headers頭。同請求頭中的Access-Control-Allow-Headers值記性比較,全部包含在内則比對成功,否則失敗
以上全部比對成功,就代表預檢成功,可以開始發送正式請求了。值得一提的事,Access-Control-Max-Age控制預檢結果的浏覽器緩存,若緩存還生效的話,是不用單獨再發送OPTIONS請求的,比對成功直接發送目标真實即可。
Access-Control-Max-Age使用細節
Access-Control-Max-Age用于控制浏覽器緩存預檢請求結果的時間,這裡存在一些使用細節你需要注意:
- 若浏覽器禁用了緩存,也就是勾選了
,那麼此屬性無效。也就說每次都還得發送OPTIONS請求Disable cache
- 判斷此緩存結果的因素有兩個:
- 必須是同一URL(也就是Origin相同才會去找對應的緩存)
- header變化了,也會重新去發OPTIONS請求(當然若去掉一些header程式設計簡單請求了,就另當别論喽)
跨域請求代碼示例
正所謂說再多,也抵不上跑幾個case,畢竟show me your code才是最重要。 下面就針對跨域情況的簡單請求、非簡單請求(預檢通過、預檢不通過)等case分别用代碼(基于文首代碼)說明。
簡單請求
簡單請求正如其名,是最簡單的請求方式。
// 跨域請求
$.get("http://localhost:8080/cors", function (result) {
$("#content").append(result).append("<br/>");
});
複制
服務端結果:
INFO ...CorsServlet - 收到請求:/cors,方法:GET, Origin頭:http://localhost:63342
複制
浏覽器結果:
若想讓請求正常,隻需在服務端響應頭裡“加點料”就成:
...
resp.setHeader("Access-Control-Allow-Origin","http://localhost:63342");
resp.getWriter().write("hello cors...");
...
複制
再次請求,結果成功:
對于簡單請求來講,服務端隻需要設定Access-Control-Allow-Origin這個一個頭即可,一個即可。
非簡單請求
非簡單請求的模拟非常簡單,随便打破一個簡單請求的限制即可。比如我們先在上面get請求的基礎上自定義個請求頭:
$.ajax({
type: "get",
url: "http://localhost:8080/cors",
headers: {secret:"kkjtjnbgjlfrfgv",token: "abc123"}
});
複制
服務端代碼:
/**
* 在此處添加備注資訊
*
* @author YourBatman. <a href=mailto:[email protected]>Send email to me</a>
* @site https://yourbatman.cn
* @date 2021/6/9 10:36
* @since 0.0.1
*/
@Slf4j
@WebServlet(urlPatterns = "/cors")
public class CorsServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String requestURI = req.getRequestURI();
String method = req.getMethod();
String originHeader = req.getHeader("Origin");
log.info("收到請求:{},方法:{}, Origin頭:{}", requestURI, method, originHeader);
resp.setHeader("Access-Control-Allow-Origin","http://localhost:63342");
resp.setHeader("Access-Control-Expose-Headers","token,secret");
resp.setHeader("Access-Control-Allow-Headers","token,secret"); // 一般來講,讓此頭的值是上面那個的【子集】(或相同)
resp.getWriter().write("hello cors...");
}
}
複制
點選按鈕,浏覽器發送請求,結果為:
服務端沒有任何日志輸出,也就是說浏覽器并未把實際請求發出去。什麼原因?檢視OPTIONS請求的傳回一看便知:
根本原因為:OPTIONS的響應頭裡并未含有任何跨域相關資訊,雖然預檢通過(注意:這個預檢是通過的喲,預檢不通過的場景就不用額外示範了吧~),但預檢的結果經浏覽器判斷此跨域實際請求不能發出,是以給攔下來了。
從代碼層面問題就出現在
resp.setHeader(xxx,xxx)
放在了處理實際方法的Get方法上,顯然不對嘛,應該放在
doOptions()
方法裡才行:
@Override
protected void doOptions(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
super.doOptions(req, resp);
resp.setHeader("Access-Control-Allow-Origin","http://localhost:63342");
resp.setHeader("Access-Control-Expose-Headers","token,secret");
resp.setHeader("Access-Control-Allow-Headers","token,secret"); // 一般來講,讓此頭的值是上面那個的【子集】(或相同)
}
複制
在此運作,一切正常:
值得特别注意的是:設定跨域的響應頭這塊代碼,在處理真實請求的doGet裡也必須得有,否則服務端處理了,浏覽器“不認”也是會出跨域錯誤的。
另外就是,Access-Control-Allow-Headers/Access-Control-Expose-Headers這兩個頭裡必須包含你的請求的自定義的Header(标準的header不需要包含),否則依舊跨域失敗哦~
在實際生産場景中,Http請求的Content-type大都是
application/json
并非簡單請求的頭,是以有個現實情況是:實際的跨域請求中,幾乎100%的情況下我們發的都是非簡單請求。
Cros跨域使用展望
如上代碼示例,處理簡單請求尚且簡單,但對于非簡單請求來說,我們在doOptions和doGet都寫了一段setHeader的代碼,是否覺得麻煩呢?
另外,對于Access-Control-Allow-Origin若我需要允許多個源怎麼辦呢?
Tips:Access-Control-Allow-Origin頭隻允許一個,且Access-Control-Allow-Origin:a.com,b.com依舊算作一個源的,它沒有逗号分隔的“特性”。從命名的藝術你也可看出,它并非是xxx-Origins而是xxx-Origin
既然實際場景中幾乎100%都是非簡單請求,那麼對于控制非簡單請求的Access-Control-Allow-Methods、Access-Control-Allow-Headers、Access-Control-Max-Age這些都都改如何指派?是否有最佳實踐?
現在我們大都在Spring Framework/Spring Boot場景下開發應用,架構層面是否提供一些優雅的解決方案?
作為一名後端開發工程師(程式設計語言不限),也許你從未處理過跨域問題,那麼到底是誰默默的幫你解決了這一切呢?是否想知其是以然?
如果這些問題也是你在使用過程中的疑問,或者希望了解的知識點,那麼請關注專欄吧。
總結
本文用很長的篇幅介紹了Cors跨域資源共享的相關知識,并且用代碼做了示範,希望能助你通關Cors這個狗皮膏藥一樣粘着我們的硬核知識點。本文文字叙述較多,介紹了同源、跨域、Cors的幾乎所有概念,雖然略顯難啃,但這些是指導我們實踐的說明書。
革命尚未統一,帶着??給到的問題,一起開啟通過Cors跨域之旅吧~
本文思考題
本文已被
https://yourbatman.cn
收錄。所屬專欄:點撥-Cors跨域,背景回複“專欄清單”即可檢視詳情。
看完了不一定懂,看懂了不一定會。來,3個思考題幫你複盤:
- 試想一下,如果浏覽器沒有同源政策,将有多大的風險?
- Cors共涉及到哪些請求頭?哪些響應頭?
- 你所知道的解決Cors跨域問題最佳實踐是什麼?
推薦閱讀
- 10. 原來是這麼玩的,@DateTimeFormat和@NumberFormat
- 9. 細節見真章,Formatter注冊中心的設計很讨巧
- 8. 格式化器大一統 – Spring的Formatter抽象
System.out.println("點個贊吧!");
print_r('關注【BAT的烏托邦】!');
var_dump('私聊YourBatman:fsx1056342982');
console.log("點個贊吧!");
NSLog(@"關注【BAT的烏托邦】!");
print("私聊YourBatman:fsx1056342982");
echo("點個贊吧!");
cout << "關注【BAT的烏托邦】!" << endl;
printf("私聊YourBatman:fsx1056342982");
Console.WriteLine("點個贊吧!");
fmt.Println("關注【BAT的烏托邦】!");
Response.Write("私聊YourBatman:fsx1056342982");
alert("點個贊吧!");
複制
YourBatman
:Java架構師,領域專家,Spring Framework開源貢獻者。緻力于寫純粹技術專欄,不嘩衆取寵。成系列的技術文修行起來會較痛苦,但做難事必有所得嘛,共勉。注重基本功修養,底層基礎決定上層建築。現有IDEA系列、Spring技術棧系列、Bean Validation系列、Java日期時間系列…關注免費擷取