Contenido

MongoDB

En este artículo voy a exponer un problema, como de costumbre, y a resolverlo de la manera más rápida posible. Una vez hecho esto, trataré de cambiarlo para que utilice MongoDB para la persistencia. Usaré YAGNI.

No soy un experto en MongoDB, sino que estoy comenzando mis primeros pinitos. Por eso voy a tratar de explicarlo como yo lo veo.

El problema es el siguiente: quiero un programa que me permita gestionar mi biblioteca. Algo sencillo: sólo libro y autor. Y quiero poder buscar un libro por el título.

La idea de este artículo partió del titulado “Diseño emergente, también para la base de datos”, por Carlos Blé.

Usaré python y freshen.

Mongo DB

Primeros pasos

Comenzando: definir lo que queremos

El primer paso es decidir qué es lo que quiero. Veamos… Lo que quiero es buscar un libro por el título. Eso es lo más importante. Pero no puedo buscar nada si no indico el título o el autor. Así que comenzaré por ahí:

1
2
3
4
5
6
7
8
9
# file: ./tests/library.feature
Feature:
  In order to store my library reference
  As a book lover
  I want to be sure my library works

  Scenario: Author or title is necessary to search a book
    When I search a book without parameters
    Then output contains "You should ask for a title or an author"

Ahora trato de ejecutarlo:

1
$ nosetests --with-freshen tests/library.feature

Preparando el entorno de pruebas

Evidentemente tengo un error. Freshen me está diciendo que no encuentra la manera de ejecutar los pasos del escenario. Por eso tengo que escribir el archivo steps.py.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# file ./tests/steps.py
import os
import subprocess
from freshen import *
from freshen.checks import *

DATABASE_FILE='test_db'

def before(sc):
    if os.path.exists(DATABASE_FILE):
        os.remove(DATABASE_FILE)
    scc.output = None
    scc.status = None

def run(args):
    cmd = ['./library.py', '-d', DATABASE_FILE] + args
    process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
    output, _ = process.communicate()
    status = process.returncode
    return (output, status)

def execute(args):
    scc.output, scc.status = process = run(args)


@When('^I search a book without parameters$')
def exec_search_without_parameters():
    execute(['--action=show'])

@Then('^output contains "(.*)"$')
def check_output_contains(expected):
    assert_true(expected in scc.output, 'Text "{}", was not found in "{}"'.format(expected, scc.output))

Para leer este archivo es mejor comenzar por las claves básicas: el When y el Then. Está claro que en el When lo que quiero es ejecutar mi aplicación; por eso la función comienza por exec_. En el Then realizaré las comprobaciones pertinentes, por lo que comienza por check_.

Llegados a este punto ya he tomado numerosas decisiones:

  • he decidido que mi biblioteca se ejecuta desde línea de órdenes. Eso es así porque era lo más sencillo de probar.
  • he decidido que tenga un parámetro action que admita la variable show que mostrará la información del libro.
  • he decidido el nombre del programa principal, library.py
  • he decidio que, de alguna manera, necesitaré un sitio donde guardar la información; claramente necesitaré limpiar ese sitio entre test y test.

Todo esto… Y aún no he escrito una línea del programa.

Pasando los tests

Vamos a por él:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#!/usr/bin/env python
#File: ./library.py

import sys
import argparse


class Library(object):
    def __init__(self, db):
        self._db = db

    def show(self, title=None, author=None):
        if not title and not author:
            print 'You should ask for a title or an author'
            sys.exit(1)


def parse_args():
    parser = argparse.ArgumentParser(description='My library!')
    parser.add_argument('--action', type=str, dest='action', choices=['add', 'remove', 'show'], required=True,
                        help='indicates the action to be performed')
    parser.add_argument('-a', '--author', dest='author', action="store", default=None,
                        help='The author of the book')
    parser.add_argument('-t', '--title', dest='title', action="store", default=None,
                        help='The title of the book')
    parser.add_argument('-d', '--database', dest='database', action="store", default='library.db',
                        help='Path to the database file')

    args = parser.parse_args()
    return args

def main():
    args = parse_args()

    library = Library(args.database)
    if args.action == 'show':
        library.show(title=args.title, author=args.author)

if __name__ == '__main__':
    main()
    sys.exit(0)

Como veréis, he hecho un poco de trampa. He decidido especificar la interfaz más probable que voy a necesitar. Así no me voy a tener que preocupar después por pelearme con el párser. Podría cambiar cualquier cosa, ya que no está comprometida mediante un test.

Pruebo a volver a ejecutarlo y ahora… pasa. Claro. No hay mucho que hacer de momento.

Avanzando

Creando más tests de aceptación

Con el fin de no alargar mucho el artículo, voy a escribir todo el programa del tirón, aunque en realidad lo he ido escribiendo escenario a escenario.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# file: ./tests/library.feature
Feature:
  In order to store my library reference
  As a book lover
  I want to be sure my library works

  Scenario: Author or title is necessary to search a book
    When I search a book without parameters
    Then output contains "You should ask for a title or an author"

  Scenario: Searching an inexistent book
    When I search the book "inexistent"
    Then output contains "Book not found"
    And my library do not contains the title "inexistent"

  Scenario: Adding a title
    When I add the book "Fundation" written by "Assimov, Isaac"
    Then output contains "Book added"
    And my library contains the title "Fundation"

  Scenario: Searching an existent a title
    Given the book "I, Robot", written by "Assimov, Isaac"
    When I search the book "I, Robot"
    Then output contains "Assimov, Isaac"

  Scenario: Removing a title
    Given the book "I, Robot", written by "Assimov, Isaac"
    When I remove the book "I, Robot"
    Then output contains "Book removed"
    And my library do not contains the title "I, Robot"

Lo que fallará estrepitosamente porque freshen no encuentra la mayoría de las frases. Vamos a solucionarlo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#File: ./tests/steps.py
import os
import subprocess
from freshen import *
from freshen.checks import *
from multiprocessing import Pool

DATABASE_FILE='test.db'

def before(sc):
    if os.path.exists(DATABASE_FILE):
        os.remove(DATABASE_FILE)
    scc.output = None
    scc.status = None

def run(args):
    cmd = ['./library.py', '-d', DATABASE_FILE] + args
    process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
    output, _ = process.communicate()
    status = process.returncode
    return (output, status)

def execute(args):
    scc.output, scc.status = process = run(args)


@Given('^the book "(.*)", written by "(.*)"$')
def initialize_library(title, author):
    execute(['--action=add', '-t', title, '-a', author])

@When('^I search the book "(.*)"$')
def exec_search_by_title(title):
    execute(['--action=show', '--title', title])

@When('^I search a book without parameters$')
def exec_search_without_parameters():
    execute(['--action=show'])

@When('I add the book "(.*)" written by "(.*)"')
def exec_add_book(title, author):
    execute(['--action=add', '-t', title, '-a', author])

@When('I remove the book "(.*)"')
def exec_remove_book(title):
    execute(['--action=remove', '-t', title])

@Then('^output contains "(.*)"$')
def check_output_contains(expected):
    assert_true(expected in scc.output, 'Text "{}", was not found in "{}"'.format(expected, scc.output))

@Then('^my library( do not|) contains the title "(.*)"$')
def check_contains_title(condition, title):
    _, rc = run(['--action=show', '-t', title])
    if not condition:
        assert_equals(0, rc, 'The book "{}" is not in the collection'.format(title))
    else:
        assert_equals(1, rc, 'The book "{}" is in the collection but it shouldnot'.format(title))

Lo que provocará que los tests se lancen… pero fallen.

Pasando los tests

Primero el código y luego lo comento.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
#!/usr/bin/env python
#File: ./library.py

import sys
import argparse
import shelve


class Library(object):
    def __init__(self, db):
        self._db = db

    def show(self, title=None, author=None):
        if not title and not author:
            print 'You should ask for a title or an author'
            sys.exit(1)
        db = self._db_file()
        if not db.has_key(title):
            print 'Book not found'
            db.close()
            sys.exit(1)
        print '{} -> {}'.format(title, db[title])
        db.close()


    def add(self, title, author):
        if not title or not author:
            print 'You should introduce the title and author'
            return

        db = self._db_file()
        db[title] = author
        db.close()
        print 'Book added'

    def remove(self, title):
        if not title:
            print 'You should introduce the title to remove'
            return

        db = self._db_file()
        if db.has_key(title):
            del db[title]
        db.close()
        print 'Book removed'

    def _db_file(self):
        return shelve.open(self._db)


def parse_args():
    parser = argparse.ArgumentParser(description='My library!')
    parser.add_argument('--action', type=str, dest='action', choices=['add', 'remove', 'show'], required=True,
                        help='indicates the action to be performed')
    parser.add_argument('-a', '--author', dest='author', action="store", default=None,
                        help='The author of the book')
    parser.add_argument('-t', '--title', dest='title', action="store", default=None,
                        help='The title of the book')
    parser.add_argument('-d', '--database', dest='database', action="store", default='library.db',
                        help='Path to the database file')

    args = parser.parse_args()
    return args

def main():
    args = parse_args()

    library = Library(args.database)
    if args.action == 'show':
        library.show(title=args.title, author=args.author)
    elif args.action == 'add':
        library.add(title=args.title, author=args.author)
    elif args.action == 'remove':
        library.remove(title=args.title)

if __name__ == '__main__':
    main()
    sys.exit(0)

Como puede observarse, al utilizar Shelve estoy creando una tabla Hash que me permite enlazar el título con el autor. Mientras busque por título, todo irá bien… cuando intente buscar por autor, sufriré una penalización terrible, ya que tendré que recorrer todos los valores :D

Como primera aproximación me basta. Así que puedo decir que está terminado.

Conclusiones

