Golang 单元测试篇:优雅地导出私有(unexported)项

过去 4 年,从无到有的构建了公司的 Golang 微服务集群使用的框架,承载了公司每日近 100 亿的请求。除了框架初期的设计缺陷导致的故障(缓存穿透)外,后来的迭代升级都非常平稳。得益于每次迭代都要求小伙伴们提供足够的单元测试。

在编写单元测试过程中,团队也遇到了各种各样的问题,大部分都被 gomock、gomonkey、monkey 、goconvey 等等的开源包解决,有些问题团队通过 fork 开源包解决。但是还是有一些问题是没法解决的,今天就导出私有(unexported)项来展开说说。

Golang 语言规范

Golang 语言规范中,有以下几种情况,可被认为是私有项:

  • identifier 的名字为小写;

    An identifier may be exported to permit access to it from another package. An identifier is exported if both:

    the first character of the identifier's name is a Unicode upper case letter (Unicode class "Lu"); and the identifier is declared in the package block or it is a field name or method name.

    All other identifiers are not exported.

  • 被放在 internal 目录下的项

    An import of a path containing the element “internal” is disallowed if the importing code is outside the tree rooted at the parent of the “internal” directory.

    表达的意思是在 internal 父目录以外的目录中是无法 import 的,根据这个规则,用以下例子来说明:

    • $GOROOT/src/pkg/internal/* 只可以被标准库 $GOROOT/src/* import;

    • $GOROOT/src/pkg/net/http/internal/* 只可以被 net/http* import;

    • $GOPATH/src/mypkg/internal/* 只可以被$GOPATH/src/mypkg* import。

那如何访问 unexported 项呢?

在阅读 Golang 源码时,经常能读到了些特殊的代码,如 time.Sleep

1
2
3
4
5
6
package time

// Sleep pauses the current goroutine for at least the duration d.
// A negative or zero duration causes Sleep to return immediately.
func Sleep(d Duration)
...

并没有看到方法实现,但是当调试代码时,却发现它指向的是 runtime/time.go 中的 timeSleep 方法。可以发现 timeSleep 方法有注释为 //go:linename timeSleep time.Sleep

image-20220827143058333

看看 Golang 官方说明:

//go:linkname localname importpath.name

The //go:linkname directive instructs the compiler to use “importpath.name” as the object file symbol name for the variable or function declared as “localname” in the source code. Because this directive can subvert the type system and package modularity, it is only enabled in files that have imported "unsafe".

这个指令告诉编译器为当前源文件中私有函数或者变量在编译时链接到指定的方法或变量。因为这个指令破坏了类型系统和包的模块化,因此在使用时必须导入 unsafe 包,所以可以看到 runtime/time.go 文件是有导入 unsafe 包的。

从描述中,可以知道,这是在告诉编译器根据名字进行链接的过程。在 timeSleep 中是由私有包主动链接到非私有包的过程,那对于当前代码中可否主动链接到另一个私有包呢?

全局函数 & 全局变量

答案肯定是可以的。

image-20220827190633130

通过 //go:linkname MyNow time.now 告诉编译器,MyNow 的实现是 time.now,而 runtime 中又将自己的 time_now 链接给了 time.now。强烈的三角关系扑面而来有没有?


敲重点:当你写下图中 linkname,会收获的是错误:Missing function body,这时你要在代码同级目录下创建任意以 ".s" 为后缀的文件即可。


同样地,全局变量也可被导出了。

image-20220827193235706

类私有属性 & 类私有方法

类私有属性的操作与 linkname 主没什么关系了,主要使用的是内存映射。为了能拥有相同的内存偏移,所以需要定义一个类,用来对齐内存。

image-20220827194459813

再来看类私有方法,它的思路与全局方法其实没什么区别,但因为涉及类对象,所以需要映射内存首地址,然后再找到具体的私有方法入口。

image-20220827195613725

其实,如果对 Golang 类方法编译后有深入了解过的小伙伴,私有方法其实还有其它 link 方式。

image-20220827200541515

是的,你没有看错,类成员方法调用其实就是把对象或者指针当成第一个参数传递给方法了。

总结

到目前为止,已经能使用私有的全局变量、方法以及类属性、方法等等了。当然你也可以使用开源包来处理,如 go-forceexport。但是你依旧会遇到问题,因为有可能被编译器优化,使得你找到不相应的符号了。不用担心,你还有编译参数可以使用。

1
2
-gcflags="-N -l" # 不内联、不优化
-ldflags="-s=false" # Golang 的 flag 默认 bool 值为 true, 所以你要关闭禁用符号

同时,还要记得加上相关包的 import。

1
2
3
import _ "awesome/outer"
// 如果已经如下被引用则不需要上一操作
import "awesome/outer"

最后总结关键点:

  • 使用 //go:linkname 告知编译器行为;
  • import 相关包;
  • 要创建 ".s" 文件;
  • 如必要,调整编译参数。

验证代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package main

import (
"fmt"
"time"
"unsafe"
_ "unsafe"

"awesome/outer"
_ "awesome/outer"
)

//go:linkname MyNow time.now
func MyNow() (sec int64, nsec int32, mono int64)

//go:linkname longDayNames time.longDayNames
var longDayNames []string

//go:linkname daysBefore time.daysBefore
var daysBefore [13]int32

type MyTime struct {
wall uint64
ext int64
loc *time.Location
}

//go:linkname (*MyTime).nsec time.(*Time).nsec
func (*MyTime) nsec() int32

//go:linkname nsec time.(*Time).nsec
func nsec(*time.Time) int32

//go:linkname add awesome/outer/internal/inner.Add
func add(x, y int) int

func main() {
// 全局方法
fmt.Println(MyNow())

// 全局变量
longDayNames[0] = "123123"
fmt.Println(longDayNames)
fmt.Println(daysBefore)

// 类私有属性
tm := time.Now()
myTm := (*MyTime)(unsafe.Pointer(&tm))
fmt.Println(tm)
myTm.wall = 123123
fmt.Println(tm)

// 类方法
fmt.Println(myTm.nsec(), tm.Nanosecond())
fmt.Println(nsec(&tm), tm.Nanosecond())

// 私有(internal)包方法
x, y := 10, 20
fmt.Println(add(x, y), outer.Add(x, y))
}
扫码_搜索联合传播样式-标准色版

Golang 单元测试篇:优雅地导出私有(unexported)项
https://blog.isnap.cn/posts/4a037ab1/
作者
三岁于辛
发布于
2022年9月3日
许可协议