gin 是目前 Go 里面使用最广泛的框架之一了,弄清楚 gin 框架的原理,有助于我们更好的使用 gin。这个系列 gin 源码阅读会逐步讲明白 gin 的原理,欢迎关注后续文章。

gin 概览

想弄清楚 gin, 需要弄明白以下几个问题:

request数据是如何流转的。
gin框架到底扮演了什么角色。
请求从gin流入net/http, 最后又是如何回到gin中。
gin的context为何能承担起来复杂的需求。
gin的路由算法。
gin的中间件是什么。
gin的Engine具体是个什么东西。
net/http的requeset, response都提供了哪些有用的东西。

从gin的官方第一个demo入手:

r.Run() 的源码:

看到开始调用的是 http.ListenAndServe(address, engine), 这个函数是net/http的函数, 然后请求数据就在net/http开始流转。

Request 数据是如何流转的?

先不使用gin, 直接使用net/http来处理http请求:

在浏览器中输入localhost:8000, 会看到Hello World. 下面利用这个简单demo看下request的流转流程。

HTTP是如何建立起来的?

简单的说一下http请求是如何建立起来的:(需要有基本的网络基础, 可以找相关的书籍查看, 推荐看UNIX网络编程卷1:套接字联网API)

在TCP/IP五层模型下, HTTP位于应用层, 需要有传输层来承载HTTP协议. 传输层比较常见的协议是TCP,UDP, SCTP等. 由于UDP不可靠, SCTP有自己特殊的运用场景, 所以一般情况下HTTP是由TCP协议承载的(可以使用wireshark抓包然后查看各层协议)。

使用TCP协议的话, 就会涉及到TCP是如何建立起来的. 面试中能够常遇到的名词三次握手, 四次挥手就是在这里产生的. 具体的建立流程就不在陈述了, 大概流程就是图中左半边。

所以说, 要想能够对客户端http请求进行回应的话, 就首先需要建立起来TCP连接, 也就是socket. 下面要看下net/http是如何建立起来socket?

net/http 是如何建立 socket 的

从图上可以看出, 不管server代码如何封装, 都离不开bind,listen,accept这些函数. 就从上面这个简单的demo入手查看源码。

注册路由

这段代码是在注册一个路由及这个路由的handler到DefaultServeMux中。

可以看到这个路由注册太过简单了, 也就给gin, iris, echo等框架留下了扩展的空间, 后面详细说这个东西。

服务监听及响应

上面路由已经注册到net/http了, 下面就该如何建立socket了, 以及最后又如何取到已经注册到的路由, 将正确的响应信息从handler中取出来返回给客户端。

1.创建 socket

2.Accept 等待客户端链接

3.提供回调接口 ServeHTTP

4.回调到实际要执行的 ServeHTTP

这基本是整个过程的代码。

ln, err := net.Listen("tcp", addr)做了初试化了socket, bind, listen的操作。
rw, e := l.Accept()进行accept, 等待客户端进行连接。
go c.serve(ctx) 启动新的goroutine来处理本次请求. 同时主goroutine继续等待客户端连接, 进行高并发操作。
h, _ := mux.Handler(r) 获取注册的路由, 然后拿到这个路由的handler, 然后将处理结果返回给客户端。

从这里也能够看出来, net/http基本上提供了全套的服务。

为什么会出现很多go框架

从这段函数可以看出来, 匹配规则过于简单, 当能匹配到路由的时候就返回其对应的handler, 当不能匹配到时就返回/. net/http的路由匹配根本就不符合 RESTful 的规则,遇到稍微复杂一点的需求时,这个简单的路由匹配规则简直就是噩梦。

所以基本所有的go框架干的最主要的一件事情就是重写net/http的route。我们直接说 gin就是一个 httprouter 也不过分, 当然gin也提供了其他比较主要的功能, 后面会一一介绍。

综述, net/http基本已经提供http服务的70%的功能, 那些号称贼快的go框架, 基本上都是提供一些功能, 让我们能够更好的处理客户端发来的请求. 如果你有兴趣的话,也可以基于 net/http 做一个 Go 框架出来。

这个例子中 http.HandleFunc 通过看源码,可以看到 URI "/" 被注册到了 DefaultServeMux 上。

net/http ServeHTTP 的作用

net/http 里面有个非常重要的 Handler interface。只有实现了这个方法才能请求的处理逻辑引入自己的处理流程中。

默认的 DefaultServeMux 就实现了这个 ServeHTTP。

这个 request 的流转过程:

socket.accept 接收到客户端请求后,启动 go c.serve(connCtx) [net/http server.go:L3013]行,专门处理这次请求,server 继续等待客户端连接。
获取能处理这次请求的 handler -> serverHandler{c.server}.ServeHTTP(w, w.req) [net/http server.go:L1952]。
跳转到真正的 ServeHTTP 去匹配路由,获取 handler。
由于并没有自定义路由,于是使用的是 net/http 默认路由 [net/http server.go:L2880-2887]。
所以最终调用去 DefaultServeMux 匹配路由,输出返回对应的结果。

探究 gin ServeHTTP 的调用链路

下面是 gin 的官方 demo, 仅仅几行代码,就启动了一个 echo server:

这段代码的大概流程:

r := gin.Default() 初始化了相关的参数。
将路由 /ping 以及对应的 handler 注册到路由树中。
使用 r.Run() 启动 server。
r.Run 的底层依然是 http.ListenAndServe。

所以 gin 建立 socket 的过程,accept 客户端请求的过程与 net/http 没有差别,会同样重复上面的过程。唯一有差别的位置就是在于获取 ServeHTTP 的位置

由于 sh.srv.Handler 是 interface 类型,但是其真正的类型是 gin.Engine,根据 interace 的动态转发特性,最终会跳转到 gin.Engine.ServeHTTP 函数中。

gin.ServeHTTP 的实现:

至此,终于我们看到了 gin.ServeHTTP 的全貌了

从 sync.pool 里面拿去一块内存,
对这块内存做初始化工作,防止数据污染,
处理请求 handleHTTPRequest,
请求处理完成后,把这块内存归还到 sync.pool 中,
现在看起来这个实现很简单,其实不然,这才是 gin 能够处理数据的第一步,也仅仅将请求流转入 gin 的处理流程而已。

这里做个结论:通过上面的源码流程分析,我们知道 net/http.ServeHTTP 这个函数相当重要性, 主要有这个函数的存在, 才能将请求流转入目前 Go 的这些框架里面。同学们有兴趣的话,可以去看看 echo, iris, go-zero 等框架是如何实现 ServeHTTP 的。

什么是路由?

这个其实挺容易理解的,就是根据不同的 URL 找到对应的处理函数即可。

目前业界 Server 端 API 接口的设计方式一般是遵循 RESTful 风格的规范。当然我也见过某些大公司为了降低开发人员的心智负担和学习成本,接口完全不区分 GET/POST/DELETE 请求,完全靠接口的命名来表示。

举个简单的例子,如:"删除用户"

这种 No RESTful 的方式,有的时候确实减少一些沟通问题和学习成本,但是只能内部使用了。这种不区分 GET/POST 的 Web 框架一般设计的会比较灵活,但是开发人员水平参差不齐,会导致出现很多“接口毒瘤”,等你发现的时候已经无可奈何了,如下面这些接口:

这样的接口设计会导致开源的框架都是解析不了的,只能自己手动一层一层 decode 字符串,这里就不再详细铺开介绍了,等下一节说到 gin Bind 系列函数时再详细说一下。

继续回到上面 RESTful 风格的接口上面来,拿下面这些简单的请求来说:

这是比较规范的 RESTful API设计,分别代表:

获取 userID 的用户信息
更新 userID 的用户信息(当然还有其 json body,没有写出来)
创建 userID 的用户(当然还有其 json body,没有写出来)
删除 userID 的用户

可以看到同样的 URI,不同的请求 Method,最终其他代表的要处理的事情也完全不一样。

看到这里你可以思考一下,假如让你来设计这个路由,要满足上面的这些功能,你会如何设计呢?

gin 路由设计

如何设计不同的 Method ?

通过上面的介绍,已经知道 RESTful 是要区分方法的,不同的方法代表意义也完全不一样,gin 是如何实现这个的呢?

其实很简单,不同的方法就是一棵路由树,所以当 gin 注册路由的时候,会根据不同的 Method 分别注册不同的路由树。

如这四个请求,分别会注册四颗路由树出来。

其实代码也很容易看懂:

拿到一个 method 方法时,去 trees slice 中遍历,
如果 trees slice 存在这个 method, 则这个URL对应的 handler 直接添加到找到的路由树上;
如果没有找到,则重新创建一颗新的方法树出来, 然后将 URL对应的 handler 添加到这个路由 树上。

gin 路由的注册过程

这段简单的代码里,r.Get 就注册了一个路由 /ping 进入 GET tree 中。这是最普通的,也是最常用的注册方式。

不过上面这种写法,一般都是用来测试的,正常情况下我们会将 handler 拿到 Controller 层里面去,注册路由放在专门的 route 管理里面,这里就不再详细拓展,等后面具体说下 gin 的架构分层设计。

使用 RouteGroup

RouteGroup 是非常重要的功能,举个例子:一个完整的 server 服务,url 需要分为鉴权接口和非鉴权接口,就可以使用 RouteGroup 来实现。其实最常用的,还是用来区分接口的版本升级。这些操作, 最终都会在反应到gin的路由树上

gin 路由的具体实现

还是从这个简单的例子入手。我们只需要弄清楚下面三个问题即可:

URL->ping 放在哪里了。
handler-> 放在哪里了。
URL 和 handler 是如何关联起来的。

1.GET/POST/DELETE/..的最终归宿

在调用POST, GET, HEAD等路由HTTP相关函数时, 会调用handle函数。handle 是 gin 路由的统一入口。

2. 生成路由树

下面考虑一个情况,假设有下面这样的路由,你会怎么设计这棵路由树?

当然最简单最粗暴的就是每个字符串占用一个树的叶子节点,不过这种设计会带来的问题:占用内存会升高,我们看到 abc, abd, af 都是用共同的前缀的,如果能共用前缀的话,是可以省内存空间的。

gin 路由树是一棵前缀树. 我们前面说过 gin 的每种方法(POST, GET ...)都有自己的一颗树,当然这个是根据你注册路由来的,并不是一上来把每种方式都注册一遍。

gin 每棵路由大概是下面的样子:

这个流程的代码太多,这里就不再贴出具体代码里,有兴趣的同学可以按照这个思路看下去即可。

  • handler 与 URL 关联

node 是路由树的整体结构:

children 就是一颗树的叶子结点。每个路由的去掉前缀后,都被分布在这些 children 数组里
path 就是当前叶子节点的最长的前缀
handlers 里面存放的就是当前叶子节点对应的路由的处理函数

当收到客户端请求时,如何找到对应的路由的handler?

《gin 源码阅读(2) - http请求是如何流入gin的?》第二篇说到 net/http 非常重要的函数 ServeHTTP,当 server 收到请求时,必然会走到这个函数里。由于 gin 实现这个 ServeHTTP,所以流量就转入 gin 的逻辑里面。

所以,当 gin 收到客户端的请求时, 第一件事就是去路由树里面去匹配对应的 URL,找到相关的路由, 拿到相关的处理函数。其实这个过程就是 handleHTTPRequest 要干的事情。

从代码上看这个过程其实也很简单:

遍历所有的路由树,找到对应的方法的那棵树,
匹配对应的路由,
找到对应的 handler.

总结:说到这里,基本上把 gin 路由的整个流程说清楚了,本期关于gin就介绍到这,后期会继续更新.