Це продовження статті «Завантаження Пов'язаних Даних». Читайте послідовно.
Lazy Loading (ліниве завантаження) — навігаційні властивості завантажуються автоматично, коли до них вперше звертаються. Виглядає зручно: просто звернись до order.Customer.Name — і EF Core сам виконає SQL. Але саме ця зручність є головною пасткою.
// 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. Застосунок деградує при зростанні навантаження.
Спосіб 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:
foreach// НЕБЕЗПЕЧНО: 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 без аналізу коду — увімкніть логування 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 (явне завантаження) — ви самі вирішуєте коли і яку навігаційну властивість завантажити. Це золота середина між Eager (завжди все) і Lazy (ніколи явно) 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
// Завантажуємо колекцію
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
// Перевірити, чи вже завантажено
if (!context.Entry(order!).Reference(o => o.Customer).IsLoaded)
{
await context.Entry(order!)
.Reference(o => o.Customer)
.LoadAsync();
}
Найпотужніша форма 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();
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 Loading | Explicit Loading | |
|---|---|---|---|
| Коли виконується SQL | Разом з основним запитом | При першому зверненні | При ручному виклику .LoadAsync() |
| N+1 ризик | Відсутній | Дуже високий | Відсутній (явний контроль) |
| Налаштування | Не потрібне | UseLazyLoadingProxies() або ILazyLoader | Не потрібне |
| virtual властивості | Не потрібні | Необхідні (для проксі) | Не потрібні |
| Гнучкість | Середня | Висока (на потребу) | Висока (повний контроль) |
| Код | Декларативний | Неявний | Явний/verbose |
| Split Query | ✅ Підтримується | ❌ Не відповідає | ❌ Не відповідає |
| Filtered Include | ✅ | ❌ | ✅ (через Query()) |
| Сценарій | Eager | Lazy | Explicit |
|---|---|---|---|
| Список з пов'язаними (API) | ⭐⭐⭐ | ⭐ (N+1) | ⭐⭐ (більше коду) |
| Деталь одного об'єкту | ⭐⭐⭐ | ⭐⭐ (один об'єкт) | ⭐⭐⭐ (умовно) |
| Умовне завантаження | ⭐⭐ (зайвий JOIN) | ⭐⭐ | ⭐⭐⭐ |
| Batch processing | ⭐⭐⭐ | ⭐ (катастрофа) | ⭐⭐ |
| Прототипування | ⭐⭐ | ⭐⭐⭐ (зручніше) | ⭐ (найбільше коду) |
Чи потрібна навігаційна властивість ЗАВЖДИ?
├─ Так → Eager Loading (Include)
│ └─ Кілька колекцій → AsSplitQuery()
└─ Ні
├─ Умовно потрібна → Explicit Loading (Query().LoadAsync())
└─ Рідко потрібна
├─ Розробка/прототип → Lazy Loading (зручніше)
└─ Production → Explicit Loading або Eager + 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 об'єкт навіть якщо кілька замовлень одного клієнта
При двосторонніх навігаційних властивостях (Order → Customer і Customer → Orders) 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); // Безпечно
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)}");
}
}
Завдання 1.1: Lazy Loading vs Eager Loading порівняння
Для BlogPost (Id, Title) з Author (Id, Name) і Comments (Id, Text, PostId):
UseLazyLoadingProxies() і виконайте:var posts = await context.Posts.ToListAsync();
foreach (var post in posts) Console.WriteLine(post.Author.Name);
Include(p => p.Author):
Скільки SQL? (1)Завдання 1.2: Explicit Loading з Query()
Для Category з великою колекцією Products (1000+ записів):
Query().OrderByDescending().Take(10).LoadAsync()Include(c => c.Products) — скільки зайвих даних без явного завантаження?Завдання 1.3: AsNoTrackingWithIdentityResolution
Order INNER JOIN Customer де один клієнт має 5 замовлень:
AsNoTracking + Include — скільки Customer об'єктів у пам'яті?AsNoTrackingWithIdentityResolution + Include — скільки?Завдання 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:
Завдання 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 програматично.
Ця стаття повністю розкрила всі три стратегії завантаження пов'язаних даних:
Частина 1 — Eager Loading:
Include: JOIN навігаційної властивості разом з основним запитомThenInclude: вглиб графа від попереднього IncludeAsSplitQuery: окремі SQL замість одного великого JOINЧастина 2 — Lazy та Explicit Loading:
virtual) та ILazyLoaderReference().LoadAsync(), Collection().LoadAsync()Query() для Explicit Loading з додатковою фільтрацієюAsNoTrackingWithIdentityResolution — дедублікація без трекінгуНаступна стаття — Raw SQL, Views та Stored Procedures (стаття 18) — розкриє виконання сирого SQL через EF Core: FromSqlRaw, ExecuteSqlRaw, маппінг View, виклик SP та DbFunction.
Завантаження Пов'язаних Даних (Частина 1)
Eager Loading через Include і ThenInclude в EF Core — стратегії завантаження навігаційних властивостей, Filtered Include, Split Queries для оптимізації картезіанського вибуху, АuditableIncludes і типові помилки.
Raw SQL, Views та Stored Procedures (Частина 1)
FromSqlRaw і FromSqlInterpolated в EF Core — виконання сирого SQL зі збереженням усіх переваг ORM. Маппінг Database Views, Keyless Entity Types, параметризація запитів і захист від SQL Injection.