Тема Websocket`ов уже не раз затрагивалась на Хабре, в частности рассматривались варианты реализации на PHP. Однако, с момента выхода последней статьи с обзором разных технологий прошло уже более года, а миру PHP есть чем похвастаться за прошедшее время.

В данной статье я хочу представить русскоязычному сообществу Swoole — Асинхронный Open Source фреймворк для PHP, написанный на Си, и поставляемый в виде pecl-расширения.

Посмотреть получившееся в итоге приложение(чат) можно: здесь.
Исходники на github.

Почему Swoole?


Наверняка найдутся люди, которые будут в принципе против использования PHP для таких целей, однако в пользу PHP часто могут играть:

  • Нежелание разводить зоопарк различных языков на проекте
  • Возможность использования уже наработанной кодовой базы(если проект на PHP).

Тем не менее, даже сравнивая с node.js/go/erlang и другими языками, нативно предлагающими асинхронную модель, Swoole — фреймворк написанный на Си и объеденивший в себе низкий порог вхождения и мощную функциональность может быть вполне хорошим кандидатом.

Возможности фреймворка:

  • Событийная, асинхронная модель программирования
  • Асинхронные TCP / UDP / HTTP / Websocket / HTTP2 клиентские/серверные API
  • Поддержка IPv4 / IPv6 / Unixsocket / TCP/ UDP и SSL / TLS
  • Быстрая сериализация / десериализация данных
  • Высокая производительность, расширяемость, поддержка до 1 миллиона одновременных соединений
  • Планировщик заданий с точностью до миллисекунд
  • Open source
  • Поддержка сопрограмм(Coroutines)

Возможные варианты использования:

  • Микросервисы
  • Игровые сервера
  • Интернет вещей
  • Живые системы общения
  • WEB API
  • Любые другие сервисы от которых требуется моментальный ответ/высокая скорость/асинхронное выполнение

Примеры кода можно увидеть на главной странице сайта. В разделе документации более подробная информация о всём функционале фреймворка.

Приступим к использованию


Ниже я опишу процесс написания несложного Websocket сервера для онлайн-чата и возможные при этом затруднения.

Перед тем как начать: Более подробная информация о классах swoole_websocket_server и swoole_server (Второй класс наследуется от первого).
Исходники самого чата.

Установка фреймворка
Linux users

#!/bin/bash
pecl install swoole

Mac users

# get a list of avaiable packages
brew install swoole
#!/bin/bash
brew install homebrew/php/php71-swoole


Для использования автокомплита в IDE предлагается использовать ide-helper

Минимальный шаблон Websocket-сервера:

<?php
$server = new swoole_websocket_server("127.0.0.1", 9502);

$server->on('open', function($server, $req) {
    echo "connection open: {$req->fd}\n";
});

$server->on('message', function($server, $frame) {
    echo "received message: {$frame->data}\n";
    $server->push($frame->fd, json_encode(["hello", "world"]));
});

$server->on('close', function($server, $fd) {
    echo "connection close: {$fd}\n";
});

$server->start();

$fd — идентификатор подключения.
Получить текущие подключения:

$server->connections;

Внутри $frame содержаться все отправленные данные. Вот пример пришедшего объекта в функцию onMessage:

Swoole\WebSocket\Frame Object
(   
    [fd] => 20
    [data] => {"type":"login","username":"new user"}
    [opcode] => 1
    [finish] => 1
)

Данные клиенту отправляются с помощью функции

Server::push($fd, $data, $opcode=null, $finish=null)

Подробнее про фреймы и opcodes на русском — на learn.javascript. Раздел «формат данных»

Максимально подробно про протокол Websocket — RFC

А как сохранять данные пришедшие на сервер?
Swoole представляет функционал для асинхронной работы с MySQL, Redis, файловый ввод-вывод

А также swoole_buffer, swoole_channel и swoole_table
Думаю различия понять не сложно по документации. Для хранения имён пользователей я выбрал swoole_table. Сами сообщения хранятся в MySQL.

Итак, инициализация таблицы имён пользователей:

        
$users_table = new swoole_table(131072);
$users_table->column('id', swoole_table::TYPE_INT, 5);
$users_table->column('username', swoole_table::TYPE_STRING, 64);
$users_table->create();

Заполнение данными происходит так:

$count = count($messages_table);

$dateTime = time();
$row = ['username' => $username, 'message' => $data->message, 'date_time' => $dateTime];
$messages_table->set($count, $row);

Для работы с MySQL я решил пока не использовать асинхронную модель, а обращаться стандартным способом, из вебсокет-сервера, через PDO

Обращение к базе
/**
     * @return Message[]
     */
    public function getAll()
    {
        $stmt = $this->pdo->query('SELECT * from messages');
        $messages = [];
        foreach ($stmt->fetchAll() as $row) {
            $messages[] = new Message( $row['username'], $row['message'], new \DateTime($row['date_time']) );
        }
        return $messages;
    }


Websocket сервер было решено оформить в виде класса, и стартовать его в конструкторе:

Конструктор
public function __construct()
    {
        $this->ws = new swoole_websocket_server('0.0.0.0', 9502);

        $this->ws->on('open', function ($ws, $request) {
            $this->onConnection($request);
        });
        $this->ws->on('message', function ($ws, $frame) {
            $this->onMessage($frame);
        });
        $this->ws->on('close', function ($ws, $id) {
            $this->onClose($id);
        });

        $this->ws->on('workerStart', function (swoole_websocket_server $ws) {
            $this->onWorkerStart($ws);
        });

        $this->ws->start();
    }


Возникшие проблемы:

  1. У пользователя подключенного к чату обрывается соединение через 60 секунд если не происходит обмена пакетами(т.е. пользователь ничего не отправлял и ничего не получал)
  2. Вебсервер теряет соединение с MySQL если долго не происходит никакого взаимодействия

Решение:

В обоих случая нужна реализация функции «пинг», которая будет постоянно каждые n секунд пинговать клиента в первом случае, и базу MySQL во втором.

Так как обе функции должны работать асинхронно, их нужно вызвать в дочерних процессах сервера.

Для этого их можно инициализировать при событии «workerStart». Мы уже определили его в конструкторе, и при этом событии уже вызывается метод $this->onWorkerStart:
Протокол Websocket поддерживает ping-pong из коробки. Ниже можно увидеть реализацию на Swoole.

onWorkerStart
private function onWorkerStart(swoole_websocket_server $ws)
    {
        $this->messagesRepository = new MessagesRepository();

        $ws->tick(self::PING_DELAY_MS, function () use ($ws) {
            foreach ($ws->connections as $id) {
                $ws->push($id, 'ping', WEBSOCKET_OPCODE_PING);
            }
        });
    }


Далее я реализовал простенькую функцию для пинга MySQL сервера каждые N секунд, используя swoole\Timer:

DatabaseHelper
Сам таймер запускается в initPdo если ещё не включен:

    /**
     * Init new Connection, and ping DB timer function
     */
    private static function initPdo()
    {
        if (self::$timerId === null || (!Timer::exists(self::$timerId))) {
            self::$timerId = Timer::tick(self::MySQL_PING_INTERVAL, function () {
                self::ping();
            });
        }

        self::$pdo = new PDO(self::DSN, DBConfig::USER, DBConfig::PASSWORD, self::OPT);
    }

    /**
     * Ping database to maintain the connection
     */
    private static function ping()
    {
        try {
            self::$pdo->query('SELECT 1');
        } catch (PDOException $e) {
            self::initPdo();
        }
    }


Основная часть работы заключалась в написании логики для добавления, сохранения, отправки сообщений(не сложнее обычного CRUD), а далее огромный простор для усовершенствований.

Пока что я привёл свой код к более-менее читаемому виду и объектно-ориентированному стилю, реализовал немного функционала:

— Вход по имени;

- Проверку что имя не занято
/**
     * @param string $username
     * @return bool
     */
    private function isUsernameCurrentlyTaken(string $username) {
        foreach ($this->usersRepository->getByIds($this->ws->connection_list()) as $user) {
            if ($user->getUsername() == $username) {
                return true;
            }
        }
        return false;
    }


- Ограничитель запросов для защиты от спама
<?php

namespace App\Helpers;

use Swoole\Channel;

class RequestLimiter
{
    /**
     * @var Channel
     */
    private $userIds;

    const MAX_RECORDS_COUNT = 10;

    const MAX_REQUESTS_BY_USER = 4;

    public function __construct() {
        $this->userIds = new Channel(1024 * 64);
    }

    /**
     * Check if there are too many requests from user
     *  and make a record of request from that user
     *
     * @param int $userId
     * @return bool
     */
    public function checkIsRequestAllowed(int $userId) {
        $requestsCount = $this->getRequestsCountByUser($userId);
        $this->addRecord($userId);
        if ($requestsCount >= self::MAX_REQUESTS_BY_USER) return false;
        return true;
    }

    /**
     * @param int $userId
     * @return int
     */
    private function getRequestsCountByUser(int $userId) {
        $channelRecordsCount = $this->userIds->stats()['queue_num'];
        $requestsCount = 0;

        for ($i = 0; $i < $channelRecordsCount; $i++) {
            $userIdFromChannel = $this->userIds->pop();
            $this->userIds->push($userIdFromChannel);
            if ($userIdFromChannel === $userId) {
                $requestsCount++;
            }
        }

        return $requestsCount;
    }

    /**
     * @param int $userId
     */
    private function addRecord(int $userId) {
        $recordsCount = $this->userIds->stats()['queue_num'];

        if ($recordsCount >= self::MAX_RECORDS_COUNT) {
            $this->userIds->pop();
        }

        $this->userIds->push($userId);
    }
}

P.S.: Да, проверка идёт по connection id. Возможно имеет смысл заменить его в данном случае, например, на IP адрес пользователя.

Ещё я не уверен что в данной ситуации лучше всего подходил именно swoole_channel. Думаю позже пересмотреть этот момент.

— Простенькую защиту от XSS используя ezyang/htmlpurifier

- Простенький спам-фильтр
С возможностью в дальнейшем добавить дополнительные проверки.

<?php

namespace App\Helpers;

class SpamFilter
{
    /**
     * @var string[] errors
     */
    private $errors = [];

    /**
     * @param string $text
     * @return bool
     */
    public function checkIsMessageTextCorrect(string $text) {
        $isCorrect = true;
        if (empty(trim($text))) {
            $this->errors[] = 'Empty message text';
            $isCorrect = false;
        }
        return $isCorrect;
    }

    /**
     * @return string[] errors
     */
    public function getErrors(): array {
        return $this->errors;
    }
}


Frontend у чата пока что весьма сырой, т.к. меня больше привлекает backend, но когда будет больше времени я постараюсь сделать его поприятнее.

Где брать информацию, узнавать новости о фреймворке?


  • Английский официальный сайт — полезные ссылки, актуальная документация, немного комментариев от пользователей
  • Twitter — актуальные новости, полезные ссылки, интересные статьи
  • Issue tracker(Github) — баги, вопросы, общение с создателями фреймворка. Отвечают очень шустро(на мою issue с вопросом ответили за пару часов, помогли с реализацией pingloop).
  • Закрытые issues — так же советую. Большая база вопросов от пользователей и ответы от создателей фремворка.
  • Тесты, написанные разработчиками — практически на каждый модуль из документации есть тесты написанные на PHP, показывающие варианты использования.
  • Китайская wiki фреймворка — вся информация что и в английской, но значительно больше комментариев от пользователей (гугл переводчик в помощь).

API documentation — описание некоторых классов и функций фреймворка в довольно удобном виде.

Резюме


Мне кажется, что Swoole очень активно развивался последний год, вышел из стадии когда его можно было назвать «сырым», и теперь вполне составляет конкуренцию использованию node.js/go с точки зрения асинхронного программирования и реализации сетевых протоколов.

Буду рад услышать различные мнения по теме и отзывы от тех кто уже имеет опыт использования Swoole

Пообщаться в описанном чатике можно по ссылке
Исходники доступны на Github.

Комментарии (20)


  1. terantul
    24.10.2018 17:12

    Больше трёх сообщений чатик Ваш не даёт отправить. НО! Перезагрузив страницу по новой логинимся и опять отправляем. Привязку к сессии сделать было бы неплохо.


    1. EvgeniiR Автор
      24.10.2018 17:12

      Да, я знаю об этом. Пока что проверяю просто по ID подключения.
      В принципе если кому-то очень хочется нафлудить, то ему и сессия не помешает, думаю чуть позже сделаю спам фильтр построже


  1. Gemorroj
    24.10.2018 17:23

    С PDO есть проблема в том, что он синхронный. Т.е. пока запрос не выполнится все клиенты swoole ждут его завершения.
    В swoole 4.1 добавили какую-то штуку по заворачиванию PDO и проч в асинхронную обертку (см https://pecl.php.net/package-changelog.php?package=swoole), но я не ковырял)


    1. EvgeniiR Автор
      24.10.2018 17:28

      Хм… Я думал что суть их асинхронного подключения БД в возможности продолжить выполнять код в текущем воркере параллельно с запросом, а другие клиенты собственно в другом процессе выполняют свои запросы. Займусь этим вопросом.


  1. fukkit
    24.10.2018 19:56
    -3

    Асинхронные серверы на подходящих для того языках типа ноды или го — это для слабаков!
    Только пхп, только гланды через назад!


    1. greatkir
      24.10.2018 20:13
      +1

      Осмелюсь спросить — что для вас «подходящий язык»?


      1. Gemorroj
        24.10.2018 21:23
        +2

        подозреваю, что человек живет в мире ~php5


    1. EvgeniiR Автор
      24.10.2018 22:12

      Ну в статье описывается инструмент, прямо предназначенный для написания асинхронных серверов, и инструмент, кстати, не на PHP. Так что не вижу никаких проблем.


  1. MaxBog
    24.10.2018 21:48

    Я пробовал Swoole год назад. Из того что помню:
    Пока делал специфичный чат, вышла новая версия, вроде 2.2. Но новая версия не смогла собраться со всеми нужными мне пакетами на убунту 16.04 (вебсокеты не собирались). Не работали корутины.

    Делал просто сервер, данные хранил в swoole_table.

    Еще была ошибка, что не мог пушануть ответ, из-за каких то вложеностей вызовов функций. Гуглил, такое бывает, решение которое предлагали китайцы(основные разработчики) прислать им дебаг инфу на Си.
    Пришлось переписать проще, чтобы логика была в функции onMessage сразу.

    Создалось впечатление, что сырой продукт еще. И пожалел, что не стал использовать ReactPHP. Более привычно для ПХПшника.
    В общем работает, соединения не рвет, как у вас. Но у меня другая версия была.
    Но может просто я не понял swoole :)


    1. EvgeniiR Автор
      24.10.2018 22:02

      Уже v4.1.0: )
      В в новой версии я таких багов не встречал, надеюсь что это не только моё везение.
      Конечно, не всё идеально
      Но серьёзных косяков вроде нет, а если что авторы всегда очень шустро отвечают, и стараются фиксить найденные баги.


  1. Lachezis
    25.10.2018 07:35

    Можно подойти к проблеме с другой стороны и заниматься многопоточностью над ПХП: https://github.com/spiral/roadrunner


  1. tungus28
    25.10.2018 10:02

    Нагрузочное тестирование не проводили?


    1. EvgeniiR Автор
      25.10.2018 10:04

      Лично я нет, но в интернете и в т.ч. у них на сайте(или на гитхабе, не помню точно), выкладывались результаты тестирования и используемый код.
      Вот нашёл например: www.swoole.co.uk/benchmark, правда тестировалась старая версия


  1. L-N
    25.10.2018 10:10

    По прежнему считаю PHP самым удачным и лучшим интерпретатором. Каноничный C-подобный синтаксис (без всяких вот этих ваших «удобочитаемых» ЯП), а так же максимально приближенное к реальности ООП.


    1. EvgeniiR Автор
      25.10.2018 10:14

      Не забывайте что язык — всего лишь инструмент для достижения поставленных целей, и может меняться в зависимости от задач: )


      1. L-N
        26.10.2018 11:24

        разумеется, но в данном контексте, я имел ввиду вэб (именно серверная часть)


  1. morozovsk
    25.10.2018 12:25
    +1

    В вашей картинке есть сравнение swoole с чем угодно кроме главного конкурента workerman.
    Во время участия в highloadcup, я сделал такое сравнение и в синтетическом тесте swoole оказался быстрее чем workerman менее чем на 10%, а в реальном разница была пару процентов.
    При этом swoole_table жрал памяти в 20 раз больше чем лучшее решение.
    В общем для конкурса я оставил swoole, потому что там была важна каждая микросекунда. В реальности же я использую workerman, потому что:

    • у него лучше документация (к тому же всегда можно прочесть код в php-исходниках, чтобы разобраться),
    • доработка под себя (средний php-разработчик может дописать php-библиотеку, а сишную — нет)
    • более быстрое развитие (любой php-разработчик может отправить пулл-реквест в воркерман, а в случае swoole приходится ждать, когда его разработчики напишут/не напишут то что тебе требуется)

    были и другие мелочи, которые приходилось терпеть, то что можно было бы исправить в workerman за 5 минут.


    1. EvgeniiR Автор
      25.10.2018 13:12

      Спасибо за развернутый комментарий. Статью вашу я конечно же читал ещё когда искал информацию про Swoole, и сейчас только понял что та issue на Github «Why is swoole table so expensive» была от вас).

      В принципе данные, если они должны лежать в памяти можно писать в Redis. А на постоянное хранение — в MySQL.

      А можете рассказать, каких мелочей не хватало в Swoole?

      Думаю займусь и изучением WorkerMan ради интереса.


      1. morozovsk
        25.10.2018 13:57

        На вскидку, могу вспомнить нотисы, которые вылезали в консоли, если вебсокет закрывается не кодом на js, а просто закрытием вкладки (т.е. ситуация достаточно стандартная) и подобные мелочи.
        Было два режима SWOOLE_BASE и SWOOLE_TASK (дефолтный), внятного описания не было даже на китайском.
        Были какие-то проблемы с воркерами. То что мне нужно было в альфе и я ловил ошибки.

        В любом случае, большое спасибо за статью. Это первая статья про swoole на хабре. Я сам о нём узнал буквально полтора-два года назад, хотя вроде как в теме «вебсокеты на пхп» я не чужой. Swoole / workerman достойно справляются со своими задачами и будут с ними справляться до выхода php8, где всё это обещают уже из коробки.


        1. EvgeniiR Автор
          25.10.2018 14:41
          +1

          Да, про документацию мне конечно известно. Проблем с нотисами при отключении клиента никаких не возникало.
          А мотивация для написания данной статьи мне пришла как раз после вашего цикла статей о Вебсокетах )