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á encode/week-15.
Ruta de lectura.
- Antes de clase (obligatorio, 15–20 min): Rutas:
Pathy Leer y escribir conFiles.- En clase: en la sesión A, Errores de E/S, Codificación: bytes y caracteres, Streams de
java.ioy 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é
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,FileWriteroInputStreamReader. 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 deFilesque 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 escribirStandardCharsets.UTF_8cuando 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
InputStreamlee bytes del archivo. - El
InputStreamReaderenvuelve al anterior y convierte bytes en caracteres con la codificación indicada. - El
BufferedReaderenvuelve al anterior, lee en bloques grandes (un buffer, para no ir al disco por cada carácter) y ofrecereadLine().
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;
}
}
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


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
BufferedWriterguarda lo escrito en memoria y lo manda al disco cuando su buffer se llena o cuando se cierra. Si el programa termina sinclose, el archivo queda vacío o incompleto. La demostración lo mide:antes de close: 0 bytes después de close: 35 bytesLa 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.
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:
- Rechazar el archivo entero con una excepción que diga en qué línea está el problema, para que una persona lo corrija.
- 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,50y en el código3.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í.
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
// ...
}
serialVersionUIDes 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, lanzaInvalidClassException. 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, yjavac -Xlint:allavisa si falta.transientmarca un campo que no se guarda. Al cargar, queda en su valor por defecto (0,null).Songes un record y también implementaSerializable;ArrayListyStringya 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
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 conInvalidClassExceptionantes de crear nada. La pruebafilterRejectsClassesThatAreNotAlloweddeSerializationTest.javalo comprueba con unPlainSong.
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 unScannersobre unString, unPrintStreamsobre 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()devuelvefalsecuando 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íaNoSuchElementException. - 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
casedel 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. Cadacasedeberí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
Pathes un nombre de archivo, relativo al directorio de trabajo o absoluto.Fileslee y escribe: todo de una vez (readAllLines,writeString) para archivos pequeños; línea por línea (newBufferedReader,Files.lines) y contry-with-resources para los grandes. IOExceptiones 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
Filesya 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
Scanneryswitches un bucle: leer, despachar, repetir. Recibe la entrada y la salida por el constructor para poder probarlo.
Ejercicios
-
Expliquen la diferencia entre
Path.of("datos.csv")yPath.of("/work/datos.csv"). Escriban un programa que imprimatoAbsolutePath()de la primera y ejecútenlo desde dos carpetas distintas. ¿Qué cambia? -
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 untry-with-resources y unaListdereadAllLinesno? -
Guarden con un editor un archivo con tildes en Windows-1252 (o con
Files.writeString(..., Charset.forName("windows-1252"))) y léanlo conFiles.readAllLinessin 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? -
Escriban un decorator
UpperCaseWriter extends FilterWriterque convierta a mayúsculas todo lo que pasa por él. Combínenlo conCountingWriteren los dos órdenes posibles. ¿Cambia el resultado? ¿Hay algún par de decorators para los que el orden sí importe? -
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"))); -
Escriban a mano el CSV que produciría
Csv.joinpara estos campos:Él dijo "sí",a,b, un campo vacío y100. Después verifíquenlo con una prueba deCsvTest. -
Cambien la política de
CsvSongRepository.loadpara 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. -
Escriban un decorator
CachingSongRepositoryque, después del primerload, devuelva la misma lista sin volver a leer el archivo, y que olvide la caché en cadasave. Pruébenlo con el fake deLoggingSongRepositoryTest, contando cuántas veces se llamó aloaddel repositorio envuelto. -
Quiten
serialVersionUIDdePlaylist. ¿Qué dicemake? Vuelvan a ponerlo, serialicen una lista a un archivo, agreguen un método nuevo aPlaylisty cambien elserialVersionUIDa2L. ¿Qué pasa al leer el archivo viejo? -
Expliquen con sus palabras por qué
PlainSongterminó con duración −5 ySongno. SiPlaylistguardara un campo con el total y alguien alterara el archivo, ¿quién detectaría que el total no coincide con las canciones? -
Agreguen a
PlaylistCliun comandoborrar nque quite la canción númeron. Decidan qué pasa con un número fuera de rango o con texto, y escriban pruebas enPlaylistCliTestantes de escribir el comando. -
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.