cloudwego/hertz 路由树解析

791 阅读6分钟

Hertz 路由树解析

简介

今天由我带各位读者分析一下 cloudwego/hertz 的路由组成部分. 路由(route)可以说是一个 web 框架的非常重要部分, 因为路由是指确定如何将传入的 HTTP 请求映射到相应的处理程序或控制器的过程.

hertz 使用 Radix Tree 作为路由树, 因为 Radix Tree 常用于 IP 路由、字符串匹配和其他相同前缀较多且字符串长度有限的场景. 首先我先来介绍一下为什么要使用 Radix Tree.

为什么路由使用 Radix Tree ?

前缀树

TXLrblk4Ool6bSx3KrHcYImLnyb.jpeg

因为 URL 可以看作为字符串, 在分配搜索的过程中, 可以通过匹配前缀来对不同的 URL 进行分类, 所以就可以考虑使用前缀树(Trie)对 URL 进行储存, 因为前缀树有以下优点:

  • 前缀树可以高效地存储和查询字符串类型的键值对,因为它利用了字符串的公共前缀,减少了无谓的字符串比较
  • 前缀树使用了共享节点和路径压缩的策略,可以有效地节约存储空间。相比于其他树结构,如二叉树或红黑树,前缀树不会存储重复的前缀,只需要存储每个字符的指针或链接。这对于存储大量字符串时非常有利,可以显著减少内存占用
  • 由于前缀树具有前缀匹配的能力,它可以实现一些高级的字符串操作,如模式匹配、最长公共前缀查找等。这使得前缀树在文本处理、编译器设计和自然语言处理等领域有着广泛的应用
  • 前缀树在插入删除操作上具有很高的效率。由于共享节点的存在,插入和删除操作只需修改相应节点的指针或链接,而不需要重新调整整个树结构。这使得前缀树非常适用于需要频繁更新数据的场景

压缩前缀树

XfPvbpYfpodhGexTmZEcmdNtneh.jpeg

但是和 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 如何匹配到它所需要的对应的路由, 敬请期待.