读 Go 项目里的命令:run、build、test 分别做了什么

0 阅读7分钟

在 Go 项目的说明或检查脚本中,经常能看到:

go build .
go build ./...
go test ./...

这里的 ./... 是需要原样输入的命令参数:三个英文句点都是真实语法,不是省略内容的标记。

这些命令看起来相似,处理的代码范围和执行的操作却不一样。理解这两件事,才能判断一次“执行成功”究竟说明了什么。

先看命令选择了哪些代码

假设一个已有 go.mod 的项目,目录如下:

hello-go/
├── go.mod
├── main.go
├── message.go
└── greeting/
    └── greeting.go

根目录的 go.mod 内容如下:

module example.com/hello-go

go 1.22

go.mod 是模块的配置文件,一个模块可以包含多个包。module 行声明模块路径,作为其中各个包的导入路径前缀;go 1.22 声明模块要求的最低 Go 版本。

本例使用 example.com/hello-go 作为练习标识。根目录包的导入路径就是这个值,子目录中的包再加上相对目录名,因此 greeting/ 对应 example.com/hello-go/greeting。这个前缀来自配置,不是由文件夹名自动生成的;本地练习也不要求先创建对应的网站或远程仓库。Go 模块参考

再看两个根目录文件的实际代码。main.go:

package main

import "fmt"

func main() {
	fmt.Println(message())
}

message.go:

package main

func message() string {
	return "Hello, Go"
}

main 包由每个文件里的 package main 主动声明。 文件名 main.go 不会自动决定它属于哪个包。这里两个文件位于同一目录,且都声明 package main,因此构建时属于同一个包,main.go 可以直接调用 message.go 中定义的 message()。

package main 与 func main() 的职责也不同:前者声明包名,后者声明函数。Go 用名为 main 的包构建可执行程序,并要求它提供 main 入口函数。包声明规范、程序入口规范

子目录里的 greeting/greeting.go 则是:

package greeting

func Hello() string {
	return "Hello, Go"
}

import 导入的是包。同目录、同包的 main.go 和 message.go 无需相互导入,就会一起参与按包构建;greeting 位于另一个目录,是另一个包。当前 main 包尚未导入它,因此构建入口时没有对它的依赖。

在项目根目录执行:

go list ./...

输出:

example.com/hello-go
example.com/hello-go/greeting

go list 默认列出包的导入路径。这里有两个包,三个源码文件;命令的处理单位是包。

./... 中,./ 表示从当前目录出发,... 是由 Go 命令识别的包路径匹配符。在这个单模块项目中,./... 选择当前目录及子目录中匹配到的包,单独的 . 只选择当前目录的包。因此,go list . 只列出第一行,go list ./... 会列出两行。

显式填写文件名是另一种选择方式。例如 go run main.go 只将指定文件纳入这次编译,会漏掉本例的 message.go,出现 undefined: message。按包运行时,使用 go run .,Go 会按构建规则选择当前包的源码文件。

为什么 go build . 成功,go build ./... 却失败

先保持根目录两个文件为开头的版本,在 greeting/greeting.go 末尾追加:

var value = missingValue

missingValue 没有定义,这个包无法编译。此时实跑三个命令,会出现不同结果:

命令结果
go list ./...仍能列出两个包
go build .成功
go build ./...失败,报告 undefined: missingValue

go build . 以当前包为目标,并沿着 import 构建所需的依赖包。这个版本的 main 包只导入了 fmt,没有直接或间接依赖 greeting;go build ./... 则把 greeting 也明确选为目标,因此发现了错误。

再做一次对照:保留 greeting 中的错误,只将 message.go 改为:

package main

import "example.com/hello-go/greeting"

func message() string {
	return greeting.Hello()
}

import 中填写的是前面列出的包路径。greeting.Hello() 调用该包提供的函数;Hello 首字母大写,表示允许其他包访问。

这个例子里的包解析和构建,可以按三步理解:

  1. 选择当前包的源码。. 选定当前目录的 main 包,Go 按构建规则选出本次使用的源码文件,本例中就是 main.go 和 message.go。
  2. 将导入路径对应到源码位置。读取这些文件的 import:fmt 来自标准库;example.com/hello-go/greeting 的前缀与 go.mod 中的模块路径一致,所以 Go 将它对应到本地的 greeting/ 目录。
  3. 继续处理依赖并构建程序。对依赖包重复选择源码、查找导入的过程,再根据包之间的依赖关系安排构建。需要生成可执行文件时,把入口与所需依赖的编译结果组合起来,这一步叫链接。

因此,即使 main.go 没有变化,message.go 中的导入也使整个 main 包依赖 greeting。这次执行 go build .,同样会在 greeting 中报告 undefined: missingValue。构建范围取决于目标包及其直接、间接依赖。Go 导入声明规范

这说明了两个容易被忽略的边界:

  • 能列出包,不代表这些包都能编译。
  • 入口能构建,不代表项目内每个包都能构建。

所以,阅读一条检查命令时,除了看 build,还要看它后面选择了哪些包。当前目录也会影响这个范围:进入 greeting 后,. 就指向了另一个包。

观察后,删除 greeting/greeting.go 中新增的 var value = missingValue,再将 message.go 恢复为开头直接返回问候语的版本。

命令成功,分别验证了什么

编译检查源码并将它转换成可执行形式;运行才会实际执行程序中的流程。编译成功本身不会核对输出是否符合需求。

把常见命令放在一起,更容易看清各自的用途:

命令主要工作应关注的结果
go run .构建并运行当前入口包程序实际输出、运行错误与行为
go build .构建当前包及其依赖能否构建;单个入口包会生成可执行文件
go list ./...查看匹配到的包处理范围是否符合预期
go test ./...编译匹配到的包并执行其中的测试已有测试是否通过

go run 也需要构建,只是把构建和立即运行衔接起来。go build 保留的可执行文件则由我们另行启动。源码修改后,旧文件仍然保留构建时的行为,需要重新构建才能更新。官方编译与安装教程展示了这两种使用方式。

构建多个包时,go build ./... 常用于检查它们能否构建,不能期待每个包都生成一个可执行文件。需要交付程序文件时,应明确选择它对应的入口包。

测试也有自己的边界。本例没有 _test.go,执行 go test ./... 会看到 [no test files]。命令虽然成功,却没有验证任何测试用例。项目存在测试时,通过也只说明这些用例在当前环境下成立。官方测试教程解释了如何编写和执行第一条测试。

另外几个常用命令,先记住它们会改什么

运行和构建之外,整理格式、维护依赖与查询帮助也很常见:

命令用途与影响
go fmt ./...统一源码排版,并写回源文件
go mod tidy整理模块依赖,可能更新 go.mod、go.sum 并下载缺少的模块
go help build查看构建命令的帮助;将 build 换成其他命令名即可查询对应说明
go version查看当前终端使用的 Go 版本和平台信息

新建模块时才需要 go mod init 这类初始化操作;阅读已有项目时,先使用它现有的模块配置。具体选项可以在需要时查帮助。

读项目里的命令,先用 list 确认包的范围,再分别判断构建、测试和实际运行给出了什么证据。看到“成功”之后,仍要说得清:这次处理了哪些代码,执行了哪些检查,还有哪些行为需要观察。

参考与数据源