Pull to refresh

Простой путь создания сложных ASP.NET MVC контролов

Reading time 6 min
Views 23K
Наверняка все создавали свои 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-а), индикацией результата валидации, да так, чтобы контрол был в отдельной сборке. Это несложный контрол, который покажет общую идею.

Как это делают другие?


Для начала рассмотрим несколько библиотек, аналогичных по функционалу, чтобы понять как они это делают:


Другие коммерческие контролы в основном используют подход «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));
        }
    }


Окей, пришло время написать код представления нашего контрола:
код cshtml представления
@* 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 (зеркало).

Комментарии привествуются.
Tags:
Hubs:
+12
Comments 8
Comments Comments 8

Articles