SSE:REST和WS之间的低调英雄

SSE就像是订阅了一个信使服务,服务器在有新闻时向你发送更新,但你不需要维护一个昂贵的双向通信通道。

SSE:REST和WS之间的低调英雄
微信 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

汇智网翻译整理,转载请标明出处