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

ASP.NET Razor: решение некоторых проблем с архитектурой для модели представления

image

Введение

Здравствуйте, коллеги!
Сегодня хочу поделиться с вами своим опытом разработки архитектуры 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...
}

Тут мы видим, по меньшей мере, две проблемы:

  1. Id и Name атрибуты имеют префикс AddDTO, и, в последствии, если метод добавления транспорта в контроллере по принципу привязки модели [8] попробует сделать биндинг данных, которые пришли от клиента, в TransportAddDTO, то объект внутри будет состоять полностью из нулей (значений по умолчанию), т.е. это будет просто новый пустой экземпляр. Оно и логично — биндер ожидал имена вида Number, а не AddDTO_Number.
  2. Пропали все мета-атрибуты, т.е. 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

Источник [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