写点什么

「Go 框架」深入理解 web 框架的中间件运行机制

作者:Go学堂
  • 2023-01-31
    北京
  • 本文字数:8502 字

    阅读完需:约 28 分钟

「Go框架」深入理解web框架的中间件运行机制

大家好,我是渔夫子。本号新推出「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 为空。


根据以上逻辑,对于一个具体的路由来说,其对应的请求处理器不仅仅是自己指定的那个,而是形成如下顺序的一组请求处理器



接下来,我们再看在路由匹配过程中,即匹配到了具体的路由后,这一组请求处理器是如何执行的。


在 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 的原因。如果不加改行代码,则就执行不到后续的请求处理器


完整的执行流程如下:


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。如下:



接下来,我们再看在路由匹配过程中,即匹配到了具体的路由后,这一组请求处理器是如何执行的。


在 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。整体执行流程如下:


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 模式。如下:


四、总结

本文分析了 gin、iris 和 echo 主流框架的中间件的实现原理。其中 gin 和 iris 是通过遍历切片的方式实现的,结构也比较简单。而 echo 是通过 pipeline 模式实现的。相信通过本篇文章,你对中间件的运行原理有了更深的理解。


---特别推荐---


特别推荐:一个专注 go 项目实战、项目中踩坑经验及避坑指南、各种好玩的 go 工具的公众号,「Go 学堂」,专注实用性,非常值得大家关注。关注送《100 个 go 常见的错误》pdf 文档。

发布于: 刚刚阅读数: 3
用户头像

Go学堂

关注

微信公众号:Go学堂 2019-08-06 加入

专注Go编程知识、案例、常见错误及原理分析。意在通过阅读更多优秀的代码,提高编程技能。同名公众号「Go学堂」期待你的关注

评论

发布
暂无评论
「Go框架」深入理解web框架的中间件运行机制_golang_Go学堂_InfoQ写作社区