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

    Наверняка все создавали свои 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 (зеркало).

    Комментарии привествуются.
    Метки:
    • +12
    • 20,5k
    • 8
    Поделиться публикацией
    Похожие публикации
    Комментарии 8
    • +2
      По производительности стрингбилдер лучше всего… поэтому его и используют… и МС в своих веб-решениях тоже
      • –1
        На сколько в процентах StringBuilder быстрее, чем то что я показал (которое, к слову, внутренне его же использует)? Хочу цифры.
        • –1
          stackoverflow.com/questions/5234147/why-stringbuilder-when-there-is-string
          Кратко: При конкатенации строк через + каждый раз создается новый объект для каждой операции конкатенации.
          На SO есть тесты.
          • +1
            клево, а вас не смутило что по ссылке речь про java?) Кроме того ответа на свой вопрос я так и не увидел.
            И вообще как конкатенация строк связана с этим постом? Отходите от праздников-то уже…
            • –1
              support.microsoft.com/kb/306822/ Это от Microsoft. Ну а по поводу причин комента — это не ко мне.
              • –1
                ну вы там написали про конкатенацию строк — которую я так и не понял где увидели… Ладно, забудьте, про StringBuilder и его внутреннее устройство я, поверьте, вкурсе (как, уверен, 99% asp.net (да и C#) разработчиков).
      • +1
        Прям неделя MVC на Хабре. :)

        А вы про display template'ы не слышали? Зачем этот весь огород-то городить?

        Вот, умный мужик Скот Хенселман (Scott Hanselman), показал как «сложный» контрол создаётся в MVC

        ASP.NET MVC DisplayTemplate and EditorTemplates for Entity Framework DbGeography Spatial Types

        И всё работает просто замечательно, а от конструкций:

        @Html.InputEx(s=>
                          {
                              s.Name = "MyInputCtrl";
                              s.CallbackRouteValues = new { Controller = "Home", Action = "MyInputCtrlPartial" };
                              s.ValidationRegexp = @"^\d+$";
                          })
        


        N.B.
        Можно и крышкой отъехать, такое впечатление что вы пытаетесь портировать мышление User Control/Web Control'а из ASP.NET WebForms в MVC.
        • 0
          Вы украли мою мысль) Это чистый WebForms-подход — HTMLразметка плюс таскаемый за ней условный «codebehind».

          Хотя идея все-равно интересная.

          PS. мы для себя поняли, что проще всего кнтролы делать через Razor-helpers в папке App_Code. И razor-разметка есть, и код можно между @{...} засунуть… А чтобы использовать один контрол в нескольких приложениях мы юзаем саб-репозитарий git / hg который включен сразу в несколько проектов.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.