Go 服務中 HTTP 請求的生命週期

Go 語言對於編寫 HTTP 服務來說是一個常見且非常合適的工具。這篇博文通過一個 Go 服務來探討一個典型 HTTP 請求的路由,涉及路由,中間件以及比如併發之類的相關問題。

爲了有具體的代碼可以參考,讓我們先從這段簡單的服務代碼開始(來自於 https://gobyexample.com/http-servers[1])

package main

import (
 "fmt"
 "net/http"
)

func hello(w http.ResponseWriter, req *http.Request) {
 fmt.Fprintf(w, "hello\n")
}

func headers(w http.ResponseWriter, req *http.Request) {
 for name, headers := range req.Header {
  for _, h := range headers {
   fmt.Fprintf(w, "%v: %v\n", name, h)
  }
 }
}

func main() {
 http.HandleFunc("/hello", hello)
 http.HandleFunc("/headers", headers)

 http.ListenAndServe(":8090", nil)
}

我們會通過查看 http.ListenAndServe 函數來開始跟蹤一個 HTTP 請求在這個服務中的生命週期:

func ListenAndServe(addr string, handler Handler) error

這張圖展示了調用時所發生的簡要流程:

這是函數和方法調用的實際序列的高度 “內聯” 版本,但是原始的代碼 [2] 並不難理解。

主流程正如你期望的那樣:ListenAndServe 監聽給定地址的 TCP 端口,之後循環接受新的連接。對於每一個新連接,它都會調度一個 goroutine 來處理這個連接(稍後詳細說明)。處理連接涉及一個這樣的循環:

handler 是一個實現 http.Handler 接口的任意實例:

type Handler interface {
  ServeHTTP(ResponseWriter, *Request)
}

默認的 handler

在我們的實例代碼中,ListenAndServe 被調用的時候使用 nil 作爲第二個參數,而這個位置本應該使用用戶定義的 handler,這是怎麼回事?

我們的圖簡化了一些細節;實際上,當這個 HTTP 包處理一個請求的時候,它並不會直接調用用戶的 handler,而是使用這個適配器:

type serverHandler struct {
 srv *Server
}

func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
 handler := sh.srv.Handler
 if handler == nil {
  handler = DefaultServeMux
 }
 if req.RequestURI == "*" && req.Method == "OPTIONS" {
  handler = globalOptionsHandler{}
 }
 handler.ServeHTTP(rw, req)
}

注意高亮的部分(if handler == nil ...),如果 handler == nil,則 http.DefaultServeMux 被用作 handler。這個是_默認的 server mux_,http 包中所包含的一個 http.ServeMux 類型的全局實例。順便一提,當我們的示例代碼使用 http.HandleFunc 註冊 handler 函數的時候,會在同一個默認的 mux 上註冊這些 handler。

我們可以如下所示這樣重寫我們的示例代碼,不再使用默認的 mux。只修改 main 函數,所以這裏沒有展示 helloheaders handler 函數,我們可以在這看完整的代碼 [3]。功能上沒有任何變化 [^1]:

func main() {
 mux := http.NewServeMux()
 mux.HandleFunc("/hello", hello)
 mux.HandleFunc("/headers", headers)

 http.ListenAndServe(":8090", mux)
}

一個 ServeMux 僅僅是一個 Handler

當看多了 Go 服務的例子後,很容易給人一種 ListenAndServe 函數 “需要一個 mux” 作爲參數的印象,但是這是不準確的。就像我們之前所見到的那樣,ListenAndServe 函數需要的是一個實現了 http.Handler 接口的值。我們可以寫下面這樣的服務而沒有任何 mux:

type PoliteServer struct {
}

func (ms *PoliteServer) ServeHTTP(w http.ResponseWriter, req *http.Request) {
 fmt.Fprintf(w, "Welcome! Thanks for visiting!\n")
}

func main() {
 ps := &PoliteServer{}
 log.Fatal(http.ListenAndServe(":8090", ps))
}

由於這裏沒有路由邏輯;所有到達 PoliteServerServeHTTP 方法的 HTTP 請求都會以同樣的信息所回覆。試着用不同的路徑和方法 curl -ing 這個服務;返回一定是一致的。

我們可以使用 http.HandlerFunc 來進一步簡化我們的 polite 服務:

func politeGreeting(w http.ResponseWriter, req *http.Request) {
 fmt.Fprintf(w, "Welcome! Thanks for visiting!\n")
}

func main() {
 log.Fatal(http.ListenAndServe(":8090", http.HandlerFunc(politeGreeting)))
}

HandlerFunc 是這樣一個位於 http 包中的巧妙的適配器:

// The HandlerFunc type is an adapter to allow the use of
// ordinary functions as HTTP handlers. If f is a function
// with the appropriate signature, HandlerFunc(f) is a
// Handler that calls f.
type HandlerFunc func(ResponseWriter, *Request)

// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
 f(w, r)
}

如果你在這篇博文的第一個示例中注意到 http.HandleFunc[^2], 它對具有 HandlerFunc 簽名的函數使用同樣的適配器。

就像 PoliteServer 一樣,http.ServeMux 是一個實現了 http.Handler 接口的類型。如果願意的話你可以仔細閱讀完整代碼 [4];這是一個大綱:

因此,mux 可以被看做是一個_轉發 handler_;這種模式在 HTTP 服務開發中極爲常見,這就是_中間件_。

http.Handler 中間件

由於中間件在不同的上下文,不同的語言以及不同的框架中意味着不同的東西,所以它很難被準確定義。

讓我們回到這篇博文開頭的流程圖上,對它進行一點簡化,隱藏 http 包所執行的細節:

現在,當我們加了中間件的話,流程圖看起來是這樣的:

在 Go 語言中,中間件只是另一個 HTTP handler,它包裹了一個其他的 handler。中間件 handler 通過調用 ListenAndServe 被註冊進來;當調用的時候,它可以執行任意的預處理,調用自身包裹的 handler 然後可以執行任意的後置處理。

我們之前已經見過了一箇中間件的例子—— http.ServeMux;在這個例子中,預處理是基於請求的 path 來選擇正確的用戶 handler 來調用。沒有後置處理。

再來另一個具體的例子,回到我們的 polite 服務上,新增一些基本的_日誌中間件_。這個中間件記錄每個請求的具體日誌,包括執行了多長時間:

type LoggingMiddleware struct {
 handler http.Handler
}

func (lm *LoggingMiddleware) ServeHTTP(w http.ResponseWriter, req *http.Request) {
 start := time.Now()
 lm.handler.ServeHTTP(w, req)
 log.Printf("%s %s %s", req.Method, req.RequestURI, time.Since(start))
}

type PoliteServer struct {
}

func (ms *PoliteServer) ServeHTTP(w http.ResponseWriter, req *http.Request) {
 fmt.Fprintf(w, "Welcome! Thanks for visiting!\n")
}

func main() {
 ps := &PoliteServer{}
 lm := &LoggingMiddleware{handler: ps}
 log.Fatal(http.ListenAndServe(":8090", lm))
}

注意 LoggingMiddleware 本身是一個 http.Handler,它持有一個用戶 handler 作爲字段。當 ListenAndServe 調用它的 ServeHTTP 方法,它做了如下事情:

  1. 預處理:在用戶的 handler 執行之前記錄一個時間戳

  2. 使用請求和返回 writer 調用用戶 handler

  3. 後置處理:記錄請求詳細日誌,包括耗時

中間件最大的優點是可以組合。被中間件所包裹 “用戶 handler” 也可以是另一箇中間件,依次類推。這是一個互相包裹的 http.Handler 鏈。事實上,這在 Go 中是一個常見的模式,來看看 Go 中間件的經典用法。還是我們的日誌 polite 服務,這次使用了更有識別度的 Go 中間件實現:

func politeGreeting(w http.ResponseWriter, req *http.Request) {
 fmt.Fprintf(w, "Welcome! Thanks for visiting!\n")
}

func loggingMiddleware(next http.Handler) http.Handler {
 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
  start := time.Now()
  next.ServeHTTP(w, req)
  log.Printf("%s %s %s", req.Method, req.RequestURI, time.Since(start))
 })
}

func main() {
 lm := loggingMiddleware(http.HandlerFunc(politeGreeting))
 log.Fatal(http.ListenAndServe(":8090", lm))
}

相對於創建一個帶有方法的結構體,loggingMiddleware 利用 http.HandlerFunc 和閉包使代碼更加簡潔,同時保留了相同的功能。更重要的是這個例子展示了中間件事實上的標準_簽名_:一個函數傳入一個 http.Handler,有時還有其他狀態,之後返回一個不同的 http.Handler。返回的 handler 現在應該替換掉傳入中間件的那個 handler,之後會 “神奇地” 執行它原有的功能,並且與中間件的功能包裝在一起。

比如。標準庫包含了以下的中間件:

func TimeoutHandler(h Handler, dt time.Duration, msg string) Handler

如果我們的代碼中有 http.Handler,像這樣包裝它:

handler = http.TimeoutHandler(handler, 2 * time.Second, "timed out")

創建了一個新版本的 handler,這個版本內置了 2 秒的超時機制。

中間件的組合可以像下面這樣展示:

handler = http.TimeoutHandler(handler, 2 * time.Second, "timed out")
handler = loggingMiddleware(handler)

經過這樣兩行代碼之後,handler 會帶有超時_和日誌_功能。你也許會注意到鏈路長的中間件編寫起來會很繁瑣;Go 有很多流行的包可以解決這個問題,不過這不在這篇文章的討論範圍內。

順便一提,雖然 http 包在內部使用中間件滿足自身需要;具體見這篇博文之前關於 serverHandler 適配器的例子。但是它提供了一個清晰的方式以默認行爲處理用戶 handler 爲 nil 的情形(把請求傳入默認的 mux)。

希望這樣可以讓大家明白爲什麼中間件是一個很吸引人的輔助設計。我們可以專注於我們的 “業務邏輯” handler 上,儘管完全正交,我們利用通用的中間件,在許多方面提升我們的 handler。在其他文章中,會進行全面的探討。

併發和 panic 處理

爲了結束我們對於 Go HTTP 服務中 HTTP 請求的探索,來介紹另外兩個主題:併發和 panic 處理。

首先是_併發_。之前簡單提到,每個連接由 http.Server.Serve 在一個新的 goroutine 中處理。

這是 Go 的 net/http 的一個強大的功能,它利用了 Go 出色的併發性能,使用輕量的 goroutine 使 HTTP handler 保持了一個非常簡單的併發模型。一個 handler 阻塞的時候(比如,讀取數據庫)不需要擔心拖慢其他 handler。但是,編寫存在共享數據的 handler 的時候需要格外小心。具體細節參考之前的文章 [5]。

最後,panic 處理。一個 HTTP 服務通常是一個長期運行的後臺進程。假如在用戶提供的請求 handler 中發生了什麼糟糕的事情,比如,一些導致運行時 panic 的 bug。會導致整個服務崩潰,這可不是什麼好事情。爲了避免這樣的慘劇,你也許會考慮在你服務的 main 函數中加上 recover,但是並沒什麼用,原因如下:

  1. 當控制返還給 main 函數的時候,ListenAndServe 已經執行完畢而不會再提供任何服務。

  2. 由於每個連接在分開的 goroutine 中處理,當 handler 中發送 panic 的時候,甚至不會影響到 main 函數,但是會導致對應進程的崩潰。

爲了提供些許的幫助,net/http 包(在 conn.serve 方法中)內置對每個服務 goroutine 有 recovery。我們可以通過簡單的例子來看到它的作用:

func hello(w http.ResponseWriter, req *http.Request) {
 fmt.Fprintf(w, "hello\n")
}

func doPanic(w http.ResponseWriter, req *http.Request) {
 panic("oops")
}

func main() {
 http.HandleFunc("/hello", hello)
 http.HandleFunc("/panic", doPanic)

 http.ListenAndServe(":8090", nil)
}

如果我們運行這個服務,並且 curl /panic 路徑,我們可以看到:

$ curl localhost:8090/panic
curl: (52) Empty reply from server

並且服務會在自身 log 中打印這樣的信息:

2021/02/16 09:44:31 http: panic serving 127.0.0.1:52908: oops
goroutine 8 [running]:
net/http.(*conn).serve.func1(0xc00010cbe0)
 /usr/local/go/src/net/http/server.go:1801 +0x147
panic(0x654840, 0x6f0b80)
 /usr/local/go/src/runtime/panic.go:975 +0x47a
main.doPanic(0x6fa060, 0xc0001401c0, 0xc000164200)
[... rest of stack dump here ...]

不過,這個服務會保持運行並且我們可以繼續訪問它!

儘管這個內置的保護機制相比服務崩潰要好,許多開發者還是發現了它的侷限。這個保護機制只關閉了連接以及在日誌中輸出錯誤;通常來說,向客戶端返回某種錯誤響應(比如 code 500 —— 內置錯誤)和附加詳細信息會有用得多。

閱讀了這個博文後,再寫實現這個功能的中間件應該是很容易的。將它作爲練習!我會在之後的博文中介紹這個用例。

[^1]: 與使用默認的 mux 的版本相比,這個版本有充分的理由更喜歡這一版本。默認的 mux 有着一定的安全風險;作爲全局實例,它可以被你工程中引入的任何包所修改。一個惡意的包也許會出於邪惡的目的而使用它。[^2]: 注意:http.HandleFunchttp.HandlerFunc 是具有不同而有相互關聯的角色的不同實體。


via: https://eli.thegreenplace.net/2021/life-of-an-http-request-in-a-go-server/

作者:Eli Bendersky[6] 譯者:dust347[7] 校對:polaris1119[8]

本文由 GCTT[9] 原創編譯,Go 中文網 [10] 榮譽推出,發佈在 Go 語言中文網公衆號,轉載請聯繫我們授權。

參考資料

[1] https://gobyexample.com/http-servers: https://gobyexample.com/http-servers

[2] 原始的代碼: https://go.googlesource.com/go/+/go1.15.8/src/net/http/server.go

[3] 完整的代碼: https://github.com/eliben/code-for-blog/blob/master/2021/go-life-http-request/basic-server-mux-object.go

[4] 完整代碼: https://go.googlesource.com/go/+/go1.15.8/src/net/http/server.go

[5] 之前的文章: https://eli.thegreenplace.net/2019/on-concurrency-in-go-http-servers

[6] Eli Bendersky: https://eli.thegreenplace.net/pages/about

[7] dust347: https://github.com/dust347

[8] polaris1119: https://github.com/polaris1119

[9] GCTT: https://github.com/studygolang/GCTT

[10] Go 中文網: https://studygolang.com/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/8j-hzmxs9NlaPDptldHJDg