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()
}
关键点解析:
- 通道通信:使用 Go 的 channel 在 goroutine 之间传递数据
- 上下文管理 :
bgCtx确保分析任务不会因客户端断开而中断 - SSE 事件 :发送三种类型的事件:
chunk:进度更新error:错误信息done:任务完成ping:保活心跳
- 超时处理: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]"
每个事件包含两部分:
event:- 事件类型(chunk、error、done、ping)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 接口的实现:
- 架构清晰 :
StreamAPI统一管理所有流式接口 - 模式灵活:支持单篇、系列、续写三种生成模式
- 实时性强:通过 SSE 实现真正的实时内容推送
- 健壮性好:完善的错误处理和资源管理
- 扩展性强:基于接口的设计便于未来添加新的流式功能
SSE 技术为 AI 内容生成应用提供了优秀的用户体验,让用户能够"看到"内容是如何一步步生成的,而不是漫长等待后的"惊喜"。
下期预告:前端状态管理:Zustand Store 设计
在下一篇文章中,我们将深入探讨前端如何优雅地管理 SSE 流式数据。你将学习到:
- Zustand 状态管理库的核心概念
- 如何设计流式数据的状态结构
- 实时更新 UI 的最佳实践
- 与 React 组件的高效集成方案
敬请期待!