Здравствуйте, коллеги!
Сегодня хочу поделиться с вами своим опытом разработки архитектуры View Model в рамках разработки веб-приложений на платформе ASP.NET, используя шаблонизатор Razor.
Описываемые в данной статье технические реализации подходят для всех актуальных на текущей момент версий ASP. NET (MVC 5, Core, etc). Сама статья предназначена для читателей, которые, по меньшей мере, уже имели опыт работы под данным стеком. Также стоит отметить, что в рамках данной мы не рассматриваем саму пользу View Model и её гипотетическое применение (предполагается, что читатель уже знаком с данными вещами), обсуждаем непосредственно реализацию. Для удобного и рационального усвоения материала предлагаю сразу рассмотреть задачу, которая естественным образом приведет нас к потенциальным проблемам и их оптимальным решениям.
Это задача о банальном добавлении, скажем, нового автомобиля в некоторый каталог транспортных средств. Дабы не усложнять абстрактную задачу, подробности остальных аспектов будут намеренно упущены. Казалось бы, элементарная задача, однако, попытаемся сделать все с уклоном на дальнейшее масштабирование системы (в частности, расширение моделей относительно кол-ва свойств и других определяющих компонент), чтобы впоследствии работать было максимально комфортно. Пусть модель выглядит следующим образом (простоты ради в искомой не приведены такие вещи как навигационные свойства и прочее): Разумеется, TransportTypeId — внешний ключ на объект типа TransportType: Для связи между frontend и backend будем использовать шаблон Data Transfer Object. Соответственно, DTO для добавления автомобиля будет выглядеть примерно следующим образом: * Используются стандартные атрибуты валидации из System.ComponentModel.DataAnnotations. Настало время понять, что же будет View Model для страницы добавления автомобиля. Некоторые разработчики с радостью бы объявили, что таковой будет являться сам TransportAddDTO, однако, это в корне неверно, так как в данный класс нельзя "запихивать" ничего кроме непосредственно информации для backend, необходимой для добавления нового элемента (по определению). А помимо этого на странице добавления могут потребоваться и другие данные: например, справочник типов транспортных средств (на основе которого и выражается впоследствии TransportTypeId). В связи с этим напрашивается примерно следующая View Model: Где TransportTypeDTO в данном случае будет прямым отображением TransportType (а это далеко не всегда так — как в сторону усечения, так и в сторону расширения): На данном этапе встает резонный вопрос: в Razor можно будет передать только одну модель (и слава богу), как же тогда использовать TransportAddDTO для генерации HTML-кода внутри данной страницы?
Очень просто! Достаточно в View Model добавить, в частности, данный DTO, примерно так: Теперь то и начинаются первые проблемы. Попробуем добавить стандартный TextBox для "номера ТС" на страницу в нашем .cshtml файле (пусть это будет TransportAddView.cshtml):  Это отрендерится в HTML-код примерно следующего содержания: Представим, что часть контроллера с методом добавления транспорта выглядит так (код в соответствии с MVC 5, для Core он будет чуть-чуть отличаться, но суть такая же): Тут мы видим, по меньшей мере, две проблемы: Данные вещи "работают" и при использовании, например, враппера для Kendo UI (т.е. @Html.Kendo().TextBoxFor() и др.). Начнем со второй проблемы: причина тут кроется в том, что в View Model переданный экземпляр TransportAddDTO имел значение null. А реализация механизмов рендеринга такова, что атрибуты при таком случае считываются по меньшей мере не полностью. Решение, соответственно, очевидно — предварительно во View Model инициализировать свойство TransportAddDTO экземпляром класса с помощью конструктора по умолчанию. Лучше это сделать в сервисе, который возвращает инициализированную View Model, однако, в рамках примера подойдет и так: После данных изменений результат будет похож на: Уже лучше! Осталось разобраться с первой проблемой — с ней, кстати, всё несколько сложнее.
Для её понимания для начала стоит разобраться что в Razor (подразумевается WebViewPage, экземпляр которого внутри .cshtml доступен как this) представляет собой свойство Html, к которому мы обращаемся с целью вызова TextBoxFor.
Посмотрев на него, можно мгновенно понять, что оно имеет тип HtmlHelper<T>, в нашем случае HtmlHelper<TransportAddViewModel>. Возникает возможное решение проблемы — создать внутри свой HtmlHelper, и передать ему на вход наш TransportAddDTO. Находим минимально возможный конструктор для экземпляра данного класса: ViewContext мы можем передать напрямую из нашего экземпляра WebViewPage через this.ViewContext. Разберемся теперь, где взять экземпляр класса, реализующего интерфейс IViewDataContainer. Например, создадим свою реализацию: Как можно заметить, теперь мы упираемся в зависимость от некоторого объекта, передаваемого в конструктор с целью инициализации ViewDataDictionary, благо тут всё просто — это и есть экземпляр нашего TransportAddDTO из View Model. То есть получить заветный экземпляр можно так: Соответственно, в создании нового HtmlHelper'a также проблем не возникает: Теперь можно воспользоваться следующим образом: Это отрендерится в HTML-код примерно следующего содержания: Как можно заметить, теперь с отрендеренным элементом никаких проблем нет, и он готов к полноценному использованию. Осталось только "причесать" код, дабы он выглядел менее громоздко. Например, расширим наш ViewDataContainer следующим образом: Тогда из Razor можно работать вот так: К тому же, никто не мешает расширить стандартную реализацию WebViewPage таким образом, чтобы она содержала нужное свойство (с сеттером по экземпляру класса DTO). На этом проблемы решены, а также получена архитектура View Model для работы с Razor, которая потенциально может содержать в себе все необходимые элементы. Стоит отметить, что получившийся ViewDataContainer получился универсальным, и пригоден для использования. Осталось добавить пару кнопок в наш .cshtml файл, и задача будет выполнена (не учитывая обработки на backend'e). Это я предлагаю сделать самостоятельно. Если у уважаемого читателя есть идеи как искомое реализовать более оптимальными способами — с радостью выслушаю в комментариях. С уважением,
Петр Осетров Software Engineer