SSE:REST和WS之间的低调英雄
SSE就像是订阅了一个信使服务,服务器在有新闻时向你发送更新,但你不需要维护一个昂贵的双向通信通道。
微信 ezpoda免费咨询:AI编程 | AI模型微调| AI私有化部署
AI模型价格对比 | AI工具导航 | ONNX模型库 | Tripo 3D | Meshy AI | ElevenLabs | KlingAI | ArtSpace | Phot.AI | InVideo
一家成长中的 SaaS 公司的平台工程师在一个周二早上排查通知系统时,注意到了一些奇怪的现象。他们的 WebSocket 实现非常稳固,零投诉。但基础设施成本呢?高得离谱。数千个持久连接,每一个都像一个小吸血鬼,从服务器集群中汲取资源。与此同时,他们用于不太关键更新的基于 REST 的轮询方案每隔几秒就用请求轰炸 API,制造了一场不必要流量的雷暴。她不禁想:难道没有介于两者之间的东西吗?
许多架构师面临的挑战是请求-响应模式和全双工通信之间的虚假二分法。我们在需要简单性时选择 REST,在需要实时双向通信时选择 WebSockets。但中间有一片广阔的地带:服务器需要向客户端推送更新,但客户端很少需要回传数据的场景。股票行情、实时仪表盘、进度通知、服务器日志流式传输到浏览器——这些都是伪装成双向高速公路的单行道。这正是 Server-Sent Events (SSE) 悄然出色的领域,它就像那个可靠的朋友,坐在角落里,不需要持续关注,但总在你需要的时候出现。
1、外交信使
Server-Sent Events 可以看作是一种外交信使服务。使用传统 REST,你会不断派送信使(HTTP 请求)来查看是否有新闻:"有什么更新吗?""现在呢?""还没有?"这既令人疲惫又低效。WebSockets 则像是安装了一条专用电话线。功能强大,但无论你是否在通话,都要为那套基础设施付费。
SSE 则不同。它就像是订阅了一个信使服务,服务器在有新闻时向你发送更新,但你不需要维护一个昂贵的双向通信通道。连接保持打开,服务器在有话要说时推送数据,你的客户端负责监听。简单。优雅。HTTP 原生。
在其核心,SSE 构建在标准 HTTP 之上。客户端发出请求,服务器以 text/event-stream 内容类型响应,并保持连接打开。服务器随后可以随时以格式化文本的形式发送事件。浏览器内置的 EventSource API 自动处理重连,你获得一个干净、简洁的接口来接收更新。
2、技术深入
SSE 运行在一个极其简洁的协议上。当客户端建立连接时,服务器以特定的头部响应,然后以文本格式流式传输事件。每个事件遵循一个简单的结构:
event: message_type
data: {"key": "value"}
id: unique_event_id
末尾的空行表示事件的结束。浏览器的 EventSource 会自动解析这些内容,处理重连,甚至跟踪最后接收的事件 ID 以恢复中断的流。
3、协议规范
| 特性 | SSE | WebSockets | 长轮询 |
|---|---|---|---|
| 方向 | 服务器到客户端 | 双向 | 客户端到服务器 |
| 协议 | HTTP | WebSocket (ws://) | HTTP |
| 自动重连 | 内置 | 手动 | 手动 |
| 消息格式 | 文本 (UTF-8) | 文本或二进制 | 任意 |
| 浏览器支持 | 优秀(IE 除外) | 优秀 | 通用 |
| 代理/防火墙友好 | 是 | 有时存在问题 | 是 |
| 连接开销 | 低 | 中等 | 高 |
4、SSE 大放异彩的场景
并非每个实时问题都需要相同的解决方案。SSE 在数据主要单向流动——从服务器到客户端——的场景中发挥最佳。如果你的架构中服务器产生连续的更新流,客户端需要消费这些更新,SSE 通常是最简单且最节省资源的选择。它自然地融入现有的 HTTP 基础设施,这意味着在防火墙、代理和负载均衡器方面更少意外。在以下场景将 SSE 作为首选方案:
- 更新主要从服务器流向客户端:实时动态、通知、监控仪表盘
- 你希望 HTTP 兼容性:无需特殊配置即可通过代理、负载均衡器和 CDN 工作
- 自动重连很重要:浏览器原生处理,支持指数退避
- 你已经投入了 HTTP 基础设施:认证、压缩、缓存都按预期工作
- 资源效率很重要:对于单向通信,开销低于 WebSockets
5、何时应该另寻他法
SSE 是一把锋利的工具,但并非每项工作都适合它。它的单向特性——在正确场景中是最大优势——在客户端需要频繁或复杂地向服务器发送消息时就变成了劣势。在决定使用 SSE 之前,检查以下条件是否适用于你的用例。如果是,不同的协议会更好地服务你,避免你与技术本质对抗。在以下场景跳过 SSE:
- 你需要真正的双向通信:聊天应用、协作编辑、实时游戏
- 二进制数据至关重要:WebSockets 更高效地处理二进制
- 需要支持 Internet Explorer:不过如果你还在支持 IE,我们需要单独谈谈
- 亚秒级延迟至关重要:WebSockets 由于其基于帧的协议,延迟略低
6、动手实践
让我们构建一个实际示例:实时服务器健康监控仪表盘。服务器将向已连接的客户端推送 CPU、内存和请求指标。
我们将创建一个单一的 ASP.NET Core Web API 项目,它同时将 HTML 客户端作为静态文件提供。完成后,你将拥有一个可工作的 SSE 流,只需运行 dotnet run 即可在任何浏览器中测试。
5.1 项目结构
HealthMonitor/
├── HealthMonitor.Api/
│ ├── Controllers/
│ │ └── MetricsController.cs # SSE 流式端点
│ ├── Models/
│ │ └── ServerMetrics.cs # 指标记录
│ ├── Services/
│ │ ├── IAuthService.cs # 认证抽象
│ │ ├── MockAuthService.cs # 开发/测试实现
│ │ └── FakeAuthenticationHandler.cs # 开发认证中间件
│ ├── Program.cs # 启动 + 静态文件
│ └── HealthMonitor.Api.csproj # 引用客户端文件夹
├── HealthMonitor.Client/
│ └── index.html # 浏览器仪表盘
└── HealthMonitor.Tests/
└── SseLoadTest.cs # 并发负载测试
创建项目:
mkdir HealthMonitor && cd HealthMonitor
dotnet new webapi -n HealthMonitor.Api --no-openapi
dotnet new xunit -n HealthMonitor.Tests
mkdir HealthMonitor.Client
5.2 定义模型
从数据模型开始,以便控制器可以干净地引用它:
// HealthMonitor.Api/Models/ServerMetrics.cs
namespace HealthMonitor.Api.Models;
public record ServerMetrics
{
public int CpuUsage { get; init; }
public int MemoryUsage { get; init; }
public int RequestsPerSecond { get; init; }
public DateTime Timestamp { get; init; }
}
5.3 设置流式端点
这是包含认证和重连支持的完整控制器文件(在步骤 3 和 5 中涵盖):
// HealthMonitor.Api/Controllers/MetricsController.cs
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using HealthMonitor.Api.Models;
using HealthMonitor.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace HealthMonitor.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class MetricsController(IAuthService authService) : ControllerBase
{
[Authorize]
[HttpGet("stream")]
public async Task StreamMetrics(
[FromHeader(Name = "Last-Event-ID")] string? lastEventId,
CancellationToken cancellationToken)
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!await authService.CanViewMetrics(userId))
{
Response.StatusCode = StatusCodes.Status403Forbidden;
return;
}
Response.Headers.Append("Content-Type", "text/event-stream");
Response.Headers.Append("Cache-Control", "no-cache");
Response.Headers.Append("Connection", "keep-alive");
// 如果客户端重连,从最后接收的事件恢复
var eventId = int.TryParse(lastEventId, out var id) ? id : 0;
while (!cancellationToken.IsCancellationRequested)
{
try
{
var metrics = await GatherSystemMetrics();
// 格式化为 SSE 事件
var eventData = $"id: {++eventId}\n" +
$"event: metrics\n" +
$"data: {JsonSerializer.Serialize(metrics, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })}\n\n";
var bytes = Encoding.UTF8.GetBytes(eventData);
await Response.Body.WriteAsync(bytes, cancellationToken);
await Response.Body.FlushAsync(cancellationToken);
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
}
catch (Exception ex) when (ex is OperationCanceledException ||
ex is IOException)
{
// 客户端断开连接,优雅退出
break;
}
}
}
private Task<ServerMetrics> GatherSystemMetrics() =>
Task.FromResult(new ServerMetrics
{
CpuUsage = Random.Shared.Next(10, 90),
MemoryUsage = Random.Shared.Next(40, 80),
RequestsPerSecond = Random.Shared.Next(100, 1000),
Timestamp = DateTime.UtcNow
});
}
关键实现细节:
- Content-Type 头:对于 SSE 必须是
text/event-stream - Cache-Control:防止代理缓冲流
- Flush:确保事件立即到达客户端的关键
- 取消处理:优雅处理客户端断开连接
- 事件 ID:跟踪流中的位置,以便重连的客户端可以恢复
5.4 添加认证
控制器使用 [Authorize],因此项目需要注册认证方案。对于开发环境,一个伪造的处理器会自动认证每个请求,这样你就可以在没有真实身份提供者的情况下进行测试。
// HealthMonitor.Api/Services/IAuthService.cs
namespace HealthMonitor.Api.Services;
public interface IAuthService
{
Task<bool> CanViewMetrics(string? userId);
}
// HealthMonitor.Api/Services/MockAuthService.cs
namespace HealthMonitor.Api.Services;
public class MockAuthService : IAuthService
{
public Task<bool> CanViewMetrics(string? userId)
{
// 开发/测试环境允许所有用户。生产环境中替换为真实认证逻辑。
return Task.FromResult(true);
}
}
// HealthMonitor.Api/Services/FakeAuthenticationHandler.cs
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace HealthMonitor.Api.Services;
public class FakeAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public FakeAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock)
: base(options, logger, encoder, clock)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "dev-user") };
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
5.5 组装在一起
Program.cs 注册所有服务,设置伪造认证方案,并将 HealthMonitor.Client/ 文件夹作为静态文件提供,使仪表盘在根 URL 加载:
// HealthMonitor.Api/Program.cs
using Microsoft.Extensions.FileProviders;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddSingleton<HealthMonitor.Api.Services.IAuthService, HealthMonitor.Api.Services.MockAuthService>();
builder.Services.AddAuthentication("DevAuth")
.AddScheme<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions,
HealthMonitor.Api.Services.FakeAuthenticationHandler>("DevAuth", _ => { });
builder.Services.AddAuthorization();
var app = builder.Build();
// 将 HealthMonitor.Client/(兄弟文件夹)作为根静态文件提供
var clientRoot = Path.Combine(builder.Environment.ContentRootPath, "..", "HealthMonitor.Client");
var clientFileProvider = new PhysicalFileProvider(clientRoot);
app.UseDefaultFiles(new DefaultFilesOptions
{
FileProvider = clientFileProvider,
DefaultFileNames = new List<string> { "index.html" }
});
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = clientFileProvider,
RequestPath = ""
});
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
更新 HealthMonitor.Api.csproj 以将客户端文件夹复制到构建输出:
<!-- HealthMonitor.Api/HealthMonitor.Api.csproj -->
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<Content Include="..\HealthMonitor.Client\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
5.6 处理重连
浏览器会自动重连并发送 Last-Event-ID 头。步骤 2 中的控制器已经通过 [FromHeader(Name = "Last-Event-ID")] 读取它,并从中初始化 eventId,因此流会从正确的位置恢复。如果你有持久化的事件存储,可以在进入实时循环之前重放 ID 大于 lastEventId 的遗漏事件。
5.7 创建浏览器仪表盘
此文件位于兄弟文件夹 HealthMonitor.Client/ 中。API 项目引用并将其作为静态文件提供(在步骤 4 中配置),因此 EventSource URL 是相对于同一来源的,没有硬编码端口,也没有 CORS 问题:
<!-- HealthMonitor.Client/index.html -->
<!DOCTYPE html>
<html lang="en">
<body>
<p>CPU: <span id="cpu">--</span></p>
<p>Memory: <span id="memory">--</span></p>
<p>Req/s: <span id="rps">--</span></p>
<script>
const eventSource = new EventSource('/api/metrics/stream', {
withCredentials: true
});
eventSource.addEventListener('metrics', (event) => {
const metrics = JSON.parse(event.data);
updateDashboard(metrics);
console.log(`Event ID: ${event.lastEventId}`);
});
eventSource.addEventListener('error', (error) => {
console.error('SSE error:', error);
// EventSource 会自动尝试重连
});
function updateDashboard(metrics) {
document.getElementById('cpu').textContent = `${metrics.cpuUsage}%`;
document.getElementById('memory').textContent = `${metrics.memoryUsage}%`;
document.getElementById('rps').textContent = metrics.requestsPerSecond;
}
</script>
</body>
</html>
现在从 HealthMonitor.Api/ 运行 dotnet run。检查控制台输出中显示的分配端口(例如 Now listening on: http://localhost:5241),然后打开该 URL。仪表盘将加载并立即开始流式传输指标。
5.8 测试和监控
使用 curl 手动测试:
curl -N -H "Accept: text/event-stream" http://localhost:<PORT>/api/metrics/stream
负载测试 HealthMonitor.Tests/SseLoadTest.cs:
// HealthMonitor.Tests/SseLoadTest.cs
using System.IO;
using System.Net.Http;
using Xunit;
namespace HealthMonitor.Tests;
public class SseLoadTest
{
[Fact]
public async Task Should_Handle_Multiple_Concurrent_Connections()
{
var tasks = Enumerable.Range(0, 100)
.Select(_ => ConnectAndReceiveEvents())
.ToArray();
await Task.WhenAll(tasks);
}
private async Task ConnectAndReceiveEvents()
{
using var client = new HttpClient();
client.Timeout = TimeSpan.FromMinutes(5);
using var response = await client.GetAsync(
"http://localhost:<PORT>/api/metrics/stream", // 将 <PORT> 替换为 dotnet run 输出中显示的端口
HttpCompletionOption.ResponseHeadersRead);
using var stream = await response.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
var eventCount = 0;
while (eventCount < 10) // 接收 10 个事件后退出
{
var line = await reader.ReadLineAsync();
if (line?.StartsWith("data:") == true)
eventCount++;
}
}
}
SSE 连接是持久的。与在毫秒内完成的普通请求不同,每个连接会无限期地保持响应流打开。一个能正常处理单个连接的服务器,在数百个并发连接下可能会崩溃——如果它阻塞线程或耗尽连接池的话。这个测试同时打开 100 个连接,每个读取 10 个事件后退出,以验证服务器是否按照 SSE 承诺的方式进行扩展。在部署前运行此测试。确保 HealthMonitor.Api 已在运行,然后从 HealthMonitor.Tests/ 文件夹执行:
dotnet test
6、结束语
Server-Sent Events 在你的架构工具箱中应占有一席之地:
- 拥抱简单性:SSE 为你提供了服务器推送,同时保持 HTTP 熟悉的语义和基础设施兼容性
- 选择正确的工具:对于服务器到客户端的更新使用 SSE,对于双向通信使用 WebSockets,对于请求-响应使用 REST
- 利用内置功能:自动重连和事件 ID 跟随浏览器的 EventSource API 免费提供
- 自信地扩展:SSE 连接比 WebSockets 更轻量,比轮询效率高无数倍
这是你的作业:看看你当前的架构。你在哪里轮询更新?你在哪里维护 WebSocket 连接只是为了推送偶尔的通知?SSE 能否简化你的生活并降低基础设施成本?
或许最重要的是:你上一次质疑"标准"解决方案是否真的是你特定用例的正确解决方案是什么时候?
原文链接: Server-Sent Events: The Humble Hero Between REST and WebSockets
汇智网翻译整理,转载请标明出处