В слоёных приложения данные на разных уровнях зачастую представлены в виде разных объектов. Например во время получения данных из БД при помощи EF выполняются манипуляции используя доменные объекты. Возможно там же результат будет оформлен в виде DTO или модели команды уровня бизнес-логики. Далее на уровне WEB API эти данные будут преобразованы в ответ сервера (Response). Такое поведение оправдано и служит для разделения ответственности. Однако печаль-беда в том, что зачастую данные между моделями разных уровней передаются при помощи копирования. Иногда вручную, иногда используя AutoMapper, Mapster или Mapperly, для удобства. Инструменты безусловно шикарные, однако сам подход copy\paste, как известно, не лишён недостатков. Возможно имеется альтернативное решение? Конечно, и не одно.

В самом простейшем случае можно прибегнуть к помощи полиморфизма, т.е. чтобы сценарий использования (UseCase, Command pattern) в качестве параметров принимал не конкретную модель, а абстракцию:

/// <summary> Модель запроса к WEB API и модель аргументов команды одновременно/summary>
public sealed record CreateTodoListRequest(string Title) : ICreateTodoListCommandArgs;

/// <summary> Абстракция аргументов команды "Создать список дел"</summary>
public interface ICreateTodoListCommandArgs
{
    string Title { get; }
}

/// <summary> Ообработчик команды </summary>
public class CreateTodoListCommandHandler : IHandler<ICreateTodoListCommandArgs, int>
{
  public Task<int> Handle(ICreateTodoListCommandArgs request, CancellationToken cancellationToken)
  {
  }
}

Однако у этого подхода есть недостатки.Во первых с популярной нынче библиотекой MediatR он не работает. Всё потому, что поиск обработчика осуществляется на основании экземпляра запроса. Не хотелось бы в это углубляться, так что если есть желание, можете «помедитировать» над кодом request.GetType() метода Send класса Mediator.

Во вторых потому, что реализовать адекватный способ передачи типа результата команды из вызывающего метода тоже не получиться. Да, конечно, мы можем задействовать универсальные методы:

/// <summary> Абстракция обработчика</summary>
public interface IHandler<in TArgs, in TResultAbstraction>
{
    Task<TResult> Handle<TResult>(TArgs args, CancellationToken cancellationToken)
        where TResult : TResultAbstraction, new();
}

Однако если TResult будет составным, то необходимо будет указывать каждый тип в иерархии и условия отношений:

public interface IGetTodosQueryHandler
{
    Task<TResult> Handle<TResult, TPriorityLevelItem, TListItem, TTodoItem>(CancellationToken cancellationToken)
        where TResult : IGetTodosQueryResult<TPriorityLevelItem, TListItem, TTodoItem>, new()
        where TPriorityLevelItem : ILookup, new()
        where TListItem : ITodoList<TTodoItem>, new()
        where TTodoItem : ITodoItem, new();
}

А такая реализация, согласитесь, уже не выглядит адекватной.

Хорошо, раз с интерфейсами не сложилось, тогда можно применить другой подход — агрегацию:

/// <summary>Domain Entity </summary>
public sealed class Person
{
    public int Id { get; set; }
    public string FirstName { get; set; } = null!;
    // ...
}

/// <summary>WEB Response создаваемый путём агрегации</summary>
/// <param name="person">источник данных</param>
public sealed class PersonResponseWrapper(Person person)
{
    public int Id => person.Id;
    public string FirstName => person.FirstName;
    // ...
}

Да-да, вы всё правильно поняли — приёмник данных реализован в виде обёртки над источником. Таким образом для каждого слоя будет своя модель, но без копирования. В конечном счёте это должно немного уменьшить потребление памяти, а возможно и быстродействие…
В теории, а что там на практике?

Экспериментировать буду вот на такой модели:

/// <summary>Domain Entity </summary>
public sealed class Person
{
    public int Id { get; set; }
    public string FirstName { get; set; } = null!;
    public string LastName { get; set; } = null!;
    public DateOnly? Birthdate { get; set; }
    public bool? Gender { get; set; } // 0 = Female, 1 = Male
    public string Email { get; set; } = null!;
    public int Score { get; set; }
}

Исходники на GitHub.

Для начала посмотрим насколько отличается копирование от «обёртывания»:

Method

Mean

Ratio

Gen0

Allocated

Alloc Ratio

Copy

10.6343 ns

2.141

0.0306

64 B

2.67

Wrapper

4.9718 ns

1.000

0.0115

24 B

1.00

StructWrapper

0.0000 ns

0.000

0.00

StructWrapperWithBoxing

4.5731 ns

0.909

0.0115

24 B

1.00


Примерно в 2 раза по скорости и в 2,5 раза по памяти.
Любопытная картина, если создать обёртку в виде структуры — это практически ничего не стОит. Однако скорее всего, в процессе передачи в другие методы такая обёртка будет упакована в объект (Boxing), что приведёт к затратам сравнимым с использованием объекта.

Хорошо, теперь сравним преобразование множества объектов — исходную коллекцию можно преобразовать в список (List), массив (Array) и перечислимое (IEnumerable). Источник содержит 25 элементов:

Method

Mean

Ratio

Gen0

Allocated

Alloc Ratio

Copy_ToList

482.0796 ns

6.46

0.9289

1944 B

40.50

Wrapper_ToList

382.8510 ns

5.13

0.4511

944 B

19.67

StructWrapper_ToList

199.0918 ns

2.67

0.1643

344 B

7.17

Copy_ToArray

405.2606 ns

5.43

0.9103

1904 B

39.67

Wrapper_ToArray

306.1110 ns

4.10

0.4320

904 B

18.83

StructWrapper_ToArray

149.4493 ns

2.00

0.1452

304 B

6.33

Copy_Enumerable

283.4875 ns

3.81

0.7877

1648 B

34.33

Wrapper_Enumerable

206.3474 ns

2.76

0.3097

648 B

13.50

StructWrapper_Enumerable

74.6366 ns

1.00

0.0229

48 B

1.00

Занимательно не так ли?
Использование структуры в качестве обёртки в сочетании с использованием перечислимого примерно в 6,5 раз быстрее и в 40 раз эффективнее по памяти. Для 100 элементов превосходство по памяти уже в 153 раза. Вот она — сила перечислимого!
И ещё массивы всегда чуть-чуть лучше списков по ресурсам. Совсем чуть-чуть, но лучше. Поэтому если не планируется добавлять элементы, то ToArray предпочтительнее ToList.

Ладно теперь добавим немного реализма — будем сериализовать множество при помощи System.Text.Json.JsonSerializer. Это как раз то, чем будет заниматься WEB API:

Method

Mean

Ratio

Allocated

Alloc Ratio

Copy_ToList_ToJson

9,558.8074 ns

1.04

2432 B

2.14

Wrapper_ToList_ToJson

10,192.7840 ns

1.11

1432 B

1.26

StructWrapper_ToList_ToJson

9,313.8018 ns

1.01

1432 B

1.26

Copy_ToArray_ToJson

9,463.7112 ns

1.03

2392 B

2.11

Wrapper_ToArray_ToJson

9,848.7048 ns

1.06

1392 B

1.23

StructWrapper_ToArray_ToJson

9,358.4291 ns

1.02

1392 B

1.23

Copy_Enumerable_ToJson

9,784.4669 ns

1.07

2136 B

1.88

Wrapper_Enumerable_ToJson

9,422.7165 ns

1.03

1136 B

1.00

StructWrapper_Enumerable_ToJson

9,186.8979 ns

1.00

1136 B

1.00

Хоба! Тут цифры совсем не такие внушительные. Причём частенько бывало так, что массив или список отрабатывали даже на 1-2% быстрее перечислимого. Но по памяти использование обёртки и перечислимого всегда давало лучший результат. С увеличением объёма данных отрыв также увеличивался.

Наверняка найдутся и те, кто скажет «да тут выигрыш в производительности на уровне погрешности, и памяти на 1 килобайт в 0-м поколении — оно того не стоит». Да, согласен, выигрыш небольшой, но он есть и достаётся нам совершенно бесплатно, потому что ни трудозатраты, ни сложность кода не увеличиваются.

А кстати, что там по части сторонних Mapper’ов? Они то как раз немного сокращают трудозатраты. Что ж — давайте посмотрим:

Method

Mean

Ratio

Allocated

Alloc Ratio

Copy_ToArray_ToJson_Mapperly

10,297.55 ns

1.08

2392 B

2.11

Copy_ToArray_ToJson_Mapster

10,333.08 ns

1.06

2392 B

2.11

Copy_ToArray_ToJson_Automapper

11,478.28 ns

1.18

2456 B

2.16

Copy_Enumerable_ToJson_Mapperly

9,663.39 ns

0.99

2136 B

1.88

Copy_Enumerable_ToJson_Mapster

10,063.57 ns

1.03

2136 B

1.88

Copy_Enumerable_ToJson_Automapper

11,842.87 ns

1.21

2200 B

1.94

Wrapper_Enumerable_ToJson_2

9,720.05 ns

1.00

1136 B

1.00

