高级主题
本章覆盖 WebSocket 集成、上下文与取消、数据库集成、测试等高级主题。
WebSocket 支持
Gin 可与 gorilla/websocket 无缝集成。由于 Gin 处理函数接收底层的 http.ResponseWriter 和 *http.Request,你可以直接将任何 Gin 路由升级为 WebSocket 连接。
安装
go get github.com/gorilla/websocket基础 Echo 示例
import "github.com/gorilla/websocket"
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // 开发环境允许所有来源
},
}
func handleWebSocket(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("Upgrade error: %v", err)
return
}
defer conn.Close()
for {
msgType, msg, err := conn.ReadMessage()
if err != nil {
break
}
log.Printf("Received: %s", msg)
if err := conn.WriteMessage(msgType, msg); err != nil {
break
}
}
}
func main() {
r := gin.Default()
r.GET("/ws", handleWebSocket)
r.Run(":8080")
}聊天广播示例
type Hub struct {
mu sync.RWMutex
clients map[*websocket.Conn]bool
}
func (h *Hub) addClient(conn *websocket.Conn) {
h.mu.Lock()
h.clients[conn] = true
h.mu.Unlock()
}
func (h *Hub) removeClient(conn *websocket.Conn) {
h.mu.Lock()
delete(h.clients, conn)
h.mu.Unlock()
conn.Close()
}
func (h *Hub) broadcast(msgType int, msg []byte) {
h.mu.RLock()
defer h.mu.RUnlock()
for conn := range h.clients {
conn.WriteMessage(msgType, msg)
}
}
func main() {
hub := &Hub{clients: make(map[*websocket.Conn]bool)}
r := gin.Default()
r.GET("/ws", func(c *gin.Context) {
conn, _ := upgrader.Upgrade(c.Writer, c.Request, nil)
hub.addClient(conn)
defer hub.removeClient(conn)
for {
msgType, msg, err := conn.ReadMessage()
if err != nil {
break
}
hub.broadcast(msgType, msg)
}
})
r.Run(":8080")
}🚨 上述示例在持有读锁的情况下广播消息,慢客户端可能阻塞所有发送。生产环境建议每个客户端使用独立 channel 发送消息。详见 gorilla/websocket chat example。
Upgrader 配置
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024, // I/O 读缓冲(默认 4096)
WriteBufferSize: 1024, // I/O 写缓冲(默认 4096)
// 跨域控制
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
return origin == "https://your-app.example.com"
},
// 应用层子协议协商
Subprotocols: []string{"graphql-ws", "graphql-transport-ws"},
}🔬 深入原理:WebSocket 本身只提供全双工二进制/文本消息通道。
Subprotocols让客户端和服务端在握手阶段协商消息格式(应用层协议协商),独立于 HTTP 的Upgrade机制。
WebSocket 最佳实践
心跳机制
const (
pongWait = 60 * time.Second
pingPeriod = (pongWait * 9) / 10 // 必须小于 pongWait
)
func handleWebSocket(c *gin.Context) {
conn, _ := upgrader.Upgrade(c.Writer, c.Request, nil)
defer conn.Close()
conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
go func() {
ticker := time.NewTicker(pingPeriod)
defer ticker.Stop()
for range ticker.C {
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}()
// ... 读取循环
}并发写入安全
gorilla/websocket 不支持对单个连接的并发写入。必须通过以下方式序列化:
- 互斥锁:
sync.Mutex保护写操作 - 通道模式:所有消息通过单一 channel 发送,由一个写入 goroutine 处理
💡 推荐通道模式——它自然处理背压,同时将所有写逻辑集中在一处。
上下文与取消
每个 Gin 处理函数接收 *gin.Context,它封装了 Go 标准的 context.Context。正确使用上下文对于超时、取消和资源清理至关重要。
访问请求上下文
r.GET("/api/data", func(c *gin.Context) {
ctx := c.Request.Context() // 标准 context.Context
// 传递给下游调用
})请求超时中间件
func TimeoutMiddleware(timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
c.Request = c.Request.WithContext(ctx) // 替换为带超时的 request
c.Next()
}
}
r.Use(TimeoutMiddleware(5 * time.Second))传递上下文给数据库查询
r.GET("/users/:id", func(c *gin.Context) {
ctx := c.Request.Context() // 继承超时和取消
var name string
err := db.QueryRowContext(ctx,
"SELECT name FROM users WHERE id = $1",
c.Param("id"),
).Scan(&name)
// 客户端断开连接时,查询自动取消
})传递上下文给外部 HTTP 调用
r.GET("/proxy", func(c *gin.Context) {
ctx := c.Request.Context()
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/data", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
c.JSON(502, gin.H{"error": "upstream error"})
return
}
defer resp.Body.Close()
// ...
})检测客户端断开连接
r.GET("/stream", func(c *gin.Context) {
ctx := c.Request.Context()
for i := 0; ; i++ {
select {
case <-ctx.Done():
log.Println("client disconnected, stopping")
return
case <-time.After(1 * time.Second):
c.SSEvent("message", gin.H{"count": i})
c.Writer.Flush()
}
}
})上下文最佳实践
- ✅ 始终传播请求上下文 — 传递给所有接受
context.Context的函数 - ✅ 在中间件级别设置超时 — 一处配置,所有路由生效
- ✅ 使用
c.Set()/c.Get()传递请求范围数据 — 优于context.WithValue - 🚨 不要在结构体中存储
*gin.Context— context 与请求生命周期绑定 - 🚨 不要在 goroutine 中直接使用
*gin.Context— 必须先c.Copy()
常见错误
在 goroutine 中使用 gin.Context
// ❌ 错误:data race!
go func() {
log.Println(c.Request.URL.Path)
}()
// ✅ 正确:先拷贝
cCopy := c.Copy()
go func() {
log.Println(cCopy.Request.URL.Path)
}()🚨 Gin 使用
sync.Pool复用gin.Context。请求返回后 context 可能被回收,goroutine 仍持有引用会导致竞态条件。
忽略上下文取消
// ❌ 不使用 ctx 感知的 API
rows, _ := db.Query("SELECT * FROM big_table")
// 客户端断开后查询仍在执行
// ✅ 使用 QueryRowContext/QueryContext
rows, _ := db.QueryContext(ctx, "SELECT * FROM big_table")上下文取消后写入响应
func handler(c *gin.Context) {
ctx := c.Request.Context()
result, err := doExpensiveWork(ctx)
if err != nil {
if ctx.Err() != nil {
return // 客户端已断开,不要写响应
}
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"result": result})
}数据库集成
Gin 不内置 ORM,但可与任何 Go 数据库库配合使用。
事务中间件
func TransactionMiddleware(db *sql.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tx, err := db.BeginTx(c.Request.Context(), nil)
if err != nil {
c.AbortWithStatusJSON(500, gin.H{"error": "transaction begin failed"})
return
}
c.Set("tx", tx)
c.Next()
// 根据响应状态决定提交或回滚
if c.Writer.Status() >= 400 {
tx.Rollback()
} else {
tx.Commit()
}
}
}使用 GORM
import "gorm.io/gorm"
type App struct {
DB *gorm.DB
}
func (a *App) GetUser(c *gin.Context) {
var user User
// GORM 接受 context.Context
result := a.DB.WithContext(c.Request.Context()).First(&user, c.Param("id"))
if result.Error != nil {
c.JSON(404, gin.H{"error": "not found"})
return
}
c.JSON(200, user)
}测试
基本路由测试
Gin 提供了 net/http/httptest 集成,测试路由无需启动真实服务器:
import (
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func TestPingRoute(t *testing.T) {
router := gin.New()
router.GET("/ping", func(c *gin.Context) {
c.String(200, "pong")
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/ping", nil)
router.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
assert.Equal(t, "pong", w.Body.String())
}带路径参数的测试
func TestGetUser(t *testing.T) {
router := gin.New()
router.GET("/user/:id", func(c *gin.Context) {
id := c.Param("id")
c.JSON(200, gin.H{"id": id})
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/user/123", nil)
router.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
assert.Contains(t, w.Body.String(), `"id":"123"`)
}带 JSON 请求体的测试
func TestCreateUser(t *testing.T) {
router := gin.New()
router.POST("/users", func(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(201, gin.H{"name": req.Name})
})
body := strings.NewReader(`{"name":"willow"}`)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/users", body)
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, 201, w.Code)
}设置 Gin Mode 为测试模式
func TestMain(m *testing.M) {
gin.SetMode(gin.TestMode)
os.Exit(m.Run())
}💡 测试模式下 Gin 会抑制调试日志输出,让测试输出更清晰。
常见陷阱汇总
| 陷阱 | 说明 | 解决方案 |
|---|---|---|
| goroutine 中使用 gin.Context | 导致 data race | 使用 c.Copy() |
不检查 ctx.Done() |
浪费时间在客户端已断开的请求上 | 使用 context-aware API |
| 上下文取消后写响应 | 静默失败或 panic | 写之前检查 ctx.Err() |
| 路由冲突 | :param 与静态路径冲突 |
避免同时使用 /user/:id 和 /user/profile |
MaxMultipartMemory 混淆 |
与 http.MaxBytesReader 职责不同 |
两者配合使用 |
| WebSocket 并发写 | gorilla/websocket 不支持 | 使用 channel 或 mutex |
c.Query() 返回空字符串 |
区分不了"不传"和"传空值" | 使用 c.GetQuery() 检查存在性 |
c.ClientIP() 被伪造 |
未配置 SetTrustedProxies |
显式配置可信代理 |