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

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

@

学习目标

学完本章,你应该能够:

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

前置知识

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

本章你会动手做的事

  • OpenDB + sqlmockSelector.Get 写一段测试,体会依赖倒置带来的可测性。
  • 写一个 Benchmark 对比反射方案与 unsafe 方案处理结果集的耗时(预期差 2~3 倍)。
  • Inserter 接入方言,分别为 MySQL 和 SQLite 生成正确的 UPSERT 语句。

一、结果集处理 —— 让 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 中。为了解决这个问题,我们将 ModelField 抽取到独立的 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 子句中。如果 ColumnAggregate 同时用于 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 后,我们发现 SelectorInserter 以及将来的 DeleterUpdater 都需要持有一些公共字段。为了简化代码,我们引入 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,例如 MySQLInserterPostgreSQLInserter 等。

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 框架的基石,也是面试中最常考察的内容。

自测题与动手练习

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

  1. Get 为什么用 QueryContext 而不是 QueryRow?它给 GetGetMulti 带来了什么复用好处?
  2. unsafe.Pointeruintptr 在 GC 发生时有何不同行为?为什么 uintptr 只适合表达偏移量这类相对量?
  3. 反射方案与 unsafe 方案处理结果集的核心区别是什么?unsafe 快在哪一步?
  4. Selectable 标记接口的作用是什么?RawExpr 在整套 API 里扮演什么角色,什么时候才该用?
  5. ORM 为什么要引入 Dialect 抽象?MySQL 与 SQLite 的 UPSERT 语法关键差异在哪两个关键字上?

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

  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,加油!加油!