开发者或多或少都会写 Code Generator,对 Golang 开发者来说尤其如此。一方面是因为 Golang 类型系统的羸弱,另一方面是因为业务中确实存在着大量重复的逻辑。很多开发者都被迫成了 Golang Template 专家,比如我(。

本文旨在介绍一个通用的 Golang 代码生成器:Xuanwo/gg。作为对比,我们会首先分析目前社区主流的代码生成方式,然后再介绍 gg 解决问题的方式和思路,最后介绍 gg 在实际场景中的应用。

背景

任何技术方案都不能脱离具体的业务来展开讨论,在研究方案之前,先看看我们试图解决的问题。

go-storageBeyondStorage 社区开发的供应商中立 Golang 存储库。作为一个存储抽象库,首先它要支持各种各样的存储接口,比如说 ListStatReadWrite,分段上传,追加上传等等。其次,它要支持这些存储接口会提供的各种参数,比如说 Write 操作中指定 StorageClass 和 KMS 密钥等。然后它要支持接口可能会返回的各式各样的 Metadata,比如说 Object 的 content-lengthcontent-md5last-modifiedstorage-class 等。go-storage 采用的方案是通过配置文件来描述,然后静态生成出代码,对外暴露强类型的 API,以取得开发时效率和运行时性能的平衡。

以生成对应的接口为例,我们会提供这样的描述文件来生成出对应的接口,对应的 stub 结构体和 Service 中对应的 Public 函数。

[storager.op.read]
description = "will read the file's data."
params = ["path", "w"]
pairs = ["size", "offset", "io_callback"]
results = ["n"]

实现

gg 出现之前,社区大概有这样几种方案:

  • 操作 ast
  • 编辑字符串
  • golang template(或者其他模板)
  • dave/jennifer

操作 ast

Golang 提供了操作 ast 需要的全部工具:go/astgo/parsergo/token,只需要搞明白各种 StmtExpr 的含义,使用起来并不难。但是直接操作 ast 非常晦涩,缺乏直观。以生成如下代码为例:

package main

import "fmt"

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

通过操作 ast 的方式来生成代码,我们需要写成这样:

func TestViaGolangAST(t *testing.T) {
	fset := token.NewFileSet()
	f := &ast.File{
		Name:  ast.NewIdent("main"),
		Scope: ast.NewScope(nil),
	}

	f.Decls = append(f.Decls, &ast.GenDecl{
		Tok: token.IMPORT,
		Specs: []ast.Spec{
			&ast.ImportSpec{
				Path: &ast.BasicLit{
					Kind:  token.STRING,
					Value: `"fmt"`,
				},
			},
		},
	})

	f.Decls = append(f.Decls, &ast.FuncDecl{
		Name: ast.NewIdent("main"),
		Type: &ast.FuncType{},
		Body: &ast.BlockStmt{
			List: []ast.Stmt{
				&ast.ExprStmt{X: &ast.CallExpr{
					Fun: &ast.SelectorExpr{
						X:   ast.NewIdent("fmt"),
						Sel: ast.NewIdent("Println"),
					},
					Args: []ast.Expr{
						&ast.BasicLit{
							Kind:  token.STRING,
							Value: `"Hello, World!"`,
						},
					},
				}},
			},
		},
	})

	err := format.Node(os.Stdout, fset, f)
	if err != nil {
		log.Fatalf("ast is incorrect")
	}
}

这还只是生成一个 Hello, World!,如果用来写真实的业务逻辑,这基本上是不可接受的,很快代码就会进入完全无法维护的状态。

这里的示例代码实际上是对着 ast.Print 输出的结果反向写出来的,否则我连 Hello, World 都写不出来(

总的来说,操作 ast 适合于在原有代码的基础上做一些静态分析和小幅度修改,比如说为函数增加 context,为文件增加一些新的 import 等等。

编辑字符串

代码本质上就是字符串的组合,所以直接操作字符串也能用来生成代码。还是以生成 Hello, world! 为例,可以这样写:

func TestViaString(t *testing.T) {
	b := &bytes.Buffer{}

	fmt.Fprintf(b, "package %s\n\n", "main")
	fmt.Fprintf(b, "import %s\n\n", `"fmt"`)
	fmt.Fprintf(b, "func main() {\n")
	fmt.Fprintf(b, "\tfmt.Println(%s)\n", `"Hello, World!"`)
	fmt.Fprint(b, "}\n")

	fmt.Println(b.String())
}

编辑字符串的缺点在于缺少 Golang 的语义支持,开发者经常需要花费时间在处理回车和空格这样细节的问题上。在代码嵌套层级比较深的时候,这个弊端会暴露的更加明显。当然我们可以选择包装一些 helper 函数,比如说自动追加末尾的 \n,比如说不考虑缩进的问题,交给 go fmt 来处理。但是这都只能缓解,并没有从根本上解决问题。

Golang Template

社区中最为常用的代码生成方式就是模板了。通常的我们会提前准备好 data 结构体,然后在生成模板的时候传进去。就像这样:

func TestViaGolangTemplate(t *testing.T) {
	b := &bytes.Buffer{}

	data := struct {
		Package string
		Import  []string
		Content string
	}{
		Package: "main",
		Import:  []string{"fmt"},
		Content: "Hello, World!",
	}

	tmpl := `package {{ .Package }}

import (
	{{ range $_, $v := .Import -}}
		"{{ $v }}"
	{{ end -}}
)

func main() {
	fmt.Println("{{ .Content }}")
}`

	err := template.Must(template.New("test").Parse(tmpl)).Execute(b, data)
	if err != nil {
		t.Error(err)
	}

	fmt.Println(b.String())
}

模板的缺点在于它膨胀的速度很快,当数据不再是简单的 string 而是复杂的 map/slice 之后,我们要么就需要在模板里面加入大量的逻辑,要么就需要提前做大量的预处理。

go-storage 中的用于生成接口的模板为例:

{{- range $_, $i := .Interfaces }}
{{ $i.Description }}
type {{ $i.DisplayName }} interface {
    {{- if or (eq $i.Name "servicer") (eq $i.Name "storager")}}
    String() string
    {{- end }}

    {{ range $_, $op := $i.Ops }}
    // {{ $op.Name | toPascal }} {{ $op.Description }}
    {{ $op.Name | toPascal }}({{ $op.FormatParams }}) ({{ $op.FormatResultsWithPackageName "storage" }})
    {{- if not $op.Local }}
    // {{ $op.Name | toPascal }}WithContext {{ $op.Description }}
    {{ $op.Name | toPascal }}WithContext(ctx context.Context,{{ $op.FormatParams }}) ({{ $op.FormatResultsWithPackageName "storage" }})
    {{ end }}
    {{ end }}

    mustEmbedUnimplemented{{ $i.DisplayName }}()
}
{{- end}}

为了不给模板增加负担,我们这里的 DescriptionDisplayName 都从结构体字段变成了结构体方法,提前做好了预处理。我们还实现了一大堆诸如 FormatResultsWithPackageName, FormatParams 的函数,就只是为了能正确的生成出函数的参数列表。

在生产实践当中,哪怕是 go-storage 的 maintianer 在维护模板的时候也需要研读很久,通过分析生成后的代码来反推模板的实现。更糟糕的是,随着功能的迭代,模板中的部分逻辑可能已经失效了,但是从外部完全看不出来,导致模板中沉积了大量没有意义的逻辑判断。相比之下,模板本身的可调试性差(少写一个 } 查半天),难以复用逻辑,无法注释,不方便测试已经算是比较轻微的问题了。

dave/jennifer

社区也看到了类似的问题,所以也在产出不同的解决方案,jennifer 就是其中比较优秀的一个。同样是以生成 Hello, world 为例:

import (
    "fmt"

    . "github.com/dave/jennifer/jen"
)

func main() {
	f := NewFile("main")
	f.Func().Id("main").Params().Block(
		Qual("fmt", "Println").Call(Lit("Hello, world")),
	)
	fmt.Printf("%#v", f)
}

jennifer 的大体思路是将 Golang 语言中的每一个 Token 转化为一个具体的函数调用,比如说 Params() 表示 ()Block() 表示 {} 等。

缺点是学习曲线比较陡峭,我曾经尝试过在 go-storage 中引入 jennifer 来代替模板做生成,但是社区普遍的反馈都是看不太懂。此外,jennifer 接管了 import 的生成逻辑,要求用户使用 Qual 来调用外部的函数,在生成的时候分析所有的 import path 并生成,用户只能够提供一些 Hint。

f := NewFilePath("a.b/c")
f.Func().Id("init").Params().Block(
	Qual("a.b/c", "Foo").Call().Comment("Local package - name is omitted."),
	Qual("d.e/f", "Bar").Call().Comment("Import is automatically added."),
	Qual("g.h/f", "Baz").Call().Comment("Colliding package name is renamed."),
)
fmt.Printf("%#v", f)
// Output:
// package c
//
// import (
// 	f "d.e/f"
// 	f1 "g.h/f"
// )
//
// func init() {
// 	Foo()    // Local package - name is omitted.
// 	f.Bar()  // Import is automatically added.
// 	f1.Baz() // Colliding package name is renamed.
// }

这个设计的出发点是好的,但是在实际应用中,具体要导入哪些包大多数时候都是静态决定的,很少会出现需要动态生成的情形。

gg

那有没有一个学习成本低,好读又好写的 Golang 代码生成器呢?来看看 Xuanwo/gg 吧!

gg 沿袭了 jennifer 的设计思路又向前走了一步,将 Golang 每一个语法块转化为对应的语义化函数调用。

生成一个 Hello, World! 看起来是这样的:

package main

import (
	"fmt"

	. "github.com/Xuanwo/gg"
)

func main() {
	f := NewGroup()
	f.AddPackage("main")
	f.NewImport().
		AddPath("fmt")
	f.NewFunction("main").AddBody(
		String(`fmt.Println("%s")`, "Hello, World!"),
	)
	fmt.Println(f.String())
}

创建一个结构体就是 NewStruct 然后再 AddField

f := Group()
f.NewStruct("World").
    AddField("x", "int64").
    AddField("y", "string")
// type World struct {
//    x int64
//    y string
//}

创建一个方法就是 NewFunction 之后再修改 Receiver,Parameter 和 Result 等:

f := Group()
f.NewFunction("hello").
    WithReceiver("v", "*World").
    AddParameter("content", "string").
    AddParameter("times", "int").
    AddResult("v", "string").
    AddBody(gg.String(`return fmt.Sprintf("say %s in %d times", content, times)`))
// func (v *World) hello(content string, times int) (v string) {
//  return fmt.Sprintf("say %s in %d times", content, times)
//}

使用 gg 的时候不再需要考虑换行和文法之类的问题,可以结构化的增加对应的语法元素。

应用

我在 go-storage 中使用 gg 全面替代了模板,这里以生成 interface 为例跟前面出现的 template 对比一下:

f.AddLineComment("%s %s", in.DisplayName(), in.Description)

inter := f.NewInterface(in.DisplayName())
if in.Name == "servicer" || in.Name == "storager" {
    inter.NewFunction("String").AddResult("", "string")
}

for _, op := range in.SortedOps() {
    pname := templateutils.ToPascal(op.Name)

    inter.AddLineComment("%s %s", pname, op.Description)
    gop := inter.NewFunction(pname)

    for _, p := range op.ParsedParams() {
        gop.AddParameter(p.Name, p.Type)
    }
    for _, r := range op.ParsedResults() {
        gop.AddResult(r.Name, r.Type)
    }

    // We need to generate XxxWithContext functions if not local.
    if !op.Local {
        inter.AddLineComment("%sWithContext %s", pname, op.Description)
        gop := inter.NewFunction(pname + "WithContext")

        // Insert context param.
        gop.AddParameter("ctx", "context.Context")
        for _, p := range op.ParsedParams() {
            gop.AddParameter(p.Name, p.Type)
        }
        for _, r := range op.ParsedResults() {
            gop.AddResult(r.Name, r.Type)
        }
    }
    // Insert an empty for different functions.
    inter.AddLine()
}

在 gg 的帮助下,我们成功去掉了 FormatResultsWithPackageName, FormatParams 这样的辅助函数,也去掉了绝大部分不必要的预处理,PR refactor: Cleanup definition generate logic 中删除了 600 余行数据预处理逻辑,这使得 go-storage 的 definitions 维护变得轻松了不少。

总结

以上就是本文的全部内容,希望 gg 能够让你的模板写起来更轻松一些,欢迎在评论去交流想法或者提出意见~

参考资料