
陈东坡:如何给 Go+ 贡献代码 & gop.cache 的作用和实现
一. 如何认识 Go+
其实给任何项目贡献代码,流程都差不多。在开源项目的世界中,每个不同的开源项目,都会有自己的开源社区,也会有自己的管理方式。但无论是通过邮件、QQ 群或者语音沟通,只要能依照社区的规范和要求,一般贡献代码的实质流程相差不会太多。
我有自己一个切身的体会就是,为了能更好的为社区贡献代码,最佳途径就是自己就是这个开源项目的使用者,“纸上得来终觉浅,觉知此事要躬行”,深度的使用会让你对对当前开源项目的理解程度更深入,也会不断熟悉,了如指掌游刃有余。
以我个人为例,我第一次接触 Go+ 源于之前做的一款英语学习的项目。算起来这可能也是第一个将 Go+ 应用在实产环境中项目,虽然只是一个很小的模块,但一直到现在还在持续给数十万用户提供稳定的服务。
之所以当时会选择 Go+,是为了解决一个实际的问题:我们需要清洗一部分线上日志数据来生成用户推荐的索引,这部分数据需要通过文本日志来分析,同时还有一个需求是是要处理字幕文本进行视频数据的分析和推送,也就是当用户打开 APP 的时候,对其进行一个视频流推荐。
对大家来说,这并不算是很复杂的需求,无论是 Java、Python、Go 或者其它任何语言都可以满足,我当时也对几类编程语言做了个选型对比:
这个对比很不严谨,仅代表我当时一闪而过的一些想法和一些尝试。
Java 工具很强大,但部署和发布相比没有那么方便,对于文本处理来说 Java 太重了。Python 很简单,第三方库也很丰富,但在配置 Python 时遇到了一点小问题,而且当时线上机器没有连接互联网的能力,如果能打包成二进制文件直接运行是最好的,显然在这个情况下 Go 比 Python 在运维上更有优势。
我个人很喜欢 Go 语言,GitHub 上也一直有关注老许的项目与账号。正巧看到老许发布的 Go+ 项目,便尝试用 Go+ 来实现这个功能,虽然是 Go+ 非常早期的版本,但对于解决我的问题来讲是完全 OK 的。
当时 Go+ 还没有文件处理的库,我所使用的版本和官方的仓库版本也有所不同,但移植 Go 标准库的文件处理能力很快,所以很快的便将 Go+ 这一门全新的语言应用到了生产环境中。
Go+ 不光有 Go 的语法糖,也让写代码的过程变得更加简单,比如说可以享受 Go 交叉编译、二进制分发的便利,又能享受到 Go+ 语法上的简单、高效,在错误处理上也更加方便。
为了说明 Go+ 的这种能力,我举个例子,假设读取一个文件并统计出现的字数,注意不光是字符数,这是一个真实的需求改过的来例子,之前处理字幕时要兼容英文和中文的情况。
如果是 Go 语言,我们应该怎么写呢?以上给一个简化的例子。
package mainimport ( "fmt" "io/ioutil")func main() { bs, err := ioutil.ReadFile("/tmp/file.txt") if err != nil { return } fmt.Println(len([]rune(string(bs))))}
这是一个很简单的代码,似乎用 Go 已经很简单了对吧,但如果是用 Go+ 来完成这个需求,代码会是什么样呢?我一般是这样写的。
import "io/ioutil"
bs:=ioutil.ReadFile("/tmp/file.txt")?:[]byte{}println []rune(string(bs)).len()
是不是感觉 Go+已经有明显的不同,更加直接了,当然如果愿意的话,上面的这个代码可以进一步的改写成:
import "io/ioutil"
println []rune(string(ioutil.ReadFile("/tmp/file.txt")?:[]byte{})).len()
这只是我从线上代码抽取出的一个例子,根据我的统计,Go 代码的书写量至少是 Go+ 代码的两倍。我一向的观点是,代码越少 Bug 越少,实现同样的功能,代码越少,能力就是指数级的多,老许经常提到的“Less is exponentially more(少就是指数级的多)”,几乎是编程界的普遍真理。
此前程序员领域有个戏谑的说法,“按行数工资”,但其实某些层面代码越多情况可能越糟糕,通过 Go+ 和 Go 的对比,明显可以看到 Go+ 可以帮助减少很多的代码。这背后的原因是 Go+ 本身处理了很多代码中可能出现的场景,从而使得代码量降低,这也是 Go+ 目前所走的路线。
我最早只用 Go+ 来处理日志,后面我将字幕处理等工作也逐渐用 Go+ 做了重构,确实很简单。实际看下来,用 Go+ 实现相关功能只用了不超过 1000 行代码,如果用 Go 来实现可能代码会膨胀的厉害。
在这个过程中,伴随着对 Go+ 的熟悉和喜欢,而且我非常认可老许的观点,更被他个人的勤奋和能力折服,同时他也给了我极大的正反馈。随着不断的深入研究和学习,现在也正不断的参与 Go+ 的维护以及开发工作。
之所以介绍我接触 Go+ 的故事以及对 Go+ 的理解,是想借这样的机会,吸引更多的朋友对 Go+ 产生兴趣。特别是本身在使用 Go 语言的开发者,完全可以用 Go+ 来尝试编写一些小模块。
二. 如何用 Go+ 进行生产
下面,来讲一讲我平时如何用 Go+ 进行工作生产。首先介绍下我平常的编程工具和环境:
我平时使用的 IDE 是伪装为 vim 的 vscode。使用 vscode 主要的开发语言就是 Go,其它编程语言涉及的很少,所以我的 vscode 插件并不需要兼容其它的开发环境和编程语言,连前端都很少涉及,所以很简单。我把自己用到的基本插件也列出来供大家参考:
2gua.rainbow-brackets
aldijav.golangwithdidi
donjayamanne.githistory
dunstontc.dark-plus-syntax
dunstontc.vscode-go-syntax
eamodio.gitlens
felipecaputo.git-project-manager
formulahendry.code-runner
golang.go
goplus.gop
k--kato.intellij-idea-keybindings
mcright.auto-save
nhoizey.gremlins
premparihar.gotestexplorer
quicktype.quicktype
rokoroku.vscode-theme-darcula
sirmspencer.vscode-autohide
SmarterTomato.locate-this-document
smulyono.reveal
sysoev.language-stylus
vscodevim.vim
zxh404.vscode-proto3
当然有很多的朋友使用的应该是 Goland,也有人在用 liteIDE,两个各有千秋,我就不做对比;顺便说一下,liteIDE 的作者七叶也是 Go+ 的核心贡献者。
目前 Go+ 的代码都是托管在 GitHub 上的,想要为 Go+ 贡献代码首先要有自己的开发账号,然后在本地配置好自己的 git 环境,需要提示大家的一点就是使用 git config 配置自己的邮箱要和 GitHub 上的邮箱保持一致,这样会在代码统计和溯源上更加方便。
下次这些动作是任何开源项目都类似的流程很类似,GitHub 做了很多工作,让我们提交代码变得很简单:
1、先在 goplus 的官方仓库 fork 到自己的代码仓库(准备工作)
2、将代码 clone 到本地目录
3、使用 vscode 打开,并更新 go.mod 等
4、使用 vscode 打开命令调色版(command palette),创建全新的代码分支,在这个分支上更新代码
5、使用 vscode push 新的代码到 github,之后可以 github 发起 pr
6、这里也请注意,我的习惯是 main 分支一起保持和官方仓库是一样的,直接使用 github 提供的 fetch upstream 保持同步,极大的减少了对冲突的解决。
在提交 commit 时,应该用英文来写,说明每次 commit 中改动的目的或者实现的功能,方便在做 pr 合并时做代码 reveiew,给 review 提供一个参考。
以上都是在说应该怎么样为 Go+ 提供代码,关键的问题是和上游仓库的一致性,以及提交 commit 时要用英文,避免出现乱码等问题。
当然根据每个人实际情况,可以按照自己的习惯来,也欢迎大家把自己在其它项目上的优秀经验带到 Go+ 的项目上来。对于复杂的代码合并,老许也会亲自进行评论与回复,分享一些编程过程中的细节问题,这些对于提升代码能力来讲有很大的价值。
三、gop.cache 的作用和实现
接下来我会结合对 gop.cache 的理解,更详细的谈谈代码贡献的过程,也让大家对这个模块有个稍微深入的印象和理解。
其实,在老许的 Go+ 公开课的第二讲中,提到了一个改进需求,说到一个问题,gop.cache 当前只能感知到本 Module 内的更新,对于模块外的依赖如果发生改变,并不能正确进行检测。临时方案为手工删除 gop.cache 文件,那我们通过这次的分析来顺便把这个问题解决掉。
我也是在听了老许第二讲后能意识到 gox 其实在整个 Go+ 工程中基石般的作用,不夸张的说 gox 决定了 Go+。而 gop.cache 也刚好在 gox 中,也就是我们需要改动的代码在 gox 这个项目中,所以我选择了 gop.chache 这个项目来对 gox 进行全面的梳理与理解。
在进一步的说明这个项目之前,这里我说明下 Go+ 现在的工作模式。
老许说过,编程语言是比自然语言更简单的表达方式,也可能是未来终极的表达方式。那么既然叫语言,他的作用就是要表达某种更底层的内容。这个更加底层的内容,在自然语言里叫“思想”,在编程语言里就是机器码(机器语言),就是 0 和 1。
Go+ 有两个引擎,一个字节码引擎 ,比如可以使用 goplus/gossa,这一部分现在七叶一直在改进,他有什么使用呢?说起来是挺简单的,就是把 Go 的代码当作脚本来执行,这个在很多场景都是非常实用的,比如让代码具备完全的热更新的能力,就更贴近游戏开发者的需求;
另外一个方式,也就是现在主要的工作模式,就是把 Go+ 的代码先编译成 Go 代码,再通过调用 Go 语言的强大工具链进行编译执行,也就是执行“源码->编译->连接->可执行文件”。这样的做的好处就是能最大化的利用 Go 语言的生态环境。在 Go+1.0 的发布会上,老许也曾对软件生态的理解做为阐释,让为生态资源兼容才能换来更高的市场,所以转换为 Go 代码是一种选择而不是折中。
把 Go+ 的代码先编译成 Go 代码,也就是 gox 做的事情。老许之所以将 Go+ 和 gox 分开,因为它认为 gox 可以作为独立的项目给其他项目或者语言使用,甚至去做一门全新的编程语言。
我今天讲的 gop.cache 主要就是讲基于后一种方式。而且还可以顺便说一下的是,由于我们可以实现从 Go+ 到 Go 的代码的等价转换,所以理论上所有的 Go+ 代码无论语法如何改进,都是可以通过字节码引擎方便的执行的。
对于任何一个 Go+的工程目录或者源文件,都会经历以下过程:
Go+ 文件 => Go+ AST => Go AST => Go 代码,这样最终编译执行还是 Go 的代码。
怎么理解这个过程呢?
老许在第一讲的时候提到 C++ 发明历史的一个小插曲,那就是 C++ 最早的编译器名字叫 C-front,也就是 C 的前端,这个版本的 C++ 编译器就是把 C++ 转换成 C,再由 C 的编译器完成编译。
对 C++ 不是很熟悉,我个人看法是这个有点像 Java 和其它基于 JVM 的语言。比如有很多基于 JVM 的编程语言除了大家可能都知道 Java,还有像 Scala、Groovy、Clojure 和 Kotlin 等等,他们在语法可有部分的相似、当然也可能也有很大不同,但最终都会由 JVM 来执行,也就是最终的结果都是一样的。
不过 Go+ 并不是直接使用了类似 Java 虚拟机的运行时环境,而只是通过 Go 代码来让整个项目运行起来,也就是说 Go 相当于 Go+ 一个中间执行层。理解这个逻辑,对于当前理解 Go+ 来说也是非常关键的。
我们言归正传,说回来,继续讲 Go +是怎么工作的。在 Go+的项目中,除了 gox 这个项目,还有个 gop 的项目,现在 gop 这个项目对应的工具的作用就是把 Go+ 代码“翻译”成 Go 的代码。并最终调用 Go 来进行执行。
也可以这样简单的说,gop build 最终调用的是 go build,同理 gop run 最终调用的还是 go run,只是在调用 go 的工具链之前先把 Go+ 的代码转化成了 Go 的代码。为了能更好的把 Go+ 代码翻译成 Go 代码,就需要有一些固定的转换的模式。这种模式类似于在不同语言的一种关系映射。比如:
I => 我
love => 热爱
coding => 写代码
有了这种固定的转换,我们可以在实质上就很容易把相同的意思进行转化。我们把对应接收信息做为最终的 run 的话,由“我热爱写代码”编译成"I love coding"的过程就是 gop 的主要的工作。
当然,我说的这个转换只是一种形象化的说明,并不是说 gop 的工作仅限于此,实际上 gop 做的工作非常复杂的,实现了一种等量代换。讲到这里,来试试我们试着想一个最简单的代码是怎么具体转换的:
println("hello,world!")
如果是 go 的代码应该是怎么样的?很简单对吧
package mainimport "fmt"func main(){ fmt.Println("hello,world!")}
我们来试着写最简单的代码
println => fmt.Println
首先要在代码中写分析出来,这个 println 有什么呢?有他的 package 名(fmt)和对应的原来函数(Println),为了突出今天要讲的内容,我们假定语法分析这部分的工作已经完成。也就是我们知道当出现 println 这个 token 时,就自动的引入这个 fmt,那上面的 Go+代码转成 Go 代码的过程就可以用下面的方式来表达。
以下代码来自老许在第一期公开课中的例子,怎么样用 gox 生成代码:
大家可以看到第二行的 pkg.Import("fmt"),这个时候只是一种声明,是一种懒加载,这个时候还并没有真正地加载包。gop.cache 真正的包加载是在上图中第二个方块里面。
当讲到这里的时候,就可以明确和大家来说明了。gop.cache 的作用就是保存这种函数及其对应的 package、函数参数及返回值等的信息。保证在多次使用的场景时,不需要重复的对包进行加载,从而让这个编译的速度更快。
如果每次调用或者编译都 Load 一次,成本是很高的,稍微大一些的项目基本会耗时 1 - 2 秒钟。gop.cache 最终缓存的数据结构如下图所示:
其中,ID、PkgPath 是包的唯一值;Vars 是变量、Consts 是常量;Fingerp 是 gop.cache 在每次 Load 时进行对比的值,如果这个值没有发生变化,就不需要重新调用 PkgPath.Load 进行加载。
下图是上述文件对应的 json 值:
gop.cache 文件存在于项目的根目录下,打开这个文件可以使用 unzip 命令:
unzip .gop/gop.cache
在当前文件夹下就会解压出 go.json 的文件,打开后看到的便是上图所示的 json 列表。大家可以看到其实最终保存的结构体位于 gox/persist.go 的 persistPkgRef 的结构体中。
上图中的代码便是 gop.cache 用来对比指纹,通过对比每一个 package 的 fingerp 来决定 package 是否重新 load 的函数。如上图第 99 行代码所示,便是针对一个普通的包,只需要记录 package 名和版本号作为指纹便可。
第 91 行判断了是否是本地的包,对于本地的包没有直接进行计算,而是记录了需要计算的因子。计算本地文件 fingerp 的方式也很简单,拿出最新记录的 files 文件和缓存的 fingerp 进行对比即可。如下图所示。
有了这样的 gop.cache 便可以知道,从 load 生成了一个巨大的包含了 package 的结构体就会缓存下来,每次进行变更。如果是本地文件便去对比本地文件中的更新时间,来提取指纹;如果是第三方包,便通过引用第三方包的 package 名和版本号进行更新。这样可以比较好的解决第三方包引入的问题。保持 Go+ 到 Go 翻译对照表的一致性,会让编译变得更加准确。
下图是指纹对比。这部分比较简单,如果是本地文件就检测是否存在于本地,如果是第三方包则进行一下版本号的检测(第 379 行)。
在实现第三方包变更时,碰到个很有意思的事情。replace 语法既可以指向本地文件,也可以指向第三方库中的别名文件。比如假如 go.org 库不太容易访问,其实可以改成 GitHub 中某一个包来使用。所以如第 91 行代码所示,无论是何种文件都可以进行判断,并进行相应的调整。
其实 gop.cache 的作用正如我上述所讲:从 Go+ 到 Go 代码翻译时需要有一个对照表,而 gop.cache 便提供了所需的翻译对照表。生成的方式是借助 package.load,生成后由 gox 缓存下来。缓存后通过对比指纹,判断是否需要更新或重新调用。
可能大家是第一次接触 gop.cache,之所以用 gop.cache 就是为了避免包的重复加载,让编译速度更快。
有的朋友会问,如果对代码不熟悉应该怎么入手才能找到这个生成的位置呢?一个项目从陌生到熟悉这个过程是必然的,我平时也会遇到。不过我的答案就是找规律,不断总结规律就能更快的适应项目。
我在拿到 gox 需求时,也是第一次接触这里的代码。当时要找 gop.cache 入口文件有两种方式,第一种是比较慢的 debug,第二种是全局搜索 "gop.cache" 字符串。
比如,在 vscode 中,全局搜索 "gop.cache",很快便可以定位到该文件位于"./gop/cl/packages.go"文件的 initPkgsLoader 函数中,这个函数调用了位于 gox.OpenLoadPkgsCached 函数,这样通过几次跳转定位到"./gox/persist.go"的 loadPkgsCacheFrom 函数中,到这里,我们基本上就定位到了关键的代码位置。
当我们对整个项目比较陌生,上面的使用是非常高效的的做法,当然经过很多次的熟悉后,就不需要这样操作,可以直接定位到项目。
到目前为止,大家基本清楚了 gop.cache 的基本信息。老许之前分享过一个观点,“Go+ 作为一门编程语言,和自然语言的逻辑很像。”这个概念从我们的翻译过程中也有体现。
既然 gop.cache 是某种缓存,那就是这个翻译的对照表也是有可能会发生变化,当发生变化时需要同步的更新翻译生成的最后的文件。这个翻译对就的对照表是从哪里来的呢?来源有三个
Go 的官方库
本地文件
第三方包的引入。
针对官方库可以不做处理,只要不升级 Go 就不会发生变化(也应该变化,不过不升级也没有关系);本地文件现在是什么文件的最后更新时间做为标识的;第三方引入的包,可以使用 version 来做为变化的标识。
所以,我们的需求就很明确了。对于本地文件,很简单就是使用文件最后的时间。对于第三方包,就是使用来 go.mod 的标识,就足够了。
可以看下项目中代码的具体实现,针对本地文件
func calcFingerp(files []string) string { var gopTime time.Time for _, file := range files { if fi, err := os.Stat(file); err == nil { modTime := fi.ModTime() if modTime.After(gopTime) { gopTime = modTime } } } return strconv.FormatInt(gopTime.UnixNano()/1000, 36)}
而针对第三方引入的包
&pkgFingerp{fingerp: loadMod.Path + "@" + loadMod.Version, versioned: true}
值得注意的是,在 go.mod 中的有 replace 语法,如果 replace 语法对应的是本地的文件目录,而不是来自第三方的引用,也需要最终调用针对本地文件的指纹方法。
这样就很明确了,如果我们想修改这里的内容,只要调整这 load 代码中比较指纹相关的函数就可以了。基本上到这里,我们就理解了 gop.cache 的作用和实现方式。