Wprowadzenie do JAXB

Z Jacek Laskowski - Wiki Projektanta Java EE

Uczestnictwo w spotkaniach grupy Warszawa JUG daje mi tą nieocenioną możliwość poznawania technologii praktycznie praktycznie bez uderzenia w klawiaturę (podwójnie użycie praktycznie świadome). Brzmi jak masło maślane, ale kiedy zastanowić się nad tym chwilę, to wszystko z pewnością stanie się jasne. Podczas spotkań większość z prezentowanego materiału okraszona jest mnóstwem przykładów, które nierzadko pisane są na bieżąco w IDE. W tym sensie mam praktyczne poznanie technologii, a że jedynie patrzę i rozmawiam z innymi uczestnikami, a nie stukam po klawiaturze, to i praktycznie odbywa się to bez mojego aktywnego obcowania z nią. Po prostu wymarzony sposób na poznawanie nowych technologii, mając bardzo ograniczony na nie "budżet" czasowy. Tak samo stało się ze specyfikacją JAXB (The Java Architecture for XML Binding), gdzie Łukasz Dywicki podczas jednego ze spotkań Warszawa JUG, a później podczas konferencji WarsJava 2007 zaprezentował JAXB. Niby wszystko proste i łatwe do użycia, ale kiedy ostatnio dostałem zadanie zapisania danych do bazy danych i plików, chwilę trwało zanim po JPA (Java Persistence API) pojawił się i JAXB. W końcu udało mi się namierzyć projekt, w którym mógłbym skorzystać z JAXB, więc i mogłem go praktycznie wypróbować.

JAXB (The Java Architecture for XML Binding) to specyfikacja zapisywania i odczytywania obiektów javowych na/z ich odpowiedników xmlowych. Można pomyśleć o JAXB jako o moście między światem javowym a światem xmlowym, podobnie jak o JPA w kontekście relacyjnych baz danych. Jako, że pliki xmlowe i baza danych to jedynie repozytorium danych, można spodziewać się specyfikacji, która ukrywa te niuanse (szczegóły technologiczne) i udostępnia spójny interfejs do różnych repozytoriów na bazie JAXB, JPA i in.

Zadanie do wykonania: zapis/odczyt danych do/z plików XML z użyciem aktualnego modelu danych aplikacji (który wcześniej wykorzystałem przy mapowaniu opartym o JPA).

W bieżącym projekcie należało zapisywać dane o pacjentach i ich wizytach do plików (użytkownik wybiera sposób zapisu - baza danych lub pliki). Byty Pacjent oraz Wizyta są reprezentowane w aplikacji jako klasy pl.jaceklaskowski.przychodnia.model.Pacjent oraz pl.jaceklaskowski.przychodnia.model.Wizyta (w relacji jeden-do-wielu).

Spis treści

Model aplikacyjny

Klasa pl.jaceklaskowski.przychodnia.model.Pacjent

Klasa pl.jaceklaskowski.przychodnia.model.Pacjent wygląda następująco:

package pl.jaceklaskowski.przychodnia.model;

import java.beans.PropertyChangeListener;
import java.beans.PropertyChangeSupport;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.List;
import javax.persistence.Transient;
import static pl.jaceklaskowski.przychodnia.model.Plec.KOBIETA;

public class Pacjent implements Serializable {

    private static final long serialVersionUID = 1L;
    private String pesel;
    private String imie;
    private String nazwisko;
    private String telefon;
    private int wiek;
    private Plec plec;
    private List<Wizyta> wizyty = new ArrayList<Wizyta>();
    @Transient
    private PropertyChangeSupport changeSupport = new PropertyChangeSupport(this);

    public Pacjent() {
    }

    public Pacjent(String pesel) {
        this(pesel, "", "");
    }

    public Pacjent(String pesel, String imie, String nazwisko) {
        this(pesel, imie, nazwisko, "");
    }

    public Pacjent(String pesel, String imie, String nazwisko, String telefon) {
        this(pesel, imie, nazwisko, telefon, 0, KOBIETA);
    }

