Про проблему DTO та шляхи її вирішення

Усім привіт, мене звати Владислав Огородніков, я .NET dev в компанії Infopulse. У цій статті поговоримо про те, як можна вирішувати проблеми з Data Transfer Object. Кожен бекенд-розробник знає, що таке N-рівнева архітектура. На одному з рівнів є бізнес-модель даних (БЛ), яка описується класом. Цей клас майже завжди відповідає таблиці у БД.

На іншому рівні відображення використовують DTO (Data Transfer Object), щоб передавати дані туди, де вони будуть використані.

Навіщо та як використовують DTO

Гарне повне пояснення можна прочитати за посиланням на stackoverflow , а тут приведу коротко.

DTO (Data Transfer Object) — об’єкт, що використовується для передачі даних з одного місця програми в інше. Найчастіше для передачі даних з бекенд рівня на рівень UI.

Чому не використовувати для цього існуючу БЛ-модель? Можна. Навіщо тоді ускладнювати життя додатковими об’єктами, класами? Якщо необхідно не передавати якесь поле, яке є у БЛ-моделі, що робити?

Можемо просто занулити значення. Що робити з типами, наприклад, int, long? Або домовитись, що від’ємні значення ігноруємо (а якщо вони дозволені?), використовувати нулабл int (а якщо бізнесом не дозволено нул?). Виникають проблеми.

Якщо необхідно додати якусь додаткову інформацію? Можна додати поле у БЛ-модель і ігнорувати його при роботі з БД. А якщо в різних місцях програми необхідні різні поля, не додавати ж їх всі у БЛ-модель? Навіть якщо треба додати лише одне поле, то виходить, що наша БЛ-модель залежить від відображення. Що зовсім не правильно.

Якщо, наприклад, є поле Description та Notes у БЛ-моделі, а ми хочемо відправити ці значення як єдиний, об’єднаний текст тільки якщо значення різні? І щоб поле мало назву просто Description? Тут вже змінити БЛ-модель не вийде. Потрібно створити проксі клас, у якому ми зробимо поле Description таким, яким нам це потрібно.

Саме такі проксі-класи прийнято називати DTO. Або ще їх називають ViewModel — модель для відображення.

З тим, що таке DTO і навіщо воно потрібне — розібрались. Далі наведу невеликий приклад того, як виглядатиме код стандартної БЛ-моделі та її ДТО:

public class MyBlModel
{
    public int Id {get; set;}
    public string Code {get; set;}
    public string Description {get; set;}
    public string Notes {get; set;}
}
public class MyBlModelDTO
{
    public int Id {get; set;}
    public string Code {get; set;}
    public string Description {get; set;}
    public string Notes {get; set;}

    public string SomeExtraData {get; set;}
}

Як бачимо, всі проперті з БЛ-моделі були просто скопійовані у DTO, грубо порушивши принцип DRY. А якщо на одну БЛ-модель необхідно декілька DTO? Одразу постає питання, чи можна це якось виправити, щоб не копіювати проперті власноруч та бути впевненим, що всі DTO будуть змінені у разі зміни БЛ-моделі?

Одним з можливих рішень є наслідування. Можна наслідувати MyBlModelDTO від MyBlModel. Це рішення лиш трохи краще, ніж змінювати БЛ-модель напряму. Таке рішення може породити безліч багів, пов’язаних з використанням ORM, та не дає можливості прибрати якісь дані з DTO.

Інше питання — як скопіювати значення з БЛ-моделі в DTO? Не писати ж на кожну ДТО власноруч по методу, який буде виглядати приблизно так:

public void MapToDto(MyBlModel bl, MyBlModelDTO dto)
{
    dto.Id = bl.Id;
    dto.Code = bl.Code;
    dto.Description = bl.Description;
    dto.Notes = bl.Notes;
    dto.SomeExtraData = "SomeExtraData";
}

Для вирішення цієї проблеми зазвичай використовують автомапери (AutoMapper, Mapster і тд).

Про що ж тоді стаття, якщо всі проблеми відомі та вирішені

Не вирішеною лишилась проблема з копіюванням пропертей з БЛ-моделі до DTO та підтримкою DTO-моделі в актуальному стані.

Яке я пропоную вирішення? Microsoft зробив можливість писати власні SourceGenerators. Що це таке та як працює — за посиланням.

Якщо коротко: SourceGenerator — це програма, яка може аналізувати код та додавати в нього інший, на етапі компіляції.

Як це працює:

  • Запускається компіляція.
  • Парситься код.
  • Запускаються всі SourceGenerator.
  • Продовження компіляції.

Було б чудово в автоматичному режимі копіювати проперті з БЛ-моделі до DTO.

Саме це і робить моя бібліотека AutoDto.

Як це працює

Необхідно на клас DTO додати атрибут [DtoFrom(typeof(MyBlModel))]. Готово, автоматично буде створено partial class MyBlModelDTO, що міститиме усі проперті з БЛ-моделі.

Таким чином, DTO-модель виглядатиме наступним чином:

[DtoFrom(typeof(MyBlModel))]
public partial class MyBlModelDTO
{
    public string SomeExtraData {get; set;}
}
Відразу видно наскільки менше написаного коду та немає необхідності не забувати змінювати DTO при зміні БЛ-моделі.

Згенерований код виглядатиме так:

public partial class MyBlModelDTO
{
    public int Id {get; set;}
    public string Code {get; set;}
    public string Description {get; set;}
    public string Notes {get; set;}
}

Далі буде розповідь про наявний функціонал бібліотеки і те, як вона працює. Тому, якщо бібліотека не зацікавила — можна закінчувати читати статтю :)

Як налаштувати роботу

1. Встановити nuget.

2. Позначити DTO клас як partial.

3. Додати атрибут [DtoFrom(typeof(MyBlModel))]та вказати тип БЛ-моделі.

Стратегія обробки зв’язків

Досить часто необхідно у DTO-модель додати Id якогось зв’язку. Наприклад:

public class BlType1
{
    public int Id {get; set;}
}
public class BlType2
{
    public int Id {get; set;}
    public BlType1 RelationBlName {get; set;}
}

Очікуваний DTO для BlType2 має вигляд:

public class BlType2DTO
{
    public int Id {get; set;}
    public int RelationBlNameId {get; set;}
}

, де BlType1 RelationBlName був замінений на int RelationBlNameId.

Щоб досягти такої поведінки при використанні бібліотеки, необхідно застосувати RelationStrategy параметр:

[DtoFrom(typeof(MyBlModel), RelationStrategy.ReplaceToIdProperty)]
public partial class MyBlModelDTO { }

Наявні RelationStrategy:

  • None.
  • ReplaceToIdProperty.
  • AddIdProperty.
  • ReplaceToDtoProperty.

None — усі проперті з БЛ-моделі будуть скопійовані у DTO без змін.

public class BlType1
{
   public int Id {get; set;}
}
public class BlType2
{
   public int Id {get; set;}
   public BlType1 RelationBlName {get; set;}
}
[DtoFrom(typeof(BlType2)]
public partial class BlType2DTO { }

Згенерований код:

public partial class BlType2DTO
{
   public int Id {get; set;}
   public BlType1 RelationBlName {get; set;}  
}

ReplaceToIdProperty — з типу проперті буде знайдено тип Id та замінено RelationBlName на RelationBlNameId.

public class BlType1
{
   public int Id {get; set;}
}
public class BlType2
{
   public int Id {get; set;}
   public BlType1 RelationBlName {get; set;}
}
[DtoFrom(typeof(BlType2), RelationStrategy.ReplaceToIdProperty)]
public partial class BlType2DTO { }

Згенерований код:

public partial class BlType2DTO
{
   public int Id {get; set;}
   public int RelationBlNameId {get; set;}
}

AddIdProperty — з типу проперті буде знайдено тип Id та додано RelationBlNameId.

public class BlType1
{
   public int Id {get; set;}
}
public class BlType2
{
   public int Id {get; set;}
   public BlType1 RelationBlName {get; set;}
}
[DtoFrom(typeof(BlType2), RelationStrategy.AddIdProperty)]
public partial class BlType2DTO { }

Згенерований код:

public partial class BlType2DTO
{
   public int Id {get; set;}
   public BlType1 RelationBlName {get; set;}  
   public int RelationBlNameId {get; set;}
}

ReplaceToDtoProperty — буде знайдено DTO для типу проперті та замінено BlType1 RelationBlName на BlType1DTO RelationBlNameDto.

public class BlType1
{
   public int Id {get; set;}
}
public class BlType2
{
   public int Id {get; set;}
   public BlType1 RelationBlName {get; set;}
}
[DtoFrom(typeof(BlType1))]
public partial class BlType1DTO { }
[DtoFrom(typeof(BlType2), RelationStrategy.ReplaceToDtoProperty )]
public partial class BlType2DTO { }

Згенерований код:

public partial class BlType1DTO 
{
   public int Id {get; set;}
}
public partial class BlType2DTO
{
   public int Id {get; set;}
   public BlType1DTO RelationBlName {get; set;}
}

Якщо існує декілька DTO на одну БЛ-модель і є необхідність використовувати ReplaceToDtoProperty стратегію — необхідно додати атрибут [MainDto] на ту DTO, яку необхідно використовувати з ReplaceToDtoProperty стратегією.

Ігнорування проперті з БЛ-моделі

Якщо є необхідність проігнорувати проперті з БЛ-моделі, необхідно використати атрибут [DtoIgnore].

[DtoFrom(typeof(MyBlModel), RelationStrategy.AddIdProperty)]
[DtoIgnore(nameof(MyBlModel.PropName1), nameof(MyBlModel.PropName2))]
public partial MyBlModelDTO {}

У разі передачі невалідного імені проперті для ігнорування — буде додано варнінг компіляції.

Як все працює (технічно)

Існує 2 типи SourceGenerators. Звичайний та Incremental. Звичайний — на вхід до генератору подається увесь код проєкту на кожну його зміну (кожен key press).

Incremental — дає можливість перевірити спочатку, в якій частині коду була зміна (в методі, в класі і тд), дає можливість зібрати лиш необхідні дані і працювати з ними, що значно зменшує кількість генерувань.

AutoDto SourceGenerator працює як Incremental SourceGenerator. Таким чином, генератор запускається лише тоді, коли структура класів була змінена, та не запускається, коли зміни не стосуються структури класів (наприклад, редагування методів).

Також ще додав перевірку на те, що структура всіх BL та DTO класів немає помилок компіляції. І лише тоді запускається генератор.

Для випадку, коли генерація запускається протягом короткого проміжку часу декілька разів — збираю всі вхідні запити та виконую лише останній. Проміжок часу можна конфігурувати, так само, як і вимкнути взагалі цей механізм. Про налаштування буде далі.

Що по швидкодії

Основний час займає компіляція згенерованого коду. Якщо згенерований код буде стабільним у часі (одні і ті ж вхідні дані генеруватимуть один і той же код з точністю до символу), то подальші парсинги займуть набагато менше часу.

Тому порахувати, скільки реально буде затрачено часу на весь парсинг коду та компіляцію згенерованого складно. Для базової перевірки створив тест, що генерує 1000 BL та по декілька DTO на кожну з BL. Також генерую 10к інших класів. Тестовий запуск генератору займає 2 секунди.

Може здадися, що 2 секунди роботи генератора — це багато, і все буде лагати. 2 секунди на досить великий проєкт — гарний показник, враховуючи, що генератор буде запускатися лише тоді, коли:

  • всі класи, що мають відношення до DTO, компілюються без помилок;
  • було змінено структуру класів, а не реалізацію методів.

Таким чином, навіть при активній зміні БЛ-моделі, генератор буде запускатися лише тоді, коли це має сенс.

Налаштування бібліотеки

Налаштування мають бути задані у .editorconfig. Зазвичай вони читаються лише один раз при старті VisualStudio. Після їх зміни необхідно перезавантажити VisualStudio.

Наявні налаштування:

  • auto_dto.logger.enabled — true/false;
  • auto_dto.logger.folder_path — string — шлях до папки, де буде файл з логами;
  • auto_dto.logger.log_level — Serilog log levels;
  • auto_dto.debounce.enabled — true/false — використовувати відкладене генерування чи генерувати новий код на кожен запит;
  • auto_dto.debounce.interval — int — in milliseconds — початковий таймаут для збору запитів на генерування DTO;
  • auto_dto.debounce.auto_rebalance_enabled — true/false — дозволити автоматичну зміну таймауту.

Що далі

Найближчими наступними оновленнями планую:

  • попрацювати над швидкодією;
  • додати можливість використовувати повністю свої DTO-моделі зі стратегією ReplaceToDtoProperty;
  • подивитись в сторону поєднання з маперами, які працюють на сорс генераторах
  • можливо, зробити власний мапер на сорс генераторі;
  • пишіть ваші пропозиції.

Код AutoDto генератора доступний на GitHub.

Окрема вдячність Андрію Подколзіну та його YouTube-каналу DevJungles за велику кількість цікавого матеріалу рівня Senior для C# розробників.

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось9
До обраногоВ обраному1
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Про проблему DTO та шляхи її вирішення

Якщо чесно, то дивна назва статті.
DTO — патерн, а як патерни як відомо, вирішують типові завдання (ну чи можна сказати проблеми). А у вас вийшов такий собі патерн-проблема.

Нагадало ситуацію з LinqToDB, можливо це якась хороша тулза, але мені потрібно було в старий проект де в базі 600+ таблиць, додати 1 нове поле в таблицю. Я ж подумав, якщо це згенеровано все LinqToDB, то логічно що потрібно просто запустити заново генератор і все буде ок. Проект був на AspNet Core 3.1, тому я з ним працював з маку та Rider, але LinqToDB щось не завівся і потрібно було ставити віртуалку з віндою та VS. 3 дні так просидів, мак гудів, пихтів, щось компілював... В результаті просто руками написав це 1 нове поле і забив розбиратися з LinqToDB.

Про AutoMapper, колись він мені подобався, поки не прийшлось 2-3 години розбиратися як додати щось просте в старий проект...

AutoDto

бачу сенс лише в великих проектах, бо в мікро сервісах тобі не так часто потрібно копіпастити. Але через великі проекти AutoDto може перерости в щось складне і складнопідтримуване, що легше буде просто взяти і написати руками.

Повністю погоджуюсь, ризики використання моєї бібліотеки такі ж, як і будь-яких інших. Може перерости у щось складне(як автомапер), нема необхідності на малих проектах(як і автомапер), може припинитися підтримка бібліотеки розробником(як і будь-яка інша), може не повністю задовольняти потреби, або буде заскладною в певному тривіальному випадку(як і будь-яка інша).

Дякую за конструктивний коментар

Я ж без хейту пишу, якщо що :)

Можливо можна обійтися без генерації коду, а щоб не пропустити якийсь параметр в Dto то можна замінити на статичний аналіз в IDE або юніт тест який по рефлексії робить перевірку і не падає якщо якесь поле не замапилось.

Тут будуть ті самі атрибути щоб вказати з чого в що перетворюється і що потрібно ігнорувати. Але на цьому не буде завязана працездатність всього проекту, якщо в один момент все стане складним, просто видаляєш ці атрибути чи видаляєш тест і все працювало як до цього. В твоєму ж підході, якщо бібліотека щось не буде вміти, то проект застрягне на переписуванні частини логіки.

Ще з порад, то мабуть варто подумати про розширення RelationStrategy і замість enum передавати туди тип стратегії, що дасть можливість користувачам додавати свої власні стратегії. Бо що робити якщо в мене не Id, а Uid називається?

Идея интересная. Разве что хотелось бы (опционально) DTO иметь в виде

public partial class MyBlModelDTO
{
    public required int Id {get; init;}
    public required string Code {get; init;}
    public required string Description {get; init;}
    public required string Notes {get; init;}
}

Так гарантируется что никакое из полей DTO не будет забыто при создании обьекта.

И дополнительно уйдут варнинги от nullable-check на строки вида

public string Description {get; set;}

Гарна ідея. І гарно поєднується з використанням рекордів. Додам у шорт лист планів такий функціонал.

Дякую за ідею

Проблема з DTO в тому, що в них з часом потрібні поля значення яких являються агрегованними, на основі інших об’єктів. Для фронтенда особливо.
І оця вся генерація починає не допомогати, а плутатись під ногами, бо треба додати запити до БД, у якихось випадках, а в якихось — ці поля можуть бути пустими. У росподілених системах частенько сервісам потрібні 20% значень полів — але кожному сервісу — свої 20%.
Тобто до необхідності агрегації додається ще необхідність врахування контекста формування DTO(кому треба? для чого треба?)

і в кінці приходимо до того, що якби ручками написали, явно якийсь

...copyToDTO(object, context)
а то ще й ...copyToDTO( object, context, rules)

і явно його викликали, то це простіше і сапортити і розширювати, аніж викручувати рукі інструменту, генератору, супер-бупер лібі — яка не може зробити того «дивного» що тра. А дивні, не описані в підручниках, потреби завжди виникають.

бо copyToDTO і код копіювання, з урахуванням context і rules
легко обкладається юніт тестами
малий
конкретний

а не ці комбайни з елементами DSL та метапрограммування.

з врахуванням же все розумніших "copilot"ів — ці генератори кода взагалі стають непотрібні. навіть копіпастити вже не треба.
Генератори коду для подібних задач — точно скоро стануть глибокім минулим. Бо вивчити їх довше аніж клацнути те що пропонує ШІ

Повністю згоден з вашим коментарем з приводу того, що мапинг може бути складним та залежати від контексту. Але стаття лише про те, щоб уникати копіпасти пропертей з БЛ у ДТО клас, не більше.

З приводу ШІ і копілотів — треба, щоб саме таку фічу з очікуваними налаштуваннями створили на базі ШІ та інтегрували у ІДЕ, але так навряд чи зроблять у найближчий час. А уникати ручного копіювання коду хочеться вже зараз.

Дякую за конструтивний коментар

А уникати ручного копіювання коду хочеться вже зараз.

цього хтілося і років 15 тому.
але питання ж в тому — скільки часу займає ручне кодування, а скільки потім займе бодання з черговою тулзою, яких написано за роки купа?

треба, щоб саме таку фічу з очікуваними налаштуваннями

не треба. якщо рутину «копіпасти» буде зроблено, тобто «копіпаста пропертей», то дописати чи видалити зайве...

Ніяка ліба чи інструмент теж нічого не знає про особливість конкретної унікальності таскі. Ій те треба — пояснити, за допомогую якогось

необхідність використовувати ReplaceToDtoProperty стратегію — необхідно додати атрибут [MainDto] на ту DTO

То чому я не можу цей випадок написати — самим кодом? в чому виграш?

А от складність читання коду ми додаємо, бо замість коду який просто описує необхідну маніпуляцію, нам треба розібратись з тулзою, і ій пояснити, яку маніпуляцію нам треба. Ми таким чином збільшуємо когнтивне навантаження на читача коду.
І це виправдано, коли нам місяцями треба писати це. Місяцями пишемо код отримання DTO з бізнес об’єктів.

але так навряд чи зроблять у найближчий час

Це ж як з аутокомплітом — коли ви в останній раз писали повністю самі назву викликаємого метода у об’єкта?
Тобто помічник «copilot» не напише те що треба як і автокопліт. Поки що :)
Але того й не треба. тому у найближчий час і не робитимуть. тим більш що ШІ не експертна система, його не «роблять» як робили аутокомпліт плагіни. Він «сам» вчиться.

Пропоную ознайомитись з дзен Пайтон.
А тут залишу цитату до цього випадку
Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren’t special enough to break the rules.
Although practicality beats purity.

Beautiful is better than ugly.

Синтаксис та зовнішній вигляд піхтона є прямим порушенням цього правила.

Крута стаття з дуже гарною структурою:
Проблема
Можливі вирішення і їх недоліки
Запропоноване вирішення
Як це працює.

Статті з такою структурою завжди кращі!)

Підписатись на коментарі