02 lutego 2006

Hibernate - tniemy koszty dostępu do danych relacyjnych

Bardzo częstym problemem z jakim borykają się programiści podczas tworzenia oprogramowania jest dostęp do danych, które zazwyczaj przechowywane są w relacyjnej bazie danych. Mimo, że jest to w sprzeczności z obiektowym programowaniem w Javie, które wręcz wymusza obiektowe podejście do danych, w tym i tych składowanych w bazach danych, wielu z nas pozostaje wiernym relacyjnym systemom składowania danych. Właściwie można z dużą pewnością napisać, że bardzo niewielu doświadczyło prostoty z jaką przechowuje się dane w obiektowych bazach danych i nie zapowiada się, aby liczba "szczęśliwców" miała się powiększyć. Zdumiewający jest również upór z jakim programiści piszą własne rozwiązania, bez wcześniejszej ewaluacji istniejących, a przede wszystkim darmowych szkieletów persystencji danych. Aż trudno w to uwierzyć, kiedy utarło się twierdzić, że czas potrzebny na realizację projektu jest zazwyczaj niewystarczający.

Bez cienia wątpliwości można napisać, że najbardziej popularnym, wolnym oprogramowaniem, które realizuje podejście mapowania obiektów na struktury danych składowanych w relacyjnych bazach danych (ang. ORM = Object-to-Relational Mapping) jest Hibernate. Ostatnia finalna wersja 3.1.2 wprowadza, aż tyle udogodnień dostępu do danych relacyjnych, że trudno wyobrazić sobie sytuację, w której pojawiłaby się potrzeba stworzenia alternatywnego rozwiązania. Dostarczana dokumentacja jest na tyle pełna, aby bez niepotrzebnego ryzyka oprzeć kolejny projekt na Hibernate.

Rozpocznijmy naukę Hibernate od prostego przykładu. Załóżmy, że tworzymy aplikację - katalog użytkowników. Podstawowym elementem naszej aplikacji będzie pojęcie użytkownika. Tworzymy klasę pl.net.laskowski.User.

package pl.net.laskowski;

public final class User {
private int id;

private String imie;

private String nazwisko;

public int getId() {
return id;
}

public void setId(int id) {
this.id = id;
}

public String getImie() {
return imie;
}

public void setImie(String imie) {
this.imie = imie;
}

public String getNazwisko() {
return nazwisko;
}

public void setNazwisko(String nazwisko) {
this.nazwisko = nazwisko;
}
}
Nasz użytkownik składa się z identyfikatora, imienia i nazwiska. Nawet dla tak prostej klasy, przechowywanej w bazie danych musimy stworzyć strukturę bazy danych, a następnie obsłużyć operacje przechowywania i odczytu danych przy pomocy JDBC. Nawet dla zaawansowanego programisty jest to zadanie na kilka dobrych kwadransów, a skoro ów zaawansowany programista robił to wiele razy to jaki miałby być cel robić to kolejny raz? Kompletna nuda. Zatem ów zaawansowany programista będzie szukał rozwiązań, które zaoszczędzą mu czas i wcześniej czy później trafi na Hibernate, bo czyż może być przyjemniej, jeśli większość płatnego zadania wykona za nas darmowe oprogramowanie? Dzięki temu nasz kwadrans możemy spędzić na twórczej pracy zamiast powtórnie pisać obsługę dostępu do danych i skoncentrować się na właściwym pisaniu kodu źródłowego naszej nowej aplikacji.

...po kwadransie...

Zakończywszy bardzo twórczą pracę, w której poznaliśmy tajniki stosowania Hibernate w czasie jego wstępnej ewaluacji, przykładowa klasa korzystająca z udogodnień Hibernate w celu przechowywania danych o użytkownikach - pl.net.laskowski.HibernateExample - wygląda następująco:

package pl.net.laskowski;

import java.util.Iterator;
import java.util.List;

import org.hibernate.Criteria;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
import org.hibernate.cfg.Configuration;

public class HibernateExample {

private final static SessionFactory factory;
static {
// 1. Inicjalizacja Hibernate
Configuration cfg = new Configuration().configure();

// 2. Utworzenie fabryki sesji Hibernate
factory = cfg.buildSessionFactory();
}

public static void main(String[] args) {
HibernateExample m = new HibernateExample();
m.createUsers();
m.displayUsers();
}

public void createUsers() {
// 3. Otwarcie sesji Hibernate
Session session = factory.openSession();

// 4. Rozpoczęcie transakcji
Transaction tx = session.beginTransaction();

// 5. Utworzenie użytkownika
User u = new User();
u.setImie("Jacek");
u.setNazwisko("Laskowski");

// 6. Zapisanie użytkownika w bazie danych
session.save(u);

// 7. Zatwierdzenie transakcji
tx.commit();

// 8. Zamknięcie sesji Hibernate
session.close();
}

public void displayUsers() {
// 3. Otwarcie sesji Hibernate
Session session = factory.openSession();

// 4. Rozpoczęcie transakcji
Transaction tx = session.beginTransaction();

// 5. Utworzenie zapytania SQL do bazy o listę użytkowników
Criteria criteria = session.createCriteria(User.class);

// 6. Wykonanie zapytania SQL
List users = criteria.list();

// 7. Iterowanie po wyniku zapytania SQL
for (Iterator it = users.iterator(); it.hasNext();) {
User user = (User) it.next();
System.out.println(user);
}

// 8. Zatwierdzenie transakcji
tx.commit();

// 9. Zamknięcie sesji Hibernate
session.close();
}
}
Na czym polega praca Hibernate? Hibernate odpowiedzialny jest za mapowanie (transfer) danych w postaci obiektów Java do postaci rekordów w bazie danych i vice versa. Rozpoczęcie pracy z Hibernate zawsze rozpoczyna się od jego konfiguracji - wywołania odpowiedniej metody Configuration.configure(), która odszukuje plik konfiguracyjny i wykonuje zawarte w nim instrukcje (o pliku konfiguracyjnym Hibernate napiszemy za moment). Jest to linia opisana jako "1. Inicjalizacja Hibernate". Po tym następuje utworzenie fabryki sesji Hibernate za pomocą metody Configuration.buildSessionFactory(). W tym momencie, jeśli nie został rzucony żaden wyjątek (a nasza przykładowa klasa jest nieprzyzwoicie uboga w obsługę sytuacji wyjątkowych), zakłada się, że Hibernate jest w pełni skonfigurowany. Oba kroki - wywołanie metod Configuration.configure() oraz Configuration.buildSessionFactory() - wykonujemy raz w całej naszej aplikacji i stąd obie umieszczone są najczęściej w statycznym bloku pewnej klasy narzędziowej (np. HibernateUtils). Należy jednak pamiętać, że zastosowanie takiego podejścia wiąże się z pewnymi konsekwencjami w środowisku wielowątkowym, jak serwer aplikacyjny J2EE i wymagałoby innego podejścia, np. umieszczenie fabryki w drzewie JNDI tak, aby zapewnić jej pojedyńcze stworzenie. Bez względu na zastosowane podejście, pamiętajmy, że obie metody tworzą obiekty typu Configuration i SessionFactory, które wystarczy, aby występowały pojedyńczo w naszej aplikacji. Dla naszego rozwiązania zaproponowane rozwiązanie (utworzenie tworzenie obiektu session w statycznym bloku) w zupełności wystarczy.

Fabryka sesji Hibernate pozwala na otwieranie sesji, które reprezentują pewną jednostkę pracy, podobnie jak rozpoczęcie transakcji, jednakże zależność między sesją (obiekt typu org.hibernate.Session), a transakcją (obiekt typu org.hibernate.Transaction) jest jeden do wielu (1-*), w szczególności żadna transakcja nie musi być uruchomiona podczas otwarcia sesji Hibernate, co nie jest prawdą w odwrotnej sytuacji (rozpoczynamy transakcję po otwarciu sesji).

