Go 每日一庫之 net-http(基礎和中間件)
簡介
幾乎所有的編程語言都以Hello World
作爲入門程序的示例,其中有一部分以編寫一個 Web 服務器作爲實戰案例的開始。每種編程語言都有很多用於編寫 Web 服務器的庫,或以標準庫,或通過第三方庫的方式提供。Go 語言也不例外。本文及後續的文章就去探索 Go 語言中的各個 Web 編程框架,它們的基本使用,閱讀它們的源碼,比較它們優缺點。讓我們先從 Go 語言的標準庫net/http
開始。標準庫net/http
讓編寫 Web 服務器的工作變得非常簡單。我們一起探索如何使用net/http
庫實現一些常見的功能或模塊,瞭解這些對我們學習其他的庫或框架將會很有幫助。
Hello World
使用net/http
編寫一個簡單的 Web 服務器非常簡單:
package main
import (
"fmt"
"net/http"
)
func index(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello World")
}
func main() {
http.HandleFunc("/", index)
http.ListenAndServe(":8080", nil)
}
首先,我們調用http.HandleFunc("/", index)
註冊路徑處理函數,這裏將路徑/
的處理函數設置爲index
。處理函數的類型必須是:
func (http.ResponseWriter, *http.Request)
其中*http.Request
表示 HTTP 請求對象,該對象包含請求的所有信息,如 URL、首部、表單內容、請求的其他內容等。
http.ResponseWriter
是一個接口類型:
// net/http/server.go
type ResponseWriter interface {
Header() Header
Write([]byte) (int, error)
WriteHeader(statusCode int)
}
用於向客戶端發送響應,實現了ResponseWriter
接口的類型顯然也實現了io.Writer
接口。所以在處理函數index
中,可以調用fmt.Fprintln()
向ResponseWriter
寫入響應信息。
仔細閱讀net/http
包中HandleFunc()
函數的源碼:
func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
DefaultServeMux.HandleFunc(pattern, handler)
}
我們發現它直接調用了一個名爲DefaultServeMux
對象的HandleFunc()
方法。DefaultServeMux
是ServeMux
類型的實例:
type ServeMux struct {
mu sync.RWMutex
m map[string]muxEntry
es []muxEntry // slice of entries sorted from longest to shortest.
hosts bool // whether any patterns contain hostnames
}
var DefaultServeMux = &defaultServeMux
var defaultServeMux ServeMux
像這種提供默認類型實例的用法在 Go 語言的各個庫中非常常見,在默認參數就已經足夠的場景中使用默認實現很方便。ServeMux
保存了註冊的所有路徑和處理函數的對應關係。ServeMux.HandleFunc()
方法如下:
func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
mux.Handle(pattern, HandlerFunc(handler))
}
這裏將處理函數handler
轉爲HandlerFunc
類型,然後調用ServeMux.Handle()
方法註冊。注意這裏的HandlerFunc(handler)
是類型轉換,而非函數調用,類型HandlerFunc
的定義如下:
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
HandlerFunc
實際上是以函數類型func(ResponseWriter, *Request)
爲底層類型,爲HandlerFunc
類型定義了方法ServeHTTP
。是的,Go 語言允許爲(基於)函數的類型定義方法。Serve.Handle()
方法只接受類型爲接口Handler
的參數:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
func (mux *ServeMux) Handle(pattern string, handler Handler) {
if mux.m == nil {
mux.m = make(map[string]muxEntry)
}
e := muxEntry{h: handler, pattern: pattern}
if pattern[len(pattern)-1] == '/' {
mux.es = appendSorted(mux.es, e)
}
mux.m[pattern] = e
}
顯然HandlerFunc
實現了接口Handler
。HandlerFunc
類型只是爲了方便註冊函數類型的處理器。我們當然可以直接定義一個實現Handler
接口的類型,然後註冊該類型的實例:
type greeting string
func (g greeting) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, g)
}
http.Handle("/greeting", greeting("Welcome, dj"))
我們基於string
類型定義了一個新類型greeting
,然後爲它定義一個方法ServeHTTP()
(實現接口Handler
),最後調用http.Handle()
方法註冊該處理器。
爲了便於區分,我們將通過HandleFunc()
註冊的稱爲處理函數,將通過Handle()
註冊的稱爲處理器。通過上面的源碼分析不難看出,它們在底層本質上是一回事。
註冊了處理邏輯後,調用http.ListenAndServe(":8080", nil)
監聽本地計算機的 8080 端口,開始處理請求。下面看源碼的處理:
func ListenAndServe(addr string, handler Handler) error {
server := &Server{Addr: addr, Handler: handler}
return server.ListenAndServe()
}
ListenAndServe
創建了一個Server
類型的對象:
type Server struct {
Addr string
Handler Handler
TLSConfig *tls.Config
ReadTimeout time.Duration
ReadHeaderTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
}
Server
結構體有比較多的字段,我們可以使用這些字段來調節 Web 服務器的參數,如上面的ReadTimeout/ReadHeaderTimeout/WriteTimeout/IdleTimeout
用於控制讀寫和空閒超時。在該方法中,先調用net.Listen()
監聽端口,將返回的net.Listener
作爲參數調用Server.Serve()
方法:
func (srv *Server) ListenAndServe() error {
addr := srv.Addr
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
return srv.Serve(ln)
}
在Server.Serve()
方法中,使用一個無限的for
循環,不停地調用Listener.Accept()
方法接受新連接,開啓新 goroutine 處理新連接:
func (srv *Server) Serve(l net.Listener) error {
var tempDelay time.Duration // how long to sleep on accept failure
for {
rw, err := l.Accept()
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Temporary() {
if tempDelay == 0 {
tempDelay = 5 * time.Millisecond
} else {
tempDelay *= 2
}
if max := 1 * time.Second; tempDelay > max {
tempDelay = max
}
srv.logf("http: Accept error: %v; retrying in %v", err, tempDelay)
time.Sleep(tempDelay)
continue
}
return err
}
tempDelay = 0
c := srv.newConn(rw)
go c.serve(connCtx)
}
}
這裏有一個指數退避策略的用法。如果l.Accept()
調用返回錯誤,我們判斷該錯誤是不是臨時性地(ne.Temporary()
)。如果是臨時性錯誤,Sleep
一小段時間後重試,每發生一次臨時性錯誤,Sleep
的時間翻倍,最多Sleep
1s。獲得新連接後,將其封裝成一個conn
對象(srv.newConn(rw)
),創建一個 goroutine 運行其serve()
方法。省略無關邏輯的代碼如下:
func (c *conn) serve(ctx context.Context) {
for {
w, err := c.readRequest(ctx)
serverHandler{c.server}.ServeHTTP(w, w.req)
w.finishRequest()
}
}
serve()
方法其實就是不停地讀取客戶端發送的請求,創建serverHandler
對象調用其ServeHTTP()
方法去處理請求,然後做一些清理工作。serverHandler
只是一箇中間的輔助結構,代碼如下:
type serverHandler struct {
srv *Server
}
func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
handler := sh.srv.Handler
if handler == nil {
handler = DefaultServeMux
}
handler.ServeHTTP(rw, req)
}
從Server
對象中獲取Handler
,這個Handler
就是調用http.ListenAndServe()
時傳入的第二個參數。在Hello World
的示例代碼中,我們傳入了nil
。所以這裏handler
會取默認值DefaultServeMux
。調用DefaultServeMux.ServeHTTP()
方法處理請求:
func (mux *ServeMux) ServeHTTP(w ResponseWriter, r *Request) {
h, _ := mux.Handler(r)
h.ServeHTTP(w, r)
}
mux.Handler(r)
通過請求的路徑信息查找處理器,然後調用處理器的ServeHTTP()
方法處理請求:
func (mux *ServeMux) Handler(r *Request) (h Handler, pattern string) {
host := stripHostPort(r.Host)
return mux.handler(host, r.URL.Path)
}
func (mux *ServeMux) handler(host, path string) (h Handler, pattern string) {
h, pattern = mux.match(path)
return
}
func (mux *ServeMux) match(path string) (h Handler, pattern string) {
v, ok := mux.m[path]
if ok {
return v.h, v.pattern
}
for _, e := range mux.es {
if strings.HasPrefix(path, e.pattern) {
return e.h, e.pattern
}
}
return nil, ""
}
上面的代碼省略了大量的無關代碼,在match
方法中,首先會檢查路徑是否精確匹配mux.m[path]
。如果不能精確匹配,後面的for
循環會匹配路徑的最長前綴。只要註冊了/
根路徑處理,所有未匹配到的路徑最終都會交給/
路徑處理。爲了保證最長前綴優先,在註冊時,會對路徑進行排序。所以mux.es
中存放的是按路徑排序的處理列表:
func appendSorted(es []muxEntry, e muxEntry) []muxEntry {
n := len(es)
i := sort.Search(n, func(i int) bool {
return len(es[i].pattern) < len(e.pattern)
})
if i == n {
return append(es, e)
}
es = append(es, muxEntry{})
copy(es[i+1:], es[i:])
es[i] = e
return es
}
運行,在瀏覽器中鍵入網址localhost:8080
,可以看到網頁顯示Hello World
。鍵入網址localhost:8080/greeting
,看到網頁顯示Welcome, dj
。
思考題:根據最長前綴的邏輯,如果鍵入localhost:8080/greeting/a/b/c
,應該會匹配/greeting
路徑。如果鍵入localhost:8080/a/b/c
,應該會匹配/
路徑。是這樣麼?答案放在後面😀。
創建ServeMux
調用http.HandleFunc()/http.Handle()
都是將處理器 / 函數註冊到ServeMux
的默認對象DefaultServeMux
上。使用默認對象有一個問題:不可控。
一來Server
參數都使用了默認值,二來第三方庫也可能使用這個默認對象註冊一些處理,容易衝突。更嚴重的是,我們在不知情中調用http.ListenAndServe()
開啓 Web 服務,那麼第三方庫註冊的處理邏輯就可以通過網絡訪問到,有極大的安全隱患。所以,除非在示例程序中,否則建議不要使用默認對象。
我們可以使用http.NewServeMux()
創建一個新的ServeMux
對象,然後創建http.Server
對象定製參數,用ServeMux
對象初始化Server
的Handler
字段,最後調用Server.ListenAndServe()
方法開啓 Web 服務:
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", index)
mux.Handle("/greeting", greeting("Welcome to go web frameworks"))
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 20 * time.Second,
WriteTimeout: 20 * time.Second,
}
server.ListenAndServe()
}
這個程序與上面的Hello World
功能基本相同,我們還額外設置了讀寫超時。
爲了便於理解,我畫了兩幅圖,其實整理下來整個流程也不復雜:
中間件
有時候需要在請求處理代碼中增加一些通用的邏輯,如統計處理耗時、記錄日誌、捕獲宕機等等。如果在每個請求處理函數中添加這些邏輯,代碼很快就會變得不可維護,添加新的處理函數也會變得非常繁瑣。所以就有了中間件的需求。
中間件有點像面向切面的編程思想,但是與 Java 語言不同。在 Java 中,通用的處理邏輯(也可以稱爲切面)可以通過反射插入到正常邏輯的處理流程中,在 Go 語言中基本不這樣做。
在 Go 中,中間件是通過函數閉包來實現的。Go 語言中的函數是第一類值,既可以作爲參數傳給其他函數,也可以作爲返回值從其他函數返回。我們前面介紹了處理器 / 函數的使用和實現。那麼可以利用閉包封裝已有的處理函數。
首先,基於函數類型func(http.Handler) http.Handler
定義一箇中間件類型:
type Middleware func(http.Handler) http.Handler
接下來我們來編寫中間件,最簡單的中間件就是在請求前後各輸出一條日誌:
func WithLogger(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger.Printf("path:%s process start...\n", r.URL.Path)
defer func() {
logger.Printf("path:%s process end...\n", r.URL.Path)
}()
handler.ServeHTTP(w, r)
})
}
實現很簡單,通過中間件封裝原來的處理器對象,然後返回一個新的處理函數。在新的處理函數中,先輸出開始處理的日誌,然後用defer
語句在函數結束後輸出處理結束的日誌。接着調用原處理器對象的ServeHTTP()
方法執行原處理邏輯。
類似地,我們再來實現一個統計處理耗時的中間件:
func Metric(handler http.Handler) http.HandlerFunc {
return func (w http.ResponseWriter, r *http.Request) {
start := time.Now()
defer func() {
logger.Printf("path:%s elapsed:%fs\n", r.URL.Path, time.Since(start).Seconds())
}()
time.Sleep(1 * time.Second)
handler.ServeHTTP(w, r)
}
}
Metric
中間件封裝原處理器對象,開始執行前記錄時間,執行完成後輸出耗時。爲了能方便看到結果,我在上面代碼中添加了一個time.Sleep()
調用。
最後,由於請求的處理邏輯都是由功能開發人員(而非庫作者)自己編寫的,所以爲了 Web 服務器的穩定,我們需要捕獲可能出現的 panic。PanicRecover
中間件如下:
func PanicRecover(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
logger.Println(string(debug.Stack()))
}
}()
handler.ServeHTTP(w, r)
})
}
調用recover()
函數捕獲 panic,輸出堆棧信息,爲了防止程序異常退出。實際上,在conn.serve()
方法中也有recover()
,程序一般不會異常退出。但是自定義的中間件可以添加我們自己的定製邏輯。
現在我們可以這樣來註冊處理函數:
mux.Handle("/", PanicRecover(WithLogger(Metric(http.HandlerFunc(index)))))
mux.Handle("/greeting", PanicRecover(WithLogger(Metric(greeting("welcome, dj")))))
這種方式略顯繁瑣,我們可以編寫一個幫助函數,它接受原始的處理器對象,和可變的多箇中間件。對處理器對象應用這些中間件,返回新的處理器對象:
func applyMiddlewares(handler http.Handler, middlewares ...Middleware) http.Handler {
for i := len(middlewares)-1; i >= 0; i-- {
handler = middlewares[i](handler)
}
return handler
}
注意應用順序是從右到左的,即右結合,越靠近原處理器的越晚執行。
利用幫助函數,註冊可以簡化爲:
middlewares := []Middleware{
PanicRecover,
WithLogger,
Metric,
}
mux.Handle("/", applyMiddlewares(http.HandlerFunc(index), middlewares...))
mux.Handle("/greeting", applyMiddlewares(greeting("welcome, dj"), middlewares...))
上面每次註冊處理邏輯都需要調用一次applyMiddlewares()
函數,還是略顯繁瑣。我們可以這樣來優化,封裝一個自己的ServeMux
結構,然後定義一個方法Use()
將中間件保存下來,重寫Handle/HandleFunc
將傳入的http.HandlerFunc/http.Handler
處理器包裝中間件之後再傳給底層的ServeMux.Handle()
方法:
type MyMux struct {
*http.ServeMux
middlewares []Middleware
}
func NewMyMux() *MyMux {
return &MyMux{
ServeMux: http.NewServeMux(),
}
}
func (m *MyMux) Use(middlewares ...Middleware) {
m.middlewares = append(m.middlewares, middlewares...)
}
func (m *MyMux) Handle(pattern string, handler http.Handler) {
handler = applyMiddlewares(handler, m.middlewares...)
m.ServeMux.Handle(pattern, handler)
}
func (m *MyMux) HandleFunc(pattern string, handler http.HandlerFunc) {
newHandler := applyMiddlewares(handler, m.middlewares...)
m.ServeMux.Handle(pattern, newHandler)
}
註冊時只需要創建MyMux
對象,調用其Use()
方法傳入要應用的中間件即可:
middlewares := []Middleware{
PanicRecover,
WithLogger,
Metric,
}
mux := NewMyMux()
mux.Use(middlewares...)
mux.HandleFunc("/", index)
mux.Handle("/greeting", greeting("welcome, dj"))
這種方式簡單易用,但是也有它的問題,最大的問題是必須先設置好中間件,然後才能調用Handle/HandleFunc
註冊,後添加的中間件不會對之前註冊的處理器 / 函數生效。
爲了解決這個問題,我們可以改寫ServeHTTP
方法,在確定了處理器之後再應用中間件。這樣後續添加的中間件也能生效。很多第三方庫都是採用這種方式。http.ServeMux
默認的ServeHTTP()
方法如下:
func (m *ServeMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.RequestURI == "*" {
if r.ProtoAtLeast(1, 1) {
w.Header().Set("Connection", "close")
}
w.WriteHeader(http.StatusBadRequest)
return
}
h, _ := m.Handler(r)
h.ServeHTTP(w, r)
}
改造這個方法定義MyMux
類型的ServeHTTP()
方法也很簡單,只需要在m.Handler(r)
獲取處理器之後,應用當前的中間件即可:
func (m *MyMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// ...
h, _ := m.Handler(r)
// 只需要加這一行即可
h = applyMiddlewares(h, m.middlewares...)
h.ServeHTTP(w, r)
}
後面我們分析其他 Web 框架的源碼時會發現,很多都是類似的做法。爲了測試宕機恢復,編寫一個會觸發 panic 的處理函數:
func panics(w http.ResponseWriter, r *http.Request) {
panic("not implemented")
}
mux.HandleFunc("/panic", panics)
運行,在瀏覽器中請求localhost:8080/
和localhost:8080/greeting
,最後請求localhost:8080/panic
觸發 panic:
思考題
思考題:
這其實就是看閱讀代碼是不是仔細,最長前綴的排序列表在ServeMux.Handle()
方法中生成:
func (mux *ServeMux) Handle(pattern string, handler Handler) {
if pattern[len(pattern)-1] == '/' {
mux.es = appendSorted(mux.es, e)
}
}
這裏明顯有個限制條件,即註冊路徑最後必須以/
結尾纔會觸發。所以localhost:8080/greeting/a/b/c
和localhost:8080/a/b/c
都只會匹配/
路徑。如果想要讓localhost:8080/greeting/a/b/c
匹配路徑/greeting
,註冊路徑需要改爲/greeting/
:
http.Handle("/greeting/", greeting("Welcome to go web frameworks"))
這時請求路徑/greeting
會自動重定向(301)到/greeting/
。
總結
本文介紹了使用標準庫net/http
創建 Web 服務器的基本流程,一步步分析源碼。然後介紹瞭如何使用中間件簡化通用的處理邏輯。學習並理解了net/http
庫的內容對於學習其他的 Go Web 框架非常有幫助。第三方的 Go Web 框架大多也是基於net/http
實現自己的ServeMux
對象而已。
大家如果發現好玩、好用的 Go 語言庫,歡迎到 Go 每日一庫 GitHub 上提交 issue😄
參考
- Go 每日一庫 GitHub:https://github.com/darjun/go-daily-lib
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/vlwX48Z6JBj6A8g7r-9dgA