三、结果集处理、SELECT 进阶与 INSERT

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

@

学习目标

学完本章,你应该能够:

  1. 解释 ORM 如何把 sql.Rows 映射回 Go 结构体(Get 流程、valuer 抽象、QueryContext 的复用考量)。
  2. 讲清 unsafe.Pointer 与 uintptr 在 GC 下的行为差异,并说明为什么 uintptr 只适合表达"偏移量"这类相对量。
  3. 对比反射与 unsafe 两种结果集方案,说清 unsafe 为什么更快——核心在于省去了"先建临时对象再搬家"的步骤。
  4. 用 Selectable / Column / Aggregate / RawExpr 构造指定列与聚合查询,理解"严苛设计"与"兜底方案"的取舍。
  5. 设计 Inserter / UPSERT,并解释 Dialect 抽象与 Upsert API 命名背后的工程权衡。

前置知识:

  • 第一章的 Selector / 元数据 / 注册中心知识(本章直接复用)
  • database/sql 的 QueryContext / Scan / ExecContext 用法
  • Go 内存布局与对齐的直觉(字长、填充)
  • 基础 SQL 语法(SELECT 指定列、聚合函数、INSERT、UPSERT)

本章你会动手做的事:

  • 用 OpenDB + sqlmock 给 Selector.Get 写一段测试,体会依赖倒置带来的可测性。
  • 写一个 Benchmark 对比反射方案与 unsafe 方案处理结果集的耗时(预期差 2~3 倍)。
  • 给 Inserter 接入方言,分别为 MySQL 和 SQLite 生成正确的 UPSERT 语句。
结果集·SELECT·INSERT结果集处理SELECT 进阶INSERT / UPSERTGet: QueryContext 复用反射 vs unsafe · valuerSelectable 标记接口Column/Aggregate/RawExprInserter · Builder 模式Dialect · Upsert/OnDuplicate

一、结果集处理 —— 让 ORM 和 sql 包结合

在前两章中,我们已经学会了如何构造 SELECT 语句,以及如何使用 database/sql 包进行 SQL 编程。现在,我们需要把这两部分结合起来——让 ORM 框架真正能够发起查询,并把数据库返回的结果集转换为 Go 结构体。

1.1 发起查询

完成了 SQL 构建之后,下一步就是执行查询。我们利用前面学到的 database/sql 知识,将 ORM 和 sql 包结合在一起:

// selector.go 文件

// Get 执行查询并返回单条记录
// T 是泛型参数,代表用户定义的模型类型(如 User)
func (s *Selector[T]) Get(ctx context.Context) (*T, error) {
    // 1. 构建 SQL 语句和参数
    //    例如:SELECT * FROM user WHERE id = ?  参数:[1]
    q, args, err := s.Build()
    if err != nil {
        return nil, err
    }

    // 2. 使用 QueryContext 发起查询
    //    为什么用 QueryContext 而不是 QueryRow?
    //    因为 QueryRow 内部也是调用 QueryContext,直接用 QueryContext 可以在
    //    GetMulti(查多条)和 Get(查一条)之间复用代码
    rows, err := s.db.db.QueryContext(ctx, q, args...)
    if err != nil {
        return nil, err
    }

    // 3. 遍历结果集
    //    rows 是一个迭代器,必须调用 Close 释放数据库连接
    for rows.Next() {
        // 创建一个 T 类型的零值对象
        // new(T) 等价于 &T{}
        res := new(T)
        // 将结果集的列值扫描到 res 对应的字段中
        // 这里用到了 valuer(后面会详细讲)
        val := s.valCreator(s.model, res)
        // cols 是数据库返回的列名列表
        cols, _ := rows.Columns()
        // values 是要传给 Scan 的参数列表
        values := make([]any, 0, len(cols))
        for _, c := range cols {
            // 根据列名找到对应的字段地址
            fd, ok := val.field(c)
            if ok {
                values = append(values, fd)
            } else {
                // 列名在模型中找不到对应字段
                // 用一个临时变量接收,避免 Scan 报错
                values = append(values, &sql.RawBytes{})
            }
        }
        // 执行扫描
        err = rows.Scan(values...)
        if err != nil {
            return nil, err
        }
        // 找到第一条记录就返回
        return res, nil
    }

    // 4. 检查遍历过程中的错误
    //    Next 返回 false 可能是正常结束,也可能是出错
    if err = rows.Err(); err != nil {
        return nil, err
    }

    // 5. 没有找到任何记录
    //    等价于 sql.ErrNoRows 的语义
    return nil, ErrNoRows
}
复习提示:关键设计:使用 QueryContext 而不是 QueryRow,是因为 Get(查一条)和 GetMulti(查多条)的查询逻辑高度相似,通过复用可以减少重复代码。

1.2 创建 DB:Open 和 OpenDB

ORM 框架需要封装 sql.DB,将其作为自己的核心组件。我们提供两种创建方式:

// db.go 文件

import (
    "database/sql"
)

// DB 是 ORM 框架的核心结构体
// 它封装了 sql.DB,并持有元数据注册中心
type DB struct {
    // db 是标准库的 sql.DB
    // ORM 的所有操作最终都会通过它来执行
    db *sql.DB

    // r 是元数据注册中心(Registry)
    // 负责解析和缓存模型元数据
    r  registry

    // valCreator 用于创建 valuer
    // valuer 是处理结果集的核心抽象(后面详细讲)
    valCreator valCreator
}

// ========== 方式一:Open ==========
// Open 接收驱动名和 DSN,内部创建 sql.DB
// 适合大多数场景:用户只需要提供连接信息
//
// 参数:
//   driverName: 驱动名,例如 "mysql"、"sqlite3"
//   dsn: 数据源名称,例如 "root:password@tcp(127.0.0.1:3306)/test"
func Open(driverName, dsn string, opts ...DBOption) (*DB, error) {
    // 调用 sql.Open 创建标准库的 DB
    db, err := sql.Open(driverName, dsn)
    if err != nil {
        return nil, err
    }
    // 内部调用 OpenDB
    return OpenDB(db, opts...)
}

// ========== 方式二:OpenDB ==========
// OpenDB 接收用户自己创建的 sql.DB
// 适合以下场景:
//   1. 测试:配合 sqlmock 使用
//   2. 集成数据库中间件:用户可能已经用中间件包装了 sql.DB
func OpenDB(db *sql.DB, opts ...DBOption) (*DB, error) {
    res := &DB{
        db: db,
        // 默认使用反射方式处理结果集
        valCreator: reflectValueCreator,
        // 默认使用元数据注册中心
        r:          &registry{},
    }
    // 应用用户传入的选项
    for _, opt := range opts {
        err := opt(res)
        if err != nil {
            return nil, err
        }
    }
    return res, nil
}

// DBOption 是 DB 的配置选项(Option 模式)
// 允许用户在创建 DB 时自定义行为
type DBOption func(*DB) error
复习提示:面试要点:OpenDB 常用于测试(配合 sqlmock)和集成数据库中间件。两种方式的区别在于谁来创建 sql.DB——Open 由 ORM 创建,OpenDB 由用户创建后传入。

1.3 反射方案处理结果集

处理结果集是 ORM 框架的核心能力之一。我们需要将数据库返回的行数据(*sql.Rows)映射到 Go 结构体中。

最直观的反射方案

// reflect_value.go 文件

// reflectValue 使用反射来处理结果集
// 它的核心职责:根据列名找到结构体中对应字段的地址
type reflectValue struct {
    model *Model
    // val 是目标结构体的反射值(reflect.Value)
    val reflect.Value
}

// newReflectValue 创建一个 reflectValue 实例
func newReflectValue(model *Model, val any) *reflectValue {
    return &reflectValue{
        model: model,
        // reflect.ValueOf(val) 得到的是指向 val 的指针的反射值
        // .Elem() 解引用,得到 val 本身的反射值
        // 这样得到的 reflect.Value 是 addressable 的(可寻址的)
        val: reflect.ValueOf(val).Elem(),
    }
}

// field 根据数据库列名,返回对应字段的地址
// 返回的 any 实际上是字段的指针,可以直接传给 rows.Scan
func (r *reflectValue) field(colName string) (any, bool) {
    // 1. 通过列名查找字段的元数据
    fd, ok := r.model.FieldMap[colName]
    if !ok {
        // 列名在模型中不存在
        return nil, false
    }

    // 2. 通过反射获取字段的值
    //    r.val 是结构体的反射值
    //    .FieldByIndex 用字段的索引(从元数据中获取)定位到具体字段
    //    注意:这里的索引是结构体字段的定义顺序索引
    fieldVal := r.val.FieldByIndex(fd.Index)

    // 3. 获取字段地址
    //    .Addr() 返回字段值的指针的反射值
    //    .Interface() 将反射值转为 Go 的 any 类型
    //    这个指针可以直接传给 rows.Scan
    return fieldVal.Addr().Interface(), true
}

反射方案的工作流程

数据库返回一行数据
  │
  ▼
rows.Columns() → ["id", "name", "age"]     ← 获取列名列表
  │
  ▼
对每个列名,调用 valuer.field(colName)
  │
  ├── "id"   → FieldMap["id"]   → 字段索引 [0] → reflect.Field(0).Addr() → &obj.Id
  ├── "name" → FieldMap["name"] → 字段索引 [1] → reflect.Field(1).Addr() → &obj.Name
  └── "age"  → FieldMap["age"]  → 字段索引 [2] → reflect.Field(2).Addr() → &obj.Age
  │
  ▼
rows.Scan(&obj.Id, &obj.Name, &obj.Age)     ← sql 包将数据填入这些地址
  │
  ▼
obj = {Id: 1, Name: "大明", Age: 28}        ← 最终得到填充好的结构体
复习提示:反射方案的原理:反射在别的地方(通过 reflect.Value)建好了对象,让 Scan 填充内容,然后再通过反射把值挪到正确的位置。这个过程有额外的开销,后面我们会用 unsafe 来优化。

二、Go unsafe 基础

要理解 unsafe 方案,首先需要理解 Go 对象在内存中是怎么布局的。unsafe 包提供了直接操作内存的能力,绕过反射的开销。

2.1 对象内存布局

在 Go 中,一个结构体在内存中是连续存放的,字段按照定义顺序排列。但并不是简单的"紧挨着"——还有**对齐(alignment)**规则。

package main

import (
    "fmt"
    "unsafe"
)

// User 测试结构体
type User struct {
    Id    int64   // 8 字节(在 64 位机器上)
    Age   int8    // 1 字节
    // 7 字节空白(对齐填充)
    Name  string  // 16 字节(string 内部是指针 + 长度)
}

func main() {
    var u User
    // unsafe.Offsetof 获取字段相对于结构体起始地址的偏移量
    fmt.Printf("User 大小: %d\n", unsafe.Sizeof(u))
    fmt.Printf("Id 偏移量: %d\n", unsafe.Offsetof(u.Id))    // 0
    fmt.Printf("Age 偏移量: %d\n", unsafe.Offsetof(u.Age))  // 8
    fmt.Printf("Name 偏移量: %d\n", unsafe.Offsetof(u.Name)) // 16
    // 为什么 Name 的偏移量是 16 而不是 9?
    // 因为 Go 按照字长对齐!
}

2.2 Go 对齐规则

Go 的内存对齐规则是按照字长对齐:

  • 在 32 位机器上,按 4 字节对齐
  • 在 64 位机器上,按 8 字节对齐
结构体 User 在 64 位机器上的内存布局:

偏移量:  0        8    9              16                32
       ┌────────┬──┬─────────────┬──────────────────┐
       │  Id    │Age│  填充空白    │     Name         │
       │ int64  │i8 │  7 bytes    │ string(16 bytes) │
       └────────┴──┴─────────────┴──────────────────┘

- Id   偏移量 = 0,大小 = 8
- Age  偏移量 = 8,大小 = 1
- 空白填充 7 字节(为了让 Name 按 8 字节对齐)
- Name 偏移量 = 16,大小 = 16
- 总大小 = 32
复习提示:为什么需要对齐:CPU 每次访问内存都是按照字长的倍数来访问的。如果一个字段跨越了字长边界,CPU 需要两次访问才能读完,性能下降。所以编译器会自动插入填充字节来保证对齐。

2.3 unsafe.Pointer 和 uintptr

Go 提供了两种表示指针的类型,它们的区别非常关键:

类型特性GC 是否维护使用场景
unsafe.PointerGo 层面的指针,有类型信息是,GC 会自动调整需要长期持有指针时
uintptr只是一个整数,代表内存地址否,GC 不会维护只用于地址运算(偏移量计算)
package main

import (
    "fmt"
    "unsafe"
    "reflect"
)

type User struct {
    Id   int64
    Name string
}

func main() {
    u := &User{Id: 42, Name: "大明"}

    // ========== unsafe.Pointer ==========
    // unsafe.Pointer 是 Go 层面的指针
    // GC 会维护它——如果对象被移动,Pointer 的值会被自动更新
    // 所以它始终指向正确的地址
    ptr := unsafe.Pointer(u)
    fmt.Printf("起始地址: %p\n", ptr)

    // ========== uintptr ==========
    // uintptr 只是一个整数,代表内存地址
    // GC 不会维护它——如果对象被移动,uintptr 的值不会更新!
    addr := uintptr(ptr)
    fmt.Printf("地址数值: %x\n", addr)

    // ========== 字段偏移量 ==========
    // 字段偏移量是相对于结构体起始地址的固定偏移
    // 无论 GC 怎么移动对象,偏移量永远不变
    // 所以用 uintptr 存储偏移量是安全的
    idOffset := unsafe.Offsetof(u.Id)     // 0
    nameOffset := unsafe.Offsetof(u.Name) // 8
    fmt.Printf("Id 偏移量: %d\n", idOffset)
    fmt.Printf("Name 偏移量: %d\n", nameOffset)

    // ========== 通过 unsafe 读写字段 ==========
    // 读取 Id 字段:
    // 1. 起始地址 + 字段偏移量 = 字段地址
    // 2. 将字段地址转换为对应类型的指针
    // 3. 解引用得到值
    idPtr := (*int64)(unsafe.Add(ptr, idOffset))
    fmt.Printf("Id = %d\n", *idPtr) // 输出: Id = 42

    // 写入 Id 字段
    *idPtr = 100
    fmt.Printf("修改后 Id = %d\n", u.Id) // 输出: 修改后 Id = 100

    // 读取 Name 字段(string 类型)
    namePtr := (*string)(unsafe.Add(ptr, nameOffset))
    fmt.Printf("Name = %s\n", *namePtr) // 输出: Name = 大明
}

2.4 uintptr 使用误区

// ========== 错误用法:用 uintptr 保存对象地址 ==========

func DangerousAccess(u *User) {
    // 错误!将对象地址转为 uintptr 保存
    // 如果此时发生 GC,对象被移动到新地址
    // 但 uintptr 不会更新,仍然指向旧地址
    addr := uintptr(unsafe.Pointer(u))

    // 假设这里触发了 GC...
    runtime.GC()

    // 此时 addr 指向的可能是无效内存!
    // 读取到的数据是未定义的,可能崩溃
    idPtr := (*int64)(unsafe.Pointer(addr))
    fmt.Println(*idPtr) // 危险!
}

// ========== 正确用法:用 unsafe.Pointer 保存对象地址 ==========

func SafeAccess(u *User) {
    // 正确!使用 unsafe.Pointer 保存对象地址
    // GC 会自动更新它的值
    ptr := unsafe.Pointer(u)

    // 即使发生 GC,ptr 仍然指向正确的地址
    runtime.GC()

    idPtr := (*int64)(unsafe.Add(ptr, unsafe.Offsetof(u.Id)))
    fmt.Println(*idPtr) // 安全
}

// ========== 安全规则 ==========
// 1. uintptr 只用于表达"相对量"(如字段偏移量)
//    偏移量不管怎么 GC 都不会变
// 2. 如果怕出错,只在地址运算时用 uintptr,其它时候都用 unsafe.Pointer
// 3. 不要将 uintptr 跨函数调用传递
复习提示:面试要点:uintptr 代表具体地址,unsafe.Pointer 代表逻辑指针。后者在 GC 时会被 Go runtime 自动调整,始终指向真实存放对象的地址。uintptr 不被 GC 维护,所以只用于表达偏移量这类不会变的相对量。

三、unsafe 方案处理结果集

了解了 unsafe 的基础知识后,我们就可以用它来优化结果集处理了。核心思路是:直接在目标结构体的字段地址上创建对象,让 Scan 直接填充到正确位置,省去反射的中间步骤。

复习提示:白话类比:数据库返回的数据像一批快递。反射方案是"先放到驿站临时货架(Scan 填进临时地址),再雇人一件件搬到你的家门口(反射赋值)"——多了一道搬运工序。而 unsafe 方案是你事先在家门口摆好对应的空箱子(在目标地址直接建好字段零值),快递员(Scan)直接把货塞进箱子,一步到位,自然更快。

这张图对比了两种方案的数据流向:

flowchart LR
    DB[(数据库结果集)] --> R[反射方案
写入临时地址] R --> M[中间变量] M --> S[结构体字段
再反射赋值] DB --> U[unsafe 方案
目的地建好空箱子] U --> F[结构体字段
Scan 直接填充]

3.1 Value API 设计

我们将处理结果集的逻辑从 Selector 中分离出去,抽象成一个 valuer 接口:

// internal/valuer/valuer.go 文件

// valuer 是处理结果集的抽象接口
// 有两个实现:
//   1. reflectValue:使用反射(简单,但慢)
//   2. unsafeValue:使用 unsafe(快,但复杂)
type valuer interface {
    // field 根据数据库列名返回对应字段的地址
    // 返回的值可以直接传给 rows.Scan
    // 第二个返回值表示该列名是否在模型中存在
    field(name string) (any, bool)
}

// valCreator 是创建 valuer 的工厂函数类型
type valCreator func(model *Model, val any) valuer
复习提示:设计要点:为什么要抽象出 valuer?因为我们希望让用户可以自由选择使用反射还是 unsafe 来处理结果集。Selector 不应该知道具体用的是哪种方案——这就是依赖倒置原则的体现。

3.2 反射实现

// internal/valuer/reflect.go 文件

// reflectValue 使用反射处理结果集
type reflectValue struct {
    model *Model
    val   reflect.Value
}

// reflectValueCreator 是反射方案的工厂函数
func reflectValueCreator(model *Model, val any) valuer {
    return &reflectValue{
        model: model,
        // reflect.ValueOf(val) 得到指针的反射值
        // .Elem() 解引用,得到结构体本身的反射值(可寻址)
        val: reflect.ValueOf(val).Elem(),
    }
}

// field 实现了 valuer 接口
func (r *reflectValue) field(name string) (any, bool) {
    // 通过列名找到字段元数据
    fd, ok := r.model.FieldMap[name]
    if !ok {
        return nil, false
    }
    // 通过反射获取字段地址
    fieldVal := r.val.FieldByIndex(fd.Index)
    return fieldVal.Addr().Interface(), true
}

3.3 unsafe 实现

// internal/valuer/unsafe.go 文件

import (
    "reflect"
    "unsafe"
)

// unsafeValue 使用 unsafe 处理结果集
// 相比反射方案,unsafe 直接在目的地建好对象,让 Scan 去填充
// 而反射则是在别的地方建好了,Scan 填充内容,然后再自己挪到正确的位置
type unsafeValue struct {
    model *Model
    // val 是目标结构体的指针(起始地址)
    val unsafe.Pointer
}

// newUnsafeValue 创建一个 unsafeValue 实例
func newUnsafeValue(model *Model, val any) *unsafeValue {
    return &unsafeValue{
        model: model,
        // 获取对象的起始地址
        // reflect.ValueOf(val).Pointer() 返回 uintptr
        // 转成 unsafe.Pointer 以便后续操作
        val: unsafe.Pointer(reflect.ValueOf(val).Pointer()),
    }
}

// unsafeValueCreator 是 unsafe 方案的工厂函数
func unsafeValueCreator(model *Model, val any) valuer {
    return newUnsafeValue(model, val)
}

// field 实现了 valuer 接口
// 核心逻辑:起始地址 + 字段偏移量 = 字段地址
func (u *unsafeValue) field(name string) (any, bool) {
    // 1. 通过列名找到字段元数据
    fd, ok := u.model.FieldMap[name]
    if !ok {
        return nil, false
    }

    // 2. 计算字段的实际地址
    //    字段地址 = 结构体起始地址 + 字段偏移量
    //    u.val 是起始地址(unsafe.Pointer)
    //    fd.Offset 是偏移量(uintptr)
    //    unsafe.Add 返回新的 unsafe.Pointer
    fieldPtr := unsafe.Add(u.val, fd.Offset)

    // 3. 在字段地址处创建对应类型的对象
    //    reflect.NewAt(typ, ptr) 在指定地址创建一个 typ 类型的新对象
    //    .Elem() 解引用得到 reflect.Value
    //    .Addr() 得到地址的 reflect.Value
    //    .Interface() 转为 any
    //
    //    为什么用 reflect.NewAt?
    //    因为我们不知道字段的类型是什么(运行时才知道),
    //    但 reflect.NewAt 可以接受 reflect.Type 参数
    fieldVal := reflect.NewAt(fd.Type, fieldPtr)
    return fieldVal.Interface(), true
}

3.4 unsafe vs 反射对比

反射方案的工作流程:
  ┌──────────┐     Scan 填充      ┌──────────────┐    反射赋值    ┌──────────┐
  │  数据库   │ ──────────────→ │ 临时变量地址  │ ──────────→ │ 结构体字段 │
  └──────────┘                  └──────────────┘              └──────────┘
  结果集数据                     Scan 在这里写入               最终存到这里
                                (中间位置)                   (目的地)

unsafe 方案的工作流程:
  ┌──────────┐                    Scan 直接填充                  ┌──────────┐
  │  数据库   │ ──────────────────────────────────────────────→ │ 结构体字段 │
  └──────────┘                                                   └──────────┘
  结果集数据                                                      最终存到这里
                                                                (目的地 = 写入位置)
复习提示:核心区别:unsafe 直接在目的地建好了对象,让 Scan 去填充;而反射则是在别的地方建好了对象,Scan 填充内容后,还需要通过反射把值挪到正确的位置。unsafe 方案省去了这个"搬家"步骤。

3.5 元数据支持字段偏移量

为了支持 unsafe 方案,我们需要在元数据中增加字段偏移量信息:

// model.go 文件

// Field 代表一个字段的元数据
type Field struct {
    // ColName 数据库列名
    ColName string

    // Type Go 类型(reflect.Type)
    Type reflect.Type

    // Index 字段在结构体中的索引路径
    // 用于反射的 FieldByIndex 方法
    // 例如嵌入字段的索引可能是 [0, 1]
    Index []int

    // Offset 字段相对于结构体起始地址的偏移量
    // 用于 unsafe 方案
    // 通过反射获取:reflect.Type.Offset
    // 这个值是固定的,不管对象怎么 GC 都不会变
    Offset uintptr
}

// Model 代表一个模型的元数据
type Model struct {
    TableName string
    // FieldMap 列名 -> 字段元数据
    FieldMap  map[string]*Field
    // FieldMapByGoName Go 字段名 -> 字段元数据
    // 用于 INSERT 时通过字段名查找列名
    FieldMapByGoName map[string]*Field
}

// parseModel 解析模型,生成元数据
func (r *registry) parseModel(val any) (*Model, error) {
    // 处理指针类型
    typ := reflect.TypeOf(val)
    for typ.Kind() == reflect.Ptr {
        typ = typ.Elem()
    }

    if typ.Kind() != reflect.Struct {
        return nil, errs.ErrPointerOnly
    }

    m := &Model{
        TableName: underscoreName(typ.Name()), // 驼峰转下划线
        FieldMap:  make(map[string]*Field, typ.NumField()),
        FieldMapByGoName: make(map[string]*Field, typ.NumField()),
    }

    // 遍历所有字段
    for i := 0; i < typ.NumField(); i++ {
        fd := typ.Field(i)
        // 跳过非导出字段(小写字母开头)
        if !fd.IsExported() {
            continue
        }

        f := &Field{
            ColName: underscoreName(fd.Name), // 驼峰转下划线
            Type:    fd.Type,
            Index:   fd.Index,
            // 通过反射获取偏移量
            // 这个值在编译期就确定了,运行时通过反射读取
            Offset:  fd.Offset,
        }
        m.FieldMap[f.ColName] = f
        m.FieldMapByGoName[fd.Name] = f
    }

    return m, nil
}

3.6 Creator 抽象 —— 让用户自由选择

我们的目标是让用户可以自由选择使用反射还是 unsafe。有两种方案:

方案做法优点缺点
方案一在 Selector 中维持标记位 isUnsafe简单Selector 需要知道实现细节
方案二在 Selector 中维持 Creator 工厂函数解耦,符合开闭原则稍微复杂

我们选择方案二,因为 Selector 不应该知道究竟用的是 unsafe 还是反射:

// selector.go 文件

// Selector 是 SELECT 语句的构造器
type Selector[T any] struct {
    db    *DB
    model *Model
    // valCreator 用于创建 valuer(处理结果集的抽象)
    // 可以是反射方案,也可以是 unsafe 方案
    valCreator valCreator
    // ... 其它字段
}

