0%

go-zero 源码分析 04:RPC、Model 与部署生成

在上一篇文章中,我们把 goctl 最核心的能力——从 .api 到 REST 工程的解析与代码生成——完整地走了一遍。但一个生产级的微服务系统远不止 REST 接口。你还需要:

  • RPC 服务:内部服务间的通信不走 HTTP,而是走 gRPC——接口用 protobuf 定义,生成类型安全的客户端和服务端桩代码。
  • 数据访问层:业务逻辑最终要读写数据库。表结构定义好了,对应的 model 代码怎么来?缓存怎么自动集成?
  • 部署描述文件:服务写好了,Dockerfile 怎么生成?Kubernetes 的 Deployment、Service、HPA 清单怎么来?Gateway 的骨架怎么搭?

同样是 定义驱动生成 的模式——你提供 proto 文件、数据库连接、几个关键参数,goctl 负责把标准化、模板化的代码和配置生成出来。本文就沿着这三条线展开:先看 goctl 如何从 .proto 文件中提取结构化信息并生成 gRPC 服务工程,再深入 model 生成,重点看缓存模型的 Cache-Aside 模式是怎样在代码生成阶段就嵌入的,最后看 Docker、Kubernetes 和 Gateway 这些部署相关文件是怎样从模板中渲染出来的。

整篇文章的逻辑是:输入定义 → 信息提取 → 模板渲染 → 工程产出。无论是 proto 文件、数据库 schema、还是一组命令行参数,goctl 都用同一套"解析(或查询) + 模板渲染"的方法将它们变成工程文件。

从 .proto 到 gRPC 工程:goctl rpc

为什么包装 protoc 而不是直接生成全部代码?

说到 gRPC 服务的代码生成,protobuf 官方已经提供了 protoc 编译器以及 protoc-gen-goprotoc-gen-go-grpc 两个插件。它们负责的是 协议层 的代码——将 .proto 中的 message 编译成 Go 结构体,将 service 定义编译成 gRPC 的客户端和服务端接口。这些是标准化的、与框架无关的代码

但光有协议层代码还不够。一个可运行的 gRPC 服务还需要:

  • 配置文件(YAML 和对应的 Go 结构体)
  • 服务上下文ServiceContext,承载依赖注入)
  • 业务逻辑骨架(每个 RPC 方法对应一个 logic 文件)
  • 服务端注册代码(将 logic 挂载到 gRPC server 上)
  • 客户端封装(调用方不需要直接面对 protoc-gen-go-grpc 生成的 raw client)
  • 入口 main 函数

这些就是 goctl rpc 在 protoc 之上增加的那一层。它不是替代 protoc,而是先调用 protoc 生成协议层代码,再基于协议层的元数据生成框架层代码

两种入口:new 和 protoc

goctl rpc 提供了两种使用方式,对应两种不同的工作流:

1
2
3
4
5
方式一:从零开始(goctl rpc new <service>)
自动生成 .proto 模板 → 用户填充 → 一键生成全部代码

方式二:已有 proto(goctl rpc protoc <file.proto>)
用户提供 proto 文件 + protoc 参数 → 生成框架层代码

两种方式的命令注册在 tools/goctl/rpc/cmd.go 中,最终都走到同一个函数:generator.NewGenerator(style, verbose).Generate(&ctx)。区别只在于 ZRpcContext 的构建方式不同。

new 模式(tools/goctl/rpc/cli/cli.go)中,goctl 先生成一个 .proto 模板文件,然后构造一个合成版的 protoc 命令字符串:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// tools/goctl/rpc/cli/cli.go
func RPCNew(_ *cobra.Command, args []string) error {
rpcname := args[0]
protoName := rpcname + ".proto"
filename := filepath.Join(".", rpcname, protoName)
src, _ := filepath.Abs(filename)

err = generator.ProtoTmpl(src) // 先生成 .proto 模板

var ctx generator.ZRpcContext
ctx.Src = src
ctx.GoOutput = filepath.Dir(src)
ctx.GrpcOutput = filepath.Dir(src)
ctx.IsGooglePlugin = true
ctx.Output = filepath.Dir(src)
ctx.ProtocCmd = fmt.Sprintf("protoc -I=%s %s --go_out=%s --go-grpc_out=%s",
filepath.Dir(src), filepath.Base(src), filepath.Dir(src), filepath.Dir(src))
// ...

g := generator.NewGenerator(style, verbose)
return g.Generate(&ctx)
}

protoc 模式(tools/goctl/rpc/cli/zrpc.go)中,wrapProtocCmd() 函数解析用户传入的 --go_out--go-grpc_out--proto_path 等参数,组装成真正的 protoc 命令。本质上,goctl rpc protoc 是一个 protoc 的包装器——它替你执行 protoc,然后接管后续的框架代码生成。

Proto 解析:纯 Go 实现,不依赖 protoc

在动手解析之前,先抛出一个问题:protoc 本身就是最权威的 proto 文件解析器,为什么 goctl 不直接调用 protoc 来提取元数据,而是自己实现了一套?

答案是速度和控制粒度。调用 protoc 是一个外部进程调用,需要启动子进程、等待退出、解析输出——这在批量生成大量文件时开销不可忽视。更重要的是,protoc 的输出格式(FileDescriptorProto)虽然完整,但也非常冗长,goctl 只需要其中的一部分信息(package、go_package、service、rpc、message、import),自己解析能拿到更精确、更精简的数据结构。

goctl 选择了 github.com/emicklei/proto 这个纯 Go 库来做 proto 文件解析。核心逻辑在 tools/goctl/rpc/parser/parser.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// tools/goctl/rpc/parser/parser.go
func (p *DefaultProtoParser) Parse(src string, multiple ...bool) (Proto, error) {
var ret Proto
abs, _ := filepath.Abs(src)
r, _ := os.Open(abs)
defer r.Close()

parser := proto.NewParser(r)
set, _ := parser.Parse()

var serviceList Services
proto.Walk(set,
proto.WithImport(func(i *proto.Import) {
ret.Import = append(ret.Import, Import{Import: i})
}),
proto.WithMessage(func(message *proto.Message) {
ret.Message = append(ret.Message, Message{Message: message})
}),
proto.WithPackage(func(p *proto.Package) {
ret.Package = Package{Package: p}
}),
proto.WithService(func(service *proto.Service) {
serv := Service{Service: service}
elements := service.Elements
for _, el := range elements {
v, _ := el.(*proto.RPC)
if v == nil {
continue
}
serv.RPC = append(serv.RPC, &RPC{RPC: v})
}
serviceList = append(serviceList, serv)
}),
proto.WithOption(func(option *proto.Option) {
if option.Name == "go_package" {
ret.GoPackage = option.Constant.Source
}
}),
)
// ...
ret.PbPackage = GoSanitized(filepath.Base(ret.GoPackage))
ret.Src = abs
ret.Name = filepath.Base(abs)
ret.Service = serviceList
return ret, nil
}

emicklei/proto 库将 proto 文件解析为一棵 AST,然后通过 proto.Walk 遍历。goctl 注册了五个回调:WithImportWithMessageWithPackageWithServiceWithOption,分别提取 import 列表、message 定义、package 声明、service 中的 RPC 方法定义、以及 go_package 选项。

最终产出的 Proto 结构体(tools/goctl/rpc/parser/proto.go)是后续所有代码生成的数据源:

1
2
3
4
5
6
7
8
9
10
11
type Proto struct {
Src string // proto 文件绝对路径
Name string // 文件名
Package Package // proto package 声明
PbPackage string // 净化后的 pb package 名(用于 Go 代码)
GoPackage string // go_package 选项的值
Import []Import // import 列表
Message []Message // message 列表
Service Services // 所有 service 定义(含 RPC 列表)
ImportedProtos []ImportedProto // 传递导入的依赖 proto 文件元数据
}

这里有一个容易被忽略但对跨文件引用至关重要的步骤:传递 import 解析parser.ParseImportedProtos() 函数递归地解析所有 import 语句引用的 proto 文件,提取它们各自的 go_package 路径。这个信息用在一个叫 resolveRPCTypeRef 的函数中——当你的 RPC 方法使用了 import 进来的 message 类型作为请求或响应时,生成器需要知道这个类型最终在 Go 代码中对应的 import 路径和类型名。

单服务模式和多服务模式

