Todo lo que BiblioTech ha guardado hasta ahora es texto que tú formateaste a mano: LIBRO;isbn;titulo;autor;anio. Funciona para el catálogo, pero prueba a extenderlo al estado completo del sistema. Un Prestamo referencia a un Material y a un Empleado; el mismo Empleado aparece en tres préstamos distintos; cada préstamo tiene una lista de Incidencia anidadas; ColaReservas guarda reservas que apuntan a los mismos materiales. Escribir eso a mano significa inventar identificadores, escribir cada objeto una vez, resolver las referencias al cargar y reconstruir el grafo. Es trabajo, y es trabajo repetitivo.

Java sabe hacerlo solo. La serialización convierte un grafo de objetos completo en una secuencia de bytes y lo reconstruye después, con todas las referencias en su sitio, incluidas las compartidas y las circulares. Dos llamadas: writeObject y readObject.

Y aquí viene el aviso, que es tan importante como el mecanismo: la serialización nativa de Java es una de las funcionalidades más problemáticas del lenguaje. Es cómoda, es potente, y ha sido durante veinte años una de las vías de ataque más explotadas del ecosistema Java. Esta lección te enseña a usarla y, con el mismo énfasis, cuándo no usarla.

try-with-resources en todo, como siempre desde 06-06.

Contenido

  1. Qué es serializar: el grafo de objetos
  2. La interfaz marcadora Serializable
  3. ObjectOutputStream y ObjectInputStream
  4. Qué se serializa y qué no: transient y static
  5. NotSerializableException: el efecto contagio
  6. serialVersionUID: qué es y por qué debes declararlo
  7. InvalidClassException en acción
  8. Evolución de clases: qué cambios son compatibles
  9. Personalización con writeObject y readObject privados
  10. Externalizable y el control total
  11. Serialización y herencia
  12. Riesgos de seguridad: por qué esto está desaconsejado
  13. Filtros de deserialización con ObjectInputFilter
  14. Alternativas y cuándo sigue siendo razonable
  15. BiblioTech: guardar y restaurar una sesión
  16. Errores Comunes y Consejos
  17. Ejercicios

  1. Qué es serializar: el grafo de objetos

Serializar es convertir un objeto —y todo lo que cuelga de él— en una secuencia de bytes. Deserializar es el camino inverso.

La palabra clave es grafo. Un objeto no está solo: tiene campos que apuntan a otros objetos, que a su vez apuntan a otros. El conjunto forma un grafo dirigido, y serializar significa recorrerlo entero.

Considera este estado de BiblioTech:

flowchart TD
    S["SesionBiblioteca"]
    P1["Prestamo PR-0001"]
    P2["Prestamo PR-0002"]
    P3["Prestamo PR-0003"]
    E1["Empleado E-001<br/>Marta Ruiz"]
    E2["Empleado E-002<br/>Diego Alonso"]
    M1["Libro<br/>Java Efectivo"]
    M2["Libro<br/>Patrones de Diseno"]
    M3["Libro<br/>Refactorizacion"]
    I1["Incidencia<br/>pagina rota"]

    S --> P1
    S --> P2
    S --> P3
    P1 --> E1
    P2 --> E1
    P3 --> E2
    P1 --> M1
    P2 --> M2
    P3 --> M3
    P1 --> I1

    style E1 fill:#fff3e0
    style S fill:#e3f2fd

Fíjate en Empleado E-001: dos préstamos apuntan al mismo objeto. No son dos copias de Marta Ruiz; es la misma instancia, referenciada dos veces. Y eso importa mucho: si Marta devuelve un libro, su contador de préstamos baja, y los dos préstamos ven el mismo cambio porque comparten el objeto.

La serialización de Java preserva esa identidad compartida. Escribe el objeto E-001 una sola vez y, cuando lo vuelve a encontrar, escribe una referencia interna a lo ya escrito. Al deserializar, ambos préstamos vuelven a apuntar a la misma instancia.

Esto se consigue con una tabla de objetos ya escritos que mantiene el ObjectOutputStream:

Situación Qué escribe
Objeto nuevo Su clase, sus campos, y lo apunta en la tabla con un identificador
Objeto ya escrito Solo una referencia al identificador de la tabla
Referencia circular (A → B → A) Se resuelve con el mismo mecanismo, sin bucle infinito
null Un marcador de nulo

Que las referencias circulares funcionen no es un detalle menor: un algoritmo ingenuo entraría en recursión infinita. La serialización de Java las maneja correctamente y sin que tengas que hacer nada.

Esa es su gran ventaja, y no es pequeña. Reproducir esto a mano con un formato de texto exige inventar identificadores, escribir cada objeto una vez, y hacer dos pasadas al cargar para resolver las referencias.

  1. La interfaz marcadora Serializable

Para que un objeto se pueda serializar, su clase debe implementar java.io.Serializable:

package java.io;

public interface Serializable {
    // Sin metodos. Ninguno.
}

Está vacía. Es una interfaz marcadora, exactamente el concepto que viste en 04-01: una interfaz sin métodos cuyo único propósito es etiquetar una clase para que otro código pueda comprobar con instanceof si tiene una propiedad.

package com.nexussoftware.bibliotech.dominio;

import java.io.Serializable;

public class Empleado implements Serializable {

    private static final long serialVersionUID = 1L;      // apartado 6

    private final String nombre;
    private final String identificador;
    private int prestamosActivos;

    // ... el resto igual que siempre
}

Sin implements Serializable, intentar serializar lanza NotSerializableException.

¿Por qué una interfaz vacía y no un mecanismo automático? Porque serializar debe ser una decisión consciente. Al implementar Serializable estás declarando un compromiso serio, y conviene saberlo:

La forma serializada de una clase forma parte de su API pública. Todos sus campos privados quedan expuestos en el fichero, y cualquier cambio en ellos puede romper la compatibilidad con los ficheros ya escritos. Un campo privado que puedes renombrar libremente en una clase normal se convierte, en una clase serializable, en un compromiso permanente.

Por eso Joshua Bloch, en Java Efectivo —uno de los libros del catálogo de BiblioTech—, dedica un capítulo entero a la serialización y su recomendación central es: implementa Serializable con mucha cautela.

Qué es serializable de fábrica en el JDK:

Serializable No serializable
String, todos los envoltorios (Integer, Double...) Thread
ArrayList, LinkedList, HashMap, HashSet Socket, InputStream, OutputStream
Arrays de tipos serializables FileReader, FileWriter
LocalDate y todo java.time (10-05) Connection de bases de datos
Los record cuyos componentes lo sean La mayoría de clases que envuelven recursos del sistema

La regla que explica la columna derecha: un objeto que representa un recurso vivo del sistema operativo no se puede serializar, porque los bytes de un descriptor de fichero o de un socket no significan nada mañana ni en otra máquina.

  1. ObjectOutputStream y ObjectInputStream

Las dos clases que hacen el trabajo son filtros de la familia de bytes (07-03):

package com.nexussoftware.bibliotech.demo;

import java.io.*;

public class SerializacionBasica {

    public static void guardar(Empleado empleado, String ruta) throws IOException {
        try (ObjectOutputStream salida = new ObjectOutputStream(
                new BufferedOutputStream(new FileOutputStream(ruta)))) {

            salida.writeObject(empleado);        // escribe el objeto y todo su grafo
        }
    }

    public static Empleado cargar(String ruta) throws IOException, ClassNotFoundException {
        try (ObjectInputStream entrada = new ObjectInputStream(
                new BufferedInputStream(new FileInputStream(ruta)))) {

            // readObject devuelve Object: hay que convertir
            return (Empleado) entrada.readObject();
        }
    }

    public static void main(String[] args) throws Exception {
        Empleado marta = new Empleado("Marta Ruiz", "E-001");
        marta.registrarPrestamo();
        marta.registrarPrestamo();

        guardar(marta, "datos/marta.ser");

        Empleado recuperada = cargar("datos/marta.ser");
        System.out.println(recuperada.getNombre());              // Marta Ruiz
        System.out.println(recuperada.getPrestamosActivos());    // 2

        // Es OTRO objeto, con el MISMO estado
        System.out.println(marta == recuperada);                 // false
        System.out.println(marta.equals(recuperada));            // true, si equals esta bien (03-09)
    }
}

Los métodos principales:

Método Qué hace
writeObject(Object) Serializa el objeto y todo su grafo
readObject() Deserializa. Devuelve Object: hay que convertir
writeInt, writeDouble, writeUTF... Heredados de DataOutputStream (07-03)
defaultWriteObject() Escribe los campos por defecto. Solo dentro de un writeObject propio
defaultReadObject() Lee los campos por defecto. Solo dentro de un readObject propio

Y las excepciones que hay que manejar:

Excepción Cuándo Tipo
NotSerializableException Un objeto del grafo no es Serializable IOException
InvalidClassException La clase cambió de forma incompatible IOException
ClassNotFoundException La clase no está en el classpath al deserializar Exception, no IOException
StreamCorruptedException El fichero no es un flujo de objetos válido IOException
EOFException Se leen más objetos de los escritos IOException

Fíjate en ClassNotFoundException: no extiende IOException, así que necesita su propio catch o un multi-catch (06-02). Es la que aparece cuando lees un fichero escrito por otra versión de la aplicación que tenía clases que la tuya no tiene.

Cómo escribir y leer varios objetos:

/** Varios objetos: se escriben y se leen EN EL MISMO ORDEN. */
public static void guardarVarios(List<Empleado> empleados, String ruta) throws IOException {
    try (ObjectOutputStream salida = new ObjectOutputStream(
            new BufferedOutputStream(new FileOutputStream(ruta)))) {

        salida.writeInt(empleados.size());          // primero, cuantos hay
        for (Empleado e : empleados) {
            salida.writeObject(e);
        }
    }
}

public static List<Empleado> cargarVarios(String ruta)
        throws IOException, ClassNotFoundException {

    List<Empleado> empleados = new ArrayList<>();
    try (ObjectInputStream entrada = new ObjectInputStream(
            new BufferedInputStream(new FileInputStream(ruta)))) {

        int cuantos = entrada.readInt();
        for (int i = 0; i < cuantos; i++) {
            empleados.add((Empleado) entrada.readObject());
        }
    }
    return empleados;
}

Más simple todavía: serializar la colección entera, porque ArrayList es serializable:

salida.writeObject(new ArrayList<>(empleados));      // un solo writeObject
// ...
List<Empleado> empleados = (List<Empleado>) entrada.readObject();

Esta segunda forma es mejor: menos código y sin posibilidad de descuadrar el contador. Pero cuidado con qué implementación de List guardas: List.of(...) y Collections.unmodifiableList(...) producen clases internas del JDK que sí son serializables pero cuya identidad concreta puede cambiar entre versiones. Si vas a serializar una colección, guarda un ArrayList o un HashMap explícito.

Cómo son los bytes que produce:

AC ED 00 05 73 72 00 2C 63 6F 6D 2E 6E 65 78 75  |....sr.,com.nexu|
73 73 6F 66 74 77 61 72 65 2E 62 69 62 6C 69 6F  |ssoftware.biblio|
74 65 63 68 2E 64 6F 6D 69 6E 69 6F 2E 45 6D 70  |tech.dominio.Emp|
6C 65 61 64 6F 00 00 00 00 00 00 00 01 02 00 03  |leado...........|

AC ED es el número mágico de la serialización de Java —el mismo concepto del ejercicio 3 de 07-03—, seguido de la versión del protocolo y, en texto legible, el nombre completo de la clase. Esto revela dos cosas: el formato no es opaco, y quien tenga el fichero sabe exactamente qué clases usa tu aplicación. Guarda esa observación para el apartado 12.

  1. Qué se serializa y qué no: transient y static

Por defecto se serializan todos los campos de instancia. Con dos excepciones:

