[7天实战入门Go语言后端] Day 7:综合实战——小型 REST API 与优雅关闭

本日关键词(实战):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 持有一个 StoreList:只处理 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

七、学习建议

  1. 对照前几日:路由像 Day 2,配置像 Day 3,分层像 Day 5,日志/优雅关闭是 Day 5~6 的延伸。
  2. 重点看ListenAndServeShutdown 的配合,理解「收到信号 → 停接新请求 → 等当前请求结束 → 退出」。
  3. 可将本日项目当作第一个「完整小后端」模板,在此基础上替换为真实数据库(Day 4)、加更多接口或中间件。

八、小结

Day 7 把 7 天内容串成一个小型 API 项目:结构清晰、可配置、可 Docker 部署,并具备优雅关闭,属于入门级 实战。学完后建议多跑几遍、改一改路由或字段,再尝试接 SQLite/Postgres,巩固整体流程;进阶实战可跟进后续 part。

相关推荐
J2虾虾1 小时前
使用Springboot Integration做无人机飞控系统
spring boot·后端·无人机
sycmancia2 小时前
C++——初始化列表的使用
开发语言·c++
番茄去哪了2 小时前
在Java中操作Redis
java·开发语言·数据库·redis
马克Markorg2 小时前
使用rust实现的高性能api测试工具
开发语言·测试工具·rust·postman
无心水2 小时前
6、合纵连横:开源快速开发平台全解析与自建平台架构实战【终篇】
java·后端·科技·spring·面试·架构·开源
闻哥2 小时前
Java虚拟机内存结构深度解析:从底层原理到实战调优
java·开发语言·jvm·python·面试·springboot
wjs20242 小时前
HTML 属性详解
开发语言
桂花很香,旭很美2 小时前
[7天实战入门Go语言后端] Day 6:测试与 Docker 部署——单元测试与多阶段构建
docker·golang·单元测试
无巧不成书02182 小时前
Kotlin Multiplatform (KMP) 鸿蒙开发整合实战|2026最新方案
android·开发语言·kotlin·harmonyos·kmp