四、事务 API、AOP 方案与集成测试

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

@

学习目标

学完本章你应该能够:

  1. 讲清为什么我们引入独立的 Tx 结构体(而不是像 GORM 那样让 DB 兼任事务),以及这种隔离带来的"禁止嵌套事务"取舍。
  2. Session 抽象统一 DBTx,让 Selector / Inserter / Updater 在事务内外共用同一套代码。
  3. 实现事务闭包 DoTx,并讲清 Go 没有 try-catch 时,如何用 defer + panicked 标志位正确处理提交与回滚。
  4. 解释事务扩散(Transaction Propagation):上游开了事务,下游如何靠 context.Context 复用同一个事务。
  5. Middleware 洋葱模型给 ORM 做 AOP(日志 / 追踪 / 监控),并基于 TestSuite + Docker 搭一套能真连数据库的集成测试。

前置知识

  • 本系列前几篇:构造 SQL(Builder)、元数据(反射 + 缓存)、valuer(结果集处理)
  • Go 的 interfacecontext.Context、闭包与 defer/recover
  • testify/suite 基本用法(集成测试部分会用到)

本章你会动手做的事

  1. DoTx 写一笔"账户 A 扣款、账户 B 加款"的转账,故意在加款前 panic,验证事务整体回滚。
  2. 写一个 LogMiddleware,在每次 SQL 执行前后记录语句与耗时,体验"无侵入"的 AOP。
  3. docker-compose 起一个 MySQL,跑一遍 INSERT / SELECT 的 TestSuite,观察 SetupSuite / TearDownTest 的生命周期。

一、事务 API

到目前为止,我们已经解决了增删改查(CRUD)的问题。但在实际业务中,很多操作需要原子性——要么全部成功,要么全部失败。这就是事务(Transaction)要解决的问题。

对于事务来说,核心就是允许用户创建事务,然后在事务内部执行增删改查。

类比:事务就像银行转账。你给朋友转 100 块,这件事内部其实是两步——你的账户减 100、朋友账户加 100。如果减完钱、加钱之前系统崩了,就会出现"钱凭空消失"。事务保证这两步要么都成、要么都不成:崩了就整体回滚到转账前的状态,就像这笔交易从没发生过。没有事务的数据库操作,就像把钱从一个口袋掏出来、还没塞进另一个口袋就摔了一跤。

1.1 主流 ORM 的事务设计

Beego ORM

Beego 的事务 API 比较直接,提供了 BeginCommitRollback 三个基本方法,以及一个事务闭包 API:

// Beego 的事务使用方式
o := orm.NewOrm()
err := o.Begin()  // 开启事务
if err != nil {
    return err
}
// 在事务中执行操作...
err = o.Commit()  // 提交
if err != nil {
    o.Rollback()  // 失败则回滚
}

GORM

GORM 的设计更加丰富:

  • DB 本身也可以被看作事务:这是 GORM 的一个独特设计,普通的 DB 操作和事务操作使用相同的接口
  • 普通的 Begin / Commit / Rollback
  • SavePoint(保存点):允许在事务内部设置保存点,可以回滚到保存点而不是整个事务
  • 事务闭包 API:传入一个函数,ORM 自动管理事务生命周期
// GORM 的事务闭包 API
err := db.Transaction(func(tx *gorm.DB) error {
    // 在事务中执行操作
    if err := tx.Create(&user).Error; err != nil {
        return err  // 返回 error 自动回滚
    }
    if err := tx.Create(&order).Error; err != nil {
        return err
    }
    return nil  // 返回 nil 自动提交
})

1.2 Tx 结构体定义

在我们的 ORM 中,事务的核心 API 有三个:

  • Begin:开始一个事务
  • Commit:提交一个事务
  • Rollback:回滚一个事务

我们引入一个全新的 Tx 结构体来表达事务,而不是像 GORM 那样让 DB 本身充当事务:

// tx.go 文件

// Tx 代表一个数据库事务
// 引入独立的 Tx 结构体,而不是让 DB 兼任事务角色
// 这样设计的好处:
//   1. DB 在创建后就是不可变的,职责清晰
//   2. 一个事务无法开启另一个事务(限制事务嵌套)
type Tx struct {
    // db 底层的 sql.Tx(标准库的事务对象)
    db *sql.Tx

    // valCreator 用于创建 valuer(处理结果集)
    // 从 DB 继承,保持事务内外的结果集处理方式一致
    valCreator valCreator

    // r 元数据注册中心
    // 从 DB 继承,事务内外共享同一份元数据缓存
    r registry
}

// Begin 开启一个事务
// 返回 Tx 实例,后续所有操作都通过 Tx 执行
func (db *DB) Begin(ctx context.Context, opts ...*sql.TxOptions) (*Tx, error) {
    // 调用标准库的 BeginTx
    // opts 是可选的事务选项(隔离级别、只读等)
    var txOpts *sql.TxOptions
    if len(opts) > 0 {
        txOpts = opts[0]
    }
    tx, err := db.db.BeginTx(ctx, txOpts)
    if err != nil {
        return nil, err
    }
    return &Tx{
        db:         tx,
        valCreator: db.valCreator,
        r:          db.r,
    }, nil
}

// Commit 提交事务
func (t *Tx) Commit() error {
    return t.db.Commit()
}

// Rollback 回滚事务
func (t *Tx) Rollback() error {
    return t.db.Rollback()
}

设计要点:引入独立的 Tx 意味着一个事务无法开启另一个事务——我们的事务都是单独的、不嵌套的。这是一种有意为之的限制,避免了嵌套事务的复杂性。

1.3 Session 抽象

现在有一个问题:我们的 SelectorInserter 等构造器原本接收 DB 作为参数,现在事务场景下需要用 Tx 来创建它们。怎么办?

我们需要一个 DBTx公共抽象——Session

// session.go 文件

// Session 是 DB 和 Tx 的公共抽象
// 在 ORM 语境下,Session 代表一个上下文
// 可以理解为一种分组机制:在这个分组内,
// 所有的查询会共享一些基本的配置(如元数据、valuer 等)
type Session interface {
    // getCore 返回核心配置
    // Selector、Inserter 等构造器通过 core 来获取共享配置
    getCore() core

    // queryContext 执行查询(SELECT)
    queryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)

    // execContext 执行增删改(INSERT、UPDATE、DELETE)
    execContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}

// core 封装了 CRUD 操作都需要使用的公共配置
type core struct {
    // r 元数据注册中心
    r          registry
    // valCreator 结果集处理器工厂
    valCreator valCreator
    // dialect 方言
    dialect    Dialect
}

DB 实现 Session

// db.go 文件

// DB 实现了 Session 接口
func (db *DB) getCore() core {
    return core{
        r:          db.r,
        valCreator: db.valCreator,
        dialect:    db.dialect,
    }
}

// DB 的查询和执行直接委托给内部的 sql.DB
func (db *DB) queryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
    return db.db.QueryContext(ctx, query, args...)
}

func (db *DB) execContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
    return db.db.ExecContext(ctx, query, args...)
}

Tx 实现 Session

// tx.go 文件

// Tx 实现了 Session 接口
func (t *Tx) getCore() core {
    return core{
        r:          t.r,
        valCreator: t.valCreator,
        dialect:    t.dialect,
    }
}

// Tx 的查询和执行委托给内部的 sql.Tx
// 所有操作都在同一个事务内执行
func (t *Tx) queryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
    return t.db.QueryContext(ctx, query, args...)
}

func (t *Tx) execContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
    return t.db.ExecContext(ctx, query, args...)
}

Selector 和 Inserter 改造

// selector.go 文件

// Selector 改造后接收 Session 而不是 DB
type Selector[T any] struct {
    core      // 组合 core,获取公共配置
    session Session  // 通过 session 执行 SQL
    // ... 其它字段
}

// NewSelector 创建 Selector
// 参数从 *DB 改为 Session
// 这样无论传入 DB 还是 Tx 都可以
func NewSelector[T any](sess Session) *Selector[T] {
    c := sess.getCore()
    return &Selector[T]{
        core:    c,
        session: sess,
    }
}
// 使用示例 —— 普通查询(无事务)
users, err := orm.NewSelector[User](db).  // db 实现了 Session
    Where(orm.C("Age").GT(18)).
    GetMulti(ctx)

// 使用示例 —— 事务查询
tx, err := db.Begin(ctx)
defer tx.RollbackIfNotCommit()  // 安全兜底
// tx 同样实现了 Session,可以传给 NewSelector
users, err = orm.NewSelector[User](tx).  // tx 实现了 Session
    Where(orm.C("Age").GT(18)).
    GetMulti(ctx)
err = tx.Commit()

1.4 事务闭包 API

在 Beego 和 GORM 中我们都看到了事务闭包 API 的设计。所谓事务闭包 API,即:

  1. 用户传入一个方法(闭包函数)
  2. ORM 框架创建事务
  3. 利用事务执行该方法
  4. 根据方法的执行情况来决定提交还是回滚

Beego 的事务闭包

// Beego 的事务闭包核心逻辑(简化版)
func (o *orm) DoTx(fn func(ctx context.Context, tx orm.Tx) error) error {
    panicked := true  // 标记是否发生了 panic
    tx, err := o.Begin()
    if err != nil {
        return err
    }

    // 使用 defer 来确保无论发生什么都能处理
    defer func() {
        // 如果发生了 panic,或者业务代码返回了 error,则回滚
        if panicked || err != nil {
            tx.Rollback()
            return
        }
        // 否则提交
        err = tx.Commit()
    }()

    // 执行用户的业务代码
    err = fn(context.Background(), tx)
    // 如果 fn 内部 panic,defer 中的 panicked 仍然是 true
    panicked = false  // 正常执行到这里说明没 panic
    return err
}

我们的 DoTx 实现

// tx.go 文件

// DoTx 是事务闭包 API
// 用户传入一个闭包函数 fn,ORM 自动管理事务生命周期:
//   - 如果 fn 返回 error 或发生 panic:回滚
//   - 如果 fn 正常返回 nil:提交
//
// 参数:
//   ctx: 上下文
//   fn:  用户的业务函数,接收事务上下文和 Tx 实例
func (db *DB) DoTx(ctx context.Context, fn func(ctx context.Context, tx *Tx) error) error {
    // 1. 开启事务
    tx, err := db.Begin(ctx)
    if err != nil {
        return err
    }

    // 2. 使用 defer 确保事务一定被处理
    //    panicked 标记是否发生了 panic
    //    Go 没有 try-catch,所以用 defer + recover 来捕获 panic
    panicked := true
    defer func() {
        if panicked || err != nil {
            // 发生 panic 或 error,回滚事务
            // 注意:回滚本身也可能出错,但我们只记录日志,不覆盖原始错误
            _ = tx.Rollback()
            return
        }
        // 正常完成,提交事务
        err = tx.Commit()
    }()

    // 3. 执行用户的业务代码
    //    如果 fn 内部 panic,defer 中的 panicked 仍为 true
    err = fn(ctx, tx)
    // 走到这里说明没有 panic
    panicked = false
    return err
}
// 使用示例:转账
err := db.DoTx(ctx, func(ctx context.Context, tx *Tx) error {
    // 从账户 A 扣款
    _, err := orm.NewInserter[Account](tx).
        // ... 执行扣款 ...
        Exec(ctx)
    if err != nil {
        return err  // 返回 error,ORM 自动回滚
    }

    // 向账户 B 加款
    _, err = orm.NewInserter[Account](tx).
        // ... 执行加款 ...
        Exec(ctx)
    if err != nil {
        return err
    }

    return nil  // 返回 nil,ORM 自动提交
})

核心要点:Go 没有 Java 的 try-catch 机制,所以事务闭包的实现需要用 defer + panicked 标志位来确保正确处理。需要判断两件事:是否发生了 panic,业务代码是否返回了 error。两者决定提交还是回滚。

下面这张时序图把 DoTx 的"开事务 → 执行业务 → 提交/回滚"全生命周期画出来,重点看 defer 那一环如何兜住 panic:

sequenceDiagram
    participant U as 业务代码 fn
    participant D as DB.DoTx
    participant T as Tx 事务
    participant DB as 数据库
    U->>D: DoTx(ctx, fn)
    D->>T: Begin 开启事务
    D->>U: 执行 fn(ctx, tx)
    alt fn 返回 error / 发生 panic
        U-->>D: error 或 panic
        D->>T: Rollback 回滚
        D-->>U: 返回错误
    else fn 返回 nil
        U-->>D: nil
        D->>T: Commit 提交
        T->>DB: 持久化
        D-->>U: 成功
    end

1.5 RollbackIfNotCommit

事务闭包能解决大部分问题,但有些场景用户还是想自己控制事务。这时候有一个痛点:Go 没有 try-catch,经常需要写大量重复的回滚代码。

我们提供一个 RollbackIfNotCommit 方法——如果事务还没有被提交,就回滚它:

// tx.go 文件

// RollbackIfNotCommit 如果事务没有被提交,就回滚
// 解决 Go 没有 try-catch 导致的重复回滚代码问题
//
// 原理:
//   如果事务已经被提交或回滚,调用 Rollback 会返回 sql.ErrTxDone
//   我们只需要忽略这个错误即可
func (t *Tx) RollbackIfNotCommit() error {
    err := t.db.Rollback()
    if err == sql.ErrTxDone {
        // 事务已经提交或回滚过了,忽略这个错误
        return nil
    }
    return err
}
// 使用示例:手动控制事务
func Transfer(db *DB, ctx context.Context, fromID, toID int64, amount float64) error {
    // 开启事务
    tx, err := db.Begin(ctx)
    if err != nil {
        return err
    }
    // 无论后续代码怎么走,只要还没提交,就回滚
    // 如果已经提交了,这个调用不会报错
    defer tx.RollbackIfNotCommit()

    // 扣款
    _, err = orm.NewUpdater[Account](tx).
        // ... 执行扣款 ...
        Exec(ctx)
    if err != nil {
        return err  // defer 中的 RollbackIfNotCommit 会回滚
    }

    // 加款
    _, err = orm.NewUpdater[Account](tx).
        // ... 执行加款 ...
        Exec(ctx)
    if err != nil {
        return err
    }

    // 提交事务
    // 提交成功后,defer 中的 RollbackIfNotCommit 会收到 ErrTxDone,忽略
    return tx.Commit()
}

1.6 事务扩散方案

事务扩散是指:在调用链中,如果上游方法开启了事务,那么下游的所有方法也会使用这个事务;否则:

  • 下游可以开一个新事务
  • 也可以无事务运行
  • 还可以报错

在其它语言(如 Java)中,事务扩散一般通过 ThreadLocal 变量来实现。而在 Go 中,凡是别的语言用 ThreadLocal 的,都用 context.Context 来替代。

context 传递事务

// tx.go 文件

// txKey 是 context 中存储事务的 key 类型
// 使用自定义类型避免 key 冲突
type txKey struct{}

// BeginTx 开启事务,支持事务扩散
// 如果 context 中已经存在事务,直接返回该事务
// 否则创建新事务
func (db *DB) BeginTx(ctx context.Context, opts ...*sql.TxOptions) (*Tx, error) {
    // 1. 检查 context 中是否已有事务
    if tx, ok := ctx.Value(txKey{}).(*Tx); ok {
        // 已经在事务中,直接返回现有事务
        // 这就是事务传播的核心:上游有事务就用上游的
        return tx, nil
    }

    // 2. 没有事务,创建新事务
    tx, err := db.Begin(ctx, opts...)
    if err != nil {
        return nil, err
    }

    // 3. 将事务存入 context
    //    后续所有从该 context 创建的子 context 都能拿到这个事务
    // 注意:context 是不可变的,需要返回新的 context
    return tx, nil
}

// WithTx 将事务存入 context,返回新的 context
func WithTx(ctx context.Context, tx *Tx) context.Context {
    return context.WithValue(ctx, txKey{}, tx)
}

// GetTx 从 context 中获取事务(如果存在)
func GetTx(ctx context.Context) (*Tx, bool) {
    tx, ok := ctx.Value(txKey{}).(*Tx)
    return tx, ok
}
// 使用示例:事务扩散

// ServiceA 是上游方法,开启事务
func (s *ServiceA) DoSomething(ctx context.Context) error {
    tx, err := s.db.BeginTx(ctx)
    if err != nil {
        return err
    }
    defer tx.RollbackIfNotCommit()

    // 将事务放入 context
    ctx = orm.WithTx(ctx, tx)

    // 调用下游方法
    // 下游方法会通过 context 拿到同一个事务
    err = s.serviceB.DoOtherThing(ctx)
    if err != nil {
        return err
    }

    return tx.Commit()
}

// ServiceB 是下游方法,复用上游事务
func (s *ServiceB) DoOtherThing(ctx context.Context) error {
    // 如果 context 中有事务(上游开启了),则复用
    // 如果没有事务(上游没开启),则新建一个
    tx, err := s.db.BeginTx(ctx)
    if err != nil {
        return err
    }
    // 注意:如果是复用上游事务,不要在这里提交或回滚
    // 只有新建的事务才需要提交
    if _, ok := orm.GetTx(ctx); !ok {
        // 是新建的事务,需要管理生命周期
        defer tx.RollbackIfNotCommit()
        // ... 执行操作 ...
        return tx.Commit()
    }
    // 复用上游事务,直接执行操作即可
    // ... 执行操作 ...
    return nil
}

类比:事务扩散就像"公司报销流程"。你(上游)已经开了一张总报销单,下属(下游)出差花钱时直接挂到你这张单子上,最后一起结账;如果你没开单,下属就自己开一张单独结。所谓"扩散",就是下游看上游有没有单——有就挂上去,没有就自己开。

下游方法到底复用上游事务还是新建事务,取决于 context 里有没有事务。这张图把 BeginTx 的判断逻辑画清楚:

flowchart TD
    A[上游 ServiceA 开启事务] --> B[WithTx 存入 context]
    B --> C[调用下游 ServiceB]
    C --> D{context 中
已有事务?} D -->|是| E[复用上游同一事务
不提交不回滚] D -->|否| F[BeginTx 新建事务
自行管理生命周期]

面试要点:什么是事务扩散?在 Go 里面怎么解决?本质就是上下文里面有事务就用事务,没有事务就开新事务。Go 里面要解决的话只能依赖于 context.Context——基本上在别的语言里面用 thread-local 解决的,到 Go 里面都是用 context.Context


二、AOP 方案

AOP(Aspect-Oriented Programming,面向切面编程)用于解决横切关注点问题——那些和业务逻辑无关,但需要在每个操作中执行的事情,比如日志、追踪、性能监控等。

2.1 回顾 Web 框架的 AOP

在 Web 框架中,我们设计了 Middleware(中间件)来解决 AOP 问题。核心设计是:每个 Middleware 要主动触发下一个(next)。

请求 → Middleware1 → Middleware2 → Middleware3 → Handler → Middleware3 → Middleware2 → Middleware1 → 响应
       (日志)         (认证)         (限流)         (业务)    (限流后处理)  (认证后处理)  (日志后处理)

ORM 框架也需要类似的机制。任何框架都需要提供 AOP 接口,因为大家都需要解决日志、追踪、性能监控等共性问题。

2.2 主流 ORM 的 AOP 设计

Beego ORM —— 几乎没有

Beego 在 ORM 层面上没有显式的 Middleware 设计。类似需求通过侵入式方案解决。根源在于 ORM 没有一个统一的和数据库交互的出口。

用户可以通过装饰器模式封装 Beego ORM 的接口来间接实现:

// 装饰器模式示例(非 Beego 原生)
type LoggingORM struct {
    inner orm.Ormer  // 被装饰的原始 ORM
}

func (l *LoggingORM) Insert(o interface{}) (int64, error) {
    start := time.Now()
    id, err := l.inner.Insert(o)
    log.Printf("Insert took %v, err=%v", time.Since(start), err)
    return id, err
}

GORM —— Hook 机制

GORM 的 AOP 设计叫做 Hook(钩子),它是一个和时机有关的概念。按照 SQL 类型划分:

SQL 类型Hook 列表
Create(插入)BeforeSave、BeforeCreate、AfterCreate、AfterSave
Update(更新)BeforeSave、BeforeUpdate、AfterUpdate、AfterSave
Delete(删除)BeforeDelete、AfterDelete
Query(查询)AfterFind
// GORM Hook 示例

// BeforeCreate 在插入前被调用
func (u *User) BeforeCreate(tx *gorm.DB) error {
    // 可以修改即将插入的数据
    u.CreatedAt = time.Now()
    return nil
}

// AfterFind 在查询后被调用
func (u *User) AfterFind(tx *gorm.DB) error {
    // 可以对查询结果做后处理
    u.FullName = u.FirstName + " " + u.LastName
    return nil
}

GORM Hook 的优缺点

优点缺点
用户使用简单,清晰知道哪个 Hook 对应哪个操作缺乏扩展性,用户指定不了执行顺序
可以修改执行上下文(实现分库分表等)BeforeSaveAfterSave 有点令人困惑(INSERT 和 UPDATE 都会调用)
要新增 Hook 类型(如 BeforeFind)需要修改源码

2.3 我们的 AOP 方案

我们的方案很简单——照着 Web 框架的 Middleware 抄一份:

// aop.go 文件

// ========== QueryContext 查询上下文 ==========
// 代表一次查询的上下文信息
// 包含查询类型、SQL、参数等
type QueryContext struct {
    // Type 查询类型:SELECT、INSERT、UPDATE、DELETE
    Type string

    // Model 元数据
    Model *Model

    // Builder SQL 构造器(Selector、Inserter 等)
    Builder QueryBuilder

    // Session 数据库会话(DB 或 Tx)
    Session Session

    // Ctx 上下文
    Ctx context.Context
}

// QueryBuilder 是能构造 SQL 的接口
type QueryBuilder interface {
    Build() (*Query, error)
}

// ========== QueryResult 查询结果 ==========
// 代表一次查询的结果
type QueryResult struct {
    // Result 查询结果(类型取决于查询类型)
    Result any

    // Err 错误信息
    Err error
}
// ========== Handler 处理器 ==========
// 代表在这个上下文里面"做点什么事情"
// Handler 是整个 AOP 链的终点——真正执行 SQL 的地方
type Handler interface {
    // Handle 执行查询
    Handle(ctx context.Context, qc *QueryContext) *QueryResult
}

// HandlerFunc 是 Handler 的函数适配器
// 让普通函数也能作为 Handler 使用
type HandlerFunc func(ctx context.Context, qc *QueryContext) *QueryResult

func (f HandlerFunc) Handle(ctx context.Context, qc *QueryContext) *QueryResult {
    return f(ctx, qc)
}
// ========== Middleware 中间件 ==========
// 连接不同的 Handler,形成洋葱模型
// 和 Web 框架的 Middleware 一模一样
type Middleware func(next Handler) Handler

洋葱模型

和 Web 的结构完全一样,ORM 的 AOP 也是洋葱模型:

查询请求 → [日志中间件] → [追踪中间件] → [监控中间件] → [真实执行]
                                                                    ↓
查询结果 ← [日志后处理] ← [追踪后处理] ← [监控后处理] ← [返回结果]

类比:ORM 的 AOP 中间件就像洋葱——一层包一层。一个查询进来,先经过日志层、再经过追踪层、再经过监控层,最后才到"真正执行 SQL"的芯;结果返回时再原路一层层往外穿回来(后处理)。你想加日志、加追踪、加监控,就是往洋葱外面再裹一层,完全不用动核心的 SQL 执行代码。这也和 Web 框架的 Middleware 一模一样。

把上面的文字示意落成一张图,BuildChain 从后往前逐层包装,最终 Handler 是被包在最里面的"芯":

flowchart LR
    Req[查询请求] --> L[日志中间件]
    L --> T[追踪中间件]
    T --> M[监控中间件]
    M --> H[真实执行 Handler
真正打 SQL] H --> M2[监控后处理] M2 --> T2[追踪后处理] T2 --> L2[日志后处理] L2 --> Res[返回结果]
// ========== 构建中间件链 ==========
// 将多个 Middleware 和一个最终的 Handler 组合成一条链
func BuildChain(handler Handler, ms ...Middleware) Handler {
    // 从后往前遍历,逐层包装
    // 效果:最后添加的 Middleware 在最外层
    for i := len(ms) - 1; i >= 0; i-- {
        handler = ms[i](handler)
    }
    return handler
}

2.4 Middleware 实现示例

查询日志中间件

// middleware_log.go 文件

// LogMiddleware 记录 SQL 执行日志
// 类似 Web 框架的 access log
//
// 优点(相比侵入式的 DEBUG 标记位):
//   1. 无侵入——不需要修改 Get、GetMulti、Exec 等方法
//   2. 用户可控性更强——可以自由决定记录什么、怎么记录
func LogMiddleware(logFunc func(query string, args []any, err error, duration time.Duration)) Middleware {
    return func(next Handler) Handler {
        return HandlerFunc(func(ctx context.Context, qc *QueryContext) *QueryResult {
            // 1. 前置:构造 SQL
            //    如果构造就失败了,可以直接返回
            //    也可以选择继续执行,让后续中间件处理
            q, err := qc.Builder.Build()
            if err != nil {
                // SQL 构造失败,直接返回
                return &QueryResult{Err: err}
            }

            // 2. 记录开始时间
            start := time.Now()

            // 3. 调用下一个 Handler(继续执行洋葱链)
            res := next.Handle(ctx, qc)

            // 4. 后置:记录日志
            //    注意:args 中可能包含敏感信息(如密码)
            //    logFunc 的提供者需要自己处理脱敏
            logFunc(q.SQL, q.Args, res.Err, time.Since(start))

            return res
        })
    }
}
// 使用示例
db.Use(orm.LogMiddleware(func(sql string, args []any, err error, d time.Duration) {
    // 用户自己决定怎么记录
    // 注意处理敏感信息!
    log.Printf("[SQL] %s | args: %v | err: %v | duration: %v", sql, args, err, d)
}))

关于 DEBUG 标记位:大多数 ORM 框架喜欢引入一个 DEBUG 标记位来控制日志。这种侵入式方案需要修改 GetGetMultiExec 等方法。相比之下,Middleware 方案无侵入,用户可控性更强。

关于 Dry Run:所谓的 dry run(干跑),其实就是在日志中间件记录了 SQL 之后直接返回,根本不会发起真实调用。

OpenTelemetry 追踪中间件

// middleware_otel.go 文件

// OtelMiddleware 使用 OpenTelemetry 记录追踪信息
// 和 Web 框架的追踪中间件类似
func OtelMiddleware(tracer trace.Tracer) Middleware {
    return func(next Handler) Handler {
        return HandlerFunc(func(ctx context.Context, qc *QueryContext) *QueryResult {
            // 1. 构造 SQL(用于 span 名称)
            q, err := qc.Builder.Build()
            if err != nil {
                return &QueryResult{Err: err}
            }

            // 2. 开启一个 span
            //    名称用表名 + 查询类型,例如 "user:SELECT"
            ctx, span := tracer.Start(ctx, qc.Model.TableName+":"+qc.Type)
            defer span.End()

            // 3. 记录属性
            span.SetAttributes(
                attribute.String("db.statement", q.SQL),
                // 注意:这里没有记录参数
                // 因为参数可能包含敏感信息
                // 如果要记录,需要处理加密/脱敏
            )

            // 4. 执行查询
            res := next.Handle(ctx, qc)

            // 5. 记录结果
            if res.Err != nil {
                span.RecordError(res.Err)
                span.SetStatus(codes.Error, res.Err.Error())
            }

            return res
        })
    }
}

Prometheus 监控中间件

// middleware_prom.go 文件

// PromMiddleware 使用 Prometheus 监控 SQL 执行
// 记录操作类型和对应的表
func PromMiddleware(counter prometheus.CounterVec) Middleware {
    return func(next Handler) Handler {
        return HandlerFunc(func(ctx context.Context, qc *QueryContext) *QueryResult {
            // 1. 构造 SQL
            q, err := qc.Builder.Build()
            if err != nil {
                return &QueryResult{Err: err}
            }

            // 2. 记录指标
            counter.WithLabelValues(
                qc.Type,              // 操作类型:SELECT、INSERT...
                qc.Model.TableName,   // 表名
            ).Inc()

            // 3. 执行查询
            return next.Handle(ctx, qc)

            // 注意:ORM 层面拿不到 IP 等信息
            // 因为 ORM 并不知道 sql 包内部的连接信息
            // 对于分库分表的数据库,这种监控可能过于粗糙
            // 因为在分库分表下,我们希望能单独监控每一个库
        })
    }
}

2.5 AOP 方案的缺陷

这种设计的缺陷是:用户实现 Middleware 的时候,可能存在大量的类型断言。因为 QueryContext 中的 Builder 是通用接口,用户需要自己判断是什么查询类型,然后做类型断言来获取具体的构造器:

// 用户自定义 Middleware 可能需要的类型断言
func MyMiddleware(next Handler) Handler {
    return HandlerFunc(func(ctx context.Context, qc *QueryContext) *QueryResult {
        // 需要判断查询类型
        switch qc.Type {
        case "SELECT":
            // 需要类型断言才能拿到 Selector
            selector, ok := qc.Builder.(*Selector[User])
            if ok {
                // 可以访问 Selector 特有的方法
                _ = selector
            }
        case "INSERT":
            inserter, ok := qc.Builder.(*Inserter[User])
            if ok {
                _ = inserter
            }
        }
        return next.Handle(ctx, qc)
    })
}

2.6 AOP 方案的优缺点总结

优点缺点
和 Web 框架一致,学习成本低用户实现 Middleware 可能需要大量类型断言
无侵入,不需要修改核心方法无法获取数据库连接级别的信息(如 IP)
用户可以自由组合中间件对分库分表场景的监控不够精细
支持链式调用,洋葱模型
可以实现 dry run 等高级特性

面试要点:怎么监控慢查询?利用 AOP 方案,写一个 Middleware 实现,里面计算 SQL 执行时间,当执行时间超过阈值时告警或打印。但所有 SQL 监控都要注意不要把敏感数据打印出来。


三、集成测试

3.1 为什么需要集成测试

到目前为止,我们的测试都停留在单元测试基准测试上。但 ORM 作为一个要和数据库打交道的中间件,必须设计集成测试

测试类型目标工具
单元测试确保在 Go 语言层面上代码没有问题(主要是 SQL 和参数)go testsqlmock
集成测试确保和数据库交互的结果符合预期go test + 真实数据库

3.2 测试阶段划分

测试有多种类型:

类型说明负责方
单元测试测试单个函数/模块的正确性开发
集成测试测试模块间集成的正确性开发/QA
回归测试确保新代码没有破坏已有功能QA
冒烟测试快速验证核心功能是否正常QA
用户体验测试从用户角度验证QA/产品

建议:开发者应该自己维护一份集成测试,万事不求人。

3.3 使用 Docker Compose 启动 MySQL

集成测试需要真实数据库,我们用 Docker Compose 来启动 MySQL:

# docker-compose.yml
version: '3'
services:
  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: test_db
    ports:
      - "3306:3306"
    # 健康检查,确保 MySQL 完全启动
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 3s
      retries: 10

3.4 TestSuite 设计

如果我们直接在测试代码中指定使用 MySQL,那么要测试 SQLite3 怎么办?复制粘贴一遍代码?定义一个公共方法?

更好的选择是 TestSuite(测试套件)。Go 的 testify/suite 包提供了 TestSuite 机制:

  • 隔离:套件之间独立运行
  • 生命周期回调(钩子):允许在套件前后执行一些动作(如环境准备、数据准备)
  • 参数控制:可以使用不同参数运行同一个套件多次(如用不同数据库参数运行同一个套件)

类比TestSuite 就像一间餐厅的"开业 / 打烊 / 每桌翻台"流程。SetupSuite 是开业前一次性备好锅灶(建连接、建表),TearDownSuite 是打烊收摊;而 SetupTest / TearDownTest 是每一桌客人(每个测试用例)上桌前擦桌子、离店后清台——保证下一桌看到的桌面是干净的。把"环境准备"和"数据清理"拆到不同粒度,测试才能既快又互不干扰。

TestSuite 的生命周期回调顺序如下:

flowchart TD
    A[SetupSuite 套件开始 执行一次
建连接/建表] --> B[SetupTest 单测前] B --> C[TestXxx 执行用例] C --> D[TearDownTest 单测后
清数据] D --> E{还有用例?} E -->|是| B E -->|否| F[TearDownSuite 套件结束
关连接/删表]
// integration/insert_test.go 文件

package integration

import (
    "context"
    "testing"

    "github.com/stretchr/testify/suite"
)

// InsertTestSuite 是 INSERT 操作的集成测试套件
type InsertTestSuite struct {
    // suite.Suite 提供了丰富的断言方法
    // 以及生命周期回调
    suite.Suite

    // db ORM 的 DB 实例
    db *orm.DB

    // driver 当前测试使用的数据库驱动名
    // 通过这个字段,可以用不同数据库运行同一个套件
    driver string
    // dsn 数据库连接字符串
    dsn    string
}

// SetupSuite 在整个套件开始前执行一次
// 用于初始化测试环境
func (s *InsertTestSuite) SetupSuite() {
    // 1. 创建 DB 实例
    db, err := orm.Open(s.driver, s.dsn)
    s.Require().NoError(err)
    s.db = db

    // 2. 创建测试表
    _, err = s.db.db.Exec(`
        CREATE TABLE IF NOT EXISTS user (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            first_name VARCHAR(64) NOT NULL,
            last_name VARCHAR(64) NOT NULL,
            age INT NOT NULL DEFAULT 0
        )
    `)
    s.Require().NoError(err)
}

// TearDownTest 在每个测试用例结束后执行
// 用于清理每个测试产生的数据
// 这样每个测试用例都是独立的,不会互相影响
func (s *InsertTestSuite) TearDownTest() {
    // 清空测试表
    _, err := s.db.db.Exec("TRUNCATE TABLE user")
    s.Require().NoError(err)
}

// TearDownSuite 在整个套件结束后执行
func (s *InsertTestSuite) TearDownSuite() {
    // 关闭数据库连接
    s.db.Close()
}

// TestInsert 单条插入测试
func (s *InsertTestSuite) TestInsert() {
    t := s.T()
    ctx := context.Background()

    // 构造测试数据
    u := &User{
        FirstName: "大明",
        LastName:  "李",
        Age:       28,
    }

    // 执行插入
    res := orm.NewInserter[User](s.db).
        Values(u).
        Exec(ctx)

    // 验证结果
    affected, err := res.RowsAffected()
    s.NoError(err)
    s.Equal(int64(1), affected)  // 应该影响 1 行

    // 验证自增 ID
    id, err := res.LastInsertId()
    s.NoError(err)
    s.True(id > 0)  // 自增 ID 应该大于 0
}

// TestInsertBatch 批量插入测试
func (s *InsertTestSuite) TestInsertBatch() {
    ctx := context.Background()

    users := []*User{
        {FirstName: "大明", LastName: "李", Age: 28},
        {FirstName: "小明", LastName: "王", Age: 25},
        {FirstName: "小红", LastName: "张", Age: 30},
    }

    res := orm.NewInserter[User](s.db).
        Values(users[0], users[1], users[2]).
        Exec(ctx)

    affected, err := res.RowsAffected()
    s.NoError(err)
    s.Equal(int64(3), affected)  // 应该影响 3 行
}

// TestInsertSuite 运行测试套件
// 这是 Go test 的入口
func TestInsertSuite(t *testing.T) {
    // 可以用不同数据库运行同一个套件
    suite.Run(t, &InsertTestSuite{
        driver: "mysql",
        dsn:    "root:root@tcp(localhost:3306)/test_db",
    })

    // 也可以测试 SQLite3
    // suite.Run(t, &InsertTestSuite{
    //     driver: "sqlite3",
    //     dsn:    "file:test.db?cache=shared&mode=memory",
    // })
}

3.5 SELECT 测试套件

SELECT 测试和 INSERT 测试有所不同:SELECT 不会修改数据,所以可以一次性准备好所有测试数据,不需要在每个测试后清理:

// integration/select_test.go 文件

type SelectTestSuite struct {
    suite.Suite
    db     *orm.DB
    driver string
    dsn    string
}

// SetupSuite 在套件开始前一次性准备好所有测试数据
func (s *SelectTestSuite) SetupSuite() {
    // 创建 DB 和表...
    // 省略创建过程

    // 一次性插入所有测试数据
    // SELECT 不修改数据,所以可以一次性准备好
    users := []*User{
        {Id: 1, FirstName: "大明", LastName: "李", Age: 28},
        {Id: 2, FirstName: "小明", LastName: "王", Age: 25},
        {Id: 3, FirstName: "小红", LastName: "张", Age: 30},
        {Id: 4, FirstName: "大强", LastName: "赵", Age: 35},
    }
    orm.NewInserter[User](s.db).Values(users...).Exec(context.Background())
}

// TearDownSuite 在套件结束后清理
// 只需要在最后清理一次即可
func (s *SelectTestSuite) TearDownSuite() {
    _, _ = s.db.db.Exec("DROP TABLE IF EXISTS user")
    s.db.Close()
}

// 注意:SELECT 测试套件不需要 TearDownTest
// 因为 SELECT 不会修改数据

func (s *SelectTestSuite) TestGet() {
    ctx := context.Background()

    u, err := orm.NewSelector[User](s.db).
        Where(orm.C("Id").EQ(1)).
        Get(ctx)

    s.NoError(err)
    s.NotNil(u)
    s.Equal("大明", u.FirstName)
}

func (s *SelectTestSuite) TestGetMulti() {
    ctx := context.Background()

    users, err := orm.NewSelector[User](s.db).
        Where(orm.C("Age").GT(20)).
        GetMulti(ctx)

    s.NoError(err)
    s.Len(users, 4)  // 年龄大于 20 的有 4 个
}

func TestSelectSuite(t *testing.T) {
    suite.Run(t, &SelectTestSuite{
        driver: "mysql",
        dsn:    "root:root@tcp(localhost:3306)/test_db",
    })
}

3.6 Suite 抽象

InsertTestSuiteSelectTestSuite 有一些共同点,可以进一步抽取公共的 Suite:

// integration/suite.go 文件

// ORMTestSuite 是所有 ORM 集成测试的公共基类
// 封装了公共的初始化和清理逻辑
type ORMTestSuite struct {
    suite.Suite

    db     *orm.DB
    driver string
    dsn    string
}

// SetupSuite 公共的初始化逻辑
func (s *ORMTestSuite) SetupSuite() {
    db, err := orm.Open(s.driver, s.dsn)
    s.Require().NoError(err)
    s.db = db

    // 创建表
    s.createTable()
}

// TearDownSuite 公共的清理逻辑
func (s *ORMTestSuite) TearDownSuite() {
    _, _ = s.db.db.Exec("DROP TABLE IF EXISTS user")
    s.db.Close()
}

// createTable 创建测试表
func (s *ORMTestSuite) createTable() {
    _, err := s.db.db.Exec(`
        CREATE TABLE IF NOT EXISTS user (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            first_name VARCHAR(64) NOT NULL,
            last_name VARCHAR(64) NOT NULL,
            age INT NOT NULL DEFAULT 0
        )
    `)
    s.Require().NoError(err)
}

// InsertTestSuite 继承 ORMTestSuite
type InsertTestSuite struct {
    ORMTestSuite  // 组合公共基类
}

// 重写 SetupSuite,先调用父类的,再执行自己的逻辑
func (s *InsertTestSuite) SetupSuite() {
    s.ORMTestSuite.SetupSuite()  // 调用公共初始化
    // INSERT 测试需要每个测试后清理数据
}

// TearDownTest 每个测试后清理
func (s *InsertTestSuite) TearDownTest() {
    _, _ = s.db.db.Exec("TRUNCATE TABLE user")
}

// SelectTestSuite 继承 ORMTestSuite
type SelectTestSuite struct {
    ORMTestSuite  // 组合公共基类
}

// 重写 SetupSuite,先调用父类的,再插入测试数据
func (s *SelectTestSuite) SetupSuite() {
    s.ORMTestSuite.SetupSuite()  // 调用公共初始化
    // 一次性插入 SELECT 测试所需的数据
    users := []*User{
        {FirstName: "大明", LastName: "李", Age: 28},
        {FirstName: "小明", LastName: "王", Age: 25},
    }
    orm.NewInserter[User](s.db).Values(users...).Exec(context.Background())
}

3.7 测试数据准备策略

在做集成测试时,有两种数据准备做法:

策略做法优点缺点
集中式启动时为所有测试用例准备好数据,结束后清理代码简洁集中管理难以维护
分散式启动时只准备环境,各测试自己准备数据测试独立性好测试代码臃肿
// 集中式示例:在 SetupSuite 中一次性准备好所有数据
func (s *SelectTestSuite) SetupSuite() {
    s.ORMTestSuite.SetupSuite()
    // 一次性插入所有测试数据
    users := []*User{
        {Id: 1, FirstName: "大明", LastName: "李", Age: 28},
        {Id: 2, FirstName: "小明", LastName: "王", Age: 25},
        // ... 更多数据
    }
    orm.NewInserter[User](s.db).Values(users...).Exec(context.Background())
}

// 分散式示例:每个测试自己准备数据
func (s *InsertTestSuite) TestInsert() {
    // 每个测试自己创建数据
    u := &User{FirstName: "大明", LastName: "李", Age: 28}
    // ...
}

3.8 go build 标签 —— 独立运行单元测试

在 TDD 开发中,我们会频繁运行测试。但此时不会想运行集成测试,因为集成测试需要启动数据库。怎么做到单独运行单元测试?

使用 go build 标签

//go:build e2e

// 注意://go:build 必须在 package 声明之上,中间不能有空行
package integration

import (
    "testing"
    // ...
)

func TestInsertSuite(t *testing.T) {
    // ...
}
// 运行命令
// 只运行单元测试(不包含 e2e 标签的文件)
go test ./...

// 运行单元测试 + 集成测试
go test -tags=e2e ./...
# Makefile 中加入集成测试命令
orm_e2e:
	docker-compose up -d mysql
	# 等待数据库启动
	go test -tags=e2e ./...
	docker-compose down

3.9 环境准备问题

运行 make orm_e2e 时可能遇到错误——因为 Docker 启动完毕不代表 MySQL 已经初始化好。我们需要主动检测能否连上数据库

// integration/setup.go 文件

// waitForDB 等待数据库就绪
// 在没有连上之前,不会运行后续测试
func waitForDB(dsn string, maxRetry int) error {
    db, err := sql.Open("mysql", dsn)
    if err != nil {
        return err
    }
    defer db.Close()

    // 重试连接
    for i := 0; i < maxRetry; i++ {
        err = db.Ping()
        if err == nil {
            return nil  // 连接成功
        }
        // 连接失败,等待后重试
        time.Sleep(time.Second)
    }
    return fmt.Errorf("数据库连接超时: %w", err)
}

// 在 TestMain 中等待数据库就绪
func TestMain(m *testing.M) {
    // 等待数据库启动
    if err := waitForDB("root:root@tcp(localhost:3306)/test_db", 30); err != nil {
        log.Fatal(err)
    }
    // 运行测试
    os.Exit(m.Run())
}

重要:如果你的集成测试依赖于第三方组件,一定要确保它们已经完全启动,才能开始运行测试。

3.10 原生查询(Raw Query)

用户不按套路出牌——系统设计诸多问题的根源。

就 MySQL SELECT 语句来说,ORM 框架能支持全部语法吗?显然不能,也不愿意支持全部。大多数框架设计时都要考虑提供兜底措施,或者提供绕开框架的机制。在 ORM 这里,就是要允许用户手写 SQL,直接绕开 ORM 的各种机制。

// raw.go 文件

// RawQuerier 原生查询器
// 允许用户直接写 SQL,绕过 ORM 的 SQL 构造机制
// 适用于:
//   1. ORM 不支持的高级语法
//   2. 复杂的联表查询
//   3. 数据库特有的函数
type RawQuerier[T any] struct {
    core
    session Session

    // sql 原始 SQL 语句
    sql string
    // args SQL 参数
    args []any
}

// Raw 创建原生查询器
// 用法:
//   orm.Raw[User]("SELECT * FROM user WHERE id = ?", 1)
//   orm.Raw[User]("SELECT * FROM user WHERE age > ? AND age < ?", 18, 60)
func Raw[T any](sess Session, sql string, args ...any) *RawQuerier[T] {
    c := sess.getCore()
    return &RawQuerier[T]{
        core:    c,
        session: sess,
        sql:     sql,
        args:    args,
    }
}

// Get 执行原生查询,返回单条记录
func (r *RawQuerier[T]) Get(ctx context.Context) (*T, error) {
    // 直接执行用户提供的 SQL
    rows, err := r.session.queryContext(ctx, r.sql, r.args...)
    if err != nil {
        return nil, err
    }

    for rows.Next() {
        res := new(T)
        // 复用 valuer 处理结果集
        val := r.valCreator(r.model, res)
        // ... 扫描数据 ...
        return res, nil
    }
    return nil, ErrNoRows
}

// Exec 执行原生 SQL(INSERT/UPDATE/DELETE)
func (r *RawQuerier[T]) Exec(ctx context.Context) *Result {
    res, err := r.session.execContext(ctx, r.sql, r.args...)
    return &Result{result: res, err: err}
}
// 使用示例

// 原生 SELECT
user, err := orm.Raw[User](db,
    "SELECT * FROM user WHERE id = ?", 1).
    Get(ctx)

// 复杂的联表查询(ORM 难以支持)
type OrderDetail struct {
    OrderID    int64
    UserName   string
    ProductName string
    Quantity   int
}
details, err := orm.Raw[OrderDetail](db,
    `SELECT o.id as order_id, u.name as user_name, p.name as product_name, oi.quantity
     FROM orders o
     JOIN user u ON o.user_id = u.id
     JOIN order_item oi ON o.id = oi.order_id
     JOIN product p ON oi.product_id = p.id
     WHERE o.status = ?`, "paid").
    GetMulti(ctx)

// 原生 INSERT
res := orm.Raw[User](db,
    "INSERT INTO user(name, age) VALUES(?, ?)", "大明", 28).
    Exec(ctx)

设计哲学:原生查询是兜底方案。用户可以选择三种方式操作数据库:

  1. 使用 ORM 的 Builder API(推荐)
  2. 使用 Raw 原生查询(兜底)
  3. 直接使用 sql.DB(完全绕过 ORM)

四、面试要点总结

事务相关

  • 什么是事务扩散?在 Go 里面怎么解决? 本质就是上下文里面有事务就用事务,没有事务就开新事务。Go 里面要解决的话只能依赖于 context.Context,基本上在别的语言里面用 thread-local 解决的,到 Go 里面都是用 context.Context

  • 事务扩散中,如果没有开启事务应该怎么办? 看你的业务需求:可以选择报错、可以选择开启新事务、也可以无事务运行。

  • 事务重复提交会怎样? 在 ORM 层面上,有些 ORM 会维护一个标记位,标记事务有没有被提交。即便没有标记位,数据库也会返回 sql.ErrTxDone 错误。

  • Go 里面实现一个事务闭包要考虑什么? 主要是考虑 panic 的问题,而后要在 panic 的时候,以及业务代码返回 error 的时候,回滚事务。

AOP 相关

  • 设计模式类:怎么在 Go 里面使用责任链模式、洋葱模式、装饰器模式。这部分在 ORM 和 Web 框架中已多次演示。

  • GORM 的 Hook 设计原理:GORM 的 Hook 按照 SQL 类型划分(如 BeforeCreate)。本质只是 GORM 在内部找准地方(执行语句前后)调用用户注册的 Hook。

  • GORM 的 Hook 有哪些:Create 有 BeforeSaveBeforeCreateAfterCreateAfterSave;Update 有 BeforeSaveBeforeUpdateAfterUpdateAfterSave;Delete 有 BeforeDeleteAfterDelete;Query 有 AfterFind。小心 Save 相关的 Hook,它会在 INSERT 和 UPDATE 语句里都被调用。

  • 怎么监控慢查询? 利用 AOP 方案,写一个 Middleware 实现,计算 SQL 执行时间,超过阈值就告警或打印。注意不要把敏感数据打印出来。

