代码 用 go build -toolexec 拦下每次 compile:计时、改源码、往 importcfg 里塞包

2026-10-18 21:01:44

用 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 的操作就是两步:

  1. 在源码里插入对应的 import(见下一节);
  2. 往 -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 默认执行 stdversion vet 检查,报告那些比当前文件生效的 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 注释里,并且只在这些设置等于最终默认值时才接受;写成旧值会让构建失败。
复制全文 生成海报 Go 构建 工具链

推荐文章

程序员茄子在线接单