蛮荆

HTTP Router 算法演进

2023-07-13

概述

本文从开发中常见的应用场景 “路由管理” 为例,介绍三种常用的实现方案背后的数据结构和算法 (代码实现为 Go 语言)。

应用示例

下面是一个典型的 REST 风格的 API 列表:

Method URL
GET /users/list
GET /users/dbwu
POST /users
PUT /users/dbwu
DELETE /users/dbwu

上面的 API 翻译为 Go 代码,大致如下 (忽略方法的具体实现):

package main

import (
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/users/list", nil)
	http.HandleFunc("/users/dbwu", nil)
	http.HandleFunc("/users", nil)
	http.HandleFunc("/users/dbwu", nil)
	http.HandleFunc("/users/dbwu", nil)

	log.Fatal(http.ListenAndServe(":8080", nil))
}

标准库方案

最简单的方案就是直接使用 map[string]func() 作为路由的数据结构,键为具体的路由,值为具体的处理方法。

标准库中使用的就是这种方案,我们可以简单追踪一下对应的代码:

// 路由管理数据结构

type ServeMux struct {
	mu    sync.RWMutex          // 对象操作读写锁
	m     map[string]muxEntry   // 存储路由映射关系
}

方法从 http.HandleFunc 方法开始追踪:

// 注册路由处理方法
func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
	DefaultServeMux.HandleFunc(pattern, handler)
}

func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
	mux.Handle(pattern, HandlerFunc(handler))
}

func (mux *ServeMux) Handle(pattern string, handler Handler) {
	mux.mu.Lock()
	defer mux.mu.Unlock()
	
	...
	
	if _, exist := mux.m[pattern]; exist {
		// 如果注册的 URL 重复了,抛出 panic
		panic("http: multiple registrations for " + pattern)
	}

	if mux.m == nil {
		// 惰性初始化
		mux.m = make(map[string]muxEntry)
	}
	
	// 注册完成
	e := muxEntry{h: handler, pattern: pattern}
	mux.m[pattern] = e
    
	...
}

优点和不足

使用 map[string]func() 作为路由的数据结构,最明显的优点就是:

  1. 实现简单: map 是标准库内置的数据结构,可以直接使用并且代码可读性高
  2. 性能较高: 因为路由写入操作只会发生一次 (注册时),后续的操作全部是读取操作,基于标准库的 map 性能已经足够优秀

同时,该方案的不足也是显而易见的:

  1. 内存浪费: 即使存在很多前缀相同的路径 (例如 /users, /users/list, /users/dbwu, 三个路径的前缀都是 /users, 这部分是可以复用的),map 结构还是会每个路径单独映射,浪费大量的内存
  2. 不够灵活: 难以处理动态路由和正则表达式匹配等复杂的路径 (例如 /users/:id 或 /users/{id:[0-9]+})
  3. 无法处理重复路径:如果多个处理方法绑定到相同的路径上 (例如 GET /users 和 POST /users),map 只能存储一个键值对,也就是只有最后一个注册的处理函数会被调用
  4. 不支持中间件:map 结构不支持中间件,这在现代 Web 开发中几乎是不可接受的

基于以上特点,在真实的项目开发中不会使用 map[string]func() 作为路由的实现数据结构。

Trie Tree

Trie Tree 也称为字典树或前缀树,是一种用于高效存储和检索、用于从某个集合中查到某个特定 key 的数据结构。 这些 key 通常是字符串,节点之间的父子关系不是由整个 key 定义,而是由 key 中的单个字符定义。 对某个 key 对应的元素进行相关操作 (写入、更新、删除) 就是一次 DFS (深度优先遍历) 过程。

算法复杂度

  • N: 字符串的数量
  • M: 字符串的平均长度
  • L: 字符串的长度
空间复杂度
O(NM)

 

操作 时间复杂度
插入 O(L)
查找 O(L)
删除 O(L)

Trie Tree 的核心思想是空间换时间,利用字符串的公共前缀来减少字符比较操作,提升查询效率。

图示

图片来源: https://theoryofprogramming.wordpress.com/2015/01/16/trie-tree-implementation/

如图所示,是一个典型的 Trie Tree, 其中包含了如下元素:

"their", "there", "this", "that", "does", "did"

本文不再描述算法的具体操作过程了,读者可以通过代码来感受一下,如果希望抓住细节,可以阅读维基百科的介绍, 或者通过 这个可视化在线工具 来手动操作体验。

实现代码

首先写一个基础版的 Trie Tree 代码,对算法本身做一个初步认识。

package trie

// Trie Tree 节点
type Trie struct {
	// 标记当前节点是否为有效的路由
	// 例如添加了路由 /users
	// 那么 /user, /usr 不能算作有效的路由
	// 也就是只有字符 "s" 节点的 IsPath 字段为 true
	IsPath bool

	// 当前节点的子节点
	Children map[byte]*Trie
}

func New() Trie {
	return Trie{false, make(map[byte]*Trie)}
}

// Add 添加一个路由到 Trie Tree
func (t *Trie) Add(path string) {
	parent := t
	// 逐个 byte 加入到 Trie Tree
	for i := range path {
		if child, ok := parent.Children[path[i]]; ok {
			// 如果子节点不为空,继续向下遍历
			parent = child
		} else {
			// 如果子节点为空,构造新的节点
			newChild := &Trie{false, make(map[byte]*Trie)}
			parent.Children[path[i]] = newChild
			parent = newChild
		}
	}

	// 更新当前路由的叶子节点的 IsPath 字段
	parent.IsPath = true
}

// Find 返回指定路由是否存在于 Trie Tree 中
func (t *Trie) Find(path string) bool {
	parent := t
	for i := range path {
		if child, ok := parent.Children[path[i]]; ok {
			parent = child
		} else {
			return false
		}
	}
	return parent.IsPath
}

然后对上面的实现代码做一个简单的小测试:

package trie

import "testing"

