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:
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.
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:
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, . . .)
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.
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étodo | Funció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:
. . . // 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()
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.
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:
// 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
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...");
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.
. . . // 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(); } }
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
:
Clase | Función |
---|---|
InetSocketAddress | Representa la dirección de un socket mediante IP y puerto |
InetAddress | Representa una dirección de red |
Inet4Address | Representa dirección IPv4, mediante 4 bytes |
Paquete java.io
:
Tipo de datos | Clase | Función |
---|---|---|
bytes | In(Out)putStream | Son las clases bases para leer flujos de bytes desde fichero o desde la red |
bytes | ObjectIn(Out)putStream | Flujo de bytes para datos primitivos y objetos serializados |
bytes | BufferedIn(Out)putStream | Flujo de bytes que usa un buffer para mejorar su rendimiento |
texto | InputStreamReader | Hace de puente entre un flujo de bytes y un flujo de caracteres |
texto | BufferedReader | Permite usar un buffer sobre un flujo de caracteres |
texto | PrintWriter | Escribe 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:
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.
InputStream
), obtenido con el método getInputStream()
y uno de salida (clase OutputStream
) obtenido con el método getOutputStream()
.PrintWriter
, BufferedReader
, ObjectOutputStream
, etc) para trabajar con estos dos canales de entrada y salida. PrintWriter
y BufferedReader
.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.
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.
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.
. . . // 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) { . . . } . . .
. . . 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) { . . . } . . .
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.
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) { . . . } . . .
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
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.
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.
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();
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.
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.
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
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
© 2024 Santiago Faci y Fernando Valdeón