一条 go build . 没有列出所有源码文件,也没有写明依赖包的磁盘位置,却能生成程序。
Go 如何把这个点变成一组源码,再把源码变成可执行文件?可以用一个小项目,结合工具实际给出的包信息,观察中间的选择过程。
准备一份可以对照的代码
项目结构如下:
text
build-demo/
├── go.mod
├── main.go
├── message.go
└── greeting/
└── greeting.go
go.mod:
text
module example.com/build-demo
go 1.22
module 声明本模块的路径,作为其中各个包的导入路径前缀;go 1.22 声明模块要求的最低 Go 版本。
main.go:
go
package main
import "fmt"
func main() {
fmt.Println(message())
}
message.go:
go
package main
import "example.com/build-demo/greeting"
func message() string {
return greeting.Hello()
}
greeting/greeting.go:
go
package greeting
func Hello() string {
return "Hello, Go"
}
两个根目录文件都主动声明 package main,属于同一个包,可以直接调用彼此定义的函数。main 是 Go 规定的可执行程序包名,func main() 提供入口;fmt.Println 负责输出文字。
子目录里的文件声明 package greeting,形成另一个包。Hello 首字母大写,允许包外调用,因此 message.go 可以在导入该包后使用 greeting.Hello()。程序入口规范
定位包:import 路径怎样对应到源码
在这个单模块项目里,Go 从当前目录或上层目录找到 go.mod,确定模块配置。go build . 中的 . 选择当前目录的包。
接着看 message.go 里的导入:
go
import "example.com/build-demo/greeting"
它的前缀与 go.mod 中的 example.com/build-demo 一致,剩下的 greeting 对应模块根目录下的子目录。Go 因而能在本地定位这份源码,无需因为路径看起来像网址就先去访问对应网站。
包的源码位置取决于它来自哪里:
| 包的来源 | 本例中的对应关系 |
|---|---|
| 标准库 | fmt 使用 Go 环境提供的标准库源码 |
| 当前模块 | example.com/build-demo/greeting 对应本地 greeting/ 目录 |
| 外部模块 | 根据项目的依赖配置确定模块与版本,再使用模块缓存中的源码,必要时按配置下载 |
这里的两个导入都不需要第三方模块。Go 模块参考说明了包路径与提供它的模块之间如何对应。
可以在项目根目录执行:
bash
go list -json .
-json 让包信息以 JSON 输出。下面摘取完整输出中与本例有关的四个字段:
json
{
"ImportPath": "example.com/build-demo",
"Name": "main",
"GoFiles": ["main.go", "message.go"],
"Imports": ["example.com/build-demo/greeting", "fmt"]
}
ImportPath 是这个包的路径,Name 是源码中声明的包名。GoFiles 列出本次选中的普通 Go 源文件,Imports 列出直接导入的包。
这份输出同时说明:main.go 与 message.go 被作为同一个包处理,分别写在两个文件里的导入也共同形成这个包的依赖。完整输出中的 Dir 还会给出它在本机的源码目录。
筛选文件:目录里存在,不等于本次参与编译
再新增一个 greeting/optional.go:
go
//go:build demo
package greeting
var marker = missingMarker
missingMarker 没有定义,这段声明在参与编译时会报错。但文件顶部还有一条真实的构建约束:
go
//go:build demo
它决定这个文件何时加入构建。demo 是本例自定义的标签名;这行约束写在 package 前,并用空行与后面的声明分开。Go 工具会读取它,尽管它采用了注释的形式。
先查看默认情况下这个包选中了哪些文件:
bash
go list -json ./greeting
从输出中取出两个字段:
json
{
"GoFiles": ["greeting.go"],
"IgnoredGoFiles": ["optional.go"]
}
默认构建没有启用 demo,所以 optional.go 被排除,go build . 可以成功。
再执行:
bash
go build -tags demo .
-tags demo 为这次构建启用 demo 标签,optional.go 于是进入编译,错误信息中会出现:
text
undefined: missingMarker
这个实验中,变化发生在送交编译器之前的文件选择 。同一个文件能否参与构建,取决于本次构建条件;不能只根据它是否存在于目录中判断。Go 构建约束说明
观察后删除 optional.go,恢复前面的完整工程。
沿着依赖构建,再链接成程序
入口包的两个直接依赖如下,箭头表示包之间的依赖:
这张图描述需要哪些包,并不表示函数的执行顺序。greeting 的导入虽然写在 message.go 中,也会成为整个 main 包的依赖;如果依赖包继续导入其他包,Go 还要沿着这些关系继续查找。
构建时,依赖包需要提供有效的编译结果,供依赖它们的包使用。刚才启用标签后的失败,就发生在依赖包 greeting 的编译阶段。
编译器按包处理选中的源码,检查名称、类型等,并产生编译结果。对于可执行程序,链接器再将 main 包与程序所需的依赖编译结果组合成一个可执行文件。这就是编译与链接各自承担的工作。Go 编译器说明、Go 链接器说明
恢复正常代码后,执行:
bash
go build -o hello .
-o hello 指定输出文件名。macOS、Linux 下执行 ./hello,会看到:
text
Hello, Go
Windows 下可以用 go build -o hello.exe . 指定程序文件,再通过 .\hello.exe 启动。构建完成后,这个文件已经可以独立启动;启动它时不再需要调用 Go 构建工具。
构建时为什么可能看不到重新编译
实际执行时,Go 会根据源码、工具链和编译选项等信息复用构建缓存。因此,前面的过程是在解释各部分的职责,并不表示每次都从头编译所有包。
需要查看本次实际执行了哪些工具命令时,可以使用:
bash
go build -x -o hello .
-x 会打印构建过程中执行的工具命令。重复构建同一份代码时,部分结果可能已从缓存复用,因此看到的编译命令会减少。Go 构建缓存说明
排查一个构建问题时,可以先看包定位到了哪里、选中了哪些文件,再沿 Imports 检查依赖,最后看失败发生在编译还是链接。这样,"找到代码""选入代码"和"生成程序"就有了各自可观察的依据。