Aifei-Go damigen:从接口定义生成 dami 消费者
一个注解,一份 typed 消费者代码。
tools/damigen扫描//dami:provider注解的 Go 接口,用go/parser解析方法签名,通过嵌入式 Enjoy 模板生成<Iface>Client实现类,把每个方法委托给dami.Call1[R]/dami.Call0——以代码生成替代 Java DamiBus 的动态代理。
1. 背景与定位
dami 是 Aifei-Go 的进程内事件总线,其中 lpc(Local Procedure Call)模式把结构体方法暴露成"像 RPC 的本地调用"。Provider 侧用反射注册:
lpcBus.RegisterProvider("order", &OrderService{}) // 把 OrderService 的方法挂到 order.* topicConsumer 侧的调用是类型不安全的字符串:
id, err := dami.Call1[OrderID](bus, ctx, "order.Create", req)一旦 Provider 方法改名、参数调整、返回类型变化,这行代码编译期毫无感知,直到运行时才报错。在 Java 版 DamiBus 里,Consumer 侧通过 CGLIB 动态代理解决:你拿到一个实现了 OrderService 接口的代理对象,调它的方法就自动转成 call(topic.method, args)。Go 没有运行时字节码增强,但代码生成可以达到同样的效果——而且更透明、更可调试、IDE 友好。
tools/damigen 做的就是这件事:扫描接口定义,生成一份 XxxClient 实现,让 Consumer 侧写成普通的接口调用。
| 维度 | 说明 |
|---|---|
| 是什么 | dami lpc 的 Consumer 侧代码生成器 |
| 解决什么 | 替代 Java 动态代理,提供编译期类型安全的 Consumer |
| Java 对应 | DamiBus 的 createConsumer(Class<T>) |
| 依赖 | Go 标准库(go/parser、go/ast、go/format)+ enjoy(模板引擎) |
| 不依赖 | dami 本身(生成代码引用 dami,但生成器只解析接口文本) |
设计选择一览:
| Java DamiBus | Aifei-Go damigen |
|---|---|
| CGLIB 动态代理 | go/parser + 代码生成 |
| 运行时反射方法分发 | 编译期生成的方法委托 |
createConsumer(Iface.class) | //go:generate go run ...damigen |
| Consumer 实例 = 代理 | Consumer 实例 = 生成的 XxxClient 结构体 |
2. 总体架构
核心抽象:
| 类型 | 文件 | 职责 |
|---|---|---|
Generate(srcDir, outDir string) error | gen.go | 唯一入口;编排全流程 |
ifaceInfo | parser.go | 一个接口的渲染模型(Name/Topic/Methods) |
methodInfo | parser.go | 一个方法的渲染模型(Name/Params/Results/CallExpr) |
clientTemplate | template.go | 单一 Enjoy 模板常量,渲染全部 Client |
engine | gen.go | 包级共享的 Enjoy 引擎实例 |
cmd/damigen/main.go | cmd/damigen/ | CLI 包装;-src / -out 两个 flag |
3. 关键 API
3.1 入口:Generate
func Generate(srcDir, outDir string) error| 参数 | 含义 | 典型值 |
|---|---|---|
srcDir | 含 //dami:provider 接口的 Go 源码目录 | .、./mysvc |
outDir | 生成文件输出目录;不存在会创建 | 通常与 srcDir 相同 |
| 返回 | 错误;失败原因有三种(见 §6) | — |
无任何配置 struct、无 functional option——调用面刻意最小化。需要定制 import 路径的场景(如 vendor 了 dami)直接改包级变量:
// DamiImportPath is the import path of the dami package referenced by generated
// clients. Exported so callers (e.g. a vendored dami) can override it.
var DamiImportPath = "github.com/crazy-airhead/aifei-go/dami"3.2 Provider 接口的写法
接口需要满足两个条件:
- 是一个
type Xxx interface { ... }(导出与否不限,但一般导出) - 紧贴接口声明的 doc 注释里有
//dami:provider <topic>
package demo
import "context"
//dami:provider user
type UserService interface {
GetUserID(ctx context.Context, name string) (int64, error)
Fire(ctx context.Context, name string) error
Count(ctx context.Context) (int, error)
}<topic>是 lpc 的 topic mapping,对应 dami 里lpcBus.RegisterProvider("user", ...)的第一个参数- 生成的方法 topic 是
<topicMapping>.<MethodName>(如user.GetUserID),与 provider 侧反射注册的规则一致
3.3 生成的 dami_gen.go
对上面那个 UserService,生成器产出(节选):
// Code generated by dami-gen; DO NOT EDIT.
package demo
import (
"context"
"github.com/crazy-airhead/aifei-go/dami"
)
// UserServiceClient is the code-generated consumer for the UserService provider
// (topic mapping "user"). It implements UserService.
type UserServiceClient struct {
Bus *dami.Bus
}
// NewUserServiceClient builds a UserServiceClient over bus.
func NewUserServiceClient(bus *dami.Bus) *UserServiceClient {
return &UserServiceClient{Bus: bus}
}
func (c *UserServiceClient) GetUserID(ctx context.Context, name string) (int64, error) {
return dami.Call1[int64](c.Bus, ctx, "user.GetUserID", name)
}
func (c *UserServiceClient) Fire(ctx context.Context, name string) error {
return dami.Call0(c.Bus, ctx, "user.Fire", name)
}
func (c *UserServiceClient) Count(ctx context.Context) (int, error) {
return dami.Call1[int](c.Bus, ctx, "user.Count")
}使用方式与普通接口调用无异:
client := NewUserServiceClient(bus)
id, err := client.GetUserID(ctx, "alice")
err = client.Fire(ctx, "bob")
n, err := client.Count(ctx)Provider 侧一旦改名或调整签名,Consumer 侧立即编译失败——这是代码生成相对于运行时反射的根本优势。
4. 方法签名约定
damigen 不是任意接口都能处理,它强约束方法的返回类型,以对齐 lpc 的请求-应答语义。
4.1 支持的返回形状
| 返回 | 生成调用 | 语义 |
|---|---|---|
(R, error) | return dami.Call1[R](c.Bus, ctx, topic, args...) | 有返回值的 RPC |
error | return dami.Call0(c.Bus, ctx, topic, args...) | 无返回值的 RPC |
其他形状会被 buildCall 拒绝(见 §6)。
4.2 参数处理:context.Context 特殊对待
buildMethod 遍历每个参数:
- 首个
context.Context参数 → 作为 lpc 调用的ctx,传给dami.Call1/Call0,不编码进 args - 其他参数 → 按顺序成为 args(
dami.Call1[R](bus, ctx, topic, arg1, arg2, ...)) - 无 ctx 参数 → 用
context.Background()作默认 ctx
因此,Count(ctx context.Context) (int, error) 生成的调用是 dami.Call1[int](c.Bus, ctx, "user.Count")——ctx 后面没有 arg。
设计意图:与 Go 惯例对齐(context 总是第一个参数),同时避免把 context 序列化进事件 payload——lpc 是进程内调用,ctx 直接透传即可,不必走 dami 的 Coder。
4.3 参数签名的还原
参数在接口源码里可能写成多种形式:
Foo(a, b int, c string) // 多名共享类型
Bar(ctx context.Context, x int) // 正常 ctx + 参数
Baz(arg0 int32) // 匿名参数(ast 里无名字)buildMethod 把每种情况都还原成 "name typ" 字符串,对匿名参数自动命名为 arg0、arg1...,最终 methodInfo.Params 串起来。生成的方法签名与原接口逐字符一致(go/format 还会做一次规整),保证 *UserServiceClient 真的能赋值给 UserService。
4.4 返回类型的提取
exprString 用 go/printer 把 ast.Expr 还原回源码字符串。这一步保证复合类型(如 *Foo、[]byte、map[string]int)能原样出现在生成的代码里,不丢信息。
5. 模板:单文件、单模板、统一风格
与 tools/generator 五个 .af 模板不同,damigen 的模板更简单——只有一个 clientTemplate,是 template.go 里的一个 const 字符串(不是独立文件):
const clientTemplate = `// Code generated by dami-gen; DO NOT EDIT.
package #(Package)
import (
"context"
"#(ImportPath)"
)
#for (iface : Ifaces)
// #(iface.Name)Client is the code-generated consumer for the #(iface.Name) provider
// (topic mapping "#(iface.Topic)"). It implements #(iface.Name).
type #(iface.Name)Client struct {
Bus *dami.Bus
}
// New#(iface.Name)Client builds a #(iface.Name)Client over bus.
func New#(iface.Name)Client(bus *dami.Bus) *#(iface.Name)Client {
return &#(iface.Name)Client{Bus: bus}
}
#for (m : iface.Methods)
func (c *#(iface.Name)Client) #(m.Name)#(m.Params) #(m.Results) {
#(m.CallExpr)
}
#end
#end
`要点:
- 一份模板渲染所有接口:一个包里有 N 个
//dami:provider接口,就一次性输出 N 个XxxClient,全部塞进同一个dami_gen.go。 - Enjoy 语法:与 dami SQL 模板、
tools/generator的.af同源——#(expr)输出、#for (x : list) ... #end遍历、.访问 struct 字段。 - 渲染器是包级单例:
var engine = enjoy.NewEngine("damigen"),所有调用共用,避免每次构造。
渲染后的格式化
formatted, err := format.Source([]byte(out))
if err != nil {
// Return unformatted source so the caller can read the error context.
return []byte(out), fmt.Errorf("format generated source: %w", err)
}
return formatted, nilgo/format 把 Enjoy 输出按 gofmt 规则规整(对齐字段、统一缩进)。格式失败时返回未格式化的源码,让调用方能在错误信息里看到上下文,便于排查模板问题——这是个相当务实的设计。
6. 错误处理与失败模式
damigen 采用严格校验、fail-closed 策略。三种失败:
| 失败场景 | 错误信息 | 何时触发 |
|---|---|---|
| 无 Go 包 | no Go package found in %s | parser.ParseDir 返回空 |
| 无 provider 接口 | no //dami:provider interfaces found in %s | collectInterfaces 返回空切片 |
| 方法签名不支持 | method %s: ... | 见下方细分 |
方法签名不支持的细分情况(buildCall):
| 返回形状 | 错误 |
|---|---|
单个非 error 返回(如 Foo() int) | method %s: single non-error result unsupported (use (R, error) or error) |
双返回但第二不是 error(如 Foo() (int, string)) | method %s: second result must be error |
| 0 个或 3+ 个返回 | method %s: must return (R, error) or error |
约定明确:要么 (R, error),要么 error,没有第三种。这是 lpc 语义的天然约束(一次调用最多返回一个有效载荷 + 错误),damigen 把这个约束前推到生成期,避免运行时才发现不支持。
接口识别细节
collectInterfaces 的几个边界处理:
- 接口声明位置:
typespec 既可能在GenDecl上(type ( Foo interface{} )),也可能在TypeSpec上(type Foo interface{})——先查ts.Doc,没有再 fallback 到gd.Doc - 嵌套接口(embedded interface):
len(f.Names) == 0的情况被跳过,不误判 - 非 interface 的 type 声明:
*ast.InterfaceType类型断言失败即跳过 - 无注解的接口:
parseProviderComment返回空字符串即跳过——所以同一个包里可以有"普通接口"和"provider 接口"共存,前者不受影响
注释格式宽容度
parseProviderComment 同时支持 // 和 /* */ 两种注释风格,并按空白拆分后查找 dami:provider 关键字及其后第一个字段:
//dami:provider user // OK
// dami:provider user // OK(多余空格容忍)
/*dami:provider user*/ // OK
//dami:provider user extra // 取 "user","extra" 忽略7. CLI 与 go:generate 集成
cmd/damigen/main.go 是对 Generate 的极简 CLI 包装:
func main() {
src := flag.String("src", ".", "source directory containing //dami:provider interfaces")
out := flag.String("out", ".", "output directory for the generated dami_gen.go")
flag.Parse()
if err := damigen.Generate(*src, *out); err != nil {
fmt.Fprintf(os.Stderr, "dami-gen: %v\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "dami-gen: generated %s\n", *out)
}两个 flag:
| Flag | 默认 | 含义 |
|---|---|---|
-src | . | 源目录(含 provider 接口) |
-out | . | 输出目录(写 dami_gen.go) |
典型用法是写在业务包里的 go:generate 指令:
//go:generate go run github.com/crazy-airhead/aifei-go/tools/damigen/cmd/damigen -src . -out .
package mysvc
//dami:provider mysvc
type Service interface {
DoSomething(ctx context.Context, req Req) (Resp, error)
}随后 go generate ./... 就会在同目录产出 dami_gen.go。dami_gen.go 这个文件名是常量 generatedFileName,不可配置——刻意固定,让 .gitignore / lint 规则能一处覆盖所有生成文件。
8. 完整使用范例
Provider 侧(业务包):
// mysvc/service.go
package mysvc
import "context"
//go:generate go run github.com/crazy-airhead/aifei-go/tools/damigen/cmd/damigen -src . -out .
//dami:provider mysvc
type Service interface {
Create(ctx context.Context, name string, val int) (int64, error)
Get(ctx context.Context, id int64) (Item, error)
Delete(ctx context.Context, id int64) error
}
type Item struct { ID int64; Name string; Val int }Provider 实现 + 注册:
// mysvc/impl.go
package mysvc
import (
"context"
"github.com/crazy-airhead/aifei-go/dami"
)
type Impl struct{}
func (p *Impl) Create(ctx context.Context, name string, val int) (int64, error) { /* ... */ }
func (p *Impl) Get(ctx context.Context, id int64) (Item, error) { /* ... */ }
func (p *Impl) Delete(ctx context.Context, id int64) error { /* ... */ }
// 注册到 dami lpc
func Register(bus *dami.Bus) {
lpc := dami.NewLpc(bus)
lpc.RegisterProvider("mysvc", &Impl{})
}Consumer 侧(任何地方):
package main
import (
"context"
"github.com/crazy-airhead/aifei-go/dami"
"example.com/app/mysvc" // import 生成的 client
)
func main() {
bus := dami.New()
mysvc.Register(bus)
client := mysvc.NewServiceClient(bus) // 类型安全的 Consumer
id, err := client.Create(context.Background(), "alice", 42)
if err != nil { panic(err) }
item, err := client.Get(context.Background(), id)
_ = item
}整个链路:Provider 用 RegisterProvider 反射注册 → Consumer 用生成的 NewServiceClient 构造 → 接口调用经 dami.Call1/Call0 走 lpc → Provider 反射分发到具体方法。Provider 改方法签名后,mysvc.Service 接口、生成代码、Consumer 调用三者必须同步变化,否则编译失败——这就是 damigen 相对于 Java 动态代理的等价保证。
9. 模块结构
tools/damigen/
├── gen.go # Generate 入口;编排 parse → collect → render → format → write
├── parser.go # 接口解析:parseProviderComment / parseMethods / buildMethod / buildCall
├── template.go # clientTemplate(单一 Enjoy 模板常量)
├── go.mod # 仅依赖 enjoy
└── cmd/
└── damigen/
└── main.go # CLI:-src / -out 两个 flag源码约 333 行(不含生成代码与测试);测试在 _test/damigen_test(黑盒,覆盖正常路径、无 provider、签名不支持三种情况)。整个模块刻意保持小巧——damigen 的复杂度本就该被控制在接口解析这一狭窄范围内。
10. 总结
Aifei-Go damigen 的设计原则:
- 替代动态代理:Go 没有运行时字节码增强,用代码生成达到 Java 动态代理的等价效果——且更透明、更可调试。
- 严格签名约定:只支持
(R, error)与error两种返回,把 lpc 的语义约束前推到生成期,避免运行时意外。 - 注释驱动:
//dami:provider <topic>一行注解即可,无需额外配置文件、无需 struct tag、无需 IDL。 - 一个文件、一个模板:所有
XxxClient集中在dami_gen.go,便于 review、ignore、清理;单模板与tools/generator共用 Enjoy 引擎,呈现一致的代码生成风格。 - 标准库优先:解析用
go/parser+go/ast,格式化用go/format,模板渲染用项目自己的 Enjoy——damigen 没有任何第三方依赖(enjoy 本身零外部依赖)。 - 上下文透传:首个
context.Context参数不进 args,直接作为 lpc 调用的 ctx,与 Go 惯例一致,避免无意义的序列化开销。
延伸阅读
- dami ——
Bus/Send/Call1[R]/Call0/lpc.RegisterProvider的运行时定义,damigen 生成的代码即委托给它 - dami-plugin —— 把 dami 总线接入 aifei 应用的插件层
- enjoy —— damigen 与
tools/generator共用的渲染内核(#()/#for/#if)