Nell’articolo WebSocket in Java abbiamo introdotto la specifica JSR 356, che definisce le API disponibili in Java per implementare una WebSocket ed il relativo client. In questo post vediamo invece come Spring supporta il protocollo delle WebSocket (definito nella specifica RFC 6455), ed in particolare lo faremo convertendo l’esempio del Bingo, introdotto nell’articolo citato sopra, in una applicazione Spring Boot.
HTTP vs WebSocket
Sebbene il protocollo WebSocket sia progettato per essere compatibile con quello HTTP e di fatto ha inizio con una richiesta HTTP, è importante comprendere che i due protocolli implicano modelli di programmazione molto differenti.
In HTTP (e REST), le applicazioni sono strutturate esponendo diversi URL, tipicamente una per ciascuna operazione implementata dall’applicazione. I client accendono a tali URL, inviando una richiesta ed attendendo una risposta, quindi la connessione viene chiusa ed una nuova richiesta deve essere inviata per invocare una nuova operazione.
Al contrario, una applicazione che utilizza le WebSocket, di solito espongono una solo un URL per la connessione iniziale. Successivamente, tutti i messaggi dell’applicazione transitano sulla stessa connessione TCP. Ciò indica un’architettura di messaggistica asincrona, guidata dagli eventi, completamente diversa.
WebSocket inoltre è un protocollo di trasporto di basso livello che, a differenza di HTTP, non specifica alcuna semantica al contenuto dei messaggi. Ciò significa che non è possibile instradare o elaborare un messaggio a meno che il client e il server non concordino sulla semantica del messaggio. I client e i server possono però negoziare l’uso di un protocollo di messaggistica di livello superiore (come ad esempio, STOMP), tramite l’intestazione Sec-WebSocket-Protocol
sulla richiesta di handshake HTTP. In mancanza di ciò, devono elaborare le proprie convenzioni.
Il Progetto
Come anticipato, nell’articolo eseguiremo il porting dell’applicazione Bingo presentata nel post WebSocket in Java. La figura seguente riepilogo le componenti che sono coinvolte nel progetto.
La componente Bingo Server è un’applicazione Spring Boot che espone la WebSocket all’url ws://localhost:8080/bingo/host/websocket
. Si noti che il suffisso websocket
alla URL base è inserito da framework Spring. Il tabellone riepilogativo dei numeri estratti è invece un semplice file index.html
che utilizza la classe WebSocket
offerta da HTML5 per ascoltare gli eventi del server di gioco. Infine i client rappresentano i giocatori che generano le proprie cartelle di gioco, formate da 5 numeri, e restano in ascolto delle estrazioni comunicando l’eventuale evento BINGO al server.
Nel seguito tralasceremo la componente index.html
, sufficientemente descritta in WebSocket in Java.
Bingo Server
Come anticipato il server è un’applicazione Spring Boot che possiamo generare utilizzando lo Spring Boot Initializr, o semplicemente creando una applicazione Maven ed inserendo le necessarie dipendenze, che sono: spring-boot-starter-
websocket
e tomcat-embed-websocket
. Il file pom.xml
risulta quindi molto snello:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
<?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.javaboss</groupId> <artifactId>jbwsocket</artifactId> <version>0.0.1-SNAPSHOT</version> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.1.5.RELEASE</version> <relativePath /> <!-- lookup parent from repository --> </parent> <properties> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-websocket</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project> |
Gestione del Tablou
Il tablou di gioco è gestito attraverso un oggetto Tablou
che estende ArrayList
ridefinendo il metodo next()
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
public class Tablou extends ArrayList<Integer> { private Random random = new Random( Calendar.getInstance().getTimeInMillis() ); synchronized public Integer next() { Integer next = 0; do { next = random.nextInt( 90 ) + 1; } while ( contains( next ) ); add( next ); return next; } synchronized public boolean isCompleted() { return super.size() == 90; } } |
La classe TablouService
è responsabile invece della gestione delle estrazioni, che sono eseguite al ritmo di una al minuto. Per la schedulazione sono utilizzate le API disponibili in Spring, in particolare il framework mette a disposizione l’annotazione @Scheduled
con cui è possibile annotare un qualsiasi bean gestito dal container IoC. Per abilitare l’utilizzo dello scheduler di Spring è innanzitutto necessario definire una classe di configurazione ed annotarla con @EnableScheduling
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
@Configuration @EnableScheduling public class SchedulerConfig implements SchedulingConfigurer { public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { taskRegistrar.setScheduler( taskExecutor() ); } @Bean(destroyMethod="shutdown") public Executor taskExecutor() { return Executors.newScheduledThreadPool(3); } @Bean public TablouService tablouService() { return new TablouService(); } } |
Di fatto l’implementazione dell’interfaccia SchedulingConfigurer
e la ridefinizione del metodo configureTasks()
, non sarebbe necessaria, se non fosse che in qualche modo lo sheduler di default sembra andare in conflitto con qualche altro componente, generando l’eccezione:
Bean named 'defaultSockJsTaskScheduler' is expected to be of type 'org.springframework.scheduling.TaskScheduler' but was actually of type 'org.springframework.beans.factory.support.NullBean'
Altra particolarità della classe TablouService
è che la comunicazione degli eventi di estrazione è eseguita in modo asincrono. Nell’articolo originale (WebSocket in Java) questi sono gestiti utilizzando la gestione degli eventi disponibili in CDI. In questo porting si utilizzeranno gli eventi di Spring, il cui utilizzo prevede poche semplici linee guida:
- gli eventi sono oggetti che estendono la classe
ApplicationEvent
. - il publisher emette gli eventi attraverso la classe
ApplicationEventPublisher
disponibile nel contesto di Spring ed iniettabile attraverso l’annotazione@Autowired
. - l listener degli eventi devono implementare l’interfaccia
ApplicationListener
.
Procediamo quindi definendo le tre classi che rappresentano i tre eventi gestiti dall’applicazione ovvero: l’avvio di una sessione di gioco, l’estrazione di un numero e il bingo da parte di uno dei partecipanti.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
public class StartEvent extends ApplicationEvent{ public StartEvent(Object source) { super(source); } } public class DrawEvent extends ApplicationEvent { private Integer number; public DrawEvent(Object source, Integer number) { super(source); this.number = number; } public Integer getNumber() { return number; } } public class BingoEvent extends ApplicationEvent { public BingoEvent(Object source) { super(source); } } |
In particolare la classe TablouService
emette gli eventi StartEvent
e DrawEvent
ed è in ascolto dell’evento BingoEvent
al fine di interrompere l’estrazione. In ragione di ciò la classe implementa l’interfaccia ApplicationListener<BingoEvent>
che definisce il metodo onApplicationEvent(BingoEvent event)
invocato dal container quando l’evento bingo è emesso. La figura seguente mostra il flusso degli eventi nell’applicazione.
La classe TablouService
ha quindi il seguente aspetto:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
public class TablouService implements ApplicationListener<BingoEvent> { private static final Log log = LogFactory.getLog( TablouService.class ); private AtomicBoolean bingo = new AtomicBoolean( false ); private Tablou tablou = new Tablou(); @Autowired private ApplicationEventPublisher applicationEventPublisher; @Scheduled( initialDelay = 10000, fixedDelay = 60000 ) public void start() { log.info( "Started bingo session" ); tablou.clear(); bingo.set( false ); applicationEventPublisher.publishEvent( new StartEvent(this) ); Integer number; do { number = tablou.next(); log.info( "Extracted: " + number ); applicationEventPublisher.publishEvent( new DrawEvent( this, number ) ); try { Thread.sleep( 250 ); } catch (InterruptedException e) { log.error( "Sleep interrupted", e ); } } while ( !tablou.isCompleted() && !bingo.get() ); log.info( "Stopped bingo session" ); } @Override public void onApplicationEvent(BingoEvent event) { bingo.set( true ); } } |
Gestione delle Connessioni
Veniamo all’argomento centrale di questo articolo, la gestione di una WebSocket. La prima cosa che è necessario fare è abilitare nel progetto l’utilizzo delle WebSocket di Spring e definire l’handler che gestirà il ciclo di vita della comunicazione con i client. A tale scopo è sufficiente definire una classe di configurazione che implementa l’interfaccia WebSocketConfigurer
e che è annotata con @EnableWebSocket
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
@Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { private static final Log log = LogFactory.getLog( WebSocketConfig.class ); @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { log.info( "Registering ws bingo handler "); registry.addHandler(bingotHandler(), "/host") .setAllowedOrigins("*") // Avoid 403 .withSockJS(); } @Bean public WebSocketHandler bingotHandler() { return new BingoWebSocketHandler(); } } |
Nel metodo registerWebSocketHandlers()
viene registrato l’handler WebSocketHandler
associandolo alla path /host
. Questo comporta che la URL della socket sarà ws://localhost:8080/bingo/host/websocket
, dove il contesto /bingo
è definito nel file application.properties
letto da Spring Boot, mentre /websocket
è inserito dalle WebSochet API di Spring. Inoltre il metodo setAllowedOrigins()
valorizzato con *
consente l’invocazione della websocket da parte di una qualsiasi origine, molto importante per la nostra applicazione di esempio al fine di evitare un errore HTTP 403 Forbidden, che sarebbe restituito dal server essendo i due client non pubblicati su web.
Infine definire l’handler di una WebSocket significa implementare l’interfaccia WebSocketHandler
che definisce i seguenti metodi:
afterConnectionEstablished() |
Invocato quando la connessione WebSocket aperta e pronto per l’uso. |
afterConnectionClosed() |
Invocata quando la connessione WebSocket è stata chiusa da entrambi i lati o dopo che si è verificato un errore di trasporto. |
handleMessage() |
Invocata quando arriva un nuovo messaggio dalla connessione WebSocket. |
handleTransportError() |
Gestisce un eventuale errore di trasporto del messaggio nella WebSocket sottostante. |
supportsPartialMessages() |
indica se l’handler è in grado di gestire messaggi parziali. |
Molto più probabilmente però l’handler andrà ad estendere le classi BinaryWebSocketHandler
o TextWebSocketHandler
, che sono classi che ammettono esclusivamente messaggi di tipo binario, nel primo caso, o testo, nel secondo. In particolare l’handler BingoWebSocketHandler
utilizzato nella nostra applicazione di esempio estende TextWebSocketHandler
, e si presenta quindi nel seguente modo:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
public class BingoWebSocketHandler extends TextWebSocketHandler implements ApplicationListener<ApplicationEvent> { private static final Log log = LogFactory.getLog( BingoWebSocketHandler.class ); private static Map<String, WebSocketSession> sessions = new HashMap<String, WebSocketSession>(); @Autowired private ApplicationEventPublisher applicationEventPublisher; //--------------------------------------------------------------------------------------- // Methods managing WebSocket lifecycle //--------------------------------------------------------------------------------------- public void handleTextMessage(WebSocketSession session, TextMessage message) { if ( message.getPayload().equalsIgnoreCase("bingo") ) { log.info( "Bingo from player id " + session.getId() ); applicationEventPublisher.publishEvent( new BingoEvent( this ) ); sendToAll( "bingo" ); } } public void afterConnectionEstablished(WebSocketSession session) throws Exception { log.info( "New player id " + session.getId() ); synchronized (sessions) { sessions.put( session.getId(), session ); try { session.sendMessage( new TextMessage( "A game every minute ... please wait" ) ); } catch (IOException e) { log.error( "Error sending greetings", e ); } } } public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { log.info( "Abandoned player id " + session.getId() ); synchronized (sessions) { sessions.remove( session.getId() ); } } public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { log.error( exception.getMessage() ); } //--------------------------------------------------------------------------------------- // Methods managing bingo events //--------------------------------------------------------------------------------------- public void onApplicationEvent(ApplicationEvent event) { if ( event instanceof DrawEvent ) { sendToAll( ((DrawEvent) event).getNumber().toString() ); } else if ( event instanceof StartEvent ) { sendToAll( "start" ); } } //--------------------------------------------------------------------------------------- // Methods managing communication to the clients //--------------------------------------------------------------------------------------- private void sendToAll( String message ) { try { if ( sessions != null && !sessions.isEmpty() ) { synchronized (sessions) { for ( WebSocketSession session : sessions.values() ) { if ( session.isOpen() ) { session.sendMessage( new TextMessage( message ) ); } } } } } catch (IOException e) { log.error("Error sending message", e); } } } |
Si noti che, come anticipato precedentemente, la classe riceve e pubblica diversi eventi, per tale motivo:
- implementa l’interfaccia
ApplicationListener
ed il metodoonApplicationEvent()
dove gestisce i due eventiDrawEvent
eStartEvent
; - si inietta la classe
ApplicationEventPublisher
attraverso la quale pubblica l’eventoBingoEvent
.
Client Java
E’ interessante notare che Spring utilizza la stessa gerarchia di classi: WebSocketHandler
, BinaryWebSocketHandler
e TextWebSocketHandler
, per la implementazione l’handler di una connessione WebSocket lato client. Nel caso però di una applicazione Java standard è necessario utilizare un oggetto WebSocketConnectionManager
che, a partire dall’handler e da un client WebSocket standard, gestisce la comunicazione su canale.
Implementiamo quindi la classe BingoClient
, che estende TextWebSocketHandler
, la quale genera una cartella di gioco ad ogni avvio di una nuova estrazione. Quindi verifica i numeri ricevuti dalla WebSocket fino all’eventuale ottenimento del bingo che comunica tempestivamente al server.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
public class BingoPlayer extends TextWebSocketHandler { private static Random random = new Random( Calendar.getInstance().getTimeInMillis() ); private int id; private Set<String> card = new HashSet<String>(); private int found = 0; private boolean started = false; public BingoPlayer(){} public BingoPlayer(int number ) { this.id = number; } public void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { if ( message.getPayload().equalsIgnoreCase( "start" ) ) { /* Generate the card */ card.clear(); for ( int i = 0; i < 5; i++ ) { card.add( String.valueOf( random.nextInt( 90 ) + 1 ) ); } found = 0; started = true; System.out.println( "Player " + id + ": " + card ); } else if ( started && card.contains( message.getPayload() ) ) { System.out.println( "Player " + id + ": found " + message.getPayload() ); found++; if ( found == card.size() ) { System.out.println( "Player " + id + ": BINGO !!!" ); session.sendMessage( new TextMessage( "bingo" ) ); } } else if ( started && message.getPayload().equalsIgnoreCase( "bingo" ) ) { started = false; } session.sendMessage( new TextMessage( "ok" ) ); } } |
Infine la classe BingoClient
espone il metodo main che utilizza il WebSocketConnectionManager
per connettere il giocatore al server di gioco:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
public class BingoClient { public static void main(String[] args) throws DeploymentException, IOException, URISyntaxException { String webSocketServerUrl = "ws://localhost:8080/bingo/host/websocket"; Set<WebSocketConnectionManager> connectionManagers = new HashSet<WebSocketConnectionManager>(); for ( int i = 1; i <= 2; i++ ) { WebSocketConnectionManager connectionManager = new WebSocketConnectionManager( new StandardWebSocketClient(), new BingoPlayer( i ), webSocketServerUrl, args); connectionManagers.add( connectionManager ); connectionManager.start(); } Scanner input = new Scanner(System.in); input.next(); for ( WebSocketConnectionManager connectionManager : connectionManagers ) { connectionManager.stop(); } } } |
Esecuzione
Il server di gioco, essendo una applicazione Spring Boot, si avvia con il comando Maven: mvn clean spring-boot:run
. Il client è un’applicazione java quindi eseguibile nel modo usuale. Il tabellone è un file html quindi è sufficiente aprirlo sul browser.
Le due immagini seguenti mostrano un round di gioco con due giocatori ed il tablou colorato con i numeri estratti sino al bingo.
Il Codice Sorgente
Il codice sorgente del progetto è scaricabile qui jbwsocket.