В релизе 2.1 было заявлена реализация такой функциональности, как новый фреймворк агрегирования данных. Хотелось бы рассказать о первых впечатлениях от этой весьма интересной штуки. Данный функционал должен позволить в некоторых местах отказаться от Map/Reduce и написания кода на JavaScript в пользу достаточно простых конструкций, предназначенных для группировки полей почти как в SQL.
Документация по новшествам расположена в соответствующем разделе официального сайта. Сначала давайте разберем то, как же это работает и какие конструкции 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 это наконец-то будет стабильно и клево.
Автор: deadkrolik