执行 go build . 后,Go 怎样找到代码并生成程序

0 阅读5分钟

一条 go build . 没有列出所有源码文件,也没有写明依赖包的磁盘位置,却能生成程序。

Go 如何把这个点变成一组源码,再把源码变成可执行文件?可以用一个小项目,结合工具实际给出的包信息,观察中间的选择过程。

准备一份可以对照的代码

项目结构如下:

build-demo/
├── go.mod
├── main.go
├── message.go
└── greeting/
    └── greeting.go

go.mod:

module example.com/build-demo

go 1.22

module 声明本模块的路径,作为其中各个包的导入路径前缀;go 1.22 声明模块要求的最低 Go 版本。

main.go:

package main

import "fmt"

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

message.go:

package main

import "example.com/build-demo/greeting"

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

greeting/greeting.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 里的导入:

import "example.com/build-demo/greeting"

它的前缀与 go.mod 中的 example.com/build-demo 一致,剩下的 greeting 对应模块根目录下的子目录。Go 因而能在本地定位这份源码,无需因为路径看起来像网址就先去访问对应网站。

包的源码位置取决于它来自哪里:

包的来源本例中的对应关系
标准库fmt 使用 Go 环境提供的标准库源码
当前模块example.com/build-demo/greeting 对应本地 greeting/ 目录
外部模块根据项目的依赖配置确定模块与版本,再使用模块缓存中的源码,必要时按配置下载

这里的两个导入都不需要第三方模块。Go 模块参考说明了包路径与提供它的模块之间如何对应。

可以在项目根目录执行:

go list -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:build demo

package greeting

var marker = missingMarker

missingMarker 没有定义,这段声明在参与编译时会报错。但文件顶部还有一条真实的构建约束:

//go:build demo

它决定这个文件何时加入构建。demo 是本例自定义的标签名;这行约束写在 package 前,并用空行与后面的声明分开。Go 工具会读取它,尽管它采用了注释的形式。

先查看默认情况下这个包选中了哪些文件:

go list -json ./greeting

从输出中取出两个字段:

{
  "GoFiles": ["greeting.go"],
  "IgnoredGoFiles": ["optional.go"]
}

默认构建没有启用 demo,所以 optional.go 被排除,go build . 可以成功。

再执行:

go build -tags demo .

-tags demo 为这次构建启用 demo 标签,optional.go 于是进入编译,错误信息中会出现:

undefined: missingMarker

这个实验中,变化发生在送交编译器之前的文件选择。同一个文件能否参与构建,取决于本次构建条件;不能只根据它是否存在于目录中判断。Go 构建约束说明

观察后删除 optional.go,恢复前面的完整工程。

沿着依赖构建,再链接成程序

入口包的两个直接依赖如下,箭头表示包之间的依赖:

flowchart LR
    Main[main包] --> Greeting[greeting包]
    Main --> Fmt[fmt标准库]

这张图描述需要哪些包,并不表示函数的执行顺序。greeting 的导入虽然写在 message.go 中,也会成为整个 main 包的依赖;如果依赖包继续导入其他包,Go 还要沿着这些关系继续查找。

构建时,依赖包需要提供有效的编译结果,供依赖它们的包使用。刚才启用标签后的失败,就发生在依赖包 greeting 的编译阶段。

编译器按包处理选中的源码,检查名称、类型等,并产生编译结果。对于可执行程序,链接器再将 main 包与程序所需的依赖编译结果组合成一个可执行文件。这就是编译与链接各自承担的工作。Go 编译器说明、Go 链接器说明

恢复正常代码后,执行:

go build -o hello .

-o hello 指定输出文件名。macOS、Linux 下执行 ./hello,会看到:

Hello, Go

Windows 下可以用 go build -o hello.exe . 指定程序文件,再通过 .\hello.exe 启动。构建完成后,这个文件已经可以独立启动;启动它时不再需要调用 Go 构建工具。

构建时为什么可能看不到重新编译

实际执行时,Go 会根据源码、工具链和编译选项等信息复用构建缓存。因此,前面的过程是在解释各部分的职责,并不表示每次都从头编译所有包。

需要查看本次实际执行了哪些工具命令时,可以使用:

go build -x -o hello .

-x 会打印构建过程中执行的工具命令。重复构建同一份代码时,部分结果可能已从缓存复用,因此看到的编译命令会减少。Go 构建缓存说明

排查一个构建问题时,可以先看包定位到了哪里、选中了哪些文件,再沿 Imports 检查依赖,最后看失败发生在编译还是链接。这样,“找到代码”“选入代码”和“生成程序”就有了各自可观察的依据。

参考与数据源