「Go 语言实战」从零手写一个分层架构的图书管理系统(HTTP + DDD 思想 · 含完整源码)
「Go 实战」从零手写一个分层架构的图书管理系统(含完整源码 · 对照 tRPC-Go)

本文配套源码已开源在本地工程
library/目录,全文所有代码片段都与仓库一一对应、可直接go run运行。阅读时长:约 25 分钟|代码行数:约 1000 行|难度:⭐⭐⭐(Go 入门 → 工程化进阶)
1. 写在前面:为什么要再写一个图书管理系统
“图书管理系统” 这个题目近乎烂大街,但99% 的版本只关心 CRUD,没人讲清以下 6 个工程问题:
- 为什么要分
handler / service / repository?业务到底放哪一层? - 为什么
repository要用接口而不是直接调*sql.DB? - HTTP
400/404/409/500这些状态码到底由谁决定? - 中间件应该怎么组合,顺序为什么会影响行为?
- 配置文件该不该写死?为什么 tRPC-Go 搞一个
trpc_go.yaml? - 服务怎么优雅退出,连接和 in-flight 请求怎么收尾?
这篇博客就是对这 6 个问题的逐条回答。 项目刻意只用 Go 标准库,目的不是炫技,而是把每一根肌肉拆开给你看:你将清楚地看到 tRPC-Go / Kitex / Gin / Kratos 这些框架替我们做了什么,而不是把它们当成黑盒。
学到这一层之后,再去学 tRPC-Go,你会有一种“原来如此”的通透感。
2. 目标与功能清单
我们要交付一个可运行的 Library Management Service:
| 维度 | 要求 |
|---|---|
| 协议 | HTTP + JSON(统一信封 {code, msg, data}) |
| 业务 | 图书 CRUD + 借阅 / 归还 + 健康检查 |
| 工程 | 分层架构 / 接口隔离 / 中间件链 / YAML 配置 / 优雅退出 |
| 测试 | repository 与 service 双层单测 |
| 依赖 | 仅 Go 标准库(教学透明、零供应链负担) |
API 一览:
| Method | Path | 说明 |
|---|---|---|
| GET | /api/health |
健康检查 |
| GET | /api/books |
列出全部图书 |
| GET | /api/books/{id} |
查询单本 |
| POST | /api/books |
新增图书 |
| PUT | /api/books/{id} |
修改图书 |
| DELETE | /api/books/{id} |
删除图书 |
| POST | /api/books/{id}/borrow |
借阅 |
| POST | /api/books/{id}/return |
归还 |
3. 架构设计:四层分层 + 中间件链
四层各司其职,严格单向依赖:
handler只关心 协议(HTTP / JSON),不写业务;service只关心 业务(规则 / 校验 / 编排),不知道 HTTP 也不知道 SQL;repository只关心 存储(CRUD / 唯一性),是“仓库”而非“业务”;model是 领域实体,被三层共同引用,但不依赖任何一层。
这套结构等价于经典的 Clean Architecture / DDD 战术分层,只是为了入门刻意保持极简。
4. 项目结构总览
library/
├── api/
│ └── book.proto # (可选)作为协议蓝本,未来切 gRPC/tRPC 复用
├── cmd/
│ ├── server/main.go # 服务端入口:装配 + 启动 + 优雅退出
│ └── client/main.go # 演示客户端:调用 8 类接口
├── configs/
│ └── config.yaml # 运行时配置(端口/超时/中间件链)
├── internal/
│ ├── model/book.go # 领域实体 Book
│ ├── repository/ # 持久化层(接口 + 内存实现 + 单测)
│ ├── service/ # 业务层(接口 + 实现 + 单测)
│ └── handler/ # HTTP 适配层(路由 + 中间件 + 统一响应)
├── pkg/
│ ├── config/ # 极简 YAML 解析器
│ └── logger/ # 分级日志封装
├── go.mod
├── README.md
└── LEARNING.md # 学习路径(9 步法)
记忆口诀:cmd 装配,internal 写业务,pkg 放可复用基础设施,api 存协议契约。
5. 逐层精讲(含完整源码)
下面的每段代码都来自仓库实文件,路径精确到行号便于定位。
5.1 model 层 —— 领域实体
文件:internal/model/book.go
// Package model defines the domain entities of the library system.
package model
import "time"
// Book represents one catalog item managed by the library.
type Book struct {
ID int64 `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
ISBN string `json:"isbn"`
Stock int32 `json:"stock"` // copies still available
Borrowed int32 `json:"borrowed"` // copies currently lent out
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// IsBorrowable reports whether at least one copy can still be lent.
func (b *Book) IsBorrowable() bool {
return b.Stock > 0
}
// HasOutstandingLoans reports whether some copies are still in users' hands.
func (b *Book) HasOutstandingLoans() bool {
return b.Borrowed > 0
}
设计要点:
Stock与Borrowed是一对“水位”:借出 →Stock--, Borrowed++;归还反向。两者之和是“理论馆藏”。IsBorrowable / HasOutstandingLoans这类方法称为“富领域行为”:把判断写在实体身上,service 层就不会写出散落各处的if b.Stock > 0 { ... }。- 不暴露 setter,也没有 ORM tag:模型保持纯净;存储层愿意用什么映射机制(GORM、sqlx)那是它自己的事。
💡 这就是 DDD 里反复强调的「模型不要贫血」。
5.2 repository 层 —— 持久化抽象
文件:internal/repository/book_repo.go
5.2.1 接口与哨兵错误
// Sentinel errors returned by every implementation of BookRepository.
var (
ErrNotFound = errors.New("book not found")
ErrISBNDuplicate = errors.New("isbn already exists")
)
// BookRepository is the persistence contract used by the service layer.
type BookRepository interface {
List() ([]*model.Book, error)
Get(id int64) (*model.Book, error)
Create(b *model.Book) (*model.Book, error)
Update(b *model.Book) (*model.Book, error)
Delete(id int64) error
}
为什么先写接口而不是直接写实现?
- 可替换:今天用
map,明天换成 MySQL,只要满足接口;service 层零修改。 - 可测试:
service的单测可以注入一个mockRepo,不需要真数据库。 - 明确边界:接口列出来的 5 个方法,就是“这个仓库总共能做的事”;多 1 个、少 1 个都需要慎重评审。
这种「面向接口编程」的姿势,和 tRPC-Go「先写 .proto 协议,再写实现」的哲学是同构的。
5.2.2 内存实现 —— 并发安全 + ISBN 唯一性
type memoryBookRepo struct {
mu sync.RWMutex
books map[int64]*model.Book
nextID int64
}
func (r *memoryBookRepo) Create(b *model.Book) (*model.Book, error) {
r.mu.Lock()
defer r.mu.Unlock()
if b.ISBN != "" {
for _, existing := range r.books {
if existing.ISBN == b.ISBN {
return nil, ErrISBNDuplicate
}
}
}
r.nextID++
b.ID = r.nextID
if b.CreatedAt.IsZero() {
b.CreatedAt = time.Now()
}
b.UpdatedAt = b.CreatedAt
stored := *b
r.books[b.ID] = &stored
return b, nil
}
三点容易被忽略的细节:
- 读写锁:
List/Get用RLock,写操作用Lock。读多写少场景下性能比Mutex好得多。 - 存指针的拷贝而非外部指针:
stored := *b; r.books[b.ID] = &stored。否则 service 层current.Title = ...会直接污染仓库内的内存。这就是教科书级的「值语义保护」。 - 唯一性约束自己实现:换成 MySQL 时,会被
UNIQUE KEY isbn取代;但是接口契约(ErrISBNDuplicate)不变。
List/Get/Update/Delete 都遵循“先加锁、操作 map、返回拷贝”的同一套套路,篇幅原因不再展开,源码完全自洽。
5.3 service 层 —— 业务规则与不变量
文件:internal/service/book_service.go
5.3.1 接口、DTO 与领域错误
var (
ErrInvalidInput = errors.New("invalid input")
ErrOutOfStock = errors.New("book is out of stock")
ErrNothingToReturn = errors.New("no borrowed copies to return")
ErrStillBorrowed = errors.New("cannot delete: copies are still borrowed")
)
type BookService interface {
List() ([]*model.Book, error)
Get(id int64) (*model.Book, error)
Create(in CreateInput) (*model.Book, error)
Update(id int64, in UpdateInput) (*model.Book, error)
Delete(id int64) error
Borrow(id int64) (*model.Book, error)
Return(id int64) (*model.Book, error)
}
type CreateInput struct {
Title string
Author string
ISBN string
Stock int32
}
为什么不直接复用 model.Book 当入参?
- 避免 mass-assignment 漏洞:客户端不可以伪造
ID / CreatedAt / Borrowed。DTO 让“可写字段”显式可见。 - 解耦层间演进:以后想给 model 加字段,不会突然破坏所有调用方。
5.3.2 业务核心:借书与还书
// Borrow moves one copy from Stock to Borrowed.
func (s *bookService) Borrow(id int64) (*model.Book, error) {
if id <= 0 {
return nil, ErrInvalidInput
}
current, err := s.repo.Get(id)
if err != nil {
return nil, err
}
if !current.IsBorrowable() {
return nil, ErrOutOfStock
}
current.Stock--
current.Borrowed++
return s.repo.Update(current)
}
// Return is the inverse of Borrow.
func (s *bookService) Return(id int64) (*model.Book, error) {
if id <= 0 {
return nil, ErrInvalidInput
}
current, err := s.repo.Get(id)
if err != nil {
return nil, err
}
if !current.HasOutstandingLoans() {
return nil, ErrNothingToReturn
}
current.Stock++
current.Borrowed--
return s.repo.Update(current)
}
把这一段跟 Delete 放一起看:
func (s *bookService) Delete(id int64) error {
...
if current.HasOutstandingLoans() {
return ErrStillBorrowed
}
return s.repo.Delete(id)
}
这就是“业务不变量”:
“一本被借出的书,不允许被删除。”
这种规则放在 SQL 触发器里很别扭,放在 controller 里又太靠外。只有 service 层是它最佳归宿。
5.4 handler 层 —— HTTP 适配器
文件:internal/handler/book_handler.go + response.go
5.4.1 统一响应信封
const (
CodeOK = 0
CodeBadRequest = 400
CodeNotFound = 404
CodeConflict = 409
CodeInternalError = 500
)
type Response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data interface{} `json:"data,omitempty"`
}
func writeOK(w http.ResponseWriter, data interface{}) {
writeJSON(w, http.StatusOK, Response{Code: CodeOK, Msg: "ok", Data: data})
}
HTTP 状态码 vs 业务码:两条线并行,前者用于 LB、监控、网关等基础设施判断;后者给前端业务侧分支用。这套打法在公司级 Open API 里非常常见。
5.4.2 路由派发:用标准库 ServeMux 也能优雅
func (h *BookHandler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/health", h.Health)
mux.HandleFunc("/api/books", h.collection) // list / create
mux.HandleFunc("/api/books/", h.subResource) // /api/books/{id}, /borrow, /return
}
subResource 是亮点:用一个函数把 GET/PUT/DELETE/{id} 和 POST /{id}/borrow|return 全派发出去。
func (h *BookHandler) subResource(w http.ResponseWriter, r *http.Request) {
rest := strings.TrimPrefix(r.URL.Path, "/api/books/")
parts := strings.Split(rest, "/")
id, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, CodeBadRequest, "invalid book id")
return
}
if len(parts) == 1 {
switch r.Method {
case http.MethodGet: h.get(w, id)
case http.MethodPut: h.update(w, r, id)
case http.MethodDelete: h.delete(w, id)
default:
writeError(w, http.StatusMethodNotAllowed, CodeBadRequest, "method not allowed")
}
return
}
if len(parts) == 2 && r.Method == http.MethodPost {
switch parts[1] {
case "borrow": h.borrow(w, id); return
case "return": h.returnBook(w, id); return
}
}
writeError(w, http.StatusNotFound, CodeNotFound, "route not found")
}
5.4.3 错误码翻译表 —— 集中式映射
func mapServiceError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, service.ErrInvalidInput):
writeError(w, http.StatusBadRequest, CodeBadRequest, err.Error())
case errors.Is(err, repository.ErrNotFound):
writeError(w, http.StatusNotFound, CodeNotFound, "book not found")
case errors.Is(err, repository.ErrISBNDuplicate):
writeError(w, http.StatusConflict, CodeConflict, "isbn already exists")
case errors.Is(err, service.ErrOutOfStock),
errors.Is(err, service.ErrNothingToReturn),
errors.Is(err, service.ErrStillBorrowed):
writeError(w, http.StatusConflict, CodeConflict, err.Error())
default:
writeError(w, http.StatusInternalServerError, CodeInternalError, "internal server error")
}
}
这是 Go Web 工程的「最佳实践 #1」:错误码翻译只能写在一处。否则你会得到 N 个 endpoint 各自返回不同状态码的灾难。
5.5 middleware —— 横切关注点
文件:internal/handler/middleware.go
5.5.1 中间件签名 & 链式装配
type Middleware func(http.Handler) http.Handler
func Chain(h http.Handler, mws ...Middleware) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
为什么倒序遍历? 答:让 YAML 里第一个中间件成为最外层包装:
middleware:
- "recovery" # 最外,负责兜底所有 panic
- "logger" # 次之,负责观测
- "cors" # 内层,离业务最近
请求路径就像剥洋葱:recovery → logger → cors → handler → cors → logger → recovery。这正是 tRPC-Go Filter 的洋葱模型。
5.5.2 RecoveryMiddleware —— 兜底 panic
func RecoveryMiddleware() Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic recovered: %v\n%s", rec, debug.Stack())
writeError(w, http.StatusInternalServerError, CodeInternalError, "internal server error")
}
}()
next.ServeHTTP(w, r)
})
}
}
生产事故救命姿势:一个空指针解引用就能让整个进程挂掉,加上这一层后,最多影响一次请求。
5.5.3 LoggerMiddleware —— 观测耗时与状态码
type statusRecorder struct {
http.ResponseWriter
status int
}
func (sr *statusRecorder) WriteHeader(code int) {
sr.status = code
sr.ResponseWriter.WriteHeader(code)
}
func LoggerMiddleware() Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(recorder, r)
log.Printf("%s %s -> %d (%s)", r.Method, r.URL.Path, recorder.status, time.Since(start))
})
}
}
「
statusRecorder包一层」是 Go 中间件圈的经典套路:标准ResponseWriter不暴露已写入的状态码,需要自己挂钩WriteHeader。
5.6 pkg/config —— 极简 YAML 解析器
文件:pkg/config/config.go
正常项目我们会用 gopkg.in/yaml.v3,但本文为了 零依赖,手撸了一个 ~100 行的扫描式解析器,仅覆盖:
key: value标量- 嵌套
map(按缩进) - item字符串序列
核心循环:
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
indent := countIndent(line)
for len(stack) > 1 && indent <= stack[len(stack)-1].indent {
top := stack[len(stack)-1]
commit(top)
stack = stack[:len(stack)-1]
}
if strings.HasPrefix(trimmed, "- ") {
top := stack[len(stack)-1]
value := strings.TrimSpace(strings.TrimPrefix(trimmed, "- "))
top.seq = append(top.seq, strings.Trim(value, "\"'"))
continue
}
colon := strings.Index(trimmed, ":")
key := strings.TrimSpace(trimmed[:colon])
rest := strings.TrimSpace(trimmed[colon+1:])
...
}
外加 默认值兜底:
func Default() *Config {
return &Config{
Server: ServerConfig{
Name: "library-management",
Host: "0.0.0.0",
Port: 8000,
ReadTimeoutSec: 10,
WriteTimeoutSec: 10,
ShutdownTimeoutSec: 5,
},
Log: LogConfig{Level: "info", Format: "text"},
Middleware: []string{"recovery", "logger", "cors"},
}
}
⚠️ YAML 注释必须用
#开头——别学我第一次写成// ...,结果启动时一直报invalid yaml line。这是个真踩过的小坑。
5.7 pkg/logger —— 分级日志
文件:pkg/logger/logger.go
type Level int
const (
LevelDebug Level = iota
LevelInfo
LevelWarn
LevelError
)
func Init(level, _ string) {
switch strings.ToLower(level) {
case "debug": currentLevel = LevelDebug
case "warn", "warning": currentLevel = LevelWarn
case "error": currentLevel = LevelError
default: currentLevel = LevelInfo
}
log.SetOutput(out)
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
}
真实项目里换成
zap或zerolog即可,调用方logger.Infof(...)不用动。这就是封装的价值。
5.8 cmd/server —— 启动装配 & 优雅退出
文件:cmd/server/main.go
func main() {
// 1. 加载配置
cfgPath := resolveConfigPath()
cfg, err := config.Load(cfgPath)
if err != nil {
fmt.Printf("warning: load config failed (%v), using defaults\n", err)
cfg = config.Default()
}
// 2. 初始化日志
logger.Init(cfg.Log.Level, cfg.Log.Format)
logger.Infof("starting %s with config %s", cfg.Server.Name, cfgPath)
// 3. 自底向上构建依赖图:repo -> service -> handler
repo := repository.NewMemoryBookRepo()
svc := service.NewBookService(repo)
bookHandler := handler.NewBookHandler(svc)
// 4. 路由 + 中间件链
mux := http.NewServeMux()
bookHandler.Register(mux)
mws := handler.BuildMiddlewares(cfg.Middleware)
rootHandler := handler.Chain(mux, mws...)
// 5. 显式超时,防慢客户端拖死 goroutine
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
srv := &http.Server{
Addr: addr,
Handler: rootHandler,
ReadTimeout: time.Duration(cfg.Server.ReadTimeoutSec) * time.Second,
WriteTimeout: time.Duration(cfg.Server.WriteTimeoutSec) * time.Second,
}
// 6. 后台启动 + 主协程等信号
serverErr := make(chan error, 1)
go func() {
logger.Infof("listening on %s", addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
serverErr <- err
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
select {
case sig := <-stop:
logger.Infof("received signal %s, shutting down...", sig)
case err := <-serverErr:
logger.Errorf("server error: %v", err)
}
// 7. 给 in-flight 请求一个有界 grace period
shutdownCtx, cancel := context.WithTimeout(
context.Background(),
time.Duration(cfg.Server.ShutdownTimeoutSec)*time.Second,
)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Errorf("graceful shutdown failed: %v", err)
os.Exit(1)
}
logger.Infof("server stopped cleanly")
}
8 个分步注释 = 8 个不容妥协的工程要点:
- 配置可以失败但启动不能死;
- 日志先于其它组件初始化;
- 依赖自底向上构造(避免出现“拿到一个 nil 的 service”);
- 中间件挂在 mux 外层而非内部;
- 显式超时保护连接;
- 监听不阻塞主协程,否则信号收不到;
signal.Notify捕SIGINT/SIGTERM;srv.Shutdown(ctx)拒绝新连接但等存量请求结束——这才叫优雅退出。
这也是为什么 tRPC-Go 的
trpc.NewServer().Serve()看起来一行就能起服务——它把这 8 步全部封装好了。我们手动写一遍,就明白框架在替我们打哪些底层基础。
6. 运行 & 调试:5 分钟跑通整套 API
6.1 启动服务端
cd library
go run ./cmd/server
输出:
2026/05/18 11:24:50.909477 [INFO] starting library-management with config configs/config.yaml
2026/05/18 11:24:50.910412 [INFO] listening on 0.0.0.0:8000
6.2 跑一遍完整业务流(curl)
# 健康检查
curl http://localhost:8000/api/health
# {"code":0,"msg":"ok","data":{"status":"up"}}
# 列表
curl http://localhost:8000/api/books
# 借书
curl -X POST http://localhost:8000/api/books/1/borrow
# stock:3 → 2, borrowed:0 → 1 ✅
# 创建(演示重复 ISBN 冲突)
curl -X POST -H "Content-Type: application/json" \
-d '{"title":"Go语言圣经","author":"Donovan","isbn":"111-22","stock":5}' \
http://localhost:8000/api/books
# 200 {"code":0,...}
curl -X POST -H "Content-Type: application/json" \
-d '{"title":"重复书","author":"X","isbn":"111-22","stock":1}' \
http://localhost:8000/api/books
# 409 {"code":409,"msg":"isbn already exists"} ✅
# 还书
curl -X POST http://localhost:8000/api/books/1/return
# stock:2 → 3, borrowed:1 → 0 ✅
6.3 用 Go 客户端调(更直观)
go run ./cmd/client list
go run ./cmd/client borrow 2
go run ./cmd/client create "TCP/IP详解" "Stevens" "999-88" 3
实测全套 7 个场景 —— 借→新增→查→还→重复 ISBN→Go 客户端调 —— 全部通过 ✅。
7. 单元测试:用接口边界做最便宜的回归
文件:internal/service/book_service_test.go
func newTestService() BookService {
return NewBookService(repository.NewMemoryBookRepo())
}
func TestBookService_BorrowAndReturn(t *testing.T) {
svc := newTestService()
created, _ := svc.Create(CreateInput{Title: "X", Author: "Y", ISBN: "Z", Stock: 1})
borrowed, _ := svc.Borrow(created.ID)
if borrowed.Stock != 0 || borrowed.Borrowed != 1 {
t.Fatalf("borrow counters wrong: stock=%d borrowed=%d",
borrowed.Stock, borrowed.Borrowed)
}
if _, err := svc.Borrow(created.ID); !errors.Is(err, ErrOutOfStock) {
t.Fatalf("expected ErrOutOfStock, got %v", err)
}
returned, _ := svc.Return(created.ID)
if returned.Stock != 1 || returned.Borrowed != 0 {
t.Fatalf("return counters wrong: stock=%d borrowed=%d",
returned.Stock, returned.Borrowed)
}
if _, err := svc.Return(created.ID); !errors.Is(err, ErrNothingToReturn) {
t.Fatalf("expected ErrNothingToReturn, got %v", err)
}
}
func TestBookService_DeleteWhileBorrowed(t *testing.T) {
svc := newTestService()
created, _ := svc.Create(CreateInput{Title: "X", Author: "Y", ISBN: "Z", Stock: 1})
_, _ = svc.Borrow(created.ID)
if err := svc.Delete(created.ID); !errors.Is(err, ErrStillBorrowed) {
t.Fatalf("expected ErrStillBorrowed, got %v", err)
}
}
跑测试:
go test ./...
这种「service 层 + 内存 repo」的搭法效率极高:不依赖 DB,不需要 mock 框架,每条用例毫秒级,并且直接覆盖了核心业务不变量(借书、还书、删除受借)。
真实工程也建议走这套:repository 层用集成测试确保 SQL 正确;service 层用上面这种轻量单元测试覆盖业务规则。
8. 一图读懂请求生命周期
9. 对照 tRPC-Go:本项目每一层都对应什么
| 本项目 | tRPC-Go 等价物 | 备注 |
|---|---|---|
model.Book |
*.proto 生成的 message |
数据模型 |
service.BookService 接口 |
.proto 中的 service { rpc ... } |
业务契约 |
service.bookService 实现 |
你在 xxx_service.go 里写的服务实现 |
业务实现 |
handler 层 |
tRPC-Go 的 transport + codec |
协议适配 |
handler.Middleware |
tRPC-Go Filter |
洋葱模型一致 |
pkg/config 解析的 config.yaml |
trpc_go.yaml |
同样“代码做什么、YAML 决定怎么做” |
cmd/server/main.go 的装配 |
trpc.NewServer().RegisterService(...).Serve() |
框架替你封装好了 |
cfg.Server.ShutdownTimeoutSec |
tRPC-Go 内置的优雅退出 | 一行代码即可 |
所以本项目本质上是“裸版 tRPC-Go”:你弄懂这套后再去看 tRPC-Go 源码,几乎一眼就能定位每一段的角色。
10. 常见踩坑与最佳实践
| # | 坑 | 解药 |
|---|---|---|
| 1 | YAML 用 // 注释 |
改成 #,否则解析失败回退默认值 |
| 2 | repository 直接返回内部指针 | 一律返回拷贝,避免上层污染缓存 |
| 3 | 中间件顺序写反 | 第一个 = 最外层;recovery 必须在最外面 |
| 4 | handler 里写业务规则 | 业务规则只在 service 层;handler 只做编解码 |
| 5 | 用 HTTP 状态码当业务码 | 双轨制:HTTP status + body.code 各司其职 |
| 6 | srv.ListenAndServe() 写在主协程 |
收不到信号;必须放 goroutine |
| 7 | 没有写超时 | 慢客户端会耗光 goroutine;ReadTimeout/WriteTimeout 必须设置 |
| 8 | 忘记 srv.Shutdown(ctx) |
in-flight 请求会被强行截断;务必走优雅退出 |
| 9 | errors.Is 没用 |
直接 == 比较容易在 wrap 后失效;统一 errors.Is |
| 10 | 单测只覆盖 happy path | 把 ErrOutOfStock / ErrNothingToReturn 这些反例也覆上 |
11. 下一步可以怎么扩展
| 阶段 | 目标 | 提示 |
|---|---|---|
| 🟢 入门加固 | 加分页、按作者搜索 | service 接口加 ListByAuthor(name string, page, size int) |
| 🟡 持久化 | MySQL 实现 BookRepository |
替换 memoryBookRepo,service / handler 零修改 |
| 🟡 鉴权 | JWT / API-Key 中间件 | 写一个 AuthMiddleware,加进 YAML chain |
| 🔵 协议 | 把 book.proto 换成 tRPC-Go 服务 |
service 实现可直接平移过去 |
| 🔵 可观测 | 接入 prometheus + opentelemetry | 在 logger 中间件旁加 metrics + traces |
| 🔴 微服务化 | 拆分用户服务 / 借阅服务 | service 层接口稳定即可平滑切割 |
12. 写在最后
这个项目刻意写得**“小而全”**:
- 小:1000 行左右,单坐 1 杯咖啡可以读完;
- 全:你能看到从 model 到优雅退出的每一根工程肌肉。
如果你看完之后能做到这两点,本文目的就达成了:
- 看到任意一个 Go Web 项目,能立刻指出它的 handler / service / repository 在哪、做得对不对;
- 学习 tRPC-Go / Kitex / Kratos 这类框架时,知道它们到底替你封装了哪几层,从此不再是黑盒。
📌 完整源码见仓库
library/目录,按 LEARNING.md 的 9 步法配套阅读,效果最佳。觉得本文有帮助,欢迎点赞 / 收藏 / 关注,后续会更新「给本项目接 MySQL」「改造为 tRPC-Go 服务」两篇进阶。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)