2.2 工程结构

Go是一门推崇软件工程理念的编程语言,它为开发周期的每个环节都提供了完备的工具和支持。Go语言高度强调代码和项目的规范和统一,这集中体现在工程结构或者说代码体制的细节之处。

Go也是一门开放的语言,它本身就是开源软件。更重要的是,Go语言可以让程序开发者很容易地通过go get命令从各种公共代码库(比如著名的代码托管网站Github)中下载开源代码并使用它们。这除了得益于Go语言自带命令的强大之外,还应该归功于Go语言工程结构的严谨和完善。在本节,我们就对Go语言的工程结构进行详述。

2.2.1 工作区

一般情况下,Go源码文件都需要放在工作区中。但是对于命令源码文件来说,这不是必须的。工作区其实就是一个对应于特定工程的目录,它应包含3个子目录:src目录、pkg目录和bin目录,下面逐一说明。

  • src目录:用于以代码包的形式组织并保存Go源码文件。这里的代码包,与src下的子目录一一对应。例如,若一个源码文件被声明为属于代码包logging,那么它就应当被保存在src目录下名为logging的子目录中。当然,我们也可以把Go源码文件直接放于src目录下,但这样的Go源码文件就只能被声明为属于main代码包了。除非用于临时测试或演示,一般还是建议把Go源码文件放入特定的代码包中。有关Go代码包,请参考2.2.4节的内容。顺便提一句,Go语言的源码文件分为3类:Go库源码文件、Go命令源码文件和Go测试源码文件。

  • pkg目录:用于存放经由go install命令构建安装后的代码包(包含Go库源码文件)的“.a”归档文件。该目录与GOROOT目录下的pkg功能类似。区别在于,工作区中的pkg目录专门用来存放用户(也就是程序开发者)代码的归档文件。构建和安装用户源码的过程一般会以代码包为单位进行,比如logging包被编译安装后,将生成一个名为logging.a的归档文件,并存放在当前工作区的pkg目录下的平台相关目录中。有关go install命令,请参考2.3节的内容。

  • bin目录:与pkg目录类似,在通过go install命令完成安装后,保存由Go命令源码文件生成的可执行文件。在Linux操作系统下,这个可执行文件一般是一个与源码文件同名的文件。而在Windows操作系统下,这个可执行文件的名称是源码文件名称加.exe后缀。

注意 这里有必要明确一下Go语言的命令源码文件和库源码文件的区别。所谓命令源码文件,就是声明为属于main代码包,并且包含无参数声明和结果声明的main函数的源码文件。这类源码文件可以独立运行(使用go run命令),也可被go build或go install命令转换为可执行文件。而库源码文件则是指存在于某个代码包中的普通源码文件。

2.2.2 GOPATH

我们需要将工作区的目录路径添加至环境变量GOPATH中。否则,即使处于同一工作区(事实上,未被加入到环境变量GOPATH中的目录不应该被称为工作区),代码之间也无法通过绝对代码包路径完成调用。在实际开发环境中,工作区往往有多个。这些工作区的目录路径都需要添加至GOPATH。以Linux操作系统为例,若有如下两个工作区:

  1. ~/golang/lib
  2. ~/golang/goc2p

则需要修改/etc/profile文件,并加入设置环境变量GOPATH的内容:

  1. export GOPATH=$HOME/golang/lib:$HOME/golang/goc2p

之后,保存/etc/profile文件,并用source命令使配置生效。当然,我们也可以修改当前用户的home目录下的.bash_profile文件,但是添加的内容都是一样的。

注意

  • GOPATH中不要包含环境变量GOROOT的值(即Go的安装目录路径),以此将Go语言本身的工作区同用户工作区严格地分开;

  • 通过Go工具中的代码获取命令go get,可将指定项目的源码下载到我们在环境变量GOPATH中设定的第一个工作区中,并在其中完成构建和安装的过程。

本书约定,在$HOME/golang/lib目录中存放第三方代码库,而在$HOME/golang/goc2p目录中存放本书的示例和附属代码库。

2.2.3 源码文件

本书为涉及的Go代码示例建立了项目goc2p,以求对它们进行更加规范的组织(2.2.2节已将goc2p项目的根目录路径添加到了环境变量GOPATH中)。该项目的源码文件全部存储在src子目录中,部分结构如下:

  1. /home/hc/golang/goc2p/src:
  2. basic/
  3. set/
  4. set_test.go
  5. set.go
  6. cnet/
  7. ctcp/
  8. base.go
  9. tcp.go
  10. tcp_test.go
  11. helper/
  12. ds/
  13. showds.go
  14. logging/
  15. tag.go
  16. base.go
  17. logger_test.go
  18. console_logger.go
  19. log_manager.go

goc2p项目的代码包中,目前仅4个包含源码文件的代码包,分别为basic/setcnet/ctcphelper/dslogging。 其中,代码包helper/ds中含有一个命令源码文件showds.go,可以直接通过go run命令运行。上面的目录树结构示意就是由它生成的。 其余3个包只含有库源码文件和测试源码文件(文件名以“_test.go”结尾)。因此,该项目包含了Go源码文件的所有3个种类,即命令源码文件、库源码文件和测试源码文件,下面对它们进行详细说明。

1. 命令源码文件

如果一个源码文件被声明为属于main代码包,且该文件代码中包含无参数声明和结果声明的main函数,则它就是命令源码文件。命令源码文件可通过go run命令直接启动运行。

同一个代码包中的所有源码文件,其所属代码包的名称必须一致。如果命令源码文件和库源码文件处于同一个代码包中,那么在该包中就无法正确执行go buildgo install命令。换句话说,这些源码文件也就无法被编译和安装。因此,命令源码文件通常会单独放在一个代码包中。这是合理的,因为一般情况下,一个程序模块或软件的启动入口只有一个。

同一个代码包中可以有多个命令源码文件,可通过go run命令分别运行它们。但这种情况会使go buildgo install命令无法编译和安装该代码包。所以一般情况下,也不建议把多个命令源码文件放在同一个代码包中。

当代码包中有且仅有一个命令源码文件时,在文件所在目录中执行go build命令,即可在该目录下生成一个与目录同名的可执行文件;而若使用go install命令,则可在当前工作区的bin目录下生成相应的可执行文件。例如,代码包helper/ds中只有一个源码文件showds.go,且它是命令源码文件,则相关操作和结果如下:

  1. hc@ubt:~/golang/goc2p/src/helper/ds$ ls
  2. showds.go
  3. hc@ubt:~/golang/goc2p/src/helper/ds$ go build
  4. hc@ubt:~/golang/goc2p/src/helper/ds$ ls
  5. ds showds.go
  6. hc@ubt:~/golang/goc2p/src/helper/ds$ go install
  7. hc@ubt:~/golang/goc2p/src/helper/ds$ ls ../../../bin
  8. ds

需要特别注意的是,只有当环境变量GOPATH中只包含一个工作区的目录路径时,go install命令才会把命令源码文件安装到当前工作区的bin目录下。若环境变量GOPATH中包含多个工作区的目录路径,像这样执行go install命令就会失败,此时必须设置环境变量GOBIN

2. 库源码文件

通常,库源码文件声明的包名会与它实际所属的代码包(目录)名一致,且库源码文件中不包含无参数声明和无结果声明的main函数。下面来安装(包含编译过程)basic/set包,其中含有若干库源码文件:

  1. hc@ubt:~/golang/goc2p/src/basic/set$ ls
  2. set.go set_test.go
  3. hc@ubt:~/golang/goc2p/src/basic/set$ go install
  4. hc@ubt:~/golang/goc2p/src/basic/set$ ls ../../../pkg
  5. linux_386
  6. hc@ubt:~/golang/goc2p/src/basic$ cd ../../../pkg/linux_386/basic
  7. hc@ubt:~/golang/goc2p/pkg/linux_386/basic$ ls
  8. set.a

这里,我们通过在basic/set目录下执行go install命令,成功地安装了basic/set包,并生成一个名为set.a的归档文件。归档文件的存放目录由以下规则产生。

  • 安装库源码文件时所生成的归档文件会被存放到当前工作区的pkg目录中。goc2p项目的set包所属的工作区的根目录是~/golang/goc2p。因此,上面所说的pkg目录即~/golang/goc2p/pkg。

  • 根据被编译时的目标计算架构,归档文件会被放置在pkg目录下的平台相关目录中。例如,我的安装操作是在Linux 32bit环境下进行的,对应的平台相关目录就是linux_386,归档文件set.a一定会被放到~/golang/goc2p/pkg/linux_386目录中的某个地方。

  • 存放归档文件的目录的相对路径与被安装的代码包的上一级代码包的相对路径是一致的。第一个相对路径是相对于工作区的pkg目录下的平台相关目录而言的,而第二个相对路径是相对于工作区的src目录而言的。如果被安装的代码包没有上一级代码包(也就是说它的父目录就是工作区的src目录),那么它的归档文件就会被直接存放到当前工作区的pkg目录的平台相关目录下。例如,basic包的归档文件basic.a总会被直接存放到~/golang/goc2p/pkg/linux_386目录下,而basic/set包的归档文件set.go则会被存放到~/golang/goc2p/pkg/linux_386/basic目录下。

3. 测试源码文件

测试源码文件是一种特殊的库文件,可以通过执行go test命令运行当前代码包下的所有测试源码文件。成为测试源码文件的充分条件有两个。

  • 文件名需要以“_test.go”结尾。

  • 文件中需要至少包含一个名称以“Test”开头或“Benchmark”开头、拥有一个类型为“testing.T”或“testing.B”的参数的函数。类型“testing.T”和“testing.B”分别对应功能测试和基准测试所需的结构体。

当我们在一个代码包中执行go test命令时,该代码包中的所有测试源码文件就会被找到并运行。我们依然用basic/set包做例子:

  1. hc@ubt:~/golang/goc2p/src/basic/set$ go test
  2. PASS
  3. ok basic/set 0.086s

我们使用go test命令在basic/set包中找到并运行了测试源码文件set_test.go,并调用了其中所有的测试函数。命令行的回显信息表示我们通过了测试,并且运行测试源码文件中的测试程序共花费了0.086秒。

最后,还有一点需要注意,存储Go代码的文本文件需要以UTF-8编码存储。如果源码文件中出现了非UTF-8编码的字符,则在运行、编译或安装时,Go会抛出“illegal UTF-8 sequence”的错误。

因力求对源码文件的讲解的连贯性和完整性,本小节也涉及了很多关于Go语言代码包和命令的内容,但并没有立即深入探讨。下一小节,我们就对Go语言的代码包进行专门的描述。

2.2.4 代码包

对于大多数计算机编程语言来说,代码包都是组织代码最有效和最直观的方式。如前文所述,Go语言中的代码包是对代码进行构建和打包的基本单元。在本节,我们继续以goc2p项目的src目录为辅助,对代码包进行说明。

1. 包声明

细心的读者可能已经发现,在goc2p项目中,src目录的每个代码包中的源码文件名称看似都与包名没什么联系。实际上,在Go语言中,代码包中的源码文件名可以是任意的。比如,在logging包中,没有任何源码文件的名称与包名相同。这些任意名称的源码文件都必须以包声明语句作为文件中代码的第一行。比如,basic/set包中的所有源码文件都要先声明自己属于basic/set包:

  1. package set

package是Go语言中用于包声明语句的关键字。Go语言规定包声明中的包名为代码包路径的最后一个元素。比如,basic/set包的包路径为basic/set,而包声明中的包名则为set。但有一个例外,前文提到过,不论命令源码文件存放在哪个代码包中,它都必须声明为属于main包。

2. 包导入

goc2p项目目前包含了4个存在源码文件的代码包:basic/sethelper/dscnet/ctcplogging。并且,goc2p库的目录已被添加到环境变量GOPATH中。那我们怎么在其他源码文件中导入(或称依赖)它们呢?请看下面的例子:

  1. import "basic/set"
  2. import "helper/ds"
  3. import "cnet/ctcp"
  4. import "logging"

代码包的导入使用代码包导入路径。代码包导入路径就是代码包在工作区的src目录下的相对路径。比如,代码包ctcp的绝对目录路径是~/golang/goc2p/src/cnet/ctcp,而~/golang/goc2p是被包含在环境变量GOPATH中的工作区目录路径,那么其代码包导入路径就是cnet/ctcp。

当导入多个代码包时,需要用圆括号括起它们,且每个代码包名独占一行。在调用被导入代码包中的函数或使用其中的结构体、变量或常量时,需要使用包路径的最后一个元素加“.”的方式指定代码所在的包。

如果我们有两个包logginggo_lib/logging,且有一个源码文件需要导入这两个包:

  1. import (
  2. "logging"
  3. "go_lib/logging"
  4. )

则这句代码logging.NewSimpleLogger()就会引起冲突,Go语言无法知道logging.代表的是哪一个包。所以,在Go语言中,如果在同一个源码文件中导入多个代码包,那么代码包路径的最后一个元素不可以重复。

如果用上面这段代码包导入代码,那么在编译代码时,Go语言会抛出“logging redeclared as imported package name”的错误。如果这种代码包确实有必要导入,那我们又该怎么做呢?当有这类重复时,我们可以给它们起个别名来区分,比如:

  1. import (
  2. la "logging"
  3. lb "go_lib/logging"
  4. )

如此导入之后,我们就可以调用包中的代码了:

  1. var logger la.Logger = la.NewSimpleLogger()

这里不必给每个引起冲突的代码包都起一个别名,只要能区分它们就可以了。

如果我们想直接调用某个依赖包的程序,就可以用“.”来代替别名,如下所示:

  1. import (
  2. . "logging"
  3. lb "go_lib/logging"
  4. )

看到那个“.”了吗?之后,在当前源码文件中,我们就可以直接进行代码调用了:

  1. var logger Logger = NewSimpleLogger()

细心的读者可能观察到函数NewSimpleLogger()的名称首字母是大写的。这好像与其他编程语言的编码规范不太一致。

Go语言把变量、常量、函数、结构体和接口统称为程序实体,而把它们的名字统称为标识符。标识符可以是任何Unicode编码可以表示的字母字符、数字以及下划线“_”。并且,首字母不能是数字或下划线。

实际上,标识符的首字母的大小写控制着对应程序实体的访问权限。如果标识符的首字母是大写的,那么它所对应的程序实体就可以被本代码包之外的代码访问到,也可以称其为可导出的。否则,对应的程序实体就只能被本包内的代码访问。当然,还有以下两个额外条件。

  • 程序实体必须是非局部的。局部程序实体的意思是,它被定义在了函数或结构体的内部。

  • 代码包所在的目录必须被包含在环境变量GOPATH中的工作区目录中。

举个例子,如果代码包logging中有一个叫作getSimpleLogger的函数。那么光从这个函数的名字上我们就可以看出,这个函数是不能被包外代码调用的。

回到代码包导入的问题上来。代码包导入还有另外一种情况。如果我们只想初始化某个代码包而不需要在当前源码文件中使用那个代码包中的任何代码,就可以用“_”来代替别名,如下所示:

  1. import (
  2. _ "logging"
  3. )

这种情况下,我们只触发了对代码包logging的初始化操作。符号“_”就像一个垃圾桶,它在Go语言代码中使用很广泛,在后续章节中我们可以看到。

一个代码包怎样被初始化呢?我们下面开始说明这个问题。

3. 包初始化

在Go语言中,可以有专门的函数负责代码包初始化。这个函数需要无参数声明和结果声明,且名称必须为init,如下所示:

  1. func init() {
  2. println("Initialize...")
  3. }

Go语言会在程序真正执行前对整个程序的依赖进行分析,并初始化相关的代码包。也就是说,所有的代码包初始化函数都会在main函数(命令源码文件中的入口函数)之前执行完成,而且只会执行一次。并且,对于每一个代码包来说,其中的所有全局变量的初始化都会在该代码包的初始化函数执行前完成。这就避免了在代码包初始化函数对某个变量进行赋值之后又被该变量声明中赋予的值覆盖掉的问题。

例如,我们有如下源码文件:

  1. package main // 命令源码文件必须在这里声明自己属于main包
  2. import ( // 引入了代码包fmt和runtime
  3. "fmt"
  4. "runtime"
  5. )
  6. func init() { // 包初始化函数
  7. fmt.Printf("Map: %v\n", m) // 先格式化再打印
  8. // 通过调用runtime包的代码获取当前机器所运行的操作系统以及计算架构
  9. // 而后通过fmt包的Sprintf方法进行字符串格式化并赋值给变量info
  10. info = fmt.Sprintf("OS: %s, Arch: %s", runtime.GOOS, runtime.GOARCH)
  11. }
  12. var m map[int]string = map[int]string{1: "A", 2: "B", 3: "C"}
  13. // 非局部变量,map类型,已被显式赋值
  14. var info string // 非局部变量,string类型,未被显式赋值
  15. func main() { // 命令源码文件必须有的入口函数
  16. fmt.Println(info) // 打印变量info
  17. }

我们把它命名为initpkg_demo.go,并保存到goc2p项目的basic/pkginit包中。现在我们来运行这个文件:

  1. hc@ubt:~/golang/goc2p/src/basic/pkginit$ go run initpkg_demo.go
  2. Map: map[1:A 2:B 3:C]
  3. OS: linux, Arch: 386

关于每行代码的用途,在源码文件中已经作了基本的解释,这里我们再解释一下这个小程序的输出。

输出的第一行是对变量m格式化后的结果。这就意味着,在函数init的第一条语句执行时,变量m已经被初始化并赋值了。这验证了一条规则:当前代码包中所有全局变量的初始化会在代码包初始化函数执行前完成。

输出的第二行是对变量info格式化后的结果。变量info被定义时并没有被显式赋值,因此它被赋予类型string的零值——""(空字符串)。之后,变量info在代码包初始化函数init中被赋值,并在入口函数main中被输出。可见,所有的包初始化函数都会在main函数之前执行完成。

在同一个代码包中,可以存在多个代码包初始化函数,甚至代码包内的每一个源码文件都可以定义多个代码包初始化函数。Go语言编译器不能保证同一个代码包中的多个代码包初始化函数的执行顺序。如果确实要求按特定顺序执行的话,可以考虑使用Channel进行控制。Channel是Go语言并发编程模型中的一员,在本书第三部分,我们会详细介绍它。

此外,被导入的代码包的初始化函数总是会在导入它的那个代码包的初始化函数之前执行。

最后需要说明的是,Go语言认可两个特殊的代码包名称——allstdall代表了环境变量GOPATH中包含的所有工作区中的所有代码包,而std则代表了Go语言标准库中的所有代码包。