Entity Framework – SQL, projekcje i pomocny AutoMapper

Korzystanie z bibliotek typu ORM niewątpliwie oszczędza nam wielu godzin poświęconych na pisaniu, over and over, bardzo podobnego kodu – komunikacji z silnikiem bazy danych. Taka wygoda potrafi jednak bardzo rozleniwić i czasem zapominamy o tym, by pilnować tego ułatwiacza i siebie również. Korzystamy z wygodnego dostępu do zdefiniowanych Data Modeli wyciągając z bazy co chcemy, kiedy chcemy… Często nawet za dużo i za często.

Pokazywałem Wam niedawno metodę wyciągającą z bazy danych listę filmów w celu przesłania jej (już jako proste obiekty DTO) do kontrolera i widoku. Przypomnę – tak wyglądała:

public IList<MovieDto> GetAllMovies()
{
    var config = new MapperConfiguration(cfg => cfg.CreateMap<Movie, MovieDto>()
    .ForMember(dest => dest.MovieGenres, m => m.MapFrom(src => src.MovieGenres.Select(g=>g.Genre.Name).ToList()))
    );
    var mapper = config.CreateMapper();
    List<Movie> movies = appContext.Movies.ToList();
    List<MovieDto> movieDtos = mapper.Map<List<MovieDto>>(movies);
    return movieDtos;
}

Jeśli jednak chwilkę się zastanowimy i dodatkowo wspomożemy dodatkowym narzędziem – okaże się, że co najmniej dwie rzeczy nie działają tutaj za dobrze. Pierwszą rzecz zobaczymy, gdy uruchomimy SqlProfilera i prześledzimy co się dzieje podczas wykonywania instrukcji appContext.Movies.ToList(). Zobaczmy najpierw:

SELECT
[Extent1].[ID] AS [ID],
[Extent1].[Title] AS [Title],
[Extent1].[ProductionYear] AS [ProductionYear]
FROM [dbo].[Movie] AS [Extent1]

a potem:

exec sp_executesql N'SELECT
[Extent1].[ID] AS [ID],
[Extent1].[MovieID] AS [MovieID],
[Extent1].[GenreID] AS [GenreID]
FROM [dbo].[MovieGenre] AS [Extent1]
WHERE [Extent1].[MovieID] = @EntityKeyValue1',N'@EntityKeyValue1 int',@EntityKeyValue1=3

exec sp_executesql N'SELECT
[Extent1].[ID] AS [ID],
[Extent1].[MovieID] AS [MovieID],
[Extent1].[GenreID] AS [GenreID]
FROM [dbo].[MovieGenre] AS [Extent1]
WHERE [Extent1].[MovieID] = @EntityKeyValue1',N'@EntityKeyValue1 int',@EntityKeyValue1=4

exec sp_executesql N'SELECT
[Extent1].[ID] AS [ID],
[Extent1].[MovieID] AS [MovieID],
[Extent1].[GenreID] AS [GenreID]
FROM [dbo].[MovieGenre] AS [Extent1]
WHERE [Extent1].[MovieID] = @EntityKeyValue1',N'@EntityKeyValue1 int',@EntityKeyValue1=5

i tak dalej… Dla każdego filmu w bazie wykonywane jest zapytanie o przypisane mu gatunki filmowe (i naturalnie dla znalezionych gatunków kolejne zapytanie o ich szczegóły). Dramat i tragedia w jednym! To oczywiście słynny problem ‚n+1 select’. Entity Framework najpierw pobiera dane z głównej tabeli modelu Movie, a potem dopiero dla każdego rekordu dociąga kolejne informacje. Sposobem na uniknięcie takiego zachowania jest np. użycie dyrektywy Include():

List<Movie> movies = appContext.Movies.Include("MovieGenres").ToList();

Otrzymane zapytanie będzie wtedy podobne do tego:

SELECT
[Project1].[ID] AS [ID],
[Project1].[Title] AS [Title],
[Project1].[ProductionYear] AS [ProductionYear],
[Project1].[C1] AS [C1],
[Project1].[ID1] AS [ID1],
[Project1].[MovieID] AS [MovieID],
[Project1].[GenreID] AS [GenreID]
FROM ( SELECT
[Extent1].[ID] AS [ID],
[Extent1].[Title] AS [Title],
[Extent1].[ProductionYear] AS [ProductionYear],
[Extent2].[ID] AS [ID1],
[Extent2].[MovieID] AS [MovieID],
[Extent2].[GenreID] AS [GenreID],
CASE WHEN ([Extent2].[ID] IS NULL) THEN CAST(NULL AS int) ELSE 1 END AS [C1]
FROM  [dbo].[Movie] AS [Extent1]
LEFT OUTER JOIN [dbo].[MovieGenre] AS [Extent2] ON [Extent1].[ID] = [Extent2].[MovieID]
)  AS [Project1]
ORDER BY [Project1].[ID] ASC, [Project1].[C1] ASC

Wygląda dużo lepiej – no i jest JEDNO.

Ale to nie wszystko, co moglibyśmy ulepszyć. Jedną z przyczyn, dla których z MovieService wysyłamy nie listę obiektów typu Movie, lecz MovieDto jest oszczędność. Nie chcemy wysyłać wszystkich informacji zawartych w obiekcie Movie, a tylko te, które są potrzebne w widoku. Po co więc pobierać wszystkie informacje z bazy danych? No właśnie!

Automapper, z którego korzystam pięknie przemapuje nam obiekty wyciągnięte z bazy danych (Movie) na docelowe Dto (MovieDto) – a to nie to czego oczekujemy. Na szczęście autor Automappera wzbogacił go o możliwość wykonywania projekcji danych pobieranych z bazy do zadanego typu obiektu. Musimy jedynie określić mapowanie obiektu bazy danych na nasze Dto i wskazać MovieDto podczas pobierania danych z bazy:

var config = new MapperConfiguration(cfg => cfg.CreateMap<Movie, MovieDto>()
.ForMember(dest => dest.MovieGenres, m => m.MapFrom(src => src.MovieGenres.Select(g => g.Genre.Name).ToList()))
);
var mapper = config.CreateMapper();
List<MovieDto> movieDtos = appContext.Movies.ProjectTo<MovieDto>(config).ToList();

Bardzo ładnie:) Otrzymamy dokładnie te dane, które chcemy przesłać dalej, a zapytanie SQL będzie wyglądało tak:

SELECT
[Project1].[ID] AS [ID],
[Project1].[Title] AS [Title],
[Project1].[ProductionYear] AS [ProductionYear],
[Project1].[C1] AS [C1],
[Project1].[Name] AS [Name]
FROM ( SELECT
    [Extent1].[ID] AS [ID],
    [Extent1].[Title] AS [Title],
    [Extent1].[ProductionYear] AS [ProductionYear],
    [Join1].[Name] AS [Name],
    CASE WHEN ([Join1].[MovieID] IS NULL) THEN CAST(NULL AS int) ELSE 1 END AS [C1]
    FROM  [dbo].[Movie] AS [Extent1]
    LEFT OUTER JOIN  (SELECT [Extent2].[MovieID] AS [MovieID], [Extent3].[Name] AS [Name]
    FROM  [dbo].[MovieGenre] AS [Extent2]
    INNER JOIN [dbo].[Genre] AS [Extent3] ON [Extent2].[GenreID] = [Extent3].[ID] ) AS [Join1] ON [Extent1].[ID] = [Join1].[MovieID]
    )  AS [Project1]
ORDER BY [Project1].[ID] ASC, [Project1].[C1] ASC

Podczas pracy nad nowym projektem zdarza się (przynajmniej mi:) zapominać o pilnowaniu wydajności – wszak danych w bazie mało, więc i tak wszystko wykonuje się wystarczająco szybko. Wcześniej czy później ugryzie nas to jednak w tyłek – warto się przed tym uchronić:)

Marcin

One Comments

  • Dawid K Maj 12, 2016 at 12:05 am

    Takie rzeczy warto robić generycznie, aby każdy nowy persistence model był automatycznie mapowany na odpowiednie dto.

    Reply

Dodaj komentarz