轉自
gin框架路由詳解
gin框架使用的是定制版本的httprouter,其路由的原理是大量使用公共前綴的樹結構,它基本上是一個緊湊的Trie tree(或者只是Radix Tree)。具有公共前綴的節點也共享一個公共父節點。
Radix Tree
基數樹(Radix Tree)又稱為PAT位樹(Patricia Trie or crit bit tree),是一種更節省空間的前綴樹(Trie Tree)。對於基數樹的每個節點,如果該節點是唯一的子樹的話,就和父節點合並。下圖為一個基數樹示例:
Radix Tree
可以被認為是一棵簡潔版的前綴樹。我們注冊路由的過程就是構造前綴樹的過程,具有公共前綴的節點也共享一個公共父節點。假設我們現在注冊有以下路由信息:
r := gin.Default() r.GET("/", func1) r.GET("/search/", func2) r.GET("/support/", func3) r.GET("/blog/", func4) r.GET("/blog/:post/", func5) r.GET("/about-us/", func6) r.GET("/about-us/team/", func7) r.GET("/contact/", func8)
那么我們會得到一個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>
上面最右邊那一列每個*<數字>
表示Handle處理函數的內存地址(一個指針)。從根節點遍歷到葉子節點我們就能得到完整的路由表。
例如:blog/:post
其中:post
只是實際文章名稱的占位符(參數)。與hash-maps
不同,這種樹結構還允許我們使用像:post
參數這種動態部分,因為我們實際上是根據路由模式進行匹配,而不僅僅是比較哈希值。
由於URL路徑具有層次結構,並且只使用有限的一組字符(字節值),所以很可能有許多常見的前綴。這使我們可以很容易地將路由簡化為更小的問題。此外,路由器為每個請求方法管理一棵單獨的樹。一方面,它比在每個節點中都保存一個method-> handle map更加節省空間,它還使我們甚至可以在開始在前綴樹中查找之前大大減少路由問題。
為了獲得更好的可伸縮性,每個樹級別上的子節點都按Priority(優先級)
排序,其中優先級(最左列)就是在子節點(子節點、子子節點等等)中注冊的句柄的數量。這樣做有兩個好處:
-
首先優先匹配被大多數路由路徑包含的節點。這樣可以讓盡可能多的路由快速被定位。
-
類似於成本補償。最長的路徑可以被優先匹配,補償體現在最長的路徑需要花費更長的時間來定位,如果最長路徑的節點能被優先匹配(即每次拿子節點都命中),那么路由匹配所花的時間不一定比短路徑的路由長。下面展示了節點(每個
-
可以看做一個節點)匹配的路徑:從左到右,從上到下。
├------------
├---------
├-----
├----
├--
├--
└-
路由樹節點
路由樹是由一個個節點構成的,gin框架路由樹的節點由node
結構體表示,它有以下字段:
// tree.go type node struct { // 節點路徑,比如上面的s,earch,和upport path string // 和children字段對應, 保存的是分裂的分支的第一個字符 // 例如search和support, 那么s節點的indices對應的"eu" // 代表有兩個分支, 分支的首字母分別是e和u indices string // 兒子節點 children []*node // 處理函數鏈條(切片) handlers HandlersChain // 優先級,子節點、子子節點等注冊的handler數量 priority uint32 // 節點類型,包括static, root, param, catchAll // static: 靜態節點(默認),比如上面的s,earch等節點 // root: 樹的根節點 // catchAll: 有*匹配的節點 // param: 參數節點 nType nodeType // 路徑上最大參數個數 maxParams uint8 // 節點是否是參數節點,比如上面的:post wildChild bool // 完整路徑 fullPath string }
請求方法樹
在gin的路由中,每一個HTTP Method
(GET、POST、PUT、DELETE…)都對應了一棵 radix tree
,我們注冊路由的時候會調用下面的addRoute
函數:
// gin.go func (engine *Engine) addRoute(method, path string, handlers HandlersChain) { // liwenzhou.com... // 獲取請求方法對應的樹 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) }
從上面的代碼中我們可以看到在注冊路由的時候都是先根據請求方法獲取對應的樹,也就是gin框架會為每一個請求方法創建一棵對應的樹。只不過需要注意到一個細節是gin框架中保存請求方法對應樹關系並不是使用的map而是使用的切片,engine.trees
的類型是methodTrees
,其定義如下:
type methodTree struct { method string root *node } type methodTrees []methodTree // slice
而獲取請求方法對應樹的get方法定義如下:
func (trees methodTrees) get(method string) *node { for _, tree := range trees { if tree.method == method { return tree.root } } return nil }
為什么使用切片而不是map來存儲請求方法->樹
的結構呢?我猜是出於節省內存的考慮吧,畢竟HTTP請求方法的數量是固定的,而且常用的就那幾種,所以即使使用切片存儲查詢起來效率也足夠了。順着這個思路,我們可以看一下gin框架中engine
的初始化方法中,確實對tress
字段做了一次內存申請:
func New() *Engine { debugPrintWARNINGNew() engine := &Engine{ RouterGroup: RouterGroup{ Handlers: nil, basePath: "/", root: true, }, // liwenzhou.com ... // 初始化容量為9的切片(HTTP1.1請求方法共9種) trees: make(methodTrees, 0, 9), // liwenzhou.com... } engine.RouterGroup.engine = engine engine.pool.New = func() interface{} { return engine.allocateContext() } return engine }
注冊路由
注冊路由的邏輯主要有addRoute
函數和insertChild
方法。
addRoute
// tree.go // addRoute 將具有給定句柄的節點添加到路徑中。 // 不是並發安全的 func (n *node) addRoute(path string, handlers HandlersChain) { fullPath := path n.priority++ numParams := countParams(path) // 數一下參數個數 // 空樹就直接插入當前節點 if len(n.path) == 0 && len(n.children) == 0 { n.insertChild(numParams, path, fullPath, handlers) n.nType = root return } parentFullPathIndex := 0 walk: for { // 更新當前節點的最大參數個數 if numParams > n.maxParams { n.maxParams = numParams } // 找到最長的通用前綴 // 這也意味着公共前綴不包含“:”"或“*” / // 因為現有鍵不能包含這些字符。 i := longestCommonPrefix(path, n.path) // 分裂邊緣(此處分裂的是當前樹節點) // 例如一開始path是search,新加入support,s是他們通用的最長前綴部分 // 那么會將s拿出來作為parent節點,增加earch和upport作為child節點 if i < len(n.path) { child := node{ path: n.path[i:], // 公共前綴后的部分作為子節點 wildChild: n.wildChild, indices: n.indices, children: n.children, handlers: n.handlers, priority: n.priority - 1, //子節點優先級-1 fullPath: n.fullPath, } // Update maxParams (max of all children) for _, v := range child.children { if v.maxParams > child.maxParams { child.maxParams = v.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.handlers = nil n.wildChild = false n.fullPath = fullPath[:parentFullPathIndex+i] } // 將新來的節點插入新的parent節點作為子節點 if i < len(path) { path = path[i:] if n.wildChild { // 如果是參數節點 parentFullPathIndex += len(n.path) n = n.children[0] n.priority++ // Update maxParams of the child node if numParams > n.maxParams { n.maxParams = numParams } numParams-- // 檢查通配符是否匹配 if len(path) >= len(n.path) && n.path == path[:len(n.path)] { // 檢查更長的通配符, 例如 :name and :names if len(n.path) >= len(path) || path[len(n.path)] == '/' { continue walk } } pathSeg := path if n.nType != catchAll { pathSeg = strings.SplitN(path, "/", 2)[0] } prefix := fullPath[:strings.Index(fullPath, pathSeg)] + n.path panic("'" + pathSeg + "' in new path '" + fullPath + "' conflicts with existing wildcard '" + n.path + "' in existing prefix '" + prefix + "'") } // 取path首字母,用來與indices做比較 c := path[0] // 處理參數后加斜線情況 if n.nType == param && c == '/' && len(n.children) == 1 { parentFullPathIndex += len(n.path) n = n.children[0] n.priority++ continue walk } // 檢查路path下一個字節的子節點是否存在 // 比如s的子節點現在是earch和upport,indices為eu // 如果新加一個路由為super,那么就是和upport有匹配的部分u,將繼續分列現在的upport節點 for i, max := 0, len(n.indices); i < max; i++ { if c == n.indices[i] { parentFullPathIndex += len(n.path) i = n.incrementChildPrio(i) n = n.children[i] continue walk } } // 否則就插入 if c != ':' && c != '*' { // []byte for proper unicode char conversion, see #65 // 注意這里是直接拼接第一個字符到n.indices n.indices += string([]byte{c}) child := &node{ maxParams: numParams, fullPath: fullPath, } // 追加子節點 n.children = append(n.children, child) n.incrementChildPrio(len(n.indices) - 1) n = child } n.insertChild(numParams, path, fullPath, handlers) return } // 已經注冊過的節點 if n.handlers != nil { panic("handlers are already registered for path '" + fullPath + "'") } n.handlers = handlers return } }
其實上面的代碼很好理解,大家可以參照動畫嘗試將以下情形代入上面的代碼邏輯,體味整個路由樹構造的詳細過程:
- 第一次注冊路由,例如注冊search
- 繼續注冊一條沒有公共前綴的路由,例如blog
注冊一條與先前注冊的路由有公共前綴的路由,例如support
insertChild
// tree.go func (n *node) insertChild(numParams uint8, path string, fullPath string, handlers HandlersChain) { // 找到所有的參數 for numParams > 0 { // 查找前綴直到第一個通配符 wildcard, i, valid := findWildcard(path) if i < 0 { // 沒有發現通配符 break } // 通配符的名稱必須包含':' 和 '*' if !valid { panic("only one wildcard per path segment is allowed, has: '" + wildcard + "' in path '" + fullPath + "'") } // 檢查通配符是否有名稱 if len(wildcard) < 2 { panic("wildcards must be named with a non-empty name in path '" + fullPath + "'") } // 檢查這個節點是否有已經存在的子節點 // 如果我們在這里插入通配符,這些子節點將無法訪問 if len(n.children) > 0 { panic("wildcard segment '" + wildcard + "' conflicts with existing children in path '" + fullPath + "'") } if wildcard[0] == ':' { // param if i > 0 { // 在當前通配符之前插入前綴 n.path = path[:i] path = path[i:] } n.wildChild = true child := &node{ nType: param, path: wildcard, maxParams: numParams, fullPath: fullPath, } n.children = []*node{child} n = child n.priority++ numParams-- // 如果路徑沒有以通配符結束 // 那么將有另一個以'/'開始的非通配符子路徑。 if len(wildcard) < len(path) { path = path[len(wildcard):] child := &node{ maxParams: numParams, priority: 1, fullPath: fullPath, } n.children = []*node{child} n = child // 繼續下一輪循環 continue } // 否則我們就完成了。將處理函數插入新葉子中 n.handlers = handlers return } // catchAll if i+len(wildcard) != len(path) || numParams > 1 { 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("catch-all conflicts with existing handle for the path segment root in path '" + fullPath + "'") } // currently fixed width 1 for '/' i-- if path[i] != '/' { panic("no / before catch-all in path '" + fullPath + "'") } n.path = path[:i] // 第一個節點:路徑為空的catchAll節點 child := &node{ wildChild: true, nType: catchAll, maxParams: 1, fullPath: fullPath, } // 更新父節點的maxParams if n.maxParams < 1 { n.maxParams = 1 } n.children = []*node{child} n.indices = string('/') n = child n.priority++ // 第二個節點:保存變量的節點 child = &node{ path: path[i:], nType: catchAll, maxParams: 1, handlers: handlers, priority: 1, fullPath: fullPath, } n.children = []*node{child} return } // 如果沒有找到通配符,只需插入路徑和句柄 n.path = path n.handlers = handlers n.fullPath = fullPath }
insertChild
函數是根據path
本身進行分割,將/
分開的部分分別作為節點保存,形成一棵樹結構。參數匹配中的:
和*
的區別是,前者是匹配一個字段而后者是匹配后面所有的路徑。
路由匹配
我們先來看gin框架處理請求的入口函數ServeHTTP
:
// gin.go func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) { // 這里使用了對象池 c := engine.pool.Get().(*Context) // 這里有一個細節就是Get對象后做初始化 c.writermem.reset(w) c.Request = req c.reset() engine.handleHTTPRequest(c) // 我們要找的處理HTTP請求的函數 engine.pool.Put(c) // 處理完請求后將對象放回池子 }
函數很長,這里省略了部分代碼,只保留相關邏輯代碼:
// gin.go func (engine *Engine) handleHTTPRequest(c *Context) { // liwenzhou.com... // 根據請求方法找到對應的路由樹 t := engine.trees for i, tl := 0, len(t); i < tl; i++ { if t[i].method != httpMethod { continue } root := t[i].root // 在路由樹中根據path查找 value := root.getValue(rPath, c.Params, unescape) if value.handlers != nil { c.handlers = value.handlers c.Params = value.params c.fullPath = value.fullPath c.Next() // 執行函數鏈條 c.writermem.WriteHeaderNow() return } // liwenzhou.com... c.handlers = engine.allNoRoute serveError(c, http.StatusNotFound, default404Body) }
路由匹配是由節點的 getValue
方法實現的。getValue
根據給定的路徑(鍵)返回nodeValue
值,保存注冊的處理函數和匹配到的路徑參數數據。
如果找不到任何處理函數,則會嘗試TSR(尾隨斜杠重定向)。
代碼雖然很長,但還算比較工整。大家可以借助注釋看一下路由查找及參數匹配的邏輯。
// tree.go type nodeValue struct { handlers HandlersChain params Params // []Param tsr bool fullPath string } // liwenzhou.com... func (n *node) getValue(path string, po Params, unescape bool) (value nodeValue) { value.params = po walk: // Outer loop for walking the tree for { prefix := n.path if path == prefix { // 我們應該已經到達包含處理函數的節點。 // 檢查該節點是否注冊有處理函數 if value.handlers = n.handlers; value.handlers != nil { value.fullPath = n.fullPath return } if path == "/" && n.wildChild && n.nType != root { value.tsr = true return } // 沒有找到處理函數 檢查這個路徑末尾+/ 是否存在注冊函數 indices := n.indices for i, max := 0, len(indices); i < max; i++ { if indices[i] == '/' { n = n.children[i] value.tsr = (len(n.path) == 1 && n.handlers != nil) || (n.nType == catchAll && n.children[0].handlers != nil) return } } return } if len(path) > len(prefix) && path[:len(prefix)] == prefix { path = path[len(prefix):] // 如果該節點沒有通配符(param或catchAll)子節點 // 我們可以繼續查找下一個子節點 if !n.wildChild { c := path[0] indices := n.indices for i, max := 0, len(indices); i < max; i++ { if c == indices[i] { n = n.children[i] // 遍歷樹 continue walk } } // 沒找到 // 如果存在一個相同的URL但沒有末尾/的葉子節點 // 我們可以建議重定向到那里 value.tsr = path == "/" && n.handlers != nil return } // 根據節點類型處理通配符子節點 n = n.children[0] switch n.nType { case param: // find param end (either '/' or path end) end := 0 for end < len(path) && path[end] != '/' { end++ } // 保存通配符的值 if cap(value.params) < int(n.maxParams) { value.params = make(Params, 0, n.maxParams) } i := len(value.params) value.params = value.params[:i+1] // 在預先分配的容量內擴展slice value.params[i].Key = n.path[1:] val := path[:end] if unescape { var err error if value.params[i].Value, err = url.QueryUnescape(val); err != nil { value.params[i].Value = val // fallback, in case of error } } else { value.params[i].Value = val } // 繼續向下查詢 if end < len(path) { if len(n.children) > 0 { path = path[end:] n = n.children[0] continue walk } // ... but we can't value.tsr = len(path) == end+1 return } if value.handlers = n.handlers; value.handlers != nil { value.fullPath = n.fullPath return } if len(n.children) == 1 { // 沒有找到處理函數. 檢查此路徑末尾加/的路由是否存在注冊函數 // 用於 TSR 推薦 n = n.children[0] value.tsr = n.path == "/" && n.handlers != nil } return case catchAll: // 保存通配符的值 if cap(value.params) < int(n.maxParams) { value.params = make(Params, 0, n.maxParams) } i := len(value.params) value.params = value.params[:i+1] // 在預先分配的容量內擴展slice value.params[i].Key = n.path[2:] if unescape { var err error if value.params[i].Value, err = url.QueryUnescape(path); err != nil { value.params[i].Value = path // fallback, in case of error } } else { value.params[i].Value = path } value.handlers = n.handlers value.fullPath = n.fullPath return default: panic("invalid node type") } } // 找不到,如果存在一個在當前路徑最后添加/的路由 // 我們會建議重定向到那里 value.tsr = (path == "/") || (len(prefix) == len(path)+1 && prefix[len(path)] == '/' && path == prefix[:len(prefix)-1] && n.handlers != nil) return } }
gin框架中間件詳解
gin框架涉及中間件相關有4個常用的方法,它們分別是c.Next()
、c.Abort()
、c.Set()
、c.Get()
。
中間件的注冊
gin框架中的中間件設計很巧妙,我們可以首先從我們最常用的r := gin.Default()
的Default
函數開始看,它內部構造一個新的engine
之后就通過Use()
函數注冊了Logger
中間件和Recovery
中間件:
func Default() *Engine { debugPrintWARNINGDefault() engine := New() engine.Use(Logger(), Recovery()) // 默認注冊的兩個中間件 return engine }
繼續往下查看一下Use()
函數的代碼:
func (engine *Engine) Use(middleware ...HandlerFunc) IRoutes { engine.RouterGroup.Use(middleware...) // 實際上還是調用的RouterGroup的Use函數 engine.rebuild404Handlers() engine.rebuild405Handlers() return engine }
從下方的代碼可以看出,注冊中間件其實就是將中間件函數追加到group.Handlers
中:
func (group *RouterGroup) Use(middleware ...HandlerFunc) IRoutes { group.Handlers = append(group.Handlers, middleware...) return group.returnObj() }
而我們注冊路由時會將對應路由的函數和之前的中間件函數結合到一起:
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() }
其中結合操作的函數內容如下,注意觀察這里是如何實現拼接兩個切片得到一個新切片的。
const abortIndex int8 = math.MaxInt8 / 2 func (group *RouterGroup) combineHandlers(handlers HandlersChain) HandlersChain { finalSize := len(group.Handlers) + len(handlers) if finalSize >= int(abortIndex) { // 這里有一個最大限制 panic("too many handlers") } mergedHandlers := make(HandlersChain, finalSize) copy(mergedHandlers, group.Handlers) copy(mergedHandlers[len(group.Handlers):], handlers) return mergedHandlers }
也就是說,我們會將一個路由的中間件函數和處理函數結合到一起組成一條處理函數鏈條HandlersChain
,而它本質上就是一個由HandlerFunc
組成的切片:
type HandlersChain []HandlerFunc
中間件的執行
我們在上面路由匹配的時候見過如下邏輯:
value := root.getValue(rPath, c.Params, unescape) if value.handlers != nil { c.handlers = value.handlers c.Params = value.params c.fullPath = value.fullPath c.Next() // 執行函數鏈條 c.writermem.WriteHeaderNow() return }
其中c.Next()
就是很關鍵的一步,它的代碼很簡單:
func (c *Context) Next() { c.index++ for c.index < int8(len(c.handlers)) { c.handlers[c.index](c) c.index++ } }
從上面的代碼可以看到,這里通過索引遍歷HandlersChain
鏈條,從而實現依次調用該路由的每一個函數(中間件或處理請求的函數)。
我們可以在中間件函數中通過再次調用c.Next()
實現嵌套調用(func1中調用func2;func2中調用func3),
或者通過調用c.Abort()
中斷整個調用鏈條,從當前函數返回。
func (c *Context) Abort() { c.index = abortIndex // 直接將索引置為最大限制值,從而退出循環 }
c.Set()/c.Get()
c.Set()
和c.Get()
這兩個方法多用於在多個函數之間通過c
傳遞數據的,比如我們可以在認證中間件中獲取當前請求的相關信息(userID等)通過c.Set()
存入c
,然后在后續處理業務邏輯的函數中通過c.Get()
來獲取當前請求的用戶。c
就像是一根繩子,將該次請求相關的所有的函數都串起來了。
總結
- gin框架路由使用前綴樹,路由注冊的過程是構造前綴樹的過程,路由匹配的過程就是查找前綴樹的過程。
- gin框架的中間件函數和處理函數是以切片形式的調用鏈條存在的,我們可以順序調用也可以借助
c.Next()
方法實現嵌套調用。 - 借助
c.Set()
和c.Get()
方法我們能夠在不同的中間件函數中傳遞數據。