001. HTTP 報文結構是怎樣的?
對於 TCP 而言,在傳輸的時候分為兩個部分:TCP頭和數據部分。
而 HTTP 類似,也是header + body的結構,具體而言:
<code>起始行 + 頭部 + 空行 + 實體/<code>
由於 http 請求報文和響應報文是有一定區別,因此我們分開介紹。
起始行
對於請求報文來說,起始行類似下面這樣:
<code>GET /home HTTP/1.1/<code>
也就是方法 + 路徑 + http版本。
對於響應報文來說,起始行一般張這個樣:
<code>HTTP/1.1 200 OK/<code>
響應報文的起始行也叫做狀態行。由http版本、狀態碼和原因三部分組成。
值得注意的是,在起始行中,每兩個部分之間用空格隔開,最後一個部分後面應該接一個換行,嚴格遵循ABNF語法規範。
頭部
展示一下請求頭和響應頭在報文中的位置:
不管是請求頭還是響應頭,其中的字段是相當多的,而且牽扯到http非常多的特性,這裡就不一一列舉的,重點看看這些頭部字段的格式:
- 字段名不區分大小寫
- 字段名不允許出現空格,不可以出現下劃線_
- 字段名後面必須緊接著:
空行
很重要,用來區分開頭部和實體。
問: 如果說在頭部中間故意加一個空行會怎麼樣?
那麼空行後的內容全部被視為實體。
實體
就是具體的數據了,也就是body部分。請求報文對應請求體, 響應報文對應響應體。
002. 如何理解 HTTP 的請求方法?
有哪些請求方法?
http/1.1規定了以下請求方法(注意,都是大寫):
- GET: 通常用來獲取資源
- HEAD: 獲取資源的元信息
- POST: 提交數據,即上傳數據
- PUT: 修改數據
- DELETE: 刪除資源(幾乎用不到)
- CONNECT: 建立連接隧道,用於代理服務器
- OPTIONS: 列出可對資源實行的請求方法,用來跨域請求
- TRACE: 追蹤請求-響應的傳輸路徑
GET 和 POST 有什麼區別?
首先最直觀的是語義上的區別。
而後又有這樣一些具體的差別:
- 從緩存的角度,GET 請求會被瀏覽器主動緩存下來,留下歷史記錄,而 POST 默認不會。
- 從編碼的角度,GET 只能進行 URL 編碼,只能接收 ASCII 字符,而 POST 沒有限制。
- 從參數的角度,GET 一般放在 URL 中,因此不安全,POST 放在請求體中,更適合傳輸敏感信息。
- 從冪等性的角度,GET是冪等的,而POST不是。(冪等表示執行相同的操作,結果也是相同的)
- 從TCP的角度,GET 請求會把請求報文一次性發出去,而 POST 會分為兩個 TCP 數據包,首先發 header 部分,如果服務器響應 100(continue), 然後發 body 部分。(火狐瀏覽器除外,它的 POST 請求只發一個 TCP 包)
003: 如何理解 URI?
URI, 全稱為(Uniform Resource Identifier), 也就是統一資源標識符,它的作用很簡單,就是區分互聯網上不同的資源。
但是,它並不是我們常說的網址, 網址指的是URL, 實際上URI包含了URN和URL兩個部分,由於 URL 過於普及,就默認將 URI 視為 URL 了。
URI 的結構
URI 真正最完整的結構是這樣的。
可能你會有疑問,好像跟平時見到的不太一樣啊!先別急,我們來一一拆解。
scheme 表示協議名,比如http, https, file等等。後面必須和://連在一起。
user:passwd@ 表示登錄主機時的用戶信息,不過很不安全,不推薦使用,也不常用。
host:port表示主機名和端口。
path表示請求路徑,標記資源所在位置。
query表示查詢參數,為key=val這種形式,多個鍵值對之間用&隔開。
fragment表示 URI 所定位的資源內的一個錨點,瀏覽器可以根據這個錨點跳轉到對應的位置。
舉個例子:
<code>https://www.baidu.com/s?wd=HTTP&rsv_spt=1/<code>
這個 URI 中,https即scheme部分,www.baidu.com為host:port部分(注意,http 和 https 的默認端口分別為80、443),/s為path部分,而wd=HTTP&rsv_spt=1就是query部分。
URI 編碼
URI 只能使用ASCII, ASCII 之外的字符是不支持顯示的,而且還有一部分符號是界定符,如果不加以處理就會導致解析出錯。
因此,URI 引入了編碼機制,將所有非 ASCII 碼字符和界定符轉為十六進制字節值,然後在前面加個%。
如,空格被轉義成了%20,三元被轉義成了%E4%B8%89%E5%85%83。
004: 如何理解 HTTP 狀態碼?
RFC 規定 HTTP 的狀態碼為三位數,被分為五類:
- 1xx: 表示目前是協議處理的中間狀態,還需要後續操作。
- 2xx: 表示成功狀態。
- 3xx: 重定向狀態,資源位置發生變動,需要重新請求。
- 4xx: 請求報文有誤。
- 5xx : 服務器端發生錯誤。
接下來就一一分析這裡面具體的狀態碼。
1xx
101 Switching Protocols。在HTTP升級為WebSocket的時候,如果服務器同意變更,就會發送狀態碼 101。
2xx
200 OK是見得最多的成功狀態碼。通常在響應體中放有數據。
204 No Content含義與 200 相同,但響應頭後沒有 body 數據。
206 Partial Content顧名思義,表示部分內容,它的使用場景為 HTTP 分塊下載和斷電續傳,當然也會帶上相應的響應頭字段Content-Range。
3xx
301 Moved Permanently即永久重定向,對應著302 Found,即臨時重定向。
比如你的網站從 HTTP 升級到了 HTTPS 了,以前的站點再也不用了,應當返回301,這個時候瀏覽器默認會做緩存優化,在第二次訪問的時候自動訪問重定向的那個地址。
而如果只是暫時不可用,那麼直接返回302即可,和301不同的是,瀏覽器並不會做緩存優化。
304 Not Modified: 當協商緩存命中時會返回這個狀態碼。詳見瀏覽器緩存
4xx
400 Bad Request: 開發者經常看到一頭霧水,只是籠統地提示了一下錯誤,並不知道哪裡出錯了。
403 Forbidden: 這實際上並不是請求報文出錯,而是服務器禁止訪問,原因有很多,比如法律禁止、信息敏感。
404 Not Found: 資源未找到,表示沒在服務器上找到相應的資源。
405 Method Not Allowed: 請求方法不被服務器端允許。
406 Not Acceptable: 資源無法滿足客戶端的條件。
408 Request Timeout: 服務器等待了太長時間。
409 Conflict: 多個請求發生了衝突。
413 Request Entity Too Large: 請求體的數據過大。
414 Request-URI Too Long: 請求行裡的 URI 太大。
429 Too Many Request: 客戶端發送的請求過多。
431 Request Header Fields Too Large請求頭的字段內容太大。
5xx
500 Internal Server Error: 僅僅告訴你服務器出錯了,出了啥錯咱也不知道。
501 Not Implemented: 表示客戶端請求的功能還不支持。
502 Bad Gateway: 服務器自身是正常的,但訪問的時候出錯了,啥錯誤咱也不知道。
503 Service Unavailable: 表示服務器當前很忙,暫時無法響應服務。
005: 簡要概括一下 HTTP 的特點?HTTP 有哪些缺點?
HTTP 特點
HTTP 的特點概括如下:
- 靈活可擴展,主要體現在兩個方面。一個是語義上的自由,只規定了基本格式,比如空格分隔單詞,換行分隔字段,其他的各個部分都沒有嚴格的語法限制。另一個是傳輸形式的多樣性,不僅僅可以傳輸文本,還能傳輸圖片、視頻等任意數據,非常方便。
- 可靠傳輸。HTTP 基於 TCP/IP,因此把這一特性繼承了下來。這屬於 TCP 的特性,不具體介紹了。
- 請求-應答。也就是一發一收、有來有回, 當然這個請求方和應答方不單單指客戶端和服務器之間,如果某臺服務器作為代理來連接後端的服務端,那麼這臺服務器也會扮演請求方的角色。
- 無狀態。這裡的狀態是指通信過程的上下文信息,而每次 http 請求都是獨立、無關的,默認不需要保留狀態信息。
HTTP 缺點
無狀態
所謂的優點和缺點還是要分場景來看的,對於 HTTP 而言,最具爭議的地方在於它的無狀態。
在需要長連接的場景中,需要保存大量的上下文信息,以免傳輸大量重複的信息,那麼這時候無狀態就是 http 的缺點了。
但與此同時,另外一些應用僅僅只是為了獲取一些數據,不需要保存連接上下文信息,無狀態反而減少了網絡開銷,成為了 http 的優點。
明文傳輸
即協議裡的報文(主要指的是頭部)不使用二進制數據,而是文本形式。
這當然對於調試提供了便利,但同時也讓 HTTP 的報文信息暴露給了外界,給攻擊者也提供了便利。WIFI陷阱就是利用 HTTP 明文傳輸的缺點,誘導你連上熱點,然後瘋狂抓你所有的流量,從而拿到你的敏感信息。
隊頭阻塞問題
當 http 開啟長連接時,共用一個 TCP 連接,同一時刻只能處理一個請求,那麼當前請求耗時過長的情況下,其它的請求只能處於阻塞狀態,也就是著名的
隊頭阻塞問題。接下來會有一小節討論這個問題。006: 對 Accept 系列字段瞭解多少?
對於Accept系列字段的介紹分為四個部分: 數據格式、壓縮方式、支持語言和字符集。
數據格式
上一節談到 HTTP 靈活的特性,它支持非常多的數據格式,那麼這麼多格式的數據一起到達客戶端,客戶端怎麼知道它的格式呢?
當然,最低效的方式是直接猜,有沒有更好的方式呢?直接指定可以嗎?
答案是肯定的。不過首先需要介紹一個標準——MIME(Multipurpose Internet Mail Extensions, 多用途互聯網郵件擴展)。它首先用在電子郵件系統中,讓郵件可以發任意類型的數據,這對於 HTTP 來說也是通用的。
因此,HTTP 從
MIME type取了一部分來標記報文 body 部分的數據類型,這些類型體現在Content-Type這個字段,當然這是針對於發送端而言,接收端想要收到特定類型的數據,也可以用Accept字段。具體而言,這兩個字段的取值可以分為下面幾類:
- text:text/html, text/plain, text/css 等
- image: image/gif, image/jpeg, image/png 等
- audio/video: audio/mpeg, video/mp4 等
- application: application/json, application/javascript, application/pdf, application/octet-stream
壓縮方式
當然一般這些數據都是會進行編碼壓縮的,採取什麼樣的壓縮方式就體現在了發送方的Content-Encoding字段上, 同樣的,接收什麼樣的壓縮方式體現在了接受方的Accept-Encoding字段上。這個字段的取值有下面幾種:
- gzip: 當今最流行的壓縮格式
- deflate: 另外一種著名的壓縮格式
- br: 一種專門為 HTTP 發明的壓縮算法
<code>起始行 + 頭部 + 空行 + 實體/<code>
支持語言
對於發送方而言,還有一個Content-Language字段,在需要實現國際化的方案當中,可以用來指定支持的語言,在接受方對應的字段為Accept-Language。如:
<code>// 發送端 Content-Language: zh-CN, zh, en // 接收端 Accept-Language: zh-CN, zh, en/<code>
字符集
最後是一個比較特殊的字段, 在接收端對應為Accept-Charset,指定可以接受的字符集,而在發送端並沒有對應的Content-Charset, 而是直接放在了Content-Type中,以charset屬性指定。如:
<code>GET /home HTTP/1.1/<code>
最後以一張圖來總結一下吧:
007: 對於定長和不定長的數據,HTTP 是怎麼傳輸的?
定長包體
對於定長包體而言,發送端在傳輸的時候一般會帶上Content-Length, 來指明包體的長度。
我們用一個nodejs服務器來模擬一下:
<code>HTTP/1.1 200 OK/<code>
啟動後訪問: localhost:8081。
瀏覽器中顯示如下:
<code>helloworld/<code>
這是長度正確的情況,那不正確的情況是如何處理的呢?
我們試著把這個長度設置的小一些:
<code>res.setHeader('Content-Length', 8);/<code>
重啟服務,再次訪問,現在瀏覽器中內容如下:
<code>hellowor/<code>
那後面的ld哪裡去了呢?實際上在 http 的響應體中直接被截去了。
然後我們試著將這個長度設置得大一些:
<code>res.setHeader('Content-Length', 12);/<code>
此時瀏覽器顯示如下:
直接無法顯示了。可以看到Content-Length對於 http 傳輸過程起到了十分關鍵的作用,如果設置不當可以直接導致傳輸失敗。
不定長包體
上述是針對於定長包體,那麼對於不定長包體而言是如何傳輸的呢?
這裡就必須介紹另外一個 http 頭部字段了:
<code>Transfer-Encoding: chunked/<code>
表示分塊傳輸數據,設置這個字段後會自動產生兩個效果:
- Content-Length 字段會被忽略
- 基於長連接持續推送動態內容
我們依然以一個實際的例子來模擬分塊傳輸,nodejs 程序如下:
<code>const http = require('http'); const server = http.createServer(); server.on('request', (req, res) => { if(req.url === '/') { res.setHeader('Content-Type', 'text/html; charset=utf8'); res.setHeader('Content-Length', 10); res.setHeader('Transfer-Encoding', 'chunked'); res.write("來啦
"); setTimeout(() => { res.write("第一次傳輸
"); }, 1000); setTimeout(() => { res.write("第二次傳輸"); res.end() }, 2000); } }) server.listen(8009, () => { console.log("成功啟動"); })/<code>
訪問效果如下:
用 telnet 抓到的響應如下:
注意,Connection: keep-alive及之前的為響應行和響應頭,後面的內容為響應體,這兩部分用換行符隔開。
響應體的結構比較有意思,如下所示:
<code>chunk長度(16進制的數) 第一個chunk的內容 chunk長度(16進制的數) 第二個chunk的內容 ...... 0 /<code>
最後是留有有一個空行的,這一點請大家注意。
以上便是 http 對於定長數據和不定長數據的傳輸方式。
008: HTTP 如何處理大文件的傳輸?
對於幾百 M 甚至上 G 的大文件來說,如果要一口氣全部傳輸過來顯然是不現實的,會有大量的等待時間,嚴重影響用戶體驗。因此,HTTP 針對這一場景,採取了範圍請求的解決方案,允許客戶端僅僅請求一個資源的一部分。
如何支持
當然,前提是服務器要支持範圍請求,要支持這個功能,就必須加上這樣一個響應頭:
<code>Accept-Ranges: none/<code>
用來告知客戶端這邊是支持範圍請求的。
Range 字段拆解
而對於客戶端而言,它需要指定請求哪一部分,通過Range這個請求頭字段確定,格式為bytes=x-y。接下來就來討論一下這個 Range 的書寫格式:
- 0-499表示從開始到第 499 個字節。
- 500- 表示從第 500 字節到文件終點。
- -100表示文件的最後100個字節。
服務器收到請求之後,首先驗證範圍是否合法,如果越界了那麼返回416錯誤碼,否則讀取相應片段,返回206狀態碼。
同時,服務器需要添加Content-Range字段,這個字段的格式根據請求頭中Range字段的不同而有所差異。
具體來說,請求單段數據和請求多段數據,響應頭是不一樣的。
舉個例子:
<code>// 單段數據 Range: bytes=0-9 // 多段數據 Range: bytes=0-9, 30-39 /<code>
接下來我們就分別來討論著兩種情況。
單段數據
對於單段數據的請求,返回的響應如下:
<code>HTTP/1.1 206 Partial Content Content-Length: 10 Accept-Ranges: bytes Content-Range: bytes 0-9/100 i am xxxxx/<code>
值得注意的是Content-Range字段,0-9表示請求的返回,100表示資源的總大小,很好理解。
多段數據
接下來我們看看多段請求的情況。得到的響應會是下面這個形式:
<code>HTTP/1.1 206 Partial Content Content-Type: multipart/byteranges; boundary=00000010101 Content-Length: 189 Connection: keep-alive Accept-Ranges: bytes --00000010101 Content-Type: text/plain Content-Range: bytes 0-9/96 i am xxxxx --00000010101 Content-Type: text/plain Content-Range: bytes 20-29/96 eex jspy e --00000010101--/<code>
這個時候出現了一個非常關鍵的字段Content-Type: multipart/byteranges;boundary=00000010101,它代表了信息量是這樣的:
- 請求一定是多段數據請求
- 響應體中的分隔符是 00000010101
因此,在響應體中各段數據之間會由這裡指定的分隔符分開,而且在最後的分隔末尾添上--表示結束。
以上就是 http 針對大文件傳輸所採用的手段。
009: HTTP 中如何處理表單數據的提交?
在 http 中,有兩種主要的表單提交的方式,體現在兩種不同的Content-Type取值:
- application/x-www-form-urlencoded
- multipart/form-data
由於表單提交一般是POST請求,很少考慮GET,因此這裡我們將默認提交的數據放在請求體中。
application/x-www-form-urlencoded
對於
application/x-www-form-urlencoded格式的表單內容,有以下特點:
- 其中的數據會被編碼成以&分隔的鍵值對
- 字符以URL編碼方式編碼。
如:
<code>// 轉換過程: {a: 1, b: 2} -> a=1&b=2 -> 如下(最終形式) "a%3D1%26b%3D2"/<code>
multipart/form-data
對於multipart/form-data而言:
- 請求頭中的Content-Type字段會包含boundary,且boundary的值有瀏覽器默認指定。例: Content-Type: multipart/form-data;boundary=----WebkitFormBoundaryRRJKeWfHPGrS4LKe。
- 數據會分為多個部分,每兩個部分之間通過分隔符來分隔,每部分表述均有 HTTP 頭部描述子包體,如Content-Type,在最後的分隔符會加上--表示結束。
相應的請求體是下面這樣:
<code>Content-Disposition: form-data;name="data1"; Content-Type: text/plain data1 ----WebkitFormBoundaryRRJKeWfHPGrS4LKe Content-Disposition: form-data;name="data2"; Content-Type: text/plain data2 ----WebkitFormBoundaryRRJKeWfHPGrS4LKe--/<code>
小結
值得一提的是,multipart/form-data 格式最大的特點在於:每一個表單元素都是獨立的資源表述。另外,你可能在寫業務的過程中,並沒有注意到其中還有boundary的存在,如果你打開抓包工具,確實可以看到不同的表單元素被拆分開了,之所以在平時感覺不到,是以為瀏覽器和 HTTP 給你封裝了這一系列操作。
而且,在實際的場景中,對於圖片等文件的上傳,基本採用multipart/form-data而不用
application/x-www-form-urlencoded,因為沒有必要做 URL 編碼,帶來巨大耗時的同時也佔用了更多的空間。
010: HTTP1.1 如何解決 HTTP 的隊頭阻塞問題?
什麼是 HTTP 隊頭阻塞?
從前面的小節可以知道,HTTP 傳輸是基於請求-應答的模式進行的,報文必須是一發一收,但值得注意的是,裡面的任務被放在一個任務隊列中串行執行,一旦隊首的請求處理太慢,就會阻塞後面請求的處理。這就是著名的HTTP隊頭阻塞問題。
併發連接
對於一個域名允許分配多個長連接,那麼相當於增加了任務隊列,不至於一個隊伍的任務阻塞其它所有任務。在RFC2616規定過客戶端最多併發 2 個連接,不過事實上在現在的瀏覽器標準中,這個上限要多很多,Chrome 中是 6 個。
但其實,即使是提高了併發連接,還是不能滿足人們對性能的需求。
域名分片
一個域名不是可以併發 6 個長連接嗎?那我就多分幾個域名。
比如 content1.sanyuan.com 、content2.sanyuan.com。
這樣一個sanyuan.com域名下可以分出非常多的二級域名,而它們都指向同樣的一臺服務器,能夠併發的長連接數更多了,事實上也更好地解決了隊頭阻塞的問題。
011: 對 Cookie 瞭解多少?
Cookie 簡介
前面說到了 HTTP 是一個無狀態的協議,每次 http 請求都是獨立、無關的,默認不需要保留狀態信息。但有時候需要保存一些狀態,怎麼辦呢?
HTTP 為此引入了 Cookie。Cookie 本質上就是瀏覽器裡面存儲的一個很小的文本文件,內部以鍵值對的方式來存儲(在chrome開發者面板的Application這一欄可以看到)。向同一個域名下發送請求,都會攜帶相同的 Cookie,服務器拿到 Cookie 進行解析,便能拿到客戶端的狀態。而服務端可以通過響應頭中的Set-Cookie字段來對客戶端寫入Cookie。舉例如下:
<code>// 請求頭 Cookie: a=xxx;b=xxx // 響應頭 Set-Cookie: a=xxx set-Cookie: b=xxx/<code>
Cookie 屬性
生存週期
Cookie 的有效期可以通過Expires和Max-Age兩個屬性來設置。
- Expires即過期時間
- Max-Age用的是一段時間間隔,單位是秒,從瀏覽器收到報文開始計算。
若 Cookie 過期,則這個 Cookie 會被刪除,並不會發送給服務端。
作用域
關於作用域也有兩個屬性: Domain和path, 給 Cookie 綁定了域名和路徑,在發送請求之前,發現域名或者路徑和這兩個屬性不匹配,那麼就不會帶上 Cookie。值得注意的是,對於路徑來說,/表示域名下的任意路徑都允許使用 Cookie。
安全相關
如果帶上Secure,說明只能通過 HTTPS 傳輸 cookie。
如果 cookie 字段帶上HttpOnly,那麼說明只能通過 HTTP 協議傳輸,不能通過 JS 訪問,這也是預防 XSS 攻擊的重要手段。
相應的,對於 CSRF 攻擊的預防,也有SameSite屬性。
SameSite可以設置為三個值,Strict、Lax和None。
a. 在Strict模式下,瀏覽器完全禁止第三方請求攜帶Cookie。比如請求sanyuan.com網站只能在sanyuan.com域名當中請求才能攜帶 Cookie,在其他網站請求都不能。
b. 在Lax模式,就寬鬆一點了,但是隻能在 get 方法提交表單況或者a 標籤發送 get 請求的情況下可以攜帶 Cookie,其他情況均不能。
c. 在None模式下,也就是默認模式,請求會自動攜帶上 Cookie。
Cookie 的缺點
- 容量缺陷。Cookie 的體積上限只有4KB,只能用來存儲少量的信息。
- 性能缺陷。Cookie 緊跟域名,不管域名下面的某一個地址需不需要這個 Cookie ,請求都會攜帶上完整的 Cookie,這樣隨著請求數的增多,其實會造成巨大的性能浪費的,因為請求攜帶了很多不必要的內容。但可以通過Domain和Path指定作用域來解決。
- 安全缺陷。由於 Cookie 以純文本的形式在瀏覽器和服務器中傳遞,很容易被非法用戶截獲,然後進行一系列的篡改,在 Cookie 的有效期內重新發送給服務器,這是相當危險的。另外,在HttpOnly為 false 的情況下,Cookie 信息能直接通過 JS 腳本來讀取。
012: 如何理解 HTTP 代理?
我們知道在 HTTP 是基於請求-響應模型的協議,一般由客戶端發請求,服務器來進行響應。
當然,也有特殊情況,就是代理服務器的情況。引入代理之後,作為代理的服務器相當於一箇中間人的角色,對於客戶端而言,表現為服務器進行響應;而對於源服務器,表現為客戶端發起請求,具有雙重身份。
那代理服務器到底是用來做什麼的呢?
功能
- 負載均衡。客戶端的請求只會先到達代理服務器,後面到底有多少源服務器,IP 都是多少,客戶端是不知道的。因此,這個代理服務器可以拿到這個請求之後,可以通過特定的算法分發給不同的源服務器,讓各臺源服務器的負載儘量平均。當然,這樣的算法有很多,包括隨機算法、輪詢、一致性hash、LRU(最近最少使用)等等,不過這些算法並不是本文的重點,大家有興趣自己可以研究一下。
- 保障安全。利用心跳機制監控後臺的服務器,一旦發現故障機就將其踢出集群。並且對於上下行的數據進行過濾,對非法 IP 限流,這些都是代理服務器的工作。
- 緩存代理。將內容緩存到代理服務器,使得客戶端可以直接從代理服務器獲得而不用到源服務器那裡。下一節詳細拆解。
相關頭部字段
Via
代理服務器需要標明自己的身份,在 HTTP 傳輸中留下自己的痕跡,怎麼辦呢?
通過Via字段來記錄。舉個例子,現在中間有兩臺代理服務器,在客戶端發送請求後會經歷這樣一個過程:
<code>客戶端 -> 代理1 -> 代理2 -> 源服務器/<code>
在源服務器收到請求後,會在請求頭拿到這個字段:
<code>Via: proxy_server1, proxy_server2/<code>
而源服務器響應時,最終在客戶端會拿到這樣的響應頭:
<code>Via: proxy_server2, proxy_server1/<code>
可以看到,Via中代理的順序即為在 HTTP 傳輸中報文傳達的順序。
X-Forwarded-For
字面意思就是為誰轉發, 它記錄的是請求方的IP地址(注意,和Via區分開,X-Forwarded-For記錄的是請求方這一個IP)。
X-Real-IP
是一種獲取用戶真實 IP 的字段,不管中間經過多少代理,這個字段始終記錄最初的客戶端的IP。
相應的,還有X-Forwarded-Host和X-Forwarded-Proto,分別記錄客戶端(注意哦,不包括代理)的域名和協議名。
X-Forwarded-For產生的問題
前面可以看到,X-Forwarded-For這個字段記錄的是請求方的 IP,這意味著每經過一個不同的代理,這個字段的名字都要變,從客戶端到代理1,這個字段是客戶端的 IP,從代理1到代理2,這個字段就變為了代理1的 IP。
但是這會產生兩個問題:
- 意味著代理必須解析 HTTP 請求頭,然後修改,比直接轉發數據性能下降。
- 在 HTTPS 通信加密的過程中,原始報文是不允許修改的。
由此產生了代理協議,一般使用明文版本,只需要在 HTTP 請求行上面加上這樣格式的文本即可:
<code>// PROXY + TCP4/TCP6 + 請求方地址 + 接收方地址 + 請求端口 + 接收端口 PROXY TCP4 0.0.0.1 0.0.0.2 1111 2222 GET / HTTP/1.1 .../<code>
這樣就可以解決X-Forwarded-For帶來的問題了。
013: 如何理解 HTTP 緩存及緩存代理?
關於強緩存和協商緩存的內容,我已經在【說一說瀏覽器緩存】做了詳細分析,小結如下:
首先通過 Cache-Control 驗證強緩存是否可用
- 如果強緩存可用,直接使用
- 否則進入協商緩存,即發送 HTTP 請求,服務器通過請求頭中的If-Modified-Since或者If-None-Match這些條件請求字段檢查資源是否更新
- 若資源更新,返回資源和200狀態碼
- 否則,返回304,告訴瀏覽器直接從緩存獲取資源
這一節我們主要來說說另外一種緩存方式: 代理緩存。
為什麼產生代理緩存?
對於源服務器來說,它也是有緩存的,比如Redis, Memcache,但對於 HTTP 緩存來說,如果每次客戶端緩存失效都要到源服務器獲取,那給源服務器的壓力是很大的。
由此引入了緩存代理的機制。讓代理服務器接管一部分的服務端HTTP緩存,客戶端緩存過期後就近到代理緩存中獲取,代理緩存過期了才請求源服務器,這樣流量巨大的時候能明顯降低源服務器的壓力。
那緩存代理究竟是如何做到的呢?
總的來說,緩存代理的控制分為兩部分,一部分是源服務器端的控制,一部分是客戶端的控制。
源服務器的緩存控制
private 和 public
在源服務器的響應頭中,會加上Cache-Control這個字段進行緩存控制字段,那麼它的值當中可以加入private或者public表示是否允許代理服務器緩存,前者禁止,後者為允許。
比如對於一些非常私密的數據,如果緩存到代理服務器,別人直接訪問代理就可以拿到這些數據,是非常危險的,因此對於這些數據一般是不會允許代理服務器進行緩存的,將響應頭部的Cache-Control設為private,而不是public。
proxy-revalidate
must-revalidate的意思是客戶端緩存過期就去源服務器獲取,而proxy-revalidate則表示代理服務器的緩存過期後到源服務器獲取。
s-maxage
s是share的意思,限定了緩存在代理服務器中可以存放多久,和限制客戶端緩存時間的max-age並不衝突。
講了這幾個字段,我們不妨來舉個小例子,源服務器在響應頭中加入這樣一個字段:
<code>Cache-Control: public, max-age=1000, s-maxage=2000/<code>
相當於源服務器說: 我這個響應是允許代理服務器緩存的,客戶端緩存過期了到代理中拿,並且在客戶端的緩存時間為 1000 秒,在代理服務器中的緩存時間為 2000 s。
客戶端的緩存控制
max-stale 和 min-fresh
在客戶端的請求頭中,可以加入這兩個字段,來對代理服務器上的緩存進行寬容和限制 操作。比如:
<code>max-stale: 5/<code>
表示客戶端到代理服務器上拿緩存的時候,即使代理緩存過期了也不要緊,只要過期時間在5秒之內,還是可以從代理中獲取的。
又比如:
<code>min-fresh: 5/<code>
表示代理緩存需要一定的新鮮度,不要等到緩存剛好到期再拿,一定要在到期前 5 秒之前的時間拿,否則拿不到。
only-if-cached
這個字段加上後表示客戶端只會接受代理緩存,而不會接受源服務器的響應。如果代理緩存無效,則直接返回504(Gateway Timeout)。
以上便是緩存代理的內容,涉及的字段比較多,希望能好好回顧一下,加深理解。
014: 什麼是跨域?瀏覽器如何攔截響應?如何解決?
在前後端分離的開發模式中,經常會遇到跨域問題,即 Ajax 請求發出去了,服務器也成功響應了,前端就是拿不到這個響應。接下來我們就來好好討論一下這個問題。
什麼是跨域
回顧一下 URI 的組成:
瀏覽器遵循同源政策(scheme(協議)、host(主機)和port(端口)都相同則為同源)。非同源站點有這樣一些限制:
- 不能讀取和修改對方的 DOM
- 不讀訪問對方的 Cookie、IndexDB 和 LocalStorage
- 限制 XMLHttpRequest 請求。(後面的話題著重圍繞這個)
當瀏覽器向目標 URI 發 Ajax 請求時,只要當前 URL 和目標 URL 不同源,則產生跨域,被稱為跨域請求。
跨域請求的響應一般會被瀏覽器所攔截,注意,是被瀏覽器攔截,響應其實是成功到達客戶端了。那這個攔截是如何發生呢?
首先要知道的是,瀏覽器是多進程的,以 Chrome 為例,進程組成如下:
WebKit 渲染引擎和V8 引擎都在渲染進程當中。
當xhr.send被調用,即 Ajax 請求準備發送的時候,其實還只是在渲染進程的處理。為了防止黑客通過腳本觸碰到系統資源,瀏覽器將每一個渲染進程裝進了沙箱,並且為了防止 CPU 芯片一直存在的Spectre 和 Meltdown漏洞,採取了站點隔離的手段,給每一個不同的站點(一級域名不同)分配了沙箱,互不干擾。具體見YouTube上Chromium安全團隊的演講視頻。
在沙箱當中的渲染進程是沒有辦法發送網絡請求的,那怎麼辦?只能通過網絡進程來發送。那這樣就涉及到進程間通信(IPC,Inter Process Communication)了。接下來我們看看 chromium 當中進程間通信是如何完成的,在 chromium 源碼中調用順序如下:
可能看了你會比較懵,如果想深入瞭解可以去看看 chromium 最新的源代碼,IPC源碼地址及Chromium IPC源碼解析文章。
總的來說就是利用Unix Domain Socket套接字,配合事件驅動的高性能網絡併發庫libevent完成進程的 IPC 過程。
好,現在數據傳遞給了瀏覽器主進程,主進程接收到後,才真正地發出相應的網絡請求。
在服務端處理完數據後,將響應返回,主進程檢查到跨域,且沒有cors(後面會詳細說)響應頭,將響應體全部丟掉,並不會發送給渲染進程。這就達到了攔截數據的目的。
接下來我們來說一說解決跨域問題的幾種方案。
CORS
CORS 其實是 W3C 的一個標準,全稱是跨域資源共享。它需要瀏覽器和服務器的共同支持,具體來說,非 IE 和 IE10 以上支持CORS,服務器需要附加特定的響應頭,後面具體拆解。不過在弄清楚 CORS 的原理之前,我們需要清楚兩個概念: 簡單請求和非簡單請求。
瀏覽器根據請求方法和請求頭的特定字段,將請求做了一下分類,具體來說規則是這樣,凡是滿足下麵條件的屬於簡單請求:
- 請求方法為 GET、POST 或者 HEAD
- 請求頭的取值範圍: Accept、Accept-Language、Content-Language、Content-Type(只限於三個值application/x-www-form-urlencoded、multipart/form-data、text/plain)
瀏覽器畫了這樣一個圈,在這個圈裡面的就是簡單請求, 圈外面的就是非簡單請求,然後針對這兩種不同的請求進行不同的處理。
簡單請求
請求發出去之前,瀏覽器做了什麼?
它會自動在請求頭當中,添加一個Origin字段,用來說明請求來自哪個源。服務器拿到請求之後,在回應時對應地添加
Access-Control-Allow-Origin字段,如果Origin不在這個字段的範圍中,那麼瀏覽器就會將響應攔截。
因此,
Access-Control-Allow-Origin字段是服務器用來決定瀏覽器是否攔截這個響應,這是必需的字段。與此同時,其它一些可選的功能性的字段,用來描述如果不會攔截,這些字段將會發揮各自的作用。
Access-Control-Allow-Credentials。這個字段是一個布爾值,表示是否允許發送 Cookie,對於跨域請求,瀏覽器對這個字段默認值設為 false,而如果需要拿到瀏覽器的 Cookie,需要添加這個響應頭並設為true, 並且在前端也需要設置withCredentials屬性:
<code>let xhr = new XMLHttpRequest(); xhr.withCredentials = true;/<code>
Access-Control-Expose-Headers。這個字段是給 XMLHttpRequest 對象賦能,讓它不僅可以拿到基本的 6 個響應頭字段(包括Cache-Control、Content-Language、Content-Type、Expires、Last-Modified和Pragma), 還能拿到這個字段聲明的響應頭字段。比如這樣設置:
<code>Access-Control-Expose-Headers: aaa/<code>
那麼在前端可以通過
XMLHttpRequest.getResponseHeader('aaa') 拿到 aaa 這個字段的值。
非簡單請求
非簡單請求相對而言會有些不同,體現在兩個方面: 預檢請求和響應字段。
我們以 PUT 方法為例。
<code>var url = 'http://xxx.com'; var xhr = new XMLHttpRequest(); xhr.open('PUT', url, true); xhr.setRequestHeader('X-Custom-Header', 'xxx'); xhr.send();/<code>
當這段代碼執行後,首先會發送預檢請求。這個預檢請求的請求行和請求體是下面這個格式:
<code>OPTIONS / HTTP/1.1 Origin: 當前地址 Host: xxx.com Access-Control-Request-Method: PUT Access-Control-Request-Headers: X-Custom-Header/<code>
預檢請求的方法是OPTIONS,同時會加上Origin源地址和Host目標地址,這很簡單。同時也會加上兩個關鍵的字段:
- Access-Control-Request-Method, 列出 CORS 請求用到哪個HTTP方法
- Access-Control-Request-Headers,指定 CORS 請求將要加上什麼請求頭
這是預檢請求。接下來是響應字段,響應字段也分為兩部分,一部分是對於預檢請求的響應,一部分是對於 CORS 請求的響應。
預檢請求的響應。如下面的格式:
<code>HTTP/1.1 200 OK Access-Control-Allow-Origin: * Access-Control-Allow-Methods: GET, POST, PUT Access-Control-Allow-Headers: X-Custom-Header Access-Control-Allow-Credentials: true Access-Control-Max-Age: 1728000 Content-Type: text/html; charset=utf-8 Content-Encoding: gzip Content-Length: 0/<code>
其中有這樣幾個關鍵的響應頭字段 :
- Access-Control-Allow-Origin: 表示可以允許請求的源,可以填具體的源名,也可以填*表示允許任意源請求。
- Access-Control-Allow-Methods: 表示允許的請求方法列表。
- Access-Control-Allow-Credentials: 簡單請求中已經介紹。
- Access-Control-Allow-Headers: 表示允許發送的請求頭字段
- Access-Control-Max-Age: 預檢請求的有效期,在此期間,不用發出另外一條預檢請求。
在預檢請求的響應返回後,如果請求不滿足響應頭的條件,則觸發XMLHttpRequest的onerror方法,當然後面真正的CORS請求也不會發出去了。
CORS 請求的響應。繞了這麼一大轉,到了真正的 CORS 請求就容易多了,現在它和簡單請求的情況是一樣的。瀏覽器自動加上Origin字段,服務端響應頭返回
Access-Control-Allow-Origin。可以參考以上簡單請求部分的內容。
JSONP
雖然XMLHttpRequest對象遵循同源政策,但是script標籤不一樣,它可以通過 src 填上目標地址從而發出 GET 請求,實現跨域請求並拿到響應。這也就是 JSONP 的原理,接下來我們就來封裝一個 JSONP:
<code>const jsonp = ({ url, params, callbackName }) => { const generateURL = () => { let dataStr = ''; for(let key in params) { dataStr += `${key}=${params[key]}&`; } dataStr += `callback=${callbackName}`; return `${url}?${dataStr}`; }; return new Promise((resolve, reject) => { // 初始化回調函數名稱 callbackName = callbackName || Math.random().toString.replace(',', ''); // 創建>
當然在服務端也會有響應的操作, 以 express 為例:
<code>let express = require('express') let app = express() app.get('/', function(req, res) { let { a, b, callback } = req.query console.log(a); // 1 console.log(b); // 2 // 注意哦,返回給script標籤,瀏覽器直接把這部分字符串執行 res.end(`${callback}('數據包')`); }) app.listen(3000)/<code>
前端這樣簡單地調用一下就好了:
<code>jsonp({ url: 'http://localhost:3000', params: { a: 1, b: 2 } }).then(data => { // 拿到數據進行處理 console.log(data); // 數據包 })/<code>
和CORS相比,JSONP 最大的優勢在於兼容性好,IE 低版本不能使用 CORS 但可以使用 JSONP,缺點也很明顯,請求方法單一,只支持 GET 請求。
Nginx
Nginx 是一種高性能的反向代理服務器,可以用來輕鬆解決跨域問題。
what?反向代理?我給你看一張圖你就懂了。
正向代理幫助客戶端訪問客戶端自己訪問不到的服務器,然後將結果返回給客戶端。
反向代理拿到客戶端的請求,將請求轉發給其他的服務器,主要的場景是維持服務器集群的負載均衡,換句話說,反向代理幫其它的服務器拿到請求,然後選擇一個合適的服務器,將請求轉交給它。
因此,兩者的區別就很明顯了,正向代理服務器是幫客戶端做事情,而反向代理服務器是幫其它的服務器做事情。
好了,那 Nginx 是如何來解決跨域的呢?
比如說現在客戶端的域名為client.com,服務器的域名為server.com,客戶端向服務器發送 Ajax 請求,當然會跨域了,那這個時候讓 Nginx 登場了,通過下面這個配置:
<code>server { listen 80; server_name client.com; location /api { proxy_pass server.com; } }/<code>
Nginx 相當於起了一個跳板機,這個跳板機的域名也是client.com,讓客戶端首先訪問 client.com/api,這當然沒有跨域,然後 Nginx 服務器作為反向代理,將請求轉發給server.com,當響應返回時又將響應給到客戶端,這就完成整個跨域請求的過程。
其實還有一些不太常用的方式,大家瞭解即可,比如postMessage,當然WebSocket也是一種方式,但是已經不屬於 HTTP 的範疇,另外一些奇技淫巧就不建議大家去死記硬背了,一方面從來不用,名字都難得記住,另一方面臨時背下來,面試官也不會對你印象加分,因為看得出來是背的。當然沒有背並不代表減分,把跨域原理和前面三種主要的跨域方式理解清楚,經得起更深一步的推敲,反而會讓別人覺得你是一個靠譜的人。
015: TLS1.2 握手的過程是怎樣的?
之前談到了 HTTP 是明文傳輸的協議,傳輸保文對外完全透明,非常不安全,那如何進一步保證安全性呢?
由此產生了 HTTPS,其實它並不是一個新的協議,而是在 HTTP 下面增加了一層 SSL/TLS 協議,簡單的講,HTTPS = HTTP + SSL/TLS。
那什麼是 SSL/TLS 呢?
SSL 即安全套接層(Secure Sockets Layer),在 OSI 七層模型中處於會話層(第 5 層)。之前 SSL 出過三個大版本,當它發展到第三個大版本的時候才被標準化,成為 TLS(傳輸層安全,Transport Layer Security),並被當做 TLS1.0 的版本,準確地說,TLS1.0 = SSL3.1。
現在主流的版本是 TLS/1.2, 之前的 TLS1.0、TLS1.1 都被認為是不安全的,在不久的將來會被完全淘汰。因此我們接下來主要討論的是 TLS1.2, 當然在 2018 年推出了更加優秀的 TLS1.3,大大優化了 TLS 握手過程,這個我們放在下一節再去說。
TLS 握手的過程比較複雜,寫文章之前我查閱了大量的資料,發現對 TLS 初學者非常不友好,也有很多知識點說的含糊不清,可以說這個整理的過程是相當痛苦了。希望我下面的拆解能夠幫你理解得更順暢些吧 : )
傳統 RSA 握手
先來說說傳統的 TLS 握手,也是大家在網上經常看到的。我之前也寫過這樣的文章,(傳統RSA版本)HTTPS為什麼讓數據傳輸更安全,其中也介紹到了對稱加密和非對稱加密的概念,建議大家去讀一讀,不再贅述。之所以稱它為 RSA 版本,是因為它在加解密pre_random的時候採用的是 RSA 算法。
TLS 1.2 握手過程
現在我們來講講主流的 TLS 1.2 版本所採用的方式。
剛開始你可能會比較懵,先彆著急,過一遍下面的流程再來看會豁然開朗。
step 1: Client Hello
首先,瀏覽器發送 client_random、TLS版本、加密套件列表。
client_random 是什麼?用來最終 secret 的一個參數。
加密套件列表是什麼?我舉個例子,加密套件列表一般張這樣:
<code>TLS_ECDHE_WITH_AES_128_GCM_SHA256/<code>
意思是TLS握手過程中,使用ECDHE算法生成pre_random(這個數後面會介紹),128位的AES算法進行對稱加密,在對稱加密的過程中使用主流的GCM分組模式,因為對稱加密中很重要的一個問題就是如何分組。最後一個是哈希摘要算法,採用SHA256算法。
其中值得解釋一下的是這個哈希摘要算法,試想一個這樣的場景,服務端現在給客戶端發消息來了,客戶端並不知道此時的消息到底是服務端發的,還是中間人偽造的消息呢?現在引入這個哈希摘要算法,將服務端的證書信息通過這個算法生成一個摘要(可以理解為比較短的字符串),用來標識這個服務端的身份,用私鑰加密後把加密後的標識和自己的公鑰傳給客戶端。客戶端拿到這個公鑰來解密,生成另外一份摘要。兩個摘要進行對比,如果相同則能確認服務端的身份。這也就是所謂數字簽名的原理。其中除了哈希算法,最重要的過程是
私鑰加密,公鑰解密。step 2: Server Hello
可以看到服務器一口氣給客戶端回覆了非常多的內容。
server_random也是最後生成secret的一個參數, 同時確認 TLS 版本、需要使用的加密套件和自己的證書,這都不難理解。那剩下的server_params是幹嘛的呢?
我們先埋個伏筆,現在你只需要知道,server_random到達了客戶端。
step 3: Client 驗證證書,生成secret
客戶端驗證服務端傳來的證書和簽名是否通過,如果驗證通過,則傳遞client_params這個參數給服務器。
接著客戶端通過ECDHE算法計算出pre_random,其中傳入兩個參數:server_params和client_params。現在你應該清楚這個兩個參數的作用了吧,由於ECDHE基於橢圓曲線離散對數,這兩個參數也稱作橢圓曲線的公鑰。
客戶端現在擁有了client_random、server_random和pre_random,接下來將這三個數通過一個偽隨機數函數來計算出最終的secret。
step4: Server 生成 secret
剛剛客戶端不是傳了client_params過來了嗎?
現在服務端開始用ECDHE算法生成pre_random,接著用和客戶端同樣的偽隨機數函數生成最後的secret。
注意事項
TLS的過程基本上講完了,但還有兩點需要注意。
第一、實際上 TLS 握手是一個雙向認證的過程,從 step1 中可以看到,客戶端有能力驗證服務器的身份,那服務器能不能驗證客戶端的身份呢?
當然是可以的。具體來說,在 step3中,客戶端傳送client_params,實際上給服務器傳一個驗證消息,讓服務器將相同的驗證流程(哈希摘要 + 私鑰加密 + 公鑰解密)走一遍,確認客戶端的身份。
第二、當客戶端生成secret後,會給服務端發送一個收尾的消息,告訴服務器之後的都用對稱加密,對稱加密的算法就用第一次約定的。服務器生成完secret也會向客戶端發送一個收尾的消息,告訴客戶端以後就直接用對稱加密來通信。
這個收尾的消息包括兩部分,一部分是Change Cipher Spec,意味著後面加密傳輸了,另一個是Finished消息,這個消息是對之前所有發送的數據做的摘要,對摘要進行加密,讓對方驗證一下。
當雙方都驗證通過之後,握手才正式結束。後面的 HTTP 正式開始傳輸加密報文。
RSA 和 ECDHE 握手過程的區別
- ECDHE 握手,也就是主流的 TLS1.2 握手中,使用ECDHE實現pre_random的加密解密,沒有用到 RSA。
- 使用 ECDHE 還有一個特點,就是客戶端發送完收尾消息後可以提前搶跑,直接發送 HTTP 報文,節省了一個 RTT,不必等到收尾消息到達服務器,然後等服務器返回收尾消息給自己,直接開始發請求。這也叫TLS False Start。
016: TLS 1.3 做了哪些改進?
TLS 1.2 雖然存在了 10 多年,經歷了無數的考驗,但歷史的車輪總是不斷向前的,為了獲得更強的安全、更優秀的性能,在2018年就推出了 TLS1.3,對於TLS1.2做了一系列的改進,主要分為這幾個部分:強化安全 、提高性能。
強化安全
在 TLS1.3 中廢除了非常多的加密算法,最後只保留五個加密套件:
- TLS_AES_128_GCM_SHA256
- TLS_AES_256_GCM_SHA384
- TLS_CHACHA20_POLY1305_SHA256
- TLS_AES_128_GCM_SHA256
- TLS_AES_128_GCM_8_SHA256
可以看到,最後剩下的對稱加密算法只有 AES 和 CHACHA20,之前主流的也會這兩種。分組模式也只剩下 GCM 和 POLY1305, 哈希摘要算法只剩下了 SHA256 和 SHA384 了。
那你可能會問了, 之前RSA這麼重要的非對稱加密算法怎麼不在了?
我覺得有兩方面的原因:
第一、2015年發現了FREAK攻擊,即已經有人發現了 RSA 的漏洞,能夠進行破解了。
第二、一旦私鑰洩露,那麼中間人可以通過私鑰計算出之前所有報文的secret,破解之前所有的密文。
為什麼?回到 RSA 握手的過程中,客戶端拿到服務器的證書後,提取出服務器的公鑰,然後生成pre_random並用公鑰加密傳給服務器,服務器通過私鑰解密,從而拿到真實的pre_random。當中間人拿到了服務器私鑰,並且截獲之前所有報文的時候,那麼就能拿到pre_random、server_random和client_random並根據對應的隨機數函數生成secret,也就是拿到了 TLS 最終的會話密鑰,每一個歷史報文都能通過這樣的方式進行破解。
但ECDHE在每次握手時都會生成臨時的密鑰對,即使私鑰被破解,之前的歷史消息並不會收到影響。這種一次破解並不影響歷史信息的性質也叫前向安全性。
RSA 算法不具備前向安全性,而 ECDHE 具備,因此在 TLS1.3 中徹底取代了RSA。
提升性能
握手改進
流程如下:
大體的方式和 TLS1.2 差不多,不過和 TLS 1.2 相比少了一個 RTT, 服務端不必等待對方驗證證書之後才拿到client_params,而是直接在第一次握手的時候就能夠拿到, 拿到之後立即計算secret,節省了之前不必要的等待時間。同時,這也意味著在第一次握手的時候客戶端需要傳送更多的信息,一口氣給傳完。
這種 TLS 1.3 握手方式也被叫做1-RTT握手。但其實這種1-RTT的握手方式還是有一些優化的空間的,接下來我們來一一介紹這些優化方式。
會話複用
會話複用有兩種方式: Session ID和Session Ticket。
先說說最早出現的Seesion ID,具體做法是客戶端和服務器首次連接後各自保存會話的 ID,並存儲會話密鑰,當再次連接時,客戶端發送ID過來,服務器查找這個 ID 是否存在,如果找到了就直接複用之前的會話狀態,會話密鑰不用重新生成,直接用原來的那份。
但這種方式也存在一個弊端,就是當客戶端數量龐大的時候,對服務端的存儲壓力非常大。
因而出現了第二種方式——Session Ticket。它的思路就是: 服務端的壓力大,那就把壓力分攤給客戶端唄。具體來說,雙方連接成功後,服務器加密會話信息,用Session Ticket消息發給客戶端,讓客戶端保存下來。下次重連的時候,就把這個 Ticket 進行解密,驗證它過沒過期,如果沒過期那就直接恢復之前的會話狀態。
這種方式雖然減小了服務端的存儲壓力,但與帶來了安全問題,即每次用一個固定的密鑰來解密 Ticket 數據,一旦黑客拿到這個密鑰,之前所有的歷史記錄也被破解了。因此為了儘量避免這樣的問題,密鑰需要定期進行更換。
總的來說,這些會話複用的技術在保證1-RTT的同時,也節省了生成會話密鑰這些算法所消耗的時間,是一筆可觀的性能提升。
PSK
剛剛說的都是1-RTT情況下的優化,那能不能優化到0-RTT呢?
答案是可以的。做法其實也很簡單,在發送Session Ticket的同時帶上應用數據,不用等到服務端確認,這種方式被稱為Pre-Shared Key,即 PSK。
這種方式雖然方便,但也帶來了安全問題。中間人截獲PSK的數據,不斷向服務器重複發,類似於 TCP 第一次握手攜帶數據,增加了服務器被攻擊的風險。
總結
TLS1.3 在 TLS1.2 的基礎上廢除了大量的算法,提升了安全性。同時利用會話複用節省了重新生成密鑰的時間,利用 PSK 做到了0-RTT連接。
017: HTTP/2 有哪些改進?
由於 HTTPS 在安全方面已經做的非常好了,HTTP 改進的關注點放在了性能方面。對於 HTTP/2 而言,它對於性能的提升主要在於兩點:
- 頭部壓縮
- 多路複用
當然還有一些顛覆性的功能實現:
- 設置請求優先級
- 服務器推送
這些重大的提升本質上也是為了解決 HTTP 本身的問題而產生的。接下來我們來看看 HTTP/2 解決了哪些問題,以及解決方式具體是如何的。
頭部壓縮
在 HTTP/1.1 及之前的時代,請求體一般會有響應的壓縮編碼過程,通過Content-Encoding頭部字段來指定,但你有沒有想過頭部字段本身的壓縮呢?當請求字段非常複雜的時候,尤其對於 GET 請求,請求報文幾乎全是請求頭,這個時候還是存在非常大的優化空間的。HTTP/2 針對頭部字段,也採用了對應的壓縮算法——HPACK,對請求頭進行壓縮。
HPACK 算法是專門為 HTTP/2 服務的,它主要的亮點有兩個:
- 首先是在服務器和客戶端之間建立哈希表,將用到的字段存放在這張表中,那麼在傳輸的時候對於之前出現過的值,只需要把索引(比如0,1,2,...)傳給對方即可,對方拿到索引查表就行了。這種傳索引的方式,可以說讓請求頭字段得到極大程度的精簡和複用。
<code>HTTP/2 當中廢除了起始行的概念,將起始行中的請求方法、URI、狀態碼轉換成了頭字段,不過這些字段都有一個":"前綴,用來和其它請求頭區分開。/<code>
- 其次是對於整數和字符串進行哈夫曼編碼,哈夫曼編碼的原理就是先將所有出現的字符建立一張索引表,然後讓出現次數多的字符對應的索引儘可能短,傳輸的時候也是傳輸這樣的索引序列,可以達到非常高的壓縮率。
多路複用
HTTP 隊頭阻塞
我們之前討論了 HTTP 隊頭阻塞的問題,其根本原因在於HTTP 基於請求-響應的模型,在同一個 TCP 長連接中,前面的請求沒有得到響應,後面的請求就會被阻塞。
後面我們又討論到用併發連接和域名分片的方式來解決這個問題,但這並沒有真正從 HTTP 本身的層面解決問題,只是增加了 TCP 連接,分攤風險而已。而且這麼做也有弊端,多條 TCP 連接會競爭
有限的帶寬,讓真正優先級高的請求不能優先處理。而 HTTP/2 便從 HTTP 協議本身解決了隊頭阻塞問題。注意,這裡並不是指的TCP隊頭阻塞,而是HTTP隊頭阻塞,兩者並不是一回事。TCP 的隊頭阻塞是在數據包層面,單位是數據包,前一個報文沒有收到便不會將後面收到的報文上傳給 HTTP,而HTTP 的隊頭阻塞是在 HTTP 請求-響應層面,前一個請求沒處理完,後面的請求就要阻塞住。兩者所在的層次不一樣。
那麼 HTTP/2 如何來解決所謂的隊頭阻塞呢?
二進制分幀
首先,HTTP/2 認為明文傳輸對機器而言太麻煩了,不方便計算機的解析,因為對於文本而言會有多義性的字符,比如回車換行到底是內容還是分隔符,在內部需要用到狀態機去識別,效率比較低。於是 HTTP/2 乾脆把報文全部換成二進制格式,全部傳輸01串,方便了機器的解析。
原來Headers + Body的報文格式如今被拆分成了一個個二進制的幀,用Headers幀存放頭部字段,Data幀存放請求體數據。分幀之後,服務器看到的不再是一個個完整的 HTTP 請求報文,而是一堆亂序的二進制幀。這些二進制幀不存在先後關係,因此也就不會排隊等待,也就沒有了 HTTP 的隊頭阻塞問題。
通信雙方都可以給對方發送二進制幀,這種二進制幀的雙向傳輸的序列,也叫做流(Stream)。HTTP/2 用流來在一個 TCP 連接上來進行多個數據幀的通信,這就是多路複用的概念。
可能你會有一個疑問,既然是亂序首發,那最後如何來處理這些亂序的數據幀呢?
首先要聲明的是,所謂的亂序,指的是不同 ID 的 Stream 是亂序的,但同一個 Stream ID 的幀一定是按順序傳輸的。二進制幀到達後對方會將 Stream ID 相同的二進制幀組裝成完整的請求報文和響應報文。當然,在二進制幀當中還有其他的一些字段,實現了優先級和流量控制等功能,我們放到下一節再來介紹。
服務器推送
另外值得一說的是 HTTP/2 的服務器推送(Server Push)。在 HTTP/2 當中,服務器已經不再是完全被動地接收請求,響應請求,它也能新建 stream 來給客戶端發送消息,當 TCP 連接建立之後,比如瀏覽器請求一個 HTML 文件,服務器就可以在返回 HTML 的基礎上,將 HTML 中引用到的其他資源文件一起返回給客戶端,減少客戶端的等待。
總結
當然,HTTP/2 新增那麼多的特性,是不是 HTTP 的語法要重新學呢?不需要,HTTP/2 完全兼容之前 HTTP 的語法和語義,如請求頭、URI、狀態碼、頭部字段都沒有改變,完全不用擔心。同時,在安全方面,HTTP 也支持 TLS,並且現在主流的瀏覽器都公開只支持加密的 HTTP/2, 因此你現在能看到的 HTTP/2 也基本上都是跑在 TLS 上面的了。最後放一張分層圖給大家參考:
018: HTTP/2 中的二進制幀是如何設計的?
幀結構
HTTP/2 中傳輸的幀結構如下圖所示:
每個幀分為幀頭和幀體。先是三個字節的幀長度,這個長度表示的是幀體的長度。
然後是幀類型,大概可以分為數據幀和控制幀兩種。數據幀用來存放 HTTP 報文,控制幀用來管理流的傳輸。
接下來的一個字節是幀標誌,裡面一共有 8 個標誌位,常用的有 END_HEADERS表示頭數據結束,END_STREAM表示單方向數據發送結束。
後 4 個字節是Stream ID, 也就是流標識符,有了它,接收方就能從亂序的二進制幀中選擇出 ID 相同的幀,按順序組裝成請求/響應報文。
流的狀態變化
從前面可以知道,在 HTTP/2 中,所謂的流,其實就是二進制幀的雙向傳輸的序列。那麼在 HTTP/2 請求和響應的過程中,流的狀態是如何變化的呢?
HTTP/2 其實也是借鑑了 TCP 狀態變化的思想,根據幀的標誌位來實現具體的狀態改變。這裡我們以一個普通的請求-響應過程為例來說明:
最開始兩者都是空閒狀態,當客戶端發送Headers幀後,開始分配Stream ID, 此時客戶端的流打開, 服務端接收之後服務端的流也打開,兩端的流都打開之後,就可以互相傳遞數據幀和控制幀了。
當客戶端要關閉時,向服務端發送END_STREAM幀,進入半關閉狀態, 這個時候客戶端只能接收數據,而不能發送數據。
服務端收到這個END_STREAM幀後也進入半關閉狀態,不過此時服務端的情況是隻能發送數據,而不能接收數據。隨後服務端也向客戶端發送END_STREAM幀,表示數據發送完畢,雙方進入關閉狀態。
如果下次要開啟新的流,流 ID 需要自增,直到上限為止,到達上限後開一個新的 TCP 連接重頭開始計數。由於流 ID 字段長度為 4 個字節,最高位又被保留,因此範圍是 0 ~ 2的 31 次方,大約 21 億個。
流傳輸的特性:
- 併發性。一個 HTTP/2 連接上可以同時發多個幀,這一點和 HTTP/1 不同。這也是實現多路複用的基礎。
- 自增性。流 ID 是不可重用的,而是會按順序遞增,達到上限之後又新開 TCP 連接從頭開始。
- 雙向性。客戶端和服務端都可以創建流,互不干擾,雙方都可以作為發送方或者接收方。
- 可設置優先級。可以設置數據幀的優先級,讓服務端先處理重要資源,優化用戶體驗。