Files
zhuangang/plan/week01-go-gin.md
T

1241 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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<T> { code: number; message: string; data: T }
const BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8080/api'
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
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<T>
if (json.code !== 0) throw new Error(json.message)
return json.data
}
export const listTasks = (done?: boolean) =>
request<Task[]>('GET', '/tasks' + (done === undefined ? '' : `?done=${done}`))
export const createTask = (title: string) => request<Task>('POST', '/tasks', { title })
export const updateTask = (id: number, patch: Partial<Pick<Task, 'title' | 'done'>>) =>
request<Task>('PATCH', `/tasks/${id}`, patch)
export const deleteTask = (id: number) => request<void>('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 .` 无输出。
---
## 本周自查清单
- [ ] 会用 `:=`、切片、map、结构体、方法、接口
- [ ] 会用 `%w` 包装错误,用 `errors.Is/As` 判断
- [ ] 会用 goroutine、channel、WaitGroup、Mutex,会用 `-race`
- [ ] 会写表格驱动测试和 `httptest` 接口测试
- [ ] 会用 Gin 写 REST 接口、统一响应、中间件
- [ ] 理解为什么存储层要用接口隔离
- [ ] 会读环境变量、做优雅关机
- [ ] 在总纲进度记录里勾掉第 1 周