ASP.NET Core 6+ 中用 IActionResult
返回 SSE 流最简单
不需要引入第三方库,.NET 6 起内置了对 Server-Sent Events 的基础支持。核心是返回一个持续写入的
FileStreamResult或更推荐的
StreamingFileResult变体——但实际最稳妥的是直接用
HttpResponse写入原始流,并手动设置响应头。
关键点:必须禁用响应缓冲、设置正确的 MIME 类型和缓存策略,否则浏览器收不到实时事件。
Response.StatusCode = 200
Response.ContentType = "text/event-stream"
Response.Headers.Add("Cache-Control", "no-cache")
Response.Headers.Add("Connection", "keep-alive")
调用 Response.Body.FlushAsync()每次写完一行后(尤其在开发环境 IIS Express 下容易卡住)
用 HttpResponse
手动写入 SSE 格式数据
SSE 协议本身极轻量:每条消息由若干字段行(
data:、
id:、
event:、
retry:)组成,空行分隔。浏览器只认
data:开头的行,且会自动拼接多行
data:成一个完整字符串。
示例片段(在 Controller Action 中):
Response.StatusCode = 200;
Response.ContentType = "text/event-stream";
Response.Headers.Add("Cache-Control", "no-cache");
Response.Headers.Add("Connection", "keep-alive");
var writer = new StreamWriter(Response.Body, Encoding.UTF8) { AutoFlush = true };
while (!HttpContext.RequestAborted.IsCancellationRequested)
{
await writer.WriteLineAsync($"data: {{\"time\":\"{DateTime.Now:O}\"}}");
await writer.WriteLineAsync("");
await Task.Delay(1000, HttpContext.RequestAborted);
}
注意:
AutoFlush = true很重要;若不用
StreamWriter,直接用
Response.Body.WriteAsync,记得每次写完调
FlushAsync。
避免 JsonSerializer
或 System.Text.Json
自动换行导致格式错误
如果用
JsonSerializer.Serialize输出对象再写入流,它默认不换行,但你仍需手动加
data:前缀和末尾空行。更麻烦的是,若 JSON 含换行符(如字符串里有 \n),SSE 会把它当成消息分隔,导致解析失败。 不要直接
WriteAsync(JsonSerializer.Serialize(obj))应先序列化为单行 JSON:
JsonSerializerOptions options = new() { WriteIndented = false };
然后拼接:await writer.WriteLineAsync($"data: {json}");
严格确保每条消息以 data:开始、以空行结束
客户端断连时如何安全清理后台任务
ASP.NET Core 不会自动取消已启动的异步循环。若用户关闭页面或网络中断,
HttpContext.RequestAborted是唯一可靠信号,但必须在所有 await 点都传入它。
常见陷阱:
忘记在Task.Delay(1000)里传
HttpContext.RequestAborted→ 任务继续跑,资源泄漏 用
while (true)但没检查
IsCancellationRequested→ 无法退出 在循环中启动新
Task.Run且未绑定
CancellationToken→ 彻底失控
真正可靠的模式是:整个循环逻辑在一个 async 方法里,每个 await 都带 token,外层用
try/finally或
using清理资源(比如取消
Timer、释放数据库连接等)。
真实项目里,SSE 很少裸写循环;多数会结合
IAsyncEnumerable<t></t>+
ChannelReader或
IObservable做解耦,但底层响应流的写法和头设置逻辑完全一致。最容易被忽略的,其实是
FlushAsync的调用时机和
RequestAborted的全程穿透 —— 这两点一漏,就变成“看似能发,实则收不到”或者“服务端悄悄堆积数百个僵尸任务”。
