В ASP.NET MVC метаданные — атрибуты, описывающие поля модели, используются как при генерации разметки (вывод названия поля, его заполнителя и т.д.), так и при валидации данных (вывод правил валидации). Условно можно выделить 2 вида валидации:
Клиентская валидация хороша тем, что пользователь сразу же видит допущенные ошибки в заполнении полей и может внести поправки без необходимости отправлять данные серверу (ненавязчивая валидация). Именно этот тип валидации необходим в нашем случае.
Итак, необходимо реализовать автоматический вывод метаданных модели MVC на клиентскую сторону и ненавязчивую валидацию.
Идея передачи метаданных через http заголовок взята из этой статьи.
Подробнее с библиотекой backbone-validation.js можно познакомиться здесь.
На сервере напишем 2 фильтра:
Стоит обратить внимание на то, что фильтры работаю с данными, отправляемыми клиенту в Json формате.
на клиенте создадим:
Метод parse переопределяем для того, что бы из http заголовка перехватить метаданные и правила валидации, которые затем будут использоваться Backbone библиотекой backbone-validation.js
Здесь в Backbone View во первых инициализируется точка входа в библиотеку валидации — backbone-validation.js:
Во вторых инициализируются callback функции (valid, invalid), необходимые для подсветки ошибок. Так же здесь инициализируется атрибут удаленной валидации:
На клиентской стороне атрибут удаленной валидации представляет из себя лишь ajax метод (тип метода можно указать в описании модели на серверной стороне), принимающий в качестве ответа переменную, указывающию состояние валидируемого поля:
Удаленная валидация необходима в том случае, когда валидацию невозможно или по каким-то причинам затруднительно сделать на клиентской стороне. В коде на серверной стороне атрибут удаленной валидации описывается следующим образом
На серверной стороне необходимо создать метод, который будет отправлять данные в Json формате. К нему и применим созданные фильтры:
В качестве параметров в фильтрах указывается название http заголовка. Эти же названия используются на клиенте в методе parse.
На клиентской стороне создадим Backbone модель FriendModel наследованную от созданной базовой Backbone модели DataMetaModel, в которой переопределен метод parse:
Так же создадим Backbone View NewFriend, наследованный от созданного базового Backbone View dataMetaView:
Здесь в методе render после выполнения всех действий необходимо вызвать базовый метод render
Создадим модель Friend:
в котроллере к методу, который возвращает модель добавим два фильтра:
на клиенте создадим модель и View, указанные в пункте использование, а так же определим шаблон, который будет использоваться View для генерации динамической разметки:
Точкой входа на странице служит скрипт:




