Módulo 08: E/S avanzada y JSON
Objetivos del módulo
- Repasar los flujos de bytes y de caracteres de
java.ioy el patrón decorador - Dominar NIO.2:
PathyFilespara leer, escribir, copiar, mover y borrar con las opciones adecuadas - Consultar atributos y permisos de ficheros
- Recorrer árboles de directorios con
Files.walk,Files.findyFileVisitor - Vigilar cambios en una carpeta con
WatchService - Trabajar con ficheros de acceso aleatorio,
FileChannelyByteBuffer - Crear y leer ficheros ZIP y GZIP
- Guardar la configuración de una aplicación con
Properties - Conocer la serialización nativa de Java y sus riesgos
- Procesar JSON con Jackson 3 (el que usa Spring Boot 4): objetos, records, anotaciones, árboles
JsonNode, listas genéricas y fechas - Exportar e importar un catálogo de productos en CSV y en JSON
1. Repaso: flujos de E/S
En el curso de Fundamentos ya leíste y escribiste ficheros de texto. Antes de entrar en la API moderna, repasemos la idea que hay debajo de toda la E/S de Java: el flujo (stream).
📘 Concepto: Un flujo es una secuencia de datos que se lee o se escribe en orden, de principio a fin, sin importar si su origen es un fichero, la memoria, el teclado o una conexión de red. Hay dos familias: los flujos de bytes (
InputStream/OutputStream), para datos binarios, y los de caracteres (Reader/Writer), para texto, que convierten bytes en caracteres aplicando una codificación (charset).
⚠️ Importante: No confundas estos flujos de E/S con los streams de la API de colecciones del módulo 04: comparten nombre, pero no tienen nada que ver.
Figura 1. Jerarquía de flujos de java.io y su composición mediante el patrón decorador.
Los flujos se componen como capas: un FileInputStream lee bytes de un fichero; un InputStreamReader los convierte en caracteres; un BufferedReader añade un búfer y el método readLine(). Cada capa “decora” a la anterior con una funcionalidad nueva.
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
public class FlujosClasicos {
public static void main(String[] args) throws IOException {
// 1. Flujo de CARACTERES con búfer: escribir y leer texto (UTF-8 explícito)
try (PrintWriter out = new PrintWriter(new BufferedWriter(new FileWriter("notas.txt", StandardCharsets.UTF_8)))) {
out.println("Ana;8,5");
out.println("Luis;6");
out.printf("Íñigo;%.1f%n", 9.25);
}
try (BufferedReader in = new BufferedReader(new FileReader("notas.txt", StandardCharsets.UTF_8))) {
String linea;
int n = 0;
while ((linea = in.readLine()) != null) {
System.out.println(++n + ": " + linea);
}
}
// 2. Flujo de BYTES con búfer: copiar un fichero bloque a bloque
try (InputStream in = new BufferedInputStream(new FileInputStream("notas.txt"));
OutputStream out = new BufferedOutputStream(new FileOutputStream("copia.txt"))) {
byte[] bufer = new byte[8192];
int leidos;
long total = 0;
while ((leidos = in.read(bufer)) != -1) {
out.write(bufer, 0, leidos);
total += leidos;
}
System.out.println("Copiados " + total + " bytes (la Í y la ñ ocupan 2 bytes cada una en UTF-8)");
}
// Desde Java 9 la copia completa es una línea:
try (InputStream in = new FileInputStream("notas.txt"); OutputStream out = new FileOutputStream("copia2.txt")) {
System.out.println("transferTo copió " + in.transferTo(out) + " bytes");
}
// 3. Flujos de DATOS: tipos primitivos en binario
try (DataOutputStream out = new DataOutputStream(new BufferedOutputStream(new FileOutputStream("datos.bin")))) {
out.writeInt(2026);
out.writeDouble(3.14159);
out.writeBoolean(true);
out.writeUTF("¡Hola!");
}
try (DataInputStream in = new DataInputStream(new BufferedInputStream(new FileInputStream("datos.bin")))) {
System.out.println("Leído: " + in.readInt() + " " + in.readDouble() + " " + in.readBoolean() + " " + in.readUTF());
}
System.out.println("Tamaño de datos.bin: " + new java.io.File("datos.bin").length() + " bytes");
}
}
docker run --rm -v "$PWD:/w" -w /w maven:3.9-eclipse-temurin-25 java -Duser.language=es -Duser.country=ES FlujosClasicos.java
1: Ana;8,5
2: Luis;6
3: Íñigo;9,3
Copiados 27 bytes (la Í y la ñ ocupan 2 bytes cada una en UTF-8)
transferTo copió 27 bytes
Leído: 2026 3.14159 true ¡Hola!
Tamaño de datos.bin: 22 bytes
El fichero tiene 25 caracteres (contando los tres saltos de línea), pero ocupa 27 bytes: en UTF-8, los caracteres no ASCII (Í, ñ) ocupan dos bytes. Y datos.bin ocupa 22 bytes: 4 del int, 8 del double, 1 del boolean y 9 de la cadena (2 de longitud + 7 de “¡Hola!” en UTF-8 modificado).
| Regla | Motivo |
|---|---|
Usa siempre try-con-recursos | Cierra los flujos aunque haya excepciones (si no, pierdes datos del búfer y dejas el fichero bloqueado) |
Indica siempre la codificación (StandardCharsets.UTF_8) | Desde Java 18 el charset por defecto es UTF-8 (JEP 400), pero el código antiguo y otros programas pueden usar otro |
Usa búferes (Buffered...) | Leer byte a byte del disco es cientos de veces más lento |
Texto → Reader/Writer; binario → InputStream/OutputStream | Leer una imagen con un Reader la corrompe |
💡 Consejo: Para ficheros, hoy casi nunca necesitarás construir estas cadenas de decoradores a mano: la clase
Filesde NIO.2 ofrece métodos de una línea (Files.readString,Files.newBufferedReader…) que ya aplican búfer y UTF-8. Los flujos siguen siendo imprescindibles para red (módulo 09), compresión y procesamiento de datos en cadena.
2. NIO.2: Path
Java 7 introdujo NIO.2 (paquete java.nio.file), que sustituye a la vieja clase java.io.File con una API más completa, coherente y con mejores mensajes de error. Sus protagonistas son la interfaz Path (una ruta) y la clase de utilidades Files (operaciones sobre rutas).
📘 Concepto: Un
Pathrepresenta una ruta en el sistema de ficheros, exista o no. Crear unPathno toca el disco: es solo un objeto que sabe descomponer y combinar rutas. Las operaciones reales (leer, crear, borrar) las haceFiles.
Figura 2. Anatomía de un Path y sus operaciones principales.
import java.nio.file.Path;
import java.nio.file.Paths;
public class Rutas {
public static void main(String[] args) {
Path p = Path.of("/home/ana/proyectos/tienda/src/Main.java"); // Path.of desde Java 11
System.out.println("toString: " + p);
System.out.println("getFileName: " + p.getFileName());
System.out.println("getParent: " + p.getParent());
System.out.println("getRoot: " + p.getRoot());
System.out.println("getNameCount: " + p.getNameCount() + ", getName(1): " + p.getName(1));
System.out.println("subpath(2,4): " + p.subpath(2, 4));
System.out.println("isAbsolute: " + p.isAbsolute());
// Construir rutas sin concatenar cadenas
Path base = Path.of("/home/ana/proyectos");
Path relativa = Path.of("tienda", "docs", "manual.md");
System.out.println("resolve: " + base.resolve(relativa));
System.out.println("resolveSibling: " + p.resolveSibling("Util.java"));
// Normalizar y relativizar
Path sucia = Path.of("/home/ana/./proyectos/../descargas/foto.png");
System.out.println("normalize: " + sucia.normalize());
Path desde = Path.of("/home/ana/proyectos/tienda");
Path hasta = Path.of("/home/ana/descargas/foto.png");
System.out.println("relativize: " + desde.relativize(hasta));
// Ruta relativa -> absoluta (respecto al directorio de trabajo)
Path rel = Path.of("datos", "clientes.csv");
System.out.println("toAbsolutePath: " + rel.toAbsolutePath());
System.out.println("Directorio de trabajo: " + Path.of("").toAbsolutePath());
System.out.println("toUri: " + p.toUri());
// Comparaciones y extensión
System.out.println("startsWith /home: " + p.startsWith("/home") + ", endsWith Main.java: " + p.endsWith("Main.java"));
String nombre = p.getFileName().toString();
System.out.println("Extensión: " + nombre.substring(nombre.lastIndexOf('.') + 1));
System.out.println("Paths.get (antiguo) == Path.of: " + Paths.get("a", "b").equals(Path.of("a", "b")));
// Para trabajar con la API antigua
java.io.File f = p.toFile();
System.out.println("toFile().getName(): " + f.getName() + ", y vuelta: " + f.toPath());
}
}
Salida real en Linux (el contenedor Docker):
toString: /home/ana/proyectos/tienda/src/Main.java
getFileName: Main.java
getParent: /home/ana/proyectos/tienda/src
getRoot: /
getNameCount: 6, getName(1): ana
subpath(2,4): proyectos/tienda
isAbsolute: true
resolve: /home/ana/proyectos/tienda/docs/manual.md
resolveSibling: /home/ana/proyectos/tienda/src/Util.java
normalize: /home/ana/descargas/foto.png
relativize: ../../descargas/foto.png
toAbsolutePath: /w/m08/datos/clientes.csv
Directorio de trabajo: /w/m08
toUri: file:///home/ana/proyectos/tienda/src/Main.java
startsWith /home: true, endsWith Main.java: true
Extensión: java
Paths.get (antiguo) == Path.of: true
toFile().getName(): Main.java, y vuelta: /home/ana/proyectos/tienda/src/Main.java
💡 Consejo: Nunca construyas rutas concatenando cadenas con
"/"o"\\": usaPath.of("a", "b", "c")oresolve. Así tu programa funciona igual en Windows (C:\Users\ana\...) y en Linux. Las rutas relativas se resuelven respecto al directorio de trabajo (user.dir), que es la carpeta desde la que se lanzó la JVM, no la carpeta del.java.
⚠️ Importante:
startsWithyendsWithdePathcomparan nombres completos, no texto:Path.of("/home/anabel").startsWith("/home/ana")esfalse. Para comprobar la extensión, convierte el nombre aString.
3. NIO.2: la clase Files
Files tiene métodos estáticos para casi todo lo que se puede hacer con un fichero. Todos lanzan IOException (o subclases concretas como NoSuchFileException, FileAlreadyExistsException, AccessDeniedException o DirectoryNotEmptyException, que explican exactamente qué ha pasado).
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.PosixFileAttributes;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.List;
import java.util.Set;
import java.util.stream.Stream;
public class Ficheros {
public static void main(String[] args) throws IOException {
Path dir = Path.of("trabajo");
Files.createDirectories(dir.resolve("informes/2026")); // crea toda la cadena si no existe
// --- Escribir ---
Path notas = dir.resolve("notas.txt");
Files.writeString(notas, "Primera línea\nSegunda línea\n"); // crea o sobrescribe (UTF-8)
Files.writeString(notas, "Tercera línea\n", StandardOpenOption.APPEND); // añade al final
Files.write(notas, List.of("Cuarta", "Quinta"), StandardOpenOption.APPEND); // una línea por elemento
try {
Files.writeString(notas, "no", StandardOpenOption.CREATE_NEW); // falla si ya existe
} catch (FileAlreadyExistsException e) {
System.out.println("CREATE_NEW: ya existe " + e.getFile());
}
// --- Leer ---
System.out.println("readString:\n" + Files.readString(notas).indent(2).stripTrailing());
List<String> lineas = Files.readAllLines(notas);
System.out.println("readAllLines: " + lineas.size() + " líneas, la última es '" + lineas.getLast() + "'");
try (Stream<String> st = Files.lines(notas)) { // perezoso: ficheros grandes
System.out.println("Líneas con 'línea': " + st.filter(l -> l.contains("línea")).count());
}
// --- Escritura con búfer y opciones ---
Path log = dir.resolve("app.log");
try (BufferedWriter w = Files.newBufferedWriter(log, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
for (int i = 1; i <= 3; i++) {
w.write("evento " + i);
w.newLine();
}
}
System.out.println("app.log ocupa " + Files.size(log) + " bytes");
// --- Copiar, mover, borrar ---
Path copia = Files.copy(notas, dir.resolve("informes/notas-copia.txt"), StandardCopyOption.REPLACE_EXISTING);
Path movida = Files.move(copia, dir.resolve("informes/2026/notas.txt"), StandardCopyOption.REPLACE_EXISTING);
System.out.println("Copiada y movida a " + movida + ", ¿existe la copia? " + Files.exists(copia));
try {
Files.delete(dir.resolve("no-existe.txt"));
} catch (NoSuchFileException e) {
System.out.println("delete: no existe " + e.getFile());
}
System.out.println("deleteIfExists: " + Files.deleteIfExists(dir.resolve("no-existe.txt")));
// --- Consultas y atributos ---
System.out.println("isDirectory(trabajo): " + Files.isDirectory(dir) + ", isRegularFile(notas): " + Files.isRegularFile(notas)
+ ", isReadable: " + Files.isReadable(notas) + ", isHidden: " + Files.isHidden(notas));
BasicFileAttributes at = Files.readAttributes(notas, BasicFileAttributes.class);
System.out.println("Tamaño: " + at.size() + " B, ¿directorio? " + at.isDirectory()
+ ", modificado: " + at.lastModifiedTime().toInstant().getClass().getSimpleName() + " OK");
System.out.println("Tipo MIME: " + Files.probeContentType(notas));
// --- Permisos POSIX (Linux, macOS; en Windows lanzaría UnsupportedOperationException) ---
Path script = dir.resolve("copia.sh");
Files.writeString(script, "#!/bin/sh\necho hola\n");
Set<PosixFilePermission> permisos = PosixFilePermissions.fromString("rwxr-x---");
Files.setPosixFilePermissions(script, permisos);
PosixFileAttributes pa = Files.readAttributes(script, PosixFileAttributes.class);
System.out.println("Permisos de copia.sh: " + PosixFilePermissions.toString(pa.permissions())
+ ", propietario: " + pa.owner().getName() + ", ¿ejecutable? " + Files.isExecutable(script));
// --- Ficheros temporales ---
Path tmp = Files.createTempFile("informe-", ".tmp");
System.out.println("Temporal: " + tmp.getParent() + "/" + tmp.getFileName().toString().replaceAll("\\d", "N"));
Files.delete(tmp);
}
}
CREATE_NEW: ya existe trabajo/notas.txt
readString:
Primera línea
Segunda línea
Tercera línea
Cuarta
Quinta
readAllLines: 5 líneas, la última es 'Quinta'
Líneas con 'línea': 3
app.log ocupa 27 bytes
Copiada y movida a trabajo/informes/2026/notas.txt, ¿existe la copia? false
delete: no existe trabajo/no-existe.txt
deleteIfExists: false
isDirectory(trabajo): true, isRegularFile(notas): true, isReadable: true, isHidden: false
Tamaño: 59 B, ¿directorio? false, modificado: Instant OK
Tipo MIME: text/plain
Permisos de copia.sh: rwxr-x---, propietario: root, ¿ejecutable? true
Temporal: /tmp/informe-NNNNNNNNNNNNNNNNNNNN.tmp
(El nombre del fichero temporal es aleatorio; el programa sustituye sus cifras por N para que la salida sea reproducible.)
Lectura y escritura: ¿qué método elijo?
| Método | Uso |
|---|---|
Files.readString(p) / writeString(p, s, opciones...) | Ficheros de texto pequeños completos (Java 11) |
Files.readAllLines(p) / write(p, lineas, opciones...) | Lista de líneas completa en memoria |
Files.lines(p) | Stream perezoso de líneas: ficheros grandes. Ciérralo con try |
Files.newBufferedReader(p) / newBufferedWriter(p, opciones...) | Control línea a línea, escritura incremental |
Files.readAllBytes(p) / write(p, bytes) | Ficheros binarios pequeños |
Files.newInputStream(p) / newOutputStream(p) | Ficheros binarios grandes o para encadenar con otros flujos |
Opciones de apertura (StandardOpenOption)
| Opción | Efecto |
|---|---|
| (ninguna) | Para escribir: CREATE, TRUNCATE_EXISTING y WRITE (crea o sobrescribe) |
APPEND | Escribe al final del fichero existente |
CREATE | Crea el fichero si no existe |
CREATE_NEW | Crea el fichero y falla si ya existe (útil para no pisar datos) |
TRUNCATE_EXISTING | Vacía el fichero al abrirlo |
READ / WRITE | Modo de acceso (para canales) |
DSYNC / SYNC | Fuerza la escritura física en disco en cada operación |
Al copiar, Files.copy falla si el destino existe salvo con REPLACE_EXISTING, COPY_ATTRIBUTES conserva las fechas y nunca copia el contenido de un directorio; Files.move admite ATOMIC_MOVE; y Files.delete lanza DirectoryNotEmptyException si el directorio tiene contenido.
Atributos y permisos
Files.readAttributes(p, BasicFileAttributes.class) lee de una vez tamaño, fechas de creación, modificación y acceso, y tipo (fichero, directorio, enlace simbólico). Las vistas específicas añaden más: PosixFileAttributes (propietario, grupo, permisos rwx) en Linux y macOS, y DosFileAttributes (oculto, solo lectura, sistema) en Windows.
⚠️ Importante: Comprobar antes de actuar (
if (Files.exists(p)) Files.delete(p);) tiene una condición de carrera con otros programas: el fichero puede desaparecer entre las dos llamadas. Es más robusto intentar la operación y tratar la excepción concreta (NoSuchFileException), o usar los métodos que ya lo contemplan (deleteIfExists,CREATE_NEW).
4. Recorrer directorios
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Comparator;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class Recorrer {
static void crearEstructura(Path raiz) throws IOException {
String[] ficheros = {"pom.xml", "README.md", "src/main/java/App.java", "src/main/java/modelo/Producto.java",
"src/main/resources/application.properties", "src/test/java/AppTest.java", "target/app.jar",
"target/classes/App.class", "docs/manual.md", "docs/img/logo.png"};
for (String f : ficheros) {
Path p = raiz.resolve(f);
Files.createDirectories(p.getParent());
Files.writeString(p, "x".repeat(f.length() * 10)); // contenido de relleno
}
}
public static void main(String[] args) throws IOException {
Path raiz = Path.of("proyecto");
crearEstructura(raiz);
// 1. Files.list: solo el primer nivel
try (Stream<Path> st = Files.list(raiz)) {
System.out.println("Primer nivel: " + st.map(p -> p.getFileName().toString()).sorted().toList());
}
// 2. Files.walk: recorrido recursivo en profundidad (con límite opcional de niveles)
try (Stream<Path> st = Files.walk(raiz)) {
st.sorted().forEach(p -> {
int nivel = raiz.relativize(p).getNameCount();
System.out.println(" ".repeat(nivel) + (Files.isDirectory(p) ? "[" + p.getFileName() + "]" : p.getFileName()));
});
}
// 3. Files.find: filtrar por atributos mientras se recorre
try (Stream<Path> st = Files.find(raiz, Integer.MAX_VALUE,
(p, attr) -> attr.isRegularFile() && p.toString().endsWith(".java"))) {
System.out.println("Fuentes Java: " + st.map(raiz::relativize).sorted().toList());
}
// 4. Estadística: bytes por extensión
try (Stream<Path> st = Files.walk(raiz)) {
Map<String, Long> porExtension = st.filter(Files::isRegularFile)
.collect(Collectors.groupingBy(
p -> { String n = p.getFileName().toString(); return n.substring(n.lastIndexOf('.') + 1); },
TreeMap::new,
Collectors.summingLong(p -> { try { return Files.size(p); } catch (IOException e) { return 0; } })));
System.out.println("Bytes por extensión: " + porExtension);
}
// 5. FileVisitor: control total (saltar directorios, actuar antes y después)
long[] total = {0};
Files.walkFileTree(raiz, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path d, BasicFileAttributes attrs) {
if (d.getFileName().toString().equals("target")) {
System.out.println(" (salto " + raiz.relativize(d) + ")");
return FileVisitResult.SKIP_SUBTREE; // no entra en target/
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path f, BasicFileAttributes attrs) {
total[0] += attrs.size();
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path f, IOException e) {
System.out.println(" no se pudo leer " + f + ": " + e);
return FileVisitResult.CONTINUE;
}
});
System.out.println("Tamaño sin target/: " + total[0] + " bytes");
// 6. Borrar un árbol completo: los hijos antes que los padres (orden inverso)
try (Stream<Path> st = Files.walk(raiz.resolve("target"))) {
st.sorted(Comparator.reverseOrder()).forEach(p -> {
try {
Files.delete(p);
} catch (IOException e) {
throw new RuntimeException(e);
}
});
}
System.out.println("¿Existe target tras borrarlo? " + Files.exists(raiz.resolve("target")));
}
}
Primer nivel: [README.md, docs, pom.xml, src, target]
[proyecto]
README.md
[docs]
[img]
logo.png
manual.md
pom.xml
[src]
[main]
[java]
App.java
[modelo]
Producto.java
[resources]
application.properties
[test]
[java]
AppTest.java
[target]
app.jar
[classes]
App.class
Fuentes Java: [src/main/java/App.java, src/main/java/modelo/Producto.java, src/test/java/AppTest.java]
Bytes por extensión: {class=240, jar=140, java=820, md=230, png=170, properties=410, xml=70}
(salto target)
Tamaño sin target/: 1700 bytes
¿Existe target tras borrarlo? false
| Método | Qué recorre | Devuelve |
|---|---|---|
Files.list(dir) | Solo el primer nivel | Stream<Path> (hay que cerrarlo) |
Files.walk(dir[, profundidad]) | Todo el árbol en profundidad | Stream<Path> (hay que cerrarlo) |
Files.find(dir, profundidad, filtro) | Todo el árbol, filtrando por ruta y atributos | Stream<Path> (más eficiente que walk + filter con atributos) |
Files.walkFileTree(dir, visitor) | Todo el árbol con callbacks | Control total: saltar ramas, tratar errores, actuar al salir de un directorio |
Files.newDirectoryStream(dir, "*.java") | Primer nivel con patrón glob | DirectoryStream<Path> iterable |
Figura 3. Orden de las llamadas de un FileVisitor y efecto de SKIP_SUBTREE.
⚠️ Importante: Los streams de
Files.list,Files.walk,Files.findyFiles.linesmantienen abiertos descriptores de fichero del sistema operativo. Ciérralos siempre contry-con-recursos; en un servidor que los deje abiertos acabarás viendoToo many open files. YFiles.walklanzaUncheckedIOExceptionsi encuentra un directorio sin permisos: si necesitas tolerar errores, usawalkFileTreeconvisitFileFailed.
💡 Consejo: Para borrar un directorio con contenido hay que borrar primero los hijos. El truco del ejemplo (ordenar las rutas en orden inverso) funciona porque una ruta hija siempre es “mayor” que la de su padre. Con
walkFileTree, el lugar natural espostVisitDirectory, que se llama cuando ya se ha visitado todo el contenido.
5. Vigilar cambios: WatchService
¿Cómo sabe un programa que alguien ha dejado un fichero nuevo en una carpeta de “entrada” (pedidos de un ERP, facturas escaneadas, configuraciones)? Consultar la carpeta cada segundo (polling) desperdicia recursos. WatchService pide al sistema operativo que avise cuando ocurren cambios.
import java.io.IOException;
import java.nio.file.ClosedWatchServiceException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardWatchEventKinds;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
public class Vigilante {
public static void main(String[] args) throws Exception {
// Ojo: en carpetas compartidas de Docker o de red puede no recibir eventos; usamos el temporal del sistema
Path bandeja = Files.createDirectories(Path.of(System.getProperty("java.io.tmpdir"), "bandeja"));
System.out.println("Vigilando " + bandeja);
WatchService ws = FileSystems.getDefault().newWatchService();
bandeja.register(ws, StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE);
// Hilo vigilante: espera eventos y los procesa
Thread vigilante = Thread.ofPlatform().name("vigilante").start(() -> {
try {
while (true) {
WatchKey clave = ws.take(); // bloquea hasta que haya eventos
for (WatchEvent<?> ev : clave.pollEvents()) {
if (ev.kind() == StandardWatchEventKinds.OVERFLOW) continue;
Path nombre = (Path) ev.context(); // ruta RELATIVA al directorio vigilado
System.out.println(" " + ev.kind().name() + " -> " + nombre + " (x" + ev.count() + ")");
}
if (!clave.reset()) break; // el directorio ya no es accesible
}
} catch (InterruptedException | ClosedWatchServiceException e) {
System.out.println("Vigilante detenido");
}
});
// Simulamos a otro programa que deja y retira ficheros
Thread.sleep(300);
System.out.println("Creo pedido-1.csv");
Files.writeString(bandeja.resolve("pedido-1.csv"), "id;total\n1;99.90\n");
Thread.sleep(300);
System.out.println("Modifico pedido-1.csv");
Files.writeString(bandeja.resolve("pedido-1.csv"), "2;15.00\n", java.nio.file.StandardOpenOption.APPEND);
Thread.sleep(300);
System.out.println("Borro pedido-1.csv");
Files.delete(bandeja.resolve("pedido-1.csv"));
Thread.sleep(300);
ws.close(); // provoca ClosedWatchServiceException en take()
vigilante.join();
}
}
Vigilando /tmp/bandeja
Creo pedido-1.csv
ENTRY_CREATE -> pedido-1.csv (x1)
ENTRY_MODIFY -> pedido-1.csv (x1)
Modifico pedido-1.csv
ENTRY_MODIFY -> pedido-1.csv (x1)
Borro pedido-1.csv
ENTRY_DELETE -> pedido-1.csv (x1)
Vigilante detenido
Fíjate en que crear el fichero con contenido produjo dos eventos (creación y modificación): son dos operaciones del sistema operativo. Tu código debe ser tolerante a eventos repetidos o agrupados (ev.count()), y a ficheros que aún se están escribiendo cuando llega el aviso.
| Paso | Código |
|---|---|
| 1. Crear el servicio | FileSystems.getDefault().newWatchService() |
| 2. Registrar un directorio | dir.register(ws, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE) |
| 3. Esperar | ws.take() (bloquea) o ws.poll(tiempo, unidad) |
| 4. Procesar | clave.pollEvents(): tipo (kind), nombre relativo (context) |
| 5. Rearmar | clave.reset() (si devuelve false, el directorio ya no es válido) |
⚠️ Importante:
WatchServiceno es recursivo: vigila solo el directorio registrado, no sus subdirectorios (hay que registrarlos uno a uno). Y depende del sistema operativo: al probar este programa sobre una carpeta compartida entre Windows y un contenedor Docker no llegó ningún evento; por eso el ejemplo vigila el directorio temporal del contenedor. Ocurre lo mismo en algunas unidades de red.
6. Acceso aleatorio, FileChannel y ByteBuffer
Los flujos se leen de principio a fin. Si un fichero contiene registros de tamaño fijo, puedes saltar directamente al registro número n (posición n × tamaño) para leerlo o modificarlo, como en una tabla. Es la base de cómo las bases de datos guardan sus datos en disco.
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
public class AccesoAleatorio {
// Registro de tamaño fijo: id (int, 4 B) + nombre (20 B) + precio (double, 8 B) = 32 bytes
static final int TAM_NOMBRE = 20;
static final int TAM_REGISTRO = 4 + TAM_NOMBRE + 8;
static void escribir(RandomAccessFile f, int pos, int id, String nombre, double precio) throws IOException {
f.seek((long) pos * TAM_REGISTRO); // salto directo al registro
f.writeInt(id);
byte[] b = new byte[TAM_NOMBRE]; // relleno con ceros hasta 20 bytes
byte[] n = nombre.getBytes(StandardCharsets.UTF_8);
System.arraycopy(n, 0, b, 0, Math.min(n.length, TAM_NOMBRE));
f.write(b);
f.writeDouble(precio);
}
static String leer(RandomAccessFile f, int pos) throws IOException {
f.seek((long) pos * TAM_REGISTRO);
int id = f.readInt();
byte[] b = new byte[TAM_NOMBRE];
f.readFully(b);
double precio = f.readDouble();
return id + " | " + new String(b, StandardCharsets.UTF_8).trim().replace("\0", "") + " | " + precio;
}
public static void main(String[] args) throws IOException {
try (RandomAccessFile f = new RandomAccessFile("productos.dat", "rw")) {
f.setLength(0);
escribir(f, 0, 1, "Portátil UltraBook", 899.00);
escribir(f, 1, 6, "Ratón inalámbrico", 19.90);
escribir(f, 2, 9, "Monitor 24 FHD", 139.00);
System.out.println("Registros: " + f.length() / TAM_REGISTRO + " (" + f.length() + " bytes)");
System.out.println("Registro 2: " + leer(f, 2));
// Modificar SOLO el precio del registro 1, sin reescribir el fichero
f.seek(1L * TAM_REGISTRO + 4 + TAM_NOMBRE);
f.writeDouble(17.50);
System.out.println("Registro 1 tras rebaja: " + leer(f, 1));
}
// Lo mismo con NIO: FileChannel + ByteBuffer
try (FileChannel ch = FileChannel.open(Path.of("productos.dat"), StandardOpenOption.READ)) {
ByteBuffer buf = ByteBuffer.allocate(TAM_REGISTRO);
ch.read(buf, 0); // lectura posicional del registro 0
buf.flip(); // pasar de "modo escritura" a "modo lectura"
int id = buf.getInt();
byte[] nombre = new byte[TAM_NOMBRE];
buf.get(nombre);
double precio = buf.getDouble();
System.out.println("FileChannel, registro 0: " + id + " | "
+ new String(nombre, StandardCharsets.UTF_8).replace("\0", "") + " | " + precio
+ " (tamaño del canal: " + ch.size() + ")");
}
}
}
Registros: 3 (96 bytes)
Registro 2: 9 | Monitor 24 FHD | 139.0
Registro 1 tras rebaja: 6 | Ratón inalámbrico | 17.5
FileChannel, registro 0: 1 | Portátil UltraBook | 899.0 (tamaño del canal: 96)
RandomAccessFile(fichero, "rw")permite leer y escribir en cualquier posición conseek(posicion). Los modos son"r","rw","rws"y"rwd"(estos dos últimos sincronizan con el disco).- Un
FileChannel(NIO) lee y escribe bloques de bytes a través de unByteBuffer: un array con posición, límite y capacidad. Tras escribir en el búfer,flip()lo prepara para leer lo escrito;clear()lo vacía para volver a escribir. - Los canales permiten además lecturas posicionales concurrentes, bloqueo de regiones (
lock()), copias muy eficientes entre canales (transferTo) y ficheros proyectados en memoria (map()).
💡 Consejo: Cuidado con los textos en registros de tamaño fijo: 20 bytes no son 20 caracteres en UTF-8 (“Portátil UltraBook” ocupa 19 bytes por la tilde). Si cortas a mitad de un carácter multibyte obtendrás basura. Para datos reales usa una base de datos (bloque 3 del curso).
7. Ficheros comprimidos: ZIP y GZIP
El paquete java.util.zip lee y escribe los formatos ZIP (varios ficheros en un archivo) y GZIP (un único fichero comprimido, típico de logs y descargas .tar.gz). Además, NIO.2 permite abrir un ZIP como si fuera un sistema de ficheros.
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.stream.Stream;
import java.util.zip.GZIPOutputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
public class Comprimir {
public static void main(String[] args) throws IOException {
Path origen = Files.createDirectories(Path.of("informes"));
Files.writeString(origen.resolve("ventas.csv"), "mes;importe\n".repeat(1) + "enero;1500\n".repeat(500));
Files.writeString(origen.resolve("leeme.txt"), "Informes de 2026\n");
Files.createDirectories(origen.resolve("graficos"));
Files.writeString(origen.resolve("graficos/barras.svg"), "<svg/>".repeat(200));
// 1. Crear un ZIP con todos los ficheros de un directorio (conservando subcarpetas)
Path zip = Path.of("informes.zip");
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zip));
Stream<Path> st = Files.walk(origen)) {
for (Path p : st.filter(Files::isRegularFile).sorted().toList()) {
String nombreEnZip = origen.relativize(p).toString().replace('\\', '/');
zos.putNextEntry(new ZipEntry(nombreEnZip));
Files.copy(p, zos);
zos.closeEntry();
}
}
System.out.println("ZIP creado: " + Files.size(zip) + " bytes");
// 2. Listar y extraer
Path destino = Files.createDirectories(Path.of("extraido"));
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(zip))) {
ZipEntry e;
while ((e = zis.getNextEntry()) != null) {
Path salida = destino.resolve(e.getName()).normalize();
if (!salida.startsWith(destino)) { // protección "Zip Slip"
throw new IOException("Entrada maliciosa: " + e.getName());
}
Files.createDirectories(salida.getParent());
long bytes = Files.copy(zis, salida, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
System.out.printf(" extraído %-20s %6d bytes (comprimido: %d)%n", e.getName(), bytes, e.getCompressedSize());
}
}
// 3. El ZIP como un sistema de ficheros (zipfs): leer una entrada como un Path
try (FileSystem zfs = FileSystems.newFileSystem(zip)) {
Path leeme = zfs.getPath("/leeme.txt");
System.out.println("Dentro del ZIP: " + Files.readString(leeme).strip());
Files.writeString(zfs.getPath("/nuevo.txt"), "añadido sin descomprimir");
}
try (FileSystem zfs = FileSystems.newFileSystem(URI.create("jar:" + zip.toUri()), Map.of())) {
try (Stream<Path> st = Files.list(zfs.getPath("/"))) {
System.out.println("Entradas de primer nivel: " + st.map(Path::toString).sorted().toList());
}
}
// 4. GZIP: comprimir un único fichero (típico de logs: app.log.gz)
Path gz = Path.of("ventas.csv.gz");
try (InputStream in = Files.newInputStream(origen.resolve("ventas.csv"));
OutputStream out = new GZIPOutputStream(Files.newOutputStream(gz))) {
in.transferTo(out);
}
System.out.println("ventas.csv: " + Files.size(origen.resolve("ventas.csv")) + " B -> ventas.csv.gz: " + Files.size(gz) + " B");
}
}
ZIP creado: 463 bytes
extraído graficos/barras.svg 1200 bytes (comprimido: 18)
extraído leeme.txt 17 bytes (comprimido: 19)
extraído ventas.csv 5512 bytes (comprimido: 52)
Dentro del ZIP: Informes de 2026
Entradas de primer nivel: [/graficos, /leeme.txt, /nuevo.txt, /ventas.csv]
ventas.csv: 5512 B -> ventas.csv.gz: 70 B
Los ficheros de ejemplo son muy repetitivos y se comprimen muchísimo (de 5512 a 52 bytes); un fichero pequeño y variado, como leeme.txt, puede incluso ocupar más comprimido. En el apartado 3, FileSystems.newFileSystem(zip) (Java 13) monta el ZIP y permite usar Files dentro de él: incluso añadimos nuevo.txt sin descomprimir nada.
⚠️ Importante: Al extraer un ZIP, comprueba siempre que la ruta de cada entrada queda dentro del directorio de destino. Un ZIP malicioso puede contener entradas como
../../etc/cron.d/tareaque, al extraerse, escriben fuera de la carpeta. Esta vulnerabilidad se conoce como Zip Slip y la evita la comprobaciónsalida.startsWith(destino)trasnormalize().
8. Configuración con Properties
Las aplicaciones necesitan parámetros que no conviene escribir en el código: la URL de la base de datos, el tamaño de un pool, si se activa el modo depuración… El formato clásico de Java son los ficheros .properties (clave=valor), que verás de nuevo en Spring Boot (application.properties, módulo 18).
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
public class Configuracion {
public static void main(String[] args) throws IOException {
Path fichero = Path.of("app.properties");
Files.writeString(fichero, """
# Configuración de la aplicación
app.nombre=Tienda Online
db.url=jdbc:mysql://localhost:3306/empresa
db.usuario=curso
db.pool.maximo=10
modo.depuracion=true
""");
// Valores por defecto: se usan si la clave no está en el fichero
Properties porDefecto = new Properties();
porDefecto.setProperty("db.pool.maximo", "5");
porDefecto.setProperty("idioma", "es");
Properties config = new Properties(porDefecto);
try (Reader r = Files.newBufferedReader(fichero, StandardCharsets.UTF_8)) { // ¡con Reader UTF-8!
config.load(r);
}
System.out.println("app.nombre = " + config.getProperty("app.nombre"));
System.out.println("db.url = " + config.getProperty("db.url"));
int maximo = Integer.parseInt(config.getProperty("db.pool.maximo"));
boolean depurar = Boolean.parseBoolean(config.getProperty("modo.depuracion"));
System.out.println("db.pool.maximo = " + maximo + " (int), modo.depuracion = " + depurar + " (boolean)");
System.out.println("idioma = " + config.getProperty("idioma") + " (valor por defecto)");
System.out.println("db.clave = " + config.getProperty("db.clave", "<sin definir>"));
System.out.println("Todas las claves (con las de por defecto): " + config.stringPropertyNames().stream().sorted().toList());
// Modificar y guardar
config.setProperty("ultima.ejecucion", "2026-10-04");
try (Writer w = Files.newBufferedWriter(Path.of("app-guardado.properties"), StandardCharsets.UTF_8)) {
config.store(w, "Guardado por la aplicación");
}
Files.readAllLines(Path.of("app-guardado.properties")).stream()
.filter(l -> !l.startsWith("#")).sorted().forEach(l -> System.out.println(" " + l));
// Propiedades del sistema y variables de entorno
System.out.println("java.version = " + System.getProperty("java.version")
+ ", user.dir = " + System.getProperty("user.dir")
+ ", HOME = " + System.getenv("HOME"));
}
}
app.nombre = Tienda Online
db.url = jdbc:mysql://localhost:3306/empresa
db.pool.maximo = 10 (int), modo.depuracion = true (boolean)
idioma = es (valor por defecto)
db.clave = <sin definir>
Todas las claves (con las de por defecto): [app.nombre, db.pool.maximo, db.url, db.usuario, idioma, modo.depuracion]
app.nombre=Tienda Online
db.pool.maximo=10
db.url=jdbc\:mysql\://localhost\:3306/empresa
db.usuario=curso
modo.depuracion=true
ultima.ejecucion=2026-10-04
java.version = 25.0.4.1, user.dir = /w/m08, HOME = /root
- Todos los valores son cadenas: conviértelos tú (
Integer.parseInt,Boolean.parseBoolean). storeescapa los caracteres especiales (:y=se escriben como\:y\=);loadlos interpreta de vuelta.getProperty(clave, porDefecto)evita losnull; un objetoPropertiespadre aporta valores por defecto globales (y aparecen enstringPropertyNames()).
⚠️ Importante:
Properties.load(InputStream)lee el fichero en ISO-8859-1 (es así desde Java 1.0, por compatibilidad), y las tildes se estropean. Usaload(Reader)con unReaderen UTF-8, como en el ejemplo. (Solo losResourceBundledel módulo 05 leen UTF-8 de serie desde Java 9.) Y no guardes contraseñas en ficheros de configuración que se suban al repositorio: usa variables de entorno (System.getenv).
9. Serialización nativa de Java
📘 Concepto: La serialización convierte un objeto (y todo el grafo de objetos a los que referencia) en una secuencia de bytes que se puede guardar o enviar por red; la deserialización reconstruye el objeto. Java la incluye desde la versión 1.1 con
ObjectOutputStream,ObjectInputStreamy la interfaz marcadoraSerializable.
import java.io.IOException;
import java.io.InvalidClassException;
import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serial;
import java.io.Serializable;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
public class Serializacion {
static class Cliente implements Serializable {
@Serial
private static final long serialVersionUID = 1L; // versión del formato de la clase
private final String nombre;
private final LocalDate alta; // LocalDate también es Serializable
private transient String claveSesion; // transient: NO se guarda
private final List<String> pedidos = new ArrayList<>();
Cliente(String nombre, LocalDate alta, String claveSesion) {
this.nombre = nombre;
this.alta = alta;
this.claveSesion = claveSesion;
}
@Override
public String toString() {
return nombre + " (alta " + alta + ", pedidos " + pedidos + ", sesión " + claveSesion + ")";
}
}
record Punto(int x, int y) implements Serializable { } // los records se deserializan con su constructor
public static void main(String[] args) throws Exception {
Cliente c = new Cliente("Ana Ruiz", LocalDate.of(2024, 3, 1), "a8f3-secreta");
c.pedidos.add("P-5001");
c.pedidos.add("P-5006");
Path fichero = Path.of("cliente.ser");
try (ObjectOutputStream out = new ObjectOutputStream(Files.newOutputStream(fichero))) {
out.writeObject(c);
out.writeObject(new Punto(3, 4));
}
System.out.println("Guardado: " + c);
System.out.println("Tamaño del fichero: " + Files.size(fichero) + " bytes (formato binario propio de Java)");
try (ObjectInputStream in = new ObjectInputStream(Files.newInputStream(fichero))) {
Cliente leido = (Cliente) in.readObject();
Punto p = (Punto) in.readObject();
System.out.println("Leído: " + leido);
System.out.println("Punto: " + p + ", ¿mismo objeto que el original? " + (leido == c));
}
// Defensa: un filtro que solo admite las clases esperadas (Java 9, JEP 290)
ObjectInputFilter filtro = ObjectInputFilter.Config.createFilter(
"Serializacion$Punto;java.base/*;!*"); // Punto y clases del JDK; nada más
try (ObjectInputStream in = new ObjectInputStream(Files.newInputStream(fichero))) {
in.setObjectInputFilter(filtro);
in.readObject();
} catch (InvalidClassException e) {
System.out.println("Filtro: rechazada la deserialización -> " + e.getMessage());
}
}
}
Guardado: Ana Ruiz (alta 2024-03-01, pedidos [P-5001, P-5006], sesión a8f3-secreta)
Tamaño del fichero: 307 bytes (formato binario propio de Java)
Leído: Ana Ruiz (alta 2024-03-01, pedidos [P-5001, P-5006], sesión null)
Punto: Punto[x=3, y=4], ¿mismo objeto que el original? false
Filtro: rechazada la deserialización -> filter status: REJECTED
El campo transient no se guardó (vuelve como null), y el objeto leído es una copia nueva. En la última parte, el filtro solo admite Punto y las clases del JDK; como el primer objeto del fichero es un Cliente, la lectura se rechaza.
| Elemento | Función |
|---|---|
implements Serializable | Marca la clase como serializable (todos sus campos no transient deben serlo también) |
transient | Excluye un campo (contraseñas, cachés, conexiones) |
serialVersionUID | Versión de la clase; si no coincide al leer, InvalidClassException. Declárala siempre (con @Serial, Java 14) |
ObjectInputFilter (Java 9) | Lista blanca de clases admitidas al deserializar |
Riesgos: por qué evitarla
⚠️ Importante: La serialización nativa ha sido una de las mayores fuentes de vulnerabilidades de Java. Al deserializar, la JVM puede crear objetos de cualquier clase del classpath y ejecutar su código de inicialización; un atacante que controle los bytes puede encadenar clases de bibliotecas comunes (gadget chains) para ejecutar código en tu servidor. Los propios arquitectos de Java la consideran un error de diseño histórico.
Por eso:
- Nunca deserialices datos de fuentes no confiables (peticiones de red, ficheros subidos por usuarios).
- Si no tienes alternativa, aplica siempre un
ObjectInputFiltercon lista blanca (también se puede configurar para toda la JVM con-Djdk.serialFilter=...). - El formato es binario, específico de Java, frágil ante cambios en las clases y difícil de inspeccionar.
- Para guardar o intercambiar datos usa formatos de texto estándar como JSON (siguiente sección), XML o CSV, o formatos binarios con esquema (Protocol Buffers, Avro).
10. JSON con Jackson
JSON (JavaScript Object Notation) es el formato de intercambio de datos de la web: lo usan las APIs REST, las bases de datos documentales, los ficheros de configuración… Java no incluye un procesador de JSON en el JDK, así que se usa una biblioteca. La más extendida es Jackson, que es además la que integra Spring Boot.
{
"id": 7,
"nombre": "Teclado mecánico",
"precio": 79.90,
"etiquetas": ["gaming", "rgb"],
"categoria": { "id": 5, "nombre": "Ratones y teclados" },
"descatalogado": false,
"descripcion": null
}
Tipos de JSON: objeto {...}, array [...], cadena "...", número, true/false y null.
Jackson 2 y Jackson 3
En otoño de 2025 se publicó Jackson 3.0, y Spring Boot 4 (con Spring Framework 7) lo adoptó como versión por defecto. Mucho código, tutoriales y bibliotecas siguen usando Jackson 2, así que debes reconocer ambos:
| Jackson 2.x | Jackson 3.x | |
|---|---|---|
groupId de Maven | com.fasterxml.jackson.core | tools.jackson.core |
| Paquetes | com.fasterxml.jackson.databind... | tools.jackson.databind... |
Anotaciones (@JsonProperty…) | com.fasterxml.jackson.annotation | Las mismas (com.fasterxml.jackson.annotation, artefacto jackson-annotations 2.x) |
| Java mínimo | 8 | 17 |
| Excepciones | JsonProcessingException, comprobada | JacksonException, no comprobada (RuntimeException) |
| Configuración | ObjectMapper mutable (mapper.configure(...)) | Inmutable: JsonMapper.builder()...build() |
Fechas java.time | Módulo aparte jackson-datatype-jsr310 + JavaTimeModule | Incluidas en jackson-databind; se escriben como texto ISO-8601 por defecto |
| Propiedades desconocidas al leer | Fallo (FAIL_ON_UNKNOWN_PROPERTIES activado) | Se ignoran (desactivado por defecto) |
| Orden de propiedades | El de declaración | Alfabético por defecto (SORT_PROPERTIES_ALPHABETICALLY) en las clases; los records conservan el orden del constructor |
| En Spring Boot | 3.x | 4.x |
Añadir Jackson 3 con Maven
<dependencyManagement>
<dependencies>
<!-- El BOM fija una versión coherente para todos los módulos de Jackson 3 -->
<dependency>
<groupId>tools.jackson</groupId>
<artifactId>jackson-bom</artifactId>
<version>3.2.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Jackson 3: groupId tools.jackson.core (las anotaciones siguen en com.fasterxml.jackson.core) -->
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
jackson-databind arrastra jackson-core (el analizador de bajo nivel) y jackson-annotations. Con la versión 3.2.3, Maven descarga exactamente estas tres bibliotecas: jackson-databind-3.2.3.jar, jackson-core-3.2.3.jar y jackson-annotations-2.22.jar. (En un proyecto Spring Boot no indicarás la versión: la fija el propio Spring Boot.)
Los ejemplos de esta sección forman un proyecto Maven (es.curso.json) que se compila y ejecuta así:
docker run --rm -v "$PWD:/w" -v java-m2:/root/.m2 -w /w maven:3.9-eclipse-temurin-25 mvn -q compile exec:java -Dexec.mainClass=es.curso.json.JsonBasico
JsonMapper: serializar y deserializar
📘 Concepto: El
JsonMapper(una subclase deObjectMapperpara JSON) convierte objetos Java en JSON (serializar,writeValue...) y JSON en objetos Java (deserializar,readValue). Para saber qué propiedades tiene un objeto usa sus getters (getNombre(),isVip()), sus campos públicos o, en un record, sus componentes.
Figura 4. Jackson convierte objetos en JSON y viceversa; las anotaciones controlan el mapeo.
package es.curso.json;
import java.util.List;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.json.JsonMapper;
public class JsonBasico {
// Un record: Jackson 3 lo serializa y deserializa sin anotaciones
public record Categoria(int id, String nombre) { }
// Una clase "JavaBean" clásica: constructor vacío + getters y setters
public static class Cliente {
private String nombre;
private String email;
private boolean vip;
private List<String> telefonos;
public Cliente() { } // Jackson lo necesita para crear el objeto
public Cliente(String nombre, String email, boolean vip, List<String> telefonos) {
this.nombre = nombre;
this.email = email;
this.vip = vip;
this.telefonos = telefonos;
}
public String getNombre() { return nombre; }
public void setNombre(String nombre) { this.nombre = nombre; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public boolean isVip() { return vip; }
public void setVip(boolean vip) { this.vip = vip; }
public List<String> getTelefonos() { return telefonos; }
public void setTelefonos(List<String> telefonos) { this.telefonos = telefonos; }
@Override
public String toString() {
return "Cliente[" + nombre + ", " + email + ", vip=" + vip + ", tel=" + telefonos + "]";
}
}
public static void main(String[] args) {
// El mapper es caro de crear y seguro entre hilos: créalo una vez y reutilízalo
JsonMapper mapper = JsonMapper.builder().build();
// Objeto -> JSON (serializar)
Categoria cat = new Categoria(2, "Portátiles");
String json = mapper.writeValueAsString(cat);
System.out.println("record -> " + json);
Cliente cli = new Cliente("Ana Ruiz", "ana@correo.es", true, List.of("600111222", "965000000"));
System.out.println("bean -> " + mapper.writeValueAsString(cli));
// JSON con sangría
JsonMapper bonito = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build();
System.out.println("con INDENT_OUTPUT:\n" + bonito.writeValueAsString(cli));
// JSON -> objeto (deserializar)
Categoria leida = mapper.readValue("{\"id\":6,\"nombre\":\"Monitores\"}", Categoria.class);
System.out.println("JSON -> " + leida + ", nombre=" + leida.nombre());
Cliente c2 = mapper.readValue("""
{"nombre":"Luis Gil","email":"luis@correo.es","vip":false,"telefonos":[]}
""", Cliente.class);
System.out.println("JSON -> " + c2);
// Propiedades desconocidas: Jackson 3 las ignora por defecto (Jackson 2 fallaba)
String conExtra = "{\"id\":6,\"nombre\":\"Monitores\",\"color\":\"rojo\"}";
System.out.println("Con propiedad desconocida: " + mapper.readValue(conExtra, Categoria.class));
JsonMapper estricto = JsonMapper.builder()
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).build();
// Errores: en Jackson 3 las excepciones son NO comprobadas (JacksonException extends RuntimeException)
String[] malos = {
conExtra, // desconocida (mapper estricto)
"{\"id\":\"seis\",\"nombre\":\"Monitores\"}", // tipo incorrecto
"{\"id\":6,\"nombre\":" // JSON mal formado
};
for (String m : malos) {
try {
estricto.readValue(m, Categoria.class);
} catch (JacksonException e) {
System.out.println(e.getClass().getSimpleName() + ": " + e.getOriginalMessage());
}
}
}
}
record -> {"id":2,"nombre":"Portátiles"}
bean -> {"email":"ana@correo.es","nombre":"Ana Ruiz","telefonos":["600111222","965000000"],"vip":true}
con INDENT_OUTPUT:
{
"email" : "ana@correo.es",
"nombre" : "Ana Ruiz",
"telefonos" : [ "600111222", "965000000" ],
"vip" : true
}
JSON -> Categoria[id=6, nombre=Monitores], nombre=Monitores
JSON -> Cliente[Luis Gil, luis@correo.es, vip=false, tel=[]]
Con propiedad desconocida: Categoria[id=6, nombre=Monitores]
UnrecognizedPropertyException: Unrecognized property "color" (class es.curso.json.JsonBasico$Categoria), not marked as ignorable
InvalidFormatException: Cannot deserialize value of type `int` from String "seis": not a valid `int` value
UnexpectedEndOfInputException: Unexpected end-of-input within/between Object entries
Observa:
- Las propiedades del bean salen en orden alfabético (
email,nombre,telefonos,vip): es un cambio de Jackson 3. El getterisVip()genera la propiedadvip. - Con el mapper por defecto, la propiedad desconocida
colorse ignoró. El mapper estricto, conFAIL_ON_UNKNOWN_PROPERTIES, la rechazó conUnrecognizedPropertyException. - No hay ningún
throwsenmain: las excepciones de Jackson 3 no son comprobadas. Todas heredan deJacksonException; las de datos (DatabindException) y las de sintaxis (StreamReadException) tienen subclases específicas.
| Método | Uso |
|---|---|
writeValueAsString(obj) | Objeto → String JSON |
writeValue(File/OutputStream/Writer, obj) | Objeto → fichero o flujo |
writeValueAsBytes(obj) | Objeto → byte[] (UTF-8) |
readValue(String/File/InputStream, Clase.class) | JSON → objeto |
readValue(..., new TypeReference<List<X>>() {}) | JSON → tipo genérico |
readTree(json) | JSON → árbol JsonNode |
treeToValue(nodo, Clase.class) / valueToTree(obj) | Árbol ↔ objeto |
convertValue(obj, Clase.class) | Objeto → objeto (por ejemplo, Map → record) |
💡 Consejo: Crea un único
JsonMapperpara toda la aplicación (por ejemplo, en una constantestatic finalo como bean de Spring) y reutilízalo: construirlo es costoso y, al ser inmutable en Jackson 3, es seguro compartirlo entre hilos. Si necesitas una variante,mapper.rebuild()...build()crea otra a partir de la existente.
Anotaciones
package es.curso.json;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.json.JsonMapper;
public class JsonAnotaciones {
@JsonPropertyOrder({"id", "nombre", "precio"}) // orden de las propiedades en el JSON
@JsonInclude(JsonInclude.Include.NON_NULL) // no escribir los campos null
@JsonIgnoreProperties(ignoreUnknown = true) // tolerar propiedades desconocidas al leer
public record Producto(
@JsonProperty("producto_id") int id, // nombre distinto en el JSON
String nombre,
String descripcion,
BigDecimal precio,
@JsonFormat(pattern = "dd/MM/yyyy") LocalDate fechaAlta,
@JsonAlias({"descatalogado", "baja"}) boolean retirado, // acepta varios nombres al leer
@JsonIgnore String notaInterna) { } // nunca sale ni entra en el JSON
public record Evento(String tipo, Instant cuando, LocalDateTime local,
@JsonFormat(shape = JsonFormat.Shape.STRING) BigDecimal importe) { }
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build();
Producto p = new Producto(7, "Teclado mecánico", null, new BigDecimal("79.90"),
LocalDate.of(2023, 2, 14), false, "proveedor nuevo");
String json = mapper.writeValueAsString(p);
System.out.println(json);
String entrada = """
{"producto_id": 14, "nombre": "Altavoz Inteligente", "precio": 49.00,
"fechaAlta": "01/10/2021", "descatalogado": true, "stock": 0, "notaInterna": "x"}
""";
Producto leido = mapper.readValue(entrada, Producto.class);
System.out.println("Leído: " + leido);
// Fechas de java.time: soportadas de serie en Jackson 3, en formato ISO-8601
Evento ev = new Evento("PAGO", Instant.parse("2026-10-04T10:15:30Z"),
LocalDateTime.of(2026, 10, 4, 12, 15, 30), new BigDecimal("1499.00"));
System.out.println(mapper.writeValueAsString(ev));
System.out.println("Vuelta: " + mapper.readValue(mapper.writeValueAsString(ev), Evento.class));
}
}
{
"producto_id" : 7,
"nombre" : "Teclado mecánico",
"precio" : 79.90,
"fechaAlta" : "14/02/2023",
"retirado" : false
}
Leído: Producto[id=14, nombre=Altavoz Inteligente, descripcion=null, precio=49.00, fechaAlta=2021-10-01, retirado=true, notaInterna=null]
{
"tipo" : "PAGO",
"cuando" : "2026-10-04T10:15:30Z",
"local" : "2026-10-04T12:15:30",
"importe" : "1499.00"
}
Vuelta: Evento[tipo=PAGO, cuando=2026-10-04T10:15:30Z, local=2026-10-04T12:15:30, importe=1499.00]
| Anotación | Dónde | Efecto |
|---|---|---|
@JsonProperty("nombre_json") | Campo, componente, getter | Nombre de la propiedad en JSON |
@JsonAlias({"a", "b"}) | Campo, componente | Nombres alternativos aceptados al leer |
@JsonIgnore | Campo, componente | Excluye la propiedad en ambos sentidos |
@JsonIgnoreProperties(ignoreUnknown = true) | Clase | Tolera propiedades desconocidas (útil si activas el modo estricto globalmente) |
@JsonInclude(Include.NON_NULL) | Clase, campo | Omite los valores null (NON_EMPTY omite también vacíos) |
@JsonPropertyOrder({...}) | Clase | Orden de las propiedades al escribir |
@JsonFormat(pattern = "dd/MM/yyyy") | Fecha | Formato de fechas; shape = STRING escribe números como texto |
@JsonCreator / @JsonValue | Constructor / método | Constructor a usar al leer / valor único con el que se representa el objeto |
Fechas java.time en JSON
En Jackson 3 los tipos de java.time funcionan sin configurar nada, y se escriben como texto ISO-8601: "2026-10-04" (LocalDate), "2026-10-04T12:15:30" (LocalDateTime), "2026-10-04T10:15:30Z" (Instant). Es el formato recomendado para APIs: no depende del idioma y lo entiende cualquier lenguaje.
⚠️ Importante: En Jackson 2 necesitas añadir
com.fasterxml.jackson.datatype:jackson-datatype-jsr310, registrarJavaTimeModuley desactivarSerializationFeature.WRITE_DATES_AS_TIMESTAMPS; si no, unaLocalDatefalla o sale como el array[2026,10,4]. Es una de las dudas más buscadas sobre Jackson y te la encontrarás en código existente. Fíjate también en queimportesalió entre comillas por@JsonFormat(shape = STRING): es habitual con importes para que JavaScript no pierda precisión con números grandes.
Árboles JsonNode y tipos genéricos
Cuando el JSON no tiene una estructura fija, o solo necesitas un par de valores de un documento grande, no merece la pena crear clases: puedes navegar por un árbol de nodos.
package es.curso.json;
import java.util.List;
import java.util.Map;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
public class JsonArbol {
public record Linea(String producto, int cantidad, double precio) { }
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
String json = """
{
"pedido": 5001,
"cliente": {"nombre": "Ana Ruiz", "ciudad": "Alicante"},
"estado": "ENTREGADO",
"lineas": [
{"producto": "Portátil UltraBook 14", "cantidad": 1, "precio": 899.00},
{"producto": "Ratón inalámbrico", "cantidad": 2, "precio": 19.90}
]
}
""";
// 1. Modelo de árbol: navegar sin clases Java
JsonNode raiz = mapper.readTree(json);
System.out.println("Pedido: " + raiz.get("pedido").asInt());
System.out.println("Cliente: " + raiz.get("cliente").get("nombre").asString());
System.out.println("Ciudad (con path): " + raiz.path("cliente").path("ciudad").asString());
System.out.println("Campo inexistente con path: '" + raiz.path("descuento").asString("sin descuento") + "'");
System.out.println("JSON Pointer: " + raiz.at("/lineas/1/producto").asString());
double total = 0;
for (JsonNode l : raiz.get("lineas")) {
total += l.get("cantidad").asInt() * l.get("precio").asDouble();
}
System.out.printf("Total calculado: %.2f%n", total);
System.out.println("¿estado es texto? " + raiz.get("estado").isString() + ", ¿lineas es array? " + raiz.get("lineas").isArray());
// 2. Modificar el árbol y crear nodos nuevos
ObjectNode obj = (ObjectNode) raiz;
obj.put("estado", "FACTURADO");
obj.put("total", Math.round(total * 100) / 100.0);
ArrayNode etiquetas = obj.putArray("etiquetas");
etiquetas.add("urgente").add("regalo");
obj.remove("cliente");
System.out.println("Árbol modificado: " + mapper.writeValueAsString(obj));
// 3. Listas y mapas genéricos: TypeReference conserva el tipo genérico
String lineasJson = raiz.get("lineas").toString();
List<Linea> lineas = mapper.readValue(lineasJson, new TypeReference<List<Linea>>() { });
System.out.println("List<Linea>: " + lineas.getFirst().producto() + " ... (" + lineas.size() + " líneas)");
// Sin TypeReference se obtiene una lista de mapas, no de Linea:
List<?> sinTipo = mapper.readValue(lineasJson, List.class);
System.out.println("Sin TypeReference, el elemento es: " + sinTipo.getFirst().getClass().getSimpleName());
Map<String, Integer> stock = mapper.readValue("{\"ratón\":200,\"teclado\":60}", new TypeReference<Map<String, Integer>>() { });
System.out.println("Map<String,Integer>: " + stock);
// 4. Convertir entre árbol y objetos
Linea primera = mapper.treeToValue(raiz.get("lineas").get(0), Linea.class);
JsonNode comoArbol = mapper.valueToTree(new Linea("Webcam HD", 3, 45.0));
System.out.println("treeToValue: " + primera + " | valueToTree: " + comoArbol);
}
}
Pedido: 5001
Cliente: Ana Ruiz
Ciudad (con path): Alicante
Campo inexistente con path: 'sin descuento'
JSON Pointer: Ratón inalámbrico
Total calculado: 938.80
¿estado es texto? true, ¿lineas es array? true
Árbol modificado: {"pedido":5001,"estado":"FACTURADO","lineas":[{"producto":"Portátil UltraBook 14","cantidad":1,"precio":899.0},{"producto":"Ratón inalámbrico","cantidad":2,"precio":19.9}],"total":938.8,"etiquetas":["urgente","regalo"]}
List<Linea>: Portátil UltraBook 14 ... (2 líneas)
Sin TypeReference, el elemento es: LinkedHashMap
Map<String,Integer>: {ratón=200, teclado=60}
treeToValue: Linea[producto=Portátil UltraBook 14, cantidad=1, precio=899.0] | valueToTree: {"producto":"Webcam HD","cantidad":3,"precio":45.0}
Método de JsonNode | Comportamiento |
|---|---|
get("campo") / get(indice) | Nodo hijo, o null si no existe |
path("campo") | Nodo hijo, o un nodo “ausente” (nunca null): permite encadenar sin NullPointerException |
at("/a/0/b") | Navega con una expresión JSON Pointer |
asString(), asInt(), asDouble(), asBoolean() | Valor convertido (con variantes con valor por defecto) |
isString(), isNumber(), isArray(), isObject(), isNull() | Tipo del nodo |
ObjectNode.put, putArray, putObject, remove; ArrayNode.add | Modificar el árbol |
⚠️ Importante: Jackson 3 renombró los métodos y clases “Text” a “String”:
asText()→asString(),textValue()→stringValue(),isTextual()→isString(),TextNode→StringNode. Si copias código de Jackson 2, ese será uno de los cambios que tengas que hacer.
📘 Concepto: ¿Por qué
TypeReference? Por el borrado de tipos del módulo 02: en tiempo de ejecuciónList<Linea>.classno existe, soloList.class, y Jackson no sabe de qué tipo son los elementos (por eso creaLinkedHashMap). La subclase anónimanew TypeReference<List<Linea>>() { }“captura” el tipo genérico completo en su declaración, que sí se conserva y Jackson puede leer por reflexión.
¿Y XML?
Para XML hay dos opciones principales. La estándar de Jakarta EE es JAXB (jakarta.xml.bind, con anotaciones @XmlRootElement, @XmlElement), que salió del JDK en Java 11 y se añade como dependencia. La otra es el módulo Jackson XML (tools.jackson.dataformat:jackson-dataformat-xml), que reutiliza tus clases y anotaciones de Jackson: solo cambias el mapper.
Con la dependencia tools.jackson.dataformat:jackson-dataformat-xml (su versión la fija el mismo BOM), el núcleo de la clase EnXml del proyecto es:
public record Producto(int id, String nombre, BigDecimal precio) { }
public record Catalogo(String empresa, List<Producto> productos) { }
// Mismas clases, mismas anotaciones: solo cambia el mapper (tools.jackson.dataformat.xml.XmlMapper)
XmlMapper xml = XmlMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build();
Catalogo c = new Catalogo("EMPRESA S.A.", List.of(
new Producto(6, "Ratón inalámbrico", new BigDecimal("19.90")),
new Producto(9, "Monitor 24 FHD", new BigDecimal("139.00"))));
String texto = xml.writeValueAsString(c);
System.out.println(texto);
Catalogo leido = xml.readValue(texto, Catalogo.class);
System.out.println("Leído de XML: " + leido.productos().size() + " productos, ¿iguales? " + leido.equals(c));
<Catalogo>
<empresa>EMPRESA S.A.</empresa>
<productos>
<productos>
<id>6</id>
<nombre>Ratón inalámbrico</nombre>
<precio>19.90</precio>
</productos>
<productos>
<id>9</id>
<nombre>Monitor 24 FHD</nombre>
<precio>139.00</precio>
</productos>
</productos>
</Catalogo>
Leído de XML: 2 productos, ¿iguales? true
Las listas se envuelven por defecto en un elemento con el nombre de la propiedad; con @JacksonXmlElementWrapper y @JacksonXmlProperty puedes ajustar los nombres (por ejemplo, <productos><producto>...). Otras bibliotecas de JSON que encontrarás son Gson (de Google) y el estándar Jakarta JSON Binding (JSON-B, implementado por Yasson).
11. Ejemplo completo: exportar e importar el catálogo de productos
Vamos a juntarlo todo. Partimos de algunos productos de la tabla productos de la base de datos EMPRESA (la misma del Curso de Bases de Datos; en el módulo 12 los leerás directamente de MySQL con JDBC) y vamos a:
- Exportarlos a CSV (con
;como separador y comillas cuando haga falta) y a JSON (con un envoltorio de metadatos). - Importar el CSV validando cada línea y acumulando los errores sin detener la importación.
- Importar el JSON y hacer un pequeño informe.
package es.curso.json;
import java.io.BufferedWriter;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.json.JsonMapper;
/** Exporta e importa el catálogo de productos de la base de datos EMPRESA en CSV y en JSON. */
public class Catalogo {
public record Producto(
int id,
String nombre,
String descripcion,
String categoria,
BigDecimal precio,
int stock,
@JsonFormat(pattern = "yyyy-MM-dd") LocalDate fechaAlta,
@JsonProperty("descatalogado") boolean descatalogado) {
public Producto { // constructor compacto: validación
if (precio.signum() < 0) throw new IllegalArgumentException("precio negativo en " + nombre);
if (stock < 0) throw new IllegalArgumentException("stock negativo en " + nombre);
}
}
/** Envoltorio del fichero JSON: metadatos + lista de productos. */
public record ExportacionCatalogo(String empresa, LocalDate fecha, int total, List<Producto> productos) { }
static final List<Producto> PRODUCTOS = List.of(
new Producto(1, "Portátil UltraBook 14", "Portátil ligero de 14 pulgadas, 16 GB RAM", "Portátiles",
new BigDecimal("899.00"), 25, LocalDate.of(2024, 1, 10), false),
new Producto(2, "Portátil Gaming 16", "Portátil de 16 pulgadas con gráfica dedicada", "Portátiles",
new BigDecimal("1499.00"), 10, LocalDate.of(2024, 3, 15), false),
new Producto(6, "Ratón inalámbrico", "Ratón óptico inalámbrico", "Ratones y teclados",
new BigDecimal("19.90"), 200, LocalDate.of(2022, 2, 1), false),
new Producto(8, "Pack teclado y ratón", "Conjunto inalámbrico de teclado y ratón", "Ratones y teclados",
new BigDecimal("34.50"), 0, LocalDate.of(2022, 11, 10), false),
new Producto(10, "Monitor 27 4K", "Monitor de 27 pulgadas 4K", "Monitores",
new BigDecimal("329.00"), 12, LocalDate.of(2024, 2, 20), false),
new Producto(14, "Altavoz Inteligente", "Altavoz con asistente de voz", "Audio",
new BigDecimal("49.00"), 0, LocalDate.of(2021, 10, 1), true),
new Producto(15, "Silla Ergonómica", "Silla de oficina con soporte \"lumbar\"; regulable", "Mobiliario",
new BigDecimal("249.00"), 20, LocalDate.of(2023, 3, 3), false));
// ---------------------------------------------------------------- CSV
static final String CABECERA = "id;nombre;descripcion;categoria;precio;stock;fecha_alta;descatalogado";
/** Entrecomilla un campo si contiene ; " o saltos de línea (regla de RFC 4180, con ; como separador). */
static String campoCsv(String s) {
if (s == null) return "";
if (s.contains(";") || s.contains("\"") || s.contains("\n")) {
return "\"" + s.replace("\"", "\"\"") + "\"";
}
return s;
}
static void exportarCsv(List<Producto> productos, Path destino) throws IOException {
try (BufferedWriter w = Files.newBufferedWriter(destino, StandardCharsets.UTF_8)) {
w.write(CABECERA);
w.newLine();
for (Producto p : productos) {
w.write(String.join(";", String.valueOf(p.id()), campoCsv(p.nombre()), campoCsv(p.descripcion()),
campoCsv(p.categoria()), p.precio().toPlainString(), String.valueOf(p.stock()),
p.fechaAlta().toString(), p.descatalogado() ? "S" : "N"));
w.newLine();
}
}
}
/** Divide una línea CSV respetando los campos entrecomillados. */
static List<String> separar(String linea) {
List<String> campos = new ArrayList<>();
StringBuilder actual = new StringBuilder();
boolean entreComillas = false;
for (int i = 0; i < linea.length(); i++) {
char c = linea.charAt(i);
if (entreComillas) {
if (c == '"' && i + 1 < linea.length() && linea.charAt(i + 1) == '"') { actual.append('"'); i++; }
else if (c == '"') entreComillas = false;
else actual.append(c);
} else if (c == '"') {
entreComillas = true;
} else if (c == ';') {
campos.add(actual.toString());
actual.setLength(0);
} else {
actual.append(c);
}
}
campos.add(actual.toString());
return campos;
}
record ResultadoImportacion(List<Producto> productos, List<String> errores) { }
static ResultadoImportacion importarCsv(Path origen) throws IOException {
List<Producto> ok = new ArrayList<>();
List<String> errores = new ArrayList<>();
List<String> lineas = Files.readAllLines(origen, StandardCharsets.UTF_8);
if (lineas.isEmpty() || !lineas.getFirst().equals(CABECERA)) {
throw new IOException("Cabecera inesperada en " + origen);
}
for (int n = 1; n < lineas.size(); n++) {
String linea = lineas.get(n);
if (linea.isBlank()) continue;
try {
List<String> c = separar(linea);
if (c.size() != 8) throw new IllegalArgumentException("se esperaban 8 campos y hay " + c.size());
ok.add(new Producto(Integer.parseInt(c.get(0)), c.get(1), c.get(2), c.get(3),
new BigDecimal(c.get(4)), Integer.parseInt(c.get(5)), LocalDate.parse(c.get(6)),
c.get(7).equals("S")));
} catch (RuntimeException e) { // NumberFormat, DateTimeParse, IllegalArgument...
errores.add("línea " + (n + 1) + ": " + e.getMessage());
}
}
return new ResultadoImportacion(ok, errores);
}
// ---------------------------------------------------------------- JSON
static final JsonMapper MAPPER = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build();
static void exportarJson(List<Producto> productos, Path destino) {
ExportacionCatalogo exp = new ExportacionCatalogo("EMPRESA S.A.", LocalDate.of(2026, 10, 4), productos.size(), productos);
MAPPER.writeValue(destino.toFile(), exp);
}
static ExportacionCatalogo importarJson(Path origen) {
return MAPPER.readValue(origen.toFile(), ExportacionCatalogo.class);
}
public static void main(String[] args) throws IOException {
Path dir = Files.createDirectories(Path.of("salida"));
Path csv = dir.resolve("productos.csv");
Path json = dir.resolve("productos.json");
// 1. Exportar
exportarCsv(PRODUCTOS, csv);
exportarJson(PRODUCTOS, json);
System.out.println("CSV (" + Files.size(csv) + " bytes):");
Files.readAllLines(csv).stream().limit(4).forEach(l -> System.out.println(" " + l));
System.out.println(" ...");
Files.readAllLines(csv).stream().filter(l -> l.startsWith("15;")).forEach(l -> System.out.println(" " + l));
System.out.println("JSON (" + Files.size(json) + " bytes), primeras líneas:");
Files.readAllLines(json).stream().limit(16).forEach(l -> System.out.println(" " + l));
// 2. Importar el CSV (añadimos a mano dos líneas erróneas para probar la validación)
Files.writeString(csv, "99;Cable HDMI;;Periféricos;cinco;10;2025-01-01;N\n"
+ "98;Funda;Funda;Portátiles;12.00;-3;2025-02-30;N\n", java.nio.file.StandardOpenOption.APPEND);
ResultadoImportacion r = importarCsv(csv);
System.out.println("Importados del CSV: " + r.productos().size() + ", errores: " + r.errores().size());
r.errores().forEach(e -> System.out.println(" " + e));
System.out.println("¿El CSV conserva los datos? " + r.productos().equals(PRODUCTOS));
// 3. Importar el JSON y hacer un pequeño informe
ExportacionCatalogo imp = importarJson(json);
System.out.println("JSON importado: " + imp.total() + " productos de " + imp.empresa() + " a fecha " + imp.fecha()
+ "; ¿iguales? " + imp.productos().equals(PRODUCTOS));
Map<String, BigDecimal> valorPorCategoria = imp.productos().stream()
.filter(p -> !p.descatalogado())
.collect(Collectors.groupingBy(Producto::categoria, TreeMap::new,
Collectors.reducing(BigDecimal.ZERO, p -> p.precio().multiply(BigDecimal.valueOf(p.stock())), BigDecimal::add)));
System.out.println("Valor del stock por categoría: " + valorPorCategoria);
Producto masCaro = imp.productos().stream().max(Comparator.comparing(Producto::precio)).orElseThrow();
System.out.println("Más caro: " + masCaro.nombre() + " (" + masCaro.precio() + " €)");
// 4. Solo la lista, sin envoltorio: TypeReference
String soloLista = MAPPER.writeValueAsString(PRODUCTOS.subList(0, 2));
List<Producto> dos = MAPPER.readValue(soloLista, new TypeReference<List<Producto>>() { });
System.out.println("Lista sin envoltorio: " + dos.stream().map(Producto::nombre).toList());
}
}
docker run --rm -v "$PWD:/w" -v java-m2:/root/.m2 -w /w maven:3.9-eclipse-temurin-25 mvn -q compile exec:java -Dexec.mainClass=es.curso.json.Catalogo
CSV (739 bytes):
id;nombre;descripcion;categoria;precio;stock;fecha_alta;descatalogado
1;Portátil UltraBook 14;Portátil ligero de 14 pulgadas, 16 GB RAM;Portátiles;899.00;25;2024-01-10;N
2;Portátil Gaming 16;Portátil de 16 pulgadas con gráfica dedicada;Portátiles;1499.00;10;2024-03-15;N
6;Ratón inalámbrico;Ratón óptico inalámbrico;Ratones y teclados;19.90;200;2022-02-01;N
...
15;Silla Ergonómica;"Silla de oficina con soporte ""lumbar""; regulable";Mobiliario;249.00;20;2023-03-03;N
JSON (1872 bytes), primeras líneas:
{
"empresa" : "EMPRESA S.A.",
"fecha" : "2026-10-04",
"total" : 7,
"productos" : [ {
"id" : 1,
"nombre" : "Portátil UltraBook 14",
"descripcion" : "Portátil ligero de 14 pulgadas, 16 GB RAM",
"categoria" : "Portátiles",
"precio" : 899.00,
"stock" : 25,
"fechaAlta" : "2024-01-10",
"descatalogado" : false
}, {
"id" : 2,
"nombre" : "Portátil Gaming 16",
Importados del CSV: 7, errores: 2
línea 9: Character c is neither a decimal digit number, decimal point, nor "e" notation exponential mark.
línea 10: Text '2025-02-30' could not be parsed: Invalid date 'FEBRUARY 30'
¿El CSV conserva los datos? true
JSON importado: 7 productos de EMPRESA S.A. a fecha 2026-10-04; ¿iguales? true
Valor del stock por categoría: {Mobiliario=4980.00, Monitores=3948.00, Portátiles=37465.00, Ratones y teclados=3980.00}
Más caro: Portátil Gaming 16 (1499.00 €)
Lista sin envoltorio: [Portátil UltraBook 14, Portátil Gaming 16]
Puntos de diseño que merece la pena destacar:
- El record valida sus datos en el constructor compacto, y eso funciona tanto si los crea el código, como el importador CSV o Jackson (que usa el constructor del record).
- CSV con comillas: el producto 15 tiene
;y comillas en la descripción; el exportador lo entrecomilla y duplica las comillas internas, y el importador lo reconstruye. Por eso la comparación final datrue. Partir consplit(";")lo habría roto. - Importación tolerante: cada línea se procesa en su propio
try; los errores se acumulan con el número de línea y la importación continúa. La línea 10 tenía dos errores (stock negativo y fecha imposible) y se informó del primero que se detectó. BigDecimalpara los precios: se exporta contoPlainString()(sin notación científica) y se conserva la escala (899.00) en CSV y JSON.- El envoltorio
ExportacionCatalogoañade metadatos (empresa, fecha, total), una práctica habitual en ficheros de intercambio.
💡 Consejo: Para CSV real (campos con saltos de línea, distintos separadores, cabeceras variables) usa una biblioteca: Jackson tiene el módulo
jackson-dataformat-csv(CsvMapper), y también son populares OpenCSV y Apache Commons CSV. Escribir el analizador a mano, como aquí, es un buen ejercicio para entender el formato.
12. Buenas prácticas
| Práctica | Motivo |
|---|---|
try-con-recursos para todo lo que se abre (flujos, Files.lines/walk/list, canales, WatchService, FileSystem) | Evita fugas de descriptores y pérdida de datos del búfer |
| Codificación explícita: UTF-8 | Los ficheros se leen igual en cualquier máquina |
Path y Files en lugar de java.io.File | Mejores errores, más funciones, rutas portables |
Construye rutas con resolve, nunca concatenando | Funciona en Windows y Linux |
Trata las excepciones concretas (NoSuchFileException…) | Mensajes de error útiles; evita comprobar y luego actuar |
Valida las rutas que vienen de fuera (normalize + startsWith) | Evita path traversal y Zip Slip |
| No uses la serialización nativa para intercambiar datos | Riesgos de seguridad y fragilidad; usa JSON |
Un único JsonMapper reutilizado | Rendimiento y coherencia |
| Usa records como DTO para JSON | Inmutables, concisos, con validación en el constructor |
Fechas en ISO-8601 y dinero en BigDecimal | Interoperabilidad y exactitud |
| Decide conscientemente si tolerar propiedades desconocidas | Jackson 3 las ignora por defecto: activa FAIL_ON_UNKNOWN_PROPERTIES si quieres detectar erratas |
13. Ejercicios
Ejercicio 1: Los ficheros más grandes
Escribe un programa que, dado un directorio, muestre los 5 ficheros más grandes de todo el árbol (con su ruta relativa y su tamaño en formato legible: B, KB o MB) y el tamaño total. Crea antes un árbol de prueba con ficheros de distintos tamaños.
Ver solución
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.stream.Stream;
public class Ej1Grandes {
record Fichero(Path ruta, long bytes) { }
static long tamano(Path p) {
try {
return Files.size(p);
} catch (IOException e) {
throw new UncheckedIOException(e); // los streams no admiten excepciones comprobadas
}
}
static String legible(long bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0);
return String.format("%.1f MB", bytes / (1024.0 * 1024));
}
public static void main(String[] args) throws IOException {
// Árbol de prueba
Path raiz = Path.of("disco");
String[][] datos = {{"fotos/playa.jpg", "2500000"}, {"fotos/perfil.png", "180000"},
{"videos/boda.mp4", "9800000"}, {"docs/tfg.pdf", "1300000"}, {"docs/notas.txt", "900"},
{"musica/cancion.mp3", "4100000"}, {"musica/lista.m3u", "300"}, {"backup.zip", "6400000"}};
for (String[] d : datos) {
Path p = raiz.resolve(d[0]);
Files.createDirectories(p.getParent());
Files.write(p, new byte[Integer.parseInt(d[1])]);
}
try (Stream<Path> st = Files.walk(raiz)) {
System.out.println("Los 5 ficheros más grandes de " + raiz + ":");
st.filter(Files::isRegularFile)
.map(p -> new Fichero(p, tamano(p)))
.sorted(Comparator.comparingLong(Fichero::bytes).reversed())
.limit(5)
.forEach(f -> System.out.printf(" %9s %s%n", legible(f.bytes()), raiz.relativize(f.ruta())));
}
try (Stream<Path> st = Files.walk(raiz)) {
long total = st.filter(Files::isRegularFile).mapToLong(Ej1Grandes::tamano).sum();
System.out.println("Total: " + legible(total));
}
}
}
Los 5 ficheros más grandes de disco:
9,3 MB videos/boda.mp4
6,1 MB backup.zip
3,9 MB musica/cancion.mp3
2,4 MB fotos/playa.jpg
1,2 MB docs/tfg.pdf
Total: 23,2 MB
El truco para usar Files.size (que lanza IOException) dentro de una lambda es envolverlo en un método que relanza UncheckedIOException. El record Fichero evita llamar a Files.size varias veces por fichero al ordenar. Cada stream de Files.walk se abre en su propio try.
Ejercicio 2: Rotación de logs
Implementa escribir(Path log, String linea), que añade una línea a app.log pero, si el fichero supera 200 bytes, antes lo rota: comprime el actual como app.log.1.gz, desplaza los anteriores (.1.gz → .2.gz → .3.gz), borra el más antiguo y vacía app.log. Escribe 40 líneas y muestra los ficheros resultantes.
Ver solución
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.stream.Stream;
import java.util.zip.GZIPOutputStream;
public class Ej2Rotacion {
static final long MAXIMO = 200; // bytes (pequeño para la prueba)
static final int CONSERVAR = 3; // app.log.1.gz ... app.log.3.gz
static void rotarSiHaceFalta(Path log) throws IOException {
if (!Files.exists(log) || Files.size(log) < MAXIMO) return;
Path dir = log.getParent();
String base = log.getFileName().toString();
// 1. Borrar el más antiguo y desplazar los demás: .2 -> .3, .1 -> .2
Files.deleteIfExists(dir.resolve(base + "." + CONSERVAR + ".gz"));
for (int i = CONSERVAR - 1; i >= 1; i--) {
Path origen = dir.resolve(base + "." + i + ".gz");
if (Files.exists(origen)) {
Files.move(origen, dir.resolve(base + "." + (i + 1) + ".gz"), StandardCopyOption.REPLACE_EXISTING);
}
}
// 2. Comprimir el actual como .1.gz y vaciarlo
try (InputStream in = Files.newInputStream(log);
OutputStream out = new GZIPOutputStream(Files.newOutputStream(dir.resolve(base + ".1.gz")))) {
in.transferTo(out);
}
Files.writeString(log, "", StandardOpenOption.TRUNCATE_EXISTING);
}
static void escribir(Path log, String linea) throws IOException {
rotarSiHaceFalta(log);
Files.writeString(log, linea + System.lineSeparator(), StandardOpenOption.CREATE, StandardOpenOption.APPEND);
}
public static void main(String[] args) throws IOException {
Path dir = Files.createDirectories(Path.of("logs"));
try (Stream<Path> st = Files.list(dir)) { // empezamos limpios
for (Path p : st.toList()) Files.delete(p);
}
Path log = dir.resolve("app.log");
for (int i = 1; i <= 40; i++) {
escribir(log, String.format("2026-10-04 10:%02d INFO evento número %d", i, i));
}
try (Stream<Path> st = Files.list(dir)) {
st.sorted().forEach(p -> {
try {
System.out.printf("%-12s %4d bytes%n", p.getFileName(), Files.size(p));
} catch (IOException e) {
throw new RuntimeException(e);
}
});
}
System.out.println("Última línea del log actual: " + Files.readAllLines(log).getLast());
}
}
app.log 120 bytes
app.log.1.gz 87 bytes
app.log.2.gz 89 bytes
app.log.3.gz 87 bytes
Última línea del log actual: 2026-10-04 10:40 INFO evento número 40
El orden del desplazamiento es importante: se empieza por el más antiguo (.2 → .3 antes que .1 → .2) para no sobrescribir ninguno. Es la estrategia que siguen herramientas como logrotate o las bibliotecas de logging (Logback, Log4j 2) con su RollingFileAppender.
Ejercicio 3: Las palabras más frecuentes
Lee un fichero de texto con Files.lines y muestra cuántas palabras distintas contiene y las 5 más frecuentes, ignorando mayúsculas, signos de puntuación, palabras de una letra, una lista de palabras vacías (de, la, el, en, y…) y las tildes (que “más” y “mas” cuenten como la misma). Muestra también la longitud de la línea más larga.
Ver solución
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.Normalizer;
import java.util.Arrays;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class Ej3Palabras {
static final Set<String> VACIAS = Set.of("de", "la", "el", "en", "y", "a", "que", "los", "las", "un", "una", "con", "del", "se", "su", "por");
static String quitarTildes(String s) {
return Normalizer.normalize(s, Normalizer.Form.NFD).replaceAll("\\p{M}", "");
}
public static void main(String[] args) throws IOException {
Path texto = Path.of("quijote.txt");
Files.writeString(texto, """
En un lugar de la Mancha, de cuyo nombre no quiero acordarme, no ha mucho tiempo
que vivía un hidalgo de los de lanza en astillero, adarga antigua, rocín flaco y galgo corredor.
Una olla de algo más vaca que carnero, salpicón las más noches, duelos y quebrantos los sábados,
lentejas los viernes, algún palomino de añadidura los domingos, consumían las tres partes de su hacienda.
El resto della concluían sayo de velarte, calzas de velludo para las fiestas, con sus pantuflos de lo mismo.
Frisaba la edad de nuestro hidalgo con los cincuenta años; era de complexión recia, seco de carnes.
""");
try (Stream<String> lineas = Files.lines(texto)) {
Map<String, Long> frecuencias = lineas
.flatMap(l -> Arrays.stream(l.toLowerCase().split("[^\\p{L}]+"))) // separa por lo que no sea letra
.filter(p -> p.length() > 1 && !VACIAS.contains(p))
.map(Ej3Palabras::quitarTildes)
.collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
System.out.println("Palabras distintas (sin vacías): " + frecuencias.size());
System.out.println("Las 5 más frecuentes:");
frecuencias.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed().thenComparing(Map.Entry.comparingByKey()))
.limit(5)
.forEach(e -> System.out.printf(" %-10s %d%n", e.getKey(), e.getValue()));
}
try (Stream<String> lineas = Files.lines(texto)) {
System.out.println("Línea más larga: " + lineas.mapToInt(String::length).max().orElse(0) + " caracteres");
}
}
}
Palabras distintas (sin vacías): 63
Las 5 más frecuentes:
hidalgo 2
mas 2
no 2
acordarme 1
adarga 1
Línea más larga: 108 caracteres
[^\\p{L}]+ separa por cualquier secuencia de caracteres que no sean letras Unicode (así se conservan la ñ y las vocales con tilde). Para quitar las tildes, Normalizer descompone cada letra acentuada en letra base + marca diacrítica (forma NFD) y la expresión \\p{M} elimina las marcas. El desempate por orden alfabético hace la salida determinista. Un stream de Files.lines solo se puede recorrer una vez: por eso se abre otro para la línea más larga.
Ejercicio 4: Copia de seguridad incremental
Con Files.walkFileTree, copia recursivamente un directorio de proyecto en una carpeta de copia, excluyendo target, .git y node_modules, y copiando solo los ficheros nuevos o modificados (distinto tamaño o fecha de modificación más reciente). Ejecuta la copia dos veces, modificando un fichero entre ambas, y muestra cuántos se copiaron cada vez.
Ver solución
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Set;
import java.util.stream.Stream;
public class Ej4Copia {
static final Set<String> EXCLUIR = Set.of("target", ".git", "node_modules");
/** Copia recursiva de origen en destino, saltando carpetas excluidas y ficheros que no han cambiado. */
static int[] copiar(Path origen, Path destino) throws IOException {
int[] contadores = new int[3]; // copiados, sin cambios, carpetas saltadas
Files.walkFileTree(origen, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
if (EXCLUIR.contains(dir.getFileName().toString())) {
contadores[2]++;
return FileVisitResult.SKIP_SUBTREE;
}
Files.createDirectories(destino.resolve(origen.relativize(dir)));
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path f, BasicFileAttributes attrs) throws IOException {
Path copia = destino.resolve(origen.relativize(f));
boolean igual = Files.exists(copia)
&& Files.size(copia) == attrs.size()
&& !Files.getLastModifiedTime(copia).toInstant().isBefore(attrs.lastModifiedTime().toInstant());
if (igual) {
contadores[1]++;
} else {
Files.copy(f, copia, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES);
contadores[0]++;
}
return FileVisitResult.CONTINUE;
}
});
return contadores;
}
public static void main(String[] args) throws IOException {
Path origen = Path.of("miapp");
for (String f : new String[]{"pom.xml", "src/Main.java", "src/util/Fechas.java", "target/miapp.jar", ".git/HEAD", "README.md"}) {
Path p = origen.resolve(f);
Files.createDirectories(p.getParent());
Files.writeString(p, "contenido de " + f);
}
Path destino = Path.of("copia-seguridad");
int[] r = copiar(origen, destino);
System.out.printf("1.ª copia: %d copiados, %d sin cambios, %d carpetas excluidas%n", r[0], r[1], r[2]);
Files.writeString(origen.resolve("src/Main.java"), "contenido modificado y más largo");
r = copiar(origen, destino);
System.out.printf("2.ª copia: %d copiados, %d sin cambios, %d carpetas excluidas%n", r[0], r[1], r[2]);
try (Stream<Path> st = Files.walk(destino)) {
System.out.println("Contenido de la copia: " + st.filter(Files::isRegularFile)
.map(p -> destino.relativize(p).toString()).sorted().toList());
}
}
}
Salida real en el sistema de ficheros del contenedor:
1.ª copia: 4 copiados, 0 sin cambios, 2 carpetas excluidas
2.ª copia: 1 copiados, 3 sin cambios, 2 carpetas excluidas
Contenido de la copia: [README.md, pom.xml, src/Main.java, src/util/Fechas.java]
La clave es origen.relativize(...) + destino.resolve(...), que traslada cada ruta de un árbol al otro, y COPY_ATTRIBUTES, que conserva la fecha de modificación para poder comparar en la siguiente ejecución. Un detalle real: al ejecutarlo sobre una carpeta compartida entre Windows y Docker, la segunda copia volvió a copiar los 4 ficheros, porque ese sistema de ficheros no conservó las fechas copiadas. En sistemas de ficheros así, compara el contenido (por ejemplo, con un hash SHA-256 de MessageDigest) o Files.mismatch(a, b) (Java 12), que devuelve -1 si dos ficheros son idénticos.
Ejercicio 5: Configuración en JSON con validación
Define con records la configuración de una aplicación (nombre, puerto, base de datos con URL, usuario y tamaño del pool, idiomas y timeout como Duration), cárgala de config.json con Jackson aplicando valores por defecto (pool 10, idiomas ["es"], timeout 30 s) y validaciones (puerto entre 1 y 65 535, URL que empiece por jdbc:), y detecta las erratas en los nombres de las claves. Guarda una versión modificada.
Ver solución
package es.curso.json;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.json.JsonMapper;
public class Ej5ConfigJson {
public record BaseDatos(String url, String usuario, int poolMaximo) {
public BaseDatos {
if (poolMaximo <= 0) poolMaximo = 10; // valor por defecto
if (url == null || !url.startsWith("jdbc:")) throw new IllegalArgumentException("url JDBC no válida: " + url);
}
}
public record Config(String nombre, int puerto, BaseDatos baseDatos, List<String> idiomas, Duration timeout) {
public Config {
if (puerto < 1 || puerto > 65535) throw new IllegalArgumentException("puerto fuera de rango: " + puerto);
idiomas = idiomas == null ? List.of("es") : List.copyOf(idiomas);
timeout = timeout == null ? Duration.ofSeconds(30) : timeout;
}
}
static final JsonMapper MAPPER = JsonMapper.builder()
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) // detecta erratas en las claves
.enable(SerializationFeature.INDENT_OUTPUT)
.build();
static Config cargar(Path p) {
try {
return MAPPER.readValue(p.toFile(), Config.class);
} catch (JacksonException e) {
// las excepciones del constructor del record llegan envueltas
Throwable causa = e.getCause() != null ? e.getCause() : e;
throw new IllegalStateException("Configuración no válida en " + p.getFileName() + ": " + causa.getMessage(), e);
}
}
public static void main(String[] args) throws Exception {
Path buena = Path.of("config.json");
Files.writeString(buena, """
{
"nombre": "Tienda Online",
"puerto": 8080,
"baseDatos": { "url": "jdbc:mysql://localhost:3306/empresa", "usuario": "curso" },
"timeout": "PT5S"
}
""");
Config c = cargar(buena);
System.out.println("Cargada: " + c);
System.out.println("Pool por defecto: " + c.baseDatos().poolMaximo() + ", idiomas por defecto: " + c.idiomas()
+ ", timeout en ms: " + c.timeout().toMillis());
// Guardar una versión modificada con "wither" manual sobre el record
Config nueva = new Config(c.nombre(), 9090, c.baseDatos(), List.of("es", "en", "ca"), c.timeout());
Path guardada = Path.of("config-nueva.json");
MAPPER.writeValue(guardada.toFile(), nueva);
System.out.println("Guardada:\n" + Files.readString(guardada));
String[] erroneas = {
"{\"nombre\":\"X\",\"puerto\":99999,\"baseDatos\":{\"url\":\"jdbc:h2:mem:\"}}",
"{\"nombre\":\"X\",\"puerto\":80,\"baseDatos\":{\"url\":\"mysql://sin-jdbc\"}}",
"{\"nombre\":\"X\",\"puerto\":80,\"puertoo\":81,\"baseDatos\":{\"url\":\"jdbc:h2:mem:\"}}"
};
for (String json : erroneas) {
Files.writeString(Path.of("config-mala.json"), json);
try {
cargar(Path.of("config-mala.json"));
} catch (IllegalStateException e) {
System.out.println("ERROR -> " + e.getMessage().lines().findFirst().orElse(""));
}
}
}
}
Cargada: Config[nombre=Tienda Online, puerto=8080, baseDatos=BaseDatos[url=jdbc:mysql://localhost:3306/empresa, usuario=curso, poolMaximo=10], idiomas=[es], timeout=PT5S]
Pool por defecto: 10, idiomas por defecto: [es], timeout en ms: 5000
Guardada:
{
"nombre" : "Tienda Online",
"puerto" : 9090,
"baseDatos" : {
"url" : "jdbc:mysql://localhost:3306/empresa",
"usuario" : "curso",
"poolMaximo" : 10
},
"idiomas" : [ "es", "en", "ca" ],
"timeout" : "PT5S"
}
ERROR -> Configuración no válida en config-mala.json: puerto fuera de rango: 99999
ERROR -> Configuración no válida en config-mala.json: url JDBC no válida: mysql://sin-jdbc
ERROR -> Configuración no válida en config-mala.json: Unrecognized property "puertoo" (class es.curso.json.Ej5ConfigJson$Config), not marked as ignorable (5 known properties: "nombre", "puerto", "baseDatos", "idiomas", "timeout")
Los valores que faltan en el JSON llegan al constructor como 0 o null, y el constructor compacto los sustituye por los valores por defecto. Las IllegalArgumentException del constructor llegan envueltas en una JacksonException, de ahí el getCause(). Como Jackson 3 ignora las propiedades desconocidas por defecto, activamos FAIL_ON_UNKNOWN_PROPERTIES para que la errata puertoo no pase desapercibida. Duration se lee y escribe en formato ISO-8601 (PT5S).
Ejercicio 6: De CSV a JSON agrupado
Lee un fichero CSV de clientes (id;nombre;ciudad;email) y genera dos JSON: uno con un objeto cuyas claves son las ciudades (ordenadas) y cuyos valores son las listas de clientes, y otro, construido con el modelo de árbol, con el total de clientes y, por cada ciudad, el número de clientes y sus nombres.
Ver solución
package es.curso.json;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
public class Ej6ClientesJson {
public record Cliente(int id, String nombre, String ciudad, String email) { }
public static void main(String[] args) throws Exception {
Path csv = Path.of("clientes.csv");
Files.writeString(csv, """
id;nombre;ciudad;email
1001;Distribuciones Levante;Valencia;compras@levante.es
1002;Comercial Ruiz;Alicante;info@cruiz.es
1003;Tecnología Norte;Bilbao;tn@norte.es
1004;Oficinas Martínez;Alicante;om@martinez.es
1005;Electro Sur;Sevilla;ventas@esur.es
1006;Papelería Centro;Madrid;pc@centro.es
1007;Muebles Torres;Valencia;mt@torres.es
""");
List<Cliente> clientes = Files.readAllLines(csv).stream()
.skip(1)
.map(l -> l.split(";"))
.map(c -> new Cliente(Integer.parseInt(c[0]), c[1], c[2], c[3]))
.toList();
// Opción 1: con objetos Java -> Map<String, List<Cliente>> serializado tal cual
Map<String, List<Cliente>> porCiudad = clientes.stream()
.collect(Collectors.groupingBy(Cliente::ciudad, TreeMap::new, Collectors.toList()));
JsonMapper mapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build();
Path json = Path.of("clientes-por-ciudad.json");
mapper.writeValue(json.toFile(), porCiudad);
System.out.println(Files.readString(json).lines().limit(12).collect(Collectors.joining("\n")));
System.out.println(" ...");
// Opción 2: con el modelo de árbol, para dar al JSON la forma exacta que queramos
ObjectNode raiz = mapper.createObjectNode();
raiz.put("totalClientes", clientes.size());
ArrayNode ciudades = raiz.putArray("ciudades");
porCiudad.forEach((ciudad, lista) -> {
ObjectNode n = ciudades.addObject();
n.put("ciudad", ciudad);
n.put("clientes", lista.size());
ArrayNode nombres = n.putArray("nombres");
lista.forEach(c -> nombres.add(c.nombre()));
});
System.out.println(JsonMapper.builder().build().writeValueAsString(raiz));
}
}
{
"Alicante" : [ {
"id" : 1002,
"nombre" : "Comercial Ruiz",
"ciudad" : "Alicante",
"email" : "info@cruiz.es"
}, {
"id" : 1004,
"nombre" : "Oficinas Martínez",
"ciudad" : "Alicante",
"email" : "om@martinez.es"
} ],
...
{"totalClientes":7,"ciudades":[{"ciudad":"Alicante","clientes":2,"nombres":["Comercial Ruiz","Oficinas Martínez"]},{"ciudad":"Bilbao","clientes":1,"nombres":["Tecnología Norte"]},{"ciudad":"Madrid","clientes":1,"nombres":["Papelería Centro"]},{"ciudad":"Sevilla","clientes":1,"nombres":["Electro Sur"]},{"ciudad":"Valencia","clientes":2,"nombres":["Distribuciones Levante","Muebles Torres"]}]}
Un Map se serializa como un objeto JSON cuyas claves son las del mapa; usar un TreeMap en el groupingBy hace que las ciudades salgan ordenadas. La opción 2 muestra cuándo compensa el árbol: cuando la forma del JSON de salida no coincide con ninguna clase del modelo y no merece la pena crear una solo para eso.
Ejercicio 7 (propuesto): Buscador de duplicados
Recorre un árbol de directorios y encuentra los ficheros con contenido idéntico aunque tengan distinto nombre. Agrupa primero por tamaño (los de distinto tamaño no pueden ser iguales) y, dentro de cada grupo, calcula un hash SHA-256 con MessageDigest. Muestra los grupos de duplicados y el espacio que se liberaría borrando todos menos uno.
Ejercicio 8 (propuesto): Importador automático
Combina WatchService y Jackson: un programa vigila la carpeta entrada/; cuando aparece un fichero .json con una lista de pedidos, lo valida, añade sus líneas a un fichero pedidos.csv acumulado y mueve el JSON a procesados/ (o a errores/ si no es válido, junto con un .txt con el motivo). Ten en cuenta que el aviso puede llegar antes de que el fichero termine de escribirse.
Ejercicio 9 (propuesto): Agenda con registros de tamaño fijo
Crea una agenda de contactos en un fichero binario con registros de tamaño fijo (RandomAccessFile): id, nombre (40 bytes), teléfono (15 bytes) y un indicador de borrado lógico. Implementa alta, consulta por posición, modificación, borrado lógico y una operación de “compactar” que reescribe el fichero sin los registros borrados.
Ejercicio 10 (propuesto): Empaquetador de proyectos
Escribe un programa que genere un ZIP de un proyecto Maven excluyendo target/, .idea/ y los ficheros *.class, que añada un MANIFEST.txt con la fecha, el número de ficheros y el tamaño total, y que después verifique el ZIP abriéndolo como FileSystem y comparando la lista de entradas con la del directorio original.
Ejercicio 11 (propuesto): Migración de Jackson 2 a Jackson 3
Busca en Internet un ejemplo de código de Jackson 2 que use ObjectMapper, JavaTimeModule, configure(...), JsonProcessingException y JsonNode.asText(). Migra el código a Jackson 3 y anota en una tabla cada cambio que has tenido que hacer (dependencias, paquetes, excepciones, configuración y métodos renombrados). Comprueba la salida de ambos.
Ejercicio 12 (propuesto): Conversor CSV ↔ JSON ↔ XML
Crea una pequeña herramienta de línea de comandos java Conversor.java entrada.csv salida.json que detecte el formato por la extensión y convierta el catálogo de productos entre CSV, JSON y XML (con XmlMapper), en cualquier dirección. Añade pruebas que comprueben que convertir A → B → A devuelve los mismos datos.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Flujos de bytes | InputStream, OutputStream | Binario; se decoran con Buffered, Data, Object, Zip… |
| Flujos de caracteres | Reader, Writer | Texto con codificación; InputStreamReader hace de puente |
| Rutas | Path.of, resolve, relativize, normalize | Un Path no toca el disco |
| Operaciones | Files.readString, writeString, lines, copy, move, delete | Excepciones específicas; StandardOpenOption |
| Atributos | Files.readAttributes, PosixFilePermissions | Tamaño, fechas, permisos |
| Recorrer | Files.list, walk, find, walkFileTree | Cerrar los streams; FileVisitResult |
| Vigilar | WatchService | Eventos de creación, modificación y borrado; no recursivo |
| Acceso aleatorio | RandomAccessFile, FileChannel, ByteBuffer | seek; flip antes de leer el búfer |
| Compresión | ZipOutputStream, ZipInputStream, GZIPOutputStream, zipfs | Cuidado con Zip Slip |
| Configuración | Properties | load(Reader) en UTF-8; valores por defecto |
| Serialización | Serializable, transient, ObjectInputFilter | Riesgo de seguridad: evítala para datos externos |
| JSON | JsonMapper (Jackson 3, tools.jackson) | writeValueAsString, readValue; un mapper reutilizado |
| Anotaciones | @JsonProperty, @JsonIgnore, @JsonFormat, @JsonInclude | Siguen en com.fasterxml.jackson.annotation |
| Árboles | JsonNode, ObjectNode, ArrayNode | get, path, at, asString |
| Genéricos | TypeReference<List<T>> | Evita el borrado de tipos |
| Fechas | java.time en Jackson 3 | ISO-8601 de serie |
| XML | XmlMapper, JAXB | Mismo modelo, otro formato |