使用golang的AST编写定制化lint
什么是lint
(来自wiki)在计算机科学中,lint是一种工具程序的名称,它用来标记源代码中,某些可疑的、不具结构性(可能造成bug)的段落。它是一种静态程序分析工具,最早适用于C语言,在UNIX平台上开发出来。后来它成为通用术语,可用于描述在任何一种计算机程序语言中,用来标记源代码中有疑义段落的工具。
什么是AST
(来自wiki)在计算机科学中,抽象语法树(Abstract Syntax Tree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。之所以说语法是“抽象”的,是因为这里的语法并不会表示出真实语法中出现的每个细节。比如,嵌套括号被隐含在树的结构中,并没有以节点的形式呈现;而类似于 if-condition-then
这样的条件跳转语句,可以使用带有三个分支的节点来表示。
(来自网络)在Go语言中,AST是通过Go语言的内置包go/ast
来实现的。该包提供了一系列类型和函数,可以用于生成和操作AST。
使用 ast 包提供的一些函数,我们可以非常方便地将如下的规则字符串:
orders > 10000 && driving_years > 5 |
解析成一棵这样的二叉树:
其中,ast.BinaryExpr
代表一个二元表达式,它由 X 和 Y 以及符号 OP 三部分组成。最上面的一个 BinaryExpr
表示规则的左半部分和右半部分相与。
很明显,左半部分就是:orders > 10000
,而右半部分则是:driving_years > 5
。神奇的是,左半部分和右半部分恰好又都是一个二元表达式。
左半部分的 orders > 10000
其实也是最小的叶子节点,它可以算出来一个 bool 值。把它拆开来之后,又可以分成 X、Y、OP。X 是 orders
,OP 是 “>",Y 则是 “10000”。其中 X 表示一个标识符,是 ast.Ident 类型,Y 表示一个基本类型的字面量,例如 int 型、字符串型……是 ast.BasicLit 类型。
右半部分的 driving_years > 18
也可以照此拆分。
然后,从 json 中取出这个司机的 orders
字段的值为 100000,它比 10000 大,所以左半部分算出来为 true。同理,右半部分算出来也为 true。最后,再算最外层的 “&&",结果仍然为 true。
至此,直接根据规则字符串,我们就可以算出来结果。
如果写成程序的话,就是一个 dfs 的遍历过程。如果不是叶子结点,那就是二元表达式结点,那就一定有 X、Y、OP 部分。递归地遍历 X,如果 X 是叶子结点,那就结束递归,并计算出 X 的值……
我们可以使用AST做什么
AST一般可以被用来做linter。现在常用的golangci-lint已经集成了一些linter了,但是我们有可能会有一些自己定制化的代码静态检查规则,就可以通过自己解析AST来实现。
之前在对接现网问题和做code review的时候,发现有可能有些编码习惯会导致一些问题,其中一个问题就是遇到某个接口报错时,返回出来的异常里面却没有包含异常的详情,导致问题无法定位,大致可能是这样的代码:
err := someFunc() if err != nil { return fmt.Errorf("call someFunc failed") } |
上面这段代码里面,调用someFunc这个接口出错了,但是最后返回的异常里面却没有包含具体的错误,最终可能会导致问题无法被定位。
实际案例就是之前定位线上告警邮件通知时,所有的参数错误都被包装成一个“参数错误”的异常被跑出来,但是具体的参数错误就没有被包含了,所以当时通过日志是完全没法定位具体是哪个参数报错了。所以正确的代码应该是这样
err := someFunc() if err != nil { return fmt.Errorf("call someFunc failed, %s", err) // 或者是 return err之类的,总之return的内容里面一定得包含err才行 } |
所以基于上面的内容,我们可以使用AST做的就是静态检查是否有类似的错误,这种错误就可以被抽象为:
在err != nil的if分支内,是否有return语句未包含err相关信息 |
我们可以把这个规则命名为NotReturnErr检查。
但是这个规则也不完善,因为有可能有这种情况:
err := someFunc() if err != nil { errMsg := fmt.Sprintf("call someFunc failed, %s", err) return fmt.Errorf("%s", errMsg) } |
这种如果要通过规则去检查可能就会复杂一点,目前选择扫描出来之后通过人工去筛查一下是否有这种误判的情况了。
如何实现NotReturnErr检查
基本概念
先具体介绍一些这里可能会用到的AST的具体概念:
ast.Node
在Go语言的抽象语法树(AST)中,Node
是所有AST节点类型的接口。所有的AST节点,无论是表达式、声明、语句,还是其他类型的节点,都实现了Node
接口。这个接口定义如下:
type Node interface { // Pos方法返回节点的第一个字符的位置。 Pos() token.Pos // End方法返回节点的最后一个字符的下一个位置。 End() token.Pos } |
ast.IfStmt
ast.Node的实现之一,If Statement的缩写,代表一个if语句,其定义如下:
type IfStmt struct { If token.Pos // "if"的位置 Init Stmt // 初始化语句;可能为空 Cond Expr // 条件表达式 Body *BlockStmt // "then"部分 Else Stmt // "else"部分;可能为空 } |
ast.BinaryExpr
在Go语言的抽象语法树(AST)中,BinaryExpr
代表一个二元表达式。二元表达式是一个包含两个操作数和一个操作符的表达式。例如,a + b
、c * d
和e == f
都是二元表达式。
在Go语言的AST中,BinaryExpr
是一个结构体,其定义如下:
type BinaryExpr struct { X Expr // 左操作数 OpPos token.Pos // 操作符的位置 Op token.Token // 操作符 Y Expr // 右操作数 } |
ast.Ident
在Go语言的抽象语法树(AST)中,ast.Ident
代表一个标识符。标识符在编程中广泛使用,它可以是变量名、函数名、类型名等。
在Go语言的AST中,ast.Ident
是一个结构体,其定义如下:
type Ident struct { NamePos token.Pos // 标识符的位置 Name string // 标识符的名字 Obj *Object // 对应的对象;可能为空 } |
ast.ReturnStmt
在Go语言的抽象语法树(AST)中,ReturnStmt
代表一个return语句。return语句用于从函数中返回,并且可以携带返回值。
在Go语言的AST中,ReturnStmt
是一个结构体,其定义如下:
type ReturnStmt struct { Return token.Pos // "return"的位置 Results []Expr // 返回值列表;可能为空 } |
ast.CallExpr
在Go语言的抽象语法树(AST)中,CallExpr
代表一个函数调用表达式。函数调用表达式是一种特殊的表达式,它表示对一个函数的调用。
在Go语言的AST中,CallExpr
是一个结构体,其定义如下:
type CallExpr struct { Fun Expr // 被调用的函数 Lparen token.Pos // 左括号的位置 Args []Expr // 函数调用的参数列表 Ellipsis token.Pos // 省略号的位置(如果存在) Rparen token.Pos // 右括号的位置 } |
ast.SelectorExpr
在Go语言的抽象语法树(AST)中,SelectorExpr
代表一个选择器表达式。选择器表达式用于访问结构体的字段或者调用包的函数或变量。
在Go语言的AST中,SelectorExpr
是一个结构体,其定义如下:
type SelectorExpr struct { X Expr // 表达式 Sel *Ident // 选择器 } |
实现逻辑
实现逻辑用一句话概括就是,遍历目录下的所有go文件(除vendor以外),然后对所有遍历的文件生成AST语法树,找到同时满足以下条件的AST节点:
- 所处函数为返回值有error的函数
- 有if语句,且if语句包含err != nil的判断
- if语句的body里有return语句
判断此节点是否返回error相关内容,例如return err或者return fmt.Errorf("%s", err.Error()),如果没有的话就打印记录。
代码仓
运行效果
然后就可以根据具体的情况来看是否需要对当前代码进行修改,我们以这个为例:
打开具体文件发现内容如下所示:
这种如果不是逻辑上就需要忽略这个err的话,那么这里可能就会有问题,这种就是需要排查是否需要修改的。
ChangeLog
下一步计划
- 优化检测逻辑,当前会检测到一些error派生的类生成的新error,这种暂时没法识别成error
- 计划将此lint和其他lint看能否集成到gitlab提交代码流程内,