CodeGym /Cursos /JAVA 25 SELF /Migración y versionado de datos serializados

Migración y versionado de datos serializados

JAVA 25 SELF
Nivel 45 , Lección 3
Disponible

1. Problema: ¿qué ocurre al cambiar una clase serializable?

En proyectos reales los objetos se serializan a menudo — se guardan en archivos, bases de datos o cachés para después restaurarlos. Pero ¿qué pasará si cambias la clase, añadiendo o eliminando campos, o cambiando tipos, cuando en producción ya existen objetos antiguos serializados?

Por ejemplo, en producción hay un archivo con objetos guardados de la clase User. Publicas una nueva versión de la aplicación, donde en User se ha añadido un nuevo campo o se ha cambiado el tipo de un campo existente. Cuando el programa intente deserializar los datos antiguos, lo más probable es que termine con un error como InvalidClassException o con pérdida de datos, porque la estructura del objeto ya no coincide con las expectativas de la JVM.

Por eso es importante planificar de antemano la compatibilidad entre versiones de las clases y de los datos serializados. En producción no puedes simplemente «borrar» los archivos antiguos: hay que mantener la compatibilidad hacia atrás o implementar una migración de datos para que las nuevas versiones de la clase funcionen correctamente con los objetos ya guardados.

2. Solución con serialVersionUID

¿Qué es serialVersionUID?

Es un campo especial que define la «versión» de una clase serializable.

private static final long serialVersionUID = 1L;
  • Si no se especifica el campo, Java lo calcula automáticamente en función de la estructura de la clase.
  • En la deserialización se compara el serialVersionUID de la clase con el de los datos serializados.
  • Si no coincide, se lanza InvalidClassException.

Generación automática y control manual

Automáticamente: si no se indica explícitamente, el compilador calculará el valor en función de la estructura de la clase (nombre, campos, métodos, etc.).

Control manual: se recomienda indicar siempre explícitamente serialVersionUID en las clases serializables para controlar la compatibilidad.

Ejemplo:

public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    // ...
}

¿Cuándo cambiarlo y cuándo mantenerlo?

  • Mantenerlo: si los cambios no rompen la compatibilidad (por ejemplo, se añadió un nuevo campo que puede inicializarse por defecto).
  • Cambiarlo: si se eliminó un campo, se cambió el tipo de un campo, se modificó la jerarquía de clases u otros cambios incompatibles.

Regla:

- Si quieres que la nueva versión de la clase pueda leer objetos antiguos serializados, no cambies serialVersionUID.
- Si la incompatibilidad es crítica (mejor obtener un error que datos «corruptos»), incrementa serialVersionUID.

3. Estrategias de migración de datos

Uno de los enfoques más cómodos es la llamada migración «perezosa». La idea es que no transformas todos los datos antiguos de inmediato, sino que lo haces gradualmente cuando el objeto se lee por primera vez.

Por ejemplo, si has añadido un nuevo campo, al deserializar el objeto antiguo este simplemente recibirá el valor por defecto — 0, null o false, según el tipo. Si se eliminó un campo, la deserialización simplemente lo ignora. La JVM empareja los campos por nombre y tipo, así que muchos cambios «pasan solos».

Es más complicado al cambiar el tipo de un campo, por ejemplo cuando antes era int y pasa a ser String. La deserialización estándar ya no basta. La solución es implementar tu propio método readObject, que procese la conversión manualmente:

private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
    ObjectInputStream.GetField fields = in.readFields();
    // Campo antiguo: int age
    int age = fields.get("age", -1);
    // Campo nuevo: String ageStr
    this.ageStr = String.valueOf(age);
}

Así, los objetos antiguos se adaptan correctamente a la nueva versión de la clase en el momento de su primera lectura.

Patrón de «conversión in-place» (in-place conversion)

Este enfoque se diferencia de la migración perezosa en que todos los datos se transforman de inmediato. La idea es simple: recorres cada objeto serializado — en archivos o en la base de datos — lo lees con la versión antigua de la clase, creas el objeto de la nueva versión y lo escribes de nuevo en el formato actualizado.

Este método es útil cuando no se puede confiar en la migración «perezosa». Por ejemplo, con grandes volúmenes de datos o cuando los objetos se leen rara vez y necesitas que todos estén ya listos para trabajar con la nueva versión de la aplicación. En la práctica, esto suele hacerse mediante un script o una utilidad aparte. Por ejemplo, el proceso puede ser así:

// Ejemplo sencillo de conversión in-place
List<File> files = getSerializedFiles(); // lista de archivos con objetos antiguos
for (File file : files) {
    try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file))) {
        OldUser oldUser = (OldUser) ois.readObject(); // leemos el objeto antiguo
        NewUser newUser = new NewUser(oldUser); // creamos el nuevo objeto a partir del antiguo
        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file))) {
            oos.writeObject(newUser); // reescribimos el archivo con la nueva versión
        }
    } catch (IOException | ClassNotFoundException e) {
        e.printStackTrace();
    }
}

De este modo, todos los objetos se llevan inmediatamente a la nueva versión y quedan seguros para su uso en producción.

4. Trabajo con versiones obsoletas: trucos avanzados

ObjectInputStream.readClassDescriptor() y readFields()

  • readClassDescriptor() — permite interceptar el proceso de lectura de metadatos de la clase y sustituirlos si necesitas «engañar» a la serialización.
  • readFields() — permite leer campos por nombre, incluso si la estructura de la clase ha cambiado.

Ejemplo:

private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
    ObjectInputStream.GetField fields = in.readFields();
    String name = (String) fields.get("name", "unknown");
    int age = fields.defaulted("age") ? 0 : fields.get("age", 0);
    // ... inicialización de los nuevos campos
}

5. Práctica: dos versiones de una clase, serialización y migración

Paso 1. Versión antigua de la clase

// OldUser.java
import java.io.Serializable;

public class OldUser implements Serializable {
    private static final long serialVersionUID = 1L;
    public String name;
    public int age;

    public OldUser(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

Paso 2. Serializamos un objeto de la versión antigua

OldUser user = new OldUser("Vasya", 30);
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("user.dat"))) {
    out.writeObject(user);
}

Paso 3. Nueva versión de la clase (se añade el campo email, se cambia el tipo de age)

// User.java
import java.io.*;

public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    public String name;
    public String age; // ¡tipo cambiado!
    public String email; // campo nuevo

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        ObjectInputStream.GetField fields = in.readFields();
        this.name = (String) fields.get("name", "unknown");
        // Convertimos el campo antiguo age (int) a cadena
        if (!fields.defaulted("age")) {
            int oldAge = fields.get("age", 0);
            this.age = String.valueOf(oldAge);
        } else {
            this.age = "unknown";
        }
        // Campo nuevo email — por defecto null
        this.email = (String) fields.get("email", null);
    }
}

Paso 4. Deserialización del objeto antiguo con la clase nueva

try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("user.dat"))) {
    User user = (User) in.readObject();
    System.out.println(user.name + ", " + user.age + ", " + user.email);
}

Resultado:

  • El campo antiguo age se ha convertido en una cadena.
  • El nuevo campo email es null.
  • No hay error InvalidClassException, porque serialVersionUID coincide y hemos tratado manualmente el desajuste de tipos.

¿Qué ocurre si no se gestiona la incompatibilidad?

Si simplemente cambias el tipo de un campo y no implementas readObject, al deserializar obtendrás un error:

java.io.InvalidClassException: User; incompatible types for field age

6. Errores típicos al migrar datos serializados

Error n.º 1: No se indicó serialVersionUID — con el más mínimo cambio de la clase obtendrás InvalidClassException incluso ante cambios poco significativos.

Error n.º 2: Cambiaste el tipo de un campo sin tratarlo en readObject — obtendrás un error de incompatibilidad de tipos.

Error n.º 3: Eliminaste un campo y los datos antiguos aún lo contienen — Java simplemente ignorará ese campo, pero si era crítico, los datos se perderán.

Error n.º 4: Intentaste migrar todos los datos manualmente sin pruebas — puedes perder parte de la información u obtener objetos inconsistentes.

Error n.º 5: No actualizaste todos los lugares donde se serializa/deserializa el objeto — parte del código trabaja con la versión nueva, parte con la antigua, aparecen bugs «fantasma».

Error n.º 6: No previste una estrategia de migración para grandes volúmenes de datos — con la migración «perezosa» los usuarios pueden encontrarse errores inesperados al primer acceso a datos obsoletos.

Error n.º 7: No hiciste una copia de seguridad antes de la migración — ¡haz siempre una copia de seguridad de los datos serializados antes de actualizar!

Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION