Best Practices ViewModel Validation in ASP.NET MVC

2019-01-10 07:23发布

问题:

I am using DataAnnotations to validate my ViewModel on client side with jquery.validate.unobtrusive and on server side in ASP.NET MVC application.

Not so long time ago, I figured out that I can write validation like this:

[Required(ErrorMessage = "{0} is required")]
public string Name { get; set; }

That way I can easily define some general strings in config or in resources and always use it in DataAnnotations. So it will be easier to change validation messages in my whole application in future.

Also I know that there is a FluentValidation library that allows to add validation rules to already existing ViewModel. I know that there is a problem with Add/Edit ViewModels that could have similar fields but different ValidationRules.

Another problem that comes from client validation is that html newly added to DOM (using ajax request) should be parsed to enable validation. This is how I do it:

$('#some-ajax-form').data('validator', null); 
$.validator.unobtrusive.parse('#some-ajax-form');

So I have some questions:

  1. Is there some other useful practises that could help centralize all validation rules in application?
  2. What's is a best way to solve Add/Edit ViewModel Validation problem? Can I use DataAnnotations with FluentValidation or separate Add and Edit ViewModels still is a best option?
  3. Is there any better way to initialize validation on new DOM elements that received with ajax call other that I mention?

I'm not asking how to create my own DataValidators I know how to do it. I seeking of ways how to use them in more productive and easy maintainable way.

回答1:

To answer your 3th question first: No there is no easier way then what you are doing. Two lines of code to get it working can hardly be easier. Although there is a plug-in you could use, like explained in the question unobtrusive validation not working with dynamic content

Your first question, how to centralize validation, I normally use a separate class file to store all my validation rules. This way I don't have to browse through every single class file to find the rules, but have them all in one place. If that's better, is matter of choice. The main reason I started to use it, is to be able to add validation to auto-generated classes, like classes from the Entity Framework.

So I have a file called ModelValidation.cs in my data layer, and have code for all my models like

/// <summary>
/// Validation rules for the <see cref="Test"/> object
/// </summary>
/// <remarks>
/// 2015-01-26: Created
/// </remarks>
[MetadataType(typeof(TestValidation))]
public partial class Test { }
public class TestValidation
{
    /// <summary>Name is required</summary>
    [Required]
    [StringLength(100)]
    public string Name { get; set; }

    /// <summary>Text is multiline</summary>
    [DataType(DataType.MultilineText)]
    [AllowHtml]
    public string Text { get; set; }
}

Now as you noticed I don't provide the actual error message. I use conventions by Haacked to add the messages. It makes it simple to add localized validation rules.

It basically comes down to a recource file containing something like:

Test_Name = "Provide name"
Test_Name_Required = "Name is required"

And these messages and naming will be used when you call regular MVC view code like

<div class="editor-container">
    <div class="editor-label">
        @Html.LabelFor(model => model.Name) <!--"Provide name"-->
    </div>
    <div class="editor-field">
        @Html.EditorFor(model => model.Name)
        @Html.ValidationMessageFor(model => model.Name) <!--"Name is required"-->
    </div>
</div>

Your second question, about different validation for add/edit can be handled in two ways. The best way, would be to use views as they are actually intended. That means you don't pass your actual models to the views, but you create a view model that contains only the data. So you have a view model for Create with the proper validation rules and a view model for Edit with the proper rules, and when they pass you insert the result in your actual model. This however requires a lot more code and manual work, so I can imagine you're not really willing to do it like this.

Another option would be to use conditional validation like explained by viperguynaz. Now instead of a boolean, my classes that require a change between edit/add have a primary key Id int. So I check if Id>0 to determine if it is an edit or not.

UPDATE:

If you want to update validation on every ajax call, you could use jQuery ajaxComplete. This will revalidate all forms after every ajax request.

$( document ).ajaxComplete(function() {
    $('form').each(function() {
        var $el = $(this);
        $el.data('validator', null); 
        $.validator.unobtrusive.parse($el);
    })
});

If this is something you want, depends on how often you receive a form via AJAX. If you have a lot of AJAX request, like polling a status every 10seconds, than you don't want this. If you have an occasional AJAX request, that mostly contains a form, then you could use it.

If your AJAX returns a form you want to validate, then yes, it is good practise to update the validation. But I guess a better question would be "Do I really need to send the form by AJAX?" AJAX is fun and useful, but it should be used with care and thought.



回答2:

Like others have said, there is no such tricks, no easy way to centralize your validations.

I have a couple of approaches that might interest you. Take note that this is how "we" solved the same problem before. Its up to you if you can find our solution maintainable and productive.

I know that there is a problem with Add/Edit ViewModels that could have similar fields but different ValidationRules.

Inheritance Approach

You can achieve centralized validation using a base class, and use subclasses for specific validations.

// Base class. That will be shared by the add and edit
public class UserModel
{
    public int ID { get; set; }
    public virtual string FirstName { get; set; } // Notice the virtual?

    // This validation is shared on both Add and Edit.
    // A centralized approach.
    [Required]
    public string LastName { get; set; }
}

// Used for creating a new user.
public class AddUserViewModel : UserModel
{
    // AddUser has its own specific validation for the first name.
    [Required]
    public override string FirstName { get; set; } // Notice the override?
}

// Used for updating a user.
public class EditUserViewModel : UserModel
{
    public override string FirstName { get; set; }
}

Extending the ValidationAttribute Approach

Using custom ValidationAtribute, you can achieve centralized validation. This is only the basic implementation, I am just showing you the idea.

using System.ComponentModel.DataAnnotations;
public class CustomEmailAttribute : ValidationAttribute
{
    public CustomEmailAttribute()
    {
        this.ErrorMessage = "Error Message Here";
    }

    public override bool IsValid(object value)
    {
        string email = value as string;

        // Put validation logic here.

        return valid;
    }
}

You would use as such

public class AddUserViewModel
{
    [CustomEmail]
    public string Email { get; set; }

    [CustomEmail]
    public string RetypeEmail { get; set; }
}

Is there any better way to initialize validation on new DOM elements that received with ajax call other that I mention?

This is how I rebind validators on dynamic elements.

/** 
* Rebinds the MVC unobtrusive validation to the newly written
* form inputs. This is especially useful for forms loaded from
* partial views or ajax.
*
* Credits: http://www.mfranc.com/javascript/unobtrusive-validation-in-partial-views/
* 
* Usage: Call after pasting the partial view
*
*/
function refreshValidators(formSelector) {
    //get the relevant form 
    var form = $(formSelector);
    // delete validator in case someone called form.validate()
    $(form).removeData("validator");
    $.validator.unobtrusive.parse(form);
};

Usage

// Dynamically load the add-user interface from a partial view.
$('#add-user-div').html(partialView);

// Call refresh validators on the form
refreshValidators('#add-user-div form');


回答3:

Jquery unobtrusive validation works by applying attributes to INPUT elements that instruct the client library to validate that element using a rule that is mapped to the respective attribute. For instance: the data-val-required html attribute is recognized by the unobtrusive library, and causes it to validate that element against the corresponding rule.

In .NET MVC, you can make this happen automatically for some specific rules by applying attributes to your model properties. Attributes like Required and MaxLength work because the Html helpers know how to read those attributes and add corresponding HTML attributes to their output that the unobtrusive library understands.

If you add validation rules to your models in IValidatableObject or using FluentValidation, the HTML Helper will not see these rules, and therefore not try to translate them to unobtrusive attributes.

In other words the "free" coordination you've seen thus far by applying attributes to your model and getting client validation is limited to validation attributes, and further, is limited (by default) only to those attributes that map directly to unobtrusive rules.

The bright side is, you are free to create your own custom validation attributes, and by implementing IClientValidatable, the Html Helper will add an unobtrusive attribute with the name of your choosing that you can then teach the unobtrusive library to respect.

This is a custom attribute we use that ensures that one date falls after another date:

    [AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
public class DateGreaterThanAttribute : ValidationAttribute, IClientValidatable
{
    string otherPropertyName;

    public DateGreaterThanAttribute(string otherPropertyName, string errorMessage = null)
        : base(errorMessage)
    {
        this.otherPropertyName = otherPropertyName;
    }

    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        ValidationResult validationResult = ValidationResult.Success;
        // Using reflection we can get a reference to the other date property, in this example the project start date
        var otherPropertyInfo = validationContext.ObjectType.GetProperty(this.otherPropertyName);
        // Let's check that otherProperty is of type DateTime as we expect it to be
        if (otherPropertyInfo.PropertyType.Equals(new DateTime().GetType()))
        {
            DateTime toValidate = (DateTime)value;
            DateTime referenceProperty = (DateTime)otherPropertyInfo.GetValue(validationContext.ObjectInstance, null);
            // if the end date is lower than the start date, than the validationResult will be set to false and return
            // a properly formatted error message
            if (toValidate.CompareTo(referenceProperty) < 1)
            {
                validationResult = new ValidationResult(this.GetErrorMessage(validationContext));
            }
        }
        else
        {
            // do nothing. We're not checking for a valid date here
        }

        return validationResult;
    }

    public override string FormatErrorMessage(string name)
    {
        return "must be greater than " + otherPropertyName;
    }

    private string GetErrorMessage(ValidationContext validationContext)
    {
        if (!this.ErrorMessage.IsNullOrEmpty())
            return this.ErrorMessage;
        else
        {
            var thisPropName = !validationContext.DisplayName.IsNullOrEmpty() ? validationContext.DisplayName : validationContext.MemberName;
            var otherPropertyInfo = validationContext.ObjectType.GetProperty(this.otherPropertyName);
            var otherPropName = otherPropertyInfo.Name;
            // Check to see if there is a Displayname attribute and use that to build the message instead of the property name
            var displayNameAttrs = otherPropertyInfo.GetCustomAttributes(typeof(DisplayNameAttribute), false);
            if (displayNameAttrs.Length > 0)
                otherPropName = ((DisplayNameAttribute)displayNameAttrs[0]).DisplayName;

            return "{0} must be on or after {1}".FormatWith(thisPropName, otherPropName);
        }
    }

    public IEnumerable<ModelClientValidationRule> GetClientValidationRules(ModelMetadata metadata, ControllerContext context)
    {
        //string errorMessage = this.FormatErrorMessage(metadata.DisplayName);
        string errorMessage = ErrorMessageString;

        // The value we set here are needed by the jQuery adapter
        ModelClientValidationRule dateGreaterThanRule = new ModelClientValidationRule();
        dateGreaterThanRule.ErrorMessage = errorMessage;
        dateGreaterThanRule.ValidationType = "dategreaterthan"; // This is the name the jQuery adapter will use
        //"otherpropertyname" is the name of the jQuery parameter for the adapter, must be LOWERCASE!
        dateGreaterThanRule.ValidationParameters.Add("otherpropertyname", otherPropertyName);

        yield return dateGreaterThanRule;
    }
}

We may apply the attribute to the model as such:

    [DateGreaterThan("Birthdate", "You have to be born before you can die")]
    public DateTime DeathDate { get; set; }

This causes the Html helper to render the following two attributes on the INPUT element when calling Html.EditorFor on a model property that has this attribute:

data-val-dategreaterthan="You have to be born before you can die" 
data-val-dategreaterthan-otherpropertyname="Birthdate" 

So far so good, but now I have to teach unobtrusive validation what to do with those attributes. First, I have to create a named rule for jquery validation:

    // Value is the element to be validated, params is the array of name/value pairs of the parameters extracted from the HTML, element is the HTML element that the validator is attached to
jQuery.validator.addMethod("dategreaterthan", function (value, element, params) {
    return Date.parse(value) > Date.parse($(params).val());
});

And then add an unobtrusive adaptor for that rule that maps the attribute to the rule:

jQuery.validator.unobtrusive.adapters.add("dategreaterthan", ["otherpropertyname"], function (options) {
    options.rules["dategreaterthan"] = "#" + options.params.otherpropertyname;
    options.messages["dategreaterthan"] = options.message;
});

After Ive done all this, I can get this validation rule for "free" anywhere else in my application just by applying that attribute to the model.

To address your question of how to apply rules conditionally based on whether the model is being used in an add or an edit operation: this can probably be done by adding additional logic to your custom attributes and having both the IsValid method the GetClientValidation rules method attempt to glean some context from the model using reflection. But honestly, that seems like a mess to me. For this, I'd just rely on server validation and whatever rules you choose to apply using IValidatableObject.Validate() method.



回答4:

There are various ways to have Client Validation, like one that Microsoft uses for MVC, works with ubobtrusive library created by itself for integrating with DataAnnotations. But, after some years of working with this helpful tool, I tired of it which is boring and tedious to employ in cases that we need separate ViewModels (and likely separate ViewModels for create/edit templates).

Another way is using MVVM which works well with MVC since the two paradigms are quite similar. In MVC you have a Model that is bounded just on the server side when the client send content to the server. While MVVM binds a local model with the UI directly on the client. Take a look at the Knockoutjs, the known one which help you to understand how to work with MVVM.

With this in mind, I'll answer your questions in order:

  1. You can't centralize validation rules in the application unless by creating shared classes and reuse them by calling in separate Models/ViewModels.
  2. If you wanna use Microsoft Validator, separating the Add/Edit ViewModels is a best option because of its readability and easier way to change.
  3. I never said that the Knockoutjs is better, they are different from each other, just gives you some flexibility to create views based on model requirements. This also take you away from centralizing validations :(