Tabla de Contenidos

Comunicación en red: Sockets

Las aplicaciones que se comunican a través de una red, utilizan la insfraestructura de protocolos con las que se rigen las redes. En nuestro caso tan solo nos debemos preocupar de cómo programar el funcionamiento de esa comunicación.

El primer concepto que debemos tratar es que toda aplicación en red utiliza una arquitectura cliente-servidor. Una parte del programa establecerá una conexión con otra parte del programa, y esta segunda parte está pendiente de atender conexiones:

Figure 1: Un programa siempre inicia la comunicación y otro la atiende

Por último, a la hora de crear una aplicación en red, en muchos casos queremos permitir que varios clientes se conecten al servidor al mismo tiempo, por lo que también debemos utilizar los conceptos de la programación multihilo para poder atender todas las solicitudes de los clientes de forma simultánea.

Arquitectura cliente-servidor

A la hora de desarrollar aplicaciones en red, debemos entender que están compuesta de dos programas que se ejecutan de forma independiente, y que debo iniciar por separado.

Algunos aspectos que debemos tener en cuenta en una aplicación cliente-servidor son:

clienteservidor.jpg
Figure 2: Arquitectura cliente-servidor

Sockets

Un socket es la conexión que se establece entre dos aplicaciones en dos hosts diferentes, una aplicación cliente en un host y otra aplicación servidor en otro (siguiendo la arquitectura cliente-servidor) a través de una red (LAN, WAN, . . .)

Sockets en Java

El package java.net dispone de toda una API para trabajar con sockets y todo lo necesario para desarrollar aplicaciones cliente-servidor. Además, como ya se ha visto en el tema anterior, también disponemos de una API completa para desarrollar aplicaciones multihilo.

Para trabajar con sockets en Java disponemos de las clases Socket y ServerSocket. Nos permiten realizar conexiones desde un cliente o para recibir y establecer la conexión desde un servidor, respectivamente.

Figure 3: Cliente y servidor conectados

Clase Socket

Para que una aplicación Java pueda realizar una conexión de red mediante un socket cliente, necesitaremos dos parámetros: la dirección IP del host al que nos queremos conectar y el puerto donde “escucha” la aplicación servidor a la que nos queremos conectar. A continuación, es esencial establecer los flujos de comunicación que permitirán comunicarnos hacia el servidor (flujo de salida) y recibir los mensaje que éste nos envíe (flujo de entrada).

MétodoFunción
Socket(String host, int port) Consctructor de la clase, se conecta a la dirección indicada
close()Cierra el socket
getInputStream()Obtiene el Stream de lectura
getOutputStream()Obtiene el Stream de escritura
isClose()Indica si el socket está cerrado
isConnected()Indica si el socket está conectado
getLocalSocketAddress()Devuelve la dirección local del socket
getRemoteSocketAddress()Devuelve la direccion del servidor

Los pasos a realizar para establecer una conexión desde un socket cliente son:

  1. Crear el socket indicando IP y Puerto del servidor
  2. Abrir los flujos de lectura y escritura
  3. Intercambiar datos con el servidor
  4. Cerrar los flujos de datos
  5. Cerrar la conexion
. . .
// Realiza la conexión con el host remoto
Socket socketCliente = new Socket("10.10.10.10", 55555);
// Establece los flujos de comunicación de entrada y salida
PrintWriter salida = new PrintWriter(socket.getOutputStream(), true);
BufferedReader entrada = new BufferedReader(new InputStreamReader(socket.getInputStream()));
. . .
//Realiza comunicación
salida.println("mensaje...");
String mensaje = entrada.readLine();
. . .
salida.close();
entrada.close();
socketCliente.close()

Clase ServerSocket

La claseServerSocket permite que aplicaciones Java se ejecuten en una máquina y definir un puerto, que será al que los Sockets cliente dirigirán sus intentos de conexión.

Para crear un ServerSocket sólo es necesario indicar el puerto en el que la aplicación quedará “escuchando” las conexiones de los clientes.

