Gin从入门到精通,Gin快速上手

记录 Gin 使用过程中的要点与易踩坑点。

Gin 官方文档:https://gin-gonic.com/zh-cn/docs/

知识点清单

知识点目录(按常见使用频率排列)。

  • HTTP 基础:请求方法、状态码、Header、JSON、Cookie、跨域 CORS。
  • Gin 基础:Engine、Route、Handler、Context 的作用与生命周期。
  • 路由设计:REST 风格、路由分组、版本化(/api/v1)。
  • 中间件:请求链路、Next/Abort、鉴权、日志、限流、Recovery。
  • 参数获取:Path、Query、Header、Body(JSON/Form)与绑定。
  • 校验与错误处理:统一响应结构、错误码、业务错误与系统错误区分。
  • 静态资源/模板:本地文件与 embed.FS(前后端一体部署常用)。
  • WebSocket:升级握手、读写循环、连接管理、心跳、关闭语义。
  • 优雅退出:HTTP Server Shutdown、Context 取消、关闭 WS 连接、等待后台任务结束。
  • 工程化:配置、日志、测试、性能、部署(systemd/Docker)。

工程化骨架(最小分层),避免 main.go 变成“巨石”:

  • cmd:程序入口(main)。
  • internal/handler:Gin 路由与 handler。
  • internal/service:业务逻辑(可单测)。
  • internal/repo:数据访问(DB/Redis/HTTP Client)。
  • internal/middleware:鉴权、日志、限流等。
  • configs:配置文件(yaml/toml),配合 Viper 等库。

Gin 快速入门

记录 Gin 常用的一些方法。

1. 安装与最小示例

# 初始化模块(把 example.com/hello-gin 换成实际模块名)
go mod init example.com/hello-gin

# 安装 Gin
go get github.com/gin-gonic/gin
package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	// 1) 创建 Engine(按需装配中间件)
	r := gin.New()

	// 2) 注册常用中间件:访问日志 + panic 恢复
	r.Use(gin.Logger(), gin.Recovery())

	// 3) 注册路由:GET /ping
	r.GET("/ping", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{"message": "pong"})
	})

	// 4) 启动 HTTP 服务
	_ = r.Run(":8080")
}

需要理解的关键点:

  • gin.New():创建一个没有任何默认中间件的 Engine。
  • gin.Default():等价于 New() + Logger() + Recovery()。
  • Handler:本质是 func(*gin.Context);Context 贯穿一次请求链路。

2. gin.Context 速记

Context 是 Gin 的“工作台”,常见能力包括:

  • 读请求:c.Param、c.Query、c.GetHeader、c.ShouldBind。
  • 写响应:c.JSON、c.String、c.Data、c.File。
  • 控制链路c.Next()c.Abort()、c.AbortWithStatusJSON。
  • 存取上下文数据:c.Set / c.Get(跨中间件传值)。
// GET /ctx/demo/:id?name=tom&v=1
r.GET("/ctx/demo/:id", func(c *gin.Context) {
	// Path 参数
	id := c.Param("id")

	// Query 参数(不存在则返回 "")
	name := c.Query("name")

	// Query 参数(不存在则返回默认值)
	verbose := c.DefaultQuery("v", "0")

	// Header
	token := c.GetHeader("Authorization")

	// 其他常用信息
	ip := c.ClientIP()
	path := c.FullPath()

	// Context 跨中间件/handler 传值(仅当前请求内有效)
	c.Set("demo_key", "demo_val")
	val, _ := c.Get("demo_key")

	// JSON 响应
	c.JSON(200, gin.H{
		"id": id,
		"name": name,
		"v": verbose,
		"token": token,
		"ip": ip,
		"path": path,
		"ctx_val": val,
	})
})

type CreateReq struct {
	Name string `json:"name" binding:"required"`
	Age  int    `json:"age"  binding:"gte=0,lte=150"`
}

// POST /ctx/demo
r.POST("/ctx/demo", func(c *gin.Context) {
	var req CreateReq

	// JSON 绑定 + 校验(失败直接返回 400)
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(400, gin.H{"code": 400, "msg": err.Error()})
		return
	}

	// 纯文本响应(调试用)
	c.String(200, "ok: name=%s age=%d", req.Name, req.Age)
})

