历史包袱上进行golang包管理

96
suoga
0.3 2017.04.06 10:57* 字数 3695

包管理的重要性不言而喻。随着项目的推进,没有合适的包管理,每一次迭代都将成为开发者的噩梦。尤其是对于进行持续集成的项目,自动化应该深入骨髓。golang的import是其一大亮点,但也是它最被人诟病的缺陷之一。在最近的vendor化改造中,我对此深有感触。

Begining Of The Story

It was a drossy afternoon... 我正在啪啪啪地写BUG,同事A走过来拍了拍我的肩膀说:“我给你发钉钉怎么没回?快来帮我看看编译不过是怎么回事,有个冲突我感觉解决不了了……”

我问:“你搞了啥?”

A说:“我就是下了份最新的代码,然后就make不了了。”

我内心:“……”

其实这个问题早就是一个隐患了

我们的go项目并没有做包管理,只是写了Makefile去进行编译,原因有很多:

  • golang目前还没有比较权威的包管理工具
  • 官方并没有给出包管理的best practice
  • 项目开始时golang版本甚至不到1.5
  • 从小需求开始,并没有考虑到项目会变得非常庞大

当时我们项目也算是我们部门的第一个go项目尝试,并没有太多golang工程方面的经验,一切都是在尝试。我们当时觉得golang import的方式很好,能直接import github或者公司内部gitlab的包,不用搞大一统把所有依赖到的第三方代码都check in到项目的repo里,这对我来说很有吸引力。而且我曾经参与过多个Node.js项目的开发,用npm进行包管理令人印象深刻,非常方便。考虑到我们只是个试验性的小项目,即使被import的第三方包有breaking APIs,对于我们这个小项目是可以跟进修改的,因为大多数第三方包的迭代都会使性能上升BUG量下降,这种成本可以接受。其实主要还是觉得我们是个小项目,怎么搞都行。

所以我们选择用Makefile的方式进行编译打包,每次构建都会:

  • 调整GOPATH
  • go get 依赖的包
  • do something
  • 产生最后的二进制文件

一切顺利,我们用这种方式迭代开发了将近半年,直到文章的开头,我的同事来找我……是的,这半年中项目的发展已经超出了预期。我们从一个小的试验性项目发展为了许多产品线数据的入口,有无数的消费者对接我们的数据,也提供了很多很多的API给其它系统。俨然成为了anothercolossal project

项目有很多人参与进来,很多人在添加不同的功能进去,你很难再对整个项目有个全局的把控,你甚至不知道他们加了什么代码,总之上线之后不影响我的接口就好。

单纯用Makefile的缺点有很多:

  • 每次去go get拉取package,如果package更新了且不向下兼容,就会是个大问题。
  • 每次go get非常耗时,编译很慢,因为github非常慢

还有很多其它缺点后面会讲到,但是到目前为止这就是Makefile的缺点。但是以上两点并不是无法忍受,因为我们绝大多数包都是引用的内网gitlab上的包,是可控的,速度也很快。即使少数的github包,运气很好它们也没有出现不向下兼容的问题。

但总有漏网之鱼

同事A这次添加新功能之后进行编译,刚好依赖的某个包发生了break APIs的现象。这让我不禁想起了著名的墨菲定律:

Anything that can go wrong will go wrong
如果一件坏事可能会发生,那么它终将发生

Vendorize

英语里应该是没有vendorize这个词的,至少我瞄了一眼百度翻译中是没有的。

其实就是vendor化的意思。

要解决这个问题,这个隐患,必须要进行包管理。我在写这篇文章的时候已经完成了项目的vendorize改造工作,但此时此刻,golang的世界中依然没有比较权威的包管理方式。但是我们的思路很一致,就是紧跟官方的节奏

在golang1.5之后,go的import新增了支持从项目下的vendor目录查找依赖,而不单单是从GOROOTGOPATH中查找的功能,而且会优先从vendor目录来找。这给了我们一个新思路——可以把项目的依赖全部放到vendor里

把项目依赖放到vendor目录里也有两种思路:

  • 把项目依赖的所有代码都下载下来放到vendor里,依赖也加入git管理
  • 像npm一样,只管理一个用于描述依赖的json文件,但是json文件能指定依赖的版本。

这两种方式各有优劣。比如第一种,把所有依赖下载到vendor目录里,会让代码库变得非常庞大,克隆项目将变得很慢。更重要的是,代码中绝大部分代码都不是自己开发的,依赖包变得隔离,你不再能轻易发现自己使用的依赖是什么版本了,如果有BUG或者需要新feature都需要自己来开发。不过这种方式的好处是all in one,即你把代码下载下来就可以进行编译了。

第二种方式也有缺陷。虽然只管理一个描述依赖的json文件,但是每次都需要去拉取依赖,速度比第一种更慢。更要命的是,你不能保证依赖一直在。比如你依赖github上的某个package的作者有一天脑子短路把那个project删除了,你就遇上大麻烦了!还有,如果依赖了一些比如golang.org/xx/yy的包,根本无法从国内访问,也将是非常令人绝望的。这种方式必须得有一个类似于npmjs这样的hub来集中存放第三包才行,否则任何依赖都有可能消失。(当然npmjs也不是太靠谱,之前的leftpad事件……)但是第二种方式也有很多优点,比如简洁,比如能够充分利用遵循semver进行发版的包的更新。同时,虽说看起来每次都要go get,但这里的每次指的是重新下载。开发者只需要下载一次!但是每次发布上线都需要go get这是真的。

有时候做选择就是做权衡,没有万全之策。

当然还有godep这样的工具进行包管理,但是我们的原则是跟进官方的脚步,因此我们只考虑用vendor这种方式,于是可选的返回就缩小了。

govendor走进了我的视线

Pick Up A Shit From A Range Of Shits

是的,这是我最真实的感受。事实上又去go语言太年轻,go社区的工具链非常地缺乏,我们经常只能在一系列“破玩意儿”中挑出一个“没那么破的”来用。不过说真的,govendor其实是个好东西。但是它的文档实在是太太烂了,看完了你也不明所以,只能一个一个去试。对,没错,只能一个一个命令地去试,才能知道commandA和commandB的区别。

想来挺可笑,看完了文档还得一个一个命令的去试,这真的是非常让人吐槽的点,不过还好终于勉强可以用了。不得不说的是,govendor默默地做了很多复杂的工作,来帮助我进行vendorize改造。

govendor其实就是用的上述的方式二,在vendor目录下生成了一个vendor.json文件,里面保存了一个用于描述依赖的json树。每个依赖都记录了它的commitID,据此实现精细化的版本控制。它主要有以下几个命令:

  • govendor init
  • govendor fetch package,从网上拉取package并把package加入vendor.json中
  • govendor sync就好比npm install,读取vendor.json然后去下载依赖。govendor还会把下载过的依赖放到GOPATH/.cache中进行缓存进行加速,还是非常不错的。

至此我改造计划的雏形基本已经形成:利用govendor管理依赖,代码仓库只管理vendor.json,每次构建通过govendor sync去拉取依赖。完整的构建任务通过shell脚本Makefile配合完成。

当然事情并不是一帆风顺的。govendor并没有想象中的好,或者说说许是npm太好,所以衬托出govendor还是很原始。并且,govendor目前有一个很大的缺陷。

govendor的vendor没有层次

vendor和“层次”在一起是什么概念?或者说什么样的才是有vendor层次?考虑这个例子吧:


vendor层次

main中引用PackageA和PackageB的V1版本,PackageA引用了PackageB的V2版本。这样的情况下会发生什么情况呢?

对main来说,它就是引用了PackageA和PackageB,不用去关心PackageA是否引用了其它包,只要PackageA run as expected就行了。换句话说,main下的vendor中只应该存放两个它直接引用的包,即PackageA和PackageB(V1),PackageA对它应该是个黑盒。PackageB(V2)应该放在PackageA的vendor中,这样即使V1和V2版本不兼容,这个代码也是可以按预期运行的。

但事实上govendor把所有的依赖都放到了main的vendor中,这样PackageB只能保留一个版本,如果V1和V2不兼容,就会出现问题。

即使有了vendor,golang import包的方式和node还是有很大不同的。node如果引用了包A,而包A又引用了包B,这时包A先会去自己目录下的node_modules寻找B,找不到的话会一直查找上级目录的node_modules,直到全局的node_modules。而golang引用某个包,只会查找它自己的vendor,如果vendor找不到就会去GOROOT和GOPATH寻找,不会查找上级目录的vendor。如下图所示

vendor引用

bar引用了baz,如果bar的vendor中没有baz,即使main的vendor中有baz,也无法引用。只能去GOPATH中查找baz。

所以,package本身加上它依赖的vendor才是一个完整的包。

govendor没有去解析依赖中的vendor.json,我觉得是有问题的。要不要造一个轮子?This is a question

改造方案

改造不能太激进,比如我们很多依赖都还没有vendorization,你不能强制把依赖都改了。同时,有很多内部包做了其它类似的vendor化改造,但并没有采用官方的vendor。比如我们有同事维护了一个common包,里面有各种utils。同时,他把所有的依赖都用git管理起来,放到项目目录下的third_party目录里,在third_party目录了又建了src/github.com/foo/bar这样的目录结构。只要在编译时把common/thrid_party也加入GOPATH,那么也算是一种vendorization吧。这是一种土方法,当然效果也是达到了。然而common只是我们的依赖之一,所以问题就是要怎么兼容common呢?

方法其实也很简单,就是每次在构建时,把common目录也clone下来,然后把common/third_party也加入GOPATH,同时不要把common相关的依赖加入到我们自己的vendor.json里。整个过程如下:

  • git clone common
  • export GOPATH=pwd:common/thrid_party
  • cp all files to pwd/src/group/project
  • cd pwd/src/group/project && govendor sync
  • go build

这一方案的缺陷在于:

  • 如果vendor.json中依赖了墙外的package如golang.org/x/syx/unix这样的包,govendor sync就会执行失败,而且无解。
  • 没有彻底解决recursive vendor的问题,即依赖的依赖,被放到了最顶层的vendor中,这是有问题的。

对于上述的缺陷一,目前的解决方案是把这类包从vendor.json里删除,直接把代码下载下来,用git进行管理,依赖跟着项目走。当然这也只是暂时的,更好的方案是像淘宝一样做一个类似于cnpm的package镜像hub,定时同步墙外的依赖,通过国内的CDN进行加速。当然目前来说这是不现实的。网上也有类似的项目,比如gopm.io。但是尴尬的是,这个站点不翻墙根本上不去……

缺陷二,目前没有特别好的解决办法。我已经给项目组提了issue,但是并没有什么卵用。最近需要仔细研究研究他govendor项目,看看是不是有这样的功能只是文档没有注明,实在不行就只能fork一份自己造轮子了。

总结

对于没有官方集中式package repository的社区,不论哪种语言都会存在或多或少的包管理的问题。Dependency译为依赖,依赖意味着信任,因此你需要对引用第三方包持有更加审慎的态度。对于一个第三方包的可靠程度,我大致列了以下几个评估项:

  • star数
  • 生产环境使用程度
  • 文档是否简洁明了
  • 代码活跃程度
  • close issue数
  • issue解决速度
  • release是否规范
    从这几个角度去评估一个依赖是否可靠,然后再决定是否把它用到自己的项目中。
golang
Web note ad 1