Наверняка все создавали свои asp.net mvc контролы (речь, конечно, про asp.net mvc кодеров). Вам должен быть знаком метод создания контролов, используя TagBuilder? Побывали писать реально сложные контролы (например с большим количеством javascript-та или разметки, которая зависит от опций)? Тогда наверняка вам знаком адЪ экранирования кавычек, конкатенации строк (или вызова .Format() функции) и т.п. «неудобства». Я предлагаю взглянуть на достаточно простую методику, которая позволит избежать подобных вещей и в тоже время сосредоточиться на функциональной стороне контролов, а не на программировании шелухи.
Отказ от ответственности
Многое, что тут будет сказано может оказаться диким баяном и говнокодом. Кроме того, этот пост расчитан на достаточно озадаченных проблемами людьми, описанными в первом абзаце. Это не учебник, и не руководство к действию. Это только описание похода, и не более чем.
Классика создания контролов ASP.NET MVC
Кратенько пробежимся по тому как сейчас лепят контролы.
Обычно делают HtmlHelper extension вроде такого:
public static string InputExControl(this HtmlHelper @this)
{
StringBuilder sb = new StringBuilder();
sb.Append("<input type="text">.......blablabla..........");
return sb.ToString();
}
либо используют TagBuilder под теже цели:
public static string InputExControl(this HtmlHelper @this)
{
TagBuilder tagBuilder = new TagBuilder("input");
//.....blablabla....
return tagBuilder.ToString();
Лично меня от этого кода воротит: как минимум этот код сложно поддерживать (ИМХО), и уж тем-более модифицировать тоже не просто.
Задача
Давайте поставим такую задачу: нам надо сделать input контрол с AJAX валидацией на стороне сервера (на основе заданного Regexp-а), индикацией результата валидации, да так, чтобы контрол был в отдельной сборке. Это несложный контрол, который покажет общую идею.
Как это делают другие?
Для начала рассмотрим несколько библиотек, аналогичных по функционалу, чтобы понять как они это делают:
- MVC Controls Toolkit использует методику возврата StringBuilder-а из хелперов HtmlHelper.
- MVCContrib project использует TagBuilder.
Другие коммерческие контролы в основном используют подход «StringBuilder.Format()» (для чистых asp.net mvc контролов), но могут (как например DevExpress) тянуть за собой asp.net webForms контролы.
Идея
Идея заключается в том, чтобы использовать Razor синтаксис asp.net mvc partial view для описания контрола в .cshtml, но при этом не тащить за собой .cshtml файлы, конечно же.
Создадим простой asp.net mvc 4 проект в студии, добавим к решению library (назовем его MyControlLib), в которой зареференсимся на основые asp.net mvc 4 либы.
Добавим в MyControlLib либу InputExControl.cshtml файл с пустым содержимым. Это будет наша View-часть контрола, которую мы ранее писали, используя TagBuilder методику. Для того чтобы перевести этот cshtml файл в C# код, мы будем использовать Razor Generator. Он позволит нам сгенерировать то, что Razor движек сгенерировал бы нам «на лету» в asp.net mvc приложении. Нужно это по сути для того чтобы не таскать за собой .cshtml файлы и казаться «взрослым контролом» (коммерческие же не тянут, вот и мы не станем). Окей, проставим в содержимом InputExControl.cshtml следующее:
@* Generator: MvcView *@
, а в его свойствах укажем Custom Tool как RazorGenerator.
Получили генерированный класс-наследник от System.Web.Mvc.WebViewPage. Генерация не очень удобная, т.к. создает не partial класс (можно исправить в исходниках генератора или просто руками в генерированном файле), т.е. такой класс тяжело расширять нужными нам методами.
Создадим класс InputExControlSettings, который будет олицетворять настройки нашего контрола. Он будет очень прост:
public class InputExControlSettings
{
public string Name { get; set; } //имя контрола
public dynamic CallbackRouteValues { get; set; } //значения для ajax callback-а
public string ValidationRegexp { get; set; } //регулярка для проверки
}
Для простоты я завел поле Name, которое будет олицетворять в клиентском коде контрола его id (свойство DOM элемента). Также это поле будет участвовать в генерации имён субэлементов, нужных нашему контролу (индикатор результата валидации).
Свойство CallbackRouteValues будет нужно нам для получения Uri куда мы из клиентского javascript зашлем запрос на валидацию. В нём обычно указывают контроллер и метод контроллера.
Теперь класс настроек можно указать в качестве модели для нашего контрол-cshtml файла:
@* Generator: MvcView *@
@model InputExControlSettings
@{
//ну и сразу определим пару переменных, чтобы ссылаться на них в коде
string controlId = Model.Name;
string controlResultId = Model.Name + "_Result";
}
Для того чтобы написать код дальше надо понять одну простую вещь: callback будет дёргать в общем случае наш же контрол, посему нам надо как-то отличать callback от простого GET запроса для получения внешнего вида контрола. Простым методом определения для нас я выбрал наличие в хедерах запроса «специального» (нашего) значения. Т.о. у нас появился небольшой хелпер. Кроме того я использовал его же (bad code!) как помошник получения Uri из значений CallbackRouteValues :
internal static class InputExControlHelper
{
public static bool IsCallback()
{
return !string.IsNullOrEmpty(HttpContext.Current.Request.Headers["InputExControAjaxRequest"]);
}
public static MvcHtmlString CallbackHeaderName
{
get { return MvcHtmlString.Create("InputExControAjaxRequest"); }
}
public static string GetUrl(dynamic routeValues)
{
if (HttpContext.Current == null) throw new InvalidOperationException("no context");
RequestContext context;
if (HttpContext.Current.Handler is MvcHandler)
{
context = ((MvcHandler) HttpContext.Current.Handler).RequestContext;
}
else
{
var httpContext = new HttpContextWrapper(HttpContext.Current);
context = new RequestContext(httpContext, new RouteData());
}
var helper = new UrlHelper(context, RouteTable.Routes);
return helper.RouteUrl(string.Empty, new RouteValueDictionary(routeValues));
}
}
Окей, пришло время написать код представления нашего контрола:
@* Generator: MvcView *@
@model InputExControlSettings
@{
string controlId = Model.Name;
string controlResultId = Model.Name + "_Result";
}
@if(!InputExControlHelper.IsCallback())
{
<input type="text" id="@controlId"/>
<span id="@controlResultId"></span>
<script>
$(function() {
$('#@controlId').change(function () {
$('#@controlResultId').text('validating ...');
$.ajax({
url: '@InputExControlHelper.GetUrl(Model.CallbackRouteValues)',
headers: {
'@InputExControlHelper.CallbackHeaderName': true
},
cache: false,
data: { value: $('#@controlId').val() },
type: 'POST',
dataType: 'json',
success: function (data) {
if (data) {
$('#@controlResultId').text('Validattion result: ' + data.result);
} else {
alert('result error?');
}
},
error: function() {
alert('ajax error');
}
});
});
});
</script>
}
else
{
System.Web.HttpContext.Current.Response.ContentType = "application/json";
@(this.InternalValidate(System.Web.HttpContext.Current.Request.Form["value"]))
}
Код максимально прост: если у нас пришел НЕ callback, то выводим основной View, включая javascript, который и будет делать этот самый callback. В случае же каллбэка мы ставим ContentType как JSON и вызываем метод валидации контрола InternalValidate(string)
.
Собственно код самой валидации и установки ViewData.Model будет оформлен как partial метод InputExControl-а и будет очень прост:
partial class InputExControl
{
public InputExControl(InputExControlSettings settings)
{
ViewData.Model = settings;
}
private MvcHtmlString InternalValidate(string value)
{
Thread.Sleep(2000); //long validation emulator...
var settings = ViewData.Model;
var regexp = new Regex(settings.ValidationRegexp, RegexOptions.Compiled);
var res = regexp.IsMatch(value);
var scriptSerializer = new JavaScriptSerializer();
var rv = scriptSerializer.Serialize(new { result = res });
return MvcHtmlString.Create(rv);
}
}
Ок, мы написали контрол, но мы пока не можем использовать его в нашем MVC проекте. Настоящие пацаны пишут под такие контролы расширитель HtmlHelper-а, что мы и сделаем:
namespace MyControlLib
{
public static class HtmlExtensions
{
public static HtmlString InputEx(this HtmlHelper @this, Action<InputExControlSettings> setupFn)
{
var options = new InputExControlSettings(); //создаем наши настройки
setupFn(options); //сетапим их
var view = new InputExControl(options); //наш супер контрол
var tempWriter = new StringWriter(CultureInfo.InvariantCulture); //буфер куда будет писаться результат работы движка Razor
view.PushContext(new WebPageContext(), tempWriter); //ставим контекст движку
view.Execute(); //выполняем наше View - код в сгенерированном файле
view.PopContext(); // восстанавливаем контекст
return MvcHtmlString.Create(tempWriter.GetStringBuilder().ToString()); //вернем результат в внешнее View
}
}
}
Оккей, у нас есть теперь метод -расширитель. Настало время интеграции нашего контрола в основное приложение. Просто создайте PartialView MyInputCtrlPartial (и Action method именованный также), где впишите нечто вроде
@using MyControlLib
@Html.InputEx(s=>
{
s.Name = "MyInputCtrl";
s.CallbackRouteValues = new { Controller = "Home", Action = "MyInputCtrlPartial" };
s.ValidationRegexp = @"^d+$";
})
и вызовете его (используя Html.Partial(«MyInputCtrlPartial»)) в основной View.
Нам нужено описать контрол именно в PartialView, т.к. результат «рендеринга» будет разный — в зависимости от переданного хедера-индикатора что у нас идёт callback на проверку.
Осталось только запустить проект на выполнение и убедится что всё работает (или не работает, т.к. кто-то накосячил) (note: чтобы вызвать событие changed надо тыкнуть мимо контрола мышкой).
Итог
Итог: мы смогли написать непростой контрол, при этом у нас работает Intellisense в Razor шаблоне (включая javascript), что не может не радовать.
Пример проекта можно скачать с http://rghost.ru/42818685 (зеркало).
Комментарии привествуются.
Автор: jonie