Una vez que tenemos definido el puerto de escucha, ServerSocket dispone del método accept() el cual bloquea la ejecución de la aplicación hasta que se recibe la conexión de un Socket cliente. En el momento en que se recibe una conexión, el método accept() nos ofrece un objeto Socket, para poder comunicarnos con el Socket del cliente. Mediante ese socket podremos abrir los canales de lectura y escritura de datos.

Figure 4: Correspondencia entre streams de cliente y servidor

Hay que tener en cuenta, según se puede observar en el gráfico anterior, que el canal de entrada del socket cliente corresponde con el de salida del socket del servidor y viceversa.

Pasos para crear un servidor:

  1. Crear el ServerSocket indicando el puerto de escucha
  2. Esperar a recibir una conexión (accept())
  3. Obtener el socket con el que establezco una conexión con el cliente
  4. Abrir flujos de lectura/escritura
  5. Intercambiar datos con el cliente
  6. Cerrar los flujos de lectura/escritura
  7. Cerrar la conexión
  8. Si no se esperan más conexiones de clientes, cerrar el ServerSocket
cliente_servidor.jpg
Figure 5: Secuencia conexión cliente y servidor
// Construyo un servidor que admite conexiones en el puerto 55555
ServerSocket socketServidor = new ServerSocket(55555);
 
// El servidor se queda esperando la conexión de un cliente
Socket socketCliente = socketServidor.accept();
 
// Una vez recibida la conexión establece los flujos de comunicación con ese cliente
PrintWriter salida = new PrintWriter(socketCliente.getOutputStream(), true);
BufferedReader entrada = new BufferedReader(new InputStreamReader(socketCliente.getInputStream()));
. . .
//Realiza la comunicación
entrada.readLine();
salida.println("mensaje...");
. . .
entrada.close();
salida.close();
socketCliente.close();
socketServidor.close();
Funcionamiento y explicación de Socket y ServerSocket

Recibir varias conexiones

El método accept() se encarga de recibir la conexión de un cliente. Podemos hacer que nuestro programa pueda recibir más conexiones invocándo ese método varias veces. Pero mientras el servidor no utilice hilos no podrá atenderlos simultaneamente, sino por turnos:

// Espero la conexión de un cliente
Socket cliente1 = socketServidor.accept();
PrintWriter salida1 = new PrintWriter(cliente1.getOutputStream(), true);
BufferedReader entrada1 = new BufferedReader(new InputStreamReader(cliente1.getInputStream()));
 
// Espero la conexión de otro cliente
Socket cliente2 = socketServidor.accept();
PrintWriter salida2 = new PrintWriter(cliente2.getOutputStream(), true);
BufferedReader entrada2 = new BufferedReader(new InputStreamReader(cliente2.getInputStream()));
 
// Realizo la comunicación con el cliente1
entrada1.readLine();
salida1.println("mensaje...");
// Realizo la comunicación con el cliente2
entrada2.readLine();
salida2.println("mensaje...");

Servidor multihilo

El ejemplo de servidor que hemos visto solo podrá atender a un cliente, ya que cuando recibe una conexión de un cliente (accept()) no sigue escuchando nuevas peticiones. Si queremos que nuestro servidor pueda aceptar una cualquier cantidad de conexiones necesitamos el uso de la programación multihilo (Threads).

A continuación se muestra una forma sencilla de hacer que nuestra aplicación servidor, al recibir una conexión, cree un hilo que se encargue de ejecutar el código para atenderla, sin afectar al hilo principal de la aplicación. De esta forma es capaz de volver a escuchar nuevas conexiones mientras está atendiendo las ya recibidas.

multithread.jpg
Figure 6: Funcionamiento servidor multihilo
. . .
// El servidor comienza a conexiones escuchar
ServerSocket socketServidor = new ServerSocket(55555);
. . .
// Recibe la conexión de un cliente
while (conectado) {
  Socket socketCliente = socketServidor.accept();
 
  // Crea y lanza un hilo para atender a ese cliente
  ConexionCliente conexionCliente = new ConexionCliente(socketCliente);
  conexionCliente.start();
}
. . .

Clase para el hilo que gestiona las comunicaciones de un cliente:

public class ConexionCliente extends Thread {
  private Socket socket;
  private PrintWriter salida; 
  private BufferedReader entrada;
 
  public ConexionCliente(Socket socket){
    this.socket = socket;
    crearCanalesIO();
  }
 
  public void crearCanalesIO(){
    // Establece los flujos de comunicación con ese cliente
    salida = new PrintWriter(socket.getOutputStream(), true);
    entrada = new BufferedReader(new InputStreamReader(socket.getInputStream()));
  }
 
  @Override
  public void run() {
     ...
     // Método que ejecutará el código para la comunicacion con el cliente
     // Leemos o escribimos a través de los flujos entrada y salida
     salida.println("Bienvenido");
     String mensajeRecibido = entrada.readLine();
  }
 
  public void desconectar(){
    entrada.close();
    salida.close();
    socket.close();
  }
}

Clases útiles

Además de las clases para trabajar con Sockets, a continuación se indican algunas clases interesantes del paquete java.net para programación en red, y también otras clases del paquete java.io para manejar los flujos de lectura/escritura:

Paquete java.net:

ClaseFunción
InetSocketAddressRepresenta la dirección de un socket mediante IP y puerto
InetAddressRepresenta una dirección de red
Inet4AddressRepresenta dirección IPv4, mediante 4 bytes

Paquete java.io:

Tipo de datosClaseFunción
bytesIn(Out)putStreamSon las clases bases para leer flujos de bytes desde fichero o desde la red
bytesObjectIn(Out)putStreamFlujo de bytes para datos primitivos y objetos serializados
bytesBufferedIn(Out)putStreamFlujo de bytes que usa un buffer para mejorar su rendimiento
textoInputStreamReaderHace de puente entre un flujo de bytes y un flujo de caracteres
textoBufferedReader Permite usar un buffer sobre un flujo de caracteres
textoPrintWriterEscribe flujos de bytes como cadenas de caracteres de forma sencilla. Tiene un constructor que activa autoflush

Hay muchas otras clases en el paquete java.io, pero con las anteriores me basta para transmitir todo tipo de datos a través de los canales de lectura y escritura de un socket. Para usarlas debo atender a las siguientes consideraciones:

Consideraciones sobre flujos de entrada y salida

Las siguientes cuestiones se deben tener en cuenta cuando utilizo clases de lectura/escritura (Streams) para transferir información por los canales de un socket.

  1. Los flujos de entrada y salida del socket solo deben ser cerrados cuando no vayamos a usar más ese socket para transmitir información durante la ejecución de mi programa.
  2. Cada socket tiene dos canales: uno de entrada (clase InputStream), obtenido con el método getInputStream() y uno de salida (clase OutputStream) obtenido con el método getOutputStream().
    1. Puedo utilizar objetos de otras clases (PrintWriter, BufferedReader, ObjectOutputStream, etc) para trabajar con estos dos canales de entrada y salida.
    2. Pero si creo un objeto para trabajar con un canal de E/S, no debo volver a crear otro objeto que use el mismo canal; debo usar el objeto de E/S elegido durante todo el uso del socket.
  3. Si en mi programa tengo diferentes clases que usan el mismo socket para transferir, debo pasar por parámetro a esas clases el socket, o los objetos de entrada y salida del socket, ya que no puedo crear nuevos objetos.
  4. Hay algunas clases que me permiten escribir texto, otras tipos primitivos, otras objetos, etc. Es por esto, que debo estudiar qué clases me interesa utilizar para el tipo de aplicación que estoy programando.
    1. Si un socket debe transmitir información de tipo texto, me basta con las clases PrintWriter y BufferedReader.
    2. Las clases ObjectIn/OutputStream me permite transmitir bytes, tipos primitivos y objetos.

En StackOverFlow hay respuestas a problemas originados de incumplir estas cuestiones. Aquí lo comentan en la primera respuesta.

Transferencia de texto

A continuación vemos un ejemplo práctica de aplicación que usas clases para transferir datos de texto. Utilizamos las clases de los ejemplos anteriores : PrintWriter para el flujo de salida y BufferedReader para el de entrada.

Cliente/Servidor echo

echo es un servicio que simplemente repite el mensaje que el cliente le envía a través de un socket. Es un servicio muy sencillo (y poco útil) pero que nos permitirá comprobar la conectividad y la funcionalidad de los sockets para un primer instante.

Cliente echo

. . .
// Nombre y puerto del servidor
String hostname = "10.10.10.10"; 
int puerto = 44444;
try {
  Socket socket = new Socket(hostname , puerto);
  PrintWriter salida = new PrintWriter(socket.getOutputStream(), true);
  BufferedReader entrada = new BufferedReader(new InputStreamReader(socket.getInputStream()));
  // Captura el teclado del usuario
  BufferedReader teclado = new BufferedReader(new InputStreamReader(System.in) );
  String cadena = null;
  // Envia lo que el usuario escribe por teclado al servidor y lee la respuesta
  while ((cadena = teclado.readLine()) != null) { 
    salida.println(cadena); 
    System.out.println(entrada.readLine());
  }
} catch (UnknownHostException uhe) { 
  . . . 
} catch (IOException ioe) { 
  . . . 
}
. . .

Servidor echo

. . .
int puerto = 44444; 
try {
  ServerSocket socketServidor = new ServerSocket(puerto);
  // Espera la conexion con un cliente
  Socket socketCliente = socketServidor.accept();
  // Establece los flujos de salida y entrada (hacia y desde el cliente, respectivamente)
  PrintWriter salida = new PrintWriter(socketCliente.getOutputStream(), true); 
  BufferedReader entrada = new BufferedReader(new InputStreamReader(socketCliente.getInputStream()));
 
  // Envia algunos mensajes al cliente en cuanto este se conecta
  salida.println("Solo sé repetir lo que me escribas"); 
  salida.println("Cuando escribas ’.’, se terminara la conexión");
  String linea = null;
  while ((linea = entrada.readLine()) != null) {
    if (linea.equals(".")) { 
      socketCliente.close(); 
      socketServidor.close(); 
      break;
    } 
  }
} catch (IOException ioe) { 
  . . . 
}
. . .

Servidor echo multihilo

En este caso, aprovechando las características de los hilos de Java, implementaremos una versión del servidor de echo capaz de atender múltiples conexiones simultáneas.

Clase Servidor

En este caso, para el servidor multihilo, tendremos una clase Servidor, que se corresponde con el siguiente código, y que lanza un objeto ConexionCliente para atender a cada uno de los clientes que se conectan al servidor. De esa manera, puesto que el objeto ConexionCliente es un hilo, puede atender una petición mientras espera otra y asi sucesivamente.

. . .
ServerSocket servidor = null; 
ConexionCliente conexionCliente = null; 
int puerto = 44444;
try {
  servidor = new ServerSocket(puerto); 
  while (!servidor.isClosed()) {
    conexionCliente = new ConexionCliente(servidor.accept());
    conexionCliente.start(); 
  }
} catch (IOException ioe) { 
  . . . 
}
. . .

Clase ConexionCliente: Hilo

En esta clase ConexionCliente habrá que implementar lo necesario para atender una sola petición de cliente. Hay que tener en cuenta que habrá tanto objetos de esta clase como clientes conectados en un momento determinado. Al tratarse de un hilo se ejecutará en segundo plano y podrán atenderse múltiples de ellas al mismo tiempo.

public class ConexionCliente extends Thread {
  private Socket socket;
  private PrintWriter salida; 
  private BufferedReader entrada;
 
  public ConexionCliente(Socket socket) throws IOException {
    this.socket = socket;
    salida = new PrintWriter(socket.getOutputStream(), true);
    entrada = new BufferedReader(new InputStreamReader(socket.getInputStream()));
  }
 
  @Override
  public void run() {
  salida.println("Solo sé repetir lo que me escribas"); 
  salida.println("Cuando escribas ’.’, se terminara la conexión");
  try {
    String mensaje = null;
    while ((mensaje = entrada.readLine()) != null) {
      if (mensaje.equals(".")) { 
        socket.close();
        break; 
      }
 
      salida.println(mensaje);
    }
  } catch (IOException ioe) { 
    . . . 
  } 
}
Servicio multihilo de mensajería

Transmitir otros tipos de datos

Los ejemplos planteados en este bloque se centran en transferir texto a través de los sockets. Pero nos puede interesar transferir otro tipo de información: bytes de un fichero completo, objetos creados por nosotros, etc. El funcionamiento de los sockets no cambia ya que están diseñados para transmitir bytes, pero necesitaremos utilizar otras clases para envolver los streams de entrada y salida del socket.

Las clases más completas son ObjectInputStream y ObjectOutputStream, ya que permiten escribir tanto bytes, como tipos primitivos u objetos.

Objetos

Para transmitir objetos, las clases ObjectInputStream y ObjectOutputStream tienen métodos para escribir y leer directamente objetos completos.

El único requisito es que toda clase del objeto que deseo transmitir, o las clases de los objetos de las que se componen, implementen la interface Serializable.

Socket cliente;
ArrayList<Persona> personas;
ObjectOutputStream flujoSalida = new ObjectOutputStream(cliente.getOutputStream());
ObjectInputStream flujoEntrada = new ObjectInputStream(cliente.getInputStream());
. . .
public void enviarLista(){
   flujoSalida.writeObject(personas);
}
. . .
public void recibirLista(){
   ArrayList<Persona> listaRecibida = (ArrayList<Persona>)flujoEntrada.readObject();
}

* En el lado Servidor debo crear siempre primero el ObjectOutputStream, antes que el ObjectInputStream. Si no, corremos riesgo de bloqueo.

Método flush()

En los casos en los que realice una comunicación bidireccional (sockets escribiendo y esperando a leer desde el mismo extremo), puedo tener la necesidad de que sea una comunicación síncrona: un extremo del socket se queda esperando a recibir datos del otro extremo, para poder una respuesta.

Si recordamos, cuando utilizamos la clase PrintWriter, indicamos en su constructor el valor true como segund0 parámetro, activando la opción autoFlush. Si no usamos ese contructor, también debemos llamar al método flush().

// Activamos autoflush en el constructor en el segundo parámetro (true)
PrintWriter salida = new PrintWriter(socket.getOutputStream(), true);
// De este modo me ahorro tener que llamar al método flush()

Sin embargo, la clase ObjectOutputStream no tiene ese constructor. Para evitar que queden datos sin enviar en el buffer de un flujo de salida (output), debo llamar explícitamente al método flush():

ObjectOutputStream salida = new ObjectOutputStream(socket.getOutputStream());
. . .
entrada.readInt();
 
salida.writeObject(datos);
salida.flush();

Método reset()

Cuando envío el mismo objeto repetidas veces a través del mismo extremo del socket, la clase ObjectOutputStream almacena en caché una copia de ese objeto, para que el envío sea más rápido la próxima vez.

Esto genera el problema de que si yo modifico propiedades de ese objeto, el método writeObject() no puede reconocer si el objeto se ha modificado o es el mismo de antes, y envía la copia que tiene ya en caché de ese objeto:

ObjectOutputStream salida = . . .;
 
persona.setNombre("Fernando");
salida.writeObject(persona); //Almacena en cache una copia de persona
 
persona.setNombre("Juan")
salida.writeObject(persona); //Envía la copia almacenada en cache (nombre->Fernando)
 
persona.setNombre("Maria");
salida.writeObject(persona);  //Envía la copia almacenada en cache (nombre->Fernando)

Cuando tengo una situación en la que debo enviar el mismo objeto modificado varias veces por un mismo canal, debo asegurarme de resetear la caché del flujo de salida (salida.reset()) después de enviar el objeto.

Esto puede ser algo necesario en una aplicación en la que envío varias veces un mismo objeto List con una lista de objetos que se ha ido actualizando añadiendo o eliminando elementos.

Ficheros

En este caso, aparte de las clases para transmitir a través del socket, necesito leer desde un objeto File para enviarlo por el socket, y escribirlo en disco al recibirlo desde el otro lado del socket: Por lo tanto, necesito también las clases FileIn(Out)putStream.

private Socket socket;
. . .
FileInputStream lectorFichero = new FileInputStream(fichero);
ObjectOutputStream salida = new ObjectOutputStream(socket.getOutputStream());
 
//Envío al receptor el tamaño total del fichero
salida.writeLong(fichero.length());
 
byte[] buffer = new byte[1024];
int bytesLeidos = 0;
while( (bytesLeidos = lectorFichero.read(buffer)) > 0){
   //Envio datos a través del socket
   salida.write(buffer, 0, bytesLeidos);
   //Me aseguro de que se escriben todos lo bytes del buffer
   salida.flush();
}
lectorFichero.close();
private Socket socket;
. . .
FileOutputStream escritorFichero = new FileOutputStream(ficheroDestino);
ObjectInputStream entrada = new ObjectInputStream(socket.getInputStream());
 
//Leo el tamaño del fichero que me ha enviado el emisor
long fileSize = entrada.readLong()
 
byte[] buffer = new byte[1024];
int bytesLeidos;
long totalLeido = 0;
//El bucle debe terminar cuando ha recibido la totalidad de bytes
// Sin esa condición, el bucle se va a quedar indefinidamente esperando a leer
while( totalLeido < fileSize && (bytesLeidos = entrada.read(buffer)) > 0 ){
   escritorFichero.write(buffer, 0, bytesLeidos);
   totalLeido += bytesLeidos; //Llevo la cuenta de los bytes leidos
}
escritorFichero.close();

Como acabamos de ver, para enviar un fichero, la parte del programa que lo envía a través del socket necesita enviarle previamente el tamaño de fichero para que el receptor sepa cuántos bytes debe leer. Si no enviamos el tamaño del fichero al receptor, el receptor nunca va a saber cuándo ha recibido la totalidad del fichero, y se quedará esperando a leer desde el socket indefinidamente.

Esto no ocurre con el flujo de lectura desde fichero, solamente con los flujos de lectura desde el socket. Cuando leemos desde un flujo de fichero (FileInputStream), hay un momento en que los bytes de ese fichero se acaban y el bucle se termina. Sin embargo al leer de un socket, no puedo saber cuándo se acaban los bytes ya que el socket seguirá abierto durante la ejecución de todo el programa, y puedo seguirlo usando para recibir otros ficheros más tarde.

Diferentes tipos de datos

Enviar objetos es útil, y lo podemos aplicar a la transferencia de ficheros. El cliente podría indicarle al servidor qué fichero desea descargar de una lista que previamente le envía el servidor.

//Lista Strings que contiene las rutas de los ficheros disponibles
List<String> listaFicheros;
//Se la envía al cliente cuando se conecta
salida.writeObject(listaFicheros);
//Espera la selección del cliente
String seleccion = (String)entrada.readObject();
 
//Crea el fichero para enviar su tamaño y disponerse a enviarlo a través del socket
File fichero = new File(seleccion);
salida.writeLong(fichero.length());
 
//Procede a leer el fichero y enviarlo a través del socket como en el ejemplo anterior
//El cliente recibe la lista de ficheros disponibles al conectarse
List<String> listaFicheros = (List<String>)entrada.readObject();
. . .
//Envía al servidor el String con la selección del usuario
salida.writeObject(ficheroElegido);
 
//Espera a recibir el tamaño del fichero para proceder la lectura
long fileSize = entrada.readLong();
 
//Procede a leer desde el socket como en el ejemplo anterior y escribirlo en fichero
Video: Transferir objetos, bytes y tipos de datos primitivos a través de sockets

Proyectos de ejemplo

Diferentes proyectos de este tema, junto con los expuestos en los videos, se pueden encontrar en el repositorio de red de Bitbucket.

Los proyectos de los ejercicios que se vayan haciendo en clase estarán disponibles en el repositorio psp-ejercicios de Bitbucket


Prácticas


© 2024 Santiago Faci y Fernando Valdeón