Gin 项目为什么要分 Handler、Service、Repository?顺便讲透接口、依赖注入与指针

很多人刚开始写 Gin 时,一个接口通常是这样的:

go 复制代码
func CreateUser(c *gin.Context) {
	// 获取参数

	// 判断用户是否存在

	// 密码加密

	// 操作数据库

	// 返回 JSON
}

项目小时完全没问题。

但是随着业务增加,一个 Handler 很快可能变成:

复制代码
参数绑定
参数验证
数据库查询
业务判断
Redis
JWT
密码加密
日志
消息通知
第三方接口

最终几百行全部堆在一个函数里。

所以真实项目通常会逐渐形成:

复制代码
Router
 ↓
Handler
 ↓
Service
 ↓
Repository
 ↓
Database

这篇文章就来讲清楚为什么要这么拆,以及 Go 中常见的 interface + struct + pointer + 依赖注入 到底是什么关系。


一、Router 负责什么

Router 负责:

什么 HTTP 请求应该交给哪个 Handler。

例如:

go 复制代码
users := api.Group("/users")

{
	users.POST("", userHandler.CreateUser)
	users.GET("", userHandler.ListUsers)
	users.GET("/:id", userHandler.GetUser)
	users.PUT("/:id", userHandler.UpdateUser)
	users.DELETE("/:id", userHandler.DeleteUser)
}

它主要描述的是:

bash 复制代码
POST   /users
GET    /users
GET    /users/:id
PUT    /users/:id
DELETE /users/:id

Router 不应该负责:

sql 复制代码
SQL
密码加密
Redis
业务规则

二、Handler 负责什么

Handler 是:

HTTP 世界和业务世界之间的适配层。

它主要处理:

vbscript 复制代码
HTTP Request
    ↓
参数解析
    ↓
参数校验
    ↓
调用 Service
    ↓
Service 返回结果
    ↓
HTTP Response

例如:

go 复制代码
func (h *UserHandler) GetUser(c *gin.Context) {
	id, err := strconv.ParseUint(
		c.Param("id"),
		10,
		64,
	)

	if err != nil {
		response.Error(
			c,
			http.StatusBadRequest,
			"invalid user id",
		)
		return
	}

	user, err := h.service.GetUser(uint(id))

	if err != nil {
		response.Error(
			c,
			http.StatusNotFound,
			"user not found",
		)
		return
	}

	response.Success(c, user)
}

Handler 应该知道:

vbscript 复制代码
gin.Context
HTTP 状态码
Request DTO
Response DTO

但尽量不要知道:

scss 复制代码
db.Where(...)
db.Create(...)

三、Service 负责业务规则

Service 负责的是:

业务应该怎么执行。

例如创建用户:

复制代码
检查邮箱是否存在
 ↓
密码哈希
 ↓
设置默认角色
 ↓
创建用户

这就属于 Service。

例如:

go 复制代码
func (s *UserService) CreateUser(
	req dto.CreateUserRequest,
) (*model.User, error) {

	_, err := s.repo.FindByEmail(req.Email)

	if err == nil {
		return nil, errors.New("email already exists")
	}

	// 其他业务逻辑

	return user, nil
}

Service 不应该关心:

复制代码
HTTP 200
HTTP 400
Gin Context

因为业务逻辑不一定永远通过 HTTP 调用。

以后:

objectivec 复制代码
HTTP Handler
Cron Job
消息队列
CLI
RPC

都可能复用:

scss 复制代码
userService.CreateUser(...)

四、Repository 负责数据库访问

Repository 的职责是:

把 Service 的数据需求转换成数据库操作。

例如:

go 复制代码
type UserRepository interface {
	Create(user *model.User) error
	FindByID(id uint) (*model.User, error)
	FindByEmail(email string) (*model.User, error)
	List() ([]model.User, error)
	Update(user *model.User) error
	Delete(id uint) error
}

GORM 实现:

go 复制代码
type GormUserRepository struct {
	db *gorm.DB
}

