「Go框架」深入理解web框架的中介軟體執行機制

Go學堂發表於2023-02-02

大家好,我是漁夫子。本號新推出「Go工具箱」系列,意在給大家分享使用go語言編寫的、實用的、好玩的工具。同時瞭解其底層的實現原理,以便更深入地瞭解Go語言。

大家在使用iris框架搭建web系統時,一定會用到中介軟體。那麼你瞭解中介軟體的執行機制嗎?你知道為什麼在iris和gin框架的請求處理函式中要加c.Next()函式嗎?本文就和大家一起探究該問題的答案。

一、中介軟體的基本使用

在web開發中,中介軟體起著很重要的作用。比如,身份驗證、許可權認證、日誌記錄等。以下就是各框架對中介軟體的基本使用。

1.1 iris框架中介軟體的使用

package main

import (
    "github.com/kataras/iris/v12"
    "github.com/kataras/iris/v12/context"

    "github.com/kataras/iris/v12/middleware/recover"
)

func main() {
    app := iris.New()

    //透過use函式使用中介軟體recover
    app.Use(recover.New())

    app.Get("/home",func(ctx *context.Context) {
        ctx.Write([]byte("Hello Wolrd"))
    })

    app.Listen(":8080")
}

1.2 gin框架中使用中介軟體

package main

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

func main() {
    g := gin.New()
    // 透過Use函式使用中介軟體
    g.Use(gin.Recovery())
    
    g.GET("/", func(ctx *gin.Context){
        ctx.Writer.Write([]byte("Hello World"))
    })

    g.Run(":8000")
}

1.3 echo框架中使用中介軟體示例

package main

import (
    v4echo "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

func main() {
    e := v4echo.New()
    // 透過use函式使用中介軟體Recover
    e.Use(middleware.Recover())
    e.GET("/home", func(c v4echo.Context) error {
        c.Response().Write([]byte("Hello World"))
        return nil
    })

    e.Start(":8080")
}

首先我們看下三個框架中使用中介軟體的共同點:

  • 都是使用Use函式來使用中介軟體
  • 都內建了Recover中介軟體
  • 都是先執行中介軟體Recover的邏輯,然後再輸出Hello World

接下來我們繼續分析中介軟體的具體實現。

二、中介軟體的實現

2.1 iris中介軟體實現

2.1.1 iris框架中介軟體型別

首先,我們看下Use函式的簽名,如下:

func (api *APIBuilder) Use(handlers ...context.Handler) {
    api.middleware = append(api.middleware, handlers...)
}

在該函式中,handlers是一個不定長引數,說明是一個陣列。引數型別是context.Handler,我們再來看context.Handler的定義如下:

type Handler func(*Context)

這個型別是不是似曾相識。是的,在註冊路由時定義的請求處理器也是該型別。如下:

func (api *APIBuilder) Get(relativePath string, handlers ...context.Handler) *Route {
    return api.Handle(http.MethodGet, relativePath, handlers...)
}

總結:在iris框架上中介軟體也是一個請求處理器。透過Use函式使用中介軟體,實際上是將該中介軟體統一加入到了api.middleware切片中。該切片我們在後面再深入研究

2.1.2 iris中自定義中介軟體

瞭解了中介軟體的型別,我們就可以根據其規則來定義自己的中介軟體了。如下:

import "github.com/kataras/iris/v12/context"

func CustomMiddleware(ctx *context.Context) {
    fmt.Println("this is the custom middleware")
    // 具體的處理邏輯
    
    ctx.Next()
}

當然,為了程式碼風格統一,也可以類似Recover中介軟體那樣定義個包,然後定義個New函式,New函式返回的是一箇中介軟體函式,如下:

package CustomMiddleware 

func New() context.Handler {
    return func(ctx *context.Context) {
        fmt.Println("this is the custom middleware")
        // 具體的處理邏輯

        ctx.Next()
    }
}

到此為止,你有沒有發現,無論是自定義的中介軟體,還是iris框架中已存在的中介軟體,在最後都有一行ctx.Next()程式碼。那麼,該為什麼要有這行程式碼呢? 透過函式名可以看到執行下一個請求處理器。 再結合我們在使用Use函式使用中介軟體的時候,是把該中介軟體處理器加入到了一個切片中。所以,Next和請求處理器切片是有關係的。這個我們在下文的執行機制部分詳細解釋。

2.2 gin中介軟體的實現

2.2.1 gin框架中介軟體型別

同樣先檢視gin的Use函式的簽名和實現,如下:

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

在gin框架的Use函式中,middleware也是一個不定長的引數,其引數型別是HandlerFunc。而HandlerFunc的定義如下:

type HandlerFunc func(*Context)

同樣,在gin框架中註冊路由時指定的請求處理器的型別也是HandlerFunc,即func(*Context)。我們再看Use中的第2行程式碼engine.RouterGroup.Use(middleware...)的實現:

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

同樣,也是將中介軟體加入到了路由的Handlers切片中。

總結:在gin框架中,中介軟體也是一個請求處理函式。透過Use函式使用中介軟體,實際上也是將該中介軟體統一加入到了group.Handlers切片中。

2.2.2 gin中自定義中介軟體

瞭解了gin的中介軟體型別,我們就可以根據其規則來定義自己的中介軟體了。如下:

import "github.com/gin-gonic/gin"

func CustomMiddleware(ctx *gin.Context) {
    fmt.Println("this is gin custom middleware")
    //    處理邏輯
    ctx.Next()
}

當然,為了程式碼風格統一,也可以類似Recover中介軟體那樣返回一個,然後定義個New函式,New函式返回的是一箇中介軟體函式,如下:

func CustomMiddleware() gin.HandlerFunc {
    return func(ctx *gin.Context) {
        fmt.Println("this is gin custom middleware")
        //    處理邏輯
        ctx.Next()
    }
}

同樣,在gin的中介軟體中,程式碼的最後一行也是ctx.Next()函式。如果不要這行程式碼行不行呢?和iris的道理是一樣的,我們也在下文的執行機制中講解。

2.3 echo框架中介軟體的實現

2.3.1 echo框架中介軟體型別

func (e *Echo) Use(middleware ...MiddlewareFunc) {
    e.middleware = append(e.middleware, middleware...)
}

在echo框架中,Use函式中的middleware引數也是一個不定長引數,說明可以新增多箇中介軟體。其型別是MiddlewareFunc。如下是MiddewareFunc型別的定義:

type MiddlewareFunc func(next HandlerFunc) HandlerFunc

這個中介軟體的函式型別跟iris和gin的不一樣。該函式型別接收一個HandlerFunc,並返回一個HanderFunc。而HanderFunc的定義如下:

HandlerFunc func(c Context) error

HanderFunc型別才是指定路由時的請求處理器型別。我們再看下echo框架中Use的實現,也是將middleware加入到了一個全域性的切片中。

總結:在echo框架中,中介軟體是一個輸入請求處理器,並返回一個新請求處理器的函式型別。這是和iris和gin框架不一樣的地方。透過Use函式使用中介軟體,也是將該中介軟體統一加入到全域性的中介軟體切片中。

2.3.2 echo中自定義中介軟體

瞭解了echo的中介軟體型別,我們就可以根據其規則來定義自己的中介軟體了。如下:

import (
    v4echo "github.com/labstack/echo/v4"
)

func CustomMiddleware(next v4echo.HandlerFunc) v4echo.HandlerFunc {
    return func(c v4echo.Context) error {
        fmt.Println("this is echo custom middleware")
        // 中介軟體處理邏輯
        return next(c)
    }
}

這裡中介軟體的實現看起來比較複雜,做下簡單的解釋。根據上面可知,echo的中介軟體型別是輸入一個請求處理器,然後返回一個新的請求處理器。在該函式中,從第6行到第10行該函式其實是中介軟體的執行邏輯。第9行的next(c)實際上是要執行下一個請求處理器的邏輯,類似於iris和gin中的ctx.Next()函式。 本質上是用一個新的請求處理器(返回的請求處理器)包裝了一下舊的請求處理器(輸入的next請求處理器)

中介軟體的定義和使用都介紹了。那麼,中介軟體和具體路由中的請求處理器是如何協同工作的呢?下面我們介紹中介軟體的執行機制。

三、中介軟體的執行機制

3.1 iris中介軟體的執行機制

根據上文介紹,我們知道使用iris.Use函式之後,是將中介軟體加入到了APIBuilder結構體的middleware切片中。那麼,該middleware是如何和路由中的請求處理器相結合的呢?我們還是從註冊路由開始看。

    app.Get("/home",func(ctx *context.Context) {
        ctx.Write([]byte("Hello Wolrd"))
    })

使用Get函式指定一個路由。該函式的第二個引數就是對應的請求處理器,我們稱之為handler。然後,檢視Get的原始碼,一直到APIBuilder.handle函式,在該函式中有建立的路由的邏輯,如下:

routes := api.createRoutes(errorCode, []string{method}, relativePath, handlers...)

在api.createRoutes函式的入參中,我們只需關注handlers,該handlers即是在app.Get中傳遞的handler。繼續進入api.createRoutes函式中,該函式是建立路由的邏輯。其實現如下:


func (api *APIBuilder) createRoutes(errorCode int, methods []string, relativePath string, handlers ...context.Handler) []*Route {
    //...省略程式碼

    var (
        // global middleware to error handlers as well.
        beginHandlers = api.beginGlobalHandlers
        doneHandlers  = api.doneGlobalHandlers
    )

    if errorCode == 0 {
        beginHandlers = context.JoinHandlers(beginHandlers, api.middleware)
        doneHandlers = context.JoinHandlers(doneHandlers, api.doneHandlers)
    } else {
        beginHandlers = context.JoinHandlers(beginHandlers, api.middlewareErrorCode)
    }

    mainHandlers := context.Handlers(handlers)

    //...省略程式碼
    
    routeHandlers := context.JoinHandlers(beginHandlers, mainHandlers)
    // -> done handlers
    routeHandlers = context.JoinHandlers(routeHandlers, doneHandlers)

    //...省略程式碼
    routes := make([]*Route, len(methods))
    // 構建routes對應的handler
    for i, m := range methods { // single, empty method for error handlers.
        route, err := NewRoute(api, errorCode, m, subdomain, path, routeHandlers, *api.macros)
        // ...省略程式碼
        routes[i] = route
    }

    return routes
}

這裡省略了大部分的程式碼,只關注和中介軟體及對應的請求處理器相關的邏輯。從實現上來看,可以得知:

  • 首先看第12行,將全域性的beginGlobalHandlers(即beginHandlers)和中介軟體api.middleware進行合併。這裡的api.middleware就是我們開頭處使用Use函式加入的中介軟體。
  • 再看第18行和22行,18行是將路由的請求處理器轉換成了切片 []Handler切片。這裡的handlers就是使用Get函式進行註冊的路由。22行是將beginHandlers和mainHandlers進行合併,可以簡單的認為是將api.middlewares和路由註冊時的請求處理器進行了合併。這裡需要注意的是,透過合併請求處理器,中介軟體的處理器排在前面,具體的路由請求處理器排在了後面
  • 再看第24行,將合併後的請求處理器再和全域性的doneHandlers進行合併。這裡可暫且認為doneHandlers為空。

根據以上邏輯,對於一個具體的路由來說,其對應的請求處理器不僅僅是自己指定的那個,而是形成如下順序的一組請求處理器
image.png

接下來,我們再看在路由匹配過程中,即匹配到了具體的路由後,這一組請求處理器是如何執行的。

在iris中,路由匹配的過程是在檔案的/iris/core/router/handler.go檔案中的routerHandler結構體的HandleRequest函式中執行的。如下:

func (h *routerHandler) HandleRequest(ctx *context.Context) {
    method := ctx.Method()
    path := ctx.Path()
    // 省略程式碼...

    for i := range h.trees {
        t := h.trees[i]

        // 省略程式碼...

        // 根據路徑匹配具體的路由
        n := t.search(path, ctx.Params())
        if n != nil {
            ctx.SetCurrentRoute(n.Route)
            // 這裡是找到了路由,並執行具體的請求邏輯
            ctx.Do(n.Handlers)
            // found
            return
        }
        // not found or method not allowed.
        break
    }

    ctx.StatusCode(http.StatusNotFound)
}

在匹配到路由後,會執行該路由對應的請求處理器n.Handlers,這裡的Handlers就是上面提到的那組包含中介軟體的請求處理器陣列。我們再來看ctx.Do函式的實現:

func (ctx *Context) Do(handlers Handlers) {
    if len(handlers) == 0 {
        return
    }

    ctx.handlers = handlers
    handlers[0](ctx)
}

這裡看到在第7行中,首先執行第1個請求處理器。到這裡是不是有疑問:handlers既然是一個切片,那後面的請求處理器是如何執行的呢?這裡就涉及到在每個請求處理器中都有一個ctx.Next函式了。我們再看下ctx.Nex函式的實現:

func (ctx *Context) Next() {
    // ...省略程式碼
    nextIndex, n := ctx.currentHandlerIndex+1, len(ctx.handlers)
    if nextIndex < n {
        ctx.currentHandlerIndex = nextIndex
        ctx.handlers[nextIndex](ctx)
    }
}

這裡我們看第11行到15行的程式碼。在ctx中有一個當前執行到哪個handler的下標currentHandlerIndex,如果還有未執行完的hander,則繼續執行下一個,即ctx.handlers[nextIndex](ctx)這也就是為什麼在每個請求處理器中都應該加一行ctx.Next的原因。如果不加改行程式碼,則就執行不到後續的請求處理器

完整的執行流程如下:
image.png

3.2 gin中介軟體執行機制

由於gin和iris都是使用陣列來儲存中介軟體,所以中介軟體執行的機制本質上是和iris一樣的。也是在註冊路由時,將中介軟體的請求處理器和路由的請求處理器進行合併後作為該路由的最終的請求處理器組。在匹配到路由後,也是透過先執行請求處理器組的第一個處理器,然後呼叫ctx.Next()函式進行迭代呼叫的。

但是,gin的請求處理器比較簡單,只有中介軟體和路由指定的請求處理器組成。我們還是從路由註冊指定請求處理器開始,如下

    g.GET("/", func(ctx *gin.Context){
        ctx.Writer.Write([]byte("Hello World"))
    })

進入GET的原始碼,直到進入到/gin/routergroup.go檔案中的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()
}

