Cliente Servidor con Threads en Java

COMPARTIR EN REDES SOCIALES

En este artículo vamos a implementar un cliente servidor con threads en Java. Para ello vamos a utilizar Sockets, concretamente la clase «ServerSocket» y la clase «Socket«. Lo que hará nuestro servidor es enviar ficheros a petición del cliente. La idea es la siguiente:

  • Iniciamos el servidor
  • Cuando un cliente se conecte a nuestro servidor creamos un «thread» en el servidor para que lo atienda y de esta forma liberamos al servidor para que pueda seguir recibiendo peticiones.
  • El cliente pide un fichero al servidor.
  • El Thread creado en el servidor que representa al cliente conectado le envía el fichero por partes.
  • El cliente recibe cada una de estas partes y las va guardando en un fichero.
  • Una vez recibido se cierra la conexión del cliente.

Vamos al lio!

El Servidor

El servidor está compuesto por las siguientes clases:

La clase Servidor

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

public class Servidor extends Thread{

	private int puerto;
	private boolean parar = false;
	
	public Servidor(int puerto) {
		this.puerto = puerto;
	}
	
		
	public int getPuerto() {
		return puerto;
	}


	public void setPuerto(int puerto) {
		this.puerto = puerto;
	}

	public void pararServidor() {
		this.parar = true;
	}
	
	public void run() {
		ServerSocket servidor = null;
		try {
			//Ponemos el servidor a escuchar
			servidor = new ServerSocket(this.puerto);
			System.out.println("Esperando conexiones en el puerto " + this.puerto);
			
			//Mientras que no llamemos al metodo parar recibimos clientes y creamos hilos para cada cliente
			while (!parar) {
				Socket nuevoCliente = servidor.accept();
				ThreadCliente tNuevoCliente = new ThreadCliente(nuevoCliente);
				tNuevoCliente.start();
			}
			
			//Cerramos el servidor una vez hayamos salido del while
			servidor.close();
			System.out.println("Servidor cerrado correctamente");
			
		} catch (IOException e) {
			System.out.println("Servidor cerrado abruptamente");
			e.printStackTrace();
		}finally {
			//Cerramos el servidor liberando los recursos una vez hayamos llamado a parar o haya saltado una excepción crítica
			if (servidor != null) {
				try {
					servidor.close();
				} catch (IOException e1) {
					e1.printStackTrace();
				}
			}
		}
		
		
	}
}

La clase ThreadCliente

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.Socket;

public class ThreadCliente extends Thread {

	private Socket cliente = null;
	private DataOutputStream dos = null;
	private DataInputStream dis = null;
	private boolean parar = false;
	private static final int BUFFER_SIZE = 8192;
	
	//Constructor, aquí pasamos el socket obtenido en la clase Servidor como resultado del método accept()
	public ThreadCliente(Socket cliente) {
		this.cliente = cliente;
	}

	public void run() {
		// Obtenemos los flujos
		try {
			dos = new DataOutputStream(this.cliente.getOutputStream());
			dis = new DataInputStream(this.cliente.getInputStream());
			// Creamos un buffer de 8KB
			byte[] data = new byte[BUFFER_SIZE];

			while (!parar) {
				// Leemos el fichero que el servidor debe mandar
				String strFichero = dis.readUTF();
				System.out.println(
						"El cliente: " + this.cliente.getLocalAddress() + " ha solicitado el fichero: " + strFichero);

				// Obtenemos las propiedades del fichero a obtener
				File fFichero = new File("C:\\Users\\usuario\\Desktop\\Servidor\\" + strFichero);

				// Comprobamos si el fichero existe
				if (fFichero.exists()) {
					long fileSize = fFichero.length();
					int bytesLeidos;
					
					//Enviamos el tamaño del fichero
					dos.writeLong(fileSize);
					
					DataInputStream disFichero = new DataInputStream(new FileInputStream(fFichero));
					
					while ((bytesLeidos = disFichero.read(data, 0, BUFFER_SIZE)) > 0) {
						//Enviamos los bytes que hemos leido
						dos.write(data, 0, bytesLeidos);
					}
					System.out.println("Fichero enviado correctamente");
					//Cerramos el flujo de lectura del fichero
					disFichero.close();
				} else {
					//El fichero no existe, enviamos -1 para informar al cliente
					dos.writeLong(-1);
				}

			}
			
		} catch (IOException e) {
			System.out.println("Conexion con cliente: " + cliente.getRemoteSocketAddress() + " cerrada");
		}finally {
			parar();
		}
		
		System.out.println("Thread finalizado");

	}

	public void parar() {
		parar = true;
		
		try {
			if (dos != null) {
				dos.close();
			}
			
			if (dis != null) {
				dis.close();
			}
			
			if (cliente != null) {
				cliente.close();
			}
		}catch (IOException ioe) {
			ioe.printStackTrace();
		}
		
	}
}

Explicación del código en el Servidor

Para crear un servidor es necesario utilizar la clase «ServerSocket», la forma de inicializar el servidor es mediante:

//Ponemos el servidor a escuchar
servidor = new ServerSocket(this.puerto);
System.out.println("Esperando conexiones en el puerto " + this.puerto);

En este momento nuestro servidor ya está escuchando y preparado para recibir conexiones en el puerto que hayamos especificado. El paso posterior es utilizar un bucle «while» para recibir las conexiones e ir creando «threads cliente» para cada cliente conectado:

//Mientras que no llamemos al metodo parar, recibimos clientes y creamos hilos para cada cliente
while (!parar) {
	Socket nuevoCliente = servidor.accept();
	ThreadCliente tNuevoCliente = new ThreadCliente(nuevoCliente);
	tNuevoCliente.start();
}

En la línea «1» del fragmento anterior establecemos el while, iteramos siempre y cuando no hayamos llamado al método «parar» o salte alguna excepción. En la línea «2» aceptamos a un nuevo cliente como resultado de llamar al método «accept» de la clase «ServerSocket». Es importante destacar que esta línea de código es bloqueante, es decir, el hilo que llama a «servidor.accept();» se queda dormido hasta que llega una nueva conexión. Por último, creamos un nuevo «Thread», le pasamos el «Socket» que representa al cliente que se acaba de conectar e iniciamos el «Thread» mediante la llamada al método «start».

El control de errores mediante excepciones es importante y por ello es necesario revisar y comprender el método «run» al completo:

public void run() {
	ServerSocket servidor = null;
	try {
		//Ponemos el servidor a escuchar
		servidor = new ServerSocket(this.puerto);
		System.out.println("Esperando conexiones en el puerto " + this.puerto);
		
		//Mientras que no llamemos al metodo parar, recibimos clientes y creamos hilos para cada cliente
		while (!parar) {
			Socket nuevoCliente = servidor.accept();
			ThreadCliente tNuevoCliente = new ThreadCliente(nuevoCliente);
			tNuevoCliente.start();
		}
		
		//Cerramos el servidor una vez hayamos salido del while
		servidor.close();
		System.out.println("Servidor cerrado correctamente");
		
	} catch (IOException e) {
		System.out.println("Servidor cerrado abruptamente");
		e.printStackTrace();
	}finally {
		//Cerramos el servidor liberando los recursos una vez hayamos llamado a parar o haya saltado una excepción crítica
		if (servidor != null) {
			try {
				servidor.close();
			} catch (IOException e1) {
				e1.printStackTrace();
			}
		}
	}		
}

Tanto la línea «10» como la «16» pueden lanzar una excepción de entrada salida, por ello es necesario capturar la excepción e informar de que el servidor se ha cerrado de forma abrupta. Si la línea «16» se ejecuta correctamente quiere decir que nos han llamado al método parar y por lo tanto nuestro servidor se ha cerrado de forma correcta. Mediante la sección de «finally» liberamos los recursos haya habido excepción o no (ya sabéis que el «finally» se ejecuta siempre).

Con respecto a la clase Thread Cliente

