Gin 源码学习(四)丨Gin 对请求的处理流程

7,029 阅读13分钟

在上一篇文章 Gin 源码学习(三)丨路由是如何构建和匹配的? 中,讲解了 Gin 的路由是如何实现的,那么,当路由成功匹配后,或者匹配失败后,在 Gin 内部会对其如何处理呢?

在这一篇文章中,将讲解 Gin 对一个 HTTP 请求的具体处理流程是怎样的。

下面,将对一个请求进入 Gin 的处理范围后的内容,进行一步步展开,讲解 Gin 对请求的处理流程。

Go 版本:1.14

Gin 版本:v1.5.0

目录

  • 请求的处理流程
  • 小结

请求的处理流程

在上一篇文章中,我们讲到 Gin 其实实现了 Go 自带函数库 net/http 库中的 Handler 接口,并且从实现的源代码中可以发现,当一个 HTTP 请求到达 Gin 处理的范围时,首先是在 Gin 的 Engine 类型中的 ServeHTTP(w http.ResponseWriter, req *http.Request) 方法中对 Gin 保存上下文信息的 gin.Context 进行属性设置和重置操作,然后才是使用 engine.handleHTTPRequest(c *Context) 方法来对 HTTP 请求进行处理的,下面,我们一步一步来对相关源代码进行分析:

// ServeHTTP conforms to the http.Handler interface.
// 符合 http.Handler 接口的约定
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	// 从对象池中获取已存在的上下文对象
	c := engine.pool.Get().(*Context)
	// 重置该上下文对象的 ResponseWriter 属性
	c.writermem.reset(w)
	// 设置该上下文对象的 Request 属性
	c.Request = req
	// 重置上下文中的其他属性信息
	c.reset()

	// 对请求进行处理
	engine.handleHTTPRequest(c)

	// 将该上下文对象重新放回对象池中
	engine.pool.Put(c)
}

// Context is the most important part of gin. It allows us to pass variables between middleware,
// manage the flow, validate the JSON of a request and render a JSON response for example.
// 上下文是 Gin 最重要的部分.
// 它允许我们在中间件之间传递变量, 管理流程, 例如验证请求的 JSON 并呈现 JSON 响应.
type Context struct {
	// 对 net/http 库中的 ResponseWriter 进行了封装
	writermem responseWriter
	// 请求对象
	Request   *http.Request
	// 非 net/http 库中的 ResponseWriter
	// 而是 Gin 用来构建 HTTP 响应的一个接口
	Writer    ResponseWriter

	// 存放请求中的 URI 参数
	Params   Params
	// 存放该请求的处理函数切片, 包括中间件加最终处理函数
	handlers HandlersChain
	// 用于标记当前执行的处理函数
	index    int8
	// 请求的完整路径
	fullPath string

	// Gin 引擎对象
	engine *Engine

	// Keys is a key/value pair exclusively for the context of each request.
	// 用于上下文之间的变量传递
	Keys map[string]interface{}

	// Errors is a list of errors attached to all the handlers/middlewares who used this context.
	// 与处理函数/中间件对应的错误列表
	Errors errorMsgs

	// Accepted defines a list of manually accepted formats for content negotiation.
	// 接受格式列表
	Accepted []string

	// queryCache use url.ParseQuery cached the param query result from c.Request.URL.Query()
	// 用于缓存请求的 URL 参数
	queryCache url.Values

	// formCache use url.ParseQuery cached PostForm contains the parsed form data from POST, PATCH,
	// or PUT body parameters.
	// 用于缓存请求体中的参数
	formCache url.Values
}

上面源代码中,展示了 engine.ServeHTTP(w http.ResponseWriter, req *http.Request) 方法的执行过程以及 gin.Context 类型的内部结构,需要注意的是,gin.Context 实现了 Go 的 Context 接口,但是并没有对其做并发安全处理,因此,应该避免多个 goroutine 同时访问同一个 Context,如果存在这种情况,需使用 gin.Context.Copy() 方法,对 gin.Context 进行复制使用。

并且,Gin 使用对象池来存放上下文信息,这是一个非常巧妙的设计思想,因为在 Gin 中,会将请求的许多处理信息存放于 gin.Context 中,而 Go 是一门带有 GC(垃圾回收)的语言,假如在访问量较大的场景下,如果不使用对象池来缓冲 gin.Context 对象的话,那么为每一个请求创建一个 gin.Context 对象,并且在完成请求的处理后,将该 gin.Context 对象交给 GC 去处理,这无疑对 GC 增添了许多压力。由于 gin.Context 只是用于保存当前请求的处理信息,用于上下文之间的参数传递,属于完全可以复用的对象,因此,使用对象池对其进行存放可以在一定程度上减少 GC 压力。

下面先来看一下在 engine.ServeHTTP(w http.ResponseWriter, req *http.Request) 方法中对 gin.Context 初始化时设置了什么样的初始值:

const (
	// 表示未写入
	noWritten     = -1
	// 200 状态码
	defaultStatus = http.StatusOK
)

type responseWriter struct {
	// net/http 库中的 ResponseWriter
	http.ResponseWriter
	// 响应内容大小
	size   int
	// 响应状态码
	status int
}

func (w *responseWriter) reset(writer http.ResponseWriter) {
	w.ResponseWriter = writer
	w.size = noWritten
	w.status = defaultStatus
}

func (c *Context) reset() {
	c.Writer = &c.writermem
	c.Params = c.Params[0:0]
	c.handlers = nil
	c.index = -1
	c.fullPath = ""
	c.Keys = nil
	c.Errors = c.Errors[0:0]
	c.Accepted = nil
	c.queryCache = nil
	c.formCache = nil
}

这里需要留意的是 gin.Context.index 的初始值,Gin 通过该值来调用处理函数和判断当前上下文是否终止。

下面,我们来看 Gin 对请求的处理流程,先来看一下 engine.handleHTTPRequest(c *Context) 方法,该方法在前面的几篇 Gin 源码学习的文章中出现过多次,所以这里也是同样,只保留其与当前文章主题相关的源代码:

func (engine *Engine) handleHTTPRequest(c *Context) {
	// 省略...

	// Find root of the tree for the given HTTP method
	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
		}
		// 省略...
		break
	}

	// 省略...
	c.handlers = engine.allNoRoute
	// 处理 404 错误
	serveError(c, http.StatusNotFound, default404Body)
}

在上一篇文章中讲到过 Gin 请求路由的匹配是在 root.getValue(path string, po Params, unescape bool) 方法中实现的,并且,当返回的 value 对象中的 handlers 属性不为 nil 时,则表示该请求存在处理函数,然后将 value 对象中的处理函数切片集、请求参数以及请求的完整路径信息存放至该请求的上下文对象中,接着调用 context.Next() 方法,下面来看一下该方法的源代码:

// Next should be used only inside middleware.
// It executes the pending handlers in the chain inside the calling handler.
// Next 只能在中间件内部使用
// 它在调用处理程序内的链中执行挂起的处理程序
// 类似于递归调用或函数装饰器
func (c *Context) Next() {
    // index 初始值为 -1
	c.index++
	for c.index < int8(len(c.handlers)) {
		c.handlers[c.index](c)
		c.index++
	}
}

context.Next() 方法的逻辑比较简单,其实就是遍历存放于 Gin 上下文中的中间件/处理函数切片,并调用,在上一篇文章中,我们也讲过,context.handlers 切片中,在有多个 HandlerFunc 的时候,除了最后一个为该路由的处理函数之外,其余的都为中间件。

这里需要注意的点是,该 Next() 方法,在使用的时候,只能在中间件内部使用,也就是说,在日常开发中,该方法只能在自己编写的中间件中出现,而不能出现在其它地方。

下面,我们以 gin.Default() 创建 gin.Engine 时添加的两个默认中间件 Logger 和 Recovery,并结合一个模拟身份验证的中间件 Auth 为例,来对 Gin 中间件的工作流程进行详细讲解,先来看一下 gin.Default() 方法添加的默认中间件 Logger 的相关源代码:

func Default() *Engine {
	debugPrintWARNINGDefault()
	engine := New()
	engine.Use(Logger(), Recovery())
	return engine
}

// Logger instances a Logger middleware that will write the logs to gin.DefaultWriter.
// By default gin.DefaultWriter = os.Stdout.
// Logger 是一个中间件, 该中间件会将日志写入 gin.DefaultWriter.
// 默认的 gin.DefaultWriter 为 os.Stdout, 即标准输出流, 控制台
func Logger() HandlerFunc {
	return LoggerWithConfig(LoggerConfig{})
}

// LoggerConfig defines the config for Logger middleware.
// Logger 中间件的相关配置
type LoggerConfig struct {
	// Optional. Default value is gin.defaultLogFormatter
	// 用于输出内容的格式化, 默认为 gin.defaultLogFormatter
	Formatter LogFormatter

	// Output is a writer where logs are written.
	// Optional. Default value is gin.DefaultWriter.
	// 日志输出对象
	Output io.Writer

	// SkipPaths is a url path array which logs are not written.
	// Optional.
	// 忽略日志输出的 URL 切片
	SkipPaths []string
}

// LoggerWithConfig instance a Logger middleware with config.
// 使用 LoggerConfig 配置的 Logger 中间件
func LoggerWithConfig(conf LoggerConfig) HandlerFunc {
	formatter := conf.Formatter
	if formatter == nil {
		formatter = defaultLogFormatter
	}

	out := conf.Output
	if out == nil {
		out = DefaultWriter
	}

	notlogged := conf.SkipPaths

	// 是否输出至终端
	isTerm := true

	if w, ok := out.(*os.File); !ok || os.Getenv("TERM") == "dumb" ||
		(!isatty.IsTerminal(w.Fd()) && !isatty.IsCygwinTerminal(w.Fd())) {
		isTerm = false
	}

	// 标记忽略日志的 path
	var skip map[string]struct{}

	if length := len(notlogged); length > 0 {
		skip = make(map[string]struct{}, length)

		for _, path := range notlogged {
			skip[path] = struct{}{}
		}
	}

	return func(c *Context) {
		// Start timer
		// 记录开始时间
		start := time.Now()
		path := c.Request.URL.Path
		raw := c.Request.URL.RawQuery

		// Process request
		// 继续执行下一个中间件
		c.Next()

		// Log only when path is not being skipped
		// 如果 path 在 skip 中, 则忽略日志记录
		if _, ok := skip[path]; !ok {
			param := LogFormatterParams{
				Request: c.Request,
				isTerm:  isTerm,
				Keys:    c.Keys,
			}

			// Stop timer
			// 记录结束时间
			param.TimeStamp = time.Now()
			// 计算耗时
			param.Latency = param.TimeStamp.Sub(start)

			// 客户端 IP
			param.ClientIP = c.ClientIP()
			// 请求方法
			param.Method = c.Request.Method
			// 请求状态码
			param.StatusCode = c.Writer.Status()
			// 错误信息
			param.ErrorMessage = c.Errors.ByType(ErrorTypePrivate).String()
			// 响应体大小
			param.BodySize = c.Writer.Size()

			if raw != "" {
				path = path + "?" + raw
			}

			param.Path = path

			// 日志打印
			fmt.Fprint(out, formatter(param))
		}
	}
}

其实中间件也就是装饰器或闭包,实质上就是一种返回类型为 HandlerFunc 的函数,通俗地讲,就是一种返回函数的函数,目的就是为了在外层函数中,对内层函数进行装饰或处理,然后再将被装饰或处理后的内层函数返回。

由于 HandlerFunc 函数只能接受一个 gin.Context 参数,因此,在上面源代码中的 LoggerWithConfig(conf LoggerConfig) 函数中,使用 LoggerConfig 配置,对 HandlerFunc 进行装饰,并返回。

同样地,在返回的 HandlerFunc 匿名函数中,首先是记录进入该中间件时的一些信息,包括时间,然后再调用 context.Next() 方法,挂起当前的处理程序,递归去调用后续的中间件,当后续所有中间件和处理函数执行完毕时,再回到此处,如果要记录该 path 的日志,则再获取一次当前的时间,与开始记录的时间进行计算,即可得出本次请求处理的耗时,再保存其它信息,包括请求 IP 和响应的相关信息等,最后将该请求的日志进行打印处理,这就是使用 gin.Default() 实例一个 gin.Engine 默认添加的 Logger() 中间件的处理流程。

下面,我们来看一下,另一个默认中间件 Recovery() 的相关源代码:

// Recovery returns a middleware that recovers from any panics and writes a 500 if there was one.
// Recovery 中间件用于捕获处理流程中出现 panic 的错误
// 如果连接未断开, 则返回 500 错误响应
func Recovery() HandlerFunc {
	// DefaultErrorWriter = os.Stderr
	return RecoveryWithWriter(DefaultErrorWriter)
}

// RecoveryWithWriter returns a middleware for a given writer that recovers from any panics and writes a 500 if there was one.
// 使用传递的 out 对 Recovery 中间件进行装饰
func RecoveryWithWriter(out io.Writer) HandlerFunc {
	var logger *log.Logger
	if out != nil {
		logger = log.New(out, "\n\n\x1b[31m", log.LstdFlags)
	}
	return func(c *Context) {
		defer func() {
			if err := recover(); err != nil {
				// Check for a broken connection, as it is not really a
				// condition that warrants a panic stack trace.
				// 用于标记连接是否断开
				var brokenPipe bool
				// 从错误信息中判断连接是否断开
				if ne, ok := err.(*net.OpError); ok {
					if se, ok := ne.Err.(*os.SyscallError); ok {
						if strings.Contains(strings.ToLower(se.Error()), "broken pipe") || strings.Contains(strings.ToLower(se.Error()), "connection reset by peer") {
							brokenPipe = true
						}
					}
				}
				// 省略, 日志打印相关...

				// If the connection is dead, we can't write a status to it.
				if brokenPipe {	// 如果连接已断开, 则已经无法为其写入状态码
					// 添加错误信息至上下文中, 用于日志输出
					c.Error(err.(error)) // nolint: errcheck
					// 终止该上下文
					c.Abort()
				} else {	// 连接未断开
					// 终止该上下文并写入 500 错误状态码
					c.AbortWithStatus(http.StatusInternalServerError)
				}
			}
		}()
		// 继续执行下一个中间件
		c.Next()
	}
}

与 LoggerWithConfig(conf LoggerConfig) 函数一样,RecoveryWithWriter(out io.Writer) 函数仅为了对最终返回的中间件 HandlerFunc 函数进行装饰,在该中间件中,可分为两个逻辑块,一个是 defer,一个是 Next(),Next() 与 Logger() 中间件中的 Next() 作用类似,这里在 defer 中使用 recover() 来捕获在后续中间件中 panic 的错误信息,并对该错误信息进行处理。

在该中间件中,首先是判断当前连接是否已中断,然后是进行相关的日志处理,最后,如果连接已中断,则直接设置错误信息,并终止该上下文,否则,终止该上下文并返回 500 错误响应。

下面,我们来看一下 context.Abort() 方法和 context.AbortWithStatus(code int) 方法的相关源代码:

// 63
const abortIndex int8 = math.MaxInt8 / 2

// 终止上下文
func (c *Context) Abort() {
	c.index = abortIndex
}

// 判断上下文是否终止
func (c *Context) IsAborted() bool {
	return c.index >= abortIndex
}

// 终止上下文并将 code 写入响应头中
func (c *Context) AbortWithStatus(code int) {
	c.Status(code)
	c.Writer.WriteHeaderNow()
	c.Abort()
}

context.Abort() 方法将当前上下文的 index 值设置为 63,用于标志上下文的终止。

context.AbortWithStatus(code int) 也是终止当前的上下文,只不过额外的使用了 code 参数,对响应的头信息进行了设置。

最后,我们再来看一个模拟身份校验的中间件 Auth,其实现的相关源代码如下:

type RequestData struct{
	Action string `json:"action"`
	UserID int `json:"user_id"`
}

func main() {
	router := gin.Default()
	// 注册中间件
	router.Use(Auth())

	router.POST("/action", func(c *gin.Context) {
		var RequestData RequestData
		if err := c.BindJSON(&RequestData); err == nil {
			c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "success"})
		}
	})

	router.Run(":8000")
}

func Auth() gin.HandlerFunc {
	// TODO: 可模仿 Logger() 或 Recovery() 中间件, 结合该函数的调用参数, 在此处做一些配置操作
	return func(c *gin.Context) {
		// TODO: 可模仿 Logger() 中间件, 在此处对请求的 path 进行忽略处理
		if auth(c.Request) {
			c.Next()
		} else {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"msg": "Unauthorized"})
		}
	}
}

func auth(req *http.Request) bool {
	// TODO: 对 http.Request 中的信息进行校验, 如 cookie, token...
	return req.Header.Get("Auth") == "colelie"
}

首先是 Auth() 函数,该函数用于装饰并返回 gin.HandlerFunc 函数,在该函数内,返回了一个 gin.HandlerFunc 匿名函数,在该匿名函数中,通过调用 auth(req *http.Request) 函数对请求信息进行校验,这里只是一个简单地对请求头中的 Auth 进行验证。

所以,在该案例中,当我们访问 /action 接口时,首先会进入 Logger() 中间件,然后进入 Recovery() 中间件,再进入 Auth() 中间件,当前面的中间件都没有发生对上下文的终止操作时,才会进入我们声明的 router.POST("/action", func) 处理函数。

当我们向 /action 接口发起一个普通的 POST 请求时,会收到如下响应:

这是由于在 Auth() 中间件中身份校验没通过,我们为该请求的头部信息中添加一个 Key 为 Auth,Value 为 colelie 的字段,会收到如下响应:

可以发现,同样的,出现了错误响应,而这次的错误响应码为 400,这是为什么呢?

在 Gin 源码学习(二)丨请求体中的参数是如何解析的? 中,我们讲过,在使用 MustBind 一类的绑定函数时,如果在参数解析过程中出现错误,会调用 c.AbortWithError(http.StatusBadRequest, err) 方法,终止当前的上下文并返回 400 响应错误码,在上面的声明的对 /action 的处理函数中,使用了 context.BindJSON(obj interface{}) 方法对请求参数进行绑定操作,下面,我们在为请求添加能够绑定成功的请求体,会收到如下响应:

这次,得到了正确的响应内容。

小结

在这篇文章中,我们围绕 gin.Context 的内部结构、Gin 中间件和处理函数的工作流程,讲解了 Gin 对请求的处理流程。

首先,在 gin.Engine 中,使用对象池 sync.Pool 来存放 gin.Context 这样做的目的是为 Go GC 减少压力。

然后,在 Gin 内部,当路由匹配成功后,将调用 context.Next() 方法,开始进入 Gin 中间件和处理函数的执行操作,并且,需要注意的是,在日常开发中,该方法,只能在中间件中被调用。

最后,以使用 gin.Default() 方法创建 gin.Engine 时携带的两个默认中间件 Logger() 和 Recovery(),和我们自己编写的一个模拟身份校验的中间件 Auth(),结合注册的 path 为 /action 的路由,对 Gin 中间件和处理函数的工作流程进行了讲解。

至此,Gin 源码学习的第四篇也就到此结束了,感谢大家对本文的阅读~~