NoSQL / Новый aggregation framework в MongoDB 2.1

в 6:55, , рубрики: aggregation framework, mongodb, метки: ,

В релизе 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

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js