实战笔记:从内存 Map 到 GORM 持久化
日期: 2026-01-23
标签: Go, GORM, Architecture, SQL
分类: 实战复盘
记录我在将后端从内存存储升级为 SQLite 的全过程。包含对 GORM 核心机制(Save 更新原理、db.Model 定位逻辑、删除机制)的深度伪代码分析。
引言
在完成了三层架构的搭建后,我的后端服务依然面临一个致命问题:重启即失忆。
为了让数据持久化,我决定引入 GORM。作为一个 SQL 基础薄弱且习惯了 C++ 思维的开发者,这个过程并非一帆风顺。本文记录了从环境选择到底层原理拷问的完整心路历程。
第一阶段:战术选择与环境搭建
1. SQLite vs MySQL?
在 Week 2 阶段,我选择了 SQLite。
理由:
- 它不需要安装任何服务,只是一个
.db文件 - 对于验证架构和 GORM 逻辑来说,它和 MySQL 99% 通用
战略:得益于我在架构中使用了 Repository 接口,未来切换到 MySQL 只需修改 main.go 里的连接代码,业务逻辑一行不用动。
2. 实战四步走
Step 1: 安装武器
go get -u gorm.io/gorm
go get -u gorm.io/driver/sqliteStep 2: 改造 Model (告诉 GORM 表长什么样)
type User struct {
gorm.Model // 自动获得 ID, CreatedAt, UpdatedAt, DeletedAt
Username string `gorm:"uniqueIndex"` // 唯一索引
Password string
}Step 3: 实现 Repository (GORM 版零件)
type GormUserRepo struct { db *gorm.DB }
func (r *GormUserRepo) GetUserByUsername(name string) (*model.User, error) {
var user model.User
// 翻译:SELECT * FROM users WHERE username = ? LIMIT 1
err := r.db.Where("username = ?", name).First(&user).Error
return &user, err
}Step 4: 依赖注入 (Main 函数换心手术)
// 1. 连接文件数据库
db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
db.AutoMigrate(&model.User{}) // 自动建表
// 2. 替换掉旧的内存 Repo
userRepo := repository.NewGormUserRepo(db)
// Service 和 Controller 完全不知情,服务平滑升级!第二阶段:灵魂拷问 —— GORM 到底是什么?
代码跑通了,但我的疑惑更多了。GORM 到底在底下干了什么?
Q1: 什么是表、行、列?
如果不懂 SQL,可以用 Excel 来类比:
| 概念 | 类比 | 说明 |
|---|---|---|
| 表 (Table) | 一个 Excel 文件 | users.xlsx |
| 列 (Column) | 表头 | Username, Password,建表时定好,是死的 |
| 行 (Row) | 每一条数据 | 具体的用户数据,是活的 |
┌─────────────────────────────────────────────────────────┐
│ users 表 │
├──────┬──────────────┬──────────────┬───────────────────┤
│ ID │ Username │ Password │ CreatedAt │ ← 列 (Column)
├──────┼──────────────┼──────────────┼───────────────────┤
│ 1 │ admin │ **** │ 2026-01-01 │ ← 行 (Row)
│ 2 │ alice │ **** │ 2026-01-02 │ ← 行 (Row)
│ 3 │ bob │ **** │ 2026-01-03 │ ← 行 (Row)
└──────┴──────────────┴──────────────┴───────────────────┘GORM 的本质
它是一个 翻译官:
- 它把 Go 的 Struct 翻译成 表结构
- 它把 Go 的 对象 翻译成 数据行
Q2: 加一个新字段就要建新表吗?
误区:我以为一行数据有这个字段,另一行没有,就得拆表。
真相:不用。数据库支持 NULL(空值)。
- 加一列 = 表头变宽了
- 老数据这列填
NULL,新数据填具体值 - 只有当出现"一对多"(如一个用户有多个手机号)时,才需要建新表
第三阶段:核心操作的伪代码透视 (CRUD 完全解剖)
为了搞懂 GORM 的魔法,我把 增删改查 (CRUD) 的每一个动作都拆解成了 "GORM 写法" vs "SQL" vs "底层伪代码"。
1. 新增 (Create)
场景:注册一个新用户。
GORM 代码
user := model.User{Username: "new_guy", Password: "123"}
// 传递指针,为了让 GORM 填回自增 ID
db.Create(&user)底层伪代码逻辑
func Create(val interface{}) {
// 1. 反射分析: 拿到 User 结构体的所有字段名 (Username, Password...)
tableName := toSnakeCase(reflect.TypeOf(val).Name) // "User" -> "users"
// 2. 自动填补: CreatedAt, UpdatedAt 为空?填入 time.Now()
// 3. 生成 SQL:
sql := "INSERT INTO users (username, password, created_at...) VALUES (?, ?, ?...)"
// 4. 执行并回填 ID
driver.Exec(sql)
fillBackID(val) // 把数据库生成的 ID=42 填回 user.ID
}2. 查询 (Read)
场景:根据用户名找人(单条数据)。
GORM 代码
var user model.User
// First 会自动加上 LIMIT 1,并把结果填进 &user
// 如果查不到,会返回 gorm.ErrRecordNotFound
db.Where("username = ?", "admin").First(&user)实际生成的 SQL
SELECT * FROM `users`
WHERE username = 'admin'
AND `users`.`deleted_at` IS NULL -- 自动过滤掉软删除的数据
ORDER BY `users`.`id` ASC -- First 默认按主键排序
LIMIT 1底层伪代码逻辑
func First(dest interface{}) {
// 1. 拼装 SQL
sql := "SELECT * FROM users WHERE username = 'admin' ..."
// 2. 执行查询,拿到"生数据" (Rows)
rows := driver.Query(sql)
// 3. 扫描 (Scan) - 这是反射最累的一步
if rows.Next() {
// GORM 看着 user 结构体的字段,把 rows 里的数据一个个填进去
dest.ID = rows["id"]
dest.Username = rows["username"]
// ...
} else {
return error("record not found")
}
}3. 更新 (Update)
场景:修改密码。这里有两种完全不同的模式。
模式 A:只更新特定字段 (推荐)
// 明确告诉 GORM:我只改 password 这一列
db.Model(&user).Update("password", "new_pass")
// SQL: UPDATE users SET password='new_pass', updated_at=NOW() WHERE id=1;模式 B:Save (全量保存)
user.Password = "new_pass" // 先改内存
db.Save(&user) // 同步到数据库底层伪代码逻辑 (Save)
func Save(val interface{}) {
// 1. 检查主键: val.ID 有值吗?
if val.ID > 0 {
// 有 ID -> 执行 UPDATE
// 注意:它会把结构体里的【所有字段】都更新一遍!
// 哪怕 Username 没变,它也会重写一遍 Username
sql := "UPDATE users SET password=?, username=?, role=?, updated_at=NOW()... WHERE id=?"
driver.Exec(sql)
} else {
// 没 ID -> 执行 INSERT (新增)
Create(val)
}
}⚠️ Save 的陷阱:如果你只想改一个字段,用
Update;Save会覆盖所有字段,可能导致意外的数据覆盖。
4. 删除 (Delete)
场景:删除用户。
GORM 代码
// 只要 ID 是对的,其他字段乱填也能删
db.Delete(&User{ID: 5})实际生成的 SQL (软删除 Magic)
因为 User 结构体嵌入了 gorm.Model,它有 DeletedAt 字段。GORM 默认执行 软删除。
-- 注意:不是 DELETE FROM !
UPDATE `users`
SET `deleted_at` = '2026-01-23 10:10:00'
WHERE `id` = 5
AND `deleted_at` IS NULL底层伪代码逻辑
func Delete(val interface{}) {
// 1. 反射检查:这个 struct 有 DeletedAt 字段吗?
if hasDeletedAtField(val) {
// 有 -> 执行 UPDATE (软删除)
// 只是给这条记录打个"已删除"的标记,以后查询会自动过滤它
sql = "UPDATE users SET deleted_at = NOW() WHERE id = ?"
} else {
// 无 -> 执行 DELETE (真删除/物理删除)
sql = "DELETE FROM users WHERE id = ?"
}
driver.Exec(sql, val.ID)
}CRUD 速查表
| 操作 | GORM 方法 | SQL 动作 | 关键点 |
|---|---|---|---|
| Create | db.Create(&user) | INSERT | 传指针,回填 ID |
| Read | db.Where().First(&user) | SELECT | 自动过滤软删除 |
| Update | db.Model(&user).Update() | UPDATE | 推荐精确更新 |
| Update | db.Save(&user) | UPDATE/INSERT | 全量覆盖,看 ID |
| Delete | db.Delete(&user) | UPDATE/DELETE | 有 DeletedAt 则软删 |
第四阶段:终极迷惑 —— db.Model
db.Model 是 GORM 链式调用中最容易混淆的方法。它其实是一个 "定位器"。
用法 A:只定表 (To Table)
当我们传入一个空结构体时:
db.Model(&User{})含义:"GORM 听令,目标是 users 表!"
场景:配合 Where 做查询。
var count int64
db.Model(&User{}).Where("role = ?", "admin").Count(&count)
// SQL: SELECT COUNT(*) FROM users WHERE role = 'admin'用法 B:既定表又定行 (To Row)
当我们传入一个带 ID 的结构体时:
u := User{ID: 100}
db.Model(&u)含义:"GORM 听令,目标是 users 表里的 第 100 号记录!"
场景:配合 Update 做更新。
db.Model(&u).Update("name", "new_name")
// SQL: UPDATE users SET name = 'new_name' WHERE id = 100db.Model 决策流程图
┌─────────────────────────────────────────────────────────┐
│ db.Model(&obj) │
├─────────────────────────────────────────────────────────┤
│ │
│ obj.ID == 0 ? │
│ │ │
│ ├── Yes ──> 只定位到表 (users) │
│ │ 适合:Count, Find, Where │
│ │ │
│ └── No ───> 定位到表 + 行 (users WHERE id=?) │
│ 适合:Update, Delete │
│ │
└─────────────────────────────────────────────────────────┘总结
Week 2 的持久化之路,不仅是代码的迁移,更是对 ORM (对象关系映射) 思想的理解。
| 收获 | 说明 |
|---|---|
| 接口解耦 | 让底层切换如丝般顺滑 |
| 反射机制 | 让 GORM 能够"猜"出我们的意图 |
| 主键思维 | 是理解数据库操作的核心钥匙 |
┌─────────────────────────────────────────────────────────┐
│ 架构升级前后对比 │
├─────────────────────────────────────────────────────────┤
│ │
│ Before (内存存储) │
│ ┌─────────┐ │
│ │ Service │ ──> MemoryRepo ──> map[string]User │
│ └─────────┘ │
│ ↓ 重启 │
│ 数据清空 💀 │
│ │
│ After (GORM 持久化) │
│ ┌─────────┐ │
│ │ Service │ ──> GormRepo ──> SQLite ──> test.db │
│ └─────────┘ │
│ ↓ 重启 │
│ 数据保留 ✅ │
│ │
└─────────────────────────────────────────────────────────┘延伸阅读
- GORM 官方文档 - 最权威的参考
- SQLite vs MySQL 选型指南
- 依赖注入与接口设计 - 本站前文
