1.gin介绍
gin框架是golang中比较常见的web框架,截止到目前(2023-03-21),github上已经累计了67.3K的star数,这足以表明其优秀。作为一名想要知其然亦想知其所以然的程序员,希望通过学习gin框架的实现原理来提高自己的技术能力,也希望通过分享来帮助想要进行学习的同学。
2. 前置知识
其实golang本身的标准库已经足以实现简单的web服务,但是出于以下原因,使得直接使用标准库开发难以满足我们的需求:
- 标准库本身提供了比较简单的路由注册能力,只支持精确匹配,而实际开发时难免会遇到需要使用通配、路径参数的场景
- 标准库暴露给开发者的函数参数是
(w http.ResponseWriter, req *http.Request)
,这就导致我们需要直接从请求中读取数据、反序列化,响应时手动序列化、设置Content-Type、写响应内容,比较麻烦 - 有时候我们希望能够在不过多地侵入业务的前提下,对请求或响应进行一些前置或后置处理。直接基于标准库开发,业务和非业务代码难免会耦合在一起
下面是直接使用标准库开发一个简单的获取当前时间的案例,当客户端以GET
方法请求路径/time
时,服务端以json方式返回当前时间,格式为: {"time": "xxx"}
.
go
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
开发web服务的一般流程:
- 利用
http.HandleFunc
函数注册路由,并指定处理函数,函数签名为func(w http.ResponseWriter, r *http.Request)
- 在处理函数内部获取查询参数、路径参数、读取请求体并反序列化
- 业务逻辑处理
- 错误处理(设置错误响应状态码、错误信息等)
- 设置响应状态码、设置响应头(如Content-Type)、处理结果序列化并写入响应体
- 调用
http.ListenAndServer
进行端口监听
操作起来略有不便,如步骤2、4、5,对每个请求基本都是通用的,每次都写一遍很麻烦。为了解决这个问题,gin框架在标准库的基础上进行了一些封装。下面基于gin框架实现上述需求:
go
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())写回输出
- 监听端口
可以看到简洁了很多,不用再关注响应内容的序列化和状态码问题了。
gin框架自身也是基于标准库net/http
开发的,很多功能实现都是在标准库的基础上进行的封装,因此本文在剖析gin框架的过程中,点到为止,不会过多的对标准库的细节进行说明(后续会专门学习标准库的源码)。
3. 路由注册流程
3.1 核心数据结构
从案例中可知,使用gin开发前需要先调用gin.Default()
函数,该函数返回一个*gin.Engine
对象,该对象就是gin中的一个核心对象。
scss
func Default() *Engine {
debugPrintWARNINGDefault()
engine := New()
engine.Use(Logger(), Recovery())
return engine
}
其实是先调用New方法创建了Engine对象,再调用Use注册middleware,这里先忽略。
(1) gin.Engine
go
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实现了对象池
如果了解golang的http标准库,应该知道: http.ListenAndServe
函数的第二个参数是一个接口类型,只要实现了该接口的ServeHTTP(ResponseWriter, *Request)
方法,就能够对请求进行自定义处理。
go
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
gin.Engine对象其实就是该接口的一个实现,因为它实现了该方法。至于具体处理过程,后续会详细说明。
scss
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
...
}
(2) 路由组RouterGroup
路由组的目的是为了实现配置的复用。
比如有一组对food的请求: /food/add、/food/query、/food/update等,我们希望在注册路由时尽量简单(不要每次都写/food),并且与food相关的请求使用一组单独的middleware(与其他对象的请求隔离开),这时候就可以使用路由组。
下面是其定义:
go
type RouterGroup struct {
// 路由组处理函数链,其下路由的函数链将结合路由组和自身的函数组成最终的函数链
Handlers HandlersChain
// 路由组的基地址,一般是其下路由的公共地址
basePath string
// 路由组所属的Engine,这里构成了双向引用
engine *Engine
// 该路由组是否位于根节点,基于RouterGroup.Group创建路由组时此属性为false
root bool
}
需要注意的是gin.Engine对象本身就是一个路由组。
(3) 处理器链 HandlersChain
上述路由组对象中有一个很重要的字段,即Handlers,用于收集该路由组下注册的middleware函数。在运行时,会按顺序执行HandlersChain中的注册的函数。
go
type HandlerFunc func(*Context)
// HandlersChain defines a HandlerFunc slice.
// NOTE: 路由处理函数链,运行时会根据索引先后顺序依次调用
type HandlersChain []HandlerFunc
3.2 执行流程
一般情况下使用gin框架开发时使用默认的engine即可,因为相对于直接使用gin.New()创建Engine对象,它只是多注册了两个中间件。
下面是一般流程:
- 创建并初始化Engine对象
- 注册middleware
- 注册路由及处理函数
- 服务端口监听