Puedo asegurar que hasta el escenario “Searching an existent a title” no decidí qué tipo de persistencia iba a utilizar. Y decidí utilizar Shelve porque es muy sencilla y me pareció que se ajustaba a lo que yo necesitaba. Igualmente podría haber utilizado un fichero de texto plano o haber volcado una hash a disco con pickle. No importa. No es nada importante.

Como veis, me he ajustado al enunciado en todo lo que he podido. Por eso tardé en escribirlo media hora, más o menos. Eso era importante, porque quería algo rápido pero usable. Además, me permite añadir más “columnas”, aunque tendría que portar los datos viejos o tirarlos, directamente.

Pasándolo a MongoDB

Vamos ahora a tratar de aprovechar todo lo aprovechable y a utilizar MongoDB en su lugar. Para ello utilizaremos la librería pymongo.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#!/usr/bin/env python
# file: ./library.py

import sys
import argparse
import pymongo


class Library(object):
    def __init__(self, db):
        self._db = db
        self._conn = None

    def show(self, title=None, author=None):
        if not title and not author:
            print 'You should ask for a title or an author'
            sys.exit(1)
        db = self._get_books_collection()
        result = db.find_one({'_id': title})
        if not result:
            print 'Book not found'
            self._disconnect()
            sys.exit(1)
        print result
        self._disconnect()


    def add(self, title, author):
        if not title or not author:
            print 'You should introduce the title and author'
            return

        db = self._get_books_collection()
        db.save({'_id':title, 'author':author})
        self._disconnect()
        print 'Book added'

    def remove(self, title):
        if not title:
            print 'You should introduce the title to remove'
            return

        db = self._get_books_collection()
        db.remove({'_id': title})
        self._disconnect()
        print 'Book removed'

    def _db_file(self):
        return shelve.open(self._db)

    def _get_books_collection(self):
        self._conn = pymongo.Connection('localhost', 27017)
        db = self._conn[self._db]
        return db['books']

    def _disconnect(self):
        if self._conn:
            self._conn.close()
            self._conn = None

# ...

El resto de ficheros será igual, de la misma forma que no he necesitado tocar el pársing de los parámetros de entrada. Y los tests pasan.

MongoDB

¿Cómo funciona MongoDB? Pues exactamente igual que la librería Shelve: como una tabla hash.

En este caso he utilizado el título del libro como índice, lo que es poco recomendable. Lo he hecho así para que sea lo más parecida posible a la primera implementación y, así, pueda explicarlo más fácilmente.

Hay algunas diferencias… Para comenzar, en la versión Shelve no se sabe lo que es el contenido. MongoDB sí sabe que es el autor. Eso nos permitirá poder añadir nuevas características sobre la marcha, sin tener que migrar nada. Añadir nuevas columnas es gratis, ya que funcionan como otra hash. Para que la versión Shelve funcione igual, sólo tenéis que guardar el autor como una hash de la forma “{‘author’: author}”.

Veamos un poco de la organización interna.

Conexiones, bases de datos y colecciones

Resulta fácil entender MongoDB como una hash de hashes:

  • Una conexión a MongoDB tiene los nombres de las bases de datos como claves.
  • La base de datos, tiene los nombres de las colecciones como claves.
  • Una colección es el equivalente en una base de datos relacional a una tabla (más o menos). Y tendrá como claves los nombres de las columnas, aunque tiene otra serie de métodos que nos permitirán realizar búsquedas.

El lenguaje: JavaScript

Una vez que hemos obtenido una instancia de la colección que queremos tratar, podemos realizar consultas. El lenguaje interno de mongodb es JavaScript.

A nivel básico no es necesario conocer este lenguaje para realizar consultas, ya que éstas serán solo hashes o listas. Con un par de ejemplos será suficiente. Sin embargo, a nivel avanzado la cosa cambia, ya que entraremos en el maravilloso mundo del mapreduce que… bueno, no termino de entender :D

Por lo poco que lo he tratado, el mapreduce consiste en dos partes:

  • La primera parte, el map, consiste en seleccionar, de cada fila, los campos que nos hacen falta.
  • La segunda parte, el reduce, permite agrupar los datos obtenidos en la primera fase.

Estas dos etapas se realizan de forma paralela, dando unos rendimientos muy elevados.

El mapreduce suele utilizarse para obtener estadísticas de la base de datos, tales como contar elementos que cumplen ciertos criterios.

Pero insisto en que estoy comenzando y, seguramente, diga alguna burrada XD

Conclusión

Probablemente existan muchos escenarios en los que una base de datos No-SQL no sea la solución, pero creo que existen muchos más escenarios donde son la solución, pero los desarrolladores se empeñan en encajarla en una base de datos relacional. A menudo, con resultados desastrosos.

Por extraño que parezca, No-SQL es muy intuitivo. No resulta muy diferente de lo que haríamos sin bases de datos convencionales. Sin embargo, utilizar un motor nos ofrece ciertas ventajas que no debemos despreciar.

Sinceramente, creo que hay todo un mundo a descubrir en este sentido.