【Gin】| 框架源码解析 :路由详解
本文目录
- 一、Gin框架路由详解
- Radix Tree
- 二、源码解析
本文为学习七米老师Gin框架源码解析文章教程的学习笔记。
一、Gin框架路由详解
Gin框架使用的是定制版的httprouter
,路由原理是大量使用公共前缀的树结构,它基本上是一个紧凑的Trie tree(或者只是Radix Tree)
。具有公共前缀的节点也共享一个公共父节点。
Radix Tree
基数树(Radix Tree)又称为PAT位树(Patricia Trie or crit bit tree),是一种更节省空间的前缀树(Trie Tree)。对于基数树的每个节点,如果该节点是唯一的子树的话,就和父节点合并。下图为一个基数树示例:
比如访问博客的时候,路径有很多参数和通配符,使用哈希map就不太方便,做一个网站路由不可能无限制的多,也是需要考量的,空间不会特别大。虽然没有哈希map那么快,但是用前缀树也够用了。
比如下面注册路由信息。
r := gin.Default()
r.GET("/", func1)
r.GET("/search/", func2)
r.GET("/support/", func3)
r.GET("/blog/", func4)
r.GET("/blog/:post/", func5)
r.GET("/about-us/", func6)
r.GET("/about-us/team/", func7)
r.GET("/contact/", func8)
那么就会得到一个GET方法对应的路由树,如下所示。
最右边那一列每个*<数字>
表示Handle处理函数的内存地址(一个指针)。从根节点遍历到叶子节点我们就能得到完整的路由表。
blog/:post
其中:post
只是实际文章名称的占位符(参数)。与hash-maps不同,这种树结构还允许我们使用像:post参数这种动态部分,因为我们实际上是根据路由模式进行匹配
,而不仅仅是比较哈希值。
路由器为每个请求方法管理一棵单独的树。一方面,它比在每个节点中都保存一个method-> handle map更加节省空间,它还使我们甚至可以在开始在前缀树中查找之前大大减少路由问题。
也就是对于search来说,可能有get、post等请求,相比于单独对search开一个handle map来处理,从请求方法的角度来进行统一划分会更节省了空间,甚至可在查找之前减少路由问题。
为了获得更好的可伸缩性,每个树级别上的子节点都按Priority(优先级)
排序,其中优先级(最左列)就是在子节点(子节点、子子节点等等)中注册的句柄的数量。这样做有两个好处:
1、首先优先匹配被大多数路由路径包含的节点。这样可以让尽可能多的路由快速被定位。
2、类似于成本补偿。最长的路径可以被优先匹配,补偿体现在最长的路径需要花费更长的时间来定位,如果最长路径的节点能被优先匹配(即每次拿子节点都命中),那么路由匹配所花的时间不一定比短路径的路由长。下面展示了节点(每个-可以看做一个节点)匹配的路径:从左到右,从上到下。
二、源码解析
先写一个gin的简单demo。
package main
import (
"github.com/gin-gonic/gin"
"net/http"
)
func main() {
r := gin.Default()
r.GET("/", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
r.Run()
}
点击Run进去看源码,可以看到调用了http
也就是go的net/http网络组件
进行开发。
来看看ListenAndServer
这个函数,需要两个参数。
Handler
类型要求必须实现ServerHTTP
方法的接口。
而在gin中,被传进去了engine
作为handler,也就是第二个参数,说明其一定实现了ServerHTTP
方法。
这个Engine
引擎实现了三个接口,如下所示。
定位到engine实现 ServeHTTP
方法的地方,来看看。
c := engine.pool.Get().(*Context)
engine.pool是一个对象池(通常是一个sync.Pool实例),用于复用Context对象。Get()方法从对象池中获取一个对象。(Context)是类型断言,确保从池中获取的对象是Context类型。
通过对象池减少每次申请、垃圾回收的资源消耗。
同时取出来之后再做的初始化,也是一个比较重要的点。
首先sync.Pool 是线程安全的,但对池的操作(如 Put 和 Get)仍然会涉及锁机制。如果在归还对象时进行初始化,可能会增加锁的持有时间,从而降低并发性能。
sync.Pool 的设计目标是提供一个通用的对象复用机制,而不是管理对象的状态。也就是对象的状态让使用者来关心,归还对象时只涉及简单的 Put 操作,避免了在归还时进行复杂的初始化逻辑。
接着看handleHTTPRequest
函数的源码。
首先是 请求路径处理与路由树匹配逻辑,在 handleHTTPRequest 函数的前半部分,代码主要负责处理 HTTP 请求的路径信息,并尝试在路由树中找到匹配的路由。首先,根据配置选择使用 Request.URL.RawPath 或 Request.URL.Path 作为路由匹配的基础路径,并决定是否对路径进行解码。如果启用了 RemoveExtraSlash,还会对路径进行清理,去除多余的斜杠。随后,代码遍历 engine.trees,这是一个按 HTTP 方法分类的路由树集合,寻找与当前请求方法匹配的路由树根节点。一旦找到对应的根节点,便调用 root.getValue 方法,尝试在路由树中匹配请求路径,并提取路径参数和对应的处理器。如果找到匹配的处理器,会将相关参数和处理器赋值给 Context,并继续后续处理;如果没有找到匹配的处理器,但满足特定条件(如路径尾部斜杠或路径拼写错误),则会尝试执行重定向操作。
进一步点进来看tengines的结构体,里边会找到一个IRouter。
var _ IRouter = (*Engine)(nil)
(*Engine)(nil):将 nil 转换为 *Engine 类型。这并不会分配内存,只是创建了一个 *Engine 类型的空值。var _ IRouter = (*Engine)(nil):将 (*Engine)(nil) 赋值给一个匿名变量 _,并声明它是一个 IRouter 类型。
如果 *Engine 没有实现 IRouter 接口,编译器会报错,提示 *Engine 不满足 IRouter 接口的定义。
在编写完一个结构体之后,通常会写一个匿名的变量,来等于一个空的结构体指针,是为了确保我们的结构体实现了指定的接口。
var _ IRouter = (*Engine)(nil) 是一种常见的类型断言写法,用于确保某个类型实现了某个接口。这种写法的作用是在编译时验证 *Engine 是否满足 IRouter 接口的定义。
如果 Engine 类型实现了 Handle、Group 以及其他所有方法,那么 *Engine 就实现了 IRouter 接口。
上面这种写法会在很多第三方库中学到。
接着继续看看trees的源码。
没有用map映射的方式去存请求方法和映射,好处就是节省内存。因为请求方法http1.1中一共就9个。只需要定义一个请求的切片即可。比map的内存会节省。
在engine
的new方法中,可以看到trees是一次性申请了9的容量。(可以一次性把切片容量申请到位,这样后面不需要动态扩容slice的容量。)
接着来看看node
的详细字段。
type node struct {
path string // 当前节点的路径片段,例如 "/user" 或 ":id"
indices string
// 路径片段的索引信息,用于快速匹配,和children字段对应, 保存的是分裂的分支的第一个字符, 例如search和support, 那么s节点的indices对应的"eu"
wildChild bool // 是否有子节点是通配符(如 `:param` 或 `*catchall`),也就是节点是否是参数节点
nType nodeType // 节点类型(如静态节点、参数节点、通配符节点等)
priority uint32 // 节点的优先级,用于排序和匹配时的优先级判断
children []*node // 子节点列表,最多包含一个 `:param` 风格的节点在数组末尾
handlers HandlersChain // 与该节点关联的处理器链
fullPath string // 完整的路径,从根节点到当前节点的路径字符串
}
接下来我们来看看怎么添加路由的。GET方法是属于RouterGroup的类型的,但是能被Eegine的r调用,说明肯定是在Engine中进行了嵌套调用。
来看看,确实看到对应的Engine中包含。
GET方法的两个参数,分别是string还有HandlerFunc
类型的切片。
接着来看,group.handle()
做了什么事情。
combineHandlers
就是把我们注册的handlerfunc给拼接了一下。group本身的处理函数切片还有我们传过来的handlers拼接,组成一个处理函数的完整切片(调用链)。
combineHandlers
函数的作用是将 RouterGroup
的现有处理器链(group.Handlers)
与传入的处理器链(handlers)
合并。它首先计算合并后的处理器链的总长度,确保合并后的处理器数量不会超出限制(通过 assert1 断言)。接着,函数创建一个新的处理器链(mergedHandlers)
,并使用 copy 函数将 group.Handlers 和 handlers 的内容依次复制到新链中。最终返回合并后的处理器链。
接着我们回过头来看看addRoute
函数的作用。
接着往下看可以发现 node 的addRoute函数,也就是构造路由树的方法。
核心的就是insertChild方法。
然后就是先判断是否是空树,如果不是,就继续walk下去。