go语言http解析(二)路由树解析与注册

0 阅读6分钟

注册流程

我们先来关注注册url的流程,我们以下面的代码为例:

mux := http.NewServeMux()
mux.Handle("GET abc.com/hello/{name}", loggingMiddleware(http.HandlerFunc(helloHandler)))

Handle这个方法主要是区分了一下版本,没有其他处理逻辑,register 是 Handle/HandleFunc 的统一内部入口,当路由发生冲突(歧义)的时候会返回panic。这里ai给的解释是路由冲突属于编程错误,应该在启动阶段暴露,而不是静默忽略。主要的处理在registerErr里面

func (mux *ServeMux) Handle(pattern string, handler Handler) {
    if use121 {
       mux.mux121.handle(pattern, handler) // 老版本处理逻辑
    } else {
       mux.register(pattern, handler) // 新的处理逻辑
    }
}

func (mux *ServeMux) register(pattern string, handler Handler) {
    if err := mux.registerErr(pattern, handler); err != nil {
       panic(err)
    }
}

registerErr 首先会将url解析为 pattern 结构。 结构如下:

// pattern 表示可以与 HTTP 请求进行匹配的模式。
type pattern struct {
    str    string // 原始字符串
    method string
    host   string
    // 路径的内部表示与表面语法不同,这样可以简化大多数算法。
    //
    // 以 '/' 结尾的路径用一个匿名的 "..." 通配符表示。
    // 例如,路径 "a/" 被表示为字面量段 "a" 后跟一个 multi==true 的段。
    //
    // 以 "{$}" 结尾的路径用字面量段 "/" 表示。
    // 例如,路径 "a/{$}" 被表示为字面量段 "a" 后跟字面量段 "/"。
    segments []segment
    loc      string // 注册调用的源码位置,用于生成有帮助的错误信息
}

type segment struct {
    s     string // 字面量或通配符名称,或 "/" 表示 "/{$}"
    wild  bool
    multi bool // "..." 通配符
}

"GET abc.com/hello/{name}"为例,首先会查找第一个空格或者制表符进行拆分,前面的就是method, 然后对剩下的字符串搜索第一个'/',以第一个'/'为间隔,前面的就是host,后面的就是path,

然后把后面 path 路径字符串按 / 切成一段一段,然后判断每段是"固定文字"还是"通配符占位符",分别记录下来,最终组成一个可用于路由匹配的结构体。

具体处理了以下几种情况:

  1. 路径以 / 结尾(如 /static/)— 视为"贪婪多段通配符",能匹配这个路径及其所有子路径。

  2. 普通字面量段(如 /users/list)— 直接做 URL 解码后原样保存,路由匹配时进行精确字符串比较。

  3. 通配符段(如 {id})— 必须严格用 { 和 } 包裹,中间是通配符名。这里还细分了三种情况:

    • {$}:精确匹配到路径末尾,后面不能再有任何路径段(相当于"必须完全到这里结束")。
    • {name...}:贪婪多段通配符,只能放在路径最后,能匹配剩余所有路径段。
    • {name}:普通单段通配符,只匹配一个路径段。
  4. 重复通配符名检测 — 同一个 pattern 里不能出现两个同名的通配符(如两个 {id}),否则报错。

segment结构的取值有以下几种类型:

wildmulti含义示例
falsefalse字面量段,精确匹配/users/list
falsefalse{$} 特殊情况,s 存 "/" 表示路径终止/foo/{$}
truefalse单段通配符,匹配一个路径段{id}{name}
truetrue多段贪婪通配符,匹配剩余所有路径段{rest...} 或尾部 /

补充说明:

  • {$} 比较特殊,wild=false, multi=false,但 s 被设为 "/",用来和普通字面量区分,表示"路径在此精确结束"。
  • 尾部裸斜杠(如 /static/)和 {name...} 都会生成 wild=true, multi=true 的 segment,区别是前者 s 为空字符串,后者 s 为通配符名。

冲突检测

接下来我们看一个关键结构routingIndex,用来通过对 pattern 建立索引加快冲突检测

type routingIndex struct {
    // segments 是一个从"(段位置, 字面量值)""所有在该位置上含有该字面量的 pattern 列表"的映射。
    // 例如,key {pos:1, s:"b"} 会索引 "/a/b""/a/b/c",
    // 但不会索引 "/a""/b/a""/a/c""/a/{x}"(后者是通配符,不是字面量)。
    segments map[routingIndexKey][]*pattern

    // multis 存储所有以多段通配符("...")或尾部斜杠结尾的 pattern。
    // 对这类 pattern 不做精细索引,因为实际注册的数量通常极少,暴力遍历即可。
    multis []*pattern
}

// routingIndexKey 是 segments 索引的键,唯一标识"某个段位置上的某个字面量"。
type routingIndexKey struct {
    pos int    // 段的位置,从 0 开始计数
    s   string // 字面量值;若为空字符串,则表示该位置是通配符
}

首先我们介绍三种 pattern 术语:

  • dollar pattern: 以 "{$}" 结尾,如 /a/b/{$},只匹配精确路径末尾的斜杠
  • multi pattern: 以尾斜杠或 "{x...}" 结尾,可匹配任意多段路径
  • ordinary pattern: 普通固定长度 pattern,两者皆非

这个结构的核心逻辑可以这样理解:

  • routingIndex 是一个"剪枝索引",注册新 pattern 时不需要跟所有已注册的 pattern 逐一比对,而是先通过索引快速过滤掉"必然不冲突"的那些,只对剩余的"可能冲突"候选者做精确判断。
  • segments 字段是剪枝的关键:如果新 pattern 在第 N 段是字面量 "foo",那么任何在第 N 段是字面量但值不是 "foo" 的 pattern,必然与它互斥,可以直接跳过。
  • multis 字段单独处理以 {name...} 或尾部 / 结尾的 pattern,因为这类 pattern 能跨越任意段数,无法用段位置索引来剪枝,但数量通常很少,直接遍历即可。
  • routingIndexKey 中 s 为空字符串时表示通配符,这类 pattern 也不参与字面量索引(通配符能匹配任意值,不能用来剪枝)。

结合 possiblyConflictingPatterns 的完整实现,整个冲突检测流程是一个两阶段过滤的设计: 首先进行剪纸,去掉不必要的匹配项目,然后调用传入下面的函数进行真正的匹配,我们将这个函数命名为 f

func(pat2 *pattern) error {
    if pat.conflictsWith(pat2) {
       d := describeConflict(pat, pat2)
       return fmt.Errorf("pattern %q (registered at %s) conflicts with pattern %q (registered at %s):\n%s",
          pat, pat.loc, pat2, pat2.loc, d)
    }
    return nil
}

第一阶段:索引剪枝

目标是快速缩小"候选冲突集合",避免对所有已注册 pattern 做全量扫描。

pattern pat
│
├─① 先对所有 multi pattern(以 {x...} 或尾斜杠结尾的)全量调用 f 进行冲突检测
│ └─ 因为 multi 能匹配任意长度路径,无法通过段位置索引剪掉
│
├─② 若新 pat 是 dollar pattern(以 {$} 结尾)
│ └─ 只查索引中"同位置上有 '/' 字面量"的 pattern,其余全部跳过,结束检测
│ (dollar pattern 只与同位置的 dollar/multi 冲突,ordinary 不影响它)
│
└─③ 若 pat 是 ordinary pattern(普通固定长度)\
    ├─ 遍历 pat 中的每个字面量段,查索引得到两组候选:
    │ · lmin = 同位置、同字面量的 pattern 列表
    │ · wmin = 同位置、是通配符的 pattern 列表
    ├─ 取候选总数最少的那个字面量段对应的两组,作为最终候选集
    │ (候选越少,后续精确判断的次数越少)
    └─ 若 pat 全是通配符(无字面量可剪枝),则对所有已注册 pattern 全量检查

第二阶段:精确判断(conflictsWith

精准判断就是调用上面的函数f, 对上一阶段筛出的每个候选 pat2 调用:

关键点 conflictsWith 内部的逻辑:

  1. 若两者 host 不同 → 直接返回 false(不冲突)
  2. 分别比较 method 关系(compareMethods)和 path 关系(comparePaths
  3. 用 combineRelationships 合并两个维度的关系
  4. 结果为 equivalent(完全等价)或 overlaps(交叉)→ 冲突,否则不冲突

决策树

image.png

决策树的示意图如上所示,ai生成的,可能略有瑕疵,但整体上是没问题的

决策树的核心结构是routingNode, 就代表图中的一个一个的节点。

// routingNode 是决策树中的一个节点,叶节点和内部节点共用同一结构体。
//
//   - 叶节点(leaf node):pattern 和 handler 均非 nil,代表一条已注册的路由规则。
//   - 内部节点(interior node):pattern 和 handler 为 nil,仅作为分支跳转使用。
type routingNode struct {
    // A leaf node holds a single pattern and the Handler it was registered
    // with.
    // 叶节点存储对应的路由 pattern 及其 Handler。
    pattern *pattern
    handler Handler

    // An interior node maps parts of the incoming request to child nodes.
    // special children keys:
    //     "/" trailing slash (resulting from {$})
    //    ""   single wildcard
    //
    // 内部节点将请求的某一部分映射到子节点。
    // children 的特殊 key:
    //   "/"  —— 尾部斜线(来自 pattern 中的 {$})
    //   ""   —— 单段通配符(来自 {name} 形式的通配符)
    children   mapping[string, *routingNode]
    multiChild *routingNode // child with multi wildcard; 多段通配符子节点(来自 {name...})
    emptyChild *routingNode // optimization: child with key ""; 单段通配符子节点的快速访问缓存(key 为 "")
}

主要的流程大概就是一层一层添加节点,如果这一层是空或者{id}这种,就添加到emptyChild下面,如果是多端匹配({id ...})就添加到multiChild,其余需要完全匹配的就添加到children这个map里,方便快速查找。

这里需要特殊说明的是,尾部 {$},会按照'/'添加到children里面


举例

以 GET /users/{id} 为例,完整走一遍插入过程。可以从源码registerErr方法的mux.tree.addPattern(pat, handler)这里开始追踪,主要是将 pattern 和对应的 Handler 插入前缀路由树


第一步:入口 addPattern

调用 root.addPattern(p, h),其中 p.host = ""p.method = "GET"p.segments = ["users", "{id}"]。主要会用到以下方法,

// addChild 在节点 n 下查找或创建 key 对应的子节点并返回。
//
//   - key == "" 时走 emptyChild 快速路径(单段通配符专用优化)。
//   - 否则先在 children 中查找,不存在则新建并追加。
func (n *routingNode) addChild(key string) *routingNode {
    if key == "" {
       // 空 key 对应单段通配符,使用专用字段以避免 map 查找开销
       if n.emptyChild == nil {
          n.emptyChild = &routingNode{}
       }
       return n.emptyChild
    }
    if c := n.findChild(key); c != nil {
       return c
    }
    c := &routingNode{}
    n.children.add(key, c)
    return c
}

第二步:建立 host 层(第 1 层)

root.addChild("")

host 是空字符串,走 emptyChild 快速路径。检查 root.emptyChild 是否存在,不存在则新建一个空节点,返回它。现在树是:

root
└── emptyChild  ← 当前位置

第三步:建立 method 层(第 2 层)

hostNode.addChild("GET")

key 是 "GET",非空字符串,去 children 这个 mapping 里查找。没有则新建节点,追加到 children 切片(此时元素数 ≤ 8,用切片存)。返回该节点。树变为:

root
└── emptyChild
    └── children["GET"]  ← 当前位置

第四步:递归处理 path 段,调用 addSegments(["users", "{id}"], p, h)

处理第 1 段 "users"

  • seg.multi = falseseg.wild = false,是字面量。
  • 调用 methodNode.addChild("users"),在 children 中新建 "users" 子节点,返回它。
  • 递归调用 usersNode.addSegments(["{id}"], p, h)

树变为:

root
└── emptyChild
    └── children["GET"]
        └── children["users"]  ← 当前位置

处理第 2 段 "{id}"

  • seg.multi = falseseg.wild = true,是单段通配符。
  • 调用 usersNode.addChild("")(空字符串 key),走 emptyChild 快速路径,新建节点,同时把它赋给 usersNode.emptyChild,返回它。
  • 递归调用 wildcardNode.addSegments([], p, h)

树变为:

root
└── emptyChild
    └── children["GET"]
        └── children["users"]
            └── emptyChild  ← 当前位置(单段通配符 {id})

第五步:addSegments 收到空切片,调用 set(p, h)

segs 为空,说明所有段都已处理完毕,当前节点就是叶节点。调用 wildcardNode.set(p, h)

  • 检查 pattern 和 handler 是否已有值(防止重复注册)。
  • 写入 wildcardNode.pattern = pwildcardNode.handler = h

最终树形状态

root
└── emptyChild  (host = "" 通用路由)
    └── children["GET"]  (method = GET)
        └── children["users"]  (字面量段 "users")
            └── emptyChild     (单段通配符 {id})
                ├── pattern = "GET /users/{id}"  ← 叶节点标志
                └── handler = handleGetUser