Gin + GORM 正确使用:不要把框架写成业务核心
wxk1991 Lv5

Gin + GORM 正确使用:不要把框架写成业务核心

Gin 和 GORM 是 Go 后端里很常见的一组搭配。

一个负责 HTTP:

1
路由、中间件、参数绑定、响应输出

一个负责数据库:

1
模型映射、CRUD、事务、关联查询、预加载

它们上手都不难。

但真正写项目时,最容易犯的错误是:

1
让 Gin 和 GORM 侵入整个业务。

最后代码变成:

1
2
3
4
handler 里写业务
handler 里写数据库
handler 里直接操作 gorm.Model
handler 里到处传 gin.Context

这样项目小的时候能跑,项目一大就会难维护。

正确使用 Gin + GORM 的重点不是“会不会调用 API”,而是:

1
把 HTTP、业务、数据库三件事分清楚。

一、Gin 只负责 Web 层

Gin 很适合做 Web 层。

比如:

  • 注册路由
  • 分组路由
  • 读取 path/query/body 参数
  • 做基础校验
  • 调用 service
  • 返回 JSON
  • 挂载中间件

一个比较干净的 handler 应该像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (h *UserHandler) GetUser(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
return
}

user, err := h.userService.GetUser(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}

c.JSON(http.StatusOK, user)
}

注意这里有一个关键点:

1
h.userService.GetUser(c.Request.Context(), id)

传给 service 的是标准库 context.Context,不是 *gin.Context

gin.Context 应该留在 Web 层。

业务层不应该依赖 Gin。


二、不要把 gin.Context 传到 service 和 repository

很多项目会这样写:

1
2
3
4
func (s *UserService) GetUser(c *gin.Context) (*User, error) {
id := c.Param("id")
return s.repo.Find(c, id)
}

这会让业务层和 Gin 绑定死。

问题很明显:

  • service 不能被命令行任务复用
  • service 不能被队列消费者复用
  • service 测试需要构造 gin.Context
  • repository 也被迫知道 Web 框架

更合理的是:

1
2
3
func (s *UserService) GetUser(ctx context.Context, id int64) (*UserDTO, error) {
return s.repo.FindByID(ctx, id)
}

这样业务层只依赖 Go 标准的 context。

Gin 只是入口。

未来换成标准库、gRPC、定时任务、消息队列,业务层都不用大改。


三、参数绑定要用 DTO

Gin 的 binding 很方便。

比如:

1
2
3
4
type CreateUserRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
}

handler 里:

1
2
3
4
5
var req CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

这里建议使用专门的 request DTO,而不是直接绑定到 GORM model。

不要这样:

1
2
3
var user User
c.ShouldBindJSON(&user)
db.Create(&user)

这会把接口字段和数据库字段绑在一起。

数据库里有的字段,不一定允许用户传。

比如:

1
2
3
4
5
6
ID
Role
Status
CreatedAt
UpdatedAt
PasswordHash

这些字段如果直接暴露在 model 上,很容易被错误赋值。

更稳的方式是:

1
2
Request DTO -> Service 入参 -> Model
Model -> Response DTO

这样接口边界会清楚很多。


四、路由分组和中间件要按职责放

Gin 的路由分组很好用。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())

api := r.Group("/api")
{
api.GET("/health", healthHandler)

users := api.Group("/users")
users.Use(authMiddleware)
{
users.GET("/:id", userHandler.GetUser)
users.POST("", userHandler.CreateUser)
}
}

中间件不要乱挂。

全局中间件适合:

  • 日志
  • Recovery
  • CORS
  • trace id

分组中间件适合:

  • 登录校验
  • 权限校验
  • 限流
  • 特定 API 的参数预处理

不要把业务逻辑塞进中间件。

中间件适合处理横切逻辑,不适合处理具体业务流程。


五、GORM 只放在 repository 层

GORM 很方便,但不要让它到处出现。

比较推荐的结构是:

1
handler -> service -> repository -> gorm.DB

repository 负责数据库读写:

1
2
3
4
5
6
7
8
9
10
11
12
type UserRepository struct {
db *gorm.DB
}

func (r *UserRepository) FindByID(ctx context.Context, id int64) (*User, error) {
var user User
err := r.db.WithContext(ctx).First(&user, id).Error
if err != nil {
return nil, err
}
return &user, nil
}

这里也有一个重点:

1
r.db.WithContext(ctx)

GORM 查询应该带上 context。

这样请求超时或取消时,数据库操作也可以跟着停止。


六、不要在 handler 里直接写 GORM 查询

不要这样写:

1
2
3
4
5
6
7
8
func GetUser(c *gin.Context) {
var user User
if err := db.First(&user, c.Param("id")).Error; err != nil {
c.JSON(404, gin.H{"error": "not found"})
return
}
c.JSON(200, user)
}

这段代码在 demo 里没问题。

但在真实项目里会逐渐变成灾难。

因为 handler 会同时承担:

  • 参数解析
  • 数据库查询
  • 业务判断
  • 错误翻译
  • 响应结构

最后所有逻辑都堆在 Web 层。

更好的方式是让 handler 只负责 HTTP:

1
2
3
4
5
6
7
8
9
func (h *UserHandler) GetUser(c *gin.Context) {
id := parseID(c)
user, err := h.service.GetUser(c.Request.Context(), id)
if err != nil {
writeError(c, err)
return
}
c.JSON(http.StatusOK, user)
}

