gin 源码分析之路由实现

1,001 阅读7分钟

这是我参与11月更文挑战的第3天,活动详情查看:2021最后一次更文挑战

pexels-filip-klinovský-3693548

导读

在go语言的框架中,由于net/http包已经实现了连接建立、拆包、封包等几乎70%的基础工作,留下了ServeHTTP这个接口给有各种不同需要的开发人员自行去扩展。这部分扩展中有很大一部分是对路由注册的封装,gin的路由实现并没有完全重新造轮子,而是一部分重要的代码使用的号称速度最快的httprouter,gin自己增加了易于路由管理的路由组的概念。

什么是路由?

路由直观理解就是根据不同的 URL 找到对应的处理函数,也可以成为一个映射的过程。

目前业界比较推荐的 API 接口的设计方式遵循 RESTful 风格。当然现实不总是这样美好,我也见过某些大公司的接口则不区分 GET/POST/DELETE 这些方法,而是完全靠接口的命名来表示不同的方法。

举个简单的例子,如:"创建一篇博客"

RESTful:    POST  /blog/acb
非RESTful: GET     /addBlog?name=acb

这种非RESTful 的方式,并不是错的,在内部使用可能不会有太多问题,只要大家都遵循相同的设计规范就好了。这种接口设计风格在人员较少时可能并没有明显的副作用,但是当团队成员超过几十人,甚至上百人时,在不同服务做对接时,因为缺少统一的规范地接口设计,沟通成本将会成倍增加。这里非常推荐大家去看看谷歌云对外提供的API,堪称设计的典范,非常值得参考和学习。同时Kubernetes的接口设计也是非常经典的,同样出自谷歌。

当URI相同,不同的请求 Method,最终其他代表的要处理的事情也完全不一样。

这里留一个小练习,让你来设计一个路由组件,需要满足不同URI和方法可以,你会如何设计呢?

gin 路由设计

如何设计不同的 Method ?

通过上面的介绍,已经知道 RESTful 是要区分方法的,不同的方法代表意义也完全不一样,gin 是如何实现这个的呢?

其实很简单,不同的方法就是一课路由树,所以当 gin 注册路由的时候,会根据不同的 Method 分别注册不同的路由树。

GET    /users/{uid} HTTP/1.1 查询
POST   /users/{uid} HTTP/1.1 更新
PUT    /users/{uid} HTTP/1.1 全量更新
DELETE /users/{uid} HTTP/1.1 删除

这四个请求最终会构造四棵不同的路由树来表达,具体添加方式如下:

func (engine *Engine) addRoute(method, path string, handlers HandlersChain) {
    // 通过方法查询根
    root := engine.trees.get(method)
    // 未找到,需要构建新根
    if root == nil {
        root = new(node)
        root.fullPath = "/"
        // 添加树根
        engine.trees = append(engine.trees, methodTree{method: method, root: root})
    }
    // 更新处理器
    root.addRoute(path, handlers)
    // ...
}

路由注册过程

func main() {
    r := gin.Default()
    r.GET("/health", func(c *gin.Context) {
        c.JSON(200, gin.H{
            "message": "OK",
        })
    })
    r.Run() // listen and serve on 0.0.0.0:8080
}

这段示例代码中,通过r.GET方式注册了一个健康检查路由到GET 路由树中。在实际工程项目中并不会这样直接注册路由,而是再进一步抽象封装将路由的注册放到一个单独的文件中进行管理,这样的好处是可以统一管理服务下的路由。

使用 RouteGroup

v1 := router.Group("/openapi/v1")
{
    v1.POST("/user/login", func(context *gin.Context) {
        context.String(http.StatusOK, "openapi v1 user login")
    })
}

RouteGroup 是非常重要和实用的功能,可以帮助开发者按照不同的目的对路由进行分组管理。例如,在一个实际的项目服务中,接口一般会分为鉴权接口和非鉴权接口,即需要登录和权限校验或者不需要,这可以通过 RouteGroup 来实现。另外不同版本的接口,也可以使用RouteGroup来区分。

gin 路由的实现细节

func main() {
    r := gin.Default()
    r.GET("/health", func(c *gin.Context) {
        c.JSON(200, gin.H{
            "message": "OK",
        })
    })
    r.Run() // listen and serve on 0.0.0.0:8080
}

从这个例子开始,我们带着下面三个问题出发:

  • URL->health 是怎么存储的?
  • handler-> 处理器又是怎么存储的?
  • health 和对应处理器实际是关联起来的?

1. 请求方法的底层实现

func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) IRoutes {
      // 不同的方法底层都是通过handle函数来实现
      return group.handle(http.MethodGet, relativePath, handlers)
}

可以看到handle函数是整个路由处理的核心所在,我们来看看对应的实现代码。

func (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes {
    // 将相对路径转换绝对路径
    absolutePath := group.calculateAbsolutePath(relativePath)
    // 将处理器进行整合
    handlers = group.combineHandlers(handlers)
    // 调用addRoute函数将该路由信息加入到路由树中
    group.engine.addRoute(httpMethod, absolutePath, handlers)
    return group.returnObj()
}

2. gin的路由树设计

在看gin的路由树设计之前,先来看看如果是我们该怎么设计路由树呢?

GET /blogs
GET /blocks
GET /boo

最简单直接的方式就是全部存储,即每个字符串占都存到树的叶子节点中。但是这种设计会有至少两个非常明显的问题:

  1. 存储空间浪费严重,不同字符串并不是完全不同,其中可能存在大量的相同的子串
  2. 查询效率并不太高,还需要其他一些辅助的措施来保证一定的顺序才能提高查询效率

还有没有更优的解决方案,通过观察 blogs, blocks, boo 是用相同的前缀的,这样就可以采用公共前缀树的方式来存储就会更好。实际上gin 路由树就是一棵前缀树。

img

// 方法树定义
type methodTree struct {
   method string
   root   *node
}

// 树节点定义
type node struct {
	path      string
	indices   string
	children  []*node // 处理器节点 至少1个
	handlers  HandlersChain
	priority  uint32
	nType     nodeType
	maxParams uint8
	wildChild bool
	fullPath  string
}

节点操作核心函数如下:

// addRoute adds a node with the given handle to the path.
// Not concurrency-safe!
func (n *node) addRoute(path string, handlers HandlersChain) {
}

3. URL如何与处理函数关联

从上小节可以看出node 是路由树的核心定义:

  • children成员记录一颗树的所有叶子结点。存储内容为去掉前缀后的路由信息。

  • path 则记录了该节点的最长前缀

  • handlers 存储了当前叶子节点对应的所有处理函数

前文说的路由注册一般发生在服务启动时,在接受请求前会完成所有的服务初始化工作,包括服务路由的注册。当 服务开始接受请求时,路由树已经在内存中构建完毕了,gin框架只需要实现路由的查询就可以了。gin框架开始处理请求的起点是ServeHTTP,因此我们从这里入手。

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    c := engine.pool.Get().(*Context)
    c.writermem.reset(w)
    c.Request = req
    c.reset()
		// 真正开始处理请求
    engine.handleHTTPRequest(c)

    engine.pool.Put(c)
}

handleHTTPRequest正是实现了请求URI到处理函数的映射。

func (engine *Engine) handleHTTPRequest(c *Context) {
    // ...
    t := engine.trees
    for i, tl := 0, len(t); i < tl; i++ {
        // 根据请求方法进行判断
        if t[i].method != httpMethod {
            continue
        }
        root := t[i].root
        // 在该方法树上查找路由
        value := root.getValue(rPath, c.params, unescape)
        if value.params != nil {
            c.Params = *value.params
        }
        // 执行处理函数
        if value.handlers != nil {
            c.handlers = value.handlers
            c.fullPath = value.fullPath
            c.Next() // 涉及到gin的中间件机制
            // 到这里时,请求已经处理完毕,返回的结果也存储在对应的结构体中了
            c.writermem.WriteHeaderNow()
            return
        }
        // 省略
      break
    }
  // 略
}

可以总结一下查找路由的整体思路:

  • 遍历所有路由树,找到对应的方法的路由树
  • 进行路由的匹配
  • 执行对应处理函数

总结

本次我们梳理总结了gin的路由整体流程,但是路由数的具体实现并没有特别仔细的讲解,这块可以留一个扣,后期有机会我们单独再讲。在go的web框架中路由的性能几乎决定了整个框架的性能,因此这是一个非常值得再深入挖掘的方向,大家感兴趣可以自行探索,也欢迎跟我讨论互动~