Interrogation de bases NoSQL

Dans ce chapitre nous commençons à étudier la gestion de grands ensembles de documents organisés en bases de données. Nous commençons par le Web: ce n’est pas vraiment une base de données (même si beaucoup rêvent d’aller en ce sens) mais c’est un système distribué de documents, et un cas-type de Big Data s’il en est. De plus, il s’agit d’une source d’information essentielle pour collecter des données, les agréger et les analyser.

Le Web s’appuie sur des protocoles bien connus (HTTP) qui ont été repris pour la définition de services (Web) dits REST. Un premier système NoSQL (CouchDB) est introduit pour illustrer l’organisation et la manipulation de documents basées sur REST.

Nous continuons ensuite notre exploration de Cassandra et de MongoDB.

S1: HTTP, REST, et CouchDB

Le Web est la plus grande base de documents ayant jamais existé! Même s’il est essentiellement constitué de documents très peu structurés et donc difficilement exploitables par une application informatique, les méthodes utilisées sont très instructives et se retrouvent dans des systèmes plus organisés. Dans cette section, l’accent est mis sur le protocole REST que nous retrouverons très fréquemment en étudiant les systèmes NoSQL. L’un de ces systèmes (CouchDB) qui s’appuie entièrement sur REST, est d’ailleurs brièvement introduit en fin de section.

Web = ressources + URL + HTTP

Rappelons les principales caractéristiques du Web, vu comme un gigantesque système d’information orienté documents. Distinguons d’abord l’Internet, réseau connectant des machines, et le Web qui constitue une collection distribuée de ressources hébergés sur ces machines.

Le Web est (techniquement) essentiellement caractérisé par trois choses: la notion de ressource, l’adressage par des URL et le protocole HTTP.

Ressources

La notion de ressource est assez générale/générique. Elle désigne toute entité disposant d’une adresse sur le réseau, et fournissant des services. Un document est une forme de ressource: le service est dans ce cas le contenu du document lui-même. D’autres ressources fournissent des services au sens plus calculatoire du terme en effectuant sur demande des opérations.

Il faut essentiellement voir une ressource comme un point adressable sur l’Internet avec lequel on peut échanger des messages. L’adressage se fait par une URL, l’échange par HTTP.

URLs

L’adresse d’une ressource est une URL, pour Universal Resource Location. C’est une chaîne de caractères qui encode toute l’information nécessaire pour trouver la ressource et lui envoyer des messages.

Note

Certaines ressources n’ont pas d’adresse sur le réseau, mais sont quand même identifiables par des URI (Universal Resource identifier).

Cet encodage prend la forme d’une chaîne de caractères formée selon des règles précises illustrées par l’URL fictive suivante:

https://www.example.com:443/chemin/vers/doc?nom=b3d&type=json#fragment

Ici, https est le protocole qui indique la méthode d’accès à la ressource. Le seul protocole que nous verrons est HTTP (le s indique une variante de HTTP comprenant un encryptage des échanges). L’hôte (hostname) est www.example.com. Un des services du Web (le DNS) va convertir ce nom d’hôte en adresse IP, ce qui permettra d’identifier la machine serveur qui héberge la ressource.

Note

Quand on développe une application, on la teste souvent localement en utilisant sa propre machine de développement comme serveur. Le nom de l’hôte est alors localhost, qui correspond à l’IP 127.0.0.1.

La machine serveur communique avec le réseau sur un ensemble de ports, chacun correspondant à l’un des services gérés par le serveur. Pour le service HTTP, le port est par défaut 80, mais on peut le préciser, comme sur l’exemple précédent, où il vaut 443. On trouve ensuite le chemin d’accès à la ressource, qui suit la syntaxe d’un chemin d’accès à un fichier dans un système de fichiers. Dans les sites simples, « statiques », ce chemin correspond de fait à un emplacement physique vers le fichier contenant la ressource. Dans des applications plus sophistiquées, les chemins sont virtuels et conçus pour refléter l’organisation logique des ressources offertes par l’application.

Après le point d’interrogation, on trouve la liste des paramètres (query string) éventuellement transmis à la ressource. Enfin, le fragment désigne une sous-partie du contenu de la ressource. Ces éléments sont optionnels.

Le protocole HTTP

HTTP, pour HyperText Transfer Protocol, est un protocole extrêmement simple, basé sur TCP/IP, initialement conçu pour échanger des documents hypertextes. HTTP définit le format des requêtes et des réponses. Voici par exemple une requête envoyée à un serveur Web:

GET /myResource HTTP/1.1
Host: www.example.com

Elle demande une ressource nommée myResource au serveur www.example.com. Voici une possible réponse à cette requête:

HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8

<html>
  <head><title>myResource</title></head>
  <body><p>Bonjour à tous!</p></body>
</html>

Un message HTTP est constitué de deux parties: l’entête et le corps, séparées par une ligne blanche. La réponse montre que l’entête contient des informations qualifiant le message. La première ligne par exemple indique qu’il s’agit d’un message codé selon la norme 1.1 de HTTP, et que le serveur a pu correctement répondre à la requête (code de retour 200). La seconde ligne de l’entête indique que le corps du message est un document HTML encodé en UTF-8.

Le programme client qui reçoit cette réponse traite le corps du message en fonction des informations contenues dans l’entête. Si le code HTTP est 200 par exemple, il procède à l’affichage. Un code 404 indique une ressource manquante, une code 500 indique une erreur sévère au niveau du serveur. Voir http://en.wikipedia.org/wiki/List_of_HTTP_status_codes pour une liste complète.

Le Web est initialement conçu comme un système d’échange de documents hypertextes se référençant les uns les autres, codés avec le langage HTML (ou XHTML dans le meilleur des cas). Ces documents s’affichent dans des navigateurs et sont donc conçus pour des utilisateurs humains. En revanche, ils sont très difficiles à traiter par des applications en raison de leur manque de structure et de l’abondance d’instructions relatives à l’affichage et pas à la description du contenu. Examinez une page HTML provenant de n’importe quel site un peu soigné et vous verrez que la part du contenu est négligeable par rapport à tous les CSS, javascript, images et instructions de mise en forme.

Une évolution importante du Web a donc consisté à étendre la notion de ressource à des services recevant et émettant des documents structurés transmis dans le corps du message HTTP. Vous connaissez déjà les formats utilisés pour représenter cette structure: JSON et XML, ce dernier étant clairement de moins en moins apprécié.

C’est cet aspect sur lequel nous allons nous concentrer: le Web des services est véritablement une forme de très grande base de documents structurés, présentant quelques fonctionnalités (rudimentaires) de gestion de données comparable aux opérations d’un SGBD classique. Les services basés sur l’architecture REST, présentée ci-dessous, sont la forme la plus courante rencontrée dans ce contexte.

L’architecture REST

REST est une forme de service Web dont le parti pris est de s’appuyer sur HTTP, ses opérations, la notion de ressource et l’adressage par URL. REST est donc très proche du Web, la principale distinction étant que REST est orienté vers l’appel à des services à base d’échanges par documents structurés, et se prête donc à des échanges de données entre applications. La Fig. 17 donne une vision des éléments essentiels d’une architecture REST.

_images/REST.png

Fig. 17 Architecture REST: client, serveur, ressources, URL (domaine + chemin)

Avec HTTP, il est possible d’envoyer quatre types de messages, ou méthodes, à une ressource web:

  • GET est une lecture de la ressource (ou plus précisément de sa représentation publique);

  • PUT est la création d’une ressource;

  • POST est l’envoi d’un message à une ressource existante;

  • DELETE la destruction d’une ressource.

REST s’appuie sur un usage strict (le plus possible) de ces quatre méthodes. Ceux qui ont déjà pratiqué la programmation Web admettront par exemple qu’un développeur ne se pose pas toujours nettement la question, en créant un formulaire, de la méthode GET ou POST à employer. De plus le PUT (qui n’est pas connu des formulaires Web) est ignoré et le DELETE jamais utilisé.

La définition d’un service REST se doit d’être plus rigoureuse.

  • le GET, en tant que lecture, ne doit jamais modifier l’état de la ressource (pas « d’effet de bord »); autrement dit, en l’absence d’autres opérations, des messages GET envoyés répétitivement à une même ressource ramèneront toujours le même document, et n’auront aucun effet sur l’environnement de la ressource ;

  • le PUT est une création, et l’URL a laquelle un message PUT est transmis ne doit pas exister au préalable; dans une interprétation un peu plus souple, le PUT crée ou remplace la ressource éventuellement existante par la nouvelle ressource transmise par le message;

  • inversement, POST doit s’adresser à une ressource existante associée à l’URL désignée par le message; cette méthode correspond à l’envoi d’un message à la ressource (vue comme un service) pour exécuter une action, avec potentiellement un changement d’état (par exemple la création d’une nouvelle ressource).

Les messages sont transmis en HTTP (voir ci-dessus) ce qui offre, entre autres avantages, de ne pas avoir à redéfinir un nouveau protocole. Le contenu du message est une information codée en XML ou en JSON (le plus souvent), soit ce que nous avons appelé jusqu’à présent un document.

  • quand le client émet une requête REST, le document contient les paramètres d’accès au service (par exemple les valeurs de la ressource à créer);

  • quand la ressource répond au client, le document contient l’information constituant le résultat du service; en cas d’erreur ou d’anomalie (droit d’accès insuffisant par exemple) un code d’erreur HTTP peut être utilisé.

Important

En toute rigueur, il faut bien distinguer la ressource et le document qui représente une information produite par la ressource.

On peut faire appel à un service REST avec n’importe quel client HTTP, et notamment avec votre navigateur préféré: copiez l’URL dans la fenêtre de navigation et consultez le résultat. Le navigateur a cependant l’inconvénient, avec cette méthode, de ne transmettre que des messages GET. Un outil plus général, s’utilisant en ligne de commande, est cURL. S’il n’est pas déjà installé dans votre environnement, il est fortement conseillé de le faire dès maintenant: le site de référence est http://curl.haxx.se/.

Voici quelques exemples d’utilisation de cURL pour parler le HTTP avec un service REST. Notre base de films est obtenue par appel à l’API REST de themoviedb.org. Voici la requête qui cherche le film tt10404944.

https://api.themoviedb.org/3/find/tt10404944?api_key=8f479aa1cb024ffe0d95da8c4ee56fe7&external_source=imdb_id

Ce qui retourne le document suivant.

{
"adult": false,
"backdrop_path": "/vkIJ2QgcKMJRvi6pBW4Tr2kgLdy.jpg",
"id": 637534,
"title": "The Stronghold",
"original_language": "fr",
"original_title": "BAC Nord",
"overview": "A police brigade works in the dangerous northern neighborhoods of Marseille, where the level of crime is higher than anywhere else in France.",
"poster_path": "/nLanxl7Xhfbd5s8FxPy8jWZw4rv.jpg",
"media_type": "movie",
"genre_ids": [53, 28, 80],
"popularity": 21.929,
"release_date": "2021-08-18",
"video": false,
"vote_average": 7.426,
"vote_count": 1147
}

Notez le placement de paramètres dans l’URL, et notamment une clé d’accès, souvent requise pour utiliser des services. Autre exemple, l’API REST de Open Weather Map, un service fournissant des informations météorologiques (créez une clé API et ajoutez-la à l’URL pour obtenir une réponse complète).

curl -X GET api.openweathermap.org/data/2.5/weather?q=Paris

Et on obtient la réponse suivante (qui varie en fonction de la météo, évidemment).

