
你大概率用过 go/ast。解析一下源码、遍历一下节点、提取点结构信息——30 行代码就能跑起来一个小工具。
挺好。
直到有一天,你想做一件"很简单"的事:检查代码里有没有忽略 error 返回值。你用 go/ast 写了一版,能抓到 _ = err 的情况。但 os.Remove("file.txt") 呢?返回值被直接丢弃,AST 层面只看到一个函数调用——你不知道它返回了什么,更不知道里面有没有 error。
问题出在哪?go/ast 只看"形状"。它知道这里有个函数调用,但不知道这个函数返回几个值、哪个是 error。这些信息在另一层——go/types。
这篇文章就从这个问题出发。我会用同一个需求——检测被忽略的 error——分别用 go/ast、go/types、go/analysis 三层工具实现,让你直观感受每层能做什么、做不了什么。
1. go/ast 能做什么:从源码到语法树
先从一个最简单的例子开始。
假设你们团队有个规矩:单个函数不能超过 50 行。你想写个工具自动检查。用 go/ast,核心逻辑不到 20 行:
|
|
我拿它跑了一个测试文件 [实测 Go 1.26.2],里面有三个函数:shortFunc(3 行)、mediumFunc(15 行)、longFunc(54 行)。输出:
|
|
干净利落。这就是 go/ast 的舒适区:纯语法层面的检查。它能告诉你代码"长什么样"——有几个函数、函数有多长、参数叫什么名字、哪里有注释。
这类需求用 go/ast 就够了:
- 函数/方法行数检查、命名规范检查
- 注释覆盖率统计(统计导出函数前是否有注释)
- 代码结构提取和简单的代码生成
核心 API 就三步:token.NewFileSet() 创建位置信息容器,parser.ParseFile() 把源码解析成 AST,ast.Inspect() 递归遍历节点。

2. go/ast 做不了什么:三个撞墙时刻
go/ast 的能力边界在哪?三个场景,每个都是"写到一半才发现做不了"。
场景一:检测被忽略的 error
你想检查代码里有没有丢弃 error 返回值。用 go/ast 能做到一部分——扫描赋值语句,找到左侧有 _ 的情况:
|
|
问题来了:os.Remove("file.txt") 返回一个 error,但调用者直接丢掉了返回值,连赋值都没有。AST 层面只看到一个 *ast.ExprStmt 包着一个 *ast.CallExpr——你不知道这个函数返回了什么。
更麻烦的是 fmt.Println("hello")。它也返回 (int, error),但在多数场景下忽略它是合理的。纯 AST 没法区分"应该检查的 error"和"可以安全忽略的 error"——因为它根本不知道返回值类型。
场景二:判断类型是否实现了某个接口
你想写个工具,找出所有 http.Handler 的实现。AST 能找到名叫 ServeHTTP 的方法声明,但它无法验证参数签名是否匹配:
|
|
AST 只看到"有个方法叫 ServeHTTP",不知道 http.ResponseWriter 和 io.Writer 是不同类型。判断接口实现必须有类型信息。
场景三:跨包的同名函数
你的代码里有一行 Parse(input)。AST 告诉你"这里调用了 Parse",但:
|
|
如果你只看 AST,两个调用长得一样——都是 SelectorExpr。但它们是完全不同的函数。纯语法分析分不清谁是谁。
三个场景的共同点:go/ast 只做语法分析,不做语义分析。它知道代码"长什么样",但不知道代码"什么意思"。

