docs: 添加 10 份技术文档,README 改为中文
- 01-architecture.md: 架构概览 - 02-backend-services.md: 后端服务层 - 03-frontend-interaction.md: 前端交互设计 - 04-database-design.md: 数据库设计 - 05-api-reference.md: API 接口文档 - 06-sse-streaming.md: SSE 流式传输 - 07-llm-integration.md: LLM 集成 - 08-deployment.md: 部署运维 - 09-development-guide.md: 开发指南 - 10-troubleshooting.md: 故障排查
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
# SSE 流式传输
|
||||
|
||||
## 概述
|
||||
|
||||
Server-Sent Events (SSE) 是 PR-Helper 的核心通信机制,用于实时推送克隆进度、PR 生成和代码审查结果。
|
||||
|
||||
## 为什么选择 SSE
|
||||
|
||||
### 对比 WebSocket
|
||||
|
||||
| 特性 | SSE | WebSocket |
|
||||
|------|-----|-----------|
|
||||
| 方向 | 单向(服务器→客户端) | 双向 |
|
||||
| 协议 | HTTP | 独立协议 |
|
||||
| 实现 | 简单 | 复杂 |
|
||||
| 重连 | 自动 | 手动 |
|
||||
| 兼容性 | 好 | 需要升级 |
|
||||
|
||||
### 适用场景
|
||||
|
||||
- 服务器推送数据到客户端
|
||||
- 不需要客户端频繁发送数据
|
||||
- 需要 HTTP 兼容性
|
||||
- 需要自动重连
|
||||
|
||||
## SSE 协议
|
||||
|
||||
### 基本格式
|
||||
|
||||
```
|
||||
event: message_type
|
||||
data: {"key": "value"}
|
||||
|
||||
```
|
||||
|
||||
- `event:` 事件类型(可选)
|
||||
- `data:` 事件数据(JSON 字符串)
|
||||
- 空行分隔事件
|
||||
|
||||
### 多行数据
|
||||
|
||||
```
|
||||
event: content
|
||||
data: {"content": "第一行\n第二行"}
|
||||
```
|
||||
|
||||
## 后端实现
|
||||
|
||||
### 设置响应头
|
||||
|
||||
```go
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
c.Status(http.StatusOK)
|
||||
```
|
||||
|
||||
### 发送事件
|
||||
|
||||
```go
|
||||
sendEvent := func(event string, data interface{}) {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
jsonData = []byte(`{"error":"序列化事件数据失败"}`)
|
||||
}
|
||||
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, jsonData)
|
||||
flusher.Flush()
|
||||
}
|
||||
```
|
||||
|
||||
### 获取 Flusher
|
||||
|
||||
```go
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器不支持流式传输"})
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
## 前端实现
|
||||
|
||||
### SSE 客户端
|
||||
|
||||
```javascript
|
||||
const SSE = {
|
||||
async post(url, body, handlers = {}) {
|
||||
const controller = new AbortController();
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text();
|
||||
// 处理错误...
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let currentEvent = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
currentEvent = line.slice(7).trim();
|
||||
} else if (line.startsWith('data: ')) {
|
||||
const raw = line.slice(6);
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch (_) {
|
||||
data = raw;
|
||||
}
|
||||
|
||||
if (currentEvent && handlers[currentEvent]) {
|
||||
handlers[currentEvent](data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (handlers.done) handlers.done();
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
if (handlers.error) handlers.error({ message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
return { abort: () => controller.abort() };
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 使用示例
|
||||
|
||||
```javascript
|
||||
SSE.post('/api/repos/1/review', {
|
||||
base: 'main',
|
||||
head: 'feature',
|
||||
top_n: 20,
|
||||
concurrency: 5
|
||||
}, {
|
||||
// 开始事件
|
||||
start: (data) => {
|
||||
console.log(`开始审查 ${data.total_files} 个文件`);
|
||||
updateProgress(0, data.reviewed_files);
|
||||
},
|
||||
|
||||
// 文件开始
|
||||
file_start: (data) => {
|
||||
console.log(`正在审查 ${data.file} (${data.index}/${data.total})`);
|
||||
highlightCurrentFile(data.file);
|
||||
},
|
||||
|
||||
// 内容流
|
||||
content: (data) => {
|
||||
appendToOutput(data.content);
|
||||
},
|
||||
|
||||
// 审查建议
|
||||
suggestion: (data) => {
|
||||
showSuggestion(data.file, data.severity, data.content);
|
||||
},
|
||||
|
||||
// 文件结束
|
||||
file_end: (data) => {
|
||||
markFileComplete(data.file);
|
||||
},
|
||||
|
||||
// 汇总
|
||||
summary: (data) => {
|
||||
showSummary(data.score, data.overall, data.findings, data.recommendations);
|
||||
},
|
||||
|
||||
// 分析保存
|
||||
analysis_saved: (data) => {
|
||||
console.log(`分析已保存,ID: ${data.analysis_id}`);
|
||||
},
|
||||
|
||||
// 进度
|
||||
progress: (data) => {
|
||||
if (data.step === 'generating_summary') {
|
||||
showSpinner('生成汇总中...');
|
||||
}
|
||||
},
|
||||
|
||||
// 完成
|
||||
done: () => {
|
||||
console.log('审查完成');
|
||||
hideSpinner();
|
||||
},
|
||||
|
||||
// 错误
|
||||
error: (data) => {
|
||||
console.error('错误:', data.message);
|
||||
showError(data.message);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 事件类型
|
||||
|
||||
### PR 生成事件
|
||||
|
||||
| 事件 | 数据 | 说明 |
|
||||
|------|------|------|
|
||||
| `content` | `{content: string}` | Markdown 片段 |
|
||||
| `done` | `{content: ""}` | 完成 |
|
||||
|
||||
### 代码审查事件
|
||||
|
||||
| 事件 | 数据 | 说明 |
|
||||
|------|------|------|
|
||||
| `start` | `{total_files, reviewed_files, top_n}` | 开始 |
|
||||
| `file_start` | `{file, index, total}` | 文件开始 |
|
||||
| `content` | `{content: string}` | LLM 输出 |
|
||||
| `suggestion` | `{file, severity, content}` | 建议 |
|
||||
| `file_end` | `{file}` | 文件结束 |
|
||||
| `summary` | `{score, overall, findings, recommendations}` | 汇总 |
|
||||
| `progress` | `{step: string}` | 进度 |
|
||||
| `error` | `{message: string}` | 错误 |
|
||||
| `analysis_saved` | `{analysis_id}` | 保存完成 |
|
||||
| `done` | `{content: ""}` | 完成 |
|
||||
|
||||
### 克隆事件
|
||||
|
||||
| 事件 | 数据 | 说明 |
|
||||
|------|------|------|
|
||||
| `progress` | `{step, current, total}` | 进度 |
|
||||
| `error` | `{message: string}` | 错误 |
|
||||
| `done` | `{repo_id, branches, tags, commit_num}` | 完成 |
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 后端错误
|
||||
|
||||
```go
|
||||
if err != nil {
|
||||
sendEvent("error", map[string]interface{}{
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
### 前端错误
|
||||
|
||||
```javascript
|
||||
error: (data) => {
|
||||
// 显示错误消息
|
||||
showErrorToast(data.message);
|
||||
|
||||
// 重置 UI
|
||||
resetProgressBar();
|
||||
hideSpinner();
|
||||
}
|
||||
```
|
||||
|
||||
### 网络错误
|
||||
|
||||
```javascript
|
||||
catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
// 用户中断,忽略
|
||||
return;
|
||||
}
|
||||
if (handlers.error) {
|
||||
handlers.error({ message: err.message });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 中断请求
|
||||
|
||||
### 前端中断
|
||||
|
||||
```javascript
|
||||
const controller = SSE.post(url, body, handlers);
|
||||
|
||||
// 用户点击取消按钮
|
||||
cancelButton.onclick = () => {
|
||||
controller.abort();
|
||||
};
|
||||
```
|
||||
|
||||
### 后端处理
|
||||
|
||||
- 客户端断开连接时,Gin 会检测到
|
||||
- 服务层应检查 context 取消
|
||||
- 长时间运行的操作应支持取消
|
||||
|
||||
## 性能优化
|
||||
|
||||
### 缓冲控制
|
||||
|
||||
```
|
||||
X-Accel-Buffering: no
|
||||
```
|
||||
|
||||
- 禁用 Nginx 缓冲
|
||||
- 确保事件立即发送
|
||||
|
||||
### 批量发送
|
||||
|
||||
- 避免频繁发送小事件
|
||||
- 合并相关数据
|
||||
|
||||
### 压缩
|
||||
|
||||
- SSE 不支持 gzip 压缩
|
||||
- 数据量大时考虑压缩正文
|
||||
|
||||
## 并发安全
|
||||
|
||||
### 线程安全回调
|
||||
|
||||
```go
|
||||
safeCallback := callback
|
||||
if callback != nil {
|
||||
safeCallback = func(event string, data interface{}) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
callback(event, data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 使用互斥锁保护回调
|
||||
- 避免并发写入
|
||||
|
||||
### Goroutine 管理
|
||||
|
||||
```go
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, file := range files {
|
||||
wg.Add(1)
|
||||
go func(idx int, f FileDiff) {
|
||||
defer wg.Done()
|
||||
// ...
|
||||
}(i, file)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
```
|
||||
|
||||
- 等待所有 goroutine 完成
|
||||
- 避免资源泄漏
|
||||
|
||||
## 测试
|
||||
|
||||
### 手动测试
|
||||
|
||||
```bash
|
||||
curl -N -X POST http://localhost:8080/api/repos/1/review \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"base":"main","head":"feature"}'
|
||||
```
|
||||
|
||||
### 自动化测试
|
||||
|
||||
```go
|
||||
func TestSSEStream(t *testing.T) {
|
||||
// 创建测试服务器
|
||||
// 发送请求
|
||||
// 读取事件流
|
||||
// 验证事件序列
|
||||
}
|
||||
```
|
||||
|
||||
## 监控
|
||||
|
||||
### 关键指标
|
||||
|
||||
- 连接数
|
||||
- 事件发送速率
|
||||
- 错误率
|
||||
- 响应时间
|
||||
|
||||
### 日志
|
||||
|
||||
```go
|
||||
log.Printf("SSE connected: %s", c.ClientIP())
|
||||
log.Printf("SSE event: %s", event)
|
||||
log.Printf("SSE disconnected: %s", c.ClientIP())
|
||||
```
|
||||
|
||||
## 安全考虑
|
||||
|
||||
### 认证
|
||||
|
||||
- 所有 SSE 端点需要认证
|
||||
- 使用会话 Cookie
|
||||
|
||||
### 速率限制
|
||||
|
||||
- 限制并发 SSE 连接数
|
||||
- 限制事件发送频率
|
||||
|
||||
### 数据验证
|
||||
|
||||
- 验证输入参数
|
||||
- 防止注入攻击
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **事件不发送**
|
||||
- 检查 Flusher 是否可用
|
||||
- 确认响应头设置正确
|
||||
- 检查 Nginx 缓冲配置
|
||||
|
||||
2. **连接断开**
|
||||
- 检查超时设置
|
||||
- 确认网络稳定
|
||||
- 查看错误日志
|
||||
|
||||
3. **数据乱码**
|
||||
- 确认 JSON 序列化正确
|
||||
- 检查字符编码
|
||||
- 验证事件格式
|
||||
|
||||
### 调试工具
|
||||
|
||||
- 浏览器开发者工具 Network 面板
|
||||
- curl 命令行测试
|
||||
- Wireshark 抓包
|
||||
Reference in New Issue
Block a user