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
提供了兩個日誌中間件:
-
LoggingHandler
:以 Apache 的Common Log Format
日誌格式記錄 HTTP 請求日誌; -
CombinedLoggingHandler
:以 Apache 的Combined Log Format
日誌格式記錄 HTTP 請求日誌,Apache 和 Nginx 默認都使用這種日誌格式。
兩種日誌格式差別很小,Common Log Format
格式如下:
%h %l %u %t "%r" %>s %b
各個指示符含義如下:
-
%h
:客戶端的 IP 地址或主機名; -
%l
:RFC 1413
定義的客戶端標識,由客戶端機器上的identd
程序生成。如果不存在,則該字段爲-
; -
%u
:已驗證的用戶名。如果不存在,該字段爲-
; -
%t
:時間,格式爲day/month/year:hour:minute:second zone
,其中: -
day
:2 位數字; -
month
:月份縮寫,3 個字母,如Jan
; -
year
:4 位數字; -
hour
:2 位數字; -
minute
:2 位數字; -
second
:2 位數字; -
zone
:+
或-
後跟 4 位數字; -
例如:
21/Jul/2021:06:27:33 +0800
-
%r
:包含 HTTP 請求行信息,例GET /index.html HTTP/1.1
; -
%>s
:服務器發送給客戶端的狀態碼,例如200
; -
%b
:響應長度(字節數)。
Combined Log Format
格式如下:
%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i"
可見相比Common Log Format
只是多了:
-
%{Referer}i
:HTTP 首部中的Referer
信息; -
%{User-Agent}i
:HTTP 首部中的User-Agent
信息。
對中間件,我們可以讓它作用於全局,即全部處理器,也可以讓它只對某些處理器生效。如果要對所有處理器生效,可以調用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
只作用於處理函數index
,CombinedLoggingHandler
只作用於處理器greeting("dj")
。
運行代碼,通過瀏覽器訪問localhost:8080
和localhost: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.Router
的Use()
方法接受類型爲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
作爲LogFormatter
,CombinedLoggingHandler
使用了預定義的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
獲取了Referer
和User-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(b []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
,則不壓縮。反之,則壓縮。根據識別的壓縮算法,創建對應gzip
或flate
的io.Writer
實現對象。
與前面的日誌中間件一樣,爲了壓縮寫入的內容,新增類型compressResponseWriter
封裝http.ResponseWriter
,重寫Write()
方法,將寫入的字節流傳入前面創建的io.Writer
實現壓縮:
type compressResponseWriter struct {
compressor io.Writer
w http.ResponseWriter
}
func (cw *compressResponseWriter) Write(b []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))
}
這樣,只要請求/login
的Content-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
首部,判斷是否在我們指定的類型中。
方法分發器
在上面的例子中,我們註冊路徑/login
的GET
和POST
方法處理採用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😄
參考
-
gorilla/handlers GitHub:github.com/gorilla/handlers
-
Go 每日一庫 GitHub:https://github.com/darjun/go-daily-lib
福利
我爲大家整理了一份從入門到進階的 Go 學習資料禮包,包含學習建議:入門看什麼,進階看什麼。關注公衆號 「polarisxu」,回覆 ebook 獲取;還可以回覆「進羣」,和數萬 Gopher 交流學習。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/0gWmwOf2hhA-N3FJWCrQ7A