到目前位置我们一直在编写单文件代码,只有一个 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
...
Go 语言的 GOPATH 路径下存放了全局的第三方依赖包,当我们在代码里面 import 某个第三方包时,编译器都会到 GOPATH 路径下面来寻找。GOPATH 目录可以指定多个位置,不过用户一般很少这样做。如果你没有人工指定 GOPATH 环境变量,编译器会默认将 GOPATH 指向的路径设定为 ~/go 目录。用户可以使用下面的命令看看自己的 GOPATH 指向哪里
$ go env GOPATH
/Users/qianwp/go
当我们导入第三方包时,编译器优先寻找已经编译好的包对象,如果没有包对象,就会去源码目录寻找相应的源码来编译。使用包对象的编译速度会明显快于使用源码。
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" // 个人提供的仓库
了解模块结构的最好办法就是看看别人的模块是怎么写的,这里我们来观察一下 mongo 包。使用下面的命令将 redis 的包下载本 GOPATH 目录下
$ go get gopkg.in/mgo.v2
打开代码中的任意一个文件你可以发现代码中的 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
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
}
package main
import (
"fmt"
"github.com/pyloque/mathy" // 引用刚刚创建的包名
)
func main() {
fmt.Println(mathy.Fib(10))
fmt.Println(mathy.Fact(10))
}
-------------
89
3628800
$ 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.com 意味着全球的人都可以使用你的代码了,前提是人们愿意使用。现在你可以将本地的 mathy 文件夹删除,然后执行一下 go get
$ go get github.com/pyloque/mathy
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"
)
import (
"image"
_ "image/gif"
_ "image/png"
_ "image/jpeg"
)
Go 语言允许包名和当前的目录名成不一样,在导入包的时候使用的是目录路径,但是在使用的时候应该使用目录下的包名。所以你会看到导入的路径尾部和真正使用时的包名前缀不一样。
import "github.com/json-iterator/go"
var json = jsoniter.ConfigCompatibleWithStandardLibrary
json.Marshal(&data)
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 github.com/go-redis/redis
当我们在本地要开发多个项目时,如果不同的项目需要依赖某个第三方包的不同版本,这时候仅仅通过全局的 GOPATH 来存放第三方包是无解的。解决方法有一个,那就是需要在不同的项目里设置不同的 GOPATH 变量来解决冲突问题。但是这还是不能解决一个重要的问题,那就是当我们的项目依赖了两个第三方包,这两个第三方包又同时依赖了另一个包的两个不同版本,这时候就会再次发生冲突。这种多版本依赖有一个专业的名称叫「钻石型」依赖。
为了解决这个问题,Go 1.6 引入了 vendor 机制。这个机制非常简单,就是在你自己项目的目录下增加一个名字为 vendor 子目录,将自己项目依赖的所有第三方包放到 vendor 目录里。这样当你导入第三方包的时候,优先去 vendor 目录里找你需要的第三方包,如果没有,再去 GOPATH 全局路径下找。
使用 vendor 有一个限制,那就是你不能将 vendor 里面依赖的类型暴露到外面去,vendor 里面的依赖包提供的功能仅限于当前项目使用,这就是 vendor 的「隔离沙箱」。正是因为这个沙箱才使得项目里可以存在因为依赖传递导致的同一个依赖包的多个版本。同时这也意味着项目里可能存在多份同一个依赖包,即使它们是同一个版本。比如你的包在 vendor 里引入了某个第三方包 A,然后别人的项目在 vendor 里引入你的包,同时它也引入第三方包 A。这就会导致生成的二进制文件变大,也会导致运行时内存变大,不过也无需担心,这点代价对于服务端程序来说基本可以忽略不计。
讲到这里还有一个很重要的问题没有解决,github 上有很多开源项目,这些项目都有多个版本号,我如何引入具体某一个版本呢?如果使用 go get 指令,它总是引入 master 分支的最新代码,它往往不是稳定的可靠代码。这就需要 Go 语言的依赖管理工具的支持了,它就好比 java 语言的 maven 工具,python 语言的 pip 工具。
Go 语言没有内置 vendor 包管理工具,它需要第三方工具的支持。这样的工具很多,目前最流行的要数 golang/dep 项目了,它差一点就被官方收纳为内置工具了,很可惜!上图是它的 Logo,图中叠起来的箱子就是 dep 正在管理的各种第三方依赖包。使用它之前我们需要将 dep 工具安装到 GOPATH 下面
$ go get github.com/golang/dep
dep 管理的项目会有两个配置文件,分别是 Godep.toml 和 Godep.lock。Godep.toml 用于配置具体的依赖规则,里面包含项目的具体版本号信息。通过 toml 配置文件,你即可以使用远程的依赖包(github),也可以直接使用本地的依赖包(GOPATH)。还可以为依赖包指定别名,这样就可以在代码里使用和真实路径不一样的导入路径。当你需要切换依赖包的不同版本时,可以在 toml 配置文件里修改依赖的版本号,然后通过 dep ensure 指令来更新依赖项。
Gopkg.lock 是基于当前的 toml 文件配置规则和项目代码来生成依赖的精确版本,它确定了 vendor 文件夹里要下载的依赖项代码的目标版本。
该指令用于初始化当前的项目,它会静态分析当前的项目代码(如有有的话),生成 Godep.toml 和 Godep.lock 依赖配置文件,将依赖的项目代码下载到当前项目的 vendor 文件夹里面。它会根据一定的策略来选择最新的依赖包版本。如果自动策略生成的版本号不是你想要的,可以再修改配置文件执行 dep ensure 来切换其它版本。
该指令会下载代码里用到的新依赖项、移除当前项目代码里不使用的依赖项。确保当前的依赖包代码和当前的项目代码配置处于完全一致的状态。
更新 Godep.lock 文件中的所有依赖项到最新版本。可以增加 一到多个包名参数,指定更新特定的依赖包。如果 toml 配置文件限定了依赖包的版本范围,那么更新必须遵守 toml 规则的版本限制。
增加并下载一个新的项目依赖包,可以指定依赖版本号。如 dep ensure -add github.com/a/b@master 或者 github.com/a/[email protected]
显示当前项目的依赖状态。
Dep 在使用起来比较简单,但是其内部实现上是一个比较复杂的工具,鉴于篇幅限制,本节就不再继续深入讲解 Dep 了,以后有空再单独开启一篇来深入探讨吧。我甚至觉得理解 Dep 已经变得没有那么必要,因为它已经被 Go 语言官方抛弃了,取而代之的解决方案是 Go Module。
大拇指长按图片识别二维码,订阅《快学 Go 语言》更多内容