Настройка Laravel Reverb + Sanctum

18 Марта 2024 21:27

Laravel Reverb вышел дней 10 назад, по нему еще не успели наплодить копипастных статей, а по его предшественнику spatie/laravel-websockets совсем мало материала было. Поэтому будет полезно рассмотреть процесс настройки websockets на сайте без сторонних сервисов типа Pusher! Но с уже привычными библиотеками для фронта laravel-echo и pusher-js (Только библиотека, без сервиса).

Не буду рассматривать лишние процессы вроде установки проекта, запуска миграций и т.д., а приступлю сразу к делу, при условии, что у вас уже установлен проект с Laravel 11.0.7 (Последняя на момент написания статьи), установлены файлы для API (php artisan install:api), а также sanctum!

Рассмотрим способ обновления данных по сокетам для заказа пользователем, имеющего доступ к такому заказу.

BACKEND

И так, наш composer.json выглядит примерно так:

"require": {
    "php": "^8.2",
    "laravel/framework": "^11.0",
    "laravel/sanctum": "^4.0",
    "laravel/tinker": "^2.9"
},
"require-dev": {
    "fakerphp/faker": "^1.23",
    "laravel/pint": "^1.13",
    "laravel/sail": "^1.26",
    "mockery/mockery": "^1.6",
    "nunomaduro/collision": "^8.0",
    "phpunit/phpunit": "^10.5",
    "spatie/laravel-ignition": "^2.4"
},

Устанавливаем Laravel Reverb

Запускаем команду

php artisan install:broadcasting

На вопрос Would you like to install Laravel Reverb? Отвечаем - Yes.

Эта команда установит нам Laravel Reverb и создаст файлы api/channels.php, config/broadcasting.php и др. (В ранних версиях Laravel эти файлы были изначально, с 11 версии создается отдельно)

Примечание. Если эти файлы уже были то они не заменятся, можно не волноваться!

 Также в .env файл были добавлены настройки:

REVERB_APP_ID=SOME_GENERATED_VALUE
REVERB_APP_KEY=SOME_GENERATED_VALUE
REVERB_APP_SECRET=SOME_GENERATED_VALUE
REVERB_HOST="localhost"
REVERB_PORT=8080
REVERB_SCHEME=http

На текущем этапе уже можно запустить сервер - php artisan reverb:start.

Примечание. Если вы обновляли Laravel 10 до Laravel 11 то замените в .env файле ключ BROADCAST_DRIVER на BROADCAST_CONNECTION со знчением reverb т.к. при установке Reverb в конфиге этот ключ был поменян, можно не заметить и долго искать в чем проблема!

Создаем событие

Создаем событие, которое будет транслироваться по сокетам (На примере обновления статуса заказа):

php artisan make:event OrderChanged

У нас создался файл по пути app/Events/OrderChanged.php со следующим содержимым:

<?php

namespace App\Events;

use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderChanged
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    /**
     * Create a new event instance.
     */
    public function __construct()
    {
        //
    }

    /**
     * Get the channels the event should broadcast on.
     *
     * @return array<int, \Illuminate\Broadcasting\Channel>
     */
    public function broadcastOn(): array
    {
        return [
            new PrivateChannel('channel-name'),
        ];
    }
}

Далее добавляем в этот файл:

  • Имплементируем класс от ShouldBroadcastNow.
    Важно! ShouldBroadcastNow - предполагает синхронное выполнение события т.е. выполняется сразу. Если у вас уже настроен Redis или другой способ организации очередей то можете использовать ShouldBroadcast, в противном случае используйте ShouldBroadcastNow.
  • Добавляем свойство public $order;. Это заказ, который мы будем передавать в событие в нужный момент (Изменение статуса).
  • Добавляем метод broadcastWith(). Это метод, который будет содержать данные для отправки. Если его не указать то класс события автоматически соберет имеющиеся свойства, сериализует и отправит в качесте ответа по сокетам (Поэтому часто используют свойство $message для чатов и пр.)
  • Добавляем метод broadcastOn(). Указываем канал по которому будем транслировать событие и передавать данные.

В конечном итоге, файл приобретет вид:

<?php

namespace App\Events\Orders;

use App\Models\Orders\Order;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderChanged implements ShouldBroadcastNow
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    /**
     * Экземпляр заказа.
     *
     * @var Order
     */
    public $order;

    /**
     * Create a new event instance.
     */
    public function __construct($order)
    {
        $this->order = $order;
    }

    /**
     * Получите данные для трансляции.
     *
     * @return array
     */
    public function broadcastWith(): array
    {
        return ['status' => $this->order->status];
    }

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

Регистрируем сервис провайдер

php artisan make:provider BroadcastServiceProvider

Меняем его содержимое на следующее:

<?php

namespace App\Providers;

use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\ServiceProvider;

class BroadcastServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap services.
     */
    public function boot(): void
    {
        Broadcast::routes(["prefix" => "api", "middleware" => ["auth:sanctum"]]);

        require base_path('routes/channels.php');
    }
}

Тут мы указываем, что собираемся использовать Broadcasting для API: Добавляем префикс api для маршрута авторизации и добавляем middleware sanctum.

Также регистрируем этот сервис провайдер - добавляем строчку в bootstrap/providers.php:

Важно. Если вы обновлялись с Laravel 10 то просто расскоментируйте этот сервис провайдер в config/app.php если этого не произошло при установке Reverb.

<?php

return [
    App\Providers\AppServiceProvider::class,
    App\Providers\BroadcastServiceProvider::class,
];

Добавляем канал

Идем в файл routes/channels.php, который сгенерировался при установке Reverb и добавляем в него канал:

Broadcast::channel('orders.{orderId}', function ($user, $orderId) {
    return $user->company_id === Order::find($orderId)->company_id;
});

Что тут происходит: 

  • {orderId} - это плейсхолдер для номера заказа, который мы передаем из события (Да, тут валидация происходит из бэка на фронт, а не с фронта на бэк, как в случае с api.php).
  • $orderId - Параметр, в который подставляется id Заказа из плейсхолдера.
  • Внутри callback-функции проводим удобную нам валидацию. Я рассмотрел вариант соответствия id компании заказа и авторизованного пользователя
  • $user - Авторизованный пользователь. Сюда он попадет после автоматического запроса laravel-echo на маршрут с auth, который мы формировали в сервис провайдере выше.

Вызов события

Вызываем событие в удобном для нас месте (Там, где будет меняться статус заказа) следующим образом:

\App\Events\OrderChanged::dispatch(Order::find(1)); // Ну или у вас уже будет заказ, у которого меняли статус, в переменной типа $order, ее и прокидываем в параметре

FRONTEND

Установка laravel-echo и pusher-js

Эти две библиотеки используются для подключения к каналам и прослушиванию событий, концепция которых была внедрена в pusher-js.

npm install --save-dev laravel-echo pusher-js

Дальше нужно инициализировать Echo. В зависимости от frontend-фреймворка, который вы используете, производиться это может в разных местах (Во Vue - это index.js,в Quasar - это конфил echo.js в папке boot и добавление в основной конфиг quasar и т.д.), но принцип одинаковый:

import Echo from 'laravel-echo';
import Pusher from 'pusher-js';

window.Pusher = Pusher;

window.Echo = new Echo({
  broadcaster: 'reverb',
  key: 'YOUR_REVERB_TOKEN',
  wsHost: window.location.hostname,
  wsPort: 8080,
  wssPort: 8080,
  forceTLS: false,
  enabledTransports: ['ws', 'wss'],
  authEndpoint: 'http://YOUR-SITE-DOMAIN.local/api/broadcasting/auth',
  auth: {
    headers: {
      Authorization: `Bearer ${localStorage.token}`
    }
  }
});

Что здесь особенного:

  • key - Ключ вашего приложения, который сгенерировался в .env-файле.
  • authEndpoint - Эндпоинт, который мы сформировали в сервис провайдере выше. Конструкция Broadcast::routes() создаем эндпоинт вида broadcasting/auth, н омы к нему еще приделали префикс api, поэтому окончательный вариант тут будет таким - api/broadcasting/auth.
  • auth - Объект с доп. данными для авторизации. Нам тут важно то, что отсюда будет браться Bearer токен нашего приложения и прокидываться для авторизации.

Подключение к каналу

Используем нижеприведенный код там, где у нас будет обновление по сокетам:

window.Echo.private('orders.1')
  .listen('OrderChanged', (data) => {
    message.value = data.message
  })
})

На что обратить внимание:

  • .private() - Мы прослушиваем Закрытый канал, в котором происходит проверка (Имеем ли мы доступ к заказу)
  • orders.1 - Здесь вместо цифры подставляем переменную, в которой будет номер заказа
  • .listen() - Метод, который слушает канал, но с проверкой по Namespace (Если мы в файле события не предусмотрели broadcastAs метод). Полное имя события будет такое App\\Events\\OrderChanged. Если у нас вложенные в папки события, то соответственно событие нужно указывать без App\\Events, например, Orders\\OrderChanged.

Важно. Если мы указали broadcastsAs() в файле OrderChanged и прописали там, скажем, событие order.changed, то уже тут в методе .listen() добавляем вначале точку, чтобы не учитывались Namespace, вот так - ,listen('.order.changed').

На этом базовая реализация сокетов с приватными каналами рассмотрена!

Весь основной процесс будет происходить так:

  1. Загружается страница, инциализируются Echo и Pusher
  2. Echo отправляет запрос на авторизацию используя Bearer токен sanctum
  3. Устанавливается соединение сокетов на основе проверки в файле routes/channels.php, где, также, проходит авторизация
  4. ...Происходят какие то действия в приложении...
  5. Срабатывает событие OrderChanged через dispatch(Order $order).
  6. В файле OrderChanged данные из метода broadcastWith() отправляются по сокетам на канал с указанием события.
  7. Echo слушает канал, получает данные и передает их приложению для дальнейших действий.

Публичный канал

Для публичного канала, используем в файле события в методе broadcastOn() вместо PrivateChannel() - просто Channel() вот так:

public function broadcastOn()
{
    return [
        new Channel('public-channel')
    ];
}

На фронте, в работе Echo используем вместо private(), просто - channel(). Вот так:

window.Echo.channel('public-channel')
  .listen('.public-event', (data) => {
    message.value = data.message
  })
})

Для публичного канала, вроде бы, даже не надо его прописывать в routes/channels.php.

На этом все! Запускаем сервер, если этого еще не делали - php artisan reverb:serve и можно работать!

Заметки.

  1. Если что-то не работает - внимательно смотрим конфиги! Я уже ранее упоминал, но тем не менее, если вы обновлялись с Laravel 10, то там должно быть BROADCAST_CONNECTION=reverb, а не BROADCAST_DRIVER=reverb, как это было в ранних версиях Laravel.
  2. Авторизация Echo с Sanctum происходит автоматически, никаких кастомных контроллеров не нужно, как и маршрутов для авторизации!
  3. Используем ShouldBroadcastNow вместо ShouldBroadcast в файле события если у вас, на текущий момент, не настроены очереди.
  4. Имена каналов по умолчанию с фронта формируются по Namespace события на бэке, причем от корня - App\\Events. Если у нас событие лежит в подпапке - App\\Events\\Orders\\Subfolder\\OrderEvent.php, то используем в Echo часть после App\\Events - .listen('Orders\\Subfolder\\OrderEvent'). К нему автоматически приклеится App\\Events.
  5. Если не хотите такие длинные события, используйте broadcastAs() метод в файле события, пусть этот метод вернет строку с наименованием события. С фронта не забудьте добавть точку перед названием в методе .listen() - .listen('.custom.event.name') , а не.listen('custom.event.name').
  6. Если вам не нравится метод .listen() со своей магией, можете использовать метод .on(), он ничего не приклеивает!