摘要 本文主要講述了一個 http request 請求從發出到收到 response 的整個生命周期,希望可以通過對整個流程的一個描述來梳理清楚五層網路協議的定義以及各層之間是如何協作的。 使用Golang發起一個HTTP請求 對於後端來說通過 http 請求來進行遠程調用是再尋常不過的事了,以 ...
摘要
本文主要講述了一個 http request 請求從發出到收到 response 的整個生命周期,希望可以通過對整個流程的一個描述來梳理清楚五層網路協議的定義以及各層之間是如何協作的。
使用Golang發起一個HTTP請求
對於後端來說通過 http 請求來進行遠程調用是再尋常不過的事了,以 Golang 的 resty
包為例,我們通過下麵這個語句來發起一個請求並獲得所請求的伺服器的 response,簡單起見這裡我們使用 GET 方法進行請求:
client := resty.New()
headers := map[string]string{
"Connection": "Keep-Alive",
}
resp1, _ := client.R().
EnableTrace().
SetHeaders(headers).
Get("https://httpbin.org/get")
fmt.Println("Request Trace Info:")
ti := resp1.Request.TraceInfo()
fmt.Println(" DNSLookup :", ti.DNSLookup)
fmt.Println(" TCPConnTime :", ti.TCPConnTime)
fmt.Println(" TLSHandshake :", ti.TLSHandshake)
fmt.Println(" IsConnReused :", ti.IsConnReused)
fmt.Println(" RemoteAddr :", ti.RemoteAddr.String())
我們在應用層發起請求,應用層是用戶的,所以 HTTP 報文的內容都是一些人類可閱讀的 ASCII 碼點,但電腦只懂得二進位,光纖中認識光信號,所以這個 HTTP 報文還需要經過一一些處理才能穿越那些物理鏈路發送到我們的目的伺服器上。首先來講講 HTTP 報文格式
在我們這個例子里我們的請求方法是 GET,GET 和 POST 是最常見的 HTTP 方法,除此以外還包括 DELETE、HEAD、OPTIONS、PUT、TRACE。我們沒有傳頭部欄位,也就是 HEADER , HTTP 的頭部可以分為兩種,一種是通用頭部如 Cache-Control、 Connection、Date、Pragma、Transfer-Encoding、Upgrade、Via 等,可以通過它傳遞一些信息,對通用頭部的擴展要求通訊雙方都支持此擴展,如果存在不支持的通用頭部,一般將會作為實體頭部處理。實體頭域包含關於實體的原信息,實體頭包括 Allow、Content-Base、Content-Encoding、Content-Language、Content-Length、Content-Location、Content-MD5、Content-Range、Content-Type、Etag、Expires、Last-Modified、extension-header。extension-header 允許客戶端定義新的實體頭,但是這些域可能無法為接受方識別。
在這個請求里我們也沒有消息體,URL為 https://httpbin.org/get, 是一個很簡單的 GET 請求。
http 響應報文結構和請求差不多,區別在於狀態行,狀態碼(Status-Code)主要用於機器理解,短語(Reason-Phrase)Status-Code 提供一個簡單的文本描述,主要幫助用戶理解:
- 1xx : 信息響應類,表示接收到請求並且繼續處理
- 2xx : 處理成功響應類,表示動作被成功接收、理解和接受
- 3xx : 重定向響應類,為了完成指定的動作,必須接受進一步處理
- 4xx : 客戶端錯誤,客戶請求包含語法錯誤或者是不能正確執行
- 5xx : 服務端錯誤,伺服器不能正確執行一個正確的請求
幾個常見的狀態碼和短語:
- 200 OK 最好的情況,即處理成功
- 404 Not Found 不希望看到的響應之一,即找不到所請求的資源
- 500 Internal Server Error 不希望看到的響應之二,服務端發生了錯誤
說完了 HTTP 報文,接下來我們來實踐一下,看看上面那段代碼發起一個 HTTP 請求,它的運行結果如下:
可以看到我們這個請求是成功了的,對方伺服器返回了 200, 短語是 OK,意味著目標伺服器成功處理了我們的請求。
輸出的Request Trace Info
信息可以幫助我們理解整個請求的過程,我們一行一行地看:
DNSLookup
HTTP 報文里包含了目的伺服器的地址,也就是我們上面輸入的 URL,一個 URL 由協議頭(HTTP、HTTPS、SFTP 等)+ 功能變數名稱 + 資源路徑組成,在我們這個例子里協議頭為https(HTTPS = HTTP + SSL(TLS),它和 HTTP 的區別在於加了一道身份驗證所以更安全),功能變數名稱是 httpbin.org ,資源路徑是 /get,也就是我們以 HTTPS 協議所約定的方式去獲取 httpbin.org 所映射的伺服器上的 /get 路徑下的資源。
功能變數名稱由字元串組成,機器是無法讀懂的,所以我們需要一個服務去將它解析成機器能讀懂的地址,也就是 IP,而這個服務就是 DNS
(Domain Name System)功能變數名稱系統,它是用於實現功能變數名稱和IP地址相互映射的一個分散式資料庫,這裡輸出的 DNSLookup
的值就是本次請求里花費在 DNS
解析上的時間。
功能變數名稱解析的過程大致如下:
完整的DNS解析過程有以下幾個步驟:
(1)查看瀏覽器緩存(我們這裡是直接通過後端來發起請求,所以沒有這一步)
當用戶通過瀏覽器訪問某功能變數名稱時,瀏覽器首先會在自己的緩存中查找是否有該功能變數名稱對應的 IP
地址(若曾經訪問過該功能變數名稱且沒有清空緩存便存在)。
(2)查看系統緩存
當瀏覽器緩存中無功能變數名稱對應 IP
則會自動檢查用戶電腦系統 Hosts
文件 DNS
緩存是否有該功能變數名稱對應 IP。
(3)查看路由器緩存
當瀏覽器及系統緩存中均無功能變數名稱對應 IP
則進入路由器緩存中檢查,以上三步均為客服端的 DNS
緩存。
(4)查看ISP DNS 緩存
當在用戶客服端查找不到功能變數名稱對應 IP
地址,則將進入 ISP DNS
緩存中進行查詢。比如你用的是電信的網路,則會進入電信的 DNS
緩存伺服器中進行查找。
(5)詢問根功能變數名稱伺服器
當以上均未完成,則進入根伺服器進行查詢。全球僅有 13 台根功能變數名稱伺服器,1 個主根功能變數名稱伺服器,其餘 12 為輔根功能變數名稱伺服器。根功能變數名稱收到請求後會查看區域文件記錄,若無則將其管轄範圍內頂級功能變數名稱(如.com、.cn等)伺服器 IP
告訴本地 DNS
伺服器。
(6)詢問頂級功能變數名稱伺服器
頂級功能變數名稱伺服器收到請求後查看區域文件記錄,若無記錄則將其管轄範圍內權威功能變數名稱伺服器的 IP
地址告訴本地 DNS
伺服器。
(7)詢問權威功能變數名稱(主功能變數名稱)伺服器
權威功能變數名稱伺服器接受到請求後查詢自己的緩存,如果沒有則進入下一級功能變數名稱伺服器進行查找,並重覆該步驟直至找到正確記錄。
(8)保存結果至緩存
本地功能變數名稱伺服器把返回的結果保存到緩存,以備下一次使用,同時將該結果反饋給客戶端,客戶端通過這個 IP
地址即可訪問目標Web伺服器。至此,DNS
遞歸查詢的整個過程結束。
通過功能變數名稱解析服務我們獲得了目標伺服器的 IP
,它會在網路層被用到。
TCPConnTime
建立 TCP
(Transmission Control Protocol) 連接所花的時間。TCP 屬於傳輸層協議,除了 TCP
外還有 UDP
也是常用的傳輸層協議。本文的傳輸層協議選擇了 TCP, 我們在應用層準備好了 HTTP
報文,然後選擇一個 TCP server
去傳輸這個 HTTP
報文給目標伺服器,TCP server
通過什麼方式進行與目標伺服器的溝通對於應用層來說是透明的(即不可見),應用層只需要等待 TCP server
返回的目標伺服器的應答結果就好了。
TCP 是面向連接的協議,而 UDP 是無連接的,使用 TCP 進行通信的雙方在互相發送消息之前要先建立一個連接,這個連接建立的過程被稱為三次握手。我們先來看看 TCP 報文格式:
埠(port),主要分為物理埠和邏輯埠。我們一般說的都是邏輯埠,用於區分不同的服務。因為網路中一臺主機只有一個 IP
,但是一個主機可以提供多個服務,埠號就用於區分一個主機上的不同服務。一個IP地址的埠通過16bit進行編號,最多可以有65536個埠,標識是從0~65535。
埠號分為系統埠(System Ports)0~ 1023、用戶埠(User Ports)1024~ 49151和動態埠號(Dynamic Ports)49152~65535。我們自己的服務一般都綁定在註冊埠上。
系統埠(0~ 1023):也叫做公認埠(Well Known Ports),它們緊密綁定(binding)於一些服務。通常這些埠的通訊明確表明瞭某種服務的協議。任何TCP/IP實現所提供的服務都用0-1023之間的埠號。我們的私用埠號不應該使用這個區間內的埠,除非你向IANA註冊了。例如:80埠實際上總是 http 通訊、443對應著 https(在本文中我們使用的就是 https 協議,在最後一行輸出的 RemoteAddr 可以看到目標伺服器的埠號就是443)、21對應著 ftp,25對應著 smtp,110對應著 pop3 等。
互聯網號碼分配局(英語:Internet Assigned Numbers Authority,縮寫 IANA),是一家互聯網地址指派機構,管理國際互聯網中使用的 IP 地址、功能變數名稱和許多其它參數的機構。 IP 地址、自治系統成員以及許多頂級和二級功能變數名稱分配的日常職責由國際互聯網註冊中心(IR)和地區註冊中心承擔。IANA 是由 ICANN 管理的。
用戶埠(1024~ 49151):也叫做註冊埠(Registered Ports),從1024到49151。它們鬆散地綁定於一些服務。也就是說有許多服務綁定於這些埠,這些埠同樣用於許多其它目的。例如:許多系統處理動態埠從1024左右開始。
動態埠(49152~65535):也叫做私有或動態埠(Private or Ephemeral Ports),從49152到65535。理論上,不應為服務分配這些埠。實際上,機器通常從1024起分配動態埠。只要運行的程式向系統提出訪問網路的申請,那麼系統就可以從這些埠號中分配一個供該程式使用。比如 49152 埠就是分配給第一個向系統發出申請的程式。在關閉程式進程後,就會釋放所占用的埠號。
所以當 client
準備發出網路請求的時候,client
所在的進程首先要向系統申請一個埠號作為源埠號,系統會隨機從49152~65535中分配一個可用的埠號給這進程,這樣當目標伺服器處理完請求要給我們返回數據的時候才能通過這個源埠號找到發出請求的這個埠所對應的服務並把 response 交給這個服務。可以說 HTTP
報文是面向服務的,它是服務與服務之間的交流,傳輸層是面向進程的,兩個埠號標識了兩個進程,一個進程里可能會有許多個服務(路由,或者說 API)。
序列號
在一個 TCP
連接中傳送的位元組流中的每一個位元組都按順序編號,這個編號就類似於數組的下標,數組裡每個元素都有自己的下標。例如,一報文段的序號是 101,共有 100 位元組的數據。這就表明:本報文段的數據的第一個位元組的序號是 101,最後一個位元組的序號是 200。顯然,下一個報文段的數據序號應當從 201 開始,即下一個報文段的序號欄位值應為 201。
確認號
期望收到對方下一個報文段的第一個數據位元組的序號。若確認號為 N
,則表明:到序號 N-1
為止的所有數據都已正確收到。
標誌位欄位
比較常見的標誌位 SYN
、ACK
、FIN
會在 TCP
連接建立與釋放的時候使用到,也就是我們常說的三次握手四次揮手。先講講三次握手建立連接:
三次握手的過程如圖所示,連接的建立一般都是由客戶端主動發起的,客戶端發送一個報文給伺服器,告訴它我想要和你建立一個連接進行數據交換,進行數據交換之前有些事情需要先同步(synchronize,也就是 SYN
標誌位,SYN
=1 表示這是一個用於同步信息的報文),雙方得約定好初始序列號(Init Sequense Number,ISN
)、視窗大小等信息,連接建立好了之後交換數據的時候才好判斷數據的起始與結束,通過 seq 欄位來告訴對方本報文的序列號。
第一次握手,SYN
= 1,客戶端告訴伺服器自己的初始序列號是 x (seq = x),伺服器收到了這個報文可以確定客戶端發送正常,自己接收正常。
第二次握手,伺服器端發出報文 SYN
= 1, ACK
= 1(僅當 ACK
=1 時,確認號欄位才有效。TCP規定,在連接建立後所有報文的傳輸都必須把ACK置1),表示這是一個應答報文,並且告訴了客戶端自己的初始序列號是 y (seq
= y),以及確認自己收到了客戶端的前 x 個位元組的信息,接下來希望收到 x + 1 的信息(ack
= x + 1),但是客戶端剛剛的報文明明沒有攜帶信息,為什麼說收到了前 x 個位元組的信息呢,因為 TCP 規定,SYN報文段(SYN
= 1 的報文段)不能攜帶數據,但需要消耗掉一個序號。
第二次握手成功之後客戶端確認了自己發送正常,接收正常,伺服器發送正常,接收正常。伺服器端確認了客戶端發送正常,自己接收正常,所以還需要第三次握手來讓伺服器端確認自己發送正常以及客戶端接收正常。
第三次握手,雙方已經同步完序列號信息了,所以第三次握手不用 SYN
標誌位了,客戶端應答(ACK
= 1)伺服器第二次握手時發來的報文,表示自己收到了伺服器端的前 y 個位元組的信息(ACK
= y + 1),告訴伺服器端自己這個報文的起始序號是 x + 1(seq
= x + 1),當伺服器收到這個報文後伺服器就可以確認自己發送正常以及客戶端接收正常了,連接就建立完成了可以進行信息傳輸了。
如果第三次握手的報文因為各種各樣的原因丟了,伺服器端沒有收到,那麼伺服器就會進行首次重傳,若等待一段時間仍未收到客戶確認包,就進行第二次重傳。如果重傳次數超過系統規定的最大重傳次數,則系統將該連接信息從半連接隊列中刪除。每次重傳等待的時間不一定相同,一般會是指數增長。
說完了三次握手建立連接再來說說四次揮手斷開連接:
四次揮手的過程如圖所示。
第一次揮手,客戶端發出一個 FIN
報文,消耗一個序列號,告訴伺服器端自己沒有要發送的數據了,連接可以斷開了。
第二次揮手,伺服器端伺服器端發送一個 ACK
報文表示收到了客戶端要斷開連接的報文(ACK
= 1, ack
= u + 1),序號 u 之前的數據都以及收到了。此時連接進入半關閉狀態,但是伺服器端可能還有數據沒有發完,所以可以繼續發送數據,直到伺服器端發完了要發的數據,發送第三次揮手的報文。
第三次揮手,伺服器端發送 FIN
報文,消耗一個序列號(seq
= w),告訴客戶端自己的數據也發完了,因為前面是伺服器端單向發數據給客戶端,所以 ack
還是為 u + 1。
第四次揮手,客戶端收到了伺服器端要斷開連接的報文,回覆一個 ACK
報文,讓伺服器端知道自己收到了它的 FIN
報文,伺服器端收到報文後立馬斷開了連接。
首部長度
也叫做數據偏移,它指出 TCP
報文段的數據起始處距離 TCP
報文段的起始處有多遠,也就是 TCP
報文段的首部長度。
視窗大小
報文能不能正常被傳輸、接收,不止取決於通信的雙方,還取決於外部環境,也就是網路環境,因為網路鏈路里不止當前的通信雙方在傳輸數據,而是由很多台在發送數據的主機在共同使用。所以數據要正常被傳輸,有兩個問題要解決,一個是發送方與接收方速率匹配,接收方能及時處理髮送方發送過來的數據,使得發送雙方速率匹配的策略我們稱為流量控制;另一個就是需要有一個良好的網路環境,努力使整體網路環境的通暢的策略我們稱為擁塞控制。流量控制與擁塞控制都是通過設置視窗大小來完成的,當然這兩個概念都是針對 TCP
協議來說的,它是一個無私的協議,UDP
可不管網路是否擁塞,它會一直發向網路中發送數據。
在介紹確認號這個首部欄位的時候我們提到了 TCP
採用的是累積確認的方式,下麵我們來具體講解是怎麼個累積確認法。引入累積確認主要是為了提高通信效率,如果沒有累積確認的話,接收方收到一個報文之後回覆一個 ACK
報文,發送方接到這個 ACK
報文才能發送下一個報文,一包一確認的方式並不是很高明,往返時間越長,通信的效率就越低。而使用累積確認,發送方就可以連續發送一批報文,而不需要等待接收方回覆了再發送下一個包。
雖然發送方可以一次性發一批報文,但是這個批大小肯定不能是無限大的,得有個規則來約束它,這個大小就是視窗大小。視窗大小以位元組為單位,比如當前視窗大小為1024個位元組,那麼在不需要等待接收方確認的情況下發送發可以連續發送報文直到已發送的報文的長度加起來等於1024個位元組。
流量控制:流量控制的過程就是通過不斷的調整視窗大小來給讓接收方能來得及處理接收到的數據,所以視窗大小是由接收方決定的。在三次握手建立 TCP
連接的時候就已經同步好了視窗大小的信息,並且如果在後續的數據傳輸中因為種種原因需要調整視窗大小也是允許的
擁塞控制:引入擁塞控制這個概念後視窗大小就受到流量控制和擁塞控制這兩個策略共同影響了,通過擁塞控制演算法算出擁塞視窗cwnd
的大小,實際視窗大小 = min(cwnd
,rwnd
)rwnd
就是流量控制中的接收視窗。
擁塞視窗cwnd
變化的規則:
- 只要網路中沒有出現擁塞,
cwnd
就會增大; - 一但網路中出現了擁塞,
cwnd
就減少;
只要「發送方」沒有在規定時間內接收到 ACK
應答報文,也就是發生了超時重傳,就會認為網路出現了用擁塞。
擁塞控制主要是四個演算法:
- 慢啟動
- 擁塞避免
- 擁塞發生
- 快速恢復
關於視窗大小的更多細節可以看看這篇博客
校驗和
數據段
segment data,這裡放著 HTTP
請求報文,像這樣:
(圖片來自這篇博客)
RemoteAddr
這個遠端地址包括了目標伺服器的 IP
和埠號, 埠號在上文我們已經說過了,它會被寫在 TCP
報文里,而 IP 則會被寫在網路層的 IP
數據報里。應用層通過 DNS
服務獲得了 IP
,然後通過函數調用傳參的方式來把這個 IP
傳給實現了傳輸層協議的 server,當然傳輸層的的報文里並不需要用到 IP
,但是功能變數名稱是屬於應用層的東西,如果沒有在應用層完成解析獲得 IP,後面的層因為沒有功能變數名稱信息就無法獲得 IP
無法正常工作了,所以 IP 從應用層通過參數傳遞的方式傳給傳輸層,傳輸層再通過參數傳遞的方式傳給真正需要用到它的網路層,讓網路層寫進它自己的報文里。Golang 的 net
包的 net.LookupHost()
函數可以完成功能變數名稱解析獲得 IP
,對 DNS
解析的過程有興趣的朋友可以看看這篇博客。
我們先來看看 IP
數據報的結構:
就像 TCP
報文的數據段應用層報文一樣,IP
數據報的數據部分指的就是傳輸層報文,在本文里就是 TCP
報文,如下圖所示:
IsConnReused
連接是否復用,說到這個話題又回到了應用層的 HTTP
協議,前面我們說到 HTTP
報文有各種各樣的 HEADER
欄位,其中有一個叫做Connection
的通用 header,它的的取值為Keep-Alive
或close
。當在 header 裡加入Connection: Keep-Alive
意味著開啟長連接,
我們把上面例子里的代碼進行一些小小修改,變成這樣:
client := resty.New()
headers := map[string]string{
"Connection": "Keep-Alive",
}
resp1, _ := client.R().
EnableTrace().
SetHeaders(headers).
Get("https://httpbin.org/get")
// Explore trace info
fmt.Println("Request Trace Info:")
ti := resp1.Request.TraceInfo()
fmt.Println(" DNSLookup :", ti.DNSLookup)
fmt.Println(" TCPConnTime :", ti.TCPConnTime)
fmt.Println(" TLSHandshake :", ti.TLSHandshake)
fmt.Println(" IsConnReused :", ti.IsConnReused)
fmt.Println(" RemoteAddr :", ti.RemoteAddr.String())’
resp2, _ := client.R().
EnableTrace().
SetHeaders(headers).
Get("https://httpbin.org/get")
fmt.Println()
fmt.Println("******** Request with Keep-Alive *******")
fmt.Println()
cname, _ := net.LookupCNAME("httpbin.org")
host, _ := net.LookupHost("httpbin.org")
addr, _ := net.LookupAddr("httpbin.org")
ip, _ := net.LookupIP("httpbin.org")
fmt.Println(cname)
fmt.Println(host)
fmt.Println(addr)
fmt.Println(ip)
// Explore trace info
fmt.Println("Request Trace Info:")
ti2 := resp2.Request.TraceInfo()
fmt.Println(" DNSLookup :", ti2.DNSLookup)
fmt.Println(" TCPConnTime :", ti2.TCPConnTime)
fmt.Println(" TLSHandshake :", ti2.TLSHandshake)
fmt.Println(" IsConnReused :", ti2.IsConnReused)
fmt.Println(" RemoteAddr :", ti2.RemoteAddr.String())
這次我們主要想看看 Keep-Alive 的實際效果,所以一些沒啥用的信息就不輸出了,這段代碼的運行效果如下:
可以看到在第二次請求的時候 DNSLookup、 TCPConnTime、TLSHandshake 這三個與連接建立有關的時間花費都變成了0啦!IsConnReused 也由 false 變成了 true,說明這次的連接是復用的第一次請求的時候建立的連接,所以不需要再重新建立連接,那些與連接建立的時間花費自然也變成零了(從圖上可以看出來節省了大約2.5s 的時間)。
關於長連接有興趣的朋友可以看看這篇博客
總結
實踐很重要,上學的時候學計網總覺得是一知半解的,書上說分層協作各層解耦,層與層之間是透明的,也就是互相看不見,