Golang Web入門(3):如何優雅的設計中間件

摘要

上一篇文章中,我們已經可以實現一個性能較高,且支援RESTful風格的路由了。但是,在Web應用的開發中,我們還需要一些可以被擴展的功能。

因此,在設計框架的過程中,應該留出可以擴展的空間,比如:日誌記錄、故障恢復等功能,如果我們把這些業務邏輯全都塞進Controller/Handler中,會顯得程式碼特別的冗餘,雜亂。

所以在這篇文章中,我們來探究如何更優雅的設計這些中間件。

1 耦合的實現方式

比如我們要實現一個日誌記錄的功能,我們可以用這種簡單粗暴的方式:

package main

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

func helloWorldHandler(w http.ResponseWriter, r *http.Request) {
	record(r.URL.Path)
	fmt.Fprintf(w, "Hello World !")
}

func main() {
	http.HandleFunc("/hello", helloWorldHandler)
	http.ListenAndServe(":8000", nil)
}

func record(path string)  {
	fmt.Println(time.Now().Format("3:04:05 PM Mon Jan") + " " + path)
}

如果這樣做的話,確實是實現了我們的目標,記錄了訪問的日誌。

但是,這樣一點都不優雅。

每一個Handler內部都需要調用record函數,然後再把需要記錄的path作為參數傳進record函數中。

如果這樣做,不管我們需要添加什麼樣的額外功能,都必須得把這個額外的功能和我們的業務邏輯牢牢地綁定到一起,不能實現擴展功能與業務邏輯間的解耦。

2 將記錄與實現解耦

既然在上面的實現中,記錄日誌和業務實現完全的耦合在了一起,那麼我們能不能把他們的業務實現解耦開來呢?

來看這段程式碼:

func record(w http.ResponseWriter, r *http.Request)  {
	path := r.URL.Path
	method := r.Method
	fmt.Println(time.Now().Format("3:04:05 PM Mon Jan") + " " + method + " " + path)
}

func helloWorldHandler(w http.ResponseWriter, r *http.Request) {
	record(w ,r)
	fmt.Fprintf(w, "Hello World !")
}

在這裡,我們已經把業務實現和日誌記錄的耦合給解開了一部分。

我們只需要在業務程式碼中,調用record(w,r)函數,把請求的內容作為參數傳進record函數中,然後在record這個方法內記錄日誌。這個時候,我們可以在方法內部任意的處理請求,保存如請求路徑、請求方法等數據。而這個過程,對業務實現是透明的

這樣做的話,我們只需要在處理業務邏輯的Handler中調用函數,然後把參數傳進去。而這個函數的具體實現,則是與業務邏輯無關的。

那麼,有沒有辦法可以把業務邏輯和擴展功能完全分開,讓業務程式碼里只有業務程式碼,使程式碼變得更加整潔呢?我們接著往下看。

3 設計中間件

我們在上一篇文章裡面,分析了httprouter這個包的實現。所以我們直接對他動手,修改他的程式碼,使得這個路由具有擴展性。

3.1 效果

在此之前,我們來看看效果:

package main

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

	"github.com/julienschmidt/httprouter"
)

func Hello(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
	fmt.Fprint(w, "Hello World!\n")
}

func record(w http.ResponseWriter, r *http.Request){
	path := r.URL.Path
	method := r.Method
	fmt.Println(time.Now().Format("3:04:05 PM Mon Jan") + " " + method + " " + path)
}


func main() {
	router := httprouter.New()
	router.AddBeforeHandle(record)
	router.GET("/hello", Hello)
	log.Fatal(http.ListenAndServe(":8080", router))
}

這部分的程式碼和上一篇的幾乎完全一樣。也是創建一個路由,將/hello這個路徑和Hello這個處理器綁定在GET的這顆前綴樹中,然後開始監聽8080埠。

這裡比較重要的是main方法裡面的第二行:

router.AddBeforeHandle(record)

從方法名可以看出,這個方法是在Handle之前增加了一個處理過程。

再看看參數,就是我們上面提到的記錄訪問日誌的方法,這個方法記錄了請求的URL,請求的方法,以及時間。

而在我們的Hello(w http.ResponseWriter, r *http.Request, _ httprouter.Params)函數中,已經不包含任何其他的業務邏輯了。

此時,這個Handler專註於處理業務邏輯,至於別的,交給別的函數去實現。這樣,就實現了完全的解耦

下面我們來看看具體的實現過程:

3.2 具體實現

先來看看AddBeforeHandle這個方法:

func (r *Router) AddBeforeHandle(fn func(w http.ResponseWriter, req *http.Request))  {
	r.beforeHandler = fn
}

這個方法很簡單,也就是接收一個處理器類型的參數,然後賦值給Router中的欄位beforeHandler

