Writing online chat on Websockets using Swoole

Although the topic of asynchronous programming is not new, in particular, in the last year`s articles was mentioned a few implementations written on PHP (Ratchet, Workerman), i think the world of PHP has something to boast about over the past year

I want to introduce the community to Swoole — Asynchronous Open Source framework for PHP, written in C, and supplied as a pecl extension.

You can see resulting application (chat) here, and sources on Github

Why swoole?

Surely there are people who will be against the use of PHP for such purposes, but in favor of PHP can often play:

  • Reluctance to breed a zoo of different programming languages on the project
  • The ability to use the existing code base(if the project is in PHP).

However, even comparing with node.js/go/erlang and other languages that natively offer an asynchronous model, a Swoole framework written in C and combining a low entry threshold and powerful functionality can be a good candidate.

Сapabilities:

  • Event-driven, asynchronous programming for PHP
  • Async TCP / UDP / HTTP / Websocket / HTTP2 client/server side API
  • IPv4 / IPv6 / Unixsocket / TCP/ UDP and SSL / TLS support
  • Fast serializer / unserializer
  • High performance, scalable, support C1000K
  • Milliseconds task scheduler
  • Open source

Use cases:

  • Mobile API server
  • Internet of Things
  • Micro services
  • Web API or apps
  • Gaming servers
  • Live chat systems

Examples of code you can see on the Official site. In docs section you can find more detailed information about all the functionality of the framework.

Let’s get started!

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

Before starting: You can learn more about the Swoole\Websocket\Server and Swoole\Server classes (the Second class inherits from the first).
Sources of the chat.

Installation:

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 the auto-complete in the IDE it is proposed to use ide-helper

Websocket server basic 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 indentifier;

Frames($frame) contain all the data sent. Here is an example of the object that came to the on(‘message’) callback:

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

Data is sent to client by function

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

You can learn some more about WS protocol and client-side(JS) implementation here

How to save the data that came to the server?

Swoole provides functionality for asynchronous work with MySQL, Redis, file I/O.

Also based on share-memory storages: swoole_buffer, swoole_channel, swoole_table.

The differences are well described in the documentation. To store the user names I choose swoole_table, and the messages are stored in MySQL.

So, initialize a table of user names:

$this->users_table = new swoole_table(131072);
$this->users_table->column('username', swoole_table::TYPE_STRING, 100);
$this->users_table->create();

Filing in the data is as follows:

/**
* Save user to table in memory;
*
@param User $user
*/
public function save(User $user) {
$result = $this->users_table->set($user->getId(), ['username' => $user->getUsername()]);
if ($result === false) {
$this->reCreateUsersTable();
$this->users_table->set($user->getId(), ['username' => $user->getUsername()]);
}
}

To work with MySQL, I decided not to use the asynchronous model yet, and to access the database directly from the workers via PDO:

/**
*
@return Message[]
*/
public function getAll() {
$stmt = $this->pdo->query('SELECT * FROM messages ORDER BY date_time DESC LIMIT 100');
$messages = [];
foreach ($stmt->fetchAll() as $row) {
$messages[] = new Message($row['username'], $row['message'], new \DateTime($row['date_time']));
}
return $messages;
}

Websocket server i formalize as a class, and put initialization into __construct().

Handling сallbacks(Open connection, new message, closed connection, worker start) i decided to implement as class methods:

public function __construct() {
$this->requestsLimiter = new RequestLimiter();

$this->initRepositories();

$this->ws = new 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 (Server $ws) {
$this->onWorkerStart($ws);
});

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

Problems encountered:

  • The user connected to the websocket is terminated after 60 seconds if there is no exchange of packets (ie, the user did not send and did not receive anything).
  • The web server loses connection to MySQL if no interaction occurs for a long time

One solution in both cases- implement a “ping” function which will constantly ping the client every “n” seconds in the first case, and the MySQL server in the second.

Because both functions must run asynchronously, they must be called in the child processes of the server.

To do this, they must be initialized after the “workerStart”event. We have already defined it in the constructor, and the $this->onWorkerStart method is already called on this event:
The Websocket Protocol supports ping-pong out of the box. Below you can see the implementation on Swoole.

onWorkerStart:

/**
*
@param Server $ws
*/
private function onWorkerStart(Server $ws) {
$this->initAsyncRepositories();

$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 to ping MySQL server after every certain period of time using Swoole\Timer:

/**
* 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 complicated than the usual CRUD), and then a huge space for improvements.

So far, I have brought my code to a more readable form and object-oriented style, implemented some functionality:

  • Login by name
  • Check that the name is not taken by another user:
private function isUsernameCurrentlyTaken(string $username) {
foreach ($this->usersRepository->getByIds($this->ws->connection_list()) as $user) {
if ($user->getUsername() == $username) {
return true;
}
}
return false;
}
  • Request limiter for spam protection:

use Swoole\Channel;

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

const MAX_RECORDS_COUNT = 6;

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

The Frontend of the chat is a bit ugly, because I was more attracted by the backend, but when I have more time I will try to make it more pleasant.

Where to get information, learn news about the framework?

  • Official site
  • Slack group
  • Twitter - actual news, interesting articles
  • Issue tracker(Github) - bugs, questions, communication with the creators of the framework. Answer very quickly.
  • Tests - almost every module in the documentation has tests written in PHP, showing the use cases
  • Chinese wiki - all the information that is in English, but much more comments from users (I used google translator to read it).
  • API documentation - description of some classes and functions of the framework in a rather convenient form.

At the conclusion.

It seems to me that Swoole has been developing very actively for the last year, came out of the stage when it could be called “raw”, and now it is competing with useing node.js/go/etc. in terms of asynchronous programming and implementation of network protocols.

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

If you have any questions or feedback for me than let’s connect on Twitter, Telegram.

To communicate in the described chat, please follow the link
The source code is available on Github.