综合练习
这一课你将能做到
- 从零搭出规范目录结构的多文件 Go 项目(模块、internal、接口、分层)
- 把前 11 课串成一次真实交付:切片/map、接口与 mock、并发锁、错误链、JSON、net/http、表驱动测试
- 用
go build 产出可部署二进制,跑通全部测试并理解优雅关停
为什么要学
单点知识都学过了,但「把它们装进同一个项目」是另一项能力:包怎么分、依赖朝哪个方向流、错误在哪里包装、锁加在哪一层——这些工程决策正是初级和熟练开发者的分水岭。
本课做一个内存版 Todo REST API(体积小到一页能放下,五脏俱全到能过面试:分层、接口抽象、并发安全、错误处理、HTTP、测试、构建一个不少)。做完它,你的简历上就有了第一个能拿得出手的 Go 项目。
先建立图像
todo-api/
├── go.mod module example.com/todo-api
├── main.go 组装:store + handler + server
├── internal/
│ ├── store/store.go 存储层:Store 接口 + 内存实现(map + Mutex)
│ └── api/handler.go HTTP 层:路由、JSON、错误转状态码
└── internal/store/store_test.go 表驱动测试
请求流转:
POST /todos → api.Handler → store.Store 接口 → MemoryStore(加锁写 map)
│ 依赖倒置:api 只认识接口,不认识具体实现
▼ 未来把 MemoryStore 换成数据库实现,api 一行不改
两条设计决策提前说明:接口定义在消费方(api 包声明自己需要什么,而不是 store 强塞一个接口出来——Go 惯例);internal 目录对外完全隐藏实现(第 5 课)。
跟着做
步骤 0:初始化项目
$ mkdir todo-api && cd todo-api
$ go mod init example.com/todo-api
$ mkdir internal/store internal/api
步骤 1:存储层——接口 + 内存实现(第 3/4/6/7 课全部登场)
internal/store/store.go:
package store
import (
"errors"
"sort"
"sync"
)
// ErrNotFound 是哨兵错误:api 层据此转 404
var ErrNotFound = errors.New("todo 不存在")
type Todo struct {
ID int `json:"id"`
Text string `json:"text"`
Done bool `json:"done"`
}
// Store 是消费方(api 包)视角的最小接口
type Store interface {
Add(text string) Todo
List() []Todo
Get(id int) (Todo, error)
Toggle(id int) (Todo, error)
}
// MemoryStore:map + 互斥锁的线程安全实现
type MemoryStore struct {
mu sync.Mutex
todos map[int]Todo
next int
}
func NewMemoryStore() *MemoryStore {
return &MemoryStore{todos: make(map[int]Todo), next: 1}
}
func (s *MemoryStore) Add(text string) Todo {
s.mu.Lock()
defer s.mu.Unlock()
t := Todo{ID: s.next, Text: text}
s.todos[t.ID] = t
s.next++
return t
}
func (s *MemoryStore) List() []Todo {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Todo, 0, len(s.todos))
for _, t := range s.todos {
out = append(out, t)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func (s *MemoryStore) Get(id int) (Todo, error) {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.todos[id] // comma-ok:第 3 课
if !ok {
return Todo{}, ErrNotFound
}
return t, nil
}
func (s *MemoryStore) Toggle(id int) (Todo, error) {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.todos[id]
if !ok {
return Todo{}, ErrNotFound
}
t.Done = !t.Done
s.todos[id] = t // map 的 value 是拷贝,必须写回
return t, nil
}
这里集中了四个高频考点:map 的 comma-ok 与 value 写回、WaitGroup 没出现但 Mutex 保护共享 map、哨兵错误、值拷贝语义。
步骤 2:HTTP 层——路由与错误转状态码(第 7/10 课)
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"example.com/todo-api/internal/store"
)
// Handler 只依赖 store.Store 接口——测试时可注入假实现
type Handler struct {
store store.Store
}
func New(s store.Store) *Handler { return &Handler{store: s} }
func (h *Handler) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /todos", h.create)
mux.HandleFunc("GET /todos", h.list)
mux.HandleFunc("GET /todos/{id}", h.get)
mux.HandleFunc("PATCH /todos/{id}/toggle", h.toggle)
return mux
}
func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
var body struct {
Text string `json:"text"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON")
return
}
if strings.TrimSpace(body.Text) == "" {
writeErr(w, http.StatusBadRequest, "text 不能为空")
return
}
t := h.store.Add(body.Text)
writeJSON(w, http.StatusCreated, t)
}
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.store.List())
}
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
t, err := h.store.Get(id)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, t)
}
func (h *Handler) toggle(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
t, err := h.store.Toggle(id)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, t)
}
// ---- 内部工具 ----
func parseID(w http.ResponseWriter, r *http.Request) (int, bool) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil || id < 1 {
writeErr(w, http.StatusBadRequest, "id 必须是正整数")
return 0, false
}
return id, true
}
func writeStoreErr(w http.ResponseWriter, err error) {
if errors.Is(err, store.ErrNotFound) { // 哨兵错误 → 404
writeErr(w, http.StatusNotFound, err.Error())
return
}
writeErr(w, http.StatusInternalServerError, "内部错误")
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
注意错误处理分层:store 返回哨兵 → api 判断后翻译成状态码 → 统一的 JSON 错误格式。业务错误永远不 panic。
步骤 3:main 组装 + 优雅关停
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"example.com/todo-api/internal/api"
"example.com/todo-api/internal/store"
)
func main() {
h := api.New(store.NewMemoryStore())
srv := &http.Server{
Addr: ":8080",
Handler: h.Routes(),
}
go func() { // 服务在 goroutine 里跑,main 负责监听退出信号
log.Println("todo-api 启动: http://localhost:8080")
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("服务异常退出: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop // Ctrl+C 到达
log.Println("正在关停...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil { // 排空存量请求再退出
log.Printf("强制退出: %v", err)
}
log.Println("已退出")
}
步骤 4:手动验证
$ go run .
$ curl -X POST localhost:8080/todos -d '{"text":"学完 Go 入门"}'
{"id":1,"text":"学完 Go 入门","done":false}
$ curl localhost:8080/todos
[{"id":1,"text":"学完 Go 入门","done":false}]
$ curl -X PATCH localhost:8080/todos/1/toggle
{"id":1,"text":"学完 Go 入门","done":true}
$ curl localhost:8080/todos/999
{"error":"todo 不存在"}
$ curl -X POST localhost:8080/todos -d '{"text":" "}'
{"error":"text 不能为空"}
步骤 5:表驱动测试(第 11 课)
internal/store/store_test.go:
package store
import (
"errors"
"sync"
"testing"
)
func TestGetAndToggle(t *testing.T) {
tests := []struct {
name string
id int
wantErr error
}{
{"存在的 id", 1, nil},
{"不存在的 id", 99, ErrNotFound},
}
s := NewMemoryStore()
s.Add("任务A")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
todo, err := s.Get(tt.id)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("Get(%d) err = %v, want %v", tt.id, err, tt.wantErr)
}
if tt.wantErr == nil && todo.Text != "任务A" {
t.Errorf("Get(%d).Text = %q, want 任务A", tt.id, todo.Text)
}
})
}
}
func TestConcurrentAdd(t *testing.T) {
s := NewMemoryStore()
var wg sync.WaitGroup
const n = 100
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
s.Add("并发任务")
}()
}
wg.Wait()
got := s.List()
if len(got) != n {
t.Errorf("并发写入后数量 = %d, want %d", len(got), n)
}
}
$ go test -race ./... # -race 必跑:验证锁真的加对了
ok example.com/todo-api/internal/store
TestConcurrentAdd 用 100 个 goroutine 并发写,配合 -race 同时验证第 6 课的锁与第 11 课的测试——没有 Mutex 这段测试会直接报竞态。
步骤 6:构建与交付
$ go build -o todo-api.exe .
$ go vet ./... && gofmt -l .
$ ./todo-api.exe # 单二进制直接运行
$ $env:GOOS="linux"; go build -o todo-api . # 交叉编译给服务器
交付物就是一个文件:扔上服务器、./todo-api 启动,没有运行时依赖。
讲清楚要点
- 接口定义在消费方:api 包声明 Store 接口,store 包只交出实现。好处是接口恰好够用(只有 4 个方法),且数据库实现可以随时替换——这就是第 4 课「隐式实现」的工程化应用。
- 分层依赖单向流动:main → api → store,上层 import 下层,绝无反向;
internal 保证外部项目无法越界依赖。
- 错误跨层翻译:store 抛哨兵(ErrNotFound),api 判断后转 HTTP 状态码。业务层与传输层的错误语言互不污染。
- 共享状态集中加锁:所有对 map 的访问都过 MemoryStore 的 Mutex,handler 层零锁——锁的下沉让并发正确性有了单一收口。
- 优雅关停是服务标配:signal 捕获 → srv.Shutdown(ctx) 排空请求 → 超时兜底强制退出;
http.ErrServerClosed 是正常退出信号要显式放行。
你可能会懵的地方
-
为什么接口不放 store 包里让实现方自己带?
Go 惯例「消费者定义接口」:api 只需要 4 个方法,就只声明 4 个;store 未来多出 10 个方法也不影响 api 的依赖面。反过来把胖接口放实现方,会造成 import 纠缠与巨型 mock。
-
内存 store 重启就丢数据,生产怎么办?
正是接口的价值:再写一个 SQLStore(组合 database/sql)实现同样 4 个方法,main 里换一行构造函数,api 层零改动。这就是第 12 课练习留给你的作业。
-
为什么 store 每个方法都要加锁,List 也要?
Map 并发读写会触发 fatal error;List 遍历时若另一 goroutine 在写同样出事。Go 的锁粒度以「不可分割的状态变更」为界,读写成对加是入门期最安全的策略。
-
curl 不带 Content-Type 也能解析成功?
Decode 只认 body 内容不强制头;生产 API 建议校验 Content-Type: application/json 并拒绝其他类型,防误用与 CSRF 类问题。
-
Shutdown 和直接 Ctrl+C 杀进程有什么区别?
Shutdown 停止接收新请求、等存量请求完成(限时);kill -9 会掐断在途请求,客户端收到连接重置。有状态写入的服务必须优雅关停。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 所有代码塞 main.go | 不可测、不可换实现 | main 只组装,业务进 internal 包 |
| handler 直接持有 map 并改 | 绕过锁,并发崩溃 | 只通过 Store 接口访问 |
| Toggle 改完 t 不写回 map | 改的是拷贝,状态不变 | s.todos[id] = t 写回 |
| handler 里 panic 当 404 用 | 整个进程被炸掉 | 错误 → 状态码,panic 只留给 bug |
测试不带 -race 就提交 | 竞态漏检上线爆雷 | CI 固定 go test -race ./... |
| ListenAndServe 放 main 直接调 | 收不到关停信号 | 放 goroutine + signal + Shutdown |
卡点急救
connect: connection refused:服务没起来或端口不对;看 main 的启动日志,用 netstat -ano | findstr 8080 查占用。
- 测试报 DATA RACE:按报告给出的两个 goroutine 位置检查锁范围;确认所有 map 访问都过了 Mutex。
- PATCH 返回 404 但 id 明明存在:PathValue 取的是字符串,Atoi 失败/越界都进不了业务;打印 r.PathValue("id") 排查。
- gofmt -l 输出了文件名:该文件格式不规范,
gofmt -w 修复后再提交。
面试常问
- 介绍一下这个项目(面试答法框架):一句话定位(内存版 Todo REST API)→ 架构(api/store 分层、接口解耦)→ 技术点(并发安全 map、哨兵错误转 404、优雅关停、表驱动 + race 测试)→ 演进方向(SQLStore、context 超时、Docker 部署)。控制在 90 秒。
- 如何把内存存储换成数据库而不改业务代码?
Store 接口已在消费方定义,新增 SQLStore 实现同接口,main 替换构造函数即可;测试里继续用内存实现,速度不变。
- 如何保证并发正确性?怎么验证?
共享状态统一收口到带锁的 store;验证手段是
go test -race + 并发表驱动测试,必要时 pprof 观察 goroutine 数量。
- 如果 QPS 上涨,先优化哪里?
先测后改:benchmark 定位热点;常见方向是把 Mutex 换读写锁(读多写少)、分片锁降低争用、预热与对象复用减少分配、以及把热点数据挪出进程内存(Redis)。
自测三问
- 为什么 Store 接口写在 api 包而不是 store 包?如果接口有 20 个方法说明什么问题?
Toggle 里为什么修改 t 之后必须写回 map?画出「取出 → 修改 → 写回」之间另一 goroutine 插入的竞争场景。
- 优雅关停的完整链路是什么?
http.ErrServerClosed 为什么不能当错误处理?
小结一句话
一个小项目串起全部 12 课:目录与模块定边界、接口定依赖方向、锁定并发、哨兵错误定协议、表驱动测试定质量——工程能力 = 把知识点装配成可交付物的能力。
延伸