NHibernate, NServiceBus i transakcje
Dziś chciałbym podzielić się z Wami moimi refleksjami na temat sposobu zarządzania transakcjami w NHibernate, ze szczególnym uwzględnieniem nietrywialnego przypadku, kiedy w ramach jednej transakcji wykorzystujemy
zarówno NHibernate, jak i NServiceBus. Posłużę się w tym celu kodem DDDSample.Net.
Aby wprowadzić Was w klimat, przypomnę jak wygląda architektura DDDSample. Począwszy od najwyższego poziomu, występują tam następujące warstwy:
- WebUI (ASP.NET MVC) - prezentacja danych, interfejs
- Application - stanowi fasadę dla modelu domeny udostępniając interfejs poszczególnych akcji/komend
- Domain - logika biznesowa żyje tutaj
- Domain.Persistence.NHibernate - mapowania NHibernate oraz implementacje repozytoriów
Która warstwa odpowiada zatem za transakcje? Oczywiście
Application. Dlaczego? Ponieważ jednostką izolacji są akcje/komendy — każda taka jednostka wykonywana jest w ramach osobnej transakcji.
Ponieważ transakcyjność postrzegam (zwykle) jako jedno z wymagań
niefunkcjonalnych, implementuje ją za pomocą
aspektów. Korzystam przy tym z możliwości mojego ulubionego kontenera Unity, jednak to samo można zrobić za pomocą niemal dowolnego innego kontenera. Moja warstwa Application składa się par (interfejs, klasa), przy czym interfejs zdefiniowany jest nie po to, aby umożliwić zmianę implementacji, ale tylko i
wyłącznie po to, aby umożliwić AOP (tak, wiem, że można to samo osiągnąć za pomocą metod wirtualnych)
1 /// <summary>
2 /// Cargo booking service.
3 /// </summary>
4 public interface IBookingService
5 {
6 /// <summary>
7 /// Registers a new cargo in the tracking system, not yet routed.
8 /// </summary>
9 /// <param name="origin">Cargo origin UN locode.</param>
10 /// <param name="destination">Cargo destination UN locode.</param>
11 /// <param name="arrivalDeadline">Arrival deadline.</param>
12 /// <returns>Cargo tracing id for referencing this cargo.</returns>
13 TrackingId BookNewCargo(UnLocode origin, UnLocode destination, DateTime arrivalDeadline);
14 //...//
15 /// <summary>
16 /// Implementation of <see cref="IBookingService"/>.
17 /// </summary>
18 public class BookingService : IBookingService
19 {
20 public TrackingId BookNewCargo(UnLocode originUnLocode, UnLocode destinationUnLocode, DateTime arrivalDeadline)
21 {
22 Location origin = _locationRepository.Find(originUnLocode);
23 Location destination = _locationRepository.Find(destinationUnLocode);
24 //...
Korzystam z tego w kodzie konfigurującym kontener DI:
1 container.Configure<Interception>()
2 //Ustaw sposób przechwytywania
3 .SetInterceptorFor<IBookingService>(new InterfaceInterceptor())
4 .SetInterceptorFor<IHandlingEventService>(new InterfaceInterceptor())
5 //Dodaj nową "politykę"
6 .AddPolicy("Transactions")
7 //Wykorzystującą aspekt obsługi transakcji
8 .AddCallHandler<TransactionCallHandler>()
9 //I podłącz do wszystkich interfejsów z assembly
10 .AddMatchingRule(new AssemblyMatchingRule("DDDSample.Application"));
Dzięki temu każde wywołanie fasad z warstwy Application automatycznie opakowywane jest w transakcje. Pozostaje jeszcze wyjaśnić, jak wygląda wspomniany aspekt. Oto kluczowy kawałek kodu:
1 public class TransactionCallHandler : ICallHandler
2 {
3 private readonly ISessionFactory _sessionFactory;
4 public TransactionCallHandler(ISessionFactory sessionFactory)
5 {
6 _sessionFactory = sessionFactory;
7 }
8 public IMethodReturn Invoke(IMethodInvocation input, GetNextHandlerDelegate getNext)
9 {
10 using (ITransaction tx = _sessionFactory.GetCurrentSession().BeginTransaction())
11 {
12 IMethodReturn result = getNext()(input, getNext);
13 if (result.Exception == null)
14 {
15 tx.Commit();
16 }
17 return result;
18 }
19 }
20 public int Order { get; set;}
21 }
Który wykorzystując API sesji kontekstowych tworzy nową transakcję, wywołuje właściwą metodę, po czym, jeśli nie wystąpił żaden wyjątek,
zatwierdza transakcję. W przypadku wyjątku transakcja pozostaje
niezatwierdzona, a wyjątek (nienaruszony) przelatuje do warstw wyższych.
Bardzo lubię ten kawałek kodu. Niestety nie sprawdza się on w moim ulubionym scenariuszu: NHibernate + NServiceBus. W takim przypadku potrzebuje transakcji rozproszonej
System.Transactions. Swego czasu
Ayende pisał o tym, że NHibernate "automagicznie" współdziała z
System.Transactions. Ostatnio jednak pojawił się w NHibernate koncept
ITransactionFactory, którego zadaniem jest (chyba?) poprawa jakości tego współdziałania.
Domyślna implementacja fabryki,
AdoNetWithDistrubtedTransactionFactory, jak sama nazwa sugeruje wspiera transakcje rozproszone. Niestety nie udało mi się sprawić, aby działała w
najprostszym scenariuszu integracji z NServiceBus. Zrezygnowałem więc z tego ficzera i powróciłem do "starego dobrego"
AdoNetTransactionFactory (który teraz trzeba sobie skonfigurować samemu). Niezbędna była jednak modyfikacja mojego
TransactionCallHandler:
8 public IMethodReturn Invoke(IMethodInvocation input, GetNextHandlerDelegate getNext)
9 {
10 IMethodReturn result;
11 SqlConnection sqlConnection = _sessionFactory.GetCurrentSession().Connection as SqlConnection;
12 using (TransactionScope tx = new TransactionScope())
13 {
14 if (sqlConnection == null)
15 {
16 throw new NotSupportedException("Only SqlConnection is supported.");
17 }
18 sqlConnection.EnlistTransaction(Transaction.Current);
19 result = getNext()(input, getNext);
20 if (result.Exception == null)
21 {
22 _sessionFactory.GetCurrentSession().Flush();
23 tx.Complete();
24 }
25 }
26 sqlConnection.EnlistTransaction(null);
27 return result;
28 }
Zamiast korzystać z API transakcji NHibernate, korzystam bezpośrednio z System.Transactions. Negatywnym skutkiem tego podejścia jest ograniczenie wspieranych baz do SQLServera 2005/2008. Niestety ADO.NET nie umożliwia niezależnego od sterownika bazy danych wpinania połączeń w transakcje rozproszone.
Pierwsze wywołanie EnlistTransaction wpina aktualne połączenie, na którym działa sesja NHibernate do transakcji rozproszonej. Drugie wywołanie (to z null-em) odłącza połączenie od transakcji. Jeśli transakcja ta nie została wcześniej zatwierdzona (tx.Complete()), zmiany są wycofywane na poziomie bazy danych.
Jest jeszcze jeden szkopuł. NHibernate domyślnie zwalnia połączenia związane z sesją najwcześniej, jak może. Jest to zachowanie optymalne z punktu widzenia wydajności, jednak w przypadku takiego zarządzania transakcjami, niepoprawne. Nie chcemy przecież, aby nasze połączenie, które podłączyliśmy do rozproszonej transakcji, zostało zamknięte. Aby temu zapobiec, musimy do konfiguracji NHibernate dodać następujący wpis:
<property name="connection.release_mode">on_close</property>
Na koniec jeszcze jedna niemiła informacja: powyższy sposób zarządzania transakcjami jest niekompatybilny z cache 2-go poziomu NHibernate. Co to oznacza? Otóż modyfikacje dokonane na danych w ramach tak zrealizowanych transakcji nie zostaną uwzględnione w cache 2-go poziomu. Nie sprawia to jednak problemu, jeśli nie modyfikujemy danych cache-owanych. Można więc próbować obejścia, polegającego na stosowaniu obu pokazanych wersji TransactionCallHandler w zależności od tego, czy transakcje rozproszone są wymagane.
Taka strategia powinna sprawdzać się dobrze, ponieważ wymaganie transakcji rozproszonych jest związane z odbieraniem / wysyłaniem komunikatów NServiceBus, a tego rodzaju akcje nie powinny modyfikować danych słownikowych (które są zwykle cache-owane).