Modificador ¿Se serializa? Por qué
(ninguno) Es el estado del objeto
private La privacidad no protege de la serialización
final Se restaura sin pasar por el constructor
static No Pertenece a la clase, no al objeto
transient No Excluido explícitamente

Dos detalles que sorprenden:

Los campos private se serializan. El encapsulamiento del módulo 3 no protege nada aquí: todos los campos privados quedan escritos en el fichero, con sus nombres. Es la consecuencia directa de que la forma serializada sea parte de la API.

Los campos final se restauran sin ejecutar el constructor. La deserialización no llama a ningún constructor de la clase serializable: crea el objeto directamente y le asigna los campos leídos. Esto tiene una implicación seria que se desarrolla en el apartado 12: la deserialización es una vía de creación de objetos que se salta todas tus validaciones.

Los static no se serializan porque son de la clase. Si Material.MULTA_MAXIMA vale 20.0 al guardar y alguien lo cambia a 25.0 antes de cargar, el objeto restaurado usará 25.0. Es correcto: la constante es de la clase, no del objeto.

Para qué sirve transient de verdad

transient marca un campo como no serializable. Tiene tres usos legítimos:

1. Datos derivados que se pueden recalcular. No tiene sentido guardar lo que se deduce de otra cosa:

public class Prestamo implements Serializable {

    private static final long serialVersionUID = 1L;

    private final String referencia;
    private final Material material;
    private final int diaInicio;
    private int diaDevolucion = -1;

    // DERIVADO: se calcula a partir de los dias y la tarifa del material.
    // Guardarlo seria duplicar informacion, y peor: si la tarifa cambia,
    // el valor guardado quedaria obsoleto y contradiria al calculado.
    private transient double multaCalculada;
    private transient boolean multaEnCache = false;

    public double calcularMulta(int diaActual) {
        if (!multaEnCache) {
            multaCalculada = material.calcularMulta(diaActual - diaInicio);
            multaEnCache = true;
        }
        return multaCalculada;
    }
}

2. Secretos que no deben quedar escritos en disco. Contraseñas, tokens, claves:

public class SesionUsuario implements Serializable {

    private static final long serialVersionUID = 1L;

    private final String identificador;

    // NUNCA en disco. Todo lo que 06-07 dijo sobre que no registrar se
    // aplica igual, o mas, a lo que se persiste: el fichero se queda.
    private transient char[] credencial;
    private transient String tokenDeSesion;
}

3. Recursos no serializables. Conexiones, flujos, hilos. Es el caso del apartado siguiente, y transient es la solución al problema del contagio.

Qué valor tienen los campos transient al deserializar: el valor por defecto de su tipo. 0 para numéricos, false para boolean, null para referencias. No se ejecuta ningún inicializador de campo ni ningún constructor.

Esa es una fuente de fallos muy concreta:

public class CacheFichas implements Serializable {

    private static final long serialVersionUID = 1L;

    // MAL: al deserializar sera null, no un HashMap vacio.
    // El inicializador NO se ejecuta.
    private transient Map<String, Ficha> cache = new HashMap<>();

    public void guardar(String clave, Ficha f) {
        cache.put(clave, f);        // NullPointerException tras deserializar
    }
}

La solución es un readObject privado que reinicialice, y es el apartado 9.

  1. NotSerializableException: el efecto contagio

Aquí está la trampa que más desconcierta:

Un objeto solo es serializable si TODO su grafo lo es. Basta un campo no serializable, a cualquier profundidad, para que falle la operación entera.

import java.io.Serializable;
import java.util.logging.Logger;

public class GestorPrestamos implements Serializable {

    private static final long serialVersionUID = 1L;

    private final Catalogo catalogo;             // serializable
    private final RegistroPrestamos registro;    // serializable

    // PROBLEMA: Logger NO es Serializable.
    // Este unico campo hace que TODO el GestorPrestamos falle.
    private final Logger log = Logger.getLogger(GestorPrestamos.class.getName());
}

Al intentar guardarlo:

Exception in thread "main" java.io.NotSerializableException: java.util.logging.Logger
    at java.base/java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1197)
    at java.base/java.io.ObjectOutputStream.defaultWriteFields(...)
    at ...

El mensaje dice qué clase falló, pero no dónde estaba ese campo, que es lo que necesitas saber. En un grafo de veinte clases anidadas, encontrarlo es un rastreo. El truco: leer el stack trace de abajo arriba siguiendo los defaultWriteFields, como aprendiste en 06-01.

La solución es transient, casi siempre:

public class GestorPrestamos implements Serializable {

    private static final long serialVersionUID = 1L;

    private final Catalogo catalogo;
    private final RegistroPrestamos registro;

    // transient: no se guarda. Y no hace falta, porque un Logger se
    // recupera con una llamada estatica en cualquier momento.
    private transient Logger log = Logger.getLogger(GestorPrestamos.class.getName());

    /**
     * Reinicializa lo transient tras la deserializacion (apartado 9).
     * Sin esto, 'log' seria null y el primer uso lanzaria NullPointerException.
     */
    private void readObject(java.io.ObjectInputStream entrada)
            throws java.io.IOException, ClassNotFoundException {

        entrada.defaultReadObject();
        log = Logger.getLogger(GestorPrestamos.class.getName());
    }
}

Y una regla práctica que ahorra mucho dolor:

Los campos Logger deben ser siempre private static final. Al ser static no se serializan y el problema no existe. Es exactamente la convención que estableciste en 06-07 por motivos de rendimiento; resulta que también resuelve esto.

Las tres soluciones al contagio, según el caso:

Situación Solución
El campo se puede recrear (logger, caché, conexión) transient + readObject que lo reinicializa
El campo es un objeto de dominio tuyo Haz que él implemente Serializable
El campo es de una librería que no controlas transient + guardar los datos necesarios para reconstruirlo

  1. serialVersionUID: qué es y por qué debes declararlo

Este es el apartado que retoma lo que hiciste en el módulo 6. En 06-04 escribiste, en cada excepción:

public class MaterialNoEncontradoException extends CatalogoException {
    private static final long serialVersionUID = 1L;
    // ...
}

Y la explicación fue breve: el compilador lo pide porque Throwable es Serializable. Ahora toca la explicación completa.

Qué es

serialVersionUID es un número que identifica la versión de la forma serializada de una clase. Se escribe en el fichero al serializar, y al deserializar se compara con el de la clase cargada. Si no coinciden, se lanza InvalidClassException.

Es un control de compatibilidad: "este fichero fue escrito por la versión 1 de la clase; ¿eres tú esa versión?".

Cómo se calcula por defecto

Si no lo declaras, el compilador genera uno automáticamente a partir de un resumen criptográfico de:

  • El nombre de la clase.
  • Los modificadores de la clase.
  • Las interfaces que implementa, en orden.
  • Los nombres, tipos y modificadores de todos los campos.
  • Los nombres, tipos y modificadores de todos los métodos, incluidos los privados.
  • Los constructores.

Y ahí está el problema:

Casi cualquier cambio en la clase cambia el serialVersionUID generado. Añadir un método privado. Cambiar un private por protected. Reordenar interfaces. Incluso compilar con otra versión del compilador puede producir un valor distinto.

Consecuencia práctica: sin declararlo, añadir un método auxiliar privado —que no cambia el estado en absoluto— rompe todos los ficheros guardados. Y el mensaje de error no dice "has añadido un método"; dice que los identificadores no coinciden, con dos números largos.

Por qué debes declararlo

Al declararlo tú, tomas el control:

public class Empleado implements Serializable {

    /**
     * Version de la forma serializada.
     *
     * SOLO se incrementa cuando se hace un cambio INCOMPATIBLE (apartado 8).
     * Los cambios compatibles (anadir campos, metodos, cambiar codigo)
     * mantienen el mismo valor.
     */
    private static final long serialVersionUID = 1L;

    // ...
}

Ahora los ficheros antiguos siguen siendo legibles mientras tú no decidas lo contrario, y decides tú cuándo romper la compatibilidad.

Cómo escribirlo, exactamente:

private static final long serialVersionUID = 1L;
  • private: no forma parte de la API pública.
  • static: es de la clase.
  • final: no cambia.
  • long: es el tipo exigido.
  • El sufijo L es obligatorio.

Los cuatro modificadores importan: si te dejas alguno, el mecanismo lo ignora en silencio y vuelve al cálculo automático. Un serialVersionUID sin static no sirve para nada y no da ningún aviso.

Cómo activar el aviso del compilador:

javac -Xlint:serial *.java
warning: [serial] serializable class Empleado has no definition of serialVersionUID

Actívalo en el proyecto. Es el aviso que te dice "vas a tener un problema dentro de seis meses".

  1. InvalidClassException en acción

La demostración vale más que la explicación. Versión 1 de la clase:

// VERSION 1 - se compila, se ejecuta y se guarda un fichero
public class Empleado implements Serializable {
    // SIN serialVersionUID declarado: se calcula automaticamente
    private final String nombre;
    private final String identificador;
    private int prestamosActivos;

    public Empleado(String nombre, String identificador) {
        this.nombre = nombre;
        this.identificador = identificador;
    }
    public String getNombre() { return nombre; }
}
Empleado marta = new Empleado("Marta Ruiz", "E-001");
guardar(marta, "datos/marta.ser");         // OK

Ahora se hace un cambio inocente: añadir un método. Ni un campo nuevo, ni un cambio de tipo. Un método.

// VERSION 2 - solo se anade un metodo
public class Empleado implements Serializable {
    private final String nombre;
    private final String identificador;
    private int prestamosActivos;

    public Empleado(String nombre, String identificador) {
        this.nombre = nombre;
        this.identificador = identificador;
    }
    public String getNombre() { return nombre; }

    /** Metodo NUEVO. No toca el estado. */
    public String getIniciales() {
        String[] partes = nombre.split(" ");
        return "" + partes[0].charAt(0) + partes[1].charAt(0);
    }
}

Al leer el fichero guardado con la versión 1:

Exception in thread "main" java.io.InvalidClassException:
    com.nexussoftware.bibliotech.dominio.Empleado;
    local class incompatible:
    stream classdesc serialVersionUID = -4738291056473829104,
    local class serialVersionUID = 8273645019283746152

El fichero es ilegible para siempre. Y el cambio no afectaba al estado en absoluto.

Con serialVersionUID = 1L declarado en las dos versiones, el fichero se lee sin ningún problema. El método nuevo simplemente existe en los objetos restaurados.

Esta es la razón de que sea obligatorio declararlo. Sin él, la compatibilidad de tus ficheros depende de detalles del compilador que no controlas ni conoces. Con él, tú decides.

Y esto es exactamente lo que hacen tus excepciones del módulo 6: BiblioTechException y todas sus subclases lo declaran, porque las excepciones se serializan cuando viajan entre máquinas —una llamada remota, un servidor de aplicaciones—, y una excepción que no se puede deserializar al otro lado convierte un error de negocio comprensible en un fallo incomprensible de infraestructura.

  1. Evolución de clases: qué cambios son compatibles

Con serialVersionUID declarado, ¿qué puedes cambiar sin romper los ficheros?

Cambio ¿Compatible? Qué pasa al deserializar un fichero antiguo
Añadir un campo El campo nuevo toma su valor por defecto (0, null, false)
Eliminar un campo El valor del fichero se descarta
Añadir, quitar o cambiar métodos Nada: los métodos no se serializan
Cambiar el cuerpo de un método Nada
Añadir constructores Nada: no se ejecutan al deserializar
Cambiar transient a normal Como añadir un campo
Cambiar normal a transient Como eliminar un campo
Cambiar static a no static Sí, con cuidado Como añadir un campo
Cambiar el TIPO de un campo NO InvalidClassException
Renombrar un campo NO Equivale a eliminar uno y añadir otro: se pierde el dato
Renombrar o mover la clase NO ClassNotFoundException
Cambiar la jerarquía de herencia NO InvalidClassException
Quitar implements Serializable NO Ilegible
Cambiar de clase a enum o record NO Incompatible

