CodeGym /Kurse /JAVA 25 SELF /Migration und Versionierung serialisierter Daten

Migration und Versionierung serialisierter Daten

JAVA 25 SELF
Level 45 , Lektion 3
Verfügbar

1. Problem: Was passiert bei Änderungen an einer Klasse mit Serialisierung?

In realen Projekten werden Objekte häufig serialisiert – in Dateien, Datenbanken oder Caches gespeichert, um sie später wiederherzustellen. Doch was geschieht, wenn Sie die Klasse ändern, Felder hinzufügen oder entfernen oder Typen ändern, während in der Produktion bereits alte serialisierte Objekte liegen?

Beispielsweise gibt es im Produktionsbetrieb eine Datei mit gespeicherten Objekten der Klasse User. Sie veröffentlichen eine neue Version der Anwendung, in der in User ein neues Feld hinzugefügt oder der Typ eines bestehenden Feldes geändert wurde. Wenn das Programm versucht, die alten Daten zu deserialisieren, endet das meist mit einem Fehler wie InvalidClassException oder mit Datenverlust, weil die Objektstruktur nicht mehr den Erwartungen der JVM entspricht.

Daher ist es wichtig, die Kompatibilität zwischen Versionen von Klassen und serialisierten Daten im Voraus zu planen. Im Produktionsbetrieb kann man alte Dateien nicht einfach „löschen“ – entweder man erhält die Abwärtskompatibilität aufrecht oder führt eine Datenmigration durch, damit neue Klassenversionen korrekt mit bereits gespeicherten Objekten arbeiten.

2. Lösung mit serialVersionUID

Was ist serialVersionUID?

Dies ist ein spezielles Feld, das die „Version“ einer serialisierbaren Klasse definiert.

private static final long serialVersionUID = 1L;
  • Wenn das Feld nicht angegeben ist, berechnet Java es automatisch auf Basis der Struktur der Klasse.
  • Bei der Deserialisierung wird die serialVersionUID der Klasse mit der in den serialisierten Daten verglichen.
  • Wenn sie nicht übereinstimmen, wird eine InvalidClassException ausgelöst.

Automatische Generierung und manuelle Steuerung

Automatisch: Wenn sie nicht explizit angegeben ist, berechnet der Compiler den Wert basierend auf der Klassenstruktur (Name, Felder, Methoden usw.).

Manuelle Steuerung: Es wird empfohlen, die serialVersionUID in serialisierbaren Klassen stets explizit anzugeben, um die Kompatibilität zu steuern.

Beispiel:

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

Wann ändern und wann unverändert lassen?

  • Unverändert lassen: wenn Änderungen die Kompatibilität nicht brechen (z. B. ein neues Feld, das mit einem Standardwert initialisiert werden kann).
  • Ändern: wenn ein Feld entfernt wurde, sich der Feldtyp geändert hat, die Klassenhierarchie angepasst wurde oder andere inkompatible Änderungen erfolgt sind.

Regel:

- Wenn die neue Klassenversion alte serialisierte Objekte lesen können soll – ändern Sie die serialVersionUID nicht.
- Wenn die Inkompatibilität kritisch ist (lieber einen Fehler als „krumme“ Daten) – erhöhen Sie die serialVersionUID.

3. Strategien zur Datenmigration

Ein praktischer Ansatz ist die sogenannte „Lazy“-Migration. Die Idee: Sie transformieren alte Daten nicht sofort, sondern schrittweise, wenn ein Objekt erstmals gelesen wird.

Wenn Sie z. B. ein neues Feld hinzugefügt haben, erhält es bei der Deserialisierung eines alten Objekts einfach den Standardwert – 0, null oder false, je nach Typ. Wenn ein Feld entfernt wurde, ignoriert die Deserialisierung es einfach. Die JVM ordnet Felder dabei nach Name und Typ zu, sodass viele Änderungen „von selbst“ funktionieren.

Komplizierter ist es, wenn sich der Feldtyp ändert, etwa wenn er zuvor int war und nun String ist. Die Standarddeserialisierung reicht dann nicht aus. Die Lösung besteht darin, eine eigene Methode readObject zu implementieren, die die Umwandlung manuell vornimmt:

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

Auf diese Weise werden alte Objekte bei ihrem ersten Lesen korrekt an die neue Klassenversion angepasst.

Pattern „in-place“-Konvertierung (in-place conversion)

Dieser Ansatz unterscheidet sich von der Lazy-Migration dadurch, dass alle Daten sofort transformiert werden. Die Idee ist einfach: Sie iterieren über jedes serialisierte Objekt – in Dateien oder in der Datenbank –, lesen es mit der alten Klassenversion, erzeugen ein Objekt der neuen Version und schreiben es im aktualisierten Format zurück.

Diese Methode ist praktisch, wenn man sich nicht auf eine „Lazy“-Migration verlassen kann. Zum Beispiel bei großen Datenmengen oder wenn Objekte selten gelesen werden und alle bereits für die neue Anwendungsversion bereitstehen sollen. In der Praxis geschieht das oft über ein separates Skript oder ein Dienstprogramm. Der Prozess kann z. B. so aussehen:

// Einfaches Beispiel für eine in-place-Konvertierung
List<File> files = getSerializedFiles(); // Liste von Dateien mit alten Objekten
for (File file : files) {
    try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file))) {
        OldUser oldUser = (OldUser) ois.readObject(); // altes Objekt lesen
        NewUser newUser = new NewUser(oldUser); // neues Objekt auf Basis des alten erzeugen
        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file))) {
            oos.writeObject(newUser); // Datei mit der neuen Version überschreiben
        }
    } catch (IOException | ClassNotFoundException e) {
        e.printStackTrace();
    }
}

So werden alle Objekte sofort auf die neue Version gebracht und sind sicher für den Einsatz im Produktionsbetrieb.

4. Umgang mit veralteten Versionen: fortgeschrittene Tricks

ObjectInputStream.readClassDescriptor() und readFields()

  • readClassDescriptor() – ermöglicht, den Lesevorgang der Klassenmetadaten abzufangen und sie bei Bedarf zu ersetzen, um die Serialisierung „auszutricksen“.
  • readFields() – erlaubt das Lesen von Feldern per Name, selbst wenn sich die Klassenstruktur geändert hat.

Beispiel:

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);
    // ... Initialisierung neuer Felder
}

5. Praxis: zwei Versionen einer Klasse, Serialisierung und Migration

Schritt 1. Alte Version der Klasse

// 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;
    }
}

Schritt 2. Objekt der alten Version serialisieren

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

Schritt 3. Neue Version der Klasse (Feld email hinzugefügt, Typ von age geändert)

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

public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    public String name;
    public String age; // Typ geändert!
    public String email; // neues Feld

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        ObjectInputStream.GetField fields = in.readFields();
        this.name = (String) fields.get("name", "unknown");
        // Altes Feld age (int) in String umwandeln
        if (!fields.defaulted("age")) {
            int oldAge = fields.get("age", 0);
            this.age = String.valueOf(oldAge);
        } else {
            this.age = "unknown";
        }
        // Neues Feld email - standardmäßig null
        this.email = (String) fields.get("email", null);
    }
}

Schritt 4. Deserialisierung des alten Objekts mit der neuen Klasse

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

Ergebnis:

  • Das alte Feld age wurde in einen String umgewandelt.
  • Das neue Feld email ist null.
  • Keine InvalidClassException, weil die serialVersionUID übereinstimmt und wir die Typinkompatibilität manuell behandelt haben.

Was passiert, wenn man die Unstimmigkeit nicht behandelt?

Wenn Sie einfach den Feldtyp ändern und readObject nicht implementieren, erhalten Sie bei der Deserialisierung einen Fehler:

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

6. Typische Fehler bei der Migration serialisierter Daten

Fehler Nr. 1: serialVersionUID nicht angegeben – bei der kleinsten Klassenänderung erhalten Sie eine InvalidClassException, selbst bei geringfügigen Anpassungen.

Fehler Nr. 2: Feldtyp ohne Behandlung in readObject geändert – führt zu einem Typinkompatibilitätsfehler.

Fehler Nr. 3: Ein Feld entfernt, aber alte Daten enthalten es noch – Java ignoriert dieses Feld einfach; war es kritisch, gehen Daten verloren.

Fehler Nr. 4: Versuch, alle Daten ohne Tests manuell zu migrieren – dabei können Informationen verloren gehen oder inkonsistente Objekte entstehen.

Fehler Nr. 5: Nicht alle Stellen aktualisiert, an denen das Objekt serialisiert/deserialisiert wird – ein Teil des Codes arbeitet mit der neuen Version, ein anderer mit der alten; dadurch entstehen „gespenstische“ Bugs.

Fehler Nr. 6: Keine Migrationsstrategie für große Datenmengen vorgesehen – bei „Lazy“-Migration können Nutzende beim ersten Zugriff auf veraltete Daten auf unerwartete Fehler stoßen.

Fehler Nr. 7: Kein Backup vor der Migration erstellt – erstellen Sie vor dem Update immer eine Sicherungskopie der serialisierten Daten!

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