要点(备忘):

  • c.Param / c.Query / c.DefaultQuery:读取 Path/Query 参数。
  • c.GetHeader:读取 Header;鉴权常用。
  • c.ShouldBindJSON:Body(JSON) 绑定 + validator 校验。
  • c.JSON / c.String:写响应(常用两种)。
  • c.Set / c.Get:在同一次请求链路里跨中间件传值。
  • c.ClientIP:获取客户端 IP(会考虑 X-Forwarded-For 等头)。
坑点记录:不要把 c 传到 goroutine 里长期使用
gin.Context 不是为“跨请求长期保存”设计的。如果要在 goroutine 里做异步任务,需要把必要数据拷贝出来(例如 userID、traceID、请求参数),避免持有 *gin.Context 指针。

路由与分组

1. 路由参数、Query 参数

r.GET("/users/:id", func(c *gin.Context) {
	id := c.Param("id")             // Path 参数
	verbose := c.Query("v")         // Query 参数,缺省返回 ""
	token := c.GetHeader("X-Token") // Header 参数

	c.JSON(200, gin.H{
		"id":    id,
		"v":     verbose,
		"token": token,
	})
})

2. 路由分组与版本化

// 分组用于“统一前缀 + 统一中间件 + 统一版本”
api := r.Group("/api")
v1 := api.Group("/v1")

v1.GET("/health", func(c *gin.Context) {
	c.JSON(200, gin.H{"ok": true})
})

这样做的价值:

  • 一致性:统一前缀与版本控制,避免“散落的路由”。
  • 中间件隔离:可对某组路由单独加鉴权、限流。

静态资源与模板

开发阶段常见做法:静态文件与模板都从磁盘读取;上线阶段再切到 go:embed。

1. 静态资源:r.Static

// 1) 静态资源(前端页面/JS/CSS)本地目录映射
// 生产环境常用 go:embed 把 web/static 或 web/dist 打进二进制;本段先用磁盘目录跑通
r.Static("/static", "./web/static")
  • r.Static(relativePath, root):把 URL 前缀映射到磁盘目录;例如 /static/app.js -> ./web/static/app.js。
  • 相对路径的基准:root 为相对路径时,基准通常是进程启动时的工作目录(Working Directory)。路径错位是常见坑;需要时可改成绝对路径。
  • 路由匹配:本质是注册 GET/HEAD 的文件服务;接口路由不要与 /static 前缀冲突。
  • 上线替换:磁盘目录 -> 内嵌文件系统时,一般改用 r.StaticFS + fs.Sub + http.FS

gin.StaticFS 是 Gin 框架专门用于自定义文件系统注册静态资源的方法,核心作用:绑定一个 ** 文件系统接口(fs.FS)** 到路由,对外提供静态文件访问,完美配合 embed、fs.Sub 打包前端资源。

// 源码定义
func (group *RouterGroup) StaticFS(relativePath string, fs http.FileSystem) IRoutes

// http.Dir 包装本地目录为 http.FileSystem
r.StaticFS("/static", http.Dir("./static"))