3. go/types 补位:从"形状"到"含义"
go/types 解决的核心问题是:给 AST 节点赋予类型信息。
还是用"检测被忽略的 error"这个需求。同一个测试文件,包含四种场景:
|
|
我分别用纯 go/ast 和 go/ast + go/types 两个版本跑了一遍 [实测 Go 1.26.2]:
纯 go/ast 版输出(7 条警告):
|
|
7 条。前 4 条是 _ 赋值的文本匹配——_ = f 并不是忽略 error,但 AST 只看到 _ 就报了。后 3 条是丢弃返回值的调用,AST 不知道它们是否返回了 error,只能模糊提示。
go/ast + go/types 版输出(3 条警告):
|
|
3 条,每条都精确到函数名。它能区分"返回了 error 的调用"——至于 fmt.Println 这种通常可安全忽略的,是否报警属于策略问题,不是工具层面的能力缺陷。
关键差异在哪?go/types 版本能拿到函数签名:
|
|
go/ast 看到 os.Remove("temp.txt") 只知道"这是一个函数调用"。go/types 知道 os.Remove 的签名是 func(name string) error——返回一个 error,而这个 error 没有被接收。
代价是什么?
| 维度 | 纯 go/ast | go/ast + go/types |
|---|---|---|
| 警告数 | 7 条(含噪音) | 3 条(精确到函数名) |
| 代码行数 | ~45 行 | ~85 行 |
| 示例加载方式 | 标准库直接解析 | golang.org/x/tools/go/packages 加载 |
| 分析范围 | 单文件 | 整个包(含依赖) |
说明:go/types 本身在标准库里,但要方便地加载整个包的类型信息,通常用 golang.org/x/tools/go/packages。这不是 go/types 的硬依赖,是实际使用中的常见搭配。
代码量翻了一倍,但换来的是从"文本匹配"到"类型系统级"的精确度提升。

4. go/analysis:标准化你的分析器
到目前为止,不管用 go/ast 还是 go/types,你写的都是"散装"工具——自己解析文件、自己遍历节点、自己输出结果。这样做有两个问题:
- 没法复用。你写的 error 检查器和我写的函数行数检查器,各自
main()一套,没法组合到一起跑。 - 没法集成。
golangci-lint支持几十个分析器并行运行,但你的"散装"工具接不上去。
golang.org/x/tools/go/analysis(注意:不在标准库,在 x/tools 扩展库里)解决的就是这个问题。它不替代 go/ast 或 go/types——它在它们之上提供了一套标准化的分析器接口。
一个 go/analysis 分析器长这样:
|
|
核心逻辑和之前的"散装"版几乎一样。但区别在于:
pass.Files帮你处理了文件加载pass.Fset帮你管理了位置信息pass.Reportf是标准化的报告接口pass.TypesInfo给你当前包的类型信息(不需要自己加载包)
先用 singlechecker 跑通单个分析器,再用 multichecker 组合多个:
|
|
这就是 go/analysis 的价值:不是让你写出更好的分析逻辑,而是让你的分析逻辑能被整个生态复用。
你可能会想:我直接用 golangci-lint 不就行了?大多数场景确实够了。但 golangci-lint 内置的规则未必覆盖你们团队的特定规范——比如"禁止在 handler 层直接调用 repository"。这些定制需求,就得自己写 Analyzer。
有个坑值得提前知道:pass.Files 默认不包含 _test.go 文件。如果你的规则需要检查测试代码,需要额外处理。

5. 决策地图:你的需求该用哪一层
回头看,Go 的代码分析工具链是三层结构:
| 层 | 包 | 适用场景 | 适用边界 |
|---|---|---|---|
| 语法层 | go/ast + go/parser |
命名检查、行数检查、结构提取、代码生成 | 不知道类型,不做跨包语义分析 |
| 语义层 | go/types |
类型推断、接口匹配、error 检测、跨包分析 | 不做控制流/数据流分析,不处理反射 |
| 框架层 | golang.org/x/tools/go/analysis(非标准库) |
标准化分析器,可组合,可集成 golangci-lint | 学习成本略高于直接用 go/ast |
选型就一个问题:你的需求需要知道"类型"吗?
- 不需要类型 →
go/ast就够了 - 需要类型 →
go/types - 需要类型 + 想集成到 golangci-lint →
go/analysis
一个更直接的判断:如果你发现自己在用字符串匹配来"猜"类型信息,说明你该升级到 go/types 了。
再往上还有 go/ssa(静态单赋值分析)、go/callgraph(调用图分析)等更高级的工具,本文不展开。

go/ast 是入口,但不是终点。下次写代码分析工具的时候,问自己一个问题:我需要知道类型吗?
完整实验代码已开源在 GitHub(zhiyulab-evidence/go-ast-tooling),所有数据均基于 Go 1.26.2 实测。
附录:实验代码和原始数据
本文 3 组实验的代码已开源:
GitHub:zhiyulab-evidence/go-ast-tooling
func-length-linter/— 函数行数检查 linter(第1章)error-check-ast/— 纯 go/ast 版 error 检查(第2-3章)error-check-types/— go/ast+go/types 版 error 检查(第3章)analysis-framework/— go/analysis 框架版分析器(第4章)
每个子目录可独立编译运行。二进制产物不入库,跑实验前自己 go build。