圖文講透 Golang 標準庫 net-http 實現原理 -- 客戶端
客戶端的內容將是如何發送請求和接收響應,走完客戶端就把整個流程就完整的串聯起來了!
這次我把調用的核心方法和流程走讀的函數也貼出來,這樣看應該更有邏輯感,重要部分用紅色標記了一下,可以着重看下。
先了解下核心數據結構 Client 和 Request。
Client 結構體
type Client struct {
Transport RoundTripper
CheckRedirect func(req *Request, via []*Request) error
Jar CookieJar
Timeout time.Duration
}
四個字段分別是:
-
• Transport:表示 HTTP 事務,用於處理客戶端的請求連接並等待服務端的響應;
-
• CheckRedirect:處理重定向的策略
-
• Jar:管理和存儲請求中的 cookie
-
• Timeout:超時設置
Request 結構體
Request 字段較多,這裏就列舉一下常見的一些字段
type Request struct {
Method string
URL *url.URL
Header Header
Body io.ReadCloser
Host string
Response *Response
...
}
-
• Method:指定的 HTTP 方法(GET、POST、PUT 等)
-
• URL:請求路徑
-
• Header:請求頭
-
• Body:請求體
-
• Host:服務器主機
-
• Response:響應參數
構造請求
var DefaultClient = &Client{}
func Get(url string) (resp *Response, err error) {
return DefaultClient.Get(url)
}
示例 HTTP 的 Get 方法會調用到 DefaultClient 的 Get 方法,,然後調用到 Client 的 Get 方法。
DefaultClient 是 Client 的一個空實例(跟 DefaultServeMux 有點子相似)
Client.Get
func (c *Client) Get(url string) (resp *Response, err error) {
req, err := NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
return c.Do(req)
}
func NewRequest(method, url string, body io.Reader) (*Request, error) {
return NewRequestWithContext(context.Background(), method, url, body)
}
Client.Get() 根據用戶的入參,請求參數 NewRequest 使用上下文包裝 NewRequestWithContext ,接着通過 Client.Do 方法,處理這個請求。
NewRequestWithContext
func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error) {
...
// 解析url
u, err := urlpkg.Parse(url)
...
rc, ok := body.(io.ReadCloser)
if !ok && body != nil {
rc = ioutil.NopCloser(body)
}
u.Host = removeEmptyPort(u.Host)
req := &Request{
ctx: ctx,
Method: method,
URL: u,
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Header: make(Header),
Body: rc,
Host: u.Host,
}
...
return req, nil
}
NewRequestWithContext 函數主要是功能是將請求封裝成一個 Request 結構體並返回,這個結構體的名稱是 req。
準備發送請求
構造好的 Request 結構 req,會傳入 c.Do() 方法。
我們看下發送請求過程調用了哪些方法,用下圖表示下
🚩 其實不管是 Get 還是 Post 請求的調用流程都是一樣的,只是對外封裝了 Post 和 Get 請求
func (c *Client) do(req *Request) (retres *Response, reterr error) {
...
for {
...
resp, didTimeout, err = send(req, deadline)
if err != nil {
return nil, didTimeout, err
}
}
...
}
//Client 調用 Do 方法處理發送請求最後會調用到 send 函數中
func (c *Client) send(req *Request, deadline time.Time) (resp *Response, didTimeout func() bool, err error) {
resp, didTimeout, err = send(req, c.transport(), deadline)
if err != nil {
return nil, didTimeout, err
}
...
return resp, nil, nil
}
c.transport() 方法是爲了回去 Transport 的默認實例 DefaultTransport ,我們看下 DefaultTransport 長什麼樣。
DefaultTransport
var DefaultTransport RoundTripper = &Transport{
Proxy: ProxyFromEnvironment,
DialContext: defaultTransportDialContext(&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}),
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
可以根據需要建立網絡連接,並緩存它們以供後續調用重用,部分參數如下:
-
• MaxIdleConns:最大空閒連接數
-
• IdleConnTimeout:空閒連接超時時間
-
• ExpectContinueTimeout:預計繼續超時
注意這裏的 RoundTripper 是個接口,也就是說 Transport 實現 RoundTripper 接口,該接口方法接收 Request,返回 Response。
RoundTripper
type RoundTripper interface {
RoundTrip(*Request) (*Response, error)
}
雖然還沒看完後面邏輯,不過我們猜測 RoundTrip 方法可能是實際處理客戶端請求的實現。
我們繼續追下後面邏輯,看下是否能驗證這個猜想。
func send(ireq *Request, rt RoundTripper, deadline time.Time) (resp *Response, didTimeout func() bool, err error) {
...
resp, err = rt.RoundTrip(req)
if err != nil {
...
}
..
}
👉 你看 send 函數的第二個參數就是接口類型,調用層傳遞的 Transport 的實例 DefaultTransport。
而 rt.RoundTrip() 方法的調用具體在 net/http/roundtrip.go 文件中,這也是 RoundTrip 接口的實現,代碼如下:
func (t *Transport) RoundTrip(req *Request) (*Response, error) {
return t.roundTrip(req)
}
Transport.roundTrip 方法概況來說幹了這些事:
-
• 封裝請求 transportRequest
-
• 調用 Transport 的 getConn 方法獲取連接
-
• 在獲取到連接後,調用 persistConn 的 roundTrip 方法等待請求響應結果
func (t *Transport) roundTrip(req *Request) (*Response, error) {
...
for {
...
// 請求封裝
treq := &transportRequest{Request: req, trace: trace, cancelKey: cancelKey}
cm, err := t.connectMethodForRequest(treq)
if err != nil {
...
}
// 獲取連接
pconn, err := t.getConn(treq, cm)
if err != nil {
...
}
// 等待響應結果
var resp *Response
if pconn.alt != nil {
t.setReqCanceler(cancelKey, nil)
resp, err = pconn.alt.RoundTrip(req)
} else {
resp, err = pconn.roundTrip(treq)
}
...
}
}
封裝請求 transportRequeste 沒啥好說的,因爲 treq 被 roundTrip 修改,所以這裏需要爲每次重試重新創建。
獲取連接
獲取連接的方法是 getConn,這裏代碼還是比較長的,會有不同的兩種方式去獲取連接:
-
1. 調用 queueForIdleConn 排隊等待獲取空閒連接
-
2. 如果獲取空閒連接失敗,那麼調用 queueForDial 異步創建一個新的連接,並通過 channel 來接收 readdy 信號,來確認連接是否構造完成
getConn
func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (pc *persistConn, err error) {
...
// 初始化wantConn結構體
w := &wantConn{
cm: cm,
key: cm.key(),
ctx: ctx,
ready: make(chan struct{}, 1),
beforeDial: testHookPrePendingDial,
afterDial: testHookPostPendingDial,
}
...
// 獲取空閒連接
if delivered := t.queueForIdleConn(w); delivered {
...
}
// 異步創建新連接
t.queueForDial(w)
select {
// 阻塞等待獲取到連接完成
case <-w.ready:
...
return w.pc, w.err
...
}
queueForIdleConn 獲取空閒連接
獲取成功
成功空閒獲取連接 Conn 流程如下圖
-
1. 根據 wantConn 的 key 從 transport.idleConn 這個 map 中查找,看是否存不存在空閒的 connection 列表
-
2. 獲取到空閒的 connection 列表後,從列表中拿最後一個 connection
-
3. 獲取到連接後會調用 wantConn.tryDeliver 方法將連接綁定到 wantConn 請求參數上
獲取失敗
當不存在該請求的 connection 列表,會將當前 wantConn 加入到名稱爲 idleConnWait 的等待空閒 map 中。
不過此時的 idleConnWait 這個 map 的值是個隊列
queueForIdleConn 方法
從上面的兩張圖解中差不多能看出是如何獲取空閒連接和如何獲取失敗時如何做的了,這裏也貼下代碼體驗下,讓大家更清楚裏面的實現邏輯。
//idleConn是map類型,指定key返回切片列表
idleConn map[connectMethodKey][]*persistConn
//idleConnWait,指定key返回隊列
idleConnWait map[connectMethodKey]wantConnQueue
這裏將獲取空閒連接的代碼實現多進行註釋,更好理解一些!
func (t *Transport) queueForIdleConn(w *wantConn) (delivered bool) {
//參數判斷
if t.DisableKeepAlives {
return false
}
if w == nil {
return false
}
// 計算空閒連接超時時間
var oldTime time.Time
if t.IdleConnTimeout > 0 {
oldTime = time.Now().Add(-t.IdleConnTimeout)
}
//從idleConn根據w.key找對應的persistConn 列表
if list, ok := t.idleConn[w.key]; ok {
stop := false
delivered := false
for len(list) > 0 && !stop {
// 找到persistConn列表最後一個
pconn := list[len(list)-1]
// 檢查這個 persistConn 是不是過期
tooOld := !oldTime.IsZero() && pconn.idleAt.Round(0).Before(oldTime)
if tooOld {
//如果過期進行異步清理
go pconn.closeConnIfStillIdle()
}
// 該 persistConn 被標記爲 broken 或 閒置太久 continue
if pconn.isBroken() || tooOld {
list = list[:len(list)-1]
continue
}
// 嘗試將該 persistConn 寫入到 wantConn(w)中
delivered = w.tryDeliver(pconn, nil)
if delivered {
// 寫入成功,將persistConn從空閒列表中移除
if pconn.alt != nil {
} else {
t.idleLRU.remove(pconn)
//缺省了最後一個conn
list = list[:len(list)-1]
}
}
stop = true
}
//對被獲取連接後的列表進行判斷
if len(list) > 0 {
t.idleConn[w.key] = list
} else {
// 如果該 key 對應的空閒列表不存在,那麼將該key從字典中移除
delete(t.idleConn, w.key)
}
if stop {
return delivered
}
}
// 如果找不到空閒的 persistConn
if t.idleConnWait == nil {
t.idleConnWait = make(map[connectMethodKey]wantConnQueue)
}
// 將該 wantConn添加到等待空閒idleConnWait中
q := t.idleConnWait[w.key]
q.cleanFront()
q.pushBack(w)
t.idleConnWait[w.key] = q
return false
}
我們知道了爲找到的空閒連接會被放到空閒 idleConnWait 這個等待 map 中,最後會被 Transport.tryPutIdleConn 方法將 pconne 添加到等待新請求的空閒持久連接列表中。
queueForDial 創建新連接
queueForDial 意思是排隊等待撥號,爲什麼說是等帶呢,因爲最終的結果是在 ready 這個 channel 上進行通知的。
流程如下圖:
我們先看下 Transport 結構體的這兩個 map,名稱不一樣 map 的屬性和解釋都是一樣的,其中 idleConnWait 是在沒查找空閒連接的時候存放當前連接的 map。
而 connsPerHostWait 用在了創建新連接的地方,可以猜測一下創建新鏈接的地方就是將當前的請求放入到 connsPerHostWait 等待 map 中。
// waiting getConns
idleConnWait map[connectMethodKey]wantConnQueue
// waiting getConns
connsPerHostWait map[connectMethodKey]wantConnQueue
Transport.queueForDial
func (t *Transport) queueForDial(w *wantConn) {
w.beforeDial()
// 小於等於零,意思是限制,直接異步建立連接
if t.MaxConnsPerHost <= 0 {
go t.dialConnFor(w)
return
}
...
//host建立的連接數沒達到上限,執行異步建立連接
if n := t.connsPerHost[w.key]; n < t.MaxConnsPerHost {
if t.connsPerHost == nil {
t.connsPerHost = make(map[connectMethodKey]int)
}
t.connsPerHost[w.key] = n + 1
go t.dialConnFor(w)
return
}
//進入等待隊列
if t.connsPerHostWait == nil {
t.connsPerHostWait = make(map[connectMethodKey]wantConnQueue)
}
q := t.connsPerHostWait[w.key]
q.cleanFront()
q.pushBack(w)
t.connsPerHostWait[w.key] = q
}
在獲取不到空閒連接之後,會嘗試去建立連接:
-
1. queueForDial 方法的內部會先校驗 MaxConnsPerHost 是否未設置和是否已達上限
-
-
1. 檢驗不通過則將當前的請求放入到 connsPerHostWait 這個等待 map 中
-
2. 校驗通過那麼會異步的調用 dialConnFor 方法創建連接
👉那會不會 queueForDial 方法中將 idleConnWait 和 connsPerHostWait 打包到等待空閒連接 idleConn 這個 map 中呢?
我們繼續看 dialConnFor 的實現,它會給我們這個問題的答案!
dialConnFor
func (t *Transport) dialConnFor(w *wantConn) {
defer w.afterDial()
//創建 persistConn
pc, err := t.dialConn(w.ctx, w.cm)
//綁定到 wantConn
delivered := w.tryDeliver(pc, err)
if err == nil && (!delivered || pc.alt != nil) {
//綁定wantConn失敗
//放到存放空閒連接idleConn的map中
t.putOrCloseIdleConn(pc)
}
if err != nil {
t.decConnsPerHost(w.key)
}
}
-
• dialConnFor 先調用 dialConn 方法創建 TCP 連接
-
• 調用 tryDeliver 將連接綁定到 wantConn 上,綁定成功的話,就將該鏈接放到空閒連接的 idleConn 這個 map 中
-
• 綁定失敗的話會調用 decConnsPerHost 方法,用遞減密鑰的每主機連接計數方式,繼續異步調用 Transport.dialConnFor
我們可以追蹤下代碼會發現 Transport.tryPutIdleConn() 方法就是將 persistConn 添加到等待的空閒持久連接列表中的實現。
Transport.dialConn 創建連接
func (t *Transport) dialConn(ctx context.Context, cm connectMethod) (pconn *persistConn, err error) {
pconn = &persistConn{
t: t,
cacheKey: cm.key(),
reqch: make(chan requestAndChan, 1),
writech: make(chan writeRequest, 1),
closech: make(chan struct{}),
writeErrCh: make(chan error, 1),
writeLoopDone: make(chan struct{}),
}
...
// 創建 tcp 連接,給pconn.conn
conn, err := t.dial(ctx, "tcp", cm.addr())
if err != nil {
return nil, wrapErr(err)
}
pconn.conn = conn
...
//開啓兩個goroutine處理讀寫
go pconn.readLoop()
go pconn.writeLoop()
return pconn, nil
}
👉 看完這個創建 persistConn 的代碼是不是心裏彷彿懂了什麼?
上述代碼中 HTTP 連接的創建過程是建立 tcp 連接,然後爲連接異步處理讀寫數據,最後將創建好的連接返回。
我們可以看到創建的每個連接會分別創建兩個 goroutine 循環地進行進行讀寫的處理,這就是爲什麼我們連接能接受請求參數和處理請求的響應的關鍵。
👉 這兩個協程功能是這樣的!
-
1. persisConn.writeLoop(),通過 persistConn.writech 通道讀取到客戶端提交的請求,將其發送到服務端
-
2. persisConn.readLoop(),讀取來自服務端的響應,並添加到 persistConn.reqCh 通道中,給 persistConn.roundTrip 方法接收
想看這兩個協程
等待響應
persistConn 連接本身創建了兩個讀寫 goroutine,而這兩個 goroutine 就是通過兩個 channel 進行通信的。
這個通信就是在 persistConn.roundTrip() 方法中的進行傳遞交互的,其中 writech 是用來寫入請求數據,reqch 是用來讀取響應數據。
func (pc *persistConn) roundTrip(req *transportRequest) (resp *Response, err error) {
...
// 請求數據寫入到 writech channel中
pc.writech <- writeRequest{req, writeErrCh, continueCh}
// 接收響應的channel
resc := make(chan responseAndError)
// 接收響應的結構體 requestAndChan 寫到 reqch channel中
pc.reqch <- requestAndChan{
req: req.Request,
cancelKey: req.cancelKey,
ch: resc,
...
}
...
for {
...
select {
// 接收到響應數據
case re := <-resc:
...
// return響應數據
return re.res, nil
...
}
}
-
1. 連接獲取到之後,會調用連接的 roundTrip 方法,將請求數據寫入到 persisConn.writech channel 中,而連接 persisConn 中的協程 writeLoop() 接收到請求後就會處理請求
-
2. 響應結構體 requestAndChan 寫入到 persisConn.reqch 中
-
3. 通過 readLoop 接受響應數據,然後讀取 resc channel 的響應結果
-
4. 接受到響應數據之後循環結束,連接處理完成
好了,net/http 標準庫的客戶端構造請求、發送請求、接受服務端的請求數據流程就講完了,看完之後是否意欲未盡呢?
還別說,小許也是第一次看是如何實現的,確實還是瞭解到了點東西呢!
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/GqKn_-IyszC4j49GMAODKA