“`html

Le applicazioni web moderne devono spesso aggiornare le informazioni in tempo reale, senza obbligare l’utente a ricaricare continuamente la pagina. Chat, notifiche, dashboard, giochi online, sistemi di monitoraggio e piattaforme collaborative sono solo alcuni esempi nei quali una comunicazione tradizionale basata esclusivamente su richieste HTTP può risultare poco efficiente.

In questi scenari entrano in gioco le WebSocket, una tecnologia che permette di mantenere una connessione persistente e bidirezionale tra client e server. Entrambe le parti possono inviare messaggi in qualsiasi momento, riducendo la latenza e il numero di richieste necessarie.

💡 In questa guida imparerai:

  • come funziona il protocollo WebSocket;
  • quali API Java utilizzare;
  • come creare un endpoint con Jakarta WebSocket;
  • come collegare un client JavaScript;
  • come realizzare una chat con più utenti;
  • come inviare messaggi JSON;
  • come gestire sicurezza, concorrenza e riconnessione;
  • come configurare un reverse proxy Nginx.

🔌 Cosa sono le WebSocket

WebSocket è un protocollo di comunicazione progettato per creare un canale persistente tra due sistemi, generalmente un browser e un server. Dopo l’apertura della connessione, client e server possono scambiarsi messaggi senza dover iniziare una nuova richiesta HTTP per ogni operazione.

La comunicazione è definita full-duplex: il client può inviare dati al server e il server può inviare dati al client in modo indipendente. Non è quindi necessario aspettare che il browser effettui una nuova richiesta prima di trasmettere un aggiornamento.

Una connessione WebSocket utilizza normalmente uno dei seguenti schemi:

  • ws:// per una connessione non cifrata;
  • wss:// per una connessione protetta tramite TLS.

In produzione è consigliabile utilizzare sempre wss://, così come per i normali siti web è consigliato utilizzare HTTPS.

⚠️ WebSocket non significa automaticamente “tempo reale perfetto”.

La velocità effettiva dipende anche dalla rete, dal server, dal carico dell’applicazione, dalla dimensione dei messaggi e dalla capacità del client di elaborarli.

⚖️ WebSocket, HTTP polling e Server-Sent Events

Le WebSocket non sono l’unica soluzione disponibile per aggiornare dinamicamente un’interfaccia. Prima di sceglierle è utile confrontarle con il polling HTTP e con le Server-Sent Events.

Tecnologia Direzione Connessione Utilizzo ideale
Polling HTTP Client → server Nuove richieste periodiche Aggiornamenti poco frequenti e implementazioni semplici
Long polling Prevalentemente server → client La richiesta resta aperta fino alla risposta Compatibilità con infrastrutture HTTP tradizionali
Server-Sent Events Server → client Persistente Notifiche, feed e aggiornamenti unidirezionali
WebSocket Bidirezionale Persistente Chat, giochi, collaborazione e interazioni frequenti

Se il server deve soltanto inviare occasionalmente dati al browser, le Server-Sent Events possono essere più semplici. Se invece entrambe le parti devono comunicare frequentemente, WebSocket rappresenta generalmente la soluzione più adatta.

🔄 Come funziona una connessione WebSocket

L’apertura di una WebSocket inizia con una normale richiesta HTTP. Il client chiede al server di effettuare un upgrade della connessione al protocollo WebSocket.

In forma semplificata, la richiesta contiene intestazioni simili alle seguenti:

GET /app/ws/chat HTTP/1.1
Host: esempio.it
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: chiave-generata-dal-client
Sec-WebSocket-Version: 13

Se il server accetta, risponde con lo stato 101 Switching Protocols. Da quel momento la connessione non viene più utilizzata come una normale sequenza di richieste e risposte HTTP, ma come un canale WebSocket.

Il ciclo di vita della connessione

Evento Descrizione Annotazione Jakarta
Apertura La connessione è stata accettata. @OnOpen
Messaggio Il client o il server ha inviato un messaggio. @OnMessage
Errore Si è verificato un problema durante la comunicazione. @OnError
Chiusura Una delle parti ha chiuso la connessione. @OnClose

Il protocollo permette di trasmettere messaggi testuali, dati binari e frame di controllo come ping, pong e close.

☕ Le API WebSocket disponibili in Java

In Java esistono due strumenti principali che non devono essere confusi.

Jakarta WebSocket

Jakarta WebSocket è l’API standard utilizzata nelle applicazioni Jakarta EE. Permette di creare sia endpoint server sia client WebSocket.

Le classi principali appartengono ai package:

  • jakarta.websocket;
  • jakarta.websocket.server.

È la scelta più naturale quando l’applicazione viene distribuita su un server compatibile con Jakarta EE o su un servlet container che supporta la specifica.

java.net.http.WebSocket

Il modulo standard java.net.http include un client WebSocket. È utile quando un’applicazione Java deve collegarsi a un server remoto, ma non fornisce un server WebSocket completo.

✅ Regola pratica

Usa Jakarta WebSocket per creare un endpoint server. Usa java.net.http.WebSocket quando ti serve soltanto un client Java leggero.

📦 Creare un progetto Maven con Jakarta WebSocket

Per questo esempio utilizzeremo un progetto web Maven distribuito come file WAR.

Una possibile struttura è la seguente:

websocket-demo/
├── pom.xml
└── src/
    └── main/
        ├── java/
        │   └── it/
        │       └── esempio/
        │           └── websocket/
        │               └── EchoEndpoint.java
        └── webapp/
            └── index.html

Nel file pom.xml aggiungiamo Jakarta WebSocket API:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         https://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>

    <groupId>it.esempio</groupId>
    <artifactId>websocket-demo</artifactId>
    <version>1.0.0</version>
    <packaging>war</packaging>

    <properties>
        <maven.compiler.release>17</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>

        <!--
            L'API è fornita dal server Jakarta EE o dal servlet container.
            Per questo motivo utilizziamo lo scope "provided".
        -->
        <dependency>
            <groupId>jakarta.websocket</groupId>
            <artifactId>jakarta.websocket-api</artifactId>
            <version>2.2.0</version>
            <scope>provided</scope>
        </dependency>

    </dependencies>

    <build>
        <finalName>websocket-demo</finalName>

        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-war-plugin</artifactId>
                <version>3.4.0</version>
                <configuration>
                    <failOnMissingWebXml>false</failOnMissingWebXml>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

Lo scope provided evita di inserire una seconda copia dell’API nel file WAR. L’implementazione concreta sarà fornita dal runtime sul quale viene distribuita l’applicazione.

🚀 Creare il primo endpoint WebSocket

Iniziamo con un semplice servizio echo: ogni messaggio ricevuto viene restituito al client.

package it.esempio.websocket;

import jakarta.websocket.CloseReason;
import jakarta.websocket.OnClose;
import jakarta.websocket.OnError;
import jakarta.websocket.OnMessage;
import jakarta.websocket.OnOpen;
import jakarta.websocket.Session;
import jakarta.websocket.server.ServerEndpoint;

import java.util.logging.Level;
import java.util.logging.Logger;

@ServerEndpoint("/ws/echo")
public class EchoEndpoint {

    private static final Logger LOGGER =
            Logger.getLogger(EchoEndpoint.class.getName());

    @OnOpen
    public void onOpen(Session session) {
        // Questo metodo viene chiamato quando un client si collega.
        LOGGER.info(() -> "Connessione aperta: " + session.getId());

        // Impostiamo un timeout di inattività di cinque minuti.
        session.setMaxIdleTimeout(300_000);
    }

    @OnMessage(maxMessageSize = 65_536)
    public void onMessage(String message, Session session) {
        LOGGER.info(() -> "Messaggio ricevuto: " + message);

        /*
         * getAsyncRemote() invia il messaggio senza bloccare
         * il thread durante l'intera trasmissione.
         */
        session.getAsyncRemote().sendText(
                "Il server ha ricevuto: " + message,
                result -> {
                    if (!result.isOK()) {
                        LOGGER.log(
                                Level.WARNING,
                                "Invio non riuscito",
                                result.getException()
                        );
                    }
                }
        );
    }

    @OnClose
    public void onClose(Session session, CloseReason reason) {
        LOGGER.info(() ->
                "Connessione chiusa: " + session.getId()
                + " - Motivo: " + reason
        );
    }

    @OnError
    public void onError(Session session, Throwable error) {
        String sessionId = session != null
                ? session.getId()
                : "non disponibile";

        LOGGER.log(
                Level.SEVERE,
                "Errore nella sessione " + sessionId,
                error
        );
    }
}

L’annotazione @ServerEndpoint definisce il percorso relativo al quale verrà pubblicato il servizio.

Se il file WAR viene distribuito con il nome websocket-demo, l’indirizzo locale sarà simile al seguente:

ws://localhost:8080/websocket-demo/ws/echo

In produzione, con HTTPS attivo, l’indirizzo dovrebbe utilizzare wss://:

wss://www.esempio.it/websocket-demo/ws/echo

🌐 Collegare un client JavaScript

I browser moderni espongono l’oggetto JavaScript WebSocket. Possiamo quindi creare una pagina molto semplice per testare il nostro endpoint Java.

<!DOCTYPE html>
<html lang="it">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Test WebSocket Java</title>
</head>
<body>

    <h1>Test WebSocket</h1>

    <p id="stato">Disconnesso</p>

    <input
        type="text"
        id="messaggio"
        placeholder="Scrivi un messaggio"
    >

    <button type="button" id="invia">Invia</button>

    <ul id="risposte"></ul>

    <script>
        /*
         * Se la pagina utilizza HTTPS, anche la WebSocket
         * deve utilizzare il protocollo sicuro WSS.
         */
        const protocollo =
            window.location.protocol === "https:" ? "wss" : "ws";

        const indirizzo =
            `${protocollo}://${window.location.host}` +
            "/websocket-demo/ws/echo";

        const stato = document.getElementById("stato");
        const input = document.getElementById("messaggio");
        const lista = document.getElementById("risposte");
        const pulsante = document.getElementById("invia");

        const socket = new WebSocket(indirizzo);

        socket.addEventListener("open", () => {
            stato.textContent = "Connesso";
            console.log("Connessione aperta");
        });

        socket.addEventListener("message", evento => {
            const elemento = document.createElement("li");
            elemento.textContent = evento.data;
            lista.appendChild(elemento);
        });

        socket.addEventListener("error", errore => {
            console.error("Errore WebSocket:", errore);
        });

        socket.addEventListener("close", evento => {
            stato.textContent =
                `Disconnesso - codice ${evento.code}`;
        });

        pulsante.addEventListener("click", () => {
            const testo = input.value.trim();

            if (!testo) {
                return;
            }

            /*
             * È importante controllare lo stato prima
             * di chiamare send().
             */
            if (socket.readyState === WebSocket.OPEN) {
                socket.send(testo);
                input.value = "";
            }
        });
    </script>

