Chat con Sockets en Java

El chat que se muestra en esta publicación utiliza sockets para la comunicación, se desarrolló utilizando Java 1.8 y NetBeans 8.0. El ejemplo está formado por dos aplicaciones, el cliente y el servidor, ambas pueden descargarse de los siguientes enlaces:

Chat con Sockets en Java (Servidor).

Chat con Sockets en Java (Cliente).

Todo el código dentro ambos proyectos está documentado con comentarios que explican su funcionamiento.

Funcionamiento del chat

Para que el chat funcione correctamente, debe ejecutarse primero la aplicación servidor, en la que el cliente debe ingresar el puerto por el cual el servidor escuchara las conexiones de los clientes que participen en el chat. Puede dejarse el puerto por defecto o puede indicarse uno diferente, que no esté siendo utilizado por ninguna otra aplicación.

Si el servidor inicia correctamente se mostrará una ventana como la siguiente.

En este punto ya podremos ejecutar aplicaciones cliente que se conecten al chat, al ejecutar la aplicación cliente nos pedirá la IP de la máquina en la que se ejecuta la aplicación servidor, por defecto tiene la dirección de localhost, en este caso se utiliza esta porque el servidor y todos los clientes se están ejecutando en la misma máquina. Además de la dirección IP, se debe ingresar el puerto por el que el servidor escucha las conexiones de los clientes y el nombre del usuario que se desea conectar.

Si la conexión es correcta, entonces se muestra una ventana que tiene una JTextArea en la que se muestra el historial de las conversaciones, un JComboBox que muestra el listado de los usuarios conectados a los que se puede enviar mensajes, un JTextField en el que se puede ingresar los mensajes a enviar y un JButton para enviar el mensaje.

Si solamente está conectado un cliente, no habrá nadie a quien se le pueda enviar mensajes, si el cliente intentara enviar uno se mostraría el siguiente mensaje de información.

Al ejecutar más de un cliente estos ya podrán comunicarse entre sí.

Luego de que los clientes envíen y reciban múltiples mensajes se tiene algo como lo siguiente.

Si se cierran las ventanas, la aplicación cliente considerará que el usuario está cerrando sesión y enviará una notificación al servidor para elimine a este cliente de la lista de clientes y notifique al resto de los usuarios conectados que deben eliminarlo de su lista de contactos.

En la aplicación servidor se muestra un log, en el que se informa cuando un usuario inicia o cierra sesión.

Si el servidor se cierra repentinamente, mientras aún hay clientes conectados, estas aplicaciones cliente se cerrarán luego de mostrar el siguiente mensaje.

Si se intenta ejecutar una aplicación cliente sin haber ejecutado antes la aplicación servidor o se ingresa una dirección IP o un puerto equivocado se mostrará el siguiente mensaje.

Lógica del chat

El chat consiste de dos aplicaciones, el cliente y el servidor, el servidor se ejecuta una vez y el cliente se ejecuta N veces, donde N es el número de usuarios que participarán en el chat, todos los mensajes que los usuarios del chat se envían entre sí pasan a través del servidor, cuando un cliente quiere enviar un mensaje a otro, le envía el mensaje al servidor indicándole su destinatario y el servidor se encarga de reenviar este mensaje, el servidor también se encarga de indicarle a todos los usuarios cuando un usuario nuevo se conecta al chat, para que puedan incluirlo en su lista de contactos y por ende en la conversación. Asimismo, cuando un cliente se desconecta, el servidor se encarga de informar a todos los usuarios que deben eliminar al cliente que cerró su sesión de la lista de contactos porque ya no podrán enviarle mensajes. No hay comunicación directa entre clientes, el servidor siempre es intermediario entre la comunicación de los clientes. Si se ejecutara el servidor y luego se ejecutaran cuatro clientes que se conectaran a dicho servidor, la comunicación sería como se muestra en la siguiente imagen.

Funcionamiento del servidor

El servidor tiene tres clases, que se describen a continuación:

  • VentanaS: esta clase gestiona la interfaz gráfica del servidor, que básicamente muestra un log de las principales acciones del servidor (conexión y desconexión de clientes).

  • Servidor: Esta clase gestiona a los clientes que se conectan al servidor, es un hilo que tiene como principal función escuchar constantemente en caso de que algún nuevo cliente quiera conectarse al chat.

  • HiloCliente: Cada vez que un nuevo cliente se conecta, dentro de la clase servidor se instancia un nuevo HiloCliente y se agrega a la lista de clientes. Este hilo cuenta con un socket con el que puede enviar y recibir mensajes del cliente con el que está conectado y tiene como principal función escuchar constantemente en caso de que el cliente envíe mensajes.

A continuación se muestra una imagen que ilustra el funcionamiento de la aplicación servidor.

Funcionamiento del cliente

