Go 分析框架:Go 团队打造的模块化静态分析工具
文档 ¶
概述 ¶
analysis 包定义了模块化静态分析与分析驱动程序之间的接口。
背景 ¶
静态分析是一个函数,它检查一个 Go 代码包并报告一组诊断结果(通常是代码中的错误),也可能产生其他结果,例如建议的重构或其他事实。报告错误的分析通常被称为“检查器”。例如,printf 检查器会报告 fmt.Printf 格式字符串中的错误。
“模块化”分析是指一次检查一个包,但可以保存来自底层包的信息,并在检查高层包时使用它,类似于工具链中的单独编译。printf 检查器是模块化的:当它发现像 log.Fatalf 这样的函数委托给 fmt.Printf 时,它会记录这一事实,并检查对该函数的调用,包括来自其他包的调用。
通过实现通用接口,来自不同来源的检查器可以轻松地被选择、集成和复用于各种驱动程序,包括命令行工具(如 vet)、文本编辑器和 IDE、构建和测试系统(如 go build、Bazel 或 Buck)、测试框架、代码审查工具、代码库索引器(如 SourceGraph)、文档查看器(如 godoc)、大型代码库的批处理管道等。
分析器 ¶
API 中的主要类型是 Analyzer。一个 Analyzer 静态地描述了一个分析函数:它的名称、文档、标志、与其他分析器的关系,当然还有它的逻辑。
要定义一个分析,用户需要声明一个类型为 Analyzer 的(逻辑上不变的)变量。下面是 go/analysis/passes/ 子目录中一个典型分析器的示例:
package unusedresult
var Analyzer = &analysis.Analyzer{
Name: "unusedresult",
Doc: "检查某些函数调用结果是否未被使用",
Run: run,
...
}
func run(pass *analysis.Pass) (interface{}, error) {
...
}
分析驱动程序(如 vet)负责运行一组分析并输出它们报告的诊断信息。驱动程序必须导入它需要使用的所有 Analyzer。通常每个 Analyzer 独立放在一个包中。要在现有驱动程序中添加新的分析器,只需在列表中新增一项即可:
import ( "unusedresult"; "nilness"; "printf" )
var analyses = []*analysis.Analyzer{
unusedresult.Analyzer,
nilness.Analyzer,
printf.Analyzer,
}
驱动程序可以利用名称、标志和文档来提供在线帮助,说明它所执行的分析。文档注释包含一行简要说明,后面可跟多段详细解释。
Analyzer 类型除了上述字段外,还有更多字段:
type Analyzer struct {
Name string
Doc string
Flags flag.FlagSet
Run func(*Pass) (interface{}, error)
RunDespiteErrors bool
ResultType reflect.Type
Requires []*Analyzer
FactTypes []Fact
}
Flags 字段声明了一组命名的(全局)标志变量,用于控制分析行为。与 vet 不同,分析标志并非直接在命令行 FlagSet 中声明,而是由驱动程序负责设置这些标志变量。对于单一分析器的驱动程序(如 a),可能直接在命令行上暴露其标志(如 -f);而对于多个分析器的驱动程序,则可能给标志名加上分析器前缀(如 -a.f)以避免歧义。IDE 可以通过图形界面暴露这些标志,批处理管道则可以从配置文件中读取它们。关于标志的实际用法,可参考 findcall 分析器示例。
RunDespiteErrors 标志表明该分析是否能够处理类型错误的代码。如果不能,当存在解析或类型错误时,驱动程序将跳过该分析。可选的 ResultType 字段指定该分析计算的结果值类型,并可供其他分析使用。Requires 字段指定该分析依赖的分析列表,这些分析的结果可以被本分析访问,同时它约束了驱动程序运行分析的顺序。FactTypes 字段在“模块化”一节中讨论。analysis 包提供了 Validate 函数,用于对 Analyzer 进行基本合理性检查,例如确保其 Requires 图无环、事实和结果类型唯一等。
最后,Run 字段包含一个函数,由驱动程序调用以对单个包执行分析。驱动程序会向它传入一个 Pass 类型的实例。
Pass ¶
Pass 描述一个单一的工作单元:将特定的 Analyzer 应用于特定的 Go 代码包。Pass 向 Analyzer 的 Run 函数提供关于被分析包的信息,并向 Run 函数提供用于向驱动程序报告诊断结果和其他信息的操作。
type Pass struct {
Fset *token.FileSet
Files []*ast.File
OtherFiles []string
IgnoredFiles []string
Pkg *types.Package
TypesInfo *types.Info
ResultOf map[*Analyzer]interface{}
Report func(Diagnostic)
...
}
Fset、Files、Pkg 和 TypesInfo 字段提供了单个 Go 代码包的语法树、类型信息和源代码位置。
OtherFiles 字段提供该包中包含的非 Go 文件(如汇编文件)的名称。类似地,IgnoredFiles 字段提供在当前构建配置下不属于该包、但可能属于其他构建配置的 Go 和非 Go 源文件的名称。这些文件的内容可以通过 Pass.ReadFile 读取;加载非 Go 文件并对其报告诊断的示例,请参考“asmdecl”或“buildtags”分析器。
ResultOf 字段提供该分析器所需的其他分析器(通过其 Analyzer.Requires 字段指定)的计算结果。驱动程序会先运行这些必需的分析器,并将其结果存入该映射中。每个分析器必须返回与其 Analyzer.ResultType 字段所描述类型一致的值。
例如,ctrlflow 分析器返回 *ctrlflow.CFGs,为包中的每个函数提供控制流图(参见 golang.org/x/tools/go/cfg);inspect 分析器返回一个值,使其他分析器能更高效地遍历包的语法树;而 buildssa 分析器则构建 SSA 形式中间表示。
这些分析器各自扩展了后续分析器的能力,且无需对核心 API 增加依赖,因此分析工具只需为所需扩展付出代价。
Report 函数用于发出诊断信息,即与源代码位置相关联的消息。对于大多数分析而言,诊断信息就是其主要输出。为方便起见,Pass 提供了辅助方法 Reportf,通过格式化字符串报告新的诊断信息。Diagnostic 的定义如下:
type Diagnostic struct {
Pos token.Pos
Category string // optional
Message string
}可选的 Category 字段是一个简短标识符,用于区分分析产生的多种诊断信息类型。
Diagnostic 结构体没有表示严重程度的字段,因为用户对分析器及其诊断信息的相对重要性的看法差异很大。该框架的设计并不要求每个分析器自行判断诊断的严重性。相反,我们期望驱动程序允许用户根据产生诊断的分析器及可选的 Category 字段,按个人偏好自定义过滤和优先级排序。
大多数分析器检查的是带类型的 Go 语法树,但少数分析器(如 asmdecl 和 buildtag)会检查 Go 源文件的原始文本,甚至非 Go 文件(如汇编文件)。要针对原始文本文件的某一行报告诊断信息,可以使用以下代码序列:
content, err := pass.ReadFile(filename)
if err != nil { ... }
tf := fset.AddFile(filename, -1, len(content))
tf.SetLinesForContent(content)
...
pass.Reportf(tf.LineStart(line), "oops")
基于事实的模块化分析 ¶
为提升效率和可扩展性,大型程序通常采用分离编译:程序单元分别编译,仅当依赖发生变化时才重新编译;独立模块可并行编译。同样的技术可应用于静态分析,以获得相同收益,这类分析称为“模块化”分析。
编译器的类型检查就是一种模块化静态分析。我们希望应用于Go程序的其他许多检查器,可理解为替代性或非标准类型系统。例如,vet的printf检查器会推断函数是否具有“printf包装器”类型,并对这类函数的调用执行更严格的检查。此外,它还会记录哪些函数是printf包装器,供后续分析阶段通过归纳识别其他包装器。像“f是printf包装器”这样的结果本身并不有趣,但作为通向有趣结果(如诊断信息)的垫脚石,被称为Fact。
分析API允许分析定义新的事实类型,将这些类型的事实与当前包中声明的对象(命名实体)或整个包关联,并查询与对象或包关联的给定类型的事实。
使用事实的分析器必须声明其类型:
var Analyzer = &analysis.Analyzer{
Name: "printf",
FactTypes: []analysis.Fact{new(isWrapper)},
...
}
type isWrapper struct{} // => *types.Func f “is a printf wrapper”
驱动程序确保在分析包之前,已生成该 pass 依赖的事实,并负责将事实从一个包传播到另一个包,可能跨越地址空间。因此,事实必须是可序列化的。API 要求驱动程序使用 gob 编码——一种高效、健壮、自描述的二进制协议。如果默认编码不合适,事实类型可以实现 GobEncoder/GobDecoder 接口。事实应该是无状态的。由于序列化后的事实可能出现在构建产物中,事实的 gob 编码必须具有确定性,以避免在使用内容寻址缓存的构建系统中出现虚假的缓存未命中。驱动程序对给定分析 pass 导出的所有事实进行一次 gob 编码调用,以保留多个事实所引用的共享数据结构的拓扑结构。
Pass 类型提供了导入和导出事实的函数,这些事实可以与对象或包相关联:
type Pass struct {
...
ExportObjectFact func(types.Object, Fact)
ImportObjectFact func(types.Object, Fact) bool
ExportPackageFact func(fact Fact)
ImportPackageFact func(*types.Package, Fact) bool
}
分析器只能导出与当前包或其对象关联的事实,但可以导入当前包所有导入依赖项中的任何包或对象的事实。
从概念上讲,ExportObjectFact(obj, fact) 将 fact 插入到一个以 (obj, TypeOf(fact)) 为键的隐藏映射中,而 ImportObjectFact 函数从该映射中检索条目,并将值复制到 fact 指向的变量中。这一方案假设 fact 的具体类型是指针;Validate 函数会检查这一假设。有关对象事实的实际示例,请参见 printf 分析器。
某些驱动程序实现(例如基于 Bazel 和 Blaze 的实现)目前不会对标准库的包应用分析器。因此,为了获得最佳效果,分析器作者不应依赖标准包的分析事实。例如,虽然 printf 检查器能够在分析 log 包时推断出 log.Printf 是一个 printf 包装器,但这一事实是内置于分析器中的,以便即使在不对标准包应用分析器的驱动程序上运行时,也能正确检查对 log.Printf 的调用。我们希望在将来消除这一限制。
测试分析器 ¶
analysistest 子包提供了测试 Analyzer 的工具。只需几行代码,就能在包含测试数据的包上运行分析器,并检查它是否报告了所有预期的诊断和事实(且不多报)。预期结果通过输入代码中的 "// want ..." 注释来表达。
独立命令 ¶
分析器以包的形式提供,供驱动程序导入。vet 命令会导入一组分析器,但用户可能希望定义自己的分析命令来执行额外检查。为简化创建分析命令(无论是单个分析器还是整套分析器)的任务,我们提供了 singlechecker 和 multichecker 子包。
singlechecker 包为运行单个分析器的命令提供了 main 函数。按照惯例,每个分析器(如 go/analysis/passes/findcall)都应附带一个基于 singlechecker 的命令(如 go/analysis/passes/findcall/cmd/findcall),其完整定义如下:
package main
import (
"golang.org/x/tools/go/analysis/passes/findcall"
"golang.org/x/tools/go/analysis/singlechecker"
)
func main() { singlechecker.Main(findcall.Analyzer) }
提供多个分析器的工具可以用类似方式使用 multichecker,向其传入 Analyzer 列表即可。