Serwer WebSocket w PHP: trzy podejścia i twarde pomiary

Serwer WebSocket w PHP: trzy podejścia i twarde pomiary
Szybka odpowiedź

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.

bash
composer require cboden/ratchet

Uwaga 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.

src/Chat.php
<?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.

composer.json
{
    "autoload": {
        "psr-4": {
            "MyApp\\": "src/"
        }
    }
}
bash
composer dump-autoload

Typ $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.

server.php
<?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();
bash
php server.php

Jedno wyjaśnienie w sprawie rozgłaszania do wszystkich klientów: IoServer nie ma metody broadcast(). Potwierdza to introspekcja.

php
$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, handleError

Rozgł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.

code
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 instead

Serwer 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.

serveur-natif.php
<?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.

bash
pecl install openswoole
docker-php-ext-enable openswoole   # albo extension=openswoole.so w php.ini
serveur-openswoole.php
<?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

public/client.js
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

Backslashe pogubione w przestrzeniach nazw 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.
IoServer::broadcast() nie istnieje Metody nie ma w klasie, sprawdzone przez introspekcję. Rozgłaszanie polega na samodzielnym przejściu po kolekcji połączeń.
Exception bez wiodącego backslasha w przestrzeni nazw W pliku z namespace MyApp; zapis Exception $e wskazuje na \MyApp\Exception. Trzeba napisać \Throwable albo \Exception.
Ratchet wywołuje ostrzeżenia o wycofaniu na PHP 8.5 Trzy komunikaty Implicitly marking parameter as nullable is deprecated przy samym ładowaniu klas. Ostatnie wydanie w grudniu 2021.
Jedna ramka odczytana na obrót pętli Nasz pierwszy serwer natywny nie opróżniał ramek czekających w kolejce: przepustowość załamywała się przez pętlę, a nie przez gniazda PHP.
ws:// ze strony na HTTPS Przeglądarka odmawia nieszyfrowanego połączenia WebSocket ze strony szyfrowanej. Na produkcji adres jest w wss://.

OpenSwoolePHPRatchetTemps réelWebSocket

Damien Flandrin Web developer od 2010 roku, twórca Gekkode i Email Impact. Każdy artykuł jest sprawdzany na prawdziwym projekcie przed publikacją. Kontakt
Newsletter

Nowe testy, poradniki i projekty — e-mailem.

Powtarzalne testy, wersjonowany kod, datowane wyniki. Nigdy spamu.