📜 ⬆️ ⬇️

Asynchronous WEB in 2018. We write chat on Websocket using Swoole



The theme of Websocket `s has been touched on more than once in Habré, in particular, options for implementing in PHP were considered. However, more than a year has passed since the last article with a review of various technologies, and the PHP world has something to boast of over time.

In this article I want to introduce Swoole to the Russian-speaking community - Asynchronous Open Source framework for PHP, written in C, and delivered as a pecl extension.
')
View the resulting application (chat), you can: here .
Sources on github .

Why Swoole?


Surely there will be people who, in principle, will be against using PHP for such purposes, but in favor of PHP they can often play:


Nevertheless, even comparing with node.js / go / erlang and other languages ​​natively offering the asynchronous model, Swoole - a framework written in C and combining the low threshold of entry and powerful functionality can be quite a good candidate.

Framework features:


Possible uses:


Code samples can be seen on the main page of the site . In the documentation section for more information about the entire functionality of the framework.

Getting Started


Below I will describe the process of writing a simple Websocket server for online chat and the possible difficulties.

Before you begin: More information about the classes swoole_websocket_server and swoole_server (The second class is inherited from the first).
Sources of the chat.

Installing the framework
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


To use autocomplete in IDE it is suggested to use ide-helper

Minimal Websocket Server Template:

 <?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 is the connection identifier.
Get current connections:

 $server->connections; 

Inside the $ frame contains all the data sent. Here is an example of the incoming object in the onMessage function:

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

Data is sent to the client using the function

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

Learn more about frames and opcodes in Russian - learn.javascript . Section "data format"

The most detailed about the protocol Websocket - RFC

How to save data that came to the server?
Swoole provides functionality for asynchronous work with MySQL , Redis , file I / O.

And also swoole_buffer , swoole_channel and swoole_table
I think the differences are not difficult to understand the documentation. For storing usernames, I chose swoole_table. The messages themselves are stored in MySQL.

So, initialization of the table of user names:

 $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(); 

Filling data is as follows:

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

To work with MySQL, I decided not to use the asynchronous model, but to use the standard way, from the web socket server, via PDO

Appeal to the base
 /** * @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 server, it was decided to issue in the form of a class, and start it in the constructor:

Constructor
 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(); } 


Problems encountered:

  1. The user connected to the chat breaks the connection after 60 seconds if there is no packet exchange (that is, the user did not send or receive anything)
  2. The web server loses connection with MySQL if no interaction takes place for a long time.

Decision:

In both cases, the implementation of the ping function is needed, which will constantly ping the client in the first case every n seconds, and the MySQL database in the second.

Since both functions must work asynchronously, they must be called in the child processes of the server.

To do this, they can be initialized at the workerStart event. We have already defined it in the constructor, and this event already calls the $ this-> onWorkerStart method:
Websocket protocol supports ping-pong out of the box. Below you can see the implementation on 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); } }); } 


Next, I implemented a simple function for pinging a MySQL server every N seconds using swoole \ Timer:

Databasehelper
The timer itself starts in initPdo if not already enabled:

  /** * 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(); } } 


The main part of the work was to write logic for adding, saving, sending messages (no more difficult than the usual CRUD), and then a huge scope for improvements.

So far, I've brought my code to a more or less readable form and object-oriented style, implemented a bit of functionality:

- Login by name;

- Verify that the name is not taken
 /** * @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; } 


- Request spammer for spam protection
 <?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); } } 

PS: Yes, the check is on connection id. Perhaps it makes sense to replace it in this case, for example, with the user's IP address.

I'm also not sure that swoole_channel was best suited in this situation. I think later to reconsider this moment.

- Simple XSS protection using ezyang / htmlpurifier

- simple spam filter
With the ability to further add additional checks.

 <?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 at the chat is still very raw, because I am more attracted to the backend, but when there is more time I will try to make it more pleasant.

Where to get information, get news about the framework?



API documentation - the description of some classes and functions of the framework in a rather convenient form.

Summary


It seems to me that Swoole was very actively developing over the past year, out of the stage when it could be called “raw”, and now it is in competition with the use of node.js / go from the point of view of asynchronous programming and implementation of network protocols.

I will be glad to hear different opinions on the topic and feedback from those who already have experience using Swoole

You can chat in the described chatik by reference.
Sources are available on Github .

Source: https://habr.com/ru/post/427589/


All Articles