Polling: Регулярний запит оновлень
Polling: Регулярний запит оновлень
У попередній статті ми побудували систему нотифікацій на основі Pull Model: клієнт сам надсилає запит і отримує список нотифікацій. Це чудово для функціональності, але уявіть реальний сценарій використання.
Користувач відкрив сторінку о 15:00 і залишив вкладку браузера відкритою. О 15:03 хтось поставив йому лайк. Лічильник «дзвоника» — і досі нуль. О 15:07 прийшло ще два повідомлення. Лічильник — досі нуль. Лише коли користувач вручну натисне F5 або перейде на іншу сторінку — інтерфейс нарешті синхронізується з базою даних.
Це неприйнятний UX для сучасного застосунку. Нам потрібен механізм, завдяки якому інтерфейс оновлюється сам, без дій користувача. Найпростіший такий механізм — Polling.
Що таке Polling
Polling (від англ. to poll — опитувати) — техніка, за якою клієнт регулярно відправляє запити до сервера, щоб дізнатися, чи з'явилися нові дані. Сервер при цьому нічого не «проштовхує» — він просто відповідає на запити, коли вони надходять.
Аналогія: уявіть людину, яка кожні кілька хвилин виходить до поштової скриньки перевірити, чи не з'явилися нові листи. Листоноша (сервер) не бігає до вас з кожним листом — ви самі ходите перевіряти.
Існує два різновиди Polling, які фундаментально відрізняються поведінкою сервера:
Short Polling
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():
// ...попередній код...
app.MapNotificationEndpoints();
app.UseStaticFiles(); // Дозволяє роздавати файли з wwwroot/
app.Run();
Тепер створюємо клієнтську сторінку:
<!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 запитів на секунду лише від одного ендпоінту.
Long Polling
Ідея — утримати з'єднання
Long Polling вирішує головну проблему Short Polling — «холостий хід». Замість того, щоб відповідати одразу (навіть якщо нічого немає), сервер тримає з'єднання відкритим і відповідає лише тоді, коли з'являться нові дані.
Клієнт Сервер
|--- GET /notifications/poll?userId=1 --->|
| | (тримає з'єднання, чекає нових даних...)
| ... 8 секунд ... |
| | (нова нотифікація в БД!)
|<---- 200 { notifications: [...] } ------| ← відповідає одразу
|
| (миттєво надсилає наступний запит)
|--- GET /notifications/poll?userId=1 --->|
| | (знову чекає...)
Клієнт після отримання відповіді одразу відправляє новий запит. Т.ч., сервер завжди має «зарезервований» запит від клієнта і може відповісти в будь-який момент.
Серверна реалізація
Для Long Polling нам потрібен новий ендпоінт. Додайте його до NotificationEndpoints.cs:
// Додайте новий маршрут у MapNotificationEndpoints():
group.MapGet("/poll", LongPollForNotifications);
// 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:
<!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 Polling | Long Polling |
|---|---|---|
| Затримка доставки | До N секунд (інтервал) | Майже миттєво |
| Навантаження на сервер | Постійне (навіть без даних) | Менше (запити тільки коли є дані) |
| Кількість запитів | Рівномірна | Пікова при появі даних |
| Складність реалізації | Дуже проста | Середня |
| Навантаження на БД | Рівномірне | Пікове при появі даних |
| Відкриті з'єднання | Короткі (< 1с) | Довгі (до N секунд) |
| Підтримка серверів | Будь-який | Потрібен async-сервер |
Short Polling підходить, коли:
- Дані оновлюються рідко (раз на хвилину і рідше)
- Простота реалізації важливіша за ефективність
- Кількість одночасних клієнтів невелика (< 100)
- Застарілі дані (до 30с) прийнятні
Long Polling підходить, коли:
- Потрібна низька затримка (< 2-3 секунди)
- Нові дані з'являються рідко, але потрібна швидка реакція
- Сервер підтримує async-запити (ASP.NET Core — так)
- Немає можливості використати SSE або WebSockets
Обмеження Long Polling
Long Polling значно краще за Short Polling: менше запитів, менша затримка. Але він має власні проблеми:
- Відкриті з'єднання: при 1000 клієнтах — 1000 відкритих HTTP-з'єднань. Кожне займає пам'ять і тред (або async-стан).
- Polling БД: ми перевіряємо базу даних кожні 2 секунди всередині циклу. При 1000 клієнтах — 500 запитів до БД на секунду лише від одного ендпоінту.
- Складність логіки:
lastCheckedAt, CancellationToken, рекурсивний цикл — вже складніше, ніжsetInterval. - Все ще не справжній push: сервер не «штовхає» (push) дані — він просто чекає, поки клієнт не запросить. Різниця тонка, але архітектурно важлива.
Рішення цих проблем — Server-Sent Events (SSE): технологія, де сервер по-справжньому надсилає нові дані клієнту через постійне односпрямоване з'єднання. Це тема наступної статті.
Підсумок
Ми зробили наш інтерфейс «живим» за допомогою двох технік:
- Short Polling — простий
setInterval+ регулярний fetch. Просто, але неефективно при великій кількості користувачів. - Long Polling — сервер тримає запит відкритим і відповідає лише коли є нові дані. Менша затримка і менше «холостих» запитів.
Обидва підходи — це варіації Pull Model: клієнт ініціює кожен запит. У наступній статті ми перейдемо до Push Model: Server-Sent Events, де сервер самостійно надсилає оновлення через постійне з'єднання.
In-App нотифікації через базу даних
Вивчаємо основи системи нотифікацій: проєктуємо таблицю, реалізуємо CRUD-ендпоінти у ASP.NET Minimal API та будуємо першу повноцінну систему сповіщень на основі Pull Model.
Server-Sent Events: Однострімовий push від сервера
Вивчаємо Server-Sent Events (SSE) — стандартну HTTP-технологію для односпрямованої передачі подій від сервера до клієнта. Реалізуємо стрімінг у ASP.NET Minimal API та нативний EventSource у браузері.