
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 the framework of this we do not consider the very benefit of the View Model and its hypothetical use (it is assumed that the reader is already familiar with these things), we are directly discussing the implementation.
Task
For convenient and rational mastering 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):
class Transport { public int Id { get; set; } public int TransportTypeId { get; set; } public string Number { get; set; } }
Of course, TransportTypeId is a foreign key to an object of type TransportType :
class TransportType { public int Id { get; set; } public string 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:
class TransportAddDTO { [Required] public int TransportTypeId { get; set; } [Required] [MaxLength(10)] public string 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 connection with this, the following View Model suggests itself:
class TransportAddViewModel { 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):
class TransportTypeDTO { public int Id { get; set; } public string 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:
class TransportAddViewModel { 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 "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 into an HTML code similar to the following:
<input id="AddDTO_Number" name="AddDTO.Number" />
Imagine that 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) {
Here we see at least two problems:
- The Id and Name attributes have the AddDTO prefix, and, subsequently, if the method of adding a transport in the controller, according to the model binding principle, tries to make a binding of the data that came from the client to TransportAddDTO , then 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 read at least not completely. 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:
class TransportAddViewModel { public TransportAddDTO AddDTO { get; set; } = new TransportAddDTO(); public IEnumerable<TransportTypeDTO> TransportTypes { get; set; } }
After these changes, the result will be similar to:
<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" />
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 the fact that Razor (implied by WebViewPage, an instance of which is available as this inside .cshtml) is an Html property, which we access to call TextBoxFor
.
Looking at it, you can instantly understand that it is of type HtmlHelper<T>
, in our case HtmlHelper<TransportAddViewModel>
. There is a possible solution to the problem - to create your own HtmlHelper inside, and transfer 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:
public class ViewDataContainer<T> : IViewDataContainer where T : class { public ViewDataDictionary ViewData { get; set; } public ViewDataContainer(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:
@model TransportAddViewModel @{ var vdc = new ViewDataContainer<TransportAddDTO>(Model.AddDTO); var Helper = new HtmlHelper<T>(this.ViewContext, vdc); } @Helper.TextBoxFor(m => m.Number)
This will be rendered into an HTML code similar to the following:
<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" />
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 extend our ViewDataContainer as follows:
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); } }
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 can 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.
Respectfully,
Peter Osetrov