一步一步分析Gin框架路由原始碼及radix tree基數樹

九卷發表於2022-03-06

Gin 簡介

Gin is a HTTP web framework written in Go (Golang). It features a Martini-like API with much better performance -- up to 40 times faster. If you need smashing performance, get yourself some Gin.

-- 這是來自 github 上 Gin 的簡介

Gin 是一個用 Go 寫的 HTTP web 框架,它是一個類似於 Martini 框架,但是 Gin 用了 httprouter 這個路由,它比 martini 快了 40 倍。如果你追求高效能,那麼 Gin 適合。

當然 Gin 還有其它的一些特性:

  • 路由效能高
  • 支援中介軟體
  • 路由組
  • JSON 驗證
  • 錯誤管理
  • 可擴充套件性

Gin 文件:

Gin 快速入門 Demo

我以前也寫過一些關於 Gin 應用入門的 demo,在這裡

Gin v1.7.0 , Go 1.16.11

官方的一個 quickstart

package main

import "github.com/gin-gonic/gin"

// https://gin-gonic.com/docs/quickstart/
func main() {
	r := gin.Default()
	r.GET("/ping", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message": "pong",
		})
	})

	r.Run() // 監聽在預設埠8080, 0.0.0.0:8080
}

上面就完成了一個可執行的 Gin 程式了。

分析上面的 Demo

第一步:gin.Default()

Engine struct 是 Gin 框架裡最重要的一個結構體,包含了 Gin 框架要使用的許多欄位,比如路由(組),配置選項,HTML等等。

New()Default() 這兩個函式都是初始化 Engine 結構體。

RouterGroup struct 是 Gin 路由相關的結構體,路由相關操作都與這個結構體有關。

image-20220301201700209

  • A. Default() 函式

這個函式在 gin.go/Default(),它例項化一個 Engine,呼叫 New() 函式:

// https://github.com/gin-gonic/gin/blob/v1.7.0/gin.go#L180

// 例項化 Engine,預設帶上 Logger 和 Recovery 2 箇中介軟體,它是呼叫 New()
// Default returns an Engine instance with the Logger and Recovery middleware already attached.
func Default() *Engine {
	debugPrintWARNINGDefault() // debug 程式
    engine := New() // 新建 Engine 例項,原來 Default() 函式是最終是呼叫 New() 新建 engine 例項
	engine.Use(Logger(), Recovery()) // 使用一些中介軟體
	return engine
}

Engine 又是什麼?

  • B. Engine struct 是什麼和 New() 函式:

gin.go/Engine:

Engine 是一個 struct 型別,裡面包含了很多欄位,下面程式碼只顯示主要欄位:

// https://github.com/gin-gonic/gin/blob/v1.7.0/gin.go#L57

// gin 中最大的一個結構體,儲存了路由,設定選項和中介軟體
// 呼叫 New() 或 Default() 方法例項化 Engine struct
// Engine is the framework's instance, it contains the muxer, middleware and configuration settings.
// Create an instance of Engine, by using New() or Default()
type Engine struct {
    RouterGroup // 組路由(路由相關欄位)
    
    ... ...

	HTMLRender       render.HTMLRender
	FuncMap          template.FuncMap
	allNoRoute       HandlersChain
	allNoMethod      HandlersChain
	noRoute          HandlersChain
	noMethod         HandlersChain
	pool             sync.Pool
	trees            methodTrees
	maxParams        uint16
	trustedCIDRs     []*net.IPNet
}

type HandlersChain []HandlerFunc

gin.go/New() 例項化 gin.go/Engine struct,簡化的程式碼如下:

這個 New 函式,就是初始化 Engine struct,

// https://github.com/gin-gonic/gin/blob/v1.7.0/gin.go#L148

// 初始化 Engine,例項化一個 engine
// New returns a new blank Engine instance without any middleware attached.
// By default the configuration is:
// - RedirectTrailingSlash:  true
// - RedirectFixedPath:      false
// - HandleMethodNotAllowed: false
// - ForwardedByClientIP:    true
// - UseRawPath:             false
// - UnescapePathValues:     true
func New() *Engine {
	debugPrintWARNINGNew()
	engine := &Engine{
		RouterGroup: RouterGroup{
			Handlers: nil,
			basePath: "/",
			root:     true,
		},
		FuncMap:                template.FuncMap{},
		... ...
		trees:                  make(methodTrees, 0, 9),
		delims:                 render.Delims{Left: "{{", Right: "}}"},
		secureJSONPrefix:       "while(1);",
	}
	engine.RouterGroup.engine = engine // RouterGroup 裡的 engine 在這裡賦值,下面分析 RouterGroup 結構體
	engine.pool.New = func() interface{} {
		return engine.allocateContext()
	}
	return engine
}
  • C. RouterGroup

gin.go/Engine struct 裡的 routergroup.go/RouterGroup struct 這個與路由有關的欄位,它也是一個結構體,程式碼如下:

//https://github.com/gin-gonic/gin/blob/v1.7.0/routergroup.go#L41
// 配置儲存路由
// 路由後的處理函式handlers(中介軟體)
// RouterGroup is used internally to configure router, a RouterGroup is associated with
// a prefix and an array of handlers (middleware).
type RouterGroup struct {
	Handlers HandlersChain // 儲存處理路由
	basePath string
	engine   *Engine  // engine
	root     bool
}

// https://github.com/gin-gonic/gin/blob/v1.7.0/gin.go#L34
type HandlersChain []HandlerFunc

https://github.com/gin-gonic/gin/blob/v1.7.0/gin.go#L31
// HandlerFunc defines the handler used by gin middleware as return value.
type HandlerFunc func(*Context)

第二步:r.GET()

r.GET() 就是路由註冊和路由處理handler。

routergroup.go/GET(),handle() -> engine.go/addRoute()

// https://github.com/gin-gonic/gin/blob/v1.7.0/routergroup.go#L102
// GET is a shortcut for router.Handle("GET", path, handle).
func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) IRoutes {
	return group.handle(http.MethodGet, relativePath, handlers)
}

handle 處理函式:

// https://github.com/gin-gonic/gin/blob/v1.7.0/routergroup.go#L72
func (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes {
	absolutePath := group.calculateAbsolutePath(relativePath)
	handlers = group.combineHandlers(handlers)
	group.engine.addRoute(httpMethod, absolutePath, handlers)
	return group.returnObj()
}

combineHandlers() 函式把所有路由處理handler合併起來。

addRoute() 這個函式把方法,URI,處理handler 加入進來, 這個函式主要程式碼如下:

// https://github.com/gin-gonic/gin/blob/v1.7.0/gin.go#L276

func (engine *Engine) addRoute(method, path string, handlers HandlersChain) {
	... ...
    
    // 每一個http method(GET, POST, PUT...)都構建一顆基數樹
    root := engine.trees.get(method) // 獲取方法對應的基數樹
    if root == nil { // 如果沒有就建立一顆新樹
        root = new(node) // 建立基數樹的根節點
        root.fullPath = "/"
        engine.trees = append(engine.trees, methodTree{method: method, root: root})
    }
    root.addRoute(path, handlers)
    
    ... ...
}
 

上面這個root.addRoute函式在 tree.go 裡,而這裡的程式碼多數來自 httprouter 這個路由庫。

gin 裡號稱 40 times faster。

到底是怎麼做到的?

httprouter 路由資料結構Radix Tree

httprouter文件

httprouter 文件裡,有這樣一句話:

The router relies on a tree structure which makes heavy use of common prefixes, it is basically a compact prefix tree (or just Radix tree)

用了 prefix tree 字首樹 或 Radix tree 基數樹。與 Trie 字典樹有關。

Radix Tree 叫基數特里樹或壓縮字首樹,是一種更節省空間的 Trie 樹。

Trie 字典樹

Trie,被稱為字首樹或字典樹,是一種有序樹,其中的鍵通常是單詞和字串,所以又有人叫它單詞查詢樹。

它是一顆多叉樹,即每個節點分支數量可能為多個,根節點不包含字串。

從根節點到某一節點,路徑上經過的字元連線起來,為該節點對應的字串。

除根節點外,每一個節點只包含一個字元。

每個節點的所有子節點包含的字元都不相同。

優點:利用字串公共字首來減少查詢時間,減少無謂的字串比較

Trie 樹圖示:

image-20220302182612542

(為 b,abc,abd,bcd,abcd,efg,hii 這7個單詞建立的trie樹, https://baike.baidu.com/item/字典樹/9825209)

trie 樹的程式碼實現:https://baike.baidu.com/item/字典樹/9825209#5

Radix Tree基數樹

認識基數樹:

Radix Tree,基數特里樹或壓縮字首樹,是一種更節省空間的 Trie 樹。它對 trie 樹進行了壓縮。

看看是咋壓縮的,假如有下面一組資料 key-val 集合:

{
"def": "redisio", 
"dcig":"mysqlio", 
"dfo":"linux", 
"dfks":"tdb", 
"dfkz":"dogdb",
}

用上面資料中的 key 構造一顆 trie 樹:

image-20220302201216602

現在壓縮 trie 樹(Compressed Trie Tree)中的唯一子節點,就可以構建一顆 radix tree 基數樹。

父節點下第一級子節點數小於 2 的都可以進行壓縮,把子節點合併到父節點上,把上圖 <2 子節點數壓縮,變成如下圖:

image-20220302201653546

把 c,f 和 c,i,g 壓縮在一起,這樣就節省了一些空間。壓縮之後,分支高度也降低了。

這個就是對 trie tree 進行壓縮變成 radix tree。

在另外看一張出現次數比較多的 Radix Tree 的圖:

image-20220304190104682

(圖Radix_tree 來自:https://en.wikipedia.org/wiki/Radix_tree)

基數樹唯一子節點都與其父節點合併,邊沿(edges)既可以儲存多個元素序列也可以儲存單個元素。比如上圖的 r, om,an,e。

基數樹的圖最下面的數字對應上圖的排序數字,比如 image-20220304202150247,就是 ruber 字元,image-20220304202229223

什麼時候使用基數樹合適:

字串元素個數不是很多,且有很多相同字首時適合使用基數樹這種資料結構。

基數樹的應用場景:

httprouter 中的路由器。

使用 radix tree 來構建 key 為字串的關聯陣列。

很多構建 IP 路由也用到了 radix tree,比如 linux 中,因為 ip 通常有大量相同字首。

Redis 叢集模式下儲存 slot 對應的所有 key 資訊,也用到了 radix tree。檔案 rax.h/rax.c

radix tree 在倒排索引方面使用也比較廣。

httprouter中的基數樹

node 節點定義:

// https://github.com/julienschmidt/httprouter/blob/v1.3.0/tree.go#L46
type node struct {
	path      string    // 節點對應的字串路徑
	wildChild bool      // 是否為引數節點,如果是引數節點,那麼 wildChild=true
    nType     nodeType  // 節點型別,有幾個列舉值可以看下面nodeType的定義
	maxParams uint8     // 節點路徑最大引數個數
	priority  uint32    // 節點權重,子節點的handler總數
	indices   string    // 節點與子節點的分裂的第一個字元
	children  []*node   // 子節點
	handle    Handle    // http請求處理方法
}

節點型別 nodeType 定義:

// https://github.com/julienschmidt/httprouter/blob/v1.3.0/tree.go#L39
// 節點型別
const (
    static nodeType = iota // default, 靜態節點,普通匹配(/user)
	root                   // 根節點
    param                 // 引數節點(/user/:id)
    catchAll              // 通用匹配,匹配任意引數(*user)
)

indices 這個欄位是快取下一子節點的第一個字元。

比如路由: r.GET("/user/one"), r.GET("/user/two"), indices 欄位快取的就是下一節點的第一個字元,即 "ot" 2個字元。這個就是對搜尋匹配進行了優化。

image-20220306190959770

如果 wildChild=true,引數節點時,indices=""。

addRoute 新增路由:

addRoute(),新增路由函式,這個函式程式碼比較多,

分為空樹和非空樹時的插入。

空樹時直接插入:

n.insertChild(numParams, path, fullPath, handlers)
n.nType = root // 節點 nType 是 root 型別

非空樹的處理:

先是判斷樹非空(non-empty tree),接著下面是一個 for 迴圈,下面所有的處理都在 for 迴圈面。

  1. 更新 maxParams 欄位

  2. 尋找共同的最長字首字元

    // https://github.com/julienschmidt/httprouter/blob/v1.3.0/tree.go#L100
    // Find the longest common prefix. 尋找字元相同字首,用 i 數字表示
    // This also implies that the common prefix contains no ':' or '*',表示沒有包含特殊匹配 : 或 *
    // since the existing key can't contain those chars.
    i := 0
    max := min(len(path), len(n.path))
    for i < max && path[i] == n.path[i] {
        i++
    }
    
  3. split edge 開始分裂節點

    比如第一個路由 path 是 user,新增一個路由 uber,u 就是它們共同的部分(common prefix),那麼就把 u 作為父節點,剩下的 ser,ber 作為它的子節點

    // https://github.com/julienschmidt/httprouter/blob/v1.3.0/tree.go#L107
    // Split edge
    if i < len(n.path) {
        child := node{
            path:      n.path[i:], // 上面已經判斷了匹配的字元共同部分用i表示,[i:] 從i開始計算取字元剩下不同部分作為子節點
            wildChild: n.wildChild, // 節點型別
            nType:     static,      // 靜態節點普通匹配
            indices:   n.indices,
            children:  n.children,
            handle:    n.handle,
            priority:  n.priority - 1, // 節點降級
        }
    
        // Update maxParams (max of all children)
        for i := range child.children {
            if child.children[i].maxParams > child.maxParams {
                child.maxParams = child.children[i].maxParams
            }
        }
    
        n.children = []*node{&child} // 當前節點的子節點修改為上面剛剛分裂的節點
        // []byte for proper unicode char conversion, see #65
        n.indices = string([]byte{n.path[i]})
        n.path = path[:i]
        n.handle = nil
        n.wildChild = false
    }
    
  4. i<len(path),將新節點作為子節點插入
    https://github.com/julienschmidt/httprouter/blob/v1.3.0/tree.go#L137

  • 4.1 n.wildChild = true,對特殊引數節點的處理 ,: 和 *
  // https://github.com/julienschmidt/httprouter/blob/v1.3.0/tree.go#L133
  if n.wildChild {
    n = n.children[0]
    n.priority++
  
    // Update maxParams of the child node
    if numParams > n.maxParams {
        n.maxParams = numParams
    }
    numParams--
  
    // Check if the wildcard matches
    if len(path) >= len(n.path) && n.path == path[:len(n.path)] &&
    // Adding a child to a catchAll is not possible
    n.nType != catchAll &&
    // Check for longer wildcard, e.g. :name and :names
    (len(n.path) >= len(path) || path[len(n.path)] == '/') {
        continue walk
    } else {
        // Wildcard conflict
        var pathSeg string
        ... ...
    }
  }
  • 4.2 開始處理 indices
// https://github.com/julienschmidt/httprouter/blob/v1.3.0/tree.go#L171
c := path[0] // 獲取第一個字元

// slash after param,處理nType為引數的情況
if n.nType == param && c == '/' && len(n.children) == 1

// Check if a child with the next path byte exists
// 判斷子節點是否和當前path匹配,用indices欄位來判斷
// 比如 u 的子節點為 ser 和 ber,indices 為 u,如果新插入路由 ubb,那麼就與子節點 ber 有共同部分 b,繼續分裂 ber 節點
for i := 0; i < len(n.indices); i++ {
    if c == n.indices[i] {
        i = n.incrementChildPrio(i)
        n = n.children[i]
        continue walk
    }
}

// Otherwise insert it
// indices 不是引數和通配匹配
if c != ':' && c != '*' {
    // []byte for proper unicode char conversion, see #65
    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)
  1. i=len(path)路徑相同

    如果已經有handler處理函式就報錯,沒有就賦值handler

insertChild 插入子節點:

insertChild

getValue 路徑查詢:

getValue

上面2個函式可以獨自分析下 - -!

視覺化radix tree操作

https://www.cs.usfca.edu/~galles/visualization/RadixTree.html

radix tree 的演算法操作可以看這裡,動態展示。

參考

相關文章