目录

go 包和管理工具

go 程序包组织结构和程序管理工具箱

1. 包简介

包和模块的概念几乎存在于所有的编程语言之中,它的存在是为了简化大型程序的设计和维护工作。通过将一组相关的特性放进一个独立的单元以便于理解和更新,这种特性提供诸多益处:

  1. 每个包可以被其它的不同项目共享和重用
  2. 包提供了一个独立的命名空间,减少了与其他部分的命名冲突
  3. 通过控制包内名字的可见性和是否导出来实现封装

Go 通过使用名字的开头字母的大小写决定了名字在包外的可见性,小写字符开头包成员不会导出,在包外不可见。通过这种方式可以严格的隐藏包内实现的 API,通过强制用户使用特定函数来访问和更新内部变量,可以保证内部变量的一致性和并发时的互斥约束。

当我们修改了一个源文件,我们必须重新编译该源文件对应的包和所有依赖该包的其他包。即使是从头构建,Go语言编译器的编译速度也明显快于其它编译语言。Go语言的闪电般的编译速度主要得益于三个语言特性:

  1. 第一点,所有导入的包必须在每个文件的开头显式声明,这样的话编译器就没有必要读取和分析整个源文件来判断包的依赖关系
  2. 第二点,禁止包的环状依赖,因为没有循环依赖,包的依赖关系形成一个有向无环图,每个包可以被独立编译,而且很可能是被并发编译
  3. 第三点,编译后包的目标文件不仅仅记录包本身的导出信息,目标文件同时还记录了包的依赖关系。因此,在编译一个包的时候,编译器只需要读取每个直接导入包的目标文件,而不需要遍历所有依赖的的文件。

本节我们就来学习与 Go 语言包相关的内容。

2. Go 程序包

2.1 包声明

Go 语言的源码也是以代码包为基本组织单位的。在文件系统中,这些代码包其实是与目录一一对应的。由于目录可以有子目录,所以代码包也可以有子包。一个代码包中可以包含任意个以.go 为扩展名的源码文件,这些源码文件都需要被声明属于同一个代码包。

在每个Go语言源文件的开头都必须有包声明语句。包声明语句的主要目的是确定当前包被其它包导入时默认的标识符(也称为包名)。代码包的名称一般会与源码文件所在的目录同名。如果不同名,那么在构建、安装的过程中会以代码包名称为准。

通常来说,默认的包名就是包导入路径名的最后一段,因此即使两个包的导入路径不同,它们依然可能有一个相同的包名。例如,math/rand包和crypto/rand包的包名都是rand。这也有三种例外情况。

第一个例外,包对应一个可执行程序,也就是main包,这时候main包本身的导入路径是无关紧要的。名字为main的包是给go build 构建命令一个信息,这个包编译完之后必须调用连接器生成一个可执行程序。

第二个例外,包所在的目录中可能有一些文件名是以 _test.go为后缀的Go源文件(译注:前面必须有其它的字符,因为以 _前缀的源文件是被忽略的),并且这些源文件声明的包名也是以_test为后缀名的。这种目录可以包含两种包:一种普通包,加一种则是测试的外部扩展包。所有以_test为后缀包名的测试外部扩展包都由go test命令独立编译,普通包和测试的外部扩展包是相互独立的。测试的外部扩展包一般用来避免测试代码中的循环导入依赖,具体细节我们将在下一章讲解。

第三个例外,一些依赖版本号的管理工具会在导入路径后追加版本号信息,例如"gopkg.in/yaml.v2"。这种情况下包的名字并不包含版本号后缀,而是yaml

2.2 包的导入

每个包是由一个全局唯一的字符串所标识的导入路径定位。在实际使用程序实体之前,我们必须先导入其所在的代码包。在工作区中,一个代码包的导入路径实际上就是从 src 子目录,到该包的实际存储位置的相对路径。而导入时包可以被重命名,被隐藏。下面是包导入时常用的语法:

1
2
3
4
5
6
7
8
package package_name

import fmt
import (
	"crypto/rand"
	mrand "math/rand"    // 包导入重命名,避免冲突
	import _ "image/png" // 匿名导入
)

需要注意的事:

  1. 包的导入必须在包声明语句之后,其它非导入声明语句之前
  2. 每个导入声明语句都明确指定了当前包和被导入包之间的依赖关系。如果遇到包循环导入的情况,Go语言的构建工具将报告错误。
  3. Go语言的规范并没有指明包的导入路径字符串的具体含义,导入路径的具体含义是由构建工具来解释的,当使用Go语言自带的go工具箱时,一个导入路径代表一个包在文件系统的路径

2.3 包的初始化

每个包在解决依赖的前提下,包会以导入声明的顺序初始化,包的初始化首先是解决包级变量的依赖顺序,然后按照包级变量声明出现的顺序依次初始化:

1
2
3
4
var a = b + c // a 第三个初始化, 为 3
var b = f() // b 第二个初始化, 为 2, 通过调用 f (依赖c)
var c = 1 // c 第一个初始化, 为 1
func f() int { return c + 1 }

如果包中含有多个.go源文件,它们将按照发给编译器的顺序进行初始化,Go语言的构建工具首先会将.go文件根据文件名排序,然后依次调用编译器编译。

每个包只会被初始化一次。因此,如果一个p包导入了q包,那么在p包初始化的时候可以认为q包必然已经初始化过了。初始化工作是自下而上进行的,main包最后被初始化。以这种方式,可以确保在main函数执行之前,所有依赖的包都已经完成初始化工作了。

对于在包级别声明的变量,如果有初始化表达式则用表达式初始化,还有一些没有初始化表达式的,例如某些表格数据初始化并不是一个简单的赋值过程。在这种情况下,我们可以用一个特殊的init初始化函数来简化初始化工作。每个文件都可以包含多个init初始化函数

1
func init() { /* ... */ }

这样的init初始化函数除了不能被调用或引用外,其他行为和普通函数类似。在每个文件中的init初始化函数,在程序开始执行时按照它们声明的顺序被自动调用。

匿名导入

如果只是导入一个包而并不使用导入的包将会导致一个编译错误。但是有时候我们只是想利用导入包而产生的副作用:计算包级变量的初始化表达式和执行导入包的init初始化函数。我们可以用下划线 _来重命名导入的包。像往常一样,下划线 _为空白标识符,并不能被访问。

包文档

Go语言中包文档注释一般是完整的句子,第一行是包的摘要说明,注释后仅跟着包声明语句。包注释可以出现在任何一个源文件中。如果包的注释内容比较长,一般会放到一个独立的源文件中;fmt包注释就有300行之多。这个专门用于保存包文档的源文件通常叫doc.go。

内部包

有时候,一个中间的状态可能也是有用的,对于一小部分信任的包是可见的,但并不是对所有调用者都可见。例如,当我们计划将一个大的包拆分为很多小的更容易维护的子包,但是我们并不想将内部的子包结构也完全暴露出去。同时,我们可能还希望在内部子包之间共享一些通用的处理包,或者我们只是想实验一个新包的还并不稳定的接口,暂时只暴露给一些受限制的用户使用

为了满足这些需求,Go语言的构建工具对包含internal名字的路径段的包导入路径做了特殊处理。这种包叫internal包,一个internal包只能被和internal目录有同一个父目录的包所导入。例如,net/http/internal/chunked内部包只能被net/http/httputil或net/http包导入,但是不能被net/url包导入。不过net/url包却可以导入net/http/httputil包。

1
2
3
4
net/http
net/http/internal/chunked
net/http/httputil
net/url

go 命令使用

go get

使用命令 go get可以下载一个单一的包或者用 …下载整个子目录里面的每个包。Go语言工具箱的go命令同时计算并下载所依赖的每个包,一旦 go get命令下载了包,然后就是安装包或包对应的可执行的程序

go get命令支持当前流行的托管网站GitHub、Bitbucket和Launchpad,可以直接向它们的版本控制系统请求代码。对于其它的网站,你可能需要指定版本控制系统的具体路径和协议,例如 Git或Mercurial。

1
2
3
4
5
6
go get github.com/golang/lint/golint
cd $GOPATH/src/golang.org/x/net

git remote v
origin https://go.googlesource.com/net (fetch)
origin https://go.googlesource.com/net (push)

需要注意的是导入路径含有的网站域名和本地Git仓库对应远程服务地址并不相同,真实的Git地址是go.googlesource.com。这其实是Go语言工具的一个特性,可以让包用一个自定义的导入路径,但是真实的代码却是由更通用的服务提供,例如googlesource.com或github.com。因为页面 https://golang.org/x/net/html 包含了如下的元数据,它告诉Go语言的工具当前包真实的Git仓库托管地址:

1
2
<meta name="go‐import"
content="golang.org/x/net git https://go.googlesource.com/net">

如果指定 ‐u命令行标志参数, go get命令将确保所有的包和依赖的包的版本都是最新的,然后重新编译和安装它们。如果不包含该标志参数的话,而且如果包已经在本地存在,那么代码那么将不会被自动更新。

go build

go build命令编译命令行参数指定的每个包。如果包是一个库,则忽略输出结果;这可以用于检测包的可以正确编译的。如果包的名字是main, go build将调用连接器在当前目录创建一个可执行程序;以导入路径的最后一段作为可执行程序的名字

默认情况下, go build命令构建指定的包和它依赖的包,然后丢弃除了最后的可执行文件之外所有的中间编译结果。

go install命令和 go build命令很相似,但是它会保存每个包的编译成果,而不是将它们都丢弃。被编译的包会被保存到$GOPATH/pkg目录下,目录路径和 src目录路径对应,可执行程序被保存到$GOPATH/bin目录。

goinstall命令和 go build命令都不会重新编译没有发生变化的包,这可以使后续构建更快捷。为了方便编译依赖的包, go build ‐i命令将安装每个目标所依赖的包。

因为编译对应不同的操作系统平台和CPU架构, go install命令会将编译结果安装到GOOS和GOARCH对应的目录。例如,在Mac系统,golang.org/x/net/html包将被安装到$GOPATH/pkg/darwin_amd64目录下的golang.org/x/net/html.a文件。

针对不同操作系统或CPU的交叉构建也是很简单的。只需要设置好目标对应的GOOS和GOARCH,然后运行构建命令即可。下面交叉编译的程序将输出它在编译时操作系统和CPU类型:有些包可能需要针对不同平台和处理器类型使用不同版本的代码文件,以便于处理底层的可移植性问题或提供为一些特定代码提供优化。如果一个文件名包含了一个操作系统或处理器类型名字,例如net_linux.go或asm_amd64.s,Go语言的构建工具将只在对应的平台编译这些文件。还有一个特别的构建注释注释可以提供更多的构建过程控制。例如,文件中可能包含下面的注释:

// +build linux darwin 在包声明和包注释的前面,该构建注释参数告诉 go build只在编译程序对应的目标操作系统是Linux或Mac OS X时才编译这个文件。下面的构建注释则表示不编译这个文件

// +build ignore 更多细节,可以参考go/build包的构建约束部分的文档

go doc

go doc命令,该命令打印包的声明和每个成员的文档注释,该命令并不需要输入完整的包导入路径或正确的大小写

1
2
3
4
go doc time
go doc time.Since
go doc time.Duration.Seconds
go doc json.decode

godoc,它提供可以相互交叉引用的HTML页面,但是包含和 go doc命令相同以及更多的信息。godoc的在线服务 https://godoc.org ,包含了成千上万的开源包的检索工具。你也可以在自己的工作区目录运行godoc服务。运行下面的命令,然后在浏览器查看 http://localhost:8000/pkg 页面:

$ godoc ‐http :8000

其中 ‐analysis=type和 ‐analysis=pointer命令行标志参数用于打开文档和代码中关于静态分析的结果

go list

go list命令可以查询可用包的信息。其最简单的形式,可以测试包是否在工作区并打印它的导入路径,还可以用 “…“表示匹配任意的包的导入路径。我们可以用它来列表工作区中的所有包:

1
2
3
4
5
6
$ go list github.com/go‐sql‐driver/mysql
github.com/go‐sql‐driver/mysql

$ go list gopl.io/ch3/...

$ go list ...xml...

go list命令还可以获取每个包完整的元信息,而不仅仅只是导入路径,这些元信息可以以不同格式提供给用户。其中 ‐json命令行参数表示用JSON格式打印每个包的元信息。命令行参数 ‐f则允许用户使用text/template包(§4.6)的模板语言定义输出文本的格式。

1
2
3
go list ‐json hash
go list ‐f '{{join .Deps " "}}' strconv
go list ‐f '{{.ImportPath}} ‐> {{join .Imports " "}}' compress/...