包管理的重要性不言而喻。随着项目的推进,没有合适的包管理,每一次迭代都将成为开发者的噩梦。尤其是对于进行持续集成的项目,自动化应该深入骨髓。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
目录查找依赖,而不单单是从GOROOT
和GOPATH
中查找的功能,而且会优先从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层次?考虑这个例子吧:
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。如下图所示
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是否规范
从这几个角度去评估一个依赖是否可靠,然后再决定是否把它用到自己的项目中。
有疑问加站长微信联系(非本文作者)