Der Programmierer bei der Arbeit

In Richtung NoSQL mit MongoDB, Teil 2

Ted Neward

Beispielcode herunterladen

Ted NewardIn meinem vorherigen Artikel haben wir uns vornehmlich mit den Grundlagen zu MongoDB beschäftigt: Installation, Ausführung sowie Einfügen und Suchen von Daten. Das waren jedoch nur die Grundlagen – die verwendeten Datenobjekte waren einfache Name/Wert-Paare. Das war sinnvoll, da unstrukturierte und verhältnismäßig einfache Datenstrukturen zu den Stärken von MongoDB gehören. Doch diese Datenbank kann natürlich sehr viel mehr, als bloße Name/Wert-Paare speichern.

In diesem Artikel werden wir eine etwas andere Methode zur Erkundung von MongoDB (oder einer anderen Technologie) anwenden. Mithilfe dieser Vorgehensweise, die als Erkundungstest bezeichnet wird, können wir einen möglichen Fehler auf dem Server finden und nebenbei auf eines der häufigsten Probleme hinweisen, mit dem sich objektorientierte Entwickler bei der Verwendung von MongoDB konfrontiert sehen.

In unserer letzten Folge…

Zunächst wollen wir sicherstellen, dass alle auf dem gleichen Kenntnisstand sind, und dann werden wir uns mit neuen Dingen befassen. Sehen wir uns MongoDB auf eine etwas strukturiertere Weise als beim letzten Mal an (msdn.microsoft.com/magazine/ee310029). Anstatt nur eine einfache Anwendung zu erstellen und diese auszureizen, werden wir zwei Fliegen mit einer Klappe schlagen und so genannte Erkundungstests erstellen – Codesegmente, die wie Komponententests aussehen, die Funktionen erkunden und nicht überprüfen.

Das Schreiben von Erkundungstests dient mehreren unterschiedlichen Zwecken, wenn es darum geht, eine neue Technologie zu untersuchen. Mit Erkundungstests kann ermittelt werden, ob die untersuchte Technologie von Natur aus testfähig ist. (Wenn Erkundungstests für eine Technologie nur schwer ausgeführt werden können, wird davon ausgegangen, dass auch Komponententests sehr schwierig sind – ein deutliches Warnsignal). Außerdem dienen Erkundungstests einer Art Regression, wenn eine neue Version der untersuchten Technologie veröffentlicht wird, da eine Warnung ausgegeben wird, wenn alte Funktionen nicht mehr funktionieren. Und zu guter Letzt ist das Erforschen einer Technologie mit Erkundungstests dank neuer „Was wäre wenn...?“-Fälle, die auf früheren Fällen basieren, einfacher, da Tests verhältnismäßig klein und detailliert sein sollten.

Im Gegensatz zu Komponententests werden Erkundungstests jedoch nicht kontinuierlich neben der Anwendung entwickelt. Wenn Sie also das Gefühl haben, dass Sie die erforschte Technologie beherrschen, hören Sie mit den Tests auf. Verwerfen Sie sie jedoch nicht – sie können auch beim Unterscheiden zwischen Fehlern im Anwendungscode und Fehlern in der Bibliothek oder im Framework behilflich sein. Dies erfolgt über eine einfache anwendungsneutrale Umgebung zu Testwecken ohne den Aufwand der Anwendung.

Lassen Sie uns mit diesem Wissen nun MongoDB-Explore, ein Visual C#-Testprojekt, erstellen. Fügen Sie „MongoDB.Driver.dll“ der Liste von Anwendungsverweisen hinzu, und führen Sie einen Erstellungsvorgang aus, um sicherzustellen, dass alles bereit ist. (Beim Erstellen sollte die eine TestMethod aufgegriffen werden, die als Teil der Projektvorlage generiert wird. Sie wird standardmäßig übergeben, sodass alles in Ordnung sein sollte. Dies bedeutet, dass in der Umgebung ein Problem vorliegt, wenn das Projekt nicht erstellt werden kann. Es ist immer empfehlenswert, Annahmen zu überprüfen.)

Es mag zwar verführerisch sein, gleich mit dem Schreiben von Code loszulegen, ein Problem kommt jedoch sehr schnell zum Vorschein: Für MongoDB muss der externe Serverprozess (mongod.exe) ausgeführt werden, bevor Clientcode eine Verbindung damit herstellen und eine Aufgabe ausführen kann. Vielleicht denken Sie „Wunderbar, ich muss den Prozess nur starten und kann dann gleich weiter den Code schreiben“. Doch leider gibt es dabei ein logisches Problem. Es ist eigentlich nur eine Frage der Zeit, bis 15 Wochen später ein armer Entwickler (Sie, ich oder ein Kollege) versuchen wird, diese Tests auszuführen, die alle fehlschlagen, und dann zwei oder drei Tage damit beschäftigt ist, herauszufinden, wo das Problem liegt, bevor er auf die Idee kommt, nachzusehen, ob der Server ausgeführt wird.

Lektion: Versuchen Sie irgendwie, alle Abhängigkeiten zu erfassen. Das Problem wird bei den Komponententests sowieso wieder hochkommen. Wir müssen an diesem Punkt nun mit einem neuen Server beginnen, einige Änderungen vornehmen und dann alle rückgängig machen. Dies ist am einfachsten zu erzielen, indem Sie den Server einfach anhalten und starten. Sie können also später Zeit sparen, wenn Sie sich jetzt darum kümmern.

Die Idee, dass ein Element vor dem Testen (bzw. danach oder beides) ausgeführt wird, ist nicht neu. Microsoft Test und Lab Manager-Projekte können sowohl Initialisierer pro Test und pro Testsuite sowie Bereinigungsmethoden aufweisen. Diese sind mit den benutzerdefinierten Attributen ClassInitialize und ClassCleanup für die Überwachung pro Testsuite und den Attributen TestInitialize und TestCleanup für die Überwachung pro Test versehen. (Weitere Informationen erhalten Sie unter „Arbeiten mit Komponententests“.) Ein Initialisierer pro Testsuite startet daher den Prozess „mongod.exe“, und die Bereinigung pro Testsuite fährt den Prozess herunter, wie in Abbildung 1 dargestellt.

Abbildung 1 Partieller Code für Testinitialisierer und Bereinigung

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();
  }
...

Bei der ersten Ausführung wird ein Dialogfeld angezeigt, das den Benutzer darüber informiert, dass der Prozess gestartet wird. Sie können das Dialogfeld bis zur nächsten Ausführung des Tests schließen, indem Sie auf „OK“ klicken. Wenn Ihnen dieses Dialogfeld zu lästig wird, suchen Sie das Optionsfeld „Dieses Dialogfeld nicht mehr anzeigen“, und aktivieren Sie es, damit die Meldung für immer verschwindet. Wenn eine Firewallsoftware wie Windows Firewall ausgeführt wird, wird dieses Dialogfeld hier sicherlich auch angezeigt, da der Server einen Port öffnen möchte, um die Clientverbindungen zu empfangen. Gehen Sie hier genauso vor, dann sollte alles im Hintergrund ausgeführt werden. Fügen Sie, falls gewünscht, einen Haltepunkt in die erste Zeile des Bereinigungscodes ein, um zu überprüfen, ob der Server ausgeführt wird.

Sobald der Server ausgeführt wird, kann mit den Tests begonnen werden, es sei denn, es tritt ein weiteres Problem auf: Jeder Test möchte mit einer eigenen frischen Datenbank arbeiten, aber es ist von Vorteil, wenn die Datenbank bereits über einige vorhandene Daten verfügt, damit bestimmte Dinge (beispielsweise Abfragen) einfacher getestet werden können. Es wäre schön, wenn jeder Test seinen eigenen Satz bereits vorhandener Daten haben könnte. Das wird die Rolle der mit TestInitializer- und TestCleanup-versehenen Methoden.