- клиентская валидация
- серверная валидация
Клиентская валидация хороша тем, что пользователь сразу же видит допущенные ошибки в заполнении полей и может внести поправки без необходимости отправлять данные серверу (ненавязчивая валидация). Именно этот тип валидации необходим в нашем случае.
в чем собственно проблема ?
При использовании классического подхода к генерации разметки все работает автоматически, но что если мы используем ajax и формируем html разметку динамически на клиенте? В этом случае автоматически ничего не добавится в разметку. Можно конечно же все необходимое добавить вручную и казалось бы проблема исчерпана, но здесь встает проблема дублирования кода, так как одни и те же данные приходится описывать дважды — на сервере и на клиенте, что в свою очередь влечет другие проблемы. В ряде случаев динамическая разметка очень удобна, но здесь встает вопрос о выводе метаданных модели и валидации данных на стороне клиента. Об этом речь пойдет далее.
Итак, необходимо реализовать автоматический вывод метаданных модели MVC на клиентскую сторону и ненавязчивую валидацию.
Основные идеи:
- метаданные клиенту будем предавать через http заголовок
- метаданные будут добавляться автоматически посредством фильтров
- перед передачей данных сделаем encoding строки в base64 (это необходимо, так как заголовок http передается в ASCII).
- на клиенте в Backbone модели переопределим метод parse — в нем мы перехватим переданные метаданные и выполним decode из base64
- на клиенте необходимо подключить Backbone библиотеку backbone-validation.js — библиотека валидации
Идея передачи метаданных через http заголовок взята из этой статьи.
Подробнее с библиотекой backbone-validation.js можно познакомиться здесь.
серверная часть
На сервере напишем 2 фильтра:
- фильтр для вывода в заголовок метаданных модели
- фильтр для вывода в заголовок правил валидации модели
фильтр метаданных (название поля, заполнитель и т.д.)
public class MetaToHeader : ActionFilterAttribute { private readonly string header; public MetaToHeader(string header) { this.header = header; } public override void OnActionExecuted(ActionExecutedContext filterContext) { var result = filterContext.Result as JsonResult; if (result != null && result.Data != null) { var meta = GetMeta(result.Data); var jsonMeta = new JavaScriptSerializer().Serialize(meta); var jsonMetaBytes = Encoding.UTF8.GetBytes(jsonMeta); filterContext.HttpContext.Response.Headers.Add(header, Convert.ToBase64String(jsonMetaBytes)); } base.OnActionExecuted(filterContext); } private static IDictionary<string, object> GetMeta(object model) { var meta = ModelMetadataProviders.Current.GetMetadataForType(() => model, model.GetType()); return meta.Properties.ToDictionary( p => p.PropertyName, p => new { label = p.GetDisplayName(), title = p.Description, placeholder = p.Watermark, readOnly = p.IsReadOnly } as object); } }
фильтр правил валидации
public class ValidationToHeader : ActionFilterAttribute { private readonly string header; private static readonly Dictionary<string, Func<ModelClientValidationRule, List<object>>> Rules; public ValidationToHeader(string header) { this.header = header; } static ValidationToHeader() { Rules = new Dictionary<string, Func<ModelClientValidationRule, List<object>>>() { { "length", r => { var result = new List<object>(); if (r.ValidationParameters.ContainsKey("max")) result.Add(new {maxLength = r.ValidationParameters["max"]}); if (r.ValidationParameters.ContainsKey("min")) result.Add(new {minLength = r.ValidationParameters["min"]}); result.Add(new { msg = r.ErrorMessage }); return result; } }, { "range", r => { var result = new List<object>(); if (r.ValidationParameters.ContainsKey("max")) result.Add(new {max = r.ValidationParameters["max"]}); if (r.ValidationParameters.ContainsKey("min")) result.Add(new {min = r.ValidationParameters["min"]}); result.Add(new {msg = r.ErrorMessage}); return result; } }, { "remote", r => { var result = new Dictionary<string, object>(); if (r.ValidationParameters.ContainsKey("url")) result.Add("url", r.ValidationParameters["url"]); if (r.ValidationParameters.ContainsKey("type")) result.Add("type", r.ValidationParameters["type"]); result.Add("msg", r.ErrorMessage); return new List<object> { new {remote = result} }; } }, { "required", r => new List<object> { new { required = true, msg = r.ErrorMessage } } }, { "number", r => new List<object> { new { pattern = "number", msg = r.ErrorMessage } } } }; } public override void OnActionExecuted(ActionExecutedContext filterContext) { var result = filterContext.Result as JsonResult; if (result != null && result.Data != null) { var meta = GetRules(result.Data, filterContext.Controller.ControllerContext); var jsonMeta = new JavaScriptSerializer().Serialize(meta); var jsonMetaBytes = Encoding.UTF8.GetBytes(jsonMeta); filterContext.HttpContext.Response.Headers.Add(header, Convert.ToBase64String(jsonMetaBytes)); } base.OnActionExecuted(filterContext); } public static IDictionary<string, object> GetRules(object model, ControllerContext context) { var meta = ModelMetadataProviders.Current.GetMetadataForType(() => model, model.GetType()); return meta.Properties.ToDictionary( p => p.PropertyName, p => PropertyRules(p, context) as object); } private static object[] PropertyRules(ModelMetadata meta, ControllerContext controllerContext) { return meta.GetValidators(controllerContext) .SelectMany(v => v.GetClientValidationRules()) .SelectMany(r => Rules[r.ValidationType](r)) .ToArray(); } }
Стоит обратить внимание на то, что фильтры работаю с данными, отправляемыми клиенту в Json формате.
клиентская часть
на клиенте создадим:
- базовую Backbone модель, в которой переопределим метод parse
- базовую Backbone View
базовая модель
(function () { var models = window.App.Models; models.DataMetaModel = Backbone.Model.extend({ metaHeader: 'data-meta', validationHeader: 'data-validation', urlRoot: '', initialize: function (options) { this.urlRoot = options.url; }, parse: function (response, xhr) { var metaData = xhr.xhr.getResponseHeader(this.metaHeader); var validationData = xhr.xhr.getResponseHeader(this.validationHeader); this.meta = metaData ? $.parseJSON(Base64.decode(metaData)) : undefined; this.validation = validationData ? $.parseJSON(Base64.decode(validationData)) : undefined; return response; } }); })();
Метод parse переопределяем для того, что бы из http заголовка перехватить метаданные и правила валидации, которые затем будут использоваться Backbone библиотекой backbone-validation.js
базовый Backbone View
(function () { var views = window.App.Views; views.dataMetaView = Backbone.View.extend({ events: { 'submit': 'evSubmit', 'blur input[type=text]': 'evBlur', }, initialize: function (options) { _.extend(Backbone.Validation.callbacks, { valid: this.validCallback, invalid: this.invalidCallback, }); _.extend(Backbone.Validation.validators, { remote: this.remoteValidator }); Backbone.Validation.bind(this, { offFlatten: true // выключает флаттен. Смотри исх код backbone-validation.js метод: var flatten = function (obj, into, prefix) { }); }, render: function () { this.addMeta(); }, addMeta: function () { _.each(this.model.meta, function (meta, name) { $('label[for=' + name + ']').text(meta.label); $('input[name=' + name + ']').attr({ title: meta.title, placeholder: meta.placeholder, readonly: meta.readOnly }); }); }, evBlur: function (e) { var $el = $(e.target); this.model.set($el.attr('name'), $el.val(), {validate: true, validateAll: false}); }, evSubmit: function (e) { if (!this.model.isValid(true)) return false; }, validCallback: function (view, attr, selector) { var control = view.$('[' + selector + '=' + attr + ']'); var group = control.parents(".control-group"); group.removeClass("error"); if (control.data("error-style") === "tooltip") { // CAUTION: calling tooltip("hide") on an uninitialized tooltip // causes bootstraps tooltips to crash somehow... if (control.data("tooltip")) control.tooltip("hide"); } else if (control.data("error-style") === "inline") { group.find(".help-inline.error-message").remove(); } else { group.find(".help-block.error-message").remove(); } }, invalidCallback: function (view, attr, error, selector) { var control = view.$('[' + selector + '=' + attr + ']'); var group = control.parents(".control-group"); group.addClass("error"); if (control.data("error-style") === "tooltip") { var position = control.data("tooltip-position") || "right"; control.tooltip({ placement: position, trigger: "manual", title: error }); control.tooltip("show"); } else if (control.data("error-style") === "inline") { if (group.find(".help-inline").length === 0) { group.find(".controls").append("<span class=\"help-inline error-message small-text\"></span>"); } var target = group.find(".help-inline"); target.text(error); } else { if (group.find(".help-block").length === 0) { group.find(".controls").append("<p class=\"help-block error-message small-text\"></p>"); } var target = group.find(".help-block"); target.text(error); } }, remoteValidator: function (value, attr, customValue, model) { var result, data = model.toJSON(); data[attr] = value; $.ajax({ type: customValue.type || 'GET', data: data, url: customValue.url, async: false, success: function (state) { if (!state) result = customValue.msg || 'remote validation error'; }, error: function () { result = "remote validation error"; } }); return result; } }); })();
Здесь в Backbone View во первых инициализируется точка входа в библиотеку валидации — backbone-validation.js:
Backbone.Validation.bind(this, { offFlatten: true // выключает флаттен. Смотри исх код backbone-validation.js метод: var flatten = function (obj, into, prefix) { });
Во вторых инициализируются callback функции (valid, invalid), необходимые для подсветки ошибок. Так же здесь инициализируется атрибут удаленной валидации:
remoteValidator: function (value, attr, customValue, model) { var result, data = model.toJSON(); data[attr] = value; $.ajax({ type: customValue.type || 'GET', data: data, url: customValue.url, async: false, success: function (state) { if (!state) result = customValue.msg || 'remote validation error'; }, error: function () { result = "remote validation error"; } }); return result; }
На клиентской стороне атрибут удаленной валидации представляет из себя лишь ajax метод (тип метода можно указать в описании модели на серверной стороне), принимающий в качестве ответа переменную, указывающию состояние валидируемого поля:
- true — поле валидно
- false — невалидно
Удаленная валидация необходима в том случае, когда валидацию невозможно или по каким-то причинам затруднительно сделать на клиентской стороне. В коде на серверной стороне атрибут удаленной валидации описывается следующим образом
[Remote("RemoteEmailValidation", "Friends", ErrorMessage = "Не корректный почтовый ящик")]
Использование
На серверной стороне необходимо создать метод, который будет отправлять данные в Json формате. К нему и применим созданные фильтры:
[MetaToHeader("data-meta")] [ValidationToHeader("data-validation")] public ActionResult GetData() { return Json(new Friend(), JsonRequestBehavior.AllowGet); }
В качестве параметров в фильтрах указывается название http заголовка. Эти же названия используются на клиенте в методе parse.
На клиентской стороне создадим Backbone модель FriendModel наследованную от созданной базовой Backbone модели DataMetaModel, в которой переопределен метод parse:
(function() { var models = window.App.Models; models.FriendModel = models.DataMetaModel.extend({ initialize: function(options) { models.DataMetaModel.prototype.initialize.call(this, options); } }); })();
Так же создадим Backbone View NewFriend, наследованный от созданного базового Backbone View dataMetaView:
(function () { var views = window.App.Views; views.NewFriend = views.dataMetaView.extend({ initialize: function (options) { views.dataMetaView.prototype.initialize.call(this); this.model.on('sync', this.render, this); this.template = _.template($(options.template).html()); }, render: function () { this.$el.html(this.template(this.model.toJSON())); views.dataMetaView.prototype.render.call(this); return this; }, load: function () { this.model.fetch(); } }); })();
Здесь в методе render после выполнения всех действий необходимо вызвать базовый метод render
для того, что бы добавить к отрисованным полям метаданные (название, заполнитель и т.д.) в соответствии с описанием модели на серверной стороне. При этом правила валидации, переданные клиенту в DOM не добавляются. Они лишь используются библиотекой backbone-validation.js.views.dataMetaView.prototype.render.call(this);
Пример
Создадим модель Friend:
public class Friend { public int Id { get; set; } [Display(Name = "Имя", Prompt = "Введите имя", Description = "Имя друга")] [Required(ErrorMessage = "First name required")] [StringLength(50, MinimumLength = 2)] public string FirstName { get; set; } [Display(Name = "Фамилия", Prompt = "Введите фамилию", Description = "Фамилия друга")] [Required(ErrorMessage = "Last name required")] [StringLength(50, MinimumLength = 2)] public string LastName { get; set; } [Display(Name = "Возраст", Prompt = "Введите возраст", Description = "Возраст друга")] [Required(ErrorMessage = "Age required")] [Range(0, 120, ErrorMessage = "Age must be between 0 and 120")] public int? Age { get; set; } [Display(Name = "Почтовый ящик", Prompt = "Введите почтовый ящик", Description = "Почтовый ящик друга")] [Required(ErrorMessage = "Email required")] [Email(ErrorMessage = "Not a valid email")] [Remote("RemoteEmailValidation", "Friends", ErrorMessage = "Не корректный почтовый ящик")] public string Email { get; set; } }
в котроллере к методу, который возвращает модель добавим два фильтра:
[MetaToHeader("data-meta")] [ValidationToHeader("data-validation")] public ActionResult GetData() { return Json(new Friend(), JsonRequestBehavior.AllowGet); }
на клиенте создадим модель и View, указанные в пункте использование, а так же определим шаблон, который будет использоваться View для генерации динамической разметки:
<script type='text/template' id='dataMeta-template'> <form action="/Friends/Create" method="post"> <div class="control-group"> <label for="FirstName"></label> <div class="controls"> <input type='text' name="FirstName" value='<%- FirstName %>' /> </div> </div> <div class="control-group"> <label for="LastName"></label> <div class="controls"> <input type='text' name="LastName" value='<%- LastName %>' /> </div> </div> <div class="control-group"> <label for="Age"></label> <div class="controls"> <input type='text' name="Age" value='<%- Age %>' /> </div> </div> <div class="control-group"> <label for="Email"></label> <div class="controls"> <input type='text' name="Email" value='<%- Email %>' /> </div> </div> <p><button class="btn" type="submit">Create</button></p> </form> </script>
Точкой входа на странице служит скрипт:
<script> (function($) { var models = window.App.Models, views = window.App.Views; var dataMetaModel = new models.FriendModel({ urlRoot: '/Friends/GetData' }); var dataMetaView = new views.NewFriend({ el: '#dataMeta', model: dataMetaModel, template: '#dataMeta-template' }); dataMetaView.load(); })(jQuery); </script>
Результат




