Ef Core

Завантаження Пов'язаних Даних (Частина 2)

Lazy Loading — усі ризики, проксі і ILazyLoader, N+1 детектування. Explicit Loading для точкового контролю. Порівняльна таблиця трьох стратегій. Practical guide і architectural best practices.

Завантаження Пов'язаних Даних: Lazy Loading та Explicit Loading

Це продовження статті «Завантаження Пов'язаних Даних». Читайте послідовно.


Lazy Loading: найнебезпечніша стратегія

Lazy Loading (ліниве завантаження) — навігаційні властивості завантажуються автоматично, коли до них вперше звертаються. Виглядає зручно: просто звернись до order.Customer.Name — і EF Core сам виконає SQL. Але саме ця зручність є головною пасткою.

Чому Lazy Loading небезпечний

// Lazy Loading увімкнено
var orders = await context.Orders.ToListAsync(); // 1 SQL: SELECT * FROM Orders

foreach (var order in orders) // 100 замовлень
{
    // ТИХО виконується додатковий SQL для КОЖНОГО order!
    Console.WriteLine(order.Customer.FullName); // +100 SQL: SELECT * FROM Customers WHERE Id=?
    Console.WriteLine(order.LineItems.Count);   // +100 SQL: SELECT * FROM OrderLineItems WHERE OrderId=?
}

// Разом: 1 + 100 + 100 = 201 SQL запит
// Без Lazy Loading і без Include — InvalidOperationException або NullReferenceException
// З Lazy Loading — все «працює», але повільно. Складно виявити без логування.

Це класична N+1 проблема: 1 запит для колекції + N запитів для кожного елемента. При 1000 замовленнях — 3001 запит. При 10000 — 30001. Застосунок деградує при зростанні навантаження.

Увімкнення Lazy Loading: два способи

Спосіб 1: через пакет Microsoft.EntityFrameworkCore.Proxies

dotnet add package Microsoft.EntityFrameworkCore.Proxies
services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString)
           .UseLazyLoadingProxies(); // ← увімкнути Lazy Loading через проксі
});

Вимоги до entity: навігаційні властивості мають бути virtual:

public class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }

    // ОБОВ'ЯЗКОВО virtual для Lazy Loading через проксі
    public virtual Customer Customer { get; set; } = null!;
    public virtual ICollection<OrderLineItem> LineItems { get; set; } = new List<OrderLineItem>();
}

При завантаженні Order EF Core створює проксі-об'єкт — нащадок Order, що перевизначає virtual властивості. При першому зверненні до Customer — проксі виконує SQL.

Спосіб 2: через ILazyLoader (без проксі)

public class Order
{
    private ILazyLoader _lazyLoader = null!;
    private Customer? _customer;
    private ICollection<OrderLineItem>? _lineItems;

    // Конструктор для Lazy Loading
    public Order(ILazyLoader lazyLoader)
    {
        _lazyLoader = lazyLoader;
    }

    // Конструктор для тестів або прямого створення
    public Order() { }

    public int Id { get; set; }
    public int CustomerId { get; set; }

    // Навігаційна властивість з ручним lazy loading
    public Customer Customer
    {
        get => _lazyLoader.Load(this, ref _customer)!;
        set => _customer = value;
    }

    public ICollection<OrderLineItem> LineItems
    {
        get => _lazyLoader.Load(this, ref _lineItems)!;
        set => _lineItems = value;
    }
}

Цей підхід не вимагає virtual і дозволяє sealed-класи, але потребує більше коду.

Коли Lazy Loading прийнятний

Незважаючи на ризики, Lazy Loading може бути прийнятним у специфічних сценаріях:

  1. Консольні застосунки без HTTP — кожен запит виконується послідовно, N+1 видно у виводі
  2. Прототипи/MVPs де продуктивність не критична
  3. Навігаційні властивості, що звертаються рідко і не у циклах — one-off доступ до конкретного reference

Завжди небезпечний Lazy Loading:

  • У циклах foreach
  • У серіалізаторах (JSON серіалізація всього графа — нескінченна рекурсія)
  • У багатопотокових контекстах
  • У ASP.NET Core де DbContext — Scoped

Lazy Loading і серіалізація: нескінченна рекурсія

// НЕБЕЗПЕЧНО: JSON серіалізатор обходить весь граф
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(int id)
{
    var order = await context.Orders.FindAsync(id);
    return Ok(order); // ← Серіалізатор:
                      // order.Customer → SQL → Customer.Orders → SQL → Order.Customer → ...
                      // Нескінченна рекурсія!
}

