
许式伟:再谈 Go+ 模块管理
大家好,今天是 Go+ 公开课总的第十期,也是 2022 年的开年第一讲,首先祝大家新年快乐。
这一期的分享主题是「再谈 Go+ 模块管理」。熟悉 Go 的朋友应该知道,Go 的模块管理成熟的比较晚,并且经历了几次大的迭代。最典型的一次迭代是引入 vendor 机制,事后证明这是一次并不成功的模块管理的探索。直到出现 go module 后,才真正实现了模块管理的统一。
到了今天,可以说在 Go 中不使用 go module 已经是过时的操作,不符合现代 Go 开发的模式。模块管理无论对于 Go 还是 Go+,都有着至关重要的地位。
Go+ 1.0 版本于去年 10 月份正式发布,这是一个重要的时间节点,我们实现了绝大部分 Go 的语法,将对 Go 语法的兼容做到了相对极致的水平。
最直观的体现是我们将 Go 官方提供的开发测试案例,在 Go+ 中全部跑通。这是我认为比较关键的一个里程碑,代表 Go+ 在工程上已经具备了相当的能力。
在 Go+ 公开课第二期中,我们就曾谈到过模块管理,之所以这么早便谈论这一话题,因为模块管理确实至关重要。Go+ 的迭代周期和 Go 类似,都是以半年为单位。在去年 10 月份发布 Go+ 1.0 版本后,今年的 3 月份我们预计会正式发布 Go+ 1.1 版本。这个版本最重要的能力更新,便是 Go+ 在模块管理能力上的完备。在这里特别强调一点,Go+ 语法中最重要的 ClassFile 功能,也和模块管理有着非常密切的关系,新版本对于 ClassFile 也将实现更加完备的支持。
在即将更新的 Go+ 1.1 版本中,我们还希望对 Go+ 的 IDaE 插件进行一定程度的优化,以及 Go+ Spx 游戏引擎的更新。Go+ spx 目前已经基本迭代到 1.0 阶段,只剩最难实现的 ask 组件没有完成,因为要用到文本框这种特殊的控件,所以开发工作量较大,但已经理清实现的思路与逻辑。
回到今天的主题。之所以单独用一期的内容对模块管理进行分享,因为这是 Go+ 1.1 版本中内核层面最重要的一个迭代。今天的内容主要分为四部分:
模块管理涉及哪些范畴?
Go 模块管理
Go+ 模块管理
练习题
一. 模块管理涉及哪些范畴
谈模块管理的范畴之前,我们先看一下 Go 的模块管理。
Go 的模块管理中,我认为有三个核心内容。
首先是版本规范。在编程语言中,模块管理最基础的就是版本的管理以及版本号的规范。
第二个核心内容是模块的发布基线,也就是输出的确定性。模块发布首先要寻找的是可重复性,这指的是编写的模块哪怕在十年后再次编译,得到的结果也是一致的,这种实现可重复性的规范便是发布基线。基线的概念比较抽象,简单理解就是 tag,代表一个模块只读的状态。
模块的发布基线包含几个不同层次的概念:首先是模块本身的 tag,对模块打 tag 后就代表模块本身的代码是确定的。但单独确定模块本身的代码是不够的,模块本身代码外的不确定性也会导致模块结果的不确定。因此,发布基线还会涉及模块依赖包的 tag。
只要模块本身代码及依赖包的 tag 是确定的,从模块源代码的角度看模块基本就是确定的,也就相当于完成了模块的代码基线。
从输出的确定性讲,在这两者之外还会涉及到模块环境的 tag,包含操作系统、编译器等。我的 PPT 中在模块环境前标注了一个(N),原因是编程语言版本模块管理的发布基线不包含这一部分,但 Go 的杀手级应用 Docker 对这部分有关注。
模块本身的基线乘以环境的基线,可以得到所有可能编译的结果。对于 Go+本身 而言,基本上我们所有模块的测试案例都会在各个平台进行编译和测试,这其中便会涉及到交叉编译。模块基线环境基线二者相乘便构成了我们所有需要测试的二进制包的内容。
第三个核心内容是包的访问协议。这也是很重要的一点,指的是我们通过一个地址就能够确定一个发布基线。这里有两个 Go+ 的示例:
- gop run github.com/goplus/FlappyCalf
- gop run github.com/goplus/FlappyCalf@v0.7.1
gop run 一个地址,便可以直接拉取源代码并运行,如果地址中不带版本号便默认使用最新的版本,同样也支持手动添加版本号。这是本次模块管理迭代后很重要的一个更新。
Go+ 的模块管理相对 Go 而言有所增加,其中最重要的就是此前提到的 ClassFile。ClassFile 在 Go+ 中是通过包管理(gop.mod)实现的,这就涉及到如何注册 ClassFile。
在工程中使用 ClassFile 需要先进行注册,否则 Go+ 编译器无法识别 ClassFile 支持的文件格式,当 register GopModPath 后,编译器才可以自动发现并支持 ClassFile 支持的后缀名。
此外,如果某一个 gop.mod 中出现了关键字 classfile,就代表这个模块实现了一个 ClassFile,设计方或者实现方需要指出支持的工程文件 ClassFile 后缀、工作文件 ClassFile 后缀分别是什么,以及实现 ClassFile 的包的名字,从而声明一个 ClassFile,使得客户可以 register 对应的 ClassFile 并使用。
这个过程也是通过 Go+ 的模块管理设计实现,在 Go+ 公开课第 7 期「Go+ ClassFile 机制详解」中有具体的介绍。
有一个细节需要和大家强调一下:支持 ClassFile 也就意味着 gop/parser 也和模块管理有关。Go 的 parser 和模块是无关的,不依赖 go.mod 文件;但 Go+ 的 parser 需要知道哪些后缀名属于 Go+ 的代码,这导致模块管理和很多东西耦合在一起,牵扯面非常广。
了解完背景后,我们来看一下 Go 的模块管理。
1. 涉及的模块
Go 中与模块管理相关的包很多,但因为 Go 的编译器很多能力并没有开放,可调用的主要便是下述四个:
golang.org/x/mod/semver
该模块聚焦于版本号部分。semver.org 是业界版本规范的标准,这个模块的命名也符合版本规范。功能方面,这个包主要实现版本号的解析(parse)和版本号的相关支持,最重要的一项是比较两个版本号的高低(compare)。
golang.org/x/mod/module
该模块的名字看起来很大,但实际是模块管理中很小的一部分,定义了模块的访问协议的地址,也就是 modPath + version。
golang.org/x/mod/modfile
该模块用于解释 go.mod 文件文本格式的解析及 DOM 操作,包括 parse、format。其实所有的文本处理模式都是一致的,由源代码、parse 转换成 DOM,也就是所谓的 AST 抽象语法树;针对抽象语法树操作后如果想转回文本,便需要通过 format 进行格式化。parse 和 format 可以理解成互为反操作。
golang.org/x/mod/sumdb
该模块为模块访问协议的管理中心。访问协议是模块管理中非常核心的部分。类似 Linux apt 之类大部分的模块管理工具都是中心化的,但 Go 和 apt、Docker 之类相比,虽然一样也有类似中心化的管理平台工具,但机制有所不同,一个细节是 Go 的管理中心只有 metadata 没有应用程序本身。当然最重要的区别是 Go 的管理中心可重建,可以将其认为是一个缓存。
Go 的包管理是极其分布式的,类似 GitHub 这类主流的源代码托管仓库都属于 Go 的源代码托管平台。而 Docker 之类则会使用 DockerHub 这类相对中心化的管理模式。Go 当中的管理中心,只是一个可重建的管理源数据的中心,实际上是一个缓存而不是存储。
2. 涉及的命令
上图为 Go 中涉及到模块管理的命令。
模块管理在一门语言中之所以非常重要、牵扯面广,因为几乎涉及了语言中所有的指令,是一项全局性的能力,这也是之所以模块管理本身的实现非常复杂的原因。
前面我们谈 Go 模块管理涉及到的包看起来似乎比较少且功能都相对单一,与其他功能的耦合性也不强,但这只是因为 Go 中大部分耦合性较强的模块并没有抽象成一种标准能力供大家使用,只存在于编译器私有的代码中。
但通过上图罗列的这些命令也不难发现,它涉及到的东西是非常多的,基本涵盖了编译器中所有的模块,基本覆盖了所有的核心能力。
这些命令可以大致划分为三个类
必须在某 module 下执行的
go build [packages]
go test [packages]
go fmt [packages]
go list [packages]
go clean [packages]
上述命令不能随便通过一个目录来执行,必须在某个 module 下才可以执行,也就是说必须在目录或者父目录中包含 go.mod 的状态下才可执行。
上述命令支持的包的格式是很丰富的。本地的包可支持相对路径和绝对路径,对于相对路径要求必须以「.」开头,代表是一个相对路径。Go 通过对包中前几个字符的语法解析,判断是本地还是远程的包。
此外,无论是本地还是远程的包都支持通配符,通过 「/...」代表对应包及所有的子包都在包的列表中。
可以在 module 外执行的
go get [packages]
go install [packages]
这两个命令都可以在 module 外执行,可以在任何一个目录去 install 或者 get 对应的包。在 module 之外,便不再能支持本地的包了。支持的 package 可以是 pkgPath(go install 不支持,但可以支持) 或者 pkgPath@version,如果不带版本号便会匹配最新的版本号。
在 module 外执行也可以支持通配符,也就是支持 pkgPath/… 或者 pkgPath@version/...,
需要注意的是 /... 的位置在 @version 之后。
go run package [arguments]
这是最特殊的一个命令,只需要输入一个 package 即可。上述所有的命令都支持 package 列表,并且可以不进行指定选择当前默认的 package。
这个命令既可以在某 module 下执行,也可以在 module 外执行,所以前面提到的规则也都适用,并可通过指定支持文件系统通配符(* 和 ?)的 .go 的源代码来指定一个包。
模块在执行时一定是有基线的,否则编译器无法判断要调用的代码是什么。go run 命令之所以可以在模块外执行,原因是使用了全局默认的 module。这个 module 中只支持 import 标准库不引用任何第三方的包,而标准库不是源代码的基线,属于环境基线(操作系统 + 编译器都属于环境),所以不存在基线问题。
今天的分享主要想从结构化的角度,来帮助大家理解模块管理,看起来相对基础,但十分重要。通过这部分内容可以基本理解模块管理背后的运行逻辑,核心便是基线。这一点也是 Go 在不违背基线的情况下,可以在任何地方执行代码的原因。
分享到这里我还没有谈太多实现相关的内容,因为调用这些包的人相对较少,也很少有人会去操作 go.mod 文件,但理解这些命令背后的执行逻辑是很重要的。
二. Go+ 模块管理
在 go.mod 的基础上,Go+ 引入了 gop.mod,并同步支持 go.mod 的使用。但在 Go+ 中使用 go.mod 会存在一个问题,需要主动的引用 Go+ 模块,让 Go+ 编译器将 Go+ 看成一个第三方库,使得 Go+ 模块对 Go+ 标准库的依赖变成一种环境依赖。
此外,Go+ 的编译器和 go.mod 中指定的 Go+ 的版本可能存在不一致,比如在 Go+ 1.1 版本中指定 Go+ 1.0 版本,类似的问题主要考验版本库的稳定性以及版本兼容的能力。
引入 gop.mod 后,解决了两个最核心的问题。第一个是增加对 ClassFile 的支持,第二个是可以通过 gop.mod 自动生成 go.mod。这个 go.mod 是不建议入库的,生成的代码中会包含环境相关的指令,如:
require github.com/goplus/gop vX.X.XX (在 gop.mod 中添加版本号)
replace github.com/goplus/gop => $GOPROOT(替换 Go+ 的包地址为本地的根目录,该指令和本地环境相关)
所以自动生成 go.mod 可以解决版本不一致的问题,这意味着 Go+ 模块对 Go+ 标准库的依赖变为环境的依赖,而不再是对第三方库的依赖。这个很重要,因为 Go+ 标准库不应该在模块管理本身的模块依赖范畴中,引入 gop.mod 会让 Go+ 的代码写起来更加轻松。
分享两个 gop run 的实例:
gop run github.com/goplus/FlappyCalf
gop run github.com/goplus/FlappyCalf@v0.7.1
Docker 大家都很熟悉,最常使用的一个命令是 docker run,也就是运行一个 Docker 的包,而从 Go+ 1.1 版本开始,Go+ 支持了版本管理中包的访问协议,大家会觉得 Go 和 Go+ 做了 Docker 做的事情。
事实上确实如此。Docker 最大的一个优势是跨语言,任何一个生产力工具都可以通过 Docker 来实现标准化,这对云计算而言是很重要的。如果面向企业内部研发,不一定需要借助 Docker,用 Go 和 Go+ 中类 Docker 的能力便可以直接通过访问的地址来运行一个包,实现和 Docker 一样,不需要考虑如何下载等复杂的操作。
(Aircraft War 运行实例)
(FlappyCalf 运行实例 // 指定具体版本)
上面的两个截图是 gop run 的具体运行实例。第一个例子是 Go+ 1.0 发布会中提到的「飞机大战」,目前在 Go+ 官方仓库中有对应的子工程,安装完 Go+ 后便可以直接 gop run,也可以指定具体的版本号,方便大家试玩。此前例子存放于 Spx 的仓库中,后期我们会将所有的案例全部独立成一个单独的仓库,方便大家在原基础上进行 fork、进行调整。
用 Go+ 开发的游戏最大的特点便是使用了 ClassFile,Spx 便是 Go+ 的第一个 ClassFile 应用。Go+ 通过 ClassFile 实现低代码,也选择游戏开发场景作为第一批示例 demo。对于用户而言这也是学习 Go+ 非常好的一个切入点,大家可以参考 FlappyCalf、飞机大战之类的游戏来编写一些自己喜欢的项目。
目前 Go+ 仍然处于「专家模式」,需要基于 VSCode 来进行开发。Go+ 团队正在制作类似 Scratch 的 IDE,帮助大家更方便的通过 Go+ 制作小游戏。
1. 涉及的模块
Go+ 模块管理的实现是相对开放的。上述我们提到的 Go 涉及的四个模块,都是非常原子性的,基本上没有复杂耦合的基础组件,用户也很难用这些组件来自己构建项目。
但 Go+ 的开放性是比 Go 高的,例如 Go+ 中模块管理相关的模块基本都是开放的,目前都放在 gop/x/... 的目录下。地址中的 x 代表对应模块的规格暂时并不稳定,协议可能还会更改。但我们会尽量保证协议的兼容。
Go+ 涉及的模块比 Go 要多很多,其中和 Go 区别较大的主要有如下几个:
第一是 modfile,也就是 gop.mod 的文件解析与 DOM 操作。gop.mod 的语法和 go.mod 是一致的,所以在解析器部分的代码有很大程度的复用。在复用的基础上,我们最重要的改变便是增加了 ClassFile 中两个指令 register 和 classfile 的支持。Go+ 的 modfile 只做了对基础能力的构建,不涉及任何与语法无关的内容。
在这一基础上,我们比较浅层的包装了 modload 模块。这是 gop.mod 的高阶操作,会涉及到一些高阶指令的实现,可以看做是 gop/x/mod/modfile 的扩展。
这两个模块基本只涉及到 gop.mod 本身,不涉及其他额外的部分。
模块 gop/x/mod/modfetch 实际上是 Go+ 模块的下载和缓存。在 Go+ 1.0 版本不涉及到模块下载的概念,当时只编译本地的包而不编译远程的包,这意味着不需要关心缓存管理。这里的缓存属于编译缓存,不属于模块下载和模块本身的缓存。
gop/x/gopmod 是 Go+ 模块管理中最核心的包之一。大部分的核心逻辑都在这个包中,比如 parser、编译器等等都依赖于这个包。
此前的三个模块都是基础能力,属于比较独立的零部件,耦合较少且相对简单,但 gopmod 模块需要考虑编译器、考虑 parser 的需求,相对比较复杂。此前我们提到 Go+ 的指令列表可以认为是 Go 的超集,基本上将 Go 指令中的 go 换为 gop 便能完全兼容。这也是 Go+ 坚持的一种理念,兼容 Go 语法的同时也要兼容 Go 的工具链、命令行、指令、参数等等。所以我们没有展开讲 Go+ 的指令,只是简单举了几个例子来说明。
这些指令的实现都和 gopmod 有关,这个包也是理解 Go+ 模块管理非常核心的一个包,包括 ClassFile 扩展名的注册、ClassFile 的自动发现等都存在于这个包当中,使得很多包都依赖于这个包。
编程语言的源代码中,和模块管理最相关的是 import,Go+ 中同样如此,这也是 Go+ 公开课第二期的主题选择「import 过程与 Go+ 的模块管理」的原因。
gop/x/gengo 是一个很重要的特例模块。在 Go 的基础上,Go+ 指令最简单的使用方式是调用 gengo 模块将 Go+ 的模块转换为 Go 的模块,从而调用 Go 的工具链来完成。这个模块在工具链中大家会反复调用。
Go+ 中包的范式统称为 project。在 Go+ 中,通过 project 来统一将远程地址、本地地址以及直接指定 Go 源代码的地址转换为 Go+ 的项目,再交由 bulid、install、test 等指令进行设计。这部分工作我们抽象为 gop/x/{gopproj, gopprojs} 包。
做编译器的周边项目是一定会和 gopmod 打交道的,我们通过这些包将 Go+ 模块管理的能力较为全面的开放,方便大家制作例如 VSCode 的插件或者其他语言周边的工具链。这也是 Go+ 开放性的一个体现。
2. Go+ 模块管理的重构
这部分的内容也是非常重要的。Go+ 1.0 基于 Go+ 0.7 版本进行了一次大重构,Go+ 1.1 版本则是在 Go+ 1.0 版本的基础上再次进行了一次较大的重构。其中最重要的就是比较完整地重写了 Go+ 的模块管理,解决了几个重要的话题:
模块加载的缓存 (gop.cache)
在公开课的前两讲中我们介绍了 gop.cache,这是很核心的一部分内容。在 Go+ 1.0 中使用了 Go 中的一个扩展包 tools,当中包含加载 Go 模块的一个工具,但那个工具很弱,加载速度特别慢,这使得我们被迫实现了加载的缓存,来改善加载的速度。对缓存的重构实际上是一个删除代码的过程,过程中我们删减掉了很多代码。
让 gop.mod 成为编译过程的 Context
gop.mod 基本已经成为编译器过程的 context,也就是上下文。如何更好的去表达这个上下文是很有讲究的,也是我对 Go+ 1.0 版本中不满意的一个地方。
关于模块分解的艺术
这个话题比较抽象,是所有架构都应该考虑的问题。下面我们分别来进行详细的讲解。
1)模块加载的缓存(gop.cache)
在 Go+ 1.1 版本中,我们完全放弃了额外的缓存机制,移除了 gop.cache。下面我来介绍下具体实现的过程。
我们的编译速度相较之前有了大幅提升,原因在于 Go+ 1.0 版本中我们实现了模块局部依赖包的加载缓存,也就是 gop.cache,但同时也带来了蛮多的额外负担。
在 Go+ 1.1 版本中,我们让 Go+ 共享了 Go 编译器的各类缓存。Go 的编译速度特别快,快的原因便是包含的各类缓存。此前 Go+ 没有直接使用这些现成的缓存,是因为没有想清楚如何复用,想清楚后享受到了很大的好处。
我们以两个最核心的缓存为例。第一个是下载缓存,也就是 gop get 一个远程的包,下载对应的模块。如果 Go 下载过某个模块,Go+ 就可以直接使用该模块,无需重新下载。并且很巧妙的一个惊喜是,Go 不只可以下载 Go 模块也可以下载 Go+ 模块。
这个的实现原因有一定巧合的成分。Go 的模块管理只判定模块的后缀,无论是 go.mod 还是 Go+ 中的 gop.mod 都会被认为是 Go 的模块,这意味着 Go 在不编译的情况下会认为 Go+ 的模块是 Go 的合法模块。这个巧合使得 Go+ 的下载可直接复用 Go 的下载,这个逻辑使得 Go+ 获得了很大的收益。
另外,Go 编译过某个包,Go+ 也可以直接使用,无需重新编译该包。这使得 Go+ 完全没必要去实现模块局部依赖包的加载缓存。
具体的操作是在 Go+ 1.1 版本中,我们不再使用 golang.org/x/tools/go/packages,这个函数背后调用的是 go list 命令,但这个命令的执行速度非常慢。我们改用了一个类似的包 github.com/goplus/gox/packages,背后用的是 go install -x 命令,它会打印 goMod => goModCachePath,从而获得映射关系,然后便可以调用 golang.org/x/tools/go/gcexportdata 读取包的导出信息。
这是 Go+ 1.1 版本很重要的工作之一,相当于完全复用了 Go 所有的缓存。
2)让 gop.mod 成为编译过程的 Context
此前 Go+ 的上下文基本通过结构体和全局变量来表达,比较复杂。在 Go+ 1.1 版本中我们将上下文替换成了 interface,使得 Context 的 struct 字段数量大幅度减少。比如最底层的 gox 模块,和包管理相关的最重要的变量只有一个 import,也就是 Go 标准库定义的一个接口。
模块管理支持的最重要的能力是 import,之所以复用 Go 标准库 import 这样的 interface,原因便是 gox 只需要依赖 import 的能力,不需要关心如何实现,从而让 Context 变成一个接口,而不是实现相关的结构体。这是一个重要的简化,依赖 interface 而不是依赖结构。
当一个 interface 只有一个方法时,就可以简化为一个闭包,所以可以将闭包看成是一个特殊的 interface。Go 的 parser 不需要依赖模块管理,但 Go+ 的 parser 需要去识别扩展名才能支持 ClassFile。我们在 parser 的 Context 中添加了 IsClass,用来判定某一个扩展名是否为 ClassFile 的扩展名。IsClass 中的第一个 bool 文件判定的是 ClassFile 扩展名的形式,判断是工程文件的扩展名还是工作文件的扩展名。
编译器则有两个依赖。首先是 import 相关的能力,编译器背后会依赖于 gox 的包,而 gox 依赖于 import,那么编译器必然也依赖于 import。
另一个依赖是 LookupClass,这是一个更强版本的 IsClass,可以通过扩展名查询 ClassFile 的信息。因为 Parser 其实不关心 ClassFile 的具体实现,只关注扩展名是否是自身感兴趣的,但编译器就必须知道 ClassFile 的细节,因此使用了 interface 来让上下文更简单
3)关于模块分解的艺术
我看过很多人做架构、做模块的拆解,但在当中通常都能看到很多问题。Go+ 的模块管理特别能够体现架构能力,因为模块管理太全局了,任何一个架构中最难拆解的就是全局性的功能,模块管理则是当中最典型的场景。
我之所以不厌其烦的分享这件事,就是因为这件事非常重要。通过前面我们提到的模块管理涉及的命令便可以看出,模块管理跟所有的编译器功能基本都相关,是一个非常全局的功能。
那么一个全局的功能应该如何拆解?这是一项艺术,因为设计的东西必然会很多。
上图中罗列的是所有和模块相关并提炼成标准库的部分编译器,还有部分由编译器直接解决所以没有列出来,涉及面还是非常广的。
如何将和很多高耦合的东西抽丝剥茧,变成一个个独立的部分,让整个系统的耦合变到最低,是特别有意思的话题。非常建议大家认真思考下为什么拆解后是这样的一系列模块?为什么这些模块中定义的函数是这个样子?
关于拆解我总结了几个观点:
其中第一条看起来比较基础,但却是判断一个模块拆解是否合理的核心依据 —— 模块规格要体现业务。模块对外提供了哪些函数,通过函数基本就能知道模块的功能和用途。
第二条更加具体。Go+ 此前使用全局变量作为整个编译过程的上下文,最典型的两个变量是:
parser.RegisterFileType // ClassFile 支持的一个功能
cl.RegisterClassFileType
这两个变量通过名字可以看出,是被用来注册 ClassFile 的扩展名。但存在一个问题,他们实际上是通过全局变量传递上下文,也就意味着编译器同时只能编译一个模块,不同模块的 ClassFlie 可能是不一样的,所以需要修改全局变量。
但修改全局变量是很复杂的事情,对于这个事情我们之前也没有加锁,所以存在并发竞争问题。并行编译是大型工程中非常关注的话题,Go+ 1.1 版本为并行编译打造了一个重要的基础,消除了所有的全局变量和全局状态。