try-with-resources
Konstrukcja try-with-resources w języku Java
25 sierpnia 2016
typy wyliczeniowe grafika java
Typ wyliczeniowy w języku Java
9 września 2016
serializacja artykuł

W artykule tym dowiesz się czym jest serializacja obiektów w Javie. Przeczytasz o klasach takich jak ObjectInputStream czy ObjectOutputStream i dowiesz się czym różnią się od innych strumieni. Poznasz nowe słowo kluczowe transient. Po przeczytaniu artykułu będziesz w stanie napisać swoją mini bazę danych z użyciem mechanizmu serializacji. Zapraszam do lektury.

To jest jeden z artykułów w ramach darmowego kursu programowania w Javie. Proszę zapoznaj się z pozostałymi częściami, mogą one być pomocne w zrozumieniu materiału z tego artykułu.

Czym jest serializacja

W jednym z poprzednich artykułów przeczytałeś o strumieniach danych, które pozwalały na zapisywanie oraz odczytywanie danych. Poznałeś wówczas między innymi klasy DataInputStream oraz DataOutputStream. Klasy te pomagają zapisywać typy proste i łańcuchy znaków.

Serializacja to wbudowany mechanizm zapisywania obiektów, który pozwala na binarny zapis całego drzewa obiektów. Oznacza to tyle, że jeśli mamy obiekt X, który posiada referencję do obiektu Y to serializując X również Y zostanie automatycznie zapisany w strumieniu wyjściowym.

Tak zapisany obiekt możesz później otworzyć przy kolejnym uruchomieniu programu. Jednak serializacja ma więcej zastosowań.

Dzięki temu mechanizmowi można na przykład przesyłać obiekty przez sieć. Obiekt, który stworzyliśmy na jednym komputerze (wewnątrz pamięci jednej wirtualnej maszyny Java) może być zserializowany, przesłany przez sieć i zdeserializowany na drugim komputerze tworząc nową instancję obiektu (wewnątrz pamięci drugiej wirtualnej maszyny Javy). Na obu tych komputerach wirtualna maszyna Javy musi mieć dostęp do skompilowanej wersji klasy.

Warunki wymagane do serializacji

Chociaż serializacja dostępna jest automatycznie dla większości obiektów z biblioteki standardowej to jeśli chcesz móc serializować instancje klas, które sam napiszesz musisz spełnić kilka warunków.

Interfejs java.io.Serializable

Jest to tak zwany interfejs znacznikowy, innymi słowy nie zwiera on żadnej metody. Służy on do pokazania wirtualnej maszynie, że instancje danej klasy implementującej ten interfejs mogą być serializowane. Musisz implementować ten interfejs jeśli chcesz aby twoje klasy były serilizowalne. Jeśli będziesz próbował zserializować klasę, która nie implementuje tego interfejsu zostanie rzucony wyjątek typu NotSerializableException.

Konstruktor bezparametrowy

Tutaj reguła niestety nie jest trywialna. Pierwsza klasa w hierarchii dziedziczenia, która nie jest serializowalna musi mieć dostępny konstruktor bezparametrowy. Łatwiej to będzie zrozumieć na przykładzie:

W przykładzie powyżej klasa Fruit musi mieć konstruktor bezparametrowy abyśmy mogli poprawnie serializować instancje klasy Apple. Natomiast ani Apple, ani Tomato takiego konstruktora już nie wymagają (Tomato dziedziczy po Object, który taki konstruktor posiada).

Dodatkowo istnieje interfejs java.io.Externalizable (opiszę go dokładnie kilka akapitów niżej), który również zapewnia, że obiekty go implementujące są serializowalne. Jednak w tym przypadku obiekt taki musi także zapewnić konstruktor bezparametrowy, który jest wywoływany w trakcie deserializacji.

Określić pola, które nie są serializowalne

Ten krok jest opcjonalny, jednak w bardziej zaawansowanych przypadkach niezbędny. Wyobraź sobie, że napisałeś klasę Human, która jako jeden z atrybutów posiada wiek zapisany w minutach od urodzenia. Zapisanie tego pola mogłoby prowadzić do odczytania niepoprawnego stanu (zapisujemy obiekt dzisiaj, odczytujemy jutro, wiek w minutach jest zupełnie inny).

Tutaj dochodzimy do słowa kluczowego transient. Otóż słowo to może być stosowane przed atrybutami klasy. Oznacza ono, że dany atrybut nie jest serializowalny i zostanie pominięty przez mechanizm serializacji1.

Przykład serializacji obiektu

Proszę zwróć uwagę na fragment kodu poniżej, który pokazuje jak mechanizm serializacji działa w praktyce.

W pierwszym bloku try-with-resources otwieramy strumień typu ObjectOutputStream, na którym następnie wywołujemy metodę writeObject zapisując do strumienia dwie liczby.

W kolejnym bloku dzięki instancji ObjectInputStream odczytujemy wcześniej zapisane obiekty. Obiekty odczytywane są w takiej samej kolejności w jakiej zostały zapisane, w naszym przypadku na konsoli zostaną wyświetlone liczby 1 a później 2.

Serializacja drzewa obiektów

Wspomniałem już wcześniej, że mechanizm serializacji automatycznie obsługuje drzewa obiektów. W przykładzie poniżej pokazana jest właśnie taka sytuacja. Instancja klasy Car posiada atrybuty typów Engine oraz Tyre[]. Serializując a następnie deserializując instancję tej klasy wszystkie jej atrybuty zostały także zapisane.

Zwróć proszę uwagę na ostatnią linię. W linijce tej porównywane są dwa adresy instancji klasy Car (pamiętasz różnicę między == a equals?). Oczywiście linijka ta wyświetli false na konsoli co dowodzi, że w procesie deserializacji został stworzony zupełnie nowy obiekt klasy Engine.

Deserializacja atrybutów transient

Zaraz, jak to? Przecież kilka akapitów wyżej napisałem, że atrybuty poprzedzone słowem kluczowym transient nie są serializowane. Tak to prawda, jednak podczas deserializacji atrybuty tego typu należy zainicjalizować pewną wartością. Otóż dla każdego typu mamy taką domyślną wartość:

  • booleanfalse,
  • liczby całkowite (int, long, itd.) – 0,
  • liczby ułamkowe (float, duble) – 0.0,
  • obiekty (Integer, Float, String, CustomClass, itd.) – null

W przykładzie powyżej po deserializacji pole age będzie miało wartość null ponieważ jest to wartość domyślna dla atrybutów poprzedzonych słowem kluczowym transient, które są obiektami.

Pola statyczne a serializacja

Serializacja dotyczy instancji klasy, nie samej klasy. Zatem jeśli zmodyfikowałeś pole statyczne a następnie zdeserializowałeś taki obiekt wprowadzone zmiany zostaną pominięte. Proszę spójrz na przykład poniżej.

W przykładzie tym modyfikujemy wartość pola statycznego someField a następnie serializujemy instancję klasy do pliku.

W drugim uruchomieniu programu (w którym nie zmodyfikowaliśmy wartości atrybutu statycznego someField) deserializujemy ten sam plik. W tym przypadku otrzymamy wartość 100 a nie 200, które miał obiekt zapisywany do pliku.

To co trzeba zapamiętać to to, że pola statyczne nie są serializowane a są pobierane z aktualnej definicji klasy (nie z klasy z momentu serializacji).

Możemy powiedzieć, że atrybuty static są też domyślnie transient. Jak zatem takie zmiany odzwierciedlić podczas deserializacji? Jest na to sposób 🙂

Specjalna obsługa serializacji/deserializacji

W specyficznych przypadkach masz możliwość zmodyfikowania domyślnego zachowania mechanizmu serializacji. Możesz to zrobić jeśli zaimplementujesz poniższe metody.

Poniższy przykład powinien Ci pomóc w zrozumieniu tego mechanizmu

Jak widzisz obie metody są tu zaimplementowane. writeObject jako argument dostaje strumień, do którego powinniśmy zapisać nasz obiekt. W przykładzie zapisuję zarówno wartość pola z modyfikatorem transient jak i delikatnie zmienioną wartość atrybutu otherField.

Metoda readObject jako jedyny argument przyjmuje strumień, z którego powinniśmy odczytać stan obiektu. Podobnie jak w przypadku samej serializacji, pola odczytujemy w tej samej kolejności w której je wcześniej zapisywaliśmy.

Warto tutaj zwrócić uwagę na to, że klasa ObjectInputStream posiada metodę defaultReadObject, która przeprowadza standardową deserializację, którą możesz rozszerzyć. Podobnie wygląda to w przypadku klasy ObjectOutputStream i metody defaultWriteObject. Metody te mogą być wywołane wyłącznie w trakcie (de)serializacji obiektu.

Serializacja a dziedziczenie

W poprzednich przykładach użyliśmy klasy Engine, która implementuje interfejs Serializable. Załóżmy, że utworzyliśmy klasę DieselEngine, która dziedziczy po Engine. Automatycznie instancje klasy DieselEngine będą implementowały interfejs Serializable (dziedzicząc go z Engine). Co powinniśmy zrobić jeśli nie chcielibyśmy aby nasz DieselEngine był serializowalny? Należy użyć wspomnianego już wyjątku NotSerializableException jak w przykładzie poniżej:

Pełny wpływ na mechanizm serializacji

Istnieje jeszcze jeden, dużo mniej popularny sposób zapewnienia iż obiekt może być serializowany. Jest nim interfejs Externalizable. W tym przypadku interfejs ten zawiera dwie metody, które musimy zaimplementować. Dodatkowo takie klasy muszą mieć konstruktor bezparametrowy, reszta pozostaje bez zmian. W przypadku tego podejścia cały protokół serializacji, kolejność zapisanych pól, format etc. leży po naszej stronie. Poniżej prosty przykład, w którym używam właśnie takiego podejścia.

W tym przypadku do utworzenia obiektu mechanizm serializacji używa standardowego konstruktora bezparametrowego. Po czym wywołuje na tej instancji metodę readExternal.

Pole serialVersionUID

Dodatkowo musisz wiedzieć o statycznym polu w klasie o nazwie serialVersionUID. Jego pełna definicja wygląda następująco:
private static long serialVersionUID

Pole to ma specyficzne zastosowanie. Mechanizm serializacji używa go do upewnienia się, że deserializowany obiekt „pasuje” do danych zapisanych w strumieniu. Wie o tym na podstawie wartości tego pola. Jeśli w zdeserializowanym obiekcie wartość tego pola jest taka sama jak aktualnej definicji klasy wówczas można bezpiecznie przeprowadzić deserializację.

Kiedy taka sytuacja może wystąpić? Załóżmy, że dzisiaj napiszesz klasę Human, zdeserializujesz jej instancję i zapiszesz w pliku na dysku. Po jakimś czasie wprowadzisz zmiany w klasie i będziesz chciał odczytać starą wersję z pliku. W niektórych przypadkach taka operacja nie będzie dozwolona. Właśnie wtedy pole serialVersionUID może pomóc w wykryciu takiej sytuacji.

Pole to możesz ustawić samodzielnie, jeśli tego nie zrobisz kompilator wygeneruje tę wartość za Ciebie na podstawie definicji klasy.

Materiały dodatkowe

Na początek zestaw dokumentacji do klas, związanych z tematem, jak zwykle znajdziesz tam ogrom informacji.

Dodatkowo możesz zajrzeć do specyfikacja mechanizmu serializacji albo artykułu na stronie Oracle. Znalazłem też inne opracowanie, które poruszą także zagadnienie serializacji, także do formatu XML. Możesz też rzucić okiem na przykłady użyte w tym artykule.

Zadania

Na koniec jak zwykle zadania dla Ciebie do przećwiczenia materiału z tego artykułu.

  • Napisz program, który poprosi użytkownika o wprowadzenie kilku imion, imiona te zapisz w liście a następnie zserializuj ją do pliku. Napisz metodę, która odczyta ten plik i wyświetli zawartość listy na konsoli. Wiesz, że właśnie napisałeś prostą bazę danych? 😉
  • Napisz klasę Human, która będzie miała dwa atrybuty name typu String oraz age typu int. Jak należałoby serializować instancje tej klasy aby zawsze poprawnie deserializować wiek (z dokładnością do roku)? (Wskazówka, możesz użyć metody Calendar.getInstance().get(Calendar.YEAR), która zwraca aktualny rok)

Przygotowałem też dla Ciebie przykładowe rozwiązania powyższych zadań, jednak traktuj je proszę jako ostatnią deskę ratunku 🙂 Więcej nauczysz się próbując samemu rozwiązać powyższe zadania.

Podsumowanie

Po przeczytaniu tego artykułu wiesz już czym są klasy ObjectOutputStream i ObjectInputStream. Znasz zasady serializacji, poznałeś słowo kluczowe transient. Teraz jesteś w stanie zapisać i odczytać każdą instancję klasy, którą stworzysz.

