Gin + GORM 正确使用:不要把框架写成业务核心
Gin 和 GORM 是 Go 后端里很常见的一组搭配。
一个负责 HTTP:
1 | 路由、中间件、参数绑定、响应输出 |
一个负责数据库:
1 | 模型映射、CRUD、事务、关联查询、预加载 |
它们上手都不难。
但真正写项目时,最容易犯的错误是:
1 | 让 Gin 和 GORM 侵入整个业务。 |
最后代码变成:
1 | handler 里写业务 |
这样项目小的时候能跑,项目一大就会难维护。
正确使用 Gin + GORM 的重点不是“会不会调用 API”,而是:
1 | 把 HTTP、业务、数据库三件事分清楚。 |
一、Gin 只负责 Web 层
Gin 很适合做 Web 层。
比如:
- 注册路由
- 分组路由
- 读取 path/query/body 参数
- 做基础校验
- 调用 service
- 返回 JSON
- 挂载中间件
一个比较干净的 handler 应该像这样:
1 | func (h *UserHandler) GetUser(c *gin.Context) { |
注意这里有一个关键点:
1 | h.userService.GetUser(c.Request.Context(), id) |
传给 service 的是标准库 context.Context,不是 *gin.Context。
gin.Context 应该留在 Web 层。
业务层不应该依赖 Gin。
二、不要把 gin.Context 传到 service 和 repository
很多项目会这样写:
1 | func (s *UserService) GetUser(c *gin.Context) (*User, error) { |
这会让业务层和 Gin 绑定死。
问题很明显:
- service 不能被命令行任务复用
- service 不能被队列消费者复用
- service 测试需要构造 gin.Context
- repository 也被迫知道 Web 框架
更合理的是:
1 | func (s *UserService) GetUser(ctx context.Context, id int64) (*UserDTO, error) { |
这样业务层只依赖 Go 标准的 context。
Gin 只是入口。
未来换成标准库、gRPC、定时任务、消息队列,业务层都不用大改。
三、参数绑定要用 DTO
Gin 的 binding 很方便。
比如:
1 | type CreateUserRequest struct { |
handler 里:
1 | var req CreateUserRequest |
这里建议使用专门的 request DTO,而不是直接绑定到 GORM model。
不要这样:
1 | var user User |
这会把接口字段和数据库字段绑在一起。
数据库里有的字段,不一定允许用户传。
比如:
1 | ID |
这些字段如果直接暴露在 model 上,很容易被错误赋值。
更稳的方式是:
1 | Request DTO -> Service 入参 -> Model |
这样接口边界会清楚很多。
四、路由分组和中间件要按职责放
Gin 的路由分组很好用。
比如:
1 | r := gin.New() |
中间件不要乱挂。
全局中间件适合:
- 日志
- Recovery
- CORS
- trace id
分组中间件适合:
- 登录校验
- 权限校验
- 限流
- 特定 API 的参数预处理
不要把业务逻辑塞进中间件。
中间件适合处理横切逻辑,不适合处理具体业务流程。
五、GORM 只放在 repository 层
GORM 很方便,但不要让它到处出现。
比较推荐的结构是:
1 | handler -> service -> repository -> gorm.DB |
repository 负责数据库读写:
1 | type UserRepository struct { |
这里也有一个重点:
1 | r.db.WithContext(ctx) |
GORM 查询应该带上 context。
这样请求超时或取消时,数据库操作也可以跟着停止。
六、不要在 handler 里直接写 GORM 查询
不要这样写:
1 | func GetUser(c *gin.Context) { |
这段代码在 demo 里没问题。
但在真实项目里会逐渐变成灾难。
因为 handler 会同时承担:
- 参数解析
- 数据库查询
- 业务判断
- 错误翻译
- 响应结构
最后所有逻辑都堆在 Web 层。
更好的方式是让 handler 只负责 HTTP:
1 | func (h *UserHandler) GetUser(c *gin.Context) { |
业务判断放 service。
数据库操作放 repository。
七、GORM Model 不等于业务对象
GORM model 通常长这样:
1 | type User struct { |
它描述的是数据库结构。
但业务层不一定要直接操作这个结构。
尤其是接口返回时,不要直接:
1 | c.JSON(http.StatusOK, user) |
更推荐转成 response:
1 | type UserResponse struct { |
这样可以避免把 PasswordHash、软删除字段、内部状态暴露出去。
模型、业务对象、响应对象可以相似,但不要默认就是同一个东西。
八、事务要放在业务边界
GORM 支持事务。
但事务不应该随便散落在 handler 里。
事务通常对应一个业务动作。
比如:
1 | 创建订单 |
这些要么全部成功,要么全部失败。
这种逻辑应该放在 service:
1 | func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderInput) error { |
事务的边界是业务边界,不是接口边界。
一个接口可能不需要事务。
一个业务动作也可能被 HTTP、CLI、队列同时调用。
所以事务应该靠近 service,而不是靠近 Gin handler。
九、关联查询要明确使用 Preload
GORM 不会自动帮你把所有关联都查出来。
如果需要预加载关联,要明确写:
1 | var users []User |
这比隐式加载更清楚。
你一眼就能看出这次查询会带出订单数据。
如果只需要部分字段,或者关联数据很多,就不要无脑 Preload。
关联查询最容易造成性能问题:
- 查太多字段
- 查太多关联
- 返回太大 JSON
- 分页前后逻辑混乱
使用 ORM 不代表不用理解 SQL。
GORM 只是帮你组织查询,不是替你做数据库设计。
十、分页、排序、过滤要白名单
接口里经常会有:
1 | page |
分页要限制最大值:
1 | if pageSize > 100 { |
排序字段要做白名单。
不要直接把用户传入的 sort 拼进 SQL:
1 | db.Order(c.Query("sort")) |
更好的方式是:
1 | switch req.Sort { |
ORM 不是安全护身符。
用户可控字段进入查询之前,仍然要经过白名单。
十一、错误要统一翻译
GORM 的错误不应该原样丢给前端。
比如:
1 | errors.Is(err, gorm.ErrRecordNotFound) |
可以在 service 或统一错误处理里转成业务错误:
1 | 用户不存在 |
再由 handler 转成 HTTP 状态码:
1 | 404 |
一个简单的分层可以是:
1 | repository 返回 GORM 错误 |
这样前端不会看到数据库细节,日志里也能保留足够的排查信息。
十二、推荐目录结构
一个简单项目可以这样组织:
1 | cmd/api/main.go |
业务模块里再分:
1 | internal/user/handler.go |
也可以按团队习惯调整。
重点不是目录名,而是职责清楚:
1 | handler 管 HTTP |
只要这个边界守住,Gin + GORM 就不会互相污染。
十三、一个更完整的调用链
最终调用链可以长这样:
1 | HTTP Request |
返回时:
1 | Database |
这个结构不复杂。
但它能让每一层都知道自己该干什么。
Gin 不碰数据库细节。
GORM 不进入 HTTP 层。
Service 不依赖 Web 框架。
这就是 Gin + GORM 更舒服的用法。
十四、结语
Gin 和 GORM 都很好用。
但它们只是工具。
Gin 不应该变成业务核心。
GORM 也不应该变成领域模型本身。
更好的写法是:
1 | Gin 负责接住请求 |
只要这个边界守住,Gin + GORM 可以写得很清楚,也很适合中小型 Go 后端项目。
真正的问题从来不是“能不能用框架”。
而是:
1 | 你是在使用框架,还是被框架牵着走。 |