</body>
</html>

L’oggetto WebSocket può trovarsi in quattro stati:

  • WebSocket.CONNECTING: la connessione è in fase di apertura;
  • WebSocket.OPEN: la comunicazione è disponibile;
  • WebSocket.CLOSING: la chiusura è iniziata;
  • WebSocket.CLOSED: la connessione è chiusa.

💬 Creare una chat in tempo reale con Java

Un servizio echo comunica soltanto con il client che ha inviato il messaggio. In una chat dobbiamo invece inoltrare i messaggi a tutti gli utenti collegati alla stessa stanza.

L’endpoint seguente utilizza un percorso parametrico:

/ws/chat/{room}/{username}

Un utente potrebbe collegarsi, per esempio, a:

ws://localhost:8080/websocket-demo/ws/chat/generale/mario

Ecco una possibile implementazione:

package it.esempio.websocket;

import jakarta.websocket.CloseReason;
import jakarta.websocket.OnClose;
import jakarta.websocket.OnError;
import jakarta.websocket.OnMessage;
import jakarta.websocket.OnOpen;
import jakarta.websocket.Session;
import jakarta.websocket.server.PathParam;
import jakarta.websocket.server.ServerEndpoint;

import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
import java.util.logging.Logger;

@ServerEndpoint("/ws/chat/{room}/{username}")
public class ChatEndpoint {

    private static final Logger LOGGER =
            Logger.getLogger(ChatEndpoint.class.getName());

    /*
     * Ogni chiave rappresenta una stanza.
     * Ogni stanza contiene un insieme thread-safe di sessioni.
     */
    private static final ConcurrentHashMap<String, Set<Session>> ROOMS =
            new ConcurrentHashMap<>();

    @OnOpen
    public void onOpen(
            Session session,
            @PathParam("room") String room,
            @PathParam("username") String username
    ) {
        // Conserviamo i dati dell'utente nella sessione.
        session.getUserProperties().put("room", room);
        session.getUserProperties().put("username", username);

        ROOMS.computeIfAbsent(
                room,
                ignored -> ConcurrentHashMap.newKeySet()
        ).add(session);

        broadcast(
                room,
                createJson(
                        "join",
                        username,
                        username + " è entrato nella stanza"
                )
        );
    }

    @OnMessage(maxMessageSize = 8_192)
    public void onMessage(
            String message,
            Session session,
            @PathParam("room") String room,
            @PathParam("username") String username
    ) {
        String cleanMessage = message == null
                ? ""
                : message.trim();

        if (cleanMessage.isEmpty()) {
            return;
        }

        /*
         * In un'applicazione reale dovremmo applicare
         * controlli aggiuntivi, autorizzazioni e rate limiting.
         */
        broadcast(
                room,
                createJson("message", username, cleanMessage)
        );
    }

