Server-Sent Events (SSE) 接口实现

Server-Sent Events (SSE) 接口实现:构建实时博客生成流

本文是 墨言博客助手 (InkWords) 技术系列的第 20 篇,完整源码请访问:https://github.com/2692341798/InkWords

引言:为什么需要实时流式传输?

想象一下这样的场景:你在一个在线文档编辑器里写文章,每敲一个字,页面就实时显示出来。这种即时反馈的体验,远比"点击生成→等待30秒→看到完整结果"要好得多。

在 AI 内容生成领域,这个需求尤为迫切。一篇技术博客可能需要 2-3 分钟才能生成完毕,如果让用户盯着空白页面等待,体验会非常糟糕。这就是为什么我们需要 Server-Sent Events (SSE) 技术。

什么是 SSE?

SSE 是一种允许服务器主动向客户端推送数据的技术。与 WebSocket 的双向通信不同,SSE 是单向的:服务器推,客户端收。这正好符合我们的需求:AI 模型生成一段内容,我们就推送给前端显示一段。
前端请求生成
后端接收请求
启动 AI 生成任务
生成第一段内容
通过 SSE 推送
前端实时显示
生成第二段内容
...持续生成...
生成完成
发送完成事件

StreamAPI 结构设计

让我们先看看 StreamAPI 的整体结构:

go 复制代码
// StreamAPI handles SSE streaming requests
type StreamAPI struct {
    generatorService     *service.GeneratorService
    decompositionService *service.DecompositionService
}

// NewStreamAPI creates a new StreamAPI instance
func NewStreamAPI() *StreamAPI {
    return &StreamAPI{
        generatorService:     service.NewGeneratorService(),
        decompositionService: service.NewDecompositionService(),
    }
}

代码解析:

  • StreamAPI 是一个结构体,包含两个服务实例
  • generatorService:负责单篇博客的生成
  • decompositionService:负责系列博客的分析和生成
  • 这种设计遵循了单一职责原则,每个服务只做自己最擅长的事

核心请求结构

所有流式生成请求都使用相同的结构体:

go 复制代码
type GenerateRequest struct {
    SourceContent string            `json:"source_content"`  // 源内容
    SourceType    string            `json:"source_type"`     // 内容类型
    Outline       []service.Chapter `json:"outline"`         // 大纲(系列生成用)
    GitURL        string            `json:"git_url"`         // Git仓库地址
    SeriesTitle   string            `json:"series_title"`    // 系列标题
    ParentID      string            `json:"parent_id"`       // 父博客ID(续写用)
}

核心处理器详解

1. AnalyzeStreamHandler:Git仓库分析流

这个处理器用于分析 Git 仓库,生成博客大纲:

go 复制代码
func (api *StreamAPI) AnalyzeStreamHandler(c *gin.Context) {
    // 1. 解析请求体
    var req GenerateRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
        return
    }
    
    // 2. 验证必要参数
    if req.GitURL == "" {
        c.JSON(http.StatusBadRequest, gin.H{"error": "git_url is required"})
        return
    }
    
    // 3. 创建通信通道
    progressChan := make(chan string)  // 进度消息通道
    errChan := make(chan error)        // 错误通道
    
    // 4. 创建后台上下文(即使客户端断开,分析任务继续)
    bgCtx := context.WithoutCancel(c.Request.Context())
    ctx := c.Request.Context()
    
    // 5. 使用 WaitGroup 确保 goroutine 完成
    var wg sync.WaitGroup
    wg.Add(1)
    
    // 6. 启动分析任务(在单独的 goroutine 中)
    go func() {
        defer wg.Done()
        api.decompositionService.AnalyzeStream(bgCtx, req.GitURL, progressChan, errChan)
    }()
    
    // 7. 设置 SSE 响应头
    c.Writer.Header().Set("Content-Type", "text/event-stream")
    c.Writer.Header().Set("Cache-Control", "no-cache")
    c.Writer.Header().Set("Connection", "keep-alive")
    
    // 8. 核心流式处理逻辑
    c.Stream(func(w io.Writer) bool {
        select {
        case <-ctx.Done():
            // 客户端断开连接
            go func() {
                for {
                    select {
                    case <-progressChan:
                    case err, ok := <-errChan:
                        if !ok || err != nil {
                            return
                        }
                    }
                }
            }()
            return false
            
        case err, ok := <-errChan:
            if ok && err != nil {
                c.SSEvent("error", err.Error())
                return false
            }
            if !ok {
                errChan = nil
            }
            return true
            
        case msg, ok := <-progressChan:
            if !ok {
                c.SSEvent("done", "[DONE]")
                return false
            }
            c.SSEvent("chunk", msg)
            return true
            
        case <-time.After(15 * time.Second):
            // 保活心跳(防止代理超时)
            c.SSEvent("ping", "keepalive")
            return true
        }
    })
    
    // 9. 等待任务完成(在单独的 goroutine 中)
    go wg.Wait()
}

