概述

因为维护的一些服务是使用 Gin 框架写的,并且有一些以前工作的网络服务也是使用了和 Gin 类似的思路实现的,所以,迫于对这些服务深度负责的责任感,我尝试深入了解一下 Gin 的底层实现,及时识别出问题和可能出现的风险。

Gin 作为一个流行的 HTTP 框架,使用的人数非常多,但是实际上 Gin 也是足够的简单,以至于没有太多可以讲的。但是,虽然可讲的不多,也是有一些描述和思路值得一提,这篇文章是系列文章的第一篇,主要是介绍一下 Gin 是如何处理 HTTP 请求的。因为 Gin 本质上是扩展原生的 HTTP Server,所以在网络模型上没有太多的介绍之处,所以这里主要关注 Gin 的一些主要数据结构,以及这些数据结构是如何与原生的 HTTP Server 结合起来的。

关键数据结构

图 1:Engine 和 RouterGroup 的关系

可以认为 Gin 的路由模块其实就只有这两个关键数据结构,本质上 Engine 也是一个 RouterGroup,但是关注点有所不同:

如果说再要提一下,那么还有一个核心的数据结构:Context,它的作用就是串联 HTTP 请求的处理逻辑,这个后面会单独拿来介绍。

路由

要深入代码,我们可以先从一个简单的实例出发,探究底层的实现细节,例如以下是一个非常简单的 Gin 服务:

  1. [root@liqiang.io]# cat main.go
  2. func main() {
  3. gin.Default().GET("/", func(c *gin.Context) {
  4. c.JSON(http.StatusOK, map[string]string{})
  5. })
  6. gin.Default().Run(":8080")
  7. }

它涵盖了 Gin 的基本要素了:

第一个我们会好奇的事情就是这个 gin.Default() 是什么?答案可以很明显地从代码中找到:

  1. [root@liqiang.io]# cat gin.go
  2. func Default() *Engine {
  3. ... ...
  4. engine := New()
  5. engine.Use(Logger(), Recovery())
  6. return engine
  7. }

这是一个 Engine 数据结构,New() 里面有一些关键的数据结构,我们等下边走边说,先看下我们关注的功能是如何实现的。

路由注册

路由的注册我们已经知道了是 gin.Default().Get 这样的公开方法使用的,实际上调用的底层是这样的:

  1. [root@liqiang.io]# cat routergroup.go
  2. func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) IRoutes {
  3. return group.handle(http.MethodGet, relativePath, handlers)
  4. }

这里我们可以发现了,数据结构已经从 Engine 转移到了 RouterGroup,这就是最开始的时候我说的职责的分工问题,路由相关的归 RouterGroup 管。那么在 RouterGroup 内部又是如何组织所有的路由信息的呢?这个答案需要继续从 gin 的根目录下的 routergroup.go 中去寻找:

  1. [root@liqiang.io]# cat routergroup.go
  2. func (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes {
  3. absolutePath := group.calculateAbsolutePath(relativePath)
  4. handlers = group.combineHandlers(handlers)
  5. group.engine.addRoute(httpMethod, absolutePath, handlers)
  6. return group.returnObj()
  7. }

可以看到这里 RouterGroup 只做了两个事情:

  1. [root@liqiang.io]# cat gin.go
  2. func (engine *Engine) addRoute(method, path string, handlers HandlersChain) {
  3. root := engine.trees.get(method)
  4. if root == nil {
  5. root = new(node)
  6. root.fullPath = "/"
  7. engine.trees = append(engine.trees, methodTree{method: method, root: root})
  8. }
  9. root.addRoute(path, handlers)
  10. ... ...
  11. }

从这里可以看到一些关键字,例如 engine.trees,同时还可以看到这个 tree 的组织是以 Request Method 为维度的,也就是说对于每种 HTTP 方法都有独立的一个 tree。那接下来比较关键的就是对于 Gin 来说,这个 tree 要如何组织,这里 Gin 使用的是变化版本的前缀树,因为常规的前缀树是完全匹配,但是 Gin 因为路由规则里面可以有通配符和可选符的存在,使得这个树会更复杂,因为篇幅限制,这里就不对这个数据结构的构造进行深入介绍,如果感兴趣的话,可以参考我的另外一篇文章(Go 框架 Gin 解析 2:路由前缀树的构造),在里面我详细地解析了 Gin 如何针对通配符和可选符进行前缀树的构造。

可以认为加入这个 trees 之后,路由信息就被添加到 Gin 里面了,到此,路由的注册就算是完成了。

路由解析

那么在我们知道这是几棵前缀树在工作之后,当一个请求进来,那么我们应该可以猜测到一些 Gin 是如何找到这个请求对应的处理函数是哪些了,这里我从这个函数开始介绍起:

  1. [root@liqiang.io]# cat gin.go
  2. // ServeHTTP conforms to the http.Handler interface.
  3. func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
  4. c := engine.pool.Get().(*Context)
  5. c.writermem.reset(w)
  6. c.Request = req
  7. c.reset()
  8. engine.handleHTTPRequest(c)
  9. engine.pool.Put(c)
  10. }

稍微熟悉一点 Go 原生 HTTP 的同学可能会熟悉,这其实就是 Go 原生 HTTP handler 的原型,实际上 Gin 也是将这个数据结构作为原生 HTTP Library 的入口,所以,如果你要问 Gin 的底层网络模型用的是什么?那么答案就是没有特别的网络模型,就是 Go HTTP Server 原生的网络模型。

在这个 Gin 的 HTTP 处理入口中,核心就一个:构建 Context,这个 Context 在最开始也介绍了,也算是 Gin 的关键数据结构之一,可以看到在这里的实现中,它使用了 pool 的技术,这些细节我都在后续的文章中会详细介绍:Go 框架 Gin 解析 4:Context

言归正传,当 Gin 开始处理请求时,那么寻找 Handler 的原理还是从 trees 入手,代码其实也比较简单:

  1. [root@liqiang.io]# cat gin.go
  2. func (engine *Engine) handleHTTPRequest(c *Context) {
  3. // Find root of the tree for the given HTTP method
  4. t := engine.trees
  5. for i, tl := 0, len(t); i < tl; i++ {
  6. if t[i].method != httpMethod {
  7. continue
  8. }
  9. root := t[i].root
  10. // Find route in tree
  11. value := root.getValue(rPath, c.params, c.skippedNodes, unescape)
  12. if value.params != nil {
  13. c.Params = *value.params
  14. }
  15. if value.handlers != nil {
  16. c.handlers = value.handlers
  17. c.fullPath = value.fullPath
  18. c.Next()
  19. c.writermem.WriteHeaderNow()
  20. return
  21. }

可以看到,一开始还是以 Request Method 为维度找到特定的那棵树,然后在这个树里面匹配请求,找到这个请求处理函数,然后调用这个请求处理函数,这个请求处理函数的处理机制又是一个精彩的故事了,我们在下面一节关于 Middleware 的 Section 里面会介绍到,所以这里可以先这么理解。那么具体在一棵树里面又是怎么找的呢?这其实也是和前缀树的构造息息相关,这里还是先跳过了,对于有兴趣的同学,还是跳转到我的这篇文章(Go 框架 Gin 解析 2:路由前缀树的构造)进行了解。

异常情况处理

除了正常的路由处理之外,Gin 还额外关注了两种情况:

Method Not Allow

如果在正常路由中找不到指定的路由处理函数,那么 Gin 会尝试其他的请求方法是否满足请求的 URL,如果存在,那么就会调用特定的处理函数来处理,这个函数用户可以自定义:

  1. [root@liqiang.io]# cat gin.go
  2. if engine.HandleMethodNotAllowed {
  3. for _, tree := range engine.trees {
  4. if tree.method == httpMethod {
  5. continue
  6. }
  7. if value := tree.root.getValue(rPath, nil, c.skippedNodes, unescape); value.handlers != nil {
  8. c.handlers = engine.allNoMethod
  9. serveError(c, http.StatusMethodNotAllowed, default405Body)
  10. return
  11. }
  12. }
  13. }

如果你想自定义处理函数,其实也很简单:

  1. [root@liqiang.io]# cat main.go
  2. server := gin.New()
  3. server.NoMethod(func(c *gin.Context) {
  4. log4go.Error("Method not found")
  5. })

404 Not Found

如果前面的正常路由以及 URL 可以找到,但是指定的请求方法找不到的情况之外,那么就会进入到 Not Found 的处理逻辑了,极其简单:

  1. [root@liqiang.io]# cat gin.go
  2. c.handlers = engine.allNoRoute
  3. serveError(c, http.StatusNotFound, default404Body)

这个 Not Found 的处理函数也是可以自定义的,自定义也很容易,需要注意的是,和 405 的处理函数一样,这个处理函数也是全局的(不是 Group)级别:

  1. [root@liqiang.io]# cat main.go
  2. server := gin.New()
  3. server.NoRoute(func(c *gin.Context) {
  4. log4go.Error("Route not found")
  5. })

字段说明

在 Engine 中,你可能会发现有两个好像类似的字段:

  1. [root@liqiang.io]# cat engine.go
  2. type Engine struct {
  3. RouterGroup
  4. ... ...
  5. allNoRoute HandlersChain
  6. noRoute HandlersChain

这两个字段的区别就是带不带 all 前缀,那么他们之间的区别是什么?其实也蛮简单的:

总结

在本文中,我介绍了 Gin 的关键数据结构以及 HTTP 请求的接收和处理流程,包括了 Gin 的路由注册和获取的逻辑。实际上这也是 Gin 的大部分内容了,Engine 的大部分字段都与此相关了,此外,还没有提及的字段相关的内容有:路由树的构造和使用模板渲染URL 参数的获取 这几个,这些都将在后面的系列文章中会介绍。

Ref