浅析 Gin 框架设计
背景
net/http
虽然具有了路由注册功能,并且可以启动监听端口,实现一个简单的 http
服务,但是 net/http
本身提供的功能还是比较简单、原始,而且暴露的函数签名的参数是 (w http.ResponseWriter, req *http.Request)
,开发者解析请求和回写结果都不是很方便,因此产生了很多 http
框架。
gin 框架基于 httprouter
实现最重要的路由模块,采用类似字典树一样的数据结构来存储路由与 handle
方法的映射,HTTP 实现和标准库 net/http
是一致的。
其他改进了 net/http
框架的还有 fasthttp
,采用了不同于 net/http
的 HTTP 实现,具有极快的运行性能。net/http
的实现是一个连接新建一个 goroutine
;fasthttp
是利用一个 worker
复用 goroutine
,减轻 runtime
调度 goroutine
的压力。
web 服务启动流程
- 核心概念:
Request
:用户请求的信息:用来解析用户的请求信息,包括 post、get、cookie、url 等信息。Response
:服务器需要反馈给客户端的信息。- 用户的每次请求连接。
Handler
:处理请求和生成返回信息的处理函数。Router
:路由根据url
找到对应的Handler
并执行
- 客户端与服务端的交互来自
client
的request
和server
的response
。- Router 实现一个 Multiplexer, Multiplexer 的目的是为了找到 path 对应的处理函数 handler
- handler 对 request 进行处理,同时构建 response
- 具体流程是:
Client
->Requests
->Multiplexer(router)
->handler
->Response
->Client
- Golang 中的 http 服务最重要的就是
Multiplexer
和handler
,Golang 中的Multiplexer
基于ServeMux
结构实现。
net/http 库中的 web 服务启动流程
核心结构
Handler
接口: 所有请求的处理器、路由ServeMux
都满足该接口。任何结构体,只要实现了ServeHTTP
方法,这个结构就可以称之为handler
对象。ServeMux
结构体: HTTP 请求的Multiplexer(路由)
。负责将每一个接收到的请求的 URL 与一个注册模式的列表进行匹配,并调用和 URL 最匹配的模式的处理器,其内部用一个 map 来保存所有处理器Handler
。muxEntry
结构体:保存了Handler
请求处理器和匹配的模式字符串- 包级别变量
DefaultServeMux:ServeMux
的一个实例,表示默认路由,使用包级别的http.Handle()
、http.HandleFunc()
方法注册处理器时都是注册到该路由中。 ServerMux
结构体的ServeHTTP()
方法: 位于ServerMux
结构体中,满足Handler
接口。主要用于间接调用它所保存的muxEntry
中保存的Handler
处理器的ServeHTTP()
方法。
http.HandlerFunc
适配器:- 满足
Handler
接口,这个类型默认就实现了ServeHTTP
- 只要函数的签名为
func(w http.ResponseWriter, r *http.Request)
,均可作为处理函数,因为它可以被转换为http.HandlerFunc
函数类型
- 满足
Server
结构体: 用户可以定义一个 struct,其中实现了ServeHTTP
方法,作为handler
对象传给Server
。
1 | // Handler接口 |
HTTP 的使用模式
- 使用默认的路由来注册处理函数
- 使用自定义的路由来注册处理函数
- 直接自定义一个
Server
实例:该模式可以很方便的管理服务端的行为
1 | func main() { |
完整执行过程:监听端口、建立连接和处理 client 请求
- 通过
http.ListenAndServe(addr string, handler Handler)
启动服务,实例化Server
,调用Server
对象的ListenAndServer
方法,并将该方法的返回值error
返回给调用方。 server.ListenAndServe()
内部调用net.Listen("tcp", addr)
,该方法内部又调用net.ListenTCP()
创建并返回一个net.Listener
监听器 ln。- 然后把监听器 ln 断言转换为
TCPListener
类型,并根据它构造一个tcpKeepAliveListener
对象并传递给server.Serve()
方法。这是因为TCPListener
实现了Listener
接口,所以tcpKeepAliveListener
也实现了Listener
接口,并且它重写了Accept()
方法,目的是为了调用SetKeepAlive(true)
,让操作系统为收到的每一个连接启动发送keepalive
消息(心跳,为了保持连接不断开)。 server.Serve()
方法调用tcpKeepAliveListener
对象的Accept()
方法返回一个连接conn(该连接启动了心跳),并为每一个conn创建一个新的go协程执行conn.server()
方法conn.server()
方法会读取请求,然后根据conn
内保存的server
来构造一个serverHandler
类型,并调用它的ServeHTTP()
方法:serverHandler{c.server}.ServeHTTP(w, w.req)
- 路由
ServeMux
的ServeHTTP
方法则会根据当前请求提供的信息来查找最匹配的Handler
- 查找到的
Handler
接口值 h 就是我们事先注册到路由中与请求匹配的Handler
;而h的动态类型是HandlerFunc
类型(它也满足Handler
接口);所以,以上h.ServeHTTP(w, r)
实际上调用的是接口值 h 中持有的动态值(也就是我们定义的处理函数,详见http.HandlerFunc
适配器)
1 | // (1) 实例化 server |
Gin 框架中的路由设置
Gin 核心结构
gin.Context
- 最重要的结构,贯穿一个 http 请求的所有流程,包含全部上下文信息。
- 提供了很多内置的数据绑定和响应形式,JSON、HTML、Protobuf 、MsgPack、Yaml 等,它会为每一种形式都单独定制一个渲染器
- engine的
ServeHTTP
方法,在响应一个用户的请求时,都会先从临时对象池中取一个 context 对象。使用完之后再放回临时对象池。为了保证并发安全,如果在一次请求新起一个协程,那么一定要 copy 这个 context 进行参数传递
1 | type Context struct { |
gin.Engine
- 框架的入口,通过
Engine
对象来定义服务路由信息、组装插件、运行服务,是框架的核心发动机,整个Web
服务的都是由它来驱动的。 - 用的是 Go 语言内置的
http server
,Engine
的本质只是对内置的 HTTP 服务器的包装。 - 关键字段
- trees:Router Tree 基于基数树实现。每个节点都会挂接若干请求处理函数构成一个请求处理链
HandlersChain
。当一个请求到来时,在这棵树上找到请求 URL 对应的节点,拿到对应的请求处理链来执行就完成了请求的处理。 - addRoute 方法:用于添加 URL 请求处理器,它会将对应的路径和处理器挂接到相应的请求树中。
RouterGroup
:RouterGroup
是对路由树的包装,所有的路由规则最终都是由它来进行管理。Engine
结构体继承了RouterGroup
,所以Engine
直接具备了 RouterGroup 所有的路由管理功能。同时RouteGroup
对象里面还会包含一个Engine
的指针,可以调用engine
的addRoute
方法。IRouter
接口:RouterGroup
实现了IRouter
接口,暴露了一系列路由方法,这些方法最终都是通过调用 Engine.addRoute 方法将请求处理器挂接到路由树中RouterGroup
内部有一个前缀路径属性,它会将所有的子路径都加上这个前缀再放进路由树中。有了这个前缀路径,就可以实现 URL 分组功能。
- trees:Router Tree 基于基数树实现。每个节点都会挂接若干请求处理函数构成一个请求处理链
1 | func New() *Engine { |
gin.RouterGroup
- 方便对路由进行管理,可以对组进行设置中间件。
RouterGroup
是对路由树的包装,所有的路由规则最终都是由它来进行管理。Engine 结构体继承了RouterGroup
,所以Engine
直接具备了RouterGroup
所有的路由管理功能。同时RouteGroup
对象里面还会包含一个Engine
的指针,可以调用 engine 的addRoute
方法。RouterGroup
实现了 IRouter 接口,暴露了一系列路由方法,这些方法最终都是通过调用Engine.addRoute
方法将请求处理器挂接到路由树中。RouterGroup
内部有一个前缀路径属性,它会将所有的子路径都加上这个前缀再放进路由树中。有了这个前缀路径,就可以实现 URL 分组功能。Engine 对象内嵌的RouterGroup
对象的前缀路径是 /,它表示根路径。RouterGroup
支持分组嵌套,使用Group
方法就可以让分组下面再挂分组。
radix tree
前缀树是一个多叉树,广泛应用于字符串搜索,每个树节点存储一个字符,从根节点到任意一个叶子结点串起来就是一个字符串。
radix tree
是优化之后的前缀树,对空间进一步压缩,从上往下提取公共前缀,非公共部分存到子节点,这样既节省了空间,同时也提高了查询效率(左边字符串sleep查询需要5步, 右边只需要3步),Gin 的路由树就是用 radix tree
实现的。
Gin为每一种请求都维护了一个 radix tree
,不同的请求会被解析并送到对应的 radix tree
进行处理。
Gin 为了更具扩展性,每一层的节点按照 priority 排序,一个节点的 priority 值表示他包含的所有子节点(子节点,孙节点等)的数量,这样做有两个好处:
- 被最多路径包含的节点会被最先评估。这样可以让尽量多的路由快速被定位。
- 有点像成本补偿。最长的路径可以被最先评估,补偿体现在最长的路径需要花费更长的时间来定位,如果最长路径的节点能被优先评估(即每次拿子节点都命中),那么所花时间不一定比短路径的路由长。
1 | //树节点 |
中间件与请求链
- 每个路由节点都挂一个函数链。
- 只有函数链的尾部是业务处理,前面的部分都是插件函数。
- 在 Gin 中插件和业务处理函数形式是一样的,都是
func(*Context)
。当我们定义路由时,Gin 会将插件函数和业务处理函数合并在一起形成一个链条结构。Gin 在接收到客户端请求时,找到相应的处理链,构造一个 Context 对象,再调用它的 Next() 方法就正式进入了请求处理的全流程。 - 4 种常用方法
- (1) c.Next()
利用函数调用栈后进先出的特点,巧妙的完成中间件在自定义处理函数完成的后处理的操作。 - (2) c.Abort()
原理是将 Context.index 调整到一个比较大的数字,这样 Next() 方法中的调用循环就会立即结束(而不是中断执行流)。执行 Abort() 方法之后,当前函数内后面的代码逻辑还会继续执行 - (3) c.Set()
- (4) c.Get()
- (1) c.Next()
1 | // Next should be used only inside middleware. |
完整启动流程
- Egnine 初始化:通过调用 gin.New() 方法来实例化 Engine。这个过程中同时会创建Context 对象池,减少频繁context实例化带来的资源消耗。
- 注册中间件:将路由处理函数(endpoint)和中间件函数(middleware) 注册到 radix tree 中,tree 中的每个节点可以认为是一个键值对,key 是路由,value 是[ ]HandlerFunc, 里面存储的就是按顺序执行的中间件和handle控制器方法。
- 注册全局中间件
- 注册路由组中间件
- 注册路由以及中间件
- 启动:通过调用 net/http 来启动服务,由于 engine 实现了 ServeHTTP 方法,只需要直接传 engine 对象就可以完成初始化并启动
- 处理请求:通过请求方法和路由找到相对应的树节点,获取储存的 []HandlerFunc 列表,通过调用 c.Next() 处理请求,通过不停的移动下标递归,最后完成处理返回结果