ViewModel и Domain Model: Границы ответственности

4 мая 2012 г.

ASP.NET MVC завоевывает всё большую популярность среди .NET программистов. Вместе с тем с развитием сообщества и наработкой знаний за счет реализации проектов у разработчиков начали возникать вопросы. Что есть MVC, в чем суть каждой его части? Как избавиться от дублирования в коде контроллеров при реализации одинаковых операций? Как проще сделать валидацию? Где описывать валидацию? Как MVC сочетается с DDD? И многое другое.

Я бы хотел рассмотреть самый главный вопрос, который, как мне кажется, является основой для понимания шаблона проектирования MVC и его реализации в .NET Framework.

Вопрос в том, что же считать моделью (M-VC)? Есть разные мнения по этому поводу. Два основных заключаются в следующем:

  • ViewModel == Domain Model - на View надо передавать доменные объекты
  • ViewModel != Domain Model - На View надо передавать специально сформированные для этой View объекты.

Классы, которые формируются специально для View, я буду называть Model или ViewModel.

Я хочу рассмотреть оба варианта в разных сценариях использования системы. В конце сделаю выводы по выбору одного или другого решения.

ViewModel == Domain Model

Первый вариант, когда доменные объекты передаются сразу на View:

Сценарий использования

У нас есть страница с отображением деталей статьи. Пользователь вводит http://www.mysite.com/Post/Details/25. Мы запрашиваем статью с ID = 25, например, из PostRepository. Получаем доменный объект Post и передаем его на View, где в HTML отображаем все необходимые поля.

ViewModel != Domain Model

Второй вариант, когда на View передаются специальные объекты, которые являются моделью и описываются в границах MVC-приложения:

Сценарий использования

У нас есть страница с отображением деталей статьи. Пользователь вводит http://www.mysite.com/Post/Details/25. Мы запрашиваем статью с ID = 25, например, из PostRepository. Получаем доменный объект Post или DTO объект. Формируем объект PostViewModel и передаем его на View, где в HTML отображаем все необходимые поля.

Применение в реальной жизни

Комбинированная модель

Даже самые ярые защитники передачи доменных объектов во View признают, что такой способ не подходит для большинства сценариев. Самые простой пример - это модель с пейджингом.

Сценарий: пользователь зашел на список статей, где есть листалка по страницам. Во View нам надо передать не просто IEnumerable, а дополнительные данные PageIndex, PageCount, чтобы отобразить текущую страницу и общее кол-во страниц:

public class PostListViewModel
{
    public IEnumerable<Post> ItemsForPage { get; set; }
    public int PageIndex { get; set; }
    public int PageCount { get; set; }
}

Как видно, передать просто список доменных объектов уже не получается, поэтому мы начинаем идти по второму варианту - формируем специальный объект для View.

Сценарий: пользователь зашел на детали статьи, где кроме самой статьи отображается подробная информация об авторе. Суть в том, что нам нужно передавать уже не один доменный объект, а сразу несколько. В этом случае у нас нет выбора, мы создаем отдельный класс - Model - для View:

public class PostDetailsViewModel
{
    public Post Post { get; set; }
    public User User { get; set; }
}

Сценарий: пользователь зашел на детали статьи, если он является автором, то сможет удалять статью. Здесь нам надо передать на View некий атрибут, который является рассчитываемым. Важно заметить, что это поле не имеет значения в самом домене, оно важно только для отображения. Поэтому переносить подобные поля в домен не надо. Если изначально мы передавали в нашу View доменный объект Post, то теперь сделаем отдельную Model:

public class PostDetailsViewModel
{
    public Post Post { get; set; }
    public bool IsContextUserPostAuthor{ get; set; }
}

Подобных сценариев в нашей практике большинство. Модели кроме доменного объекта необходимы дополнительные поля, которые содержат в себе набор данных для отображения. Например, на форме надо показать пользователей и напротив каждого поставить галочку с выбором роли этого пользователя - настройка прав доступа в системе. Возможность поставить галочку в ту или иную роль будет зависеть от того есть у пользователя купленные продукты или нет.

Что в этом случае представляет собой модель данных? Это будет объект, который содержит некоторые данные пользователей, некоторые данные ролей и некоторые данные о продуктах. Это может быть имя пользователя, причем для этого отображения имя берется, как логин + ID, кол-во продуктов, купленных пользователем в определенный период и т.д. Этот набор данных формируется, исходя из требований к модели для отображения данных.

Как мы видим, даже если мы начинали передавать во View просто доменный объект, то в итоге, мы формируем специальные классы - ViewModel, которые не содержат в себе доменных объектов.