Los dos casos peligrosos merecen atención especial:

Añadir un campo es compatible, pero el valor por defecto puede ser inválido. Si añades private int diasMaximos; y todo tu código supone que vale al menos 1, los objetos restaurados de ficheros antiguos tendrán 0 y romperán tus invariantes en silencio. La solución es un readObject que compruebe y corrija, y es el apartado siguiente.

Renombrar un campo pierde el dato sin avisar. Cambiar nombre por nombreCompleto significa, para el mecanismo, que el campo nombre ya no existe (se descarta) y que hay uno nuevo llamado nombreCompleto (se pone a null). No hay ningún error: obtienes objetos con el nombre a null. Es la clase de fallo que aparece en producción tres semanas después.

La regla práctica:

Incrementa serialVersionUID solo cuando hagas un cambio incompatible, y asume que a partir de ese momento los ficheros antiguos son ilegibles. Si necesitas poder leerlos, no incrementes: escribe un readObject que sepa manejar las dos formas.

  1. Personalización con writeObject y readObject privados

El mecanismo por defecto se puede intervenir con dos métodos de firma exacta:

private void writeObject(ObjectOutputStream salida) throws IOException;
private void readObject(ObjectInputStream entrada) throws IOException, ClassNotFoundException;

Son privados y aun así el mecanismo los encuentra y los llama —lo hace por reflexión, que verás en 10-03—. Si la firma no es exactamente esa, se ignoran en silencio, que es una de las trampas más frustrantes de esta API.

Los cuatro usos legítimos:

package com.nexussoftware.bibliotech.servicio;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;
import java.util.logging.Logger;

import com.nexussoftware.bibliotech.dominio.Ficha;

/**
 * Cache de fichas con serializacion personalizada.
 *
 * La cache NO se guarda (es derivada), pero hay que reconstruirla vacia al
 * cargar: si no, seria null y el primer uso lanzaria NullPointerException.
 */
public class CacheFichas implements Serializable {

    private static final long serialVersionUID = 1L;

    /** static: no se serializa. La convencion correcta para un Logger. */
    private static final Logger LOG = Logger.getLogger(CacheFichas.class.getName());

    private final String nombre;
    private int capacidadMaxima;

    /** transient: al deserializar sera null si no lo arreglamos. */
    private transient Map<String, Ficha> cache = new HashMap<>();
    private transient int aciertos = 0;
    private transient int fallos = 0;

    public CacheFichas(String nombre, int capacidadMaxima) {
        this.nombre = java.util.Objects.requireNonNull(nombre);
        if (capacidadMaxima < 1) {
            throw new IllegalArgumentException(
                    "La capacidad debe ser al menos 1, y era: " + capacidadMaxima);
        }
        this.capacidadMaxima = capacidadMaxima;
    }

    /**
     * Serializacion personalizada.
     *
     * USO 1: registrar la operacion.
     * USO 2: escribir datos adicionales tras los campos por defecto.
     */
    private void writeObject(ObjectOutputStream salida) throws IOException {
        LOG.fine(() -> "Serializando cache '" + nombre + "' con "
                + cache.size() + " entradas (que NO se guardan)");

        salida.defaultWriteObject();          // primero, los campos normales

        // Se pueden escribir datos extra. Se leeran en el MISMO orden.
        salida.writeInt(cache.size());        // solo informativo
    }

    /**
     * Deserializacion personalizada.
     *
     * USO 3: reinicializar los campos transient.
     * USO 4: VALIDAR el estado leido.
     */
    private void readObject(ObjectInputStream entrada)
            throws IOException, ClassNotFoundException {

        entrada.defaultReadObject();          // primero, los campos normales

        int tamanoAnterior = entrada.readInt();   // el extra, en el mismo orden

        // USO 3: reinicializar. Sin esto, cache seria null.
        this.cache = new HashMap<>();
        this.aciertos = 0;
        this.fallos = 0;

        // USO 4: VALIDAR. La deserializacion NO llama al constructor, asi que
        // sus comprobaciones no se han ejecutado. Un fichero manipulado o de
        // una version antigua puede traer valores imposibles.
        if (capacidadMaxima < 1) {
            LOG.warning(() -> "Capacidad invalida al deserializar ("
                    + capacidadMaxima + "); se corrige a 100");
            this.capacidadMaxima = 100;
        }
        if (nombre == null) {
            throw new java.io.InvalidObjectException(
                    "Cache deserializada sin nombre: fichero corrupto o manipulado");
        }

        LOG.fine(() -> "Cache '" + nombre + "' restaurada vacia (tenia "
                + tamanoAnterior + " entradas)");
    }

    public java.util.Optional<Ficha> buscar(String clave) {
        Ficha f = cache.get(clave);
        if (f != null) { aciertos++; } else { fallos++; }
        return java.util.Optional.ofNullable(f);
    }

    public void guardar(String clave, Ficha ficha) {
        if (cache.size() >= capacidadMaxima) {
            cache.clear();                    // politica simple de desalojo
        }
        cache.put(clave, ficha);
    }

    public int getAciertos() { return aciertos; }
    public int getFallos()   { return fallos; }
}

El uso 4 es el más importante de los cuatro, y conviene subrayarlo:

La deserialización no llama a ningún constructor. Todas las validaciones que escribiste en 06-03 —Objects.requireNonNull, comprobaciones de rango, formatos— se saltan por completo. Un fichero manipulado puede producir objetos que tu código considera imposibles: un Prestamo con referencia null, un Empleado con −5 préstamos activos, un Material con multa negativa.

Por eso readObject debe validar como si fuera un constructor, y lanzar InvalidObjectException si el estado no es admisible. Es la puerta trasera de tu dominio, y hay que cerrarla.

Un método relacionado, readResolve, permite sustituir el objeto deserializado por otro:

/** Preserva el patron singleton al deserializar. */
private Object readResolve() {
    return INSTANCIA;      // se devuelve la instancia unica, no la deserializada
}

Sin él, deserializar un singleton crea una segunda instancia, rompiendo la garantía de unicidad. Los enum no tienen este problema —el lenguaje lo garantiza—, que es una de las razones por las que 04-07 recomendaba enum para los singleton.

  1. Externalizable y el control total

Externalizable extiende Serializable y sustituye el mecanismo automático por control completo:

public interface Externalizable extends Serializable {
    void writeExternal(ObjectOutput salida) throws IOException;
    void readExternal(ObjectInput entrada) throws IOException, ClassNotFoundException;
}

Diferencias:

Serializable Externalizable
Campos guardados Todos, automáticamente Solo los que escribas
Métodos Opcionales y privados Obligatorios y públicos
Constructor al deserializar No se llama Se llama el público sin argumentos
Herencia de campos Automática La gestionas tú
Tamaño del fichero Mayor: incluye metadatos Menor
Riesgo de error Bajo Alto: un campo olvidado se pierde en silencio
public class Ubicacion implements Externalizable {

    private String sala;
    private int estanteria;
    private int balda;

    /** OBLIGATORIO y PUBLICO: Externalizable lo llama al deserializar. */
    public Ubicacion() { }

    public Ubicacion(String sala, int estanteria, int balda) {
        this.sala = sala;
        this.estanteria = estanteria;
        this.balda = balda;
    }

    @Override
    public void writeExternal(ObjectOutput salida) throws IOException {
        salida.writeUTF(sala);
        salida.writeInt(estanteria);
        salida.writeInt(balda);
    }

    @Override
    public void readExternal(ObjectInput entrada) throws IOException {
        sala = entrada.readUTF();          // el MISMO orden
        estanteria = entrada.readInt();
        balda = entrada.readInt();
    }
}

Los tres motivos por los que casi nunca se usa:

  1. Los campos final son imposibles. El constructor sin argumentos no puede inicializarlos y readExternal no puede asignarlos. Adiós a la inmutabilidad de 03-07.
  2. Un campo olvidado se pierde en silencio. Añades un campo, olvidas actualizar los dos métodos, y ese dato desaparece sin ningún error.
  3. El ahorro rara vez compensa. Los metadatos de Serializable son un porcentaje pequeño en la mayoría de los casos.

Conócelo para leer código ajeno. Si necesitas ese nivel de control, casi siempre es mejor escribir tu propio formato, como hiciste con DataOutputStream en 07-03 o como harás con CSV en 07-07.

  1. Serialización y herencia

Las reglas cuando hay jerarquía:

Si la superclase es Serializable, las subclases lo son automáticamente. Serializable se hereda como cualquier interfaz. Si Material lo implementa, Libro, Revista y Dvd lo son sin declarar nada.

Si la superclase NO es Serializable, hay una condición estricta:

La superclase no serializable debe tener un constructor accesible sin argumentos. Al deserializar, sus campos no se leen del fichero: se inicializan llamando a ese constructor.

/** Superclase NO serializable. */
public abstract class ElementoInventario {

    private final String codigoInterno;
    private int revisiones;

    /**
     * OBLIGATORIO para que las subclases serializables funcionen.
     * Sin el: InvalidClassException "no valid constructor".
     */
    protected ElementoInventario() {
        this.codigoInterno = "SIN-CODIGO";     // valor por defecto
        this.revisiones = 0;
    }

    protected ElementoInventario(String codigoInterno) {
        this.codigoInterno = codigoInterno;
        this.revisiones = 0;
    }

    public String getCodigoInterno() { return codigoInterno; }
}

/** Subclase SI serializable. */
public class Material extends ElementoInventario implements Serializable {

    private static final long serialVersionUID = 1L;

    private final String titulo;
    private final String referencia;
    private boolean disponible;

    // ...
}

Al deserializar un Material:

  1. Se llama al constructor sin argumentos de ElementoInventario, así que codigoInterno vale "SIN-CODIGO" y el valor original se ha perdido.
  2. Se leen del fichero titulo, referencia y disponible.

Los campos de la superclase no serializable no se guardan. Si eso no es aceptable —y con un codigoInterno real no lo sería—, hay dos salidas: hacer serializable la superclase, o guardar esos campos a mano en un writeObject/readObject de la subclase.

Y el error si falta el constructor:

java.io.InvalidClassException: com.nexussoftware.bibliotech.dominio.Material;
    no valid constructor

Un mensaje escueto que significa exactamente: "tu superclase no serializable no tiene constructor sin argumentos accesible".

  1. Riesgos de seguridad: por qué esto está desaconsejado

Este es el apartado más importante de la lección. Hasta aquí has visto una herramienta cómoda. Ahora toca por qué la comunidad Java lleva años recomendando evitarla.

El problema de fondo

Deserializar datos no confiables permite, en el caso general, ejecutar código arbitrario en tu máquina.

No es una exageración. Es la causa de algunas de las vulnerabilidades más graves de la historia del ecosistema Java.

El mecanismo, explicado sin dar recetas:

  1. readObject() instancia clases cuyo nombre viene escrito en el fichero. Recuerda el volcado del apartado 3: el nombre de la clase está ahí, en texto.
  2. Al deserializar, se ejecutan métodos definidos por esas clases: readObject, readResolve, validateObject, y de forma indirecta equals, hashCode o compareTo al insertar en colecciones.
  3. Un atacante que controle los bytes puede componer un grafo de objetos de clases que ya están en tu classpath —las tuyas, las del JDK, las de tus librerías— encadenando sus efectos hasta conseguir algo dañino.
  4. Todo esto ocurre ANTES de que tu código vea el objeto. No hay ningún punto donde puedas comprobar nada: para cuando readObject() devuelve, el daño ya está hecho.

El punto 4 es el que hace que este problema sea distinto de todos los demás:

