Handler-Service-Repository 分层
HTTP 后端将协议、业务与数据访问拆成 Handler / Service / Repository:单向依赖、可测边界,以及 DTO 与领域模型的分界。
[!info] related notes
Handler-Service-Repository 分层
这个概念为什么出现
一个请求天然混合多种关注点:
- HTTP 状态码、Header、JSON
- 业务规则与事务边界
- SQL / 缓存 / 外部 API
全塞进一个 HandleFunc 时:
- 业务测试被绑在协议处理上;虽可用 httptest 直接调用,但难以单独测试用例
- 换存储要改协议层
- 错误映射混乱(DB 错误直接 500 文本)
分层把变化隔离开:协议变、业务稳、存储可换。
[!abstract] 一句话理解 Handler 翻译协议;Service 实现用例;Repository 访问数据。运行时调用沿 Handler → Service → Repository;源码依赖可通过接口倒置,Service 无需 import 具体数据库实现。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:GET /users/{id} 查用户资料,三层拆分
用户中心提供 GET /users/{id}:
| 层 | 本例职责 |
|---|---|
| Handler | 取路径参数、调 service、把错误映射成 404/400/500 |
| Service | 校验 id 非空、编排取数(可扩展权限/缓存) |
| Repository | 内存假数据(可换成 SQL) |
本例把所有类型放在一个 main 包里演示协作;实际拆包时,Service 持有消费方定义的仓储接口,由 main 注入具体实现。需要 Go 1.22+。
package main
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
)
// 领域错误:由 service/repo 返回,handler 映射 HTTP
var errNotFound = errors.New("not found")
var errInvalidID = errors.New("id required")
// User:领域/响应模型(小例合并;大项目可拆 DTO)
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
// UserRepository:仓储端口——service 只依赖接口
type UserRepository interface {
Find(ctx context.Context, id string) (User, error)
}
// UserService:用例层
type UserService struct{ repo UserRepository }
func (s UserService) Get(ctx context.Context, id string) (User, error) {
// 业务校验:不碰 http.ResponseWriter
if id == "" {
return User{}, errInvalidID
}
return s.repo.Find(ctx, id)
}
// memRepo:Repository 实现(演示用内存;生产可 postgres)
type memRepo struct{}
func (memRepo) Find(_ context.Context, id string) (User, error) {
if id != "1" {
return User{}, errNotFound
}
return User{ID: "1", Name: "Ada"}, nil
}
// UserHandler:协议层
type UserHandler struct{ svc UserService }
func (h UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
// Go 1.22+ ServeMux 路径变量
id := r.PathValue("id")
u, err := h.svc.Get(r.Context(), id)
if err != nil {
// 错误在边界映射状态码
switch {
case errors.Is(err, errNotFound):
http.Error(w, "not found", http.StatusNotFound)
case errors.Is(err, errInvalidID):
http.Error(w, "bad request", http.StatusBadRequest)
default:
http.Error(w, "internal error", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(u)
}
func main() {
// main 组装依赖(手动 DI)
h := UserHandler{svc: UserService{repo: memRepo{}}}
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", h.GetUser)
log.Fatal(http.ListenAndServe(":8080", mux))
}
建议运行:
go run .
# 终端 2:
curl -s http://localhost:8080/users/1
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/users/9
期望:
{"id":"1","name":"Ada"}
404
此例只演示只读内存仓储,不需要锁,也没有真实 DB/事务。替换实现时需保持错误、取消和并发合同;不能仅凭接口签名假定不同存储语义等价。
结合场景再看三个关注点
- Handler 不拼 SQL;Service 不写 HTTP 状态码。
- Repository 返回领域错误,边界再映射协议。
- 换存储只换
UserRepository实现,用例与路由可不动。
核心职责
| 层 | 职责 | 不该做 |
|---|---|---|
| Handler | 解码/编码、状态码、鉴权上下文取出、调用 service | 拼 SQL、藏业务规则 |
| Service | 用例、事务、领域校验、编排多仓储 | 依赖 http.ResponseWriter |
| Repository | 持久化细节、扫描行、缓存键 | 写 HTTP 状态码 |
依赖方向
handler → service → repository → driver
上图表示运行时调用顺序。采用依赖倒置时,具体 store 实现满足 app 定义的接口,main 同时依赖双方来组装;app 不必 import store。避免底层 import handler,也避免接口签名暴露 SQL 驱动类型导致反向耦合。
DTO 与领域模型
- 请求/响应 JSON 结构可与 DB 模型分离
- Service 使用领域类型;Handler 做映射
- 避免
jsontag 泄漏进所有层(按项目纪律)
错误映射
- Service 返回领域错误(
ErrNotFound、校验错误) - Handler 按明确错误类型映射 404/400/409,未分类内部失败返回通用 500;不要把 DB 故障误记为客户端 400
- 日志在边界记录,避免每层打同一错误三遍
设计动机
与整洁架构同构但更轻:Go 语言不强制这些层或目录名;项目通过设计和评审维护依赖方向与可测试边界。见 go-project-layout-and-layering。
边界与变体
- 极简服务:两层(handler+store)也可,随复杂度长出 service。
- CQRS / 事件:service 可拆命令查询,不改分层思想。
- 事务:用例决定原子性范围;仓储操作必须共享同一事务,不能各自提交。可用事务回调/端口在存储适配层管理 Tx,避免为简单操作先造通用 UnitOfWork。
- Framework:Gin/Echo 只替换 handler 适配,不把业务写进框架上下文。
常见误区
[!warning] God Handler
上千行ServeHTTP含 SQL。
[!warning] Service 依赖
*gin.Context
业务与框架耦合,难测。
[!warning] 每层都定义巨大接口
接口按使用方最小集合定义。
工程实践
- 包结构示例:
internal/httpapi、internal/app、internal/store - 单测:service + fake repo;handler +
httptest - 集成测:真实 DB 测 repository
- 认证主体由中间件提供;Service 按主体与资源执行授权,不能把「有 userID」等同于有访问权限
- 与 手动 DI 在 main 组装
可验证实验
- 为
UserService.Get写表驱动测试,repo 返回 not found。 - 用
httptest.NewRecorder测 handler 状态码。 - 检查 Service 是否依赖入站 HTTP Writer/Request;调用外部 HTTP 服务可以藏在客户端端口后,不能仅按 import net/http 一概判错。
本节总结
自测题
- 为什么 Repository 不应返回
http status? - 事务应放在哪一层开启?
- Handler 能否直接调 Repository?
答案
- 持久化层不应知道传输协议。
- 通常在 Service(用例原子性)。
- 小项目可以,但绕过业务层易导致规则分散;有规则时走 Service。
延伸阅读
笔记元信息
- 文件:
go-http-handler-service-repository.md - 状态:已深化