Help us understand the problem. What is going on with this article?

MongoDBのAggregationでRDBみたいな使い方

自社サービスでMongoDBを使っていて、使いやすい点・使いにくい点がいろいろ分かってきました。
この記事ではMongoDBでもやり方次第でリレーションが張られたデータをRDBみたいに利用できるということ、その方法、そしてその辛さを書いていきます。

キーワードしては下記を利用していきます。

  • stage
    • lookup
    • unwind
    • addfield
    • match
  • 様々なoperators

データ準備

まずデータを作成します。

  • 本、著者、注文のcollectionからなる
  • 本は1人の著者を含み、注文は複数の本を含む
// 著者を作成
db.authors.insertMany(
  [{
      name: 'john'
    },
    {
      name: 'tom'
    }
  ]
)

// 結果が返る
{
  "acknowledged": true,
  "insertedIds": [
    ObjectId("5deb4e1b077d77772cd018e0"),
    ObjectId("5deb4e1b077d77772cd018e1")
  ]
}

// 本を作成
db.books.insertMany(
  [{
      name: 'bookA',
      price: 1000,
      author_id: ObjectId("5deb4e1b077d77772cd018e0") // john
    },
    {
      name: 'bookB',
      price: 1500,
      author_id: ObjectId("5deb4e1b077d77772cd018e0") // john
    },
    {
      name: 'bookC',
      price: 2000,
      author_id: ObjectId("5deb4e1b077d77772cd018e1") // tom
    }
  ]
)

// 結果が返る
{
  "acknowledged": true,
  "insertedIds": [
    ObjectId("5deb4fba077d77772cd018e2"),
    ObjectId("5deb4fba077d77772cd018e3"),
    ObjectId("5deb4fba077d77772cd018e4")
  ]
}

// 注文を作成
db.orders.insertMany(
  [{
      book_ids: [
        ObjectId("5deb4fba077d77772cd018e2"), // bookA
        ObjectId("5deb4fba077d77772cd018e3")  // bookB

      ]
    },
    {
      book_ids: [
        ObjectId("5deb4fba077d77772cd018e2"), // bookA
        ObjectId("5deb4fba077d77772cd018e4")  // bookC
      ]
    }
  ]
)

// 結果が返る
{
  "acknowledged": true,
  "insertedIds": [
    ObjectId("5deb5395077d77772cd018e5"),
    ObjectId("5deb5395077d77772cd018e6")
  ]
}

Aggregationの考え方

Aggregationを実行する上でいくつか抑えておきたいと思う点があります。

  • Pipeline
    • MongoDBのAggregationはPipelineという、Stageのリストを渡す構造になっている
    • PipelineはStageの中で定義して、ネスト構造にすることができる
  • Stage
    • Pipelineのリストの各要素はStageという概念になる
    • Stageにはlookup(外部collectionからリレーションをたどる)やsortといった機能が入る
    • Stageは複数を順番に実行できる
  • Operator
    • 細かい演算
    • ==,!=,IN,+,-,* などなど
  • StageもOperatorも{}で囲む。囲み忘れてエラーになるということが何度あったことか

Aggregationの実践

下記のような流れで進めます。
3つあるcollectionを結合していき、各fieldの値から計算をします。どこまでAggregationで実施して、どこからアプリケーションで実装するかは状況しだいだと思いますが、あえて複雑になるようなシナリオにしています。

  • 【aggregate】 order(注文)collectionを対象とする
  • 【lookup】 orderにあるbook_idから内包されるbook(本)を取得
  • 【lookup unwind】 さらにbookにあるauther_idから内包されるauthor(著者)を取得
  • 【addfields】 特定のauthorのbookは割引という仮定の元、割引金額を計算
  • 【addfields】 割引金額を加味した合計金額を計算
  • 【project】 必要なfieldのみ取得する

【aggregate】 order(注文)collectionを対象とする

まず、Stageが含まれないPipelineを与えてみます。

db.orders.aggregate([])

# orderのidと内包するbookのidのみ返る
{
  "_id": ObjectId("5deb5395077d77772cd018e5"),
  "book_ids": [
      ObjectId("5deb4fba077d77772cd018e2"), 
      ObjectId("5deb4fba077d77772cd018e3")
      ]
} {
  "_id": ObjectId("5deb5395077d77772cd018e6"),
  "book_ids": [
      ObjectId("5deb4fba077d77772cd018e2"), 
      ObjectId("5deb4fba077d77772cd018e4")
      ]
}

