5. WebSocket
Hasta el momento hemos visto los servlets como un componente web que encapsula el mecanismo petición/respuesta definido en el protocolo HTTP. Sin embargo, en muchas ocasiones nos interesa mantener un canal de comunicación abierto con el servidor, para así poder recibir información del mismo sin tener que interrogarlo mediante peticiones continuamete.
Pongamos por ejemplo el caso de una aplicación de chat, en la que el cliente debe estar pendiente de actualizar el listado de mensajes cuando otros usuarios realicen una publicación. Con protocolo HTTP tendríamos que estar continuamente realizando peticiones (por ejemplo mediante AJAX) para comprobar si hay nuevos mensajes, y en tal caso actualizar la lista. Si la frecuencia de peticiones es alta estaremos malgastando ancho de banda, pero si es baja tendremos un mayor retraso en la actualización de los mensajes. Este es un escenario donde es conveniente utilizar WebSocket en lugar de HTTP.
5.1. WebSocket en Java EE
La API JSR 356 (Java API for WebSocket) nos permite crear endpoints WebSocket dentro de una aplicación web Java EE. Esta API se define dentro del paquete javax.websocket
, cuya clase principal es Endpoint
, la cual nos permitirá crear un endpoint de tipo WebSocket, aunque también podremos crear endpoints añadiendo anotaciones a cualquier clase Java.
5.1.1. Endpoints programados
Se crean mediante una subclase de Endpoint
, en la que tendremos que sobrescribir los métodos de su ciclo de vida:
-
onOpen
: Es el único método obligatorio de implementar. Indica que se ha abierto el canal de comunicación. -
onClose
: Indica que se ha cerrado el canal de comunicación. -
onError
: Indica un error en la conexión
public class MiEndpoint extends Endpoint { (1)
@Override
public void onOpen(final Session session, EndpointConfig config) { (2)
session.addMessageHandler(new MessageHandler.Whole<String>() { (3)
@Override
public void onMessage(String msg) { (4)
// Interactuar con el objeto session para intercambiar datos
...
}
});
}
}
1 | La clase debe heredar de Endpoint |
2 | Debemos sobrescribir de forma obligatoria al menos el método onOpen |
3 | Normalmente definiremos un manejador de mensajes sobre la sesión (MessageHandler ) |
4 | En el manejador de mensajes debemos definir el método onMessage que nos notifica la llegada de un mensaje |
Todos los métodos anteriores reciben como parámetro un objeto Session
que representa la conversación con el cliente. Normalmente definiremos un MessageHandler
sobre la sesión para gestionar el intercambio de mensajes. Este manejador nos permite definir un método onMessage
que nos notificará la llegada de un mensaje desde el cliente.
El despliegue de este tipo de endpoints se hace también de forma programada. Para ello deberemos introducir el siguiente código:
ServerEndpointConfig.Builder.create(MiEndpoint.class, "/socket").build();
Con esto el endpoint quedará publicado en la siguiente dirección:
ws://localhost:8080/miaplicacion/socket
5.1.2. Endpoints mediante anotaciones
Una forma más sencilla de crear endpoints consiste en hacerlo mediante anotaciones. Podemos crear un endpoint equivalente al anterior mediante anotaciones con el siguiente código:
@ServerEndpoint("/socket") (1)
public class EchoEndpoint {
@OnMessage (2)
public void onMessage(Session session, String msg) {
// Interactuar con el objeto session para intercambiar datos
...
}
}
1 | Un endpoint se declara con la anotación ServerEndpoint , que además nos permite especificar la dirección de despliegue |
2 | El método onMessage se puede definir mediante una anotación directamente. No es necesario crear un manejador de mensajes. |
Lo más destacable es que ya no es necesario realizar el despliegue de forma programada. Con añadir la anotación ServetEndpoint
y como atributo suyo la dirección de despliegue el endpoint quedará desplegado en el servidor.
También es importante destacar que disponemos de las siguientes anotaciones para los métodos:
Anotación | Descripción |
---|---|
|
Se llama al abrirse el canal de datos |
|
Se llama al cerrarse el canal de datos |
|
Se llama al producirse un error en la comunicación |
|
Se llama al recibirse un mensaje |
A continuación se muestra un ejemplo de endpoint con los 4 tipos de métodos:
@ServerEndpoint("/socket")
public class EchoEndpoint {
@OnOpen
public void open(Session session,
EndpointConfig conf) {
}
@OnClose
public void close(Session session,
CloseReason reason) {
}
@OnError
public void error(Session session,
Throwable error) {
}
@OnMessage
public void onMessage(Session session,
String msg) {
}
}
Podemos observar que en todos ellos siempre se proporciona como primer parámetro el objeto Session
. El segundo parámetro dependerá del método.
5.1.3. Mantenimiento del estado
Al contrario que los servlets, los endpoints WebSocket se instancian una vez por cada cliente conectado. De esta forma podemos utilizar la misma clase del endpoint para mantener el estado del cliente. Tenemos dos formas de guardar datos de estado:
-
Utilizar variables de instancia de la clase que implementa el endpoint.
-
Utilizar el mapa
session.getUserProperties()
para guardar la información en forma de parejas <clave, valor>.
5.2. Intercambio de mensajes
Vamos a pasar a estudiar la forma de intercambiar información mediante WebSocket. Las operaciones básicas de intercambio de datos que encontramos en WebSocket son:
-
Envío de datos (de texto o binarios)
-
Ping: Mensaje de control, puede contener datos
-
Pong: Respuesta al ping, puede contener datos
5.2.1. Envío de mensajes
Para enviar un mensaje necesitaremos siempre un objeto Session
. Todos los métodos anotados nos proporcionan este objeto como parámetro, por lo que al recibir un mensaje de entrada en onMessage
podremos responder utilizando el objeto Session
que proporciona como parámetro. En caso de que necesitemos enviar mensajes que no sean respuesta a un mensaje de entrada deberemos guardar el objeto Session
en una variable de instancia de nuestra clase en onOpen
para así tenerlo disponible en cualquier momento.
A partir del objeto Session
deberemos obtener un objeto RemoteEndpoint
para enviar el mensaje al endpoint remoto. Encontramos dos tipos de RemoteEndpoint
:
-
Básico: Se define mediante la clase
RemoteEndpoint.Basic
, y se obtiene consession.getRemoteBasic()
. -
Asíncrono: Se define mediante la clase
RemoteEndpoint.Async
, y se obtiene consession.getRemoteAsync()
. Permite realizar las operaciones de forma no bloqueante.
Contamos con las siguientes operaciones para intercambiar datos:
Operación | Descripción |
---|---|
sendText(String) |
Envia un mensaje de texto al endpoint remoto de forma bloqueante o no bloqueante (según el tipo de |
sendBinary(ByteBuffer) |
Envia un mensaje binario al endpoint remoto de forma bloqueante o no bloqueante (según el tipo de |
sendPing(ByteBuffer) |
Envía un ping al endpoint remoto |
sendPong(ByteBuffer) |
Contesta con un pong al endpoint remoto |
Por ejemplo, podremos enviar un mensaje de texto de la siguiente forma:
@ServerEndpoint("/socket")
public class MiEndpoint {
@OnMessage
public void onMessage(Session session, String msg) {
session.getBasicRemote().sendText("Recibido " + msg); (1)
}
}
1 | Envía un mensaje de texto al endpoint remoto. |
Podemos también enviar un mensaje a todos los endpoints remotos conectados actualmente a nuestro endpoint. Para ello podemos utilizar el método getOpenSessions
del objeto Session
, que nos proporciona la lista de todas las sesiones abiertas. Esto será útil por ejemplo en una aplicación de chat, en la que al recibir un mensaje de un cliente deberemos difundirlo a todos:
@ServerEndpoint("/chat")
public class ChatEndpoint {
@OnMessage
public void onMessage(Session session, String msg) {
try {
for (Session s : session.getOpenSessions()) {
if (s.isOpen())
s.getBasicRemote().sendText(msg);
}
} catch (IOException e) { }
}
}
5.2.2. Recepción de mensajes
Podemos recibir tres tipos de mensajes, según el tipo de parámetros del método etiquetado como @OnMessage
:
-
Mensajes de texto (
String
,Reader
) -
Mensajes binarios (
byte[]
,ByteBuffer
,InputStream
) -
Mensajes pong (
PongMessage
)
A continuación se muestra un ejemplo de endpoint que puede recibir los tres tipos de mensajes:
@ServerEndpoint("/socket")
public class ReceiveEndpoint {
@OnMessage
public void texto(Session session, String msg) { (1)
}
@OnMessage
public void binario(Session session, ByteBuffer msg) { (2)
}
@OnMessage
public void pong(Session session, PongMessage msg) { (3)
}
}
1 | Recepción de un mensaje de texto |
2 | Recepción de un mensaje binario |
3 | Recepción de una respuesta pong |
5.3. Conversión entre Java y mensajes WebSocket
Aunque los mensajes que se intercambian se limitan a ser de tipo texto o tipo binario, podemos definir un mapeo entre nuestros objetos Java y una determinada codificación (JSON, XML, etc) de forma que se realice una conversión automática entre el mensaje WebSocket y una clase Java. Esto lo haremos definiendo objetos Encoder
que definan la forma de realizar la conversión de Java a WebSocket, y objetos Decoder
que realicen la conversión en el sentido inverso.
Imaginemos que tenemos una aplicación que trabaja con películas, que constan de título, director y duración. Podemos tener una clase Java como la siguiente para encapsular los datos de cada película:
public class Pelicula {
String titulo;
String director;
int duracion;
// Getters y setters
...
}
5.3.1. Encoders
Podríamos definir un Encoder
que transforme un objeto de nuestra clase Pelicula
en texto (Encoder.Text
) o en binario (Encoder.Binary
). Vamos a ver un ejemplo en el que transformamos la película a texto con formato <titulo>;<director>;<duracion>, por lo que utilizaremos un objeto de tipo Encoder.Text
:
public class PeliculaAJsonEncoder implements Encoder.Text<Pelicula> {
@Override
public void init(EndpointConfig ec) { }
@Override
public void destroy() { }
@Override
public String encode(Pelicula p) throws EncodeException {
String msg = p.getTitulo() + ";" +
p.getDirector() + ";" +
p.getDuracion();
return msg;
}
}
5.3.2. Decoders
Con un Decoder
realizaremos la operación inversa: transformamos un mensaje WebSocket en un objeto Java. Siguiendo con el ejemplo anterior, podríamos transformar un mensaje JSON son los datos de un libro en un objeto Libro
definiendo el siguiente Decoder
, también de tipo Decoder.Text
:
public class JsonAPeliculaDecoder implements Decoder.Text<Pelicula> {
@Override
public void init(EndpointConfig ec) { }
@Override
public void destroy() { }
@Override
public Pelicula decode(String string) throws DecodeException { (1)
String [] items = string.split(";");
Pelicula p = new Pelicula();
p.setTitulo(items[0]);
p.setDirector(items[1]);
p.setDuracion(Integer.parseInt(items[2]);
return p;
}
@Override
public boolean willDecode(String string) { (2)
boolean mensajeValido = string.matches("[A-Za-z0-9]+;[A-Za-z0-9]+;[0-9]+");
return mensajeValido;
}
}
1 | Debemos transformar la cadena de texto en nuestro objeto Java |
2 | Debemos comprobar si la cadena de texto tiene el formato correcto |
Sólo podemos definir un único Decoder para todos los mensajes de texto. Si fuese posible recibir más de un tipo de objeto deberemos crear una superclase para todos los tipos de mensaje, y crearemos el Decoder para el tipo de la superclase. Dentro del Decoder deberemos distinguir de qué tipo es el mensaje y devolver la instancia adecuada. Por ejemplo, si además de Pelicula tuviesemos Libro y Disco , tendríamos que hacer que todas ellas heredasen de una misma clase, por ejemplo Articulo (Pelicula extends Articulo ). El decoder tendría que definirse en este caso del tipo Decoder.Text<Articulo> , y dentro del método decode en función del tipo de mensaje detectado instanciaríamos y devolveríamos la subclase correcta.
|
5.3.3. Uso de encoders y decoders
Una vez definidos el Encoder
y el Decoder
, deberemos añadirlos a la anotación ServerEndpoint
para que la operación de conversión se realice de forma automática:
@ServerEndpoint(
value = "/socket",
encoders = { PeliculaAJsonEncoder.class } (1)
decoders = { JsonAPeliculaDecoder.class } (2)
)
public class MiEndpoint {
@OnMessage
public void libro(Session session, Pelicula p) { (3)
Pelicula nuevaPelicula = new Pelicula();
nuevaPelicula.setTitulo("Copia de " + p.getTitulo());
session.getBasicRemote.sendObject(nuevaPelicula); (4)
}
}
1 | Declaramos el Encoder en la anotación ServerEndpoint |
2 | Declaramos el Decoder en la anotación ServerEndpoint |
3 | Podemos utilizar Libro como tipo de datos del mensaje recibido en @OnMessage |
4 | Podemos enviar directamente objetos Libro con sendObject |
5.4. Parámetros del path y de la query
Al mapear el endpoint a una URL podemos especificar segmentos variables del path:
@ServerEndpoint("/tiempo/{cp}")
public class TiempoEndpoint {
...
}
De esta forma podremos acceder al endpoint con diferentes URLs, siempre que cumplan el patrón anterior (el segmento {cp}
puede tomar cualquier valor):
ws://localhost:8080/miaplicacion/tiempo/03001
ws://localhost:8080/miaplicacion/tiempo/03004
ws://localhost:8080/miaplicacion/tiempo/03690
El valor introducido podrá ser inyectado como parámetro de los métodos de nuestro endpoint añadiendo la anotación @PathParam(<nombre_segmento>)
. Podrá inyectarse en los métodos de tipo onOpen
, onClose
y onMessage
. A continuación se muestra un ejemplo de inyección en onOpen
:
@ServerEndpoint("/tiempo/{cp}")
public class ChatEndpoint {
String cp;
Session session;
@OnOpen
public void open(Session session,
EndpointConfig c,
@PathParam("cp") String cp) { (1)
this.cp = cp; (2)
this.session = session; (3)
}
}
1 | Inyección del valor introducido en el segmento variable {cp} |
2 | Almacenamos el valor del código postal para recordarlo cuando vayamos a enviar una actualización de los datos del tiempo al cliente |
3 | Almacenamos la sesión para poder enviar mensajes al cliente sin la necesidad de que exista un mensaje de entrada |
Este tipo de parámetros se conoce como parámetros del path. Podríamos también añadir parámetros en la query con el siguiente formato:
ws://localhost:8080/miaplicacion/tiempo?hora=18
En este caso la forma de obtenerlos en el endpoint es distinta. Podremos acceder a estos parámetros a través del objeto Session
con getRequestParameterMap
. Esta función nos devuelve un Map
con todos los parámetros recibidos, que podrían ser multivaluados. El parámetro cp
anterior se obtendría de la siguiente forma:
String hora = session.getRequestParameterMap().get("hora").get(0);
Tenemos que poner .get(0)
debido a que para cada parámetro nos da una lista de objetos String
, para así permitir los parámetros con múltiples valores. En conveniente que nos aseguremos de que el parámetro es distinto de null
y que la lista no está vacía.
if(session.getRequestParameterMap().get("cp")!=null &&
session.getRequestParameterMap().get("cp").size() > 0) {
String cp = session.getRequestParameterMap().get("cp").get(0);
}
Podríamos combinar los dos tipos de parámetros en una misma URL, por ejemplo:
ws://localhost:8080/miaplicacion/tiempo/03001?hora=18
5.5. Cliente JavaScript
La gran mayoría de navegadores actuales soportan WebSocket. Encontramos una API JavaScript que nos permite conectar a este tipo de componentes. Deberemos crear un objecto JavaScript de tipo WebSocket
a partir de la URL de nuestro endpoint:
websocket = new WebSocket("ws://localhost:8080/miaplicacion/socket");
Una vez creado el objeto, deberemos especificar un callback en su atributo onmessage
para recibir una notificación cuando recibamos un nuevo mensaje:
websocket.onmessage = onMessage;
A continuación se muestra un ejemplo completo, en el que suponemos que se reciben datos de una película con el formato <titulo>;<director>;<duracion> de los ejemplos anteriores. Se trocea la cadena y se muestra cada elemento en el documento web:
var websocket;
function connect() {
websocket = new WebSocket("ws://localhost:8080/miaplicacion/socket");
websocket.onmessage = onMessage;
}
function onMessage(event) {
var items = event.data.split(";");
document.getElementById("titulo").innerHTML = items[0];
document.getElementById("director").innerHTML = items[1];
}
window.addEventListener("load", connect, false);
A través del objeto WebSocket
podemos también enviar mensajes con send
:
websocket.send("Mensaje");
5.6. Ejercicios
5.6.1. Chat básico con WebSocket (0.5 puntos)
Vamos a crear una versión alternativa del chat utilizando WebSocket. Para empezar simplemente implementaremos un chat que nos permita intercambiar mensajes con el resto de clientes conectados al servidor sin incluir información del emisor de cada mensaje.
Tenemos en la aplicación cweb-chat
una página chatws.html
que contiene el código JavaScript que implementa el chat. Deberemos crear el endpoint en el lado del servidor:
-
Creamos un endpoint mapeado a la dirección
/ChatWS
. -
En el endpoint definimos un método
OnMessage
que difunda el mismo mensaje que reciba a todas las sesiones abiertas actualmente. -
El mensaje a difundir debe tener el siguiente formato:
<nick>;<mensaje-rebido>
De momento utilizaremos como nick la cadena fija
"Anonimo"
. En el próximo ejercicio implementaremos nicks y salas de chat. -
Prueba el chat y comprueba que funciona correctamente desde diferentes navegadores.
5.6.2. Chat con nombres de usuario y salas (0.5 puntos)
En este ejercicio vamos a mejorar el chat anterior añandiendo distintas salas de chat y asignando a cada cliente un nickname. La sala se especificará mediante un segmento de la URL (path param), mientras que el nickname llegará como parámetro de la query:
ws://localhost:8080/cweb-chat/ChatWS/SalaA?nick=Pepe
Deberemos:
-
En primer lugar en el fichero
chatws.html
comentaremos la siguiente línea del código JavaScript:window.addEventListener("load", connect, false);
Con esto la conexión con el endpoint dejará de funcionar al hacerse la petición a una ruta distinta a la que está mapeado (no está preparado para recibir el segmento con el nombre de la sala).
-
Ahora deberemos mapear correctamente el endpoint a una dirección que acepte el path param con el nombre de la sala como segmento de ruta. Comprueba que la conexión funciona correctamente tras establecer este mapeo.
-
Implementa soporte para establecer el nick. Define para ello en el endpoint un método
OnOpen
que lea el parámetro de la querynick
y guarde el nick en una variable de instancia. Cuando difunda los mensajes enOnMessage
incluiremos el nick correcto en lugar de"Anonimo"
. -
Por último, implementaremos la posibilidad de tener múltiples salas de chat, cada una de ellas identificadas por un nombre. Sólo veremos los mensajes de los usuario que estén en nuestra misma sala. Para ello:
-
En primer lugar en
OnOpen
inyectaremos el path param como parámetro. -
Guardaremos el nombre de la sala en el mapa
session.getUserProperties()
. -
Al difundir los mensajes comprobaremos si en nuestra la propiedad sala coincide con cada una de las otras sesiones abiertas. Sólo enviaremos mensajes a las sesiones en las que coincida.
-