在 Go 项目的说明或检查脚本中,经常能看到:
bash
go build .
go build ./...
go test ./...
这里的 ./... 是需要原样输入的命令参数:三个英文句点都是真实语法,不是省略内容的标记。
这些命令看起来相似,处理的代码范围和执行的操作却不一样。理解这两件事,才能判断一次"执行成功"究竟说明了什么。
先看命令选择了哪些代码
假设一个已有 go.mod 的项目,目录如下:
text
hello-go/
├── go.mod
├── main.go
├── message.go
└── greeting/
└── greeting.go
根目录的 go.mod 内容如下:
text
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:
go
package main
import "fmt"
func main() {
fmt.Println(message())
}
message.go:
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 则是:
go
package greeting
func Hello() string {
return "Hello, Go"
}
import 导入的是包。同目录、同包的 main.go 和 message.go 无需相互导入,就会一起参与按包构建;greeting 位于另一个目录,是另一个包。当前 main 包尚未导入它,因此构建入口时没有对它的依赖。
在项目根目录执行:
bash
go list ./...
输出:
text
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 末尾追加:
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 改为:
go
package main
import "example.com/hello-go/greeting"
func message() string {
return greeting.Hello()
}
import 中填写的是前面列出的包路径。greeting.Hello() 调用该包提供的函数;Hello 首字母大写,表示允许其他包访问。
这个例子里的包解析和构建,可以按三步理解:
- 选择当前包的源码 。
.选定当前目录的main包,Go 按构建规则选出本次使用的源码文件,本例中就是main.go和message.go。 - 将导入路径对应到源码位置 。读取这些文件的
import:fmt来自标准库;example.com/hello-go/greeting的前缀与go.mod中的模块路径一致,所以 Go 将它对应到本地的greeting/目录。 - 继续处理依赖并构建程序。对依赖包重复选择源码、查找导入的过程,再根据包之间的依赖关系安排构建。需要生成可执行文件时,把入口与所需依赖的编译结果组合起来,这一步叫链接。
因此,即使 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 确认包的范围,再分别判断构建、测试和实际运行给出了什么证据。看到"成功"之后,仍要说得清:这次处理了哪些代码,执行了哪些检查,还有哪些行为需要观察。