Práctica.- Desarrollo de una aplicación chat utilizando Sockets

    Programación Distribuida y su Aplicación Bajo Internet
    Febrero 2005
    Autor: Juansa Sendra

Contents

1 Introducción
2 Protocolos
2.1 Conexión
2.2 Mensajes
2.3 Desconexión
3 Resumen protocolos
4 Estructura del servidor
5 Estructura del cliente
6 Tarea a desarrollar
7 Diseño del cliente
7.1 Descripción del código
8 Diseño del servidor
8.1 Tipos de mensajes
8.2 Clase conversación
8.3 Clase Cliente
8.4 Clase Principal
[ back to top ]

1 Introducción

[ back to top ]

2 Protocolos

2.1 Conexión

2.2 Mensajes

2.3 Desconexión

[ back to top ]

3 Resumen protocolos

    conexión (apodo nuevo):     A "/n:Apodo" S "nuevo apodo" TODOS
    conexión (ya existe apodo):     A "/n:Apodo" S "/nt" A
    cambio apodo (apodo nuevo):     A "/n:Apodo" S "cambio apodo" TODOS
    cambio apodo (ya existe apodo): A "/n:Apodo" S "/nt" A
    mensaje público:                A "xxx" S "xxx" TODOS
    mensaje privado:                A "/p:B:xxx" S "/p:A:xxx" B
    desconexión:                A "/d" S "fin de A" TODOS
[ back to top ]

4 Estructura del servidor

Si detecta una nueva conexión la añade al conjunto de conexiones, asocia el apodo correspondiente, y muestra un mensaje en pantalla

Para simplificar la aplicación se mantiene una única conversación (room), de forma que todos los clientes que puedan conectarse forman parte de la misma.

[ back to top ]

5 Estructura del cliente

[ back to top ]

6 Tarea a desarrollar

  1. Compila y prueba el código proporcionado.
  2. Modifica el código para que:

El resto del texto describe con detalle los fuentes proporcionados.

[ back to top ]

7 Diseño del cliente

En un aplicación chat se definen distintos tipos de mensajes 'especiales' (ej.- para cambio de apodo, mensaje privado, etc.), pero en su mayor parte se trata de mensajes desde el cliente al servidor.

En nuestro diseño, el único mensaje especial que remite el servidor al cliente es el mensaje "/nt", que indica que no puede utilizarse el apodo propuesto. Este hecho justifica el diseño de un cliente que no sea consciente de los distintos tipos de mensajes posibles, y se limite a volcar a la conversación las tiras de caracteres que recibe desde el servidor.

Con ello se simplifica el servidor, pero se limita su flexibilidad (ej.- si se añaden nuevos tipos de mensajes se complica significativamente el código, y además exigimos que el usuario sea quien edite los mensajes 'especiales' utilizando la sintaxis adecuada).

7.1 Descripción del código

Nuestro cliente chat (CliChat.java) utilizará el mecanismo de sockets para dialogar con el servidor (ServChat.java). Por ello deben importarse las bibliotecas java.net y java.io

    import java.net.*;
    import java.io.*;

Además pretendemos desarrollarlo en modo gráfico, por lo que incluimos lasbibliotecas java.awt y java.awt.event

    import java.awt.*;
    import java.awt.event.*;

