Ejemplo Chat en java usando sockets e hilos

En esta ocasión vamos a ver como hacer un chat en java capaz de atender múltiples clientes. Hacer un chat para comunicarse entre 2 es sencillo siempre y cuando se entienda bien como funcionan los sockets que dicho sea de paso no tienen mucha complicación pero hacer que se pueda mantener una comunicación entre más de dos hace que sean necesarias algunas cosas más puesto que un socket se comunica con un único socket y hay esta el obstáculo ¿Como poder enviar un mensaje que se envía entre 2 sockets a un tercero? pues voy a dar un ejemplo de como solucionar esto de una forma bastante sencilla.

Para hacer el chat vamos a hacer 2 aplicaciones independientes, una actuara de servidor y la otra de cliente.

La función del servidor de forma resumida se puede decir que es mantenerse en un bucle infinito a la espera de nuevas conexiones y cuando se produzca una nueva conexión se crea un hilo para atenderla donde dentro de otro bucle infinito se recibirán los mensajes enviados por los clientes y se renviarán. Y el cliente lo que hace es crear un bucle infinito para recibir los mensajes del servidor (previamente enviados por un cliente) y una función para poder enviar mensajes al servidor.

Visto que es lo que tienen que hacer el servidor y el cliente «solo» queda escribir el código java, que no será muy complicado puesto que el problema tampoco lo es y se puede explicar en 1 minuto.

El Servidor para el chat

En el código de la clase principal del servidor hay poco que explicar puesto que únicamente se crea el ServerSocket y un bucle infinito en el que se esperan conexiones y cuando se producen se crea una ConexionCliente y se pone a correr el hilo para que atienda la conexión con el cliente.


package servidorchat;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import org.apache.log4j.Logger;
import org.apache.log4j.PropertyConfigurator;

/**
 * Servidor para el chat.
 * 
 * @author Ivan Salas Corrales <http://programandoointentandolo.com>
 */
 
public class ServidorChat {

    /**
     * @param args the command line arguments
     */

    public static void main(String[] args) {
        
        // Carga el archivo de configuracion de log4J
        PropertyConfigurator.configure("log4j.properties");        
        Logger log = Logger.getLogger(ServidorChat.class);
        
        int puerto = 1234;
        int maximoConexiones = 10; // Maximo de conexiones simultaneas
        ServerSocket servidor = null; 
        Socket socket = null;
        MensajesChat mensajes = new MensajesChat();
        
        try {
            // Se crea el serverSocket
            servidor = new ServerSocket(puerto, maximoConexiones);
            
            // Bucle infinito para esperar conexiones
            while (true) {
                log.info("Servidor a la espera de conexiones.");
                socket = servidor.accept();
                log.info("Cliente con la IP " + socket.getInetAddress().getHostName() + " conectado.");
                
                ConexionCliente cc = new ConexionCliente(socket, mensajes);
                cc.start();
                
            }
        } catch (IOException ex) {
            log.error("Error: " + ex.getMessage());
        } finally{
            try {
                socket.close();
                servidor.close();
            } catch (IOException ex) {
                log.error("Error al cerrar el servidor: " + ex.getMessage());
            }
        }
    }
}

En el código anterior se crea un objeto (de la clase MensajesChat) que será el que se utilizará para permitir que se puedan intercambiar mensajes entre múltiples clientes que es la idea de un chat.

Esta clase es muy sencilla porque únicamente tiene un set y get aunque puesto que hereda de Observable hay que saber de que va el patrón Observer.

En el patrón observer hay 2 tipos de elementos los observadores y los observados (MensajesChat es un observado). Si un objeto quiere observar a otro se apunta a su lista de observadores para avisarle de que quiere saber cuando cambia su estado para realizar alguna acción, por ejemplo mostrar el cambio, y el objeto observado lo que hace es informar a todos los objetos que lo están observando para decirles que su estado ha cambiado.

Una vez entendido el funcionamiento del patrón observer únicamente hay que decir que mediante la función setChanged() se indica que el estado del objeto observable a cambiado y además hay que notificárselo a sus observadores con notifyObservers(Object o) que le pasará al objeto observador el objeto o que se quiera enviar, en nuestro caso un mensaje de un cliente.


