Skip to content
Go
Gin
高级主题

高级主题

本章覆盖 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 显式配置可信代理