背景
最近在 ITA 寫了一個聊天機器人的 Flask 服務,自己寫了一些 node 單元測試腳本跑沒有問題,但是測試的同學也想覆寫到所有的 case,于是就幫忙寫一個 html 頁面去測試,然後就遇到了下面的問題:
XMLHttpRequest cannot load http://localhost:8085/predict. No ‘Access-Control-Allow-Origin’ header is present on the requested resource. Origin ‘null’ is therefore not allowed access.
這個是典型的跨域問題(跨域是指:協定、域名、端口有任何一個不同,都被當做是不同的域),想想之前也了解過跨域的知識,現在借着這個機會總結一下了。關于 GET 請求的跨域,使用 JSONP 是目前最好的解決方案,各大浏覽器也基本都支援 JSONP,而 jQuery,AngularJS 等前端架構也都預設添加了對 JSONP 的封裝,并且這次遇到的跨域問題是 POST 請求的,于是暫時先不寫關于 JSONP 的相關知識。
簡化代碼
伺服器代碼:
from flask import Flask
if __name__ == "__main__":
print('Start server')
app = Flask(__name__)
# 路由
@app.route('/predict', methods=['POST'])
def predict():
return 'result'
app.run(host='0.0.0.0', port=8085, debug=True)
複制
頁面代碼:
<!doctype html>
<html ng-app="chatApp">
<head>
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.6.0/angular.min.js"></script>
<script src="./main.js"></script>
</head>
<body>
<div ng-controller="ChatController">
<input type="text" ng-model="chat" placeholder="Enter content here">
<button ng-click="onclick()">POST</button>
<p> {{ result }} </p>
</div>
</body>
</html>
複制
– 原諒我用 Angular 做頁面 ☹
main.js
angular.module('chatApp', [])
.controller('ChatController', ['$scope', '$http', function($scope, $http) {
$scope.onclick = function() {
$http({
method: 'POST',
url: 'http://localhost:8085/predict'
}).then((data) => {
$scope.result = data;
});
};
}]);
複制
解決方案
要想解決跨域,必先了解跨域。那什麼是跨域呢?
對于 web 開發來講,由于浏覽器的同源政策,我們需要經常使用一些 hack 的方法去跨域擷取資源,直到 W3C 出了一個标準-CORS-“跨域資源共享”(Cross-origin resource sharing),
它允許浏覽器向跨源伺服器,發出 XMLHttpRequest 請求,進而克服了 AJAX 隻能同源使用的限制。
CORS 與 JSONP 的使用目的相同,但是比 JSONP 更強大。
JSONP 隻支援 GET 請求,CORS 支援所有類型的 HTTP 請求。JSONP 的優勢在于支援老式浏覽器,以及可以向不支援 CORS 的網站請求資料。
CORS 解決方案:
(1) 伺服器代碼
from flask import Flask, Response, request
if __name__ == "__main__":
print('Start server')
app = Flask(__name__)
# post
@app.route('/predict', methods=['POST'])
def predict():
if request.form.get('content') is None:
exp = 'Missing content'
else:
exp = request.form.get('content')
print(exp)
headers = {"Access-Control-Allow-Origin": "*"}
return Response(exp, headers=headers)
# port=8085
app.run(host='0.0.0.0', port=8085, debug=True)
複制
(2) main.js
angular.module('chatApp', [])
.controller('ChatController', ['$scope', '$http', function($scope, $http) {
$scope.onclick = function() {
$http({
method: 'POST',
url: 'http://localhost:8085/predict',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
data: 'content= ' + $scope.chat
}).then((data) => {
$scope.result = data.data;
});
};
}]);
複制
此時再次發送 Ajax call就可以拿到結果了:
注意到伺服器端代碼發生了一點改動,那就是在Response header中增加了一個參數 “Access-Control-Allow-Origin”,表示接受某域名的請求,“*” 表示允許所有的請求。
也可以使用确定的值,如: “http://api.abc.com”。
于是代碼中增加 headers = {“Access-Control-Allow-Origin”: ""}* 後伺服器就可以響應所有的請求了。
再看 Web 端的代碼,我們在請求頭裡面添加了 “Content-Type”,為了能向服務端傳遞資料。這裡使用的 “Content-Type” 為 “application/x-www-form-urlencoded” 表示以表單送出的形式傳遞參數。
為什麼要用表單的形式送出POST請求呢?
兩種請求
浏覽器将 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
上文中的請求屬于簡單請求。
簡單請求(simple request)
對于簡單的跨域請求,浏覽器會自動在請求的頭資訊加上 Origin 字段,表示本次請求來自哪個源(協定 + 域名 + 端口),服務端會擷取到這個值,然後判斷是否同意這次請求并傳回。
// 請求
GET /cors HTTP/1.1
Origin: http://api.abc.com
Host: api.bcd.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0…
如果服務端許可本次請求,就會在傳回的頭資訊多出關于 Access-Control 的資訊,比如上述伺服器傳回的資訊:
非簡單請求(not-so-simple request)
非簡單請求是那種對伺服器有特殊要求的請求,比如請求方法是 PUT 或 DELETE,或者 Content-Type 字段的類型是 application/json。
非簡單請求的 CORS 請求,會在正式通信之前,增加一次 HTTP 查詢請求,稱為“預檢”請求(preflight)。
浏覽器先詢問伺服器,目前網頁所在的域名是否在伺服器的許可名單之中,以及可以使用哪些 HTTP 動詞和頭資訊字段。隻有得到肯定答複,浏覽器才會發出正式的 XMLHttpRequest 請求,否則就報錯。
“預檢”請求用的請求方法是 OPTIONS,表示這個請求是用來詢問的。頭資訊裡面,關鍵字段是Origin,表示請求來自哪個源。
非簡單請求解決方案
項目中使用的 Content-Type 為 application/json,屬于非簡單請求,将上述程式修改為
(1) main.js:
angular.module('chatApp', [])
.controller('ChatController', ['$scope', '$http', function($scope, $http) {
$scope.onclick = function() {
$http({
method: 'POST',
url: 'http://localhost:8086/predict',
headers: {
'Content-Type': 'application/json'
},
data: JSON.stringify({
'content': $scope.chat
})
}).then((data) => {
$scope.result = data.data;
});
};
}]);
複制
伺服器代碼:
from flask import Flask, Response, request
if __name__ == "__main__":
print('Start server')
app = Flask(__name__)
# 路由
@app.route('/predict', methods=['POST', 'OPTIONS'])
def predict():
# 傳回頭
headers = {"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Origin, X-Requested-With, Content-Type",
"Access-Control-Allow-Methods": "POST, PUT, GET, OPTIONS, DELETE"}
# preflight
if request.method == 'OPTIONS':
return Response(headers=headers)
# request
if 'content' in request.json:
exp = request.json.get('content')
else:
exp = 'Missing content'
print(exp)
return Response(exp, headers=headers)
# run server
app.run(host='0.0.0.0', port=8086, debug=True)
複制
啟動後發送請求,發現可以跑通,但是擷取不到參數,原因是使用 application/json 的形式發送 request, 參數并沒有放在 form 裡面,而是放在 request.data 裡面了。
request.data 裡面為 bytes 類型的資料,通過 request.json 可以擷取其 dict 類型。
通過以上方式,完美地解決了複雜請求的跨域問題。
才怪嘞!!!
問題所在
以上解決跨域的方式為 CORS,準确地說,這是一種伺服器端的技術。而現實生産環境中,如果一個前端想要用這種方式實作跨域,不知道要跟後端做多少溝通,那有沒有純前端的解決方案呢?
且聽下回分解。