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


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/ ),不得用於商業目的,基於本文修改后的作品務必以相同的許可發布。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM