笨办法学 Golang(2):Go包基础
现如今即便是个人开发的一般程序,可能其包含的函数都超过了一万个,这些函数代码一般都由他人编写并打包为“包”或者“模块”的形式,并通过相关社区分发,最后由软件作者通过调用这些“包”或“模块”的函数来更高效地完成开发。因此在大部分时候,作者只会用到其中很小的一部分函数,但是通过“包”或“模块”的形式重用代码使得编程开发变得更加轻松,这也是现如今大部分编程语言都有自己的包管理工具和包分发渠道的原因。
在学习Go语言过程中,我们几乎每个例子都使用到了Go语言包,例如像fmt、os等这样具有常用功能的内置包在Go语言中有一百多个,我们习惯称之为标准包(标准库),这些标准包大部分都内置到Go本身。(完整列表可以在Go Walker查看,或者使用go list std命令查看标准包列表)。
在本文的学习中,将包含以下知识点:
- 包结构认识
- 包的使用基础
- 常用标准包详解
- 使用自定义包
包的基础
在大部分编程语言中都存在“包”概念,而任何一种包设计的目的都是为了简化大型软件设计和维护的工作,实际上包是函数和数据的集合,通过把一组有相关特性的函数和数据放进一个单元中,方便使用和更新相应的模块。这种包系统的设计使得每一个模块(包)都与程序、其他单元(其他包)保持一定的独立性,这使得每一个模块(包)可以被应用到不同的程序部分中,当然也包括其他程序中,甚至可以通过社区分发渠道流通到世界各地的项目中被不断重复利用,不仅降低了项目模块之间的耦合度,也提高了整体的开发效率。
在编写代码过程中,不同模块(包)之间为了实现某一个类似的功能可能会采用相同的名字去命名一个函数,如果一个软件开发过程中需要同时使用两个模块(包),就会在调用函数时产生歧义。为了解决这个问题Go语言引入了命名空间的概念。让每个包都定义一个命名空间,用于内部标识符的访问。因为每个命名空间关联一个特定的包,这使得我们在调用类型、函数时有了独一无二的简短明了的名字,避免在使用它们的时候产生命名冲突。
为了提高包的独立性以及安全性,Go语言的包可以通过控制包内名字的可见性来实现包的封装,通过限制包成员的可见性、隐藏具体的实现过程可以极大提高软件的安全性,同时开发人员调用时也不必关心其实现过程,直接使用包的API,另一方面也允许包的维护者在不影响包的用户使用的前提下调整包的内部实现。通过限制包内变量的可见性还可以强制用户通过某些特定函数来访问和更新内部变量,这样可以保证内部变量的一致性和并发时的互斥约束。
与大部分编译语言类似,在Go语言中,当我们改动了一个源文件,就必须重新编译该源文件,以及它对应的包和所有依赖该包的其他包。但即使是从头构建,Go语言编译器的编译速度也明显快于绝大部分编译语言。如此优异的编译速度主要得益于其包设计的三个特性。
- 显式声明:所有导入的包必须在每个文件的开头显式声明,这样的话编译器就没有必要读取和分析整个源文件来判断包的依赖关系。
- 无环依赖:禁止包的环状依赖,因为没有循环依赖,包的依赖关系形成一个有向无环图,每个包可以被独立编译,而且很可能是被并发编译。
- 无需遍历:编译后包的目标文件不仅仅记录包本身的导出信息,目标文件同时还记录了包的依赖关系。因此,在编译一个包的时候,编译器只需要读取每个直接导入包的目标文件,而不需要遍历所有依赖的的文件,毕竟很多都是重复的间接依赖。
1.1 包的结构
在前面的学习中我们就已经知道Go语言编译工具对源码目录有很严格的要求,每个工作空间(workspace)必须由bin、pkg、src三个目录组成。bin目录主要存放可执行文件;pkg目录存放编译好的库文件,主要是*.a文件;src目录下主要存放go的源文件。
.
|-- bin
| |-- goimports
| `-- gophernotes
|-- pkg
| `-- linux_amd64
| |-- github.com
| | |-- gopherds
| | | `-- gophernotes
| | | |-- internal
| | | | `-- repl.a
| ... ...
`-- src
|-- github.com
| |-- gopherds
... ...
1. 工作空间
因为Go语言采用了工作空间这种方式来管理本地代码,这与大部分编程语言不一样,因此这里解释一下GOROOT和GOPATH之间的关系。首先显而易见的一点就是GOROOT是一个全局并且唯一的变量,用于指定存放Go语言本身的目录路径(安装路径);而GOPATH是一个工作空间的变量,它可以有很多个(用;号分隔),用于指定工作空间的目录路径。例如:
GOPATH=$HOME/workspace/golib:$HOME/projects/go
通常go get会使用第一个工作空间保存下载的第三方库(包),在开发时不管是哪一个工作空间下载的包都可以在任意工作空间使用。注意一点就是尽量不要把GOROOT和GOPATH设置为同一个路径。
包的源代码书写与正常的Go语言没有区别,文件必须是UTF-8格式,编写规范与Go语言编程规范一致。
2. 包的源文件
包的代码必须全部都放在包中,并且源文件头部都必须一致使用package <name>的语句作声明。Go包可以由多个文件组成,所以文件名不需要与包名一致,包名建议使用小写字符。包名类似命名空间(namespace),与包所在目录、编译文件名无关,目录名尽量不要使用保留名称(main、all、std),对于可执行文件必须包含package main以及入口函数main。
Go语言使用名称首字母大小写来判断一个对象(全局变量、全局常量、类型、结构字段、函数、方法)的访问权限,对于包而言同样如此。包中成员名称首字母大小写决定了该成员的访问权限。首字母大写,可被包外访问,即为public(公开的);首字母小写,则仅包内成员可以访问,即为internal(内部的)。
与大部分现代编程语言一样,Go语言同样支持使用UTF-8字符来命名对象,因此关于“大写”这个概念不限于US ASCII,它被扩展到了所有大小写字母表(包括拉丁文、希腊文、斯拉夫文、亚美尼亚文和埃及古文等)。汉字一般没有大小写的概念(除了汉字数字),因此如果你使用汉字作为一个函数的名称,该函数默认是私有的,你需要在汉字前面加上一个大写字母才能使其变为公有函数。
3. 包的声明
上面提到每一个包内源文件都需要在开头声明所在包,这其实就是包的声明。包的声明对于包内而言主要用于源文件编译时能够为编译器指明哪些是包的源代码;对于包外而言,在导入包的时候可以使用“包名.函数名”的方式使用包内函数。
对于包名相同的情况,例如math/rand包和crypto/rand包的包名都是rand,Go语言也有相应的办法解决,在下一节导入包时我们会介绍。
关于包的声明有一个例外,那就是包编译后是一个可执行程序时,我们会使用package main的方式声明main包,这时候main包本身的导入路径是无关紧要的,这个名字实际是给go build构建命令一个信息,这个包编译完之后必须调用连接器生成一个可执行程序。(本文暂不讨论测试包的情况)
1.2 包的导入
就如前面阐述的一样,使用包成员之前先要导入包。导入包的关键字是import,因为Go语言包不能形成环形依赖,如果遇到导入包循环依赖的情况,Go语言的构建工具将返回错误。一般而言对于直接从分发渠道下载回来的包都不会轻易产生依赖环。
import "相对目录/包主文件名"
相对目录是指从<workspace>/pkg/<os_arch>开始的子目录,以标准库为例:
import "fmt" // 对应/usr/local/go/pkg/linux_amd64/fmt.a
import "os/exec" // 对应/usr/local/go/pkg/linux_amd64/os/exec.a
除了一行一个包的方式导入,还可以使用一条语句导入多个包的写法:
import (
"fmt"
"os/exec"
)
1. 导入声明
在上一节中我们有一个问题没有解决,就是同名包导入时会有冲突。虽然包的命名空间解决了函数重名的情况,但是没有避免包重名的情况,因此Go语言在导入包时可以对包名作重定向,以解决包名冲突的情况。例如下面的几种例子:
import "crypto/rand" // 默认模式: rand.Function
import R "crypto/rand" // 包重命名: R.Function
import . "crypto/rand" // 简便模式: Function
import _ "crypto/rand" // 匿名导入: 仅让该包执行行初始化函数。
另一种写法:
import (
"crypto/rand"
mrand "math/rand" // 包重命名
)
注意:
- Go语言不允许导入包却又不使用,如果导入的包并未使用,在编译时会被视为错误(不包括 "import _")。
- 包的重命名不仅可以用于解决包名冲突,还可以解决包名过长、避免与变量或常量名称冲突等情况。
除了以上比较常见的包导入方式,还有子包导入方式以及自定义路径导包方式。其中对于当前目录下的子包,除使用默认完整导入路径外,还可使用相对路径的方式。
.
└── src
└── test
├── main.go
└── test2
└── test.go
3 directories, 2 files
如上面的目录结构,可以在main.go文件中使用下面的方式导入test2这个包:
import "test/test2" // 一般我们使用这种方式导入
import "./test2" // 也可以使用相对目录,但这种方式导入的包仅对go run main.go有效。
如果在一个文件中导入的包比较多,为了管理源代码中导入的包,还可以为导入的包分组。分组是通过空行来分隔的,例如:
import (
"fmt"
"html/template"
"os"
"golang.org/x/net/html"
"golang.org/x/net/ipv4"
)
我们知道Go语言编译的时候会格式化代码,因此导入包的顺序并不需要我们调整,编译时会自动按字母排序。我们可以调整的只有包分组,同样每一个分组内的包在编译时会被格式化为按字母排序。
2. 导入路径
当前Go语言的规范并没有强制包的导入路径字符串的格式,导入路径由构建工具来解释。但如果你打算分享或发布你编写的包,那么最好使用全球唯一的导入路径。
这主要是为了避免导入路径冲突,因此有一个约定俗成的路径格式是:所有非标准库包的导入路径以所在组织的互联网域名为前缀,这样一来就有了一个独一无二的路径,另一方面也有利于包的检索。
例如下面导入的包中就有两个使用了互联网域名为前缀:
import (
"fmt"
"math/rand"
"encoding/json"
"golang.org/x/net/html"
"github.com/go-sql-driver/mysql"
)
3. 自定义路径
在上面一节中我们使用了一种域名为前缀的导入路径,对于编译器来说,只有较为流行的代码托管站点才可以直接使用这种路径。对于一些个人站点(例如企业自己搭建的私有GitLab仓库),为了可以更方便使用这种方式导入就需要告诉编译器这是一个包代码链接。
我们有三种方式实现这个功能,一是直接在包链接中加上VCS格式,目前支持的格式有:
Bazaar .bzr
Git .git
Mercurial .hg
Subversion .svn
例如:
import "example.org/user/foo.git"
第二种办法是针对没有提供版本控制符的链接,go get甚至不知道应该如何下载代码的情况,例如下面这种链接:
example.org/repo/foo
这个时候就需要在网页中加入一句标签:
<meta name="go-import" content="import-prefix vcs repo-root">
然后就可以使用链接导入:
import "example.org/pkg/foo"
第三种情况是重定向网页链接:
例如下面的情况,go get访问链接时会被重定向到example.org/r/p/exproj。
<meta name="go-import" content="example.org git https://example.org/r/p/exproj">
如果你没有服务器还可以使用Go语言搭建一个简单的本地服务器:
package main
import (
"fmt"
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `<meta name="go-import"
content="example.com/zuolan/test git https://github.com/zuolan/test">`)
}
func main() {
http.HandleFunc("/zuolan/test", handler)
http.ListenAndServe(":80", nil)
}
保存为server.go,然后编译执行,就可以实现把example.com/zuolan/test重定向到github.com/zuolan/test。
改动网页(这也是官网的方法),在
1.3 包的使用
为了更好理解包导入的细节,在本节中将创建一个包,这个包很简单,首先在工作空间建立一个项目test:
mkdir $GOPATH/src/test
在src/test中新建一个文件如下:
package test
// 公开函数
func Even(i int) bool {
return i % 2 == 0
}
// 私有函数
func odd (i int) bool {
return i % 2 == 1
}
然后保存为test.go文件,最后使用go build和go install命令编译和安装这个包。现在我们有了一个包,接下来看如何导入刚才建立的包,应用于新的程序之中。
新建一个文件,就叫做main.go吧(当然也可以命名为其他名字):
package main
// 下面导入了本地包test和官方标准包fmt
import (
"test"
"fmt"
)
// 调用test包中的Even函数。访问一个包中的函数的语法是package.Function()。
func main() {
i := 5
fmt.Printf("Is %d even? %v\n", i, test.Even(i))
}
在上面的例子中,如果使用了odd这个函数,在编译的时候就会报错,因为在test包中定义了odd函数为私有函数,不能被外部访问。