Scaling Websockets in PHP

Scaling Websockets in PHP
Photo by Mike Winkler / Unsplash

I have to implement some kind of sub-system for a larger platform, able to handle realtime communications (such as chat, notifications, and a few other things). And this means: websockets. And, as the platform itself is implemented in PHP/Laravel, this means: websockets in PHP.

So I get back to Ratchet, and I've realized this cannot work alone: as you have a single thread running, getting connections and handling the messages one at a time, the whole system is going to collapse very fast. Nor it can be scaled horizontally simply adding more instances, as different clients will be connected to different servers and you would not be able to share messages (e.g. chatting) among them.

So, I dug deeper and I've managed to include yet another element in the pattern: pub/sub communications through Redis (using ReactPHP-Redis, which conveniently uses ReactPHP exactly as Ratchet and can executed on the same mainloop). The idea is: have many instances, subscribe all of them to a common Redis channel, open the websocket, as soon as a new message arrives publish it on the same Redis channel to reach all the instances, and let each of them broadcast the notice to the owned involved websocket clients.

In code, something like:

<?php

use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;
use Ratchet\Server\IoServer;
use Ratchet\Http\HttpServer;
use Ratchet\WebSocket\WsServer;
use React\Socket\Server as Reactor;
use Clue\React\Redis\Factory;
use Clue\React\Redis\Client;

/*
    This is the interface to Redis
*/
class LivePusher
{
    private $sender_redis = null;
    private $subscriber_redis = null;
    private $server = null;

    public function __construct($loop)
    {
        $factory = new Factory($loop);

        /*
            Note: cannot use the same Redis client for both subscribe and publish
        */
        $this->sender_redis = $factory->createLazyClient($REDIS_HOST);
        $this->subscriber_redis = $factory->createLazyClient($REDIS_HOST);
        $this->subscriber_redis->subscribe('live_signalling');
    }

    public function setServer($server)
    {
        $this->server = $server;

        $this->subscriber_redis->on('message', function ($channel, $payload) use ($server) {
            /*
                Deliver here the message to the proper $server->clients
            */
        });
    }

    public function publish($msg)
    {
        $this->sender_redis->publish('live_signalling', $msg);
    }
}

/*
    This is the interface to the websocket
*/
class LiveServer implements MessageComponentInterface
{
    public $clients;
    private $pusher;

    public function __construct()
    {
        $this->clients = [];
    }

    public function setPusher($pusher)
    {
        $this->pusher = $pusher;
    }

    private function closeConnection($connection)
    {
        /*
            Remove the connection from $this->clients
        */
    }

    public function onOpen(ConnectionInterface $conn)
    {
        /*
            Save in $this->clients the informations about the connections, organizing them by user
        */
    }

    public function onMessage(ConnectionInterface $from, $data)
    {
        $this->pusher->publish($msg);
    }

    public function onClose(ConnectionInterface $conn)
    {
        $this->closeConnection($conn);
    }

    public function onError(ConnectionInterface $conn, \Exception $e)
    {
        $this->closeConnection($conn);
        $conn->close();
    }
}

/*
    The trick here is share the same ReactPHP loop for both the
    asyncronous Redis client and the websocket server, to run them in
    parallel on the same instance
*/

$loop = \React\EventLoop\Factory::create();

$server = new LiveServer();
$socket = new Reactor('0.0.0.0:9090', $loop);
$ioserver = new IoServer(new HttpServer(new WsServer($server)), $socket, $loop);

$pusher = new LivePusher($loop);
$pusher->setServer($server);
$server->setPusher($pusher);

$loop->run();

Design may vary (e.g. managing Redis publishing directly into LiveServer) and some optimization is required (e.g. keep on the LiveServer instance messages which can be locally managed, and which do not involve other clients), but this is the essential core.

Who needs NodeJS???