Do tej pory wszystkie kroki - od 1. do 4. - związane były całkowicie z Hibernate i będą nierozłącznym elementem aplikacji opartej na Hibernate. Po ich stworzeniu, rozpoczynamy właściwą pracę naszej aplikacji. Dla celów poglądowych klasa - p.n.l.HibernateExample - zawiera dwie metody - jedna do stworzenia użytkownika - HibernateExample.createUsers(), a druga do jego wyświetlenia - HibernateExample.displayUsers(). Po linii "5. Utworzenie użytkownika" następuje seria wywołań nie związanych z Hibernate - z funkcjonalnego punktu widzenia tworzymy użytkownika, co przekłada się na utworzenie instancji typu p.n.l.User. W tym momencie może nastąpić utworzenie dowolnej liczby obiektów, które ostatecznie trafią do bazy danych poprzez Hibernate. Tworzenie i inicjowanie pól obiektów w żaden sposób nie jest związane z Hibernate i odbywa się w dowolny, wybrany przez projektanta sposób. Zaraz po linii "6. Zapisanie użytkownika w bazie danych" następuje próba transferu obiektów do bazy danych za pomocą wywołania metody Session.save(Object). Należy pamiętać, że obiekty są zapamiętywane przez Hibernate i nie są natychmiast wysyłane do bazy wraz z ich wyczyszczeniem w pamięci podręcznej Hibernate (bardzo istotne miejsce optymalizacji kodu korzystającego z Hibernate).

Zanim przejdziemy do kolejnych kroków w naszej przykładowej aplikacji spójrzmy na plik konfiguracyjny Hibernate i plik z mapowaniem naszej klasy p.n.l.User.

Poniżej znajduje się plik konfiguracyjny Hibernate - hibernate.cfg.xml. Istnieje kilka innych sposobów konfiguracji Hibernate, jednakże konfiguracja poprzez plik hibernate.cfg.xml jest najczęściej wykorzystywanym sposobem. Metoda odszukiwania pliku przez Hibernate polega na przeszukaniu CLASSPATH. Podczas budowania projektu, należy pamiętać o umieszczeniu pliku hibernate.cfg.xml do odpowiedniego katalogu, który jest włączony do CLASSPATH.

<?xml version='1.0' encoding='utf-8'?>
<!DOCTYPE hibernate-configuration PUBLIC "-//Hibernate/Hibernate Configuration DTD 3.0//EN"
"http://hibernate.sourceforge.net/hibernate-configuration-3.0.dtd">
<hibernate-configuration>
<session-factory>
<property name="connection.driver_class">org.hsqldb.jdbcDriver</property>
<property name="connection.url">jdbc:hsqldb:.</property>
<property name="connection.username">sa</property>
<property name="connection.password"></property>
<property name="connection.pool_size">1</property>
<property name="dialect">org.hibernate.dialect.HSQLDialect</property>
<property name="current_session_context_class">thread</property>
<property name="cache.provider_class">org.hibernate.cache.NoCacheProvider</property>
<property name="show_sql">true</property>
<property name="hbm2ddl.auto">create</property>
<mapping resource="pl/net/laskowski/User.hbm.xml"/>
</session-factory>
</hibernate-configuration>
W zasadzie, znaczenie poszczególnych elementów pliku jest samowyjaśniające się. Hibernate posiada bardzo bogaty zbiór parametrów konfiguracyjnych (ang. property), jednakże powyższy plik jest dobrym punktem wyjścia do bardziej wyrafinowanej konfiguracji dla innych projektów. Przede wszystkim należy zaznaczyć, że struktura pliku hibernate.hbm.xml jest zgodna z formatem pliku XML, czyli wszystko znajduje się pomiędzy znacznikami, każdy zaczynający znacznik ma swój kończący odpowiednik i żadne dwa znaczniki nie przecinają się, tj. kończące znaczniki są w odwrotnej kolejności do początkowych. W powyższym pliku konfiguracyjnym korzystamy z darmowej, relacyjnej bazy danych HSQLDB, która ma możliwość uruchamiania w trybie wbudowanym, tj. bez konieczności uruchamiania osobnego procesu z bazą danych (co jest również możliwe w HSQLDB). Bardzo upraszcza to testowanie i w wielu przypadkach jest również wystarczającym repozytorium danych dla budowanych aplikacji. Ważnymi parametrami Hibernate w przedstawionym pliku są show_sql oraz hbm2ddl.auto. Ich wartości pozwalają na wyświetlanie zapytań SQL na konsolę (show_sql wynosi true) oraz tworzenie struktury bazy danych przy starcie aplikacji (hbm2ddl.auto wynosi create). Po przetestowaniu aplikacji parametr hbm2ddl.auto zaleca się ustawić na validate, albo po prostu wyłączyć poprzez usunięcie, czy zakomentowanie linii zawierającej parametr. Inną ciekawostką tego pliku konfiguracyjnego jest wskazanie pliku z ustawieniami mapowania klas na reprezentencję relacyjną, która będzie potrzebna podczas zapisu danych przez Hibernate do bazy danych. Służy do tego znacznik mapping i tylko te klasy, które są zawarte w plikach wskazanych przez niego będą wykorzystywane przez Hibernate. Kolejna częsta pomyłka podczas konfiguracji Hibernate, która potrafi zająć sporo czasu.

Poniżej znajduje się plik - pl/net/laskowski/User.hbm.xml, który wskazany został przez znacznik mapping w pliku konfiguracyjnym hibernate.cfg.xml.

<?xml version="1.0"?>
<!DOCTYPE hibernate-mapping PUBLIC "-//Hibernate/Hibernate Mapping DTD 3.0//EN"
"http://hibernate.sourceforge.net/hibernate-mapping-3.0.dtd">
<hibernate-mapping package="pl.net.laskowski">
<class name="User" table="User">
<id name="id"><generator class="native"/></id>
<property name="imie" column="T_IMIE" length="10" not-null="true"/>
<property name="nazwisko" column="T_NAZWISKO" length="25" not-null="true"/>
</class>
</hibernate-mapping>
Plik - User.hbm.xml - przedstawia konfigurację mapowania klasy pl.net.laskowski.User (atrybut package znacznika hibernate-mapping oraz atrybut name znacznika class). W zasadzie nazwa pliku i jego umiejscowienie w strukturze plików nie jest istotne, jednakże przyjęło się, że każda klasa, która będzie obsługiwana przez Hibernate, posiada swój własny plik mapowania, który znajduje się w katalogu odpowiadającym pakietowi mapowanej klasy z nazwą odpowiadającą nazwie klasy z rozszeżeniem hbm.xml. Jest to jedynie konwencja, której stosowanie znacząco przyspiesza proces wdrażania się przez nowe osoby do projektu, kiedy w użyciu jest Hibernate (oczywiście bardziej zaawansowana osoba rozpocznie swoje poznawanie warstwy persystencji od zapoznania się z plikiem konfiguracyjnym Hibernate). Metoda odszukania pliku polega na odszukaniu pliku, wskazanego w hibernate.cfg.xml jako atrybut resource znacznika mapping i mimo, że nazwa pliku i jego ścieżka nie mają znaczenia, to jego umiejscowienie w CLASSPATH już tak. Należy upewnić się, że Hibernate będzie mógł odszukać plik poprzez odszukanie zasobu w CLASSPATH.

Plik mapowania pl/net/laskowski/User.hbm.xml składa się ze znacznika class, którego atrybuty wskazują na nazwę klasy, której dotyczą (atrubut name) oraz tablicę w bazie danych, która będzie przechowywała obiekty (atrybut table). Pomiędzy znacznikiem class znajduje się właściwe mapowanie pól klasy na odpowiadające im pola w bazie danych. Mapowanie pól odbywa się za pomocą znaczników id oraz property. Są one identyczne ze względu na akceptowane atrybuty, jednakże pierwszy - id - wskazuje na nazwę zmiennej instancji klasy (atrybut name), która reprezentuje klucz główny tabeli. W naszym przykładzie, dla zmiennej instancji klasy o nazwie id, skonfigurowany został generator kolejnych wartości, który będzie oparty o mechanizmy wbudowane w wybranej bazie danych. Za dobranie właściwego mechanizmu dba dialekt specyficzny dla każdej bazy danych i jest konfigurowany w pliku hibernate.cfg.xml jako wartość parametru hibernate.dialect. Poprzedziłem nazwę parametru prefiksem hibernate mimo, że taki parametr nie istnieje w pliku hibernate.cfg.xml, a jedynie dialect. Wynika to z zaszłości stosowania pliku o formacie properties, w który każdy parametr Hibernate jest poprzedzany przez prefiks hibernate. Stosowanie go nie jest konieczne podczas konfigurowania Hibernate przez plik hibernate.cfg.xml i jest jedynie używane w dokumentacji do wyróżnienia pochodzenia parametru. Atrybut name znaczników id oraz property wskazuje na nazwę zmiennej instancji klasy (świat obiektowy - Java), podczas gdy column wskazuje na nazwę kolumny, która będzie przechowywała dane (świat relacyjny - SQL). Dalsze atrybuty pozwalają na dokładniejsze wyrażenie mapowania i odpowiadają charakterystyce kolumn w tabeli. W naszym przykładzie wykorzystałem atrybuty column, length oraz not-null.

