bigywater.tech
Gin · 5

路由树节点:static、param、catchAll 的结构

Gin 的路由是一棵压缩前缀树,但“压缩”之后树到底长什么样?这篇在 Gin 源码副本里加了一个只读的打印函数,把 8 条路由注册后的真实节点逐个打出来,再对着 tree.go 里的字段和注释看每个字段是干什么的。

12
8 条路由注册后树里的节点个数
4 种
nodeType:static、root、param、catchAll
1100 / 2100
1000 条静态路由 / 1000 条带 :id 路由的节点数

node 结构体:8 个字段、4 种类型

实测 · 本机执行输出
  tree.go(Gin v1.12.0):
      90 | type nodeType uint8
      91 | 
      92 | const (
      93 |     static nodeType = iota
      94 |     root
      95 |     param
      96 |     catchAll
      97 | )
      98 | 
      99 | type node struct {
     100 |     path      string
     101 |     indices   string
     102 |     wildChild bool
     103 |     nType     nodeType
     104 |     priority  uint32
     105 |     children  []*node // child nodes, at most 1 :param style node at the end of the array
     106 |     handlers  HandlersChain
     107 |     fullPath  string
     108 | }

Gin 的路由树节点非常小:一条边上的字符串 path、子节点首字节拼成的 indices、是否带通配子节点的 wildChild、类型 nType、优先级 priority、children、handlers 和完整路由 fullPath。类型只有 static、root、param、catchAll 四种,root 不是一种通配语法,只是根节点的标记。

children 的源码注释写着“最多一个 :param 风格的节点,放在数组最后”,下一节的树印证了这点。

8 条路由,12 个节点

实测 · 本机执行输出
  注册顺序: [/ /users /users/:id /users/:id/posts /users/new /static/*filepath /search /src/a/b]
  "/" [root prio=8 indices="us"] handlers=1 full="/"
    "users" [static prio=4 indices="/"] handlers=1 full="/users"
      "/" [static prio=3 indices="n" wildChild]
        "new" [static prio=1 indices=""] handlers=1 full="/users/new"
        ":id" [param prio=2 indices="/"] handlers=1 full="/users/:id"
          "/posts" [static prio=1 indices=""] handlers=1 full="/users/:id/posts"
    "s" [static prio=3 indices="ter"]
      "tatic" [static prio=1 indices="/"]
        "" [catchAll prio=1 indices="" wildChild]
          "/*filepath" [catchAll prio=1 indices=""] handlers=1 full="/static/*filepath"
      "earch" [static prio=1 indices=""] handlers=1 full="/search"
      "rc/a/b" [static prio=1 indices=""] handlers=1 full="/src/a/b"

“压缩前缀树”的压缩体现在:/users、/users/:id、/users/new 共用 users 这条边,/static、/search、/src/a/b 共用首字母 s。indices 只放静态子节点的首字节,所以 :id 不在 "/users/" 这层的 indices 里(那里只有 n),而是排在 children 数组的最后,wildChild 为真。

树里“长什么样”我只验证到这一层;匹配时先查 indices、再落到通配子节点的顺序,在后面讲优先级和 param 与 catchAll 的差别时再用实验验证。

:id 和 *filepath 的结构

实测 · 本机执行输出
  [只有 /users/:id]
  "/users/" [root prio=1 indices="" wildChild]
    ":id" [param prio=1 indices=""] handlers=1 full="/users/:id"
  [只有 /static/*filepath]
  "/static" [root prio=1 indices="/"]
    "" [catchAll prio=1 indices="" wildChild]
      "/*filepath" [catchAll prio=1 indices=""] handlers=1 full="/static/*filepath"
  [/a/:x/b/:y/*rest]
  "/a/" [root prio=1 indices="" wildChild]
    ":x" [param prio=1 indices=""]
      "/b/" [static prio=1 indices="" wildChild]
        ":y" [param prio=1 indices=""]
          "" [static prio=1 indices="/"]
            "" [catchAll prio=1 indices="" wildChild]
              "/*rest" [catchAll prio=1 indices=""] handlers=1 full="/a/:x/b/:y/*rest"

只有 /users/:id 时,树就是一个 "/users/" 带一个 ":id"。*filepath 则占两个节点:一个 path 为空的 catchAll 节点和下面的 "/*filepath"。我只验证了树的形状,没有逐行去验证 insertChild 里每个分支的作用。

匹配出来的参数

实测 · 本机执行输出
    /users/new                   → FullPath="/users/new"           Params=[]
    /users/42                    → FullPath="/users/:id"           Params=[{id 42}]
    /users/42/posts              → FullPath="/users/:id/posts"     Params=[{id 42}]
    /static/css/app.css          → FullPath="/static/*filepath"    Params=[{filepath /css/app.css}]
    /static/                     → FullPath="/static/*filepath"    Params=[{filepath /}]
    /a/1/b/2/c/d/e               → FullPath="/a/:x/b/:y/*rest"     Params=[{x 1} {y 2} {rest /c/d/e}]
    /users/                      → 状态码 404 位置 ""
    /users/42/post               → 状态码 404 位置 ""
    /static                      → 状态码 301 位置 "/static/"

c.FullPath() 返回的是注册时的路由模板,c.Params 才是 URL 里取出的值。通配符 *filepath 的值总是以 / 开头,拼路径时别再多加一个斜杠。没有注册 /users 时,/users/ 是 404;/static 因为后面有 /static/*filepath,被 301 重定向到 /static/。

规模与每方法一棵树

实测 · 本机执行输出
  1000 条静态路由 节点数: 1100
  1000 条带 :id 的路由 节点数: 2100
  4 个方法各注册 /x 之后的树(顺序): [GET POST PUT DELETE]

静态路由共用前缀,1000 条只要 1100 个节点;每条路由多一个参数就多一个 param 节点,2100 个。不同 HTTP 方法各自一棵树,所以同一个路径用 GET 和 POST 注册不会互相干扰,树也不会共享。

分步演示

路由树节点
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew)/ Gin v1.12.0(go.mod 里 require,源码在模块缓存里);用 go run / go test 直接编译运行,需要库时经代理从 proxy.golang.org 下载;HTTP 实验用 httptest 或本机回环端口(9700 以上);计时用 time.Now / testing.B,数字每次运行会有小幅抖动。DumpTree 是我在 Gin v1.12.0 源码副本里新增的 zz_dump.go(只读遍历 Engine.trees,不改变任何行为),方便把内部节点打印出来;文中的行号是模块缓存里原文的行号;每种情形各一次
    • 没有验证:insertChild 里每个分支(如 catchAll 前一个字符必须是斜杠的检查)是否都由本文的例子覆盖;其他版本的 Gin 里节点结构是否一致(只看了 v1.12.0);路由数量更大(几万条)时的内存占用;Engine.maxParams / maxSections 等预分配字段的作用
    京ICP备2026067502号-1京公网安备11010502063487号