これは db.orders.find({}) と同じ結果が返ります。ここからPipelineに追加していきます。

【lookup】 orderにあるbook_idから内包されるbook(本)を取得

orderは複数のbook_idを内包するので、そのidを元にbook collectionからdocumentを引っ張ってきて、orderの内部に結合します。
他のcollectionの参照にはlookupというStageを使います。

db.orders.aggregate(
  [{
    $lookup: {
      from: "books",             // 参照先を指定
      let: {
        "book_ids": "$book_ids" // この後のpipelineで利用するための変数を作成。 let book_id = $book_ids という感じ
      },
      pipeline: [{
        $match: {
          $expr: {
            $in: [               // if $_id in $$book_ids という感じ。複数のbookを内包できるので、inオペレーターを利用
              '$_id',            // 参照先のbook documentのid
              '$$book_ids',      // ↑で作成したbook_ids
            ]
          }
        }
      }],
      as: 'books'                // 結合後の名称を指定できる
    }
  }]
)

lookup Stageの中で更にPipelineを使っています。localFieldとforeignFieldを使ってもう少しスッキリと書くことも出来ますが、Pipelineを使うと後ほどさらに複雑なことを行いたいとなった時に有利になります。

このAggregateは下記の結果を返します。

{
  "_id": ObjectId("5deb5395077d77772cd018e5"),
  "book_ids": [ObjectId("5deb4fba077d77772cd018e2"), ObjectId("5deb4fba077d77772cd018e3")],
  "books": [{                                      // bookが結合されている
    "_id": ObjectId("5deb4fba077d77772cd018e2"),
    "name": "bookA",
    "price": 1000,
    "author_id": ObjectId("5deb4e1b077d77772cd018e0")
  }, {
    "_id": ObjectId("5deb4fba077d77772cd018e3"),
    "name": "bookB",
    "price": 1500,
    "author_id": ObjectId("5deb4e1b077d77772cd018e0")
  }]
} {
  "_id": ObjectId("5deb5395077d77772cd018e6"),
  "book_ids": [ObjectId("5deb4fba077d77772cd018e2"), ObjectId("5deb4fba077d77772cd018e4")],
  "books": [{                                      // bookが結合されている
    "_id": ObjectId("5deb4fba077d77772cd018e2"),
    "name": "bookA",
    "price": 1000,
    "author_id": ObjectId("5deb4e1b077d77772cd018e0")
  }, {
    "_id": ObjectId("5deb4fba077d77772cd018e4"),
    "name": "bookC",
    "price": 2000,
    "author_id": ObjectId("5deb4e1b077d77772cd018e1")
  }]
}

ちゃんとbookを引っ張ってこれています。

【lookup unwind】 さらにbookにあるauther_idから内包されるauthor(著者)を取得

bookを結合できましたが、bookにはauthorへのリレーションもあります。
なのでもう1段階lookupして著者の情報も取得したいと思います。

db.orders.aggregate(
  [{
    $lookup: {
      from: 'books',
      let: {
        'book_ids': '$book_ids'
      },
      pipeline: [{
          $match: {
            $expr: {
              $in: [
                '$_id',
                '$$book_ids',
              ]
            }
          }
        },
        {
          $lookup: {                    // bookをlookupする時にpipelineをネストしたので、さらにlookupできる
            from: 'authors',
            let: {
              'author_id': '$author_id' 
            },
            pipeline: [{
              $match: {
                $expr: {
                  $eq: [                // bookの時はinだったが、今回は配列ではなく1つのauthorしか紐付かないのでeqオペレータ
                    '$_id',
                    '$$author_id',
                  ]
                }
              }
            }],
            as: 'author'
          }
        },
        {               
          $unwind: {                     // 結果がauthorの配列になるので、unwindでフラットにする
            path: '$author'
          }
        }
      ],
      as: 'books'
    }
  }]
)

このAggregationは下記の結果を返します。