func TestTrie(t *testing.T) {
	trieTree := New()

	if got := trieTree.Find("hello"); got != false {
		t.Errorf("Get() = %v, want %v", got, false)
	}

	trieTree.Add("hello")

	if got := trieTree.Find("hello"); got != true {
		t.Errorf("Get() = %v, want %v", got, true)
	}
	if got := trieTree.Find("he"); got != false {
		t.Errorf("Get() = %v, want %v", got, false)
	}

	trieTree.Add("he")
	if got := trieTree.Find("he"); got != true {
		t.Errorf("Get() = %v, want %v", got, true)
	}
}

实现路由管理

现在,我们将刚才的 “算法部分” 代码配合标准库提供的 API 代码,完成一个基础版的路由管理功能。

package main

import (
	"fmt"
	"log"
	"net/http"
)

// Router 节点
type Router struct {
	Path   string
	Method string

	// 标记当前节点是否为有效的路由
	// 例如添加了路由 /users
	// 那么 /user, /usr 不能算作有效的路由
	// 也就是只有字符 "s" 节点的 IsPath 字段为 true
	IsPath bool

	// 当前节点的子节点
	Children map[byte]*Router

	// 路由处理方法
	Handler http.HandlerFunc
}

func NewRouter() *Router {
	return &Router{IsPath: false, Children: make(map[byte]*Router)}
}

// Add 添加一个路由到 Router
func (r *Router) Add(method, path string, handler http.HandlerFunc) {
	parent := r
	// 逐个 byte 加入到 Router Tree
	for i := range path {
		if child, ok := parent.Children[path[i]]; ok {
			// 如果子节点不为空,继续向下遍历
			parent = child
		} else {
			// 如果子节点为空,构造新的节点
			newChild := NewRouter()
			parent.Children[path[i]] = newChild
			parent = newChild
		}
	}

	parent.Method = method
	parent.Handler = handler

	// 更新当前路由的叶子节点的 IsPath 字段
	parent.IsPath = true
}

// Find 返回指定路由是否存在于 Router 中
func (r *Router) Find(method, path string) (http.HandlerFunc, bool) {
	parent := r

	for i := range path {
		if child, ok := parent.Children[path[i]]; ok {
			parent = child
		} else {
			return nil, false
		}
	}

	return parent.Handler, parent.IsPath && parent.Method == method
}

// 实现 http.Handler 接口
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	handler, ok := r.Find(req.Method, req.URL.Path)
	if ok {
		handler(w, req)
	} else {
		http.NotFound(w, req)
	}
}

// 处理所有路由的方法
// 输出请求 Method 和 URL
func allHandler(w http.ResponseWriter, req *http.Request) {
	_, _ = fmt.Fprintln(w, req.Method, req.URL)
}

func main() {
	r := NewRouter()

	r.Add("GET", "/hello", allHandler)
	r.Add("GET", "/users/list", allHandler)

	log.Fatal(http.ListenAndServe(":8080", r))
}

为了节省篇幅,这里就不写测试代码了,下面进行几个简单的测试:

# 启动服务
$ go run main.go

# 测试两个正常的 URL

$ curl 127.0.0.1:8080/hello

# 输出如下
GET /hello

$ curl 127.0.0.1:8080/users/list

# 输出如下
GET /users/list

# 测试两个不存在的 URL

$ curl 127.0.0.1:8080

# 输出如下
404 page not found

$ curl 127.0.0.1:8080/users/123456

# 输出如下
404 page not found

优点

Trie Tree 时间复杂度低,和一般的树形数据结构相比,Trie Tree 拥有更快的前缀搜索和查询性能,和查询时间复杂度为 O(1) 常数的哈希算法相比, Trie Tree 支持前缀搜索,并且可以节省哈希函数的计算开销和避免哈希值碰撞的情况,最后,Trie Tree 还支持对关键字进行字典排序。

适用场景

  • 排序 : 一组字符串 key 的字典排序,可以通过为给定 key 构建一个 Trie Tree, 然后通过前序方式遍历树来实现, burstsort 是 2007 年最快的字符串排序算法,其基础数据结构就是 Trie Tree
  • 全文索引: 通过一种特殊的 Trie Tree 实现,一般称为后缀树,可用于索引文本中的所有后缀以执行快速全文搜索
  • 搜索引擎: 当你在搜索引擎的输入框中输入关键字时,自动补全的提示信息
  • 生物信息: 基因序列对比软件
  • 路由管理: 网络 IP 路由表,Web 中的 HTTP Router 管理

不适用场景

  • 字符串公共前缀太少,造成 Trie Tree 节点稀疏分布,这时哈希表是更好的选择
  • 节点之间的父子节点使用指针连接,对 CPU 和自带 GC 语言不太友好
  • 字符集过大会造成过多的存储空间占用 (Trie Tree 是空间换时间)
  • 字符串过长会使 Trie Tree 深度变大,这时应该使用接下来讲到的 Radix Tree

Radix Tree

Radix Tree(基数树)是一种特殊的数据结构,用于高效地存储和搜索字符串键值对,它是一种基于前缀的树状结构,通过将相同前缀的键值对合并在一起来减少存储空间的使用。 Radix Tree 的关键思想是利用公共前缀来合并节点,每个节点代表一个字符,从根节点到叶子节点的路径即为一个字符串键,每个节点上存储着一个字符串的部分子串,并且每个节点可以代表多个键值对。

算法复杂度

  • N: 字符串的数量
  • M: 字符串的平均长度
  • L: 字符串的长度
空间复杂度
O(NM)

注意: Radix Tree 的使用场景是树中有较多节点拥有相同前缀,所以即使和 Trie Tree 的空间复杂度一样,但是实际应用中,Radix Tree 通过压缩公共前缀, 空间使用要比 Trie Tree 节省很多。

操作 时间复杂度
插入 O(L)
查找 O(L)
删除 O(L)

操作示例

下面引用维基百科页面上的示例图来说明 Radix Tree 的操作过程。

初始状态下,树中包含两个节点,分别是字符串 test 和 slow。

