Compartir a través de


El programador políglota

Optar por NoSQL con MongoDB, parte 2

Ted Neward

Descargar el código de muestra

Ted NewardEn mi artículo anterior, se presentaron los aspectos básicos de MongoDB: su instalación, ejecución, inserción y búsqueda de datos. Sin embargo, sólo abarqué los aspectos básicos: los objetos de datos que usé fueron pares de nombres/valores simples. Esto tenía sentido, porque el uso más idóneo de MongoDB incluye estructuras de datos no estructuradas y relativamente simples. Pero seguramente esta base de datos puede almacenar más que pares de nombres/valores simples.

En este artículo, usaré un método un poco diferente para investigar MongoDB (o cualquier tecnología). El procedimiento, llamado “prueba de exploración”, nos ayudará a encontrar un posible error en el servidor y, al mismo tiempo, resaltar uno de los problemas comunes que enfrentarán los desarrolladores orientados a objetos al usar MongoDB.

En nuestro último episodio...

Primero nos aseguraremos de que todos estamos en la misma página, y también cubriremos un terreno ligeramente nuevo. Analicemos MongoDB de manera un poco más estructurada respecto de cómo lo hicimos en el artículo anterior (msdn.microsoft.com/magazine/ee310029). En lugar de sólo crear una aplicación simple y analizarla, matemos dos pájaros de un tiro y creemos pruebas de exploración; segmentos de código que se asemejan a pruebas unitarias, pero que exploran la funcionalidad en lugar de comprobarla.

Escribir pruebas de exploración sirve para varios propósitos distintos cuando se investiga una tecnología nueva. Uno, ayudan a detectar si la tecnología que se está investigando se puede probar de manera inherente (suponiendo que si es difícil de realizar una prueba de exploración, será difícil realizar una prueba unitaria: una gran bandera roja). Dos, sirven como una especie de regresión cuando surge una versión nueva de la tecnología que se está investigando, porque pone sobre aviso en caso de que la funcionalidad anterior ya no funcione. Y tres, como las pruebas deben ser relativamente pequeñas y granulares, las pruebas de exploración hacen que aprender una tecnología sea inherentemente más fácil al crear nuevos casos de supuestos que pueden basarse en casos anteriores.

Pero, a diferencia de lo que ocurre con las pruebas unitarias, las pruebas de exploración no se realizan continuamente junto con la aplicación, por lo que una vez que considere que se aprendió la tecnología, separe las pruebas. Sin embargo, no las descarte: también pueden ayudar a separar los errores existentes en el código de la aplicación de los que se encuentran en la biblioteca o en el marco. Las pruebas realizan esto al brindar un entorno ligero y sin aplicaciones para realizar experimentación sin la sobrecarga que significa la aplicación.

Teniendo eso en mente, creemos MongoDB-Explore, un proyecto de prueba de Visual C#. Agregue MongoDB.Driver.dll a la lista de referencias de ensamblado y compile para asegurarse de que todo está preparado para comenzar. (La compilación debe recoger el TestMethod que se genera como parte de la plantilla del proyecto. Pasará la prueba de manera predeterminada, por lo que todo debiera resultar bien, lo que significa que si el proyecto no se compila, hay algo que falla en el entorno. Siempre es bueno revisar los supuestos).

Sin embargo, y por más tentador que sea ir de inmediato a escribir código, un problema surge muy rápidamente: MongoDB necesita que el proceso de servidor externo (mongod.exe) se ejecute antes de que el código cliente pueda conectarse a él y hacer algo útil. A pesar de que es tentador decir simplemente “Bueno, bueno, iniciémoslo y volvamos a escribir código”, existe un problema evidente. Es una apuesta casi segura que, en algún punto, 15 semanas después cuando se vuelva a mirar este código, algún pobre desarrollador (usted, yo mismo o un compañero de equipo) intentará ejecutar estas pruebas, verá que todas fallan y perderá dos o tres días tratando de entender qué pasa antes de que se le ocurra ver si el servidor se está ejecutando.

Lección: intente capturar todas las dependencias de las pruebas de alguna manera. El problema de todas maneras volverá a surgir durante las pruebas unitarias. En ese punto, tendremos que comenzar desde un servidor limpio, realizar algunos cambios y luego deshacerlos completamente. Esto es más fácil de lograr si simplemente se detiene e inicia el servidor, por lo que resolver el problema ahora ahorrará tiempo más adelante.

Esta idea de ejecutar algo antes de realizar las pruebas (o después, o ambas opciones) no es nueva, y los proyectos del Administrador de pruebas y laboratorio de Microsoft pueden tener inicializadores por prueba y por conjunto de pruebas y métodos de limpieza. Estos se adornan con los atributos personalizados ClassInitialize y ClassCleanup para la contabilidad por conjunto de pruebas y TestInitialize y TestCleanup para la contabilidad por prueba. (Consulte “Trabajo con pruebas unitarias” para obtener más detalles). De esa manera, un inicializador por conjunto de pruebas lanzará el proceso mongod.exe y la limpieza por conjunto de pruebas cerrará el proceso, tal como aparece en la figura 1.

Figura 1 Código parcial para inicializador y limpieza de pruebas

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

La primera vez que esto se ejecute, aparecerá un cuadro de diálogo informando al usuario que se está iniciando el proceso. Si hace clic en Aceptar, el cuadro de diálogo desaparecerá... hasta la próxima vez que se ejecute la prueba. Cuando el cuadro de diálogo se transforme en una molestia, busque el cuadro de opciones que dice “No volver a mostrar este cuadro de diálogo” y márquelo para que el mensaje no vuelva a aparecer. Si se ejecuta un software de firewall, como Firewall de Windows, es posible que el cuadro de diálogo también aparezca aquí, porque el servidor desea abrir un puerto para recibir las conexiones de cliente. Aplique el mismo tratamiento y todo debería ejecutarse de manera silenciosa. Si lo desea, ponga un punto de interrupción en la primera línea del código de limpieza para comprobar que se está ejecutando el servidor.

Una vez que el servidor esté en ejecución, es posible iniciar las pruebas, a menos que surja otro problema: cada prueba desea trabajar con su propia base de datos nueva, pero es útil que la base de datos tenga datos preexistentes para facilitar las pruebas de ciertos elementos (por ejemplo, consultas). Sería bueno que cada prueba tuviera su propio conjunto nuevo de datos preexistentes. Ése sería el rol de los métodos adornados con TestInitializer y TestCleanup.

Pero antes de que vayamos a eso, examinemos este TestMethod rápido, que intenta garantizar que es posible encontrar el servidor, realizar una conexión e insertar, encontrar y eliminar un objeto, para llevar a las pruebas de exploración a la velocidad que hemos cubierto en el artículo anterior (consulte la figura 2).

Figura 2 TestMethod para garantizar que es posible encontrar el servidor y realizar una conexión

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

Si este código se ejecuta, activa una aserción y falla la prueba. En especial, se activa la última aserción en torno a “cumpleaños”. Por lo que, aparentemente, enviar un DateTime a la base de datos MongoDB sin una hora no realiza el recorrido de ida y vuelta de manera correcta. El tipo de datos va como una fecha con una hora de medianoche asociada, pero vuelve como una fecha con una hora asociada de 8 a.m., lo que interrumpe la aserción AreEqual al final de la prueba.

Esto resalta la utilidad de la prueba de exploración; sin ello (como es el caso, por ejemplo, con el código del artículo anterior), es posible que esta pequeña característica de MongoDB haya pasado desapercibida hasta semanas o meses en el proyecto. Analizar si esto es un error en el servidor MongoDB es un juicio de valor, no algo para explorar ahora mismo. El punto es que la prueba de exploración puso la tecnología bajo el microscopio, lo que ayuda a aislar este comportamiento “interesante”. Eso permite que los desarrolladores que buscan usar la tecnología tomen sus propias decisiones respecto de si es un cambio importante. Hombre prevenido vale por dos.

A propósito, corregir el código para pasar las pruebas requiere convertir el DateTime que se devuelve de la base de datos a la hora local. Me referí a esto en un foro en línea, y según la respuesta de Sam Corder, creador de MongoDB.Driver, "Todas las fechas entrantes se convierten a UTC, pero se dejan como UTC cuando salen”. Por lo tanto, debe convertir el DateTime a hora UTC antes de almacenarlo vía DateTime.ToUniversalTime o, de lo contrario, convertir cualquier DateTime recuperado de la base de datos a la zona horaria local a través de DateTime.ToLocalTime, mediante el siguiente código de muestra:

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

Esto resalta en sí mismo una de las grandes ventajas de los esfuerzos de la comunidad; normalmente, las entidades involucradas están sólo a un correo electrónico de distancia.

Adición de complejidad

Los desarrolladores que buscan usar MongoDB deben comprender que, contrariamente a las apariencias iniciales, no es una base de datos de objetos; es decir, no puede manejar de manera arbitraria gráficos de objetos complejos sin ayuda. Existen algunas convenciones que tienen que ver con maneras de brindar dicha ayuda, pero eso en lugar de hacerlo queda como responsabilidad del desarrollador.

Por ejemplo, considere la figura 3, una recopilación simple de objetos diseñados para reflejar el almacenamiento de un número de documentos que describen una familia ampliamente conocida. Ningún problema por el momento. De hecho, mientras está ahí, la prueba realmente debería consultar a la base de datos esos objetos insertados, tal como se muestra en la figura 4, sólo para asegurarse de que se pueden recuperar. Y... se pasa la prueba. Fabuloso.

Figura 3 Una recopilación simple de objetos

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

Figura 4 Consulta de objetos en la base de datos

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

En realidad, es probable que esto no sea completamente cierto; puede que los lectores que nos siguen en casa y que escriben el código encuentren que, después de todo, no se pasa la prueba, porque reclama que el número esperado de objetos no coincide con 2. Esto se debe a que, tal como se espera que hagan las bases de datos, esta retiene el estado en todas las invocaciones, y como el código de prueba no elimina explícitamente esos objetos, permanecen en todas las pruebas.

Esto resalta otra característica de la base de datos orientada a documentos: se esperan y permiten los duplicados. Es por esta razón que cada documento, una vez insertado, se etiqueta con el atributo implicit_id y recibe un identificador único dentro del cual almacenarse, lo que de hecho se convierte en la clave principal del documento.

Por lo tanto, si se van a pasar las pruebas, se debe borrar la base de datos antes de ejecutar cada prueba. A pesar de que es muy fácil simplemente eliminar los archivos en el directorio donde MongDB los almacena, es considerablemente preferible hacer que esto se haga de manera automática como parte del conjunto de pruebas. Cada prueba puede hacer esto de manera manual después de la finalización, lo que con el tiempo puede volver un poco tedioso. O el código de prueba puede aprovechar la característica TestInitialize y TestCleanup del Administrador de pruebas y laboratorio de Microsoft para capturar el código común (y por qué no, incluir la lógica de conexión y desconexión de la base de datos), tal como se muestra en la figura 5.

Figura 5 Ventajas de TestInitialize y 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;
}

A pesar de que la última línea del método CleanDatabase es innecesaria porque la próxima prueba sobrescribirá la referencia de campo con un nuevo objeto de Mongo, a veces es mejor clarificar que la referencia ya no sirve. Por cuenta y riesgo del comprador. Lo importante es que se elimina la base de datos de prueba sucias, vaciando los archivos que MongoDB usa para almacenar los datos y dejando todo muy limpio para la próxima prueba.

Sin embargo y como están las cosas, el modelo de familia está incompleto: las dos personas a las que se hace referencia son una pareja y, por lo tanto, deben tener una referencia entre sí como cónyuges, tal como se muestra aquí:

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

Sin embargo, ejecutar esto en la prueba produce una StackOverflowException; el serializador de controladores de MongoDB no comprende de manera nativa la noción de referencias circulares y sigue las referencias una y otra vez indefinidamente. Uy. Eso no es bueno.

Para corregir esto debe elegir una de dos opciones. Con una, es posible rellenar el campo cónyuge con el campo _id del otro documento (una vez que se ha insertado ese documento) y actualizar, tal como se muestra en la figura 6.

Figura 6 Cómo superar el problema de las referencias circulares

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

Sin embargo, existe un inconveniente en el enfoque: requiere que se inserten documentos en la base de datos y que se copien sus valores _id (que son instancias OID, en lenguaje de MongoDB.Driver) en los campos cónyuges de cada objeto según sea adecuado. Luego se vuelve a actualizar cada documento. A pesar de que los recorridos a la base de datos MongoDB son rápidos en comparación con los de una actualización RDBMS tradicional, este método sigue siendo poco económico.

Un segundo enfoque es generar previamente los valores OID para cada documento, rellenar los campos cónyuge y luego enviar todo el lote a la base datos, tal como se muestra en la figura 7.

Figura 7 Una manera mejor para solucionar el problema de las referencias circulares

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

Este enfoque sólo requiere el método Insert, porque ahora los valores OID se conocen por adelantado. A propósito, observe que las llamadas ToString en la prueba de aserción son deliberadas; de esta manera, los documentos se convierten en cadenas antes de compararlos.

Sin embargo, lo realmente importante que hay que notar acerca del código que aparece en la figura 7 es que dejar de hacer referencia al documento al que se hace referencia a través de OID puede ser relativamente difícil y tedioso, debido a que el estilo orientado a documentos supone que los documentos son entidades más o menos independientes o jerárquicas, y no un gráfico de objetos. (Observe que el controlador .NET ofrece DBRef, que brinda una manera levemente más enriquecida de hacer referencia o dejar de hacer referencia a otro documento, pero que seguirá sin hacer que este sea un sistema compatible con gráficos de objetos). Así, a pesar de que ciertamente es posible tomar un modelo de objetos enriquecido y almacenarlo en una base de datos MongoDB, no es recomendable. Siga con el almacenamiento de grupos de datos estrechamente agrupados, usando documentos de Word o Excel como metáfora guía. Si hay algo que se pueda pensar como un gran documento u hoja de cálculo, es probablemente una buena elección para MongoDB o alguna otra base de datos orientada a documentos.

Más para explorar

Hemos finalizado nuestra investigación de MongoDB, pero antes de concluir, hay algunos elementos más que debemos explorar, incluida la realización de consultas de predicado, agregados, compatibilidad con LINQ y algunas notas de administración de producción. Trataremos eso el próximo mes. (Ese artículo requerirá mucho trabajo) Mientras tanto, explore el sistema MongoDB y no dude en dejarme un correo electrónico con sugerencias para futuras columnas.      

Ted Neward es un director de Neward & Associates, una empresa independiente que se especializa en sistemas empresariales de .NET Framework y plataformas Java. Ha escrito más de 100 artículos, es un MVP de C#, orador de INETA y ha sido autor o coautor de decenas de libros, incluido el próximo “Professional F# 2.0” (Wrox). Regularmente asesora y asiste como mentor. Puede ponerse en contacto con él en ted@tedneward.com y leer su blog en blogs.tedneward.com.

Gracias al siguiente experto técnico por su ayuda en la revisión de este artículo: Sam Corder