HTTP Router 算法演進

概述

本文從開發中常見的應用場景 “路由管理” 爲例,介紹三種常用的實現方案背後的數據結構和算法 (代碼實現爲 Go 語言)。

應用示例

下面是一個典型的 REST 風格的 API 列表:

kwvK8R

上面的 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 (深度優先遍歷) 過程。

算法複雜度

PMAdBT

KERgJI

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 還支持對關鍵字進行字典排序。

適用場景

不適用場景

Radix Tree

Radix Tree(基數樹)是一種特殊的數據結構,用於高效地存儲和搜索字符串鍵值對,它是一種基於前綴的樹狀結構,通過將相同前綴的鍵值對合並在一起來減少存儲空間的使用。 Radix Tree 的關鍵思想是利用公共前綴來合併節點,每個節點代表一個字符,從根節點到葉子節點的路徑即爲一個字符串鍵,每個節點上存儲着一個字符串的部分子串,並且每個節點可以代表多個鍵值對。

算法複雜度

2yQow8

注意: Radix Tree 的使用場景是樹中有較多節點擁有相同前綴,所以即使和 Trie Tree 的空間複雜度一樣,但是實際應用中,Radix Tree 通過壓縮公共前綴, 空間使用要比 Trie Tree 節省很多。

SpYUtj

操作示例

下面引用維基百科頁面上的示例圖來說明 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] 來作爲代碼實現的學習方案,選擇這個組件的主要原因有兩點:

  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>

對上面的結構圖做一個簡單的說明:

將上面的結構圖轉換代碼:

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,查找並返回對應的路由處理方法。

返回值列表順序如下:

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 字符串過長和字符集過大時導致的存儲空間過多問題,同時公共前綴優化了路徑層數,提升了插入、查詢、刪除等操作效率。

適用場景

不適用場景

Trie Tree 對比 Radix Tree

下面是插入 4 個單詞 [PLAN, PLAY, POLL, POST] 後的結果 (粗體字符表示節點字符,圓圈內字符串表示該節點表示的值)。

圖片來源: https://subscription.packtpub.com/

小結

本文從開發中常見的 “路由管理” 功能爲出發點,探索了實現背後用到的數據結構和算法,並先後分析了各解決方案的優化過程,分別是 Go 標準庫、手動實現 Trie Tree、開源的 Radix Tree, 希望讀者在讀完文章後,能準確地理解 Trie Tree 和 Radix Tree 的差異及其適用場景,如果在此基礎上還有興趣和時間,可以深入瞭解下由這兩種數據結構演變出的其他數據結構和算法。

Reference

鏈接

[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