La lección anterior terminó con una frase que conviene tomarse en serio: leer es seguro, escribir destruye. Si te equivocas leyendo, obtienes datos incorrectos y lo notas. Si te equivocas escribiendo, el fichero anterior ya no existe, y puede que no lo notes hasta que alguien lo necesite.
En esta lección BiblioTech guarda por primera vez. Y para que guarde bien, hay tres cosas que hay que entender antes de escribir una línea: el parámetro append, que decide entre añadir al final del fichero y vaciarlo por completo; el búfer, que explica por qué lo que has escrito puede no estar todavía en el disco; y la escritura atómica, el patrón que impide dejar un fichero a medias cuando el proceso falla a mitad de camino.
Ese tercer punto es el mismo problema que resolviste en 06-05 con la compensación en finally: mantener el sistema en un estado coherente pase lo que pase. Allí el estado estaba en memoria; aquí está en el disco, y las consecuencias duran más.
Como en toda la lección anterior, todo recurso se abre con
try-with-resources(06-06). Se da por sabido y no se vuelve a justificar. En escritura importa aún más que en lectura, porque elclose()de un escritor vacía el búfer al disco: si no cierras, no has escrito.
Contenido
FileWriter: crear, sobrescribir y añadir- El parámetro
append, o cómo perder un fichero entero - Escribir texto con
write PrintWriter: el envoltorio cómodo- El búfer y el vaciado:
flushfrente aclose - Qué pasa si el programa termina sin cerrar
- Codificación explícita al escribir
- El separador de línea del sistema
- Permisos, ficheros de solo lectura y
IOException - Escritura atómica: temporal y renombrado
- Ficheros de bloqueo y sobrescritura accidental
- BiblioTech:
ExportadorCatalogoescribe de verdad - Errores Comunes y Consejos
- Ejercicios
FileWriter: crear, sobrescribir y añadir
FileWriter: crear, sobrescribir y añadirFileWriter es la contraparte exacta de FileReader: escribe texto en un fichero. Su comportamiento en la apertura es lo primero que hay que fijar:
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public class PrimeraEscritura {
public static void main(String[] args) throws IOException {
try (FileWriter escritor = new FileWriter("salida.txt", StandardCharsets.UTF_8)) {
escritor.write("Catalogo de BiblioTech\n");
escritor.write("Nexus Software\n");
}
System.out.println("Escrito.");
}
}Qué ocurre exactamente al abrir:
| Situación previa | Comportamiento por defecto |
|---|---|
| El fichero no existe | Se crea, vacío, y se escribe en él |
| El fichero existe | Se trunca a cero bytes y se escribe desde el principio |
| El directorio padre no existe | IOException: FileWriter no crea directorios |
| No hay permiso de escritura | IOException (FileNotFoundException, que la extiende) |
Fíjate en la segunda fila, porque es la que arruina el día: abrir un FileWriter sobre un fichero existente lo vacía inmediatamente, en el momento de la construcción, antes de que escribas nada. Si abres el fichero y luego lanzas una excepción antes de escribir, te quedas con un fichero vacío y sin los datos anteriores.
Y fíjate también en la tercera: FileWriter no crea el directorio. Si escribes en datos/catalogo.txt y no existe datos/, obtienes una IOException cuyo mensaje típico es "No such file or directory", que muchos interpretan como "no encuentra el fichero" cuando en realidad falta la carpeta. Crear directorios es tarea de mkdirs() en la API antigua o de Files.createDirectories() en NIO.2 (07-06).
Los constructores disponibles:
// Sobrescribe. Charset de la plataforma: NO USAR (07-01, apartado 10)
new FileWriter("salida.txt");
// Sobrescribe, charset explicito (Java 11+). ESTA es la forma correcta
new FileWriter("salida.txt", StandardCharsets.UTF_8);
// Anade al final, charset de la plataforma
new FileWriter("salida.txt", true);
// Anade al final, charset explicito (Java 11+). La otra forma correcta
new FileWriter("salida.txt", StandardCharsets.UTF_8, true);Cuidado con la ambigüedad del segundo parámetro.
new FileWriter(ruta, true)significa "añadir".new FileWriter(ruta, StandardCharsets.UTF_8)significa "sobrescribir con UTF-8". Se parecen mucho y significan cosas distintas. Cuando quieras las dos cosas, usa la versión de tres parámetros, y fíjate en que elbooleanva el último.
- El parámetro
append, o cómo perder un fichero entero
append, o cómo perder un fichero enteroEste es el apartado que hay que leer dos veces. La tabla es corta y las consecuencias son largas:
| Constructor | Fichero nuevo | Fichero existente con datos |
|---|---|---|
new FileWriter(ruta, UTF_8) |
Se crea | Se vacía. Los datos anteriores se pierden |
new FileWriter(ruta, UTF_8, false) |
Se crea | Se vacía. Idéntico al anterior |
new FileWriter(ruta, UTF_8, true) |
Se crea | Se conserva y se escribe al final |
El caso real, y ocurre constantemente:
// BiblioTech registra cada prestamo en un fichero de auditoria.
// El fichero lleva tres anos acumulando operaciones.
public void registrarOperacion(String linea) throws IOException {
// BUG: falta el 'true'. Cada llamada BORRA todo el historico
// y deja solo la ultima linea.
try (FileWriter escritor = new FileWriter("datos/auditoria.txt", StandardCharsets.UTF_8)) {
escritor.write(linea + System.lineSeparator());
}
}Ese código funciona: no lanza excepciones, no da avisos, el fichero existe y tiene contenido. Solo que tiene una línea en vez de trescientas mil. Y como cada ejecución lo vuelve a dejar con una línea, el fallo es perfectamente estable y silencioso. Se descubre el día que alguien pide el histórico.
La versión correcta:
public void registrarOperacion(String linea) throws IOException {
// El 'true' final: ANADIR, no sobrescribir
try (FileWriter escritor =
new FileWriter("datos/auditoria.txt", StandardCharsets.UTF_8, true)) {
escritor.write(linea + System.lineSeparator());
}
}Tres defensas profesionales contra este error:
- Nombra la intención. No dejes el
truesuelto en la llamada:
private static final boolean ANADIR = true;
private static final boolean SOBRESCRIBIR = false;
new FileWriter(ruta, StandardCharsets.UTF_8, ANADIR); // se lee solo-
Usa las opciones explícitas de NIO.2 cuando llegues a 07-06.
StandardOpenOption.APPENDyStandardOpenOption.TRUNCATE_EXISTINGdicen literalmente lo que hacen, y no hay forma de confundirlas con un charset. -
Escribe siempre a un fichero temporal y renombra cuando estés regenerando un fichero completo. Es el apartado 10, y elimina la categoría entera de problemas.
Y una advertencia que va más allá de este parámetro:
Abrir un
FileWriterpara "comprobar algo" ya destruye el fichero. No existe un modo "abrir solo si puedo". Si necesitas saber si el fichero existe antes de decidir, compruébalo antes de abrir, y aun así recuerda el aviso de TOCTOU de 06-07: entre la comprobación y la apertura, el mundo puede cambiar.
- Escribir texto con
write
writeWriter —la superclase de FileWriter— ofrece varias sobrecargas de write:
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public class FormasDeEscribir {
public static void main(String[] args) throws IOException {
try (FileWriter w = new FileWriter("demo.txt", StandardCharsets.UTF_8)) {
w.write("Texto completo"); // String entero
w.write('\n'); // un solo caracter (int)
w.write("Solo una parte", 5, 3); // subcadena: desde 5, 3 caracteres -> "una"
w.write('\n');
char[] buffer = { 'B', 'i', 'b', 'l', 'i', 'o' };
w.write(buffer); // array completo
w.write(buffer, 0, 3); // parte del array -> "Bib"
w.append("Encadenable") // append devuelve el Writer
.append(' ')
.append("y comodo");
}
}
}| Método | Escribe |
|---|---|
write(String) |
La cadena completa |
write(String, int inicio, int longitud) |
Una subcadena, sin crear objetos intermedios |
write(int) |
Un solo carácter, dado por su código |
write(char[]) |
El array completo |
write(char[], int inicio, int longitud) |
Parte del array |
append(CharSequence) |
Igual que write(String), pero devuelve el Writer, encadenable |
Un detalle que descoloca: write(int) escribe un carácter, no el número. w.write(65) escribe la letra A, no el texto 65. Si quieres escribir el número, conviértelo: w.write(String.valueOf(65)). Es la misma asimetría que ya viste en read() de 07-01, y por el mismo motivo: la unidad de la API es el carácter, y el int está ahí para que quepa el -1.
Y la carencia práctica más evidente: FileWriter no tiene println. No hay ningún método que añada el salto de línea por ti, ni ninguno que formatee. Tienes que escribir el separador a mano cada vez. Eso lo resuelve PrintWriter.
PrintWriter: el envoltorio cómodo
PrintWriter: el envoltorio cómodoPrintWriter envuelve otro Writer y le añade toda la comodidad de System.out: los mismos print, println y printf que usas desde el módulo 1.
import java.io.FileWriter;
import java.io.PrintWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
public class InformeConPrintWriter {
public static void main(String[] args) throws IOException {
try (PrintWriter salida = new PrintWriter(
new FileWriter("informe.txt", StandardCharsets.UTF_8))) {
salida.println("INFORME DE MULTAS - BiblioTech");
salida.println("==============================");
salida.println();
// printf con la misma sintaxis del modulo 1
salida.printf("%-25s %-15s %8s%n", "MATERIAL", "EMPLEADO", "MULTA");
salida.printf("%-25s %-15s %8.2f%n", "Java Efectivo", "Marta Ruiz", 3.75);
salida.printf("%-25s %-15s %8.2f%n", "Patrones de Diseno", "Diego Alonso", 0.00);
salida.printf("%-25s %-15s %8.2f%n", "Refactorizacion", "Nuria Vidal", 20.00);
// Locale explicito: en Espana el separador decimal es la coma.
// Para un fichero que otro programa vaya a leer, fija Locale.ROOT.
salida.printf(Locale.ROOT, "%nTOTAL: %.2f EUR%n", 23.75);
}
}
}Resultado:
INFORME DE MULTAS - BiblioTech
==============================
MATERIAL EMPLEADO MULTA
Java Efectivo Marta Ruiz 3,75
Patrones de Diseno Diego Alonso 0,00
Refactorizacion Nuria Vidal 20,00
TOTAL: 23.75 EURFíjate en la diferencia entre las líneas del cuerpo (con coma decimal, porque usan el Locale de la máquina) y la del total (con punto, porque fija Locale.ROOT). En un fichero destinado a que lo lea otro programa, fija siempre el Locale; en uno destinado a que lo lea una persona en España, la coma es lo correcto. Este mismo conflicto reaparecerá con los CSV en 07-07 y es más importante de lo que parece.
Lo que aporta PrintWriter sobre FileWriter:
| Método | Qué hace |
|---|---|
println(x) |
Escribe y añade el separador de línea del sistema |
print(x) |
Escribe sin salto. Acepta cualquier tipo, incluidos objetos (usa toString()) |
printf(fmt, args...) |
Formatea como System.out.printf |
format(fmt, args...) |
Idéntico a printf. Existe por simetría con String.format |
write(String) |
Heredado de Writer |
Y ahora la trampa grande de PrintWriter, que hay que conocer sí o sí:
PrintWriterse traga las excepciones. Sus métodosprintlnyprintfno declaranIOException. Si el disco se llena o el dispositivo falla, no lo sabrás: el error se guarda en una bandera interna en lugar de lanzarse.
La única forma de enterarte es preguntar:
try (PrintWriter salida = new PrintWriter(
new FileWriter("informe.txt", StandardCharsets.UTF_8))) {
salida.println("linea 1");
salida.println("linea 2");
// COMPROBACION OBLIGATORIA en codigo serio.
// checkError() vacia el buffer y devuelve true si HA HABIDO algun fallo
// en cualquier momento desde que se abrio.
if (salida.checkError()) {
throw new IOException("Fallo al escribir el informe (detectado por checkError)");
}
}Este diseño viene de que PrintWriter nació para System.out, donde un fallo de escritura en consola no debía obligar a poner try/catch en cada println. Para un fichero, ese compromiso es peligroso.
Regla práctica:
- Para salida por consola y volcados de depuración:
PrintWritersin más, con su comodidad. - Para ficheros cuyo contenido importa:
PrintWriterconcheckError()antes de cerrar, o directamenteBufferedWriter(07-04), cuyos métodos sí declaranIOException.
Un aviso más sobre los constructores de PrintWriter:
// Estos DOS abren el fichero por su cuenta, con el charset de la plataforma
// en las versiones antiguas. Son comodos y traicioneros.
new PrintWriter("salida.txt");
new PrintWriter(new File("salida.txt"));
// Con charset explicito (Java 10+): correcto
new PrintWriter("salida.txt", StandardCharsets.UTF_8);
// Envolviendo un Writer que tu controlas: la forma mas clara y flexible
new PrintWriter(new FileWriter("salida.txt", StandardCharsets.UTF_8, true));La última es la recomendada: tú decides el charset y el modo append, y PrintWriter solo aporta el formateo. Es composición, y en 07-03 verás que ese es el principio que organiza toda la API de flujos.
- El búfer y el vaciado:
flush frente a close
flush frente a closeAquí está el concepto que separa a quien escribe ficheros de quien escribe ficheros bien.
Cuando llamas a write, los datos no van al disco. Se acumulan en un búfer en memoria, y solo se envían al sistema operativo cuando el búfer se llena o cuando alguien lo pide explícitamente. Y hay más de un nivel de acumulación:
flowchart TD
A["salida.println(...)"] --> B["Buffer de PrintWriter<br/>o BufferedWriter"]
B -->|"flush o buffer lleno"| C["Buffer interno del FileWriter"]
C -->|"llamada al sistema write"| D["Cache de pagina del<br/>sistema operativo"]
D -->|"fsync o el propio SO"| E["Disco fisico"]
style B fill:#e3f2fd
style C fill:#e3f2fd
style D fill:#fff3e0
style E fill:#f3e5f5
Y aquí está la clave que casi nadie tiene clara:
| Operación | Garantiza |
|---|---|
write(...) |
Que los datos están en el búfer de la aplicación. Nada más |
flush() |
Que los datos han llegado al sistema operativo. Ya no dependen de tu proceso |
close() |
Un flush() más la liberación del descriptor de fichero |
FileDescriptor.sync() |
Que los datos están físicamente en el disco. Lo único que sobrevive a un corte de corriente |
Es decir: close() protege frente a que tu programa termine; solo sync() protege frente a que se vaya la luz. Para la inmensa mayoría de las aplicaciones, close() es suficiente y sync() es un coste innecesario. Para un sistema donde perder la última operación sea inaceptable —un cobro, un asiento contable—, hay que llegar hasta el disco.
Demostración de la diferencia entre flush y no hacer nada:
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public class DemoBuffer {
public static void main(String[] args) throws Exception {
FileWriter w = new FileWriter("demo-buffer.txt", StandardCharsets.UTF_8);
w.write("Primera linea\n");
System.out.println("Tras write, tamano en disco: " + tamano()); // 0
w.flush();
System.out.println("Tras flush, tamano en disco: " + tamano()); // 15
w.write("Segunda linea\n");
System.out.println("Tras 2o write, tamano: " + tamano()); // 15
w.close();
System.out.println("Tras close, tamano en disco: " + tamano()); // 29
}
private static long tamano() {
return new java.io.File("demo-buffer.txt").length();
}
}Salida:
Tras write, tamano en disco: 0
Tras flush, tamano en disco: 15
Tras 2o write, tamano: 15
Tras close, tamano en disco: 29El fichero tiene 0 bytes después del primer write. Los datos existen, pero están en la memoria del proceso. Si en ese instante alguien mira el fichero desde fuera, lo ve vacío.
Consecuencia práctica muy común: si estás escribiendo un fichero de registro y lo consultas con tail -f mientras el programa corre, no verás nada durante mucho rato, porque el búfer no se ha llenado. Eso no es un fallo de tu código; es el búfer haciendo su trabajo. Para registros que hay que poder seguir en vivo, se hace flush() tras cada línea, a costa de rendimiento —y eso es exactamente lo que hace el FileHandler de java.util.logging que configuraste en 06-07, y el motivo de que el registro tenga un coste apreciable—.
Cuándo llamar a flush() explícitamente:
- Cuando otro proceso o persona necesita ver los datos ya, sin esperar al cierre.
- Cuando vas a mantener el fichero abierto mucho tiempo y quieres acotar cuánto se perdería en una caída.
- Antes de una operación larga o arriesgada, para dejar en disco lo escrito hasta ese punto.
- Nunca justo antes de
close(): es redundante,close()ya lo hace.
- Qué pasa si el programa termina sin cerrar
Esta demostración conviene ejecutarla, porque el resultado sorprende:
import java.io.FileWriter;
import java.nio.charset.StandardCharsets;
public class SinCerrar {
public static void main(String[] args) throws Exception {
// MAL A PROPOSITO: sin try-with-resources y sin close
FileWriter w = new FileWriter("sin-cerrar.txt", StandardCharsets.UTF_8);
w.write("Esta linea puede no llegar nunca al disco.\n");
System.out.println("Terminando sin cerrar...");
// main termina aqui. No hay close(). No hay flush().
}
}Resultado habitual: el fichero existe y está vacío. Los datos se quedaron en el búfer del proceso, y al terminar el proceso el búfer desaparece con él.
Preguntas que surgen siempre:
- ¿No lo cierra el recolector de basura? El
finalize()de algunas clases de E/S hacía algo parecido, pero está obsoleto desde Java 9 y eliminado en versiones recientes. Nunca fue una garantía: el recolector no promete ejecutarse antes de que el proceso termine. No cuentes con ello jamás. - ¿Y si el proceso lo mata el sistema? Con
kill -9o un corte de corriente, se pierde todo lo que no haya pasado al sistema operativo. Ni siquiera los shutdown hooks de 06-05 se ejecutan conkill -9. - ¿Y si termina con excepción? Sin
try-with-resources, se pierde igual. Contry-with-resources, el cierre está garantizado y los datos llegan.
La versión correcta, y la única que se escribe en código real:
try (FileWriter w = new FileWriter("bien-cerrado.txt", StandardCharsets.UTF_8)) {
w.write("Esta linea SI llega al disco.\n");
} // close() garantizado: con exito, con excepcion y con return (06-06)Esto le da un peso extra al
try-with-resourcesen escritura. En lectura, no cerrar es una fuga de recursos: molesto pero no destructivo. En escritura, no cerrar es perder datos. El fichero queda vacío o truncado, y a menudo nadie se entera hasta mucho después.
- Codificación explícita al escribir
Todo lo de 07-01 sobre codificación se aplica igual, con un matiz que empeora las cosas:
Un fallo de codificación al leer se ve y se arregla. Al escribir, se graba. Si escribes un catálogo con el charset equivocado, el fichero queda mal en disco. Puedes seguir leyéndolo bien mientras uses el mismo charset equivocado, y el problema solo aparece cuando otra herramienta —un editor, una hoja de cálculo, otro sistema— intenta leerlo con UTF-8. Para entonces el fichero lleva meses acumulando datos corruptos.
Y hay un caso peor todavía: escribir un carácter que el charset de destino no puede representar:
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public class CaracterNoRepresentable {
public static void main(String[] args) throws IOException {
// US-ASCII no tiene 'ñ' ni '€'
try (FileWriter w = new FileWriter("ascii.txt", StandardCharsets.US_ASCII)) {
w.write("Patrones de Diseño cuestan 45 €");
}
// NO lanza excepcion. Sustituye lo no representable por '?'.
// En el fichero queda: "Patrones de Dise?o cuestan 45 ?"
}
}Silencio total y datos destruidos. El comportamiento por defecto del codificador es CodingErrorAction.REPLACE. Si quieres que falle en lugar de destruir, hay que bajar al nivel de CharsetEncoder, que es API de java.nio.charset y queda fuera del alcance de esta lección; lo importante es que sepas que el silencio es el comportamiento por defecto.
La regla es la de 07-01, repetida porque merece repetirse:
// SIEMPRE, en cada apertura de escritura de texto
new FileWriter(ruta, StandardCharsets.UTF_8);
new FileWriter(ruta, StandardCharsets.UTF_8, ANADIR);
new PrintWriter(new FileWriter(ruta, StandardCharsets.UTF_8));Y una simetría que hay que respetar siempre: quien escribe y quien lee el mismo fichero deben usar el mismo charset. Si ExportadorCatalogo escribe en UTF-8, CargadorCatalogo tiene que leer en UTF-8. Lo mejor es que ese charset esté declarado una sola vez en una constante compartida:
package com.nexussoftware.bibliotech.infraestructura;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
/** Convenciones de formato de todos los ficheros de BiblioTech. */
public final class FormatoFicheros {
/** Charset unico de todos los ficheros de texto del sistema. */
public static final Charset CHARSET = StandardCharsets.UTF_8;
/** Separador de campos de los ficheros tabulados. */
public static final String SEPARADOR_CAMPOS = ";";
/** Prefijo de las lineas de comentario. */
public static final String COMENTARIO = "#";
private FormatoFicheros() { }
}Un solo sitio que cambiar, y ninguna posibilidad de que el lector y el escritor discrepen.
- El separador de línea del sistema
Los sistemas operativos no se pusieron de acuerdo en cómo terminar una línea:
| Sistema | Bytes | Escape en Java | Nombre |
|---|---|---|---|
| Linux, macOS moderno | 0x0A |
\n |
LF |
| Windows | 0x0D 0x0A |
\r\n |
CRLF |
| macOS clásico (hasta 2001) | 0x0D |
\r |
CR |
Java expone el del sistema actual:
¿Cuándo importa? Depende de quién vaya a leer el fichero:
| Consumidor del fichero | ¿Le molesta \n en Windows? |
|---|---|
Tu propio programa con BufferedReader.readLine() |
No: acepta las tres formas |
| Un editor moderno (VS Code, Notepad++, IntelliJ) | No |
| El Bloc de notas de Windows antiguo | Sí: mostraba todo en una línea |
| Herramientas de línea de comandos de Windows | A veces |
| Otro sistema que espere el formato nativo | Sí |
En la práctica:
PrintWriter.println()ya usa el separador del sistema. No tienes que hacer nada.BufferedWriter.newLine()también. Es el método correcto (07-04).- Escribir
"\n"a mano en unwriteproduce siempre LF, sea cual sea el sistema.
Ahora bien, hay un argumento a favor de escribir \n siempre, y es serio: la reproducibilidad. Si un fichero generado en Windows lleva CRLF y el mismo fichero generado en Linux lleva LF, un control de versiones los verá como completamente distintos aunque el contenido sea idéntico. Para ficheros de datos que se versionan o se comparan, muchos equipos fijan LF a propósito.
La decisión, resumida:
| Tipo de fichero | Separador recomendado |
|---|---|
| Informe para leer una persona en su sistema | System.lineSeparator() (o println) |
| Fichero de datos que se compara o versiona | "\n" fijo, y documentarlo |
| Fichero que otro sistema define | El que ese sistema exija |
| Protocolo de red | El que diga el protocolo. HTTP exige CRLF (módulo 9) |
En BiblioTech usaremos System.lineSeparator() para los informes y "\n" fijo para los ficheros de datos. La constante va, como el charset, en FormatoFicheros.
- Permisos, ficheros de solo lectura y
IOException
IOExceptionEscribir puede fallar por causas que no dependen de tu código:
| Causa | Excepción | Mensaje típico |
|---|---|---|
| No existe el directorio padre | FileNotFoundException |
No such file or directory |
| Sin permiso de escritura | FileNotFoundException |
Permission denied |
| Fichero marcado de solo lectura | FileNotFoundException |
Access is denied (Windows) |
| La ruta es un directorio | FileNotFoundException |
Is a directory |
| Disco lleno | IOException |
No space left on device |
| Unidad de red desconectada | IOException |
Varía |
| Cuota de usuario superada | IOException |
Disk quota exceeded |
Observa un detalle importante: los fallos de apertura llegan como FileNotFoundException —igual que en lectura, con el nombre igual de engañoso— y los fallos durante la escritura llegan como IOException genérica. Y el más traicionero de todos es el disco lleno, porque no ocurre al abrir sino a mitad del fichero: cuando salta, ya tienes medio fichero escrito.
Ese es precisamente el argumento definitivo a favor del apartado siguiente.
Manejo por capas, con el criterio de 06-07:
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.logging.Level;
import java.util.logging.Logger;
public class EscrituraConManejo {
private static final Logger LOG = Logger.getLogger(EscrituraConManejo.class.getName());
public void exportar(String ruta, String contenido) throws CatalogoNoAccesibleException {
try (FileWriter w = new FileWriter(ruta, StandardCharsets.UTF_8)) {
w.write(contenido);
} catch (FileNotFoundException e) {
// Problema de UBICACION o PERMISOS: el usuario puede corregirlo
LOG.log(Level.WARNING, "No se puede escribir en "
+ new java.io.File(ruta).getAbsolutePath(), e);
throw new CatalogoNoAccesibleException(ruta, e); // 06-04, con la causa
} catch (IOException e) {
// Problema DURANTE la escritura: disco lleno, red caida...
LOG.log(Level.SEVERE, "Fallo de E/S escribiendo " + ruta, e);
throw new CatalogoNoAccesibleException(ruta, e);
}
}
}Fíjate en que se reutiliza CatalogoNoAccesibleException, la excepción comprobada que declaraste en 06-04 precisamente para esto: un fallo de infraestructura del que la aplicación tiene una alternativa razonable, y que por tanto merece que el compilador obligue a decidir qué hacer. La causa original viaja dentro, según la regla de encadenamiento de 06-03.
- Escritura atómica: temporal y renombrado
Este es el patrón profesional de la lección. El problema es este:
// PELIGROSO: regenera el catalogo completo sobrescribiendo el fichero bueno
try (PrintWriter w = new PrintWriter(
new FileWriter("datos/catalogo.txt", StandardCharsets.UTF_8))) {
for (Material m : catalogo.listar()) { // 10 000 materiales
w.println(serializar(m));
}
// Si falla en el material 6 000 (disco lleno, excepcion en serializar,
// el proceso muere), el fichero queda con 6 000 lineas y el catalogo
// ORIGINAL YA NO EXISTE: se trunco al abrir.
}El fichero bueno se destruyó en el instante de abrir el FileWriter, y el nuevo no llegó a completarse. Has perdido el catálogo. Y lo peor: el fichero resultante parece válido —tiene formato correcto, se lee sin errores— solo que le faltan 4 000 materiales. Un fallo silencioso, que es la peor clase.
La solución es el patrón escribir-y-renombrar:
flowchart LR
A["catalogo.txt<br/>version buena"] --> B{"Escribir todo en<br/>catalogo.txt.tmp"}
B -->|"exito"| C["Renombrar tmp<br/>sobre catalogo.txt"]
B -->|"fallo"| D["Borrar el tmp"]
C --> E["catalogo.txt<br/>version nueva completa"]
D --> F["catalogo.txt<br/>version buena INTACTA"]
style A fill:#e8f5e9
style E fill:#e8f5e9
style F fill:#e8f5e9
style D fill:#ffebee
Por qué funciona: el renombrado dentro del mismo sistema de ficheros es una operación atómica a ojos de quien lee. No existe un instante en el que el fichero esté a medias. Un lector concurrente ve la versión antigua completa o la nueva completa, nunca una mezcla. Y si algo falla antes del renombrado, el fichero bueno ni se ha tocado.
La implementación:
package com.nexussoftware.bibliotech.infraestructura;
import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.FileWriter;
import java.nio.charset.StandardCharsets;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Escritura atomica de ficheros de texto.
*
* Escribe en un fichero temporal y, SOLO si todo ha ido bien, lo renombra
* sobre el definitivo. Garantiza que el fichero destino nunca queda a medias.
*
* Es el equivalente en disco de la compensacion en finally de 06-05: si la
* operacion no se completa, el estado anterior se conserva intacto.
*/
public final class EscrituraAtomica {
private static final Logger LOG = Logger.getLogger(EscrituraAtomica.class.getName());
private static final String SUFIJO_TEMPORAL = ".tmp";
private EscrituraAtomica() { }
/**
* Contrato de lo que se va a escribir. Interfaz funcional (04-06):
* permite pasar la logica de escritura como lambda.
*
* No es java.util.function.Consumer porque necesitamos que pueda
* declarar IOException, y Consumer.accept() no la declara.
*/
@FunctionalInterface
public interface Contenido {
void escribirEn(PrintWriter salida) throws IOException;
}
/**
* Escribe de forma atomica.
*
* @param destino fichero final
* @param contenido que escribir
* @throws IOException si falla la escritura o el renombrado
*/
public static void escribir(File destino, Contenido contenido) throws IOException {
File temporal = new File(destino.getAbsolutePath() + SUFIJO_TEMPORAL);
// 1. Asegurar que existe el directorio padre (FileWriter no lo crea)
File padre = destino.getAbsoluteFile().getParentFile();
if (padre != null && !padre.exists() && !padre.mkdirs()) {
throw new IOException("No se pudo crear el directorio " + padre.getAbsolutePath());
}
boolean completado = false;
try {
// 2. Escribir TODO en el temporal
try (PrintWriter salida = new PrintWriter(
new FileWriter(temporal, StandardCharsets.UTF_8))) {
contenido.escribirEn(salida);
// PrintWriter se traga las IOException: hay que preguntarle
if (salida.checkError()) {
throw new IOException("Fallo al escribir en "
+ temporal.getAbsolutePath());
}
} // close() garantizado: aqui el temporal esta cerrado y completo
// 3. Renombrar SOLO si llegamos hasta aqui
if (!renombrar(temporal, destino)) {
throw new IOException("No se pudo renombrar "
+ temporal.getName() + " sobre " + destino.getName());
}
completado = true;
LOG.fine(() -> "Escritura atomica completada: " + destino.getAbsolutePath());
} finally {
// 4. COMPENSACION (06-05): si no se completo, no dejar basura.
// El destino original sigue intacto porque nunca se abrio.
if (!completado && temporal.exists() && !temporal.delete()) {
LOG.warning(() -> "Quedo un fichero temporal sin borrar: "
+ temporal.getAbsolutePath());
}
}
}
/**
* Renombra el temporal sobre el destino.
*
* File.renameTo devuelve un boolean mudo (07-01) y en Windows falla si
* el destino existe. De ahi el borrado previo. En 07-06 esto se resuelve
* con Files.move y ATOMIC_MOVE, que es la forma correcta.
*/
private static boolean renombrar(File temporal, File destino) {
if (destino.exists() && !destino.delete()) {
return false;
}
return temporal.renameTo(destino);
}
}Se usa así, con una lambda:
EscrituraAtomica.escribir(new File("datos/catalogo.txt"), salida -> {
salida.println("# Catalogo de BiblioTech");
for (Material m : catalogo.listar()) {
salida.printf("%s;%s;%s;%b%n",
m.getTipo(), m.getReferencia(), m.getTitulo(), m.estaDisponible());
}
});Tres observaciones sobre esta implementación:
- El
finallycompensa igual que en 06-05. Si no se completó, se borra el temporal. Y el destino no necesita compensación porque nunca se abrió: esa es toda la gracia del patrón. checkError()es obligatorio conPrintWriter. Sin él, un disco lleno pasaría desapercibido y renombrarías un fichero truncado sobre el bueno, que es justo lo que queríamos evitar.renameToes defectuoso —booleanmudo, comportamiento distinto en Windows, ventana entre eldeletey elrenameTo—. En 07-06 se sustituye porFiles.move(origen, destino, StandardCopyOption.ATOMIC_MOVE), que sí es atómica de verdad y lanza excepciones informativas. La versión de aquí es la que había que escribir antes de Java 7, y sirve para entender exactamente qué garantiza NIO.2.
Cuándo usar escritura atómica y cuándo no:
| Situación | ¿Atómica? |
|---|---|
| Regenerar un fichero completo (catálogo, exportación, configuración) | Sí, siempre |
| Añadir una línea a un registro de auditoría | No: se abre en modo append y se cierra |
| Fichero temporal de trabajo que se borra después | No hace falta |
| Cualquier fichero que otro proceso pueda estar leyendo | Sí, imprescindible |
- Ficheros de bloqueo y sobrescritura accidental
Dos peligros más, breves pero reales.
Dos procesos escribiendo el mismo fichero. Si dos instancias de BiblioTech exportan el catálogo a la vez, el resultado es impredecible: líneas entremezcladas, fichero truncado, o una de las dos escrituras perdida. Ni el sistema operativo ni Java lo impiden por defecto.
La solución tradicional es un fichero de bloqueo: un fichero cuya sola existencia significa "ocupado".
package com.nexussoftware.bibliotech.infraestructura;
import java.io.File;
import java.io.IOException;
/**
* Bloqueo entre procesos basado en la existencia de un fichero.
*
* AutoCloseable (06-06): el bloqueo se libera al salir del try, se salga
* como se salga. Sin esa garantia, un bloqueo huerfano deja el sistema
* inutilizable hasta que alguien borre el fichero a mano.
*/
public class BloqueoFichero implements AutoCloseable {
private final File bloqueo;
private boolean adquirido = false;
public BloqueoFichero(String rutaProtegida) throws IOException {
this.bloqueo = new File(rutaProtegida + ".lock");
// createNewFile() es ATOMICO: comprueba y crea en una sola operacion.
// Por eso sirve como bloqueo y un exists() + create() NO serviria:
// entre las dos llamadas cabe el otro proceso (TOCTOU, 06-07).
if (!bloqueo.createNewFile()) {
throw new IOException("El fichero '" + rutaProtegida
+ "' esta siendo usado por otro proceso"
+ " (existe " + bloqueo.getName() + ")");
}
adquirido = true;
bloqueo.deleteOnExit(); // red de seguridad ante una salida brusca
}
@Override
public void close() {
if (!adquirido) {
return; // idempotente (06-06)
}
adquirido = false;
if (!bloqueo.delete()) {
System.err.println("Aviso: no se pudo liberar " + bloqueo.getAbsolutePath());
}
}
}Uso:
try (BloqueoFichero lock = new BloqueoFichero("datos/catalogo.txt")) {
EscrituraAtomica.escribir(new File("datos/catalogo.txt"), salida -> { /* ... */ });
} // el bloqueo se libera aqui, pase lo que paseLimitaciones honestas de este mecanismo, que hay que conocer: si el proceso muere de forma brusca, el .lock queda huérfano y bloquea a todos los demás. Los sistemas serios guardan dentro el identificador del proceso y su hora de inicio para poder detectar bloqueos caducos. Java ofrece además FileLock a través de FileChannel, que sí es un bloqueo real del sistema operativo. Y en cuanto entren hilos en juego, el problema cambia de naturaleza: eso es el módulo 8.
Sobrescritura accidental. Antes de regenerar un fichero importante, guarda una copia:
/** Conserva la version anterior antes de sobrescribir. */
private static void hacerCopiaSeguridad(File fichero) {
if (!fichero.exists()) {
return;
}
File copia = new File(fichero.getAbsolutePath() + ".bak");
if (copia.exists()) {
copia.delete();
}
if (!fichero.renameTo(copia)) {
LOG.warning("No se pudo hacer copia de seguridad de " + fichero.getName());
}
}En 07-06 esto se convierte en una copia de seguridad rotativa con NIO.2: .bak.1, .bak.2, .bak.3, conservando las tres últimas versiones.
- BiblioTech:
ExportadorCatalogo escribe de verdad
ExportadorCatalogo escribe de verdadHora de saldar la deuda. En 06-06 declaraste ExportadorCatalogo como Closeable y explicaste por qué su close() sí debe propagar IOException —porque al cerrar se vacía el búfer, y si eso falla los datos no están escritos—. Pero su escritura era un esbozo. Ahora se escribe entera, con todo lo de esta lección.
package com.nexussoftware.bibliotech.servicio;
import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.List;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
import com.nexussoftware.bibliotech.dominio.Libro;
import com.nexussoftware.bibliotech.dominio.Material;
import com.nexussoftware.bibliotech.dominio.Prestamo;
import com.nexussoftware.bibliotech.infraestructura.EscrituraAtomica;
import com.nexussoftware.bibliotech.infraestructura.FormatoFicheros;
/**
* Exporta el catalogo y el registro de prestamos de BiblioTech a ficheros
* de texto, de forma ATOMICA: el fichero destino nunca queda a medias.
*
* Cambio de diseno respecto a 06-06: esta clase ya NO es Closeable.
* No mantiene ningun fichero abierto entre llamadas; cada exportacion
* abre, escribe y cierra dentro de EscrituraAtomica. Un objeto que no
* posee recursos vivos no debe ser cerrable: seria una promesa vacia.
*
* El formato es el que lee CargadorCatalogo (07-01). Ambos comparten las
* constantes de FormatoFicheros para que no puedan discrepar.
*/
public class ExportadorCatalogo {
private static final Logger LOG = Logger.getLogger(ExportadorCatalogo.class.getName());
private static final String CABECERA_CATALOGO =
FormatoFicheros.COMENTARIO + " Catalogo de BiblioTech - Nexus Software";
private static final String CAMPOS_CATALOGO =
FormatoFicheros.COMENTARIO + " tipo;referencia;titulo;autor;anio";
private final File directorio;
public ExportadorCatalogo(String directorioDatos) {
this.directorio = new File(
Objects.requireNonNull(directorioDatos, "El directorio no puede ser nulo"));
}
/**
* Exporta el catalogo completo.
*
* @return numero de materiales escritos
* @throws IOException si no se pudo completar la escritura
*/
public int exportarCatalogo(Catalogo catalogo) throws IOException {
Objects.requireNonNull(catalogo, "El catalogo no puede ser nulo");
List<Material> materiales = catalogo.listar();
File destino = new File(directorio, "catalogo.txt");
long inicio = System.nanoTime();
EscrituraAtomica.escribir(destino, salida -> {
salida.println(CABECERA_CATALOGO);
salida.println(CAMPOS_CATALOGO);
for (Material m : materiales) {
salida.println(serializar(m));
}
});
double ms = (System.nanoTime() - inicio) / 1_000_000.0;
// Logger, nunca System.out para diagnostico (06-07). Forma perezosa.
LOG.info(() -> String.format("Catalogo exportado: %d materiales en %s (%.1f ms)",
materiales.size(), destino.getAbsolutePath(), ms));
return materiales.size();
}
/**
* Serializa un material a una linea.
*
* Los campos se saneen: un ';' dentro de un titulo romperia el fichero
* al releerlo. Aqui se sustituye; en 07-07 se hara BIEN, entrecomillando
* y escapando segun la convencion CSV.
*/
private String serializar(Material m) {
String sep = FormatoFicheros.SEPARADOR_CAMPOS;
if (m instanceof Libro libro) { // patron de 03-06
return String.join(sep,
"LIBRO",
sanear(libro.getIsbn()),
sanear(libro.getTitulo()),
sanear(libro.getAutor()),
String.valueOf(libro.getAnioPublicacion()));
}
return String.join(sep,
m.getTipo().toUpperCase(),
sanear(m.getReferencia()),
sanear(m.getTitulo()),
"-",
"0");
}
/** Elimina separadores y saltos de linea que romperian el formato. */
private String sanear(String valor) {
if (valor == null) {
return "";
}
return valor.replace(FormatoFicheros.SEPARADOR_CAMPOS, ",")
.replace("\n", " ")
.replace("\r", " ")
.trim();
}
/**
* Anade una linea al registro de auditoria de prestamos.
*
* ESTE fichero SI se abre en modo ANADIR: es un historico acumulativo,
* no un fichero que se regenera. No lleva escritura atomica porque no
* hay nada que reemplazar; solo se agrega al final.
*/
public void registrarPrestamo(Prestamo prestamo, String operacion) {
Objects.requireNonNull(prestamo, "El prestamo no puede ser nulo");
File auditoria = new File(directorio, "auditoria.txt");
// El 'true' final es ANADIR. Sin el, cada llamada borraria el historico.
try (PrintWriter salida = new PrintWriter(
new java.io.FileWriter(auditoria, FormatoFicheros.CHARSET, true))) {
salida.printf("%s;%s;%s;%s%n",
operacion,
prestamo.getReferencia(),
prestamo.getMaterial().getReferencia(),
prestamo.getTitular().getIdentificador());
if (salida.checkError()) {
throw new IOException("Fallo escribiendo en " + auditoria.getAbsolutePath());
}
} catch (IOException e) {
// POLITICA: la auditoria NO debe tumbar la operacion de negocio.
// El prestamo ya se ha registrado en memoria y es valido.
// Se degrada: se avisa en el log y se sigue (06-07).
LOG.log(Level.WARNING, "No se pudo auditar la operacion "
+ operacion + " de " + prestamo.getReferencia(), e);
}
}
}Las cinco decisiones de diseño que hay que entender de esta clase:
- Ha dejado de ser
Closeable. En 06-06 mantenía unBufferedWriterabierto durante toda su vida. Ahora cada exportación abre y cierra dentro deEscrituraAtomica, así que no posee ningún recurso vivo. Un objeto que no tiene nada que cerrar no debe implementarCloseable: sería una promesa vacía que hace que quien lo usa escriba untry-with-resourcesinútil. - El catálogo se escribe de forma atómica; la auditoría, en modo
append. Son dos naturalezas distintas: uno se regenera entero, el otro crece. La escritura atómica es para regenerar; el modoappendes para crecer. Confundirlos produce, en un sentido, un fichero destruido, y en el otro, un fichero que duplica todo cada vez. - El fallo de auditoría no aborta la operación. El préstamo es válido aunque no se haya podido auditar. Es la distinción recuperable/irrecuperable de 06-07 aplicada aquí: no auditar es una pérdida de información, no un dato incorrecto. Si la política de la empresa exigiera lo contrario —y en un sistema financiero lo exigiría—, esta decisión se invertiría y se documentaría.
- El
checkError()está en los dos sitios. Sin él,PrintWriterno dice nada cuando el disco se llena. sanear()es un parche declarado como tal. Sustituir el;por una coma pierde información: el título vuelve mal. Es un compromiso consciente hasta 07-07, donde el escapado CSV lo resolverá de verdad. Marcar los apaños en un comentario es parte del oficio.
Y el guardado al salir, enganchado al shutdown hook de 06-05:
package com.nexussoftware.bibliotech.presentacion;
import java.io.IOException;
import java.util.logging.Level;
import java.util.logging.Logger;
import com.nexussoftware.bibliotech.servicio.Catalogo;
import com.nexussoftware.bibliotech.servicio.CargadorCatalogo;
import com.nexussoftware.bibliotech.servicio.ExportadorCatalogo;
import com.nexussoftware.bibliotech.infraestructura.ConfiguracionLog;
public class BiblioTechApp {
private static final Logger LOG = Logger.getLogger(BiblioTechApp.class.getName());
private static final String DIRECTORIO_DATOS = "datos";
public static void main(String[] args) {
ConfiguracionLog.inicializar();
Catalogo catalogo = new CargadorCatalogo(DIRECTORIO_DATOS + "/catalogo.txt").cargar();
ExportadorCatalogo exportador = new ExportadorCatalogo(DIRECTORIO_DATOS);
// Guardado al salir. Cubre la salida normal y Ctrl+C; NO cubre kill -9.
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
int n = exportador.exportarCatalogo(catalogo);
LOG.info("Catalogo guardado al salir: " + n + " materiales");
} catch (IOException e) {
LOG.log(Level.SEVERE, "NO SE PUDO GUARDAR EL CATALOGO AL SALIR", e);
}
}, "guardado-final"));
System.out.println("BiblioTech - Nexus Software");
System.out.println("Materiales en catalogo: " + catalogo.tamano());
new MenuBiblioTech(catalogo, exportador).ejecutar();
}
}Aviso importante sobre el hook. Guardar solo al salir es frágil:
kill -9, un corte de corriente o unOutOfMemoryErrorse lo saltan. Y el hook tiene un tiempo limitado antes de que el sistema mate el proceso. En un sistema real se guarda también después de cada operación relevante, o de forma periódica. El hook es la red de seguridad, no la estrategia.
Estado de BiblioTech al cerrar esta lección: el catálogo se carga al arrancar (07-01) y se guarda al salir (07-02). Por primera vez en siete módulos, la aplicación recuerda.
Errores Comunes y Consejos
- Olvidar el
truedel modoappend. El error más caro del módulo. Cada ejecución borra el histórico y deja una línea. No falla, no avisa: destruye en silencio. Usa una constanteANADIRcon nombre. - Confundir
new FileWriter(ruta, true)connew FileWriter(ruta, UTF_8). Se parecen y significan cosas opuestas. Cuando quieras las dos, usa la versión de tres parámetros con elbooleanal final. - Creer que abrir el fichero es inofensivo. Construir un
FileWritersinappendtrunca el fichero inmediatamente, antes de escribir nada. Si luego falla, te quedas sin datos y sin fichero nuevo. - No cerrar el escritor. El fichero queda vacío. En lectura no cerrar es una fuga; en escritura es pérdida de datos.
try-with-resourcessiempre. - Confiar en el recolector de basura para cerrar.
finalize()está obsoleto y eliminado, y nunca fue una garantía. - Confundir
flush()con "está en el disco".flush()llega al sistema operativo; solosync()llega al plato. Para casi todo,close()basta. - Ignorar que
PrintWriterse traga lasIOException. Un disco lleno pasa desapercibido y renombras un fichero truncado sobre el bueno.checkError()antes de cerrar, o usaBufferedWriter. - Escribir sobre el fichero definitivo. Si falla a mitad, pierdes el original y obtienes uno incompleto que parece válido. Temporal y renombrado.
- No crear el directorio padre.
FileWriterno lo crea; lanzaIOExceptioncon un mensaje que parece decir otra cosa. - No especificar el charset al escribir. Peor que en lectura: los datos quedan mal grabados, y el problema aparece meses después cuando otra herramienta los lee.
- Escribir caracteres no representables en el charset destino. Se sustituyen por
?sin avisar.€yñen US-ASCII desaparecen en silencio. - Que el lector y el escritor usen charsets distintos. Declara el charset una sola vez en una constante compartida.
- Usar
\ncuando el consumidor espera CRLF, o al revés.printlnynewLine()usan el del sistema; para ficheros que se versionan, fija\ny documéntalo. - Creer que
write(65)escribe "65". Escribe la letraA. Es un carácter, no un número. - Dos procesos escribiendo el mismo fichero. Resultado impredecible. Fichero de bloqueo, y aun así con sus límites.
- Consejo: pregúntate siempre "¿este fichero se regenera o crece?". Regenerar exige escritura atómica; crecer exige modo
append. Toda la lección cabe en esa pregunta. - Consejo: prueba a llenar el disco. Escribe en un dispositivo pequeño —una partición temporal de unos pocos MB— y comprueba qué hace tu código cuando se acaba. La mayoría del código no lo soporta, y el fallo a mitad de fichero es el escenario que la escritura atómica existe para cubrir.
- Consejo: comprueba con un editor externo. Abre el fichero generado con otro programa y con otra codificación. Si solo lo lees con tu propio código, los errores de formato y de charset son invisibles.
- Consejo: no escribas la contraseña ni el DNI en el fichero. Todo lo de 06-07 sobre qué no registrar se aplica igual a lo que se persiste, y con más motivo: el fichero se queda.
Ejercicios
Ejercicio 1: demostrador de append
Escribe DemoAppend que demuestre empíricamente la diferencia entre los dos modos:
- Un método
escribirLinea(String ruta, String texto, boolean anadir)que escriba una línea con el modo indicado y charset UTF-8. - Un método
mostrar(String ruta)que imprima el contenido y el número de líneas. - Un
mainque: borre el fichero si existe; escriba tres líneas sinappendmostrando el estado tras cada una; y repita el experimento conappend. - Un comentario final que explique el resultado en una frase.
Ejercicio 2: escritura atómica con verificación
Amplía EscrituraAtomica con un método escribirVerificado(File destino, Contenido contenido, int lineasEsperadas) que, además de lo que ya hace:
- Cuente las líneas realmente escritas en el temporal.
- Antes de renombrar, verifique que el número coincide con
lineasEsperadas; si no, lanceIOExceptiony no renombre. - Compruebe también que el temporal tiene tamaño mayor que cero.
- Registre en el logger el resultado de la verificación.
Escribe un main que provoque el fallo a propósito —una lambda que lance una excepción a mitad— y compruebe que el fichero destino conserva su contenido anterior.
Ejercicio 3: registro de auditoría rotativo
Escribe AuditoriaBiblioTech que añada líneas a un fichero de auditoría con rotación por tamaño:
registrar(String operacion, String referencia, String idEmpleado)que añada una línea con formatooperacion;referencia;idEmpleadoen modoappend.- Antes de escribir, si el fichero supera
TAMANO_MAXIMO(usa 1 KB para poder probarlo), renómbralo aauditoria-1.txt, desplazando los anteriores hasta un máximo de 3, y empieza uno nuevo. - Charset explícito y separador de línea fijo
\n(es un fichero de datos, no un informe). - Un fallo de escritura no debe propagarse: se registra en el logger y se continúa, siguiendo la política de
ExportadorCatalogo. - Un
mainque genere 200 operaciones y muestre los ficheros resultantes con sus tamaños.
Soluciones
Solución 1
package com.nexussoftware.bibliotech.demo;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;
/**
* Demostracion empirica del parametro append.
*/
public class DemoAppend {
/** Nombres para el boolean: evita el 'true' suelto e ilegible. */
private static final boolean ANADIR = true;
private static final boolean SOBRESCRIBIR = false;
private static final String RUTA = "demo-append.txt";
public static void escribirLinea(String ruta, String texto, boolean anadir)
throws IOException {
try (FileWriter w = new FileWriter(ruta, StandardCharsets.UTF_8, anadir)) {
w.write(texto);
w.write('\n');
} // close() vacia el buffer: sin el, el fichero quedaria vacio
}
public static void mostrar(String ruta) {
File f = new File(ruta);
if (!f.exists()) {
System.out.println(" (el fichero no existe)");
return;
}
System.out.println(" tamano: " + f.length() + " bytes");
try (Scanner sc = new Scanner(f, StandardCharsets.UTF_8)) {
int n = 0;
while (sc.hasNextLine()) {
System.out.println(" | " + sc.nextLine());
n++;
}
System.out.println(" lineas: " + n);
} catch (IOException e) {
System.out.println(" error al leer: " + e.getMessage());
}
}
public static void main(String[] args) throws IOException {
File f = new File(RUTA);
if (f.exists() && !f.delete()) {
System.err.println("No se pudo borrar el fichero previo");
return;
}
System.out.println("=== EXPERIMENTO 1: SIN append (sobrescribir) ===");
for (int i = 1; i <= 3; i++) {
escribirLinea(RUTA, "Operacion numero " + i, SOBRESCRIBIR);
System.out.println(" Tras escribir la linea " + i + ":");
mostrar(RUTA);
}
f.delete();
System.out.println();
System.out.println("=== EXPERIMENTO 2: CON append (anadir) ===");
for (int i = 1; i <= 3; i++) {
escribirLinea(RUTA, "Operacion numero " + i, ANADIR);
System.out.println(" Tras escribir la linea " + i + ":");
mostrar(RUTA);
}
// CONCLUSION: sin append, cada escritura TRUNCA el fichero a cero
// en el momento de abrirlo, asi que solo sobrevive la ultima linea.
// Con append, el puntero se situa al final y el contenido crece.
}
}Salida:
=== EXPERIMENTO 1: SIN append (sobrescribir) ===
Tras escribir la linea 1:
tamano: 20 bytes
| Operacion numero 1
lineas: 1
Tras escribir la linea 2:
tamano: 20 bytes
| Operacion numero 2
lineas: 1
Tras escribir la linea 3:
tamano: 20 bytes
| Operacion numero 3
lineas: 1
=== EXPERIMENTO 2: CON append (anadir) ===
Tras escribir la linea 1:
tamano: 20 bytes
| Operacion numero 1
lineas: 1
Tras escribir la linea 2:
tamano: 40 bytes
| Operacion numero 1
| Operacion numero 2
lineas: 2
Tras escribir la linea 3:
tamano: 60 bytes
| Operacion numero 1
| Operacion numero 2
| Operacion numero 3
lineas: 3El experimento 1 es el bug del apartado 2 en directo. Fíjate en que el tamaño se mantiene constante en 20 bytes: el fichero no crece porque cada apertura lo vacía. Nada falla, nada avisa, y el histórico no existe.
Solución 2
package com.nexussoftware.bibliotech.infraestructura;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Escritura atomica con verificacion previa al renombrado.
*
* Anade una comprobacion de integridad ANTES de sustituir el fichero bueno:
* si el resultado no es el esperado, el original se conserva intacto.
*/
public final class EscrituraAtomicaVerificada {
private static final Logger LOG =
Logger.getLogger(EscrituraAtomicaVerificada.class.getName());
private static final String SUFIJO_TEMPORAL = ".tmp";
private EscrituraAtomicaVerificada() { }
@FunctionalInterface
public interface Contenido {
void escribirEn(PrintWriter salida) throws IOException;
}
/**
* Escribe de forma atomica verificando el resultado.
*
* @param lineasEsperadas numero de lineas que DEBE tener el resultado
* @throws IOException si falla la escritura o la verificacion
*/
public static void escribirVerificado(File destino, Contenido contenido,
int lineasEsperadas) throws IOException {
File temporal = new File(destino.getAbsolutePath() + SUFIJO_TEMPORAL);
File padre = destino.getAbsoluteFile().getParentFile();
if (padre != null && !padre.exists() && !padre.mkdirs()) {
throw new IOException("No se pudo crear el directorio " + padre.getAbsolutePath());
}
boolean completado = false;
try {
// ---- FASE 1: escribir en el temporal ----
try (PrintWriter salida = new PrintWriter(
new FileWriter(temporal, StandardCharsets.UTF_8))) {
contenido.escribirEn(salida);
if (salida.checkError()) {
throw new IOException("Fallo de escritura en " + temporal.getName());
}
}
// ---- FASE 2: verificar ANTES de tocar el destino ----
long tamano = temporal.length();
if (tamano == 0) {
throw new IOException("El fichero temporal quedo vacio; no se sustituye "
+ destino.getName());
}
int lineasReales = contarLineas(temporal);
if (lineasReales != lineasEsperadas) {
throw new IOException(String.format(
"Verificacion fallida en %s: se esperaban %d lineas y hay %d. "
+ "El fichero original NO se ha modificado.",
destino.getName(), lineasEsperadas, lineasReales));
}
LOG.fine(() -> String.format("Verificacion OK: %d lineas, %d bytes",
lineasReales, tamano));
// ---- FASE 3: sustituir. Solo si todo lo anterior fue bien ----
if (destino.exists() && !destino.delete()) {
throw new IOException("No se pudo eliminar el destino " + destino.getName());
}
if (!temporal.renameTo(destino)) {
throw new IOException("No se pudo renombrar " + temporal.getName());
}
completado = true;
LOG.info(() -> String.format("Escritura verificada de %s: %d lineas, %d bytes",
destino.getAbsolutePath(), lineasEsperadas, tamano));
} finally {
// COMPENSACION (06-05): sin basura, y con el original intacto
if (!completado && temporal.exists() && !temporal.delete()) {
LOG.warning(() -> "Temporal sin borrar: " + temporal.getAbsolutePath());
}
}
}
private static int contarLineas(File f) throws IOException {
int n = 0;
try (Scanner sc = new Scanner(f, StandardCharsets.UTF_8)) {
while (sc.hasNextLine()) {
sc.nextLine();
n++;
}
}
return n;
}
// ------------------------- DEMOSTRACION -------------------------
public static void main(String[] args) throws IOException {
File destino = new File("datos/catalogo-verificado.txt");
// 1. Escritura correcta de 3 lineas
escribirVerificado(destino, salida -> {
salida.println("LIBRO;978-0000000001;Java Efectivo;Bloch;2018");
salida.println("LIBRO;978-0000000002;Patrones de Diseno;Gamma;1994");
salida.println("LIBRO;978-0000000003;Refactorizacion;Fowler;1999");
}, 3);
System.out.println("Tras la escritura correcta: "
+ destino.length() + " bytes, "
+ contarLineas(destino) + " lineas");
// 2. Escritura que FALLA a mitad
try {
escribirVerificado(destino, salida -> {
salida.println("LIBRO;978-0000000004;Nuevo libro;Autor;2024");
// Fallo simulado: disco lleno, error de serializacion, lo que sea
throw new IOException("Fallo simulado a mitad de la exportacion");
}, 4);
} catch (IOException e) {
System.out.println("Fallo capturado: " + e.getMessage());
}
// 3. COMPROBACION CLAVE: el fichero bueno sigue ahi, entero
System.out.println("Tras el fallo: "
+ destino.length() + " bytes, "
+ contarLineas(destino) + " lineas");
System.out.println("¿Quedo basura .tmp? "
+ new File(destino.getAbsolutePath() + ".tmp").exists());
// 4. Escritura con numero de lineas incorrecto: se rechaza
try {
escribirVerificado(destino, salida -> salida.println("Solo una linea"), 5);
} catch (IOException e) {
System.out.println("Verificacion: " + e.getMessage());
}
System.out.println("Tras la verificacion fallida: "
+ contarLineas(destino) + " lineas (intacto)");
}
}Salida:
Tras la escritura correcta: 138 bytes, 3 lineas
Fallo capturado: Fallo simulado a mitad de la exportacion
Tras el fallo: 138 bytes, 3 lineas
¿Quedo basura .tmp? false
Verificacion: Verificacion fallida en catalogo-verificado.txt: se esperaban 5 lineas y hay 1. El fichero original NO se ha modificado.
Tras la verificacion fallida: 3 lineas (intacto)Las tres líneas que importan de esa salida son la tercera, la cuarta y la última: tras un fallo a mitad de escritura y tras una verificación fallida, el fichero bueno sigue con sus 3 líneas y no ha quedado basura temporal. Eso es exactamente lo que aporta el patrón, y es imposible de conseguir escribiendo directamente sobre el destino.
Fíjate también en que la verificación va entre la escritura y el renombrado. Ese hueco es el que hace que el patrón sea tan valioso: puedes comprobar lo que quieras —número de líneas, cabecera, formato, incluso releerlo y parsearlo entero— antes de comprometerte a sustituir el original.
Solución 3
package com.nexussoftware.bibliotech.infraestructura;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Registro de auditoria de BiblioTech con rotacion por tamano.
*
* Modo ANADIR: el fichero crece, no se regenera. Por eso NO usa escritura
* atomica: no hay nada que reemplazar.
*
* Separador de linea fijo '\n': es un fichero de DATOS que se puede comparar
* entre maquinas, no un informe para leer en pantalla (apartado 8).
*/
public class AuditoriaBiblioTech {
private static final Logger LOG = Logger.getLogger(AuditoriaBiblioTech.class.getName());
private static final boolean ANADIR = true;
private static final String SALTO = "\n";
private static final String SEP = ";";
/** 1 KB, pequeno a proposito para poder probar la rotacion. */
private static final long TAMANO_MAXIMO = 1024L;
/** Ficheros historicos que se conservan: auditoria-1 .. auditoria-3. */
private static final int MAX_HISTORICOS = 3;
private final File directorio;
private final String nombreBase;
private int operacionesRegistradas = 0;
private int rotaciones = 0;
private int fallos = 0;
public AuditoriaBiblioTech(String directorio) {
this.directorio = new File(
Objects.requireNonNull(directorio, "El directorio no puede ser nulo"));
this.nombreBase = "auditoria";
if (!this.directorio.exists() && !this.directorio.mkdirs()) {
LOG.warning("No se pudo crear " + this.directorio.getAbsolutePath());
}
}
/**
* Registra una operacion.
*
* POLITICA: un fallo de auditoria NUNCA se propaga. La operacion de
* negocio ya es valida; perder su rastro es una degradacion aceptable
* y se avisa en el log (06-07).
*/
public void registrar(String operacion, String referencia, String idEmpleado) {
Objects.requireNonNull(operacion, "La operacion no puede ser nula");
File actual = new File(directorio, nombreBase + ".txt");
try {
// 1. Rotar ANTES de escribir, si toca
if (actual.exists() && actual.length() >= TAMANO_MAXIMO) {
rotar(actual);
}
// 2. Anadir la linea. El 'true' es lo que hace que esto sea
// un historico y no un fichero de una sola linea.
try (PrintWriter salida = new PrintWriter(
new FileWriter(actual, StandardCharsets.UTF_8, ANADIR))) {
salida.write(String.join(SEP, operacion, referencia, idEmpleado));
salida.write(SALTO);
if (salida.checkError()) {
throw new IOException("Fallo escribiendo en " + actual.getAbsolutePath());
}
}
operacionesRegistradas++;
} catch (IOException e) {
fallos++;
LOG.log(Level.WARNING, "No se pudo auditar " + operacion
+ " de " + referencia, e);
// Sin relanzar: la degradacion es deliberada
}
}
/**
* Desplaza los historicos: -2 pasa a -3, -1 pasa a -2, el actual a -1.
*
* Se recorre de MAYOR a MENOR. Al reves, el primer renombrado
* machacaria el siguiente antes de haberlo desplazado.
*/
private void rotar(File actual) throws IOException {
// El mas antiguo se pierde
File masAntiguo = historico(MAX_HISTORICOS);
if (masAntiguo.exists() && !masAntiguo.delete()) {
throw new IOException("No se pudo borrar " + masAntiguo.getName());
}
for (int i = MAX_HISTORICOS - 1; i >= 1; i--) {
File origen = historico(i);
File destino = historico(i + 1);
if (origen.exists() && !origen.renameTo(destino)) {
throw new IOException("No se pudo rotar " + origen.getName());
}
}
if (!actual.renameTo(historico(1))) {
throw new IOException("No se pudo rotar " + actual.getName());
}
rotaciones++;
LOG.fine(() -> "Auditoria rotada (rotacion numero " + rotaciones + ")");
}
private File historico(int n) {
return new File(directorio, nombreBase + "-" + n + ".txt");
}
public int getOperacionesRegistradas() { return operacionesRegistradas; }
public int getRotaciones() { return rotaciones; }
public int getFallos() { return fallos; }
// ------------------------- DEMOSTRACION -------------------------
public static void main(String[] args) {
AuditoriaBiblioTech auditoria = new AuditoriaBiblioTech("datos/auditoria");
String[] operaciones = { "PRESTAMO", "DEVOLUCION", "ALTA", "BAJA" };
String[] empleados = { "E-001", "E-002", "E-003" };
for (int i = 1; i <= 200; i++) {
auditoria.registrar(
operaciones[i % operaciones.length],
String.format("PR-%04d", i),
empleados[i % empleados.length]);
}
System.out.println("=== AUDITORIA ===");
System.out.printf(" Operaciones registradas: %d%n",
auditoria.getOperacionesRegistradas());
System.out.printf(" Rotaciones realizadas : %d%n", auditoria.getRotaciones());
System.out.printf(" Fallos : %d%n", auditoria.getFallos());
System.out.println(" --- Ficheros ---");
File dir = new File("datos/auditoria");
File[] ficheros = dir.listFiles(); // puede ser null: se comprueba
if (ficheros != null) {
java.util.Arrays.sort(ficheros); // 05-09
for (File f : ficheros) {
System.out.printf(" %-20s %6d bytes%n", f.getName(), f.length());
}
}
}
}Salida:
=== AUDITORIA ===
Operaciones registradas: 200
Rotaciones realizadas : 4
Fallos : 0
--- Ficheros ---
auditoria-1.txt 1043 bytes
auditoria-2.txt 1044 bytes
auditoria-3.txt 1043 bytes
auditoria.txt 836 bytesLos cuatro puntos didácticos:
- El bucle de rotación va de mayor a menor. Es el detalle que más se falla. Si rotaras de 1 a 3, el primer
renameTomachacaríaauditoria-2.txtantes de haberlo desplazado a-3, y perderías todo el histórico menos el último. Hazte el dibujo mental de las tres flechas antes de escribirlo. - Se rota antes de escribir, no después. Así el fichero nunca supera el límite de forma apreciable, y no hay que preocuparse del tamaño de la línea que va a entrar.
- El fallo no se propaga y se cuenta. El contador
fallospermite detectar en el informe que la auditoría está degradada, aunque la aplicación siga funcionando. Degradar sin dejar rastro sería peor que fallar. listFiles()puede devolvernull. La comprobación no es paranoia: devuelvenullsi el directorio no existe o si falla el acceso. Es elbooleanmudo del apartado 6 de 07-01 en otra forma, y en 07-06 lo resuelveFiles.list().
Y observa que este mecanismo es exactamente lo que hace por dentro el FileHandler de java.util.logging que configuraste en 06-07, con su bibliotech-%g.log, sus 5 ficheros y su límite de 1 MB. Ahora sabes cómo está implementado.
Conclusión
BiblioTech ya guarda.
Conoces FileWriter y su comportamiento en la apertura: crea el fichero si no existe y —esto es lo que hay que recordar por encima de todo— lo trunca a cero bytes si existe, en el instante de construirlo, antes de escribir nada. Dominas el parámetro append y sabes que olvidarlo convierte un histórico de trescientas mil líneas en un fichero de una, sin excepción, sin aviso y de forma perfectamente estable. Tienes las tres defensas: nombrar el boolean con una constante, usar las opciones explícitas de NIO.2 que verás en 07-06, y escribir siempre a un temporal cuando regeneres un fichero entero.
Sabes escribir con write en sus cinco sobrecargas, con la asimetría de write(int) que escribe un carácter y no un número, y conoces PrintWriter como envoltorio que aporta println, printf y format reutilizando el formateo del módulo 1 —con Locale.ROOT cuando el fichero lo va a leer un programa y el Locale del sistema cuando lo va a leer una persona—. Y conoces su trampa mayor: PrintWriter no lanza IOException, se la guarda en una bandera, así que un disco lleno pasa desapercibido si no llamas a checkError().
Entiendes el búfer y la cadena completa de vaciados: write llega al búfer de la aplicación, flush llega al sistema operativo, close hace flush y libera, y solo sync llega al plato del disco. Sabes por qué un fichero recién escrito tiene 0 bytes, por qué tail -f no muestra nada durante un rato, y qué pasa exactamente si el programa termina sin cerrar: el fichero queda vacío, porque el recolector de basura no cierra nada y finalize() está eliminado. Por eso el try-with-resources importa más al escribir que al leer: allí no cerrar es una fuga; aquí es pérdida de datos.
Sabes que el charset explícito es aún más crítico al escribir, porque el error queda grabado y solo aparece cuando otra herramienta lee el fichero; y que escribir un carácter no representable —una ñ en US-ASCII— lo sustituye por ? sin decir nada. Conoces System.lineSeparator(), las tres convenciones de fin de línea, y el criterio para elegir: el del sistema para informes, \n fijo para ficheros de datos que se comparan o se versionan. Y sabes que el charset y el separador deben declararse una sola vez en una constante compartida por el lector y el escritor.
Y tienes el patrón que separa el código aficionado del profesional: la escritura atómica. Escribir en un temporal, verificar, y renombrar solo al final. Sabes por qué funciona —el renombrado es atómico para quien lee, así que nunca se ve un fichero a medias—, sabes que el finally compensa borrando el temporal exactamente como aprendiste en 06-05, y sabes que el destino no necesita compensación porque nunca se abrió. Y sabes cuándo aplicarla: cuando el fichero se regenera; nunca cuando el fichero crece, que es el caso del modo append. Toda la lección cabe en esa pregunta: ¿este fichero se regenera o crece?
Conoces también los ficheros de bloqueo con createNewFile() como operación atómica de comprobar-y-crear, sus limitaciones honestas —bloqueos huérfanos, la alternativa de FileLock—, y las copias de seguridad rotativas que 07-06 hará bien con NIO.2.
BiblioTech, al cerrar esta lección, carga su catálogo al arrancar y lo guarda al salir. ExportadorCatalogo escribe de verdad, de forma atómica, comparte con CargadorCatalogo las constantes de FormatoFicheros para que no puedan discrepar, y ha dejado de ser Closeable porque ya no posee ningún recurso vivo —un objeto que no tiene nada que cerrar no debe prometer que se cierra—. Su registro de auditoría se abre en modo append porque crece, y sus fallos no tumban la operación de negocio, sino que degradan con un aviso en el log. Y un shutdown hook garantiza el guardado en la salida normal, con la advertencia explícita de que un kill -9 se lo salta y de que la estrategia seria es guardar también durante la ejecución.
Queda una pregunta de fondo que las dos lecciones han esquivado. Has usado FileReader, FileWriter, Scanner y PrintWriter como si fueran piezas sueltas, y no lo son: forman parte de un diseño con una estructura muy deliberada. ¿Por qué PrintWriter envuelve a FileWriter en lugar de sustituirlo? ¿Por qué hay dos familias distintas de clases, unas terminadas en Reader/Writer y otras en InputStream/OutputStream? ¿Y cómo se lee una imagen, que no es texto?
En la lección 07-03, Flujos de Archivos, se responde. Verás el modelo conceptual del flujo con su vocabulario de fuente, destino y dirección; las dos jerarquías —bytes y caracteres— y por qué tenían que ser dos; la distinción entre clases de nodo y clases de filtro, que es el patrón Decorador en estado puro y explica de una vez esos new BufferedInputStream(new FileInputStream(...)) que aparecen por todas partes; los puentes InputStreamReader y OutputStreamWriter, que son exactamente el punto donde se decide la codificación que llevas dos lecciones especificando; la lectura y escritura de datos binarios por bytes y por bloques, con la copia de la imagen de portada de un libro; y transferTo, la forma moderna de copiar un flujo entero en una línea. Al terminarla, dejarás de usar estas clases de memoria y empezarás a componerlas sabiendo exactamente qué hace cada capa.
Curso de Programación en Java
Módulo 1: Introducción a Java
- Introducción a Java
- Configuración del Entorno de Desarrollo
- Sintaxis y Estructura Básica
- Variables y Tipos de Datos
- Operadores
- Entrada y Salida por Consola
- Tu Primer Programa Completo: BiblioTech
Módulo 2: Flujo de Control
- Sentencias Condicionales
- Bucles
- Sentencias Switch
- Break y Continue
- Depuración y Trazas de Ejecución
- Proyecto: Menú Interactivo de BiblioTech
Módulo 3: Programación Orientada a Objetos
- Introducción a la POO
- Clases y Objetos
- Métodos
- Constructores
- Herencia
- Polimorfismo
- Encapsulamiento
- Abstracción
- La Clase Object: equals, hashCode y toString
Módulo 4: Programación Orientada a Objetos Avanzada
- Interfaces
- Clases Abstractas
- Clases Internas
- Clases Anónimas
- Expresiones Lambda
- Interfaces Funcionales y Referencias a Métodos
- Enumeraciones y Registros
Módulo 5: Estructuras de Datos y Colecciones
- Arreglos
- El Framework de Colecciones
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cola y Deque
- Pila
- Ordenación y Búsqueda en Colecciones
Módulo 6: Manejo de Excepciones
- Introducción a las Excepciones
- Bloque Try-Catch
- Throw y Throws
- Excepciones Personalizadas
- Bloque Finally
- Try-with-resources y AutoCloseable
- Estrategias de Manejo de Errores y Logging
Módulo 7: Entrada/Salida de Archivos
- Lectura de Archivos
- Escritura de Archivos
- Flujos de Archivos
- BufferedReader y BufferedWriter
- Serialización
- La API NIO.2: Path y Files
- Formatos de Intercambio: CSV y Properties
Módulo 8: Multihilo y Concurrencia
- Introducción al Multihilo
- Creación de Hilos
- Ciclo de Vida de un Hilo
- Sincronización
- Utilidades de Concurrencia
- Colecciones Concurrentes y Variables Atómicas
- Tareas Asíncronas con CompletableFuture
Módulo 9: Redes
- Introducción a las Redes
- Sockets
- ServerSocket
- DatagramSocket y DatagramPacket
- URL y HttpURLConnection
- El Cliente HTTP Moderno
Módulo 10: Temas Avanzados
- Genéricos
- Anotaciones
- Reflexión
- Características de Java 8: Streams y Optional
- Fechas y Horas con java.time
- Java 9 y Más Allá
- Memoria, Recolección de Basura y Rendimiento
Módulo 11: Frameworks y Librerías de Java
- Introducción a los Frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Pruebas Avanzadas con Mockito
- Librerías Esenciales del Ecosistema
