Apuntes · Capítulo 15Diseño

Archivos, E/S y persistencia

Archivos, E/S y persistencia

Hasta ahora, todo lo que hacían nuestros programas desaparecía al terminar: las canciones, los productos y las ventas vivían en el heap y se iban con la JVM. Un programa de verdad tiene que recordar entre una ejecución y otra. Esta semana aprendemos a leer y escribir archivos y a guardar el estado de un programa en ellos.

La semana pasada LineImporter leía objetos de una List<String> y dejamos pendiente la pregunta de qué cambia cuando las líneas vienen de un archivo de verdad. La respuesta tiene dos partes. En la sesión A vemos la API de archivos de Java (java.nio.file), los streams de E/S de java.io y el patrón que los organiza, Decorator. En la sesión B guardamos y cargamos una lista de canciones de dos maneras, en CSV y con la serialización de Java, y vemos por qué la segunda no se usa con datos que vienen de fuera. Al final armamos el primer menú de línea de comandos, el punto de partida de P5.

Lectura. Eck, secciones 11.1 I/O Streams, Readers, and Writers (11.1.1, 11.1.2, 11.1.4 y 11.1.6) y 11.2.1 Reading and Writing Files, y las subsecciones 11.3.2 y 11.3.3 sobre datos persistentes. Eck no cubre java.nio.file; para eso, el tutorial The Java I/O API de dev.java, en especial Working with Paths, Reading and Writing Small Files y Decorating I/O Streams. El código del capítulo está en code/week-15.

Ruta de lectura.

  • Antes de clase (obligatorio, 15–20 min): Rutas: Path y Leer y escribir con Files.
  • En clase: en la sesión A, Errores de E/S, Codificación: bytes y caracteres, Streams de java.io y Decorator: agregar capacidades envolviendo; en la sesión B, Persistencia: guardar y cargar el estado, hasta Decorator otra vez: un repositorio que registra, y Serialización de Java.
  • Después de clase: Pruebas de ida y vuelta, Por qué no deserializar datos que no son de confianza, CSV o serialización, Un menú de línea de comandos, el Resumen y los Ejercicios. Para ampliar, las secciones de Eck de la nota Lectura.

Rutas: Path

Un archivo se nombra con una ruta (path). En Java, una ruta es un objeto de tipo Path, y se crea con el static factory method Path.of (semana 14). Un Path es solo un nombre: crearlo no abre ni crea ningún archivo. FilesDemo.java empieza así:

Path dir = Path.of("build", "demo");
Path file = dir.resolve("notas.txt");
IO.println("ruta: " + file + "  nombre: " + file.getFileName() + "  padre: " + file.getParent());
IO.println("¿absoluta? " + file.isAbsolute() + "  ¿existe? " + Files.exists(file));
$ java -cp build FilesDemo
ruta: build/demo/notas.txt  nombre: notas.txt  padre: build/demo
¿absoluta? false  ¿existe? false
...

Path.of("build", "demo") arma la ruta con el separador del sistema (/ en Linux y macOS, \ en Windows), así que el programa no tiene que saber dónde corre. resolve agrega un nombre al final.

Una ruta absoluta empieza en la raíz del sistema de archivos (/work/code/week-15/build/demo/notas.txt). Una ruta relativa, como build/demo/notas.txt, se interpreta desde el directorio de trabajo: la carpeta desde la que se lanzó java. Dentro del contenedor del curso, si ejecutan make test en code/week-15, el directorio de trabajo es /work/code/week-15. El mismo programa lanzado desde otra carpeta buscaría otro archivo.

Error común. Probar un programa desde el editor, que lo lanza en la carpeta del proyecto, y después desde la terminal en otra carpeta, y sorprenderse de que “el archivo desapareció”. Con rutas relativas, el archivo depende de dónde se ejecuta el programa. Cuando importa, impriman file.toAbsolutePath() para ver cuál es.

Leer y escribir con Files

La clase Files tiene métodos estáticos para casi todo lo que se hace con archivos. Para archivos pequeños, los que caben cómodamente en memoria, se lee o se escribe todo de una vez:

Método Qué hace
Files.readString(path) Todo el archivo en un String
Files.readAllLines(path) Todo el archivo en una List<String>, una línea por elemento
Files.writeString(path, texto) Escribe un String (crea el archivo o lo reemplaza)
Files.write(path, líneas) Escribe una lista de líneas, cada una con su salto de línea
Files.exists(path), Files.size(path) Preguntas sobre el archivo
Files.createDirectories(path) Crea la carpeta y las que falten en el camino
Files.createDirectories(dir);
Files.write(file, List.of("Lima, 18 °C", "Cusco, 9 °C", "Iquitos, 31 °C"));
List<String> lines = Files.readAllLines(file);
IO.println("leídas " + lines.size() + " líneas; la segunda es \"" + lines.get(1) + "\"");
Files.writeString(file, "Puno, 4 °C\n", StandardOpenOption.APPEND);
IO.print(Files.readString(file));
leídas 3 líneas; la segunda es "Cusco, 9 °C"
Lima, 18 °C
Cusco, 9 °C
Iquitos, 31 °C
Puno, 4 °C

Sin opciones, write y writeString reemplazan el contenido anterior; con StandardOpenOption.APPEND, agregan al final.

Para archivos grandes, cargar todo en memoria no es buena idea. Se lee línea por línea con un BufferedReader, o con Files.lines, que devuelve un stream (semana 12) de líneas. Los dos mantienen el archivo abierto mientras se usan, así que van dentro de un try-with-resources (semana 9), que lo cierra pase lo que pase:

try (BufferedReader in = Files.newBufferedReader(file)) {
    String line;
    int n = 0;
    while ((line = in.readLine()) != null) {
        if (line.contains("°")) {
            n++;
        }
    }
    IO.println("con newBufferedReader: " + n + " líneas");
}
try (Stream<String> stream = Files.lines(file)) {
    IO.println("con Files.lines: " + stream.filter(l -> l.startsWith("C")).toList());
}
con newBufferedReader: 4 líneas
con Files.lines: [Cusco, 9 °C]

readLine() devuelve null cuando se acaba el archivo; es la forma tradicional del bucle de lectura.

Errores de E/S

Casi todo método de Files declara throws IOException, una excepción checked (semana 9): el compilador obliga a capturarla o a declararla. Es justo: un disco lleno, un archivo borrado o un permiso que falta son cosas que pasan aunque el programa esté bien escrito. Las subclases más comunes dicen qué pasó:

NoSuchFileException: build/demo/no-existe.txt
Excepción Cuándo
NoSuchFileException El archivo o una carpeta del camino no existe
AccessDeniedException No hay permiso para leer o escribir
FileAlreadyExistsException Se pidió crear algo que ya existe
MalformedInputException Los bytes no corresponden a la codificación esperada (la vemos abajo)

¿Dónde se capturan? En el borde del programa: en el main, en el menú, en la capa que habla con el usuario y puede decirle “no encontré el archivo, ¿quiere empezar con una lista vacía?”. Las clases del modelo (Song, Playlist) no leen archivos, y el código que sí lee (el repositorio de la sesión B) declara throws IOException y deja que el error suba. Capturar una IOException en lo profundo e imprimir un mensaje deja al resto del programa creyendo que todo salió bien.

Codificación: bytes y caracteres

Un archivo no guarda letras: guarda bytes. Para leer texto hay que saber cómo se convirtieron las letras en bytes: la codificación (charset). Las dos que más van a encontrar:

  • UTF-8, la de la web y de casi todo hoy: las letras del inglés ocupan un byte, y ñ, é o ° ocupan dos.
  • ISO-8859-1 (Latin-1) y su pariente Windows-1252: cada letra ocupa exactamente un byte, pero solo hay 256 posibles. Todavía aparece en archivos exportados por programas viejos o por Excel en Windows.

El mismo texto da bytes distintos según la codificación, y leerlo con la equivocada da basura o un error. El final de FilesDemo lo muestra con "Piña, café":

Files.writeString(latin, "Piña, café", StandardCharsets.ISO_8859_1);
IO.println("bytes en Latin-1: " + Files.size(latin));
IO.println("bytes en UTF-8:   " + "Piña, café".getBytes(StandardCharsets.UTF_8).length);
try {
    Files.readString(latin);                    // Files usa UTF-8 por defecto
} catch (IOException e) {
    IO.println("leído como UTF-8: " + e);
}
IO.println("leído como Latin-1: " + Files.readString(latin, StandardCharsets.ISO_8859_1));
Files.writeString(utf8, "Piña, café");
IO.println("UTF-8 leído como Latin-1: " + Files.readString(utf8, StandardCharsets.ISO_8859_1));
bytes en Latin-1: 10
bytes en UTF-8:   12
leído como UTF-8: java.nio.charset.MalformedInputException: Input length = 4
leído como Latin-1: Piña, café
UTF-8 leído como Latin-1: Piña, café
Arriba, la palabra Piña con cada carácter en una caja. En UTF-8 se codifica en cinco bytes: 50, 69, C3 B1 (dos bytes para la ñ) y 61. En Latin-1, en cuatro: 50, 69, F1 y 61. Abajo, los cinco bytes UTF-8 leídos como Latin-1, un byte por carácter: C3 se lee como à y B1 como ±, así que sale PiñaArriba, la palabra Piña con cada carácter en una caja. En UTF-8 se codifica en cinco bytes: 50, 69, C3 B1 (dos bytes para la ñ) y 61. En Latin-1, en cuatro: 50, 69, F1 y 61. Abajo, los cinco bytes UTF-8 leídos como Latin-1, un byte por carácter: C3 se lee como à y B1 como ±, así que sale Piña
La misma palabra, dos codificaciones. Leer bytes UTF-8 como si fueran Latin-1 parte la ñ en dos caracteres; leer Latin-1 como UTF-8 da un error.

La última línea es la basura típica: la ñ en UTF-8 son dos bytes, C3 B1, y Latin-1 los lee como dos caracteres, à y ±. Si alguna vez ven é en vez de é, ya saben qué pasó.

En Java. Hay dos reglas distintas. Desde Java 18 (JEP 400), la codificación por defecto es UTF-8 en todos los sistemas; la usan las API que no reciben una codificación, como FileReader, FileWriter o InputStreamReader. Antes, esa codificación dependía del sistema operativo, y un programa que funcionaba en Linux rompía las tildes en Windows. La otra regla es más antigua: los métodos de texto de Files que usamos aquí (readString, readAllLines, newBufferedReader, writeString…) ya especificaban UTF-8 cuando se omite la codificación, desde que existen, sin consultar la del sistema. Igual conviene escribir StandardCharsets.UTF_8 cuando el archivo se comparte con otros programas: deja la decisión a la vista y nadie tiene que recordar qué regla aplica a cada método.

Streams de java.io

Files es la API moderna (del paquete java.nio.file, de Java 7). Por debajo, y en todo el código anterior, están los streams de E/S del paquete java.io. (No son los streams de la semana 12: es el mismo nombre para dos ideas distintas.) Un stream de E/S es una secuencia de datos que se lee o se escribe en orden, de a poco. Hay cuatro clases abstractas base:

Bytes Caracteres
Entrada InputStream Reader
Salida OutputStream Writer

Lo interesante es cómo se combinan. Cada clase concreta agrega una capacidad, y para tener varias se envuelve un objeto dentro de otro. Al leer un archivo de texto línea por línea:

try (InputStream bytes = Files.newInputStream(file);
        InputStreamReader chars = new InputStreamReader(bytes, StandardCharsets.UTF_8);
        BufferedReader lines = new BufferedReader(chars)) {
    String line;
    while ((line = lines.readLine()) != null) {
        IO.println("> " + line);
    }
}
  • El InputStream lee bytes del archivo.
  • El InputStreamReader envuelve al anterior y convierte bytes en caracteres con la codificación indicada.
  • El BufferedReader envuelve al anterior, lee en bloques grandes (un buffer, para no ir al disco por cada carácter) y ofrece readLine().

Los tres son objetos distintos, encadenados: cuando el programa pide una línea, el BufferedReader le pide caracteres al InputStreamReader, que le pide bytes al InputStream. Es lo que hace por dentro Files.newBufferedReader(file). Y es un patrón con nombre.

Decorator: agregar capacidades envolviendo

El problema: una subclase por cada combinación

Supongan que java.io hubiera resuelto lo anterior con herencia: una clase para leer bytes de un archivo, una subclase que además decodifica UTF-8, otra que además tiene buffer, otra que además cuenta líneas… Y lo mismo para leer de la red, de un arreglo de bytes en memoria o de un archivo comprimido. Con cuatro orígenes y tres capacidades opcionales harían falta decenas de clases, una por combinación, y cada capacidad nueva duplicaría el número.

La solución: un objeto que envuelve a otro del mismo tipo

Decorator agrega comportamiento a un objeto envolviéndolo en otro que implementa la misma interfaz. El decorator recibe cada llamada, hace lo suyo (antes o después) y la delega al objeto envuelto. Como el decorator también es un Writer (o un Reader, o lo que sea), se puede envolver a su vez, y las capacidades se combinan en tiempo de ejecución, en el orden que se quiera.

CountingWriter.java es un decorator propio: un Writer que cuenta las líneas que pasan por él. Extiende FilterWriter, la clase de java.io pensada para escribir decorators: ya guarda el Writer envuelto (en el campo out) y delega todos los métodos en él. Nosotros hacemos override solo de los que necesitamos:

public class CountingWriter extends FilterWriter {
    private int lines = 0;

    public CountingWriter(Writer inner) {
        super(inner);
    }

    @Override
    public void write(String str, int off, int len) throws IOException {
        for (int i = off; i < off + len; i++) {
            if (str.charAt(i) == '\n') {
                lines++;
            }
        }
        super.write(str, off, len);              // delega en el Writer envuelto
    }

    // ... lo mismo para write(int) y write(char[], int, int)

    public int lines() {
        return lines;
    }
}
Diagrama de clases de Decorator en java.io. La clase abstracta Writer tiene write(...), flush() y close(). La extienden BufferedWriter, PrintWriter y FilterWriter; FilterWriter guarda un campo out de tipo Writer, que es el objeto envuelto. CountingWriter extiende FilterWriter y agrega lines(). Una flecha de FilterWriter a Writer marcada envuelve cierra el ciclo: el decorator es un Writer y contiene un WriterDiagrama de clases de Decorator en java.io. La clase abstracta Writer tiene write(...), flush() y close(). La extienden BufferedWriter, PrintWriter y FilterWriter; FilterWriter guarda un campo out de tipo Writer, que es el objeto envuelto. CountingWriter extiende FilterWriter y agrega lines(). Una flecha de FilterWriter a Writer marcada envuelve cierra el ciclo: el decorator es un Writer y contiene un Writer
Decorator: el decorator es del mismo tipo que lo que envuelve. Por eso se puede envolver a sí mismo, en cualquier orden y cuantas veces haga falta.

DecoratorDemo.java arma una cadena de tres capas para escribir un informe: un PrintWriter (que ofrece println y printf) sobre nuestro CountingWriter sobre el BufferedWriter que devuelve Files:

CountingWriter counter = new CountingWriter(Files.newBufferedWriter(file));
try (PrintWriter out = new PrintWriter(counter)) {
    out.println("Informe del día");
    out.printf("%-10s %5d%n", "Lima", 18);
    out.printf("%-10s %5d%n", "Cusco", 9);
}
IO.println("líneas escritas: " + counter.lines());
$ java -cp build DecoratorDemo
líneas escritas: 3
...
> Informe del día
> Lima          18
> Cusco          9
Animación: tres cajas encadenadas de izquierda a derecha, PrintWriter, CountingWriter y BufferedWriter, y a la derecha el archivo informe.txt. Paso 1: out.println("Informe del día") entra al PrintWriter. Paso 2: el texto pasa al CountingWriter, cuyo contador sube a 1. Paso 3: llega al BufferedWriter y se queda en su buffer; el archivo sigue vacío. Paso 4: después de dos líneas más, el contador está en 3 y el buffer tiene las tres líneas. Paso 5: close() recorre la cadena, el buffer se vacía y el archivo recibe las tres líneasAnimación: tres cajas encadenadas de izquierda a derecha, PrintWriter, CountingWriter y BufferedWriter, y a la derecha el archivo informe.txt. Paso 1: out.println("Informe del día") entra al PrintWriter. Paso 2: el texto pasa al CountingWriter, cuyo contador sube a 1. Paso 3: llega al BufferedWriter y se queda en su buffer; el archivo sigue vacío. Paso 4: después de dos líneas más, el contador está en 3 y el buffer tiene las tres líneas. Paso 5: close() recorre la cadena, el buffer se vacía y el archivo recibe las tres líneas
Cada capa hace lo suyo y le pasa el texto a la siguiente. El BufferedWriter guarda lo escrito en memoria y recién lo manda al archivo al vaciar el buffer: con close() o flush().

Fíjense en el try: se cierra solo el PrintWriter, el de afuera. Cerrar un decorator cierra lo que envuelve, así que un solo close recorre toda la cadena. Si el archivo nunca se cierra, pasa lo que muestra la animación:

Error común. Olvidar cerrar un archivo, o leerlo antes de cerrarlo. Un BufferedWriter guarda lo escrito en memoria y lo manda al disco cuando su buffer se llena o cuando se cierra. Si el programa termina sin close, el archivo queda vacío o incompleto. La demostración lo mide:

antes de close: 0 bytes
después de close: 35 bytes

La solución es la de siempre: try-with-resources.

Decorator o herencia

Con herencia, CountingBufferedWriter sería una clase y CountingPrintWriter otra. Con Decorator, CountingWriter funciona con cualquier Writer, también con los que se escriban después, y se combina con los demás en el orden que se quiera. Es la misma idea que Strategy (semana 14) y que la composición de la semana 7: tener un objeto y delegarle trabajo, en lugar de ser una subclase suya. La diferencia con Strategy es la intención: una strategy es una pieza que el context usa; un decorator se hace pasar por el objeto que envuelve, y quien lo usa no nota que está ahí.

Cuándo no usar Decorator

  • Si la capacidad es para un solo caso, un método en la clase o un objeto auxiliar es más claro que un decorator.
  • Si la interfaz es grande, el decorator tiene que delegar cada método, aunque no le interese (por eso existe FilterWriter, que lo hace por nosotros). Con interfaces de muchos métodos, los decorators se vuelven largos y fáciles de equivocar.
  • Si el orden de las capas importa y nadie lo controla. Un decorator que cifra y otro que comprime dan resultados distintos según quién envuelve a quién. Cuando el orden es parte de la corrección, conviene que lo arme un solo lugar del código (una factory), no cada cliente.
  • Para depurar, una cadena larga de decorators hace que el stack trace pase por muchas clases antes de llegar a la que hace el trabajo.

Persistencia: guardar y cargar el estado

Dónde va el código que guarda

Persistir es traducir objetos a un formato que sobreviva al programa (un archivo de texto, bytes, una base de datos) y, al cargar, traducir de vuelta. La pregunta de diseño es dónde poner esa traducción.

La tentación es agregarle a Song un método toCsvLine() y un constructor que reciba una línea. Funciona, pero Song tendría dos razones para cambiar: las reglas de una canción y el formato del archivo. Es la responsabilidad única de la semana 13. La solución habitual es una clase aparte, un repositorio (repository), detrás de una interfaz (SongRepository.java):

public interface SongRepository {
    List<Song> load() throws IOException;

    void save(List<Song> songs) throws IOException;
}

El resto del programa depende de la interfaz (inversión de dependencias): no sabe si las canciones están en CSV, en un archivo binario o en memoria. Y en las pruebas se reemplaza por un fake: un repositorio que guarda todo en memoria.

CSV a mano

CSV (comma-separated values) es el formato de texto más simple para tablas: una línea por objeto, campos separados por comas y, normalmente, una primera línea de encabezado con el nombre de cada columna. Se abre en una hoja de cálculo y se lee a simple vista.

Escribir CSV parece trivial: String.join(",", campos). Leerlo parece trivial: line.split(","). Hasta que una canción se llama Lima, de noche:

Lima, de noche,Los Ensayos,205

split(",") da cuatro campos, no tres. No hay una solución única: hay que decidir una regla, documentarla y probarla. La nuestra (Csv.java) es la más común, la del estándar RFC 4180:

  • Un campo que tiene una coma o comillas va entre comillas.
  • Las comillas dentro de un campo se escriben dobles: "".
  • No se permiten saltos de línea dentro de un campo (el estándar sí los permite; nosotros los rechazamos para que una línea del archivo sea siempre un registro).
public static String join(List<String> fields) {
    List<String> quoted = new ArrayList<>();
    for (String f : fields) {
        if (f.contains("\n") || f.contains("\r")) {
            throw new IllegalArgumentException("salto de línea en un campo: " + f);
        }
        if (f.contains(",") || f.contains("\"")) {
            quoted.add("\"" + f.replace("\"", "\"\"") + "\"");
        } else {
            quoted.add(f);
        }
    }
    return String.join(",", quoted);
}

Csv.split hace lo contrario: recorre la línea carácter por carácter recordando si está dentro de comillas, y solo corta en las comas que están afuera. Las pruebas de CsvTest.java fijan los casos difíciles: comas, comillas, campos vacíos ("a,," son tres campos), saltos de línea y comillas sin cerrar.

CsvSongRepository.java usa esas dos funciones. Guardar es armar las líneas y escribirlas de una vez:

@Override
public void save(List<Song> songs) throws IOException {
    List<String> lines = new ArrayList<>();
    lines.add(HEADER);                                   // "title,artist,seconds"
    for (Song s : songs) {
        lines.add(Csv.join(List.of(s.title(), s.artist(), String.valueOf(s.seconds()))));
    }
    Files.write(file, lines, StandardCharsets.UTF_8);
}

Cargar es leer las líneas, verificar el encabezado y convertir cada una, recordando su número para poder informarlo:

