вторник, 30 марта 2010 г.

Пишем CMS. Шаг № 8. Unit-тестирование

Это архиполезная вещь, особенно в проектах, которые имеют функционал работы с данными, с БД, например. Я расскажу, как я организую юнит-тестирование в своих проектах.

1. Перво-наперво качаем последнюю версию NUnit (откуда, найдете сами).

2. Создаем в солюшене отдельный проект типа ClassLibrary и добавляем референс на библиотеку nunit.framework.dll, которая лежит в папке framework каталога, куда распаковался наш nunit.

3. Создаем новый класс, добавляем в него ссылку на пространство имен NUnit.Framework, затем перед классом добавляем атрибут [TestFixture], а перед методом-тестом (который должен быть обязательно public и возвращать void - атрибут [Test].

4. Теперь надо организовать удобный запуск тестов. Я обычно делаю так. В окне свойств проекта с тестами щелкаю вкладку Debug, а там - Start external program, где указываю путь к nunit.exe. А сам проект устанавливаю как SetUp as statrup project. Это значит, что теперь, когда я нажимаю F5, запускается именно этот проект, вернее, запускается оболочка nunit.exe, которая подтягивает наши проекты.

5. Теперь надо создать проект среды тестирования. Для этого нажмем F5 и увидим диалог NUnit, там мы выберем меню New Project, введем название и сохраним проект где-нибудь (я предпочитаю корневую папку проекта с тестами). Теперь можно добавить туда нашу dll с тестами (она обычно лежит в bin/debug) - через меню Project - Add assembly (некоторые версии NUnit имеют такой баг - после того, как вы создаете проект и загружаете туда dll, он не подхватывает библиотеку, выдает какую-то странную ошибку. Если это произошло, просто сохраните проект и закройте оболочку, затем запустите снова - должно пройти.)

6. Ну вот и все! Теперь можете писать свои тесты и запускать их. Да! Одна маленькая, но очень важная деталь - если вы не обратите на это внимание, вы можете часами биться об стену, пытаясь понять, в чем дело. А дело в том, что если вы работаете например, с ADO.Net Entity Framework и вам надо потестировать, хорошо ли из базы забираются данные, то вы обязательно должны скопировать файл App.Config в папку проекта с тестами - именно отсюда фреймворк будет брать информацию по соединению с БД. Но и это еще не все. В свойствах NUnit проекта в поле Configuration File Name нужно обязательно указать имя этого конфигурационного файла.

Теперь, кажется, все... удачного тестирования!

среда, 17 марта 2010 г.

Пишем CMS. Шаг № 7. Шаг вперед, два шага назад. Рефакторинг

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

Добавим в ресурсный файл Messages переменную по имени TransferPageMessage, что-то вроде "Вы будете перемещены на страницу {0} через {1} сек. Или, если ваш браузер не поддерживает автоматическое перенаправление, перейдите по ссылке ниже:"

Изменим содержимое контрола:
  <asp:Panel ID="panelInfo" runat="server" Visible="true">
    <asp:Label ID="labInfo" runat="server" Text="" CssClass="LabelInfo"></asp:Label>
    <asp:Label ID="labLink" runat="server" CssClass="LabelInfo" Visible="false"></asp:Label> 
    <asp:HyperLink ID="linkInfo" runat="server" CssClass="LinkInfo" Visible="false">
    </asp:HyperLink>
  </asp:Panel>


* This source code was highlighted with Source Code Highlighter.


А теперь обновим код:

public void ShowFail(string Message)
{
  this.Visible = true;
  panelInfo.CssClass = "PanelInfoFail";
  if (Message != string.Empty)
    labInfo.Text = Message;
}
public void ShowOk(string Message, string PageToName, string LinkLabelCode, string Link, int SecondsToTransfer)
{
  this.Visible = true;
  panelInfo.CssClass = "PanelInfoOK";
  if (Message != string.Empty)
    labInfo.Text = Message + "<br><br>";
  if (PageToName != string.Empty)
  {
    labLink.Visible = true;
    labLink.Text = string.Format(Messages.TransferPageMessage, PageToName, SecondsToTransfer) + "<br><br>";
  }
  if (LinkLabelCode != string.Empty && Link != string.Empty)
  {
    linkInfo.Visible = true;
    linkInfo.Text = LinkLabelCode;
    linkInfo.NavigateUrl = Link;
  }
}

* This source code was highlighted with Source Code Highlighter.


По-моему, стало лучше. И не забудем обновить вызовы в коде нашей страницы:
try
{
  if (context == null)
    context = new PBPWebDatabaseConnection();
  if (languageObject != null)
  {
    languageObject.LanguageTitle = Server.HtmlEncode(txtLangName.Text);
    languageObject.LanguageCode = Server.HtmlEncode(txtLangCode.Text);
    if (filePathToUpload != "")
      languageObject.IconName = Path.GetFileName(filePathToUpload);
    if (languageObject.LanguageId == 0)
      context.AddObject("Language", languageObject);

    int saved = context.SaveChanges();
    if (saved == 1)
    {
      panelInfo.ShowOk(Messages.LanguageSaveOK, Messages.LanguagePageToTransfer,
        Messages.LabelLinkToLanguages, Pages.Languages, 4);
      Master.AddRedirect(Pages.Languages, 4);
      panelMain.Visible = false;
      result = true;
    }
  }
}
catch (Exception ex)
{
  //log
}
if (!result)
  panelInfo.ShowFail(Messages.LanguageSaveFail);

* This source code was highlighted with Source Code Highlighter.


До идеала, конечно, далеко, но мы постепенно будем улучшать код. Вот такие сообщения у меня получились в итоге:

четверг, 11 марта 2010 г.

Пишем CMS. Шаг № 6. Добавление нового языка

Продолжим.
Сегодня мы добавим функциональность на страницу LanguageEdit.aspx, и сможем добавлять новые языки или редактировать уже существующие.

Для начала определим две переменные: одна будет представлять собой объект Language, который мы создали еще раньше с помощью ADO.NET Framework Entity, а вторая - собственно инструмент работы с БД - контекст:
private Language languageObject;
private DatabaseConnection context;


* This source code was highlighted with Source Code Highlighter.


Далее в обработчик загрузки страницы Page_Load добавим проверку на наличие id:
      if (Request["id"] != null)
      {
        int id = 0;
        int.TryParse(Request["id"], out id);
        if (id != 0)
        {
          try
          {
            context = new DatabaseConnection();
              var lan = from l in context.Language
                   where l.LanguageId == id
                   select l;
              languageObject = (Language)lan.First();
          }
          catch
          {
            //log
          }
        }
      }
      if (languageObject == null)
      {
        languageObject = new Language();
      }
      if (!IsPostBack)
        Page.DataBind();


* This source code was highlighted with Source Code Highlighter.


Логика работы этого блока проста: если мы получаем в качестве параметра id, то извлекаем из базы объект по этому id. Если же параметра нет, значит, мы хотим создать новый язык.

В обработчик события Unload можно добавить "уничтожение" объекта context.Dispose(), хотя, полагаю, ресурсы и так будут автоматически очищены по завершении обработки страницы.

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

Мы подошли к ключевому вопросу - привязка данных к форме. ASP.NET предлагает несколько контролов для привязки данных, но после некоторой возни с ним мне пришлось отказаться от их использования. Фактически, только два контрола - DetailsView и FormView - позволяют отображать данные по одному объекту, но мне так и не удалось заставить их привязываться к EntityObject. По какой-то, неведомой мне причине, эти контролы в качестве источника данных требуют обязательно объект, который наследут IListSource, IEnumerable, или IDataSource. Наш объект EntityObject не реализует ни один из этих интерфейсов. В качестве источника данных можно было бы использовать ObjectDataSource, но для этого нужно соблюсти определенные требования к классам доступа DAL - поскольку мы используем классы, сгенерированные EntityFramework, использование их не получается. Можно, конечно, было бы написать обертку к этим классам, но плодить лишний код не хочется совершенно.

После некоторых раздумий, а также просмотра обновленного кода проекта TheBeerHouse пришлось остановится на простой односторонней привязке данных к объекту. Что это значит? Очень просто - при открытии страницы мы создаем объект, а наши поля, привязанные к нему, заполняются автоматически. Когда мы хотим сохранить измененный объект, мы извлекаем содержимое полей вручную. Полагаю, ручное извлечение неизбежно - ведь объект еще не сохранен, а значит, поля невозможно привязать. Если кто подскажет более удачное решение, будет здорово.

Итак, создаем на странице простое свойство, которое имеет только метод get и возвращает наш объект:
    protected Language LanguageObject
    {
      get
      {
        return languageObject;
      }
    }


* This source code was highlighted with Source Code Highlighter.


На страницу добавляем текстбоксы и осуществляем привязку, например:
<asp:TextBox ID="txtLangCode" runat="server"
          Text="<%# LanguageObject.LanguageCode %>" Width="214px" />


* This source code was highlighted with Source Code Highlighter.


А теперь вернемся к первому коду, обратите внимание:
if (!IsPostBack)
    Page.DataBind();


* This source code was highlighted with Source Code Highlighter.


Этот код проверяет, был ли постбэк, и, если нет, привязывает все контролы формы. Если мы будем делать привязку и после постбэка, то введенные нами данные в форму будут затерты извлеченными из объекта, что нам совсем не нужно.

Ну вот, осталось дело за малым: сохранить измененные данные. Кидаем на форму кнопку, добавляем событие Click и в обработчике пишем примерно такой код:
bool result = false;
try
      {
        if (context == null)
          context = new PBPWebDatabaseConnection();
        if (languageObject != null)
        {
          languageObject.LanguageTitle = Server.HtmlEncode(txtLangName.Text);
          languageObject.LanguageCode = Server.HtmlEncode(txtLangCode.Text);
          if (filePathToUpload != "")
            languageObject.IconName = Path.GetFileName(filePathToUpload);
          if (languageObject.LanguageId == 0)
            context.AddObject("Language", languageObject);

          int saved = context.SaveChanges();
          if (saved == 1)
          {
            panelInfo.Activate(true, Messages.LanguageSaveOK, Messages.LabelLinkToLanguages, Pages.Languages);
            Master.AddRedirect(Pages.Languages, 4);
            panelMain.Visible = false;
            result = true;
          }
        }
      }
      catch (Exception ex)
      {
        //log
      }
      if (!result)
        panelInfo.Activate(false, Messages.LanguageSaveFail, string.Empty, string.Empty);


* This source code was highlighted with Source Code Highlighter.


Сначала мы проверяем, не нуль ли контекст. Если вдруг он равен null, создаем новый. Объект languageObject уже должен быть создан к этому времени (пустой или взятый из базы). Перезаписываем его свойства данными из полей формы. Затем, если объект был заново созданный (id у него равно 0), то добавляем в контекст. Если нет, ничего не делаем, потому что в этом случае объект уже привязан к контексту (это произошло, когда мы извлекали объект по id). Осталось просто сохранить объект и отреагировать на полученный результат.

Вроде ничего сложного. Если что непонятно, спрашивайте. А загрузку файлов мы чуть попозже обсудим.

среда, 10 марта 2010 г.

Пишем CMS. Шаг № 5. Перенаправляем пользователя

Итак, мы показали пользователю сообщение, а том, что все в порядке, и теперь хотим через 4 секунды перенаправить его на другую страницу.

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

<meta http-equiv="Refresh" content="4;url=http://site.com/anotherpage.aspx">

* This source code was highlighted with Source Code Highlighter.


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

Сначала добавим в .cs файл мастер-страницы наш код:

    public void AddRedirect(string PageToCode, int SecondsToWait)
    {
      HtmlMeta metaRedirect = new HtmlMeta();
      metaRedirect.HttpEquiv = "Refresh";
      metaRedirect.Content = string.Format("{0};url={1}", SecondsToWait, ResolveUrl(PageToCode));
      this.Page.Header.Controls.Add(metaRedirect);
    }


* This source code was highlighted with Source Code Highlighter.



Теперь откроем страницу LanguageEdit.aspx, с помощью которой мы редактируем и добавляем языки. Там мы добавим следующую директиву:

<%@ MasterType VirtualPath="~/Main.Master" %>

* This source code was highlighted with Source Code Highlighter.


Эта директива позволяет нам обращаться к мастер странице не просто как к объекту типа System.Web.UI.MasterPage, а к объекту нашего конкретного типа. Если вы запутались, поясняю: когда мы добавляем в проект мастер-страницу, всегда создается класс, который наследует вышеприведенный класс. Однако наша страница имеет объект Master, который всегда определен как MasterPage, и поэтому любые дополнительные члены мастера будут нам недоступны. Мы, конечно, можем осуществлять приведение типа ((Main)Master).OurMethod(), но согласитесь, это не очень красиво. Зато теперь наша страница знает, что мастер не просто MasterPage, а Main, и поэтому мы может осуществлять вызов метода напрямую:

Master.AddRedirect(Pages.Languages, 4);

* This source code was highlighted with Source Code Highlighter.


Здесь мы опять же передаем переменную из ресурсного файла, которая имеет значение ~/Languages/Languages.aspx - т.е. страница, которая показывает список всех языков.

Да, не забываем скрывать основную часть формы. Для этого ее (основную часть) можно разместить на панели и просто выставлять ей Visible = false. Не волнуйтесь, что она потащит за собой все данные. ASP.NET, когда видит что что-то невидимое, даже не генерирует html-код.

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

вторник, 9 марта 2010 г.

Пишем CMS. Шаг № 4. Показываем пользователю результат действия

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

Итак, как же сказать пользователю "Все в порядке!"? Первое, что приходит в голову - разместить на странице Label, в методе обработке результата менять цвет и текст в зависимости от результата. Что мне не нравится в этом подходе? Ну допустим мы определяем внешний вид стилями, которые храним в файле CSS, одном на весь сайт. А что, если мы захотим рядом с текстом добавлять картинку или еще как-то украсить его? Будет весьма затруднительно поменять Label на Div на всех страницах, где мы используем сообщения. Поэтому мы так делать не будем. А мы поэтому создадим простой пользовательский контрол. Делается это очень просто:

1. Добавляем новый item в наш проект (предлагаю сначала создать папку Controls и хранить все контролы там), выбираем тип Web User Control.

2. Открываем файл ascx и добавляем такую панель:

  <asp:Panel ID="panelInfo" runat="server" Visible="true">
    <asp:Label ID="labInfo" runat="server" Text="" CssClass="LabelInfo"></asp:Label> <p></p> 
    <asp:HyperLink ID="linkInfo" runat="server">
      <asp:Label ID="labLink" runat="server" Text=""></asp:Label>
    </asp:HyperLink>
  </asp:Panel>


* This source code was highlighted with Source Code Highlighter.



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

Теперь заглянем в код. У меня это выглядит так:

public void Activate(bool? IsResultOK, string Message, string LinkLabelCode, string Link)
    {
      this.Visible = true;
      if (IsResultOK == null)
      {
        panelInfo.CssClass = "PanelInfoFYI";
      }
      if (IsResultOK.Value)
      {
        panelInfo.CssClass = "PanelInfoOK";
      }
      else if (!IsResultOK.Value)
      {
        panelInfo.CssClass = "PanelInfoFail";
      }
      if (Message != string.Empty)
        labInfo.Text = Message;
      if (LinkLabelCode != string.Empty && Link != string.Empty)
      {
        linkInfo.Visible = true;
        string linkText = LinkLabelCode;
        linkInfo.Text = linkText;
        labLink.Text = linkText;
        linkInfo.NavigateUrl = Link;
      }
      else
        linkInfo.Visible = false;
    }


* This source code was highlighted with Source Code Highlighter.



Первый параметр говорит о том, каков результат - если все плохо (false), то подключаем стиль ошибки, если хорошо (true) - положительный результат. Иногда требуется просто проинформировать пользователя, в этом случае передадим null и отобразим сообщение голубеньким.

Теперь посмотрим, как происходит вызов этого метода. После того, как мы положили на форму наш контрол (не забываем о регистрации - <%@ Register src="../Controls/InfoPanel.ascx" tagname="InfoPanel" tagprefix="controls" %> ), мы можем обращаться к нему напрямую:


if (result)
panelInfo.Activate(true, Messages.LanguageSaveOK, Messages.LabelLinkToLanguages, Pages.Languages);


Вот тут немного интереснее. Что это за Messages и Pages? Это имена ресурсных файлов (вернее, классов, которые сгенерировались как только мы добавили файл ресурса). Зачем, спрашивается? Нам нужно, чтобы ссылка отображала текст. Мы, конечно, можем просто захардкодить этот текст, но, во-первых, мы с самого начала договорились, что будем писать красивый код, а во-вторых, и это, конечно, главное, подобный подход может привести к очень большому геморрою в будущем. Представим себе, что мы таким образом захардкодили сообщений эдак... двести по всему проекту... а потом пришла директива сверху: все сообщения начинать со слов "Дорогой пользователь!" И вот мы ползаем по сайту, и, чертыхаясь, исправляем все двести сообщений...

В-общем, наши сообщения мы будем хранить в ресурсных файлах. Если что, его даже без проблем можно будет перевести на другие языки. Но сейчас для наших целей нам нужно два обычных ресурсных файла. Один - для сообщений, второй - для адресов страниц. Второй, возможно, нам в будущем не понадобится (пока не могу точно сказать), но пока что он избавляет от необходимости хардкодить ссылки, по которым надо переходить.

Можно, конечно, хранить сообщения в отдельном классе, но по-моему, в ресурсах хранить удобнее. Теперь мы с легкостью можем ссылаться на любые переменные, добавленные в текстовый ресурс, кроме того, студия позволяет сортировать переменные по именам и значениям, что, согласитесь, очень удобно. Значения, которые мы забиваем в табличке ресурсов, можно посмотреть в файле <НазваниеРесурса>.designer.cs

В следующем шаге мы перенаправим пользователя на указанную страницу.

пятница, 5 марта 2010 г.

Пишем CMS. Шаг № 3. Обработка действий пользователя

Не волнуйтесь, друзья, все в порядке. Никто ничего не забросил. Шла работа мысли. Оказывается, такие достаточно простые вопросы, как оповещение пользователя и разработка поведения системы в целом вовсе не являются простыми. То есть то, что получилось в результате, конечно, не является биномом Ньютона, однако, как показывает практика, огромное количество сайтостроителей правильный, хотя и простой подход игнорируют начисто. Я имею в виду либо отсутствие таких очевидных вещей, как редирект на нужную страницу, либо отсутствие вообще какой-то информабельности. Интернет-серферы меня поймут - как, бывает, бесит, когда регистрация заканчивается ничем! И непонятно, зарегистрировала тебя система или нет, а если да, то почему ничего не сказала?

Рассмотрим несколько моделей поведения системы.
1. Пользователь нажимает кнопку "Сохранить", система делает Postback и на странице появляется сообщение, что все в порядке. И что потом? Если пользователь нажмет кнопку "Сохранить" или "Отправить", что произойдет? Это зависит от того, что вообще происходит. Вполне может вылететь ошибка, если это, например, удаление, и производится дважды. И вообще, зачем, например, опять показывать пользователю форму с только что введенными данными, если все прошло нормально? Фтопку.

2. Пользователь нажимает кнопку "Сохранить", система благополучно перенаправляет его на список чего-нибудь (где пользователь увидит добавленное). Немного лучше. По крайней мере F5 не будет вызывать ошибку. Минусы этого дела:
- чтобы страница могла определить, какое сообщение показывать, очевидно, надо передавать его через строку запроса, которая будет иметь некрасивый вид;
- гораздо более важно то, что если перенаправление в случае ошибки производится на эту же страницу, пользователь теряет все введенные данные.
Не подходит тоже.

3. Нечно среднее (как сделано в форуме PHPbb). Пользователь видит сообщение об успешной отправке данных 3-4 секунды, после чего система автоматически перенаправляет его на нужную страницу. В случае ошибки пользователь также видит сообщение, перенаправления не происходит, и все данные, введенные пользователем, по-прежнему видны. Если браузер не поддерживает автоматическое перенаправление, пользователь видит на странице, где сообщение, ссылку, и переходит по ней вручную. По-моему, отличный подход.

Вот его мы и будем реализовывать. Рассмотрим сценарий с точки зрения asp.net. Пользователь нажимает кнопку, происходит Postback, мы возвращаемся на ту же страницу, обрабатываем введенные данные. Если все в порядке, показываем сообщение (а саму форму можно спрятать, чтобы не путать пользователя) со ссылкой, примерно 3-4 секунды, после чего перенаправляем пользователя на нужную страницу. Если ошибка, ничего особенного не делаем, просто показываем сообщение. Все данные, введенные пользователем, в результате будут сохранены.

Теперь разобьем задачу на несколько подзадач:
1. Как показывать сообщение и ссылку
2. Как обрабатывать данные
3. Как перенаправить пользователя

Начнем с первой задачи, поскольку она независима, и будет намного лучше, если к моменту обработки данных мы будем знать, что делать с результатом. Как организовать сообщение о результате - в следующем шаге.