
Последнее время работаю с Lumen. Lumen — это урезанная версия Laravel, аля symfony-skeleton для Symfony. По этой причине большинство решений, описанных для Laravel-систем, к Lumen’у прикрутить бывает сложно, а что-то и совсем невозможно, без переопределения vendor-пакетов.
Сегодня расскажу, как и с помощью чьей матери мне удалось завести web-сокеты в Lumen и успешно интегрировать их с Vue-клиентом.
Начну с того, что PHP — это не лучший язык для реализации websocket-ов. Паттерн “запрос-процесс-смерть” в принципе не подразумевает долгоживущих запросов, а если такие появляются — то надо быть на 100% уверенным, что процесс точно убирает за собой мусор и что он грамотно менеджерится. Есть реализации websocket-систем для PHP на основе серверных сокетов, например Ratchet — на канале уже была статья по интеграции Ratchet в Symfony-приложение. По определённым причинам этот вариант мне не подходил, поэтому пришлось выбрать другой путь — реализацию websocket-системы через посредника.
Это часто используемая практика, которая по количеству внедрений в прод не уступает первому решению, к тому же её рекомендует официальная документация лары. Смысл в том, что PHP-back общается по какому-либо каналу связи с каким-либо сервисом, который умеет в websocket’ы, а сервис уже общается с клиентом. Чтобы PHP мог общаться с сервисом — ему нужен долгоживущий процесс, например консольная команда, которая постоянно опрашивает канал связи на предмет новых сообщений. Канал связи — например, Redis. Мы теряем в надёжности, но прибавляем в скорости получения, на что я, в рамках текущей задачи, согласен. Посредник, умеющий в вебсокеты — например, максимально урезанный инстанс nodejs, который умеет только слушать websocket-соединение и кидать данные в Redis. По-факту, получается своего рода микросервисная система с общей магистралью.
Весь смысл данной статьи — изложение всех подводных камней и решения ошибок, которые возникли у меня при запуске подобной системы для Lumen (для Laravel, я уверен, особых проблем не возникло бы). Как бонус — все модули докеризируем и в конце я выложу ссылку на весь исходный код.
Итак, начнём. Клонируй проект, открывай папку docker, где я разместил образы, необходимые для backend-части. Нам нужен php-fpm и php-cli для работы с PHP, nginx для запуска web-сервера, redis для обмена с node и сам node.js для сервера вебсокетов. Находясь в директории /docker билдим и запускаем контейнеры:
docker-compose up -d --build
Проверим, что всё корректно запустилось:
docker-compose ps