    public Pacjent(String pesel, String imie, String nazwisko, String telefon, int wiek, Plec plec) {
        this.pesel = pesel;
        this.imie = imie;
        this.nazwisko = nazwisko;
        this.telefon = telefon;
        this.wiek = wiek;
        this.plec = plec;

        ustawWiekPlecNaPodstawiePeselu();

    }

    public String getPesel() {
        return pesel;
    }

    public void setPesel(String pesel) {
        if (pesel == null || pesel.length() == 0) {
            throw new IllegalArgumentException("pesel jest pusty");
        }
        if (!pesel.matches("[0-9]{11}+")) {
            throw new IllegalArgumentException("pesel [" + pesel + "] nie zawiera 11 cyfr");
        }
        String oldPesel = this.pesel;
        this.pesel = pesel;
        changeSupport.firePropertyChange("pesel", oldPesel, pesel);

        ustawWiekPlecNaPodstawiePeselu();
    }

    public String getImie() {
        return imie;
    }

    public void setImie(String imie) {
        String oldImie = this.imie;
        this.imie = imie;
        changeSupport.firePropertyChange("imie", oldImie, imie);
    }

    public String getNazwisko() {
        return nazwisko;
    }

    public void setNazwisko(String nazwisko) {
        String oldNazwisko = this.nazwisko;
        this.nazwisko = nazwisko;
        changeSupport.firePropertyChange("nazwisko", oldNazwisko, nazwisko);
    }

    public Plec getPlec() {
        return plec;
    }

    public void setPlec(Plec plec) {
        Plec oldPlec = this.plec;
        this.plec = plec;
        changeSupport.firePropertyChange("plec", oldPlec, plec);
    }

    public String getTelefon() {
        return telefon;
    }

    public void setTelefon(String telefon) {
        String oldTelefon = this.telefon;
        this.telefon = telefon;
        changeSupport.firePropertyChange("telefon", oldTelefon, telefon);
    }

    public int getWiek() {
        return wiek;
    }

    public void setWiek(int wiek) {
        int oldWiek = this.wiek;
        this.wiek = wiek;
        changeSupport.firePropertyChange("wiek", oldWiek, wiek);
    }

    public List<Wizyta> getWizyty() {
        return wizyty;
    }

    public void setWizyty(List<Wizyta> wizyty) {
        this.wizyty = wizyty;
    }

    @Override
    public int hashCode() {
        int hash = 0;
        hash += (pesel != null ? pesel.hashCode() : 0);
        return hash;
    }

    @Override
    public boolean equals(Object object) {
        // TODO: Warning - this method won't work in the case the id fields are not set
        if (!(object instanceof Pacjent)) {
            return false;
        }
        Pacjent other = (Pacjent) object;
        if ((this.pesel == null && other.pesel != null) || (this.pesel != null && !this.pesel.equals(other.pesel))) {
            return false;
        }
        return true;
    }

    @Override
    public String toString() {
        return "Pacjent[id=" + pesel + "]";
    }

    protected void ustawWiekPlecNaPodstawiePeselu() {

        // za Wikipedią o wieku w PESELu:
        // http://pl.wikipedia.org/wiki/PESEL#P.C5.82e.C4.87
        // Numeryczny zapis daty urodzenia przedstawiony jest w następującym porządku: 
        // dwie ostatnie cyfry roku, miesiąc i dzień. 
        // Dla odróżnienia poszczególnych stuleci przyjęto następującą metodę kodowania:
        //  * dla osób urodzonych w latach 1900 do 1999 - miesiąc zapisywany jest w sposób naturalny
        //  * dla osób urodzonych w innych latach niż 1900 - 1999 dodawane są do numeru miesiąca następujące wielkości:
        //      o dla lat 1800-1899 - 80
        //      o dla lat 2000-2099 - 20
        //      o dla lat 2100-2199 - 40
        //      o dla lat 2200-2299 - 60
        int rokDzisiaj = Calendar.getInstance().get(Calendar.YEAR);
        int rok = Integer.valueOf(this.pesel.substring(0, 2));
        int miesiac = Integer.valueOf(this.pesel.substring(2, 4));
        if (miesiac > 80) {
            miesiac -= 80;
            rok += 1800;
        } else if (miesiac > 60) {
            miesiac -= 60;
            rok += 2200;
        } else if (miesiac > 40) {
            miesiac -= 40;
            rok += 2100;
        } else if (miesiac > 20) {
            miesiac -= 20;
            rok += 2000;
        } else {
            rok += 1900;
        }
        this.wiek = rokDzisiaj - rok;
        if (this.wiek <= 0) {
            throw new IllegalArgumentException("Niedozwolony wiek " + this.wiek + " [PESEL:" + this.pesel + "]");
        }

        // za Wikipedią o płci w PESELu:
        // http://pl.wikipedia.org/wiki/PESEL#P.C5.82e.C4.87
        // Informacja o płci osoby, której zestaw informacji jest identyfikowany,
        // zawarta jest na 10 (przedostatniej) pozycji numeru PESEL.
        //  * cyfry parzyste (0, 2, 4, 6, 8) – oznaczają płeć żeńską
        //  * cyfry nieparzyste (1, 3, 5, 7, 9) – oznaczają płeć męską
        this.plec = Plec.valueOf(Integer.valueOf(this.pesel.substring(9, 10)) % 2);
    }

    public void addPropertyChangeListener(PropertyChangeListener listener) {
        changeSupport.addPropertyChangeListener(listener);
    }

    public void removePropertyChangeListener(PropertyChangeListener listener) {
        changeSupport.removePropertyChangeListener(listener);
    }
}

Klasa pl.jaceklaskowski.przychodnia.model.Wizyta

Klasa pl.jaceklaskowski.przychodnia.model.Wizyta prezentuje się następująco:

package pl.jaceklaskowski.przychodnia.model;

import java.beans.PropertyChangeListener;
import java.beans.PropertyChangeSupport;
import java.io.Serializable;
import java.util.Date;
import javax.persistence.Transient;
import javax.xml.bind.annotation.XmlTransient;

public class Wizyta implements Serializable {

    private static final long serialVersionUID = 1L;
    private long id;
    private Date dzien;
    private String objawy;
    private String rozpoznanie;
    private String leczenie;
    private Pacjent pacjent;
    
    @Transient
    private PropertyChangeSupport changeSupport = new PropertyChangeSupport(this);

    public Wizyta() {
    }

    public Wizyta(Date dzien, String objawy, String rozpoznanie, String leczenie) {
        this(0, dzien, objawy, rozpoznanie, leczenie);
    }

    public Wizyta(long id, Date dzien, String objawy, String rozpoznanie, String leczenie) {
        this.id = id;
        this.dzien = dzien;
        this.objawy = objawy;
        this.rozpoznanie = rozpoznanie;
        this.leczenie = leczenie;
    }

    public long getId() {
        return id;
    }

    public void setId(long id) {
        long oldId = this.id;
        this.id = id;
        changeSupport.firePropertyChange("id", oldId, id);
    }

    @XmlTransient
    public Pacjent getPacjent() {
        return pacjent;
    }

    public void setPacjent(Pacjent pacjent) {
        Pacjent oldPacjent = this.pacjent;
        this.pacjent = pacjent;
        changeSupport.firePropertyChange("pacjent", oldPacjent, pacjent);
    }

    public Date getDzien() {
        return dzien;
    }

    public void setDzien(Date dzien) {
        Date oldDzien = this.dzien;
        this.dzien = dzien;
        changeSupport.firePropertyChange("dzien", oldDzien, dzien);
    }

    public String getLeczenie() {
        return leczenie;
    }

    public void setLeczenie(String leczenie) {
        String oldLeczenie = this.leczenie;
        this.leczenie = leczenie;
        changeSupport.firePropertyChange("leczenie", oldLeczenie, leczenie);
    }

    public String getObjawy() {
        return objawy;
    }

    public void setObjawy(String objawy) {
        String oldObjawy = this.objawy;
        this.objawy = objawy;
        changeSupport.firePropertyChange("objawy", oldObjawy, objawy);
    }

    public String getRozpoznanie() {
        return rozpoznanie;
    }

    public void setRozpoznanie(String rozpoznanie) {
        String oldRozpoznanie = this.rozpoznanie;
        this.rozpoznanie = rozpoznanie;
        changeSupport.firePropertyChange("rozpoznanie", oldRozpoznanie, rozpoznanie);
    }

    @Override
    public int hashCode() {
        int hash = 5;
        hash = 61 * hash + (int) (this.id ^ (this.id >>> 32));
        return hash;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj == null) {
            return false;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }
        final Wizyta other = (Wizyta) obj;
        if (this.id != other.id) {
            return false;
        }
        if (this.pacjent == null || !this.pacjent.equals(other.pacjent)) {
            return false;
        }
        if (this.dzien == null || !this.dzien.equals(other.dzien)) {
            return false;
        }
        if (this.objawy == null || !this.objawy.equals(other.objawy)) {
            return false;
        }
        if (this.rozpoznanie == null || !this.rozpoznanie.equals(other.rozpoznanie)) {
            return false;
        }
        if (this.leczenie == null || !this.leczenie.equals(other.leczenie)) {
            return false;
        }
        return true;
    }

    @Override
    public String toString() {
        return "Wizyta[id=" + id + "]";
    }


    public void addPropertyChangeListener(PropertyChangeListener listener) {
        changeSupport.addPropertyChangeListener(listener);
    }

    public void removePropertyChangeListener(PropertyChangeListener listener) {
        changeSupport.removePropertyChangeListener(listener);
    }
}

Typ wyliczeniowy pl.jaceklaskowski.przychodnia.model.Plec

Dodatkowo klasa Pacjent korzysta z typu wyliczeniowego pl.jaceklaskowski.przychodnia.model.Plec.

package pl.jaceklaskowski.przychodnia.model;

public enum Plec {

    KOBIETA('K', "Kobieta"), MEZCZYZNA('M', "Mężczyzna");

    private final char kod;
    private final String nazwa;

    Plec(char kod, String nazwa) {
        this.kod = kod;
        this.nazwa = nazwa;
    }

    public char kod() {
        return kod;
    }
    
    @Override
    public String toString() {
        return this.nazwa;
    }

    public static Plec valueOf(int cyfra) {
        return cyfra % 2 == 0 ? KOBIETA : MEZCZYZNA;
    }
    
}

W zasadzie, z drobnymi wyjątkami ze względu na błąd w TopLinku, którego użyłem przy mapowaniu JPA, klasy są "technologicznie czyste" (nie ma żadnego wskazania na wykorzystaną technologię wykorzystywaną na bazie modelu). Podobnie chciałem wdrożyć JAXB, który odpowiedzialny byłby za zapis danych i ich odczyt do/z plików bez konieczności modyfikacji modelu (w idealnej sytuacji byłoby tak, że model dostarczałby mi inny dostawca, a ja jedynie z niego korzystałbym, bez możliwości zmiany).

Bystre oko zauważy, że klasa Wizyta zawiera jednak adnotację @XmlTransient, która należy do JAXB, a o której za moment.

Procedura wdrożenia JAXB

Gdybym chciał bezpośrednio zapisywać klasę Pacjent do pliku XML przy pomocy JAXB to dla każdego pacjenta musiałbym utworzyć osobny plik i konieczność zarządzania nimi spadłaby na mnie. Chciałem tego uniknąć i stąd skorzystałem z pomysłu agregacji pacjentów do klasy agregującej - Kartoteka (nazwa klasy ma o tyle znaczenie, że domyślnie odpowiada nazwie głównego elementu w docelowym pliku xml).

Krok 1: Utworzenie klasy zbiorczej modelu - pl.jaceklaskowski.przychodnia.jaxb.Kartoteka

Wdrożenie JAXB rozpoczynam stworzeniem klasy agregującej pl.jaceklaskowski.przychodnia.jaxb.Kartoteka, która będzie jedynie listą pacjentów z ich wizytami.

package pl.jaceklaskowski.przychodnia.jaxb;

import java.util.ArrayList;
import java.util.List;
import javax.xml.bind.annotation.XmlRootElement;
import pl.jaceklaskowski.przychodnia.model.Pacjent;

@XmlRootElement
public class Kartoteka {
    private List<Pacjent> pacjenci = new ArrayList<Pacjent>();

    public Kartoteka() {
    }

    public Kartoteka(List<Pacjent> pacjenci) {
        this.pacjenci = pacjenci;
    }
    
    public List<Pacjent> getPacjenci() {
        return pacjenci;
    }

    public void setPacjenci(List<Pacjent> pacjenci) {
        this.pacjenci = pacjenci;
    }
}

Klasa nie należy do modelu, chociaż w wielu przypadkach mogłaby lub nawet byłaby. Ja założyłem, że skoro nie należała i służy wyłącznie celom wdrożenia JAXB to będzie poza projektem modelu (jest to w zasadzie wyłącznie podyktowane rozszczepieniem elementów aplikacji na niezależne moduły i stąd ma to dla mnie tak ogromne znaczenie).

Elementem wiodącym klasy jest adnotacja @XmlRootElement, która określa klasę główną mapowania JAXB, od której rozpocznie się tworzenie struktury do zapisu do XMLa. Poza tym wszystko jest typowo javowe (przypominam, że to odpowiada mojej definicji "technologicznej czystości").

Krok 2 (opcjonalnie): Utworzenie wspomagającego pliku konfiguracyjnego jaxb.index

W pliku jaxb.index umieszczamy informację o klasie będącej pod kontrolą JAXB. W tym przypadku będzie dotyczyło wyłącznie klasy Kartoteka.

Kartoteka

Jest to plik opcjonalny i jedynie usprawnia późniejsze pisanie aplikacji. Plik musi zawierać się w katalogu odpowiadającym pakietom klas, które wskazuje. Aktualnie jest to pojedyńcze wskazanie na klasę Kartoteka i znajduje się w katalogu pl/jaceklaskowski/przychodnia/jaxb.

Krok 3: Aplikacja z JAXB

Zapis stanu modelu do pliku XML przy tak skonfigurowanym modelu sprowadza się do następujących wywołań:

final File kartotekaXml = new File("kartoteka.xml"); // plik, gdzie zostanie zapisany stan modelu
Kartoteka kartoteka = new Kartoteka(list); // list zawiera listę pacjentów z ich wizytami
try {
    JAXBContext context = JAXBContext.newInstance("pl.jaceklaskowski.przychodnia.jaxb"); // tutaj nastąpi wczytanie pliku jaxb.index
    Marshaller marshaller = context.createMarshaller();
    marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true);
    marshaller.marshal(kartoteka, new FileOutputStream(kartotekaXml)); // to jest moment zapisania stanu do pliku
} catch (Exception ex) {
    // obsługa wyjątku
}

Plik XML - kartoteka.xml - z przykładowymi danymi wygląda następująco:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<kartoteka>
    <pacjenci>
        <imie>Pewien</imie>
        <nazwisko>Pan</nazwisko>
        <pesel>99020200051</pesel>
        <plec>MEZCZYNA</plec>
        <telefon></telefon>
        <wiek>9</wiek>
        <wizyty>
            <dzien>2008-05-21T08:25:27.937+02:00</dzien>
            <id>2</id>
            <leczenie>Lewatywa</leczenie>
            <objawy>Bule brzucha</objawy>
            <rozpoznanie>Zatrucie</rozpoznanie>
        </wizyty>
        <wizyty>
            <dzien>2008-05-21T08:25:27.937+02:00</dzien>
            <id>3</id>
            <leczenie>Spokój</leczenie>
            <objawy>Zawroty głowy</objawy>
            <rozpoznanie>Ciąża</rozpoznanie>
        </wizyty>
    </pacjenci>
    <pacjenci>
        <imie>Pewna</imie>
        <nazwisko>Pani</nazwisko>
        <pesel>00220200001</pesel>
        <plec>KOBIETA</plec>
        <telefon></telefon>
        <wiek>8</wiek>
        <wizyty>
            <dzien>2008-05-21T08:25:27.937+02:00</dzien>
            <id>4</id>
            <leczenie>Jakieś leki</leczenie>
            <objawy>Czerwone kropki na całym ciele</objawy>
            <rozpoznanie>Różyczka</rozpoznanie>
        </wizyty>
        <wizyty>
            <dzien>2008-05-21T08:25:27.937+02:00</dzien>
            <id>5</id>
            <leczenie>Długi urlop</leczenie>
            <objawy>Zawroty głowy</objawy>
            <rozpoznanie>Przemęczenie</rozpoznanie>
        </wizyty>
    </pacjenci>
</kartoteka>

Odczyt danych z pliku XML sprowadza się do

try {
    final File kartotekaXml = new File("kartoteka.xml"); // plik, z którego wczytujemy dane do modelu
    JAXBContext context = JAXBContext.newInstance("pl.jaceklaskowski.przychodnia.jaxb");
    Unmarshaller um = context.createUnmarshaller();
    Kartoteka kartoteka = (Kartoteka) um.unmarshal(kartotekaXml); // wczytanie danych do modelu - począwszy od Kartoteka
    // ...dalsze programowanie z wypełnioną kartoteką
} catch (JAXBException ex) {
    // obsługa wyjątku
}

I tyle. W ten sposób mam obsłużony zapis i odczyt danych do/z pliku XML za pomocą JAXB.

Adnotacja @XmlTransient

Wyjaśnienia domaga się element @XmlTransient w klasie pl.jaceklaskowski.przychodnia.model.Wizyta. Podczas zapisu danych JAXB zapisuje plik XML według schematu - nazwa klasy jako element główny z każdym z atrybutów klasy jako podelementy. Domyślnie JAXB poszukuje wyłącznie atrybutów klasy (pole instancji z metodą odczytu), więc metody odczytu (ang. getters) wskazują na kandydatów na podelementy. Wszystkie atrybuty oznaczone jako @XmlTransient są ignorowane przy zapisie do postaci XML.

Wyobraźmy sobie sposób zapisu klasy Kartoteka. Wszystkie jej pola odpowiadają podelementom elementu kartoteka. Następnie wszystkie atrybuty klasy Kartoteka - wyłącznie pacjenci - zapisywane są jako podelementy. Dalej sprawdzane są atrybuty klas w liście pacjenci, itd. Dochodzimy w końcu do zapisu zwrotnej zależności między klasami Pacjent a Wizyta, gdzie klasa Wizyta zawiera wskazanie na właściciela - Pacjenta. Powstaje cykl, który wykryty przez JAXB oznajmiany jest jako:

javax.xml.bind.MarshalException
 - with linked exception:
[com.sun.istack.internal.SAXException2: A cycle is detected in the object graph. This will cause infinitely deep XML: 
Pacjent[id=99020200051] -> Wizyta[id=0] -> Pacjent[id=99020200051]]
        at com.sun.xml.internal.bind.v2.runtime.MarshallerImpl.write(MarshallerImpl.java:295)
        at com.sun.xml.internal.bind.v2.runtime.MarshallerImpl.marshal(MarshallerImpl.java:221)
        at javax.xml.bind.helpers.AbstractMarshallerImpl.marshal(AbstractMarshallerImpl.java:70)
        at pl.jaceklaskowski.przychodnia.PrzychodniaView.zapiszDaneDoPliku(PrzychodniaView.java:324)

Właśnie, aby zapobieć cyklom w grafie składającym się z atrybutów klasy głównej Kartoteka, Pacjent i Wizyta skorzystałem z adnotacji @XmlTransient. Jego odpowiednikiem w JPA byłby...właśnie! Pora na pytanie konkursowe: Jaka adnotacja JPA odpowiada funkcjonalnie adnotacji @XmlTransient w JAXB? Nagrodą jest bezpłatny udział w konferencji JAVArsovia 2008, gdzie takie i wiele innych ciekawostek będzie prezentowane. Koniecznie się zarejestruj!

Osobiste