ASP.NET MVC+VM: разбиение сложных представлений на простые с применением моделей видов на примере календаря мероприятий

в 10:05, , рубрики: .net, ASP, ASP.NET, asp.net core, asp.net mvc, mvc

Я решил написать на эту тему, т. к. постоянно использую модели видов (view models) в веб-приложениях на ASP.NET MVC, и часто приходится объяснять суть такого подхода коллегам, а подходящего материала, чтобы ссылаться на него, мы так и не нашли. Эта статья ориентирована прежде всего на новичков.

Представим, что нам необходимо отобразить календарь некоторых мероприятий на текущий месяц. Это достаточно сложная конструкция. Календарь должен содержать заголовок с названием текущего месяца и годом, строку с названиями дней и, собственно, сами дни (6 рядов по 7 дней), каждый из которых имеет дату и, опционально, некоторый набор мероприятий, названия которых необходимо отобразить, предварительно загрузив их из базы данных. Также предположим, что выходные и праздничные дни должны быть отмечены особым образом. Т. е. в итоге должно получиться нечто такое:

ASP.NET MVC+VM: разбиение сложных представлений на простые с применением моделей видов на примере календаря мероприятий - 1

Немного теории

Шаблон проектирования MVC и его концепция разделения приложения на 3 части (модель, представление и контроллер) знакомы, наверное, каждому разработчику.

Под моделью можно понимать как просто лишь данные (объекты предметной области, которые часто так и называются — модели), так и данные совокупно с некой логикой их обработки. (Хранение же данных должно быть выделено в отдельный, независимый слой.) Т. е. в идеале у нас будет модель, которая существует и функционирует независимо от того, каким образом ее данные отображаются и хранятся.

Рассмотрим для примера интернет-магазины. Зачастую в подобных проектах фронтенд занимает лишь малую часть всего функционала, основная часть которого сосредоточена в различных административных подсистемах, таких как учет заказов и взаимодействие с клиентами, складской учет, аналитика, API и так далее. Очевидно, что функционирование таких подсистем (которые, кстати, запросто могут быть выполненными и виде отдельных приложений) в контексте единой и общей модели является очень важным. Реализация таких функций, как подсчет стоимости конкретной позиции (с учетом текущих акций и скидок) или общей суммы заказа, должна быть единой для всего проекта.

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

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

Для изоляции и повторного использования кода инициализации моделей видов идеально подходят строители моделей видов (view model builders) — параллельная иерархия классов, порождающих объекты моделей видов соответствующих типов. Строители родительских моделей видов могут использовать строителей дочерних моделей видов, чтобы строить всю необходимую иерархию, вызывая друг друга по цепочке, сверху вниз. Обратим внимание, что для простых моделей видов, где инициализация сводится лишь к установке получаемых от контроллера значений, применение строителя является излишним и громоздким — в таком случае хватит и обычного конструктора.

На этом, думаю, пора переходить к практике.

Практика

Вернемся теперь к нашему календарю мероприятий как к более простому примеру. Для начала, подготовим пустое веб-приложение на ASP.NET MVC (я буду использовать ASP.NET Core, чтобы заодно продемонстрировать возможности новой платформы, но в контексте нашего примера это не имеет значения). Добавим в него единственный контроллер DefaultController с единственным действием (action) Calendar в нем — оно будет отвечать за отображение календаря. Добавим также соответствующее представление (пока что без всякого содержимого). Если сейчас запустить наше приложение мы должны получить пустую страницу. (В конце статьи вы найдете ссылку на готовый тестовый проект, выложенный на GitHub.)

Как мы уже убедились выше, чтобы передать все необходимые данные нашему представлению, нам потребуется соответствующая модель вида. Назовем ее CalendarViewModel. (Модели вида очень удобно размещать в папке ViewModels проекта, повторяя структуру папки Views; позже я приведу соответствующий скриншот.) Сразу добавим в нее очевидное свойство Date типа DateTime. Оно потребуется нам для отображения текущих месяца и года. Должен получиться вот такой класс:

public class CalendarViewModel
{
  public DateTime Date { get; set; }
}

Теперь добавим строитель для нашей модели вида (сейчас в нем особой необходимости нет, но позже она появится — сделаем это заранее):

public class CalendarViewModelBuilder
{
  public CalendarViewModel Build()
  {
    return new CalendarViewModel()
    {
      Date = DateTime.Now
    };
  }
}

Как видим, метод Build строителя не принимает параметров и возвращает новый объект класса CalendarViewModel — готовую модель вида.

Теперь укажем наш класс CalendarViewModel в качестве модели вида для представления Calendar и добавим отображение месяца и года из этой модели вида:

@model AspNetCoreViewModels.ViewModels.Default.Calendar.CalendarViewModel
<div class="calendar">
  <div class="header">
    @Model.Date.ToString("MMMM yyyy")
  </div>
</div>

Далее воспользуемся строителем CalendarViewModelBuilder для передачи модели вида представлению. Наш контроллер должен принять следующий вид:

public class DefaultController : Controller
{
  public ActionResult Calendar()
  {
    return this.View(new CalendarViewModelBuilder().Build());
  }
}

Теперь мы можем запустить приложение снова, и на этот раз кое-что уже буде отображаться (я немного повозился со стилями, поэтому будущий календарь уже имеет некоторое оформление):

ASP.NET MVC+VM: разбиение сложных представлений на простые с применением моделей видов на примере календаря мероприятий - 2

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

@model AspNetCoreViewModels.ViewModels.Default.Calendar.CalendarViewModel
<div class="calendar">
  <div class="header">
    @Model.Date.ToString("MMMM yyyy")
  </div>
  <table cellpadding="0" cellspacing="0">
    <tr>
      <th>Пн</th>
      <th>Вт</th>
      <th>Ср</th>
      <th>Чт</th>
      <th>Пт</th>
      <th>Сб</th>
      <th>Вс</th>
    </tr>
  </table>
</div>

В браузере это выглядит так:

ASP.NET MVC+VM: разбиение сложных представлений на простые с применением моделей видов на примере календаря мероприятий - 3

Теперь настал черед самого интересного — отображения дней. Для этого добавим отдельное частичное представление _Day и модель вида DayViewModel для него. (Также вполне возможно, что в дальнейшем в нашем проекте мы могли бы захотеть отображать дни с запланированными мероприятиями независимо от календаря. Например, как отдельный блок мероприятий на сегодня. Будем иметь это в виду.)

Пока что добавим в класс DayViewModel свойство Date типа DateTime и еще 3 свойства типа bool — IsNotCurrentMonth, IsWeekendOrHoliday и IsToday:

public class DayViewModel
{
  public DateTime Date { get; set; }
  public bool IsNotCurrentMonth { get; set; }
  public bool IsWeekendOrHoliday { get; set; }
  public bool IsToday { get; set; }
}

Метод Build строителя на этот раз принимает один параметр — дату:

public class DayViewModelBuilder
{
  public DayViewModel Build(DateTime date)
  {
    return new DayViewModel()
    {
      Date = date,
      IsNotCurrentMonth = date.Month != DateTime.Now.Month,
      IsWeekendOrHoliday = date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday,
      IsToday = date.Date == DateTime.Now.Date
    };
  }
}

Как видим, строитель инициализирует все свойства модели вида. Флаг IsNotCurrentMonth определяет, находится ли день вне текущего месяца (чтобы иметь возможность выделить его серым цветом). Установленный IsWeekendOrHoliday означает, что день является выходным или праздничным, а IsToday — сегодняшним (соответственно, мы будем выделять такие дни красным или зеленым).

Частичное представление _Day может выглядеть следующим образом (обратите внимание, мы специально не используем здесь тег td, чтобы это частичное представление можно было использовать отдельно от календаря):


<div class="day @(this.Model.IsNotCurrentMonth ? "not-current-month" : null) @(this.Model.IsWeekendOrHoliday ? "weekend-or-holiday" : null) @(this.Model.IsToday ? "today" : null)">
  <div class="date">
    @Model.Date.Day.ToString("00")
  </div>
</div>

В зависимости от значения свойств IsNotCurrentMonth, IsWeekendOrHoliday и IsToday устанавливаются соответствующие CSS-классы.

Осталось добавить набор моделей видов дней в модель вида календаря и сделать так, чтобы строитель модели вида календаря инициализировал этот набор.

Размышляя о том, какую логику лучше реализовать в строителе модели вида, а какую — непосредственно в представлении, следует исходить из результатов простого теста: придется ли заново дублировать эту логику, если потребуется заменить представление? Если да, то логику следует размещать в строителе модели вида. Если нет — непосредственно в представлении (это означает, что код слишком специфичен и относится только к конкретному способу отображения). Хотя если под логикой подразумевается нечто действительно объемное, то возможно лучшим решением будет сделать дополнительную модель вида под конкретное представление и перенести эту логику в метод Build ее строителя. В нашем случае мы могли бы представить дни в виде массива из 42 элементов (6 рядов по 7 дней), но в таком случае в представлении Calendar нам потребуется логика для разбиения этого массива на строки. Поэтому, пожалуй, уместнее будет сразу сделать массив двумерным (если только мы не предполагаем, что в дальнейшем потребуется выводить дни как-то иначе, чем таблицей):

public class CalendarViewModel
{
  public DateTime Date { get; set; }
  public DayViewModel[,] Days { get; set; }
}

Метод Build строителя этой модели вида теперь можно дополнить примерно такой логикой:

DayViewModel[,] days = new DayViewModel[6,7];
DateTime date = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1);
int offset = (int)date.DayOfWeek;

if (offset == 0)
  offset = 7;

offset--;
date = date.AddDays(offset * -1);

for (int i = 0; i != 6; i++)
{
  for (int j = 0; j != 7; j++)
  {
    days[i, j] = new DayViewModelBuilder().Build(date);
    date = date.AddDays(1);
  }
}

Запустим приложение и посмотрим, что получилось:

ASP.NET MVC+VM: разбиение сложных представлений на простые с применением моделей видов на примере календаря мероприятий - 4

Почти готово. Теперь разберемся с мероприятиями. Во-первых, нам потребуется добавить класс Event в нашу модель:

public class Event
{
  public int Id { get; set; }
  public DateTime Date { get; set; }
  public string Name { get; set; }
}

Во-вторых, мы добавим некий фейковый слой доступа к данным, который на самом деле просто будет возвращать предопределенные объекты для заданных дат. Не буду останавливаться на этом подробно, его реализацию (с использованием шаблонов Единица работы и Репозиторий в простейшем виде + использование встроенного в ASP.NET Core DI) можно посмотреть в тестовом проекте.

Сразу же добавим модель вида EventViewModel, строитель для нее и частично представление _Event.

Класс EventViewModel:

public class EventViewModel
{
  public DateTime Date { get; set; }
  public string Name { get; set; }
}

Метод Build класса EventViewModelBuilder:

public EventViewModel Build(Event @event)
{
  return new EventViewModel()
  {
    Date = @event.Date,
    Name = @event.Name
  };
}

Как видим, в данном случае набор свойств модели вида практически идентичен набор свойств модели, поэтому, чтобы не проецировать вручную каждое свойство одного класса на свойство другого класса, нам следовало бы воспользоваться чем-то вроде AutoMapper.

Частичное представление _Event:

@model AspNetCoreViewModels.ViewModels.Shared.EventViewModel
<div class="event">
  @Model.Date.ToString("HH:mm")<br />
  @Model.Name
</div>

Очевидно, что все это может использоваться повторно, вне зависимости от дней или календаря.

Чтобы закончить наше приложение нам необходимо добавить свойство Events типа IEnumerable в класс DayViewModel и проинициализировать его в строителе модели вида дня. Для этого нам потребуется, чтобы строитель модели вида дня мог обращаться к слою доступа к данным, который у нас представлен реализацией шаблона Единица работы. Я не хотел бы касаться сейчас этого детально, чтобы не увеличивать и без того большую статью. Вкратце, все обращения к слою доступа к данным в рамках одного запроса к контроллеру должны происходить контексте единственного экземпляра класса нашей единицы работы. Т. е. такой экземпляр должен быть создан при создании объекта контроллера (например, с помощью DI) и передан во все строители моделей видов по цепочке. Поэтому я добавил еще один абстрактный класс ViewModelBuilderBase, конструктор которого принимает один аргумент storage типа IStorage и сохраняет его в защищенной переменной, чтобы все наследники имели к нему доступ. Теперь метод Build класса DayViewModelBuilder может быть дополнен инициализацией свойства Events:

Events = this.Storage.EventRepository.FilteredByDate(date).Select(
  e => new EventViewModelBuilder(this.Storage).Build(e)
)

Как видим, мы обращаемся к репозиторию EventRepository и с помощью его метода FilteredByDate отбираем все мероприятия на заданную дату, а затем при помощи метода LINQ Select и строителя EventViewModelBuilder проецируем каждый объект модели Event на объект модели вида EventViewModel.

Если теперь добавить вывод мероприятий в частичное представление _Day, то наше приложение будет готово и запустив его мы получим то, что было изображено на первом скриншоте:

<div class="events">
  @foreach (var @event in this.Model.Events)
  {
    @Html.Partial("_Event", @event)
  }
</div>

Вот и все. Итоговая структура проекта:

ASP.NET MVC+VM: разбиение сложных представлений на простые с применением моделей видов на примере календаря мероприятий - 5

Заключение

Я выложил этот проект на GitHub, чтобы можно было посмотреть на него вживую. Повторюсь, он реализован на ASP.NET Core. Вот здесь можно найти все что необходимо, чтобы его запустить.

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

Автор: DmitrySikorsky

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js