touka/docs/sse.md
wjqserver 2f94763c65 fix(SSE)!: redesign EventStreamChan to prevent context pool recycling
BREAKING CHANGE: EventStreamChan signature changed from
  (chan<- Event, <-chan error)
to
  (eventChan <-chan Event)
The caller now creates and passes the channel instead of receiving it.
The errChan return value is removed.

The old non-blocking design allowed the handler to return before the SSE
stream ended, causing ServeHTTP to return the Context to the pool while
the internal goroutine was still writing to the pooled writer — a data
race across requests. The new blocking design keeps the handler inside
EventStreamChan until the event channel is closed or the client
disconnects, ensuring the Context remains bound throughout the stream.

- Caller creates channel, producer goroutine sends events
- EventStreamChan blocks handler until stream ends
- Internal goroutine captures stable references (Flusher, context.Context)
  instead of holding *Context pointer
- Nil guard on Flusher type assertion
- Add sse_test.go covering blocking, disconnect, and event format
- Update docs/sse.md for new API
2026-03-29 15:42:01 +08:00

4.8 KiB
Raw Blame History

Server-Sent Events (SSE)

Server-Sent Events 允许服务器向客户端实时推送数据。Touka 对此提供了原生且易用的支持。

核心结构:Event

Event 结构体代表一个 SSE 消息:

type Event struct {
    Event string // 事件名称
    Data  string // 数据内容 (支持多行)
    Id    string // 事件 ID
    Retry string // 重连时间 (毫秒)
}

模式一:回调模式 (EventStream)

这是最推荐的使用方式,它更简单且能自动管理连接生命周期。

r.GET("/events", func(c *touka.Context) {
    c.EventStream(func(w io.Writer) bool {
        // 构建事件
        event := touka.Event{
            Data: "现在的时间是: " + time.Now().Format(time.RFC3339),
        }

        // 渲染并写入
        if err := event.Render(w); err != nil {
            return false // 发生写入错误(如客户端断开),返回 false 停止流
        }

        time.Sleep(2 * time.Second)
        return true // 返回 true 继续下一次循环
    })
})

模式二:通道模式 (EventStreamChan)

如果您需要更高级的并发控制(例如从多个异步源接收数据),可以使用通道模式。与回调模式类似,此方法是阻塞的handler 会在此方法中停留,直到事件 channel 被关闭或客户端断开连接。

r.GET("/events-chan", func(c *touka.Context) {
    eventChan := make(chan touka.Event)

    // 在独立的 goroutine 中发送事件.
    go func() {
        defer close(eventChan) // 务必在结束时关闭以结束事件流.

        for i := 0; i < 10; i++ {
            select {
            case <-c.Request.Context().Done():
                return // 客户端已断开, 退出 goroutine.
            default:
                eventChan <- touka.Event{
                    Data: fmt.Sprintf("消息 #%d", i),
                }
                time.Sleep(1 * time.Second)
            }
        }
    }()

    // EventStreamChan 会阻塞直到流结束.
    c.EventStreamChan(eventChan)
})

最佳实践

  1. 资源回收: EventStreamChan 是阻塞的handler 在事件流结束前不会返回。请确保生产者 goroutine 在 select 中监听 c.Request.Context().Done() 以响应客户端断开。
  2. 关闭 Channel: 生产者完成发送后必须 close(eventChan),否则 handler 会永远阻塞。
  3. 数据格式: SSE 协议要求数据为 UTF-8。Touka 的 Render 方法会自动处理多行数据并加上必要的 data: 前缀。
  4. 超时管理: SSE 连接通常是长连接,请确保您的反向代理(如 Nginx配置了足够大的写超时时间。

优雅关闭与资源清理

在长连接场景下,正确处理客户端断开或服务器关闭信号至关重要,以防止资源泄漏。

示例:监听 Context 取消信号

r.GET("/events-graceful", func(c *touka.Context) {
    // 设置响应头如果手动处理EventStream 会自动设置)

    // 使用 Context 的 Done 通道来感知连接关闭
    ctx := c.Request.Context()

    // 启动一个用于模拟数据生成的循环
    ticker := time.NewTicker(2 * time.Second)
    defer ticker.Stop()

    c.EventStream(func(w io.Writer) bool {
        select {
        case <-ctx.Done():
            // 收到优雅关闭信号(客户端离开或服务器正在关闭)
            fmt.Println("SSE 连接正在关闭,开始清理资源...")
            return false // 返回 false 告知框架停止流

        case t := <-ticker.C:
            event := touka.Event{
                Data: "Tick at " + t.Format(time.RFC3339),
            }
            if err := event.Render(w); err != nil {
                return false
            }
            return true
        }
    })

    fmt.Println("SSE 连接已彻底释放")
})

在该示例中,我们显式地在回调函数中使用 select 监听 ctx.Done()。虽然 Touka 的 EventStream 内部也会检查此信号,但在回调内部自行处理可以执行更复杂的清理逻辑(如关闭数据库连接、停止特定的 Goroutine 等)。

为什么会出现 "context deadline exceeded"

如果您在优雅停机时遇到 context deadline exceeded 错误,通常是因为 SSE 连接仍然活跃,而 http.Server.Shutdown 正在等待它们结束。

在 Touka 的新版本中,我们通过 BaseContextEngine 的关闭信号注入到了每个请求的 Context 中。这意味着:

  1. 当服务器收到关闭信号时,engine.shutdownCtx 会被取消。
  2. 随后,所有活跃请求的 c.Request.Context() 也会收到取消信号。
  3. 您的 SSE 处理器中的 case <-c.Request.Context().Done(): 会立即触发,从而优雅地结束连接。

注意: 请务必使用 RunShutdownRunTLSRunTLSRedir 来启动服务器,以便框架能自动管理这些信号。