Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Трансляція подій

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

6 питань

Звичайний вебзастосунок відповідає лише на запит: сторінка оновиться тоді, коли її оновить користувач. Бродкастинг дозволяє серверу самому надіслати повідомлення у відкритий браузер - без перезавантаження й без опитування.

Навіщо: чат, сповіщення, лічильник онлайн, прогрес довгого завдання, оновлення списку без кнопки «оновити».

Як це виглядає. Подія позначається інтерфейсом:

class VacancyPublished implements ShouldBroadcast
{
    public function __construct(public Vacancy $vacancy)
    {
    }

    public function broadcastOn(): Channel
    {
        return new Channel('vacancies');
    }
}

Далі звичайне VacancyPublished::dispatch($vacancy) - і подія йде не лише слухачам на сервері, а й у браузер.

На клієнті її слухає Echo:

Echo.channel('vacancies')
    .listen('VacancyPublished', (e) => {
        console.log(e.vacancy.title);
    });

Що потрібно, крім коду: окремий сервіс, який тримає WebSocket-зʼєднання - Laravel Reverb (власний), Pusher чи Ably. PHP-процес сам зʼєднання не тримає.

Альтернатива, про яку варто памʼятати. Якщо оновлення рідкісні або потрібні лише одному користувачеві, звичайне опитування раз на кілька секунд чи wire:poll у Livewire простіші й не вимагають окремого сервера. Бродкастинг виправданий, коли подій багато, вони мають доходити миттєво й до багатьох одразу.

Докладніше в документації: Бродкастинг

Reverb - власний WebSocket-сервер Laravel, написаний на PHP. Він замінює сторонні сервіси на кшталт Pusher чи Ably: замість платити за кількість з'єднань і повідомлень, ви запускаєте сервер на своїй інфраструктурі.

Reverb реалізує протокол Pusher, тому фронтенд працює зі звичним Laravel Echo і клієнтом pusher-js, а перехід з Pusher на Reverb (чи назад) - це зміна змінних оточення.

Встановлення:

php artisan install:broadcasting --reverb

Команда встановлює пакети Composer і NPM, публікує конфігурацію, створює routes/channels.php і додає в .env ключі:

BROADCAST_CONNECTION=reverb
REVERB_APP_ID=...
REVERB_APP_KEY=...
REVERB_APP_SECRET=...
REVERB_HOST=localhost
REVERB_PORT=8080

Запуск сервера:

php artisan reverb:start
php artisan reverb:start --debug   # показувати вхідні й вихідні повідомлення

Подія для трансляції:

class OrderShipped implements ShouldBroadcast
{
    public function __construct(public Order $order) {}

    public function broadcastOn(): array
    {
        return [new PrivateChannel('orders.' . $this->order->user_id)];
    }
}

Прослуховування на фронтенді:

Echo.private(`orders.${userId}`).listen('OrderShipped', (e) => {
    console.log(e.order);
});

Що важливо пам'ятати:

  • ShouldBroadcast відправляє подію через чергу - без запущеного воркера повідомлення не надходять. ShouldBroadcastNow - одразу, без черги;
  • REVERB_SERVER_HOST/REVERB_SERVER_PORT - де слухає сам сервер, а REVERB_HOST/REVERB_PORT - куди Laravel надсилає повідомлення. На продакшені Reverb зазвичай слухає 0.0.0.0:8080, а назовні доступний через Nginx на 443;
  • Reverb - довгоживучий процес: після деплою його треба перезапустити (reverb:restart), а на сервері тримати під Supervisor.

Докладніше в документації: Broadcasting: Reverb

Broadcasting транслює серверні події на клієнт через WebSockets - для оновлень у реальному часі (чати, нотифікації).

class MessageSent implements ShouldBroadcast
{
    public function broadcastOn(): array
    {
        return [new PrivateChannel('chat.'.$this->roomId)];
    }
}

На клієнті Laravel Echo підписується на канал:

Echo.private(`chat.${roomId}`)
    .listen('MessageSent', (e) => console.log(e.message));

Сервер WebSockets - Laravel Reverb (офіційний), Pusher або Soketi. Канали бувають public, private (з авторизацією) і presence (зі списком учасників).

Докладніше в документації: Broadcasting

Канал присутності (presence channel) - приватний канал, який додатково знає, хто саме в ньому зараз. Ідеальний для чатів, спільного редагування, списку «зараз переглядають».

Авторизація відрізняється від звичайного приватного каналу: колбек повертає масив даних про користувача, а не true:

// routes/channels.php
Broadcast::channel('chat.{roomId}', function (User $user, int $roomId) {
    if ($user->canJoinRoom($roomId)) {
        return ['id' => $user->id, 'name' => $user->name];
    }
});

Ці дані побачать усі учасники каналу - не повертайте туди email, телефони чи інші приватні поля.

Приєднання на фронтенді:

Echo.join(`chat.${roomId}`)
    .here((users) => { online.value = users; })          // хто вже в каналі
    .joining((user) => { online.value.push(user); })      // хтось прийшов
    .leaving((user) => { remove(user); })                 // хтось пішов
    .listen('NewMessage', (e) => { messages.value.push(e.message); });

Клієнтські події (whisper) - повідомлення від одного клієнта іншим без звернення до Laravel:

// відправник
Echo.private(`chat.${roomId}`).whisper('typing', { name: user.name });

// отримувачі
Echo.private(`chat.${roomId}`).listenForWhisper('typing', (e) => {
    showTyping(e.name);
});

Застосунок не бачить цих повідомлень, тому:

  • whisper - лише для ефемерних, неважливих сигналів: «набирає», курсор співрозмовника, «переглядає зараз»;
  • нічого, що має зберігатися чи перевірятися (повідомлення чату, зміни даних), через whisper не йде - тільки через HTTP-запит до застосунку, який валідує, зберігає й транслює подію;
  • whisper працює лише в приватних каналах і каналах присутності; у Pusher клієнтські події треба окремо увімкнути в налаштуваннях застосунку.

toOthers() - щоб відправник не отримав власну подію повторно:

broadcast(new NewMessage($message))->toOthers();

Echo автоматично додає заголовок X-Socket-ID до запитів, і Laravel виключає це з'єднання з отримувачів.

Нюанси: користувач з двома вкладками - два з'єднання, але в списку присутності він один (учасники об'єднуються за id). Події leaving приходять із затримкою при обриві мережі - сервер виявляє мертве з'єднання не миттєво.

Докладніше в документації: Broadcasting: канали присутності

Канали бувають трьох типів, і різниця між ними - саме в доступі.

Публічний - слухати може будь-хто, хто знає назву:

class VacancyPublished implements ShouldBroadcast
{
    public function broadcastOn(): Channel
    {
        return new Channel('vacancies');
    }
}

Годиться лише для того, що й так публічне.

Приватний - потребує авторизації:

public function broadcastOn(): PrivateChannel
{
    return new PrivateChannel('user.'.$this->user->id);
}

Правило описується в routes/channels.php:

Broadcast::channel('user.{userId}', function (User $user, int $userId) {
    return $user->id === $userId;
});

Клієнт спершу звертається до /broadcasting/auth, і лише отримавши підпис, підписується на канал.

Presence - приватний плюс список присутніх: хто зараз онлайн, хто друкує.

Де помиляються:

  • Публічний канал для приватних даних. Назву каналу видно у фронтенді, тож Channel('user.'.$id) слухається ким завгодно з перебором id.
  • Замикання авторизації, що завжди повертає true. Це те саме, що публічний канал, але виглядає безпечно.
  • Забувають, що подія несе всю модель. За замовчуванням у payload потрапляють усі публічні властивості - разом із полями, яких клієнт бачити не мусить. Формуйте payload явно через broadcastWith().
  • Приватні дані у назві каналу. Email чи токен у назві видно всім, хто дивиться трафік.

Практично: усе, що стосується конкретного користувача, - PrivateChannel; payload завжди явний.

Докладніше в документації: Бродкастинг

WebSocket-з'єднання довгоживучі: кожна відкрита вкладка - постійне з'єднання, яке тримає пам'ять і файловий дескриптор. Обмеження зазвичай не в процесорі, а в лімітах ОС, вебсервера й циклу подій.

1. Ліміт відкритих файлів. У Unix кожне з'єднання - файл. Типовий ліміт ulimit -n - 1024:

# /etc/security/limits.conf
forge  soft  nofile  10000
forge  hard  nofile  10000

Під Supervisor - minfds=10000 у supervisord.conf, бо процес успадковує ліміти від менеджера процесів.

2. Цикл подій. Reverb працює на ReactPHP, і за замовчуванням використовує stream_select, обмежений приблизно 1024 файлами. Для понад тисячі з'єднань потрібне розширення ext-uv (pecl install uv) - Reverb підхопить його автоматично.

3. Вебсервер перед Reverb. Nginx проксіює WebSocket із заголовками Upgrade і Connection "Upgrade", і має власні ліміти:

worker_rlimit_nofile 10000;
events {
    worker_connections 10000;
}

4. Горизонтальне масштабування. Один процес Reverb однопотоковий. Коли одного сервера замало:

REVERB_SCALING_ENABLED=true
  • усі сервери Reverb підключаються до спільного Redis і обмінюються повідомленнями через pub/sub: подія, отримана одним сервером, доставляється клієнтам на всіх;
  • сервери стоять за балансувальником, що підтримує WebSocket;
  • Redis для масштабування - центральна залежність: його відмова розриває обмін між серверами.

5. Деплой і перезапуск. reverb:restart коректно закриває з'єднання, і клієнти Echo перепідключаються. Але тисячі одночасних перепідключень - стрибок навантаження на Reverb і на ендпойнт авторизації каналів /broadcasting/auth (кожне приватне з'єднання - запит до застосунку). Варто перезапускати сервери по черзі.

6. Моніторинг. Reverb інтегрується з Pulse: картки з кількістю з'єднань і повідомлень. Слідкуйте за пам'яттю процесу й pulse:check на серверах Reverb.

7. Що навантажує систему крім самих з'єднань:

  • трансляція йде через чергу - при масових подіях пропускна здатність черги стає вузьким місцем;
  • розмір повідомлень - транслюйте ідентифікатори й мінімум полів, а не моделі з усіма зв'язками;
  • частота - події, що генеруються сотні разів на секунду, варто об'єднувати чи дебаунсити.

Альтернатива власному масштабуванню - керований сервіс (Pusher, Ably, Laravel Cloud) з тим самим протоколом: код застосунку не змінюється.

Докладніше в документації: Reverb: масштабування