goctl rpc 支持两种 proto 文件的组织方式:

  • 单服务模式(默认):一个 .proto 文件只定义一个 service。生成的代码放在 internal/logic/internal/server/ 下,文件命名直接对应 RPC 方法名。
  • 多服务模式(--multiple / -m:一个 .proto 文件可以定义多个 service。每个 service 在 internal/logic/<service>/internal/server/<service>/ 下拥有独立的子目录,client 代码也一样。

多服务模式在微服务拆分初期特别有用——你可以先在一个 proto 文件中定义所有服务接口,后续再逐步拆分为独立的 proto 文件和服务。

Generate 管线:十步生成一个 gRPC 服务

一切汇聚在 Generator.Generate() 方法(tools/goctl/rpc/generator/gen.go)。这个方法定义了一条严格的顺序管线:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// tools/goctl/rpc/generator/gen.go
func (g *Generator) Generate(zctx *ZRpcContext) error {
// 1. 环境准备:检查 go、protoc、protoc-gen-go、protoc-gen-go-grpc 是否可用
err = g.Prepare()

// 2. 项目上下文:解析 go.mod,确定模块路径
projectCtx, err = ctx.Prepare(abs)

// 3. Proto 解析:将 .proto 文件解析为 parser.Proto 结构体
proto, err := p.Parse(zctx.Src, zctx.Multiple)

// 4. 传递导入解析:递归解析 import 依赖
proto.ImportedProtos, err = resolveImportedProtos(zctx)

// 5. 创建目录结构
dirCtx, err = mkdir(projectCtx, proto, g.cfg, zctx)

// 6. 生成 etc/<service>.yaml (YAML 配置)
err = g.GenEtc(dirCtx, proto, g.cfg)

// 7. 生成 pb.go + _grpc.pb.go (调用 protoc)
err = g.GenPb(dirCtx, zctx)

// 8. 生成 internal/config/config.go (配置结构体)
err = g.GenConfig(dirCtx, proto, g.cfg)

// 9. 生成 internal/svc/service_context.go
err = g.GenSvc(dirCtx, proto, g.cfg)

// 10. 生成 internal/logic/<rpc>_logic.go (业务逻辑骨架)
err = g.GenLogic(dirCtx, proto, g.cfg, zctx)

// 11. 生成 internal/server/<service>_server.go (服务端注册)
err = g.GenServer(dirCtx, proto, g.cfg, zctx)

// 12. 生成 main.go (服务入口)
err = g.GenMain(dirCtx, proto, g.cfg, zctx)

// 13. [可选] 生成 client/ (客户端封装)
if zctx.IsGenClient {
err = g.GenCall(dirCtx, proto, g.cfg, zctx)
}
return err
}

这十来步的排列有内在的依赖逻辑。先做环境检查,确保 protoc 和必要的 Go 插件存在;再解析 go.mod 获取模块路径(后续生成 import 语句需要);解析 proto 文件和它的 import 依赖;创建目录结构;然后才逐个生成文件。其中 GenPb——调用 protoc 生成协议层代码——必须在 GenConfigGenSvcGenLogicGenServer 之前执行,因为后续的框架代码需要 import 生成的 pb 包。

生成的文件及其角色

一个典型的 goctl rpc new greet 会生成如下目录结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
greet/
etc/
greet.yaml ← 服务 YAML 配置
internal/
config/
config.go ← Config 结构体(嵌入 zrpc.RpcServerConf)
logic/
sayhello_logic.go ← 每个 RPC 方法的业务逻辑骨架
server/
greet_server.go ← gRPC 服务端实现(委托给 logic)
svc/
service_context.go ← 服务上下文
greet/
greet.pb.go ← protoc-gen-go 生成的消息结构体
greet_grpc.pb.go ← protoc-gen-go-grpc 生成的 gRPC 接口
greet.proto ← proto 定义文件
main.go ← 服务入口
[client/] ← 客户端封装(可选)

我们挑其中几个关键文件展开来看。

入口文件 main.go(模板 main.tpl)和 REST 服务的入口结构非常相似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 生成自 main.tpl
func main() {
flag.Parse()
var c config.Config
conf.MustLoad(*configFile, &c)
ctx := svc.NewServiceContext(c)

s := zrpc.MustNewServer(c.RpcServerConf, func(grpcServer *grpc.Server) {
greet.RegisterGreetServer(grpcServer, server.NewGreetServer(ctx))
if c.Mode == service.DevMode || c.Mode == service.TestMode {
reflection.Register(grpcServer)
}
})
defer s.Stop()

fmt.Printf("Starting rpc server at %s...\n", c.ListenOn)
s.Start()
}

zrpc.MustNewServer 接收一个回调,在回调中执行 RegisterGreetServer——这个函数来自 protoc-gen-go-grpc 生成的 _grpc.pb.go 文件。server.NewGreetServer(ctx) 返回的 GreetServer 则是 goctl 生成的 server 代码。注意它加了一个小细节:在 DevModeTestMode 下注册 gRPC reflection,便于用 grpcurl 调试。

Server 层server.tpl)的逻辑非常薄——它是一个纯粹的转发层:

1
2
3
4
5
6
7
8
9
10
// 生成自 server.tpl
type GreetServer struct {
svcCtx *svc.ServiceContext
greet.UnimplementedGreetServer // 嵌入 gRPC 的未实现接口,保证向前兼容性
}

func (s *GreetServer) SayHello(ctx context.Context, in *greet.HelloReq) (*greet.HelloResp, error) {
l := logic.NewSayHelloLogic(ctx, s.svcCtx)
return l.SayHello(in)
}

每个 RPC 方法的实现只做一件事:创建对应的 logic 实例,调用其同名方法。这种"Server → Logic"的两层结构是 go-zero 的惯用模式:Server 层负责实现 gRPC 接口并持有 ServiceContext,Logic 层负责业务逻辑并持有 contextloggersvcCtx。两者之间的边界很清晰——你只需要在 logic 文件中写业务代码。

Logic 层logic.tpl)是业务代码的容器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 生成自 logic.tpl
type SayHelloLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
logx.Logger
}

func NewSayHelloLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SayHelloLogic {
return &SayHelloLogic{
ctx: ctx,
svcCtx: svcCtx,
Logger: logx.WithContext(ctx),
}
}

func (l *SayHelloLogic) SayHello(in *greet.HelloReq) (*greet.HelloResp, error) {
// todo: add your logic here and delete this line
return &greet.HelloResp{}, nil
}

注意 logic 文件的结构和上一篇文章中 REST 的 logic 是一致的:私有结构体 + 构造函数 + 方法。这正是 goctl 的设计哲学——跨通信协议的统一编程模型。无论你写的是 HTTP handler 还是 gRPC handler,你面对的都是同一套"从 ServiceContext 取依赖、在 Logic 中实现业务"的模式。

客户端封装call.tpl)是 goctl 在 protoc-gen-go-grpc 生成的 raw gRPC client 之上又包了一层:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 生成自 call.tpl
type Greet interface {
SayHello(ctx context.Context, in *greet.HelloReq, opts ...grpc.CallOption) (*greet.HelloResp, error)
}

type defaultGreet struct {
cli zrpc.Client
}

func (m *defaultGreet) SayHello(ctx context.Context, in *greet.HelloReq, opts ...grpc.CallOption) (*greet.HelloResp, error) {
client := greet.NewGreetClient(m.cli.Conn())
return client.SayHello(ctx, in, opts...)
}

这一层封装的价值在于:它将 zrpc.Client(go-zero 自己的客户端抽象,携带了服务发现、负载均衡、熔断等能力)和 gRPC 的桩代码连接起来。调用方只需要 NewGreet(zrpcClient) 就能拿到一个携带全部治理能力的客户端,而不需要手动管理 gRPC 连接。

最后如果对上述流程有不清楚的地方,可以参考下 Kratos 源码分析 06:gRPC Transport(1) 关于原生 gRPC Server 的开发流程,可以对 go-zero 的封装有更直观的理解。

一个具体的例子

我们用 goctl rpc new greetzrpc 来验证整条管线:

1
2
3
4
5
# 1. 生成 RPC 服务
goctl rpc new greetzrpc

# 2. 查看生成的文件
find greetzrpc -type f | sort

生成的文件结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
greetzrpc/
etc/greetzrpc.yaml ← Name: greetzrpc.rpc, ListenOn: 0.0.0.0:8080, Etcd...
greetzrpc.go ← main 入口
greetzrpc.proto ← proto 模板(包含 SayHello RPC)
internal/
config/config.go ← Config 嵌入 zrpc.RpcServerConf
logic/sayhello_logic.go ← 业务逻辑骨架(待填充)
server/greetzrpc_server.go ← gRPC 服务端实现
svc/service_context.go ← ServiceContext
greetzrpc/
greetzrpc.pb.go ← protoc-gen-go 产出
greetzrpc_grpc.pb.go ← protoc-gen-go-grpc 产出

从一份 proto 模板到九个 Go 文件,整个过程不到一秒。和 goctl api go 一样,logic 文件是"生成了就不再覆盖"的安全编辑区——你可以在其中填充业务逻辑,后续修改 proto 重新生成时,已修改的 logic 文件保持不变,而 server、config、client 等会自动同步。

从 SQL 到数据访问层:goctl model

理解了 RPC 生成的管线后,我们转向数据访问层的生成。这里的问题域有所不同:输入不是一份 DSL 文本,而是数据库中的实际表结构(或者描述表结构的 DDL 文件);输出不是 RPC handler,而是类型安全的 CRUD 代码和缓存层

命令结构与数据源

goctl model 的命令树由三级组成(tools/goctl/model/cmd.go):

1
2
3
4
5
6
7
goctl model
├── mysql
│ ├── ddl ← 从 .sql DDL 文件生成
│ └── datasource ← 从在线 MySQL 数据库生成
├── pg
│ └── datasource ← 从在线 PostgreSQL 数据库生成
└── mongo ← 从类型名生成 MongoDB model

MySQL 提供了两种获取表结构的方式,分别适用于不同的工作流:

  • DDL 模式goctl model mysql ddl -src user.sql -dir .——你手头有建表 SQL 文件,goctl 解析这些 SQL 语句提取表结构。适用于"先设计表、再生成代码"的场景。
  • Datasource 模式goctl model mysql datasource -url "user:pass@tcp(localhost:3306)/db" -table user——goctl 连接到你指定的数据库,通过 information_schema 查询表结构。适用于"已有数据库、需要快速补代码"的场景。

PostgreSQL 也支持 datasource 模式,原理类似,只是查询的是 pg_cataloginformation_schema 这两个 PG 专用的系统元数据表。MongoDB 则完全不同——MongoDB 没有 schema 概念,所以 goctl 只需要类型名称就能生成对应的 CRUD 封装。

不论哪种模式,最终都收归到同一个生成器——gen.NewDefaultGenerator。DDL 模式走 StartFromDDL,datasource 模式走 StartFromInformationSchema,两者只是入口不同,内部的模板渲染完全一致。

DDL 解析:从 SQL 到 Table 结构体

DDL 解析的核心在 tools/goctl/model/sql/parser/parser.go,它使用了外部库 github.com/zeromicro/ddl-parser/parser 来解析建表 SQL。

解析的结果是一个 Table 结构体,包含了代码生成需要的关键信息:

1
2
3
4
5
6
7
8
9
// tools/goctl/model/sql/parser/parser.go
type Table struct {
Name stringx.String // 表名
Db stringx.String // 数据库名
PrimaryKey Primary // 主键(字段 + 是否自增)
UniqueIndex map[string][]*Field // 唯一索引(索引名 → 字段列表)
Fields []*Field // 所有字段
ContainsPQ bool // 是否包含 PostgreSQL 特有类型
}

解析过程中有几个关键的提取逻辑:

  • 主键识别:既检查列级约束(Constraint.Primary),也检查表级约束(Constraints.ColumnPrimaryKey)。联合主键会被拒绝——go-zero 的缓存模型要求单一主键。
  • 唯一索引提取:从列级约束(Constraint.Unique)和表级约束(Constraints.ColumnUniqueKey)中收集。注意,如果某一列既是主键又在唯一索引声明中,主键优先——checkDuplicateUniqueIndex 会去重。
  • 类型转换:调用 converter(tools/goctl/model/sql/converter/types.go),将 SQL 类型映射为 Go 类型。比如 tinyint → int64datetime → time.Timevarchar → string。可空字段自动映射为 sql.NullInt64sql.NullString 等;如果开启了 --strict 且字段是无符号的,则映射为 uint64uint32 等。

对于 datasource 模式,tools/goctl/model/sql/model/infoschemamodel.go 通过查询 information_schema.TABLESCOLUMNSSTATISTICS 获取表结构,然后通过 ColumnData.Convert()parser.ConvertDataType() 转换为与 DDL 模式相同的 Table 结构体。这是统一入口的关键:不管数据源是什么,代码生成阶段看到的都是同一套数据结构。

缓存模型的核心:Cache-Aside 模式的代码生成

go-zero model 的一个独到设计是:缓存并不是运行时通过配置动态开启的,而是在代码生成阶段就"编织"进去了。当你执行 goctl model mysql ddl -src user.sql -dir . -c 时,-c 标志会让生成器为每个表生成两套版本的代码路径——带缓存和不带缓存——放在同一个 _gen.go 文件中。

为什么要这样做?因为缓存的开启会改变代码的结构,而不仅仅是行为。带缓存的 Insert 方法需要生成缓存失效的 key,FindOne 需要先查缓存再查数据库,Update 和 Delete 需要先查出旧数据(用于计算旧的缓存 key)再执行操作。如果这些逻辑放在运行时,就需要反射和动态代码生成,既慢又不类型安全。goctl 的选择是把这些在编译期就确定下来。

这一切的起点在 genCacheKeys 函数(tools/goctl/model/sql/gen/keys.go)。它为一个表生成三类缓存 key:

1
2
3
4
5
6
7
8
9
10
func genCacheKeys(prefix string, table parser.Table) (Key, []Key) {
// 主键缓存 key
primaryKey = genCacheKey(prefix, table.Db, table.Name,
[]*parser.Field{&table.PrimaryKey.Field})
// 每个唯一索引一个缓存 key
for _, each := range table.UniqueIndex {
uniqueKey = append(uniqueKey, genCacheKey(prefix, table.Db, table.Name, each))
}
return primaryKey, uniqueKey
}

genCacheKey 生成的命名遵循固定约定。假设表名为 user,数据库名为 user_center,主键为 id,前缀为 cache,那么:

  • 变量名:cacheUserCenterUserIdPrefix = "cache:userCenter:user:id:"
  • 运行时 key:userCenterUserIdKey := fmt.Sprintf("%s%v", cacheUserCenterUserIdPrefix, userId)

如果还有一个 UNIQUE(email) 索引,还会多出一组:

  • cacheUserCenterUserEmailPrefix = "cache:userCenter:user:email:"
  • userCenterUserEmailKey := fmt.Sprintf("%s%v", cacheUserCenterUserEmailPrefix, email)

缓存的 key 是固定前缀 + 字段值:前缀(db 名 + 表名 + 字段名)在生成时确定,值在运行时才有。问题在于,同一个 key 在不同方法里,值是以不同形式拿到手的——FindOne(ctx, id) 手里是裸入参 id,而 Insert(ctx, data *User) 手里是结构体,只能写 data.Id。于是同一个 key 需要三种写法,genCacheKey 一次性算好塞进 Key

  • VarExpression:声明前缀常量(vars.go 里,用 =)——cacheUserCenterUserIdPrefix = "cache:userCenter:user:id:"
  • KeyExpression:值来自裸入参——userCenterUserIdKey := fmt.Sprintf("%s%v", prefix, id)
  • DataKeyExpression:值来自 data. 字段——userCenterUserIdKey := fmt.Sprintf("%s%v", prefix, data.Id)

两者生成的变量名相同,唯一区别是 fmt.Sprintf 的参数来源(入参 vs data.Xxx)。最直观的证据是带唯一索引的 Delete:主键 key 用 KeyExpressionid 就是入参),唯一索引 key 用 DataKeyExpression(要先 FindOne 查旧行,再取 data.Email)。

缓存如何影响每个 CRUD 方法

前文说缓存"改变的是代码的结构,而不仅是行为",落到方法上就是:方法体从「直接拿 conn 执行 SQL」变成「先算 key,再调带缓存的连接」。本小节看最基本的两侧——Insert 和 FindOne;一旦表有唯一索引,FindOneByField、Update、Delete 会多出更复杂的结构变化,放到后面两节。

先看生成器侧。genInserttools/goctl/model/sql/gen/insert.go)为带缓存版本收集两类东西:

1
2
3
4
5
6
7
8
9
10
11
// 伪代码:Insert 的带缓存逻辑
func genInsert(table Table, withCache, postgreSql bool) (string, string, error) {
// keySet 收「表达式文本」:xxxKey := fmt.Sprintf(...) 这一整行
keySet.Add(table.PrimaryCacheKey.DataKeyExpression)
for _, key := range table.UniqueCacheKey {
keySet.Add(key.DataKeyExpression)
}
// keyVariableSet 收「变量名」:xxxKey
keyVariableSet.Add(table.PrimaryCacheKey.KeyLeft)
// ...
}

渲染时,keySet 里的每一行被拼成模板的 .keyskeyVariableSet 里的变量名被拼成 .keyValues(逗号分隔)。前者出现在方法体开头,后者作为缓存连接方法的尾参。

把带缓存和不带缓存两个版本并排看,差异一目了然:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 不带缓存(未加 -c)
func (m *defaultUserModel) Insert(ctx context.Context, data *User) (sql.Result, error) {
query := fmt.Sprintf("insert into %s (...) values (...)", m.table)
ret, err := m.conn.ExecCtx(ctx, query, data.Name, data.Email) // 直接执行
return ret, err
}

// 带缓存
func (m *defaultUserModel) Insert(ctx context.Context, data *User) (sql.Result, error) {
userCenterUserIdKey := fmt.Sprintf("%s%v", cacheUserCenterUserIdPrefix, data.Id) // 先算 key
userCenterUserEmailKey := fmt.Sprintf("%s%v", cacheUserCenterUserEmailPrefix, data.Email)
ret, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
return conn.ExecCtx(ctx, "insert into user ...", data.Name, data.Email)
}, userCenterUserIdKey, userCenterUserEmailKey) // 多传一组 key
return ret, err
}

两处差异源于同一个事实:带缓存时 defaultUserModel 嵌入的是 core/stores/sqlc 包的 CachedConn,不带缓存时只是一个 sqlx.SqlConn 字段。所以带缓存版调的是提升上来的 m.ExecCtx(多收一组 key 尾参),不带缓存版调的是 m.conn.ExecCtxExecCtx 的语义是"先执行 SQL,成功后按 key 删缓存"——这就是 Cache-Aside 的写后删除。

FindOne 是镜像的另一侧。带缓存版调 m.QueryRowCtx,语义是"先按 key 读缓存,未命中再查库并回填":

1
2
3
4
5
6
7
8
9
// 带缓存
func (m *defaultUserModel) FindOne(ctx context.Context, id int64) (*User, error) {
userCenterUserIdKey := fmt.Sprintf("%s%v", cacheUserCenterUserIdPrefix, id) // 值来自裸入参
var resp User
err := m.QueryRowCtx(ctx, &resp, userCenterUserIdKey, func(ctx context.Context, conn sqlx.SqlConn, v any) error {
return conn.QueryRowCtx(ctx, v, "select ... where id = ? limit 1", id)
})
return &resp, err
}

到这里只有主键一个 key,缓存逻辑还算简单。真正的复杂度出现在表有唯一索引之后:FindOneByEmail 要引入两级缓存,Update / Delete 要先回查旧值才能算出要失效的索引 key——这正是下面两节的内容。

FindOneByField 与两级缓存

当表有唯一索引时,goctl 会额外生成 FindOneBy<Field> 方法。比如 UNIQUE(email) 会生成 FindOneByEmail。它比 FindOne 难在:缓存的 key 里嵌着主键值(cache:userCenter:user:id:1),而 FindOneByEmail 手里只有 email,拼不出这个主键 key,所以没法直接去数据缓存里命中。

解决办法是加一层索引缓存,让 email 也能先查到主键值:

  1. 索引缓存email → 主键值(key 形如 cache:userCenter:user:email:xxx@yy.com
  2. 数据缓存:主键值 → 行数据(key 形如 cache:userCenter:user:id:1,和 FindOne 用的是同一份)

查询时先走 1 拿到主键,再走 2 拿行数据。但要澄清一点:这两级不是生成代码里的两个独立调用,生成代码只调了一次 m.QueryRowIndexCtx,第二级是框架在这个方法内部完成的。看 find-one-by-field.tpl 渲染出来的代码就清楚了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (m *defaultUserModel) FindOneByEmail(ctx context.Context, email string) (*User, error) {
userCenterUserEmailKey := fmt.Sprintf("%s%v", cacheUserCenterUserEmailPrefix, email)
var resp User
err := m.QueryRowIndexCtx(ctx, &resp, userCenterUserEmailKey, m.formatPrimary,
func(ctx context.Context, conn sqlx.SqlConn, v any) (any, error) {
// 索引缓存 miss:按 email 查库,返回主键值
query := fmt.Sprintf("select %s from %s where `email` = ? limit 1", userRows, m.table)
if err := conn.QueryRowCtx(ctx, &resp, query, email); err != nil {
return nil, err
}
return resp.Id, nil
},
m.queryPrimary) // 索引缓存命中后:拿主键值去数据缓存/数据库查行数据
// ...
}

传给 QueryRowIndexCtx 的除了 email 的 key,还有两个回调,它们由 find-one-by-field-extra-method.tpl 生成:

1
2
3
4
5
6
7
8
9
10
// keyer:把主键值拼成数据缓存的 key,框架用它把「索引 miss 时查到的行」回填到主键 key 下
func (m *defaultUserModel) formatPrimary(primary any) string {
return fmt.Sprintf("%s%v", cacheUserCenterUserIdPrefix, primary)
}

// primaryQuery:数据缓存也 miss 时,按主键值查库
func (m *defaultUserModel) queryPrimary(ctx context.Context, conn sqlx.SqlConn, v, primary any) error {
query := fmt.Sprintf("select %s from %s where `id` = ? limit 1", userRows, m.table)
return conn.QueryRowCtx(ctx, v, query, primary)
}

QueryRowIndexCtxcore/stores/sqlc/cachedsql.go)内部把两级串起来:先用 email 的 key 去索引缓存 TakeWithExpireCtx,命中了拿到主键值;miss 了就执行上面的匿名函数查库,顺手把整行数据用 formatPrimary(主键值) 回填到数据缓存。索引命中后,再用主键 key 走一次数据缓存(TakeCtx),这时才轮到 queryPrimary 在数据缓存也 miss 时查库。所以 formatPrimary 负责"两级之间怎么接",queryPrimary 负责"最后兜底查库"。

Update 和 Delete 中的旧值回查

这个回查不是所有 Update / Delete 都有:只有在开启缓存,且表上存在唯一索引缓存时,模板才会先生成 FindOne。如果只有主键缓存,Update 可以从 newData.Id 拼出 key,Delete 也可以从入参 id 拼出 key,都不需要回查整行。

先明确“旧值”指什么。假设 user 表的主键是 idemail 上有唯一索引,并且数据库中 id = 1 的当前记录是 email = old@email.com。此时可能有两类缓存 key:

  • 数据缓存 key:cache:userCenter:user:id:1,指向整行数据;
  • 索引缓存 key:cache:userCenter:user:email:old@email.com,指向主键 1

执行 UPDATE user SET email = 'new@email.com' WHERE id = 1 后,主键 key 下的整行数据已经失效,旧 email key 下的 email -> id 关系也已经失效,所以两者都要删除。主键 1 可以直接从 Update 入参中取到;但入参中只有新 email 值,没有旧 email 值,因而拼不出 ...:email:old@email.com 这个旧索引 key。这就是 Update 必须先调用 FindOne(ctx, newData.Id) 回查修改前整行数据的原因。这里的“查”会正常经过缓存,不代表每次都会访问数据库。

Delete 的原因更直接:Delete(ctx, id) 只接收主键,它能拼出主键 key,却不知道这行的 email,因而也必须先通过 FindOne(ctx, id) 取得旧行,才能把该行对应的所有唯一索引 key 一起删掉。

把模板渲染后的核心流程简化一下,大致是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func (m *defaultUserModel) Update(ctx context.Context, newData *User) error {
// data 是修改前的数据,newData 是待写入的新数据
data, err := m.FindOne(ctx, newData.Id)
if err != nil {
return err
}

userIdKey := fmt.Sprintf("%s%v", cacheUserCenterUserIdPrefix, data.Id)
oldEmailKey := fmt.Sprintf("%s%v", cacheUserCenterUserEmailPrefix, data.Email)

_, err = m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
// SQL 使用 newData 中的新值
return conn.ExecCtx(ctx, "update user set email = ? where id = ?", newData.Email, newData.Id)
}, userIdKey, oldEmailKey)
return err
}

func (m *defaultUserModel) Delete(ctx context.Context, id int64) error {
data, err := m.FindOne(ctx, id)
if err != nil {
return err
}

userIdKey := fmt.Sprintf("%s%v", cacheUserCenterUserIdPrefix, id)
oldEmailKey := fmt.Sprintf("%s%v", cacheUserCenterUserEmailPrefix, data.Email)

_, err = m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
return conn.ExecCtx(ctx, "delete from user where id = ?", id)
}, userIdKey, oldEmailKey)
return err
}

有两个顺序容易看反。第一,key 在执行 SQL 之前就已经根据旧行计算好;第二,ExecCtx 内部先执行 SQL,只有 SQL 成功后才删除传入的缓存 key。它不会计算新旧 key 的 diff,也不会在写操作中回填新数据;做的是无条件失效“主键 key + 由旧行拼出的所有唯一索引 key”,后续读取再按需回填。

这正是代码生成在这里的价值:goctl 在生成阶段已经知道表上有哪些唯一索引、每个 key 需要哪些字段,因此直接生成确定的 fmt.SprintfExecCtx(..., keys...) 调用,运行时不需要用反射去遍历索引定义。

PostgreSQL 和 MongoDB 的生成

PostgreSQL 的 datasource 模式流程和 MySQL 几乎一致,区别只在于 PostgreSqlModeltools/goctl/model/sql/model/postgresqlmodel.go)查询的是 pg_classpg_attributepg_index 等 PG 系统表。一个巧妙的做法是:PostgreSQL 的类型(int8float8timestamptzuuid 等)在查询阶段就被映射为 MySQL 兼容的类型名(bigintdoubletimestampvarchar),然后复用同一套类型转换器。这样一来,后续的代码生成逻辑完全不需要感知数据库差异——唯一的区别只在 SQL 占位符上(MySQL 用 ?,PostgreSQL 用 $1$2……)。

MongoDB 的生成(tools/goctl/model/mongo/mongo.go)则完全是另一条路。MongoDB 本身没有 schema,所以 goctl 走的是"按类型名生成"的路径——给一个类型名(如 User),生成 UserModel 接口和 customUserModel 实现。模板位于 tools/goctl/model/mongo/generate/ 下,逻辑和 SQL model 生成类似,只是底层调用的是 MongoDB 的 client。

生成文件的全貌

以带缓存模式的 user 表为例,最终生成的文件包括:

1
2
3
4
model/
user_model_gen.go ← 每次覆盖,DO NOT EDIT:interface + struct + CRUD 方法
user_model.go ← 仅生成一次,Safe to edit:custom struct + 构造函数
vars.go ← 仅生成一次:ErrNotFound 等错误变量

user_model_gen.go 包含完整的 interface 定义、defaultUserModel 结构体、以及 Insert/FindOne/FindOneByXxx/Update/Delete 方法。user_model.go 是按 “MaybeCreateFile” 策略仅生成一次的文件,你可以在这里添加自定义方法。这和 REST 的 handler/logic 文件的策略一脉相承。

部署生成:Docker、Kubernetes 与 Gateway

RPC 和 model 解决了"写什么代码"的问题,但一个完整的工程实践还需要回答"怎么部署"。goctl 提供了 docker、kube 和 gateway 三个命令,帮助你快速生成部署相关的文件。

Dockerfile 生成:多阶段构建的智能模板

goctl docker -go greet.go -port 8888 会根据你的 Go 文件路径和参数,生成一个多阶段构建的 Dockerfile(tools/goctl/docker/docker.go)。

生成逻辑有两个值得注意的智能判断:

自动查找配置文件findConfig 函数在 etc/ 目录中搜索 .yaml 文件,优先匹配与 Go 文件名相同的前缀(比如 greet.go 会匹配 etc/greet-api.yaml)。如果找到,CMD 指令会自动带上 -f etc/xxx.yaml;如果没找到,只复制二进制文件。

自动推导项目路径getFilePath 函数通过查找 go.mod 文件来推导项目的相对路径,避免使用绝对路径。

生成的 Dockerfile 采用标准的双阶段构建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
FROM golang:1.21-alpine AS builder           # 阶段一:编译
RUN apk add --no-cache tzdata
WORKDIR /build
ADD go.mod .; ADD go.sum .; RUN go mod download
COPY . .
RUN go build -ldflags="-s -w" -o /app/greet ./greet.go

FROM scratch # 阶段二:运行
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /usr/share/zoneinfo/Asia/Shanghai
WORKDIR /app
COPY --from=builder /app/greet /app/greet
COPY ./etc /app/etc
EXPOSE 8888
CMD ["./greet", "-f", "etc/greet-api.yaml"]

Kubernetes 部署清单:一站式生成

goctl kube deploy 是一个"输入参数、输出 YAML"的工具。它生成的是一份四合一的多文档 YAML(tools/goctl/kube/kube.go + deployment.tpl):

  • Deployment:包含资源限制、健康检查探针(TCP)、时区挂载和可选的 image pull secret。
  • Service:支持 ClusterIP 和 NodePort 两种类型,通过 --nodePort 参数切换。
  • HorizontalPodAutoscaler(CPU):基于 CPU 利用率的自动扩缩容,目标为 80%。
  • HorizontalPodAutoscaler(Memory):基于内存利用率的自动扩缩容,同样目标 80%。

你只需要提供几个必要参数:

1
goctl kube deploy -name greet -namespace default -image greet:v1 -o greet.yaml -port 8888

就能输出一份包含所有 Kubernetes 资源的 YAML 文件。这种"一份输入、四份输出"的设计非常务实——在微服务场景下,这四个资源总是配套使用的。

Gateway 骨架:一行命令搭起 HTTP-to-gRPC 网关

goctl gateway --dir gatewaytools/goctl/gateway/cmd.go)生成的是一个极简的 Gateway 入口。它只产出两个文件:

  • main.go:加载配置、创建 gateway.Server、启动。
  • etc/gateway.yaml:包含带注释的示例配置——如何定义 Upstream(直连或 etcd 发现),如何配置 ProtoSets(proto 描述文件路径),如何配置 Mappings(HTTP 路由到 gRPC 方法的映射)。

Gateway 的本质是一个 HTTP-to-gRPC 的协议转换网关。它的代码生成量很少——框架的 gateway 包已经封装了大部分逻辑,goctl 只需要生成启动代码和带注释的参考配置。

模板定制的边界:内建、覆盖与安全编辑

无论是 RPC、model 还是部署生成,goctl 的模板系统都遵循同一套机制:内建模板作为默认 + 用户模板覆盖 + 部分文件永不覆盖

模板加载的入口是 pathx.LoadTemplate(category, filename, builtin)tools/goctl/util/pathx/file.go),它先检查 ~/.goctl/<version>/<category>/<filename> 是否存在,存在就用用户的,否则用编译进二进制的内建模板。你可以通过 goctl template init 初始化模板目录,修改后重新执行生成命令即可。

对于生成的文件,有两类覆盖策略:

  • 始终覆盖(删了重建):routes、types、server、client、main 等。这些文件必须与定义文件保持同步。
  • 仅创建不覆盖MaybeCreateFile):logic、handler、config(RPC 的 config.go)、model.go(用户自定义部分)。这些文件是你写业务代码的地方。

理解这个边界很重要——它决定了你在修改生成代码时哪些可以放心改、哪些需要回到定义文件中修改。

总结:goctl 的统一生成范式

现在我们可以把本文和上一篇文章串起来,抽象出 goctl 的统一生成范式:

1
2
3
4
5
6
7
8
9
10
11
12
13
输入定义(.api / .proto / SQL / 命令行参数)


信息提取(ANTLR 解析 / emicklei 解析 / information_schema 查询 / 参数收集)


中间表示(spec.ApiSpec / parser.Proto / parser.Table / 模板数据结构体)


模板渲染(Go text/template + 内建/用户模板 + MaybeCreateFile 策略)


工程产出(Go 源文件 / YAML / Dockerfile)

无论是 REST 还是 RPC,无论是 model 还是 Kubernetes 清单,goctl 都在遵循这条"解析 → 中间表示 → 模板渲染的管线。差异只存在于"解析"这一步——不同的输入类型需要不同的解析器——但一旦进入中间表示阶段,后续的模板渲染和文件写入策略高度统一。

理解了这个统一范式后,你可以做到:

  • 自定义生成模板:通过修改 ~/.goctl/ 下的模板文件来控制生成代码的风格和结构。
  • 区分安全的编辑区域:logic、handler、model.go 是你的地盘,可以随意修改;而 routes、types、server 则需要回到定义文件去改。
  • 理解不同生成命令之间的关系goctl apigoctl rpc 生成的服务虽然通信协议不同,但它们的工程结构(config、svc、logic、handler/server)是统一的——这正是"统一编程模型"在工具链层面的体现。

至此,goctl 工具链的全部核心能力——API/REST、RPC、model、部署——我们都走了一遍。从下一篇开始,我们将转向框架的运行时:一个由 goctl 生成的工程,在 go run 之后是怎么启动的、配置是怎么加载的、多个服务是怎么协同启停的。