// Get 执行查询
func (s *Selector[T]) Get(ctx context.Context) (*T, error) {
    // ... 构建 SQL ...

    // 创建结果集处理器
    // s.valCreator 决定用反射还是 unsafe
    res := new(T)
    val := s.valCreator(s.model, res)

    // ... 调用 val.field() 处理结果集 ...
    _ = val
    return res, nil
}
// 用户使用示例

// 方式一:默认使用反射(适合初学者,安全)
db, _ := orm.Open("mysql", "root:password@tcp(localhost:3306)/test")
// db.valCreator 默认是 reflectValueCreator

// 方式二:使用 unsafe(追求性能)
db, _ = orm.Open("mysql", "root:password@tcp(localhost:3306)/test",
    orm.DBWithValuer(orm.UnsafeValuer))
// 此时 db.valCreator 是 unsafeValueCreator

// 查询操作完全一样,用户感知不到差异
u, err := orm.NewSelector[User](db).Get(ctx)

3.7 性能测试对比

// reflect_test.go 文件

// BenchmarkReflectValue 基准测试:反射方案
func BenchmarkReflectValue(b *testing.B) {
    model := &Model{
        FieldMap: map[string]*Field{
            "id":   {ColName: "id", Index: []int{0}, Offset: 0},
            "name": {ColName: "name", Index: []int{1}, Offset: 8},
        },
    }
    for i := 0; i < b.N; i++ {
        u := &User{}
        val := newReflectValue(model, u)
        _, _ = val.field("id")
        _, _ = val.field("name")
    }
}

// BenchmarkUnsafeValue 基准测试:unsafe 方案
func BenchmarkUnsafeValue(b *testing.B) {
    model := &Model{
        FieldMap: map[string]*Field{
            "id":   {ColName: "id", Index: []int{0}, Offset: 0},
            "name": {ColName: "name", Index: []int{1}, Offset: 8},
        },
    }
    for i := 0; i < b.N; i++ {
        u := &User{}
        val := newUnsafeValue(model, u)
        _, _ = val.field("id")
        _, _ = val.field("name")
    }
}
复习提示:性能结论:unsafe 方案比反射方案快约 2~3 倍。因为反射可以看作是对 unsafe 的封装,直接使用 unsafe 就相当于绕开了中间商,同时减少了 CPU 消耗和内存消耗。

3.8 抽取 model 包

在实现 valuer 时,我们遇到了一个依赖循环问题:valuer 需要访问 Model,而 Model 定义在 registry 中,registry 又在 DB 中。为了解决这个问题,我们将 Model 和 Field 抽取到独立的 model 包中。

项目结构:
orm/
├── model/
│   └── model.go        ← Model 和 Field 的定义(公有字段)
├── internal/
│   └── valuer/
│       ├── valuer.go   ← valuer 接口定义
│       ├── reflect.go  ← 反射实现
│       └── unsafe.go   ← unsafe 实现
├── db.go               ← DB 定义
└── selector.go         ← Selector 定义

有两种方案:

方案做法优点缺点
方案一放到 internal/ 下外部无法访问,封装好用户无法自定义 Registry
方案二放到顶级目录用户可以自定义 Registry字段暴露,可能被误用

我们选择方案二,允许用户实现自己的 Registry,增加了灵活性。

// model/model.go 文件

package model

// Option 是 Model 的配置选项
type Option func(m *Model) error

// Model 代表一个模型的元数据
// 公有字段,允许本项目其它包访问
type Model struct {
    TableName string
    FieldMap  map[string]*Field
    FieldMapByGoName map[string]*Field
}

// Field 代表一个字段的元数据
type Field struct {
    ColName string
    Type    reflect.Type
    Index   []int
    Offset  uintptr
}

// WithTableName 自定义表名的 Option
// 因为包名已经是 model,所以不需要 Model 前缀
func WithTableName(name string) Option {
    return func(m *Model) error {
        m.TableName = name
        return nil
    }
}
复习提示:Go 命名规范:不建议使用包名作为结构体名或方法名的前缀。例如 model 包里的表名选项应该叫 WithTableName,而不是 WithModelTableName。

四、SELECT 进阶:指定列和聚合函数

到目前为止,我们的 SELECT 语句只能查询所有列(SELECT *)。但在实际使用中,我们经常需要:

  • 只查询特定列:SELECT id, name FROM user
  • 使用聚合函数:SELECT COUNT(*) FROM user
  • 设置别名:SELECT COUNT(*) AS total FROM user

4.1 指定列的需求

在 SELECT 语句中,可以指定的内容有:

  • 普通列:id, name
  • 聚合函数:COUNT(*), AVG(age)
  • 子查询:(SELECT COUNT(*) FROM order WHERE ...)
  • 表达式:age + 1

4.2 最简设计

最简单的方案是让用户直接传入字符串:

// 最简设计:直接传字符串
func (s *Selector[T]) Select(cols ...string) *Selector[T] {
    s.selectCols = cols
    return s
}

// 使用
s := orm.NewSelector[User](db).
    Select("id", "name", "age").
    From("user")

// 生成的 SQL:SELECT id, name, age FROM user

缺点:

  • 缺乏校验:手一抖写错列名都发现不了
  • 用户直接写的是列名(如 user_name),而不是我们希望的字段名(如 UserName)
  • 无法表达聚合函数等复杂语义

4.3 Selectable 抽象

为了解决最简设计的问题,我们引入一个标记接口来限定传入的类型:

// selectable.go 文件

// Selectable 是一个标记接口
// 只有实现了此接口的类型才能用于 Select 方法
// 这是一种"严苛设计"——限定用户能传入什么,从而可以做校验
type Selectable interface {
    selectable()
}
复习提示:

设计哲学:严苛设计 vs 宽容设计

  • 严苛设计:限定类型,基本假设用户会乱搞,所以限定住防止犯错
  • 宽容设计:用户随便搞,不做校验

在 WHERE 设计中我们也讨论过类似问题。这里我们选择严苛设计。

4.4 Column 实现 Selectable

我们之前在构造 WHERE 语句时已经设计了 Column 结构体,现在可以复用:

// column.go 文件

// Column 代表一个列引用
// 用于 SELECT 和 WHERE 子句中
type Column struct {
    name  string // 字段名(Go 字段名,不是数据库列名)
    alias string // 别名(用于 AS 子句)
}

// C 是 Column 的快捷构造函数
// 用法:orm.C("UserName") —— 注意传的是 Go 字段名
func C(name string) Column {
    return Column{name: name}
}

// selectable 实现 Selectable 接口
func (c Column) selectable() {}

// expr 实现 Expression 接口(用于 WHERE 子句)
func (c Column) expr() {}

// As 设置别名
// 返回新的 Column,支持链式调用
// 这是不可变对象设计,每次返回新对象
func (c Column) As(alias string) Column {
    return Column{
        name:  c.name,
        alias: alias,
    }
}

4.5 Aggregate 聚合函数

// aggregate.go 文件

// Aggregate 代表聚合函数
// 例如 COUNT(*)、AVG(age)、SUM(balance)
type Aggregate struct {
    fn    string  // 聚合函数名,如 "COUNT"、"AVG"、"SUM"
    arg   string  // 参数,如 "*"、"age"
    alias string  // 别名
}

// 聚合函数快捷构造
// 用法:orm.Count("*")、orm.Avg("age")
func Count(col string) Aggregate {
    return Aggregate{fn: "COUNT", arg: col}
}

func Avg(col string) Aggregate {
    return Aggregate{fn: "AVG", arg: col}
}

func Sum(col string) Aggregate {
    return Aggregate{fn: "SUM", arg: col}
}

func Max(col string) Aggregate {
    return Aggregate{fn: "MAX", arg: col}
}

func Min(col string) Aggregate {
    return Aggregate{fn: "MIN", arg: col}
}

// selectable 实现 Selectable 接口
func (a Aggregate) selectable() {}

// As 设置别名
func (a Aggregate) As(alias string) Aggregate {
    return Aggregate{
        fn:    a.fn,
        arg:   a.arg,
        alias: alias,
    }
}

4.6 Select 方法实现

// selector.go 文件

// Select 指定查询列
// 参数是 Selectable 接口,可以是 Column 或 Aggregate
func (s *Selector[T]) Select(selects ...Selectable) *Selector[T] {
    s.selects = selects
    return s
}

// Build 构建 SQL
func (s *Selector[T]) Build() ([]byte, []any, error) {
    // ... 省略其它部分 ...

    // 构建 SELECT 子句
    if len(s.selects) == 0 {
        // 没有指定列,查询所有列:SELECT *
        sb.WriteString("*")
    } else {
        // 遍历指定的列
        for i, sel := range s.selects {
            if i > 0 {
                sb.WriteString(", ")
            }
            // 类型断言,根据具体类型生成 SQL
            switch v := sel.(type) {
            case Column:
                // 通过字段名查找列名
                fd, ok := s.model.FieldMapByGoName[v.name]
                if !ok {
                    return nil, nil, errs.NewErrUnknownField(v.name)
                }
                sb.WriteString(fd.ColName)
                // 如果有别名,加上 AS
                if v.alias != "" {
                    sb.WriteString(" AS ")
                    sb.WriteString(v.alias)
                }
            case Aggregate:
                sb.WriteString(v.fn)
                sb.WriteString("(")
                sb.WriteString(v.arg)
                sb.WriteString(")")
                if v.alias != "" {
                    sb.WriteString(" AS ")
                    sb.WriteString(v.alias)
                }
            }
        }
    }

    // ... 省略其它部分 ...
}
// 使用示例

// 查询指定列
users, err := orm.NewSelector[User](db).
    Select(orm.C("Id"), orm.C("Name")).
    From("user").
    GetMulti(ctx)
// SQL: SELECT id, name FROM user

// 使用聚合函数
type CountResult struct {
    Total int `orm:"column=total"`
}
count, err := orm.NewSelector[User](db).
    Select(orm.Count("*").As("total")).
    From("user").
    Get(ctx)
// SQL: SELECT COUNT(*) AS total FROM user

4.7 万金油 RawExpr —— 兜底方案

用户的脑洞总是比设计者大。万一用户需要写一些我们 API 不支持的表达式怎么办?我们需要一个兜底方案:

// raw_expr.go 文件

// RawExpr 是原始表达式
// 用户自己写 SQL 片段,自己保证正确性
// 这是"宽容设计"的体现——虽然不推荐,但得有
type RawExpr struct {
    raw   string  // 原始 SQL 片段
    args  []any   // 参数值
    alias string  // 别名
}

// Raw 创建原始表达式
// 用法:orm.Raw("COUNT(DISTINCT `age`)")
func Raw(raw string, args ...any) RawExpr {
    return RawExpr{
        raw:  raw,
        args: args,
    }
}

// selectable 实现 Selectable 接口
func (r RawExpr) selectable() {}

// expr 实现 Expression 接口(用于 WHERE 子句)
func (r RawExpr) expr() {}

// As 设置别名
func (r RawExpr) As(alias string) RawExpr {
    return RawExpr{
        raw:   r.raw,
        args:  r.args,
        alias: alias,
    }
}
// 使用示例

// RawExpr 用于 SELECT
result, err := orm.NewSelector[User](db).
    Select(orm.Raw("COUNT(DISTINCT `age`)", "age").As("distinct_count")).
    From("user").
    Get(ctx)
// SQL: SELECT COUNT(DISTINCT `age`) AS distinct_count FROM user

// RawExpr 用于 WHERE(通过 Expression 接口)
users, err := orm.NewSelector[User](db).
    Where(orm.Raw("`age` > ? AND `age` < ?", 18, 60)).
    GetMulti(ctx)
// SQL: SELECT * FROM user WHERE age > ? AND age < ?
// 参数: [18, 60]
复习提示:使用建议:RawExpr 是终极兜底方案,不到逼不得已不要用。平时开发中如果找不到合适的 API,可以先看看框架是否有万金油设计。

4.8 别名 AS 的注意事项

在拼接 SQL 时,别名只用在 SELECT 子句中。如果 Column 或 Aggregate 同时用于 WHERE(或 HAVING),必须忽略掉别名:

// 在 SELECT 子句中:需要输出别名
// SELECT id AS user_id, COUNT(*) AS total FROM user

// 在 WHERE 子句中:不需要输出别名
// SELECT * FROM user WHERE id = ?
// 而不是:SELECT * FROM user WHERE id AS user_id = ?
// buildColumn 构建列引用
// withAlias 参数控制是否输出别名
func (s *Selector[T]) buildColumn(sb *strings.Builder, c Column, withAlias bool) {
    // 查找列名
    fd, ok := s.model.FieldMapByGoName[c.name]
    if !ok {
        // 未知字段,可能用户想用 RawExpr
        sb.WriteString(c.name)
    } else {
        sb.WriteString(fd.ColName)
    }

    // 只在需要时输出别名
    if withAlias && c.alias != "" {
        sb.WriteString(" AS ")
        sb.WriteString(c.alias)
    }
}

五、INSERT 语句

经过了漫长的 SELECT 语句之后,现在我们开始 INSERT 语句的设计。INSERT 是 CRUD 中最简单的操作——把一个对象插入到数据库中。

5.1 MySQL INSERT 语法

MySQL 支持三种 INSERT 风格:

-- 风格一:最普通的 INSERT
INSERT INTO user (id, name, age) VALUES (1, '大明', 28);

-- 风格二:带 SET 赋值
INSERT INTO user SET id = 1, name = '大明', age = 28;

-- 风格三:带 SELECT 子句
INSERT INTO user SELECT * FROM temp_user;

-- 以上三种都支持 ON DUPLICATE KEY UPDATE
INSERT INTO user (id, name, age) VALUES (1, '大明', 28)
ON DUPLICATE KEY UPDATE name = '大明', age = 28;

5.2 主流 ORM 框架的 INSERT 设计

Beego ORM

// Beego 的 API 设计
// Insert / InsertWithCtx:普通插入
// InsertOrUpdate:UPSERT 语义
// InsertMulti:批量插入
//   特点:可以将一大批拆分成几个小批次
//   例如传入 1000 个,设定每批 100 个,自动拆成 10 批

o := orm.NewOrm()
id, err := o.Insert(&user)
// 返回自增 ID

GORM

// GORM 的 API 设计
// Create:支持单个、批量
// CreateInBatches:分批次插入
// Save:更新或插入(根据主键是否存在决定)

db.Create(&user)              // 插入单个
db.Create(&users)             // 批量插入
db.CreateInBatches(users, 100) // 每批 100 个

5.3 Builder 模式 Inserter

我们延续 Builder 模式,设计 Inserter 构造器:

// inserter.go 文件

// Inserter 是 INSERT 语句的构造器
// T 是泛型参数,代表用户定义的模型类型
type Inserter[T any] struct {
    db    *DB
    model *Model

    // columns 指定要插入的列
    // 如果为空,表示插入所有列
    columns []string

    // values 要插入的值列表
    // 每个元素是一行数据的参数值
    values [][]any

    // onDuplicate ON DUPLICATE KEY UPDATE 子句
    onDuplicate *OnDuplicate
}

// NewInserter 创建一个 Inserter 实例
func NewInserter[T any](db *DB) *Inserter[T] {
    return &Inserter[T]{
        db: db,
    }
}

INSERT 语句的构造分为三部分:

INSERT INTO `user`(id, name, age)  VALUES(?, ?, ?)     [, (?, ?, ?)...]
└── 表名 ──┘ └── 插入列 ──┘      └── 插入值 ──┘
     第一部分                      第二部分               第三部分(批量)

5.4 Values 方法

// Values 添加要插入的值
// 支持传入一个或多个模型实例
func (i *Inserter[T]) Values(values ...T) *Inserter[T] {
    // 获取模型元数据(首次调用时解析并缓存)
    if i.model == nil {
        m, err := i.db.r.Get(values[0])
        if err != nil {
            // 这里简化处理,实际应该返回 error
            panic(err)
        }
        i.model = m
    }

    for _, v := range values {
        // 通过反射提取每个字段的值
        rv := reflect.ValueOf(v)
        // 处理指针
        for rv.Kind() == reflect.Ptr {
            rv = rv.Elem()
        }

        // 收集这一行的参数值
        row := make([]any, 0, len(i.model.FieldMap))
        for _, fd := range i.model.FieldMap {
            // 跳过自增主键(如果值为零值)
            // 例如 Id 为 0 时,让数据库自动生成
            fieldVal := rv.FieldByIndex(fd.Index)
            row = append(row, fieldVal.Interface())
        }
        i.values = append(i.values, row)
    }
    return i
}

5.5 Build 方法

// Build 构建 INSERT SQL
func (i *Inserter[T]) Build() (*Query, error) {
    var sb strings.Builder
    var args []any

    // ========== 第一部分:INSERT INTO 表名(列名) ==========

    // 如果用户没有指定列,使用所有列
    cols := i.columns
    if len(cols) == 0 {
        // 从元数据中获取所有列名
        for goName, fd := range i.model.FieldMapByGoName {
            // 跳过自增主键的零值
            // 这里简化处理,实际需要更复杂的判断
            cols = append(cols, fd.ColName)
            _ = goName
        }
    }

    sb.WriteString("INSERT INTO `")
    sb.WriteString(i.model.TableName)
    sb.WriteString("`(")
    for j, col := range cols {
        if j > 0 {
            sb.WriteString(", ")
        }
        sb.WriteString("`")
        sb.WriteString(col)
        sb.WriteString("`")
    }
    sb.WriteString(")")

    // ========== 第二部分:VALUES(?, ?, ?) ==========

    sb.WriteString(" VALUES")
    for k, row := range i.values {
        if k > 0 {
            sb.WriteString(",")
        }
        sb.WriteString("(")
        for j := range row {
            if j > 0 {
                sb.WriteString(", ")
            }
            sb.WriteString("?")
        }
        sb.WriteString(")")
        args = append(args, row...)
    }

    // ========== 第三部分:ON DUPLICATE KEY UPDATE(如果有)==========

    if i.onDuplicate != nil {
        sb.WriteString(" ON DUPLICATE KEY UPDATE ")
        // 构建 UPSERT 子句(后面详细讲)
        updSQL, updArgs, err := i.onDuplicate.Build(i.model)
        if err != nil {
            return nil, err
        }
        sb.WriteString(updSQL)
        args = append(args, updArgs...)
    }

    sb.WriteString(";")
    return &Query{SQL: sb.String(), Args: args}, nil
}
// 使用示例

// 插入单条数据
u := &User{Name: "大明", Age: 28}
res, err := orm.NewInserter[User](db).
    Values(u).
    Exec(ctx)
// SQL: INSERT INTO `user`(`id`, `name`, `age`) VALUES(?, ?, ?);
// 参数: [0, 大明, 28]

// 批量插入
u1 := &User{Name: "大明", Age: 28}
u2 := &User{Name: "小明", Age: 25}
res, err = orm.NewInserter[User](db).
    Values(u1, u2).
    Exec(ctx)
// SQL: INSERT INTO `user`(`id`, `name`, `age`) VALUES(?, ?, ?),(?, ?, ?);
// 参数: [0, 大明, 28, 0, 小明, 25]

5.6 INSERT 指定列

有时候我们只想插入部分列:

// Columns 指定要插入的列
// 参数是 Go 字段名,不是数据库列名
func (i *Inserter[T]) Columns(cols ...string) *Inserter[T] {
    i.columns = cols
    return i
}
// 使用示例
u := &User{Name: "大明", Age: 28}
res, err := orm.NewInserter[User](db).
    Columns("Name", "Age").  // 只插入 Name 和 Age
    Values(u).
    Exec(ctx)
// SQL: INSERT INTO `user`(`name`, `age`) VALUES(?, ?);
// 参数: [大明, 28]

六、UPSERT —— INSERT OR UPDATE

6.1 什么是 UPSERT

现实中经常遇到一种场景:

  • 如果数据不存在,就插入
  • 如果数据存在(主键或唯一索引冲突),就更新

这就是所谓的 UPSERT(UPDATE or INSERT)。判断数据是否存在的标准是主键或唯一索引冲突。

在 MySQL 中,这种特性叫做 ON DUPLICATE KEY UPDATE:

-- 如果 id=1 不存在,插入;如果存在,更新 name 和 age
INSERT INTO user (id, name, age) VALUES (1, '大明', 28)
ON DUPLICATE KEY UPDATE name = '大明', age = 28;

6.2 UPSERT 的赋值类型

在 ON DUPLICATE KEY UPDATE 中,赋值语句的右边可以有几种类型:

类型示例说明
特定值name = '大明'更新为固定的值
列引用name = VALUES(name)更新为尝试插入的值
行别名引用name = new.name引用行别名(我们不支持)
表别名引用name = user.name引用另一个列

我们只支持**类型 1(特定值)和类型 3(列引用)**两种。

6.3 Assignment 结构体

// assignment.go 文件

// Assignment 代表一个赋值语句
// 用于 ON DUPLICATE KEY UPDATE 子句
// 例如:name = '大明' 或 name = VALUES(name)
type Assignment struct {
    // column 要更新的列
    column Column
    // value 赋值的表达式
    // 可以是特定值,也可以是 VALUES(col) 表达式
    value Expression
}

// Assign 创建一个赋值语句
// 用法:orm.Assign(orm.C("Name"), "大明")
func Assign(col Column, val any) Assignment {
    return Assignment{
        column: col,
        // 如果 val 已经是 Expression,直接使用
        // 否则包装成 Value 表达式
        value:  expressionOf(val),
    }
}

6.4 OnDuplicate 结构体

我们使用一个独立的 OnDuplicate 结构体来封装 UPSERT 逻辑,而不是直接在 Inserter 中加字段。这样设计的好处是将来可以扩展更复杂的行为:

// on_duplicate.go 文件

// OnDuplicate 代表 ON DUPLICATE KEY UPDATE 子句
type OnDuplicate struct {
    // assignments 赋值列表
    assignments []Assignment
}

// Upsert 设置 UPSERT 子句
// 这是用户调用的 API
// 用法:
//   .Upsert(orm.Assign(orm.C("Name"), "新名字"))
//   .Upsert(orm.Assign(orm.C("Age"), orm.Values(orm.C("Age"))))
func (i *Inserter[T]) Upsert(assigns ...Assignment) *Inserter[T] {
    i.onDuplicate = &OnDuplicate{
        assignments: assigns,
    }
    return i
}

6.5 更新为特定值

// 使用示例:更新为特定的值

u := &User{Id: 1, Name: "大明", Age: 28}
res, err := orm.NewInserter[User](db).
    Values(u).
    Upsert(orm.Assign(orm.C("Name"), "大明更新"),  // 冲突时 name 更新为 "大明更新"
           orm.Assign(orm.C("Age"), 29)).          // 冲突时 age 更新为 29
    Exec(ctx)

// 生成的 SQL:
// INSERT INTO `user`(`id`, `name`, `age`) VALUES(?, ?, ?)
// ON DUPLICATE KEY UPDATE `name` = ?, `age` = ?;
// 参数: [1, 大明, 28, 大明更新, 29]

6.6 更新为插入的值

有时候我们希望冲突时使用尝试插入的值来更新。MySQL 提供了 VALUES(col) 语法:

// Values 函数:生成 VALUES(col) 表达式
// 用于 ON DUPLICATE KEY UPDATE 中引用插入的值
func Values(col Column) RawExpr {
    return RawExpr{
        raw: fmt.Sprintf("VALUES(`%s`)", col.name),
    }
}
// 使用示例:冲突时使用插入的值更新

u := &User{Id: 1, Name: "大明", Age: 28}
res, err := orm.NewInserter[User](db).
    Values(u).
    Upsert(orm.Assign(orm.C("Name"), orm.Values(orm.C("Name"))),
           orm.Assign(orm.C("Age"), orm.Values(orm.C("Age")))).
    Exec(ctx)

// 生成的 SQL:
// INSERT INTO `user`(`id`, `name`, `age`) VALUES(?, ?, ?)
// ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `age` = VALUES(`age`);
// 参数: [1, 大明, 28]
// 效果:如果 id=1 已存在,用新插入的 name 和 age 更新已有记录

七、方言抽象(Dialect)

7.1 方言问题

SQL 作为一个"不太强"的规范,不同数据库有各自定义的语法,这种差异叫做方言。

最典型的例子就是 UPSERT 语法:

数据库UPSERT 语法
MySQLON DUPLICATE KEY UPDATE col = val
SQLiteON CONFLICT(col) DO UPDATE SET col = excluded.col
PostgreSQLON CONFLICT(col) DO UPDATE SET col = excluded.col

在处理 SELECT 语句时,我们不需要考虑方言问题,因为各数据库的 SELECT 语法基本一致。但到了 UPSERT,就不得不考虑了。

此外,引号也有方言差异:

数据库引号字符
MySQL`(反引号)
Oracle / PostgreSQL"(双引号)

7.2 Dialect 接口

我们引入 Dialect 接口来抽象方言差异:

// dialect.go 文件

// Dialect 是方言抽象接口
// 每当我们发现不同数据库的 SQL 写法不一样,就加一个方法
type Dialect interface {
    // quoter 返回引号相关的操作
    // 不同数据库的引号不同:MySQL 用 `,Oracle 用 "
    quoter() quoter

    // onDuplicateKey 构建 UPSERT 子句
    // 不同数据库的 UPSERT 语法不同
    onDuplicateKey(sb *strings.Builder, model *Model, odk *OnDuplicate) error
}

// ========== 标准方言实现 ==========
// standardSQL 实现了标准 SQL 的方言
// 其它方言通过组合它来复用公共部分

type standardSQL struct{}

// ========== MySQL 方言 ==========
// mysqlDialect 组合了 standardSQL
// 只需实现和标准 SQL 不一样的部分

type mysqlDialect struct {
    standardSQL  // 组合标准实现
}

// onDuplicateKey 实现 MySQL 的 UPSERT 语法
// ON DUPLICATE KEY UPDATE col = val
func (m mysqlDialect) onDuplicateKey(sb *strings.Builder, model *Model, odk *OnDuplicate) error {
    sb.WriteString(" ON DUPLICATE KEY UPDATE ")
    for i, assign := range odk.assignments {
        if i > 0 {
            sb.WriteString(", ")
        }
        // 查找列名
        fd, ok := model.FieldMapByGoName[assign.column.name]
        if !ok {
            return errs.NewErrUnknownField(assign.column.name)
        }
        sb.WriteString(fd.ColName)
        sb.WriteString(" = ")
        // 构建赋值表达式
        // 这里省略了具体的表达式构建逻辑
        _ = assign
    }
    return nil
}

// ========== SQLite 方言 ==========
// sqlite3Dialect 也组合了 standardSQL
// 实现 SQLite 特有的 UPSERT 语法

type sqlite3Dialect struct {
    standardSQL
}

// onDuplicateKey 实现 SQLite 的 UPSERT 语法
// ON CONFLICT(col) DO UPDATE SET col = excluded.col
func (s sqlite3Dialect) onDuplicateKey(sb *strings.Builder, model *Model, odk *OnDuplicate) error {
    sb.WriteString(" ON CONFLICT(")
    // SQLite 需要指定冲突的列
    // 这里简化处理,实际需要用户指定
    sb.WriteString(") DO UPDATE SET ")
    for i, assign := range odk.assignments {
        if i > 0 {
            sb.WriteString(", ")
        }
        fd, ok := model.FieldMapByGoName[assign.column.name]
        if !ok {
            return errs.NewErrUnknownField(assign.column.name)
        }
        sb.WriteString(fd.ColName)
        sb.WriteString(" = ")
        // SQLite 使用 excluded.col 语法引用插入的值
        // 而 MySQL 使用 VALUES(col)
        _ = assign
    }
    return nil
}

7.3 MySQL vs SQLite UPSERT 对比

MySQL UPSERT:
INSERT INTO user(id, name, age) VALUES(1, '大明', 28)
ON DUPLICATE KEY UPDATE name = VALUES(name), age = VALUES(age);
                  ↑                          ↑
          MySQL 特有关键字              MySQL 引用插入值的方式

SQLite UPSERT:
INSERT INTO user(id, name, age) VALUES(1, '大明', 28)
ON CONFLICT(id) DO UPDATE SET name = excluded.name, age = excluded.age;
           ↑    ↑                      ↑
     指定冲突列  SQLite 关键字         SQLite 引用插入值的方式

7.4 builder 公共抽象

引入 Dialect 后,我们发现 Selector、Inserter 以及将来的 Deleter、Updater 都需要持有一些公共字段。为了简化代码,我们引入 builder 抽象:

// builder.go 文件

// builder 是所有语句构造器的公共基类
// 封装了公共字段和轻量级操作
type builder struct {
    // sb 用于拼接 SQL
    sb strings.Builder
    // model 元数据
    model *Model
    // args SQL 参数
    args []any
    // dialect 方言
    dialect Dialect
}

// quote 给标识符加引号
// MySQL: `user`  SQLite: "user"
func (b *builder) quote(name string) {
    b.sb.WriteString(b.dialect.quoter().quote(name))
}

// addArg 添加 SQL 参数
func (b *builder) addArg(arg any) {
    b.args = append(b.args, arg)
}
// Inserter 改造后
type Inserter[T any] struct {
    builder      // 组合 builder,复用公共字段
    db    *DB
    values [][]any
    onDuplicate *OnDuplicate
}

7.5 Dialect 的局限性

Dialect 抽象虽然解决了基本问题,但有一些局限性:

  • 容易膨胀:每一点不同都会导致 Dialect 添加方法
  • 独有特性难以处理:例如 JSON 支持,只有 PostgreSQL 有,加入到公共接口不合适
  • 无法放到 internal 包:因为用户需要通过 DBOption 来指定方言

可选的替代方案:为不同方言提供不同的 Builder,例如 MySQLInserter、PostgreSQLInserter 等。

7.6 重命名问题:OnDuplicateKey vs Upsert

只有 MySQL 的语法叫 ON DUPLICATE KEY,其它数据库都是 ON CONFLICT DO UPDATE。那我们的 API 应该叫什么?

方案名称评价
方案一OnConflictBeego 和 GORM 都用这个,但很多初学者不知道
方案二Upsert直白易懂,一看就知道是"更新或插入"

根据多年答疑经验,很多初学者一直在找 OnDuplicateKey 方法,却不知道可以用 OnConflict。所以我们直接叫 Upsert,一了百了。

// 最终 API 设计
// .Upsert(...) 而不是 .OnConflict(...) 或 .OnDuplicateKey(...)
res, err := orm.NewInserter[User](db).
    Values(u).
    Upsert(orm.Assign(orm.C("Name"), "新名字")).
    Exec(ctx)

八、INSERT 执行与 Result 抽象

8.1 基本执行

执行 INSERT 非常简单,直接调用 ExecContext:

// Exec 执行 INSERT 语句
func (i *Inserter[T]) Exec(ctx context.Context) (sql.Result, error) {
    // 构建 SQL
    q, err := i.Build()
    if err != nil {
        return nil, err
    }
    // 执行
    return i.db.db.ExecContext(ctx, q.SQL, q.Args...)
}

8.2 Result 抽象

直接使用 sql.Result 有一个缺点:获取自增 ID 或影响行数时,需要两次处理 error:

// 不优雅的写法:需要两次处理 error
result, err := inserter.Exec(ctx)
if err != nil {
    return err
}
id, err := result.LastInsertId()  // 第二次处理 error
if err != nil {
    return err
}
fmt.Println(id)

我们引入 Result 抽象来简化错误处理(类似于 sql.Row 的设计):

// result.go 文件

// Result 封装了 sql.Result 和 error
// 用户可以先判断 error,再获取 ID 或影响行数
type Result struct {
    // result 底层的 sql.Result
    result sql.Result
    // err 执行过程中的错误
    err    error
}

// Exec 执行 INSERT 并返回 Result
func (i *Inserter[T]) Exec(ctx context.Context) *Result {
    q, err := i.Build()
    if err != nil {
        return &Result{err: err}
    }
    res, err := i.db.db.ExecContext(ctx, q.SQL, q.Args...)
    return &Result{
        result: res,
        err:    err,
    }
}

// LastInsertId 获取自增 ID
func (r *Result) LastInsertId() (int64, error) {
    // 如果执行时就有错误,直接返回
    if r.err != nil {
        return 0, r.err
    }
    return r.result.LastInsertId()
}

// RowsAffected 获取影响行数
func (r *Result) RowsAffected() (int64, error) {
    if r.err != nil {
        return 0, r.err
    }
    return r.result.RowsAffected()
}
// 优雅的写法
res := inserter.Exec(ctx)
// 只需要判断一次 error
id, err := res.LastInsertId()
if err != nil {
    return err
}
fmt.Println(id)

8.3 unsafe 读取字段用于 INSERT

在 Values 方法中,我们需要从用户传入的结构体中读取字段值。这里同样可以使用 unsafe 来优化:

// 使用 valuer 抽象读取字段值
func (i *Inserter[T]) Values(values ...T) *Inserter[T] {
    if i.model == nil {
        m, _ := i.db.r.Get(values[0])
        i.model = m
    }

    for _, v := range values {
        // 使用 valuer 创建实例
        // 可以是反射方案,也可以是 unsafe 方案
        val := i.db.valCreator(i.model, v)
        row := make([]any, 0, len(i.model.FieldMap))
        for _, fd := range i.model.FieldMap {
            // 通过 valuer 读取字段值
            fieldAddr, ok := val.field(fd.ColName)
            if ok {
                // fieldAddr 是字段的指针
                // 通过反射解引用获取值
                rv := reflect.ValueOf(fieldAddr)
                row = append(row, rv.Elem().Interface())
            }
        }
        i.values = append(i.values, row)
    }
    return i
}

8.4 处理自增主键

ORM 在插入时需要处理自增主键的问题:

  • 如果主键是自增主键(如 MySQL 的 AUTO_INCREMENT),并且值为零值(如 0),则插入时应该忽略主键列,让数据库自动生成
  • 如果主键有非零值,则正常插入
// 判断是否应该跳过主键
func shouldSkipPK(field *Field, val reflect.Value) bool {
    // 1. 检查是否是主键
    // 2. 检查是否是自增主键
    // 3. 检查值是否为零值
    if field.IsPrimaryKey && field.IsAutoIncrement {
        return val.IsZero()
    }
    // TiDB 的随机主键也需要类似处理
    return false
}
复习提示:面试要点:ORM 如果知道主键是自增主键(或者 TiDB 的随机主键),并且这个主键是零值,那么插入的时候就会忽略主键列,让数据库自动生成。

九、面试要点总结

结果集处理

  • ORM 框架怎么处理数据库返回的数据? 将列映射到字段(借助于元数据),然后将每一列的数据解析为字段的类型(这个过程在 Go 里由 sql 包完成),利用反射将转化后的数据塞到结构体里。unsafe 的过程类似,不同在于 unsafe 是先在目标地址创建了字段的零值,后面 sql 包把数据注入到这些位置。

  • 使用 unsafe 有什么优点? 性能更好。

  • ORM 的性能瓶颈在哪里? 两个:构造 SQL 的过程和处理结果集。前者通过 buffer pool 能极大缓解;后者处理结果集则可以使用 unsafe 来加速。

  • 为什么 unsafe 比反射更快? 反射可以看作是对 unsafe 的封装,直接使用 unsafe 相当于绕开了中间商,同时减少了 CPU 消耗和内存消耗。

unsafe 基础

  • uintptr 和 unsafe.Pointer 的区别? 前者代表具体地址,后者代表逻辑指针。后者在 GC 等情况下,Go runtime 会帮你调整,使其永远指向真实存放对象的地址。

  • Go 对象怎么对齐? 按照字长。64 位机器按 8 字节对齐,32 位机器按 4 字节对齐。

  • 怎么计算对象地址? 对象的起始地址通过反射获取,对象内部字段的地址通过起始地址 + 字段偏移量来计算。

SELECT 进阶

  • Selectable 抽象的意义? 限定传入类型,可以进行校验,防止用户写错列名。

  • RawExpr 的作用? 兜底方案,用户自己写 SQL 片段,用于 API 不支持的场景。不到逼不得已不要用。

INSERT 与 UPSERT

  • ORM 框架如何支持不同数据库? 引入 Dialect(方言)抽象,通过设计公共接口,为不同数据库提供不同实现。核心都是构造 SQL,因为数据库驱动已经屏蔽了结果集的差异。

  • ORM 插入时如何处理主键? 如果知道主键是自增主键(或 TiDB 随机主键),并且是零值,插入时忽略主键列。

  • unsafe 读取字段如何计算偏移量? 直接使用反射获取(fd.Offset)。在组合结构体的情况下,一个字段的偏移量等于组合结构体的起始偏移量 + 该字段的偏移量。

  • INSERT 能不能插入复杂表达式? 能。有一种特殊情况是 INSERT xxx VALUES(a, a+1),后面的列可以用前面的列来组成表达式,反过来则不行。

核心三角

ORM 框架的核心可以总结为三点,面试时要时刻记住:

        ORM 核心三角
       ┌─────────────┐
       │             │
       │  构造 SQL   │ ── Builder 模式
       │             │
       └──────┬──────┘
              │
    ┌─────────┼─────────┐
    │                   │
┌───┴────┐        ┌────┴─────┐
│        │        │          │
│ 元数据  │        │ 处理结果集│
│        │        │          │
│反射解析 │        │反射/unsafe│
│缓存复用 │        │Creator抽象│
└────────┘        └──────────┘

到这里,我们已经将 ORM 框架的核心都讲到了:构造 SQL、设计元数据、处理结果集。这三点是 ORM 框架的基石,也是面试中最常考察的内容。

自测题与动手练习

自测题(合上书能答出来,才算懂):

面试官
Get 为什么用 QueryContext 而不是 QueryRow?它给 Get 与 GetMulti 带来了什么复用好处?
候选人
QueryRow 一次只能拿一行、且返回的是 *sql.Row;而 Get 同时要支持单行(Get)和多行(GetMulti)。用 QueryContext 返回 *sql.Rows 后,两者可以共用同一套"列名→字段地址"的映射与扫描逻辑——Get 只取第一行、GetMulti 循环取全部,省去两套代码。QueryContext 还带 context,能支持超时与取消。
面试官
unsafe.Pointer 和 uintptr 在 GC 发生时有何不同行为?为什么 uintptr 只适合表达偏移量这类相对量?
候选人
unsafe.Pointer 是真正的指针,GC 能识别它指向的对象、并在对象被移动时自动修正这个指针;uintptr 只是一个整数地址值,GC 既不追踪也不修正。如果先把地址存成 uintptr,中间一旦发生 GC compaction 导致对象搬家,这个整数就指向了旧地址、变成野值。所以 uintptr 只用来表达"相对起始地址偏移了多少"这种偏移量,真正持有对象地址的始终是 unsafe.Pointer。
面试官
反射方案与 unsafe 方案处理结果集的核心区别是什么?unsafe 快在哪一步?
候选人
反射方案先 Scan 进一组临时变量,再逐个 reflect 取地址、赋值回目标结构体,等于"先建临时对象再搬家";unsafe 方案根据元数据的 Offset 直接在目标结构体实例的字段地址上建好对象,让 Scan 一把填进去,省掉了"搬家"那一步。unsafe 快就快在省去了中间的临时分配与逐字段赋值,实测约快 2~3 倍。
面试官
Selectable 标记接口的作用是什么?RawExpr 在整套 API 里扮演什么角色,什么时候才该用?
候选人
Selectable 是一个空标记接口,用来在编译期约束 Select 能接收的列类型(只有实现了它的 Column/Aggregate 等才合法),这就是"严苛设计"——能提前挡掉非法传参。但严苛设计总会遇到表达不出来的特殊 SQL,于是提供 RawExpr 作为"兜底方案",允许直接塞一段原始表达式。原则是:优先用类型安全的 Selectable,只有在框架 API 覆盖不到时才退到 RawExpr。
面试官
ORM 为什么要引入 Dialect 抽象?MySQL 与 SQLite 的 UPSERT 语法关键差异在哪两个关键字上?
候选人
不同数据库的 UPSERT 语法各不相同,把方言差异写死在 Inserter 里会让代码臃肿且难扩展,所以抽出一个 Dialect 接口,让 Inserter 只管构造,方言自己生成对应 SQL。MySQL 用的是 ON DUPLICATE KEY UPDATE,而 SQLite 用的是 ON CONFLICT ... DO UPDATE——关键差异就在这两组关键字(ON DUPLICATE KEY vs ON CONFLICT)。

动手练习(建议真做一遍):

  1. 用 unsafe 方案改写 Inserter.Values 的字段读取(复用 valuer 抽象),跑通单条/批量插入并对比性能。
  2. 给 Selector 增加 OrderBy(cols ...Column) 与 Having(pred Predicate) 支持,生成带排序与分组过滤的 SQL。
  3. 实现一个 postgreSQLDialect,让 Upsert 生成 ON CONFLICT(col) DO UPDATE SET col = excluded.col 形式,并补测试验证。

本章小结

复习提示:
  • 结果集处理的核心是"列名→字段地址"的映射:反射方案先 Scan 进临时地址再赋值,unsafe 方案直接在目标地址建好对象让 Scan 填充,省去"搬家"步骤,快约 2~3 倍。
  • unsafe 的关键纪律:uintptr 仅用于表达偏移量(GC 不维护),对象地址永远用 unsafe.Pointer 持有(GC 会自动修正)。
  • valuer 接口把"反射还是 unsafe"从 Selector 中解耦出来(Creator 工厂),是依赖倒置原则的典型应用;元数据新增 Offset 字段支撑 unsafe。
  • SELECT 进阶用 Selectable 标记接口限定传入类型做校验,并提供 RawExpr 作为终极兜底;INSERT 用 Builder 模式,UPSERT 通过独立 OnDuplicate 结构 + Dialect 适配各库语法,API 取名 Upsert 以降低初学者认知成本。
  • Result 抽象把两次 error 处理合并为一次,呼应 sql.Row 的设计思路。
复习提示:ORM 的"构造 SQL / 元数据 / 结果集"核心三角已经讲完。后续可继续深入 UPDATE / DELETE 构造器、关联查询,以及把 Dialect 从接口抽象演进为"分方言 Builder"的更彻底方案。
About Me

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

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

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

目标

学AI,加油!加油!