    @OnClose
    public void onClose(
            Session session,
            CloseReason closeReason,
            @PathParam("room") String room,
            @PathParam("username") String username
    ) {
        removeSession(room, session);

        broadcast(
                room,
                createJson(
                        "leave",
                        username,
                        username + " ha lasciato la stanza"
                )
        );

        LOGGER.info(() ->
                "Sessione chiusa: " + session.getId()
                + " - " + closeReason
        );
    }

    @OnError
    public void onError(Session session, Throwable error) {
        LOGGER.log(Level.WARNING, "Errore WebSocket", error);

        if (session == null) {
            return;
        }

        Object roomProperty =
                session.getUserProperties().get("room");

        if (roomProperty instanceof String room) {
            removeSession(room, session);
        }
    }

    private static void broadcast(String room, String message) {
        Set<Session> sessions =
                ROOMS.getOrDefault(room, Set.of());

        for (Session session : sessions) {
            if (!session.isOpen()) {
                removeSession(room, session);
                continue;
            }

            /*
             * L'invio asincrono evita che un client lento
             * blocchi tutti gli altri utenti della stanza.
             */
            session.getAsyncRemote().sendText(
                    message,
                    result -> {
                        if (!result.isOK()) {
                            LOGGER.log(
                                    Level.WARNING,
                                    "Messaggio non inviato",
                                    result.getException()
                            );
                        }
                    }
            );
        }
    }

    private static void removeSession(
            String room,
            Session session
    ) {
        Set<Session> sessions = ROOMS.get(room);

        if (sessions == null) {
            return;
        }

        sessions.remove(session);

        /*
         * Rimuoviamo la stanza dalla mappa quando
         * non contiene più connessioni.
         */
        if (sessions.isEmpty()) {
            ROOMS.remove(room, sessions);
        }
    }

    private static String createJson(
            String type,
            String username,
            String text
    ) {
        return "{"
                + "\"type\":\"" + escapeJson(type) + "\","
                + "\"username\":\"" + escapeJson(username) + "\","
                + "\"text\":\"" + escapeJson(text) + "\""
                + "}";
    }

    private static String escapeJson(String value) {
        if (value == null) {
            return "";
        }

        /*
         * Questa funzione è sufficiente per una demo.
         * In produzione è preferibile utilizzare JSON-P,
         * JSON-B o una libreria JSON consolidata.
         */
        return value
                .replace("\\", "\\\\")
                .replace("\"", "\\\"")
                .replace("\r", "\\r")
                .replace("\n", "\\n");
    }
}
🛑 Attenzione alla concorrenza

Il container crea normalmente un’istanza dell’endpoint per ogni connessione. Le variabili statiche, invece, sono condivise tra più connessioni e possono essere utilizzate contemporaneamente da thread differenti.

Per questo motivo la raccolta delle stanze e delle sessioni deve essere thread-safe.

🧩 Inviare oggetti e messaggi JSON

Una semplice stringa può essere sufficiente per una demo, ma le applicazioni reali utilizzano generalmente messaggi strutturati.

Un formato JSON potrebbe avere questa forma:

{
    "type": "message",
    "username": "mario",
    "text": "Ciao a tutti"
}

Jakarta WebSocket permette di definire encoder e decoder personalizzati. Un encoder converte un oggetto Java in un messaggio WebSocket, mentre un decoder compie l’operazione inversa.

Definiamo innanzitutto un record:

package it.esempio.websocket;

public record ChatMessage(
        String type,
        String username,
        String text
) {
}

Un encoder basato su Jakarta JSON Processing può essere scritto così:

package it.esempio.websocket;

import jakarta.json.Json;
import jakarta.websocket.EncodeException;
import jakarta.websocket.Encoder;
import jakarta.websocket.EndpointConfig;

public class ChatMessageEncoder
        implements Encoder.Text<ChatMessage> {

    @Override
    public String encode(ChatMessage message)
            throws EncodeException {

        return Json.createObjectBuilder()
                .add("type", safe(message.type()))
                .add("username", safe(message.username()))
                .add("text", safe(message.text()))
                .build()
                .toString();
    }

    private String safe(String value) {
        return value == null ? "" : value;
    }

    @Override
    public void init(EndpointConfig config) {
        // Nessuna inizializzazione necessaria.
    }

    @Override
    public void destroy() {
        // Nessuna risorsa da liberare.
    }
}

Il decoder può leggere la stringa JSON e creare un nuovo oggetto:

package it.esempio.websocket;

import jakarta.json.Json;
import jakarta.json.JsonObject;
import jakarta.json.JsonReader;
import jakarta.websocket.DecodeException;
import jakarta.websocket.Decoder;
import jakarta.websocket.EndpointConfig;

import java.io.StringReader;

public class ChatMessageDecoder
        implements Decoder.Text<ChatMessage> {

    @Override
    public ChatMessage decode(String text)
            throws DecodeException {

        try (
            JsonReader reader =
                    Json.createReader(new StringReader(text))
        ) {
            JsonObject json = reader.readObject();

            return new ChatMessage(
                    json.getString("type", ""),
                    json.getString("username", ""),
                    json.getString("text", "")
            );
        } catch (RuntimeException exception) {
            throw new DecodeException(
                    text,
                    "Messaggio JSON non valido",
                    exception
            );
        }
    }

    @Override
    public boolean willDecode(String text) {
        return text != null
                && text.stripLeading().startsWith("{");
    }

    @Override
    public void init(EndpointConfig config) {
        // Nessuna inizializzazione necessaria.
    }

    @Override
    public void destroy() {
        // Nessuna risorsa da liberare.
    }
}

Encoder e decoder devono essere dichiarati nell’annotazione dell’endpoint:

@ServerEndpoint(
    value = "/ws/json-chat",
    encoders = ChatMessageEncoder.class,
    decoders = ChatMessageDecoder.class
)
public class JsonChatEndpoint {

    @OnMessage
    public void onMessage(
            ChatMessage message,
            Session session
    ) {
        ChatMessage response = new ChatMessage(
                "response",
                "server",
                "Messaggio ricevuto da " + message.username()
        );

        /*
         * sendObject seleziona automaticamente l'encoder
         * compatibile con il tipo ChatMessage.
         */
        session.getAsyncRemote().sendObject(
                response,
                result -> {
                    if (!result.isOK()) {
                        result.getException().printStackTrace();
                    }
                }
        );
    }
}

Questo approccio separa la logica applicativa dalla serializzazione dei messaggi e rende il codice più semplice da mantenere.

🖥️ Creare un client WebSocket Java

Un’applicazione Java standalone può collegarsi a un endpoint utilizzando il client presente nel modulo java.net.http.

package it.esempio.client;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.time.Duration;
import java.util.concurrent.CompletionStage;

public class JavaWebSocketClient {

    public static void main(String[] args)
            throws Exception {

        HttpClient httpClient = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(10))
                .build();

        WebSocket socket = httpClient
                .newWebSocketBuilder()
                .connectTimeout(Duration.ofSeconds(10))
                .buildAsync(
                    URI.create(
                        "ws://localhost:8080/"
                        + "websocket-demo/ws/echo"
                    ),
                    new MessageListener()
                )
                .join();

        /*
         * Il secondo parametro indica che questo frammento
         * conclude il messaggio testuale.
         */
        socket.sendText("Ciao dal client Java", true)
                .join();

