--- id: plan.week01 title: "第 1 周:Go 基础 + Gin 写 REST 接口" type: week domain: plan stack: [go, gin, git] week: [1] est_hours: 12 order: 1 --- # 第 1 周:Go 基础 + Gin 写 REST 接口 > 第一段“Go 全栈基础”(第 1~3 周)的第 1 周。总纲见 `README.md`,下周 `week02-db-auth.md`。 > Go 在本计划里是**加分项**,只学到“能独立写出一个带数据库、鉴权、缓存、部署的后端”为止,不追求深挖。 **本周目标**:掌握 Go 的核心语法,用 Gin 写出“任务清单”的增删改查接口(数据先放内存),并用你熟悉的前端接上。 **每天投入**:1.5~2 小时。代码可以让 AI 生成,但每一行你都要能讲清楚。 **参考资料** - Go 官方教程(中文):https://tour.go-zh.org/ - Go by Example(中文):https://gobyexample-cn.github.io/ - Gin 文档(中文):https://gin-gonic.com/zh-cn/docs/ ## 本周最终目录 ```text go-learning/ ├── go.mod ├── day1/main.go ├── day2/main.go ├── day3/ │ ├── main.go │ └── calc_test.go todo-api/ ├── backend/ │ ├── go.mod │ ├── main.go # 入口:读配置、组装依赖、优雅关机 │ ├── api.http # 手动测试接口 │ └── internal/ │ ├── model/task.go # Task 结构体和校验 │ ├── store/store.go # TaskStore 接口 + ErrNotFound │ ├── store/memory_store.go # 内存实现(并发安全) │ ├── store/memory_store_test.go │ ├── handler/response.go # 统一响应格式 │ ├── handler/task_handler.go │ ├── handler/task_handler_test.go │ ├── middleware/logger.go │ ├── middleware/cors.go │ └── router/router.go └── frontend/ # Vite + Vue 或 React(第 7 周还会系统查漏) ``` --- ## 第 1 天:环境、变量、函数和错误返回 ### 学习内容 **1. 安装和模块** - 说明:Go 用“模块(module)”管理依赖,`go.mod` 相当于前端的 `package.json`。每个目录是一个包(package),能运行的程序必须是 `package main` 并有 `main()` 函数。 - 案例:新建练习目录,初始化模块。 ```bash # Ubuntu:建议从 https://go.dev/dl/ 下载最新版,apt 源里的版本可能偏旧 go version mkdir -p go-learning/day1 && cd go-learning go mod init go-learning ``` **2. 变量、常量、基本类型、格式化输出** - 说明:`var` 显式声明,`:=` 在函数内声明并推断类型;声明了却没使用的变量会**编译报错**(Go 不允许“死代码”)。类型之间不会自动转换,`int` 和 `float64` 相加要手动转。 - 案例:打印一段自我介绍,并计算平均工作时长。 ```go package main import "fmt" const city = "上海" func main() { var name string = "王士奎" years := 7 stack := []string{"Vue", "React", "TypeScript"} totalHours := 7 * 2000 avg := float64(totalHours) / float64(years) // 必须显式转换 fmt.Printf("我是%s,在%s,前端 %d 年,技术栈 %v\n", name, city, years, stack) fmt.Printf("平均每年 %.1f 小时\n", avg) } ``` - 常见坑:`:=` 只能在函数里用;在 `if` 块里用 `:=` 会声明一个新变量,遮住外面的同名变量。 **3. 函数、多返回值和 error** - 说明:Go 没有 `try/catch`,可能失败的函数把 `error` 作为最后一个返回值,调用方立刻检查。这让“哪里会出错”一眼可见。 - 案例:除法函数,除数为 0 时返回错误。 ```go package main import ( "errors" "fmt" ) func divide(a, b float64) (float64, error) { if b == 0 { return 0, errors.New("除数不能为 0") } return a / b, nil } func main() { for _, b := range []float64{2, 0} { v, err := divide(10, b) if err != nil { fmt.Println("出错:", err) continue } fmt.Println("结果:", v) } } ``` - 面试常问:“Go 为什么不用异常?”——错误是普通值,必须显式处理,控制流清楚;真正不可恢复的情况才用 `panic`。 **4. defer 和闭包** - 说明:`defer` 把调用推迟到函数返回前执行,多个 `defer` 后进先出,常用来关闭文件、释放锁。闭包和 JS 一样能捕获外部变量。 - 案例:计时器。 ```go package main import ( "fmt" "time" ) func timer(name string) func() { start := time.Now() return func() { fmt.Printf("%s 用时 %v\n", name, time.Since(start)) } } func work() { defer timer("work")() // 注意最后的 ():立刻拿到返回的函数,推迟执行它 time.Sleep(50 * time.Millisecond) } func main() { defer fmt.Println("第 3 个打印") defer fmt.Println("第 2 个打印") work() } ``` ### 要建的文件 - `go-learning/go.mod` - `go-learning/day1/main.go`:合并上面 2~4 的示例,再加一个 FizzBuzz(1 到 30)。 ### 运行方式 ```bash cd go-learning && go run ./day1 ``` ### 完成标准 - 能说清 `var a int = 1` 和 `a := 1` 的区别,以及为什么没用的变量会编译报错。 - 能说清 Go 的错误处理和 JS `try/catch` 的差别。 --- ## 第 2 天:切片、map、结构体、方法、接口 ### 学习内容 **1. 切片和 map** - 说明:切片(slice)是对底层数组的“视图”,包含指针、长度、容量;`append` 超过容量时会分配新数组。map 是哈希表,读不存在的 key 返回零值,用 `v, ok := m[k]` 判断是否存在。 - 案例:统计单词出现次数、演示子切片共享底层数组。 ```go package main import ( "fmt" "strings" ) func wordCount(text string) map[string]int { counts := make(map[string]int) for _, w := range strings.Fields(strings.ToLower(text)) { counts[w]++ } return counts } func main() { fmt.Println(wordCount("Go is fun and go is fast")) nums := []int{1, 2, 3, 4, 5} sub := nums[1:3] // [2 3],和 nums 共用底层数组 sub[0] = 99 fmt.Println(nums) // [1 99 3 4 5] safe := append([]int(nil), nums[1:3]...) // 复制一份,互不影响 safe[0] = 0 fmt.Println(nums, safe) if _, ok := wordCount("a b")["c"]; !ok { fmt.Println("c 不存在") } } ``` - 面试常问:切片的底层结构;`append` 什么时候扩容;为什么函数里 `append` 后要把结果返回。 **2. 结构体和方法** - 说明:结构体类似 TS 的 `interface` + 数据;方法是带“接收者”的函数。要修改结构体自身用**指针接收者** `(t *Task)`,只读可以用值接收者。结构体标签 `json:"title"` 控制 JSON 字段名。 - 案例:任务结构体,标记完成并转成 JSON。 ```go package main import ( "encoding/json" "fmt" "time" ) type Task struct { ID int `json:"id"` Title string `json:"title"` Done bool `json:"done"` CreatedAt time.Time `json:"createdAt"` } func (t *Task) MarkDone() { t.Done = true } func (t Task) String() string { mark := " " if t.Done { mark = "x" } return fmt.Sprintf("[%s] %d. %s", mark, t.ID, t.Title) } func main() { t := Task{ID: 1, Title: "学习 Go", CreatedAt: time.Now()} t.MarkDone() fmt.Println(t) b, _ := json.MarshalIndent(t, "", " ") fmt.Println(string(b)) } ``` **3. 接口(隐式实现)** - 说明:Go 的接口不需要写 `implements`,只要方法集对得上就算实现。好处:存储层可以先用内存实现,以后换数据库实现,调用方代码不动。 - 案例:`Store` 接口和内存实现。 ```go package main import "fmt" type Store interface { Add(title string) int List() []string } type MemoryStore struct{ items []string } func (m *MemoryStore) Add(title string) int { m.items = append(m.items, title) return len(m.items) } func (m *MemoryStore) List() []string { return m.items } func printAll(s Store) { for i, it := range s.List() { fmt.Println(i+1, it) } } func main() { var s Store = &MemoryStore{} // *MemoryStore 实现了 Store s.Add("学 Gin") s.Add("学 PostgreSQL") printAll(s) } ``` - 常见坑:方法是指针接收者时,只有指针类型 `*MemoryStore` 实现接口,写成 `MemoryStore{}` 会编译报错。 ### 要建的文件 - `go-learning/day2/main.go`:合并以上示例,再写一个 `Filter(nums []int, fn func(int) bool) []int`(类似 JS 的 `filter`)。 ### 运行方式 ```bash go run ./day2 ``` ### 完成标准 - 能解释切片共享底层数组的现象,以及什么时候用指针接收者。 --- ## 第 3 天:错误包装、并发、单元测试 ### 学习内容 **1. 错误包装和判断** - 说明:用 `fmt.Errorf("...: %w", err)` 给错误加上下文并保留原错误;`errors.Is` 判断是不是某个错误,`errors.As` 取出特定类型的错误。 - 案例:查找任务,找不到时返回包装后的 `ErrNotFound`,上层据此返回 404。 ```go package main import ( "errors" "fmt" ) var ErrNotFound = errors.New("not found") type ValidationError struct{ Field, Reason string } func (e *ValidationError) Error() string { return e.Field + ": " + e.Reason } func findTask(id int) error { if id <= 0 { return &ValidationError{Field: "id", Reason: "必须大于 0"} } return fmt.Errorf("查找任务 %d: %w", id, ErrNotFound) } func main() { err := findTask(42) fmt.Println(err, errors.Is(err, ErrNotFound)) // true var ve *ValidationError if errors.As(findTask(-1), &ve) { fmt.Println("参数错误,字段:", ve.Field) } } ``` **2. goroutine、channel、WaitGroup、Mutex** - 说明:`go f()` 启动一个轻量级协程;`channel` 在协程之间传数据;`sync.WaitGroup` 等一组协程结束;`sync.Mutex` 保护共享数据。和 JS 的区别:JS 是单线程事件循环,Go 的协程会**真正并行**,共享数据必须加锁。 - 案例:并发“抓取”多个网址(用 sleep 模拟),汇总结果。 ```go package main import ( "fmt" "sync" "time" ) func fetch(url string) int { time.Sleep(100 * time.Millisecond) // 模拟网络请求 return len(url) } func main() { urls := []string{"a.com", "bb.com", "ccc.com"} results := make(map[string]int) var mu sync.Mutex var wg sync.WaitGroup start := time.Now() for _, u := range urls { wg.Add(1) go func(u string) { defer wg.Done() n := fetch(u) mu.Lock() results[u] = n mu.Unlock() }(u) } wg.Wait() fmt.Println(results, "耗时约", time.Since(start).Round(10*time.Millisecond)) // channel 版:生产者 → 消费者 ch := make(chan int) go func() { defer close(ch) for i := 1; i <= 3; i++ { ch <- i * i } }() for v := range ch { fmt.Println("收到", v) } } ``` - 面试常问:goroutine 和线程的区别;无缓冲和有缓冲 channel 的区别;什么是数据竞争,怎么用 `go run -race` 发现。 **3. 表格驱动测试** - 说明:`go test` 运行 `_test.go` 文件里的 `TestXxx` 函数。“表格驱动”把多组输入和期望写成切片循环测试,是 Go 社区的惯例。 - 案例:测试除法函数。 ```go // 文件:day3/calc.go package main import "errors" func Divide(a, b float64) (float64, error) { if b == 0 { return 0, errors.New("division by zero") } return a / b, nil } ``` ```go // 文件:day3/calc_test.go package main import "testing" func TestDivide(t *testing.T) { cases := []struct { name string a, b float64 want float64 wantErr bool }{ {"正常", 10, 2, 5, false}, {"除数为 0", 1, 0, 0, true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got, err := Divide(c.a, c.b) if (err != nil) != c.wantErr { t.Fatalf("err = %v, wantErr %v", err, c.wantErr) } if got != c.want { t.Errorf("got %v, want %v", got, c.want) } }) } } ``` ### 要建的文件 - `go-learning/day3/main.go`:错误包装和并发示例。 - `go-learning/day3/calc.go`、`calc_test.go`。 ### 运行方式 ```bash go run -race ./day3 go test ./day3 -v ``` ### 完成标准 - `go test` 通过,`-race` 没有警告;能说出 goroutine 和 JS `Promise` 的区别。 --- ## 第 4 天:从 net/http 到 Gin ### 学习内容 **1. 标准库 HTTP 服务** - 说明:Go 标准库就能写 HTTP 服务。理解 `Handler`、`ResponseWriter`、`Request` 之后,再看框架就知道它省了什么。 - 案例:最小服务器。 ```go package main import ( "encoding/json" "net/http" ) func main() { http.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) }) http.ListenAndServe(":8080", nil) } ``` (Go 1.22 起,路由模式支持写方法和路径参数,如 `"GET /api/tasks/{id}"`。) **2. Gin 路由、参数、请求体绑定** - 说明:Gin 提供路由分组、参数解析、请求体绑定和校验(`binding:"required"`)、中间件。招聘里 Go 框架没有统一要求,Gin 是国内用得多、概念少的一个。 - 案例:任务接口的骨架。 ```bash cd todo-api/backend && go mod init todo-api && go get github.com/gin-gonic/gin ``` ```go package main import ( "net/http" "strconv" "github.com/gin-gonic/gin" ) type CreateTaskRequest struct { Title string `json:"title" binding:"required,max=100"` } func main() { r := gin.Default() api := r.Group("/api") api.GET("/tasks/:id", func(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"message": "id 必须是数字"}) return } done := c.Query("done") // 查询参数 ?done=true c.JSON(http.StatusOK, gin.H{"id": id, "doneFilter": done}) }) api.POST("/tasks", func(c *gin.Context) { var req CreateTaskRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"title": req.Title}) }) r.Run(":8080") } ``` - 常见坑:处理完错误后忘了 `return`,会继续往下执行并写第二次响应。 **3. REST 设计和状态码** - 说明:资源用名词复数(`/tasks`),动作用 HTTP 方法;常用状态码:200 成功、201 已创建、204 无内容、400 参数错、401 未登录、403 无权限、404 不存在、409 冲突、429 请求过多、500 服务器错误。 - 案例:本周要实现的接口: | 方法 | 路径 | 说明 | 成功状态码 | | --- | --- | --- | --- | | GET | `/api/health` | 健康检查 | 200 | | GET | `/api/tasks?done=true` | 列表,可过滤 | 200 | | GET | `/api/tasks/:id` | 详情 | 200 | | POST | `/api/tasks` | 新增 | 201 | | PATCH | `/api/tasks/:id` | 修改标题或完成状态 | 200 | | DELETE | `/api/tasks/:id` | 删除 | 204 | **易错点 / 面试常问(补充)** - 易错点:Gin 的 `c.JSON` 之后要 `return`,否则后面的代码还会继续执行并可能再次写响应(日志里会出现 `headers were already written`)。 - 易错点:`ShouldBindJSON` 校验失败要自己返回 400;`BindJSON` 会自动写 400,但之后不能再改状态码,所以一般统一用 `ShouldBind*`。 - 易错点:路由参数 `c.Param("id")` 是字符串,要用 `strconv.Atoi` 转换并处理错误。 - 面试常问:`net/http` 和 Gin 的关系(Gin 基于标准库的 `http.Handler`,增加了路由树、中间件链、参数绑定);RESTful 的资源命名和状态码怎么选(创建 201、删除 204、参数错 400、未登录 401、无权限 403、不存在 404、冲突 409)。 ### 要建的文件 - `todo-api/backend/main.go`:先写上面的骨架跑通。 - `todo-api/backend/api.http`:每个接口一条请求(VS Code / Cursor 装 REST Client 插件即可点击发送)。 ```http ### 健康检查 GET http://localhost:8080/api/health ### 新建任务 POST http://localhost:8080/api/tasks Content-Type: application/json {"title": "学习 Gin"} ``` ### 运行方式 ```bash go run . curl -s localhost:8080/api/tasks/1?done=true ``` ### 完成标准 - 能说清一个请求从进入 Gin 到返回经过了哪些步骤。 --- ## 第 5 天:分层:model / store / handler ### 学习内容 **1. 为什么分层** - 说明:handler 只管“解析请求、返回响应”;store 只管“数据存哪”;model 放数据结构和校验。下周把内存存储换成数据库时,只需要新写一个 store 实现。 - 案例:`TaskStore` 接口 + 并发安全的内存实现。 ```go // 文件:internal/model/task.go package model import ( "errors" "time" "unicode/utf8" ) type Task struct { ID int64 `json:"id"` Title string `json:"title"` Done bool `json:"done"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } func (t Task) Validate() error { n := utf8.RuneCountInString(t.Title) if n == 0 || n > 100 { return errors.New("标题长度必须在 1~100 之间") } return nil } ``` ```go // 文件:internal/store/store.go package store import ( "context" "errors" "todo-api/internal/model" ) var ErrNotFound = errors.New("task not found") // 每个方法第一个参数都是 context:请求被取消或超时时,下游(比如数据库查询)能及时停下。 // 内存实现用不到它,但下周换成数据库时就很重要,所以现在就把接口定好。 type TaskStore interface { List(ctx context.Context, done *bool) ([]model.Task, error) Get(ctx context.Context, id int64) (model.Task, error) Create(ctx context.Context, t model.Task) (model.Task, error) Update(ctx context.Context, t model.Task) (model.Task, error) Delete(ctx context.Context, id int64) error } ``` ```go // 文件:internal/store/memory_store.go package store import ( "context" "sort" "sync" "time" "todo-api/internal/model" ) type MemoryStore struct { mu sync.RWMutex nextID int64 tasks map[int64]model.Task } func NewMemoryStore() *MemoryStore { return &MemoryStore{nextID: 1, tasks: map[int64]model.Task{}} } func (s *MemoryStore) List(_ context.Context, done *bool) ([]model.Task, error) { s.mu.RLock() defer s.mu.RUnlock() out := make([]model.Task, 0, len(s.tasks)) for _, t := range s.tasks { if done == nil || t.Done == *done { out = append(out, t) } } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out, nil } func (s *MemoryStore) Get(_ context.Context, id int64) (model.Task, error) { s.mu.RLock() defer s.mu.RUnlock() t, ok := s.tasks[id] if !ok { return model.Task{}, ErrNotFound } return t, nil } func (s *MemoryStore) Create(_ context.Context, t model.Task) (model.Task, error) { s.mu.Lock() defer s.mu.Unlock() now := time.Now() t.ID, t.CreatedAt, t.UpdatedAt = s.nextID, now, now s.nextID++ s.tasks[t.ID] = t return t, nil } func (s *MemoryStore) Update(_ context.Context, t model.Task) (model.Task, error) { s.mu.Lock() defer s.mu.Unlock() old, ok := s.tasks[t.ID] if !ok { return model.Task{}, ErrNotFound } t.CreatedAt, t.UpdatedAt = old.CreatedAt, time.Now() s.tasks[t.ID] = t return t, nil } func (s *MemoryStore) Delete(_ context.Context, id int64) error { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.tasks[id]; !ok { return ErrNotFound } delete(s.tasks, id) return nil } ``` - 面试常问:`Mutex` 和 `RWMutex` 的区别(读多写少用读写锁);`context` 是干什么的(传递取消信号、超时和请求范围的值)。 **2. 统一响应格式和 handler** - 说明:前后端约定统一结构 `{code, message, data}`,前端只需一处处理错误。`PATCH` 请求体里的字段用指针,才能区分“没传”和“传了 false/空串”。 - 案例:任务 handler 和路由(路由里用到的中间件第 6 天写;第 5 天可以先把 `r.Use(...)` 那行注释掉)。 ```go // 文件:internal/handler/response.go package handler import ( "net/http" "github.com/gin-gonic/gin" ) type Resp struct { Code int `json:"code"` Message string `json:"message"` Data any `json:"data"` } func OK(c *gin.Context, data any) { c.JSON(http.StatusOK, Resp{0, "ok", data}) } func Created(c *gin.Context, data any) { c.JSON(http.StatusCreated, Resp{0, "ok", data}) } func Fail(c *gin.Context, status, code int, msg string) { c.AbortWithStatusJSON(status, Resp{code, msg, nil}) } ``` ```go // 文件:internal/handler/task_handler.go package handler import ( "errors" "net/http" "strconv" "github.com/gin-gonic/gin" "todo-api/internal/model" "todo-api/internal/store" ) type TaskHandler struct{ Store store.TaskStore } type updateReq struct { Title *string `json:"title"` Done *bool `json:"done"` } func (h *TaskHandler) Create(c *gin.Context) { var req struct { Title string `json:"title"` } if err := c.ShouldBindJSON(&req); err != nil { Fail(c, http.StatusBadRequest, 40001, "请求体格式错误") return } t := model.Task{Title: req.Title} if err := t.Validate(); err != nil { Fail(c, http.StatusBadRequest, 40002, err.Error()) return } created, err := h.Store.Create(c.Request.Context(), t) if err != nil { Fail(c, http.StatusInternalServerError, 50000, "保存失败") return } Created(c, created) } func (h *TaskHandler) Update(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { Fail(c, http.StatusBadRequest, 40003, "id 不合法") return } var req updateReq if err := c.ShouldBindJSON(&req); err != nil { Fail(c, http.StatusBadRequest, 40001, "请求体格式错误") return } t, err := h.Store.Get(c.Request.Context(), id) if errors.Is(err, store.ErrNotFound) { Fail(c, http.StatusNotFound, 40401, "任务不存在") return } if req.Title != nil { t.Title = *req.Title } if req.Done != nil { t.Done = *req.Done } if err := t.Validate(); err != nil { Fail(c, http.StatusBadRequest, 40002, err.Error()) return } updated, err := h.Store.Update(c.Request.Context(), t) if err != nil { Fail(c, http.StatusInternalServerError, 50000, "保存失败") return } OK(c, updated) } func (h *TaskHandler) List(c *gin.Context) { var done *bool if v := c.Query("done"); v != "" { b, err := strconv.ParseBool(v) if err != nil { Fail(c, http.StatusBadRequest, 40004, "done 只能是 true 或 false") return } done = &b } tasks, err := h.Store.List(c.Request.Context(), done) if err != nil { Fail(c, http.StatusInternalServerError, 50000, "查询失败") return } OK(c, tasks) } func (h *TaskHandler) Get(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { Fail(c, http.StatusBadRequest, 40003, "id 不合法") return } t, err := h.Store.Get(c.Request.Context(), id) if errors.Is(err, store.ErrNotFound) { Fail(c, http.StatusNotFound, 40401, "任务不存在") return } OK(c, t) } func (h *TaskHandler) Delete(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { Fail(c, http.StatusBadRequest, 40003, "id 不合法") return } if err := h.Store.Delete(c.Request.Context(), id); errors.Is(err, store.ErrNotFound) { Fail(c, http.StatusNotFound, 40401, "任务不存在") return } c.Status(http.StatusNoContent) } ``` ```go // 文件:internal/router/router.go package router import ( "net/http" "github.com/gin-gonic/gin" "todo-api/internal/handler" "todo-api/internal/middleware" ) func New(h *handler.TaskHandler) *gin.Engine { r := gin.New() r.Use(gin.Recovery(), middleware.Logger(), middleware.CORS("http://localhost:5173")) api := r.Group("/api") api.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) api.GET("/tasks", h.List) api.GET("/tasks/:id", h.Get) api.POST("/tasks", h.Create) api.PATCH("/tasks/:id", h.Update) api.DELETE("/tasks/:id", h.Delete) return r } ``` ### 要建的文件 - `internal/model/task.go`、`internal/store/store.go`、`internal/store/memory_store.go` - `internal/store/memory_store_test.go`: ```go // 文件:internal/store/memory_store_test.go package store import ( "context" "sync" "testing" "todo-api/internal/model" ) func TestConcurrentCreate(t *testing.T) { s := NewMemoryStore() ctx := context.Background() var wg sync.WaitGroup for i := 0; i < 100; i++ { wg.Add(1) go func() { defer wg.Done() s.Create(ctx, model.Task{Title: "x"}) }() } wg.Wait() all, _ := s.List(ctx, nil) if len(all) != 100 { t.Fatalf("got %d tasks, want 100", len(all)) } } ``` - `internal/handler/response.go`、`internal/handler/task_handler.go`、`internal/router/router.go`:先自己写,再对照示例。 ### 运行方式 ```bash go test ./... -race go run . ``` ### 完成标准 - `api.http` 里每个接口的正常和出错情况(空标题、不存在的 id)都测过,状态码正确。 --- ## 第 6 天:中间件和接口测试 ### 学习内容 **1. 中间件** - 说明:中间件是包在 handler 外面的一层函数,`c.Next()` 之前的代码在请求进入时执行,之后的代码在响应返回时执行。日志、跨域、鉴权、限流、捕获 panic 都用它。 - 案例:请求日志 + 跨域。 ```go // 文件:internal/middleware/logger.go package middleware import ( "log/slog" "time" "github.com/gin-gonic/gin" ) func Logger() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() c.Next() slog.Info("request", "method", c.Request.Method, "path", c.FullPath(), "status", c.Writer.Status(), "cost", time.Since(start)) } } ``` ```go // 文件:internal/middleware/cors.go package middleware import ( "net/http" "github.com/gin-gonic/gin" ) func CORS(allowOrigin string) gin.HandlerFunc { return func(c *gin.Context) { h := c.Writer.Header() h.Set("Access-Control-Allow-Origin", allowOrigin) h.Set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS") h.Set("Access-Control-Allow-Headers", "Content-Type,Authorization") if c.Request.Method == http.MethodOptions { c.AbortWithStatus(http.StatusNoContent) // 预检请求直接返回 return } c.Next() } } ``` - 面试常问:跨域为什么会发生(浏览器同源策略)、什么是预检请求。 **2. 用 httptest 测接口** - 说明:`httptest.NewRecorder` 不用真正启动端口就能测试路由,速度快,可以放进 CI。 - 案例:创建任务返回 201,空标题返回 400。 ```go // 文件:internal/handler/task_handler_test.go package handler_test import ( "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" "todo-api/internal/handler" "todo-api/internal/router" "todo-api/internal/store" ) func setup() *gin.Engine { gin.SetMode(gin.TestMode) return router.New(&handler.TaskHandler{Store: store.NewMemoryStore()}) } func TestCreateTask(t *testing.T) { r := setup() cases := []struct { body string want int }{ {`{"title":"学 Gin"}`, http.StatusCreated}, {`{"title":""}`, http.StatusBadRequest}, {`not json`, http.StatusBadRequest}, } for _, c := range cases { w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/tasks", strings.NewReader(c.body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) if w.Code != c.want { t.Errorf("body=%s got %d want %d", c.body, w.Code, c.want) } } } ``` ### 要建的文件 - `internal/middleware/logger.go`、`cors.go`;把 `router.go` 里 `r.Use(...)` 那行打开。 - `internal/handler/task_handler_test.go`:再补“获取不存在返回 404”“删除后再获取 404”“按 done 过滤”。 ### 运行方式 ```bash go test ./... -v -race ``` ### 完成标准 - 测试全部通过;故意在某个接口里 `panic`,服务不挂,返回 500。 --- ## 第 7 天:配置、优雅关机、接前端 ### 学习内容 **1. 环境变量配置和优雅关机** - 说明:配置从环境变量读,不写死在代码里(部署时用 Docker 注入)。优雅关机:收到 `Ctrl+C` 或 `SIGTERM` 后不再接新请求,等处理中的请求完成再退出,避免部署时请求被掐断。 - 案例:`main.go` 完整版。 ```go // 文件:main.go package main import ( "context" "errors" "log/slog" "net/http" "os" "os/signal" "syscall" "time" "todo-api/internal/handler" "todo-api/internal/router" "todo-api/internal/store" ) func getenv(k, def string) string { if v := os.Getenv(k); v != "" { return v } return def } func main() { h := &handler.TaskHandler{Store: store.NewMemoryStore()} srv := &http.Server{Addr: ":" + getenv("PORT", "8080"), Handler: router.New(h)} go func() { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { slog.Error("listen", "err", err) os.Exit(1) } }() slog.Info("started", "addr", srv.Addr) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { slog.Error("shutdown", "err", err) } slog.Info("bye") } ``` **2. 前端接入(快速版,第 7 周系统查漏)** - 说明:前后端分离,前端用环境变量配置接口地址;统一封装请求,`code != 0` 时抛错。 - 案例:TypeScript 请求封装。 ```ts // 文件:frontend/src/api/tasks.ts export interface Task { id: number; title: string; done: boolean; createdAt: string; updatedAt: string } interface Resp { code: number; message: string; data: T } const BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8080/api' async function request(method: string, path: string, body?: unknown): Promise { const res = await fetch(BASE + path, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined, }) if (res.status === 204) return undefined as T const json = (await res.json()) as Resp if (json.code !== 0) throw new Error(json.message) return json.data } export const listTasks = (done?: boolean) => request('GET', '/tasks' + (done === undefined ? '' : `?done=${done}`)) export const createTask = (title: string) => request('POST', '/tasks', { title }) export const updateTask = (id: number, patch: Partial>) => request('PATCH', `/tasks/${id}`, patch) export const deleteTask = (id: number) => request('DELETE', `/tasks/${id}`) ``` **易错点 / 面试常问(补充)** - 易错点:优雅关机要先停止接收新请求(`srv.Shutdown(ctx)`),再关数据库等资源;顺序反了会让正在处理的请求报错。`Shutdown` 要带超时 `context`,否则可能一直等。 - 易错点:配置只从环境变量读,默认值只用于开发;不要把 `.env` 提交到仓库。 - 易错点:前端开发时用 Vite 的 `server.proxy` 转发 `/api`,避免跨域;生产用 Nginx 同域转发。 - 面试常问:`SIGTERM` 和 `SIGKILL` 的区别(前者可以被捕获做清理,后者不能,`kill -9` 就是 `SIGKILL`);容器停止时 Docker 先发 `SIGTERM`,超时后再 `SIGKILL`。 ### 要建的文件 - 完成 `main.go`。 - `npm create vite@latest frontend -- --template vue-ts`(或 `react-ts`),写 `src/api/tasks.ts` 和一个任务列表页面(新增、勾选、删除、过滤)。 - `todo-api/README.md`:功能、接口表、运行步骤。 - 提交并推送到 `zhuangang` 仓库。 ### 运行方式 ```bash cd backend && go run . cd frontend && npm install && npm run dev ``` ### 完成标准 - 页面上能完成增删改查;`Ctrl+C` 关闭后端时日志里先打印 `bye` 再退出。 - `go vet ./...` 无警告,`gofmt -l .` 无输出。 --- ## 本周相关专题 > 本节由 `tools/gen_week_topics.py` 根据专题文件的 `week` 字段自动生成,请勿手改;想调整就修改对应专题文件的 frontmatter 后重新生成。 - **AI 编程练习(每周)**:本周至少一个功能用 AI 编程工具(Cursor / Claude Code / Codex 等)完成,按 [AI 编程:工作流](../topics/ai/ai-coding/01-workflow-rules-skills.md) 写清需求和规则,按 [验证与证据](../topics/ai/ai-coding/02-verify-evidence.md) 保存测试结果和会话记录(脱敏后),第 15 周整理成“AI 编程 + 验证方法”的应聘案例。 | 技术栈 | 课程 / 场景 / 面试题 | | --- | --- | | [AI 编程工具与验证](../topics/ai/ai-coding/index.md) | 课程:[AI 编程工具工作流](../topics/ai/ai-coding/01-workflow-rules-skills.md) | | [Gin Web 框架](../topics/backend/gin/index.md) | 课程:[路由、参数绑定和校验](../topics/backend/gin/01-routing-binding.md);[面试题](../topics/backend/gin/interview.md) | | [Go 语言](../topics/backend/go/index.md) | 课程:[语法、类型和接口(对照 TypeScript)](../topics/backend/go/01-basics-types.md);课程:[错误处理和并发](../topics/backend/go/02-errors-concurrency.md);课程:[测试和工程化](../topics/backend/go/03-testing-engineering.md);[面试题](../topics/backend/go/interview.md) | | [Git](../topics/devops/git/index.md) | 课程:[分支模型和提交规范](../topics/devops/git/01-branch-commit.md) | ## 本周自查清单 - [ ] 会用 `:=`、切片、map、结构体、方法、接口 - [ ] 会用 `%w` 包装错误,用 `errors.Is/As` 判断 - [ ] 会用 goroutine、channel、WaitGroup、Mutex,会用 `-race` - [ ] 会写表格驱动测试和 `httptest` 接口测试 - [ ] 会用 Gin 写 REST 接口、统一响应、中间件 - [ ] 理解为什么存储层要用接口隔离 - [ ] 会读环境变量、做优雅关机 - [ ] 在总纲进度记录里勾掉第 1 周