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
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
Thanks!
ОтветитьУдалитьI agree with you.
Александр, спасибо за статью! Все подробно и доступно изложено
ОтветитьУдалитьИнтересно, а в случае MVVM паттерна (WPF, Silverlight) Model и доменная модель также разные сущности? Просто там уже есть ViewModel как абстракция View.
ОтветитьУдалитьВ случае MVVM объекты домена на клиенте не фигурируют (по крайней мере у меня на Silverlight). Фигурируют только объекты DTO. Иногда в качестве ViewModel выступает сам объект DTO (например, в гридах), иногда делается отдельный класс ViewModel (как правило, когда в ViewModel должна быть реализована какая-то логика, поведение)
ОтветитьУдалитьА я логики из ViewModel всегда в экстеншины выношу.
ОтветитьУдалитьМожно пример того как это выглядит и как используется?
ОтветитьУдалитьразве Silverlight может биндиться к Extension методам?
Спасибо за статью. Сам пользуюсь подобными практиками. Тяжело представляю, как можно было бы обходится без них в моём случае (уж больно сильно модель отличается от того, что в итоге видит пользователь). Отмечу лишь, что, как и любой дополнительный слой, работа с ViewModel создает дополнительные затраты по реализации (даже с механизмами отображения вроде AutoMapper. Кстати, с ним бывает тоже довольностаточное количество рутины).
ОтветитьУдалитьСпасибо очень полезная статья. Я как то упустил из виду AutoMapper, и все время думал - куда эту рутину приткнуть. В последнее время начал думать в сторону определния неявных операторов.
ОтветитьУдалитьКласная статься, спасиб!
ОтветитьУдалитьИнтересен ещё один момент. Дано: класы данных из базы (доменные объекты у нас), репозитории для их извлечения, сервисы бизнес логики и сверху MVC. Простейший сценарий: получить, например, пользователя. Он лежит в базе в таблице пользователи и с помощью орм от туда получается.
Пример для понимания всего процесса и понятно, что может и через урощённую схему сделан быть, но интересен процесс в общем случае.
Вопрос: через что проходит пользователь и в каком виде приходит во вью?
Для начала, какие мысли у меня на счёт этого всего: пришёл запрос в контроллер -> далее контроллер просит сервис дать ему пользователя -> сервис просит репозиторий пользователей -> репозиторий с помощью орм достаёт его из базы. Далее в обратном направлении пользователя возвращают: репозиторий отдал в сервис клас User -> сервис его обернул в UserDTO и отдал контроллеру -> контроллер смапил в нужный UserViewModel и отдал во вью. Так получается в общем случае или поправьте меня?
Спасиб
Да, так получается в "общем" случае, но не получается в реальной жизни.
ОтветитьУдалитьРазбейте сервисы и репозитории на небольшие классы. Сервисы на команды, репозитории на Query. Тогда у вас будет Controller -> Query (map to DTO) -> Controller -> View
как я понимаю, Вы говоритье про CQRS принцип. Получается у нас репозитории не должны лежать на уровне ниже сервисов, а должны быть на одном уровне и контроллер использует либо репозитории (для гет запросов в общем), либо сервисы (для пост запросов). И в этом случае, если сервису нужны доп. данные, то сервис обращается тоже к репозиторию.
ОтветитьУдалитьИли всё таки у нас есть низкоуровневые репозитории, для работы с источником данных (бд), а уровень выше их состоит из commands и queries, с которыми по сути контроллер и работает. Query в итоге использует репозиторий и достаёт из базы объект User, мапит его в DTO и отсылает в контроллер -> view.
А когда мы используем command, что мы формируем в контроллере как InputModel: объеты DTO или же ViewModel / доп. классы? что комманда на вход принимает?
Я думаю, что 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/
Ясно, направление есть - займусь изучением вопроса поглубже.
ОтветитьУдалитьспасибо!
как минус передачи на view Domain Entities - это то что постоянно приходится заботиться о model binding security и сигнатуры action начинают обрастать : FooAction(Bind(Include="ID,Email,Website,Body")]FooDomainEntity entity)
ОтветитьУдалитьСпасибо за статью. Статья замечательная.
ОтветитьУдалитьПравда, на мой взгляд, есть одна небольшая нестыковка - чисто текстуальная. В самом начале Вы говорите про то, что рассмотрите "... оба варианта в разных сценариях использования системы" и сделаете "в конце ... выводы по выбору одного или другого решения". По факту же вся статья сводится к доказательству почти безусловного преимущества варианта с ViewModel.А упоминаемые в выводе "очень простые CRUD приложения, которые никогда никто не будет развивать" вряд ли вообще стоит упоминаться всерьез как по определению нежизнеспособные.
Артем, я честно рассмотрел каждый вариант в каждом сценарии :)
ОтветитьУдалитьВывод я сделал исходя из рассмотренного и моего опыта разработки. Думаю, что он вполне закономерен.
Нет, я не к тому, что вариант 1 был рассмотрен недостаточно и был "нечестно загнана в угол". Все замечательно. Прост тон статьи такой, что ее следовало бы назвать: "Почему использовать ViewModel почти всегда лучше" (ну, или как-то в этом роде).
ОтветитьУдалитьНа самом деле в нашей команде применялся как раз подобный подход. В качестве ORM мы использовали NHibernate, а там, как знаете есть свои проблемы с передачей доменных объектов на View - зачастую, для того же LazyLoad, надо еще и сессию за собой тащить.
Александр, спасибо за статью!
ОтветитьУдалитьКрайне приятно увидеть что подобные вопросы интересуют наше сообщество. По своему опыту могу сказать что именно 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 сам делает все нужные маппинги и при заполнении данными формы и при сборе данных с формы.
Алекс, покажите код, про который вы рассказываете. Хочу увидеть, как это у вас реализовано.
ОтветитьУдалить> В этом флейме обсуждается использование AutoMapper. Не вполне понимаю зачем он
Он нужен, чтобы из доменного объекта сделать ViewModel и обратно. Binder от MVC такого сделать сам не сможет, особенно, когда ViewModel сложная, а не просто набор примитивных типов.
Спасибо за статью, Александр. Давно задаюсь вопросом - что делать, если во View нужно показать много данных из разных таблиц БД (причем не только выборки, но и аггрегация в том числе)? Мне далеко до эксперта в MVC, но как правило в этой ситуации у меня получается сложный класс ViewModel и несколько запросов к БД. В этот ViewModel через AutoMapper отображаются результаты запросов. Маппинг происходит непосредственно внутри action и контроллер распухает. Что посоветуете в таких случаях?
ОтветитьУдалитьГлеб, я не совсем понимаю из-за чего распухает контроллер. В вашем случае в нем будет:
ОтветитьУдалить// вызов Query
// составление Model (хотя Query может возвращать сразу готовые DTO и эта часть будет отсутствовать)
// return View(model)
dfgdfgdfgdfgdfgdfg
ОтветитьУдалитьПервый вариант из запроса возвращать DTO и складывать их во ViewModel.
ОтветитьУдалитьreturn new View(new ComplexViewModel{
Tags = tagsDto,
Posts = postsDto,
....
});
Второй вариант делать ViewModel вручную, вызывая Automapper.
return new View(new ComplexViewModel{Tags = Automapper.Mat<>(tags),....});
Возможно для какого-то суперсложного объекта и нужен AutoMapper. Но у меня все объекты прекрасно мапятся и без него, исключительно встроенными средствами asp.net mvc
ОтветитьУдалитьВ первом варианте разве не будет жесткого связывания между ViewModel и тем типом, который возвращает запрос?
ОтветитьУдалитьУ меня сейчас как раз второй вариант, с небольшими собственными допилами
Запрос вернет DTO, который будет отображаться на View. Назовите его не DTO, а ViewModel, суть такая же:
ОтветитьУдалитьreturn new View(new ComplexViewModel{Tags = tagsViewModel,Posts = postsViewModel,....});
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; }
}
}
Спасибо, идея понятна.
ОтветитьУдалитьЧто вы делаете, когда надо выводить и забирать вложенные объекты/коллекции? Может ли при этом появляться дублирование в коде, которые "маппит" вложенные объекты/коллекции?
1. На веб форме описываю враппер как модель
ОтветитьУдалить@model Web.Model.Wrappers.JournalEntryWrapper
2. Для рисования данных из враппера на форме
return View(new JournalEntryWrapper(id));
3. Для сбора данных с формы во враппер
[HttpPost]
public ActionResult Create(int id, JournalEntryWrapper entry)
{ .... }
то есть никакой посторонний маппер не требуется.
Коллекции у меня не собираются с форм, но в инете видел примеры и для коллекций. Ничего особенного в этом нет.
Вопрос в том, что делать если у вас идет обращение к вложенным полям сущностей, например, вот такт Post.Comments[1].Author.Name
ОтветитьУдалитьКогда идёт прорисовка данных из враппера на форме, то проблем никаких не вижу. во 2й операции - сбор данных с формы во враппер конечно просто так вложенные объекты не наполнятся. Значит надо подключать всю мощь ООП и своего мозгового аппарата.
ОтветитьУдалитьЗдравствуйте. По этой теме возник такой вопрос:
ОтветитьУдалитьИмеется 3 уровня(представление, БД, и БЛ).
В качестве доступа к БД используем EntityFramework. Но объекты, которые он генерирует мне не нравится, к тому же для их использования надо везде носить с собой всю модель(то есть доступ к БД получается).
Правильно создать еще объекты, аналогичные сгенерированным(но более упрощенные и подстроенные под себя) в отдельной сборочке, которые будут использовать представление и БЛ?
Но БЛ тогда для записи в БД должна перегонять эти объекты в Entity объекты. То есть получается аж 3 типа объектов: для представления, для БЛ и для EntityFramework. Правильно ли все это?)
Я понимаю проблемы, с которыми вы столкнулись. Сам когда-то генерировал объекты по БД
ОтветитьУдалитьhttp://blog.byndyu.ru/2009/12/llblgen-vs-nhibernate.html
Первое, что лучше всего сделать, перейти на Code First.
Если проект очень большой, давно разрабатывается и так просто от генерации не отказаться, то есть несколько вариантов.
1. Использовать то, что уже есть. Передавать сгенерированные объекты в представление и осознать всё командой, что в представлении не надо писать запросы к БД. Как писал Роберт Мартин: Унаследованные системы - это те, которые работают. Поэтому подумайте, стоит ли вам тратить на _эту_ систему время и заниматься ее переработкой.
2. Создать интерфейсы или DTO объекты, которые будут курсировать между слоем доступа к данным (сгенерированным EF) и остальной частью системы. Накладные расходы тут понятны.