HTTP大家都不陌生,但是HTTP的許多細節就並不是很多人都知道了,本文將討論一些容易被忽略但又比較重要的點。
首先,怎麼用原生JS寫一個GET請求呢?如下程式碼,只需3行:
let xhr = new XMLHttpRequest();
xhr.open("GET", "/list");
xhr.send();
複製程式碼
xhr.open第一個引數是請求方法,第二個引數是請求url,然後把它send出去就行了。
如果需要加上請求引數,如果用jQuery的ajax,那麼是這麼寫的:
$.ajax({
url: "/list",
data: {
page: 5
}
});複製程式碼
如果是用原生的話就得拼在請求url上面,即open的第二個引數:
並且引數需要轉義,如下程式碼所示:
function ajax (url, data) {
let args = [];
for (let key in data) {
// 引數需要轉義
args.push(`${encodeURIComponent(key)} =
${encodeURIComponent(data[key])}`);
}
let search = args.join("&");
// 判斷當前url是否已有引數
url += ~url.indexOf("?") ? `&${search}` : `?${search}`;
let xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.send();
}複製程式碼
那為什麼用jq就不用呢?因為jq幫我們做了,jq的ajax支援一個叫processData的引數,預設為true:
$.ajax({
url: "/list",
data: {
page: 5
},
processData: true
});
複製程式碼
這個引數的作用是用來處理傳進來的data的,如下jq的原始碼:
如果傳了data,並且processData為true,並且data不是一個string了,就會調param處理data。然後我們來看下這個param函式是怎麼實現的:
可以看到,它也是跟我自己實現的ajax類似,把key和value轉義用"="拼接,然後push到一個陣列,最後再join地一下。不一樣的地方是它的判斷邏輯比我的複雜,它會再調一個buildParams的函式去處理key/value,因為value可能是一個陣列,也可能是一個Object。如果value是一個Object,那麼直接encode一下就會變成"[object Object]"的轉義了:
所以buildParams在處理每個key/value時,會先判斷當前value是否是一個陣列或者是Object,如下圖所示:
如果它是一個陣列的話,這個陣列的每一個元素都會變成單獨的一個請求欄位,它的key是父級的key拼上陣列的索引得到,如{ids: [1, 2, 3]}就會被拼成:ids[0]=1、ids[1] = 2、ids[2] = 3,如果是一個Object的話key的字尾就是子Object的key,如{user: {id: 1333, name: "yin"}}會被拼成:user[id]=1333、user[name]=yin,否則就認為它是一個簡單的型別,就直接調一下param函式定義的add,把它push到s那個陣列。這裡用到了遞迴呼叫,會不斷地拼key值,直到value是一個普通變數了,就到了最後面的else邏輯。
也就是說,以下程式碼:
$.ajax({
url: "/list",
data: {
user: {
name: "yin",
age: 18
}
},
});複製程式碼
將會被拼成的url為:
/list?user[name]=yin&user[age]=18
注意上面的中括號還沒有轉義。而如果是一個陣列的話:
$.ajax({
url: "/list",
data: {
ids: [1, 2, 3]
},
});複製程式碼
拼成的請求url為:
/list?ids[0]=1&ids[1]=2&ids[2]=3
如果後端用的Java的Spring MVC框架的話,是理解這種格式的,框架收到這樣的引數後會生成一個Array,傳遞給業務程式碼,業務程式碼是不用關心怎麼處理這種引數的。其它的框架應該也類似。
怎麼用原生JS寫一個POST請求呢?如下圖所示:
POST請求的引數就不是放在url了,而是放在send裡面,即請求體。你可能會問:難道就不能放url麼?我就要放url。如果你夠任性,那麼可以,前提是後端所使用的http框架能夠在url裡面取資料,因為它一定會收到url,也一定會收到請求體,所以取決於它要怎麼處理,按照http標準,如果請求方法是POST,那麼應該是得去請求體拿的,就不會在url的search上取了,當然它可以改一下,改成兩個都可以拿。
然後我們會發現請求的mime型別是text/plain:
並且檢視請求引數的時候,並不是平時所看到能夠按照欄位一行行地展示:
這是為什麼呢?這是因為我們沒有設定它的Content-Type,如下程式碼:
let xhr = new XMLHttpRequest();
xhr.open("POST", "/add");
xhr.setRequestHeader("Content-type",
"application/x-www-form-urlencoded");
xhr.send("id=5&name=yin");複製程式碼
如果設定Content-Type為x-www-form-urlencoded的話,那麼在檢查的話,Chrome也會按欄位分行展示了:
這個也是jq預設的Content-Type:
它是一種最常用的一種請求編碼方式,支援GET/POST等方法,特點是:所有的資料變成鍵值對的形式key1=value1&key2=value2的形式,並且特殊字元需要轉義成utf-8編號,如空格會變成%20:
由於中文在utf-8需要佔用3個位元組,所以它有3個%符號。
我們剛剛是xhr.send一個字串,如果send一個Object會怎麼樣呢?如下程式碼所示:
let xhr = new XMLHttpRequest();
xhr.open("POST", "/add");
xhr.send({id:5, name: "yin"});複製程式碼
檢查控制檯的時候是這樣的:
也就是說,實際上是調了Object的toString方法。所以可以看到,在send的資料需要轉成字串。
除了字串之外,send還支援FormData/Blob等格式,如:
let form = $("form")[0];
xhr.send(new FormData(form));
複製程式碼
但最後都是被轉成字串傳送。
我們再看下其它的請求格式,如Github的REST API是使用json的格式發請求:
這個時候要求格式要變成json,就需要指定Content-Type為application/json,然後send的資料要stringify一下:
let xhr = new XMLHttpRequest();
xhr.open("POST", "/add");
xhr.setRequestHeader("Content-type", "application/json");
let data = {id:5, name: "yin"};
xhr.send(JSON.stringify(data));複製程式碼
如果是用jq的話,那麼可以這樣:
$.ajax({
processData: false,
data: JSON.stringify(data),
contentType: "application/json"
});
複製程式碼
這個時候processData為false,告訴jq不要處理資料了——即拼成key1=value1&key2=value2的形式,直接把傳給它的資料send就好了。
我們可以比較json和urlencoded這兩種形式的優缺點,json的缺點是parse解析的工作量要明顯高於split("&")的工作量,但是json的優點又在於表達複雜結構的時候比較簡潔,如二維陣列(m * n)在urlencoded需要拆成m * n個欄位,而json就不用了。所以相對來說,如果請求資料結構比較簡單應該是使用常用的urlencoded比較有利,而比較複雜時使用json比較有利。通常來說比較常用的還是urlencoded.
還有第3種常見的編碼是multipart/form-data,這種也可以用來發請求,如下程式碼所示:
let formData = new FormData();
formData.append("id", 5); // 數字5會被立即轉換成字串 "5"
formData.append("name", "#yin");
// formData.append("file", input.files[0]);
let xhr = new XMLHttpRequest();
xhr.open("POST", "/add");
xhr.send(formData);複製程式碼
它通常用於上傳檔案,上傳檔案必須要使用這種格式。上面程式碼傳送的內容如下圖所示:
每個欄位之間用一個隨機字串隔開,保證傳送的內容不會出現這個字串,這樣傳送的內容就不需要進行轉義了,因為如果檔案很大的話,轉義需要花費相當的時間,體積也可能會成倍地增長。
然後再討論一個問題,我們知道在瀏覽器位址列輸入一個網址請求資料,這個時候是用的GET請求,而我們在程式碼裡面用ajax發的GET請求也是GET,瀏覽器訪問網址的GET和ajax的GET有什麼區別嗎?
為了能夠觀察到瀏覽器自已發出去的GET,需要用一個抓包工具看一下這個GET是怎麼樣的,如下圖所示:
瀏覽器自己發的GET有一個明顯的特點,它會設定http請求頭的Accept欄位,並且把text/html排在第一位,即它最希望收到的是html格式。而動態的ajax抓包顯示是這樣的:
可以看到,使用位址列訪問的和使用ajax的get請求本質上都是一樣的,只是使用ajax我們可以設定http請求頭,而使用位址列訪問的是由瀏覽器新增預設的請求頭。
上面是使用http抓的包,我們可以看到請求的完整url,包括請求的引數,而如果是抓的https的包的話,GET放在url上的引數就看不到了:
也就是說https的請求報文是加密的,包括請求的uri等,需要解密後才能看到。那是不是說使用https的GET也是安全的,而不是https的POST不會比GET安全?
我們先來看一下http的請求報文,如下圖所示:
如果使用抓包工具的話,可以看到請求報文確實是按照上圖排列的,如圖所示的GET:
而POST是這樣的:
所以本質上GET/POST是一樣的,只是GET把資料拼到url,而POST是放到請求體。另外一點,url是有長度限制的,包括瀏覽器和接收的服務如nginx,而請求體是沒有限制的(瀏覽器沒有限制,但是一般nginx接收會有限制),還有POST的資料支援多種編碼格式。
雖然如此,POST還是比GET安全的,體現在以下幾點:
- GET引數是放在url上面,使用者可以儲存為書籤、傳播連結,如果引數有敏感資料,如登陸的密碼,那麼可能會洩露
- 搜尋引擎在爬取網站的時候如果修改資料庫的請求支援GET,那麼很可能資料庫會無意被搜尋引擎修改
- script/img等標籤是GET請求,會更加方便跨站請求偽造,在瀏覽器位址列輸入也是GET,也是為修改請求提供便利
接著討論請求響應狀態碼。很多人都知道200、404、500這幾個,對於其它的可能就不甚瞭解了。這裡我把一些常用的狀態碼列一下。
1. 301 永久轉移
當你想換域名的時候,就可以使用301,如之前的域名叫www.renfed.com,後來換了一個新域名www.rrfed.com,希望使用者訪問老域名的時候能夠自動跳轉到新的域名,那麼就可以使用nginx返回301:
server {
listen 80;
server_name www.rrfed.com;
root /home/fed/wordpress;
return 301 https://www.rrfed.com$request_uri;
}複製程式碼
瀏覽器收到301之後,就會自動跳轉了。搜尋引擎在爬的時候如果發現是301,在若干天之後它會把之前收錄的網頁的域名給換了。
還有一個場景,如果希望訪問http的時候自動跳轉到https也是可以用301,因為如果直接在瀏覽器位址列輸入域名然後按回車,前面沒有帶https,那麼是預設的http協議,這個時候我們希望使用者能夠訪問安全的https的,不要訪問http的,所以要做一個重定向,也可以使用301,如:
server {
listen 80;
server_name www.rrfed.com;
if ($scheme != "https") {
return 301 https://$host$request_uri;
}
}
複製程式碼
2. 302 Found 資源暫時轉移
很多短連結跳轉長連結就是使用的302,如下圖所示:
3. 304 Not Modified 沒有修改
在本地使用webpack-dev-server開發的時候,如果沒有改js/css,那麼重新整理頁面的時候請求本地的js/css檔案將返回304,如下圖所示:
webpack-dev-server的服務怎麼知道沒有修改呢,因為瀏覽器在請求的時候帶上了etag,如:
W/"10e632-Oz38I6asQyS459XpsaJYkjMUoZI"
服務會計算當前檔案的etag,它是一個檔案的雜湊值,然後比較一下傳過來的etag,如果相等,則認為沒有修改返回304。如果有修改的話就會返回200和檔案的內容,並重新給瀏覽器一個新的etag。下次請求的時候瀏覽器就會帶上這個新的etag。如果把控制檯的disable cached開啟的話,那麼瀏覽器即使有etag也不會帶上。另外一個判斷有沒有修改的欄位是Last Modified Time,這是根據檔案的修改時間。
4. 400 Bad Request 請求無效
當必要引數缺失、引數格式不對時,後端通常會返回400,如下圖所示:
並帶上了提示資訊:
{"message":"opportunityId type mismatch required type 'long' "}
通過400可以知道請求引數有誤,結合提示資訊,說明需要傳一個數字,而不是字串。
5. 403 Forbidden 拒絕服務
服務能夠理解你的請求,包括傳參正確,但是拒絕提供服務。例如,服務允許直接訪問靜態檔案:
但是不允許訪問某個目錄:
否則,別人對你伺服器上的檔案就一覽無遺了。
403和401的區別在於,401是沒有認證,沒有登陸驗證之類的錯誤。
6. 500 內部伺服器錯誤
如業務程式碼出現了異常沒有捕獲,被tomcat捕獲了,就會返回500錯誤:
如:資料庫欄位長度限制為30個字元,如果沒有判斷直接插入一條31個字元的記錄,就會導致資料庫拋異常,如果異常沒有捕獲處理,就直接返回500。
當服務徹底掛了,連返回都沒有的時候,那麼就是502了。
7. 502 Bad Gateway 閘道器錯誤
如下圖所示:
這種情況是因為nginx收到請求,但是請求沒有打過去,可能是因為業務服務掛了,或者是打過去的埠號寫錯了:
server {
location / {
# webpack的服務
proxy_pass https://127.0.0.1:7071;
}
}
複製程式碼
nginx返回了502.
8. 504 Gateway Timeout 閘道器超時
通常是因為服務處理請求太久,導致超時,如PHP服務預設的請求響應最長處理時間為30s,如果超過30s,將會掛掉,返回504,如下圖所示:
這種情況可能是因為服務還要請求第三方的服務,第三方服務處理時間較久沒有返回,如在向FCM傳送Push的時候,如果一個請求裡面需要傳送的訂閱的瀏覽器(subscriptions)太多了,就經常會處理很久,導致504.
9. 101 協議轉換
websocket是從http升級而來,在建立連線前需要先通過http進行協議升級:
還有一個600,600是一種不太常用的狀態碼,表示伺服器沒有返回響應頭部,只返回實體內容。
這些狀態碼實際上就是一個數字,可以任意返回,但是最好是按照http的規定返回合適的狀態碼。如果返回4、5、6開頭的http狀態碼,瀏覽器將會列印錯誤,認為當前請求失敗。
我們還沒有說怎麼判斷請求成功了,如下程式碼所示:
xhr.open("POST", UPLOAD_URL);
xhr.onreadystatechange = function() {
// readyState為4表示請求完成
if (this.readyState === 4){
if (this.status === 200) {
let response = JSON.parse(this.responseText);
if (!response.status || response.status.code !== 0) {
// 失敗
callback.failed && callback.failed();
} else {
// 成功
callback.success(response.data.url);
}
} else if (this.status >= 400 || this.status === 0) {
// 失敗
callback.failed && callback.failed();
// 正常不應該返回20幾的狀態碼,這種情況也認為是失敗
} else {
callback.failed && callback.failed();
}
}
};
xhr.send(formData);複製程式碼
這裡有個問題,如果返回的狀態碼是3開頭的重定向,需要自己再去發一個請求嗎?
實踐證明,不需要,瀏覽器會自動重定向,如下圖所示:
最後,本文提到了3種常用的請求編碼,分別是application/www-x-form-urlencoded、application/json、multipart/form-data,第一種是最常用的一種,適用於GET/POST等,第二種常見於請求響應的資料格式,第三種通常用於上傳檔案。然後還比較了POST和GET,雖然兩者的請求資料都在http報文裡面,只是位置不一樣,但是考慮到使用者、搜尋引擎等使用場景,POST還是會比GET更安全。最後說了幾個常用的http狀態碼,並用一些實際的例子加深印象和理解。