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 el close() de un escritor vacía el búfer al disco: si no cierras, no has escrito.

Contenido

  1. FileWriter: crear, sobrescribir y añadir
  2. El parámetro append, o cómo perder un fichero entero
  3. Escribir texto con write
  4. PrintWriter: el envoltorio cómodo
  5. El búfer y el vaciado: flush frente a close
  6. Qué pasa si el programa termina sin cerrar
  7. Codificación explícita al escribir
  8. El separador de línea del sistema
  9. Permisos, ficheros de solo lectura y IOException
  10. Escritura atómica: temporal y renombrado
  11. Ficheros de bloqueo y sobrescritura accidental
  12. BiblioTech: ExportadorCatalogo escribe de verdad
  13. Errores Comunes y Consejos
  14. Ejercicios

  1. FileWriter: crear, sobrescribir y añadir

FileWriter 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 el boolean va el último.

  1. El parámetro append, o cómo perder un fichero entero

Este 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:

  1. Nombra la intención. No dejes el true suelto 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
  1. Usa las opciones explícitas de NIO.2 cuando llegues a 07-06. StandardOpenOption.APPEND y StandardOpenOption.TRUNCATE_EXISTING dicen literalmente lo que hacen, y no hay forma de confundirlas con un charset.

  2. 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 FileWriter para "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.

  1. Escribir texto con write

Writer —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.

  1. PrintWriter: el envoltorio cómodo

PrintWriter 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 EUR

Fí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í:

PrintWriter se traga las excepciones. Sus métodos print, println y printf no declaran IOException. 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: PrintWriter sin más, con su comodidad.
  • Para ficheros cuyo contenido importa: PrintWriter con checkError() antes de cerrar, o directamente BufferedWriter (07-04), cuyos métodos sí declaran IOException.

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.

  1. El búfer y el vaciado: flush frente a close

Aquí 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: 29

El 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.

  1. 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 -9 o 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 con kill -9.
  • ¿Y si termina con excepción? Sin try-with-resources, se pierde igual. Con try-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-resources en 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.

  1. 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.

  1. 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:

String salto = System.lineSeparator();     // "\n" o "\r\n" segun el sistema

¿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 : mostraba todo en una línea
Herramientas de línea de comandos de Windows A veces
Otro sistema que espere el formato nativo

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 un write produce 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.

  1. Permisos, ficheros de solo lectura y IOException

Escribir 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.

  1. 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:

  1. El finally compensa 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.
  2. checkError() es obligatorio con PrintWriter. 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.
  3. renameTo es defectuosoboolean mudo, comportamiento distinto en Windows, ventana entre el delete y el renameTo—. En 07-06 se sustituye por Files.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

  1. 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 pase

Limitaciones 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.

  1. BiblioTech: ExportadorCatalogo escribe de verdad

Hora 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:

  1. Ha dejado de ser Closeable. En 06-06 mantenía un BufferedWriter abierto durante toda su vida. Ahora cada exportación abre y cierra dentro de EscrituraAtomica, así que no posee ningún recurso vivo. Un objeto que no tiene nada que cerrar no debe implementar Closeable: sería una promesa vacía que hace que quien lo usa escriba un try-with-resources inútil.
  2. 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 modo append es para crecer. Confundirlos produce, en un sentido, un fichero destruido, y en el otro, un fichero que duplica todo cada vez.
  3. 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.
  4. El checkError() está en los dos sitios. Sin él, PrintWriter no dice nada cuando el disco se llena.
  5. 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 un OutOfMemoryError se 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 true del modo append. 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 constante ANADIR con nombre.
  • Confundir new FileWriter(ruta, true) con new FileWriter(ruta, UTF_8). Se parecen y significan cosas opuestas. Cuando quieras las dos, usa la versión de tres parámetros con el boolean al final.
  • Creer que abrir el fichero es inofensivo. Construir un FileWriter sin append trunca 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-resources siempre.
  • 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; solo sync() llega al plato. Para casi todo, close() basta.
  • Ignorar que PrintWriter se traga las IOException. Un disco lleno pasa desapercibido y renombras un fichero truncado sobre el bueno. checkError() antes de cerrar, o usa BufferedWriter.
  • 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. FileWriter no lo crea; lanza IOException con 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 \n cuando el consumidor espera CRLF, o al revés. println y newLine() usan el del sistema; para ficheros que se versionan, fija \n y documéntalo.
  • Creer que write(65) escribe "65". Escribe la letra A. 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:

  1. Un método escribirLinea(String ruta, String texto, boolean anadir) que escriba una línea con el modo indicado y charset UTF-8.
  2. Un método mostrar(String ruta) que imprima el contenido y el número de líneas.
  3. Un main que: borre el fichero si existe; escriba tres líneas sin append mostrando el estado tras cada una; y repita el experimento con append.
  4. 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:

  1. Cuente las líneas realmente escritas en el temporal.
  2. Antes de renombrar, verifique que el número coincide con lineasEsperadas; si no, lance IOException y no renombre.
  3. Compruebe también que el temporal tiene tamaño mayor que cero.
  4. 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:

  1. registrar(String operacion, String referencia, String idEmpleado) que añada una línea con formato operacion;referencia;idEmpleado en modo append.
  2. Antes de escribir, si el fichero supera TAMANO_MAXIMO (usa 1 KB para poder probarlo), renómbralo a auditoria-1.txt, desplazando los anteriores hasta un máximo de 3, y empieza uno nuevo.
  3. Charset explícito y separador de línea fijo \n (es un fichero de datos, no un informe).
  4. Un fallo de escritura no debe propagarse: se registra en el logger y se continúa, siguiendo la política de ExportadorCatalogo.
  5. Un main que 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: 3

El 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 bytes

Los cuatro puntos didácticos:

  1. 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 renameTo machacaría auditoria-2.txt antes 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.
  2. 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.
  3. El fallo no se propaga y se cuenta. El contador fallos permite 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.
  4. listFiles() puede devolver null. La comprobación no es paranoia: devuelve null si el directorio no existe o si falla el acceso. Es el boolean mudo del apartado 6 de 07-01 en otra forma, y en 07-06 lo resuelve Files.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

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados