用 go build -toolexec 拦下每次 compile:计时、改源码、往 importcfg 里塞包
Go Weekly #620 推荐的长文 Hooking into the Go Toolchain 围绕 go build -toolexec(下称 toolexec)讲了一层介入点:它能把一个包装程序插到 compile、link、asm、cgo、cover 这些工具调用前面。从打印耗时起步,可以一路做到构建中途改写源码、修改 importcfg 追加 import。这里记录机制和可复现的做法。
调用形态
-toolexec 接受一个程序和它的参数,再把它作为前缀加到每一次工具调用上:
go build -toolexec="mywrapper" ./...
cmd/go 实际执行的是:
/path/to/wrapper
一次编译展开后大致是这样:
mywrapper /usr/local/go/pkg/tool/linux_amd64/compile \
-o /tmp/go-build382910/b001/_pkg_.a \
-trimpath "$WORK/b001=>" \
-p main \
-importcfg /tmp/go-build382910/b001/importcfg \
-pack ./main.go
包装程序的 $1 是工具路径,后面全是工具自己的参数。顺序不能乱,包装程序必须把真正的工具 exec 起来,否则构建就断在这一步。正常构建时它就是一个透明的转发层。go build 会传下来 GOROOT、GOCACHE 这些环境变量,工具路径本身也在参数里,所以包装程序不需要自己猜 Go 装在哪。
先做一个计时器
最小的包装程序就是一个 exec 加上前后时间戳:
// wrapper.go
package main
import (
"log"
"os"
"os/exec"
"path/filepath"
"time"
)
func main() {
tool := os.Args[1]
args := os.Args[2:]
start := time.Now()
cmd := exec.Command(tool, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()
if err := cmd.Run(); err != nil {
log.Printf("%s failed after %s, err: %v", filepath.Base(tool), time.Since(start), err)
os.Exit(1)
}
log.Printf("%s done in %s", filepath.Base(tool), time.Since(start))
}
编译成 mywrapper 放进 PATH,然后 go build -toolexec="mywrapper" ./...。日志要走 stderr,stdout 会被 go build 自己的输出混在一起。
读 importcfg:编译器看到了哪些包
每次编译都会在临时目录里生成一份 importcfg,告诉 compile 每个被 import 的包对应哪个归档文件。目录结构大体是:
/tmp/go-build382910/
├── b001/
│ ├── importcfg
│ ├── importcfg.link
│ └── _pkg_.a
├── b002/
│ ├── importcfg
│ └── _pkg_.a
└── b1xx/
└── importcfg.link
b001/importcfg 的内容是若干行 packagefile:
packagefile fmt=/root/.cache/go-build/xx/xxxxxx-d
packagefile internal/fmtsort=/root/.cache/go-build/yy/yyyyyy-d
包装程序从 -importcfg 参数(Go 1.27 起也可能是 @/tmp/go-build.../b001/importcfg 这种响应文件形式)拿到路径,解析出「包路径 → 归档文件」的映射,就知道当前这次编译依赖了什么。链接阶段用的是同目录下的 importcfg.link。
往 importcfg 里加 import
既然 importcfg 只是一张表,追加 import 的操作就是两步:
- 在源码里插入对应的
import(见下一节); - 往
-importcfg指向的文件末尾追加一行packagefile =,指向那个包已经编译好的.a或缓存归档。
如果传给 compile 的是 @file 形式,包装程序要先按响应文件格式展开成参数列表,或者直接改写那个响应文件本身,再交给真正的工具。用 GOCACHE 里已有的归档比自己现编一个更省事;缺的包得先单独 go build 出来。
在构建中途改源码
改写代码同样在包装程序里做:识别出这次是 compile 调用,把参数里的 .go 文件复制到临时目录,用 AST 或文本替换改完,再把参数里的原始路径换成副本路径。输出路径 -o 保持不变,构建流程不需要感知这一步。
改源码和改 importcfg 通常要一起做:源码里引用了新包,编译器就会去 importcfg 里找它;表里没有这一行,编译直接报错。
几个会踩到的点
-toolexec是统一前缀,compile、link、asm、cgo、cover全部经过它,包装程序需要按工具名分流。- 每次工具调用多一个进程,构建会明显变慢,别在日常构建里长期挂着。
- 包装程序本身不是构建缓存 key 的一部分。改了包装程序,缓存里的结果不会自动失效,得改构建 flag 或者加
-a强制重跑。
Go 1.27 里和这条路径相关的工具变化
go.dev/doc/go1.27 的 Tools 一节里有几处直接影响包装程序:
compile、link、asm、cgo、cover、pack都支持@file响应文件。格式是空白分隔的参数,支持单引号、双引号、转义序列和反斜杠续行,和 GCC 的响应文件实现兼容。go test默认执行stdversionvet 检查,报告那些比当前文件生效的 Go 版本更新的标准库符号(版本由go.mod的go指令和文件的 build tag 决定)。go test -json的"Action":"output"行多了一个可选字段"OutputType",取值包括"error"、"error-continue"、"frame"。go doc支持package@version语法,例如go doc example.com/pkg@v1.2.3,并新增-ex用来列出可执行示例。go fix新增atomictypes、embedlit、slicesbackward、unsafefuncs几个 modernizer;fmtappendf分析器被移除,waitgroup更名为waitgroupgo。go 1.27及以上的模块,go mod tidy会合并重复的require块,最多保留两个(直接依赖和间接依赖),注释块保持不动。- gofmt 的列对齐逻辑修了一处 bug:以前在不同 CPU 架构上行为略有差异,用 1.27 的 gofmt 跑一遍可能出现细微空白变化。
- vet 的 printf 分析器现在会报告
fmt.Errorf("...: %w", p)里%w操作数p类型为*E、而E实现了error的情况,这种写法会导致errors.Is或类型断言假性失败。 go tool trace -http=:6060只传端口时现在只监听 localhost,要监听所有地址得写-http=0.0.0.0:6060。- go 命令不再支持
bzr。 - GODEBUG:go 命令现在会识别那些支持已被移除的设置,前提是它们出现在
go.mod的godebug条目或//go:debug注释里,并且只在这些设置等于最终默认值时才接受;写成旧值会让构建失败。