Intro

Reprenons où nous nous sommes arrêtés la dernière fois. Nous avons un object storage ainsi qu’une implémentation Rust pour communiquer avec lui.

Cette implémentation nous permet de :

  • stocker des fichiers
  • lire des fichiers
  • lire le contenu d’un répertoire

Dans cette partie, nous allons construire tout le reste de l’application c’est à dire :

  • l’organisation du projet Rust en architecture hexagonale
  • la couche métier
  • l’API REST
  • l’UI permettant de consulter un package

⚠️ l’article va être dense vu le nombre de sujets que nous allons devoir aborder, n’hésitez pas à utiliser le sommaire pour accéder aux chapitres.

C’est parti !

Organisation du projet en architecture hexagonale

C’est quoi l’architecture hexagonale ?

Je ne vais pas revenir en détail sur les concepts de l’architecture hexagonale, si vous souhaitez approfondir le sujet, j’ai écrit en collaboration avec Sebastián Plaza un article assez détaillé sur le sujet https://dev.to/ziggornif/hexagonal-architecture-as-a-solution-to-the-obsolescence-of-ui-frameworks-ej2.

Extrait de l’article :

L’architecture hexagonale est un pattern d’architecture créé par Alistair Cockburn qui place la couche métier au centre de l’application (hexagone) tout en garantissant un couplage lâche avec les briques techniques.

Concepts de base :

  • Le domaine métier est agnostique et n’a pas de dépendances.
  • L’étanchéité du domaine métier est garantie via le système de ports.
  • Les couches (adapters) gravitants autour de l’hexagone doivent respecter les interfaces définies dans la couche port pour communiquer avec le domaine.

hexagon schema

Vocabulaire

  • API (Application Programming Interface): contient toutes les interfaces requises et fournies par le domaine pour interagir avec le domaine
  • SPI (Service Provider Interface) : contient toutes les interfaces requises et fournies par le domaine pour interagir avec la donnée
  • Domain : l’espace agnostique où se trouve le code métier
  • Adapters / Controllers : les couches techniques en orbite autour de l’hexagone qui implémentent les interfaces API et SPI

Organisation du projet Rust

Ci-dessous une représentation simplifiée de notre projet :

.
├── docker
│   └── docker-compose.yml
├── adapters
├── controllers
├── domain
│   └── ports
│       ├── api
│       └── spi
├── infra
└── templates
  • Le répertoire domain contiendra le code métier de notre CDN.
  • Le répertoire ports contiendra les interfaces API et SPI. En Rust ce seront des traits.
  • Le répertoire adapters contiendra la couche technique de communication avec l’object storage
  • Le répertoire controllers contiendra la couche API REST
  • Le répertoire infra contiendra le code technique lié à la création du contexte ainsi qu’au démarrage du serveur HTTP
  • Le répertoire templates contiendra les templates HTML utilisés pour construire nos UI

Nous allons maintenant voir étape par étape comment construire chaque partie du projet.

Implémentation

Hexagone - Création des ports

Pour créer nos ports API et SPI, nous allons utiliser les traits Rust.

💡Explication sur les traits

Un trait est un modèle abstrait qui permet de décrire un ensemble de fonctionnalités ou de comportements.

Les traits peuvent être appliqués à différentes implémentations pour étendre leurs fonctionnalités.

Prenons l’exemple suivant :

trait Say {
	fn say_hello(&self, name: &str);
}

struct Person {
	name: String,
}

impl Person {
    pub fn new(name: String) -> Self {
    Self { name }
  }
}

Si nous implémentons le trait Say sur la structure Person, nous devrons ajouter une méthode say_hello qui respecte la signature définie dans le trait.

impl Say for Person {
  fn say_hello(&self, name: &str) {
    println!("Hello {} !", name);
  }
}

Nous pouvons désormais utiliser la méthode say_hello sur la structure Person.

let p = Person::new("Matthieu".to_string());
p.say_hello("Simon");

Cette technique a l’avantage de garantir le respect des signatures des fonctions définies dans les traits.

SPI Content Storage

La SPI content storage va définir la signature que doit respecter l’adapter qui se charge de récupérer la donnée utilisée par notre CDN.

Pour faire simple, elle définit les signatures des besoins que nous avons décrits dans la partie 2.

  • Lister les objets d’un bucket
  • Ajouter un objet dans un bucket
  • Lire un objet dans un bucket

J’ai volontairement ajouté la fonctionnalité de suppression d’un objet, car elle sera utile plus tard notamment pour les tests.

Voici à quoi cela ressemble une fois écrit :

#[async_trait]
pub trait ContentStorageAdapter: Send + Sync {
    async fn list_objects(&self, parent_object: String) -> Result<ListObjectResult, Error>;
    async fn add_object(
        &mut self,
        filename: &str,
        object: &[u8],
        content_type: &str,
    ) -> Result<(), Error>;
    async fn get_object(&self, filename: String) -> Result<File, Error>;
    async fn delete_objects(&mut self, filename: String) -> Result<(), Error>;
}

💡 j’utilise la librairie async_trait car la version actuelle de Rust ne supporte pas les dyn Trait asynchrones.

Ce trait a été implémenté dans le fichier ObjectStorageAdapter que nous avons développé durant la partie 2.

Vous pouvez retrouver le fichier à jour ici : object_storage_adapter.rs.

API Content manager

L’API content manager va permettre de définir l’API de notre domaine métier.

Pour cette première version du CDN, les fonctionnalités métier sont :

  • Pouvoir ajouter une version de librairie dans le CDN à partir d’une archive
  • Pouvoir lire le fichier d’une librairie intégrée au CDN
  • Pouvoir lister les fichiers d’une librairie intégrée au CDN

Techniquement, cela ressemble à ça :

#[async_trait]
pub trait ContentManagerAPI: Send + Sync {
    async fn create_package(
        &self,
        name: String,
        version: String,
        file: Vec<u8>,
    ) -> Result<(), Error>;

    async fn get_file(
        &self,
        package_name: String,
        version: String,
        file_name: String,
    ) -> Result<File, Error>;

    async fn get_files(
        &self,
        package_name: String,
        version: String,
        child_dir: Option<String>,
    ) -> Result<ListObjectResult, Error>;
}

Ce trait sera implémenté par notre domaine métier.

Hexagone - Implémentation du domaine

Maintenant que notre API est écrite, nous pouvons passer à l’implémentation de notre domaine métier.

Commençons par déclarer l’instanciation :

pub struct ContentManager {
    content_storage_adapter: Arc<Mutex<dyn ContentStorageAdapter>>,
}

impl ContentManager {
    pub fn new(content_storage_adapter: Arc<Mutex<dyn ContentStorageAdapter>>) -> Self {
        Self {
            content_storage_adapter,
        }
    }
}

#[async_trait]
impl ContentManagerAPI for ContentManager {
	// contenu à venir
}

Notre domaine ContentManager possède une propriété content_storage_adapter typée avec le trait de la SPI créé dans la partie précédente.

Pour faire simple, cela veut dire que le domaine prendra n’importe quelle instance d’adapter qui implémente le trait de la SPI ContentStorageAdapter pour être agnostique.

Si demain, nous souhaitons changer de technologie ou même de librairie, il est possible d’implémenter un nouvel adapter puis de changer l’injection dans le domaine.

Étant donné que les signatures d’entrées et de sorties sont définies, cela n’aura pas d’impact sur le fonctionnement du domaine métier.

Ajout d’une version de librairie à partir d’une archive

Le domaine est posé, commençons avec la méthode la plus complexe à implémenter, l’ajout d’une version de librairie à partir d’une archive.

C’est la fonctionnalité métier qui va avoir le plus de charge sur notre système car nous allons devoir lire les fichiers de l’archive et les pousser dans l’object storage via la méthode add_object de l’adapter.

Nous allons implémenter le traitement en parallélisant les tâches pour gagner en temps d’exécution.

En effet, le temps d’exécution sera cette fois le temps de l’appel à la méthode add_object le plus long vu que nous faisons tous les appels en même temps.

parallel

Vous l’avez deviné, c’est la seconde solution que nous allons implémenter.

Pour cela, nous allons utiliser les librairies suivantes :

  • semver : pour vérifier que la version reçue respecte le semver
  • zip : pour extraire les fichiers de l’archive
  • futures : pour paralléliser les tâches
  • mime_guess : pour récupérer le mime type du fichier

Pour paralléliser les créations de fichiers dans l’object storage, nous allons construire un tableau de futures pour ensuite les exécuter à l’aide de la méthode futures::future::try_join_all;

Cette méthode retourne un tableau de résultat pour s’assurer de la bonne exécution des différents appels.

Ci-dessous la fonction complète :

async fn create_package(
    &self,
    name: String,
    version: String,
    file: Vec<u8>,
) -> Result<(), Error> {
    Version::parse(&version)
        .map_err(|_| anyhow!("The input version doesn't respect semver format"))?;

    let mut archive = ZipArchive::new(Cursor::new(file))?;
    let dirname = format!("{name}@{version}");

    let mut tasks = Vec::new();

    for i in 0..archive.len() {
        let mut child_file = archive.by_index(i)?;
        let mut file_data: Vec<u8> = Vec::new();
        child_file.read_to_end(&mut file_data)?;

        if !file_data.is_empty() {
            let filename: String = format!("{}/{}", dirname, child_file.name());
            let content_type = MimeGuess::from_path(&filename).first_or_text_plain();

            let adapter = Arc::clone(&self.content_storage_adapter);
            let task = tokio::spawn(async move {
                adapter
                    .lock()
                    .await
                    .add_object(&filename, &file_data, content_type.as_ref())
                    .await
            });

            tasks.push(task);
        }
    }

    let results = try_join_all(tasks).await?;

    for result in results {
        match result {
            Ok(_) => (),
            Err(e) => return Err(e),
        }
    }

    Ok(())
}

Lecture des fichiers

Cette fois les méthodes sont très simples car le domaine métier va seulement faire passe plat vers la couche adapter en s’assurant tout de même que la version demandée est bien semver.

async fn get_file(
    &self,
    package_name: String,
    version: String,
    file_name: String,
) -> Result<File, Error> {
    Version::parse(&version)
        .map_err(|_| anyhow!("The input version doesn't respect semver format"))?;
    let path = format!("{}@{}{}", package_name, version, file_name);
    let file: File = self
        .content_storage_adapter
        .lock()
        .await
        .get_object(path)
        .await?;
    Ok(file)
}

async fn get_files(
    &self,
    package_name: String,
    version: String,
    child_dir: Option<String>,
) -> Result<ListObjectResult, Error> {
    Version::parse(&version)
        .map_err(|_| anyhow!("The input version doesn't respect semver format"))?;
    let path = if let Some(child_dir) = &child_dir {
        format!("{}@{}{}", package_name, version, child_dir)
    } else {
        format!("{}@{}/", package_name, version)
    };

    let result = self
        .content_storage_adapter
        .lock()
        .await
        .list_objects(path)
        .await?;

    Ok(result)
}

Création de l’API REST

Le domaine métier étant complet, nous allons pouvoir nous intéresser à la création de l’API REST.

Cette API sera créée à l’aide de la librairie actix-web ainsi que son extension actix_multipart.

Si vous n’avez jamais utilisé actix, n’hésitez pas à consulter leur documentation qui contient tout ce qu’il faut savoir pour créer vos API https://actix.rs/docs/getting-started/.

Déclaration d’une application actix

La déclaration d’une application actix se fait de la façon suivante :

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            .service(hello)
            .service(echo)
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

💡 Les .service sont les déclarations des routes.

Création du contexte (state)

Actix permet de passer un contexte d’application (state) accessible depuis les routes.

C’est dans ce state que nous allons créer l’instance de notre hexagone.

Voici à quoi cela ressemble :

pub struct State {
    pub content_manager: Arc<dyn ContentManagerAPI>,
}

pub fn bootstrap() -> Result<Data<State>, Error> {
    let bucket_name = env::var("BUCKET").expect("expected bucket name");
    let region = env::var("REGION").expect("expected object storage region");
    let endpoint = env::var("ENDPOINT").expect("expected object storage endpoint");
    let access_key = env::var("ACCESS_KEY").expect("expected object storage access key");
    let secret_key = env::var("SECRET_KEY").expect("expected object storage secret key");

    let storage_adapter =
        ObjectStorageAdapter::new(bucket_name, region, endpoint, access_key, secret_key)?;
    let content_manager = ContentManager::new(Arc::new(Mutex::new(storage_adapter)));

    Ok(Data::new(State {
        content_manager: Arc::new(content_manager),
    }))
}

La méthode bootstrap va lire les variables d’environnements avec les informations liées à l’object storage, instancier les couches de notre application puis retourner un state au format attendu par actix.

Mettons à jour la déclaration de notre application avec notre state fraichement créé.

let server = HttpServer::new(move || {
        App::new()
            .app_data(bootstrap().unwrap())
            .service(hello)
            .service(echo)
    })
    .bind(("0.0.0.0", port))?
    .run();

Création des routes API

Il existe plusieurs façons d’écrire des routes avec actix, voici la méthode que nous allons utiliser :

#[get("/")]
async fn hello() -> impl Responder {
    HttpResponse::Ok().body("Hello world!")
}

#[post("/echo")]
async fn echo(req_body: String) -> impl Responder {
    HttpResponse::Ok().body(req_body)
}

💡 L’attribut en header de fonction permet de donner le verbe HTTP ainsi que le path de la route.

C’est bien beau de faire des hello world mais passons aux choses sérieuses !

Route POST /packages

Cette route va servir à insérer un package dans le CDN. La route sera de type POST et ce sera une requête multipart/form-data pour envoyer à la fois l’archive et les informations du package (nom et version).

Tout d’abord, nous devons déclarer la structure correspondant à la requête multipart :

#[derive(MultipartForm)]
struct UploadForm {
    name: Text<String>,
    version: Text<String>,
    file: TempFile,
}

Cette structure utilise un attribut pour dériver automatiquement l’implémentation du trait MultipartForm fourni par actix_multipart.

C’est ce module qui va gérer la partie requête multipart pour notre route.

Notre route va prendre en paramètre l’objet multipart ainsi que l’instance du state actix créé précédemment :

#[post("/packages")]
async fn upload_package(
    payload: MultipartForm<UploadForm>,
    data: web::Data<State>,
) -> impl Responder {
  • Le paramètre payload permet de récupérer le contenu de la requête de l’utilisateur.
  • Le paramètre data permet d’accéder à notre domaine métier pour appeler ses fonctionnalités.

L’objectif ici est d’appeler la méthode create_package de notre domaine.

Voici ce que ça donne une fois implémenté :

#[post("/packages")]
async fn upload_package(
    payload: MultipartForm<UploadForm>,
    data: web::Data<State>,
) -> impl Responder {
    let file_data = fs::read(payload.file.file.path()).unwrap();

    match data
        .content_manager
        .create_package(
            payload.name.to_owned(),
            payload.version.to_owned(),
            file_data,
        )
        .await
    {
        Ok(()) => {
            HttpResponse::Created().body("".to_string())
        }
        Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
    }
}

Nous utilisons le mot clé match pour gérer les cas de succès et d’erreur de l’appel au domaine.

💡 Dans cet exemple, toutes les erreurs sont retournées en 500, mais il faudrait idéalement implémenter une couche de gestion des erreurs pour différencier les erreurs venant des données fournies par l’utilisateur des erreurs serveur.

Route GET /gimme/package

Cette route va servir à récupérer le fichier d’un package du CDN.

C’est cette route que l’on va retrouver dans les différents CDN et qui permet d’inclure une dépendance externe dans un fichier (ex: HTML).

Par exemple :

<link rel="stylesheet" href="https://unpkg.com/@picocss/pico@latest/css/pico.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mgdis/mg-components@5/dist/mg-components/variables.css" />
<script type="module"
    src="https://cdn.jsdelivr.net/npm/@mgdis/mg-components@5/dist/mg-components/mg-components.esm.js"></script>

Cette route va utiliser les extracteurs de path actix pour récupérer les différentes informations dont nous avons besoin.

#[get("/gimme/{package}@{version}{file:.*}")]
pub async fn gimme(
    data: web::Data<State>,
    tera: web::Data<Tera>,
    path: web::Path<(String, String, String)>,
) -> impl Responder {

Cette fois, la partie path de l’attribut nous permet de définir les différentes variables à extraire.

Ces variables seront contenues dans la propriété path de la fonction.

💡 Le file:.* permet de capturer tout ce qui va se trouver après la version dans le path.

Exemple : prenons le path /gimme/mg-components@5.24.0/components/index.js

La partie file contiendra /components/index.js

Cette fois, nous allons appeler la méthode get_file du domaine métier.

Voici ce que ça donne une fois implémenté :

#[get("/gimme/{package}@{version}{file:.*}")]
pub async fn gimme(
    data: web::Data<State>,
    tera: web::Data<Tera>,
    path: web::Path<(String, String, String)>,
) -> impl Responder {
	let (package_name, version, filename) = path.into_inner();
	match data
        .content_manager
        .get_file(package_name, version, filename)
        .await
    {
        Ok(file) => HttpResponse::Ok()
            .insert_header(("Content-Type", file.content_type))
            .body(file.binary),
        Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
    }
}

💡 On récupère le content-type du fichier retourné par le domaine pour le mettre dans les headers de la réponse.

Déclaration des routes

Il ne nous reste plus qu’à déclarer les routes dans notre application actix :

let server = HttpServer::new(move || {
    App::new()
        .app_data(bootstrap().unwrap())
        .service(package_controller::upload_package)
        .service(package_controller::gimme)
    })
    .bind(("0.0.0.0", port))?
    .run();

UI de consultation d’un package

On approche du but ! Plus qu’une page de consultation et nous en aurons fini avec le développement de l’application.

L’objectif ici est d’avoir une page web comme on peut le retrouver par exemple sur JSDelvr pour consulter le contenu d’un package dans une version donnée.

jsdelivr ui

Pour cela, nous allons utiliser le moteur de templating HTML tera.

Tera est assez simple à mettre en place, il permet de charger un répertoire de template lors de la compilation puis se comporte ensuite comme n’importe quel moteur de templating utilisé pour faire du rendu côté serveur.

Chaque template peut utiliser des balises {} permettant d’injecter des données lors du rendu via un objet de contexte Context::new().

Si vous souhaitez voir l’étendue des fonctionnalités fournies par Tera, n’‘hésitez pas à consulter la documentation du projet : https://keats.github.io/tera/docs/#templates.

Template HTML

Commençons par notre template HTML.

Nous allons utiliser les librairies picocss pour la partie style et mg-components développée par mes collègues de MGDIS qui fournit entre autre un web-component d’icône (mg-icon).

💡 N’hésitez pas à lire l’article de Simon Duhem qui parle justement de l’intégration des icônes au sein du design system MGDIS Simplifier l’intégration des icônes depuis Figma : De la conception au design system.

Et voici notre template :

<!DOCTYPE html>
<html data-theme="light">

<head>
  <meta charset='utf-8'>
  <meta http-equiv='X-UA-Compatible' content='IE=edge'>
  <title>{{package_name}} CDN by Gimme</title>
  <meta name='viewport' content='width=device-width, initial-scale=1'>
  <link rel="stylesheet" href="https://unpkg.com/@picocss/pico@latest/css/pico.min.css">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mgdis/mg-components@5/dist/mg-components/variables.css" />
  <script type="module"
    src="https://cdn.jsdelivr.net/npm/@mgdis/mg-components@5/dist/mg-components/mg-components.esm.js"></script>
</head>

<body>
  <header class="container">
    <svg>logo</svg>
  </header>
  <main class="container">
    <h1>{{ package_name }}@{{package_version}} files</h1>
    <div>
      <table>
        <thead>
          <tr>
            <th scope="col">File</th>
            <th scope="col">Size (bytes)</th>
          </tr>
        </thead>
        <tbody>
          {% for dir in directories %}
          <tr id="dir-{{loop.index}}" class="dir">
            <td><mg-icon icon="folder-outline"></mg-icon> <a href="/gimme/{{dir.name}}">{{dir.name}}</a></td>
            <td></td>
          </tr>
          {% endfor %}
          {% for file in files %}
          <tr id="file-{{loop.index}}" class="file">
            <td><mg-icon icon="file-outline"></mg-icon> <a href="/gimme/{{file.name}}">{{file.name}}</a></td>
            <td>{{file.size}}</td>
          </tr>
          {% endfor %}
        </tbody>
      </table>
    </div>
  </main>
</body>

</html>

Le template étant fait, nous pouvons maintenant le déclarer pour qu’il soit utilisable au sein de notre application.

lazy_static! {
    pub static ref TEMPLATES: Tera = {
        let mut tera = Tera::default();
        tera.add_raw_template("package.html", include_str!("../templates/package.html"))
            .expect("Expected template");
        tera.autoescape_on(vec![".html", ".sql"]);
        tera
    };
}

Exposition de l’UI via Actix

Maintenant que le template est déclaré, nous pouvons l’utiliser dans notre application actix.

Pour cela, comme pour le domaine métier, nous allons passer par un state actix.

Voici à quoi ressemble la déclaration de l’application à jour :

let server = HttpServer::new(move || {
    App::new()
        .app_data(web::Data::new(TEMPLATES.clone()))
        .app_data(bootstrap().unwrap())
        .service(package_controller::upload_package)
        .service(package_controller::gimme)
    })
    .bind(("0.0.0.0", port))?
    .run();

Nous pouvons mettre à jour la route GET pour utiliser notre UI.

L’idée est simple : si l’appel GET /gimme/package@version/... est un répertoire, on retourne l’UI. Si c’est un fichier qui est demandé, on retourne le contenu du fichier.

Dans le cas où c’est un répertoire qui est demandé, nous devons appeler la méthode get_files du domaine métier cette fois.

Ci-dessous la partie API actix modifiée qui concerne le rendu HTML :

async fn get_html_package(
    data: web::Data<State>,
    tera: web::Data<Tera>,
    package_name: String,
    version: String,
    filename: Option<String>,
) -> HttpResponse {
    match data
        .content_manager
        .get_files(package_name.clone(), version.clone(), filename.clone())
        .await
    {
        Ok(files) => {
            let mut context = Context::new();
            context.insert("package_name", &package_name);
            context.insert("package_version", &version);
            context.insert("files", &files.objects);
            context.insert("directories", &files.directories);

            let template = tera
                .render("package.html", &context)
                .expect("Fail to render package view");
            HttpResponse::Ok().body(template)
        }
        Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
    }
}

💡 Vous pouvez retrouver le code complet de la route ici : https://gitlab.com/ziggornif/gimme-cdn/-/blob/master/src/controllers/package_controller.rs?ref_type=heads

Test - Upload d’un package

Tout est implémenté, nous pouvons procéder à un test de notre application.

On commence par upload un package via notre route POST /packages :

cdn upload

La route doit retourner un code HTTP 201.

Une fois le package créé, nous pouvons nous rendre sur l’UI pour consulter son contenu :

http://localhost:8080/gimme/mg-components@5.24.0/

Et … ça fonctionne ! 🎉🎉🎉

cdn ui

Nous pouvons également tester l’accès à un fichier :

http://localhost:8080/gimme/mg-components@5.24.0/mg-components/mg-components.esm.js

Ça fonctionne également !

cdn result

Les tests sont donc concluants, nous avons un CDN opérationnel.

Au prochain épisode

L’implémentation du CDN est maintenant terminée, il ne reste plus qu’une seule étape, le déploiement dans le cloud.

Au programme :

  • Déploiement du projet sur Clever Cloud
  • Test du CDN en mode déployé

Vous pouvez retrouver les sources du projet sur Gitlab https://gitlab.com/ziggornif/gimme-cdn.

À la prochaine !

Lien vers la partie 4 : https://blog.ziggornif.xyz/post/cdn-part4/