Веб аналитика с помощью фреймворка MongoDB Aggregation

До версии 2.1 для агрегации в MongoDB (группировка документов по ключам, вычисление общего или среднего значения и т.д.) приходилось использовать MapReduce., что довольно успешно делали программисты практически на интуитивном уровне. Конечно, присутствует определенный порог вхождения для полного понимания процесса, но использование MapReduce в приложении, написанном не на JavaScript, требует “хакерских” способностей.

Приходится распределять данные и сокращать функции на JavaScript, код в качестве обычных строковых данных отправлять на сервер MongoDB. Довольно часто вследствие такого подхода мы получаем практически нечитаемый код.

Aggregation фреймворк предоставляет функционал для создания и выполнения агрегатных запросов к MongoDB,. Начиная с версии 2.1 эти возможности составили здоровую конкуренцию MapReduce.хотя конечно существуют некоторые нюансы, которых мы коснемся позднее.

Основы

Основа этого фреймворка заключена в следующей идее: все документы в вашем хранилище проходят через последовательность агрегационных команд. По сути это своеобразный конвейер, состоящий из ряда команд, воздействию которых подвергается каждый документ в определенном порядке. Идея была заимствована от оператора (|) из командной строки UNIX. Выходные данные первой команды передаются на вход второй команде. Команда задается выражениями. Выражение определяет изменения, которым должен подвергнуться входной документ. Как правило, выражения не хранят никакой информации об обрабатываемом документе. Но существует исключение, это так называемые накопительные выражения (accumulators), сохраняющие состояние.

Веб Аналитика при помощи Aggregation фреймворк

После ознакомления с основными теоретическими аспектами, рассмотрим работу фреймворка на живом примере — веб-аналитика будет хорошим вариантом. При отслеживании трафика вашего сайта в MongoDB, вы можете выполнить агрегационный запрос для измерения ключевых параметров (например, определение самой популярной страницы, наиболее загруженного дня в месяце и т.д.).

Создание тестовых данных

Я написал скрипт{:target=»_blank»} для генерации 1000 пустых документов в MongoDB. Вот пример такого документа:

{
    "page" : "/2011/01/02/blog-stats-2010/",
    "visited_at" : ISODate("2012-12-11T10:04:28Z"),
    "user_agent" : "Mozilla/5.0 (X11; Ubuntu; Linux i686; rv:15.0) Gecko/20100101 Firefox/15.0.1",
    "ip_address" : "23.22.63.122",
    "response_time_ms" : 194.53
}

Каждый документ представляет собой посещение пользователем одной страницы. Поле page хранит ссылку на посещённую страницу, visited_at — время посещения, user_agent — значение поля User Agent, отвечающее за то, каким браузером или устройством пользовался посетитель, ip_address — IP адрес пользователя и, наконец, response_time_ms — время ответа сервера на запрос. Если вы используете MongoDB на локальной машине, то загрузить тестовые данные вы можете запустив скрипт в клиенте mongo.

mongo localhost:27017/mysampledb --quite /path/to/load_data.js

При удачном исходе вы получите коллекцию web_traffic, состоящий из 1000 документов в БД mysampledb.

Вычисление общего числа посещений за день

Следующий пример демонстрирует как просчитать количество посещений сайта за Декабрь 2013 года.

db.web_traffic.aggregate(
[
    {
        $match: {visited_at: {$gte: new Date(2013, 11, 1), $lt: new Date(2014, 0, 1)}}
    },
    {
        $project: { _id: 0, day_of_month: {$dayOfMonth: "$visited_at"}}
    },
    {
        $group: { _id: {day_of_month: "$day_of_month"}, hits: {$sum: 1}}
    },
    {
        $sort: {hits: -1}
    }
])

Рассмотрим этот пример шаг за шагом:

  1. Первая операция — $match выбирает документы значение поля visited_on ,которое попадает в интервал между 1 и 31 декабря 2013 года.
  2. Далее мы видим $project, который преобразует документ в поток (stream) посредством добавления, удаления или изменения одного или нескольких полей. В данном случае мы удаляем поле _id, выставив его значение в ноль, (так как _id добавляется автоматически, нам приходится удалить его вручную. Все остальные поля удаляются автоматически, если мы не укажем обратное). Также мы добавляем новое поле day_of_month, хранящее день месяца из значения visited_on. Это значение мы получаем при помощи выражения $dayOfMonth.
  3. Оператор $group группирует документы и вычисляет агрегированные значения. Для этого оператора необходимо указать значение поля _id, именно по этому выражению документы будут сгруппированы. Выражение $sum хранит значение счетчика под названием hits для каждого дня в месяце. Говоря языком SQL — SELECT COUNT(*) as hits FROM web_traffic GROUP BY MONTH(visited_on).
  4. Наконец, $sort упорядочивает сгруппированные документы по значению hits в убывающем порядке. (-1— убывание, 1 — увеличение).

Вывод такой операции приведен ниже:

{
    "result" : [
        {
            "_id" : {
                "day_of_month" : 7
            },
            "hits" : 40
       },
       {
           "_id" : {
                "day_of_month" : 16
           },
           "hits" : 39
       },
       .....
       {
           "_id" : {
               "day_of_month" : 20
           },
           "hits" : 23
       }
    ],
    "ok" : 1
}

Мы видим, что 7 Декабря был самый загруженный день месяца (40 просмотров), а 23 числа был самый наименее загруженный день — 23 просмотра.

Вычисление среднего, максимального и минимального времени ответа сервера

В следующем примере мы вычислим среднее, максимальное и минимальное значение времени ответа сервера на запрос (response_time_ms).

db.web_traffic.aggregate([
{
    $group: {
        _id:null,
        avg_response_time_ms: {$avg: "$response_time_ms"},
        max_response_time_ms: {$max: "$response_time_ms"},
        min_response_time_ms: {$min: "$response_time_ms"}
    }
},
{
    $project:{
        _id: 0,
        avg_response_time_s: {$divide: ["$avg_response_time_ms", 1000]},
        max_response_time_s: {$divide: ["$max_response_time_ms", 1000]},
        min_response_time_s: {$divide: ["$min_response_time_ms", 1000]}
    }
}])

Рассмотрим ключевые моменты:

  1. Сначала при помощи $group устанавливаем _id в null., т.е. мы не группируем документы. Требуемое нам значение мы получим при помощи $avg, $max и $min.
  2. Операция $project игнорирует поле _id и в то же время делит значения на 1000 (при помощи выражения $divide), чтобы преобразовать миллисекунды в секунды.

Вычисление доли браузеров

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

var total = db.web_traffic.find({user_agent: {$exists:true}}).count();
var pipeline = [
    {
        $group: {_id: "$user_agent", count: {$sum: 1}}
    },
    {
        $project: {percentage: {$multiply: ["$count", 100 / total]}}
    },
    {
        $sort: {percentage: -1}
    }
];
db.web_traffic.aggregate(pipeline)
  1. Сначала получаем общее число документов с указанным полем user_agent и сохраняем результат в total.
  2. $group группирует документы исходя из значения user_agent (_id:$user_agent) и суммирует одинаковые документы (count: {$sum: 1}).
  3. $project вычисляет процентное соотношение разделив count на total и умножив на 100.
  4. $sort — сортирует результат исходя из процентного соотношения в убывающем порядке.

Определение посещений пользователей за неделю

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

db.web_traffic.aggregate([
{
    $project: {
        _id: 0,
        page: 1,
        ip_address: 1,
        visited_weekday: {$dayOfWeek: "$visited_at"}
    }
},
{
    $group: {
        _id: {visited_weekday: "$visited_weekday", ip_address: "$ip_address"},
        visits: {$sum: 1},
        pages_visited: {$addToSet: "$page"}
    }
},
{
    $sort: {"_id.visited_weekday": 1}
}
])
  1. $project выдает документы с полями page, ip_address и вычисленным полем visited_weekday — день недели (1 — Воскресенье, 2 — Понедельник и т.д.) из поля visited_at.
  2. Документы группируются по полю visited_weekday и ip_address. Общее количество посещений с одного IP адреса подсчитывается при помощи $sum. Так же значение поля page добавляется в массив pages_visited выражением $addToSet.
  3. Сортируем документ в возрастающем порядке по полю visited_weekday.

Результат операции выглядит следующим образом:

{
    "result" : [
    {
        "_id" : {
             "visited_weekday" : 1,
             "ip_address" : "4.242.114.0"
        },
        "visits" : 19,
        "pages_visited" : [
        "/2012/03/26/tab-completion-for-fabric-tasks-on-os-x",
        "/2010/02/05/modifying-pdf-files-with-php",
        "/2011/11/24/random-snaps-from-thailand-tour",
        "/2011/01/29/on-programming-and-craftsmanship",
        "/2011/03/04/getting-started-with-pipvirtualenv",
        "/2010/05/07/moments-from-darjeeling",
        "/2011/01/02/blog-stats-2010/",
        "/2011/05/06/making-chrome-canary-the-default-browser-os-x"
   ]
}
...

Первая запись в результате показывает, что пользователь с IP адресом 4.242.114.0 посетил сайт 19 раз в воскресенье и перешел по ссылкам из массива pages_visited.

Вычисление времени пребывания пользователя на сайте

В последнем примере рассмотрим операцию, при помощи которой вычислим, сколько в сумме по времени (в часах) пользователь провел на сайте 7 Декабря 2013 года.

db.web_traffic.aggregate([
{
    $match: {visited_at: {$gte: new Date(2013, 11, 7),
    $lt: new Date(2013, 11, 8)}}
},
{
    $sort: {visited_at: 1}
},
{
    $group: {
        _id: "$ip_address",
        first_visit: {$first: "$visited_at"},
        last_visit: {$last: "$visited_at"},
        total_visits: {$sum: 1}
    }
},
{
    $match: {
        total_visits: {$gt: 1}
    }
},
{
    $project: {
        duration_hours: {$subtract: [{$hour: "$last_visit"}, {$hour: "$first_visit"}]}
    }
},
{
    $sort: {duration_hours: -1}
}])
  1. Сначала с помощью $match выбираем документы у которых значение поля visited_at равно 7.12.2013.
  2. Затем сортируем документы в возрастающем порядке по отметке времени.
  3. $group группирует документы по ip_address и вычисляет общее количество посещений для каждого IP адреса. Также это выражение хранит первое и последнее значения visited_at для каждой группы.
  4. Затем $match отфильтровывает документы со значением поля total_visit равным одному и менее.
  5. $project вычисляет время пребывания пользователя на сайте путем вычитания первого значения visited_at из последнего, а результат предоставляется в часах.
  6. Наконец, сортируем документы по времени пребывания в убывающем порядке.

Ограничения

Хотелось бы отметить следующие ограничения фреймворка:

  • Окончательный вывод конвейера команд хранится в виде одного документа в памяти. В этом заключается основное отличие с MapReduce, где допускается хранить результат в базе данных. Это означает, что результат не может превышать допустимого размера документа в MongoDB — 16 мегабайт.
  • Если операция для своего выполнения требует больше 10% оперативной памяти, то генерируется ошибка.
  • Агрегатный конвейер не работает со следующими типами данных: Symbol, MinKey, MaxKey, DBRef, Code, CodeWScope.