go generate and ast

你的名字 2022-01-19 23:23 322阅读 0赞

原文首发于我的博客: lailin.xyz/post/41140.…

楔(xiē)子

最近写API CURD比较多,为了结构清晰,返回值需要统一错误码,所以在一个统一的errcode包中定义错误码常量,以及其错误信息.

如下图所示,由于常量是导出字符 -> golint 检测需要编写注释 -> 注释信息其实就是错误信息,已经在下文的msg map[int]string中定义,如果在写就得写两遍

不写,就满屏波浪线,不能忍!

写了,就得Copy一份,还不利于维护,不能忍!

能不能只写一份注释,剩下的msg通过读取注释信息自动生成,将我们宝(hua)贵(diao)的生命,从这些重复繁杂无意义的劳动中解放出来。

为了实现这个伟大的目标, 需要以下两个关键的数据:

  1. 解析源代码获取常量与注释之间的关系 -> ?Go抽象语法树: AST[3]
  2. 从Go源码生成Go代码 -> ? go generate[5]

? go generate

golang1.4版本中引入了go generate命令,常用于文件生成,例如在Golang官方博客[5]中介绍的Stringer可以为枚举自动实现Stringer的方法,从业务代码中解放出来

? 命令文档

使用go help generate我们可以查看一下命令的帮助文档

  1. go help generate
  2. usage: go generate [-run regexp] [-n] [-v] [-x] [build flags] [file.go... | packages]
  3. ...
  4. 复制代码

解释很长,就不贴上来了,简要的概括一下:

  1. 参数说明

    • -run 正则表达式匹配命令行,仅执行匹配的命令(和go test -run类似)
    • -v 打印已被检索处理的文件。
    • -n 打印出将被执行的命令,此时将不真实执行命令
    • -x 打印已执行的命令
  2. 举个栗子

    1. # 对当前包下的Go文件进行处理, 并打印已被检索处理的文件。
    2. go generate -v
    3. # 打印当前目录下所有文件中将要被执行的命令(实际不会执行)
    4. go generate -n ./...
    5. 复制代码
  3. go generate会扫描.go源码文件中的注释//go:generate command args..., 并且执行其命令,注意:

    • 这些命令是为了更新或者创建Go源文件
    • command必须是可执行的指令,例如在PATH中或者使用绝对路径
    • arg如果带引号会被识别成一个参数, 例如: //go:generate command "x1 x2", 这条语句执行的命令只有一个参数
    • 注释中//go之间没有空格
  4. go generate必须手动执行,如果想等着go build, go test, go run 命令执行的时候自动执行,可以洗洗睡了
  5. 为了让别人或者是IDE识别代码是通过go generate生成的,请在生成的代码中添加注释(一般放在文件开头)

    1. # PS: 这是一个正则表达式
    2. ^// Code generated .* DO NOT EDIT\.$
    3. 复制代码

    举个栗子:

    1. // Code generated by mohuishou DO NOT EDIT
    2. package painkiller
    3. 复制代码
  6. go generate在执行的时候会自动注入以下环境变量:

    1. $GOARCH
    2. 系统架构: arm, amd64
    3. $GOOS
    4. 操作系统: linux, windows
    5. $GOFILE
    6. 当前执行的命令所处的文件名
    7. $GOLINE
    8. 当前执行的命令在文件中的行号
    9. $GOPACKAGE
    10. 执行的命令所处的文件的包名
    11. $DOLLAR
    12. $ 符号
    13. 复制代码

? Go官方博客中给出的栗子

源文件: painkiller.go

  1. //go:generate stringer -type=Pill
  2. package painkiller
  3. type Pill int
  4. const (
  5. Placebo Pill = iota
  6. Aspirin
  7. Ibuprofen
  8. Paracetamol
  9. Acetaminophen = Paracetamol
  10. )
  11. 复制代码

执行命令

  1. go generate
  2. 复制代码

生成文件: painkiller_stringer.go

  1. // generated by stringer -type Pill pill.go; DO NOT EDIT
  2. package painkiller
  3. import "fmt"
  4. const _Pill_name = "PlaceboAspirinIbuprofenParacetamol"
  5. var _Pill_index = [...]uint8{
  6. 0, 7, 14, 23, 34}
  7. func (i Pill) String() string {
  8. if i < 0 || i+1 >= Pill(len(_Pill_index)) {
  9. return fmt.Sprintf("Pill(%d)", i)
  10. }
  11. return _Pill_name[_Pill_index[i]:_Pill_index[i+1]]
  12. }
  13. 复制代码

从上面的?,我们可以发现,在.go源文件中,添加了一行注释go:generate stringer -type=Pill, 执行命令go generate就调用stringer命令在同目录下生成了一个新的_stringer.go的文件

回想一下上文提到的需求,是不是感觉很类似,从Go源文件中,生成了一些不想重复写的业务逻辑

? AST

回到前面的需求,我们需要从源代码中获取常量和注释之前的关系,这时就需要我们的?AST隆重登场了。

本文不对AST过多介绍,可以阅读参考资料中的AST标准库文档[3],Go的AST(抽象语法树)[4]

简要介绍一下AST包

基础的接口类型

  1. // Node AST树节点
  2. type Node interface {
  3. Pos() token.Pos
  4. End() token.Pos
  5. }
  6. // Expr 所有的表达式都需要实现Expr接口
  7. type Expr interface {
  8. Node
  9. exprNode()
  10. }
  11. // Stmt 所有的语句都需要实现Stmt接口
  12. type Stmt interface {
  13. Node
  14. stmtNode()
  15. }
  16. // Decl 所有的声明都需要实现Decl接口
  17. type Decl interface {
  18. Node
  19. declNode()
  20. }
  21. 复制代码