這個名為beforeHandler欄位也是我們新增在Router中的,相信你也能看得出來了,所謂的AddBeforeHandle方法,就是把我們傳進去的處理函數,保存在Router中,在需要的時候調用他。

那麼我們來看看,什麼時候會調用這個方法。下面列出的這個方法,在上一篇文章有提到,是關於httprouter是如何處理路由的:

func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	...
	if root := r.trees[req.Method]; root != nil {
		if handle, ps, tsr := root.getValue(path, r.getParams); handle != nil {
		    if r.beforeHandler != nil{
				r.beforeHandler(w, req)
			}
			if ps != nil {
				handle(w, req, *ps)
				r.putParams(ps)
			} else {
				handle(w, req, nil)
			}
			return
		} 
	}
    ...
}

注意看,router在找到了Handler,準備執行之前,我們添加了這麼幾行:

if r.beforeHandler != nil{
	r.beforeHandler(w, req)
}

也就是說,如果我們之前調用了AddBeforeHandle方法,給beforeHandler這個欄位賦了值,那麼他就不會為nil,然後調用這個函數。這也就實現了我們的目的,在處理請求之前,先執行我們設置的函數。

3.3 思考

現在我們已經實現了一個完全解耦的中間件。並且,這個中間件是可以任意配置的。你可以拿來做日誌記錄,也可以做許可權校驗等等,而且這些功能還不會對Handler中的業務邏輯造成影響。

如果你是個Java開發者,你可能會覺得這個很像Filter,或者是AOP

但是,和過濾器不同的是,我們不僅可以在請求到來之前處理,也可以在請求完成之後處理。比如這個請求發生了一些panic,你可以在最後處理它,或者你可以記錄這個請求的時間等等,你要做的,只是在Handle方法之後,調用你所註冊的方法。

比如:

func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	...
	if root := r.trees[req.Method]; root != nil {
		if handle, ps, tsr := root.getValue(path, r.getParams); handle != nil {
		    if r.beforeHandler != nil{
				r.beforeHandler(w, req)
			}
			if ps != nil {
				handle(w, req, *ps)
				r.putParams(ps)
			} else {
				handle(w, req, nil)
			}
			if r.afterHandler != nil {
				r.afterHandler(w, req)
			}
			return
		} 
	}
    ...
}

我們只是添加了一個afterHandler方法,就是這麼的簡單。

那麼問題來了:現在這樣的處理操作,我們僅僅只能在請求前和請求後各自添加一個中間件。如果我們想要添加任意多個中間件,該怎麼做呢?

可以先自己思考一下,然後我們來看看在gin中,是怎麼實現的。

4 Gin的中間件

4.1 使用

總所周知,在閱讀源碼之前,一定要先看看他是怎麼用的:

package main

import (
	"fmt"
	"github.com/gin-gonic/gin"
)

func Hello(ctx *gin.Context) {
	fmt.Fprint(ctx.Writer, "Hello World!\n")
}

func main() {
	router := gin.New()
	router.Use(gin.Logger(), gin.Recovery())
	router.GET("/hello", Hello)
	router.Run(":8080")
}

可以看到,在gin中,使用中間件的方法和上文中我們所設計的是差不多的。都是業務和中間件完全解耦,並且在註冊路由的時候,添加進去。

但是我們注意到,在gin中是不分Handle之前還是Handle之後的。那麼他是如何做到的呢,我們來看看源碼。

4.2 源碼解釋

先從Use方法看起:

func (engine *Engine) Use(middleware ...HandlerFunc) IRoutes {
	engine.RouterGroup.Use(middleware...)
	engine.rebuild404Handlers()
	engine.rebuild405Handlers()
	return engine
}

func (group *RouterGroup) Use(middleware ...HandlerFunc) IRoutes {
	group.Handlers = append(group.Handlers, middleware...)
	return group.returnObj()
}

在這裡我們先不管group這個東西,他是路由分組,和我們這篇文章沒有關係,我們先不管他。我們只需要看到append方法。Use方法就是把參數裡面的函數,全部增加到group.Handlers中。這裡的group.Handlers,是一個Handler類型的數組。

所以,在gin中,每一個中間件,也是Handler類型的。

在上一節我們留了一個問題,要怎麼實現多個中間件。答案就在這裡了,用數組保存。

那麼問題又來了:怎麼保證調用的順序呢?

我們繼續往下看看路由的註冊:

func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) IRoutes {
	return group.handle(http.MethodGet, relativePath, handlers)
}

這裡是不是也有點熟悉呢?和上一篇文章提到的httprouter很相似,我們直接看group.handle

func (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes {
	absolutePath := group.calculateAbsolutePath(relativePath)
	handlers = group.combineHandlers(handlers)
	group.engine.addRoute(httpMethod, absolutePath, handlers)
	return group.returnObj()
}

在這段程式碼中,第一行關於path的我們先不管,這個也是和路由分組有關的,簡單來說就是拼接出完整的請求path

先看看第二行,方法名是combineHandlers,我們可以猜測一下這個方法的作用,把各個Handler結合起來。看看詳細的程式碼:

func (group *RouterGroup) combineHandlers(handlers HandlersChain) HandlersChain {
	finalSize := len(group.Handlers) + len(handlers)
	if finalSize >= int(abortIndex) {
		panic("too many handlers")
	}
	mergedHandlers := make(HandlersChain, finalSize)
	copy(mergedHandlers, group.Handlers)
	copy(mergedHandlers[len(group.Handlers):], handlers)
	return mergedHandlers
}

先解釋一下,這裡返回的HandlersChain類型,是Handler的數組。

也就是說,在這個方法裡面,把之前放入group中的中間件,和當前路由的Handler,組合成一個新的數組。

並且,中間件在前面,路由Handler在後面。注意,這個順序很重要

然後我們繼續往下,執行完這個方法之後執行的就是addRoute方法了。在這裡不展開講。所以最重要的是,這裡把中間件和Handler全都組合在了一起,綁定到了這個前綴樹上。

到了這裡註冊方面的內容已經結束了,我們來看看他是怎麼處理各個中間件的調用順序

因為我們的目的是看路由是怎麼處理請求的,所以我們直接看ginServeHTTP方法:

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	c := engine.pool.Get().(*Context)
	c.writermem.reset(w)
	c.Request = req
	c.reset()

	engine.handleHTTPRequest(c)

	engine.pool.Put(c)
}

這裡要注意的是*Context,他是對請求的封裝,包含了有responseWriter*http.Request等。

我們繼續往下看看handleHTTPRequest(c)這個方法:

func (engine *Engine) handleHTTPRequest(c *Context) {
    httpMethod := c.Request.Method
	rPath := c.Request.URL.Path
	...
    t := engine.trees
	for i, tl := 0, len(t); i < tl; i++ {
		if t[i].method != httpMethod {
			continue
		}
		root := t[i].root
		// Find route in tree
		value := root.getValue(rPath, c.Params, unescape)
		if value.handlers != nil {
			c.handlers = value.handlers
			c.Params = value.params
			c.fullPath = value.fullPath
			c.Next()
			c.writermem.WriteHeaderNow()
			return
		}
		...
	}
	...
}

在這個方法中,其實和之前我們研究的httprouter是很相似的。也是先根據請求方法找到相對應的前綴樹,然後獲取相對應的Handler,並把獲取到的handler數組保存在Context中。

這裡我們注意看c.Next()方法,他是gin中關於中間件的調用最精妙的部分。我們來看看:

func (c *Context) Next() {
	c.index++
	for c.index < int8(len(c.handlers)) {
		c.handlers[c.index](c)
		c.index++
	}
}

我們可以看到,當調用這個Next()方法的時候,會增加保存在Context中的下標,然後根據這個下標的順序執行handler

而在前面我們有提到,我們把中間件排在了這個handler數組的前面,先執行中間件,然後最後才是執行用戶自定義的handler

我們再來看看日誌記錄這個中間件:

func LoggerWithConfig(conf LoggerConfig) HandlerFunc {
	...
	return func(c *Context) {
		//開始計時
		start := time.Now()
		path := c.Request.URL.Path
		raw := c.Request.URL.RawQuery

		c.Next()
		...
		// Stop timer
		param.TimeStamp = time.Now()
		param.Latency = param.TimeStamp.Sub(start)
		...
	}
}

可以看到,先開始計時,然後調用了c.Next()這個方法,然後才結束計時。

那麼我們可以由此推斷,c.Next()後面的程式碼,是執行完用戶自定義的Handler才執行的。

也就是說,其實中間件的業務邏輯是這樣的:

func Middleware(c *gin.Context){
    //請求前執行
    c.Next()
    //請求後執行
}

5 寫在最後

首先,謝謝你能看到這裡。

簡單的來講,我們應該考慮解耦合,使得業務程式碼可以專註於業務,中間件專註於實現功能。為了實現這點,我們可以修改路由的實現邏輯,在執行Handler的前後加入中間件的調用。

在本文中,可能會有很多的疏漏。如果在閱讀的過程中,有哪些解釋不到位,或者作者的理解出現了一些差錯,也請你留言指正。

再次感謝~

PS:如果有其他的問題,也可以在公眾號找到作者。並且,所有文章第一時間會在公眾號更新,歡迎來找作者玩~