Go 每日一庫之 gorilla-handlers

簡介

上一篇文章中,我們介紹了 gorilla web 開發工具包中的路由管理庫gorilla/mux,在文章最後我們介紹瞭如何使用中間件處理通用的邏輯。在日常 Go Web 開發中,開發者遇到了很多相同的中間件需求,gorilla/handlers(後文簡稱爲handlers)收集了一些比較常用的中間件。一起來看看吧~

關於中間件,前面幾篇文章已經介紹的很多了。這裏就不贅述了。handlers庫提供的中間件可用於標準庫net/http和所有支持http.Handler接口的框架。由於gorilla/mux也支持http.Handler接口,所以也可以與handlers庫結合使用。這就是兼容標準的好處

項目初始化 & 安裝

本文代碼使用 Go Modules。

創建目錄並初始化:

$ mkdir gorilla/handlers && cd gorilla/handlers
$ go mod init github.com/darjun/go-daily-lib/gorilla/handlers

安裝gorilla/handlers庫:

$ go get -u github.com/gorilla/handlers

下面依次介紹各個中間件和相應的源碼。

日誌

handlers提供了兩個日誌中間件:

兩種日誌格式差別很小,Common Log Format格式如下:

%h %l %u %t "%r" %>s %b

各個指示符含義如下:

Combined Log Format格式如下:

%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i"

可見相比Common Log Format只是多了:

對中間件,我們可以讓它作用於全局,即全部處理器,也可以讓它只對某些處理器生效。如果要對所有處理器生效,可以調用Use()方法。如果只需要作用於特定的處理器,在註冊時用中間件將處理器包裝一層:

func index(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintln(w, "Hello World")
}

type greeting string

func (g greeting) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintf(w, "Welcome, %s", g)
}

func main() {
  r := mux.NewRouter()
  r.Handle("/", handlers.LoggingHandler(os.Stdout, http.HandlerFunc(index)))
  r.Handle("/greeting", handlers.CombinedLoggingHandler(os.Stdout, greeting("dj")))

  http.Handle("/", r)
  log.Fatal(http.ListenAndServe(":8080", nil))
}

上面代碼中LoggingHandler只作用於處理函數indexCombinedLoggingHandler只作用於處理器greeting("dj")

運行代碼,通過瀏覽器訪問localhost:8080localhost:8080/greeting

::1 - - [21/Jul/2021:06:39:45 +0800] "GET / HTTP/1.1" 200 12
::1 - - [21/Jul/2021:06:39:54 +0800] "GET /greeting HTTP/1.1" 200 11 "" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.164 Safari/537.36"

對照前面分析的指示符,很容易看出各個部分。

由於*mux.RouterUse()方法接受類型爲MiddlewareFunc的中間件:

type MiddlewareFunc func(http.Handler) http.Handler

handlers.LoggingHandler/CombinedLoggingHandler並不滿足,所以還需要包裝一層才能傳給Use()方法:

func Logging(handler http.Handler) http.Handler {
  return handlers.CombinedLoggingHandler(os.Stdout, handler)
}

func main() {
  r := mux.NewRouter()
  r.Use(Logging)
  r.HandleFunc("/", index)
  r.Handle("/greeting/", greeting("dj"))

  http.Handle("/", r)
  log.Fatal(http.ListenAndServe(":8080", nil))
}

另外handlers還提供了CustomLoggingHandler,我們可以利用它定義自己的日誌中間件:

func CustomLoggingHandler(out io.Writer, h http.Handler, f LogFormatter) http.Handler

最關鍵的LogFormatter類型定義:

type LogFormatterParams struct {
  Request    *http.Request
  URL        url.URL
  TimeStamp  time.Time
  StatusCode int
  Size       int
}

type LogFormatter func(writer io.Writer, params LogFormatterParams)

我們實現一個簡單的LogFormatter,記錄時間 + 請求行 + 響應碼:

func myLogFormatter(writer io.Writer, params handlers.LogFormatterParams) {
  var buf bytes.Buffer
  buf.WriteString(time.Now().Format("2006-01-02 15:04:05 -0700"))
  buf.WriteString(fmt.Sprintf(` "%s %s %s" `, params.Request.Method, params.URL.Path, params.Request.Proto))
  buf.WriteString(strconv.Itoa(params.StatusCode))
  buf.WriteByte('\n')

  writer.Write(buf.Bytes())
}

func Logging(handler http.Handler) http.Handler {
  return handlers.CustomLoggingHandler(os.Stdout, handler, myLogFormatter)
}

使用:

func main() {
  r := mux.NewRouter()
  r.Use(Logging)
  r.HandleFunc("/", index)
  r.Handle("/greeting/", greeting("dj"))

  http.Handle("/", r)
  log.Fatal(http.ListenAndServe(":8080", nil))
}

現在記錄的日誌是下面這種格式:

2021-07-21 07:03:18 +0800 "GET /greeting/ HTTP/1.1" 200

翻看源碼,我們可以發現LoggingHandler/CombinedLoggingHandler/CustomLoggingHandler都是基於底層的loggingHandler實現的,不同的是LoggingHandler使用了預定義的writeLog作爲LogFormatterCombinedLoggingHandler使用了預定義的writeCombinedLog作爲LogFormatter,而CustomLoggingHandler使用我們自己定義的LogFormatter

func CombinedLoggingHandler(out io.Writer, h http.Handler) http.Handler {
  return loggingHandler{out, h, writeCombinedLog}
}

func LoggingHandler(out io.Writer, h http.Handler) http.Handler {
  return loggingHandler{out, h, writeLog}
}

func CustomLoggingHandler(out io.Writer, h http.Handler, f LogFormatter) http.Handler {
  return loggingHandler{out, h, f}
}

預定義的writeLog/writeCombinedLog實現如下:

func writeLog(writer io.Writer, params LogFormatterParams) {
  buf := buildCommonLogLine(params.Request, params.URL, params.TimeStamp, params.StatusCode, params.Size)
  buf = append(buf, '\n')
  writer.Write(buf)
}

func writeCombinedLog(writer io.Writer, params LogFormatterParams) {
  buf := buildCommonLogLine(params.Request, params.URL, params.TimeStamp, params.StatusCode, params.Size)
  buf = append(buf, ` "`...)
  buf = appendQuoted(buf, params.Request.Referer())
  buf = append(buf, `" "`...)
  buf = appendQuoted(buf, params.Request.UserAgent())
  buf = append(buf, '"', '\n')
  writer.Write(buf)
}

它們都是基於buildCommonLogLine構造基本信息,writeCombinedLog還分別調用http.Request.Referer()http.Request.UserAgent獲取了RefererUser-Agent信息。

loggingHandler定義如下:

type loggingHandler struct {
  writer    io.Writer
  handler   http.Handler
  formatter LogFormatter
}

loggingHandler實現有一個比較巧妙的地方:爲了記錄響應碼和響應大小,定義了一個類型responseLogger包裝原來的http.ResponseWriter,在寫入時記錄信息:

type responseLogger struct {
  w      http.ResponseWriter
  status int
  size   int
}

func (l *responseLogger) Write([]byte) (int, error) {
  size, err := l.w.Write(b)
  l.size += size
  return size, err
}

func (l *responseLogger) WriteHeader(s int) {
  l.w.WriteHeader(s)
  l.status = s
}

func (l *responseLogger) Status() int {
  return l.status
}

func (l *responseLogger) Size() int {
  return l.size
}

loggingHandler的關鍵方法ServeHTTP()

func (h loggingHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
  t := time.Now()
  logger, w := makeLogger(w)
  url := *req.URL

  h.handler.ServeHTTP(w, req)
  if req.MultipartForm != nil {
    req.MultipartForm.RemoveAll()
  }

  params := LogFormatterParams{
    Request:    req,
    URL:        url,
    TimeStamp:  t,
    StatusCode: logger.Status(),
    Size:       logger.Size(),
  }

  h.formatter(h.writer, params)
}

構造LogFormatterParams對象,調用對應的LogFormatter函數。

壓縮

如果客戶端請求中有Accept-Encoding首部,服務器可以使用該首部指示的算法將響應壓縮,以節省網絡流量。handlers.CompressHandler中間件啓用壓縮功能。還有一個CompressHandlerLevel可以指定壓縮級別。實際上CompressHandler就是使用gzip.DefaultCompression調用的CompressHandlerLevel

func CompressHandler(h http.Handler) http.Handler {
  return CompressHandlerLevel(h, gzip.DefaultCompression)
}

看代碼:

func index(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintln(w, "Hello World")
}

type greeting string

func (g greeting) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintf(w, "Welcome, %s", g)
}

func main() {
  r := mux.NewRouter()
  r.Use(handlers.CompressHandler)
  r.HandleFunc("/", index)
  r.Handle("/greeting/", greeting("dj"))

  http.Handle("/", r)
  log.Fatal(http.ListenAndServe(":8080", nil))
}

運行,請求localhost:8080,通過 Chrome 開發者工具的 Network 頁籤可以看到響應採用了 gzip 壓縮:

忽略一些細節處理,CompressHandlerLevel函數代碼如下:

func CompressHandlerLevel(h http.Handler, level int) http.Handler {
  const (
    gzipEncoding  = "gzip"
    flateEncoding = "deflate"
  )

  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    var encoding string
    for _, curEnc := range strings.Split(r.Header.Get(acceptEncoding)",") {
      curEnc = strings.TrimSpace(curEnc)
      if curEnc == gzipEncoding || curEnc == flateEncoding {
        encoding = curEnc
        break
      }
    }

    if encoding == "" {
      h.ServeHTTP(w, r)
      return
    }

    if r.Header.Get("Upgrade") != "" {
      h.ServeHTTP(w, r)
      return
    }

    var encWriter io.WriteCloser
    if encoding == gzipEncoding {
      encWriter, _ = gzip.NewWriterLevel(w, level)
    } else if encoding == flateEncoding {
      encWriter, _ = flate.NewWriter(w, level)
    }
    defer encWriter.Close()

    w.Header().Set("Content-Encoding", encoding)
    r.Header.Del(acceptEncoding)

    cw := &compressResponseWriter{
      w:          w,
      compressor: encWriter,
    }

    w = httpsnoop.Wrap(w, httpsnoop.Hooks{
      Write: func(httpsnoop.WriteFunc) httpsnoop.WriteFunc {
        return cw.Write
      },
      WriteHeader: func(httpsnoop.WriteHeaderFunc) httpsnoop.WriteHeaderFunc {
        return cw.WriteHeader
      },
      Flush: func(httpsnoop.FlushFunc) httpsnoop.FlushFunc {
        return cw.Flush
      },
      ReadFrom: func(rff httpsnoop.ReadFromFunc) httpsnoop.ReadFromFunc {
        return cw.ReadFrom
      },
    })

    h.ServeHTTP(w, r)
  })
}

從請求Accept-Encoding首部中獲取客戶端指示的壓縮算法。如果客戶端未指定,或請求首部中有Upgrade,則不壓縮。反之,則壓縮。根據識別的壓縮算法,創建對應gzipflateio.Writer實現對象。

與前面的日誌中間件一樣,爲了壓縮寫入的內容,新增類型compressResponseWriter封裝http.ResponseWriter,重寫Write()方法,將寫入的字節流傳入前面創建的io.Writer實現壓縮:

type compressResponseWriter struct {
  compressor io.Writer
  w          http.ResponseWriter
}

func (cw *compressResponseWriter) Write([]byte) (int, error) {
  h := cw.w.Header()
  if h.Get("Content-Type") == "" {
    h.Set("Content-Type", http.DetectContentType(b))
  }
  h.Del("Content-Length")

  return cw.compressor.Write(b)
}

內容類型

我們可以通過handler.ContentTypeHandler指定請求的Content-Type必須在我們給出的類型中,只對POST/PUT/PATCH方法生效。例如我們限制登錄請求必須通過application/x-www-form-urlencoded的形式發送:

func main() {
  r := mux.NewRouter()
  r.HandleFunc("/", index)
  r.Methods("GET").Path("/login").HandlerFunc(login)
  r.Methods("POST").Path("/login").
    Handler(handlers.ContentTypeHandler(http.HandlerFunc(dologin)"application/x-www-form-urlencoded"))

  http.Handle("/", r)
  log.Fatal(http.ListenAndServe(":8080", nil))
}

這樣,只要請求/loginContent-Type不是application/x-www-form-urlencoded就會返回 415 錯誤。我們可以故意寫錯,再請求看看錶現:

Unsupported content type "application/x-www-form-urlencoded"; expected one of ["application/x-www-from-urlencoded"]

ContentTypeHandler的實現非常簡單:

func ContentTypeHandler(h http.Handler, contentTypes ...string) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    if !(r.Method == "PUT" || r.Method == "POST" || r.Method == "PATCH") {
      h.ServeHTTP(w, r)
      return
    }

    for _, ct := range contentTypes {
      if isContentType(r.Header, ct) {
        h.ServeHTTP(w, r)
        return
      }
    }
    http.Error(w, fmt.Sprintf("Unsupported content type %q; expected one of %q", r.Header.Get("Content-Type"), contentTypes), http.StatusUnsupportedMediaType)
  })
}

就是讀取Content-Type首部,判斷是否在我們指定的類型中。

方法分發器

在上面的例子中,我們註冊路徑/loginGETPOST方法處理採用r.Methods("GET").Path("/login").HandlerFunc(login)這種冗長的寫法。handlers.MethodHandler可以簡化這種寫法:

