很多人刚开始写 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 项目的工程结构了。