开发者或多或少都会写 Code Generator,对 Golang 开发者来说尤其如此。一方面是因为 Golang 类型系统的羸弱,另一方面是因为业务中确实存在着大量重复的逻辑。很多开发者都被迫成了 Golang Template 专家,比如我(。
本文旨在介绍一个通用的 Golang 代码生成器:Xuanwo/gg。作为对比,我们会首先分析目前社区主流的代码生成方式,然后再介绍 gg
解决问题的方式和思路,最后介绍 gg
在实际场景中的应用。
背景
任何技术方案都不能脱离具体的业务来展开讨论,在研究方案之前,先看看我们试图解决的问题。
go-storage 是 BeyondStorage 社区开发的供应商中立 Golang 存储库。作为一个存储抽象库,首先它要支持各种各样的存储接口,比如说 List
,Stat
,Read
,Write
,分段上传,追加上传等等。其次,它要支持这些存储接口会提供的各种参数,比如说 Write
操作中指定 StorageClass
和 KMS 密钥等。然后它要支持接口可能会返回的各式各样的 Metadata,比如说 Object 的 content-length
,content-md5
,last-modified
,storage-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/ast
,go/parser
和 go/token
,只需要搞明白各种 Stmt
和 Expr
的含义,使用起来并不难。但是直接操作 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}}
为了不给模板增加负担,我们这里的 Description
,DisplayName
都从结构体字段变成了结构体方法,提前做好了预处理。我们还实现了一大堆诸如 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
能够让你的模板写起来更轻松一些,欢迎在评论去交流想法或者提出意见~
参考资料
- Generating Go code in Kubebuilder style 是 Banzai Cloud 对比多种代码生成方式的文章。
- Golang AST语法树使用教程及示例
- golang 和 ast