等会儿可能会用到的ValueSpec

  1. // ValueSpec 表示常量声明或者变量声明
  2. type ValueSpec struct {
  3. Doc *CommentGroup // associated documentation; or nil
  4. Names []*Ident // value names (len(Names) > 0)
  5. Type Expr // value type; or nil
  6. Values []Expr // initial values; or nil
  7. Comment *CommentGroup // line comments; or nil
  8. }
  9. 复制代码

CommentMap

在godoc[3]的Example中可以发现有一个CommentMap例子

  1. // CommentMap把AST节点和其关联的注释列表进行映射
  2. type CommentMap map[Node][]*CommentGroup
  3. 复制代码
  1. 通过parse读取源码创建一个AST

    1. fset := token.NewFileSet() // positions are relative to fset
    2. f, err := parser.ParseFile(fset, "src.go", src, parser.ParseComments)
    3. if err != nil {
    4. panic(err)
    5. }
    6. 复制代码
  2. 从AST中新建一个CommentMap

    1. cmap := ast.NewCommentMap(fset, f, f.Comments)
    2. 复制代码

需求实现

1. 获取常量和注释的关联关系

  1. file := os.Getenv("GOFILE")
  2. // 保存注释信息
  3. var comments = make(map[string]string)
  4. // 解析代码源文件,获取常量和注释之间的关系
  5. fset := token.NewFileSet()
  6. f, err := parser.ParseFile(fset, file, nil, parser.ParseComments)
  7. checkErr(err)
  8. // Create an ast.CommentMap from the ast.File's comments.
  9. // This helps keeping the association between comments
  10. // and AST nodes.
  11. cmap := ast.NewCommentMap(fset, f, f.Comments)
  12. for node := range cmap {
  13. // 仅支持一条声明语句,一个常量的情况
  14. if spec, ok := node.(*ast.ValueSpec); ok && len(spec.Names) == 1 {
  15. // 仅提取常量的注释
  16. ident := spec.Names[0]
  17. if ident.Obj.Kind == ast.Con {
  18. // 获取注释信息
  19. comments[ident.Name] = getComment(ident.Name, spec.Doc)
  20. }
  21. }
  22. }
  23. 复制代码

2. 获取注释信息

  1. // getComment 获取注释信息,来自AST标准库的summary方法
  2. func getComment(name string, group *ast.CommentGroup) string {
  3. var buf bytes.Buffer
  4. for _, comment := range group.List {
  5. // 注释信息会以 // 参数名,开始,我们实际使用时不需要,去掉
  6. text := strings.TrimSpace(strings.TrimPrefix(comment.Text, fmt.Sprintf("// %s", name)))
  7. buf.WriteString(text)
  8. }
  9. // replace any invisibles with blanks
  10. bytes := buf.Bytes()
  11. for i, b := range bytes {
  12. switch b {
  13. case '\t', '\n', '\r':
  14. bytes[i] = ' '
  15. }
  16. }
  17. return string(bytes)
  18. }
  19. 复制代码

3. 生成代码

  1. const suffix = "_msg_gen.go"
  2. // tpl 生成代码需要用到模板
  3. const tpl = ` // Code generated by github.com/mohuishou/gen-const-msg DO NOT EDIT // { {.pkg}} const code comment msg package { {.pkg}} // noErrorMsg if code is not found, GetMsg will return this const noErrorMsg = "unknown error" // messages get msg from const comment var messages = map[int]string{ { {range $key, $value := .comments}} { {$key}}: "{ {$value}}",{ {end}} } // GetMsg get error msg func GetMsg(code int) string { var ( msg string ok bool ) if msg, ok = messages[code]; !ok { msg = noErrorMsg } return msg } `
  4. // gen 生成代码
  5. func gen(comments map[string]string) ([]byte, error) {
  6. var buf = bytes.NewBufferString("")
  7. data := map[string]interface{}{
  8. "pkg": os.Getenv("GOPACKAGE"),
  9. "comments": comments,
  10. }
  11. t, err := template.New("").Parse(tpl)
  12. if err != nil {
  13. return nil, errors.Wrapf(err, "template init err")
  14. }
  15. err = t.Execute(buf, data)
  16. if err != nil {
  17. return nil, errors.Wrapf(err, "template data err")
  18. }
  19. return format.Source(buf.Bytes())
  20. }
  21. 复制代码

总结

从一个简单的效率需求引申到go generateast的使用,顺便阅读了一下ast的源码,花费的时间其实可能是这个工具节约的时间的几倍了,但是收获也是之前没有想到的。

  1. 使用了这么久的go命令,详细的阅读了go help command的说明之后,发现之前可能连了解都算不上
  2. 标准库的godoc是最好的使用说明,第二好的是它的源代码

参考资料

  1. go-const-msg 本文实现的源代码
  2. Golang Generate命令说明与使用
  3. AST标准库文档
  4. Go的AST(抽象语法树)
  5. GO 官方博客: Generating code

License

  • 本文作者: mohuishou 1@lailin.xyz
  • 本文链接: lailin.xyz/post/41140.…
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

发表评论

表情:
评论列表 (有 0 条评论,322人围观)

还没有评论,来说两句吧...

相关阅读