Go+ 两周年:我们遇到过的那些坑

恰逢 Go+ 进入两周年的时间,七牛云创始人兼 CEO、Go+ 语言创造者许式伟 (@xushiwei) 发布了一篇名为《Go+ 两周年:我们遇到过的那些坑》的文章,具体内容如下:

在开发过程中,可能会因为各种原因导致重构的发生。有的是因为历史包袱(代码经过太多轮次的演进已经不堪重负),有的是因为业务理解(接触这个领域时间比较短,对需求的理解不深刻),有的是因为技术选型(以前用了 A 方案,现在想改成 B)。但是让你印象最深,重构次数最多的模块(或功能)是什么?

今天就 Go+ 开发进入 2 周年的时间(Go+ 史前时代:Go+ 代码仓库是 2015 年 12 月建立,当时它并不是 Go+,而是作为 qlang 这个脚本语言的仓库。就像 Go 的代码仓库提交记录特意追溯到 C 语言的诞生史一样,Go+ 仓库以 qlang 作为起点是因为两者有一些发展上的渊源,但是从语言角度来说,两者并无任何关系。Go+ 语言诞生日:第一行代码是 2020 年 3 月 30 日提交。第一个体验版:v0.6.01,该版本代码最后提交的时间是 5 月 23 日),我想借此机会聊聊我们在这两年中遇到的坑。

Go+ 第一次大规模重构是发生在 2021 年 6 月 29 日开始第一行重构代码,7 月 21 日发布 v0.9.0 重构预览版本(github.com/goplus/gop/tree/v0.9.0),9 月 17 日发布 v1.0 版本,它代表了第一次的大重构基本告一段落(github.com/goplus/gop/tree/v1.0.1)。

这个重构可以说是在预期中的。Go+ v0.6 和 v0.7 这两个大版本都是以功能预览为目的,尽可能快让大家对 Go+ 到底是个什么模样有一个认知。但是有以下几个根本性的问题,导致了它最终不得不走向一次大型的重构。

其一,类型系统的局限性。Go+ v0.6 延续了 qlang 时期以 reflect.Type 作为 Go+ 的类型表示。这个做法是有好处的,做脚本比较方便。而 Go+ 从一开始就提倡这个语言是双引擎的,也就是它同时支持静态编译和脚本解释执行。从脚本执行角度,reflect.Type 表示类型是一个非常天然的选择。但是不幸的是 reflect.Type 并不能完全表达 Go 类型系统的所有能力,它只是代表了 Go 语言的动态特性。

慢慢地,这种机制下表达一些高级的 Go+ 语言特性越来越力不从心,其中以接口(interface)功能最为典型。Go 语言的动态特性本来就建立在接口的基础上。所以 reflect.Type 可以表达各种类型,但是唯独没有接口。另一个例子是结构体(struct)。结构体虽然支持但也有一些局限性,比如你可以创建小写字母开头的字段(Field),但是正常手段下无法访问它(这是 Go 的安全机制,小写字母开头代表私有变量,动态特性不能访问私有变量)。

当然这些最终都有办法突破。后面我们会有专门的文章来介绍怎么突破这些限制,以实现脚本版本的 Go+。

其二,import 包的障碍。如果说接口和结构体实现起来是有困难,那么 import 包就是大障碍了。从脚本版的 Go+ 来说,import 包最简单的做法是对包的所有导出函数进行一次包装,然后提前注册到脚本解释引擎中去。这种方式实现的 import 其实是伪 import,功能已经链接进来了,其实不 import 也能用,但是还是得 import 装装样子。

基本上你见到的主流 Go 的解释引擎都是这么干的。但是它的缺陷很明显。首先是解释器很臃肿,链接了一堆的包,仅仅只是以备不时之需。其次是扩展难,无法真正做到自由 import 任意的包。

难一点的做法是真去将包用 Go+ 解释器引擎去编译成字节码,然后去 import 字节码来执行。这样通用性够了,但是对 Go 的语法兼容必须非常完备,甚至包括 cgo。另一方面来说,这种做法的性能肯定是远不如前面那种对所有导出函数做一次包装的。

还有一种可能性,是将第一种方法改造一下,把所有的包(严谨来说是模块)都编译成动态库,然后让 Go+ 解释器引擎加载动态库来实现 import。这种做法消除了前面两种做法各自的缺陷,理论上来说是最完美的方案,但实现上也最为复杂。

其三,双引擎带来的迭代效率。我们越来越发现,让两大引擎同时支持所有功能是困难的,这让 Go+ 的功能迭代变得越来越慢。而 Go+ 在起步阶段最需要的并不是双引擎,而是快速迭代它的使用范式,让人看到它的样子,体验它,吐槽它,改进它。

所以,无论是出于长治久安的目的(解决 reflect.Type 缺陷),还是出于版本迭代效率,我们最终选择了第一次大的重构。这次重构主要有以下变化:

其一,以 go/types 作为类型系统的支撑(最英明神武的决策,但并不是唯一的)。这个决策让 Go+ 的功能迭代一下子飞了起来。Go+ 1.0 发布的时候,我们跑通了 Go 官方几乎所有语言语法上的测试用例(除了我们暂时不考虑实现的一些功能)。

其二,放弃了双引擎同步迭代模式,以静态编译引擎迭代为优先,动态脚本引擎独立迭代发展(也就是 github.com/goplus/gossa 这个库了)。

其三,以 Go AST 作为标准协议,而并非 Go+ v0.6 引入的 exec.spec 执行器规范。而这个决策其实也是动态脚本引擎独立发展的重要基础,双方的协作边界非常明确而清晰。

其四,引入了 gox 包(github.com/goplus/gox)作为 Go AST 的生成辅助模块(最英明神武的决策,没有之一)。这其中最重要的也最根本的变化,是把类型推导能力从 Go+ 编译器中独立出来,由 gox 来完成。这一下子大大给 Go+ 编译器做了减负。编译器的代码可读性巨幅提升(大家可以回去看 Go+ v0.6 和 v0.7 版本的编译器即 gop/cl 模块的代码),代码量大幅减少。

这也是后面 Go+ c2go 模块的 C 编译器为什么 10 天就能够做出来的根本原因。详细请见《Go+ 探究:如何 10 天实现工业级的 C 编译器》。

其五,引入 golang.org/x/tools/go/packages 来实现 Go+ import 包的能力(最糟糕的决策,没有之一)。

总结来说,这次的重构获得了非常巨大的成功,直接加速了 Go+ v1.0 版本的到来。但,它也留了一个大坑,那就是 golang.org/x/tools/go/packages。

先让我们看看 golang.org/x/tools/go/packages 有什么问题:

其一,慢,非常慢。有多慢?加载一个常规工程(不是那么大型的工程)的所有包,可以到几十秒之久。为此 gox 专门为包加载过程实现了一个缓存模块。从最早的纯内存缓存,到后来也实现了磁盘缓存(以实现跨不同的包、跨多次编译的缓存共享)。

其二,接口不合理,不能在多次的 packages.Load 之间共享加载过的包。这除了慢(如果共享了,可以检测以前加载过就不用再加载,我们的缓存模块的原理就是这样),还带来一个新问题:同一个包 A 可能被加载过两次,得到了两个不同的实例 A1、A2。我们人肉眼可能可以知道 A1 和 A2 是等同的,但是对于 go/types 类型系统来说,A1.Foo 和 A2.Foo 两个就属于完全不同的类型了,这样就会导致类型匹配失败的误报。

怎么办?

我们在 gox 中实现了一个 dedup(去重)模块,专门用于解决同一个包被加载两次的问题。假如我们先后加载了 B 和 C 两个包。B 加载时依赖了 A1,C 加载时依赖了 A2。那么 dedup 模块做什么呢,dedup C 的时候,会发现它依赖的 A2 和 之前加载过的 A1 是同一个模块,进而将其修正为 A1。

但是这些都不太完美。缓存,会有缓存更新的问题。怎么正确更新缓存,需要一点点去改进,把各种需要更新缓存的场景加上。dedup(去重),会有类型遍历成环的问题。怎么确保所有函数原型、变量、常量、类型等公开的符号被遍历(这其实是复杂的,类型里面还可以套类型),要想不会出现死循环,就要做标记,哪些已经被访问过了别再访问。

总之,为了一个坑,我们给自己又挖了两个不小的坑。

于是准备再三,我又开始了一次不小的重构。这次重构从 2021 年 12 月 26 日开始,我创建了 v1.1 分支。这是为了能够让大部分的 Go+ 贡献者仍然基于 v1.0 主版本进行迭代,不受我的重构干扰。到 2022 年 1 月 5 日,我们发布了 v1.1.0-alpha1,有了第一个可用的版本(github.com/goplus/gop/tree/v1.1.0-alpha1)。

当然这还是因为 import 包实际上是一个非常全局性的事情,因为它涉及的是模块这样一个关键概念。如果说类型系统是语言设计的核心的话,那么模块是工程能力的核心,所以它天然跟很多功能交织在一起。这一点大家看看 go 命令行那条命令和模块无关(go build/install/test/...)就知道了,它基本和所有工程能力相关的功能都有关。

如果大家留意过的话,可能也知道,最新一个 Go+ 的发布是 v1.1.0-beta2,还没有转正。所以大家如果要体验 Go+ 的话,还是鼓励下载 Go+ v1.0.x 系列,最新是 v1.0.39 版本。

这次重构还并没有结束,但是我给自己按了暂停键。

我们先来说说重构的方案,然后再一起看它有什么问题。

实际上,在第一次大重构时,我并非不知道 golang.org/x/tools/go/packages 不够好,它远非我的首选。在重构前,我对与 Go 语法能力相关的,除了标准库的 go/... 这些库之外,我还系统性地学习了 golang.org/x/tools/go/... 这些包。以下是部分学习笔记的截图。

这是总纲:

这是 go/gcexportdata(支持 .a 文件读取的包):

 

这是 go/ssa(Go 编译器引入的 ir 文法):

 

整个学习笔记我在重构前分享给了 Go+ 贡献者群的小伙伴们。它对整个重构的影响是比较深的,比如为什么我们有 gossa 这个 Go 解释器,看了上面的学习笔记大家可能就比较清楚了。

回到正题。刚才我说 golang.org/x/tools/go/packages 并非我的首选,我心目中的首选实际上是 golang.org/x/tools/go/gcexportdata,也就是直接去读 .a 文件里面的内容。但是这里面有一个问题是我无法克服的:就是这些 .a 文件在哪里,不知道。

在 Go 的早期版本里面,这些 .a 在哪里是明确的,我们只需要知道要 import 的包叫什么名字,就可以找到对应的 .a 文件。但是随着 Go 的版本迭代,除了 Go 标准库编译后的 .a 文件还在老地方外,其他所有的包编译后的 .a 文件,被某种未知的 hash 算法分散到各处。

找不到了。

不得已我们选择了 golang.org/x/tools/go/packages,掉入了坑。

那么我们想怎么改掉呢?

这次 import 包的局部重构,我们的起点是想 fork 一个 golang.org/x/tools/go/packages 然后改掉它的毛病。在研究它的实现时,发现它用的是 go list 命令行。于是我开始研究起 go 命令行来。

在一次和七叶沟通中,七叶给我看了 go 目录的 -x 参数,可以打印 go 的执行细节,这让我大有启发。

最终我发现,找不到 .a 文件的问题,通过 -x 参数就可以解决了,go 命令行会向 stderr 打印 packagefile 语句,用于输出 package import path 与 .a 文件的对应关系。

这太好了。

于是,gox 就有了大版本升级,从 v1.8.8 升级到 v1.9.1,这里面最主要的变化,就是去掉了 golang.org/x/tools/go/packages,而直接改用 gox 自己的 packags 包。它工作的机理也很简单,通过 go install -x 命令获得 .a 文件位置,然后通过 gcexportdata 包去读取它。

这个方法解决了 golang.org/x/tools/go/packages 的所有缺陷。

其一,它很快,非常快。不需要我们自己做缓存,直接复用 go 自身的 “缓存”(如果我们把 .a 看做一种缓存的话)。

其二,它不会重复加载包,所以也就不需要 dedup 操作。

看起来完美?但是它带来了一些新问题。

其一,丑陋的使用接口。我们首先需要提前告诉 github.com/goplus/gox/packages 我们要加载哪些包。为了大家有直观感受,我们列出代码大家体感一下:

package main

import (
"github.com/goplus/gox"
"github.com/goplus/gox/packages"
)

func main() {
imp, _, _ := packages.NewImporter(

nil, "fmt", "strings", "strconv")
pkg := gox.NewPackage(

"", "main",

&gox.Config{Importer: imp})
fmt := pkg.Import("fmt")
...
}

这还是是忽略了错误处理后的代码。

以前我们是怎么用的?如下:

package main

import (
"github.com/goplus/gox"
)

func main() {
pkg := gox.NewPackage("", "main", nil)

fmt := pkg.Import("fmt")
...

}

基于 golang.org/x/tools/go/packages 虽然问题多多,但是用起来还是很清爽的。

其二,internal 包的加载问题。github.com/goplus/gox/packages 包背后的机理是通过构造一个临时的包(比如叫 dummy),把所有要加载的包统统 import 进去。比如我们要加载 A,就在这个临时包里面加上 import _ "A" 这样的语句,然后用 go install -x 显示出各个包对应的 .a 文件在哪里,然后去加载。但是这个做法有一个问题,那就是 internal 包是有加载规则限制的。临时的 dummy 包放在哪个目录,决定了它是否有权限加载某些 internal 包。

这个细节不是不能解决,只是很恶心。简单说,我们多搞几个 dummy 包去规避 internal 包的加载限制就好了。

这两个问题都只是不够优雅,但他们不是我停下来的原因。

停下来的原因,是我在考虑 Go+ 几个新功能的时候,发现无论是 golang.org/x/tools/go/packages,还是我们重构后的 github.com/goplus/gox/packages,都不能解决。

卡壳了。

是什么样的新功能让我卡壳了?是 Go 语言进一步的兼容。我在考虑如何兼容 cgo,以及 Go 的泛型。

在 Go+ 语法的选择上,我是很笃定的:Go+ 不会支持 cgo(我们选择了 c2go,详细参阅《Go+ 下个里程碑:超越 cgo,无缝对接 C 语言》这篇文章),短期也不考虑完整支持泛型(Go+ 只考虑支持调用泛型,但是不允许定义泛型,无论是定义泛型函数还是泛型的类),完整支持等 Go 的泛型迭代个若干大版本后再说。

你可能说:既然都不支持,那有什么好卡壳的啊?

好吧,我们只是说 Go+ 自身不支持 cgo,暂不支持泛型定义。但是我们选择了一个很重要的特性:支持 Go 和 Go+ 混合工程。

简单说,你拿一个手头现有的 Go 项目,在里面加几个 Go+ 源代码文件,然后用 Go+ 编译器编译,它总能够正常执行。并且,Go 和 Go+ 代码相互可以自由引用对方的代码,就如同用一个语言写的一样。

我们以此来变相支持 cgo 和泛型定义。

这意味着我们突然打破了一个固有的边界:我们之前会认为 Go 包是 Go 包,Go+ 包是 Go+ 包,但是现在 Go 代码单独看是不完整的,无法编译的,Go+ 代码单独看也是不完整的,无法编译的,他们只有合并起来才能够编译。

我们进入了死循环。Go+ 编译需要依赖 Go 代码中的类信息信息,而 Go 代码如果给 go install -x 来处理也会因为代码不完整而报错。

这意味着我们需要新的解决问题的方法。

我找到了。

在上一篇《Go+ 探究:如何 10 天实现工业级的 C 编译器》中我提到 go/types 的重要性时说到,types.Checker 类也很重要,我们 gox 后面也需要用到它。其实我是为这一篇准备了一个伏笔。

是的,问题的答案是:我们用它来解决。

我们先来看一下相关的函数原型:

func (conf *Config) Check(

path string, fset *token.FileSet,

files []*ast.File, info *Info) (*Package, error)

这个 Check 函数的意思是,我们输入 Go 代码的抽象语法树(Go AST),也就是这里面的 files 参数,就可以得到 types.Package 实例。

还有一个重要的细节:Config 有 IgnoreFuncBodies 成员,指示 Check 函数忽略函数的代码,只关注函数原型。

所以对于混合 Go、Go+ 代码的工程,我们只需要用 go/parser 处理 Go 代码,用 gop/parser 处理 Go+ 代码,然后把 Go+ AST 转成 Go AST(可以忽略函数的代码,只需要转换原型,详细见 gop/ast/togo 这个包),然后把它们放到一起交给上面的 Check 方法即可。

Go/Go+ 混合编码的问题这就解决了。

我们还有意外收获。在研究 types.Checker 类的时候,我留意到 importer 包也有使用它,接下来是让人大跌眼镜的一幕。

我们再次做了重构,改写了 gox 的 packages 包:

  

去除防御性的 if 语句,只有一行代码!然后我们用这个新的 packages 包去实现 gox 的 import 包能力。

没有 golang.org/x/tools/go/packages 需要的缓存和 dedup,也没有 github.com/goplus/gox/packages.v1(是的我们把基于 go install -x 版本的留了下来,只是改名为 packages.v1 了)的接口丑陋(最新使用界面见下,和最早的版本一致)和 internal 包限制问题。

package main

import (
"github.com/goplus/gox"
)

func main() {
pkg := gox.NewPackage("", "main", nil)
fmt := pkg.Import("fmt")
...

}

我们前面两次重构用尽浑身各种解数,只干了一行代码干的事情?

总结一下:importer.ForCompiler 将编译器类型设为 "source" 绝对是隐藏最深的暗门,如果不是研究 types.Checker 绝对发现不了。之前 Go+ 第一次大重构的时候,我就测试过 importer 包各种行为,竟没发现此等暗门。

藏得真够深的啊。

猜你喜欢

转载自www.oschina.net/news/190972/goplus-two-years
GO+