终于搞懂了Gin中的路由树

他归来后的没几个月,便经历了一个急剧衰老的过程,很快就被归为那类无用的老翁,他们像幽灵般在卧室间步履蹒跚地游荡,高声追怀美好岁月却无人理睬,直到某天清晨死在床上才被人想起。------ 《百年孤独》

前言

路由树Gin 框架中核心概念之一,它采用了树的数据结构,准确来说就是 radix tree(压缩前缀树),那么压缩前缀树到底是什么呢?那就是压缩版的前缀树呗,唉,确实是。

所以,引申下一个问题,前缀树

前缀树

前缀树又叫 trie树,是一种基于字符串的公共前缀字符索引的树状结构:

  1. 除了根节点,每个节点都对应着一个字符
  2. 一个节点的所有子孙都有相同的前缀,也就是这个节点对应的字符串,在这里的表现就是,当前节点,于它的所有父节点结合,即为该节点对应的字符串

示例:

比如有 A,to,tea,ted,ten,i,in,inn 这些字符串,那么它们在前缀树中的表现为:

leetcode 上也有 trie树 对应的题208. 实现Trie(前缀树),有兴趣也可以去看看,加深理解。

压缩前缀树

压缩前缀树也被称为基数树,Radix Trie,这是一种更节省空间的前缀树,之所以被称为压缩,就是当一个节点,有唯一子结点的时候,那么子结点可以和父节点合并成一个节点,这样就压缩了空间,Gin 中路由表实现使用的数据结构正是Radix Trie

示例: 将上述的前缀树变为压缩前缀树后:

了解这些前置知识后,接下来进入源码来了解 Gin 的路由是如何实现的吧

Gin 入口

当你使用Gin编写接口的时候,会使用到对应方法来注册对应路由

Go 复制代码
package main

import (
  "net/http"

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

func main() {
  r := gin.Default()
  r.GET("/ping", func(c *gin.Context) {
    c.JSON(http.StatusOK, gin.H{
      "message": "pong",
    })
  })
  r.Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
}

在这里,其他先不管,我们看到 GET 方法,进入源码内部

Go 复制代码
func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) IRoutes {  
   return group.handle(http.MethodGet, relativePath, handlers)  
}

func (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes {
   // 将相对路径转为绝对路径
   absolutePath := group.calculateAbsolutePath(relativePath)
   // 生成完整的 handlers
   handlers = group.combineHandlers(handlers) 
   // 注册路由树
   group.engine.addRoute(httpMethod, absolutePath, handlers)  
   return group.returnObj()  
}

这样我们可以很清楚的知道,GET 方法其实是调用了 handle 方法,在handle中分别做了以下事情:

  1. 将相对路径转为绝对路径
  2. 生成完整的 handlers
  3. 注册对应路由树
  4. 返回注册生成后的路由 这篇文章关注点在路由上,所以我们现在核心就是第三步,继续进入到 addRouter 方法内部
Go 复制代码
func (engine *Engine) addRoute(method, path string, handlers HandlersChain) {  
   // other code

   // 获取对应方法的根节点
   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)  
  
   // other code
}

这是省略了一部分代码之后的源码,在这里我们可以看到路由树的根节点初始化,最后核心还是调用了另一个addRoute,在初始化root,添加到 engine.trees 中后,用图表示:

Engine 和 Tress

Go 复制代码
type Engine struct {
	// other code
	trees  methodTrees
	// other code
}

type methodTree struct {  
   method string  
   root   *node  
}  

type methodTrees []methodTree

type node struct {  
   // 相对路径  
   path string  
   // 每个 indice 字符对应一个孩子节点的 path 首字母  
   indices   string  
   // other code

   nType     nodeType  
   // 后续节点的数量  
   priority uint32  
   // 子节点列表  
   children []*node // child nodes, at most 1 :param style node at the end of the array   // 处理函数链  
   handlers HandlersChain  
   // path 拼接上前缀后的完整路径  
   fullPath string  
}

了解了这些结构体后,我们继续进入到 addRoute 方法中

addRoute

此处为核心代码,添加了详细的代码注释,仔细阅读即可

Go 复制代码
func (n *node) addRoute(path string, handlers HandlersChain) {  
   fullPath := path  
   // 每一个新路由经过此节点,priority 都要加 1   n.priority++  
   n.priority++
   // 加入到的当前节点是 root,且没有注册过子节点,则直接插入并返回
   if len(n.path) == 0 && len(n.children) == 0 {  
      n.insertChild(path, fullPath, handlers)  
      n.nType = root
      return  
   }  
   parentFullPathIndex := 0


// 为下面代码打上一个标签,在里面可以是这段标签中的代码,重新执行,也就是相当于递归
walk:  
   for {  
	  // 获取节点中的 path 和待插入路由的 path 的最长公共前缀的长度
      i := longestCommonPrefix(path, n.path)
      
      if i < len(n.path) {  
      // 当公共前缀长度比已经存储的节点上的 path 的长度小,那么说明,这个节点需要分解成多个节点
         child := node{  
            path:      n.path[i:],  
            nType:     static,  
            indices:   n.indices,  
            children:  n.children,  
            handlers:  n.handlers,  
            // 新路由刚进入的时候,先将父节点 priority 加 1 了,此时需要扣除  
            priority: n.priority - 1,  
            fullPath: n.fullPath,  
         }  
         n.children = []*node{&child}  
         // 将 indices 设置为子节点的首字母
         n.indices = bytesconv.BytesToString([]byte{n.path[i]})  
         // 重新调整 path 
         n.path = path[:i]  
         // 将 handlers 清空  
         n.handlers = nil  
         // other code  
         // 按字符添加
         n.fullPath = fullPath[:parentFullPathIndex+i]  
      }  



      //  最长公共前缀长度小于 path 的时候,就说明,待注册的路由,需要创建新的子节点
      if i < len(path) {
	     // 剩下的非公共区的字符串   
         path = path[i:]
         c := path[0]  
  
         // '/' after param 
         // 处理特殊字符
         if n.nType == param && c == '/' && len(n.children) == 1 {  
            parentFullPathIndex += len(n.path)  
            n = n.children[0]  
            n.priority++  
            continue walk  
         }  
  
         // 根据 indices 辅助判断,其子节点中是否于当前 path 还存在公共前缀  
         for i, max := 0, len(n.indices); i < max; i++ {  
            // n.indices 存储的是所有子节点的首字母,如果相等,说明他们还有公共部分,就将 node = child 然后在child开始新一轮 walk            
            if c == n.indices[i] {  
               parentFullPathIndex += len(n.path)  
               // 补偿策略
               i = n.incrementChildPrio(i)  
               n = n.children[i]  
               continue walk  
            }  
         }  
         // 自此,公共前缀已经处理完了,不会再有公共前缀了
         // 且不是这些 特殊字符的时候
         if c != ':' && c != '*' && n.nType != catchAll {  
            // 这时候会新增子节点的首字母到 indices 中
            n.indices += bytesconv.BytesToString([]byte{c})  
            // 将这个新子节点插入 children
            child := &node{  
               fullPath: fullPath,  
            }            
            n.addChild(child)  
            n.incrementChildPrio(len(n.indices) - 1)
            // 让新子节点赋值给 node 
            n = child  
         } else if n.wildChild {  
            // 当前节点类型是 通配符,且已经插入了节点,此时继续插入通配符的节点,会有冲突,执行覆盖操作
            n = n.children[len(n.children)-1]  
            n.priority++  
  
            // Check if the wildcard matches  
            if len(path) >= len(n.path) && n.path == path[:len(n.path)] &&  
               // Adding a child to a catchAll is not possible  
               n.nType != catchAll &&  
               // Check for longer wildcard, e.g. :name and :names  
               (len(n.path) >= len(path) || path[len(n.path)] == '/') {  
               continue walk  
            }  
   
           // other code
         }  
         n.insertChild(path, fullPath, handlers)  
         return  
      }  
  
      // Otherwise add handle to current node  
      if n.handlers != nil {  
         panic("handlers are already registered for path '" + fullPath + "'")  
      }      
      n.handlers = handlers  
      n.fullPath = fullPath  
      return  
   }  
}

当 i < len(n.path) 时的图示过程

比如:当注册 /ping 后继续注册了一个路由 /pi

Go 复制代码
  r.GET("/ping", func(c *gin.Context) {})
  
  r.GET("/pii", func(c *gin.Context) {})

  r.GET("/pin", func(c *gin.Context) {})

变化过程:

当 i < len(path) 时的图示过程

Go 复制代码
  r.GET("/pi", func(c *gin.Context) {})

  r.GET("/pin", func(c *gin.Context) {})

  r.GET("/pii", func(c *gin.Context) {})

变化过程:

补偿策略

Gin 在路由还做了一个优化,就是补偿策略,根绝 priority 值的大小,有序排列在 children 中的位置,这样就可以被优先匹配,核心代码请看 incrementChildPrio 方法

Go 复制代码
// Increments priority of the given child and reorders if necessaryfunc (n *node) incrementChildPrio(pos int) int {  
   cs := n.children  
   cs[pos].priority++  
   prio := cs[pos].priority  
  
   // Adjust position (move to front)  
   newPos := pos  
   for ; newPos > 0 && cs[newPos-1].priority < prio; newPos-- {  
      // Swap node positions  
      cs[newPos-1], cs[newPos] = cs[newPos], cs[newPos-1]  
   }  
   // Build new index char string  
   if newPos != pos {  
      n.indices = n.indices[:newPos] + // Unchanged prefix, might be empty  
         n.indices[pos:pos+1] + // The index char we move  
         n.indices[newPos:pos] + n.indices[pos+1:] // Rest without char at 'pos'  
   }  
  
   return newPos  
}

自此,注册路由树的过程就完成了,形成了完整的压缩前缀树。

总结

  1. 路由树采取压缩前缀树的数据结构
  2. 路由中有补偿策略,公共前缀的重复次数越多,在children中越靠前
  3. 每一个请求方法都对应一个不同的路由树
相关推荐
桃园码工9 小时前
6-Gin 路由详解 --[Gin 框架入门精讲与实战案例]
gin·实战案例·入门精讲·路由详解
中草药z13 小时前
【Spring】深入解析 Spring 原理:Bean 的多方面剖析(源码阅读)
java·数据库·spring boot·spring·bean·源码阅读
慕城南风1 天前
Go语言中的defer,panic,recover 与错误处理
golang·go
zyh_0305211 天前
GIN中间件
后端·golang·gin
桃园码工1 天前
5-Gin 静态文件服务 --[Gin 框架入门精讲与实战案例]
gin·实战案例·静态文件服务·入门精讲
桃园码工2 天前
4-Gin HTML 模板渲染 --[Gin 框架入门精讲与实战案例]
前端·html·gin·模板渲染
桃园码工2 天前
1-Gin介绍与环境搭建 --[Gin 框架入门精讲与实战案例]
go·gin·环境搭建
云中谷2 天前
Golang 神器!go-decorator 一行注释搞定装饰器,v0.22版本发布
go·敏捷开发
苏三有春2 天前
五分钟学会如何在GitHub上自动化部署个人博客(hugo框架 + stack主题)
git·go·github
Narutolxy3 天前
深入探讨 Go 中的高级表单验证与翻译:Gin 与 Validator 的实践之道20241223
开发语言·golang·gin