Wstęp

W artykule przedstawiony został sposób implementacji technologii NHibernate na przykładzie prostej aplikacji konsolowej. Od czytelnika wymaga się znajomości środowiska Visual Studio oraz podstawowej wiedzy na temat pisania programów. Ponadto zakładam iż czytelnik zapoznał się z poprzednim tematem pt. [Teoretyczne wprowadzenie do NHibernate]. Po przeczytaniu artykułu zdobędzie on następującą wiedzę:
  • sposoby zapisywania/odczytywania danych poprzez mechanizm NHibernate,
  • automatyczne tworzenie obiektów poprzez mechanizm NHibernate,
  • odwzorowanie relacji jeden do wiele;
Jedna klasa jedna tabela
Załóżmy że istnieje potrzeba zapisania informacji z klasy Country(listing poniżej) do bazy danych.
public class Country {
    virtual public int CountryID { get; set; }
    virtual public string Name { get; set; }
    virtual public string Description { get; set; }
}
 
Klasa Country
Rys. 1 Diagram klasy Country

Jest to klasa zgodna z modelem POCO bowiem jej właściwości są zadeklarowane jako virtual, oraz posiada bezparametrowy konstruktor (stworzony zostanie automatycznie podczas kompilacji). W tym przypadku każdej z właściwości będzie odpowiadać kolumna w tabeli, ale nie zawsze tak musi być. Plik Country.hbm.xml odwzorowujący klasę na tabelę (listing poniżej) powinien posiadać parametr Build Action ustawiony na Embedded Resource.
<?xml version="1.0" encoding="utf-8" ?>
<hibernate-mapping xmlns="urn:nhibernate-mapping-2.2" namespace="TestNHibernate.Domain" assembly="TestNHibernate">
  <class name="Country" table="Countries">
    <id name="CountryID">
      <generator class="native"/>
    </id>
    <property name="Name" not-null=”true”/>
    <property name="Description" not-null=”true”/>
  </class>
</hibernate-mapping>
W miarę rozwoju projektu liczba metod wymagających sesji (ISession) będzie rosła, dlatego do odczytywania konfiguracji oraz inicjowania sesji warto utworzyć osobną klasę pomocniczą np. NHibernateHelper(listing poniżej).
public class NHibernateHelper {
    private static ISessionFactory _sessionFactory;
 
    //tworzy sesję jeżeli takowej jeszcze nie ma
    public static ISession OpenSession() {
        if (_sessionFactory == null)
            _sessionFactory = GetConfig().BuildSessionFactory();
        return _sessionFactory.OpenSession();
    }
 
    public static Configuration GetConfig() {
        return new Configuration().Configure() //Odczytuje konfiguracje z plików "hibernate.cfg.xml"
        .AddClass(typeof(Country));
    }
}
 
Mając tak przygotowany projekt jedną komendą można utworzyć tabelę w bazie danych (listing poniżej). Jest to szczególnie przydatne podczas testów jednostkowych, gdzie każdy test mógłby być poprzedzony usunięciem wszystkich tabel oraz ich ponownym utworzeniem, tak by stworzyć możliwe jednorodne środowisko dla każdego z testów.
new SchemaExport(NHibernateHelper.GetConfig()).Execute(true, true, false);
Teraz po uruchomieniu aplikacji program powinien połączyć się z bazą danych i utworzyć tabelę o nazwie Countries, a jeżeli już istniała najpierw ją usunie. Dodatkowo na konsoli powinny się pojawić zapytania SQL tworzące tabelę (parametr show_sql=true w nhibernate.cfg.xml).
Zapis i odczyt z bazy danych
Tworzenie zapytań SQL polega na zastosowaniu jednej z następujących technik: HQL (ang. Hibernate Query Language), ICriteria API oraz zwykłe zapytania SQL. HQL i ICriteria są obiektowymi językami i różnią się głównie składnią.
Zwykłych zapytań SQL powinno się używać jak najmniej ponieważ NHibernate nie ma na nie wpływu i przy zmianie bazy danych może dojść do problemów . Bazy danych różnią się między sobą np. w Oracle nie ma klauzuli Join. Dodatkowo zmiany w warstwie biznesowej wymagają od programisty zmian zwykłych zapytań SQL w warstwie dostępu do danych. Wady tej pozbawione są języki HQL i ICriteria API. W artykule autor korzystał głównie z HQL, jako że jest on bardziej, według niego, intuicyjnym językiem zapytań. Zapis informacji w systemach ORM przebiega zazwyczaj według następującego schematu:
  • stworzenie obiektu na podstawie encji z logiki biznesowej,
  • zapisanie jej do bazy;
Czyli:
//tworzenie obiektu
Country _country = new Country() { Name = "Poland", Description="Country on the river Wisła" };
 
//zapis do DB
using (ISession session = NHibernateHelper.OpenSession()) 
    using (ITransaction transaction = session.BeginTransaction()) {
           session.Save(_country);
           transaction.Commit();
    }
Całość obudowana jest transakcją (interfejs ITransaction). Pozwala on na wykonanie wszystkich instrukcji, lub żadnej w przypadku kiedy któraś z nich się nie uda. W tak prostym przykładzie transakcje można śmiało pominąć. Pole CountryID zostało wypełnione dopiero podczas operacji zapisu, numerem ID z bazy danych.
Odczyt z bazy wygląda analogicznie:
Country _c1;
using (ISession session = NHibernateHelper.OpenSession())
    _c1 = session.Get<Country>(1);
//lub
Country _c2;
using (ISession session = NHibernateHelper.OpenSession())
    _c2 = session.Load<Country>(1);
Różnice pomiędzy Load a Getsą następujące
  • na to jak działa Load wpływ ma parametr lazy,
  • jeżeli Load nie znajdzie rekordu w bazie zwraca wyjątek,
  • jeżeli Get nie znajdzie rekordu zwraca obiekt null;
Load oraz Get korzystają z bufora (ang. cache) w obrębie jednej sesji/transakcji dlatego poniższe instrukcje spowodują wykonanie tylko jednego zapytania SQL:
Country _c1, _c2;
using (ISession session = NHibernateHelper.OpenSession())    {
  _c1= session.Get<Country>(1);
  _c1= session.Get<Country>(1);
}
Poniższy blok kodu przy parametrze lazy=true nie zwróci żadnego zapytania ponieważ nastąpiło tu utworzenie obiektu zastępczego (ang. proxy). NHibernate zwróciłby zapytanie do bazy danych jeżeli obiekt _c1 lub _c2 byłby użyty w obrębie sesji. Jeżeli użycie obiektu _c1 lub _c2 nastąpi po zamknięciu sesji to zostanie zwrócony wyjątek: "Could not inicjalize proxy – no Session".
Country _c1, _c2;
using (ISession session = NHibernateHelper.OpenSession())    {
  _c1= session.Get<Country>(1);
  _c1= session.Get<Country>(1);
}
Uwaga: cały czas mowa o sytuacji kiedy parametr lazy=true. W przypadku kiedy lazy=false powyższy kod zwróci jedno zapytanie SQL. Load z parametrem lazy=false jest podobne do Get z tym wyjątkiem że Get odwołując się do nie istniejącego rekordu zwróci null a Load wyjątek.
Aktualizacja rekordów wygląda następująco:
  • pobranie rekordu z bazy,
  • utworzenie obiektu na jego podstawie,
  • edycja tego obiektu,
  • zapis do bazy;