关键点解析:

  1. 通道通信:使用 Go 的 channel 在 goroutine 之间传递数据
  2. 上下文管理bgCtx 确保分析任务不会因客户端断开而中断
  3. SSE 事件 :发送三种类型的事件:
    • chunk:进度更新
    • error:错误信息
    • done:任务完成
    • ping:保活心跳
  4. 超时处理:15 秒发送一次心跳,防止代理服务器断开连接

2. GenerateBlogStreamHandler:博客生成流

这是最核心的生成处理器,支持单篇和系列博客生成:

go 复制代码
func (api *StreamAPI) GenerateBlogStreamHandler(c *gin.Context) {
    // 1. 解析请求
    var req GenerateRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
        return
    }
    
    // 2. 创建通信通道
    chunkChan := make(chan string)
    errChan := make(chan error)
    
    ctx := c.Request.Context()
    
    // 3. 获取用户ID(从认证中间件)
    var userID uuid.UUID
    if v, exists := c.Get("user_id"); exists {
        if id, ok := v.(uuid.UUID); ok {
            userID = id
        }
    }
    if userID == uuid.Nil {
        // 测试回退:生成临时UUID
        userID = uuid.New()
    }
    
    // 4. 根据是否有大纲决定生成模式
    if len(req.Outline) > 0 {
        // 系列生成模式
        var parentID uuid.UUID
        if req.ParentID != "" {
            parsedID, err := uuid.Parse(req.ParentID)
            if err == nil {
                parentID = parsedID
            }
        }
        if parentID == uuid.Nil {
            parentID = uuid.New()
        }
        
        go api.decompositionService.GenerateSeries(
            ctx, userID, parentID, req.SeriesTitle, 
            req.Outline, req.SourceContent, req.SourceType, 
            req.GitURL, chunkChan, errChan,
        )
    } else {
        // 单篇博客生成
        go api.generatorService.GenerateBlogStream(
            ctx, userID, req.SourceContent, 
            req.SourceType, chunkChan, errChan,
        )
    }
    
    // 5. 设置SSE头并开始流式响应
    c.Writer.Header().Set("Content-Type", "text/event-stream")
    c.Writer.Header().Set("Cache-Control", "no-cache")
    c.Writer.Header().Set("Connection", "keep-alive")
    
    c.Stream(func(w io.Writer) bool {
        select {
        case <-ctx.Done():
            // 客户端断开处理(与AnalyzeStreamHandler类似)
            go func() {
                for {
                    select {
                    case <-chunkChan:
                    case err, ok := <-errChan:
                        if !ok || err != nil {
                            return
                        }
                    }
                }
            }()
            return false
            
        case err, ok := <-errChan:
            if ok && err != nil {
                c.SSEvent("error", err.Error())
                return false
            }
            if !ok {
                errChan = nil
            }
            return true
            
        case chunk, ok := <-chunkChan:
            if !ok {
                c.SSEvent("done", "[DONE]")
                return false
            }
            c.SSEvent("chunk", chunk)
            return true
            
        case <-time.After(15 * time.Second):
            c.SSEvent("ping", "keepalive")
            return true
        }
    })
}

模式选择逻辑:

  • 单篇生成 :直接调用 generatorService.GenerateBlogStream
  • 系列生成 :需要处理父子关系,调用 decompositionService.GenerateSeries

3. ContinueBlogStreamHandler:续写生成流

这个处理器允许用户基于已有的博客继续生成:

go 复制代码
func (api *StreamAPI) ContinueBlogStreamHandler(c *gin.Context) {
    // 1. 解析博客ID
    blogIDStr := c.Param("id")
    blogID, err := uuid.Parse(blogIDStr)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid blog ID"})
        return
    }
    
    // 2. 必须要有用户认证
    var userID uuid.UUID
    if v, exists := c.Get("user_id"); exists {
        if id, ok := v.(uuid.UUID); ok {
            userID = id
        }
    }
    if userID == uuid.Nil {
        c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
        return
    }
    
    // 3. 创建通道并启动续写任务
    chunkChan := make(chan string)
    errChan := make(chan error)
    
    bgCtx := context.WithoutCancel(c.Request.Context())
    ctx := c.Request.Context()
    
    go api.decompositionService.ContinueGeneration(bgCtx, userID, blogID, chunkChan, errChan)
    
    // 4. SSE流式响应(逻辑与前面类似)
    // ... 省略相似代码 ...
}

