本日关键词(实战):REST API、健康检查、slog、结构化日志、优雅关闭、Shutdown、SIGINT、SIGTERM、完整项目模板
本日语法/概念(实战):
| 语法/概念 | 实战用途 | 本日示例 |
|---|---|---|
GET/POST + 路由 + JSON |
完整接口,前后端联调标准写法 | /api/users 系列 |
| slog(Go 1.21+) | 结构化日志,key-value 形式,便于采集与排查 | 请求日志中间件 |
http.Server + Shutdown(ctx) |
优雅关闭:停接新请求,等当前请求跑完再退出 | 生产必做,面试常问 |
signal.Notify + <-quit |
监听 SIGINT/SIGTERM(Ctrl+C、kill),触发 Shutdown | cmd/server |
| 内存 store + 接口 | 先跑通流程,再换成 Day 4 真实 DB | 数据层抽象 |
获取实战代码 :如需在本地跑通本文示例,请克隆仓库 WenSongWang/go-quickstart-7days,本文示例在 day7 目录,克隆后在项目根目录执行下文中的命令即可。
一、本篇目标
本日整合前 6 天内容:标准项目结构、配置、HTTP 路由、中间件、内存版「数据层」,实现一个小型 REST API,并带优雅关闭 (面试常问)。定位为入门级综合实战,进阶实战可跟进后续 part。
| 功能 | 说明 |
|---|---|
GET /health |
健康检查 |
GET /api/users |
用户列表(内存) |
GET /api/users/:id |
用户详情 |
POST /api/users |
创建用户(JSON body) |
| 中间件 | 请求日志(slog)、可选 API Key 鉴权 |
| 优雅关闭 | SIGINT/SIGTERM 后等待当前请求完成再退出 |
二、前置要求
- 已完成 Day 1~6。
- 命令在项目根目录执行。
三、本日目录结构与示例(先混个眼熟)
目录结构
day7/
├── cmd/server/main.go # 入口:配置、路由、中间件链、Server、Shutdown、signal
├── internal/
│ ├── config/config.go # 端口、API_KEY(空则不做鉴权)
│ ├── store/memory.go # 内存存用户,List/Get/Create
│ ├── handler/users.go # UserHandler:List、GetByID、Create
│ └── middleware/
│ ├── logging.go # slog 打请求日志
│ └── auth.go # X-API-Key 鉴权(APIKey 空则直接放行)
├── README.md
└── csdn.md
| 目录/文件 | 说明 |
|---|---|
| cmd/server | 入口:Load 配置、NewMemoryStore、UserHandler、挂 /health 与 /api/users、中间件链、http.Server、goroutine 里 ListenAndServe、signal.Notify + Shutdown |
| internal/config | 简易配置:HTTP_PORT、API_KEY(空则不鉴权) |
| internal/store | 内存版数据层:map + RWMutex、atomic 自增 ID,List/Get/Create |
| internal/handler | UserHandler 依赖 Store,提供 List、GetByID、Create;统一 writeJSON |
| internal/middleware | Logging(slog)、APIKey(X-API-Key,空则放行) |
示例与知识点
| 示例 | 主要知识点 |
|---|---|
| main.go | 路由 /api/users 与 /api/users/ 分开、中间件链、go func ListenAndServe、quit channel、Shutdown(ctx) |
| config | os.Getenv、HTTP_PORT 默认 8080、API_KEY 空即不鉴权 |
| store | RWMutex、atomic、List 复制 slice 防外部改 map |
| handler | List/GetByID/Create、TrimPrefix 取 id、Decode JSON body、WriteHeader(201) |
| middleware | slog.Info key-value、APIKey 空时 next 直接放行 |
四、核心概念与最小示例(不看代码也能懂)
为什么先用「内存 store」而不是直接接数据库?
先把「列表、按 id 查、创建」在内存里跑通,接口和 Handler 稳定后再把 MemoryStore 换成 Day 4 的 SQLite/Postgres,改动的只是数据层,路由和业务逻辑不用大改。适合迭代开发、也便于理解分层。
什么是「优雅关闭」?为什么要做?
直接退出进程时,正在处理的请求会被中断,客户端可能拿到不完整响应或连接错误。优雅关闭 :收到 SIGINT/SIGTERM(如 Ctrl+C、kill)后,不再接受新连接 ,等当前已在处理的请求全部完成 ,再退出。做法是:用 http.Server.Shutdown(ctx),Shutdown 会停掉 Listener 并等待所有活跃请求结束;context.WithTimeout 给一个最大等待时间,超时未完成也退出,避免卡死。面试常问:如何做优雅关闭?答:signal.Notify 收 SIGINT/SIGTERM → 调 srv.Shutdown(ctx),ctx 带超时。
slog 和 log.Printf 有啥区别?
slog 是 Go 1.21 起标准库的结构化日志 :用 key-value 打日志(如 slog.Info("request", "method", r.Method, "path", r.URL.Path)),输出便于机器解析(如 JSON),方便日志采集、检索。替代散乱的 log.Printf,生产环境常用。
路由为什么 /api/users 和 /api/users/ 要分开?
标准库 ServeMux 里:末尾带 / 的 pattern 是前缀匹配 ,不带 / 是精确匹配。所以 HandleFunc("/api/users", ...) 只匹配 exactly /api/users(GET 列表、POST 创建);HandleFunc("/api/users/", ...) 匹配 /api/users/1、/api/users/2 等(GET 详情)。若只写一个 /api/users,则 /api/users/1 会 404。
易踩坑小结
| 坑 | 原因 | 解法 |
|---|---|---|
| ListenAndServe 阻塞 main | 不 go func 的话 main 卡在 ListenAndServe,后面收不到信号 | 用 go func() { srv.ListenAndServe() }(),main 里等 <-quit |
| Shutdown 后仍用 srv | Shutdown 返回后 Server 已关闭,不要再 ListenAndServe | 只在 goroutine 里调一次 ListenAndServe,Shutdown 后正常退出 |
| /api/users/1 返回 404 | 只注册了 /api/users 没注册 /api/users/ | 两个都注册:/api/users 精确、/api/users/ 前缀 |
五、Day 7 示例代码与逐段解读
1. cmd/server/main.go(精简示意)
go
func main() {
cfg := config.Load()
st := store.NewMemoryStore()
userHandler := &handler.UserHandler{Store: st}
mux := http.NewServeMux()
mux.HandleFunc("/health", ...)
mux.HandleFunc("/api/users", func(w, r) {
switch r.Method {
case http.MethodGet: userHandler.List(w, r)
case http.MethodPost: userHandler.Create(w, r)
default: http.Error(w, "Method Not Allowed", 405)
}
})
mux.HandleFunc("/api/users/", func(w, r) {
if r.Method == http.MethodGet { userHandler.GetByID(w, r); return }
http.Error(w, "Method Not Allowed", 405)
})
chain := middleware.Logging(mux)
if cfg.APIKey != "" { chain = middleware.APIKey(cfg.APIKey)(chain) }
srv := &http.Server{Addr: ":" + strconv.Itoa(cfg.HTTPPort), Handler: chain}
go func() {
slog.Info("server listening", "addr", srv.Addr)
_ = srv.ListenAndServe() // 忽略 ErrServerClosed
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
slog.Info("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil { slog.Error("shutdown error", "err", err); os.Exit(1) }
slog.Info("server stopped")
}
解读 :配置 + 内存 store + UserHandler;路由 /health、/api/users(GET 列表、POST 创建)、/api/users/(GET 详情)。中间件链:Logging 包 mux,若配置了 APIKey 再包一层鉴权。关键 :ListenAndServe 放在 go func() 里,main 继续执行到 signal.Notify 和 <-quit,收到 Ctrl+C 后调 Shutdown(ctx),等当前请求结束(最多 10 秒)再退出。
2. internal/config/config.go
从环境变量读 HTTP_PORT(默认 8080)、API_KEY(空则不做鉴权)。本目录为简易版 os.Getenv,可与 Day 3 的 viper 对比;生产可统一用 viper。
go
type Config struct {
HTTPPort int
APIKey string
}
func Load() *Config {
port, _ := strconv.Atoi(getEnv("HTTP_PORT", "8080"))
return &Config{HTTPPort: port, APIKey: os.Getenv("API_KEY")}
}
3. internal/store/memory.go
用 map[int]User + sync.RWMutex 存用户,atomic.AddInt64 生成自增 ID。List() 复制一份 slice 返回,避免调用方改 map。Get(id)、Create(name) 供 handler 调用。后续可替换为实现同一接口的「DB 版」store。
go
type MemoryStore struct {
mu sync.RWMutex
next int64
users map[int]User
}
func (s *MemoryStore) Create(name string) User {
id := int(atomic.AddInt64(&s.next, 1))
s.mu.Lock()
defer s.mu.Unlock()
u := User{ID: id, Name: name}
s.users[id] = u
return u
}
4. internal/handler/users.go
UserHandler 持有一个 Store。List:只处理 GET 且 path 精确为 /api/users,调 Store.List() 写 JSON。GetByID:从 path 里 TrimPrefix 取 id,Atoi 解析,Store.Get(id),无则 404。Create:POST、path 精确 /api/users,Decode JSON body 取 name,空则 400,否则 Store.Create(name) 并返回 201 + 创建结果。辅助函数 writeJSON(w, code, v) 统一设置 Content-Type、WriteHeader、Encode。
go
func (h *UserHandler) GetByID(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/users/")
id, err := strconv.Atoi(path)
if err != nil || id <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
u, ok := h.Store.Get(id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(u)
}
5. internal/middleware
Logging :包一层 Handler,请求前后记时间,next.ServeHTTP 后打 slog.Info("request", "method", ..., "path", ..., "duration_ms", ...)。APIKey :若传入的 apiKey 为空,直接 next.ServeHTTP;非空则校验 X-API-Key,不对就 401 并 return。
go
// Logging:记录 method、path、耗时
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
slog.Info("request", "method", r.Method, "path", r.URL.Path, "duration_ms", time.Since(start).Milliseconds())
})
}
// APIKey:空则放行,非空则校验 X-API-Key
func APIKey(apiKey string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if apiKey == "" { next.ServeHTTP(w, r); return }
if r.Header.Get("X-API-Key") != apiKey {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"unauthorized"}`))
return
}
next.ServeHTTP(w, r)
})
}
}
六、运行当天代码
在项目根目录执行:
bash
go run ./day7/cmd/server
示例请求:
bash
curl http://localhost:8080/health
curl http://localhost:8080/api/users
curl http://localhost:8080/api/users/1
curl -X POST http://localhost:8080/api/users -H "Content-Type: application/json" -d '{"name":"李四"}'
若配置了 API_KEY=secret,请求需带:-H "X-API-Key: secret"。
接口返回示例(便于对照):
json
// GET /health
{"status":"ok"}
// GET /api/users(无用户时)
[]
// GET /api/users(有用户时)
[{"id":1,"name":"李四"}]
// GET /api/users/1
{"id":1,"name":"李四"}
// POST /api/users 成功,201
{"id":1,"name":"李四"}
验证优雅关闭 :启动后在运行 server 的终端按 Ctrl+C ,观察是否先输出 shutting down...,再输出 server stopped 后退出。关闭时终端预期日志示例(slog 格式):
2026/02/17 13:14:37 INFO shutting down...
2026/02/17 13:14:37 INFO server stopped
Docker(与 Day 6 共用 Dockerfile):
bash
docker build -f day6/Dockerfile -t go-7days-app .
docker run -p 8080:8080 go-7days-app
七、学习建议
- 对照前几日:路由像 Day 2,配置像 Day 3,分层像 Day 5,日志/优雅关闭是 Day 5~6 的延伸。
- 重点看 :
ListenAndServe与Shutdown的配合,理解「收到信号 → 停接新请求 → 等当前请求结束 → 退出」。 - 可将本日项目当作第一个「完整小后端」模板,在此基础上替换为真实数据库(Day 4)、加更多接口或中间件。
八、小结
Day 7 把 7 天内容串成一个小型 API 项目:结构清晰、可配置、可 Docker 部署,并具备优雅关闭,属于入门级 实战。学完后建议多跑几遍、改一改路由或字段,再尝试接 SQLite/Postgres,巩固整体流程;进阶实战可跟进后续 part。