Wprowadzenie
Od wielu lat używam viewmodeli do przekazywania danych do widoku – niezależnie od tego, czy to aplikacja desktopowa w WPF, czy webowa w ASP.NET MVC. Do tego jeszcze Automapper do mapowania danych na viewmodele. Niestety mapowanie z wykorzystaniem metody Map obiektów z Entity Frameworka bardzo często ma swoje negatywne konsekwencje (na ogół pobieramy zbyt dużo danych do aplikacji). Jednym z rozwiązań tego problemu jest skorzystanie z metody ProjectTo z Automappera, która w większości przypadków może rozwiązać ten problem. Ale niestety ma też i swoje ograniczenia, o których dowiesz się w tym wpisie.
Problem z metodą Map
Korzystanie z metody Map bardzo często powoduje wczytanie zbyt dużej liczby danych do aplikacji. Po pierwsze, Entity Framework pobiera dane dla całego obiektu, mimo że później w widoku używamy tylko jednej czy dwóch właściwości. Po drugie, gdy wyświetlamy jeszcze dane z powiązanego obiektu, możemy doświadczyć w takiej sytuacji problemu N + 1 zapytań. Lazy Loading pobierze po prostu dane w dodatkowych zapytaniach do bazy danych. A to powoduje spadek wydajności wyświetlania widoku.
Warto zobaczyć ten problem na przykładzie. Poniżej znajdują się dwie klasy (Category oraz Product), których użyję. Obie klasy są powiązane relacją jeden do wielu (produkt znajduje się w jakiejś kategorii):
public class Category | |
{ | |
public int Id { get; set; } | |
public string Name { get; set; } | |
public string Description { get; set; } | |
public virtual ICollection<Product> Products { get; set; } | |
} |
public class Product | |
{ | |
public int Id { get; set; } | |
public string Name { get; set; } | |
public int CategoryId { get; set; } | |
public virtual Category Category { get; set; } | |
public ProductType Type { get; set; } | |
} | |
public enum ProductType | |
{ | |
Virtual, | |
Physical | |
} |
W przykładzie będziemy chcieli zmapować listę produktów na listę ProductViewModel. Sam viewmodel wygląda tak:
public class ProductViewModel | |
{ | |
public int Id { get; set; } | |
public string Name { get; set; } | |
public string Category { get; set; } | |
public string Type { get; set; } | |
} |
Warto zauważyć, że właściwość Category w viewmodelu jest typu string. Podczas mapowania będziemy chcieli do tej właściwości skopiować wartość z właściwości Name z obiektu kategorii, aby później w widoku produktu wyświetlić jej nazwę.
Zobaczmy, jak zachowa się metoda Map w tym przykładzie:
static void Main(string[] args) | |
{ | |
HibernatingRhinos.Profiler.Appender.EntityFramework.EntityFrameworkProfiler.Initialize(); | |
MapTest(); | |
} | |
static void MapTest() | |
{ | |
var config = new MapperConfiguration(cfg => { | |
cfg.CreateMap<Product, ProductViewModel>() | |
.ForMember(dest => dest.Category, opt => opt.MapFrom(src => src.Category.Name)); | |
}); | |
var mapper = config.CreateMapper(); | |
using (var db = new DataContext()) | |
{ | |
var products = mapper.Map<List<ProductViewModel>>(db.Products.ToList()); | |
} | |
} |
W metodzie MapTest w pierwszej kolejności konfiguruję i tworzę instancję Automappera. Tak jak wspomniałem, dla właściwości Category definiujemy mapowanie z właściwości Name obiektu Category. Na końcu metody pobieramy wszystkie produkty z bazy i mapujemy na listę ProductViewModel.
W przykładzie skorzystałem z profilera do Entity Framework, w którym ładnie widać, co wykonało się na bazie danych:
Widać, że w pierwszej kolejności wykonało się zapytanie zwracające listę produktów, a później dodatkowe zapytania zwracające kategorie. Warto też zaznaczyć, że w przypadku dodatkowych zapytań baza danych zwróciła wszystkie dane kategorii:
W tym przypadku moglibyśmy zredukować liczbę zapytań poprzez skorzystanie z metody Include, ale dalej w takiej sytuacji Entity Framework pobierałby wszystkie dane do aplikacji.
Projekcja w Automapperze
Jednym z rozwiązań tego problemu jest skorzystanie z projekcji, która jest wspierana przez Automapper. Użycie jej w Automapperze sprowadza się do skorzystania z metody ProjectTo zamiast Map. Widać to na kodzie poniżej:
static void Main(string[] args) | |
{ | |
HibernatingRhinos.Profiler.Appender.EntityFramework.EntityFrameworkProfiler.Initialize(); | |
ProjectToTest(); | |
} | |
static void ProjectToTest() | |
{ | |
var config = new MapperConfiguration(cfg => { | |
cfg.CreateMap<Product, ProductViewModel>() | |
.ForMember(dest => dest.Category, opt => opt.MapFrom(src => src.Category.Name)); | |
}); | |
var mapper = config.CreateMapper(); | |
using (var db = new DataContext()) | |
{ | |
var products = mapper.ProjectTo<ProductViewModel>(db.Products).ToList(); | |
} | |
} |
Główna różnica znajduje się w linijce 19, w której właśnie zamiast metody Map użyłem ProjectTo.
Tak mała zmiana kodu bardzo wpływa na to, co wykonuje się po stronie bazy danych:
W tym przypadku widać, że wszystkie dane zostały pobrane jednym zapytaniem (bez konieczności używania metody Include). Dodatkowo zapytanie zwraca tylko te dane, które znajdują się w docelowym viewmodelu. Bez pobierania zbędnych danych. A przypomnę, że w przykładzie zmieliłem tylko jedną linijkę kodu!
Jak to działa? W przypadku metody ProjectTo Automapper buduje na obiekcie IQueryable wywołanie metody Select, w której znajduje się mapowanie danych na viewmodel. W praktyce wykonuje się coś bardzo podobnego do:
var products = db.Products.Select(p => new ProductViewModel() | |
{ | |
Id = p.Id, | |
Name = p.Name, | |
Category = p.Category.Name | |
}).ToList(); |
Ograniczenia projekcji
Niestety korzystanie z metody ProjectTo ma swoje ograniczenia. Głównym ograniczeniem jest to, co daje nam provider, z którego korzystamy. Wszystko przez to, że to on wykonuje nasze mapowanie. Nie możemy więc skorzystać z większości bardziej zaawansowanych rzeczy z Automappera.
Nie możemy na przykład skorzystać z opisywanego jakiś czas temu na blogu lokalizowania enumów. Przy próbie wykonania czegoś takiego otrzymamy wyjątek. Który, jak widać niżej, nie mówi tak naprawdę, co się stało:
Informacja o tym, co jest, a co nie jest wspierane, znajduje się w dokumentacji Automappera poświęconej działaniu projekcji – http://docs.automapper.org/en/stable/Queryable-Extensions.html.
Przykład
Na githubie znajduje się projekt, którego używałem podczas pracy nad tym wpisem – https://github.com/danielplawgo/AutomapperProjection. Po pobraniu przykładu należy w app.config ustawić poprawnego connection stringa do bazy. Podczas tworzenia struktury bazy danych przykład generuje dane testowe z wykorzystaniem biblioteki Bogus.
Podsumowanie
Należy bardzo uważać podczas korzystania z Automappera w aplikacji. Bardzo łatwo jest nieświadomie wczytywać dużo danych do aplikacji podczas mapowania obiektów na viewmodele. Jednym z sposobów ograniczenia tego problemu jest zastąpienie metody Map metodą ProjectTo. Ale. jak to widać w powyższym przykładzie, nie zawsze jest to możliwe. Wtedy trzeba skorzystać z jakiegoś innego rozwiązania, które postaram się opisać w jednym w przyszłych wpisów.
Dzięki za artykuł, przyda się 🙂 Ciekawa sprawa z metodą ProjectTo – przyda się do filtrowania zapytań w wygodny sposób bez robienia tego w selectach.
Nie ma konieczności używania metody Include, ponieważ wygenerowany Select zrobi Include za nas. Osobiście jednak preferuję dodawanie Include’ów ze względu na czytelność query i dołączanych encji.
Hej, dzięki!
Co do używania Include, wszystko zależy od tego co dalej potrzebujesz zrobić z danymi? Z Entity Framework czasami trzeba uważać, aby nie pobierać za dużo danych. Nie raz widziałem jak zapytanie pobierało obiekt z 20 właściwościami do aplikacji, aby skorzystać tylko z jednej kolumny. W takiej sytuacji ProjectTo bardzo się przydaje.