他归来后的没几个月,便经历了一个急剧衰老的过程,很快就被归为那类无用的老翁,他们像幽灵般在卧室间步履蹒跚地游荡,高声追怀美好岁月却无人理睬,直到某天清晨死在床上才被人想起。------ 《百年孤独》
前言
路由树
是 Gin
框架中核心概念之一,它采用了树的数据结构,准确来说就是 radix tree(压缩前缀树)
,那么压缩前缀树到底是什么呢?那就是压缩版的前缀树
呗,唉,确实是。
所以,引申下一个问题,前缀树
前缀树
前缀树又叫 trie树
,是一种基于字符串的公共前缀字符索引的树状结构:
- 除了根节点,每个节点都对应着一个字符
- 一个节点的所有子孙都有相同的前缀,也就是这个节点对应的字符串,在这里的表现就是,当前节点,于它的所有父节点结合,即为该节点对应的字符串
示例:
比如有 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
中分别做了以下事情:
- 将相对路径转为绝对路径
- 生成完整的 handlers
- 注册对应路由树
- 返回注册生成后的路由 这篇文章关注点在路由上,所以我们现在核心就是第三步,继续进入到
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
}
自此,注册路由树的过程就完成了,形成了完整的压缩前缀树。
总结
- 路由树采取压缩前缀树的数据结构
- 路由中有补偿策略,公共前缀的重复次数越多,在children中越靠前
- 每一个请求方法都对应一个不同的路由树