1. 序言
gin框架是golang中比较常见的web框架,截止到目前(2023-03-21),github上已经累计了67.3K的star数,这足以表明其优秀。作为一名想要知其然亦想知其所以然的程序员,希望通过学习gin框架的实现原理来提高自己的技术能力,也希望通过分享来帮助想要进行学习的同学。
版本: v1.8.2
2. 前置知识
其实golang本身的标准库已经足以实现简单的web服务,但是出于以下原因,使得直接使用标准库开发难以满足我们的需求:
(w http.ResponseWriter, req *http.Request)
GET/time{"time": "xxx"}
func main() {
http.HandleFunc("/time", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
tim := map[string]string{
"time": time.Now().Format("2006-01-02"),
}
byts, err := json.Marshal(tim)
if err != nil {
panic(err)
}
w.Write(byts)
})
http.ListenAndServe("0.0.0.0:8080", nil)
}
net/http
http.HandleFuncfunc(w http.ResponseWriter, r *http.Request)http.ListenAndServer
操作起来略有不便,如步骤2、4、5,对每个请求基本都是通用的,每次都写一遍很麻烦。为了解决这个问题,gin框架在标准库的基础上进行了一些封装。下面基于gin框架实现上述需求:
func main() {
mux := gin.Default()
mux.GET("/time", func(c *gin.Context) {
m := map[string]string{
"time": time.Now().Format("2006-01-02"),
}
c.JSON(http.StatusOK, m)
})
err := mux.Run("0.0.0.0:8080")
if err != nil {
panic(err)
}
}
基于gin开发的一般流程可总结为:
- 创建gin.Engine、注册middleware
- 注册路由,编写处理函数,在函数内通过gin.Context获取参数,进行逻辑处理,通过gin.Context暴露的方法(如JSON())写回输出
- 监听端口
可以看到简洁了很多,不用再关注响应内容的序列化和状态码问题了。
net/http
3. 路由注册流程
3.1 核心数据结构
gin.Default()*gin.Engine
func Default() *Engine {
debugPrintWARNINGDefault()
engine := New()
engine.Use(Logger(), Recovery())
return engine
}
其实是先调用New方法创建了Engine对象,再调用Use注册middleware,这里先忽略。
(1) gin.Engine
func New() *Engine {
...
engine := &Engine{
// NOTE: 实例化RouteGroup,路由管理相关(Engine自身也是一个RouterGroup)
RouterGroup: RouterGroup{
Handlers: nil,
basePath: "/",
root: true,
},
...
// NOTE: 负责存储路由和处理方法的映射,采用类似字典树的结构(这里构造了几棵树,每棵树对应一个http请求方法)
trees: make(methodTrees, 0, 9),
...
}
...
// NOTE: 基于sync.Pool实现的context池,能够避免context频繁销毁和重建
engine.pool.New = func() any {
return engine.allocateContext(engine.maxParams)
}
return engine
}
该结构中包含三个核心对象:
- RouterGroup: 路由组,和路由管理相关
- 路由树数组trees: 标准库本身的路由是不区分请求方法的,也就是说注册一个路由后,GET、POST都能匹配到该路由。这显然不是我们想要的,我们希望的是同一个路由在不同的请求方法下,由不同的逻辑进行处理。其实就是通过路由树实现的,gin的针对每个请求方法都有一棵路由树
- context对象池: gin.Context是gin框架暴露给开发的另一个核心对象,可以通过该对象获取请求信息,业务处理的结果也是通过该对象写回客户端的。为了实现context对象的复用,gin基于sync.Pool实现了对象池
http.ListenAndServeServeHTTP(ResponseWriter, *Request)
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
gin.Engine对象其实就是该接口的一个实现,因为它实现了该方法。至于具体处理过程,后续会详细说明。
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
...
}
(2) 路由组RouterGroup
路由组的目的是为了实现配置的复用。
比如有一组对food的请求: /food/add、/food/query、/food/update等,我们希望在注册路由时尽量简单(不要每次都写/food),并且与food相关的请求使用一组单独的middleware(与其他对象的请求隔离开),这时候就可以使用路由组。
下面是其定义:
type RouterGroup struct {
// 路由组处理函数链,其下路由的函数链将结合路由组和自身的函数组成最终的函数链
Handlers HandlersChain
// 路由组的基地址,一般是其下路由的公共地址
basePath string
// 路由组所属的Engine,这里构成了双向引用
engine *Engine
// 该路由组是否位于根节点,基于RouterGroup.Group创建路由组时此属性为false
root bool
}
需要注意的是gin.Engine对象本身就是一个路由组。
(3) 处理器链 HandlersChain
上述路由组对象中有一个很重要的字段,即Handlers,用于收集该路由组下注册的middleware函数。在运行时,会按顺序执行HandlersChain中的注册的函数。
type HandlerFunc func(*Context)
// HandlersChain defines a HandlerFunc slice.
// NOTE: 路由处理函数链,运行时会根据索引先后顺序依次调用
type HandlersChain []HandlerFunc
3.2 执行流程
一般情况下使用gin框架开发时使用默认的engine即可,因为相对于直接使用gin.New()创建Engine对象,它只是多注册了两个中间件。
下面是一般流程:
- 创建并初始化Engine对象
- 注册middleware
- 注册路由及处理函数
- 服务端口监听
下面详细说明
3.3 创建并初始化gin.Engine
我们调用gin.Default创建一个默认的gin.Engine对象,其实际上会调用gin.New
func New() *Engine {
...
engine := &Engine{
// NOTE: 实例化RouteGroup,路由管理相关(Engine自身也是一个RouterGroup)
RouterGroup: RouterGroup{
Handlers: nil,
basePath: "/",
root: true,
},
...
// NOTE: 负责存储路由和处理方法的映射,采用类似字典树的结构(这里构造了几棵树,每棵树对应一个http请求方法)
trees: make(methodTrees, 0, 9),
...
}
...
// NOTE: 基于sync.Pool实现的context池,能够避免context频繁销毁和重建
engine.pool.New = func() any {
return engine.allocateContext(engine.maxParams)
}
return engine
}
对以下对象进行初始化:
- 创建根路径下的路由组
- 创建九棵路由树
- 初始化context对象池
3.4 注册middleware
gin.Defaultgin.Newgin.Enginegin.UseLogger()Recovery()
func (engine *Engine) Use(middleware ...HandlerFunc) IRoutes {
// NOTE: 将注册的中间件添加到RouterGroup的Handlers处理函数链中
engine.RouterGroup.Use(middleware...)
engine.rebuild404Handlers()
engine.rebuild405Handlers()
return engine
}
func (group *RouterGroup) Use(middleware ...HandlerFunc) IRoutes {
group.Handlers = append(group.Handlers, middleware...)
return group.returnObj()
}
HandlersChain
3.5 注册路由及处理函数
mux.GET("/time", func(c *gin.Context) {
m := map[string]string{
"time": time.Now().Format("2006-01-02"),
}
c.JSON(http.StatusOK, m)
})
gin.Engine
RouterGroup.handle
func (group *RouterGroup) POST(relativePath string, handlers ...HandlerFunc) IRoutes {
return group.handle(http.MethodPost, relativePath, handlers)
}
// GET is a shortcut for router.Handle("GET", path, handlers).
func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) IRoutes {
return group.handle(http.MethodGet, relativePath, handlers)
}
...
handle
- 拼接完整的路径参数
- 组合处理函数链
- 注册完成路径及处理函数链到路由树
func (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes {
// 将路由组的基地址和传入的相对地址组合成绝对路径
absolutePath := group.calculateAbsolutePath(relativePath)
// 将路由组的处理函数链和当前路由的处理函数组合成完成的处理函数链
handlers = group.combineHandlers(handlers)
// 将路由及其对应的处理函数链添加到路由树中
group.engine.addRoute(httpMethod, absolutePath, handlers)
return group.returnObj()
}
(1) 拼接完整的路径参数
这个很好理解,上面说过使用路由组之后,注册路由时不用每次都写前缀。比如/food/add、/food/query、/food/update这几个,路由组的路径是/food,基于该路由组注册路由时只需要注册/add、/query、/update就行了。其实就是在这里进行拼接的。
func (group *RouterGroup) calculateAbsolutePath(relativePath string) string {
return joinPaths(group.basePath, relativePath)
}
func joinPaths(absolutePath, relativePath string) string {
if relativePath == "" {
return absolutePath
}
finalPath := path.Join(absolutePath, relativePath)
if lastChar(relativePath) == '/' && lastChar(finalPath) != '/' {
return finalPath + "/"
}
return finalPath
}
(2) 组合处理函数链
我们可以针对每个路由组单独设置middleware,实际执行时会先执行注册的中间件,最后才执行注册的业务处理函数。实现上,则是将路由组中注册的中间件和业务处理函数组合在一起。由于是按照顺序append到切片中的,所以执行顺序其实就是注册顺序。
func (group *RouterGroup) combineHandlers(handlers HandlersChain) HandlersChain {
// 构造新的切片,其长度为路由组过滤器链长度 + 路由的处理链长度
finalSize := len(group.Handlers) + len(handlers)
// 这里要求处理器链的长度最大为63,超过此长度注册路由会失败(Abort就是通过设置Index为63来提前中断处理器链的执行的)
assert1(finalSize < int(abortIndex), "too many handlers")
mergedHandlers := make(HandlersChain, finalSize)
// 深拷贝路由组处理器链
copy(mergedHandlers, group.Handlers)
// 深拷贝路由处理器链
copy(mergedHandlers[len(group.Handlers):], handlers)
return mergedHandlers
}
(3) 注册完成路径及处理函数链到路由树
前面说过gin针对每个http请求方法,都构造了一棵路由树。这里就需要根据注册路由的请求方法获取对应的路由树,再将路由的完整路径和对应的处理函数链注册到路由树中,后续才能根据请求路径调用对应的处理函数链进行处理。
func (engine *Engine) addRoute(method, path string, handlers HandlersChain) {
...
// 每个请求方法(GET/POST...)都对应一棵前缀树,这里获取当前方法的前缀树
root := engine.trees.get(method)
// 首次添加此方法的路由,构造前缀树
if root == nil {
root = new(node)
root.fullPath = "/"
engine.trees = append(engine.trees, methodTree{method: method, root: root})
}
// 将路由的绝对路径和对应的完整处理函数链添加到路由树
root.addRoute(path, handlers)
...
}
这里只需要先知道,路由树是用压缩前缀树实现的,由于比较复杂,后面再讲。
3.6 服务端口监听
Engine.Run
func (engine *Engine) Run(addr ...string) (err error) {
defer func() { debugPrintError(err) }()
...
err = http.ListenAndServe(address, engine.Handler())
return
}
4. 请求处理
http.ServeHTTPEngine.ServeHTTP
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
c := engine.pool.Get().(*Context)
c.writermem.reset(w)
c.Request = req
c.reset()
engine.handleHTTPRequest(c)
engine.pool.Put(c)
}
其核心处理处理逻辑如下:
http.ResponseWriterhttp.RequesthandleHTTPRequestgin.Context
handleHTTPRequest
func (engine *Engine) handleHTTPRequest(c *Context) {
httpMethod := c.Request.Method
rPath := c.Request.URL.Path
...
// Find root of the tree for the given HTTP method
t := engine.trees
for i, tl := 0, len(t); i < tl; i++ {
// 根据http请求方法获取对应的路由树
if t[i].method != httpMethod {
continue
}
root := t[i].root
// Find route in tree
// 根据请求路径获取路由树节点信息,包括处理器链和路径
value := root.getValue(rPath, c.params, c.skippedNodes, unescape)
if value.params != nil {
c.Params = *value.params
}
// 将处理器链注入到context中
if value.handlers != nil {
c.handlers = value.handlers
c.fullPath = value.fullPath
// NOTE: 开启 handlers 链的遍历调用流程
c.Next()
c.writermem.WriteHeaderNow()
return
}
...
break
}
...
}
前面讲到过,gin为每一个http请求方法创建了一棵路由树,每棵树保存了完整的路由路径和对应的处理器链。所以这部分逻辑其实是:
c.Next()WriteHeaderNow
func (w *responseWriter) WriteHeaderNow() {
if !w.Written() {
w.size = 0
w.ResponseWriter.WriteHeader(w.status)
}
}
func (w *responseWriter) Written() bool {
return w.size != noWritten
}
上面说过,注册处理器时,会将所属RouterGroup注册的中间件函数和路由处理器组合在一个切片中。
由于采用的是append操作,所以注册的顺序就是实际执行的顺序。
index
c.Next()
func (c *Context) Next() {
c.index++
for c.index < int8(len(c.handlers)) {
c.handlers[c.index](c)
c.index++
}
}
Abort
func (c *Context) Abort() {
c.index = abortIndex
}
const abortIndex int8 = math.MaxInt8 >> 1
5. 请求绑定和响应渲染
基于标准库开发时,我们可以从请求体中以字节流的方式读取请求内容,也可以将内容以字节流的方式写回去。但是会比较麻烦,
请求时我们需要基于请求的数据格式,决定应该怎样反序列化输入流、自己实现数据校验。
响应时,需要自己去序列化响应结构、设置content-type、写入响应流。
这几个过程不仅重复,而且需要多次判断error,最好是交给框架来做这件事,从而将开发的注意力集中在业务逻辑上。
5.1. 请求绑定
问题在于,从请求中读取的数据应该以什么类型组织呢,是string、int还是某个自定义的结构体?
ShoudBindJson
func (c *Context) ShouldBindJSON(obj any) error {
return c.ShouldBindWith(obj, binding.JSON)
}
func (c *Context) ShouldBindWith(obj any, b binding.Binding) error {
return b.Bind(c.Request, obj)
}
binding.BindingjsonBinding
func (jsonBinding) Bind(req *http.Request, obj any) error {
if req == nil || req.Body == nil {
return errors.New("invalid request")
}
return decodeJSON(req.Body, obj)
}
func decodeJSON(r io.Reader, obj any) error {
decoder := json.NewDecoder(r)
if EnableDecoderUseNumber {
decoder.UseNumber()
}
if EnableDecoderDisallowUnknownFields {
decoder.DisallowUnknownFields()
}
if err := decoder.Decode(obj); err != nil {
return err
}
return validate(obj)
}
反序列化完毕后,还涉及输入内容的校验,哪些字段必填、长度是否固定等,如果我们要在程序中判断,会比较繁琐。我们一般会采用 https://github.com/go-playground/validator 这个库的实现。实际上,gin也是基于这个库实现的。
var Validator StructValidator = &defaultValidator{}
type defaultValidator struct {
once sync.Once
validate *validator.Validate
}
func validate(obj any) error {
if Validator == nil {
return nil
}
return Validator.ValidateStruct(obj)
}
5.2 响应渲染
JSON
├── any.go
├── data.go
├── html.go
├── json.go
├── msgpack.go
├── protobuf.go
├── reader.go
├── redirect.go
├── render.go
├── text.go
├── toml.go
├── xml.go
└── yaml.go
context.JSON
func (c *Context) JSON(code int, obj any) {
c.Render(code, render.JSON{Data: obj})
}
func (c *Context) Render(code int, r render.Render) {
c.Status(code)
if !bodyAllowedForStatus(code) {
r.WriteContentType(c.Writer)
c.Writer.WriteHeaderNow()
return
}
if err := r.Render(c.Writer); err != nil {
// Pushing error to c.Errors
_ = c.Error(err)
c.Abort()
}
}
Statusr.Render
func (c *Context) Status(code int) {
c.Writer.WriteHeader(code)
}
这里r是一个接口类型,该类型用于对所有响应内容的方法进行抽象。需要实现的方法包括:
http.ResponseWriter
type Render interface {
// Render writes data with custom ContentType.
Render(http.ResponseWriter) error
// WriteContentType writes custom ContentType.
WriteContentType(w http.ResponseWriter)
}
以JSON类型为例。
Renderhttp.ResponseWriter
func (r JSON) Render(w http.ResponseWriter) error {
return WriteJSON(w, r.Data)
}
func WriteJSON(w http.ResponseWriter, obj any) error {
writeContentType(w, jsonContentType)
jsonBytes, err := json.Marshal(obj)
if err != nil {
return err
}
_, err = w.Write(jsonBytes)
return err
}
WriteContentTypeapplication/json
jsonContentType = []string{"application/json; charset=utf-8"}
func writeContentType(w http.ResponseWriter, value []string) {
header := w.Header()
if val := header["Content-Type"]; len(val) == 0 {
header["Content-Type"] = value
}
}
6. 核心数据结构
gin.Context
gin.Context
该结构是一个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.RequestResponseWriter
context中还封装了处理器链HandlersChain和当前处理位置索引,因此可以很方便地访问处理器。
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
}
queryCacheformCache
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
6.2 前缀树
6.2.1 前缀树
前缀树也称Trie树或字典树,是一种基于字符串公共前缀构建树形结构,来降低查询时间和提高效率的目的。前缀树一般用于统计和排序大量的字符串,其核心思想是空间换时间。
前缀树有三个重要特性:
- 根节点不包含字符,除根节点外每一个节点都只包含一个字符。
- 从根节点到某一节点路径上所有字符连接起来,就是该节点对应的字符串。
- 每个节点任意子节点包含的字符都不相同。
如下是普通前缀树的结构:
6.2.2 压缩前缀树
上述前缀树实现起来比较简单,但是在空间利用上并不高效,因此有压缩前缀树。不同之处在于,压缩前缀树会对节点进行压缩,可以简单认为如果某一个节点是其父节点的唯一子节点,则会与父节点合并。
gin框架就采用的是压缩前缀树实现。
我们一般会将前缀树与哈希表结构进行对比,实际上标准库采用的就是哈希表实现。哈希表实现简单粗暴,但是有一些缺点,不太适合作为通用的路由结构。如:
- 哈希表实现只支持简单的路径,不支持路径参数和通配
- 路由的数量一般是有限的,使用map的优势并不明显
- 哈希表需要存储完整的路径,相比较而言前缀树存储公共前缀只需要一个节点,空间效率更高
6.2.3 代码实现
前面说过,gin针对每一个http请求方法,都构造了一棵前缀树,即:
type methodTree struct {
method string
root *node // 该方法对应的路由树的根节点
}
methodrootnode
type node struct {
path string
indices string
wildChild bool
nType nodeType
priority uint32
handlers HandlersChain
fullPath string
}
上述字段含义如下:
- path: 节点路径(不包含父节点)
- children: 子节点数组
- indices: 子节点数组中每个节点path的首字母
- wildChild: 是否存在通配类型的子节点
- nType: 节点类型,包括root(根节点)、static(静态节点)、catchAll(通配符*匹配的节点)、param(参数节点,即带:的节点)
- priority: 根据经过节点的路由数确定的节点优先级。同一个节点下的子节点会按照节点优先级降序排序,匹配时按序遍历children。优先级越高,越先被匹配。
- handlers: 处理器链
- fullPath: 完整路径(路由树结构中根节点到当前节点的路径上的全部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框架中最复杂的代码,出于篇幅原因这里只说明其中一部分,我在代码仓库中做了更详尽的注释。
如果有同学愿意一起学习gin的代码,也可以补充注释后给我的仓库提MR