七天用GO从零实现系列-学习

Gee第三天 前缀树路由Router

Trie 树简介

之前,我们用了一个非常简单的map结构存储了路由表,使用map存储键值对,索引非常高效,但是有一个弊端,键值对的存储的方式,只能用来索引静态路由。那如果我们想支持类似于/hello/:name这样的动态路由怎么办呢?所谓动态路由,即一条路由规则可以匹配某一类型而非某一条固定的路由。例如/hello/:name,可以匹配/hello/geektutuhello/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/docp:lang节点的pattern属性皆为空。因此,当匹配结束时,我们可以使用n.pattern == ""来判断路由规则是否匹配成功。例如,/p/python虽能成功匹配到:lang,但:langpattern值为空,因此匹配失败。查询功能,同样也是递归查询每一层的节点,退出规则是,匹配到了*,匹配失败,或者匹配到了第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效果

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值