Загрузка связанных данных

166 Исходный проект

До сих пор мы загружали данные только из одной таблицы базы данных. Но в реальном приложении, скорее всего будут использоваться несколько таблиц с отношениями (связями) между друг другом. В примере нашего приложения существуют две связанные таблицы – Customers и Orders, первая содержит данные покупателей, а вторая их заказы. Возможно нам нужно загрузить данные какого-то покупателя и все связанные с ним заказы.

В Entity Framework существует три подхода для загрузки связанных данных: “отложенная загрузка” (lazy loading), “прямая загрузка” (eager loading) и “явная загрузка” (explicit loading). С помощью этих подходов обеспечивается одинаковая загрузка данных, но при этом они влияют на производительность приложения. В последующих разделах мы опишем каждый из этих подходов.

Отложенная загрузка (lazy loading)

Отложенная загрузка (lazy loading) заключается в том, что Entity Framework автоматически загружает данные, при этом не загружая связанные данные. Когда потребуются связанные данные Entity Framework создаст еще один запрос к базе данных. В контексте нашего примера это означает, что вы можете, например, загрузить первого заказчика из таблицы Customers и сохранить его в переменной customer. Затем вам может понадобиться узнать, какие заказы связаны с этим покупателем. Напомню, в классе модели Customer у нас определено навигационное свойство Orders. Если вы обратитесь к этому свойству (customer.Orders), то Entity Framework отправит запрос в базу данных на извлечение всех связанных с этим покупателем заказов.

Entity Framework применяет отложенную загрузку, используя динамические прокси-объекты. Вот как это работает. Когда Entity Framework возвращает результаты запроса, он создает экземпляры ваших классов и заполняет их данными, которые были возвращены из базы данных. Entity Framework имеет возможность динамически создавать новый тип во время выполнения, производный от вашего класса модели POCO. Этот новый класс выступает в качестве прокси-объекта для вашего класса POCO и называется динамическим прокси-объектом. Он будет переопределять навигационные свойства вашего класса POCO и включать в себя некоторую дополнительную логику для извлечения данных из базы данных, когда вызывается навигационное свойство. Т.к. динамический прокси-класс является производным от вашего класса POCO, ваше приложение работает непосредственно с классом POCO и не должно знать, что за кулисами создается динамический прокси-объект во время выполнения.

DbContext имеет настройку конфигурации для отложенной загрузки с помощью свойства DbContext.Configuration.LazyLoadingEnabled. Этот параметр включен по умолчанию, поэтому если вы не изменяли значение по умолчанию для него, динамический прокси-объект будет выполнять отложенную загрузку.

Для того, чтобы использовать динамические прокси-объекты, и, следовательно, отложенную загрузку, есть пара условий, которым должен соответствовать ваш класс модели. Если эти условия не выполняются, Entity Framework, не будет создавать динамические прокси-объекты для класса и будет просто возвращать экземпляры вашего класса POCO, которые не могут выполнять отложенную загрузку, а следовательно, взаимодействовать с базой данных:

Прежде чем изменять класс модели, давайте посмотрим на поведение приложения без отложенной загрузки. Добавьте новый метод LazyLoading() в ваш класс программы, который имеет следующий код:

public static void LazyLoading()
{
    SampleContext context = new SampleContext();

    // Загрузить одного покупателя
    Customer customer = context.Customers
        .Where(c => c.CustomerId == 2)
        .FirstOrDefault();

    // Попытаться загрузить связанные с ним заказы
    if (customer != null && customer.Orders != null)
        foreach (Order order in customer.Orders)
            Console.WriteLine(order.ProductName);
}

Если вы теперь вызовите этот метод в главном методе Main(), то никакие связанные данные не будут загружаться (т.к. мы еще не изучили как вставлять данные с помощью EF, вам нужно будет вручную вставить какие-нибудь данные в таблицу Orders с помощью Visual Studio или SQL Server Management Studio, для тестирования последующих примеров). В этом примере не происходит отложенная загрузка, т.к. наш класс модели Customer не подходит под второе условие – навигационное свойство Customer.Orders не является виртуальным. Давайте изменим это:

public class Customer
{
     // ...

     // Ссылка на заказы
     public virtual List<Order> Orders { get; set; }
}

Теперь, при запуске приложения Entity Framework создаст динамически прокси-объект для класса Customer и извлечет данные заказов из базы при их запросе. На рисунке ниже наглядно показано какие данные загружает Entity Framework в этом примере:

Загрузка всех данных из зависимой таблицы с помощью Entity Framework

Отложенная загрузка является очень простой в использовании, потому что используется автоматически. При этом, она является довольно опасной в плане производительности! Что если мы захотим извлечь сначала всех пользователей используя не отложенный запрос, а затем захотим извлечь все заказы, связанные с этими пользователями:

public static void LazyLoading()
{
    SampleContext context = new SampleContext();

    context.Database.Log = (s => System.Diagnostics.Debug.WriteLine(s));

    // Загрузить всех покупателей
    List<Customer> customers = context.Customers
         .ToList();      // +1 запрос к базе

    // ... какой-то код работы с данными покупателей

    // Загрузить все их заказы
    List<Order> orders = customers.SelectMany(c => c.Orders)
         .ToList();      // +3 запроса к базе данных
}

В этом примере создается три запроса, при попытке извлечь все заказы, связанные с заказчиками в коллекции customers. Мы даже включили средство протоколирования в этом запросе, чтобы вы убедились сами. Количество запросов SELECT при извлечении данных из таблицы Orders зависит от количества покупателей в коллекции customers – Entity Framework будет отправлять один запрос на выборку заказов для каждого покупателя. Очевидно, что такой подход является катастрофическим в плане производительности, если в коллекции будет храниться большое число покупателей.

Прямая загрузка (eager loading)

Прямая загрузка данных (eager loading) позволяет указать в запросе какие связанные данные нужно загрузить при выполнении запроса. Благодаря этому, когда в коде вы будете ссылаться на связанную таблицу через навигационное свойство, SQL-запрос не будет направляться в базу данных, т.к. связанные данные уже будут загружены при первом запросе. В Entity Framework для этих целей используется метод Include(), которому передается делегат, в котором можно указать навигационное свойство, по которому данные должны загружаться при первом запросе. Этот метод является расширяющим для IQueryable. В примере ниже мы добавили метод EagerLoading(), в котором исправили предыдущий пример, используя прямую загрузку:

public static void EagerLoading()
{
    SampleContext context = new SampleContext();

    context.Database.Log = (s => System.Diagnostics.Debug.WriteLine(s));

    // Загрузить всех покупателей и связанные с ними заказы
    List<Customer> customers = context.Customers
        .Include(c => c.Orders)
        .ToList();      // +1 запрос к базе

    // ... какой-то код работы с данными покупателей

    // Получить все их заказы
    List<Order> orders = customers.SelectMany(c => c.Orders)
        // Запрос к базе данных не выполняется,
        // т.к. данные уже были извлечены 
        // ранее с помощью прямой загрузки
        .ToList();
}

В этом примере базе данных будет отправляться всего один запрос при инициализации коллекции customers.

Метод Include() можно использовать для загрузки нескольких связанных таблиц. Этот метод возвращает тип IQueryable<TEntity>, где TEntity это базовый тип, на коллекции которого он был вызван, поэтому можно использовать цепочку вызовов Include. Давайте изменим модель, добавив два новых сущностных класса:

public class Customer
{
     // ...
     
     public Profile Profile { get; set; }
}

public class Order
{
    // ...
    
    public List<OrderLines> Lines { get; set; }
}

public class Profile
{
    [Key]
    [ForeignKey("CustomerOf")]
    public int CustomerId { get; set; }
    public DateTime RegistrationDate { get; set; }
    public DateTime LastLoginDate { get; set; }

    public Customer CustomerOf { get; set; }
}

public class OrderLines
{
    public int OrderLinesId { get; set; }
    public string Address { get; set; }
}

Класс Profile связан с классом Customer связью один-к-одному, а класс OrderLine связан с классом Order связью один-ко-многим. Обратите внимание, что мы не использовали виртуальные навигационные свойства, т.к. при прямой загрузке делать это необязательно. Для загрузки связанных данных из таблицы Profiles нужно будет использовать отдельный вызов метода Include(), а для загрузки связанных данных из OrderLines нужно указать метод Select() со ссылкой на этот класс, при вызове метод Include() для Orders (т.к. OrderLines напрямую не связан с Customer):

context.Customers
       .Include(c => c.Profile)
       .Include(c => c.Orders.Select(o => o.Lines))
       .ToList();

В этом примере будут загружены все связанные данные для всех пользователей. Стоит напомнить, когда мы изменяем модель и используем подход Code-First базу данных нужно будет воссоздать с использованием инициализатора модели или удалив ее вручную.

Т.к. метод Include() возвращает тип IQueryable<TEntity>, мы можем использовать цепочку вызовов методов LINQ как обычно. Например, следующий запрос извлекает все связанные данные для покупателя с идентификатором равным 2:

Customer customer = context.Customers
    .Include(c => c.Profile)
    .Include(c => c.Orders.Select(o => o.Lines))
    .Where(c => c.CustomerId == 2)
    .FirstOrDefault();

if (customer.Orders != null)
    foreach (Order order in customer.Orders)
        Console.WriteLine(order.ProductName);

Стоит заметить, что при таком подходе к созданию запроса, обязательно возникнет проблема с производительностью приложения. Любой IT-специалист на определенном этапе работы должен ставить перед собой задачу: как увеличить быстродействие системы, как ускорить ноутбук или как улучшить отклик сервера. Вы, как разработчик, должны контролировать количество запросов к базе и объем данных, получаемых при выполнении этих запросов.

Ранее мы рассмотрели, как можно ограничить выбор столбцов в запросе. Естественно такой вопрос обязательно возникнет при выборке связанных данных, т.к. зачастую большинство данных из связанных таблиц не требуется. Для такой сложной загрузки можно не использовать методы Include(), а явно указать загрузку нужных связанных данных через вложенные анонимные объекты в запросе Select(). Допустим, нам нужно выбрать имена и фамилии всех покупателей, названия товаров, которые они заказали, а также их количество. Это можно сделать с помощью следующего запроса:

public static void EagerLoading()
{
    SampleContext context = new SampleContext();

    List<Customer> customers = context.Customers
        .Select(c => new
        {
            fname = c.FirstName,
            lname = c.LastName,
            orders = c.Orders.Select(o => new  // вложенный анонимный объект
            {
                pname = o.ProductName,
                quantity = o.Quantity
            })
        })
        .AsEnumerable()
        .Select(an => new Customer
        {
            // Инициализируем экземпляр Customer из анонимного объекта
            FirstName = an.fname,
            LastName = an.lname,
            Orders = an.orders.Select(o => new Order 
            {
                ProductName = o.pname,
                Quantity = o.quantity
            }).ToList()
        })
        .ToList();

    // Отобразить извлеченные данные
    foreach (Customer customer in customers)
    {
        Console.WriteLine("\nЗаказы покупателя {0} {1}: \n",
            customer.FirstName, customer.LastName);

        foreach (Order order in customer.Orders)
            Console.WriteLine("\t\t{0} - {1} шт.",
        order.ProductName, order.Quantity);
    }
}

На рисунке ниже показан результат выполнения этого запроса:

Загрузка связанных данных только из определенных столбцов

Не смотря на свою сложность, этот запрос намного производительнее, нежели если бы мы извлекали все данные из таблиц Customers и Orders. На рисунке ниже наглядно показано, какие данные извлекаются из этих таблиц с помощью этого запроса:

Пример загрузки определенных данных из связанной таблицы

Явная загрузка (explicit loading)

Последним вариантом загрузки данных в Entity Framework является явная загрузка (explicit loading) данных. Явная загрузка, как и отложенная загрузка, не приводит к загрузке всех связанных данных в первом запросе. Но при этом, в отличие от отложенной загрузки, при вызове навигационного свойства связанного класса, эта загрузка не приводит к автоматическому извлечению связанных данных, вы должны явно вызвать метод Load(), если хотите загрузить связанные данные. Такой тип загрузки может использоваться в следующих случаях:

Явная загрузка использует метод DbContext.Entry() для доступа к сущностному объекту, а не свойство типа DbSet. Объект DbEntityEntry, возвращаемый этим методом, дает вам доступ ко всей информации о сущностном типе данных. Помимо обычных свойств модели, этот объект хранит большое количество расширенных настроек сущностных объектов, а также позволяет вызвать метод Load() для вызова явной загрузки. В примере ниже мы используем метод ExplicitLoading(), в котором мы реализовали самый первый пример в этой статье, в котором мы не могли загрузить данные заказов автоматически, т.к. навигационное свойство Customer.Orders не было виртуальным:

public static void ExplicitLoading()
{
    SampleContext context = new SampleContext();

    // Загрузить одного покупателя
    Customer customer = context.Customers
        .Where(c => c.CustomerId == 2)
        .FirstOrDefault();

    // Загрузить связанные с ним заказы с помощью явной загрузки
    context.Entry(customer)
            .Collection(c => c.Orders)
            .Load();
    
    if (customer != null && customer.Orders != null)
        foreach (var order in customer.Orders)
            Console.WriteLine(order.ProductName);
}

В первой части этого примера мы загружаем данные пользователя с идентификатором, равным 2. Затем мы используем явную загрузку, чтобы указать Entity Framework о необходимости загрузить для пользователя связанные с ним заказы. Обратите внимание, что в этом примере используется метод Collection(), которому передается делегат, в котором выбирается навигационное свойство, имеющее тип коллекции. Если навигационное свойство имеет тип ссылки (например, для связи один-к-одному между таблицами), то нужно использовать метод Reference(). После этого используется метод Load() для создания запроса к базе данных.

