概要

failpoint 项目是一个挺有意思的项目,是 failpoint 方法在 Golang 上的克隆。具体的基本原理 Golang Failpoint 的设计与实现 这篇文章已经解释得非常清楚了,本文主要是在官方文档的基础上再丰富一下细节。

什么是 failpoint

failpoint 早先源于 FreeBSD 项目,所谓 failpoint 就是:

Fail points are used to add code points where errors may be injected in a user controlled fashion.

在正常代码中注入可引发错误逻辑的代码,且这段代码可人为控制是否触发

比如你有一段进行 HTTP 访问的代码:

1
2
3
4
5
6
7
func doSomething() (data, error) {
    if data, err := doHTTPRequest(); err != nil {
        return nil, err
    }
    
    return data, nil
}

如果你想模拟 HTTP 超时,可在 doHTTPRequest() 中加入一个 failpoint:

1
2
3
4
5
func doHTTPRequest() {
    // ...
    delayFailpoint(10 * time.Seconds) // 这段代码可导致一个 10 秒的延迟
    // ...
}

这个 delayFailpoint 必须具备如下特性才称得上生产可用:

  • failpoint 仅用于测试,生产编译中不包含 failpoint 的任何代码;
  • 加入 failpoint 代码不会有其他副作用;
  • failpoint 可人为触发和撤销;

有了 failpoint,我们就可以制造各种人为错误来在集成测试中检测系统的稳定性,而不需要通过配置外部依赖件或者加入临时错误生成代码来进行测试。

体验

让我们来尝鲜一下 Go 版本的 failpoint:

  1. 下载和编译 failpoint-ctl

    1
    2
    3
    4
    
    $ git clone https://github.com/pingcap/failpoint.git
    $ cd failpoint
    $ make
    $ ls bin/failpoint-ctl
    

    failpoint-ctl 是一个开启和关闭 failpoint 的命令行工具,它目前只有两个子命令:enabledisable

  2. 写一段带有 failpoint 逻辑的代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    package main
    
    import "github.com/pingcap/failpoint"
    
    func main() {
        failpoint.Inject("testPanic", func() {
            panic("failpoint triggerd")
        })
    }
    

    对于 failpoint 的使用者来说,他们只需要:

    • 引入 github.com/pingcap/failpoint package,并使用 failpoint 提供的各种注入接口;
    • 使用 failpoint-ctl 来选择开启和关闭 failpoint;

    正常阅读代码时,可默认忽略各种 failpoint.Inject,因为它们并不影响业务逻辑。上述这段代码可直接编译:

    1
    
    $ go build test-failpoint.go
    

    最终执行效果类似于一个空的 main(),即没有 failpoint 的逻辑。

  3. 使用 failpoint-ctl 开启 failpoint

    1
    
    $ failpoint-ctl enable test-failpoint.go
    

    此时将额外生成 binding__failpoint_binding__.gotest-failpoint.go__failpoint_stash__,其中:

    • *__failpoint_stash__ 保存的是原先 Go 代码;
    • binding__failpoint_binding__.go 是提供一个 _curpkg_ 辅助函数,这个函数用于生成 failpoint 的名称标识:当前 failpoint 所在的 package + failpoint 的名称。比如上文的 failpoint 即为 main/testPanic

    此时我们在看 test-failpoint.go,已经被改写为:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    package main
    
    import "github.com/pingcap/failpoint"
    
    func main() {
        if _, ok := failpoint.Eval(_curpkg_("testPanic")); ok {
            panic("failpoint triggerd")
        }
    }
    

    原来的 failpoint 代码被重写为一个 IF 语句(代码行数还是保持一致)。

  4. 编译重写后的 Go 代码

    1
    
    $ go build
    
  5. 使用环境变量触发 failpoint

    1
    
     GO_FAILPOINTS="main/testPanic=return(true)" test-failpoint
    

    failpoint 将会检测 GO_FAILPOINTS,如果 GO_FAILPOINTS 中包含开启 failpoint 的指令,则此时运行时就会执行 failpoint,这也就是为什么改写后的代码是一段 IF 语句(failpoint.Eval()):

    1
    2
    3
    4
    5
    
    // ...
    if runFailpointByEnv(GO_FAILPOINTS) {
        runFailpoint()
    }
    // ...
    
  6. 恢复代码

    1
    
    $ failpoint-ctl disable .
    

    此时将删除所有额外生成的 Go 代码并将 test-failpoint.go 恢复为原始状态。

实现细节

Go 不具备 Macro 语义且是编译型语言,所以决定了实现 failpoint 必须要在编译时做点手脚。这个项目主要可分为两部分实现:

  • 编译时重写failpoint/code 中的 rewrite.gorestore.go
  • failpoint package:用来实现 FreeBSD failpoint 的触发语义;

所谓编译时重写,其实本质上就是利用 Go 标准库提供的一些工具(go/[ast,parser,token])来将标准的 Go 代码解析成 AST,并将指定的语法项(函数、表达式等)重写为 failpoint package 中某些函数,然后再输出到源文件。

顺带提一句:Kubernetes 里的 code-generator 也是利用 Go 的这些工具集,来从代码注释中读取相应的 annotation,然后利用 template 来生成相应的代码。由此看来,Go 开放的这几个 ast 和 parser 包还是挺好用的。