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

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

  1. Загрузить все и упасть с out of memory / timeout
  2. Явно указать какие зависимости мы хотим загрузить, а какие — нет и нарушить принцип tell don't ask
  3. Загружать зависимости неявно по требованию с помощью 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, потому что опять можно обойтись без создания сущностей. Для особо тяжелых случаев существуют старые добрые хранимые процедуры и даже специальные средства СУБД.

Лайфхак


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

Комментарии (6)


  1. muhaa
    26.01.2019 10:58

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

    Что если речь идет о более сложных системах, автоматизирующих некие функции управления, производства, проектирования? Тогда ситуация с загрузкой графа из большого количества сложно связанных объектов становиться типичной.
    Здесь все выглядит иначе.
    1. Вариант «загружать все» бывает довольно хорошим решением.
    2. Сущности нужно загружать потому, что если пользоваться SQL, программисту придется писать неимоверное количество лишнего кода. Ленивая загрузка сущностей обеспечивает разработчику ощущение, что все объекты просто есть в памяти и позволяет сосредоточиться на решении задачи и писать более понятный код.
    3. На начальном этапе разработки ленивая загрузка может сильно тормозить, но когда все уже работает, легко можно перебрать код и добавить запросы, вовремя поставляющие данные алгоритму так, чтобы исключить ленивую загрузку вообще и минимизировать количество обращений к базе. Эту задачу можно решать независимо от реализации самого алгоритма.
    4. Если алгоритм вынужден до-запрашивать данные в несколько приемов при исполнении, данные могут измениться и стать не согласованными в контексте. Для этой проблемы сложно найти универсальное решение, но в каждой конкретной системе она как-то решается. Аналогичная проблема должна решаться при сохранении накопленных в контексте изменений.


  1. Dansoid
    26.01.2019 20:07
    +2

    Что делать если моя логика не транслируется в SQL?

    Использовать наш экстеншин, в котором и балки поддерживаются и рекурсивные CTE и оконные функции и кастомные агрегаты, update from, insert from. Мне ну очень редко приходилось писать сторед процедуры, все потому что библиотека заточена на написание многоэтажных сиквелов через Linq и никак не прячет от вас базу данных.


    1. marshinov Автор
      26.01.2019 20:10

      Запилили совместимость с ЕFCore?


      1. Dansoid
        26.01.2019 21:52
        +1

        Это был интересный челенж. За неделю управился. Да, читаем метаданные из IModel, что знал об EF Core, то и поддержал.
        В основном данные черпаю из их issues. Это печальное зрелище с детскими болезнями. Но, также, должен заметить пишут его складно и вдумчиво. За пару лет может и доведут до ума. Все сводится к дикой универсальности, да и чтоб in-memory для тестов работал и еще черти знает что, и пока от этого не отходят. Вот так универсальность и приводит к ограничениям и приоритетам фиксов проблем. Народ не унывает и клепает сторед процедуры, которые их же миграции превращают в фарс.


        1. marshinov Автор
          25.01.2019 22:57
          +1

          Строго говоря многие проблемы как по мне не на уровне библиотек, а в реализации LINQ. С Roslyn наверняка можно добавить специальный атрибутик для компиляции, чтобы гибче управлять тем, что за экспрешны в лямбды попадают, чтобы не в рантайме это фиксить, а компилировать сразу как надо. А совсем прекрасно было бы, если еще настроечка была, чтобы билд ломался, если лямбду не разобрать.

          Можно конечно анализатор написать, но в такие моменты появляется ощущение, что делаешь чужую работу.


          1. Dansoid
            25.01.2019 23:14

            Я с этим не согласен, все что они могут сделать в Roslyn это поддержать еще больше стейтментов в linq query (not i method chain). Плагин для рослина, разбирающий запрос, часто в принципе невозможен если части запроса разбросаны по функциям.
            Все остальное разбирается на ура (ну не совсем ;))

            Много сейчас проблем в основном с недоучетами в third party library Relinq. Ну, и мое субъективное мнение, в использовании ExpressionVisitor pattern, оно их тормозит просто неподетски.