分享一波gin的路由演算法

小魔童哪吒發表於2021-06-04
[TOC]

gin是什麼呢?

我們在github上看看官方簡介

Gin is a web framework written in Go (Golang). It features a martini-like API with performance that is up to 40 times faster thanks to httprouter. If you need performance and good productivity, you will love Gin.

Gin 是用 Go 開發的一個微框架,Web框架,類似 Martinier 的 API,介面簡潔,效能極高,也因為 httprouter的效能提高了 40 倍。

如果你需要良好的表現和工作效率,你會喜歡Gin

img

gin有啥特性呢?

tag 說明
異常處理 服務始終可用,不會當機
Gin 可以捕獲 panic,並恢復。而且有極為便利的機制處理HTTP請求過程中發生的錯誤。
路由分組 可以將需要授權和不需要授權的API分組,不同版本的API分組。
而且分組可巢狀,且效能不受影響。
例如v1/xxx/xxx v2/xxx/xxx
渲染內建 原生支援JSON,XML和HTML的渲染。
JSON Gin可以解析並驗證請求的JSON。
這個特性對Restful API的開發尤其有用。
中介軟體 HTTP請求,可先經過一系列中介軟體處理
就向日志Logger,Authorization等。
中介軟體機制也極大地提高了框架的可擴充套件性。

gin大致都包含了哪些知識點?

gin的實戰演練我們之前也有分享過,我們再來回顧一下,gin大致都包含了哪些知識點

  • :路由*路由
  • query查詢引數
  • 接收陣列和 Map
  • Form 表單
  • 單檔案和多檔案上傳
  • 分組路由,以及路由巢狀
  • 路由中介軟體
  • 各種資料格式的支援,json、struct、xml、yaml、protobuf
  • HTML模板渲染
  • url重定向
  • 非同步協程等等

要是朋友們對gin還有點興趣的話,可以點進來看看,這裡有具體的知識點對應的案例gin實戰演練

路由是什麼?

我們再來了解一下路由是什麼

路由器是一種連線多個網路或網段的網路裝置,它能將不同網路或網段之間的資料資訊進行“翻譯”,以使它們能夠相互“讀”懂對方的資料,從而構成一個更大的網路。

路由器有兩大典型功能

  • 即資料通道功能

包括轉發決定、背板轉發以及輸出鏈路排程等,一般由特定的硬體來完成

  • 控制功能

一般用軟體來實現,包括與相鄰路由器之間的資訊交換、系統配置、系統管理等

gin裡面的路由

路由是web框架的核心功能

寫過路由的朋友最開始是不是這樣看待路由的:

  • 根據路由裡的 / 把路由切分成多個字串陣列
  • 然後按照相同的前子陣列把路由構造成樹的結構

當需要定址的時候,先把請求的 url 按照 / 切分,然後遍歷樹進行定址,這樣子有點像是深度優先演算法的遞迴遍歷,從根節點開始,不停的向根的地方進行延伸,知道不能再深入為止,算是得到了一條路徑

舉個例子

定義了兩個路由 /v1/hi/v1/hello

那麼這就會構造出擁有三個節點的路由樹,根節點是 v1,兩個子節點分別是 hi hello

上述是一種實現路由樹的方式,這種是比較直觀,容易理解的。對 url 進行切分、比較,可是時間複雜度是 O(2n),那麼我們有沒有更好的辦法優化時間複雜度呢?大名鼎鼎的GIn框架有辦法,往後看

演算法是什麼?

再來提一提演算法是啥。

演算法是解決某個問題的計算方法、步驟,不僅僅是有了計算機才有演算法這個名詞/概念的,

例如我們小學學習的九九乘法表

中學學習的各種解決問題的計算方法,例如物理公式等等

現在各種吃播大秀廚藝,做法的流程和方法也是演算法的一種

  • 面臨的問題是bug , 解決的方法不盡相同,步驟也大相徑庭
  • 面臨豬蹄,烹飪方法各有特色
  • 面臨我們生活中的難題,也許每個人都會碰到同樣的問題,可是每個人解決問題的方式方法差異也非常大,有的人處理事情非常漂亮,有的人拖拖拉拉,總留尾巴

大學裡面學過演算法這本書,演算法是計算機的靈魂,面臨問題,好的演算法能夠輕易應對且健壯性好

面臨人生難題,好的解決方式,也同樣能夠讓我們走的更遠,更確切有點來說,應該是好的思維模型。

演算法有如下五大特徵

每個事物都會有自己的特點,否則如何才能讓人記憶深刻呢

  • 有限性 , 演算法得有明確限步之後會結束
  • 確切性,每一個步驟都是明確的,涉及的引數也是確切的
  • 輸入,演算法有零個或者多個輸入
  • 輸出,演算法有零個或者多個輸出
  • 可行性,演算法的每一個步驟都是可以分解出來執行的,且都可以在有限時間內完成

gin的路由演算法

那我們開始進入進入正題,gin的路由演算法,千呼萬喚始出來

gin的是路由演算法類似於一棵字首樹

只需遍歷一遍字串即可,時間複雜度為O(n)。比上面提到的方式,在時間複雜度上來說真是大大滴優化呀

不過,僅僅是對於一次 http 請求來說,是看不出啥效果的

誒,敲黑板了,什麼叫做字首樹呢?

Trie樹,又叫字典樹字首樹(Prefix Tree),是一種多叉樹結構

畫個圖,大概就能明白字首樹是個啥玩意了

這棵樹還和二叉樹不太一樣,它的鍵不是直接儲存在節點中,而是由節點在樹中的位置決定

一個節點的所有子孫都有相同的字首,也就是這個節點對應的字串,而根節點對應空字串

例如上圖,我們一個一個的來定址一下,會有這樣的字串

  • MAC
  • TAG
  • TAB
  • HEX

字首樹有如下幾個特點:

  • 字首樹除根節點不包含字元,其他節點都包含字元
  • 每個節點的子節點包含的字串不相同
  • 從根節點到某一個節點,路徑上經過的字元連線起來,為該節點對應的字串
  • 每個節點的子節點通常有一個標誌位,用來標識單詞的結束

有沒有覺得這個和路由的樹一毛一樣?

gin的路由樹演算法類似於一棵字首樹. 不過並不是只有一顆樹, 而是每種方法(POST, GET ,PATCH…)都有自己的一顆樹

例如,路由的地址是

  • /hi
  • /hello
  • /:name/:id

那麼gin對應的樹會是這個樣子的

GO中 路由對應的節點資料結構是這個樣子的

type node struct {
    path      string
    indices   string
    children  []*node
    handlers  HandlersChain
    priority  uint32
    nType     nodeType
    maxParams uint8
    wildChild bool
}

具體新增路由的方法,實現方法是這樣的

func (engine *Engine) addRoute(method, path string, handlers HandlersChain) {
    assert1(path[0] == '/', "path must begin with '/'")
    assert1(method != "", "HTTP method can not be empty")
    assert1(len(handlers) > 0, "there must be at least one handler")

    debugPrintRoute(method, path, handlers)
    // 此處可以好好看看
    root := engine.trees.get(method) 
    if root == nil {
        root = new(node)
        engine.trees = append(engine.trees, methodTree{method: method, root: root})
    }
    root.addRoute(path, handlers)
}

仔細看,gin的實現不像一個真正的樹

因為他的children []*node所有的孩子都會放在這個陣列裡面,具體實現是,他會利用indices, priority變相的去實現一棵樹

我們來看看不同註冊路由的方式有啥不同?每一種註冊方式,最終都會反應到gin的路由樹上面

普通註冊路由

普通註冊路由的方式是 router.xxx,可以是如下方式

  • GET
  • POST
  • PATCH
  • PUT
router.POST("/hi", func(context *gin.Context) {
    context.String(http.StatusOK, "hi xiaomotong")
})

也可以以組Group的方式註冊,以分組的方式註冊路由,便於版本的維護

v1 := router.Group("v1")
{
    v1.POST("hello", func(context *gin.Context) {
        context.String(http.StatusOK, "v1 hello world")
    })
}

在呼叫POST, GET, PATCH等路由HTTP相關函式時, 會呼叫handle函式

func (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes {
    absolutePath := group.calculateAbsolutePath(relativePath) // calculateAbsolutePath
    handlers = group.combineHandlers(handlers) //  combineHandlers
    group.engine.addRoute(httpMethod, absolutePath, handlers)
    return group.returnObj()
}

calculateAbsolutePathcombineHandlers 還會再次出現

呼叫組的話,看看是咋實現的

func (group *RouterGroup) Group(relativePath string, handlers ...HandlerFunc) *RouterGroup {
    return &RouterGroup{
        Handlers: group.combineHandlers(handlers),
        basePath: group.calculateAbsolutePath(relativePath),
        engine:   group.engine,
    }
}

同樣也會呼叫 calculateAbsolutePathcombineHandlers 這倆函式,我們來看看 這倆函式是幹啥的,看到函式名字,也許大概也能猜出個所以然了吧,來看看原始碼

img

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
}
func (group *RouterGroup) calculateAbsolutePath(relativePath string) string {
    return joinPaths(group.basePath, relativePath)
}

func joinPaths(absolutePath, relativePath string) string {
    if relativePath == "" {
        return absolutePath
    }

    finalPath := path.Join(absolutePath, relativePath)
    appendSlash := lastChar(relativePath) == '/' && lastChar(finalPath) != '/'
    if appendSlash {
        return finalPath + "/"
    }
    return finalPath
}

joinPaths函式在這裡相當重要,主要是做拼接的作用

從上面來看,可以看出如下2點:

  • 呼叫中介軟體, 是將某個路由的handler處理函式和中介軟體的處理函式都放在了Handlers的陣列中
  • 呼叫Group, 是將路由的path上面拼上Group的值. 也就是/hi/:id, 會變成v1/hi/:id

使用中介軟體的方式註冊路由

我們也可以使用中介軟體的方式來註冊路由,例如在訪問我們的路由之前,我們需要加一個認證的中介軟體放在這裡,必須要認證通過了之後,才可以訪問路由

router.Use(Login())
func (group *RouterGroup) Use(middleware ...HandlerFunc) IRoutes {
    group.Handlers = append(group.Handlers, middleware...)
    return group.returnObj()
}

不管是普通的註冊,還是通過中介軟體的方式註冊,裡面都有一個關鍵的handler

handler方法 呼叫 calculateAbsolutePathcombineHandlers 將路由拼接好之後,呼叫addRoute方法,將路由預處理的結果註冊到gin Engine的trees上,來在看讀讀handler的實現

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()
}

那麼,服務端寫好路由之後,我們通過具體的路由去做http請求的時候,服務端是如何通過路由找到具體的處理函式的呢?

我們仔細追蹤原始碼, 我們可以看到如下的實現

...
// 一棵字首樹
t := engine.trees
for i, tl := 0, len(t); i < tl; i++ {
    if t[i].method != httpMethod {
        continue
    }
    root := t[i].root
    // Find route in tree
    // 這裡通過 path 來找到相應的  handlers 處理函式
    handlers, params, tsr := root.getValue(path, c.Params, unescape) 
    if handlers != nil {
        c.handlers = handlers
        c.Params = params
        // 在此處呼叫具體的 處理函式
        c.Next()
        c.writermem.WriteHeaderNow()
        return
    }
    if httpMethod != "CONNECT" && path != "/" {
        if tsr && engine.RedirectTrailingSlash {
            redirectTrailingSlash(c)
            return
        }
        if engine.RedirectFixedPath && redirectFixedPath(c, root, engine.RedirectFixedPath) {
            return
        }
    }
    break
}
...
func (c *Context) Next() {
    c.index++
    for c.index < int8(len(c.handlers)) {
        c.handlers[c.index](c)
        c.index++
    }
}

當客戶端請求服務端的介面時, 服務端此處 handlers, params, tsr := root.getValue(path, c.Params, unescape) , 通過 path 來找到相應的 handlers 處理函式,

handlersparams 複製給到服務中,通過 c.Next()來執行具體的處理函式,此時就可以達到,客戶端請求響應的路由地址,服務端能過對響應路由做出對應的處理操作了

總結

  • 簡單回顧了一下gin的特性
  • 介紹了gin裡面的路由
  • 分享了gin的路由演算法,以及具體的原始碼實現流程

img

好了,本次就到這裡,下一次 分享最常用的限流演算法以及如何在http中介軟體中加入流控

技術是開放的,我們的心態,更應是開放的。擁抱變化,向陽而生,努力向前行。

我是小魔童哪吒,歡迎點贊關注收藏,下次見~

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章