在 gomonkey[1] 社区,用户ericuni 提了一个 issue[2],如下图所示:
泛型是Go1.18引入的一个新特性,笔者在本地升级Go版本到Go1.18,复现了问题。
(1)产品代码示例:
func Add[T int | float32 | float64](a T, b T "T int | float32 | float64") T {
return a + b
}
(2)测试代码示例:
func TestGenericFuncPatch(t *testing.T) {
Convey("TestGenericFuncPatch", t, func() {
So(Add[int](1, 2 "int"), ShouldEqual, 3)
So(Add(1, 2), ShouldEqual, 3)
patches := ApplyFuncReturn(Add[int], 5)
defer patches.Reset()
So(Add(1, 2), ShouldEqual, 5)
})
}
(3)执行该测试用例,复现问题,打桩未生效 测试用例执行结果:
=== RUN TestGenericFuncPatch TestGenericFuncPatch ✔✔✘
Failures:
* /Users/zhangxiaolong/Desktop/D/go-workspace/gostudy/framework-study/goconvey-study/slice/slice_test.go
Line 203:
Expected: '5'
Actual: '3'
(Should be equal)
3 total assertions
--- FAIL: TestGenericFuncPatch (0.00s)
FAIL
FAIL goconvey-study/slice 0.010s
说明:
Add[int](1, 2 "int")
与Add(1, 2)
两种写法等价,但Add(1, 2)
这种写法更方便,因为这种写法使用了 Go 泛型类型实参的自动推导,从而不用显式的手动指定类型实参,实际上是编译器帮我们完成了相关工作;ApplyFuncReturn
接口完成对函数Add[int]
的打桩后,再次调用Add(1, 2)
,发现返回值仍然是3
而不是期望的5
,说明对该函数的打桩并未生效!每次产品代码中实例化泛型函数的过程,比如Add[int]
,对应的汇编代码是一个函数调用,所以对实例化后的泛型函数打桩就变成了对函数调用打桩了。然而,每次产品代码中实例化泛型函数的时候,对应的汇编代码中的函数调用是不同的函数名字,比如func1, func2, ...
等,这会导致虽然打桩当时成功了,但后续再实例化泛型函数的时候,对应的函数调用名称就变成了其他的,所以打桩没有真正生效,比如打桩打在了func2
上,而之后的调用却发生在func3, func4
上了。
其实,实例化泛型函数过程中产生的func1, func2, ...
等都是中间函数,真正的泛型函数实例会在中间函数层被最终调用。不清楚Go语言为啥要这样设计泛型的实现,但它肯定有深层次的原因。我们只能从中间函数的代码段逐条解析,找到CALL
指令后提取参数来计算泛型函数实例化后的真正地址。
找到泛型函数实例的真正地址后,就确定了真正的目标函数,但桩函数如何写?
泛型函数会占用AX、BX
和 CX
三个寄存器来保存参数,而普通函数仅占用AX
和BX
两个寄存器来来保存参数,同时泛型函数的第一个入参并不在AX
中,而普通函数的第一个入参却在AX
中,所以桩函数必须和泛型函数长的一模一样才行。
还有一点,空接口通过反射无法区分是泛型函数还是普通函数,所以泛型函数打桩接口中要通过bool
值来区分到底是泛型函数还是普通函数,这就导致接口不同。
综上,泛型函数打桩接口与普通函数不同,同时泛型函数需要特殊获取目标函数的地址,到了函数跳转时刻,泛型函数的桩函数使用的寄存器个数与普通函数的桩函数不同,从而汇编代码不同,几乎是两套代码。
考虑到泛型的使用场景并不广泛,同时也有替代方案(比如接口+反射),而要完整的支持泛型打桩却需要为 gomonkey 引入很高的复杂度,因此不是很划算,并且 Go 语言泛型机制也在不断的演进,或许后面再支持泛型打桩就变得很容易了。
于是我们计划在 gomonkey 框架层面暂时不支持对泛型打桩,但是 gomonkey 用户或多或少还是会有对泛型打桩的诉求,那么这种情况下应该怎么办是一个值得思考的问题。
如果让中间函数变成唯一,那么对中间函数打桩就是对泛型打桩。这时,我们想到了函数变量,可以规避直接对泛型打桩带来的一系列问题。
我们引入函数变量,将上述复现问题的案例打造成泛型函数打桩的典型案例,供大家参考!
(1)产品代码中增加函数变量的定义
func Add[T int | float32 | float64](a T, b T "T int | float32 | float64") T {
return a + b
}// 函数变量的定义
var AddInt = Add[int]
(2)在测试代码中对函数变量进行打桩
func TestGenericFuncPatch(t *testing.T) {
Convey("TestGenericFuncPatch", t, func() {
So(AddInt(1, 2), ShouldEqual, 3)
// 也可以使用 ApplyFuncVar 接口,但此处使用 ApplyFuncVarReturn 接口更简洁
patches := ApplyFuncVarReturn(&AddInt, 5)
defer patches.Reset()
So(AddInt(1, 2), ShouldEqual, 5)
})}
(3)执行该测试用例,结果符合预期 测试用例执行结果:
=== RUN TestGenericFuncPatch TestGenericFuncPatch ✔✔
2 total assertions
--- PASS: TestGenericFuncPatch (0.00s)
gomonkey 既然对泛型函数可以打桩了,那么对泛型方法是否也可以打桩呢?
很不幸,目前 Go 的方法并不支持泛型,如下:
type A struct {
}// 不支持泛型方法
func (receiver A) Add[T int | float32 | float64](a T, b T "T int | float32 | float64") T {
return a + b
}
但是因为receiver支持泛型, 所以如果想在方法中使用泛型的话,目前唯一的办法就是曲线救国,迂回地通过receiver使用类型形参:
type A[T int | float32 | float64] struct {
}// 方法可以使用类型定义中的形参 T
func (receiver A[T]) Add(a T, b T) T {
return a + b
}
// 用法:
var a A[int]
a.Add(1, 2)
var aa A[float32]
aa.Add(1.0, 2.0)
打桩过程与之前泛型函数类似,供大家参考!
(1)产品代码中还是增加函数变量的定义
type A[T int | float32 | float64] struct {
}// 方法可以使用类型定义中的形参 T
func (receiver A[T]) Add(a T, b T) T {
return a + b
}
// 函数变量的定义
var AObject = new(A[int])
var SAddInt = AObject.Add
(2)在测试代码中对函数变量进行打桩
func TestGenericMethodPatch(t *testing.T) {
Convey("TestGenericMethodPatch", t, func() {
So(SAddInt(1, 2), ShouldEqual, 3)
patches := ApplyFuncVarReturn(&SAddInt, 5)
defer patches.Reset()
So(SAddInt(1, 2), ShouldEqual, 5)
})
}
(3)执行该测试用例,结果符合预期 测试用例执行结果:
=== RUN TestGenericMethodPatch TestGenericMethodPatch ✔✔
2 total assertions
--- PASS: TestGenericMethodPatch (0.00s)
用户期望可以在框架层面解决一切共性问题,这本属于合理的诉求,但现实有时很骨感,在种种约束下必须改变思路,而在用户侧较优雅的解决相关问题也是一种不错的选择。
本文针对 gomonkey 用户,给出了如何对泛型进行打桩的解决方案,并提供了典型案例及其延伸,希望对读者有一定的帮助!
gomonkey: https://github.com/agiledragon/gomonkey
[2]issue: https://github.com/agiledragon/gomonkey/issues/95
推荐阅读