go 如何优雅的实现函数级超时控制
曦子一般情况下后端的代码都是树状的,随着业务的堆积,支线可能越来越多,拿漫画的 ComicDetail 举例,这个接口的目的在于返回漫画的章节和详情信息,但是随着业务的增长可能它还返回了特典、个人追漫、打折等信息,一般情况下 golang 开发 err 都是往上抛的,有时候也会有 err = nil 的方案,进行业务降级。这里就会有一个问题 A 在调用边缘业务 B 时,你虽然忽略 B 的 err 但是 B 的耗时过长,仍然会影响主流程的正常返回,在一些 http/grpc/db/redis ..etc 的请求下我们会给请求上一个 timeout,下一个操作进行超时检测,当然,如果业务比较简单,封装比较少,这样还行,当领域划分多的情况下,很明显我们需要能给某个函数同样的加超时时间。
题外话,当然我们可以从软件工程和代码重构的角度来规避这些问题,比如 ComicDetail 就是仅返回漫画详情,其他的都拆出来,这会衍生另外的问题:
- 拆分粒度的粗细该当如何?
- 还有调用方的感受,过多的接口可能让调用方,比如 web 和 客户端的同学因此红温。
期望的方案
既然牵扯到降级,那么对业务代码的入侵是无法避免的,只有多少的问题。
泛型
假设我们有个函数如下:
func Add(a, b int) (r int) {
return a+b
}
如果给它加超时控制的话, 那我可能期望是 Hook(Add, time.Second)
Hook 函数有 Add 函数一样的返回值, 只是多了一个入参 time.Duration. 这样对业务代码的改造比较小, 入侵少负担就小.
起初的想法就是泛型, 我们能不能对原函数简单的包装达到目的?很遗憾泛型有局限:
func Hook[T any](ori T, t time.Duration) T {
var x T
return x
}
虽然可以声明变量 x,但是没办法实现 T, 只用泛型行不通。
不过我们还有旁门左道能用, 反射。
func Hook[T any](ori T, t time.Duration) T {
:= reflect.MakeFunc(reflect.TypeOf(ori), func(in []reflect.Value) []reflect.Value {
v // 超时控制
// ...
:= reflect.ValueOf(ori)
f return f.Call(in)
})
return v.Interface().(T)
}
对性能要求不高的朋友到这里就可以不用往下看了, 实测下来反射的性能比直接调用差了几百倍, 酌情使用。
装饰器
计算机遇到不能解决的问题就加一层, 如果不要求跟原函数一样的函数签名(意味着更多的改动量), 装饰器模式也能实现。
func SyncWithTimeout(ctx context.Context, timeout time.Duration, task func(ctx context.Context) error) Resp {
:= context.Background()
nctx // 创建一个有超时的上下文
, cancel := context.WithTimeout(nctx, timeout)
nctxdefer cancel()
// 创建一个通道,来接收任务执行的结果
:= make(chan error, 1)
resultChan
// 启动一个 goroutine 来执行任务
go func() {
<- task(nctx)
resultChan }()
// 选择监听任务的结果或上下文的超时
select {
case <-nctx.Done(): // 超时或取消
return Resp{err: ErrTimeout}
case err := <-resultChan: // 正常完成
return Resp{err: err}
}
}
这个时候我们如果对 Add 进行超时控制就要这么改:
var res int
.SyncWithTimeout(ctx, time.Millisecond*20, func(ctx context.Context) error {
chronos= Add(a, b)
res return nil
}).Do(func() {
// not timeout
})
本来简单的一行 Add(a, b) 现在要写很多行, 而且需要处理变量竞争问题, 很明显这个方案不是很优雅, 问题变复杂了。
代码生成
要说优雅, 我们能不能给 Add(a, b) 加行注释就搞定呢?比如:
// @timeout
func Add(a, b int) int
看过我之前的给 gopls 贡献源码的同学可能就立马想到了, 我们可以解析 ast 来构造一个Hook(Add, time.Second)
, 既然选择代码生成, 这个形式也没有那么符合直觉, 我们可以期望 AddWithTimeout(a, b, time.Duration)
的写法, 且返回值保持一致。
思路:
扫描项目所有包含关键字 @timeout 的 package
加载 package 构造抽象语法树
:= &packages.Config{
cfg : packages.NeedName | packages.NeedTypes | packages.NeedTypesInfo | packages.NeedSyntax | packages.NeedImports,
Mode: false,
Tests: g.logf,
Logf}
, err := packages.Load(cfg, patterns...) pkgs
这里详细的内容可以参考 golang.org/x/tools/go/packages, 会按照 package 加载抽象语法树. 注意具体加载哪些数据是要进行 mode 字段调整的, 不指定就不会去解析.
- 通过扫描 comment 来锚定函数
// 遍历所有包
for _, pkg := range pkgs {
// 遍历包中的每个 Go 文件
for _, syntax := range pkg.Syntax {
// 遍历文件中的声明
for _, decl := range syntax.Decls {
// 只处理函数声明
, ok := decl.(*ast.FuncDecl)
funcDeclif !ok {
continue
}
// 检查函数声明中的注释
if funcDecl.Doc == nil {
continue
}
for _, comment := range funcDecl.Doc.List {
// 查找 go:generate timeout 指令
if strings.HasPrefix(comment.Text, "// @timeout") {
continue
}
:= pkg.Fset.Position(funcDecl.Pos())
pos .Printf("Found @timeout in %s at line %d for function %s: %s, type:%+v\n",
fmt.Filename, pos.Line, funcDecl.Name.Name, comment.Text, funcDecl.Type)
pos.timeFuncs = append(g.timeFuncs, funcDecl)
g}
}
}
}
锚定函数也很简单, 只要根据注释反查函数即可.
- 使用装饰器模式操作函数签名来构造对应的超时函数
获取到函数签名后这里其实就很简单了, 只要按照自己的逻辑去拼接代码然后写入文件即可。
测试下效果:
// @timeout
func GetByID(id int) (string, error) {
return "foo", nil
}
执行 timeout -r .
, 得到以下结果:
func GetByIDWithTimeout(id int, timeout time.Duration) (string, error) {
if timeout == 0 {
return GetByID(id)
}
:= context.Background()
nctx , cancel := context.WithTimeout(nctx, timeout)
nctxdefer cancel()
:= make(chan error, 1)
resultChan var r0 string
var r1 error
go func() {
, r1 = GetByID(id)
r0<- nil
resultChan }()
select {
case <-nctx.Done():
return r0, ErrTimeout
case _ = <-resultChan:
return r0, r1
}
return r0, r1
}
调整业务代码的时候也只需要把 GetByID(1)
==> GetByIDWithTimeout(1, time.Second)
. 跟其他方案比可以说改动是相当小了。
小结
源码可以参考 timeout.