Realtime web logger на MongoDB, часть 1

Мне как человеку любознательному интересно знать не только демографику посещений моих сайтов, но также демографику посещения сторонних (чужих) сайтов. Зачем? Ну, например мне надо знать сколько человек посмотрели мою статью на Хабре или… или чужую статью, или чьего-то блога, например. В этом посте я начну рассказ про собственный логгер с испoльзованием Asp.Net MVC2 и MongoDB.

Идея

Все гениальное просто – чтобы трекать чужой ресурс, нужно иметь возможность постить на нем картинки. Тогда можно на своем сервере обрабатывать запрос и возвращать однопиксельный прозрачный GIF дабы не “палиться”. В свое время я реализовал именно это, и написал про это коротенький пост. Если коротко, то в те времена (WebForms) у меня на сервере крутилась страничка под названием default.aspx (красивого роутинга не было) у которой в Page_Load() было примитивное сохранение данных о посетителе

using (var dc = new VisitsDataContext())
{
  Visit v = new Visit();
  v.DateAndTime = DateTime.UtcNow;
  v.IPAddress = Request.UserHostAddress;
  v.ReferrerURL = Request.UrlReferrer != null ? Request.UrlReferrer.AbsoluteUri : null;
  dc.Visits.InsertOnSubmit(v);
  dc.SubmitChanges();
}

ну и созврат однопиксельного GIF’а с последующим “отрезанием” ответа. (Кстати, так и не понял как в WebForms отключить возвращение собственно страницы. Сейчас правда это уже не важно ибо [Obsolete]).

using (Bitmap bmp = (Bitmap)Bitmap.FromFile(Server.MapPath("OnePixel.gif")))
{
  Response.ContentType = "image/gif";
  bmp.Save(Response.OutputStream, ImageFormat.Gif);
}
Response.End();

Моя идея достаточно неплохо работала, хотя я толком не дописал reporting (потерял интерес), в результате чего страничка отчета выглядит не очень-то опрятно, да и к тому же тормозит (это вина и EF и SQL Server – обсудим через минуту).

Прошло много времени и я нашел сервис под названием LLoogg, который предоставляет псевдо-realtime статистику по посещениям сайта. Проект этот построен на Redis – NoSQL базе данных которой, к сожалению, я не могу воспользоваться т.к. она не поддерживает Windows (да-да, и такое бывает). Но учитывая что объем данных у меня будет не безумный, я решил попробовать MongoDB для этой важной задачи. В качестве драйвера я выбрал NoRM.

Итак, что же я хочу? Мне интересно получить следующие фичи:

  • Более детальную информацию по посещениям – IP адрес (и страну, через геолокацию), информацию о браузере, дату и время посещения
  • Хочется все видеть через AJAX-обновления
  • Хочется красивый “вебдванольный” reporting, с графиками, jQuery, шахматами и поэтессами

Помимо использования MongoDB я буду использовать Asp.Net MVC2 т.к. WebForms я не очень хорошо помню, а workflow для создания классных сайтов на MVC уже налажен – спасибо jQuery и Telerik MVC. Итак, поехали.

Наброски

Начнем с того, что посмотрим как получать информацию о единичном посещении. Вообще вся информация идет из Requestа, так что ее можно просто собрать и, используя магию DLR (заметили как вьюшки дефолтно-типизированы как dynamic?) передать все это в представление:

public ActionResult Test()
{
  dynamic d = new ExpandoObject();
  d.Referrer =
    Request.UrlReferrer == null
      ? string.Empty
      : Request.UrlReferrer.AbsoluteUri;
  d.IP = Request.UserHostAddress;
  d.Country = CountryCodes.GetCountryName(
    Geolocation.GetCountryCode(d.IP));
  d.Browser = Request.UserAgent;
  return View(d);
}

Код выше достает все нужные данные. UrlReferrer в данном случае – это Url от которого пришел запрос. Если запросить картинку напрямую (написать в браузере mydomain.com/mysite/test – это значение будет равно null, и такого посетителя логгировать не нужно.

Теперь поясню что же за классы такие Geolocation и CountryCodes. Первый занимается тем, что формирует запрос к сервису который по IP адресу выдает страну:

public static string GetCountryCode(string ipAddress)
{
  string request = "http://api.wipmania.com/" + ipAddress + "?google.com";
  try
  {
    var rw = WebRequest.Create(request);
    using (var resp = rw.GetResponse())
    using (var s = resp.GetResponseStream())
    using (var sr = new StreamReader(s))
      return sr.ReadToEnd();
  } 
  catch (Exception ex)
  {
    return string.Empty;
  }
}

Но не все так просто – это сервис возвращает двухбуквенный код страны (например RU), и для перевода в нормально-читабельное состояние табличка нужна. Для этого у нас как раз класс CountryCodes имеется.

Сущность

Увы, POCO не получится раз мы NoRM используем. Для хранения данных подойдет вот такая структура:

public class Visit
{
  public ObjectId Id { get; set; }
  public string IP { get; set; }
  public string Browser { get; set; }
  public string CountryCode { get; set; }
  public string Source { get; set; }
  public DateTime When { get; set; }
  public Visit()
  {
    Id = ObjectId.NewObjectId();
  }
  public override string ToString()
  {
    return string.Format("Visited {0} from IP={1} in {2} at {3} with {4}",
      Source, IP, CountryCodes.GetCountryName(CountryCode),
      When, Browser);
  }
}

Хранение и доступ

Для начала обсудим запись данных в базу. Тут все прозаично:

public ActionResult Log()
{
  // ensure there's a referrer
  using (var mongo = new Mongo("visitlogger", "localhost", "27017", ""))
  {
    var visits = mongo.GetCollection<Visit>("visits");
    var v = new Visit
              {
                When = DateTime.Now,
                Browser = Request.UserAgent,
                IP = Request.UserHostAddress,
                CountryCode = Geolocation.GetCountryCode(Request.UserHostAddress),
                Source = Request.UrlReferrer == null
                            ? string.Empty
                            : Request.UrlReferrer.AbsoluteUri
              };
    if (v.Source != string.Empty)
      visits.Insert(v);
  }
  return new ImageResult { ImageFormat = ImageFormat.Gif, Image = singlePixel };
}

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

public ActionResult Index()
{
  dynamic viewData = new ExpandoObject();
  using (var mongo = new Mongo("visitlogger", "localhost", "27017", ""))
  {
    viewData.Visits = mongo.GetCollection<Visit>("visits")
      .Find().ToArray();
  }
  return View(viewData);
}

На самом деле, такая брутальная выдача не совсем комильфо, но сейчас не важно. В отличии от NHibernate, никакой головной боли с CRUD не образовалось – я правда молчу про то, что в MongoDB нет ACID’а, но это вообще не важно. Давайте лучше однопиксельный GIF обсудим – это же так важно!

Выдача однопискельного GIFa

Это была настоящая эпопея. Если коротко, вызовы а-ля MapPath не работают в конструкторе контроллера. Вместо этого, однопиксельный GIF под названием WhitePixel.gif (надеюсь он прозрачный а не белый) был добавлен в проект как Embedded Resource. А дальше – картина маслом:

private static Image singlePixel;
static HomeController()
{
  var stream =
    Assembly.GetExecutingAssembly().GetManifestResourceStream(
      "VisitLogger.Content.WhitePixel.gif");
  if (stream != null) singlePixel = Image.FromStream(stream);
}

Вообще статические конструкторы для контроллера – тот еще расколбас. Еще интересно то, что поток ресурса нельзя закрыть тоже наводит на мысли, особенно после вкушения всех прелестей абсолютно невнятных GDI+ исключений в стиле “что-то сломалось”.

Ну а что касается ImageResult, то я его попросту украл. Ничего умного там нет.

Конец первой части

Вообщем, небольшой эксперимент на тему realtime-логгирования начался… хотя тут realtime полноценный рядом не стоял, да и AJAX я даже пока не подкрутил. Но промежуточные результаты логгера уже можно посмотреть тут. Как proof of concept пойдет, а насколько все это хорошо работает мы еще посмотрим. Продолжение следует!

P.S.: исходники тут: http://bitbucket.org/nesteruk/visitlogger

17 responses to “Realtime web logger на MongoDB, часть 1”

  1. Давайте лучше однопиксельный GIF обсудим – это же так важно!

    Зе бест – это хранить однопиксельный gif в виде статического массива байтов

    1. Да, знаю что перемудрил :)

    2. Еще можно хранить однопиксельный гиф в виде base64 строки, а потом преобразовывать эту строку в массив байт. Тогда в методе Log можно возвращать:

      return File( SINGLE_PIXEL_BYTES, “image/gif”);

  2. Кстати, Redis вполне работает под Win-платформой

    1. Наверное имеется ввиду использование под Cygwin? Лично мне решения которые не таргетят Windows неинтересны.

  3. Очень полезно. Большое спасибо.

    1. Пожалуйста :)

  4. Отличная статья, только не пойму зачем все так сложно. Может проще использовать HTTPRequestHandler?

    1. А смысл? У меня ведь есть и полноценная клиентская часть для репортинга.

  5. Как убить себя гранатой (с)
    Зачем использовать классы GDI+ для отправки файла клиенту – это отдельная загадка. И загадка эта примерно такого же порядка как зачем использовать NoSql и рассказывать как это круто, при том что обычный MSSQL на достаточно дохлой машине в состоянии держать больше 200 запросов в секунду (ну это конечно если не пытаться нагрузить его EF или гибернейтом).

    Без обид, но в моем личном рейтинге бестолковых статей эта займет одно из первых мест. =))

    1. Так это да, в плане GDI, это будет пофикшено (тяга к изврату и т.п.). Что касается SQL Server, просто у меня уже есть решение которые не выдерживает нагрузки – хотя на нем бывает и более 200/c. Да и суть не только в быстрых INSERTах, а вообще в совокупности NoSQL подхода.

  6. С однопиксельным гифом можно так сделать — хранить прямо статический массив байт, что-то типа

    private static string SINGLE_PIXEL_64BASED = “R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==”;
    private static byte[] SINGLE_PIXEL_BYTES = Convert.FromBase64String(SINGLE_PIXEL_64BASED);

    А в методе Log возвращать:

    return File(SINGLE_PIXEL_BYTES, “image/gif”);

    1. Класс! Так пожалуй и надо было сделать.

  7. Redis – отличная база, имхо она стоит того чтобы поставить её хотя бы в виртуалку и поиграться. Я сам совсем недавно с ней экспериментировал. Ещё Riak очень интересная штучка, правда не знаю как там с ASP.NET коннекторами.

  8. что-то я не понял к чему тут этот весь dynamic, почему не жесткая типизация?

    1. А смысл? Делать для каждого маленького теста отдельный класс? Абсолютно бессмысленно.

Leave a reply to Dmitri Cancel reply