воскресенье, 18 мая 2008 г.

MVC и Ajax

Создавая проект на основе MVC, вы можете использовать Ajax, причем сделать это совсем несложно. Кто-то захочет использовать самописный код, а может быть, библиотеку Microsoft Ajax, я же предпочитаю использовать простую и удобную библиотеку Prototype. Единственный видимый ее недостаток - размер (123 К), который устраняется сжатием. Например, можно воспользоваться вот этим онлайновым компрессором, после которого файл уменьшается до 50К, что вполне приемлемо.

Задача
Мне требуется сделать следующую, довольно простую штуку: есть два комбо, в одном перечислены страны, а в другом надо обновлять список городов для данной страны. Т.е. выбрали мы, например, Russia в первом комбо, во втором должны появиться Moscow, SPb и так далее. И естественно, без перезагрузки страницы.

План
Задача на первый взгляд простая и даже тривиальная, и решается она действительно просто. Но давайте в учебных целях подробно рассмотрим план решения.
1. БД - в базе должны были страны и города
2. Entity Framework - интерфейс для доступа к данным.
3. Модель - должен обеспечивать как выдачу списка доступных стран, так и списка городов для страны.
4. Контроллер - должен иметь методы действий для отображения всей страницы (на которой размещаются комбо) и части (обновление второго комбо).
5. Представления - отображение данных.
6. Ajax (prototype) - код для подтягивания данных по событию комбо.

1. База данных
Заведем две таблицы - города и страны. Я не очень доверяю числовым идентификаторам, т.е. я им доверяю, но связывать такие справочные таблицы предпочитаю с помощью более долговечных вещей. Поэтому я ввожу поле кода для страны, а в таблице городов делаю ссылку на это поле (FK т.е.).

Тут есть еще дистрикты, но они нам в данный момент не интересны.
После этого можно забить нужные вам данные (в дальнейшем это, конечно, надо делать с помощью админского сайта).

2. Entity Framework
После создания таблиц надо сгенерировать код для доступа к ним. Можно просто апдейтить нашу модель, но у меня, например, студия сглюкнула и пришлось удалять модель полностью, а затем создавать заново. Чтобы обновить модель, надо перейти в режим Model browser (дважды щелкнуть на файле .edmx - мне такой способ не очень нравится, но другого я не знаю). Затем выбрать корневой элемент в дереве и выполнить команду Update model from database. Далее вы просто выбираете новые таблицы и жмете Finish. Если все пройдет нормально, вы получите обновленную модель и обновленный код доступа к данным.

3. Модель
В каталоге Models проекта создадим два класса - с помощью первого будем извлекать статические данные для первого комбобокса (страны), с помощью второго - динамического для комбо с городами.
Итак, первый класс - StaticData - содержит одно публичное свойство и приватный метод, который забирает из базы список стран.

  public class StaticData
  {
    public List<Country> CountryList
    {
      get
      {
        return GetAllCountries();
      }
    }
    private static List<Country> GetAllCountries()
    {
      List<Country> countries = null;
      using (DataCoreConnection context = new DataCoreConnection())
      {
        countries = (from c in context.Country
               select c).ToList();
      }
      return countries;
    }
    
  }



Код простейший, добавлю лишь, что DataCoreConnection - это класс типа System.Data.Objects.ObjectContext из файла, сгенерированного при построении модели.
Второй класс аналогичен первому, за исключением конструктора, в который передается код страны:
public class Cities
  {
    private string countryCode;
    public Cities(string CountryCode)
    {
      countryCode = CountryCode;
    }
    public List<City> CitiesForCountry
    {
      get
      {
        return GetCitiesForCountry(countryCode);
      }
    }
    private List<City> GetCitiesForCountry(string CountryCode)
    {
      List<City> list = null;
      using (DataCoreConnection context = new DataCoreConnection())
      {
        list = (from c in context.City
                where c.Country.Code == CountryCode
                select c).ToList();
      }
      return list;
    }
  }



4. Контроллер
Контроллер у нас уже есть - HomeController (поскольку наши комбобоксы располагаются на главной странице). Сюда мы добавим один-единственный метод:
public void UpdateCities(string code)
    {
      Cities cities = new Cities(code);
      RenderView("SelectCity", cities);
    }



Здесь мы всего лишь получаем наши города и передаем экземпляр объекта представлению SelectCity.
Не забываем о роутинге - в файле Global.asax добавим строку:
      routes.Add(new Route("Home/UpdateCities/{code}", new RouteValueDictionary(new { controller = "Home", action = "UpdateCities" }),
        new MvcRouteHandler()));


Обратите внимание на последний элемент - {code} - это код города, который передается контроллеру. Контроллер принимает его как string code.

5. Представления - отображение данных.
Контролы помещаются на странице (т.е. представлении) Index.aspx. Поскольку страница с момента загрузки должна содержать некоторые статические данные, это должна быть не просто ViewPage, а в нашем случае ViewPage<StaticData>. Сначала отобразим комбобокс со списком доступных стран:



Как видим, мы получаем данные напрямую из ViewData, как будто ViewData - это объект типа StaticData (так оно и есть). Очень удобно. Кого-то может смутить тот факт, что разметка перемешивается с кодом. Как же так? Ведь обещали полное отделение кода! Мммм... ну.... в-общем, ничего страшного. Кода все-таки очень мало, и мне он кажется логическим дополнением к разметке.
Обращаю внимание на объявление обработчика:

onchange="UpdateCitiesList();"


Это функция JavaScript, которая будет подтягивать список городов при изменении значения комбобокса со странами (эту функцию рассмотрим чуть позже).
А пока займемся представлением, обновляющим второй комбобокс. Да-да, комбобокс будет обновлен целым представлением! Добавляем новое представление SelectCity, которое, конечно, объявляем так:

  public partial class SelectCity : ViewPage<Cities>
  {
  }



А вот код, генерирующий содержимое комбобокса:



6. Ajax
Ну а теперь посмотрим, какая функция подтягивает список городов (вернемся к Index.aspx):

  1: <script type="text/javascript" language="javascript">
  2:   function UpdateCitiesList()
  3:   {
  4:     if ($('selCountry').value == "0")
  5:       return;
  6:       var url = '/Home/UpdateCities/' + $('selCountry').value;
  7:     new Ajax.Updater('selCities', url,
  8:     {
  9:       method: 'get'
 10:     }
 11:         );
 12:   }
 13: </script>



В строке 4 мы проверяем, не выбран ли нулевой элемент выпадающего списка (тот, который содержит текст "Select ..."). В этом случае просто покидаем функцию. Далее формируем url, к которому будем обращаться - это обычный адрес в MVC, нам надо вызвать контроллер Home, метод UpdateCities, и передать код страны. В строке 7 мы вызываем очень удобный метод Ajax.Updater - он обновляет содержимое нужного нам контрола. Сигнатура метода проста:
new Ajax.Updater(container, url[, options])

Container - это контрол, который требуется обновить; url - вызываемый url; options - мы указали только метод передачи запроса (полный список параметров см. на сайте Prototype).

Как только мы выбираем страну, срабатывает событие onchange выпадающего списка, и Ajax.Updater обновляет комбобокс городов. Все! По-моему, все выглядит просто и красиво.

суббота, 17 мая 2008 г.

MVC. Представления

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

Вообще обычные веб-страницы MVC, т.е. представления являются наследниками класса ViewPagе, а тот, в свою очередь, наследует обычный Page и, кроме того, реализует интерфейс IViewDataContainer. По умолчанию экземпляр ViewPage не включает тэг <form>, поэтому, если вы захотите добавить серверные контролы, не забудьте добавить и форму.
Следует отметить, что представление должно служить только для отображения данных, оно не должно содержать никакой логики или кода доступа к данным. Просто взять данные и отобразить их.

Передача данных
Главное, ради чего создаются веб-страницы (и вообще приложение) - это данные, которые должны быть переданы форме (в нашем случае представлению). В MVC существует несколько способов передать странице данные. Рассмотрим их по порядку.
1. Использование словаря ViewData
Класс Controller содержит член ViewData, он объявлен следующим образом:

public IDictionary<string, object> ViewData { get; }

Как видим, это простая дженерик коллекция, доступ к ней получаем из самого контроллера:

ViewData["CategoryName"] = Category.Name;
ViewData["Categories"] = categories; //categories - это <List>Category

Этот же словарь доступен из представления, поэтому мы можем обратиться прямо на странице к нему так:

<h2><% = ViewData["CategoryName"] %></h2>

Для отображения повторяющихся частей страницы (как в Repeater) можно использовать цикл foreach:

<select>
<%foreach (var cat in ((<List>)ViewData["Categories"]) ){ %>
<option value='<%=cat.id %>'> <%= cat.Name %> </option>
</select>

Обращаю ваше внимание на две вещи:
1. Несмотря на то, что cat имеет "неопределенный" тип var, мы можем обращаться к полям его настоящего типа, поскольку к нему мы привели объект ViewData["Categories"].
2. Мы использовали встроенный код как для содержимого контейнера (для option), так и для определения атрибута тэга (value) - во втором случае кавычки обязательны (даже несмотря на то, что в обычных тэгах мы можем их и не ставить).

2. Использование типизированных объектов
Вы можете создавать слой DAL самостоятельно, я пытаюсь использовать MS EntityFramework, в любом случае у нас есть доступ к типизированным объектам и коллекциям, которые мы прекрасно можем передать странице. Делается это с помощью перегруженного метода RenderView, который в качестве второго параметра принимает любой объект:

protected void RenderView(string viewName, object viewData);

Вот туда-то мы и можем передать, например, список категорий:

RenderView("Category", categories); //categories - это <List>Category

На страницу этот параметр приходит в виде ViewData, поэтому далее мы работаем с ним также, как было показано выше.

Но есть еще более эффективный способ использовать типизированные объекты на странице (уже без всякого приведения). Дело в том, что в MS MVC существует еще один класс, наследующий ViewPage, определенный следующим образом:

public class ViewPage<TViewData> : System.Web.Mvc.ViewPage
Member of System.Web.Mvc

TViewData - это шаблон, вместо него вы подставляете имя класса, содержащего необходимые данные.
Все, что надо сделать - это наследовать представление от ViewPage<TViewData>, например:

public partial class Index : ViewPage<Category>
{
}

После чего прямо на странице можно легко обращаться к ViewData, объекту, который в нашем примере имеет тип Category:

<%=ViewData.Subcategory %>

Может показаться, что данный подход не очень удобен для сложных страниц, которые отображают разные наборы данных, однако не составляет труда сделать дополнительные классы, которые будут иметь в своем составе все необходимые коллекции, и обращаться к ним через ViewData.

Ссылки по теме:
ScottGu о контроллерах (и представлениях)
ASP.NET Model View Controller Applications > Views and Rendering Helpers

четверг, 15 мая 2008 г.

MVC. Контроллеры.

Итак, добрались мы наконец до самого, на мой взгляд, "вкусного" в этом новом фреймворке - до контроллеров. Это, можно сказать, сердце вашего проекта, построенного на основе MVC. Надо сказать, что концепция контроллеров не кажется сложной, но содержит некоторые нюансы, без знания которых сложно создать что-то стоящее.
1. Как работают контроллеры
Контроллеры - это специальные классы, в которых вы определяете внутреннюю логику работы приложения - откуда взять данные, куда их передать, и какую страницу показать.
Например, если у вас должна быть страница, которая показывает список категорий (чего-нибудь), а также отдельно содержимое категории, то логично создать контроллер Category, в котором определить методы ShowAll и Show, принимающий в качестве параметра id категории.
Подобные методы носят название action methods (методы действия). Вызов метода производится пользователем, когда он кликает ссылку или набирает в адресной строке /Category/Show/5 - показать содержимое категории с id=5. Методы вызываются в соответствии с правилами, определенными в файле Global.asax.

О чем надо помнить. Имя самого класса контроллера должно заканчивать словом controller, а при вызове вы указываете непосредственно имя контроллера, без этого постфикса. Т.е. если вызов /Category/Show/5, то класс контроллера должен называться CategoryСontroller.

Если контроллер содержит публичный метод, который не является методом действия, пометьте его атрибутом [NonActionAttribute].
2. Параметры метода действия
Все параметры, переданные контроллеру, находятся в объекте Request. Кроме значений параметров, оттуда можно извлечь куки и строку запроса. Извлекать параметры очень просто:

int id = Convert.ToInt32(Request["id"]);

Также доступен объект Response. В отличие от стандартных объетов Response и Request ASP.NET (которые являются запечатанными объектами) эти реализуют соответствующие интерфейсы System.Web.IHttpResponse и System.Web.IHttpRequest. Это обстоятельно позволяет создавать псевдообъекты для тестирования контроллеров.
Есть еще более удобный способ извлекать параметры. Для этого метод контроллера должен просто принимать параметр соответствующего типа.

Важно! Не забывайте простую вещь, что именно эти правила определяют роутинг. Если у вас в правилах по дефолту идентификатор передается как id, то в методы вы должны определить параметр точно с таким именем (id, а не CategoryId, например). Если вам нужно передавать именно CategoryId, добавляйте соответствующее правило.


Второй важный момент - тип параметра. Если метод принимает int, а вы передаете ему "hello", возникнет исключение.

Если параметр не совпадает с шаблоном роутинга, или если он вообще не передан, его значение в методе будет null.
Вызывать контроллер можно двумя способами: обычным для MVC (Category/Show/5) и обычным для ASP.NET (Category/Show?id=5).
В конце кода метода действия обычно идет метод RenderView("..."), в качестве параметра указывается имя представления (т.е. страницы, попросту говоря, которая должна быть отображена в браузера при обращении к данному методу).
Кстати, если вызывается метод, которого на самом деле нет, то естественно, возникает ошибка 404. Можно переопределить метод HandleUnknownAction(string actionName) и перенаправить запрос к другому методу действия, используя RedirectToAction (этот метод позволяет вызвать не только другой метод действия в данном контроллере, но и метод любого доступного контроллера).


Ссылки по теме:
ScottGu о контроллерах
ASP.NET Model View Controller Applications > Controllers and Controller Actions

VS 2008 Service Pack 1. Решает одни проблемы и добавляет другие

Итак, вышли два новеньких сервис-пака, призванные устранить немеренное количество багов и добавить новые. Качать здесь. Прошу учесть, что эти сервис паки - тоже беты, что позволяет их разработчикам выпускать бесчисленное множество последующих версий.
Итак, какие баги, видимые невооруженным глазом, устраняет VS SP1 ?
1. Устранена белиберда с копированием файлов Entity Framework (я имею в виду, что теперь, если ваша модель edmx размещена в одном проекте, а вызываете вы ее в другом, то теперь не надо копировать файлы ssdl, csdl и msl в этот проект). Но копировать строку подключения надо по-прежнему (а то получите сообщение об ошибке Unable to load the specified metadata resource).
2. Устранен дурацкий баг с закрытием файла конфига после его сохранения.
3. Диаграммы EF стали еще красивее... (все изменения EF приведены самими разработчиками в соответствующем блоге).
Больше пока не вспомню.
А вот что появилось:
1. Перестал работать роутинг для страницы Default.aspx. Т.е. если раньше вы спокойно задавали

routes.Add(new Route("Default.aspx", new MvcRouteHandler())
{
Defaults = new RouteValueDictionary(new { controller = "Home", action = "Index", id = "" }),
});

то действительно, после набора в строке localhost:1111/ вы переходили по адресу Home/Index. Теперь браузер упорно зависает на пустой странице default.aspx и ни с места. Пока я эту проблему решаю так: добавляю в default.aspx.cs обработчик Page_Load, где делаю явную переадресацию

Response.Redirect("~/Home");

Топорно, но, по крайней мере, работает.

воскресенье, 27 апреля 2008 г.

Разбираемся с ADO.NET Entity Framework

Итак, в этом проекте мы не будем использовать никаких посторонных систем ORM (про ORM здесь), а попробуем построить слой DAL на основе новой технологии ADO.NET Entity Framework, предложенной Microsoft. Говорят, что это не просто ORM, а нечто большее. Что ж, попробуем понять, что именно. Честно говоря, не совсем понимаю, почему вокруг этой ORM столько шума. Мне, например, очень нравится ORM LLBLGen, я думаю, что эта система ни в чем не уступает майкрософтовской, за исключением, разве что тем, что в ней нельзя использовать LINQ (хотя может быть, и можно).

Entity Data Model
В основе ADO.NET EF лежит модель сущностей, т.е. Entity Data Model. Как и предполагалось, основные объекты модели - это сущности и связи. Все объекты и их связи представлены в виде XML-схемы - в файле с расширением .edmx. Таблицы представлены объектами EntitySet, поля - свойствами Property (обычные поля) и NavigationProperty (поля, участвующие в ассоциациях - ключи, foreign keys).

Служебные объекты (Object Services)
В ADO.NET EF уществует несколько полезных объектов, с помощью которых можно получать доступ к соединению с БД или, например, к выборке из базы, представленной в виде типизированной коллекции.
ObjectContext позволяет получить доступ к соединению, метаданным и еще кое-каким службам.
ObjectQuery - это, по сути, запрос, который возвращает коллекцию типизированных сущностей.
ObjectStateManager кэширует запросы, а также отслеживает изменения в сущностях (с целью дальнейшей синхронизацией с БД).

Mapping
Это то, как полученные сущности связаны с реальными объектами базы данных.

Как все это выглядит
Добавим в наш солюшн еще один проект, который назовем DataCore типа ClassLibrary. Затем в проект добавим новый айтем типа ADO.NET Entity Data model. Сразу же на экране возникает визард, предлагающий нам выбрать, будет ли наша модель построена на основе базы данных, или она будет пустой. Конечно, выбираем Generate from database. Дальше все просто. Визард автоматически подключается к серверу, вернее, сразу к самой базе, после чего мы выбираем таблицы и другие объекты, которые хотим включить в модель. В нашем проекте мы включим следующие таблицы:

  • aspnet_Applications

  • aspnet_Membership

  • aspnet_Users

  • Category

  • Comment

  • Question

  • UserDetails


Ни представления, ни хранимые процедуры пока включать в модель не будем. После этого жмем Finish и вот перед нами диаграмма сгенерированных сущностей. Вот как она выглядит:


Если теперь выбрать файл DataModel.edmx в SolutionExplorer, а затем Open with... XML Editor, то мы увидим, как в действительности выглядит этот файл. Это обычный XML, точнее схема, где описаны все элементы: сущности идут здесь как EntitySet, связи между таблицами (ассоциации) как AssociationSet, а отдельные поля - это Properties.

Помимо этой схемы, студия генерирует еще три файла DataModel.csdl, DataModel.msl и DataModel.ssdl. Файл CSDL представляет собой концептуальную схему - это XML файл, в котором представлены объекты на уровне сущностей. Файл SSDL - схема объектов (таблиц и пр.), как они представлены в базе данных.Файл MSL - схема сопоставления - содержит данные о том, как связаны между собой концептуальная схема и схема объектов БД.

Теперь вернемся в Solution Explorer и рассмотрим файл DataModel.Designer.cs. Это самый важный для нас файл, потому что здесь сосредоточены все сгенерированные объекты. Если мы откроем наш первый проект (MVC), то в любом месте сможем, например, написать (не забудьте добавить ссылку на проект):

using (DataCoreConnection context = new DataCoreConnection())
{
Category cat = new FAQEngineModel.Category();
cat.Name = "Web";
context.AddObject("Category",cat);
context.SaveChanges();
}

Итак, что же делает этот код?
В первую очередь он создает объект контекста (DataCoreConnection), который будет уничтожен после выхода за пределы фигурных скобок. Этот объект идет первым в нашем файле DataModel.Designer.cs и является по сути связующим звеном между нашим кодом (объектами) и базой данных. С его помощью выполняются все стандартные операции с данными - выборка, вставка, обновление и удаление.

Затем мы создаем новый объект категории, присваиваем ей имя Web, добавляем объект в контекст и сохраняем изменения. Это, конечно, всего лишь пример кода, поскольку настоящий код должен обязательно содержать конструкцию try-catch.

Однако в этом коротком примере кроется одна проблема, которая, как я надеюсь, будет в дальнейшем решена разработчиками студии. Дело в том, что все проходит гладко, когда мы имеем один проект, из которого вызываем наш контекст. Но как только мы выделяем обработку с данными в отдельный проект, а вызываем код из другого проекта, начинаются проблемы. Во-первых, поскольку вызывающий проект (у нас это веб-проект) имеет свой файл конфигурации, в котором содержатся свои строки подключения к БД, он в упор не видит файла конфигурации App.config проекта DataCore, и следовательно, не может подключиться к базе. Как результат, мы видим на экране сообщение EntityConnection error: The specified named connection is either not found in the configuration. Это исправляется легко - достаточно скопировать строку подключения из файла App.config в файл Web.config нашего веб-проекта.

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

<add name="DataCoreConnection" connectionString="metadata=.\FAQEngineModel.csdl|.\FAQEngineModel.ssdl|.\FAQEngineModel.msl;provider=System.Data.SqlClient;provider connection string="Data Source=.\SQLEXPRESS;Initial Catalog=FAQEngine;Integrated Security=True;MultipleActiveResultSets=True"" providerName="System.Data.EntityClient" />


Обратите внимание, на какие файлы метаданных она ссылается. Все эти файлы лежат в каталоге bin/Debug проекта DataCore (.\) - т.е. в каталоге, куда помещается dll. Но поскольку мы вызываем эту dll из другого проекта, то получается, что мы ищем эти файлы там, где их нет, и получаем новую ошибку: "The specified metadata path is not valid. A valid path must be either an existing directory, an existing file with extension '.csdl', '.ssdl', or '.msl', or a URI that identifies an embedded resource.". Нам остается два выхода - либо исправить строку подключения ,либо каждый раз копировать нужные файлы в какой-либо каталог веб-проекта.

Попробуем сначала первый способ. Лучше всего, если бы можно было указать относительный путь, например, с использованием какого-нибудь макроса. Единственное, что мне удается найти - это DataDirectory. Но, поскольку мы вызываем файлы из веб-приложения, этот макрос будет заменен каталогом App_Data, который нам вовсе ни к чему. Поэтому можно просто указать абсолютный путь к файлам на диске. Но потом, после деплоймента, эту строку придется менять. Поэтому этот способ мне не нравится.

Второй способ - копирование файлов - тоже не слишком хорош. Получается, что каждый раз, как только я изменяю модель, мне надо куда-то еще копировать сгенерированные файлы? Получается, что так... ладно, пусть это делает за меня студия. Открываем свойства проекта DataCore, идем в Post-Build event и пишем следующее:


copy $(TargetDir)FAQEngineModel.csdl $(SolutionDir)FAQEngine\App_Data\
copy $(TargetDir)FAQEngineModel.msl $(SolutionDir)FAQEngine\App_Data\
copy $(TargetDir)FAQEngineModel.ssdl $(SolutionDir)FAQEngine\App_Data\


Здесь мы просто копируем нужные файлы в каталог App_Data нашего веб-приложения. Теперь остается чуть подправить строку подключения (в файле Web.config):

<add name="DataCoreConnection" connectionString="metadata=|DataDirectory|\FAQEngineModel.csdl||DataDirectory|\FAQEngineModel.ssdl||DataDirectory|\FAQEngineModel.msl;provider=System.Data.SqlClient;provider connection string="Data Source=.\SQLEXPRESS;Initial Catalog=FAQEngine;Integrated Security=True;MultipleActiveResultSets=True"" providerName="System.Data.EntityClient" />

Все работает!
Решение данной проблемы описано на форуме ADO.NET Entity Framework.


Ссылки по теме:
Обзор ADO.NET Entity Framework
Блог Sergey Rozovik - посты, посвященные EF
Работа с макросами в Pre- и Post-Build events