Gin 框架底层原理(中) | 青训营
2023/8/24 ·雨辰login
书接上篇
本篇内容引用自知乎用户@小徐先生.
这真的是一个宝藏博主,B站也有号:小徐先生1212
所以把他的笔记找来跟大家分享,希望大家都去看看,真的做的非常好。
3 启动服务流程
3.1 流程入口
下面通过 Gin 框架运行 http 服务为主线,进行源码走读:
func main() {
// 创建一个 gin Engine,本质上是一个 http Handler
mux := gin.Default()
// 一键启动 http 服务
if err := mux.Run(); err != nil{
panic(err)
}
}
3.2 启动服务
一键启动 Engine.Run 方法后,底层会将 gin.Engine 本身作为 net/http 包下 Handler interface 的实现类,并调用 http.ListenAndServe 方法启动服务.
func (engine *Engine) Run(addr ...string) (err error) {
// ...
err = http.ListenAndServe(address, engine.Handler())
return
}
顺便多提一嘴,ListenerAndServe 方法本身会基于主动轮询 + IO 多路复用的方式运行,因此程序在正常运行时,会始终阻塞于 Engine.Run 方法,不会返回.
func (srv *Server) Serve(l net.Listener) error {
// ...
ctx := context.WithValue(baseCtx, ServerContextKey, srv)
for {
rw, err := l.Accept()
// ...
connCtx := ctx
// ...
c := srv.newConn(rw)
// ...
go c.serve(connCtx)
}
}
3.3 处理请求
在服务端接收到 http 请求时,会通过 Handler.ServeHTTP 方法进行处理. 而此处的 Handler 正是 gin.Engine,其处理请求的核心步骤如下:
- 对于每笔 http 请求,会为其分配一个 gin.Context,在 handlers 链路中持续向下传递
- 调用 Engine.handleHTTPRequest 方法,从路由树中获取 handlers 链,然后遍历调用
- 处理完 http 请求后,会将 gin.Context 进行回收. 整个回收复用的流程基于对象池管理
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// 从对象池中获取一个 context
c := engine.pool.Get().(*Context)
// 重置/初始化 context
c.writermem.reset(w)
c.Request = req
c.reset()
// 处理 http 请求
engine.handleHTTPRequest(c)
// 把 context 放回对象池
engine.pool.Put(c)
}
Engine.handleHTTPRequest 方法核心步骤分为三步:
- 根据 http method 取得对应的 methodTree
- 根据 path 从 methodTree 中找到对应的 handlers 链
- 将 handlers 链注入到 gin.Context 中,通过 Context.Next 方法按照顺序遍历调用 handler
此处根据 path 从路由树寻找 handlers 的逻辑位于 root.getValue 方法中,和路由树数据结构有关,放在本文第 4 章详解;
根据 gin.Context.Next 方法遍历 handler 链的内容放在本文第 5 章详解.
func (engine *Engine) handleHTTPRequest(c *Context) {
httpMethod := c.Request.Method
rPath := c.Request.URL.Path
// ...
t := engine.trees
for i, tl := 0, len(t); i < tl; i++ {
// 获取对应的方法树
if t[i].method != httpMethod {
continue
}
root := t[i].root
// 从路由树中寻找路由
value := root.getValue(rPath, c.params, c.skippedNodes, unescape)
if value.params != nil {
c.Params = *value.params
}
if value.handlers != nil {
c.handlers = value.handlers
c.fullPath = value.fullPath
c.Next()
c.writermem.WriteHeaderNow()
return
}
// ...
break
}
// ...
}
4 Gin的路由树
4.1 策略与原理
在聊 Gin 路由树实现原理之前,需要先补充一个压缩前缀树 radix tree 的基础设定.
(1)前缀树
前缀树又称 trie 树,是一种基于字符串公共前缀构建索引的树状结构,核心点包括:
- 除根节点之外,每个节点对应一个字符
- 从根节点到某一节点,路径上经过的字符串联起来,即为该节点对应的字符串
- 尽可能复用公共前缀,如无必要不分配新的节点
tries 树在 leetcode 上的题号为 208,大家感兴趣不妨去刷刷算法题,手动实现一下.
(2)压缩前缀树
压缩前缀树又称基数树或 radix 树,是对前缀树的改良版本,优化点主要在于空间的节省,核心策略体现在:
倘若某个子节点是其父节点的唯一孩子,则与父节点进行合并
在 gin 框架中,是用压缩前缀树
(3)为什么使用压缩前缀树
与压缩前缀树相对的就是使用 hashmap,以 path 为 key,handlers 为 value 进行映射关联,这里选择了前者的原因在于:
- path 匹配时不是完全精确匹配,比如末尾 ‘/’ 符号的增减、全匹配符号 '*' 的处理等,map 无法胜任(模糊匹配部分的代码于本文中并未体现,大家可以深入源码中加以佐证)
- 路由的数量相对有限,对应数量级下 map 的性能优势体现不明显,在小数据量的前提下,map 性能甚至要弱于前缀树
- path 串通常存在基于分组分类的公共前缀,适合使用前缀树进行管理,可以节省存储空间
(4)补偿策略
在 Gin 路由树中还使用一种补偿策略,在组装路由树时,会将注册路由句柄数量更多的 child node 摆放在 children 数组更靠前的位置.
这是因为某个链路注册的 handlers 句柄数量越多,一次匹配操作所需要话费的时间就越长,被匹配命中的概率就越大,因此应该被优先处理.
4.2 核心数据结构
下面聊一下路由树的数据结构,对应于 9 种 http method,共有 9 棵 methodTree. 每棵 methodTree 会通过 root 指向 radix tree 的根节点.
type methodTree struct {
method string
root *node
}
node 是 radix tree 中的节点,对应节点含义如下:
- path:节点的相对路径,拼接上 RouterGroup 中的 basePath 作为前缀后才能拿到完整的路由 path
- indices:由各个子节点 path 首字母组成的字符串,子节点顺序会按照途径的路由数量 priority进行排序
- priority:途径本节点的路由数量,反映出本节点在父节点中被检索的优先级
- children:子节点列表
- handlers:当前节点对应的处理函数链
type node struct {
// 节点的相对路径
path string
// 每个 indice 字符对应一个孩子节点的 path 首字母
indices string
// ...
// 后继节点数量
priority uint32
// 孩子节点列表
children []*node
// 处理函数链
handlers HandlersChain
// path 拼接上前缀后的完整路径
fullPath string
}
4.3 注册到路由树
承接本文 2.4 小节第(3)部分,下述代码展示了将一组 path + handlers 添加到 radix tree 的详细过程,核心位置均已给出注释,此处就不再赘述了,请大家尽情享用源码盛宴吧!
// 插入新路由
func (n *node) addRoute(path string, handlers HandlersChain) {
fullPath := path
// 每有一个新路由经过此节点,priority 都要加 1
n.priority++
// 加入当前节点为 root 且未注册过子节点,则直接插入由并返回
if len(n.path) == 0 && len(n.children) == 0 {
n.insertChild(path, fullPath, handlers)
n.nType = root
return
}
// 外层 for 循环断点
walk:
for {
// 获取 node.path 和待插入路由 path 的最长公共前缀长度
i := longestCommonPrefix(path, n.path)
// 倘若最长公共前缀长度小于 node.path 的长度,代表 node 需要分裂
// 举例而言:node.path = search,此时要插入的 path 为 see
// 最长公共前缀长度就是 2,len(n.path) = 6
// 需要分裂为 se -> arch
-> e
if i < len(n.path) {
// 原节点分裂后的后半部分,对应于上述例子的 arch 部分
child := node{
path: n.path[i:],
// 原本 search 对应的参数都要托付给 arch
indices: n.indices,
children: n.children,
handlers: n.handlers,
// 新路由 see 进入时,先将 search 的 priority 加 1 了,此时需要扣除 1 并赋给 arch
priority: n.priority - 1,
fullPath: n.fullPath,
}
// 先建立 search -> arch 的数据结构,后续调整 search 为 se
n.children = []*node{&child}
// 设置 se 的 indice 首字母为 a
n.indices = bytesconv.BytesToString([]byte{n.path[i]})
// 调整 search 为 se
n.path = path[:i]
// search 的 handlers 都托付给 arch 了,se 本身没有 handlers
n.handlers = nil
// ...
}
// 最长公共前缀长度小于 path,正如 se 之于 see
if i < len(path) {
// path see 扣除公共前缀 se,剩余 e
path = path[i:]
c := path[0]
// 根据 node.indices,辅助判断,其子节点中是否与当前 path 还存在公共前缀
for i, max := 0, len(n.indices); i < max; i++ {
// 倘若 node 子节点还与 path 有公共前缀,则令 node = child,并调到外层 for 循环 walk 位置开始新一轮处理
if c == n.indices[i] {
i = n.incrementChildPrio(i)
n = n.children[i]
continue walk
}
}
// node 已经不存在和 path 再有公共前缀的子节点了,则需要将 path 包装成一个新 child node 进行插入
// node 的 indices 新增 path 的首字母
n.indices += bytesconv.BytesToString([]byte{c})
// 把新路由包装成一个 child node,对应的 path 和 handlers 会在 node.insertChild 中赋值
child := &node{
fullPath: fullPath,
}
// 新 child node append 到 node.children 数组中
n.addChild(child)
n.incrementChildPrio(len(n.indices) - 1)
// 令 node 指向新插入的 child,并在 node.insertChild 方法中进行 path 和 handlers 的赋值操作
n = child
n.insertChild(path, fullPath, handlers)
return
}
// 此处的分支是,path 恰好是其与 node.path 的公共前缀,则直接复制 handlers 即可
// 例如 se 之于 search
if n.handlers != nil {
panic("handlers are already registered for path '" + fullPath + "'")
}
n.handlers = handlers
// ...
return
}
func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) {
// ...
n.path = path
n.handlers = handlers
// ...
}
呼应于 4.1 小节第(4)部分谈到的补偿策略,下面这段代码体现了,在每个 node 的 children 数组中,child node 在会依据 priority 有序排列,保证 priority 更高的 child node 会排在数组前列,被优先匹配.
func (n *node) incrementChildPrio(pos int) int {
cs := n.children
cs[pos].priority++
prio := cs[pos].priority
// Adjust position (move to front)
newPos := pos
for ; newPos > 0 && cs[newPos-1].priority < prio; newPos-- {
// Swap node positions
cs[newPos-1], cs[newPos] = cs[newPos], cs[newPos-1]
}
// Build new index char string
if newPos != pos {
n.indices = n.indices[:newPos] + // Unchanged prefix, might be empty
n.indices[pos:pos+1] + // The index char we move
n.indices[newPos:pos] + n.indices[pos+1:] // Rest without char at 'pos'
}
return newPos
}
4.4 检索路由树
承接本文 3.3 小节,下述代码展示了从路由树中匹配 path 对应 handler 的详细过程,请大家结合注释消化源码吧.
type nodeValue struct {
// 处理函数链
handlers HandlersChain
// ...
}
// 从路由树中获取 path 对应的 handlers
func (n *node) getValue(path string, params *Params, skippedNodes *[]skippedNode, unescape bool) (value nodeValue) {
var globalParamsCount int16
// 外层 for 循环断点
walk:
for {
prefix := n.path
// 待匹配 path 长度大于 node.path
if len(path) > len(prefix) {
// node.path 长度 < path,且前缀匹配上
if path[:len(prefix)] == prefix {
// path 取为后半部分
path = path[len(prefix):]
// 遍历当前 node.indices,找到可能和 path 后半部分可能匹配到的 child node
idxc := path[0]
for i, c := range []byte(n.indices) {
// 找到了首字母匹配的 child node
if c == idxc {
// 将 n 指向 child node,调到 walk 断点开始下一轮处理
n = n.children[i]
continue walk
}
}
// ...
}
}
// 倘若 path 正好等于 node.path,说明已经找到目标
if path == prefix {
// ...
// 取出对应的 handlers 进行返回
if value.handlers = n.handlers; value.handlers != nil {
value.fullPath = n.fullPath
return
}
// ...
}
// 倘若 path 与 node.path 已经没有公共前缀,说明匹配失败,会尝试重定向,此处不展开
// ...
}