// Este codigo YA ES VULNERABLE si 'entrada' viene de fuera.
// La comprobacion llega TARDE: el dano se produjo dentro de readObject().
Object o = entrada.readObject();
if (o instanceof Prestamo p) {          // <-- demasiado tarde
    procesar(p);
}

No hay forma de validar antes, porque la validación tendría que ocurrir durante la propia deserialización. Por eso la única defensa real es no deserializar datos no confiables, y en segundo lugar los filtros del apartado 13.

Qué se considera "no confiable"

Prácticamente todo lo que no hayas escrito tú en un fichero que solo tú puedes tocar:

Origen ¿Confiable?
Fichero escrito por tu propia aplicación, en un directorio protegido Razonablemente
Fichero que el usuario puede subir o sustituir No
Datos recibidos por red No
Contenido de una cookie o de un campo de formulario No
Mensaje de una cola o de un sistema externo No
Fichero en un directorio compartido o temporal No
Copia de seguridad de origen desconocido No

Y la observación incómoda: incluso un fichero "tuyo" deja de serlo si alguien puede escribir en ese directorio. Un fichero de sesión en /tmp con permisos amplios no es confiable.

La postura oficial

No es una opinión de blog. La documentación del propio JDK, en java.io.ObjectInputStream, advierte de que deserializar datos no confiables es intrínsecamente peligroso. Y el proyecto Amber de OpenJDK trabaja desde hace años en sustituir la serialización nativa, precisamente por esto.

Las recomendaciones profesionales establecidas:

  1. No deserialices nada que no controles por completo.
  2. Para intercambio de datos, usa formatos de texto: CSV (07-07), JSON (11-07), XML. Esos formatos no instancian clases arbitrarias: producen cadenas y números que tú conviertes en objetos con tu código y tus validaciones.
  3. Si no queda más remedio, usa un filtro de deserialización (apartado 13).
  4. Reduce la superficie: no hagas Serializable lo que no lo necesite.
  5. Mantén las dependencias actualizadas. Muchos ataques usan clases de librerías conocidas.

AVISO EXPLÍCITO Y NECESARIO: todo lo de este apartado es una introducción, no una guía de seguridad. En un sistema real, cualquier uso de la serialización que cruce una frontera de confianza debe revisarlo el responsable de seguridad de tu organización, y las decisiones deben documentarse. Esto no es algo que un desarrollador decida por su cuenta un martes por la tarde. La seguridad de aplicaciones se trata, en lo que a este curso corresponde, en 12-07.

  1. Filtros de deserialización con ObjectInputFilter

Java 9 introdujo un mecanismo de defensa: filtros que se aplican durante la deserialización, antes de instanciar cada clase.

package com.nexussoftware.bibliotech.infraestructura;

import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;

/**
 * Filtros de deserializacion para BiblioTech.
 *
 * ADVERTENCIA: un filtro REDUCE el riesgo, no lo elimina. La unica defensa
 * completa es no deserializar datos no confiables (apartado 12).
 */
public final class FiltroDeserializacion {

    private FiltroDeserializacion() { }

    /**
     * Filtro por lista blanca: solo se permiten las clases de BiblioTech
     * y un conjunto minimo del JDK. Todo lo demas se RECHAZA.
     *
     * La lista blanca es la unica forma correcta: una lista negra siempre
     * se queda corta, porque no puedes enumerar todo lo peligroso.
     */
    public static ObjectInputFilter deBiblioTech() {
        String patron = String.join(";",
                "com.nexussoftware.bibliotech.**",   // nuestras clases
                "java.util.ArrayList",
                "java.util.LinkedList",
                "java.util.HashMap",
                "java.util.HashSet",
                "java.lang.String",
                "java.lang.Number",
                "java.lang.Integer",
                "java.lang.Double",
                "java.lang.Boolean",
                "java.lang.Enum",
                "maxdepth=20",           // profundidad maxima del grafo
                "maxrefs=10000",         // referencias maximas
                "maxbytes=10485760",     // 10 MB
                "maxarray=100000",       // elementos maximos de un array
                "!*");                   // RECHAZAR TODO LO DEMAS

        return ObjectInputFilter.Config.createFilter(patron);
    }

    /** Aplica el filtro a un flujo concreto. */
    public static void aplicarA(ObjectInputStream entrada) {
        entrada.setObjectInputFilter(deBiblioTech());
    }
}

Uso:

try (ObjectInputStream entrada = new ObjectInputStream(
        new BufferedInputStream(new FileInputStream(ruta)))) {

    // ANTES de cualquier readObject
    FiltroDeserializacion.aplicarA(entrada);

    SesionBiblioteca sesion = (SesionBiblioteca) entrada.readObject();
}

Si llega una clase no permitida:

java.io.InvalidClassException: filter status: REJECTED

Sintaxis de los patrones:

Patrón Significado
com.ejemplo.Clase Esa clase exacta
com.ejemplo.* Las clases de ese paquete, sin subpaquetes
com.ejemplo.** Ese paquete y todos sus subpaquetes
!com.malo.** Rechaza ese paquete
!* Rechaza todo lo no permitido antes. Debe ir el último
maxdepth=N Profundidad máxima del grafo
maxrefs=N Número máximo de referencias
maxbytes=N Tamaño máximo del flujo
maxarray=N Elementos máximos de un array

Los cuatro límites numéricos no son decorativos: protegen contra las llamadas bombas de deserialización, ficheros pequeños que al deserializarse producen estructuras enormes o entran en recursión profunda y agotan la memoria o la pila. Un fichero de unos pocos KB puede tumbar un servidor sin ellos.

También se puede aplicar un filtro global a toda la JVM:

java -Djdk.serialFilter='com.nexussoftware.bibliotech.**;java.util.*;!*' BiblioTechApp

Repite conmigo: el filtro reduce el riesgo, no lo elimina. Sigue habiendo clases permitidas cuyos readObject hacen cosas, y sigue siendo cierto que el código se ejecuta antes de que tú veas nada. Un filtro es una capa de defensa en profundidad, no una autorización para deserializar lo que llegue.

  1. Alternativas y cuándo sigue siendo razonable

Comparación honesta de las opciones:

Formato Legible Portable entre lenguajes Seguro con datos ajenos Tamaño Velocidad Se trata en
Serialización Java No No No Medio Rápida Esta lección
CSV Pequeño Rápida 07-07
Properties Pequeño Rápida 07-07
JSON (con un parseador correcto) Medio Media 11-07
XML Sí, con precauciones Grande Lenta Mencionado en 07-07
Binario propio (DataStream) No Con esfuerzo Pequeño Muy rápida 07-03
Base de datos 11-03

La diferencia crucial de la columna de seguridad: los formatos de texto no instancian clases. Un fichero CSV produce cadenas; tú decides qué objeto construir con ellas, pasando por tus constructores y tus validaciones de 06-03. La serialización, en cambio, construye los objetos por ti a partir de nombres de clase que vienen en el fichero.

Cuándo la serialización nativa sigue siendo razonable:

Caso Por qué
Caché local de datos que puedes recalcular Si se corrompe, se borra y se recalcula. No cruza fronteras
Estado interno entre ejecuciones de una aplicación de escritorio El fichero está en el directorio del usuario y no viaja
Copia profunda de un objeto en memoria Serializar a ByteArrayOutputStream y deserializar. Es un truco conocido
Comunicación entre procesos de confianza de la misma aplicación Con filtros y controlando ambos extremos
Código heredado que ya la usa Cambiar de formato tiene su propio coste

Cuándo no usarla, sin excepciones:

  • Datos que vienen de fuera, de cualquier forma.
  • Formato de intercambio con otros sistemas.
  • Almacenamiento a largo plazo: los ficheros dejan de leerse en cuanto cambian las clases.
  • Nada que atraviese una red.
  • Nada que un usuario pueda sustituir.

  1. BiblioTech: guardar y restaurar una sesión

Con todas las cautelas puestas, este es un caso donde la serialización encaja: un fichero local, escrito y leído por la propia aplicación, en un directorio que ella controla, con datos que se pueden regenerar.

package com.nexussoftware.bibliotech.servicio;

import java.io.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.nexussoftware.bibliotech.infraestructura.FiltroDeserializacion;

/**
 * Guarda y restaura el estado de una sesion de trabajo de BiblioTech.
 *
 * POR QUE AQUI SI ES ADMISIBLE LA SERIALIZACION NATIVA:
 *   - El fichero lo escribe y lo lee la MISMA aplicacion.
 *   - Vive en el directorio de datos local, no viaja por red ni lo sube nadie.
 *   - Su contenido se puede REGENERAR: si se corrompe, se borra y se empieza
 *     una sesion nueva. No es la fuente de verdad; el catalogo en CSV lo es.
 *
 * POR QUE EL CATALOGO NO USA ESTO: el catalogo es el dato de valor, debe ser
 * legible, versionable e importable desde una hoja de calculo. Eso es 07-07.
 *
 * Aun asi se aplica un FILTRO de deserializacion: defensa en profundidad.
 */
public class GestorSesion {

    private static final Logger LOG = Logger.getLogger(GestorSesion.class.getName());

    private static final String EXTENSION = ".sesion";

    private final File directorio;

    public GestorSesion(String directorioDatos) {
        this.directorio = new File(
                Objects.requireNonNull(directorioDatos, "El directorio no puede ser nulo"));
        if (!directorio.exists() && !directorio.mkdirs()) {
            LOG.warning("No se pudo crear " + directorio.getAbsolutePath());
        }
    }

    // ------------------------- EL ESTADO -------------------------

    /**
     * Instantanea serializable del estado de una sesion.
     *
     * Es una clase APARTE de SesionBiblioteca a proposito: la sesion
     * gestiona recursos (bloqueos, logger) que no deben serializarse.
     * Esta clase solo contiene DATOS.
     */
    public static class EstadoSesion implements Serializable {

        /**
         * DECLARADO desde el primer dia. Sin el, anadir un metodo privado
         * dentro de seis meses dejaria ilegibles todas las sesiones guardadas
         * (apartado 7).
         */
        private static final long serialVersionUID = 1L;

        private final String idEmpleado;
        private final int diaApertura;
        private final List<String> operaciones;
        private final int prestamosRealizados;
        private final int devolucionesRealizadas;
        private final double multasCobradas;

        /** Derivado: se recalcula. No tiene sentido guardarlo. */
        private transient String resumenFormateado;

        public EstadoSesion(String idEmpleado, int diaApertura, List<String> operaciones,
                            int prestamosRealizados, int devolucionesRealizadas,
                            double multasCobradas) {

            this.idEmpleado = Objects.requireNonNull(idEmpleado, "El empleado es obligatorio");
            if (diaApertura < 1) {
                throw new IllegalArgumentException("Dia invalido: " + diaApertura);
            }
            if (multasCobradas < 0) {
                throw new IllegalArgumentException("Multas negativas: " + multasCobradas);
            }
            this.diaApertura = diaApertura;
            // ArrayList explicito: es serializable y su forma es estable
            this.operaciones = new ArrayList<>(operaciones);
            this.prestamosRealizados = prestamosRealizados;
            this.devolucionesRealizadas = devolucionesRealizadas;
            this.multasCobradas = multasCobradas;
        }

        /**
         * VALIDACION al deserializar.
         *
         * Imprescindible: la deserializacion NO llama al constructor, asi que
         * las comprobaciones de arriba NO se han ejecutado. Un fichero
         * corrupto o manipulado podria traer un dia negativo o multas
         * imposibles, y el resto del sistema los daria por buenos.
         */
        private void readObject(ObjectInputStream entrada)
                throws IOException, ClassNotFoundException {

            entrada.defaultReadObject();

            if (idEmpleado == null || idEmpleado.isBlank()) {
                throw new InvalidObjectException(
                        "Sesion sin empleado: fichero corrupto o manipulado");
            }
            if (diaApertura < 1) {
                throw new InvalidObjectException(
                        "Dia de apertura invalido: " + diaApertura);
            }
            if (multasCobradas < 0) {
                throw new InvalidObjectException(
                        "Multas negativas: " + multasCobradas);
            }
            if (operaciones == null) {
                throw new InvalidObjectException("Lista de operaciones nula");
            }
            // El transient se recalcula bajo demanda: no hay que tocarlo aqui
        }

        public String getIdEmpleado()          { return idEmpleado; }
        public int getDiaApertura()            { return diaApertura; }
        public List<String> getOperaciones()   { return List.copyOf(operaciones); }
        public int getPrestamosRealizados()    { return prestamosRealizados; }
        public int getDevolucionesRealizadas() { return devolucionesRealizadas; }
        public double getMultasCobradas()      { return multasCobradas; }

        /** Campo derivado: se calcula la primera vez y se cachea. */
        public String getResumen() {
            if (resumenFormateado == null) {
                resumenFormateado = String.format(
                        "Sesion de %s (dia %d): %d prestamos, %d devoluciones, %.2f EUR",
                        idEmpleado, diaApertura, prestamosRealizados,
                        devolucionesRealizadas, multasCobradas);
            }
            return resumenFormateado;
        }
    }

    // ------------------------- GUARDAR -------------------------

    /**
     * Guarda el estado de forma ATOMICA (07-02): temporal y renombrado.
     *
     * Una sesion guardada a medias seria peor que ninguna: al restaurarla
     * daria StreamCorruptedException o, peor, un objeto incompleto.
     */
    public void guardar(EstadoSesion estado) throws IOException {
        Objects.requireNonNull(estado, "El estado no puede ser nulo");

        File destino = new File(directorio, estado.getIdEmpleado() + EXTENSION);
        File temporal = new File(destino.getAbsolutePath() + ".tmp");

        boolean completado = false;
        try {
            try (ObjectOutputStream salida = new ObjectOutputStream(
                    new BufferedOutputStream(new FileOutputStream(temporal)))) {

                salida.writeObject(estado);
            }   // close() -> flush garantizado

            if (destino.exists() && !destino.delete()) {
                throw new IOException("No se pudo sustituir " + destino.getName());
            }
            if (!temporal.renameTo(destino)) {
                throw new IOException("No se pudo renombrar " + temporal.getName());
            }
            completado = true;

            LOG.info(() -> String.format("Sesion de %s guardada en %s (%d bytes)",
                    estado.getIdEmpleado(), destino.getName(), destino.length()));

        } finally {
            if (!completado && temporal.exists() && !temporal.delete()) {
                LOG.warning(() -> "Temporal sin borrar: " + temporal.getAbsolutePath());
            }
        }
    }

    // ------------------------- RESTAURAR -------------------------

    /**
     * Restaura la sesion de un empleado.
     *
     * DEGRADACION ELEGANTE (06-07): si no hay sesion guardada, o si esta
     * corrupta, o si la clase cambio, se devuelve vacio y se empieza de
     * cero. Perder una sesion no es un fallo grave: los datos de valor
     * estan en el catalogo.
     *
     * @return Optional vacio si no se pudo restaurar
     */
    public java.util.Optional<EstadoSesion> restaurar(String idEmpleado) {
        File fichero = new File(directorio, idEmpleado + EXTENSION);

        if (!fichero.exists()) {
            LOG.fine(() -> "No hay sesion guardada para " + idEmpleado);
            return java.util.Optional.empty();
        }

        try (ObjectInputStream entrada = new ObjectInputStream(
                new BufferedInputStream(new FileInputStream(fichero)))) {

            // FILTRO antes de leer nada. Defensa en profundidad (apartado 13).
            FiltroDeserializacion.aplicarA(entrada);

            EstadoSesion estado = (EstadoSesion) entrada.readObject();

            LOG.info(() -> "Sesion restaurada: " + estado.getResumen());
            return java.util.Optional.of(estado);

        } catch (InvalidClassException e) {
            // La clase cambio de forma incompatible (apartado 8).
            LOG.log(Level.WARNING, "La sesion de " + idEmpleado
                    + " se guardo con otra version de la aplicacion y no se puede leer", e);
            descartar(fichero);
            return java.util.Optional.empty();

        } catch (InvalidObjectException e) {
            // Nuestro readObject rechazo el contenido: corrupto o manipulado.
            LOG.log(Level.SEVERE, "Sesion de " + idEmpleado + " rechazada por validacion", e);
            descartar(fichero);
            return java.util.Optional.empty();

        } catch (StreamCorruptedException | EOFException e) {
            // Fichero truncado: se corto una escritura. La escritura atomica
            // de guardar() lo evita, pero un fallo del disco no.
            LOG.log(Level.WARNING, "Sesion de " + idEmpleado + " corrupta", e);
            descartar(fichero);
            return java.util.Optional.empty();

        } catch (ClassNotFoundException e) {
            // No extiende IOException: necesita su propio catch (apartado 3)
            LOG.log(Level.SEVERE, "Clase no encontrada al restaurar la sesion de "
                    + idEmpleado, e);
            return java.util.Optional.empty();

        } catch (IOException e) {
            LOG.log(Level.SEVERE, "Fallo de E/S restaurando la sesion de " + idEmpleado, e);
            return java.util.Optional.empty();
        }
    }

    /** Aparta un fichero ilegible en vez de borrarlo: puede hacer falta para diagnosticar. */
    private void descartar(File fichero) {
        File apartado = new File(fichero.getAbsolutePath() + ".invalido");
        if (apartado.exists()) {
            apartado.delete();
        }
        if (fichero.renameTo(apartado)) {
            LOG.info(() -> "Sesion ilegible apartada como " + apartado.getName());
        }
    }
}

Uso completo:

public class DemoSesion {

    public static void main(String[] args) throws IOException {
        GestorSesion gestor = new GestorSesion("datos/sesiones");

        // --- Al arrancar: intentar restaurar ---
        gestor.restaurar("E-001").ifPresentOrElse(
                estado -> System.out.println("Continuando: " + estado.getResumen()),
                ()     -> System.out.println("Sesion nueva para E-001"));

        // --- Trabajo del turno ---
        List<String> operaciones = new ArrayList<>();
        operaciones.add("PRESTAMO PR-0001");
        operaciones.add("PRESTAMO PR-0002");
        operaciones.add("DEVOLUCION PR-0001 (multa 1,75)");

        // --- Al salir: guardar ---
        gestor.guardar(new GestorSesion.EstadoSesion(
                "E-001", 15, operaciones, 2, 1, 1.75));

        System.out.println("Sesion guardada. Vuelve a ejecutar para ver la restauracion.");
    }
}

Primera ejecución:

Sesion nueva para E-001
Sesion guardada. Vuelve a ejecutar para ver la restauracion.

Segunda ejecución:

Continuando: Sesion de E-001 (dia 15): 2 prestamos, 1 devoluciones, 1,75 EUR
Sesion guardada. Vuelve a ejecutar para ver la restauracion.

Las siete decisiones de diseño que hay que entender:

  1. EstadoSesion es una clase aparte de SesionBiblioteca. La sesión gestiona recursos —bloqueos, logger, catálogo— que no deben serializarse. El estado contiene solo datos. Separar el objeto vivo del objeto persistible es una decisión de diseño que evita el contagio del apartado 5.
  2. serialVersionUID declarado desde el primer día. Añadir un método privado dentro de seis meses dejaría ilegibles todas las sesiones si no estuviera.
  3. readObject valida como si fuera un constructor. Es imprescindible, porque el constructor no se ejecuta. Sin esa validación, un fichero corrupto produciría objetos que el resto del sistema considera imposibles.
  4. El filtro se aplica antes de leer nada. Defensa en profundidad, aunque el fichero sea local.
  5. Escritura atómica. Una sesión guardada a medias sería peor que ninguna.
  6. Cinco catch distintos, cada uno con su tratamiento. InvalidClassException es un cambio de versión; InvalidObjectException es contenido rechazado; StreamCorruptedException es un fichero truncado; ClassNotFoundException no es una IOException y necesita su propio bloque. Es 06-02 aplicado con precisión.
  7. El fichero ilegible se aparta, no se borra. Renombrarlo a .invalido permite diagnosticar después qué pasó, sin bloquear al usuario.

Y la decisión que resume la lección: la sesión usa serialización; el catálogo, no. El catálogo es el dato de valor, debe ser legible, versionable, importable desde una hoja de cálculo y estable frente a cambios de código. Eso es CSV, y es la lección 07-07.

Errores Comunes y Consejos

  • No declarar serialVersionUID. El error número uno. Un método privado añadido seis meses después rompe todos los ficheros guardados, con un mensaje que no explica la causa.
  • Declararlo mal. Sin static, sin final o sin el sufijo L, se ignora en silencio y vuelve el cálculo automático.
  • Incrementarlo por costumbre. Solo se incrementa ante un cambio incompatible. Incrementarlo por rutina invalida los ficheros sin necesidad.
  • Renombrar un campo. Equivale a borrar uno y crear otro: el dato se pierde y no hay ningún error. El fallo aparece semanas después.
  • Creer que private protege. Todos los campos privados quedan escritos en el fichero, con sus nombres.
  • Olvidar que la deserialización no llama al constructor. Todas tus validaciones se saltan. Valida en readObject o tu dominio tiene una puerta trasera.
  • No reinicializar los campos transient. Quedan a null o 0, y el inicializador de campo no se ejecuta. NullPointerException en el primer uso.
  • Un Logger no static. Contagia la NotSerializableException a toda la clase. private static final, siempre.
  • Serializar objetos con recursos vivos. Un socket o un flujo no tienen sentido fuera de su proceso.
  • Serializar List.of(...) o colecciones no modificables del JDK. Son serializables, pero su clase concreta es un detalle interno. Guarda un ArrayList o un HashMap explícitos.
  • Un writeObject/readObject con la firma incorrecta. Debe ser exactamente private void, con ese parámetro y esas excepciones. Si no, se ignora sin ningún aviso.
  • Olvidar readResolve en un singleton. Deserializar crea una segunda instancia y rompe la unicidad. Los enum no tienen este problema.
  • Deserializar datos que no controlas. El error más grave posible. Permite ejecución de código y el daño ocurre antes de que tu código vea nada.
  • Confiar en un instanceof posterior. Llega tarde: la clase ya se instanció durante readObject().
  • Usar una lista negra en el filtro. Siempre se queda corta. Lista blanca y !* al final.
  • Creer que un filtro basta. Reduce el riesgo; no autoriza a deserializar lo que llegue.
  • Usar serialización como formato de intercambio. Solo Java lo lee, no es legible, y se rompe con cada cambio de clase.
  • Usarla para almacenamiento a largo plazo. Los ficheros de hace dos años dejan de leerse en cuanto evoluciona el código.
  • Consejo: separa el objeto vivo del objeto persistible. SesionBiblioteca gestiona recursos; EstadoSesion solo tiene datos. Esa separación elimina de raíz el problema del contagio.
  • Consejo: activa -Xlint:serial. El aviso que evita el problema antes de tenerlo.
  • Consejo: escribe una prueba de compatibilidad. Guarda un fichero de referencia con la versión actual, mételo en el repositorio y ten una prueba que lo lea. El día que alguien rompa la compatibilidad, la prueba falla en vez de fallar el cliente.
  • Consejo: si el dato tiene valor, no lo guardes solo así. La serialización es aceptable para caché y estado transitorio. Para lo que importa, un formato legible.

Ejercicios

Ejercicio 1: laboratorio de compatibilidad

Escribe LaboratorioCompatibilidad que demuestre empíricamente el efecto del serialVersionUID:

  1. Una clase interna DatoV1 sin serialVersionUID, con tres campos.
  2. Un método que la serialice a fichero y otro que la deserialice.
  3. Documenta con comentarios el experimento: compilar, guardar, añadir un método privado, recompilar, e intentar leer. Incluye el mensaje de error esperado.
  4. Una clase DatoV2 con serialVersionUID = 1L y un campo añadido, y demuestra que sí puede leer un fichero escrito por DatoV1B (misma clase, mismo UID, sin ese campo), mostrando qué valor toma el campo nuevo.
  5. Un informe final con la tabla de qué cambios son compatibles.

