"Go framework" in-depth understanding of the middleware operating mechanism of the web framework

Hello everyone, I am a fisherman. This issue newly launched the "Go Toolbox" series, which aims to share with you practical and fun tools written in go language. At the same time, understand its underlying implementation principles in order to gain a deeper understanding of the Go language.

When you use the iris framework to build a web system, you will definitely use middleware. So do you understand the operating mechanism of middleware? Do you know why the c.Next() function is added to the request processing functions of the iris and gin frameworks? This article will explore the answer to this question with you.

1. Basic use of middleware

Middleware plays an important role in web development. For example, authentication, authority authentication, logging, etc. The following is the basic use of middleware in each framework.

1.1 Use of iris framework middleware

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 the middleware recover through the use function
 app.Use(recover.New())

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

 app.Listen(":8080")
}
copy

1.2 Using middleware in the gin framework

package main

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

func main() {
 g := gin.New()
    // Using middleware with the Use function
 g.Use(gin.Recovery())
    
 g.GET("/", func(ctx *gin.Context){
  ctx.Writer.Write([]byte("Hello World"))
 })

 g.Run(":8000")
}
copy

1.3 Example of using middleware in the echo framework

package main

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

func main() {
 e := v4echo.New()
    // Use the middleware Recover through the use function
 e.Use(middleware.Recover())
 e.GET("/home", func(c v4echo.Context) error {
  c.Response().Write([]byte("Hello World"))
  return nil
 })

 e.Start(":8080")
}
copy

First, let's look at the common points of using middleware in the three frameworks:

  • Both use the Use function to use middleware
  • Both have built-in Recover middleware
  • All execute the logic of the middleware Recover first, and then output Hello World

Next, we continue to analyze the specific implementation of middleware.

Second, the implementation of middleware

2.1 iris middleware implementation

2.1.1 iris framework middleware type

First, let's look at the signature of the Use function, as follows:

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

In this function, handlers is a parameter of variable length, indicating that it is an array. The parameter type is context.Handler, let's look at the definition of context.Handler as follows:

type Handler func(*Context)
copy

Does this type look familiar? Yes, the request handler defined when registering the route is also of this type. as follows:

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

Summary: Middleware is also a request processor on the iris framework. Using the middleware through the Use function actually adds the middleware to the api.middleware slice. We will study this slice in depth later.

2.1.2 Custom middleware in iris

Knowing the type of middleware, we can define our own middleware according to its rules. as follows:

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

func CustomMiddleware(ctx *context.Context) {
 fmt.Println("this is the custom middleware")
 // specific processing logic
 
 ctx.Next()
}
copy

Of course, in order to unify the code style, you can also define a package like the Recover middleware, and then define a New function. The New function returns a middleware function, as follows:

package CustomMiddleware 

func New() context.Handler {
 return func(ctx *context.Context) {
     fmt.Println("this is the custom middleware")
  // specific processing logic

  ctx.Next()
 }
}
copy

So far, have you found that whether it is a custom middleware or an existing middleware in the iris framework, there is a line of ctx.Next() code at the end. So, why should there be this line of code? Through the function name, you can see the execution of the next request handler. In combination, when we use the Use function to use middleware, we add the middleware processor to a slice. Therefore, Next is related to request processor slicing. This is explained in detail in the operating mechanism section below.

2.2 Implementation of gin middleware

2.2.1 gin framework middleware type

Also first check the signature and implementation of gin's Use function, as follows:

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

In the Use function of the gin framework, middleware is also a parameter of variable length, and its parameter type is HandlerFunc. And the definition of HandlerFunc is as follows:

type HandlerFunc func(*Context)
copy

Similarly, the type of the request handler specified when registering the route in the gin framework is also HandlerFunc, namely func(*Context). Let's look at the implementation of the second line of code engine.RouterGroup.Use(middleware...) in Use:

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

Similarly, the middleware is also added to the Handlers slice of the route.

Summary: In the gin framework, middleware is also a request processing function. Using the middleware through the Use function actually adds the middleware to the group.Handlers slice.

2.2.2 Custom middleware in gin

Knowing the middleware type of gin, we can define our own middleware according to its rules. as follows:

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

func CustomMiddleware(ctx *gin.Context) {
 fmt.Println("this is gin custom middleware")
 // processing logic
 ctx.Next()
}
copy

Of course, in order to unify the code style, you can also return one like the Recover middleware, and then define a New function. The New function returns a middleware function, as follows:

func CustomMiddleware() gin.HandlerFunc {
 return func(ctx *gin.Context) {
  fmt.Println("this is gin custom middleware")
  // processing logic
  ctx.Next()
 }
}
copy

Similarly, in gin's middleware, the last line of code is also the ctx.Next() function. Would it work if I didn't need this line of code? The principle is the same as that of iris, and we will also explain it in the operating mechanism below.

2.3 Implementation of echo framework middleware

2.3.1 echo framework middleware type

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

In the echo framework, the middleware parameter in the Use function is also a variable-length parameter, indicating that multiple middleware can be added. Its type is MiddlewareFunc. The following is the definition of the MiddewareFunc type:

type MiddlewareFunc func(next HandlerFunc) HandlerFunc
copy

The function type of this middleware is different from that of iris and gin. This function type takes a HandlerFunc and returns a HanderFunc. And the definition of HanderFunc is as follows:

HandlerFunc func(c Context) error
copy

The HanderFunc type is the request handler type when specifying a route. Let's look at the implementation of Use in the echo framework, which also adds middleware to a global slice.

Summary: In the echo framework, middleware is a function type that takes an input request handler and returns a new request handler. This is different from the iris and gin frameworks. Using the middleware through the Use function also adds the middleware to the global middleware slice.

2.3.2 Custom middleware in echo

Knowing the middleware type of echo, we can define our own middleware according to its rules. as follows:

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")
  // middleware processing logic
  return next(c)
 }
}
copy

The implementation of middleware here looks complicated, so let's give a simple explanation. According to the above, the middleware type of echo is to input a request handler, and then return a new request handler. In this function, the function from line 6 to line 10 is actually the execution logic of the middleware. next(c) on line 9 actually executes the logic of the next request processor, similar to the ctx.Next() function in iris and gin. ** Essentially wrapping the old request handler (the input next request handler) with a new request handler (returned request handler)**.

The definition and use of middleware are introduced. So, how do middleware and request handlers in specific routes work together? Next we introduce the operating mechanism of the middleware.

3. The operating mechanism of middleware

3.1 The operating mechanism of iris middleware

According to the above introduction, we know that after using the iris.Use function, the middleware is added to the middleware slice of the APIBuilder structure. So, how does the middleware combine with the request processor in the route? Let's start by registering routes.

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

Use the Get function to specify a route. The second parameter of this function is the corresponding request handler, which we call handler. Then, view the source code of Get until the APIBuilder´┐Ż.handle´┐Ż function, in which there is the logic of the route created, as follows:

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

In the input parameters of the api.createRoutes function, we only need to pay attention to the handlers, which are the handlers passed in app.Get. Continue to enter the api.createRoutes function, which is the logic for creating routes. It is implemented as follows:

func (api *APIBuilder) createRoutes(errorCode int, methods []string, relativePath string, handlers ...context.Handler) []*Route {
 //...omit the code

 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)

 //...omit the code
    
 routeHandlers := context.JoinHandlers(beginHandlers, mainHandlers)
 // -> done handlers
 routeHandlers = context.JoinHandlers(routeHandlers, doneHandlers)

    //...omit the code
 routes := make([]*Route, len(methods))
 // Build the handler corresponding to routes
 for i, m := range methods { // single, empty method for error handlers.
  route, err := NewRoute(api, errorCode, m, subdomain, path, routeHandlers, *api.macros)
     // ...omit the code
  routes[i] = route
 }

 return routes
}
copy

Most of the code is omitted here, and only focus on the logic related to middleware and corresponding request handlers. From the implementation point of view, it can be known that:

  • First look at line 12, merge the global beginGlobalHandlers (beginHandlers) with the middleware api.middleware. The api.middleware here is the middleware we added using the Use function at the beginning.
  • Look at lines 18 and 22 again. Line 18 converts the routed request handler into a slice []Handler slice. The handlers here are routes registered using the Get function. Line 22 is to merge beginHandlers and mainHandlers, which can be simply considered to be the merger of api.middlewares and request handlers during route registration. It should be noted here that by merging request processors, the middleware processors are in the front, and the specific routing request processors are in the back.
  • Look at line 24 again, and merge the merged request handler with the global doneHandlers. Here it can be considered that doneHandlers is empty for the time being.

According to the above logic, for a specific route, its corresponding request processor is not only the one specified by itself, but a group of request processors in the following order:

Next, let's look at how this group of request processors are executed during the route matching process, that is, after a specific route is matched.

In iris, the route matching process is performed in the HandleRequest function of the routerHandler structure in the /iris/core/router/handler.go file of the file. as follows:

func (h *routerHandler) HandleRequest(ctx *context.Context) {
 method := ctx.Method()
 path := ctx.Path()
 // omit the code...

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

     // omit the code...

        // Match specific routes based on paths
  n := t.search(path, ctx.Params())
  if n != nil {
   ctx.SetCurrentRoute(n.Route)
            // Here is to find the route and execute the specific request logic
   ctx.Do(n.Handlers)
   // found
   return
  }
  // not found or method not allowed.
  break
 }

 ctx.StatusCode(http.StatusNotFound)
}
copy

After a route is matched, the request processor n.Handlers corresponding to the route will be executed. The Handlers here are the above-mentioned group of request processor arrays containing middleware. Let's look at the implementation of the ctx.Do function again:

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

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

Here we see that in line 7, the first request handler is executed first. Do you have any doubts here: Since handlers are a slice, how does the subsequent request processor execute? This involves having a ctx.Next function in each request handler. Let's look at the implementation of the ctx.Nex function:

func (ctx *Context) Next() {
 // ...omit the code
 nextIndex, n := ctx.currentHandlerIndex+1, len(ctx.handlers)
 if nextIndex < n {
  ctx.currentHandlerIndex = nextIndex
  ctx.handlers[nextIndex](ctx)
 }
}
copy

Here we look at the code from lines 11 to 15. In ctx, there is a subscript currentHandlerIndex of which handler is currently executed. If there are still unfinished handlers, continue to execute the next one, that is, ctx.handlers[nextIndex](ctx). This is why a line of ctx.Next should be added to each request handler. If the code is not changed, the subsequent request processor will not be executed.

The complete execution flow is as follows:

3.2 gin middleware operation mechanism

Since both gin and iris use arrays to store middleware, the middleware operation mechanism is essentially the same as iris. Also when registering a route, the request processor of the middleware and the request processor of the route are combined as the final request processor group of the route. After the route is matched, the first processor of the request processor group is executed first, and then the ctx.Next() function is called for iterative calls.

However, gin's request processor is relatively simple, consisting only of middleware and the request processor specified by the route. We still start with the route registration to specify the request processor, as follows

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

Enter the source code of GET until you enter the handle source code in the /gin/routergroup.go file, as follows:

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()
}
copy

In this function, we can see that group.combineHandlers(handlers) is group.combineHandlers(handlers) at line 3, which can be seen from the name to combine request handlers. We enter to continue to view:

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
}
copy

In line 5, group.Handlers, the middleware, is first added to mergedHandlers, and then route-specific handlers are added to mergedHandlers in line 6, and finally the combined mergedHandlers are used as the final handlers of the route. as follows:

Next, let's look at how this group of request processors are executed during the route matching process, that is, after a specific route is matched.

In gin, the logic of route matching is in the Engine.handleHTTPRequest function of the /gin/gin.go file, as follows:

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

 // ...omit the code
}
copy

The logic for matching routes and executing corresponding route processing is in lines 13 to 18. In line 14, first assign the handlers of the matched route (that is, middleware + specific route processor) to the context c, and then execute the c.Next() function. The c.Next() function is as follows:

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

In the Next function, the subscript c.index is directly used to execute the loop handlers. It should be noted here that c.index starts from -1. So if c.index++ is performed first, the initial value is 0. The overall execution process is as follows:

3.3 The operation mechanism of echo middleware

According to the above introduction, we know that the echo.Use function is used to register middleware, and the registered middleware is placed in the middleware slice of the Echo structure. So, how does the middleware combine with the request processor in the route? Let's start by registering routes.

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

Use the Get function to specify a route. The second parameter of this function is the corresponding request handler, which we call handler. Of course, there is a third optional parameter in this function, which is for the middleware of the route, and its principle is the same as that of the global middleware.

The middleware of the echo framework and the processor of the route are combined when the route is registered, but only after the route is matched. The logic is in Echo's ServeHTTP function, as follows:

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)
}
copy

on line 10 or 18 of the function. Let's look at the implementation of the applyMiddleware(h, e.middleware...) function in line 10:

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

Here h is the request handler specified when registering the route. middelware is all middleware registered using the Use function. Here, h is actually packaged layer by layer in a loop. The index i is executed from the last element of the middleware slice, so that the middleware registered with the Use function is executed first.

The implementation here is not the same as using an array. Let's take the use of Recover middleware as an example to see the specific nesting process.

package main

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

func main() {
 e := v4echo.New()
    // Use the middleware Recover through the use function
 e.Use(middleware.Recover())
 e.GET("/home", func(c v4echo.Context) error {
  c.Response().Write([]byte("Hello World"))
  return nil
 })

 e.Start(":8080")
}
copy

The Recover middleware here is actually the following function:

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

  defer func() {
   // ...omit specific logic code
  }()
  return next(c)
 }
}
copy

Then the request processor corresponding to the route is assumed to be h:

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

Then, when the applyMiddleware function is executed, the Recover function is executed as a result, and the value of the next parameter passed to the Recover function is h (that is, the request handler registered by the route), as follows: Then the new request handler becomes as follows:

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

 defer func() {
  // ...omit specific logic code
 }()
 
    return h(c) // Here h is the request processing of routing registration
}
copy

You see, it's ultimately a request handler type. This is the packaging principle of the echo framework middleware: return a new request handler whose logic is the logic of the middleware + the logic of the input request processing. In fact, this is also a classic pipeline mode. as follows:

Four. Summary

This article analyzes the implementation principles of the middleware of the mainstream frameworks of gin, iris and echo. Among them, gin and iris are implemented by traversing slices, and the structure is relatively simple. The echo is implemented through the pipeline mode. I believe that through this article, you have a deeper understanding of the operating principle of middleware.

---Recommended---

Special recommendation: a public account that focuses on the actual combat of go projects, the experience of stepping on pitfalls in the project and guides for avoiding pitfalls, and various fun go tools, "Go School", which focuses on practicality, is very worthy of everyone's attention. Click on the official account card below to follow directly. Pay attention to send "100 common mistakes in go" pdf document.

Tags: api

Posted by ahmadajcis on Tue, 31 Jan 2023 20:07:24 +1030