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.
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.featureFeature: 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.
# file ./tests/steps.pyimportosimportsubprocessfromfreshenimport*fromfreshen.checksimport*DATABASE_FILE='test_db'defbefore(sc):ifos.path.exists(DATABASE_FILE):os.remove(DATABASE_FILE)scc.output=Nonescc.status=Nonedefrun(args):cmd=['./library.py','-d',DATABASE_FILE]+argsprocess=subprocess.Popen(cmd,stdout=subprocess.PIPE,stderr=subprocess.STDOUT)output,_=process.communicate()status=process.returncodereturn(output,status)defexecute(args):scc.output,scc.status=process=run(args)@When('^I search a book without parameters$')defexec_search_without_parameters():execute(['--action=show'])@Then('^output contains "(.*)"$')defcheck_output_contains(expected):assert_true(expectedinscc.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.
#!/usr/bin/env python#File: ./library.pyimportsysimportargparseclassLibrary(object):def__init__(self,db):self._db=dbdefshow(self,title=None,author=None):ifnottitleandnotauthor:print'You should ask for a title or an author'sys.exit(1)defparse_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()returnargsdefmain():args=parse_args()library=Library(args.database)ifargs.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.
# file: ./tests/library.featureFeature: 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:
#File: ./tests/steps.pyimportosimportsubprocessfromfreshenimport*fromfreshen.checksimport*frommultiprocessingimportPoolDATABASE_FILE='test.db'defbefore(sc):ifos.path.exists(DATABASE_FILE):os.remove(DATABASE_FILE)scc.output=Nonescc.status=Nonedefrun(args):cmd=['./library.py','-d',DATABASE_FILE]+argsprocess=subprocess.Popen(cmd,stdout=subprocess.PIPE,stderr=subprocess.STDOUT)output,_=process.communicate()status=process.returncodereturn(output,status)defexecute(args):scc.output,scc.status=process=run(args)@Given('^the book "(.*)", written by "(.*)"$')definitialize_library(title,author):execute(['--action=add','-t',title,'-a',author])@When('^I search the book "(.*)"$')defexec_search_by_title(title):execute(['--action=show','--title',title])@When('^I search a book without parameters$')defexec_search_without_parameters():execute(['--action=show'])@When('I add the book "(.*)" written by "(.*)"')defexec_add_book(title,author):execute(['--action=add','-t',title,'-a',author])@When('I remove the book "(.*)"')defexec_remove_book(title):execute(['--action=remove','-t',title])@Then('^output contains "(.*)"$')defcheck_output_contains(expected):assert_true(expectedinscc.output,'Text "{}", was not found in "{}"'.format(expected,scc.output))@Then('^my library( do not|) contains the title "(.*)"$')defcheck_contains_title(condition,title):_,rc=run(['--action=show','-t',title])ifnotcondition: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.
#!/usr/bin/env python#File: ./library.pyimportsysimportargparseimportshelveclassLibrary(object):def__init__(self,db):self._db=dbdefshow(self,title=None,author=None):ifnottitleandnotauthor:print'You should ask for a title or an author'sys.exit(1)db=self._db_file()ifnotdb.has_key(title):print'Book not found'db.close()sys.exit(1)print'{} -> {}'.format(title,db[title])db.close()defadd(self,title,author):ifnottitleornotauthor:print'You should introduce the title and author'returndb=self._db_file()db[title]=authordb.close()print'Book added'defremove(self,title):ifnottitle:print'You should introduce the title to remove'returndb=self._db_file()ifdb.has_key(title):deldb[title]db.close()print'Book removed'def_db_file(self):returnshelve.open(self._db)defparse_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()returnargsdefmain():args=parse_args()library=Library(args.database)ifargs.action=='show':library.show(title=args.title,author=args.author)elifargs.action=='add':library.add(title=args.title,author=args.author)elifargs.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.
#!/usr/bin/env python# file: ./library.pyimportsysimportargparseimportpymongoclassLibrary(object):def__init__(self,db):self._db=dbself._conn=Nonedefshow(self,title=None,author=None):ifnottitleandnotauthor:print'You should ask for a title or an author'sys.exit(1)db=self._get_books_collection()result=db.find_one({'_id':title})ifnotresult:print'Book not found'self._disconnect()sys.exit(1)printresultself._disconnect()defadd(self,title,author):ifnottitleornotauthor:print'You should introduce the title and author'returndb=self._get_books_collection()db.save({'_id':title,'author':author})self._disconnect()print'Book added'defremove(self,title):ifnottitle:print'You should introduce the title to remove'returndb=self._get_books_collection()db.remove({'_id':title})self._disconnect()print'Book removed'def_db_file(self):returnshelve.open(self._db)def_get_books_collection(self):self._conn=pymongo.Connection('localhost',27017)db=self._conn[self._db]returndb['books']def_disconnect(self):ifself._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.