gomonkey用户如何对泛型打桩

2022-08-07  本文已影响0人  _张晓龙_

问题的由来

gomonkey 社区,用户
ericuni 提了一个 issue,如下图所示:

issue-95.png

泛型是Go1.18引入的一个新特性,笔者在本地升级Go版本到Go1.18,复现了问题。
(1)产品代码示例:

func Add[T int | float32 | float64](a T, b T) T {
    return a + b
}

(2)测试代码示例:

func TestGenericFuncPatch(t *testing.T) {
    Convey("TestGenericFuncPatch", t, func() {
        So(Add[int](1, 2), 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],对应的汇编代码是一个函数调用,所以对实例化后的泛型函数打桩就变成了对函数调用打桩了。然而,每次产品代码中实例化泛型函数的时候,对应的汇编代码中的函数调用是不同的函数名字,比如func1, func2, ...等,这会导致虽然打桩当时成功了,但后续再实例化泛型函数的时候,对应的函数调用名称就变成了其他的,所以打桩没有真正生效,比如打桩打在了func2上,而之后的调用却发生在func3, func4上了。

其实,实例化泛型函数过程中产生的func1, func2, ...等都是中间函数,真正的泛型函数实例会在中间函数层被最终调用。不清楚Go语言为啥要这样设计泛型的实现,但它肯定有深层次的原因。我们只能从中间函数的代码段逐条解析,找到CALL指令后提取参数来计算泛型函数实例化后的真正地址。

找到泛型函数实例的真正地址后,就确定了真正的目标函数,但桩函数如何写?

泛型函数会占用AX、BXCX 三个寄存器来保存参数,而普通函数仅占用AXBX两个寄存器来来保存参数,同时泛型函数的第一个入参并不在AX中,而普通函数的第一个入参却在AX中,所以桩函数必须和泛型函数长的一模一样才行。

还有一点,空接口通过反射无法区分是泛型函数还是普通函数,所以泛型函数打桩接口中要通过bool值来区分到底是泛型函数还是普通函数,这就导致接口不同。

综上,泛型函数打桩接口与普通函数不同,同时泛型函数需要特殊获取目标函数的地址,到了函数跳转时刻,泛型函数的桩函数使用的寄存器个数与普通函数的桩函数不同,从而汇编代码不同,几乎是两套代码。

考虑到泛型的使用场景并不广泛,同时也有替代方案(比如接口+反射),而要完整的支持泛型打桩却需要为 gomonkey 引入很高的复杂度,因此不是很划算,并且 Go 语言泛型机制也在不断的演进,或许后面再支持泛型打桩就变得很容易了。

于是我们计划在 gomonkey 框架层面暂时不支持对泛型打桩,但是 gomonkey 用户或多或少还是会有对泛型打桩的诉求,那么这种情况下应该怎么办是一个值得思考的问题。

如果让中间函数变成唯一,那么对中间函数打桩就是对泛型打桩。这时,我们想到了函数变量,可以规避直接对泛型打桩带来的一系列问题。

典型案例

我们引入函数变量,将上述复现问题的案例打造成泛型函数打桩的典型案例,供大家参考!

(1)产品代码中增加函数变量的定义

func Add[T int | float32 | float64](a T, b T) 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 {
    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 用户,给出了如何对泛型进行打桩的解决方案,并提供了典型案例及其延伸,希望对读者有一定的帮助!

上一篇下一篇

猜你喜欢

热点阅读