一、select与元数据

2021-02-14T14:21:02+08:00 | 33分钟阅读 | 更新于 2021-02-14T14:21:02+08:00

@

学习目标

学完本章,你应该能够:

  1. 用自己的话讲清 ORM 的两大核心职责(对象↔SQL、结果集↔对象),并说清元数据在其中扮演的"基石"角色。
  2. 对比 Beego ORM、GORM、Ent 在 SQL 构造方式与元数据层级上的设计差异,并在选型时权衡灵活性与学习成本。
  3. 解释 Selector 如何用 Builder 模式 + 泛型构造 SELECT,以及 Predicate / Expression 如何支撑 AND / OR / NOT 的树形组合。
  4. 用 Go 反射(reflect.Type / reflect.Value)从结构体解析出元数据(表名、列名、偏移量),并说清指针/值、导出字段的处理细节。
  5. 解释元数据注册中心为什么用 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 ORMGORMEnt
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 子句决定查询哪张表。我们提供两种方式:

  1. 如果用户没有指定表名,默认使用泛型类型名
  2. 如果用户调用了 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 像"取反"——它只需要右边那棵子树,自己没有左边。每当我们把两个 PredicateAnd(...) / Or(...) 串起来,就等于在树上多长出一个分叉节点。

这张图展示了 id = 1 OR name = 大明 再和 age > 18AND 组合后的结构:

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 为 PtrArraySlice 等时才能调用。

用反射输出字段名和值

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 &registry{
        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
  • 自定义列名:字段 Statususer_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 的四个抽象BuilderExpressionClauseInterface分别负责什么一句话概括其设计理念
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)、GORMBuilder+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 的字段。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!