第 5 章 · Repository 与分层架构
本章目标:实现 Handler → Service → Repository 三层架构;ProductRepository 完整 CRUD;Service 承载业务规则;Handler 仅处理 HTTP;将 ch02/ch04 代码接入数据库;对照 fastapi-web ch05/ch06 services/repositories 与 flask-web ch05 模型访问分层。
学时建议:4~5 小时(含 2.5 小时跟练)
前置:完成 gin-web ch04。
5.1 场景说明:分层职责
随着商品 CRUD 逻辑增长,全部写在 handler 会导致难以测试与复用。api-go-demo 采用与 svc-demo 类似的分层:
HTTP Request
▼
┌─────────┐ bind/DTO ┌─────────┐ 业务规则 ┌──────────────┐
│ Handler │ ───────────► │ Service │ ───────────► │ Repository │
└─────────┘ └─────────┘ └──────┬───────┘
▲ │ │
└──── JSON 响应 ─────────┘ ▼
GORM / MySQL
| 层 | 职责 | 禁止 |
|---|---|---|
| Handler | 参数绑定、HTTP 状态码、调用 Service | 直接 db.Where |
| Service | 校验、权限边界、领域错误 | 依赖 gin.Context |
| Repository | CRUD、查询组装、传递 context.Context | 返回 HTTP 响应 |
对照章节:
| 主题 | fastapi-web ch06 | flask-web | gin-web ch05 |
|---|---|---|---|
| 路由层 | router + deps | Blueprint view | handler |
| 业务层 | service | 部分在 view | service |
| 数据层 | repository | model.query | repository |
5.2 逐步操作表
| 步骤 | 操作 | 验证 |
|---|---|---|
| 1 | 创建 internal/repository/product.go | CRUD 方法编译 |
| 2 | 创建 internal/service/product.go | 领域 error 定义 |
| 3 | 创建 internal/dto/product.go | 响应 DTO |
| 4 | 重构 handler/product.go 注入 Service | 去除 mock |
| 5 | 更新 router 与 main wiring | 启动成功 |
| 6 | GET 已发布 slug | 200 + DB 数据 |
| 7 | GET 未发布 slug(匿名) | 404 |
| 8 | POST 重复 slug | 409 CONFLICT |
5.3 目录结构
internal/
├── dto/product.go
├── handler/product.go
├── service/product.go
└── repository/product.go
5.4 ProductRepository 完整 CRUD
// internal/repository/product.go
package repository
import (
"context"
"example.com/api-go-demo/internal/model"
"gorm.io/gorm"
)
type ProductRepository struct {
db *gorm.DB
}
func NewProductRepository(db *gorm.DB) *ProductRepository {
return &ProductRepository{db: db}
}
func (r *ProductRepository) ListPublished(ctx context.Context, page, size int) ([]model.Product, int64, error) {
var items []model.Product
var total int64
q := r.db.WithContext(ctx).Model(&model.Product{}).Where("is_published = ?", true)
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
if page < 1 {
page = 1
}
if size < 1 {
size = 20
}
offset := (page - 1) * size
err := q.Order("id desc").Offset(offset).Limit(size).Find(&items).Error
return items, total, err
}
func (r *ProductRepository) GetBySlug(ctx context.Context, slug string, publishedOnly bool) (*model.Product, error) {
var p model.Product
q := r.db.WithContext(ctx).Where("slug = ?", slug)
if publishedOnly {
q = q.Where("is_published = ?", true)
}
err := q.First(&p).Error
if err != nil {
return nil, err
}
return &p, nil
}
func (r *ProductRepository) Create(ctx context.Context, p *model.Product) error {
return r.db.WithContext(ctx).Create(p).Error
}
func (r *ProductRepository) UpdateBySlug(ctx context.Context, slug string, updates map[string]any) error {
res := r.db.WithContext(ctx).Model(&model.Product{}).Where("slug = ?", slug).Updates(updates)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (r *ProductRepository) DeleteBySlug(ctx context.Context, slug string) error {
res := r.db.WithContext(ctx).Where("slug = ?", slug).Delete(&model.Product{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
| 方法 | 说明 |
|---|---|
ListPublished | 分页 + 仅 is_published=true |
GetBySlug | publishedOnly 控制匿名是否可见草稿 |
UpdateBySlug | map 更新,0 行 → NotFound |
DeleteBySlug | 硬删(选修软删:DeletedAt) |
5.5 ProductService
// internal/service/product.go
package service
import (
"context"
"errors"
"example.com/api-go-demo/internal/model"
"example.com/api-go-demo/internal/repository"
"gorm.io/gorm"
)
var (
ErrProductNotFound = errors.New("product not found")
ErrDuplicateSlug = errors.New("duplicate slug")
ErrInvalidPrice = errors.New("invalid price")
)
type ProductService struct {
repo *repository.ProductRepository
}
func NewProductService(repo *repository.ProductRepository) *ProductService {
return &ProductService{repo: repo}
}
func (s *ProductService) ListPublished(ctx context.Context, page, size int) ([]model.Product, int64, error) {
return s.repo.ListPublished(ctx, page, size)
}
func (s *ProductService) GetPublished(ctx context.Context, slug string) (*model.Product, error) {
p, err := s.repo.GetBySlug(ctx, slug, true)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrProductNotFound
}
return p, err
}
func (s *ProductService) GetBySlugAdmin(ctx context.Context, slug string) (*model.Product, error) {
p, err := s.repo.GetBySlug(ctx, slug, false)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrProductNotFound
}
return p, err
}
func (s *ProductService) Create(ctx context.Context, p *model.Product) error {
if p.Price < 0 {
return ErrInvalidPrice
}
_, err := s.repo.GetBySlug(ctx, p.Slug, false)
if err == nil {
return ErrDuplicateSlug
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return s.repo.Create(ctx, p)
}
func (s *ProductService) Update(ctx context.Context, slug string, updates map[string]any) error {
if price, ok := updates["price"].(int64); ok && price < 0 {
return ErrInvalidPrice
}
err := s.repo.UpdateBySlug(ctx, slug, updates)
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrProductNotFound
}
return err
}
func (s *ProductService) Delete(ctx context.Context, slug string) error {
err := s.repo.DeleteBySlug(ctx, slug)
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrProductNotFound
}
return err
}