lumen я установил локально, забросив все файлы в ту же директорию, где лежит директория docker:
# этот листинг не нужно выполнять, он указан для примера
cd ..composer create-project --prefer-dist laravel/lumen app
mv app/* lumen-websockets/
Тебе достаточно просто поднять docker-контейнеры в склонированном репозитории, локально ничего ставить не нужно. Теперь подтянем зависимости — заходим в контейнер lws-php-cli
и подключаемся к терминалу:
docker exec -it lws-php-cli bash

Вводим:
composer install
После установки проверяем, что 127.0.0.1:8080 корректно открывает начальную страницу:

Переходим к интеграции websocket-системы.
Первое, что нужно понимать — обмен с node-модулем у нас будет происходить через Redis. Все сообщения в Redis, чтобы сохранить их последовательность, проходят через систему Laravel-очередей.
Первым делом обновляем настройки приложения, устанавливая драйвером очередей и системы вещания redis:
.env:
BROADCAST_DRIVER=redis
QUEUE_CONNECTION=redis
QUEUE_DRIVER=redis
Все сообщения, пересылаемые через Redis в node — в терминах Laravel (Lumen) обозначаются как события (event). Событие описывается классом, реализующим интерфейс ShouldBroadcast
, в частности определяющим метод broadcastOn
:
app/Events/NotificationEvent.php:
<?php
namespace App\Events;
use Illuminate\Broadcasting\{
Channel,
InteractsWithSockets
};
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Queue\SerializesModels;
class NotificationEvent implements ShouldBroadcast
{
use InteractsWithSockets, SerializesModels;
/**
* @var array
*/
public $notification;
/**
* @param array $notification
*/
public function __construct(array $notification)
{
$this->notification = $notification;
}
/**
* Метод отвечает за возвращение каналов, на которые вещается событие
*
* @return Channel|array
*/
public function broadcastOn()
{
return new Channel('notifications');
}
}
Метод broadcastOn
возвращает канал(ы), на которые уходит событие. Каналы бывают публичные и приватные. Приватные каналы позволяют проверить авторизацию пользователя и транслировать событие на клиент, где он авторизован. Публичные — вещают без этой проверки. В данном примере я использовал публичные каналы.
Lumen — урезанная версия Laravel, так что дальше нам нужно добавить различные конфиги, для активации тех или иных возможностей.
Создаём директорию config/
в корне проекта и добавляем в неё файл queue.php
:
<?php
return [
'default' => env('QUEUE_DRIVER', 'redis'),
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => 'default',
'retry_after' => 90,
],
],
];
Этот конфиг определяет настройки для системы очередей, если она работает через Redis.
Добавляем файл database.php
в config/
:
<?php
return [
'default' => env('DB_CONNECTION', 'mysql'),
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => env('DB_PREFIX', ''),
],
'mysql' => [
'driver' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', 3306),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => env('DB_PREFIX', ''),
'strict' => env('DB_STRICT_MODE', true),
'engine' => env('DB_ENGINE', null),
'timezone' => env('DB_TIMEZONE', '+00:00'),
],
'pgsql' => [
'driver' => 'pgsql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', 5432),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => env('DB_PREFIX', ''),
'schema' => env('DB_SCHEMA', 'public'),
'sslmode' => env('DB_SSL_MODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', 1433),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => env('DB_PREFIX', ''),
],
],
'migrations' => 'migrations',
'redis' => [
'cluster' => false,
'default' => [
'host' => env('REDIS_HOST', 'lws-redis'),
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', 6379),
'database' => env('REDIS_DATABASE', 0),
]
],
];
Этот конфиг определяют настройки для подключения к Redis и другим СУБД.
Добавляем файл broadcasting.php
в config/
:
<?php
return [
'default' => env('BROADCAST_DRIVER', 'redis'),
'connections' => [
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'encrypted' => true,
],
],
'redis' => [
'driver' => 'redis',
'connection' => 'default',
],
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];
тот файл определяет, как будет идти трансляция на node-модуль. Обычно, в большинстве туториалов, используют pusher — внешний сервис для обмена подобными уведомлениями. Но в моём случае, по определённым причинам, его использование было невозможно. Да и локально, как по мне, обмениваться лучше.
Теперь в bootstrap/app.php
добавляем путь до папки config/
:
$app->instance('path.config', app()->basePath() . DIRECTORY_SEPARATOR . 'config');
И ниже подключаем новые конфиги:
$app->configure('database');
$app->configure('queue');
$app->configure('redis');
$app->configure('broadcasting');
Теперь установим пакеты для работы с Redis:
sudo docker exec -it lws-php-cli bash
composer require predis/predis laravel/lumen-framework
И зарегистрируем в app.php ServiceProvider для Redis:
...
$app->register(\Illuminate\Redis\RedisServiceProvider::class);
...
В корне проекта ты можешь увидеть файл laravel-echo-server.json
— это файл, который использует websockets-контейнер для подключения к Redis, авторизации (в случае с приватными каналами) и прочие настройки, о которых подробней можно прочитать в документации. С этим файлом было нереально много проблем — сейчас, по-итогу, он настроен на запуск с определёнными контейнерами и на доступ с локальной машины. Если ты применяешь решение в проде — не забудь поменять название контейнеров и убедиться, что docker-compose logs выдаёт корректный запуск всех образов.
Давай проверим, что наши события успешно отправляются.
Для того, чтобы кинуть событие с бэк-части я создал команду app/Console/Commands/TestCommand.php
:
<?php
namespace App\Console\Commands;
use App\Events\NotificationEvent;
use Exception;
use Illuminate\Console\Command;
class TestCommand extends Command
{
/**
* @var string
*/
protected $signature = 'test:websockets';
/**
* @var string
*/
protected $description = 'Websockets testing';
public function __construct()
{
parent::__construct();
}
public function handle()
{
try {
event(new NotificationEvent(['test' => 'success']));
} catch (Exception $exception) {
echo $exception->getMessage() . PHP_EOL;
}
}
}
app/Console/Kernel.php
:
<?php
namespace App\Console;
use App\Console\Commands\TestCommand;
use Illuminate\Console\Scheduling\Schedule;
use Laravel\Lumen\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
/**
* The Artisan commands provided by your application.
*
* @var array
*/
protected $commands = [
TestCommand::class
];
/**
* Define the application's command schedule.
*
* @param Schedule $schedule
* @return void
*/
protected function schedule(Schedule $schedule)
{
//
}
}
Теперь осталось запустить очередь сообщений:
sudo docker exec -it lws-php-cli bash
php artisan queue:listen --timeout=0
И из другого терминала отправить уведомление:
sudo docker exec -it lws-php-cli bash
php artisan test:websockets
Теперь о том, что нужно сделать на стороне клиента, чтобы слушать websocket-соединение. Чтобы эксперимент был чистым, я с нуля установлю последний Vue и покажу, как получить отправленное уведомление.
vue create lumen-websockets-front
...
cd lumen-websockets-front/
yarn add laravel-echo socket.io-client
npm run serve
Теперь я дропаю все, что было в App.vue и заменяю на:
<template>
<div id="app">
Test Websockets
</div>
</template>
<script>
import Echo from "laravel-echo"
window.io = require('socket.io-client')
if (typeof io !== 'undefined') {
window.Echo = new Echo({
broadcaster: 'socket.io',
host: 'localhost:6001',
});
}
export default {
name: 'app',
mounted() {
window.Echo.channel('notifications')
.listen('NotificationEvent', function (data) {
/* eslint-disable no-console */
console.log(data)
console.log('data')
})
}
}
</script>
<style>
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>
Запускаем:
npm run serve

Оставляем страницу открытой, чтобы websocket-соединение оставалось открытым.
Переходим в консоль образа php-cli
, откуда через команду отправляем событие и выполняем команду ещё раз:
php artisan test:websockets
Теперь смотрим в консоль браузера:

Ну вот и всё, оно работает. Чуть попозже, если кому-то это будет интересно, напишу о том, как организовать проверку авторизации пользователя и посылать сообщение на приватный канал.
По-идее — ничего особо сложного, не считая нескольких дней настройки docker-образов, подгонки всех скриптов и понимания логики всей работы 🙂 Надеюсь, кому-то этот материал поможет и сэкономит время при разработке.
Исходный код (back)— https://bitbucket.org/junsenior/lumen-websockets/src/master/
Twitter — https://twitter.com/SeniorJun
Наш ламповый чатик — https://t.me/junsenior_chat
Поддержать проект — https://www.patreon.com/junsenior