Go 是一种十分适合 Web 开发的语言,语法简单并且容易上手,同时内置go
这个并发的神器语法,使得我们可以轻易的开发一些多并发的项目,用在并发需求数量巨大的服务端是再合适不过了。
上个学期曾经使用过 go 写一个实时同步的 MOBA 游戏的服务端,通过go
的并发特性很容易就做到了高并发,支持大量的用户同时在线。同时,Golang 在网络方面的基础库也是相对地完善,因此写一个 Http 框架的复杂度也不会更高。
awesome-go整理了一些用 go 语言编写的比较好的库,其中单单Web Frameworks部分就有 37 个。有Beego
这种大而全的传统 MVC 架构的框架,也有echo
、gin
这种微框架。之前开发过的两个项目都是使用Iris
这个 Web 框架的,同样也是一个重型框架,不过这里来看一个微框架gin
gin
是一个 golang 的 Http 微框架,有着不错的性能以及封装优雅的 API,提供了路由,中间件,上下文等机制,非常使用写一些微服务,而如今 go 语言在这一方面也有着比较高的使用率,国内的很多互联网公司都把一些微服务用 go 语言重写了。
简单的 Web 程序
这里先来写一个简单的 Cloud 程序
项目架构
1 | . |
Router
这里简单地实现了四个 API
GET /list
,列出内容POST /add
,添加内容GET /test
,输出一段简单的字符串GET /date
,输出当前时间
1 | package main |
curl
使用curl
对服务端一些端口进行访问
AB 测试
使用ab
对服务器进行压力测试
-n
请求的总数-c
并发的客户端个数
这里一共发送了 10000 个请求,一共耗费了 3.730s,当然这个和机器的性能也有一定的关系。
Gin 源码分析
这里就来分析一下 Gin 的源码和实现。
首先,来看看官方网站的例子
1 | // Quick Start |
这里创建了一个默认的gin
实例,在/ping
下挂上一个简单的路由,返回一个简单的 JSON
gin.Default
首先来看看gin.Default()
1 | // Default returns an Engine instance with the Logger and Recovery middleware already attached. |
首先,调用debugPrintWARNINGDefault
输出调试信息,这里调试信息统一使用debugPrint
进行输出
1 | func debugPrint(format string, values ...interface{}) { |
通过控制运行模式debug
和release
,就可以统一控制调试信息的输出,这样的设计对于日志输出管理无疑带来了好处。
New
这里通过New()
方法来产生一个gin
的实例,这实际上也是 go 语言中一个比较常见的用法。因为 go 语言并没有类的概念,其他语言中的类,go 可以通过使用结构体然后再上面绑定一些方法来实现同样功能,然而结构体并没有构造函数。因此,在 go 语言中,通常在一个包中使用New()
表示这个包生产一个实例的方法,这个方法会返回一个实例的指针。
1 | // New returns a new blank Engine instance without any middleware attached. |
这里使用默认参数初始化了一个Engine
结构体,然后为一些成员如RouterGroup
,FuncMap
初始化。
由于一些成员还需要父类(虽然不应该叫做类,但是不知道叫什么好)的引用,就需要在初始化之后把自身的指针交给子成员,这里RouterGroup
需要持有Engine
的指针,因此就需要在这个时候给他。
Middleware
然后engine.Use(Logger(), Recovery())
中的Use
明显就是 Http 路由中的中间件的用法,使得所有的请求都会经过这个中间件。
1 | // Use attachs a global middleware to the router. ie. the middleware attached though Use() will be |
engine.RouterGroup.Use
这个方法将中间件函数使用append
加入当前路由组(根路由)的调用链下,在 Gin 的设计中,HandlersChain
是一个经常出现的东西,也是一个Http Router
的核心组成,其本质就是一个函数数组,当路由匹配的时候,就会调用当前路由依次调用这个数组中绑定的方法和中间件。
1 | // HandlerFunc defines the handler used by gin middleware as return value. |
添加了中间件之后,这个路由中的 404 和 405 对应的Handler
也要及时地更新,加入中间件。
绑定之后Use
函数返回了一个IRoute
的接口,当实际上返回的是一个RouterGroup
对象,通过IRoute
,可以过滤掉一些函数,只返回允许外部调用的函数。
1 | // IRoutes defines all router handle interface. |
绑定了中间件后,函数会返回一个路由组,这时候,我们还可以这个路由组下无限嵌套绑定子路由。
可以看到,这里绑定的中间件为Logger
和Recovery
,前者负责日志,后者负责从恐慌中恢复。
Logger
Logger
是一个负责日志的中间件,他返回的正是一个HandlerFunc
,也正是上面提到的HandlersChain
的组成,Use
将其加入到根路由的HandlersChain
中,就可以实现任意路由的访问之前先调用。
1 | // Logger instances a Logger middleware that will write the logs to gin.DefaultWriter. |
我们再来看看他返回的东西
1 | // LoggerWithWriter instance a Logger middleware with the specified writer buffer. |
这个函数稍微有点长。
首先,他使用了go-isatty
这个库,来获取当前的系统运行环境,从而判断是否在终端里面运行
然后使用了一个 map 来存储一些不需要日志的路由
接下来就是返回了一个Handler
函数
当接受到一个请求的时候,记录下当前的时间,请求路由和请求数据,然后调用c.Next()
这里就不得不提一下 Web 框架的一个比较基本的结构了。
在一般的 Web 框架里面,通常使用的是一种叫做洋葱圈的结构,因为这种结构的调用机制十分类似洋葱,一层层进入然后一层层离开,有点类似下面的结构。
之前使用Node.js
的时候,koa
框架就是典型的洋葱圈结构,而另一个比较出名的框架express
,用的是一种尾递归的方式实现。
1 | // Next should be used only inside middleware. |
当请求进入这个中间件之后,这个c.Next()
表示进入下一层的中间件,等到里层的中间件全部进入并执行完毕退出的时候,程序的执行又会回到当前中间件来。
如果某个中间件中不执行c.Next()
就意味着当前请求不再返回函数体中,而是依次执行。
这种结构就十分适合做日志处理,一般日志都是需要等待处理结果出来之后才能输出的,同时也要记录当前执行所消耗的时间。使用洋葱圈模型处理中间件,就正好可以实现。当请求来到的时候,记录当前时间,然后交给下一步处理,当处理完成的时候,又返回来,结合当前的上下文和时间,得到处理结果和耗时,就正好可以输出。这种模型天生就适合于 Web 后端对于请求的处理。
Recovery
可以收集所有的错误,这是gin
的一大特性,Recovery
这个中间件明显就是负责从恐慌中恢复并处理错误的。
1 | // RecoveryWithWriter returns a middleware for a given writer that recovers from any panics and writes a 500 if there was one. |
他这里主要使用到一个内建函数recover()
,先来看看这个内建函数的注释。
1 | // The recover built-in function allows a program to manage behavior of a |
这个函数需要用在一个defer
函数内,他可以捕获一个 go 例程所抛出的panic
并对其进行处理,然后恢复程序的正常运行。这个中间件明显就是使用这个来实现错误捕获的。
defer
函数相当于一个析构函数,会在当前函数所有语句执行完毕之后调用,平时我们可以用来管理一些文件流、网络流的close
,这里就可以用来错误捕获,来实现相当于其他语言中的 throw 的功能。
最后,gin.Default()
就会返回一个默认的,带有日志和错误捕获中间件的gin
实例。
这里顺便提一下,在生产环境中,日志读写的 I/O 对于并发的性能有着很大的影响,因此,在一些高并发的场景下,关闭或减少非必要日志的输出可能会带来比较高的性能提升。
1 | // Logger |
使用 ab 测试了一下,可以看出性能有着 30%左右的影响。
Handler
有了一个gin
实例之后,我们就可以为他设置路由和对应的Handler
。
我们可以通过gin
来轻易实现 RESTful 风格的 API 接口,上面提到的IRoutes
接口就实现了 RESTful 风格的各种 API 的接口。官方给出来的r.GET
就是其中最常用的一个。
1 | // GET is a shortcut for router.Handle("GET", path, handle). |
从源码中可以看到,GET
,POST
, PUT
等各种方法的路由都是交给handle
来实现的。
1 | func (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes { |
gin
在这里使用的是绝对的路由路径,这样对于多层嵌套的路由性能也不会有太大的损耗。
从之前的中间件的分析中可以知道,一个路由所绑定的中间件都会被append
到当前路由的group.Handlers
里面的。
通过combineHandlers
,将当前路由需要绑定的Handler
和之前的中间件合在一起,每当这个Handler
被调用的时候,就必然会先经过前面所设置的两个中间件Logger
和Recovery
,那样就实现了中间件的绑定。
最后一步,就是加入路由当中。
1 | func (engine *Engine) addRoute(method, path string, handlers HandlersChain) { |
为了更好的性能,可以看到gin
是使用了基树这种数据结构来存储路由的,基数也叫做压缩前缀树,是一种节省空间的前缀树,如果一个节点只有一个子树,那么这个子树就会和父节点合并,带来更小的空间和更优的性能。
维基百科例子:
根据不同的请求类型,分为不同的树,如GET Tree
、POST Tree
等等,然后加入到engine
的trees
数组当中。
然后根据路由的path
,生成一棵路由树,其节点存储的就是当前路由的调用链。
这样的设计无疑可以带来更好的路由性能。
在之前的一个简单的小例子里面,使用了默认的gin.Default
,这里运行一下,可以看到下面的日志输出。
1 | [GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached. |
可以看到,每个路由上都被绑定了三个handlers
,依次就是Logger
、Recovery
和我们自己绑定的Handler
。
Run
Run
就是开始工作,开始监听端口。
1 | // Run attaches the router to a http.Server and starts listening and serving HTTP requests. |
首先,这个函数同样是使用了defer
的技巧来输出错误。
然后解析地址,最后调用内置http
库的ListenAndServe
方法将整个engine
作为一个Handler
绑定在这个端口。
接下来看看http
中的实现
1 | // ListenAndServe listens on the TCP network address addr and then calls |
为什么engine
可以作为Handler
参数传入这个函数呢?原因就在于Handler
只是一个接口,只要实现了ServeHTTP(ResponseWriter, *Request)
这个方法的所有struct
,都可以作为参数传入。这样,就实现了Caller
和Callee
之间的解耦,这也是 OO 的核心思想。
调用者不需要关心被调用者的逻辑,只需要实现给定的接口,调用者就可以在适当的时候调用,来实现功能。这也是Javascript
中比较常见的回调函数的一种实现。
1 | // A Handler responds to an HTTP request. |
当有请求的时候,程序就会调用ServeHTTP
这个函数,将请求信息和回应的ResponseWriter
交给Handler
处理(这里是gin
的engine
),因此,框架可以完全不管网络层、传输层的实现,直接在应用层之上编程。把复杂的网络通信高度抽象化成一个请求、回应的接口:
这里的ResponseWriter
同样是一个接口,go 的网络库中实现了这个接口,我们可以通过Write([]byte) (int, error)
或者WriteHeader(statusCode int)
将响应的内容返回给网络库。
1 | // ServeHTTP conforms to the http.Handler interface. |
显然,这个接口在Engine
内部已经实现好了
众所周知, go 语言是一种具有自动 GC(垃圾回收)特性的语言。这里使用了sync.Pool
,是谷歌提供的一个临时对象池,是一组临时对象的组合,存储那些被分配了但是没有被使用,而未来可能会使用的值,以减小垃圾回收的压力。这是一个协程安全的操作,照高并发的情况下,使用这个可以大幅度提高性能。
当收到一个请求的时候,从临时对象池中获取一个上下文对象Context
1 | // Context is the most important part of gin. It allows us to pass variables between middleware, |
Context
是一般的 Web 框架中最常见的组件,是穿插在整个请求过程当中的一个对象。通过Context
,我们可以在各个中间件之间传递变量,保存着当前上下文有关的数据和变量。
这种设计模式在并发的时候就十分有用。
首先,他限制了这个请求的数据的作用域,这个请求的数据只存在于这次请求当中,并且保证了数据的生命周期,使得数据在整个请求当中都有效,避免了使用全局变量。
然后,Context
的存在使得数据的粒度更细,避免了使用冲突和过度的耦合。
从临时对象池中取出Context
之后,初始化并且将当前的请求和响应的Writer
绑定在Context
上
使用engine.handleHTTPRequest(c)
将上下文交给下一步处理
最后将当前上下文重新放入临时对象池,等待下一次的使用。
在handleHTTPRequest
中,对请求进行判断,然后交给相应的Handler
处理。
1 | func (engine *Engine) handleHTTPRequest(c *Context) { |
这里就可以从之前创建的基树中查找路由,找出当前路由对应的Handler
,然后将Handler
绑定在当前的上下文中,调用c.Next()
,将当前的上下文传递给Handler
中第一个函数(在这里默认是日志中间件)。
当所有的Handler
执行完毕的时候,最后就会调用c.writermem.WriteHeaderNow()
,将状态通过ResponseWriter
写入到响应当中,然后网络库就会将响应发送给用户,这样就完成了一个请求的响应了。
到此,官方的 DEMO 就分析完了。但是gin
当中不仅仅只有这些内容,这里只是分析了一个服务端创建、监听和处理一个简单的请求的过程。