Golang Web入門(3):如何優雅的設計中間件
- 2020 年 4 月 22 日
- 筆記
- Golang學習筆記
摘要
在上一篇文章中,我們已經可以實現一個性能較高,且支援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全都組合在了一起,綁定到了這個前綴樹上。
到了這裡註冊方面的內容已經結束了,我們來看看他是怎麼處理各個中間件的調用順序。
因為我們的目的是看路由是怎麼處理請求的,所以我們直接看gin
的ServeHTTP
方法:
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:如果有其他的問題,也可以在公眾號找到作者。並且,所有文章第一時間會在公眾號更新,歡迎來找作者玩~