Введение
Здравствуйте, коллеги!
Сегодня хочу поделиться с вами своим опытом разработки архитектуры View Model в рамках разработки веб-приложений на платформе ASP.NET, используя шаблонизатор Razor.
Описываемые в данной статье технические реализации подходят для всех актуальных на текущей момент версий ASP. NET (MVC 5, Core, etc). Сама статья предназначена для читателей, которые, по меньшей мере, уже имели опыт работы под данным стеком. Также стоит отметить, что в рамках данной мы не рассматриваем саму пользу View Model и её гипотетическое применение (предполагается, что читатель уже знаком с данными вещами), обсуждаем непосредственно реализацию.
Задача
Для удобного и рационального усвоения материала предлагаю сразу рассмотреть задачу, которая естественным образом приведет нас к потенциальным проблемам и их оптимальным решениям.
Это задача о банальном добавлении, скажем, нового автомобиля в некоторый каталог транспортных средств. Дабы не усложнять абстрактную задачу, подробности остальных аспектов будут намеренно упущены. Казалось бы, элементарная задача, однако, попытаемся сделать все с уклоном на дальнейшее масштабирование системы (в частности, расширение моделей относительно кол-ва свойств и других определяющих компонент), чтобы впоследствии работать было максимально комфортно.
Реализация
Пусть модель выглядит следующим образом (простоты ради в искомой не приведены такие вещи как навигационные свойства и прочее):
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. Соответственно, 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...
}
Тут мы видим, по меньшей мере, две проблемы:
- Id и Name атрибуты имеют префикс AddDTO, и, в последствии, если метод добавления транспорта в контроллере по принципу привязки модели попробует сделать биндинг данных, которые пришли от клиента, в TransportAddDTO, то объект внутри будет состоять полностью из нулей (значений по умолчанию), т.е. это будет просто новый пустой экземпляр. Оно и логично — биндер ожидал имена вида Number, а не AddDTO_Number.
- Пропали все мета-атрибуты, т.е. data-val-required и все другие, которые мы так тщательно описывали в AddDTO в виде атрибутов валидации. Для тех кто использует всю мощь Razor это критично, так как это существенная потеря информации для frontend.
Нам повезло, и они имеют соответственные решения.
Данные вещи "работают" и при использовании, например, враппера для 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