        // Manteniamo vivo il processo per ricevere le risposte.
        Thread.currentThread().join();
    }

    private static class MessageListener
            implements WebSocket.Listener {

        private final StringBuilder buffer =
                new StringBuilder();

        @Override
        public void onOpen(WebSocket webSocket) {
            System.out.println("Connessione aperta");

            /*
             * Il client richiede esplicitamente
             * il primo messaggio al server.
             */
            webSocket.request(1);
        }

        @Override
        public CompletionStage<?> onText(
                WebSocket webSocket,
                CharSequence data,
                boolean last
        ) {
            buffer.append(data);

            if (last) {
                System.out.println(
                        "Ricevuto: " + buffer
                );

                buffer.setLength(0);
            }

            /*
             * Chiediamo al client WebSocket di consegnare
             * anche il messaggio successivo.
             */
            webSocket.request(1);

            return null;
        }

        @Override
        public CompletionStage<?> onClose(
                WebSocket webSocket,
                int statusCode,
                String reason
        ) {
            System.out.println(
                    "Connessione chiusa: "
                    + statusCode
                    + " - "
                    + reason
            );

            return null;
        }

        @Override
        public void onError(
                WebSocket webSocket,
                Throwable error
        ) {
            error.printStackTrace();
        }
    }
}

La chiamata a request(1) è importante: indica quanti eventi il listener è pronto a elaborare. Questo meccanismo aiuta a evitare che il client riceva più dati di quanti ne possa gestire.

🔐 Sicurezza e autenticazione delle WebSocket

Una WebSocket aperta non deve essere considerata automaticamente affidabile. Ogni messaggio ricevuto proviene da un client esterno e deve essere validato.

Utilizzare sempre WSS in produzione

Il protocollo wss:// protegge la comunicazione tramite TLS. Evita che messaggi, cookie e dati di autenticazione possano essere letti facilmente durante il transito.

Autenticare l’utente durante l’handshake

Il browser non permette di aggiungere liberamente qualsiasi header alla connessione WebSocket. In un’applicazione web, l’autenticazione viene quindi gestita frequentemente mediante:

  • cookie di sessione sicuri;
  • sessione HTTP già autenticata;
  • token temporanei e con durata limitata;
  • subprotocollo WebSocket, quando appropriato.

È preferibile evitare token permanenti nella query string, perché gli URL possono finire nei log del server, nella cronologia o nei sistemi di monitoraggio.

Verificare l’header Origin

Un configuratore personalizzato può limitare le origini ammesse:

package it.esempio.websocket;

import jakarta.websocket.server.ServerEndpointConfig;

import java.util.Set;

public class SecureConfigurator
        extends ServerEndpointConfig.Configurator {

    private static final Set<String> ALLOWED_ORIGINS =
            Set.of(
                "https://www.esempio.it",
                "https://app.esempio.it"
            );

    @Override
    public boolean checkOrigin(String origin) {
        return origin != null
                && ALLOWED_ORIGINS.contains(origin);
    }
}

Il configuratore viene associato all’endpoint:

@ServerEndpoint(
    value = "/ws/private",
    configurator = SecureConfigurator.class
)
public class PrivateEndpoint {
    // Implementazione dell'endpoint.
}
⚠️ Il controllo Origin non sostituisce l’autenticazione.

Un client non-browser può modificare liberamente questo header. L’identità e i permessi dell’utente devono essere controllati separatamente.

Applicare l’autorizzazione a ogni operazione

Anche dopo aver autenticato l’utente, il server deve verificare che possa realmente:

  • entrare nella stanza richiesta;
  • leggere un determinato canale;
  • inviare messaggi;
  • modificare una risorsa;
  • eseguire un’azione amministrativa.

Limitare la dimensione dei messaggi

Messaggi molto grandi possono consumare memoria e risorse. È possibile impostare una dimensione massima direttamente nell’annotazione:

@OnMessage(maxMessageSize = 65_536)
public void onMessage(String message) {
    // Massimo 64 KiB per questo handler.
}

Applicare rate limiting

Un singolo client non dovrebbe poter inviare migliaia di messaggi al secondo. È opportuno introdurre limiti per:

  • utente;
  • indirizzo IP;
  • sessione;
  • stanza o canale;
  • tipo di operazione.

⚙️ Prestazioni, thread e scalabilità

RemoteEndpoint.Basic e RemoteEndpoint.Async

Metodo Comportamento Quando usarlo
getBasicRemote() Invio sincrono e potenzialmente bloccante Risposte brevi e controllate
getAsyncRemote() Invio asincrono Broadcast, molti utenti o client potenzialmente lenti

In un’applicazione con numerosi utenti è generalmente preferibile utilizzare l’invio asincrono. Un client lento non dovrebbe bloccare il thread che gestisce tutti gli altri utenti.

Non eseguire operazioni lente nell’endpoint

All’interno dei metodi annotati è preferibile evitare:

  • query al database molto lente;
  • chiamate HTTP sincrone verso servizi esterni;
  • elaborazioni CPU particolarmente pesanti;
  • attese artificiali con Thread.sleep();
  • lettura di file di grandi dimensioni.

Queste operazioni dovrebbero essere delegate a servizi applicativi, code di messaggi o executor gestiti dal container.

Scalare su più server

Una mappa statica di sessioni funziona soltanto all’interno della stessa istanza Java. Se l’applicazione viene distribuita su più server, ogni nodo conoscerà esclusivamente le proprie connessioni.

Per distribuire i messaggi tra più nodi è possibile utilizzare un sistema esterno di pubblicazione e sottoscrizione, per esempio:

  • un message broker;
  • un sistema publish/subscribe;
  • una piattaforma di streaming;
  • un servizio distribuito per gli eventi.

Ogni server riceve l’evento dal sistema condiviso e lo inoltra alle proprie sessioni WebSocket locali.

Gestire le connessioni inattive

Proxy, firewall e bilanciatori possono chiudere connessioni che restano inattive per molto tempo. Un sistema di heartbeat basato su ping e pong permette di verificare che il collegamento sia ancora utilizzabile.

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;

public void sendPing(Session session) {
    if (!session.isOpen()) {
        return;
    }

    ByteBuffer payload = ByteBuffer.wrap(
            "heartbeat".getBytes(StandardCharsets.UTF_8)
    );

    try {
        session.getAsyncRemote().sendPing(payload);
    } catch (IOException exception) {
        exception.printStackTrace();
    }
}

La pianificazione periodica del ping dovrebbe essere gestita tramite gli strumenti di concorrenza offerti dal runtime, evitando la creazione incontrollata di thread manuali.

🔁 Riconnessione automatica del client

Una connessione WebSocket può interrompersi per molte ragioni: perdita di rete, sospensione del computer, riavvio del server, timeout del proxy o aggiornamento dell’applicazione.

Il client dovrebbe quindi implementare una riconnessione controllata, preferibilmente con attesa progressiva.

let socket;
let tentativo = 0;
let chiusuraVolontaria = false;

function connetti() {
    const protocollo =
        window.location.protocol === "https:" ? "wss" : "ws";

    const url =
        `${protocollo}://${window.location.host}` +
        "/websocket-demo/ws/echo";

    socket = new WebSocket(url);

    socket.addEventListener("open", () => {
        console.log("WebSocket collegata");

        // Una connessione riuscita azzera il contatore.
        tentativo = 0;
    });

    socket.addEventListener("message", evento => {
        console.log("Messaggio:", evento.data);
    });

    socket.addEventListener("close", evento => {
        console.log(
            "Connessione chiusa:",
            evento.code,
            evento.reason
        );

        if (!chiusuraVolontaria) {
            pianificaRiconnessione();
        }
    });

    socket.addEventListener("error", errore => {
        console.error("Errore WebSocket:", errore);
    });
}

function pianificaRiconnessione() {
    /*
     * Attesa esponenziale con limite massimo di 30 secondi.
     * 1s, 2s, 4s, 8s, 16s, 30s...
     */
    const ritardo = Math.min(
        1000 * Math.pow(2, tentativo),
        30000
    );

    tentativo++;

    console.log(
        `Nuovo tentativo tra ${ritardo} ms`
    );

    setTimeout(connetti, ritardo);
}

function disconnetti() {
    chiusuraVolontaria = true;

    if (
        socket
        && socket.readyState === WebSocket.OPEN
    ) {
        socket.close(
            1000,
            "Chiusura richiesta dall'utente"
        );
    }
}

connetti();

È importante non riconnettersi continuamente senza attesa. In caso di indisponibilità del server, migliaia di client potrebbero generare un’ondata di tentativi contemporanei.

🛡️ Configurare WebSocket dietro Nginx

Quando l’applicazione Java si trova dietro Nginx, il proxy deve inoltrare correttamente gli header necessari all’upgrade della connessione.

