为什么我放弃了 GORM,转向 Ent

为什么我放弃了 GORM,转向 Ent

Lirous.
12/28/2025
25 min read
从 GORM 到 SQLBoiler,再到 Ent。三个 ORM 的踩坑和迁移历程。讲讲我怎么从不够安全的 GORM 走向了强类型的世界。

我的 ORM 选型之路还挺曲折的。最初用 GORM,后来因为类型安全的问题,做了一次全量迁移到 SQLBoiler。再后来又了解到 Ent,现在的新项目优先考虑 Ent。

这段经历让我理解了一个道理:选 ORM 不是一成不变的,而是随着项目规模和经验的积累不断优化的

GORM:好用,但太灵活了

我第一次用 GORM 的时候就被它的简洁迷住了。想要查询?三两行代码搞定:

var users []User
db.Where("age > ?", 18).Find(&users)

// 想要更复杂的条件?
db.Where("age > ? AND status = ?", 18, "active").
    Order("created_at DESC").
    Limit(10).
    Find(&users)

但这份"灵活"也埋下了隐患。

隐患 1:字符串字段名,容易打错

// 假设 User 有个字段 Status
// 但我不小心写成了 Statuss(多打了一个 s)
db.Where("statuss = ?", "active").Find(&users)  // SQL 成功,但查不出结果

// 更惨的情况:
db.Where("created_at > ?", "2026-01-01").
    Order("updated_at DESC").  // 这个字段我打成了 updatedAt(驼峰)
    Find(&users)

这种bug在开发时很难察觉,线上跑起来才能发现。而且搜索这样的问题——"为什么查询结果不对"——会让人头大。

隐患 2:类型不匹配,直到运行时才知道

type User struct {
    ID    int
    Name  string
    Age   int
    Email string
}

// Scan 一个不存在的列
var age string
db.Table("users").Select("age").Scan(&age)  // age 是 int,但我 Scan 到 string
// 运行时才会报错或者数据损坏

// Select 中文字段名打错
db.Select("name, agee").Find(&users)  // agee 不存在
// SQL 执行失败,错误信息模糊

隐患 3:Hook 的魔法性和隐藏的行为

// User 的 BeforeSave Hook
func (u *User) BeforeSave(tx *gorm.DB) error {
    u.Email = strings.ToLower(u.Email)
    return nil
}

// 在代码的某个地方
user.Email = "ALICE@EXAMPLE.COM"
db.Save(user)  // 这时候 Email 被自动转成小写

// 但如果我不知道这个 Hook,我会想:为什么我保存的数据被改了?
// 这种隐藏的行为特别容易导致 bug

隐患 4:关系处理容易混乱

// 定义 User 和 Post 的关系
type User struct {
    ID    int
    Posts []Post  // One to Many
}

type Post struct {
    ID     int
    UserID int
    User   User  // Many to One
}

// 查询时,忘记 Preload,导致 N+1 问题
var users []User
db.Find(&users)  // 只查了 users 表
for _, u := range users {
    // 遍历时每个 user 都会额外查一次 posts
    _ = u.Posts
}

// 修复要记得 Preload
db.Preload("Posts").Find(&users)

// 但如果有 10 个关系,你要全记住哪些需要 Preload?

这些都是我在生产环境踩过的坑。GORM 的便利性和危险性往往是一枚硬币的两面。

Ent:麻烦点,但更踏实

Ent 的学习曲线确实陡峭。第一次上手的时候,我被它的代码生成机制搞懵了:

go run entgo.io/ent/cmd/ent init User
# 这会生成一堆文件,schema 定义、代码生成...

但当我真正用上去以后,我的感受就变了。

优势 1:Schema 即类型,编译期检查

Ent 用代码定义 Schema,和程序逻辑无缝结合:

// schema/user.go
package schema

import (
    "entgo.io/ent"
    "entgo.io/ent/schema/field"
)

type User struct {
    ent.Schema
}

func (User) Fields() []ent.Field {
    return []ent.Field{
        field.Int("id"),
        field.String("name").NotEmpty(),
        field.Int("age").Positive(),
        field.String("email").Unique(),
        field.String("status").Default("active"),
    }
}

Ent 会自动生成:

  1. User 结构体(类型安全)
  2. UserCreate/UserUpdate builder(链式 API)
  3. UserQuery builder(类型安全的查询)

生成出来的查询代码是这样的:

// 类型安全的查询!编译期就能检查字段
user, err := client.User.Query().
    Where(user.AgeGT(18)).
    Where(user.StatusEQ("active")).
    Order(ent.Asc(user.FieldCreatedAt)).  // 字段名来自枚举,不是字符串
    Limit(10).
    All(ctx)

// 试试打错字段名?
// user.FieldAGEE  // 编译错误!IDE 也会直接告诉你

这就是强类型的威力。错误在编译期就暴露了,而不是线上才发现。

优势 2:关系处理清晰,自动 N+1 检测

// schema/user.go
type User struct {
    ent.Schema
}

func (User) Edges() []ent.Edge {
    return []ent.Edge{
        edge.To("posts", Post.Type),
    }
}

// schema/post.go
type Post struct {
    ent.Schema
}

func (Post) Edges() []ent.Edge {
    return []ent.Edge{
        edge.From("user", User.Type).Ref("posts").Unique(),
    }
}

// 查询时的 API 非常清晰
users, err := client.User.Query().
    WithPosts().  // 显式加载 posts,代码很清楚在做什么
    All(ctx)

// 如果你忘记了 WithPosts(),Ent 可以配置检测,防止 N+1

对比 GORM 的 Preload,Ent 的 With* 更明确,而且是编译期生成的。

优势 3:代码生成带来的一致性

Ent 所有的查询、更新、删除都遵循同一套模式。你不用记住各种 API,因为都是自动生成的:

// 创建
user, err := client.User.Create().
    SetName("Alice").
    SetEmail("alice@example.com").
    SetAge(25).
    Save(ctx)

// 更新
user, err := user.Update().
    SetStatus("inactive").
    Save(ctx)

// 查询
users, err := client.User.Query().
    Where(user.AgeGT(18)).
    All(ctx)

// 删除
err := client.User.DeleteOne(user).Exec(ctx)

每个操作的 API 都是一致的 builder 模式,学一个就学了所有。

优势 4:Hooks 是显式的,易于理解

// schema/user.go
type User struct {
    ent.Schema
}

func (User) Hooks() []ent.Hook {
    return []ent.Hook{
        // Hooks 在 schema 定义时就声明,谁看 schema 谁就知道有 hooks
        hook.On(
            func(next ent.Mutator) ent.Mutator {
                return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
                    // mutation 前的逻辑
                    user := m.(*gen.UserMutation)
                    if name, ok := user.GetName(); ok {
                        user.SetName(strings.ToLower(name))
                    }
                    return next.Mutate(ctx, m)
                })
            },
            ent.OpCreate | ent.OpUpdate,  // 明确指定在什么操作时触发
        ),
    }
}

Hook 的位置明确,触发条件清楚,不像 GORM 那样隐隐约约。

我的踩坑和收获

坑 1:初期代码生成带来的"不安感"

一开始我特别不习惯 Ent 的代码生成。每改一个 schema,就得 go generate ./... 重新生成。我的第一反应是:这样太麻烦了,生成的代码改起来也不方便。

但后来我意识到——正是因为代码是生成的,我才不应该手改。生成的代码是工具的输出,不是我的资产。我只需要维护 schema 定义,其他都由工具保证一致性。

这个转变思想还是挺关键的。

坑 2:关系定义时的对称性问题

// 我第一次定义 User 和 Post 的关系时搞错了
type User struct {
    ent.Schema
}

func (User) Edges() []ent.Edge {
    return []ent.Edge{
        edge.To("posts", Post.Type),  // User 有多个 Post
    }
}

// 然后在 Post 里写
func (Post) Edges() []ent.Edge {
    return []ent.Edge{
        edge.From("user", User.Type),  // 但这个 From 和上面 To 不对应
    }
}

// 编译时 Ent 会报错,告诉你关系定义不对称

这是 Ent 的"麻烦"地方,但也正是它的优势——问题在编译期就暴露了,而不是线上数据错乱。

坑 3:Context 的使用

Ent 的所有操作都需要 Context,这一开始很烦:

// 不能这样
users, err := client.User.Query().All()

// 得这样
users, err := client.User.Query().All(ctx)

但这个设计是对的。Context 用来传递超时、取消、tracing 信息,在生产环境非常重要。强制使用 Context 防止了很多问题。

为什么我最后选择了 Ent

总结一下:

方面 GORM Ent SQLBoiler
学习曲线 平缓 陡峭 中等
类型安全 部分(字符串字段名) 完全(生成的代码) 完全(生成的代码)
错误检测 运行时 编译期 编译期
API 一致性 一般 非常好 函数式
性能 不错 基本一样 非常好
社区规模 很大 中等,但快速增长 小但稳定
学习文档 非常丰富 正在完善 完善
代码生成 正向(从代码→DB) 反向(从DB→代码)
关系处理 动态 Preload With* API 手动 Join

我的选择标准很简单:在保证类型安全的前提下,最大化开发效率和代码清晰度

这个标准是从 GORM → SQLBoiler → Ent 的迁移中总结出来的。三个阶段让我明白了:

  1. GORM 的便利性是有代价的 — 看起来快,其实是把问题推后了
  2. SQLBoiler 的类型安全很重要 — 它让我意识到了强类型 ORM 的价值
  3. 但工作流同样重要 — 代码优先 vs 数据库优先,选择哪个直接影响开发效率

Ent 恰好平衡了这三个方面。现在的新项目,我都优先考虑 Ent,除非有特别的理由用 SQLBoiler。

SQLBoiler 也值得考虑

说到代码生成,SQLBoiler 也是一个不错的选择,虽然我最后没选它,但它的思路很有意思。

SQLBoiler 的核心特点:从数据库 schema 反向生成代码。你先在数据库里建表,SQLBoiler 扫描数据库结构,自动生成类型和查询代码。

# SQLBoiler 的工作流
# 1. 在数据库创建表
# 2. 运行命令生成代码
sqlboiler mysql  # 根据你的 MySQL 数据库生成

# 3. 自动生成所有查询代码

生成出来的 API 比较接近 SQL,风格很函数式:

// SQLBoiler 的查询风格
users, err := models.Users(
    qm.Where("age > ?", 18),
    qm.Where("status = ?", "active"),
    qm.OrderBy("created_at DESC"),
    qm.Limit(10),
).All(ctx, db)

// 创建
u := &models.User{Name: "Alice", Email: "alice@example.com", Age: 25}
err := u.Insert(ctx, db, boil.Infer())

// 更新
u.Status = "inactive"
rowsAff, err := u.Update(ctx, db, boil.Infer())

SQLBoiler 的优势

  1. 性能最强。生成的代码最接近原生 SQL,执行效率非常高
  2. 和数据库状态同步。数据库是 source of truth,不用担心代码和 DB 不同步
  3. SQL 完全可控。如果有复杂 SQL,可以写原生 SQL,SQLBoiler 不会过度抽象
  4. 文档完善。虽然社区小,但文档质量不错

SQLBoiler 的劣势

  1. 关系处理没那么优雅。需要手动 Join,虽然有辅助函数但还是比较繁琐
  2. API 风格偏函数式。这对有 FP 背景的人很友好,但习惯了 builder 模式的人可能不太适应
  3. 社区较小。问题多了以后自救能力有限
  4. 依赖数据库。你必须有可用的数据库才能生成代码,这在某些场景(比如容器化开发)会有点麻烦

我的 ORM 迁移之路:GORM → SQLBoiler → Ent

这是一段漫长的踩坑和优化过程。

第一阶段:用 GORM,不断踩坑

最开始用 GORM,上面列举的那些隐患,我全都遇到过。特别是有一次,一个线上 bug 就是因为打错了字段名——updated_at 被我不小心写成 updatedat,查询跑得特别"成功",结果没查到任何东西。测试没覆盖到这个逻辑,线上才暴露。

那一刻我就想:有没有办法在编译期就发现这种错误?

第二阶段:全量迁移到 SQLBoiler

后来我了解到了 SQLBoiler,一个从数据库 schema 反向生成强类型代码的 ORM。我特别兴奋,这不就是我想要的吗?类型安全,编译期检查!

于是我做了一次大胆的决定:把整个项目从 GORM 全量迁移到 SQLBoiler。那时候项目还不算太大,大概 20+ 个表,几十个关系。迁移花了差不多一周。

迁移完了以后,我的代码确实更安全了。没有再出现"打错字段名"的问题。但新的问题又来了。

关系处理这块特别痛。我的项目中,一个 User 有多个 Post,一个 Post 有多个 Comment。当我想查询一个 User 的所有信息(包括所有 Post 和每个 Post 的 Comment)时,SQLBoiler 的写法就变得又臭又长:

// SQLBoiler 的关系加载
user, err := models.Users(
    qm.Load(models.UserRels.Posts),
).One(ctx, db)

// 然后还要分别加载每个 Post 的 Comment...
// 代码会变得特别复杂

迭代速度也受限。SQLBoiler 的工作流是固定的:先改数据库,再跑 sqlboiler 命令生成代码。但我习惯了在代码里先思考 schema 应该怎么设计,然后再同步到数据库。SQLBoiler 的反向工程思路和我的开发习惯有点冲突。

而且依赖数据库环境。想要生成代码,就必须连上数据库。这在开发新功能时会有些麻烦。

第三阶段:了解到 Ent,再次迁移

在用 SQLBoiler 的过程中,我逐渐了解到了 Ent。它的设计理念和 SQLBoiler 完全相反——不是"数据库优先",而是**"代码优先"**。在 Go 代码里定义 schema,Ent 自动生成查询代码和类型。

看起来这就是我一直想要的!于是我又做了一次迁移,从 SQLBoiler 转向 Ent。

这次迁移的感受很不一样。Ent 的 With* API 处理关系特别舒服:

// Ent:关系加载一行搞定
user, err := client.User.Query().
    WithPosts(func(q *ent.PostQuery) {
        q.WithComments()  // 嵌套加载也很清楚
    }).
    Only(ctx)

这比 SQLBoiler 的手动 Join 清晰太多了。而且代码优先的模式完全符合我的开发流程——我可以先写 schema,Ent 自动同步到数据库(甚至可以用 migrate 工具)。

现在的选择:Ent 优先,SQLBoiler 作为备选

经过这三个阶段的踩坑,我现在的标准很明确:

  • 新项目优先用 Ent。如果项目关系复杂、需要频繁迭代,Ent 的代码优先模式特别香。
  • 如果需要最大性能或者 schema 特别稳定,考虑 SQLBoiler。它生成的代码最接近原生 SQL,性能无敌。

但说实话,大多数情况下我都会选 Ent。GORM 的便利性在项目规模变大时会逐渐变成隐患,而 Ent 给了我足够的安全感和开发速度的平衡。

实战建议

GORM 适合的场景

  • 快速原型和 PoC(Proof of Concept)
  • 临时脚本或一次性任务
  • 团队已经有大量 GORM 代码,迁移成本太高
  • 项目规模很小,类型安全没那么关键

SQLBoiler 适合的场景

  • 数据库 schema 已经存在且相对稳定(比如迁移 legacy 系统)
  • 有复杂 SQL 需求,需要最大性能
  • 团队的工作流是"数据库优先设计"
  • 项目对 ORM 的性能开销特别敏感

Ent 适合的场景 (我现在的优先选择):

  • 新项目,想从一开始就建立安全的基础
  • 关系复杂,需要优雅的关系处理 API
  • 团队规模大,代码维护和协作很重要
  • 想要强类型检查和完整的 IDE 支持

迁移策略:如果你和我一样想从 GORM 迁移到更安全的 ORM:

  1. 先在新功能中使用 Ent(或 SQLBoiler,取决于你的需求)
  2. 让两个 ORM 共存一段时间,逐步验证
  3. 一旦稳定了,逐步重构老代码
  4. 最后完全替换

这样的话,风险比较小,也能给团队时间适应新的开发范式。


写到这里,我想说的是:ORM 选型的最好方案,往往是从实践中总结出来的

我的三阶段迁移(GORM → SQLBoiler → Ent)看起来有点折腾,但每一步都给了我新的认识:

  • GORM 教会了我:便利和安全往往不能兼得
  • SQLBoiler 教会了我:强类型和编译期检查有多重要
  • Ent 教会了我:工作流和 API 设计一样关键

现在我对 ORM 的理解就是:没有完美的工具,只有最适合当前项目的工具。GORM 对快速验证想法很好,SQLBoiler 对稳定的 schema 和性能敏感场景很好,Ent 对长期维护和团队协作最友好。

关键是要根据自己的项目特点和工作习惯,做出合理的选择。如果你现在还在用 GORM,我的建议是:试试 Ent,体验一下强类型 ORM 的安心感。一旦用过,你就很难再回到字符串拼接的日子了。

评论区