Go語言核心36講(Go語言實戰與應用二十五)--學習筆記

MingsonZheng發表於2021-12-10

47 | 基於HTTP協議的網路服務

我們在上一篇文章中簡單地討論了網路程式設計和 socket,並由此提及了 Go 語言標準庫中的syscall程式碼包和net程式碼包。

我還重點講述了net.Dial函式和syscall.Socket函式的引數含義。前者間接地呼叫了後者,所以正確理解後者,會對用好前者有很大裨益。

之後,我們把視線轉移到了net.DialTimeout函式以及它對操作超時的處理上,這又涉及了net.Dialer型別。實際上,這個型別正是net包中這兩個“撥號”函式的底層實現。

我們像上一篇文章的示例程式碼那樣用net.Dial或net.DialTimeout函式來訪問基於 HTTP 協議的網路服務是完全沒有問題的。HTTP 協議是基於 TCP/IP 協議棧的,並且它也是一個面向普通文字的協議。

原則上,我們使用任何一個文字編輯器,都可以輕易地寫出一個完整的 HTTP 請求報文。只要你搞清楚了請求報文的頭部(header)和主體(body)應該包含的內容,這樣做就會很容易。所以,在這種情況下,即便直接使用net.Dial函式,你應該也不會感覺到困難。

不過,不困難並不意味著很方便。如果我們只是訪問基於 HTTP 協議的網路服務的話,那麼使用net/http程式碼包中的程式實體來做,顯然會更加便捷。

其中,最便捷的是使用http.Get函式。我們在呼叫它的時候只需要傳給它一個 URL 就可以了,比如像下面這樣:

url1 := "http://google.cn"
fmt.Printf("Send request to %q with method GET ...\n", url1)
resp1, err := http.Get(url1)
if err != nil {
  fmt.Printf("request sending error: %v\n", err)
}
defer resp1.Body.Close()
line1 := resp1.Proto + " " + resp1.Status
fmt.Printf("The first line of response:\n%s\n", line1)

http.Get函式會返回兩個結果值。第一個結果值的型別是*http.Response,它是網路服務給我們傳回來的響應內容的結構化表示。

第二個結果值是error型別的,它代表了在建立和傳送 HTTP 請求,以及接收和解析 HTTP 響應的過程中可能發生的錯誤。

http.Get函式會在內部使用預設的 HTTP 客戶端,並且呼叫它的Get方法以完成功能。這個預設的 HTTP 客戶端是由net/http包中的公開變數DefaultClient代表的,其型別是*http.Client。它的基本型別也是可以被拿來使用的,甚至它還是開箱即用的。下面的這兩行程式碼:

var httpClient1 http.Client
resp2, err := httpClient1.Get(url1)

與前面的這一行程式碼

resp1, err := http.Get(url1)

是等價的。

http.Client是一個結構體型別,並且它包含的欄位都是公開的。之所以該型別的零值仍然可用,是因為它的這些欄位要麼存在著相應的預設值,要麼其零值直接就可以使用,且代表著特定的含義。

package main

import (
	"fmt"
	"net/http"
)

func main() {
	host := "google.cn"

	// 示例1。
	url1 := "http://" + host
	fmt.Printf("Send request to %q with method GET ...\n", url1)
	resp1, err := http.Get(url1)
	if err != nil {
		fmt.Printf("request sending error: %v\n", err)
		return
	}
	defer resp1.Body.Close()
	line1 := resp1.Proto + " " + resp1.Status
	fmt.Printf("The first line of response:\n%s\n", line1)
	fmt.Println()

	// 示例2。
	url2 := "https://golang." + host
	fmt.Printf("Send request to %q with method GET ...\n", url2)
	var httpClient1 http.Client
	resp2, err := httpClient1.Get(url2)
	if err != nil {
		fmt.Printf("request sending error: %v\n", err)
		return
	}
	defer resp2.Body.Close()
	line2 := resp2.Proto + " " + resp2.Status
	fmt.Printf("The first line of response:\n%s\n", line2)
}

現在,我問你一個問題,是關於這個型別中的最重要的一個欄位的。

今天的問題是:http.Client型別中的Transport欄位代表著什麼?

這道題的典型回答是這樣的。

http.Client型別中的Transport欄位代表著:向網路服務傳送 HTTP 請求,並從網路服務接收 HTTP 響應的操作過程。也就是說,該欄位的方法RoundTrip應該實現單次 HTTP 事務(或者說基於 HTTP 協議的單次互動)需要的所有步驟。

這個欄位是http.RoundTripper介面型別的,它有一個由http.DefaultTransport變數代表的預設值(以下簡稱DefaultTransport)。當我們在初始化一個http.Client型別的值(以下簡稱Client值)的時候,如果沒有顯式地為該欄位賦值,那麼這個Client值就會直接使用DefaultTransport。

順便說一下,http.Client型別的Timeout欄位,代表的正是前面所說的單次 HTTP 事務的超時時間,它是time.Duration型別的。它的零值是可用的,用於表示沒有設定超時時間。

問題解析

下面,我們再通過該欄位的預設值DefaultTransport,來深入地瞭解一下這個Transport欄位。

DefaultTransport的實際型別是*http.Transport,後者即為http.RoundTripper介面的預設實現。這個型別是可以被複用的,也推薦被複用,同時,它也是併發安全的。正因為如此,http.Client型別也擁有著同樣的特質。

http.Transport型別,會在內部使用一個net.Dialer型別的值(以下簡稱Dialer值),並且,它會把該值的Timeout欄位的值,設定為30秒。

也就是說,這個Dialer值如果在 30 秒內還沒有建立好網路連線,那麼就會被判定為操作超時。在DefaultTransport的值被初始化的時候,這樣的Dialer值的DialContext方法會被賦給前者的DialContext欄位。

http.Transport型別還包含了很多其他的欄位,其中有一些欄位是關於操作超時的。

  • IdleConnTimeout:含義是空閒的連線在多久之後就應該被關閉。
  • DefaultTransport會把該欄位的值設定為90秒。如果該值為0,那麼就表示不關閉空閒的連線。注意,這樣很可能會造成資源的洩露。
  • ResponseHeaderTimeout:含義是,從客戶端把請求完全遞交給作業系統到從作業系統那裡接收到響應報文頭的最大時長。DefaultTransport並沒有設定該欄位的值。
  • ExpectContinueTimeout:含義是,在客戶端遞交了請求報文頭之後,等待接收第一個響應報文頭的最長時間。在客戶端想要使用 HTTP 的“POST”方法把一個很大的報文體傳送給服務端的時候,它可以先通過傳送一個包含了“Expect: 100-continue”的請求報文頭,來詢問服務端是否願意接收這個大報文體。這個欄位就是用於設定在這種情況下的超時時間的。注意,如果該欄位的值不大於0,那麼無論多大的請- 求報文體都將會被立即傳送出去。這樣可能會造成網路資源的浪費。DefaultTransport把該欄位的值設定為了1秒。
  • TLSHandshakeTimeout:TLS 是 Transport Layer Security 的縮寫,可以被翻譯為傳輸層安全。這個欄位代表了基於 TLS 協議的連線在被建立時的握手階段的超時時間。若該值為0,則表示對這個時間不設限。DefaultTransport把該欄位的值設定為了10秒。

此外,還有一些與IdleConnTimeout相關的欄位值得我們關注,即:MaxIdleConns、MaxIdleConnsPerHost以及MaxConnsPerHost。

無論當前的http.Transport型別的值(以下簡稱Transport值)訪問了多少個網路服務,MaxIdleConns欄位都只會對空閒連線的總數做出限定。而MaxIdleConnsPerHost欄位限定的則是,該Transport值訪問的每一個網路服務的最大空閒連線數。

每一個網路服務都會有自己的網路地址,可能會使用不同的網路協議,對於一些 HTTP 請求也可能會用到代理。Transport值正是通過這三個方面的具體情況,來鑑別不同的網路服務的。

MaxIdleConnsPerHost欄位的預設值,由http.DefaultMaxIdleConnsPerHost變數代表,值為2。也就是說,在預設情況下,對於某一個Transport值訪問的每一個網路服務,它的空閒連線數都最多隻能有兩個。

與MaxIdleConnsPerHost欄位的含義相似的,是MaxConnsPerHost欄位。不過,後者限制的是,針對某一個Transport值訪問的每一個網路服務的最大連線數,不論這些連線是否是空閒的。並且,該欄位沒有相應的預設值,它的零值表示不對此設限。

DefaultTransport並沒有顯式地為MaxIdleConnsPerHost和MaxConnsPerHost這兩個欄位賦值,但是它卻把MaxIdleConns欄位的值設定為了100。

換句話說,在預設情況下,空閒連線的總數最大為100,而針對每個網路服務的最大空閒連線數為2。注意,上述兩個與空閒連線數有關的欄位的值應該是聯動的,所以,你有時候需要根據實際情況來定製它們。

當然了,這首先需要我們在初始化Client值的時候,定製它的Transport欄位的值。定製這個值的方式,可以參看DefaultTransport變數的宣告。

最後,我簡單說一下為什麼會出現空閒的連線。我們都知道,HTTP 協議有一個請求報文頭叫做“Connection”。在 HTTP 協議的 1.1 版本中,這個報文頭的值預設是“keep-alive”。

在這種情況下的網路連線都是持久連線,它們會在當前的 HTTP 事務完成後仍然保持著連通性,因此是可以被複用的。

既然連線可以被複用,那麼就會有兩種可能。一種可能是,針對於同一個網路服務,有新的 HTTP 請求被遞交,該連線被再次使用。另一種可能是,不再有對該網路服務的 HTTP 請求,該連線被閒置。

顯然,後一種可能就產生了空閒的連線。另外,如果分配給某一個網路服務的連線過多的話,也可能會導致空閒連線的產生,因為每一個新遞交的 HTTP 請求,都只會徵用一個空閒的連線。所以,為空閒連線設定限制,在大多數情況下都是很有必要的,也是需要斟酌的。

如果我們想徹底地杜絕空閒連線的產生,那麼可以在初始化Transport值的時候把它的DisableKeepAlives欄位的值設定為true。這時,HTTP 請求的“Connection”報文頭的值就會被設定為“close”。這會告訴網路服務,這個網路連線不必保持,當前的 HTTP 事務完成後就可以斷開它了。

如此一來,每當一個 HTTP 請求被遞交時,就都會產生一個新的網路連線。這樣做會明顯地加重網路服務以及客戶端的負載,並會讓每個 HTTP 事務都耗費更多的時間。所以,在一般情況下,我們都不要去設定這個DisableKeepAlives欄位。

順便說一句,在net.Dialer型別中,也有一個看起來很相似的欄位KeepAlive。不過,它與前面所說的 HTTP 持久連線並不是一個概念,KeepAlive是直接作用在底層的 socket 上的。

它的背後是一種針對網路連線(更確切地說,是 TCP 連線)的存活探測機制。它的值用於表示每間隔多長時間傳送一次探測包。當該值不大於0時,則表示不開啟這種機制。DefaultTransport會把這個欄位的值設定為30秒。

好了,以上這些內容闡述的就是,http.Client型別中的Transport欄位的含義,以及它的值的定製方式。這涉及了http.RoundTripper介面、http.DefaultTransport變數、http.Transport型別,以及net.Dialer型別。

知識擴充套件

問題:http.Server型別的ListenAndServe方法都做了哪些事情?

http.Server型別與http.Client是相對應的。http.Server代表的是基於 HTTP 協議的服務端,或者說網路服務。

http.Server型別的ListenAndServe方法的功能是:監聽一個基於 TCP 協議的網路地址,並對接收到的 HTTP 請求進行處理。這個方法會預設開啟針對網路連線的存活探測機制,以保證連線是持久的。同時,該方法會一直執行,直到有嚴重的錯誤發生或者被外界關掉。當被外界關掉時,它會返回一個由http.ErrServerClosed變數代表的錯誤值。

package main

import (
	"fmt"
	"net"
	"net/http"
	"strings"
	"sync"
	"time"
)

// domains 包含了我們將要訪問的一些網路域名。
// 你可以隨意地對它們進行增、刪、改,
// 不過這會影響到後面的輸出內容。
var domains = []string{
	"google.com",
	"google.com.hk",
	"google.cn",
	"golang.org",
	"golang.google.cn",
}

func main() {
	// 你可以改變myTransport中的各個欄位的值,
	// 並觀察後面的輸出會有什麼不同。
	myTransport := &http.Transport{
		Proxy: http.ProxyFromEnvironment,
		DialContext: (&net.Dialer{
			Timeout:       15 * time.Second,
			KeepAlive:     15 * time.Second,
			FallbackDelay: 0,
		}).DialContext,
		MaxConnsPerHost:       2,
		MaxIdleConns:          10,
		MaxIdleConnsPerHost:   2,
		IdleConnTimeout:       30 * time.Second,
		ResponseHeaderTimeout: 0,
		ExpectContinueTimeout: 1 * time.Second,
		TLSHandshakeTimeout:   10 * time.Second,
	}
	// 你可以改變myClient中的各個欄位的值,
	// 並觀察後面的輸出會有什麼不同。
	myClient := http.Client{
		Transport: myTransport,
		Timeout:   20 * time.Second,
	}

	var wg sync.WaitGroup
	wg.Add(len(domains))
	for _, domain := range domains {
		go func(domain string) {
			var logBuf strings.Builder
			var diff time.Duration
			defer func() {
				logBuf.WriteString(
					fmt.Sprintf("(elapsed time: %s)\n", diff))
				fmt.Println(logBuf.String())
				wg.Done()
			}()
			url := "https://" + domain
			logBuf.WriteString(
				fmt.Sprintf("Send request to %q with method GET ...\n", url))
			t1 := time.Now()
			resp, err := myClient.Get(url)
			diff = time.Now().Sub(t1)
			if err != nil {
				logBuf.WriteString(
					fmt.Sprintf("request sending error: %v\n", err))
				return
			}
			defer resp.Body.Close()
			line2 := resp.Proto + " " + resp.Status
			logBuf.WriteString(
				fmt.Sprintf("The first line of response:\n%s\n", line2))
		}(domain)
	}
	wg.Wait()
}

對於本問題,典型回答可以像下面這樣。

這個ListenAndServe方法主要會做下面這幾件事情。

1、檢查當前的http.Server型別的值(以下簡稱當前值)的Addr欄位。該欄位的值代表了當前的網路服務需要使用的網路地址,即:IP 地址和埠號. 如果這個欄位的值為空字串,那麼就用":http"代替。也就是說,使用任何可以代表本機的域名和 IP 地址,並且埠號為80。

2、通過呼叫net.Listen函式在已確定的網路地址上啟動基於 TCP 協議的監聽。

3、檢查net.Listen函式返回的錯誤值。如果該錯誤值不為nil,那麼就直接返回該值。否則,通過呼叫當前值的Serve方法準備接受和處理將要到來的 HTTP 請求。

package main

import (
	"fmt"
	"log"
	"net/http"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	wg.Add(2)

	// 示例1。
	go startServer1(&wg)

	// 示例2。
	go startServer2(&wg)

	wg.Wait()
}

func startServer1(wg *sync.WaitGroup) {
	defer wg.Done()
	var httpServer1 http.Server
	httpServer1.Addr = "127.0.0.1:8080"
	// 由於我們沒有定製handler,所以這個網路服務對任何請求都只會響應404。
	if err := httpServer1.ListenAndServe(); err != nil {
		if err == http.ErrServerClosed {
			log.Println("HTTP server 1 closed.")
		} else {
			log.Printf("HTTP server 1 error: %v\n", err)
		}
	}
}

func startServer2(wg *sync.WaitGroup) {
	defer wg.Done()
	mux1 := http.NewServeMux()
	mux1.HandleFunc("/hi", func(w http.ResponseWriter, req *http.Request) {
		if req.URL.Path != "/hi" {
			http.NotFound(w, req)
			return
		}
		name := req.FormValue("name")
		if name == "" {
			fmt.Fprint(w, "Welcome!")
		} else {
			fmt.Fprintf(w, "Welcome, %s!", name)
		}
	})
	httpServer2 := http.Server{
		Addr:    "127.0.0.1:8081",
		Handler: mux1,
	}
	if err := httpServer2.ListenAndServe(); err != nil {
		if err == http.ErrServerClosed {
			log.Println("HTTP server 2 closed.")
		} else {
			log.Printf("HTTP server 2 error: %v\n", err)
		}
	}
}

可以從當前問題直接衍生出的問題一般有兩個,一個是“net.Listen函式都做了哪些事情”,另一個是“http.Server型別的Serve方法是怎樣接受和處理 HTTP 請求的”。

對於第一個直接的衍生問題,如果概括地說,回答可以是:

1、解析引數值中包含的網路地址隱含的 IP 地址和埠號;

2、根據給定的網路協議,確定監聽的方法,並開始進行監聽。

從這裡的第二個步驟出發,我們還可以繼續提出一些間接的衍生問題。這往往會涉及net.socket函式以及相關的 socket 知識。

對於第二個直接的衍生問題,我們可以這樣回答:

在一個for迴圈中,網路監聽器的Accept方法會被不斷地呼叫,該方法會返回兩個結果值;第一個結果值是net.Conn型別的,它會代表包含了新到來的 HTTP 請求的網路連線;第二個結果值是代表了可能發生的錯誤的error型別值。

如果這個錯誤值不為nil,除非它代表了一個暫時性的錯誤,否則迴圈都會被終止。如果是暫時性的錯誤,那麼迴圈的下一次迭代將會在一段時間之後開始執行。

如果這裡的Accept方法沒有返回非nil的錯誤值,那麼這裡的程式將會先把它的第一個結果值包裝成一個*http.conn型別的值(以下簡稱conn值),然後通過在新的 goroutine 中呼叫這個conn值的serve方法,來對當前的 HTTP 請求進行處理。

這個處理的細節還是很多的,所以我們依然可以找出不少的間接的衍生問題。比如,這個conn值的狀態有幾種,分別代表著處理的哪個階段?又比如,處理過程中會用到哪些讀取器和寫入器,它們的作用分別是什麼?再比如,這裡的程式是怎樣呼叫我們自定義的處理函式的,等等。

諸如此類的問題很多,我就不在這裡一一列舉和說明了。你只需要記住一句話:“原始碼之前了無祕密”。上面這些問題的答案都可以在 Go 語言標準庫的原始碼中找到。如果你想對本問題進行深入的探索,那麼一定要去看net/http程式碼包的原始碼。

總結

今天,我們主要講的是基於 HTTP 協議的網路服務,側重點仍然在客戶端。

我們在討論了http.Get函式和http.Client型別的簡單使用方式之後,把目光聚焦在了後者的Transport欄位。

這個欄位代表著單次 HTTP 事務的操作過程。它是http.RoundTripper介面型別的。它的預設值由http.DefaultTransport變數代表,其實際型別是*http.Transport。

http.Transport包含的欄位非常多。我們先講了DefaultTransport中的DialContext欄位會被賦予什麼樣的值,又詳細說明了一些關於操作超時的欄位。

比如IdleConnTimeout和ExpectContinueTimeout,以及相關的MaxIdleConns和MaxIdleConnsPerHost等等。之後,我又簡單地解釋了出現空閒連線的原因,以及相關的定製方式。

最後,作為擴充套件,我還為你簡要地梳理了http.Server型別的ListenAndServe方法,執行的主要流程。不過,由於篇幅原因,我沒有做深入講述。但是,這並不意味著沒有必要深入下去。相反,這個方法很重要,值得我們認真地去探索一番。

在你需要或者有興趣的時候,我希望你能去好好地看一看net/http包中的相關原始碼。一切祕密都在其中。

思考題

我今天留給你的思考題比較簡單,即:怎樣優雅地停止基於 HTTP 協議的網路服務程式?

筆記原始碼

https://github.com/MingsonZheng/go-core-demo

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。

相關文章