路由功能是web框架中一個很重要的功能,它將不同的請求轉發給不同的函數(handler)處理,很容易能想到,我們可以用一個字典保存它們之間的對應關系,字典的key存放path,value存放handler。當一個請求過來后,使用 routers.get(path, None) 就可以找到對應的handler。
利用字典實現路由可以參考我的這篇文章:動手實現web框架 。
使用字典有一個問題,不支持動態路由。如果路由像這樣呢?
/hello/:name/profile
name前面是通配符: ,表示這是個動態的值。一個解決辦法是使用前綴樹trie。
前綴樹
leetcode中有這個算法, 點這里 查看。
前綴樹前綴樹,首先是一棵樹。不同的是樹中一個節點的所有子孫都有相同的前綴。前綴樹將單詞中的每個字母依次插入樹中,插入前首先確認該單詞是否存在,不存在才創建新節點,如果一個單詞已經全部插入,則將末尾單詞設置為標志位。
type Node struct {
isWord bool // 是否是單詞結尾
next map[string]*Node // 子節點
}
type Trie struct {
root *Node
}
以單詞leetcode,leetd和code為例,首先一次插入leetcode中的每個單詞,然后插入leetd的時候,leet在樹中已經存在,跳過往下,現在要插入字母d,不存在,所以新建節點插入樹中,並將該節點的isWord置位true,表明到了單詞末尾。
最終插入結果為:
func (this *Trie) Insert(word string) {
cur := this.root
for _, w := range []rune(word) {
c := string(w)
if cur.next[c] == nil {
cur.next[c] = &Node{next: make(map[string]*Node)}
}
cur = cur.next[c]
}
cur.isWord = true
}
那么,當我們要搜索單詞leetd的時候,從根節點開始查找,如果找到某條路徑是leetd,並且末尾的d是單詞標志位,則表示搜索成功。
func (this *Trie) Search(word string) bool {
cur := this.root
for _, w := range []rune(word) {
c := string(w)
if cur.next[c] == nil {
return false
}
cur = cur.next[c]
}
return cur.isWord
}
明白了前綴樹的原理,我們來看看路由匹配是如何利用前綴樹來實現的。
路由前綴樹
go語言中gin框架的路由實現就是利用前綴樹,可以看看它的源代碼是如何實現的。
考慮一下,路由或者說路徑的特點,是以 / 分隔的單詞組成的,那我們將 / 的每一部分掛靠在前綴樹上就可以了。如下圖所示:
還有一點需要考慮,我們在用web框架定義路由的時候,常見的做法是根據不同的HTTP方法來定義。比如:
// 以go語言gin框架為例
g := gin.New()
g.GET("/hello", Hello)
g.POST("/form", Form)
對於同一個路徑,可能有多個方法支持。所以我們要以不同HTTP方法為樹根創建前綴樹。當一個GET請求過來的時候,就從GET樹上搜索,POST請求就從POST樹上搜索。
除了為不同的HTTP方法定義樹之外,還要給那些是通配符的節點增加一個標志位。所以,我們的路由前綴樹結構看起來像這樣:
type node struct {
path string // 路由路徑
part string // 路由中由'/'分隔的部分
children map[string]*node // 子節點
isWild bool // 是否是通配符節點
}
type router struct {
root map[string]*node // 路由樹根節點
route map[string]HandlerFunc // 路由處理handler
}
依照上面的前綴樹算法的實現,照葫蘆畫瓢,我們可以寫出插入路由和搜索路由的方法:
// addRoute 綁定路由到handler
func (r *router) addRoute(method, path string, handler HandlerFunc) {
parts := parsePath(path)
if _, ok := r.root[method]; !ok {
r.root[method] = &node{children: make(map[string]*node)}
}
root := r.root[method]
key := method + "-" + path
// 將parts插入到路由樹
for _, part := range parts {
if root.children[part] == nil {
root.children[part] = &node{
part: part,
children: make(map[string]*node),
isWild: part[0] == ':' || part[0] == '*'}
}
root = root.children[part]
}
root.path = path
// 綁定路由和handler
r.route[key] = handler
}
// getRoute 獲取路由樹節點以及路由變量
func (r *router) getRoute(method, path string) (node *node, params map[string]string) {
params = map[string]string{}
searchParts := parsePath(path)
// get method trie
var ok bool
if node, ok = r.root[method]; !ok {
return nil, nil
}
// 在該方法的路由樹上查找該路徑
for i, part := range searchParts {
var temp string
// 查找child是否等於part
for _, child := range node.children {
if child.part == part || child.isWild {
// 添加參數
if child.part[0] == '*' {
params[child.part[1:]] = strings.Join(searchParts[i:], "/")
}
if child.part[0] == ':' {
params[child.part[1:]] = part
}
temp = child.part
}
}
// 遇到通配符*,直接返回
if temp[0] == '*' {
return node.children[temp], params
}
node = node.children[temp]
}
return
}
上面的代碼是我自己實現的一個web框架 gaga 中路由前綴樹相關的代碼,有需要的可以去看看源代碼。另外,歡迎star 呀。
其中的 addRoute 用來將路由插入到對應method的路由樹中,如果節點是通配符,將其設置為 isWild , 同時綁定路由和handler方法。
getRoute 方法首先查找路由方法對應的路由前綴樹,然后在樹中查找是否存在該路徑。
總結
前綴樹trie算法不光可以用在路由的實現上,搜索引擎中自動補全的實現,拼寫檢查等等都是用trie實現的。trie樹查找的時間和空間復雜度都是線性的,效率很高,很適合路由這種場景使用。
路由的實現上,go語言中 httpRouter 這個庫除了使用前綴樹之外,還加入了優先級,有興趣的可以看看它的源碼了解下。
參考: https://studygolang.com/articles/26324