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
The user land solution in PHP
(does not need any extra PHP extensions)
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
A websocket implementation on top of ReactPHP
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();
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');
}
});
conn.onopen
conn.onmessage
conn.onclose
conn.onerror
conn.readyState === 1
conn.send()
manage the open state
use custom JS events
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();
}
}
$app = new Ratchet\App('HOSTNAME', 8080);
$app->route('/chat', new MyChat(), array('*'));
$app->run();
var conn = new WebSocket('ws://HOSTNAME:8080/chat');
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 = '';
});
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();
}
}
Right now, everything is only in memory
How could we add a backlog?
Something non-blocking
Something fast
With atomic updates
Possibility to retrieve as a list or stack
We're going to use 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();
}
}
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
$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
# 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/"
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
Ratchet is looking for contributors and maintainers
and is therefor not compatible with the latest ReactPHP
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!
X: @FoppelFB
Fediverse: @foppel@phpc.social
fberger@sudhaus7.de
https://sudhaus7.de/