在該函式中我們可以看到第3行處是將group.combineHandlers(handlers),由名字可知是對請求處理器進行組合。我們進入繼續檢視:

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

在第5行,是先將group.Handlers即中介軟體加入到mergedHandlers,然後再第6行再將路由具體的handlers加入到mergedHandlers,最後將組合好的mergedHandlers作為該路由最終的handlers。如下:
image.png

接下來,我們再看在路由匹配過程中,即匹配到了具體的路由後,這一組請求處理器是如何執行的。

在gin中,路由匹配的邏輯是在/gin/gin.go檔案的Engine.handleHTTPRequest函式中,如下:

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++ {
        // ...省略程式碼
        root := t[i].root
        // Find route in tree
        value := root.getValue(rPath, c.params, c.skippedNodes, unescape)
        //...省略程式碼
        if value.handlers != nil {
            c.handlers = value.handlers
            c.fullPath = value.fullPath
            c.Next()
            c.writermem.WriteHeaderNow()
            return
        }
        // ...省略程式碼
        break
    }

    // ...省略程式碼
}

匹配路由以及執行對應路由處理的邏輯是在第13行到18行。在第14行,首先將匹配到的路由的handlers(即中介軟體+具體的路由處理器)賦值給上下文c,然後執行c.Next()函式。c.Next()函式如下:

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

Next函式中直接就是使用下標c.index進行迴圈handlers的執行。這裡需要注意的是c.index是從-1開始的。所以先進行c.index++則初始值就是0。整體執行流程如下:
image.png

3.3 echo中介軟體的執行機制

根據上文介紹,我們知道使用echo.Use函式來註冊中介軟體,註冊的中介軟體是放到了Echo結構體的middleware切片中。那麼,該middleware是如何和路由中的請求處理器相結合的呢?我們還是從註冊路由開始看。

    e.GET("/home", func(c v4echo.Context) error {
        c.Response().Write([]byte("Hello World"))
        return nil
    })

使用Get函式指定一個路由。該函式的第二個引數就是對應的請求處理器,我們稱之為handler。當然,在該函式中還有第三個可選的引數是針對該路由的中介軟體的,其原理和全域性的中介軟體是一樣的。

echo框架的中介軟體和路由的處理器結合並是在路由註冊的時候進行的,而是在匹配到路由後才結合的。其邏輯是在Echo的ServeHTTP函式中,如下:

func (e *Echo) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // Acquire context
    c := e.pool.Get().(*context)
    c.Reset(r, w)
    var h HandlerFunc

    if e.premiddleware == nil {
        e.findRouter(r.Host).Find(r.Method, GetPath(r), c)
        h = c.Handler()
        h = applyMiddleware(h, e.middleware...)
    } else {
        h = func(c Context) error {
            e.findRouter(r.Host).Find(r.Method, GetPath(r), c)
            h := c.Handler()
            h = applyMiddleware(h, e.middleware...)
            return h(c)
        }
        h = applyMiddleware(h, e.premiddleware...)
    }

    // Execute chain
    if err := h(c); err != nil {
        e.HTTPErrorHandler(err, c)
    }

    // Release context
    e.pool.Put(c)
}

在該函式的第10行或第18行。我們接著看第10行中的applyMiddleware(h, e.middleware...)函式的實現:

func applyMiddleware(h HandlerFunc, middleware ...MiddlewareFunc) HandlerFunc {
    for i := len(middleware) - 1; i >= 0; i-- {
        h = middleware[i](h)
    }
    return h
}

這裡的h是註冊路由時指定的請求處理器。middelware就是使用Use函式註冊的所有的中介軟體。這裡實際上迴圈對h進行層層包裝。 索引i從middleware切片的最後一個元素開始執行,這樣就實現了先試用Use函式註冊的中介軟體先執行。

這裡的實現跟使用陣列實現不太一樣。我們以使用Recover中介軟體為例看下具體的巢狀過程。

package main

import (
    v4echo "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

func main() {
    e := v4echo.New()
    // 透過use函式使用中介軟體Recover
    e.Use(middleware.Recover())
    e.GET("/home", func(c v4echo.Context) error {
        c.Response().Write([]byte("Hello World"))
        return nil
    })

    e.Start(":8080")
}

這裡的Recover中介軟體實際上是如下函式:

func(next echo.HandlerFunc) echo.HandlerFunc {
    return func(c echo.Context) error {
        if config.Skipper(c) {
            return next(c)
        }

        defer func() {
            // ...省略具體邏輯程式碼
        }()
        return next(c)
    }
}

然後路由對應的請求處理器我們假設是h:

func(c v4echo.Context) error {
    c.Response().Write([]byte("Hello World"))
    return nil
}

那麼,執行applyMiddleware函式,則結果執行了Recover函式,傳給Recover函式的next引數的值是h(即路由註冊的請求處理器),如下:
那麼新的請求處理器就變成了如下:

func(c echo.Context) error {
    if config.Skipper(c) {
        return next(c)
    }

    defer func() {
        // ...省略具體邏輯程式碼
    }()
    
    return h(c) // 這裡的h就是路由註冊的請求處理
}

你看,最終還是個請求處理器的型別。這就是echo框架中介軟體的包裝原理:返回一個新的請求處理器,該處理器的邏輯是 中介軟體的邏輯 + 輸入的請求處理的邏輯。其實這個也是經典的pipeline模式。如下:
image.png

四、總結

本文分析了gin、iris和echo主流框架的中介軟體的實現原理。其中gin和iris是透過遍歷切片的方式實現的,結構也比較簡單。而echo是透過pipeline模式實現的。相信透過本篇文章,你對中介軟體的執行原理有了更深的理解。

---特別推薦---

特別推薦:一個專注go專案實戰、專案中踩坑經驗及避坑指南、各種好玩的go工具的公眾號,「Go學堂」,專注實用性,非常值得大家關注。點選下方公眾號卡片,直接關注。關注送《100個go常見的錯誤》pdf文件。

相關文章