Serializacja obiektów dziedziczących w Javie

Serializacja obiektów to coś czego rzadko używamy wprost, jednak w środowiskach klastrowych jest to jednak z ważniejszych rzeczy która dzieje się w tle, np. migracja sesji pomiędzy członkami klastra, tak aby w przypadku padu jednego z nich kolejne żądania użytkowników były obsługiwane przez inny węzeł a dane nie były tracone.

Każdy, kto chociaż trochę zna Javę, wie że żeby obiekt był serializowalny to wystarczy żeby jego klasa implementowała interfejs Serializable oraz wszystkie jej pola również implementowały interfejs Serializable. Jednak pewnie nie wszyscy wiedzą, a ja w każdym razie nie pamiętałem, czy co w przypadku jeżeli klasa dziedziczy z innej klasy: czy tamta bazowa musi być serializowalna, czy wystarczy że tylko ta konkretna dziedzicząca jest serializowalna.

Przygotowałem (przy pomocy kolegi Bartka który wcześniej zajął się tym tematem) prosty test: mamy dwie klasy: Base (bazową) oraz dziedziczącą z niej Concrete (konkretną) i w pierwszym przypadku ta basowa nie będzie serializowalna:

public abstract class Base {

    private String baseValue;
    
    public Base(String value) {
        this.baseValue = value;
    }

    public String getBaseValue() {
        return baseValue;
    }
    
    @Override
    public String toString() {
    	return "Base: " + baseValue;
    }
}
public class Concrete extends Base implements Serializable {

    private String localValue;

    public Concrete(String baseValue, String localValue) {
        super(baseValue);
        this.localValue = localValue;
    }

    public String getLocalValue() {
        return localValue;
    }
    
    @Override
    public String toString() {
    	return super.toString() + ", local: " + localValue;
    }
}

Do tego mamy prosty programik który tworzy obiekt klasy, serializuje go do bajtów, a następnie znowu wczytuje z bajtów do obiektu:

public class SerializationTest {
	
    public static void main(String[] args) throws Exception {
        Concrete concrete = new Concrete("A", "B");
        System.out.println("Before: " + concrete);
        byte[] bytes =  write(concrete);
        concrete = (Concrete) read(bytes);
        System.out.println("After:  " + concrete);
    }
    
    private static byte[] write(Object o) throws Exception {
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(bos);
        oos.writeObject(o);
        oos.close();
        return bos.toByteArray();
    }
    
    private static Object read(byte[] bytes) throws Exception {
        ObjectInputStream ois = new ObjectInputStream(
            new ByteArrayInputStream(bytes));
        Object result = ois.readObject();
        ois.close();
        return result;
    }
}

Uruchomienie tego programu spowoduje że najpierw wyświetlą się nam dane obiektu a następnie zostanie rzucony wyjątek:

Before: Base: A, local: B
Exception in thread "main" java.io.InvalidClassException: 
serialization_test.Concrete;
serialization_test.Concrete; no valid constructor
	at java.io.ObjectStreamClass.checkDeserialize(ObjectStreamClass.java:711)
	at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1732)
	at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1328)
	at java.io.ObjectInputStream.readObject(ObjectInputStream.java:350)
	at serialization_test.SerializationTest.read(SerializationTest.java:32)

To właśnie oznacza że nasz obiekt nie chce się poprawnie zdeserializować (wczytać z reprezentacji bajtowej do obiektu). Jest to wyjątek który znaleźliśmy w logach naszej aplikacji i od niego rozpoczęliśmy śledztwo.

Tutaj jednak czai się pułapka, ponieważ naiwne podejście do rozwiązania problemu, tj. „wpisz w google i zobacz jak sobie radzą inni” może zawieść. Spotykaną sugestią (np. na stackoverflow.com) jest dodanie bezparametrowego konstruktora, czyli modyfikacji naszej klasy bazowej w następujący sposób:

public abstract class Base {

    private String baseValue;
    
    public Base() {
    }
    
    public Base(String value) {
        this.baseValue = value;
    }

    public String getBaseValue() {
        return baseValue;
    }
    
    @Override
    public String toString() {
    	return "Base: " + baseValue;
    }
}

Pułapka polega na tym, że wyjątek nie jest rzucany i program się wykonuje do końca. Jednak jego zachowanie jest niezgodne z oczekiwaniem, ponieważ zwraca poniższy rezultat:

Before: Base: A, local: B
After:  Base: null, local: B

Wyraźnie widać co się stało: przy deserializacji został użyty konstruktor bezparametrowy, który właśnie dodaliśmy, ale przez to wartość zmiennej „baseValue” pozostała niezainicjowana i nasz obiekt biznesowo jest już niepoprawny.

W przypadku bardziej skomplikowanych klas niż powyższa przykładowa, najpewniej otrzymamy wyjątek NullPointerException, który będzie uciążliwy w ustaleniu przyczyny, ponieważ serializacja jest najczęściej niezwiązana z działaniem użytkownika a błąd wystąpi później i trudno te fakty połączyć. Ponadto takie błędy są nieodtwarzalne w środowiskach nieklastowych (developerskie lub „biedne” testowe) wiec tym trudniej je analizować.

Oczywiście poprawne rozwiązanie to implementacja Serializable przez klasę bazową (bez niepotrzebnego konstruktora), czyli:

public abstract class Base implements Serializable {

    private String baseValue;
    
    public Base(String value) {
        this.baseValue = value;
    }

    public String getBaseValue() {
        return baseValue;
    }
    
    @Override
    public String toString() {
    	return "Base: " + baseValue;
    }
}

Teraz działanie programu będzie prawidłowe:

Before: Base: A, local: B
After:  Base: A, local: B

Siadam przeglądać cały nasz kod :-).

O autorze

Marek Berkan Marek Berkan: programista, entuzjasta tworzenia oprogramowania, zarządzania zespołami technicznymi. Prywatnie motocyklista, kolarz MTB, biegacz, żeglarz, rekreacyjny wspinacz, zamiłowany turysta. Witryny: , , .

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *