February 2016
Volume 31 Number 2
働くプログラマ - MEAN あれこれ: MongoDB の詳細
Ted Neward | February 2016
「ミーニーズ」(MEANies) の皆さん、おかえりなさい (こちらの方が「ノーディスト」よりも響きが良いと考えました。それに、今回から Node 以外のことも取り上げます)。
コードは、重要な転換期に入ります。機能に変化がないことを検証するテストもいくつか実装できました。これで、ある程度本格的なリファクタリングを開始しても安全です。特に、現在のインメモリ データ ストレージ システムは、簡単なデモでは機能しても、徐々にスケール アップしていくことができません (当然、スタートアップ コードでハードコーディングしていないデータは、VM を 1 つ再起動するだけですべて失われます)。というわけで、今回は MEAN の「M」、MongoDB を取り上げます。
巨大なる DB
まず、MongoDB という名前が「humongous」(巨大) という単語から来ているという噂を知っておくことは重要です。このインターネット雑学の真偽はさておき、MongoDB は平均的なリレーショナル データベースとまったく同じ機能セットを提供するために構築されているわけではありません。MongoDB にとって優先順位が高いのはスケーラビリティです。そのため、「最終的な整合性」を選択し、クラスター間の ACID トランザクション機能を妥協し、若干の整合性は犠牲にします。
このシステムは、レコード数が膨大になることは決してありません。事実、その必要性があるとしたらかなり驚きです。ただし、MongoDB にはもう 1 つ興味深い特徴があります。それは、そのデータ モデル「ドキュメント指向データベース」にあります。つまり、テーブルと列のスキーマの適用が必要な従来のリレーショナル モデルではなく、コレクション内に収集される「スキーマレス」ドキュメントというデータ モデルです。これらのドキュメントは JSON で表現されるので、各ドキュメントは名前と値のペアで構成されます。値は、従来のデータ型 (文字列、整数、浮動小数点値、ブール値など) にすることも、「複合」データ型 (上記のデータ型の配列、または結果的に名前と値のペアを保持できる子オブジェクト) にすることもできます。そのため、リレーショナル データベースしか扱ったことがなければ、データ モデリングは想像とはかなり異なるものになります。現状、ここで扱っているアプリケーションは小さいので、違いはそれほど明白ではありませんが、複雑なストレージ ニーズに対処する場合はこのことを頭に入れておく必要があります。
注: .NET 開発者の立場から見た MongoDB の詳細については、「働くプログラマ」で 2010 年 5 月~ 7 月にかけて執筆した MongoDB のコラム (msdn.microsoft.com/ja-jp/magazine/ee291612.aspx) を参照してください。
データの設計
設計の点から言うと、「人物」データ モデルを MongoDB にどのように対応させればよいかは簡単にわかります。JSON ベースの名前と値のペアになる「人物」のコレクションと各ドキュメントの詳細を用意します。
まるで冗談のようですが、だいたいこんなところです。ドキュメント指向のデータベースが開発コミュニティで重宝される理由がここにもあります。スキーマベースのリレーショナル データベースに比べて、データ格納に伴う最初の障壁はほとんどありません。もちろん、欠点もあります。1 つでも入力ミスがあれば、どのドキュメントにも「firstName」フィールドがないため、「firstName」に基づくはずだったすべてのクエリが突如として空で戻ってきます。ただし、後からこうした欠点の一部を軽減する方法を紹介します。
ここからは、MongoDB にデータを入出力する方法を見ていきます。
データ アクセス
まず、アプリケーションが MongoDB と対話できるようにします。このためには、当然「mongodb」という新しい npm パッケージをインストールします。現状では、次のようにほぼ自動的に実行できます。
npm install --save mongodb
この npm ツールはいつものように実行され、処理から戻ったら、Node.js MongoDB ドライバーが node_modules ディレクトリにインストールされます。Kerberos パッケージがインストールされていないという警告が表示されることがありますが (「mongodb-core@1.2.28 requires a peer of kerberos@~0.0」)、これは既知のバグで、npm で Kerberos を直接インストール (「npm install kerberos」) するだけでおそらく解決されます。これ以外に問題は発生しないはずですが、それはもちろん、このパッケージの次期リリース次第です。最先端の技術で開発する際にはこのようなことはつきものです。
次に、コードで MongoDB インスタンスへの接続を開く必要があります。ただし、インスタンスの場所については、少々議論があります。
データの場所
本連載の初回コラムで述べたように、MongoDB には 2 つの簡単なオプションがあります。1 つは、ローカルに実行するオプションです。これは開発には優れていますが、運用には不向きです。もう 1 つはクラウドで実行するオプションで、運用には優れていても開発には不向きです (会議に向かう途中の機内でコードを実行できないのであれば、開発には不向きだというのが個人的見解です)。これは一般的なケースで、解決策はすべてのアプリケーションでほぼ同じです。つまり、開発中はローカルに実行し、運用またはテスト目的ではクラウドで実行します。
ほとんどのデータベースと同様、MongoDB に接続するには、サーバー DNS 名または IP アドレス、データベース名、および使用するポート (省略可能) が必要です。一般的に開発では、「localhost」、データベース名、および「27017」(MongoDB の既定値) になります。ただし、クラウドでの MongoDB インスタンスの設定は、これとはまったく異なります。たとえば、今回利用した「msdn-mean」という Mongolab MongoDB インスタンスのサーバーとポート設定は、それぞれ「ds054308.mongolab.com」と「54308」です。
Nodeでこの違いを最も簡単に確認するには、スタンドアロン JS ファイル (通常は config.js) を作成して、以下のように app.js コードに要求します。
// Load modules
var express = require('express'),
bodyParser = require('body-parser'),
debug = require('debug')('app'),
_ = require('lodash');
// Go get your configuration settings
var config = require('./config.js');
debug("Mongo is available at",config.mongoServer,":",config.mongoPort);
// Create express instance
var app = express();
app.use(bodyParser.json());
// ... The rest as before
次に、config ファイルによって、このアプリケーションの実行環境を判断します。そのため Node.js 環境では、通常「ENV」環境変数を確認します。この変数は「prod」、「dev」、「test」のいずれかに設定されます (「test」の場合、QA 専用の環境があることになります)。したがって config コードでは、以下のように ENV 環境変数を調べ、エクスポート後のモジュール オブジェクトに適切な値を格納する必要があります。
// config.js: Configuration determination
//
var debug = require('debug')('config');
debug("Configuring environment...");
// Use these as the default
module.exports = {
mongoServer : "localhost",
mongoPort : "27017"
};
if (process.env["ENV"] === "prod") {
module.exports.mongoServer = "ds054308.mongolab.com";
module.exports.mongoPort = "54308";
}
「process」オブジェクトを使用する際の注意点があります。これは標準の Node.js オブジェクトで、Node.js を実行しているどのアプリケーションにも暗黙のうちに存在します。「env」プロパティは「ENV」環境変数の検索に使用します (鋭い方は、使用するポートを決定するときに ExpressJS コードがまったく同じことを行っていることにお気付きだと思います。config.js 設定を使用するようにこのスニペットをおそらくリファクタリングすることもできますが、皆さんへの課題として残しておきます)。
ここまでは問題ありません。むしろ、前よりよくなりました。構成コードをメインのコード ベースから、暗黙のうちに正しく分離しています。
では、データの追加と削除に移ります。
MongoDB + Node.js
ほとんどのデータベースと同様、MongoDB への接続を開いて、そのオブジェクトを保持し、その後のデータベース操作でこのオブジェクトを使用します。したがって、明らかな最初の手順は、アプリケーションの起動時にオブジェクトを作成してグローバルに格納することです (図 1 参照)。
図 1 MongoDB でのオブジェクトの作成
// Go get your configuration settings
var config = require('./config.js');
debug("Mongo is available at ",config.mongoServer,":",config.mongoPort);
// Connect to MongoDB
var mongo = null;
var persons = null;
var mongoURL = "mongodb://" + config.mongoServer +
":" + config.mongoPort + "/msdn-mean";
debug("Attempting connection to mongo @",mongoURL);
MongoClient.connect(mongoURL, function(err, db) {
if (err) {
debug("ERROR:", err);
}
else {
debug("Connected correctly to server");
mongo = db;
mongo.collections(function(err, collections) {
if (err) {
debug("ERROR:", err);
}
else {
for (var c in collections) {
debug("Found collection",collections[c]);
}
persons = mongo.collection("persons");
}
});
}
});
// Create express instance
var app = express();
app.use(bodyParser.json());
// ...
connect 呼び出しは URL とコールバックを受け取ります。コールバックは、Node.js の表記法と同様、エラー オブジェクトとデータベース接続オブジェクトをパラメーターとして受け取ります。URL が未定義または null の場合はエラーになりますが、そうでなければすべてがスムーズに進みます。URL は、スキームに「mongodb」を使用する MongoDB 固有の URL ですが、それ以外は従来の HTTP URL とほぼ同じに見えます。
ただし、上記のコードには、一見しただけではよくわからない繊細な部分が存在します。コールバックは、スタートアップ コードの残りの部分が完了したずっと後の、いずれかの時点で呼び出されます。このことは、デバッグ出力を見ると明らかです (図 2 参照)。
図 2 デバッグ出力
コールバックからの「Connected correctly to server」(サーバーに正常に接続されました) メッセージの前に「Example app listening」(サンプル アプリがリッスンしています) メッセージが表示されています。これがアプリケーションの起動時に行われていれば、この同時実行の問題は深刻になりません。とは言え、問題がなくなるわけではなく、これは問答無用で Node.js を操作するときに最も扱いにくい部分の 1 つです。Node.js コードは同時に 2 つのスレッドで実行されることは決してないというのは事実ですが、だからと言って同時実行の問題が発生しないわけではありません。これは .NET 開発者にはなじみがないしくみです。
また、このコードが新しい MongoDB データベースに対して初めて実行されるとき、コレクション ループは空であることに注意してください。MongoDB は、どうしても必要に迫られない限り、コレクションを作成することはありません (データベースでもそうです)。もし作成するとすれば、それは一般的にだれかがデータベースに書き込んだ場合です。挿入が行われたら、MongoDB は、必要な成果物と、データを格納するためのデータ構造を作成します。
とにかく、ひとまずはデータベース接続が完了しました。次は CRUD メソッドを更新して、データベースの使用を開始します。
挿入
insertPerson は MongoDB コレクション オブジェクトの insert メソッドを使用します。ここでも、次のように、データベース操作の結果によって呼び出すコールバックが必要です。
var insertPerson = function(req, res) {
var person = req.body;
debug("Received", person);
// person.id = personData.length + 1;
// personData.push(person);
persons.insert(person, function(err, result) {
if (err)
res.status(500).jsonp(err);
else
res.status(200).jsonp(person);
});
};
(リファクタリング前のインメモリ データベースの) コメントアウトしたコードに注目してください。あることを証明するために、意図的にこの場所に置きました。MongoDB は識別子フィールドの「_id」を作成します。これは、データベース内のドキュメントの主キーなので、前作の「id」ジェネレーター コードはもはや必要ない、むしろまったく役立たずになります。
また、この関数の最後のステートメントは、関連するコールバックを持つ insert メソッドになっていることもわかります。これを関数ブロックの最終ステートメントにする必要はありませんが、データベース挿入の完了前に insertPerson 関数が終了することを理解しておくのは重要です。Node.js はコールバックに基づくため、データベース操作の成功または失敗がわかるまで呼び出し元に何も返さないようにします。したがって、「res」への呼び出しはコールバック外部では行いません (これに懐疑的な方は、persons.insert 呼び出しの後にデバッグ呼び出しを配置して、コールバック自体に別の呼び出しを配置してみてください。そして、コールバックが発生する前に、前者の呼び出しが発生するのを確認します)。
すべて取得
挿入には検証が必要なので、ここで getAllPersons をリファクタリングします。この関数では、以下のように、コレクションに簡単なクエリを実行して、コレクション内のすべてのドキュメントを探します。
var getAllPersons = function(req, res) {
persons.find({}).toArray(function(err, results) {
if (err) {
debug("getAllPersons--ERROR:",err);
res.status(500).jsonp(err);
}
else {
debug("getAllPersons:", results);
res.status(200).jsonp(results);
}
});
};
話を進める前に、注意を必要とする重要な点がいくつかあります。まず、find 呼び出しは、どの基準でコレクションをクエリするか記載した述語ドキュメントを受け取ります。今回このドキュメントを空にしていますが、これが名前によるクエリであれば、以下のようにします。
"{ 'firstName':'Ted' }"
次に、find から返されるオブジェクトはまだ実際の結果セットではないので、toArray を呼び出して何か使えるものに変換する必要があります。toArray は、コールバックを受け取ります。ここでもコールバックの各分岐は、res.status().jsonp を使用して、何かしらを呼び出し元に返すようにする必要があります。
ミドルウェア
先に進む前に、前回までのコラムを思い出してください。getPerson、updatePerson、deletePerson の各関数はすべて、識別子で人物を検索するのに personId ミドルウェア関数を利用していました。つまりこのミドルウェアは、インメモリ配列を探すのではなく、自身の _id フィールド (これは文字列ではなく、MongoDB ObjectID であることに注意) でコレクションをクエリするよう更新しなければなりません (図 3 参照)。
図 3 コレクションをクエリするようミドルウェアを更新
app.param('personId', function (req, res, next, personId) {
debug("personId found:",personId);
if (mongodb.ObjectId.isValid(personId)) {
persons.find({"_id":new mongodb.ObjectId(personId)})
.toArray(function(err, docs){
if (err) {
debug("ERROR: personId:",err);
res.status(500).jsonp(err);
}
else if (docs.length < 1) {
res.status(404).jsonp(
{ message: 'ID ' + personId + ' not found'});
}
else {
debug("person:", docs[0]);
req.person = docs[0];
next();
}
});
}
else {
res.status(404).jsonp({ message: 'ID ' + personId + ' not found'});
}
});
MongoDB Node.js ドライバーには findOne メソッドが記載されているので、このメソッドは適切に思えますが、ドライバーのドキュメントには非推奨という記述があります。
ミドルウェアは、無効な ObjectId を取得すると、next を呼び出しません。これを活用すれば、データベースから人物を見つけ出すのに利用しているさまざまなメソッドで、数行のコードを手っ取り早く節約できます。正当な ID でなければ、ここには存在できないため、404 を返すためです。結果が 0 個のドキュメント (つまり、ID がデータベースにない) の場合も同様です。
1 件取得、削除、更新
したがって、このミドルウェアは、すべてのあり得るエラーやドキュメントが見つからない状況に対処するため、次のように getPerson が簡単になります。
var getPerson = function(req, res) {
res.status(200).jsonp(req.person);
};
次のように、deletePerson も同じように簡単になります。
var deletePerson = function(req, res) {
debug(“Removing”, req.person.firstName, req.person.lastName);
persons.deleteOne({“_id”:req.person._id}, function(err, result) {
if (err) {
debug(“deletePerson: ERROR:”, err);
res.status(500).jsonp(err);
}
else {
res.person._id = undefined;
res.status(200).jsonp(req.person);
}
});
};
そしてこれらにより、次のように updatePerson も非常にわかりやすくなります。
var updatePerson = function(req, res) {
debug(“Updating”,req.person,”with”,req.body);
_.merge(req.person, req.body);
persons.updateOne({“_id”:req.person._id}, req.person, function(err, result) {
if (err)
res.status(500).jsonp(err);
else {
res.status(200).jsonp(result);
}
});
};
ちなみに以前、データベース外で読み込まれた person オブジェクトに要求本文からプロパティをコピーするために Lodash 関数を使用しましたが、merge 呼び出しはそれと同じ Lodash 関数です。
まとめ
今回は、今までよりも少し難しかったかもしれません。ですが、コードは、モックアップで使用していたインメモリ配列ではなく、MongoDB データベースに対して完全に実行されるようになりました。ただし、決して完ぺきとは言えません。まず、これらのクエリ述語周辺でコードに誤記があると、予期せぬランタイム エラーが発生します。さらに重要なことに、.NET 開発者としては「ドメイン オブジェクト」のようなものを操作するのに慣れています。オブジェクトのさまざまなプロパティでなんらかの検証を実行する必要がある場合は、特にそうです。この検証コードを、コード ベースの Express 部分全体に広げるのは良いアイデアとは言えません。そのことは次回扱うとして、今はコーディングを楽しんでください。
Ted Neward は、ポリテクノロジーに関するシアトルのコンサルティング サービス会社 iTrellis で CTO を務めています。これまでに 100 本を超える記事を執筆している Ted は、F# MVP および INETA 講演者で、さまざまな書籍を執筆および共同執筆しています。仕事への協力を依頼する場合、連絡先は ted@tedneward.com (英語のみ) です。また、tedneward.com (英語) でブログを公開しています。
この記事のレビューに協力してくれた技術スタッフの Shawn Wildermuth に心より感謝いたします。