大綱
前言
http是目前應用最為廣泛, 也是程式設計師接觸最多的協議之一。今天筆者站在GoPher的角度對http1.1的請求流程進行全面的分析。希望讀者讀完此文後, 能夠有以下幾個收穫:
對http1.1的請求流程有一個大概的了解在平時的開發中能夠更好地復用底層TCP連接對http1.1的線頭阻塞能有一個更清楚的認識HTTP1.1流程
今天內容較多, 廢話不多說, 直接上乾貨。
接下來, 筆者將根據流程圖,對除了NewRequest以外的函數進行逐步的展開和分析
(*Client).do
(*Client).do方法的核心代碼是一個沒有結束條件的for循環。
上面的代碼中, 請求第一次進入會調用c.send, 得到響應後會判斷請求是否需要重定向, 如果需要重定向則繼續循環, 否則返迴響應。
進入重定向流程後, 這裡筆者簡單介紹一下checkRedirect函數:
由上可知, 用戶可以自己定義重定向的檢查規則。如果用戶沒有自定義檢查規則, 則重定向次數不能超過10次。
(*Client).send
(*Client).send方法邏輯較為簡單, 主要看用戶有沒有為http.Client的Jar欄位實現CookieJar接口。主要流程如下:
如果實現了CookieJar接口, 為Request添加保存的cookie信息。調用send函數。如果實現了CookieJar接口, 將Response中的cookie信息保存下來。
另外, 我們還需要關注c.transport()的調用。如果用戶未對http.Client指定Transport則會使用go默認的DefaultTransport。
該Transport實現RoundTripper接口。在go中RoundTripper的定義為「執行單個HTTP事務的能力,獲取給定請求的響應」。
send
send函數會檢查request的URL,以及參數的rt, 和header值。如果URL和rt為nil則直接返回錯誤。同時, 如果請求中設置了用戶信息, 還會檢查並設置basic的驗證頭信息,最後調用rt.RoundTrip得到請求的響應。
(*Transport).RoundTrip
(*Transport).RoundTrip的邏輯很簡單,它會調用(*Transport).roundTrip方法,因此本節實際上是對(*Transport).roundTrip方法的分析。
由上可知, 每次for循環, 會判斷請求上下文是否已經取消, 如果沒有取消則繼續進行後續的流程。
先調用t.getConn方法獲取一個persistConn。因為本篇主旨是http1.1,所以我們直接看http1.1的執行分支。根據源碼中的注釋和實際的debug結果,獲取到連接後, 會繼續調用pconn.roundTrip。(*Transport).getConn
筆者認為這一步在http請求中是非常核心的一個步驟,因為只有和server端建立連接後才能進行後續的通信。
由上能夠清楚的知道, 獲取連接分為以下幾個步驟:
調用t.queueForIdleConn獲取一個空閒且可復用的連接,如果獲取成功則直接返回該連接。如果未獲取到空閒連接則調用t.queueForDial開始新建一個連接。等待w.ready關閉,則可以返回新的連接。(*Transport).queueForIdleConn
(*Transport).queueForIdleConn方法會根據請求的connectMethodKey從t.idleConn獲取一個[]*persistConn切片, 並從切片中,根據算法獲取一個有效的空閒連接。如果未獲取到空閒連接,則將wantConn結構體變量放入t.idleConnWait[w.key]等待隊列,此處wantConn結構體變量就是前面提到的w。
connectMethodKey定義和queueForIdleConn部分關鍵代碼如下:
其中w.tryDeliver方法主要作用是將連接協程安全的賦值給w.pc,並關閉w.ready管道。此時我們便可以和(*Transport).getConn中調用queueForIdleConn成功後的返回值對應上。
(*Transport).queueForDial
(*Transport).queueForDial方法包含三個步驟:
如果t.MaxConnsPerHost小於等於0,執行go t.dialConnFor(w)並返回。其中MaxConnsPerHost代表著每個host的最大連接數,小於等於0表示不限制。如果當前host的連接數不超過t.MaxConnsPerHost,對當前host的連接數+1,然後執行go t.dialConnFor(w)並返回。如果當前host的連接數等於t.MaxConnsPerHost,則將wantConn結構體變量放入t.connsPerHostWait[w.key]等待隊列, 此處wantConn結構體變量就是前面提到的w。另外在放入等待隊列前會先清除隊列中已經失效或者不再等待的變量。
(*Transport).dialConnFor
(*Transport).dialConnFor方法調用t.dialConn獲取一個真正的*persistConn。並將這個連接傳遞給w, 如果w已經獲取到了連接,則會傳遞失敗,此時調用t.putOrCloseIdleConn將連接放回空閒連接池。
如果連接獲取錯誤則會調用t.decConnsPerHost減少當前host的連接數。
(*Transport).putOrCloseIdleConn方法
由上可知,將連接放入t.idleConn前,先檢查t.idleConnWait的數量。如果有請求在等待空閒連接, 則將連接復用,沒有空閒連接時,才將連接放入t.idleConn。連接放入t.idleConn後,還會重置連接的可空閒時間。
另外在t.putOrCloseIdleConn函數中還需要注意兩點:
如果用戶自定義了http.client,且將DisableKeepAlives設置為true,或者將MaxIdleConnsPerHost設置為負數,則連接不會放入t.idleConn即連接不能復用。在判斷已有空閒連接數量時, 如果MaxIdleConnsPerHost 不等於0, 則返回用戶設置的數量,否則返回默認值2,詳見上面(*Transport).maxIdleConnsPerHost函數。綜上, 我們知道對於部分有連接數限制的業務, 我們可以為http.Client自定義一個Transport, 並設置Transport的MaxConnsPerHost,MaxIdleConnsPerHost,IdleConnTimeout和DisableKeepAlives從而達到即限制連接數量, 又能保證一定的並發。
(*Transport).decConnsPerHost方法
由上可知, decConnsPerHost方法主要幹了兩件事:
判斷是否有請求在等待撥號, 如果有則執行go t.dialConnFor(w)。如果沒有請求在等待撥號, 則減少當前host的連接數量。(*Transport).dialConn
根據http.Client的默認配置和實際的debug結果,(*Transport).dialConn方法主要邏輯如下:
調用t.dial(ctx, "tcp", cm.addr())創建TCP連接。如果是https的請求, 則對請求建立安全的tls傳輸通道。為persistConn創建讀寫buffer, 如果用戶沒有自定義讀寫buffer的大小, 根據writeBufferSize和readBufferSize方法可知, 讀寫bufffer的大小默認為4096。執行go pconn.readLoop()和go pconn.writeLoop()開啟讀寫循環然後返回連接。
(*persistConn).roundTrip
(*persistConn).roundTrip方法是http1.1請求的核心之一,該方法在這裡獲取真實的Response並返回給上層。
由上可知, (*persistConn).roundTrip方法可以分為三步:
向連接的writech寫入writeRequest:pc.writech <- writeRequest{req, writeErrCh, continueCh}, 參考(*Transport).dialConn可知pc.writech是一個緩衝大小為1的管道,所以會立馬寫入成功。向連接的reqch寫入requestAndChan:pc.reqch <- requestAndChan, pc.reqch和pc.writech一樣都是緩衝大小為1的管道。其中requestAndChan.ch是一個無緩衝的responseAndError管道,(*persistConn).roundTrip就通過這個管道讀取到真實的響應。開啟for循環select, 等待響應或者超時等信息。(*persistConn).writeLoop 寫循環(*persistConn).writeLoop 方法主體邏輯相對簡單, 把用戶的請求寫入連接的寫緩存buffer, 最後再flush就可以了。
(*persistConn).readLoop 讀循環(*persistConn).readLoop有較多的細節, 我們先看代碼, 然後再逐步分析。
由上可知, 只要連接處於活躍狀態, 則這個讀循環會一直開啟, 直到 連接不活躍或者產生其他錯誤才會結束讀循環。
在上述源碼中,pc.readResponse(rc,trace)會從連接的讀buffer中獲取一個請求對應的Response。
讀到響應之後判斷請求是否是HEAD請求或者響應內容為空,如果是HEAD請求或者響應內容為空則將響應寫入rc.ch, 並將連接放入idleConn(此處因為篇幅的原因省略了源碼內容, 正常請求的邏輯也有寫響應和將連接放入idleConn兩個步驟)。
如果不是HEAD請求並且響應內容不為空即!hasBody || bodyWritable為false:
創建一個緩衝大小為2的等待響應被讀取的管道waitForBodyRead:waitForBodyRead := make(chan bool, 2)將響應的Body修改為bodyEOFSignal結構體。通過上面的源碼我們可以知道,此時的resp.Body中有earlyCloseFn和fn兩個函數。earlyCloseFn函數會向waitForBodyRead管道寫入false, fn函數會判斷響應是否讀完, 如果已經讀完則向waitForBodyRead寫入true否則寫入false。將修改後的響應寫入rc.ch。其中rc.ch從rc := <-pc.reqch獲取,而pc.reqch正是前面(*persistConn).roundTrip函數寫入的requestAndChan。requestAndChan.ch是一個無緩衝的responseAndError管道,(*persistConn).roundTrip通過這個管道讀取到真實的響應。select 讀取 waitForBodyRead被寫入的值。如果讀到到的是true則可以調用tryPutIdleConn(此方法會調用前面提到的(*Transport).tryPutIdleConn方法)將連接放入idleConn從而復用連接。waitForBodyRead寫入true的原因我們已經知道了,但是被寫入true的時機我們尚不明確。
由上述源碼可知, 只有當調用方完整的讀取了響應,該連接才能夠被復用。因此在http1.1中,一個連接上的請求,只有等前一個請求處理完之後才能繼續下一個請求。如果前面的請求處理較慢, 則後面的請求必須等待, 這就是http1.1中的線頭阻塞。
根據上面的邏輯, 我們GoPher在平時的開發中如果遇到了不關心響應的請求, 也一定要記得把響應body讀完以保證連接的復用性。筆者在這裡給出一個demo:
以上,就是筆者整理的HTTP1.1的請求流程。
注意
筆者本著嚴謹的態度, 特此提醒:上述流程中筆者對很多細節並未詳細提及或者僅一筆帶過,希望讀者酌情參考。
總結
在go中發起http1.1的請求時, 如果遇到不關心響應的請求,請務必完整讀取響應內容以保證連接的復用性。如果遇到對連接數有限制的業務,可以通過自定義http.Client的Transport, 並設置Transport的MaxConnsPerHost,MaxIdleConnsPerHost,IdleConnTimeout和DisableKeepAlives的值,來控制連接數。如果對於重定向業務邏輯有需求,可以自定義http.Client的CheckRedirect。在http1.1中,一個連接上的請求,只有等前一個請求處理完之後才能繼續下一個請求。如果前面的請求處理較慢, 則後面的請求必須等待, 這就是http1.1中的線頭阻塞。注: 寫本文時, 筆者所用go版本為: go1.14.2
生命不息, 探索不止, 後續將持續更新有關於go的技術探索
原創不易, 卑微求關注收藏二連.