Dans toute la première partie de ce cours, on a vu comment utiliser HDFS pour stocker des données massives sous forme de fichiers. Étant donnés les besoins qu'on a établis pour la création de notre data lake, on peut considérer que ce choix ne prête pas vraiment à débat. Il nous reste à structurer nos données et à les organiser pour constituer un master dataset, et les choix qu'on va faire risquent d'être plus polémiques...
Dans le premier chapitre de ce cours, on a établi qu'on ne pouvait pas se permettre de dissocier les données et leur schéma : si on stocke les données sans stocker la manière dont elles sont structurées (leur schéma) alors il devient très difficile de garantir leur évolution dans le temps. Rappelons que le master dataset va être exploité par un grand nombre d'applications indépendantes les unes des autres, potentiellement créées dans des langages différents. Cette contrainte élimine d'emblée la possibilité de stocker les données au format JSON ou XML, par exemple. Il existe des moyens d'utiliser ces formats de stockage tout en contournant le problème d'évolution des schémas ; sans s'attarder sur les détails, ils reviendraient à créer un nouveau format de sérialisation des données... et c'est justement un format de sérialisation qu'on veut présenter dans cette seconde partie ! Plutôt que de réinventer la roue, on va donc s'intéresser à Apache Avro. Plutôt que de faire une longue introduction théorique, on va cette fois directement mettre le pied à l'étrier avec un premier exemple.
Installation
Avro peut être utilisé dans plusieurs langages. Dans ce cours on va utiliser Python, mais si ce n'est pas votre langage préféré n'hésitez pas à installer la librairie correspondante du langage de votre choix.
En Python, nous allons utiliser la librairie fastavro
que nous installons en exécutant :
$ pip install fastavro
Sérialisation de données
Commençons par sérialiser les données suivantes :
[
{
"id": 1,
"name": "Martin Riggs"
},
{
"id": 2,
"name": "John Wick"
},
{
"id": 3,
"name": "Ripley"
}
]
Il s'agit d'une liste de deux personnages dont chacun est caractérisé par un identifiant entier et un nom. Nous créons cette liste pour le site badassmoviecharacters.com. Voici le schéma de nos données :
{
"namespace": "com.badassmoviecharacters",
"name": "Character",
"doc": "Seriously badass characters",
"type": "record",
"fields": [
{"name": "name", "type": "string"},
{"name": "id", "type": "int"}
]
}
Lenamespace
et lename
permettent d'identifier l'application et nom du modèle que représentent les données. Il est d'usage de choisir pour le namespace l'URL du projet en inversant l'ordre des sous-domaines (à la mode Java). Cela vous garantit que votre namespace sera unique et que vous pourrez échanger vos données avec d'autres sans risque d'erreur. On ajoute également une documentation (doc
) à notre schéma pour aider les utilisateurs de nos données à comprendre ce qu'elles représentent. Nos objetCharacter
sont de typerecord
— nous allons voir un peu plus loin les différents types disponibles dans Avro.
Puis vient la liste des différents champs (fields
) qui composent notre schéma : pour l'instant cette liste est simple et se comprend d'elle-même. Nous pouvons maintenant sérialiser nos données. Voici comment faire en Python :
import fastavro
# Définition des personnages
characters = [
{
"id": 1,
"name": "Martin Riggs"
},
{
"id": 2,
"name": "John Wick"
},
{
"id": 3,
"name": "Ripley"
}
]
# Définition du schéma des données
schema = {
"type": "record",
"namespace": "com.badassmoviecharacters",
"name": "Character",
"doc": "Seriously badass characters",
"fields": [
{"name": "name", "type": "string"},
{"name": "id", "type": "int"}
]
}
# Ouverture d'un fichier binaire en mode écriture
with open("characters.avro", 'wb') as avro_file:
# Ecriture des données
fastavro.writer(avro_file, schema, characters)
Lorsque l'on exécute ce script, un fichiercharacters.avro
est créé. Ce fichier contient nos données, comme on peut le voir à l'aide de la commande suivante :
$ fastavro characters.avro
{"name": "Martin Riggs", "id": 1}
{"name": "John Wick", "id": 2}
{"name": "Ripley", "id": 3}
Mais ce fichier contient aussi le schéma des données :
$ fastavro --schema characters.avro
{
"name": "Character",
"fields": [
{
"name": "name",
"type": "string"
},
{
"name": "id",
"type": "int"
}
],
"namespace": "com.badassmoviecharacters",
"doc": "Seriously badass characters",
"type": "record"
}
Félicitations ! Si vous avez suivi les étapes qui précèdent, vous venez de sérialiser vos premières données avec Avro. Comme vous pouvez vous en rendre compte en examinant le contenu du fichiercharacters.avro
, il s'agit d'un fichier binaire ; le schéma des données est stocké au début du fichier, sous forme binaire lui aussi. Ce format permet de minimiser l'espace utilisé par rapport à une solution basée sur un format texte. Pour minimiser encore la taille des données, on peut les écrire en activant la compression. Cela se fait en passant l'argumentcodec="deflate"
au writer defastavro
:
fastavro.writer(avro_file, schema, characters, codec="deflate")
Désérialisation de données
Et maintenant, comment faire pour désérialiser les données ? Le script de lecture n'est pas très compliqué, comme on peut le voir :
import fastavro
# Ouverture du fichier binaire en mode lecture
with open("characters.avro", 'rb') as avro_file:
# Création d'un reader pour lire les données
reader = fastavro.reader(avro_file)
# Affichage du schéma des données
print(reader.schema)
# Itération sur tous les personnages
for character in reader:
print(character)
Notez que dans cet exemple, on ne passe pas le schéma au reader defastavro
; le schéma est lu au début de la lecture du fichiercharacters.avro
. On peut avoir envie de passer explicitement ce schéma pour vérifier que le schéma de lecture est bien celui qui est attendu ; c'est un scénario qui sera exploité plus finement dans le chapitre suivant sur l'évolution des schémas de données. Dans ce cas, il est courant de stocker le schéma dans un fichier JSON et dont l'extension est.avsc
("Avro schema"). Ce schéma peut alors être passé au reader avec l'argumentreader_schema
:
import json
schema = json.load(open("character.avsc")
with open("characters.avro", 'rb') as avro_file:
reader = fastavro.reader(avro_file, reader_schema=schema)
L'exemple de lecture des données qu'on a utilisé est relativement simple, mais mine de rien on peut l'utiliser pour sérialiser des milliards de personnages de films. On voudra alors probablement intégrer des données un peu plus complexes à notre schéma. Voyons donc quels types sont à notre disposition dans Avro.
Lecture et écriture dans HDFS
Ce qui est bien avecfastavro
, c'est que ce package s'intègre naturellement avec HDFS. Le premier argument passé aux fonctionsfastavro.writer
etfastavro.reader
doit simplement être un objet possédant les propriétés d'un fichier ouvert en écriture ou en lecture, respectivement. Pour écrire ou lire des données dans HDFS, il suffit donc d'utiliser le package Pythonhdfs
qu'on a vu dans la partie précédente :
Au lieu d'écrire :
with open("/local/path/data.avro", 'wb') as avro_file:
fastavro.writer(avro_file, schema, data)
On écrit :
hdfs_client = hdfs.InsecureClient("http://0.0.0.0:50070")
with hdfs_client.write("/hdfs/path/data.avro") as avro_file:
fastavro.writer(avro_file, schema, data)
Et similairement, pour la lecture :
hdfs_client = hdfs.InsecureClient("http://0.0.0.0:50070")
with hdfs_client.read("/hdfs/path/data.avro") as avro_file:
reader = fastavro.reader(avro_file, reader_schema=schema)
À titre d'exemple, les scripts de sérialisation et de désérialisation de données géographiques décrits dans la vidéo de ce cours sont disponibles dans le dépôt de code associé.
Les types dans Avro
Dans Avro, on distingue deux types catégories de types : les types primitifs et les types complexes. Les types primitifs sontnull
,boolean
,int
,long
,float
,double
,bytes
, etstring
. Les types complexes sontrecord
,enum
,array
,map
,union
,fixed
. Un type complexe possède des propriétés, et c'est ce qui le distingue des types primitifs. Pour représenter un type primitif on utilise juste son nom, tandis que pour représenter un type complexe, on utilise la syntaxe suivante :
{"type": "nomdutype", "propriete1": valeur1, "propriete2": valeur2, ...}
Par exemple, le type array
possède une seule propriétéitems
qui définit le type des données contenues dans le tableau. Donc, pour représenter un tableau contenant des chaînes de caractères, on écrit :
{"type": "array", "items": "string"}
Quant au type complexe record
, on l'a déjà évoqué : c'est celui qu'on a utilisé pour représenter nos personnages. Unrecord
possède, entre autres, des champs (fields
). Chacun de ces champs possède, entre autres, un type qui est représenté de la même manière que ci-dessus. Prenons un exemple : on veut ajouter à notre schéma la liste des films dans lesquels apparaissent les personnages, que l'on appelleramovies
. Pour cela, on écrit :
schema = {
"type": "record",
"namespace": "com.badassmoviecharacters",
"name": "Character",
"doc": "Seriously badass characters",
"fields": [
{"name": "name", "type": "string"},
{"name": "id", "type": "int"},
# Nouveau champ
{"name": "movies", "type": {"type": "array", "items": "string"}}
]
}
De la même manière, on peut définir des champs relativement complexes. On pourrait par exemple imaginer qu'un film soit un objet à part entière, possédant un titre, une date de sortie et une liste d'acteurs. On écrirait alors le schéma suivant :
schema = {
"type": "record",
"namespace": "com.badassmoviecharacters",
"name": "Character",
"fields": [
...
{"name": "movies", "type": {"type": "array", "items": {
"type": "record",
"namespace": "com.badassmoviecharacters",
"name": "Movie",
"fields": [
{"name": "title", "type": "string"},
{"name": "year", "type": "int"},
{"name": "actors", "type": {
"type": "array",
"items": "string"
}}
]
}}}
]
}
Si vous comprenez ce mécanisme d'imbrication, alors vous avez presque tout compris aux schémas de données d'Avro :honte: Il vous reste à découvrir les autres types complexes d'Avro. Nous allons les couvrir ci-dessous, mais en cas de doute n'hésitez pas à vous référer à la documentation officielle, nécessairement très complète.
Vous allez probablement être amenés à utiliser fréquemment le type map
qui correspond aux tables de hachage et dont la seule propriété est le type des valeurs. Notez que les clés d'unemap
sont toujours des chaînes de caractères dans Avro. Par exemple, voici unemap
dont les valeurs sont de typelong
:
{"type": "map", "values": "long"}
Remarquez que dans cette représentation, le typemap
, tout comme le typearray
, ne peut contenir des valeurs que d'un seul type. De manière générale, pour l'instant on n'a vu que des champs d'un unique type. Si vous voulez indiquer qu'un champ peut être de plusieurs types différents, vous pouvez utiliser le type complexeunion
. Dans uneunion
, on représente les différents types possibles entre crochets ([]
). Par exemple, uneunion
est fréquemment utilisée pour autoriser les valeurs nulles dans un champ :
{"name": "year", "type": ["int", "null"]}
Dans cet exemple, le champyear
peut être soit un entier, soitnull
.
Il nous reste à évoquer le type complexe enum
qui permet de représenter une valeur choisie parmi plusieurs, fixées à l'avance. Un typeenum
doit être nommé et indique la liste des symboles autorisés. Par exemple :
{"name": "gender", "type": {"type": "enum", "name": "Gender", "symbols": ["FEMALE", "MALE", "OTHER"]}}
Notons que lors de la définition d'un champ, on peut utiliser la propriétédefault
pour définir une valeur par défaut. Par exemple :
"fields": [
...
{
"name": "gender",
"type": {
"type": "enum", "name": "Gender", "symbols": ["FEMALE", "MALE", "OTHER"]}, ""
},
"default": "MALE"
]
Un mot sur la génération de code...
Python n'est pas un langage fortement typé, donc charger des objets de type arbitraire et modifier les attributs à la volée ne pose pas de problème. Dans d'autres langages, il peut être nécessaire de déclarer les classes au préalable. Évidemment, ça nécessite un effort considérable de générer ces classes dans toutes sortes de langages de programmation en fonction d'un schéma susceptible d'évoluer avec le temps. C'est pour cette raison que Avro propose des outils pour générer le code associé à un schéma.
La procédure à suivre varie d'un langage à un autre ; pour générer le code correspondant à une classe en Java, il faut commencer par téléchargeravro-tools
:
$ wget http://apache.mediamirrors.org/avro/avro-1.8.2/java/avro-tools-1.8.2.jar
Puis on génère la classe en lui passant le fichier contenant le schéma des données :
$ java -jar ./avro-tools-1.8.2.jar compile schema ~/code/character.avsc .
Cela génère un fichierCharacter.java
dans le répertoire./com/badassmoviecharacters/
. Cette classe est trop complexe pour être représentée ici, mais en voici un aperçu :
public class Character extends org.apache.avro.specific.SpecificRecordBase implements org.apache.avro.specific.SpecificRecord {
private static final long serialVersionUID = 5395921204592234550L;
public static final org.apache.avro.Schema SCHEMA$ = new org.apache.avro.Schema.Parser().parse("{\"type\":\"record\",\"name\":\"Character\",\"namespace\":\"com.badassmoviecharacters\",\"doc\":\"Seriously badass characters\",\"fields\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"id\",\"type\":[\"int\",\"string\"]}]}");
public static org.apache.avro.Schema getClassSchema() { return SCHEMA$; }
private static SpecificData MODEL$ = new SpecificData();
private static final BinaryMessageEncoder<Character> ENCODER =
new BinaryMessageEncoder<Character>(MODEL$, SCHEMA$);
private static final BinaryMessageDecoder<Character> DECODER =
new BinaryMessageDecoder<Character>(MODEL$, SCHEMA$);
/**
* Return the BinaryMessageDecoder instance used by this class.
*/
public static BinaryMessageDecoder<Character> getDecoder() {
return DECODER;
}
...
}