次の方法で共有


働くプログラマ

MongoDB と NoSQL を試す (第 2 部)

Ted Neward

サンプル コードのダウンロード

Ted Neward前回のコラムでは、MongoDB の基本事項 (MongoDB のインストール、MongoDB の実行、およびデータの挿入と検索) について主に説明しました。ただし、基本事項しか説明しませんでしたし、使用したデータ オブジェクトも単純な名前と値のペアでした。MongoDB にとって肝心なことは、データベースが構造化されていないことと、データ構造が比較的シンプルなことなので、これでも問題はありませんでした。しかし、このデータベースには、間違いなく、単純な名前と値のペア以上のものを格納できます。

今回のコラムでは、やや方法を変えて MongoDB (つまり、すべてのテクノロジ) を調査します。ここで紹介する「調査テスト」という手順では、サーバーで発生する可能性があるバグを見つけることができます。また、この過程で、オブジェクト指向の開発者が MongoDB を使用する際に陥る共通の問題の 1 つに注目します。

前回のエピソード

まず、皆さんがこのページをご覧になっていることを確認してから、少しだけ新しい内容についても説明しましょう。ここでは、前回のコラム (msdn.microsoft.com/ja-jp/magazine/ee310029) で紹介したものより、やや構造化された MongoDB を紹介します。今回は、簡単なアプリケーションを作成しながら、それをざっと説明するのではなく、一石二鳥を狙って調査テストを作成します。この調査テストは、単体テストのように見えますが、機能を試して検証するのではなく、機能を調査するコード セグメントです。

このような調査テストを作成することは、新しいテクノロジを調査する際のさまざまな目的に役立ちます。第 1 に、調査対象のテクノロジが、本質的にテストが容易かどうかを判断するのに役立ちます (調査テストを行うのが難しければ、単体テストを行うのも困難であると想定されます。これには十分な注意が必要です)。第 2 に、調査対象のテクノロジの新しいバージョンが公開されるときに、再現テストの役割を果たします。古い機能が機能しなくなれば、警告を発することになります。第 3 に、テストは比較的小さく、細かい単位で行われるため、以前のケースに基づく新しい "what-if" ケースを作成することで、調査テストによるテクノロジの学習が本質的に容易になります。

ただし、調査テストは、単体テストとは異なり、アプリケーションの開発に併せて継続的に開発されることはありません。そのため、テクノロジの学習は済んだ思ったら、ひとまず脇に置いておきます。ただし、破棄してしまわないようにしてください。調査テストを利用して、アプリケーション コードのバグと、ライブラリまたはフレームワークのバグを切り分けることも可能です。調査テストでは、アプリケーションのオーバーヘッドなしにテストを行うために、軽量でアプリケーションに依存しない環境を用意することで、この切り分けを行います。

以上のことを念頭に置いて、MongoDB-Explore (Visual C# テスト プロジェクト) を作成しましょう。MongoDB.Driver.dll をアセンブリ参照の一覧に追加し、ビルドして、準備が整ったことを確認します (ビルドすると、プロジェクト テンプレートの一部として生成される TestMethod が 1 つ得られます。既定では TestMethod が渡されるため、準備は完了します。つまり、プロジェクトでビルドに失敗すると、環境内で何か問題が発生していることになります。想定事項を確認することは、常に役立ちます)。

すぐにもコードの作成に取り組みたいところですが、ここでは興味をそそる問題を簡単に取り上げます。その問題とは、MongoDB では、クライアント コードから MongoDB に接続して、意味のある処理を行う前に、外部サーバー プロセス (mongod.exe) を実行しておかなければならないことです。「大丈夫、MongoDB を起動しておいて、コードの作成に戻りましょう」と言いたいところですが、当然ながら問題があります。ここでコードをながめてから 15 週間後、経験豊富とはいえない開発者 (皆さん、私、チーム メンバー) がこの調査テストを実行すると、ほぼ間違いなく、テストはすべて失敗することになり、外部サーバー プロセスを実行しておかなければならないことに思い至るまで、2 ~ 3 日を無駄にすごすことになります。

「教訓: なんらかの方法で、テストにかかわるすべての依存関係を把握しておきましょう。」いずれにしても、この問題は単体テスト中にも再び発生することになるでしょう。そのときは、クリーンなサーバーからテストを開始し、いくつかの変更を加えてから、すべて元に戻すことになるでしょう。この問題は、単純にサーバー プロセスを停止および開始するだけで、簡単に解決できます。ですから、ここでこの問題を解決しておけば、後の時間の節約になります。

テストの前後に何かを実行するという考え方は、新しいものではありません。Microsoft Test and Lab Manager のプロジェクトには、テスト単位およびテスト スイート単位の初期化子やクリーンアップ メソッドが用意されています。これらには、テスト スイート単位のブックキーピング用に ClassInitialize と ClassCleanup、テスト単位のブックキーピング用に TestInitialize と TestCleanup というカスタム属性が付いています (詳細については、「単体テストの操作」を参照してください)。そこで、テスト スイート単位の初期化子を使用して mongod.exe プロセスを開始し、テスト スイート単位のクリーンアップ メソッドを使用してプロセスを終了します (図 1 参照)。

図 1 テスト用の初期化子とクリーンアップ メソッドのコードの一部

namespace MongoDB_Explore
{
  [TestClass]
  public class UnitTest1
  {
    private static Process serverProcess;

   [ClassInitialize]
   public static void MyClassInitialize(TestContext testContext)
   {
     DirectoryInfo projectRoot = 
       new DirectoryInfo(testContext.TestDir).Parent.Parent;
     var mongodbbindir = 
       projectRoot.Parent.GetDirectories("mongodb-bin")[0];
     var mongod = 
       mongodbbindir.GetFiles("mongod.exe")[0];

     var psi = new ProcessStartInfo
     {
       FileName = mongod.FullName,
       Arguments = "--config mongo.config",
       WorkingDirectory = mongodbbindir.FullName
     };

     serverProcess = Process.Start(psi);
   }
   [ClassCleanup]
   public static void MyClassCleanup()
   {
     serverProcess.CloseMainWindow();
     serverProcess.WaitForExit(5 * 1000);
     if (!serverProcess.HasExited)
       serverProcess.Kill();
  }
...

初めてこのコードを実行すると、プロセスが開始されたことをユーザーに通知するダイアログ ボックスが表示されます。[OK] をクリックすると、次回テストが実行されるまで、このダイアログ ボックスは表示されません。ダイアログ ボックスが表示されることが煩わしい場合は、[今後このダイアログ ボックスを表示しない] チェック ボックスをオンにして、今後はメッセージが表示されないようにします。サーバーではクライアント接続を受信するポートを開く必要があるため、Windows ファイアウォールなど、ファイアウォール ソフトウェアを実行中の場合も、ダイアログ ボックスが表示されることがあります。同じようにチェック ボックスをオンにしておけば、すべての処理が暗黙のうちに実行されます。必要に応じて、クリーンアップ用のコードの先頭行にブレークポイントを設定して、サーバーが実行されていることを確認します。

サーバーが実行されたら、テストを開始できます。ただし、別の問題が表面化しなければですが。各テストでは毎回新しいデータベースを使用することを考えます。ただし、特定の処理 (たとえばクエリ) のテストを容易にするには、データベースに事前にデータが用意しておくと便利です。各テストでは、事前にデータが設定された新しいセットが毎回用意されていればすばらしいでしょう。このような問題にも、TestInitialize 属性や TestCleanup 属性を付けたメソッドが有効です。

この問題に取り組む前に、TestMethod について簡単に紹介しておきましょう。前回のコラムで説明した内容を調査テストに盛り込むために、TestMethod では、サーバーの検索、接続の確立、オブジェクトの挿入、検出、削除を確認します (図 2 参照)。

図 2 サーバーの検出と接続の確立を確認する TestMethod

[TestMethod]
public void ConnectInsertAndRemove()
{
  Mongo db = new Mongo();
  db.Connect();

  Document ted = new Document();
  ted["firstname"] = "Ted";
  ted["lastname"] = "Neward";
  ted["age"] = 39;
  ted["birthday"] = new DateTime(1971, 2, 7);
  db["exploretests"]["readwrites"].Insert(ted);
  Assert.IsNotNull(ted["_id"]);

  Document result =
    db["exploretests"]["readwrites"].FindOne(
    new Document().Append("lastname", "Neward"));
  Assert.AreEqual(ted["firstname"], result["firstname"]);
  Assert.AreEqual(ted["lastname"], result["lastname"]);
  Assert.AreEqual(ted["age"], result["age"]);
  Assert.AreEqual(ted["birthday"], result["birthday"]);

  db.Disconnect();
}

このコードを実行すると、1 つのアサーションが発生して、テストが失敗します。具体的には、"birthday" に関連する最後のアサーションが発生します。ここで明らかになることは、時刻を指定しない DateTime を MongoDB データベースに送信すると、データベースと正確にやり取りされないことです。時刻を指定しないと、データ型では真夜中の時刻が日付に関連付けられますが、データベースから返される日付には午前 8 時という時刻が関連付けられるため、テストの最後で AreEqual アサーションが発生します。

このことは、調査テストの実用性を明らかにしています。調査テストを実行しなければ (たとえば、前回のコラムのコードを使用した場合)、このような MongoDB の細かい特性は、プロジェクトが数週間から数か月経過するまで明らかにならない可能性があります。この特性が MongoDB サーバーのバグかどうかは価値観の問題なので、ここで検証すべきことではありません。重要なのは、調査テストではテクノロジを詳しく調べ、こうした "興味深い" 動作を切り分けるのに役立つことです。その結果、このテクノロジの使用を検討している開発者は、この動作によって大きな変更につながるかどうかについて、決断を下すことができます。備えあれば憂いなしです。

コードを修正すればテストは合格します。ちなみに、データベースから返される DateTime は、現地時刻に変換する必要があります。現地時刻への変換についてオンライン フォーラムに投稿すると、MongoDB.Driver の作成者である Sam Corder 氏から「渡される日付はすべて UTC に変換され、UTC のまま返されます」との回答がありました。そこで、DateTime を UTC 時刻に変換してから、DateTime.ToUniversalTime を使用して格納する必要があります。または、DateTime.ToLocalTime を使用して、データベースから取得した DateTime をローカル タイム ゾーンに変換する必要があります。後者の方法で変換するサンプル コードを次に示します。

Assert.AreEqual(ted["birthday"], 
  ((DateTime)result["birthday"]).ToLocalTime());

このこと自体でコミュニティの取り組みの大きなメリットの 1 つが明らかになります。つまり、主なやり取りが電子メールだけで行われることです。

複雑さの増加

MongoDB の使用を検討している開発者は、MongoDB が第一印象とは異なり、オブジェクト データベースではないことを理解する必要があります。つまり、何のサポートもなしに、任意の複雑なオブジェクト グラフを処理することはできません。このようなサポートを提供する方法に関して慣例はいくつかありますが、その多くは開発者が負担することになります。

たとえば、図 3 のようなシンプルなオブジェクトのコレクションを考えてみましょう。このコレクションは、米国では有名な家族を記載した多数のドキュメントのストレージを反映するように設計されています。ここまでは順調ですね。ちなみに、このようなオブジェクト コレクションに対して、テストでは、これらのオブジェクトが挿入されたデータベースに実際にクエリして (図 4 参照)、オブジェクトが取得可能であることを確認します。このテストは合格します。なんてすばらしいのでしょう。

図 3 シンプルなオブジェクト コレクション

[TestMethod]
public void StoreAndCountFamily()
{
  Mongo db = new Mongo();
  db.Connect();

  var peter = new Document();
  peter["firstname"] = "Peter";
  peter["lastname"] = "Griffin";

  var lois = new Document();
  lois["firstname"] = "Lois";
  lois["lastname"] = "Griffin";

  var cast = new[] {peter, lois};
  db["exploretests"]["familyguy"].Insert(cast);
  Assert.IsNotNull(peter["_id"]);
  Assert.IsNotNull(lois["_id"]);

  db.Disconnect();
}

図 4 データベースにオブジェクトをクエリ

[TestMethod]
public void StoreAndCountFamily()
{
  Mongo db = new Mongo();
  db.Connect();

  var peter = new Document();
  peter["firstname"] = "Peter";
  peter["lastname"] = "Griffin";

  var lois = new Document();
  lois["firstname"] = "Lois";
  lois["lastname"] = "Griffin";

  var cast = new[] {peter, lois};
  db["exploretests"]["familyguy"].Insert(cast);
  Assert.IsNotNull(peter["_id"]);
  Assert.IsNotNull(lois["_id"]);

  ICursor griffins =
    db["exploretests"]["familyguy"].Find(
      new Document().Append("lastname", "Griffin"));
  int count = 0;
  foreach (var d in griffins.Documents) count++;
  Assert.AreEqual(2, count);

  db.Disconnect();
}

実は、ここまでの説明は、完全に正しいわけではありません。自宅でこの説明を読んで、コードを入力した読者は、テストが最終的には合格しなくなることに気付くでしょう。想定するオブジェクト数が 2 にならなくなります。これは、データベースがそうあるべきだと考えられているように、複数回データベースを呼び出しても状態が保持されると考え、テスト コードでは挿入したオブジェクトを明示的に削除していないため、テストを繰り返すうちにオブジェクトが残っていくためです。

このことは、ドキュメント指向のデータベースのもう 1 つの特徴を明らかにしています。つまり、重複を完全に想定および許可している点です。そのため、ドキュメントが挿入されると、暗黙のうちに _id 属性のタグが付加され、データベース内に格納するための一意識別子が指定されます。この一意識別子は、事実上、ドキュメントの主キーになります。

そこで、テストが合格したら、次のテストを実行する前に、毎回データベースをクリアする必要があります。MongoDB がファイルを格納するディレクトリ内のファイルを削除する方法が非常に簡単ですので、この処理をテスト スイートの一部として、自動的に実行することを強くお勧めします。この処理が完了したら、各テストを手動で実行できますが、手動での実行は時間のかかる少し面倒な作業に感じられます。そこで、テスト コードで、Microsoft Test and Lab Manager の TestInitialize と TestCleanup の機能を利用して、共通コードを実行することができます (データベースの接続と切断のロジックをここに含めてはいけない理由はありません)。この処理については、図 5 をご覧ください。

図 5 TestInitialize および TestCleanup の使用

private Mongo db;

[TestInitialize]
public void DatabaseConnect()
{
  db = new Mongo();
  db.Connect();
}
        
[TestCleanup]
public void CleanDatabase()
{
  db["exploretests"].MetaData.DropDatabase();

  db.Disconnect();
  db = null;
}

次回のテストではフィールド参照を新しい Mongo オブジェクトで上書きするため、CleanDatabase メソッドの最後の行は必要ありませんが、場合にもよりますが、参照を利用できなくなっていることを明確に示すことをお勧めします。用心を怠らないようにしてください。重要なのは、テストに使用したデータベースを削除し、MongoDB がデータの格納に使用するファイルを空にして、次回のテストに備えてすべてをクリーンアップすることです。

ただし、現状では、家族のモデルは不完全です。例に挙げた 2 人は夫婦ですから、配偶者としてお互いに参照できるため、次のようなコードを用意します。

peter["spouse"] = lois;
  lois["spouse"] = peter;

テストでこのコードを実行すると、StackOverflowException が生成されます。MongoDB ドライバー シリアライザーでは、循環参照という概念が最初から考慮されていないため、単純に際限なく参照を続けることになります。さて、困りました。これは問題です。

これを解決するには、2 つのオプションのいずれかを選択する必要があります。1 つは、spouse フィールドに、他のドキュメントの _id フィールドをそのドキュメントの挿入後に設定し、更新する方法です (図 6 参照)。

図 6 循環参照の問題の解決方法

[TestMethod]
public void StoreAndCountFamily()
{
  var peter = new Document();
  peter["firstname"] = "Peter";
  peter["lastname"] = "Griffin";

  var lois = new Document();
  lois["firstname"] = "Lois";
  lois["lastname"] = "Griffin";

  var cast = new[] {peter, lois};
  var fg = db["exploretests"]["familyguy"];
  fg.Insert(cast);
  Assert.IsNotNull(peter["_id"]);
  Assert.IsNotNull(lois["_id"]);

  peter["spouse"] = lois["_id"];
  fg.Update(peter);
  lois["spouse"] = peter["_id"];
  fg.Update(lois);

  Assert.AreEqual(peter["spouse"], lois["_id"]);
  TestContext.WriteLine("peter: {0}", peter.ToString());
  TestContext.WriteLine("lois: {0}", lois.ToString());
  Assert.AreEqual(
    fg.FindOne(new Document().Append("_id",
    peter["spouse"])).ToString(),
    lois.ToString());

  ICursor griffins =
    fg.Find(new Document().Append("lastname", "Griffin"));
  int count = 0;
  foreach (var d in griffins.Documents) count++;
  Assert.AreEqual(2, count);
}

ただし、この方法には 1 つ欠点があります。ドキュメントをデータベースに挿入したら、そのドキュメントの _id 値 (MongoDB.Driver 用語では Oid インスタンス) を、該当する各オブジェクトの spouse フィールドにコピーする必要があります。その後、各ドキュメントを再び更新します。従来の RDBMS 更新を使用したアクセスに比べれば、MongoDB データベースへのアクセスは高速ですが、それでもやや無駄の多い方法です。

もう 1 つは、各ドキュメントの Oid 値を事前に生成して、spouse フィールドに設定して、すべてまとめてデータベースに送信する方法です (図 7 参照)。

図 7 循環参照の問題を解決するより優れた方法

[TestMethod]
public void StoreAndCountFamilyWithOid()
{
  var peter = new Document();
  peter["firstname"] = "Peter";
  peter["lastname"] = "Griffin";
  peter["_id"] = Oid.NewOid();

  var lois = new Document();
  lois["firstname"] = "Lois";
  lois["lastname"] = "Griffin";
  lois["_id"] = Oid.NewOid();

  peter["spouse"] = lois["_id"];
  lois["spouse"] = peter["_id"];

  var cast = new[] { peter, lois };
  var fg = db["exploretests"]["familyguy"];
  fg.Insert(cast);

  Assert.AreEqual(peter["spouse"], lois["_id"]);
  Assert.AreEqual(
    fg.FindOne(new Document().Append("_id",
    peter["spouse"])).ToString(),
    lois.ToString());

  Assert.AreEqual(2, 
    fg.Count(new Document().Append("lastname", "Griffin")));
}

Oid 値が事前に把握されるため、必要なのは Insert メソッドのみです。ちなみに、アサーション テストにおける ToString 呼び出しは意図的なものであることに注意してください。これで、ドキュメントを文字列に変換してから、比較を行っています。

図 7 のコードで注目すべき本当に重要なことは、Oid を使用して参照されるドキュメントの逆参照は、比較的難しく面倒になる可能性があることです。ドキュメント指向のスタイルは、ドキュメントが多かれ少なかれスタンドアロンのエンティティまたは階層エンティティであり、オブジェクトのグラフではないことを前提としているためです (.NET ドライバーには DBRef が用意されています。DBRef の方が、別のドキュメントの参照や逆参照の方法をわずかに多く用意しています。ただし、ドキュメントを、オブジェクト グラフに対応したシステムに作り替えることはできません)。これで、機能豊富なオブジェクト モデルを受け取って、MongoDB データベースに格納できますが、この方法はあまりお勧めしません。Word 文書または Excel ドキュメントを指標となるメタファーとして使用し、密接にクラスター化されるデータのグループを格納するようにしてください。処理の対象が大規模な文書やワークシートの可能性がある場合は、MongoDB などのドキュメント指向データベースを使用するのが適しているでしょう。

さらなる応用

これで MongoDB の調査は終了ですが、まとめに入る前に、クエリ述語の使用、集計、LINQ サポート、運用環境の管理に関する説明など、他にも調査すべき事項がいくつかあります。これについては来月紹介します (内容の濃いコラムになることでしょう)。今回のコラムでは、MongoDB システムを調査しました。今後のコラムに関して提案がありましたら、電子メールでお知らせください。

Ted Neward は、.NET Framework および Java のエンタープライズ プラットフォーム システムを専門とする独立企業 Neward & Associates の社長を務めています。これまでに 100 個を超える記事を執筆している Ted は、C# MVP であり、INETA の講演者でもあります。さまざまな書籍を執筆および共同執筆していて、近々発売される『Professional F# 2.0』(Wrox、英語) もその 1 つです。彼は定期的にコンサルティングを行い、開発者を指導しています。彼の連絡先は ted@tedneward.com (英語のみ) です。ブログを blogs.tedneward.com (英語) に公開しています。

この記事のレビューに協力してくれた技術スタッフの Sam Corder に心より感謝いたします。