Сетевой дневник одного программиста

Персональный блог Константина Огородова

Передача данных между слоями приложения

В слоёных приложения данные на разных уровнях зачастую представлены в виде разных объектов. Например во время получения данных из БД при помощи 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______Error____StdDev___Median__RatioRatioSDGen0AllocatedAlloc_Ratio
Copy10.6343 ns0.4807 ns1.3635 ns10.0801 ns2.1410.260.030664 B2.67
Wrapper4.9718 ns0.1545 ns0.2216 ns4.8973 ns1.0000.000.011524 B1.00
StructWrapper0.0000 ns0.0000 ns0.0000 ns0.0000 ns0.0000.000.00
StructWrapperWithBoxing4.5731 ns0.0808 ns0.0716 ns4.5505 ns0.9090.040.011524 B1.00

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

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

Method____Mean_______Error____StdDev____Median___RatioRatioSDGen0AllocatedAlloc_Ratio
Copy_ToList482.0796 ns4.8092 ns4.2632 ns482.4337 ns6.460.070.92891944 B40.50
Wrapper_ToList382.8510 ns5.2524 ns4.3860 ns382.7545 ns5.130.050.4511944 B19.67
StructWrapper_ToList199.0918 ns1.0528 ns0.9848 ns199.1990 ns2.670.020.1643344 B7.17
Copy_ToArray405.2606 ns7.5120 ns7.0267 ns405.3012 ns5.430.090.91031904 B39.67
Wrapper_ToArray306.1110 ns2.5366 ns2.2486 ns306.3019 ns4.100.030.4320904 B18.83
StructWrapper_ToArray149.4493 ns2.1675 ns2.0275 ns148.2788 ns2.000.030.1452304 B6.33
Copy_Enumerable283.4875 ns5.6299 ns6.4834 ns284.7359 ns3.810.090.78771648 B34.33
Wrapper_Enumerable206.3474 ns4.1268 ns9.2302 ns203.8522 ns2.760.190.3097648 B13.50
StructWrapper_Enumerable74.6366 ns0.3839 ns0.3403 ns74.5589 ns1.000.000.022948 B1.00

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

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

Method_____Mean_________Error_______StdDev_______Median____RatioRatioSDGen0AllocatedAlloc_Ratio
Copy_ToList_ToJson9,558.8074 ns43.6619 ns36.4597 ns9,560.8902 ns1.040.011.15972432 B2.14
Wrapper_ToList_ToJson10,192.7840 ns22.0680 ns18.4277 ns10,189.7202 ns1.110.000.67141432 B1.26
StructWrapper_ToList_ToJson9,313.8018 ns50.1845 ns44.4872 ns9,311.0718 ns1.010.010.67141432 B1.26
Copy_ToArray_ToJson9,463.7112 ns43.8438 ns34.2304 ns9,459.2583 ns1.030.001.12922392 B2.11
Wrapper_ToArray_ToJson9,848.7048 ns196.7889 ns393.0087 ns9,737.1506 ns1.060.040.65611392 B1.23
StructWrapper_ToArray_ToJson9,358.4291 ns102.9748 ns91.2845 ns9,333.6189 ns1.020.010.65611392 B1.23
Copy_Enumerable_ToJson9,784.4669 ns115.6057 ns108.1376 ns9,743.6325 ns1.070.011.00712136 B1.88
Wrapper_Enumerable_ToJson9,422.7165 ns46.6198 ns43.6082 ns9,418.1305 ns1.030.010.53411136 B1.00
StructWrapper_Enumerable_ToJson9,186.8979 ns43.5737 ns36.3860 ns9,174.8413 ns1.000.000.53411136 B1.00

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

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

Method____Mean_______Error_____StdDev_____Median___RatioRatioSDGen0AllocatedAlloc_Ratio
Copy_ToArray_ToJson_Mapperly10,297.55 ns198.108 ns558.768 ns10,122.572 ns1.080.081.12922392 B2.11
Copy_ToArray_ToJson_Mapster10,333.08 ns157.737 ns147.547 ns10,359.288 ns1.060.031.12922392 B2.11
Copy_ToArray_ToJson_Automapper11,478.28 ns127.511 ns113.035 ns11,467.357 ns1.180.041.15972456 B2.16
Copy_Enumerable_ToJson_Mapperly9,663.39 ns47.330 ns41.957 ns9,654.192 ns0.990.031.00712136 B1.88
Copy_Enumerable_ToJson_Mapster10,063.57 ns197.194 ns256.408 ns9,984.233 ns1.030.041.00712136 B1.88
Copy_Enumerable_ToJson_Automapper11,842.87 ns148.995 ns139.370 ns11,773.734 ns1.210.041.03762200 B1.94
Wrapper_Enumerable_ToJson_29,720.05 ns190.424 ns285.018 ns9,647.125 ns1.000.000.53411136 B1.00

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

Передача данных между слоями приложения

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

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

Пролистать наверх