LoginSignup
2
1

More than 5 years have passed since last update.

7つのデータベース 7つの世界 ~第5章 MongoDB 2日目2/2~

Posted at

はじめに


 引き続き、「7つのデータベース 7つの世界 -オーム社-」の第5章からMongoDBの2日目のもう半分を勉強していく。
 1/2で早めに切り上げてしまったので、こっちのほうが少し分量が多くなってしまった。

2日目「インデックス・集約・mapreduce」


集約クエリ


 今まで調べたクエリは、基本的なデータの抽出に便利なものだった。しかし、これだとデータの後処理を自分でやらなければいけない。例えば、5,599,999よりも大きな電話番号をカウントする場合は、データベースにバックエンドでカウントしてもらいたい。PostgreSQLと同様に、count()は最も基本的な集約関数である。クエリを受け取り、(マッチした)数を返す。

book
> db.phones.count({'components.number' : { $gt : 5599999 } })
50000

 次の集約クエリのパワーを見る前に、新たに電話番号100,000件をphonesコレクションに追加しよう。今回は違うエリアコードをつけている。
 distinct()コマンドは、マッチする値が1つ以上存在する場合に、(ドキュメント全体ではなく)マッチした値を返す。以下のようにすれば、5,550,005未満のコンポーネントナンバーを(重複せずに)取り出すことが出来る。

book
> populatePhones( 855, 5550000, 5650000 )
> db.phones.distinct('components.number', {'components.number' : { $lt : 5550005 } })
[ 5550000, 5550001, 5550002, 5550003, 5550004 ]

 5,550,000というナンバーは2つあるのだが(エリアコードが800と885)、このリストには1つしか登場しない。
 group()集約クエリは、SQLのGROUP BYと似ている。これは、Mongoで最も複雑な基本クエリでもある。例えば5,599,999より大きな電話番号をカウントして、エリアコードをキーにしたグループを結果にできる。keyはグループにするフィールドである。cond(condition:条件)は値の範囲である。reduceは出力値を制御する関数を受け取る。
 Riakの章でやったコマンドにmapreduceがあるが、このデータは、ドキュメントの既存のコレクションにmapされていて、これ以上のmapは不要なので、reduceだけにする。

book
> db.phones.group({
... initial: { count : 0 },
... reduce: function(phone, output) { output.count++; },
... cond: { 'components.number' : { $gt : 5599999 } },
... key: { 'components.area' : true }
... })
[
    {
        "components.area" : 800,
        "count" : 50000
    },
    {
        "components.area" : 855,
        "count" : 50000
    }
]

 以下の2つの例は、少し変な使用法みたいだが、group()の柔軟性を見るのには良いようだ。
 まず。group()を以下のように呼び出すことで、count()関数を簡単に再現できる。ここでは、keyを省略している。

book
> db.phones.group({
... initial: { count:0 },
... reduce: function(phone, output) { output.count++; },
... cond: { 'components.number' : { $gt : 5599999 } }
... })
[ { "count" : 100000 } ]

 この例では、最初にcountフィールドを0にして、オブジェクトを初期化している。ここで生成したフィールドが最終的に出力される。次に、reduce関数を宣言して、ドキュメントが見つからなかったらフィールドに加算するようにしている。最後に、reduceするドキュメントの条件を渡している。条件が同じなので、count()の結果と同じになるはずだ。keyを省略したのは、すべてのドキュメントをリストに追加するからである。
 また、disinct()関数を再現することも出来る。パフォーマンスを高めるために、まず数値をフィールドとして保存するオブジェクトを生成する(その場限りのセットを作成することになる)。続くreduce関数(マッチしたドキュメント毎に実行される)では、その値にプレースホルダーとして1を設定するだけである(これが必要なフィールドになる)。
 技術的に必要なことは以上だが、disinct()を完璧に再現するには、整数の配列を返さなければいけない。そこで、最後に一度だけ実行するfinalize(out)メソッドを追加している。これは、オブジェクトをフィールド値の配列に変換するものである。ここでは、文字列を整数に変換している(途中経過を見たければ、finalize関数無しで実行するといい)。

book
> db.phones.group({
... initial: { prefixes : {} },
... reduce: function(phone, output) {
... output.prefixes[phone.components.prefix] = 1;
... },
... finalize: function(out) {
... var ary = [];
... for (var p in out.prefixes) { ary.push( parseInt( p ) ); }
... out.prefixes = ary;
... }
... })[0].prefixes
[ 555, 556, 557, 558, 559, 560, 561, 562, 563, 564 ]

 group()関数は、SQLのGROUP BYのように強力である。ただし、MongoDBの実装には弱点がある。まず、結果のドキュメント数の上限が10,000である(バージョンアップして上限数は上がった)。さらに、コレクションをシャード(3日目で説明がある)すると、group()は上手く動かない。クエリを柔軟に組み立てる方法は他にもあるので、mapreduceを使ったやり方を紹介する。だがその前に、クライアントサイドとサーバーサイドのコマンドの境界線を説明する。この違いは、アプリケーションにとって重要だ。

サーバーサイドコマンド


 以下の関数をコマンドライン(やドライバ経由)で実行すると、クライアントは100,000研すべての電話番号をローカルに持ってきて、1件ずつサーバーに保存する。

/js/update_area.js
update_area = function() {
        db.phones.find().forEach(
                function(phone) {
                        phone.components.area++;
                        phone.display = "+"+
                        phone.components.country+" "+
                        phone.components.area+"-"+
                        phone.components.number;
                        db.phones.update({ _id : phone._id }, phone, false);
                }
        )
}

 Mongoのdbオブジェクトには、eval()というコマンドがある。これは、受け取った関数をサーバーに渡すものである。リモートで実行されるので、クライアントとサーバーのやり取りが大幅に減る。

book
> db.eval(update_area)

 JavaScriptの関数を評価するコマンド以外にも、Mongoにはあらかじめ用意されたコマンドがある。その多くはサーバーで実行される。ただし、adminデータはベースでしか実行できないものもある(use adminを入力すればアクセス出来る)。

book
> use admin
> db.listCommands()

 topコマンドは、すべてのコレクションのアクセス情報を出力する。

book
> use book
> db.listCommands()

 listCommands()を実行すると、コマンドがたくさんあることがわかる。電話番号をカウントするなどのコマンドもrunCommand()メソッド経由で実行できる。ただし、出力は全く違ったものになる。

book
> db.runCommand({ "count" : "phones" })
{ "n" : 200000, "ok" : 1 }

 帰ってきた数字(「n」)は正しい数字(200000)である。しかし、このオブジェクトには「ok」フィールドが付いている。その理由は、db.phones.count()がラッパー関数だからである。これは、シェルのJavaScriptインターフェイスから便利に使えるように作られたものである。一方、runCommand()のカウントは、サーバーで実行されるものである。覚えているだろうが、count()のような関数の動きを調べるには、呼び出しの丸括弧を外せば良い。

book
> db.runCommand({ "count" : "phones" })
{ "n" : 200000, "ok" : 1 }
> db.phones.count
function ( x ){
    return this.find( x ).count();
}

 collection.count()は、find()の結果のcount()を呼び出すラッパー(カーソルを返すクエリオブジェクトのラッパー)にすぎない。そのクエリを実行してみると・・・

book
> db.phones.find().count
function ( applySkipLimit ) {
    var cmd = { count: this._collection.getName() };
    if ( this._query ) {
        if ( this._special ) {
            cmd.query = this._query.query;
            if ( this._query.$maxTimeMS ) {
                cmd.maxTimeMS = this._query.$maxTimeMS;
            }
            if ( this._query.$hint ) {
                cmd.hint = this._query.$hint;
            }
        }
        else {
            cmd.query = this._query;
        }
    }
    cmd.fields = this._fields || {};

    if ( applySkipLimit ) {
        if ( this._limit )
            cmd.limit = this._limit;
        if ( this._skip )
            cmd.skip = this._skip;
    }

    var res = this._db.runCommand( cmd );
    if( res && res.n != null ) return res.n;
    throw "count failed: " + tojson( res );
}

 もっと大きな関数が手に入る。コードの最後の3行をよく見ると、count()はrunCommand()を実行して、フィールドnから値を返していることがわかる。

runCommand

 メッソドの挙動を深掘りしているが、runCommand()関数についても少しだけ見てみる。

book
> db.runCommand
function ( obj ){
    if ( typeof( obj ) == "string" ){
        var n = {};
        n[obj] = 1;
        obj = n;
    }
    return this.getCollection( "$cmd" ).findOne( obj );
}

 runCommand()も$cmdという名前のコレクションを呼び出すラッパーである。これもヘルパー関数だということがわかった。このコレクションを直接呼び出してもコマンドを実行できる。

book
> db.$cmd.findOne({'count' : 'phones' })
{ "n" : 200000, "ok" : 1 }

 これがむき出しの状態であり、ドライバはこれでMongoサーバーとやり取りをしている。

寄り道

 以下の2つの理由から、ちょっと寄り道をする。

  • mongoコンソールで実行した魔法の多くが、クライアントではなくサーバーで実行されていることを理解するため。MongoDBでは、便利なラッパー関数が提供されている。
  • サーバーサイドでコードを実行するため。これは、PostgreSQLのストアドプロシージャとよく似ているらしい。

 JavaScriptの関数は、system.jsという特別なコレクションに保存できる。これは通常のコレクションである。関数の名前を_idに設定して保存する。valueは関数オブジェクトである。

book
> db.system.js.save({
... _id : 'getLast',
... value : function(collection){
... return collection.find({}).sort({'_id':1}).limit(1)[0]
... }
... })
WriteResult({ "nMatched" : 0, "nUpserted" : 1, "nModified" : 0, "_id" : "getLast" })

 次にやるのは、サーバーで直接実行することである。eval()関数はサーバーに文字列を渡して、JavaScriptのコードとして評価して、結果を返す。

book
> db.eval('getLast(db.phones)')
{
    "_id" : 18005550010,
    "components" : {
        "country" : 1,
        "area" : 801,
        "prefix" : 555,
        "number" : 5550010
    },
    "display" : "+1 801-5550010"
}

 ローカルでgetLast(collection)を呼び出したのと同じ値になるだろう。

book
> db.system.js.findOne({
... '_id': 'getLast' }).value(db.phones)
{
    "_id" : 18005550010,
    "components" : {
        "country" : 1,
        "area" : 801,
        "prefix" : 555,
        "number" : 5550010
    },
    "display" : "+1 801-5550010"
}

 eval()は、実行時にmongodをブロックするので、プロダクション環境ではなくテスト環境で実行するといいだろう。この関数は、$whereやmapreduceの内部でも使われている。それでは、最終兵器のmapreduceを紹介する。

mapreduce(とfinalize)


 Mongoのmapreduceは、多少のちがいはあるものの、Riakのmapreduceとよく似ている。Mongoは、変換した値を返すmap()関数よりも、emit()関数を呼び出すmapperを必要とする。これには、ドキュメント毎に複数回emit出来るという利点がある。reduce()関数は、1つのキーとそのキーにemitされた値のリストを受け取る。Mongoは3番目の手順も提供している。finalize()である。これは、reduceを実行したあとに、mapした値毎に実行するものである。最終的な計算やクリーンナップに使える。
 国番号と同じ番号を含む電話番号を全てカウントするレポートに生成してみる。まずは、disinctした番号の配列を抽出するヘルパー関数を保存する(このヘルパーの挙動は、mapreduceの理解に必須ではない)。

/js/distinct_digits.js
distinctDigits = function(phone) {
        var
          number = phone.components.number + '',
          seen = [],
          result = [],
          i = number.length;
        while(i--) {
          seen[+number[i]] = 1;
        }
        for (i=0; i<10; i++) {
          if (seen[i]) {
                result[result.length] = i;
          }
        }
        return result;
}
db.system.js.save({_id: 'distinctDigits', value: distinctDigits})

 mongoコマンドラインで読み取る方法をここで紹介しよう。ファイルがmongoを起動したディレクトリにあれば、ファイル名の指定だけで良い。自分のようにほかのディレクトリにあれば、フルパスで指定する。

book
> load('~/Documents/mongodb/js/distinct_digits.js')

 準備ができたらテストしてみよう。

book
> db.eval("distinctDigits(db.phones.findOne({ 'components.number' : 5551213 }))")
[ 1, 2, 3, 5 ]

 これでmapperにとりかかることができる。mapreduce関数もそうだが、どのフィールドをmapするかが重要である。それが最終的に集約する値になるからである。このレポートはdistinctした数字を探しているので、distinctした値の配列が1つのフィールドになる。ただし、国ごとにクエリを発行する必要があるので、それもまた別のフィールドになる。したがって、両方の値を{digits : X, country : Y}のように複合キーで表す。
 これらのカウントすることが目標である。したがって、1の値をemitする(各ドキュメントがカウントする項目になる)。reducerの仕事は、これらの1を合計することである。

/js/map_1.js
map = function() {
        var digits = distinctDigits(this);
        emit({digits : digits, country : this.components.country}, {count : 1});
}
/js/reduce_1.js
reduce = function(key, value) { 
        var total = 0;
        for(var i=0; i<values.length; i++) {
                total += value[i].count;
        }
        return { count : total };
}       

results = db.runCommand({
        mapReduce: 'phones',
        map: map,
        reduce: reduce,
        out: 'phones.report'
})

 コレクションの名前は引数outで設定するので(out : 'phones.report')、他と同じように結果を問い合わせることができる。以下は、show tablesのリストにあるマテリアライズドビューである。

book
> db.phones.report.find({'_id.country' : 8}

 結果の続きを見るにはitを入力する。emitした一意なキーは、_idフィールドにある。reducerから戻されたすべてのデータは、valueフィールドにある。
 mapreduceの結果だけを出力したい場合は、outの値を{ inline : 1 }に設定すれば良い。ただし、出力する結果にはサイズ制限があるので注意しよう。

おわりに


 2日目では、集約クエリを扱い、クエリのパワーを広げた。MongoDBのインデックスを使って、クエリの応答時間を短くした。もっとパワーが必要なら、mapreduce()が利用できる。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1