はじめに
引き続き、「7つのデータベース 7つの世界 -オーム社-」の第5章からMongoDBの2日目のもう半分を勉強していく。
1/2で早めに切り上げてしまったので、こっちのほうが少し分量が多くなってしまった。
2日目「インデックス・集約・mapreduce」
集約クエリ
今まで調べたクエリは、基本的なデータの抽出に便利なものだった。しかし、これだとデータの後処理を自分でやらなければいけない。例えば、5,599,999よりも大きな電話番号をカウントする場合は、データベースにバックエンドでカウントしてもらいたい。PostgreSQLと同様に、count()は最も基本的な集約関数である。クエリを受け取り、(マッチした)数を返す。
> db.phones.count({'components.number' : { $gt : 5599999 } })
50000
次の集約クエリのパワーを見る前に、新たに電話番号100,000件をphonesコレクションに追加しよう。今回は違うエリアコードをつけている。
distinct()コマンドは、マッチする値が1つ以上存在する場合に、(ドキュメント全体ではなく)マッチした値を返す。以下のようにすれば、5,550,005未満のコンポーネントナンバーを(重複せずに)取り出すことが出来る。
> 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だけにする。
> 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を省略している。
> 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関数無しで実行するといい)。
> 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件ずつサーバーに保存する。
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()というコマンドがある。これは、受け取った関数をサーバーに渡すものである。リモートで実行されるので、クライアントとサーバーのやり取りが大幅に減る。
> db.eval(update_area)
JavaScriptの関数を評価するコマンド以外にも、Mongoにはあらかじめ用意されたコマンドがある。その多くはサーバーで実行される。ただし、adminデータはベースでしか実行できないものもある(use adminを入力すればアクセス出来る)。
> use admin
> db.listCommands()
topコマンドは、すべてのコレクションのアクセス情報を出力する。
> use book
> db.listCommands()
listCommands()を実行すると、コマンドがたくさんあることがわかる。電話番号をカウントするなどのコマンドもrunCommand()メソッド経由で実行できる。ただし、出力は全く違ったものになる。
> db.runCommand({ "count" : "phones" })
{ "n" : 200000, "ok" : 1 }
帰ってきた数字(「n」)は正しい数字(200000)である。しかし、このオブジェクトには「ok」フィールドが付いている。その理由は、db.phones.count()がラッパー関数だからである。これは、シェルのJavaScriptインターフェイスから便利に使えるように作られたものである。一方、runCommand()のカウントは、サーバーで実行されるものである。覚えているだろうが、count()のような関数の動きを調べるには、呼び出しの丸括弧を外せば良い。
> db.runCommand({ "count" : "phones" })
{ "n" : 200000, "ok" : 1 }
> db.phones.count
function ( x ){
return this.find( x ).count();
}
collection.count()は、find()の結果のcount()を呼び出すラッパー(カーソルを返すクエリオブジェクトのラッパー)にすぎない。そのクエリを実行してみると・・・
> 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()関数についても少しだけ見てみる。
> db.runCommand
function ( obj ){
if ( typeof( obj ) == "string" ){
var n = {};
n[obj] = 1;
obj = n;
}
return this.getCollection( "$cmd" ).findOne( obj );
}
runCommand()も$cmdという名前のコレクションを呼び出すラッパーである。これもヘルパー関数だということがわかった。このコレクションを直接呼び出してもコマンドを実行できる。
> db.$cmd.findOne({'count' : 'phones' })
{ "n" : 200000, "ok" : 1 }
これがむき出しの状態であり、ドライバはこれでMongoサーバーとやり取りをしている。
寄り道
以下の2つの理由から、ちょっと寄り道をする。
- mongoコンソールで実行した魔法の多くが、クライアントではなくサーバーで実行されていることを理解するため。MongoDBでは、便利なラッパー関数が提供されている。
- サーバーサイドでコードを実行するため。これは、PostgreSQLのストアドプロシージャとよく似ているらしい。
JavaScriptの関数は、system.jsという特別なコレクションに保存できる。これは通常のコレクションである。関数の名前を_idに設定して保存する。valueは関数オブジェクトである。
> 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のコードとして評価して、結果を返す。
> db.eval('getLast(db.phones)')
{
"_id" : 18005550010,
"components" : {
"country" : 1,
"area" : 801,
"prefix" : 555,
"number" : 5550010
},
"display" : "+1 801-5550010"
}
ローカルでgetLast(collection)を呼び出したのと同じ値になるだろう。
> 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の理解に必須ではない)。
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を起動したディレクトリにあれば、ファイル名の指定だけで良い。自分のようにほかのディレクトリにあれば、フルパスで指定する。
> load('~/Documents/mongodb/js/distinct_digits.js')
準備ができたらテストしてみよう。
> 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を合計することである。
map = function() {
var digits = distinctDigits(this);
emit({digits : digits, country : this.components.country}, {count : 1});
}
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のリストにあるマテリアライズドビューである。
> db.phones.report.find({'_id.country' : 8}
結果の続きを見るにはitを入力する。emitした一意なキーは、_idフィールドにある。reducerから戻されたすべてのデータは、valueフィールドにある。
mapreduceの結果だけを出力したい場合は、outの値を{ inline : 1 }に設定すれば良い。ただし、出力する結果にはサイズ制限があるので注意しよう。
おわりに
2日目では、集約クエリを扱い、クエリのパワーを広げた。MongoDBのインデックスを使って、クエリの応答時間を短くした。もっとパワーが必要なら、mapreduce()が利用できる。