Resumen

Manejar la entrada y la salida son tareas comunes para los programadores de Java. En este tutorial, veremos las bibliotecas originales java.io (IO) y las más recientes java.nio (NIO) y cómo se diferencian cuando se comunican a través de una red.

Características principales

Comencemos por ver las características principales de ambos paquetes.

2.1. IO – java.io

El paquete java.io fue introducido en Java 1.0, con Reader introducido en Java 1.1. Proporciona:

  • InputStream y OutputStream – que proporcionan datos un byte a la vez
  • Reader y Writer – envolturas de conveniencia para los flujos
  • modo de bloqueo – para esperar un mensaje completo

2.2. NIO – java.nio

El paquete java.nio fue introducido en Java 1.4 y actualizado en Java 1.7 (NIO.2) con operaciones de archivo mejoradas y un ASynchronousSocketChannel. Proporciona:

  • Buffer – para leer trozos de datos a la vez
  • CharsetDecoder – para mapear bytes crudos a/desde caracteres legibles
  • Channel – para comunicarse con el mundo exterior
  • Selector – para habilitar la multiplexación en un SelectableChannel y proporcionar acceso a cualquier Channels que esté listo para I/O
  • non-modo de bloqueo – para leer cualquier cosa que esté lista

Ahora echemos un vistazo a cómo usamos cada uno de estos paquetes cuando enviamos datos a un servidor o leemos su respuesta.

Configurar nuestro servidor de pruebas

Aquí usaremos WireMock para simular otro servidor y así poder ejecutar nuestras pruebas de forma independiente.

Lo configuraremos para que escuche nuestras peticiones y nos envíe las respuestas como lo haría un servidor web real. También utilizaremos un puerto dinámico para no entrar en conflicto con ningún servicio de nuestra máquina local.

Agreguemos la dependencia de Maven para WireMock con ámbito de prueba:

<dependency> <groupId>com.github.tomakehurst</groupId> <artifactId>wiremock-jre8</artifactId> <version>2.26.3</version> <scope>test</scope></dependency>

En una clase de prueba, definamos una @Rule de JUnit para iniciar WireMock en un puerto libre. A continuación, lo configuraremos para que nos devuelva una respuesta HTTP 200 cuando pidamos un recurso predefinido, con el cuerpo del mensaje como algún texto en formato JSON:

@Rule public WireMockRule wireMockRule = new WireMockRule(wireMockConfig().dynamicPort());private String REQUESTED_RESOURCE = "/test.json";@Beforepublic void setup() { stubFor(get(urlEqualTo(REQUESTED_RESOURCE)) .willReturn(aResponse() .withStatus(200) .withBody("{ \"response\" : \"It worked!\" }")));}

Ahora que tenemos nuestro servidor simulado configurado, estamos listos para ejecutar algunas pruebas.

Bloqueo de IO – java.io

Veamos cómo funciona el modelo original de bloqueo de IO leyendo algunos datos de un sitio web. Utilizaremos un java.net.Socket para acceder a uno de los puertos del sistema operativo.

4.1. Enviar una solicitud

En este ejemplo, crearemos una solicitud GET para recuperar nuestros recursos. En primer lugar, vamos a crear un Socket para acceder al puerto en el que nuestro servidor WireMock está escuchando:

Socket socket = new Socket("localhost", wireMockRule.port())

Para una comunicación HTTP o HTTPS normal, el puerto sería 80 o 443. Sin embargo, en este caso, utilizamos wireMockRule.port() para acceder al puerto dinámico que configuramos anteriormente.

Ahora vamos a abrir un OutputStream en el socket, envuelto en un OutputStreamWriter y lo pasamos a un PrintWriter para escribir nuestro mensaje. Y asegurémonos de vaciar el buffer para que nuestra petición sea enviada:

OutputStream clientOutput = socket.getOutputStream();PrintWriter writer = new PrintWriter(new OutputStreamWriter(clientOutput));writer.print("GET " + TEST_JSON + " HTTP/1.0\r\n\r\n");writer.flush();

4.2. Esperar la respuesta

Abramos un InputStream en el socket para acceder a la respuesta, leemos el flujo con un BufferedReader, y lo almacenamos en un StringBuilder:

InputStream serverInput = socket.getInputStream();BufferedReader reader = new BufferedReader(new InputStreamReader(serverInput));StringBuilder ourStore = new StringBuilder();

Usemos reader.readLine() para bloquear, esperando una línea completa, y luego añadimos la línea a nuestro almacén. Seguiremos leyendo hasta que obtengamos un null, que indica el final del flujo:

for (String line; (line = reader.readLine()) != null;) { ourStore.append(line); ourStore.append(System.lineSeparator());}

Non-Blocking IO – java.nio

Ahora, veamos cómo funciona el modelo de IO sin bloqueo del paquete nio con el mismo ejemplo.

Esta vez, crearemos un java.nio.channel.SocketChannel para acceder al puerto de nuestro servidor en lugar de un java.net.Socket, y le pasaremos una InetSocketAddress.

5.1. Enviar una petición

Primero, vamos a abrir nuestro SocketChannel:

InetSocketAddress address = new InetSocketAddress("localhost", wireMockRule.port());SocketChannel socketChannel = SocketChannel.open(address);

Y ahora, vamos a obtener un conjunto de caracteres UTF-8 estándar para codificar y escribir nuestro mensaje:

Charset charset = StandardCharsets.UTF_8;socket.write(charset.encode(CharBuffer.wrap("GET " + REQUESTED_RESOURCE + " HTTP/1.0\r\n\r\n")));

5.2. Leer la respuesta

Después de enviar la petición, podemos leer la respuesta en modo no bloqueante, utilizando buffers sin procesar.

Como vamos a procesar texto, necesitaremos un ByteBuffer para los bytes sin procesar y un CharBuffer para los caracteres convertidos (ayudados por un CharsetDecoder):

ByteBuffer byteBuffer = ByteBuffer.allocate(8192);CharsetDecoder charsetDecoder = charset.newDecoder();CharBuffer charBuffer = CharBuffer.allocate(8192);

Nuestro CharBuffer tendrá espacio de sobra si los datos se envían en un conjunto de caracteres multibyte.

Nótese que si necesitamos un rendimiento especialmente rápido, podemos crear un MappedByteBuffer en memoria nativa utilizando ByteBuffer.allocateDirect(). Sin embargo, en nuestro caso, usar allocate() desde el heap estándar es suficientemente rápido.

Cuando tratamos con buffers, necesitamos saber qué tamaño tiene el buffer (la capacidad), dónde estamos en el buffer (la posición actual), y hasta dónde podemos llegar (el límite).

Así que vamos a leer desde nuestro SocketChannel, pasándole nuestro ByteBuffer para almacenar nuestros datos. Nuestra lectura desde el SocketChannel terminará con la posición actual de nuestro ByteBuffer fijada en el siguiente byte a escribir (justo después del último byte escrito), pero con su límite sin cambios:

socketChannel.read(byteBuffer)

Nuestro SocketChannel.read() devuelve el número de bytes leídos que podrían ser escritos en nuestro buffer. Esto será -1 si el socket fue desconectado.

Cuando a nuestro buffer no le queda espacio porque aún no hemos procesado todos sus datos, entonces SocketChannel.read() devolverá cero bytes leídos pero nuestro buffer.position() seguirá siendo mayor que cero.

Para asegurarnos de que empezamos a leer desde el lugar correcto del buffer, usaremos Buffer.flip() para poner la posición actual de nuestro ByteBuffer a cero y su límite al último byte que fue escrito por el SocketChannel. Luego guardaremos el contenido del buffer usando nuestro método storeBufferContents, que veremos más adelante. Por último, utilizaremos buffer.compact() para compactar el búfer y fijar la posición actual, lista para nuestra próxima lectura desde el SocketChannel.

Como nuestros datos pueden llegar por partes, vamos a envolver nuestro código de lectura del buffer en un bucle con condiciones de terminación para comprobar si nuestro socket sigue conectado o si hemos sido desconectados pero aún nos quedan datos en nuestro buffer:

while (socketChannel.read(byteBuffer) != -1 || byteBuffer.position() > 0) { byteBuffer.flip(); storeBufferContents(byteBuffer, charBuffer, charsetDecoder, ourStore); byteBuffer.compact();}

Y no nos olvidemos de cerrar() nuestro socket (a menos que lo hayamos abierto en un bloque try-with-resources):

socketChannel.close();

5.3. Almacenamiento de datos de nuestro buffer

La respuesta del servidor contendrá cabeceras, lo que puede hacer que la cantidad de datos supere el tamaño de nuestro buffer. Así que usaremos un StringBuilder para construir nuestro mensaje completo a medida que llega.

Para almacenar nuestro mensaje, primero decodificamos los bytes crudos en caracteres en nuestro CharBuffer. Luego voltearemos los punteros para poder leer nuestros datos de caracteres, y los anexaremos a nuestro StringBuilder expandible. Por último, limpiaremos el CharBuffer para que esté listo para el siguiente ciclo de escritura/lectura.

Así que ahora vamos a implementar nuestro método storeBufferContents() completo pasando nuestros buffers, CharsetDecoder, y StringBuilder:

void storeBufferContents(ByteBuffer byteBuffer, CharBuffer charBuffer, CharsetDecoder charsetDecoder, StringBuilder ourStore) { charsetDecoder.decode(byteBuffer, charBuffer, true); charBuffer.flip(); ourStore.append(charBuffer); charBuffer.clear();}

Conclusión

En este artículo, hemos visto cómo el modelo original de java.io se bloquea, espera una petición y utiliza Streams para manipular los datos que recibe.

En cambio, las librerías java.nio permiten la comunicación sin bloqueo utilizando Buffers y Channels y pueden proporcionar acceso directo a la memoria para un rendimiento más rápido. Sin embargo, esta velocidad conlleva la complejidad adicional del manejo de buffers.

Como es habitual, el código de este artículo está disponible en GitHub.

Comienza a trabajar con Spring 5 y Spring Boot 2, a través del curso Aprende Spring:

>> CONSULTA EL CURSO

.

Deja una respuesta

Tu dirección de correo electrónico no será publicada.