业务判断放 service。

数据库操作放 repository。


七、GORM Model 不等于业务对象

GORM model 通常长这样:

1
2
3
4
5
6
7
8
type User struct {
ID uint `gorm:"primaryKey"`
Email string `gorm:"uniqueIndex"`
PasswordHash string
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}

它描述的是数据库结构。

但业务层不一定要直接操作这个结构。

尤其是接口返回时,不要直接:

1
c.JSON(http.StatusOK, user)

更推荐转成 response:

1
2
3
4
type UserResponse struct {
ID uint `json:"id"`
Email string `json:"email"`
}

这样可以避免把 PasswordHash、软删除字段、内部状态暴露出去。

模型、业务对象、响应对象可以相似,但不要默认就是同一个东西。


八、事务要放在业务边界

GORM 支持事务。

但事务不应该随便散落在 handler 里。

事务通常对应一个业务动作。

比如:

1
2
3
4
创建订单
扣库存
写订单日志
生成支付记录

这些要么全部成功,要么全部失败。

这种逻辑应该放在 service:

1
2
3
4
5
6
7
8
9
10
11
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderInput) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.orderRepo.CreateWithTx(tx, order); err != nil {
return err
}
if err := s.stockRepo.DecreaseWithTx(tx, req.ProductID, req.Count); err != nil {
return err
}
return nil
})
}

事务的边界是业务边界,不是接口边界。

一个接口可能不需要事务。

一个业务动作也可能被 HTTP、CLI、队列同时调用。

所以事务应该靠近 service,而不是靠近 Gin handler。


九、关联查询要明确使用 Preload

GORM 不会自动帮你把所有关联都查出来。

如果需要预加载关联,要明确写:

1
2
3
4
var users []User
err := db.WithContext(ctx).
Preload("Orders").
Find(&users).Error

这比隐式加载更清楚。

你一眼就能看出这次查询会带出订单数据。

如果只需要部分字段,或者关联数据很多,就不要无脑 Preload。

关联查询最容易造成性能问题:

  • 查太多字段
  • 查太多关联
  • 返回太大 JSON
  • 分页前后逻辑混乱

使用 ORM 不代表不用理解 SQL。

GORM 只是帮你组织查询,不是替你做数据库设计。


十、分页、排序、过滤要白名单

接口里经常会有:

1
2
3
4
5
page
page_size
sort
keyword
status

分页要限制最大值:

1
2
3
if pageSize > 100 {
pageSize = 100
}

排序字段要做白名单。

不要直接把用户传入的 sort 拼进 SQL:

1
db.Order(c.Query("sort"))

更好的方式是:

1
2
3
4
5
6
7
8
switch req.Sort {
case "created_at_desc":
db = db.Order("created_at desc")
case "created_at_asc":
db = db.Order("created_at asc")
default:
db = db.Order("id desc")
}

ORM 不是安全护身符。

用户可控字段进入查询之前,仍然要经过白名单。


十一、错误要统一翻译

GORM 的错误不应该原样丢给前端。

比如:

1
errors.Is(err, gorm.ErrRecordNotFound)

可以在 service 或统一错误处理里转成业务错误:

1
用户不存在

再由 handler 转成 HTTP 状态码:

1
404

一个简单的分层可以是:

1
2
3
repository 返回 GORM 错误
service 转成业务错误
handler 转成 HTTP 响应

这样前端不会看到数据库细节,日志里也能保留足够的排查信息。


十二、推荐目录结构

一个简单项目可以这样组织:

1
2
3
4
5
cmd/api/main.go
internal/config
internal/database
internal/user
internal/order

业务模块里再分:

1
2
3
4
5
internal/user/handler.go
internal/user/service.go
internal/user/repository.go
internal/user/model.go
internal/user/dto.go

也可以按团队习惯调整。

重点不是目录名,而是职责清楚:

1
2
3
4
5
handler 管 HTTP
service 管业务
repository 管数据库
model 管表结构
dto 管输入输出

只要这个边界守住,Gin + GORM 就不会互相污染。


十三、一个更完整的调用链

最终调用链可以长这样:

1
2
3
4
5
6
7
8
HTTP Request
-> Gin Router
-> Middleware
-> Handler
-> Service
-> Repository
-> GORM
-> Database

返回时:

1
2
3
4
5
6
Database
-> GORM Model
-> Service 业务处理
-> Response DTO
-> Handler JSON
-> HTTP Response

这个结构不复杂。

但它能让每一层都知道自己该干什么。

Gin 不碰数据库细节。

GORM 不进入 HTTP 层。

Service 不依赖 Web 框架。

这就是 Gin + GORM 更舒服的用法。


十四、结语

Gin 和 GORM 都很好用。

但它们只是工具。

Gin 不应该变成业务核心。

GORM 也不应该变成领域模型本身。

更好的写法是:

1
2
3
4
5
Gin 负责接住请求
Service 负责表达业务
GORM 负责访问数据库
DTO 负责隔离输入输出
Context 负责贯穿请求链路

只要这个边界守住,Gin + GORM 可以写得很清楚,也很适合中小型 Go 后端项目。

真正的问题从来不是“能不能用框架”。

而是:

1
你是在使用框架,还是被框架牵着走。

参考资料