Ostatnio pisałem o AutoMapperze, a kiedyś wcześniej o NHibernate. Dzisiaj złączę te dwa narzędzia niczym Jasia i Małgosię, Tristana i Izoldę, Lecha i Jarosława, a z ich nawzajemnego obcowania narodzi się problem, który dość łatwo przegapić.
Wróćmy do przedstawionych ostatnio, banalnych klas:
1: public class User 2: { 3: public virtual int Id { get; set; } 4: public virtual string Name { get; set; } 5: public virtual IList<Address> Addresses { get; set; } 6: 7: public User() 8: { 9: Addresses = new List<Address>(); 10: } 11: } 12: 13: public class Address 14: { 15: public virtual int Id { get; set; } 16: public virtual string Street { get; set; } 17: public virtual string City { get; set; } 18: public virtual string Country { get; set; } 19: }
Ich mapowań z wykorzystaniem Fluent NHibernate:
1: public class UserMap : ClassMap<User> 2: { 3: public UserMap() 4: { 5: Id(x => x.Id); 6: Map(x => x.Name); 7: HasMany(x => x.Addresses).Inverse().Cascade.All(); 8: } 9: } 10: 11: public class AddressMap : ClassMap<Address> 12: { 13: public AddressMap() 14: { 15: Id(x => x.Id); 16: Map(x => x.Street); 17: Map(x => x.City); 18: Map(x => x.Country); 19: } 20: }
Reprezentacji DTO:
1: [DataContract] 2: public class UserDto 3: { 4: [DataMember] 5: public int Id { get; set; } 6: [DataMember] 7: public string Name { get; set; } 8: [DataMember] 9: public AddressDto[] Addresses { get; set; } 10: } 11: 12: [DataContract] 13: public class AddressDto 14: { 15: [DataMember] 16: public int Id { get; set; } 17: [DataMember] 18: public string Street { get; set; } 19: [DataMember] 20: public string City { get; set; } 21: [DataMember] 22: public string Country { get; set; } 23: }
Oraz konfiguracji AutoMappera:
1: Mapper.CreateMap<User, UserDto>(); 2: Mapper.CreateMap<Address, AddressDto>();
Raczej nic nie powinno wymagać tu wyjaśnienia (o Fluent-NH zamierzam wkrótce napisać trochę więcej).
Teraz kontekst:
Jakiś klient żąda od nas listy wszystkich użytkowników do wyświetlenia… gdzieś tam. Potrzebuje jedynie nazwy użytkownika oraz jego ID, adresy go nie obchodzą. Zadowoleni pobieramy więc samych użytkowników bez adresów:
1: UserDto[] dtos = null; 2: using (var session = AppFacade.DataAccess.OpenSession()) 3: { 4: User[] users = session.Linq<User>().ToArray(); 5: dtos = Mapper.Map<User[], UserDto[]>(users); 6: } 7: //...
Ależ z nas optymalizatory! Nie potrzebujemy adresów, więc ich nie pobieramy! A przecież równie dobrze mogło to dotyczyć o wiele bardziej obciążających bazę i łącze danych. Za tak wyrafinowane rozwiązanie należy nam się browar. Czy aby na pewno?
Na wszelki wypadek podejrzymy wygenerowany przez NHibernate SQL:
U’la’la. Nie dość, że pobieramy z bazy adresy, to jeszcze wpadliśmy w pułapkę "select n+1". A przecież mapowanie NH nie mówiło nic o "eager loading". Oj, nie na browar zasłużyliśmy, a co najwyżej na klapsa. I to bez niespodzianki.
Wtrącenie: w teorii powinno się mieć osobny obiekt DTO dla każdej operacji… ale często można pójść na skróty i zdefiniować jeden wykorzystywany w kilku miejscach. Moim zdaniem nie ma w tym nic szczególnie zdrożnego, należy po prostu uważać i być świadomym tego co się robi. W tym przypadku poszedłem na skróty i zdefiniowałem UserDto zamiast np. UserDto i UserWithAddressesDto.
Powód takiego a nie innego zapytania jest prosty, wystarczy lekko zmodyfikować kod aby się domyślić:
1: User[] users; 2: using (var session = AppFacade.DataAccess.OpenSession()) 3: { 4: users = session.Linq<User>().ToArray(); 5: } 6: UserDto[] dtos = Mapper.Map<User[], UserDto[]>(users);
Takie coś będzie skutkowało NHibernate.LazyInitializationException w linijce 6, czyli podczas mapowania. I… prawidłowo! AutoMapper chce dobrać się do kolekcji adresów i przepisać je do DTO. NHibernate próbuje w tym momencie załadować adresy z bazy, a że jesteśmy poza sesją – wyrzuca wyjątek. Poprzednio cały czas byliśmy w obszarze aktywności sesji i adresy były leniwie pobierane dla każdego użytkownika po kolei.
Ale samo przedstawienie problemu to tylko połowa fajności pisania postów, czas na rozwiązanie.
Napisałem klasę dziedziczącą z AutoMapper.ValueResolver, która będzie próbowała dostać się do wartości źródłowej tylko i wyłącznie wtedy, gdy ta zostanie uprzednio zainicjowana przez NHibernate. W przeciwnym wypadku w miejsce docelowe wpisany zostanie null. Oto i ona:
1: public class NHibernateResolver : ValueResolver<object, object> 2: { 3: protected override object ResolveCore(object source) 4: { 5: if (NHibernateUtil.IsInitialized(source)) 6: return source; 7: return null; 8: } 9: }
Oczywiście trzeba odpowiednio poinstruować AutoMappera, aby wiedział że ma z tego mechanizmu skorzystać:
1: Mapper.CreateMap<User, UserDto>() 2: .ForMember(dest => dest.Addresses, 3: opts => opts.ResolveUsing<NHibernateResolver>().FromMember(src => src.Addresses));
I wszystko jest już jak być powinno, przyjemnie i "reużywalnie":
Wygraliśmy browar!
Tip: do testowania podobnych rzeczy wykorzystuje się klasę NHibernateUtil (zastosowaną zresztą w prezentowanym rozwiązaniu).