Couchdb : Accès en HTTP avec Python
09/10/2011
La base de données orientée document Apache CouchDB fait désormais partie de mon quotidien en production depuis plusieurs mois et gère plusieurs Go de données sans problème. Outre l'orientation document qui marque une différence fondamentale avec les bases de données relationnelles classiques, l'intérêt majeur de couchdb réside dans son accès basé sur HTTP (RESTful). Cela permet un accès vraiment simple aux données dans n'importe quel langage.
Le but ici est de fournir des exemples d'accès à la base CouchDB, à l'aide du langage Python, en restant au plus près d'HTTP. D'autres bibliothèques sont évidemment disponibles pour avoir une abstraction d'HTTP. La plus connue est sans doute couchdb-python. Le wiki de CouchDB liste quelques projets et fournit également des exemples avec la bibliothèque httplib. Je n'utiliserai que les bibliothèques logging, json et urllib2. Les fonctions présentées ici ont été réalisées pour être relativement indépendantes les unes des autres. En effet, elles pourraient être optimisées. Une fonction unique pourrait se charger de faire la requête HTTP, quel que soit le verbe utilisé. Mais dans ce qui suit, l'aspect didactique a prévalu. Charge à chacun de cloner le programme (voir le dernier paragraphe) pour adapter/améliorer le code.
Le point de départ pour utiliser le protocole HTTP avec CouchDB est bien évidemment la documentation sur l'API HTTP de cette base. Outre les verbes HTTP utilisables, deux notions sont importantes dans cette API : l'identifiant (ID) du document et sa révision.
ID du document
Sans surprise, l'ID du document identifie de manière unique le document dans la base. Sa notation spéciale
_id
permet de le
reconnaître. Cet ID est soit imposé par l'utilisateur ou le programme, soit calculé automatiquement par
CouchDB. En fonction de ce choix, il sera nécessaire d'utiliser le verbe HTTP POST ou le verbe HTTP PUT pour créer/modifier
le document en question. Si l'ID est choisi, le verbe HTTP PUT devra être utilisé. Si par contre, l'ID est calculé
par la base, ce sera le verbe POST.
Révision d'un document
La révision d'un document fait référence à sa version. Ainsi, à la création du document, la révision de celui-ci
est la révision 1. À chaque mise à jour, la révision est incrémentée, de sorte que si le document a été mis à jour 3
fois (le même document identifié de façon unique par le même _id
), il y a aura 4 révisions : 1 pour la
création et 3 de plus pour chaque nouvelle version. À la différence des bases de données relationnelles, il est
donc possible d'obtenir pour un même document, chaque version de celui-ci en spécifiant simplement son
numéro (de révision). Lorsque l'on veut supprimer tout cet historique et ne garder
que la dernière révision du document, il faut compacter la base.
La notion de révision est fondamentale pour la bonne et simple raison qu'il est nécessaire de la fournir pour mettre à jour le document et pour le supprimer.
Exemples d'accès
Au fil des exemples, nous allons voir comment :
- obtenir des informations sur le document : HEAD ;
- créer le document en base : PUT ;
- mettre à jour le document : PUT ;
- le supprimer : DELETE ;
Les exemples se basent sur Python 2.6 avec une base CouchDB déployée en local. Pour ce faire, rien de plus simple, il suffit de télécharger sur le site de couchbase une version packagée qui a le bon goût de ne demander aucune configuration ! Il suffit de lancer le programme et votre base est démarrée.
CouchDB est donc :
- accessible à l'adresse http://localhost:5984 ;
- navigable à l'aide de l'application Futon à l'adresse http://localhost:5984/_utils ;
- agrémentée d'une base "test" pour notre exemple → http://localhost:5984/test.
Obtenir des informations sur le document
Pour obtenir des informations sur une ressource, il suffit de faire une requête HTTP HEAD en précisant son URL.
En utilisant l'outil curl, essayons d'avoir des informations sur le document current
de la base test
. L'option -I
de curl spécifie que la requête doit être un HTTP HEAD :
fred:~ opikanoba$ curl -I http://localhost:5984/test/current HTTP/1.1 404 Object Not Found Server: CouchDB/1.0.2 (Erlang OTP/R14B) Date: Tue, 27 Sep 2011 22:59:40 GMT Content-Type: text/plain;charset=utf-8 Content-Length: 41 Cache-Control: must-revalidate
Le document n'est pas crée, la base retourne logiquement un code HTTP 404 : Not Found.
En python :
def retrieveRevision(res_url): """ Test si la ressource existe dans la base (HEAD) et recuperation de sa revision si c'est le cas. La revision est mise dans l'entete HTTP Etag res_url : URL de la ressource """ assert res_url, "une ressource doit etre fournie" try: logger.info("""Recuperation de la revision de la ressource Couchdb : %s"""%res_url) request = urllib2.Request(res_url) request.get_method = lambda: 'HEAD' resp=urllib2.urlopen(request) # suppression des " au debut et a la fin de l'etag rev=resp.info()["etag"][1:-1] logger.info("Document existant : rev %s "%rev) return rev except urllib2.HTTPError, e: logger.error("Existance de la ressource %s ? ERR HTTP (%s)" %(res_url,e.code)) except urllib2.URLError, e: logger.error("Existance de la ressource %s ? ERR d'URL (%s)" %(res_url,e.code)) # aucune revision trouvee return None
La fonction ci-dessous retourne soit la révision de la resource si elle existe, soit None si ce n'est pas le cas :
>>> print retrieveRevision("http://localhost:5984/test/current") None
Pour faire une requête avec le verbe HEAD en python, il est nécessaire de modifier la méthode get_method
de l'objet Request
et de lui affecter une fonction qui, une fois appelée, retournera le verbe HEAD. Le plus simple
est donc de lui fournir une lambda expression réduite à sa plus simple expression, puisqu'elle n'a aucun argument et
se contente de retourner 'HEAD'.
request = urllib2.Request(res_url) # creation de l'objet Request request.get_method = lambda: 'HEAD' # affection du verbe HTTP HEAD resp=urllib2.urlopen(request) # execution de la requete
La revision du document est positionnée dans les données d'en-tête de la réponse, dans l'en-tête ETag.
Créer le document dans Couchdb
Créer un document ou le modifier se résume en une même action : une requête PUT (dans le cas où l'ID du document est connu) ou une requête POST. Deux façons d'opérer :
- comportement optimiste : tout va bien se passer, la ressource n'existe pas. Dans ce cas, nul besoin de positionner la révision du document,
CouchDB répondra si l'opération a fonctionné ou non. Avec curl, créons la ressource dont le nom (ID) est current dans la base test :
fred:~ opikanoba$ curl -X PUT http://localhost:5984/test/current \ > -H "Content-Type: application/json" \ > -d '{"music":["Chinese Man","Chapelier Fou"], "book":{"author":"Haruki Murakami","title":"Kafka sur le rivage"}}' {"ok":true,"id":"current","rev":"1-531a58a6bd9b979bdf3311f920b893d5"}
Tout s'est bien passé, CouchDB répond ok et donne la révision du document. Si l'on retente la même opération (ignorant donc que le document existe déjà) :fred:~ opikanoba$ curl -X PUT http://localhost:5984/test/current \ > -H "Content-Type: application/json" \ > -d '{"music":["Chinese Man","Chapelier Fou"], "book":{"author":"Haruki Murakami","title":"Kafka sur le rivage"}}' {"error":"conflict","reason":"Document update conflict."}
La réponse est tout autre, puisque CouchDB indique un conflit (un statut HTTP 409 : conflict). Le document existant, la requête est considérée comme une mise à jour. Pour voir le code HTTP renvoyé, il suffit de tracer les en-têtes de retour en ajoutant l'option--dump-header /tmp/trace_headers.txt
. Les informations se trouvent dans le fichiertrace_headers.txt
fred:~ opikanoba$ curl -X PUT http://localhost:5984/test/current \ > -H "Content-Type: application/json" \ > -d '{"music":["Chinese Man","Chapelier Fou"], "book":{"author":"Haruki Murakami","title":"Kafka sur le rivage"}}' \ > --dump-header /tmp/trace_headers.txt {"error":"conflict","reason":"Document update conflict."} fred:~ opikanoba$ cat /tmp/trace_headers.txt HTTP/1.1 409 Conflict Server: CouchDB/1.0.2 (Erlang OTP/R14B) Date: Wed, 28 Sep 2011 22:47:09 GMT Content-Type: text/plain;charset=utf-8 Content-Length: 58 Cache-Control: must-revalidate fred:~ opikanoba$
- comportement pessimiste : un risque, quant à l'existence de la ressource, existe : une requête HEAD permet de s'en assurer et de retrouver la révision courante. Cette révision doit alors être positionnée dans le prochain PUT/POST.
Dans l'exemple en python, nous adoptons le comportement pessismiste en vérifiant si une revision existe pour le document à créer.
def store(res_url,data): """ Stockage des donnees dans couchDB - test pour savoir si la ressoure existe deja -> recuperation de la revision - PUT de la ressource dans couchdb (la ressource a deja l'id) res_url : URL de la ressource data : donnees a stocker dans la base """ assert res_url, "une ressource doit etre fournie" assert isinstance(data, dict), """des donnees doivent etre de type dictionnaire""" try: # Test pour savoir si le document existe rev=retrieveRevision(res_url) if rev: logger.info("""Positionnement de la revision actuelle sur le document %s"""%rev) data["_rev"]=rev # Sauvegarde logger.info("Store Couchdb : %s"%res_url) opener = urllib2.build_opener(urllib2.HTTPHandler) # les donnees sont encodees en JSON request = urllib2.Request(res_url, data=jsonify(data)) # positionnement du Content-Type request.add_header('Content-Type', 'application/json') # positionnement du verbe HTTP PUT request.get_method = lambda: 'PUT' f = opener.open(request) result=f.read().decode('utf-8') logger.info("Reponse de Couchdb : %s"%result) except urllib2.HTTPError, e: logger.error("Erreur HTTP d'acces a la ressource %s (%d" %(res_url, e.code)) except urllib2.URLError, e: logger.error("Erreur d'URL %s (%d)"%(res_url, e.code))
Un simple appel à cette fonction en donnant l'URL de la ressource et les données sous la forme d'un dictionnaire Python, permet de stocker nos informations dans CouchDB :
fred:~ opikanoba$ python Python 2.6.1 (r261:67515, Jun 24 2010, 21:47:49) [GCC 4.2.1 (Apple Inc. build 5646)] on darwin Type "help", "copyright", "credits" or "license" for more information. >>> from couchette import * >>> url="http://localhost:5984/test/lastweek" >>> d={"music":["Monsieur Grandin","Berry Weight"], "book":{"author":"Dino Buzzati","title":"Le K"}} >>> store(url,d) >>> >>> retrieveRevision(url) '1-5b862d5df3fc84d7cd3971393d5b177e'
Mettons à jour nos données en rajoutant un élément à la liste music.
En recupérant la dernière révision, la mise à jour ne pose alors aucun problème. Elle est positionnée dans
les données (clé "_rev"
) avant que les données ne soient renvoyées à la base :
>>> d {'book': {'title': 'Le K', 'author': 'Dino Buzzati'}, 'music': ['Monsieur Grandin', 'Berry Weight']} >>> d['music'].append("Wax Tailor") >>> d {'book': {'title': 'Le K', 'author': 'Dino Buzzati'}, 'music': ['Monsieur Grandin', 'Berry Weight', 'Wax Tailor']} >>> store(url, d) >>> retrieveRevision(url) '2-48a47a90262d70870c78116eb379a071' >>>
Obtenir les données du document stocké dans CouchDB
L'obtention d'un document stocké dans CouchDB est l'opération la plus simple, puisqu'une simple requête GET avec l'adresse de cette ressource permet de l'obtenir. En utilisant curl
, l'opération est triviale :
fred:~ opikanoba$ curl http://localhost:5984/test/lastweek {"_id":"lastweek","_rev":"2-48a47a90262d70870c78116eb379a071","book":{"title":"Le K","author":"Dino Buzzati"},"music":["Monsieur Grandin","Berry Weight","Wax Tailor"]}
L'équivalent Python n'est pas plus complexe :
def retrieve(res_url): """ Recuperation des donnees d'une ressource res_url : URL de la ressource """ assert res_url, "une ressource doit etre fournie" try: opener = urllib2.build_opener(urllib2.HTTPHandler) logger.info("Load Couchdb : %s"%res_url) request = urllib2.Request(res_url) f = opener.open(request) data=f.read().decode('utf-8') # conversion JSON -> dictionnaire Python result=json.loads(data) except urllib2.HTTPError, e: logger.error("Erreur HTTP d'acces a la ressource %s : %d" %(res_url, e.code)) except urllib2.URLError, e: logger.error("Erreur d'URL %s : %d"%(res_url, e.code)) return result
L'opération HTTP GET étant positionnée par défaut, aucun paramétrage n'est nécessaire.
La seule action à entreprendre pour pouvoir avoir les données sous forme de dictionnaire Python est la conversion JSON → Python (utilisation de json.loads
) :
>>> result=retrieve(url) >>> import pprint >>> pp = pprint.PrettyPrinter(indent=4) >>> pp.pprint(result) { u'_id': u'lastweek', u'_rev': u'2-48a47a90262d70870c78116eb379a071', u'book': { u'author': u'Dino Buzzati', u'title': u'Le K'}, u'music': [u'Monsieur Grandin', u'Berry Weight', u'Wax Tailor']} >>>
Suppression du document
Comme pour la modification, la suppression d'un document exige que la révision soit fournie. Dans ce cas, celle-ci est passée en paramètre de la requête HTTP DELETE.
Dans l'exemple curl ci-dessous, la première requête GET permet de récupérer la révision courante du document (une requête HEAD aurait été plus efficace). Cette révision est ensuite passée à la deuxième requête dans le paramètre rev=
.
fred:~ opikanoba$ curl http://localhost:5984/test/current {"_id":"current","_rev":"1-531a58a6bd9b979bdf3311f920b893d5","music":["Chinese Man","Chapelier Fou"],"book":{"author":"Haruki Murakami","title":"Kafka sur le rivage"}} fred:~ opikanoba$ curl -X DELETE http://localhost:5984/test/current?rev=1-531a58a6bd9b979bdf3311f920b893d5 {"ok":true,"id":"current","rev":"2-47d8892807cec72b46ae8e30fd1a0c2c"}
En faisant de nouveau une requête sur la ressource précédemment supprimée (sans révision pour avoir la version actuelle du document), CouchDB renvoit une erreur not_found
mais également une raison deleted
.
Il s'agit du code HTTP 404, que l'on peut constater en traçant les entêtes HTTP avec l'option --dump-header
.Si l'on donne la révision précédente du document, c'est-à-dire la révision du document avant sa suppression, le contenu du document supprimé est renvoyé. Cette manipulation n'est pas sans risque, voir la note à ce sujet.
fred:~ opikanoba$ curl http://localhost:5984/test/current {"error":"not_found","reason":"deleted"} fred:~ opikanoba$ curl http://localhost:5984/test/current?rev=2-47d8892807cec72b46ae8e30fd1a0c2c {"_id":"current","_rev":"2-47d8892807cec72b46ae8e30fd1a0c2c","_deleted":true} fred:~ opikanoba$ curl http://localhost:5984/test/current?rev=1-531a58a6bd9b979bdf3311f920b893d5 {"_id":"current","_rev":"1-531a58a6bd9b979bdf3311f920b893d5","book":{"title":"Kafka sur le rivage","author":"Haruki Murakami"},"music":["Chinese Man","Chapelier Fou"]}
La fonction Python permettant de faire le delete est très ressemblante aux précédentes :
def delete(res_url, revision): """ Suppression d'un document par sa revision res_url : URL de la ressource revision : revision du document a supprimer """ assert res_url, "une ressource doit etre fournie" assert revision, "une revision doit etre fournie" try: logger.info("Suppression de ressource %s REV [%s]" %(res_url,revision)) url_rev=res_url+'?rev='+revision request = urllib2.Request(url_rev) request.add_header('Content-Type', 'application/json') request.get_method = lambda: 'DELETE' resp=urllib2.urlopen(request) rev=resp.info()["etag"][1:-1] logger.info("REV supprimee [%s]"%rev) except urllib2.HTTPError, e: logger.error("ERR HTTP pour le DELETE de la ressource %s (%s)" %(res_url,e.code)) except urllib2.URLError, e: logger.error("Erreur d'URL %s (%d)"%(res_url, e.code))
En poursuivant, l'exécution dans la console Python :
>>> delete(url, result["_rev"]) >>> f=retrieveRevision(url) >>> assert f is None
En Python, le procédé est le même qu'avec les autres verbes. La lambda expression permet de spécifier le verbe HTTP DELETE à utiliser dans ce cas. La révision de la ressource supprimée se retrouve dans l'entête ETag.
Note sur la suppression
Lorsqu'un document est supprimé, la révision est incrémentée de sorte que la prochaine requête GET, renvoie la révision du document supprimé : l'option"_deleted":true
. Le document reste accessible via sa/ses révision(s) précédente(s) pendant un temps dépendant des actions sur la base. En effet, si la base est compactée ou repliquée, les révisions intermédiaires ne seront plus accessibles
(le compactage ne garde que la dernière version, et seules les dernières révisions des documents sont répliqués).
Par conséquent, il n'est pas recommandé de travailler sur des révisions historiques à moins de maîtriser parfaitement le processus !
Obtenir le code
Pour obtenir le code source du programme python utilisé dans ce billet :
git clone git://github.com/flrt/couchette