- PVSM.RU - https://www.pvsm.ru -

Здравствуйте, коллеги!
Сегодня хочу поделиться с вами своим опытом разработки архитектуры View Model [1] в рамках разработки веб-приложений на платформе ASP.NET [2], используя шаблонизатор Razor [3].
Описываемые в данной статье технические реализации подходят для всех актуальных на текущей момент версий ASP. NET (MVC 5 [4], Core [5], etc). Сама статья предназначена для читателей, которые, по меньшей мере, уже имели опыт работы под данным стеком. Также стоит отметить, что в рамках данной мы не рассматриваем саму пользу View Model и её гипотетическое применение (предполагается, что читатель уже знаком с данными вещами), обсуждаем непосредственно реализацию.
Для удобного и рационального усвоения материала предлагаю сразу рассмотреть задачу, которая естественным образом приведет нас к потенциальным проблемам и их оптимальным решениям.
Это задача о банальном добавлении, скажем, нового автомобиля в некоторый каталог транспортных средств. Дабы не усложнять абстрактную задачу, подробности остальных аспектов будут намеренно упущены. Казалось бы, элементарная задача, однако, попытаемся сделать все с уклоном на дальнейшее масштабирование системы (в частности, расширение моделей относительно кол-ва свойств и других определяющих компонент), чтобы впоследствии работать было максимально комфортно.
Пусть модель выглядит следующим образом (простоты ради в искомой не приведены такие вещи как навигационные свойства [6] и прочее):
class Transport
{
public int Id { get; set; }
public int TransportTypeId { get; set; }
public string Number { get; set; }
}
Разумеется, TransportTypeId — внешний ключ на объект типа TransportType:
class TransportType
{
public int Id { get; set; }
public string Name { get; set; }
}
Для связи между frontend и backend будем использовать шаблон Data Transfer Object [7]. Соответственно, DTO для добавления автомобиля будет выглядеть примерно следующим образом:
class TransportAddDTO
{
[Required]
public int TransportTypeId { get; set; }
[Required]
[MaxLength(10)]
public string Number { get; set; }
}
* Используются стандартные атрибуты валидации из System.ComponentModel.DataAnnotations.
Настало время понять, что же будет View Model для страницы добавления автомобиля. Некоторые разработчики с радостью бы объявили, что таковой будет являться сам TransportAddDTO, однако, это в корне неверно, так как в данный класс нельзя "запихивать" ничего кроме непосредственно информации для backend, необходимой для добавления нового элемента (по определению). А помимо этого на странице добавления могут потребоваться и другие данные: например, справочник типов транспортных средств (на основе которого и выражается впоследствии TransportTypeId). В связи с этим напрашивается примерно следующая View Model:
class TransportAddViewModel
{
public IEnumerable<TransportTypeDTO> TransportTypes { get; set; }
}
Где TransportTypeDTO в данном случае будет прямым отображением TransportType (а это далеко не всегда так — как в сторону усечения, так и в сторону расширения):
class TransportTypeDTO
{
public int Id { get; set; }
public string Name { get; set; }
}
На данном этапе встает резонный вопрос: в Razor можно будет передать только одну модель (и слава богу), как же тогда использовать TransportAddDTO для генерации HTML-кода внутри данной страницы?
Очень просто! Достаточно в View Model добавить, в частности, данный DTO, примерно так:
class TransportAddViewModel
{
public TransportAddDTO AddDTO { get; set; }
public IEnumerable<TransportTypeDTO> TransportTypes { get; set; }
}
Теперь то и начинаются первые проблемы. Попробуем добавить стандартный TextBox для "номера ТС" на страницу в нашем .cshtml файле (пусть это будет TransportAddView.cshtml):
@model TransportAddViewModel
@Html.TextBoxFor(m => m.AddDTO.Number)
Это отрендерится в HTML-код примерно следующего содержания:
<input id="AddDTO_Number" name="AddDTO.Number" />
Представим, что часть контроллера с методом добавления транспорта выглядит так (код в соответствии с MVC 5, для Core он будет чуть-чуть отличаться, но суть такая же):
[Route("add"), HttpPost]
public ActionResult Add(TransportAddDTO transportAddDto)
{
// Некоторая работа с полученным transportAddDto...
}
Тут мы видим, по меньшей мере, две проблемы:
Данные вещи "работают" и при использовании, например, враппера для Kendo UI (т.е. @Html.Kendo().TextBoxFor() и др.).
Начнем со второй проблемы: причина тут кроется в том, что в View Model переданный экземпляр TransportAddDTO имел значение null. А реализация механизмов рендеринга такова, что атрибуты при таком случае считываются по меньшей мере не полностью. Решение, соответственно, очевидно — предварительно во View Model инициализировать свойство TransportAddDTO экземпляром класса с помощью конструктора по умолчанию. Лучше это сделать в сервисе, который возвращает инициализированную View Model, однако, в рамках примера подойдет и так:
class TransportAddViewModel
{
public TransportAddDTO AddDTO { get; set; } = new TransportAddDTO();
public IEnumerable<TransportTypeDTO> TransportTypes { get; set; }
}
После данных изменений результат будет похож на:
<input data-val="true" id="AddDTO_Number" name="AddDTO.Number" data-val-required="The Number field is required." data-val-length="The field Number must be a string with a maximum length of 10." data-val-length-max="10" />
Уже лучше! Осталось разобраться с первой проблемой — с ней, кстати, всё несколько сложнее.
Для её понимания для начала стоит разобраться что в Razor (подразумевается WebViewPage, экземпляр которого внутри .cshtml доступен как this) представляет собой свойство Html, к которому мы обращаемся с целью вызова TextBoxFor.
Посмотрев на него, можно мгновенно понять, что оно имеет тип HtmlHelper<T>, в нашем случае HtmlHelper<TransportAddViewModel>. Возникает возможное решение проблемы — создать внутри свой HtmlHelper, и передать ему на вход наш TransportAddDTO. Находим минимально возможный конструктор для экземпляра данного класса:
HtmlHelper<T>.HtmlHelper(ViewContext viewContext, IViewDataContainer viewDataContainer);
ViewContext мы можем передать напрямую из нашего экземпляра WebViewPage через this.ViewContext. Разберемся теперь, где взять экземпляр класса, реализующего интерфейс IViewDataContainer. Например, создадим свою реализацию:
public class ViewDataContainer<T> : IViewDataContainer where T : class
{
public ViewDataDictionary ViewData { get; set; }
public ViewDataContainer(object model)
{
ViewData = new ViewDataDictionary(model);
}
}
Как можно заметить, теперь мы упираемся в зависимость от некоторого объекта, передаваемого в конструктор с целью инициализации ViewDataDictionary, благо тут всё просто — это и есть экземпляр нашего TransportAddDTO из View Model. То есть получить заветный экземпляр можно так:
var vdc = new ViewDataContainer<TransportAddDTO>(Model.AddDTO);
Соответственно, в создании нового HtmlHelper'a также проблем не возникает:
var Helper = new HtmlHelper<T>(this.ViewContext, vdc);
Теперь можно воспользоваться следующим образом:
@model TransportAddViewModel
@{
var vdc = new ViewDataContainer<TransportAddDTO>(Model.AddDTO);
var Helper = new HtmlHelper<T>(this.ViewContext, vdc);
}
@Helper.TextBoxFor(m => m.Number)
Это отрендерится в HTML-код примерно следующего содержания:
<input data-val="true" id="Number" name="Number" data-val-required="The Number field is required." data-val-length="The field Number must be a string with a maximum length of 10." data-val-length-max="10" />
Как можно заметить, теперь с отрендеренным элементом никаких проблем нет, и он готов к полноценному использованию. Осталось только "причесать" код, дабы он выглядел менее громоздко. Например, расширим наш ViewDataContainer следующим образом:
public class ViewDataContainer<T> : IViewDataContainer where T : class
{
public ViewDataDictionary ViewData { get; set; }
public ViewDataContainer(object model)
{
ViewData = new ViewDataDictionary(model);
}
public HtmlHelper<T> GetHtmlHelper(ViewContext context)
{
return new HtmlHelper<T>(context, this);
}
}
Тогда из Razor можно работать вот так:
@model TransportAddViewModel
@{
var Helper = new ViewDataContainer<TransportAddDTO>(Model.AddDTO).GetHtmlHelper(ViewContext);
}
@Helper.TextBoxFor(m => m.Number)
К тому же, никто не мешает расширить стандартную реализацию WebViewPage таким образом, чтобы она содержала нужное свойство (с сеттером по экземпляру класса DTO).
На этом проблемы решены, а также получена архитектура View Model для работы с Razor, которая потенциально может содержать в себе все необходимые элементы.
Стоит отметить, что получившийся ViewDataContainer получился универсальным, и пригоден для использования.
Осталось добавить пару кнопок в наш .cshtml файл, и задача будет выполнена (не учитывая обработки на backend'e). Это я предлагаю сделать самостоятельно.
Если у уважаемого читателя есть идеи как искомое реализовать более оптимальными способами — с радостью выслушаю в комментариях.
С уважением,
Петр Осетров
Автор: ParadoxFilm
Источник [9]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/c-2/285219
Ссылки в тексте:
[1] View Model: https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel
[2] ASP.NET: https://en.wikipedia.org/wiki/ASP.NET
[3] Razor: https://en.wikipedia.org/wiki/ASP.NET_Razor
[4] MVC 5: https://docs.microsoft.com/ru-ru/aspnet/mvc/mvc5
[5] Core: https://docs.microsoft.com/ru-ru/aspnet/core/?view=aspnetcore-2.1
[6] навигационные свойства: https://msdn.microsoft.com/ru-ru/library/bb738520(v=vs.100).aspx
[7] Data Transfer Object: https://ru.wikipedia.org/wiki/DTO
[8] принципу привязки модели: https://docs.microsoft.com/ru-ru/aspnet/core/mvc/models/model-binding?view=aspnetcore-2.1
[9] Источник: https://habr.com/post/416315/?utm_campaign=416315
Нажмите здесь для печати.