Zapis/odczyt obiektów na dysku, czyli funkcjonalność save/load danych w programie

0

Na początek napiszę, że problemu jako takiego w zasadzie nie mam, gdyż problem postawiony w temacie realizuję. Główna rzecz, o którą się tutaj zgłaszam, to porady bardziej doświadczonych, gdyż nie do końca mam przekonanie że moje rozwiązanie jest dobre. Post będzie długi, więc mam nadzieję, że komuś starczy cierpliwości, żeby to przeczytać ;)

Na start przyjmijmy, że mam klasę „Model”, która posiada pole: private long number; + setter/getter.
Potrzebuję zapisać stan obiektu tej klasy na dysku w celu późniejszego przywrócenia go w programie. W początkowej nauce Javy rozwiązaniem przeznaczonym do tego wydawała się być serializacja, jednak po zapoznaniu się z różnymi materiałami wniosek był taki, że nie powinno się jej używać do „długotrwałego” przechowywania stanu obiektów. Bardziej ma służyć jedynie do przesyłania obiektów, gdyż nie ma gwarancji, że w kolejnej aktualizacji Javy taka klasa nie zmieni swojego serialVersionUID i przestanie być możliwa do wczytania.

Dalszy tok rozumowania doprowadza mnie do zapotrzebowania na metody:

public static void save(Model model, File file);
public static Model load(File file);

Ponieważ nie chcę obsługi zapisu/odczytu trzymać bezpośrednio w modelu, zazwyczaj powstaje osobna, abstrakcyjna klasa IOModel w jakimś pakiecie io i w niej tworzę powyższe metody. W metodzie save() rozbijam całą logikę zapisywanej klasy, aż dojdę do typów prostych i je kolejno zapisuje do pliku. W tej samej kolejności następuje odczyt i odtwarzanie obiektu w metodzie load().

Chwilowo problem rozwiązany, jednak z czasem powstaje kolejny:

Podczas tworzenia programu i często także już po wydaniu pierwszych testowych wersji kod ewoluuje dalej. Jedne dane przybywają, inne są modyfikowane i trzeba wprowadzić także zmiany w zapisie/odczycie. Przyjmijmy, że klasa Model zyskała nowe pole: private String text;. Następuje utrata kompatybilności wstecznej jak w przypadku serializable (wiem, że przy dodawaniu właściwości nie ma potrzeby zmiany UID, ale upraszczam na potrzeby przykładu). Sytuacja nie do zaakceptowania, gdyż nie chcę, aby pierwsi użytkownicy testujący aplikację tracili dane.

Rozwiązanie, które stosuję, to dodanie w klasie IOModel:

private static final int VERSION = 1;

Metoda save() pierwsze, co zapisuje do pliku, to wartość VERSION, metoda load() tylko wczytuje wartość VERSION z pliku i na jej podstawie wywołuje którąś z metod prywatnych typu loadVersion1(InputObjectStream in), loadVersion2(InputObjectStream in) itd.

Za każdym razem, gdy następuje zmiana formatu danych, zwiększam wartość VERSION, aktualizuję metodę save() aby odzwierciedlała nowy stan zapisywanego obiektu, i tworzę kolejną metodę loadVersionX(), która obsługuje odczyt w danej wersji.

Zaletą rozwiązania jest to, że program potrafi wczytać wszystkie wcześniejsze wersje danych (zakładając, że nowo dodane właściwości modelu są prawidłowe w swoich wartościach domyślnych).

Aktualnie rozwiązuje to wszystkie moje problemy z przechowywaniem danych, ale...

  • Wymaga to tworzenia dosyć sporej ilości kodu dla każdej klasy, dla której potrzebuję możliwości zapisu/odczytu szczególnie, gdy klasa jest rozbudowana i posiada sporo właściwości często w postaci nie tylko typów prostych ale także kolejnych obiektów. Dlatego serializable wydawał się tak atrakcyjnym rozwiązaniem.

  • Gdy następuje zmiana wersji pliku podyktowana zmianami w modelu, kod rozrasta się w brzydki sposób. Muszę w zasadzie skopiować całą metodę loadVersionX, by utworzyć z niej metodę loadVersionX+1 z niewielkimi zmianami. Daleko temu do zasady DRY.

