paint-brush
Comment créer un système de fichiers dynamique avec FUSE et Node.js : une approche pratiquepar@rglr
386 lectures
386 lectures

Comment créer un système de fichiers dynamique avec FUSE et Node.js : une approche pratique

par Aleksandr Zinin33m2024/06/13
Read on Terminal Reader

Trop long; Pour lire

Vous êtes-vous déjà demandé ce qui se passe lorsque vous exécutez sshfs user@remote:~/ /mnt/remoteroot ? Comment les fichiers d'un serveur distant apparaissent-ils sur votre système local et se synchronisent-ils si rapidement ? Avez-vous entendu parler de WikipediaFS, qui vous permet d'éditer un article Wikipédia comme s'il s'agissait d'un fichier dans votre système de fichiers ? Ce n'est pas magique, c'est la puissance de FUSE (Filesystem in Userspace). FUSE vous permet de créer votre propre système de fichiers sans avoir besoin d'une connaissance approfondie du noyau du système d'exploitation ou des langages de programmation de bas niveau. Cet article présente une solution pratique utilisant FUSE avec Node.js et TypeScript. Nous explorerons le fonctionnement de FUSE sous le capot et démontrerons son application en résolvant une tâche du monde réel. Rejoignez-moi dans une aventure passionnante dans le monde de FUSE et Node.js.
featured image - Comment créer un système de fichiers dynamique avec FUSE et Node.js : une approche pratique
Aleksandr Zinin HackerNoon profile picture

Vous êtes-vous déjà demandé ce qui se passe lorsque vous exécutez sshfs user@remote:~/ /mnt/remoteroot ? Comment les fichiers d'un serveur distant apparaissent-ils sur votre système local et se synchronisent-ils si rapidement ? Avez-vous entendu parler de WikipediaFS , qui vous permet d'éditer un article Wikipédia comme s'il s'agissait d'un fichier dans votre système de fichiers ? Ce n'est pas magique, c'est la puissance de FUSE (Filesystem in Userspace). FUSE vous permet de créer votre propre système de fichiers sans avoir besoin d'une connaissance approfondie du noyau du système d'exploitation ou des langages de programmation de bas niveau.


Cet article présente une solution pratique utilisant FUSE avec Node.js et TypeScript. Nous explorerons le fonctionnement de FUSE sous le capot et démontrerons son application en résolvant une tâche du monde réel. Rejoignez-moi dans une aventure passionnante dans le monde de FUSE et Node.js.

Introduction

J'étais responsable des fichiers multimédias (principalement des images) dans mon travail. Cela inclut de nombreux éléments : bannières latérales ou supérieures, médias dans les discussions, autocollants, etc. Bien sûr, il existe de nombreuses exigences pour ceux-ci, telles que "la bannière est PNG ou WEBP, 300 x 1 000 pixels". Si les conditions ne sont pas remplies, notre back-office ne laissera pas passer une image. Et un mécanisme de déduplication des objets est là : aucune image ne peut entrer deux fois dans le même fleuve.


Cela nous amène à une situation où nous disposons d’un ensemble massif d’images à des fins de test. J'ai utilisé des one-liners ou des alias pour me faciliter la vie.


Par exemple:

 convert -size 300x1000 xc:gray +noise random /tmp/out.png


Exemple d'image de bruit


Une combinaison de bash et convert est un excellent outil, mais ce n’est évidemment pas le moyen le plus pratique de résoudre le problème. Discuter de la situation de l'équipe d'assurance qualité révèle d'autres complications. Outre le temps appréciable consacré à la génération des images, la première question lorsque nous étudions un problème est « Êtes-vous sûr d'avoir téléchargé une image unique ? » Je crois que tu comprends à quel point c'est ennuyeux.

Choisissons la technologie que nous aimerions utiliser

Vous pouvez adopter une approche simple : créer un service Web qui dessert un itinéraire avec un fichier explicite, comme GET /image/1000x100/random.zip?imagesCount=100 . L'itinéraire renverrait un fichier ZIP avec un ensemble d'images uniques. Cela semble bien, mais cela ne résout pas notre problème principal : tous les fichiers téléchargés doivent être uniques pour les tests.


Votre prochaine réflexion pourrait être : « Pouvons-nous remplacer une charge utile lors de son envoi ? » L'équipe QA utilise Postman pour les appels API. J'ai enquêté sur les composants internes de Postman et j'ai réalisé que nous ne pouvions pas modifier le corps de la requête "à la volée".


Une autre solution consiste à remplacer un fichier dans le système de fichiers chaque fois que quelque chose tente de lire le fichier. Linux dispose d'un sous-système de notification appelé Inotify, qui vous avertit des événements du système de fichiers tels que les changements de répertoires ou les modifications de fichiers. Si vous obtenez « Visual Studio Code ne parvient pas à surveiller les modifications de fichiers dans ce grand espace de travail », il y a un problème avec Inotify. Il peut déclencher un événement lorsqu'un répertoire est modifié, qu'un fichier est renommé, qu'un fichier est ouvert, etc.


La liste complète des événements est à retrouver ici : https://sites.uclouvain.be/SystInfo/usr/include/linux/inotify.h.html


Le plan est donc :

  1. Écoute de l'événement IN_OPEN et comptage des descripteurs de fichiers.

  2. Écoute de l'événement IN_CLOSE ; si le décompte tombe à 0, nous remplacerons le fichier.


Cela semble bien, mais cela pose quelques problèmes :

  • Seul Linux prend en charge inotify .
  • Les requêtes parallèles au fichier doivent renvoyer les mêmes données.
  • Si un fichier comporte des opérations d'E/S intensives, le remplacement n'aura jamais lieu.
  • Si un service qui sert les événements Inotify tombe en panne, les fichiers resteront dans le système de fichiers utilisateur.


Pour résoudre ces problèmes, nous pouvons écrire notre propre système de fichiers. Mais il y a un autre problème : le système de fichiers standard s'exécute dans l'espace du noyau du système d'exploitation. Cela nous oblige à connaître le noyau du système d’exploitation et à utiliser des langages comme C/Rust. De plus, pour chaque noyau, nous devrions écrire un module spécifique (pilote).


Par conséquent, écrire un système de fichiers est excessif pour le problème que nous voulons résoudre ; même si un long week-end nous attend. Heureusement, il existe un moyen d'apprivoiser cette bête : le système de fichiers dans l'espace utilisateur (FUSE). FUSE est un projet qui vous permet de créer des systèmes de fichiers sans modifier le code du noyau. Cela signifie que tout programme ou script via FUSE, sans aucune logique complexe liée au cœur, est capable d'émuler un flash, un disque dur ou un SSD.


En d’autres termes, un processus d’espace utilisateur ordinaire peut créer son propre système de fichiers, accessible normalement via n’importe quel programme ordinaire de votre choix – Nautilus, Dolphin, ls, etc.


Pourquoi FUSE est-il bon pour répondre à nos besoins ? Les systèmes de fichiers basés sur FUSE sont construits sur des processus espacés par les utilisateurs. Par conséquent, vous pouvez utiliser n’importe quel langage que vous connaissez et qui a une liaison avec libfuse . De plus, vous bénéficiez d'une solution multiplateforme avec FUSE.


J'ai beaucoup d'expérience avec NodeJS et TypeScript, et j'aimerais choisir cette (merveilleuse) combinaison comme environnement d'exécution pour notre tout nouveau FS. De plus, TypeScript fournit une excellente base orientée objet. Cela me permettra de vous montrer non seulement le code source, que vous pouvez retrouver sur le repo public GitHub mais aussi la structure du projet.

Plongez en profondeur dans FUSE

Permettez-moi de vous fournir une citation de la page officielle de FUSE :

FUSE est un framework de système de fichiers en espace utilisateur. Il se compose d'un module noyau (fuse.ko), d'une bibliothèque d'espace utilisateur (libfuse.*) et d'un utilitaire de montage (fusermount).


Un cadre pour écrire des systèmes de fichiers semble passionnant.


Je devrais expliquer ce que signifie chaque partie de FUSE :

  1. fuse.ko effectue toutes les tâches de bas niveau liées au noyau ; cela nous permet d'éviter toute intervention dans un noyau de système d'exploitation.


  2. libfuse est une bibliothèque qui fournit une couche de haut niveau pour la communication avec fuse.ko .


  3. fusermount permet aux utilisateurs de monter/démonter les systèmes de fichiers de l'espace utilisateur (appelez-moi Captain Obvious !).


Les principes généraux ressemblent à ceci :
Les principes généraux de FUSE


Le processus de l'espace utilisateur ( ls dans ce cas) envoie une requête au noyau du système de fichiers virtuel qui achemine la requête vers le module du noyau FUSE. Le module FUSE, à son tour, achemine la requête vers l'espace utilisateur vers la réalisation du système de fichiers ( ./hello dans l'image ci-dessus).


Ne vous laissez pas tromper par le nom du système de fichiers virtuel. Ce n'est pas directement lié au FUSE. C'est la couche logicielle du noyau qui fournit l'interface du système de fichiers aux programmes de l'espace utilisateur. Par souci de simplicité, vous pouvez le percevoir comme un motif composite .


libfuse propose deux types d'API : de haut niveau et de bas niveau. Ils ont des similitudes mais des différences cruciales. Celui de bas niveau est asynchrone et fonctionne uniquement avec inodes . Asynchrone, dans ce cas, signifie qu'un client qui utilise une API de bas niveau doit appeler lui-même les méthodes de réponse.


Celui de haut niveau offre la possibilité d'utiliser des chemins pratiques (par exemple, /etc/shadow ) au lieu d' inodes plus "abstraits" et renvoie les réponses de manière synchronisée. Dans cet article, j'expliquerai comment fonctionne le haut niveau plutôt que le bas niveau et inodes .


Si vous souhaitez implémenter votre propre système de fichiers, vous devez implémenter un ensemble de méthodes responsables des requêtes provenant de VFS. Les méthodes les plus courantes sont :


  • open(path, accessFlags): fd -- ouvre un fichier par chemin. La méthode doit renvoyer un identifiant numérique, appelé descripteur de fichier (à partir de maintenant fd ). Un indicateur d'accès est un masque binaire qui décrit l'opération que le programme client souhaite effectuer (lecture seule, écriture seule, lecture-écriture, exécution ou recherche).


  • read(path, fd, Buffer, size, offset): count of bytes read - lit size octets d'un fichier lié avec le descripteur de fichier fd au tampon transmis. L'argument path est ignoré car nous utiliserons fd.


  • write(path, fd, Buffer, size, offset): count of bytes written -- size d'écriture en octets du Buffer dans un fichier lié avec fd .


  • release(fd) -- ferme le fd .


  • truncate(path, size) -- modifie la taille d'un fichier. La méthode doit être définie si vous souhaitez réécrire des fichiers (et nous le faisons).


  • getattr(path) -- renvoie les paramètres du fichier tels que la taille, la création à, l'accès à, etc. La méthode est la méthode la plus appelable par le système de fichiers, alors assurez-vous de créer la méthode optimale.


  • readdir(path) -- lit tous les sous-répertoires.


Les méthodes ci-dessus sont essentielles pour chaque système de fichiers entièrement opérationnel construit sur l'API FUSE de haut niveau. Mais la liste n’est pas complète ; la liste complète que vous pouvez trouver sur https://libfuse.github.io/doxygen/structfuse__operations.html


Pour revisiter le concept de descripteur de fichier : dans les systèmes de type UNIX, y compris MacOS, un descripteur de fichier est une abstraction pour les fichiers et autres ressources d'E/S telles que les sockets et les canaux. Lorsqu'un programme ouvre un fichier, le système d'exploitation renvoie un identifiant numérique appelé descripteur de fichier. Cet entier sert d'index dans la table des descripteurs de fichiers du système d'exploitation pour chaque processus. Lors de l'implémentation d'un système de fichiers à l'aide de FUSE, nous devrons générer nous-mêmes des descripteurs de fichiers.


Considérons le flux d'appels lorsque le client ouvre un fichier :

  1. getattr(path: /random.png) → { size: 98 }; le client a obtenu la taille du fichier.


  2. open(path: /random.png) → 10; fichier ouvert par chemin ; L'implémentation FUSE renvoie un numéro de descripteur de fichier.


  3. read(path: /random.png, fd: 10 buffer, size: 50, offset: 0) → 50; lire les 50 premiers octets.


  4. read(path: /random.png, fd: 10 buffer, size: 50, offset: 50) → 48; lire les 50 suivants. Les 48 octets ont été lus en raison de la taille du fichier.


  5. release(10); toutes les données ont été lues, si proches du fd.

Écrivons un produit minimum viable et vérifions la réaction du facteur

Notre prochaine étape consiste à développer un système de fichiers minimal basé sur libfuse pour tester comment Postman interagira avec un système de fichiers personnalisé.


Les conditions d'acceptation pour le FS sont simples : la racine du FS doit contenir un fichier random.txt , dont le contenu doit être unique à chaque lecture (appelons cela "lecture toujours unique"). Le contenu doit contenir un UUID aléatoire et une heure actuelle au format ISO, séparés par une nouvelle ligne. Par exemple:

 3790d212-7e47-403a-a695-4d680f21b81c 2012-12-12T04:30:30


Le produit minimal sera composé de deux parties. Le premier est un simple service Web qui acceptera les requêtes HTTP POST et imprimera un corps de requête sur le terminal. Le code est assez simple et ne vaut pas notre temps, principalement parce que l'article concerne FUSE, pas Express. La deuxième partie est la mise en œuvre du système de fichiers qui répond aux exigences. Il ne contient que 83 lignes de code.


Pour le code, nous utiliserons la bibliothèque node-fuse-bindings, qui fournit des liaisons vers l'API de haut niveau de libfuse .


Vous pouvez ignorer le code ci-dessous ; Je vais écrire un résumé du code ci-dessous.

 const crypto = require('crypto'); const fuse = require('node-fuse-bindings'); // MOUNT_PATH is the path where our filesystem will be available. For Windows, this will be a path like 'D://' const MOUNT_PATH = process.env.MOUNT_PATH || './mnt'; function getRandomContent() { const txt = [crypto.randomUUID(), new Date().toISOString(), ''].join('\n'); return Buffer.from(txt); } function main() { // fdCounter is a simple counter that increments each time a file is opened // using this we can get the file content, which is unique for each opening let fdCounter = 0; // fd2ContentMap is a map that stores file content by fd const fd2ContentMap = new Map(); // Postman does not work reliably if we give it a file with size 0 or just the wrong size, // so we precompute the file size // it is guaranteed that the file size will always be the same within one run, so there will be no problems with this const randomTxtSize = getRandomContent().length; // fuse.mount is a function that mounts the filesystem fuse.mount( MOUNT_PATH, { readdir(path, cb) { console.log('readdir(%s)', path); if (path === '/') { return cb(0, ['random.txt']); } return cb(0, []); }, getattr(path, cb) { console.log('getattr(%s)', path); if (path === '/') { return cb(0, { // mtime is the file modification time mtime: new Date(), // atime is the file access time atime: new Date(), // ctime is the metadata or file content change time ctime: new Date(), size: 100, // mode is the file access flags // this is a mask that defines access rights to the file for different types of users // and the type of file itself mode: 16877, // file owners // in our case, it will be the owner of the current process uid: process.getuid(), gid: process.getgid(), }); } if (path === '/random.txt') { return cb(0, { mtime: new Date(), atime: new Date(), ctime: new Date(), size: randomTxtSize, mode: 33188, uid: process.getuid(), gid: process.getgid(), }); } cb(fuse.ENOENT); }, open(path, flags, cb) { console.log('open(%s, %d)', path, flags); if (path !== '/random.txt') return cb(fuse.ENOENT, 0); const fd = fdCounter++; fd2ContentMap.set(fd, getRandomContent()); cb(0, fd); }, read(path, fd, buf, len, pos, cb) { console.log('read(%s, %d, %d, %d)', path, fd, len, pos); const buffer = fd2ContentMap.get(fd); if (!buffer) { return cb(fuse.EBADF); } const slice = buffer.slice(pos, pos + len); slice.copy(buf); return cb(slice.length); }, release(path, fd, cb) { console.log('release(%s, %d)', path, fd); fd2ContentMap.delete(fd); cb(0); }, }, function (err) { if (err) throw err; console.log('filesystem mounted on ' + MOUNT_PATH); }, ); } // Handle the SIGINT signal separately to correctly unmount the filesystem // Without this, the filesystem will not be unmounted and will hang in the system // If for some reason unmount was not called, you can forcibly unmount the filesystem using the command // fusermount -u ./MOUNT_PATH process.on('SIGINT', function () { fuse.unmount(MOUNT_PATH, function () { console.log('filesystem at ' + MOUNT_PATH + ' unmounted'); process.exit(); }); }); main();


Je suggère de rafraîchir nos connaissances sur les bits d'autorisation dans un fichier. Les bits d'autorisation sont un ensemble de bits associés à un fichier ; ils sont une représentation binaire de qui est autorisé à lire/écrire/exécuter le fichier. « Qui » comprend trois groupes : le propriétaire, le groupe de propriétaires et autres.


Les autorisations peuvent être définies pour chaque groupe séparément. Habituellement, chaque autorisation est représentée par un nombre à trois chiffres : lecture (4 ou « 100 » dans le système de nombres binaires), écriture (2 ou « 010 ») et exécution (1 ou « 001 »). Si vous additionnez ces numéros, vous créerez une autorisation combinée. Par exemple, 4 + 2 (ou « 100 » + « 010 ») feront 6 (« 110 »), ce qui signifie une autorisation de lecture + écriture (RO).


Si le propriétaire du fichier a un masque d'accès de 7 (111 en binaire, signifiant lecture, écriture et exécution), le groupe en a 5 (101, signifiant lecture et exécution) et les autres en ont 4 (100, signifiant lecture seule). Le masque d'accès complet au fichier est donc 754 en décimal. Gardez à l’esprit que l’autorisation d’exécution devient une autorisation de lecture pour les répertoires.


Revenons à l'implémentation du système de fichiers et créons une version texte de ceci : chaque fois qu'un fichier est ouvert (via un appel open ), le compteur entier s'incrémente, produisant le descripteur de fichier renvoyé par l'appel open. Le contenu aléatoire est ensuite créé et enregistré dans un magasin clé-valeur avec le descripteur de fichier comme clé. Lorsqu'un appel de lecture est effectué, la partie de contenu correspondante est renvoyée.


Lors d'un appel de libération, le contenu est supprimé. N'oubliez pas de gérer SIGINT pour démonter le système de fichiers après avoir appuyé sur Ctrl+C. Sinon, nous devrons le faire manuellement dans le terminal en utilisant fusermount -u ./MOUNT_PATH .


Maintenant, passez aux tests. Nous exécutons le serveur Web, puis créons un dossier vide en tant que dossier racine pour le prochain FS et exécutons le script principal. Une fois la ligne "Serveur à l'écoute sur le port 3000" imprimée, ouvrez Postman et envoyez quelques requêtes consécutives au serveur Web sans modifier aucun paramètre.
Le côté gauche est le FS, celui de droite est le serveur Web


Tout a l'air bien ! Chaque demande a un contenu de fichier unique, comme nous l'avions prévu. Les journaux prouvent également que le flux d’appels d’ouverture de fichiers décrit ci-dessus dans la section « Présentation approfondie de FUSE » est correct.


Le dépôt GitHub avec MVP : https://github.com/pinkiesky/node-fuse-mvp . Vous pouvez exécuter ce code sur votre environnement local ou utiliser ce référentiel comme modèle pour votre propre implémentation de système de fichiers.

L'idée de base

L'approche est vérifiée : il est maintenant temps de procéder à la mise en œuvre primaire.


Avant l'implémentation de la "lecture toujours unique", la première chose que nous devons implémenter est de créer et de supprimer des opérations pour les fichiers originaux. Nous implémenterons cette interface via un répertoire au sein de notre système de fichiers virtuel. L'utilisateur mettra les images originales qu'il souhaite rendre "toujours uniques" ou "randomisées", et le système de fichiers préparera le reste.


Ici et dans les sections suivantes, « lecture toujours unique », « image aléatoire » ou « fichier aléatoire » fait référence à un fichier qui renvoie un contenu unique au sens binaire à chaque fois qu'il est lu, tout en restant visuellement aussi similaire que possible. à l'original.


La racine du système de fichiers contiendra deux répertoires : Image Manager et Images. Le premier est un dossier permettant de gérer les fichiers originaux de l'utilisateur (vous pouvez le considérer comme un référentiel CRUD). Le second est le répertoire non géré du point de vue de l'utilisateur qui contient des images aléatoires.
L'utilisateur interagit avec le système de fichiers


Arbre FS comme sortie du terminal


Comme vous pouvez le voir sur l'image ci-dessus, nous allons également implémenter non seulement des images « toujours uniques » mais aussi un convertisseur de fichiers ! C'est un bonus supplémentaire.


L'idée centrale de notre implémentation est que le programme contiendra une arborescence d'objets, chaque nœud et chaque feuille fournissant des méthodes FUSE communes. Lorsque le programme reçoit un appel FS, il doit trouver un nœud ou une feuille dans l'arborescence par le chemin correspondant. Par exemple, le programme reçoit l'appel getattr(/Images/1/original/) puis essaie de trouver le nœud auquel le chemin est adressé.


Quelque chose comme ça: Exemple d'arborescence FS


La question suivante est de savoir comment nous allons stocker les images originales. Une image dans le programme sera composée de données binaires et de méta-informations (une méta inclut un nom de fichier original, un type MIME de fichier, etc.). Les données binaires seront stockées dans un stockage binaire. Simplifions-le et construisons le stockage binaire comme un ensemble de fichiers binaires dans le système de fichiers de l'utilisateur (ou de l'hôte). Les méta-informations seront stockées de la même manière : JSON dans les fichiers texte du système de fichiers utilisateur.


Comme vous vous en souvenez peut-être, dans la section « Écrivons un produit minimum viable », nous avons créé un système de fichiers qui renvoie un fichier texte par un modèle. Il contient un UUID aléatoire plus une date actuelle, donc l'unicité des données n'était pas le problème : l'unicité a été obtenue par la définition des données. Cependant, à partir de ce moment, le programme devrait fonctionner avec des images utilisateur préchargées. Alors, comment créer des images similaires mais toujours uniques (en termes d'octets et donc de hachages) à partir de l'originale ?


La solution que je propose est assez simple. Plaçons un carré de bruit RVB dans le coin supérieur gauche d'une image. Le carré de bruit doit mesurer 16 x 16 pixels. Cela donne presque la même image mais garantit une séquence d'octets unique. Est-ce que cela suffira à garantir beaucoup d’images différentes ? Faisons quelques calculs. La taille du carré est de 16. 16×16 = 256 pixels RVB dans un seul carré. Chaque pixel a 256×256×256 = 16 777 216 variantes.


Ainsi, le nombre de carrés uniques est de 16 777 216^256 – un nombre à 1 558 chiffres, ce qui est bien plus que le nombre d’atomes dans l’univers observable. Cela signifie-t-il que nous pouvons réduire la taille du carré ? Malheureusement, la compression avec perte comme JPEG réduirait considérablement le nombre de carrés uniques, donc 16x16 est la taille optimale.


Exemple d'images avec des carrés de bruit

Passthrough sur les classes

L'arbre
Diagramme de classes UML montrant les interfaces et les classes pour un système basé sur FUSE. Comprend les interfaces IFUSEHandler, ObjectTreeNode et IFUSETreeNode, avec FileFUSETreeNode et DirectoryFUSETreeNode implémentant IFUSETreeNode. Chaque interface et classe répertorie les attributs et les méthodes, illustrant leurs relations et leur hiérarchie.

IFUSEHandler est une interface qui sert les appels FUSE courants. Vous pouvez voir que j'ai remplacé read/write par readAll/writeAll , respectivement. J'ai fait cela pour simplifier les opérations de lecture et d'écriture : lorsque IFUSEHandler effectue la lecture/écriture pour une partie entière, nous pouvons déplacer la logique de lecture/écriture partielle vers un autre endroit. Cela signifie que IFUSEHandler n'a pas besoin de connaître les descripteurs de fichiers, les données binaires, etc.


La même chose s’est également produite avec la méthode open FUSE. Un aspect notable de l’arborescence est qu’elle est générée à la demande. Au lieu de stocker l'intégralité de l'arborescence en mémoire, le programme crée des nœuds uniquement lors de l'accès à ceux-ci. Ce comportement permet au programme d'éviter un problème de reconstruction de l'arborescence en cas de création ou de suppression de nœuds.


Vérifiez l'interface ObjectTreeNode et vous constaterez que children ne sont pas un tableau mais une méthode, c'est donc ainsi qu'ils sont générés à la demande. FileFUSETreeNode et DirectoryFUSETreeNode sont des classes abstraites dans lesquelles certaines méthodes génèrent une erreur NotSupported (évidemment, FileFUSETreeNode ne devrait jamais implémenter readdir ).

FUSEFaçade

Diagramme de classes UML montrant les interfaces et leurs relations pour un système FUSE. Le diagramme inclut les interfaces IFUSEHandler, IFUSETreeNode, IFileDescriptorStorage et la classe FUSEFacade. IFUSEHandler a des attributs nom et méthodes checkAvailability, create, getattr, readAll, Remove et writeAll. IFileDescriptorStorage a les méthodes get, openRO, openWO et release. IFUSETreeNode étend IFUSEHandler. FUSEFacade comprend les méthodes constructor, create, getattr, open, read, readdir, release, rmdir, safeGetNode, unlink et write, et interagit avec IFUSETreeNode et IFileDescriptorStorage.


FUSEFacade est la classe la plus cruciale qui implémente la logique principale du programme et relie les différentes parties entre elles. node-fuse-bindings a une API basée sur le rappel, mais les méthodes FUSEFacade sont créées avec une API basée sur la promesse. Pour remédier à cet inconvénient, j'ai utilisé un code comme celui-ci :

 const handleResultWrapper = <T>( promise: Promise<T>, cb: (err: number, result: T) => void, ) => { promise .then((result) => { cb(0, result); }) .catch((err) => { if (err instanceof FUSEError) { fuseLogger.info(`FUSE error: ${err}`); return cb(err.code, null as T); } fuseLogger.warn(err); cb(fuse.EIO, null as T); }); }; // Ex. usage: // open(path, flags, cb) { // handleResultWrapper(fuseFacade.open(path, flags), cb); // },


Les méthodes FUSEFacade sont encapsulées dans handleResultWrapper . Chaque méthode de FUSEFacade qui utilise un chemin analyse simplement le chemin, trouve un nœud dans l'arborescence et appelle la méthode demandée.


