четверг, 7 августа 2008 г.

URL rewriting в ASP.NET

URL rewriting - это такая штука, которая позволяет из некрасивых урлов с параметрами, вроде
www.somesite.ru/?cat=blogs&author=darja&year=2008&month=8
позволяет делать красивые, т.н. Человеку понятные урлы (ЧПУ), вроде
www.somesite.ru/blogs/darja/2008/8

Возникла у меня такая задача. Есть табличка (вернее, ListView), данные в которой располагаются на нескольких страницах. Пейджер у таблицы устроен так, что к N-ной странице можно обратиться http://localhost/Default.aspx?page=N И вот захотелось, чтобы к n-ной странице таблички можно было бы обратиться как http://localhost/N. Типа так ссылки приятнее выглядят и роботам хорошо. Итак, было найдено следующее решение.

URL rewriting

  1. Скачиваем библиотеку ThunderMain.URLRewriter.dll, кидаем в свой проект.
  2. В Global.asax добавляем:
    protected void Application_BeginRequest(Object sender, EventArgs e)
    {
      ThunderMain.URLRewriter.Rewriter.Process();
    }
    
  3. Вносим изменения в web.config. В configSections пишем:
    <sectionGroup name="system.web">
      <section name="urlrewrites" type="ThunderMain.URLRewriter.Rewriter, ThunderMain.URLRewriter,
     Version=1.0.783.30976, Culture=neutral, PublicKeyToken=7a95f6f4820c8dc3"/></sectionGroup>
    
    Далее, в system.web пишем:
    <urlrewrites><rule>
      <url>/(\d*)</url>
      <rewrite>Default.aspx?page=$1</rewrite></rule>
    </urlrewrites>
    
    Элементов rule может быть несколько. В узле url указывается регулярное выражение, которому должен удовлетворять url, который мы хотим переписывать, в rewrite - на что мы будем его заменять. Можно использовать стандартные для регулярных выражений группы ($1, $2 и т.д., как в перле). В нашем случае, по адресу http://localhost/N, где N - число, загрузится http://localhost/Default.aspx?page=N. Всё очень просто.
  4. Также потребуется некоторая донастройка IIS. А именно, нужно зайти в настройки сайта -> Home directory -> Configuration -> Mappings и добавить там обработчик для файлов .* такой же, как для aspx (ну там C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\aspnet_isapi.dll). И обязательно снять галочку "Verify that file exists".
UPDATE: Кстати, у меня тут практика показала, что прописывать в IIS мэппинг для .* не есть хорошо, потому что при этом ломаются стили и прочие статические файлы, потому что IIS и их начинает спрашивать с asp.net-a. Я в итоге, дабы не париться, сделала адреса вида http://localhost/N.htm и добавила в IIS мэппинг только для .htm

Пейджер у ListView

Итак, к страницам уже можно обращаться по адресу http://localhost/N. Но в ссылки пейджера всё ещё выглядят по-прежнему. Делаем следующее.
  1. Пейджер выносим в отдельный div и ставим у него style="display:none".
  2. Под листвью добавляем панельку:
    <asp:Panel ID="Pager" runat="server" onprerender="Pager_PreRender">
    </asp:Panel>
    
  3. В коде пишем:
    protected void Pager_PreRender(object sender, EventArgs e)
    {
      Pager.Controls.Clear();
    
      DataPager pager = (DataPager)RatingListView.FindControl("RatingPager");
    
      int count = pager.TotalRowCount;
      int pageSize = pager.PageSize;
      int pagesCount = count / pageSize + (count % pageSize == 0 ? 0 : 1);
      int pageSelected = pager.StartRowIndex / pageSize + 1;
    
      for (int i = 1; i <= pagesCount; ++i)
      {
        if (pageSelected != i)
        {
          HyperLink link = new HyperLink();
          link.NavigateUrl = link.ResolveUrl(i.ToString());
          link.Text = i.ToString();
          Pager.Controls.Add(link);
        }
        else
        {
          Label label = new Label();
          label.Text = i.ToString();
          Pager.Controls.Add(label);
        }
    
        Literal space = new Literal();
        space.Text = " ";
        Pager.Controls.Add(space);
      }
    }
    
    Здесь RatingListView - наш листвью, RatingPager - его "родной" пейджер.

Литература

вторник, 29 июля 2008 г.

Не надо использовать ничего из com.sun.*

В общем, я почти дописала свой мидлет. И оказалось, что на целевой телефон (Nokia N73) он вообще отказывается ставиться. Authorisation error и все. Погуглив, узнала, что во всем виноват пакет com.sun.midp.io.Base64. Люди пишут, что вообще не стоит использовать какие-либо возможности из пакетов, которые называются com.sun.*, ибо это приводит к несовместимости мидлета с некоторыми устройствами. А так как работа с Base64 все-таки нужна, пришлось добавить в код следующее:
private static char[] base64map = new char[64];

static
{
  int i = 0;
  for (char c = 'A'; c <= 'Z'; c++)
  {
    base64map[i++] = c;
  }
  for (char c = 'a'; c <= 'z'; c++)
  {
    base64map[i++] = c;
  }
  for (char c = '0'; c <= '9'; c++)
  {
    base64map[i++] = c;
  }
  base64map[i++] = '+';
  base64map[i++] = '/';
}

private static String base64Encode(byte[] in, int offset, int length)
{
  byte[] src = new byte[length];

  for (int i = 0; i < length; ++i)
  {
    src[i] = in[offset + i];
  }
  return base64Encode(src);
}

private static String base64Encode(byte[] in)
{
  int iLen = in.length;
  int oDataLen = (iLen * 4 + 2) / 3;// output length without padding
  int oLen = ((iLen + 2) / 3) * 4;// output length including padding
  char[] out = new char[oLen];
  int ip = 0;
  int op = 0;
  int i0;
  int i1;
  int i2;
  int o0;
  int o1;
  int o2;
  int o3;
  while (ip < iLen)
  {
    i0 = in[ip++] & 0xff;
    i1 = ip < iLen ? in[ip++] & 0xff : 0;
    i2 = ip < iLen ? in[ip++] & 0xff : 0;
    o0 = i0 >>> 2;
    o1 = ((i0 & 3) << 4) | (i1 >>> 4);
    o2 = ((i1 & 0xf) << 2) | (i2 >>> 6);
    o3 = i2 & 0x3F;
    out[op++] = base64map[o0];
    out[op++] = base64map[o1];
    out[op] = op < oDataLen ? base64map[o2] : '=';
    op++;
    out[op] = op < oDataLen ? base64map[o3] : '=';
    op++;
  }
  return new String(out);
}
Теперь вместо
content = Base64.encode(src, i * CHUNK_SIZE, CHUNK_SIZE);
нужно писать
content = base64Encode(src, i * CHUNK_SIZE, CHUNK_SIZE);

вторник, 15 июля 2008 г.

Upload картинки из мидлета на сервер

Сначала следует отметить, что у мидлета есть ограничение: он может передать по HTTP файл размером не больше чем в 2Кб. Так что будем передавать файл кусочками, а потом на сервере склеивать. Кроме того, картинку следует передавать не абы как, а в кодировке base64, иначе, опять же, будут возникать непонятные проблемы передачи (по крайней мере, у меня возникали).

Клиент

Пусть мы каким-то образом получили файл как byte[]. Напишем специальный класс Uploader, с помощью которого будем заливать картинку на сервер. Передавать файл будем фрагментами по 1 Кб. Для каждого фрагмента будем открывать отдельный коннекшен и составлять POST-запрос, содержащий следующие данные:
  • Номер фрагмента (number)
  • Общее количество фрагментов (count)
  • Собственно содержание (content) в виде вложенного файла
Добавим в наш мидлет следующие поля:
/**
* Размер передаваемого фрагмента картинки
*/
private static int CHUNK_SIZE = 1024;

/**
* Граница между разделами в HTTP-запросе
*/
private static String BOUNDARY = "42hjkiqp4279h";

/**
* Адрес, на который мы посылаем запросы
*/
private static String HOST = "http://some_url/Handler.ashx";

/**
* Сообщение, которое мы получаем в случае успешной отправки запроса
*/
private static String UPLOAD_OK_MSG = "Upload OK";
И следующие методы:
/**
* Залитие картинки на сервер
* @param src Массив символов, содержащий картинку
* @return Ответ от сервера: удалось залить, или нет
*/
public String Upload(byte[] src)
{
  String response = "";
  try
  {
    int nchunks = src.length / CHUNK_SIZE;
    System.out.println("size = " + src.length + ", chunks count = " + nchunks);

    String fileId = GetNextPhotoName(5);

    for (int i = 0; i < nchunks; ++i)
    {
      String content = Base64.encode(src, i * CHUNK_SIZE, CHUNK_SIZE);
      response = UploadChunk(fileId, i + 1, nchunks, content);
      if (!IsUploadOk(response))
      {
        break;
      }
    }
    System.out.println("Uploading file " + fileId + ": " + response);
  }
  catch (Exception ex)
  {
    System.out.println(ex.getClass().getName() + " while uploading file: " + ex.getMessage());
  }
  finally
  {
  }
  return response;
}

/**
* Формирование раздела HTTP-запроса, содержащего значение некоторого параметра
* @param name Имя параметра
* @param value Значение параметра
* @return Строка - раздел HTTP-запроса
*/
private static String FormData(String name, Object value)
{
  return "--" + BOUNDARY + "\r\n" +
      "Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n" + value + "\r\n";
}

/**
* Заливает на сервер фрагмента картинки
* @param fileId Идентификатор картинки
* @param number Номер фрагмента
* @param count Общее количество фрагментов
* @param content Содержаение фрагмента
* @return Ответ от сервера: удалось залить или нет
*/  
private String UploadChunk(String fileId, int number, int count, String content)
{
  String message =
      FormData("number", new Integer(number)) +
      FormData("count", new Integer(count)) +
      FormData("sessionId", sessionId) +
      "--" + BOUNDARY + "\r\n" +
      "Content-Disposition: form-data; name=\"imagefile\"; filename=\"" + fileId + "\"\r\n" +
      "Content-Transfer-Encoding: base64\r\n" +
      "Content-Type: image/png\r\n\r\n" + content + "\r\n" +
      "--" + BOUNDARY + "--\r\n";
  String response = SendPostData(message);

  System.out.println("Chunk " + fileId + "_" + number + " " + response);
  return response;
}

/**
* Отправляет на сервер POST-запрос
* @param message Текст запроса
* @return Ответ сервера
*/
private static String SendPostData(String message)
{
  HttpConnection conn = null;
  InputStream is = null;
  OutputStream os = null;
  String response = null;
  try
  {          
    conn = (HttpConnection) Connector.open(HOST);

    // Задаем параметры запроса
    conn.setRequestMethod(HttpConnection.POST);
    conn.setRequestProperty("Connection", "Keep-Alive");
    conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + BOUNDARY);

    byte[] messageBody = message.getBytes();          
    conn.setRequestProperty("Content-Length", Integer.toString(messageBody.length));

    // Посылаем запрос
    os = conn.openOutputStream();
    os.write(messageBody);

    int rc = conn.getResponseCode();          

    if (rc != HttpConnection.HTTP_OK)
    {
      throw new IOException("HTTP responce code: " + rc);
    }

    // Получаем ответ
    is = conn.openInputStream();
    int ch;
    StringBuffer data = new StringBuffer();
    while ((ch = is.read()) != -1)          
    {
      data.append((char) ch);
    }
    response = data.toString();
  }
  catch (Exception e)
  {
    response = "Error while connecting:\n\n" + e.getMessage();
  }
  finally
  {
    try
    {
      if (is != null)
      {
        is.close();
      }              
      if (os != null)
      {
        os.close();
      }
      if (conn != null)
      {
        conn.close();
      }
    }
    catch (Exception e)
    {
    }
  }
  return response;
}

/**
* Проверка, успешно ли произошел Upload на сервер
* @param response Ответ сервера
* @return
*/
private boolean IsUploadOk(String response)
{
  return response.equals(UPLOAD_OK_MSG);
}

Сервер

Сервер, как и раньше, написан на .NET. Есть хэндлер Handler.ashx, и в нем написано следующее:
namespace MobileServer
{
  [WebService(Namespace = "http://tempuri.org/")]
  [WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]
  public class Handler : IHttpHandler
  {
    private const string UPLOAD_PATH = "...";
    
    /// <summary>
    /// Получение пути ко временному файлу с фрагментом картинки
    /// </summary>
    private static string GetChunkFileName(string fileName, int number)
    {
    return String.Format("{0}\{1}_{2}", UPLOAD_PATH, f.FileName, number);
    }

    /// <summary>
    /// Получение пути к файлу с картинкой
    /// </summary>
    private static string GetFileName(string fileName)
    {
    return String.Format("{0}\{1}.png", UPLOAD_PATH, f.FileName);
    }
    
    /// <summary>
    /// Сохранение фрагмента картинки в файл
    /// </summary>
    /// <param name="number">Номер фрагмента
    /// <param name="f">Файл, полученный по HTTP
    private static void SaveChunkToFile(int number, HttpPostedFile f)
    {
      byte[] buf = new byte[f.ContentLength];
      f.InputStream.Read(buf, 0, f.ContentLength);
      String content = String.Empty;

      for (int i = 0; i < buf.Length; ++i)
      {
        if (buf[i] == 0)
          break;

        content += ((char)buf[i]).ToString();
      }

      byte[] rez = Convert.FromBase64String(content);

      string fileName = GetChunkFileName(f.FileName, number);

      FileStream fs = new FileStream(fileName, FileMode.CreateNew);
      BinaryWriter bw = new BinaryWriter(fs);
      bw.Write(rez, 0, rez.Length);
      bw.Flush();
      bw.Close();
      fs.Close();
    }
    
    /// <summary>
    /// Проверка, все ли фрагменты данной картинки залились
    /// </summary>
    /// <param name="fileName">Имя файла с картинкой
    /// <param name="count">Общее количество фрагментов
    private static bool AreAllChunksUploaded(string fileName, int count)
    {
      for (int i = 1; i <= count; ++i)
      {
        if (!File.Exists(GetChunkFileName(fileName, i)))
        {
          return false;
        }
      }
      return true;
    }

    /// <summary>
    /// Объединение всех фрагментов файла, и сохранение в отдельный файл
    /// </summary>
    /// <param name="fileName">Название файла
    private static void MergeChunks(string fileName)
    {
      for (int i = 1; i <= imageInfo.ChunksCount; ++i)
      {
        FileStream fs = new FileStream(GetChunkFileName(fileName, i), FileMode.Open);
        BinaryReader br = new BinaryReader(fs);
        byte[] buf = br.ReadBytes(2000);
        br.Close();
        fs.Close();

        fs = new FileStream(GetFileName(fileName), FileMode.Append);
        BinaryWriter bw = new BinaryWriter(fs);
        bw.Write(buf);
        bw.Close();
        fs.Close();
      }
    }

    #region IHttpHandler members
    public void ProcessRequest(HttpContext context)
    {
      try
      {
        int number = Convert.ToInt32(context.Request["number"]);
        int count = Convert.ToInt32(context.Request["count"]);
        string sessionId = context.Request["sessionId"];

        HttpPostedFile f = (HttpPostedFile)context.Request.Files[0];

        SaveChunkToFile(number, f);

        if (AreAllChunksUploaded(f.FileName, count))
        {
          MergeChunks(f.FileName);
          //TODO: код для удаления временных файлов
        }
        context.Response.Write("Upload OK");
      }
      catch (Exception e)
      {
        context.Response.Write("Upload failed:\n" + e.Message);
      }
    }

    public bool IsReusable
    {
      get
      {
        return false;
      }
    }
    #endregion
  }
}
В общем, как-то так.

четверг, 26 июня 2008 г.

Простейший мидлет с возможностью работы с HTTP

Задача: написать мидлет, который будет посылать некоторый POST-запрос на сервер и выводить ответ.

Аппаратное обеспечение

Проверялось на Sony Ericsson k320i и на Nokia 2600 classic.

Сервер

На сервере у нас простое Web-приложение, написанное на ASP.NET. Для этого приложения напишем хэндлер Handler.ashx, который будет принимать и обрабатывать запросы. К примеру, он будет получать значение переменной message, и возвращать строку "Message is <значение>"
[WebService(Namespace = "http://tempuri.org/")]
[WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]
public class Handler : IHttpHandler
{
  public void ProcessRequest(HttpContext context)
  {
    string msg = context.Request["message"];
    context.Response.ContentType = "text/plain";
    context.Response.Write(String.Format("Message is: {0}", msg));
  }

  public bool IsReusable
  {
    get
    {
      return false;
    }
  }
}

Создание мидлета

Для работы использовалась среда NetBeans 6.1. Создаем Mobile Application с профилем MIDP 2.0 (c 2.1 на указанных телефонах мидлет не заработал). На форму мидлета добавим StringItem и назовем MessageString. Также добавим на форму команду OkCommand, и в ее обработчик (не знаю, как это по-явовски правильно называется) добавим вызов функции CallServer Сама функция выглядит примерно таким образом:
public void CallServer()
{
  Runnable r = new Runnable()
  {
    public void run()
    {
      HttpConnection conn = null;
      InputStream is = null;
      OutputStream os = null;
      try
      {
        conn = (HttpConnection) Connector.open("http://localhost:1988/Handler.ashx");

        // Настройки соединения
        conn.setRequestMethod(HttpConnection.POST);
        conn.setRequestProperty("User-Agent", "Profile/MIDP-2.0 Configuration/CLDC-1.0");
        conn.setRequestProperty("Content-Language", "en-US");
        conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");

        // GenerateString - некоторая функция, возвращающая строку
        String str = "message=" + GenerateString() + "\r\n";

        // Посылка сообщения
        os = conn.openOutputStream();
        os.write(str.getBytes());
        //os.flush();

        // Смотрим, какой код пришел с сервера
        int rc = conn.getResponseCode();
        if (rc != HttpConnection.HTTP_OK)
        {
          throw new IOException("HTTP responce code: " + rc);
        }                 

        // Получение ответа и вывод его в строке MessageString
        is = conn.openInputStream();
        int ch;
        StringBuffer data = new StringBuffer();
        while ((ch = is.read()) != -1)
        {
          data.append((char) ch);
        }
        MessageString.setText(data.toString());
      }
      catch (Exception ex)
      {
        MessageString.setText("something failed: " + ex.getMessage());
      }
      finally
      {
          // Закрытие соединений
        try
        {
          if (is != null)
          {
            is.close();
          }
          if (os != null)
          {
            os.close();
          }
          if (conn != null)
          {
            conn.close();
          }
        }
        catch (Exception e)
        {
        }
      }
    }
  };
  new Thread(r).start();
}
При работе с HttpConnection обязательно нужно создавать отдельный поток (Runnable), потому что иначе при установке соединения будет возникать дедлок. А возникает он по следующей причине. Поток, инициирующий соединение, блокируются до тех пор, пока соединение не будет установлено. То есть, если код выполняется в системном потоке мидлета, то блокируется системный поток. И тут мидлет вдруг решает спросить у пользователя разрешения на выполнение операции соединения. Без разрешения соединение не установится, а разрешить не получится, потому что системный поток заблокирован. Отсюда дедлок. Статья по этому поводу

четверг, 12 июня 2008 г.

Коллекции в конфиге приложения

Задача была следующая. Нужно было написать робота, который будет ходить по тестовому сайту, делать там хиты, прикидываться разными пользователями, делать несколько сессий, да еще и работать на нескольких браузерах. Робот реализован в виде консольного приложения, для работы с браузерами используется Selenium, параметры (количество пользователей, сессий и хитов, а также список браузеров) хранятся в App.config. Собственно, здесь я напишу о том, как работать с параметрами из конфига.

Добавление в конфиг своей секции

Итак, мы хотим, чтобы в конфиге можно было написать так:
<VisitorSettings hitsPerDay="300" sessionsPerDay="21" duniqsPerDay="15"><Browsers>
  <browser name="chrome" />
  <browser name="iexplore"/>
  <browser name="opera" path="C:\Program Files\Opera\Opera.exe" />
  <browser name="custom" path="C:\Program Files\Safari\Safari.exe" /></Browsers>
</VisitorSettings>
Пишем следующие классы:

Класс для хранения конфигурации браузера

public class Browser : ConfigurationSection
{
  public const string SectionName = "Browsers";
  private const string name = "name";
  private const string path = "path";

  [ConfigurationProperty(name, IsRequired = true)]
  public string Name
  {
    get { return (string)base[name]; }
    set { base[name] = value; }
  }

  [ConfigurationProperty(path, IsRequired = false)]
  public string Path
  {
    get { return (string)base[path]; }
    set { base[path] = value; }
  }

  public string GetBrowserString()
  {
    return String.Format(
      "*{0}{1}{2}",
      Name,
      String.IsNullOrEmpty(Path) ? String.Empty : " ",
      Path
    );
  }
}

Класс для хранения конфигурации списка браузеров

[ConfigurationCollection(typeof(Browser), CollectionType = ConfigurationElementCollectionType.AddRemoveClearMap)]
public class BrowserElementCollection : ConfigurationElementCollection
{
  #region Constructor
  static BrowserElementCollection()
  {
    browsers = new ConfigurationPropertyCollection();
  }
  #endregion

  #region Fields
  private static ConfigurationPropertyCollection browsers;
  #endregion

  #region Properties
  protected override ConfigurationPropertyCollection Properties
  {
    get { return browsers; }
  }

  public override ConfigurationElementCollectionType CollectionType
  {
    get { return ConfigurationElementCollectionType.BasicMap; }
  }

  protected override string ElementName
  {
    get
    {
      return "browser";
    }
  }
  #endregion

  #region Indexers
  public Browser this[int index]
  {
    get { return (Browser)base.BaseGet(index); }
    set
    {
      if (base.BaseGet(index) != null)
      {
        base.BaseRemoveAt(index);
      }
      base.BaseAdd(index, value);
    }
  }
  #endregion

  #region Overrides
  protected override ConfigurationElement CreateNewElement()
  {
    return new Browser();
  }

  protected override object GetElementKey(ConfigurationElement element)
  {
    return (element as Browser).GetBrowserString();
  }
  #endregion
}

Класс для самой секции конфига:

public class VisitorSettings : ConfigurationSection
{
  static public string SectionName = "VisitorSettings";

  const string hitsPerDay = "hitsPerDay";
  const string duniqsPerDay = "duniqsPerDay";
  const string sessionsPerDay = "sessionsPerDay";
  const string browsers = "Browsers";

  [ConfigurationProperty(hitsPerDay, IsRequired = true)]
  public int HitsPerDay
  {
    get { return (int)base[hitsPerDay]; }
    set { base[hitsPerDay] = value; }
  }

  [ConfigurationProperty(sessionsPerDay, IsRequired = true)]
  public int SessionsPerDay
  {
    get { return (int)base[sessionsPerDay]; }
    set { base[sessionsPerDay] = value; }
  }

  [ConfigurationProperty(duniqsPerDay, IsRequired = true)]
  public int DuniqsPerDay
  {
    get { return (int)base[duniqsPerDay]; }
    set { base[duniqsPerDay] = value; }
  }
  [ConfigurationProperty(browsers, IsRequired = true)]
  public BrowserElementCollection Browsers
  {
    get { return (BrowserElementCollection)base[browsers]; }
    set { base[browsers] = value; }
  }
}
Готово! Теперь для полного счастья в App.config configSections осталось прописать:
<section name="VisitorSettings" type="Visitor.VisitorSettings, Visitor, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />

Чтение данных из конфига

Теперь можно писать так:
int uniques = WatcherConfiguration.VisitorSettings.DuniqsPerDay;
int sessions = WatcherConfiguration.VisitorSettings.SessionsPerDay;
int hits = WatcherConfiguration.VisitorSettings.HitsPerDay;
BrowserElementCollection browsers = WatcherConfiguration.VisitorSettings.Browsers;

Редактирование конфига из программы

Пусть теперь у нас есть админка, из которой мы должны иметь возможность добавлять и удалять браузеры. Сначала нужно добавить в класс BrowserElementCollection следующие методы:
#region Методы
public void Add(Browser browser)
{
  base.BaseAdd(browser);
}

public void Remove(string key)
{
  base.BaseRemove(key);
}

public void Remove(Browser browser)
{
  base.BaseRemove(GetElementKey(browser));
}

public void Clear()
{
  base.BaseClear();
}

public void RemoveAt(int index)
{
  base.BaseRemoveAt(index);
}

public string GetKey(int index)
{
  return (string)base.BaseGetKey(index);
}
#endregion
Теперь добавлять браузеры мы можем так:
protected void AddBrowser(string name, string path)
{
  Browser browser = new Browser()
  {
    Name = name;
    Path = path;
  };

  WatcherConfiguration.VisitorSettings.Browsers.Add(browser);
  WatcherConfiguration.SaveAll();
}
А удалять - так:
protected void AddBrowser(string name, string path)
{
  WatcherConfiguration.VisitorSettings.Browsers.RemoveAt(index);
  WatcherConfiguration.SaveAll();
}

понедельник, 2 июня 2008 г.

Отключение internal log в log4net

log4net имеет обыкновение выводить в консоль еще и свой собственный вывод. Выглядит это примерно так:
log4net: XmlHierarchyConfigurator: Configuration update mode [Merge].
log4net: XmlHierarchyConfigurator: Logger [root] Level string is [ALL].
log4net: XmlHierarchyConfigurator: Logger [root] level set to [name="ALL",value=-2147483648].
...
Называется это internal log. Когда мы пишем web-приложение, мы его не видим и не знаем. Но стоит начать писать консольное приложение, использующие log4net, как он тут же вылезает и начинает мешать. Для того, чтобы отключить, достаточно в секции appSettings конфигурационного файла приложения написать:
<add key="log4net.Internal.Quiet" value="true" />

понедельник, 26 мая 2008 г.

GUI-тесты на Selenium

Задача следующая: надо провести один и тот же набор GUI-тестов на разных браузерах.

Как завести Selenium Server

Сначала идем по адресу и качаем последнюю версию Selenium RC. В архиве будет много папочек с названиями типа selenium-dotnet-client-driver. В конце будет папочка selenium-server. Ее надо распаковать в какое-нибудь известное место. Selenium server запускается примерно такой командой:
start java -jar selenium-server.jar -multiWindow -debug -timeout 100000
Открывается консольное java-приложение, которое в начале напишет что-то вроде:
11:20:19.413 INFO - Java: Sun Microsystems Inc. 10.0-b19
11:20:19.433 INFO - OS: Windows XP 5.1 x86
11:20:19.433 INFO - v1.0-beta-1 [2201], with Core v1.0-beta-1 [1994]
Сервер должен работать на протяжении всех тестов.

Как написать тесты

Тесты будем писать на C#. Для организации тестов используется NUnit, так что его тоже надо будет сначала выкачать и поставить. Теперь создаем в Visual Studio проект Tests типа Class Library. В References надо добавить сборки nunit.framework и ThoughtWorks.Selenium.Core. Кроме того, нужно будет зайти в Properties проекта, пойти на вкладку Debug и указать в графе Start external program путь до nunit.exe, а в Command line arguments - Tests.dll. Это нужно для того, чтобы можно было запускать и отлаживать тесты прямо из Visual Studio. Теперь можно и писать тестовые классы. Наша задача состоит в том, чтобы одни и те же тесты выполнились на нескольких браузерах. Напишем следующий класс:
using NUnit.Framework;
using Selenium;

namespace Tests
{
  public abstract class GuiTestCommon
  {
    protected ISelenium selenium;
    protected static string browserString;

    protected virtual void ConfigureSelenium()
    {
      selenium = new DefaultSelenium("localhost", 4444, browserString, "http://www.google.ru");
      selenium.Start();
    }

    [SetUp]
    public void SetupTest()
    {
      ConfigureSelenium();
    }

    [TearDown]
    public void TeardownTest()
    {
      selenium.Stop();
    }

    [Test]
    public void GoogleSearch()
    {
      selenium.Open("/");
      Assert.AreEqual("Google", selenium.GetTitle());
      selenium.Type("q", "Selenium OpenQA");
      Assert.AreEqual("Selenium OpenQA", selenium.GetValue("q"));
      selenium.Click("btnI");
      selenium.WaitForPageToLoad("50000");
    }
  }
}
В коде, в принципе, все довольно понятно. Есть объект selenuim, посредством которого осуществляются все манипуляции в браузере. В SetupTest этот объект создается, в тесте GoogleSearch он идет в гугл, набирает там "Selenium OpenQA", нажимает кнопку "Мне повезет" и ждет какое-то время, пока страница загрузится. В конфигурации мы специально не указали конкретное значение browserString. Теперь можно унаследовать от данного класса отдельные классы для каждого интересующего нас браузера. Получим что-то вроде:
using NUnit.Framework;

namespace Tests
{
  [TestFixture]
  public class GuiTestFirefox : GuiTestCommon
  {
    protected override void ConfigureSelenium()
    {
      browserString = "*firefox";
      base.ConfigureSelenium();
    }
  }
}
Это класс, тестирующий Firefox. Аналогично можно написать для IE ("*iexplore") и еще много для чего.

Замечания

  • Кое-кто утверждает, что для Firefox browserString должна быть не "*firefox", а "*chrome". Тесты работает и так, и так, но говорят, что во втором случае не вылезает ошибка "Permission denied to get property Location.href". Спорить не буду, но на память запишу
  • Не следует в Open использовать абсолютные адреса. Так, вместо
    selenium.Open("http://www.google.ru")
    
    лучше написать
    selenium.Open("/")
    
    Опять же, по той же причине.
  • Слабое место Selenium - это всевозможные Wait (как то: WaitForPageToLoad, WaitForCondition и т.д.). Тут бывают баги. В частности, приведенный тест иногда не проходит в IE: выдается то таймаут, то, опять же, "Permission denied to get property Location.href"

пятница, 25 апреля 2008 г.

Написание своего фильтра для log4net

Например, я использую NHibernate. Он использует log4net чтобы выводить свои сообщения, а я использую log4net для своих целей. И куча хибернейтовских сообщений мне мешают. Очевидное решение данной проблемы - написать собственный фильтр. Фильтр должен наследоваться от абстрактного класса FilterSkeleton. Вот, что получилось у меня:

using System;
using log4net.Filter;

namespace Common
{
  class SourceFilter : FilterSkeleton
  {
    private string source;
    private bool acceptOnMatch;

    public string Source
    {
      get { return source; }
      set { source = value; }
    }

    public bool AcceptOnMatch
    {
      get { return acceptOnMatch; }
      set { acceptOnMatch = value; }
    }

    public override FilterDecision Decide(log4net.Core.LoggingEvent loggingEvent)
    {
      if (loggingEvent.LocationInformation.ClassName.Contains(source) == acceptOnMatch)
        return FilterDecision.Accept;
      else
        return FilterDecision.Deny;
    }
  }
}
После этого можно в Web.config добавить в appender следующее:
<filter type="Orpo.Common.SourceFilter">
  <Source value="NHibernate"/>
  <AcceptOnMatch value="false"/>
</filter>
Все, фильтр готов, сообщения из источника NHibernate никуда не выводятся.