详细介绍 GoLang 的包管理机制

2016-10-16 language golang

当拿到代码后,用户首先看到和接触的就是源码文件的布局、命名还有包的结构,代码漂亮、布局清晰、通俗易懂,就像是设计严谨的 API 一样。GoLang 的包管理经历了好几个阶段,从简单的通过环境变量管理,到现在完善的机制。

相比其它语言,GoLang 的包管理机制对包、变量、代码格式,甚至代码组织结构等,都有详细的约束,这里详细介绍其包管理的规则。

简介

GoLang 包的命名遵循简洁、小写、单数和与目录同名的原则,这样便于引用和快速定位查找。一个包中可以根据功能拆分为多个文件,不同的文件实现不同功能点;相同包下的函数可以直接使用。

对于自带的标准包,例如 net/http 采用的是全路径,net 是最顶级的包,然后是 http 包,包的路径跟其在源码中的目录路径相同,这样就便于查找。

自己或者公司开发的程序而言,一般采用域名作为顶级包名的方式,这样就不用担心和其他开发者包名重复了,例如 github.com/coreos

main 包

当把一个 go 文件的包名声明为 main 时,就等于告诉编译器这是一个可执行程序,会尝试把它编译为一个二进制的可执行文件。main 包可以被拆分成多个文件,但是只能有一个 main() 函数入口,假设被拆成了 main.gofoobar.go 那么直接运行时需要包含两个文件,如下。

----- 直接运行
$ go run main.go foobar.go

----- 编译,生成的程序名称以第一个文件为准
$ go build main.go foobar.go

import

在 golang 中可以通过如下的方式导入。

import(
    "fmt"       // 标准库
    "./model"   // 本地库
    "model/png" // 加载$GOPATH/src/model/png中的库
    . "png"     // 直接使用相关的函数即可,无需包前缀
    p "png"     // 重命名包名
)

另外,常见的一种操作符 _ ,例如:

import ("database/sql" _ "github.com/ziutek/mymysql/godrv")

这里其实只是引入该包,当导入一个包时,它所有的 init() 函数就会被执行,如果仅仅是希望它的 init() 函数被执行,此时就可以使用 _

如果有多个包,那么不同包之间引入的初始化顺序为:

golang init sequence

  1. import pkg 的初始化过程;
  2. pkg 中定义的 const 变量初始化;
  3. pkg 中定义的 var 全局变量;
  4. pkg 中定义的 init 函数,可能有多个。

包管理 (历史)

最初的包是通过环境变量进行查找的,其中 GOROOT 保存 Go 的源码目录,而通过 GOPATH 保存项目工程目录,当然,如果由多个目录,可以通过 : 分割来设置多个。

在实际使用时,通过会将一些常用的三方库保存在 GOPATH 的第一个目录下,所以,此时的目录一般为。

WORKSPACE
 |-src/github.com/hello/world   引用的三方库
 |    /foobar                   项目实现的代码
 |    /foobar/mymath            项目子模块
 |-bin
 |-pkg

其中,在 foobar 目录下保存了项目代码,如果要引用 mymath 模块,需要调用 import foobar/mymath 才可以;然后,通过 go install foobar 安装,此时会在 bin/ 目录下生成对应的二进制文件。

注意,如果通过 go build foobar 会在当前目录下生成二进制文件。

示例

新建一个临时的项目工程 mkdir /tmp/foobar && GOPATH=/tmp/foobar,简单来说,上述的三方库引入一个最简单打印输出。

// src/github.com/hello/world/hello.go
package world

import "fmt"

func Hi() {
        fmt.Println("Hello World!")
}

如下是一个项目内的代码。

// src/foobar/main.go
package main

import (
        "fmt"
        "foobar/mymath"
        "github.com/hello/world"
)

func main() {
        world.Hi()
        fmt.Printf("sqrt(4) %.2f\n", mymath.Sqrt(4))
}

以及项目中的子模块。

// src/foobar/mymath/sqrt.go
package mymath

import "math"

func Sqrt(x float64) float64 {
        return math.Sqrt(x)
}

Vendor

从 v1.5 开始开始引入 vendor 包模式,如果项目目录下有 vendor 目录,那么 go 工具链会优先使用 vendor 内的包进行编译、测试等,而不是之前的 GOPATH GOROOT 等环境变量。

实际上,这之后第三方的包管理思路都是通过这种方式来实现,比如说由社区维护准官方包管理工具 dep ,不过官方不认可。如果只维护了一个项目,而且该目录下包含的都是与当前项目相关的内容,那么实际上维护起来还好,但是如果有多个项目,而且想共用一些三方仓库,那么维护起来就比较麻烦。

WORKSPACE
 |-src/github.com/some/third                  引用的一些通用三方库
 |    /foobar                                 项目实现的代码
 |    /foobar/mymath                          项目子模块
 |    /foobar/vendor/github.com/hello/world   单个项目引用的三方库
 |-bin
 |-pkg

在 v1.11 中加入了 Go Module 作为官方包管理形式,在 v1.11 和 v1.12 版本中 gomod 不能直接使用,可以执行 go env 命令查看是否有 GOMOD 判断是否已开启。如果没有开启,可以通过设置环境变量 export GO111MODULE=on 开启,当使用 modules 时,会完全忽略原有的 vendor 机制。

接着详细介绍如何使用 Go 标准的包管理方案 Modules 。

Module (最新)

之所以会引入依赖,无非是为了复用自己 (或别人) 的工作成果,但这样会存在很多的不确定因素:包的 API 会变化,内部行为会变化,改包的依赖会变化,包与包之间的不同依赖相互冲突等等。不仅如此,随着软件开发规模的逐步增大,涉及到的外部依赖越来越多,手动管理的所有依赖愈发不可能。所以需要依赖管理,需要有个工具或者规范来描述和定义包与包之间的依赖关系,并自动化的去处理、解析和满足这些依赖。

GoLang 的 Module 是官方提供的一个依赖管理方案,各个发布包通过版本进行兼容性约束,不同的依赖包同时引入了版本管理,以保证兼容性。

这里实际是强制要求所有的模块遵循语义化版本规则 Semantic Versioning ,规定,当主版本号大于等于 v2 时,这个模块在 import 路径的结尾上必须指定主版本号;也就是说,当主版本号为 v0 或者 v1 的时候可以省略。

根据语义化版本的要求,v0 是不需要保证兼容性的,可以随意的引入破坏性变更,所以不需要显式的写出来;而省略 v1 更大程度上是为了兼容现有的代码库,很少有版本会超过 v2 。

0. 准备环境

在使用之前,首先设置如下的环境变量。

export GO111MODULE=on
export GOPROXY=https://goproxy.cn
export GONOSUMDB=*

----- 版本大于1.13(推荐)
go env -w GO111MODULE=on
go env -w GOPROXY=https://goproxy.cn,direct

----- 查看当前设置的环境变量
go env

其中 GO111MODULE 打开模块支持,忽略 GOPATH 以及 vendor 目录;GONOSUMDB 默认下载完依赖模块后,会检查其校验值,默认是 https://sum.golang.org ,这里过滤掉所有的包。

校验地址可以通过 GOSUMDB 指定地址及其公钥值,与此相关的还有 GOPRIVATE GONOPROXY 几个环境变量。

注意 如果之前已经设置了环境变量,例如 GOPROXY ,那么通过 go env -w 修改会报 does not override conflicting OS environment variable 类似的错误,主要是因为通过 go env 修改时不支持覆盖,此时可以直接命令行修改。

1. 创建新模块

这里尝试在 /tmp/HelloWorld 目录下创建一个项目,先创建目录 /tmp/HelloWorld ,然后添加如下文件。

// hello.go
package hello

func Hello() string {
    return "Hello World."
}
// hello_test.go
package hello

import "testing"

func TestHello(t *testing.T) {
    want := "Hello, world."
    if got := Hello(); got != want {
        t.Errorf("Hello() = %q, want %q", got, want)
    }
}

当执行测试时,会有如下的输出信息。

$ go test
PASS
ok      _/tmp/HelloWorld    0.020s

因为当前目录不在 $GOPATH 且不是一个 Module 工程,所以上述的测试结果会根据当前路径生成一个虚拟的包名称。

当初始化包之后再执行,会直接输出包的名称。

$ go mod init example.com/hello
go: creating new go.mod: module example.com/hello
$ go test
PASS
ok      example.com/hello    0.020s

注意,在 go mod init 过程中,会在 $GOPATH 目录下创建一个子目录 pkg/mod/cache 来缓存版本信息,所以使用时需要保证当前用户对该目录有写权限。

完成初始化之后会自动创建一个 go.mod 文件。

$ cat go.mod
module example.com/hello

go 1.13

改文件只会出现在模块的顶层。

2. 添加依赖

将上述的 hello.go 文件引入一个三方模块。

package hello

import "rsc.io/quote"

func Hello() string {
    return quote.Hello()
}

然后再次执行 go test

$ go test
go: downloading rsc.io/quote v3.1.0+incompatible
go: extracting rsc.io/quote v3.1.0+incompatible
go: downloading rsc.io/sampler v1.3.0
go: extracting rsc.io/sampler v1.3.0
go: downloading golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c
go: extracting golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c
go: finding rsc.io/sampler v1.3.0
go: finding golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c
PASS
ok      example.com/hello       0.003s

相关的工具链会解析 go.mod 中的包并下载,如果没有指定,那么会尝试下载最新的包及其相关的依赖包。然后会更新 go.mod 以及 go.sum 文件,包会缓存在 $GOPATH/pkg/mod 目录下。

module example.com/hello

go 1.13

require rsc.io/quote v3.1.0+incompatible

整个模块的依赖可以通过 go list -m all 查看。

go.mod

这里简单介绍一些相关的版本命名方式,详细可以参考 Pseudo Versions 中的介绍。

建议使用标准的 vX.Y.Z 的 tag 格式,如果没有会使用 v0.0.0-yyyymmddhhmmss-abcdefabcdef 的格式,其中 v0.0.0 表示最新的 tag ,接着是 UTC 提交时间,以及最近一次提交的 hash 值。这样,Go 就可以通过时间比较那个的版本最新。

golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c

还有一种是打了 tag ,但是没有使用模块,会有类似 v3.2.1+incompatible 的版本号。

3. 依赖升级

上述的 golang.org/x/text 因为默认的最小依赖原则,实际上下载的是一个老的版本,这里直接尝试更新成最新的版本。

$ go get golang.org/x/text
go: finding golang.org/x/text v0.3.2
go: downloading golang.org/x/text v0.3.2
go: extracting golang.org/x/text v0.3.2
$ go test
PASS
ok      example.com/hello       0.003s

也就是升级成了 v0.3.2 版本,而且测试通过。此时的 go.mod 文件会同步更新成如下内容。

module example.com/hello

go 1.13

require (
        golang.org/x/text v0.3.2 // indirect
        rsc.io/quote v3.1.0+incompatible
)

其中 indirect 表示非本模块直接引入的包,其它的标识可以查看 go help modules 命令。

同样的方式尝试更新 rsc.io/sampler 包。

$ go get rsc.io/sampler
go: finding rsc.io/sampler v1.99.99
go: downloading rsc.io/sampler v1.99.99
go: extracting rsc.io/sampler v1.99.99
$ go test
--- FAIL: TestHello (0.00s)
    hello_test.go:8: Hello() = "99 bottles of beer on the wall, 99 bottles of beer, ...", want "Hello, world."
FAIL
exit status 1
FAIL    example.com/hello       0.002s

不过这次更新后的测试没有通过,也就是说最新的版本是不兼容的,通过如下命令查看该包当前的版本。

$ go list -m -versions rsc.io/sampler
rsc.io/sampler v1.0.0 v1.2.0 v1.2.1 v1.3.0 v1.3.1 v1.99.99

然后尝试使用 v.1.3.1 版本,也就是命令 go get rsc.io/sampler@v1.3.1 ,其中 @XXXX 用来指定具体的版本号,默认是 @latest

4. 指定大版本号

引入一个函数 Proverb 返回那句 Go 里面经典的 Concurrency is not parallelism.,相关源码文件更新如下。

package hello

import (
        "rsc.io/quote"
        quoteV3 "rsc.io/quote/v3"
)

func Hello() string {
        return quote.Hello()
}

func Proverb() string {
        return quoteV3.Concurrency()
}
package hello

import "testing"

func TestHello(t *testing.T) {
        want := "Hello, world."
        if got := Hello(); got != want {
                t.Errorf("Hello() = %q, want %q", got, want)
        }
}

func TestProverb(t *testing.T) {
        want := "Concurrency is not parallelism."
        if got := Proverb(); got != want {
                t.Errorf("Proverb() = %q, want %q", got, want)
        }
}

然后同样调用 go test 测试。

$ go test
go: finding rsc.io/quote/v3 v3.1.0
go: downloading rsc.io/quote/v3 v3.1.0
go: extracting rsc.io/quote/v3 v3.1.0
PASS
ok      example.com/hello       0.003s

然后这个 hello 模块会依赖 rsc.io/quote 的两个版本。

$ go list -m rsc.io/q...
rsc.io/quote v3.1.0+incompatible
rsc.io/quote/v3 v3.1.0

每个大版本的路径都会添加版本号信息,例如上述的 v3 版本路径为 rsc.io/quote/v3,也就是 Semantic Import Versioning ,对于不兼容的版本使用不同的路径。

一般来说,同一个大版本中,应该是向前兼容的,当然也有例外,例如上述的 rsc.io/sampler v1.99.99

通过版本号的控制,可以对代码中的不同部分逐渐升级。

5. 升级到同一版本

上述的 rsc.io/quote 引入了两个版本,这里将其统一到同一个版本,也就是最新版本。首先查看文档,确认其对应的接口变化。

$ go doc rsc.io/quote/v3
package quote // import "rsc.io/quote/v3"

Package quote collects pithy sayings.

func Concurrency() string
func GlassV3() string
func GoV3() string
func HelloV3() string
func OptV3() string

然后直接替换为如下。

package hello

import "rsc.io/quote/v3"

func Hello() string {
    return quote.HelloV3()
}

func Proverb() string {
    return quote.Concurrency()
}

6. 清理不需要的包

如上,已经不再依赖 rsc.io/quote 这个包了,但是通过 go list -m all 查看时仍然存在。

$ cat go.mod
module example.com/hello

go 1.13

require (
        golang.org/x/text v0.3.2 // indirect
        rsc.io/quote v3.1.0+incompatible
        rsc.io/quote/v3 v3.1.0
        rsc.io/sampler v1.3.1 // indirect
)

这主要是因为,像 go test go build 这类的工具,很容易发现那些包需要,但如果要确认那些不再依赖,需要加载所有的包依赖。

可以通过 go mod tidy 手动清理。

$ cat go.mod
module example.com/hello

go 1.13

require (
        golang.org/x/text v0.3.2 // indirect
        rsc.io/quote/v3 v3.1.0
        rsc.io/sampler v1.3.1 // indirect
)

相关包的依赖关系可以通过 go mod graph 查看。

7. 其它

常用命令

----- 查看所有依赖
go list -u -m all

Semantic Import Versioning

详细可以参考 Semantic Import Versioning 中的相关介绍,这里只是对其关键信息的摘录。

主要是为了解决,当前项目引入了一个非兼容的包之后如何进行处理,而 go 的原则是,只要是相同的路径,对应的包就是向前兼容的,对于不兼容的包,则通过 vN 版本号解决。

这样带来的好处是,对于不兼容的接口,允许代码完成灰度的升级替换。

三方包

在 1.5 版本之前,包的管理方式简单的粗暴,仅通过环境变量进行设置。

GOROOT=/usr/local/golang
GOPATH=/home/USER/golang
GOBIN=/usr/local/golang/bin

其中 GOROOT 会保存编译器、工具链、基础源码库等基础代码,而 GOPATH 是用户自定义的代码所在位置。

如果执行 go install 安装包,那么对应的二进制会保存在 ${GOBIN}/bin 目录下,如果 GOBIN 环境变量不存在,那么就会保存在 ${GOPATH}/bin 目录下。

当通过 go get -v 下载包时,会将下载依赖包源码保存到 ${GOPATH}/src 目录下,然后在 ${GOPATH}/pkg 目录下生成该包的静态库,那么下次使用就不用再从源码编译。

在包搜索时,会依次查找 ${GOROOT} 以及 ${GOPATH} ,所以尽量不要重名。

参考