例如:

go 复制代码
func (r *GormUserRepository) FindByEmail(
	email string,
) (*model.User, error) {

	var user model.User

	err := r.db.
		Where("email = ?", email).
		First(&user).
		Error

	if err != nil {
		return nil, err
	}

	return &user, nil
}

这里才应该出现:

sql 复制代码
Where
First
Create
Save
Delete

五、为什么 Service 不直接依赖 GormUserRepository

错误写法:

go 复制代码
type UserService struct {
	repo *GormUserRepository
}

问题是 Service 被 GORM 实现绑死了。

如果以后想换:

复制代码
MySQL Repository
PostgreSQL Repository
Mock Repository
Memory Repository

Service 也要跟着改。

所以通常依赖接口:

go 复制代码
type UserService struct {
	repo repository.UserRepository
}

这里:

go 复制代码
repository.UserRepository

只是规定:

复制代码
你必须拥有这些方法

但不关心:

复制代码
方法内部到底使用 GORM
还是内存
还是 Mock

这就是接口解耦。


六、Go 为什么不用 implements

例如:

go 复制代码
type UserRepository interface {
	FindByID(id uint) (*model.User, error)
}

然后:

go 复制代码
type GormUserRepository struct {
	db *gorm.DB
}

只要它实现:

go 复制代码
func (r *GormUserRepository) FindByID(
	id uint,
) (*model.User, error) {
	...
}

Go 就会自动认为:

yaml 复制代码
*GormUserRepository
实现了
UserRepository

不需要:

java 复制代码
implements UserRepository

Go 使用的是隐式接口实现。

这也是 Go 很重要的设计哲学:

类型不需要主动声明自己属于某个接口,只需要满足接口要求。


七、什么叫依赖注入

假设 Service 需要 Repository:

go 复制代码
type UserService struct {
	repo repository.UserRepository
}

如果 Service 自己创建:

go 复制代码
func NewUserService() *UserService {
	repo := repository.NewUserRepository()

	return &UserService{
		repo: repo,
	}
}

这叫:

复制代码
自己创建依赖

更常见的做法:

go 复制代码
func NewUserService(
	repo repository.UserRepository,
) *UserService {

	return &UserService{
		repo: repo,
	}
}

然后外部:

go 复制代码
repo := repository.NewUserRepository(db)

userService := service.NewUserService(repo)

这就是:

Dependency Injection,依赖注入。

翻译得非常直白:

复制代码
Dependency
依赖

Injection
注入

也就是把依赖从外面传进去。


八、为什么 main.go 经常像"组装工厂"

真实项目中的 main.go 经常会出现:

go 复制代码
db := config.InitDB()

userRepo := repository.NewUserRepository(db)

userService := service.NewUserService(userRepo)

userHandler := handler.NewUserHandler(userService)

r := router.SetupRouter(userHandler)

可以画成:

复制代码
Database
   ↓
Repository
   ↓
Service
   ↓
Handler
   ↓
Router

main.go 本身不处理业务。

它主要负责:

创建对象并把依赖关系组装起来。

这也叫 Composition Root。


九、接口和指针不要混淆

初学时很容易看到:

go 复制代码
func (s *AuthService) Login(
	req dto.LoginRequest,
) (*model.User, error)

然后疑惑:

为什么这里到处都是 *,Service 依赖 Repository 时不是传接口吗?

其实这是完全不同的问题。

interface 解决的是

复制代码
依赖谁

例如:

go 复制代码
type AuthService struct {
	userRepo repository.UserRepository
}

表示:

复制代码
AuthService
只依赖 UserRepository 抽象

指针解决的是

复制代码
怎么使用这个具体对象

例如:

go 复制代码
func (s *AuthService) Login(...)

这里:

yaml 复制代码
s *AuthService

表示 Login 是 AuthService 指针接收者的方法。

而:

go 复制代码
(*model.User, error)

表示:

go 复制代码
成功 → 返回 User 指针
失败 → 可以返回 nil

所以记住一句:

接口解决"依赖谁",指针解决"如何传递和操作对象"。


十、为什么 Repository 经常返回 *model.User

例如:

go 复制代码
func FindByID(id uint) (*model.User, error)

为什么不直接:

go 复制代码
func FindByID(id uint) (model.User, error)

因为查不到时,指针可以:

go 复制代码
return nil, err

语义非常明确:

sql 复制代码
没有 User

如果返回值类型是:

go 复制代码
model.User

只能:

go 复制代码
return model.User{}, err

得到:

go 复制代码
User{
	ID: 0,
	Name: "",
}

这其实是一个合法的零值 struct,只不过内容为空。

因此:

复制代码
*model.User

可以更加清楚地表达:

复制代码
存在
或者
不存在

当然,指针还能避免较大 struct 的复制,但对普通 User 来说,这通常不是最重要的理由。


十一、为什么 Handler 通常也持有 *Service

例如:

go 复制代码
type UserHandler struct {
	service *service.UserService
}

Handler 只需要调用:

scss 复制代码
h.service.GetUser(...)
h.service.CreateUser(...)

为什么还需要指针?

因为 UserService 是一个已经创建好的 Service 实例,里面还保存着 Repository 依赖:

go 复制代码
type UserService struct {
	repo repository.UserRepository
}

我们希望整个应用复用这个 Service,而不是每调用一个 Handler 方法就复制一份。


十二、最终结构

一个请求:

bash 复制代码
GET /api/users/1

最终运行:

markdown 复制代码
Gin Router
    ↓
UserHandler.GetUser
    ↓
UserService.GetUser
    ↓
UserRepository.FindByID
    ↓
GormUserRepository
    ↓
GORM
    ↓
MySQL

而创建阶段:

css 复制代码
main.go

创建 db
 ↓
创建 repository
 ↓
注入 service
 ↓
注入 handler
 ↓
注册 router

理解这两条链非常重要。

一个是:

复制代码
依赖创建链

一个是:

复制代码
请求调用链

很多 Go 工程代码看似复杂,其实本质就是围绕这两条链展开。


十三、面试总结

如果面试官问:

为什么 Gin 项目要分 Handler、Service、Repository?

可以回答:

Handler 负责 HTTP 协议相关逻辑,例如参数解析和响应;Service 负责核心业务规则;Repository 负责数据访问,从而实现职责分离。Service 通常依赖 Repository 接口而不是具体数据库实现,这样可以降低耦合,也方便测试和替换数据源。各层依赖通常通过构造函数注入,在 main 中统一完成对象组装。

如果能把这套逻辑真正讲清楚,你就已经不只是"会写 Gin CRUD",而是开始理解 Go Web 项目的工程结构了。

相关推荐
风中的小熊生气1 小时前
Spring Boot 面试知识(一):从启动流程到 AOP 与事务失效
spring boot·后端·面试
程序员cxuan1 小时前
DeepSeek V4.1 Flash 正式发布!
人工智能·后端·程序员
特立独行的猫A1 小时前
AtomMQTT Broker — 用 Rust 实现的轻量级高性能 MQTT 消息代理
前端·后端·架构
the局外人1 小时前
学习 FastAPI 的 Day 3:企业级目录与数据库迁移
后端·python·fastapi
mldong1 小时前
AI Agent 不能自己签字:用 Python 工作流引擎给 AI 加一道人类审批闸门
后端·python·agent
她的男孩1 小时前
多租户隔离怎么落地?拆完这1600行Starter源码,我把5个坑全踩明白了
java·后端·架构
宫水三叶的刷题日记1 小时前
铁打的影视飓风,流水的新 iPhone
后端
Bs_MoneyMagnet1 小时前
基于springboot+vue的医院陪诊服务预约平台的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring
SimonKing1 小时前
一个Docker命令,40万首古诗词API开箱即用
java·后端·程序员