Introduction to ReactPHP and Websockets 2.0

phpcon Poland 2024

Frank Berger

A bit about me

  • Frank Berger
  • Head of Engineering Sudhaus7, a label of B-Factor GmbH, Stuttgart
  • Started as an Unix System administrator who also codes in 1996
  • Web development since 2000
  • Does TYPO3 since 2005

Goal of this talk

so.... Websockets

Protocol ws:// and wss://

Standardized in 2011 as RFC 6455

Located in layer 7 of the OSI model (on top of layer 4 TCP)

Upgrade protocol on top of HTTP

Allows full-duplex communication

Support for all major browsers

Support in all major languages

ReactPHP (& Ratchet)

The user land solution in PHP

(does not need any extra PHP extensions)

ReactPHP

ReactPHP is an event driven, non-blocking I/O framework in PHP

(think NodeJS on the server, but with PHP)

Runs on a separate port on the server, needs to be proxied through the webserver

Handles thousands of connections and events in a single PHP process

Does not need any special extensions in PHP, but leverages new features in PHP 8.1 like fibers

Ratchet

A websocket implementation on top of ReactPHP

Basic Implementation (server side)


						composer require cboden/ratchet
					


						use Ratchet\MessageComponentInterface;
						use Ratchet\ConnectionInterface;

						require_once __DIR__ . "/vendor/autoload.php";

						$app = new Ratchet\App('HOSTNAME', 8080);
						$app->route('/echo', new Ratchet\Server\EchoServer, array('*'));
						$app->run();
					

Client Side


						
						

						var conn = new WebSocket('ws://HOSTNAME:8080/echo');
						conn.onopen = function(ev) {
							conn.send('hello');
						}
						conn.onmessage = function(ev) {
							let li=document.createElement('li');
							li.innerText = ev.data;
							document.getElementById('log').append(li);
						}
						document.querySelector('button').addEventListener('click',function() {
						   if (conn && conn.readyState) {
								let d = new Date();
								conn.send(d.toString()+' Hello Poland, welcome to step 1');
						   }
						});
					

Let's try to hear (see?) some echo

CLient Side available events

conn.onopen
conn.onmessage
conn.onclose
conn.onerror
conn.readyState === 1
conn.send()

manage the open state

use custom JS events

Nothing much happening yet, how about we try this in two browsers?

Distributing the message

Server side


class MyChat implements MessageComponentInterface {
protected \SplObjectStorage $clients;
public function __construct() {
	$this->clients = new \SplObjectStorage();
}
public function onOpen(ConnectionInterface $conn) {
	$this->clients->attach($conn);
}
public function onMessage(ConnectionInterface $from, $msg) {
	foreach ($this->clients as $client) {
		$client->send($msg);
	}
}
public function onClose(ConnectionInterface $conn) {
	$this->clients->detach($conn);
}
public function onError(ConnectionInterface $conn, \Exception $e) {
	$conn->close();
}
}
				

Server side (2)


$app = new Ratchet\App('HOSTNAME', 8080);
$app->route('/chat', new MyChat(), array('*'));
$app->run();
					

Client side


						var conn = new WebSocket('ws://HOSTNAME:8080/chat');
					

let's try it

Now that we have the foundation

Lets implement a simple chat

Client side (HTML)


Client side (Javascript)


var conn = new WebSocket('ws://HOSTNAME:8080/chat');
let nickname = '';
conn.onopen = function(ev) {
	nickname = prompt('your nickname');
	if (nickname) {
		let payload = {
			action: 'login',
			nickname: nickname
		}
		conn.send(JSON.stringify(payload));
	}
}
conn.onmessage = function(ev) {

	let payload = JSON.parse(ev.data);
	let li = document.createElement('li');

	switch (payload.action) {
		case 'login':
			li.classList.add('system');
			li.innerText = payload.nickname + ' ' + 'logged in';
			break;
		case 'logoff':
			li.classList.add('system');
			li.innerText = payload.nickname + ' ' + 'logged out';
			break;
		case 'msg':
			li.classList.add('msg');

			let nick = document.createElement('span');
			nick.innerText = payload.nickname;
			li.append(nick);

			let msg = document.createElement('span');
			msg.innerText = payload.msg;
			li.append(msg);

			if (payload.nickname === nickname) {
				li.classList.add('me');
			}

			break;
	}
	let chatlog = document.getElementById('chat');
	chatlog.append(li);
	chatlog.scrollTop = chatlog.lastElementChild.offsetTop;
}

document.querySelector('form').addEventListener('submit',(ev)=>{
	ev.stopPropagation();
	ev.preventDefault();
	let payload = {
		action: 'msg',
		msg: document.getElementById('msg').value,
		nickname: nickname
	};
	conn.send(JSON.stringify(payload));
	document.getElementById('msg').value = '';
});
					

Server side


class MyChat implements MessageComponentInterface {
	protected \SplObjectStorage $clients;

	public function __construct() {
		$this->clients = new \SplObjectStorage();
	}

	public function onOpen(ConnectionInterface $conn) {
		$this->clients->attach($conn);
	}

	public function onMessage(ConnectionInterface $from, $msg) {

		$payload = json_decode( $msg , true);
		foreach($payload as $k=>$v) {
			$payload[$k] = htmlentities(strip_tags($v),ENT_NOQUOTES);
		}
		$msg = json_encode($payload);

		switch($payload['action']) {
			case 'login':
				foreach ($this->clients as $client) {
					if ($client !== $from) {
						$client->send( $msg );
					} else { // $client is $from
						$this->clients[$from] = ['nickname'=>$payload['nickname']];
					}
				}
				break;
			case 'msg':
				foreach ($this->clients as $client) {
					$client->send( $msg );
				}
				break;
		}
	}

	public function onClose(ConnectionInterface $conn) {
		$info = $this->clients[$conn];
		$this->clients->detach($conn);
		if ($info && $info['nickname']) {
			foreach($this->clients as $client) {
				$client->send(json_encode( [
						'action'=>'logoff',
						'nickname'=>$info['nickname']
				]));
			}
		}
	}

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

					

What about persistence?

Right now, everything is only in memory

How could we add a backlog?

Considerations

Something non-blocking

Something fast

With atomic updates

Possibility to retrieve as a list or stack

We're going to use Redis lists!

Adding redis lists

using predis/predis


class MyChat implements MessageComponentInterface {

	protected SplObjectStorage $clients;
	protected \Predis\Client $redis;
	protected string $redisconnect;

	public function __construct($redisconnect = 'tcp://127.0.0.1:6379') {
		$this->redisconnect = $redisconnect;
		$this->clients = new SplObjectStorage();
		$this->redis  = new Client($redisconnect);
	}

	public function connectRedis()
	{
		if (
			!$this->redis instanceof Client ||
			$this->redis->ping('pong')!=='pong'
		) {
			$this->redis  = new Client($this->redisconnect);
		}
	}

	public function onOpen(ConnectionInterface $conn) {
		$this->clients->attach($conn);
	}

	public function onMessage(ConnectionInterface $from, $msg) {
		$this->connectRedis();
		$payload = json_decode( $msg , true);
		foreach($payload as $k=>$v) {
			$payload[$k] = htmlentities(strip_tags($v),ENT_NOQUOTES);
		}
		$msg = json_encode($payload);

		switch($payload['action']) {
			case 'login':
				foreach ($this->clients as $client) {
					if ($client !== $from) {
						$client->send( $msg );
					} else { // $client is $from
						$this->clients[$from] = ['nickname'=>strip_tags($payload['nickname'])];

						$chatlog = $this->redis->lrange('chatlog',0,50) ?? [];
						$chatlog = array_reverse($chatlog);
						foreach ($chatlog as $chatmsg) {
							$from->send($chatmsg);
						}
					}
				}
				break;
			case 'msg':
				$this->redis->lpush('chatlog', $msg);
				foreach ($this->clients as $client) {
					$client->send( $msg );
				}
				break;
		}
	}

	public function onClose(ConnectionInterface $conn) {
		$info = null;
		if ($this->clients[$conn]) {
			$info = $this->clients[$conn];
		}
		$this->clients->detach($conn);
		if ($info && $info['nickname']) {
			foreach($this->clients as $client) {
				$client->send(json_encode( ['action'=>'logoff','nickname'=>$info['nickname']]));
			}
		}
	}

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

Further considerations

Keep your messages small (chatty vs size)

Use the ReactPHP event loop to async/await

Especially for Filesystem and Socket Operations (react/stream)

Predis has a ReactPHP eventhandler for Redis events/queues

setting up the event loop manually


						$loop = new StreamSelectLoop();
						$redisevents = new OtherReactPHPEventHandlerForRedisForExample($loop);
					

and add it to the Ratchet Server


$app = new \Ratchet\App('HOSTNAME', 8080, '127.0.0.1' ,$loop);
$app->route('/chat', new MyChat, array('*'));
$app->route('/echo', new Ratchet\Server\EchoServer , array('*'));
$app->run();
					

Or - do manually what \Ratchet\App is doing in registering their part onto the event loop, adding it as a normal event-loop handler

Proxy it!


						# NGINX
						server {
							upstream ws {
								server 127.0.0.1:8080 fail_timeout=0;
							}
							server {
								listen 443 ssl;
								listen [::]:443 ssl;
								location /ws/ {
									  proxy_pass http://ws;
									  proxy_http_version 1.1;
									  proxy_set_header Upgrade $http_upgrade;
									  proxy_set_header Connection "upgrade";
								}
							}
						}
					

						# APACHE 2.4
						ProxyPass "/ws/"  "wss://HOSTNAME:8080/"
					

Keep it running

forever - from the nodejs world

docker - running the "server.php" as an entrypoint (and mark it for restart if it fails)

on Linux - register as systemd service

inside a screen session

Real life examples

Real life examples

One caveat

Ratchet is looking for contributors and maintainers

and is therefor not compatible with the latest ReactPHP

Summary

Working with websockets is not so hard

and you don't need large frameworks to start (or to finish)

So go ahead and build cool, interactive applications!

What are your questions?

X: @FoppelFB

Fediverse: @foppel@phpc.social

fberger@sudhaus7.de

https://sudhaus7.de/