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

@OnOpen

Se llama al abrirse el canal de datos

@OnClose

Se llama al cerrarse el canal de datos

@OnError

Se llama al producirse un error en la comunicación

@OnMessage

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 con session.getRemoteBasic().

  • Asíncrono: Se define mediante la clase RemoteEndpoint.Async, y se obtiene con session.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 RemoteEndpoint)

sendBinary(ByteBuffer)

Envia un mensaje binario al endpoint remoto de forma bloqueante o no bloqueante (según el tipo de RemoteEndpoint)

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 query nick y guarde el nick en una variable de instancia. Cuando difunda los mensajes en OnMessage 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.