Badam aktualnie temat porządnego implementowania serializacji obiektów, które następnie powinny być utrzymywane przez cały czas życia rozwijanej aplikacji, co oznacza skrupulatne wersjonowanie i wsparcie dla deserializacji wcześniejszych wersji.
Implementowanie interfejsu Externalizable wyglądało obiecująco, gdyż daje większą kontrolę nad tym, jak serializowany jest obiekt. Biorąc pod uwagę, że w metodzie writeExternal(ObjectOutput out) można kontrolować sposób serializacji całego obiektu (łącznie z hierarchią dziedziczenia) aż do pojedynczych bajtów, liczyłem na to, że nie będzie problemu nawet ze wsparciem dla obiektów, które w międzyczasie zmieniły nazwę klasy, czy pakiet, w którym się znajdują. W końcu mam komplet niezbędnych danych do ich odtworzenia i skoro mam pełną kontrolę nad procesem, wiem jak odtworzyć obiekt niezależnie od tego, jak kiedyś nazywała się klasa, której ten obiekt jest instancją. Tutaj jednak pojawia się zawód.
Stworzyłem sobie dwie testowe klasy o dokładnie takiej samej zawartości OldDataObject i NewDataObject. Różnią się tylko nazwą, aby zasymulować zmianę nazwy klasy w czasie. Następnie próbuję wykonać taki kod:
OldDataObject objectToSerialize = new OldDataObject();
objectToSerialize.setNumber(42);
File defaultSaveFile = new File(FILE_PATH_1);
try (ObjectOutputStream oos = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(defaultSaveFile)))) {
oos.writeObject(objectToSerialize);
}
File defaultLoadFile = new File(FILE_PATH_1);
try (ObjectInputStream ois = new ObjectInputStream(new BufferedInputStream(new FileInputStream(defaultLoadFile)))) {
NewDataObject defaultLoadedObject = (NewDataObject) ois.readObject();
System.out.println(defaultLoadedObject);
}
Jednak rezultatem jest ClassCastException (lub ClassNotFoundException, gdybym faktycznie wykonywał zmianę nazwy).
Gdy zajrzę do pliku z zapisanym obiektem, staje się jasne, że mimo lepszej kontroli nad serializacją, niż w metodzie writeObject, metadane samej klasy nadal są zapisywane
00000000 ac ed 00 05 73 72 00 1f 74 65 73 74 2e 44 61 74 |....sr..test.Dat|
00000010 61 54 65 73 74 4d 61 69 6e 24 4f 6c 64 44 61 74 |aTestMain$OldDat|
00000020 61 4f 62 6a 65 63 74 00 00 00 00 00 00 00 01 0c |aObject.........|
00000030 00 00 78 70 77 04 00 00 00 2a 78 |..xpw....*x|
0000003b
Ale wystarczy jedna drobna zmiana w kodzie.
Zamiast
//save
oos.writeObject(objectToSerialize);
//load
NewDataObject defaultLoadedObject = (NewDataObject) ois.readObject();
Stosuję odpowiednio:
//save
objectToSerialize.writeExternal(oos);
//load
NewDataObject loadedObject = new NewDataObject();
loadedObject.readExternal(ois);
I w tym momencie zmiana nazwy klasy przestaje być problemem.
Hexdump pliku wygląda tak:
00000000 ac ed 00 05 77 04 00 00 00 2a |....w....*|
0000000a
Czyli wylądowało w nim tylko to, co potrzebne i bez problemu odtwarzam obiekt pod nową nazwą klasy.
I teraz pytanie. Czy takie wykorzystanie serializacji jest akceptowalne/bezpieczne, czy gdzieś w przyszłości może mnie to zaboleć? Mam wrażenie, że jest to swego rodzaju hack, wykorzystanie luki w celu obejścia ograniczeń funkcji serializowania. Ale zaletą jest, że rozwiązuje spory z mojego punktu widzenia problem.
Poniżej pełen kod testowy pozwalający odtworzyć mój PoC
package test;
import java.io.*;
public class DataTestMain {
private static final String FILE_PATH_1 = "/home/vgt/serialized-1.bin";
private static final String FILE_PATH_2 = "/home/vgt/serialized-2.bin";
public static void main(String[] args) throws IOException, ClassNotFoundException {
OldDataObject objectToSerialize = new OldDataObject();
objectToSerialize.setNumber(42);
//Default usage
try {
File defaultSaveFile = new File(FILE_PATH_1);
try (ObjectOutputStream oos = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(defaultSaveFile)))) {
oos.writeObject(objectToSerialize);
}
File defaultLoadFile = new File(FILE_PATH_1);
try (ObjectInputStream ois = new ObjectInputStream(new BufferedInputStream(new FileInputStream(defaultLoadFile)))) {
NewDataObject defaultLoadedObject = (NewDataObject) ois.readObject();
System.out.println(defaultLoadedObject);
}
} catch (ClassCastException e) {
System.out.println("Default usage failure");
}
//Questionable usage
File questionableSaveFile = new File(FILE_PATH_2);
try (ObjectOutputStream oos = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(questionableSaveFile)))) {
objectToSerialize.writeExternal(oos);
}
File questionableLoadFile = new File(FILE_PATH_2);
try (ObjectInputStream ois = new ObjectInputStream(new BufferedInputStream(new FileInputStream(questionableLoadFile)))) {
NewDataObject questionableLoadedObject = new NewDataObject();
questionableLoadedObject.readExternal(ois);
System.out.println(questionableLoadedObject);
}
}
public static class OldDataObject implements Externalizable {
private static final long serialVersionUID = 1L;
private int number;
public int getNumber() {
return number;
}
public void setNumber(int number) {
this.number = number;
}
@Override
public void writeExternal(ObjectOutput out) throws IOException {
out.writeInt(number);
}
@Override
public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
number = in.readInt();
}
@Override
public String toString() {
return "Object{" +
"number=" + number +
'}';
}
}
public static class NewDataObject implements Externalizable {
private static final long serialVersionUID = 1L;
private int number;
public int getNumber() {
return number;
}
public void setNumber(int number) {
this.number = number;
}
@Override
public void writeExternal(ObjectOutput out) throws IOException {
out.writeInt(number);
}
@Override
public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
number = in.readInt();
}
@Override
public String toString() {
return "Object{" +
"number=" + number +
'}';
}
}
}