package servidorchat;

import java.util.Observable;

/**
 * Objeto observable del patron observer.
 * 
 * @author Ivan Salas Corrales <http://programandoointentandolo.com>
 */

public class MensajesChat extends Observable{

    private String mensaje;
    
    public MensajesChat(){
    }
    
    public String getMensaje(){
        return mensaje;
    }
    
    public void setMensaje(String mensaje){
        this.mensaje = mensaje;
        // Indica que el mensaje ha cambiado
        this.setChanged();
        // Notifica a los observadores que el mensaje ha cambiado y se lo pasa
        // (Internamente notifyObservers llama al metodo update del observador)
        this.notifyObservers(this.getMensaje());
    }
}

Y finalmente la clase ConexionCliente que solo tiene dos métodos, el método run que hereda de Thread y que es el que se encarga de recibir los mensajes del cliente y el método update que es necesario implementar por implementar la interfaz Observer y que es el encargado de enviar el mensaje al cliente.


package servidorchat;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
import java.util.Observable;
import java.util.Observer;
import org.apache.log4j.Logger;


/**
 * Esta clase gestiona el envio de datos entre el servidor y el cliente al que atiende.
 * 
 * @author Ivan Salas Corrales <http://programandoointentandolo.com>
 */

public class ConexionCliente extends Thread implements Observer{
    
    private Logger log = Logger.getLogger(ConexionCliente.class);
    private Socket socket; 
    private MensajesChat mensajes;
    private DataInputStream entradaDatos;
    private DataOutputStream salidaDatos;
    
    public ConexionCliente (Socket socket, MensajesChat mensajes){
        this.socket = socket;
        this.mensajes = mensajes;
        
        try {
            entradaDatos = new DataInputStream(socket.getInputStream());
            salidaDatos = new DataOutputStream(socket.getOutputStream());
        } catch (IOException ex) {
            log.error("Error al crear los stream de entrada y salida : " + ex.getMessage());
        }
    }
    
    @Override
    public void run(){
        String mensajeRecibido;
        boolean conectado = true;
        // Se apunta a la lista de observadores de mensajes
        mensajes.addObserver(this);
        
        while (conectado) {
            try {
                // Lee un mensaje enviado por el cliente
                mensajeRecibido = entradaDatos.readUTF();
                // Pone el mensaje recibido en mensajes para que se notifique 
                // a sus observadores que hay un nuevo mensaje.
                mensajes.setMensaje(mensajeRecibido);
            } catch (IOException ex) {
                log.info("Cliente con la IP " + socket.getInetAddress().getHostName() + " desconectado.");
                conectado = false; 
                // Si se ha producido un error al recibir datos del cliente se cierra la conexion con el.
                try {
                    entradaDatos.close();
                    salidaDatos.close();
                } catch (IOException ex2) {
                    log.error("Error al cerrar los stream de entrada y salida :" + ex2.getMessage());
                }
            }
        }   
    }
    
    @Override
    public void update(Observable o, Object arg) {
        try {
            // Envia el mensaje al cliente
            salidaDatos.writeUTF(arg.toString());
        } catch (IOException ex) {
            log.error("Error al enviar mensaje al cliente (" + ex.getMessage() + ").");
        }
    }
} 

En el método run hay que tener en cuenta 2 detalles, el primero es que hay que apuntarse a la lista de observadores de mensajes para que posteriormente cuando mensajes contenga un nuevo mensaje sea notificado y pueda enviar el mensaje al cliente gracias al método update, independientemente de si el mensaje lo ha enviado el cliente que es atendido por ese mismo hilo o por otro distinto. Y lo segundo es que hay que hacer uso de setMensaje para que mensajes cambie su estado y lo notifique a sus observadores.

En el método run aunque teóricamente hay que tener un bucle infinito para recibir mensajes, en la practica no es así puesto que si el cliente que es atendido por ese hilo se desconecta este hilo se continuaría ejecutando indefinidamente de forma absurda porque estaría lanzando una IOException continuamente por lo que cuando se lance esta excepción cerraremos el bucle y se terminara la ejecución del hilo liberando al servidor de una tarea absurda.

