Serializacja obiektów Externalizable - obejście problemu zmiany nazwy klasy

0

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 +
					'}';
		}

	}

}
1

A co powiesz na to:

class  OldDataObject  {
....
  public Object readResolve() throws ObjectStreamException {
            NewDataObject result =  new NewDataObject();
            result.setNumber(this.number);
            return result;
        }
....
}

To externalizable może Ci zrobić problem jak będziesz miał dużą strukturę hierarchiczną obiektów do odczytu i zapisu- możesz się pogubić kiedyś.

0

Faktycznie po dodaniu tego do PoC obydwa scenariusze działają prawidłowo. Jedną wadą jest to, że tworzy to konieczność zachowania wszystkich archiwalnych wersji klas, aby móc w nich utrzymywać te metody deserializujące obiekt do najnowszej wersji. Mój przykład zawiera obydwie wersje klasy, ale zrobiłem tak w celu łatwiejszego odtworzenia kodu do mojego pytania. Produkcyjnie bardziej rozważałem scenariusz, gdzie będzie to jedna klasa ewoluująca w czasie. Ale obadam temat, czy takie podejście mi się sprawdzi. Dzięki za pomysł.

1 użytkowników online, w tym zalogowanych: 0, gości: 1