El cliente tiene dos clases, que se describen a continuación:

  • VentanaC: esta clase gestiona la interfaz gráfica del servidor, que básicamente muestra una lista de contactos, un historial de conversación, una caja de texto para ingresar nuevos mensajes y un botón que permite enviar mensajes.

  • Cliente: Cuando el cliente se instancia y se conecta con el servidor, se crea un hilo que está escuchar constantemente en caso de que el servidor envíe mensajes. Este cliente cuenta con un socket con el que puede enviar y recibir mensajes del servidor con el que está conectado.

A continuación se muestra una imagen que ilustra el funcionamiento de la aplicación cliente.

Intérprete sencillo utilizando Java, Jlex y Cup

En los cursos de compiladores de la universidad, es bastante común que se solicite al estudiante desarrollar un intérprete, una herramienta que reciba como entrada cierto lenguaje de programación y lo ejecute, pero la mayoría de documentación al respecto solo muestra ejemplos de cosas sencillas, como una calculadora o un lenguaje que imprime cadenas en consola. Pero qué pasa si lo que deseamos es que se ejecuten sentencias de control como el IF o ciclos como la sentencia WHILE y que además estas sentencias soporten muchos niveles de anidamiento, que se declaren variables y se asigne valores a estas variables, que se tenga control de los ámbitos de las variables, en fin, que tenga las funciones básicas de un lenguaje de programación. No es común encontrar este tipo de ejemplos, en lo personal, puedo asegurar que nunca encontré un tutorial en el que se mostrara un ejemplo documentado y bien explicado sobre esto. Es por eso que les traigo este ejemplo, espero que les sea útil.

Funcionamiento de la aplicación

En este tutorial se desarrolla un intérprete que recibe como entrada un archivo de texto que contiene varias sentencias en un lenguaje programación diseñado especialmente para esta aplicación, primero se hace análisis léxico y sintáctico de dicha entrada, durante el análisis sintáctico se carga en memoria un Árbol de Sintaxis Abstracta (AST) que se utiliza posteriormente para ejecutar las sentencias. Los analizadores se generan con Jlex y Cup. Se desarrollaron dos versiones del proyecto, una utilizando Windows 10 y otra utilizando Ubuntu 14.04. El proyecto completo del ejemplo puede descargarse de los siguientes enlaces:

Intérprete sencillo utilizando Java, Jlex y Cup (Linux)
Intérprete sencillo utilizando Java, Jlex y Cup (Windows)

Todo el código dentro del proyecto está documentado con comentarios que contienen explicaciones sobre su funcionamiento.

Si desean una pequeña introducción al uso de Jlex y Cup pueden visitar mi post: Mi primer proyecto utilizando Jlex y Cup (Linux) o bien Mi primer proyecto utilizando Jlex y Cup (Windows).

El lenguaje de entrada

Dentro de la carpeta del proyecto, hay un archivo de entrada llamado “entrada.txt”, en él se muestran ejemplos de todas las funciones del lenguaje diseñado para esta aplicación, al leerlo se puede tener una idea clara de las funciones con las que el lenguaje cuenta, este archivo contiene lo siguiente:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/******************************************
* Ejemplo desarrollado por Erick Navarro *
* Blog: e-navarro.blogspot.com *
* Septiembre - 2015 *
******************************************/

//Se imprime el encabezado
imprimir("Tablas de" & " multiplicar");

//Se declara la variable a, de tipo numero
numero a;
//Se asigna a la variable a el valor 0
a=0;
//Se declara la variable c, de tipo numero
numero c;
//Se asigna a la variable c el valor 0
c=1;
//Se imprime un separador
imprimir("----------------");
/**
* Se imprimen las tablas del 1 al 5 y
* para cada tabla, se imprimen los resultados
* desde el uno hasta el 5, esto se hace con
* dos ciclos while anidados.
**/
mientras(a<4+c){
a=a+1;
numero b;
b=0;
mientras(b<4+c){
b=b+1;
imprimir(a & " * " & b & " = " & a * b);
}
imprimir("----------------");
}

//Se asigna a la variable a el valor de 11
a=11;
/**
* La variable b ya había sido declarada pero
* dentro del ámbito del primer ciclo while,
* entonces no existe en este ámbito por lo que
* debe declararse.
**/
numero b;
//Se asigna valor de 12 a b y valor de 13 a c
b=12;
c=13;
/**
* Se evalua si el valor de la variable a es
* mayor que 10, si el b es mayor que 11 y si
* el de c es mayor que 12.
**/
If(a>10){
imprimir("a es mayor que 10.");
if(b>11){
imprimir("a es mayor que 10 y b es mayor que 11.");
if(c>12){
imprimir("a es mayor que 10, b es mayor que 11 y c es mayor que 12.");
}
}
}else{
imprimir("a es menor o igual que 10.");
}

Como se puede observar, el lenguaje acepta:

  • Comentarios de muchas líneas (//).

  • Comentarios de una línea (//).

  • Concatenación de cadenas, mediante el operador “&”.

  • Función “imprimir”: que recibe como parámetro una cadena e imprime en consola dicha cadena.

  • Declaración de variables: el único tipo de variables que el lenguaje soporta es “numero”, que es una variable de tipo numérico que suporta números enteros o con punto decimal (Dentro del rango del tipo Double de Java).

  • Asignación de variables, a cualquier variable se le puede asignar cualquier expresión que tenga como resultado un número.

  • Instrucción “mientras”: tiene el comportamiento clásico del ciclo while, ejecuta el ciclo mientras la expresión booleana que recibe sea verdadera. Esta instrucción soporta anidamiento.

  • Instrucción “if” e instrucción “if-else”: si la expresión booleana que recibe es verdadera entonces ejecuta las instrucciones contenidas en el “if”, si es falsa y la instrucción tiene un “else” entonces se ejecutan las instrucciones contenidas en el “else”. Esta instrucción soporta anidamiento.

  • Expresiones aritméticas: Estas expresiones soportan sumas, restas, divisiones, multiplicaciones, expresiones negativas y paréntesis para agrupar operaciones. Tiene la precedencia habitual de las expresiones aritméticas.

  • Expresiones booleanas: comparan dos expresiones que tengan como resultado un número y soportan únicamente los operadores mayor que y menor que (<, >).

El resultado de la ejecución

Al ejecutar el archivo de entrada mostrado anteriormente se obtiene el siguiente resultado en consola:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
run:
Tablas de multiplicar
----------------
1.0 * 1.0 = 1.0
1.0 * 2.0 = 2.0
1.0 * 3.0 = 3.0
1.0 * 4.0 = 4.0
1.0 * 5.0 = 5.0
----------------
2.0 * 1.0 = 2.0
2.0 * 2.0 = 4.0
2.0 * 3.0 = 6.0
2.0 * 4.0 = 8.0
2.0 * 5.0 = 10.0
----------------
3.0 * 1.0 = 3.0
3.0 * 2.0 = 6.0
3.0 * 3.0 = 9.0
3.0 * 4.0 = 12.0
3.0 * 5.0 = 15.0
----------------
4.0 * 1.0 = 4.0
4.0 * 2.0 = 8.0
4.0 * 3.0 = 12.0
4.0 * 4.0 = 16.0
4.0 * 5.0 = 20.0
----------------
5.0 * 1.0 = 5.0
5.0 * 2.0 = 10.0
5.0 * 3.0 = 15.0
5.0 * 4.0 = 20.0
5.0 * 5.0 = 25.0
----------------
a es mayor que 10.
a es mayor que 10 y b es mayor que 11.
a es mayor que 10, b es mayor que 11 y c es mayor que 12.
BUILD SUCCESSFUL (total time: 0 seconds)

Sobre la tabla de símbolos

La tabla de símbolos es una parte importante en el proceso de ejecución del código, es en esta estructura de datos en donde guardamos información de las variables como su tipo, identificador y valor. A esta estructura podemos pedirle el valor de una variable, o pedirle que le asigne cierto valor a una variable.

Es importante mencionar que en el proceso de ejecución la tabla de símbolos va cambiando de forma dinámica, esto con el objetivo de manejar los ámbitos, por ejemplo, la instrucción WHILE tiene su propio ámbito, lo que significa que su tabla de símbolos contiene información de las variables declaradas en ámbitos superiores y la información de las variables declaradas en el ámbito local de la instrucción, al terminar de ejecutar la instrucción, todas las variables declaradas en el ámbito local se eliminan de la tabla de símbolos que almacena la información de los ámbitos superiores, de tal manera que los ámbitos superiores no tendrán acceso a las variables declaradas dentro del WHILE.

La magia detrás de todo esto: Árbol de sintaxis abstracta (AST)

Un árbol de sintaxis abstracta (AST) es una representación simplificada de la estructura sintáctica del código fuente. A nivel de programación un AST es una estructura de datos que se genera durante el proceso de análisis sintáctico.

En este ejemplo el AST es la pieza más importante porque al recorrerlo pueden ejecutarse las acciones del código de entrada y ese es el principal objetivo de la aplicación.

En el código fuente de Cup se observa que la mayoría de las acciones se enfocan en cargar el AST, básicamente es lo único que hace el analizador, además de verificar que la sintaxis de la entrada sea correcta

La estructura en este caso es un tanto compleja ya que cada nodo puede tener muchos hijos, en el caso de las instrucciones IF-ELSE y WHILE, el número de hijos es incierto ya que estas instrucciones pueden contener muchas otras instrucciones dentro, lo cierto es que el árbol se acopla muy bien al lenguaje de programación porque en el árbol se tiene bien claro qué instrucciones están contenidas dentro de otras instrucciones, porque cada nodo esta directamente ligado a sus hijos, entonces la ejecución de instrucciones anidadas no representa mayor problema.

Hacemos análisis sintáctico una sola vez para cargar el árbol, posteriormente recorremos ese árbol para ejecutar el código.

El árbol es una representación exacta de lo que el código de entrada contiene. Las únicos tres paquetes del proyecto son:

  • analizadores: que contiene los archivos de Cup y JLex y los analizadores que con estas herramientas se generaron.

  • arbol: que contiene todas las clases que forman parte del AST, que se utiliza como estructura primaria en la aplicación.

  • interpretesencillo: que contiene la clase principal de la aplicación.

Fuentes consultadas:

Compiladores, principios, técnicas y herramientas. Aho, Lam, Sethi y Ullman. Segunda Edición.

Analizador sintáctico en Visual Basic

En esta publicación se muestra un ejemplo sencillo de la implementación de un analizador sintáctico a partir de una gramática independiente del contexto. Este proyecto se desarrolló utilizando Visual Studio 2013. El proyecto completo puede descargarse del siguiente enlace:

Analizador sintáctico en Visual Basic.

Todo el código dentro del proyecto está documentado con comentarios que contienen explicaciones sobre su funcionamiento.

Funcionamiento del proyecto

Este ejemplo ilustra la implementación de un analizador sintáctico a partir de una gramática independiente del contexto. No se utiliza ningún generador de analizadores sintácticos que genere el analizador, ni se realiza el proceso de análisis sintáctico con ninguna librería. Los errores identificados en el proceso de análisis sintáctico se muestran en consola, si en el entorno de Visual Studio no aparece la consola, esta puede abrirse desde el menú ver, en la opción resultados o con Ctrl+Alt+O. Inicialmente se muestra una expresión aritmética de ejemplo que puede utilizarse como entrada, esta entrada contiene una expresión incompleta que léxicamente es correcta pero sintácticamente no, su estructura es incorrecta porque le hace falta un numero y un paréntesis derecho al final.

Al presionar el botón Analizar se ejecuta el análisis de la entrada y en consola se despliegan los mensajes de error.

El fundamento teórico que sirvió de soporte para el desarrollo de este ejemplo es el descrito en la sección 4.4.1 titulada Análisis sintáctico de descenso recursivo del libro: Compiladores, principios, técnicas y herramientas. Aho, Lam, Sethi y Ullman. Segunda Edición.

Gramática independiente del contexto utilizada

La gramática utilizada, reconoce expresiones aritméticas respetando la precedencia de operadores, no es ambigua y no tiene recursividad por la izquierda. La gramática es la siguiente:

1
2
3
4
5
6
7
8
9
10
E  → T E'
E' → + T E'
E' → - T E'
E' → ε
T → F T'
T' → * F T'
T' → / F T'
T' → ε
F → ( E )
F → numero

Método utilizado para el desarrollo del analizador

Se desarrolló un analizador sintáctico predictivo recursivo. Los analizadores predictivos o descendentes consisten en la construcción de un árbol de análisis sintáctico para la cadena de entrada, partiendo desde la raíz y creando los nodos del árbol de análisis sintáctico en pre-orden. En este caso no se construye un árbol en memoria, ya que no es necesario guardar lo que se analiza, pero las llamadas recursivas a los diferentes métodos del analizador crean un árbol en pila mientras se ejecutan. La construcción de este analizador sintáctico predictivo recursivo sigue los siguientes principios:

  • Consiste en un conjunto de procedimientos, uno para cada no terminal.

  • La ejecución empieza con el procedimiento para el símbolo inicial.

  • Se detiene y anuncia que tuvo éxito si el cuerpo de su procedimiento explora la cadena completa de entrada.

  • Para cada no terminal del lado derecho de las producciones se hace una llamada al método que le corresponde.

  • Para cada terminal del lado derecho de las producciones se hace una llamada al método match enviando como parámetro el terminal.

  • El método match valida si el terminal que se recibe es el que se esperaba, de no ser así despliega un mensaje de error.

  • La gramática a utilizar reconoce expresiones aritméticas y cumple con lo siguiente:

  • No es ambigua

  • No tiene recursividad por la izquierda

Sobre la recuperación de errores sintácticos Este ejemplo es bastante básico, por lo que no tiene implementado un sistema de recuperación de errores sintácticos, para hacerlo existen muchas estrategias, como las siguientes:

  • Recuperación en modo pánico

  • Recuperación a nivel de frase

  • Producción de errores

  • Corrección global

Se recomienda la recuperación en modo pánico por ser la más sencilla de implementar

Fuentes consultadas:

  • Compiladores, principios, técnicas y herramientas. Aho, Lam, Sethi y Ullman. Segunda Edición. Sección 4.4.1.
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×