.NET 8 实时推送魔法:SSE 让数据 “主动跑” 到客户端

引言

在实时Web应用开发中,Server-Sent Events(SSE)作为一种轻量级的服务器推送技术,正成为提升用户体验的重要方案。SSE基于HTTP协议,允许服务器通过持久连接主动向客户端推送数据流,特别适合新闻更新、实时监控等场景。.NET 8通过性能优化和服务器端增强,为SSE提供了更强大的支持,使其成为构建高效实时应用的理想选择。

基本原理

SSE是一种基于HTTP/1.1的单向通信技术,核心是通过text/event-stream MIME类型建立持久连接。客户端使用EventSource接口监听服务器推送的事件流,服务器则以分块编码形式持续发送数据。与轮询相比,SSE显著降低网络开销;与WebSocket相比,SSE实现更简单,特别适合服务器到客户端的单向数据推送。

后端代码实现

1、Controller层代码实现

C# 复制代码
/// <summary>
/// 建立 SSE 流连接的端点
/// 客户端通过此端点建立持久连接以接收服务器推送的事件
/// </summary>
/// <param name="clientId">客户端唯一标识符(可选)</param>
/// <returns>异步任务</returns>
[HttpGet("stream")]
public async Task StreamEvents([FromQuery] string clientId)
{
    // 如果客户端未提供ID,则生成一个唯一的ID
    if (string.IsNullOrEmpty(clientId))
    {
        clientId = Guid.NewGuid().ToString();
    }

    // 设置响应头以支持 SSE 协议
    Response.Headers.Append("Content-Type", "text/event-stream");         // 指定内容类型为 SSE
    Response.Headers.Append("Cache-Control", "no-cache");                 // 禁用缓存
    Response.Headers.Append("Connection", "keep-alive");                  // 保持连接
    Response.Headers.Append("Access-Control-Allow-Origin", "*");          // 允许跨域访问

    // 将当前客户端添加到 SSE 服务管理器中
    await _sseService.AddClientAsync(clientId, Response);

    // 保持连接打开,直到客户端断开或请求被取消
    try
    {
        // 循环检查请求是否已被取消
        while (!HttpContext.RequestAborted.IsCancellationRequested)
        {
            // 每隔1秒检查一次连接状态
            await Task.Delay(1000, HttpContext.RequestAborted);
        }
    }
    catch (OperationCanceledException)
    {
        _logger.LogInformation($"Client {clientId} disconnected (canceled)");
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, $"Error in SSE stream for client: {clientId}");
    }
    finally
    {
        await _sseService.RemoveClientAsync(clientId);
        _logger.LogInformation($"SSE connection cleaned up for client: {clientId}");
    }
}

2、Service代码实现

C# 复制代码
/// <summary>
/// Server-Sent Events (SSE) 服务接口
/// 提供向客户端推送实时事件的功能
/// </summary>
public interface ISseService
{
    /// <summary>
    /// 向指定客户端发送事件数据
    /// </summary>
    /// <param name="clientId">客户端唯一标识符</param>
    /// <param name="data">要发送的数据</param>
    /// <returns>异步任务</returns>
    Task SendEventAsync(string clientId, string data);

    /// <summary>
    /// 向指定客户端发送指定类型的事件数据
    /// </summary>
    /// <param name="clientId">客户端唯一标识符</param>
    /// <param name="eventType">事件类型</param>
    /// <param name="data">要发送的数据</param>
    /// <returns>异步任务</returns>
    Task SendEventAsync(string clientId, string eventType, string data);

    /// <summary>
    /// 添加新的 SSE 客户端连接
    /// </summary>
    /// <param name="clientId">客户端唯一标识符</param>
    /// <param name="response">HTTP 响应对象</param>
    /// <returns>异步任务</returns>
    Task AddClientAsync(string clientId, HttpResponse response);

    /// <summary>
    /// 移除指定的客户端连接
    /// </summary>
    /// <param name="clientId">客户端唯一标识符</param>
    /// <returns>异步任务</returns>
    Task RemoveClientAsync(string clientId);

    Task BroadcastAsync(string data);
    Task BroadcastAsync(string eventType, string data);
}


/// <summary>
/// SSE 服务实现类
/// 管理所有客户端连接并向其推送实时数据
/// </summary>
public class SseService : ISseService
{
    private ILogger<SseService> _logger;

    public SseService(ILogger<SseService> logger)
    {
        _logger = logger;
    }

    /// <summary>
    /// 存储所有活动的客户端连接
    /// 使用线程安全的字典存储客户端ID和对应的HttpResponse
    /// </summary>
    private readonly ConcurrentDictionary<string, HttpResponse> _clients = new();

    /// <summary>
    /// 添加新的 SSE 客户端连接到管理器
    /// </summary>
    /// <param name="clientId">客户端唯一标识符</param>
    /// <param name="response">HTTP 响应对象</param>
    /// <returns>异步任务</returns>
    public async Task AddClientAsync(string clientId, HttpResponse response)
    {
        // 将客户端添加到连接字典中
        _clients.TryAdd(clientId, response);
        // 发送连接确认事件给客户端
        await SendEventAsync(clientId, "connection", $"Connected with ID: {clientId}");
    }

    /// <summary>
    /// 从管理器中移除指定的客户端连接
    /// </summary>
    /// <param name="clientId">要移除的客户端ID</param>
    /// <returns>异步任务</returns>
    public async Task RemoveClientAsync(string clientId)
    {
        // 尝试从字典中移除客户端并获取其响应对象
        if (_clients.TryRemove(clientId, out var response))
        {
            // 完成 HTTP 响应
            //await response.CompleteAsync(); 
            // 清空缓冲区并关闭流。这实际上等同于完成了响应。但更常用的是不需要手动调用此方法。 
            await response.Body.FlushAsync();
        }
    }

    /// <summary>
    /// 向指定客户端发送默认类型的事件消息
    /// </summary>
    /// <param name="clientId">目标客户端ID</param>
    /// <param name="data">要发送的数据</param>
    /// <returns>异步任务</returns>
    public async Task SendEventAsync(string clientId, string data)
    {
        // 调用重载方法,使用默认的消息类型
        await SendEventAsync(clientId, "message", data);
    }

    /// <summary>
    /// 向指定客户端发送指定类型的事件消息
    /// </summary>
    /// <param name="clientId">目标客户端ID</param>
    /// <param name="eventType">事件类型(如:message, notification, update等)</param>
    /// <param name="data">要发送的数据内容</param>
    /// <returns>异步任务</returns>
    public async Task SendEventAsync(string clientId, string eventType, string data)
    {
        // 检查客户端是否仍然连接
        if (_clients.TryGetValue(clientId, out var response))
        {
            try
            {
                // 按照 SSE 格式写入事件类型
                await response.WriteAsync($"event: {eventType}\n");
                // 按照 SSE 格式写入数据内容
                await response.WriteAsync($"data: {data}\n\n");
                // 刷新响应流,确保数据立即发送到客户端
                await response.Body.FlushAsync();
            }
            catch
            {
                // 如果发生异常(如客户端断开连接),则移除该客户端
                await RemoveClientAsync(clientId);
            }
        }
    }

    /// <summary>
    /// 向所有连接的客户端广播消息
    /// </summary>
    /// <param name="data">要广播的数据</param>
    /// <returns>异步任务</returns>
    public async Task BroadcastAsync(string data)
    {
        // 为每个客户端创建发送任务并等待全部完成
        var tasks = _clients.Keys.Select(clientId => SendEventAsync(clientId, data));
        await Task.WhenAll(tasks);
    }

    /// <summary>
    /// 向所有连接的客户端广播指定类型的事件
    /// </summary>
    /// <param name="eventType">事件类型</param>
    /// <param name="data">要广播的数据</param>
    /// <returns>异步任务</returns>
    public async Task BroadcastAsync(string eventType, string data)
    {
        // 为每个客户端创建发送任务并等待全部完成
        var tasks = _clients.Keys.Select(clientId => SendEventAsync(clientId, eventType, data));
        await Task.WhenAll(tasks);
    }
}

模拟发送数据

C# 复制代码
这里只是为了模拟数据,具体可根据自己的业务做出调整

    /// <summary>
    /// SSE 后台服务
    /// 定期向所有连接的客户端推送数据更新
    /// </summary>
    public class SseBackgroundService : BackgroundService
    {
        private readonly ISseService _sseService;
        /// <summary>
        /// 日志记录器,用于记录服务运行状态和错误信息
        /// </summary>
        private readonly ILogger<SseBackgroundService> _logger;

        /// <summary>
        /// 构造函数,通过依赖注入获取所需的服务
        /// </summary>
        /// <param name="scopeFactory">服务作用域工厂</param>
        /// <param name="logger">日志记录器</param>
        public SseBackgroundService(ISseService sseService, ILogger<SseBackgroundService> logger)
        {
            _sseService = sseService;
            _logger = logger;
        }

        /// <summary>
        /// 后台服务执行方法
        /// 定期执行任务直到服务停止
        /// </summary>
        /// <param name="stoppingToken">服务停止通知令牌</param>
        /// <returns>异步任务</returns>
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            // 循环执行直到收到停止信号
            while (!stoppingToken.IsCancellationRequested)
            {
                try
                {
                    // 准备要广播的事件数据
                    var eventData = new
                    {
                        timestamp = DateTime.UtcNow,                    // 当前 UTC 时间戳
                        message = "Server time update",                 // 消息内容
                        temperature = Random.Shared.Next(20, 30)        // 模拟温度数据
                    };

                    // 将事件数据广播给所有连接的客户端
                    await _sseService.BroadcastAsync("time-update", JsonSerializer.Serialize(eventData));

                    // 等待 5 秒后继续下一次循环,支持取消操作
                    await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
                }
                catch (Exception ex)
                {
                    // 记录执行过程中发生的异常
                    _logger.LogError(ex, "Error in SSE background service");
                }
            }
        }
    }

其他配置

C# 复制代码
public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        ConfigureAutofac(builder);

        // Add services to the container.
        builder.Services.AddControllers();

        builder.Services.AddEndpointsApiExplorer();
        builder.Services.AddSwaggerGen();

        var app = builder.Build();
        // Configure the HTTP request pipeline.
        if (app.Environment.IsDevelopment())
        {
            app.UseSwagger();
            app.UseSwaggerUI();
        }

        app.UseHttpsRedirection();
        app.UseAuthorization();
        app.MapControllers();
        app.Run();
    }

    /// <summary>
    /// 配置 Autofac
    /// </summary>
    /// <param name="builder"></param>
    private static void ConfigureAutofac(WebApplicationBuilder builder)
    {
        // 配置 Autofac 作为 DI 容器
        builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory());

        // 配置容器注册
        builder.Host.ConfigureContainer<ContainerBuilder>(containerBuilder =>
        {
            // 重点内容,这里必须要注册成单例模式
            containerBuilder.RegisterType<SseService>().As<ISseService>().SingleInstance();
            // Autofac注册
            containerBuilder.RegisterModule(new AutofacModuleRegister());
            // 注册后台服务,项目启动时就会执行
            containerBuilder.RegisterType<SseBackgroundService>().As<IHostedService>();
        });
    }
}

前端代码

JS 复制代码
<script>
    function sendMessage() {
        // 连接到 SSE 流
        const clientId = 'client-' + Math.random().toString(36).substr(2, 9);
        const eventSource = new EventSource(`替换成你的域名/api/sse/stream?clientId=${clientId}`);

        eventSource.onopen = function(event) {
            console.log('SSE connection opened');
        };

        eventSource.onmessage = function(event) {
            console.log('Message received:', event.data);
        };

        eventSource.addEventListener('connection', function(event) {
            console.log('Connection event:', event.data);
        });

        eventSource.addEventListener('time-update', function(event) {
            const data = JSON.parse(event.data);
            console.log('Time update:', data);
            document.getElementById('server-time').innerText = data.timestamp;
        });

        eventSource.onerror = function(event) {
            console.error('SSE error occurred');
        };
    }
        
</script>

<div class="text-center">
    <div>
        <input type="button" value="Send message" onclick="sendMessage()" />
    </div>
</div>

最后效果

相关推荐
00后程序员1 小时前
如何解决浏览器HTTPS不安全连接警告及SSL证书问题
后端
00后程序员1 小时前
苹果App上架审核延迟7工作日无反应:如何通过App Store Connect和邮件询问进度
后端
DS小龙哥1 小时前
基于物联网设计的蜂箱智能监测系统设计
后端
QZQ541881 小时前
C++编译期计算
后端
饕餮争锋1 小时前
Spring内置的Bean作用域介绍
java·后端·spring
CryptoRzz1 小时前
美股 (US) 与 墨西哥 (Mexico) 股票数据接口集成指南
后端
张人大 Renda Zhang1 小时前
Java 虚拟线程 Virtual Thread:让“每请求一线程”在高并发时代复活
java·jvm·后端·spring·架构·web·虚拟线程
一勺菠萝丶2 小时前
解决 SLF4J 警告问题 - 完整指南
java·spring boot·后端
零日失眠者2 小时前
【文件管理系列】001:文件批量重命名工具
后端·shell