Golang Web入門(2):如何實現一個高效能的路由

紅雞菌發表於2020-04-19

摘要

上一篇文章中,我們聊了聊在Golang中怎麼實現一個Http伺服器。但是在最後我們可以發現,固然DefaultServeMux可以做路由分發的功能,但是他的功能同樣是不完善的。

DefaultServeMux做路由分發,是不能實現RESTful風格的API的,我們沒有辦法定義請求所需的方法,也沒有辦法在API路徑中加入query引數。其次,我們也希望可以讓路由查詢的效率更高。

所以在這篇文章中,我們將分析httprouter這個包,從原始碼的層面研究他是如何實現我們上面提到的那些功能。並且,對於這個包中最重要的字首樹,本文將以圖文結合的方式來解釋。

1 使用

我們同樣以怎麼使用作為開始,自頂向下的去研究httprouter。我們先來看看官方文件中的小例子:

package main

import (
    "fmt"
    "net/http"
    "log"

    "github.com/julienschmidt/httprouter"
)

func Index(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
    fmt.Fprint(w, "Welcome!\n")
}

func Hello(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
    fmt.Fprintf(w, "hello, %s!\n", ps.ByName("name"))
}

func main() {
    router := httprouter.New()
    router.GET("/", Index)
    router.GET("/hello/:name", Hello)

    log.Fatal(http.ListenAndServe(":8080", router))
}

其實我們可以發現,這裡的做法和使用Golang自帶的net/http包的做法是差不多的。都是先註冊相應的URI和函式,換一句話來說就是將路由和處理器相匹配。

在註冊的時候,使用router.XXX方法,來註冊相對應的方法,比如GETPOST等等。

註冊完之後,使用http.ListenAndServe開始監聽。

至於為什麼,我們會在後面的章節詳細介紹,現在只需要先了解做法即可。

2 建立

我們先來看看第一行程式碼,我們定義並宣告瞭一個Router。下面來看看這個Router的結構,這裡把與本文無關的其他屬性省略:

type Router struct {
	//這是字首樹,記錄了相應的路由
	trees map[string]*node
	
	//記錄了引數的最大數目
	maxParams  uint16

}

在建立了這個Router的結構後,我們就使用router.XXX方法來註冊路由了。繼續看看路由是怎麼註冊的:

func (r *Router) GET(path string, handle Handle) {
	r.Handle(http.MethodGet, path, handle)
}

func (r *Router) POST(path string, handle Handle) {
	r.Handle(http.MethodPost, path, handle)
}

...

在這裡還有一長串的方法,他們都是一樣的,呼叫了

r.Handle(http.MethodPost, path, handle)

這個方法。我們再來看看:

func (r *Router) Handle(method, path string, handle Handle) {
	...
	if r.trees == nil {
		r.trees = make(map[string]*node)
	}

	root := r.trees[method]
	if root == nil {
		root = new(node)
		r.trees[method] = root

		r.globalAllowed = r.allowed("*", "")
	}

	root.addRoute(path, handle)
	...
}

在這個方法裡,同樣省略了很多細節。我們只關注一下與本文有關的。我們可以看到,在這個方法中,如果tree還沒有初始化,則先初始化這顆字首樹

然後我們注意到,這顆樹是一個map結構。也就是說,一個方法,對應了一顆樹。然後,對應這棵樹,呼叫addRoute方法,把URI和對應的Handle儲存進去。

3 字首樹

3.1 定義

又稱單詞查詢樹,Trie樹,是一種樹形結構,是一種雜湊樹的變種。典型應用是用於統計,排序和儲存大量的字串(但不僅限於字串),所以經常被搜尋引擎系統用於文字詞頻統計。它的優點是:利用字串的公共字首來減少查詢時間,最大限度地減少無謂的字串比較,查詢效率比雜湊樹高。

簡單的來講,就是要查詢什麼,只要跟著這棵樹的某一條路徑找,就可以找得到。

比如在搜尋引擎中,你輸入了一個

他會有這些聯想,也可以理解為是一個字首樹。

再舉個例子:

在這顆GET方法的字首樹中,包含了以下的路由:

  • /wow/awesome
  • /test
  • /hello/world
  • /hello/china
  • /hello/chinese

說到這裡你應該可以理解了,在構建這棵樹的過程中,任何兩個節點,只要有了相同的字首,相同的部分就會被合併成一個節點

3.2 圖解構建

上面說的addRoute方法,就是這顆字首樹的插入方法。假設現在數為空,在這裡我打算以圖解的方式來說明這棵樹的構建。

假設我們需要插入的三個路由分別為:

  • /hello/world
  • /hello/china
  • /hello/chinese

(1)插入/hello/world

因為此時樹為空,所以可以直接插入:

(2)插入/hello/china

此時,發現/hello/world/hello/china有相同的字首/hello/

那麼要先將原來的/hello/world結點,拆分出來,然後將要插入的結點/hello/china,截去相同部分,作為/hello/world的子節點。

(3)插入/hello/chinese

此時,我們需要插入/hello/chinese,但是發現,/hello/chinese和結點/hello/有公共的字首/hello/,所以我們去檢視/hello/這個結點的子節點。

