ASP.NET Razor: solving some architecture problems for the view model
- Tutorial
Introduction
Hello colleagues!
Today I want to share with you my experience in developing the View Model architecture within the framework of developing web applications on the ASP.NET platform using the Razor template engine .
The technical implementations described in this article are suitable for all current ASP versions . NET ( MVC 5 , Core , etc). The article itself is intended for readers who, at least, have already had experience working under this stack. It is also worth noting that within this we do not consider the very benefit of the View Model. and its hypothetical application (it is assumed that the reader is already familiar with these things), we discuss directly the implementation.
Task
For a convenient and rational assimilation of the material, I propose to immediately consider the problem, which naturally leads us to potential problems and their optimal solutions.
This is the task of simply adding, say, a new car to a catalog of vehicles . In order not to complicate the abstract task, the details of the remaining aspects will be intentionally missed. It would seem an elementary task, however, we will try to do everything with a bias towards further scaling the system (in particular, expanding models with respect to the number of properties and other defining components) in order to work as comfortably as possible later on.
Implementation
Let the model look like this (for the sake of simplicity, such things as navigation properties , etc., are not given in the search ):
classTransport
{
publicint Id { get; set; }
publicint TransportTypeId { get; set; }
publicstring Number { get; set; }
}
Of course, TransportTypeId is a foreign key to an object of type TransportType :
classTransportType
{
publicint Id { get; set; }
publicstring Name { get; set; }
}
For the connection between the frontend and backend, we will use the Data Transfer Object pattern . Accordingly, the DTO for adding a car will look something like this:
classTransportAddDTO
{
[Required]
publicint TransportTypeId { get; set; }
[Required]
[MaxLength(10)]
publicstring Number { get; set; }
}
* Uses standard validation attributes from System.ComponentModel.DataAnnotations
.
It is time to understand what the View Model will be for the car page. Some developers would be happy to announce that TransportAddDTO itself will be such , however, this is fundamentally wrong, because you cannot “stuff” anything other than directly information for the backend needed to add a new element (by definition) to this class. In addition, other data may be required on the add-on page: for example, a directory of vehicle types (on the basis of which TransportTypeId is subsequently expressed ). In this regard, approximately the following View Model suggests itself:
classTransportAddViewModel
{
public IEnumerable<TransportTypeDTO> TransportTypes { get; set; }
}
Where TransportTypeDTO in this case will be a direct mapping of TransportType (and this is not always the case - both in the direction of truncation, and in the direction of expansion):
classTransportTypeDTO
{
publicint Id { get; set; }
publicstring Name { get; set; }
}
At this stage, a reasonable question arises: in Razor, it will be possible to transfer only one model (and thank God), how then can TransportAddDTO be used to generate HTML code inside this page?
Very simple! It is enough to add, in particular, this DTO to the View Model , something like this:
classTransportAddViewModel
{
public TransportAddDTO AddDTO { get; set; }
public IEnumerable<TransportTypeDTO> TransportTypes { get; set; }
}
Now the first problems begin. Let's try to add a standard TextBox for the "vehicle number" to the page in our .cshtml file (let it be TransportAddView.cshtml):
@model TransportAddViewModel
@Html.TextBoxFor(m => m.AddDTO.Number)
This will be rendered in an HTML code similar to the following:
<inputid="AddDTO_Number"name="AddDTO.Number" />
Imagine that the part of the controller with the method of adding transport looks like this (the code is in accordance with MVC 5, for Core it will be slightly different, but the essence is the same ):
[Route("add"), HttpPost]
public ActionResult Add(TransportAddDTO transportAddDto)
{
// Некоторая работа с полученным transportAddDto...
}
Here we see at least two problems:
- Id and Name attributes are prefixed with AddDTO , and later, if the method of adding a transport in a controller using the model binding principle tries to make a binding of the data that came from the client to TransportAddDTO , the object inside will consist entirely of zeros (default values), those. it will be just a new empty copy. It is logical - Binder expected names of the form Number , not AddDTO_Number .
- All meta-attributes disappeared , i.e. data-val-required and all others that we have so carefully described in AddDTO as validation attributes. For those who use the full power of Razor, this is critical, as this is a significant loss of information for the frontend.
We are lucky and they have the appropriate solutions.
These things also work when using, for example, a wrapper for Kendo UI (i.e. @Html.Kendo().TextBoxFor()
, etc.).
Let's start with the second problem: the reason for this lies in the fact that in the View Model the passed TransportAddDTO instance was null . And the implementation of rendering mechanisms is such that the attributes in this case are at least not completely read. The solution, respectively, is obvious - first, in the View Model, initialize the TransportAddDTO property by an instance of the class using the default constructor. It is better to do this in a service that returns an initialized View Model, however, in the framework of the example, it will work like this:
classTransportAddViewModel
{
public TransportAddDTO AddDTO { get; set; } = new TransportAddDTO();
public IEnumerable<TransportTypeDTO> TransportTypes { get; set; }
}
After these changes, the result will be similar to:
<inputdata-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" />
Already better! It remains to deal with the first problem - with her, by the way, everything is somewhat more complicated.
To understand it, it’s worthwhile to start with what Razor (means WebViewPage, an instance of which is available as this inside .cshtml ) is an Html property that we access to call TextBoxFor
.
Looking at him, you can instantly understand that it has a type HtmlHelper<T>
, in our case HtmlHelper<TransportAddViewModel>
. There is a possible solution to the problem - to create your own HtmlHelper inside and pass on our TransportAddDTO to it . Find the smallest possible constructor for an instance of this class:
HtmlHelper<T>.HtmlHelper(ViewContext viewContext, IViewDataContainer viewDataContainer);
ViewContext we can send directly from our WebViewPage instance via this.ViewContext
. Let's figure it out now where to get an instance of the class that implements the IViewDataContainer interface. For example, create your own implementation:
publicclassViewDataContainer<T> : IViewDataContainerwhereT : class
{
public ViewDataDictionary ViewData { get; set; }
publicViewDataContainer(object model)
{
ViewData = new ViewDataDictionary(model);
}
}
As you can see, now we are going to depend on some object that is passed to the constructor in order to initialize the ViewDataDictionary , since all is simple here - this is an instance of our TransportAddDTO from View Model. That is, you can get the coveted copy as follows:
var vdc = new ViewDataContainer<TransportAddDTO>(Model.AddDTO);
Accordingly, the creation of a new HtmlHelper'a also does not cause problems:
var Helper = new HtmlHelper<T>(this.ViewContext, vdc);
Now you can use the following:
@modelTransportAddViewModel
@{
var vdc = newViewDataContainer<TransportAddDTO>(Model.AddDTO);
varHelper = newHtmlHelper<T>(this.ViewContext, vdc);
}
@Helper.TextBoxFor(m => m.Number)
This will be rendered in an HTML code similar to the following:
<inputdata-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" />
As you can see, now with the rendered element there are no problems, and it is ready for full use. It remains only to "comb" the code so that it looks less cumbersome. For example, we expand our ViewDataContainer as follows:
publicclassViewDataContainer<T> : IViewDataContainerwhereT : class
{
public ViewDataDictionary ViewData { get; set; }
publicViewDataContainer(object model)
{
ViewData = new ViewDataDictionary(model);
}
public HtmlHelper<T> GetHtmlHelper(ViewContext context)
{
returnnew HtmlHelper<T>(context, this);
}
}
Then from Razor you can work like this:
@model TransportAddViewModel
@{
var Helper = new ViewDataContainer<TransportAddDTO>(Model.AddDTO).GetHtmlHelper(ViewContext);
}
@Helper.TextBoxFor(m => m.Number)
In addition, no one bothers to expand the standard WebViewPage implementation so that it contains the desired property (with a setter on an instance of the DTO class).
Conclusion
This solved the problems, and also obtained the View Model architecture for working with Razor, which could potentially contain all the necessary elements.
It should be noted that the resulting ViewDataContainer turned out to be universal, and suitable for use.
It remains to add a couple of buttons to our .cshtml file, and the task will be completed (disregarding processing on the backend). I propose to do this yourself.
If a respected reader has ideas on how to implement the desired in more optimal ways, I’ll be happy to hear in the comments.
Sincerely,
Peter Osetrov