martes, 2 de diciembre de 2008

Error típico al leer ficheros en Java

Esta vez quiero centrarme en un apartado muy común en las aplicaciones: la lectura de ficheros. El caso es que hace poco me encontré con un trozo de código que tenía un pequeño bug difícil de detectar. De hecho, el código llevaba funcionando años con el bug y ni usuarios ni programadores lo habían notado. Como esto mismo ya me dio problemas hace algún tiempo en otra aplicación, quiero compartir mi experiencia con vosotros para que no os pase. Lo mismo incluso descubrís que habéis cometido el mismo error tras leer el post. :-p

El código es muy tonto, pero es extensible a otros casos más complejos. En general, se trata de leer de un InputStream o Reader y, tras un procesado o no, escribir lo leído en un OuputStream o Writer. Veamos el ejemplo en cuestión:

BufferedInputStream in = new BufferedInputStream(new FileInputStream(archivoOrigen));
BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(archivoDestino));
byte buffer[] = new byte[1024];
while(in.read(buffer,0,1024)!=-1){
   out.write(buffer);
}
in.close();
out.close();

Simple, ¿verdad? Leemos los bytes de un fichero a través del stream in, de 1024 en 1024 y lo vamos escribiendo en otro fichero a través del stream out. Vamos lo que viene siendo copiar un fichero.

¿Dónde está el error? Veamos lo que dice el javadoc de la función read: devuelve el número de bytes leídos o -1 si se ha alcanzado el final del fichero. Es decir, mientras haya datos los va a leer, los va a almacenar en buffer y nos va a devolver el número de bytes leídos. Como mucho, leerá 1024 cada vez, ya que lo hemos configurado así. Nosotros estamos ignorando el valor devuelto y de ahí nuestro error.

En este ejemplo tan tonto, se pueden dar dos errores distintos:

Error #1

Lo normal es que en cada llamada al metodo read se lean 1024 bytes, pero cuando llegamos al final del fichero, es decir, en la penúltima lectura, se leerán tan sólo los bytes restantes -recuerda que en la última lectura no se lee nada, se detecta fin de fichero, se devuelve -1 y se termina el bucle while-.

Por ejemplo, supongamos que al final quedan 534 bytes por leer. Vemos el código paso a paso:

  • while(in.read(buffer,0,1024)!=-1){
La llamada a la función read almacenará los 534 bytes al principio del buffer, pero el resto del buffer quedará con los bytes de la lectura anterior. La función read devolverá el número de bytes leídos, 534, y como es distinto de -1, sigue en el bucle.

  • out.write(buffer);
Escribimos en el flujo de salida out todo el buffer, es decir, los 534 bytes de esta lectura más los 490 de la lectura anterior.

Esto provoca que el fichero copiado no sea exactamente igual que el leído, ya que el copiado tiene unos bytes de más, concretamete 1024 menos el resto de dividir el tamaño del fichero por 1024. Dicho de otra forma, el fichero generado siempre va a tener un tamaño multiplo de 1024, sea cual sea el tamaño del leído.

El error en este ejemplo sería fácil de detectar ya que se trata de copiar un fichero. Si miramos los tamaños de los ficheros y no son iguales es que falla. Sin embargo, si hacemos un procesado de los bytes, por ejemplo el redimensionamiento de una imagen, detectar el error es más complicado. Por un lado, los tamaños de los ficheros origen y destino no tienen por qué coincidir, y por otro, puesto que los bytes se añaden al final y en general serán pocos comparados con el resto, tan sólo van a suponer un cierto número de píxeles de diferencia, cosa que puede no apreciarse en la imagen o achacarse a efectos del procesado.

Error #2

Otro error que puede ocurrir es cuando en el flujo de entrada in es lento. Por ejemplo, supongamos que estuvieramos leyendo a través de internet, mediante http, un fichero. Podría ocurrir que en una iteración el flujo in no tuviera datos que leer. En este caso, no almacenaría nada en el buffer y devolvería 0 como resultado. Sin embargo, nosotros sí que escribiríamos el buffer, que contiene los datos de la lectura anterior, en el flujo out, repitiendo de esta forma el conjunto de bytes en el fichero de destino. Es decir, estaríamos escribiendo dos o más veces los mismos bytes.

Esto me ocurrió en un pequeño programita que leía los bytes de una página web y los enviaba por correo electrónico. Al repetirse ciertas secuencias de bytes, el html leído no era para nada correcto: a veces había dos etiquetas <html><head>.... , otras veces había dos <body>, y lo peor es que era aleatorio. Unas veces ocurría una cosa, otras veces otra distinta y a veces leía bien. Por supuesto, se debía a que a veces la conexión era lenta y se quedaba atascado en un sitio, otras veces en otro y otras iba bien. Cuando se quedaba atascado, el bucle iba repitiendo el último trozo leído. Un verdadero quebradero de cabeza, os lo aseguro.

Solución

La solución a esto es bien sencilla. Simplemente basta con almacenar el número de bytes leídos y escribir solamente este número usando la función write con 3 parámetros en lugar de 1:

BufferedInputStream in = new BufferedInputStream(new FileInputStream(archivoOrigen));
BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(archivoDestino));
byte buffer[] = new byte[1024];
int leidos;
while((leidos=in.read(buffer,0,1024))!=-1){
   out.write(buffer,0,leidos);
}
in.close();
out.close();

Por último, alguno pensará que por qué no se leen y escriben todos los bytes del tirón en lugar de hacerlo de 1024 en 1024. La respuesta es que se debe a cuestiones de rendimiento: se hace para evitar tener alamacenado en memoria todo el fichero. De esta forma, aunque leas un fichero de cientos de megas únicamente tendrás a la vez en memoria 1024 bytes del fichero. Por supuesto, el tamaño del buffer cada uno lo puede ajustar a sus necesidades. Si hay que procesar el fichero, puede que no haya más remedio que mantenerlo todo en memoria. La cuestión es evitarlo en la medida de lo posible.

Espero que os haya servido.

7 comentarios:

Anónimo dijo...

Domingo :P, muy buen articulo, la verdad es que seguramente yo haya cometido ese error mas de una vez. Espero que haya mas como éste! un saludo!

Xela dijo...

Gracias, Domingo. Mi intención es seguir poniendo artículos de este tipo. Lo que pasa es que, como puedes comprobar viendo el número de post cada mes, últimamente no tengo mucho tiempo para escribir. Aún así, creo que el próximo post os va a gustar.

kone dijo...

Oscar de chile.


Saludos y excelente vuestra observacion, sigue con los buenos articulos en tu blog y ojala tengas mas tiempo para dedicarle.

un afectuoso saludo.

Xela dijo...

Muchas gracias, Kone. Por fin he logrado sacar algo de tiempo para escribir un nuevo post. Espero que os guste.

Xela dijo...

Muchas gracias, Kone. Por fin he logrado sacar algo de tiempo para escribir un nuevo post. Espero que os guste.

Anónimo dijo...

Muchas gracias! Recientemente estoy entrando al mundo de java y se me presentó precisamente ese problema por ahí... y la verdad no tenía idea de como resolverlo ^_^ me has salvado!

Muy buen articulo, felicidades!

Jose Luis Hermida Mancilla dijo...

Muchas gracias buena explicación