Ejemplo de Sockets con SocketChannel Java

De ChuWiki

Veamos un ejemplo de servidor y cliente de socket usando el paquete java.nio de Java, en concreto, las clases ServerSocketChannel y SocketChannel. Usaremos también para lectura y escritura ByteBuffer

El código de ejemplo en Github

El socket servidor[editar]

Abrir el puerto[editar]

El servidor es el programa java que abre el socket y espera a que alguien se conecte a él. Abrir un socket servidor es fácil, basta con este par de llamadas

ServerSocketChannel server = ServerSocketChannel.open();
server.bind(new InetSocketAddress("0.0.0.0", 5557));

En la segunda línea, decimos al socket servidor a qué interface de red debe atender y en qué puerto. ¿Qué es eso de la interface de red?. Es alguna de las IP que tenga el ordenador donde va a correr este ejecutable. Puede ser:

  • La IP concreta del ordenador, por ejemplo, 192.168.1.2. Si ponemos esta IP, sólo se aceptarán clientes que se conecten contra esa IP.
  • Puede ser la IP de localhost, es decir, 127.0.0.1. Si ponemos esta IP, sólo se aceptarán clientes que se conecten contra 127.0.0.1, es decir, sólo se aceptan clientes que corran en el mismo ordenador.
  • Si el ordenador tiene varias tarjetas de red cada una con una IP específica y ponemos una de ellas, sólo se aceptarán clientes que se conecten a esa IP concreta, pero no se aceptarán clientes que intenten conectarse a las otras IPs. Si queremos que los clientes puedan conectarse usando cualquiera de las IPs del ordenador, podemos poner 0.0.0.0 (como en el ejemplo). Con esta IP, el servidor aceptará conexiones vengan de donde vengan.

En cuanto al puerto, puede ser un número cualquiera entre 0 y 65535 que no esté siendo usando por otro socket servidor en nuestro ordenador. En algunos sistemas operativos, se necesitan permisos de administración para poder usar puertos por debajo del 1024, que además, suelen ser utilizados por servicios estándar del sistema operativo, como ssh, ftp, http, telnet, etc.

Aceptar clientes[editar]

Ahora nos queda esperar que se conecte algún cliente. Para ello se hace la siguiente llamada

while (true) {
   SocketChannel clientChannel = server.accept();
   new Thread(new Client(clientChannel)).start();
}

Normalmente nos metemos en un bucle infinito, para aceptar tantos clientes como se quieran conectar. Si no hacemos un bucle de este estilo, solo aceptaremos un cliente, el primero que se conecte.

Dentro del bucle, la llamada server.accept() se queda bloqueada hasta que un cliente se conecte. En el momento que se conecta, la llamada nos devuelve un SocketChannel, que es la conexión con el cliente, por la que podremos enviar bytes al cliente y recibir bytes de él.

Suele ser normal en este momento lanzar un nuevo hilo para atender a este ciente, de forma que no tengamos que esperar a que el cliente termine para aceptar al siguiente. Es por tanto habitual tener una clase propia Client que implemente Runnable, que reciba en el constructor el SocketChannel con el cliente que se acaba de conectar y lanzar dicha clase Client en un hilo separado.

Veremos más adelante los detalles de esta clase Client. Vamos a ver ahora cómo se conecta un cliente.

Socket cliente[editar]

Abrir un socket cliente que se conecte con un servidor es también fácil, basta esta línea

SocketChannel channel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 5557))

Indicamos la IP a la que queremos conectarnos. Esta vez sí, debe ser una IP en la que esté el servidor, no vale un 0.0.0.0. Como nuestro programa de pruebas corre en el mismo PC que el servidor y es servidor escucha en todas las IP (pusimos 0.0.0.0 cuando lo creamos), podemos poner de IP 127.0.0.1, equivalente a localhost.

También debemos poner el puerto en el que escucha el servidor, 5557 en nuestro ejemplo.

Y con esto vale. Obtenemos un SocketChannel que está conectado con el SocketChannel que obtuvimos en el servidor en la llamada a accept(). Ahora sólo nos queda escribir bytes en el SocketChannel de uno de los lados (cliente o servidor) y leerlos en el SocketChannel del otro lado.

Escribir bytes[editar]

Los SocketChannel tienen un método write() que admiten un ByteBuffer con los bytes que queremos enviar por el canal. Debemos por tanto crear un ByteBuffer y rellenarlo con los datos a enviar. El mecanismo para obtener y rellenar este ByteBuffer es el siguiente

String[] lines = new String[] { "line 1", "line 2", "line 3" };
ByteBuffer buffer = ByteBuffer.allocate(100);

for (String line : lines) {
   buffer.put(line.getBytes());
   ...
}

Creamos un array de String con varias líneas de texto que queremos enviar. Obviamente, esto es para nuestro ejemplo, en tu caso concreto tendrás que obtener de alguna forma qué bytes quieres enviar y no tienen que ser necesariamente líneas de texto en un array.

