Объектно-ориентированная парадигма — стандарт для прикладного ПО. Реляционные СУБД — стандарт хранения данных в прикладном ПО. Да, можно писать и на Haskell и хранить данные исключительно в ClickHouse. Но речь о мейнстриме.
ORM позволяет натянуть сову на глобус сделать вид, что RDBMS'а нет и данные хранятся в объектной модели, более подходящей для ООП. Остается «маленькая» такая проблемка — эта абстракция, как и многие другие, «течет». Там где в объектной модели ссылка на другой объект в базе данных foreign key и id. В момент материализации сущности мы встаем перед выбором:
- Загрузить все и упасть с out of memory / timeout
- Явно указать какие зависимости мы хотим загрузить, а какие — нет и нарушить принцип tell don't ask
- Загружать зависимости неявно по требованию с помощью Lazy Load и получить проблемы с производительностью где-то в вызываемом коде
Какую-же ногу себе отрезать: левую или правую?
TLDR Lazy Load не так плох, если использовать только для записи и не использовать при чтении. Но все не так просто и есть куча нюансов.
Со временем я пришел ко мнению, что Lazy Load и/или зависимость сущностей от реализации ORM -меньшее из зол при соблюдении некоторых условий.
В read-подсистеме всегда читать только DTO
В 90% случаев проблемы с Lazy Load возникают именно при чтении. Получаем список сущностей, пробегаемся по нему циклом и начинаем выбирать все необходимые данные. Получаем вал запросов к БД. При этом чаще всего единственное, что нужно сделать — это получить данные, сериализовать и отправить их в ответ в виде JSON. Зачем же тогда вообще загружать сущности? Нет никакой нужды добавлять эти данные в change tracker UOW, читать целиком сущность вместе с «лишними» полями. Вместо этого можно всегда писать либо Select
, либо ProjectTo
. Lazy Load не потребуется, потому что C#-код из Select
будет транслирован в SQL и выполнен на стороне БД.
Что делать если моя логика не транслируется в SQL?
Client Evaluation я рекомендую держать выключенным. Во первых, можно «помочь» и дописать поддержку необходимых функций прямо в субд. Не самый плохой вариант, если речь идет о простых вычислениях, а не бизнес-правилах. Вариант номер два: выделить интерфейс из сущности и реализовать его и в сущности и в DTO.
Например, в БД есть два поля: «цена без скидки» и «цена со скидкой». Если поле «цена со скидкой» заполнено, то используем его, если нет — то используем поле с обычной ценой. Добавим еще одно правило. При покупке 3 товаров вы платите только за 2 самых дорогих, при этом обычные скидки также учитываются.
Реализация может быть такой:
public interface IHasProductPrice
{
decimal BasePrice { get; }
decimal? SalePrice { get; }
}
public class Product: IHasProductPrice
{
// ... a lot of code
public decimal BasePrice { get; protected set;}
public decimal? SalePrice { get; protected set;}
}
public class ProductDto: IHasProductPrice
{
public decimal BasePrice { get; set;}
public decimal? SalePrice { get; set;}
}
public static class ProductCalculator
{
public static void decimal Calculate(IEnumerable<IHasProductPrice> prices)
}
Во write-подсистеме Lazy Load не так страшен
Во write-подсистеме, наоборот, довольно часто только id для записи не достаточно. Всевозможные проверки не редко заставляют читать сущность целиком, потому что объектная парадигма предполагает совмещение данных и операций над ними в рамках объекта класса и его инварианта. Если в проекте используется DDD, то операции записи/изменения должны производиться через корень агрегации, а значит только над одним объектом и его зависимостями. Большое количество запросов может возникнуть только при работе со связанными коллекциями.
Связанные коллекции в агрегатах
Если в агрегате слишком много данных, это может свидетельствовать о проблемах с проектированием. Типичные корни агрегации — корзина, заказ, посылка. Люди обычно не работают с данными из тысяч строк, поэтому загрузка всей связанной коллекции может быть не самой производительной, но не смертельной операцией. А вот если в коллекции тысячи объектов, возможно, что такого корня агрегации на самом деле нет и его придумали разработчики, потому то было очень просто это сделать с помощью подручных инструментов.
Что если в агрегате все-таки тысячи записей
Передайте DbContext
в конструктор и читайте из него только необходимые в контексте операции данные. Да, нарушаем DIP. Либо так, либо вообще не использовать агрегат в этом случае.
Массовые операции
Импорт файла на 10.000 строк отличная мишень для Lazy Load. Здесь ко всем проблемам read-подсистемы добавляются еще и тормоза ChangeTracker'а. Для массовой записи нужно использовать отдельные инструменты. Я отдаю предпочтения Batch Extensions, потому что опять можно обойтись без создания сущностей. Для особо тяжелых случаев существуют старые добрые хранимые процедуры и даже специальные средства СУБД.
Лайфхак
Если нужно реализовать и массовую операцию и обычную, нужно начинать с массовой. Обычная операция — просто частный случай массовой, кода в последовательности только один элемент.
Автор: marshinov