TrueAsync ServerTrueAsync Server: WebSocket

WebSocket

(PHP 8.6+, true_async_server 0.9+)

HttpServer::addWebSocketHandler() registra un handler per connessioni full-duplex su RFC 6455.

Una connessione inizia come una normale richiesta HTTP, dopodiché il client chiede al server di passare a un protocollo diverso sulla stessa connessione TCP: questo è l'Upgrade. Il server risponde con lo stato 101 Switching Protocols, e da quel momento la stessa connessione porta WebSocket, non più HTTP. Supportati:

  • Upgrade da HTTP/1.1 (il classico header Connection: Upgrade).
  • Upgrade da HTTP/2 (RFC 8441 Extended CONNECT).
  • wss:// (WebSocket su TLS).
  • permessage-deflate (RFC 7692), compressione a livello di messaggio.
  • Topic pub/sub che raggiungono ogni worker del processo, così una chat non richiede un server a singolo worker né un broker esterno.

L'implementazione è verificata rispetto alla suite di conformità Autobahn|Testsuite e supera tutti i 246 test della categoria behavior.

php
use TrueAsync\HttpServer;
use TrueAsync\HttpServerConfig;
use TrueAsync\WebSocket;

$server = new HttpServer(
    (new HttpServerConfig())
        ->addListener('0.0.0.0', 8080)
);

$server->addWebSocketHandler(function (WebSocket $ws) {
    foreach ($ws as $msg) {
        $ws->send('echo: ' . $msg->data);
    }
});

// Obbligatorio: il server rifiuta di partire senza un handler HTTP, ed è lui a
// rispondere alle richieste che non sono upgrade.
$server->addHttpHandler(function ($req, $res) {
    $res->setStatusCode(404)->end();
});

$server->start();

Registrare l'handler è ciò che attiva WebSocket — non c'è un interruttore separato da azionare, esattamente come per HTTP/2 e addHttp2Handler().

HttpServerConfig::enableWebSocket() è un interruttore legacy, non è quello giusto. Passargli true lancia HttpServerRuntimeException che ti rimanda a addWebSocketHandler() — registra invece l'handler.

Ogni connessione viene servita dalla propria coroutine, lo stesso modello per richiesta usato per HTTP. Un handler che lancia un'eccezione non porta giù il worker con sé: l'eccezione viene registrata a log, e il peer viene informato nel protocollo — uno stato HTTP se il lancio precede l'upgrade, un CLOSE 1011 una volta che la sessione è attiva.

L'handler viene sempre chiamato con tre argomenti, e PHP scarta quelli che non hai dichiarato — quindi function (WebSocket $ws), function (WebSocket $ws, HttpRequest $req) e la forma a tre parametri sono tutti validi. Dichiara solo ciò che usi.

Ciclo di vita

Una connessione resta aperta finché la coroutine dell'handler non ritorna. Se l'handler semplicemente termina (ad esempio, il loop recv()/foreach ottiene null alla fine), il server chiude la connessione con il codice 1000 Normal automaticamente. Una close() esplicita prima del return serve solo se si vuole un codice diverso o un proprio testo di motivo.

Ricezione dei messaggi: recv() e foreach

php
public WebSocket::recv(): ?WebSocketMessage

Sospende la coroutine finché non arriva il messaggio successivo o la connessione si chiude. Restituisce un WebSocketMessage o null quando il client ha chiuso la connessione in modo pulito (un codice di chiusura normale, o una disconnessione senza un frame CLOSE esplicito):

php
while (($msg = $ws->recv()) !== null) {
    handle($msg->data, $msg->binary);
}

WebSocket implementa \Iterator, quindi lo stesso loop si può scrivere più concisamente come foreach ($ws as $msg) { ... }. Una chiusura pulita termina semplicemente il foreach; una chiusura con errore lancia WebSocketClosedException direttamente fuori dal loop.

Leggi i messaggi da un solo punto: se chiami recv() da due coroutine in parallelo sulla stessa connessione, la seconda chiamata lancia WebSocketConcurrentReadException. Se devi distribuire i messaggi a più handler, mantieni un solo loop recv() e distribuisci da lì.

Invio dei messaggi: send(), trySend()