Creamos el ByteBuffer con una llamada a ByteBuffer.allocate(100);, que nos dará un ByteBuffer con capacidad para 100 bytes. Podemos poner el tamaño que queramos, siempre y cuando sea suficientemente grande para meter lo que necesitemos en él. Nuestras líneas son de 6 caracteres, así que con 100 bytes tenemos más que de sobre.

Bucle para cada linea y las vamos metiendo en el buffer con el método put(). Como este método admite bytes, tenemos que convertir nuestro String en bytes por medio del método getBytes(). Como no hemos pasado un CharSet como parámetro, se usará el de defecto.

Listo, ya tenemos el ByteBuffer con la línea de texto dentro. Ahora sólo nos queda enviarlo en el canal.

buffer.flip();

while (buffer.hasRemaining()) {
   channel.write(buffer);
}

buffer.clear();

El ByteBuffer tiene capacidad para 100 bytes. Cuando hemos escrito en él los caracteres, se han ido llenando 6 de esos bytes. Una variable interna de ByteBuffer llamada position ha avanzado hasta la posición 6 (siguiente al último byte escrito, que van de 0 a 5). Otra variable interna, llamada limit, indica la úlitma posición del ByteBuffer, es decir, 100.

Si mandamos el ByteBuffer tal cual a escribir, el método channel.write(buffer) tratará de escribir desde position hasta limit, es decir, justo lo que no hemos rellenado. Así que es necesario poner position a cero y limit a 6, para que la operación de escritura escriba los 6 bytes que hemos rellenado.

Eso es justo lo que hace la llamada a buffer.flip(). Pone limit en la actual position y position a cero. La siguiente imagen quizás lo dejem más claro ....

Una vez que tenemos todo a punto ( position en el primer byte que queremos mandar y limit detrás del último ), ya podemos llamar a channel.write(buffer que enviará por el canal desde position hasta limit (excluido).

Esta llamada se suele quedar bloqueada hasta que escribe todos los bytes que le hemos indicado. Pero si hubiesemos configurado el canal como no bloqueante (no lo hemos hecho), la llamada podría volver antes de haber terminado de escribir todo. Por ello, suele ser buena costumbre meterla en un bucle "mientras no se haya enviado todo". Dentro de ByteBuffer, remaining es la diferencia entre limit y position. Al ir escribiendo bytes, position irá avanzando hasta llegar a limit. Si no puediera escribir todo, se quedaría en alguna posición intermedia. remaining nos diría entonces cuántos bytes quedan por escribir y hasRemaining() devolvería true.

Una vez escrito todo, el bucle de escritura termina y tenemos que limpiar el buffer, para que quede listo para la siguiente línea de texto. buffer.clear pone position a cero y limit a la capacidad del buffer, es decir 100. Nos deja el buffer como si acabáramos de crearlo.

Leer bytes[editar]

Leer bytes es similar, necesitamos un ByteBuffer con capacidad suficiente para leer los bytes que queramos leer, y normalmente un bucle para ir leyendo bytes. Algo parecido a esto

ByteBuffer buffer = ByteBuffer.allocate(100);
   
while (channel.read(buffer) > -1) {
...
}

channel.read(buffer> lee bytes del canal y los va metiendo en el buffer, empezando y haciendo avanzar position y hasta que llega a limit o deja de tener bytes disponibles. Si no hubiera ningún byte disponible se queda bloqueada hasta que haya al menos uno.

La llamada devuelve el número de bytes leídos (puede ser 0 si el canal es no bloqueante, que no es el caso) o -1 si el canal se ha terminado (fin de fichero si el canal es de un fichero, si el socket se cierra en nuestro ejemplo, etc). Por ello el bucle se debe repetir mientras se lean 0 o más bytes, es decir, el número de bytes leídos sea mayor que -1.

Ahora nos pasa como nos pasaba cuando escribíamos en el canal. Después de rellenar el buffer con los bytes leídos del canal, position queda al final del último byte leído y limit sigue al final del buffer, 100 en nuestro caso. Si queremos extraer los bytes del buffer para hacer algo con ellos (por ejemplo, sacarlos por pantalla), debemos colocar adecuadamente position en la posición 0 y limit detrás del último byte leído que es donde actualmente está position. Necesitamos, por tanto, hacer una llamada a buffer.flip() antes de extraer los bytes.

buffer.flip();

while (buffer.hasRemaining()) {
   System.out.print((char) buffer.get());
}

System.out.println();

buffer.clear();

Hecha la llamada a buffer.flip(), igual que antes, un bucle mientras haya bytes pendientes de leer dentro del buffer y los vamos sacando por pantala como caracteres. buffer.get() nos da el byte que está en position y hace avanzar en uno position. Por lo que sucesivas llamadas a buffer.get() nos irán dando los bytes leídos consecutivamente.

Terminado el bucle de lectura de bytes, un buffer.clear(); deja el buffer limpio para la siguiente lectura del canal.

Enlaces[editar]