集成测试相关

  • 怎么在 Go 里面设计集成测试? 利用 go test,所不同的是集成测试会启动外部依赖(如 MySQL)。在集成测试上打上 e2e 标签,就可以单独运行单元测试。集成测试放在单独的包里。

  • 怎么在业务代码里测试数据库操作? 三种方式:

    1. 真实启动数据库,准备测试数据
    2. 使用 sqlmockgomock 工具模拟数据库
    3. 利用 AOP 接口(Middleware)拦截查询进行验证
  • TestSuite 的回调:注意有整个 TestSuite 执行一次的(SetupSuite/TearDownSuite),也有单个测试执行一次的(SetupTest/TearDownTest)。一般利用 Suite 维度的来启动测试环境,利用单个测试维度的来准备测试数据。

ORM 核心全景

到这里,ORM 框架的所有核心部分都讲完了。回顾整个系列:

下面先用一张图把 ORM 框架的几大模块和它们之间的依赖关系串起来,再看下面的详细树状结构:

flowchart TD
    ORM[ORM 框架全景] --> B[构造 SQL / Builder]
    ORM --> M[元数据 / 反射缓存]
    ORM --> V[处理结果集 / valuer]
    ORM --> T[事务管理]
    ORM --> A[AOP / Middleware 洋葱]
    ORM --> D[方言 Dialect]
    ORM --> TEST[测试体系]

    B --> B1[SELECT Selector]
    B --> B2[INSERT Inserter]
    B --> B3[UPDATE Updater]
    B --> B4[DELETE Deleter]

    M --> M1[Model / Field]
    M --> M2[Registry 注册中心]
    M --> M3[自定义列名 Tag/接口/Option]

    V --> V1[reflectValue 反射方案]
    V --> V2[unsafeValue 方案]
    V --> V3[Creator 工厂模式]

    T --> T1[Tx 独立结构体]
    T --> T2[Session 抽象 DB+Tx]
    T --> T3[DoTx 闭包 API]
    T --> T4[RollbackIfNotCommit]
    T --> T5[事务扩散 context 传递]

    A --> A1[QueryContext / QueryResult]
    A --> A2[Handler / Middleware]
    A --> A3[日志 / 追踪 / 监控]

    D --> D1[standardSQL]
    D --> D2[mysqlDialect]
    D --> D3[sqlite3Dialect]

    TEST --> TE1[单元测试 sqlmock]
    TEST --> TE2[集成测试 TestSuite+Docker]
    TEST --> TE3[原生查询 RawQuerier]
ORM 框架全景
├── 构造 SQL(Builder 模式)
│   ├── SELECT —— Selector
│   ├── INSERT —— Inserter
│   ├── UPDATE —— Updater(后续扩展)
│   └── DELETE —— Deleter(后续扩展)
├── 元数据(反射解析 + 缓存)
│   ├── Model / Field 定义
│   ├── Registry 注册中心
│   └── 自定义表名/列名(Tag、接口、Option)
├── 处理结果集(valuer 抽象)
│   ├── 反射方案 —— reflectValue
│   ├── unsafe 方案 —— unsafeValue
│   └── Creator 工厂模式
├── 事务管理
│   ├── Tx 结构体(独立于 DB)
│   ├── Session 抽象(DB 和 Tx 的公共接口)
│   ├── 事务闭包 API(DoTx)
│   ├── RollbackIfNotCommit
│   └── 事务扩散(context 传递)
├── AOP 方案(Middleware 洋葱模型)
│   ├── QueryContext / QueryResult
│   ├── Handler / Middleware
│   ├── 日志中间件
│   ├── 追踪中间件(OpenTelemetry)
│   └── 监控中间件(Prometheus)
├── 方言抽象(Dialect)
│   ├── standardSQL 标准实现
│   ├── mysqlDialect
│   └── sqlite3Dialect
└── 测试体系
    ├── 单元测试(sqlmock)
    ├── 集成测试(TestSuite + Docker)
    └── 原生查询(RawQuerier 兜底)

ORM 框架的核心可以总结为三点:构造 SQL设计元数据处理结果集。这三点是 ORM 的基石,加上事务管理、AOP 方案、方言抽象和测试体系,就构成了一个完整的 ORM 框架。


自测题与动手练习

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

  1. 我们为什么引入独立的 Tx 结构体,而不是像 GORM 那样让 DB 本身充当事务?这种隔离带来什么限制(提示:嵌套事务)?
  2. Session 抽象解决了什么具体问题?为什么 Selector / Inserter 的构造函数参数要从 *DB 改成 Session
  3. Go 没有 try-catch,DoTx 靠什么机制保证"panic 或返回 error 时回滚、正常返回 nil 时提交"?panicked 标志位是干嘛的?
  4. 什么是事务扩散?在 Go 里为什么必须用 context.Context 而不是 ThreadLocal 来传递事务?
  5. ORM 的 AOP 用 Middleware 洋葱模型,相比 GORM 的 Hook 机制,优缺点各是什么?怎么监控慢查询?

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

  1. DoTx 写一笔转账(账户 A 减、账户 B 加),在加款前主动 panic,验证 defer 把整笔事务回滚,A、B 余额都不变。
  2. 自己实现一个 LogMiddleware,在 SQL 执行前后记录语句和耗时;再故意写一条慢 SQL,确认能在日志里看到耗时超过阈值的告警。
  3. docker-compose 起一个 MySQL,把 InsertTestSuitedriver/dsn 改成 MySQL,跑一遍 go test -tags=e2e,观察 SetupSuite / TearDownTest / TearDownSuite 各执行了几次。

本章小结

  • 事务的核心是原子性:我们引入独立 Tx 结构体,职责清晰、不可变,且刻意禁止嵌套事务,避免复杂度。
  • Session 是 DB 与 Tx 的公共抽象:让 Selector / Inserter 等构造器在事务内外共用一套代码,无需为事务单独写一套。
  • DoTx 是闭包式事务 API:靠 defer + panicked 标志位模拟 try-catch,统一处理 panic 与 error;RollbackIfNotCommit 解决手动控制事务时的重复回滚代码。
  • 事务扩散靠 context.Context:上游事务存入 context,下游 BeginTx 检测后复用,这是 Go 里替代 ThreadLocal 的标准做法。
  • AOP 用 Middleware 洋葱模型:无侵入地叠加日志、追踪、监控,相比 GORM Hook 更灵活但可能有类型断言开销。
  • 集成测试要真连库:用 testify/suite + Docker 起 MySQL,靠 SetupSuite / TearDownTest 控制环境与数据隔离,e2e 标签隔离重跑。

到此 ORM 框架系列全部讲完——构造 SQL、元数据、结果集是基石,事务、AOP、方言、测试体系让它能真正落地生产。

About Me

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

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

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

目标

学AI,加油!加油!