学习目标
学完本章你应该能够:
- 讲清 ORM 为什么需要
TableReference抽象来支持 JOIN 与子查询,能画出Table/Join/Subquery三种实现的关系。 - 对比 INNER / LEFT / RIGHT JOIN 的语义差异与执行原理(近似双重循环),能说清不匹配行各自怎么处理。
- 解释 GORM Preload 的本质是 JOIN,并讲清 WHERE / ON / HAVING 的取舍——为什么建议把 JOIN 条件放 ON 上。
- 说明 Protobuf 生成代码无法携带 orm tag 的原因,讲清"修改插件源码"这一侵入式方案的来龙去脉与局限。
- 描述 AST 代码生成的完整链路(parse → inspect → template → 生成文件),并讲清它和 Java 动态代理的本质区别。
前置知识:
- 前四章 ORM 基础:Selector 查询、元数据(反射 + 缓存)、事务、AOP 中间件。
- Go 接口、
fmt.Sprintf拼字符串、Builder 模式。 - Protobuf 基本用法(
.proto写 message、protoc生成 Go 代码)。
本章你会动手做的事:
- 用
orm.TableOf/Join/SubqueryOf拼一个"嵌套 JOIN + 子查询",打印出生成的 SQL。 - 跑一遍文中的 AST 示例,把你自己的 model 结构体变成
user_gen.go。 - 把 GORM 的
Preload改写成显式JOIN,对比二者生成的 SQL 是否一致。
一、复杂查询 —— JOIN 与子查询
到目前为止,我们的 ORM 已经支持了基本的增删改查、事务、AOP 等功能。但在实际业务中,单表查询往往不够用——我们需要 JOIN(联表查询)和子查询。这一章我们就来解决复杂查询的问题。
1.1 SQL JOIN 语法分析
JOIN 查询有点像我们的 Expression——可以查询套查询,无限套下去。让我们先看看不同数据库的 JOIN 语法。
类比:JOIN 就像查两本通讯录。你有一本"用户录"(user),一本"订单录"(order)。
INNER JOIN是只保留两本里都出现的人;LEFT JOIN是左边全保留,右边没匹配到的空着(NULL);RIGHT JOIN反过来。而"FROM 后面可以套查询",就像你可以先临时拼出一本小册子(子查询),再拿它去和别册 JOIN。
下面这张图展示了 TableReference 的三种实现,以及它们如何互相嵌套——这正是我们 ORM 要抽象的核心:
flowchart LR
T[TableReference 接口] --> A[Table 普通表]
T --> B[Join 连接查询]
T --> C[Subquery 子查询]
B -->|left 可以是| T
C -->|内部持有| SEL[Selector 子查询]MySQL JOIN 语法
-- INNER JOIN(内连接):只返回两表匹配的行
SELECT * FROM user u
INNER JOIN order o ON u.id = o.user_id;
-- LEFT JOIN(左连接):返回左表所有行,右表不匹配的为 NULL
SELECT * FROM user u
LEFT JOIN order o ON u.id = o.user_id;
-- RIGHT JOIN(右连接):返回右表所有行,左表不匹配的为 NULL
SELECT * FROM user u
RIGHT JOIN order o ON u.id = o.user_id;
-- USING 语法:当两表列名相同时可以用 USING
SELECT * FROM user u
INNER JOIN order o USING (user_id);
SQLite 和 PostgreSQL
SQLite 和 PostgreSQL 的 JOIN 语法与 MySQL 基本一致,都支持 INNER JOIN、LEFT JOIN、RIGHT JOIN,以及 ON 和 USING 两种条件语法。
1.2 JOIN 语法总结
JOIN 语法有两种形态:
├── JOIN ... ON ← ON 后面跟条件表达式(如 u.id = o.user_id)
└── JOIN ... USING ← USING 后面跟列名(如 USING(user_id))
JOIN 本身有:
├── INNER JOIN / JOIN ← 内连接,只返回匹配的行
├── LEFT JOIN ← 左连接,左表全保留
└── RIGHT JOIN ← 右连接,右表全保留
FROM 后面可以是:
├── 普通的表 ← SELECT * FROM user
└── JOIN 查询 ← SELECT * FROM user JOIN order ON ...
└── JOIN 可以嵌套 ← ... JOIN order ON ... JOIN item ON ...
1.3 主流 ORM 的 JOIN 设计
Beego ORM
Beego 的 JOIN 查询主要出现在两个地方:
- QueryBuilder:提供了
InnerJoin、LeftJoin、RightJoin三个方法 - 关联关系:Beego 支持一对一、一对多、多对多关联,设置正确后会自动生成 JOIN 查询
// Beego 的 QueryBuilder JOIN
qb, _ := orm.NewQueryBuilder("mysql")
qb.Select("u.name", "o.id").
From("user", "u").
InnerJoin("order", "o").On("u.id = o.user_id").
Where("u.age > ?", 18)
sql := qb.String()
观点:关联关系不是 ORM 框架应该处理的,而应该是领域对象(Repository 层面)处理的。ORM 只需要提供 JOIN 的能力,怎么用是用户的自由。
GORM
GORM 中 JOIN 查询主要是为了 Preload(预加载)服务:
// GORM 的 Preload
// 本质上是一个 JOIN 查询,把相关数据都查出来并组装成结构体
db.Preload("Orders").Find(&users)
// 会生成类似:SELECT * FROM users JOIN orders ON ...
面试要点:GORM 的 Preload 本质上就是一个 JOIN 查询。严格来说,在 Go 语言里面很难实现 lazy load(延迟加载),所以 GORM 的 Preload 就是通过 Join 把相关数据一次性查询出来并组装成结构体。
1.4 TableReference 抽象
为了支持 JOIN 和子查询,我们需要一个抽象——TableReference。FROM 后面不再只能接表名,而是可以接任何 TableReference:
// table.go 文件
// TableReference 是 FROM 子句的抽象接口
// 它有三种实现:
// 1. Table:普通的表
// 2. Join:JOIN 查询
// 3. Subquery:子查询
type TableReference interface {
// tableReference 是一个标记方法
// 只有实现了此接口的类型才能用于 FROM 子句
tableReference()
}
// ========== Table 普通表 ==========
// Table 代表一个普通的表
// 它也是 JOIN 查询的起点
type Table struct {
// name 表名
name string
// alias 表别名(AS)
// 例如:FROM user u 中的 u
alias string
}
// TableOf 创建一个 Table 实例
// 用法:orm.TableOf("user") 或 orm.TableOf("user", "u")
func TableOf(name string, alias ...string) *Table {
t := &Table{name: name}
if len(alias) > 0 {
t.alias = alias[0]
}
return t
}
// tableReference 实现 TableReference 接口
func (t *Table) tableReference() {}
// As 设置表别名
// 返回新对象,支持链式调用
func (t *Table) As(alias string) *Table {
return &Table{
name: t.name,
alias: alias,
}
}
1.5 JoinBuilder 和 Join 定义
JOIN 的设计理念与 OnDuplicateKeyBuilder 一致——使用 Builder 模式,On 和 Using 是终结方法,直接返回 Join:
// join.go 文件
// Join 代表一个 JOIN 查询
// JOIN 可以进一步 JOIN,形成嵌套
type Join struct {
// typ JOIN 类型:"INNER"、"LEFT"、"RIGHT"
typ string
// table 被连接的表
table TableReference
// using USING 子句的列名列表(和 cond 互斥)
using []string
// cond ON 子句的条件表达式(和 using 互斥)
cond Expression
// left JOIN 的左操作数(用于嵌套 JOIN)
left TableReference
}
// tableReference 实现 TableReference 接口
// 这样 Join 本身也可以作为 FROM 的输入
func (j *Join) tableReference() {}
// ========== JoinBuilder ==========
// JoinBuilder 用于构建 JOIN 子句
// 设计理念与 OnDuplicateKeyBuilder 一致
type JoinBuilder struct {
// typ JOIN 类型
typ string
// table 被连接的表
table TableReference
}
// Join 创建一个 JoinBuilder
// 用法:orm.Join(orm.TableOf("order", "o"))
func Join(table TableReference) *JoinBuilder {
return &JoinBuilder{
typ: "INNER", // 默认 INNER JOIN
table: table,
}
}
// LeftJoin 创建一个 LEFT JOIN
func LeftJoin(table TableReference) *JoinBuilder {
return &JoinBuilder{
typ: "LEFT",
table: table,
}
}
// RightJoin 创建一个 RIGHT JOIN
func RightJoin(table TableReference) *JoinBuilder {
return &JoinBuilder{
typ: "RIGHT",
table: table,
}
}
// On 是终结方法,使用 ON 条件
// 返回 Join,不能再调用 Using
// 这种设计可以避免用户同时调用 On 和 Using
func (j *JoinBuilder) On(cond Expression) *Join {
return &Join{
typ: j.typ,
table: j.table,
cond: cond,
}
}
// Using 是终结方法,使用 USING 列名
// 返回 Join,不能再调用 On
func (j *JoinBuilder) Using(cols ...string) *Join {
return &Join{
typ: j.typ,
table: j.table,
using: cols,
}
}
JOIN 嵌套
JOIN 本身也可以进一步 JOIN,所以我们需要在 Join 上定义类似的方法:
// join.go 文件(续)
// Join 在当前 Join 的基础上继续 INNER JOIN
// 支持链式调用,形成嵌套 JOIN
func (j *Join) Join(table TableReference) *JoinBuilder {
return &JoinBuilder{
typ: "INNER",
// 注意:当前 Join 作为左操作数
table: table,
}
}
func (j *Join) LeftJoin(table TableReference) *JoinBuilder {
return &JoinBuilder{
typ: "LEFT",
table: table,
}
}
func (j *Join) RightJoin(table TableReference) *JoinBuilder {
return &JoinBuilder{
typ: "RIGHT",
table: table,
}
}
// 使用示例:嵌套 JOIN
// SELECT * FROM user u
// INNER JOIN order o ON u.id = o.user_id
// LEFT JOIN item i ON o.id = i.order_id
query := orm.NewSelector[User](db).
From(orm.TableOf("user", "u")).
Join(orm.TableOf("order", "o")).
On(orm.C("u.Id").EQ(orm.C("o.UserId"))).
LeftJoin(orm.TableOf("item", "i")).
On(orm.C("o.Id").EQ(orm.C("i.OrderId")))
1.6 重构 Selector
引入 TableReference 后,我们需要重构 Selector 的 From 方法:
// selector.go 文件
// Selector 是 SELECT 语句的构造器
type Selector[T any] struct {
core
session Session
// table FROM 子句
// 从原来的 string 改为 TableReference
// 这样可以支持 JOIN 和子查询
table TableReference
// selects 指定的列
selects []Selectable
// where WHERE 条件
where []Expression
// ... 其它字段
}
// From 指定 FROM 子句
// 参数从 string 改为 TableReference
// 兼容旧用法:orm.TableOf("user") 传给 From
func (s *Selector[T]) From(table TableReference) *Selector[T] {
s.table = table
return s
}
构建 FROM 子句
// buildFrom 构建 FROM 子句
func (s *Selector[T]) buildFrom(sb *strings.Builder) error {
// 使用类型断言判断是哪种 TableReference
switch t := s.table.(type) {
case nil:
// 用户没有调用 From 方法
// 使用模型默认的表名
sb.WriteString(s.model.TableName)
case *Table:
// 普通表
s.buildTable(sb, t)
case *Join:
// JOIN 查询
if err := s.buildJoin(sb, t); err != nil {
return err
}
}
return nil
}
// buildTable 构建表引用
func (s *Selector[T]) buildTable(sb *strings.Builder, t *Table) {
sb.WriteString(t.name)
if t.alias != "" {
sb.WriteString(" AS ")
sb.WriteString(t.alias)
}
}
// buildJoin 构建 JOIN 子句(支持嵌套)
func (s *Selector[T]) buildJoin(sb *strings.Builder, j *Join) error {
// 1. 先构建左操作数(可能是 Table 或 Join)
switch left := j.left.(type) {
case *Table:
s.buildTable(sb, left)
case *Join:
// 递归构建嵌套 JOIN
if err := s.buildJoin(sb, left); err != nil {
return err
}
}
// 2. 构建 JOIN 类型和右表
switch j.typ {
case "INNER":
sb.WriteString(" JOIN ")
case "LEFT":
sb.WriteString(" LEFT JOIN ")
case "RIGHT":
sb.WriteString(" RIGHT JOIN ")
}
// 3. 构建右表
switch right := j.table.(type) {
case *Table:
s.buildTable(sb, right)
case *Join:
// 子 JOIN 需要括号包裹
sb.WriteString("(")
if err := s.buildJoin(sb, right); err != nil {
return err
}
sb.WriteString(")")
}
// 4. 构建条件(ON 或 USING,互斥)
if len(j.using) > 0 {
sb.WriteString(" USING(")
for i, col := range j.using {
if i > 0 {
sb.WriteString(", ")
}
sb.WriteString(col)
}
sb.WriteString(")")
} else if j.cond != nil {
sb.WriteString(" ON ")
// 构建条件表达式
if err := s.buildExpr(sb, j.cond); err != nil {
return err
}
}
return nil
}
1.7 列校验逻辑改造
在支持 JOIN 之前,列校验很简单——只需要看操作的元数据里面有没有这个列。支持 JOIN 之后,列可能有一个拥有者(owner)——例如 t1.col1,其中 t1 就是 col1 的拥有者。
⚠️ 新手必踩的坑:这里的列校验是"简化版"。文中
validateColumn在owner != ""时直接return nil,并没有真去TableReference里反查该表到底有没有这个列。真实生产 ORM 要遍历所有 JOIN 涉及的表元数据逐个校验,否则拼错orm.C("u.Name")这类列名只能等到数据库报错才发现。
// selector.go 文件(续)
// buildColumn 构建列引用,支持表前缀
func (s *Selector[T]) buildColumn(sb *strings.Builder, c Column, withAlias bool) error {
// 1. 如果用户指定了表前缀(owner)
// 例如 orm.C("u.Id") 中的 "u"
if c.owner != "" {
// 在 JOIN 场景下,需要验证 owner 是否是某个表的别名
// 这里简化处理,直接输出
sb.WriteString(c.owner)
sb.WriteString(".")
}
// 2. 查找列名
if fd, ok := s.model.FieldMapByGoName[c.name]; ok {
// 找到了字段,输出列名
sb.WriteString(fd.ColName)
} else {
// 没找到,可能是用户写的原始列名
sb.WriteString(c.name)
}
// 3. 输出别名(如果有)
if withAlias && c.alias != "" {
sb.WriteString(" AS ")
sb.WriteString(c.alias)
}
return nil
}
// Column 结构体增加 owner 字段
type Column struct {
name string // 字段名
alias string // 别名
owner string // 表前缀(owner),如 "u.Id" 中的 "u"
}
// C 支持 owner 的列构造
// 用法:
// orm.C("Id") → 输出 id
// orm.C("u", "Id") → 输出 u.id
func C(owner string, name ...string) Column {
if len(name) > 0 {
return Column{owner: owner, name: name[0]}
}
return Column{name: owner}
}
校验逻辑
支持 JOIN 后的列校验逻辑:
如果用户指定了表(owner):
└── 检查指定的表上有没有这个列
├── 有 → 正常输出 owner.col
└── 没有 → 报错:未知列
如果没有指定表:
└── 走老逻辑(case nil 分支)
└── 在当前模型的元数据中查找
// validateColumn 校验列是否有效
func (s *Selector[T]) validateColumn(c Column) error {
if c.owner != "" {
// 用户指定了表前缀
// 需要检查该表是否有此列
// 这里需要从 TableReference 中找到对应的表元数据
// 简化处理:如果 JOIN 涉及多个表,需要查找所有表
// 实际实现会更复杂
return nil
}
// 没有指定表,走老逻辑
_, ok := s.model.FieldMapByGoName[c.name]
if !ok {
return errs.NewErrUnknownField(c.name)
}
return nil
}
1.8 子查询
TableReference 的第三种实现是子查询。子查询也可以用来构造 JOIN 查询,或者直接用于 FROM 子句:
类比:子查询就像一个"临时视图"。你不想每次都 JOIN 一大张表,就先写一条
SELECT ... WHERE age > 18当成一个临时小册子(子查询),给它起个别名sub,外层再对sub继续筛选。对 ORM 来说,子查询和表是同一类东西(TableReference),只是它内部还抱着一个Selector。
下面这张图展示子查询作为 FROM 的用法:外层 SELECT 直接查一个"查询出来的结果集"。
flowchart LR
O[外层 SELECT] -->|FROM| SQ[(子查询 sub)]
SQ -->|内部持有| SEL[Selector
SELECT id,name FROM user WHERE age>18]// subquery.go 文件
// Subquery 代表一个子查询
type Subquery struct {
// selector 子查询的 Selector
selector *Selector[any]
// alias 子查询的别名
alias string
}
// tableReference 实现 TableReference 接口
func (s *Subquery) tableReference() {}
// SubqueryOf 创建一个子查询
// 用法:orm.SubqueryOf(selector, "sub")
func SubqueryOf(selector *Selector[any], alias string) *Subquery {
return &Subquery{
selector: selector,
alias: alias,
}
}
// As 设置子查询别名
func (s *Subquery) As(alias string) *Subquery {
return &Subquery{
selector: s.selector,
alias: alias,
}
}
// 使用示例:子查询作为 FROM
// SELECT * FROM (
// SELECT id, name FROM user WHERE age > 18
// ) AS sub WHERE sub.id > 100
subQuery := orm.NewSelector[User](db).
Select(orm.C("Id"), orm.C("Name")).
From(orm.TableOf("user")).
Where(orm.C("Age").GT(18))
result, err := orm.NewSelector[User](db).
From(orm.SubqueryOf(subQuery, "sub")).
Where(orm.C("sub.Id").GT(100)).
GetMulti(ctx)
1.9 WHERE vs ON vs HAVING
| 子句 | 作用位置 | 说明 |
|---|---|---|
| WHERE | 在分组前过滤 | 对所有行生效,在 GROUP BY 之前执行 |
| ON | JOIN 条件 | 控制 JOIN 时哪些行匹配,影响中间结果集大小 |
| HAVING | 在分组后过滤 | 对分组后的结果生效,在 GROUP BY 之后执行 |
面试要点:在 JOIN 查询里,一般建议尽量把条件放到 ON 上面,这样 JOIN 生成的中间数据要少很多,性能更好。
下面这张图把 WHERE / ON / HAVING 在"查询流水线"上的作用位置标出来,帮助理解为什么条件越早过滤越好:
flowchart LR
J[JOIN 两表
按 ON 条件匹配] --> W[WHERE
分组前过滤行]
W --> G[GROUP BY 分组] --> H[HAVING
分组后过滤]1.10 JOIN 执行原理
JOIN 的执行原理可以近似理解为一个双重循环:
Nested Loop Join(嵌套循环连接):
for each row r1 in left_table: ← 遍历左表
for each row r2 in right_table: ← 遍历右表
if r1.key == r2.key: ← 检查匹配条件
output(r1, r2) ← 输出匹配的行
优化:
├── Index Nested Loop Join:右表有索引,内层循环变成索引查找
├── Block Nested Loop Join:把左表分块,减少右表扫描次数
└── Hash Join:用哈希表替代内层循环(MySQL 8.0+)
下面这张图把"嵌套循环 JOIN"的匹配过程画出来:外层遍历左表,内层遍历右表,匹配 key 才输出一行。
flowchart TD
L[遍历左表每行 r1] --> R[遍历右表每行 r2]
R -->|r1.key == r2.key| O[输出 r1,r2 组合行]
R -->|不匹配| N[丢弃 或 填 NULL]面试要点:JOIN 的执行原理算是热门面试题,可以近似理解为一个双重循环。不同 JOIN 类型的区别在于对不匹配行的处理:INNER JOIN 丢弃,LEFT JOIN 保留左表行(右表填 NULL),RIGHT JOIN 保留右表行(左表填 NULL)。
二、Protobuf 集成
类比:Protobuf 生成的 Go 代码就像工厂流水线批量印出来的表格,每张都长一样、只印了它该印的字段(protobuf / json tag)。我们想额外在表格上加一行"数据库列名",但流水线模具(插件)不支持——于是要么改模具(改插件源码),要么另想办法。本文走的是"改模具"这条侵入式路线。
下面这张图是定制 Protobuf 插件的工作流:写 .proto → 用改过的插件 protoc 生成带 orm tag 的代码 → ORM 直接复用。
flowchart LR
P[user.proto] -->|protoc + 定制插件| G[user.pb.go
带 orm column tag]
G -->|ORM 读取 tag| O[找到列名 user_name]2.1 Protobuf 的问题
在元数据设计中,我们利用 Go 的 Tag(标签)来指定列名:
// 普通结构体:可以用 Tag 指定列名
type User struct {
Name string `orm:"column=user_name"`
}
但 Protobuf 生成的代码没有自定义 Tag的能力:
// user.proto
message User {
string name = 1; // Protobuf 生成代码时不会加 orm tag
}
Protobuf 生成的 Go 代码类似这样:
// protoc 自动生成的代码(无法修改)
type User struct {
// 只有 protobuf 的 tag,没有 orm 的 tag
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
}
我们希望能够达到这样的效果——让 Protobuf 生成的代码也能携带 ORM 的列名信息。
2.2 Protobuf 的局限性
Protobuf 虽然暴露了插件机制,但插件不能修改生成的 Go 代码——插件只能自己额外生成一些代码。
所以我们实际上不能直接利用 Protobuf 的插件机制来给字段添加 ORM Tag。
2.3 修改 Protobuf Go 插件
既然插件不能修改生成的代码,那我们只能走一条侵入式的路——修改 protobuf Go 插件的源码:
步骤:
1. clone 原本的 protobuf Go 代码库(google/protobuf-go)
2. 修改 protobuf Go 的代码
└── 找到生成 json 标签的位置,插入我们的 orm 标签
3. 安装修改后的 Go 插件
4. 执行 protoc 命令生成代码
为什么这种方法可行
我们知道 Protobuf 必然会生成 json 的标签:
// 原本生成的代码
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
所以我们只需要找到生成 json 标签的位置,然后插入我们的代码:
// 修改插件后生成的代码
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty" orm:"column=user_name"`
修改示例
// 以下是 protobuf Go 插件源码中修改的核心逻辑(简化版)
// 原始代码(google/protobuf-go 内部)
func (g *generator) generateField(field *descriptor.FieldDescriptorProto) {
// ... 原始逻辑 ...
// 生成 json tag
tag := fmt.Sprintf(`protobuf:"%s" json:"%s,omitempty"`,
protobufTag, jsonName)
g.P(field.GoName, " ", field.GoType, " `", tag, "`")
}
// 修改后的代码
func (g *generator) generateField(field *descriptor.FieldDescriptorProto) {
// ... 原始逻辑 ...
// 生成 json tag
tag := fmt.Sprintf(`protobuf:"%s" json:"%s,omitempty"`,
protobufTag, jsonName)
// ===== 新增:添加 orm tag =====
// 将字段名转为下划线命名作为列名
ormColName := underscoreName(field.GoName)
tag = tag + fmt.Sprintf(` orm:"column=%s"`, ormColName)
// ==============================
g.P(field.GoName, " ", field.GoType, " `", tag, "`")
}
注意:这是一种侵入式的修改方案,不过我们别无选择。你们公司可以维护一个自己定制过的 Protobuf Go 插件。
关于 Field Option
Protobuf 有一个 field option 扩展机制,理论上可以用来添加额外的属性:
// 自定义 field option
import "orm/options.proto";
message User {
// 希望通过 option 指定列名
string name = 1 [(orm.column) = "user_name"];
}
但实际测试后发现,field option 的值在生成 Go 代码时无法直接写入到 Tag 中,所以最终还是只能用修改插件源码的方案。
⚠️ 新手必踩的坑:别迷信 field option 能省事。很多人第一反应是用
[(orm.column) = "..."]这种扩展字段来传列名,但 Protobuf 的设计决定这些 option 值只能在运行时通过反射 API 读取,无法在代码生成阶段写进 Go 的 struct tag。实测走不通,直接上"改插件源码"更靠谱。
2.4 使用定制 Protobuf 的工作流
开发流程:
1. 编写 .proto 文件
┌─────────────────┐
│ user.proto │
│ message User { │
│ string name=1;│
│ } │
└────────┬────────┘
│
2. 执行 protoc 命令(使用定制插件)
│
▼
┌──────────────────────────────────────┐
│ user.pb.go(自动生成) │
│ type User struct { │
│ Name string `protobuf:"..." │
│ json:"name,omitempty" │
│ orm:"column=name"` ← 定制插件添加 │
│ } │
└──────────────────────────────────────┘
│
3. 在 ORM 中直接使用 Protobuf 生成的结构体
│
▼
┌──────────────────────────────────────┐
│ db, _ := orm.Open("mysql", dsn) │
│ user, _ := orm.NewSelector[User](db)│
│ .Where(orm.C("Name").EQ("大明")) │
│ .Get(ctx) │
│ // ORM 通过 orm tag 找到列名 "name" │
└──────────────────────────────────────┘
三、代码生成辅助方法
3.1 为什么需要代码生成
我们的 Predicate 设计有一些缺陷,这些缺陷是可以通过代码生成来改进的:
// 当前的写法:用户需要手写字段名字符串
// 容易写错,没有编译期检查
users, err := orm.NewSelector[User](db).
Where(orm.C("FirstName").EQ("大明")). // "FirstName" 是字符串,写错了编译器不会报错
Get(ctx)
// 期望的写法:通过代码生成,提供类型安全的常量和方法
users, err := orm.NewSelector[User](db).
Where(UserColumns.FirstName.EQ("大明")). // 编译器能检查 FirstName 是否存在
Get(ctx)
我们需要生成两类东西:
- 字段名的常量:避免手写字符串
- Predicate 辅助方法:简化查询条件的构造
3.2 生成目标代码
// ================================
// 原始模型文件:model/user.go
// ================================
package model
type User struct {
Id int64
FirstName string
LastName string
Age int
}
// ================================
// 代码生成的文件:model/user_gen.go
// ================================
// 注意:这个文件是自动生成的,不要手动修改
package model
import "orm"
// UserColumns 包含 User 的所有字段常量
// 使用常量避免手写字符串,提供编译期检查
var UserColumns = struct {
Id orm.Column
FirstName orm.Column
LastName orm.Column
Age orm.Column
}{
Id: orm.Column{name: "Id"},
FirstName: orm.Column{name: "FirstName"},
LastName: orm.Column{name: "LastName"},
Age: orm.Column{name: "Age"},
}
// UserPredicates 提供 User 的 Predicate 快捷构造方法
var UserPredicates = struct {
Id func(val any) orm.Predicate
FirstName func(val any) orm.Predicate
LastName func(val any) orm.Predicate
Age func(val any) orm.Predicate
}{
Id: func(val any) orm.Predicate { return orm.C("Id").EQ(val) },
FirstName: func(val any) orm.Predicate { return orm.C("FirstName").EQ(val) },
LastName: func(val any) orm.Predicate { return orm.C("LastName").EQ(val) },
Age: func(val any) orm.Predicate { return orm.C("Age").EQ(val) },
}
// 使用生成的辅助方法
// 比手写字符串更安全、更方便
// 使用列常量
users, err := orm.NewSelector[User](db).
Where(UserColumns.FirstName.EQ("大明")).
Get(ctx)
// 使用 Predicate 辅助方法
users, err = orm.NewSelector[User](db).
Where(UserPredicates.Age(28)).
Get(ctx)
// 组合使用
users, err = orm.NewSelector[User](db).
Where(UserPredicates.FirstName("大明").
And(UserPredicates.Age(28))).
Get(ctx)
3.3 什么是 AST
AST(Abstract Syntax Tree,抽象语法树)是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。
源代码: AST 树:
type User struct { StructType
Name string ├── Fields
Age int │ ├── Field: Name string
} │ └── Field: Age int
└── Name: "User"
重要:并发、反射和 AST 三者是构建复杂系统(中间件)的基石,是真正的高级工具。
3.4 AST 编程的应用场景
| 用途 | 说明 | 典型项目 |
|---|---|---|
| 篡改源码 | 修改 AST 来改变源码行为 | - |
| 代码生成 | Go 没有动态代理,代码生成大行其道 | GORM Gen、ENT |
| 依赖注入 | 编译时生成依赖注入代码 | Google Wire |
| 语法检查 | 静态分析工具 | golangci-lint |
| 格式化 | 代码格式化工具 | gofmt |
| 自动补全 | IDE 智能提示 | gopls |
3.5 AST 编程基本步骤
// ast_gen.go 文件
package main
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
)
func main() {
// ========== 步骤 1:创建 FileSet ==========
// FileSet 用于记录源代码的位置信息
// 每个 AST 节点都关联一个位置,FileSet 管理这些位置
fset := token.NewFileSet()
// ========== 步骤 2:解析源文件 ==========
// parser.ParseFile 将 Go 源文件解析为 AST
// 参数:
// fset: 位置信息管理器
// filename: 文件路径(可以为空)
// src: 源代码内容
// mode: 解析模式(控制解析的详细程度)
f, err := parser.ParseFile(fset, "user.go", nil, parser.ParseComments)
if err != nil {
panic(err)
}
// ========== 步骤 3:实现 ast.Visitor 接口 ==========
// Visitor 用于遍历 AST 树
visitor := &structVisitor{}
// ========== 步骤 4:遍历 AST ==========
// ast.Walk 从根节点开始遍历整棵 AST 树
ast.Walk(visitor, f)
}
// structVisitor 实现了 ast.Visitor 接口
// 用于遍历 AST 并查找结构体定义
type structVisitor struct {
// 记录找到的结构体信息
structs []structInfo
}
// structInfo 存储解析到的结构体信息
type structInfo struct {
Name string // 结构体名
Fields []fieldInfo // 字段列表
}
// fieldInfo 存储字段信息
type fieldInfo struct {
Name string // 字段名
Type string // 字段类型
}
// Visit 实现 ast.Visitor 接口
// 每遍历到一个 AST 节点都会调用此方法
// 返回值:如果返回非 nil,则继续遍历子节点;返回 nil 则跳过子节点
func (v *structVisitor) Visit(node ast.Node) ast.Visitor {
// node 可能为 nil(遍历结束时)
if node == nil {
return nil
}
// 使用类型断言判断节点类型
switch n := node.(type) {
case *ast.TypeSpec:
// TypeSpec 代表类型声明(type xxx ...)
// 检查是否是结构体
if structType, ok := n.Type.(*ast.StructType); ok {
info := structInfo{
Name: n.Name.Name, // 结构体名
}
// 遍历结构体的字段
for _, field := range structType.Fields.List {
// 一个 field 可能对应多个名字(如 Name, Email string)
for _, name := range field.Names {
info.Fields = append(info.Fields, fieldInfo{
Name: name.Name,
// 获取类型名(简化处理)
Type: fmt.Sprintf("%v", field.Type),
})
}
}
v.structs = append(v.structs, info)
}
}
// 返回自身,继续遍历子节点
return v
}
3.6 Visitor 设计模式详解
Visitor(访问者)设计模式常用于遍历复杂的数据结构,典型的就是树形结构。
Visitor 模式的核心思想:
对于 Node(节点):
└── 接收 Visitor,在实现中调用 Visitor.Visit(node)
对于 Visitor(访问者):
└── 接收 Node,做类型判断,判断能不能处理该 Node
遍历过程:
ast.Walk(visitor, rootNode)
│
▼
rootNode.Accept(visitor)
│
├── visitor.Visit(rootNode) → 返回 childVisitor
│ │
└── for each child: ▼
ast.Walk(childVisitor, child)
│
└── child.Accept(childVisitor)
│
└── ...继续递归...
Go AST 的 Visitor 特殊之处
// ast.Visitor 接口定义
type Visitor interface {
// Visit 方法接收一个 Node
// 返回值也是一个 Visitor——用于进一步遍历子节点
// 如果返回 nil,则不再遍历子节点
Visit(node Node) (w Visitor)
}
// ast.Walk 的简化实现
func Walk(v Visitor, node Node) {
if node == nil {
return
}
// 调用 Visitor 的 Visit 方法
// 如果返回的 Visitor 不为 nil,则继续遍历子节点
if v = v.Visit(node); v == nil {
return // 返回 nil,停止遍历子节点
}
// 根据节点类型,遍历所有子节点
switch n := node.(type) {
case *ast.File:
// 遍历文件中的所有声明
for _, decl := range n.Decls {
Walk(v, decl)
}
case *ast.GenDecl:
// 遍历通用声明(type、var、const、import)
for _, spec := range n.Specs {
Walk(v, spec)
}
case *ast.TypeSpec:
// 遍历类型声明的类型部分
Walk(v, n.Type)
case *ast.StructType:
// 遍历结构体的字段
for _, field := range n.Fields.List {
Walk(v, field)
}
// ... 还有几十种节点类型
}
// 遍历完毕,调用 Visit(nil) 通知 Visitor
v.Visit(nil)
}
3.7 AST 节点类型
ast.Node 接口代表了 AST 的节点。任何一个 Go 语句在 AST 里面都代表一个节点。有三个关键子接口:
ast.Node(根接口)
├── ast.Expr(表达式)
│ ├── 基本类型:int, string, bool
│ ├── 方法调用:foo.Bar()
│ ├── 数组索引:arr[0]
│ ├── 二元运算:a + b
│ └── ...(约 30 种)
├── ast.Stmt(语句)
│ ├── 赋值语句:a = b
│ ├── if 语句:if x { ... }
│ ├── for 语句:for { ... }
│ └── ...(约 20 种)
└── ast.Decl(声明)
├── GenDecl:通用声明(type、var、const、import)
└── FuncDecl:方法/函数声明
提示:不需要死记这些类型。写代码的时候再去看具体的实现,因为总共有 69 种实现。
GenDecl —— 通用声明
// GenDecl 代表通用声明
// 通过 Tok 字段判断具体是什么声明:
// token.TYPE → 类型声明 (type)
// token.VAR → 变量声明 (var)
// token.CONST → 常量声明 (const)
// token.IMPORT → 导入声明 (import)
type GenDecl struct {
Doc *ast.CommentGroup // 文档注释
TokPos token.Pos // 关键字位置
Tok token.Token // 关键字:type、var、const、import
Lparen token.Pos // 左括号位置(如果有的话)
Specs []ast.Spec // 具体声明内容
Rparen token.Pos // 右括号位置
}
FuncDecl —— 方法声明
// FuncDecl 代表方法/函数声明
type FuncDecl struct {
Doc *ast.CommentGroup // 文档注释
Recv *ast.FieldList // 接收器(只有方法有,普通函数为 nil)
Name *ast.Ident // 方法名
Type *ast.FuncType // 方法签名(包含泛型参数、参数、返回值)
Body *ast.BlockStmt // 方法体
}
StructType —— 结构体声明
// StructType 代表一个结构体声明
type StructType struct {
Struct token.Pos // struct 关键字位置
Fields *ast.FieldList // 字段列表
// 注意:Fields 里面找不到定义在该结构体上的方法
// 方法声明在 ast.File 的 Decls 里(作为 FuncDecl)
}
// ast.FieldList 是字段列表
type FieldList struct {
Opening token.Pos // 左括号位置
List []*Field // 字段列表
Closing token.Pos // 右括号位置
}
// ast.Field 代表一个字段
type Field struct {
Doc *ast.CommentGroup // 文档注释
Names []*Ident // 字段名(可能多个,如 Name, Email string)
Type Expr // 字段类型
Tag *BasicLit // Tag(标签)
Comment *ast.CommentGroup // 行注释
}
3.8 代码生成实现
现在我们把 AST 编程和模板结合起来,实现代码生成:
// gen/main.go 文件
package main
import (
"bytes"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"strings"
"text/template"
)
// ========== 数据结构 ==========
// StructInfo 存储解析到的结构体信息
type StructInfo struct {
Package string // 包名
Name string // 结构体名
Fields []FieldInfo // 字段列表
}
// FieldInfo 存储字段信息
type FieldInfo struct {
Name string // 字段名(Go 字段名)
Type string // 字段类型
}
// ========== AST 解析器 ==========
// Parser 解析 Go 源文件,提取结构体信息
type Parser struct {
structs []StructInfo
pkgName string
}
// ParseFile 解析指定的 Go 源文件
func (p *Parser) ParseFile(filename string) error {
// 1. 创建 FileSet
fset := token.NewFileSet()
// 2. 解析源文件
f, err := parser.ParseFile(fset, filename, nil, parser.ParseComments)
if err != nil {
return err
}
// 3. 记录包名
p.pkgName = f.Name.Name
// 4. 遍历 AST
ast.Inspect(f, func(node ast.Node) bool {
// ast.Inspect 是 ast.Walk 的简化版
// 返回 true 继续遍历子节点,返回 false 跳过
// 查找类型声明
typeSpec, ok := node.(*ast.TypeSpec)
if !ok {
return true
}
// 检查是否是结构体
structType, ok := typeSpec.Type.(*ast.StructType)
if !ok {
return true
}
// 提取结构体信息
info := StructInfo{
Package: p.pkgName,
Name: typeSpec.Name.Name,
}
// 遍历字段
for _, field := range structType.Fields.List {
// 跳过非导出字段
for _, name := range field.Names {
if !name.IsExported() {
continue
}
info.Fields = append(info.Fields, FieldInfo{
Name: name.Name,
Type: exprToString(field.Type),
})
}
}
p.structs = append(p.structs, info)
return true
})
return nil
}
// exprToString 将 AST 表达式转为类型字符串
// 例如:*ast.Ident → "string",*ast.SelectorExpr → "time.Time"
func exprToString(expr ast.Expr) string {
switch t := expr.(type) {
case *ast.Ident:
// 简单类型,如 string、int
return t.Name
case *ast.SelectorExpr:
// 包名.类型,如 time.Time
return exprToString(t.X) + "." + t.Sel.Name
case *ast.StarExpr:
// 指针类型,如 *string
return "*" + exprToString(t.X)
default:
return fmt.Sprintf("%v", expr)
}
}
// ========== 代码生成 ==========
// 生成模板
const genTemplate = `// Code generated by ORM gen. DO NOT EDIT.
package {{.Package}}
import "orm"
{{range .Structs}}
// {{.Name}}Columns 包含 {{.Name}} 的所有字段常量
var {{.Name}}Columns = struct {
{{range .Fields}} {{.Name}} orm.Column
{{end}}}{
{{range .Fields}} {{.Name}}: orm.Column{name: "{{.Name}}"},
{{end}}}
// {{.Name}}Predicates 提供 {{.Name}} 的 Predicate 快捷构造方法
var {{.Name}}Predicates = struct {
{{range .Fields}} {{.Name}} func(val any) orm.Predicate
{{end}}}{
{{range .Fields}} {{.Name}}: func(val any) orm.Predicate { return orm.C("{{.Name}}").EQ(val) },
{{end}}}
{{end}}
`
// GenConfig 代码生成配置
type GenConfig struct {
InputFile string // 输入文件路径(模型文件)
OutputFile string // 输出文件路径(生成文件)
}
// Generate 执行代码生成
func Generate(config GenConfig) error {
// 1. 解析源文件
p := &Parser{}
if err := p.ParseFile(config.InputFile); err != nil {
return err
}
// 2. 准备模板数据
data := struct {
Package string
Structs []StructInfo
}{
Package: p.pkgName,
Structs: p.structs,
}
// 3. 解析模板
tmpl, err := template.New("gen").Parse(genTemplate)
if err != nil {
return err
}
// 4. 执行模板渲染
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return err
}
// 5. 写入输出文件
return os.WriteFile(config.OutputFile, buf.Bytes(), 0644)
}
func main() {
// 执行代码生成
err := Generate(GenConfig{
InputFile: "model/user.go",
OutputFile: "model/user_gen.go",
})
if err != nil {
fmt.Printf("代码生成失败: %v\n", err)
os.Exit(1)
}
fmt.Println("代码生成成功!")
}
3.9 代码生成流程图
下面这张图是代码生成的完整链路,和下面的 ASCII 图对照看更清楚:
flowchart TD
S[model/user.go 源文件] -->|parser.ParseFile| A[AST 树]
A -->|ast.Inspect 遍历| I[StructInfo 字段信息]
I -->|template.Execute 渲染| G[user_gen.go
UserColumns + UserPredicates]代码生成完整流程:
┌──────────────────────────┐
│ model/user.go(源文件) │
│ type User struct { │
│ Id int64 │
│ Name string │
│ Age int │
│ } │
└──────────┬───────────────┘
│
▼ parser.ParseFile()
┌──────────────────────────┐
│ AST 树 │
│ └── TypeSpec │
│ ├── Name: "User" │
│ └── StructType │
│ └── Fields │
│ ├── Id:int64│
│ ├── Name:str│
│ └── Age:int │
└──────────┬───────────────┘
│
▼ ast.Inspect() / Visitor
┌──────────────────────────┐
│ StructInfo 结构体信息 │
│ { │
│ Package: "model" │
│ Name: "User" │
│ Fields: [ │
│ {Id, int64}, │
│ {Name, string}, │
│ {Age, int} │
│ ] │
│ } │
└──────────┬───────────────┘
│
▼ template.Execute()
┌──────────────────────────┐
│ model/user_gen.go(生成)│
│ var UserColumns = ... │
│ var UserPredicates = ...│
└──────────────────────────┘
3.10 代码生成的常用场景
| 场景 | 说明 | 典型项目 |
|---|---|---|
| 数据库查询生成 | 根据模型生成类型安全的查询代码 | GORM Gen、ENT |
| 增删改查代码 | 生成标准 CRUD 代码 | Beego Code Generator |
| 依赖注入 | 编译时生成依赖注入代码 | Google Wire |
| 前端代码 | 从后端模型生成前端 TypeScript 接口 | openapi-generator |
| Protobuf | 从 .proto 生成各语言代码 | protoc |
| Mock 代码 | 生成接口的 Mock 实现 | mockery |
面试要点:代码生成和动态代理的区别?绝大多数语言的动态代理机制就是语言的运行时帮你做了代码生成(例如 Java),而 Go 因为运行时和反射无法做到动态生成新的结构体,所以只能采用代码生成方案。
四、面试要点总结
JOIN 与复杂查询
GORM 的 Preload 是什么? 本质上就是一个 JOIN 查询,并且严格来说,在 Go 语言里面是很难实现 lazy load 的。GORM 的 Preload 就是通过 Join 把相关的数据都查询出来,并且组装成结构体。
WHERE、ON 和 HAVING 的区别? 主要考察的是 WHERE 和 ON。在 JOIN 查询里面,一般的建议都是尽量把条件放到 ON 上面,这样 JOIN 生成的中间数据要少很多。
JOIN、LEFT JOIN 和 RIGHT JOIN 的区别? INNER JOIN 只返回匹配的行;LEFT JOIN 返回左表所有行,右表不匹配的为 NULL;RIGHT JOIN 返回右表所有行,左表不匹配的为 NULL。
JOIN 的执行原理? 可以近似理解为一个双重循环。优化方案包括 Index Nested Loop Join(索引嵌套循环)、Block Nested Loop Join(块嵌套循环)和 Hash Join(哈希连接)。
Protobuf
怎么修改 Protobuf 生成的代码? 生成好的代码从实践上来说是不应该修改的,而很不幸的是,Protobuf 对应不同插件生成的目标代码,只能改插件的源码。
怎么为 Protobuf 的字段添加额外的属性? 可以通过 field option 来增加额外的属性,但是这种新的属性需要你自己写代码解析。最终方案是修改 Protobuf Go 插件源码。
AST 与代码生成
什么是 AST? 是源代码的一种树形结构的表示,我们可以通过修改 AST 来修改源码。
AST 编程有什么用? 语法检查、格式检查、错误检查、自动补全、优化代码、代码生成。基本上就是只要你够强,用 AST 你就可以为所欲为,甚至设计新的语言。
Go 有没有注解? 没有,但是我们可以用 AST 来自己设计。
Visitor 设计模式? Visitor 模式主要用于遍历复杂结构体,要点在于接收一个节点作为输入,并且返回一个遍历子节点的 Visitor。
代码生成的常用场景? 样板代码都可以考虑使用代码生成来替换掉,比如生成数据库查询(ENT)、增删改查代码、前端代码等。
代码生成和动态代理的区别? 绝大多数语言的动态代理机制就是语言的运行时帮你做了代码生成(如 Java),而 Go 因为运行时和反射无法做到动态生成新的结构体,所以只能采用代码生成方案。
ORM 框架完整能力图
到这里,ORM 框架系列教程全部完成。回顾整个系列,ORM 框架的完整能力包括:
ORM 框架完整能力图
│
├── SQL 构造(Builder 模式)
│ ├── SELECT(Selector)
│ │ ├── 基础查询(Where、From、Get、GetMulti)
│ │ ├── 指定列与聚合函数(Selectable、Column、Aggregate)
│ │ ├── JOIN 查询(TableReference、Join、JoinBuilder)
│ │ └── 子查询(Subquery)
│ ├── INSERT(Inserter)
│ │ ├── 单条/批量插入
│ │ ├── 指定列插入
│ │ └── UPSERT(OnDuplicate、Assignment)
│ ├── UPDATE(Updater)
│ └── DELETE(Deleter)
│
├── 元数据(反射 + 缓存)
│ ├── Model / Field(表名、列名、类型、偏移量)
│ ├── Registry 注册中心(并发安全缓存)
│ ├── 自定义表名/列名(Tag、接口、Option)
│ └── Protobuf 集成(修改插件源码)
│
├── 结果集处理(valuer 抽象)
│ ├── 反射方案(reflectValue)
│ ├── unsafe 方案(unsafeValue)
│ └── Creator 工厂模式
│
├── 事务管理
│ ├── Tx 结构体 + Session 抽象
│ ├── 事务闭包(DoTx)
│ ├── RollbackIfNotCommit
│ └── 事务扩散(context 传递)
│
├── AOP 方案(Middleware 洋葱模型)
│ ├── QueryContext / QueryResult / Handler / Middleware
│ ├── 日志 / 追踪 / 监控中间件
│ └── Dry Run 支持
│
├── 方言抽象(Dialect)
│ ├── standardSQL / mysqlDialect / sqlite3Dialect
│ └── builder 公共抽象
│
├── 测试体系
│ ├── 单元测试(sqlmock)
│ ├── 集成测试(TestSuite + Docker + go build 标签)
│ └── 原生查询(RawQuerier 兜底)
│
└── 代码生成
├── AST 解析(Visitor 模式)
├── 字段常量生成(UserColumns)
├── Predicate 辅助方法生成(UserPredicates)
└── Protobuf 集成(定制 protoc 插件)
ORM 框架的核心可以总结为三点:构造 SQL、设计元数据、处理结果集。这三点是 ORM 的基石。在此之上,事务管理保证了数据一致性,AOP 方案提供了横切关注点的解决方案,方言抽象实现了多数据库兼容,测试体系确保了代码质量,代码生成提升了开发效率。这就是一个完整 ORM 框架的全貌。
自测题与动手练习
自测题(合上书能答出来,才算懂):
- INNER JOIN / LEFT JOIN / RIGHT JOIN 的语义区别是什么?各自对"不匹配的行"怎么处理?
- 为什么 Go 里很难实现 lazy load?GORM 的 Preload 本质是什么?
- WHERE、ON、HAVING 分别什么时候执行?为什么建议把 JOIN 条件放到 ON 上?
- JOIN 的执行原理近似什么?Index Nested Loop Join 和 Hash Join 各自解决了什么问题?
- 为什么 Protobuf 字段的额外属性不能靠 field option 直接写进 Go tag?AST 代码生成相比 Java 动态代理的本质区别是什么?
动手练习(建议真做一遍):
- 用
orm.TableOf/Join/LeftJoin拼一个嵌套 JOIN(user JOIN order LEFT JOIN item),打印生成的 SQL 验证括号与 ON 条件。 - 把文中
ast_gen.go跑一遍,扫描你自己项目里的 model 包,生成UserColumns/UserPredicates,并在查询里替换手写字符串。 - 写一条聚合查询,把它改写成"子查询作为 FROM"的写法,对比两者生成的 SQL、可读性与执行计划。
本章小结
- 复杂查询:用
TableReference(Table / Join / Subquery 三实现)统一抽象 FROM 子句,JOIN 可任意嵌套;执行上近似双重循环,条件越早(放 ON)过滤,中间结果越小。 - Protobuf 集成:生成代码无法自带 orm tag,field option 也救不了,唯一现实方案是侵入式修改
protocGo 插件源码,在生成 json tag 处顺手拼上orm:"column=..."。 - 代码生成:基于 AST(
parser.ParseFile→ast.Inspect遍历 →text/template渲染)把模型字段变成类型安全的UserColumns/UserPredicates,本质是"编译期替你写样板代码",这是 Go 没有运行时动态代理下的必然选择。 - 至此 ORM 系列全部完结,下一阶段可以把这些能力接入真实数据库与压测,验证在大数据量下的表现。