简介
gin 是 golang 一个非常经典的 web 框架,以简洁,性能高著称,其性能高的原因是其基于 httprouter 项目的路由策略,所以我们来研究下 gin 中关于路由注册与路由查询的源码部分。
首先需要有的基础概念是 gin 的路由算法是基于 radix 树的算法,如果有不了解 radix 树的需要先了解一下 radix 树,简单来说,radix tree 是 tire tree 的改版,tire tree,也叫“前缀树”或者“字典树”,它是一个树形结构,专门用于处理字符串匹配,用来解决在一组字符串集合中快速查找某个字符串的问题,比如以下是一颗 tire tree:
这棵树中保存了8个键:“A”,“to”,“tea”,“ted”,“ten”,“i”,“in”,“inn”。
而 radix tree,基数树,是 tire tree 的改版,基数树会合并那些只有一个子节点的路径,从而减少树的高度和节点的数量。在实现上,radix 树更加复杂,但是减少了内存的使用,并提高了查询效率。
比如以下是一颗 radix tree:
这颗树中保存了7个键:“romane”,“romanus”,“romulus”,“rubens”,“ruber”,“rubicon”,“rubicundus”。
关于这两种树的详细说明在这里就不做赘述,接下来进入正题。
路由注册
首先,在 gin 中注册路由的伪代码如下图所示:
func routers(root *gin.RouterGroup) {
testGroup := root.Group("test")
testGroup.GET("/success", success)
testGroup.GET("/500", success)
testGroup.GET("/400", success)
testGroup.GET("/401", success)
testGroup.GET("/403", success)
testGroup.GET("/panic", success)
testGroup.GET("/query", success)
testGroup.GET("/path/:id", success)
testGroup.GET("/action/*action", success)
}
假如我们注册以上路由,那么在 gin 中就生成如下图所示的路由树(GET method 树,简易版本):
先从 GET 方法开始,GET 方法在 routergroup.go 文件中,是路由注册的入口:
// GET 路由注册入口
func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) IRoutes {
return group.handle(http.MethodGet, relativePath, handlers)
}
// Any 方法为所有 Method 注册该路由
func (group *RouterGroup) Any(relativePath string, handlers ...HandlerFunc) IRoutes {
for _, method := range anyMethods {
group.handle(method, relativePath, handlers) // 所有 method 都会注册一次路由
}
return group.returnObj()
}
其中的 anyMethods 常量是九种 api 的 method,在 gin 的路由树中,每种 method 都会生成一棵树,所以最多有九棵树:
anyMethods = []string{
http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch,
http.MethodHead, http.MethodOptions, http.MethodDelete, http.MethodConnect,
http.MethodTrace,
}
接着再调用 handle 方法:
func (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes {
absolutePath := group.calculateAbsolutePath(relativePath) // 计算完整的路径,即加上 group 的 basepath
handlers = group.combineHandlers(handlers)
group.engine.addRoute(httpMethod, absolutePath, handlers) // 添加到路由树中
return group.returnObj()
}
添加路由的正式操作发生在 addRoute 方法中,在这个方法,gin 开始生成树与根节点:
func (engine *Engine) addRoute(method, path string, handlers HandlersChain) {
assert1(path[0] == '/', "path must begin with '/'")
assert1(method != "", "HTTP method can not be empty")
assert1(len(handlers) > 0, "there must be at least one handler")
debugPrintRoute(method, path, handlers) // 在 debug 模式下打印出注册的 api
root := engine.trees.get(method) // 是否存在该 method 的树
if root == nil { // 如果没有,则创建 method 的根节点
root = new(node)
root.fullPath = "/"
engine.trees = append(engine.trees, methodTree{method: method, root: root})
}
root.addRoute(path, handlers) // 根节点添加路由
// 统计路由中通配符的最大个数
if paramsCount := countParams(path); paramsCount > engine.maxParams {
engine.maxParams = paramsCount
}
// 统计路由中路由段(/xxx)的最大个数
if sectionsCount := countSections(path); sectionsCount > engine.maxSections {
engine.maxSections = sectionsCount
}
}
其中树节点的正式生成在 root.addRoute 方法中,稍后进入。 先来看下树的定义 methodTree:
type Engine struct {
// 省略部分代码...
trees methodTrees // []methodTree
}
type methodTrees []methodTree
type methodTree struct {
method string // 方法
root *node // 节点
}
在创建 gin 实例的时候,gin 会初始化这颗树:
gin.New()
func New(opts ...OptionFunc) *Engine {
debugPrintWARNINGNew()
engine := &Engine{
// 省略部分代码...
trees: make(methodTrees, 0, 9), // size 为 9,即 anyMethods 中允许注册的 method 数量,每个 method 都会有一棵树
}
}
接下来继续往下看路由树的节点生成,这部分的代码主要都集中在 tree.go 文件下,先来看一些定义:
const (
static nodeType = iota // 静态路由
root // 根节点路由
param // :param 通配符节点路由
catchAll // * 通配符路由
)
type node struct {
path string // 节点路由
indices string // 记录子节点的路由的第一个字符,用于快速查找
wildChild bool // 是否包含通配符子节点
nType nodeType // 节点类型
priority uint32 // 路径匹配的优先级,优先级高的优先匹配,父节点的优先级会比子节点高
children []*node // 子节点路由列表,通配符节点会在列表最后
handlers HandlersChain // 注册在该路由上的 hander([]type HandlerFunc func(*Context))
fullPath string // 在该节点上路由的完整路径
}
然后继续从刚才的 root.addRoute 方法开始,这部分的代码比较长, 主要是 addRoute 和 insertChild 方法,在代码中都会有相应的注释。 两个方法主要使用 for 循环遍历树的节点执行插入操作,addRoute 方法主要判断了欲添加节点是否已经存在或者通配符节点冲突,与添加节点时是否需要进行节点分裂;insertChild 方法主要是将节点标记为路由节点以及如果欲添加节点是通配符节点则再为其分裂出子节点存储。
func (n *node) addRoute(path string, handlers HandlersChain) {
fullPath := path
n.priority++
// 如果是一个空树,则设置为 root 节点
if len(n.path) == 0 && len(n.children) == 0 {
n.insertChild(path, fullPath, handlers)
n.nType = root
return
}
// 如果不是空树,已经有注册过路由,开始处理添加,合并路由
parentFullPathIndex := 0 // 父节点 full path 开始的索引,用于赋值使用
walk:
for {
// 查询当前节点路由与欲添加路由的最长相同前缀,且最长相同前缀中不会包含通配符,因为此时 n.path 不会包含通配符
i := longestCommonPrefix(path, n.path)
// 如果相同前缀的索引小于当前节点的路由,说明有相同前缀但是不完全相同,当前节点需要分裂节点,且 i 至少为 1,因为路由会有 '/'
// 将相同前缀部分作为当前节点的路由,将相同前缀后的部分作为子节点路由
if i < len(n.path) {
child := node{
path: n.path[i:],
wildChild: n.wildChild,
nType: static,
indices: n.indices,
children: n.children,
handlers: n.handlers,
priority: n.priority - 1,
fullPath: n.fullPath,
}
n.children = []*node{&child}
// []byte for proper unicode char conversion, see #65
n.indices = bytesconv.BytesToString([]byte{n.path[i]})
n.path = path[:i] // 重新设置欲添加路由为相同前缀后部分
n.handlers = nil
n.wildChild = false
n.fullPath = fullPath[:parentFullPathIndex+i]
}
// 如果相同前缀的索引小于欲添加路由的长度,说明这个路由需要截取相同前缀后部分的路由作为节点的子节点,如果等于则说明欲添加节点的路由已经被这两个节点的相同前缀覆盖,则直接标记当前节点为已路由节点
if i < len(path) {
path = path[i:]
c := path[0]
// 相同前缀后部分为欲添加路由
// 处理 :param 通配符后跟着 '/' 的情况
// case::param/11 & :param/12
if n.nType == param && c == '/' && len(n.children) == 1 {
parentFullPathIndex += len(n.path)
n = n.children[0]
n.priority++
continue walk // 重新执行 walk
}
// 欲添加路由是否已经存在于子节点中
for i, max := 0, len(n.indices); i < max; i++ {
if c == n.indices[i] {
parentFullPathIndex += len(n.path)
i = n.incrementChildPrio(i) // 调整子节点中的 priority 优先级与当前节点的 indices 排序
n = n.children[i]
continue walk // 重新执行 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) // 调整子节点中的 priority 优先级与当前节点的 indices 排序
n = child
} else if n.wildChild { // 如果欲添加路由包含通配符,检查是否在子节点中已经存在
n = n.children[len(n.children)-1] // 最后一个子节点即为通配符节点
n.priority++
// 通配符子节点是否冲突
if len(path) >= len(n.path) && n.path == path[:len(n.path)] &&
n.nType != catchAll &&
(len(n.path) >= len(path) || path[len(n.path)] == '/') {
continue walk // 重新执行 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 +
"'")
}
// 添加节点
n.insertChild(path, fullPath, handlers)
return
}
// 标记当前节点为已路由节点
if n.handlers != nil {
panic("handlers are already registered for path '" + fullPath + "'")
}
n.handlers = handlers
n.fullPath = fullPath
return
}
}
func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) {
for {
// for 代码块中为通配符参数的处理。。。
wildcard, i, valid := findWildcard(path) // 查询 path 是否包含合格的通配符,合格的格式为 /xxx/:param 或者 /xxx/*param
if i < 0 {
break // 如果没有通配符,则退出通配符处理块
}
// The wildcard name must only contain one ':' or '*' character
if !valid {
panic("only one wildcard per path segment is allowed, has: '" +
wildcard + "' in path '" + fullPath + "'")
}
// check if the wildcard has a name
if len(wildcard) < 2 {
panic("wildcards must be named with a non-empty name in path '" + fullPath + "'")
}
// 处理 /:param
if wildcard[0] == ':' { // param
if i > 0 {
// Insert prefix before the current wildcard
n.path = path[:i] // 将节点的 path 设置为相同前缀部分
path = path[i:] // 将欲添加路由设置为相同前缀后部分
}
// 创建 param 类型的子节点
child := &node{
nType: param,
path: wildcard,
fullPath: fullPath,
}
n.addChild(child) // 添加一个子节点,如果该节点的子节点列表包含通配符节点,则会保证通配符节点在列表最后
n.wildChild = true
n = child
n.priority++
// 如果通配符后还有剩余其他字符,则继续添加类型为静态节点到子节点列表中
if len(wildcard) < len(path) {
path = path[len(wildcard):] // 将欲添加路由设置为相同前缀后部分
child := &node{
priority: 1,
fullPath: fullPath,
}
n.addChild(child)
n = child
continue
}
n.handlers = handlers
return // 没有剩余字符则节点添加完成
}
// 处理 *param
// *param 必须在路径的最后
if i+len(wildcard) != len(path) {
panic("catch-all routes are only allowed at the end of the path in path '" + fullPath + "'")
}
// 如果节点上还有其他子节点,说明已经有其他 api 注册,则不能使用 *param 通配符
if len(n.path) > 0 && n.path[len(n.path)-1] == '/' {
pathSeg := ""
if len(n.children) != 0 {
pathSeg = strings.SplitN(n.children[0].path, "/", 2)[0]
}
panic("catch-all wildcard '" + path +
"' in new path '" + fullPath +
"' conflicts with existing path segment '" + pathSeg +
"' in existing prefix '" + n.path + pathSeg +
"'")
}
// 使用 *param 通配符必须有 '/'
i--
if path[i] != '/' {
panic("no / before catch-all in path '" + fullPath + "'")
}
// *param 通配符校验通过,创建两个 catchAll 类型的子节点
n.path = path[:i]
// First node: catchAll node with empty path
child := &node{
wildChild: true,
nType: catchAll,
fullPath: fullPath,
}
n.addChild(child)
n.indices = string('/')
n = child
n.priority++
// second node: node holding the variable
child = &node{
path: path[i:],
nType: catchAll,
handlers: handlers,
priority: 1,
fullPath: fullPath,
}
n.children = []*node{child}
return
}
// 如果不包含通配符,则标记当前节点为已路由节点
n.path = path
n.handlers = handlers
n.fullPath = fullPath
}
路由查询
以上完成了 gin 路由注册的解析,接下来开始路由查询,首先从请求开始,gin 接收请求的入口从 go sdk 中,http.Server.Server 方法开始,调用 gin.engine.ServerHTTP 方法进入:
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) // 查询注册的 handler
engine.pool.Put(c)
}
接着调用 handleHTTPRequest 方法,在这个方法,gin 会完成路由查询与响应,其中关键的路由查询方法是 root.getValue 方法:
func (engine *Engine) handleHTTPRequest(c *Context) {
// 省略部分代码...
t := engine.trees
for i, tl := 0, len(t); i < tl; i++ {
if t[i].method != httpMethod { // 找到该请求的 method 树
continue
}
root := t[i].root
// 根据 url 查询树中注册的 handler
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() // 依次执行该路由注册的 handler
c.writermem.WriteHeaderNow()
return
}
if httpMethod != http.MethodConnect && rPath != "/" {
if value.tsr && engine.RedirectTrailingSlash {
redirectTrailingSlash(c)
return
}
if engine.RedirectFixedPath && redirectFixedPath(c, root, engine.RedirectFixedPath) {
return
}
}
break // 找不到注册在该 method 的 handler
}
// 如果开启此选项,会再次寻找能否在其他 method 下是否能找到 handler,如果找到则返回 method not allowed
if engine.HandleMethodNotAllowed {
allowed := make([]string, 0, len(t)-1)
for _, tree := range engine.trees {
if tree.method == httpMethod {
continue
}
if value := tree.root.getValue(rPath, nil, c.skippedNodes, unescape); value.handlers != nil {
allowed = append(allowed, tree.method)
}
}
if len(allowed) > 0 {
c.handlers = engine.allNoMethod
c.writermem.Header().Set("Allow", strings.Join(allowed, ", "))
serveError(c, http.StatusMethodNotAllowed, default405Body)
return
}
}
// 如果没有开启选项,直接返回 404
c.handlers = engine.allNoRoute
serveError(c, http.StatusNotFound, default404Body)
}
回到 tree.go 文件中,看下路由查询的关键方法 getValue,主要也是利用 for 循环来查找节点,优先选择精确匹配再通配符匹配:
// path 欲查找路由
// params 路由通配符入参
// skippedNodes 路由匹配中的一个优化手段,用来跳过一些不必要的节点,提高路由匹配效率。它的赋值通常发生在遍历路由树时,如果当前节点不匹配且可以跳过,就将其标记为 skippedNode。
// unescape 是否转码
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) { // 如果欲查询的路由大于当前节点路由且匹配当前节点路由,说明这个节点发生了节点分裂,选择查询子节点的路由
if path[:len(prefix)] == prefix {
path = path[len(prefix):] // 设置欲查询路由为相同前缀后部分
// 当存在精确子节点与通配符子节点时,先精确匹配,再通配符匹配
idxc := path[0]
for i, c := range []byte(n.indices) {
if c == idxc { // 匹配到了子节点
if n.wildChild { // 在匹配到了子节点的情况下,如果当前节点有通配符子节点,则将当前节点添加到 skippedNodes
// case:注册 /test/:param 和 /test/test,查询 /test/test
index := len(*skippedNodes)
*skippedNodes = (*skippedNodes)[:index+1]
(*skippedNodes)[index] = skippedNode{
path: prefix + path,
node: &node{
path: n.path,
wildChild: n.wildChild,
nType: n.nType,
priority: n.priority,
children: n.children,
handlers: n.handlers,
fullPath: n.fullPath,
},
paramsCount: globalParamsCount,
}
}
// 匹配到了精确子节点,优先选择,所以设置匹配的子节点为当前节点,并进入下一轮 walk,
n = n.children[i]
continue walk // 重新执行 walk
}
}
if !n.wildChild { // 没有匹配到精确子节点且当前节点不包含通配符子节点,说明无法匹配到子节点
if path != "/" { // 尝试回到上一个skippedNode节点重新执行 walk
for length := len(*skippedNodes); length > 0; length-- {
skippedNode := (*skippedNodes)[length-1]
*skippedNodes = (*skippedNodes)[:length-1]
if strings.HasSuffix(skippedNode.path, path) {
path = skippedNode.path
n = skippedNode.node
if value.params != nil {
*value.params = (*value.params)[:skippedNode.paramsCount]
}
globalParamsCount = skippedNode.paramsCount
continue walk // 重新执行 walk
}
}
}
// 无法找到与之配对的子节点
value.tsr = path == "/" && n.handlers != nil
return value
}
// 当前节点包含了通配符子节点,尝试处理通配符节点;获取最后一个子节点,即为通配符节点
n = n.children[len(n.children)-1]
globalParamsCount++
switch n.nType {
case param: // 处理 :param 通配符
// 计算第一个路由块的长度
end := 0
for end < len(path) && path[end] != '/' {
end++
}
// 保存入参 *value.params
if params != nil {
// Preallocate capacity if necessary
if cap(*params) < int(globalParamsCount) {
newParams := make(Params, len(*params), globalParamsCount)
copy(newParams, *params)
*params = newParams
}
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)[i] = Param{
Key: n.path[1:],
Value: val,
}
}
if end < len(path) { // 代表欲查询路由包含两个路由块以上,需要继续往一个路由块查询
if len(n.children) > 0 { // 子节点存在
path = path[end:] // 设置欲查询路由为下一个路由块
n = n.children[0] // 设置当前节点为下一个子节点
continue walk // 重新执行 walk
}
// 如果没有子节点,说明通配符路由失败
// case:注册 /test/:param,查询 /test/test1/test2
value.tsr = len(path) == end+1
return value
}
// 如果当前节点被标记为路由节点,说明节点找到,返回
if value.handlers = n.handlers; value.handlers != nil {
value.fullPath = n.fullPath
return value
}
if len(n.children) == 1 { // 当前节点没有被标记为子节点,且还有子节点,则表示匹配失败
// case:注册 /test/:param/test,查询 /test/test
n = n.children[0]
value.tsr = (n.path == "/" && n.handlers != nil) || (n.path == "" && n.indices == "/")
}
return value
case catchAll: // 处理 *param 通配符
// 保存入参 *value.params
if params != nil {
// Preallocate capacity if necessary
if cap(*params) < int(globalParamsCount) {
newParams := make(Params, len(*params), globalParamsCount)
copy(newParams, *params)
*params = newParams
}
if value.params == nil {
value.params = params
}
// Expand slice within preallocated capacity
i := len(*value.params)
*value.params = (*value.params)[:i+1]
val := path
if unescape {
if v, err := url.QueryUnescape(path); err == nil {
val = v
}
}
(*value.params)[i] = Param{
Key: n.path[2:],
Value: val,
}
}
// * 通配符直接匹配成功
value.handlers = n.handlers
value.fullPath = n.fullPath
return value
default:
panic("invalid node type")
}
}
}
// 欲查询的路由匹配到了当前节点
if path == prefix {
if n.handlers == nil && path != "/" {
// 如果该节点不是根节点且没有被标记为路由节点,说明匹配到了通配符的空路由,只会发生在通配符的路由下,因为通配符的路由在树中会被分为两个节点存储(父与子)
// 如果存在 skippedNode 则使用 skippedNode
// case:注册 /test/:param,查询 /test/
for length := len(*skippedNodes); length > 0; length-- {
skippedNode := (*skippedNodes)[length-1]
*skippedNodes = (*skippedNodes)[:length-1]
if strings.HasSuffix(skippedNode.path, path) {
path = skippedNode.path // 重新设置欲查询路由为 skipoedNode 的路由
n = skippedNode.node // 重新设置欲节点
if value.params != nil {
*value.params = (*value.params)[:skippedNode.paramsCount]
}
globalParamsCount = skippedNode.paramsCount
continue walk
}
}
}
// 依旧判断当前节点是否被标记为路由节点,如果是,则节点找到,返回,否则继续
// 静态路由会在这里匹配到
if value.handlers = n.handlers; value.handlers != nil {
value.fullPath = n.fullPath
return value
}
// If there is no handle for this route, but this route has a
// wildcard child, there must be a handle for this path with an
// additional trailing slash
if path == "/" && n.wildChild && n.nType != root {
value.tsr = true
return value
}
if path == "/" && n.nType == static {
value.tsr = true
return value
}
// No handle found. Check if a handle for this path + a
// trailing slash exists for trailing slash recommendation
for i, c := range []byte(n.indices) {
if c == '/' {
n = n.children[i]
value.tsr = (len(n.path) == 1 && n.handlers != nil) ||
(n.nType == catchAll && n.children[0].handlers != nil)
return value
}
}
return value
}
// 欲查找路由小于当前节点路由,则无法找到路由
// 返回推荐的路由跳转,省略部分代码...
return value
}
}
最后
以上就是 Gin 中关于路由部分源码的解析,其 radix tree 路由算法的查找时间复杂度为 O(n),n 为欲查找路由的长度,这也是 Gin 路由性能高的关键;源码部分我觉得还是比较好懂的,该有的关键注释也有,耐心去读不是很困难,希望对各位有帮助。