ORM, LazyLoad и генерация объектов

Вы используете ORM, которая генерирует объекты по БД? Если вы передаете эти объекты прямо во View, то возможно ли в отображении данных сделать:

// поле IsDirty генерируется ORM и является публичным в сущности
@post.IsDirty

// здесь произойдет запрос за счет LazyLoad
@post.Comments.Where(x => x.IsDeleted == false) 

В первом случае мы связываем себя с ORM так сильно, что придется потратить много времени на переход к другому способу сохранения данных. IsDirty - это только пример, многие ORM грешат генерацией "своих" полей в доменные объекты.

Ничего не мешает написать такой код, который во View подгрузит комментарии и выберет из них те, что не удалены. Теперь наше бизнес-правило "к статье относятся только не удаленные комментарии" начинает жить в отображении данных. Думаю, что не надо останавливаться на том, почему это плохо и чем грозит. Даже если LazyLoad не включен, то подобный код можно написать, размазав тем самым бизнес-логику по всему приложению.

ViewModel решают эту проблему, потому что содержат только те поля, которые нужно отобразить во View.

Валидация

Как мы делаем валидацию для отображения в ASP.NET MVC? Есть два самых распространенных способа.

Первый предлагается нам, когда мы создаем новый проект ASP.NET MVC в Visual Studio. На модель навешиваются атрибуты валидации, которые лежать в DataAnnotations. ASP.NET умеет подхватывать эти атрибуты и делать по ним клиентскую и серверную валидацию.

Если вы передаете во View доменный объект, то получается, что эти атрибуты валидации надо писать прямо в него? Т.е. ваши доменные объекты обрастут атрибутами для валидации из слоя UI. Что же делать, если для одного и того же поля в доменном объекте на разных View надо делать разную валидацию?

Второй способ - это описание метаданных для модели с помощью MvcExtensions. Здесь мы можем описывать метаданных не трогая модель в отдельном классе, который унаследован от ModelMetadataConfiguration.

Метаданные можно описать для конкретного класса. Если метаданные приходится описывать для доменного объекта, что делать, если доменный объект используется на нескольких формах и метаданные должны быть разными? ViewModel решают эту проблему, для каждой формы нужно сделать свою модель данных.

Формирование ViewModel

ViewModel чаще всего формируются из доменных объектов, поэтому процесс сопоставления полей может быть очень утомительным. Если в Post и PostViewModel совпадают 5-6 полей, то придется вручную брать поля из Post и присваить их соответствующим полям из PostViewModel.

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

Выводы

Если подвести итог, то модель в MVC и доменная модель "играют за разные команды".

Передавать объекты, которые описывают ваш домен, напрямую в отображение можно только в очень простых CRUD приложения, которые никогда никто не будет развивать.

В корпоративных системах ASP.NET MVC обычно является одной из частей инфраструктуры. Общая схема взаимосвязи домена и приложений должна выглядеть так:

Сам домен не должен зависеть от любых периферийных приложений. Они не должны навязывать доменным объектам свои атрибуты, поля или методы.

С другой стороны никакое приложение не должно зависеть напрямую от домена. Это значительно упростит работу по рефакторингу и любым манипуляциям с доменом. Плюс сами приложения становятся более подвижны. Их, например, легко можно заменить сервисом или облаком.

Ниже я привел ссылки на источники, в которых эта тема рассматривается с разных точек зрения.


Обсуждения

DotNetConf: Model (M-VC) != Domain Model

StackOverflow: Why Two Classes, View Model and Domain Model?

StackOverflow: View and Domain model, where to perform calculation

StackOverflow: Bestpractice - Mixing View Model with Domain Model

StackOverflow: ASP.NET MVC: ViewModels versus Domain Entities


Ссылки

Separated Presentation, Martin Fowler

GUI Architectures, Martin Fowler

To Model Or ViewModel, That Is the Question, Jeffrey Palermo

Pros and Cons of Data Transfer Objects, Dino Esposito

How we do MVC – View models, Jimmy Bogard