Considérez quelques méthodes de la classe FUSEFacade .

 async create(path: string, mode: number): Promise<number> { this.logger.info(`create(${path})`); // Convert path `/Image Manager/1/image.jpg` in // `['Image Manager', '1', 'image.jpg']` // splitPath will throw error if something goes wrong const parsedPath = this.splitPath(path); // `['Image Manager', '1', 'image.jpg']` const name = parsedPath.pop()!; // 'image.jpg' // Get node by path (`/Image Manager/1` after `pop` call) // or throw an error if node not found const node = await this.safeGetNode(parsedPath); // Call the IFUSEHandler method. Pass only a name, not a full path! await node.create(name, mode); // Create a file descriptor const fdObject = this.fdStorage.openWO(); return fdObject.fd; } async readdir(path: string): Promise<string[]> { this.logger.info(`readdir(${path})`); const node = await this.safeGetNode(path); // As you see, the tree is generated on the fly return (await node.children()).map((child) => child.name); } async open(path: string, flags: number): Promise<number> { this.logger.info(`open(${path}, ${flags})`); const node = await this.safeGetNode(path); // A leaf node is a directory if (!node.isLeaf) { throw new FUSEError(fuse.EACCES, 'invalid path'); } // Usually checkAvailability checks access await node.checkAvailability(flags); // Get node content and put it in created file descriptor const fileData: Buffer = await node.readAll(); // fdStorage is IFileDescriptorStorage, we will consider it below const fdObject = this.fdStorage.openRO(fileData); return fdObject.fd; }

Un descripteur de fichier

Avant de passer à l'étape suivante, examinons de plus près ce qu'est un descripteur de fichier dans le contexte de notre programme.

Diagramme de classes UML montrant les interfaces et leurs relations pour les descripteurs de fichiers dans un système FUSE. Le diagramme comprend les interfaces IFileDescriptor, IFileDescriptorStorage et les classes ReadWriteFileDescriptor, ReadFileDescriptor et WriteFileDescriptor. IFileDescriptor a les attributs binaire, fd, taille et les méthodes readToBuffer, writeToBuffer. IFileDescriptorStorage a les méthodes get, openRO, openWO et release. ReadWriteFileDescriptor implémente IFileDescriptor avec des méthodes de constructeur supplémentaires, readToBuffer et writeToBuffer. ReadFileDescriptor et WriteFileDescriptor étendent ReadWriteFileDescriptor, avec ReadFileDescriptor ayant une méthode writeToBuffer et WriteFileDescriptor ayant une méthode readToBuffer

ReadWriteFileDescriptor est une classe qui stocke les descripteurs de fichiers sous forme de nombres et les données binaires sous forme de tampon. La classe possède les méthodes readToBuffer et writeToBuffer qui offrent la possibilité de lire et d'écrire des données dans un tampon de descripteur de fichier. ReadFileDescriptor et WriteFileDescriptor sont des implémentations de descripteurs en lecture seule et en écriture seule.


IFileDescriptorStorage est une interface qui décrit le stockage des descripteurs de fichiers. Le programme n'a qu'une seule implémentation pour cette interface : InMemoryFileDescriptorStorage . Comme son nom l'indique, il stocke les descripteurs de fichiers en mémoire car nous n'avons pas besoin de persistance pour les descripteurs.


Voyons comment FUSEFacade utilise les descripteurs de fichiers et le stockage :

 async read( fd: number, // File descriptor to read from buf: Buffer, // Buffer to store the read data len: number, // Length of data to read pos: number, // Position in the file to start reading from ): Promise<number> { // Retrieve the file descriptor object from storage const fdObject = this.fdStorage.get(fd); if (!fdObject) { // If the file descriptor is invalid, throw an error throw new FUSEError(fuse.EBADF, 'invalid fd'); } // Read data into the buffer and return the number of bytes read return fdObject.readToBuffer(buf, len, pos); } async write( fd: number, // File descriptor to write to buf: Buffer, // Buffer containing the data to write len: number, // Length of data to write pos: number, // Position in the file to start writing at ): Promise<number> { // Retrieve the file descriptor object from storage const fdObject = this.fdStorage.get(fd); if (!fdObject) { // If the file descriptor is invalid, throw an error throw new FUSEError(fuse.EBADF, 'invalid fd'); } // Write data from the buffer and return the number of bytes written return fdObject.writeToBuffer(buf, len, pos); } async release(path: string, fd: number): Promise<0> { // Retrieve the file descriptor object from storage const fdObject = this.fdStorage.get(fd); if (!fdObject) { // If the file descriptor is invalid, throw an error throw new FUSEError(fuse.EBADF, 'invalid fd'); } // Safely get the node corresponding to the file path const node = await this.safeGetNode(path); // Write all the data from the file descriptor object to the node await node.writeAll(fdObject.binary); // Release the file descriptor from storage this.fdStorage.release(fd); // Return 0 indicating success return 0; }


Le code ci-dessus est simple. Il définit des méthodes pour lire, écrire et libérer des descripteurs de fichiers, garantissant ainsi que le descripteur de fichier est valide avant d'effectuer des opérations. La méthode de libération écrit également les données d'un objet descripteur de fichier sur le nœud du système de fichiers et libère le descripteur de fichier.


Nous en avons fini avec le code autour libfuse et de l'arbre. Il est temps de plonger dans le code lié aux images.

Images : partie « Objet de transfert de données »
Diagramme de classes UML montrant les interfaces et leurs relations pour la gestion des images. Le diagramme comprend les interfaces ImageBinary, ImageMeta, Image et IImageMetaStorage. ImageBinary a les attributs buffer et size. ImageMeta a les attributs id, name, originalFileName et originalFileType. L'image a les attributs binaire et méta, où binaire est de type ImageBinary et méta est de type ImageMeta. IImageMetaStorage a des méthodes de création, d'obtention, de liste et de suppression


ImageMeta est un objet qui stocke des méta-informations sur une image. IImageMetaStorage est une interface qui décrit un stockage pour les méta. Le programme n'a qu'une seule implémentation pour l'interface : la classe FSImageMetaStorage implémente l'interface IImageMetaStorage pour gérer les métadonnées d'image stockées dans un seul fichier JSON.


Il utilise un cache pour stocker les métadonnées en mémoire et garantit que le cache est hydraté en lisant le fichier JSON en cas de besoin. La classe fournit des méthodes pour créer, récupérer, répertorier et supprimer les métadonnées d’image, et réécrit les modifications dans le fichier JSON pour conserver les mises à jour. Le cache améliore les performances en réduisant le nombre d'opérations d'E/S.


ImageBinary , évidemment, est un objet qui contient des données d'image binaires. L'interface Image est la composition de ImageMeta et ImageBinary .

Images : stockage binaire et générateurs

Diagramme de classes UML montrant les interfaces et leurs relations pour la génération d'images et le stockage binaire. Le diagramme comprend les interfaces IBinaryStorage, IImageGenerator et les classes FSBinaryStorage, ImageGeneratorComposite, PassThroughImageGenerator, TextImageGenerator et ImageLoaderFacade. IBinaryStorage a des méthodes de chargement, de suppression et d'écriture. FSBinaryStorage implémente IBinaryStorage et possède un constructeur supplémentaire. IImageGenerator a une méthode de génération. PassThroughImageGenerator et TextImageGenerator implémentent IImageGenerator. ImageGeneratorComposite a les méthodes addGenerator et generate. ImageLoaderFacade possède un constructeur et une méthode de chargement, et interagit avec IBinaryStorage et IImageGenerator


IBinaryStorage est une interface de stockage de données binaires. Le stockage binaire doit être dissocié des images et peut stocker n'importe quelle donnée : images, vidéo, JSON ou texte. Ce fait est important pour nous et vous comprendrez pourquoi.


IImageGenerator est une interface qui décrit un générateur. Le générateur est une partie importante du programme. Il prend des données binaires brutes plus des méta et génère une image basée sur celles-ci. Pourquoi le programme a-t-il besoin de générateurs ? Le programme peut-il fonctionner sans eux ?


C’est possible, mais les générateurs ajouteront de la flexibilité à la mise en œuvre. Les générateurs permettent aux utilisateurs de télécharger des images, des données textuelles et, d'une manière générale, toutes les données pour lesquelles vous écrivez un générateur.


Diagramme montrant le processus de conversion d'un fichier texte en image à l'aide de l'interface IImageGenerator. Sur la gauche, il y a une icône pour un fichier texte intitulé « monfichier.txt » avec le contenu « Bonjour tout le monde ! ». Une flèche intitulée « IImageGenerator » pointe vers la droite, où se trouve une icône pour un fichier image intitulé « myfile.png » avec le même texte « Bonjour tout le monde ! » affiché dans l'image


Le flux est le suivant : les données binaires sont chargées depuis le stockage ( myfile.txt dans l'image ci-dessus), puis le binaire passe à un générateur. Il génère une image « à la volée ». Vous pouvez le percevoir comme un convertisseur d'un format à un autre, ce qui nous convient le mieux.


Voyons un exemple de générateur :

 import { createCanvas } from 'canvas'; // Import createCanvas function from the canvas library to create and manipulate images const IMAGE_SIZE_RE = /(\d+)x(\d+)/; // Regular expression to extract width and height dimensions from a string export class TextImageGenerator implements IImageGenerator { // method to generate an image from text async generate(meta: ImageMeta, rawBuffer: Buffer): Promise<Image | null> { // Step 1: Verify the MIME type is text if (meta.originalFileType !== MimeType.TXT) { // If the file type is not text, return null indicating no image generation return null; } // Step 2: Determine the size of the image const imageSize = { width: 800, // Default width height: 600, // Default height }; // Extract dimensions from the name if present const imageSizeRaw = IMAGE_SIZE_RE.exec(meta.name); if (imageSizeRaw) { // Update the width and height based on extracted values, or keep defaults imageSize.width = Number(imageSizeRaw[1]) || imageSize.width; imageSize.height = Number(imageSizeRaw[2]) || imageSize.height; } // Step 3: Convert the raw buffer to a string to get the text content const imageText = rawBuffer.toString('utf-8'); // Step 4: Create a canvas with the determined size const canvas = createCanvas(imageSize.width, imageSize.height); const ctx = canvas.getContext('2d'); // Get the 2D drawing context // Step 5: Prepare the canvas background ctx.fillStyle = '#000000'; // Set fill color to black ctx.fillRect(0, 0, imageSize.width, imageSize.height); // Fill the entire canvas with the background color // Step 6: Draw the text onto the canvas ctx.textAlign = 'start'; // Align text to the start (left) ctx.textBaseline = 'top'; // Align text to the top ctx.fillStyle = '#ffffff'; // Set text color to white ctx.font = '30px Open Sans'; // Set font style and size ctx.fillText(imageText, 10, 10); // Draw the text with a margin // Step 7: Convert the canvas to a PNG buffer and create the Image object return { meta, // Include the original metadata binary: { buffer: canvas.toBuffer('image/png'), // Convert canvas content to a PNG buffer }, }; } }


La classe ImageLoaderFacade est une façade qui combine logiquement le stockage et le générateur ; en d'autres termes, elle implémente le flux que vous avez lu ci-dessus.

Images : variantes

Diagramme de classes UML montrant les interfaces et leurs relations pour la génération d'images et le stockage binaire. Le diagramme comprend les interfaces IBinaryStorage, IImageGenerator et les classes FSBinaryStorage, ImageGeneratorComposite, PassThroughImageGenerator, TextImageGenerator et ImageLoaderFacade. IBinaryStorage a des méthodes de chargement, de suppression et d'écriture. FSBinaryStorage implémente IBinaryStorage et possède un constructeur supplémentaire. IImageGenerator a une méthode de génération. PassThroughImageGenerator et TextImageGenerator implémentent IImageGenerator. ImageGeneratorComposite a les méthodes addGenerator et generate. ImageLoaderFacade possède un constructeur et une méthode de chargement, et interagit avec IBinaryStorage et IImageGenerator


IImageVariant est une interface permettant de créer diverses variantes d'image. Dans ce contexte, une variante est une image générée "à la volée" qui sera affichée à l'utilisateur lors de la visualisation des fichiers dans notre système de fichiers. La principale différence avec les générateurs est qu'ils prennent une image en entrée plutôt que des données brutes.


Le programme comporte trois variantes : ImageAlwaysRandom , ImageOriginalVariant et ImageWithText . ImageAlwaysRandom renvoie l'image originale avec un carré de bruit RVB aléatoire.


 export class ImageAlwaysRandomVariant implements IImageVariant { // Define a constant for the size of the random square edge in pixels private readonly randomSquareEdgeSizePx = 16; // Constructor takes the desired output format for the image constructor(private readonly outputFormat: ImageFormat) {} // Asynchronous method to generate a random variant of an image async generate(image: Image): Promise<ImageBinary> { // Step 1: Load the image using the sharp library const sharpImage = sharp(image.binary.buffer); // Step 2: Retrieve metadata and raw buffer from the image const metadata = await sharpImage.metadata(); // Get image metadata const buffer = await sharpImage.raw().toBuffer(); // Get raw pixel data // the buffer size is plain array with size of image width * image height * channels count (3 or 4) // Step 3: Apply random pixel values to a small square region in the image for (let y = 0; y < this.randomSquareEdgeSizePx; y++) { for (let x = 0; x < this.randomSquareEdgeSizePx; x++) { // Calculate the buffer offset for the current pixel const offset = y * metadata.width! * metadata.channels! + x * metadata.channels!; // Set random values for RGB channels buffer[offset + 0] = randInt(0, 255); // Red channel buffer[offset + 1] = randInt(0, 255); // Green channel buffer[offset + 2] = randInt(0, 255); // Blue channel // If the image has an alpha channel, set it to 255 (fully opaque) if (metadata.channels === 4) { buffer[offset + 3] = 255; // Alpha channel } } } // Step 4: Create a new sharp image from the modified buffer and convert it to the desired format const result = await sharp(buffer, { raw: { width: metadata.width!, height: metadata.height!, channels: metadata.channels!, }, }) .toFormat(this.outputFormat) // Convert to the specified output format .toBuffer(); // Get the final image buffer // Step 5: Return the generated image binary data return { buffer: result, // Buffer containing the generated image }; } }


J'utilise la bibliothèque sharp comme moyen le plus pratique d'opérer sur des images dans NodeJS : https://github.com/lovell/sharp .


ImageOriginalVariant renvoie une image sans aucune modification (mais il peut renvoyer une image dans un format de compression différent). ImageWithText renvoie une image avec du texte écrit dessus. Cela sera utile lorsque nous créerons des variantes prédéfinies d'une seule image. Par exemple, si nous avons besoin de 10 variations aléatoires d’une image, nous devons distinguer ces variations les unes des autres.


La solution ici est de créer 10 images basées sur l'original, où nous rendons un nombre séquentiel de 0 à 9 dans le coin supérieur gauche de chaque image.

Une séquence d'images montrant un chat blanc et noir aux yeux écarquillés. Les images sont étiquetées avec des nombres commençant par 0 à gauche, incrémentés de 1 et continuant par des points de suspension jusqu'à 9 à droite. L'expression du chat reste la même dans chaque image


ImageCacheWrapper a un objectif différent de celui des variantes et agit comme un wrapper en mettant en cache les résultats de la classe IImageVariant particulière. Il sera utilisé pour envelopper des entités qui ne changent pas, comme un convertisseur d'image, des générateurs de texte en image, etc. Ce mécanisme de mise en cache permet une récupération plus rapide des données, principalement lorsque les mêmes images sont lues plusieurs fois.


Eh bien, nous avons couvert toutes les parties principales du programme. Il est temps de tout combiner.

L'arborescence

Diagramme de classes UML montrant la hiérarchie et les relations entre les différents nœuds de l'arborescence FUSE liés à la gestion des images. Les classes incluent ImageVariantFileFUSETreeNode, ImageCacheWrapper, ImageItemAlwaysRandomDirFUSETreeNode, ImageItemOriginalDirFUSETreeNode, ImageItemCounterDirFUSETreeNode, ImageManagerItemFileFUSETreeNode, ImageItemDirFUSETreeNode, ImageManagerDirFUSETreeNode, ImagesDirFUSETreeNode et RootDirFUSETreeNode. Chaque classe possède des attributs et des méthodes pertinents pour les métadonnées d'image, les données binaires et les opérations sur les fichiers telles que create, readAll, writeAll, delete et getattr.


Le diagramme de classes ci-dessous représente la façon dont les classes d'arbres sont combinées avec leurs homologues d'image. Le diagramme doit être lu de bas en haut. RootDir (permettez-moi d'éviter le suffixe FUSETreeNode dans les noms) est le répertoire racine du système de fichiers que le programme implémente. En passant à la rangée supérieure, voyez deux répertoires : ImagesDir et ImagesManagerDir . ImagesManagerDir contient la liste des images utilisateur et permet de les contrôler. Ensuite, ImagesManagerItemFile est un nœud pour un fichier particulier. Cette classe implémente les opérations CRUD.


Considérez ImagesManagerDir comme une implémentation habituelle d'un nœud :

 class ImageManagerDirFUSETreeNode extends DirectoryFUSETreeNode { name = 'Image Manager'; // Name of the directory constructor( private readonly imageMetaStorage: IImageMetaStorage, private readonly imageBinaryStorage: IBinaryStorage, ) { super(); // Call the parent class constructor } async children(): Promise<IFUSETreeNode[]> { // Dynamically create child nodes // In some cases, dynamic behavior can be problematic, requiring a cache of child nodes // to avoid redundant creation of IFUSETreeNode instances const list = await this.imageMetaStorage.list(); return list.map( (meta) => new ImageManagerItemFileFUSETreeNode( this.imageMetaStorage, this.imageBinaryStorage, meta, ), ); } async create(name: string, mode: number): Promise<void> { // Create a new image metadata entry await this.imageMetaStorage.create(name); } async getattr(): Promise<Stats> { return { // File modification date mtime: new Date(), // File last access date atime: new Date(), // File creation date // We do not store dates for our images, // so we simply return the current date ctime: new Date(), // Number of links nlink: 1, size: 100, // File access flags mode: FUSEMode.directory( FUSEMode.ALLOW_RWX, // Owner access rights FUSEMode.ALLOW_RX, // Group access rights FUSEMode.ALLOW_RX, // Access rights for all others ), // User ID of the file owner uid: process.getuid ? process.getuid() : 0, // Group ID for which the file is accessible gid: process.getgid ? process.getgid() : 0, }; } // Explicitly forbid deleting the 'Images Manager' folder remove(): Promise<void> { throw FUSEError.accessDenied(); } }


À l'avenir, ImagesDir contient des sous-répertoires nommés d'après les images de l'utilisateur. ImagesItemDir est responsable de chaque répertoire. Il comprend toutes les variantes disponibles ; comme vous vous en souvenez, le nombre de variantes est de trois. Chaque variante est un répertoire qui contient les fichiers image finaux dans différents formats (actuellement : jpeg, png et webm). ImagesItemOriginalDir et ImagesItemCounterDir enveloppent toutes les instances ImageVariantFile générées dans un cache.


Ceci est nécessaire pour éviter un réencodage constant des images originales, car l’encodage consomme beaucoup de CPU. En haut du diagramme se trouve le ImageVariantFile . C'est le joyau de l'implémentation et de la composition des IFUSEHandler et IImageVariant décrits précédemment. C’est vers ce dossier que tous nos efforts ont été déployés.

Essai

Testons comment le système de fichiers final gère les requêtes parallèles vers le même fichier. Pour ce faire, nous exécuterons l'utilitaire md5sum dans plusieurs threads, qui lira les fichiers du système de fichiers et calculera leurs hachages. Ensuite, nous comparerons ces hachages. Si tout fonctionne correctement, les hachages devraient être différents.

 #!/bin/bash # Loop to run the md5sum command 5 times in parallel for i in {1..5} do echo "Run $i..." # `&` at the end of the command runs it in the background md5sum ./mnt/Images/2020-09-10_22-43/always_random/2020-09-10_22-43.png & done echo 'wait...' # Wait for all background processes to finish wait


J'ai exécuté le script et vérifié le résultat suivant (un peu nettoyé pour plus de clarté) :

 Run 1... Run 2... Run 3... Run 4... Run 5... wait... bcdda97c480db74e14b8779a4e5c9d64 0954d3b204c849ab553f1f5106d576aa 564eeadfd8d0b3e204f018c6716c36e9 73a92c5ef27992498ee038b1f4cfb05e 77db129e37fdd51ef68d93416fec4f65


Excellent! Tous les hachages sont différents, ce qui signifie que le système de fichiers renvoie une image unique à chaque fois !

Conclusion

J'espère que cet article vous a inspiré pour écrire votre propre implémentation de FUSE. N'oubliez pas que le code source de ce projet est disponible ici : https://github.com/pinkiesky/node-fuse-images .


Le système de fichiers que nous avons construit est simplifié pour démontrer les principes fondamentaux du travail avec FUSE et Node.js. Par exemple, il ne prend pas en compte les dates correctes. Il y a beaucoup de place à l’amélioration. Imaginez ajouter des fonctionnalités telles que l'extraction d'images à partir de fichiers GIF utilisateur, le transcodage vidéo ou même la parallélisation de tâches via des travailleurs.


Or, le parfait est l’ennemi du bien. Commencez avec ce que vous avez, faites-le fonctionner, puis répétez. Bon codage !