「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. 架构设计:四层分层 + 中间件链

被各层共享

被各层共享

被各层共享

HTTP Client

Middleware Chain
recovery → logger → cors

Handler 层
请求/响应适配

Service 层
业务规则 & 不变量

Repository 层
持久化接口

In-Memory / MySQL / Redis ...

Model 层
领域实体

四层各司其职,严格单向依赖

  • 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
}

设计要点

  1. StockBorrowed 是一对“水位”:借出 → Stock--, Borrowed++;归还反向。两者之和是“理论馆藏”。
  2. IsBorrowable / HasOutstandingLoans 这类方法称为“富领域行为”:把判断写在实体身上,service 层就不会写出散落各处的 if b.Stock > 0 { ... }
  3. 不暴露 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
}

三点容易被忽略的细节:

  1. 读写锁List/GetRLock,写操作用 Lock。读多写少场景下性能比 Mutex 好得多。
  2. 存指针的拷贝而非外部指针stored := *b; r.books[b.ID] = &stored。否则 service 层 current.Title = ...直接污染仓库内的内存。这就是教科书级的「值语义保护」。
  3. 唯一性约束自己实现:换成 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)
}

真实项目里换成 zapzerolog 即可,调用方 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 个不容妥协的工程要点

  1. 配置可以失败但启动不能死;
  2. 日志先于其它组件初始化;
  3. 依赖自底向上构造(避免出现“拿到一个 nil 的 service”);
  4. 中间件挂在 mux 外层而非内部;
  5. 显式超时保护连接;
  6. 监听不阻塞主协程,否则信号收不到;
  7. signal.NotifySIGINT/SIGTERM
  8. 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. 一图读懂请求生命周期

BookRepository BookService BookHandler Middleware Chain Client BookRepository BookService BookHandler Middleware Chain Client POST /api/books/1/borrow recovery (defer) logger.start = now() ServeHTTP parse id=1 Borrow(1) Get(1) book Stock=3 Borrowed=0 校验 IsBorrowable 通过 Stock 减一,Borrowed 加一 Update(book) book Stock=2 Borrowed=1 book writeOK(book) logger.printf("POST /api/books/1/borrow ->> 200") 200 {"code":0,"data":{...,"stock":2,"borrowed":1}}

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 到优雅退出的每一根工程肌肉

如果你看完之后能做到这两点,本文目的就达成了:

  1. 看到任意一个 Go Web 项目,能立刻指出它的 handler / service / repository 在哪、做得对不对;
  2. 学习 tRPC-Go / Kitex / Kratos 这类框架时,知道它们到底替你封装了哪几层,从此不再是黑盒。

📌 完整源码见仓库 library/ 目录,按 LEARNING.md 的 9 步法配套阅读,效果最佳。

觉得本文有帮助,欢迎点赞 / 收藏 / 关注,后续会更新「给本项目接 MySQL」「改造为 tRPC-Go 服务」两篇进阶。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