HTTP Router 算法演進
概述
本文從開發中常見的應用場景 “路由管理” 爲例,介紹三種常用的實現方案背後的數據結構和算法 (代碼實現爲 Go 語言)。
應用示例
下面是一個典型的 REST 風格的 API 列表:
上面的 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()
作爲路由的數據結構,最明顯的優點就是:
-
實現簡單: map 是標準庫內置的數據結構,可以直接使用並且代碼可讀性高
-
性能較高: 因爲路由寫入操作只會發生一次 (註冊時),後續的操作全部是讀取操作,基於標準庫的 map 性能已經足夠優秀
同時,該方案的不足也是顯而易見的:
-
內存浪費: 即使存在很多前綴相同的路徑 (例如 /users, /users/list, /users/dbwu, 三個路徑的前綴都是 /users, 這部分是可以複用的),map 結構還是會每個路徑單獨映射,浪費大量的內存
-
不夠靈活: 難以處理動態路由和正則表達式匹配等複雜的路徑 (例如 /users/:id 或 /users/{id:[0-9]+})
-
無法處理重複路徑:如果多個處理方法綁定到相同的路徑上 (例如 GET /users 和 POST /users),map 只能存儲一個鍵值對,也就是隻有最後一個註冊的處理函數會被調用
-
不支持中間件:map 結構不支持中間件,這在現代 Web 開發中幾乎是不可接受的
基於以上特點,在真實的項目開發中不會使用 map[string]func()
作爲路由的實現數據結構。
Trie Tree
Trie Tree 也稱爲字典樹或前綴樹,是一種用於高效存儲和檢索、用於從某個集合中查到某個特定 key 的數據結構。 這些 key 通常是字符串,節點之間的父子關係不是由整個 key 定義,而是由 key 中的單個字符定義。 對某個 key 對應的元素進行相關操作 (寫入、更新、刪除) 就是一次 DFS (深度優先遍歷) 過程。
算法複雜度
-
N: 字符串的數量
-
M: 字符串的平均長度
-
L: 字符串的長度
Trie Tree 的核心思想是空間換時間,利用字符串的公共前綴來減少字符比較操作,提升查詢效率。
圖示
圖片來源: https://theoryofprogramming.wordpress.com/2015/01/16/trie-tree-implementation/
如圖所示,是一個典型的 Trie Tree, 其中包含了如下元素:
"their", "there", "this", "that", "does", "did"
本文不再描述算法的具體操作過程了,讀者可以通過代碼來感受一下,如果希望抓住細節,可以閱讀維基百科的介紹, 或者通過 這個可視化在線工具 [1] 來手動操作體驗。
實現代碼
首先寫一個基礎版的 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: 字符串的長度
注意: Radix Tree 的使用場景是樹中有較多節點擁有相同前綴,所以即使和 Trie Tree 的空間複雜度一樣,但是實際應用中,Radix Tree 通過壓縮公共前綴, 空間使用要比 Trie Tree 節省很多。
操作示例
下面引用維基百科頁面上的示例圖來說明 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[2] 來作爲代碼實現的學習方案,選擇這個組件的主要原因有兩點:
-
該組件專注於路由管理,代碼量少且結構簡單,涉及到 Radix Tree 數據結構和算法的代碼已經獨立到一個文件中,這可以大大降低我們閱讀源代碼的壓力和時間成本
-
很多 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 組件的源代碼實現:
-
Radix Tree 數據結構和算法的實現
-
基於 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 字符串過長和字符集過大時導致的存儲空間過多問題,同時公共前綴優化了路徑層數,提升了插入、查詢、刪除等操作效率。
適用場景
-
字典和前綴匹配 :適合用作字典或關聯數組的底層數據結構,可以快速找到以給定前綴開頭的所有鍵,例如 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
-
Radix tree[3]
-
Trie tree[4]
-
httprouter[5]
-
gin[6]
-
Redis Rax[7]
-
Rax, an ANSI C radix tree implementation[8]
鏈接
[1] 這個可視化在線工具: https://www.cs.usfca.edu/~galles/visualization/Trie.html
[2] httprouter: https://github.com/julienschmidt/httprouter
[3] Radix tree: https://en.wikipedia.org/wiki/Radix_tree
[4] Trie tree: https://en.wikipedia.org/wiki/Trie
[5] httprouter: https://github.com/julienschmidt/httprouter
[6] gin: https://github.com/gin-gonic/gin/
[7] Redis Rax: https://github.com/redis/redis/blob/6.0.14/src/rax.h
[8] Rax, an ANSI C radix tree implementation: https://github.com/antirez/rax
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/Vnb5akB5b5kuOt7ZrZX-EQ