Esta clase sigue la siguiente lógica, que luego veremos en profundidad:

  1. Se obtienen los flujos de entrada y salida para poder comunicarnos con el cliente.
  2. Se crea un «Array» de bytes que servirá para almacenar cada parte del fichero que vamos a enviar a nuestro cliente.
  3. Se recibe por parte del cliente el nombre del fichero a recibir.
  4. Si el fichero existe enviamos la longitud del mismo al cliente para que sepa cuantos bytes tiene que leer. Si el fichero no existe enviamos el valor «-1».
  5. Se abre el fichero solicitado por el cliente para lectura.
  6. Mediante un bucle «while», se lee el fichero por partes de 8 KBytes y se envía cada parte.
  7. Una vez salimos del «while» ya hemos enviado el fichero, y por lo tanto, ya podemos cerrar los flujos y parar el «ThreadCliente».

Veamos el código. Lo primero es obtener los flujos, mediante:

dos = new DataOutputStream(this.cliente.getOutputStream());
dis = new DataInputStream(this.cliente.getInputStream());

Utilizamos las clases «DataOutputStream» para enviar datos y la clase «DataInputStream» para recibir datos. El siguiente paso es crear el «Array» que utilizaremos para almacenar los bytes a enviar del fichero:

// Creamos un buffer de 8KB
byte[] data = new byte[BUFFER_SIZE];

Nuestro tercer paso es recibir el nombre del fichero que el servidor debe enviar:

// Leemos el fichero que el servidor debe mandar
String strFichero = dis.readUTF();
System.out.println("El cliente: " + this.cliente.getLocalAddress() + " ha solicitado el fichero: " + strFichero);

El cuarto paso es enviar la longitud del fichero:

// Obtenemos las propiedades del fichero a obtener
File fFichero = new File("C:\\Users\\usuario\\Desktop\\Servidor\\" + strFichero);

// Comprobamos si el fichero existe
if (fFichero.exists()) {
	long fileSize = fFichero.length();
    //Enviamos el tamaño del fichero
	dos.writeLong(fileSize);
    ....
}else{
    //El fichero no existe, enviamos -1 para informar al cliente
	dos.writeLong(-1);
}			

Nuestro quinto paso ya es abrir el fichero para lectura mediante un «DataInputStream»:

DataInputStream disFichero = new DataInputStream(new FileInputStream(fFichero));

El sexto paso es leer tantos bytes como podamos teniendo en cuenta que el máximo es «8 KBytes» repetidamente mediante un bucle «while» y enviar cada parte por nuestro socket a través del flujo de salida:

while ((bytesLeidos = disFichero.read(data, 0, BUFFER_SIZE)) > 0) {
	//Enviamos los bytes que hemos leido
	dos.write(data, 0, bytesLeidos);
}
System.out.println("Fichero enviado correctamente");

Por último, cerramos nuestro fichero y paramos el «ThreadCliente»:

//Cerramos el flujo de lectura del fichero
disFichero.close();
....
}finally {
		parar();
}

Nuestro método «parar» simplemente libera los recursos cerrando todos los flujos:

public void parar() {
	parar = true;
	
	try {
		if (dos != null) {
			dos.close();
		}
		
		if (dis != null) {
			dis.close();
		}
		
		if (cliente != null) {
			cliente.close();
		}
	}catch (IOException ioe) {
		ioe.printStackTrace();
	}		
}

Si nuestro «ThreadCliente» finaliza correctamente, es decir, si no se ha producido ningún error de entrada/salida, veremos en la consola del servidor:

System.out.println("Thread finalizado");

Por el contrario, si se produce cualquier excepción de entrada/salida veremos:

System.out.println("Conexion con cliente: " + cliente.getRemoteSocketAddress() + " cerrada");

De esta forma si el cliente remoto cerrase la conexión en el medio de una transferencia nuestro servidor estaría preparado, ya que informaría de que la conexión se ha perdido y liberaría los recursos asociados (flujos, socket, etc…).

¿ Cómo se lanza nuestro Servidor ?

Como nuestro servidor también es un Thread se lanza desde un método «main» de la siguiente forma:

public static void main(String[] args) {
	//Creamos el servidor en el puerto 1234
	Servidor servidor = new Servidor(1234);
	//Lo ponemos a escuchar
	servidor.start();
}

El Cliente

El cliente está compuesto de la siguiente clase:

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.Socket;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;

public class Cliente {

	private static final int BUFFER_SIZE = 8192;
	
