学习目标
学完本章,你应该能够:
- 解释 ORM 如何把
sql.Rows映射回 Go 结构体(Get 流程、valuer抽象、QueryContext的复用考量)。 - 讲清
unsafe.Pointer与uintptr在 GC 下的行为差异,并说明为什么uintptr只适合表达"偏移量"这类相对量。 - 对比反射与
unsafe两种结果集方案,说清unsafe为什么更快——核心在于省去了"先建临时对象再搬家"的步骤。 - 用
Selectable/Column/Aggregate/RawExpr构造指定列与聚合查询,理解"严苛设计"与"兜底方案"的取舍。 - 设计
Inserter/ UPSERT,并解释Dialect抽象与UpsertAPI 命名背后的工程权衡。
前置知识:
- 第一章的
Selector/ 元数据 / 注册中心知识(本章直接复用) database/sql的QueryContext/Scan/ExecContext用法- Go 内存布局与对齐的直觉(字长、填充)
- 基础 SQL 语法(SELECT 指定列、聚合函数、INSERT、UPSERT)
本章你会动手做的事:
- 用
OpenDB+sqlmock给Selector.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: ®istry{},
}
// 应用用户传入的选项
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.Pointer | Go 层面的指针,有类型信息 | 是,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 语法 |
|---|---|
| MySQL | ON DUPLICATE KEY UPDATE col = val |
| SQLite | ON CONFLICT(col) DO UPDATE SET col = excluded.col |
| PostgreSQL | ON 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 应该叫什么?
| 方案 | 名称 | 评价 |
|---|---|---|
| 方案一 | OnConflict | Beego 和 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带来了什么复用好处?unsafe.Pointer和uintptr在 GC 发生时有何不同行为?为什么uintptr只适合表达偏移量这类相对量?- 反射方案与
unsafe方案处理结果集的核心区别是什么?unsafe快在哪一步? Selectable标记接口的作用是什么?RawExpr在整套 API 里扮演什么角色,什么时候才该用?- ORM 为什么要引入
Dialect抽象?MySQL 与 SQLite 的 UPSERT 语法关键差异在哪两个关键字上?
动手练习(建议真做一遍):
- 用
unsafe方案改写Inserter.Values的字段读取(复用valuer抽象),跑通单条/批量插入并对比性能。 - 给
Selector增加OrderBy(cols ...Column)与Having(pred Predicate)支持,生成带排序与分组过滤的 SQL。 - 实现一个
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"的更彻底方案。