🔬 آموزش کامل Server-Sent Events (SSE) در ASP.NET Core (آموزش عمیق)

Server sent event asp.net core همه سطوح
parsakarimidev.ir

🔬 آموزش کامل Server-Sent Events (SSE) در ASP.NET Core (آموزش عمیق)

Server sent event asp.net coreفریم‌ورک سطح: همه سطوح 1405/07/07

آموزش کامل 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 هر ۱۵ ثانیه، اتصال را زنده نگه دار.

۳ اشتباه رایج که باید ازشون پرهیز کنی:

  1. فراموش کردن FlushAsync که باعث بافر شدن و تأخیر در دریافت رویدادها می‌شود.
  2. حلقه بی‌نهایت بدون بررسی ct.IsCancellationRequested که بعد از قطع کلاینت CPU را مصرف می‌کند.
  3. استفاده از یک ChannelReader مشترک برای همه کاربران و تصور اینکه مثل broadcast برای همه ارسال می‌شود.

اگر فقط ۵ دقیقه وقت داری، این‌ها رو یاد بگیر:

  1. تنظیم هدر Content-Type: text/event-stream روی پاسخ HTTP.
  2. نوشتن رویداد با قالب data: متن پیام\n\n.
  3. صدا زدن Response.Body.FlushAsync(ct) بعد از هر نوشتن.
  4. استفاده از CancellationToken برای توقف به موقع حلقه.
  5. تست سریع با 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 و منابع سرور توجه کن.

۱۰. منابع و ادامه مسیر

۱۱. مرجع کامل توابع و متدها (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 معتبر می‌دهد.

کاری که انجام می‌ده (گام به گام):

  1. متن را به صورت UTF-8 انکود می‌کند.
  2. داده انکود شده را در Response.Body می‌نویسد.
  3. اگر نوشتن به دلیل لغو توکن متوقف شود، OperationCanceledException پرتاب می‌کند.
  4. بعد از اتمام نوشتن، 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 کامل می‌شود.
  • بعد از این متد می‌توانی با خیال راحت روی بدنه پاسخ بنویسی.

کاری که انجام می‌ده (گام به گام):

  1. وضعیت پاسخ را بررسی می‌کند.
  2. هدرهای HTTP را به همراه کد وضعیت ارسال می‌کند.
  3. اگر بدنه پاسخ شروع شده باشد، ممکن است بدون ارسال مجدد برگردد.
  4. عملیات را با لغو توکن هماهنگ می‌کند.

مثال ساده و قابل اجرا:

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 کامل می‌شود.

کاری که انجام می‌ده (گام به گام):

  1. بررسی می‌کند که آیا streame قابل فلاش است.
  2. داده‌های موجود در بافر داخلی را به خروجی می‌فرستد.
  3. اگر داده‌ای نباشد، بلافاصله کامل می‌شود.
  4. در صورت لغو توکن، 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 منتقل می‌کند.

کاری که انجام می‌ده (گام به گام):

  1. منبع IAsyncEnumerable را می‌گیرد.
  2. آن را با توکن لغو ترکیب می‌کند.
  3. در هنگام enumeration، توکن را به MoveNextAsync پاس می‌دهد.
  4. اگر توکن لغو شود، 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 — دریافت enumerator
  • Task.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 دارد.

کاری که انجام می‌ده (گام به گام):

  1. یک صف نامحدود در حافظه ایجاد می‌کند.
  2. یک ChannelReader<T> برای خواندن و یک ChannelWriter<T> برای نوشتن فراهم می‌کند.
  3. با تنظیمات پیش‌فرض، چند خواننده و چند نویسنده مجاز هستند.
  4. در صورت نبود فضای کافی (در حالت 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 — نوشتن بدون await
  • ChannelReader<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 سریع کامل می‌شود.

کاری که انجام می‌ده (گام به گام):

  1. بررسی می‌کند که آیا کانال کامل شده یا نه.
  2. آیتم را در صف داخلی قرار می‌دهد.
  3. خواننده‌ها را برای مصرف آیتم جدید مطلع می‌کند.
  4. اگر کانال کامل شده باشد، 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 — نوشتن بدون async
  • TryComplete — بستن کانال
  • 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 پایان می‌یابد.

کاری که انجام می‌ده (گام به گام):

  1. هر بار که MoveNextAsync فراخوانی می‌شود، منتظر آیتم جدید یا پایان کانال می‌ماند.
  2. اگر کانال کامل شده و صف خالی باشد، enumeration را تمام می‌کند.
  3. در صورت لغو توکن، OperationCanceledException پرتاب می‌کند.
  4. داده‌ها را به صورت 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 پرتاب می‌شود.

کاری که انجام می‌ده (گام به گام):

  1. یک Timer داخلی برای مدت مشخص تنظیم می‌کند.
  2. اگر توکن لغو شود، تایمر را متوقف و Task را لغو می‌کند.
  3. بعد از اتمام مدت، Task را کامل می‌کند.
  4. هیچ تردی را بلاک نمی‌کند.

مثال ساده و قابل اجرا:

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 — منتظر ماندن برای هر یک از چند Task
  • CancellationTokenSource.CancelAfter — لغو خودکار بعد از مدت

📌 متد: CancellationTokenSource.CancelAfter(TimeSpan)

امضای متد (Signature):

public void CancelAfter(TimeSpan delay)

ورودی‌ها (Parameters):

نام پارامتر نوع اجباری؟ توضیح دقیق مثال مقدار
delay TimeSpan بله مدت زمانی که پس از آن CancellationTokenSource لغو می‌شود TimeSpan.FromMinutes(5)

مقدار برگشتی (Return Value):

  • نوع: void
  • هیچ مقداری برنمی‌گرداند؛ بعد از مدت مشخص، CancellationTokenSource.Cancel() فراخوانی می‌شود.

کاری که انجام می‌ده (گام به گام):

  1. یک تایمر داخلی برای مدت مشخص تنظیم می‌کند.
  2. پس از سپری شدن مدت، متد Cancel را صدا می‌زند.
  3. تمام توکن‌های صادر شده از این منبع لغو می‌شوند.
  4. اگر مدت خیلی کوتاه یا نامعتبر باشد، استثنا پرتاب می‌کند.

مثال ساده و قابل اجرا:

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 ساخته‌ام.

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please retry or reload the page.