gomonkey 用来给函数打桩,这种使用一个新的方法实现来替换原来的实现逻辑,怎么看都觉得很神奇。举个例子,在单测中方法 json.Marshal 可以被 gomonkey 覆写成另一种逻辑实现,我准备从原理和使用的角度来看看 gomonkey。主要是来看看作者的解释。
实现原理
如何在运行时修改函数的实现?这涉及到很多汇编的知识。使用 go build -gcflags=-l
编译下面的 go 代码,之后,下载 Hopper 打开生成的二进制文件。Hopper 幸亏有试用期限,让我们可以看看它究竟有哪些能力。
package main
func a() int { return 1 }
func main() {
print(a())
}
我也找到了 _main.a 的汇编代码,但和作者文章的图示有些差别,这展示的是什么汇编啊?go 本身也有简单的汇编,go tool compile -S main.go
这两者有什么区别呢?不过对比两者的输出,差别还是挺大的。紧接着,我也把 _main.main 的汇编贴出来。
即使不懂汇编,但其中的 call 命令还是认识的,要表达的含义就是调用 _main.a 函数,将函数结果移动到某个位置上,在继续调用 _runtime.printlock 方法。
函数变量如何工作
package main
import (
"fmt"
"unsafe"
)
func a() int { return 1 }
func main() {
f := a
fmt.Printf("0x%x\n", *(*uintptr)(unsafe.Pointer(&f)))
}
上面的代码将 a 赋值给 f,调用 f 实际上会调用到函数 a。然后通过 unsafe 的方式获取 f 的地址,将它转换为 (*uintptr) 的指针类型,然后再获取这个指针类型的值。目的就是通过类型强制转换,获取16进制表示的地址。
重新编译代码,输出的地址为:0x10aa548,但预期输出的地址应该是 _main.a 那里的 0x108aca0。使用 Hopper 做汇编分析,却查不到作者描述的 main.a.f,不知道什么情况。但作者在示例中发现,&f 指向的是地址 a 指针的指针,而非指针。
所以,代码重新调整成下面这样,执行编译好的可执行文件,输出结果是 0x108aca0。然后重新使用 Hopper 分析可执行文件,发现值和 _main.a 的地址是相同的。这也是刷新我认知的地方,编译后后输出的 f 指向的指针的指针地址居然是编译前就已经确定好的,我原本还以为它们在运行时会实时发生变化呢?
package main
import (
"fmt"
"unsafe"
)
func a() int { return 1 }
func main() {
f := a
fmt.Printf("0x%x\n", **(**uintptr)(unsafe.Pointer(&f)))
}
最后,再来编译分析下面的例子,使用 Hopper 分析编译源码,难道是因为试用版的缘故,就是找不到 mian.a.f 的记录。差别比较明显的地方是:call rax,之前可是 _main.a 的,主要还是得看 rax 的赋值过程。
指令 lea 是“load effective address”的缩写,是一个地址操作符,而 mov 是一个值操作符,指令的赋值顺序都是从右向左的。所以,rax 其实是 qword [qword_1064dc0] 的值。
package main
func a() int { return 1 }
func main() {
f := a
f()
}
最后是我的理解:可以通过函数赋值,获取实际函数的真实地址,然后将这个函数的地址指向我们声明函数的地址。但这个函数变量赋值该怎么在代码里实现呢,只能通过反射了。