bigywater.tech
Gin · 14

每种 HTTP 方法一棵树

同一个路径用 GET 和 POST 注册,在 Gin 里是两棵完全独立的树。这篇看它们怎么存放、怎么找、顺序由什么决定,以及“方法很多”时线性扫描的代价到底有多大。

3 棵
GET、POST、DELETE 三种方法各注册后的树个数,彼此独立
9 棵
r.Any 一次创建的方法树个数
21.7 → 84.2 ns
26 棵方法树里查第 1 棵与第 26 棵的耗时

methodTrees:一个切片

实测 · 本机执行输出
  tree.go(Gin v1.12.0):
      45 | type methodTree struct {
      46 |     method string
      47 |     root   *node
      48 | }
      49 | 
      50 | type methodTrees []methodTree
      51 | 
      52 | func (trees methodTrees) get(method string) *node {
      53 |     for _, tree := range trees {
      54 |         if tree.method == method {
      55 |             return tree.root
      56 |         }
      57 |     }
      58 |     return nil
      59 | }

Engine.trees 是个切片,每项记录一个方法和它的根节点。找方法的树就是遍历切片比较字符串,找不到返回 nil——于是 handleHTTPRequest 里“这个方法没有树”和“这个方法的树里没有这条路由”是两种不同的失败路径。

每个方法各一棵

实测 · 本机执行输出
    trees 的顺序: [GET POST DELETE]
    [GET]
  "/users" [root prio=2 indices="/"] handlers=1 full="/users"
    "/" [static prio=1 indices="" wildChild]
      ":id" [param prio=1 indices=""] handlers=1 full="/users/:id"
    [POST]
  "/users" [root prio=2 indices="/"] handlers=1 full="/users"
    "/" [static prio=1 indices="" wildChild]
      ":id" [param prio=1 indices=""]
        "/avatar" [static prio=1 indices=""] handlers=1 full="/users/:id/avatar"
    [DELETE]
  "/users/" [root prio=1 indices="" wildChild]
    ":id" [param prio=1 indices=""] handlers=1 full="/users/:id"

同一个路径在 GET、POST、DELETE 里互不影响:POST 注册了 /users/:id/avatar,GET 的树里就没有它。树的创建发生在该方法的第一次注册,所以 trees 的顺序是各方法第一次出现的顺序(405 的 Allow 头也是按这个顺序拼的)。

Any 与自定义方法

实测 · 本机执行输出
  routergroup.go 的 Any(Gin v1.12.0):
     147 | func (group *RouterGroup) Any(relativePath string, handlers ...HandlerFunc) IRoutes {
     148 |     for _, method := range anyMethods {
     149 |         group.handle(method, relativePath, handlers)
     150 |     }
     151 | 
     152 |     return group.returnObj()
     153 | }
    Any 之后 trees: [GET POST PUT PATCH HEAD OPTIONS DELETE CONNECT TRACE] 共 9 棵
    Handle("get") → panic: http method get is not valid
    Handle("M-SEARCH") → panic: http method M-SEARCH is not valid
    Handle("PURGE") → 注册成功,trees=[PURGE]
    Handle("LINK") → 注册成功,trees=[LINK]
    Handle("") → panic: http method  is not valid
    Handle("G E T") → panic: http method G E T is not valid

Any 为 9 个标准方法各创建一棵树,如果只是想让 GET 和 POST 都能访问,分别注册这两个方法就够了,不必为 CONNECT、TRACE 也建树。自定义方法(比如缓存清理用的 PURGE)通过 Handle 注册,方法名必须全是大写英文字母,M-SEARCH 这种带连字符的会被拒绝。

线性扫描的代价

实测 · 本机执行输出
    trees: 26 棵,第 1 棵 XA ,第 26 棵 XZ
    第 1 轮
    第 1 棵树的方法 XA                           中位 21.7 ns/op(最小 21.6 / 最大 21.8)
    第 13 棵树的方法 XM                          中位 50.8 ns/op(最小 50.7 / 最大 51.0)
    第 26 棵树的方法 XZ                          中位 84.2 ns/op(最小 84.0 / 最大 84.6)
    第 2 轮
    第 1 棵树的方法 XA                           中位 21.8 ns/op(最小 21.6 / 最大 22.1)
    第 13 棵树的方法 XM                          中位 51.0 ns/op(最小 50.8 / 最大 51.8)
    第 26 棵树的方法 XZ                          中位 84.3 ns/op(最小 83.7 / 最大 84.6)

每多一棵树大约多 2.5 ns,26 棵树里最后一棵比第一棵慢约 62 ns。正常服务只有几个方法,GET 又通常最先注册,这个代价可以忽略;只有大量自定义方法、并且把热点方法放在最后注册时才需要关心。

分步演示

方法树
本步的对象

    本文用到的工具与边界

    • 本机 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 / TreeMethods 是源码副本里加的只读函数;基准是 testing.Benchmark 测 Engine.ServeHTTP(丢弃输出的 ResponseWriter),每个数字是 9 轮的中位数,整个实验跑了两轮;26 个自定义方法名是 XA…XZ,每个只注册 /x
    • 没有验证:每个方法树的内存开销;对比用 map 存方法树时的耗时(只测了现有的线性扫描);HTTP/2 / h2c 下方法的大小写处理
    京ICP备2026067502号-1京公网安备11010502063487号