	public static void main(String[] args) {
		byte [] data = new byte[BUFFER_SIZE];
		
		DataInputStream dis = null;
		DataOutputStream dos = null;
		Socket socket = null;
		long fileSize = -1;
		
		try {
			//Nos conectamos con el servidor
			socket = new Socket("localhost", 1234);
			System.out.println("Conectado con: " + socket.getRemoteSocketAddress());
			
			//Obtenemos los flujos
			dis = new DataInputStream(socket.getInputStream());
			dos = new DataOutputStream(socket.getOutputStream());

			//En este punto ya hemos obtenido los flujos, solicitamos el fichero a recibir
			Scanner sc = new Scanner(System.in);
			System.out.println("Introduce el fichero que quieres recibir:");
			String strFichero = sc.nextLine();
			strFichero = new String(strFichero.getBytes(), StandardCharsets.UTF_8);
			
			//Enviamos el nombre del fichero que queremos recibir
			dos.writeUTF(strFichero);
			
			//Recibimos el tamaño del fichero
			fileSize = dis.readLong();
			
			//Si fileSize == -1, el fichero no existe. El servidor envía -1 en este caso 
			if (fileSize != -1) {
				//Abrimos el fichero para escribir los bytes que recibiremos posteriormente
				DataOutputStream dosFile = new DataOutputStream(new FileOutputStream(new File("C:\\Users\\usuario\\Desktop\\Cliente\\" + strFichero)));
				
				int bytesReceived = 0;
				
				while (fileSize > 0 && (bytesReceived = dis.read(data, 0, BUFFER_SIZE)) > 0) {
					//Escribimos en el fichero los bytes recibidos
					fileSize -=  bytesReceived;
					dosFile.write(data, 0, bytesReceived);
					System.out.println(bytesReceived);
				}
				
				System.out.println("Fichero recibido correctamente!!!");
				dosFile.close();
			}else {
				System.out.println("El fichero solicitado no existe");
			}
			
		} catch (UnknownHostException e) {
			e.printStackTrace();
		} catch (IOException e) {
			System.out.println("Conexion con el servidor cerrada");
			
			if (fileSize != 0) {
				System.out.println("No se he realizado la recepción del fichero correctamente!");
			}
		}finally {
			try {
				if (dis != null) {
					dis.close();
				}
				
				if (dos != null) {
					dos.close();
				}
				
				if (socket != null) {
					socket.close();
				}
			} catch (IOException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
			
		}
	}
}

Los pasos que realiza el cliente son los siguientes:

  1. Establece la conexión con el servidor.
  2. Obtiene los flujos de entrada y salida de datos.
  3. Pregunta al usuario el nombre del fichero que desea recibir.
  4. Escribe a través de su flujo de salida el nombre del fichero.
  5. Obtiene el tamaño de fichero a través de su flujo de entrada. Este dato es enviado por el servidor como hemos visto anteriormente.
  6. Si el fichero existe, abrimos un fichero para escritura y mediante un bucle «while» recibimos cada parte del fichero. Dejamos de iterar cuando ya hayamos recibido todos los bytes.
  7. Una vez terminado el «while», cerramos el fichero. Ya no necesitamos escribir más en él.
  8. Por último liberamos los recursos y dejamos que termine nuestra aplicación.

Veamos el código por separado empezando por la conexión con el servidor:

//Nos conectamos con el servidor
socket = new Socket("localhost", 1234);
System.out.println("Conectado con: " + socket.getRemoteSocketAddress());

Esta línea se conecta con el «Servidor» y lanza una excepción sí por cualquier tipo de error no hemos sido capaces de conectarnos, por ejemplo, un «timeout» porque el servidor está parado…

La obtención de los flujos es igual que en el «ThreadCliente» del servidor pero en orden inverso, es decir, si en el servidor hemos obtenido primero el flujo de salida y entrada, aquí sería primero entrada y luego salida:

//Obtenemos los flujos
dis = new DataInputStream(socket.getInputStream());
dos = new DataOutputStream(socket.getOutputStream());

Nuestro siguiente paso es preguntar al usuario el nombre del fichero:

//En este punto ya hemos obtenido los flujos, solicitamos el fichero a recibir
Scanner sc = new Scanner(System.in);
System.out.println("Introduce el fichero que quieres recibir:");
String strFichero = sc.nextLine();
strFichero = new String(strFichero.getBytes(), StandardCharsets.UTF_8);

Ahora necesitamos enviar el nombre del fichero a través del Socket:

//Enviamos el nombre del fichero que queremos recibir
dos.writeUTF(strFichero);

Posterior a esto leemos el tamaño del fichero que vamos a recibir:

//Recibimos el tamaño del fichero
fileSize = dis.readLong();

La parte más importante, recibir el fichero:

//Si fileSize == -1, el fichero no existe. El servidor envía -1 en este caso 
if (fileSize != -1) {
	//Abrimos el fichero para escribir los bytes que recibiremos posteriormente
	DataOutputStream dosFile = new DataOutputStream(new FileOutputStream(new File("C:\\Users\\ivanr\\Desktop\\Cliente\\" + strFichero)));
	
	int bytesReceived = 0;
	
	while (fileSize > 0 && (bytesReceived = dis.read(data, 0, BUFFER_SIZE)) > 0) {
		//Escribimos en el fichero los bytes recibidos
		fileSize -=  bytesReceived;
		dosFile.write(data, 0, bytesReceived);
		System.out.println(bytesReceived);
	}
	
	System.out.println("Fichero recibido correctamente!!!");
	dosFile.close();
}else {
	System.out.println("El fichero solicitado no existe");
}

Aquí la clave está en abrir el fichero donde vamos a volcar los bytes que recibimos en modo escritura e iterar en el while siempre y cuando:

  1. Nos queden bytes por recibir
  2. los bytes recibidos sean mayor que 0

Finalmente cerraremos nuestros flujos cerrando primero el «DataOutputStream» y después mediante la clausula «finally» todos los demás recursos:

finally {
	try {
		if (dis != null) {
			dis.close();
		}
		
		if (dos != null) {
			dos.close();
		}
		
		if (socket != null) {
			socket.close();
		}
	} catch (IOException e) {
		// TODO Auto-generated catch block
		e.printStackTrace();
	}
	
}

Las pruebas de nuestro Cliente Servidor

Para probar nuestras aplicaciones lo primero será lanzar el servidor, veremos la siguiente salida:

Esperando conexiones en el puerto 1234

Luego lanzaremos el cliente:

Conectado con: localhost/127.0.0.1:1234
Introduce el fichero que quieres recibir:

Ahora introduciremos el fichero que queremos recibir:

Conectado con: localhost/127.0.0.1:1234
Introduce el fichero que quieres recibir:
video.mp4
Fichero recibido correctamente!!!

La salida del servidor habrá cambiado de la siguiente forma:

Esperando conexiones en el puerto 1234
El cliente: /127.0.0.1 ha solicitado el fichero: video.mp4
Fichero enviado correctamente
Conexion con cliente: /127.0.0.1:28694 cerrada
Thread finalizado

Como podéis observar nuestro programa ha funcionado correctamente:

Recepción del fichero en nuestro cliente.
Recepción del fichero en nuestro cliente.

Vamos a probar ahora los casos de uso de desconexiones:

¿ Qué ocurre si el cliente envía el nombre del fichero y el servidor se ha cerrado ?

Esto es lo que se verá en el cliente:

Conectado con: localhost/127.0.0.1:1234
Introduce el fichero que quieres recibir:
video.mp4
Conexión con el servidor cerrada
No se he realizado la recepción del fichero correctamente!

¿ Qué ocurre si el cliente cierra la conexión con el servidor de forma abrupta ?

Esto es lo que se verá en el servidor:

Esperando conexiones en el puerto 1234
Conexion con cliente: /127.0.0.1:20500 cerrada
Thread finalizado

Por lo tanto, nuestra gestión de errores es correcta.

Si os ha gustado la entrada compartidla en redes sociales, y dejad un comentario en la caja de comentarios para poder debatir cualquier duda que tengáis o dar vuestro apoyo.

Os dejo el código fuente aquí

El servidor:

El cliente:


COMPARTIR EN REDES SOCIALES

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *