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

需要完成的目标

  • 使用 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/homepart     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]HandlerFuncroot    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] = handlerif _, ok := r.root[method]; !ok {r.root[method] = &node{}}parts := parsePath(path)r.root[method].insert(path, parts)key := method + "-" + pathr.handers[key] = handler
}//在trie.go文件中
func (n *node) insert(path string, parts []string) {tmpNode := nfor _, part := range parts {var tmp *nodefor _, child := range tmpNode.children { //一个for循环就是一层,一层一层查找if child.part == part {tmp = childbreak}}//表示没有找到该节点,需要创建新节点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就是myif 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.ResponseWriterReq    *http.RequestPath   stringMethod stringParams 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.pathkey := c.Method + "-" + n.pathr.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.mzph.cn/news/179160.shtml

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

相关文章

selenium工作原理详解

一、什么是WebDriver WebDriver提供了另外一种方式与浏览器进行交互。那就是利用浏览器原生的API,封装成一套更加面向对象的Selenium WebDriver API,直接操作浏览器页面里的元素,甚至操作浏览器本身(截屏,窗口大小&am…

如何在Ubuntu系统上安装YApi

简单介绍 YApi是高效、易用、功能强大的api管理平台,旨在为开发、产品、测试人员提供更优雅的接口管理服务。可以帮助开发者轻松创建、发布、维护API,YApi还为用户提供了优秀的交互体验,开发人员只需利用平台提供的接口数据写入工具以及简单的…

【从浅识到熟知Linux】基本指令之mkdir

🎈归属专栏:从浅学到熟知Linux 🚗个人主页:Jammingpro 🐟每日一句:加油努力,这次写完真的要去干饭了! 文章前言:本文介绍mkdir指令用法并给出示例和截图。 文章目录 基本…

ABAP算法 模拟退火

模拟退火算法 算法原理及概念本文仅结合实现过程做简述 模拟退火算法是一种解决优化问题的算法。通过模拟固体退火过程中的原子热运动来寻找全局最优解。在求解复杂问题时,模拟退火算法可以跳出局部最优解获取全局最优解。 模拟退火算法包含退火过程和Metropolis算法…

舞蹈店管理系统服务预约会员小程序效果如何

舞蹈的作用很广,也有大量求学者,每个城市也有大小各异的舞蹈品牌店,他们承接商演、也会教学员、宣传拓展生意等,因此近些年来,随着互联网深入及短视频,舞蹈业市场规模也在增加。 而在门店经营中&#xff0…

Java中关于ArrayList集合的练习题

目录 题目内容​编辑 完整源码 题目内容 根据下图所示数据,定义学生类Student,设置对应的字段并进行封装在Test中,定义ArrayList集合 ,将上述学生对象实例化,并放入集合,定义方法t1,参数为学生类集合&am…

js数组map()的用法

JavaScript Array map() 方法 先说说这个方法浏览器的支持: 支持五大主流的浏览器, 特别注意:IE 9 以下的浏览器不支持,只支持IE 9以上的版本的浏览器 特别注意:IE 9 以下的浏览器不支持,只支持IE 9以上的…

从零开始的c语言日记day37——数组指针练习

一、 取地址数组储存在了*p里,里面储存的是整个数组的地址但本质也是第一个元素的地址解引用后1为4个字节所以就可以打印数组了。但一般不用这种方法 这样更方便一些 打印多维数组 如果不用这样传参,用指针传参怎么做呢? Main里函数的arr表示…

QT基础实践之简易计算器

文章目录 简易计算器源码分享演示图第一步 界面设计第二步 设置槽第三步 计算功能实现 简易计算器 源码分享 链接:https://pan.baidu.com/s/1Jn5fJLYOZUq77eNJ916Kig 提取码:qwer 演示图 第一步 界面设计 这里直接用了ui界面,如果想要自己…

TiDB 7.x 源码编译之 TiDB Server 篇,及新特性详解

本文将介绍如何编译 TiDB Server 源码。以及阐释 TiDB Server 7.x 的部分新特性。 TiDB v7.5.0 LTS 计划于 2023 年 11 月正式 Release,目前代码虽未冻结,但已经可以看到 Alpha 版本的 Code 了,本文代码将以 v7.5.0-alpha 为基准。 TiDB Se…

filebeat 日志收集工具

elk:filebeat日志收集工具和logstash相同。 filebeat是一个轻量级的日志收集工具,所使用的系统资源比logstash部署和启动时使用的资源要小的多。 filebeat可以运行在非Java环境。他可以代理logtash在非java环境上收集日志。 filebeat无法实现数据的过…

设计师福利!2024在线图标设计网站推荐,不容错过的宝藏!

在当今竞争激烈的商业环境中,公司或个人品牌的视觉识别元素已经成为区分你和竞争对手的关键因素之一。一个独特而引人注目的标志可以深深扎根于人们的心中,并在消费者心中建立一个强烈的品牌印象。如果你正在寻找合适的工具来创建或改进你的标志&#xf…

WIFI HaLow技术引领智能互联,打破通信限制

在过去十年里,WIFI技术已在家庭和企业中建立起了庞大的网络,连接了数十亿智能互联设备,促进了信息的迅速传递。然而,当前的WIFI标准存在一些挑战,包括协议范围的限制和整体功能的受限,导致在较远距离进行通…

02-鸿蒙学习之4.0todoList练习

02-鸿蒙学习之4.0todoList练习 代码 /*** 1:组件必须使用Component装饰* 2.Entry 装饰哪个组件,哪个组件就呈现在页面上* 3.被Entry 装饰的入口组件。build()必须有且仅有一个根 ** 容器 ** 组件* 其他的自定义组件,build() 中…

C++学习——类和对象(上)

C学习——类和对象 一、面向对象和面向过程的初步认识二、什么是类 一、面向对象和面向过程的初步认识 我们之前学习了C语言,我们知道 ① C语言:C语言是一门面向过程的语言,关注的是过程,分析出求解问题的步骤,通过函…

Anakki个人网站持续更新中

Anakki-World github: GitHub - Anyuei/anakki 欢迎注册,成为我的盆友

Android Bitmap保存成至手机图片文件,Kotlin

Android Bitmap保存成至手机图片文件,Kotlin fun saveBitmap(name: String?, bm: Bitmap) {val savePath Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).toString()if (!Files.exists(Paths.get(savePath))) {Log.d("保存文…

用通俗的方式讲解Transformer:从Word2Vec、Seq2Seq逐步理解到GPT、BERT

直到今天早上,刷到CSDN一篇讲BERT的文章,号称一文读懂,我读下来之后,假定我是初学者,读不懂。 关于BERT的笔记,其实一两年前就想写了,迟迟没动笔的原因是国内外已经有很多不错的资料&#xff0…

Appium自动化如果出现报错怎么办?这么做确实解决问题

解决通过appium的inspector功能无法启动app的原因 在打开appium-desktop程序,点击inspector功能,填写app的配置信息,启动服务提示如下: 报错信息: An unknown server-side error occurred while processing the com…

【ShardingSphere专题】SpringBoot整合ShardingSphere(一、数据分片入门及实验)

目录 前言阅读对象笔记正文一、ShardingSphere介绍1.1 ShardingSphere-JDBC:代码级别1.2 ShardingSphere-Proxy:应用级别1.3 横向对比图 二、ShardingSphere之——数据分片2.1 基本介绍2.2 分片的形式2.2.1 垂直分片2.2.2 水平分片 2.3 数据分片核心概念…