В слоёных приложения данные на разных уровнях зачастую представлены в виде разных объектов. Например во время получения данных из БД при помощи 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; } }
Для начала посмотрим насколько отличается копирование от «обёртывания»:
Method | ___Mean___ | ___Error___ | _StdDev_ | __Median__ | Ratio | RatioSD | Gen0 | Allocated | Alloc_Ratio |
---|---|---|---|---|---|---|---|---|---|
Copy | 10.6343 ns | 0.4807 ns | 1.3635 ns | 10.0801 ns | 2.141 | 0.26 | 0.0306 | 64 B | 2.67 |
Wrapper | 4.9718 ns | 0.1545 ns | 0.2216 ns | 4.8973 ns | 1.000 | 0.00 | 0.0115 | 24 B | 1.00 |
StructWrapper | 0.0000 ns | 0.0000 ns | 0.0000 ns | 0.0000 ns | 0.000 | 0.00 | — | — | 0.00 |
StructWrapperWithBoxing | 4.5731 ns | 0.0808 ns | 0.0716 ns | 4.5505 ns | 0.909 | 0.04 | 0.0115 | 24 B | 1.00 |
Примерно в 2 раза по скорости и в 2,5 раза по памяти.
Любопытная картина, если создать обёртку в виде структуры — это практически ничего не стОит. Однако скорее всего, в процессе передачи в другие методы такая обёртка будет упакована в объект (Boxing), что приведёт к затратам сравнимым с использованием объекта.
Хорошо, теперь сравним преобразование множества объектов — исходную коллекцию можно преобразовать в список (List), массив (Array) и перечислимое (IEnumerable). Источник содержит 25 элементов:
Method | ____Mean____ | ___Error___ | _StdDev_ | ___Median___ | Ratio | RatioSD | Gen0 | Allocated | Alloc_Ratio |
---|---|---|---|---|---|---|---|---|---|
Copy_ToList | 482.0796 ns | 4.8092 ns | 4.2632 ns | 482.4337 ns | 6.46 | 0.07 | 0.9289 | 1944 B | 40.50 |
Wrapper_ToList | 382.8510 ns | 5.2524 ns | 4.3860 ns | 382.7545 ns | 5.13 | 0.05 | 0.4511 | 944 B | 19.67 |
StructWrapper_ToList | 199.0918 ns | 1.0528 ns | 0.9848 ns | 199.1990 ns | 2.67 | 0.02 | 0.1643 | 344 B | 7.17 |
Copy_ToArray | 405.2606 ns | 7.5120 ns | 7.0267 ns | 405.3012 ns | 5.43 | 0.09 | 0.9103 | 1904 B | 39.67 |
Wrapper_ToArray | 306.1110 ns | 2.5366 ns | 2.2486 ns | 306.3019 ns | 4.10 | 0.03 | 0.4320 | 904 B | 18.83 |
StructWrapper_ToArray | 149.4493 ns | 2.1675 ns | 2.0275 ns | 148.2788 ns | 2.00 | 0.03 | 0.1452 | 304 B | 6.33 |
Copy_Enumerable | 283.4875 ns | 5.6299 ns | 6.4834 ns | 284.7359 ns | 3.81 | 0.09 | 0.7877 | 1648 B | 34.33 |
Wrapper_Enumerable | 206.3474 ns | 4.1268 ns | 9.2302 ns | 203.8522 ns | 2.76 | 0.19 | 0.3097 | 648 B | 13.50 |
StructWrapper_Enumerable | 74.6366 ns | 0.3839 ns | 0.3403 ns | 74.5589 ns | 1.00 | 0.00 | 0.0229 | 48 B | 1.00 |
Занимательно не так ли?
Использование структуры в качестве обёртки в сочетании с использованием перечислимого примерно в 6,5 раз быстрее и в 40 раз эффективнее по памяти. Для 100 элементов превосходство по памяти уже в 153 раза. Вот она — сила перечислимого!
И ещё массивы всегда чуть-чуть лучше списков по ресурсам. Совсем чуть-чуть, но лучше. Поэтому если не планируется добавлять элементы, то ToArray предпочтительнее ToList.
Ладно теперь добавим немного реализма — будем сериализовать множество при помощи System.Text.Json.JsonSerializer. Это как раз то, чем будет заниматься WEB API:
Method | _____Mean_____ | ____Error____ | ___StdDev___ | ____Median____ | Ratio | RatioSD | Gen0 | Allocated | Alloc_Ratio |
---|---|---|---|---|---|---|---|---|---|
Copy_ToList_ToJson | 9,558.8074 ns | 43.6619 ns | 36.4597 ns | 9,560.8902 ns | 1.04 | 0.01 | 1.1597 | 2432 B | 2.14 |
Wrapper_ToList_ToJson | 10,192.7840 ns | 22.0680 ns | 18.4277 ns | 10,189.7202 ns | 1.11 | 0.00 | 0.6714 | 1432 B | 1.26 |
StructWrapper_ToList_ToJson | 9,313.8018 ns | 50.1845 ns | 44.4872 ns | 9,311.0718 ns | 1.01 | 0.01 | 0.6714 | 1432 B | 1.26 |
Copy_ToArray_ToJson | 9,463.7112 ns | 43.8438 ns | 34.2304 ns | 9,459.2583 ns | 1.03 | 0.00 | 1.1292 | 2392 B | 2.11 |
Wrapper_ToArray_ToJson | 9,848.7048 ns | 196.7889 ns | 393.0087 ns | 9,737.1506 ns | 1.06 | 0.04 | 0.6561 | 1392 B | 1.23 |
StructWrapper_ToArray_ToJson | 9,358.4291 ns | 102.9748 ns | 91.2845 ns | 9,333.6189 ns | 1.02 | 0.01 | 0.6561 | 1392 B | 1.23 |
Copy_Enumerable_ToJson | 9,784.4669 ns | 115.6057 ns | 108.1376 ns | 9,743.6325 ns | 1.07 | 0.01 | 1.0071 | 2136 B | 1.88 |
Wrapper_Enumerable_ToJson | 9,422.7165 ns | 46.6198 ns | 43.6082 ns | 9,418.1305 ns | 1.03 | 0.01 | 0.5341 | 1136 B | 1.00 |
StructWrapper_Enumerable_ToJson | 9,186.8979 ns | 43.5737 ns | 36.3860 ns | 9,174.8413 ns | 1.00 | 0.00 | 0.5341 | 1136 B | 1.00 |
Хоба! Тут цифры совсем не такие внушительные. Причём частенько бывало, что массив или список отрабатывали даже на 1-2% быстрее перечислимого. Но по памяти использование обёртки + перечислимого давало всегда лучший результат. С увеличением объёма данных отрыв также увеличивался.
Наверняка найдутся и те, кто скажет «да тут выигрыш в производительности на уровне погрешности, и памяти на 1 килобайт в 0-м поколении — оно того не стоит». Да, согласен, выигрыш небольшой, но он есть и достаётся нам совершенно бесплатно, потому что ни трудозатраты, ни сложность кода не увеличиваются.
А кстати, что там по части сторонних Mapper’ов? Они то как раз немного сокращают трудозатраты. Что ж — давайте посмотрим:
Method | ____Mean____ | ___Error___ | __StdDev__ | ___Median___ | Ratio | RatioSD | Gen0 | Allocated | Alloc_Ratio |
---|---|---|---|---|---|---|---|---|---|
Copy_ToArray_ToJson_Mapperly | 10,297.55 ns | 198.108 ns | 558.768 ns | 10,122.572 ns | 1.08 | 0.08 | 1.1292 | 2392 B | 2.11 |
Copy_ToArray_ToJson_Mapster | 10,333.08 ns | 157.737 ns | 147.547 ns | 10,359.288 ns | 1.06 | 0.03 | 1.1292 | 2392 B | 2.11 |
Copy_ToArray_ToJson_Automapper | 11,478.28 ns | 127.511 ns | 113.035 ns | 11,467.357 ns | 1.18 | 0.04 | 1.1597 | 2456 B | 2.16 |
Copy_Enumerable_ToJson_Mapperly | 9,663.39 ns | 47.330 ns | 41.957 ns | 9,654.192 ns | 0.99 | 0.03 | 1.0071 | 2136 B | 1.88 |
Copy_Enumerable_ToJson_Mapster | 10,063.57 ns | 197.194 ns | 256.408 ns | 9,984.233 ns | 1.03 | 0.04 | 1.0071 | 2136 B | 1.88 |
Copy_Enumerable_ToJson_Automapper | 11,842.87 ns | 148.995 ns | 139.370 ns | 11,773.734 ns | 1.21 | 0.04 | 1.0376 | 2200 B | 1.94 |
Wrapper_Enumerable_ToJson_2 | 9,720.05 ns | 190.424 ns | 285.018 ns | 9,647.125 ns | 1.00 | 0.00 | 0.5341 | 1136 B | 1.00 |
Ну да, ну да — за комфорт нужно платить 🙂