Sockets en Java

De ChuWiki


Qué es un Socket[editar]

Un socket es una comunicación en red entre dos programas a través de la cual pueden intercambiarse datos. Hay dos tipos básicos de sockets:

  • TCP u orientados a conexión. Ambos programas deben establecer conexión entre ellos y hasta que dicha conexión no esté establecida, no pueden intercambiar datos. El establecimiento de conexión garantiza que los datos no se pierden.
  • UDP o no orientado a conexión. Los programas no tienen que establecer conexión de ningún tipo. Al no establecer conexión, cualquiera de ellos puede enviar datos independientemente de que el otro esté o no arrancado. Esto hace que los datos puedan perderse si el programa receptor no está leyendo los mensajes.

Socket TCP en Java[editar]

Para establecer un socket TCP en java tenemos las clases java.net.SocketServer y java.net.Socket.

Para establecer la comunicación entre ambos programas, uno de ellos hace las veces de "servidor". Esto simplemente significa que se pone a la escucha en espera de que el otro programa comience la conexión. El programa que hace de servidor usa la clase java.net.SocketServer

El otro programa es el que comienza la conexión con el servidor, que está a la escucha. Este programa se conoce como "cliente" y usa la clase java.net.Socket.

Veamos ambos lados.

Socket Servidor Java[editar]

El siguiente código muestra como el servidor se pone a la escucha para aceptar conexiones de cliente

try(ServerSocket serverSocket = new ServerSocket(5557)){
    while (true){
        Socket accept = serverSocket.accept();
        new Thread(new ClientRunnable(accept)).start();
    }
} catch (IOException e){
    e.printStackTrace();
}

Ponerse a la escucha[editar]

Hemos instanciado la clase ServerSocket. Como parámetro, le pasamos un "puerto" de escucha. Este puerto no es más que un número de nuestra elección entre 1 y 65535. Si en nuestro ordenador tenemos varios servidores atendiendo conexiones para distintas funcionalidades, debemos usar para cada uno de ellos un puerto distinto.

El sistema operativo y aplicaciones más estándar como un servidor web o un servidor ftp también usan estos números. Habitualmente, el sistema oeprativo y estas aplicaciones estándar suelen coger puertos del 1 al 1024. Estos puertos incluso puede que no podamos usarlos sin permisos de administrador. Por ello, se recomienda que usemos números de 1025 en adelante.

El cliente debe conocer este núermo para conectarse a nuestro programa servidor dentro de nuestro ordenador. Para el ejemplo hemos cogido el puerto 5557.

Si el puerto que elijas ya está en uso por otro de los programas de tu ordenador, obtendrás la siguiente excepción

java.net.BindException: Address already in use: bind

El error Address already in use indica que ese puerto ya está en uso. Elige otro o busca qué programa lo está usando y páralo.

Puesto que ServerSocket debe cerrarse cuando terminemos con él, lo hemos metido entre paréntesis detrás del try. Esto es una sintaxis de java conocida como try-with-resources que garantiza el cierre de ServerSocket cuando termine el código de try-cath, independientemente de si hay o no excepciones. De esta forma no necesitamos cerrarlo explícitamente.

Aceptar conexiones de ciente[editar]

Para esperar que un cliente se conecte, hacemos la llamada a serverSocket.accpet(). Esta llamada se quedará bloqueada hasta que algún cliente se conecte. En el momento que se conecte, la llamada nos devolverá una instancia de Socket. Esta instancia será la que nos sirva para intercambiar datos con ese cliente concreto que se acaba de conectar.

Aceptar varios clientes[editar]

Si queremos que se puedan conectar más clientes y no solo uno, debemos meter la llamada a serverSocket.accpet() en un bucle y cada vez que recibamos una conexión de un cliente, lanzar un hilo separado para intercambiar datos con ese cliente concreto. En nuestro ejemplo, hemos creado una clase ClientRunnable que implementa Runnable y podemos, por tanto, lanzarla en un Thread nuevo. Vemos más adelante los detalles de esta clase. Vemos ahora como el cliente establece conexión con el servidor.

Socket Cliente Java[editar]

El siguiente código muestra cómo establecer la conexión

try (Socket socket = new Socket("127.0.0.1", 5557)) {
   ...
}

Hemos instanciado la clase Socket. Le pasamos como primer parámetro el nombre del ordendor o la IP donde corre nuestro programa servidor. "127.0.0.1" hace referencia a tu mismo ordenador. Es válido si vamos a ejecutar ambos programas, servidor y cliente, en el mismo ordenador. Si no fuera el caso, debes poner la IP o nombre del ordenador donde corre el servidor.

El segundo parámetro es el puerto. Debe ser el mismo número que pusimos al instanciar SeverSocket.

Esta llamada establece la conexión con el servidor y la instancia de Socket nos sirve para intercambiar datos con el servidor. Si no se puede establecer conexión porque el servidor no está arrancado, obtendrás la siguiente excepción

java.net.ConnectException: Connection refused: connect

El error Connection refused indica que no hay nadie escuchando en la IP y puerto que has indicado. Verifica que has puesto bien IP y puerto y que el programa servidor está arrancado.

Escribir datos en un socket[editar]

Si te fijas, tanto cliente como servidor acaban manejando una instancia de Socket. El cliente porque crea la instancia directamente. El servidor porque la obtiene con el método serverSocket.accept() cuando el cliente se conecta. Por ello, tanto el envío (escritura) como la recepción (lectura) de datos es igual en ambos lados. Vamos con la escritura

El siguiente código envía un array de bytes por el socket

byte[] data = "Hello".getBytes();
socket.getOutputStream().write(data);

Necesitamos un array de bytes byte[] con los datos a enviar. En este ejemplo lo obtenemos relleno con a partir de un String. En otros casos podrías enviar, por ejemplo, los bytes de un fichero de imagen o cualquier otra cosa que puedas convertir a bytes.

socket tiene un método getOutputStream que nos da una instancia OutputStream que refleja el flujo de salida de datos del socket. Es decir, el sitio por el que podemos enviar los datos al otro programa. Con el método write() podemos escribir (enviar) los bytes de nuestro array.

Obtendrás un error

java.net.SocketException: Se ha anulado una conexión establecida por el software en su equipo host.

si intentas enviar datos y el otro programa ha cerrado la conexión. Si eres el cliente, deberias cerrar también tu conexión socket.close() e intentar volver a establecerla si lo necesitas. Si eres servidor, deberías cerrar también la conexión. Si tienes serverSocket.accept() en un bucle, volverás a aceptar la conexión del cliente si este lo reintenta.

Leer datos de un socket[editar]

El siguiente código nos permite leer datos de un socket

byte[] readBuffer = new byte[100];
final int read = socket.getInputStream().read(readBuffer);
if (read>0) {
    System.out.println(new String(readBuffer,0,read));
} else {
    System.out.println("En el otro extremo han cerrado el socket");
    return;
}

Primero preparamos un buffer byte[] del suficiente tamaño como para recoger los datos que esperemos del otro lado. En el ejemplo, enviabamos sólo 5 bytes, los de las letras de "Hello". Nos bastaría con un buffer de 5 bytes. Hemos puesto 100.

socket.getInputStream() nos devuelve una instancia de InputStream que representa el flujo de entrada de datos del socket, es decir, de donde podemos leer lo que nos envían. Con el método read() podemos leer los bytes que recibamos. Pasamos como parámetro el buffer byte[] de 100 bytes. Esto hará que read() intente leer un máximo de 100 bytes.

La llamada a read() se queda bloqueada hasta que haya al menos un byte disponible o hasta que en el otro lado cierren la conexión. read() devuelve el número de bytes que ha leído o -1 si en el otro lado han cerrado la conexión.

Si leemos más de 0 bytes, es que hemos leído algunos bytes. Es importante tener en cuenta que independientemente de los bytes que tenga nuestro buffer o los bytes que envíen desde el otro lado, read() no tiene por qué leerlos todos. Para que quede más claro, si nuestro buffer es de 100 bytes y el del otro lado envía 50 bytes, read() puede leer y rellenar solo 25, o 13, o los que le venga bien.

Por ello es muy importante recoger siempre el valor de vuelta de read(). Nuestro buffer tendrá datos válidos desde el byte 0 hasta el byte read-1. Si read() devuelve 10, los bytes válidos en nuestro buffer son del 0 al 9 ambos incluidos. El resto están sin cambios.

Más detalles de todo esto más adelante.

Cierre del socket[editar]

Cuando terminemos con un socket, debemos cerrarlo. Si eres cliente, llama a socket.close(). Si eres servidor y quieres terminar la conexión con un cliente concreto, llama a socket.client() siendo socket la instancia concreta de ese cliente. Si cierras serverSocket.close() dejarás de aceptar conexiones de cliente.

Si metes la creación de sockets en un try-with-resources no hace falta que los cierres explícitamente.

Ejemplo completo de código de socket java[editar]

En ejemplo socket java tienes el código completo de este ejemplo.

El cliente ClientSocketExample establece conexión con el servidor, le envía los bytes de "Hello", lee los bytes de vuelta del servidor, que será "World" y los saca por pantalla y cierra conexión.