{
  "_id": ObjectId("5deb5395077d77772cd018e5"),
  "book_ids": [ObjectId("5deb4fba077d77772cd018e2"), ObjectId("5deb4fba077d77772cd018e3")],
  "books": [{
    "_id": ObjectId("5deb4fba077d77772cd018e2"),
    "name": "bookA",
    "price": 1000,
    "author_id": ObjectId("5deb4e1b077d77772cd018e0"),
    "author": {                                        // authorが結合されている
      "_id": ObjectId("5deb4e1b077d77772cd018e0"),
      "name": "john"
    }
  }, {
    "_id": ObjectId("5deb4fba077d77772cd018e3"),
    "name": "bookB",
    "price": 1500,
    "author_id": ObjectId("5deb4e1b077d77772cd018e0"),
    "author": {                                        // authorが結合されている
      "_id": ObjectId("5deb4e1b077d77772cd018e0"),
      "name": "john"
    }
  }]
} {
  "_id": ObjectId("5deb5395077d77772cd018e6"),
  "book_ids": [ObjectId("5deb4fba077d77772cd018e2"), ObjectId("5deb4fba077d77772cd018e4")],
  "books": [{
    "_id": ObjectId("5deb4fba077d77772cd018e2"),
    "name": "bookA",
    "price": 1000,
    "author_id": ObjectId("5deb4e1b077d77772cd018e0"),
    "author": {                                        // authorが結合されている
      "_id": ObjectId("5deb4e1b077d77772cd018e0"),
      "name": "john"
    }
  }, {
    "_id": ObjectId("5deb4fba077d77772cd018e4"),
    "name": "bookC",
    "price": 2000,
    "author_id": ObjectId("5deb4e1b077d77772cd018e1"),
    "author": {                                        // authorが結合されている
      "_id": ObjectId("5deb4e1b077d77772cd018e1"),
      "name": "tom"
    }
  }]
}

ネストがさらに深くなり、authorが結合できています。
そろそろきつくなってきました。

【addfields】 特定のauthorのbookは割引という仮定の元、割引金額を計算

orderには複数のbookが内包されるので、order毎の合計金額を計算したいと思いますが、その前にややこしくする目的で「bookのauthorがjohnだったら100円割引く」という条件を与えてみます。
addFieldsというStageを使って、割引分の金額を持った john_campaignというfieldを追加してみます。

// 該当部分のみなのでこれだけだと動きません。
john_campaign: {
  $multiply: [{                    // 掛け算オペレータ
      $size: {                     // 配列のlenを取る
        $filter: {                 // 配列から特定の条件に合致するものをフィルタリング
          input: '$books.author', // 対象はauthor
          as: 'author',            // cond(条件)内で利用する変数を定義 let author = $books.author という感じ
          cond: {                  // フィルタリングの条件
            $eq: [          // if $$author.name == john という感じ
              '$$author.name',
              'john',
            ]
          },

        }
      }
    },
    -100                           // 上で配列のlen(イコールjohnがauthorのbooksの数)が計算され、それに-100を掛ける  
  ]
}

このように、operatorsをつなげて利用することで色々なことができます。

【addfields】 割引金額を加味した合計金額を計算

さて、ここまでで各orderのbookの個数、それぞれのbookのprice、さらにjohnの割引き分の価格が取得できます。
これらを合計してorderの価格を計算してみます。

// 該当部分のみなのでこれだけだと動きません。
total_price: {
  $sum: [{
    $sum: '$books.price'              // booksの合計金額
  }, '$john_campaign']                // に割引分を足す
}

ここまでのAggregationをすべて合わせるとこのようになります↓

db.orders.aggregate(
  [{
      $lookup: {
        from: 'books',
        let: {
          'book_ids': '$book_ids'
        },
        pipeline: [{
            $match: {
              $expr: {
                $in: [
                  '$_id',
                  '$$book_ids',
                ]
              }
            }
          },
          {
            $lookup: {
              from: 'authors',
              let: {
                'author_id': '$author_id'
              },
              pipeline: [{
                $match: {
                  $expr: {
                    $eq: [
                      '$_id',
                      '$$author_id',
                    ]
                  }
                }
              }],
              as: 'author'
            }
          },
          {
            $unwind: {
              path: '$author'
            }
          }

        ],
        as: 'books'
      }
    },                          // ↑ここまでlookup
    {                           // ↓ここからaddFields
      $addFields: {
        john_campaign: {
          $multiply: [{
              $size: {
                $filter: {
                  input: '$books.author',
                  as: 'author',
                  cond: {
                    $eq: [
                      '$$author.name',
                      'john',
                    ]
                  },

                }
              }
            },
            -100
          ]
        },
        total_price: {
          $sum: [{
            $sum: '$books.price'
          }, '$john_campaign']
        }
      }
    }
  ]
)

