Lumen + Docker + Websockets

Image for post

Последнее время работаю с 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
Image for post

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
Image for post

Вводим:

composer install

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

Image for post

Переходим к интеграции 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
Image for post

Оставляем страницу открытой, чтобы websocket-соединение оставалось открытым.

Переходим в консоль образа php-cli, откуда через команду отправляем событие и выполняем команду ещё раз:

php artisan test:websockets

Теперь смотрим в консоль браузера:

Image for post

Ну вот и всё, оно работает. Чуть попозже, если кому-то это будет интересно, напишу о том, как организовать проверку авторизации пользователя и посылать сообщение на приватный канал.

По-идее — ничего особо сложного, не считая нескольких дней настройки 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

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *