3

gg: 像写 Golang 一样生成代码

 3 years ago
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.
neoserver,ios ssh client

gg: 像写 Golang 一样生成代码

开发者或多或少都会写 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.
// }

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

那有没有一个学习成本低,好读又好写的 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

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK