主题
第12章 实战项目:迷你 Web 服务(Todo API)
📚 学习目标:把前 11 章的知识串起来,做一个可运行、能扩展的 Todo 服务。
🎯 项目目标
写一个 Todo API,对外提供 RESTful 接口:
| 方法 | 路径 | 功能 |
|---|---|---|
| GET | /todos | 列出所有 todo(支持分页、筛选) |
| POST | /todos | 创建一个 todo |
| GET | /todos/{id} | 获取单个 todo |
| PUT | /todos/{id} | 更新 todo |
| DELETE | /todos/{id} | 删除 todo |
| POST | /todos/{id}/done | 标记完成 |
附带:
- 中间件:日志、CORS、panic recovery
- 优雅关闭
- 配置文件
- 单元测试
🏗 项目结构
miniweb/
├── cmd/
│ └── server/
│ └── main.go # 入口
├── internal/
│ ├── handler/ # HTTP handler 层
│ │ ├── todo.go
│ │ └── todo_test.go
│ ├── service/ # 业务逻辑
│ │ └── todo.go
│ ├── repository/ # 数据存储
│ │ └── memory.go
│ ├── model/
│ │ └── todo.go
│ └── middleware/
│ ├── logger.go
│ ├── cors.go
│ └── recovery.go
├── go.mod
└── README.md这是 Go 工程通行的 三层架构:handler → service → repository。
1️⃣ Model(数据模型)
go
// internal/model/todo.go
package model
import "time"
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
CreatedAt time.Time `json:"created_at"`
}2️⃣ Repository(存储层)
最简单的实现:内存存储。后续可以替换成 MySQL、Redis、etcd…
go
// internal/repository/memory.go
type MemoryRepo struct {
mu sync.RWMutex
data map[int]*model.Todo
nextID int
}
func (r *MemoryRepo) Create(t *model.Todo) (*model.Todo, error) { ... }
func (r *MemoryRepo) Get(id int) (*model.Todo, error) { ... }
func (r *MemoryRepo) List() []*model.Todo { ... }
func (r *MemoryRepo) Update(t *model.Todo) error { ... }
func (r *MemoryRepo) Delete(id int) error { ... }关键:所有方法都用
sync.RWMutex保护——这是并发安全的基础。
3️⃣ Service(业务层)
业务规则集中处理。比如"标题不能为空"、"已完成的不能再次完成"。
go
type TodoService struct {
repo Repository
}
func (s *TodoService) Create(title string) (*model.Todo, error) {
title = strings.TrimSpace(title)
if title == "" {
return nil, ErrTitleEmpty
}
return s.repo.Create(&model.Todo{
Title: title,
CreatedAt: time.Now(),
})
}service 层只依赖 Repository 接口,不依赖具体实现。这样换 DB 时不用动业务代码。
4️⃣ Handler(HTTP 层)
go
type TodoHandler struct {
svc *service.TodoService
}
func (h *TodoHandler) Create(w http.ResponseWriter, r *http.Request) {
var req struct{ Title string `json:"title"` }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, 400, map[string]string{"error": "invalid json"})
return
}
t, err := h.svc.Create(req.Title)
if err != nil {
writeJSON(w, 400, map[string]string{"error": err.Error()})
return
}
writeJSON(w, 201, t)
}5️⃣ 中间件(洋葱模型)
5.1 logger
go
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}5.2 recovery(捕获 panic 不让进程崩)
go
func Recovery(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: %v", rec)
http.Error(w, "internal server error", 500)
}
}()
next.ServeHTTP(w, r)
})
}5.3 CORS
go
func CORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(204)
return
}
next.ServeHTTP(w, r)
})
}6️⃣ main.go:组装一切
go
func main() {
flag.Parse()
repo := repository.NewMemoryRepo()
svc := service.NewTodoService(repo)
handler := handler.NewTodoHandler(svc)
mux := http.NewServeMux()
handler.Register(mux)
chain := middleware.CORS(middleware.Logger(middleware.Recovery(mux)))
srv := &http.Server{
Addr: ":" + *port,
Handler: chain,
}
// 优雅关闭
go func() {
log.Printf("listening on %s", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
log.Println("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(ctx)
log.Println("bye")
}🚀 运行 & 测试
bash
cd 12-project/miniweb
# 1. 启动服务
go run ./cmd/server
# 2. 创建 todo
curl -X POST http://localhost:8080/todos \
-H "Content-Type: application/json" \
-d '{"title":"学 Go"}'
# → {"id":1,"title":"学 Go","done":false,"created_at":"..."}
# 3. 列出
curl http://localhost:8080/todos
# 4. 标完成
curl -X POST http://localhost:8080/todos/1/done
# 5. 删除
curl -X DELETE http://localhost:8080/todos/1
# 6. 跑测试
go test ./...
# 7. 优雅关闭:Ctrl+C📊 架构图
┌─────────────────────────────────────────────────────────────┐
│ Client (curl / browser / mobile) │
└─────────────────────┬───────────────────────────────────────┘
│ HTTP
┌─────────────────────▼───────────────────────────────────────┐
│ cmd/server/main.go │
│ ↓ │
│ Middleware Chain: CORS → Logger → Recovery │
│ ↓ │
│ internal/handler (HTTP I/O,参数校验、状态码) │
│ ↓ │
│ internal/service (业务规则、事务、组合) │
│ ↓ │
│ internal/repository (数据存储抽象) │
│ ↓ │
│ ┌───────────┐ ┌───────────┐ ┌──────────┐ │
│ │ Memory │ │ MySQL │ │ Redis │ ← 可替换 │
│ └───────────┘ └───────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘🧠 设计原则总结
- 依赖倒置:service 依赖
Repository接口而不是具体实现 → 换存储不影响业务 - 单一职责:handler 只管 HTTP 协议,service 只管业务,repo 只管存储
- 错误向上传:每层只处理自己关心的错误,剩下的
return err - 并发安全:repo 用读写锁保护共享 map
- 优雅关闭:捕获 SIGTERM,给已有请求 10 秒善后
- 可观测性:日志、错误信息、请求耗时都打到 stdout
- 可测试:service 可单测(mock repo);handler 可用
httptest
📁 本章配套
- 📄 完整代码:
miniweb/子目录 - 🌐 demo.html:在线接口测试器
- ❓ interview.md:项目相关面试题
- 🖼 diagrams/:分层架构图
🎓 通关恭喜!
读到这里,你已经完成了:
- Go 语法基础(变量、控制流、函数、集合、struct、接口、并发、错误处理)
- 工程能力(包管理、标准库、并发模型、HTTP 服务)
- 实战项目(三层架构、中间件、优雅关闭)
下一步建议:
- 把这个项目的
MemoryRepo改成 MySQL 或 Postgres - 引入
gin或chi框架对比标准库 - 学习微服务(gRPC + 服务注册)
- 阅读优秀开源项目:
docker、kubernetes、prometheus、etcd、gin、cobra、viper
🎉 Happy hacking with Go!