从 Stringer 工具的自动化生成了解 Go AST

使用go ast和自动化释放Go代码的潜力

联系作者@:微信公众号,Medium,LinkedIn,Twitter

golang ast(from github.com/MariaLetta/free-gophers-pack)|300

最近了解到了 stringer 的使用,顺便接触到 golang ast 的相关内容,才发现自己也可以写一些 Go 相关的自动化工具。

1.1 stringer and go generate

开发中经常有定义错误码这样的需求,错误码唯一标识具体的错误信息。另外还需要设置每个错误的具体描述。例如在 HTTP 协议中,200 表示 “OK”,404 表示”Not Found”。

传统方式下,每次定义错误码的时候,同时需要添加描述信息,而且描述信息经常会忘,不利于维护。但是我们可以通过 go generate + stringer工具链优雅地解决这个问题。

stringer 的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Usage of stringer:
stringer [flags] -type T [directory]
stringer [flags] -type T files... # Must be a single package
For more information, see:
https://pkg.go.dev/golang.org/x/tools/cmd/stringer
Flags:
-linecomment
use line comment text as printed text when present
-output string
output file name; default srcdir/<type>_string.go
-tags string
comma-separated list of build tags to apply
-trimprefix prefix
trim the prefix from the generated constant names
-type string
comma-separated list of type names; must be set

举个例子:

1
2
3
4
5
6
7
8
9
10
package main

//go:generate stringer -type ErrCode -trimprefix ERR_CODE_ -output code_string.go
type ErrCode int

const (
ERR_CODE_OK ErrCode = 0 // ok
ERR_CODE_INVALID_PARAMS ErrCode = 1 // invalid params
ERR_CODE_TIMEOUT ErrCode = 2 // timeout
)

运行 go generate 生成如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Code generated by "stringer -type ErrCode -trimprefix ERR_CODE -output code_string.go"; DO NOT EDIT.

package main

import "strconv"

func _() {
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
var x [1]struct{}
_ = x[ERR_CODE_OK-0]
_ = x[ERR_CODE_INVALID_PARAMS-1]
_ = x[ERR_CODE_TIMEOUT-2]
}

const _ErrCode_name = "_OK_INVALID_PARAMS_TIMEOUT"

var _ErrCode_index = [...]uint8{0, 3, 18, 26}

func (i ErrCode) String() string {
if i < 0 || i >= ErrCode(len(_ErrCode_index)-1) {
return "ErrCode(" + strconv.FormatInt(int64(i), 10) + ")"
}
return _ErrCode_name[_ErrCode_index[i]:_ErrCode_index[i+1]]
}

go generate 使用可以先阅读文章 Generating code - The Go Programming Language,并运行 go help generate 查看基本用法。

1.2 stringer 是如何实现自动化生成的?

源代码实现:stringer.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import (
"bytes"
"flag"
"fmt"
"go/ast"
"go/constant"
"go/format"
"go/token"
"go/types"
"log"
"os"
"path/filepath"
"sort"
"strings"

"golang.org/x/tools/go/packages"
)

主要是”flag” 和 “go/ast”、”go/token”。”flag”主要处理一些命令行参数,可以参考我的一篇文章:Flag Package You should Know in Golang | by Wesley Wei | Programmer’s Career。重点要理解 “go/ast”,”go/ast” 是 Go 语言处理源代码的重要工具,它不仅简化了代码分析的过程,还为代码生成和重构提供了强大的支持。下面我们来一点点介绍。

1.3 抽象语法树(AST)

学过编译原理的人可能听说过编译器的前端和后端,编译器的前端一般承担着词法分析、语法分析、类型检查和中间代码生成几部分工作,而编译器后端主要负责目标代码的生成和优化,也就是将中间代码翻译成目标机器能够运行的二进制机器码。

不搞编译器的我们大多只需要懂前端部分就行,不涉及后端,同时 go 官方还提供了开箱即用的库 go/ast

1.3.1 概念及其作用

抽象语法树(Abstract Syntax Tree,简称 AST)是一种树状的数据结构,用于表示源代码的语法结构。AST 将源代码分解为具有层次结构的节点,每个节点代表代码中的一种语法元素,如表达式、语句、变量等。

AST 在编译器和分析工具中有着广泛的应用:

  1. 代码解析:编译器将源代码转换成 AST,为后续的代码分析和优化提供基础。
  2. 代码分析与检查:开发静态分析工具、代码检查工具,可以通过遍历 AST 来检查代码中的错误或改进点。
  3. 代码转化和优化:通过修改 AST,可以实现代码的自动优化和转换,如代码格式化工具。
  4. 代码生成:编写代码生成工具,生成新的源代码文件或中间代码。

1.3.2 如何在 Go 语言中生成和使用 AST

在 Go 语言中,我们主要用到如下三个标准库来生成和操作 AST:

  • **go/parser**:用于解析 Go 源代码并生成 AST。
  • **go/token**:用于管理源代码的位置和标记。
  • **go/ast**:用于表示和操作 AST。

我们可以使用 parser.ParseFile 函数来解析文件并生成 AST。以下是一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

import (
"fmt"
"go/ast"
"go/parser"
"go/token"
)

func main() {
src := `package main

import "fmt"

func main() {
fmt.Println("Hello, World!")
}`

// 创建一个新的文件集
fset := token.NewFileSet()

// 解析源代码并生成 AST
node, err := parser.ParseFile(fset, "", src, parser.ParseComments)
if err != nil {
fmt.Println("Error:", err)
return
}

// 打印生成的 AST
ast.Print(fset, node)
}

输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
 0  *ast.File {
1 . Package: 1:1
2 . Name: *ast.Ident {
3 . . NamePos: 1:9
4 . . Name: "main"
5 . }
6 . Decls: []ast.Decl (len = 2) {
7 . . 0: *ast.GenDecl {
8 . . . TokPos: 3:1
9 . . . Tok: import
10 . . . Lparen: -
11 . . . Specs: []ast.Spec (len = 1) {
12 . . . . 0: *ast.ImportSpec {
13 . . . . . Path: *ast.BasicLit {
14 . . . . . . ValuePos: 3:8
15 . . . . . . Kind: STRING
16 . . . . . . Value: "\"fmt\""
17 . . . . . }
18 . . . . . EndPos: -
19 . . . . }
20 . . . }
21 . . . Rparen: -
22 . . }
23 . . 1: *ast.FuncDecl {
24 . . . Name: *ast.Ident {
25 . . . . NamePos: 5:6
26 . . . . Name: "main"
27 . . . . Obj: *ast.Object {
28 . . . . . Kind: func
29 . . . . . Name: "main"
30 . . . . . Decl: *(obj @ 23)
31 . . . . }
32 . . . }
33 . . . Type: *ast.FuncType {
34 . . . . Func: 5:1
35 . . . . Params: *ast.FieldList {
36 . . . . . Opening: 5:10
37 . . . . . Closing: 5:11
38 . . . . }
39 . . . }
40 . . . Body: *ast.BlockStmt {
41 . . . . Lbrace: 5:13
42 . . . . List: []ast.Stmt (len = 1) {
43 . . . . . 0: *ast.ExprStmt {
44 . . . . . . X: *ast.CallExpr {
45 . . . . . . . Fun: *ast.SelectorExpr {
46 . . . . . . . . X: *ast.Ident {
47 . . . . . . . . . NamePos: 6:5
48 . . . . . . . . . Name: "fmt"
49 . . . . . . . . }
50 . . . . . . . . Sel: *ast.Ident {
51 . . . . . . . . . NamePos: 6:9
52 . . . . . . . . . Name: "Println"
53 . . . . . . . . }
54 . . . . . . . }
55 . . . . . . . Lparen: 6:16
56 . . . . . . . Args: []ast.Expr (len = 1) {
57 . . . . . . . . 0: *ast.BasicLit {
58 . . . . . . . . . ValuePos: 6:17
59 . . . . . . . . . Kind: STRING
60 . . . . . . . . . Value: "\"Hello, World!\""
61 . . . . . . . . }
62 . . . . . . . }
63 . . . . . . . Ellipsis: -
64 . . . . . . . Rparen: 6:32
65 . . . . . . }
66 . . . . . }
67 . . . . }
68 . . . . Rbrace: 7:1
69 . . . }
70 . . }
71 . }
72 . FileStart: 1:1
73 . FileEnd: 7:2
74 . Scope: *ast.Scope {
75 . . Objects: map[string]*ast.Object (len = 1) {
76 . . . "main": *(obj @ 27)
77 . . }
78 . }
79 . Imports: []*ast.ImportSpec (len = 1) {
80 . . 0: *(obj @ 12)
81 . }
82 . Unresolved: []*ast.Ident (len = 1) {
83 . . 0: *(obj @ 46)
84 . }
85 . GoVersion: ""
86 }

常见的节点类型

  1. **ast.File**:代表一个源文件。
  2. **ast.GenDecl**:代表常规声明(如导入、常量、变量、类型声明)。
  3. **ast.FuncDecl**:代表函数声明。
  4. **ast.BlockStmt**:代表代码块。
  5. **ast.Expr**:所有表达式的基础节点类型,如字面量、二元表达式等。
  6. **ast.Stmt**:所有语句的基础节点类型,如赋值语句、返回语句等。