Ну да, ну да — за комфорт нужно платить :-)

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


  1. lair
    30.11.2023 14:08

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

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


    1. KonstantinOgorodov Автор
      30.11.2023 14:08

      Имелось ввиду по сравнению с ручным копированием.


      1. lair
        30.11.2023 14:08

        Мне казалось, ручное копирование применяется сравнительно редко именно в силу высокой стоимости поддержки, нет?


        1. KonstantinOgorodov Автор
          30.11.2023 14:08

          Безусловно, ручное копирование это наихудший вариант.
          Если модельки имеют одноимённые свойства, то использование сторонних mapper'ов выглядит привлекательным, особенно с кодогенерацией.
          Но если mapper'у приходится помогать с конфигурацией, то уже не всё так однозначно.


        1. Gromilo
          30.11.2023 14:08

          А какие есть варианты лучше, именно с точки зрения поддержки?


          1. lair
            30.11.2023 14:08
            -2

            Автомаппинг.


            1. Gromilo
              30.11.2023 14:08
              +2

              У меня довольно мало случаев, когда маппинг 1 к 1: либо названия меняются, либо нужно преобразование с подтягиванием каких-то данных делать. Чисто теоретически, сложилось ощущение, то автомаппинг настраивать надо и это как-то не сильно выгоднее, чем присвоить поле руками.

              В каких реальных кейсах помогает автомапинг? есть примеры кода?

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


              1. lair
                30.11.2023 14:08

                В каких реальных кейсах помогает автомапинг?

                В тех, где большая часть имен совпадает (это именно тогда, когда я его использую).

                Еще автомаппинг помогает тогда, когда есть одинаковые правила преобразования (например, один и тот же тип в разных местах иерархии преобразуется в один и тот же тип в целевой иерархии).

                есть примеры кода?

                Нет, это то, что я делал по работе, следовательно, под NDA. А если переписать для примера, оно будет неотличимо от любого примера, который уже есть в интернете.


    1. Gromilo
      30.11.2023 14:08
      +1

      Вместо перемапливания создаём класс или структуру суть которой сводится к public int Id => person.Id;. Я так даже делал когда генерировал отчёт и обёртка уходила в генератор экселины.

      А бесплатность была про то, что код маппинга всё равно писать и без разницы будет это обёртка или копирование в отдельный объект.

      Я же главную проблему с обёртками вижу в связи обёртки с оборачиваемым типом. В ситуации, когда зависимая библиотека выставляет контракт и ничего не знает про наши типы, обёртку в неё можно будет передать только как наследника интерфейса. В дикой природе такое не встречается и обычно либы принимают какие-то свои ДТОхи.


      1. Didntread
        30.11.2023 14:08
        +1

        обычно либы принимают какие-то свои ДТО

        в теории можно заэкстендить dto, добавить ссылку на обьект, заоверрайдить геттеры


        1. Gromilo
          30.11.2023 14:08
          +1

          Кажись нет: не получится оверрайдить не виртуальные функции и после каста к исходной дто, значения будут читаться из исходных полей.


        1. lair
          30.11.2023 14:08

          Если DTO и геттер - не sealed.


          1. Gromilo
            30.11.2023 14:08
            +1

            Вот пример где DTO и геттер - не sealed. Один фиг не получится поле заоверрайдить, если оно не виртуальное.

            Если не прав, жду ссылку на код.


            1. lair
              30.11.2023 14:08

              Я же не говорю, что если не sealed, то получится. sealed - не единственный способ это сломать, просто самый наглядный. Ну и да, на самом деле, разницы между не-virtual-геттером, и геттером override sealed с этой точки зрения никакой (оба нельзя заоверрайдить).


      1. KonstantinOgorodov Автор
        30.11.2023 14:08
        +1

        Всё верно.

        Вообще идею с интерфейсами я подсмотрел у дяди Боба в схеме чистой архитектуры. Там помимо "большой круглой штуки" в центре есть схема справа снизу, на которой как раз сценарий использования (Use Case) и 2 интерфейса к нему: Use Case Input Port и Use Case Output Port.

        Use Case Input Port позволяет абстрагироваться от типа входных параметров. Это позволяет использовать модель запроса WEB API (RequestDto) в качестве также и в качестве параметров сценария использования. Не работает с MediatR, но прекрасно работает при инъектировании сценария использования в контроллер через DI и вызова обработчика напрямую. Такой подход и интерфейс сценария использования ( IQueryHandler I CommandHandler ) описывал Максим Аршинов в статье "Быстрорастворимое проектирование".
        Use Case Output Port позволяет скрыть детали реализации результата сценария использования от вызывающего кода.

        Однако если взять описываемый вами случай, т.е. стороннюю библиотеку принимающую конкретный DTO в качестве параметра метода, то да - тут без вариантов.



      1. lair
        30.11.2023 14:08

        А бесплатность была про то, что код маппинга всё равно писать и без разницы будет это обёртка или копирование в отдельный объект.

        Я обычно не пишу код маппинга, для этого инструменты есть.

        Я же главную проблему с обёртками вижу в связи обёртки с оборачиваемым типом.

        Это да, это крупная проблема.


  1. Gromilo
    30.11.2023 14:08
    +2

    Я люблю использовать обёртки структуры над элементарными типам. Например, если в бд есть число наличия товара и 0 - обозначает "под заказ", то обёртка "доступное количество" будет содержать метод для проверки под заказ ли этот товар, чтобы по коду не расползалисьavailableQuantity == 0. Ну или ноль в специальной константе находится, но сути это не меняет. Т.е. знание о том, что значит 0 находится ровно в одном месте и это хорошо.

    На самое интересное, что структура над интом в результате компиляции исчезает из ассемблерного кода: по функциям путешествует обычный инт, а функции инлайнятся. Прямо zero cost abstraction.


    1. KonstantinOgorodov Автор
      30.11.2023 14:08

      В EF Core 8 как раз разрешили использовать структуру в качестве ComplexType (ValueObject).