- PVSM.RU - https://www.pvsm.ru -

Тема Websocket`ов [1] уже не раз затрагивалась на Хабре, в частности рассматривались варианты реализации на PHP. Однако, с момента выхода последней статьи [2] с обзором разных технологий прошло уже более года, а миру PHP есть чем [3] похвастаться за прошедшее время.
В данной статье я хочу представить русскоязычному сообществу Swoole [4] — Асинхронный Open Source фреймворк для PHP, написанный на Си, и поставляемый в виде pecl-расширения.
Посмотреть получившееся в итоге приложение(чат) можно: здесь [5].
Исходники на github [6].
Наверняка найдутся люди, которые будут в принципе против использования PHP для таких целей, однако в пользу PHP часто могут играть:
Тем не менее, даже сравнивая с node.js/go/erlang и другими языками, нативно предлагающими асинхронную модель, Swoole — фреймворк написанный на Си и объеденивший в себе низкий порог вхождения и мощную функциональность может быть вполне хорошим кандидатом.
Возможности фреймворка:
Возможные варианты использования:
Примеры кода можно увидеть на главной странице сайта [9]. В разделе документации более подробная информация о всём функционале фреймворка.
Ниже я опишу процесс написания несложного Websocket сервера для онлайн-чата и возможные при этом затруднения.
Перед тем как начать: Более подробная информация о классах swoole_websocket_server [10] и swoole_server [11] (Второй класс наследуется от первого).
Исходники самого чата. [6]
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 [12]
Минимальный шаблон 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:
SwooleWebSocketFrame Object
(
[fd] => 20
[data] => {"type":"login","username":"new user"}
[opcode] => 1
[finish] => 1
)
Данные клиенту отправляются с помощью функции
Server::push($fd, $data, $opcode=null, $finish=null)
Подробнее про фреймы и opcodes на русском — на learn.javascript [13]. Раздел «формат данных»
Максимально подробно про протокол Websocket — RFC [14]
А как сохранять данные пришедшие на сервер?
Swoole представляет функционал для асинхронной работы с MySQL [15], Redis [16], файловый ввод-вывод [17]
А также swoole_buffer [18], swoole_channel [19] и swoole_table [20]
Думаю различия понять не сложно по документации. Для хранения имён пользователей я выбрал 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();
}
Возникшие проблемы:
Решение:
В обоих случая нужна реализация функции «пинг», которая будет постоянно каждые n секунд пинговать клиента в первом случае, и базу MySQL во втором.
Так как обе функции должны работать асинхронно, их нужно вызвать в дочерних процессах сервера.
Для этого их можно инициализировать при событии «workerStart». Мы уже определили его в конструкторе, и при этом событии уже вызывается метод $this->onWorkerStart:
Протокол Websocket поддерживает ping-pong [21] из коробки. Ниже можно увидеть реализацию на Swoole.
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 секунд, используя swooleTimer:
/**
* 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 AppHelpers;
use SwooleChannel;
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 [22]
<?php
namespace AppHelpers;
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, но когда будет больше времени я постараюсь сделать его поприятнее.
API documentation [28] — описание некоторых классов и функций фреймворка в довольно удобном виде.
Мне кажется, что Swoole очень активно развивался последний год, вышел из стадии когда его можно было назвать «сырым», и теперь вполне составляет конкуренцию использованию node.js/go с точки зрения асинхронного программирования и реализации сетевых протоколов.
Буду рад услышать различные мнения по теме и отзывы от тех кто уже имеет опыт использования Swoole
Пообщаться в описанном чатике можно по ссылке [5]
Исходники доступны на Github [6].
Автор: EvgeniiR
Источник [29]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/php-2/296882
Ссылки в тексте:
[1] Websocket`ов: https://ru.wikipedia.org/wiki/WebSocket
[2] последней статьи: https://habr.com/post/331462/
[3] есть чем: https://tsh.io/blog/swoole-is-it-node-in-php-or-am-i-wrong/?utm_source=twitter&utm_medium=social&utm_campaign=php&utm_content=fanpage
[4] Swoole: https://www.swoole.co.uk
[5] здесь: http://62.109.21.96/
[6] Исходники на github: https://github.com/EvgeniiR/ws-chat
[7] Open source: https://github.com/swoole/swoole-src
[8] Поддержка сопрограмм(Coroutines): https://www.swoole.co.uk/coroutine
[9] главной странице сайта: https://www.swoole.co.uk/
[10] swoole_websocket_server: https://www.swoole.co.uk/docs/modules/swoole-websocket-server
[11] swoole_server: https://www.swoole.co.uk/docs/modules/swoole-server-doc
[12] ide-helper: https://github.com/wudi/swoole-ide-helper
[13] learn.javascript: https://learn.javascript.ru/websockets#%D1%84%D0%BE%D1%80%D0%BC%D0%B0%D1%82-%D0%B4%D0%B0%D0%BD%D0%BD%D1%8B%D1%85
[14] RFC: https://tools.ietf.org/html/rfc6455
[15] MySQL: https://www.swoole.co.uk/docs/modules/swoole-async-mysql-client
[16] Redis: https://www.swoole.co.uk/docs/modules/swoole-async-redis-client
[17] файловый ввод-вывод: https://www.swoole.co.uk/docs/modules/swoole-async-io
[18] swoole_buffer: https://www.swoole.co.uk/docs/modules/swoole-buffer
[19] swoole_channel: https://www.swoole.co.uk/docs/modules/swoole-channel
[20] swoole_table: https://www.swoole.co.uk/docs/modules/swoole-table
[21] ping-pong: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers#Pings_and_Pongs_The_Heartbeat_of_WebSockets
[22] ezyang/htmlpurifier: https://packagist.org/packages/ezyang/htmlpurifier
[23] Twitter: https://twitter.com/php_swoole
[24] Issue tracker(Github): https://github.com/swoole/swoole-src/issues
[25] Закрытые issues: https://github.com/swoole/swoole-src/issues?q=is%3Aissue+is%3Aclosed
[26] Тесты, написанные разработчиками: https://github.com/swoole/swoole-src/tree/master/tests
[27] Китайская wiki фреймворка: https://wiki.swoole.com/wiki/page/p-async.html
[28] API documentation: https://rawgit.com/tchiotludo/swoole-ide-helper/english/docs/index.html
[29] Источник: https://habr.com/post/427589/?utm_campaign=427589
Нажмите здесь для печати.