Gee第三天 前缀树路由Router
Trie 树简介
之前,我们用了一个非常简单的map
结构存储了路由表,使用map
存储键值对,索引非常高效,但是有一个弊端,键值对的存储的方式,只能用来索引静态路由。那如果我们想支持类似于/hello/:name
这样的动态路由怎么办呢?所谓动态路由,即一条路由规则可以匹配某一类型而非某一条固定的路由。例如/hello/:name
,可以匹配/hello/geektutu
、hello/jack
等。
动态路由有很多种实现方式,支持的规则、性能等有很大的差异。例如开源的路由实现gorouter
支持在路由规则中嵌入正则表达式,例如/p/[0-9A-Za-z]+
,即路径中的参数仅匹配数字和字母;另一个开源实现httprouter
就不支持正则表达式。著名的Web开源框架gin
在早期的版本,并没有实现自己的路由,而是直接使用了httprouter
,后来不知道什么原因,放弃了httprouter
,自己实现了一个版本。
HTTP请求的路径恰好是由/
分隔的多段构成的,因此,每一段可以作为前缀树的一个节点。我们通过树结构查询,如果中间某一层的节点都不满足条件,那么就说明没有匹配到的路由,查询结束。
接下来我们实现的动态路由具备以下两个功能。
- 参数匹配
:
。例如/p/:lang/doc
,可以匹配/p/c/doc
和/p/go/doc
。 - 通配
*
。例如/static/*filepath
,可以匹配/static/fav.ico
,也可以匹配/static/js/jQuery.js
,这种模式常用于静态服务器,能够递归地匹配子路径。
Gee\http-base\base1\gee\trie.go
首先就要设计树节点上应该存储那些信息
type node struct {
pattern string //待匹配路由,例如 /p/:lang
part string //路由中的一部分,例如:land
children []*node //子节点
isWild bool //是否精确匹配,part含有 : 或 * 时为true
}
为了实现动态路由匹配,加上了isWild这个参数。即当我们匹配 /p/go/doc/这个路由时,第一层节点,p精准匹配到了p,第二层节点,go模糊匹配到:lang,那么将会把lang这个参数赋值为go,继续下一层匹配。我们将匹配的逻辑,包装为一个辅助函数。
//用于查找当前节点的子节点中是否有匹配给定部分(part)的节点
//child.isWild: 如果当前子节点是一个通配符节点,则直接返回该节点。通配符节点用于表示路由中的动态部分,比如 :lang。
func (n *node) matchChild(part string) *node {
//遍历当前节点 n 的所有子节点
for _, child := range n.children {
//在循环中,对每个子节点child检查它的part属性是否与给定的part相等或者它是一个通配符节点
if child.part == part || child.isWild {
return child
}
}
return nil
}
//用于查找当前节点的子节点中是否有匹配给定部分(part)的节点,并返回所有匹配的节点
func (n *node) matchChildren(part string) []*node {
//创建了一个切片 nodes,用于存储所有匹配的子节点
nodes := make([]*node, 0)
for _, child := range n.children {
//在循环中,对于每个子节点 child,检查它的 part 属性是否与给定的 part 相等,或者它是否是一个通配符节点(isWild)
if child.part == part || child.isWild {
//如果找到了匹配的子节点,则将其追加到nodes切片中
nodes = append(nodes, child)
}
}
//返回存储所有匹配子节点的切片
return nodes
}
对于路由来说,最重要的当然是注册与匹配了。开发服务时,注册路由规则,映射handler;访问时,匹配路由规则,查找到对应的handler。
//这个方法的作用是向前缀树中插入一个新的路径模式。如果路径模式已经存在,则更新对应节点的 pattern 属性;如果路径模式不存在,则创建相应的节点并插入路径模式
func (n *node) insert(pattern string, parts []string, height int) {
//如果当前高度 height 等于路径模式的部分数量,说明已经到达路径模式的末尾。
if len(parts) == height {
//如果到达了路径模式的末尾,将当前节点的 pattern 属性设置为给定的 pattern,表示该节点是路径的末端
n.pattern = pattern
return
}
//获取当前高度对应的路径部分
part := parts[height]
//调用matchChild方法查找当前节点的子结点中是否有与当前部分匹配的节点
child := n.matchChild(part)
//如果没有找到匹配的子节点,需要创建一个新的子节点
if child == nil {
//其中 part 属性设置为当前部分,isWild 属性表示当前部分是否是一个通配符节点
child = &node{part: part, isWild: part[0] == ':' || part[0] == '*'}
//将新创建的子节点添加到当前节点的子节点列表中
n.children = append(n.children, child)
}
//递归调用 insert 方法,向下一层节点插入路径模式的下一个部分,递归的高度加一。
child.insert(pattern, parts, height+1)
}
//这个方法的作用是在前缀树中搜索与给定路径模式匹配的节点,并返回匹配的节点。
func (n *node) search(parts []string, height int) *node {
//检查是否已经到达路径的末尾或当前节点是一个通配符节点(以 "*" 开头)
if len(parts) == height || strings.HasPrefix(n.part, "*") {
//如果当前节点没有存储路径模式,即 pattern 属性为空,说明没有找到匹配的路径模式,返回 nil
if n.pattern == "" {
return nil
}
//否则返回当前节点,表示找到了与给定路径匹配的节点
return n
}
//获取当前高度对应的路径部分
part := parts[height]
//调用 matchChildren 方法查找当前节点的子节点中与当前部分匹配的所有节点
//matchChildren返回的是 []*node 结构体类型的切片
children := n.matchChildren(part)
//循环遍历children切片
for _, child := range children {
//对每个匹配的子节点,递归调用 search 方法,向下一层节点搜索路径模式的下一个部分,递归的高度加一
result := child.search(parts, height+1)
//如果找到了与给定路径匹配的节点,则返回该节点
if result != nil {
return result
}
}
//如果在当前节点的所有子节点中都没有找到匹配的节点,则返回 nil,表示未找到匹配的路径模式
return nil
}
插入和搜索函数对于构建树结构来说也是必要的
因此,Trie 树需要支持节点的插入与查询。插入功能很简单,递归查找每一层的节点,如果没有匹配到当前part
的节点,则新建一个,有一点需要注意,/p/:lang/doc
只有在第三层节点,即doc
节点,pattern
才会设置为/p/:lang/doc
。p
和:lang
节点的pattern
属性皆为空。因此,当匹配结束时,我们可以使用n.pattern == ""
来判断路由规则是否匹配成功。例如,/p/python
虽能成功匹配到:lang
,但:lang
的pattern
值为空,因此匹配失败。查询功能,同样也是递归查询每一层的节点,退出规则是,匹配到了*
,匹配失败,或者匹配到了第len(parts)
层节点。
Gee\http-base\base1\gee\router.go
package gee
import (
"net/http"
"strings"
)
type router struct {
roots map[string]*node //存储每种请求方式的Trie 树根节点
handlers map[string]HandlerFunc //存储每种请求方式的HandlerFunc
}
// roots key eg, roots['GET'] roots['POST']
// handlers key eg, handlers['GET-/p/:lang/doc'], handlers['POST-/p/book']
// 用于解析路径模式
// 将给定的路径模式字符串解析成部分,并返回一个字符串切片,其中不包括空字符串,并且在遇到 * 通配符之后的部分停止解析
func parsePattern(pattern string) []string {
//使用 strings.Split 函数将路径模式字符串按照 / 字符进行分割,
//得到一个字符串切片 vs,其中每个元素是路径模式的一个部分
vs := strings.Split(pattern, "/")
//用于存储解析后的路径模式部分
parts := make([]string, 0)
for _, item := range vs {
if item != "" {
//将非空的路径模式部分添加到 parts 切片中
parts = append(parts, item)
//如果当前部分以 * 开头,则跳出循环。因为 * 通配符之后的部分不需要继续解析
if item[0] == '*' {
break
}
}
}
return parts
}
// 创建并返回一个新的路由器实例,初始化了路由处理函数的映射
func newRouter() *router {
return &router{
roots: make(map[string]*node),
handlers: make(map[string]HandlerFunc),
}
}
// 添加一个路由规则,将请求方法、URL 模式和处理函数映射起来,并打印添加的路由信息。
func (r *router) addRoute(method, pattern string, handler HandlerFunc) {
parts := parsePattern(pattern)
key := method + "-" + pattern
_, ok := r.roots[method]
if !ok {
r.roots[method] = &node{}
}
r.roots[method].insert(pattern, parts, 0)
r.handlers[key] = handler
}
// 根据请求的方法和路径获取匹配的路由节点,并提取路径参数存储在一个映射中返回
func (r *router) getRoute(method, path string) (*node, map[string]string) {
//使用 parsePattern 函数解析请求路径,将其分割成部分,存储在 searchParts 变量中
searchParts := parsePattern(path)
//创建一个空的字符串映射,用于存储路径参数
params := make(map[string]string)
//获取请求方法对应的根节点,并检查是否存在
root, ok := r.roots[method]
//不存在则返回空
if !ok {
return nil, nil
}
//调用根节点的 search 方法,在路由树中搜索与请求路径匹配的节点,返回匹配的节点 n
n := root.search(searchParts, 0)
if n != nil {
//使用 parsePattern 函数解析节点的路径模式,将其分割成部分,存储在 parts 中
parts := parsePattern(n.pattern)
for index, part := range parts {
//如果当前部分是以 : 开头的参数部分,则将对应的参数值存储在 params 中
if part[0] == ':' {
params[part[1:]] = searchParts[index]
}
//如果当前部分是以 * 开头的通配符部分,并且长度大于 1,
if part[0] == '*' && len(part) > 1 {
//则将剩余部分组合成一个参数值并存储在 params 中,然后跳出循环
params[part[1:]] = strings.Join(searchParts[index:], "/")
break
}
}
return n, params
}
return nil, nil
}
func (r *router) handle(c *Context) {
//通过传入的请求方式和请求路径来获取匹配的路由节点
n, params := r.getRoute(c.Method, c.Path)
//找到了匹配的路由节点之后
if n != nil {
c.Params = params
//在通过请求路径在树中的匹配路径找到相对于的HandlerFunc
key := c.Method + "-" + n.pattern
r.handlers[key](c)
} else {
c.String(http.StatusNotFound, "404 NOT FOUND:%s\n", c.Path)
}
}
Gee\http-base\base1\gee\context.go
package gee
import (
"encoding/json"
"fmt"
"io"
"net/http"
)
type H map[string]interface{}
type Context struct {
Writer http.ResponseWriter
Req *http.Request
//请求路径
Path string
//请求方法
Method string
//状态码
StatusCode int
Params map[string]string
}
func (c *Context) Param(key string) string {
value := c.Params[key]
return value
}
func newContext(w http.ResponseWriter, r *http.Request) *Context {
return &Context{
Writer: w,
Req: r,
Path: r.URL.Path,
Method: r.Method,
}
}
/*
http.Request 对象提供了一个方法 FormValue(key string) string,
用于获取请求中的表单参数。传入的 key 参数是你想要查找的表单参数的键名。
FormValue 会返回表单中对应键的值
当客户端发起一个 POST 请求,并且在请求体中包含表单数据时,你可以通过调用 PostForm 方法来获取指定键的值。
例如,如果请求体中包含数据 name=John&age=25,
调用 c.PostForm("name") 将会返回 John,而 c.PostForm("age") 将会返回 25。
*/
func (c *Context) PostForm(key string) string {
return c.Req.FormValue(key)
}
/*
http.Request 对象的 URL 字段提供了一个 Query() 方法,用于解析 URL 中的查询参数,并返回一个 url.Values 类型的对象,其中包含了所有的查询参数。
Get(key string):url.Values 对象提供了一个 Get(key string) 方法,用于获取指定键的查询参数的值。
因此,调用 Query 方法时,它会从请求的 URL 中提取出查询参数,并返回指定键的值。
例如,如果 URL 是 http://example.com?name=John&age=25,调用 c.Query("name") 将会返回 John,而 c.Query("age") 将会返回 25
*/
func (c *Context) Query(key string) string {
return c.Req.URL.Query().Get(key)
}
/*
这一行将输入的状态码 code 赋值给 Context 结构体中的 StatusCode 字段,以便后续可以在处理程序中访问这个状态码
WriteHeader(code) 方法用于设置 HTTP 响应的状态码,但不会写入响应体。这里用输入的状态码 code 来设置响应的状态码
*/
func (c *Context) Status(code int) {
c.StatusCode = code
c.Writer.WriteHeader(code)
}
/*
Header() 方法返回一个 http.Header 对象,表示 HTTP 响应的头部信息
Set(key, value string):http.Header 对象提供了一个 Set(key, value string) 方法,用于设置指定键的头部字段的值。
在这里,我们传入的 key 是要设置的头部字段的键名,value 是对应的值。
因此,调用 SetHeader 方法时,它会在 HTTP 响应的头部信息中添加指定的键值对,用于定制响应的头部
*/
func (c *Context) SetHeader(key string, value string) {
c.Writer.Header().Set(key, value)
}
func (c *Context) String(code int, format string, values ...interface{}) {
//调用 SetHeader 方法设置 HTTP 响应头部的 Content-Type 字段为 text/plain,表示响应的内容类型是纯文本
c.SetHeader("Content-Type", "text/plain")
//调用Status方法设置HTTP响应的状态码为输入的code值
c.Status(code)
//将格式化后的字符串转换为字节数组,并通过 c.Writer 的 Write 方法写入 HTTP 响应体中,以便发送给客户端。
c.Writer.Write([]byte(fmt.Sprintf(format, values...)))
}
/*
这个方法的功能是向客户端发送一个 JSON 格式的响应
obj interface{}:要序列化为 JSON 格式并发送给客户端的对象
使用 JSON 编码器将 obj 对象序列化为 JSON 格式,并写入到 c.Writer 中。
*/
func (c *Context) JSON(code int, obj interface{}) {
//调用 SetHeader 方法设置 HTTP 响应头部的 Content-Type 字段为 application/json,表示响应的内容类型是 JSON 格式
c.SetHeader("Content-Type", "application/json")
//调用Status方法设置HTTP响应的状态码为输入的code值
c.Status(code)
//创建一个新的 JSON 编码器,将要发送的 JSON 内容写入到 c.Writer 中,
//这里 c.Writer 是 http.ResponseWriter 接口的一个实现,用于写入 HTTP 响应
encoder := json.NewEncoder(c.Writer)
if err := encoder.Encode(obj); err != nil {
//如果在序列化过程中发生错误,就使用 http.Error 函数将错误信息以 HTTP 500 的状态码发送给客户端。
http.Error(c.Writer, err.Error(), 500)
}
}
/*
调用 Data 方法会根据输入的状态码和字节数据,直接将数据发送给客户端。
这个方法通常用于发送文件、图片、音频等二进制数据,或者其他不需要特殊处理的数据
*/
func (c *Context) Data(code int, data []byte) {
//调用 Status 方法设置 HTTP 响应的状态码为输入的 code 值
c.Status(code)
//通过 c.Writer 的 Write 方法直接将输入的字节数据 data 写入 HTTP 响应体中,以便发送给客户端。
c.Writer.Write(data)
}
/*
这个方法的功能是向客户端发送 HTML 格式的响应
*/
func (c *Context) HTML(code int, html string) {
//调用 SetHeader 方法设置 HTTP 响应头部的 Content-Type 字段为 text/plain,表示响应的内容类型是纯文本
c.SetHeader("Content-Type", "text/html")
//调用 Status 方法设置 HTTP 响应的状态码为输入的 code 值
c.Status(code)
//将输入的 HTML 字符串转换为字节数组,并通过 c.Writer 的 Write 方法写入 HTTP 响应体中,以便发送给客户端。
c.Writer.Write([]byte(html))
}
//这块是上次要在postman中获取对应的数据写的函数
func (c *Context) ShouldBindJSON(obj interface{}) error {
//读取请求体中的所有内容,并将其存储在 body 变量中
body, err := io.ReadAll(c.Req.Body)
if err != nil {
return err
}
//在函数的最后执行Close操作
defer c.Req.Body.Close()
//将请求体中的 JSON 数据解码到指定的目标对象 obj 中
if err := json.Unmarshal(body, obj); err != nil {
return err
}
return nil
}
Gee\http-base\base1\gee\context.go
package main
import (
"gee"
"net/http"
)
func main() {
r := gee.New()
r.GET("/", func(c *gee.Context) {
c.HTML(http.StatusOK, "<h1>Hello Gee<h1>")
})
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) {
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.Run(":8080")
}
极客兔兔老师的实验结果
postman效果