пятница, 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

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

Вышла третья версия ASP.NET MVC (Preview) - перелопачиваем проект (а лучше создаем новый)

Значит, вышла, родная. Изменений немерено!
Скачать новую версию можно здесь. Там на странице внизу список доступных файлов. Обязательно скачайте Readme - он содержит перечень всех изменений. А для тех, кто не очень дружит с английским (ну и вообще - для уяснения) перечислю эти изменения здесь.

Контроллеры
Методы действия теперь возвращают экземпляр типа ActionResult. Вернее, один из типов, наследующих ActionResult, потому что ActionResult - абстрактный класс. Вот какие это могут быть типы:

  • ViewResult - такой тип возвращает метод контроллера View (то, что раньше было RenderView), параметры остаются те же

  • EmptyResult - ничего не делает - аналогичен тому, что метод действия возвращает null

  • RedirectResult - перенаправляет на другой URL

  • RedirectToRouteResult - перенаправление по указанному маршруту

  • JsonResult - содержимое ViewData сериализуется в формат JSON

  • ContentResult - рендерит некий текст (обычно это HTML)


Объекты всех этих типов возвращают соответствующие методы контроллера:

  • View

  • Redirect - перенаправляет на новый URL

  • RedirectToAction - перенаправляет на другой метод действия

  • RedirectToRoute - перенаправляет по новому маршруту

  • Json - возвращает экземпляр типа JsonResult

  • Content - рендерит текст (обычно это HTML)


Интересный момент. Если мы пытаемся вернуть объект, не наследующий ActionResult (теоритечески это может быть что угодно), то метод неявно сериализует этот объект в строку (вызывая его ToString()), после чего оборачивает полученный текст в ContentResult и передает его клиенту.

Представления
Теперь ViewData в представлении не заменяется объектом, из которого мы подтягиваем данные. А мы получаем к ним доступ через свойство Model, т.е. вместо ViewData.CitiesForCountry пишем теперь ViewData.Model.CitiesForCountry.

Роутинг
Дефолтные значения теперь задаются проще. Вместо:
routes.Add("route-name", new Route("{locale}/{year}", new MvcRouteHandler()) {
 Defaults = new RouteValueDictionary(new {locale="en-US", year=DateTime.Now.Year.ToString()})
}

теперь чуть проще
routes.MapRoute("route-name", "{locale}/{year}", new {locale="en-US", year=DateTime.Now.Year.ToString()});


Это основные новшества. Некоторые моменты касаются констрейнтов, контролов и т.д.

Установка
Поверх не ставится. Сначала надо удалить вторую версию.

Изменения в проекте
Попробуем скомпилировать наш маленький (пока еще) проект. Ну что ж, для начала неплохо - всего 24 ошибки.
Первое, что делаем - удаляем из списка ссылок ссылки на System.Web.Abstractions, System.Web.MVC, System.Web.Routing и заново добавляем их из C:\Program Files\Microsoft ASP.NET\ASP.NET MVC Preview 3\Assemblies. Компилируем опять, уже лучше - всего 14 ошибок.
Далее по тексту файла Readme меняем код. Все компилируется отлично, но одна маленькая проблема - полностью перестает работать роутинг. Т.е. вообще полностью - хотя маршруты и сохраняются в коллекции, однако перехода к методам контролов не осуществляется. Поэтому мне не осталось ничего другого, как создать новый проект, в котором это работает. Мне, к сожалению, так и не удалось найти "5 отличий" между измененным проектом и новым. Если кто-нибудь знает, как это пофиксить, сообщите пожалуйста.
Update Обнаружилась еще одна неприятная фича (которая, конечно, скорее бага). Если у вас модельная часть с edmx находится в отдельном проекте, то из проекта MVC не удалется получить к ней доступ. Т.е. компилируется проект нормально, но при обращении к странице, которая дергает данные из модели, вываливаются непонятные ошибки вроде "включите ссылку на namespace System.Data.Entity". После того, как на страницу импортируешь это пространство имен, начинает кричать, что он не знает, что такое System.Data.Objects. Как только модель была перенесена в проект MVC, подобные ошибки сразу же исчезли. Кто знает, как лечить по-другому, сообщайте.