Skip to content

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 侧用反射注册:

go
lpcBus.RegisterProvider("order", &OrderService{})   // 把 OrderService 的方法挂到 order.* topic

Consumer 侧的调用是类型不安全的字符串:

go
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 DamiBusAifei-Go damigen
CGLIB 动态代理go/parser + 代码生成
运行时反射方法分发编译期生成的方法委托
createConsumer(Iface.class)//go:generate go run ...damigen
Consumer 实例 = 代理Consumer 实例 = 生成的 XxxClient 结构体

2. 总体架构 ​

核心抽象:

类型文件职责
Generate(srcDir, outDir string) errorgen.go唯一入口;编排全流程
ifaceInfoparser.go一个接口的渲染模型(Name/Topic/Methods)
methodInfoparser.go一个方法的渲染模型(Name/Params/Results/CallExpr)
clientTemplatetemplate.go单一 Enjoy 模板常量,渲染全部 Client
enginegen.go包级共享的 Enjoy 引擎实例
cmd/damigen/main.gocmd/damigen/CLI 包装;-src / -out 两个 flag

3. 关键 API ​

3.1 入口:Generate ​

go
func Generate(srcDir, outDir string) error
参数含义典型值
srcDir含 //dami:provider 接口的 Go 源码目录.、./mysvc
outDir生成文件输出目录;不存在会创建通常与 srcDir 相同
返回错误;失败原因有三种(见 §6)—

无任何配置 struct、无 functional option——调用面刻意最小化。需要定制 import 路径的场景(如 vendor 了 dami)直接改包级变量:

go
// 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 接口的写法 ​

接口需要满足两个条件:

  1. 是一个 type Xxx interface { ... }(导出与否不限,但一般导出)
  2. 紧贴接口声明的 doc 注释里有 //dami:provider <topic>
go
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,生成器产出(节选):

go
// 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")
}

使用方式与普通接口调用无异:

go
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
errorreturn 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 参数签名的还原 ​

参数在接口源码里可能写成多种形式:

go
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 字符串(不是独立文件):

go
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"),所有调用共用,避免每次构造。

渲染后的格式化 ​

go
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, nil

go/format 把 Enjoy 输出按 gofmt 规则规整(对齐字段、统一缩进)。格式失败时返回未格式化的源码,让调用方能在错误信息里看到上下文,便于排查模板问题——这是个相当务实的设计。


6. 错误处理与失败模式 ​

damigen 采用严格校验、fail-closed 策略。三种失败:

失败场景错误信息何时触发
无 Go 包no Go package found in %sparser.ParseDir 返回空
无 provider 接口no //dami:provider interfaces found in %scollectInterfaces 返回空切片
方法签名不支持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 的几个边界处理:

  • 接口声明位置:type spec 既可能在 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 关键字及其后第一个字段:

go
//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 包装:

go
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
//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 侧(业务包):

go
// 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 实现 + 注册:

go
// 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 侧(任何地方):

go
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 的设计原则:

  1. 替代动态代理:Go 没有运行时字节码增强,用代码生成达到 Java 动态代理的等价效果——且更透明、更可调试。
  2. 严格签名约定:只支持 (R, error) 与 error 两种返回,把 lpc 的语义约束前推到生成期,避免运行时意外。
  3. 注释驱动://dami:provider <topic> 一行注解即可,无需额外配置文件、无需 struct tag、无需 IDL。
  4. 一个文件、一个模板:所有 XxxClient 集中在 dami_gen.go,便于 review、ignore、清理;单模板与 tools/generator 共用 Enjoy 引擎,呈现一致的代码生成风格。
  5. 标准库优先:解析用 go/parser + go/ast,格式化用 go/format,模板渲染用项目自己的 Enjoy——damigen 没有任何第三方依赖(enjoy 本身零外部依赖)。
  6. 上下文透传:首个 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)

基于 Apache-2.0 许可发布