func main() {
  r := mux.NewRouter()
  r.HandleFunc("/", index)
  r.Handle("/login", handlers.MethodHandler{
    "GET":  http.HandlerFunc(login),
    "POST": http.HandlerFunc(dologin),
  })

  http.Handle("/", r)
  log.Fatal(http.ListenAndServe(":8080", nil))
}

MethodHandler底層是一個map[string]http.Handler類型,它的ServeHTTP()方法根據請求的 Method 調用不同的處理:

type MethodHandler map[string]http.Handler

func (h MethodHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
  if handler, ok := h[req.Method]; ok {
    handler.ServeHTTP(w, req)
  } else {
    allow := []string{}
    for k := range h {
      allow = append(allow, k)
    }
    sort.Strings(allow)
    w.Header().Set("Allow", strings.Join(allow, ", "))
    if req.Method == "OPTIONS" {
      w.WriteHeader(http.StatusOK)
    } else {
      http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    }
  }
}

方法如果未註冊,則返回405 Method Not Allowed。有一個方法除外,OPTIONS。該方法通過Allow首部返回支持哪些方法。

重定向

handlers.CanonicalHost可以將請求重定向到指定的域名,同時指定重定向響應碼。在同一個服務器對應多個域名時比較有用:

func index(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintln(w, "hello world")
}

func main() {
  r := mux.NewRouter()
  r.Use(handlers.CanonicalHost("http://www.gorillatoolkit.org", 302))
  r.HandleFunc("/", index)
  http.Handle("/", r)
  log.Fatal(http.ListenAndServe(":8080", nil))
}

上面將所有請求以 302 重定向到http://www.gorillatoolkit.org

CanonicalHost的實現也很簡單:

func CanonicalHost(domain string, code int) func(h http.Handler) http.Handler {
  fn := func(h http.Handler) http.Handler {
    return canonical{h, domain, code}
  }

  return fn
}

關鍵類型canonical

type canonical struct {
  h      http.Handler
  domain string
  code   int
}

核心方法:

func (c canonical) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  dest, err := url.Parse(c.domain)
  if err != nil {
    c.h.ServeHTTP(w, r)
    return
  }

  if dest.Scheme == "" || dest.Host == "" {
    c.h.ServeHTTP(w, r)
    return
  }

  if !strings.EqualFold(cleanHost(r.Host), dest.Host) {
    dest := dest.Scheme + "://" + dest.Host + r.URL.Path
    if r.URL.RawQuery != "" {
      dest += "?" + r.URL.RawQuery
    }
    http.Redirect(w, r, dest, c.code)
    return
  }

  c.h.ServeHTTP(w, r)
}

由源碼可知,域名不合法或未指定協議(Scheme)或域名(Host)的請求下不轉發。

Recovery

之前我們自己實現了PanicRecover中間件,避免請求處理時 panic。handlers提供了一個RecoveryHandler可以直接使用:

func PANIC(w http.ResponseWriter, r *http.Request) {
  panic(errors.New("unexpected error"))
}

func main() {
  r := mux.NewRouter()
  r.Use(handlers.RecoveryHandler(handlers.PrintRecoveryStack(true)))
  r.HandleFunc("/", PANIC)
  http.Handle("/", r)
  log.Fatal(http.ListenAndServe(":8080", nil))
}

選項PrintRecoveryStack表示 panic 時輸出堆棧信息。

RecoveryHandler的實現與之前我們自己編寫的基本一樣:

type recoveryHandler struct {
  handler    http.Handler
  logger     RecoveryHandlerLogger
  printStack bool
}

func (h recoveryHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
  defer func() {
    if err := recover(); err != nil {
      w.WriteHeader(http.StatusInternalServerError)
      h.log(err)
    }
  }()

  h.handler.ServeHTTP(w, req)
}

總結

GitHub 上有很多開源的 Go Web 中間件實現,可以直接拿來使用,避免重複造輪子。handlers很輕量,容易與標準庫net/http和 gorilla 路由庫mux結合使用。

大家如果發現好玩、好用的 Go 語言庫,歡迎到 Go 每日一庫 GitHub 上提交 issue😄

參考

  1. gorilla/handlers GitHub:github.com/gorilla/handlers

  2. Go 每日一庫 GitHub:https://github.com/darjun/go-daily-lib


福利

我爲大家整理了一份從入門到進階的 Go 學習資料禮包,包含學習建議:入門看什麼,進階看什麼。關注公衆號 「polarisxu」,回覆 ebook 獲取;還可以回覆「進羣」,和數萬 Gopher 交流學習。

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