// 1. 根路由访问前端页面(/ → index.html)
r.StaticFS("/", http.FS(distFS))
StaticFS会自动生成一个通配符路由 /*filepath,它会吞掉所有子路径,包括 /health、/metrics

2. 模板:r.LoadHTMLGlob

// 2) 模板加载(html/template)——从磁盘按 glob 读取
// 不管模板在哪个子目录,只要文件名一样,后加载的会覆盖先加载的。
r.LoadHTMLGlob("web/templates/*")

要点(备忘):

  • r.LoadHTMLGlob(pattern):启动时一次性解析模板并缓存到 Engine;pattern 使用 glob 匹配(例如 web/templates/*.html)。
  • 渲染入口:handler 内使用 c.HTML(status, templateName, data)。其中 templateName 通常是模板文件名。
  • 函数注入:需要自定义模板函数时,先调用 r.SetFuncMap,再 LoadHTMLGlob。
  • 上线替换(embed):模板走 go:embed 时,可用 template.ParseFS 解析内嵌模板,然后 r.SetHTMLTemplate 设置到 Engine(示例放在 embed.FS 小节末尾)。
补充:哪些 HTML 才算 Gin 模板?
所谓“模板”,本质是已经被 Go 标准库 html/template 解析进 *template.Template 的内容;只有解析进来的模板,才能被 c.HTML(status, name, data) 按 name 找到并渲染。

  • 磁盘模板:通过 r.LoadHTMLGlob / r.LoadHTMLFiles(或标准库 ParseFiles/ParseGlob)解析后,才能当模板用。
  • embed 模板:通过 template.ParseFS 解析后,再 r.SetHTMLTemplate 注入,才能当模板用。
  • 静态 HTML:通过 r.Static 提供的文件只会“原样返回”,不会经过模板解析,因此不属于模板。

中间件:Next/Abort

1. 中间件执行顺序

Gin 的中间件按“洋葱模型”工作:先进入、后退出。

func Trace() gin.HandlerFunc {
	return func(c *gin.Context) {
		// 进入阶段:请求进来先执行这里
		c.Set("trace_id", "xxx")

		// 继续执行后续中间件与最终 handler
		c.Next()

		// 退出阶段:handler 完成后回到这里
		status := c.Writer.Status()
		_ = status
	}
}
  • c.Next():放行到后续处理链;后续执行完会“回到这里”。
  • c.Abort():停止执行后续 handler(但当前中间件后面的代码仍会继续执行)。

2. 典型鉴权中间件(AbortWithStatusJSON)

func Auth() gin.HandlerFunc {
	return func(c *gin.Context) {
		// 在中间件里尽早返回:减少后续 handler 计算与资源消耗
		if c.GetHeader("Authorization") == "" {
			c.AbortWithStatusJSON(401, gin.H{
				"code": 401,
				"msg":  "missing token",
			})
			return
		}
		c.Next()
	}
}

3. 挂载中间件:r.Use / group.Use / 路由级中间件

中间件是按注册顺序串起来的一条链。挂载位置不同,影响作用范围:

  • 全局:对所有路由生效(含后续注册的路由)。
  • 分组:只对某个路由组生效(比如 /api/v1)。
  • 路由级:只对某一个具体路由生效。
r := gin.New()

// 1) 全局中间件:对所有路由生效
r.Use(gin.Logger(), gin.Recovery())

// 2) 分组中间件:只对 /api 下的路由生效
api := r.Group("/api")
api.Use(Auth()) // 例如:鉴权只挂在 API 上

// 3) 路由级中间件:只对某条路由生效
r.GET("/debug", Trace(), func(c *gin.Context) {
	c.JSON(200, gin.H{"ok": true})
})
链路顺序(备忘)
同一路由的执行顺序大致是:全局中间件 -> 分组中间件 -> 路由级中间件 -> 最终 handler,然后再按“洋葱模型”反向返回。

4. 常用中间件清单(按出现频率)

  • 访问日志gin.Logger()(或自定义日志格式)。
  • panic 恢复gin.Recovery()(避免服务直接崩)。
  • 请求 ID:写入/透传 X-Request-Id,方便串联日志与排障(常见做法:如果请求头没有则生成)。
  • CORS:浏览器跨域;常用 github.com/gin-contrib/cors。
  • 超时控制:给请求链路加 deadline;复杂场景更建议在下游(DB/HTTP Client)层面设置超时并尊重 ctx.Done。
  • 限流:保护自身与下游(令牌桶/漏桶/滑动窗口)。
  • 鉴权:JWT / Session / API Key(通常挂在路由组)。
  • 压缩:gzip/br(静态资源或响应体较大时)。

5. 中间件里常见的两个动作:Set/Get 与 Abort

中间件经常做两件事:把数据塞进 Context,以及在不满足条件时提前终止

func RequestID() gin.HandlerFunc {
	return func(c *gin.Context) {
		// 1) 读取/生成 request_id
		rid := c.GetHeader("X-Request-Id")
		if rid == "" {
			rid = "gen-xxx" // 示例:实际可用 uuid
		}

		// 2) 写入 Context,供后续中间件/handler 使用
		c.Set("request_id", rid)

		// 3) 也可以写回响应头,便于客户端拿到
		c.Header("X-Request-Id", rid)

		c.Next()
	}
}

参数绑定与校验

1. JSON Body 绑定(ShouldBindJSON)

type CreateUserReq struct {
	Name  string `json:"name" binding:"required"`
	Email string `json:"email" binding:"required,email"`
}

r.POST("/users", func(c *gin.Context) {
	var req CreateUserReq

	// ShouldBindJSON:把请求 Body(JSON) 绑定到结构体,并触发 binding/validator 校验
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(400, gin.H{"code": 400, "msg": err.Error()})
		return
	}
	c.JSON(200, gin.H{"code": 0, "data": req})
})

需要理解的点:

  • ShouldBindXxx:推荐使用 “Should” 系列;出错时返回 error,由业务侧决定响应。
  • binding 标签:Gin 默认集成 validator(基于 go-playground/validator),常用 required、email、min、max。
  • 错误处理:可先用 err.Error() 作为最简输出;需要更友好提示时再做字段级错误与错误码映射。
可选项:统一响应结构
接口变多后,统一返回格式(code/msg/data)会更省心,同时把“参数错误、鉴权失败、业务错误、系统错误”分开,便于前端处理与排障。

embed.FS 结合 Gin

记录 embed.FS 的用法、常用方法、以及 Sub/http.FS 的组合方式,在 Gin 中可直接复用的写法。

1. embed.FS 是什么

Go 1.16 引入 go:embed。可以把文件在编译期打进可执行文件,运行时通过 embed.FS 读取。

  • 适用场景:单文件部署、容器镜像更小、前后端打包到一个二进制(常见:SPA dist + 后端 API)。
  • 不适用:需要运行时动态修改/上传的文件(embed 是编译时固化的)。

2. 最小示例:把 web/dist 打进程序

package main

import (
	"embed"
)

// go:embed 在编译期把匹配到的文件打进二进制
//go:embed web/dist/*
var assets embed.FS

func main() {}
路径规则提醒
go:embed 的路径是相对当前 Go 文件所在目录,而不是相对运行时工作目录。经验做法:把 embed 声明放在固定位置(例如 internal/static/static.go),避免路径混乱。

3. embed.FS 的方法与能力

embed.FS 这个类型实现了多个标准库接口(因此能跟很多库无缝对接)。

  • Open(name string) (fs.File, error):打开文件,返回 fs.File(可读、可 Stat)。
  • ReadFile(name string) ([]byte, error):一次性读取完整文件内容。
  • ReadDir(name string) ([]fs.DirEntry, error):读取目录条目。
  • Glob(pattern string) ([]string, error):按通配符匹配文件路径(例如 *.html)。

常见疑问:“属性/字段在哪?”——embed.FS 没有对外可见的字段;其内容由编译器注入,通过标准接口访问即可。

4. 关键组合:fs.Sub + http.FS + Gin.StaticFS

Gin 的静态文件服务需要 http.FileSystem,而 embed.FS 是 fs.FS。常用三步:

  • fs.Sub:从 embed.FS 切出子目录(例如 web/dist)。
  • http.FS:把 fs.FS 适配成 http.FileSystem。
  • r.StaticFS:用这个文件系统提供静态资源。

完整示例:

package main

import (
	"embed"
	"io/fs"
	"net/http"

	"github.com/gin-gonic/gin"
)

//go:embed web/dist/*
var assets embed.FS

func main() {
	r := gin.New()
	r.Use(gin.Logger(), gin.Recovery())

	// 1) 切子目录:把内嵌文件系统中的 "web/dist" 作为静态资源根目录
	// 注意:这里的路径需要与 go:embed 的实际目录一致
	dist, err := fs.Sub(assets, "web/dist")
	if err != nil {
		panic(err)
	}

	// 2) 适配成 http.FileSystem:让 net/http 能识别 fs.FS
	fileSystem := http.FS(dist)

	// 3) 提供静态资源
	// 例如:GET /static/app.js => 读取 web/dist/app.js(来自二进制内嵌文件)
	r.StaticFS("/static", fileSystem)

	_ = r.Run(":8080")
}

详解上述代码中的相关方法和规则:

浏览器访问地址 fileSystem 中对应的路径
/static/index.htm index.htm
/static/css/a.css css/a.css
/static/js/app.js js/app.js
  • fs.Sub() 之后,根目录直接变成 web/dist,此时 subFS 的根就是 web/dist:
    // subFS.Open("index.html") → 对应 web/dist/index.html
    // subFS.Open("static/js/main.js") → web/dist/static/js/main.js

5. 模板(go:embed):template.ParseFS + r.SetHTMLTemplate

LoadHTMLGlob 读取的是磁盘文件;模板嵌入后通常改为:用 template.ParseFS 解析模板,再用 r.SetHTMLTemplate 注入到 Engine。

package main

import (
	"embed"
	"html/template"

	"github.com/gin-gonic/gin"
)

//go:embed web/templates/*
var templateFS embed.FS

func main() {
	r := gin.New()
	r.Use(gin.Logger(), gin.Recovery())

	// 1) 可选:模板函数
	// funcMap := template.FuncMap{"upper": strings.ToUpper}
	// tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "web/templates/*"))

	// 2) 不带函数的最简写法
	tmpl := template.Must(template.New("").ParseFS(templateFS, "web/templates/*"))

	// 3) 注入到 Gin
	r.SetHTMLTemplate(tmpl)
}

6. SPA(前端路由):history 模式 fallback 到 index.html

很多前端项目用 history 路由(React/Vue)。访问 /dashboard 实际应返回 index.html,而不是 404。思路是:静态文件找得到就返回,找不到则返回 index.html。

r.NoRoute(func(c *gin.Context) {
	// 对 API 仍然返回 404(避免把接口请求错误地回退到 index.html)
	if len(c.Request.URL.Path) >= 4 && c.Request.URL.Path[:4] == "/api" {
		c.JSON(404, gin.H{"code": 404, "msg": "not found"})
		return
	}
	// 其他路径返回 index.html(前端 history 路由的通用回退策略)
	c.FileFromFS("index.html", http.FS(dist))
})
千万不要这样做
不要把用户上传文件、运行时生成文件也放在 embed.FS 里“读取”。embed.FS 是编译期固化的,运行时不会变化;容易误以为“写成功了”,但读取到的永远是编译时那份内容。

Gin + WebSocket

Gin 本身不直接实现 WebSocket 协议,但可以用成熟的 WebSocket 库(常见是 github.com/gorilla/websocket)在 Gin 的 handler 里完成升级。

1. 最小升级示例:从 HTTP 升级为 WebSocket

import (
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
	ReadBufferSize:  1024,
	WriteBufferSize: 1024,
	CheckOrigin: func(r *http.Request) bool {
		// 生产环境通常按域名白名单校验 Origin,避免跨站滥用
		// 示例为了便于跑通,直接放开
		return true
	},
}

func wsHandler(c *gin.Context) {
	// 1) 完成协议升级:HTTP -> WebSocket
	conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
	if err != nil {
		return
	}
	defer conn.Close()

	for {
		// 2) 读消息:出错通常意味着对端关闭或网络异常
		msgType, msg, err := conn.ReadMessage()
		if err != nil {
			break
		}
		// 3) 写消息:此处做 echo(原样回写),真实业务可改为推送/广播
		_ = conn.WriteMessage(msgType, msg)
	}
}

需要掌握的点:

  • Upgrade:完成握手后,这条连接进入 WebSocket 的长连接通信。
  • ReadMessage/WriteMessage:常用的简化 API;需要更细控制时可用 NextReader/NextWriter。
  • CheckOrigin:需要做来源校验,否则可能被跨站滥用。

2. 连接管理(Hub 思路)

当业务存在“广播、多人聊天室、订阅推送”需求时,通常需要管理所有连接,否则:

  • 无法广播:无法得知有哪些 conn。
  • 无法优雅关闭:服务退出时无法得知该关闭哪些连接。
  • 容易泄漏:断线连接没清理,会导致 goroutine/内存泄漏。

一个最小 Hub(示意):

type Hub struct {
	// clients 保存当前存活连接;示例中用 map 模拟“集合”
	clients map[*websocket.Conn]struct{}

	// register/unregister 用 channel 串行化对 clients 的读写,避免并发冲突
	register   chan *websocket.Conn
	unregister chan *websocket.Conn

	// stop 用于优雅退出时通知 Hub 收尾
	stop chan struct{}
}

func NewHub() *Hub {
	return &Hub{
		clients:     make(map[*websocket.Conn]struct{}),
		register:    make(chan *websocket.Conn),
		unregister:  make(chan *websocket.Conn),
		stop:        make(chan struct{}),
	}
}

func (h *Hub) Run() {
	for {
		select {
		case c := <-h.register:
			h.clients[c] = struct{}{}
		case c := <-h.unregister:
			delete(h.clients, c)
			_ = c.Close()
		case <-h.stop:
			// 服务退出:关闭所有连接,避免 goroutine 泄漏
			for c := range h.clients {
				_ = c.Close()
			}
			return
		}
	}
}
WebSocket 与 Gin 中间件的关系
WebSocket 的升级发生在某个 Gin handler 内部。升级成功后,仍可继续使用日志、鉴权等 Gin 中间件的成果(例如在中间件里解析得到的 userID)。但升级之后的读写循环需要自行维护,一般在升级前就将“鉴权结果、用户信息、traceID”等数据提取出来并保存到局部变量。

3. 关闭语义:Close / 读写错误 / 服务退出

WebSocket 的关闭通常有三类触发:

  • 客户端主动断开:ReadMessage 返回 error(常见是 close frame)。
  • 服务端主动断开:调用 conn.Close() 或发送 Close control frame。
  • 服务退出:优雅退出时通知 Hub 停止并关闭所有连接。

备忘:读写循环里只要 ReadMessage/WriteMessage 出错,就退出循环并 Close,同时把连接从 Hub 清理掉。

优雅退出与资源收尾(Shutdown/Close/Context)

当 embed 静态资源与 WebSocket 都已接入后,需要考虑优雅结束与资源收尾。未处理时常见后果:部署滚动更新时请求中断、WS 连接突然断、后台任务被硬杀。

1. 为什么不要只用 r.Run()

r.Run() 内部会自己创建 http.Server 并 ListenAndServe,不利于进行优雅关闭控制。更常见做法是自行创建 http.Server,然后调用 server.Shutdown() 结束。

2. http.Server 的结束方法(备忘)

  • Shutdown(ctx):优雅关闭。停止接收新连接,并等待已有连接在 ctx 超时前处理完毕。
  • Close():强制关闭。立即关闭底层 listener 与连接,可能导致进行中的请求/WS 直接断开。

关闭顺序(备忘):

  • 先停接入Shutdown(ctx),让 HTTP 不再接新请求。
  • 再收尾长连接:通知 Hub 停止,关闭所有 WS。
  • 再等待后台任务:用 WaitGroup 等待 goroutine 退出(如有)。
  • 到点兜底:超时后再调用 Close() 强杀(最后手段)。

3. 可复制的优雅退出示例(Gin + HTTP Server + Hub)

package main

import (
	"context"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/gin-gonic/gin"
)

func main() {
	// 0) 初始化 Gin
	r := gin.New()
	r.Use(gin.Logger(), gin.Recovery())

	// 假设存在一个 Hub
	// hub := NewHub()
	// go hub.Run()

	srv := &http.Server{
		Addr:    ":8080",
		Handler: r,
	}

	// 1) 启动 HTTP Server(放到 goroutine,主 goroutine 用于等待退出信号)
	go func() {
		_ = srv.ListenAndServe()
	}()

	// 2) 监听系统信号:Ctrl+C 或容器停止信号
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	// 3) 设定优雅关闭超时时间
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	// 4) 先优雅关闭 HTTP:停止接新请求,等待在途请求完成
	_ = srv.Shutdown(ctx)

	// 5) 再通知 hub 停止并关闭 WS(示意):避免长连接 goroutine 泄漏
	// close(hub.stop)

	// 6) 如有后台任务,这里再 wait(可用 WaitGroup)
}
Context 的结束属性:Done() 与 Err()
当使用 context.WithTimeout/WithCancel 创建 ctx 后:

  • ctx.Done():只读 channel;超时/取消时会被关闭,用来通知“该结束了”。
  • ctx.Err():结束原因,常见是 context.Canceled 或 context.DeadlineExceeded。

在 WebSocket 读写循环或后台任务里,可以 select 监听 ctx.Done() 来触发退出与收尾。

上线前检查清单(备忘)

  • 日志:生产环境可使用结构化日志(zap/logrus),并记录 trace_id、user_id、耗时。
  • 配置:分环境配置(dev/staging/prod),避免把密钥写进代码。
  • 测试:从 service 层单测开始;HTTP 层可以用 httptest 做集成测试。
  • CORS:前后端分离时务必配置,避免浏览器跨域问题。
  • 限流与超时:对外 API 通常会加超时与限流,保护自身与下游。
  • 部署:Docker + 健康检查 /health;滚动更新务必配合优雅退出。