因为main包无法被其他模块import,所有可复用的orm逻辑必须放在非main包中,否则会导致代码重复、难以测试和维护;正确做法是按关注点分离:db包管连接、model包管结构、repo包管查询,并通过接口+依赖注入实现解耦与可测试性。

为什么不能直接在 main 包里写 ORM 逻辑
因为 Go 的 main 包无法被其他模块 import,所有想复用的 ORM 封装必须放在非 main 包里。一旦你把数据库初始化、模型定义、CRUD 方法全塞进 main.go,后续换项目或加微服务时就得复制粘贴再改——这不是封装,是埋雷。
真正可复用的封装,核心是「分离关注点」:连接管理归 db 包,模型结构归 model 包,查询逻辑归 repo 或 query 包。不是越“大而全”越好,而是让每个包只做一件事,且能独立测试。
-
go mod init example.com/orm后,所有子包路径都基于这个 module path,比如example.com/orm/db - 别用
_或空包名导入(如import _ "example.com/orm/db"),这会让初始化逻辑隐式执行,调试时找不到入口 - 如果 ORM 封装依赖
sqlx或gorm,它们的DB类型要作为参数传入,而不是在包内硬编码var db *sqlx.DB
如何设计可注入的 Repository 接口
硬编码 SQL 字符串或直接调用 db.Query 会导致测试困难、SQL 注入风险、难以替换底层驱动。正确做法是定义接口 + 具体实现分离,靠依赖注入传递 *sql.DB 或 *gorm.DB。
例如用户查询,不要写 func GetUserByID(id int) User,而是:
type UserRepository interface {
FindByID(ctx context.Context, id int64) (*User, error)
Create(ctx context.Context, u *User) error
}
<p>type pgRepo struct {
db <em>sql.DB // 或 </em>gorm.DB,取决于你选的驱动
}</p><p>func (r <em>pgRepo) FindByID(ctx context.Context, id int64) (</em>User, error) {
row := r.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id)
// ...
}</p>
- 接口方法必须带
context.Context参数,否则无法支持超时、取消、trace propagation - 返回值用指针(
*User)而非值类型,避免 nil panic;错误统一用error,不混用自定义错误码 - 别在接口里暴露
sql.Rows或*gorm.DB,这会把实现细节泄漏给调用方
怎么安全地处理 GORM 的 Model 定义与迁移
GORM 的 gorm.Model 和结构体标签(如 gorm:"primaryKey")本身不绑定具体数据库,但迁移(AutoMigrate)会直接操作 DB schema。这意味着:迁移逻辑绝不能放在通用模型包里,而应由使用方按环境控制。
推荐方式是把模型定义单独抽成 model 包,只含结构体和 GORM 标签;迁移代码放在 migration 包或主应用启动流程中。
package model
<p>type User struct {
ID uint <code>gorm:"primaryKey"</code>
Name string <code>gorm:"not null"</code>
CreatedAt time.Time
}</p>
- 结构体字段必须导出(首字母大写),否则 GORM 反射读不到
- 避免在模型里写业务方法(如
u.FullName()),这会让模型变重,且和 ORM 耦合过紧 -
AutoMigrate不会删除字段或表,上线前务必手动验证变更,尤其涉及NOT NULL或外键约束时 - 如果用 SQLite 做测试,GORM 默认不支持
ALTER COLUMN,迁移失败后不会回滚,需提前清理test.db
什么时候该自己写 Query Builder 而不是用 GORM
当你遇到复杂 JOIN、动态条件拼接、或需要精细控制预编译语句(如防止 SQL 注入+性能敏感场景),GORM 的链式 API 反而容易写出 N+1 查询或难 debug 的嵌套调用。这时直接用 database/sql + sqlx 更可控。
比如按多条件筛选订单并分页,GORM 写法易出错:
// ❌ 错误示例:Where 条件嵌套导致 OR 逻辑混乱
db.Where("status = ?", status).Where("created_at > ?", t).Offset(offset).Limit(limit)
<p>// ✅ 推荐:用 sqlx.Named + map 构建动态 SQL
query := <code>SELECT * FROM orders WHERE 1=1</code>
args := map[string]interface{}{}
if status != "" {
query += " AND status = :status"
args["status"] = status
}
rows, err := db.NamedQuery(query, args)</p>
- 永远用
NamedQuery或QueryRowContext,不用Exec拼接字符串,哪怕只是简单 INSERT - WHERE 条件为空时不删掉
WHERE 1=1,比动态拼 SQL 字符串更安全、更易读 - 分页慎用
LIMIT OFFSET,大数据量时性能陡降;优先考虑游标分页(WHERE id > ? ORDER BY id LIMIT ?)
实际封装时最常被忽略的是错误分类——不是所有 error 都该透传给上层。数据库连接失败、唯一键冲突、记录未找到,这三类错误的处理策略完全不同,但很多人全塞进一个 fmt.Errorf("db error: %w", err) 里,结果上层只能靠字符串匹配判断,极其脆弱。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











