пятница, 20 июня 2008 г.

MVC - создание и использование повторно используемых компонентов

Итак, встала передо мной несложная, в-общем, задача - в зависимости от элемента, выбранного в выпадающем списке добавить, на страницу соответствующий блок с контролами. Данные берутся из базы, никаких перезагрузок страницы быть не должно. В результате мне удалось найти три способа, решающих данную задачу.

Способ первый. Использование вложенных представлений
Оптимальное решение, на мой взгляд - использование контролов, или встроенных представлений. Однако и оно не без греха. Но рассмотрим все по порядку.

В MS MVC существует класс под названием ComponentController. Это, по сути, обычный контроллер, использующий немного другие методы и сигнатуры. Зачем понадобилось делать дополнительный класс с почти такой же функциональностью, для меня осталась загадка. Итак, вот как используется этот класс:


public class CategoryController : ComponentController
  {
    public ActionResult Index()
    {
      // Add action logic here
      throw new NotImplementedException();
    }
    public void RenderControl(string CategoryName)
    {
      RenderView(CategoryName);
    }
  }

Т.е. видим два существенных отличия: класс наследует ComponentController, а не просто Controller, и второе - по-другому выглядят методы действия. А выглядят они точно также, как выглядели методы действия обычных контроллеров раньше - т.е. возвращают void и рендерят представление с помощью RenderView.

В данном случае имя представления передается как параметр, что позволяет вызывает вызывать представления абсолютно произвольно, используя при этом всего один метод контроллера.

Размещается контроллер вместе с остальными в папке Controllers (думаю, на самом деле эти файлы можно размещать где угодно). А вот с представлением не так все просто. Оно должно лежать в специальной папке Components/[ComponentControllerName]/Views/[ViewName]. Т.е. в корне вы создаете папку Components, в ней произвольную папку, в которой создаете папку Views, а уже сюда кладете файлы представлений.

Дальше все просто. Контроллер компонента создан, представление - тоже, остается только его вызвать. У меня это сделано так: главная страница загружает выпадающий список, в нем на onchange вызывается функция JavaScript, которая с помощью библиотеки Prototype загружает представление с соответствующими параметрами (подробности работы с Prototype смотри в другой записи блога тут). А вот это представление содержит вызов компонента.

Вызов можно осуществлять двумя способами. Во-первых, используя RenderUserControl() - тогда сам вызов будет выглядеть так:

<%= Html.RenderUserControl("~/Views/SomeView.aspx") %>


Как видите, здесь указывается непосредственно имя файла. В ряде случае это может быть совершенно неудобно. Например, когда мы на стадии этого вызова еще не знаем имя файла, или хотим передать другой параметр, а метод чтобы сам решал, какое представление ему вызывать. Для этого используем второй способ:

<% =Html.RenderComponent<CategoryController>(c => c.RenderControl("Public")) %>


Здесь мы просто указываем имя представления, а могли бы, например, передавать какой-нибудь идентификатор. Мы можем брать из модели, загруженной в объект ViewData, любой доступный член. Почему бы не передавать его в качестве параметра, например, вот так:

<% =Html.RenderComponent<CategoryController>(c => c.RenderControl(View.Model.PageId)) %>


К сожалению, подобный код не будет работать (из-за бага в MVC). Вы просто увидите ошибку следующего содержания:

Unable to cast object of type 'System.Linq.Expressions.MemberExpression' to type 'System.Linq.Expressions.ConstantExpression'.

Выход нашел некто Chris Sainty (ссылка на его статью в конце заметки). Просто добавляем следующий метод в файл вызывающего представления, и вместо стандартного Html.RenderComnonent вызываем его:

protected string RenderComponent(Expression<Action<CategoryController>> action)
    {
      var controller = new CategoryController();
      controller.Context = this.ViewContext;
      var ex = action.Compile();
      ex.Invoke(controller);
      return controller.RenderedHtml;
    }

Вызов будет выглядеть соответственно:

<% =RenderComponent(c => c.RenderControl(ViewData.Model.ParentCategoryName)) %>

Все работает!

Способ второй. Подтягиваем разметку Html из базы данных
Первый способ, безусловно, хорош. Но у него есть один существенный недостаток - что, если состав вложенных контролов постоянно меняется, т.е. попросту говоря, не является детерменированным? Ничего такого, надо просто каждый раз обновлять версию dll на сервере, и все. Тоже выход, но можно ли обойтись без этого? Можно - например, если хранить код разметки в поле таблицы. Этот вариант подходит для не очень большой и сложной разметки, но зато позволяет обойтись без постоянного деплоя.

Как это сделать? Очень просто - не забываем, что мы можем передать представлению любой класс, а уже в этом классе не составит труда загрузить любое поле из таблицы. Например, так:

private string html;
    public CategoryParameters(int? ParentCategoryId)
    {
      using (DataCoreConnection context = new DataCoreConnection())
      {
        var htm = (from p in context.ItemType
             where p.Id == ParentCategoryId
             select p.ParametersHTML).ToList();
        if (((List<string>)htm).Count > 0)
          html = ((List<string>)htm)[0];
      }
    }
public string Html
    { get { return html;} }

А вот так выглядит вызов:
<%=ViewData.Model.Html %>


Третий способ. Подтягивание представления с помощью Ajax
Подробно о том, как использовать Ajax в проектах MVC, рассказано в другой моей заметке (тут), здесь я лишь покажу, как это использовать в наших целях. Вот код JavaScript, который вызывает представление:
function LoadCategory(parentCategory)
{
  var newDiv = document.createElement("div");
  newDiv.id = "ControlDiv_" + parentCategory;
  newDiv.className = "ControlBlock";
  var url = "Controls/GetCategories/" + parentCategory;
  new Ajax.Updater(newDiv, url,
    {
      method: 'get'
    }
  );
  $('Container').appendChild(newDiv);
}

Т.е. здесь мы динамически создаем блок DIV, в который загружаем содержимое представления, а сам блок помещаем в ранее созданный DIV с Id= Container.

На самой странице же достаточно сделать такой вызов:
<script language="javascript" type="text/javascript">
      LoadCategory(1);
    </script>


Полезные ссылки:
Статья Chris Sainty, посвященная RenderComponent() и RenderUserControl()
Using the ComponentController in ASP.NET MVC CTP 2 - Mike Bosch

Комментариев нет: