概述
因为维护的一些服务是使用 Gin 框架写的,并且有一些以前工作的网络服务也是使用了和 Gin 类似的思路实现的,所以,迫于对这些服务深度负责的责任感,我尝试深入了解一下 Gin 的底层实现,及时识别出问题和可能出现的风险。
Gin 作为一个流行的 HTTP 框架,使用的人数非常多,但是实际上 Gin 也是足够的简单,以至于没有太多可以讲的。但是,虽然可讲的不多,也是有一些描述和思路值得一提,这篇文章是系列文章的第一篇,主要是介绍一下 Gin 是如何处理 HTTP 请求的。因为 Gin 本质上是扩展原生的 HTTP Server,所以在网络模型上没有太多的介绍之处,所以这里主要关注 Gin 的一些主要数据结构,以及这些数据结构是如何与原生的 HTTP Server 结合起来的。
关键数据结构
- gin.Engine
- gin.RouterGroup
图 1:Engine 和 RouterGroup 的关系 |
---|
可以认为 Gin 的路由模块其实就只有这两个关键数据结构,本质上 Engine
也是一个 RouterGroup
,但是关注点有所不同:
Engine
:关注如何接收 HTTP 请求,然后抽象成内部的数据结构;RouterGroup
:关注如何处理 HTTP 请求,核心在于Router
的构建以及Handler
的查找
如果说再要提一下,那么还有一个核心的数据结构:Context
,它的作用就是串联 HTTP 请求的处理逻辑,这个后面会单独拿来介绍。
路由
要深入代码,我们可以先从一个简单的实例出发,探究底层的实现细节,例如以下是一个非常简单的 Gin 服务:
[root@liqiang.io]# cat main.go
func main() {
gin.Default().GET("/", func(c *gin.Context) {
c.JSON(http.StatusOK, map[string]string{})
})
gin.Default().Run(":8080")
}
它涵盖了 Gin 的基本要素了:
- 首先是
gin.Default().GET
这个函数,它是一个典型的注册路由方法; - 然后是
gin.Default().Run
这个函数,它运行了一个 HTTP Server,并且将接受请求;
第一个我们会好奇的事情就是这个 gin.Default()
是什么?答案可以很明显地从代码中找到:
[root@liqiang.io]# cat gin.go
func Default() *Engine {
... ...
engine := New()
engine.Use(Logger(), Recovery())
return engine
}
这是一个 Engine 数据结构,New()
里面有一些关键的数据结构,我们等下边走边说,先看下我们关注的功能是如何实现的。
路由注册
路由的注册我们已经知道了是 gin.Default().Get
这样的公开方法使用的,实际上调用的底层是这样的:
[root@liqiang.io]# cat routergroup.go
func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) IRoutes {
return group.handle(http.MethodGet, relativePath, handlers)
}
这里我们可以发现了,数据结构已经从 Engine
转移到了 RouterGroup
,这就是最开始的时候我说的职责的分工问题,路由相关的归 RouterGroup
管。那么在 RouterGroup
内部又是如何组织所有的路由信息的呢?这个答案需要继续从 gin 的根目录下的 routergroup.go
中去寻找:
[root@liqiang.io]# cat routergroup.go
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()
}
可以看到这里 RouterGroup
只做了两个事情:
- 重新构造
handlers
:handlers
可以理解成 HTTP 的业务处理函数,更多额含义我们后面看 Middleware 的时候再介绍; - 添加路由到
group.engine
:这个engine
其实就是Engine
的实例。- 这里的实现看上去有一点诡异,
Engine
本来就包含了RouterGroup
,RouterGroup
又包含一个Engine
,那能不能直接让用Engine
就行了? - 理论上是可以的,但是实际上
RouterGroup
它履行的是Group
功能,承载的是一组的路由信息和配置; Engine
是全局的RouterGroup
的概念,它允许承载多个RouterGroup
,并且最终都将映射到全局的RouterGroup
中;
- 这里的实现看上去有一点诡异,
[root@liqiang.io]# cat gin.go
func (engine *Engine) addRoute(method, path string, handlers HandlersChain) {
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)
... ...
}
从这里可以看到一些关键字,例如 engine.trees
,同时还可以看到这个 tree 的组织是以 Request Method 为维度的,也就是说对于每种 HTTP 方法都有独立的一个 tree。那接下来比较关键的就是对于 Gin 来说,这个 tree 要如何组织,这里 Gin 使用的是变化版本的前缀树,因为常规的前缀树是完全匹配,但是 Gin 因为路由规则里面可以有通配符和可选符的存在,使得这个树会更复杂,因为篇幅限制,这里就不对这个数据结构的构造进行深入介绍,如果感兴趣的话,可以参考我的另外一篇文章(Go 框架 Gin 解析 2:路由前缀树的构造),在里面我详细地解析了 Gin 如何针对通配符和可选符进行前缀树的构造。
可以认为加入这个 trees
之后,路由信息就被添加到 Gin 里面了,到此,路由的注册就算是完成了。
路由解析
那么在我们知道这是几棵前缀树在工作之后,当一个请求进来,那么我们应该可以猜测到一些 Gin 是如何找到这个请求对应的处理函数是哪些了,这里我从这个函数开始介绍起:
[root@liqiang.io]# cat gin.go
// ServeHTTP conforms to the http.Handler interface.
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)
}
稍微熟悉一点 Go 原生 HTTP 的同学可能会熟悉,这其实就是 Go 原生 HTTP handler 的原型,实际上 Gin 也是将这个数据结构作为原生 HTTP Library 的入口,所以,如果你要问 Gin 的底层网络模型用的是什么?那么答案就是没有特别的网络模型,就是 Go HTTP Server 原生的网络模型。
在这个 Gin 的 HTTP 处理入口中,核心就一个:构建 Context,这个 Context 在最开始也介绍了,也算是 Gin 的关键数据结构之一,可以看到在这里的实现中,它使用了 pool 的技术,这些细节我都在后续的文章中会详细介绍:Go 框架 Gin 解析 4:Context。
言归正传,当 Gin 开始处理请求时,那么寻找 Handler 的原理还是从 trees 入手,代码其实也比较简单:
[root@liqiang.io]# cat gin.go
func (engine *Engine) handleHTTPRequest(c *Context) {
// Find root of the tree for the given HTTP method
t := engine.trees
for i, tl := 0, len(t); i < tl; i++ {
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
}
if value.handlers != nil {
c.handlers = value.handlers
c.fullPath = value.fullPath
c.Next()
c.writermem.WriteHeaderNow()
return
}
可以看到,一开始还是以 Request Method 为维度找到特定的那棵树,然后在这个树里面匹配请求,找到这个请求处理函数,然后调用这个请求处理函数,这个请求处理函数的处理机制又是一个精彩的故事了,我们在下面一节关于 Middleware 的 Section 里面会介绍到,所以这里可以先这么理解。那么具体在一棵树里面又是怎么找的呢?这其实也是和前缀树的构造息息相关,这里还是先跳过了,对于有兴趣的同学,还是跳转到我的这篇文章(Go 框架 Gin 解析 2:路由前缀树的构造)进行了解。
异常情况处理
除了正常的路由处理之外,Gin 还额外关注了两种情况:
- URL 存在,但是不支持请求的方法
- 请求的 URL 不存在
Method Not Allow
如果在正常路由中找不到指定的路由处理函数,那么 Gin 会尝试其他的请求方法是否满足请求的 URL,如果存在,那么就会调用特定的处理函数来处理,这个函数用户可以自定义:
[root@liqiang.io]# cat gin.go
if engine.HandleMethodNotAllowed {
for _, tree := range engine.trees {
if tree.method == httpMethod {
continue
}
if value := tree.root.getValue(rPath, nil, c.skippedNodes, unescape); value.handlers != nil {
c.handlers = engine.allNoMethod
serveError(c, http.StatusMethodNotAllowed, default405Body)
return
}
}
}
如果你想自定义处理函数,其实也很简单:
[root@liqiang.io]# cat main.go
server := gin.New()
server.NoMethod(func(c *gin.Context) {
log4go.Error("Method not found")
})
404 Not Found
如果前面的正常路由以及 URL 可以找到,但是指定的请求方法找不到的情况之外,那么就会进入到 Not Found 的处理逻辑了,极其简单:
[root@liqiang.io]# cat gin.go
c.handlers = engine.allNoRoute
serveError(c, http.StatusNotFound, default404Body)
这个 Not Found 的处理函数也是可以自定义的,自定义也很容易,需要注意的是,和 405 的处理函数一样,这个处理函数也是全局的(不是 Group)级别:
[root@liqiang.io]# cat main.go
server := gin.New()
server.NoRoute(func(c *gin.Context) {
log4go.Error("Route not found")
})
字段说明
在 Engine 中,你可能会发现有两个好像类似的字段:
[root@liqiang.io]# cat engine.go
type Engine struct {
RouterGroup
... ...
allNoRoute HandlersChain
noRoute HandlersChain
这两个字段的区别就是带不带 all 前缀,那么他们之间的区别是什么?其实也蛮简单的:
allNoRoute
:这个方法会调用你添加的 Middlewares,所以你的 Middleware 才能在出现 404 错误的时候做一些额外的处理,例如日志记录和监控数据的记录之类的;noRoute
:这就是一个简单的 Gin Handler,不会调用 Middleware,直接使用的地方也只有staticHandler
中;
总结
在本文中,我介绍了 Gin 的关键数据结构以及 HTTP 请求的接收和处理流程,包括了 Gin 的路由注册和获取的逻辑。实际上这也是 Gin 的大部分内容了,Engine
的大部分字段都与此相关了,此外,还没有提及的字段相关的内容有:路由树的构造和使用、模板渲染、URL 参数的获取 这几个,这些都将在后面的系列文章中会介绍。