1.5 工程管理
在实际的开发工作中,直接调用编译器进行编译和链接的场景是少而又少,因为在工程中不会简单到只有一个源代码文件,且源文件之间会有相互的依赖关系。如果这样一个文件一个文件逐步编译,那不亚于一场灾难。Go语言的设计者作为行业老将,自然不会忽略这一点。早期Go语言使用makefile作为临时方案,到了Go 1发布时引入了强大无比的Go命令行工具。
Go命令行工具的革命性之处在于彻底消除了工程文件的概念,完全用目录结构和包名来推导工程结构和构建顺序。针对只有一个源文件的情况讨论工程管理看起来会比较多余,因为这可以直接用go run和go build搞定。下面我们将用一个更接近现实的虚拟项目来展示Go语言的基本工程管理方法。
假设有这样一个场景:我们需要开发一个基于命令行的计算器程序。下面为此程序的基本用法:
$ calc help USAGE: calc command [arguments] ... The commands are: sqrt Square root of a non-negative value. add Addition of two values. $ calc sqrt 4 # 开根号 2 $ calc add 1 2 # 加法 3
我们假设这个工程被分割为两个部分:
❏ 可执行程序,名为calc,内部只包含一个calc.go文件;
❏ 算法库,名为simplemath,每个command对应于一个同名的go文件,比如add.go。
则一个正常的工程目录组织应该如下所示:
<calcproj> ├—<src> ├—<calc> ├—calc.go ├—<simplemath> ├—add.go ├—add_test.go ├—sqrt.go ├—sqrt_test.go ├—<bin> ├—<pkg>#包将被安装到此处
在上面的结构里,带尖括号的名字表示其为目录。xxx_test.go表示的是一个对于xxx.go的单元测试,这也是Go工程里的命名规则。
为了让读者能够动手实践,这里我们会列出所有的源代码并以注释的方式解释关键内容,如代码清单1-5至代码清单1-9所示。需要注意的是,本示例主要用于示范工程管理,并不保证代码达到产品级质量。
代码清单1-5 calc.go
//calc.go package main import "os"// 用于获得命令行参数os.Args import "fmt" import "simplemath" import "strconv" var Usage = func() { fmt.Println("USAGE: calc command [arguments] ...") fmt.Println("\nThe commands are:\n\tadd\tAddition of two values.\n\tsqrt\tSquare root of a non-negative value.") } func main() { args := os.Args[1:] if args == nil || len(args) < 2 { Usage() return } switch args[0] { case "add": if len(args) ! = 3 { fmt.Println("USAGE: calc add <integer1><integer2>") return } v1, err1 := strconv.Atoi(args[1]) v2, err2 := strconv.Atoi(args[2]) if err1 ! = nil || err2 ! = nil { fmt.Println("USAGE: calc add <integer1><integer2>") return } ret := simplemath.Add(v1, v2) fmt.Println("Result: ", ret) case "sqrt": if len(args) ! = 2 { fmt.Println("USAGE: calc sqrt <integer>") return } v, err := strconv.Atoi(args[1]) if err ! = nil { fmt.Println("USAGE: calc sqrt <integer>") return } ret := simplemath.Sqrt(v) fmt.Println("Result: ", ret) default: Usage() } }
代码清单1-6 add.go
// add.go package simplemath func Add(a int, b int) int { return a + b }
代码清单1-7 add_test.go
// add_test.go package simplemath import "testing" func TestAdd1(t *testing.T) { r := Add(1, 2) if r ! = 3 { t.Errorf("Add(1, 2) failed. Got %d, expected 3.", r) } }
代码清单1-8 sqrt.go
// sqrt.go package simplemath import "math" func Sqrt(i int) int { v := math.Sqrt(float64(i)) return int(v) }
代码清单1-9 sqrt_test.go
// sqrt_test.go package simplemath import "testing" func TestSqrt1(t *testing.T) { v := Sqrt(16) if v ! = 4 { t.Errorf("Sqrt(16) failed. Got %v, expected 4.", v) } }
为了能够构建这个工程,需要先把这个工程的根目录加入到环境变量GOPATH中。假设calcproj目录位于~/goyard下,则应编辑~/.bashrc文件,并添加下面这行代码:
export GOPATH=~/goyard/calcproj
然后执行以下命令应用该设置:
$ source ~/.bashrc
GOPATH和PATH环境变量一样,也可以接受多个路径,并且路径和路径之间用冒号分割。
设置完GOPATH后,现在我们开始构建工程。假设我们希望把生成的可执行文件放到calcproj/bin目录中,需要执行的一系列指令如下:
$ cd ~/goyard/calcproj $ mkdir bin $ cd bin $ go build calc
顺利的话,将在该目录下发现生成的一个叫做calc的可执行文件,执行该文件以查看帮助信息并进行算术运算:
$ ./calc USAGE: calc command [arguments] ... The commands are: addAddition of two values. sqrtSquare root of a non-negative value. $ ./calc add 2 3 Result: 5 $ ./calc sqrt 9 Result: 3
从上面的构建过程中可以看到,真正的构建命令就一句:
go build calc
这就是为什么说Go命令行工具是非常强大的。我们不需要写makefile,因为这个工具会替我们分析,知道目标代码的编译结果应该是一个包还是一个可执行文件,并分析import语句以了解包的依赖关系,从而在编译calc.go之前先把依赖的simplemath编译打包好。Go命令行程序制定的目录结构规则让代码管理变得非常简单。
另外,我们在写simplemath包时,为每一个关键的函数编写了对应的单元测试代码,分别位于add_test.go和sqrt_test.go中。那么我们到底怎么运行这些单元测试呢?这也非常简单。因为已经设置了GOPATH,所以可以在任意目录下执行以下命令:
$ go test simplemath ok simplemath0.014s
可以看到,运行结果列出了测试的内容、测试结果和测试时间。如果我故意把add_test.go的代码改成这样的错误场景:
func TestAdd1(t *testing.T) { r := Add(1, 2) if r ! = 2 { // 这里本该是3,故意改成2测试错误场景 t.Errorf("Add(1, 2) failed. Got %d, expected 3.", r) } }
然后我们再次执行单元测试,将得到如下的结果:
$ go test simplemath --- FAIL: TestAdd1 (0.00 seconds) add_test.go:8: Add(1, 2) failed. Got 3, expected 3. FAIL FAILsimplemath0.013s
打印的错误信息非常简洁,却已经足够让开发者快速定位到问题代码所在的文件和行数,从而在最短的时间内确认是单元测试的问题还是程序的问题。