Con estas 3 clases ya tenemos un servidor capaz de permitir que se puedan conectar múltiples clientes y que cuando uno envié un mensaje todos los reciban.

El cliente para el chat

El servidor aunque es sencillo, puede ser más difícil de comprender sobretodo por el uso del patrón observer que mezclado con hilos y sockets por lo que es un buen ejemplo del uso de ese patrón aunque no era esa la idea, pero en el cliente no hay ninguna dificultad especial y si se entiende el servidor el cliente es trivial.

He hecho que clienteChat herede de JFrame por lo que será una ventana, en el constructor se crean y se colocan los componentes necesarios (JTextArea, JTextField y JButton) y un JScrollPane para que se pueda ver toda la conversación aunque sea muy larga y para colocar las cosas he usado un GridBagLayout que me parece que es bastante sencillo de usar para colocar las cosas de una forma bastante sencilla y no tener que recurrir a meter varios JPanels incluso para casos como este en el que solo hay 3 elementos, si no lo has usado nunca quizás el tener que usar GridBagConstraints puede parecer algo engorroso aunque con un par de veces que lo usas ya te acostumbras.

Después de colocar los componentes en la línea 80 se crea una VentanaConfiguracion que no es más que un JDialog para pedir un nombre de usuario y el puerto y el host del servidor por si se quieren modificar. Y finalmente se crea el socket para conectar con el servidor del chat y en la ultima línea se añade un actionListener al botón para que cuando se pulse enviar se envié el mensaje al servidor.

Y en el main se instancia un ClienteChat y se llama al método recibirMensajesServidor() que dentro de un bucle infinito recibe los mensajes enviados por el servidor y los va añadiendo al JTextArea para mostrarlos.


package clientechat;

import java.awt.Container;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.io.DataInputStream;
import java.io.IOException;
import java.net.Socket;
import java.net.UnknownHostException;
import javax.swing.*;
import org.apache.log4j.Logger;
import org.apache.log4j.PropertyConfigurator;

/**
 * Clase principal del cliente del chat
 * 
 * @author Ivan Salas Corrales <http://programandoointentandolo.com>
 */

public class ClienteChat extends JFrame {
    
    private Logger log = Logger.getLogger(ClienteChat.class);
    private JTextArea mensajesChat;
    private Socket socket;
    
    private int puerto;
    private String host;
    private String usuario;
    
    public ClienteChat(){
        super("Cliente Chat");
        
        // Elementos de la ventana
        mensajesChat = new JTextArea();
        mensajesChat.setEnabled(false); // El area de mensajes del chat no se debe de poder editar
        mensajesChat.setLineWrap(true); // Las lineas se parten al llegar al ancho del textArea
        mensajesChat.setWrapStyleWord(true); // Las lineas se parten entre palabras (por los espacios blancos)
        JScrollPane scrollMensajesChat = new JScrollPane(mensajesChat);
        JTextField tfMensaje = new JTextField("");
        JButton btEnviar = new JButton("Enviar");
        
        
        // Colocacion de los componentes en la ventana
        Container c = this.getContentPane();
        c.setLayout(new GridBagLayout());
        
        GridBagConstraints gbc = new GridBagConstraints();
        
        gbc.insets = new Insets(20, 20, 20, 20);
        
        gbc.gridx = 0;
        gbc.gridy = 0;
        gbc.gridwidth = 2;
        gbc.weightx = 1;
        gbc.weighty = 1;
        gbc.fill = GridBagConstraints.BOTH;
        c.add(scrollMensajesChat, gbc);
        // Restaura valores por defecto
        gbc.gridwidth = 1;        
        gbc.weighty = 0;
        
        gbc.fill = GridBagConstraints.HORIZONTAL;        
        gbc.insets = new Insets(0, 20, 20, 20);
        
        gbc.gridx = 0;
        gbc.gridy = 1;
        c.add(tfMensaje, gbc);
        // Restaura valores por defecto
        gbc.weightx = 0;
        
        gbc.gridx = 1;
        gbc.gridy = 1;
        c.add(btEnviar, gbc);
        
        this.setBounds(400, 100, 400, 500);
        this.setVisible(true);
        this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);     
        
        // Ventana de configuracion inicial
        VentanaConfiguracion vc = new VentanaConfiguracion(this);
        host = vc.getHost();
        puerto = vc.getPuerto();
        usuario = vc.getUsuario();
        
        log.info("Quieres conectarte a " + host + " en el puerto " + puerto + " con el nombre de ususario: " + usuario + ".");
        
        // Se crea el socket para conectar con el Sevidor del Chat
        try {
            socket = new Socket(host, puerto);
        } catch (UnknownHostException ex) {
            log.error("No se ha podido conectar con el servidor (" + ex.getMessage() + ").");
        } catch (IOException ex) {
            log.error("No se ha podido conectar con el servidor (" + ex.getMessage() + ").");
        }
        
        // Accion para el boton enviar
        btEnviar.addActionListener(new ConexionServidor(socket, tfMensaje, usuario));
        
    }
    
    /**
     * Recibe los mensajes del chat reenviados por el servidor
     */

    public void recibirMensajesServidor(){
        // Obtiene el flujo de entrada del socket
        DataInputStream entradaDatos = null;
        String mensaje;
        try {
            entradaDatos = new DataInputStream(socket.getInputStream());
        } catch (IOException ex) {
            log.error("Error al crear el stream de entrada: " + ex.getMessage());
        } catch (NullPointerException ex) {
            log.error("El socket no se creo correctamente. ");
        }
        
        // Bucle infinito que recibe mensajes del servidor
        boolean conectado = true;
        while (conectado) {
            try {
                mensaje = entradaDatos.readUTF();
                mensajesChat.append(mensaje + System.lineSeparator());
            } catch (IOException ex) {
                log.error("Error al leer del stream de entrada: " + ex.getMessage());
                conectado = false;
            } catch (NullPointerException ex) {
                log.error("El socket no se creo correctamente. ");
                conectado = false;
            }
        }
    }
    
    /**
     * @param args the command line arguments
     */

    public static void main(String[] args) {
        // Carga el archivo de configuracion de log4J
        PropertyConfigurator.configure("log4j.properties");        
        
        ClienteChat c = new ClienteChat();
        c.recibirMensajesServidor();
    }

}  

La VentanaConfiguracion como decía es un JDialog con un campo de texto para cada cosa que queremos saber y un botón para «aceptar» los datos introducidos aunque realmente lo que hace el botón es ocultar la ventana ya que es una ventana modal y mientras se esté mostrando no se podrá acceder a la ventana principal. Y para obtener los valores introducidos basta con unos simples gets que recojan los valores de los JTextFields en el formato adecuado.


package clientechat;


import java.awt.Container;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import javax.swing.*;
import org.apache.log4j.Logger;

/**
 * Una sencilla ventana para configurar el chat
 * 
 * @author Ivan Salas Corrales <http://programandoointentandolo.com/>
 */

public class VentanaConfiguracion extends JDialog{
    
    private Logger log = Logger.getLogger(VentanaConfiguracion.class);
    private JTextField tfUsuario;
    private JTextField tfHost;
    private JTextField tfPuerto;
    
    /**
     * Constructor de la ventana de configuracion inicial
     * 
     * @param padre Ventana padre
     */

    public VentanaConfiguracion(JFrame padre) {
        super(padre, "Configuracion inicial", true);
        
        JLabel lbUsuario = new JLabel("Usuario:");
        JLabel lbHost = new JLabel("Host:");
        JLabel lbPuerto = new JLabel("Puerto:");
        
        tfUsuario = new JTextField();
        tfHost = new JTextField("localhost");
        tfPuerto = new JTextField("1234");
        
        JButton btAceptar = new JButton("Aceptar");
        btAceptar.addActionListener(new ActionListener() {
            
            @Override
            public void actionPerformed(ActionEvent e) {
                setVisible(false);
            }
        });
        
        Container c = this.getContentPane();
        c.setLayout(new GridBagLayout());
        GridBagConstraints gbc = new GridBagConstraints();
        
        gbc.insets = new Insets(20, 20, 0, 20);
        
        gbc.gridx = 0;
        gbc.gridy = 0;
        c.add(lbUsuario, gbc);
        
        gbc.gridx = 0;
        gbc.gridy = 1;
        c.add(lbHost, gbc);
        
        gbc.gridx = 0;
        gbc.gridy = 2;
        c.add(lbPuerto, gbc);
        
        gbc.ipadx = 100;
        gbc.fill = GridBagConstraints.HORIZONTAL;
        
        gbc.gridx = 1;
        gbc.gridy = 0;
        c.add(tfUsuario, gbc);
        
        gbc.gridx = 1;
        gbc.gridy = 1;
        c.add(tfHost, gbc);
        
        gbc.gridx = 1;
        gbc.gridy = 2;
        c.add(tfPuerto, gbc);
        
        gbc.gridx = 0;
        gbc.gridy = 3;
        gbc.gridwidth = 2;
        gbc.insets = new Insets(20, 20, 20, 20);
        c.add(btAceptar, gbc);
        
        this.pack(); // Le da a la ventana el minimo tamaño posible
        this.setLocation(450, 200); // Posicion de la ventana
        this.setResizable(false); // Evita que se pueda estirar la ventana
        this.setDefaultCloseOperation(JDialog.DO_NOTHING_ON_CLOSE); // Deshabilita el boton de cierre de la ventana 
        this.setVisible(true);
    }
    
    
    public String getUsuario(){
        return this.tfUsuario.getText();
    }
    
    public String getHost(){
        return this.tfHost.getText();
    }
    
    public int getPuerto(){
        return Integer.parseInt(this.tfPuerto.getText());
    }

}

Y la ultima clase es ConexionServidor que implementa ActionListener y que es la clase que se llama al pulsar el botón enviar y que simplemente inicializa un DataOutputStream en el constructor para poder recibir datos y que en actionPerformed (que se ejecuta cuando se hace click en el botón y de igual forma se podría ejecutar por ejemplo al pulsar intro en el JTextField aunque esto no esta implementado) envía el texto al servidor del chat precedido del nombre del usuario para que luego se muestre en el chat y borra el contenido del campo de texto para que se pueda escribir el siguiente mensaje sin tener que borrarlo manualmente.


package clientechat;

import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
import javax.swing.JTextField;
import org.apache.log4j.Logger;

/**
 * Esta clase gestiona el envio de datos entre el cliente y el servidor.
 * 
 * @author Ivan Salas Corrales <http://programandoointentandolo.com>
 */

public class ConexionServidor implements ActionListener {
    
    private Logger log = Logger.getLogger(ConexionServidor.class);
    private Socket socket; 
    private JTextField tfMensaje;
    private String usuario;
    private DataOutputStream salidaDatos;
    
    public ConexionServidor(Socket socket, JTextField tfMensaje, String usuario) {
        this.socket = socket;
        this.tfMensaje = tfMensaje;
        this.usuario = usuario;
        try {
            this.salidaDatos = new DataOutputStream(socket.getOutputStream());
        } catch (IOException ex) {
            log.error("Error al crear el stream de salida : " + ex.getMessage());
        } catch (NullPointerException ex) {
            log.error("El socket no se creo correctamente. ");
        }
    }

    @Override
    public void actionPerformed(ActionEvent e) {
        try {
            salidaDatos.writeUTF(usuario + ": " + tfMensaje.getText() );
            tfMensaje.setText("");
        } catch (IOException ex) {
            log.error("Error al intentar enviar un mensaje: " + ex.getMessage());
        }
    }
}

Y con esto ya esta finalizado tanto el servidor como el cliente del chat. Aquí puedes descargar el código fuente tanto del servidor como del cliente y también están incluidos los jar para que puedas probar su funcionamiento sin necesidad de un IDE o de línea de comandos, aunque debes de tener en cuenta que el servidor puesto que no tiene parte grafica cuando lo ejecutes parecerá que no pasa nada pues no hay nada que mostrar y que únicamente puede haber un servidor, bueno si cambias el puerto puede haber todos los que quieras pero si lo intentas ejecutar con el mismo puerto te dará un error porque no se pueden crear varios serverSockets para atender un mismo puerto, el cliente como es normal lo puedes ejecutar tantas veces como quieras y en los ordenadores, tablets, móviles, etc. que quieras teniendo en cuenta que entonces tienes que ver cual es la IP del ordenador en el que se ejecuta el servidor para poder conectarte.