把「Go静态分析」放进你的工具箱
背景
提到静态分析,大家第一反应可能是Sonar、FindBugs等静态代码检查工具,它通过对源码的分析,不实际运行代码,检查代码是否符合编码规范,发现源码中潜在的漏洞。这当中主要通过静态编译相关技术,其实大家在日常的业务代码开发过程中借助IDE,使用的代码格式化、查找某方法的定义/调用等功能也是基于这套技术实现的。
本文期望扩充静态分析的定义,泛指所有通过静态编译相关技术实现的工具,不局限于上面提到的通用的,开箱即用的功能。掌握静态分析的原理,基于特定场景,可以组装属于自己的工具,提升开发效率,把「Go静态分析」放进你的工具箱。
Golang在 github.com/golang/tool… 提供了相关工具和代码包,非常清晰的将静态编译的各个过程和中间结果暴露了出来,让我们可以方便的定义自己的工具。
下面带大家一起浏览一下Golang静态编译的基础知识,更加深入的了解静态编译的整个过程,并通过几个例子看看具体能做什么新工具。
Go编译过程
和很多其他静态语言类似,Golang编译的过程如下:
暂时无法在飞书文档外展示此内容
下面将展开几个主要步骤的细节,主要目的让大家对于该步骤的概念,和中间结果具备哪些信息量、信息组织形式有一个大致的感知。
词法分析
词法分析将源代码的字符序列转换为单词(Token)序列的过程,不同的语言定义了不同的关键字和词法规则。
下面用最简单的hello world举例,第一部分是源代码,第二部分是转化后的Token。
package main
import "fmt"
func main() {
fmt.Println("Hello, world!")
}
1:1 package "package"
1:9 IDENT "main"
1:13 ; "\n"
2:1 import "import"
2:8 STRING ""fmt""
2:13 ; "\n"
3:1 func "func"
3:6 IDENT "main"
3:10 ( ""
3:11 ) ""
3:13 { ""
4:3 IDENT "fmt"
4:6 . ""
4:7 IDENT "Println"
4:14 ( ""
4:15 STRING ""Hello, world!""
4:30 ) ""
4:31 ; "\n"
5:1 } ""
5:2 ; "\n"
5:3 EOF ""
Token展示了3列信息
- token position,词对应源码的位置(行:列)
- token,词类型
- literal string,某些词类型具体的字符
在这个例子中可以看到,分词结果由下面三类组成:
- 关键字(package、import、func)
- 字面量(没有引号的单词为IDENT,有引号的单词为STRING)
- 操作符(各种括号)
这里可以看到Golang的分号规则是在词法分析阶段完成的,自动在行尾添加了分号token。
也就出现了下面有趣的现象
// 词法分析通过,main()后面会多一个分号token,也就是上面例子中第10行后面多一个分号token
// 语法分析不通过,因为多的这个分号token破坏语法结构
func main()
{
fmt.Println("Hello, world!")
}
// 词法分析通过,词法分析结果与上面例子完全一致,因为正括号token后面的换行不会补分号token
// 语法分析通过
func main() { fmt.Println("Hello, world!")
}
下面展示完整的Golang SDK中对于词类型的定义:
// The list of tokens.
const (
// Special tokens
ILLEGAL Token = iota
EOF
COMMENT
// 第1组,字面量
literal_beg
// Identifiers and basic type literals
// (these tokens stand for classes of literals)
IDENT // main
INT // 12345
FLOAT // 123.45
IMAG // 123.45i
CHAR // 'a'
STRING // "abc"
literal_end
// 第2组,操作符
operator_beg
// Operators and delimiters
ADD // +
SUB // -
MUL // *
QUO // /
REM // %
// ...
operator_end
// 第3组,关键字
keyword_beg
// Keywords
BREAK
CASE
CHAN
CONST
CONTINUE
// ...
keyword_end
)
语法分析
语法分析是将单词(Token)序列通过语法规则转化为语法树AST(Abstract Syntax Tree)的过程,AST本质上是一个树形结构的对象,由下面三类基本节点组成:
- Decl,声明
- GenDecl,类型声明(import,constant,type,变量)
- FuncDecl,函数声明
- Stmt,语句
- IfStmt、ForStmt、ReturnStmt,流程控制语句
- BlockStmt,代码块
- ExprStmt,表达式语句
- Expr,表达式
- BinaryExpr,二元表达式(X、操作符、Y)
- CallExpr,调用函数
下面还是hello world的例子,展示语法分析的结果
// 语法树以文件为根
*ast.File {
// 包声明
. Package: 1:1
. Name: *ast.Ident { //... }
// 内容声明
. Decls: []ast.Decl (len = 2) {
// 1. import声明
. . 0: *ast.GenDecl {
. . . Tok: import
. . . // ...
. . }
// 2. main函数声明
. . 1: *ast.FuncDecl {
. . . Name: *ast.Ident {
. . . . Name: "main"
. . . . // ...
. . . }
. . . Type: *ast.FuncType { // ... }
// main函数体声明
. . . Body: *ast.BlockStmt {
. . . . List: []ast.Stmt (len = 1) {
// 第1句声明
. . . . . 0: *ast.ExprStmt {
// 调用语句
. . . . . . X: *ast.CallExpr {
. . . . . . . Fun: *ast.SelectorExpr {
. . . . . . . . X: *ast.Ident {
. . . . . . . . . Name: "fmt"
. . . . . . . . }
. . . . . . . . Sel: *ast.Ident {
. . . . . . . . . Name: "Println"
. . . . . . . . }
. . . . . . . }
// 调用语句参数
. . . . . . . Args: []ast.Expr (len = 1) {
// 第1个参数
. . . . . . . . 0: *ast.BasicLit {
. . . . . . . . . Kind: STRING
. . . . . . . . . Value: ""Hello, world!""
. . . . . . . . }
. . . . . . . }
. . . . . . }
. . . . . }
. . . . }
. . . }
. . }
. }
}
可以看到
- 一个文件是一个AST树,本质是在一个Package下的一组内容声明的集合,一项内容声明可以是一个import外部包、一个常量定义、一个函数定义等。
- 一个函数定义FuncDecl,本质是一组Stmt语句的集合,所以FuncDecl的Body由一个BlockStmt语句块组成。一个语句可以是if、for等条件控制语句,或一个ExprStmt表达式语句。
- 一个表达式ExprStmt,本质是一个BinaryExpr二元表达式,用于赋值;或一个CallExpr用于函数调用。
总结来说,Decl(声明)Stmt(语句)Expr(表达式)是一个逐级包含的关系,共同组成了AST树。
SSA
Go 1.7开始,Go将原来的IR(Intermediate Representation,中间代码)转换成SSA(Static Single Assignment,静态单赋值)形式的IR,可以引入更多优化。具体细节和原理更多涉及编译期优化,这里就不过多展开,只介绍一下SSA的概念和过程中间变量。
SSA的结果,可以理解为每个赋值得到唯一的变量名。当 x 重新分配了另一个值时,将创建一个新名称 x_1。比如下面例子:
SSA前
x = 1
y = 7
// do stuff with x and y
x = y
y = func()
// do more stuff with x and y
SSA后
x = 1
y = 7
// do stuff with x and y
x_1 = y
y_1 = func()
// do more stuff with x_1 and y_1
在概念上SSA这一步也是输出语法分析的结果,对于我们来说更多有用的内容是因为Golang/Tools工具链在SSA之后的结果做了基本的分析和汇聚,大部分的三方工具也是基于SSA的结果开始分析的。
我们这里主要看一下对于一个函数定义Function包含哪些信息。
type Function struct {
name string
object types.Object // a declared *types.Func or one of its wrappers
method *types.Selection // info about provenance of synthetic methods
Signature *types.Signature
pos token.Pos
Synthetic string // provenance of synthetic function; "" for true source functions
syntax ast.Node // *ast.Func{Decl,Lit}; replaced with simple ast.Node after build, unless debug mode
parent *Function // enclosing function if anon; nil if global
Pkg *Package // enclosing package; nil for shared funcs (wrappers and error.Error)
Prog *Program // enclosing program
Params []*Parameter // function parameters; for methods, includes receiver
FreeVars []*FreeVar // free variables whose values must be supplied by closure
Locals []*Alloc // local variables of this function
Blocks []*BasicBlock // basic blocks of the function; nil => external
Recover *BasicBlock // optional; control transfers here after recovered panic
AnonFuncs []*Function // anonymous functions directly beneath this one
referrers []Instruction // referring instructions (iff Parent() != nil)
}
一些常见的Function内容都汇总在这个结构体中
- name(名称)
- Params(入参)
- Signature(函数签名,函数入参和返回值)
- Blocks(函数具体语句)
- Recover(Recover具体语句)等
上面的method指golang中的成员函数,是一种特殊的function,所以对应Params比代码中多一个参数,多的第一个参数是receiver(具体的类型实例)。
工具包
最后整理介绍一下Golang SDK提供的工具包,方便大家查询对应的接口和使用
- go/scanner,词法分析
- go/token,token定义
- go/parser,语法分析
- go/ast,AST结构定义
- golang.org/x/tools/go/packages,一组包检查和分析
- golang.org/x/tools/go/ssa,SSA分析
- golang.org/x/tools/go/callgraph,调用关系算法和工具
- golang.org/x/tools/go/analysis,静态分析工具
可以看到,对于源码的分析程度逐渐加深,从单一文件的词法信息(scanner、token)、到单一文件的语法信息(parser、ast)、最后多个文件互相引用和调用的信息(ssa、packages、callgraph、analysis)。在我们使用编译信息的时候,可以选择需要的解析程度和中间变量开始。
常用工具原理解析
通过上面对编译过程的拆解和讲解,大家对于一些基本概念和中间变量已经有一定的了解,现在通过几个Go官方工具中具体的应用场景,一起看看可以用这些信息制作哪些工具和它们的基本原理。
gofmt
gofmt是Golang官方提供的工具,用于代码格式化,统一代码风格。实现非常简洁,并没有提供非常复杂的代码样式模版可供定制,唯一可定制的是用tab或空格格式化缩进。
它的基本原理是通过解析为AST后反写格式化实现的,具体代码详见Go SDK/src/cmd/gofmt,大致流程如下:
- 第一步源代码 -> AST,通过go/parser完成
- 第二步AST Rewrite,主要目的是做一些简单的代码优化,主要下面3个步骤:
- rewrite by rule,根据自定义的规则重写AST,格式pattern -> replacement
- 例如 foo -> bar,将编译的所有foo变量重命名为bar
- SortImports,排序import
- simplify,根据Go内部规则重写AST
- 例如 s[a:len(s)] -> s[a:],将多余的len(s)操作去掉
- rewrite by rule,根据自定义的规则重写AST,格式pattern -> replacement
- 第三步AST Printer,通过go/printer完成,按照每个node不同的类型(Decl、Stmt、Expr)递归完成输出
下面展示一下AST Printer的主流程,源码中按照👇引导读者下钻阅读:
// 入口函数,传入AST根节点的node,类型为ast.File节点
func (p *printer) printNode(node interface{}) error {
// ...
// format node
switch n := node.(type) {
// ...
case *ast.File:
p.file(n) // 👇
// ...
default:
goto unsupported
}
return nil
}
// 进入ast.File的输出函数,这里print包信息,剩下交给declList函数
func (p *printer) file(src *ast.File) {
p.setComment(src.Doc)
p.print(src.Pos(), token.PACKAGE, blank) // 输出package关键字
p.expr(src.Name) // 输出包名,一个类型为ast.Ident表达式
p.declList(src.Decls) // 输出所有定义列表,一组类型为ast.Decl声明 // 👇
p.print(newline)
}
// 进入ast.Decl列表的输出函数,定位每一个声明开始,剩下交给decl函数
func (p *printer) declList(list []ast.Decl) {
tok := token.ILLEGAL
for _, d := range list {
// 根据一定条件判定输出换行
p.linebreak(p.lineFor(d.Pos()), min, ignore, tok == token.FUNC && p.numLines(d) > 1)
// 输出单个ast.Decl
p.decl(d) // 👇
}
}
// 进入ast.Decl的输出函数,例子中只有一个main函数
func (p *printer) decl(decl ast.Decl) {
switch d := decl.(type) {
case *ast.BadDecl:
p.print(d.Pos(), "BadDecl")
case *ast.GenDecl:
p.genDecl(d)
case *ast.FuncDecl:
p.funcDecl(d) // 👇
default:
panic("unreachable")
}
}
// 进入ast.FuncDecl的输出函数,输出func关键字,函数签名(入参和出参),剩下交给funcBody输出函数体
func (p *printer) funcDecl(d *ast.FuncDecl) {
p.setComment(d.Doc)
p.print(d.Pos(), token.FUNC, blank)
// We have to save startCol only after emitting FUNC; otherwise it can be on a
// different line (all whitespace preceding the FUNC is emitted only when the
// FUNC is emitted).
startCol := p.out.Column - len("func ")
if d.Recv != nil {
p.parameters(d.Recv, false) // method: print receiver
p.print(blank)
}
p.expr(d.Name)
p.signature(d.Type) // signature = 函数签名
p.funcBody(p.distanceFrom(d.Pos(), startCol), vtab, d.Body) // 👇
}
// 进入funcBody的输出函数
func (p *printer) funcBody(headerSize int, sep whiteSpace, b *ast.BlockStmt) {
// ...
p.block(b, 1) // 👇
}
// 进入ast.BlockStmt的输出函数
func (p *printer) block(b *ast.BlockStmt, nindent int) {
p.print(b.Lbrace, token.LBRACE) // 输出左花括号
p.stmtList(b.List, nindent, true) // 循环print包含的各个stmt
p.linebreak(p.lineFor(b.Rbrace), 1, ignore, true)
p.print(b.Rbrace, token.RBRACE) // 输出右花括号
}
剩余的代码不再展开,上面的过程已经把Decl的输出逻辑表达清楚,剩余的进入Stmt、Expr的部分结构类似,具体细节有兴趣同学可以自行阅读源码。
可以看到实现的逻辑与语法分析部分讲解的Decl(声明)Stmt(语句)Expr(表达式)逐级包含的关系完全一致,附加了相关的语法规则符号,换行。
lint
Go vet、golangci-lint是Golang常用的代码静态检查工具。它的实现原理都是基于SSA的go/analysis分析框架实现相关的逻辑。
这里以golangci-lint中的bodyclose检查为例,这个检查项主要检查HTTP response body使用后是否调用过close方法,避免未关闭的情况。
下面可以看到常用源码使用如下,获取一个resp,使用完成之后调用resp.Body.Close()关闭流
resp, err := http.Get("http://example.com/")
// ...
resp.Body.Close()
利用了SSA静态单赋值的特点,判断每一个引用是否调用了close方法或传递给下一个引用。
基本逻辑是遍历所有引用了Response的包中的所有指令Instruction,分为下面三种情况
- 直接使用,对应下面源码的ssa.FieldAddr
- http.Get获取的resp变量,不经过第二次赋值,不产生第二个SSA的变量,判断是否调用了Close方法
- 指针引用,对应下面源码的ssa.Store
- 更常见的情况,我们一般在defer func中调用resp.Body.Close(),保证close函数一定可以执行
- 形如变量被某个闭包函数引用,会产生一个ssa.Store的引用,我们需要判断被引用后是否调用了Close方法
- 函数调用,对应下面源码的ssa.Call
- resp获取后,如果被用于参数传递给了其他函数,会产生一个ssa.Call的引用,那就递归到对应的函数中进行判断,可能直接使用 或 指针引用
下面一起通过源码具体看一下如何实现一个lint的检查,具体源码位于 github.com/timakin/bod…
const (
Doc = "bodyclose checks whether HTTP response body is closed successfully"
nethttpPath = "net/http"
closeMethod = "Close"
)
// 这里介绍主要流程,过滤掉具体细节
func (r runner) run(pass *analysis.Pass) (interface{}, error) {
// 查找所有引用了net/http包,且使用了Response的
r.resObj = analysisutil.LookupFromImports(pass.Pkg.Imports(), nethttpPath, "Response")
// 收集相关引用对象和类型信息
// 。。。
// 循环所有检查函数
for _, f := range funcs {
// 如果函数返回不是http.Response跳过
// ...
// 未跳过的,遍历具体语句,查看是否open之后,没有close
for _, b := range f.Blocks {
for i := range b.Instrs {
pos := b.Instrs[i].Pos()
if r.isopen(b, i) {
pass.Reportf(pos, "response body must be closed")
}
}
}
}
}
func (r *runner) isopen(b *ssa.BasicBlock, i int) bool {
// 判断一些前置引用,省略
// ...
for _, resRef := range resRefs {
switch resRef := resRef.(type) {
case *ssa.Store: // Call in Closure function
if len(*resRef.Addr.Referrers()) == 0 {
return true
}
for _, aref := range *resRef.Addr.Referrers() {
if c, ok := aref.(*ssa.MakeClosure); ok {
f := c.Fn.(*ssa.Function)
if r.noImportedNetHTTP(f) {
// skip this
return false
}
called := r.isClosureCalled(c)
return r.calledInFunc(f, called)
}
}
case *ssa.Call: // Indirect function call
if f, ok := resRef.Call.Value.(*ssa.Function); ok {
for _, b := range f.Blocks {
for i := range b.Instrs {
return r.isopen(b, i)
}
}
}
case *ssa.FieldAddr: // Normal reference to response entity
if resRef.Referrers() == nil {
return true
}
bRefs := *resRef.Referrers()
for _, bRef := range bRefs {
bOp, ok := r.getBodyOp(bRef)
if !ok {
continue
}
if len(*bOp.Referrers()) == 0 {
return true
}
ccalls := *bOp.Referrers()
for _, ccall := range ccalls {
if r.isCloseCall(ccall) {
return false
}
}
}
}
}
return true
}
场景实践
除了一些通用的场景,一些日常开发过程中遇到的问题,也可以方便的通过静态分析的工具链快速实现,下面通过两个例子简单说明。
函数特征查找
- 问题定义
在一次oncall的过程中,发现一个依赖方的RPC方法BatchGetUserInfoByEmail出现问题。
RPC方法定义入参为Email slice,返回为用户信息slice,接口约定两个slice保持大小、顺序一致。后续业务代码也是按照这个假设进行的开发,并没有做相关的校验。
这次问题因为依赖方调整了相关逻辑,在一组email中有重复数据的时候,会返回去重后的用户信息,破坏了之前的假设,导致一些问题。
这个case处理完成后,想通过入参和出参都包含slice的特征排查一下是否存在其他隐患的可能,因为没有特定的关键字,源代码文本查询的方式不可用,但可以通过SSA相关工具链快速实现这样的查询。
- 实现思路
根据我们需要使用的特征,选择刚好足够的解析层面工具会更便于我们使用,这次需求的特征全部来自函数签名,所以不用使用多文件分析的工具包,使用单文件ssautil工具包就可以满足我们的需求。
基本的实现步骤如下:
- 加载项目源码,编译
- 遍历所有项目中的Function函数
- 判断函数签名中关于入参和出参的特征,输出函数的源码位置
下面通过源码详细讲解:
dir := "项目代码下载的本地路径"
// 1. 加载项目代码所有的package名称
initial, _ := packages.Load(&packages.Config{
Mode: packages.NeedName |
packages.NeedFiles |
packages.NeedCompiledGoFiles |
packages.NeedImports |
packages.NeedTypes |
packages.NeedTypesSizes |
packages.NeedSyntax |
packages.NeedTypesInfo,
Tests: false,
Dir: dir,
ParseFile: func(fset *token.FileSet, filename string, src []byte) (*ast.File, error) {
// 忽略测试文件
if strings.HasSuffix(filename, "_test.go") {
return nil, nil
}
return parser.ParseFile(fset, filename, src, parser.ParseComments)
},
}, dir+"/...")
// 2. 基于指定的package名称,创建SSA项目(包含所有引用的包)
prog, pkgs := ssautil.AllPackages(initial, 0)
for _, p := range pkgs {
if p != nil {
// 编译
p.Build()
}
}
// 3. 查找SSA项目所有Function
allFuncs := ssautil.AllFunctions(prog)
// 遍历所有Function,查找需要特征
for f := range allFuncs {
if f.Pkg != nil {
// 这里只演示查找入参包含Slice的代码
find := false
for _, p := range f.Params {
if typ, ok := p.Type().(*types.Slice); ok {
find = true
}
}
// ...
}
}
跨项目调用链路分析
- 问题定义
开源项目中实现调用链路分析的工具已经有很多,可以方便的对项目中的调用链路实现分析、可视化输出等功能,例如go callgraph、go-callvis。基本原理是分析每一个FuncDecl,忽略IfStmt、ForStmt等流程控制语句,只提取ExprStmt中的CallExpr。得到每一个Function里调用的下游Function,形成一个调用链路。
但在基于微服务搭建的实际业务项目中,会遇到两个问题:
- 调用链路分析需要一个入口函数,作为分析的起点,常见的通用工具一般使用main函数作为起点,但一般微服务请求通过RPC handler作为入口函数
- 微服务架构下,通常一组服务共同提供一个业务服务,一个功能需要调用多个下游服务和中间件才能完成,无法直接通过静态分析结果将多个微服务的调用链路串联起来。
- 实现思路
基于上面介绍过的ssautil的能力,将多个项目RPC调用连接起来其实是很简单的一件事情,每一个Function的Blocks已经形成了本方法的AST,基于RPC框架自动生成的client代码,做一个简单的手工映射,把RPC client和RPC server的Function做一个链接,就可以形成完整的AST。
基本的实现步骤如下:
- 加载多个项目源码,编译
- 按照基于RPC工具生成handler为函数分析入口,开始分析
- 根据包名对于不同的外部包进行打标,忽略Go SDK、log包等通用包,只分析中间件或下游的函数
- 根据SDP生成的client代码,将RPC client到RPC server的映射关系自动生成
ssautil解析的部分就不重复介绍,与「函数特征查找」处的代码类似,下面展示构建callgraph的代码:
func doBuildCallGraph(fullName string, funcMap map[string]*ssa.Function, level int) {
if f, ok := funcMap[fullName]; ok {
// 循环每一个代码块
for _, b := range f.Blocks {
// 循环每一个操作
for _, instr := range b.Instrs {
// 只看调用操作
if site, ok := instr.(ssa.CallInstruction); ok {
call := site.Common()
if callFunc, ok2 := call.Value.(*ssa.Function); ok2 {
fullName2 := GetFullName(callFunc)
// 组装CallGraphTree,这里省略
// 通过手工映射,将RPC client到RPC server做name转换
if newFullName, ok3 := rpcJump(fullName2); ok3 {
fullName2 = newFullName
}
// 递归下一级Function
doBuildCallGraph(fullName2, funcMap, level+1)
}
}
}
}
}
}
跨项目调用链路有什么作用呢?
- 例如目前系统对应的mongo出现故障,对于某个核心接口是否受到影响?或者影响多少接口?传统的方法基于整理的文档或开发人员的经验,利用跨项目调用链路可以快速给出基于目前代码给出结论,主要看收集的CallGraphTree中是否包含mongo相关的SDK。
- 例如在依赖包检查提示升级某个依赖包版本时候,影响多少功能?可以通过分析前后两个版本代码变动影响的函数,确定有多少我确实使用的函数有变更,已经向上影响我的多少业务方法,针对性的进行回归测试。
总结
以上就是Golang静态分析这一块目前总结的内容,包含基本概念、常用工具的应用、日常工作中的应用。当然可能应用的地方远远不止上面提到的部分。工欲善其事,必先利其器,通过本文的讲解大家已经看到静态分析工具强大的能力,期望可以帮助大家扩充一些视角,更好的将代码本身作为工具,解决日常开发中的问题。
加入我们
我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。
扫码发现职位&投递简历
转载自:https://juejin.cn/post/7114851198762483748