Ejercicio 2: copia profunda por serialización

Escribe una clase de utilidad CopiaProfunda con un método que cree una copia profunda de cualquier objeto serializable usando ByteArrayOutputStream y ByteArrayInputStream (07-03), sin tocar el disco.

  1. El método debe funcionar con cualquier objeto serializable (usa Object; no definas genéricos propios, que es 10-01).
  2. Demuestra la diferencia entre copia superficial y profunda con una clase CarritoPrestamos que contenga una List<String>: modifica la lista de la copia y comprueba que el original no cambia.
  3. Mide el tiempo y compáralo con una copia manual escrita a mano.
  4. Explica en comentarios tres limitaciones de esta técnica.

Ejercicio 3: gestor de sesiones con versionado

Amplía GestorSesion para que soporte dos versiones del estado guardado:

  1. EstadoSesion versión 1 con los campos actuales.
  2. Añade un campo int materialesConsultados manteniendo serialVersionUID = 1L.
  3. En readObject, detecta que el fichero es antiguo —el campo nuevo vale 0— y aplica un valor por defecto razonable con un aviso en el logger.
  4. Añade un método listarSesiones() que devuelva un informe de todas las sesiones guardadas en el directorio, indicando cuáles son legibles y cuáles no.
  5. Añade limpiarSesionesInvalidas() que aparte los ficheros ilegibles.
  6. Un main que demuestre el ciclo completo.

Soluciones

Solución 1

package com.nexussoftware.bibliotech.demo;

import java.io.*;

/**
 * Laboratorio del serialVersionUID.
 *
 * COMO REPRODUCIR EL EXPERIMENTO PRINCIPAL:
 *
 *   1. Compilar y ejecutar con el metodo getEtiqueta() COMENTADO en DatoV1.
 *      Se genera datos/v1.ser
 *
 *   2. DESCOMENTAR getEtiqueta() (un metodo PRIVADO, que no toca el estado).
 *
 *   3. Recompilar y volver a ejecutar.
 *
 *   RESULTADO: al leer datos/v1.ser se obtiene
 *
 *     java.io.InvalidClassException: ...DatoV1; local class incompatible:
 *       stream classdesc serialVersionUID = -4738291056473829104,
 *       local class serialVersionUID = 8273645019283746152
 *
 *   El fichero queda ILEGIBLE PARA SIEMPRE por haber anadido un metodo
 *   privado que no afecta al estado en absoluto.
 */
public class LaboratorioCompatibilidad {

    // ---------------- SIN serialVersionUID: fragil ----------------

    static class DatoV1 implements Serializable {
        // SIN serialVersionUID: se calcula automaticamente a partir de
        // nombre, campos, METODOS (incluidos los privados), constructores...
        private final String referencia;
        private final String titulo;
        private final int anio;

        DatoV1(String referencia, String titulo, int anio) {
            this.referencia = referencia;
            this.titulo = titulo;
            this.anio = anio;
        }

        // PASO 2 DEL EXPERIMENTO: descomentar esto y recompilar.
        // private String getEtiqueta() { return referencia + " - " + titulo; }

        @Override
        public String toString() {
            return String.format("DatoV1[%s, %s, %d]", referencia, titulo, anio);
        }
    }

    // ---------------- CON serialVersionUID: estable ----------------

    /** Version B: tres campos, UID declarado. */
    static class DatoV2 implements Serializable {
        private static final long serialVersionUID = 1L;

        private final String referencia;
        private final String titulo;
        private final int anio;

        // CAMPO ANADIDO en la version 2. Compatible: los ficheros escritos
        // sin el se leen igual, y este campo toma su valor por defecto.
        private final String autor;

        DatoV2(String referencia, String titulo, int anio, String autor) {
            this.referencia = referencia;
            this.titulo = titulo;
            this.anio = anio;
            this.autor = autor;
        }

        @Override
        public String toString() {
            return String.format("DatoV2[%s, %s, %d, autor=%s]",
                    referencia, titulo, anio,
                    autor == null ? "(null: fichero antiguo)" : autor);
        }
    }

    // ---------------- Utilidades ----------------

    static void guardar(Object o, String ruta) throws IOException {
        try (ObjectOutputStream salida = new ObjectOutputStream(
                new BufferedOutputStream(new FileOutputStream(ruta)))) {
            salida.writeObject(o);
        }
    }

    static Object cargar(String ruta) throws IOException, ClassNotFoundException {
        try (ObjectInputStream entrada = new ObjectInputStream(
                new BufferedInputStream(new FileInputStream(ruta)))) {
            return entrada.readObject();
        }
    }

    /** Muestra el UID que el mecanismo asigna a una clase, declarado o calculado. */
    static long uidDe(Class<?> clase) {
        ObjectStreamClass osc = ObjectStreamClass.lookup(clase);
        return (osc == null) ? 0L : osc.getSerialVersionUID();
    }

    public static void main(String[] args) throws Exception {
        new File("datos").mkdirs();

        System.out.println("=== UID DE CADA CLASE ===");
        System.out.printf("  DatoV1 (sin declarar): %d%n", uidDe(DatoV1.class));
        System.out.printf("  DatoV2 (declarado)   : %d%n", uidDe(DatoV2.class));
        System.out.println("  El primero cambia al tocar la clase; el segundo, nunca.");
        System.out.println();

        // --- Experimento 1: sin UID declarado ---
        System.out.println("=== SIN serialVersionUID ===");
        File v1 = new File("datos/v1.ser");

        if (!v1.exists()) {
            guardar(new DatoV1("978-0000000001", "Java Efectivo", 2018), v1.getPath());
            System.out.println("  Fichero creado. AHORA: descomenta getEtiqueta(),");
            System.out.println("  recompila y vuelve a ejecutar.");
        } else {
            try {
                System.out.println("  Leido: " + cargar(v1.getPath()));
                System.out.println("  (la clase no ha cambiado desde que se guardo)");
            } catch (InvalidClassException e) {
                System.out.println("  INCOMPATIBLE, como se esperaba:");
                System.out.println("  " + e.getMessage());
                System.out.println("  Causa: se anadio un metodo. El fichero es ilegible.");
            }
        }

        // --- Experimento 2: con UID declarado y campo anadido ---
        System.out.println();
        System.out.println("=== CON serialVersionUID ===");

        File v2 = new File("datos/v2.ser");
        guardar(new DatoV2("978-0000000002", "Patrones de Diseno", 1994, "Gamma"),
                v2.getPath());
        System.out.println("  Guardado y leido: " + cargar(v2.getPath()));

        System.out.println();
        System.out.println("=== CAMBIOS COMPATIBLES (con el UID declarado) ===");
        System.out.println("  COMPATIBLES:");
        System.out.println("    - Anadir un campo        -> vale null / 0 / false");
        System.out.println("    - Eliminar un campo      -> el valor del fichero se descarta");
        System.out.println("    - Anadir o quitar metodos-> ningun efecto");
        System.out.println("    - Cambiar el cuerpo      -> ningun efecto");
        System.out.println("    - normal <-> transient   -> como anadir o quitar campo");
        System.out.println("  INCOMPATIBLES:");
        System.out.println("    - Cambiar el TIPO de un campo -> InvalidClassException");
        System.out.println("    - RENOMBRAR un campo          -> se PIERDE el dato, sin error");
        System.out.println("    - Renombrar o mover la clase  -> ClassNotFoundException");
        System.out.println("    - Cambiar la herencia         -> InvalidClassException");
    }
}

El punto crítico del ejercicio está en el comentario de cabecera: un método privado que no toca el estado invalida todos los ficheros. Es la demostración más contundente de por qué serialVersionUID no es opcional. Y fíjate en la línea de DatoV2 que imprime autor=(null: fichero antiguo): así se ve exactamente qué ocurre al añadir un campo, y por qué hay que decidir un valor por defecto sensato en lugar de aceptar el null.

Solución 2

package com.nexussoftware.bibliotech.util;

import java.io.*;
import java.util.ArrayList;
import java.util.List;

/**
 * Copia profunda mediante serializacion en memoria.
 *
 * Serializa a un array de bytes y deserializa desde el: el resultado es un
 * grafo completamente nuevo, sin ninguna referencia compartida con el
 * original. Sin tocar el disco: ByteArrayOutputStream / ByteArrayInputStream
 * (07-03).
 *
 * TRES LIMITACIONES REALES:
 *
 *   1. TODO el grafo debe ser Serializable. Un solo campo no serializable a
 *      cualquier profundidad hace fallar la copia entera (NotSerializableException).
 *
 *   2. ES LENTA. Serializar y deserializar implica reflexion, construccion
 *      de metadatos y copia de bytes. Un metodo de copia escrito a mano es
 *      entre diez y cien veces mas rapido.
 *
 *   3. LOS CAMPOS transient NO SE COPIAN. Quedan a null o 0 en la copia,
 *      que es correcto para una cache pero un error silencioso si el campo
 *      se marco transient por otro motivo.
 *
 *   Y una cuarta que conviene tener presente: si el objeto viniera de una
 *   fuente no confiable, esta tecnica hereda TODOS los riesgos del apartado
 *   12. Usala solo con objetos que ya tienes en memoria y controlas.
 */
public final class CopiaProfunda {

    private CopiaProfunda() { }

    /**
     * Copia profunda de un objeto serializable.
     *
     * Devuelve Object: convertir es cosa de quien llama. (Con genericos
     * quedaria mejor, pero eso es 10-01.)
     */
    public static Object copiar(Serializable original) throws IOException {
        if (original == null) {
            return null;
        }

        ByteArrayOutputStream memoria = new ByteArrayOutputStream();

        try (ObjectOutputStream salida = new ObjectOutputStream(memoria)) {
            salida.writeObject(original);
        }
        // ByteArrayOutputStream no necesita cerrarse: su close() no hace nada,
        // y cerrarlo antes de toByteArray() seria un error conceptual (07-03).

        try (ObjectInputStream entrada = new ObjectInputStream(
                new ByteArrayInputStream(memoria.toByteArray()))) {

            return entrada.readObject();

        } catch (ClassNotFoundException e) {
            // Imposible en la practica: la clase esta cargada, acabamos de
            // serializarla. Se traduce a IOException para no obligar a
            // quien llama a capturar algo que no puede pasar (06-03).
            throw new IOException("Fallo imposible al copiar: clase no encontrada", e);
        }
    }

    // ------------------------- DEMOSTRACION -------------------------

    /** Objeto de prueba con una lista mutable dentro. */
    static class CarritoPrestamos implements Serializable {
        private static final long serialVersionUID = 1L;

        private final String idEmpleado;
        private final List<String> referencias;
        private double totalEstimado;

        /** transient: NO se copiara. Queda a null en la copia. */
        private transient String notaInterna;

        CarritoPrestamos(String idEmpleado) {
            this.idEmpleado = idEmpleado;
            this.referencias = new ArrayList<>();
            this.notaInterna = "nota de trabajo";
        }

        void anadir(String referencia, double coste) {
            referencias.add(referencia);
            totalEstimado += coste;
        }

        List<String> getReferencias() { return referencias; }   // referencia mutable
        String getNotaInterna()       { return notaInterna; }

        @Override
        public String toString() {
            return String.format("Carrito[%s, %s, %.2f, nota=%s]",
                    idEmpleado, referencias, totalEstimado,
                    notaInterna == null ? "(null)" : notaInterna);
        }
    }

