CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Go 语言入门/课时/综合练习
RawBlame
课时目录12 节

Go 语言入门

  1. 01Go 是什么
  2. 02基础语法与类型
  3. 03数组、切片与 map
  4. 04结构体与接口
  5. 05模块与包管理
  6. 06并发模型
  7. 07错误处理
  8. 08泛型入门
  9. 09I/O 与 CLI
  10. 10Web 与数据库
  11. 11测试与基准
  12. 12综合练习

综合练习

小型 REST API + 单元测试 + go mod 发布结构。

40 分钟

综合练习

这一课你将能做到

  1. 从零搭出规范目录结构的多文件 Go 项目(模块、internal、接口、分层)
  2. 把前 11 课串成一次真实交付:切片/map、接口与 mock、并发锁、错误链、JSON、net/http、表驱动测试
  3. 用 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 课)

internal/api/handler.go:

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 组装 + 优雅关停

main.go:

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 启动,没有运行时依赖。

讲清楚要点

  1. 接口定义在消费方:api 包声明 Store 接口,store 包只交出实现。好处是接口恰好够用(只有 4 个方法),且数据库实现可以随时替换——这就是第 4 课「隐式实现」的工程化应用。
  2. 分层依赖单向流动:main → api → store,上层 import 下层,绝无反向;internal 保证外部项目无法越界依赖。
  3. 错误跨层翻译:store 抛哨兵(ErrNotFound),api 判断后转 HTTP 状态码。业务层与传输层的错误语言互不污染。
  4. 共享状态集中加锁:所有对 map 的访问都过 MemoryStore 的 Mutex,handler 层零锁——锁的下沉让并发正确性有了单一收口。
  5. 优雅关停是服务标配: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

卡点急救

  1. connect: connection refused:服务没起来或端口不对;看 main 的启动日志,用 netstat -ano | findstr 8080 查占用。
  2. 测试报 DATA RACE:按报告给出的两个 goroutine 位置检查锁范围;确认所有 map 访问都过了 Mutex。
  3. PATCH 返回 404 但 id 明明存在:PathValue 取的是字符串,Atoi 失败/越界都进不了业务;打印 r.PathValue("id") 排查。
  4. 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)。

自测三问

  1. 为什么 Store 接口写在 api 包而不是 store 包?如果接口有 20 个方法说明什么问题?
  2. Toggle 里为什么修改 t 之后必须写回 map?画出「取出 → 修改 → 写回」之间另一 goroutine 插入的竞争场景。
  3. 优雅关停的完整链路是什么?http.ErrServerClosed 为什么不能当错误处理?

小结一句话

一个小项目串起全部 12 课:目录与模块定边界、接口定依赖方向、锁定并发、哨兵错误定协议、表驱动测试定质量——工程能力 = 把知识点装配成可交付物的能力。

延伸

  • 官方教程:编写 Web 应用(wiki)
  • Go 项目布局参考
  • 毕业方向:gin/gorm 深入、database/sql 版 SQLStore、Docker 部署、pprof 性能分析——本课程的终点,是你 Go 实战路线图的起点。
加载本节练习…
← 上一课测试与基准