基于context扩展gin框架
大家用过gin的话,应该了解gin框架是通过一个叫做*gin.Context的对象传递请求数据的,一个简单的例子如下:
1 2 3 4 5 6 7 |
r := gin.Default() r.GET("/ping", func(c *gin.Context) { c.JSON(200, gin.H{ "message": "pong", }) }) r.Run() // 监听并在 0.0.0.0:8080 上启动服务 |
使用gin.Context可以获取请求参数,返回应答,这通常没什么问题。
但是如果希望把gin扩展成一个完备的MVC框架,作为公司级的统一开发标准,那么就需要做更顶层的统一设计。
此时,我们只是将Gin作为路由层的一个非常好用的组件,然而为了达成MVC框架的整体设计目标,我们应该对围绕Gin外围做一些适配和扩展,从而更好的服务框架整体设计。
典型需求
超时控制
作为一个MVC框架,我希望能对每一个请求进行全生命期的超时控制。
在golang中,我们只有一种选择就是使用context.Context参数,在代码全流程中向下透传到各个类库/第三方类库。
只要我们在请求的入口生成withTimeout的context,那么对于那些支持并处理了context传参的类库来说,就可以实现请求生命期的超时中断,避免请求因为个别调用点阻塞而导致协程大量打满。
调用链埋点
在微服务化时代,我们非常依赖于调用链trace能力。
在golang中,一个请求到来后经过一系列数据库/网络调用直到最终得到应答的过程,全部需要主动进行trace埋点。
作为一个公司级的MVC框架,会对诸如rpc、mysql、redis等各个类库进行侵入式埋点的实现,而为了将同一个请求的所有调用过程顺序记录下来,就需要将同一个trace上下文对象在各个类库之间持续透传,因此还是只能依靠每个请求的context.Context上下文对象来携带trace对象。
从上面2个典型需求来看,一个MVC框架最重要的就是每个请求要有一个完全可控的context.Context对象,这需要我们在框架层面生成并从controller层首次传入,并由开发者手动向下逐层透传。同时,例如rpc、mysql、redis等类库均需要接受context传参,以便从context中获取trace对象完成自定义埋点。
当然,context.Context接口支持操作多个key-value数据,因此例如trace对象只是其中的一种场景,我们还可以注入更多的框架层信息到context.Context中。
gin.Context可以存储上下文数据
gin.Context实现了context.Context接口的方法,并且给context.Context的Value()方法提供了一个map的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
type Context struct { writermem responseWriter Request *http.Request Writer ResponseWriter Params Params handlers HandlersChain index int8 engine *Engine // Keys is a key/value pair exclusively for the context of each request. Keys map[string]interface{} // Errors is a list of errors attached to all the handlers/middlewares who used this context. Errors errorMsgs // Accepted defines a list of manually accepted formats for content negotiation. Accepted []string } |
它实现context.Context的方法,其中Value()特别实用,用于从Keys字典查询k-v:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
// Deadline returns the time when work done on behalf of this context // should be canceled. Deadline returns ok==false when no deadline is // set. Successive calls to Deadline return the same results. func (c *Context) Deadline() (deadline time.Time, ok bool) { return } // Done returns a channel that's closed when work done on behalf of this // context should be canceled. Done may return nil if this context can // never be canceled. Successive calls to Done return the same value. func (c *Context) Done() <-chan struct{} { return nil } // Err returns a non-nil error value after Done is closed, // successive calls to Err return the same error. // If Done is not yet closed, Err returns nil. // If Done is closed, Err returns a non-nil error explaining why: // Canceled if the context was canceled // or DeadlineExceeded if the context's deadline passed. func (c *Context) Err() error { return nil } // Value returns the value associated with this context for key, or nil // if no value is associated with key. Successive calls to Value with // the same key returns the same result. func (c *Context) Value(key interface{}) interface{} { if key == 0 { return c.Request } if keyAsString, ok := key.(string); ok { val, _ := c.Get(keyAsString) return val } return nil } |
当然,gin.Context提供了一个Set方法,它不是context.Context接口定义,仅能通过gin.Context.Set调用:
1 2 3 4 5 6 7 8 |
// Set is used to store a new key/value pair exclusively for this context. // It also lazy initializes c.Keys if it was not used previously. func (c *Context) Set(key string, value interface{}) { if c.Keys == nil { c.Keys = make(map[string]interface{}) } c.Keys[key] = value } |
我们可以通过gin.Context.Set保存针对当前请求的trace上下文对象,然后将gin.Context作为context.Context向下透传,那么例如mysql、redis等类库内部就可以通过context.Context.Value()取到trace对象,完成埋点:
1 2 3 4 |
return func (c *gin.Context) { // 可以在gin.Context中设置key-value c.Set("trace", "假设这是一个调用链追踪sdk") dbQuery(c, "select * from xxx") |
在mysql类库内可以这样取出trace对象使用:
1 2 3 4 |
// 模拟一个MYSQL查询 func dbQuery(ctx context.Context, sql string) { // 模拟调用链埋点 trace := ctx.Value("trace").(string) |
context.WithTimeout附加超时能力
为了完整控制请求的超时,需要基于现有gin.Context附加timeout能力:
1 2 3 4 5 6 |
return func (c *gin.Context) { // 可以在gin.Context中设置key-value c.Set("trace", "假设这是一个调用链追踪sdk") // 请求超时控制 timeoutCtx, _ := context.WithTimeout(c, 5 * time.Second) |
WithTimeout传入gin.Context作为parent,返回1个新的context.Context作为child。
当timeoutCtx超时后,timeoutCtx以及其下的孩子context的Done()方法都会返回,当前代码中我们还没有给timeoutCtx下面挂载任何子context。
现在timeoutCtx已经蜕化为context.Context接口了,继续向下透传就只能访问到context.Context接口中的方法(例如:Done方法),而不能再调用到gin.Context中的特有方法了。
WithTimeout返回的context继承了gin.Context的接口实现,我们看WithTimeout代码会发现它使用了一种很罕见的语法(结构体继承接口):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// A cancelCtx can be canceled. When canceled, it also cancels any children // that implement canceler. type cancelCtx struct { Context mu sync.Mutex // protects following fields done chan struct{} // created lazily, closed by first cancel call children map[canceler]struct{} // set to nil by the first cancel call err error // set to non-nil by the first cancel call } type timerCtx struct { cancelCtx timer *time.Timer // Under cancelCtx.mu. deadline time.Time } // newCancelCtx returns an initialized cancelCtx. func newCancelCtx(parent Context) cancelCtx { return cancelCtx{Context: parent} } |
最终WithTimeout返回的是timerCtx,其继承了cancelCtx结构体,而cancelCtx结构体继承了Context接口,结构体继承接口的语法是非常罕见的。
通过观察newCancelCtx函数会发现,这里parent就是gin.Context,它被赋值到结构体里继承的Context接口,这样cancelCtx就继承了gin.Context的接口实现。
而如果我们仔细再去观察,会发现timerCtx和cancelCtx两个结构体都没有实现Context的Value()方法,而通过将gin.Context赋值给结构体中继承的Context接口这样一个骚操作,timerCtx就继承了gin.Context的Value()方法,而先前我们看过gin.Context的Value方法是基于map维护k-v的。
封装框架层Context
经过上述操作后,timeoutCtx已经具备来自gin.Context的Value方法(可以拿到trace对象),也具备了timeout的超时能力。
但是我们也提到,timeoutCtx已经蜕化为context.Context接口,我们没法再调用到gin.Context的特有方法,如果框架层把这样一个蜕化的context.Context传给Controller,那么用户无法享受到gin.Context中的各种方法。
因此,我需要自定义一个Context,作为timerCtx的子context,并且在这个Context中保存一份gin.Context的引用,同时也不丧失此前timerCtx继承自gin.Context的能力(我们需要它的Value()实现)。
这就需要模仿WithTimeout,自定义一个框架Context结构体并且继承context.Context,并将timerCtx赋予接口:
1 2 3 4 |
type YuerContext struct { context.Context Gin *gin.Context } |
完整的生成流程如下:
1 2 3 4 5 6 7 8 |
return func (c *gin.Context) { // 可以在gin.Context中设置key-value c.Set("trace", "假设这是一个调用链追踪sdk") // 全局超时控制 timeoutCtx, _ := context.WithTimeout(c, 5 * time.Second) // 框架自定义上下文 yuerCtx := YuerContext{Context: timeoutCtx, Gin: c} |
yuerCtx就是一个继承了gin.Context的Value方法以及timeoutCtx的Done方法的自定义Context了。
我把gin.Context放在了结构体的一个字段里,将yuerContext保持类型传给controller层,用户就可以很方便的得到gin,并且继续向下按context.Context类型透传也仍旧可以通过Value()方法拿到trace对象。
包装Controller层
为了让开发者可以使用YuerContext而不是直接使用gin.Context,我提供了一种方法来包装原有的HandleFunc并返回一个用于gin注册路由的HandleFunc:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
type YuerHandleFunc func (c *YuerContext) func WithYuerContext(yuerHandle YuerHandleFunc) gin.HandlerFunc { return func (c *gin.Context) { // 可以在gin.Context中设置key-value c.Set("trace", "假设这是一个调用链追踪sdk") // 全局超时控制 timeoutCtx, _ := context.WithTimeout(c, 5 * time.Second) // ZDM上下文 yuerCtx := YuerContext{Context: timeoutCtx, Gin: c} // 回调接口 yuerHandle(&yuerCtx) } } |
现在注册路由稍作改变:
1 2 3 4 5 6 7 8 9 10 |
r := gin.New() r.GET("/test", WithYuerContext(func(c *YuerContext) { // 业务层处理 dbQuery(c, "select * from xxx") // 调用gin应答 c.Gin.String(200, "请求完成") })) r.Run() |
这样就以很小的代码修改代价,实现了自定义context参数,并且保障了框架层的扩展性和操控性。
WithYuerContext不是必须的,原生gin的HandleFunc仍旧可以直接注册路由,只是没有办法享受到YuerContext的特有能力。
完整代码见:https://github.com/owenliang/go-advanced/blob/master/context-demo/main.go。
如果文章帮助您解决了工作难题,您可以帮我点击屏幕上的任意广告,或者赞助少量费用来支持我的持续创作,谢谢~

如果只是 设置读写timeout,那么只要在http.Server里设置ReadTimeout和writeTimeout就好了。。不需要再包装一个timeoutContext吧。
Gin 方法虽然方便,但是在控制层使用就够了吧,如果深入到Service 和 Model层,有点耦合吧。在 控制层也就不用封装一层了吧
是的耦合了
1