    /** Copia manual, escrita a mano, para comparar rendimiento. */
    static CarritoPrestamos copiarAMano(CarritoPrestamos original) {
        CarritoPrestamos copia = new CarritoPrestamos(original.idEmpleado);
        copia.referencias.addAll(original.referencias);      // nueva lista
        copia.totalEstimado = original.totalEstimado;
        copia.notaInterna = original.notaInterna;            // esta SI se copia
        return copia;
    }

    public static void main(String[] args) throws IOException {
        CarritoPrestamos original = new CarritoPrestamos("E-001");
        original.anadir("978-0000000001", 0.25);
        original.anadir("978-0000000002", 0.25);

        System.out.println("=== COPIA SUPERFICIAL (asignacion de referencia) ===");
        CarritoPrestamos superficial = original;
        superficial.getReferencias().add("978-0000000003");
        System.out.println("  Original: " + original.getReferencias());
        System.out.println("  'Copia' : " + superficial.getReferencias());
        System.out.println("  Son el MISMO objeto: " + (original == superficial));

        System.out.println();
        System.out.println("=== COPIA PROFUNDA (serializacion) ===");
        CarritoPrestamos profunda = (CarritoPrestamos) copiar(original);
        profunda.getReferencias().add("978-0000000004");

        System.out.println("  Original: " + original.getReferencias());
        System.out.println("  Copia   : " + profunda.getReferencias());
        System.out.println("  Objetos distintos      : " + (original != profunda));
        System.out.println("  Listas distintas       : "
                + (original.getReferencias() != profunda.getReferencias()));
        System.out.println("  Nota (transient) copiada: " + profunda.getNotaInterna());

        System.out.println();
        System.out.println("=== RENDIMIENTO (10 000 copias) ===");

        long inicio = System.nanoTime();
        for (int i = 0; i < 10_000; i++) {
            copiar(original);
        }
        long msSerializacion = (System.nanoTime() - inicio) / 1_000_000;

        inicio = System.nanoTime();
        for (int i = 0; i < 10_000; i++) {
            copiarAMano(original);
        }
        long msManual = (System.nanoTime() - inicio) / 1_000_000;

        System.out.printf("  Por serializacion: %5d ms%n", msSerializacion);
        System.out.printf("  A mano           : %5d ms%n", msManual);
        System.out.printf("  Factor           : %.1fx mas lenta%n",
                (double) msSerializacion / Math.max(1, msManual));
    }
}

Salida:

=== COPIA SUPERFICIAL (asignacion de referencia) ===
  Original: [978-0000000001, 978-0000000002, 978-0000000003]
  'Copia' : [978-0000000001, 978-0000000002, 978-0000000003]
  Son el MISMO objeto: true

=== COPIA PROFUNDA (serializacion) ===
  Original: [978-0000000001, 978-0000000002, 978-0000000003]
  Copia   : [978-0000000001, 978-0000000002, 978-0000000003, 978-0000000004]
  Objetos distintos      : true
  Listas distintas       : true
  Nota (transient) copiada: null

=== RENDIMIENTO (10 000 copias) ===
  Por serializacion:   412 ms
  A mano           :     4 ms
  Factor           : 103.0x mas lenta

Los tres puntos que hay que ver en esa salida:

  1. La copia superficial no es una copia. superficial = original copia la referencia: añadir a una añade a la otra, porque son el mismo objeto. Es el error clásico que esta técnica resuelve.
  2. transient no se copia. La nota interna sale a null. Es correcto para una caché y es un error silencioso si el campo se marcó transient por otro motivo, como una contraseña que la copia sí necesitaba.
  3. Cien veces más lenta. Para copiar un objeto en un bucle, escribe el método de copia a mano. Esta técnica sirve cuando el grafo es profundo, cambia a menudo y el rendimiento no es crítico: entonces ahorra escribir y mantener decenas de líneas de copia.

Solución 3

package com.nexussoftware.bibliotech.servicio;

import java.io.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.nexussoftware.bibliotech.infraestructura.FiltroDeserializacion;

/**
 * Gestor de sesiones con soporte de DOS versiones del estado guardado.
 *
 * La version 2 anade el campo 'materialesConsultados' MANTENIENDO el mismo
 * serialVersionUID, porque anadir un campo es un cambio COMPATIBLE: los
 * ficheros de la version 1 se siguen leyendo y el campo nuevo llega a 0.
 *
 * El truco esta en readObject: distinguir "de verdad se consultaron 0" de
 * "este fichero es antiguo y no lo sabe" es imposible con el valor por
 * defecto, asi que se usa un CENTINELA explicito.
 */
public class GestorSesionVersionado {

    private static final Logger LOG =
            Logger.getLogger(GestorSesionVersionado.class.getName());

    private static final String EXTENSION = ".sesion";
    private static final String EXTENSION_INVALIDA = ".sesion.invalido";

    private final File directorio;

    public GestorSesionVersionado(String directorioDatos) {
        this.directorio = new File(Objects.requireNonNull(directorioDatos));
        if (!directorio.exists() && !directorio.mkdirs()) {
            LOG.warning("No se pudo crear " + directorio.getAbsolutePath());
        }
    }

    // ------------------------- EL ESTADO, VERSION 2 -------------------------

    public static class EstadoSesion implements Serializable {

        /** MISMO valor que en la version 1: el cambio es compatible. */
        private static final long serialVersionUID = 1L;

        /** Marca "no informado", para distinguirlo de un 0 real. */
        private static final int NO_INFORMADO = -1;

        private final String idEmpleado;
        private final int diaApertura;
        private final List<String> operaciones;
        private final int prestamosRealizados;
        private final int devolucionesRealizadas;
        private final double multasCobradas;

        /**
         * CAMPO ANADIDO EN LA VERSION 2.
         *
         * No es final: readObject necesita poder corregirlo cuando el
         * fichero viene de la version 1.
         */
        private int materialesConsultados;

        /** Version del formato con la que se creo. transient: se deduce. */
        private transient int versionDetectada = 2;

        public EstadoSesion(String idEmpleado, int diaApertura, List<String> operaciones,
                            int prestamosRealizados, int devolucionesRealizadas,
                            double multasCobradas, int materialesConsultados) {

            this.idEmpleado = Objects.requireNonNull(idEmpleado, "El empleado es obligatorio");
            if (diaApertura < 1) {
                throw new IllegalArgumentException("Dia invalido: " + diaApertura);
            }
            if (multasCobradas < 0) {
                throw new IllegalArgumentException("Multas negativas: " + multasCobradas);
            }
            this.diaApertura = diaApertura;
            this.operaciones = new ArrayList<>(operaciones);
            this.prestamosRealizados = prestamosRealizados;
            this.devolucionesRealizadas = devolucionesRealizadas;
            this.multasCobradas = multasCobradas;
            this.materialesConsultados = Math.max(0, materialesConsultados);
        }

        /**
         * Migracion y validacion.
         *
         * Un fichero de la version 1 no contiene 'materialesConsultados', asi
         * que llega con 0. Como no podemos distinguir ese 0 de un 0 real,
         * aplicamos una HEURISTICA razonable y DEJAMOS CONSTANCIA en el log:
         * quien consulto materiales al menos consulto los que presto.
         */
        private void readObject(ObjectInputStream entrada)
                throws IOException, ClassNotFoundException {

            entrada.defaultReadObject();

            // --- VALIDACION (el constructor no se ha ejecutado) ---
            if (idEmpleado == null || idEmpleado.isBlank()) {
                throw new InvalidObjectException("Sesion sin empleado: fichero corrupto");
            }
            if (diaApertura < 1) {
                throw new InvalidObjectException("Dia invalido: " + diaApertura);
            }
            if (multasCobradas < 0) {
                throw new InvalidObjectException("Multas negativas: " + multasCobradas);
            }
            if (operaciones == null) {
                throw new InvalidObjectException("Lista de operaciones nula");
            }

            // --- MIGRACION DE VERSION ---
            if (materialesConsultados == 0 && prestamosRealizados > 0) {
                versionDetectada = 1;
                materialesConsultados = prestamosRealizados;

                LOG.info(() -> String.format(
                        "Sesion de %s guardada con la version 1 del formato: se estima "
                                + "materialesConsultados = %d a partir de los prestamos",
                        idEmpleado, prestamosRealizados));
            } else {
                versionDetectada = 2;
            }

            if (materialesConsultados < 0) {
                materialesConsultados = 0;
            }
        }

        public String getIdEmpleado()          { return idEmpleado; }
        public int getDiaApertura()            { return diaApertura; }
        public List<String> getOperaciones()   { return List.copyOf(operaciones); }
        public int getPrestamosRealizados()    { return prestamosRealizados; }
        public int getDevolucionesRealizadas() { return devolucionesRealizadas; }
        public double getMultasCobradas()      { return multasCobradas; }
        public int getMaterialesConsultados()  { return materialesConsultados; }
        public int getVersionDetectada()       { return versionDetectada; }

        public String getResumen() {
            return String.format(
                    "Sesion de %s (dia %d, formato v%d): %d prestamos, %d devoluciones, "
                            + "%d consultas, %.2f EUR",
                    idEmpleado, diaApertura, versionDetectada, prestamosRealizados,
                    devolucionesRealizadas, materialesConsultados, multasCobradas);
        }
    }

    // ------------------------- OPERACIONES -------------------------

    public void guardar(EstadoSesion estado) throws IOException {
        Objects.requireNonNull(estado, "El estado no puede ser nulo");

        File destino = new File(directorio, estado.getIdEmpleado() + EXTENSION);
        File temporal = new File(destino.getAbsolutePath() + ".tmp");
        boolean completado = false;

        try {
            try (ObjectOutputStream salida = new ObjectOutputStream(
                    new BufferedOutputStream(new FileOutputStream(temporal)))) {
                salida.writeObject(estado);
            }
            if (destino.exists() && !destino.delete()) {
                throw new IOException("No se pudo sustituir " + destino.getName());
            }
            if (!temporal.renameTo(destino)) {
                throw new IOException("No se pudo renombrar " + temporal.getName());
            }
            completado = true;
            LOG.info(() -> "Sesion guardada: " + destino.getName());

        } finally {
            if (!completado && temporal.exists() && !temporal.delete()) {
                LOG.warning(() -> "Temporal sin borrar: " + temporal.getAbsolutePath());
            }
        }
    }

    public java.util.Optional<EstadoSesion> restaurar(String idEmpleado) {
        File fichero = new File(directorio, idEmpleado + EXTENSION);
        if (!fichero.exists()) {
            return java.util.Optional.empty();
        }
        try {
            return java.util.Optional.of(leer(fichero));
        } catch (Exception e) {
            LOG.log(Level.WARNING, "No se pudo restaurar " + fichero.getName(), e);
            return java.util.Optional.empty();
        }
    }

    /** Lectura con filtro. Lanza para que quien llame decida. */
    private EstadoSesion leer(File fichero) throws IOException, ClassNotFoundException {
        try (ObjectInputStream entrada = new ObjectInputStream(
                new BufferedInputStream(new FileInputStream(fichero)))) {

            FiltroDeserializacion.aplicarA(entrada);
            return (EstadoSesion) entrada.readObject();
        }
    }

    // ------------------------- INFORME Y LIMPIEZA -------------------------

    /** Estado de un fichero de sesion del directorio. */
    public record InfoSesion(String nombre, long tamano, boolean legible,
                             String detalle) { }

