Hertz 路由树解析
简介
今天由我带各位读者分析一下 cloudwego/hertz 的路由组成部分. 路由(route)可以说是一个 web 框架的非常重要部分, 因为路由是指确定如何将传入的 HTTP 请求映射到相应的处理程序或控制器的过程.
hertz 使用 Radix Tree 作为路由树, 因为 Radix Tree 常用于 IP 路由、字符串匹配和其他相同前缀较多且字符串长度有限的场景. 首先我先来介绍一下为什么要使用 Radix Tree.
为什么路由使用 Radix Tree ?
前缀树
因为 URL 可以看作为字符串, 在分配搜索的过程中, 可以通过匹配前缀来对不同的 URL 进行分类, 所以就可以考虑使用前缀树(Trie)对 URL 进行储存, 因为前缀树有以下优点:
- 前缀树可以高效地存储和查询字符串类型的键值对,因为它利用了字符串的公共前缀,减少了无谓的字符串比较
- 前缀树使用了共享节点和路径压缩的策略,可以有效地节约存储空间。相比于其他树结构,如二叉树或红黑树,前缀树不会存储重复的前缀,只需要存储每个字符的指针或链接。这对于存储大量字符串时非常有利,可以显著减少内存占用
- 由于前缀树具有前缀匹配的能力,它可以实现一些高级的字符串操作,如模式匹配、最长公共前缀查找等。这使得前缀树在文本处理、编译器设计和自然语言处理等领域有着广泛的应用
- 前缀树在插入和删除操作上具有很高的效率。由于共享节点的存在,插入和删除操作只需修改相应节点的指针或链接,而不需要重新调整整个树结构。这使得前缀树非常适用于需要频繁更新数据的场景
压缩前缀树
但是和 Trie 相比较, Radix Tree (压缩前缀树) 拥有更大的优势:
- Radix Tree 在存储上相对于普通 Trie 更加节省空间。它通过压缩共同前缀的节点,减少了节点的数量和存储开销
- 由于 Radix Tree 压缩了共同前缀的节点,查找时就可以跳过一些节点,减少了比较的次数,因此在一般情况下具有更快的查询速度
- Radix Tree 在存储上的优化使得其结构更加简洁明了, 相比之下,普通 Trie 由于存储了每个字符的节点,可能会有更多的节点层级,结构相对复杂一些
很明显, 使用 Radix Tree 作为路由树是一个不错的选择. 往下我将介绍 hertz 的路由实现
Hertz 的路由实现
节点
hertz 为每个 HTTP 方法维护一个单独的 Radix Tree,因此不同方法的路由树是隔离的
type router struct {
method string
root *node
hasTsrHandler map[string]bool
}
我们再来浏览一下路由节点的定义:
type (
node struct {
// kind 为节点类型
// 有 static, param, all 三种类型
kind kind
label byte
prefix string
// 指向父节点
parent *node
// 子节点数组
children children
// original path
ppath string
// param names
pnames []string
// 函数处理链
handlers app.HandlersChain
paramChild *node
anyChild *node
// isLeaf indicates that node does not have child routes
isLeaf bool
}
kind uint8
children []*node
)
Radix 树的每个节点不仅存储了当前节点的字符串,还储存了它的父节点的地址. 除此之外,查询过程也得到了优化,索引字段保存了子节点的第一个字符(label),以快速确定当前路径在哪个子节点中。
const (
// static kind
skind kind = iota
// param kind
pkind
// all kind
akind
paramLabel = byte(':')
anyLabel = byte('*')
slash = "/"
nilString = ""
)
除此之外 hertz 分别支持 静态, 参数, 任意路径三种不同的路径, 其中 paramLabel
, anyLabel
使用 byte 的存储的意义在于解析字符串时避免无意义的 byte 和 string 直接的转换, 在后续的解析中这项意义将会得以体现.
注册路由
首先在将节点插入树之前, 需要检查 path 的合法性, 以保证路由在匹配时真的可以匹配的到, 我在一下代码中加入了大量的注释, 以便于理解
func checkPathValid(path string) (valid bool) {
if path == nilString {
panic("empty path")
}
if path[0] != '/' {
panic("path must begin with '/'")
}
for i, c := range []byte(path) {
switch c {
case ':':
// i < len(path)-1 如果 path 为 :/ 的情况
// i == (len(path)-1) 如果 path 末尾为 : 的情况
if (i < len(path)-1 && path[i+1] == '/') || i == (len(path)-1) {
panic("wildcards must be named with a non-empty name in path '" + path + "'")
}
i++
for ; i < len(path) && path[i] != '/'; i++ {
if path[i] == ':' || path[i] == '*' {
panic("only one wildcard per path segment is allowed, find multi in path '" + path + "'")
}
}
case '*':
// path 不能为 * 结尾, 因为没有携带参数名
if i == len(path)-1 {
panic("wildcards must be named with a non-empty name in path '" + path + "'")
}
// path 必须为 /*param 的形式
if i > 0 && path[i-1] != '/' {
panic(" no / before wildcards in path " + path)
}
// path 不能为 /*param/ 的形式
for ; i < len(path); i++ {
if path[i] == '/' {
panic("catch-all routes are only allowed at the end of the path in path '" + path + "'")
}
}
}
}
return true
}
注册路由的逻辑也并没有非常复杂, 首先验证参数的合法性, 通过解析 path 将 param, any, static 三种路径进行不同的插入处理, 在遇到非静态路由时, 首先先添加前面的静态路由部分, 再插入非静态路由
其中 path[i] == paramLabel
使用 byte 直接进行比较, 减少不必要的类型转换.
// addRoute adds a node with the given handle to the path.
func (r *router) addRoute(path string, h app.HandlersChain) {
checkPathValid(path)
var pnames []string // Param names
ppath := path // Pristine path
// 函数链不能为空
if h == nil {
panic(fmt.Sprintf("Adding route without handler function: %v", path))
}
// lcp: long common prefix
// Add the front static route part of a non-static route
for i, lcpIndex := 0, len(path); i < lcpIndex; i++ {
// 遇到 param route
if path[i] == paramLabel {
// j 为参数起始的下标
// i 逐渐递增,直到遇到参数标签后的下一个斜杠(/)或到达路径末尾
j := i + 1
// 插入静态路径
r.insert(path[:i], nil, skind, nilString, nil)
for ; i < lcpIndex && path[i] != '/'; i++ {
}
// 添加参数路径
// path[j:i] 用于取出参数
pnames = append(pnames, path[j:i])
// 将参数从路径去除
// eg: /hello/:/:
path = path[:j] + path[i:]
// 将 i 更新为参数名结束位置 j,同时将 lcpIndex 更新为新的路径长度 len(path)。
i, lcpIndex = j, len(path)
// 出现 i == lcpIndex 的情况意味着所有参数都存入 pnames 中
if i == lcpIndex {
// path node is last fragment of route path. ie. /users/:id
// 将参数路径插入到 param 树中, 并结束循环
r.insert(path[:i], h, pkind, ppath, pnames)
return
} else {
r.insert(path[:i], nil, pkind, nilString, pnames)
}
} else if path[i] == anyLabel {
r.insert(path[:i], nil, skind, nilString, nil)
pnames = append(pnames, path[i+1:])
r.insert(path[:i+1], h, akind, ppath, pnames)
return
}
}
r.insert(path, h, skind, ppath, pnames)
}
在此我举一个简单的示例, 其中有 GET, POST 两种方法的 handler, 拥有 static, param 两种 path
package main
import (
"context"
"github.com/cloudwego/hertz/pkg/app"
"github.com/cloudwego/hertz/pkg/app/server"
"github.com/cloudwego/hertz/pkg/common/utils"
"github.com/cloudwego/hertz/pkg/network/standard"
)
func main() {
h := server.New(
server.WithTransport(standard.NewTransporter),
)
h.GET("/hello/:param/:params2", func(c context.Context, ctx *app.RequestContext) {
param := ctx.Param("param")
ctx.JSON(200, utils.H{
"msg": param,
})
})
h.GET("/he/:param", func(c context.Context, ctx *app.RequestContext) {
param := ctx.Param("param")
ctx.JSON(200, utils.H{
"msg": param,
})
})
h.POST("/live", func(c context.Context, ctx *app.RequestContext) {
ctx.JSON(200, utils.H{
"isLive": h.IsRunning(),
})
})
h.Spin()
}
我们来看看它的 Radix Tree, 这个图其实还是压缩了许多东西, 其中 llo/
, /:param
在同一平面, 它们是用切片储存的
如何插入不同的路径 ?
我们先来看他的函数签名, 它提供了相当完整的参数, 用于进行分类和处理
func (r *router) insert(path string, handlersChain app.HandlersChain, kind kind, ppath string, pnames []string)
这里用于寻找 path 中的最长前缀, 通过前缀的长度从而进行不同逻辑处理
search := path
for {
// eg: /hello
searchLen := len(search)
// eg: /he
prefixLen := len(currentNode.prefix)
// 最长共同前缀长度
lcpLen := 0
max := prefixLen
if searchLen < max {
max = searchLen
}
// 找到最长共同前缀
for ; lcpLen < max && search[lcpLen] == currentNode.prefix[lcpLen]; lcpLen++ {
}
}
之后是一个非常复杂的 if 判断, 我先删去其中的逻辑部分, 免得直接晕头转向.'
lcpLen (long comment prefix Len) 为 0, 可以判断其为 root 节点
lcpLen < prefixLen 表示找到了部分共同前缀,即当前节点的前缀是待插入路径的子串. 这种情况下,需要拆分当前节点,并创建一个新的中间节点来存储共同前缀的部分.
lcpLen < searchLen 时, 上面的情况是此情况的子集, 表示当前节点的前缀与待插入路径的前缀完全匹配,但是待插入路径还有剩余部分, 即还有参数未插入. 于是将查明子节点的类型, 再进行不同的处理
// At root node
if lcpLen == 0 {
// ...
// 意味着出现了这种情况:
// 1. /hello 2. /he
// 即当前节点的前缀是待插入路径的子串
} else if lcpLen < prefixLen {
// ...
// 查明子节点类型, 再进行插入
} else if lcpLen < searchLen {
// ...
search = search[lcpLen:]
c := currentNode.findChildWithLabel(search[0])
if c != nil {
// Go deeper
currentNode = c
continue
}
// ...
} else {
}
lcpLen == 0 时
此为根节点, 如果根节点有 handler 进行对应的处理, 将会赋值所需的属性, 并判断是否为叶节点
// At root node
currentNode.label = search[0]
currentNode.prefix = search
if handlersChain != nil {
currentNode.kind = kind
currentNode.handlers = handlersChain
currentNode.ppath = ppath
currentNode.pnames = pnames
}
currentNode.isLeaf = n.children == nil && n.paramChild == nil && n.anyChild == nil
lcpLen < prefixLen 时
表示找到了部分共同前缀,即当前节点的前缀是待插入路径的子串.
这种情况下,需要拆分当前节点,并创建一个新的中间节点来存储共同前缀的部分对路径进行分割, 例如现在拥有 /hello
和 /hear
这两个 path, 这时它们明显拥有共同的 prefix: /he
, 这个时候节点就会进行分割
以 lcpLen
为界一分为二, 并进行相应的更新
// Split node
n := newNode(
currentNode.kind,
currentNode.prefix[lcpLen:],
currentNode,
currentNode.children,
currentNode.handlers,
currentNode.ppath,
currentNode.pnames,
currentNode.paramChild,
currentNode.anyChild,
)
// Update parent path for all children to new node
for _, child := range currentNode.children {
child.parent = n
}
if currentNode.paramChild != nil {
currentNode.paramChild.parent = n
}
if currentNode.anyChild != nil {
currentNode.anyChild.parent = n
}
// Reset parent node
currentNode.kind = skind
currentNode.label = currentNode.prefix[0]
currentNode.prefix = currentNode.prefix[:lcpLen]
currentNode.children = nil
currentNode.handlers = nil
currentNode.ppath = nilString
currentNode.pnames = nil
currentNode.paramChild = nil
currentNode.anyChild = nil
currentNode.isLeaf = false
// Only Static children could reach here
currentNode.children = append(currentNode.children, n)
// 拥有相同的前缀
if lcpLen == searchLen {
// At parent node
currentNode.kind = kind
currentNode.handlers = handlersChain
currentNode.ppath = ppath
currentNode.pnames = pnames
} else {
// Create child node
n = newNode(kind, search[lcpLen:], currentNode, nil, handlersChain, ppath, pnames, nil, nil)
// Only Static children could reach here
currentNode.children = append(currentNode.children, n)
}
currentNode.isLeaf = judgeIsLeaf(currentNode)
lcpLen < searchLen 时
lcpLen < prefixLen 是 lcpLen < searchLen 的子集,但是待插入路径还有剩余部分, 于是在这种情况的 search
必定拥有参数.
因此程序会进行递归, 直至所有的子节点都被正确的赋值.
search = search[lcpLen:]
c := currentNode.findChildWithLabel(search[0])
if c != nil {
// Go deeper
currentNode = c
continue
}
// Create child node
n := newNode(kind, search, currentNode, nil, handlersChain, ppath, pnames, nil, nil)
switch kind {
case skind:
currentNode.children = append(currentNode.children, n)
case pkind:
currentNode.paramChild = n
case akind:
currentNode.anyChild = n
}
currentNode.isLeaf = judgeIsLeaf(currentNode)
else 时
此时以上所有情况都不适用, 很特殊(特殊到我都不知道它是上面情况)
但是从逻辑上看要么这个节点被重复注册了, 要么这个节点没有对应的 handlers.
没有对应的 handlersChain
? 那我就给你赋值一个......
// Node already exists
if currentNode.handlers != nil && handlersChain != nil {
panic("handlers are already registered for path '" + ppath + "'")
}
if handlersChain != nil {
currentNode.handlers = handlersChain
currentNode.ppath = ppath
if len(currentNode.pnames) == 0 {
currentNode.pnames = pnames
}
}
结尾
至此, 我简要分析了 hertz 是如何将路由信息构建成 Radix Tree 的, 在下次分享, 我将介绍如何 hertz 如何匹配到它所需要的对应的路由, 敬请期待.