El desarrollo requiere una única clase, que será una extensión de Frame (para permitir visualizar una ventana en pantalla, y ubicar sobre ella los controles de interacción necesarios)

    public class CliChat extends Frame {

El constructor de dicha clase recoge como argumentos los valores correspondientes a la máquina donde reside el servidor, y el port que éste utiliza. Ambos valores son opcionales, con valores por defecto "localhost" y 29029 respectivamente

    public static void main(String[] arg) {
            String host=(arg.length>0)?arg[0]:"localhost";
            int port=(arg.length>1)?Integer.parseInt(arg[1]):29029;
            new CliChat(host,port);
    }

La última línea del constructor puede parecer extraña, pero resulta necesario crear un objeto de la clase, puesto que en caso contrario todos los métodos y atributos deberían ser 'static'.

El constructor de la clase especifica el código del único objeto que vamos a crear. El constructor recibe como argumentos los valores de host y port. Su código consiste en:

    super("Cliente Chat");
    orden= new TextField(40);
    List conversacion=new List(20,false);
    add("Center",conversacion); add("South",orden);
    addWindowListener(new WindowAdapter(){
        public void windowClosing(WindowEvent e) {adios();}
    });
    orden.addActionListener(new ActionListener(){
        public void actionPerformed(ActionEvent e) {
            envia(); orden.setText("");
        }
    });
    setSize(600,300); show();
    try {   Socket s=new Socket(host,port);
        in= new BufferedReader(new InputStreamReader(s.getInputStream()));
        out= new PrintWriter(s.getOutputStream());
        ....
    } catch (IOException e) {System.exit(-1);}
    while(true) conversacion.add(procesa(in.readLine()), 0);

El método auxiliar 'procesa' recibe una tira de caracteres, y realiza tres acciones:

    public static String procesa(String s) {
        System.out.println(s); // para facilitar la depuración
        if (s.equals("/nt")) return "SERVIDOR: Ese apodo ya está en uso. Intenta con otro";
        else return s;
    }

Otro método auxiliar es 'envia', que se encarga de remitir un mensaje al servidor a través del socket

    public void envia() {out.println(orden.getText()); out.flush();}

Por último, el método 'adios' remite el mensaje de desconexión y termina la ejecución del cliente

    public void adios() {out.println("/d"); out.flush(); System.exit(0);}
[ back to top ]

8 Diseño del servidor

En el servidor se ha utilizado una estrategia diferente. En lugar de simplificar al máximo, se ha proporcionado un conjunto de clases adicional pensando en futuras extensiones. Analizando dichas clases podrían plantearse fácilmente nuevas extensiones para el cliente del chat.

Con este fin se han creado las siguientes clases:

8.1 Tipos de mensajes

El servidor debe responder a las solicitudes de varios clientes, y procesar distintos tipos de mensajes, incluyendo distintos mensajes de control (cambio de apodo, desconexión, mensaje privado).

La existencia de distintos tipos de mensajes, y el deseo de añadir flexibilidad (posible expansión) conducen a crear una jerarquía de mensajes, de forma que cada uno de ellos sabe cómo convertirse a una tira de caracteres. De forma similar, necesitamos convertir las tiras de caracteres leídas a mensajes, pero en este caso no sabemos a qué clase corresponde hasta que se lee la información. Por ello, se incorpora en la clase base (Msg) un método que sabe cómo distinguir de qué tipo de mensaje se trata, creando un objeto del tipo adecuado (que además ya sabrá como interpretar el resto del mensaje).Otro aspecto importante es el deseo de crear listas de mensajes, lo que implica definir el campo 'sig' en la clase Msg (con ello podemos crear listas enlazadas cuyos elementos son extensiones de Msg).

    abstract class Msg {
        Msg sig;
        public static Msg decodifica(String s) {
            if (0==s.compareTo("/nt"))  return new MsgRechazoApodo();
            if (0==s.compareTo("/d"))   return new MsgDesconexion();
            if (s.startsWith("/n:"))    return new MsgApodo(s.substring(3));
            if (s.startsWith("/p:"))    return new MsgPrivado(s.substring(3));
            return new MsgPublico(s);
        }
        abstract public String toString();
    }
    class MsgPublico extends Msg {
        String txt;
        public MsgPublico(String s) {txt=s;}
        public String toString() {return txt;}
    }
    class MsgPrivado extends Msg {
        String txt, to;
        public MsgPrivado(String s) {
            int sep=s.indexOf(":");
            to=s.substring(0,sep);
            txt=s.substring(sep+1);
        }
        public String toString() {return "/p:"+to+":"+txt;}
    }
    class MsgApodo extends Msg {
        String txt;
        public MsgApodo(String s) {txt=s;}
        public String toString() {return "/n:"+txt;}
    }
    class MsgRechazoApodo extends Msg {
        public String toString() {return "/nt";}
    }
    class MsgDesconexion extends Msg {
        public String toString() {return "/d";}
    }

La clase Msg es abstracta, de forma que no podemos crear instancias de tipo Msg, sino siempre de alguna de sus extensiones. Además, al definir el método 'toString()' como abstracto, obliga a que todas las extensiones le dén código. El nombre de dicho método no es casual; se trata del método que invoca el sistema cuando necesita convertir un objeto a catacteres, lo que permite utilizar directamente objetos de tipo Msg (o extensiones) en cualquier sentencia de escritura.

Por último, se observa que cada tipo de mensaje posee atributos diferentes (ej.- un mensaje privado posee texto (txt) y destino (to), mientras que un mensaje de desconexión no posee atributos)

8.2 Clase conversación

Planteamos un servidor capaz de soportar varias conversaciones simultáneas. Una conversación se caracteriza por el conjunto de usuarios que participan en la misma, y el conjunto de mensajes (historia) intercambiados hasta el momento actual.

Al diseñar una clase Conversacion, permitimos que el servidor ofrezca varias conversaciones (ej.- por ejemplo identificadas por el tema de discusión) y la posibilidad de incorporarse a cualquiera de ellas.

Sin embargo, en esta primera versión se utilizará únicamente un objeto de dicha clase, de forma que cuando se incorpora un usuario se añade a la única conversación existente.

-- detalle del códigoUna conversación debe mantener la lista de clientes (usuarios) de la misma (ej.- para poder difundir mensajes entre ellos, etc.), y la lista de mensajes remitidos hasta ahora.

En ambos casos utilizamos listas enlazadas. Mantenemos sendas cabeceras a la lista de clientes (C) y mensajes (M), aprovechando el hecho de que tanto los objetos mensaje como los objetos cliente (definidos en el siguiente apartado) poseen un campo 'sig' para enlazarse entre sí. Inicialmente ambas listas están vacías (ambas cabeceras a null)

    Cliente C=null;     // cabeza lista clientes
    Msg M=null;     // cabeza lista mensajes

En la lista de mensaje únicamente vamos a insertar (o recorrer), pero nunca eliminamos mensajes. Por ello, la única operación es 'nuevoMsg', que inserta un nuevo mensaje al principio de la lista (se inserta al principio para simplificar el código, y para tener acceso inmediato a los mensajes más recientes). De esta forma, la lista de mensajes sigue un orden cronológico inverso.

    public void nuevoMsg(Msg m) {m.sig=M; M=m;}

Con la lista de clientes realizamos cuatro operaciones:

    public void altaCliente(Socket s) {
        System.out.println("nuevo cliente");
        (C=new Cliente(this,s,null,C)).start(); // para atender al nuevo cliente
    }
    public void bajaCliente(Cliente c) { // elimina de la lista OJO.- sabemos que c existe
        if (c==C) C=c.sig;
        else {
            Cliente p;
            for (p=C; p.sig!=c; p=p.sig) {}
            p.sig=p.sig.sig;
        }
    }
    public Cliente apodado(String s) {
        Cliente c;
        for (c=C; (c!=null) && (!s.equals(c.apodo)); c=c.sig) {}
        return c;
    }
    public void difunde(String s) {
        for (Cliente c=C; c!=null; c=c.sig) c.envia(s);
    }

}

8.3 Clase Cliente

Es el objeto creado por el servidor para cada uno de los clientes (usuarios) del chat. Se trata de una extensión de Thread, de forma que cada objeto Cliente puede dialogar estrictamente con 'su' usuario, y tendremos tantas actividades simultáneas como clientes.

El resultado es un diálogo muy simple, donde las operaciones de escucha son bloqueantes

El constructor recibe como argumentos a qué conversación corresponde el cliente, el socket que representa la conexión con el programa usuario, y el cliente siguiente (para construir la lista de clientes). Los argumentos correspondientes a la conversación y al cliente siguiente se utilizan para inicializar los atributos del mismo nombre, mientras que el socket sirve para definir los flujos de entrada y salida (atributos in y out) que permitirán la lectura/escritura en el socket.

    public Cliente(Conversacion conv, Socket s, Cliente sig) {
        this.conv=conv; this.apodo=null; this.sig=sig;
        try {   in= new BufferedReader(new InputStreamReader(s.getInputStream()));
            out= new PrintWriter(s.getOutputStream());
                } catch (IOException e) {System.out.println("Error E/S: "); System.exit(-1);}
    }

El código a ejecutar por la tarea es trivial. Se limita a iterar en un bucle (mientras 'activo' sea cierta), y en cada iteración lee una línea del socket, decodifica dicha tira, y procesa el mensaje resultante.

    public void run() {
        try {while (activo) procesa(Msg.decodifica(in.readLine()));
                } catch (IOException e) {System.out.println("Error E/S: "); System.exit(-1);}
    }

El método envía se limita a escribir en el socket la tira de caracteres recibida como argumento

    public void envia(String s) {
        out.println(s); out.flush();
    }

El método más complejo es 'procesa', encargado de responder a la recepción de un mensaje. En dicho método se añade el mensaje a la lista de mensajes de la conversación, y comprueba el tipo exacto de mensaje para tomar una acción diferente según el caso.

    if (m instanceof MsgPublico) { System.out.println("publico");
        conv.difunde(apodo+": "+((MsgPublico)m).txt);
    }
    if (m instanceof MsgPrivado) { System.out.println("privado");
        Cliente destino=conv.apodado(((MsgPrivado)m).to);
        if (destino!=null) destino.envia("["+apodo+"] "+((MsgPrivado)m).txt);
    }
    if (m instanceof MsgApodo) { System.out.println("apodo");
        String nuevo= ((MsgApodo)m).txt;
        Cliente c=conv.apodado(nuevo);
        if (c!=null) envia(""+new MsgRechazoApodo());
        else {
            if (apodo==null) 
                conv.difunde("SERVIDOR: '"+nuevo+"' se incorpora a la conversación");
            else
                conv.difunde("SERVIDOR: '"+apodo+"' pasa a llamarse "+nuevo);
            apodo=nuevo;
        }
    }
    if (m instanceof MsgDesconexion) { System.out.println("desconexion");
        conv.bajaCliente(this); activo=false;
        conv.difunde("SERVIDOR: '"+apodo+"' abandona la conversación");
    }

8.4 Clase Principal

Gracias al desarrollo de las distintas clases auxiliares, la clase principal es muy simple. Dispone únicamente del método principal, que se encarga de leer el posible argumento (el port donde escuchar, por defecto el 29029), crear el objeto de tipo Conversación (únicamente asumimos una conversación), crear el ServerSocket donde escuchar posibles nuevas conexiones, y aceptar nuevos clientes.

    public class ServChat { // clase principal
        public static void main(String[] arg) {
            int port=(arg.length>0)?Integer.parseInt(arg[0]):29029;
            Conversacion conv= new Conversacion();
            System.out.println("Servidor activado en port: "+port);
            try {   ServerSocket ss= new ServerSocket(port);
                System.out.println("Espero conexiones ..");
                while (true) conv.altaCliente(ss.accept());
            } catch (IOException e) {System.exit(-1);}
        }
    }

La única parte a detallar es la aceptación de nuevos clientes. En esta línea se itera en un bucle infinito, y en cada iteración se acepta una nueva conexión (operación bloqueante). El método accept devuelve un socket, y dicho socket sirve como parámetro a la operación 'altaCliente' del objeto conversación. De esta forma, cada vez que se acepta un cliente creamos un objeto 'Cliente' asociado, y se registra dicho objeto en la lista de clientes de la conversación actual.

    while (true) conv.altaCliente(ss.accept());
[ back to top ]