El servidor ServerSocketExample queda a la espera de clientes. Cuando un cliente se conecta, lanza un hilo con la clase ClientRunnable para que atienda al cliente. Esta clase lee los datos que le envíe el cliente, que será "Hello" y los saca por pantalla. Después le envía los bytes de "World" y cierra la conexión.

Lectura de datos de socket avanzada[editar]

Como has podido intuir cuando comentamos que el método read() lee lo que le da la gana, la lectura de datos completos puede ser compplicada. En nuestro ejemplo mandamos "Hello", es posible que en el otro lado reciban en un read() una parte, por ejemplo "Hel" y en la siguiente lectura, el resto "lo".

Necesitamos por tanto una forma de asegurarnos que hemos recibido el mensaje completo antes de tratarlo. Debemos saber cuántos bytes nos envían desde el otro lado en un mensaje y debemos hacer varias lecturas hasta tenerlos todos. Si yo sé que el otro lado me envía 5 bytes ("Hello"), debo estar leyendo hasta tener los 5 bytes y tengo que "jugar" con el array byte[] para que cada byte acabe en su sitio sin machacar lo de la lectura anterior.

Hay muchas formas de hacerlo, veamos un par de ellas sencillas.

Los InputStream y OutputStream que obtenemos con socket.getInputStream() y socket.getOutputStream() son un poco limitados en el sentido de que sus métodos read() y write() son muy simples, sólo nos permite leer o escribir arrays de bytes, pero sin control de cuánto se lee o escribe

Afortunadamente, Java tiene clases que pueden envolver a InputStream y OutputStream y nos ofrecen métodos más potentes para lectura y escritura.

Envío y recepción de texto en sockets con BufferedReader y PrintWriter[editar]

Podemos encapsular OutputStream con PrintWriter. La clase PrintWriter ofrece métodos para envíar líneas de texto terminadas en retorno de carro.

// Encasulamos el OutputStream del socket en un PrintWriter
PrintWriter pw = new PrintWriter(socket.getOutputStream());
// Envía "Hello" con un byte \n (fin de línea) detrás.
pw.println("Hello");

Ese delimitador de fin de línea \n nos permite leerlo en el otro lado con BufferedReader que tiene métodos para leer líneas terminadas en ese byte.

// Encapsulamos el InputStream del socket en un InputStreamReader
InputStreamReader isr = new InputStreamReader(socket.getInputStream());
// Encapsulamos a su ves InputStreamReader en un BufferedReader
BufferedReader br = new BufferedReader(isr);

// Ahora podemos leer líneas completas que hayan sido enviadas con PrintWriter
String line = br.readLine();

Este es una de las formas más cómodas si queremos enviar y recibir texto a través de sockets. Tenemso la garantía de recibir la línea completa sin preocuparnos por ir haciendo lecturas parciales.

Delimitar mensajes en socket con DataInputStream y DataOutputStream[editar]

Si en vez de texto queremos enviar bytes en el que \n, un mecanismo es enviar primero dos bytes (un short) que indiquen la longitud del mensaje y leugo el menaje. Por ejemplo, si nuestro mensaje son 25 bytes, mandamos primero un short (dos bytes) con valor 25 y luego los bytes del mensaje. La clase DataOutputStream() nos ayuda a enviar ese short y luego el mensaje. Por supuesto, si tus mensajes tienen mas o menos longitud, puede usar un integer, un long o incluso un unico byte para la longitud, no tiene que ser necesariamente un short.

// Encapsulamos el OutputStream de socket en un DataOutputStream
DataOutputStream dos = new DataOutputStream(socket.getOutputStream());

// Mensaje que queremos enviar
byte[] data = "Hello".getBytes();
// Enviamos la longitud de mensaje.
dos.writeShort(data.length);
// Enviamos el mensaje
dos.writeBytes(data, 0, data.length);

En el lado de recibir, leemos dos bytes (el short) para saber la longitud y luego ya podemos leer el mensaje completo puesto que sabemos su longitud. La clase DataInputStream nos ayuda a leer el short primero y el mensaje completo después sin preocuparnos de las posibles lecturas paraciales del método read() que comentabamos antes.

// Encapsulamos el InputStream de socket en un DataInputStream
DataInputStream dis = new DataInputStream(socket.getInputStream());

// Leemos la longitud del mensaje
short length = dis.readShort();
// Un buffer para el mensaje
byte[] buffer = new byte[length];
// Y leemos el mensaje
dis.readFully(buffer);

Los métodos readShort() y readFully() nos aseguran leer el short completo (dos bytes) o el buffer completo (length bytes) en una sola lectura. No como el método read() de InputStream que podría leer, por ejemplo, un solo byte del short obligándonos a hacer una segunda lectura para leer el segundo byte.