33 комментария:

  1. Андрей Чистяков5 мая 2012 г. в 00:16

    Александр, спасибо за статью! Все подробно и доступно изложено

    ОтветитьУдалить
  2. Pavel Shchegolevatykh5 мая 2012 г. в 04:14

    Интересно, а в случае MVVM паттерна (WPF, Silverlight) Model и доменная модель также разные сущности? Просто там уже есть ViewModel как абстракция View.

    ОтветитьУдалить
  3. В случае MVVM объекты домена на клиенте не фигурируют (по крайней мере у меня на Silverlight). Фигурируют только объекты DTO. Иногда в качестве ViewModel выступает сам объект DTO (например, в гридах), иногда делается отдельный класс ViewModel (как правило, когда в ViewModel должна быть реализована какая-то логика, поведение)

    ОтветитьУдалить
  4. А я логики из ViewModel всегда в экстеншины выношу.

    ОтветитьУдалить
  5. Можно пример того как это выглядит и как используется?

    разве Silverlight может биндиться к Extension методам?

    ОтветитьУдалить
  6. Спасибо за статью. Сам пользуюсь подобными практиками. Тяжело представляю, как можно было бы обходится без них в моём случае (уж больно сильно модель отличается от того, что в итоге видит пользователь). Отмечу лишь, что, как и любой дополнительный слой, работа с ViewModel создает дополнительные затраты по реализации (даже с механизмами отображения вроде AutoMapper. Кстати, с ним бывает тоже довольностаточное количество рутины).

    ОтветитьУдалить
  7. Спасибо очень полезная статья. Я как то упустил из виду AutoMapper, и все время думал - куда эту рутину приткнуть. В последнее время начал думать в сторону определния неявных операторов.

    ОтветитьУдалить
  8. Класная статься, спасиб!
    Интересен ещё один момент. Дано: класы данных из базы (доменные объекты у нас), репозитории для их извлечения, сервисы бизнес логики и сверху MVC. Простейший сценарий: получить, например, пользователя. Он лежит в базе в таблице пользователи и с помощью орм от туда получается.
    Пример для понимания всего процесса и понятно, что может и через урощённую схему сделан быть, но интересен процесс в общем случае.

    Вопрос: через что проходит пользователь и в каком виде приходит во вью?

    Для начала, какие мысли у меня на счёт этого всего: пришёл запрос в контроллер -> далее контроллер просит сервис дать ему пользователя -> сервис просит репозиторий пользователей -> репозиторий с помощью орм достаёт его из базы. Далее в обратном направлении пользователя возвращают: репозиторий отдал в сервис клас User -> сервис его обернул в UserDTO и отдал контроллеру -> контроллер смапил в нужный UserViewModel и отдал во вью. Так получается в общем случае или поправьте меня?

    Спасиб

    ОтветитьУдалить
  9. Да, так получается в "общем" случае, но не получается в реальной жизни.

    Разбейте сервисы и репозитории на небольшие классы. Сервисы на команды, репозитории на Query. Тогда у вас будет Controller -> Query (map to DTO) -> Controller -> View

    ОтветитьУдалить
  10. как я понимаю, Вы говоритье про CQRS принцип. Получается у нас репозитории не должны лежать на уровне ниже сервисов, а должны быть на одном уровне и контроллер использует либо репозитории (для гет запросов в общем), либо сервисы (для пост запросов). И в этом случае, если сервису нужны доп. данные, то сервис обращается тоже к репозиторию.

    Или всё таки у нас есть низкоуровневые репозитории, для работы с источником данных (бд), а уровень выше их состоит из commands и queries, с которыми по сути контроллер и работает. Query в итоге использует репозиторий и достаёт из базы объект User, мапит его в DTO и отсылает в контроллер -> view. 


    А когда мы используем command, что мы формируем в контроллере как InputModel: объеты DTO или же ViewModel / доп. классы? что комманда на вход принимает?

    ОтветитьУдалить
  11. Я думаю, что Repository вообще не должно быть 
    http://blog.byndyu.ru/2011/08/repository.html, вот тут описана замена на бестелесный IQueryFactory 
    http://blog.byndyu.ru/2011/08/queryfactory-iqueryfactory.html

    Команда принимает на вход форму, если это пост-запрос с формы, либо данные для команды, если это какое-то другое изменение системы. Например, при добавлении статьи метод контролле выглядит как-то так:

    [HttpPost]
    public ActionResult AddPost(AddPost form)
    {
    // do action, validate form, return result

    То, что касается формы постоянно повторяется, поэтому лучше использовать FormHandler http://lostechies.com/jimmybogard/2011/06/22/cleaning-up-posts-in-asp-net-mvc/

    ОтветитьУдалить
  12. Ясно, направление есть - займусь изучением вопроса поглубже.


    спасибо!

    ОтветитьУдалить
  13. как минус передачи на view Domain Entities - это то что постоянно приходится заботиться о model binding security и сигнатуры action начинают обрастать : FooAction(Bind(Include="ID,Email,Website,Body")]FooDomainEntity entity)

    ОтветитьУдалить
  14. Спасибо за статью. Статья замечательная.
    Правда, на мой взгляд, есть одна небольшая нестыковка - чисто текстуальная. В самом начале Вы говорите про то, что рассмотрите "... оба варианта в разных сценариях использования системы" и сделаете "в конце ... выводы по выбору одного или другого решения". По факту же вся статья сводится к доказательству почти безусловного преимущества варианта с ViewModel.А упоминаемые в выводе "очень простые CRUD приложения, которые никогда никто не будет развивать" вряд ли вообще стоит упоминаться всерьез как по определению нежизнеспособные.

    ОтветитьУдалить
  15. Артем, я честно рассмотрел каждый вариант в каждом сценарии :)

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

    ОтветитьУдалить
  16. Нет, я не к тому, что вариант 1 был рассмотрен недостаточно и был "нечестно загнана в угол". Все замечательно. Прост тон статьи такой, что ее следовало бы назвать: "Почему использовать ViewModel почти всегда лучше" (ну, или как-то в этом роде).


    На самом деле в нашей команде применялся как раз подобный подход. В качестве ORM мы  использовали NHibernate, а там, как знаете есть свои проблемы с передачей доменных объектов на View - зачастую, для того же LazyLoad, надо еще и сессию за собой тащить.

    ОтветитьУдалить
  17. Александр, спасибо за статью!
    Крайне приятно увидеть что подобные вопросы интересуют наше сообщество. По своему опыту могу сказать что именно asp.net mvc дала возможность заглянуть в такие вопросы. В обычном asp.net у меня таких вопросов не было - всё было крайне прозаично.

    Теперь по теме - моё субъективное мнение по опыту работы с asp.net mvc. Безусловно ViewModel и Domain Model это совершенно разные группы сущностей и пытаться нарисовать на форме данные из сущности Domain Model часто вообще нельзя (форма требует ещё каких доп данных которых нет в данных Domain Model). В тоже время сущности ViewModel и Domain Model несут в себе примерно одну и ту же информацию. И меня хорошо получилось делать сущности ViewModel в виде обёрток (враперов) над  сущностями Domain Model. Это позволяет наполнять враппер через конструктор из доменного объекта, которым мы тянем из бд. Враппер играет роль модели на cshtml форме. Далее враппер заполняет данными поля на форме. При сабмите с формы на следующий экшн враппер автоматом заполняется данными с формы и одновременно при этом наполняется данными доменный объект, который обёртывает враппер. В следующем экшне мы вытаскиваем доменный объект из враппера, сохраняем его в бд и редиректимся на следующую нужную нам форму.

    Ещё такое замечание. В этом флейме обсуждается использование AutoMapper. Не вполне понимаю зачем он. asp.net mvc сам делает все нужные маппинги и при заполнении данными формы и при сборе данных с формы.

    ОтветитьУдалить
  18. Алекс, покажите код, про который вы рассказываете. Хочу увидеть, как это у вас реализовано.

    > В этом флейме обсуждается использование AutoMapper. Не вполне понимаю зачем он

    Он нужен, чтобы из доменного объекта сделать ViewModel и обратно. Binder от MVC такого сделать сам не сможет, особенно, когда ViewModel сложная, а не просто набор примитивных типов. 

    ОтветитьУдалить
  19. Спасибо за статью, Александр. Давно задаюсь вопросом - что делать, если во View нужно показать много данных из разных таблиц БД (причем не только выборки, но и аггрегация в том числе)? Мне далеко до эксперта в MVC, но как правило в этой ситуации у меня получается сложный класс ViewModel и несколько запросов к БД. В этот ViewModel через AutoMapper отображаются результаты запросов. Маппинг происходит непосредственно внутри action и контроллер распухает. Что посоветуете в таких случаях?

    ОтветитьУдалить
  20. Глеб, я не совсем понимаю из-за чего распухает контроллер. В вашем случае в нем будет:

    // вызов Query

    // составление Model (хотя Query может возвращать сразу готовые DTO и эта часть будет отсутствовать)

    // return View(model)

    ОтветитьУдалить
  21. Первый вариант из запроса возвращать DTO и складывать их во ViewModel.

    return new View(new ComplexViewModel{
    Tags = tagsDto,
    Posts = postsDto,
    ....
    });
    Второй вариант делать ViewModel вручную, вызывая Automapper.
    return new View(new ComplexViewModel{Tags = Automapper.Mat<>(tags),....});

    ОтветитьУдалить
  22. Возможно для какого-то суперсложного объекта и нужен AutoMapper. Но у меня все объекты прекрасно мапятся и без него, исключительно встроенными средствами asp.net mvc

    ОтветитьУдалить
  23. В первом варианте разве не будет жесткого связывания между ViewModel и тем типом, который возвращает запрос? 
    У меня сейчас как раз второй вариант, с небольшими собственными допилами

    ОтветитьУдалить
  24. Запрос вернет DTO, который будет отображаться на View. Назовите его не DTO, а ViewModel, суть такая же:

    return new View(new ComplexViewModel{Tags = tagsViewModel,Posts = postsViewModel,....}); 

    ОтветитьУдалить
  25.  journal_entry - это сущность Domain Model:
    JournalEntryWrapper - это сущность ViewModel

        public class JournalEntryWrapper
        {
            public journal_entry JournalEntry { get; set; }

            public JournalEntryWrapper()
            {
                JournalEntry = new journal_entry();
            }

            public JournalEntryWrapper(int projectId)
            {
                JournalEntry = new journal_entry { project_id = projectId };
            }

            public JournalEntryWrapper(journal_entry entry)
            {
                JournalEntry = entry;
            }

            public override int ProjectId
            {
                get { return JournalEntry.project_id; }
                set { JournalEntry.project_id = value; }
            }

            public int JournalEntryId
            {
                get { return JournalEntry.id; }
                set { JournalEntry.id = value; }
            }

            [Display(Name = "Содержание")]
            [Required(AllowEmptyStrings = false, ErrorMessage = "Введите текст записи")]
            public string Description
            {
                get { return JournalEntry.description; }
                set { JournalEntry.description = value; }
            }
        
            [Display(Name = "Дата")]
            [DataType(DataType.Date)]
            public DateTime CreationDate
            {
                get { return JournalEntry.creation_date; }
                set { JournalEntry.creation_date = value; }
            }
        }

    ОтветитьУдалить
  26. Спасибо, идея понятна.
    Что вы делаете, когда надо выводить и забирать вложенные объекты/коллекции? Может ли при этом появляться дублирование в коде, которые "маппит" вложенные объекты/коллекции?

    ОтветитьУдалить
  27.  1. На веб форме описываю враппер как модель
    @model Web.Model.Wrappers.JournalEntryWrapper

    2. Для рисования данных из враппера на форме
    return View(new JournalEntryWrapper(id));

    3. Для сбора данных с формы во враппер
            [HttpPost]
            public ActionResult Create(int id, JournalEntryWrapper entry)
            { .... }
     то есть никакой посторонний маппер не требуется.

    Коллекции у меня не собираются с форм, но в инете видел примеры и для коллекций. Ничего особенного в этом нет.

    ОтветитьУдалить
  28. Вопрос в том, что делать если у вас идет обращение к вложенным полям сущностей, например, вот такт Post.Comments[1].Author.Name

    ОтветитьУдалить
  29. Когда идёт прорисовка данных из враппера на форме, то проблем никаких не вижу. во 2й операции - сбор данных с формы во враппер конечно просто так вложенные объекты не наполнятся. Значит надо подключать всю мощь ООП и своего мозгового аппарата.

    ОтветитьУдалить
  30. Здравствуйте. По этой теме возник такой вопрос:
    Имеется 3 уровня(представление, БД, и БЛ).
    В качестве доступа к БД используем EntityFramework. Но объекты, которые он генерирует мне не нравится, к тому же для их использования надо везде носить с собой всю модель(то есть доступ к БД получается).
    Правильно создать еще объекты, аналогичные сгенерированным(но более упрощенные и подстроенные под себя) в отдельной сборочке, которые будут использовать представление и БЛ?
    Но БЛ тогда для записи в БД должна перегонять эти объекты в Entity объекты. То есть получается аж 3 типа объектов: для представления, для БЛ и для EntityFramework. Правильно ли все это?)

    ОтветитьУдалить
  31. Я понимаю проблемы, с которыми вы столкнулись. Сам когда-то генерировал объекты по БД 
    http://blog.byndyu.ru/2009/12/llblgen-vs-nhibernate.html

    Первое, что лучше всего сделать, перейти на Code First.

    Если проект очень большой, давно разрабатывается и так просто от генерации не отказаться, то есть несколько вариантов.

    1. Использовать то, что уже есть. Передавать сгенерированные объекты в представление и осознать всё командой, что в представлении не надо писать запросы к БД. Как писал Роберт Мартин: Унаследованные системы - это те, которые работают. Поэтому подумайте, стоит ли вам тратить на _эту_ систему время и заниматься ее переработкой.

    2. Создать интерфейсы или DTO объекты, которые будут курсировать между слоем доступа к данным (сгенерированным EF) и остальной частью системы. Накладные расходы тут понятны.

    ОтветитьУдалить

Моя книга «Антихрупкость в IT»

Как достигать результатов в IT-проектах в условиях неопределённости. Подробнее...