Про проблему 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# розробників.
13 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів