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

32 KiB
Raw Blame History

id, title, type, domain, stack, week, est_hours, order
id title type domain stack week est_hours order
plan.week01 第 1 周:Go 基础 + Gin 写 REST 接口 week plan
go
gin
git
1
12 1

第 1 周:Go 基础 + Gin 写 REST 接口

第一段“Go 全栈基础”(第 1~3 周)的第 1 周。总纲见 README.md,下周 week02-db-auth.md。 Go 在本计划里是加分项,只学到“能独立写出一个带数据库、鉴权、缓存、部署的后端”为止,不追求深挖。

本周目标:掌握 Go 的核心语法,用 Gin 写出“任务清单”的增删改查接口(数据先放内存),并用你熟悉的前端接上。

每天投入:1.5~2 小时。代码可以让 AI 生成,但每一行你都要能讲清楚。

参考资料

本周最终目录

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() 函数。
  • 案例:新建练习目录,初始化模块。
# 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 相加要手动转。
  • 案例:打印一段自我介绍,并计算平均工作时长。
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 时返回错误。
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 一样能捕获外部变量。
  • 案例:计时器。
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)。

运行方式

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] 判断是否存在。
  • 案例:统计单词出现次数、演示子切片共享底层数组。
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。
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 接口和内存实现。
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)。

运行方式

go run ./day2

完成标准

  • 能解释切片共享底层数组的现象,以及什么时候用指针接收者。

第 3 天:错误包装、并发、单元测试

学习内容

1. 错误包装和判断

  • 说明:用 fmt.Errorf("...: %w", err) 给错误加上下文并保留原错误;errors.Is 判断是不是某个错误,errors.As 取出特定类型的错误。
  • 案例:查找任务,找不到时返回包装后的 ErrNotFound,上层据此返回 404。
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 模拟),汇总结果。
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 社区的惯例。
  • 案例:测试除法函数。
// 文件: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
}
// 文件: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。

运行方式

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 之后,再看框架就知道它省了什么。
  • 案例:最小服务器。
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 是国内用得多、概念少的一个。
  • 案例:任务接口的骨架。
cd todo-api/backend && go mod init todo-api && go get github.com/gin-gonic/gin
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 插件即可点击发送)。
### 健康检查
GET http://localhost:8080/api/health

### 新建任务
POST http://localhost:8080/api/tasks
Content-Type: application/json

{"title": "学习 Gin"}

运行方式

go run . 
curl -s localhost:8080/api/tasks/1?done=true

完成标准

  • 能说清一个请求从进入 Gin 到返回经过了哪些步骤。

第 5 天:分层:model / store / handler

学习内容

1. 为什么分层

  • 说明:handler 只管“解析请求、返回响应”;store 只管“数据存哪”;model 放数据结构和校验。下周把内存存储换成数据库时,只需要新写一个 store 实现。
  • 案例:TaskStore 接口 + 并发安全的内存实现。
// 文件: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
}
// 文件: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
}
// 文件: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(...) 那行注释掉)。
// 文件: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})
}
// 文件: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)
}
// 文件: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:
// 文件: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:先自己写,再对照示例。

运行方式

go test ./... -race
go run .

完成标准

  • api.http 里每个接口的正常和出错情况(空标题、不存在的 id)都测过,状态码正确。

第 6 天:中间件和接口测试

学习内容

1. 中间件

  • 说明:中间件是包在 handler 外面的一层函数,c.Next() 之前的代码在请求进入时执行,之后的代码在响应返回时执行。日志、跨域、鉴权、限流、捕获 panic 都用它。
  • 案例:请求日志 + 跨域。
// 文件: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))
	}
}
// 文件: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。
// 文件: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 过滤”。

运行方式

go test ./... -v -race

完成标准

  • 测试全部通过;故意在某个接口里 panic,服务不挂,返回 500。

第 7 天:配置、优雅关机、接前端

学习内容

1. 环境变量配置和优雅关机

  • 说明:配置从环境变量读,不写死在代码里(部署时用 Docker 注入)。优雅关机:收到 Ctrl+C 或 SIGTERM 后不再接新请求,等处理中的请求完成再退出,避免部署时请求被掐断。
  • 案例:main.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 请求封装。
// 文件: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 仓库。

运行方式

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 周