续写功能特点:

  • 需要有效的用户认证
  • 基于特定的博客ID继续生成
  • 使用 context.WithoutCancel 确保任务不会中断

SSE 事件格式详解

SSE 事件有特定的格式要求。让我们看看 Gin 框架的 c.SSEvent 方法发送的是什么:

javascript 复制代码
// 前端接收到的 SSE 数据格式:
event: chunk
data: "这是生成的第一段内容"

event: chunk  
data: "这是生成的第二段内容"

event: ping
data: "keepalive"

event: error
data: "生成失败:API调用超时"

event: done
data: "[DONE]"

每个事件包含两部分:

  1. event: - 事件类型(chunk、error、done、ping)
  2. data: - 事件数据内容

实战:如何测试 SSE 接口?

使用 curl 测试

bash 复制代码
# 测试分析流
curl -X POST http://localhost:8080/api/v1/stream/analyze \
  -H "Content-Type: application/json" \
  -d '{"git_url": "https://github.com/2692341798/InkWords"}' \
  -N

# 测试生成流  
curl -X POST http://localhost:8080/api/v1/stream/generate \
  -H "Content-Type: application/json" \
  -d '{"source_content": "Go语言并发编程", "source_type": "topic"}' \
  -N

使用 JavaScript 测试

html 复制代码
<!DOCTYPE html>
<html>
<body>
  <div id="output"></div>
  
  <script>
    const eventSource = new EventSource('/api/v1/stream/generate');
    
    eventSource.addEventListener('chunk', (event) => {
      document.getElementById('output').innerHTML += event.data;
    });
    
    eventSource.addEventListener('error', (event) => {
      console.error('Error:', event.data);
      eventSource.close();
    });
    
    eventSource.addEventListener('done', () => {
      console.log('Generation completed');
      eventSource.close();
    });
  </script>
</body>
</html>

性能优化与注意事项

1. 内存管理

  • 使用 channel 进行数据传递,避免大内存占用
  • 及时关闭不再使用的 channel
  • 使用 context 控制 goroutine 生命周期

2. 连接管理

  • 15 秒心跳防止代理超时
  • 客户端断开时清理资源
  • 使用 sync.WaitGroup 确保 goroutine 正确退出

3. 错误处理

  • 区分客户端错误和服务端错误
  • 错误信息通过 SSE 事件发送,而不是 HTTP 状态码
  • 使用独立的 error channel 传递错误

总结

通过本文的讲解,我们深入了解了 InkWords 后端 SSE 接口的实现:

  1. 架构清晰StreamAPI 统一管理所有流式接口
  2. 模式灵活:支持单篇、系列、续写三种生成模式
  3. 实时性强:通过 SSE 实现真正的实时内容推送
  4. 健壮性好:完善的错误处理和资源管理
  5. 扩展性强:基于接口的设计便于未来添加新的流式功能

SSE 技术为 AI 内容生成应用提供了优秀的用户体验,让用户能够"看到"内容是如何一步步生成的,而不是漫长等待后的"惊喜"。


下期预告:前端状态管理:Zustand Store 设计

在下一篇文章中,我们将深入探讨前端如何优雅地管理 SSE 流式数据。你将学习到:

  • Zustand 状态管理库的核心概念
  • 如何设计流式数据的状态结构
  • 实时更新 UI 的最佳实践
  • 与 React 组件的高效集成方案

敬请期待!

相关推荐
ZHENGZJM2 小时前
统一响应封装与 API 错误处理
react.js·go·gin
不会写DN2 小时前
处理非 UTF-8 输入:GB18030 回退策略
后端·go
人道领域2 小时前
GPT-5架构泄露?Kubernetes 1.31发布与Rust重构浪潮下的云原生之变
gpt·云原生·架构
ZHENGZJM3 小时前
仓库抓取与内容提取
go·gin
人道领域3 小时前
【黑马点评日记02】Redis解决Tomcat集群Session共享问题
java·前端·后端·架构·tomcat·firefox
cheems95273 小时前
[JavaEE]深度解构 Spring 核心:从控制反转 (IoC) 到依赖注入 (DI) 的架构演进
java·spring·架构·java-ee
立莹Sir3 小时前
【架构图解+实战配置】SaaS多租户资源隔离的云原生完整方案
云原生·架构
LONGZETECH3 小时前
破解智能网联汽车教学痛点!龙泽科技AR仿真软件,重新定义实训新范式
科技·架构·汽车·ar·职业教育·汽车仿真教学软件·汽车故障诊断
段一凡-华北理工大学12 小时前
【大模型+知识图谱+工业智能体技术架构】~系列文章01:快速了解与初学入门!!!
人工智能·python·架构·知识图谱·工业智能体