    /**
     * Informe de todas las sesiones guardadas.
     *
     * Intenta leer cada una y clasifica el resultado. NO propaga: el informe
     * debe poder generarse aunque haya ficheros rotos, que es precisamente
     * cuando mas falta hace.
     */
    public List<InfoSesion> listarSesiones() {
        List<InfoSesion> informe = new ArrayList<>();

        File[] ficheros = directorio.listFiles(
                (dir, nombre) -> nombre.endsWith(EXTENSION));

        if (ficheros == null) {                        // puede ser null (07-01)
            LOG.warning("No se pudo listar " + directorio.getAbsolutePath());
            return informe;
        }

        Arrays.sort(ficheros);                          // orden estable (05-09)

        for (File f : ficheros) {
            try {
                EstadoSesion estado = leer(f);
                informe.add(new InfoSesion(f.getName(), f.length(), true,
                        estado.getResumen()));

            } catch (InvalidClassException e) {
                informe.add(new InfoSesion(f.getName(), f.length(), false,
                        "Version de clase incompatible"));

            } catch (InvalidObjectException e) {
                informe.add(new InfoSesion(f.getName(), f.length(), false,
                        "Rechazada por validacion: " + e.getMessage()));

            } catch (StreamCorruptedException | EOFException e) {
                informe.add(new InfoSesion(f.getName(), f.length(), false,
                        "Fichero corrupto o truncado"));

            } catch (ClassNotFoundException e) {
                informe.add(new InfoSesion(f.getName(), f.length(), false,
                        "Clase no encontrada: " + e.getMessage()));

            } catch (IOException e) {
                informe.add(new InfoSesion(f.getName(), f.length(), false,
                        "Fallo de E/S: " + e.getMessage()));
            }
        }
        return informe;
    }

    /**
     * Aparta los ficheros ilegibles renombrandolos.
     *
     * NO los borra: un fichero ilegible puede hacer falta para diagnosticar
     * por que dejo de serlo.
     *
     * @return cuantos se apartaron
     */
    public int limpiarSesionesInvalidas() {
        int apartados = 0;

        for (InfoSesion info : listarSesiones()) {
            if (info.legible()) {
                continue;
            }
            File origen = new File(directorio, info.nombre());
            File destino = new File(directorio,
                    info.nombre().replace(EXTENSION, EXTENSION_INVALIDA));

            if (destino.exists()) {
                destino.delete();
            }
            if (origen.renameTo(destino)) {
                apartados++;
                LOG.info(() -> "Apartada sesion ilegible: " + destino.getName()
                        + " (" + info.detalle() + ")");
            } else {
                LOG.warning(() -> "No se pudo apartar " + origen.getName());
            }
        }
        return apartados;
    }

    // ------------------------- DEMOSTRACION -------------------------

    public static void main(String[] args) throws IOException {
        GestorSesionVersionado gestor = new GestorSesionVersionado("datos/sesiones");

        // 1. Guardar tres sesiones
        gestor.guardar(new EstadoSesion("E-001", 15,
                List.of("PRESTAMO PR-0001", "DEVOLUCION PR-0001"), 1, 1, 1.75, 4));
        gestor.guardar(new EstadoSesion("E-002", 15,
                List.of("PRESTAMO PR-0002"), 1, 0, 0.0, 2));
        gestor.guardar(new EstadoSesion("E-003", 16,
                List.of(), 0, 0, 0.0, 0));

        // 2. Crear un fichero corrupto a proposito
        File corrupto = new File("datos/sesiones/E-999.sesion");
        try (FileOutputStream fos = new FileOutputStream(corrupto)) {
            fos.write("esto no es un flujo de objetos".getBytes());
        }

        // 3. Informe
        System.out.println("=== SESIONES GUARDADAS ===");
        for (InfoSesion info : gestor.listarSesiones()) {
            System.out.printf("  %-18s %6d bytes  %-10s %s%n",
                    info.nombre(), info.tamano(),
                    info.legible() ? "[OK]" : "[ERROR]", info.detalle());
        }

        // 4. Restauracion concreta
        System.out.println();
        System.out.println("=== RESTAURACION ===");
        gestor.restaurar("E-001").ifPresentOrElse(
                e -> System.out.println("  " + e.getResumen()),
                () -> System.out.println("  No se pudo restaurar E-001"));

        // 5. Limpieza
        System.out.println();
        System.out.printf("=== LIMPIEZA: %d ficheros apartados ===%n",
                gestor.limpiarSesionesInvalidas());
    }
}

Salida:

=== SESIONES GUARDADAS ===
  E-001.sesion          287 bytes  [OK]       Sesion de E-001 (dia 15, formato v2): 1 prestamos, 1 devoluciones, 4 consultas, 1,75 EUR
  E-002.sesion          256 bytes  [OK]       Sesion de E-002 (dia 15, formato v2): 1 prestamos, 0 devoluciones, 2 consultas, 0,00 EUR
  E-003.sesion          231 bytes  [OK]       Sesion de E-003 (dia 16, formato v2): 0 prestamos, 0 devoluciones, 0 consultas, 0,00 EUR
  E-999.sesion           30 bytes  [ERROR]    Fichero corrupto o truncado

=== RESTAURACION ===
  Sesion de E-001 (dia 15, formato v2): 1 prestamos, 1 devoluciones, 4 consultas, 1,75 EUR

=== LIMPIEZA: 1 ficheros apartados ===

Los cuatro puntos didácticos:

  1. El serialVersionUID no cambia al añadir el campo. Añadir un campo es compatible, así que mantenerlo es lo correcto. Incrementarlo habría invalidado innecesariamente todas las sesiones existentes.
  2. La migración usa una heurística explícita y la registra. Un fichero de la versión 1 llega con materialesConsultados = 0, y no hay forma de distinguirlo de un 0 real. La solución honesta es aplicar una estimación razonable, dejarla en el logger y exponer versionDetectada para que se sepa que el dato es estimado. Fingir que el dato es exacto sería peor.
  3. listarSesiones() no propaga. Un informe de diagnóstico tiene que poder generarse precisamente cuando hay ficheros rotos. Si el primer fichero corrupto abortara el listado, la herramienta sería inútil justo cuando hace falta.
  4. Los ficheros ilegibles se apartan, no se borran. Renombrarlos permite investigar después qué ocurrió sin bloquear al usuario. Borrar datos porque no se entienden es una decisión que casi nunca es la correcta.

Conclusión

Sabes serializar, y —más importante— sabes cuándo no hacerlo.

Entiendes que serializar es convertir un grafo de objetos en bytes, no un objeto suelto: con sus referencias compartidas preservadas mediante una tabla interna, de modo que dos préstamos que apuntaban al mismo Empleado vuelven a apuntar a la misma instancia, y con las referencias circulares resueltas sin recursión infinita. Reproducir eso a mano exige inventar identificadores y hacer dos pasadas al cargar; esa es la ventaja real del mecanismo, y no es pequeña.

Conoces Serializable como interfaz marcadora —el concepto de 04-01— y sabes que implementarla es un compromiso serio: la forma serializada de una clase pasa a formar parte de su API pública, con todos sus campos privados expuestos y sujetos a compatibilidad. Sabes usar ObjectOutputStream/ObjectInputStream con writeObject/readObject, escribir y leer varios objetos, y que serializar la colección entera es mejor que llevar un contador a mano. Y sabes que ClassNotFoundException no extiende IOException y necesita su propio catch.

Sabes qué se guarda y qué no: todo lo de instancia, incluidos los private y los final, y nada de lo static ni de lo transient. Conoces los tres usos legítimos de transient —datos derivados, secretos y recursos no serializables— y el detalle que provoca NullPointerException la primera vez que se usa un objeto restaurado: los inicializadores de campo no se ejecutan, así que un transient Map inicializado en su declaración llega a null. Conoces el efecto contagio de NotSerializableException, cómo leer su stack trace y las tres soluciones, con la regla que lo evita casi siempre: el Logger va private static final.

Dominas serialVersionUID: qué es, cómo se calcula por defecto a partir de nombres, campos, métodos —incluidos los privados— y constructores, y por qué eso significa que añadir un método auxiliar deja ilegibles todos los ficheros guardados. Sabes declararlo con sus cuatro modificadores exactos, sabes que si te dejas alguno se ignora en silencio, y sabes que solo se incrementa ante un cambio incompatible. Ahora entiendes del todo por qué tus excepciones del módulo 6 lo llevan: una excepción que no se puede deserializar convierte un error de negocio comprensible en un fallo incomprensible de infraestructura. Y tienes la tabla completa de qué cambios son compatibles, con las dos trampas: añadir un campo es compatible pero su valor por defecto puede violar tus invariantes, y renombrar un campo pierde el dato sin ningún error.

Sabes personalizar con writeObject/readObject privados, cuya firma debe ser exacta o se ignoran sin avisar, y conoces su uso más importante: validar. Porque la deserialización no llama a ningún constructor, todas las comprobaciones que escribiste en 06-03 se saltan, y un fichero manipulado puede producir objetos que tu código considera imposibles. readObject es la puerta trasera de tu dominio y hay que cerrarla con InvalidObjectException. Conoces readResolve para preservar los singleton, Externalizable y sus tres motivos para no usarlo, y las reglas de la herencia, con el constructor sin argumentos que necesita toda superclase no serializable.

Y tienes el apartado que de verdad importa: deserializar datos no confiables permite ejecutar código arbitrario, porque readObject instancia clases cuyo nombre viene en el fichero y ejecuta sus métodos antes de que tu código vea nada, lo que hace inútil cualquier comprobación posterior. Sabes qué se considera no confiable —básicamente todo lo que no hayas escrito tú en un directorio que controlas—, conoces los ObjectInputFilter con lista blanca y !* al final, y sus cuatro límites numéricos contra las bombas de deserialización. Y sabes que el filtro reduce el riesgo pero no lo elimina, con el aviso formal de que cualquier uso que cruce una frontera de confianza debe revisarlo el responsable de seguridad de tu organización, no tú un martes por la tarde.

Tienes la tabla de alternativas con la diferencia crucial: los formatos de texto no instancian clases. Un CSV produce cadenas, y tú decides qué construir con ellas pasando por tus validaciones. Y sabes cuándo la serialización nativa sigue siendo razonable: caché local, estado interno, copia profunda en memoria, procesos de confianza que controlas por ambos extremos.

BiblioTech guarda y restaura una sesión completa entre ejecuciones. GestorSesion separa el objeto vivo (SesionBiblioteca, con sus bloqueos y su logger) del objeto persistible (EstadoSesion, solo datos), declara serialVersionUID desde el primer día, valida en readObject como si fuera un constructor, aplica un filtro antes de leer nada, escribe de forma atómica, distingue cinco tipos de fallo con su tratamiento propio, y aparta los ficheros ilegibles en lugar de borrarlos. Y la decisión que resume la lección está tomada explícitamente: la sesión usa serialización; el catálogo, no, porque el catálogo es el dato de valor y debe ser legible, versionable, importable y estable frente a cambios de código.

Nos queda por resolver la parte más incómoda de todo lo que has hecho hasta aquí. Sigues usando java.io.File —con sus boolean mudos que dicen que algo falló sin decir por qué, sus listFiles() que devuelven null, y su renameTo que se comporta distinto en cada sistema operativo—. Has escrito a mano la creación de directorios, la escritura atómica y la rotación de ficheros, y en cada una has tenido que poner un comentario diciendo que en 07-06 se hace bien.

En la lección 07-06, La API NIO.2: Path y Files, se hace bien. Verás por qué existe NIO.2 y qué problemas concretos de File resuelve; Path con resolve, normalize, relativize y toda su aritmética de rutas; la clase de utilidad Files con createDirectories, copy, move con ATOMIC_MOVE —que convierte tu escritura atómica en una sola llamada correcta—, delete frente a deleteIfExists, y excepciones específicas en lugar de boolean; la lectura y escritura de alto nivel con readString, writeString y newBufferedReader, con las StandardOpenOption que hacen imposible confundir APPEND con un charset; el recorrido de árboles de directorios; y los atributos de fichero. Y BiblioTech migrará por fin toda su capa de persistencia, creará su directorio datos/ si no existe, hará copias de seguridad rotativas de verdad y localizará todos sus informes en un árbol de carpetas.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

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

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

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

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

© Copyright 2026. Todos los derechos reservados