Bardzo się cieszę, że przeczytałeś cały artykuł. Na koniec mam do Ciebie prośbę. Proszę przekaż adres bloga swoim znajomym, w grupie uczy się raźniej 😉 Jeśli nie chcesz ominąć nowych artykułów dopisz się do newslettera i polub stronę Samouczka na facebooku. Miłego dnia i do następnego razu 🙂

[FM_form id=”3″]

Zdjęcie dzięki uprzejmości https://www.flickr.com/photos/28653536@N07/.

  1. Istnieje też inny, mniej popularny sposób ominięcia pól podczas serializacjim– użycie pola serialPersistentFields (jest ono dokładniej opisane w specyfikacji).
  • lukasz

    Cześć. Jak w tym przykładzie z tego kursu odwołać się to paramertru size:

    Chciałem zamiast System.out.println(deserializedCar.getTyre().length); napisać
    System.out.println(deserializedCar.getTyre().getSize());
    ale nie działa

    • Marcin Pietraszek

      Dlaczego nie działa? Jaki wyjątek dostajesz? Przeanalizowałeś go? Po pierwsze masz literówkę, bo metoda nazywa się getTyres a nie getTyre. Wskazówka: jaki typ ma wartość zwracana przez metodę getTyres?

  • lukasz

    Analizuję zdanie „W tym przypadku otrzymamy wartość 100 a nie 200, które miał obiekt zapisywany do pliku.”
    Z kodu w Github odnoszącego się do tego zdania wynika co innego : otrzymujemy 200 i 200

    • Marcin Pietraszek

      Cześć Łukasz 🙂

      Serializacja dotyczy instancji klasy, nie samej klasy. Zatem jeśli zmodyfikowałeś pole statyczne a następnie zdeserializowałeś taki obiekt wprowadzone zmiany zostaną pominięte.

      W drugim uruchomieniu programu (w którym nie zmodyfikowaliśmy wartości atrybutu statycznego someField) deserializujemy ten sam plik. W tym przypadku otrzymamy wartość 100 a nie 200, które miał obiekt zapisywany do pliku.

      Tutaj w grę wchodzi pewien niuans i ważna jest kolejność wykonywania czynności.

      1. Klasa ma pole statyczne o wartości 100.
      2. Serializujesz instancję do pliku x.bin
      3. Deserializujesz instancję z pliku x.bin, pole statyczne ma wartość 100
      4. Modyfikujesz klasę tak aby pole statyczne miało wartość 200
      5. Mija tydzień, dzień miesiąc, trzy godziny…
      6. Deserializujesz instację z pliku x.bin, pole statyczne ma wartość 200
      7. To co trzeba z tego przykładu wynieść to to, że pola statyczne nie są serializowane a są pobierane z aktualnej definicji klasy (nie z klasy z momentu serializacji).

  • lukasz

    Dzieki!!

    • Marcin Pietraszek

      Spoko. Jak zwykle, jeśli pojawią się jakiekolwiek pytania – wiesz gdzie mnie znaleźć 😉

  • lukasz

    Hej, Jednego nie rozumiem też.
    Dlaczego jesli skróce kod z rozwiązania cwieczenia w lasie Human do postaci:

    to wiek jest ciągle 0.

    • Marcin Pietraszek

      Proszę czytaj uważnie artykuły, kopiowanie kodu i losowe zmiany bez zrozumienia nie zawsze (nigdy?) są najlepszym sposobem nauki. Przeczytaj artykuł jeszcze raz. Szczególnie sekcję „Deserializacja atrybutów transient”.

  • lukasz

    Dobra cofam nie liczy się!!! Przecież age jest typu transient dlatego jest wartość domyślna dla int = 0

  • lukasz

    Ta serializacja idzie mi najdłużej… ale już opanowuje powoli.

  • lukasz

    Aby sprawdzić czy dobrze działa program z ćw pierwszego to nalezy pewnie daty w windows przesunac no o rok?

    • Marcin Pietraszek

      Rzeczywiście w ten sposób możesz sprawdzić czy wiek rzeczywiście będzie poprawnie odczytany. Jednak w jednym z kolejnych artykułów pokażę lepszy sposbó – testy jednostkowe, które są niezbędne w pracy programisty.

  • lukasz

    Super!!