5 分钟搞定 Golang 自定义代码分析器

162 阅读7分钟

本文介绍了如何利用 Go 的 analysis 包提高代码质量,通过构建自定义分析器并与 golangci-lint 集成,详细说明了 analysis 包的使用方法和如何定义分析器,以及如何将自定义分析器集成到 golangci-lint 中。原文:Enhancing Code Quality with Go’s Analysis Package

Go 语言因其简洁的语法和极高的性能而广受欢迎。随着使用范围越来越广,遵循编码标准和一致性就越来越重要。为了实现这一目标,就需要借助静态代码分析工具的帮助,比如著名的 golangci-lint

在研究 golangci-lint 和 Go 官方指南的过程中,我发现了一个强大的工具 -- golang.org/x/tools/go/… 软件包,它可以帮助定制代码分析器。

分析什么内容

golang.org/x/tools/go/analysis 软件包为 Go 提供了一个静态分析框架,支持可传递分析(分析器的输出可作为其他分析器的输入),并使开发人员能够定制用于静态代码检查的工具。这些工具可以集成到 Go 工具链中,例如可以通过 go vet 命令运行。

主要 API
  • 核心结构 Analyzer 也是一个静态分析器。每个 Analyzer 都有名称、描述和 Run 函数作为前置条件。
type Analyzer struct {
  Name             string
  Doc              string
  Flags            flag.FlagSet
  Run              func(*Pass) (interface{}, error)
  RunDespiteErrors bool
  ResultType       reflect.Type
  Requires         []*Analyzer
  FactTypes        []Fact
}

Analyzer 类似于 Cobra Go CLI 命令,包括用于定义参数的 Flags 和用于执行 Pass 的主 Run 函数。ResultType 是该 Analyzer 的执行结果,可被其他分析器使用。Requires 是当前 Analyzer 依赖的一组分析程序。

type Pass struct {
  Analyzer *Analyzer // 当前分析器的标识

  // 语法和类型信息
  Fset         *token.FileSet // 文件位置信息
  Files        []*ast.File    // 每个文件的抽象语法树
  OtherFiles   []string       // 此包的非go文件的名称
  IgnoredFiles []string       // 包中被忽略的源文件名称
  Pkg          *types.Package // 关于包的类型信息
  TypesInfo    *types.Info    // 关于语法树的类型信息
  TypesSizes   types.Sizes    // 计算类型大小的函数
  ExportObjectFact func(types.Object, Fact)
  ImportObjectFact func(types.Object, Fact) bool

  ...
}
  • Fact 是一种中间事实,可用于在不同检查器之间传递信息。这些信息通常是对软件包的断言,例如某个类型是否实现了某个接口,或者某个函数是否总是返回非零错误,这些信息被附加到语法树节点上,供后续分析使用。
type Fact interface {
 AFact() // dummy method to avoid type errors
}

使用 Fact 通常有 4 个步骤:创建、注册、保存和查看。

下面是一个简单的分析器,可以遍历文件找到相应的 Function 语句,并将其保存为 Fact 供以后查看。

type myFact struct {
    Message string
}

func (f *myFact) AFact() {}

var Analyzer = &analysis.Analyzer{
    Name: "example",
    Doc:  "example analyzer that uses Facts and Passes",
    Run:  run,
    FactTypes: []analysis.Fact{(*myFact)(nil)},
}

func run(pass *analysis.Pass) (interface{}, error) {
    for _, file := range pass.Files {
        for _, decl := range file.Decls {
            if funcDecl, ok := decl.(*ast.FuncDecl); ok {
                // add a Fact
                fact := &myFact{Message: "This is a function"}
                // export the fact
                pass.ExportObjectFact(funcDecl.Name, fact)
            }
        }
    }
    checkFacts(pass)
    return nil, nil
}

func checkFacts(pass *analysis.Pass) {
    for _, file := range pass.Files {
        for _, decl := range file.Decls {
            if funcDecl, ok := decl.(*ast.FuncDecl); ok {
                var fact myFact
                // import the fact
                if pass.ImportObjectFact(funcDecl.Name, &fact) {
                    fmt.Printf("Function %s: %s\n", funcDecl.Name.Name, fact.Message)
                }
            }
        }
    }
}

func main() {
    singlechecker.Main(Analyzer)
}

正常情况下,我们很少使用 Facts。每个检查器都相互独立,只关注自己的任务,并使用 Pass.Reportf 方法报告错误。下面展示了如何检查没有未使用变量的 go 文件。

package main

import (
    "golang.org/x/tools/go/analysis"
    "golang.org/x/tools/go/analysis/passes/inspect"
    "golang.org/x/tools/go/analysis/singlechecker"
    "golang.org/x/tools/go/ast/inspector"
)

var Analyzer = &analysis.Analyzer{
    Name: "unusedvar",
    Doc: "checks for unused variables in functions",
    Run: run,
}

func run(pass *analysis.Pass) (interface{}, error) {
    inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)

    nodeFilter := []ast.Node{
        (*ast.FuncDecl)(nil),
    }

    inspect.Preorder(nodeFilter, func(n ast.Node) {
        fn, _ := n.(*ast.FuncDecl)
        if fn.Body == nil {
            return // interface
        }

        for _, stmt := range fn.Body.List {
            switch stmt := stmt.(type) {
            case *ast.AssignStmt:
                for _, lhs := range stmt.Lhs {
                    if ident, ok := lhs.(*ast.Ident); ok && !ident.Used() {
                        pass.Reportf(ident.Pos(), "unused variable: %s", ident.Name)
                    }
                }
            }
        }
    })

    return nil, nil
}

func main() {
    singlechecker.Main(Analyzer)
}

在分析器中,我们首先定义一个 Analyzer 结构实例,然后指定分析器名称、描述和 Run 函数。在 Run 函数中,使用 inspector 过滤函数声明节点,检查每个函数中的变量分配情况,如果变量未被使用,则通过 pass.Reportf 方法报错。

AST 树

analysis 包本身只提供了代码框架,侧重于代码分析操作、结果计算和交付等上层抽象,并提供了链式组合接口。如上述代码所示,ast.Node 发起的遍历发生在 Preorder 函数中。因此,理解 Golang 的 ast 树有助于创建分析器。

在 ast 树中,节点(Node)是最基本的接口,由此衍生出越来越多的具体类型。

// different types of expr
// Ident
var x
// BasicLit
42
"hello"
// BinaryExpr
a + b
// CallExpr
fmt.Println("hello world!")
// IndexExpr
arr[1]
m["key"]
// SliceExpr
s[1:5]
// TypeAssertExpr
x.(int)
// UnaryExpr
&x
!b
// CompositeLit
[]int{1,2,3}

处理的功能包括:

  • filter 方法:FilterDeclFilterFileFilterPackage 可以通过传入的过滤器过滤不同节点。请看如下示例,该示例通过遍历和过滤来获取没有 Function 声明的节点。
// 定义 Filter 函数来过滤掉函数声明
filterFunc := func(d ast.Decl) bool {
    _, ok := d.(*ast.FuncDecl)
    return !ok  
}

// 过滤文件
ast.FilterFile(node, filterFunc)
// 打印非 nil 的所有节点
ast.Fprint(os.Stdout, fset, node, ast.NotNilFilter)
  • traversal 方法:Inspect 方法和 Walk 方法用于遍历树,这两种方法都使用深度优先遍历访问 AST 树。不过,Walk 方法默认调用节点的 Visit 方法,而在 Inspect 方法中,可以通过定义函数来添加额外逻辑,后者在实现检查器时应用得更多。

定义 Linter

Mastering Go: In-Depth Analysis of Uber and Google’s Coding Standards 中,我们知道 Uber 建议用 var 代替 {} 进行切片初始化,下面将定义一个简单的 Linter 来进行检查。

  • 定义分析器
var InitChecker = &analysis.Analyzer{
 Name: "InitChecker",
 Doc:  `This analyzer suggests "good" initilization behaviors.`,
 Run:  runAnalyzer,
 Requires: []*analysis.Analyzer{
  inspect.Analyzer,
 },
}
  • 初始化 ast 树
inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
 nodeFilter := []ast.Node{
  (*ast.AssignStmt)(nil),
  (*ast.GenDecl)(nil),
  (*ast.CompositeLit)(nil),
 }
  • PreOrder 中检查切片的初始化声明
inspect.Preorder(nodeFilter, func(n ast.Node) {
  switch x := n.(type) {
  case *ast.AssignStmt:
   // check for slice init `var a []int` should be `a := []int{}`
   // check for map init with make
   for _, rhs := range x.Rhs {
    if cl, ok := rhs.(*ast.CompositeLit); ok && cl.Type != nil && len(cl.Elts) == 0 {
     switch cl.Type.(type) {
     case *ast.ArrayType:
      pass.Reportf(cl.Pos(), "consider using 'var' for empty slice initialization to avoid unnecessary memory allocation")
    }
   }
  }
 })

如果需要扩展这个检查器,比如检查 make 是否用于 map 的初始化,只需在上面的 switch 中添加以下内容即可。

     case *ast.MapType:
      pass.Reportf(cl.Pos(), "consider using 'make' for map initialization to be explicit about intent")
     }
自定义 Golangci 检查器

golangci-lint 是一个集成了多种检查器的工具,支持通过配置文件定制和启用不同的检查器。要在 golangci-lint 中使用自定义检查器,需要将它们编译为插件,然后在 .golangci.yml 配置文件中指定插件路径。例如:

linters-settings:
  custom:
    initcheck:
      path: ./path/to/initcheck
      description: Checks if initializations follow Uber's style guide
      original-url: "https://github.com/xxx"

结论

本文通过对 Go analysis 软件包的探索,分享了构建分析器的见解,这些分析器是改进代码质量的基础。通过对软件包的主要 API(尤其是 PassFact)的了解,介绍了它们在构建强大的上下文感知工具中的重要作用。通过实际例子,进一步深入了解了 ast 软件包的复杂性,解读了 Node 概念及其变体,如表达式和语句。有了这些基础,我们才能设计出旨在优化切片初始化的检查器。最后,详细介绍了与 golangci-lint 的集成过程,通过简洁的方法,将自定义检查整合到 Go 项目中。这项工作不仅提高了代码质量,还增强了我们对 Go 分析能力的理解。


你好,我是俞凡,在Motorola做过研发,现在在Mavenir做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起交流学习。为了方便大家以后能第一时间看到文章,请朋友们关注公众号"DeepNoMind",并设个星标吧,如果能一键三连(转发、点赞、在看),则能给我带来更多的支持和动力,激励我持续写下去,和大家共同成长进步!