如果让我说一个 Go+ 实现过程中遇到最大的坑,我会毫不犹豫选择模块管理。在《Go+ 两周年:我们遇到过的那些坑》中我们已经提到过了 Go+ 模块管理上的三次重构,然而这并非是故事的结束,事实上它只是个开始。第一次,在 Go+ v1.0.0-beta1 版本中我们引入 golang.org/x/tools/go/packages 来实现 Go+ 对依赖模块的加载(对应 gox 则从 v0.2.0 版本开始)。在此之前,我们的模块加载用的是脚本引擎中常见的的伪模块加载(也就是说编译器已经提前知道会依赖哪些包,只是使用的时候还是要 “装模作样” 加载一下)。第二次,在 Go+ v1.1.0-alpha1 版本中,我们基于 go install -work -x 命令找到一个 pkgPath 编译后生成的 .a 文件的位置,然后再调用 golang.org/x/tools/go/gcexportdata 包来读取它(对应 gox 则从 gox v1.9.0 版本开始)。第三次,在 Go+ v1.1.0-beta3 版本中,我们基于 importer.ForCompiler,将编译器类型设为 "source",即基于源代码来加载(对应 gox 则从 v1.10.0 版本开始)。如下:这种做法的好处是代码简洁,而且只使用了标准库的功能,没有任何第三方包的依赖。当然它的问题其实也是比较明确的,那就是慢。它相当于每次 import 一个包的时候都会重新编译这个包(不仅如此,它所有依赖的包也会进行编译)。在《Go+ 两周年:我们遇到过的那些坑》这篇文章发表之后,我给 Go team 提交了一个 issue(github.com/golang/go/issues/52269):"x/tools/go/gcexportdata: can't import package from pkg/mod",报告了 gcexportdata.NewImporter 无法正常加载非标准库的包的 bug。这个 issue 本意只是让他们知晓一下问题,但是得到了一些很有趣的反馈。刚开始,他们告诉我 gcexportdata.NewImporter 已经过时了,推荐使用 golang.org/x/tools/go/packages 的 Load 方法来加载包,也就是我们第一次重构时采用的方式。在我详细描述了 packages.Load 的问题后,他们承认了问题,并表示之前的建议考虑不周,然后提到了一个新方式:推荐只使用 packages.Load 来找到编译后的 .a 文件的位置(通过指定 Mode 为 packages.NeedExportsFile 来完成),然后用 golang.org/x/tools/go/gcexportdata 来读取它。实际测试发现他们的建议并不能走通,原因是 packages.Load 设定 Mode 为 NeedExportsFile 下的实现是有 bug 的。但研究相关的实现代码让我明白了在 Go 中获取 .a 文件所在位置的最佳实践是什么:那就是基于 go list -json -export 命令。于是第四次重构发生了。我们在 gox v1.10.4 版本改为了基于 go list -json -export 命令实现了新的 github.com/goplus/gox/packages.NewImporter 函数。它和我们第二次重构大的思路方向差不多,只不过前面我自己山寨了一种找 .a 文件的方式,并非基于最佳实践。如果我们只考虑 import 一个 Go 包的话,模块管理的故事到此就结束了。但是考虑到以下需求的话,Go+ 模块管理的故事还只是刚开始:首先,完整来说在 Go+ 语言中我们支持以下 4 种类型的包:Go 包的加载,我们上面已经介绍过了。经由 c2go 转换的 C 包本质上也是 Go 包,只不过其根目录不只是有常规的 go.mod,还会有一个 c2go.a.pub 文件用来记录所有可公开访问的 C 符号。C 包的处理逻辑颇为简单,类似 Go 包引入 github.com/goplus/gox/packages,对于 C 包我们引入了 github.com/goplus/gox/cpackages 进行处理。其特殊之处无非是在 Lookup 的时候将 C 符号(C.printf)转换为 Go 符号(libc.Printf)而已。基于类文件(class file)的包,本质上是一个 Go+ 包,区别在于以下两点:- 它的 gop.mod 文件里面有 class file 相关的声明(通过 register 或 classfile 关键字);
- 它的 Go+ 源文件后缀不是常规的 .gop,而是由 class file 定义的扩展名(例如 spx 定义的扩展名是 .spx 和 .gmx 两种)。
类文件虽然后缀不是 .gop,但是它们是非常正宗的 Go+ 代码。它好玩的地方在于你可以用最朴素的过程式的代码来实现面向对象的能力。class file 的主要代码由 Go+ 的 parser 和 compiler 完成,模块管理相关主要建立扩展名和 class file 之间的关联关系。我们在 github.com/goplus/mod/gopmod 包中实现了相关的支持。可能大家的直觉会觉得,既然我们已经实现了 Go 包的加载,实现 Go+ 包的加载应该是一件比较容易的事情,但事实并不是这样的。怎么加载 Go+ 包?最直观的想法无非是两件事情:- 将 gop.mod 转换为 go.mod + go.sum
这样,Go+ 包就变成了 Go 包,可以用 Go 包的加载过程来加载它。但是,到底应该先将 gop.mod 转换为 go.mod + go.sum,还是应该先将 Go+ 代码转换为 Go 代码?最直观的想法是先将 gop.mod 转换为 go.mod + go.sum。这样想是有其合理性:我们实测发现,如果没有 go.sum 文件,那么我们前面实现的 import Go 包的模块对于外部包(非标准库,亦非本模块内的包)将不能正常工作。但这个过程的麻烦之处在于生成 go.sum 文件,它需要了解 Go 包 checksum 相关的实现细节。虽然 golang.org/x/mod/sumdb/dirhash 有提供一些相关的支持,但我的想法是能够避开就尽可能避开。避开的方法是先将 Go+ 代码转换为 Go 代码,然后调用 go mod tidy 命令来自动更新 go.mod 和 go.sum(当然用 go mod download 也可以更新 go.sum 文件)。在没有 go.mod 和 go.sum 的情况下,先将 Go+ 代码转换为 Go 代码可能么?可能。没有 go.sum 的问题无非就是 import 功能不能正常使用。但其实这是有办法绕过去的。其中的窍门在于:我们只需要把执行 go list -json -export 的工作目录切换到我们要加载的包所在的 module,而不是当前我们正在编译的包所知 module 即可。基于此,github.com/goplus/gox/packages.Importer 类增加了 ImportFrom 方法。这个方法的语义并不是我们创造的,而是由 go/types 包引入(见 types.ImporterFrom 接口)。事实上,Go 更推荐我们使用 ImportFrom,而不是之前我们一直采用的 Import 方法。- 对于 Go 标准库以及当前 module 下的包,我们将 ImportFrom 的执行目录设为当前 module 的根目录;
- 对于 Go+ 标准库,我们将 ImportFrom 的执行目录设为 Go+ 源代码的根目录(GOPROOT);
- 对于第三方库,我们将 ImportFrom 的执行目录设为该第三方库源代码的根目录。
这些逻辑我们实现在 github.com/goplus/gop.Importer 类中。什么是 Go/Go+ 混合工程?简单说,你拿一个手头现有的 Go 项目,在里面加几个 Go+ 源代码文件,然后用 Go+ 编译器编译,它总能够正常执行。并且,Go 和 Go+ 代码相互可以自由引用对方的代码,就如同用一个语言写的一样。这就是 Go/Go+ 混合工程。这条路的确走得通。但是它的性能很糟糕:一个非常普通的 Go 包让 types.Checker 来处理往往也需要 2-3 秒的时间。考虑到 Go/Go+ 混合工程会是我们一个主推的特色功能,我们当然不希望因为编译慢而影响到这个功能的用户体验。最后我从最近 c2go 项目的模块支持(参考《Go+ c2go: 如何实现自动模块切分及品质保障》一文)中找到了灵感:从 Go 代码中抽出所有公开符号的原型(prototype),将其转为 Go+ AST(参考 github.com/goplus/gop/ast/fromgo 包),形成类似 C 语言中的头文件的效果(即只提供类型信息但不生成代码),这样,Go+ AST 就包含了所有符号的完整类型信息,可以正常编译了。如同 Go 语言提供的 go 命令一样,Go+ 几乎所有的能力,最终都通过 gop 命令对外提供。所以最后的最后,我们谈谈 gop 命令对模块管理的支持。Go+ 模块管理功能隐含在绝大部分的 gop 命令。下面是最常规的:- gop install/build/run/test
- gop mod init/download/tidy
这里面绝大部分是大家非常熟悉的命令,除了 gop go 命令。gop go 用于将 Go+ 包转换为 Go 包,用法上和 gop install/build/run/test 这些完全类似。gop install 以及 gop run 还支持安装/运行远程包。这很有点 docker 的感觉(相信大家都很熟悉 docker run 了),非常方便。例如:gop run github.com/goplus/spx/tutorial/01-Weather
gop run github.com/goplus/spx/tutorial/01-Weather@v1.0.0-rc6
gop run github.com/goplus/spx@v1.0.0-rc6/tutorial/01-Weather
也就是说,版本号放到包(package)末尾或者模块(module)末尾都是可以的。在我们提供了 gop go 命令的前提下,这些命令大部分实现上都比较平庸。但与 Go 不太一样的是,Go+ 在 github.com/goplus/gop 包中实现了这些命令的核心能力,以便大家进行整合,而非依赖命令行。以 gop run 为例,它的功能被分拆到 3 个函数中:如果我们执行 gop run . 命令,最终会调用到 gop.RunDir 函数;如果我们执行 gop run github.com/goplus/spx/tutorial/01-Weather,最终会调用到 gop.RunPkgPath 函数;如果我们调用 gop run *.gop,最终会调用到 gop.RunFiles 函数。实现上最复杂的是 gop mod tidy 命令。它首先要 parse 所有的 Go/Go+ 源代码(包括 class file 文件)得到所有依赖的 module 列表,下载这些 module(如果他们还没有被下载过的话),并把 module 加入到 gop.mod 文件中。常规而言,似乎做到这样就差不多了。但是为了方便后续使用过程的流畅性,它会调用 GenGo 函数将所有 Go+ 源代码转换为 Go 代码,并最后调用 go mod tidy 来更新 go.mod 及 go.sum 文件。最后总结一下:从需求的影响面来说,Go+ 模块管理所牵涉的内容实在太多了,这导致这块的功能一直实现上未能尽如人意。Go+ v1.1 正式版发布时我们会尽最大可能去保证使用上的流畅。但是难以避免的,会出现在一些 case 上考虑不周的情形。如果你在体验 Go+ 中遇到了,欢迎给我们提 issue。