{
   "coord":{
     "lon":2.35,
     "lat":48.85
   },
   "weather":[
     {
      "id":800,
      "main":"Clear",
      "description":"Sky is Clear",
      "icon":"01d"
     }
   ],
   "base":"cmc stations",
   "main":{
    "temp":271.139,
    "temp_min":271.139,
    "temp_max":271.139,
    "pressure":1021.17,
    "sea_level":1034.14,
    "grnd_level":1021.17,
    "humidity":87
   },
   "name":"Paris"
}

Même chose, mais en demandant une réponse codée en XML. Notez l’option -v qui permet d’afficher le détail des échanges de messages HTTP gérés par cURL.

curl -X GET -v api.openweathermap.org/data/2.5/weather?q=Paris&mode=xml

Nous verrons ultérieurement des exemples de PUT et de POST pour créer des ressources et leur envoyer des messages avec cURL.

Note

La méthode GET est utilisée par défaut par cURL, on peut donc l’omettre.

Nous nous en tenons là pour les principes essentiels de REST, qu’il faudrait compléter de nombreux détails mais qui nous suffiront à comprendre les interfaces (ou API) REST que nous allons rencontrer.

Important

Les méthodes d’accès aux documents sont représentatives des opérations de type dictionnaire: toutes les données ont une adresse, on peut accéder à la donnée par son adresse (get), insérer une donnée à une adresse (put), détruire la donnée à une adresse (delete). De nombreux systèmes NoSQL se contentent de ces opérations qui peuvent s’implanter très simplement et efficacement.

Pour être concret et rentrer au plus vite dans le cœur du sujet, nous présentons l’API de CouchDB qui est conçu comme un serveur de documents (JSON) basé sur REST.

L’API REST de CouchDB

Faisons connaissance avec CouchDB, un système NoSQL qui gère des collections de documents JSON. Les quelques manipulations ci-dessous sont centrées sur l’utilisation de CouchDB via son interface REST, mais rien ne vous empêche d’explorer le système en profondeur pour en comprendre le fonctionnement.

CouchDB est essentiellement un serveur Web étendu à la gestion de documents JSON. Comme tout serveur Web, il parle le HTTP et manipule des ressources (Fig. 18).

_images/CouchDB.png

Fig. 18 Architecture (simplifiée) de CouchDB

Vous pouvez installer CouchDB sur votre machine avec Docker, en exposant le port 5984 sur la machine hôte. Voici la commande d’installation.

docker run  -d --name my-couchdb -e COUCHDB_USER=admin \
    -e COUCHDB_PASSWORD=admin -p 5984:5984 couchdb:latest

Dans ce qui suit, on suppose que le serveur est accessible à l’adresse http://localhost:5984. Une première requête REST permet de vérifier la disponibilité de ce serveur.

curl -X GET http://admin:admin@localhost:5984

CouchDB devrit vous répondre par un message JSON:

{
"couchdb": "Welcome",
"version": "3.3.3",
"git_sha": "40afbcfc7",
"uuid": "0f4f4743bf65f3c4cd61caf3e789c559",
"vendor": {"name": "The Apache Software Foundation"}
}

Note

Vous noterez qu’il faut indiquer dans l’URL le compte d’accès (admin/admin) juste avant le nom du serveur.

