学习资料来自:GitHub - geektutu/7days-golang: 7 days golang programs from scratch (web framework Gee, distributed cache GeeCache, object relational mapping ORM framework GeeORM, rpc framework GeeRPC etc) 7天用Go动手写/从零实现系列https://github.com/geektutu/7days-golang
Go语言动手写Web框架 - Gee第三天 前缀树路由Router | 极客兔兔 (geektutu.com)https://geektutu.com/post/gee-day3.html
其中pattern表示当前节点可以匹配的路由;part表示当前节点对应的路由某一部分的内容;children则存储当前节点的子节点,即后续可匹配路由;isWild 则表示当前节点是否是模糊匹配。(模糊匹配举例,/:lang/ 可匹配/go/或者/aaa/等内容;/*filepath/ 匹配后续文件路径)
type node struct {
pattern string // 待匹配的路由
part string // 节点内容,即树中的某一部分,例如:lang,about等
children []*node // 当前节点的子节点
isWild bool // 是否模糊匹配,当含有:或者*时为 true
}
1)匹配
(1)查找子树中第一个匹配:遍历当前节点对象的所有子节点,遇到第一个 part 部分可匹配的节点即返回。
func (n *node) matchChild(part string) *node {
for _, child := range n.children { // 遍历当前节点的所有子节点
if child.part == part || child.isWild {
return child
}
}
return nil
}
(2) 查找子树中所有可能的匹配:遍历当前节点对象的所有子节点,将所有可匹配的节点放入 slice 中,遍历所有节点后,返回 slice
func (n *node) matchChilren(part string) []*node {
matchedNodes := make([]*node, 0)
for _, child := range n.children {
if child.part == part || child.isWild {
matchedNodes = append(matchedNodes, child)
}
}
return matchedNodes
}
2) 插入和查询
(1)插入:对应路由中的注册过程,根据目标路由内容,在当前节点的子树中找第一个可匹配的节点。如果可匹配的节点不存在,根据目标路由内容,构建新的节点,并将该新节点插入到当前节点的子树中。然后再根据完整路由,插入下一个路由内容,递归完成整个路由内容的节点插入,即完成路由的注册。
func (n *node) insert(pattern string, parts []string, height int) {
if len(parts) == height { //已经是最后一个节点,不需要再插入,更新已有待匹配字符即可
n.pattern = pattern
return
}
part := parts[height] // 当前节点内容
child := n.matchChild(part) // 找第一个匹配的树节点(当前节点准备插入的位置)
if child == nil { // 未找到可匹配的节点,需要进行插入操作,添加为当前节点的子节点
child = &node{part: part, isWild: part[0] == ':' || part[0] == '*'}
n.children = append(n.children, child)
}
child.insert(pattern, parts, height+1) // 递归到下一层,使用下一个part继续插入
}
(2)查询:对应路由中的注册过程,根据已有的trie树,查找所给的路由对应的树节点。当查找到最后一个节点或者当前节点包含*通配符,证明路由成功匹配,返回当前节点。否则查找子树中所有可以匹配的节点,遍历这些节点,递归查找下一层是否匹配,直至找到完全匹配路由的叶子节点,并将该叶子节点返回。
func (n *node) search(parts []string, height int) *node {
if len(parts) == height || strings.HasPrefix(n.part, "*") { // 查找到最后一个节点,或者匹配到 *
if n.pattern == "" {
return nil
}
return n
}
part := parts[height] // 获取当前节点内容
children := n.matchChilren(part) // 查找n的子树中所有符合匹配的节点
for _, child := range children {
result := child.search(parts, height+1) // 递归查找下一层节点是否匹配
if result != nil { // 匹配则返回叶子节点
return result
}
}
return nil // 未找到可匹配的,返回nil
}
其中,roots 是请求的路由对应的树根节点,用于判断路由是否匹配,起始的树节点为 GET/POST等请求类型节点(key eg, roots['GET'] roots['POST']);handlers存储对应的响应处理函数,键值通常为 “请求类型-完整路由” 例如 handlers['GET-/p/:lang/doc'], handlers['POST-/p/book']。
type router struct {
roots map[string]*node // 请求的路由对应的树根节点,用于判断路由是否匹配(key eg, roots['GET'] roots['POST'])
handlers map[string]HandlerFunc // 对应的处理函数(key eg, handlers['GET-/p/:lang/doc'], handlers['POST-/p/book'])
}
// ---构造函数
func newRouter() *router {
return &router{
roots: make(map[string]*node),
handlers: make(map[string]HandlerFunc),
}
}
根据”/“对路由进行划分,将其拆分成可作为node.part的各个部分,注意:理由中只能有一个*,*后的所有内容均被视为文件路径。
func parsePattern(pattern string) []string {
vs := strings.Split(pattern, "/") // 使用 ’/‘ 对字符串进行分割
parts := make([]string, 0) // 初始化路由的各个部分
for _, item := range vs { // 遍历路由中每一部分
if item != "" { // 如果该部分不为空
parts = append(parts, item) // 添加路由
if item[0] == '*' {
break
}
}
}
return parts
}
1)注册与匹配
(1)注册:根据所给的请求类型以及路由,构建可匹配的 trie 树。将路由拆分后依次插入到请求类型对应的子树中,同时在handlers中存储对应的响应方式。
func (r *router) addRoute(method string, pattern string, handler HandlerFunc) {
parts := parsePattern(pattern) // 完成对路由的分析,获取其中的各个部分
key := method + "-" + pattern // 构建router中handlers的注册路由
_, ok := r.roots[method]
if !ok { // 该方法还没有树根节点,添加一个空节点便于插入
r.roots[method] = &node{}
}
r.roots[method].insert(pattern, parts, 0) // 像树中添加该路由的各个节点
r.handlers[key] = handler
}
(2)匹配: 根据请求类型以及对应的路由,返回匹配的节点以及对应的参数列表(模糊匹配部分的内容)
func (r *router) getRoute(method string, path string) (*node, map[string]string) {
searchParts := parsePattern(path) // 获取待查找路由的各个部分
params := make(map[string]string) // 模糊匹配对应的匹配内容
root, ok := r.roots[method] // 获取该类型请求对应的树节点
if !ok {
return nil, nil
} // 不存在该类型请求的路由,直接返回空
n := root.search(searchParts, 0) // 查找是否存在匹配的路由节点
if n != nil { //节点匹配
parts := parsePattern(n.pattern) // 解析当前找到的节点的路由
for index, part := range parts { // 遍历路由的各个部分
if part[0] == ':' { // 遇到模糊匹配:
params[part[1:]] = searchParts[index] // key:除匹配符(:)的其余字符,value:待匹配路由的对应位置内容
}
if part[0] == '*' && len(part) > 1 { // 遇到模糊匹配*
params[part[1:]] = strings.Join(searchParts[index:], "/") // key:除匹配符(*)的其余字符,value:待匹配路由之后的内容
break // 后续可不再匹配
}
}
return n, params // 返回匹配的节点,以及模糊匹配对应的内容
}
return nil, nil // 没有匹配的节点,直接返回空
}
2)根据路由给出响应
根据请求的类型以及路由找到匹配的节点后,返回对应的响应内容。
func (r *router) handle(c *Context) {
n, params := r.getRoute(c.Method, c.Path) // 根据请求中的路由进行匹配
if n != nil { // 查找到对应的路由,返回对应的响应
c.Params = params
key := c.Method + "-" + n.pattern
r.handlers[key](c)
} else { // 未查找到对应的路由,返回未找到路由的响应
c.String(http.StatusNotFound, "404 NOT FOUND: %s\n", c.Path)
}
}
对context结构进行如下修改:
1)结构体中增加 Params: make(map[string]string),Params存储模糊匹配对应的内容(例如 param[”:lang“]=”go“),需要时可通过Param获取相应的模糊匹配参数。
2)构造函数增加对Params的初始化。
3)新增方法,根据所给的模糊匹配的内容,返回对应路由中的匹配内容
func (c *Context) Param(key string) string {
value, _ := c.Params[key]
return value
}
测试的main函数
func main() {
r := gee.New()
r.GET("/", func(c *gee.Context) {
c.HTML(http.StatusOK, "Hello Gee
")
})
r.GET("/hello", func(c *gee.Context) {
c.String(http.StatusOK, "hello %s, you're at %s\n", c.Query("name"), c.Path)
})
r.GET("/hello/:name", func(c *gee.Context) {
// expect /hello/geektutu
c.String(http.StatusOK, "hello %s, you're at %s\n", c.Param("name"), c.Path)
})
r.GET("/assets/*filepath", func(c *gee.Context) {
c.JSON(http.StatusOK, gee.H{"filepath": c.Param("filepath")})
})
r.POST("/login", func(c *gee.Context) {
c.JSON(http.StatusOK, gee.H{
"username": c.PostForm("username"),
"password": c.PostForm("password"),
})
})
r.Run(":9999")
}
命令行使用 curl 进行路由测试,返回指定内容