We write online chat on Websockets 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 different 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.

Sources on github .

Why Swoole?


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

  • Reluctance to breed a zoo of various languages ​​on the project
  • The ability to use the already established code base (if the project is in PHP).

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:

  • Event, Asynchronous Programming Model
  • Asynchronous TCP / UDP / HTTP / Websocket / HTTP2 client / server APIs
  • Support for IPv4 / IPv6 / Unixsocket / TCP / UDP and SSL / TLS
  • Fast serialization / deserialization of data
  • High performance, expandability, support for up to 1 million simultaneous connections
  • Task Scheduler accurate to milliseconds
  • Open source
  • Coroutines support (Coroutines)

Possible uses:

  • Microservices
  • Game servers
  • Internet of things
  • Live communication systems
  • WEB API
  • Any other services that require instant response / high speed / asynchronous execution

Code samples can be seen on the main page of the site . In the documentation section for more information about all the 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

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

As well as swoole_buffer , swoole_channel and swoole_table
I think the differences are not difficult to understand in 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 so far, but to use the standard way, from the web socket server, via PDO

Appeal to the base
/**
     * @return Message[]
     */publicfunctiongetAll(){
        $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
publicfunction__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.

Solution:

In both cases, we need the implementation of the ping function, which will constantly ping the client every n seconds in the first case, 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:
The Websocket protocol supports ping-pong out of the box. Below you can see the implementation on Swoole.

onWorkerStart
privatefunctiononWorkerStart(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 to ping the 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
     */privatestaticfunctioninitPdo(){
        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
     */privatestaticfunctionping(){
        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:

- Log in by name;

- Verify that the name is not taken
/**
     * @param string $username
     * @return bool
     */privatefunctionisUsernameCurrentlyTaken(string $username){
        foreach ($this->usersRepository->getByIds($this->ws->connection_list()) as $user) {
            if ($user->getUsername() == $username) {
                returntrue;
            }
        }
        returnfalse;
    }


- Request spammer for spam protection
<?phpnamespaceApp\Helpers;
useSwoole\Channel;
classRequestLimiter{
    /**
     * @var Channel
     */private $userIds;
    const MAX_RECORDS_COUNT = 10;
    const MAX_REQUESTS_BY_USER = 4;
    publicfunction__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
     */publicfunctioncheckIsRequestAllowed(int $userId){
        $requestsCount = $this->getRequestsCountByUser($userId);
        $this->addRecord($userId);
        if ($requestsCount >= self::MAX_REQUESTS_BY_USER) returnfalse;
        returntrue;
    }
    /**
     * @param int $userId
     * @return int
     */privatefunctiongetRequestsCountByUser(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
     */privatefunctionaddRecord(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.

<?phpnamespaceApp\Helpers;
classSpamFilter{
    /**
     * @var string[] errors
     */private $errors = [];
    /**
     * @param string $text
     * @return bool
     */publicfunctioncheckIsMessageTextCorrect(string $text){
        $isCorrect = true;
        if (empty(trim($text))) {
            $this->errors[] = 'Empty message text';
            $isCorrect = false;
        }
        return $isCorrect;
    }
    /**
     * @return string[] errors
     */publicfunctiongetErrors(): array{
        return$this->errors;
    }
}


Frontend at the chat is still very raw, because I'm more attracted to the backend, but when there is more time I will try to make it more pleasant.

Where to get information, learn news about the framework?


  • English official site - useful links, relevant documentation, some comments from users
  • Twitter - current news, useful links, interesting articles
  • Issue tracker (Github) - bugs, questions, communication with the creators of the framework. They answer very quickly (my question with a question was answered in a couple of hours, helped with the implementation of pingloop).
  • Closed issues - also advise. A large database of questions from users and answers from the creators of the framework.
  • Tests written by developers - for almost every module of the documentation there are tests written in PHP, showing use cases.
  • Chinese wiki framework - all the information is in English, but much more comments from users (google translate help).

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

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 in terms of asynchronous programming and implementation of network protocols.

I would be happy to hear different opinions on the topic and feedback from those who already have experience using Swoole.

You can chat in the described chat by clicking the link. The
sources are available on Github .

Also popular now: