Хабрахабр

О сущностях, DTO, ORM и Lazy Load

Объектно-ориентированная парадигма — стандарт для прикладного ПО. Реляционные СУБД — стандарт хранения данных в прикладном ПО. Да, можно писать и на Haskell и хранить данные исключительно в ClickHouse. Но речь о мейнстриме.

Остается «маленькая» такая проблемка — эта абстракция, как и многие другие, «течет». ORM позволяет натянуть сову на глобус сделать вид, что RDBMS'а нет и данные хранятся в объектной модели, более подходящей для ООП. В момент материализации сущности мы встаем перед выбором: Там где в объектной модели ссылка на другой объект в базе данных foreign key и id.

  1. Загрузить все и упасть с out of memory / timeout
  2. Явно указать какие зависимости мы хотим загрузить, а какие — нет и нарушить принцип tell don't ask
  3. Загружать зависимости неявно по требованию с помощью Lazy Load и получить проблемы с производительностью где-то в вызываемом коде

Какую-же ногу себе отрезать: левую или правую?

Но все не так просто и есть куча нюансов.
Со временем я пришел ко мнению, что Lazy Load и/или зависимость сущностей от реализации ORM -меньшее из зол при соблюдении некоторых условий. TLDR Lazy Load не так плох, если использовать только для записи и не использовать при чтении.

В 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? 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, потому что опять можно обойтись без создания сущностей. Для особо тяжелых случаев существуют старые добрые хранимые процедуры и даже специальные средства СУБД.

Лайфхак

Если нужно реализовать и массовую операцию и обычную, нужно начинать с массовой. Обычная операция — просто частный случай массовой, кода в последовательности только один элемент.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»