Go 模板:用代码生成代码
用代码生成代码。
不用 Go 写代码,就不知道 Java 程序员被“惯”得有多厉害。 Java 奉行“拿来主义”,什么东西都有现成的库。而 Go 就没有那么丰富的库了。
本文用生成器模式作为例子,来演示如何用代码生成代码。
生成器模式
熟悉 Java 开发的同学都知道,lombok 有一个著名的注解 @Builder ,只要加在类上面,就可以自动生成 Builder 模式的代码。如下所示:
@Builder
public class DetectionQuery {
private String uniqueKey;
private Long startTime;
private Long endTime;
}
然后就可以这样使用:
return DetectionQuery.builder()
.uniqueKey(uniqueKey)
.startTime(startTime)
.endTime(endTime)
.build();
是不是很爽?
不过 Go 可没有这样好用的注解。 Go 你得自己手写。假设我们要造一辆车,车有车身、引擎、座位、轮子。Go 的生成器模式的代码是这样子的:
package model
import "fmt"
type ChinaCar struct {
Body string
Engine string
Seats []string
Wheels []string
}
func newChinaCar(body string, engine string, seats []string, wheels []string) *ChinaCar {
return &ChinaCar{
Body: body,
Engine: engine,
Seats: seats,
Wheels: wheels,
}
}
type CarBuilder struct {
body string
engine string
seats []string
wheels []string
}
func ChinaCharBuilder() *CarBuilder {
return &CarBuilder{}
}
func (b *CarBuilder) Build() *ChinaCar {
return newChinaCar(b.body, b.engine, b.seats, b.wheels)
}
func (b *CarBuilder) Body(body string) *CarBuilder {
b.body = body
return b
}
func (b *CarBuilder) Engine(engine string) *CarBuilder {
b.engine = engine
return b
}
func (b *CarBuilder) Seats(seats []string) *CarBuilder {
b.seats = seats
return b
}
func (b *CarBuilder) Wheels(wheels []string) *CarBuilder {
b.wheels = wheels
return b
}
func main() {
car := ChinaCharBuilder().
Body("More advanced").
Engine("Progressed").
Seats([]string{"good", "nice"}).
Wheels([]string{"solid", "smooth"}).
Build()
fmt.Printf("%+v", car)
}
生成器模式怎么写?遵循三步即可:
(1) 先构造一个对应的生成器,这个生成器与目标对象有一样的属性;
(2) 对于每一个属性,有一个方法设置属性,然后返回生成器的引用本身;
(3) 最后调用生成器的 Build 方法,这个方法会调用目标对象的构造器来生成目标对象。
为啥不直接调用目标对象的构造器,要这么拐弯抹角呢?因为生成器模式一般用于复杂对象的构造。这个复杂对象的每一个组件都需要逐步构造,而不是一下子都有的。必须等所有组件都正确构造完成后,才能返回一个可用的目标对象。像 CarBuilder 这种才算是生成器模式的合理使用。而 DetectionQuery 的 builder 模式只是为了享受链式调用的畅感。
用 AI 生成生成器模式代码
啊!难道来一个 query 就得写这么一大堆吗? 懒精又发作了。
对了,可以用 AI ,看看 AI 怎么写。
向 AI 提问:
我有个对象 ChinaCar 如下所示, 请生成对应的生成器模式的代码
type ChinaCar struct {
Body string
Engine string
Seats []string
Wheels []string
}
刷刷刷,AI 就写出来了(这里使用的是阿里的通义千问):
package main
import (
"fmt"
)
type ChinaCar struct {
Body string
Engine string
Seats []string
Wheels []string
}
type ChinaCarBuilder struct {
body string
engine string
seats []string
wheels []string
}
func (c *ChinaCarBuilder) SetBody(body string) *ChinaCarBuilder {
c.body = body
return c
}
func (c *ChinaCarBuilder) SetEngine(engine string) *ChinaCarBuilder {
c.engine = engine
return c
}
func (c *ChinaCarBuilder) SetSeats(seats []string) *ChinaCarBuilder {
c.seats = seats
return c
}
func (c *ChinaCarBuilder) SetWheels(wheels []string) *ChinaCarBuilder {
c.wheels = wheels
return c
}
func (c *ChinaCarBuilder) Build() *ChinaCar {
return &ChinaCar{
Body: c.body,
Engine: c.engine,
Seats: c.seats,
Wheels: c.wheels,
}
}
func main() {
builder := new(ChinaCarBuilder)
builder.SetBody("Sedan").
SetEngine("Gasoline").
SetSeats([]string{"Driver", "Passenger"}).
SetWheels([]string{"Front left", "Front right", "Back left", "Back right"})
chinaCar := builder.Build()
fmt.Println(chinaCar)
}
嗯,看上去基本一样,只是设置属性的方法名不一样。有的会直接用属性名,有的会用 With+属性名,这里用的是 Set+属性名。
好耶!可是,如果还有其它模板型的代码怎么办呢?还是自己亲自来生成代码吧!
生成器代码生成
用代码生成代码?嗯,其实不算稀奇。代码也只是一种普通的可读文本而已。
模板是用于动态生成文本的常用技术。虽然看上去不算特别高明的方式,但也很管用。咱们使用 Go template 来实现它。
思路与实现
首先要分析,哪些是固定的文本,哪些是动态的文本。
红框框出来的都是动态文本。事实上,除了几个关键字和括号是静态的以外,其它基本都是动态生成的。这些文本通常是根据业务对象类型和业务对象的属性名及属性类型来推理出来的。我们把这些动态文本用伪标签语言先标出来。
先根据最终要生成的代码,把模板文件给定义出来(这里可以用自然语言先填充,再替换成技术实现):
func New{{ 目标对象类型 }}(逗号分隔的属性名 属性类型列表)) *{{ 目标对象类型 }} {
return &{{ 目标对象类型 }}{
每一行都是: 属性名 :属性名 (属性名小写)
}
}
type {{ 生成器类型 }} struct {
每一行都是: 属性名 属性类型(属性名小写)
}
func {{ 生成器方法名 }}() *{{ 生成器类型 }} {
return &{{ 生成器类型 }}{
}
}
func (b *{{ 生成器类型 }}) Build() *{{ 目标对象类型 }} {
return New{{ 目标对象类型 }}(
逗号分隔的 b.属性名 列表
}
对于每一个属性,遍历,做如下动作:
func (b *{{ 生成器类型 }}) {{ 属性名 }}({{ 属性名(小写) }} {{ 属性类型 }}) *{{ 生成器类型 }} {
b.{{ 属性名(小写) }} = {{ 属性名(小写) }}
return b
}
然后,抽象出用来填充动态文本的填充对象:
type BuilderInfo struct {
BuilderMethod string
BuilderClass string
BizClass string
Attrs []Attr
}
type Attr struct {
Name string
Type string
}
func newAttr(Name, Type string) Attr {
return Attr{Name: Name, Type: Type}
}
接下来,要根据具体的模板语言,来填充上面的自然语言,同时从目标对象中生成填充对象,来填充这些动态文本和自定义函数。
如下代码所示:
builder_tpl 就是生成器模式的代码模板文本。我们先用具体的值填充,把模板调通,然后再把这些具体的值用函数替换。
func LowercaseFirst(s string) string {
r, n := utf8.DecodeRuneInString(s)
return string(unicode.ToLower(r)) + s[n:]
}
func MapName(attrs []Attr) []string {
return util.Map[Attr, string](attrs, func(attr Attr) string { return "b." + LowercaseFirst(attr.Name) })
}
func MapNameAndType(attrs []Attr) []string {
return util.Map[Attr, string](attrs, func(attr Attr) string { return LowercaseFirst(attr.Name) + " " + LowercaseFirst(attr.Type) })
}
func autoGenBuilder(builder_tpl string) {
t1 := template.Must(template.New("test").Funcs(template.FuncMap{
"lowercaseFirst": LowercaseFirst, "join": strings.Join, "mapName": MapName, "mapNameAndType": MapNameAndType,
}).Parse(builder_tpl))
bi := BuilderInfo{
BuilderMethod: "QueryBuilder",
BuilderClass: "CarBuilder",
BizClass: "ChinaCar",
Attrs: []Attr{
newAttr("Body", "string"), newAttr("Engine", "string"),
newAttr("Seats", "[]string"), newAttr("Wheels", "[]string")},
}
t1.ExecuteTemplate(os.Stdout, "test", bi)
}
func main() {
builder_tpl := `
func New{{ .BizClass }}({{- join (mapNameAndType .Attrs) ", " }})) *{{ .BizClass }} {
return &{{ .BizClass }}{
{{ range .Attrs }}
{{ .Name }}: {{ lowercaseFirst .Name }},
{{ end }}
}
}
type {{ .BuilderClass }} struct {
{{ range .Attrs }}
{{ lowercaseFirst .Name }} {{ .Type }}
{{ end }}
}
func {{ .BuilderMethod }}() *{{ .BuilderClass }} {
return &{{ .BuilderClass }}{
}
}
func (b *{{ .BuilderClass }}) Build() *{{ .BizClass }} {
return New{{ .BizClass }}(
{{- join (mapName .Attrs) ", " }})
}
{{- range .Attrs }}
func (b *{{ $.BuilderClass }}) {{ .Name }}({{ lowercaseFirst .Name }} {{ .Type }}) *{{ $.BuilderClass }} {
b.{{ lowercaseFirst .Name }} = {{ lowercaseFirst .Name }}
return b
}
{{- end }}
`
car := model.ChinaCar{}
//autoGenBuilder(builder_tpl)
autoGenBuilder2(builder_tpl, car)
}
Go Template 语法
这里基本上概括了Go template 的常用语法:
- {{ . }} 表示顶层作用域对象,也就是你从如下方法传入的 bi 对象。
- {{ .BuilderClass }} 就是取 bi.BuilderClass , {{ .Attrs }} 就是取 bi.Attrs
t1.ExecuteTemplate(os.Stdout, "test", bi)
- 这有个 range 循环, 取 Attrs 里的每一个元素进行循环。注意到,range 里面的 {{ .Name }} 的 . 表示的是 Attrs 里的每一个元素对象。
{{ range .Attrs }}
{{ .Name }}: {{ lowercaseFirst .Name }},
{{ end }}
- 这里还传入了一个自定义函数 lowercaseFirst, 可以通过如下方法传入:
t1 := template.Must(template.New("test").Funcs(template.FuncMap{
"lowercaseFirst": LowercaseFirst, "join": strings.Join, "mapName": MapName, "mapNameAndType": MapNameAndType,
}).Parse(builder_tpl))
- 还有一个技巧,就是如何在 range 循环里引用顶层对象。这里要引用 BuilderClass 的值,必须用 $.BuilderClass,否则输出为空。
{{- range .Attrs }}
func (b *{{ $.BuilderClass }}) {{ .Name }}({{ lowercaseFirst .Name }} {{ .Type }}) *{{ $.BuilderClass }} {
b.{{ lowercaseFirst .Name }} = {{ lowercaseFirst .Name }}
return b
}
{{- end }}
嗯,多写写就熟了。通过实战来练习和掌握是一种高效学习之法。
注意一定要写 . 号。 我最开始老是忘记写。然后就卡住没响应了。
go template 报错不太友好。分三种情况:
- 直接卡住,你也不知道到底发生了什么。比如 {{ .BuilderClass }} 写成 {{ BuilderClass }}
- 直接报错,地址引用错误。 比如模板语法错误。
- 不输出内容。比如引用不到内容。
进一步完善
接下来,就要把写死的 BuilderMethod, BuilderClass, BizClass 和 Attrs 通过给定的业务类型来生成。这不难办,问 AI 就可以了:
func GetBizClass(t any) string {
qualifiedClass := fmt.Sprintf("%T", t)
return qualifiedClass[strings.Index(qualifiedClass, ".")+1:]
}
func GetAttributes(obj any) []Attr {
typ := reflect.TypeOf(obj)
attrs := make([]Attr, typ.NumField())
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
attr := Attr{
Name: field.Name,
Type: field.Type.String(),
}
attrs[i] = attr
}
return attrs
}
用 GetBizClass 和 GetAttributes 生成的值分别填充那几处硬写的值即可。
程序的主体,本文已经都给出来了,读者也可以将其拼接起来,做一次完型填空。
AI 辅助编程
这次, AI 可帮了忙。我也是刚上手 Go 编程语言不久,对 Go 的语法和库的掌握比较生疏,因此逢疑就问 AI。
这说明:当一个人能够熟练使用某种语言进行开发时,如果要切换到一种新语言上, AI 能够给予很大的帮助,快速扫清障碍,熟悉新语言和新库。
试想,如果没有 AI, 我还得去网上去查 template 的语法,出了错也不知道是怎么回事,这个挺耗费时间和情绪的。
小结
嗯,虽然是做了个 demo,但是要做成完善的成品给别人用,还是有很多改善之处的,比如健壮性、更多的属性类型。此外,其它的代码生成技术也可以去了解下。