Intro

C’est parti pour la suite du sujet, cette fois on rentre dans le dur avec l’écriture de la couche de communication avec l’object storage.

Mais tout d’abord, il nous faut un object storage pour pouvoir y stocker des choses.

Mise en place de l’object storage de développement

Comme vu dans l’article précédent, nous allons utiliser un container docker Minio comme object storage de développement.

Pour cela rien de plus simple :

services:
  minio:
    image: minio/minio
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: miniosecret
    ports:
      - "9000:9000"
      - "9001:9001"

Explications :

  • image : l’image docker que nous allons utiliser (ici l’image officielle de minio)
  • command : la commande qui va être lancée dans le container minio
  • MINIO_ROOT_USER et MINIO_ROOT_PASSWORD les identifiants par défaut de notre compte administrateur
  • ports : les ports du container que nous souhaitons exposer (ici 9000 et 9001)

Un petit docker compose up -d et notre object storage est en route !

L’interface d’administration est disponible via l’url http://localhost:9001.

On tombe automatiquement sur la mire d’authentification et on s’identifie avec le compte administrateur paramétré dans le docker compose.

minio login

Une fois authentifié nous arrivons sur le dashboard.

minio dashboard

Il est maintenant possible de créer notre bucket.

💡 C’est quoi un bucket ?

Un bucket est un conteneur qui va permettre d’organiser et de stocker des objets (nos données).

Ces données peuvent être hiérarchisées en dossiers et enrichies de métadonnées.

Il est également possible de définir des droits d’accès par objet.

minio bucket

minio bucket created

Nous aurons également besoin plus tard de clés d’accès pour lire et stocker des objets dans notre bucket, faisons ça tout de suite.

Cliquer sur “Access keys” dans le menu gauche pour accéder à l’écran de création de clés puis une fois sur l’écran, cliquer sur le bouton “Create access key +”.

minio access keys

Une interface de création de clé s’affiche, renseigner les différentes informations comme ci-dessous

minio access keys create

Une fois les informations renseignées cliquer sur le bouton “Create”, une interface s’affiche avec votre paire de clés (Access et Secret keys).

Gardez les bien au chaud pour plus tard, elles nous seront utiles dans le code.

minio access keys recap

Et voilà, c’est tout pour la partie object storage ! Nous pouvons passer au code.

Initialisation du projet Rust

💡Pré-requis : installation de Rust

Avant toute chose, il va falloir installer Rust sur votre machine si ce n’est pas déjà fait.

Pour cela, il suffit de suivre les instructions du site officiel : https://www.rust-lang.org/tools/install

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Lancer la commande cargo new <project> pour initialiser un nouveau projet.

Dans notre cas, le CDN s’appellera gimme, ce qui nous donne :

cargo new gimme

Vous devez obtenir un répertoire de projet similaire à ce qui suit :

.
├── Cargo.lock
├── Cargo.toml
├── src
│   └── main.rs
└── target

Ajoutons maintenant un répertoire docker pour y stocker notre fichier docker compose réalisé plus haut.

.
├── Cargo.lock
├── Cargo.toml
├── docker
│   └── docker-compose.yml
├── src
│   └── main.rs
└── target

Nous sommes prêts pour commencer à coder !

Interfaçage avec l’Object Storage

Intro

Dans cette partie nous allons voir comment implémenter la couche de communication avec notre Object Storage.

Pour les besoins de notre futur CDN, nous avons besoin d’implémenter les fonctionnalités suivantes :

  • Connexion à l’object storage
  • Lister les objets d’un bucket
  • Ajouter un objet dans un bucket
  • Lire un objet dans un bucket

(Dans le futur nous pourrions également gérer la création du bucket via le code ou même la suppression d’un objet dans le bucket)

Mais avant toute chose il nous faut une librairie pour s’interfacer avec l’object storage, nous n’allons pas tout coder from scratch.

A la recherche de la librairie

Après quelques recherches sur https://crates.io/, je trouve deux modules qui pourraient faire l’affaire :

💡Update du 02/03/2024

Après avoir testé rust-s3, j’ai changé de librairie pour rusoto_s3 suite à des problèmes de performances lors de l’upload de fichiers dans l’object storage.

Sur des packages composés de nombreux fichiers, j’obtenais une latence de 20s pour upload tous les fichiers avec la librairie rust-s3 même en parallélisant les tâches.

En comparaison, le même code avec la librairie rusoto_s3 prend 1.7s pour effectuer la même tâche.

Profitons-en pour ajouter toutes les librairies dont nous aurons besoin dans cette partie :

anyhow = "1.0.79"
dotenvy = "0.15.7"
regex = "1.10.3"
rusoto_core = "0.48"
rusoto_s3 = "0.48"
serde = "1.0.196"
tokio = { version = "1.0", features = ["full"] }

Implémentation

Initialisation du module ObjectStorageAdapter

Nous allons commencer par créer un répertoire storage avec à l’intérieur un fichier object_storage_adapter.rs.

C’est ce fichier qui contiendra toute la logique de communication avec l’object storage.

Pour commencer, nous allons déclarer une structure ObjectStorageAdapter ainsi que son implémentation.

pub struct ObjectStorageAdapter {
    bucket: Bucket,
}

impl ObjectStorageAdapter {}

Connexion à l’object storage

La librairie rusoto_s3 permet de se connecter à un object storage. C’est la structure S3Client de la lib qui gère cette partie.

Pour créer notre instance d’object storage qui permettra de communiquer avec le bucket, on utilise la méthode S3Client::new_with().

Cette méthode a besoin à minima de plusieurs paramètres tels que la région, le endpoint et les clés d’accès.

C’est ici que nous allons utiliser les informations créées dans le chapitre précédent.

Voici un exemple d’instanciation :

let provider = StaticProvider::new_minimal(access_key, secret_key);

let region = Region::Custom {
    name: region,
    endpoint,
};

let client = S3Client::new_with(HttpClient::new().unwrap(), provider, region);

💡 Bien que l’exemple utilise un unwrap, il est important de gérer les erreurs dans notre véritable implémentation.

Insérer un objet dans le bucket

L’insertion d’un objet dans le bucket d’un object storage est relativement simple, la librairie a seulement besoin d’un chemin (path + nom du fichier) et de binaire de l’objet à stocker.

Si je souhaite stocker un fichier à la racine du bucket, je peux donc tout simplement faire :

let object = "Hello world !".as_bytes();

let request = PutObjectRequest {
	bucket: "gimme".to_string(),
	key: "hello_world.txt".to_string(),
	body: Some(object.to_owned().into()),
	..Default::default()
};

client.put_object(request).await.unwrap();

Si tout s’est passé comme prévu, vous devriez pouvoir voir le fichier hello_world.txt dans le bucket gimme depuis l’interface web de Minio.

minio created file

Lister les objets à partir d’un chemin d’accès

Pour lister les objets dans un bucket, la librairie propose une méthode list_objects_v2 qui prend en paramètre un préfixe et un délimiteur.

  • Le nom du bucket
  • Le préfixe est le répertoire à partir duquel on souhaite lister les fichiers
  • Le délimiteur est le caractère qui sépare les niveaux d’arborescence

Exemple : si j’avais une arborescence /foo/bar/baz, je pourrais utiliser le délimiteur /

Dans l’exemple suivant, nous allons lire les fichiers à la racine du bucket :

let request = ListObjectsV2Request {
	bucket: "gimme".to_string(),
	prefix: Some("".to_string()),
	delimiter: Some("/".to_string()),
	..Default::default()
};

client.list_objects_v2(request).await.unwrap();

La méthode list_objects_v2 retourne un objet avec un tableau de fichiers contents présents dans le répertoire ainsi que un tableau des sous-répertoires common_prefixes.

Récupérer le binaire d’un objet

Pour notre usage en tant que stockage de CDN, nous avons besoin de récupérer les binaires des objets stockés.

Pour cela, nous allons utiliser la méthode get_object qui prend en paramètre le nom du bucket ainsi que le nom du fichier que l’on souhaite récupérer.

let request = GetObjectRequest {
	bucket: "gimme".to_string(),
	key: "hello_world.txt".to_string(),
	..Default::default()
};

client.get_object(request).await.unwrap();

La réponse contient le fichier au format ByteStream.

Gestion des variables d’environnements

Nous allons utiliser la librairie dotenvy pour déclarer les variables d’environnements dans un .env.

Cette librairie permet de charger automatiquement les valeurs présentes dans un fichier .env ce qui sera bien pratique pour déclarer notre configuration d’object storage.

Pour l’ajouter dans le projet, rien de plus simple, il suffit de lancer la commande cargo add dotenvy dans le terminal.

Puis tout en haut de la fonction main dans le fichier main.rs, ajouter la ligne suivante :

dotenv().ok();

Le fichier .env sera maintenant chargé automatiquement.

Pour utiliser les variables d’environnements chargées, nous pouvons maintenant utiliser dotenvy::var.

let bucket_name = dotenvy::var("BUCKET").unwrap();
let region = dotenvy::var("REGION").unwrap();
let endpoint = dotenvy::var("ENDPOINT").unwrap();
let access_key = dotenvy::var("ACCESS_KEY").unwrap();
let secret_key = dotenvy::var("SECRET_KEY").unwrap();

Au prochain épisode

Maintenant que nous avons toutes les méthodes nécessaires à notre application pour stocker et lire des fichiers, nous allons pouvoir attaquer la couche métier ainsi que l’exposition API.

Au programme :

  • Couche métier avec ajout et lecture de contenu
  • Mise en place de l’API

L’objectif à la fin du prochain article est de gérer l’upload des librairies au format zip, de les désarchiver à la volée avant de les stocker.

Vous pouvez également suivre l’avancement de l’implémentation via le projet Gitlab https://gitlab.com/ziggornif/gimme-cdn.

À la prochaine !

Lien vers la partie 3 : https://blog.ziggornif.xyz/post/cdn-part3/