Go 博客教程 · 第 4 课 / 10
第 4 课:分层架构与文章 CRUD
第 2 课的 handler 直接操作一个全局 store []PostDTO,第 3 课的 repository 已经能跟 MySQL 说话。这一课把它们接起来——但不是直接接。
① 本课目标
学完能独立写出:一套 model / repository / service / handler 四层结构,层与层之间用接口解耦、用构造函数注入串起来。并能解释:
- Go 的接口为什么是隐式实现,这跟 Java/PHP 的
implements差在哪 - 为什么
PostRepository接口要写在service包而不是repository包 - 依赖为什么从
main一路传下去,而不是用全局变量 - PATCH 部分更新为什么必须用指针字段
- 领域错误怎么在 handler 一处集中映射成 HTTP 状态码
② 前置检查
cd ~/go-blog
go run ./cmd/dbtest # 第 3 课的 repository 还能读写数据库
mysql -uroot -p123456 blog_dev -e "select id,title,status from posts;"
③ 核心概念
3.1 先看不分层长什么样
把第 3 课的 SQL 直接搬进 handler。它能跑,而且更短:
// ❌ 反面教材:internal/handler/post_handler.go
func handleGetPost(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
var p model.Post
var summary sql.NullString
err := db.QueryRowContext(r.Context(), // db 是包级全局变量
`SELECT id, title, summary, status FROM posts WHERE id = ?`, id).
Scan(&p.ID, &p.Title, &summary, &p.Status)
if errors.Is(err, sql.ErrNoRows) {
writeError(w, http.StatusNotFound, "NOT_FOUND", "文章不存在"); return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "INTERNAL", err.Error()); return
}
if p.Status != model.StatusPublished { // ← 业务规则混在这里
writeError(w, http.StatusForbidden, "FORBIDDEN", "未发布"); return
}
p.Summary = summary.String
writeJSON(w, http.StatusOK, p)
}
三个具体问题:
- 不能测。要测「草稿不能被非作者看到」这条规则,你得先起一个 HTTP server、再连一个真 MySQL、再往里塞一行草稿数据。一条业务规则的单测变成了集成测试,跑一次几百毫秒,CI 上还得起数据库容器。
- 改 DB 就改 HTTP。哪天
summary换成从另一张表关联出来,你要改的是这个 HTTP handler 函数。数据库的变化不该传染到协议层。 - 业务规则散落。「未发布的不给看」这条规则,在
GET /posts/{id}里写了一遍,GET /posts列表里得再写一遍,将来 RSS 输出、站内搜索、管理后台各写一遍。七个地方,六个人改,迟早有一个漏。
3.2 四层各自的唯一职责
| 层 | 唯一职责 | 禁止事项 | 认识谁 |
|---|---|---|---|
model |
定义领域概念(Post、Status、领域错误) |
不许 import database/sql、net/http |
谁都不认识 |
repository |
把领域对象存取到数据库 | 不许含业务规则(不许判"草稿能不能看") | model + database/sql |
service |
执行业务规则,编排多个 repository | 不许知道 HTTP 存在(没有 w、r、状态码) |
model + repository 接口 |
handler |
HTTP ↔ service 的翻译 | 不许含业务规则,不许写 SQL | model + service |
判断某段代码该放哪,用一个问题就够:「如果这个系统改成 gRPC,这段代码要不要改?」 要改 → handler;不用改 → service 或更下面。
3.3 依赖方向:箭头只能单向
cmd/server/main.go ← 唯一知道"具体用哪个实现"的地方,在这里构造并串起所有对象
│ 构造并注入
▼
handler.PostHandler ← HTTP 翻译层,持有 *service.PostService
│ 调用方法
▼
service.PostService ──依赖──▶ service.PostRepository (interface)
│ ▲
│ │ 隐式实现(repository 完全不知道 service 存在)
│ repository.PostRepo ──▶ MySQL
▼ │
┌────────────────────────────────────────┴─────────────────┐
│ model (Post / Status / PostFilter / ErrNotFound ...) │ ← 最底层,谁都 import 它
└──────────────────────────────────────────────────────────┘
三条铁律:
- 箭头只能向下。
repository里出现import "blog/internal/service"就是错的(Go 编译器还会直接拒绝循环 import)。 model是最底层,谁都能 import 它,它谁都不 import(除标准库)。service依赖的是接口不是具体类型,所以那条从repository上来的箭头是「实现」而不是「依赖」——方向仍然向下。
3.4 Go 的接口是隐式实现
如果你写过 Java 或 PHP,接口是这样声明的:
// Java
class PostRepo implements PostRepository { ... } // 必须显式写 implements
Go 没有 implements 关键字。 只要一个类型拥有接口要求的全部方法(名字、参数、返回值完全一致),它就自动满足这个接口:
// service 包里声明需求
type PostRepository interface {
GetByID(ctx context.Context, id int64) (*model.Post, error)
}
// repository 包里的 *PostRepo 有这个方法签名 → 它自动满足 PostRepository
// repository 包**完全不知道** service 包的存在,一行都没提到它
这个差别为什么重要? 因为它把"谁定义接口"的主动权,从实现方交给了消费方。Java 里 PostRepo 必须先知道有个 PostRepository 接口才能 implements 它;Go 里 PostRepo 只管把方法写好,任何人都可以事后定义一个接口来"框住"它——包括你的测试代码,包括第三方。
副作用:你可能不小心满足了某个接口而不自知。所以要防御性地加一行编译期断言(见 4.2)。
3.5 接口定义在消费方
Go 社区的一句口头禅:"Accept interfaces, return structs."
定义在实现方(repository 包) |
定义在消费方(service 包) |
|
|---|---|---|
| 谁决定接口有哪些方法 | 实现者猜"别人可能要什么" | 使用者说"我需要什么" |
| 加一个 repository 方法 | 接口要跟着改,所有实现都得补 | 接口不用动 |
| 测试时替换 | 得 import repository 包才能拿到接口 | service 包自给自足 |
| 依赖方向 | service → repository(具体包) | service 不 import repository |
关键在最后一行:接口定义在 service 包里,service 就完全不需要 import repository。整个 service 包可以在没有数据库、没有驱动、甚至 repository 包还没写出来的情况下编译和测试。
另一条原则:接口要小。 一到三个方法最好。我们的 PostRepository 有 6 个方法——因为它就是一个完整 CRUD 的消费需求。"接口要小"的真正含义不是"方法数少",而是「按消费方实际需要的最小集合来切」。如果某个函数只需要读一篇文章,就为它单独切一个:
// 只需要读的地方,声明一个 1 方法接口就够了
type postFinder interface {
GetByID(ctx context.Context, id int64) (*model.Post, error)
}
func renderRSS(ctx context.Context, f postFinder, ids []int64) { ... }
// *PostRepo 和 *fakeRepo 都自动满足它,不用改任何代码
3.6 构造函数注入,不用全局变量
// internal/service/post_service.go —— 片段
func NewPostService(repo PostRepository) *PostService {
return &PostService{repo: repo}
}
依赖从参数进来,不是从包级变量拿。对照一下全局变量版本:
全局变量 var db *sql.DB |
构造函数注入 | |
|---|---|---|
| 测试时换成假实现 | 得改全局变量,测试之间互相污染,不能并行 | 每个测试传自己的假对象 |
| 看一个类型需要什么 | 得通读它的所有方法找全局引用 | 看构造函数签名就知道 |
| 初始化顺序 | 靠 init() 和包加载顺序,出错难查 |
main 里从上到下,一目了然 |
| 同时连两个库 | 做不到 | 构造两个实例就行 |
3.7 DTO 与领域模型分离
model.Post 有 AuthorID 和 DeletedAt。直接把它 JSON 返回:
{"ID":10,"AuthorID":7,"DeletedAt":null,"Content":"...","Status":1}
四个问题:① 泄露内部字段(DeletedAt 让攻击者知道你在做软删除);② 字段名是 Go 的大驼峰,不是 API 惯用的 snake_case;③ Status 是裸数字 1,客户端得自己查表;④ 改字段名 = 破坏 API,领域模型和对外契约被焊死。
所以要有独立的 DTO:
| 类型 | 方向 | 定义在 | 为什么 |
|---|---|---|---|
service.CreatePostRequest |
入 | service |
service 要校验它,且非 HTTP 的调用方(CLI、定时任务)也用得上 |
service.UpdatePostRequest |
入 | service |
同上,且字段是指针(见 4.5) |
handler.PostResponse |
出 | handler |
纯粹是 HTTP 表现层的事,service 不该知道 |
3.8 错误分层
第 3 课的 ErrNotFound 住在 repository 包。现在 handler 需要 errors.Is(err, ErrNotFound) 来决定返回 404——如果错误还在 repository 包,handler 就得 import "blog/internal/repository",依赖箭头就跨过 service 直插底层,3.3 节那张图就废了。
所以本课把它们搬到 model 包(handler 本来就 import model):
// internal/model/errors.go
package model
import "errors"
// 领域错误:跨层传递的"业务事实",不带任何 SQL 或 HTTP 味道。
var (
ErrNotFound = errors.New("资源不存在")
ErrDuplicate = errors.New("资源已存在")
ErrForbidden = errors.New("无权访问该资源")
ErrInvalidArg = errors.New("参数不合法")
ErrConflict = errors.New("状态冲突")
)
三层的分工:
| 层 | 做什么 | 例子 |
|---|---|---|
| repository | 把技术错误翻译成领域错误 | sql.ErrNoRows → model.ErrNotFound;MySQL 1062 → model.ErrDuplicate |
| service | 产生业务领域错误,或用 %w 透传下层的 |
「标题为空」→ model.ErrInvalidArg;「草稿不可见」→ model.ErrForbidden |
| handler | 把领域错误映射成 HTTP 状态码 | errors.Is(err, model.ErrNotFound) → 404 |
同时把 repository/errors.go 删掉,post_repo.go 里所有 ErrNotFound 改成 model.ErrNotFound。
④ 函数逐个精讲
4.1 PostRepository 接口 + NewPostService —— 接口设计与注入
// internal/service/post_service.go
package service
import (
"context"
"errors"
"fmt"
"strings"
"time"
"blog/internal/model"
// 注意:**没有** import "blog/internal/repository"。
// 整个 service 包不知道 PostRepo 这个类型存在,也不知道有 MySQL。
)
// PostRepository 声明 service 需要仓储做的事。
// 关键:接口定义在**消费方**(service),不在实现方(repository)。
type PostRepository interface {
Create(ctx context.Context, p *model.Post) error
GetByID(ctx context.Context, id int64) (*model.Post, error)
List(ctx context.Context, f model.PostFilter) ([]model.Post, error)
Update(ctx context.Context, p *model.Post) error
SoftDelete(ctx context.Context, id int64) error
ExistsBySlug(ctx context.Context, slug string) (bool, error)
}
逐项说明为什么是这几个方法、参数为什么这么定:
- 每个方法第一个参数都是
ctx—— 超时和取消要能一路传到数据库(第 3 课 3.10)。接口里定死这一点,任何实现都逃不掉。 Create收*model.Post返回error,不是返回(int64, error)—— 数据库会生成一批值(ID、时间戳),直接填回调用方手里的结构体,比返回一堆零散值干净。-
List收model.PostFilter而不是一串(status, limit, offset)—— 这是关键设计。PostFilter定义在model包:// internal/model/post.go // PostFilter 是列表查询的过滤条件。 // 放在 model 包而不是 service 或 repository 包:这样 service 定义接口、 // repository 实现接口时,两边都只依赖 model,不会互相 import。 type PostFilter struct { Status *Status // nil = 不过滤状态;指针才能表达"这个条件不存在" Limit int Offset int }如果
PostFilter定义在service包,repository就必须import "blog/internal/service"才能写出这个方法签名——依赖箭头当场反转。 把跨层传递的数据结构放在最底层的model包,是维持单向依赖的关键手法。另一个好处:以后加「按作者过滤」「按标签过滤」,只加
PostFilter的字段,接口签名一个字都不用改。 -
ExistsBySlug而不是GetBySlug—— service 只想知道"占没占用",不需要整行数据。接口方法应该表达意图,不是数据库操作。实现上是SELECT EXISTS(...),只回一个 bit。 - 没有
CreateWithTags—— 那是第 8 课标签功能的事。接口只放当前消费方真的会调的方法。
配套的构造函数:
// internal/service/post_service.go
type PostService struct {
repo PostRepository // ← 字段类型是**接口**,不是 *repository.PostRepo
now func() time.Time // 注入时钟:第 10 课测试里能冻结时间
}
// NewPostService 是唯一构造 PostService 的方式。
//
// 签名 (repo PostRepository) *PostService 就是 "accept interfaces, return structs":
// - 参数收**接口** → 调用方爱传什么传什么(真 repo、假 repo、带缓存的装饰器)
// - 返回**具体结构体指针**而不是接口 → 调用方能直接用所有方法,
// 也能在自己那边按需定义更窄的接口来框住它。过早返回接口只会限制调用方。
func NewPostService(repo PostRepository) *PostService {
// Truncate(time.Second):MySQL 的 DATETIME 不带小数秒,写入时会"四舍五入"到秒
//(第 3 课 3.5 实测过)。不截断的话,内存里的 PublishedAt 会和库里的值差 1 秒,
// 「发布幂等」的断言就会莫名其妙失败。
return &PostService{
repo: repo,
now: func() time.Time { return time.Now().Truncate(time.Second) },
}
}
配一行编译期断言,防止 PostRepo 哪天改了签名却没人发现:
// cmd/server/main.go(或任何一个同时 import 两个包的文件)
// 这行不产生任何运行时代码,纯粹让编译器帮你检查"实现是否还满足接口"。
// (*repository.PostRepo)(nil) 是一个类型正确的 nil 指针,不会真的调用方法。
var _ service.PostRepository = (*repository.PostRepo)(nil)
我故意把 ExistsBySlug 的返回值改成三个,编译器立刻报(这就是这一行的价值):
cannot use (*repository.PostRepo)(nil) (value of type *repository.PostRepo)
as service.PostRepository value in variable declaration:
*repository.PostRepo does not implement service.PostRepository (wrong type for method ExistsBySlug)
have ExistsBySlug(context.Context, string) (bool, string, error)
want ExistsBySlug(context.Context, string) (bool, error)
没有这一行的话,你会在 main.go 里 NewPostService(postRepo) 那一行才发现,报错信息还没这么清楚。
4.3 Create —— 校验 + slug 生成 + 冲突处理
// internal/service/post_service.go
// CreatePostRequest 是创建文章的入参。定义在 service 而不是 handler:
// 校验规则属于业务,CLI 工具、定时任务也可能调 svc.Create。
type CreatePostRequest struct {
Title string `json:"title"`
Summary string `json:"summary"`
Content string `json:"content"`
Publish bool `json:"publish"` // true = 直接发布,false = 存草稿
}
// Create 创建一篇文章。
// 返回 (*model.Post, error) 而不是 (int64, error):调用方通常要立刻把整个对象返回给客户端。
func (s *PostService) Create(ctx context.Context, req CreatePostRequest) (*model.Post, error) {
title := strings.TrimSpace(req.Title)
content := strings.TrimSpace(req.Content)
// ---- 业务规则 1:必须有标题和正文 ----
// 为什么在 service 不在 handler:换成 gRPC 接口这条规则一个字都不用改。
// 为什么不在 repository:repository 的职责是"存取"不是"判断合不合法"。
// 用 %w 包装 model.ErrInvalidArg:handler 一句 errors.Is 就能映射成 400,
// 同时错误文本保留了"哪个字段为什么不合法"的上下文。
if title == "" {
return nil, fmt.Errorf("标题不能为空: %w", model.ErrInvalidArg)
}
if content == "" {
return nil, fmt.Errorf("正文不能为空: %w", model.ErrInvalidArg)
}
// len([]rune(...)) 数字符数,对应 VARCHAR(200) 的语义;
// 直接 len(title) 数字节数,中文标题会被误判(一个汉字 3 字节)。
if len([]rune(title)) > 200 {
return nil, fmt.Errorf("标题长度 %d 超过 200: %w", len([]rune(title)), model.ErrInvalidArg)
}
p := &model.Post{
Title: title,
Summary: strings.TrimSpace(req.Summary),
Content: content,
Status: model.StatusDraft, // 默认草稿
}
if req.Publish {
p.Status = model.StatusPublished
t := s.now()
p.PublishedAt = &t
}
// ---- 业务规则 2:slug 冲突自动加后缀 ----
base := p.Slugify() // 第 1 课写的方法
if base == "" { // 纯符号标题(如 "???")会 slug 成空串,得兜底
base = fmt.Sprintf("post-%d", s.now().UnixNano())
}
slug, err := s.uniqueSlug(ctx, base)
if err != nil {
return nil, err
}
p.Slug = slug
// 这里仍可能拿到 model.ErrDuplicate —— 即使上面查过一遍还是可能撞:
// "查"和"插"之间有时间窗,别的请求可能插了同一个 slug(TOCTOU,第 3 课 3.2)。
// 唯一索引是最后一道防线,这个错误必须原样往上传,别吞掉。
if err := s.repo.Create(ctx, p); err != nil {
return nil, fmt.Errorf("创建文章: %w", err)
}
return p, nil
}
// uniqueSlug 找一个没被占用的 slug:hello → hello-2 → hello-3 ...
func (s *PostService) uniqueSlug(ctx context.Context, base string) (string, error) {
const maxAttempts = 5 // 有上限:不能因为 slug 冲突把数据库查爆
for i := 0; i < maxAttempts; i++ {
candidate := base
if i > 0 {
candidate = fmt.Sprintf("%s-%d", base, i+1)
}
exists, err := s.repo.ExistsBySlug(ctx, candidate)
if err != nil {
return "", fmt.Errorf("检查 slug 唯一性: %w", err)
}
if !exists {
return candidate, nil
}
}
// 试满 5 次还冲突就用时间戳保底 —— 宁可 slug 难看,也不能创建失败。
return fmt.Sprintf("%s-%d", base, s.now().UnixNano()), nil
}
4.4 Publish —— 状态机 + 时间戳 + 幂等
// internal/service/post_service.go
// Publish 把一篇文章置为已发布。
//
// 返回 (*model.Post, error):客户端发布后通常要立刻拿到新状态和发布时间。
func (s *PostService) Publish(ctx context.Context, id int64) (*model.Post, error) {
// 直接透传:err 已经是 model.ErrNotFound 的包装(repository 翻译好的),
// 再包一层 fmt.Errorf 只会让文本变长,信息量不增加。
p, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
// ---- 幂等:已发布就原样返回,不刷新 published_at,也不算错误 ----
// 客户端网络重试、用户连点两下按钮,都会重复调这个接口。第二次报 409,
// 用户体验是"我明明发布成功了却报错";第二次刷新 published_at,
// 文章会在列表里莫名其妙跳到最前面。两个都不能接受。
if p.Status == model.StatusPublished {
return p, nil
}
// ---- 状态机:只有草稿和已下线能进入已发布 ----
// 显式列出**允许**的来源状态,而不是排除不允许的。将来加了 StatusBanned(封禁),
// 这段代码不用改就自动拒绝它 —— 白名单优于黑名单。
if p.Status != model.StatusDraft && p.Status != model.StatusOffline {
return nil, fmt.Errorf("文章 id=%d 当前状态 %d 不能发布: %w", id, p.Status, model.ErrConflict)
}
// ---- 业务规则:发布前必须有标题和正文 ----
// 和 Create 的校验重复?不算 —— 文章可能先存草稿(只填标题)再发布,中间还可能被
// Update 改空。**发布是一个独立的关口,必须自己把关。**
if strings.TrimSpace(p.Title) == "" || strings.TrimSpace(p.Content) == "" {
return nil, fmt.Errorf("文章 id=%d 缺少标题或正文: %w", id, model.ErrInvalidArg)
}
p.Status = model.StatusPublished
// 只在从没发布过时才写时间戳。「下线 → 重新上线」保留首次发布时间,
// 这是内容平台的通行做法(否则老文章下线再上线会跑到首页最前面)。
if p.PublishedAt == nil {
t := s.now()
p.PublishedAt = &t
}
if err := s.repo.Update(ctx, p); err != nil {
return nil, fmt.Errorf("发布文章 id=%d: %w", id, err)
}
return p, nil
}
这个函数里有四条业务规则(幂等、状态机、内容完整性、时间戳保留),它们一条都不该出现在 handler 里——因为它们跟"这是个 HTTP 请求"毫无关系。
顺带看「草稿不能被非作者看到」这条规则:
// GetByID 读一篇文章并执行可见性规则。
// viewerID 用 *int64 而不是 int64:nil 表示"匿名访问者"。用零值 0 表示匿名,
// 会和"用户 ID 恰好是 0"混淆(虽然自增 ID 不会是 0,但别赌)。
func (s *PostService) GetByID(ctx context.Context, id int64, viewerID *int64) (*model.Post, error) {
p, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
// 未发布的文章只有作者本人能看。返回 403 而不是 404 是**产品决定**不是技术决定:
// 404 能隐藏"这篇文章存在"(防枚举),403 则告诉对方"有但你不能看"。
// 本教程选 403 是为了让 httpStatusFor 的映射表更好演示;真做内容平台建议 404。
if p.Status != model.StatusPublished && !isOwner(p, viewerID) {
return nil, fmt.Errorf("文章 id=%d 未发布: %w", id, model.ErrForbidden)
}
return p, nil
}
func isOwner(p *model.Post, viewerID *int64) bool {
if p.AuthorID == nil || viewerID == nil {
return false // 任一边为 nil 就不可能是"同一个人"
}
return *p.AuthorID == *viewerID
}
4.5 PostHandler.Update —— PATCH 用指针区分「没传」和「传了空值」
这是本课最实用的一个坑。
// internal/service/post_service.go
// UpdatePostRequest 用**指针字段**区分「没传这个字段」和「传了零值」。
//
// 写成 Title string 的话:请求 {"summary":""} 解析出 Title="" ——
// 你无法判断用户是想清空标题还是压根没提标题,两种意图撞成同一个值。
// 结果只能二选一:要么把没传的字段误改成空,要么永远无法清空任何字段。都错。
//
// 写成 *string 之后:没传 → nil(跳过);传 "" → 指向 ""(清空);传 "abc" → 改成 abc。
type UpdatePostRequest struct {
Title *string `json:"title"`
Summary *string `json:"summary"`
Content *string `json:"content"`
}
// Update 部分更新:只覆盖 req 里非 nil 的字段。
func (s *PostService) Update(ctx context.Context, id int64, req UpdatePostRequest) (*model.Post, error) {
// "读-改-写"模式。好处:能在内存里做业务校验(如"标题不能改成空"),顺便确认文章存在。
// 代价:读写之间有并发窗口,两个人同时改会后写覆盖先写。真要解决得加乐观锁
//(version 列)或 SELECT ... FOR UPDATE,本教程先不做。
p, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if req.Title != nil {
title := strings.TrimSpace(*req.Title)
if title == "" { // 标题是 NOT NULL 列且业务上必填 → 不允许清空
return nil, fmt.Errorf("标题不能改成空: %w", model.ErrInvalidArg)
}
p.Title = title
}
if req.Summary != nil {
// summary 允许清空。传 "" 会经 repository 的 nullString() 落库成 NULL。
p.Summary = strings.TrimSpace(*req.Summary)
}
if req.Content != nil {
content := strings.TrimSpace(*req.Content)
if content == "" {
return nil, fmt.Errorf("正文不能改成空: %w", model.ErrInvalidArg)
}
p.Content = content
}
if err := s.repo.Update(ctx, p); err != nil {
return nil, fmt.Errorf("更新文章 id=%d: %w", id, err)
}
return p, nil
}
对应的 handler 极短——这正是分层做对了的标志:
// internal/handler/post_handler.go
// Update 处理 PATCH /api/posts/{id}。整个函数只做四件事:解析路径参数、
// 解析请求体、调 service、写响应。一条业务规则都没有,一句 SQL 都没有。
func (h *PostHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
writeDomainError(w, err)
return
}
// decodeJSON 是第 2 课的泛型辅助函数,带 DisallowUnknownFields 和 1MB 上限。
// req 用 service 包的类型 —— handler 不重新定义一份,避免两处字段不同步。
var req service.UpdatePostRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
return
}
p, err := h.svc.Update(r.Context(), id, req) // r.Context() 一路传下去
if err != nil {
writeDomainError(w, err) // 领域错误 → HTTP 状态码,见 4.6
return
}
respond(w, http.StatusOK, h.toResponse(p))
}
陷阱:DisallowUnknownFields() + 指针字段有个交互效应——客户端把字段名拼错成 {"titel":"x"} 会直接报 400 而不是被静默忽略。这是好事,但要在 API 文档里写清楚。
上面用到的两个小工具,一并给出:
// internal/handler/post_handler.go
// respond 是 writeJSON 的"成功路径"包装。
// 为什么需要它:第 2 课的 writeJSON 返回 error(序列化失败、客户端提前断开都会触发),
// 而 handler 走到这一步响应头已经发出去了,**没办法再改状态码**——
// 唯一能做的就是记一条日志。包一层是为了不在每个 handler 里重复这四行。
func respond(w http.ResponseWriter, status int, v any) {
if err := writeJSON(w, status, v); err != nil {
log.Printf("响应写入失败: %v", err)
}
}
// pathID 解析 /api/posts/{id} 里的 id。
// 返回领域错误 model.ErrInvalidArg 而不是直接 writeError:
// 这样调用方统一走 writeDomainError,handler 里只有一条错误出口。
func pathID(r *http.Request) (int64, error) {
raw := r.PathValue("id") // 第 2 课学的 Go 1.22+ 路径参数
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id <= 0 {
// 显式挡掉 0 和负数:它们能通过 ParseInt,但不可能是合法主键,
// 放行只会让一个必然查不到的查询打到数据库。
return 0, fmt.Errorf("路径参数 id=%q 不是正整数: %w", raw, model.ErrInvalidArg)
}
return id, nil
}
4.6 httpStatusFor —— 领域错误集中映射
// internal/handler/errors.go
package handler
import (
"errors"
"log"
"net/http"
"strings"
"blog/internal/model"
)
// httpStatusFor 是「领域错误 → HTTP 状态码」的**唯一**映射点。
// 集中成一个函数而不是在每个 handler 里 if-else,图三件事:
// 1. 加一种领域错误只改一处,不用翻遍所有 handler
// 2. 纯函数,能被单测覆盖(不需要 http.ResponseWriter)
// 3. 一致性 —— 不会 A 接口把 ErrForbidden 映射成 403、B 接口映射成 401
func httpStatusFor(err error) int {
// 用 switch + errors.Is 而不是 type switch:领域错误是哨兵值(errors.New 出来的),
// 要按**身份**比对;且 errors.Is 会顺着 %w 链一路解开,service 包多少层都能认出来。
switch {
case err == nil:
return http.StatusOK
case errors.Is(err, model.ErrNotFound):
return http.StatusNotFound // 404
case errors.Is(err, model.ErrInvalidArg):
return http.StatusBadRequest // 400
case errors.Is(err, model.ErrForbidden):
return http.StatusForbidden // 403
case errors.Is(err, model.ErrDuplicate), errors.Is(err, model.ErrConflict):
return http.StatusConflict // 409
default:
// 认不出来的一律 500。这是**安全默认值**:宁可把本该 400 的错误报成 500
//(会被监控发现然后修),也不要把真正的系统故障报成 200/400(会被静默忽略)。
return http.StatusInternalServerError
}
}
// errorCodeFor 由状态码推导出 APIError.Code(第 2 课定义的响应结构)。
// 从 http.StatusText 推导而不是再维护一张 map:两张表迟早会不同步。
// 404 → "Not Found" → "NOT_FOUND"
func errorCodeFor(status int) string {
return strings.ToUpper(strings.ReplaceAll(http.StatusText(status), " ", "_"))
}
// writeDomainError 是 handler 处理 service 返回错误的唯一出口。
func writeDomainError(w http.ResponseWriter, err error) {
status := httpStatusFor(err)
msg := err.Error()
if status == http.StatusInternalServerError {
// ⭐ 500 的细节只进日志不给客户端:err.Error() 里可能有 SQL 片段、
// 表名、内网地址 —— 全是攻击者想要的信息。
log.Printf("未预期错误: %v", err)
msg = "服务器内部错误"
}
writeError(w, status, errorCodeFor(status), msg) // 复用第 2 课的 writeError
}
预告:writeDomainError 到第 5 课会被 writeErrorFrom(w, r, err) 取代——多一个 r 参数,为的是把 request_id 写进错误日志(request_id 存在 r.Context() 里,不接 r 拿不到)。现在这版先用着,第 5 课会告诉你怎么替换。httpStatusFor 和 errorCodeFor 是纯函数,届时保留不动。
4.7 toResponse —— DTO 转换
// internal/handler/post_handler.go
// PostResponse 是第 2 课 PostDTO 的正式版。入参和出参形状已经不一样了
//(入参没有 id/created_at,出参没有 publish 标志),所以拆成两个类型不再共用。
type PostResponse struct {
ID int64 `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
// omitempty:列表接口把 Content 置空,让它从 JSON 里整个消失而不是
// 返回一堆 "content":"",能显著减小列表响应体积。
Content string `json:"content,omitempty"`
Summary string `json:"summary"`
Status string `json:"status"` // "draft"/"published",不是裸数字
ViewCount int `json:"view_count"`
PublishedAt *string `json:"published_at"` // 指针:草稿要输出 null 而不是空串
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
// AuthorID / DeletedAt / Content 的内部形态一律不出现在这里
}
var statusText = map[model.Status]string{
model.StatusDraft: "draft",
model.StatusPublished: "published",
model.StatusOffline: "offline",
}
// toResponse 把领域模型翻译成对外 DTO。
//
// 为什么是 handler 的方法而不是 model.Post 的方法:写成
// func (p *model.Post) ToResponse() PostResponse 的话,model 包就要 import
// handler 包 —— 依赖箭头直接反转(3.3 节)。翻译成什么形状是**表现层**的事。
// 收 *model.Post 而不是值:避免大结构体(含 MEDIUMTEXT 正文)的拷贝。
func (h *PostHandler) toResponse(p *model.Post) PostResponse {
resp := PostResponse{
ID: p.ID,
Title: p.Title,
Slug: p.Slug,
Summary: p.Summary,
Content: p.Content,
Status: statusText[p.Status], // map 取不到时得到 "",比 panic 好
ViewCount: p.ViewCount,
// 时间统一转 RFC3339 字符串。不直接输出 time.Time:Go 的默认序列化格式
// 和前端库的期望经常对不上,转成字符串把格式钉死在这一处。
CreatedAt: p.CreatedAt.Format(time.RFC3339),
UpdatedAt: p.UpdatedAt.Format(time.RFC3339),
}
if p.PublishedAt != nil {
s := p.PublishedAt.Format(time.RFC3339)
resp.PublishedAt = &s // 局部变量取地址,不是 &p.PublishedAt
}
// ⭐ AuthorID 和 DeletedAt 刻意不出现在 DTO 里 —— 不是"忘了写",是**显式的
// 安全决定**:白名单式输出。反过来用 json:"-" 做黑名单,将来 model 加了
// 新的敏感字段就会默认泄露出去。
return resp
}
4.8 main.go —— 装配
// cmd/server/main.go
package main
import (
"log"
"net/http"
"os"
"time"
"blog/internal/handler"
"blog/internal/repository"
"blog/internal/service"
)
const defaultDSN = "root:123456@tcp(127.0.0.1:3306)/blog_dev?charset=utf8mb4&parseTime=True&loc=Local"
// 编译期断言:确认 *repository.PostRepo 仍然满足 service.PostRepository。
var _ service.PostRepository = (*repository.PostRepo)(nil)
func main() {
dsn := os.Getenv("BLOG_DSN")
if dsn == "" {
dsn = defaultDSN
}
// ---- 装配:从最底层往上一层层构造。main 是整个程序**唯一**知道
// "具体用哪个实现"的地方 —— 换 PostgreSQL、加缓存装饰器、换假 repo,都只改这几行。
db, err := repository.OpenDB(dsn)
if err != nil {
log.Fatalf("数据库初始化失败: %v", err)
}
defer db.Close()
// 每一行的输出是下一行的输入 —— 这就是"依赖从 main 一路传下去"。
// 没有任何一个包级 var db / var svc;任何组件需要什么,都从构造函数拿。
postRepo := repository.NewPostRepo(db) // *PostRepo,认识 *sql.DB
postSvc := service.NewPostService(postRepo) // *PostService,只认识 PostRepository 接口
postHandler := handler.NewPostHandler(postSvc) // *PostHandler,只认识 *PostService
mux := http.NewServeMux()
postHandler.Routes(mux) // 路由注册交给 handler 自己,main 不用知道有哪些路径
mux.HandleFunc("GET /healthz", healthz(db))
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second, // 防 Slowloris:不设的话慢速攻击能拖死连接
WriteTimeout: 15 * time.Second,
}
log.Printf("服务启动,监听 %s", srv.Addr)
log.Fatal(srv.ListenAndServe())
}
// healthz 就绪探针:DB 连不上就返回 503,让编排系统别把流量打进来。
func healthz(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := db.PingContext(r.Context()); err != nil {
http.Error(w, "db down", http.StatusServiceUnavailable)
return
}
w.Write([]byte("ok"))
}
}
配套的路由注册:
// internal/handler/post_handler.go
func (h *PostHandler) Routes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/posts", h.Create)
mux.HandleFunc("GET /api/posts", h.List)
mux.HandleFunc("GET /api/posts/{id}", h.Get)
mux.HandleFunc("PATCH /api/posts/{id}", h.Update)
mux.HandleFunc("POST /api/posts/{id}/publish", h.Publish)
mux.HandleFunc("DELETE /api/posts/{id}", h.Delete)
}
⑤ 跑起来验证
cd ~/go-blog && go build ./... && go run ./cmd/server
# 2026/09/06 09:32:53 服务启动,监听 :8080
另开一个终端,走完整条链路。下面每一条都是我真实跑过的,输出照抄。
1. 创建(草稿)
curl -s -X POST localhost:8080/api/posts -H 'Content-Type: application/json' \
-d '{"title":"Go 分层架构实战","summary":"handler/service/repository 三层","content":"依赖箭头只能单向。","publish":false}'
{"id":10,"title":"Go 分层架构实战","slug":"go-分层架构实战","summary":"handler/service/repository 三层","content":"依赖箭头只能单向。","status":"draft","view_count":0,"published_at":null,"created_at":"2026-09-06T09:32:57-07:00","updated_at":"2026-09-06T09:32:57-07:00"}
[HTTP 201]
status 是 "draft" 不是 0(DTO 翻译过了),published_at 是 null,响应里没有 author_id / deleted_at。
注意 slug 里有中文——第 1 课的 Slugify 用 unicode.IsLetter 保留了所有语言的字母。练习 4 会修它。
SQL 对照:
mysql -uroot -p123456 blog_dev -e "select id,slug,status,published_at,deleted_at from posts where id=10;"
# id slug status published_at deleted_at
# 10 go-分层架构实战 0 NULL NULL
2. 校验失败与错误映射
curl -s -X POST localhost:8080/api/posts -H 'Content-Type: application/json' \
-d '{"title":" ","content":"正文"}' # 空标题 → service 业务校验
# {"code":"BAD_REQUEST","message":"标题不能为空: 参数不合法"} [HTTP 400]
curl -s -X POST localhost:8080/api/posts -H 'Content-Type: application/json' \
-d '{"title":"x","content":"y","autor":"typo"}' # 字段名拼错 → DisallowUnknownFields
# {"code":"BAD_REQUEST","message":"decodeJSON: json: unknown field \"autor\""} [HTTP 400]
3. 列表
curl -s "localhost:8080/api/posts?page=1&size=2"
{"items":[{"id":2,"title":"database/sql 入门","slug":"database-sql-101","summary":"","status":"published","view_count":1,"published_at":"2026-09-06T09:25:04-07:00","created_at":"2026-09-06T09:25:04-07:00","updated_at":"2026-09-06T09:27:13-07:00"},{"id":1,"title":"Hello Go","slug":"hello-go","summary":"Go 的第一课","status":"published","view_count":1,"published_at":"2026-09-06T09:25:04-07:00","created_at":"2026-09-06T09:25:04-07:00","updated_at":"2026-09-06T09:27:13-07:00"}],"page":1,"size":2,"has_more":false}
刚创建的 id=10 不在列表里——它是草稿。列表项也没有 content(omitempty 生效)。
has_more 而不是 total:COUNT(*) 在大表上很贵,多查一条(Limit: size + 1)就能知道还有没有下一页。这个技巧在 service 层实现,handler 只是照抄。
4. 读取 —— 三种错误各一条
curl -s localhost:8080/api/posts/10 # 草稿,匿名访问
# {"code":"FORBIDDEN","message":"文章 id=10 未发布: 无权访问该资源"} [HTTP 403]
curl -s localhost:8080/api/posts/99999 # 不存在
# {"code":"NOT_FOUND","message":"查询文章 id=99999: 资源不存在"} [HTTP 404]
curl -s localhost:8080/api/posts/abc # id 非法
# {"code":"BAD_REQUEST","message":"路径参数 id=\"abc\" 不是正整数: 参数不合法"} [HTTP 400]
三个不同的层各产生了一个错误,httpStatusFor 一个函数全接住了:403 来自 service 的可见性规则,404 来自 repository 翻译的 sql.ErrNoRows,400 来自 handler 的路径参数解析。
5. 部分更新(PATCH 的指针语义)
P=localhost:8080/api/posts/10; H='Content-Type: application/json'
curl -s -X PATCH $P -H "$H" -d '{"summary":""}' # 传空串 → 明确要求清空
# {"id":10,"title":"Go 分层架构实战","summary":"","status":"draft",...} [HTTP 200]
mysql -uroot -p123456 blog_dev -e "select summary, summary is null as is_null from posts where id=10;"
# summary is_null
# NULL 1 ← 空串真的落成了 NULL(repository 的 nullString() 在起作用)
curl -s -X PATCH $P -H "$H" -d '{"title":"Go 分层架构实战(改)"}' # 不传 summary → 保持不变
# {"id":10,"title":"Go 分层架构实战(改)","summary":"",...} ← 只有 title 变了
curl -s -X PATCH $P -H "$H" -d '{"title":" "}' # 把 title 改成空
# {"code":"BAD_REQUEST","message":"标题不能改成空: 参数不合法"} [HTTP 400]
如果 UpdatePostRequest 的字段不是指针,第二条命令会把 summary 和 content 一起清空——content 还会因为"正文不能改成空"直接报 400,你连改个标题都做不到。
6. 发布 + 幂等
curl -s -X POST localhost:8080/api/posts/10/publish # 第 1 次: published 2026-09-06T09:34:06-07:00
sleep 1
curl -s -X POST localhost:8080/api/posts/10/publish # 第 2 次: published 2026-09-06T09:34:06-07:00
mysql -uroot -p123456 blog_dev -e "select id,status,published_at from posts where id=10;"
# id status published_at
# 10 1 2026-09-06 09:34:06 ← 时间戳没变、没报错,且和 API 返回完全一致
这条对照是我实测时抓到 bug 的地方:一开始 API 返回 09:34:07 而库里是 09:34:06,因为 time.Now() 带小数秒,MySQL 的 DATETIME 写入时四舍五入(第 3 课 3.5)。加上 Truncate(time.Second) 才对上。
发布之后草稿变可见了:
curl -s -w "\n[HTTP %{http_code}]\n" localhost:8080/api/posts/10 | tail -1
# [HTTP 200] ← 之前是 403
7. 删除(软删除)
curl -s -X DELETE localhost:8080/api/posts/10 # [HTTP 204],无响应体
curl -s -X DELETE localhost:8080/api/posts/10 # 再来一次
# {"code":"NOT_FOUND","message":"软删除文章 id=10: 资源不存在"} [HTTP 404]
mysql -uroot -p123456 blog_dev -e "select id,status,deleted_at from posts where id=10;"
# id status deleted_at
# 10 1 2026-09-06 09:33:14 ← 行还在,只是打了删除标记
8. 分层的回报:不用数据库就能测业务规则
这是分层最直接的收益。写一个假 repo,它不需要 implements 任何东西,方法签名对上就自动满足接口:
// internal/service/post_service_test.go
package service
// fakeRepo 是 PostRepository 的假实现,全部数据存在 map 里。
type fakeRepo struct {
posts map[int64]*model.Post
nextID int64
slugSet map[string]bool
}
func (f *fakeRepo) GetByID(_ context.Context, id int64) (*model.Post, error) {
p, ok := f.posts[id]
if !ok {
return nil, model.ErrNotFound // 假实现也要返回**领域错误**,才能测到错误路径
}
cp := *p // 返回副本,模拟"从数据库读出来"的独立对象
return &cp, nil
}
// Create / List / Update / SoftDelete / ExistsBySlug 同理,都是几行 map 操作
func TestPublish_幂等且不刷新时间(t *testing.T) {
svc := NewPostService(newFakeRepo()) // ← 注入假 repo,一行搞定
ctx := context.Background()
p, _ := svc.Create(ctx, CreatePostRequest{Title: "draft one", Content: "c"})
got1, _ := svc.Publish(ctx, p.ID)
got2, err := svc.Publish(ctx, p.ID)
if err != nil {
t.Fatalf("第二次 Publish 不应报错: %v", err)
}
if !got1.PublishedAt.Equal(*got2.PublishedAt) {
t.Fatalf("published_at 被刷新了: %v -> %v", got1.PublishedAt, got2.PublishedAt)
}
}
go test ./internal/service/ -v
--- PASS: TestCreate_空标题被拒 (0.00s)
--- PASS: TestCreate_slug冲突自动加后缀 (0.00s)
--- PASS: TestPublish_幂等且不刷新时间 (0.00s)
--- PASS: TestGetByID_草稿对非作者不可见 (0.00s)
PASS
ok blog/internal/service 0.389s
0.389 秒,四条业务规则,零数据库、零 HTTP、零 Docker。 回头看 3.1 那个"SQL 写在 handler 里"的版本——这四条测试一条都写不出来。第 10 课会把这套铺开。
⑥ TODO 练习
练习 1:Unpublish(下线)
// TODO(练习1): func (s *PostService) Unpublish(ctx context.Context, id int64) (*model.Post, error)
// 规则:① 只有 StatusPublished 能下线,其他状态返回 model.ErrConflict
// ② 幂等:已经是 StatusOffline 就原样返回,不报错
// ③ **保留 published_at 不清空** —— 重新上线时 Publish 会复用它(4.4 节的规则)
// 再在 handler 加 POST /api/posts/{id}/unpublish
验收:发布 → 下线 → 再发布,三次的 published_at 完全相同;对草稿调用返回 409。
练习 2:给 List 加 status 过滤
// TODO(练习2): 支持 GET /api/posts?status=draft
// 要求:① **不改 PostRepository 接口**(提示:PostFilter 里已经有 Status *Status)
// ② handler 负责把字符串 "draft" 翻译成 model.StatusDraft —— 这是 HTTP 层的翻译活
// ③ 非法值(?status=xxx)返回 400,不要静默忽略
// ④ 想一想:允许匿名用户 ?status=draft 列出所有草稿吗?(不行,这是安全洞,
// service 层要挡住;第 6 课有了身份之后才能真正做对)
验收:?status=published 只返回已发布;?status=xxx 返回 400;PostRepository 接口一个字没改。
练习 3:GetBySlug 走 HTTP
// TODO(练习3): GET /api/posts/slug/{slug} 按 slug 读文章
// 需要动三层:repository 加方法 → service 的 PostRepository 接口加一行 → handler 加路由。
// 亲手走一遍"加一个功能要改哪几个文件",体会分层的成本与收益。
// 注意:加了接口方法后测试里的 fakeRepo 也必须补上,否则编译失败 —— 这是好事。
验收:/api/posts/slug/hello-go 返回 id=1;不存在的 slug 返回 404;go build ./... 和 go test ./... 都过。
练习 4:修掉 slug 里的中文
验证时你看到 slug 是 go-分层架构实战——能用但不理想(URL 里会变成一长串 %E5%88%86...)。
// TODO(练习4): 改造 model.Post.Slugify(),只保留 ASCII 字母数字。
// 中文标题会 slug 成空串,此时由 service 的 base == "" 分支兜底成 post-<时间戳>。
// 想一想:为什么这个改动放在 model 层而不是 service 层?
// (提示:Slugify 是 Post 这个概念自身的行为,不依赖任何外部资源)
验收:Slugify("Go 分层架构 v2") 返回 go-v2;Slugify("纯中文标题") 返回 "";创建纯中文标题的文章不报错,slug 形如 post-1788712...。
练习 5:给 httpStatusFor 写表驱动单测
// TODO(练习5): internal/handler/errors_test.go
// 它是纯函数,不需要起 server。用表驱动测试覆盖:每种领域错误 → 对应状态码;
// **被 fmt.Errorf("%w") 包了两层的错误**仍能被认出来 ← 重点;未知错误 → 500;nil → 200。
验收:go test ./internal/handler/ 通过;把某个 %w 改成 %v 后测试必须失败(这才证明测试真的在测东西)。
⑦ 自检清单
- 我能说出「SQL 写在 handler 里」的三个具体问题,且不是"不好看"这种理由
- 我能说出四层各自的唯一职责和禁止事项
- 我知道 Go 的接口是隐式实现,没有
implements - 我能解释为什么
PostRepository接口写在service包而不是repository包 - 我知道
PostFilter为什么必须放在model包(否则依赖方向反转) - 我理解 "accept interfaces, return structs"
- 我会用
var _ Iface = (*Impl)(nil)做编译期接口断言 - 我能说出构造函数注入相对全局变量的至少三个好处
- 我知道依赖是从
main一路传下去的,项目里没有包级var db - 我知道领域模型不能直接当 JSON 响应返回,能说出至少两个理由
- 我理解 PATCH 必须用指针字段区分「没传」和「传了零值」
- 我知道领域错误应该集中在
httpStatusFor一处映射成 HTTP 码 - 我知道 500 的错误详情只能进日志,不能返回给客户端
- 我能写一个假 repo,在不连数据库的情况下测 service 的业务规则
- 我知道
toResponse为什么是 handler 的方法而不是model.Post的方法
下一课:现在每个 handler 都在重复"解析参数 → 调 service → 判错误 → 写响应"。第 5 课用中间件把日志、panic 恢复、CORS 这些横切关注点从 handler 里抽出来,并用 context 在中间件和 handler 之间传值(为第 6 课的登录态做准备)。