1.5 工程管理

在实际的开发工作中,直接调用编译器进行编译和链接的场景是少而又少,因为在工程中不会简单到只有一个源代码文件,且源文件之间会有相互的依赖关系。如果这样一个文件一个文件逐步编译,那不亚于一场灾难。Go语言的设计者作为行业老将,自然不会忽略这一点。早期Go语言使用makefile作为临时方案,到了Go 1发布时引入了强大无比的Go命令行工具。

Go命令行工具的革命性之处在于彻底消除了工程文件的概念,完全用目录结构和包名来推导工程结构和构建顺序。针对只有一个源文件的情况讨论工程管理看起来会比较多余,因为这可以直接用go rungo 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

GOPATHPATH环境变量一样,也可以接受多个路径,并且路径和路径之间用冒号分割。

设置完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

打印的错误信息非常简洁,却已经足够让开发者快速定位到问题代码所在的文件和行数,从而在最短的时间内确认是单元测试的问题还是程序的问题。

目录