学习目标
学完本章,你应该能够:
- 用自己的话讲清 ORM 的两大核心职责(对象↔SQL、结果集↔对象),并说清元数据在其中扮演的"基石"角色。
- 对比 Beego ORM、GORM、Ent 在 SQL 构造方式与元数据层级上的设计差异,并在选型时权衡灵活性与学习成本。
- 解释
Selector如何用 Builder 模式 + 泛型构造 SELECT,以及Predicate/Expression如何支撑 AND / OR / NOT 的树形组合。 - 用 Go 反射(
reflect.Type/reflect.Value)从结构体解析出元数据(表名、列名、偏移量),并说清指针/值、导出字段的处理细节。 - 解释元数据注册中心为什么用
DB隔离而非全局变量,以及读写锁 Double-Check 与sync.Map两种并发安全方案的取舍。
前置知识:
- Go 泛型与接口基础(本篇用泛型约束模型类型)
- Go 反射基础:
reflect.Type/reflect.Value/Kind/Elem database/sql包的初步使用经验- 基础 SQL 语法(SELECT / WHERE / AND / OR)
本章你会动手做的事:
- 用 Beego ORM、GORM、Ent 各写一个"增删查改"的最小可运行例子,体会三者元数据层级的差异。
- 实现一个最小
Selector,能生成SELECT * FROM \T` WHERE …` 形式的 SQL。 - 用反射写一个
parseModel,把结构体的导出字段映射成下划线列名。
一、ORM 框架概览
1.1 为什么需要 ORM
在没有 ORM 框架的时候,我们操作数据库需要做两件繁琐的事情:
- 手写 SQL:容易出错,难以重构。一旦表结构变化,所有手写的 SQL 都要逐条排查修改。
- 手动处理结果集:从
database/sql返回的rows中逐行扫描字段值,填充到结构体中。样板代码满天飞,精力都花在了跟业务没关系的地方。
ORM(Object-Relational Mapping,对象-关系映射)的诞生初衷,就是帮助我们自动完成这两个步骤,让开发者专注于业务逻辑。
1.2 ORM 是什么
ORM 框架是帮助用户完成对象到 SQL,以及结果集到对象的映射的工具。
简单来说:
- 对象 → SQL:当用户传入一个
User对象时,ORM 能自动生成对应的INSERT INTO语句。 - 结果集 → 对象:当数据库返回一行数据时,ORM 能自动将该行组装成一个
User对象返回给用户。
1.3 ORM 框架的主要职责
一个完整的 ORM 框架通常包含以下功能点:
| 功能 | 说明 |
|---|---|
| SQL 构造 | 必须支持增删改查(CRUD),DDL 一般作为扩展功能 |
| 映射 | 将结果集封装成对象,通常是性能瓶颈所在 |
| 事务 | 维护事务状态(Begin / Commit / Rollback) |
| 元数据 | SQL 构造和映射两个部分的基石 |
| AOP | 处理横向关注点(如缓存、日志、慢查询监控) |
| 关联关系 | 部分框架提供,但性价比低(复杂度高、收益有限) |
| 方言 | 兼容不同的数据库(MySQL、SQLite、PostgreSQL 等) |
1.4 主流 ORM 框架对比
Beego ORM
Beego ORM 是 Beego 框架自带的 ORM 组件。它的元数据采用三级结构:modelInfo → fields → fieldInfo。
// Beego ORM 入门示例
// 以 sqlite3 为例(不需要启动数据库服务端)
import (
"github.com/beego/beego/v2/client/orm"
_ "github.com/mattn/go-sqlite3"
)
// User 定义一个模型(对应数据库中的一张表)
// beego ORM 会通过反射解析这个结构体,生成元数据
type User struct {
// Id 字段:beego ORM 默认将名为 Id 的字段作为主键
Id int
// Name 字段:对应数据库中的 name 列
Name string
// Age 字段:对应数据库中的 age 列
Age int
}
func init() {
// 第一步:注册模型
// 告诉 ORM 框架:"我有一个 User 结构体,请你帮我管理它"
orm.RegisterModel(new(User))
// 第二步:注册驱动
// 告诉 ORM 框架:"我使用的是 sqlite3 数据库"
orm.RegisterDriver("sqlite", orm.DRSqlite)
// 第三步:注册数据库
// 参数含义:数据库名称(别名)、驱动名称、连接字符串
// 这里的 "default" 是默认数据库的别名
orm.RegisterDataBase("default", "sqlite3", "test.db")
// 第四步:自动建表
// 同步模型到数据库(如果表不存在就创建)
orm.RunSyncdb("default", false, true)
}
func main() {
// 获取 ORM 实例
// "default" 对应上面注册的数据库名称
o := orm.NewOrm()
// 插入一条记录
user := User{Name: "大明", Age: 28}
// Insert 方法会自动生成 INSERT INTO 语句并执行
id, _ := o.Insert(&user)
fmt.Printf("插入成功,ID = %d\n", id)
// 查询一条记录
var u User
// Read 方法默认按主键查询,生成 SELECT * FROM user WHERE id = ?
err := o.Read(&u)
if err == nil {
fmt.Printf("查询结果:%+v\n", u)
}
}
Beego ORM 的事务接口分为两大类:
// 方式一:传统的 Begin / Commit / Rollback
o := orm.NewOrm()
// 开启事务
tx, err := o.Begin()
if err != nil {
return err
}
// 执行操作...
if _, err := tx.Insert(&user); err != nil {
// 出错就回滚
tx.Rollback()
return err
}
// 成功就提交
tx.Commit()
// 方式二:闭包形式(推荐,自动处理提交和回滚)
o.DoTx(func(ctx context.Context, txOrm orm.TxOrmer) error {
// 在闭包内执行操作
// 如果返回 nil,自动提交;如果返回 error,自动回滚
_, err := txOrm.Insert(&user)
return err
})
GORM
GORM 是 Go 生态中最流行的 ORM 框架之一。它的元数据只有 Schema → Field 两级结构,比 Beego 更简洁。
// GORM 入门示例
import (
"gorm.io/gorm"
"gorm.io/driver/sqlite"
)
// User 模型定义
// GORM 使用结构体标签(Tag)来定制字段的数据库映射
type User struct {
// gorm 标签:primarykey 表示主键
ID uint `gorm:"primarykey"`
// gorm 标签:column 指定列名,type 指定数据库类型,size 限制长度
Name string `gorm:"column:name;type:varchar(100);size:100"`
// gorm 标签:not null 表示不允许为空
Age int `gorm:"column:age;not null"`
}
func main() {
// 连接数据库
// gorm.Open 接收两个参数:驱动(这里用 sqlite)和配置选项
db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
if err != nil {
panic("连接数据库失败")
}
// 自动迁移(创建表)
// AutoMigrate 会根据模型定义自动创建或修改表结构
db.AutoMigrate(&User{})
// 插入
// Create 方法生成 INSERT INTO users (name, age) VALUES (?, ?)
user := User{Name: "大明", Age: 28}
db.Create(&user)
// 查询
// First 方法生成 SELECT * FROM users ORDER BY id LIMIT 1
var result User
db.First(&result, user.ID)
fmt.Printf("查询结果:%+v\n", result)
// 条件查询
// Where 方法添加 WHERE 条件
// 生成 SELECT * FROM users WHERE name = '大明'
var users []User
db.Where("name = ?", "大明").Find(&users)
// 更新
// Save 方法生成 UPDATE users SET name=?, age=? WHERE id=?
user.Age = 29
db.Save(&user)
// 删除
// Delete 方法生成 DELETE FROM users WHERE id = ?
db.Delete(&user)
}
GORM 的事务接口:
// 方式一:传统事务
tx := db.Begin()
// 执行操作...
if err := tx.Create(&user).Error; err != nil {
tx.Rollback()
return err
}
tx.Commit()
// 方式二:闭包事务(推荐)
db.Transaction(func(tx *gorm.DB) error {
// 在闭包内执行操作
if err := tx.Create(&user).Error; err != nil {
// 返回 error 会自动回滚
return err
}
// 返回 nil 会自动提交
return nil
})
// 方式三:SavePoint(保存点)
// 支持在事务内部设置保存点,可以部分回滚
Ent
Ent 与 Beego ORM、GORM 有一个本质区别:Ent 采用代码生成技术。
// Ent 的使用方式与众不同
// 第一步:定义 schema(在 schema/user.go 中)
// 第二步:运行代码生成命令(go generate ./...)
// 第三步:使用生成的代码
// schema/user.go —— 定义模型
// Ent 的 schema 不是普通的 Go 结构体,而是一个 schema 定义
type User struct {
ent.Schema
}
// Fields 方法定义字段
func (User) Fields() []ent.Field {
return []ent.Field{
field.Int("id").Optional(), // 自增 ID
field.String("name").Default(""), // 名字,默认空字符串
field.Int("age").Positive(), // 年龄,必须为正数
}
}
// 代码生成后会自动创建 ent/client.go 等文件
// 使用时直接调用生成的 API,类型安全且无需手写 SQL
func main() {
// 创建 client(相当于数据库连接)
client, _ := ent.Open("sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
defer client.Close()
// 自动迁移
client.Schema.Create(context.Background())
// 插入 —— 类型安全,编译期就能发现错误
user, _ := client.User.Create().
SetName("大明").
SetAge(28).
Save(context.Background())
// 查询
u, _ := client.User.Get(context.Background(), user.ID)
fmt.Printf("查询结果:%+v\n", u)
}
1.5 三个框架的设计理念对比
| 特性 | Beego ORM | GORM | Ent |
|---|---|---|---|
| SQL 构造方式 | 方法链式调用(耦合度高) | Builder + Expression + Clause | 纯 Builder 模式 |
| 元数据层级 | modelInfo → fields → fieldInfo(三级) | Schema → Field(两级) | 代码生成(编译期确定) |
| 事务支持 | Begin/Commit + 闭包 | Begin/Commit + 闭包 + SavePoint | 事务回调 |
| 灵活性 | 较低 | 高(接口机制) | 最高(代码生成) |
| 学习成本 | 低 | 中 | 高 |
总结:ORM 框架的核心就是 SQL 构造 和 处理结果集。在 Go 中,因为标准库
database/sql已经解决了连接和会话管理的问题,所以 ORM 框架不需要关心这些底层细节。
二、SELECT 起步 —— SQL 构造的设计
2.1 不同 ORM 的 SQL 构造方式
我们从 SQL 构造开始起步。先看看不同 ORM 框架是如何构造 SQL 的。
Beego 的构造形式
Beego 的第一种构造形式直接在查询方法中拼接 SQL:
// Beego 的方式:SQL 构造、执行和结果集处理完全混在一起
o := orm.NewOrm()
// 查询所有用户
// 这里 SQL 的构造、执行、结果集处理是一体的
var users []User
o.QueryTable("user").All(&users)
// 条件查询
// Filter 方法直接构造 WHERE 条件
o.QueryTable("user").Filter("name", "大明").All(&users)
优点:API 极其简单,用户使用无脑。
缺点:代码耦合性强,扩展性差。SQL 的构造和执行完全混在一起,职责不清。SELECT 语句语法形式非常复杂,这种处理方式难以支持完整语法。
Beego 的第二种设计是 QueryBuilder,一个独立的组件:
// Beego QueryBuilder:独立的 SQL 构造组件
// 和执行查询、处理结果集解耦了
qb, _ := orm.NewQueryBuilder("mysql")
// 用户必须完全按照 SQL 语句的顺序来调用方法
qb.Select("id", "name", "age").
From("user").
Where("age > ?").And("name LIKE ?").
OrderBy("id").Desc().
Limit(10).Offset(0)
sql := qb.String()
// 生成:SELECT id, name, age FROM user WHERE age > ? AND name LIKE ? ORDER BY id DESC LIMIT 10 OFFSET 0
好处是用户完全明白自己在干什么,缺点是约束太强,灵活构建 SQL 时比较困难。
GORM 的构造形式
GORM 有四个主要抽象:
- Builder:提供最基本的构造方法
- Expression:表达式,表达式之间可以组合成复合表达式
- Clause:按照特定需要组合而成的 SQL 的一个部分
- Interface:构造自身,以及和其它 Clause 组合
核心理念:SQL 的不同部分分开构造,最后拼接在一起。
// GORM 的 Expression 实现,对应 SELECT XXX 这个单一部分
// 这是一个简化的示例,展示 GORM 的设计思路
// Expression 接口:所有 SQL 表达式都要实现这个接口
type Expression interface {
// Build 把表达式构造成 SQL 片段,并收集参数
Build(builder Builder)
}
// Column 表达式:代表一个列名
type Column struct {
Name string
}
// 实现 Expression 接口
func (c Column) Build(builder Builder) {
// 把列名写入 SQL
builder.WriteByte('`')
builder.WriteString(c.Name)
builder.WriteByte('`')
}
// Eq 表达式:等于条件
type Eq struct {
Column string
Value interface{}
}
func (eq Eq) Build(builder Builder) {
// 构造 column = ? 的形式
builder.WriteString(eq.Column)
builder.WriteString(" = ?")
// 把参数加入参数列表
builder.AddArg(eq.Value)
}
Ent 的构造形式
Ent 是最为纯粹的 Builder 模式,不要求调用顺序,也没有复杂的接口机制:
// Ent 的 Builder 模式
// 方法可以以任意顺序调用,最终生成 SQL
client.User.Query().
Where(user.NameEQ("大明")).
Order(user.ByAge()).
Limit(10).
All(ctx)
2.2 设计风格决策
在对比了三种 ORM 框架后,我们做出以下设计决策:
- 使用 Builder 模式:SQL 里面的不同语句有不同的实现
- 使用泛型做类型约束:例如 SELECT 语句和 INSERT 语句使用不同的泛型参数
- 引入 QueryBuilder:作为构建 SQL 这一个单独步骤的顶级抽象
2.3 Selector 接口定义
我们采用单一职责的 Builder 模式,为每种 SQL 语句定义独立的接口:Selector(查询)、Deleter(删除)、Updater(更新)、Inserter(插入)。
// query.go 文件
// QueryBuilder 是构建 SQL 的顶级抽象
// 任何能够构建 SQL 的类型都应该实现这个接口
type QueryBuilder interface {
// Build 方法:把构造好的 SQL 和参数返回
// 返回值:SQL 字符串、参数列表、错误
Build() (*Query, error)
}
// Query 代表一个构建好的查询
type Query struct {
// SQL 是生成的 SQL 语句
SQL string
// Args 是 SQL 中的占位符参数(对应 ? 的值)
Args []any
}
// Selector 是 SELECT 语句的构造器接口
// 泛型参数 T 用于约束用户传入的模型类型
// 例如 Selector[User] 表示查询 User 表
type Selector[T any] struct {
// table 存储表名
// 如果用户没有调用 From 方法,则使用泛型类型名作为表名
table string
// where 存储查询条件
// 类型是 Predicate,后面会详细讲解
where []Predicate
// db 存储数据库引用(后面元数据注册中心会用到)
db *DB
}
2.4 FROM 子句的实现
FROM 子句决定查询哪张表。我们提供两种方式:
- 如果用户没有指定表名,默认使用泛型类型名
- 如果用户调用了
From方法,就使用用户指定的表名
// selector.go 文件
// From 方法:指定查询的表名
// 这是 Builder 模式的典型写法:方法接收者是指针,返回值也是自身
// 这样用户可以链式调用:NewSelector[T]().From("user").Where(...)
func (s *Selector[T]) From(table string) *Selector[T] {
// 将用户指定的表名保存起来
s.table = table
// 返回自身,支持链式调用
return s
}
// Build 方法:构造完整的 SELECT 语句
func (s *Selector[T]) Build() (*Query, error) {
var (
// sql 使用 strings.Builder 来高效拼接字符串
sql strings.Builder
args []any
)
// 1. 拼接 SELECT * FROM
sql.WriteString("SELECT * FROM ")
// 2. 处理表名
if s.table == "" {
// 如果用户没有指定表名,使用泛型类型名
// 通过反射获取泛型参数 T 的类型名
var t T
typ := reflect.TypeOf(t)
// 如果是指针类型,取指针指向的类型
if typ.Kind() == reflect.Ptr {
typ = typ.Elem()
}
// 用反引号括起来,防止表名是 SQL 关键字
sql.WriteByte('`')
sql.WriteString(typ.Name())
sql.WriteByte('`')
} else {
// 如果用户指定了表名,直接使用
// 不加反引号,因为用户可能传入了 "db.table" 这种格式
sql.WriteString(s.table)
}
// 3. 处理 WHERE 条件(后面会实现)
// ...
return &Query{
SQL: sql.String(),
Args: args,
}, nil
}
// NewSelector 创建一个 Selector 实例
// 泛型参数 T 指定查询的模型类型
func NewSelector[T any](db *DB) *Selector[T] {
return &Selector[T]{
db: db,
}
}
测试用例:
// selector_test.go 文件
// TestSelectorFrom 测试 From 方法
func TestSelectorFrom(t *testing.T) {
// 定义测试模型
type TestModel struct {
Id int64
Name string
}
// 测试用例表格
tests := []struct {
name string // 测试用例名称
s *Selector[TestModel] // Selector 实例
wantSQL string // 期望生成的 SQL
wantErr error // 期望的错误
}{
{
// 用例1:不指定 From,使用泛型类型名作为表名
// 期望生成:SELECT * FROM `TestModel`
name: "no from",
s: NewSelector[TestModel](nil),
wantSQL: "SELECT * FROM `TestModel`",
},
{
// 用例2:指定 From,使用用户提供的表名
// 期望生成:SELECT * FROM user_t(不加反引号)
name: "with from",
s: NewSelector[TestModel](nil).From("user_t"),
wantSQL: "SELECT * FROM user_t",
},
{
// 用例3:指定带数据库前缀的表名
// 期望生成:SELECT * FROM test_db.test_model
name: "with db name",
s: NewSelector[TestModel](nil).From("test_db.test_model"),
wantSQL: "SELECT * FROM test_db.test_model",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 构造 SQL
q, err := tt.s.Build()
if err != nil {
t.Fatal(err)
}
// 验证生成的 SQL 是否正确
if q.SQL != tt.wantSQL {
t.Fatalf("生成的 SQL 不符合预期\n期望: %s\n实际: %s", tt.wantSQL, q.SQL)
}
})
}
}
2.5 WHERE 子句与 Predicate
WHERE 是 SELECT 语句中最复杂的部分。我们需要处理各种查询条件,以及由 AND、OR、NOT 混合在一起的复杂条件。
白话类比:把 WHERE 条件想成一棵"决策树"。树叶是最简单的判断(比如
age > 18),树干是逻辑连接词(AND/OR/NOT)。AND像"并且"——左右两棵子树都得成立;OR像"或者"——有一棵成立就行;NOT像"取反"——它只需要右边那棵子树,自己没有左边。每当我们把两个Predicate用And(...)/Or(...)串起来,就等于在树上多长出一个分叉节点。
这张图展示了 id = 1 OR name = 大明 再和 age > 18 用 AND 组合后的结构:
flowchart TD
A((AND)) --> B((OR))
A --> C[age 大于 18]
B --> D[id = 1]
B --> E[name 等于 大明]第一版:字符串方式
// 第一版 Where 方法:直接接收字符串和参数
// 优点:简单明了,非常灵活
// 缺点:缺乏校验,用户容易写错字段名、漏括号等
func (s *Selector[T]) Where(query string, args ...any) *Selector[T] {
// 直接把字符串和参数存起来
// 后面 Build 的时候拼接进去
s.where = append(s.where, Predicate{
query: query,
args: args,
})
return s
}
这种方式的问题在于无法支持 AND、OR、NOT 的嵌套组合。
第二版:结构化 Predicate
我们引入结构化的 Predicate(谓词)来替代字符串:
// predicate.go 文件
// Predicate 代表一个查询条件
// 它可以是一个简单的比较(如 id > 10),也可以是复合条件(如 a AND b OR c)
type Predicate struct {
// left 是左操作数,可以是列名,也可以是另一个 Predicate
left Expression
// op 是操作符,如 =, >, <, AND, OR 等
op op
// right 是右操作数,可以是值,也可以是另一个 Predicate
right Expression
}
// op 是操作符类型
// 定义为 string 的衍生类型,方便使用和调试
type op string
// 定义常用的操作符常量
const (
opEq op = "=" // 等于
opLt op = "<" // 小于
opGt op = ">" // 大于
opLtEq op = "<=" // 小于等于
opGtEq op = ">=" // 大于等于
opNotEq op = "<>" // 不等于
opAnd op = "AND" // 逻辑与
opOr op = "OR" // 逻辑或
opNot op = "NOT" // 逻辑非
)
Predicate 在考虑 AND、OR 之后,可以构成一棵复杂的二叉树:
AND
/ \
OR age > 18
/ \
id=1 name="大明"
Expression 抽象
我们引入 Expression 接口,让列名、值和 Predicate 都成为表达式:
// Expression 是所有 SQL 表达式的抽象接口
type Expression interface {
// expr 方法是一个空方法,仅用于标记实现了这个接口
// 这种做法叫做"密封接口"(Sealed Interface)模式
// 只有在我们的包内定义的类型才能实现这个接口
expr()
}
// Column 代表一个列名,实现了 Expression 接口
type Column struct {
name string
}
func (c Column) expr() {}
// value 代表一个参数值,实现了 Expression 接口
type value struct {
val any
}
func (value) expr() {}
// Predicate 也实现了 Expression 接口
// 这样 Predicate 可以作为另一个 Predicate 的 left 或 right
// 从而构成树形结构
func (Predicate) expr() {}
// C 是 Column 的构造函数(C 代表 Column)
// 用法:orm.C("id") 表示列名 id
func C(name string) Column {
return Column{name: name}
}
// 对应的 Predicate 构造函数
// Lt 构造一个小于条件
// 用法:orm.Lt(C("age"), 18) 表示 age < 18
func Lt(left Expression, right Expression) Predicate {
return Predicate{
left: left,
op: opLt,
right: right,
}
}
// Eq 构造一个等于条件
// 用法:orm.Eq(C("name"), "大明") 表示 name = "大明"
func Eq(left Expression, right Expression) Predicate {
return Predicate{
left: left,
op: opEq,
right: right,
}
}
// Gt 构造一个大于条件
func Gt(left Expression, right Expression) Predicate {
return Predicate{
left: left,
op: opGt,
right: right,
}
}
// Not 构造一个取反条件
// 用法:orm.Not(orm.Eq(C("name"), "大明")) 表示 NOT (name = "大明")
func Not(p Predicate) Predicate {
return Predicate{
op: opNot,
right: p,
}
}
// And 构造一个逻辑与条件
// 用法:orm.And(orm.Eq(C("name"), "大明"), orm.Gt(C("age"), 18))
// 表示 name = "大明" AND age > 18
func And(left Predicate, right Predicate) Predicate {
return Predicate{
left: left,
op: opAnd,
right: right,
}
}
// Or 构造一个逻辑或条件
func Or(left Predicate, right Predicate) Predicate {
return Predicate{
left: left,
op: opOr,
right: right,
}
}
Where 方法改造
// Where 方法接收 Predicate 作为输入
func (s *Selector[T]) Where(p *Predicate) *Selector[T] {
if p != nil {
s.where = append(s.where, *p)
}
return s
}
buildExpression 递归构造
// buildExpression 递归地构造表达式对应的 SQL 片段
// 这是 WHERE 条件构造的核心方法
func (s *Selector[T]) buildExpression(e Expression, sb *strings.Builder, args *[]any) {
// 根据表达式的具体类型,分别处理
switch exp := e.(type) {
case nil:
// nil 不做任何处理
return
case Column:
// 列名:直接拼接列名
sb.WriteString(exp.name)
case value:
// 参数值:加入占位符 ?,把值放入参数列表
sb.WriteByte('?')
*args = append(*args, exp.val)
case Predicate:
// Predicate 是查询条件,需要递归处理
// 1. 处理左操作数
if exp.left != nil {
// 如果左边是一个 Predicate,需要加括号
// 例如:(age > 18) AND (name = '大明')
if lp, ok := exp.left.(Predicate); ok {
_ = lp
sb.WriteByte('(')
}
s.buildExpression(exp.left, sb, args)
if _, ok := exp.left.(Predicate); ok {
sb.WriteByte(')')
}
}
// 2. 处理操作符
// NOT 比较特殊,只有右操作数,没有左操作数
if exp.op != "" {
sb.WriteString(" ")
sb.WriteString(string(exp.op))
sb.WriteString(" ")
}
// 3. 处理右操作数
if exp.right != nil {
// 如果右边是一个 Predicate,也需要加括号
if _, ok := exp.right.(Predicate); ok {
sb.WriteByte('(')
}
s.buildExpression(exp.right, sb, args)
if _, ok := exp.right.(Predicate); ok {
sb.WriteByte(')')
}
}
}
}
完整的 Build 方法整合:
// Build 构造完整的 SELECT 语句
func (s *Selector[T]) Build() (*Query, error) {
var (
sql strings.Builder
args []any
)
// 1. SELECT * FROM
sql.WriteString("SELECT * FROM ")
// 2. 处理表名(同前面的实现)
if s.table == "" {
var t T
typ := reflect.TypeOf(t)
if typ.Kind() == reflect.Ptr {
typ = typ.Elem()
}
sql.WriteByte('`')
sql.WriteString(typ.Name())
sql.WriteByte('`')
} else {
sql.WriteString(s.table)
}
// 3. 处理 WHERE 条件
if len(s.where) > 0 {
sql.WriteString(" WHERE ")
// 多个 WHERE 条件之间用 AND 连接
p := s.where[0]
for i := 1; i < len(s.where); i++ {
// 如果有多个条件,用 AND 组合起来
p = And(p, s.where[i])
}
// 递归构造 WHERE 表达式
s.buildExpression(p, &sql, &args)
}
return &Query{
SQL: sql.String(),
Args: args,
}, nil
}
测试用例:
func TestSelectorWhere(t *testing.T) {
type TestModel struct {
Id int64
Name string
Age int
}
tests := []struct {
name string
s *Selector[TestModel]
wantSQL string
wantArg []any
}{
{
// 简单的等于条件
// 生成:SELECT * FROM `TestModel` WHERE name = ?
// 参数:["大明"]
name: "simple where",
s: NewSelector[TestModel](nil).Where(Eq(C("Name"), "大明")),
wantSQL: "SELECT * FROM `TestModel` WHERE `Name` = ?",
wantArg: []any{"大明"},
},
{
// AND 组合条件
// 生成:SELECT * FROM `TestModel` WHERE (name = ?) AND (age > ?)
// 参数:["大明", 18]
name: "and",
s: NewSelector[TestModel](nil).Where(
And(Eq(C("Name"), value{val: "大明"}), Gt(C("Age"), value{val: 18})),
),
wantSQL: "SELECT * FROM `TestModel` WHERE (`Name` = ?) AND (`Age` > ?)",
wantArg: []any{"大明", 18},
},
{
// NOT 取反条件
// 生成:SELECT * FROM `TestModel` WHERE NOT (`Name` = ?)
name: "not",
s: NewSelector[TestModel](nil).Where(
Not(Eq(C("Name"), value{val: "大明"})),
),
wantSQL: "SELECT * FROM `TestModel` WHERE NOT (`Name` = ?)",
wantArg: []any{"大明"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
q, err := tt.s.Build()
if err != nil {
t.Fatal(err)
}
if q.SQL != tt.wantSQL {
t.Fatalf("SQL 不匹配\n期望: %s\n实际: %s", tt.wantSQL, q.SQL)
}
})
}
}
面试要点:GORM 是如何构造 SQL 的?主要有四个抽象:Builder、Expression、Clause 和 Interface。一句话概括就是 SQL 的不同部分分开构造,最后拼接在一起。Builder 模式尤其适合构造复杂多变的对象。
三、元数据 —— 反射解析模型
3.1 为什么需要元数据
假设用户写了这样的查询:
// 用户把列名写错了,写成了 "Nme" 而不是 "Name"
NewSelector[TestModel](nil).Where(Eq(C("Nme"), "大明"))
这时候有两种处理策略:
| 策略 | 说明 | 优缺点 |
|---|---|---|
| 不做校验 | SQL 拼接好直接发到数据库,数据库自然会报错 | 简单,但错误发现晚 |
| 尽可能校验 | 在构造 SQL 阶段就检查字段名是否合法 | 尽早发现错误,方便单元测试 |
要想校验,就需要获得模型的基本信息——也就是元数据。
元数据是对模型的描述,包括表名、列名、主键、索引等。ORM 框架需要解析模型以获得元数据,这些元数据将被用于构建 SQL、执行校验,以及处理结果集。
3.2 元数据的设计
从 Beego 和 GORM 的设计中,我们可以总结出元数据的核心结构:
模型(Model / Schema)
├── 表名
├── 主键
├── 索引
├── 关联关系
└── 字段列表(Field)
├── 列名
├── Go 类型
├── 数据库类型
├── 是否主键
├── 是否外键
└── ...
最开始我们不需要那么复杂,只需要两个东西:
- 表名:用于 FROM 子句
- 列名:用于 WHERE 子句的字段校验
要获取这些信息,我们需要用到 Go 的反射(Reflection)。
3.3 Go 反射基础
reflect.Type 和 reflect.Value
在 Go 反射中,一个实例可以看成两部分:值和实际类型。
package main
import (
"fmt"
"reflect"
)
func main() {
// 创建一个 User 实例
type User struct {
Name string
Age int
}
u := User{Name: "大明", Age: 28}
// reflect.Value:用于操作值(读、写)
// 通过 reflect.ValueOf 获取任意值的反射值
val := reflect.ValueOf(u)
fmt.Println("Value:", val) // 输出: {大明 28}
// reflect.Type:用于操作类型信息(只读)
// 通过 reflect.TypeOf 获取任意值的类型信息
typ := reflect.TypeOf(u)
fmt.Println("Type:", typ) // 输出: main.User
// reflect.Type 可以通过 reflect.Value 得到,反之不行
typ2 := val.Type()
fmt.Println("Type from Value:", typ2) // 输出: main.User
}
reflect.Kind
Kind 是一个枚举值,用来判断操作对象的类型类别(指针、数组、切片、结构体等)。
func main() {
type User struct {
Name string
}
u := User{Name: "大明"}
// 通过 Kind() 判断类型类别
typ := reflect.TypeOf(u)
fmt.Println("Kind:", typ.Kind()) // 输出: struct
// 指针类型
pu := &u
pTyp := reflect.TypeOf(pu)
fmt.Println("Pointer Kind:", pTyp.Kind()) // 输出: ptr
// 通过 Elem() 获取指针指向的类型
fmt.Println("Elem Kind:", pTyp.Elem().Kind()) // 输出: struct
}
重要提示:reflect 的方法如果调用得不对,直接就 panic。在调用 API 之前一定要先读注释,确认什么情况下可以调用。例如
Elem()方法只有在 Kind 为Ptr、Array、Slice等时才能调用。
用反射输出字段名和值
package main
import (
"fmt"
"reflect"
)
// User 测试模型
type User struct {
Name string
Age int
// 私有字段:反射能拿到类型信息,但拿不到值
email string
}
func main() {
u := User{Name: "大明", Age: 28, email: "daming@example.com"}
// 注意:传入的是值,不是指针
// 如果传入指针,Kind 就是 Ptr 而不是 Struct
typ := reflect.TypeOf(u)
val := reflect.ValueOf(u)
// 只有 Kind == Struct 的才有字段
// 指针类型是没有字段的!
if typ.Kind() != reflect.Struct {
fmt.Println("不是结构体,无法遍历字段")
return
}
// NumField 返回结构体的字段数量
for i := 0; i < typ.NumField(); i++ {
// typ.Field(i) 返回 StructField,包含字段的类型信息
field := typ.Field(i)
// val.Field(i) 返回字段的值
fieldVal := val.Field(i)
fmt.Printf("字段名: %s, 类型: %s, 值: %v\n",
field.Name, // 字段名,如 "Name"
field.Type, // 字段类型,如 string
fieldVal, // 字段值,如 "大明"
)
}
// 输出:
// 字段名: Name, 类型: string, 值: 大明
// 字段名: Age, 类型: int, 值: 28
// 字段名: email, 类型: string, 值: daming@example.com
// 注意:私有字段的类型信息能拿到,但值有时候拿不到(取决于上下文)
}
指针和指针指向的结构体
func main() {
type User struct {
Name string
Age int
}
// 如果用户传入的是指针
u := &User{Name: "大明", Age: 28}
typ := reflect.TypeOf(u)
// 指针的 Kind 是 Ptr
fmt.Println("Kind:", typ.Kind()) // 输出: ptr
// 需要先用 Elem() 获取指针指向的类型
// Elem() 相当于 Go 代码中的 * 操作
elemType := typ.Elem()
fmt.Println("Elem Kind:", elemType.Kind()) // 输出: struct
// 现在 elemType 就是 User 类型了
for i := 0; i < elemType.NumField(); i++ {
field := elemType.Field(i)
fmt.Printf("字段名: %s, 类型: %s\n", field.Name, field.Type)
}
}
用反射修改值
func main() {
type User struct {
Name string
Age int
}
// 要修改值,必须传入指针
// 如果传入值,reflect.ValueOf 会复制一份,修改不会影响原始变量
u := &User{Name: "大明", Age: 28}
// 注意:这里传入的是 u(指针),所以 val 是指针的 Value
val := reflect.ValueOf(u)
// Elem() 获取指针指向的对象
elemVal := val.Elem()
// 修改 Name 字段
// CanSet() 检查字段是否可以被修改
// 只有通过指针获取的字段,且字段是公开的,才能被修改
nameField := elemVal.FieldByName("Name")
if nameField.CanSet() {
nameField.SetString("小明")
}
fmt.Printf("修改后:%+v\n", *u) // 输出: {小明 28}
}
反射编程小技巧:
- 读写值,使用
reflect.Value- 读取类型信息,使用
reflect.Type- 时刻注意你现在操作的类型是不是指针,指针和指针指向的对象在反射层面是两个东西
- 没有足够的测试就不要用反射,因为反射 API 充斥着 panic
3.4 用反射解析模型元数据
我们采用 TDD(测试驱动开发)的思路:先定义 API,再写测试,再写实现。
定义元数据结构
// model.go 文件
// Model 代表一个模型的元数据
// 它是对 Go 结构体的描述,告诉 ORM 这张表长什么样
type Model struct {
// TableName 是数据库中的表名
// 例如结构体 User 对应的表名可能是 "user"
TableName string
// Fields 是该模型的所有字段信息
FieldMap map[string]*Field
}
// Field 代表一个字段的元数据
type Field struct {
// ColName 是数据库中的列名
// 例如结构体字段 FirstName 对应的列名可能是 "first_name"
ColName string
// Type 是 Go 的类型信息
Type reflect.Type
// Offset 是字段在结构体中的偏移量
// 用于后面处理结果集时快速赋值
Offset uintptr
}
解析模型的实现
// model.go 文件
// parseModel 通过反射解析 Go 结构体,提取元数据
// 入参 val 是模型的实例(可以是指针)
// 返回解析出的 Model 元数据
func parseModel(val any) (*Model, error) {
if val == nil {
return nil, ErrInvalidInput
}
// 第一步:获取 reflect.Type
typ := reflect.TypeOf(val)
// 第二步:处理指针类型
// 用户可能传入 *User,也可能传入 User
// 我们需要统一处理为结构体类型
for typ.Kind() == reflect.Ptr {
// Elem() 获取指针指向的类型
// 循环是为了处理多重指针(虽然实际中很少用)
typ = typ.Elem()
}
// 第三步:校验是否是结构体
// 只有结构体才能映射为数据库表
if typ.Kind() != reflect.Struct {
return nil, ErrInvalidInput
}
// 第四步:创建 Model 并填充表名
// 默认使用类型名作为表名
// 例如 User -> "User"(后面会做驼峰转下划线)
m := &Model{
TableName: typ.Name(),
FieldMap: make(map[string]*Field, typ.NumField()),
}
// 第五步:逐个解析字段
for i := 0; i < typ.NumField(); i++ {
// 获取第 i 个字段的类型信息
fd := typ.Field(i)
// 跳过非导出字段(私有字段以小写字母开头)
// 因为私有字段无法被外部访问,也没必要映射到数据库
if !fd.IsExported() {
continue
}
// 将字段名(驼峰)转换为列名(下划线)
// 例如 FirstName -> first_name
colName := underscoreName(fd.Name)
// 保存字段元数据
m.FieldMap[fd.Name] = &Field{
ColName: colName,
Type: fd.Type,
Offset: fd.Offset,
}
}
return m, nil
}
3.5 驼峰转下划线
// model.go 文件
// underscoreName 将驼峰命名转换为下划线命名
// 例如:FirstName -> first_name, ID -> i_d(已知缺陷)
func underscoreName(name string) string {
var buf strings.Builder
for i, r := range name {
// 判断是否是大写字母
if unicode.IsUpper(r) {
// 第一个字母前面不需要下划线
if i > 0 {
buf.WriteByte('_')
}
// 转换为小写
buf.WriteRune(unicode.ToLower(r))
} else {
// 非大写字母直接写入
buf.WriteRune(r)
}
}
return buf.String()
}
测试用例:
// model_test.go 文件
func TestUnderscoreName(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
// 正常用例
{"FirstName", "FirstName", "first_name"},
{"Age", "Age", "age"},
{"CreateTime", "CreateTime", "create_time"},
// 已知缺陷:连续大写处理不好
// ID 会被转换为 i_d,而我们期望的是 id
// 这个测试叫做"防呆测试",用来记录 API 的已知缺陷
{"ID", "ID", "i_d"},
// 带数字的情况
// Table1Name 会被转换为 table1_name
// 有些人可能期望 table_1_name
{"Table1Name", "Table1Name", "table1_name"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := underscoreName(tt.input)
if got != tt.want {
t.Errorf("期望 %s, 实际 %s", tt.want, got)
}
})
}
}
3.6 指针处理
用户可能传入指针类型,例如 *TestModel。我们需要修改实现来支持这种情况:
func TestParseModel(t *testing.T) {
type TestModel struct {
Id int64
Name string
}
tests := []struct {
name string
input any
wantErr error
}{
{
// 传入结构体值
name: "struct value",
input: TestModel{},
},
{
// 传入结构体指针(最常用的方式)
name: "struct pointer",
input: &TestModel{},
},
{
// 传入 nil(应该报错)
name: "nil",
input: nil,
wantErr: ErrInvalidInput,
},
{
// 传入基础类型(应该报错,因为不是结构体)
name: "int",
input: 42,
wantErr: ErrInvalidInput,
},
{
// 传入切片(应该报错)
name: "slice",
input: []int{1, 2, 3},
wantErr: ErrInvalidInput,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m, err := parseModel(tt.input)
if err != tt.wantErr {
t.Errorf("期望错误 %v, 实际错误 %v", tt.wantErr, err)
}
if err == nil {
// 验证表名
fmt.Printf("表名: %s\n", m.TableName)
// 验证字段
for name, field := range m.FieldMap {
fmt.Printf("字段: %s -> 列名: %s\n", name, field.ColName)
}
}
})
}
}
3.7 中心式 error 定义
// error.go 文件
// 中心式 error 定义:把所有错误集中在一处定义
// 优点:
// 1. 便于管理:所有错误定义在一个地方,一目了然
// 2. 便于演进:将来要对 error 进行改造(比如加入错误码),只需要修改一处
// 3. 便于定位:新手用户能够快速读懂错误信息,容易定位问题
// 定义常用错误
var (
// ErrInvalidInput 表示输入参数不合法
// 例如:传入了 nil、传入了非结构体类型
ErrInvalidInput = errors.New("orm: 输入参数不合法")
// ErrUnsupportedExpression 表示不支持的表达式类型
ErrUnsupportedExpression = errors.New("orm: 不支持的表达式类型")
// ErrUnknownColumn 表示未知的列名
// 当用户在 WHERE 中使用了不存在的字段名时返回
ErrUnknownColumn = errors.New("orm: 未知列名")
// ErrInvalidTag 表示标签格式不正确
ErrInvalidTag = errors.New("orm: 标签格式不正确")
)
// 需要携带额外信息的错误,可以用方法创建
// 例如:未知列名错误,附带列名和表名信息
func NewUnknownColumnError(colName string, tableName string) error {
return fmt.Errorf("orm: 未知列名 %s,表 %s 中不存在该列", colName, tableName)
}
作为中间件设计者,尤其是设计面向新手用户的中间件,一定要考虑清楚怎么让新手能够快速读懂你的错误信息,并且能比较容易定位问题、解决问题。错误码在中间件中应该比在业务中还要更加重要。
3.8 用元数据改造 Selector
现在我们有了元数据,可以在构造 SQL 时进行校验:
// Build 方法中加入元数据校验
func (s *Selector[T]) Build() (*Query, error) {
var (
sql strings.Builder
args []any
)
// 1. 获取元数据
var t T
// 解析模型,获取表名和字段信息
m, err := parseModel(&t)
if err != nil {
return nil, err
}
// 2. SELECT * FROM
sql.WriteString("SELECT * FROM ")
// 3. 处理表名(使用元数据中的表名)
if s.table == "" {
// 没有指定表名,使用元数据中的表名
// 用反引号括起来
sql.WriteByte('`')
sql.WriteString(m.TableName)
sql.WriteByte('`')
} else {
// 用户指定了表名,直接使用
sql.WriteString(s.table)
}
// 4. 处理 WHERE 条件
if len(s.where) > 0 {
sql.WriteString(" WHERE ")
p := s.where[0]
for i := 1; i < len(s.where); i++ {
p = And(p, s.where[i])
}
// 在构造表达式时,可以使用元数据校验列名
s.buildExpression(p, &sql, &args, m)
}
return &Query{
SQL: sql.String(),
Args: args,
}, nil
}
// buildExpression 改造版:加入元数据校验
func (s *Selector[T]) buildExpression(e Expression, sb *strings.Builder, args *[]any, m *Model) {
switch exp := e.(type) {
case Column:
// 使用元数据校验列名
// 如果用户写了一个不存在的字段名,在这里就能发现
field, ok := m.FieldMap[exp.name]
if !ok {
// 字段不存在,这里可以 panic 或返回 error
// 暂时用 panic 简化演示
panic(NewUnknownColumnError(exp.name, m.TableName))
}
// 使用列名而不是字段名
sb.WriteByte('`')
sb.WriteString(field.ColName)
sb.WriteByte('`')
// ... 其他 case 同前面
}
}
四、元数据注册中心
4.1 为什么要缓存
目前每个 Selector 每次构造 SQL 都要解析一遍模型。即便我们查询的是同一个 TestModel,也会重复解析来解析去。
能不能一个类型只解析一次?后面的就复用前面的结果?答案是可以的——我们需要一个元数据注册中心。
4.2 为什么不用全局变量
全局 map 看似简单,但有三个致命问题:
| 问题 | 说明 |
|---|---|
| 缺乏扩展性 | 无法在 models 上定义任何方法 |
| 缺乏隔离性 | 如果不同 DB 之间需要隔离,毫无办法 |
| 难以测试 | 包变量的天然缺点,会间接引起不同测试之间的耦合 |
个人建议:不到逼不得已,不要使用包变量,更不要利用包变量来协调不同方法或结构体的行为。
4.3 DB 是最佳选择
DB 在 ORM 中的地位,就相当于 HTTPServer 在 Web 框架中的地位:
- 允许用户使用多个 DB 实例
- 每个 DB 实例可以单独配置(如元数据中心、超时配置、方言等)
- DB 就是天然的隔离和治理单位
// db.go 文件
// DB 代表一个数据库实例
// 它是 ORM 的核心入口,也是元数据注册中心的持有者
type DB struct {
// registry 是元数据注册中心
// 每个 DB 实例有自己的注册中心,实现了隔离
registry
}
// NewDB 创建一个 DB 实例
// 使用 Option 模式,为将来留下扩展性的口子
// 虽然目前不会返回 error,但公开方法尽量都加上 error 作为返回值
func NewDB(opts ...DBOption) (*DB, error) {
// 创建 DB 实例,初始化注册中心
db := &DB{
registry: newRegistry(),
}
// 应用用户传入的配置选项
for _, opt := range opts {
opt(db)
}
return db, nil
}
// DBOption 是 DB 的配置选项函数
// 这是 Go 中常见的 Option 模式写法
type DBOption func(*DB)
4.4 registry 定义与实现
// registry.go 文件
// registry 是元数据注册中心
// 负责解析模型、缓存元数据
type registry struct {
// models 存储已解析的模型元数据
// key 是 reflect.Type(唯一的选择)
// 为什么用 reflect.Type 而不是类型名或表名?
// - 类型名:不同包可能有同名结构体(如 buyer.User 和 seller.User)
// - 表名:在拿到元数据之前,我们不知道表名是什么
// - reflect.Type:唯一确定一个类型
models map[reflect.Type]*Model
}
// newRegistry 创建一个注册中心实例
func newRegistry() *registry {
return ®istry{
models: make(map[reflect.Type]*Model, 64),
}
}
// get 获取模型的元数据
// 如果缓存中有,直接返回;如果没有,解析后放入缓存
func (r *registry) get(val any) (*Model, error) {
typ := reflect.TypeOf(val)
// 处理指针类型
for typ.Kind() == reflect.Ptr {
typ = typ.Elem()
}
// 第一次查找:先从缓存中找
m, ok := r.models[typ]
if ok {
// 缓存命中,直接返回
return m, nil
}
// 缓存未命中,执行解析
m, err := r.parseModel(val)
if err != nil {
return nil, err
}
// 放入缓存,下次就不用再解析了
r.models[typ] = m
return m, nil
}
// parseModel 解析模型元数据(从 registry 的角度)
// 注意:这个方法只在 registry 内部使用,外部只调用 get
func (r *registry) parseModel(val any) (*Model, error) {
if val == nil {
return nil, ErrInvalidInput
}
typ := reflect.TypeOf(val)
for typ.Kind() == reflect.Ptr {
typ = typ.Elem()
}
if typ.Kind() != reflect.Struct {
return nil, ErrInvalidInput
}
m := &Model{
TableName: underscoreName(typ.Name()),
FieldMap: make(map[string]*Field, typ.NumField()),
}
for i := 0; i < typ.NumField(); i++ {
fd := typ.Field(i)
if !fd.IsExported() {
continue
}
m.FieldMap[fd.Name] = &Field{
ColName: underscoreName(fd.Name),
Type: fd.Type,
Offset: fd.Offset,
}
}
return m, nil
}
4.5 并发安全
registry 使用普通的 map,在并发读写的场景下会崩溃。有两种解决思路:
方案一:提前注册(去除并发写)
要求用户在启动前注册好所有模型,运行时只读不写。
// 方案一:要求用户提前注册
// 类似于 Web 框架中要求在启动前注册好路由
// 优点:性能最好,没有锁开销
// 缺点:开发体验差
// Beego 早期就是这种做法
func (r *registry) Register(val any) error {
// 在启动前注册,运行时不再写入
_, err := r.parseModel(val)
if err != nil {
return err
}
r.models[reflect.TypeOf(val)] = nil // 实际存入解析结果
return nil
}
方案二:读写锁(Double-Check)
// registry.go 文件
import "sync"
type registry struct {
// mu 保护 models 的并发访问
mu sync.RWMutex
// models 存储已解析的模型元数据
models map[reflect.Type]*Model
}
// get 方法:使用读写锁保证并发安全
func (r *registry) get(val any) (*Model, error) {
typ := reflect.TypeOf(val)
for typ.Kind() == reflect.Ptr {
typ = typ.Elem()
}
// 第一次查找:读锁
// 因为大部分情况下模型已经解析过了,读锁就足够
r.mu.RLock()
m, ok := r.models[typ]
r.mu.RUnlock()
if ok {
return m, nil
}
// 缓存未命中,需要写锁来解析和写入
// Double-Check:拿到写锁后再检查一次,防止重复解析
r.mu.Lock()
defer r.mu.Unlock()
// 再次检查:可能在等锁期间,别的 goroutine 已经解析好了
m, ok = r.models[typ]
if ok {
return m, nil
}
// 确实没有,开始解析
m, err := r.parseModel(val)
if err != nil {
return nil, err
}
r.models[typ] = m
return m, nil
}
方案三:sync.Map
// 方案三:使用 sync.Map
// 优点:不需要手动管理锁
// 缺点:同时解析同一类型时会出现覆盖问题(但元数据是不变的,问题不大)
type registry struct {
models sync.Map
}
func (r *registry) get(val any) (*Model, error) {
typ := reflect.TypeOf(val)
for typ.Kind() == reflect.Ptr {
typ = typ.Elem()
}
// Load:原子读取
m, ok := r.models.Load(typ)
if ok {
return m.(*Model), nil
}
// 没找到,解析
model, err := r.parseModel(val)
if err != nil {
return nil, err
}
// Store:原子写入
// 注意:如果两个 goroutine 同时解析同一个类型,
// 会出现覆盖问题,但因为元数据是不变的,影响不大
r.models.Store(typ, model)
return model, nil
}
4.6 改造 Selector 使用 registry
// Selector 从 DB 的 registry 获取元数据
func NewSelector[T any](db *DB) *Selector[T] {
return &Selector[T]{
db: db,
}
}
func (s *Selector[T]) Build() (*Query, error) {
var (
sql strings.Builder
args []any
)
// 从 DB 的 registry 获取元数据
// 如果 db 为 nil,直接解析(用于测试)
var t T
var m *Model
var err error
if s.db != nil {
// 通过注册中心获取(有缓存)
m, err = s.db.registry.get(&t)
} else {
// 没有注册中心,直接解析(每次都解析)
m, err = parseModel(&t)
}
if err != nil {
return nil, err
}
// 后续构造 SQL 逻辑同前...
sql.WriteString("SELECT * FROM ")
if s.table == "" {
sql.WriteByte('`')
sql.WriteString(m.TableName)
sql.WriteByte('`')
} else {
sql.WriteString(s.table)
}
if len(s.where) > 0 {
sql.WriteString(" WHERE ")
p := s.where[0]
for i := 1; i < len(s.where); i++ {
p = And(p, s.where[i])
}
s.buildExpression(p, &sql, &args, m)
}
return &Query{
SQL: sql.String(),
Args: args,
}, nil
}
五、自定义表名与列名
5.1 需求背景
目前我们的策略是驼峰转下划线命名,例如 FirstName 变成 first_name。但用户有各种个性化需求:
- 自定义表名:有些公司认为
User结构体对应的表名应该是user_t - 自定义列名:字段
Status在user_t表中可能叫user_status
5.2 三种方案对比
| 方案 | 优点 | 缺点 |
|---|---|---|
| 标签(Tag) | 和模型定义在一起,内聚 | 标签容易写错 |
| 接口 | 也是直接定义在模型上,可实现分库分表 | 隐晦,用户可能不知道能实现什么接口 |
| 编程注册 | 部分编译期检查,运行到注册代码就知道模型是否正确 | 学习 API 比前两个难 |
还需要考虑模型的两种来源:自己手写的结构体三种方案都可以;代码生成的结构体(如 protobuf)只能用接口和编程注册方式。
5.3 标签方式自定义列名
Go 的结构体标签(Tag)允许在字段上附加元信息:
// 用户在结构体字段上添加标签
type User struct {
// orm 标签语法:orm:"column=user_name"
// 表示这个字段对应的列名是 user_name
Name string `orm:"column=user_name"`
// 没有标签的字段,使用驼峰转下划线作为列名
Age int
// 标签中没有 column 值时,使用默认列名
Status int `orm:"column="`
}
解析标签的实现:
// model.go 文件
// parseTag 解析 orm 标签,提取 column 值
// 标签格式:orm:"column=user_name"
func parseTag(tag string) (colName string, ok bool) {
// tag 的格式是 "column=xxx" 这样的键值对
// 先按 = 分割
parts := strings.Split(tag, "=")
if len(parts) != 2 {
// 格式不对,返回 ok=false
return "", false
}
// 检查 key 是否是 "column"
if strings.TrimSpace(parts[0]) != "column" {
return "", false
}
// 返回 value
colName = strings.TrimSpace(parts[1])
if colName == "" {
// value 为空,表示用户没指定列名
// 我们可以选择使用默认值,也可以报错
// 这里选择返回 ok=false,让调用方使用默认值
return "", false
}
return colName, true
}
// 在 parseModel 中使用标签
func (r *registry) parseModel(val any) (*Model, error) {
// ... 前面的代码同前
for i := 0; i < typ.NumField(); i++ {
fd := typ.Field(i)
if !fd.IsExported() {
continue
}
// 默认使用驼峰转下划线作为列名
colName := underscoreName(fd.Name)
// 检查是否有 orm 标签
// fd.Tag.Get("orm") 获取 orm:"..." 中的内容
if tagVal, ok := fd.Tag.Lookup("orm"); ok {
// 有标签,尝试解析 column
if name, ok := parseTag(tagVal); ok {
// 用户指定了列名,使用用户指定的
colName = name
}
// 如果 ok 为 false,说明标签中没有有效的 column 值
// 继续使用默认的驼峰转下划线列名
}
m.FieldMap[fd.Name] = &Field{
ColName: colName,
Type: fd.Type,
Offset: fd.Offset,
}
}
return m, nil
}
5.4 接口方式自定义表名
Go 的标签只能声明在字段上,不能声明在类型上。所以结构体级别(表级别)的定制,需要用接口来实现。
// model.go 文件
// TableName 接口:用户实现这个接口来自定义表名
// 类似 Beego 的 TableNameI 和 GORM 的 Tabler
type TableName interface {
// TableName 方法返回数据库表名
TableName() string
}
// 在 parseModel 中检查是否实现了 TableName 接口
func (r *registry) parseModel(val any) (*Model, error) {
// ... 前面的代码
// 检查是否实现了 TableName 接口
// 注意:要同时检查结构体和结构体指针两种接收器
// 因为 Go 中结构体实现接口和指针实现接口不是等价的
tableName := underscoreName(typ.Name()) // 默认值
// 尝试用值类型断言
if t, ok := reflect.New(typ).Interface().(TableName); ok {
tableName = t.TableName()
}
// 也可以检查指针类型
// if t, ok := reflect.New(typ).Elem().Addr().Interface().(TableName); ok {
// tableName = t.TableName()
// }
m := &Model{
TableName: tableName,
FieldMap: make(map[string]*Field, typ.NumField()),
}
// ... 后续解析字段
return m, nil
}
用户使用示例:
// User 实现了 TableName 接口
// ORM 会使用 TableName() 返回的值作为表名
type User struct {
Id int64
Name string
}
// 自定义表名为 user_t
func (u User) TableName() string {
return "user_t"
}
// 现在查询会生成:SELECT * FROM `user_t` WHERE ...
## 自测题与动手练习
**自测题(合上书能答出来,才算懂)**:
1. ORM 的两大核心职责是什么?为什么作者说元数据是 SQL 构造和结果集映射的"基石"?
2. GORM 构造 SQL 的四个抽象(Builder、Expression、Clause、Interface)分别负责什么?一句话概括其设计理念。
3. `Predicate` 结合 `AND` / `OR` / `NOT` 后会构成什么数据结构?为什么 `NOT` 只有右操作数、没有左操作数?
4. 用反射解析模型时,为什么必须先处理指针(`Elem`)?私有(非导出)字段为什么被跳过?
5. 元数据注册中心为什么不用全局 `map`?读写锁方案里的 Double-Check 是为了解决什么问题?
**动手练习(建议真做一遍)**:
1. 实现 `underscoreName`,并补测试用例覆盖 `ID` 这种连续大写的已知缺陷(期望 `id` 而非 `i_d`),体会"防呆测试"的写法。
2. 给 `Selector` 增加 `Limit(n)` 与 `Offset(n)` 方法,让它能生成带分页的完整 `SELECT ... LIMIT ? OFFSET ?`。
3. 用 `sync.Map` 重写 `registry.get`,写一段并发基准测试,对比读写锁(Double-Check)方案在高并发下的表现。
## 本章小结
- ORM 的本质是**对象↔SQL**与**结果集↔对象**两段自动映射,而元数据(表名、列名、类型、偏移量)是支撑这两段的共同基石。
- SQL 构造上,Beego(链式/QueryBuilder)、GORM(Builder+Expression+Clause)、Ent(纯代码生成)代表了从"重运行期"到"重编译期"的不同取舍;自研框架采用单一职责的 Builder 模式(`Selector` / `Deleter` / `Updater` / `Inserter`)。
- WHERE 条件通过 `Predicate` / `Expression` 抽象成可递归组合的树,既能校验列名,又能自动加括号保证语义正确。
- 元数据的获取靠 Go 反射,必须把"指针还是值""是否导出字段"这两个坑处理干净;同类模型只解析一次,靠挂在 `DB` 上的注册中心缓存,并优先用读写锁或 `sync.Map` 保证并发安全。
- 自定义表名/列名提供标签、接口、编程注册三种方案,需在"内聚"与"对代码生成友好"之间权衡。
> 下一章我们将进入"结果集处理"——也就是 ORM 怎么把数据库返回的行,真正填回 Go 结构体;在那里你会遇到反射与 `unsafe` 的性能之争,以及 SELECT 进阶(指定列、聚合)与 INSERT 的设计。
5.5 编程方式自定义表名和列名
我们允许用户显式地注册模型,同时自定义表名和列名。这种方式使用 Option 模式:
// registry.go 文件
// Registry 接口:注册中心的抽象
type Registry interface {
// Register 注册模型,可附带自定义选项
Register(model any, opts ...ModelOption) (*Model, error)
// Get 获取模型的元数据
Get(val any) (*Model, error)
}
// ModelOption 是模型注册的配置选项
type ModelOption func(m *Model) error
// Model 注册时可以自定义的选项
// WithTableName 自定义表名
// 用法:db.Register(&User{}, orm.WithTableName("user_t"))
func WithTableName(name string) ModelOption {
return func(m *Model) error {
if name == "" {
return ErrInvalidInput
}
m.TableName = name
return nil
}
}
// WithColumnName 自定义列名
// 用法:db.Register(&User{}, orm.WithColumnName("Name", "user_name"))
func WithColumnName(field string, colName string) ModelOption {
return func(m *Model) error {
fd, ok := m.FieldMap[field]
if !ok {
return NewUnknownColumnError(field, m.TableName)
}
fd.ColName = colName
return nil
}
}
// Register 方法实现
func (r *registry) Register(val any, opts ...ModelOption) (*Model, error) {
// 先解析模型
m, err := r.parseModel(val)
if err != nil {
return nil, err
}
// 应用用户传入的选项
for _, opt := range opts {
if err = opt(m); err != nil {
return nil, err
}
}
// 放入缓存
typ := reflect.TypeOf(val)
for typ.Kind() == reflect.Ptr {
typ = typ.Elem()
}
r.mu.Lock()
defer r.mu.Unlock()
r.models[typ] = m
// 返回注册后的模型元数据
// 在实际使用中,返回注册后的数据会很好用
return m, nil
}
用户使用示例:
func main() {
// 创建 DB 实例
db, _ := NewDB()
// 注册模型,自定义表名和列名
// 编程方式的优点:运行到注册代码就知道模型是否正确
_, err := db.Register(&User{},
// 自定义表名为 user_t
WithTableName("user_t"),
// 自定义列名:Name 字段对应 user_name 列
WithColumnName("Name", "user_name"),
// 自定义列名:Status 字段对应 user_status 列
WithColumnName("Status", "user_status"),
)
if err != nil {
panic(err)
}
// 查询
// 现在会生成:SELECT * FROM `user_t` WHERE `user_name` = ?
selector := NewSelector[User](db)
q, err := selector.Where(Eq(C("Name"), value{val: "大明"})).Build()
if err != nil {
panic(err)
}
fmt.Println("SQL:", q.SQL)
fmt.Println("Args:", q.Args)
}
5.6 元数据总结
对于 ORM 来说,用户应该操作字段名(Go 结构体的字段名),从而不必知道数据库的定义,达到解耦的效果。用户操作字段名,但 SQL 使用的是列名——这之间的转换由元数据完成。
// Model 的字段都是私有的
// 虽然对外暴露了 Model 类型,但字段不公开
// 因为目前来看,用户不应该自由修改 Model
type Model struct {
tableName string
fieldMap map[string]*Field
}
// Field 同样
type Field struct {
colName string
typ reflect.Type
offset uintptr
}
// 用户只能通过 Register 方法的 Option 来定制
// 不能直接操作 Model 内部字段
六、面试要点总结
ORM 框架概览
- ORM 框架的核心是什么? SQL 构造和处理结果集。Go 不需要 ORM 解决连接和会话管理的问题(标准库已解决)。
- ORM 是什么? 对象关系映射,用于语言对象和关系型数据库行相互转化的工具。
- 使用 ORM 性能会更好吗? 显然不能,直接写 SQL 的性能最好。
SELECT 起步
- 什么是 Builder 模式? 用于构造复杂多变的对象。SQL 的不同部分(FROM、WHERE、ORDER BY 等)分开构造,最后拼接在一起。
- ORM 中泛型有什么用? 约束用户传入的参数或返回值,加强类型安全。
元数据
- ORM 如何将结构体映射为表? 依赖于元数据,元数据描述了两者之间的映射关系。
- 元数据包含什么? 表信息(表名、分库分表)、列信息(列名、类型、索引、主键)、关联关系。
- 如何获得模型信息? 利用反射解析 Go 类型,同时可利用 Tag 或编程接口允许用户额外定制。
- Go 反射能不能修改方法? 不能。Go runtime 没有暴露接口。
- 什么样的字段可以被反射修改?
CanSet方法返回 true 的字段,简单来说就是 addressable 的字段。