Go語言HTTP用戶端實現簡述

2020-07-16 10:05:20
HTTP(HyperText Transfer Protocol,超文字傳輸協定)是網際網路上應用最為廣泛的一種網路協定,定義了用戶端和伺服器端之間請求與響應的傳輸標準。

Go語言標準庫內建提供了 net/http 包,涵蓋了 HTTP 用戶端和伺服器端的具體實現。使用 net/http 包,我們可以很方便地編寫 HTTP 用戶端或伺服器端的程式。

基本方法

net/http 包提供了最簡潔的 HTTP 用戶端實現,無需借助第三方網路通訊庫(比如 libcurl)就可以直接使用最常見的 GET 和 POST 方式發起 HTTP 請求。

具體來說,我們可以通過 net/http 包裡面的 Client 類提供的如下方法發起 HTTP 請求:

func (c *Client) Get(url string) (r *Response, err error)
func (c *Client) Post(url string, bodyType string, body io.Reader) (r *Response, err error)
func (c *Client) PostForm(url string, data url.Values) (r *Response, err error)
func (c *Client) Head(url string) (r *Response, err error)
func (c *Client) Do(req *Request) (resp *Response, err error)

下面就來簡單介紹一下這幾個方法的使用。

1) http.Get()

要請求一個資源,只需呼叫 http.Get() 方法(等價於 http.DefaultClient.Get())即可,範例程式碼如下:
package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
)

func main() {
    resp, err := http.Get("http://c.biancheng.net")
    if err != nil {
        fmt.Println(err)
    }
    defer resp.Body.Close()
    body, err := ioutil.ReadAll(resp.Body)
    fmt.Println(string(body))
}
上面這段程式碼請求一個網站首頁,並將其網頁內容列印出來,如下所示:

<!DOCTYPE html>
<html lang="zh-cn">
<head>
    ......
</head>
<body>
    ......
</body>
</html>

底層呼叫

其實通過 http.Get 發起請求時,預設呼叫的是上述 http.Client 預設物件上的 Get 方法:

func Get(url string) (resp *Response, err error) {
    return DefaultClient.Get(url)
}

而 DefaultClient 預設指向的正是 http.Client 的範例物件:

var DefaultClient = &Client{}

它是 net/http 包公開屬性,當我們在 http 上呼叫 Get、Post、PostForm、Head 方法時,最終呼叫的都是該物件上的對應方法。

返回值

http.Get() 方法的返回值有兩個,分別是一個響應物件和一個 error 物件,如果請求過程中出現錯誤,則 error 物件不為空,否則可以通過響應物件獲取狀態碼、響應頭、響應實體等資訊,響應物件所屬的類是 http.Response。

可以通過檢視 API 文件或者原始碼了解該型別的具體資訊,一般我們可以通過 resp.Body 獲取響應實體,通過 resp.Header 獲取響應頭,通過 resp.StatusCode 獲取響應狀態碼。

獲取響應成功後記得呼叫 resp.Body 上的 Close 方法結束網路請求釋放資源。

2) http.Post()

要以 POST 的方式傳送資料,也很簡單,只需呼叫 http.Post() 方法並依次傳遞下面的 3 個引數即可:
  • 請求的目標 URL
  • 將要 POST 資料的資源型別(MIMEType)
  • 資料的位元流([]byte 形式)

下面的範例程式碼演示了如何上傳一張圖片:
resp, err := http.Post("http://c.biancheng.net/upload", "image/jpeg", &buf)
if err != nil {
    fmt.Println(err)
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
    fmt.Println(err)
}
fmt.Println(string(body))
其中 &buf 為圖片的資源。

3) http.PostForm()

http.PostForm() 方法實現了標準編碼格式為“application/x-www-form-urlencoded”的表單提交,下面的範例程式碼模擬了 HTML 表單向後台提交資訊的過程:
package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
    "net/url"
)

func main() {
    resp, err := http.PostForm("http://www.baidu.com", url.Values{"wd": {"golang"}})
    if err != nil {
        fmt.Println(err)
    }
    defer resp.Body.Close()
    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        fmt.Println(err)
    }
    fmt.Println(string(body))
}
注意:POST 請求引數需要通過 url.Values 方法進行編碼和封裝。

4) http.Head()

HTTP 的 Head 請求表示只請求目標 URL 的響應頭資訊,不返回響應實體。可以通過 net/http 包的 http.Head() 方法發起 Head 請求,該方法和 http.Get() 方法一樣,只需要傳入目標 URL 引數即可。

下面的範例程式碼請求一個網站首頁的 HTTP Header 資訊:
package main

import (
    "fmt"
    "net/http"
)

func main() {
    resp, err := http.Head("http://c.biancheng.net")
    if err != nil {
        fmt.Println("Request Failed: ", err.Error())
        return
    }
    defer resp.Body.Close() // 列印頭資訊
    for key, value := range resp.Header {
        fmt.Println(key, ":", value)
    }
}
執行結果如下:

go run main.go
X-Swift-Savetime : [Thu, 02 Jan 2020 02:12:51 GMT]
X-Swift-Cachetime : [31104000]
Content-Type : [text/html]
......
Via : [cache12.l2cn2178[13,200-0,M], cache10.l2cn2178[14,0], kunlun9.cn1481[0,200-0,H], kunlun8.cn1481[1,0]]
X-Cache : [HIT TCP_MEM_HIT dirn:11:355030002]

通過 http.Head() 方法返回的響應實體 resp.Body 值為空。

5) (*http.Client).Do()

下面我們再來看一下 http.Client 類的 Do 方法。

在多數情況下,http.Get()、http.Post() 和 http.PostForm() 就可以滿足需求,但是如果我們發起的 HTTP 請求需要更多的自定義請求資訊,比如:
  • 設定自定義 User-Agent,而不是預設的 Go http package;
  • 傳遞 Cookie 資訊;
  • 發起其它方式的 HTTP 請求,比如 PUT、PATCH、DELETE 等。

此時可以通過 http.Client 類提供的 Do() 方法來實現,使用該方法時,就不再是通過預設的 DefaultClient 物件呼叫 http.Client 類中的方法了,而是需要我們手動範例化 Client 物件並傳入新增了自定義請求頭資訊的請求物件來發起 HTTP 請求:
package main

import (
    "fmt"
    "io"
    "net/http"
    "os"
)

func main() {
    // 初始化用戶端請求物件
    req, err := http.NewRequest("GET", "http://c.biancheng.net", nil)
    if err != nil {
        fmt.Println(err)
        return
    }
    // 新增自定義請求頭
    req.Header.Add("Custom-Header", "Custom-Value")
    // 其它請求頭設定
    client := &http.Client{
        // 設定用戶端屬性
    }
    resp, err := client.Do(req)
    if err != nil {
        fmt.Println(err)
        return
    }
    defer resp.Body.Close()
    io.Copy(os.Stdout, resp.Body)
}
執行結果如下:

<!DOCTYPE html>
<html lang="zh-cn">
<head>
    ......
</head>
<body>
    ......
</body>
</html>

用於初始化請求物件的 http.NewRequest 方法需要傳入三個引數,第一個是請求方法,第二個是目標 URL,第三個是請求實體,只有 POST、PUT、DELETE 之類的請求才需要設定請求實體,對於 HEAD、GET 而言,傳入 nil 即可。

http.NewRequest 方法返回的第一個值就是請求物件範例 req,該範例所屬的類是 http.Request,可以呼叫該類上的公開方法和屬性對請求物件進行自定義設定,比如請求方法、URL、請求頭等。

設定完成後,就可以將請求物件傳入 client.Do() 方法發起 HTTP 請求,之後的操作和前面四個基本方法一樣,http.Post、http.PostForm、http.Head、http.NewRequest 方法的底層實現及返回值和 http.Get 方法一樣。

高階封裝

除了之前介紹的基本 HTTP 操作,Go語言標準庫也暴露了比較底層的 HTTP 相關庫,讓開發者可以基於這些庫靈活客製化 HTTP 伺服器和使用 HTTP 服務。

1) 自定義 http.Client

前面我們使用的 http.Get()、http.Post()、http.PostForm() 和 http.Head() 方法其實都是在 http.DefaultClient 的基礎上進行呼叫的,比如 http.Get() 等價於 http.Default-Client.Get(),依次類推。

http.DefaultClient 在字面上就向我們傳達了一個資訊,既然存在預設的 Client,那麼 HTTP Client 大概是可以自定義的。實際上確實如此,在 net/http 包中,的確提供了 Client 型別。讓我們來看一看 http.Client 型別的結構:
type Client struct {
    // Transport 用於確定HTTP請求的建立機制。
    // 如果為空,將會使用DefaultTransport
    Transport RoundTripper
    // CheckRedirect定義重定向策略。
    // 如果CheckRedirect不為空,用戶端將在跟蹤HTTP重定向前呼叫該函數。
    // 兩個引數req和via分別為即將發起的請求和已經發起的所有請求,最早的
    // 已發起請求在最前面。
    // 如果CheckRedirect返回錯誤,用戶端將直接返回錯誤,不會再發起該請求。
    // 如果CheckRedirect為空,Client將採用一種確認策略,將在10個連續
    // 請求後終止
    CheckRedirect func(req *Request, via []*Request) error
    // 如果Jar為空,Cookie將不會在請求中傳送,並會
    // 在響應中被忽略
    Jar CookieJar
}
在Go語言標準庫中,http.Client 型別包含了 3 個公開資料成員:

Transport RoundTripper
CheckRedirect func(req *Request, via []*Request) error
Jar CookieJar

其中 Transport 型別必須實現 http.RoundTripper 介面。Transport 指定了執行一個 HTTP 請求的執行機制,倘若不指定具體的 Transport,預設會使用 http.DefaultTransport,這意味著 http.Transport 也是可以自定義的。net/http 包中的 http.Transport 型別實現了 http.RoundTripper 介面。

CheckRedirect 函數指定處理重定向的策略。當使用 HTTP Client 的 Get() 或者是 Head() 方法傳送 HTTP 請求時,若響應返回的狀態碼為 30x (比如 301 / 302 / 303 / 307),HTTP Client 會在遵循跳轉規則之前先呼叫這個 CheckRedirect 函數。

Jar 可用於在 HTTP Client 中設定 Cookie,Jar 的型別必須實現了 http.CookieJar 介面,該介面預定義了 SetCookies() 和 Cookies() 兩個方法。

如果 HTTP Client 中沒有設定 Jar,Cookie 將被忽略而不會傳送到用戶端。實際上,我們一般都用 http.SetCookie() 方法來設定 Cookie。

使用自定義的 http.Client 及其 Do() 方法,我們可以非常靈活地控制 HTTP 請求,比如傳送自定義 HTTP Header 或是改寫重定向策略等。建立自定義的 HTTP Client 非常簡單,具體程式碼如下:
client := &http.Client {
    CheckRedirect: redirectPolicyFunc,
}
resp, err := client.Get("http://example.com")
// ...
req, err := http.NewRequest("GET", "http://example.com", nil)
// ...
req.Header.Add("User-Agent", "Our Custom User-Agent")
req.Header.Add("If-None-Match", `W/"TheFileEtag"`)
resp, err := client.Do(req)
// ...

2) 自定義 http.Transport

在 http.Client 型別的結構定義中,我們看到的第一個資料成員就是一個 http.Transport 物件,該物件指定執行一個 HTTP 請求時的執行規則。下面我們來看看 http.Transport 型別的具體結構:
type Transport struct {
    // Proxy指定用於針對特定請求返回代理的函數。
    // 如果該函數返回一個非空的錯誤,請求將終止並返回該錯誤。
    // 如果Proxy為空或者返回一個空的URL指標,將不使用代理
    Proxy func(*Request) (*url.URL, error)
    // Dial指定用於建立TCP連線的dail()函數。
    // 如果Dial為空,將預設使用net.Dial()函數
    Dial func(net, addr string) (c net.Conn, err error)
    // TLSClientConfig指定用於tls.Client的TLS設定。
    // 如果為空則使用預設設定
    TLSClientConfig *tls.Config
    DisableKeepAlives bool
    DisableCompression bool
    // 如果MaxIdleConnsPerHost為非零值,它用於控制每個host所需要
    // 保持的最大空閒連線數。如果該值為空,則使用DefaultMaxIdleConnsPerHost
    MaxIdleConnsPerHost int
    // ...
}
在上面的程式碼中,我們定義了 http.Transport 型別中的公開資料成員,下面詳細說明其中的各行程式碼。

Proxy func(*Request) (*url.URL, error)

Proxy 指定了一個代理方法,該方法接受一個 *Request 型別的請求範例作為引數並返回一個最終的 HTTP 代理。如果 Proxy 未指定或者返回的 *URL 為零值,將不會有代理被啟用。

Dial func(net, addr string) (c net.Conn, err error)

Dial 指定具體的 dial() 方法來建立 TCP 連線。如果不指定,預設將使用 net.Dial() 方法。

TLSClientConfig *tls.Config

SSL 連線專用,TLSClientConfig 指定 tls.Client 所用的 TLS 設定資訊,如果不指定,也會使用預設的設定。

DisableKeepAlives bool

是否取消長連線,預設值為 false,即啟用長連線。

DisableCompression bool

是否取消壓縮(GZip),預設值為 false,即啟用壓縮。

MaxIdleConnsPerHost int

指定與每個請求的目標主機之間的最大非活躍連線(keep-alive)數量。如果不指定,預設使用 DefaultMaxIdleConnsPerHost 的常數值。

除了 http.Transport 型別中定義的公開資料成員以外,它同時還提供了幾個公開的成員方法。
  • func(t *Transport) CloseIdleConnections()。該方法用於關閉所有非活躍的連線。
  • func(t *Transport) RegisterProtocol(scheme string, rt RoundTripper)。該方法可用於註冊並啟用一個新的傳輸協定,比如 WebSocket 的傳輸協定標準(ws),或者 FTP、File 協定等。
  • func(t *Transport) RoundTrip(req *Request) (resp *Response, err error)。用於實現 http.RoundTripper 介面。

自定義 http.Transport 也很簡單,如下列程式碼所示:
tr := &http.Transport{
    TLSClientConfig: &tls.Config{RootCAs: pool},
    DisableCompression: true,
}
client := &http.Client{Transport: tr}
resp, err := client.Get("https://example.com")
Client 和 Transport 在執行多個 goroutine 的併行過程中都是安全的,但出於效能考慮,應當建立一次後反復使用。

3) 靈活的 http.RoundTripper 介面

在前面的兩小節中,我們知道 HTTP Client 是可以自定義的,而 http.Client 定義的第一個公開成員就是一個 http.Transport 型別的範例,且該成員所對應的型別必須實現 http.RoundTripper 介面。

下面我們來看看 http.RoundTripper 介面的具體定義:
type RoundTripper interface {
    // RoundTrip執行一個單一的HTTP事務,返回相應的響應資訊。
    // RoundTrip函數的實現不應試圖去理解響應的內容。如果RoundTrip得到一個響應,
    // 無論該響應的HTTP狀態碼如何,都應將返回的err設定為nil。非空的err
    // 只意味著沒有成功獲取到響應。
    // 類似地,RoundTrip也不應試圖處理更高階別的協定,比如重定向、認證和
    // Cookie等。
    //
    // RoundTrip不應修改請求內容, 除非了是為了理解Body內容。每一個請求
    // 的URL和Header域都應被正確初始化
    RoundTrip(*Request) (*Response, error)
}
從上述程式碼中可以看到,http.RoundTripper 介面很簡單,只定義了一個名為 RoundTrip 的方法。任何實現了 RoundTrip() 方法的型別即可實現 http.RoundTripper 介面。前面我們看到的 http.Transport 型別正是實現了 RoundTrip() 方法繼而實現了該介面。

http.RoundTripper 介面定義的 RoundTrip() 方法用於執行一個獨立的 HTTP 事務,接受傳入的 *Request 請求值作為引數並返回對應的 *Response 響應值,以及一個 error 值。

在實現具體的 RoundTrip() 方法時,不應該試圖在該函數裡邊解析 HTTP 響應資訊。若響應成功,error 的值必須為 nil,而與返回的 HTTP 狀態碼無關。若不能成功得到伺服器端的響應,error 必須為非零值。類似地,也不應該試圖在 RoundTrip() 中處理協定層面的相關細節,比如重定向、認證或是 cookie 等。

非必要情況下,不應該在 RoundTrip() 中改寫傳入的請求體(*Request),請求體的內容(比如 URL 和 Header 等)必須在傳入 RoundTrip() 之前就已組織好並完成初始化。

通常,我們可以在預設的 http.Transport 之上包一層 Transport 並實現 RoundTrip() 方法,程式碼如下所示。
package main
import(
    "net/http"
)
type OurCustomTransport struct {
    Transport http.RoundTripper
}
func (t *OurCustomTransport) transport() http.RoundTripper {
    if t.Transport != nil {
        return t.Transport
    }
    return http.DefaultTransport
}
func (t *OurCustomTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    // 處理一些事情 ...
    // 發起HTTP請求
    // 新增一些域到req.Header中
    return t.transport().RoundTrip(req)
}
func (t *OurCustomTransport) Client() *http.Client {
    return &http.Client{Transport: t}
}
func main() {
    t := &OurCustomTransport{
        //...
    }
    c := t.Client()
    resp, err := c.Get("http://example.com")
    // ...
}
因為實現了 http.RoundTripper 介面的程式碼通常需要在多個 goroutine 中並行執行,因此我們必須確保實現程式碼的執行緒安全性。

4) 設計優雅的 HTTP Client

綜上範例講解可以看到,Go語言標準庫提供的 HTTP Client 是相當優雅的。一方面提供了極其簡單的使用方式,另一方面又具備極大的靈活性。

Go語言標準庫提供的 HTTP Client 被設計成上下兩層結構。一層是上述提到的 http.Client 類及其封裝的基礎方法,我們不妨將其稱為“業務層”。之所以稱為業務層,是因為呼叫方通常只需要關心請求的業務邏輯本身,而無需關心非業務相關的技術細節,這些細節包括:
  • HTTP 底層傳輸細節
  • HTTP 代理
  • gzip 壓縮
  • 連線池及其管理
  • 認證(SSL 或其他認證方式)

之所以 HTTP Client 可以做到這麼好的封裝性,是因為 HTTP Client 在底層抽象了 http.RoundTripper 介面,而 http.Transport 實現了該介面,從而能夠處理更多的細節,我們不妨將其稱為“傳輸層”。

HTTP Client 在業務層初始化 HTTP Method、目標 URL、請求引數、請求內容等重要資訊後,經過“傳輸層”,“傳輸層”在業務層處理的基礎上補充其他細節,然後再發起 HTTP 請求,接收伺服器端返回的 HTTP 響應。