别再只用 go/ast 了:Go 代码分析工具链三层实战

go/ast 只看语法,看不到类型。用同一个需求贯穿 go/ast、go/types、go/analysis 三层实现,帮你建立代码分析工具的选型认知。

封面

你大概率用过 go/ast。解析一下源码、遍历一下节点、提取点结构信息——30 行代码就能跑起来一个小工具。

挺好。

直到有一天,你想做一件"很简单"的事:检查代码里有没有忽略 error 返回值。你用 go/ast 写了一版,能抓到 _ = err 的情况。但 os.Remove("file.txt") 呢?返回值被直接丢弃,AST 层面只看到一个函数调用——你不知道它返回了什么,更不知道里面有没有 error。

问题出在哪?go/ast 只看"形状"。它知道这里有个函数调用,但不知道这个函数返回几个值、哪个是 error。这些信息在另一层——go/types

这篇文章就从这个问题出发。我会用同一个需求——检测被忽略的 error——分别用 go/astgo/typesgo/analysis 三层工具实现,让你直观感受每层能做什么、做不了什么。


1. go/ast 能做什么:从源码到语法树

先从一个最简单的例子开始。

假设你们团队有个规矩:单个函数不能超过 50 行。你想写个工具自动检查。用 go/ast,核心逻辑不到 20 行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
    log.Fatal(err)
}

ast.Inspect(f, func(n ast.Node) bool {
    fn, ok := n.(*ast.FuncDecl)
    if !ok {
        return true
    }
    start := fset.Position(fn.Body.Lbrace)
    end := fset.Position(fn.Body.Rbrace)
    lines := end.Line - start.Line
    if lines > 50 {
        fmt.Printf("⚠ %s:%d  函数 %s 有 %d 行(上限 50)\n",
            start.Filename, start.Line, fn.Name.Name, lines)
    }
    return true
})

我拿它跑了一个测试文件 [实测 Go 1.26.2],里面有三个函数:shortFunc(3 行)、mediumFunc(15 行)、longFunc(54 行)。输出:

1
⚠ testdata.go:14  函数 longFunc 有 54 行(上限 50)

干净利落。这就是 go/ast 的舒适区:纯语法层面的检查。它能告诉你代码"长什么样"——有几个函数、函数有多长、参数叫什么名字、哪里有注释。

这类需求用 go/ast 就够了:

  • 函数/方法行数检查、命名规范检查
  • 注释覆盖率统计(统计导出函数前是否有注释)
  • 代码结构提取和简单的代码生成

核心 API 就三步:token.NewFileSet() 创建位置信息容器,parser.ParseFile() 把源码解析成 AST,ast.Inspect() 递归遍历节点。

go/ast 扫描代码


2. go/ast 做不了什么:三个撞墙时刻

go/ast 的能力边界在哪?三个场景,每个都是"写到一半才发现做不了"。

场景一:检测被忽略的 error

你想检查代码里有没有丢弃 error 返回值。用 go/ast 能做到一部分——扫描赋值语句,找到左侧有 _ 的情况:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
ast.Inspect(f, func(n ast.Node) bool {
    assign, ok := n.(*ast.AssignStmt)
    if !ok {
        return true
    }
    for _, lhs := range assign.Lhs {
        ident, ok := lhs.(*ast.Ident)
        if ok && ident.Name == "_" {
            fmt.Printf("发现 _ 赋值,可能忽略了 error\n")
        }
    }
    return true
})

问题来了:os.Remove("file.txt") 返回一个 error,但调用者直接丢掉了返回值,连赋值都没有。AST 层面只看到一个 *ast.ExprStmt 包着一个 *ast.CallExpr——你不知道这个函数返回了什么

更麻烦的是 fmt.Println("hello")。它也返回 (int, error),但在多数场景下忽略它是合理的。纯 AST 没法区分"应该检查的 error"和"可以安全忽略的 error"——因为它根本不知道返回值类型。

场景二:判断类型是否实现了某个接口

你想写个工具,找出所有 http.Handler 的实现。AST 能找到名叫 ServeHTTP 的方法声明,但它无法验证参数签名是否匹配:

1
2
3
4
5
6
// AST 能看到这个方法声明
func (s *MyServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { ... }

// 但如果签名写错了呢?AST 同样会找到它:
func (s *MyServer) ServeHTTP(w io.Writer, r *http.Request) { ... }
// ↑ 第一个参数类型不对,没实现 http.Handler,AST 分不清

AST 只看到"有个方法叫 ServeHTTP",不知道 http.ResponseWriterio.Writer 是不同类型。判断接口实现必须有类型信息。

场景三:跨包的同名函数

你的代码里有一行 Parse(input)。AST 告诉你"这里调用了 Parse",但:

1
2
3
4
5
6
import "encoding/json"
import "github.com/you/myparser"

// 这两个 Parse 签名完全不同,行为完全不同
json.Unmarshal(data, &v)    // 标准库
myparser.Parse(input)       // 你自己的包

如果你只看 AST,两个调用长得一样——都是 SelectorExpr。但它们是完全不同的函数。纯语法分析分不清谁是谁。

三个场景的共同点:go/ast 只做语法分析,不做语义分析。它知道代码"长什么样",但不知道代码"什么意思"。

三个撞墙时刻


3. go/types 补位:从"形状"到"含义"

go/types 解决的核心问题是:给 AST 节点赋予类型信息

还是用"检测被忽略的 error"这个需求。同一个测试文件,包含四种场景:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func goodExample() {
    f, err := os.Open("file.txt")   // 正确处理了 error
    if err != nil { return }
    defer f.Close()
}

func badExample1() {
    _, _ = fmt.Println("hello")     // 用 _ 显式丢弃
}

func badExample2() {
    os.Remove("temp.txt")           // 完全丢弃返回值
}

func noErrorReturn() {
    fmt.Println("no error here")    // 返回值包含 error 但通常可忽略
}

我分别用纯 go/ast 和 go/ast + go/types 两个版本跑了一遍 [实测 Go 1.26.2]:


纯 go/ast 版输出(7 条警告):

1
2
3
4
5
6
7
[AST] target.go:20  发现 _ 赋值,可能忽略了 error     ← _ = fmt.Println 的两个返回值
[AST] target.go:20  发现 _ 赋值,可能忽略了 error     ← 同上,两个 _ 各报一次
[AST] target.go:30  发现 _ 赋值,可能忽略了 error     ← f, _ := os.Open 的 _
[AST] target.go:31  发现 _ 赋值,可能忽略了 error     ← _ = f 的 _
[AST] target.go:12  函数调用返回值被丢弃(不知道是否含 error)  ← fmt.Println 在 goodExample 里
[AST] target.go:25  函数调用返回值被丢弃(不知道是否含 error)  ← os.Remove
[AST] target.go:36  函数调用返回值被丢弃(不知道是否含 error)  ← fmt.Println 在 noErrorReturn 里

7 条。前 4 条是 _ 赋值的文本匹配——_ = f 并不是忽略 error,但 AST 只看到 _ 就报了。后 3 条是丢弃返回值的调用,AST 不知道它们是否返回了 error,只能模糊提示。


go/ast + go/types 版输出(3 条警告):

1
2
3
[Types] target.go:12  函数 fmt.Println 返回 error 但被丢弃
[Types] target.go:25  函数 os.Remove 返回 error 但被丢弃
[Types] target.go:36  函数 fmt.Println 返回 error 但被丢弃

3 条,每条都精确到函数名。它能区分"返回了 error 的调用"——至于 fmt.Println 这种通常可安全忽略的,是否报警属于策略问题,不是工具层面的能力缺陷。

关键差异在哪?go/types 版本能拿到函数签名:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 完整链路:AST 标识符 → 类型对象 → 函数签名
// 1. 从 AST 拿到函数标识符(*ast.SelectorExpr 的方法名)
// 2. 通过 TypesInfo.ObjectOf() 拿到类型对象(types.Object)
// 3. 再取函数签名(*types.Signature)
obj := pkg.TypesInfo.ObjectOf(sel)  // sel 是 *ast.Ident
sig, ok := obj.Type().(*types.Signature)
results := sig.Results()
for i := 0; i < results.Len(); i++ {
    if types.Identical(results.At(i).Type(), errorType) {
        // 确认:这个函数返回了 error,且被丢弃了
    }
}

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 的硬依赖,是实际使用中的常见搭配。

代码量翻了一倍,但换来的是从"文本匹配"到"类型系统级"的精确度提升。

go/types 补位


4. go/analysis:标准化你的分析器

到目前为止,不管用 go/ast 还是 go/types,你写的都是"散装"工具——自己解析文件、自己遍历节点、自己输出结果。这样做有两个问题:

  1. 没法复用。你写的 error 检查器和我写的函数行数检查器,各自 main() 一套,没法组合到一起跑。
  2. 没法集成golangci-lint 支持几十个分析器并行运行,但你的"散装"工具接不上去。

golang.org/x/tools/go/analysis(注意:不在标准库,在 x/tools 扩展库里)解决的就是这个问题。它不替代 go/ast 或 go/types——它在它们之上提供了一套标准化的分析器接口

一个 go/analysis 分析器长这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var Analyzer = &analysis.Analyzer{
    Name: "funclen",
    Doc:  "检查函数行数是否超过上限",
    Run:  run,
}

func run(pass *analysis.Pass) (any, error) {
    for _, file := range pass.Files {
        ast.Inspect(file, func(n ast.Node) bool {
            fn, ok := n.(*ast.FuncDecl)
            if !ok {
                return true
            }
            start := pass.Fset.Position(fn.Body.Lbrace)
            end := pass.Fset.Position(fn.Body.Rbrace)
            if end.Line-start.Line > 50 {
                pass.Reportf(fn.Pos(), "函数 %s 有 %d 行",
                    fn.Name.Name, end.Line-start.Line)
            }
            return true
        })
    }
    return nil, nil
}

核心逻辑和之前的"散装"版几乎一样。但区别在于:

  • pass.Files 帮你处理了文件加载
  • pass.Fset 帮你管理了位置信息
  • pass.Reportf 是标准化的报告接口
  • pass.TypesInfo 给你当前包的类型信息(不需要自己加载包)

先用 singlechecker 跑通单个分析器,再用 multichecker 组合多个:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 单独跑
func main() { singlechecker.Main(Analyzer) }

// 组合多个分析器一起跑
func main() {
    multichecker.Main(
        funclen.Analyzer,
        errcheck.Analyzer,
        nilcheck.Analyzer,
    )
}

这就是 go/analysis 的价值:不是让你写出更好的分析逻辑,而是让你的分析逻辑能被整个生态复用

你可能会想:我直接用 golangci-lint 不就行了?大多数场景确实够了。但 golangci-lint 内置的规则未必覆盖你们团队的特定规范——比如"禁止在 handler 层直接调用 repository"。这些定制需求,就得自己写 Analyzer。

有个坑值得提前知道:pass.Files 默认不包含 _test.go 文件。如果你的规则需要检查测试代码,需要额外处理。

go/analysis 标准化工具箱


5. 决策地图:你的需求该用哪一层

回头看,Go 的代码分析工具链是三层结构:

适用场景 适用边界
语法层 go/ast + go/parser 命名检查、行数检查、结构提取、代码生成 不知道类型,不做跨包语义分析
语义层 go/types 类型推断、接口匹配、error 检测、跨包分析 不做控制流/数据流分析,不处理反射
框架层 golang.org/x/tools/go/analysis(非标准库) 标准化分析器,可组合,可集成 golangci-lint 学习成本略高于直接用 go/ast

选型就一个问题:你的需求需要知道"类型"吗?

  • 不需要类型go/ast 就够了
  • 需要类型go/types
  • 需要类型 + 想集成到 golangci-lintgo/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