
许式伟: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函数在设计上有比较大的问题。这主要表现在:
1.重复加载的开销
虽然尽量单次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的加载时间甚至到了秒级,很慢很慢。因此,这是一个需要解决的大问题。
2.多次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标准库,那么就需要计算依赖包的指纹。如果指纹发生变化,则认为依赖包发生变化。如何计算指纹?它包含两种情况:
1.如果依赖包属于本Module内的(代码在$ModRoot下),那么我们需要枚举files(文件列表)后根据每个文件的最后更新时间计算指纹。算法详见:gox/import.go的func calcFingerp函数;
2.如果依赖包不属于本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+模块
1.如何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+包是很类似的。
2.如何管理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+标准库版本不匹配的问题。