
Trzy drogi: Ratchet w czystym Composerze, gniazda natywne bez żadnej zależności albo OpenSwoole jako rozszerzenie PECL. Każda wymaga stałego procesu, czyli VPS-a lub kontenera, nigdy hostingu współdzielonego. Zmierzonym kryterium wyboru jest pamięć na połączenie: 22,4 kB dla Ratcheta, 5,3 kB dla gniazd natywnych, praktycznie zero przy OpenSwoole.
PHP potrafi utrzymywać otwarte połączenia WebSocket, pod warunkiem że zaakceptujesz model wykonania, którego domyślnie nie ma: proces działający bez przerwy zamiast skryptu, który rodzi się i umiera przy każdym żądaniu. W tym artykule stawiamy serwer echo w trzech podejściach dostępnych w 2026 roku, Ratchet, gniazda natywne, OpenSwoole, i porównujemy je na pomiarach zrobionych na tej samej maszynie.
Co trzeba wiedzieć przed startem
Serwera WebSocket nie wdraża się jak zwykłej strony w PHP. Potrzebny jest stały proces, nadzorca, który podniesie go po awarii, i serwer frontowy, który zakończy TLS oraz przekaże połączenie dalej. Na hostingu współdzielonym jest to prawie zawsze niewykonalne: uruchamianie długo żyjących procesów jest tam zabronione, instalowanie rozszerzeń PECL również. Potrzebujesz VPS-a albo kontenera.
Druga sprawa: proces PHP działający całymi dniami nie ma już siatki bezpieczeństwa w postaci restartu. Wyciek pamięci prędzej czy później zabije usługę. Zmienne statyczne, logi gromadzone w pamięci i pozostawione otwarte połączenia z bazą stają się realnym problemem.
Ratchet, czyli podejście biblioteczne
Ratchet to historyczna biblioteka WebSocket w świecie PHP, zbudowana na pętli zdarzeń ReactPHP. Instaluje się bez kompilowania czegokolwiek.
composer require cboden/ratchetUwaga na przestrzenie nazw: separatorem jest backslash, a krążące po sieci przykłady gubią go przy przenoszeniu z edytora do edytora. Bez niego kod kończy się błędem krytycznym.
<?php
declare(strict_types=1);
namespace MyApp;
use Ratchet\ConnectionInterface;
use Ratchet\MessageComponentInterface;
use SplObjectStorage;
final class Chat implements MessageComponentInterface
{
private SplObjectStorage $clients;
public function __construct()
{
$this->clients = new SplObjectStorage();
}
public function onOpen(ConnectionInterface $conn): void
{
$this->clients->attach($conn);
echo "Connexion {$conn->resourceId}\n";
}
public function onMessage(ConnectionInterface $from, $msg): void
{
foreach ($this->clients as $client) {
if ($from !== $client) {
$client->send($msg);
}
}
}
public function onClose(ConnectionInterface $conn): void
{
$this->clients->detach($conn);
}
public function onError(ConnectionInterface $conn, \Throwable $e): void
{
error_log('WebSocket : ' . $e->getMessage());
$conn->close();
}
}Klasa mieszka w src/ w przestrzeni nazw MyApp: zadeklaruj ją Composerowi, inaczej server.php zatrzyma się z błędem Class "MyApp\Chat" not found.
{
"autoload": {
"psr-4": {
"MyApp\\": "src/"
}
}
}composer dump-autoloadTyp $e zasługuje na komentarz. W pliku z namespace MyApp; zapis Exception $e wskazuje na \MyApp\Exception, klasę, która nie istnieje, więc sygnatura przestaje pasować do interfejsu. Potrzebny jest wiodący backslash: \Throwable albo \Exception.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Ratchet\Server\IoServer;
use Ratchet\Http\HttpServer;
use Ratchet\WebSocket\WsServer;
use MyApp\Chat;
$server = IoServer::factory(
new HttpServer(new WsServer(new Chat())),
8080,
'0.0.0.0',
);
$server->run();php server.phpJedno wyjaśnienie w sprawie rozgłaszania do wszystkich klientów: IoServer nie ma metody broadcast(). Potwierdza to introspekcja.
$r = new ReflectionClass(Ratchet\Server\IoServer::class);
echo implode(', ', array_map(fn($m) => $m->getName(), $r->getMethods(ReflectionMethod::IS_PUBLIC)));
// __construct, factory, run, handleConnect, handleData, handleEnd, handleErrorRozgłaszanie polega na samodzielnym przejściu po kolekcji połączeń, tak jak w onMessage() powyżej.
Stan projektu
To punkt, który powinien zaważyć na decyzji. cboden/ratchet jest w wersji 0.4.4, wydanej 14 grudnia 2021 roku. ratchet/rfc6455, jądro protokołu, pochodzi z 9 grudnia 2021. Prawie pięć lat bez wydania. Zależności ReactPHP pozostają za to aktualne (react/socket 1.17.0, listopad 2025).
Konkretna konsekwencja na PHP 8.5: samo załadowanie klas generuje trzy ostrzeżenia o wycofaniu.
Deprecated: Ratchet\Server\IoServer::__construct(): Implicitly marking parameter
$loop as nullable is deprecated, the explicit nullable type must be used instead
Deprecated: Ratchet\Http\HttpServerInterface::onOpen(): Implicitly marking parameter
$request as nullable is deprecated, the explicit nullable type must be used instead
Deprecated: Ratchet\WebSocket\WsServer::onOpen(): Implicitly marking parameter
$request as nullable is deprecated, the explicit nullable type must be used insteadSerwer działa, ale zaśmieca logi i nic nie gwarantuje, że przejdzie na PHP 9. Jeśli zaczynasz projekt dzisiaj, zerknij też prosto na ReactPHP albo na AmPHP, oba są aktywnie utrzymywane.
Bez żadnych zależności
Protokół WebSocket mieści się w niewielkiej ilości kodu: handshake po HTTP, a potem binarny format ramki. Przy prostej potrzebie napisanie go samemu oszczędza dokładania trzynastu paczek do projektu.
<?php
declare(strict_types=1);
$serveur = stream_socket_server('tcp://0.0.0.0:8081', $errno, $errstr);
if (!$serveur) {
fwrite(STDERR, "Écoute impossible : {$errstr}\n");
exit(1);
}
stream_set_blocking($serveur, false);
$clients = [];
$prets = [];
function poigneeDeMain($socket): bool
{
$requete = fread($socket, 8192);
if (!$requete || !preg_match('#Sec-WebSocket-Key:\s*(\S+)#i', $requete, $m)) {
return false;
}
// Stała poniżej jest narzucona przez RFC 6455
$accept = base64_encode(sha1($m[1] . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true));
fwrite($socket, "HTTP/1.1 101 Switching Protocols\r\n"
. "Upgrade: websocket\r\nConnection: Upgrade\r\n"
. "Sec-WebSocket-Accept: {$accept}\r\n\r\n");
return true;
}
function lireTrame($socket): ?string
{
$entete = fread($socket, 2);
if ($entete === false || strlen($entete) < 2) {
return null;
}
$second = ord($entete[1]);
$masquee = (bool) ($second & 0x80);
$taille = $second & 0x7F;
if ($taille === 126) {
$taille = unpack('n', fread($socket, 2))[1];
} elseif ($taille === 127) {
$taille = unpack('J', fread($socket, 8))[1];
}
$masque = $masquee ? fread($socket, 4) : '';
$donnees = '';
while (strlen($donnees) < $taille) {
$bloc = fread($socket, $taille - strlen($donnees));
if ($bloc === '' || $bloc === false) {
break;
}
$donnees .= $bloc;
}
if ($masquee) {
for ($i = 0; $i < strlen($donnees); $i++) {
$donnees[$i] = $donnees[$i] ^ $masque[$i % 4];
}
}
return $donnees;
}
function ecrireTrame($socket, string $charge): void
{
$taille = strlen($charge);
$trame = chr(0x81); // FIN + opcode tekstowy
if ($taille < 126) {
$trame .= chr($taille);
} elseif ($taille < 65536) {
$trame .= chr(126) . pack('n', $taille);
} else {
$trame .= chr(127) . pack('J', $taille);
}
fwrite($socket, $trame . $charge);
}
while (true) {
$lecture = array_merge([$serveur], $clients);
$ecriture = $exception = null;
if (@stream_select($lecture, $ecriture, $exception, 0, 200000) === false) {
continue;
}
foreach ($lecture as $socket) {
if ($socket === $serveur) {
$nouveau = @stream_socket_accept($serveur, 0);
if ($nouveau) {
stream_set_blocking($nouveau, false);
$clients[(int) $nouveau] = $nouveau;
$prets[(int) $nouveau] = false;
}
continue;
}
$id = (int) $socket;
if (!($prets[$id] ?? false)) {
if (poigneeDeMain($socket)) {
$prets[$id] = true;
} else {
fclose($socket);
unset($clients[$id], $prets[$id]);
}
continue;
}
// Opróżnij wszystkie oczekujące ramki, nie jedną na obrót pętli
stream_set_blocking($socket, true);
$ferme = false;
do {
$message = lireTrame($socket);
if ($message === null || $message === '') {
$ferme = true;
break;
}
ecrireTrame($socket, $message); // echo
$meta = stream_get_meta_data($socket);
} while (($meta['unread_bytes'] ?? 0) > 0);
stream_set_blocking($socket, false);
if ($ferme) {
fclose($socket);
unset($clients[$id], $prets[$id]);
}
}
}Komentarz o opróżnianiu ramek nie jest anegdotą. Nasza pierwsza wersja obsługiwała jedną wiadomość na klienta na obrót pętli: zmierzona przepustowość się załamywała, a winna była nasza pętla, nie gniazda PHP. To rodzaj błędu, który ujawnia benchmark, a lektura kodu przepuszcza.
Ten serwer jest celowo niekompletny. Nie obsługuje ramek kontrolnych (ping, pong, close), fragmentacji, ładunków binarnych ani TLS. Do realnego zastosowania trzeba je dopisać albo przejść na bibliotekę.
OpenSwoole
OpenSwoole to rozszerzenie w C, które daje PHP prawdziwy asynchroniczny serwer wieloprocesowy. Instaluje się przez PECL, wymaga więc dostępu administracyjnego do maszyny.
pecl install openswoole
docker-php-ext-enable openswoole # albo extension=openswoole.so w php.ini<?php
declare(strict_types=1);
$serveur = new OpenSwoole\WebSocket\Server('0.0.0.0', 8082);
$serveur->set([
'worker_num' => 1,
'log_level' => OpenSwoole\Constant::LOG_ERROR,
]);
$serveur->on('message', function (OpenSwoole\WebSocket\Server $srv, $frame) {
$srv->push($frame->fd, $frame->data); // echo
});
$serveur->on('close', function () {});
$serveur->start();Kod jest najkrótszy z trzech, a rozgłaszanie do wszystkich klientów dostajesz od rozszerzenia. W zamian rozszerzenie narzuca swoją obecność na maszynie i własny model procesów.
Klient po stronie przeglądarki
const socket = new WebSocket('wss://exemple.com/ws');
socket.addEventListener('open', () => {
console.log('connexion ouverte');
socket.send('Bonjour serveur');
});
socket.addEventListener('message', (e) => {
console.log('reçu :', e.data);
});
socket.addEventListener('close', (e) => {
console.log('fermée', e.code, e.reason);
// ponowne łączenie z rosnącym opóźnieniem
});
socket.addEventListener('error', () => {
console.error('erreur de transport');
});Na produkcji adres jest w wss://, nie ws://: przeglądarka na stronie HTTPS odmówi nieszyfrowanego połączenia WebSocket. Ponowne łączenie też nie dzieje się samo: powyższy kod musi otworzyć połączenie na nowo, gdy zadziała close, z opóźnieniem rosnącym przy każdej próbie.
Pomiary
Trzy serwery realizują to samo echo, na tej samej maszynie, każdy sam podczas swojego pomiaru: kontener ograniczony do 1 rdzenia i 512 MB, PHP 8.5.10, Docker na macOS. Klientem jest skrypt PHP, który mówi protokołem wprost.
Pamięć na połączenie
To pomiar najstabilniejszy i najbardziej rozstrzygający. Otwieramy 500 połączeń, utrzymujemy je i odczytujemy VmRSS procesu serwera przed i w trakcie.
| Serwer | W spoczynku | Przy 500 połączeniach | Koszt na połączenie |
|---|---|---|---|
| Ratchet 0.4.4 | 23 700 kB | 34 940 kB | 22,4 kB |
| Gniazda natywne | 22 880 kB | 25 540 kB | 5,3 kB |
| OpenSwoole 26.2.0 | 26 512 kB | 26 140 kB | ≈ 0 kB |
Liczba przy OpenSwoole nie jest pomyłką: rozszerzenie alokuje tablicę połączeń już przy starcie, co tłumaczy wyższą bazę i brak późniejszego wzrostu. Ratchet kosztuje na połączenie cztery razy więcej niż gołe gniazda, to cena obiektów ReactPHP i PSR-7 spiętrzonych za każdym klientem. Przy 10 000 połączeń różnica między Ratchetem a wersją natywną przekracza 170 MB.
Opóźnienie w obie strony
50 podłączonych klientów, 800 wiadomości po 64 bajty wysyłanych pojedynczo na pierwszym kliencie, z czekaniem na echo przed wysłaniem kolejnej.
| Serwer | p50 | p95 | p99 | Nawiązanie połączenia |
|---|---|---|---|---|
| Ratchet 0.4.4 | 0,27 ms | 1,88 ms | 3,90 ms | 1,83 ms |
| Gniazda natywne | 0,03 ms | 0,25 ms | 0,43 ms | 1,52 ms |
| OpenSwoole 26.2.0 | 0,03 ms | 0,75 ms | 3,02 ms | 0,76 ms |
Wszystkie trzy odpowiadają w medianie poniżej milisekundy. Różnica w ogonie rozkładu mówi więcej niż mediana: to ją czuć w interfejsie.
Przepustowość: pomiar, który nie rozstrzyga
50 klientów w ścisłym trybie żądanie-odpowiedź przez sześć sekund, pięć przebiegów na serwer, w wiadomościach wymienionych na sekundę.
| Serwer | Przebiegi | Mediana |
|---|---|---|
| Ratchet 0.4.4 | 25 450 · 31 667 · 32 400 · 33 933 · 36 067 | 32 400 |
| Gniazda natywne | 25 258 · 27 375 · 33 425 · 35 975 · 46 417 | 33 425 |
| OpenSwoole 26.2.0 | 22 333 · 23 175 · 25 050 · 32 317 · 34 192 | 25 050 |
Zakresy nakładają się całkowicie: od 22 000 do 46 000 wiadomości na sekundę, na wszystkich serwerach łącznie, z różnicą sięgającą 40 % między dwoma przebiegami tego samego serwera. Ten benchmark nie pozwala uszeregować ich pod względem przepustowości. Maszyna testowa, wirtualka Dockera na macOS dzieląca rdzenie z resztą systemu, wprowadza zbyt dużo szumu. Wolimy to powiedzieć, niż publikować ranking, którego pomiar nie broni. Na serwerze dedykowanym i z rozproszonym generatorem obciążenia różnica byłaby może wyraźna, tego nie zmierzyliśmy.
Co można stwierdzić: wszystkie trzy spokojnie wyciągają kilkadziesiąt tysięcy wiadomości na sekundę na jednym rdzeniu. Dla niemal każdej aplikacji przepustowość nie jest kryterium wyboru.
Jak wybrać
| Ratchet | Gniazda natywne | OpenSwoole | |
|---|---|---|---|
| Instalacja | Composer | nic | PECL, dostęp roota |
| Dodane zależności | 19 paczek | 0 | 1 rozszerzenie |
| Pełny protokół | tak | do napisania | tak |
| Pamięć / połączenie | 22,4 kB | 5,3 kB | ≈ 0 |
| Ostatnie wydanie | gru. 2021 | — | aktywne |
| Ostrzeżenia o wycofaniu na 8.5 | 3 | 0 | 0 |
| Hosting współdzielony | nie | nie | nie |
W praktyce: OpenSwoole, jeśli panujesz nad maszyną i celujesz w dużą liczbę jednoczesnych połączeń. Gniazda natywne przy prostej, dobrze zamkniętej potrzebie albo po to, żeby zrozumieć protokół. Ratchet, jeśli chcesz zostać przy czystym Composerze i godzisz się na bibliotekę zamrożoną od 2021 roku, a wtedy porównaj ją najpierw z ReactPHP albo AmPHP, które są utrzymywane.
I przede wszystkim: jeśli chodzi tylko o wypychanie powiadomień, bez intensywnej wymiany w obie strony, Server-Sent Events jadą po zwykłym HTTP, przechodzą przez każde proxy bez konfiguracji i same się łączą ponownie. To często właściwe narzędzie i nie wymaga żadnego z tych trzech serwerów.
Do klasycznej wymiany żądanie-odpowiedź odpowiednim narzędziem pozostaje zapytanie cURL. Po stronie klienta poradnik o budowie czatu online w JavaScripcie pokazuje drugi koniec połączenia. Resztę poradników PHP zebraliśmy w hubie Programowanie webowe.
Częste błędy
use RatchetServerIoServer; zamiast use Ratchet\Server\IoServer; daje Fatal error: Uncaught Error: Class "IoServer" not found, poprzedzone czterema ostrzeżeniami o instrukcjach use bez efektu.namespace MyApp; zapis Exception $e wskazuje na \MyApp\Exception. Trzeba napisać \Throwable albo \Exception.Implicitly marking parameter as nullable is deprecated przy samym ładowaniu klas. Ostatnie wydanie w grudniu 2021.wss://.

