Gin 原始碼閱讀(三):Gin 路由的實現剖析

語言: CN / TW / HK

hi, 大家好,我是 hhf。

往期回顧:

上面兩篇文章基本講清楚了 Web Server 如何接收客戶端請求,以及如何將請求流轉到 gin 的邏輯。

gin 原理剖析說到這裡,就完全進入 gin 的邏輯裡面了。gin 已經拿到 http 請求了,第一件重要的事情肯定就是重寫 路由 了,所以本節內容主要是分析 gin 的路由相關的內容。

其實 gin 的路由也不是完全自己寫的,其實很重要的一部分程式碼是使用的開源的 julienschmidt/httprouter,當然 gin 也添加了部分自己獨有的功能,如:routergroup。

什麼是路由?

這個其實挺容易理解的,就是根據不同的 URL 找到對應的處理函式即可。

目前業界 Server 端 API 介面的設計方式一般是遵循 RESTful 風格的規範。當然我也見過某些大公司為了降低開發人員的心智負擔和學習成本,介面完全不區分 GET/POST/DELETE 請求,完全靠介面的命名來表示。

舉個簡單的例子,如:"刪除使用者"

RESTful:    DELETE  /user/hhf
No RESTful: GET /deleteUser?name=hhf

這種 No RESTful 的方式,有的時候確實減少一些溝通問題和學習成本,但是隻能內部使用了。這種不區分 GET/POST 的 Web 框架一般設計的會比較靈活,但是開發人員水平參差不齊,會導致出現很多“介面毒瘤”,等你發現的時候已經無可奈何了,如下面這些介面:

GET /selectUserList?userIds=[1,2,3] -> 引數是否可以是陣列?
GET /getStudentlist?skuIdCntMap={"200207366":1} -> 引數是否可以是字典?

這樣的介面設計會導致開源的框架都是解析不了的,只能自己手動一層一層 decode 字串,這裡就不再詳細鋪開介紹了,等下一節說到 gin Bind 系列函式時再詳細說一下。

繼續回到上面 RESTful 風格的介面上面來,拿下面這些簡單的請求來說:

GET    /user/{userID} HTTP/1.1
POST /user/{userID} HTTP/1.1
PUT /user/{userID} HTTP/1.1
DELETE /user/{userID} HTTP/1.1

這是比較規範的 RESTful API設計,分別代表:

  • 獲取 userID 的使用者資訊

  • 更新 userID 的使用者資訊(當然還有其 json body,沒有寫出來)

  • 建立 userID 的使用者(當然還有其 json body,沒有寫出來)

  • 刪除 userID 的使用者

可以看到同樣的 URI,不同的請求 Method,最終其他代表的要處理的事情也完全不一樣。

看到這裡你可以思考一下,假如讓你來設計這個路由,要滿足上面的這些功能,你會如何設計呢?

gin 路由設計

如何設計不同的 Method ?

通過上面的介紹,已經知道 RESTful 是要區分方法的,不同的方法代表意義也完全不一樣,gin 是如何實現這個的呢?

其實很簡單,不同的方法就是一棵路由樹,所以當 gin 註冊路由的時候,會根據不同的 Method 分別註冊不同的路由樹。

GET    /user/{userID} HTTP/1.1
POST /user/{userID} HTTP/1.1
PUT /user/{userID} HTTP/1.1
DELETE /user/{userID} HTTP/1.1

如這四個請求,分別會註冊四顆路由樹出來。

func (engine *Engine) addRoute(method, path string, handlers HandlersChain) {
//....
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)
// ...
}

其實程式碼也很容易看懂,

  • 拿到一個 method 方法時,去 trees slice 中遍歷

  • 如果 trees slice 存在這個 method, 則這個URL對應的 handler 直接新增到找到的路由樹上

  • 如果沒有找到,則重新建立一顆新的方法樹出來, 然後將 URL對應的 handler 新增到這個路由 樹上

gin 路由的註冊過程

func main() {
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "pong",
})
})
r.Run() // listen and serve on 0.0.0.0:8080
}

這段簡單的程式碼裡,r.Get 就註冊了一個路由 /ping 進入 GET tree 中。這是最普通的,也是最常用的註冊方式。

不過上面這種寫法,一般都是用來測試的,正常情況下我們會將 handler 拿到 Controller 層裡面去,註冊路由放在專門的 route 管理裡面,這裡就不再詳細拓展,等後面具體說下 gin 的架構分層設計。

//controller/somePost.go
func SomePostFunc(ctx *gin.Context) {
// do something
context.String(http.StatusOK, "some post done")
}

```go
// route.go
router.POST("/somePost", controller.SomePostFunc)

使用 RouteGroup

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

RouteGroup 是非常重要的功能,舉個例子:一個完整的 server 服務,url 需要分為 鑑權介面非鑑權介面 ,就可以使用 RouteGroup 來實現。其實最常用的,還是用來區分介面的版本升級。這些操作, 最終都會在反應到gin的路由樹上

gin 路由的具體實現

func main() {
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "pong",
})
})
r.Run() // listen and serve on 0.0.0.0:8080
}

還是從這個簡單的例子入手。我們只需要弄清楚下面三個問題即可:

  • URL->ping 放在哪裡了?

  • handler-> 放在哪裡了?

  • URL 和 handler 是如何關聯起來的?

1. GET/POST/DELETE/..的最終歸宿

func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) IRoutes {
return group.handle(http.MethodGet, relativePath, handlers)
}

在呼叫 POST , GET , HEAD 等路由HTTP相關函式時, 會呼叫 handle 函式。handle 是 gin 路由的統一入口。

// routergroup.go:L72-77
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()
}

2. 生成路由樹

下面考慮一個情況,假設有下面這樣的路由,你會怎麼設計這棵路由樹?

GET /abc 
GET /abd
GET /af

當然最簡單最粗暴的就是每個字串佔用一個樹的葉子節點,不過這種設計會帶來的問題: 佔用記憶體會升高,我們看到 abc, abd, af 都是用共同的字首的,如果能共用字首的話,是可以省記憶體空間的。

gin 路由樹是一棵字首樹. 我們前面說過 gin 的每種方法(POST, GET ...)都有自己的一顆樹,當然這個是根據你註冊路由來的,並不是一上來把每種方式都註冊一遍。gin 每棵路由大概是下面的樣子

這個流程的程式碼太多,這裡就不再貼出具體程式碼裡,有興趣的同學可以按照這個思路看下去即可。

3. handler 與 URL 關聯

type node struct {
path string
indices string
wildChild bool
nType nodeType
priority uint32
children []*node // child nodes, at most 1 :param style node at the end of the array
handlers HandlersChain
fullPath string
}

node 是路由樹的整體結構

  • children 就是一顆樹的葉子結點。每個路由的去掉字首後,都被分佈在這些 children 數組裡

  • path 就是當前葉子節點的最長的字首

  • handlers 裡面存放的就是當前葉子節點對應的路由的處理函式

當收到客戶端請求時,如何找到對應的路由的handler?

《gin 原始碼閱讀(2) - http請求是如何流入gin的?》第二篇說到 net/http 非常重要的函式 ServeHTTP,當 server 收到請求時,必然會走到這個函式裡。由於 gin 實現這個 ServeHTTP,所以流量就轉入 gin 的邏輯裡面。

// gin.go:L439-443
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
c := engine.pool.Get().(*Context)
c.writermem.reset(w)
c.Request = req
c.reset()

engine.handleHTTPRequest(c)

engine.pool.Put(c)
}

所以,當 gin 收到客戶端的請求時, 第一件事就是去路由樹裡面去匹配對應的 URL,找到相關的路由, 拿到相關的處理函式。其實這個過程就是 handleHTTPRequest 要乾的事情。


func (engine *Engine) handleHTTPRequest(c *Context) {
// ...
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
value := root.getValue(rPath, c.params, unescape)
if value.params != nil {
c.Params = *value.params
}
if value.handlers != nil {
c.handlers = value.handlers
c.fullPath = value.fullPath
c.Next()
c.writermem.WriteHeaderNow()
return
}
if httpMethod != "CONNECT" && rPath != "/" {
if value.tsr && engine.RedirectTrailingSlash {
redirectTrailingSlash(c)
return
}
if engine.RedirectFixedPath && redirectFixedPath(c, root, engine.RedirectFixedPath) {
return
}
}
break
}
// ...
}

從程式碼上看這個過程其實也很簡單:

  • 遍歷所有的路由樹,找到對應的方法的那棵樹

  • 匹配對應的路由

  • 找到對應的 handler

總結

說到這裡,基本上把 gin 路由的整個流程說清楚了,不過關於路由樹的詳細實現說的比較籠統,歡迎有興趣的同學入群詳聊(加我好友,我拉你入群)。

寫文章不易,如果你覺得本篇文章還不錯,請大家幫忙 點贊、 在看、 分享 ,感謝感謝。

歡迎關注公眾號。更多學習資料分享,關注公眾號回覆指令:

  • 回覆 0,獲取 《Go 面經》

  • 回覆 1,獲取 《Go 原始碼流程圖》