我知道的HTTP請求
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();}
那為什麼用jQuery就不用呢?因為jQuery幫我們做了,jQuery的ajax支持一個叫processData的參數,默認為true:
$.ajax({ url: "/list", data: { page: 5 }, processData: true});
這個參數的作用是用來處理傳進來的data的,如下jQuery的源碼:
如果傳了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也會按欄位分行展示了:
這個也是jQuery默認的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));
如果是用jQuery的話,那麼可以這樣:
$.ajax({ processData: false, data: JSON.stringify(data), contentType: "application/json"});
這個時候processData為false,告訴jQuery不要處理數據了——即拼成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,如之前的域名叫http://www.renfed.com,後來換了一個新域名http://fed.renren.com,希望用戶訪問老域名的時候能夠自動跳轉到新的域名,那麼就可以使用nginx返回301:
server { listen 80; server_name www.renfed.com; root /home/fed/wordpress; return 301 https://fed.renren.com$request_uri;}
瀏覽器收到301之後,就會自動跳轉了。搜索引擎在爬的時候如果發現是301,在若干天之後它會把之前收錄的網頁的域名給換了。
還有一個場景,如果希望訪問http的時候自動跳轉到https也是可以用301,因為如果直接在瀏覽器地址欄輸入域名然後按回車,前面沒有帶https,那麼是默認的http協議,這個時候我們希望用戶能夠訪問安全的https的,不要訪問http的,所以要做一個重定向,也可以使用301,如:
server { listen 80; server_name fed.renren.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狀態碼,並用一些實際的例子加深印象和理解。
推薦閱讀:
※Aspera技術如何實現原來需要傳26小時的24GB文檔做到只需30秒?
※WebSocket 是什麼原理?為什麼可以實現持久連接?
※一個網頁的漂洋過海之旅:傳輸
※57行價值八千萬美元的車牌識別代碼
※雲計算的1024種玩法之零基礎入門