Crea una Aplicación de Chat con WebSockets en Java

Introducción

En el dunia actual de la programación web, la necesidad de una comunicación eficiente y en tiempo real entre servidores y navegadores es cada vez más crucial. Para abordar esta necesidad, Java ofrece una solución robusta a través de la API de WebSocket. Esta entrada de blog está dedicada a explorar la API de WebSocket de Java, enfatizando su aplicación en la construcción de una simple aplicación de chat en tiempo real. A lo largo del artículo, discutiremos conceptos clave, ejemplos de código y consejos prácticos para programadores que buscan mejorar sus habilidades en Java.

1. Overview

WebSocket proporciona una alternativa a las limitaciones de comunicación eficiente entre el servidor y el navegador web al ofrecer comunicaciones client/server en tiempo real, bidireccionales y en “full-duplex”. Esto significa que el servidor puede enviar datos al cliente en cualquier momento y viceversa, lo que maximiza la interacción entre ambas partes. Gracias a su funcionamiento sobre TCP, se asegura una comunicación de bajo nivel y baja latencia, al mismo tiempo que se reduce la sobrecarga de cada mensaje. Este nivel de funcionalidad es especialmente poderoso en aplicaciones que requieren comunicaciones instantáneas, como redes sociales, juegos en línea o aplicaciones de chat.

En este tutorial, exploraremos la API de WebSocket de Java creando una aplicación de chat sencilla.

2. JSR 356

La API de WebSocket de Java es definida por JSR 356, que especifica un API que los desarrolladores de Java pueden utilizar para integrar WebSockets dentro de sus aplicaciones, tanto en el lado del servidor como en el cliente. Esta API de Java proporciona componentes tanto para el servidor como para el cliente:

  • Servidor: Todo en el paquete jakarta.websocket.server.
  • Cliente: El contenido del paquete jakarta.websocket, que consiste en APIs del lado del cliente, así como bibliotecas comunes para ambas partes.

3. Construyendo un Chat Usando WebSockets

Crearemos una aplicación de chat muy simple donde cualquier usuario podrá abrir el chat desde cualquier navegador, ingresar su nombre, conectarse y comenzar a comunicarse con todos los demás conectados.

3.1. Configuración del Endpoint

Para comenzar, debemos agregar la última dependencia al archivo pom.xml:

<dependency>
    <groupId>jakarta.websocket</groupId>
    <artifactId>jakarta.websocket-api</artifactId>
    <version>2.2.0</version>
</dependency>

La última versión se puede encontrar aquí.

Dado que necesitamos convertir objetos Java en sus representaciones JSON, y viceversa, utilizaremos Gson:

<dependency>
    <groupId>com.google.code.gson</groupId>
    <artifactId>gson</artifactId>
    <version>2.8.0</version>
</dependency>

La última versión está disponible en Maven Central.

Hay dos formas de configurar los endpoints: basado en anotaciones y basado en extensiones. Podemos extender la clase jakarta.websocket.Endpoint o usar anotaciones de nivel de método dedicadas. El modelo basado en anotaciones tiende a generar un código más limpio, lo que lo convierte en la elección convencional. Manejaríamos los eventos del ciclo de vida del WebSocket con las siguientes anotaciones:

  • @ServerEndpoint: Si se decora con @ServerEndpoint, el contenedor se asegura de que la clase esté disponible como un servidor WebSocket que escucha un espacio de URI específico.
  • @OnOpen: Un método en Java con @OnOpen es invocado cuando se inicia una nueva conexión WebSocket.
  • @OnMessage: Un método anotado con @OnMessage recibe la información del contenedor WebSocket cuando se envía un mensaje al endpoint.
  • @OnError: Un método con @OnError se invoca cuando hay un problema con la comunicación.
  • @OnClose: Utilizado para decorar un método de Java que se llama después de que se cierra la conexión WebSocket.

3.2. Escritura del Endpoint del Servidor

Declararemos una clase de Java que actúe como endpoint del servidor WebSocket anotándola con @ServerEndpoint. También especificaremos la URI donde implementaremos el endpoint:

@ServerEndpoint(value = "/chat/{username}")
public class ChatEndpoint {

    @OnOpen
    public void onOpen(Session session) throws IOException {
        // Obtener la sesión y la conexión WebSocket
    }

    @OnMessage
    public void onMessage(Session session, Message message) throws IOException {
        // Manejar nuevos mensajes
    }

    @OnClose
    public void onClose(Session session) throws IOException {
        // Cerrar la conexión WebSocket
    }

    @OnError
    public void onError(Session session, Throwable throwable) {
        // Manejar errores aquí
    }
}

El código anterior es el esqueleto del endpoint del servidor para nuestra aplicación de chat. Este contiene cuatro anotaciones mapeadas a sus respectivos métodos:

@ServerEndpoint(value="/chat/{username}")
public class ChatEndpoint {

    private Session session;
    private static Set<ChatEndpoint> chatEndpoints = new CopyOnWriteArraySet<>();
    private static HashMap<String, String> users = new HashMap<>();

    @OnOpen
    public void onOpen(Session session, @PathParam("username") String username) throws IOException {
        this.session = session;
        chatEndpoints.add(this);
        users.put(session.getId(), username);

        Message message = new Message();
        message.setFrom(username);
        message.setContent("Connected!");
        broadcast(message);
    }

    @OnMessage
    public void onMessage(Session session, Message message) throws IOException {
        message.setFrom(users.get(session.getId()));
        broadcast(message);
    }

    @OnClose
    public void onClose(Session session) throws IOException {
        chatEndpoints.remove(this);
        Message message = new Message();
        message.setFrom(users.get(session.getId()));
        message.setContent("Disconnected!");
        broadcast(message);
    }

    @OnError
    public void onError(Session session, Throwable throwable) {
        // Manejar errores aquí
    }

    private static void broadcast(Message message) throws IOException, EncodeException {
        chatEndpoints.forEach(endpoint -> {
            synchronized (endpoint) {
                try {
                    endpoint.session.getBasicRemote().sendObject(message);
                } catch (IOException | EncodeException e) {
                    e.printStackTrace();
                }
            }
        });
    }
}

Cuando un nuevo usuario inicia sesión, el método @OnOpen se invoca y se mapea a una estructura de datos de usuarios activos. A continuación, se crea un mensaje que se envía a todos los endpoints utilizando el método broadcast. Este último también se utiliza cada vez que un nuevo mensaje es enviado por los usuarios conectados.

Si ocurre un error en algún momento, el método anotado con @OnError lo maneja. Usamos este método para registrar información sobre el error y limpiar los endpoints. Finalmente, cuando un usuario se desconecta, el método @OnClose limpia el endpoint y comunica a todos los usuarios que alguien se ha desconectado.

4. Tipos de Mensajes

La especificación de WebSocket admite dos formatos de datos en la red, texto y binario. La API admite ambos formatos y añade capacidades para trabajar con objetos Java y mensajes de verificación (ping-pong), como se define en la especificación:

  • Texto: Cualquier dato textual (ej., java.lang.String, primitivos, o sus equivalentes en clases envolventes).
  • Binario: Datos binarios (ej., audio, imagen, etc.) representados por un java.nio.ByteBuffer o un byte[].
  • Objetos Java: La API permite trabajar con representaciones nativas de Java en nuestro código y usar transformadores personalizados (codificadores/decodificadores) para convertirlos en formatos compatibles permitidos por el protocolo WebSocket.
  • Ping-Pong: Un jakarta.websocket.PongMessage es un acuse de recibo enviado por un par WebSocket en respuesta a una solicitud de verificación de salud (ping).

Para nuestra aplicación, utilizaremos Objetos Java creando las clases necesarias para codificar y decodificar mensajes.

4.1. Codificador

Un codificador toma un objeto Java y produce una representación típica adecuada para la transmisión como un mensaje, como JSON, XML o representación binaria. Los codificadores pueden ser utilizados mediante la implementación de las interfaces Encoder.Text<T> o Encoder.Binary<T>.

Veamos el código para definir la clase Message, que codificaremos, utilizando Gson para la codificación:

public class Message {
    private String from;
    private String to;
    private String content;

    //constructores estándar, getters, setters
}

Y el codificador se vería así:

public class MessageEncoder implements Encoder.Text<Message> {

    private static Gson gson = new Gson();

    @Override
    public String encode(Message message) throws EncodeException {
        return gson.toJson(message);
    }

    @Override
    public void init(EndpointConfig endpointConfig) {
        // Lógica de inicialización personalizada
    }

    @Override
    public void destroy() {
        // Cerrar recursos
    }
}

4.2. Decodificador

Un decodificador es lo opuesto de un codificador. Lo usamos para transformar datos de nuevo en un objeto Java. Los decodificadores se pueden implementar utilizando las interfaces Decoder.Text<T> o Decoder.Binary<T>.

El método decode es donde tomamos el JSON obtenido en el mensaje enviado al endpoint y lo transformamos otra vez en la clase Java Message:

public class MessageDecoder implements Decoder.Text<Message> {

    private static Gson gson = new Gson();

    @Override
    public Message decode(String s) throws DecodeException {
        return gson.fromJson(s, Message.class);
    }

    @Override
    public boolean willDecode(String s) {
        return (s != null);
    }

    @Override
    public void init(EndpointConfig endpointConfig) {
        // Lógica de inicialización personalizada
    }

    @Override
    public void destroy() {
        // Cerrar recursos
    }
}

4.3. Configurando Codificador y Decodificador en el Endpoint del Servidor

Finalmente, uniremos todo agregando las clases creadas para la codificación y decodificación en la anotación de nivel de clase @ServerEndpoint:

@ServerEndpoint( 
  value="/chat/{username}", 
  decoders = MessageDecoder.class, 
  encoders = MessageEncoder.class )

Cada vez que se envíen mensajes al endpoint, se convertirán automáticamente a JSON o a objetos Java.

5. Conclusión

En este artículo, analizamos la API de WebSocket de Java y aprendimos cómo puede ayudarnos a construir aplicaciones, como este chat en tiempo real. Discutimos los dos modelos de programación para crear un endpoint: anotaciones y programático. Luego definimos un endpoint utilizando el modelo basado en anotaciones para nuestra aplicación, junto con los métodos del ciclo de vida.

Además, para poder comunicarnos entre el servidor y el cliente, demostramos que necesitamos codificadores y decodificadores para convertir objetos Java a JSON, y viceversa. La API JSR 356 es muy simple, y el modelo de programación basado en anotaciones facilita enormemente la construcción de aplicaciones WebSocket.

Consejos Prácticos para Programadores:

  • Familiarízate con el manejo de excepciones para asegurarte de que la experiencia de usuario sea lo más fluida posible.
  • Considera implementar medidas de seguridad en tu aplicación de chat, como autenticación y validaciones de entrada.
  • Realiza pruebas exhaustivas en diferentes navegadores para garantizar la compatibilidad, especialmente si tu aplicación alcanza un uso amplio.

Al dominar la API de WebSocket en Java, podrás enriquecer tus aplicaciones con comunicaciones en tiempo real, lo que te permitirá variar la interacción de usuario y mejorar significativamente la experiencia general. ¡Feliz codificación!