@Override
public List<Song> load() throws IOException {
    List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);
    if (lines.isEmpty() || !lines.get(0).equals(HEADER)) {
        throw new MalformedCsvException(1, "se esperaba el encabezado " + HEADER);
    }
    List<Song> songs = new ArrayList<>();
    for (int i = 1; i < lines.size(); i++) {
        if (!lines.get(i).isBlank()) {
            songs.add(parse(lines.get(i), i + 1));
        }
    }
    return songs;
}

private static Song parse(String line, int number) throws MalformedCsvException {
    try {
        List<String> f = Csv.split(line);
        if (f.size() != 3) {
            throw new MalformedCsvException(number, "se esperaban 3 campos y hay " + f.size());
        }
        return new Song(f.get(0), f.get(1), Integer.parseInt(f.get(2).strip()));
    } catch (NumberFormatException e) {
        throw new MalformedCsvException(number, "la duración no es un número");
    } catch (IllegalArgumentException e) {
        throw new MalformedCsvException(number, e.getMessage());
    }
}

El orden de los catch importa: NumberFormatException es subclase de IllegalArgumentException, así que va primero (semana 9). El segundo catch recoge lo que rechazan Csv.split (comillas sin cerrar) y el constructor de Song (una duración negativa): las reglas de la canción siguen en Song, y el repositorio solo les agrega el número de línea.

Arriba, una List<Song> con tres canciones en el heap. Una flecha save() baja a un archivo canciones.csv con el encabezado title,artist,seconds y tres líneas; la del medio tiene comillas dobles alrededor de Arturo ""Zambo"" Cavero, y la última tiene "Lima, de noche" entre comillas. Otra flecha load() sube a una segunda List<Song>. Entre las dos listas, una marca equals: trueArriba, una List<Song> con tres canciones en el heap. Una flecha save() baja a un archivo canciones.csv con el encabezado title,artist,seconds y tres líneas; la del medio tiene comillas dobles alrededor de Arturo ""Zambo"" Cavero, y la última tiene "Lima, de noche" entre comillas. Otra flecha load() sube a una segunda List<Song>. Entre las dos listas, una marca equals: true
Ida y vuelta: guardar y volver a cargar tiene que dar una lista igual (equals) a la original. Las comillas aparecen solo en el archivo; los objetos no las tienen.

Ejemplo resuelto: guardar, mirar el archivo, cargar

RepositoryDemo.java guarda tres canciones elegidas para probar la regla (una con comillas en el artista y otra con una coma en el título), muestra el archivo y lo vuelve a cargar:

List<Song> songs = List.of(
        new Song("Contigo Perú", "Arturo \"Zambo\" Cavero", 254),
        new Song("Cariñito", "Los Hijos del Sol", 187),
        new Song("Lima, de noche", "Los Ensayos", 205));
repo.save(songs);
IO.print(Files.readString(file));

List<Song> back = repo.load();
IO.println("¿iguales? " + back.equals(songs));
$ java -cp build RepositoryDemo
[log] save: 3 canciones
title,artist,seconds
Contigo Perú,"Arturo ""Zambo"" Cavero",254
Cariñito,Los Hijos del Sol,187
"Lima, de noche",Los Ensayos,205
[log] load: 3 canciones
¿iguales? true
[log] load falló: línea 3: la duración no es un número
no se pudo cargar: línea 3: la duración no es un número (e.line() = 3)

Las líneas [log] las imprime un decorator; lo vemos en un momento. Las dos últimas salen de cargar otro archivo, malo.csv, cuya tercera línea dice Huayno,Anónimo,tres.

Una línea mal formada

Un archivo puede leerse bien y aun así no entenderse: una duración que dice tres, una línea con cuatro campos. Hay dos políticas razonables:

  1. Rechazar el archivo entero con una excepción que diga en qué línea está el problema, para que una persona lo corrija.
  2. Saltar la línea y seguir, informando al final qué líneas se saltaron.

La primera es más segura (no se pierden datos sin que nadie se entere) y es la que usamos. La segunda tiene sentido para importar datos de otros, donde una línea mala no debería detener todo. Lo que no es razonable es no decidir: que el programa se caiga con una NumberFormatException sin contexto, o que salte la línea sin avisar.

Nuestra excepción es MalformedCsvException, con el número de línea como campo. Extiende IOException: para quien llama a load(), “no pude leer el archivo” y “lo leí pero no lo entiendo” son dos formas de que la carga fallara, y las dos se manejan en el mismo lugar. Quien quiera distinguirlas puede capturar MalformedCsvException antes que IOException.

Error común. Leer números con el formato de la configuración regional. En el Perú se escribe 3,50 y en el código 3.50. Si un CSV guarda precios con coma decimal, la coma del número se confunde con la del separador. Regla del curso: los archivos guardan los números con punto (o, mejor, en céntimos, como enteros), y el formato con coma es solo para mostrarlos en pantalla.

Decorator otra vez: un repositorio que registra

Los [log] de la salida los escribe LoggingSongRepository.java, un decorator de SongRepository: implementa la misma interfaz, guarda otro repositorio y le delega todo, registrando cada operación:

public class LoggingSongRepository implements SongRepository {
    private final SongRepository inner;
    private final Consumer<String> log;

    public LoggingSongRepository(SongRepository inner, Consumer<String> log) {
        this.inner = inner;
        this.log = log;
    }

    @Override
    public List<Song> load() throws IOException {
        try {
            List<Song> songs = inner.load();
            log.accept("load: " + songs.size() + " canciones");
            return songs;
        } catch (IOException e) {
            log.accept("load falló: " + e.getMessage());
            throw e;
        }
    }

    @Override
    public void save(List<Song> songs) throws IOException {
        inner.save(songs);
        log.accept("save: " + songs.size() + " canciones");
    }
}

CsvSongRepository no sabe que la están registrando, y el registro funciona igual con cualquier otro repositorio. El destino del registro es un Consumer<String> (semana 12): en la demostración imprime; en las pruebas agrega a una lista. Las pruebas de LoggingSongRepositoryTest.java usan un fake, un repositorio en memoria (semana 7), para comprobar que el decorator delega, registra, relanza los errores y se puede apilar.

Pruebas de ida y vuelta

La prueba más importante de cualquier persistencia es la de ida y vuelta (round trip): guardar, cargar y comparar con lo original. Para eso, Song (un record) necesita un equals que compare campos, como vimos en la semana 5; con el equals de Object, ninguna canción cargada sería igual a la original.

Las pruebas no deben tocar archivos reales. JUnit crea una carpeta temporal nueva para cada prueba con @TempDir, y la borra al terminar (CsvSongRepositoryTest.java):

class CsvSongRepositoryTest {
    @TempDir
    Path dir;

    @Test
    void roundTripKeepsEverySong() throws IOException {
        List<Song> songs = List.of(
                new Song("Contigo Perú", "Arturo \"Zambo\" Cavero", 254),
                new Song("Lima, de noche", "Los Ensayos", 205),
                new Song("Ñaña", "Piña y Café", 99));
        SongRepository repo = new CsvSongRepository(dir.resolve("s.csv"));
        repo.save(songs);
        assertEquals(songs, repo.load());
    }

    @Test
    void badNumberReportsTheLine() throws IOException {
        Path file = dir.resolve("malo.csv");
        Files.writeString(file, "title,artist,seconds\nA,B,100\nC,D,tres\n");
        MalformedCsvException e = assertThrows(MalformedCsvException.class,
                () -> new CsvSongRepository(file).load());
        assertEquals(3, e.line());
    }
}

El archivo tiene pruebas para el archivo inexistente, el encabezado que falta, el número de campos equivocado y una canción inválida. Los casos de error están ahí porque son los que van a aparecer cuando un usuario edite el archivo a mano.

Serialización de Java

Java trae una forma de persistir que no requiere escribir ningún formato: la serialización. Una clase que implementa la interfaz Serializable (que no tiene métodos: solo marca la clase) se puede convertir en bytes con un ObjectOutputStream y reconstruir con un ObjectInputStream, dos decorators más de java.io. Se guarda el objeto con todo lo que alcanza: sus campos, los objetos a los que apuntan esos campos, y así.

Playlist.java:

public class Playlist implements Serializable {
    private static final long serialVersionUID = 1L;

    private final String name;
    private final ArrayList<Song> songs = new ArrayList<>();
    private transient int totalSeconds;      // se recalcula; no se guarda
    // ...
}
  • serialVersionUID es el número de versión de la clase. Al leer, la JVM compara el del archivo con el de la clase; si no coinciden, lanza InvalidClassException. Si no se declara, la JVM calcula uno a partir de los campos y métodos, y cualquier cambio en la clase (un método nuevo) vuelve ilegibles los archivos viejos. Por eso se declara a mano, y javac -Xlint:all avisa si falta.
  • transient marca un campo que no se guarda. Al cargar, queda en su valor por defecto (0, null).
  • Song es un record y también implementa Serializable; ArrayList y String ya lo hacen.

SerializationDemo.java guarda una lista y la vuelve a cargar:

try (ObjectOutputStream out = new ObjectOutputStream(Files.newOutputStream(file))) {
    out.writeObject(list);
}
try (ObjectInputStream in = new ObjectInputStream(Files.newInputStream(file))) {
    Playlist back = (Playlist) in.readObject();
    // ...
}
$ java -cp build SerializationDemo
antes:   2 canciones, 441 s
archivo: 290 bytes, empieza con ac ed 00 05
después: 2 canciones, 0 s (totalSeconds es transient)
¿mismas canciones? true
...

Los bytes ac ed 00 05 son la marca de todo archivo de serialización de Java. El total quedó en 0: era transient. Si el programa lo necesita, tendrá que recalcularlo después de cargar.

Es cómodo: dos líneas para guardar un grafo de objetos completo. Pero tiene tres problemas serios:

  • Es frágil ante cambios. Renombrar un campo, cambiar su tipo o mover la clase de paquete puede dejar ilegibles los archivos guardados.
  • No se lee. Es binario: no se puede revisar ni corregir a mano, y ningún otro lenguaje lo entiende.
  • Es inseguro con datos de fuera. Este es el más grave.

Por qué no deserializar datos que no son de confianza

Al deserializar, la JVM crea los objetos sin llamar a sus constructores (salvo en los records, como veremos). Los valores de los campos salen de los bytes del archivo, y quien escribió esos bytes decide qué valores tienen. La validación del constructor, y con ella el invariante que tanto cuidamos desde la semana 3, no se ejecuta.

La demostración lo comprueba. Serializa una canción de 254 segundos, busca en los bytes el entero 254 (00 00 00 fe), lo cambia por −5 (ff ff ff fb) y deserializa. Lo hace con dos versiones de la canción: PlainSong, una clase común que valida en su constructor, y Song, un record que valida en su constructor compacto:

clase común: PlainSong[Contigo Perú, -5]
record:      java.io.InvalidObjectException: duración inválida: -5
A la izquierda, los bytes de una canción serializada con el entero 00 00 00 fe, que alguien cambia por ff ff ff fb. Dos caminos salen de ahí. Arriba, con la clase común PlainSong, ObjectInputStream crea el objeto sin pasar por el constructor y llena los campos desde los bytes: sale un PlainSong con seconds = -5, con el invariante roto. Abajo, con el record Song, la deserialización llama al constructor canónico, que valida y lanza la excepción: sale InvalidObjectException y no se crea ningún objetoA la izquierda, los bytes de una canción serializada con el entero 00 00 00 fe, que alguien cambia por ff ff ff fb. Dos caminos salen de ahí. Arriba, con la clase común PlainSong, ObjectInputStream crea el objeto sin pasar por el constructor y llena los campos desde los bytes: sale un PlainSong con seconds = -5, con el invariante roto. Abajo, con el record Song, la deserialización llama al constructor canónico, que valida y lanza la excepción: sale InvalidObjectException y no se crea ningún objeto
Los mismos bytes alterados. Una clase común se reconstruye sin constructor y el invariante se rompe en silencio; un record pasa por su constructor canónico, y la validación lo detiene.

La clase común quedó con una duración imposible, sin ningún error. El record no: la especificación de la serialización dice que un record se reconstruye llamando a su constructor canónico, así que su validación se ejecuta. Es una buena razón para preferir records en lo que se serializa.

Y una duración negativa es lo de menos. Un archivo de serialización puede nombrar cualquier clase serializable que el programa tenga a mano, y al reconstruirla se ejecuta código de esas clases. En programas grandes, con muchas bibliotecas, se han encontrado combinaciones de clases que permiten a un atacante ejecutar código propio con solo enviar un archivo. Por eso la guía de seguridad de Oracle (Secure Coding Guidelines for Java SE, sección 8) recomienda evitar deserializar datos que no sean de confianza.

En Java. Cuando no hay más remedio que deserializar, un filtro (ObjectInputFilter, desde Java 9) limita qué clases se aceptan. El filtro "Song;Playlist;java.util.ArrayList;!*" acepta esas tres clases y rechaza todas las demás (!*); un archivo que nombra otra clase falla con InvalidClassException antes de crear nada. La prueba filterRejectsClassesThatAreNotAllowed de SerializationTest.java lo comprueba con un PlainSong.

CSV o serialización

CSV Serialización de Java
Código que hay que escribir El formato, a mano Casi nada
Se lee y se edita a mano Sí No (binario)
Otros programas lo entienden Sí (hojas de cálculo, Python…) Solo Java
Si la clase cambia Se adapta el lector Puede quedar ilegible
Con datos de fuera Se valida cada campo Peligroso
Grafos con referencias compartidas Difícil Los guarda tal cual

Para el proyecto, y para casi todo lo que un usuario pueda tocar, CSV (o un formato de texto parecido, como JSON) es la mejor opción. La serialización queda para casos internos, como una caché que el mismo programa escribe y lee.

Un menú de línea de comandos

P5 necesita una interfaz para que una persona use el programa. La más simple es un menú de línea de comandos: el programa espera una línea, la interpreta como un comando, lo ejecuta y vuelve a esperar. Se lee con un Scanner (Eck, subsec. 2.4.6) y se despacha con un switch (Eck, subsec. 3.6.2). PlaylistCli.java:

public void run() {
    load();
    out.println("comandos: listar, agregar título;artista;segundos, guardar, salir");
    while (true) {
        out.print("> ");
        if (!in.hasNextLine()) {
            break;                          // se acabó la entrada
        }
        String line = in.nextLine().strip();
        String command = line.contains(" ") ? line.substring(0, line.indexOf(' ')) : line;
        String rest = line.substring(command.length()).strip();
        switch (command) {
            case "listar" -> list();
            case "agregar" -> add(rest);
            case "guardar" -> save();
            case "salir" -> {
                out.println("hasta luego");
                return;
            }
            case "" -> { }
            default -> out.println("comando desconocido: " + command);
        }
    }
}

Tres decisiones de diseño, pensando en las pruebas:

  • La entrada, la salida y el repositorio llegan por el constructor: new PlaylistCli(new Scanner(System.in), System.out, new CsvSongRepository(file)). En las pruebas se pasa un Scanner sobre un String, un PrintStream sobre un arreglo de bytes y un fake del repositorio. Es la inversión de dependencias de la semana 13, otra vez.
  • El fin de la entrada termina el bucle. hasNextLine() devuelve false cuando no hay más líneas (el usuario apretó Ctrl+D, o la entrada venía de un archivo que se acabó). Sin esa pregunta, nextLine() lanzaría NoSuchElementException.
  • Un comando mal escrito no detiene el programa. Los errores de cada comando se capturan dentro del comando y se informan; el menú sigue.

El archivo se recibe como argumento de línea de comandos, en args[0] (Eck, subsec. 4.3.6). make test le pasa una copia del CSV de la demostración y le da como entrada el archivo menu-input.txt, como si alguien escribiera esas líneas:

$ java -cp build PlaylistCli build/demo/menu.csv < menu-input.txt
cargadas 3 canciones
comandos: listar, agregar título;artista;segundos, guardar, salir
> 1. Contigo Perú - Arturo "Zambo" Cavero (254 s)
2. Cariñito - Los Hijos del Sol (187 s)
3. Lima, de noche - Los Ensayos (205 s)
> agregada: Valicha
> no se agregó: la duración no es un número
> comando desconocido: borrar
> 1. Contigo Perú - Arturo "Zambo" Cavero (254 s)
...
4. Valicha - Miguel Ángel Hurtado (192 s)
> guardadas 4 canciones
> hasta luego

Los comandos no aparecen en la salida porque vienen de un archivo y no del teclado; cada > es el momento en que el programa esperó una línea.

Error común. Poner la lógica del programa dentro de los case del menú: validar la canción, calcular, guardar. Así no se puede probar sin simular un usuario, y no se puede reutilizar cuando aparezca otra interfaz. Cada case debería ser una llamada corta a un método del modelo o del repositorio. La semana 16 lleva esta idea más lejos.

El código de la semana y make test

$ cd code/week-15
$ make test
...
== FilesDemo
== DecoratorDemo
== RepositoryDemo
== SerializationDemo
== PlaylistCli (entrada desde menu-input.txt)
...
├─ JUnit Jupiter ✔
│  ├─ LoggingSongRepositoryTest ✔
│  ├─ SerializationTest ✔
│  ├─ CsvSongRepositoryTest ✔
│  ├─ PlaylistCliTest ✔
│  └─ CsvTest ✔
...
[        23 tests successful      ]
[         0 tests failed          ]

Las demostraciones escriben sus archivos en build/demo/; make clean los borra junto con lo compilado.

Resumen

  • Un Path es un nombre de archivo, relativo al directorio de trabajo o absoluto. Files lee y escribe: todo de una vez (readAllLines, writeString) para archivos pequeños; línea por línea (newBufferedReader, Files.lines) y con try-with-resources para los grandes.
  • IOException es checked. Se captura en el borde del programa, donde se puede hablar con el usuario, no en el modelo.
  • Un archivo guarda bytes; la codificación los convierte en caracteres. Desde Java 18 la codificación por defecto es UTF-8 (JEP 400); los métodos de texto de Files ya usaban UTF-8 sin que se la indiquen. Los archivos de otros programas pueden venir en Latin-1 o Windows-1252.
  • Los streams de java.io (InputStream, Reader, OutputStream, Writer) se envuelven unos a otros, y cada capa agrega una capacidad: el patrón Decorator. Un decorator implementa la misma interfaz que lo que envuelve y le delega el trabajo; se combinan en tiempo de ejecución sin una subclase por combinación.
  • Cerrar un decorator cierra la cadena. Sin close, lo que está en un buffer no llega al archivo.
  • Persistir es traducir objetos a un formato y de vuelta. Esa traducción va en un repositorio detrás de una interfaz, no en las clases del dominio.
  • En CSV hay que decidir la regla para comas, comillas y saltos de línea, y qué hacer con una línea mal formada; y probarlo. La prueba principal es la de ida y vuelta, en una carpeta temporal (@TempDir).
  • La serialización guarda grafos de objetos con muy poco código (Serializable, serialVersionUID, transient), pero es frágil, binaria e insegura con datos de fuera: se crean objetos sin pasar por el constructor. Los records sí pasan por su constructor canónico.
  • Un menú con Scanner y switch es un bucle: leer, despachar, repetir. Recibe la entrada y la salida por el constructor para poder probarlo.

Ejercicios

  1. Expliquen la diferencia entre Path.of("datos.csv") y Path.of("/work/datos.csv"). Escriban un programa que imprima toAbsolutePath() de la primera y ejecútenlo desde dos carpetas distintas. ¿Qué cambia?

  2. Escriban un programa que cuente las palabras de un archivo de texto grande usando Files.lines. ¿Por qué el stream tiene que ir dentro de un try-with-resources y una List de readAllLines no?

  3. Guarden con un editor un archivo con tildes en Windows-1252 (o con Files.writeString(..., Charset.forName("windows-1252"))) y léanlo con Files.readAllLines sin indicar codificación. ¿Qué excepción aparece? Corrijan la lectura. ¿Cómo podría un programa saber qué codificación tiene un archivo que no escribió él?

  4. Escriban un decorator UpperCaseWriter extends FilterWriter que convierta a mayúsculas todo lo que pasa por él. Combínenlo con CountingWriter en los dos órdenes posibles. ¿Cambia el resultado? ¿Hay algún par de decorators para los que el orden sí importe?

  5. Sin ejecutarlo, digan qué contiene el archivo después de este fragmento, y por qué. Después compruébenlo.

    BufferedWriter w = Files.newBufferedWriter(Path.of("x.txt"));
    w.write("hola");
    IO.println(Files.readString(Path.of("x.txt")));
  6. Escriban a mano el CSV que produciría Csv.join para estos campos: Él dijo "sí", a,b, un campo vacío y 100. Después verifíquenlo con una prueba de CsvTest.

  7. Cambien la política de CsvSongRepository.load para que salte las líneas mal formadas y devuelva, además de las canciones, la lista de números de línea que se saltaron. ¿Qué tipo de retorno usarían? Escriban las pruebas.

  8. Escriban un decorator CachingSongRepository que, después del primer load, devuelva la misma lista sin volver a leer el archivo, y que olvide la caché en cada save. Pruébenlo con el fake de LoggingSongRepositoryTest, contando cuántas veces se llamó a load del repositorio envuelto.

  9. Quiten serialVersionUID de Playlist. ¿Qué dice make? Vuelvan a ponerlo, serialicen una lista a un archivo, agreguen un método nuevo a Playlist y cambien el serialVersionUID a 2L. ¿Qué pasa al leer el archivo viejo?

  10. Expliquen con sus palabras por qué PlainSong terminó con duración −5 y Song no. Si Playlist guardara un campo con el total y alguien alterara el archivo, ¿quién detectaría que el total no coincide con las canciones?

  11. Agreguen a PlaylistCli un comando borrar n que quite la canción número n. Decidan qué pasa con un número fuera de rango o con texto, y escriban pruebas en PlaylistCliTest antes de escribir el comando.

  12. Comparen dos formas de guardar el inventario de su proyecto: CSV con una línea por producto y la serialización de Java. Para cada una, escriban qué pasaría si mañana agregan un campo nuevo al producto, y cuál elegirían para P5.