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

ColeLie · · 1976 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

在上一篇文章 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 时添加的两个默认中间件 LoggerRecovery,并结合一个模拟身份验证的中间件 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 源码学习的第四篇也就到此结束了,感谢大家对本文的阅读~~

欢迎扫描以下二维码关注笔者的个人订阅号,获取最新文章推送:


有疑问加站长微信联系(非本文作者)

本文来自:掘金

感谢作者:ColeLie

查看原文:Gin 源码学习(四)丨Gin 对请求的处理流程

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

1976 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传