1. 将字符串 water 插入到树中

图片来源: https://en.wikipedia.org/wiki/Radix_tree

因为字符串 water 和已有的两个节点没有公共前缀,所以直接插入到新的节点中。

2. 将字符串 slower 插入到树中

图片来源: https://en.wikipedia.org/wiki/Radix_tree

字符串 slower 和已有的节点 slow 存在公共前缀 slow, 所以放在 slow 节点下面。

3. 将字符串 tester 插入到树中

图片来源: https://en.wikipedia.org/wiki/Radix_tree

字符串 tester 和已有的节点 test 存在公共前缀 test, 所以放在 test 节点下面。

4. 将字符串 team 插入到树中

图片来源: https://en.wikipedia.org/wiki/Radix_tree

字符串 team 和已有的节点 test 存在公共前缀 te, 将 test 节点分裂为 st 和 am 两个子节点,两个子节点的父节点为 te。

5. 将字符串 toast 插入到树中

图片来源: https://en.wikipedia.org/wiki/Radix_tree

字符串 toast 和已有的节点 te 存在公共前缀 t, 将 te 节点分裂为 e 和 oast 两个子节点,两个子节点的父节点为 t, 将 te 原来的两个子节点放在 e 节点下面。

实现代码

笔者选择了开源的组件库 httprouter 来作为代码实现的学习方案,选择这个组件的主要原因有两点:

  1. 该组件专注于路由管理,代码量少且结构简单,涉及到 Radix Tree 数据结构和算法的代码已经独立到一个文件中,这可以大大降低我们阅读源代码的压力和时间成本
  2. 很多 Web 框架中的路由管理功能都是基于该组件实现的,比如非常流行的 Gin

Web Frameworks based on HttpRouter

httprouter 提供的 API 非常简洁,例如下面是一个简单的 Demo :

package main

import (
    "net/http"
    "log"

    "github.com/julienschmidt/httprouter"
)

func main() {
    router := httprouter.New()
    router.GET("/", someHandle)
    router.GET("/hello/:name", someHandle)

    log.Fatal(http.ListenAndServe(":8080", router))
}

接下来我们分为两部分来学习 httprouter 组件的源代码实现:

  1. Radix Tree 数据结构和算法的实现
  2. 基于 Radix Tree 的路由管理功能实现

httprouter UML

💡 重要提示: 下文中的代码和注释内容较多,读者如果时间有限,可以快速浏览一下核心对象及方法和文末的总结,在需要了解细节时再回来阅读。

Radix Tree 实现

工作原理简述

路由管理功能中,底层的数据结构是使用公共前缀的树形结构,也就是基数树。在该数据结构中,所有具有公共前缀的节点共享同一个父节点, 下面是一个关于这种数据结构的示例 (请求方法为 GET)。

Priority   Path             Handle
9          \                *<1>
3          ├s               nil
2          |├earch\         *<2>
1          |└upport\        *<3>
2          ├blog\           *<4>
1          |    └:post      nil
1          |         └\     *<5>
2          ├about-us\       *<6>
1          |        └team\  *<7>
1          └contact\        *<8>

对上面的结构图做一个简单的说明:

  • *<数字> 代表 Handler 方法指针
  • search 节点和 support 节点拥有共同的父节点 s ,并且 s 没有对应的 Handle, 只有叶子节点才有 Handler
  • 从 root 节点开始,一直到叶子节点,算作一条路由的完整路径
  • 路径搜索的顺序是从上向下 -> 从左到右,为了快速找到尽可能多的路由,子节点越多的节点优先级越高

将上面的结构图转换代码:

router.GET("/", func1)
router.GET("/search/", func2)
router.GET("/support/", func3)
router.GET("/blog/:post/", func5)
router.GET("/about-us/", func6)
router.GET("/about-us/team/", func7)
router.GET("/contact/", func8)

node 对象

node 对象表示 Radix Tree 的单个节点。

// 节点类型
type nodeType uint8

const (
	static nodeType = iota 
	root        // 根节点 
	param       // 参数节点 (例如 /users/:id)
	catchAll    // 通用节点,匹配任意参数 (例如 /users/*logs)
)

type node struct {
	path      string    // 路径
	wildChild bool      // 是否为参数节点
	nType     nodeType  // 节点类型
	maxParams uint8     // 路径参数个数上限
	priority  uint32    // 优先级
	indices   string    // 导致当前节点和其子节点分裂的首个字符 (wildChild == true 时, indices == "")
	children  []*node   // 子节点
	handle    Handle    // 路由处理方法
}

添加路由

addRoute 方法将给定的路由处理方法绑定到具体的 Path 上面,该方法不是并发安全的,也就是需要通过加锁等机制将操作串行化。

为了最大化提升读者的阅读体验和效率,这里将代码剖析注解直接贴在源代码中。

func (n *node) addRoute(path string, handle Handle) {
	fullPath := path
	// 当前节点优先级 + 1
	n.priority++
	// 计算路径中的参数个数
	numParams := countParams(path)

	// non-empty tree
	if len(n.path) > 0 || len(n.children) > 0 {
		// 说明当前 Radix Tree 已经存在节点了
		// 接下来进入 walk 循环标签
	walk:
		for {
			// 更新当前节点最大参数个数
			if numParams > n.maxParams {
				n.maxParams = numParams
			}

			// 查找当前节点路径和参数路径的最长公共前缀
			// 公共前缀中不能包含 ':'  '*' 通配符 (因为这样就无法区分具体的路径了)
			// PS: 查找最长公共前缀应该独立为一个函数,提高代码可读性,编译器会自动内联优化
			//     Gin 框架中已经独立为一个函数 longestCommonPrefix 
			//     https://github.com/gin-gonic/gin/blob/d4a64265f21993368c90602c18e778bf04ef36db/tree.go#L75C6-L75C6
			i := 0
			max := min(len(path), len(n.path))
			for i < max && path[i] == n.path[i] {
				i++
			}

			// 如果最长公共前缀长度小于当前节点路径长度
			// 这意味着可能是下列两种情况之一 :
			//    1. 最长公共前缀 > 0, 例如当前节点路径为 /users, 参数路径为 /usr
			//    2. 最长公共前缀 == 0, 例如当前节点路径为 /users, 参数路径为 /books
			// 此时当前节点就需要分裂成两个节点
			if i < len(n.path) {
				// 将当前节点路径中非 “公共前缀” 部分独立到一个新的子节点中
				child := node{
					// 路径为当前节点路径中非 “公共前缀” 部分
					path:      n.path[i:],
					// 类型待定, 没有 handler
					nType:     static,
					indices:   n.indices,
                    children:  n.children,
                    handle:    n.handle,
					// 优先级 - 1
					priority:  n.priority - 1,
				}

				// 遍历新的子节点的所有子节点 (也就是当前节点的所有子节点)
				// 更新当前节点最大参数个数
				for i := range child.children { 
					if child.children[i].maxParams > child.maxParams {
						child.maxParams = child.children[i].maxParams
					}
				}

				// 将新节点作为当前节点的子节点 (当前节点之前的子节点已经被挂载到了新节点的子节点上面)
				n.children = []*node{&child}
				
				// 获取导致当前节点和其子节点分裂的首个字符,并更新 indices 字段
				// (例如 /users/dbwu 和 /users/dbwt, 更新 indices 字段为 "u")
				n.indices = string([]byte{n.path[i]})
				
				// 更新当前节点的路径为公共前缀
				n.path = path[:i]
				// 删除当前节点的路由处理方法
				n.handle = nil
				// 删除当前节点的参数节点标志
				n.wildChild = false
			}

			// 如果最长公共前缀长度小于参数路径长度
			// 为什么这样判断呢?
			//    因为如果最长公共前缀长度大于等于参数路径长度
			//    说明参数路径本身就是公共前缀的一部分,就没必要插入这个新节点了
			// 将新节点插入到当前节点的子节点
			if i < len(path) {
				// 删除公共前缀部分,剩余部分的 path 是子节点的路径
				path = path[i:]

				// 如果当前节点是参数节点 (例如 /users/:id)
				if n.wildChild {
					// 代码执行到这里说明: 最长公共前缀长度大于等于当前节点路径长度
					// 也就是说: 参数路径的长度要大于当前节点路径长度
					// (例如 当前节点路径为 /users/:id, 参数路径为 /users/:id/profile)
					
					
					// 获取到当前节点的第一个子节点
					n = n.children[0]
					// 当前节点要插入一个新节点,优先级 + 1
					n.priority++

					// 更新当前节点最大参数个数
					if numParams > n.maxParams {
						n.maxParams = numParams
					}
					numParams--

					// 检测通配符模式是否匹配
					if len(path) >= len(n.path) && n.path == path[:len(n.path)] &&
						n.nType != catchAll &&
						// 检测更长的通配符匹配
						// (例如当前节点的 path = name, 子节点的路径是 path = names)
						(len(n.path) >= len(path) || path[len(n.path)] == '/') {
						
						// 如果通配符模式匹配,进入下一次循环
						continue walk
					} else {
						// 通配符发生了冲突
						// (例如当前节点的 path = /users/:id/profile, 子节点的路径是 path = /users/:name/profile)
						var pathSeg string
						
						// 如果当前节点类型是通用节点 (通配符类型)
						if n.nType == catchAll {
							pathSeg = path
						} else {
							// 如果当前节点类型不是通用节点
							// 将 path 字符串进行分割
							// (例如 path = /users/:id/profile, 分割之后 pathSeg = profile)
							pathSeg = strings.SplitN(path, "/", 2)[0]
						}
						
						// 提取出参数 path 的前缀
						// (例如 fullPath = /users/:id/profile, 当前节点 path = :id, prefix = /user/:id)
						prefix := fullPath[:strings.Index(fullPath, pathSeg)] + n.path
						
						// 直接抛出一个 panic
						// 信息中会输出产生冲突的具体通配符 path
						panic("'" + pathSeg +
							"' in new path '" + fullPath +
							"' conflicts with existing wildcard '" + n.path +
							"' in existing prefix '" + prefix +
							"'")
					}
				}
				
				c := path[0]

				// 如果当前节点是一个参数节点并且只有一个子节点 并且参数 path 是以 "/" 开头的
				// (例如当前节点 path = /:name/profile, 参数 path = /:name/setting)
				if n.nType == param && c == '/' && len(n.children) == 1 {
					// 将当前节点修改为其第一个子节点
					n = n.children[0]
					// 优先级 + 1
					n.priority++
					// 进入下一次循环
					continue walk
				}

				// 检测新增子节点的 path 的首字母是否存在于当前节点的 indices 字段中
				for i := 0; i < len(n.indices); i++ {
					if c == n.indices[i] {
						// 更新子节点的优先级
						i = n.incrementChildPrio(i)
						// 更新当前节点为对应的子节点
						//  (
						//    例如当前节点 path = p, 包含两个子节点 rofile, assword)
						//    此时 indices 字段就包含字符 r 和 a, 正好和两个子节点相对应
						//    新增子节点的 path = projects, 删除和当前节点的公共前缀符 p 后,变成了 rojects 
						//    然后当前节点会被更新为 rofile 子节点
						//    最后,跳转到下一次循环,然后拿 rofile 和 rojects 进行对比
						//  )
						n = n.children[i]
						
						// 进入下一次循环
						continue walk
					}
				}

				// 如果上面的条件都不满足
				// 直接将新的子节点插入
				if c != ':' && c != '*' {
					// 如果参数 path 的第一个字符不是通配符
					// 将第一个字符追加到当前节点的 indices 字段 
					n.indices += string([]byte{c})
					
					// 初始化一个新的子节点
					child := &node{
						maxParams: numParams,
					}
					
					// 将新的子节点追加到当前节点的子节点列表中
					n.children = append(n.children, child)
                    // 更新子节点的优先级 
					n.incrementChildPrio(len(n.indices) - 1)
					// 更新当前节点为新增的子节点
					n = child
				}
				
				n.insertChild(numParams, path, fullPath, handle)
				return

			} else if i == len(path) { 
				// 如果公共前缀和参数 path 长度相同
				// 说明参数 path 本身就是当前节点 path 的一部分
				if n.handle != nil {
					// 如果当前节点已经注册了路由处理方法
					// 那么再次注册时等于重复注册
					// 直接抛出 panic
					panic("a handle is already registered for path '" + fullPath + "'")
				}
				
				// 如果当前节点没有注册路由处理方法
				// 说明当前节点是作为其子节点的公共前缀符而存在的
				// (例如 当前节点 path = p, 包含两个子节点 rofile, assword))
				n.handle = handle
			}
			return
		}
	} else { 
		// 如果当前节点是一个空节点
		// 说明当前 Radix Tree 没有任何节点
		// 直接插入一个新节点
		// 并且将插入的新节点类型标记为 root
		// PS: 笔者认为这两行边界检测代码应该放在函数开头部分,提高可读性
		n.insertChild(numParams, path, fullPath, handle)
		n.nType = root
	}
}

incrementChildPrio 方法用于更新给定子节点的优先级,并在必要的时候进行排序。

func (n *node) incrementChildPrio(pos int) int {
	// 将对应索引的子节点的优先级 + 1
	n.children[pos].priority++
	prio := n.children[pos].priority

    // 向前移动位置
	// (例如原本的子节点顺序为 [a, b, c, d], 此时传入参数 2, 移动之后的子节点顺序为 [c, a, b, d])
	newPos := pos
	for newPos > 0 && n.children[newPos-1].priority < prio {
		n.children[newPos-1], n.children[newPos] = n.children[newPos], n.children[newPos-1]

		newPos--
	}

	// 更新当前节点的 indices 字段信息
	// (例如原本的 indices 字段为 abcd, 此时传入参数 2, 移动之后的 indices 字段为 cabd
	if newPos != pos {
		n.indices = n.indices[:newPos] + // unchanged prefix, might be empty
			n.indices[pos:pos+1] + // the index char we move
			n.indices[newPos:pos] + n.indices[pos+1:] // rest without char at 'pos'
	}
	
	// 返回移动后的新的位置
	return newPos
}

insertChild 方法负责执行将具体的 path 插入到节点中。

func (n *node) insertChild(numParams uint8, path, fullPath string, handle Handle) {
	// 参数 path 已经处理完的计算数量 
	var offset int
	
	// 查找前缀直到第一个参数匹配或者通配符 (以 ':' 或 '*' 开头)
	// 参数匹配 (类似这种形式 :name)
	// 通配符 (类似这种形式 *logs)
	// 为了便于描述,下文中统一将 参数匹配符号 和 通配符号 统称为 Token
	for i, max := 0, len(path); numParams > 0; i++ {
		c := path[i]
		if c != ':' && c != '*' {
			// 如果不是 ':' 或 '*', 直接跳过
			continue
		}

		// 查找当前 Token 结束符, 也就是下一个 '/'
		// (例如 Token 为 /:name/profile, 查找的就是 :name 后面的 / 符号)
		end := i + 1
		for end < max && path[end] != '/' {
			switch path[end] {
			// 如果 Token 中嵌套 Token,直接 panic
            // (例如 /:name:id)
			case ':', '*':
				panic("only one wildcard per path segment is allowed, has: '" +
					path[i:] + "' in path '" + fullPath + "'")
			default:
				end++
			}
		}

		// 如果 Token 所在的索引位置已经有节点了,直接 panic 
		// (例如已经存在了节点 path = /users/dbwu, 就不能再定义 path = /user/:name)
		if len(n.children) > 0 {
			panic("wildcard route '" + path[i:end] +
				"' conflicts with existing children in path '" + fullPath + "'")
		}

		// Token 至少需要一个字符表示的参数名称, 否则直接 panic
		// (例如 :name, :id 中的 name 和 id 就是 Token 的参数名称)
		if end-i < 2 {
			panic("wildcards must be named with a non-empty name in path '" + fullPath + "'")
		}

		// 如果 Token 的类型是参数匹配符
		if c == ':' {
			// 将当前节点的位置更新为 从 offset 到当前索引位置之间的 path,
			if i > 0 {
				n.path = path[offset:i]
				// 更新 offset
				offset = i
			}

			// 根据参数初始化一个新的子节点
			child := &node{
				nType:     param,
				maxParams: numParams,
			}
			
			// 更新当前节点的子节点
			n.children = []*node{child}
			// 更新当前节点类型为参数节点
			n.wildChild = true
			// 当前节点下降为子节点,继续后面的路由处理
			n = child
			
			// 当前节点优先级 + 1
			n.priority++
			// 最大参数个数 - 1
			numParams--

			// 如果 Token 结束符比参数 path 长度要小
			// 说明参数 path 中还有子路径
			if end < max {
				// 更新当前节点 path 
				n.path = path[offset:end]
				// 更新 offset
				offset = end

				// 初始化一个新节点作为参数 path 中的子路径节点
				child := &node{
					maxParams: numParams,
					priority:  1,
				}
				// 更新当前节点的子节点
				n.children = []*node{child}
				// 当前节点下降为子节点,继续后面的路由处理
				n = child
			}

		} else { // 如果 Token 的类型是通配符
            
			if end != max || numParams > 1 {
				// 通配符类型的路径必须位于路由 path 的最后一部分,否则直接 panic
				// (
				//   例如 /users/*name 是合法的,但是 /users/*name/profile 不是合法的,因为这样就无法区分其他路由了
				//   例如 path = /users/dbwu/profile 是一个单独的 path, 但是却匹配了 /users/*name 这个 path
				//   因此获取到的参数 name = "dbwu/profile"
				//   所以不会再将后面的 /dbwu/profile 作为单独路径来处理了
				// )
				panic("catch-all routes are only allowed at the end of the path in path '" + fullPath + "'")
			}
			
			if len(n.path) > 0 && n.path[len(n.path)-1] == '/' {
				// 新定义的通配符和已有的通配符冲突了,直接 panic
				// (例如一个已有的 path = /users/dbwu, 新定义的 path = /users/:name, 如果不终止,后者就会覆盖前者)
				panic("catch-all conflicts with existing handle for the path segment root in path '" + fullPath + "'")
			}

			i--
			if path[i] != '/' {
				// 如果通配符前面没有 '/', 直接 panic
				panic("no / before catch-all in path '" + fullPath + "'")
			}

			n.path = path[offset:i]
			
			// 初始化一个新的子节点,类型为通配符节点
			child := &node{
				wildChild: true,
				nType:     catchAll,
				maxParams: 1,
			}
			// 更新当前节点的最大参数个数
			if n.maxParams < 1 {
				n.maxParams = 1
			}
			// 更新当前节点的子节点
			n.children = []*node{child}
			// 更新当前节点的 indices 字段
			n.indices = string(path[i])
			// 当前节点下降为子节点,继续后面的路由处理
			n = child
			// 当前节点优先级 + 1
			n.priority++

			// 初始化一个新节点作为参数 path 中的剩余部分的节点
			child = &node{
				path:      path[i:],
				nType:     catchAll,
				maxParams: 1,
				handle:    handle,  // 绑定路由的处理函数
				priority:  1,
			}
			// 更新当前节点的子节点
			n.children = []*node{child}

			// 通配符类型的路径必须位于路由 path 的最后一部分
			// 直接返回即可
			return
		}
	}

	// 更新当前节点的 path
	n.path = path[offset:]
	// 更新当前节点的处理函数
	n.handle = handle
}

查找路由处理方法

getValue 方法根据指定的 path,查找并返回对应的路由处理方法。

返回值列表顺序如下:

  • handle : path 对应的处理方法,如果没有找到,返回 nil
  • p : 如果 path 对应的节点类型是参数节点,会将参数返回
  • tsr : 路由是否可以被重定向
func (n *node) getValue(path string) (handle Handle, p Params, tsr bool) {
	
	// 循环标签
walk: 
	for {
		// 如果要查找的 path 长度大于当前节点的 path 长度
		// 除了根路径 /, 其他 path 应该都可以满足这个条件
		if len(path) > len(n.path) {
			// 如果当前节点的 path 是要查找的 path 的前缀
			if path[:len(n.path)] == n.path {
				// 那么就将前缀去掉,查找剩余的部分
				path = path[len(n.path):]
				
				// 如果当前节点类型不是参数节点
				// 直接在其子节点中查找即可
				if !n.wildChild {
					// 通过要查找 path 的首字母快速匹配对应的子节点
					c := path[0]
					for i := 0; i < len(n.indices); i++ {
						if c == n.indices[i] {
							// 更新当前节点
							n = n.children[i]
							// 跳转到下一个循环
							continue walk
						}
					}

					// 代码执行到这里,说明没有匹配到子节点
					// 如果路径剩余的部分正好是 '/', 并且当前节点注册了路由处理方法
					// 更新 tsr 重定向标记为 true
					// (例如查找的 path = /users/, 当前节点 path = /users, 那么就重定向到 /users)
					tsr = (path == "/" && n.handle != nil)
					return
				}

				// 如果当前节点类型是参数节点或者通配符节点
				// 那么当前节点只会有一个子节点
				// 更新当前节点为其第一个子节点
				n = n.children[0]
				
				switch n.nType {
				// 如果当前节点类型是参数节点
				// 参数匹配 (类似这种形式 :name)
				case param:
					// 查找路径中的参数结束符或者 '/'
					end := 0
					for end < len(path) && path[end] != '/' {
						end++
					}

					if p == nil {
						// 惰性初始化参数返回值
						// 注意初始化的时候已经指定了切片容量
						p = make(Params, 0, n.maxParams)
					}
					
					// 为参数返回值赋值
					i := len(p)
					p = p[:i+1]
					p[i].Key = n.path[1:]
					p[i].Value = path[:end]

					// 如果路径还没有匹配完,
					if end < len(path) {
                        // 如果子节点下面还存在子节点
                        // (例如 path = /users/:name/:setting, 当前匹配到 :name, 发现其还有子节点)
                        if len(n.children) > 0 {
							// 更新查找路径
							path = path[end:]
							// 更新当前节点为其第一个子节点
							n = n.children[0]
							// 跳转到下一个循环
							continue walk
						}

						// 没有查到到对应到路由处理函数
						// 直接返回
						tsr = (len(path) == end+1)
						return
					}
					
					if handle = n.handle; handle != nil {
                        // 如果当前节点有对应到路由处理函数
                        // 直接返回
						return
					} else if len(n.children) == 1 {
						// 如果当前节点没有对应到路由处理函数
						// 确认其子节点是否为 '/' 并且子节点有对应到路由处理函数
						// 这样就可以进行进行重定向了
						n = n.children[0]
						tsr = (n.path == "/" && n.handle != nil)
					}

					return
					
                // 如果当前节点类型是通配符节点
				// 通配符 (类似这种形式 *logs)
				case catchAll:
					if p == nil {
                        // 惰性初始化参数返回值
                        // 注意初始化的时候已经指定了切片容量
						p = make(Params, 0, n.maxParams)
					}
					
					// 通配符不会有子节点,直接赋值后返回即可
					// 为参数返回值赋值
					i := len(p)
					p = p[:i+1]
					p[i].Key = n.path[2:]
					p[i].Value = path

					handle = n.handle
					return

				default:
					// 代码执行到这里
					// 说明当前节点的类型不在枚举范围内,直接 panic
					panic("invalid node type")
				}
			}
		} else if path == n.path {
			// 如果要查找的 path 等于当前节点的 path
			// 检测当前节点是否有路由处理函数,如果有的话,直接返回即可
			if handle = n.handle; handle != nil {
				return
			}

			// 如果要查找的 path 等于 / 并且当前节点类型不是 root, 并且当前节点是参数节点
			// 允许重定向
			if path == "/" && n.wildChild && n.nType != root {
				tsr = true
				return
			}

			// 当前节点没有路由处理函数,但是有一个子节点的路径为 '/', 这时允许重定向
			// (例如当前节点的 path = /users/, 但是查找的 path = /users, 此时就允许重定向到 /users/ 上面)
			for i := 0; i < len(n.indices); i++ {
				if n.indices[i] == '/' {
					n = n.children[i]
					tsr = (len(n.path) == 1 && n.handle != nil) ||
						(n.nType == catchAll && n.children[0].handle != nil)
					return
				}
			}

			return
		}

		// 没有查到到对应到路由处理函数
		// 根据条件更新是否允许重定向字段
		tsr = (path == "/") ||
			(len(n.path) == len(path)+1 && n.path[len(path)] == '/' &&
				path == n.path[:len(n.path)-1] && n.handle != nil)
		return
	}
}

路由管理实现

相比于上面 Radix Tree 的实现,路由管理功能实现要简单的多,这里分析一下核心的代码。

Router 对象

Router 对象表示全局的路由管理对象,可以将不同的 HTTP Method + Path 请求分发给不同的路由处理函数。

type Router struct {
	// 路由管理的核心字段,每个 HTTP Method 对应一个 Radix Tree 结构
	// 例如
	//   GET => Radix Tree
	//   POST => Radix Tree
	//   DELETE => Radix Tree
	//   ...
	trees map[string]*node

	// 是否启用请求的自动重定向
	// (例如当前请求为 /foo/, 但是路由管理注册的路径只有 /foo, 此时就将 /foo/ 重定向到 /fooo)
	// (重定向时,GET 请求的响应码为 301, 其他请求的为 307)
	RedirectTrailingSlash bool

	// 是否启用自动修复路径
	// 首先会删除路径中多余的部分,例如 ../ 和 //
	// 然后再次查找路径 (这一次不区分大小写),看是否可以匹配到对应的路径处理方法
	// 如果能匹配到路径,就进行重定向
	// (重定向时,GET 请求的响应码为 301, 其他请求的为 307)
    RedirectFixedPath bool
    
	// 是否启用 Method Not Allowed
	// 启用机制后,如果当前路径没有匹配到对应的路径处理方法
	//   查看其他当前路径是否注册了其他 HTTP 请求类型的路径处理方法
	//   如果注册了,返回 405 状态码
	// 如果没有开启机制,当前路径没有匹配到对应的路径处理方法
	//   返回 404 状态码
    HandleMethodNotAllowed bool
    
	// 是否启用 OPTIONS 请求响应
    HandleOPTIONS bool

    // 404 响应方法
	// 如果没有设置,就使用标准库的 404 方法
    NotFound http.Handler

	// 405 响应方法
	// 如果没有设置,就使用标准库的 Error 方法
	//   其中响应文本为 Method Not Allowed,状态码为 405
    MethodNotAllowed http.Handler

	// 用于捕获并处理 panic 错误的方法,返回错误码 500 (Internal Server Error)
	// 保证程序因为没有捕获 panic 而崩溃退出
    PanicHandler func(http.ResponseWriter, *http.Request, interface{})
}

// 通过编译期间保证 Router 必须实现 http.Handler 接口
var _ http.Handler = New()

// 创建并返回一个路由管理实例
func New() *Router {
	return &Router{
		RedirectTrailingSlash:  true,
		RedirectFixedPath:      true,
		HandleMethodNotAllowed: true,
		HandleOPTIONS:          true,
	}
}

注册路由处理方法

Router 提供了常见的 HTTP 请求路由处理方法注册的 API, 每个方法的内部都是调用了 Handle 方法,下面是 GET 方法和 POST 方法的实现代码。

// 注册 GET 请求处理方法
func (r *Router) GET(path string, handle Handle) {
	r.Handle(http.MethodGet, path, handle)
}

// 注册 POST 请求处理方法
func (r *Router) POST(path string, handle Handle) {
    r.Handle(http.MethodPost, path, handle)
}

...

Handle 方法将指定的 HTTP Method + Path 和路由处理方法进行绑定。

func (r *Router) Handle(method, path string, handle Handle) {
	if len(path) < 1 || path[0] != '/' {
		// 如果路径为空或者路径第一个字符不等于 '/'
		// 这种路径格式就是不合法的,直接 panic
		panic("path must begin with '/' in path '" + path + "'")
	}

	if r.trees == nil {
		// 初始化 trees 字段
		r.trees = make(map[string]*node)
	}

	root := r.trees[method]
	if root == nil {
		// 初始化参数 HTTP 方法对应的 Radix Tree 结构
		root = new(node)
		r.trees[method] = root

		r.globalAllowed = r.allowed("*", "")
	}

	// 添加路由的注册方法
	// 详情见前文对于 addRoute 方法的注解
	root.addRoute(path, handle)
}

ServeHTTP 实现

ServeHTTP 方法负责处理 HTTP 请求并返回响应,同时实现了标准库中的 http.Handler 接口。

// net/http/server.go
type Handler interface {
	ServeHTTP(ResponseWriter, *Request)
}
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	if r.PanicHandler != nil {
		// 将 panic 错误处理函数注册到 defer 函数
		defer r.recv(w, req)
	}

	// 获取当前请求路径 
	path := req.URL.Path

	// 检测当前请求方法是否存在对应的 Radix Tree 结构
	if root := r.trees[req.Method]; root != nil {
		if handle, ps, tsr := root.getValue(path); handle != nil {
			// 如果当前请求路径有对应的处理方法,直接调用即可
			handle(w, req, ps)
			return
		} else if req.Method != http.MethodConnect && path != "/" {
			// PS: 上面的 if 条件分支都已经直接返回了
			//     这里实在没必要再写一个 else 条件分支
			//     整个库的代码,可读性比较低 ~
			
			// 如果当前请求路径没有对应的处理方法
			// 将响应状态码设置为 301 (永久重定向,针对 GET 方法)
			code := 301 
			if req.Method != http.MethodGet {
				// 如果当前请求不是 GET 方法
				// 将响应状态码设置为 307 (临死重定向,针对非 GET 方法)
				// 为什么不使用 301 呢?
				// 因为 308 和 307 不允许请求从 POST 修改为 GET
				
				// Temporary redirect, request with same method
				// As of Go 1.3, Go does not support status code 308.
				code = 307
			}

			// 如果请求路径需要重定向并且路由支持重定向
			if tsr && r.RedirectTrailingSlash {
				// 如果路径的长度大于 1 并且路径是以 '/' 字符结尾的
				// 就将末尾的 '/' 字符删除
				if len(path) > 1 && path[len(path)-1] == '/' {
					req.URL.Path = path[:len(path)-1]
				} else {
					// 在路径的结尾加一个 '/' 字符
					req.URL.Path = path + "/"
				}
				// 返回重定向响应
				http.Redirect(w, req, req.URL.String(), code)
				return
			}

			// 如果当前请求路径没有对应的处理方法 并且 也没有符合规则的重定向条件
			// 尝试修复请求路径
			if r.RedirectFixedPath {
				// 去除路径中的多余部分并返回经过修正后是否有匹配的路径
				fixedPath, found := root.findCaseInsensitivePath(
					CleanPath(path),
					r.RedirectTrailingSlash,
				)
				if found {
					// 如果经过修正,可以找到匹配的路径
					// 直接重定向
					req.URL.Path = string(fixedPath)
					http.Redirect(w, req, req.URL.String(), code)
					return
				}
			}
		}
	}

	// 代码执行到了这里
	// 说明上面的几个条件都不符合,当前请求路径还是没有找到对应的处理方法
	if req.Method == http.MethodOptions && r.HandleOPTIONS {
		// 如果请求方法是 OPTIONS, 并且路由管理允许响应 OPTIONS
		// 返回一个 OPTIONS 响应
		if allow := r.allowed(path, http.MethodOptions); allow != "" {
			w.Header().Set("Allow", allow)
			if r.GlobalOPTIONS != nil {
				r.GlobalOPTIONS.ServeHTTP(w, req)
			}
			return
		}
	} else if r.HandleMethodNotAllowed { 
		// 如果请求方法不是 OPTIONS, 或者路由管理不允许响应 OPTIONS
		// 返回一个 405 响应
		if allow := r.allowed(path, req.Method); allow != "" {
			w.Header().Set("Allow", allow)
			if r.MethodNotAllowed != nil {
				r.MethodNotAllowed.ServeHTTP(w, req)
			} else {
				http.Error(w,
					http.StatusText(http.StatusMethodNotAllowed),
					http.StatusMethodNotAllowed,
				)
			}
			return
		}
	}

	if r.NotFound != nil {
		// 如果路由管理中注册了 404 处理函数
		// 直接调用
		r.NotFound.ServeHTTP(w, req)
	} else {
		// 如果路由管理中没有注册 404 处理方法
		// 调用标准库中的 404 处理方法
		http.NotFound(w, req)
	}
}

优点

Radix Tree 通过合并公共前缀来降低存储空间的开销,避免了 Trie Tree 字符串过长和字符集过大时导致的存储空间过多问题,同时公共前缀优化了路径层数,提升了插入、查询、删除等操作效率。

使用 Radix Tree 可以减少树的层高,同时每个节点上的数据存储通常比 Trie Tree 多,所以程序的 CPU 缓存局部性较好 (一个节点的 path 加载到 CPU 缓存就可以进行多个字符的操作)。

适用场景

  • 字典和前缀匹配 :适合用作字典或关联数组的底层数据结构,可以快速找到以给定前缀开头的所有键,例如 Redis 中的某个前缀 key
  • IP 路由表 :可以高效地存储和搜索 IP 地址及其对应的路由表信息
  • 文件系统和目录 :每个节点可以表示一个目录或文件,节点之间的关系通过共同的路径前缀进行连接
  • 编译器 :可以用于编译器中的符号表管理,符号表存储了程序中定义的变量、函数名和类型等信息,Radix Tree 可以快速查找和更新符号表项,支持高效的名称解析和类型检查

不适用场景

  • 字符串公共前缀太少,造成 Radix Tree 节点稀疏分布 (退化到 Trie Tree),这时哈希表是更好的选择 (这个问题在 Trie Tree 中同样存在)
  • 字符集过小,可以直接使用其他简单的数据结构
  • 动态数据集,也就是数据集需要频繁地进行插入和删除操作,由于 Radix Tree 的节点需要合并和路径重组,动态修改树结构可能引起较大的开销,在这种情况下,这时平衡二叉搜索树结构(AVL树、红黑树)更适合
  • 节点之间的父子节点使用指针连接,对 CPU 和自带 GC 语言不太友好 (这个问题在 Trie Tree 中同样存在)

Trie Tree 和 Radix Tree 对比

下面是插入 4 个单词 [PLAN, PLAY, POLL, POST] 后的结果 (粗体字符表示节点字符,圆圈内字符串表示该节点表示的值)。

图片来源: https://subscription.packtpub.com/

小结

本文从开发中常见的 “路由管理” 功能为出发点,探索了实现背后用到的数据结构和算法,并先后分析了各解决方案的优化过程,分别是 Go 标准库、手动实现 Trie Tree、开源的 Radix Tree, 希望读者在读完文章后,能准确地理解 Trie Tree 和 Radix Tree 的差异及其适用场景,如果在此基础上还有兴趣和时间,可以深入了解下由这两种数据结构演变出的其他数据结构和算法。

Reference

转载申请

本作品采用 知识共享署名 4.0 国际许可协议 进行许可,转载时请注明原文链接,图片在使用时请保留全部内容,商业转载请联系作者获得授权。