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

本文介绍了如何利用 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/analysis 软件包,它可以帮助定制代码分析器。

分析什么内容

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",并设个星标吧,如果能一键三连(转发、点赞、在看),则能给我带来更多的支持和动力,激励我持续写下去,和大家共同成长进步!

本文由mdnice多平台发布

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,657评论 6 505
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,889评论 3 394
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,057评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,509评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,562评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,443评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,251评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,129评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,561评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,779评论 3 335
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,902评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,621评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,220评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,838评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,971评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,025评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,843评论 2 354

推荐阅读更多精彩内容