Go 語言 Middleware 模式詳解

如果您想在每個請求之前和之後執行一些代碼,而不管請求的 URL 是什麼,該怎麼辦呢?例如,如果您希望記錄向服務器發出的所有請求,或者允許跨站調用所有 API,或者確保當前用戶在調用安全資源的處理程序之前已經過身份驗證,該怎麼辦?我們可以使用中間件處理程序輕鬆有效地完成所有這些工作。

一箇中間件就是一個 http.handler 對另一個 http.handler 的封裝,實現的功能就是對請求前後做一些處理。稱爲 “中間件” 是因爲它是作用在 web 服務器和實際請求處理函數之間的。

logging 中間件

爲了瞭解它是如何工作的,讓我們構建一個帶有日誌中間件處理程序的簡單 web 服務器。在 $GOPATH/src 目錄中創建一個新目錄,並在該目錄中創建一個名爲 main.go 的文件。將以下代碼添加到該文件中:

package main

import (
    "fmt"
    "log"
    "net/http"
    "os"
    "time"
)

func HelloHandler(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Hello, World!"))
}

func CurrentTimeHandler(w http.ResponseWriter, r *http.Request) {
    curTime := time.Now().Format(time.Kitchen)
    w.Write([]byte(fmt.Sprintf("the current time is %v", curTime)))
}

func main() {
    addr := os.Getenv("ADDR")

    mux := http.NewServeMux()
    mux.HandleFunc("/v1/hello", HelloHandler)
    mux.HandleFunc("/v1/time", CurrentTimeHandler)

    log.Printf("server is listening at %s", addr)
    log.Fatal(http.ListenAndServe(addr, mux))
}

你可以通過設置 ADDR 環境變量並使用 go run main.go 來運行該服務器:

export ADDR=localhost:4000
go run main.go

服務器運行後,可以在瀏覽器中打開 http://localhost:4000/v1/hello 以查看 HelloHandler() 響應,打開 http://localhost:4000/v1/time 以查看 CurrentTimeHandler() 響應。

現在,假設我們希望記錄向該服務器發起的所有請求,列出請求方法、資源路徑以及處理所需的時間。我們可以向每個處理程序函數添加類似的代碼,但如果我們可以在一個地方處理日誌記錄,那就更好了。我們可以使用中間件處理程序來實現這一點。

首先定義一個新結構體實現 http.handler 接口的 ServeHTTP() 方法。結構體需要有一個字段來跟蹤實際 http.handler,實際請求 handler 將在請求的預處理和後處理之間被調用.

//Logger 是一個打印請求日誌的中間件
type Logger struct {
    handler http.Handler
}

//ServeHTTP 通過將原請求handler傳入然後調用,實現對請求的封裝
//處理並打印請求的細節信息
func (l *Logger) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    start := time.Now()
    l.handler.ServeHTTP(w, r)
    log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
}

//NewLogger常見Logger中間件實例
func NewLogger(handlerToWrap http.Handler) *Logger {
    return &Logger{handlerToWrap}
}

NewLogger() 函數以 http.handler 爲參數,封裝後返回一個 Logger 實例。因爲 http.ServeMux 滿足 http.Handler 接口,可以用 Logger 中間件對整個 mux 實例進行封裝。而且 Logger 實現了 ServeHTTP() 函數,所以 Logger 也實現了 http.Handler 接口,因此可以取代原來的 mux 作爲 http.ListenAndServe() 函數傳入。修改 main() 如下:

func main() {
    addr := os.Getenv("ADDR")

    mux := http.NewServeMux()
    mux.HandleFunc("/v1/hello", HelloHandler)
    mux.HandleFunc("/v1/time", CurrentTimeHandler)
    //用Logger中間件封裝mux
    wrappedMux := NewLogger(mux)

    log.Printf("server is listening at %s", addr)
    //使用wrappedMux代替mux作爲根處理程序
    log.Fatal(http.ListenAndServe(addr, wrappedMux))
}

現在重新啓動 web 服務器並重新請求 APIs。因爲我們封裝了整個 mux,所以您應該可以看到打印到終端的所有請求,而不管請求的是哪個資源路徑!

鏈接中間件

因爲每個中間件構造函數都接受並返回一個 http.handler,您可以將多箇中間件處理程序鏈接在一起。例如,假設我們還想爲添加到 mux 的所有處理程序編寫的所有響應添加一個響應頭。我們首先創建另一箇中間件處理程序。

//ResponseHeader是一個向響應中添加報頭的中間件處理程序
type ResponseHeader struct {
    handler http.Handler
    headerName string
    headerValue string
}

//NewResponseHeader構造一個新的ResponseHeader中間件處理程序
func NewResponseHeader(handlerToWrap http.Handler, headerName string, headerValue string) *ResponseHeader {
    return &ResponseHeader{handlerToWrap, headerName, headerValue}
}

//ServeHTTP在請求的返回信息中添加響應頭
func (rh *ResponseHeader) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    //add the header
    w.Header().Add(rh.headerName, rh.headerValue)
    //調用被封裝的請求處理函數
    rh.handler.ServeHTTP(w, r)
}

要同時使用上面實現的中間件和 logger 這兩個中間件處理程序,只需將其中一個封裝到另一個上即可:

func main() {
    //...已有代碼...

    //用日誌中間件和響應頭中間件包裝整個mux
    wrappedMux := NewLogger(NewResponseHeader(mux, "X-My-Header""my header value"))

    log.Printf("server is listening at %s", addr)
    //使用wrappedMux代替mux作爲根處理程序
    log.Fatal(http.ListenAndServe(addr, wrappedMux))
}

您可以通過將每個中間件處理程序封裝到其他中間件處理程序之間,將任意多箇中間件處理程序鏈接在一起。當你只有幾個中間件處理程序時 (這是很典型的),這種方法可以很好地工作,但如果你發現自己添加了很多,你應該嘗試 Mat Ryer 的優秀文章《在 #golang 中編寫中間件以及 Go 如何讓它變得如此有趣》中描述的適配器模式。適配器模式可能很難理解,但它能以一種非常優雅的方式將許多中間件處理程序鏈接在一起。

中間件和請求範圍值

現在讓我們考慮一個稍微複雜一點的例子。假設我們有幾個處理程序,它們都需要一個經過身份驗證的用戶,而且假設已經有了一個函數可以返回驗證的用戶或返回一個錯誤。如下所示:

func GetAuthenticatedUser(r *http.Request) (*User, error) {
    //驗證請求中的會話令牌
    //從會話中獲取會話狀態,
    //並返回經過驗證的用戶
    //或者如果用戶沒有經過身份驗證,則出現錯誤
}

func UsersMeHandler(w http.ResponseWriter, r *http.Request) {
    user, err := GetAuthenticatedUser(r)
    if err != nil {
        http.Error(w, "please sign-in", http.StatusUnauthorized)
        return
    }

    //GET = 響應當前用戶的配置文件
    //PATCH = 更新當前用戶的配置文件
}

UserMeHandler() 函數需要當前認證用戶,因此調用 GetAuthenticatedUser() 並處理返回的錯誤。這個可以正常工作,如果我們增加更多的處理函數也需要當前用戶該怎麼處理呢?我們可以將上面的代碼在每個 handler 都拷貝一份,但重複的代碼不是一個好主意。我們可以創建一箇中間件確保用戶被認證才能調用最終的處理程序。我們可以從定義一個類似於上面的中間件處理程序開始:

type EnsureAuth struct {
    handler http.Handler
}

func (ea *EnsureAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    user, err := GetAuthenticatedUser(r)
    if err != nil {
        http.Error(w, "please sign-in", http.StatusUnauthorized)
        return
    }

    //TODO: 調用真正的處理程序,但我們如何共享用戶?
    ea.handler.ServeHTTP(w, r)
}

func NewEnsureAuth(handlerToWrap http.Handler) *EnsureAuth {
    return &EnsureAuth{handlerToWrap}
}

ServeHTTP() 函數添加了用戶認證的代碼,如果 GetAuthenticatedUser() 返回錯誤,中間件將返回不會調用最終處理函數(handler)。但還有一個問題:如何將 user 共享給最終處理程序(handler)?
因爲這個值和特定請求相關的,不能將其共享給所有其他的請求。因此需要將這個用戶存在請求的 context 上下文中。

請求上下文是在 Go1.7 版本引入的,支持一些高級技術,但我們這裏關心的是存儲特定請求範圍的值。請求上下文爲我們提供了一個存儲和檢索保留在 http.request 中的鍵 / 值對的位置。由於該對象的新實例是在每個請求開始時創建的,所以我們放入的任何內容都是針對當前請求的。

首先定義需要存儲的經過身份驗證的用戶的鍵值類型:

type contextKey int
const authenticatedUserKey contextKey = 0

下面在定義的中間件的 ServeHTTP() 函數中將當前認證的用戶添加到請求 context 中:

func (ea *EnsureAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    user, err := GetAuthenticatedUser(r)
    if err != nil {
        http.Error(w, "please sign-in", http.StatusUnauthorized)
        return
    }

    //創建一個包含經過身份驗證的用戶的新請求上下文
    ctxWithUser := context.WithValue(r.Context(), authenticatedUserKey, user)
    //使用這個新上下文創建一個新請求
    rWithUser := r.WithContext(ctxWithUser)
    //調用真正的處理程序,傳遞新的請求
    ea.handler.ServeHTTP(w, rWithUser)
}

請注意,將用戶放到請求上下文中涉及到基於當前上下文中創建一個新的上下文,將用戶作爲值添加到其中,並使用該新上下文中創建一個新的請求對象。然後將新的請求傳給正真的 handler 處理,因此後面的處理函數就能獲得用戶信息。這確保了當中間件處理程序返回時,鏈中較早的中間件處理程序看不到這些值。
在 handler 函數中檢索值如下所示:

func UsersMeHandler(w http.ResponseWriter, r *http.Request) {
    //從請求上下文中獲取經過身份驗證的用戶
    user := r.Context().Value(authenticatedUserKey).(*User)

    //使用獲取到的用戶做一些事情...
}

這裏我們使用 authenticatedUserKey 常量檢索到 context 中的值,但是. value() 返回的是一個接口,需要使用斷言來拿到實際的類型。

另一種方法

上面用於存儲和檢索請求特定範圍的值的語法看起來有點笨拙,而且它往往會模糊依賴關係,因此一些開發人員反對使用它。他們主張修改需要附加請求作用域值的處理程序的函數簽名。如果這些 handler 處理函數需要這些值,需要明確這些依賴關係,當添加到 web 服務器 mux 時,需要一些中間件適配器。例如,我們的 UsersMeHandler 可以改成如下方式:

func UsersMeHandler(w http.ResponseWriter, r *http.Request, user *User) {
    //使用user做一些事情...
}

添加這個額外的參數意味着該函數不再符合 HTTP.handler 函數簽名,因此也不能將其與 http.ServeMux 使用,我們需要調整它。我們可以使用上面的中間件處理程序的一個修改版本來做到這一點:

//authenticatedHandler 是一個需要用戶參數的函數類型
type AuthenticatedHandler func(http.ResponseWriter, *http.Request, *User)

type EnsureAuth struct {
    handler AuthenticatedHandler
}

func (ea *EnsureAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    user, err := GetAuthenticatedUser(r)
    if err != nil {
        http.Error(w, "please sign-in", http.StatusUnauthorized)
        return
    }

    ea.handler(w, r, user)
}

func NewEnsureAuth(handlerToWrap AuthenticatedHandler) *EnsureAuth {
    return &EnsureAuth{handlerToWrap}
}

在這裏,我們爲 AuthenticatedHandler 定義了一個新類型,這是一個處理函數,它接受一個 * User 類型的附加參數。然後,我們更改 EnsureAuth 中間件,以包裝這些經過身份驗證的處理程序函數之一,而不是 http.Handler。然後,我們的 ServeHTTP() 方法可以簡單地將 user 作爲第三個參數傳遞給經過身份驗證的處理程序函數。
這種方法的一個小缺點是,當我們將 EnsureAuth 添加到 mux 時,它現在是一個適配器,必須將每個經過身份驗證的處理程序函數包裝起來。例如,在 main() 函數中是這樣使用它的:

mux.Handle("/v1/users/", NewEnsureAuth(UsersHandler))
mux.Handle("/v1/users/me", NewEnsureAuth(UsersMeHandler))

與一次包裝整個 mux 不同,我們必須在將每個經過身份驗證的處理函數添加到 mux 時包裝它們。這是因爲. handlefunc() 方法要求函數只有兩個參數,而不是三個。

通過創建自己的 AuthenticatedServeMux 結構,使用接受 AuthenticatedHandler 函數而不是普通 HTTP 處理函數的方法,可以克服這個小小的缺點。然後,您可以創建這個經過身份驗證的 mux 實例,將所有經過身份驗證的處理程序添加到它中,然後將經過身份驗證的 mux 添加到主服務器 mux 中。

轉自:zhuanlan.zhihu.com/p/400110289

Go 開發大全

參與維護一個非常全面的 Go 開源技術資源庫。日常分享 Go, 雲原生、k8s、Docker 和微服務方面的技術文章和行業動態。

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