Jakiś czas temu opublikowałem garść porad dla naprawdę zaczynających z NHibernate - od zera. Była to raczej wysokopoziomowa teoria pomieszana z linkami. Tym razem zajrzymy w kod i pokażę w jaki sposób można zacząć tworzyć i testować aplikację z NHibernate jeszcze przed zaplanowaniem struktury bazy danych czy nawet przed wyborem docelowego serwera baz danych.
Paczka do ściągnięcia w dziale Samples.
Każdy może sobie ściągnąć kod (jest go raczej minimalna ilość), podłubać i wyrobić własne zdanie na jego temat. Poniżej przedstawię kilka podstawowych założeń, które stanowią "core" mojego podejścia:
1) NHibernate jest częścią aplikacji
Dokładnie tak.
Kiedyś wydawało mi się, że coś takiego jak dostęp do danych MUSI być ukryte pod własną warstwą repozytoriów, serwisów, czegokolwiek. Po co? Ano... żeby można ją było łatwo podmienić. Albo żeby cały kod odpowiedzialny za bazę danych znajdował się w osobnej dllce. Albo "bo to przecież osobna warstwa". Albo z jeszcze jakichś innych powodów.
Doświadczenie nauczyło, że korzystanie z ORMappera i CHOWANIE go gdzieś całkowicie pod spodem jest po prostu niepraktyczne (ORMapper JEST warstwą dostępu do danych!). Szczególnie jeśli chodzi o NH, które oprócz operacji na bazie oferuje masę innych możliwości (na przykład cache czy walidację). Zamiast stosować rozdęte "repozytoria" lepiej zainteresować się chociażby Command/Query Separation.
Postanowiłem zatem przestać udawać, że ISession nie istnieje i najzwyczajniej w świecie zacząć korzystać z ORMa, a nie własnej na niego nakładki.
Otwieranie nowej sesji udostępniane jest przez statyczną klasę o wiele mówiącej nazwie DataAccessFacade.
1: ISession session = DataAccessFacade.OpenSession();
Wygląda bardzo prosto, bo jak skomplikowana może być implementacja OpenSession()? Ano... może.
2) Func<ISession> zamiast ISession
Implementacja logiki otwierania nowej sesji wygląda tak:
1: public static class DataAccessFacade
2: {
3: [ThreadStatic]
4: private static Func<ISession> _openSession;
5:
6: public static Func<ISession> OpenSession
7: {
8: set { _openSession = value; }
9: get { return _openSession ?? _defaultOpenSession; }
10: }
Co daje nam takie zamotanie? Otóż niekiedy (a dokładniej - w testach jednostkowych, ale o tym za chwilę) może najść nas potrzeba podmiany instrukcji otwierających nową sesję. W takim przypadku każdy wątek z osobna (za sprawą ThreadStaticAttribute) wstawi tam sobie własną logikę i szlus. A jeśli żadna podmiana nie nastąpi, to wykonana zostanie domyślna implementacja:
1: private static readonly Func<ISession> _defaultOpenSession =
2: () =>
3: {
4: if (_sessionFactory == null)
5: {
6: lock (_syncRoot)
7: {
8: if (_sessionFactory == null)
9: Configure();
10: }
11: }
12:
13: return _sessionFactory.OpenSession();
14: };
W tym przypadku - całkowity standard. Jedna SessionFactory per aplikacja.
Krótkie wyjaśnienie:
Ten sam efekt (różne logiki otwierania połączenia w zależności od scenariusza) można oczywiście uzyskać poprzez abstrakcję tej czynności do interfejsu ISessionProvider z metodą Open(). "Prawdziwa" implementacja byłaby albo dostarczana przez framework Dependency Injection podczas działania aplikacji, natomiast w testach ręcznie przekazywany byłby mock zwracający sesję w pełni przeze mnie kontrolowaną.
ALE.
Oczywistym jest, że baaaardzo duża część systemu potrzebuje nowej sesji, a co za tym idzie: masa konstruktorów musiałaby przyjmować w parametrze ISessionProvider. Niby jest to podejście całkowicie poprawne, lecz w tym konkretnym przypadku skrót w postaci klasy statycznej, eliminujący masę jednakowych i nicniewnoszących zależności sprawdza się moim zdaniem po prostu lepiej. Be pragmatic! Podobnie zresztą jak w przypadku klasy obsługującej logi czy (w niektórych przypadkach) dostarczającej konfigurację.
Dobra, trochę zboczyłem z uzasadnieniami, więc dość na ten temat. Kiedyś może rozwinę się bardziej. I nigdy się nie kończę, mięciutki jak kaczuszka...
3) Skrót do transakcji: DataAccessFacade.InTransaction(...)
W aplikacjach NHibernate bardzo często powtarza się blok kodu analogiczny do tego:
1: using (var session = DataAccessFacade.OpenSession())
2: {
3: using (var transaction = session.BeginTransaction())
4: {
5:
6:
7: transaction.Commit();
8: }
9: }
Mierziło mnie to nieco, zatem mam metodę pomocniczą:
1: public static void InTransaction(Action<ISession> operation)
2: {
3: using (var session = OpenSession())
4: {
5: using (var tx = session.BeginTransaction())
6: {
7: operation(session);
8:
9: tx.Commit();
10: }
11: }
12: }
Teraz wykonanie czegoś w transakcji wygląda tak:
1: DataAccessFacade.InTransaction(
2: session =>
3: {
4: session.Save(user1);
5: session.Save(user2);
6: });
Wieeele linii kodu dało się dzięki temu zepchnąć w piekielne czeluście, †KYSZSZSZ† !!!
4) Testy jednostkowe - założenie
Pragnieniem moim jest taką metodę:
1: public void AddUser(User user)
2: {
3: DataAccessFacade.InTransaction(session => session.Save(user));
4: }
przetesować w taki sposób:
1: [Test]
2: public void AddsNewUser()
3: {
4: var newUser = new User()
5: {
6: UserName = RandomValues.String(),
7: Age = RandomValues.Number(),
8: };
9:
10: new UsersService().AddUser(newUser);
11:
12: User fetched;
13: using (var session = DataAccessFacade.OpenSession())
14: {
15: fetched = session.Linq<User>().Where(x => x.UserName == newUser.UserName).SingleOrDefault();
16: }
17:
18: Assert.IsNotNull(fetched);
19: Assert.AreEqual(newUser.Age, fetched.Age);
20: }
I chcę to robić używając SQLite, które pozwala na tworzenie bazy danych w pamięci. Jest to naprawdę megabłyskawiczny proces - dzięki temu każden jeden test może otrzymać nową, świeżą, specjalnie dla niego utworzoną bazę. I trwa to mgnienie oka.
5) Mechanizm testów jednostkowych
O testowaniu jednostkowym NHibernate z użyciem SQLite pisało wiele osób (wystarczy zajrzeć w Google). Mimo to... musiałem trochę pokombinować aby uzyskać pożądany przeze mnie efekt.
Wróćmy na chwilę do tego CO chcę osiągnąć. Zależy mi na tym, aby testowana logika SAMA dostarczała sobie sesję. Nie chcę tworzyć w tym miejscu sztucznych zależności o których pisałem wcześniej. Mało tego - metoda ta może zrobić z uzyskaną sesją co jej się żywnie podoba. Między innymi (co oczywiste): wywołać na niej Dispose(). A należy wiedzieć, że baza SQLite tworzona w pamięci żyje tyle, ile połączenie, które ją utworzyło. Wniosek jest prosty: zamknięcie sesji == zamknięcie połączenia == zniszczenie bazy. Normalna sytuacja przedstawia się tak: testowana metoda uzyskuje sesję podłączoną do bazy w pamięci -> wykonuje operacje -> zamyka sesję -> niszczy bazę... i tyleśmy ją widzieli. Test jednostkowy MOŻE mieć dostęp do tej samej sesji (pamiętamy o możliwości modyfikacji metody OpenSession(), prawda?), ale co z tego, skoro jest ona disposed...?
Po dość długim eksperymentowaniu skończyło się na rozwiązaniu, które w pełni mnie satysfakcjonowało. Każdy test jednostkowy wymagający bazy danych posiada własną instancję klasy InMemoryDatabase. Każda instancja podczas konstrukcji tworzy nową bazę danych i wypełnia jej strukturę na podstawie mapowań (dzięki klasie NH SchemaExport). Dodatkowo (standardowo) statycznie konfigurowana jest SessionFactory. Przedstawione wcześniej założenia WYMUSZAJĄ możliwość wielokrotnego skorzystania z jednego obiektu implementującego ISession: zarówno w testowanej logice, jak i w samym teście. Wymaga to oczywiście modyfikacji mechanizmu otwierającego sesje, co osiągnąłem w trzech krokach.
Po pierwsze, w konstruktorze InMemoryDatabase tworzę prawdziwą sesję NHibernate, jak Bozia przykazała - to ona koniec końców posłuży do zbudowania bazy danych i operacji na niej:
1: _session = _sessionFactory.OpenSession();
Po drugie, dopilnuję, że z metody DataAccessFacade.OpenSession() zwracana jest właśnie ta jedna sesja; ale wcześniej - uwaga - wywołuję na niej Clear(), aby wyczyścić first-level cache, w przeciwnym wypadku wszystkie dane wykorzystane w operacjach byłyby zapamiętane i podczas faktycznego wykonywania testu NH wcale nie wędrowałoby do bazy:
1: DataAccessFacade.OpenSession =
2: () =>
3: {
4: _session.Clear();
5: return new UndisposableSession(_session);
6: };
I wreszcie po trzecie: dopilnuję, aby Dispose() czy Close() nie zamykało połączenia, nie niszczyło bazy, nie powodowało bezużyteczności tej sesji. Do jej "odświeżenia" wystarczy w zupełności pokazane Clear(). Do tego celu zaimplementuję widocznego w powyższym snippecie, banalnego dekoratora sesji:
1: public class UndisposableSession : ISession
2: {
3: private readonly ISession _session;
4:
5: public UndisposableSession(ISession session)
6: {
7: _session = session;
8: }
9:
10: public void Dispose()
11: {
12: _session.Clear();
13: }
14:
15: public IDbConnection Close()
16: {
17: _session.Clear();
18: return null;
19: }
20:
21:
22:
I śmiga aż miło. Oczywiście nie zabezpiecza to przed wszystkimi możliwymi scenariuszami (np. można ręcznie dogrzebać się do połączenia poprzez obiekt sesji i je zamknąć, tylko... wiem że tego nie robię).
I to tyle. Na pewno można to ulepszyć / uprościć. Jestem bardzo ciekaw waszych opinii, zatem zachęcam do ściągnięcia kodu źródłowego i zostawiania komentarzy. Zadawajcie też pytania, dzięki nim da się ciekawie zgłębić ten temat.