Golang 中依赖管理的灾难

阅读数:3910 2016 年 8 月 15 日

话题:语言 & 开发架构其他

我在 Golang 的世界里已经遨游一段时间了,虽然远说不上精通,但是我还是发现了一些自己不太喜欢抑或不太适应的东西。通常对我来说,在决定为一样事物投入比较多的精力之前,我都会做充分的了解,在生活中选择是否看某部电影是这样,coding 时也是这样。有充分的质疑,才会有充分的理解不是吗?:P

依赖管理在软件工程中一直是一个比较核心的话题。一个人的力量是有限的,在较大规模的软件开发中,你总是需要与别人协作编写代码,或是充分借助开源世界的力量。软件在它的有效生命周期内是一直在迭代的,那么在你依赖的这些"外部"代码库演进的过程中,如何保证自己的代码或工程是可以在任何时间任何地点重复编译重复部署的,而不会出现满屏的红色编译错误告警,甚至是莫名其妙的运行时崩溃?在 Java 世界中,通过 maven 工具和 gradle 工具编辑依赖清单列表 / 脚本,指定依赖库的位置 / 版本等信息,这些可以帮助你在合适的时间将项目固化到一个可随时随地重复编译发布的状态。这些工具对我来说已经足够优雅有效(虽然 maven 中也有不同依赖库的内部依赖版本冲突等令人心烦的问题)。

在初步了解了 Golang 中是如何管理依赖后,我确实有些失望。自然的,Golang 官方团队也早已意识到语言內建的依赖管理机制不够理想,需要有一个优雅而统一的解决方案。因此,如果你打算建立一个包含外部依赖的 Golang 工程,不妨先参考下官方建议

那么,让我们来看一看 Golang 的依赖管理问题到底是怎么回事吧。

依赖

假设你已经自己设置好一个 Golang 的开发环境和工作目录 (workdir) 了,然后我们在 workdir 里先创建一个新的 playground 工程:

~/workdir/src/playground/

接下来在 playground 工程内新建 main.go 文件,并且使用一个开源的外部 http 库(echo 框架)提供一个简单的"hello world" http 服务。

package main

import (
    "net/http"
    "github.com/labstack/echo"
    "github.com/labstack/echo/engine/standard"
)

func main() {
    e := echo.New()
    e.Get("/", func(c echo.Context) error {
        return c.String(http.StatusOK, "Hello, World!\n")
    })
    e.Run(standard.New(":1234"))
}

这里我们有三个依赖:net/http 是 Golang 的标准库,另外两个是外部依赖 --echo 框架。你只需要在 import 代码块中声明所需的依赖库,Golang 就会自动帮你去拉取它们。怎样运行这个简单的 http server 呢,执行以下命令(假设 GOPATH 环境变量已被设置为~/workdir):

cd ~/workdir/src/playground/
go get ./...
go run main.go

声明依赖,执行几个 shell 命令就可以直接把工程跑起来,看起来相当简单明了。但是,事实真的如此吗?

问题在哪里

还记得我之前说过的吗,软件在它的有效生命周期内是一直在迭代的,也就是一直在变化的。如果我们依赖的 echo 框架改变了它的 API(比如方法签名变更,过时方法删除等等),playground 工程还能正常编译运行吗?项目的可靠交付受制于第三方的开源代码库,这是我们和客户都不能接受的!尽管在类似 GitHub 的开源平台上,知名的开源软件都尽力的保持着自身的可靠性和 API 的一贯性,但是作为软件开发人员,我们很清楚事与愿违的事情还是经常发生不是吗?总有一天,我们会发现当我们想要去升级一个老系统,迁移使用新版、性能更高的外部依赖库时,之前的代码都不工作了。更糟的是,如果没有做好模块或调用链的解耦,没有对外部依赖库做进一步的封装,工程内部到处是外部依赖库中"不稳定"API 的调用,在大型的软件系统中,基于这样的遗留代码的重构就是个灾难!(熟悉的"满屏飘红"又回来了)

与外部依赖库的最新版本保持一致的风险实在太大,如何避免这种后向兼容性被打破的情况?有一些团队的做法是直接将外部依赖库的某个特定版本代码进行拷贝并纳入到自身的版本控制系统中去。据我所知,Google 内部当时是这么做的。事实上,我所在的团队初期也是这么做的。这样,所有项目所需的依赖都使用被纳入到内部版本控制系统中的版本,这些代码由专人或团队去维护,一来可以统一不同团队使用的外部库版本;二来最大可能的降低了 API 被更新升级打破的可能性。

好吧,那就把代码拉进 svn/git 吧……现在没有问题了吧?让我们来看看工作目录的情况吧。之前我们执行了这条命令:

go get ./...

如果进入 ~/workdir/src/github.com/ 目录,你就会发现labstack, mattnvalyala 这三个目录。labstack 目录是 echo 框架所在的目录,没有问题。mattnvalyala 则是 echo 框架本身所依赖的外部库,读到这里你是否已经嗅到了一丝危险的味道?如果你编写的项目依赖 mattn,并且也使用了 echo 框架,那么如果 echo 框架依赖的 mattn 版本和你依赖的版本不一致怎么办?

我们先搁置上面的问题,假设已经决定把外部依赖都纳入到版本控制系统中。好,这意味着,在 GitHub 上开源的代码库,对应本地的 ~/workdir/src/github.com/目录,必须受版本控制。另外,你会说,并不是所有的外部依赖库都来自 GitHub 吧,也有可能是 bitbucket、golang.org 等等,那么是不是要把整个 ~/workdir/src/ 目录都纳入版本控制呢?粗暴,丑陋……

生产环境的实践

在生产环境中,确实有一些团队就是按照上面所说把所有外部依赖纳入到自身的版本控制系统中去的。这样他们能够控制所有的依赖。但是要注意的一点是,在编写代码时,所有的导入路径(import path)必须被修改为使用内部版本控制系统管理的库版本。比如原先

import "github.com/labstack/echo"

这样的代码必须修改为类似下面这样:

import "own_dependency_root_path/labstack/echo"

你不仅要对自己的源码做更改,还必须包含依赖库的源码,以及依赖库的依赖等等,一直递归下去。有一些自动化的工具可以帮我们做这些事,但这整个方案仍然显得粗暴丑陋。

幸好,基于 Golang 的官方建议,我们已经可以找到许多不错的解决方案。

Golang 在 1.5 版本引入了实验性的 vendoring 标记GO15VENDOREXPERIMENT,并且从 1.6 版本起使 vendoring 机制成为默认行为,不再需要额外配置。现在,如果一个 package 或它的父目录内包含 vendor 目录,它就会被作为 import 指令的依赖搜索路径。有很多工具帮我们轻松的做到这点,例如godep,那么,挑一个顺手的,去阅读他们的使用文档吧~

总结

Golang 开发社区一直秉承着"简约"的宗旨,尽力不去给用户增加额外的设计上的负担。然而依赖管理的处理,确实是一个需要谨慎而精心设计的技术点。Golang 官方团队对此也十分重视,它的重要性甚至超过软件工程中普遍认同的"DRY"("Don't Repeat Yourself",强调代码的复用) 原则:

"Through the design of the standard library, great effort was spent on controlling dependencies. It can be better to copy a little code than to pull in a big library for one function. Dependency hygiene trumps code reuse." - Go at Google

Golang 官方团队强调的是代码的洁净 / 清晰度胜过代码的复用。在 Golang 的世界里,这很可能是一种非常值得借鉴的编程哲学。

那么最后,结合官方团队对依赖管理的建议,这里也给出一些我在 Golang 日常实践中提炼出的指导性思路供参考:

  • 当你开源类库或者提供代码作为公司内部的公共库时,请尽量的少用第三方库,推崇使用标准库。

  • 发布的类库,尽量辅以版本管理(版本固化),例如使用gopkg来管理版本。

  • 使用官方推荐的工具来管理依赖。如果你有自己的想法,可以直接对这些官方推荐的工具做贡献,加入社区共同解决问题。

作者

Mr.Key,目前就职于途牛旅游网,关注 DevOps,云计算平台,分布式系统


感谢木环对本文的审校。

给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ@丁晓昀),微信(微信号:InfoQChina)关注我们。