《快学 Go 语言》第 16 课 —— 包管理 GOPATH 和 Vendor
到目前位置我们一直在编写单文件代码,只有一个 main.go 文件。本节我们要开始朝完整的项目结构迈进,需要使用 Go 语言的模块管理功能来组织很多的代码文件。
细数 Go 语言的历史发展,模块管理经历了三个重要的阶段。第一阶段是通过全局的 GOPATH 来管理所有的第三方包,第二阶段是通过 Vendor 机制将项目的依赖包局部化,第三阶段是 Go 语言的最新功能 Go Module。
本节我们重点讲解前两个阶段,这两个阶段要求我们编写代码时必须在 GOPATH 下面对应的包路径目录里写。第三个阶段 Go Module 内容较新,也比较复杂需要另起一节单独讲解。
系统包路径
Go 语言有很多内置包,内置包的使用需要用户手工 import 进来。Go 语言的内置包都是已经编译好的「包对象」,使用时编译器不需要进行二次编译。可以使用下面的命令查看这些已经编译好的包对象在哪里。
// go sdk 安装路径 $ go env GOROOT /usr/local/go $ go env GOOS darwin $ go env GOARCH amd64 $ ls /usr/local/go/darwin_amd64 total 22264 drwxr-xr-x 4 root wheel 136 11 3 05:11 archive -rw-r--r-- 1 root wheel 169564 11 3 05:06 bufio.a -rw-r--r-- 1 root wheel 177058 11 3 05:06 bytes.a drwxr-xr-x 7 root wheel 238 11 3 05:11 compress drwxr-xr-x 5 root wheel 170 11 3 05:11 container -rw-r--r-- 1 root wheel 93000 11 3 05:06 context.a drwxr-xr-x 21 root wheel 714 11 3 05:11 crypto -rw-r--r-- 1 root wheel 24002 11 3 05:02 crypto.a ...
该命令显示出来的后缀名为 .a 的文件就是已经编译好的包对象。
全局管理 GOPATH
Go 语言的 GOPATH 路径下存放了全局的第三方依赖包,当我们在代码里面 import 某个第三方包时,编译器都会到 GOPATH 路径下面来寻找。GOPATH 目录可以指定多个位置,不过用户一般很少这样做。如果你没有人工指定 GOPATH 环境变量,编译器会默认将 GOPATH 指向的路径设定为 ~/go 目录。用户可以使用下面的命令看看自己的 GOPATH 指向哪里
$ go env GOPATH
/Users/qianwp/go
GOPATH 下有三个重要的子目录,分别是 src、pkg 和 bin 目录。src 目录存放第三方包的源代码,pkg 目录存放编译好的第三方包对象,bin 存放第三方包提供的二进制可执行文件。
当我们导入第三方包时,编译器优先寻找已经编译好的包对象,如果没有包对象,就会去源码目录寻找相应的源码来编译。使用包对象的编译速度会明显快于使用源码。
友好的包路径
Go 语言允许包路径带有网站域名,这样它就可以使用 go get 指令直接去相应的网站上拉去包代码。最常用的要数 github.com、gopkg.in、golang.org 这三个网址。
import "github.com/go-redis/redis" import "golang.org/x/net" import "gopkg.in/mgo.v2" import "myhost.com/user/repo" // 个人提供的仓库
Go 语言不存在官方维护的集中包仓库,它将包的选择分散到开源社区网站。使用量最大的要数 github.com,我们平时使用的大部分第三方包都是来源于此。也可以使用自己公司提供的代码仓库,路径名用上公司代码仓库的域名即可。默认会使用 https 协议下载代码仓库 ,可以使用 -insecure 参数切换到 http 协议。
模块的标准结构
了解模块结构的最好办法就是看看别人的模块是怎么写的,这里我们来观察一下 mongo 包。使用下面的命令将 redis 的包下载本 GOPATH 目录下
$ go get gopkg.in/mgo.v2
进入到 GOPATH 目录下面的 src 子目录寻找刚刚下载的 mongo 包,你会发现目录层级和 go get 指令的包路径正好一一对应起来,目录下面还有更深的子目录。
打开代码中的任意一个文件你可以发现代码中的 package 声明的包名是 mgo,这个和当前的目录名称可以不一样,不过当前目录下所有的文件都是这同一个包名 mgo。同时我们还注意到即使是包内代码引用,还是使用了全路径来导入而不是相对导入,比如下图的 bson,虽然同属一个项目,但是它们好像根本就互不相识,要使用对方的的路径全称来打招呼。
当其它项目导入这个包时,import 语句后面的路径是 mongo 包的目录路径,而使用的包名却是这个目录下面代码中 package 语句声明的包名 mgo。
package main import "gopkg.in/mgo.v2" func main() { session, err := mgo.Dial(url) ... }
很不幸,例子中这个项目已经停止维护了,下面是它的文档中停止维护的声明。
它已经由另一个社区项目接手。如果你要使用 mongo 的包,请使用
$ go get github.com/globalsign/mgo
编写第一个模块
下面我们尝试编写第一个模块,这个模块是一个算法模块,提供两个方法,一个是计算斐波那契数,一个用来计算阶乘。我们要将这个包放到 github.com 上,需要读者在 github.com 上申请自己的账户,然后创建自己的项目名叫 mathy。我的 github id 是 pyloque,于是这个项目的包名就是 github.com/pyloque/mathy。第一步在 GOPATH 里创建这个包目录
$ mkdir -p ~/go/src/github.com/pyloque/mathy
$ cd ~/go/src/github.com/pyloque/mathy
好,现在我们进入了包的目录下,开始编写代码吧,首先创建 mathy.go 文件,将下面的代码贴进去
package mathy // 函数名大写,其它的包才可以看的见 func Fib(n int) int64 { if n <= 1 { return 1 } var s = make([]int64, n+1) s[0] = 1 s[1] = 1 for i := 2; i <= n; i++ { s[i] = s[i-1] + s[i-2] } return s[n] } func Fact(n int) int64 { if n <= 1 { return 1 } var s int64 = 1 for i := 2; i <= n; i++ { s *= int64(i) } return s }
现在这个包的功能都齐全了,下面来编写 main 函数使用它。我们可以去其它的任意空目录下编写下面的 main.go 文件,但是不可以在当前目录编写,因为同一个目录只能有同一个包名。比如我们在 mathy 目录下面创建一个子目录 cmd,将下面的代码贴到 cmd 目录下的 main.go 文件里。执行 go run cmd/main.go 运行观察结果
package main import ( "fmt" "github.com/pyloque/mathy" // 引用刚刚创建的包名 ) func main() { fmt.Println(mathy.Fib(10)) fmt.Println(mathy.Fact(10)) } ------------- 89 3628800
现在将代码提交到 github.com 上去吧,你最好已经比较熟悉 git 指令
$ git init Initialized empty Git repository in /Users/qianwp/go/src/github.com/pyloque/mathy/.git/ $ git add --all $ git commit -a -m 'first commit' [master (root-commit) 7da8809] first commit 2 files changed, 37 insertions(+) create mode 100644 cmd/main.go create mode 100644 mathy.go $ git remote add origin https://github.com/pyloque/mathy.git $ git push origin master Counting objects: 5, done. Delta compression using up to 4 threads. Compressing objects: 100% (4/4), done. Writing objects: 100% (5/5), 555 bytes | 555.00 KiB/s, done. Total 5 (delta 0), reused 0 (delta 0) remote: remote: Create a pull request for 'master' on GitHub by visiting: remote: https://github.com/pyloque/mathy/pull/new/master remote: To https://github.com/pyloque/mathy.git * [new branch] master -> master
打开你的 github 项目页看一看你刚刚提交的成果吧
这个项目提交到了 github.com 意味着全球的人都可以使用你的代码了,前提是人们愿意使用。
现在你可以将本地的 mathy 文件夹删除,然后执行一下 go get
$ go get github.com/pyloque/mathy
你会发现刚才删掉的 mathy 目录又出现了,因为 go get 指令会自动去 github.com 网站上拉取你刚才提交的项目代码。
Go 语言支持使用 . 和 .. 符号相对导入,但是不推荐使用。官方表示相对导入只应该用于本地测试,如果要正式发布一定需要修改为绝对导入。相对导入可以不必将代码放在 GOPATH 里面编写,所以会方便本地测试。但是将代码放到 GOPATH 里面写又能产生多大障碍呢?总之就是不推荐使用相对导入。
两个包的包名一样怎么办?
如果你的代码需要使用两个包,这两个包的路径最后一个单词是一样的,那该如何分清使用的是那个包呢?为了解决这个问题,Go 语言支持导入语句名称替换功能
import pmathy "github.com/pyloque/mathy" import omathy "github.com/other/mathy"
无名导入
Go 语言还支持一种罕见的导入语法可以将其它包的所有类型变量都导入到当前的文件中,在使用相关类型变量时可以省去包名前缀。
package main import "fmt" import . "github.com/pyloque/mathy" func main() { fmt.Println(Fib(10)) fmt.Println(Fact(10)) }
但是这种用法很少见,而且非常不推荐使用,读者可以当着没看见完全不知道。
匿名导入
Go 语言还支持匿名导入,就是说你导入了某个第三方包,但是不需要显示使用它,这时就可以使用匿名导入。什么时候需要导入某个包而不使用呢?这是因为 Go 语言的代码文件中可以存在一个特殊的 init() 函数,它会在包文件第一次被导入的时候运行。
当我们使用数据库驱动的时候就会经常遇到匿名导入,第三方驱动包会在 init() 函数中将当前驱动注册到全局的驱动列表中,这样通过特定的 URI 就可以识别并找到相应的驱动来使用。
import ( "database/sql" _ "github.com/go-sql-driver/mysql" )
当我们使用 Go 语言自带的图像处理包时也会遇到匿名导入,在对图像进行编码解码的时候需要根据不同的图像编码选择不同的逻辑。
import ( "image" _ "image/gif" _ "image/png" _ "image/jpeg" )
包名和目录名不一样
Go 语言允许包名和当前的目录名成不一样,在导入包的时候使用的是目录路径,但是在使用的时候应该使用目录下的包名。所以你会看到导入的路径尾部和真正使用时的包名前缀不一样。
import "github.com/json-iterator/go" var json = jsoniter.ConfigCompatibleWithStandardLibrary json.Marshal(&data)
为什么 json-iterator 会使用这样奇怪的包路径呢,因为它要支持多种语言的,直接将最后的目录名改成语言的名称更加易于辨识。
go get vs go build vs go install
Go 提供了三个比较的常用的指令用来进行全局的包管理。
go build: 仅编译。如果当前包里有 main 包,就会生成二进制文件。如果没有 main 包,build 指令仅仅用来检查编译是否可以通过,编译完成后会丢弃编译时生成的所有临时包对象。这些临时包包括自身的包对象以及所有第三方依赖包的包对象。如果指定 -i 参数,会将编译成功的第三方依赖包对象安装到 GOPATH 的 pkg 目录。
go install:先编译,再安装。将编译成的包对象安装到 GOPATH 的 pkg 目录中,将编译成的可执行文件安装到 GOPATH 的 bin 目录中。如果指定 -i 参数,还会安装编译成功的第三方依赖包对象。
go get:下载代码、编译和安装。安装内容包括包对象和可执行文件,但是不包括依赖包。
$ go get github.com/go-redis/redis
注意编译过程中第三方包的 main 包是不可能被编译的,安装的对象也就不可能包括第三方依赖包的可执行文件。
当我们使用 go run 指令来测试运行正在开发的程序时,如果发现启动了很久,这时候可以考虑先执行 go build -i 指令,将编译成功的依赖包都安装到 GOPATH 的 pkg 目录下,这样再次运行 go run 指令就会快很多。
$ go build -i
$ go run main.go
当我们使用的第三方包已经比较陈旧,可以使用 go get -u 指令拉取最新的依赖包。
$ go get -u github.com/go-redis/redis
局部管理 Vendor
当我们在本地要开发多个项目时,如果不同的项目需要依赖某个第三方包的不同版本,这时候仅仅通过全局的 GOPATH 来存放第三方包是无解的。解决方法有一个,那就是需要在不同的项目里设置不同的 GOPATH 变量来解决冲突问题。但是这还是不能解决一个重要的问题,那就是当我们的项目依赖了两个第三方包,这两个第三方包又同时依赖了另一个包的两个不同版本,这时候就会再次发生冲突。这种多版本依赖有一个专业的名称叫「钻石型」依赖。
为了解决这个问题,Go 1.6 引入了 vendor 机制。这个机制非常简单,就是在你自己项目的目录下增加一个名字为 vendor 子目录,将自己项目依赖的所有第三方包放到 vendor 目录里。这样当你导入第三方包的时候,优先去 vendor 目录里找你需要的第三方包,如果没有,再去 GOPATH 全局路径下找。
然后每个第三方项目都会有自己的 vendor 子目录,如此递归下去,可以想象,一个大型项目将会有一颗很深的依赖树。不过实际上这颗依赖数没你想象的那么深,因为 Go 的第三方开源包普遍比较轻量级,依赖不是很多。毕竟 Go 语言已经将很多互联网常用的工具包都内置了。
使用 vendor 有一个限制,那就是你不能将 vendor 里面依赖的类型暴露到外面去,vendor 里面的依赖包提供的功能仅限于当前项目使用,这就是 vendor 的「隔离沙箱」。正是因为这个沙箱才使得项目里可以存在因为依赖传递导致的同一个依赖包的多个版本。同时这也意味着项目里可能存在多份同一个依赖包,即使它们是同一个版本。比如你的包在 vendor 里引入了某个第三方包 A,然后别人的项目在 vendor 里引入你的包,同时它也引入第三方包 A。这就会导致生成的二进制文件变大,也会导致运行时内存变大,不过也无需担心,这点代价对于服务端程序来说基本可以忽略不计。
讲到这里还有一个很重要的问题没有解决,github 上有很多开源项目,这些项目都有多个版本号,我如何引入具体某一个版本呢?如果使用 go get 指令,它总是引入 master 分支的最新代码,它往往不是稳定的可靠代码。这就需要 Go 语言的依赖管理工具的支持了,它就好比 java 语言的 maven 工具,python 语言的 pip 工具。
Dep
Go 语言没有内置 vendor 包管理工具,它需要第三方工具的支持。这样的工具很多,目前最流行的要数 golang/dep 项目了,它差一点就被官方收纳为内置工具了,很可惜!上图是它的 Logo,图中叠起来的箱子就是 dep 正在管理的各种第三方依赖包。使用它之前我们需要将 dep 工具安装到 GOPATH 下面
$ go get github.com/golang/dep
同时需要将 ~/go/bin 目录加入到环境变量 PATH 中,因为 dep 可执行文件默认会安装到 ~/go/bin 中。但是令人意外的是 dep 居然表示不能直接解决「钻石型」依赖,这让我感受到了它的危机,在 dep 中依赖包是扁平化的,vendor 不允许嵌套。如果出现了版本冲突,需要使用某种特殊手段来解决。
配置文件
dep 管理的项目会有两个配置文件,分别是 Godep.toml 和 Godep.lock。Godep.toml 用于配置具体的依赖规则,里面包含项目的具体版本号信息。通过 toml 配置文件,你即可以使用远程的依赖包(github),也可以直接使用本地的依赖包(GOPATH)。还可以为依赖包指定别名,这样就可以在代码里使用和真实路径不一样的导入路径。当你需要切换依赖包的不同版本时,可以在 toml 配置文件里修改依赖的版本号,然后通过 dep ensure 指令来更新依赖项。
Gopkg.lock 是基于当前的 toml 文件配置规则和项目代码来生成依赖的精确版本,它确定了 vendor 文件夹里要下载的依赖项代码的目标版本。
dep init
该指令用于初始化当前的项目,它会静态分析当前的项目代码(如有有的话),生成 Godep.toml 和 Godep.lock 依赖配置文件,将依赖的项目代码下载到当前项目的 vendor 文件夹里面。它会根据一定的策略来选择最新的依赖包版本。如果自动策略生成的版本号不是你想要的,可以再修改配置文件执行 dep ensure 来切换其它版本。
dep ensure
该指令会下载代码里用到的新依赖项、移除当前项目代码里不使用的依赖项。确保当前的依赖包代码和当前的项目代码配置处于完全一致的状态。
dep ensure -update
更新 Godep.lock 文件中的所有依赖项到最新版本。可以增加 一到多个包名参数,指定更新特定的依赖包。如果 toml 配置文件限定了依赖包的版本范围,那么更新必须遵守 toml 规则的版本限制。
dep ensure -add github.com/a/b
增加并下载一个新的项目依赖包,可以指定依赖版本号。如 dep ensure -add github.com/a/b@master 或者 github.com/a/b@1.0.0
dep status
显示当前项目的依赖状态。
Dep 在使用起来比较简单,但是其内部实现上是一个比较复杂的工具,鉴于篇幅限制,本节就不再继续深入讲解 Dep 了,以后有空再单独开启一篇来深入探讨吧。我甚至觉得理解 Dep 已经变得没有那么必要,因为它已经被 Go 语言官方抛弃了,取而代之的解决方案是 Go Module。