Вирішення: завжди проєктувати у DTO (без навігаційних зворотних посилань) або налаштувати JsonIgnore.


Явне виявлення N+1

Щоб виявити N+1 без аналізу коду — увімкніть логування SQL і рахуйте однотипні запити:

// Логування у development
optionsBuilder.LogTo(Console.WriteLine, [DbLoggerCategory.Database.Command.Name],
                     LogLevel.Information);

Або через MiniProfiler:

dotnet add package MiniProfiler.AspNetCore.Mvc
dotnet add package MiniProfiler.EntityFrameworkCore
// Program.cs
services.AddMiniProfiler(options =>
{
    options.RouteBasePath = "/profiler";
}).AddEntityFramework();

MiniProfiler показує всі SQL-запити на кожен HTTP-запит — N+1 відразу видно як серія однотипних запитів.


Explicit Loading: повний контроль

Explicit Loading (явне завантаження) — ви самі вирішуєте коли і яку навігаційну властивість завантажити. Це золота середина між Eager (завжди все) і Lazy (ніколи явно) loading.

Reference Loading

// Отримали Order без навігаційних властивостей
var order = await context.Orders.FindAsync(orderId);

// Явно завантажуємо Customer
await context.Entry(order!)
             .Reference(o => o.Customer)
             .LoadAsync();

// Тепер order.Customer доступний
Console.WriteLine(order!.Customer.FullName);
// SQL: SELECT * FROM Customers WHERE Id = @customerId

Collection Loading

// Завантажуємо колекцію
await context.Entry(order!)
             .Collection(o => o.LineItems)
             .LoadAsync();

// order.LineItems тепер заповнений
foreach (var item in order!.LineItems)
{
    Console.WriteLine($"{item.ProductId}: {item.Quantity}");
}
// SQL: SELECT * FROM OrderLineItems WHERE OrderId = @orderId

IsLoaded: перевірка перед завантаженням

// Перевірити, чи вже завантажено
if (!context.Entry(order!).Reference(o => o.Customer).IsLoaded)
{
    await context.Entry(order!)
                 .Reference(o => o.Customer)
                 .LoadAsync();
}

Explicit Loading з запитом (Query)

Найпотужніша форма Explicit Loading — додаткова фільтрація або проєкція через Query():

// Завантажити лише схвалені відгуки (не всі)
await context.Entry(product!)
             .Collection(p => p.Reviews)
             .Query()
             .Where(r => r.IsApproved && r.Rating >= 4) // ← додатковий фільтр
             .OrderByDescending(r => r.CreatedAt)
             .LoadAsync();

// Завантажити кількість без завантаження об'єктів
int reviewCount = await context.Entry(product!)
                               .Collection(p => p.Reviews)
                               .Query()
                               .CountAsync();

// Завантажити з IgnoreQueryFilters (якщо є GQF)
await context.Entry(order!)
             .Collection(o => o.LineItems)
             .Query()
             .IgnoreQueryFilters()  // ← вимкнути GQF
             .LoadAsync();

Practical Explicit Loading: умовне завантаження

public class OrderService
{
    private readonly AppDbContext _context;

    public async Task<OrderDetailDto> GetOrderDetailAsync(int orderId, bool includeInvoice = false)
    {
        var order = await _context.Orders
            .Include(o => o.Customer)  // завжди потрібний
            .FirstOrDefaultAsync(o => o.Id == orderId)
            ?? throw new NotFoundException($"Order {orderId} not found");

        await _context.Entry(order)
                      .Collection(o => o.LineItems)
                      .Query()
                      .Include(li => li.Product)
                      .LoadAsync();

        // Умовно завантажуємо Invoice
        if (includeInvoice)
        {
            await _context.Entry(order)
                          .Reference(o => o.Invoice)
                          .LoadAsync();
        }

        return MapToDto(order, includeInvoice);
    }
}

Explicit Loading дозволяє пропускати непотрібні JOIN — корисно коли більшість запитів не потребують певної навігаційної властивості.


Порівняльна таблиця трьох стратегій

Технічна характеристика

Eager Loading (Include)Lazy LoadingExplicit Loading
Коли виконується SQLРазом з основним запитомПри першому зверненніПри ручному виклику .LoadAsync()
N+1 ризикВідсутнійДуже високийВідсутній (явний контроль)
НалаштуванняНе потрібнеUseLazyLoadingProxies() або ILazyLoaderНе потрібне
virtual властивостіНе потрібніНеобхідні (для проксі)Не потрібні
ГнучкістьСередняВисока (на потребу)Висока (повний контроль)
КодДекларативнийНеявнийЯвний/verbose
Split Query✅ Підтримується❌ Не відповідає❌ Не відповідає
Filtered Include✅ (через Query())

