Gee教程3.实现前缀树路由

news2024/11/19 16:21:23

需要完成的目标

  • 使用 Trie 树实现动态路由(dynamic route)解析。
  • 支持两种模式:name*filepath,(开头带有':'或者'*')

 这里前缀树的实现修复了Go语言动手写Web框架 - Gee第三天 前缀树路由Router | 极客兔兔​​​​​​  中路由冲突的bug

 Trie树简介

 之前,我们用了一个非常简单的map结构存储了路由表,使用map存储键值对,索引非常高效,但是有一个弊端,键值对的存储的方式,只能用来索引静态路由。

如果我们想支持类似于/hello/:name这样的动态路由怎么办呢?所谓动态路由,即一条路由规则可以匹配某一类型而非某一条固定的路由。例如/hello/:name,可以匹配/hello/abchello/jack等。

实现动态路由最常用的数据结构,被称为前缀树(Trie树)。看到名字你大概也能知道前缀树长啥样了:每一个节点的所有的子节点都拥有相同的前缀。这种结构非常适用于路由匹配。

所有路由按照请求 method 分成对应的 method 树,然后将请求根据 `/` 拆封后,组装成树形结构。

接下来我们实现的动态路由具备以下两个功能。

  • 参数匹配:。例如 /p/:lang/doc,可以匹配 /p/c/doc 和 /p/go/doc
  • 通配*。例如 /static/*filepath,可以匹配/static/fav.ico,也可以匹配/static/js/jQuery.js,这种模式常用于静态服务器,能够递归地匹配子路径。

Trie树实现

 力扣上有前缀树的题目实现 Trie (前缀树),若不懂前缀树的,可以前去查看了解。

首先是需要设置树节点上要存储的信息

节点结构

type node struct {
	path     string           //路由路径 例如 /aa.com/home
	part     string           //路由中由'/'分隔的部分
	children []*node //子节点
	isWild   bool             //是否是通配符节点,是为true
}

与普通树的不同,为了实现动态路由匹配,加上了isWild这个参数。即是当我们匹配 /a/b/c/这个路径时。假如当前有个节点的path是 /a/:name,这时候a精准匹配到了a,b模糊匹配到了:name,那么会将name这个参数赋值为b,继续下一层的匹配。

那么前缀树的操作基本是插入和查找

那么讲解前需要了解下这一节的路由router结构

type router struct {
	handers map[string]HandlerFunc
	root    map[string]*node //key是GET,POST等请求方法
}

插入

那就要和router.go文件中的插入操作一起来讲解。

该插入的实现与极客兔兔的教程会有所不同。

举个例子:要插入GET方法的/user/info/a。要结合开头的前缀树那图片来想象

1.先判断该路由中是否有GET方法的树,若是没有就需要创建该树,即是创建一个头结点。

2.接着调用parsePath函数,这个函数就是把/user/info/a组成一个切片,切片有三个元素

[]string{"user","info","a"}

 之后就调用节点的插入方法insert

一层一层往下插入数据。

parts中第一个是user,当前的children[part]是空,所以需要新建一个结点。之后就cur = cur.children[part],这样就可以一层一层往下走。

到最后就是把path赋值给当前结点的路径。

//在router.go文件中
func (r *router) addRoute(method string, path string, handler HandlerFunc) {
	// r.handers[key] = handler

	if _, ok := r.root[method]; !ok {
		r.root[method] = &node{}
	}

	parts := parsePath(path)
	r.root[method].insert(path, parts)

	key := method + "-" + path
	r.handers[key] = handler
}

//在trie.go文件中
func (n *node) insert(path string, parts []string) {
	tmpNode := n
	for _, part := range parts {
		var tmp *node
		for _, child := range tmpNode.children { //一个for循环就是一层,一层一层查找
			if child.part == part {
				tmp = child
				break
			}
		}
        //表示没有找到该节点,需要创建新节点
		if tmp == nil {
			tmp = &node{
				part:   part,
				isWild: part[0] == ':' || part[0] == '*',
			}
			tmpNode.children = append(tmpNode.children, tmp)
		}
		tmpNode = tmp
	}
	tmpNode.path = path
}

//在router.go文件中
func parsePath(path string) (parts []string) {
	par := strings.Split(path, "/")
	for _, p := range par {
		if p != "" {
			parts = append(parts, p)
			//如果p是以通配符*开头的
			if p[0] == '*' {
				break
			}
		}
	}
	return
}

查找

 先看getRoute方法,要是没有对应的方法树,直接返回空即可。

接着调用parsePath函数。最后调用前缀树的search方法。

search方法是递归查找的。

有一点需要注意,例如:/user/:id/a只有在第三层节点,即a节点,path才会设置为/user/:id/auser:id节点的path属性皆为空。

因此,当匹配结束时,我们可以使用n.path == ""来判断路由规则是否匹配成功。

例如,/user/th虽能成功匹配到/user/:id,但/user/:id的path值为空,因此匹配失败。查询功能,同样也是递归查询每一层的节点,退出规则是,匹配到了*,匹配失败,或者匹配到了第len(parts)层节点。

matchChildren有点重要,可以对比下和极客兔兔教程的matchChildren函数有何不同。

//在router.go文件中
func (r *router) getRoute(method, path string) (*node, map[string]string) {
	root, ok := r.roots[method]
	if !ok {
		return nil, nil
	}
	searchParts := parsePath(path)

	n := root.search(searchParts, 0)
	if n == nil {
		return nil, nil
	}
	params := make(map[string]string)
	parts := parsePath(n.path)
	for i, part := range parts {
        //这些操作是为了可以找到动态路由的参数
        //例如添加了路由 /user/:id/a,
        //那用户使用/user/my/a来访问的时候,其参数id就是my
		if part[0] == ':' {
			params[part[1:]] = searchParts[i]
		}
		if part[0] == '*' && len(part) > 1 {
			params[part[1:]] = strings.Join(searchParts[i:], "/")
			break
		}
	}
	return n, params
}

//在trie.go文件中
func (n *node) search(searchParts []string, height int) *node {
	if len(searchParts) == height || strings.HasPrefix(n.part, "*") {
		if n.path == "" {
			return nil
		}
		return n
	}

	part := searchParts[height]
	childern := n.matchChildren(part)

	for _, child := range childern {
		result := child.search(searchParts, height+1)
		if result != nil {
			return result
		}
	}

	return nil
}

func (n *node) matchChildren(part string) (result []*node) {
	nodes := make([]*node, 0)
	for _, child := range n.children {
		if child.part == part {
			result = append(result, child)
		} else if child.isWild {
			nodes = append(nodes, child)
		}
	}
	return append(result, nodes...)
}

Router

 前缀树的算法实现后,接下来就需要把该树应用到路由中。我们使用root来存储每中请求方法的前缀树根结点。使用hander来存储每种请求方式的处理方法HandlerFunc。

代码也在Trie实现中讲解了。

getRoute 函数中,解析了:*两种匹配符的参数,返回一个 map 。例如前缀树有/p/:lang/doc和/static/*filepath。

路径/p/go/doc匹配到/p/:lang/doc,解析结果为:{lang: "go"};路径/static/css/geektutu.css匹配到/static/*filepath,解析结果为{filepath: "css/geektutu.css"}

这个匹配就是通过getRoute函数中for range获取的。

Contex和Router.handle的变化

 Context有了些许变化。在 HandlerFunc 中,希望能够访问到解析的参数,因此,需要对 Context 对象增加一个属性和方法,来提供对路由参数的访问。我们将解析后的参数存储到Params中,通过c.Param("lang")的方式获取到对应的值。

type Context struct {
	Wrtier http.ResponseWriter
	Req    *http.Request

	Path   string
	Method string
	Params map[string]string      //新添加的
	//响应的状态码
	StatusCode int
}

func (c *Context) Param(key string) string {
	value, _ := c.Params[key]
	return value
}

Router.handle方法

 在调用匹配到的handler前,将解析出来的路由参数赋值给了c.Params。这样就能够在handler中,通过Context对象访问到具体的值了。

func (r *router) handle(c *Context) {
	n, params := r.getRoute(c.Method, c.Path)
	if n != nil {
		c.Params = params
		//key := c.Method + "-" + c.Path 这样写是错误的,是要+n.path
		key := c.Method + "-" + n.path
		r.handers[key](c)
	} else {
		c.String(http.StatusNotFound, "404 NOT FOUND: %s\n", c.Path)
	}

    //上一节的实现
	// key := c.Method + "-" + c.Path
	// if hander, ok := r.handers[key]; ok {
	// 	hander(c)
	// } else {
	// 	c.String(http.StatusNotFound, "404 NOT FOUND: %s\n", c.Path)
	// }
}

 修复的路由冲突BUG

主要是对比极客兔兔的教程,这节的路由有两部分不同。

一在node的insert函数中,这里只是判别child.part == part,没有判别child.isWild==true。

这样当出现要先后插入/:name,/16时候,/:name是没有的,那就是直接创建插入。

而到插入/16时候,若是也判别child.isWild==true的话,这时是true的,那么就不会创建part是16的结点。所以不进行判断child.isWild==true,只判断child.part是否等于所给的part,这样就可以创建part是16的结点。

二是在node的matchChildren函数中。

还是/:name,/16的例子,这时用户通过/16来访问,那肯定是想返回/16对应的处理函数。假如matchChildren返回的[]*node第一个元素:name,那么这个是符合条件的,那就会执行:name对应的处理函数了。

func (n *node) matchChildren(part string) (result []*node) {
	nodes := make([]*node, 0)
	for _, child := range n.children {
		if child.part == part {
			result = append(result, child)
		} else if child.isWild {
			nodes = append(nodes, child)
		}
	}
	return append(result, nodes...)
}

//极客兔兔教程的
func (n *node) matchChildren(part string) []*node {
	nodes := make([]*node, 0)
	for _, child := range n.children {
		if child.part == part || child.isWild {
			nodes = append(nodes, child)
		}
	}
	return nodes
}

而这里,是把/16放在返回的[]*node中的第一个位置。那么就会先把 /16来进行判别是否符合条件,而/16是符合条件的,那就会执行/16对应的处理函数。 

基本就是这样。若有不同意见或有更好的想法,欢迎在评论区讨论。

Router单元测试

当前框架的文件结构

创建router_test.go文件来进行测试router。

进入到gee文件夹,执行命令 go test -run 要测试的函数

例如测试TestGetRoute,执行命令 go test -run TestGetRoute

后面添加-v,可以查看具体的情况,例如: go test -run TestGetRoute -v

func newTestRouter() *router {
	r := newRouter()
	r.addRoute("GET", "/", nil)
	r.addRoute("GET", "/hello/:name", nil)
	r.addRoute("GET", "/hello/b/c", nil)
	r.addRoute("GET", "/hi/:name", nil)
	r.addRoute("GET", "/assets/*filepath", nil)
	return r
}

func TestParsePattern(t *testing.T) {
	ok := reflect.DeepEqual(parsePath("/p/:name"), []string{"p", ":name"})
	ok = ok && reflect.DeepEqual(parsePath("/p/*"), []string{"p", "*"})
	ok = ok && reflect.DeepEqual(parsePath("/p/*name/*"), []string{"p", "*name"})
	if !ok {
		t.Fatal("test parsePattern failed")
	}
}

func TestGetRoute(t *testing.T) {
	r := newTestRouter()
	n, ps := r.getRoute("GET", "/hello/li")

	if n == nil {
		t.Fatal("nil shouldn't be returned")
	}

	if n.path != "/hello/:name" {
		t.Fatal("should match /hello/:name")
	}

	if ps["name"] != "li" {
		t.Fatal("name should be equal to 'li'")
	}

	fmt.Printf("matched path: %s, params['name']: %s\n", n.path, ps["name"])

}

func TestGetRoute2(t *testing.T) {
	r := newTestRouter()
	n1, ps1 := r.getRoute("GET", "/assets/file1.txt")

	ok1 := n1.path == "/assets/*filepath" && ps1["filepath"] == "file1.txt"
	if !ok1 {
		t.Fatal("pattern shoule be /assets/*filepath & filepath shoule be file1.txt")
	}

	n2, ps2 := r.getRoute("GET", "/assets/css/test.css")
	ok2 := n2.path == "/assets/*filepath" && ps2["filepath"] == "css/test.css"
	if !ok2 {
		t.Fatal("pattern shoule be /assets/*filepath & filepath shoule be css/test.css")
	}
}

测试

func main() {
	fmt.Println("hello web")
	r := gee.New()

	r.GET("/:name", func(c *gee.Context) {
		name := c.Param("name")
		c.String(http.StatusOK, "name is %s", name)
	})

	r.GET("/16", func(c *gee.Context) {
		c.String(http.StatusOK, "id is 16")
	})

	r.GET("/user/info/a", func(c *gee.Context) {
		c.String(http.StatusOK, "static is %s", "sdfsd")
	})

	r.GET("/user/:id/a", func(c *gee.Context) {
		name := c.Param("id")
		c.String(http.StatusOK, "id is %s", name)
	})

	r.Run("localhost:10000")
}

完整代码:https://github.com/liwook/Go-projects/tree/main/gee-web/3-trie-router

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1263878.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

20世纪的葡萄酒有哪些创新?

葡萄酒是用酵母发酵的,直到20世纪中叶,这一过程都依赖于自然产生的酵母。这些发酵的结果往往不一致,而且由于发酵时间长,容易腐败。 酿酒业最重要的进步之一是在20世纪50、60年代引进了地中海的纯发酵菌种酿酒酵母,俗称…

全面介绍SSO(单点登录)

全面介绍SSO(单点登录) SSO英文全称Single SignOn,单点登录。SSO是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。它包括可以将这次主要的登录映射到其他应用中用于同一个用户的登录的机制。它是目前比…

C++类与对象(7)—友元、内部类、匿名对象、拷贝对象时编译器优化

目录 一、友元 1、定义 2、友元函数 3、友元类 二、内部类 1、定义 2、特性: 三、匿名对象 四、拷贝对象时的一些编译器优化 1、传值&传引用返回优化对比 2、匿名对象作为函数返回对象 3、接收返回值方式对比 总结: 一、友元 1、定义…

0005Java程序设计-ssm基于微信小程序的校园求职系统

文章目录 摘 要目 录系统设计开发环境 编程技术交流、源码分享、模板分享、网课分享 企鹅🐧裙:776871563 摘 要 随着我国经济迅速发展,人们对手机的需求越来越大,各种手机软件也都在被广泛应用,但是对于手机进行数据…

因为计算机中丢失MSVCP140.dll,无法启动此程序运行软件的解决方法

msvcp140.dll重新安装五个解决方法与msvcp140.dll文件的作用和丢失对电脑的影响介绍 正文: 在计算机使用过程中,我们经常会遇到一些错误提示,其中最常见的就是“缺少xxx.dll文件”。而msvcp140.dll就是其中之一。那么,msvcp140.…

哈希表——闭散列表

该哈希表实现是闭散列实现法。 闭散列表: 闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。 那如何寻…

sprintboot快速初始化【Springboot】

1.首先选择创建项目 2.填写对应的项目信息 一定要勾选maven,否则没有pom文件,选择next 3.选择应用场景 点击 create,DIEA就会根据你的选择自动创建项目骨架; 4.创建一个控制层 随便创建一个控制层,测试一下项目是否…

阿里云语雀频繁崩溃,有什么文档管理工具是比较稳定的?

10月23 日14:00左右,蚂蚁集团旗下的在线文档编辑与协同工具语雀发生服务器故障,在线文档和官网都无法打开。直到当天晚上22:24,语雀服务才全部恢复正常。从故障发生到完全恢复正常,语雀整个宕机时间将近 8 小时,如此长…

麒麟V10桌面搭建FTP服务

1.1介绍 FTP:File transfer protocol (文件传输协议)是 TCP/IP 协议组中的协议之一。FTP协议包括两个组成部分,其一为FTP服务器,其二为FTP客户端。其中FTP服务器用来存储文件,用户可以使用FTP客户端通过FT…

Java变量理解

成员变量VS局部变量的区别 语法形式:从语法形式上看,成员变量是属于类的,而局部变量是在代码块或方法中定义的变量或是方法的参数;成员变量可以被 public,private,static 等修饰符所修饰,而局部变量不能被访问控制修饰…

深度盘点:100 个 Python 数据分析函数总结

经过一段时间的整理,本期将分享我认为比较常用的100个实用函数,这些函数大致可以分为六类,分别是统计汇总函数、数据清洗函数、数据筛选、绘图与元素级运算函数、时间序列函数和其他函数。 技术交流 技术要学会交流、分享,不建议…

DMX512协议及对接口电路的分析

1、DMX512协议简介 DMX 是Digital MultipleX 的缩写,意为多路数字传输(具有512条信息的数字多路复用”)。DMX512控制协议是美国舞台灯光协会(usITT)于1990年发布的灯光控制器与灯具设备进行数据传输的工业标准,全称是USITTDMX512(1990); DMX512 在其物理…

达索系统3DEXPERIENCE WORKS 2024 结构仿真功能增强

simulia结构仿真是什么? 不仅能对结构进行力学、热学、声学等多学科计算,辅助于设计方案的优化;还能采用数字化技术模拟产品性能,大幅节约试验和样机迭代成本。达索系统3DEXPERIENCE WORKS 2024 结构仿真为企业提供随需应变、精准…

Android开源框架--Dagger2详解

功名只向马上取,真是英雄一丈夫 一,定义 我们知道在一个类中,通常会定义其他类型的变量,这个变量就是我们所说的“依赖“。 对一个类的变量进行初始化,有两种方式。第一种,这个类自己进行初始化&#xff…

Linux环境安装Java,Tomcat,Mysql,

1、Java的安装 载 jdk1.8 注:此处 CentOS7 是64位,所以下载的是:Linux x64, 文件类型为 tar.gz 的文件 JDK 官网地址:https://www.oracle.com/java/ cd /usr/local/ mkdir jdk cd jdk/tar -xvf jdk-8u202-linux-x64.…

pandas教程:US Baby Names 1880–2010 1880年至2010年美国婴儿姓名

文章目录 14.3 US Baby Names 1880–2010(1880年至2010年美国婴儿姓名)1 Analyzing Naming Trends(分析命名趋势)评价命名多样性的增长“最后一个字母”的变革变成女孩名字的男孩名字(以及相反的情况) 14.3…

【Docker项目实战】使用Docker部署Plik临时文件上传系统

【Docker实战项目】使用Docker部署Plik 临时文件上传系统 一、Plik介绍1.1 Plik简介1.2 Plik特点 二、本地环境介绍2.1 本地环境规划2.2 本次实践介绍 三、本地环境检查3.1 检查Docker服务状态3.2 检查Docker版本3.3 检查docker compose 版本 四、下载Plik镜像五、部署Plik临时…

学习知识随笔(Django)

文章目录 MVC与MTV模型MVCMTV Django目录结构Django请求生命周期流程图路由控制路由是什么路由匹配反向解析路由分发 视图层视图函数语法reqeust对象属性reqeust对象方法 MVC与MTV模型 MVC Web服务器开发领域里著名的MVC模式,所谓MVC就是把Web应用分为模型(M&#…

案例-某乎参数x-zse-96逆向补环境

文章目录 前言一、流程分析二、导出代码三、补环境总结 前言 本文章中所有内容仅供学习交流使用,不用于其他任何目的,不提供完整代码,抓包内容、敏感网址、数据接口等均已做脱敏处理,严禁用于商业用途和非法用途,否则…

Java代码的编译与执行过程

一、编译过程 1、javac 编译 Java源代码通过编译器(javac)编译为字节码文件(.class)。 idea中的 build 和 maven package等指令都可以编译为 .class 2、类加载器(Class Loader) 类加载器负责将类的字节码文件加载到内存中,以便在运行时创…