Pipeline配列にStageを追加していく感じが分かってくると思います。
このAggregationは下記の結果を返します。

{
  '_id': ObjectId('5deb5395077d77772cd018e5'),
  'book_ids': [ObjectId('5deb4fba077d77772cd018e2'), ObjectId('5deb4fba077d77772cd018e3')],
  'books': [{
    '_id': ObjectId('5deb4fba077d77772cd018e2'),
    'name': 'bookA',
    'price': 1000,
    'author_id': ObjectId('5deb4e1b077d77772cd018e0'),
    'author': {
      '_id': ObjectId('5deb4e1b077d77772cd018e0'),
      'name': 'john'
    }
  }, {
    '_id': ObjectId('5deb4fba077d77772cd018e3'),
    'name': 'bookB',
    'price': 1500,
    'author_id': ObjectId('5deb4e1b077d77772cd018e0'),
    'author': {
      '_id': ObjectId('5deb4e1b077d77772cd018e0'),
      'name': 'john'
    }
  }],
  'john_campaign': -200,     // 新しいfieldが追加されている
  'total_price': 2500        // 新しいfieldが追加されている
} {
  '_id': ObjectId('5deb5395077d77772cd018e6'),
  'book_ids': [ObjectId('5deb4fba077d77772cd018e2'), ObjectId('5deb4fba077d77772cd018e4')],
  'books': [{
    '_id': ObjectId('5deb4fba077d77772cd018e2'),
    'name': 'bookA',
    'price': 1000,
    'author_id': ObjectId('5deb4e1b077d77772cd018e0'),
    'author': {
      '_id': ObjectId('5deb4e1b077d77772cd018e0'),
      'name': 'john'
    }
  }, {
    '_id': ObjectId('5deb4fba077d77772cd018e4'),
    'name': 'bookC',
    'price': 2000,
    'author_id': ObjectId('5deb4e1b077d77772cd018e1'),
    'author': {
      '_id': ObjectId('5deb4e1b077d77772cd018e1'),
      'name': 'tom'
    }
  }],
  'john_campaign': -100,     // 新しいfieldが追加されている
  'total_price': 3000        // 新しいfieldが追加されている
}

【project】 必要なfieldのみ取得する

最後にもう1段階Stageをつなげてみます。
例えばAPIから値を返却する時に必要な情報のみ抽出したいとか、データの型をstringにしたいみたいな場面があると思います。
そんな時はproject Stageを使います。

{
  $project: {
    _id: 0,                // _idはデフォルトで返却されるので、0で返却しないよう制御
    id: {
      '$toString': '$_id'  // _idのままだとObjectIdが返るので、stringに変換
    },
    total_price: 1,        // 1だと返却される
    book_count: {
      $size: '$book_ids'   // オペレータが使える。簡単なものならわざわざaddFields Stageを使う必要もない
    }
  }
}

このproject Stageを先程までのPipelineの最後に追加したものでAggregationを実行すると下記の結果を返します。

{
  "total_price": 2500,
  "id": "5deb5395077d77772cd018e5",  // stringになっている
  "book_count": 2
} {
  "total_price": 3000,
  "id": "5deb5395077d77772cd018e6",  // stringになっている
  "book_count": 2
}

MongoDB Aggregationに関する学び

ということでMongoDBのAggregationに関して、経験からの学びをつらつら書いていきます。

  • 直感的でないので結構つらい。時間がかかる
  • トライアンドエラーしにくい。MonboDB Compassというクライアントアプリケーションを使ってAggregationを組み立てているのですが、エラー内容が新設ではなく手探り間があります。
  • 頑張ればRDBみたいな使い方ができる。MongoDBでは4.0からトランザクションをサポートしているので、複数のcollectionに対する一括処理なんかがやりやすくなっています。今回紹介したlookupと合わせると、リレーショナルな使い方が可能です。

今回はMongoDBのAggregationに焦点を当てましたが、MongoDBにはもちろんスキーマレスならではの、データ構造の変更があってもあまり気にせず進めらるといった利点もあります。

最近はPostgreSQLにjsonbが取り入れられてMongoDBいらないんじゃないかみたいに言われていますが、どんな用途で何を使うかは毎回しっかり考える必要があると思うので、色々使って経験値を上げていきたいものです。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした