gin框架之路由前缀树初始化分析

2020 年 8 月 19 日

gin框架之路由前缀树初始化分析

gin 框架作为 Golang 的轻量级 web 框架,包含了路由的 dispatch 功能,本文将重点分析根据设置的请求 path 构建路由前缀树的相关功能。本文分析是基于 gin 1.6.3 版本的代码实现。


1. 路由存储的结构和算法


Engine 是 gin 框架的实例,在 Engine 结构中,trees 是一个数组,针对框架支持的每一种方法,都会创建一个节点。例如 GET、POST 是 trees 的两个元素。


//框架实例包含一个方法数数组type Engine struct {        trees            methodTrees}type methodTrees []methodTree//方法树的定义type methodTree struct {        method string        root   *node}
复制代码


1.1 前缀树节点的定义


//path树的节点结构type node struct {        path      string        indices   string        children  []*node        handlers  HandlersChain        priority  uint32        nType     nodeType        maxParams uint8        wildChild bool        fullPath  string}
复制代码


方法树是通过节点包含 children 的节点数组的结构形成的,在 node 结构中:


path:表示当前节点的 path;


indices:通常情况下维护了 children 列表的 path 的各首字符组成的 string,之所以是通常情况,是在处理包含通配符的 path 处理中会有一些例外情况;


priority:代表了有几条路由会经过此节点,用于在节点进行排序时使用;


nType:是节点的类型,默认是 static 类型,还包括了 root 类型,对于 path 包含冒号通配符的情况,nType 是 param 类型,对于包含 * 通配符的情况,nType 类型是 catchAll 类型;


wildChild:默认是 false,当 children 是 通配符类型时,wildChild 为 true;


fullPath:是从 root 节点到当前节点的全部 path 部分;如果此节点为终结节点 handlers 为对应的处理链,否则为 nil;maxParams 是当前节点到各个叶子节点的包含的通配符的最大数量。


1.2 一个普通的路由前缀树


以如下路由配置作为示例:


engine.GET("/admin/model/get", api.GetTemplate)engine.GET("/admin/model/query", api.QueryTemplate)engine.POST("/admin/model/set", api.SetTemplate)engine.POST("/admin/model/upload", api.UploadTemplate)engine.POST("/admin/model/uploadv2", api.GetTemplateWithSignature)engine.POST("/admin/model/update", api.UpdateTemplate)
复制代码


形成的前缀树如下图,左边是 GET 方法的树,右边是 POST 方法树。



2. 路由的初始化过程


路由的初始化过程,首先生成绝对 path,然后调用 Engine 的 addRoute 方法,根据请求的 method 类型,找到根节点。再调用 node 的 addRoute 方法,将 path 增加到树的合适节点。这个过程是路由初始化过程中构建前缀树的核心流程。


2.1 Engine 的 addRoute 方法


func (engine *Engine) addRoute(method, path string, handlers HandlersChain) {   //根据调用的method获取对应的前缀树,如果为nil进行初始化        root := engine.trees.get(method)        if root == nil {               root = new(node)               root.fullPath = "/"               engine.trees = append(engine.trees, methodTree{method: method, root: root})        }    //调用node的addRoute方法,其中的path是绝对的path        root.addRoute(path, handlers)}
复制代码


2.2 查找 wildCard


wildCard 是指包含通配符的 path 段,例如 path 是 /:id/info 那么 id 代表了通配符的名字;这个方法用于查找 path 中是否包含 wildCard;通配支持了 path 上传参数,但是也增加了 path 设计的复杂性;如果没有通配符的设计,程序员需要定义每一个 path。


func findWildcard(path string) (wildcard string, i int, valid bool) {
for start, c := range []byte(path) { //如果没有遇到通配符就继续向后查找 if c != ':' && c != '*' { continue }
//找到通配符设置valid为true,那么通配符在path的起始位置就是start valid = true //从通配符后面继续查找 for end, c := range []byte(path[start+1:]) { switch c { //如果遇到下划线,返回wildCard(不包括下划线)、start、true case '/': return path[start : start+1+end], start, valid
//如果遇到通配符,valid设置为false case ':', '*': valid = false } } //在这个位置返回,遍历完了path,valid为true和false的可能性都有 return path[start:], start, valid } //在path里没有找到通配符 return "", -1, false}
复制代码


2.3 增加孩子节点


node 的 addRoute 方法分为两步,第一步是根据 path 和已有的前缀树的匹配,找到剩余的 path 需要插入的孩子节点的位置,第二步是插入到该节点。当一颗树是空树时,增加第一条 path 的过程便退化为只有第二步。本小节先分析插入孩子节点的操作。


numParams 是 path 包含的通配符的数量,包含冒号和星号;如果 path 不包含通配符,直接设置当前节点的 path 为传入的 path;按照通配符的数量逐个处理。


对于通配符为冒号的处理方法,当前节点设置一个 path 为 wildCard 的子节点,设置 handler 后结束;特别的如果 wildCard 不是 path 的全部,将 path 更新为剩余的部分,n 节点指向 wildCard 的孩子节点继续循环。循环如果后半部分没有通配符到结尾设置 path,否则继续处理通配符节点。


对于通配符是星号的处理方法,校验必须是最后一个通配符,星号的前面符号是下划线,生成一个 path 为空的孩子节点和包含“/*name”的孙子节点。其中孩子节点 wildChild 设置为 true,表明孩子节点是通配符类型。


在冒号通配符的处理过程中,当最后的通配符处理完成,但是还有剩余 path 的时候,n 会更新为空 path 的 child 节点,循环处理剩下的 path,保证了在调用的节点 path 为空。



func (n *node) insertChild(numParams uint8, path string, fullPath string, handlers HandlersChain) { for numParams > 0 { // Find prefix until first wildcard wildcard, i, valid := findWildcard(path) //path中不包含通配符,直接结束对numParams条件的for循环 if i < 0 { // No wildcard found break }
// valid为false的两种情况是没有找到通配符(之前已经break) 或者 一个path段有多个通配符 if !valid { panic("only one wildcard per path segment is allowed, has: '" + wildcard + "' in path '" + fullPath + "'") }
// 如果path段只有通配符没有名字 也会panic;由于wildCard一定是以通配符开头的,通配符后面不能直接加下划线 if len(wildcard) < 2 { panic("wildcards must be named with a non-empty name in path '" + fullPath + "'") }
// Check if this node has existing children which would be // unreachable if we insert the wildcard here // 如果要在当前节点增加一个通配符的孩子节点,当前节点不能有孩子节点,这个时候会导致路由冲突 if len(n.children) > 0 { panic("wildcard segment '" + wildcard + "' conflicts with existing children in path '" + fullPath + "'") } //冒号类型的通配符处理 if wildcard[0] == ':' { // param if i > 0 { // Insert prefix before the current wildcard n.path = path[:i] //设置当前节点的path path = path[i:] //更新path } //孩子节点是通配符,当前节点设置为true n.wildChild = true child := &node{ nType: param, //冒号类型的通配符类型 path: wildcard, //设置path为wildCard path包含通配符和名字 maxParams: numParams, fullPath: fullPath, } n.children = []*node{child} //children挂接到当前节点 n = child //n更新为下沉到孩子节点 n.priority++ numParams-- //控制循环的通配符数量减1
// if the path doesn't end with the wildcard, then there // will be another non-wildcard subpath starting with '/' //如果wildCard的长度小于path,则说明path中还包含以及path if len(wildcard) < len(path) { path = path[len(wildcard):] //重新更新path
child := &node{ //new一个child节点 maxParams: numParams, priority: 1, fullPath: fullPath, } n.children = []*node{child} n = child //更新n节点为child节点 continue }
// Otherwise we're done. Insert the handle in the new leaf n.handlers = handlers return }
//星号通配符类型的处理 //星号通配符必须是path的最后一个通配符 否则会panic if i+len(wildcard) != len(path) || numParams > 1 { panic("catch-all routes are only allowed at the end of the path in path '" + fullPath + "'") }
if len(n.path) > 0 && n.path[len(n.path)-1] == '/' { panic("catch-all conflicts with existing handle for the path segment root in path '" + fullPath + "'") }
//星号通配符的前一个字符,必须为下划线,否则panic i-- if path[i] != '/' { panic("no / before catch-all in path '" + fullPath + "'") }
//当前node的path为星号通配符之前的path n.path = path[:i]
// First node: catchAll node with empty path child := &node{ //一个path为空的节点 wildChild: true, //空节点的wildCard为true nType: catchAll, maxParams: 1, fullPath: fullPath, } // update maxParams of the parent node if n.maxParams < 1 { n.maxParams = 1 } n.children = []*node{child} //空节点挂接到当前node节点 n.indices = string('/') //node节点 indices设置为 下划线 n = child //node节点下沉为path为空的节点 n.priority++
// second node: node holding the variable child = &node{ path: path[i:], //path 为从 下划线开始的包含星号通配符的path nType: catchAll, maxParams: 1, handlers: handlers, priority: 1, fullPath: fullPath, } n.children = []*node{child} //将 包含星号通配符的path节点挂接到空节点下
return }
// 剩下的path不再包含冒号或者星号 n.path = path n.handlers = handlers n.fullPath = fullPath}
复制代码


2.4 查找插入节点


当根节点不为空节点时候,需要查找前缀树,找到一个 path 为空的节点,插入剩余没有与已有树匹配完成的 path。countParams 计算 path 通配符的数量,即冒号和星号的数量;longestCommonPrefix 计算 path 和当前节点的 path 的共同前缀长度。incrementChildPrio 用于对一个节点的孩子节点进行排序。


func (n *node) addRoute(path string, handlers HandlersChain) {        fullPath := path        n.priority++    //计算当前path的通配符的数量        numParams := countParams(path)
// 如果单前树是空树,直接在当前node插入path if len(n.path) == 0 && len(n.children) == 0 { n.insertChild(numParams, path, fullPath, handlers) n.nType = root return } //path的共同前缀位置 parentFullPathIndex := 0
walk: for { // Update maxParams of the current node if numParams > n.maxParams { n.maxParams = numParams }
// Find the longest common prefix. // This also implies that the common prefix contains no ':' or '*' // since the existing key can't contain those chars. i := longestCommonPrefix(path, n.path)
// 如果path与当前的node有部分匹配,需要拆分当前的node if i < len(n.path) { child := node{ path: n.path[i:], //新的节点包括 node path 没有匹配上的后半部分 wildChild: n.wildChild, //设置与当前节点相同 indices: n.indices, children: n.children, handlers: n.handlers, priority: n.priority - 1, fullPath: n.fullPath, }
// Update maxParams (max of all children) for _, v := range child.children { if v.maxParams > child.maxParams { child.maxParams = v.maxParams } }
n.children = []*node{&child} //将后半部分设置为孩子节点 // []byte for proper unicode char conversion, see #65 n.indices = string([]byte{n.path[i]}) n.path = path[:i] //当前节点的path只保持前半部分 n.handlers = nil n.wildChild = false //后半部分节点一定不包含通配符 n.fullPath = fullPath[:parentFullPathIndex+i] //当前节点的fullPath截取 }
// Make new node a child of this node //path没有完成匹配,需要继续向下寻找 if i < len(path) { path = path[i:] //path 更新为没有匹配上的后半部分
//如果当前节点是wildChild节点,那么子节点是通配符节点 if n.wildChild { parentFullPathIndex += len(n.path) n = n.children[0] n.priority++
// Update maxParams of the child node if numParams > n.maxParams { n.maxParams = numParams } numParams--
// path为通配符的时候必须一致,然后继续向后 if len(path) >= len(n.path) && n.path == path[:len(n.path)] { // check for longer wildcard, e.g. :name and :names //path与当前node的path长度相同 或者path有下划线,继续 if len(n.path) >= len(path) || path[len(n.path)] == '/' { continue walk } }
//其他情况会panic pathSeg := path if n.nType != catchAll { pathSeg = strings.SplitN(path, "/", 2)[0] } prefix := fullPath[:strings.Index(fullPath, pathSeg)] + n.path panic("'" + pathSeg + "' in new path '" + fullPath + "' conflicts with existing wildcard '" + n.path + "' in existing prefix '" + prefix + "'") }
//当前节点的孩子节点不是通配符类型,取出第一个字符 c := path[0]
// slash after param //冒号通配符后面的 下划线处理 if n.nType == param && c == '/' && len(n.children) == 1 { parentFullPathIndex += len(n.path) n = n.children[0] //更新node节点为孩子节点,继续查找 n.priority++ continue walk }
// Check if a child with the next path byte exists //当前节点的某个孩子与path有相同的前缀 for i, max := 0, len(n.indices); i < max; i++ { if c == n.indices[i] { parentFullPathIndex += len(n.path) i = n.incrementChildPrio(i) n = n.children[i] //更新当前节点为对应的孩子节点,继续查找 continue walk } }
// Otherwise insert it //如果是其他情况,新增一个child节点,并且基于这个child节点,插入剩下的path if c != ':' && c != '*' { // []byte for proper unicode char conversion, see #65 n.indices += string([]byte{c}) child := &node{ maxParams: numParams, fullPath: fullPath, } n.children = append(n.children, child) n.incrementChildPrio(len(n.indices) - 1) n = child } n.insertChild(numParams, path, fullPath, handlers) return }
// Otherwise and handle to current node if n.handlers != nil { panic("handlers are already registered for path '" + fullPath + "'") } n.handlers = handlers return }}
复制代码


对上面的查找节点总结,流程如下:



3. 路由初始化的 case 分析


本小节针对几种比较特殊的情况,做具体分析。前两种情况针对了初始路由信息为空的情况,可以了解到 insertChild 的过程,第三个 case 主要用于分析寻找路由的过程。


case1:单条包含两个冒号通配 path 的一颗前缀树


路由信息: “/user/:name/:age” name 和 age 是通配符的名字;这条路径形成的前缀树如下。



形成的过程是这样的:


  1. 查找到当前path /user/:name/:age的wildCard为【:name】,开始的位置是第7个字符

  2. 判断wildCard是冒号通配符类型且起始位置大于0,那么path之前的部分【/user/】设置为当前节点(上面树中的第一层节点)的path,然后更新要处理的path为后半部分,即【:name/:age】

  3. 设置当前node wildChild为true。

  4. 新建孩子节点,path为wilCard【:name】,并且挂接到第一层节点,并将当前节点指向第二层的节点。

  5. 判断path是否处理结束,如果处理结束,则可以退出

  6. 如果path还没有处理结束,更新path为除去wildCard剩余部分【/:age】,新建孩子节点,挂接到当前的节点(当前还指向第二层)并且设置当前节点为第三层结点

  7. 更新后的path为【/:age】,当前的节点为第三层节点,继续从第一步开始处理。再次循环一遍,两个冒号通配符处理完成。


case2:单条包含星号通配 path 的一颗前缀树


路由信息:"/user/:name/*age" ,其中包含一个冒号通配符和一个星号通配符;这条路径形成的前缀树如下:



对于这个路由,形成的过程如下:


  1. 查找当前path的wildCard,当前node指向第一层的node,wildCard的查找结果为【:name】位置在第七个

  2. 按照上面的处理步骤,处理完wildCard,path更新为【/*age】,当前node指向第三层,进行下一次循环处理

  3. 再次查找wildCard结果为【*age】位置是第二个符号,进入insertChild插入星号通配符的逻辑

  4. 查看通配符之前的是否是下划线,不是的话报错,将当前的节点path设置为下划线之前的path,这个时候当前节点path为空

  5. 生成子节点,即图中的第四层节点,设置节点类型为catchAll,挂接到当前节点,设置单前节点的indices为【/】,当前节点指向第四层节点

  6. 新建第五层节点,节点path设置为剩下的path(此处,星号通配符之后不再处理),并挂接到当前当前节点

  7. 处理结束


case3:多种 path 的寻找过程


路由信息:第一条【"/user/info"】,第二条【"/user/info/:name"】,第三条【"/user/info/:name/*age"】



对于上述的路由信息,前缀树的形成过程为:


第一条路由信息,root树为空树并且没有通配符,会在insertChild方法中直接设置当前节点(第一层节点)为path,即在第一层节点设置path为【"/user/info"】

对于第二条路由,先找到与当前节点的最长匹配长度,长度小于单前path长度,重置path为【/:name】且当前节点的wilChild为false,取出剩余path第一个字符进行判断

根据第一个字符逻辑 在当前节点新增孩子(图中第二级孩子),并将当前node指向第二层节点,调用insertChild将【/:name】插入到第二层节点,逻辑同case1,这样形成了第二层和第三层节点。

对于第三条路由,先找到与第一层节点的共同前缀,path更新为【/:name/*age】,取出剩余path第一个字符【/】,【/】在当前的indices里,对孩子节点做优先级调整,并且将当前节点更新下沉到孩子节点,也就是图中第二层节点,继续下一次循环查找。

path【/:name/*age】与第二层节点的共同前缀为【/】,当前第二层节点wildCard为true,这个时候将当前节点指定到孩子节点,即图中第三层节点,并且判断path在下划线之前的部分,即【:name】与三层节点的path相同,然后继续循环查找。

path剩余部分为【:name/*age】,当前node指向第三层节点,继续查找插入位置;共同前缀为【:name】, 更新path变为【/*age】,由于在第二部分处理完以后只有第三层节点。这个时候直接新增节点,进入insertChild逻辑。与case2的后半部分按照相同的逻辑,形成第四五六层节点


4. 使用问题


4.1 处于同层 path 的路由必须具有相同的通配符以及名字


【/user/:name/:age】和 【/user/*age】的第二段 path,第一个是【:name】,第二个是【*age】会报错,这种情况会导致路由匹配失效;在做路由匹配的时候,当发现子节点是 wildCard 类型时候,会将子节点的 path 与当前 path 相同长度的前缀 path 进行比较,相同会继续比较,不同会报错。


4.2 星号通配符必须是最后一个通配符


在 insertChild 时候,进入星号通配符逻辑后,会进行检查


    if i+len(wildcard) != len(path) || numParams > 1 {            panic("catch-all routes are only allowed at the end of the path in path '" + fullPath + "'")        }
复制代码


4.3 星号通配符的一个 bug


对于路由【"/user/info/*name"】 和路由【"/user/info/*name/age"】,在查找的时候只会匹配成功第一个路由,但是第二个路由会新增成功。


本文转载自公众号(ID:)。


原文链接


gin框架之路由前缀树初始化分析


2020 年 8 月 19 日 10:05676

评论

发布
暂无评论
发现更多内容

你的团队属于部落的哪个阶段?

Yanel 说敏捷产品

敏捷 敏捷开发 敏捷精髓

危机过后,「表格文档协同」需要具备什么能力?

Geek_Willie

前端开发 开发者工具 Excel

如何高效阅读

ElvinYang

Python程序性能分析和火焰图

ElvinYang

Python网络编程socket 简易聊天窗

Flychen

带你吃透原型设计

Yanel 说敏捷产品

产品 产品经理 产品设计 产品开发 产品推荐

工具集系列|值得收藏的几个免费在线学习国外网站

一尘观世界

学习 工具 网站 提升

工具集系列 02|还在为海报设计、LOGO 设计发愁?这些在线工具值得收藏

一尘观世界

效率工具 设计 海报 课程封面 知识付费

目光聚集之处,金钱必将追随

Tom

学习 个人成长 思考 读书

【解析+示例】2种方法,通过SpreadJS在前端实现甘特图

Geek_Willie

前端开发 甘特图 SpreadJS 表格控件

接口限流算法有哪些,看完这篇又能和面试官互扯了~

不才陈某

Java 分布式 后端

DDD 实践手册(6. Bounded Context - 限界上下文)

Joshua

企业架构 设计模式 领域驱动设计 DDD 架构模式

每个人都应该知道的性能参数

ElvinYang

对话 CTO | 喜茶也有 CTO?听陈霈霖讲讲茶饮中的技术甜度

ONES 王颖奇

研发管理 CTO 零售

C语言运算符

C语言技术网-码农有道

C语言 运算符

从技术层面理解对于区块链技术的10.24集体学习讲话

MaxHu

区块链 智能合约 以太坊 加密货币 去中心化网络

当前的经济形势,如何让自己免于风险?

鼎玉谷

追光逐影:读《我们这一代》

北风

ShedLock:一个轻量级的定时任务协调组件

kk

定时任务 shedlock

“随大流”的你是不会成功的

小天同学

个人成长 思考 写作平台 感悟 坚持

JavaScript 学习笔记——数据类型

zjlulsum

Java 学习 前端 类型推断 入门

如何让团队产生“多米诺骨牌”效应?

Yanel 说敏捷产品

项目管理 敏捷 敏捷开发 敏捷精髓

你真的懂"看板文化"么?

Yanel 说敏捷产品

敏捷 敏捷开发 敏捷精髓

NIO 看破也说破(三)—— 不同的IO模型

小眼睛聊技术

Java 学习 深度思考 程序员 架构

对话 CTO | 听快看漫画 CTO 李润超讲重塑漫画产业的技术推动力

ONES 王颖奇

研发管理 CTO 动画 文化

C语言常量、变量和关键字

C语言技术网-码农有道

C语言 常量 变量 关键字

Try-Catch包裹的代码异常后,竟然导致了产线事务回滚!

码大叔

Java spring 事务

Linux学习-2020.05.11

Flychen

C语言输入和输出

C语言技术网-码农有道

C语言 输入 输出

认识数据产品经理(二 数据产品经理的稀缺性)

马踏飞机747

大数据 互联网 数据分析 产品经理

前端有未来吗?

欧雷

前端 前端开发

gin框架之路由前缀树初始化分析-InfoQ