send() e sendBinary() sono sicuri da chiamare da qualsiasi coroutine, anche più di una alla volta: il server garantisce che i dati di chiamate diverse non si mescolino mai sulla rete.

php
$ws->send('text frame');       // il testo DEVE essere UTF-8 valido
$ws->sendBinary($binaryData);  // i dati binari non hanno vincoli di codifica

Di solito queste funzioni ritornano subito. Se il client legge lentamente e il buffer di invio si riempie, la coroutine si sospende e riprende quando il client svuota un po' di buffer. Se l'attesa supera write_timeout_ms, viene lanciata WebSocketBackpressureException, e l'handler decide cosa fare: scartare il messaggio, chiudere la connessione o riprovare.

Per il broadcast di un messaggio a molti client, dove un client lento non deve rallentare gli altri, esistono varianti non bloccanti:

php
if (!$ws->trySend($text)) {
    // il buffer di questo client è pieno, il messaggio NON è stato inviato, il client è indietro
}

trySend()/trySendBinary() non sospendono mai la coroutine: ritornano subito true se il messaggio è stato accettato, e false se il buffer è pieno (in tal caso il messaggio semplicemente non viene inviato). La dimensione del buffer è impostata da HttpServerConfig::setStreamWriteBufferBytes() (0 disattiva il limite: trySend() invia sempre e restituisce sempre true).

Topic: publish/subscribe su ogni worker

Un worker è un thread con il proprio contesto PHP. Quindi il modo ovvio di costruire una chat — tenere un array di connessioni e iterarci sopra — può raggiungere soltanto i peer di un worker, ed è per questo che una chat del genere doveva girare su setWorkers(1).

I topic risolvono questo. Vivono nel server, non nel tuo handler: ogni worker indicizza le connessioni che possiede, e una publish() viene consegnata a ogni worker, che poi la recapita ai propri socket. Niente Redis, niente message broker, niente server a singolo worker.

php
$server->addWebSocketHandler(function (WebSocket $ws, HttpRequest $req) {
    $room = ltrim($req->getPath(), '/') ?: 'lobby';

    $ws->subscribe("chat/$room");

    foreach ($ws as $msg) {
        $ws->publish("chat/$room", $msg->data);   // raggiunge gli iscritti su TUTTI i worker
    }
});

Un topic è indirizzato per nome, nel punto di chiamata. Non c'è nessun oggetto topic da ottenere, conservare o passare a un handler.

I filtri seguono MQTT

I livelli sono separati da /, + corrisponde esattamente a un livello, e un # finale corrisponde al resto:

FiltroRiceve
chat/generalesattamente quel topic
chat/+/typingchat/general/typing, chat/random/typing — un livello, qualsiasi valore
user/42/#user/42, user/42/presence, user/42/dm/7 — l'intero sottoalbero

I wildcard appartengono alle sottoscrizioni. Un topic di publish deve essere concreto: un messaggio diffuso verso un pattern non ha una destinazione ben definita, quindi publish('chat/+/typing', …) lancia WebSocketException. I filtri possono essere profondi fino a 128 livelli.

L'API

php
$ws->subscribe('chat/+/typing');            // idempotente
$ws->unsubscribe('chat/+/typing');          // idempotente
$ws->getTopics();                           // string[] — i filtri di questa connessione

$ws->publish('chat/general', $text);        // testo, a ogni worker
$ws->publishBinary('chat/general', $bytes); // controparte binaria

$ws->subscriberCount('chat/general');       // su tutti i worker, wildcard inclusi

publish() non sospende mai. Un peer la cui coda in uscita è congestionata scarta il messaggio invece di bloccare la consegna al resto del topic — la stessa semantica di trySend(). Quando serve una garanzia di consegna, usa send() verso la singola connessione. Un iscritto individuato da più dei suoi stessi filtri riceve comunque esattamente una copia.

$excludeSelf vale true di default — il caso "tutti tranne il mittente" che una chat vuole:

php
$ws->publish('chat/general', $msg->data);                      // il mittente non lo riceve indietro
$ws->publish('chat/general', $msg->data, excludeSelf: false);  // lo riceve anche il mittente

Il valore di ritorno è il numero di iscritti serviti solo sul worker chiamante. La consegna agli altri worker è asincrona e non può essere contata nel punto di chiamata, quindi questo è un numero locale, non uno a livello di processo. subscriberCount() è quello a livello di processo — ma poiché ogni worker risponde con il proprio conteggio e le risposte vengono sommate, è uno snapshot più che un contatore live, e un worker che non risponde in tempo viene escluso.

Una connessione in chiusura si disiscrive da tutto da sola.

Limiti

Entrambi sono disattivati di default, che è ciò che ogni broker self-hosted spedisce (EMQX max_subscriptions / messages_rate, NATS max_subs): solo l'applicazione sa quanti topic le servono.

php
$config
    ->setWsMaxSubscriptions(32)          // filtri distinti che una connessione può tenere
    ->setWsPublishRateLimit(50, burst: 100);

Imposta setWsMaxSubscriptions() ogni volta che input del client raggiunge subscribe() — ad esempio $ws->subscribe($msg->data) — così un peer non può far crescere all'infinito l'albero dei topic del worker. Oltre il limite, subscribe() lancia WebSocketException e la connessione resta attiva.

setWsPublishRateLimit() è un token bucket per connessione. publish() è l'unica chiamata WebSocket che un peer non privilegiato può trasformare in lavoro su ogni worker del processo — send() e trySend() toccano sempre e solo il proprio socket. Senza misura, un client che itera su un messaggio rilanciato riempie l'inbox di ogni worker, e gli scarti che ne seguono colpiscono anche il traffico di altri topic. Oltre il rate, publish() lancia WebSocketBackpressureException e la connessione resta attiva: il mittente viene informato, invece che il messaggio svanisca in una mailbox piena dove nessuno può vederlo.

$burst è la profondità del bucket in messaggi — quanto un handler può correre avanti rispetto al rate sostenuto. 0 significa il valore di un secondo.

php
try {
    $ws->publish("chat/$room", $msg->data);
} catch (WebSocketBackpressureException) {
    $ws->send('you are sending too fast');
} catch (WebSocketException $e) {
    $ws->send('bad topic: ' . $e->getMessage());
}

Quanto costa

Ogni worker riassume le proprie sottoscrizioni in un counting Bloom filter di prefissi di topic, e un publisher salta i worker che provabilmente non hanno alcun iscritto invece di svegliarli tutti. Una publish verso un topic che nessuno nel processo ascolta costa zero risvegli cross-worker. HttpServer::getRuntimeStats() riporta l'esito — ws_topic_posted, ws_topic_skipped (il filtro che si guadagna il pane) e ws_topic_dropped (la mailbox di un worker era piena: quello è perdita di dati).

I topic funzionano su ogni transport WebSocket, non solo su HTTP/1 in plaintext — su TLS, su HTTP/2 Extended CONNECT, e con permessage-deflate, dove una publish() serve fianco a fianco un peer compresso e uno in chiaro, ciascuno con il framing che ha negoziato.

L'indirizzo del client

php
$ws->getRemoteAddress();   // "203.0.113.7" o "2001:db8::1" — IP nudo, senza porta
$ws->getRemotePort();      // 54321

getRemoteAddress() restituisce l'IP nudo: nessuna porta, e nessuna parentesi attorno a un letterale IPv6 — la stessa forma di $_SERVER['REMOTE_ADDR'], così va dritto in filter_var(…, FILTER_VALIDATE_IP), in una ACL o in un rate limiter. Entrambi restituiscono null su un listener Unix-socket, che non ha un peer IP.

Questo è il peer della connessione TCP. Non è derivato da X-Forwarded-For — dietro un proxy, analizza quell'header tu stesso, e solo quando ti fidi del proxy che l'ha impostato.

Modifica breaking. getRemoteAddress() restituiva "host:port" (e "" quando non c'era un peer IP). Ora restituisce l'IP nudo, e null. Usa getRemotePort() per la porta.

Chiusura di una connessione: close(), isClosed()

php
$ws->close(WebSocketCloseCode::NORMAL, 'bye');

Avvia la chiusura della connessione. Sicura da chiamare più volte: le chiamate successive sono no-op. Il codice di chiusura è un valore WebSocketCloseCode oppure un intero nell'intervallo 4000..4999 (riservato ai codici specifici dell'applicazione). $reason accetta testo UTF-8, fino a 123 byte.

