前端HTTP鞏固

前端HTTP鞏固

前端HTTP鞏固

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鞏固

前端HTTP鞏固

不管是請求頭還是響應頭,其中的字段是相當多的,而且牽扯到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 真正最完整的結構是這樣的。

前端HTTP鞏固

可能你會有疑問,好像跟平時見到的不太一樣啊!先別急,我們來一一拆解。

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 的特點概括如下:

  1. 靈活可擴展,主要體現在兩個方面。一個是語義上的自由,只規定了基本格式,比如空格分隔單詞,換行分隔字段,其他的各個部分都沒有嚴格的語法限制。另一個是傳輸形式的多樣性,不僅僅可以傳輸文本,還能傳輸圖片、視頻等任意數據,非常方便。
  2. 可靠傳輸。HTTP 基於 TCP/IP,因此把這一特性繼承了下來。這屬於 TCP 的特性,不具體介紹了。
  3. 請求-應答。也就是一發一收、有來有回, 當然這個請求方和應答方不單單指客戶端和服務器之間,如果某臺服務器作為代理來連接後端的服務端,那麼這臺服務器也會扮演請求方的角色。
  4. 無狀態。這裡的狀態是指通信過程的上下文信息,而每次 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>

最後以一張圖來總結一下吧:

前端HTTP鞏固

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>

此時瀏覽器顯示如下:

前端HTTP鞏固

直接無法顯示了。可以看到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>

訪問效果如下:

前端HTTP鞏固

用 telnet 抓到的響應如下:

前端HTTP鞏固

注意,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 的有效期可以通過ExpiresMax-Age兩個屬性來設置。

  • Expires即過期時間
  • Max-Age用的是一段時間間隔,單位是秒,從瀏覽器收到報文開始計算。

若 Cookie 過期,則這個 Cookie 會被刪除,並不會發送給服務端。

作用域

關於作用域也有兩個屬性: Domainpath, 給 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 的缺點

  1. 容量缺陷。Cookie 的體積上限只有4KB,只能用來存儲少量的信息。
  2. 性能缺陷。Cookie 緊跟域名,不管域名下面的某一個地址需不需要這個 Cookie ,請求都會攜帶上完整的 Cookie,這樣隨著請求數的增多,其實會造成巨大的性能浪費的,因為請求攜帶了很多不必要的內容。但可以通過Domain和Path指定作用域來解決。
  3. 安全缺陷。由於 Cookie 以純文本的形式在瀏覽器和服務器中傳遞,很容易被非法用戶截獲,然後進行一系列的篡改,在 Cookie 的有效期內重新發送給服務器,這是相當危險的。另外,在HttpOnly為 false 的情況下,Cookie 信息能直接通過 JS 腳本來讀取。

012: 如何理解 HTTP 代理?

我們知道在 HTTP 是基於請求-響應模型的協議,一般由客戶端發請求,服務器來進行響應。

當然,也有特殊情況,就是代理服務器的情況。引入代理之後,作為代理的服務器相當於一箇中間人的角色,對於客戶端而言,表現為服務器進行響應;而對於源服務器,表現為客戶端發起請求,具有雙重身份

那代理服務器到底是用來做什麼的呢?

功能

  1. 負載均衡。客戶端的請求只會先到達代理服務器,後面到底有多少源服務器,IP 都是多少,客戶端是不知道的。因此,這個代理服務器可以拿到這個請求之後,可以通過特定的算法分發給不同的源服務器,讓各臺源服務器的負載儘量平均。當然,這樣的算法有很多,包括隨機算法輪詢一致性hashLRU(最近最少使用)等等,不過這些算法並不是本文的重點,大家有興趣自己可以研究一下。
  2. 保障安全。利用心跳機制監控後臺的服務器,一旦發現故障機就將其踢出集群。並且對於上下行的數據進行過濾,對非法 IP 限流,這些都是代理服務器的工作。
  3. 緩存代理。將內容緩存到代理服務器,使得客戶端可以直接從代理服務器獲得而不用到源服務器那裡。下一節詳細拆解。

相關頭部字段

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。

但是這會產生兩個問題:

  1. 意味著代理必須解析 HTTP 請求頭,然後修改,比直接轉發數據性能下降。
  2. 在 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 的組成:

前端HTTP鞏固

瀏覽器遵循同源政策(scheme(協議)、host(主機)和port(端口)都相同則為同源)。非同源站點有這樣一些限制:

  • 不能讀取和修改對方的 DOM
  • 不讀訪問對方的 Cookie、IndexDB 和 LocalStorage
  • 限制 XMLHttpRequest 請求。(後面的話題著重圍繞這個)

當瀏覽器向目標 URI 發 Ajax 請求時,只要當前 URL 和目標 URL 不同源,則產生跨域,被稱為跨域請求。

跨域請求的響應一般會被瀏覽器所攔截,注意,是被瀏覽器攔截,響應其實是成功到達客戶端了。那這個攔截是如何發生呢?

首先要知道的是,瀏覽器是多進程的,以 Chrome 為例,進程組成如下:

前端HTTP鞏固

前端HTTP鞏固

WebKit 渲染引擎V8 引擎都在渲染進程當中。

當xhr.send被調用,即 Ajax 請求準備發送的時候,其實還只是在渲染進程的處理。為了防止黑客通過腳本觸碰到系統資源,瀏覽器將每一個渲染進程裝進了沙箱,並且為了防止 CPU 芯片一直存在的SpectreMeltdown漏洞,採取了站點隔離的手段,給每一個不同的站點(一級域名不同)分配了沙箱,互不干擾。具體見YouTube上Chromium安全團隊的演講視頻。

在沙箱當中的渲染進程是沒有辦法發送網絡請求的,那怎麼辦?只能通過網絡進程來發送。那這樣就涉及到進程間通信(IPC,Inter Process Communication)了。接下來我們看看 chromium 當中進程間通信是如何完成的,在 chromium 源碼中調用順序如下:

前端HTTP鞏固

可能看了你會比較懵,如果想深入瞭解可以去看看 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?反向代理?我給你看一張圖你就懂了。

前端HTTP鞏固

正向代理幫助客戶端訪問客戶端自己訪問不到的服務器,然後將結果返回給客戶端。

反向代理拿到客戶端的請求,將請求轉發給其他的服務器,主要的場景是維持服務器集群的負載均衡,換句話說,反向代理幫其它的服務器拿到請求,然後選擇一個合適的服務器,將請求轉交給它。

因此,兩者的區別就很明顯了,正向代理服務器是幫客戶端做事情,而反向代理服務器是幫其它的服務器做事情。

好了,那 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 版本所採用的方式。

前端HTTP鞏固

剛開始你可能會比較懵,先彆著急,過一遍下面的流程再來看會豁然開朗。

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_paramsclient_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 握手過程的區別

  1. ECDHE 握手,也就是主流的 TLS1.2 握手中,使用ECDHE實現pre_random的加密解密,沒有用到 RSA。
  2. 使用 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

可以看到,最後剩下的對稱加密算法只有 AESCHACHA20,之前主流的也會這兩種。分組模式也只剩下 GCMPOLY1305, 哈希摘要算法只剩下了 SHA256SHA384 了。

那你可能會問了, 之前RSA這麼重要的非對稱加密算法怎麼不在了?

我覺得有兩方面的原因:

第一、2015年發現了FREAK攻擊,即已經有人發現了 RSA 的漏洞,能夠進行破解了。

第二、一旦私鑰洩露,那麼中間人可以通過私鑰計算出之前所有報文的secret,破解之前所有的密文。

為什麼?回到 RSA 握手的過程中,客戶端拿到服務器的證書後,提取出服務器的公鑰,然後生成pre_random並用公鑰加密傳給服務器,服務器通過私鑰解密,從而拿到真實的pre_random。當中間人拿到了服務器私鑰,並且截獲之前所有報文的時候,那麼就能拿到pre_random、server_random和client_random並根據對應的隨機數函數生成secret,也就是拿到了 TLS 最終的會話密鑰,每一個歷史報文都能通過這樣的方式進行破解。

但ECDHE在每次握手時都會生成臨時的密鑰對,即使私鑰被破解,之前的歷史消息並不會收到影響。這種一次破解並不影響歷史信息的性質也叫前向安全性

RSA 算法不具備前向安全性,而 ECDHE 具備,因此在 TLS1.3 中徹底取代了RSA。

提升性能

握手改進

流程如下:

前端HTTP鞏固

大體的方式和 TLS1.2 差不多,不過和 TLS 1.2 相比少了一個 RTT, 服務端不必等待對方驗證證書之後才拿到client_params,而是直接在第一次握手的時候就能夠拿到, 拿到之後立即計算secret,節省了之前不必要的等待時間。同時,這也意味著在第一次握手的時候客戶端需要傳送更多的信息,一口氣給傳完。

這種 TLS 1.3 握手方式也被叫做1-RTT握手。但其實這種1-RTT的握手方式還是有一些優化的空間的,接下來我們來一一介紹這些優化方式。

會話複用

會話複用有兩種方式: Session IDSession 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,...)傳給對方即可,對方拿到索引查表就行了。這種傳索引的方式,可以說讓請求頭字段得到極大程度的精簡和複用。
前端HTTP鞏固

<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 上面的了。最後放一張分層圖給大家參考:

前端HTTP鞏固

018: HTTP/2 中的二進制幀是如何設計的?

幀結構

HTTP/2 中傳輸的幀結構如下圖所示:

前端HTTP鞏固

每個幀分為幀頭和幀體。先是三個字節的幀長度,這個長度表示的是幀體的長度。

然後是幀類型,大概可以分為數據幀控制幀兩種。數據幀用來存放 HTTP 報文,控制幀用來管理流的傳輸。

接下來的一個字節是幀標誌,裡面一共有 8 個標誌位,常用的有 END_HEADERS表示頭數據結束,END_STREAM表示單方向數據發送結束。

後 4 個字節是Stream ID, 也就是流標識符,有了它,接收方就能從亂序的二進制幀中選擇出 ID 相同的幀,按順序組裝成請求/響應報文。

流的狀態變化

從前面可以知道,在 HTTP/2 中,所謂的流,其實就是二進制幀的雙向傳輸的序列。那麼在 HTTP/2 請求和響應的過程中,流的狀態是如何變化的呢?

HTTP/2 其實也是借鑑了 TCP 狀態變化的思想,根據幀的標誌位來實現具體的狀態改變。這裡我們以一個普通的請求-響應過程為例來說明:

前端HTTP鞏固

最開始兩者都是空閒狀態,當客戶端發送Headers幀後,開始分配Stream ID, 此時客戶端的流打開, 服務端接收之後服務端的流也打開,兩端的流都打開之後,就可以互相傳遞數據幀和控制幀了。

當客戶端要關閉時,向服務端發送END_STREAM幀,進入半關閉狀態, 這個時候客戶端只能接收數據,而不能發送數據。

服務端收到這個END_STREAM幀後也進入半關閉狀態,不過此時服務端的情況是隻能發送數據,而不能接收數據。隨後服務端也向客戶端發送END_STREAM幀,表示數據發送完畢,雙方進入關閉狀態。

如果下次要開啟新的流,流 ID 需要自增,直到上限為止,到達上限後開一個新的 TCP 連接重頭開始計數。由於流 ID 字段長度為 4 個字節,最高位又被保留,因此範圍是 0 ~ 2的 31 次方,大約 21 億個。


流傳輸的特性:

  • 併發性。一個 HTTP/2 連接上可以同時發多個幀,這一點和 HTTP/1 不同。這也是實現多路複用的基礎。
  • 自增性。流 ID 是不可重用的,而是會按順序遞增,達到上限之後又新開 TCP 連接從頭開始。
  • 雙向性。客戶端和服務端都可以創建流,互不干擾,雙方都可以作為發送方或者接收方。
  • 可設置優先級。可以設置數據幀的優先級,讓服務端先處理重要資源,優化用戶體驗。


分享到:


相關文章: