许式伟:import 过程与 Go+ 的模块管理丨Go+ 公开课 • 第二期
为保持与 Go+ 开发者及行业从业者的密切沟通,共同促进、交流 Go+ 的迭代发展,Go+ 开发团队策划了「Go+ 公开课」系列直播课程。「Go+ 公开课」将以每周一期的节奏陆续推出精彩的干货分享,欢迎 Go+ 开发者、爱好者持续关注!
第一期:Go+ v1.x 的设计与实现(点击左侧文字链可查看内容回顾)
第二期:import 过程与 Go+ 的模块管理(主讲人:七牛云 CEO、Go+语言发明人 许式伟)
本文为第二期直播内容整理。
我们接下来对外输出的 Go+ 内容建设主要包含两个部分:
第一类面向更为广泛的 Go+ 使用者,Go+ 的官方 GitHub 中有相关的特性介绍,以及功能开发的操作介绍。我们也在持续整理相关基础文档以及资料,供开发者使用、学习。
第二类也就是类似「Go+ v1.x 设计」系列的内容,是面向希望理解这门语言背后的原理,希望更进一步参与 Go+ 开发、成为 Go+ 贡献者、为社区深度贡献价值的社区贡献者,以及部分希望获得深度体验的用户。
我有一个观点,只有深度理解事物背后原理后,才能更好的使用它。因此,「Go+ v1.x 设计」系列的内容更多会从 Inside Go+ 的视角进行分享。第一期中我分享来了 Go+ 的宏观架构,本期会结合具体的功能开发,为大家介绍 Go+ 如何实现具体的功能。
本期的分享内容是「import 过程与 Go+ 的模块管理」。之所以第二期选择这一话题,是因为 import 过程与模块管理属于相对宏观的过程,与具体分享 Go+ 有哪些语法实现相比,理解它更加重要一些,更有助于大家理解 Go+。
本期的分享主要分为两部分:
Go+ 的 import 过程
Go+ 模块管理
一. Go+ 的 import 过程
其实 Go+ import 的语法与 Go 基本上没有太大的区别,Go 的 import 语法内容相对较多,我们把它详细展开的话可以有下图这些内容。
在这个图中,包含了 import Go 的标准库、import 一个用户自定义包,还有 import 一个 Go+ 标准库比如 import "gop/ast"。而红色的 import lacal package 部分,通过相对路径来引入包的语法格式,因为工程中很少会使用这一功能,所以 Go+ 暂时还没有实现。
还有一种写法,是给 import 的包定一个别名。有两个特殊的别名:“” 和 “.”,其中 “” 使用较为普遍,而 “.” 则也是官方不推荐使用的写法。
有了语法后,下一步要了解的便应该是 token&scanner 和 ast&parser。因为其余部分都相对比较普通,我们今天主要来重点讨论下 import 一个包的 ast,也就是抽象语法树。
这个抽象语法树比较深,但其实内容比较基础。最顶层是包(Package),包下面是文件列表,再下面一层是全局声明列表。全局声明分为两类:一类叫函数声明(FuncDecl),另一类叫通用声明(GenDecl)。
通用声明看起来比较抽象,主要是包含 Import、Type、Var、Const 四类声明。通用声明与函数声明的区别在于,函数声明一次只能声明一个函数,而在通用声明中,你可以同时 import 多个包,也可以一次定义多个 Type 类型,虽然这种做法不太常见,但是可行的。同时声明多个变量或常量则使用较为普遍。
在通用声明下包含所谓的规格列表(Specs)。规格也是一个抽象的设定,今天我们关注的 ImportSpec,它代表导入了一个包。ImportSpec 下面则是包的别名(Name),包的路径( Path)。
以上就是 Import 包相关的抽象语法树了。
有了抽象语法树后,便是对抽象语法树进行编译。我们在第 1 期内容中介绍过,编译过程最主要做的事情,就是将 Go+ 的抽象语法树转为对 gox DOM Writer 组件的函数调用。在 gox 中,和 import 一个包相关的 DOM Writer 函数有以下几个:
第一个是在 Package 下面有一个 Import 函数,调用它会得到一个 PkgRef 实例。在 PkgRef 类型中有一个最重要的变量是 Types *types.Package,它里面包含了包中所有符号的信息。
PkgRef 类有两个比较重要的方法。一个是 Ref,也就是引用某一个符号,传入符号名字,得到符号的引用;第二个是 MarkForceUsed,也就是强制 import 一个包。
在 gox 中 import 一个包是很聪明的。如果 import 包后没有使用,在生成的代码中不会体现对该 package 的引用。这类似很多 Go IDE,如果 import 了没有使用过的包,便会自动进行删除这个引用。
要了解 gox DOMWriter 的具体使用方式,我们看一个具体的例子:
import "fmt"
func main(){
fmt.Println("Hello world")
}
这里我们假设要写一个 Hello world。首先我们 import fmt 包,然后通过 fmt.PrintIn 输入“Hello world”。这段代码特别简单。
对编译器来说,它会产生以下对 gox 的调用序列:
第一步是 NewPackage。这里我们假设要创建的是一个 main 包,我们得到了 main 包的 package 实例,赋值给 pkg 变量。
第二步是调用 pkg.Import,这句话延迟加载了 fmt 包,并赋值给 fmt 变量。
接下来我们再通过 NewFunc 定义了 main 函数。在上一讲我们我们用 NewClosure 创建了一个闭包。闭包是特殊的 Func,它没用函数名。NewClosure 只有三个参数:输入、输出和一个代表是否支持可变参数的布尔变量,而 NewFunc 则比 NewClosure 会额外多两个参数,也就是上面的前两个参数:nil 和 "main"。第一个参数是「reciever」,类似其他语言的 this 指针,main 函数没用 receiver,所以为 nil。第二个就比较好理解了,是函数的名字。
NewFunc 后,就调用 BodyStart 开始实现函数体。在上面这个例子中,函数体就是一个函数调用。在上一讲中我们介绍过,在 gox 中我们通过类逆波兰表达式的方式来写代码,也就是先参数列表,再指令。函数调用的指令是 Call。所以这个函数调用的顺序是先传函数地址 fmt.Ref("Println"),再参数 "Hello world",然后才是函数调用指令 Call。因为这是带有 1 个参数的函数调用,所以是 Call(1)。最后我们调用 End 结束函数体。
通过这段代码,我们可以看到 gox DOM Writer 整体的代码逻辑是非常直观的。只要理解了逆波兰表达式,整个逻辑就非常容易理解。
前面我们已经提过,在 import 的过程,gox DOM Writer 会涉及到三个函数:
(Package).Import(pkgPath string)PkgRef
(PkgRef).Ref(name string) Ref
(PkgRef).MarkForceUsed()
我们接下来对他们一一详细展开来介绍。
其中,(*Package).Import 函数最重要的一点,这一点前面我们也提过,在于 import 过程是延迟加载的,如果包没有被引用,那么这个 import 便什么都不会发生。
(PkgRef).Ref 函数则会进行判断,如果包还没有被加载的情况下,就会去真正进行包的加载;如果已经加载,便直接查找相关的符号信息(lookup by name)。由于延迟加载的原因,(PkgRef).Ref 可能导致多个包会一起被加载,这很正常。而且从性能优化的角度,我们鼓励多包,甚至将所有的包,一起进行加载。
(*PkgRef).MarkForceUsed 代表强制加载一个包。它对应于 Go 语言中 import _ "pkgPath" 这个语法。这种情况虽然 pkgPath import 后没有被使用,但是仍然希望去加载这个包,这时就可以通过调用 MarkForceUsed 来实现强制加载的能力。
在 Go 语言中,import _ "pkgPath" 一般在插件机制中出现的比较多。在 Go 标准库中最典型的插件机制是 image 模块。因为要支持的图片格式很多,很难预知需要支持哪些类型的图片。所以在 Go 语言中图片的 encode 和 decode 都基于插件机制,让 image 的系统更加开放。
前面我们分享了 Import 语法、它对应的抽象语法树,编译器和 gox 的调用序列以及 gox 相关函数的介绍。整体来说,import 只是从使用或者整体结构理解的角度来说还是比较简单的。但实际上它内部发生的事情是很复杂的。
接下来我们就来详细介绍下 gox 在 import 包的加载过程中到底发生了什么,以及为什么我们鼓励多包同时加载。
实际上 gox import 包的过程中,加载包的代码并不是 gox 自己写的,而是调用了 Go Team 写的一个扩展库 —— golang.org/x/tools/go/package ,这个包中有个 Load 函数,可以实现同时加载多个包。
func Load(cfg Config, patterns ...string) ([]Package, error)
Load 函数中的 patterns 是要加载的 pkgPath 列表,之所以叫 patterns 是因为它支持用 "..." 表达包的通配符(这和所有 go tools 一致,go install、go build 等也支持包的通配符)。例如 "go/..." 表示所有 "go/" 开头的 Go 标准库中的包,包括 "go/ast"、"go/token"、"go/parser" 等等。
之所以要支持多个包同时加载,是因为不同包依赖的基础包大同小异,加载过程中有很多重复的工作量,而当前 packages.Load 函数没有缓存机制,速度会很慢。我们以 fmt 包为例。fmt 依赖于 9 个基础包,加上自身需要加载 10 个包,如果同时加载另一个包比如 os 包,它和 fmt 有大量重复加载的基础包,如果同时加载 os 和 fmt 就无需重复加载这些包,从而加载速度大幅提升。
Load 的结果是一个 Package 列表,列表中有两个重要的变量:
Imports map[string]*Package:通过这个变量可以构建包与包间的依赖关系树;
Types *types.Package:依赖包的核心信息,通过该变量可以构建出 *gox.PkgRef 实例。
但是,我个人认为,package.Load 函数在设计上有比较大的问题。这主要表现在:
- 重复加载的开销
虽然尽量单次 Packages.Load 加载多个包可以一定程度上避免重复加载的问题,但如上所述,多次 Packages.Load 调用之间没有进行优化,会导致很多重复加载的开销。
我们举个例子。假设我们将 Load(nil,"go/token"); Load(nil,"go/ast"); Load(nil,"go/parser") 合并为 Load(nil,"go/token","go/ast"."go/parser"),那么后者的加载时间基本只有接近前者的三分之一,多一次调用就是多一次的开销。
而且,packages.Load 的加载时间甚至到了秒级,很慢很慢。因此,这是一个需要解决的大问题。
- 多次 packages.Load 导致相同的包有多份实例
每次 packages.Load 产生的 *types.Package 实例是独立的,多次调用会导致同一个包存在多个实例。这个问题导致的结果是,我们不能简单用 T1 == T2 来判断是否是同一类型。这很反直觉。而因为不同的包间存在依赖关系,这种反直觉最终会产生很奇怪的结果。
例如将 Load(nil,"go/token"); Load(nil,"go/ast"); Load(nil,"go/parser") 分开调用,那么parser.ParseDir 一类的第一个参数类型为 *token.FileSet,它和单独调用 Load(nil,"go/token") 构造出的 *token.FileSet 类型实例,虽然名字一模一样,但是我们真去做类型匹配却会失败。
那么,怎么解决这两个问题?在 Go+ 中,我们的确做了相应的解决方案。
首先,为解决加载慢的问题,Go+ 引入 package.Load 的缓存。只要有一个包在加载中被发现未缓存,便会调用 package.Load 进行加载,加载完后便会被缓存下来,下次调用便无需重复进行加载。
而为解决相同包产生多份多份实例的问题,Go+ 对 package.Load 的结果进行了一次 dedup 操作,也就是去重复化。具体的流程是,我们对第二次 package.Load 的结果进行扫描和重构,确保相同类型只有一个实例(具体代码可查看:gox/dedup.go)。
这是一种补丁式的改法,更彻底的改法是修改 package.Load 本身,让多次 Load 间可以共享依赖包。这个方式我认为更加科学,但基于尽量不调整第三方包的原则,Go+ 目前采用了 dedup 这样的「后处理」过程来解决。
我们重点聊一下 packages.Load 缓存的机制。
首先我们简单看一下缓存过程本身,这很基础。它大概的逻辑是,在 package.Load 前先查询要加载的包是否已经缓存过,如果缓存过,直接返回结果;如果没有缓存过,先调用 package.Load,然后 dedup 解决包重复实例的问题,然后再保存到缓存中。
这个过程详见 gox/import.go 的 func(*LoadPkgCached)load 函数。
当然这个还不够。在程序退出时,我们还要对所有依赖包的缓存进行持久化。持久化的逻辑,是先将它们序列化成 json,然后再进行 zip 的压缩。这个 zip 压缩过程中非常重要,如果不压缩整个的缓存会比较大。最后压缩后我们将缓存保存为 $ModRoot/.gop/gop.cache 文件。
如果大家了解 Go 语言的工具链就会知道 Go 本身也有做类似 package.Load 的缓存,只不过它的缓存是全局的,而 Go+ 不同,我们的缓存是模块级的。在每一个编译的模块根目录下会设有隐藏目录 .gop,其中保存的便是缓存文件。
具体缓存持久化的代码可详见:gox/persist.go。
当然大家都知道,缓存有缓存的问题。所有缓存要考虑的一个重点问题,就是缓存的更新。关于这个问题我们分为几类情况来看。
首先,如果依赖包是 Go 标准库,因为本地的属性以及不太会有人修改 Go 的标准库,我们可认为这种情况下,缓存是不会变化的。
如果依赖包不是 Go 标准库,那么就需要计算依赖包的指纹。如果指纹发生变化,则认为依赖包发生变化。如何计算指纹?它包含两种情况:
-
如果依赖包属于本 Module 内的(代码在 $ModRoot 下),那么我们需要枚举 files(文件列表)后根据每个文件的最后更新时间计算指纹。算法详见:gox/import.go 的 func calcFingerp 函数;
-
如果依赖包不属于本 Module 内的(此功能暂未实现),那么需要读 go.mod 文件来检查该依赖包的版本(tag),若两次 packages.Load 的版本没变则认为包没有变化。当然一个特殊的情况是我们还要考虑 replace 情形,如果某个包被 replace 为本地代码,则视同该依赖包属于本 Module 内的依赖处理。
当然,这个暂未实现的功能希望大家可以尝试进行实现。目前情况下,如果你发现 gop 编译时依赖包的信息过时了,临时的处理方案是手工删除 gop cache 文件(删除指令:rm $ModRoot/.gop/gop.cache)。
二. Go+ 模块管理
关于 Go+ 模块管理有个很基础但核心的问题 —— 模块(Module)是什么?
首先,模块不同于包(Package),一个模块通常包含一个或多个包。我自己给模块简单做的定义如下:
模块是代码发布的单元
模块是代码版本管理的单元
这两个定义本质是已知的。道理很自然,有发布才有版本管理。版本管理就是针对发布单元而进行的。
关于 Go+ 模块管理这部分的内容,我们也分为两部分:
如何 import 一个 Go+ 的包
如何管理 Go+ 模块
- 如何 import 一个 Go+ 的包
大家思考一下,在 gox import 过程中,传给 packages.Load 的 pkgPath 不是一个 Go 包而是一个 Go+ 的包,会怎样?
结果显然是无法识别。我们的解决方案比较简单,实现了一个 Go+ 版本的 packages.Load。
因为是 Go+ 代码,所以代码并不在 gox 中(gox 还是专注 Go AST 的生成),而是在 gop/cl/packages.go 文件中的 func(*PkgsLoader)Load 函数。
这个函数的基础逻辑如下:
先调用 packages.Load 来 import 依赖包。如果出错则 error 信息包含哪些包加载失败;
将加载失败的 Go+ 包编译成 Go 包。这个过程具体怎么做我们上一讲已经介绍过。最终我们会在这个 Go+ 包所在目录下生成 gop_autogen.go 文件;
重新调用 packages.Load 来 import 该依赖包。由于写入了 go 文件,所以它已经是一个合法的 Go 包,packages.Load 加载成功。
这里这个过程的逻辑很类似于 CPU 内存管理的缺页处理。先尝试加载,加载失败类似于缺页中断,中断后加载缺页的内存(这里则是将 Go+ 包转换为 Go 包),然后继续执行(这里则是重新加载 Go+ 包)。从 gox 模块的角度来看,它其实不认识 Go+ 的包,但又能进行加载,这个过程非常自然并且有趣。
但这里可能还有最后一个问题,如果依赖的 Go+ 包还没有下载怎么办?
在 Go 当中,早期是通过 go get 来进行包的下载,目前使用最多的方法是通过 go mod tidy 来下载所有依赖包所在的模块。
关于这个问题,我们的考虑是实现类似 gop mod tidy 的功能来实现 Go+ 包的自动下载,这个功能还没有实现,大家可以进行尝试。它的逻辑其实和上面的 import Go+ 包是很类似的。
- 如何管理 Go+ 模块
下面我们谈谈 Go+ 的模块管理机制。它有两种可能的选择:
基于 Go Module(go.mod) 管理
自己实现 Go+ Module(gop.mod) 管理
因为 Go+ 能在自己的目录中生成 Go 文件,来让自己模拟成 Go 包,因此可以借助 Go 的工具链以及 Go 模块管理机制来实现对 Go+ 的管理与使用。这是一个比较偷懒但相对容易实现的机制。
而自己实现 Go+ Module(gop.mod) 管理,与上述方式相比会有些不同,我们来进行一下详细的对比。
目前我们采用的方式便是基于 Go 的模块管理,它最大的优势就是容易实现、简单,不用额外做什么,躺平就行了。但劣势在于,编译一个哪怕最简单的 Go+ 程序也需要引用 Go+ 标准库,因为其中有一个特殊的库叫 buitin,也就是内建库。对这个库的依赖会导致要把对 Go+ 标准库的引用加到所有 Go+ 模块的 go.mod 文件中,这会让 Go+ 的使用者会觉得非常不方便,而且很容易出现各种奇怪的问题。
这个问题我们在考虑如何彻底去解决。目前的思路便是实现 Go+ 自己的 Module 管理,通过 gop.mod 文件来自动生成 go.mod。而更新 go.mod 的时机比较简单,当每次 gop.mod 文件更新时,我们便重新生成一次 go.mod。
所以对于 Go+ 模块来说,go.mod 文件就无需写在入库,因为它是自动生成的。自动生成中额外增加的主要就是对 Go+ 标准库的引用,它通过 replace 指令来实现。用 replace 我们可以做到引用的永远是本地的 Go+ 标准库,这相当于对 gop tools 与 Go+ 的标准库进行了一次自动对齐。
前面我们说容易出各种奇怪的问题,主要就是在基于 Go Module 机制下,gop tools 我们可能已经更新到最新了,但是 go.mod 文件里面的 Go+ 标准库可能是很老的版本,这种不一致有时就会产生奇怪的问题。
而通过 Go+ Module 文件自动生成 Go Module 文件,这样既可实现对 Go 工具链无缝的协同,复用了 Go 工具链,又可以解决 gop tools 和 Go+ 标准库版本不匹配的问题。
三. 练习题
首先我们更新一下公开课第 1 期练习题的状态。里面我们最期待被完成的 Range 表达式已经有人完成,最新的 Go+ 版本已经带了这个功能。我们在知识星球 Go+ 公开课中也介绍了这个新功能。欢迎大家到知识星球 Go+ 公开课详细了解。
- 基础练习
- 解决 gop cache 更新问题
issues 地址:
http://github.com/goplus/gop/issues/891
$ModRoot/.gop/gop.cache 当前只能感知到本 Module 内的更新,对于模块外的依赖如果发生改变,并不能正确进行检测。临时方案为手工删除 gop.cache 文件,欢迎大家来解决这个问题。
- import "pkgPath" 问题
issues 地址:
http://github.com/goplus/gop/issues/881
严谨来说,真正实现 import "pkgPath" 需要先加载依赖包来得到这个包的别名 pkgName。目前并不加载依赖包,而是简单 pkgName = path.Base(pkgPath)。这在大部分情况下是对的,但并不严谨。
这个问题比较简单,而且遇到问题开发人员很容易绕过(通过手工指定 pkgName),所以优先级不高,适合作为基础练习。
- import local package
issues 地址:
http://github.com/goplus/gop/issues/814
这个情况在实际环境中很少使用,只是从兼容性角度考虑希望增加该实现。它在上一讲中也作为练习讲过,这里不展开。
- 进阶练习
-
实现 gop mod tidy
issues 地址:
http://github.com/goplus/gop/issues/889 -
实现 Go+ Module(gop.mod)
issues 地址:
http://github.com/goplus/gop/issues/861 -
修改 packages.Load 本身,让多次 Load 之间可以共享依赖包
issues 地址:
http://github.com/goplus/gop/issues/810
我们也会协助大家解决训练过程中遇到的问题。联系方式如下:
- Go+ 用户群(微信群):可以直接在群里提出问题并@我,我会直接在社群进行解答;
- Go+ 公开课(知识星球):本次演讲的 PPT 及文字内容会同步在知识星球中,欢迎在上面提问交流。