第13章:数据库迁移与 envinit 命令行
本章解析 internal/migrator 与 cmd/envinit:建表为什么用 GORM AutoMigrate 而非 SQL 迁移文件、AutoMigrate 的盲区如何用「显式补列」兜底、以及 envinit 五个子命令(schema/seed/init/reinit/reset-password)如何把运维动作收敛成一个工具。
流程
envinit 是独立编译的命令行二进制,与 webserver 共享 bootstrap 与 config。执行流程:cobra 解析子命令 → config.Load 加载配置 → InitLogger/InitDatabase 建立连接 → 执行子命令逻辑。五个子命令构成一个运维动作矩阵:
schema:只建表(AutoMigrate 五模型 + 补列)。seed:只填种子数据(账号 admin/admin + 默认提示词,第 14 章)。init= schema + seed,首次部署标准动作。reinit:DropAllTables(逆序删表)→ init,清空重建,开发环境专用。reset-password -p 'xxx':直接重置 admin 密码,忘密自救。
Docker 部署时对应 docker exec -it live-mixer /app/envinit init——镜像同时打包两个二进制,容器内即可初始化。删表顺序是 allModels 的逆序(Account → ... → Task 反过来),避免外键约束冲突(虽然本项目实际无外键,顺序纪律仍保留为通用安全网)。
实现
建表的核心矛盾:GORM AutoMigrate 方便但对「已有表加列」有已知盲区——它对比结构体与表元数据后可能不执行 ADD COLUMN(版本差异、缓存元数据等原因)。仓库的应对是 ensureVideoProjectMetaColumns:对后加的三个列(title/description/topics)在 AutoMigrate 之后强制补齐:
- PostgreSQL 路径:
ALTER TABLE video_project ADD COLUMN IF NOT EXISTS <col> <定义>——不依赖 GORM 判断,幂等且确定。 - 非 PG 路径(理论上本地测试用 SQLite 等):
HasColumn检查后AddColumn,并再验证一次(失败则提示「请确认 envinit 已用当前代码重新编译」——把编译过期这个常见坑直接写进错误信息)。
这段代码揭示的迁移哲学:新部署靠 AutoMigrate,老部署靠显式补丁,两者叠加。migrations/ 目录下的 SQL 只是「参考脚本」(README 用词),真正执行者是 envinit。每加一个 JSONB 列,就要在这个机制下登记补丁,否则存量表升级会缺列。
另一处细节:InitSchema 的错误聚合——AutoMigrate 与补列的错误合并报告(fmt.Errorf("...: %w; %v", migrateErr, err)),避免补列失败掩盖建表失败。
📌 设计决策
- AutoMigrate 而非 golang-migrate 版本化 SQL:单人全栈项目演进快,结构体即 schema 单一事实源;代价是没有降级路径、升级历史不可审计。若团队扩大,migrations/*.sql 应升级为正式版本化脚本。
- 补列用
IF NOT EXISTS幂等 SQL:可以重复执行、可写进容器 entrypoint、失败可安全重试。 - reinit 的破坏性操作独立成子命令而非 flag:误触成本高于多记一个命令。
- envinit 与 webserver 共享 bootstrap/config 包:初始化与运行时看到同一个配置世界,杜绝「初始化连 A 库、运行连 B 库」的错位。
代码示例
子命令注册表——运维动作一目了然:
// cmd/envinit/main.go(节选)
root.AddCommand(&cobra.Command{
Use: "init", Short: "建表并填充种子数据",
RunE: withDB(func(db *gorm.DB, logger *zap.Logger) error {
if err := migrator.InitSchema(db, logger); err != nil {
return err
}
return seeder.SeedAll(db, logger)
}),
})
root.AddCommand(&cobra.Command{
Use: "reinit", Short: "删除全部业务表后重新建表并填充种子数据",
RunE: withDB(func(db *gorm.DB, logger *zap.Logger) error {
if err := migrator.DropAllTables(db, logger); err != nil {
return err
}
if err := migrator.InitSchema(db, logger); err != nil {
return err
}
return seeder.SeedAll(db, logger)
}),
})
AutoMigrate 盲区的兜底补列:
// internal/migrator/migration.go(节选)
func InitSchema(db *gorm.DB, logger *zap.Logger) error {
migrateErr := db.AutoMigrate(allModels()...)
if err := ensureVideoProjectMetaColumns(db, logger); err != nil {
if migrateErr != nil {
return fmt.Errorf("数据库表初始化失败: %w; %v", migrateErr, err)
}
return fmt.Errorf("数据库表初始化失败: %w", err)
}
if migrateErr != nil {
return fmt.Errorf("数据库表初始化失败: %w", migrateErr)
}
return nil
}
// PostgreSQL 使用 ADD COLUMN IF NOT EXISTS,不依赖 GORM 是否判定列已存在
if db.Dialector.Name() == "postgres" {
for _, col := range videoProjectMetaColumns {
err := db.Exec("ALTER TABLE ? ADD COLUMN IF NOT EXISTS ? "+col.pg,
clause.Table{
Name: "video_project"},
clause.Column{
Name: col.name}).Error
// ...
}
}
小结
- envinit 五子命令覆盖建表、种子、重建、改密,容器内外同一工具。
- AutoMigrate + 幂等补列双保险,新增 JSONB 列必须登记补丁。
- 删表逆序、错误聚合、共享 bootstrap 是三个值得抄的工程习惯。
思考题
- 设计一个「补列登记表」的演化方案,让每个新列自动获得与 title/description/topics 同级的兜底。
- 若引入 golang-migrate,现有 AutoMigrate 首次全量建表应翻译为哪个版本的 baseline migration?
项目信息
- GitHub仓库:github.com/Chyona/live-mixer
- 项目案例:gogoshine.com