文章目录
- 1.核心数据结构
- 1.1 gin.Context
- 1.2 前缀树
- (1)前缀树
- (2)压缩前缀树
- (3)代码实现
上期文章我们讲到了golang中gin框架的基本原理和底层请求、渲染的流程,还不知道的小伙伴查看golang学习笔记02——gin框架及基本原理。
那么,本章节我们来更进一步,深入学习gin框架的核心数据结构、gin.Context的讲解、前缀树、压缩前缀树和代码实现。
1.核心数据结构
1.1 gin.Context
gin.Context是我们基于gin框架业务开发时最常接触到的结构。
该结构是一个context.Context实现,因此可以将该结构传递到所有接收context.Context的方法或函数中。
type Context struct {
writermem responseWriter
Request *http.Request // http请求
Writer ResponseWriter // http响应输出流
Params Params // URL路径参数
handlers HandlersChain // 处理器链
index int8 // 当前的处理进度,即处理链路处于函数链的索引位置
fullPath string
engine *Engine
...
mu sync.RWMutex // 用于保护 map 的读写互斥锁
// 提供对外暴露的 Get 和 Set 接口向用户提供了共享数据的存取服务,相关操作都在读写锁的保护之下,能够保证并发安全
Keys map[string]any // 缓存 handlers 链上共享数据的 map,由于使用的map,避免了设置多个值时context形成链表
...
queryCache url.Values // 查询参数缓存,使用时调用`Request.URL.Query()`,该方法每次都会对原始的查询字符串进行解析,所以这里设置缓存避免冗余的解析操作
formCache url.Values // 表单参数缓存,作用同上
...
}
由于封装了http.Request和ResponseWriter(内部是http.ResponseWriter)对象,因此可以通过context对http请求响应进行操作。
context中还封装了处理器链HandlersChain和当前处理位置索引,因此可以很方便地访问处理器。
另外,我们知道,context能够以链表形式存储值(也就是说每个k-v会对应一个context,这些context之间之间以链表形式连接),当存在大量值时,访问效率比较低。因此gin.context在内部有一个map[string]any结构专门用于保存这些值,并且提供了线程安全访问方法。
func (c *Context) Set(key string, value any) {
c.mu.Lock()
defer c.mu.Unlock()
if c.Keys == nil {
c.Keys = make(map[string]any)
}
c.Keys[key] = value
}
// Get returns the value for the given key, ie: (value, true).
// If the value does not exist it returns (nil, false)
func (c *Context) Get(key string) (value any, exists bool) {
c.mu.RLock()
defer c.mu.RUnlock()
value, exists = c.Keys[key]
return
}
针对需要用到表单参数和查询字符串参数的场景,gin.Context进行了优化,设计了两个缓存结构(即queryCache和formCache)来提高重复访问时的效率。以表单参数为例:
func (c *Context) PostForm(key string) (value string) {
value, _ = c.GetPostForm(key)
return
}
func (c *Context) GetPostForm(key string) (string, bool) {
if values, ok := c.GetPostFormArray(key); ok {
return values[0], ok
}
return "", false
}
func (c *Context) PostFormArray(key string) (values []string) {
values, _ = c.GetPostFormArray(key)
return
}
func (c *Context) GetPostFormArray(key string) (values []string, ok bool) {
c.initFormCache()
values, ok = c.formCache[key]
return
}
func (c *Context) initFormCache() {
if c.formCache == nil {
c.formCache = make(url.Values)
req := c.Request
// 从这里可以看出,如果不使用缓存,则每次都会解析请求,效率较低
if err := req.ParseMultipartForm(c.engine.MaxMultipartMemory); err != nil {
if !errors.Is(err, http.ErrNotMultipart) {
debugPrint("error on parse multipart form array: %v", err)
}
}
c.formCache = req.PostForm
}
}
通过这样两个缓存结构,避免每次请求时都调用net/http库的方法。
1.2 前缀树
(1)前缀树
前缀树也称Trie树或字典树,是一种基于字符串公共前缀构建树形结构,来降低查询时间和提高效率的目的。前缀树一般用于统计和排序大量的字符串,其核心思想是空间换时间。
前缀树有三个重要特性:
- 根节点不包含字符,除根节点外每一个节点都只包含一个字符。
- 从根节点到某一节点路径上所有字符连接起来,就是该节点对应的字符串。
- 每个节点任意子节点包含的字符都不相同。
如下是普通前缀树的结构:
(2)压缩前缀树
上述前缀树实现起来比较简单,但是在空间利用上并不高效,因此有压缩前缀树。不同之处在于,压缩前缀树会对节点进行压缩,可以简单认为如果某一个节点是其父节点的唯一子节点,则会与父节点合并。
gin框架就采用的是压缩前缀树实现。
我们一般会将前缀树与哈希表结构进行对比,实际上标准库采用的就是哈希表实现。哈希表实现简单粗暴,但是有一些缺点,不太适合作为通用的路由结构。如:
- 哈希表实现只支持简单的路径,不支持路径参数和通配
- 路由的数量一般是有限的,使用map的优势并不明显
- 哈希表需要存储完整的路径,相比较而言前缀树存储公共前缀只需要一个节点,空间效率更高
(3)代码实现
前面说过,gin针对每一个http请求方法,都构造了一棵前缀树,即:
type methodTree struct {
method string
root *node // 该方法对应的路由树的根节点
}
其中method即http请求方法,root则是指向对应前缀树根节点的指针,node结构是前缀树的节点。
type node struct {
path string // 节点路径(不包含父节点)
indices string // 子节点数组中每个节点path的首字母
wildChild bool // 是否存在通配类型的子节点
nType nodeType // 节点类型,包括root(根节点)、static(静态节点)、catchAll(通配符*匹配的节点)、param(参数节点,即带:的节点)
priority uint32 // 根据经过节点的路由数确定的节点优先级。同一个节点下的子节点会按照节点优先级降序排序,匹配时按序遍历children。优先级越高,越先被匹配。
handlers HandlersChain // 处理器链
fullPath string // 完整路径(路由树结构中根节点到当前节点的路径上的全部path的完整拼接)
}
如下是有关优先级的一部分代码:
func (n *node) incrementChildPrio(pos int) int {
// 子节点数组
cs := n.children
// 增加对应的子节点的优先级
cs[pos].priority++
prio := cs[pos].priority
// 调整节点位置,确保整个子节点数组是按照优先级倒序排列的,从而优先级更大的节点会被优先匹配
newPos := pos
for ; newPos > 0 && cs[newPos-1].priority < prio; newPos-- {
// Swap node positions
cs[newPos-1], cs[newPos] = cs[newPos], cs[newPos-1]
}
// 调整前缀字符串,确保每个字母和子节点数组路径的首字母一致
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
}
压缩前缀树部分是gin框架中最复杂的代码。
本人技术水平有限,文章中可能存在不足和遗漏,如果有同学愿意一起学习golang和gin的代码,也可以留言补充,一起学习共同成长!
关注我,带你发现更多有意思的技术和应用~👉👉