32 KiB
32 KiB
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 |
|
|
12 | 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/
本周最终目录
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.modgo-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 和 JSPromise的区别。
第 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.gointernal/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 周