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

看看 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
中是由私有包主动链接到非私有包的过程,那对于当前代码中可否主动链接到另一个私有包呢?
全局函数 & 全局变量
答案肯定是可以的。

通过 //go:linkname MyNow time.now
告诉编译器,MyNow
的实现是 time.now
,而
runtime 中又将自己的 time_now
链接给了
time.now
。强烈的三角关系扑面而来有没有?
敲重点:当你写下图中
linkname,会收获的是错误:Missing function body
,这时你要在代码同级目录下创建任意以
".s" 为后缀的文件即可。
同样地,全局变量也可被导出了。

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

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

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

是的,你没有看错,类成员方法调用其实就是把对象或者指针当成第一个参数传递给方法了。
总结
到目前为止,已经能使用私有的全局变量、方法以及类属性、方法等等了。当然你也可以使用开源包来处理,如 go-forceexport。但是你依旧会遇到问题,因为有可能被编译器优化,使得你找到不相应的符号了。不用担心,你还有编译参数可以使用。
1 |
|
同时,还要记得加上相关包的 import。
1 |
|
最后总结关键点:
- 使用
//go:linkname
告知编译器行为; - 要
import
相关包; - 要创建 ".s" 文件;
- 如必要,调整编译参数。
验证代码
1 |
|
