为什么用前缀树实现
Go官方提供的http的路由基于hashmap实现,适用于整段路径和前缀路径匹配,
- 整段路径:
/stu/allen
能匹配/stu/allen
- 前缀匹配:
/stu/
能匹配/stu/allen
和/stu/bob
Gin的路由匹配基于 httprouter实现,主要是能支持路径参数匹配,例如:
/stu/:id/class
能匹配 /stu/1/class
和 /stu/2/class
其实用普通的前缀树也能解决,但是httprouter使用 Radix tree实现,树的高度更小,查询速度更快,更省内存
本文将介绍Gin的路由注册,路由匹配的实现原理
数据结构
Engine 是 gin 框架的实例,在 Engine 结构中,trees 是一个数组,针对框架支持的每一种http方法,都会创建一棵树,每棵树用根节点代表,例如 GET、POST 是 trees 的两个元素。
type Engine struct {
// ...
trees methodTrees
// ,,,
}
type methodTrees []methodTree
type methodTree struct {
method string
root *node
}
前缀树节点定义:
type node struct {
// 当前节点代表的路径
path string
// 子节点的首字母
indices string
// 子节点中是否有路径参数节点
wildChild bool
// 当前节点类型
nType nodeType
// 有多少路径经过当前节点
priority uint32
// 子节点
children []*node
// 若是叶子节点,存放该节点对应的处理器链
handlers HandlersChain
fullPath string
}
-
indices:维护了子节点的首字母
- 例如:节点n的path 为:/stu/,子节点为 ["/allen","/bob"],那么n.indices为 "ab"
- 作用在于查找子节点时只用比对首字母,效率更高
- 如果给节点n添加一个 "/apple"的子节点,那么数会分裂成以下形式,确保indices中每个字符,只会对应一个子节点
-
/stu /a llen pple /bob
-
nType:节点类型,主要用于区分是静态节点,还是路径参数节点,还是通配符节点
- 通配符节点很少用到,本文不涉及
type nodeType uint8
const (
root nodeType = iota + 1 // 1
param // 2
catchAll // 3
)
一颗拥有如下路径的前缀树:
r.GET( "/pig" , funcA)
r.GET( "/ping/:stu/allen" , funcB)
r.GET( "/ping/:stu/tom" , func)
其内部形式为:
源码分析
添加路由
从trees获取到http方法对应的根节点后,调用根节点的addRoute方法添加路由
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 (n *node) addRoute(path string, handlers HandlersChain) {
fullPath := path
// 经过该节点的路径数+1
n.priority++
// Empty tree
if len(n.path) == 0 && len(n.children) == 0 {
n.insertChild(path, fullPath, handlers)
n.nType = root
return
}
// ...
}
如果是空树,第一次加入路径,调用insertChild添加,后面可以看到,当在一棵空的子树上添加剩余的路径时,也是调该方法:
func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) {
for {
// 检查path中是否有路径参数
// 返回路径参数名,路径参数起始位置,是否合法
// 关于具体怎么检查,下文分析
wildcard, i, valid := findWildcard(path)
// 如果没有路径参数,直接跳出循环
if i < 0 {
break
}
// 路径参数必须是合法的,即同一段路径不能有大于1个路径参数
if !valid {
panic( "only one wildcard per path segment is allowed, has: '" +
wildcard + "' in path '" + fullPath + "'" )
}
// 路径参数必须有名字
if len(wildcard) < 2 {
panic( "wildcards must be named with a non-empty name in path '" + fullPath + "'" )
}
// 是路径参数
if wildcard[0] == ':' { // param
if i > 0 {
// 当前节点n为 前面的静态路径
n.path = path[:i]
// 把前面部分阶段,下次循环从后面部分开始添加
path = path[i:]
}
// child为路径参数节点,
child := &node{
nType: param,
// path为路径参数名
path: wildcard,
fullPath: fullPath,
}
n.addChild(child)
// n的child有路径参数
n.wildChild = true
n = child
n.priority++
// 如果path不以路径参数结尾,则从路径参数后一个字符继续分割
if len(wildcard) < len(path) {
path = path[len(wildcard):]
child := &node{
priority: 1,
fullPath: fullPath,
}
n.addChild(child)
n = child
continue
}
// 否则到结尾了,将handlers设置给n
n.handlers = handlers
return
}
// catchAll部分,忽略
}
// 如果没有路径参数,直接将完成的path赋值给node
n.path = path
n.handlers = handlers
n.fullPath = fullPath
}
- 首先查找path是否有路径参数,如果没有,直接将完成的path赋值给node,返回
-
调用findWildcard,返回path中第一个路径参数的参数名,开始位置,是否合法
-
路径参数必须是合法的,即同一段路径不能有大于1个路径参数
- 例如:
/:id:name
就是不合法的
- 例如:
-
路径参数必须有名字
- 例如:
/:
就是不合法的
- 例如:
-
- 将路径参数前面部分的path赋值给当前node
- 然后给当前node创建一个child, nType为 param,表示路径参数节点,path为路径参数名,n跳到该child
- 如果path不以路径参数结尾,则从路径参数后一个字符继续分割,继续循环添加节点
-
否则到尾了,将handlers赋值给尾结点,返回
接着看看如何path中检查路径参数:
func findWildcard(path string) (wildcard string, i int, valid bool) {
// Find start
for start, c := range []byte(path) {
// 碰到 ":"或 "*"
if c != ':' && c != '*' {
continue
}
valid = true
// 从 "*" 开始往后遍历,一旦遇到结尾,或者 "/" ,或者再次遇到 ':' , '*'停止
for end, c := range []byte(path[start+1:]) {
switch c {
case '/' :
return path[start : start+1+end], start, valid
case ':' , '*' :
valid = false
}
}
return path[start:], start, valid
}
return "" , -1, false
}
-
遍历path中的每个字符
- 碰到
":"
或"*"
开始 - 从 "*" 开始往后遍历,一旦遇到结尾,或者
"/"
,或者再次遇到':', '*'
为止 - 如果遇到
"/"
,或者结尾了,表示该路径参数合法,否则不合法 - 如果整段都没有路径参数,表明这是个静态路径,返回 -1
- 碰到
-
例如:
-
Path = "/stu/:id/name",会返回 :id,5,true
-
Path = "/stu/:id:name/",就会返回 valid = false,表示不合法,因为同一段路径只能有一个路径参数
-
如果不是第一次添加路径,就会进入addRoute中后面的流程:
walk:
for {
// 寻找path和节点.path最长公共前缀
i := longestCommonPrefix(path, n.path)
// 需要分裂n
if i < len(n.path) {
child := node{
path: n.path[i:],
wildChild: n.wildChild,
indices: n.indices,
children: n.children,
handlers: n.handlers,
priority: n.priority - 1,
fullPath: n.fullPath,
}
n.children = []*node{&child}
// 设置索引
n.indices = bytesconv.BytesToString([]byte{n.path[i]})
// 分裂后当前节点变为父节点,path为公共前缀
n.path = path[:i]
n.handlers = nil
n.wildChild = false
n.fullPath = fullPath[:parentFullPathIndex+i]
}
// 需要为添加新节点
if i < len(path) {
// 需要添加的path为从i开始到最后的部分
path = path[i:]
c := path[0]
// 如果n为路径参数节点,且path以 '/' 开头,直接去n.children[0]匹配
if n.nType == param && c == '/' && len(n.children) == 1 {
parentFullPathIndex += len(n.path)
n = n.children[0]
n.priority++
continue walk
}
// 检查c是否在n.indices存在,如果存在,就取对应的child匹配
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
}
}
// 否则就需要插入新节点
if c != ':' && c != '*' && n.nType != catchAll {
// []byte for proper unicode char conversion, see #65
n.indices += bytesconv.BytesToString([]byte{c})
child := &node{
fullPath: fullPath,
}
n.addChild(child)
n.incrementChildPrio(len(n.indices) - 1)
n = child
} else if n.wildChild {
// 如果n的child为路径节点,且要插入的path也有路径参数
n = n.children[len(n.children)-1]
n.priority++
// 要么len(path)大于len(n.path),且n.path == path[:len(n.path)],且path[len(n.path)] == '/'
// 要么len(path)等于len(n.path),且n.path == path[:len(n.path)]
// 否则就会出现路径参数冲突,直接panic
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
}
// 冲突,会panic
pathSeg := path
if n.nType != catchAll {
pathSeg = strings.SplitN(pathSeg, "/" , 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 +
"'" )
}
// 需要插入新节点
// 下一段path是普通节点,或者是路径参数节点但n的child不是路径参数节点,调用insertChild插入
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
}
}
- 寻找path和节点.path最长公共前缀,返回前缀的长度i
-
如果最长公共前缀比n.path短,说明n节点本身需要分裂
- 分裂后n的path为前缀,并为n添加一个子节点
- n.path 改为
n.path[:i]
,子节点的path为n.path[i:]
- 例如:n.path = "/allen",要添加的path = "/apple",n就会分裂为 '/a'和 'llen'两个节点
-
如果 ****i < len(path),说明需要添加新节点,
- 如果n为路径参数节点,且path以 '/' 开头,直接去n.children[0]匹配
-
检查path[0]是否在n.indices存在
-
如果存在,就取对应的child继续匹配
-
如果不存在,说明需要插入新节点:
- 如果path的下一段不是路径参数,那么创建一个子节点,当前节点跳到子节点,最后执行insertChild进行插入
-
-
如果path下一段是路径参数,且当前节点的子节点也有路径参数,那么就需要看是否冲突:
- 要么
len(path)大于len(n.path)
,且n.path == path[:len(n.path)]
,且path[len(n.path)] == '/'
- 要么
len(path)等于len(n.path)
,且n.path == path[:len(n.path)]
- 也就是说,要么path和n.path完全相同,要么path比n.path多的部分以 '/'开头才算合法,这里的逻辑是:路径参数的参数名必须相同,因为如果不同,请求过来就不知道匹配哪条路径
- 如果不冲突,跳到child继续执行for循环
- 要么
-
最终会调用insertChild生成新的子树
匹配路由
入口为ServeHTTP,Engine实现了http.Handler接口,因此go http会将请求转发到Engine.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:
t := engine.trees
for i, tl := 0, len(t); i < tl; i++ {
if t[i].method != httpMethod {
continue
}
// 获取请求方法对应的根节点
root := t[i].root
// 调用root.getValue获取handlers和路径参数信息
value := root.getValue(rPath, c.params, c.skippedNodes, unescape)
if value.params != nil {
c.Params = *value.params
}
if value.handlers != nil {
c.handlers = value.handlers
c.fullPath = value.fullPath
c.Next()
c.writermem.WriteHeaderNow()
return
}
关键在于怎么从tree中获取到和请求路径对应的处理器链,以及怎么从请求路径中解析出路径参数:
func (n *node) getValue(path string, params *Params, skippedNodes *[]skippedNode, unescape bool) (value nodeValue) {
var globalParamsCount int16
walk:
for {
prefix := n.path
if len(path) > len(prefix) {
// path的前缀和 n.path相同
if path[:len(prefix)] == prefix {
// 接下来要找的path为后半段
path = path[len(prefix):]
// 尝试所有的indices
idxc := path[0]
for i, c := range []byte(n.indices) {
// 匹配
if c == idxc {
// 去下一个节点找
n = n.children[i]
continue walk
}
}
// 如果静路由没有匹配,就需要找 路径参数的路由
n = n.children[len(n.children)-1]
globalParamsCount++
switch n.nType {
case param:
// 一直找到path的结尾,或者遇到 '/' 为止
end := 0
for end < len(path) && path[end] != '/' {
end++
}
// Save param value
if params != nil && cap(*params) > 0 {
if value.params == nil {
value.params = params
}
// Expand slice within preallocated capacity
i := len(*value.params)
*value.params = (*value.params)[:i+1]
val := path[:end]
if unescape {
if v, err := url.QueryUnescape(val); err == nil {
val = v
}
}
// 找到和该路径参数匹配的值,存放到value.params中
(*value.params)[i] = Param{
Key: n.path[1:],
Value: val,
}
}
// 如果path还有剩余,继续遍历后面的子树
if end < len(path) {
if len(n.children) > 0 {
path = path[end:]
n = n.children[0]
continue walk
}
// ... but we can't
value.tsr = len(path) == end+1
return
}
// path没有剩余,则找到目标节点
if value.handlers = n.handlers; value.handlers != nil {
value.fullPath = n.fullPath
return
}
return
// catchAll的逻辑,忽略
default:
panic( "invalid node type" )
}
}
}
return
}
}
这里忽略了对catchAll节点,和skippedNodes的处理,只看静态路径和路径参数的核心逻辑:
- 首先请求路径path的前缀,必须和当前节点n.path相同
- 然后尝试
n.indices
, 如果有匹配的,继续去匹配的child寻找
-
如果没有匹配的,且child中有路径参数节点,就去该节点匹配:
- 将path从当前位置,到结尾或遇到
'/'
中间的所有字符当做路径参数的值,保存到value.params
中
- 将path从当前位置,到结尾或遇到
- 如果path还有剩余,继续匹配后面的路径