Pytanie końcowe.
Chodzi mi głównie o ocenę, czy takie rozwiązanie jest dobre/akceptowalne, czy może jednak zabrnąłem w złą uliczkę i ktoś oświeci mnie, jak można to zrobić dużo prościej, wygodniej i profesjonalniej.

Poniżej kod obydwu omawianych w poście klas. Obydwie klasy są już po dodaniu nowego pola do klasy Model. Komentarze w klasie IOModel pokazują, które fragmenty kodu musiałem zmodyfikować, aby przejść z VERSION 1 do 2.

Model.java:

public class Model
	{
	private long number;
	private String text;
	public Model()
		{
		number = System.currentTimeMillis();
		text = String.valueOf(number);
		}
	public long getNumber()
		{
		return number;
		}
	public String getText()
		{
		return text;
		}
	public void setNumber(long value)
		{
		number = value;
		}
	public void setText(String value)
		{
		text = value;
		}
	}

IOModel.java:

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;


public class IOModel
	{
	private static final int VERSION = 2; //Zmiana numeru wersji na ++VERSION
	public static void save(Model model, File file)
		throws FileNotFoundException, IOException
		{
		ObjectOutputStream out = null;
		try
			{
			out = new ObjectOutputStream(new FileOutputStream(file));
			out.writeInt(VERSION);
			out.writeLong(model.getNumber());
			out.writeObject(model.getText()); //nowa wlasciwosc obiektu uwzgledniona w nowym formacie zapisu
			//zapis zawsze wystarczy tylko w najnowszej wersji, wiec przynajmniej tu w odroznieniu od wczytywania
			//nie ma potrzeby tworzyc wielu wersji zapisu
			}
		finally
			{
			if (out != null)
				out.close();
			}
		}
	public static Model load(File file)
		throws FileNotFoundException, IOException
		{
		ObjectInputStream in = null;
		try
			{
			in = new ObjectInputStream(new FileInputStream(file));
			int fileVersion = in.readInt();
			switch (fileVersion)
				{
				case 2:
					return loadVersion2(in); //dopisany nowy blok case wywolujacy wczytywanie nowej wersji pliku
				case 1:
					return loadVersion1(in);
				default:
					throw new IOException("Unknown file version");
				}
			}
		catch (ClassNotFoundException e)
			{
			throw new IOException("Unknown class type", e);
			}
		finally
			{
			if (in != null)
				in.close();
			}
		}
	private static Model loadVersion1(ObjectInputStream in)
		throws FileNotFoundException, IOException
		{
		Model model = new Model();
		model.setNumber(in.readLong());
		return model;
		}
	//nowa metoda wczytywania danych utworzona przez skopiowanie poprzedniej i dodanie wczytywania nowych danych
	private static Model loadVersion2(ObjectInputStream in)
		throws ClassNotFoundException, FileNotFoundException, IOException
		{
		Model model = new Model();
		model.setNumber(in.readLong());
		model.setText((String)in.readObject()); //dopisana linia po skopiowaniu metody loadVersion1()
		return model;
		}
	}

0

Ja bym jednak trzymał się standardowej serializacji. Wydaje mi się, że dziedziczenie + serializacja powinno załatwić sprawę.

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

abstract class Model implements Serializable {

    protected int pole1;
    protected char pole2;
}

class Model1 extends Model {

    private static final long serialVersionUID = 1L;

    public Model1(String pole3) {
        this.pole3 = pole3;
    }
    protected String pole3;
}

class Model2 extends Model {

    private static final long serialVersionUID = 2L;

    public Model2(float pole3) {
        this.pole3 = pole3;
    }
    protected float pole3;
}

public class Main {

    public static void main(String[] args) throws IOException, ClassNotFoundException {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos);
        Model model1 = new Model1("Lolo");
        model1.pole1 = 5;
        model1.pole2 = 'c';
        oos.writeObject(model1);
        model1 = null;
        Model model2 = new Model2(5.6f);
        model2.pole1 = 8;
        model2.pole2 = 'a';
        oos.writeObject(model2);
        model2 = null;
        ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
        ObjectInputStream ois = new ObjectInputStream(bais);
        Model model = null;
        model = (Model) ois.readObject();
        System.out.println(model.pole1 + " " + model.pole2 + " " + ((Model1) model).pole3);
        model = (Model) ois.readObject();
        System.out.println(model.pole1 + " " + model.pole2 + " " + ((Model2) model).pole3);
    }
}

Po prostu roszerzasz klasy i dodajesz pola.

ZTCW jeśli nie wpiszesz serialVersionUID to Java go autmatycznie wygeneruje.

If a serializable class does not explicitly declare a serialVersionUID, then the serialization runtime will calculate a default serialVersionUID value for that class based on various aspects of the class, as described in the Java(TM) Object Serialization Specification. However, it is strongly recommended that all serializable classes explicitly declare serialVersionUID values, since the default serialVersionUID computation is highly sensitive to class details that may vary depending on compiler implementations, and can thus result in unexpected InvalidClassExceptions during deserialization. Therefore, to guarantee a consistent serialVersionUID value across different java compiler implementations, a serializable class must declare an explicit serialVersionUID value. It is also strongly advised that explicit serialVersionUID declarations use the private modifier where possible, since such declarations apply only to the immediately declaring class--serialVersionUID fields are not useful as inherited members. Array classes cannot declare an explicit serialVersionUID, so they always have the default computed value, but the requirement for matching serialVersionUID values is waived for array classes.

Dodatkowo możesz zmieniać klasę w pewien ograniczony sposób, zachowując kompatybilność serializacji - o ile oczywiście zostawisz serialVersionUID bez zmian. Zmiany kompatybilne z serializacją to np dodanie nowych metod do klasy - nie zmienia to formatu zserializowanej klasy, a więc można odczytać.

The serialization runtime associates with each serializable class a version number, called a serialVersionUID, which is used during deserialization to verify that the sender and receiver of a serialized object have loaded classes for that object that are compatible with respect to serialization. If the receiver has loaded a class for the object that has a different serialVersionUID than that of the corresponding sender's class, then deserialization will result in an InvalidClassException.

http://download.oracle.com/javase/1.3/docs/guide/serialization/spec/serialTOC.doc.html
http://download.oracle.com/javase/1.3/docs/guide/serialization/spec/version.doc.html
http://download.oracle.com/javase/1.3/docs/guide/serialization/spec/version.doc5.html
http://download.oracle.com/javase/1.3/docs/guide/serialization/spec/version.doc6.html
http://download.oracle.com/javase/1.3/docs/guide/serialization/spec/version.doc7.html
http://download.oracle.com/javase/1.3/docs/guide/serialization/spec/version.doc8.html

0

@Wibowit, dzięki za odpowiedź. Nie spodziewałem się żadnej tak szybko :)

Jeśli chodzi o Twoje propozycje, to albo zbyt słabo się wczytałem, albo nie do końca rozwiązywałoby to moją sytuację.

Dziedziczenie:
Nie całkiem mi to odpowiada z tego względu, że jest dla mnie zbędne tworzenie wielu kolejnych wersji danej klasy. Potrzebuję zawsze tylko najnowszej, ostatnio zakodowanej, a wiele różnych wersji wprowadzało by lekki zamęt. Także już w całym projekcie obiekty są tworzone przez new Model() i po stworzeniu potomka musiałbym to pozmieniać na new ModelV2(), tam gdzie potrzebuję nowych danych, rzutować do tej klasy itp.

Być może samo moje podejście do tak częstych zmian formatu zapisu jest złe. Przy dużych, komercyjnych projektach wszelkie zmiany w formacie danych są złem, gdyż wszelkiej maści "3rd party software" współpracujące z danym oprogramowaniem przestaje działać, więc należy to ograniczać i w razie ostatecznej potrzeby zmiany formatu zmieniać go tak, aby jak najdalej odsunąć kolejną taką potrzebę.

Także jeszcze dodam, bo może z pierwszego posta płyną inne wnioski. Tu nie chodzi o zmianę formatu zapisu między kolejnymi wersjami programu. Chodzi o zmianę nawet między kolejnymi build'ami, gdy siedzę przed kompem i koduję. Np klasa będąca modelem konta użytkownika przechowuje dane typu login, haslo. Któregoś dnia dodaję pole przechowujące timestamp ostatniego logowania, zmieniam format zapisu, zapewniam odczyt starego formatu, żebym nie musiał od nowa generować danych testowych i tak np kilka razy w najbliższych dniach. Przykład przejaskrawiony, gdyż porządny projekt aplikacji powinien takie rzeczy mieć ustalone przed powstaniem pierwszych linijek kodu, ale komu nie zdarzyło się zmieniać wcześniej zatwierdzonej specyfikacji? ;)

Serializacja:
Obawiam się jej z tego względu, że chyba nie mam nad nią całkowitej kontroli. Wiem o tym, że istnieją bezpieczne zmiany dla serializacji, i taką jest dodanie nowego pola, jak w moim przykładzie, ale przykład był uproszczony i chodzi mi o sytuacje, gdy serializacja nie zadziała. Dwa przykłady do Twojego kodu, które chyba sprawią problem (?):

  • program od paru dni działa, sporo instancji klasy Model jest zapisanych w plikach na dysku. Nagle wychodzi na to, że int dla pole1 jest niewystarczający. Zachodzi potrzeba zmiany na long.
  • jednym z pól klasy Model jest kolekcja ArrayList jakiś obiektów. Oracle wypuszcza kolejną wersję javy, w której lekko podłubał w bebechach tej klasy. Mój plik z danymi już się nie załaduje.

Z tego co naczytałem w necie o serializacji, w obydwu tych przypadkach miałbym sporo roboty z przywróceniem danych. Z tego właśnie powodu bezpieczniej czuje się tworząc własną implementację w postaci metod save/load.

Ogólnie problem, który przedstawiam, jest złożony i ciężko mi to sensownie ubrać w słowa :) Dużo lepiej bym pewnie na tym wyszedł, gdyby dało się ze współpracownikami porozmawiać na żywo, ale o ile miałem takie możliwości podczas ostatnich lat pracy zawodowej w PHP, to jednak z Java jeszcze takich możliwości nie miałem.

0
  • program od paru dni działa, sporo instancji klasy Model jest zapisanych w plikach na dysku. Nagle wychodzi na to, że int dla pole1 jest niewystarczający. Zachodzi potrzeba zmiany na long.

Skoro w takich sprawach jesteś niezdecydowany to już poważny problem. Sprawa jest praktycznie taka sama jak w przypadku baz danych, tam też zmiana jakiejś wartości z inta na floata powoduje taki sam problem.

  • jednym z pól klasy Model jest kolekcja ArrayList jakiś obiektów. Oracle wypuszcza kolejną wersję javy, w której lekko podłubał w bebechach tej klasy. Mój plik z danymi już się nie załaduje.

Wątpię, aby Oracle zrobił coś takiego. Jeśli coś nie nadaje się do długotrwałej serializacji to raczej jest to zaznaczone w dokumentacji (np Swingowe klasy).

Szczerze mówiąc to nie spotkałem się jeszcze z żadnym systemem, który by ułatwiał konwersję pomiędzy różnymi strukturami klas/ tabel w bazach danych.

0
Wibowit napisał(a)

Skoro w takich sprawach jesteś niezdecydowany to już poważny problem. Sprawa jest praktycznie taka sama jak w przypadku baz danych, tam też zmiana jakiejś wartości z inta na floata powoduje taki sam problem.

Czyli podsumowując problem dostrzegłem prawidłowo, tyle że jego rozwiązanie jest inne niż zakładałem. Należy więcej czasu spędzić nad projektem, zanim zacznie się pisać kod, aby do takich sytuacji nie doprowadzać. Odniesienie tego do bazy danych w zasadzie mi rozjaśniło sytuację, gdyż jest to dużo bliższe mojej pracy zawodowej i przy takim porównaniu staje się dosyć oczywiste.

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