В релизе 2.1 было заявлена реализация такой функциональности, как новый фреймворк агрегирования данных. Хотелось бы рассказать о первых впечатлениях от этой весьма интересной штуки. Данный функционал должен позволить в некоторых местах отказаться от Map/Reduce и написания кода на JavaScript в пользу достаточно простых конструкций, предназначенных для группировки полей почти как в SQL.
Документация по новшествам расположена в соответствующем разделе официального сайта. Сначала давайте разберем то, как же это работает и какие конструкции MongoDB нам помогут.
Итак, самая главная сложность в выборке данных из MongoDB это работа с массивами и данными, содержащимися внутри каких-то отдельных элементов. Да, мы можем их выбрать как и в SQL, но не можем агрегировать по ним непосредственно при выборке. Новый фреймвок представляет собой декларативный способ работы с такими данными, основываясь на цепочке специальных операторов (их всего 7 штук). Данные выборки передаются из выхода одного оператора на вход другого, совсем как в unix. Отчасти при помощи новых операторов можно повторить уже существующие. Пусть коллекция test — это коллекция для хранения данных о людях. Стандартная выборка:
будет аналогична
Но все немного интереснее, потому что во втором примере мы можем строить цепочку обработки данных, перечисляя операторы через запятую. Для сортировки предназначен оператор $sort, например:
Так мы веберем всех людей с именем «Ivan» и отсортируем выборку по возрасту. А для того, что бы выбрать самого старшего Ивана нам надо отсечь выборку одним элементом:
Вы скажете, что это повторение уже существующей фукциональности. В какой-то мере да, но мы так и не рассмотрели новые операторы, позволяющие более гибко работать с выборками. Разберем их подробнее.
Предназначен для манипулирования полями, может добавлять новые, удалять и переименовывать их в документах, поступающих ему на вход. Следующая конструкция включит в поток документов (отфильтрует) только имена и возраст пользователей:
На вход следующего оператора попадут все документы только с двумя полями, других полей в потоке не будет (за исключением поля _id, что бы его исключить надо специально указать _id: 0). Цифра 1 включает, цифра 0 исключает передачу поля. Кроме того этот оператор позволяет переименовывать поля, «доставать» поля из вложенного объекта какого-либо поля или же добавлять новые поля на основе каких-либо вычислений.
На мой взгляд это самый интересный оператор. Он позволяет «разворачивать» вложенные массивы на каждый элемент выборки документов. Например, пускай у нас есть следующая база людей:
Пусть поле likes означает какие девочки нравятся какому мальчику. Применим оператор $unwind:
Мы видим что массив likes развернулся и каждый документ теперь имеет поле likes с каждым значением массива, которые он имел до этого. Если мы хотим найти самую популярную девочку достаточно сгруппировать выборку по полю likes. Для группировки служит следующий оператор.
Для удобства дополним выборку еще одним полем заполненным цифрой 1 (так проще будет суммировать):
Это позволит нам использовать оператор агрегирования $sum. То есть теперь мы просто добавляем в поле number значение поля count каждый раз и группируем всю выборку по полю likes, содержающую имя девочки.
Осталось отсортировать и ограничить вывод только одним документом:
Наша самая популярная девочка — это Анна.
Для того, что бы чисто конкретно проникнуться новыми возможностями предположим что у нас есть коллекция, хранящая данные о животных в зоопарке и решим несколько задач по агрегированию данных. Вот наши лапы и хвосты:
Поле name хранит имя, поле ration это массив объектов хранящих сколько и какой еды требуется зверю ежедневно, holidays это дни в которые зверь отдыхает и не показывается посетителям, staff.like — смотрители, которые ему нравятся (панды, очаровашки, любят вапще всех-всех), staff.dislike — не нравятся.
Начнем с простой выборки — только названия животных, что бы директор зоопарка не забывал кого как зовут. Тут все просто:
Бояться надо хищников. А хищник это тот, у кого в рационе есть мясо. Давайте их найдем. Для начала отфильтруем поток и выделим только два поля в документах — имя и рацион.
Затем развернем массив рациона на элементы основного массива:
Далее отфильтруем выборку только по тем полям, где есть поле ration.meat
И окончательный вывод только имени хищника
Для этого «расслоим» массив holidays на весь массив зверей (панда как обычно доступна всем и всегда).
И отфильтруем только те, где поле holidays это число большее -1 (ну или 0, кому как удобнее)
Уберем все лишнее.
Самая интересная, на мой взгляд, задача. Для ее реализации вспомним, что $project умеет создавать поля и создадим поле meat со значением свойства meat.
Если этого поля в свойствах рациона животного нет, то оно создано не будет. Вот пример части выборки:
Поступим таким образом для всех типов еды и уберем вывод самого объекта ration:
в результате получим
Осталось лишь сложить/сгруппировать все это дело при помощи функции $group. Указание поля _id в группировке здесь обязательно, но нам оно в принципе не нужно, поэтому пусть это будет какая-нибудь ерунда. Для каждого типа еды создаем соответствующее поле для суммирования отдельных рационов каждого животного:
Фильтруем по полям и разматываем массив staff.like:
Вспоминаем, что $project умеет поднимать поле на уровень вверх:
Так выбрали всех смотрителей, которые хоть кому-то нравятся и кто-то нравится двум животным, то он присутствует в выборке два раза.
Теперь необходимо просуммировать эти поля. Но так просто это не сделать, так как у нас нет поля для суммирования, поэтому создаем это поле при уже известной фишки.
В результате к каждому объекту добавится еще одно поле count со значением 1. Группируем и суммируем:
Сортируем и ограничиваем вывод самым первым элементом
И получим следующее:
Вот собственно и все. Для интересующихся есть два простеньких доклада на английском по этой теме: раз и два.
Если честно то MongoDB мне очень нравится, хотя мы использовали его только на части проекта для хранения разрозненных данных. Те же Map/Reduce для меня всегда были чем-то страшным и непонятным, но новая штука агрегирования данных позволяет частично исключить JavaScript, потому что так или иначе он язык интерпретируемый, а потому медленный и заменить его уже готовыми, а значит быстрыми, элементами языка.
P.S. Стоит отметить что версия 2.1 пока что достаточно сырая. Я постоянно получал всякие исключения по assertion failed. Но я думаю, что в 2.2 это наконец-то будет стабильно и клево.
Документация по новшествам расположена в соответствующем разделе официального сайта. Сначала давайте разберем то, как же это работает и какие конструкции MongoDB нам помогут.
Итак, самая главная сложность в выборке данных из MongoDB это работа с массивами и данными, содержащимися внутри каких-то отдельных элементов. Да, мы можем их выбрать как и в SQL, но не можем агрегировать по ним непосредственно при выборке. Новый фреймвок представляет собой декларативный способ работы с такими данными, основываясь на цепочке специальных операторов (их всего 7 штук). Данные выборки передаются из выхода одного оператора на вход другого, совсем как в unix. Отчасти при помощи новых операторов можно повторить уже существующие. Пусть коллекция test — это коллекция для хранения данных о людях. Стандартная выборка:
db.test.find({name: "Ivan"});
будет аналогична
db.test.aggregate({$match: {name: "Ivan"}});
Но все немного интереснее, потому что во втором примере мы можем строить цепочку обработки данных, перечисляя операторы через запятую. Для сортировки предназначен оператор $sort, например:
db.test.aggregate({$match: {name: "Ivan"}}, {$sort: {age: 1}});
Так мы веберем всех людей с именем «Ivan» и отсортируем выборку по возрасту. А для того, что бы выбрать самого старшего Ивана нам надо отсечь выборку одним элементом:
db.test.aggregate({$match: {name: "Ivan"}}, {$sort: {age: -1}}, {$limit: 1});
Вы скажете, что это повторение уже существующей фукциональности. В какой-то мере да, но мы так и не рассмотрели новые операторы, позволяющие более гибко работать с выборками. Разберем их подробнее.
Оператор $project
Предназначен для манипулирования полями, может добавлять новые, удалять и переименовывать их в документах, поступающих ему на вход. Следующая конструкция включит в поток документов (отфильтрует) только имена и возраст пользователей:
{$project: {name: 1, age: 1}}
На вход следующего оператора попадут все документы только с двумя полями, других полей в потоке не будет (за исключением поля _id, что бы его исключить надо специально указать _id: 0). Цифра 1 включает, цифра 0 исключает передачу поля. Кроме того этот оператор позволяет переименовывать поля, «доставать» поля из вложенного объекта какого-либо поля или же добавлять новые поля на основе каких-либо вычислений.
Оператор $unwind
На мой взгляд это самый интересный оператор. Он позволяет «разворачивать» вложенные массивы на каждый элемент выборки документов. Например, пускай у нас есть следующая база людей:
db.test.insert({name: "Ivan", likes: ["Maria", "Anna"]});
db.test.insert({name: "Serge", likes: ["Anna"]});
Пусть поле likes означает какие девочки нравятся какому мальчику. Применим оператор $unwind:
db.test.aggregate({$unwind: "$likes"});
{
"result" : [
{
"_id" : ObjectId("4f598de76a8f8bc74573e9fd"),
"name" : "Ivan",
"likes" : "Maria"
},
{
"_id" : ObjectId("4f598de76a8f8bc74573e9fd"),
"name" : "Ivan",
"likes" : "Anna"
},
{
"_id" : ObjectId("4f598e086a8f8bc74573e9fe"),
"name" : "Serge",
"likes" : "Anna"
}
],
"ok" : 1
}
Мы видим что массив likes развернулся и каждый документ теперь имеет поле likes с каждым значением массива, которые он имел до этого. Если мы хотим найти самую популярную девочку достаточно сгруппировать выборку по полю likes. Для группировки служит следующий оператор.
Оператор $group
Для удобства дополним выборку еще одним полем заполненным цифрой 1 (так проще будет суммировать):
db.test.aggregate({$unwind: "$likes"}, {$project: {name:1, likes:1, count: {$add: [1]}}});
{
"result" : [
{
"_id" : ObjectId("4f598de76a8f8bc74573e9fd"),
"name" : "Ivan",
"likes" : "Maria",
"count" : 1
},
{
"_id" : ObjectId("4f598de76a8f8bc74573e9fd"),
"name" : "Ivan",
"likes" : "Anna",
"count" : 1
},
{
"_id" : ObjectId("4f598e086a8f8bc74573e9fe"),
"name" : "Serge",
"likes" : "Anna",
"count" : 1
}
],
"ok" : 1
}
Это позволит нам использовать оператор агрегирования $sum. То есть теперь мы просто добавляем в поле number значение поля count каждый раз и группируем всю выборку по полю likes, содержающую имя девочки.
db.test.aggregate({$unwind: "$likes"}, {$project: {name:1, likes:1, count: {$add: [1]}}}, {$group: {_id: "$likes", number: {$sum: "$count"}}});
{
"result" : [
{
"_id" : "Anna",
"number" : 2
},
{
"_id" : "Maria",
"number" : 1
}
],
"ok" : 1
}
Осталось отсортировать и ограничить вывод только одним документом:
db.test.aggregate({$unwind: "$likes"}, {$project: {name:1, likes:1, count: {$add: [1]}}}, {$group: {_id: "$likes", number: {$sum: "$count"}}}, {$sort: {number: -1}}, {$limit: 1});
{ "result" : [ { "_id" : "Anna", "number" : 2 } ], "ok" : 1 }
Наша самая популярная девочка — это Анна.
А теперь конкретный пример.
Для того, что бы чисто конкретно проникнуться новыми возможностями предположим что у нас есть коллекция, хранящая данные о животных в зоопарке и решим несколько задач по агрегированию данных. Вот наши лапы и хвосты:
db.zoo.insert({name: "Lion", ration: [{meat: 20}, {fish: 1}, {water: 30}], holidays: [1,4], staff: {like: ["Petrovich", "Mihalich"], dislike: "Maria"}});
db.zoo.insert({name: "Tiger", ration: [{meat: 15}, {water: 25}], holidays: [6], staff: {like: ["Petrovich", "Maria"]}});
db.zoo.insert({name: "Monkey", ration: [{banana: 15}, {water: 10}, {nuts: 1}], holidays: [2], staff: {like: ["Anna"], dislike: "Petrovich"}});
db.zoo.insert({name: "Panda", ration: [{bamboo: 15}, {dumplings: 50}, {water: 3}], staff: {like: ["Petrovich", "Mihalich", "Maria", "Anna"]}});
Поле name хранит имя, поле ration это массив объектов хранящих сколько и какой еды требуется зверю ежедневно, holidays это дни в которые зверь отдыхает и не показывается посетителям, staff.like — смотрители, которые ему нравятся (панды, очаровашки, любят вапще всех-всех), staff.dislike — не нравятся.
Начнем с простой выборки — только названия животных, что бы директор зоопарка не забывал кого как зовут. Тут все просто:
db.zoo.aggregate({$project: {name: 1}});
{
"result" : [
{
"_id" : ObjectId("4f58b7f627f86b11258dc70c"),
"name" : "Lion"
},
{
"_id" : ObjectId("4f58b86027f86b11258dc70d"),
"name" : "Tiger"
},
{
"_id" : ObjectId("4f58b90c27f86b11258dc70e"),
"name" : "Monkey"
},
{
"_id" : ObjectId("4f58b98727f86b11258dc70f"),
"name" : "Panda"
}
],
"ok" : 1
}
Каких зверей надо бояцца?
Бояться надо хищников. А хищник это тот, у кого в рационе есть мясо. Давайте их найдем. Для начала отфильтруем поток и выделим только два поля в документах — имя и рацион.
db.zoo.aggregate({$project: {name: 1, _id: 0, ration: 1}});
{
"result" : [
{
"name" : "Lion",
"ration" : [
{
"meat" : 20
},
{
"fish" : 1
},
{
"water" : 30
}
]
},
{
"name" : "Tiger",
"ration" : [
{
"meat" : 15
},
{
"water" : 25
}
]
},
{
"name" : "Monkey",
"ration" : [
{
"banana" : 15
},
{
"water" : 10
},
{
"nuts" : 1
}
]
},
{
"name" : "Panda",
"ration" : [
{
"bamboo" : 15
},
{
"dumplings" : 50
},
{
"water" : 3
}
]
}
],
"ok" : 1
}
Затем развернем массив рациона на элементы основного массива:
db.zoo.aggregate({$project: {name: 1, _id: 0, ration: 1}}, {$unwind: "$ration"});
{
"result" : [
{
"name" : "Lion",
"ration" : {
"meat" : 20
}
},
{
"name" : "Lion",
"ration" : {
"fish" : 1
}
},
{
"name" : "Lion",
"ration" : {
"water" : 30
}
},
{
"name" : "Tiger",
"ration" : {
"meat" : 15
}
},
{
"name" : "Tiger",
"ration" : {
"water" : 25
}
},
{
"name" : "Monkey",
"ration" : {
"banana" : 15
}
},
{
"name" : "Monkey",
"ration" : {
"water" : 10
}
},
{
"name" : "Monkey",
"ration" : {
"nuts" : 1
}
},
{
"name" : "Panda",
"ration" : {
"bamboo" : 15
}
},
{
"name" : "Panda",
"ration" : {
"dumplings" : 50
}
},
{
"name" : "Panda",
"ration" : {
"water" : 3
}
}
],
"ok" : 1
}
Далее отфильтруем выборку только по тем полям, где есть поле ration.meat
db.zoo.aggregate({$project: {name: 1, _id: 0, ration: 1}}, {$unwind: "$ration"}, {$match: {"ration.meat": {$exists: true}}});
{
"result" : [
{
"name" : "Lion",
"ration" : {
"meat" : 20
}
},
{
"name" : "Tiger",
"ration" : {
"meat" : 15
}
}
],
"ok" : 1
}
И окончательный вывод только имени хищника
db.zoo.aggregate({$project: {name: 1, _id: 0, ration: 1}}, {$unwind: "$ration"}, {$match: {"ration.meat": {$exists: true}}}, {$project: {name: 1, _id: 0}});
{
"result" : [
{
"name" : "Lion"
},
{
"name" : "Tiger"
}
],
"ok" : 1
}
В какие дни отдыхает хотя бы один зверь?
Для этого «расслоим» массив holidays на весь массив зверей (панда как обычно доступна всем и всегда).
db.zoo.aggregate({$project: {name: 1, holidays: 1}}, {$unwind: "$holidays"});
{
"result" : [
{
"_id" : ObjectId("4f58b7f627f86b11258dc70c"),
"name" : "Lion",
"holidays" : 1
},
{
"_id" : ObjectId("4f58b7f627f86b11258dc70c"),
"name" : "Lion",
"holidays" : 4
},
{
"_id" : ObjectId("4f58b86027f86b11258dc70d"),
"name" : "Tiger",
"holidays" : 6
},
{
"_id" : ObjectId("4f58b90c27f86b11258dc70e"),
"name" : "Monkey",
"holidays" : 2
},
{
"_id" : ObjectId("4f58b98727f86b11258dc70f"),
"name" : "Panda"
}
],
"ok" : 1
}
И отфильтруем только те, где поле holidays это число большее -1 (ну или 0, кому как удобнее)
db.zoo.aggregate({$project: {name: 1, holidays: 1}}, {$unwind: "$holidays"},{$match: {holidays : {$gt: -1}}});
{
"result" : [
{
"_id" : ObjectId("4f58b7f627f86b11258dc70c"),
"name" : "Lion",
"holidays" : 1
},
{
"_id" : ObjectId("4f58b7f627f86b11258dc70c"),
"name" : "Lion",
"holidays" : 4
},
{
"_id" : ObjectId("4f58b86027f86b11258dc70d"),
"name" : "Tiger",
"holidays" : 6
},
{
"_id" : ObjectId("4f58b90c27f86b11258dc70e"),
"name" : "Monkey",
"holidays" : 2
}
],
"ok" : 1
}
Уберем все лишнее.
db.zoo.aggregate({$project: {name: 1, holidays: 1}}, {$unwind: "$holidays"},{$match: {holidays : {$gt: -1}}}, {$project: {holidays: 1, _id: 0}});
{
"result" : [
{
"holidays" : 1
},
{
"holidays" : 4
},
{
"holidays" : 6
},
{
"holidays" : 2
}
],
"ok" : 1
}
Сколько продуктов в день необходимо закупать.
Самая интересная, на мой взгляд, задача. Для ее реализации вспомним, что $project умеет создавать поля и создадим поле meat со значением свойства meat.
db.zoo.aggregate({$project: {ration: 1, _id: 0}}, {$unwind: "$ration"}, {$project: {ration: 1, meat: "$ration.meat", _id: 0}});
Если этого поля в свойствах рациона животного нет, то оно создано не будет. Вот пример части выборки:
{
"result" : [
{
"ration" : {
"meat" : 20
},
"meat" : 20
},
{
"ration" : {
"fish" : 1
}
},
{
"ration" : {
"water" : 30
}
},
...
}
Поступим таким образом для всех типов еды и уберем вывод самого объекта ration:
db.zoo.aggregate({$project: {ration: 1}}, {$unwind: "$ration"}, {$project: {ration: 0, _id: 0, meat: "$ration.meat", fish: "$ration.fish", water: "$ration.water", banana: "$ration.banana", bamboo: "$ration.bamboo", nuts: "$ration.nuts", dumplings: "$ration.dumplings", _id: 0}});
в результате получим
{
"result" : [
{
"_id" : ObjectId("4f58e58227f86b11258dc713"),
"meat" : 20
},
{
"_id" : ObjectId("4f58e58227f86b11258dc713"),
"fish" : 1
},
{
"_id" : ObjectId("4f58e58227f86b11258dc713"),
"water" : 30
},
{
"_id" : ObjectId("4f58e5e127f86b11258dc714"),
"meat" : 15
},
{
"_id" : ObjectId("4f58e5e127f86b11258dc714"),
"water" : 25
},
{
"_id" : ObjectId("4f58e60027f86b11258dc715"),
"banana" : 15
},
{
"_id" : ObjectId("4f58e60027f86b11258dc715"),
"water" : 10
},
{
"_id" : ObjectId("4f58e60027f86b11258dc715"),
"nuts" : 1
},
{
"_id" : ObjectId("4f58e64a27f86b11258dc716"),
"bamboo" : 15
},
{
"_id" : ObjectId("4f58e64a27f86b11258dc716"),
"dumplings" : 50
},
{
"_id" : ObjectId("4f58e64a27f86b11258dc716"),
"water" : 3
}
],
"ok" : 1
}
Осталось лишь сложить/сгруппировать все это дело при помощи функции $group. Указание поля _id в группировке здесь обязательно, но нам оно в принципе не нужно, поэтому пусть это будет какая-нибудь ерунда. Для каждого типа еды создаем соответствующее поле для суммирования отдельных рационов каждого животного:
db.zoo.aggregate({$project: {ration: 1}}, {$unwind: "$ration"}, {$project: {ration: 0, _id: 0, meat: "$ration.meat", fish: "$ration.fish", water: "$ration.water", banana: "$ration.banana", bamboo: "$ration.bamboo", nuts: "$ration.nuts", dumplings: "$ration.dumplings"}}, {$group: {_id: "s", sum_meat: {$sum: "$meat"}, sum_fish: {$sum: "$fish"}, sum_water: {$sum: "$water"}, sum_banana: {$sum: "$banana"}, sum_nuts: {$sum: "$nuts"}, sum_bamboo: {$sum: "$bamboo"}, sum_dumplings: {$sum: "$dumplings"}}});
{
"result" : [
{
"_id" : "s",
"sum_meat" : 35,
"sum_fish" : 1,
"sum_water" : 68,
"sum_banana" : 15,
"sum_nuts" : 1,
"sum_bamboo" : 15,
"sum_dumplings" : 50
}
],
"ok" : 1
}
Самый любимый смотритель
Фильтруем по полям и разматываем массив staff.like:
db.zoo.aggregate({$project: {name: 1, _id: 0, "staff.like": 1}}, {$unwind: "$staff.like"});
Вспоминаем, что $project умеет поднимать поле на уровень вверх:
db.zoo.aggregate({$project: {name: 1, _id: 0, "staff.like": 1}}, {$unwind: "$staff.like"}, {$project: {_id: 0, name: "$staff.like"}});
Так выбрали всех смотрителей, которые хоть кому-то нравятся и кто-то нравится двум животным, то он присутствует в выборке два раза.
{
"result" : [
{
"name" : "Petrovich"
},
{
"name" : "Mihalich"
},
{
"name" : "Petrovich"
},
{
"name" : "Maria"
},
{
"name" : "Anna"
},
{
"name" : "Petrovich"
},
{
"name" : "Mihalich"
},
{
"name" : "Maria"
},
{
"name" : "Anna"
}
],
"ok" : 1
}
Теперь необходимо просуммировать эти поля. Но так просто это не сделать, так как у нас нет поля для суммирования, поэтому создаем это поле при уже известной фишки.
db.zoo.aggregate({$project: {name: 1, _id: 0, "staff.like": 1}}, {$unwind: "$staff.like"}, {$project: {_id: 0, name: "$staff.like", count: {$add: [1]}}});
В результате к каждому объекту добавится еще одно поле count со значением 1. Группируем и суммируем:
db.zoo.aggregate({$project: {name: 1, _id: 0, "staff.like": 1}}, {$unwind: "$staff.like"}, {$project: {_id: 0, name: "$staff.like", count: {$add: [1]}}}, {$group: {_id: "$name", num: {$sum: "$count"}}});
Сортируем и ограничиваем вывод самым первым элементом
db.zoo.aggregate({$project: {name: 1, _id: 0, "staff.like": 1}}, {$unwind: "$staff.like"}, {$project: {_id: 0, name: "$staff.like", count: {$add: [1]}}}, {$group: {_id: "$name", num: {$sum: "$count"}}}, {$sort: {num: -1}}, {$limit: 1});
И получим следующее:
{ "result" : [ { "_id" : "Petrovich", "num" : 3 } ], "ok" : 1 }
Вот собственно и все. Для интересующихся есть два простеньких доклада на английском по этой теме: раз и два.
Если честно то MongoDB мне очень нравится, хотя мы использовали его только на части проекта для хранения разрозненных данных. Те же Map/Reduce для меня всегда были чем-то страшным и непонятным, но новая штука агрегирования данных позволяет частично исключить JavaScript, потому что так или иначе он язык интерпретируемый, а потому медленный и заменить его уже готовыми, а значит быстрыми, элементами языка.
P.S. Стоит отметить что версия 2.1 пока что достаточно сырая. Я постоянно получал всякие исключения по assertion failed. Но я думаю, что в 2.2 это наконец-то будет стабильно и клево.