Работающий программист

Куда идет NoSQL с MongoDB. Части 2 и 3

Тэд Ньюард

Загрузка примера кода

Ted NewardВ предыдущей статье по MongoDB были рассмотрены: установка, запуск, а также вставка и поиск данных. Однако я изложил самые элементарные вещи: использовавшиеся объекты данных были простыми парами «имя-значение». Это имело смысл, потому что «изюминка» MongoDB – работа с неструктурированными данными и сравнительно простыми структурами данных. Но, разумеется, эта база данных способна куда на большее, чем на хранение незамысловатых пар «имя-значение».

В этой статье мы используем несколько иной способ изучения MongoDB (или любой технологии). Процедура под названием исследовательский тест (exploration test) поможет нам найти возможную ошибку в серверном процессе и попутно высветить одну из наиболее распространенных проблем, с которой сталкиваются при использовании MongoDB разработчики, привыкшие к объектно-ориентированным базам данных.

В последнем эпизоде…

Сначала убедимся, что все мы на одной странице, и двинемся дальше. Давайте подойдем к MongoDB несколько более структурированно, чем в предыдущей статье (msdn.microsoft.com/magazine/ee310029). Вместо того чтобы просто написать элементарное приложение и забавляться с ним, убьем двух зайцев одним выстрелом и создадим исследовательские тесты: сегменты кода, похожие на модульные тесты, но позволяющие изучать функциональность, а не пытаться тестировать ее.

Написание исследовательских тестов служит нескольким целям при изучении новой технологии. Во-первых, эти тесты помогают выяснить, присуща ли изучаемой технологии поддержка тестирования (если ее трудно тестировать в целях изучения, то же самое будет и при модульном тестировании — огромный красный флаг). Во-вторых, они служат своего рода регрессионными тестами в случае выпуска новой версии существующей технологии, так как позволяют заблаговременно узнать, что старая функциональность больше не работает. И в-третьих, поскольку тесты должны быть компактными и гранулярными, исследовательские тесты по самой своей природе облегчают изучение технологии за счет создания новых случаев «что если», которые строятся на основе предыдущих случаев.

В отличие от модульных тестов исследовательские не разрабатываются непрерывно в процессе создания приложения, поэтому, как только вы изучили технологию, отложите эти тесты. Но не выбрасывайте их — они способны помочь в отделении ошибок приложения от ошибок в библиотеке или инфраструктуре. Это достигается тем, что такие тесты создают облегченную и нейтральную к приложению среду для экспериментов, свободную от генерируемых приложением издержек.

Учитывая все это, давайте создадим MongoDB-Explore— тестовый проект на VisualC#. Добавьте MongoDB.Driver.dll в список ссылок на сборки и скомпилируйте проект, чтобы убедиться, все ли в порядке. (При сборке должен быть выбран один TestMethod, генерируемый как часть шаблона проекта. По умолчанию он успешно выполняется, поэтому все должно быть в порядке; если же сборка проекта завершится неудачей, значит, в вашей среде что-то не так. Проверка никогда не помешает.)

Как бы ни хотелось сразу же взяться за написание кода, вы быстро обнаружите: прежде чем клиентский код сможет соединяться сMongoDBи делать что-то полезное, нужно запустить внешний серверный процесс (mongod.exe). Вероятно, вы подумали: «Ну и ладно, сейчас запустим его и вернемся к написанию кода». Но проблема глобальнее. Почти уверен, что в какой-то момент, недель эдак через 15, когда какой-нибудь разработчик-бедолага (вы, я или коллега) попытается запустить эти тесты, он увидит, что все они провалились, и потеряет два-три дня, пытаясь разобраться, в чем дело. И только потом поймет, что надо проверять, выполняется ли сервер.

Урок: старайтесь каким-либо образом охватить в тестах все зависимости. В любом случае эта проблема вылезет при модульном тестировании. Тогда нам понадобится запуск из чистого серверного процесса, внесение некоторых изменений и последующая их отмена. Легче всего добиться этого простой остановкой и запуском сервера, так что, сделав это сейчас, вы сэкономите время потом.

Эта идея запуска чего-либо до его тестирования (или одновременно) ненова, и в проектах MicrosoftTestandLabManager можно найти инициализаторы и методы очистки как для каждого теста индивидуально, так и для целых наборов тестов. Они дополнены атрибутами ClassInitializeиClassCleanup в случае наборов тестов и атрибутами TestInitializeиTestCleanup в случае индивидуальных тестов. (Детали см. в статье «Working with Unit Tests».) Таким образом, инициализаторнаборатестовавтоматическизапуститпроцесс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, вы закроете это окно..., пока не запустится следующий тест. Когда этот диалог вас окончательно достанет, найдите кнопку-переключатель Never show this dialog box again, выберите ее, и это уведомление больше не будет показываться. Если у вас работает брандмауэр, например WindowsFirewall, диалог скорее всего появится и здесь, так как сервер пытается открыть порт для приема клиентских соединений. Проделайте то же самое, и все должно будет работать «молча». Если хотите, установите точку прерывания на первую строку кода очистки, чтобы убедиться, что сервер выполняется.

Как только сервер стартует, тесты можно запускать, но возникает другая проблема: каждый тест хочет работать с собственной «свежей» базой данных, но для базы данных полезно иметь какие-то заранее внесенные данные, чтобы при тестировании было проще выполнять определенные операции (например, запросы). Было бы неплохо, чтобы у каждого теста был собственный «свежий» набор имеющихся данных. В этом и заключается роль методов с атрибутами 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();
}

Если этот код запускается, он спотыкается о проверочное условие Assert, и тест завершается неудачей. В частности, срабатывает последняя проверка вокруг «birthday». Таким образом, совершенно очевидно, что передача DateTimeбез указания времени в базу данных MongoDBи возврат из нее не выполняются корректно. Тип данных передается как дата с сопоставленным ей временем (полночь), но возвращается как дата со связанным временем, равным восьми утра, что нарушает условие AreEqualв конце теста.

Это подчеркивает полезность исследовательского теста — без него (как в случае, например, с кодом из предыдущей статьи) эта маленькая особенность MongoDB могла бы оставаться незамеченной в течение недель или даже месяцев работы над проектом. Ошибка ли это в сервере MongoDB, пока не столь важно и прямо сейчас мы это исследовать не станем. Суть в том, что исследовательский тест помещает технологию под микроскоп, помогая выделить ее «интересные» особенности. Это помогает разработчикам, которые подумывают о применении данной технологии, самим принимать обоснованные решения, станет ли переход на эту технологию разрушающим изменением. Предупрежден — значит, вооружен.

Исправление кода для успешного прохождения тестов, между прочим, требует преобразования DateTime, возвращаемого базой данных в местное время. Я узнал об этом на одном из сетевых форумов, и, согласно ответу от автора MongoDB.Driver, Сэма Кордера (Sam Corder), «все принимаемые даты преобразуются базой данных в UTC и при возврате остаются в этом формате». Так что вы должны либо преобразовывать DateTime в UTC-время перед сохранением в базе данных с помощью DateTime.ToUniversalTime, либо преобразовывать любые значения DateTime, извлекаемые из базы данных в местное время в соответствии с вашим часовым поясом через DateTime.ToLocalTime:

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 хранит их, автоматическое выполнение этой операции как части набора тестов гораздо предпочтительнее. В каждом тесте это делается вручную после завершения, что со временем может несколько утомить. В коде тестов можно использовать преимущества атрибутов TestInitialize и TestCleanup от MicrosoftTestandLabManager для охвата общего кода (и почему бы не включить в область их действия логику подключения к базе данных и отключения от нее?), как показано на рис. 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, иногда лучше явно сделать ссылку недействительной. Caveat emptor. Важно, что измененная тестом база данных отбрасывается, опустошая файлы, которые MongoDB использует для хранения данных, и оставляет все в первозданном виде для следующего теста.

Однако в том виде, как сейчас, модель семьи не закончена: двое людей, на которых есть ссылка, являются парой, а значит, у них должна быть ссылка друг на друга как на супружескую чету:

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

Но запуск этого теста дает исключение StackOverflowException — сериализатор драйвера MongoDB не понимает концепции круговых ссылок и наивно следует по этим ссылкам ad infinitum. Ой. Это не хорошо.

Для исправления можно выбрать один из двух вариантов. В первом случае поле 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);
}

Однако у этого подхода есть недостаток: Он требует вставки документов в базу данных и нужно, чтобы их значения _id (которые на языкеMongoDB.Driver являются экземплярами Oid) копировались в поля spouse каждого объекта. Затем каждый документ опять обновляется. Хотя обмен информацией с базой данных MongoDB происходит быстро в сравнении с операциями обновления в традиционных реляционных СУБД, этот способ все же попусту расходует ресурсы.

Второй вариант — предварительная генерация значений 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")));
}

При таком подходе достаточно метода Insert, потому что теперь значения Oid известны заблаговременно. Кстати, заметьте, что вызовы ToString в тесте проверки условия включены преднамеренно: благодаря этому документы преобразуются в строки до сравнения.

Но особенно важно отметить в коде на рис. 7, что разыменование (de-referencing) документа, на который код ссылается через Oid, может оказаться относительно сложной и утомительной задачей, так как документно-ориентированный стиль предполагает, что документы более-менее автономные или иерархические сущности, а не граф объектов. (Заметьте, что .NET-драйвер предоставляет DBRef для большей гибкости в ссылке на другой документ и разыменовании, но все равно не приближает эту задачу к системе, дружественной к графам объектов.) Таким образом, хотя в принципе возможно сохранить в базе данных MongoDB богатую объектную модель, делать это не рекомендуется. Придерживайтесь хранения сгруппированных данных, используя документы WordилиExcel в качестве «руководящей метафоры». Если нечто можно интерпретировать как большой документ или электронную таблицу, оно почти наверняка хорошо подойдет для хранения в MongoDBили какой-либо другой документно-ориентированной базе данных.

Дополнительно

Мы завершили рассмотрение MongoDB, но в заключение необходимо рассмотреть еще несколько вещей, включая выполняющиеся запросы предикатов, совокупности, поддержку LINQ и некоторые замечания о рабочей среде. Это будет рассмотрено в статье в следующем месяце. (Эта статья будет довольно трудной!) Тем временем изучите систему MongoDB и свяжитесь со мной по электронной почте, если у вас есть предложения для будущих статей.      

Тэд Ньюард (Ted Neward)  – глава Neward & Associates, независимой компании, специализирующейся системах корпоративных платформ Microsoft .NET Framework и Java. Он автор более 100 статей, обладатель статуса MVP по C#, является спикером INETA, а также автором и соавтором десятка книг, включая еще не выпущенную «Professional F# 2.0» (Wrox). Он регулярно занимается консультированием преподаванием. Связаться с ним можно по адресу ted@tedneward.com или через блог blogs.tedneward.com.

Выражаем благодарность за рецензирование статьи эксперту : Сэму Кордеру