Aber bevor wir dazu kommen, sehen wir uns diese schnelle TestMethod an, die versucht sicherzustellen, dass der Server gefunden, eine Verbindung hergestellt und ein Objekt eingefügt, gefunden und entfernt werden kann, um Erkundungstests damit zu beschleunigen, was im vorherigen Artikel behandelt wurde (siehe Abbildung 2).

Abbildung 2 TestMethod, um sicherzustellen, dass der Server gefunden und eine Verbindung damit hergestellt werden kann

[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();
}

Wenn dieser Code ausgeführt wird, wird eine Assertion ausgegeben und der Test schlägt fehl. Insbesondere die letzte Assertion „birthday“ wird ausgelöst. Das Senden von DateTime an die MongoDB-Datenbank ohne Zeit ergibt offensichtlich keinen korrekten Roundtrip. Der Datentyp wird als Datum der zugewiesenen Zeit „Mitternacht“ gesendet, wird jedoch als Datum mit der zugewiesenen Zeit von 8 Uhr zurückgegeben, weshalb die AreEqual-Assertion am Ende des Tests fehlschlägt.

Dadurch wird hervorgehoben, wie hilfreich der Erkundungstest ist. Ohne ihn (wie es beispielsweise bei dem Code aus dem vorherigen Artikel der Fall ist), wäre diese unscheinbare MongoDB-Eigenschaft möglicherweise wochen- oder monatelang unentdeckt geblieben. Ob dies ein Fehler auf dem MongoDB-Server ist, ist ein Werturteil. Damit werden wir uns aber jetzt nicht näher befassen. Der Punkt ist, dass mithilfe des Erkundungstests die Technologie unter die Lupe genommen wurde; dies ist hilfreich bei der Isolierung dieses „interessanten“ Verhaltens. Entwickler, die vorhaben, die Technologie einzusetzen, können eigene Entscheidungen dahingehend treffen, ob dies eine bedeutende Änderung ist. Gefahr erkannt, Gefahr verbannt.

Um den Code so zu korrigieren, dass der Test erfolgreich ausgeführt wird, muss im Übrigen der DateTime-Wert, der von der Datenbank geliefert wird, in die lokale Zeit konvertiert werden. Ich habe dies in einem Onlineforum zur Sprache gebracht, und der Autor von MongoDB.Driver, Sam Corder, sagt dazu: „Alle gesendeten Datumsangaben werden in UTC umgewandelt, aber beim Zurückgeben in der UTC belassen.“ Sie müssen daher entweder den DateTime-Wert in UTC umwandeln, bevor Sie ihn über DateTime.ToUniversalTime speichern, oder Sie müssen alle aus der Datenbank abgerufenen DateTime-Werte über DateTime.ToLocalTime in die lokale Zeitzone umwandeln. Verwenden Sie hierfür den folgenden Code:

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

Dadurch wird einer der größten Vorteile von gemeinschaftlichen Bemühungen deutlich – die beteiligten Prinzipale sind in der Regel nur eine E-Mail weit entfernt.

Steigern der Komplexität

Entwickler, die vorhaben, MongoDB zu verwenden, müssen verstehen, dass es sich entgegen des ersten Eindrucks nicht um eine Objektdatenbank handelt, d. h. die Datenbank kann ohne Unterstützung keine beliebig komplexen Objektdiagramme verarbeiten. Es gibt einige Konventionen, die sich mit den Möglichkeiten zur Bereitstellung der erforderlichen Unterstützung befassen, die tatsächliche Bereitstellung liegt bisher jedoch in der Verantwortung des Entwicklers.

Sehen Sie sich beispielsweise Abbildung 3 an, eine einfache Sammlung von Objekten, die zum Speichern einer gewissen Anzahl von Dokumenten gedacht ist, die eine bekannte Familie beschreiben. So weit, so gut. Der Test muss während der Ausführung diese Objekte tatsächlich aus der Datenbank abrufen, wie in Abbildung 4 dargestellt, einfach um sicherzustellen, dass die Objekte abrufbar sind. Und…der Test wird erfolgreich ausgeführt. Toll.

Abbildung 3 Eine einfache Objektsammlung

[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();
}

Abbildung 4 Abfragen von Objekten aus der Datenbank

[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();
}

Eigentlich ist das nicht ganz richtig – Leser, die dies zu Hause mitverfolgen und den Code eingeben, werden vielleicht feststellen, dass der Test nicht erfolgreich ausgeführt wird, da die erwartete Anzahl von Objekten nicht 2 entspricht. Dies liegt daran, dass diese Datenbank, ganz wie es von Datenbanken erwartet wird, den Status über Aufrufe hinweg beibehält, und da der Testcode diese Objekte nicht explizit entfernt, werden diese über Tests hinweg beibehalten.

Dies ist eine weitere Funktion der dokumentorientierten Datenbank: Duplikate werden erwartet und sind zulässig. Das ist der Grund, warum jedes Dokument nach dem Einfügen mit dem impliziten _id-Attribut gekennzeichnet wird und einen eindeutigen Bezeichner erhält, in dem es gespeichert wird; dies wird dann letztendlich der primäre Schlüssel des Dokuments.

Wenn die Tests erfolgreich ausgeführt werden, muss die Datenbank geleert werden, bevor die einzelnen Tests ausgeführt werden. Sie können zwar einfach die Dateien in dem Verzeichnis löschen, in dem diese von MongoDB gespeichert werden, aber es ist auf jeden Fall vorzuziehen, dies automatisch im Rahmen der Testsuite auszuführen zu lassen. Jeder Test kann dies nach Abschluss manuell ausführen, dies könnte jedoch mit der Zeit etwas mühsam werden. Der Testcode kann auch die TestInitialize- und TestCleanup-Funktion von Microsoft Test und Lab Manager nutzen, um den gemeinsamen Code zu erfassen (und vielleicht auch die Logik der Datenbank zum Herstellen und Trennen der Verbindung einfügen), wie in Abbildung 5 dargestellt.

Abbildung 5 Verwenden von TestInitialize und 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;
}

Die letzte Zeile der CleanDatabase-Methode ist zwar nicht erforderlich, da der nächste Test den Feldverweis mit einem neuen Mongo-Objekt überschreiben wird, manchmal bietet es sich aber an, darauf hinzuweisen, dass der Verweis nicht mehr gültig ist. Ohne Garantie. Wichtig ist hierbei, dass die von dem Test „verschmutzte“ Datenbank gelöscht wird, wodurch die Dateien, die MongoDB zum Speichern der Daten verwendet, geleert werden, sodass für den nächsten Test alles frisch und sauber ist.

Das Familienmodell ist jedoch nach derzeitigem Stand nicht vollständig – die beiden Personen, auf die verwiesen wird, sind ein Paar und deshalb sollten sie einen gegenseitigen Verweis als Ehepartner aufweisen, wie nachfolgend dargestellt:

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

Durch das Ausführen im Test wird jedoch eine StackOverflowException generiert – der MongoDB-Treiberserialisierer versteht von sich aus keine Zirkelverweise und folgt deshalb den Verweisen endlos. Das ist ein großes Problem, das gelöst werden muss.

Zur Lösung dieses Problems müssen Sie eine von zwei Optionen auswählen. Bei der einen Option kann das spouse-Feld mit dem _id-Feld des anderen Dokuments gefüllt werden (nachdem dieses Dokument eingefügt und aktualisiert wurde), wie in Abbildung 6 dargestellt.

Abbildung 6 Beheben des Zirkelverweisproblems

[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);
}

Dieser Ansatz hat jedoch auch einen Nachteil: Die Dokumente müssen in die Datenbank eingefügt werden, und ihre _id-Werte (die im MongoDB.Driver-Sprachgebrauch Oid-Instanzen sind) müssen, soweit erforderlich, in die spouse-Felder jedes Objekts kopiert werden. Anschließend werden die einzelnen Dokumente erneut aktualisiert. Abrufaktionen in der MongoDB-Datenbank sind zwar im Vergleich zu denen mit einem herkömmlichen RDBMS-Update verhältnismäßig schnell, diese Methode ist jedoch etwas verschwenderisch.

Der zweite Ansatz besteht darin, die Oid-Werte für jedes Dokument vorab zu generieren, die spouse-Felder aufzufüllen und dann den gesamten Batch an die Datenbank zu senden, wie in Abbildung 7 dargestellt.

Abbildung 7 Ein besserer Ansatz zur Lösung des Zirkelverweisproblems

[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")));
}

Für diesen Ansatz ist nur die Insert-Methode erforderlich, da die Oid-Werte nun vorab bekannt sind. Beachten Sie, dass die ToString-Aufrufe in dem Assertionstest wohl durchdacht sind – auf diese Weise können die Dokumente vor dem Vergleichen in Zeichenfolgen umgewandelt werden.

Wirklich wichtig bei dem Code in Abbildung 7 ist jedoch, dass das Aufheben des Verweises auf das Dokument, auf das über die Oid-Werte verwiesen wird, verhältnismäßig schwierig und mühsam sein kann, da der dokumentorientierte Stil voraussetzt, dass die Dokumente mehr oder weniger eigenständige bzw. hierarchische Entitäten darstellen und kein Diagramm mit Objekten. (Beachten Sie, dass der .NET-Treiber DBRef bereitstellt, der eine vielfältigere Möglichkeiten zum Verweisen auf ein anderes Dokument bzw. zum Aufheben dieses Verweises liefert, doch auch dadurch entsteht kein objektdiagrammfreundliches System.) Es ist zwar durchaus möglich, ein umfangreiches Objektmodell in einer MongoDB-Datenbank zu speichern, es wird aber nicht empfohlen. Halten Sie sich daran, eng gruppierte Datengruppen zu speichern, und verwenden Sie Word- und Excel-Dokumente als Leitmetapher. Wenn etwas als großes Dokument oder als große Tabelle gilt, bietet es sich wahrscheinlich für MongoDB oder eine andere dokumentorientierte Datenbank an.

Ein Blick auf die Zukunft

Wir sind nun mit unserer Erkundung von MongoDB fertig, bevor wir das Thema jedoch abschließen, möchte ich noch einige Dinge erwähnen, die wir uns näher ansehen sollten, beispielsweise das Ausführen von Prädikatabfragen, Aggregate, LINQ-Unterstützung und einige Produktionsverwaltungshinweise. Damit werden wir uns nächsten Monat befassen. (Das wird ein ganz schön langer Artikel werden.) Machen Sie sich in der Zwischenzeit mit dem MongoDB-System vertraut, und senden Sie mir eine E-Mail mit Vorschlägen für künftige Kolumnen.      

Ted Neward ist ein Geschäftsführer von Neward & Associates, einer unabhängigen Firma, die sich auf .NET Framework- und Java-Plattformsysteme für Unternehmen spezialisiert hat. Er hat mehr als 100 Artikel geschrieben, ist ein C#-MVP und ein INETA-Sprecher und hat mehrere Bücher allein und in Zusammenarbeit mit anderen geschrieben, darunter das in Kürze erscheinende „Professional F# 2.0“ (Wrox). Er berät und hilft regelmäßig. Sie können ihn unter ted@tedneward.com erreichen, oder lesen Sie seinen Blog unter blogs.tedneward.com.

Unser Dank gilt dem folgenden technischen Experten für die Durchsicht dieses Artikels: Sam Corder