//zmiana liczby ludności w województwie pomorskim
using (ISession session = NHibernateHelper.OpenSession())
using (ITransaction transaction = session.BeginTransaction()) {
    _p1 = session.Get<Province>(2);
    _p1.Population += 1;
    session.Save(_p1);
    transaction.Commit();
Jeżeli trzeba wykonać UPDATE na wielu rekordach (np. 100 000) lepiej użyć procedury zawartej bezpośrednio w bazie danych (ang. stored procedure) lub bezpośredniego zapytania SQL. W przeciwnym wypadku może to się zakończyć wyjątkiem OutOfMemmoryException.
Tworzenie relacji pomiędzy tabelami
Jako przykład posłuży relacja jeden do wielu pomiędzy klasami Country oraz Province
Diagram
Rys. 2 Relacja pomiędzy klasą Country a Province.
Klasa Country połączona jest kompozycja z klasą Province. Obiekt klasy Province musi zawierać jedno odwołanie z obiektem klasy Country. Obiekt klasy Country może zawierać wiele obiektów klasy Province lub nie zawierać ich wcale.
Kod klas wygląda następująco:
public class Province {
    virtual public int ProvinceID { get; set; }
    virtual public Country Country { get; set; }        
    virtual public string Name { get; set; }
    virtual public int Population { get; set; }
}
public class Country {
    private ISet<Province> _provinces;
    public Country() {
        _provinces = new HashedSet<Province>();
    }
 
    virtual public int CountryID { get; set; }
    virtual public string Name { get; set; }
    virtual public string Description { get; set; }
    virtual public ISet<Province> Provinces {
        get { return _provinces; }
        set { _provinces = value; }
    }
 
    virtual public void AddProvince(Province _province) {
        _province.Country = this;
        Provinces.Add(_province);
    }
}
Każda klasa Province jest związana z jedną klasą Country dlatego posiada pole typu Country do przechowywania obiektu z którym jest związana.
Natomiast obiekt klasy Country może być związany z wieloma obiektami klasy Province dlatego posiada pole które przechowuje kolekcje tych obiektów. Uwaga: kolekcja ta nie może przechowywać jednakowych obiektów, gdzie jednakowy znaczy z tym samym numerem id. Byłoby to nielogiczne podczas operacji odwzorowania klasy na tabele. Klasa zawiera metodę AddProvince która dodaje nowe obiekty do kolekcji, posiada ona modyfikator virtual, tak by NHibernate mógł ją dynamicznie zmienić na własne potrzeby.
Treść plików odwzorowań jest nastepująca:
<?xml version="1.0" encoding="utf-8" ?>
<hibernate-mapping xmlns="urn:nhibernate-mapping-2.2" 
      namespace="TestNHibernate.Domain" assembly="TestNHibernate">
  <class name="Province" lazy="true" table="Provinces">
    <id name="ProvinceID">
      <generator class="native"/>
    </id>
    <many-to-one name="Country"
                 column="CountryID"
                 not-null="true" />
    <property name="Name"/>
    <property name="Population"/>
  </class>
</hibernate-mapping>
<?xml version="1.0" encoding="utf-8" ?>
<hibernate-mapping xmlns="urn:nhibernate-mapping-2.2" 
      namespace="TestNHibernate.Domain" assembly="TestNHibernate">
  <class name="Country" table="Countries" lazy="true">
    <id name="CountryID">
      <generator class="native"/>
    </id>
    <property name="Name"/>
    <property name="Description" />
    <set name="Provinces" inverse="true" cascade="all">
      <key column="CountryID" />
      <one-to-many class="Province" />
    </set>
  </class>
</hibernate-mapping>
Kod pliku Province.hbm.xml zawiera sekcję many-to-one oznaczającą jednokierunkowe powiązanie Province z Country. Analogicznie kod pliku Country.hbm.xml zawiera sekcje set oznaczającą jednokierunkowe powiązanie Country z Province. Te dwa jednokierunkowe powiązania tworzą w sumie jedno dwukierunkowe. Z obiektu klasy Province można się odwołać do Country, tak jak i z obiektu klasy Country do Province.
Diagram ERD
Rys. 3 Diagram ERD tabel Countries i Provinces

Zapis do bazy (listing poniżej) odbywa się analogicznie do przykładów przedstawionych powyżej.
Country _country = new Country() { Name = "Poland", Description="Country on the river Wisła" };
_country.AddProvince(new Province(){ Name="warmińsko-mazurskie", Population=1426155});
_country.AddProvince(new Province() { Name="pomorskie", Population=2230099});
using (ISession session = NHibernateHelper.OpenSession())
  using (ITransaction transaction = session.BeginTransaction()) {
       session.Save(_country);
       transaction.Commit();
  }
Właściwie jedna linijka session.Save(nazwaObiektu) zastępuje kilka poleceń SQL.
Podsumowanie
Tak przygotowany projekt bardzo łatwo rozbudować o kolejne moduły. Zmiany w logice biznesowej są automatycznie przenoszone do bazy danych. Nawet zmiana typu bazy danych nie powoduje większych problemów (plik hibernate.cfg.xml).
Cała technologia jest bardzo rozbudowana i wymaga dobrego zrozumienia wszystkich jej szczegółów. Autor miał z tym wiele problemów dlatego postanowił napisać między innymi ten artykuł.
Przedstawione rozwiązanie jest tylko jedną z ścieżek. System NHibernate jest rozwijany przez wiele osób i ten sam efekt można osiągnąć na wiele sposobów.
Literatura