go web框架 gin-gonic源碼解讀02————router
本來想先寫context,但是發現context能簡單講講的東西不多,就準備直接和router合在一起講好了
router是web服務的路由,是指講來自客戶端的http請求與服務器端的處理邏輯或者資源相映射的機制。(這里簡單說說,詳細的定義網上都可以查到)
那一個優秀的web router應該提供以下功能:
- URL解析:路由的過程始于URL解析。URL是一個標識資源位置的字符串,通常由協議、主機名、端口號和路徑組成。服務器需要解析這個URL,以便找到對應的處理程序或資源。
- 路由規則:在Web應用程序中,通常會定義一組路由規則,這些規則將特定的URL模式與相應的處理程序或資源進行綁定。路由規則可以基于URL路徑、HTTP方法(GET、POST等)、查詢參數和其他條件來匹配請求。
- 動態路由:除了靜態路由(固定的URL與處理程序映射)外,現代Web應用程序還支持動態路由。動態路由允許使用參數將URL與處理程序關聯起來。這樣,同一種類型的請求可以通過不同的參數調用不同的處理程序,實現更靈活和通用的路由。
- …
為了展示gin框架的router的優越性,我們先看看go原生的net/http處理(不是說net/http不好,只是說說他的一些小缺點,不要網暴我) 這里摘抄部分net/http默認的路由器的代碼
// GO\src\net\http\server.go// ServeMux就是我們net/http默認的router
type ServeMux struct {mu sync.RWMutex // 大家都知道為了并發安全,搞得讀鎖 m map[string]muxEntry // 這個map就是我們路由的核心es []muxEntry // slice of entries sorted from longest to shortest.// 為了前綴匹配搞得切片,后面func match() 方法你一看就知道他是干什么用的了 hosts bool // whether any patterns contain hostnames
}// 上一篇文章說過的每個Server都要實現的ServeHTTP(w ResponseWriter, r *Request)接口會調用這個方法來獲取要執行的h Handler(可以理解為這個傳參path的這個url對應的邏輯)
func (mux *ServeMux) handler(host, path string) (h Handler, pattern string) {mux.mu.RLock()defer mux.mu.RUnlock()// Host-specific pattern takes precedence over generic ones// 本代碼段行6的波爾值,這個配置一般是有要區別不同主機會有不同邏輯的時候才會用if mux.hosts {// 這個match就是我們net/http的匹配算法h, pattern = mux.match(host + path)}if h == nil {// 這個match就是我們net/http的匹配算法h, pattern = mux.match(path)}if h == nil {// 默認的404處理返回函數h, pattern = NotFoundHandler(), ""}return
}// 匹配算法 (非常的簡單,特別是和后面的gin比起來)
func (mux *ServeMux) match(path string) (h Handler, pattern string) {// Check for exact match first.// 很簡單直接去map里find,其實效率也還可以,但是這里你就能看出來他只能支持靜態路由,map這里也不支持模糊搜索v, ok := mux.m[path]if ok {// return v.h, v.pattern}// Check for longest valid match. mux.es contains all patterns// that end in / sorted from longest to shortest.// 這里英文注釋也蠻清楚的,就是map里找不到,這里找一下以入參path為前綴的url。// 并且這個mux.es還是有序的,為了提升一點效率,從這里看他似乎也不是完全靜態的。for _, e := range mux.es {if strings.HasPrefix(path, e.pattern) {return e.h, e.pattern}}return nil, ""
}
這里篇幅有限只講講net/http的match,inster就不說了。
從上面的macth代碼也可以看出net/http的路由存在以下缺點
-
缺乏靈活的路由定義:net/http 包的路由定義相對簡單,只能通過 http.HandleFunc 或 http.Handle 來定義路由處理函數。這導致難以支持復雜的路由模式,如正則表達式匹配、參數提取等
-
不支持動態路由:這個包并沒有原生支持動態路由,即不能直接將路由和參數綁定起來,需要手動解析 URL。
-
不支持中間件:中間件是一種常用的擴展路由處理的方法,可以在請求到達路由處理函數之前或之后執行一些操作。然而,net/http 包沒有內置的中間件支持,需要手動編寫和管理中間件。
-
不支持子路由:在一些應用場景中,需要將不同類型的請求映射到不同的處理函數,這些處理函數可能共享某些共同的前綴。net/http 包并沒有內置的子路由支持,需要自己實現。
-
不支持路由組:在一些情況下,希望將一組相關的路由規則進行分組管理,以便更好地組織代碼。net/http 包沒有原生支持路由組的功能。
接下來正片開始,講router主要講兩個函數:match(路由匹配),insert(路由注冊)
gin的路由數據結構
和大多說的web框架一樣,gin選擇了使用前綴樹算法,來進行路由匹配,因為確實蠻合適的。前綴樹這邊不細講了,蠻簡單的,大家可Google看看。這里直接擼gin的源碼。
// ../gin/tree.go// static:表示靜態節點。靜態節點是指路由路徑中沒有參數或通配符的節點,其值是固定的字符串。例如,路徑 "/home" 中的 "home" 就是一個靜態節點。
// root:表示根節點。根節點是整個路由樹的頂級節點,它沒有路徑,其作用是起始點,用于構建路由樹的根結構。
// param:表示參數節點。參數節點是指路由路徑中的一部分可以是變量的節點,例如 "/user/:id" 中的 ":id" 就是一個參數節點,可以匹配任意值。
// catchAll:表示通配符節點。通配符節點是指路由路徑中的一部分可以匹配任意內容的節點,例如 "/static/*filepath" 中的 "*filepath" 就是一個通配符節點,可以匹配以 "/static/" 開頭的所有路徑。type nodeType uint8const (static nodeType = iotarootparamcatchAll
)
// 這個結構存放的是每個方法的根節點,如GET,POST,PUT,他們的根節點也是這種方式在Engine中存儲的
// type Engine struct {
// ...
// // 簡單來書就是每種請求方法是一顆獨立的前綴樹
// trees methodTrees
// ...
// }
type methodTrees []methodTree
// 一個簡單的get方法
func (trees methodTrees) get(method string) *node {for _, tree := range trees {if tree.method == method {return tree.root}}return nil
}type methodTree struct {method stringroot *node
}// 樹的各個節點
type node struct {// 到該節點的路由路徑片段,例如"/home",那他就是hemopath string// 索引,下文細講indices string// 子節點中,是否是有通配符節點,有的話插入新的節點時,要注意維護通配符節點是最后一個節點wildChild bool// 上文提到的該節點的類型nType nodeType// 優先級,下文細講priority uint32// 子節點,gin是以字符串的每個字符當做節點的children []*node// 所有的中間件和對應的url處理的邏輯函數,后續講中間件的時候細講為什么中間件和服務邏輯函數寫在一個數組里handlers HandlersChain// 全路徑fullPath string
}
路由器的節點插入(路由綁定)
使用過gin框架的同學都知道,我們在使用go-gin時,只需要 gin.GET(“/hello/world”, helloworld) 這么一句簡單的代碼就可以實現url"/hello/world" 和 邏輯函數helloworld()的綁定,接下來讓我們看看func GET()里都發生了什么。
func (group *RouterGroup) Handle(httpMethod, relativePath string, handlers ...HandlerFunc) IRoutes {if matched := regEnLetter.MatchString(httpMethod); !matched {panic("http method " + httpMethod + " is not valid")}return group.handle(httpMethod, relativePath, handlers)
}// POST is a shortcut for router.Handle("POST", path, handlers).
func (group *RouterGroup) POST(relativePath string, handlers ...HandlerFunc) IRoutes {return group.handle(http.MethodPost, relativePath, handlers)
}// GET is a shortcut for router.Handle("GET", path, handlers).
func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) IRoutes {return group.handle(http.MethodGet, relativePath, handlers)
}
當然不止get和post,gin還定義各種其他的http的請求方法,但是都大同小異,這邊以get和post舉例。
從這里可以看出來不管是什么http的方法最終調用的都是Handle 方法,并且講請求方法作以string的方式傳入。比如注釋上說的
// POST is a shortcut for router.Handle(“POST”, path, handlers)
func (group *RouterGroup) Handle(httpMethod, relativePath string, handlers …HandlerFunc) IRoutes {}
而Handle()方法之中又調用了一個handle方法
// gin.gofunc (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes {// (group *RouterGroup) RouterGroup 大家常用也很熟悉,就是我們的url分組,我們開發中會為每個url分組設置一個統一的前綴url,// group.calculateAbsolutePath(relativePath)這一步是為了幫助我們拼接處正確的url全文absolutePath := group.calculateAbsolutePath(relativePath)// handlers 就是我們這篇文章上面所講的node 結構體中的handlers 。// 這一步是為了把你注冊路由綁定的服務邏輯方法綁定到調用鏈上,這個下一章講中間件的時候會細講handlers = group.combineHandlers(handlers)// 終于到了最終的一步!AddRouter,這個方法里就是我們路由器的節點插入(路由綁定)核心方法group.engine.addRoute(httpMethod, absolutePath, handlers)return group.returnObj()
}// 核心方法 addRoute (這里為了篇幅我會做一些摘抄)
func (engine *Engine) addRoute(method, path string, handlers HandlersChain) {// 做一些斷言,檢測一下輸入的url是否合法,比較越早階段的暴露問題是程序的鐵律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")// debug模式下一些日志打印(不太重要)debugPrintRoute(method, path, handlers)// engine.tree中存儲了一個http請求方法的字符串為根節點的切片,所以這里我們拿http請求方法method先get// 這個get方法在上文有,大家可以自己拉上去看看root := engine.trees.get(method)// 一個好的設計肯定不可能一開始把所有的可能性都構造出來,這邊也是,管你是get還是post,都是用到了再創建,然后插入。if root == nil {root = new(node)root.fullPath = "/"// 這種http請求方法是第一次出現,這邊創建一個,并且插入engine.trees = append(engine.trees, methodTree{method: method, root: root})}// 核心的核心來了,大家請看下面的代碼塊tree.goroot.addRoute(path, handlers)// Update maxParamsif paramsCount := countParams(path); paramsCount > engine.maxParams {engine.maxParams = paramsCount}if sectionsCount := countSections(path); sectionsCount > engine.maxSections {engine.maxSections = sectionsCount}
}
tree.go
// tree.go// 核心的核心非常的長
func (n *node) addRoute(path string/*綁定的url路徑*/, handlers HandlersChain/*綁定的邏輯函數*/) {fullPath := path// 優先級++,主要用于每個節點子節點排序,提高查找的效率n.priority++// Empty treeif len(n.path) == 0 && len(n.children) == 0 {n.insertChild(path, fullPath, handlers)n.nType = rootreturn}parentFullPathIndex := 0walk:for {// Find the longest common prefix.// This also implies that the common prefix contains no ':' or '*'// since the existing key can't contain those chars.// 大家注意哈,我們這里進循環了,請開始轉動你的小腦瓜// 就是找當前節點的路徑和你patch最長的相同的前綴,并且放回下標i := longestCommonPrefix(path, n.path)// Split edge// 顯然現在的path和該節點的path有不相同的地方,說明我們的前綴樹要開始分叉了// 接下來我們要做的就是把我們傳入的path從不相同的地方開始拆分成兩個節點。if i < len(n.path) {// 創建一個孩子節點, 初始化的時候我們先把大部分的值設置的和我們的當前節點一致child := node{// 這里就是我說的拆分的地方,你看從兩者字符串不同的地方的下標剪斷了path: n.path[i:],wildChild: n.wildChild,nType: static,indices: n.indices,children: n.children,handlers: n.handlers,// 這個開始拆分就表示他至少會有兩個子節點,優先級就降低了priority: n.priority - 1,fullPath: n.fullPath,}// 由于一個分支下的所有孩子節點都會有相同的前綴,也就是他們父節點所記錄的值,這里字符出現了不同// 其實就是我們當前節點成了父節點,我們要把相同的部分留給當前節點,然后不同部分分成兩個新的子節點// 所以這里先把當前節點拆了,拆一個子節點出來。n.children = []*node{&child}// []byte for proper unicode char conversion, see #65// 這段代碼看完也基本解開了indices這個索引變量的神秘面紗了,他其實就是該節點的所有字節的首字符// 拼接在一起的一個字符串,方便后面的查找,這里只有一個i是因為他把當前節點拆成了0-i和i-len兩個節點// 在我們path插入之前他肯定只有一個孩子節點。n.indices = bytesconv.BytesToString([]byte{n.path[i]})n.path = path[:i]// 當前節點現在被拆分了,他現在只是前綴樹上的一個分叉節點,它本身并不代表某個url,所以給他的這兩個參數置為nil和falsen.handlers = niln.wildChild = falsen.fullPath = fullPath[:parentFullPathIndex+i]}// Make new node a child of this node// 當前節點雖然已經被拆成了兩個節點:父節點(當前node與我們字符匹配的部分)--> 子節點(當前node與我們字符不匹配的部分)// 當時我們自己的path還沒有變成節點插入呢。這里會有兩種情況,一種是字符串匹配下標i小于path的長度或者大于等于path的長度// 這里我們分開處理,先說 i < len(path) 這種情況下我們tree會是下圖這種情況// 父節點(當前node與我們字符匹配的部分)// |--> 子節點1(當前node與我們字符不匹配的部分)// |--> 字節點2(我們的path)// 這里其實就兩種情況。1.i<len(path);2.i=len(path);1情況下面還有幾種情況要處理// 而情況2相當于當前節點就是我們的path了,需要給他綁定邏輯函數handlersif i < len(path) {// path的前半段path[0-i]已經被當前節點node表示,所以這里裁掉path = path[i:]c := path[0]// '/' after param// 這里處理一種特殊情況,當前節點是param參數節點,且他只有一個子節點,并且我們用來查找的字符串是'/'// 那就這個節點這里兩者是可以匹配上的,那我們直接把當前節點變成子節點繼續匹配。if n.nType == param && c == '/' && len(n.children) == 1 {parentFullPathIndex += len(n.path)// continue了,請大家帶著當前所有的記憶返回 代碼行walk:n = n.children[0]n.priority++continue walk}// Check if a child with the next path byte exists// 遍歷當前節點的所有索引,其實就是看看孩子節點里有沒有哪個首字符和我們path首字符一樣的for i, max := 0, len(n.indices); i < max; i++ {if c == n.indices[i] {parentFullPathIndex += len(n.path)// 有的話這個孩子就是我們的當前節點了,所以我們要維護一下這個children節點,并且再次拿到他的下標// 維護的方法incrementChildPrio()這個的代碼我貼在下面了i = n.incrementChildPrio(i)n = n.children[i]// continue了,請大家帶著當前所有的記憶返回 代碼行walk:continue walk}}// Otherwise insert it// 走到這一步,說明當前節點的所有的子節點都沒有匹配上,我們先看看要插入的path是否是匹配路徑(c != ':' && c != '*')// 再看看我們的當前節點是否是匹配節點(n.nType != catchAll)if c != ':' && c != '*' && n.nType != catchAll {// 如果都不是,那說明是個正常節點和正常path,那我們把這個path當這個正常子節點,先給他創造結構體,后續統一插入// []byte for proper unicode char conversion, see #65// 更新當前節點的索引n.indices += bytesconv.BytesToString([]byte{c})// 創建結構體,等待后續插入child := &node{fullPath: fullPath,}// 給當前節點插入子節點n.addChild(child)// 維護索引n.indices有序n.incrementChildPrio(len(n.indices) - 1)n = child} else if n.wildChild {// 到這一步說明當前節點的子節點中有通配符節點,那我們直接取子節點的最后一個節點// (插入子節點的時候我們會特意維護,通配符節點的最后一個,這樣子取用起來也很方便)// inserting a wildcard node, need to check if it conflicts with the existing wildcardn = n.children[len(n.children)-1]n.priority++// Check if the wildcard matches// 檢查n是否和path匹配(這里n = n.children[len(n.children)-1]了)if len(path) >= len(n.path) && n.path == path[:len(n.path)] &&// Adding a child to a catchAll is not possiblen.nType != catchAll &&// Check for longer wildcard, e.g. :name and :names(len(n.path) >= len(path) || path[len(n.path)] == '/') {// 匹配上了我們直接continue 整個再來一次continue walk}// Wildcard conflict// 這都沒匹配上,說明出問題了,這里拼接一下錯誤,panic了// 一般是同一級分支下出現了兩個同級的通配符 ,示例可以看下文的func TestTreePanic1(t *testing.T)pathSeg := pathif n.nType != catchAll {pathSeg = strings.SplitN(pathSeg, "/", 2)[0]}prefix := fullPath[:strings.Index(fullPath, pathSeg)] + n.pathpanic("'" + pathSeg +"' in new path '" + fullPath +"' conflicts with existing wildcard '" + n.path +"' in existing prefix '" + prefix +"'")}// 這里說明c是通配符(c == ':' || c == '*')// 或n是全匹配節點(n.nType != catchAll)// 并且都當前節點沒有通配符子節點,直接插入n.insertChild(path, fullPath, handlers)return}// Otherwise add handle to current nodeif n.handlers != nil {panic("handlers are already registered for path '" + fullPath + "'")}// 迭代到這里了就是當前節點n就是我們path了,需要最后做一些賦值n.handlers = handlersn.fullPath = fullPathreturn}
}func longestCommonPrefix(a, b string) int {i := 0max := min(len(a), len(b))for i < max && a[i] == b[i] {i++}return i
}// Increments priority of the given child and reorders if necessary
// 這里主要做了一個排序操作,維護了一下node的子節點切片,使他們以priority的 大小為規則排序
// 排序之后我們要拿的那個兒子節點的下標有可能會改變,所以還要返回一個維護過得newpos來保證返回的是正確的坐標
func (n *node) incrementChildPrio(pos int) int {cs := n.children// 優先級現先++cs[pos].priority++prio := cs[pos].priority// Adjust position (move to front)newPos := pos// 經典冒泡,根據優先級priority進行排序for ; newPos > 0 && cs[newPos-1].priority < prio; newPos-- {// Swap node positionscs[newPos-1], cs[newPos] = cs[newPos], cs[newPos-1]}// Build new index char stringif newPos != pos {n.indices = n.indices[:newPos] + // Unchanged prefix, might be emptyn.indices[pos:pos+1] + // The index char we moven.indices[newPos:pos] + n.indices[pos+1:] // Rest without char at 'pos'}return newPos
}
func findWildcard(path string) (wildcard string, i int, valid bool) {// Find startfor start, c := range []byte(path) {// A wildcard starts with ':' (param) or '*' (catch-all)if c != ':' && c != '*' {continue}// Find end and check for invalid charactersvalid = truefor end, c := range []byte(path[start+1:]) {switch c {case '/':return path[start : start+1+end], start, validcase ':', '*':valid = false}}return path[start:], start, valid}return "", -1, false
}
func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) {for {// Find prefix until first wildcard// 這個就是查找path是否有通配符的'*',':','/'// 沒有查到直接break// wildcard拿的是中間那段比如:/:w/hello 那wildcard就是:wwildcard, i, valid := findWildcard(path)if i < 0 { // No wildcard foundbreak}// The wildcard name must only contain one ':' or '*' characterif !valid {panic("only one wildcard per path segment is allowed, has: '" +wildcard + "' in path '" + fullPath + "'")}// check if the wildcard has a nameif len(wildcard) < 2 {panic("wildcards must be named with a non-empty name in path '" + fullPath + "'")}// 如果wildcard首字符是':'拼裝child,把wildcard之前的不是通配符區塊的,拼接到n的path中if wildcard[0] == ':' { // paramif i > 0 {// Insert prefix before the current wildcardn.path = path[:i]path = path[i:]}child := &node{nType: param,path: wildcard,fullPath: fullPath,}n.addChild(child)n.wildChild = truen = childn.priority++// if the path doesn't end with the wildcard, then there// will be another subpath starting with '/'if len(wildcard) < len(path) {path = path[len(wildcard):]child := &node{priority: 1,fullPath: fullPath,}n.addChild(child)n = childcontinue}// Otherwise we're done. Insert the handle in the new leafn.handlers = handlersreturn}// catchAllif i+len(wildcard) != len(path) {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] == '/' {pathSeg := strings.SplitN(n.children[0].path, "/", 2)[0]panic("catch-all wildcard '" + path +"' in new path '" + fullPath +"' conflicts with existing path segment '" + pathSeg +"' in existing prefix '" + n.path + pathSeg +"'")}// currently fixed width 1 for '/'i--if path[i] != '/' {panic("no / before catch-all in path '" + fullPath + "'")}n.path = path[:i]// First node: catchAll node with empty pathchild := &node{wildChild: true,nType: catchAll,fullPath: fullPath,}n.addChild(child)n.indices = string('/')n = childn.priority++// second node: node holding the variablechild = &node{path: path[i:],nType: catchAll,handlers: handlers,priority: 1,fullPath: fullPath,}n.children = []*node{child}return}// If no wildcard was found, simply insert the path and handlen.path = pathn.handlers = handlersn.fullPath = fullPath
}
這里放一些panic的或者正常的測試代碼實例方便大家理解
func TestTreePanic1(t *testing.T) {tree := &node{}routes := [...]string{"/hi/","/hi/:go","/hi/:go1",}for _, route := range routes {tree.addRoute(route, fakeHandler(route))}
}