基于context扩展gin框架

大家用过gin的话,应该了解gin框架是通过一个叫做*gin.Context的对象传递请求数据的,一个简单的例子如下:

使用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的实现:

它实现context.Context的方法,其中Value()特别实用,用于从Keys字典查询k-v:

当然,gin.Context提供了一个Set方法,它不是context.Context接口定义,仅能通过gin.Context.Set调用:

我们可以通过gin.Context.Set保存针对当前请求的trace上下文对象,然后将gin.Context作为context.Context向下透传,那么例如mysql、redis等类库内部就可以通过context.Context.Value()取到trace对象,完成埋点:

在mysql类库内可以这样取出trace对象使用:

context.WithTimeout附加超时能力

为了完整控制请求的超时,需要基于现有gin.Context附加timeout能力:

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代码会发现它使用了一种很罕见的语法(结构体继承接口):

最终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赋予接口:

完整的生成流程如下:

yuerCtx就是一个继承了gin.Context的Value方法以及timeoutCtx的Done方法的自定义Context了。

我把gin.Context放在了结构体的一个字段里,将yuerContext保持类型传给controller层,用户就可以很方便的得到gin,并且继续向下按context.Context类型透传也仍旧可以通过Value()方法拿到trace对象。

包装Controller层

为了让开发者可以使用YuerContext而不是直接使用gin.Context,我提供了一种方法来包装原有的HandleFunc并返回一个用于gin注册路由的HandleFunc:

现在注册路由稍作改变:

这样就以很小的代码修改代价,实现了自定义context参数,并且保障了框架层的扩展性和操控性。

WithYuerContext不是必须的,原生gin的HandleFunc仍旧可以直接注册路由,只是没有办法享受到YuerContext的特有能力。

完整代码见:https://github.com/owenliang/go-advanced/blob/master/context-demo/main.go

如果文章帮助您解决了工作难题,您可以帮我点击屏幕上的任意广告,或者赞助少量费用来支持我的持续创作,谢谢~