# Questo blocco "map" deve trovarsi nel contesto http.
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 443 ssl;
    server_name www.esempio.it;

    # Configurazione dei certificati TLS.
    ssl_certificate     /percorso/certificato.pem;
    ssl_certificate_key /percorso/chiave.pem;

    location /websocket-demo/ {

        # Server Java interno.
        proxy_pass http://127.0.0.1:8080;

        # WebSocket richiede il supporto HTTP/1.1 verso il backend.
        proxy_http_version 1.1;

        # Header necessari per l'upgrade.
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;

        # Conserviamo le informazioni originali della richiesta.
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Aumentiamo il timeout per le connessioni persistenti.
        proxy_read_timeout 300s;
        proxy_send_timeout 300s;
    }
}

Se il collegamento funziona direttamente sulla porta del server Java ma non attraverso il dominio pubblico, il problema si trova spesso nella configurazione del reverse proxy o del bilanciatore.

🧰 Errori comuni e possibili soluzioni

Problema Possibile causa Soluzione
404 durante la connessione Percorso o context path errato Verificare il nome del WAR e il valore di @ServerEndpoint
Errore durante l’handshake Header Upgrade non inoltrati Controllare proxy, CDN e bilanciatore
Mixed Content nel browser Pagina HTTPS collegata a ws:// Utilizzare wss://
La connessione cade dopo alcuni minuti Timeout del proxy o della rete Configurare heartbeat e timeout
Messaggi duplicati Più connessioni aperte dal client Chiudere la sessione precedente prima di riconnettersi
Consumo crescente di memoria Sessioni non rimosse dalle raccolte Pulire le strutture in @OnClose e @OnError
ClassNotFoundException su javax.websocket Codice precedente alla migrazione Jakarta Sostituire gli import javax.websocket con jakarta.websocket e usare dipendenze compatibili

🎯 Quando utilizzare le WebSocket

Le WebSocket sono particolarmente indicate per applicazioni nelle quali client e server devono scambiarsi frequentemente informazioni con una latenza ridotta.

Utilizzi consigliati

  • chat individuali o di gruppo;
  • notifiche istantanee;
  • dashboard operative aggiornate continuamente;
  • monitoraggio di dispositivi e infrastrutture;
  • giochi multiplayer;
  • aste online;
  • editor collaborativi;
  • tracciamento in tempo reale;
  • aggiornamento dello stato di ordini o consegne;
  • sistemi di assistenza live.

Quando una normale API HTTP è sufficiente

Non è necessario utilizzare WebSocket per qualsiasi funzionalità dinamica. Una normale API REST rimane spesso più semplice per:

  • creare, leggere, modificare o eliminare risorse;
  • caricare file;
  • inviare moduli;
  • eseguire operazioni occasionali;
  • ottenere dati che cambiano raramente;
  • implementare comunicazioni senza stato persistente.

Molte architetture utilizzano entrambe le tecnologie: HTTP per le operazioni tradizionali e WebSocket per notifiche e aggiornamenti immediati.

✅ Conclusioni

Jakarta WebSocket permette di integrare comunicazioni bidirezionali nelle applicazioni Java attraverso un modello basato su endpoint, sessioni e annotazioni. Con poche classi è possibile gestire apertura, ricezione dei messaggi, errori e chiusura della connessione.

La semplicità dell’API non deve però far dimenticare gli aspetti necessari per un utilizzo in produzione. Una soluzione robusta deve prevedere autenticazione, autorizzazione, connessioni cifrate, validazione dei messaggi, limiti di dimensione, rate limiting, gestione delle sessioni inattive e invio asincrono.

Quando l’applicazione cresce, bisogna inoltre considerare la distribuzione degli eventi tra più server. Le sessioni WebSocket sono locali al nodo che le gestisce, quindi il broadcasting su un ambiente scalato richiede generalmente un sistema condiviso di messaggistica o pubblicazione degli eventi.

🚀 Percorso consigliato

  1. iniziare con un endpoint echo;
  2. collegare un semplice client JavaScript;
  3. aggiungere stanze e sessioni concorrenti;
  4. strutturare i messaggi con JSON;
  5. implementare autenticazione e autorizzazione;
  6. aggiungere heartbeat e riconnessione;
  7. testare il comportamento con più utenti e più nodi.

📖 Riferimenti tecnici

“`

Categorie: J2EEJava