آموزش کامل Server-Sent Events (SSE) در ASP.NET Core
⚡ خلاصه سریع — اصل کاریها
اینها مهمترین چیزهایی هستن که اگه هیچی ندونی، فقط اینا رو بدونی کافیه:
- تعریف SSE: یک ارتباط یکطرفه از سرور به کلاینت روی HTTP معمولی با هدر
Content-Type: text/event-stream. - ساختار پیام: هر رویداد با
data:شروع میشود و هر رویداد باید با دو خط\n\nتمام شود. - فلاش اجباری: بعد از هر
WriteAsyncبایدResponse.Body.FlushAsync(ct)صدا بزنی تا داده فوراً ارسال شود. - لغو اتصال: همیشه
CancellationTokenرا به حلقه وTask.Delayبده تا با قطع کلاینت عملیات متوقف شود. - شناسه رویداد: با فیلد
id:میتوانی اتصال مجدد و ازدسترفتن رویدادها را مدیریت کنی. - چند کلاینت: برای پخش به همه، از
Channel<T>جداگانه برای هر کلاینت استفاده کن؛ یکChannelReaderمشترک broadcast نیست. - Heartbeat: با ارسال خط نظر
: ping\n\nهر ۱۵ ثانیه، اتصال را زنده نگه دار.
۳ اشتباه رایج که باید ازشون پرهیز کنی:
- فراموش کردن
FlushAsyncکه باعث بافر شدن و تأخیر در دریافت رویدادها میشود. - حلقه بینهایت بدون بررسی
ct.IsCancellationRequestedکه بعد از قطع کلاینت CPU را مصرف میکند. - استفاده از یک
ChannelReaderمشترک برای همه کاربران و تصور اینکه مثل broadcast برای همه ارسال میشود.
اگر فقط ۵ دقیقه وقت داری، اینها رو یاد بگیر:
- تنظیم هدر
Content-Type: text/event-streamروی پاسخ HTTP. - نوشتن رویداد با قالب
data: متن پیام\n\n. - صدا زدن
Response.Body.FlushAsync(ct)بعد از هر نوشتن. - استفاده از
CancellationTokenبرای توقف به موقع حلقه. - تست سریع با
new EventSource('/sse')در جاوااسکریپت مرورگر.
۱. مقدمه
Server-Sent Events (SSE) یک فناوری ساده و استاندارد برای ارسال بهروزرسانیهای یکطرفه از سرور به کلاینت است. این ارتباط بر بستر HTTP معمولی برقرار میشود و برخلاف WebSocket که دوطرفه است، اینجا فقط سرور میتواند پیام بفرستد و کلاینت گوش میدهد.
چرا باید یادش بگیری؟
- بسیار سادهتر از WebSocket برای سناریوهای یکطرفه است.
- از زیرساخت HTTP معمولی استفاده میکند؛ یعنی پروکسی، load balancer، کش و ابزارهای مانیتورینگ اغلب بدون پیکربندی خاص کار میکنند.
- مرورگرها به صورت بومی از طریق
EventSourceاز آن پشتیبانی میکنند. - برای داشبوردها، قیمت لحظهای، اعلانها و فیدهای خبری ایدهآل است.
کجا استفاده میشود؟
- قیمت لحظهای سهام یا رمزارز
- اعلانهای زنده (Notification)
- وضعیت پردازشهای طولانی (Progress bar)
- داشبورد مدیریتی با دادههای بلادرنگ
- لاگهای زنده سرور در مرورگر
۲. پیشنیازها
برای شروع، اینها را باید بلد باشی یا نصب کنی:
- .NET SDK: نسخه ۶ یا ۸ (پیشنهاد: .NET 8)
- ابزار برنامهنویسی: Visual Studio 2022 یا VS Code با افزونه C#
- دانش HTML و JavaScript: کار با
EventSourceبرای تست کلاینت - آشنایی با ASP.NET Core Minimal API یا Controllers
- مفاهیم async/await و Task در سیشارپ
- درک ابتدایی از HTTP Headers و Response Body
هیچ پکیج NuGet خاصی برای SSE لازم نیست؛ همه چیز در خود ASP.NET Core موجود است.
۳. نصب و راهاندازی
گام ۱: ساخت پروژه جدید
dotnet new web -n SseDemo
cd SseDemo
گام ۲: ساخت اولین endpoint با SSE
فایل Program.cs را باز کن و این کد را جایگزین کن:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => """
<!DOCTYPE html>
<html>
<body>
<h1>Server-Sent Events Demo</h1>
<ul id="messages"></ul>
<script>
const source = new EventSource('/sse');
source.onmessage = (event) => {
const item = document.createElement('li');
item.textContent = event.data;
document.getElementById('messages').appendChild(item);
};
</script>
</body>
</html>
""");
app.MapGet("/sse", async (HttpContext ctx, CancellationToken ct) =>
{
ctx.Response.Headers.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers.Add("X-Accel-Buffering", "no");
var i = 0;
while (!ct.IsCancellationRequested)
{
i++;
await ctx.Response.WriteAsync($"data: پیام شماره {i} - {DateTime.Now:T}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
await Task.Delay(1000, ct);
}
});
app.Run();
گام ۳: اجرا و تست
dotnet run
حالا در مرورگر به آدرس http://localhost:5000 برو. هر ثانیه یک پیام جدید به لیست اضافه میشود.
۴. مفاهیم پایه
۴.۱. هدرهای ضروری
برای شروع یک ارتباط SSE، باید سه هدر را ست کنی:
| هدر | مقدار | چرا؟ |
|---|---|---|
Content-Type |
text/event-stream |
مرورگر بفهمد که پاسخ از نوع SSE است. |
Cache-Control |
no-cache |
جلوگیری از ذخیره شدن پاسخ. |
X-Accel-Buffering |
no |
غیرفعال کردن بافر در پروکسیهایی مثل Nginx. |
۴.۲. قالب پیامهای SSE
هر رویداد از چند فیلد متنی تشکیل شده و با یک خط خالی پایان مییابد.
event: userSignup
id: 101
data: {"name": "Ali"}
data: {"plan": "Pro"}
data:بدنه اصلی پیام است. میتواند چند خطی باشد و همه خطوط به هم متصل میشوند.event:نام رویداد سفارشی است. اگر نباشد، رویدادmessageدرEventSourceفراخوانی میشود.id:شناسه رویداد است. برای بازیابی رویدادهای از دست رفته پس از اتصال مجدد استفاده میشود.retry:زمان انتظار برای اتصال مجدد خودکار را به میلیثانیه تعیین میکند.- خط
:شروع شده، یک پیام heartbeat/کامنت است و توسط کلاینت نادیده گرفته میشود.
۴.۳. چرا Flush لازم است؟
در ASP.NET Core، نوشتن روی Response.Body ممکن است بافر شود. تا وقتی FlushAsync صدا نزنی، داده ممکن است در حافظه باقی بماند و ارسال نشود. برای SSE باید بلافاصله بعد از هر رویداد فلاش کنی.
۴.۴. نقش CancellationToken
وقتی کلاینت قطع میشود، CancellationToken مربوط به درخواست (ctx.RequestAborted) لغو میشود. اگر حلقه تو به این توکن توجه نکند، تا ابد ادامه مییابد و منابع سرور را هدر میدهد.
۵. مثالهای کد ساده
مثال ۱: ساعت لحظهای با Minimal API
app.MapGet("/clock", async (HttpContext ctx, CancellationToken ct) =>
{
ctx.Response.Headers.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers.Add("X-Accel-Buffering", "no");
while (!ct.IsCancellationRequested)
{
var now = DateTime.Now.ToString("HH:mm:ss");
await ctx.Response.WriteAsync($"data: {now}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
await Task.Delay(1000, ct);
}
});
مثال ۲: استفاده از Controller
[ApiController]
[Route("api/[controller]")]
public class EventsController : ControllerBase
{
[HttpGet("sse")]
public async Task GetSse(CancellationToken ct)
{
Response.Headers.ContentType = "text/event-stream";
Response.Headers.CacheControl = "no-cache";
Response.Headers.Add("X-Accel-Buffering", "no");
var i = 0;
while (!ct.IsCancellationRequested)
{
i++;
await Response.WriteAsync($"data: رویداد {i}\n\n", ct);
await Response.Body.FlushAsync(ct);
await Task.Delay(1000, ct);
}
}
}
مثال ۳: ارسال JSON و رویداد سفارشی
app.MapGet("/stocks", async (HttpContext ctx, CancellationToken ct) =>
{
ctx.Response.Headers.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers.Add("X-Accel-Buffering", "no");
var random = new Random();
string[] symbols = { "AAPL", "GOOG", "MSFT" };
while (!ct.IsCancellationRequested)
{
var stock = new
{
symbol = symbols[random.Next(symbols.Length)],
price = 100 + random.NextDouble() * 50,
time = DateTime.Now
};
var json = JsonSerializer.Serialize(stock);
await ctx.Response.WriteAsync($"event: stock\nid: {DateTime.Now.Ticks}\ndata: {json}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
await Task.Delay(2000, ct);
}
});
کلاینت:
const source = new EventSource('/stocks');
source.addEventListener('stock', (event) => {
const stock = JSON.parse(event.data);
console.log(`${stock.symbol}: ${stock.price.toFixed(2)}`);
});
مثال ۴: استفاده از IAsyncEnumerable
async IAsyncEnumerable<string> GenerateEvents([EnumeratorCancellation] CancellationToken ct)
{
int i = 0;
while (!ct.IsCancellationRequested)
{
yield return $"data: Event {++i}\n\n";
await Task.Delay(1000, ct);
}
}
app.MapGet("/sse-enum", async (HttpContext ctx, CancellationToken ct) =>
{
ctx.Response.Headers.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers.Add("X-Accel-Buffering", "no");
await ctx.Response.StartAsync(ct);
await foreach (var message in GenerateEvents(ct).WithCancellation(ct))
{
await ctx.Response.WriteAsync(message, ct);
await ctx.Response.Body.FlushAsync(ct);
}
});
۶. مثال واقعی و کاربردی
یک داشبورد قیمت لحظهای سهام میسازیم که قیمت چند نماد را هر ثانیه به همه کلاینتها ارسال میکند.
گام ۱: مدل داده
public record StockUpdate(string Symbol, decimal Price, DateTime Time);
گام ۲: ساخت Broadcast Broker برای پخش به همه کلاینتها
using System.Collections.Concurrent;
using System.Threading.Channels;
public class StockEventBroker
{
private readonly ConcurrentDictionary<Guid, Channel<StockUpdate>> _clients = new();
public (Guid Id, ChannelReader<StockUpdate> Reader) Subscribe()
{
var id = Guid.NewGuid();
var channel = Channel.CreateUnbounded<StockUpdate>();
_clients[id] = channel;
return (id, channel.Reader);
}
public void Unsubscribe(Guid id)
{
if (_clients.TryRemove(id, out var channel))
{
channel.Writer.TryComplete();
}
}
public void Publish(StockUpdate update)
{
foreach (var channel in _clients.Values)
{
channel.Writer.TryWrite(update);
}
}
}
گام ۳: BackgroundService برای تولید داده
public class StockBackgroundService : BackgroundService
{
private readonly StockEventBroker _broker;
public StockBackgroundService(StockEventBroker broker)
{
_broker = broker;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var random = new Random();
string[] symbols = { "AAPL", "GOOG", "MSFT", "AMZN" };
while (!stoppingToken.IsCancellationRequested)
{
foreach (var symbol in symbols)
{
var update = new StockUpdate(
symbol,
100 + Math.Round((decimal)random.NextDouble() * 50, 2),
DateTime.Now
);
_broker.Publish(update);
}
await Task.Delay(1000, stoppingToken);
}
}
}
گام ۴: ثبت سرویسها در Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<StockEventBroker>();
builder.Services.AddHostedService<StockBackgroundService>();
var app = builder.Build();
گام ۵: endpoint مربوط به SSE
app.MapGet("/sse/stocks", async (HttpContext ctx, StockEventBroker broker, CancellationToken ct) =>
{
var (id, reader) = broker.Subscribe();
try
{
ctx.Response.Headers.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers.Add("X-Accel-Buffering", "no");
await ctx.Response.StartAsync(ct);
await foreach (var stock in reader.ReadAllAsync(ct))
{
var json = JsonSerializer.Serialize(stock);
await ctx.Response.WriteAsync($"event: stock\ndata: {json}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
}
}
finally
{
broker.Unsubscribe(id);
}
});
گام ۶: رابط کاربری ساده
<!DOCTYPE html>
<html>
<body>
<h1>Live Stock Dashboard</h1>
<div id="prices">
<p>AAPL: <span id="AAPL">-</span></p>
<p>GOOG: <span id="GOOG">-</span></p>
<p>MSFT: <span id="MSFT">-</span></p>
<p>AMZN: <span id="AMZN">-</span></p>
</div>
<script>
const source = new EventSource('/sse/stocks');
source.addEventListener('stock', (event) => {
const stock = JSON.parse(event.data);
const el = document.getElementById(stock.symbol);
if (el) {
el.textContent = `${stock.price} (${new Date(stock.time).toLocaleTimeString()})`;
}
});
source.onerror = () => {
console.log('Connection lost, retrying...');
};
</script>
</body>
</html>
۷. مباحث پیشرفته
۷.۱. مدیریت چند کلاینت به صورت Broadcast
همانطور که در مثال بالا دیدی، هر کلاینت باید ChannelReader مخصوص خودش را داشته باشد. یک ChannelReader مشترک بین کلاینتها مثل صف عمل میکند و هر آیتم فقط به یک مصرفکننده میرسد. الگوی درست، نگهداری یک دیکشنری از کانالها و پخش پیام به همه است.
۷.۲. ارسال Heartbeat
برای جلوگیری از بستن اتصال توسط پروکسیها یا load balancerها، یک پیام کامنت خالی ارسال کن:
if (DateTime.UtcNow - lastSent > TimeSpan.FromSeconds(15))
{
await ctx.Response.WriteAsync(": ping\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
lastSent = DateTime.UtcNow;
}
۷.۳. Authentication و Authorization
EventSource بومی نمیتواند هدر سفارشی ارسال کند. راهها:
- استفاده از کوکیهای معمولی
- ارسال توکن در Query String
- استفاده از fetch با ReadableStream به جای EventSource (برای اتصال با هدر)
const response = await fetch('/sse/stocks', {
headers: { 'Authorization': `Bearer ${token}` }
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
console.log(decoder.decode(value));
}
۷.۴. پروکسی معکوس و Nginx
اگر از Nginx استفاده میکنی، بافر پروکسی را غیرفعال کن:
location /sse/ {
proxy_pass http://backend;
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_http_version 1.1;
}
۷.۵. Scale-out
در برنامههای بزرگ با چند سرور، SSE به تنهایی چالشبرانگیز است. گزینهها:
- استفاده از Redis Pub/Sub برای همگامسازی بین سرورها
- استفاده از سرویسهایی مانند Azure SignalR Service که مقیاسپذیری را مدیریت میکنند
- چسباندن کلاینت به یک سرور خاص (Sticky Session)
۸. اشتباهات رایج
- فراموش کردن
FlushAsync: دادهها بافر میشوند و کلاینت رویدادها را با تأخیر دریافت میکند. - عدم استفاده از
CancellationToken: حلقه بعد از قطع کلاینت همچنان ادامه مییابد و CPU مصرف میکند. - ست نکردن
Content-Type: text/event-stream: مرورگر پاسخ را به عنوان SSE نمیفهمد. - استفاده از
Thread.Sleepبه جایTask.Delay: باعث بلاک شدن ترد و کاهش scalability میشود. - فرمت اشتباه رویداد: فراموش کردن
\n\nدر انتهای پیام یا قرار دادنdata:در خط جدا. - استفاده از یک Channel مشترک برای broadcast: رویدادها بین کلاینتها تقسیم میشوند.
- ندادن
idبه رویدادها: در صورت قطع و وصل، کلاینت نمیتواند از حالت مشخص ادامه دهد. - ارسال نکردن heartbeat: اتصال توسط پروکسی یا firewall بسته میشود.
- استفاده از WebSocket برای سناریوی فقط سرور به کلاینت: پیچیدگی غیرضروری است.
۹. بهترین روشها (Best Practices)
- همیشه
CancellationTokenرا به تمام متدهای async پاس بده. - هر رویداد را بلافاصله بعد از نوشتن فلاش کن.
- از
idبرای رویدادها استفاده کن تا کلاینت بتواند پس از قطعی، ادامه دهد. - برای هر کلاینت یک Channel مجزا بساز.
- منطق تولید داده را از endpoint جدا کن (BackgroundService یا سرویس مجزا).
- برای جلوگیری از خطاهای بافر،
X-Accel-Buffering: noرا فراموش نکن. - پیامها را به صورت JSON و با نام رویداد سفارشی ارسال کن.
- در کلاینت، حتماً رویداد
onerrorرا مدیریت کن. - از heartbeat استفاده کن تا اتصال زنده بماند.
- اتصالها را محدود کن و در صورت لزوم timeout بگذار.
- همینطور که کد را توسعه میدهی، به Rate Limiting و منابع سرور توجه کن.
۱۰. منابع و ادامه مسیر
- MDN: Using server-sent events
- Microsoft Docs: Streaming responses in ASP.NET Core
- Microsoft Docs: Channels
- HTML Standard: Server-sent events
- Azure SignalR Service
۱۱. مرجع کامل توابع و متدها (API Deep Dive)
در این بخش مهمترین متدها و classهایی که در SSE استفاده میشوند را با جزئیات کامل بررسی میکنیم.
📌 متد: HttpResponse.WriteAsync(string, CancellationToken)
امضای متد (Signature):
public static Task WriteAsync(
this HttpResponse response,
string text,
CancellationToken cancellationToken = default)
ورودیها (Parameters):
| نام پارامتر | نوع | اجباری؟ | توضیح دقیق | مثال مقدار |
|---|---|---|---|---|
response |
HttpResponse |
بله | گیرنده extension؛ پاسخ HTTP فعلی | ctx.Response |
text |
string |
بله | متنی که باید در بدنه پاسخ نوشته شود | "data: hello\n\n" |
cancellationToken |
CancellationToken |
خیر | توکن لغو عملیات نوشتن | ctx.RequestAborted |
مقدار برگشتی (Return Value):
- نوع:
Task - وقتی عملیات نوشتن کامل شود، Task کامل میشود. خطاها از طریق همین Task بروز میکنند.
- در حالت عادی
nullبرنمیگرداند؛ بلکه یک Task معتبر میدهد.
کاری که انجام میده (گام به گام):
- متن را به صورت UTF-8 انکود میکند.
- داده انکود شده را در
Response.Bodyمینویسد. - اگر نوشتن به دلیل لغو توکن متوقف شود،
OperationCanceledExceptionپرتاب میکند. - بعد از اتمام نوشتن، Task کامل میشود ولی لزوماً روی شبکه فلاش نمیشود.
مثال ساده و قابل اجرا:
app.MapGet("/write", async (HttpContext ctx, CancellationToken ct) =>
{
ctx.Response.Headers.ContentType = "text/event-stream";
await ctx.Response.WriteAsync("data: simple message\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
});
مثال واقعی و کاربردی:
ارسال یک رویداد JSON در یک حلقه SSE:
app.MapGet("/sse", async (HttpContext ctx, CancellationToken ct) =>
{
ctx.Response.Headers.ContentType = "text/event-stream";
while (!ct.IsCancellationRequested)
{
var json = JsonSerializer.Serialize(new { time = DateTime.Now });
await ctx.Response.WriteAsync($"data: {json}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
await Task.Delay(1000, ct);
}
});
خطاها و Exceptions احتمالی:
| Exception | زمان رخ دادن | راهحل |
|---|---|---|
OperationCanceledException |
وقتی CancellationToken لغو شود |
عملیات را متوقف کن و در endpoint برگرد |
IOException |
قطع شدن اتصال شبکه | حلقه را متوقف کن |
ObjectDisposedException |
وقتی پاسخ قبلاً کامل شده باشد | از نوشتن بعد از CompleteAsync خودداری کن |
Overloadها:
public static Task WriteAsync(
this HttpResponse response,
string text,
Encoding encoding,
CancellationToken cancellationToken = default)
public static Task WriteAsync(
this HttpResponse response,
byte[] data,
int offset,
int count,
CancellationToken cancellationToken = default)
اشتباهات رایج:
- ❌ فراموش کردن
FlushAsyncبعد ازWriteAsync - ✅ همیشه بعد از نوشتن رویداد، فلاش کن
- ❌ نوشتن بعد از لغو توکن
- ✅ قبل از نوشتن،
ct.IsCancellationRequestedرا چک کن
متدهای مرتبط:
HttpResponse.StartAsync— ارسال هدرها قبل از شروع جریانStream.FlushAsync— ارسال فوری دادهHttpResponse.Body.WriteAsync— نوشتن داده باینری
📌 متد: HttpResponse.StartAsync(CancellationToken)
امضای متد (Signature):
public virtual Task StartAsync(CancellationToken cancellationToken = default)
ورودیها (Parameters):
| نام پارامتر | نوع | اجباری؟ | توضیح دقیق | مثال مقدار |
|---|---|---|---|---|
cancellationToken |
CancellationToken |
خیر | توکن لغو ارسال هدرها | ctx.RequestAborted |
مقدار برگشتی (Return Value):
- نوع:
Task - وقتی هدرها ارسال شدند، Task کامل میشود.
- بعد از این متد میتوانی با خیال راحت روی بدنه پاسخ بنویسی.
کاری که انجام میده (گام به گام):
- وضعیت پاسخ را بررسی میکند.
- هدرهای HTTP را به همراه کد وضعیت ارسال میکند.
- اگر بدنه پاسخ شروع شده باشد، ممکن است بدون ارسال مجدد برگردد.
- عملیات را با لغو توکن هماهنگ میکند.
مثال ساده و قابل اجرا:
app.MapGet("/start", async (HttpContext ctx, CancellationToken ct) =>
{
ctx.Response.Headers.ContentType = "text/event-stream";
await ctx.Response.StartAsync(ct);
await ctx.Response.WriteAsync("data: started\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
});
مثال واقعی و کاربردی:
در SSE بهتر است قبل از شروع حلقه، هدرها را ارسال کنی:
app.MapGet("/sse", async (HttpContext ctx, CancellationToken ct) =>
{
ctx.Response.Headers.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
await ctx.Response.StartAsync(ct);
while (!ct.IsCancellationRequested)
{
await ctx.Response.WriteAsync($"data: {DateTime.Now}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
await Task.Delay(1000, ct);
}
});
خطاها و Exceptions احتمالی:
| Exception | زمان رخ دادن | راهحل |
|---|---|---|
InvalidOperationException |
اگر پاسخ قبلاً شروع شده یا کامل شده باشد | اطمینان از عدم فراخوانی دوباره |
OperationCanceledException |
لغو توکن قبل از ارسال هدر | لغو را مدیریت کن |
Overloadها:
این متد یک overload ساده دارد:
public virtual Task StartAsync() => StartAsync(CancellationToken.None);
اشتباهات رایج:
- ❌ نوشتن بدون ارسال هدر و در نتیجه تغییر هدر بعد از نوشتن
- ✅ ابتدا هدرها را ست کن و
StartAsyncرا صدا بزن - ❌ صدا زدن
StartAsyncبعد ازWriteAsync - ✅ ترتیب را رعایت کن
متدهای مرتبط:
HttpResponse.CompleteAsync— کامل کردن پاسخHttpResponse.WriteAsync— نوشتن در بدنه
📌 متد: HttpResponse.Body.FlushAsync(CancellationToken)
امضای متد (Signature):
public virtual Task FlushAsync(CancellationToken cancellationToken)
ورودیها (Parameters):
| نام پارامتر | نوع | اجباری؟ | توضیح دقیق | مثال مقدار |
|---|---|---|---|---|
cancellationToken |
CancellationToken |
بله | توکن لغو عملیات فلاش | ctx.RequestAborted |
مقدار برگشتی (Return Value):
- نوع:
Task - وقتی دادههای بافر شده روی شبکه ارسال شدند، Task کامل میشود.
کاری که انجام میده (گام به گام):
- بررسی میکند که آیا streame قابل فلاش است.
- دادههای موجود در بافر داخلی را به خروجی میفرستد.
- اگر دادهای نباشد، بلافاصله کامل میشود.
- در صورت لغو توکن،
OperationCanceledExceptionپرتاب میکند.
مثال ساده و قابل اجرا:
await ctx.Response.WriteAsync("data: message\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
مثال واقعی و کاربردی:
ارسال چند رویداد پشت سر هم با فلاش جداگانه:
foreach (var notification in notifications)
{
await ctx.Response.WriteAsync($"data: {notification}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct); // ارسال فوری
}
خطاها و Exceptions احتمالی:
| Exception | زمان رخ دادن | راهحل |
|---|---|---|
OperationCanceledException |
لغو توکن | بررسی ct و خروج از حلقه |
IOException |
قطع اتصال کلاینت | خاتمه عملیات |
NotSupportedException |
اگر استریم از فلاش پشتیبانی نکند | استفاده از Stream معمولی |
Overloadها:
public virtual Task FlushAsync() => FlushAsync(CancellationToken.None);
اشتباهات رایج:
- ❌ فراموش کردن
FlushAsyncو تصور اینکه داده ارسال شده است - ✅ بعد از هر رویداد فلاش کن
- ❌ صدا زدن
FlushAsyncبا توکن لغو شده - ✅ قبل از فلاش، توکن را بررسی کن
متدهای مرتبط:
HttpResponse.WriteAsync— نوشتن در بدنهStream.WriteAsync— نوشتن باینری
📌 متد: TaskAsyncEnumerableExtensions.WithCancellation<T>()
امضای متد (Signature):
public static ConfiguredCancelableAsyncEnumerable<T> WithCancellation<T>(
this IAsyncEnumerable<T> source,
CancellationToken cancellationToken)
ورودیها (Parameters):
| نام پارامتر | نوع | اجباری؟ | توضیح دقیق | مثال مقدار |
|---|---|---|---|---|
source |
IAsyncEnumerable<T> |
بله | دنباله async که میخواهی enumerates کنی | GenerateEvents(ct) |
cancellationToken |
CancellationToken |
بله | توکنی که در هر MoveNextAsync بررسی میشود |
ctx.RequestAborted |
مقدار برگشتی (Return Value):
- نوع:
ConfiguredCancelableAsyncEnumerable<T> - یک enumerable قابل استفاده در
await foreachکه لغو توکن را به enumerator منتقل میکند.
کاری که انجام میده (گام به گام):
- منبع
IAsyncEnumerableرا میگیرد. - آن را با توکن لغو ترکیب میکند.
- در هنگام enumeration، توکن را به
MoveNextAsyncپاس میدهد. - اگر توکن لغو شود، enumeration متوقف و
OperationCanceledExceptionپرتاب میشود.
مثال ساده و قابل اجرا:
async IAsyncEnumerable<int> GenerateNumbers([EnumeratorCancellation] CancellationToken ct)
{
for (var i = 0; !ct.IsCancellationRequested; i++)
{
yield return i;
await Task.Delay(100, ct);
}
}
await foreach (var number in GenerateNumbers(ct).WithCancellation(ct))
{
Console.WriteLine(number);
}
مثال واقعی و کاربردی:
در SSE برای خواندن جریان رویدادها:
await foreach (var message in GenerateEvents(ct).WithCancellation(ct))
{
await ctx.Response.WriteAsync(message, ct);
await ctx.Response.Body.FlushAsync(ct);
}
خطاها و Exceptions احتمالی:
| Exception | زمان رخ دادن | راهحل |
|---|---|---|
OperationCanceledException |
لغو توکن در حین enumeration | catch و خروج تمیز از متد |
Overloadها:
معادل بدون این extension، استفاده مستقیم از GetAsyncEnumerator است.
اشتباهات رایج:
- ❌ فراموش کردن
[EnumeratorCancellation]روی متد async iterator - ✅ پارامتر
CancellationTokenرا با[EnumeratorCancellation]علامت بزن - ❌ استفاده نکردن از
WithCancellationدرawait foreach - ✅ همیشه توکن را پاس بده
متدهای مرتبط:
IAsyncEnumerable<T>.GetAsyncEnumerator— دریافت enumeratorTask.Delay— توقف async
📌 متد: Channel.CreateUnbounded<T>()
امضای متد (Signature):
public static Channel<T> CreateUnbounded<T>(
UnboundedChannelOptions options = null)
ورودیها (Parameters):
| نام پارامتر | نوع | اجباری؟ | توضیح دقیق | مثال مقدار |
|---|---|---|---|---|
options |
UnboundedChannelOptions |
خیر | تنظیمات کانال مثل SingleReader، SingleWriter |
new UnboundedChannelOptions { SingleWriter = true } |
مقدار برگشتی (Return Value):
- نوع:
Channel<T> - یک کانال بدون محدودیت ظرفیت. خاصیت
ReaderوWriterدارد.
کاری که انجام میده (گام به گام):
- یک صف نامحدود در حافظه ایجاد میکند.
- یک
ChannelReader<T>برای خواندن و یکChannelWriter<T>برای نوشتن فراهم میکند. - با تنظیمات پیشفرض، چند خواننده و چند نویسنده مجاز هستند.
- در صورت نبود فضای کافی (در حالت unbounded) هرگز نوشتن مسدود نمیشود.
مثال ساده و قابل اجرا:
var channel = Channel.CreateUnbounded<string>();
await channel.Writer.WriteAsync("Hello");
await foreach (var item in channel.Reader.ReadAllAsync())
{
Console.WriteLine(item);
}
مثال واقعی و کاربردی:
ساخت کانال جداگانه برای هر کلاینت SSE:
public (Guid Id, ChannelReader<StockUpdate> Reader) Subscribe()
{
var channel = Channel.CreateUnbounded<StockUpdate>();
var id = Guid.NewGuid();
_clients[id] = channel;
return (id, channel.Reader);
}
خطاها و Exceptions احتمالی:
| Exception | زمان رخ دادن | راهحل |
|---|---|---|
ArgumentNullException |
اگر options null نباشد ولی نادرست باشد | از مقدار معتبر استفاده کن |
Overloadها:
public static Channel<T> CreateUnbounded<T>() => CreateUnbounded<T>(null);
همچنین CreateBounded<T>(int capacity) برای صف محدود.
اشتباهات رایج:
- ❌ استفاده از یک
ChannelReaderمشترک بین چند کلاینت برای broadcast - ✅ برای هر کلاینت یک کانال مجزا بساز
- ❌ فراموش کردن
TryCompleteروی Writer بعد از پایان کار - ✅ پس از لغو اشتراک، کانال را complete کن
متدهای مرتبط:
Channel.CreateBounded<T>— کانال با ظرفیت محدودChannelWriter<T>.TryWrite— نوشتن بدون awaitChannelReader<T>.ReadAllAsync— خواندن جریان
📌 متد: ChannelWriter<T>.WriteAsync(T, CancellationToken)
امضای متد (Signature):
public ValueTask WriteAsync(
T item,
CancellationToken cancellationToken = default)
ورودیها (Parameters):
| نام پارامتر | نوع | اجباری؟ | توضیح دقیق | مثال مقدار |
|---|---|---|---|---|
item |
T |
بله | دادهای که باید به کانال نوشته شود | new StockUpdate(...) |
cancellationToken |
CancellationToken |
خیر | توکن لغو انتظار برای نوشتن | stoppingToken |
مقدار برگشتی (Return Value):
- نوع:
ValueTask - وقتی آیتم در کانال قرار گرفت، کامل میشود. در کانال unbounded سریع کامل میشود.
کاری که انجام میده (گام به گام):
- بررسی میکند که آیا کانال کامل شده یا نه.
- آیتم را در صف داخلی قرار میدهد.
- خوانندهها را برای مصرف آیتم جدید مطلع میکند.
- اگر کانال کامل شده باشد،
ChannelClosedExceptionپرتاب میکند.
مثال ساده و قابل اجرا:
var channel = Channel.CreateUnbounded<int>();
await channel.Writer.WriteAsync(42);
Console.WriteLine("Wrote 42");
مثال واقعی و کاربردی:
ارسال بهروزرسانی به همه کلاینتها:
public void Publish(StockUpdate update)
{
foreach (var channel in _clients.Values)
{
_ = channel.Writer.TryWrite(update); // بدون await
}
}
خطاها و Exceptions احتمالی:
| Exception | زمان رخ دادن | راهحل |
|---|---|---|
ChannelClosedException |
وقتی کانال با TryComplete بسته شده است |
از TryWrite استفاده کن یا وضعیت کانال را چک کن |
OperationCanceledException |
لغو توکن در حین انتظار | لغو را مدیریت کن |
Overloadها:
public bool TryWrite(T item); // نوشتن بدون await
اشتباهات رایج:
- ❌ استفاده از
WriteAsyncروی کانال بسته شده - ✅ قبل از نوشتن از
TryWriteیا بررسی وضعیت استفاده کن - ❌ blocking منتظر ماندن در کد async
- ✅ به جای
Wait()ازawaitاستفاده کن
متدهای مرتبط:
TryWrite— نوشتن بدون asyncTryComplete— بستن کانالChannelReader<T>.ReadAllAsync— خواندن آیتمها
📌 متد: ChannelReader<T>.ReadAllAsync(CancellationToken)
امضای متد (Signature):
public virtual IAsyncEnumerable<T> ReadAllAsync(
CancellationToken cancellationToken = default)
ورودیها (Parameters):
| نام پارامتر | نوع | اجباری؟ | توضیح دقیق | مثال مقدار |
|---|---|---|---|---|
cancellationToken |
CancellationToken |
خیر | توکن لغو خواندن | ct |
مقدار برگشتی (Return Value):
- نوع:
IAsyncEnumerable<T> - یک دنباله async که تمام آیتمهای کانال را تا زمانی که Writer کامل شود، تولید میکند.
- وقتی
Writer.TryCompleteصدا زده شود، enumeration پایان مییابد.
کاری که انجام میده (گام به گام):
- هر بار که
MoveNextAsyncفراخوانی میشود، منتظر آیتم جدید یا پایان کانال میماند. - اگر کانال کامل شده و صف خالی باشد، enumeration را تمام میکند.
- در صورت لغو توکن،
OperationCanceledExceptionپرتاب میکند. - دادهها را به صورت async در اختیار
await foreachقرار میدهد.
مثال ساده و قابل اجرا:
var channel = Channel.CreateUnbounded<string>();
_ = Task.Run(async () =>
{
for (var i = 0; i < 5; i++)
{
await channel.Writer.WriteAsync($"Msg {i}");
await Task.Delay(100);
}
channel.Writer.TryComplete();
});
await foreach (var message in channel.Reader.ReadAllAsync())
{
Console.WriteLine(message);
}
مثال واقعی و کاربردی:
در endpoint مربوط به SSE:
await foreach (var stock in reader.ReadAllAsync(ct))
{
var json = JsonSerializer.Serialize(stock);
await ctx.Response.WriteAsync($"event: stock\ndata: {json}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
}
خطاها و Exceptions احتمالی:
| Exception | زمان رخ دادن | راهحل |
|---|---|---|
OperationCanceledException |
لغو توکن در حین انتظار برای آیتم | حلقه را متوقف کن |
ChannelClosedException |
اگر Writer با exception کامل شده باشد | خطا را log و درخواست را تمام کن |
Overloadها:
این متد یک پارامتر اختیاری دارد؛ overload دیگری ندارد.
اشتباهات رایج:
- ❌ استفاده از
ReadAllAsyncبدونCancellationTokenو سپس ناتوانی در توقف - ✅ همیشه توکن را پاس بده
- ❌ تصور اینکه پس از قطع کلاینت،
ReadAllAsyncخودش متوقف میشود - ✅ توکن مناسب را ارسال کن
متدهای مرتبط:
ChannelReader<T>.ReadAsync— خواندن یک آیتم به صورت تکیChannelWriter<T>.WriteAsync— نوشتن در کانال
📌 متد: Task.Delay(TimeSpan, CancellationToken)
امضای متد (Signature):
public static Task Delay(
TimeSpan delay,
CancellationToken cancellationToken)
ورودیها (Parameters):
| نام پارامتر | نوع | اجباری؟ | توضیح دقیق | مثال مقدار |
|---|---|---|---|---|
delay |
TimeSpan |
بله | مدت زمانی که باید منتظر بماند | TimeSpan.FromSeconds(1) |
cancellationToken |
CancellationToken |
بله | توکن لغو انتظار | ct |
مقدار برگشتی (Return Value):
- نوع:
Task - وقتی مدت تأخیر تمام شود، Task کامل میشود.
- اگر توکن لغو شود،
TaskCanceledExceptionپرتاب میشود.
کاری که انجام میده (گام به گام):
- یک Timer داخلی برای مدت مشخص تنظیم میکند.
- اگر توکن لغو شود، تایمر را متوقف و Task را لغو میکند.
- بعد از اتمام مدت، Task را کامل میکند.
- هیچ تردی را بلاک نمیکند.
مثال ساده و قابل اجرا:
await Task.Delay(TimeSpan.FromSeconds(1), ct);
مثال واقعی و کاربردی:
استفاده در حلقه SSE برای ارسال هر ثانیه:
while (!ct.IsCancellationRequested)
{
await ctx.Response.WriteAsync($"data: {DateTime.Now}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
await Task.Delay(TimeSpan.FromSeconds(1), ct);
}
خطاها و Exceptions احتمالی:
| Exception | زمان رخ دادن | راهحل |
|---|---|---|
TaskCanceledException (شاخهای از OperationCanceledException) |
لغو توکن | catch و خروج تمیز |
ArgumentOutOfRangeException |
اگر delay کمتر از -1 میلیثانیه یا خیلی بزرگ باشد |
از مدت معتبر استفاده کن |
Overloadها:
public static Task Delay(int millisecondsDelay, CancellationToken cancellationToken);
public static Task Delay(TimeSpan delay);
public static Task Delay(int millisecondsDelay);
اشتباهات رایج:
- ❌ استفاده از
Thread.Sleepکه ترد را بلاک میکند - ✅ استفاده از
Task.Delayکه async است - ❌ فراموش کردن توکن لغو در
Delay - ✅ همیشه توکن را پاس بده
متدهای مرتبط:
Task.WhenAny— منتظر ماندن برای هر یک از چند TaskCancellationTokenSource.CancelAfter— لغو خودکار بعد از مدت
📌 متد: CancellationTokenSource.CancelAfter(TimeSpan)
امضای متد (Signature):
public void CancelAfter(TimeSpan delay)
ورودیها (Parameters):
| نام پارامتر | نوع | اجباری؟ | توضیح دقیق | مثال مقدار |
|---|---|---|---|---|
delay |
TimeSpan |
بله | مدت زمانی که پس از آن CancellationTokenSource لغو میشود |
TimeSpan.FromMinutes(5) |
مقدار برگشتی (Return Value):
- نوع:
void - هیچ مقداری برنمیگرداند؛ بعد از مدت مشخص،
CancellationTokenSource.Cancel()فراخوانی میشود.
کاری که انجام میده (گام به گام):
- یک تایمر داخلی برای مدت مشخص تنظیم میکند.
- پس از سپری شدن مدت، متد
Cancelرا صدا میزند. - تمام توکنهای صادر شده از این منبع لغو میشوند.
- اگر مدت خیلی کوتاه یا نامعتبر باشد، استثنا پرتاب میکند.
مثال ساده و قابل اجرا:
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(10));
await Task.Delay(Timeout.Infinite, cts.Token);
مثال واقعی و کاربردی:
محدود کردن مدت زمان یک اتصال SSE:
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(30));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
await foreach (var message in reader.ReadAllAsync(linkedCts.Token))
{
// ...
}
خطاها و Exceptions احتمالی:
| Exception | زمان رخ دادن | راهحل |
|---|---|---|
ArgumentOutOfRangeException |
اگر delay کمتر از -1 میلیثانیه باشد |
از مقدار معتبر استفاده کن |
Overloadها:
public void CancelAfter(int millisecondsDelay);
public void CancelAfter(TimeSpan delay);
public void CancelAfter(int millisecondsDelay, TimeProvider timeProvider); // .NET 8+
اشتباهات رایج:
- ❌ استفاده از
CancelAfterو سپس فراموش کردنDisposeکردنCancellationTokenSource - ✅ از
using var cts = ...استفاده کن - ❌ فرض اینکه
CancelAfterترد فعلی را متوقف میکند - ✅ فقط توکنها را لغو میکند
متدهای مرتبط:
CancellationTokenSource.Cancel— لغو دستیCancellationTokenSource.CreateLinkedTokenSource— ترکیب چند توکن
۱۲. مقایسه کلاسها/توابع مشابه
| ویژگی | SSE | WebSocket | Long Polling |
|---|---|---|---|
| جهت ارتباط | یکطرفه (سرور → کلاینت) | دوطرفه | یکطرفه در هر درخواست |
| پروتکل | HTTP | WebSocket (ارتقا از HTTP) | HTTP |
| پشتیبانی مرورگر | بومی با EventSource |
بومی با WebSocket |
دستی با fetch |
| پیچیدگی پیادهسازی | کم | متوسط | کم |
| اتصال مجدد خودکار | بله | نه (دستی) | نه |
| مناسب برای | اعلان، فید خبری، قیمت لحظهای | چت، بازی آنلاین | موارد با تأخیر قابل قبول |
| متد/کلاس | وضعیت | کاربرد در SSE |
|---|---|---|
HttpResponse.WriteAsync |
استفاده مستقیم | نوشتن رویداد |
HttpResponse.StartAsync |
پیشنهادی | ارسال زودهنگام هدرها |
Stream.FlushAsync |
اجباری بعد از هر رویداد | ارسال فوری داده |
Channel<T> |
برای مدیریت چند کلاینت | جداسازی producer/consumer |
IAsyncEnumerable |
برای جریانهای async | تولید رویدادها در حلقه |
۱۳. سناریوهای واقعی (Real-World Scenarios)
سناریو ۱: نمایش زنده لاگهای سرور
یک سرویس لاگ را به کانالهای هر کلاینت متصل میکنی. هر بار که لاگ جدید ثبت شود، به همه کلاینتها ارسال میشود. این کار برای مانیتورینگ سرور مناسب است.
سناریو ۲: نوار پیشرفت عملیات طولانی
کاربر یک فایل بزرگ را آپلود میکند. سرور عملیات پردازش را انجام میدهد و درصد پیشرفت را به وسیله SSE به کلاینت ارسال میکند. کلاینت ProgressBar را آپدیت میکند.
سناریو ۳: اعلانهای بلادرنگ
کاربر در سیستم وارد شده است. یک سرویس پسزمینه اعلانها را دریافت میکند و از طریق SSE به کلاینت کاربر ارسال میکند. کلاینت با استفاده از Notification API مرورگر اعلان نشان میدهد.
سناریو ۴: داشبورد مدیریتی
یک داشبورد برای مدیر سیستم داری که وضعیت سرور، تعداد کاربران آنلاین، میزان حافظه و CPU را به صورت زنده نشان میدهد. دادهها هر ۵ ثانیه یکبار از سرور ارسال میشوند.
۱۴. کارایی و Performance
نکات کلیدی:
- استفاده از
ValueTask: در کانالها،WriteAsyncازValueTaskاستفاده میکند که سربار heap allocation را در مسیرهای سریع کاهش میدهد. - عدم بلاک کردن ترد: کل جریان باید async باشد. استفاده از
Task.DelayوWriteAsyncباعث میشود تردهای ThreadPool آزاد بمانند. - کانالهای bounded برای backpressure: اگر producer سریعتر از consumer است، از
CreateBoundedاستفاده کن تا مصرف حافظه کنترل شود. - لغو به موقع: با قطع کلاینت، توکن لغو شده و منابع آزاد میشوند.
- Bufferng را جدی بگیر: فلاش نکردن نه تنها تأخیر ایجاد میکند، بلکه ممکن است حافظه سرور را نیز زیاد کند.
- تعداد اتصالها: هر اتصال SSE یک socket باز است. باید در سرور و load balancer محدودیتها را مدیریت کنی.
بهینهسازی برای تعداد زیاد کلاینت:
- از
Channel<T>با تک reader و تک writer برای هر کلاینت استفاده کن. - از
TryWriteبه جایWriteAsyncدر broadcaster استفاده کن تا گلوگاه نشود. - اگر کلاینتی کند است، با کانال bounded و سیاست drop قدیمیترین آیتم، از عقب افتادن بقیه جلوگیری کن.
var options = new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.DropOldest
};
var channel = Channel.CreateBounded<StockUpdate>(options);
۱۵. چکلیست یادگیری
- مفهوم SSE و تفاوت آن با WebSocket را میدانم.
- میتوانم یک endpoint ساده SSE با Minimal API بسازم.
- هدرهای
Content-Type،Cache-ControlوX-Accel-Bufferingرا ست میکنم. - بعد از هر
WriteAsync،FlushAsyncصدا میزنم. - از
CancellationTokenدر حلقه وTask.Delayاستفاده میکنم. - قالببندی رویدادها (
data:،id:،event:) را میدانم. - میتوانم داده JSON را به کلاینت ارسال کنم.
- با استفاده از
EventSourceدر جاوااسکریپت، رویدادها را دریافت میکنم. - برای چند کلاینت، از
Channel<T>جداگانه برای هر کلاینت استفاده میکنم. - heartbeat را برای زنده نگهداشتن اتصال پیادهسازی کردهام.
- با
IAsyncEnumerableوWithCancellationکار کردهام. - تفاوت بین
ChannelWriter.WriteAsyncوTryWriteرا میدانم. - خطاهای رایج مانند بافر شدن یا حلقه بینهایت را نمیسازم.
- تنظیمات پروکسی معکوس (Nginx) را برای SSE میدانم.
- حداقل یک پروژه واقعی با SSE ساختهام.