Po wykonaniu zapisu do bazy danych zatwierdzamy transakcję (linia "7. Zatwierdzenie transakcji") oraz zamykamy sesję Hibernate (linia "8. Zamknięcie sesji"). W ten sposób kończymy metodę createUsers, której celem było utworzenie użytkownika i jego zapis do bazy danych.

Kolejną metodą naszej przykładowej aplikacji jest displayUsers().

public void displayUsers() {
// 3. Otwarcie sesji Hibernate
Session session = factory.openSession();

// 4. Rozpoczęcie transakcji
Transaction tx = session.beginTransaction();

// 5. Utworzenie zapytania SQL do bazy o listę użytkowników
Criteria criteria = session.createCriteria(User.class);

// 6. Wykonanie zapytania SQL
List users = criteria.list();

// 7. Iterowanie po wyniku zapytania SQL
for (Iterator it = users.iterator(); it.hasNext();) {
User user = (User) it.next();
System.out.println(user);
}

// 8. Zatwierdzenie transakcji
tx.commit();

// 9. Zamknięcie sesji Hibernate
session.close();
}
W poprzedniej metodzie dokonaliśmy zapisu informacji w bazie danych, teraz nastąpi jej odczyt. Procedura odczytu danych z bazy danych poprzez Hibernate rozpoczyna się tradycyjnie otwarciem sesji ("3. Otwarcie sesji Hibernate") i rozpoczęciu transakcji ("4. Rozpoczęcie transakcji"). Ta część programu jest już nam znana. Przejdźmy do ciekawszego jego elementu stworzeniem zapytania do pobrania danych. W tym celu korzysta się z klasy org.hibernate.Criteria. Do utworzenia instancji klasy służy metoda Session.createCriteria(Class). Jako parametr wejściowy podaje się klasę, której instancje chcemy zainicjować danymi pochodzącymi z bazy danych. Konfiguracja Hibernate wskazuje, która tabela przechowuje dane relacyjne reprezentujące instancje klasy (dla przypomnienia: jest to część konfiguracji hibernate.cfg.cml znacznik mapping). Naturalnie, w klasie o.h.Criteria znajdziemy metody, które pozwalają na zawężenie zapytania do interesujących nas rekordów. Zaletą tworzenia zapytania za pomocą takiego sposobu, to przede wszystkim wyniesienie informacji dotyczącej poszczególnych pól biorących udział w zapytaniu do pliku zewnętrznego (cf. pl/net/laskowski/User.hbm.xml), który można w prosty sposób zmodyfikować bez nadmiarowej rekompilacji aplikacji. Wywołanie metody Criteria.list() zwraca listę obiektów (utworzonych na podstawie danych relacyjnych) typu będącego parametrem wejściowym podczas tworzenia instancji klasy o.h.Criteria. Iterowanie po wynikach nie jest już niczym nadzwyczajnym i nie jest związane z Hibernate w żaden sposób (poza tym, że implementacja zwróconego obiektu typu java.util.Iterator pochodzi z Hibernate i jest możliwa bardziej specjalizowana konfiguracja sposobu pobierania danych podczas odpytania iteratora o kolejny obiekt, który będzie zmaterializowany na podstawie danych relacyjnych). Jak przedstawiono w poprzedniej metodzie, koniec pracy z Hibernate to zamknięcie obiektów z nim związanych - transakcji i sesji (cf. "8. Zatwierdzenie transakcji" oraz "9. Zamknięcie sesji Hibernate").

Do uruchomienia aplikacji będą nam potrzebne następujące biblioteki, które dostarczane są razem z Hibernate (w katalogu głównym, bądź lib):

  • antlr-2.7.6rc1.jar
  • cglib-2.1.3.jar
  • commons-collections-2.1.1.jar
  • commons-logging-1.0.4.jar
  • dom4j-1.6.1.jar
  • ehcache-1.1.jar
  • hibernate3.jar
  • hsqldb.jar
  • jta.jar
Po ustawieniu środowiska tak, aby wspomniane biblioteki były dostępne, uruchomienie aplikacji sprowadza się do wywołania klasy p.n.k.HibernateExample.
java pl.net.laskowski.HibernateExample
. Należy pamiętać o prawidłowym rozmieszczeniu plików konfiguracyjnych - hibernate.cfg.xml oraz User.hbm.xml w odpowiednich katalogach.