isClosed() restituisce true dopo close(), o dopo che il client ha inviato il proprio segnale di chiusura.

Ping e keepalive

php
$ws->ping('optional payload');   // fino a 125 byte, RFC 6455 §5.5

Il codice applicativo raramente ha bisogno di chiamarlo a mano: il timer di keepalive del server (HttpServerConfig::setWsPingIntervalMs()) invia PING automaticamente. Se il client non risponde in tempo (setWsPongTimeoutMs()), il server chiude la connessione da solo. Vedi Configurazione per i dettagli.

Negoziazione del subprotocollo e rifiuto: WebSocketUpgrade

Per impostazione predefinita l'handler riceve solo WebSocket $ws. Per decidere da soli se accettare la connessione e quale subprotocollo scegliere, registra l'handler con tre parametri: il server rileva il numero di parametri e, in tal caso, passa un terzo oggetto, WebSocketUpgrade:

php
use TrueAsync\WebSocket;
use TrueAsync\HttpRequest;
use TrueAsync\WebSocketUpgrade;

$server->addWebSocketHandler(function (WebSocket $ws, HttpRequest $req, WebSocketUpgrade $u) {
    $offered = $u->getOfferedSubprotocols();   // dall'header Sec-WebSocket-Protocol

    if (!in_array('chat.v2', $offered, true)) {
        $u->reject(400, 'unsupported subprotocol');
        return;
    }

    $u->setSubprotocol('chat.v2');   // deve essere chiamato prima del return o di reject()

    foreach ($ws as $msg) {
        // ...
    }
});

WebSocketUpgrade vive dal momento in cui l'handler viene invocato fino a reject() o a un return con successo (momento in cui il server completa l'handshake con il subprotocollo scelto). Dopo di che, qualsiasi chiamata su questo oggetto lancia un'eccezione: la risposta è già sulla rete e il subprotocollo non può più cambiare.

getOfferedExtensions() restituisce l'elenco delle estensioni offerte dal client. permessage-deflate (RFC 7692, compressione dei messaggi) viene negoziata dal server stesso tramite HttpServerConfig::setWsPermessageDeflate(); gli altri valori offerti sono solo informativi.

Codici di chiusura ed eccezioni

WebSocketCloseCode è un enum con i codici di chiusura standard di RFC 6455 (NORMAL, GOING_AWAY, PROTOCOL_ERROR, MESSAGE_TOO_BIG e altri). La gerarchia delle eccezioni:

\Exception
  └── TrueAsync\HttpServerException
        └── TrueAsync\WebSocketException            // anche: filtro topic errato, limite sottoscrizioni
              ├── WebSocketClosedException          // closeCode / closeReason
              ├── WebSocketBackpressureException    // lettore lento — o publish() oltre il suo rate limit
              └── WebSocketConcurrentReadException  // secondo recv() in parallelo

Una chiusura pulita da parte del client si presenta come null da recv(), non come un'eccezione. Un'eccezione viene lanciata solo per un errore di protocollo o una chiusura con un codice di errore esplicito; $closeCode/$closeReason portano il motivo. Vedi il riferimento per i dettagli.

Configurazione

MetodoPredefinitoScopo
setWsMaxMessageSize($bytes)1 MiBdimensione massima del messaggio riassemblato, altrimenti 1009
setWsMaxFrameSize($bytes)1 MiBdimensione massima di un singolo frame, protegge da un flood di frammenti minuscoli
setWsPingIntervalMs($ms)30000ogni quanto il server pinga una connessione idle, 0 lo disattiva
setWsPongTimeoutMs($ms)60000quanto attendere il PONG prima di chiudere (1001)
setWsPermessageDeflate($bool)falseRFC 7692, opt-in per via del costo in CPU
setWsMaxSubscriptions($count)0 (nessun limite)filtri topic distinti che una connessione può tenere
setWsPublishRateLimit($perSecond, $burst)0 (off)token bucket per connessione su publish()

Vedi Configurazione per maggiori dettagli.

Vedi anche