Продуктивність за сценарієм

СценарійEagerLazyExplicit
Список з пов'язаними (API)⭐⭐⭐⭐ (N+1)⭐⭐ (більше коду)
Деталь одного об'єкту⭐⭐⭐⭐⭐ (один об'єкт)⭐⭐⭐ (умовно)
Умовне завантаження⭐⭐ (зайвий JOIN)⭐⭐⭐⭐⭐
Batch processing⭐⭐⭐⭐ (катастрофа)⭐⭐
Прототипування⭐⭐⭐⭐⭐ (зручніше)⭐ (найбільше коду)

Матриця вибору

Чи потрібна навігаційна властивість ЗАВЖДИ?
├─ Так → Eager Loading (Include)
│         └─ Кілька колекцій → AsSplitQuery()
└─ Ні
   ├─ Умовно потрібна → Explicit Loading (Query().LoadAsync())
   └─ Рідко потрібна
      ├─ Розробка/прототип → Lazy Loading (зручніше)
      └─ Production → Explicit Loading або Eager + AsNoTracking

AsNoTracking і навігаційні властивості

AsNoTracking добре сполучається з Include для read-only сценаріїв:

// Read-only: список замовлень з клієнтами
var orderSummaries = await context.Orders
    .AsNoTracking()
    .Include(o => o.Customer)
    .Where(o => o.Status == OrderStatus.Pending)
    .Select(o => new OrderSummaryDto
    {
        Id           = o.Id,
        OrderNumber  = o.OrderNumber,
        CustomerName = o.Customer.FullName,
        TotalAmount  = o.TotalAmount
    })
    .ToListAsync();
// Беcтрекінговий, з JOIN, тільки потрібні поля

При AsNoTracking EF Core не дедублікує через Identity Map — кожен навігаційний об'єкт може бути продублікований. AsNoTrackingWithIdentityResolution() вирішує це:

var orders = await context.Orders
    .AsNoTrackingWithIdentityResolution() // дедублікація без трекінгу
    .Include(o => o.LineItems)
    .Include(o => o.Customer)
    .ToListAsync();
// Один Customer об'єкт навіть якщо кілька замовлень одного клієнта

Loading у Circular References

При двосторонніх навігаційних властивостях (OrderCustomer і CustomerOrders) Include може потрапити у нескінченний цикл при серіалізації. Правильне рішення — проєкція у DTO:

// НЕ ПОВЕРТАЙТЕ entity напряму з Include при двосторонніх зв'язках
var orders = await context.Orders.Include(o => o.Customer).ToListAsync();
return Ok(orders); // JSON: Order → Customer → Orders → Order → ... ∞

// ЗАВЖДИ проєктуйте у DTO
var orderDtos = await context.Orders
    .Include(o => o.Customer)
    .Select(o => new OrderDto
    {
        Id           = o.Id,
        CustomerName = o.Customer.FullName
        // Жодних навігаційних властивостей у DTO!
    })
    .ToListAsync();
return Ok(orderDtos); // Безпечно

Lazy Loading Detection: перевірка у тестах

public class LazyLoadingDetectionTests
{
    [Fact]
    public async Task EnsureNoLazyLoading_OnOrderLoad()
    {
        // Arrange: підраховуємо SQL-запити
        var sqlQueries = new List<string>();
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlite("DataSource=:memory:")
            .LogTo(sql => sqlQueries.Add(sql), [DbLoggerCategory.Database.Command.Name])
            .Options;

        using var context = new AppDbContext(options);
        await context.Database.EnsureCreatedAsync();
        // ... seed data

        sqlQueries.Clear(); // скидаємо лічильник

        // Act
        var orders = await context.Orders
            .Include(o => o.Customer)
            .Include(o => o.LineItems)
            .ToListAsync();

        // Імітуємо доступ без Lazy Loading
        foreach (var order in orders)
        {
            _ = order.Customer.FullName;        // не має генерувати SQL
            _ = order.LineItems.Count;           // не має генерувати SQL
        }

        // Assert: тільки один (або два при SplitQuery) SQL запит
        var commandQueries = sqlQueries.Where(q => q.Contains("SELECT")).ToList();
        Assert.True(commandQueries.Count <= 2, $"Expected <=2 queries, got {commandQueries.Count}:\n{string.Join("\n", commandQueries)}");
    }
}

Практичні завдання (Частина 2)

Рівень 1 — Базовий

Завдання 1.1: Lazy Loading vs Eager Loading порівняння

Для BlogPost (Id, Title) з Author (Id, Name) і Comments (Id, Text, PostId):

  1. Увімкніть UseLazyLoadingProxies() і виконайте:
    var posts = await context.Posts.ToListAsync();
    foreach (var post in posts) Console.WriteLine(post.Author.Name);
    

    Скільки SQL запитів? (10 posts → 1 + 10 = 11)
  2. Вимкніть Lazy Loading, додайте Include(p => p.Author): Скільки SQL? (1)
  3. Поясніть різницю у кількості roundtrips до БД.

Завдання 1.2: Explicit Loading з Query()

Для Category з великою колекцією Products (1000+ записів):

  1. Завантажте Category без Products
  2. Явно завантажте лише TOP 10 найдорожчих через Query().OrderByDescending().Take(10).LoadAsync()
  3. Порівняйте з Eager Loading Include(c => c.Products) — скільки зайвих даних без явного завантаження?

Завдання 1.3: AsNoTrackingWithIdentityResolution

Order INNER JOIN Customer де один клієнт має 5 замовлень:

  1. AsNoTracking + Include — скільки Customer об'єктів у пам'яті?
  2. AsNoTrackingWithIdentityResolution + Include — скільки?
  3. Чи мають однакову адресу у пам'яті (ReferenceEquals)?

Рівень 2 — Логіка

Завдання 2.1: Shopping Cart із умовним завантаженням

ShoppingCart (Id, CustomerId) → CartItems (Item, Qty, ProductId) → Product (Name, Price, Stock).

Реалізуйте сервіс:

  • GetCartAsync(cartId) → Eager Loading (Customer + CartItems + Products)
  • GetCartSummaryAsync(cartId) → тільки кількість позицій і загальна сума (без Products у пам'яті)
  • GetCartForCheckoutAsync(cartId) → CartItems + Products + перевірка Stock

Для кожного методу: який підхід (Eager/Explicit/Select-проєкція) і чому?

Завдання 2.2: N+1 детектор

Реалізуйте SqlQueryCounter — interceptor що рахує SQL команди на запит:

public class SqlQueryCounter : DbCommandInterceptor
{
    public int Count { get; private set; }
    // Increment Count при кожному CommandExecuted
}

Напишіть тест що перевіряє відсутність N+1:

  • Завантажте 10 Order з Include → Assert Count <= 2 (1 або SplitQuery)
  • Завантажте без Include → Assert Count > 2 (якщо є Lazy Loading)

Рівень 3 — Архітектура

Завдання 3.1: Include Graph Builder

Реалізуйте fluent IncludeGraphBuilder<T> що дозволяє декларативно описати які навігаційні властивості завантажувати:

var graph = IncludeGraphBuilder<Order>.Create()
    .Include(o => o.Customer)
    .Include(o => o.LineItems, items => items
        .Include(li => li.Product, product => product
            .Include(p => p.Category)))
    .Include(o => o.Invoice)
    .WithSplitQuery();

var orders = await graph.ApplyTo(context.Orders)
                        .Where(o => o.Status == OrderStatus.Pending)
                        .ToListAsync();

Реалізуйте ApplyTo(IQueryable<T>) що застосовує всі Include/ThenInclude програматично.


Підсумок статті 17

Ця стаття повністю розкрила всі три стратегії завантаження пов'язаних даних:

Частина 1 — Eager Loading:

  • Include: JOIN навігаційної властивості разом з основним запитом
  • ThenInclude: вглиб графа від попереднього Include
  • Cartesian Explosion: N колекцій Include → N×M рядків у результаті
  • AsSplitQuery: окремі SQL замість одного великого JOIN
  • Filtered Include: умова безпосередньо на навігаційній властивості
  • Автоматична дедублікація через Identity Map

Частина 2 — Lazy та Explicit Loading:

  • Lazy Loading: автоматично при зверненні → класична N+1 пастка
  • Lazy Loading через проксі (virtual) та ILazyLoader
  • Серіалізаційна нескінченна рекурсія при Lazy Loading
  • Explicit Loading: Reference().LoadAsync(), Collection().LoadAsync()
  • Query() для Explicit Loading з додатковою фільтрацією
  • AsNoTrackingWithIdentityResolution — дедублікація без трекінгу
  • Матриця вибору стратегії по сценарію
  • Тестування відсутності N+1 через SQL лічильник

Наступна стаття — Raw SQL, Views та Stored Procedures (стаття 18) — розкриє виконання сирого SQL через EF Core: FromSqlRaw, ExecuteSqlRaw, маппінг View, виклик SP та DbFunction.


Додаткові ресурси