Bien entendu, dans ce qui suit, utilisez l’adresse de votre propre serveur. Même si nous utilisons REST pour communiquer avec CouchDB par la suite, rien ne vous empêche de consulter en parallèle l’interface graphique qui est disponible à l’URL relative _utils (donc à l’adresse complète http://localhost:5984/_utils dans notre cas). La Fig. 19 montre l’aspect de cette interface graphique, très pratique.

_images/fauxton.png

Fig. 19 L’interface graphique (Fauxton) de CouchDB

CouchDB adopte délibérément les principes et protocoles du Web. Une base de données et ses documents sont vus comme des ressources et on dialogue avec eux en HTTP, conformément au protocole REST.

Un serveur CouchDB gère un ensemble de bases de données. Créer une nouvelle base se traduit, avec l’interface REST, par la création d’une nouvelle ressource. Voici donc la commande avec cURL pour créer une base films (notez la méthode PUT pour créer une ressource).

curl -X PUT http://admin:admin@localhost:5984/films
{"ok":true}

Maintenant que la ressource est créée, et on peut obtenir sa représentation avec une requête GET.

curl -X GET http://admin:admin@localhost:5984/films

Cette requête renvoie un document JSON décrivant la nouvelle base.

{"update_seq":"0-g1AAAADfeJz6t",
 "db_name":"films",
 "sizes": {"file":17028,"external":0,"active":0},
 "purge_seq":0,
 "other":{"data_size":0},
 "doc_del_count":0,
 "doc_count":0,
 "disk_size":17028,
 "disk_format_version":6,
 "compact_running":false,
 "instance_start_time":"0"
}

Vous commencez sans doute à saisir la logique des interactions. Les entités gérées par le serveur (des bases de données, des documents, voire des fonctions) sont transposées sous forme de ressources Web auxquelles sont associées des URLs correspondant, autant que possible, à l’organisation logique de ces entités.

Pour insérer un nouveau document dans la base films, on envoie donc un message PUT à l’URL qui va représenter le document. Cette URL est de la forme http://localhost:5984/films/idDoc, où idDoc désigne l’identifiant de la nouvelle ressource.

curl -X PUT http://admin:admin@localhost:5984/films/doc1 -d '{"key": "value"}'

  {"ok":true,"id":"doc1","rev":"1-25eca"}

Que faire si on veut insérer des documents placés dans des fichiers? Vous avez dû récupérer dans nos jeux de données des documents représentant des films en JSON. Le document film629.json par exemple représente le film Usual Suspects. Voici comment on l’insère dans la base en lui attribuant l’identifiant us.

curl -X PUT http://admin:admin@localhost:5984/films/us -d @film629.json -H "Content-Type: application/json"

Cette commande cURL est un peu plus compliquée car il faut créer un message HTTP plus complexe que pour une simple lecture. On passe dans le corps du message HTTP le contenu du fichier film629.json avec l’option -d et le préfixe @, et on indique que le format du message est JSON avec l’option -H. Voici la réponse de CouchDB.

{
 "ok":true,
 "id":"us",
 "rev":"1-68d58b7e3904f702a75e0538d1c3015d"
}

Le nouveau document a un identifiant (celui que nous attribué par l’URL de la ressource) et un numéro de révision. L’identifiant doit être unique (pour une même base), et le numéro de révision indique le nombre de modifications apportées à la ressource depuis sa création.

Si vous essayez d’exécuter une seconde fois la création de la ressource, CouchDB proteste et renvoie un message d’erreur:

{
  "error":"conflict",
 "reason":"Document update conflict."
}

Un document existe déjà à cette URL.

Une autre façon d’insérer, intéressante pour illustrer les principes d’une API REST, et d’envoyer non pas un PUT pour créer une nouvelle ressource (ce qui impose de choisir l’identifiant) mais un POST à une ressource existante, ici la base de données, qui se charge alors de créer la nouvelle ressource représentant le film, et de lui attribuer un identifiant.

Voici cette seconde option à l’œuvre pour créer un nouveau document en déléguant la charge de la création à la ressource films.

curl -X POST http://admin:admin@localhost:5984/films -d @film629.json -H "Content-Type: application/json"

Voici la réponse de CouchDB:

{
 "ok":true,
 "id":"movie:629",
 "rev":"1-68d58b7e3904f702a75e0538d1c3015d"
}

CouchDB a trouvé l’identifiant (conventionnellement nommé id) dans le document JSON et l’utilise. Si aucun identifiant n’est trouvé, une valeur arbitraire (une longue et obscure chaîne de caractère) est engendrée.

CouchDB est un système multi-versions: une nouvelle version du document est créée à chaque insertion. Chaque document est donc identifié par une paire (id, revision): notez l’attribut rev dans le document ci-dessus. Dans certains cas, la version la plus récente est implicitement concernée par une requête. C’est le cas quand on veut obtenir la ressource avec un simple GET.

curl -X GET http://admin:admin@localhost:5984/films/us

En revanche, pour supprimer un document, il faut indiquer explicitement quelle est la version à détruire en précisant le numéro de révision.

curl -X DELETE http://localhost:5984/films/us?rev=1-68d58b7e3904f702a75e0538d1c3015d

Nous en restons là pour l’instant. Cette courte session illustre assez bien l’utilisation d’une API REST pour gérer une collection de document à l’aide de quelques opérations basiques: création, recherche, mise à jour, et destruction, implantées par l’une des 4 méthodes HTTP. La notion de ressource, existante (et on lui envoie des messages avec GET ou POST) ou à créer (avec un message PUT), associée à une URL correspondant à la logique de l’organisation des données, est aussi à retenir.

Quiz

Parmi les définitions suivantes, laquelle vous semble le mieux correspondre à la notion de ressource Web.

  1. C’est un document qui peut être téléchargé à une URL donnée

  2. C’est un point de communication référencé par une URL

  3. C’est une entité qui fournit des services à une URL donnée

Quelle est la bonne affirmation

  1. HTTP est un protocole qui sert à échanger des documents HTML

  2. HTTP est un protocole qui sert à échanger tout type de documents

  3. HTTP ne peut être utilisé que dans le contexte du Web.

Parmi les affirmations suivantes sur REST, l’une est vraie: laquelle

  1. Si on envoie un POST à une ressource inexistante, elle est créée

  2. POST est idempotent: deux appels successifs donnent le même résultat

  3. GET est idempotent: deux appels successifs à la même ressource donnent le même résultat

  4. On peut faire plusieurs appels PUT à une même ressource

  5. Un appel GET à une ressource renvoie toujours le même document

Quelle est la bonne affirmation

  1. CouchDB est un serveur Web qui gère des données statiques codés en JSON

  2. CouchDB est un système NoSQL basé sur les principes REST et le codage JSON

  3. On ne peut communiquer avec CouchDB que via un navigateur ou cUrl

Mise en pratique

Les propositions suivantes vous permettent de mettre en pratique les connaissances précédentes.

MEP MEP-S1-1: reproduisez les commandes REST avec votre serveur CouchDB

Il s’agit simplement de reproduire les commandes ci-dessus, en examinant éventuellement les requêtes HTTP engendrées par cURL pour bien comprendre les échanges effectués.

Vous pouvez tenter d’importer l’ensemble des documents en une seule commande avec l’API décrite ici: http://wiki.apache.org/couchdb/HTTP_Bulk_Document_API. Récupérez sur le site http://deptfod.cnam.fr/bd/tp/datasets/ le fichier films_couchdb.json au format spécifique d’insertion CouchDB. Il a la forme suivante:

 {"docs":
  [
   {
    "_id": "movie:1",
    "title": "Vertigo",
    ...
   },
   {
    "_id": "movie:2",
    "title": "Alien",
    ...
   }
  ]
}

La commande d’insertion est alors la suivante:

curl -X POST  http://admin:admin@localhost:5984/films/_bulk_docs \
     -d @films_couchdb.json -H "Content-Type: application/json"

MEP MEP-S1-2: Documents et services Web

Soyons concret: vous construisez une application qui, pour une raison X ou Y, a besoin de données météo sur une région ou une ville donnée. Comment faire? la réponse est simple: trouver le service Web qui fournit ces données, et appeler ces services. Pour la première question, on peut par exemple prendrele site OpenweatherMap, dont les services sont décrits ici: http://openweathermap.org/api. Pour appeler ce service, comme vous pouvez le deviner, on passe par le protocole HTTP.

Application: utilisez les services de OpenWeatherMap pour récupérer les données météo pour Paris, Marseille, Lyon, ou toute ville de votre choix. Testez les formats JSON et XML.

MEP MEP-S1-3: comprendre les services géographiques de Google.

Google fournit (librement, jusqu’à une certaine limite) des services dits de géolocalisation: récupérer une carte, calculer un itinéraire, etc. Vous devriez être en mesure de comprendre les explications données ici: https://developers.google.com/maps/documentation/webservices/?hl=FR (regardez en particulier les instructions pour traiter les documents JSON et XML retournés).

Pour vérifier que vous avez bien compris: créez un formulaire HTML avec deux champs dans lesquels on peut saisir les points de départ et d’arrivée d’un itinéraire (par exemple, Paris - Lyon). Quand on valide ce formulaire, afficher le JSON ou XML (mieux, donner le choix du format à l’utilisateur) retourné par le service Google (aide: il s’agit du service directions).

MEP MEP-S1-4: explorer les services Web et l’Open data.

Le Web est une source immense de données structurées représentées en JSON ou en XML. Vous voulez connaître le programme d’une station de radio, accéder à une entre Wikipedia sous forme structurée, gérer des calendriers? On trouve à peu près tout sous forme de services.

Explorez ces API pour commencer à vous faire une idée du type de projet qui vous intéresse. Récupérez des données et regardez leur format.

Autre source de données: les données publiques. Allez voir par exemple sur

Essayer d’imaginer une application qui combine plusieurs sources de données.

S2: requêtes Cassandra

Cassandra propose un langage, nommé CQL, inspiré de SQL, mais fortement restreint par l’absence de jointure. De plus, d’autres types de restrictions s’appliquent, motivées par l’hypothèse qu’une base Cassandra est nécessairement une base à très grande échelle, et que les seules requêtes raisonnables sont celles pour lequelles la structuration des données permet des temps de réponse acceptables.

Note

Cette session est une démonstration pratique ces capacités d’interrogation de Cassandra. Si vous souhaitewz reproduire les manipulations, il vous faut un environnement constitué d’un serveur Cassandra, d’un client et de la base de données des films. Les instructions pour installer tout cela ont été données dans le chapitre Modélisation de bases NoSQL. En résumé, vous devriez avoir:

  • une table artists avec la liste des artistes;

  • une table movies où chaque film contient des données imbriquées représentant le réalisateur du film et les acteurs.

CQL, un sous-ensemble de SQL

CQL ne permet d’interroger qu’une seule table. Cette (très forte) restriction mise à part (!), le langage est délibérement conçu comme un sous-ensemble de SQL et de sa construction select from where.

Note

Toute requête CQL doit se terminer par un “;”

Commençons par quelques exemples.

Sélectionnons tous les artistes.

select  * from artists;

Selon l’utilitaire que vous utilisez, vous devriez obtenir l’affichage des premiers artistes sous une forme ou sous une autre. Cassandra étant supposé gérer de très grandes bases de données, ces utilitaires vont souvent ajouter automatiquement une clause limitant le nombre de lignes retournées. Vous pouvez ajouter cette clause explicitement.

select  * from artists limit 20;

On peut obtenir le résultat encodé en JSON en ajoutant simplement le mot-clé JSON.

select JSON * from artists;

Bien entendu, le * peut être remplacé par la liste des attributs à conserver (projeter).

select title from movies;

Si une valeur v est un dictionnaire (objet en JSON), on peut accéder à l’un de ses composants c avec la notation v.c. Exemple pour le réalisateur du film.

select title, director.last_name from movies;

En revanche, quand la valeur est un ensemble ou une liste, on ne sait pas avec CQL accéder à son contenu. La tentative d’exécuter la requête:

select title, actors.last_name from movies;

devrait retourner une erreur. Il est vrai que l’on ne sait pas très bien à quoi devrait ressembler le résultat. D’autres langages (notamment XQuery, mais également le langage de script Pig que nous étudierons en fin de cours) proposent des solutions au problème d’interrogation de collections imbriquées. Il se peut que CQL évolue un jour pour proposer quelque chose de semblable.

On peut, dans la clause select, appliquer des fonctions. Cassandra permet la définition de fonctions utilisateur, et leur application aux données grâce à CQL. Quelques fonctions prédéfinies sont également disponibles. Voici un exemple (sans intérêt autre qu’illustratif) de conversion de l’année du film en texte (c’est un entier à l’origine).

select cast(year as text) as yearText from movies ;

Notez le renommage de la colonne avec le mot-clé as. Tout cela est directement emprunté à SQL. On peut également compter le nombre de lignes dans la table.

select count(*) from movies ;

On peut effectuer des filtrages avec la clause where. Par exemple:

select  *  from movies where id='movie:33';

Remarque importante: le critère de sélection porte ici sur la clé. On peut généraliser à plusieurs valeurs avec la clause in.

    select  * from movies
where id in ('movie:33', 'movie:44214', 'movie:29845');

Tentons maintenant une recherche sur un attribut non-clé.

select  * from movies
where title='Elle' ;

Vous devriez obtenir un rejet de cette requête avec le message suivant:

Unable to execute CQL script. Cannot execute this query as it might involve data
filtering and thus may have unpredictable performance. If you want
to execute this query despite the performance unpredictability,
use ALLOW FILTERING.

En revanche, en ajoutant l’option ALLOW FILTERING, on obtient le résultat.

select  * from movies
where title='Elle'
ALLOW FILTERING;

Nous avons atteint les limites de CQL en tant que clône de SQL.

Pourquoi CQL n’est pas SQL

Pourquoi un where sur un attribut non-clé est-il rejeté? Pour une raison qui tient à l’organisation des données:

  • Cassandra organise une table selon une structure (que nous étudierons ultérieurement) qui permet très rapidement de trouver un document par sa clé. La recherche par clé est donc autorisée.

  • Cette structure n’existe que pour la clé. Toute recherche sur un autre attribut n’a d’autre solution que de parcourir séquentiellement toute la table en effectuant le test sur le critère de recherche à chaque fois.

Comme déjà indiqué, Cassandra est conçu pour de très grandes bases de données, et le rejet de ces requêtes est une précaution. Le message indique clairement à l’utilisateur que sa requête est susceptible de prendre beaucoup de temps à s’exécuter.

À l’usage on décrouvre tout un ensemble de restrictions (par rapport à SQL) qui s’expliquent par cette volonté d’éviter l’exécution d’une requête qui impliquerait un parcours de tout ou partie de la table. Voyons quelques exemples, avec explications.

Note

Certaines des explications qui suivent sont volontairement brèves car elles impliquent une compréhension de la structure interne des données dans Cassandra ainsi que de la méthode de répartition dans un environnement distribué. Nous présenterons tout cela plus tard.

Tentons une requête sur la clé primaire, mais avec un critère d’inégalité.

select  * from movies
where id > '000000';

On obtient un rejet avec un message indiquant que seule l’égalité est autorisée sur la clé (et d’autres détails à éclaircir ultérieurement).

Peut-on trier les données avec la clause order by? Essayons.

select  * from movies order by title;

Les deux requêtes sont rejetées. Le message nous dit (à peu près) que le tri est autorisé seulement quand on est assuré que les données à trier proviennent d’une seule partition. En (un peu plus) clair: Cassandra ne veut pas avoir à trier des données provenant de plusieurs serveurs, dans un environnement distribué avec répartition d’une table sur plusieurs nœuds.

Et voilà. Cassandra interdit tout usage de CQL qui amènerait à parcourir toute la base ou une partie non prédictible de la base pour constituer le résultat. Cette interdiction n’est cependant pas totale. Dans le cas de la clause where, l’utilisateur peut prendre explicitement ses responsabilités en ajoutant la clause allow filtering, comme nous l’avons montré ci-dessus.

Si la table contient des milliards de ligne (bon, c’est peu probable ici), il faudra certainement attendre longtemps et exploiter intensivement les ressources du système pour un résultat limité. À utiliser à bon escient donc.

Il faut penser que le coût d’évaluation de cette requête est proportionnel à la taille de la base. Cassandra tente de limiter les requêtes à celles dont le coût est proportionnel à la taille du résultat.

Note

Cette remarque explique pourquoi la requête select * from movies;, qui parcourt toute la base, est autorisée.

À partir du moment où on autorise explicitement le filtrage, on peut combiner plusieurs critères de recherche, comme en SQL.

select  * from movies
where country='US' and year=2020 allow filtering;

Mais, si c’est pour faire du SQL, autant choisir une base relationnelle. Les restrictions de Cassandra doivent s’interpréter dans un contexte Big Data où l’accès aux données doit prendre en compte leur volumétrie (et notamment le fait que cette volumétrie impose une répartition des données dans un système distribué).

Une autre possibilité est de créer un index secondaire sur les attributs auxquels on souhaite appliquer des critères de recherche.

create index on movies(year);

Cassandra autorise alors de requêtes avec la clause where portant sur les attributs indexés.

select * from movies where year = 2020;

En présence d’un index, il n’est plus nécessaire de parcourir toute la collection. Cette option est cependant à utiliser avec prudence. En premier lieu, un index peut être coûteux à maintenir. Mais surtout sa sélectivité n’est pas toujours assurée. Ici, par exemple, un index sur l’année est probablement une très mauvaise idée. On peut estimer qu’un film sur 100 a été tourné en 1992, et à l’échelle du Big Data, ça laisse beaucoup de films à trouver, même avec l’index, et une requête qui peut ne pas être performante du tout.

Mise en pratique

Voici quelques manipulations et suggestions de recherches complémentaires.

Exercice MEP-S2-1: expérimentez CQL

À vous de jouer: reproduisez les requêtes ci-dessus sur votre base Cassandra.

Exercice MEP-S2-2: données imbriquées

Peut-on exprimer des critères sur les données imbriquées? Peut-on par exemple trouver tous les films mis en scène par Tarantino? À vous de chercher la solution (si elle existe) dans la documentation Cassandra.

Exercice MEP-S2-3: sujet d’étude, les vues matérialisées

Depuis la version 3, Cassandra propose un mécanisme de vue matérialisé. Etudiez la documentation à ce sujet, et montrez comment ce mécanisme peut permettre de répondre à des requêtes comme celle de l’exercice précédent.

S3: requêtes avec MongoDB

Précisons tout d’abord que le langage de requête sur des collections est spécifique à MongoDB. Essentiellement, c’est un langage de recherche dit « par motif » (pattern). Il consiste à interroger une collection en donnant un objet (le « motif/pattern », en JSON) dont chaque attribut est interprété comme une contrainte sur la structure des objets à rechercher. Voici des exemples, plus parlants que de longues explications. Nous travaillons sur la base contenant les films complets, sans référence (donc, celle nommée movies si vous avez suivi les instructions du chapitrte précédent).

L’apprentissage de ce langage n’est pas le sujet de cette session. Ce qui suit ne vise qu’à illustrer une approche délibérement différente de SQL pour tenter d’adapter l’interrogation de bases de données aux documents structurés. Une courte discussion est consacrée à l’opération de jointure, qui n’existe en pas en MongoDB mais qui peut être obtenue en programmant nous-mêmes l’algorithme.

Sélections

Commençons par la base: on veut parcourir toute une collection. On utilise alors find() dans argument.

db.movies.find ()

S’il y a des millions de documents, cela risque de prendre du temps… D’ailleurs, comment savoir combien de documents comprend le résultat?

db.movies.countDocuments ()

Comme en SQL (étendu), les options skip et limit permettent de « paginer » le résultat. La requête suivante affiche 12 documents à partir du dixième inclus.

db.movies.find ().skip(9).limit(12)

Implicitement, cela suppose qu’il existe un ordre sur le parcours des documents. Par défaut, cet ordre est dicté par le stockage physique: MongoDB fournit les documents dans l’ordre où il les trouve (dans les fichiers). On peut trier explicitement, ce qui rend le résultat plus déterministe. La requête suivante trie les documents sur le titre du film, puis pagine le résultat.

db.movies.find ().sort({"title": 1}).skip(9).limit(12)

La spécification du tri repose sur un objet JSON, et ne prend en compte que les noms d’attribut sur lesquels s’effectue le tri. La valeur (ici, celle du titre) ne sert qu’à indiquer si on trie de manière ascendante (valeur 1) ou descendante (valeur -1).

Attention, trier n’est pas anodin. En particulier, tout tri implique que le système constitue l’intégralité du résultat au préalable, ce qui induit une latence (temps de réponse) potentiellement élevée. Sans tri, le système peut délivrer les documents au fur et à mesure qu’il les trouve.

Critères de recherche

Si on connaît l’identifiant, on effectue la recherche ainsi.

db.movies.find ({"_id": "movie:33"})

Une requête sur l’identifiant ramène (au plus) un seul document. Dans un tel cas, on peut utiliser findOne.

db.movies.findOne ({"_id": "movie:33"})

Cette fonction renvoie toujours un document (au plus), alors que la fonction find renvoie un curseur sur un ensemble de documents (même si c’est un singleton). La différence est surtout importante quand on utilise une API pour accéder à MongoDB avec un langage de programmation.

Sur le même modèle, on peut interroger n’importe quel attribut.

db.movies.find ({"title": "Alien"})

Ca marche bien pour des attributs atomiques (une seule valeur), mais comment faire pour interroger des objets ou des tableaux imbriqués? On utilise dans ce cas des chemins, un peu à la XPath, mais avec une syntaxe plus « orienté-objet ». Voici comment on recherche les films de Quentin Tarantino.

db.movies.find ({"director.last_name": "Tarantino"})

Et pour les acteurs, qui sont eux-mêmes dans un tableau? Ca fonctionne de la même manière.

db.movies.find ({"actors.last_name": "Tarantino"})

La requête s’interprète donc comme: « Tous les films dont l’un des acteurs se nomme Tarantino ».

Conformément aux principes du semi-structuré, on accepte sans protester la référence à des attributs ou des chemins qui n’existent pas. En fait, dire « ce chemin n’existe pas » n’a pas grand sens puisqu’il n’y a pas de schéma, pas de contrainte sur la structure des objets, et que donc tout chemin existe potentiellement: il suffit de le créer. La requête suivante ne ramène rien, mais ne génére pas d’erreur.

db.movies.find ({"actor.last_name": "Tarantino"})

Important

Contrairement à une base relationnelle, une base semi-structurée ne proteste pas quand on fait une faute de frappe sur des noms d’attributs.

Quelques raffinements permettent de dépasser la limite sur le prédicat d’égalité implicitement utilisé ici pour comparer les critères donnés et les objets de la base. Pour les chaînes de caractères, on peut introduire des expressions régulières. Tous les films dont le titre commence par Re? Voici:

db.movies.find ({"title": /^Re/})

Pas d’apostrophes autour de l’expression régulière. On peut aussi effectuer des recherches par intervalle.

db.movies.find( {"year": { $gte: 2000, $lte: 2005 } })

Projections

Jusqu’à présent, les requêtes ramènent l’intégralité des objets satisfaisant les critères de recherche. On peut aussi faire des projections, en passant un second argument à la fonction find().

db.movies.find ({"director.last_name": "Tarantino"}, {"title": true, "actors": 'j'} )

Le second argument est un objet JSON dont les attributs sont ceux à conserver dans le résultat. Notez que seules les clés du document JSON sont prises en compte (et correspondent aux attributs à conserver). La valeur ne compte pas, pourvu qu’elle soit différente de 0 ou null.

Opérateurs ensemblistes

Les opérateurs du langage SQL in, not in, any et all se retrouvent dans le langage d’interrogation. La différence, notable, est que SQL applique ces opérateurs à des relations (elles-mêmes obtenues par des requêtes) alors que dans le cas de MongoDB, ce sont des tableaux JSON. MongoDB ne permet pas d’imbriquer des requêtes.

Voici un premier exemple: on cherche les films dans lesquels joue au moins un des artistes dans une liste (on suppose que l’on connaît l’identifiant).

db.movies.find({"actors._id": {$in: ["artist:34","artist:98","artist:1"]}})

Gardez cette recherche en mémoire: elle s’avèrera utile pour contourner l’absence de jointure en MongoDB. Le in exprime le fait que l’une des valeurs du premier tableau (actors._id) doit être égale à l’une des valeurs de l’autre. Il correspond implicitement, en SQL, à la clause ANY. Pour exprimer le fait que toutes les valeurs de premier tableau se retrouvent dans le second (en d’autres termes, une inclusion), on utilise la clause all.

db.movies.find({"director._id": {$all: ["artist:23","artist:147"]}})

Le not in correspond à l’opérateur $nin.

db.movies.find({"director._id": {$nin: ["artist:34","artist:98","artist:1"]}})

Comment trouver les films qui n’ont pas d’attribut summary?

db.movies.find({"summary": {$exists: false}}, {"title": 1})

Opérateurs Booléens

Par défaut, quand on exprime plusieurs critères, c’est une conjonction (and) qui est appliquée. On peut l’indiquer explicitement. Voici la syntaxe (les films tournés avec Leonardo DiCaprio en 1997):

db.movies.find({$and : [{"year": 1997}, {"actors.last_name": "DiCaprio"}]} )

L’opérateur and s’applique à un tableau de conditions. Bien entendu il existe un opérateur or avec la même syntaxe. Les films parus en 1997 ou avec Leonardo DiCaprio.

db.movies.find({$or : [{"year": 1997}, {"actors.last_name": "DiCaprio"}]} )

Voici pour l’essentiel en ce qui concerne les recherches portant sur une collection et consistant à sélectionner des documents. Grosso modo, on obtient la même expressivité que pour SQL dans ce cas. Que faire quand on doit croiser des informations présentes dans plusieurs collections? En relationnel, on effectue des jointures. Avec Mongo, il faut bricoler.

Jointures

La jointure, au sens de: associer des objets distincts, provenant en général de plusieurs collections, pour appliquer des critères de recherche croisés, n’existe pas en MongoDB. C’est une limitation importante du point de vue de la gestion de données. On peut considérer qu’elle est cohérente avec une approche documentaire dans laquelle les documents s’appuient sur la dénormalisation et sont supposés indépendants les uns des autres. Cela étant, on peut imaginer toutes sortes de situations où une jointure est quand même nécessaire dans une aplication de traitement de données.

Voyons comment nous pouvons contourner le problème. Nous allons supposer pour les besoins de la cause que la collection des films ne contient que les identifiants des artistes impliquées, et qu’une seconde collection contient les informations sur ces artistes (vous pouvez charger cette dernière collection à partir d’un fichier disponible sur le site).

Une première approche est de créer une vue qui assemble deux collections dans une troisième, virtuel. Cela suppose qu’on accepte de créer une vue pour chaque jointure…

La création de vue est la suivante:

     db.createView( "full_movies", "movies", [
     {
       $lookup:
              {
             from: "artists",
             localField: "director._id",
             foreignField: "_id",
             as: "metteur_en_scene"
             }
     }]
)

On crée une collection-vue full_movies qui étend chaque document de la collection movies``en y intégrant un champ ``metteur_en_scene, lequel contient le document de la collection artists correspondant à l’identifiant director._id` (relisez encore une fois si ce n’est pas clair…).

On peut alors interroger la collection full_movies, qui implante à peu près l’équivalent d’une jointure externe en relationnel.

db.full_movies.find()

L’autre approche consiste à effectuer la jointure côté client, comme illustré sur la Fig. 20. Cela revient essentiellement à appliquer l’algorithme de jointures par boucle imbriquées en stockant des données temporaires dans des structures de données sur le client, et en effectuant des échanges réseaux entre le client et le serveur, ce qui dans l’ensemble est très inefficace.

_images/jointure-serveur-client.png

Fig. 20 Jointure côté serveur et côté client

Comme l’interpréteur mongo permet de programmer en Javascript, nous pouvons en fait illustrer la méthode assez simplement. Considérons la requête: « Donnez tous les films dont le directeur est Clint Eastwood ».

La première étape dans la jointure côté client consiste à chercher l’artiste Clint Eastwood et à le stocker dans l’espace mémoire du client (dans une variable, pour dire les choses simplement).

eastwood = db.artists.findOne({"first_name": "Clint", "last_name": "Eastwood"})

On dispose maintenant d’un objet eastwood. Une seconde requête va récupérer les films dirigés par cet artiste.

db.movies.find({"director._id": eastwood['_id']}, {"title": 1})

Voilà le principe. Voyons maintenant plus généralement comment on effectue l’équivalent des jointures en SQL. Prenons la requête suivante:

select m.titre, a.*  from Movie m, Artist a
where m.id_director = a.id

On veut donc les titres des films et le réalisateur. On va devoir coder, du côté client, un algorithme de jointure par boucles imbriquées. Le voici, sous le shell de MongoDB (et donc en programmation javascript).

var lesFilms = db.movies.find()
while (lesFilms.hasNext()) {
  var film = lesFilms.next();
  var mes = db.artists.findOne({"_id": film.director._id});
  printjson(film.title);
  printjson(mes);
}

On a donc une boucle, et une requête imbriquée, exécutée autant de fois qu’il y a de films. C’est exactement la méthode qui serait utilisée par le serveur si ce dernier implantait les jointures. L’exécuter du côté client induit un surcoût en programmation, et en échanges réseau entre le client et le serveur.

Mise en pratique

Voici quelques propositions d’exercices si vous souhaitez vous frotter concrètement à l’interrogation MongoDB.

Exercice MEP-S3-1: requêtes sur la base des films.

Sur votre base movies,

  • tous les titres;

  • tous les titres des films parus après 2000;

  • le résumé de Spider-Man;

  • qui est le metteur en scène de Gladiator?

  • titre des films avec Kirsten Dunst;

  • quels films ont un résumé?

  • les films qui ne sont ni des drames ni des comédies.

  • affichez les titres des films et les noms des acteurs.

  • dans quels films Clint Eastwood est-il acteur mais pas réalisateur (aide: utilisez l’opérateur de comparaison $ne).

  • Difficile: Comment chercher les films dont le metteur en scène est aussi un acteur? Pas sûr que ce soit possible sans recourir à une auto-jointure, côté client…

Correction

  • db.movies.find({}, {"title": 1})

  • db.movies.find({"year": {$gt: "2000"}}, {"title": 1, "year": 1})

  • db.movies.find({"title": "Spider-Man"}, {"summary": 1})

  • db.movies.find({"title": "Gladiator"}, {"director": 1})

  • db.movies.find({"actors.last_name": "Dunst"}, {"title": 1})

  • db.movies.find({"summary": {$exists: true}}, {"title": 1})

    NB: cette fonction regarde si le champ existe, pas s’il est vide ou non. Dans la base, il existe des films avec un résumé ayant pour valeur null. Afin de ne récupérer que les films ayant réellement un résumé, on peut ajouter $ne:null

    db.movies.find({"summary": {$exists: true, $ne:null}}, {"title": 1})

    donne les films dont le champ résumé existe et dont la valeur du champ est différente de null.

  • db.movies.find({"genre": {$nin: ["Drame", "Comédie"]}}, {"title": 1, "genre": 1})

  • db.movies.find({}, {"title": 1, "actors.first_name": 1, "actors.last_name": 1})

  • db.movies.find({"actors.last_name": "Eastwood", "director.last_name": {$ne: "Eastwood"}}, {"title": 1})