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()方法。DefaultServeMuxServeMux類型的實例:

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實現了接口HandlerHandlerFunc類型只是爲了方便註冊函數類型的處理器。我們當然可以直接定義一個實現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對象初始化ServerHandler字段,最後調用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/clocalhost: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😄

參考

  1. Go 每日一庫 GitHub:https://github.com/darjun/go-daily-lib
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/vlwX48Z6JBj6A8g7r-9dgA