gg: 像写 Golang 一样生成代码
source link: https://xuanwo.io/2021/09-gg/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
gg: 像写 Golang 一样生成代码
开发者或多或少都会写 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.
// }
这个设计的出发点是好的,但是在实际应用中,具体要导入哪些包大多数时候都是静态决定的,很少会出现需要动态生成的情形。
那有没有一个学习成本低,好读又好写的 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
能够让你的模板写起来更轻松一些,欢迎在评论去交流想法或者提出意见~
Recommend
-
36
README.md Mix CLI 本项目是 MixPHP 一个开发 CLI 程序的分支,使用 MixPHP V2 框架核心,V2 开始我们封装了大量命令行开发基础设施,包括: 统一的...
-
40
来源 | 异步 | 文末赠书
-
7
V2EX › 程序员 有什么像 golang 一样低内存占用, 但是语法更偏向 Java /c++/js 的语言吗? ...
-
5
使用 Sonic Pi 像写代码一样编曲 | Linux 中国有了这个易于使用的开源程序,不需要掌握乐器,就可以把你变成一个音乐大师。来源:
-
3
像OpenResty一样使用Golang开发Web App Jun 26 2018 https://github.com/zhu327/glualor 最近在公司内网读过一篇Gopher Lua的文章, 感觉在Golang中使用Lua VM的模式跟OpenResty是一样一...
-
3
这是一个不缺广告的时代,但是好的广告却又像荒漠里的绿洲一样稀缺。作为广告的创造者,我们需要认识到优秀的广告不仅仅等于曝光量数字,好的广告是可以给一代人甚至几代人留下记忆的信息片段。“别看...
-
13
本文的目标读者# 对用 Golang 代码生成折线图、扇形图等图表有兴趣...
-
7
对于后台开发新的需求时,一般会先进行各种表的设计,写各个表的建表语句 然后根据建立的表,写对应的model代码、基础的增删改查代码(基础的增删改查服务可以划入DAO(Data Access Object)层)。 model代码都有一些固定的格式,可以通过解析SQL建表语...
-
6
前端开发利器Jsdoc:让我们像写Typescript一样写JavaScript 作者:前端梁哥 2023-02-03 16:03:17 有没有一种不用Typescript的解决方案呢?有,那就是今天的主角:Jsdoc;这可能是一个大家很少使用的开发利器;它是...
-
6
像PPT一样生成3D虚拟人视频!魔珐科技发布三款消费级产品,顺便还公开技术秘籍
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK