xgo: 在go中使用-toolexec实现猴子补丁
概述
在这篇博客中,我将详细介绍 xgo 的实现细节。
如果你不知道,xgo 项目位于 https://github.com/xhd2015/xgo。
它的作用很简单,就是在每个 Go 函数的开头添加拦截器,从而引入了所谓的 Trap
概念,然后在此基础上引入了其他功能,比如 Mock
、Patch
和 Trace
。
什么是 Trap?
Trap 是插入到函数体开头的一段代码。以一个名为 greet
的函数为例:
func greet(s string) string {
return "hello " + s
}
经过xgo的处理后,编译器看到的代码将变为:
import "runtime"
func greet(s string) (r0 string){
stop, post := runtime.__xgo_trap(greet, &s, &r0)
if stop {
return
}
defer post()
return "hello " +s
}
这两者的差异可以通过下面的图表来可视化:
如图所示,一旦函数被调用,它的控制流首先转移到 Trap
,然后一系列拦截器将根据其目的检查当前调用是否应该被Mock、修改、记录或停止。
这个想法很简单,但也引发了一些问题:
- 编译器如何看到插桩后的代码?
import runtime
是什么?
这两个问题反映了 xgo 的两个基本部分:编译器插桩和运行时插桩。
让我们先看看第一个问题。
编译器如何看到插桩后的代码?
为了让编译器看到与其原始源代码不同的代码,中间必须发生某种事情。
有趣的是,go build
有一个名为-toolexec
的标志:
$ go help build
...
-toolexec 'cmd args'
a program to use to invoke toolchain programs like vet and asm.
For example, instead of running asm, the go command will run
'cmd args /path/to/asm <arguments for asm>'.
The TOOLEXEC_IMPORTPATH environment variable will be set,
matching 'go list -f {{.ImportPath}}' for the package being built.
...
如果你搜索 go toolexec
,甚至有一个示例:https://go.dev/src/cmd/go/testdata/script/toolexec.txt。
简而言之,-toolexec
标志允许用户拦截 go 调用的每个 compile
和 link
命令,并根据需要执行某种插桩,如下图所示:
请注意,当你在 go build
中添加 -toolexec=my_tool
标志时,它不会直接调用 compile args
和 link args
,而是将这些调用转发给 my_tool <cmd> args
。
因此,xgo 利用这个标志来拦截 compile
命令,将所有的编译转发到增强后的编译器。
然后,增强后的编译器将在每个函数中插入这些Trap调用,为运行时在实际调用之前捕获函数调用提供机会。
import runtime
是什么?
现在,编译器已经为我们添加了Trap调用,我们如何知道需要进行什么样的检查?
我们不能让每个包都依赖于 xgo
,因为它们可能并不需要它。
好在 runtime
也被插桩了,将调用转发给 xgo
。因为在 Go 中,每个包都隐式依赖于 runtime
包。控制流程如下图所示:
这实际上是一种依赖注入。这样一来,用户的代码就不必显式依赖xgo。
上述代码可以在 patch/trap_runtime/xgo_trap.go 和 runtime/trap/trap.go 中找到。
为了使Trap可扩展,xgo 引入了一个名为 interceptor 的概念。它具有以下签名:
type Interceptor struct {
Pre func(ctx context.Context, f *core.FuncInfo, args core.Object, result core.Object) (data interface{}, err error)
Post func(ctx context.Context, f *core.FuncInfo, args core.Object, result core.Object, data interface{}) error
}
一个 interceptor 由两个子函数组成,称为 Pre
和 Post
。
Pre
在函数逻辑之前调用,Post
在函数逻辑之后使用defer
语句调用。
总结
让我们总结一下我们所讨论的内容。
当你运行 xgo test ./
时,它会执行以下操作:
- 找到 GOROOT,
- 将 GOROOT 复制到 ~/.xgo/go-instruments/GOROOT 以准备进行插桩,
- 对 ~/.xgo/go-instruments/GOROOT 进行补丁,包括编译器和运行时,
- 构建插桩的编译器,
- 使用额外的标志调用 go build:
go build -toolexec=exec_tool ./
, exec_tool
然后将所有编译命令转发给插桩的编译器,- 一旦所有编译完成,go 调用 link 生成可执行文件,你就得到了一个插桩的二进制文件!
优点和缺点
因此,xgo 从上述机制中获得了优点和缺点。 优点:
- 并发安全:它不会替换需要修改全局地址的函数,因此每个 goroutine 可以设置自己的拦截器并单独删除它们,
- 兼容性:它重写源代码而不是架构指令,因此与操作系统和架构无关,
- 可扩展性:它提供了通用的拦截器,因此它的用途不仅限于模拟,你可以借鉴 GRPC 拦截器的所有用途,比如已经实现的追踪、缓存、日志记录等…
缺点:
- 用户需要安装 xgo 才能启用陷阱功能。
感谢阅读,xgo的核心实现已经在上面全部介绍了。你对此有什么看法?请在这里留下评论,让我们一起讨论吧!