Если вы теперь вызовете этот метод в основном методе Main() и запустите приложение, то сможете убедиться, что заказы извлекаются корректно. Стоит отметить, что в этом примере будет создано два SQL-запроса к базе данных: при извлечения данных покупателя с CustomerId = 2 и при загрузке заказов для этого покупателя.

Методы Collection() и Reference() класса DbEntityEntry возвращают экземпляры классов DbCollectionEntry и DbReferenceEntry. Эти классы содержат полезное свойство IsLoaded, которое указывает, были ли уже загружены ранее связанные данные. Это свойство позволит оптимизировать вам некоторые запросы, чтобы повторно не извлекать соответствующие данные и его можно использовать не только при явной загрузке, но и при других типах загрузок. Ниже показан соответствующий пример:

public static void ExplicitLoading()
{
    SampleContext context = new SampleContext();

    // Загрузить одного покупателя с использованием прямой загрузки
    Customer customer = context.Customers
        .Include(c => c.Orders)
        .Where(c => c.CustomerId == 2)
        .FirstOrDefault();

    // Мы можем проверить, были ли загружены ранее данные о заказах,
    // для этого покупателя, если нет, то используем явную загрузку.
    if (!context.Entry(customer)
            .Collection(c => c.Orders).IsLoaded)
    {
        context.Entry(customer)
            .Collection(c => c.Orders)
            .Load();
    }
    
    if (customer != null && customer.Orders != null)
        foreach (var order in customer.Orders)
            Console.WriteLine(order.ProductName);
}

В предыдущих примерах при использовании явной загрузки мы указывали, что нужно выбрать все заказы. Что делать, если нужно использовать LINQ-методы для ограничения этой выборки, ведь классы DbCollectionEntry и DbReferenceEntry, объекты которых возвращаются методами Collection() и Reference(), не подходят для использования LINQ-запросов? Для этих целей в этих классах определен вспомогательный метод Query(), который возвращает типизированную сущностным классом коллекцию IQueryable. В примере ниже показано использование этого метода, для ограничения заказов, если количество товаров меньше 5:

context.Entry(customer)
    .Collection(c => c.Orders)
    .Query()
    .Where(o => o.Quantity >= 5)
    .Load();

Универсальный метод загрузки данных

Очевидно, что написание каждый раз запроса (включая объявление класса контекста) для выборки данных является утомительным занятием. Мы можем определить универсальный метод выборки данных, определив его в одном месте приложения, и ссылаться на него в любой части приложения. Давайте добавим в папку Model файл Repository.cs со следующим содержимым:

using System;
using System.Linq;
using System.Collections.Generic;
using System.Data.Entity;

namespace ExamplesEF
{
    public class Repository
    {
        public static IQueryable<TEntity> Select<TEntity>()
            where TEntity : class
        {
            SampleContext context = new SampleContext();
            
            // Здесь мы можем указывать различные настройки контекста,
            // например выводить в отладчик сгенерированный SQL-код
            context.Database.Log = 
                (s => System.Diagnostics.Debug.WriteLine(s));

            // Загрузка данных с помощью универсального метода Set
            return context.Set<TEntity>();
        }
    }
}

Этот обобщенный метод Select типизируется сущностным типом из класса модели. Т.к. он является обобщенным, то для выборки объекта DbSet из класса контекста DbContext мы не можем использовать свойства. Вместо этого мы используем вспомогательный метод DbContext.Set(). В нашем универсальном методе можно определять общие настройки для обработки запросов, мы, например, добавили возможность выводить команды SQL с помощью свойства DbContext.Database.Log. После того как вы развернете приложение, достаточно будет удалить этот вызов в одном месте, чем искать везде в приложении места, где осуществляли загрузку данных из базы.

Ниже показан пример использования этого метода в методе Main() класса приложения, где мы извлекаем всех покупателей, старше 25:

static void Main()
{
     var customers = Repository.Select<Customer>()
        .Include(c => c.Orders)
        .Where(c => c.Age > 25)
        .ToList();
}

Благодаря тому, что универсальный метод Select() возвращает коллекцию IQueryable<TEntity>, мы можем создавать цепочку запросов не беспокоясь о предварительной загрузке данных (т.е. здесь используется отложенная природа выполнения запросов). Этот метод можно использовать для любого класса сущности в модели.

Пройди тесты
Лучший чат для C# программистов