注意,在結點中有一個屬性,叫indices。它記錄了這個結點的子節點的首字母,便於我們查詢。比如這個/hello/結點,他的indices值為wc。而我們要插入的結點是/hello/chinese,除去公共字首後,chinese的第一個字母也是c,所以我們進入china這個結點。

這時,有沒有發現,情況回到了我們一開始插入/hello/china時候的局面。那個時候公共字首是/hello/,現在的公共字首是chin

所以,我們同樣把chin截出來,作為一個結點,將a作為這個結點的子節點。並且,同樣把ese也作為子節點。

3.3 總結構建演算法

到這裡,構建就已經結束了。我們來總結一下演算法。

具體帶註釋的程式碼將在本文最末尾給出,如果想要了解的更深可以自行檢視。在這裡先理解這個過程:

(1)如果樹為空,則直接插入
(2)否則,查詢當前的結點是否與要插入的URI有公共字首
(3)如果沒有公共字首,則直接插入
(4)如果有公共字首,則判斷是否需要分裂當前的結點
(5)如果需要分裂,則將公共部分作為父節點,其餘的作為子節點
(6)如果不需要分裂,則尋找有無字首相同的子節點
(7)如果有字首相同的,則跳到(4)
(8)如果沒有字首相同的,直接插入
(9)在最後的結點,放入這條路由對應的Handle

但是到了這裡,有同學要問了:怎麼這裡的路由,不帶引數的呀?

其實只要你理解了上面的過程,帶引數也是一樣的。邏輯是這樣的:在每次插入之前,會掃描當前要插入的結點的path是否帶有引數(即掃描有沒有/或者*)。如果帶有引數的話,將當前結點的wildChild屬性設定為true,然後將引數部分,設定為一個新的子節點

4 監聽

在講完了路由的註冊,我們來聊聊路由的監聽。

上一篇文章的內容中,我們有提到這個:

type serverHandler struct {
	srv *Server
}

func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
	handler := sh.srv.Handler
	if handler == nil {
		handler = DefaultServeMux
	}
	if req.RequestURI == "*" && req.Method == "OPTIONS" {
		handler = globalOptionsHandler{}
	}
	handler.ServeHTTP(rw, req)
}

當時我們提到,如果我們不傳入任何的Handle方法,Golang將使用預設的DefaultServeMux方法來處理請求。而現在我們傳入了router,所以將會使用router來處理請求。

因此,router也是實現了ServeHTTP方法的。我們來看看(同樣省略了一些步驟):

func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	...
	path := req.URL.Path

	if root := r.trees[req.Method]; root != nil {
		if handle, ps, tsr := root.getValue(path, r.getParams); handle != nil {
			if ps != nil {
				handle(w, req, *ps)
				r.putParams(ps)
			} else {
				handle(w, req, nil)
			}
			return
		} 
	}
    ...
    // Handle 404
	if r.NotFound != nil {
		r.NotFound.ServeHTTP(w, req)
	} else {
		http.NotFound(w, req)
	}
}

在這裡,我們選擇請求方法所對應的字首樹,呼叫了getValue方法。

簡單解釋一下這個方法:在這個方法中會不斷的去匹配當前路徑與結點中的path,直到找到最後找到這個路由對應的Handle方法。

注意,在這期間,如果路由是RESTful風格的,在路由中含有引數,將會被儲存在Param中,這裡的Param結構如下:

type Param struct {
	Key   string
	Value string
}

如果未找到相對應的路由,則呼叫後面的404方法。

5 處理

到了這一步,其實和以前的內容幾乎一樣了。

在獲取了該路由對應的Handle之後,呼叫這個函式。

唯一和之前使用net/http包中的Handler不一樣的是,這裡的Handle,封裝了從API中獲取的引數。

type Handle func(http.ResponseWriter, *http.Request, Params)

6 寫在最後

謝謝你能看到這裡~

至此,httprouter介紹完畢,最關鍵的也就是字首樹的構建了。在上面我用圖文結合的方式,模擬了一次字首樹的構建過程,希望可以讓你理解字首樹是怎麼回事。當然,如果還有疑問,也可以留言或者在微信中與我交流~

當然,如果你不滿足於此,可以看看後面的附錄,有字首樹的全程式碼註釋

當然了,作者也是剛入門。所以,可能會有很多的疏漏。如果在閱讀的過程中,有哪些解釋不到位,或者理解出現了偏差,也請你留言指正。

再次感謝~

PS:如果有其他的問題,也可以在公眾號找到作者。並且,所有文章第一時間會在公眾號更新,歡迎來找作者玩~

7 原始碼閱讀

7.1 樹的結構

type node struct {
	
	path      string    //當前結點的URI
	indices   string    //子結點的首字母
	wildChild bool      //子節點是否為引數結點
	nType     nodeType  //結點型別
	priority  uint32    //權重
	children  []*node   //子節點
	handle    Handle    //處理器
}

7.2 addRoute

func (n *node) addRoute(path string, handle Handle) {

	fullPath := path
	n.priority++

	// 如果這是個空樹,那麼直接插入
	if len(n.path) == 0 && len(n.indices) == 0 {

		//這個方法其實是在n這個結點插入path,但是會處理引數
		//詳細實現在後文會給出
		n.insertChild(path, fullPath, handle)
		n.nType = root
		return
	}

	//設定一個flag
walk:
	for {
		// 找到當前結點path和要插入的path中最長的字首
		// i為第一位不相同的下標
		i := longestCommonPrefix(path, n.path)

		// 此時相同的部分比這個結點記錄的path短
		// 也就是說需要把當前的結點分裂開
		if i < len(n.path) {
			child := node{

				// 把不相同的部分設定為一個切片,作為子節點
				path:      n.path[i:],
				wildChild: n.wildChild,
				nType:     static,
				indices:   n.indices,
				children:  n.children,
				handle:    n.handle,
				priority:  n.priority - 1,
			}

			// 將新的結點作為這個結點的子節點
			n.children = []*node{&child}
			// 把這個結點的首字母加入indices中
			// 目的是查詢更快
			n.indices = string([]byte{n.path[i]})
			n.path = path[:i]
			n.handle = nil
			n.wildChild = false
		}

		// 此時相同的部分只佔了新URI的一部分
		// 所以把path後面不相同的部分要設定成一個新的結點
		if i < len(path) {
			path = path[i:]

			// 此時如果n的子節點是帶引數的
			if n.wildChild {
				n = n.children[0]
				n.priority++

				// 判斷是否會不合法
				if len(path) >= len(n.path) && n.path == path[:len(n.path)] &&
					n.nType != catchAll &&
					(len(n.path) >= len(path) || path[len(n.path)] == '/') {
					continue walk
				} else {
					pathSeg := path
					if n.nType != catchAll {
						pathSeg = strings.SplitN(pathSeg, "/", 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的第一位記錄下來
			idxc := path[0]

			// 如果此時n的子節點是帶引數的
			if n.nType == param && idxc == '/' && len(n.children) == 1 {
				n = n.children[0]
				n.priority++
				continue walk
			}

			// 這一步是檢查拆分出的path,是否應該被合併入子節點中
			// 具體例子可看上文中的圖解
			// 如果是這樣的話,把這個子節點設定為n,然後開始一輪新的迴圈
			for i, c := range []byte(n.indices) {
				if c == idxc {
					// 這一部分是為了把權重更高的首字元調整到前面
					i = n.incrementChildPrio(i)
					n = n.children[i]
					continue walk
				}
			}

			// 如果這個結點不用被合併
			if idxc != ':' && idxc != '*' {
				// 把這個結點的首字母也加入n的indices中
				n.indices += string([]byte{idxc})
				child := &node{}
				n.children = append(n.children, child)
				n.incrementChildPrio(len(n.indices) - 1)
				// 新建一個結點
				n = child
			}
			// 對這個結點進行插入操作
			n.insertChild(path, fullPath, handle)
			return
		}

		// 直接插入到當前的結點
		if n.handle != nil {
			panic("a handle is already registered for path '" + fullPath + "'")
		}
		n.handle = handle
		return
	}
}

7.3 insertChild

func (n *node) insertChild(path, fullPath string, handle Handle) {
	for {
		// 這個方法是用來找這個path是否含有引數的
		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] == ':' {
			if i > 0 {
				// 把當前的path設定為引數之前的那部分
				n.path = path[:i]
				// 準備把引數後面的部分作為一個新的結點
				path = path[i:]
			}

			//然後把引數部分作為新的結點
			n.wildChild = true
			child := &node{
				nType: param,
				path:  wildcard,
			}
			n.children = []*node{child}
			n = child
			n.priority++

			// 這裡的意思是,path在引數後面還沒有結束
			if len(wildcard) < len(path) {
				// 把引數後面那部分再分出一個結點,continue繼續處理
				path = path[len(wildcard):]
				child := &node{
					priority: 1,
				}
				n.children = []*node{child}
				n = child
				continue
			}

			// 把處理器設定進去
			n.handle = handle
			return

		} else { // 另外一種情況
			if 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] == '/' {
				panic("catch-all conflicts with existing handle for the path segment root in path '" + fullPath + "'")
			}

			// 判斷在這之前有沒有一個/
			i--
			if path[i] != '/' {
				panic("no / before catch-all in path '" + fullPath + "'")
			}

			n.path = path[:i]

			// 設定一個catchAll型別的子節點
			child := &node{
				wildChild: true,
				nType:     catchAll,
			}
			n.children = []*node{child}
			n.indices = string('/')
			n = child
			n.priority++

			// 把後面的引數部分設定為新節點
			child = &node{
				path:     path[i:],
				nType:    catchAll,
				handle:   handle,
				priority: 1,
			}
			n.children = []*node{child}

			return
		}
	}

	// 對應最開頭的部分,如果這個path裡面沒有引數,直接設定
	n.path = path
	n.handle = handle
}

最關鍵的幾個方法到這裡就全部結束啦,先給看到這裡的你鼓個掌!

這一部分理解會比較難,可能需要多看幾遍。

如果還是有難以理解的地方,歡迎留言交流,或者直接來公眾號找我~

相關文章