孜孜不倦的程序员

通过 MongoDB 推动 NoSQL(第 2 部分)

Ted Neward

下载示例代码

上一篇文章中,主要介绍了 MongoDB 的基本知识:安装、运行,以及插入和查找数据。不过,这篇文章只介绍了基本知识,所用的数据对象是简单的名称/值对。这是有道理的,因为 MongoDB 的最大优势就包括可使用相对简单的非结构化数据结构。可以肯定地说,这种数据库能存储的不只是简单的名称/值对。

在本文中,我们将通过一种略微不同的方法来研究 MongoDB(或任何技术)。这个称为探索测试的过程可帮助我们发现服务器中可能存在的错误,同时可以凸显面向对象开发人员在使用 MongoDB 时会遇到的常见问题之一。

前文回顾…

首先,我们要确保讨论同样的问题,还要涉及一些略微不同的新领域。让我们以一种与前一文章 (msdn.microsoft.com/magazine/ee310029) 相比更加结构化的方式来探讨 MongoDB。我们不只是创建简单的应用程序,然后进行调试,我们将采取一举两得的做法,创建探索测试。探索测试的代码段看起来像单元测试,但它们探索功能而不是尝试验证功能。

在研究一项新技术时,编写探索测试可实现几种不同的目的。其一,它们有助于发现所研究的技术在本质上是不是可以测试的(假设如下:如果难于进行探索测试,则难于进行单元测试,而这是一个很严重的问题)。其二,在所研究的技术出现新的版本时,它们可作为一种回归测试,因为它们可在旧功能不再正常工作的情况下发出警告。其三,测试应是相对小型精细的,因此,在本质上,探索测试通过基于以前用例创建新“what-if”用例,使得新技术的学习更为容易。

不过,与单元测试不同,探索测试不是随应用程序连续开发的,因此,一旦考虑所学习的技术,请将这些测试放在一旁。但不要将它们丢弃,它们还可帮助分离应用程序代码中的错误与库或框架中的错误。这些测试通过提供一种与应用程序无关的轻型环境来进行实验,从而完成这种分离,不会产生应用程序开销。

明确了这一点后,我们来创建 Visual C# 测试项目 MongoDB-Explore。将 MongoDB.Driver.dll 添加到程序集引用列表中,然后进行生成,以确保一切正常。(生成时应选择作为项目模板的一部分而生成的 TestMethod。默认情况下,该测试将会通过,因此一切正常,这意味着,如果项目无法生成,则环境中存在问题。检查假设是否正确总是很好的方法。)

看起来可以立即着手编写代码了,不过,马上会出现一个问题:MongoDB 需要运行外部服务器进程 (mongod.exe),这样客户端代码才能对该进程进行连接,执行有用的操作。我们很容易说“好,好,让我们启动它,然后开始编写代码”,这样还是存在一个必然的问题。几乎可以肯定,15 个星期后的某个时候,回头再看这些代码,某些糟糕的开发人员(您、我或团队同事)会尝试运行这些测试,看着它们全部失败,然后浪费两三天努力寻找原因,这才想起看一看服务器是否已运行。

经验教训:尝试以某种方式在测试中捕获所有依赖关系。不管怎样,问题会再次出现在单元测试过程中。因此,我们需要从全新状态的服务器开始,进行一些更改,然后撤消全部更改。要完成这项工作,最简单的方法是停止并启动服务器,现在将问题解决,就为以后节约了时间。

在测试之前(和/或之后)进行运行操作不是什么新方法,Microsoft 测试和实验室管理器项目可以使用按测试和按测试套件的初始值设定项和清理方法。这些方法包含适用于按测试套件记帐的自定义属性 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();
  }
...

上述代码第一次运行时,将弹出一个对话框,通知用户正在启动进程。单击“确定”,该对话框就会消失 ... 直到下一次运行该测试。如果不希望显示该对话框,请找到并选中单选框“不再显示此对话框”,以便不再显示该消息。如果正在运行防火墙软件(如 Windows 防火墙),也可能出现该对话框,这是因为服务器需要打开一个端口来接收客户端连接。采用同样的方法处理,所有操作都应以无提示方式运行。如果需要,可在清理代码的第一行放置一个断点,验证服务器是否正在运行。

只要服务器正在运行,就可开始测试,除非出现另一个问题:每个测试都需要使用自己的全新数据库,但数据库中预先存在一些数据也是很有用的,这样,更便于进行某些方面(如查询)的测试。每个测试最好都有自己的预先存在的全新数据。包含 TestInitializer 和 TestCleanup 的方法可以完成这一任务。

对此加以讨论之前,我们来看一看这个快速 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();
}

如果运行上述代码,运行到声明时,测试将失败。具体来说,问题出在最后一条关于“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());

这件事本身凸显了社区的一个极大的优点,即通信双方的距离就是一封电子邮件。

增加复杂性

希望使用 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 不匹配。这是因为,正如通常的数据库一样,这个数据库的状态在多次调用中保持不变,此外,由于测试代码不显式删除对象,这些对象在各个测试中都存在。

这凸显了面向文档数据库的另外一个特点:完全可能存在重复项,也允许存在重复项。正因为这,每个文档一经插入,都会由 implicit_id 属性进行标记,并且有一个唯一的标识符存储在该属性中,这个唯一标识符实际上会成为文档的主键。

因此,如果要通过测试,需要在运行每个测试之前清除数据库。尽管删除 MongoDB 存储文件的目录中的文件十分容易,但最好使测试套件能够自动执行这一任务。每个测试都可在完成后以手动方式完成这一任务,时间一长,这会变得有些乏味。测试代码可利用 Microsoft 测试和实验室管理器的 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;
}

CleanDatabase 方法的最后一行不是必不可少的,因为下一个测试会用新的 Mongo 对象覆盖该字段引用,不过,有时最好明确表示出该引用不再有内容。用者自慎。重要的是删除在测试中使用过的数据库,清空 MongoDB 用于存储数据的文件,一切都以全新的状态迎接下一个测试。

不过,就目前情况看,该家庭模型是不完整的。所引用的两个人是一对伴侣,假设他们应将对方引用为配偶,如下所示:

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

如果在测试中运行这段代码,会产生 StackOverflowException。MongoDB 驱动程序序列化程序本身不理解循环引用的概念,它会无休止地引用下去。天哪。这可不是什么好事。

若要修复这一问题,可以在两种方法中选择其一。一种方法是,配偶字段可使用其他文档的 _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);
}

不过,这种方法有一个缺点:它要求将文档插入数据库,并根据需要将它们的 _id 值(在 MongoDB.Driver 中是 Oid 实例)复制到每个对象的配偶字段中。这时,每个文档会再次更新。尽管访问 MongoDB 数据库与传统 RDBMS 更新相比速度是很快的,这种方法仍有些费时。

第二种方法是为每个文档预先生成 Oid 值,填充配偶字段,然后将整个批次发送到数据库,如图 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")));
}

这种方法仅需要 Insert 方法,因为 Oid 值是提前已知的。顺便提请注意,对声明测试的 ToString 调用是特意进行的,这样,文档会在进行比较之前转换为字符串。

图 7 的代码中,真正务必要注意的是,对通过 Oid 引用的文档解除引用可能比较困难和乏味,因为面向文档这种形式假设文档或多或少是独立实体或分层实体,而不是对象图。(请注意,.NET 驱动程序提供了 DBRef,后者可通过略微更丰富的方式来引用/解除引用其他文档,但仍无法实现对象图友好的系统。)因此,尽管肯定可以获得一个丰富的对象模型并将其存储到 MongoDB 数据库中,仍不建议这样做。请坚持使用 Word 或 Excel 这样的文档来存储紧密群集的数据组。如果某些内容可视为大型文档或电子表格,则可能非常适合 MongoDB 或其他某种面向文档的数据库。

了解更多内容

我们已经研究了 MongoDB,在进行总结之前,还需要探索其他一些问题,包括执行谓词查询、聚合、LINQ 支持和一些生产管理说明。我们将在下月探讨这些问题。(敬请期待这篇文章的丰富内容!)同时,我们还会探索 MongoDB 系统,如果对以后的专栏文章有任何建议,欢迎向我发送电子邮件。      

Ted Neward 是 Neward & Associates 的负责人,这是一家专门研究企业 .NET Framework 系统和 Java 平台系统的独立公司。他曾写过 100 多篇文章,是 C# 领域最优秀的专家之一并且是 INETA 发言人,著作或合著过十几本书,包括即将出版的《Professional F# 2.0》(Wrox)。他定期提供咨询和指导。您可通过 ted@tedneward.com 与他联系,也可通过 blogs.tedneward.com 访问其博客。

*衷心感谢以下技术专家对本文的审阅:*Sam Corder