1258 lines
33 KiB
Markdown
1258 lines
33 KiB
Markdown
---
|
||
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 .` 无输出。
|
||
|
||
---
|
||
|
||
<!-- week-topics:start -->
|
||
|
||
## 本周相关专题
|
||
|
||
> 本节由 `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) |
|
||
|
||
<!-- week-topics:end -->
|
||
|
||
## 本周自查清单
|
||
|
||
- [ ] 会用 `:=`、切片、map、结构体、方法、接口
|
||
- [ ] 会用 `%w` 包装错误,用 `errors.Is/As` 判断
|
||
- [ ] 会用 goroutine、channel、WaitGroup、Mutex,会用 `-race`
|
||
- [ ] 会写表格驱动测试和 `httptest` 接口测试
|
||
- [ ] 会用 Gin 写 REST 接口、统一响应、中间件
|
||
- [ ] 理解为什么存储层要用接口隔离
|
||
- [ ] 会读环境变量、做优雅关机
|
||
- [ ] 在总纲进度记录里勾掉第 1 周
|