Нотифікації

Polling: Регулярний запит оновлень

Робимо інтерфейс «живим»: вивчаємо Short Polling та Long Polling — два перші кроки до нотифікацій реального часу. Реалізуємо обидва підходи в ASP.NET Minimal API та мінімальному HTML-клієнті.

Polling: Регулярний запит оновлень

У попередній статті ми побудували систему нотифікацій на основі Pull Model: клієнт сам надсилає запит і отримує список нотифікацій. Це чудово для функціональності, але уявіть реальний сценарій використання.

Користувач відкрив сторінку о 15:00 і залишив вкладку браузера відкритою. О 15:03 хтось поставив йому лайк. Лічильник «дзвоника» — і досі нуль. О 15:07 прийшло ще два повідомлення. Лічильник — досі нуль. Лише коли користувач вручну натисне F5 або перейде на іншу сторінку — інтерфейс нарешті синхронізується з базою даних.

Це неприйнятний UX для сучасного застосунку. Нам потрібен механізм, завдяки якому інтерфейс оновлюється сам, без дій користувача. Найпростіший такий механізм — Polling.

Що ми побудуємо: розширимо проєкт із попередньої статті HTML-сторінкою, яка автоматично перевіряє нові нотифікації. Реалізуємо обидва різновиди Polling — Short і Long — і побачимо різницю між ними.

Що таке Polling

Polling (від англ. to poll — опитувати) — техніка, за якою клієнт регулярно відправляє запити до сервера, щоб дізнатися, чи з'явилися нові дані. Сервер при цьому нічого не «проштовхує» — він просто відповідає на запити, коли вони надходять.

Аналогія: уявіть людину, яка кожні кілька хвилин виходить до поштової скриньки перевірити, чи не з'явилися нові листи. Листоноша (сервер) не бігає до вас з кожним листом — ви самі ходите перевіряти.

Існує два різновиди Polling, які фундаментально відрізняються поведінкою сервера:

Short Polling

Клієнт надсилає запит → сервер одразу відповідає (навіть якщо немає нових даних) → клієнт чекає N секунд → повторює. Простий і передбачуваний, але неефективний.

Long Polling

Клієнт надсилає запит → сервер тримає з'єднання відкритим і відповідає лише коли з'являться нові дані (або після таймауту) → клієнт одразу надсилає наступний запит. Ефективніший, але складніший.

Short Polling

Як це працює

Клієнт                           Сервер
  |--- GET /notifications/unread-count --->|
  |<----------- { count: 0 } -------------|  ← одразу відповідає
  |
  | (чекає 5 секунд)
  |
  |--- GET /notifications/unread-count --->|
  |<----------- { count: 0 } -------------|  ← одразу відповідає (нічого нового)
  |
  | (нова нотифікація з'явилася в БД)
  |
  |--- GET /notifications/unread-count --->|
  |<----------- { count: 2 } -------------|  ← клієнт дізнався про нові дані

Ендпоінт для Short Polling у нас вже є з попередньої статті — GET /notifications/unread-count. Він відповідає стандартним чином: отримав запит, зробив запит до бази, повернув результат.

Клієнтська сторона

Тепер нам потрібна HTML-сторінка, яка буде автоматично викликати цей ендпоінт. Створимо її у директорії wwwroot/:

mkdir wwwroot

Щоб ASP.NET міг роздавати статичні файли, додайте в Program.cs перед app.Run():

Program.cs
// ...попередній код...
app.MapNotificationEndpoints();

app.UseStaticFiles(); // Дозволяє роздавати файли з wwwroot/

app.Run();

Тепер створюємо клієнтську сторінку:

wwwroot/index.html
<!DOCTYPE html>
<html lang="uk">
<head>
    <meta charset="UTF-8">
    <title>Нотифікації — Short Polling Demo</title>
    <style>
        body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }
        #badge {
            display: inline-block;
            background: #ef4444;
            color: white;
            border-radius: 50%;
            width: 24px;
            height: 24px;
            text-align: center;
            line-height: 24px;
            font-size: 14px;
            font-weight: bold;
        }
        #badge.zero { background: #94a3b8; } /* сірий, коли нуль */
        #log { margin-top: 20px; font-size: 13px; color: #64748b; }
        #log p { margin: 2px 0; }
    </style>
</head>
<body>
    <h1>🔔 <span id="badge" class="zero">0</span> непрочитаних</h1>
    <button onclick="checkNow()">Перевірити зараз</button>
    <div id="log"></div>

    <script>
        const USER_ID = 1;          // Для демо — хардкоджений userId
        const POLL_INTERVAL = 5000; // 5 секунд між запитами

        // Ця функція надсилає запит до API і оновлює лічильник
        async function checkUnreadCount() {
            const response = await fetch(`/notifications/unread-count?userId=${USER_ID}`);
            const data = await response.json(); // { count: N }

            const badge = document.getElementById('badge');
            badge.textContent = data.count;
            // Якщо нуль — значок сірий, якщо є нові — червоний
            badge.className = data.count === 0 ? 'zero' : '';

            // Логуємо кожен запит, щоб видіти активність поллінгу
            const log = document.getElementById('log');
            const time = new Date().toLocaleTimeString('uk-UA');
            log.innerHTML = `<p>${time} — перевірено: ${data.count} непрочитаних</p>` + log.innerHTML;
        }

        // Запуск одразу при відкритті сторінки
        checkUnreadCount();

        // І далі — кожні POLL_INTERVAL мілісекунд
        // setInterval повертає ID таймера (можна зупинити через clearInterval)
        const intervalId = setInterval(checkUnreadCount, POLL_INTERVAL);

        // Кнопка для миттєвої перевірки
        function checkNow() { checkUnreadCount(); }
    </script>
</body>
</html>

Відкрийте http://localhost:5000/index.html та в окремому вікні виконайте POST-запит для створення нотифікації — ви побачите, як лічильник зміниться протягом 5 секунд.

Проблеми Short Polling

Відкрийте DevTools → Network у браузері і поспостерігайте за трафіком. Кожні 5 секунд — новий запит до сервера:

15:00:00  GET /notifications/unread-count  → 200  { count: 0 }
15:00:05  GET /notifications/unread-count  → 200  { count: 0 }
15:00:10  GET /notifications/unread-count  → 200  { count: 0 }
15:00:15  GET /notifications/unread-count  → 200  { count: 0 }  ← 3 запити, нічого немає
15:00:20  GET /notifications/unread-count  → 200  { count: 1 }  ← нарешті!

З 5 запитів лише один ніс реальну нову інформацію. Решта — «холостий хід». При 1000 активних користувачів це 200 запитів на секунду лише від одного ендпоінту.

Масштаб має значення. Short Polling з інтервалом 5 секунд і 10 000 одночасних користувачів дає 2 000 запитів/секунду. З інтервалом 1 секунда — вже 10 000 запитів/секунду. Це значне навантаження для будь-якого сервера.

Long Polling

Ідея — утримати з'єднання

Long Polling вирішує головну проблему Short Polling — «холостий хід». Замість того, щоб відповідати одразу (навіть якщо нічого немає), сервер тримає з'єднання відкритим і відповідає лише тоді, коли з'являться нові дані.

Клієнт                                    Сервер
  |--- GET /notifications/poll?userId=1 --->|
  |                                         | (тримає з'єднання, чекає нових даних...)
  |                  ... 8 секунд ...       |
  |                                         | (нова нотифікація в БД!)
  |<---- 200 { notifications: [...] } ------|  ← відповідає одразу
  |
  | (миттєво надсилає наступний запит)
  |--- GET /notifications/poll?userId=1 --->|
  |                                         | (знову чекає...)

Клієнт після отримання відповіді одразу відправляє новий запит. Т.ч., сервер завжди має «зарезервований» запит від клієнта і може відповісти в будь-який момент.

Серверна реалізація

Для Long Polling нам потрібен новий ендпоінт. Додайте його до NotificationEndpoints.cs:

Endpoints/NotificationEndpoints.cs
// Додайте новий маршрут у MapNotificationEndpoints():
group.MapGet("/poll", LongPollForNotifications);
Endpoints/NotificationEndpoints.cs
// GET /notifications/poll?userId=1&lastCheckedAt=2024-01-01T12:00:00Z
private static async Task<IResult> LongPollForNotifications(
    AppDbContext db,
    int userId,
    DateTime? lastCheckedAt,  // Клієнт передає момент, з якого хоче нові нотифікації
    CancellationToken cancellationToken) // ASP.NET автоматично передає цей токен
{
    // Час очікування: якщо за 30 секунд нічого не з'явилося — повертаємо порожній результат.
    // Клієнт одразу надішле новий запит.
    var timeout = TimeSpan.FromSeconds(30);
    var checkFrom = lastCheckedAt ?? DateTime.UtcNow.AddMinutes(-1); // Якщо не задано — остання хвилина
    var deadline = DateTime.UtcNow.Add(timeout);

    while (DateTime.UtcNow < deadline)
    {
        // Перевіряємо, чи клієнт не від'єднався (закрив вкладку, натиснув F5)
        // cancellationToken.IsCancellationRequested == true, якщо браузер закрив з'єднання
        if (cancellationToken.IsCancellationRequested)
            return Results.NoContent(); // 204 — чисте завершення

        // Шукаємо нові нотифікації, які з'явилися після checkFrom
        var newNotifications = await db.Notifications
            .Where(n => n.UserId == userId && n.CreatedAt > checkFrom)
            .OrderBy(n => n.CreatedAt)
            .Select(n => new NotificationResponse(
                n.Id, n.Type, n.Message, n.ActionUrl, n.IsRead, n.CreatedAt))
            .ToListAsync(cancellationToken);

        if (newNotifications.Count > 0)
            return Results.Ok(newNotifications); // Є нові — відповідаємо одразу!

        // Нічого немає — чекаємо 2 секунди перед наступною перевіркою БД.
        // Task.Delay підтримує CancellationToken: якщо клієнт від'єднається —
        // виняток OperationCanceledException автоматично завершить запит.
        try
        {
            await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
        }
        catch (OperationCanceledException)
        {
            return Results.NoContent(); // Клієнт від'єднався під час очікування
        }

        // Оновлюємо checkFrom, щоб наступний цикл не знаходив ті ж самі записи
        checkFrom = DateTime.UtcNow;
    }

    // Таймаут вичерпався — повертаємо порожній результат
    // Клієнт одразу надішле новий запит і все повториться
    return Results.Ok(Array.Empty<NotificationResponse>());
}

Розберемо ключові моменти:

CancellationToken cancellationToken — цей параметр ASP.NET автоматично «вводить» через DI. Токен переходить у стан «скасовано» (IsCancellationRequested == true) коли HTTP-з'єднання розривається з будь-якої причини: клієнт закрив вкладку, перейшов на іншу сторінку, або мережа обірвалася. Без перевірки цього токена сервер продовжував би марно тримати goroutine в пам'яті.

Цикл while — ми не можемо «підписатися» на нові записи в SQLite, тому перевіряємо базу кожні 2 секунди. Це компроміс: інтервал 2 секунди дає затримку до 2 секунд у доставці нотифікацій, але зменшує навантаження на БД.

lastCheckedAt — клієнт передає часову мітку свого попереднього запиту. Це дозволяє серверу шукати лише «нові» нотифікації, а не починати з самого початку кожного разу.

Клієнтська частина Long Polling

Оновимо index.html, щоб додати режим Long Polling:

wwwroot/index.html
<!DOCTYPE html>
<html lang="uk">
<head>
    <meta charset="UTF-8">
    <title>Нотифікації — Polling Demo</title>
    <style>
        body { font-family: sans-serif; max-width: 700px; margin: 40px auto; padding: 0 20px; }
        #badge { display: inline-block; background: #ef4444; color: white;
                 border-radius: 50%; width: 24px; height: 24px; text-align: center;
                 line-height: 24px; font-size: 14px; font-weight: bold; }
        #badge.zero { background: #94a3b8; }
        .controls { display: flex; gap: 10px; margin: 20px 0; }
        button { padding: 8px 16px; border-radius: 6px; border: none; cursor: pointer; }
        .btn-short { background: #3b82f6; color: white; }
        .btn-long { background: #8b5cf6; color: white; }
        .btn-stop { background: #ef4444; color: white; }
        #mode { font-weight: bold; color: #475569; }
        #log { margin-top: 20px; font-size: 13px; color: #64748b; max-height: 200px; overflow-y: auto; }
        #log p { margin: 2px 0; }
    </style>
</head>
<body>
    <h1>🔔 <span id="badge" class="zero">0</span> непрочитаних</h1>
    <p>Режим: <span id="mode">зупинено</span></p>

    <div class="controls">
        <button class="btn-short" onclick="startShortPolling()">Short Polling (5с)</button>
        <button class="btn-long" onclick="startLongPolling()">Long Polling</button>
        <button class="btn-stop" onclick="stopPolling()">Зупинити</button>
    </div>

    <div id="log"></div>

    <script>
        const USER_ID = 1;
        let isRunning = false;
        let shortPollTimerId = null;
        let lastCheckedAt = new Date().toISOString(); // Стартовий момент для Long Polling

        function log(message) {
            const el = document.getElementById('log');
            const time = new Date().toLocaleTimeString('uk-UA');
            el.innerHTML = `<p>${time}${message}</p>` + el.innerHTML;
        }

        function updateBadge(count) {
            const badge = document.getElementById('badge');
            badge.textContent = count;
            badge.className = count === 0 ? 'zero' : '';
        }

        // ===== SHORT POLLING =====
        async function shortPollOnce() {
            const res = await fetch(`/notifications/unread-count?userId=${USER_ID}`);
            const { count } = await res.json();
            updateBadge(count);
            log(`[Short] отримано: ${count} непрочитаних`);
        }

        function startShortPolling() {
            stopPolling();
            isRunning = true;
            document.getElementById('mode').textContent = 'Short Polling (кожні 5с)';
            shortPollOnce(); // Одразу при старті
            shortPollTimerId = setInterval(shortPollOnce, 5000);
        }

        // ===== LONG POLLING =====
        // Рекурсивна async-функція: після кожної відповіді сервера одразу викликає себе
        async function longPollLoop() {
            if (!isRunning) return; // Зупинилися — виходимо з рекурсії

            try {
                log(`[Long] очікую відповіді від сервера...`);

                const res = await fetch(
                    `/notifications/poll?userId=${USER_ID}&lastCheckedAt=${lastCheckedAt}`
                );

                if (!res.ok) {
                    log(`[Long] помилка ${res.status}, повтор через 3с`);
                    setTimeout(longPollLoop, 3000); // При помилці — пауза перед повтором
                    return;
                }

                const notifications = await res.json();

                if (notifications.length > 0) {
                    log(`[Long] 🎉 ${notifications.length} нових нотифікацій!`);
                    // Оновлюємо lastCheckedAt до часу останньої нотифікації
                    const latest = notifications[notifications.length - 1];
                    lastCheckedAt = latest.createdAt;
                } else {
                    log(`[Long] таймаут — нічого нового, повторюю`);
                }

                // Оновлюємо лічильник через окремий запит (або можна рахувати з масиву)
                const countRes = await fetch(`/notifications/unread-count?userId=${USER_ID}`);
                const { count } = await countRes.json();
                updateBadge(count);

            } catch (err) {
                // fetch кидає помилку якщо мережа обірвалася
                log(`[Long] помилка мережі: ${err.message}, повтор через 5с`);
                setTimeout(longPollLoop, 5000);
                return;
            }

            // Одразу запускаємо наступний цикл — без затримки!
            // Затримка вже є на стороні сервера (до 30 секунд)
            longPollLoop();
        }

        function startLongPolling() {
            stopPolling();
            isRunning = true;
            document.getElementById('mode').textContent = 'Long Polling (миттєве оновлення)';
            longPollLoop();
        }

        function stopPolling() {
            isRunning = false;
            if (shortPollTimerId) {
                clearInterval(shortPollTimerId);
                shortPollTimerId = null;
            }
            document.getElementById('mode').textContent = 'зупинено';
            log('— polling зупинено —');
        }
    </script>
</body>
</html>

Відкрийте сторінку і переключіться між режимами. Різниця помітна: при Short Polling лічильник оновлюється з фіксованою затримкою до 5 секунд, при Long Polling — майже миттєво після появи нової нотифікації.


Порівняння підходів

ХарактеристикаShort PollingLong Polling
Затримка доставкиДо N секунд (інтервал)Майже миттєво
Навантаження на серверПостійне (навіть без даних)Менше (запити тільки коли є дані)
Кількість запитівРівномірнаПікова при появі даних
Складність реалізаціїДуже простаСередня
Навантаження на БДРівномірнеПікове при появі даних
Відкриті з'єднанняКороткі (< 1с)Довгі (до N секунд)
Підтримка серверівБудь-якийПотрібен async-сервер

Обмеження Long Polling

Long Polling значно краще за Short Polling: менше запитів, менша затримка. Але він має власні проблеми:

  1. Відкриті з'єднання: при 1000 клієнтах — 1000 відкритих HTTP-з'єднань. Кожне займає пам'ять і тред (або async-стан).
  2. Polling БД: ми перевіряємо базу даних кожні 2 секунди всередині циклу. При 1000 клієнтах — 500 запитів до БД на секунду лише від одного ендпоінту.
  3. Складність логіки: lastCheckedAt, CancellationToken, рекурсивний цикл — вже складніше, ніж setInterval.
  4. Все ще не справжній push: сервер не «штовхає» (push) дані — він просто чекає, поки клієнт не запросить. Різниця тонка, але архітектурно важлива.

Рішення цих проблем — Server-Sent Events (SSE): технологія, де сервер по-справжньому надсилає нові дані клієнту через постійне односпрямоване з'єднання. Це тема наступної статті.


Підсумок

Ми зробили наш інтерфейс «живим» за допомогою двох технік:

  • Short Polling — простий setInterval + регулярний fetch. Просто, але неефективно при великій кількості користувачів.
  • Long Polling — сервер тримає запит відкритим і відповідає лише коли є нові дані. Менша затримка і менше «холостих» запитів.

Обидва підходи — це варіації Pull Model: клієнт ініціює кожен запит. У наступній статті ми перейдемо до Push Model: Server-Sent Events, де сервер самостійно надсилає оновлення через постійне з'єднання.

Copyright © 2026