例如这里输出的*ast.FuncDecl 代表函数的定义,名称是 main。内容有点多,可以参考 ast.go;l=972自行探索。

1.3.3 遍历访问 AST 节点

生成 AST 后,可以遍历和访问各个节点。例如,以下代码展示了如何遍历 AST 并提取想要的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package main

import (
"go/ast"
"go/parser"
"go/token"
"fmt"
)

func main() {
src := `package main

func add(a int, b int) int {
return a + b
}`

fset := token.NewFileSet()
fileNode, err := parser.ParseFile(fset, "", src, parser.ParseComments)
if err != nil {
fmt.Println("Error:", err)
return
}

ast.Inspect(fileNode, func(n ast.Node) bool {
switch n := n.(type) {
case *ast.File:
fmt.Printf("Package: %s\n", n.Name.Name)
case *ast.FuncDecl:
fmt.Printf("Function: %s\n", n.Name.Name)
case *ast.GenDecl:
fmt.Printf("General Declaration: %s\n", n.Tok)
}
return true
})
}

输出:

1
2
Package: main
Function: add

1.4 实际应用

1.4.1 代码检查

代码检查是静态分析的重要部分,可以帮助发现潜在的问题或优化代码。通过遍历 AST,我们可以编写自定义的规则来检查代码。

以下示例展示了如何使用 AST 检查未使用的变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package main

import (
"fmt"
"go/ast"
"go/parser"
"go/token"
)

func main() {
src := `package main

func main() {
var unused int
fmt.Println("Hello, World!")
}`

fset := token.NewFileSet()
node, err := parser.ParseFile(fset, "", src, parser.ParseComments)
if err != nil {
fmt.Println("Error:", err)
return
}

// 检查未使用的变量
ast.Inspect(node, func(n ast.Node) bool {
if decl, ok := n.(*ast.GenDecl); ok {
for _, spec := range decl.Specs {
if valueSpec, ok := spec.(*ast.ValueSpec); ok {
for _, name := range valueSpec.Names {
if !name.IsExported() && name.Name != "_" {
fmt.Printf("Unused variable: %s\n", name.Name)
}
}
}
}
}
return true
})
}

1.4.2 代码格式化

代码格式化是保证代码风格一致性的重要部分。通过 AST,我们可以重新组织代码,以符合预定的格式规则。

以下示例展示了如何使用 AST 为每个函数添加注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package main

import (
"fmt"
"go/ast"
"go/parser"
"go/printer"
"go/token"
"os"
)

func main() {
src := `package main

func add(a int, b int) int {
return a + b
}`

fset := token.NewFileSet()
node, err := parser.ParseFile(fset, "", src, parser.ParseComments)
if err != nil {
fmt.Println("Error:", err)
return
}

// 为每个函数添加注释
ast.Inspect(node, func(n ast.Node) bool {
if decl, ok := n.(*ast.FuncDecl); ok {
decl.Doc = &ast.CommentGroup{
List: []*ast.Comment{
{
Slash: decl.Pos() - 1,
Text: "// Function " + decl.Name.Name,
},
},
}
}
return true
})

printer.Fprint(os.Stdout, fset, node)
}

1.4.3 注释处理

注释处理可以用于提取文档、生成代码等任务。通过遍历 AST,我们可以收集和处理代码中的注释。

以下示例展示了如何提取并打印所有注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

import (
"fmt"
"go/ast"
"go/parser"
"go/token"
)

func main() {
src := `package main

// This is a package comment

func add(a int, b int) int {
// This is a function comment
return a + b
}`

fset := token.NewFileSet()
node, err := parser.ParseFile(fset, "", src, parser.ParseComments)
if err != nil {
fmt.Println("Error:", err)
return
}

// 提取并打印注释
for _, comment := range node.Comments {
fmt.Println(comment.Text())
}
}

1.4.4 Go中的具体例子

看了上述几个例子,你一定能想到 go 的一些 tool。

在 Go 中,很多工具是基于 go/ast(抽象语法树)库实现的,用于解析和分析 Go 源代码。以下是几个常用的基于 go/ast 实现的工具:

  1. gofmt
  • gofmt 是 Go 自带的代码格式化工具,用于将 Go 代码格式化成标准的风格。它利用 go/ast 分析代码结构,将其转换为统一的格式。
  • 例如,gofmt 自动进行缩进、对齐、空格等操作,确保 Go 代码的一致性和可读性。
  1. go vet
  • go vet 是一个静态代码分析工具,能检查代码中的潜在错误或不符合规范的用法。通过 go/ast 分析代码,go vet 能检测到代码中的一些常见错误,如不匹配的 Printf 参数、不符合规则的锁操作等。
  1. golint
  • golint 是 Go 语言的代码规范检查工具,帮助开发者遵循 Go 的编码规范。golint 使用 go/ast 来解析代码,查找不符合规范的编码模式,例如未命名的导出结构或未遵循惯例的命名方式。
  1. go doc
  • go doc 是 Go 的文档工具,利用 go/ast 分析代码中的文档注释,生成函数、方法、类型等的文档信息。
  • 这使得 go doc 能提供方便的文档查看方式,尤其适合查看本地代码库的文档。
  1. gorename
  • gorename 是一个重命名工具,允许开发者安全地重命名 Go 代码中的标识符。它使用 go/ast 分析代码的结构和依赖关系,确保所有引用都能安全重命名。
  1. stringer
  • stringer 是 Go 官方提供的代码生成工具,专门用于生成枚举类型的字符串表示。它通过 go/ast 解析代码中的常量定义,并生成相关的字符串方法,方便代码中的调试输出和日志记录。

这些工具基于 go/ast 提供了对 Go 代码的结构化分析能力,从而实现了格式化、静态检查、文档生成和代码生成等功能。

1.5 其他应用AST的工具

介绍两个项目 pingcap failpoint 和 uber-go 的 gopatch

failpoint 的使用是在代码里实现 Marker 函数,这些函数在正常编译时会被编译器优化去掉,所以正常运行时无额外开销:

1
2
3
4
var outerVar = "declare in outer scope"  
failpoint.Inject("failpoint-name-for-demo", func(val failpoint.Value) {
fmt.Println("unit-test", val, outerVar)
})

故障注入时通过 failctl 将 Marker 函数转换为故障注入函数,这里就用到了 go ast 做转换。

uber-go 的 gopatch 也非常强大,假如你的代码有很多 go func 开启的 goroutine, 你想批量加入 recover 逻辑,如果数据特别多人工加很麻烦,这时可以用 gopatcher:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var patchTemplateString = `@@  
@@
+ import "runtime/debug"
+ import "{{ Logger }}"
+ import "{{ Statsd }}"

go func(...) {
+ defer func(){
+ if err := recover(); err != nil {
+ statsd.Count1("{{ StatsdTag }}", "{{ FileName }}")
+ logging.Error("{{ LoggerTag }}", "{{ FileName }} recover from panic, err=%+v, stack=%v", err, string(debug.Stack()))
+ }
+ }()
...
}()
`

编写模板例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package main

import (
"os"
"text/template"
)

type PatchTemplate struct {
Statsd string
Logger string
StatsdTag string
FileName string
LoggerTag string
}

func main() {
patchTemplate := `@@
@@
+ import "runtime/debug"
+ import "{{ .Logger }}"
+ import "{{ .Statsd }}"

go func(...) {
+ defer func(){
+ if err := recover(); err != nil {
+ statsd.Count1("{{ .StatsdTag }}", "{{ .FileName }}")
+ logging.Error("{{ .LoggerTag }}", "{{ .FileName }} recover from panic, err=%+v, stack=%v", err, string(debug.Stack()))
+ }
+ }()
...
}()`

data := PatchTemplate{
Statsd: "github.com/yourusername/statsd",
Logger: "github.com/yourusername/logger",
StatsdTag: "error_count",
FileName: "example.go",
LoggerTag: "example_logger",
}

t := template.Must(template.New("patch").Parse(patchTemplate))
t.Execute(os.Stdout, data)
}

上面的例子可以实现自动在 go func(…) { 开头注入 recover 语句块,非常方便。

当然 gopatch还可以做很多事情,可以尽情探索。

1.6 总结

上述内容介绍了基本的go ast 使用用法和比较成熟的工具,但是在实际工作中,遇到的都是一些介于这两者之间的自动化场景,例如自定义错误码,标准的HTTP错误码数量太少,对于一个大型服务来说这些错误码显然是不够的,所以你完全可以写一个递增的错误码自动化生成工具,来生成自定义错误码。
当然自动化工具只有在应对大量冗余工作时非常有用,它可以帮助我们节省人力并且保持专注。节省下的精力可以花费在值得投入的地方。
总之,go ast 库给予开发者很大的空间去处理 go 代码,掌握 AST 的使用,可以更高效地进行代码分析和工具开发。


更多该系列文章,参考medium链接:

https://wesley-wei.medium.com/list/you-should-know-in-golang-e9491363cd9a

English post: https://programmerscareer.com/golang-ast/
作者:微信公众号,Medium,LinkedIn,Twitter
发表日期:原文在 2024-10-26 20:28 时创作于 https://programmerscareer.com/zh-cn/golang-ast/
版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证

启迪周刊-001:如何理解代码和对投资的认知 Golang Sync.Once 使用与原理,看这一篇足矣

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×