Асинхронный WEB в 2018. Пишем чат на Websocket используя Swoole +33




Тема 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.




К сожалению, не доступен сервер mySQL