paint-brush
So erstellen Sie ein dynamisches Dateisystem mit FUSE und Node.js: Ein praktischer Ansatzvon@rglr
290 Lesungen

So erstellen Sie ein dynamisches Dateisystem mit FUSE und Node.js: Ein praktischer Ansatz

von Aleksandr Zinin33m2024/06/13
Read on Terminal Reader

Zu lang; Lesen

Haben Sie sich schon einmal gefragt, was passiert, wenn Sie sshfs user@remote:~/ /mnt/remoteroot ausführen? Wie erscheinen Dateien von einem Remote-Server auf Ihrem lokalen System und werden so schnell synchronisiert? Haben Sie schon von WikipediaFS gehört, mit dem Sie einen Wikipedia-Artikel bearbeiten können, als wäre er eine Datei in Ihrem Dateisystem? Das ist keine Zauberei – es ist die Leistung von FUSE (Filesystem in Userspace). Mit FUSE können Sie Ihr eigenes Dateisystem erstellen, ohne dass Sie umfassende Kenntnisse des Betriebssystemkernels oder von Low-Level-Programmiersprachen benötigen. Dieser Artikel stellt eine praktische Lösung unter Verwendung von FUSE mit Node.js und TypeScript vor. Wir werden untersuchen, wie FUSE hinter den Kulissen funktioniert, und seine Anwendung anhand einer realen Aufgabe demonstrieren. Begleiten Sie mich auf einem spannenden Abenteuer in die Welt von FUSE und Node.js.
featured image - So erstellen Sie ein dynamisches Dateisystem mit FUSE und Node.js: Ein praktischer Ansatz
Aleksandr Zinin HackerNoon profile picture

Haben Sie sich schon einmal gefragt, was passiert, wenn Sie sshfs user@remote:~/ /mnt/remoteroot ausführen? Wie erscheinen Dateien von einem Remote-Server auf Ihrem lokalen System und werden so schnell synchronisiert? Haben Sie schon von WikipediaFS gehört, mit dem Sie einen Wikipedia-Artikel bearbeiten können, als wäre er eine Datei in Ihrem Dateisystem? Das ist keine Zauberei – es ist die Leistungsfähigkeit von FUSE (Filesystem in Userspace). Mit FUSE können Sie Ihr eigenes Dateisystem erstellen, ohne dass Sie umfassende Kenntnisse des Betriebssystemkernels oder einfacher Programmiersprachen benötigen.


Dieser Artikel stellt eine praktische Lösung mit FUSE mit Node.js und TypeScript vor. Wir werden untersuchen, wie FUSE hinter den Kulissen funktioniert, und seine Anwendung anhand der Lösung einer realen Aufgabe demonstrieren. Begleiten Sie mich auf einem spannenden Abenteuer in die Welt von FUSE und Node.js.

Einführung

Bei meiner Arbeit war ich für Mediendateien (hauptsächlich Bilder) verantwortlich. Dazu gehören viele Dinge: Seiten- oder Top-Banner, Medien in Chats, Aufkleber usw. Natürlich gibt es dafür viele Anforderungen, wie z. B. „Banner ist PNG oder WEBP, 300 x 1000 Pixel“. Wenn die Anforderungen nicht erfüllt sind, lässt unser Backoffice ein Bild nicht durch. Und es gibt einen Objekt-Deduplizierungsmechanismus: Kein Bild kann zweimal in denselben Fluss gelangen.


Dies führt uns zu einer Situation, in der wir zu Testzwecken über einen riesigen Satz von Bildern verfügen. Ich habe Shell-Einzeiler oder Aliase verwendet, um mir das Leben zu erleichtern.


Zum Beispiel:

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


Beispiel für das Rauschbild


Eine Kombination aus bash und convert ist ein großartiges Tool, aber natürlich nicht die bequemste Art, das Problem anzugehen. Die Diskussion über die Situation des QA-Teams offenbart weitere Komplikationen. Abgesehen von dem beträchtlichen Zeitaufwand für die Bildgenerierung lautet die erste Frage bei der Untersuchung eines Problems: „Sind Sie sicher, dass Sie ein einzigartiges Bild hochgeladen haben?“ Ich glaube, Sie verstehen, wie ärgerlich das ist.

Wählen wir die Technologie aus, die wir nutzen möchten

Sie könnten einen einfachen Ansatz wählen: Erstellen Sie einen Webdienst, der eine Route mit einer selbsterklärenden Datei bereitstellt, etwa GET /image/1000x100/random.zip?imagesCount=100 . Die Route würde eine ZIP-Datei mit einer Reihe eindeutiger Bilder zurückgeben. Das klingt gut, aber es behebt nicht unser Hauptproblem: Alle hochgeladenen Dateien müssen zum Testen eindeutig sein.


Ihr nächster Gedanke könnte sein: „Können wir eine Nutzlast beim Senden ersetzen?“ Das QA-Team verwendet Postman für API-Aufrufe. Ich habe die internen Vorgänge von Postman untersucht und festgestellt, dass wir den Anforderungstext nicht „on the fly“ ändern können.


Eine andere Lösung besteht darin, eine Datei im Dateisystem jedes Mal zu ersetzen, wenn versucht wird, die Datei zu lesen. Linux verfügt über ein Benachrichtigungssubsystem namens Inotify, das Sie über Dateisystemereignisse wie Änderungen in Verzeichnissen oder Dateimodifikationen informiert. Wenn Sie die Meldung „Visual Studio Code kann in diesem großen Arbeitsbereich nicht auf Dateiänderungen achten“ erhalten, liegt ein Problem mit Inotify vor. Es kann ein Ereignis auslösen, wenn ein Verzeichnis geändert, eine Datei umbenannt, eine Datei geöffnet usw. wird.


Die vollständige Liste der Veranstaltungen finden Sie hier: https://sites.uclouvain.be/SystInfo/usr/include/linux/inotify.h.html


Der Plan ist also:

  1. Auf das IN_OPEN Ereignis hören und Dateideskriptoren zählen.

  2. Wir hören auf das Ereignis IN_CLOSE . Wenn der Zähler auf 0 fällt, ersetzen wir die Datei.


Klingt gut, bringt aber ein paar Probleme mit sich:

  • Nur Linux unterstützt inotify .
  • Parallele Anfragen an die Datei sollten die gleichen Daten zurückgeben.
  • Wenn für eine Datei intensive E/A-Vorgänge erforderlich sind, erfolgt kein Ersatz.
  • Wenn ein Dienst abstürzt, der Inotify-Ereignisse bereitstellt, verbleiben die Dateien im Dateisystem des Benutzers.


Um diese Probleme zu lösen, können wir unser eigenes Dateisystem schreiben. Aber es gibt noch ein weiteres Problem: Das normale Dateisystem läuft im Kernelbereich des Betriebssystems. Dazu müssen wir uns mit dem Betriebssystemkernel auskennen und Sprachen wie C/Rust verwenden. Außerdem müssen wir für jeden Kernel ein spezielles Modul (Treiber) schreiben.


Daher ist das Schreiben eines Dateisystems für das Problem, das wir lösen möchten, übertrieben; selbst wenn ein langes Wochenende bevorsteht. Glücklicherweise gibt es eine Möglichkeit, dieses Biest zu zähmen: Filesystem in Use rspace (FUSE). FUSE ist ein Projekt, mit dem Sie Dateisysteme erstellen können, ohne den Kernelcode zu bearbeiten. Dies bedeutet, dass jedes Programm oder Skript über FUSE ohne komplexe kernbezogene Logik einen Flash, eine Festplatte oder eine SSD emulieren kann.


Mit anderen Worten: Ein gewöhnlicher Userspace-Prozess kann sein eigenes Dateisystem erstellen, auf das über jedes beliebige normale Programm – Nautilus, Dolphin, ls usw. – normal zugegriffen werden kann.


Warum ist FUSE gut geeignet, um unsere Anforderungen abzudecken? FUSE-basierte Dateisysteme werden über benutzerdefiniert verteilte Prozesse aufgebaut. Daher können Sie jede Ihnen bekannte Sprache verwenden, die eine Bindung an libfuse hat. Außerdem erhalten Sie mit FUSE eine plattformübergreifende Lösung.


Ich habe viel Erfahrung mit NodeJS und TypeScript und möchte diese (wunderbare) Kombination als Ausführungsumgebung für unser brandneues FS wählen. Darüber hinaus bietet TypeScript eine hervorragende objektorientierte Basis. Dies ermöglicht es mir, Ihnen nicht nur den Quellcode zu zeigen, den Sie im öffentlichen GitHub-Repo finden, sondern auch die Struktur des Projekts.

Detaillierter Einblick in FUSE

Ich möchte hier ein Zitat von der offiziellen FUSE-Seite anführen:

FUSE ist ein Userspace-Dateisystem-Framework. Es besteht aus einem Kernelmodul (fuse.ko), einer Userspace-Bibliothek (libfuse.*) und einem Mount-Dienstprogramm (fusermount).


Ein Framework zum Schreiben von Dateisystemen klingt spannend.


Ich sollte erklären, was jeder FUSE-Teil bedeutet:

  1. fuse.ko erledigt alle mit dem Kernel in Zusammenhang stehenden Low-Level-Jobs. Dadurch können wir Eingriffe in den Betriebssystemkernel vermeiden.


  2. libfuse ist eine Bibliothek, die eine High-Level-Schicht für die Kommunikation mit fuse.ko bereitstellt.


  3. fusermount können Benutzer Dateisysteme im Userspace mounten/unmounten (nennen Sie mich Captain Obvious!).


Die allgemeinen Grundsätze sehen wie folgt aus:
Die allgemeinen Grundsätze von FUSE


Der Userspace-Prozess (in diesem Fall ls ) stellt eine Anforderung an den Virtual File System-Kernel, der die Anforderung an das FUSE-Kernelmodul weiterleitet. Das FUSE-Modul leitet die Anforderung wiederum zurück in den Userspace zur Dateisystemrealisierung ( ./hello im obigen Bild).


Lassen Sie sich nicht vom Namen „Virtual File System“ täuschen. Er hat nichts direkt mit FUSE zu tun. Es handelt sich um die Softwareschicht im Kernel, die die Dateisystemschnittstelle für Userspace-Programme bereitstellt. Der Einfachheit halber können Sie es als ein zusammengesetztes Muster betrachten.


libfuse bietet zwei Arten von APIs: High-Level und Low-Level. Sie haben Ähnlichkeiten, aber entscheidende Unterschiede. Die Low-Level-API ist asynchron und funktioniert nur mit inodes . Asynchron bedeutet in diesem Fall, dass ein Client, der eine Low-Level-API verwendet, die Antwortmethoden selbst aufrufen muss.


Die High-Level-Variante bietet die Möglichkeit, bequeme Pfade (z. B. /etc/shadow ) anstelle von „abstrakteren“ inodes zu verwenden und gibt Antworten synchron zurück. In diesem Artikel erkläre ich, wie die High-Level-Variante im Gegensatz zur Low-Level-Variante und inodes funktioniert.


Wenn Sie Ihr eigenes Dateisystem implementieren möchten, sollten Sie eine Reihe von Methoden implementieren, die für Anfragen zuständig sind, die von VFS bedient werden. Die gebräuchlichsten Methoden sind:


  • open(path, accessFlags): fd -- öffne eine Datei nach Pfad. Die Methode soll eine numerische Kennung zurückgeben, den sogenannten File Descriptor (im Folgenden fd ). Ein Access Flag ist eine binäre Maske, die beschreibt, welche Operation das Client-Programm ausführen möchte (Nur Lesen, Nur Schreiben, Lesen/Schreiben, Ausführen oder Suchen).


  • read(path, fd, Buffer, size, offset): count of bytes read – liest size Bytes aus einer mit fd -Dateideskriptor verknüpften Datei in den übergebenen Puffer. Das path wird ignoriert, da wir fd verwenden werden.


  • write(path, fd, Buffer, size, offset): count of bytes written – schreibt size Bytes aus dem Puffer in eine mit fd verknüpfte Datei.


  • release(fd) – schließt das fd .


  • truncate(path, size) – Ändert die Größe einer Datei. Die Methode muss definiert werden, wenn Sie Dateien neu schreiben möchten (und das tun wir).


  • getattr(path) – gibt Dateiparameter wie Größe, Erstellungsdatum, Zugriffsdatum usw. zurück. Diese Methode wird vom Dateisystem am häufigsten aufgerufen. Stellen Sie daher sicher, dass Sie die optimale Methode erstellen.


  • readdir(path) – alle Unterverzeichnisse lesen.


Die oben genannten Methoden sind für jedes voll funktionsfähige Dateisystem, das auf der High-Level-FUSE-API basiert, unerlässlich. Die Liste ist jedoch nicht vollständig. Die vollständige Liste finden Sie unter https://libfuse.github.io/doxygen/structfuse__operations.html


Um noch einmal auf das Konzept eines Dateideskriptors zurückzukommen: In UNIX-ähnlichen Systemen, einschließlich MacOS, ist ein Dateideskriptor eine Abstraktion für Dateien und andere E/A-Ressourcen wie Sockets und Pipes. Wenn ein Programm eine Datei öffnet, gibt das Betriebssystem eine numerische Kennung zurück, die als Dateideskriptor bezeichnet wird. Diese Ganzzahl dient als Index in der Dateideskriptortabelle des Betriebssystems für jeden Prozess. Wenn wir ein Dateisystem mit FUSE implementieren, müssen wir selbst Dateideskriptoren generieren.


Betrachten wir den Anruffluss, wenn der Client eine Datei öffnet:

  1. getattr(path: /random.png) → { size: 98 }; der Client hat die Dateigröße erhalten.


  2. open(path: /random.png) → 10; Datei nach Pfad geöffnet; FUSE-Implementierung gibt eine Dateideskriptornummer zurück.


  3. read(path: /random.png, fd: 10 buffer, size: 50, offset: 0) → 50; liest die ersten 50 Bytes.


  4. read(path: /random.png, fd: 10 buffer, size: 50, offset: 50) → 48; die nächsten 50 lesen. Die 48 Bytes wurden aufgrund der Dateigröße gelesen.


  5. release(10); alle Daten wurden gelesen, also nahe am FD.

Schreiben wir ein Minimum Viable Product und sehen wir uns die Reaktion des Postboten darauf an

Unser nächster Schritt besteht darin, ein minimales Dateisystem auf Basis von libfuse zu entwickeln, um zu testen, wie Postman mit einem benutzerdefinierten Dateisystem interagiert.


Die Akzeptanzvoraussetzungen für das FS sind unkompliziert: Das Stammverzeichnis des FS sollte eine Datei random.txt enthalten, deren Inhalt bei jedem Lesen eindeutig sein sollte (nennen wir dies „immer eindeutiges Lesen“). Der Inhalt sollte eine zufällige UUID und eine aktuelle Zeit im ISO-Format enthalten, getrennt durch eine neue Zeile. Beispiel:

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


Das Minimalprodukt besteht aus zwei Teilen. Der erste ist ein einfacher Webdienst, der HTTP-POST-Anfragen akzeptiert und einen Anfragetext auf dem Terminal ausgibt. Der Code ist recht einfach und unsere Zeit nicht wert, vor allem, weil der Artikel von FUSE und nicht von Express handelt. Der zweite Teil ist die Implementierung des Dateisystems, das die Anforderungen erfüllt. Er besteht nur aus 83 Codezeilen.


Für den Code verwenden wir die Bibliothek node-fuse-bindings, die Bindungen an die High-Level-API von libfuse bereitstellt.


Sie können den folgenden Code überspringen. Ich werde unten eine Codezusammenfassung schreiben.

 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();


Ich schlage vor, unser Wissen über Berechtigungsbits in einer Datei aufzufrischen. Berechtigungsbits sind eine Reihe von Bits, die einer Datei zugeordnet sind. Sie sind eine binäre Darstellung dessen, wer die Datei lesen/schreiben/ausführen darf. „Wer“ umfasst drei Gruppen: den Eigentümer, die Eigentümergruppe und andere.


Berechtigungen können für jede Gruppe separat festgelegt werden. Normalerweise wird jede Berechtigung durch eine dreistellige Zahl dargestellt: Lesen (4 oder „100“ im Binärsystem), Schreiben (2 oder „010“) und Ausführen (1 oder „001“). Wenn Sie diese Zahlen addieren, erhalten Sie eine kombinierte Berechtigung. Beispielsweise ergibt 4 + 2 (oder „100“ + „010“) 6 („110“), was Lese- + Schreibberechtigung (RO) bedeutet.


Wenn der Dateibesitzer eine Zugriffsmaske von 7 hat (111 im Binärsystem, was Lesen, Schreiben und Ausführen bedeutet), hat die Gruppe 5 (101, was Lesen und Ausführen bedeutet) und andere haben 4 (100, was schreibgeschützt bedeutet). Daher ist die vollständige Zugriffsmaske für die Datei 754 im Dezimalsystem. Beachten Sie, dass die Ausführungsberechtigung zur Leseberechtigung für Verzeichnisse wird.


Kehren wir zur Dateisystemimplementierung zurück und erstellen eine Textversion davon: Jedes Mal, wenn eine Datei geöffnet wird (über einen open Aufruf), wird der Integer-Zähler hochgezählt und erzeugt den Dateideskriptor, der vom Open-Aufruf zurückgegeben wird. Anschließend wird zufälliger Inhalt erstellt und in einem Schlüssel-Wert-Speicher mit dem Dateideskriptor als Schlüssel gespeichert. Wenn ein Leseaufruf erfolgt, wird der entsprechende Inhaltsteil zurückgegeben.


Bei einem Release-Aufruf wird der Inhalt gelöscht. Denken Sie daran, SIGINT zu verarbeiten, um das Dateisystem nach dem Drücken von Strg+C auszuhängen. Andernfalls müssen wir dies manuell im Terminal mit fusermount -u ./MOUNT_PATH tun.


Jetzt können wir mit dem Testen beginnen. Wir führen den Webserver aus, erstellen dann einen leeren Ordner als Stammordner für das kommende FS und führen das Hauptskript aus. Nachdem die Zeile „Server lauscht auf Port 3000“ ausgegeben wurde, öffnen wir Postman und senden ein paar Anfragen nacheinander an den Webserver, ohne irgendwelche Parameter zu ändern.
Links ist das FS, rechts der Webserver


Alles sieht gut aus! Jede Anfrage hat, wie wir es erwartet haben, einen einzigartigen Dateiinhalt. Die Protokolle beweisen auch, dass der oben im Abschnitt „Deep Dive into FUSE“ beschriebene Ablauf der Dateiöffnungsaufrufe korrekt ist.


Das GitHub-Repo mit MVP: https://github.com/pinkiesky/node-fuse-mvp . Sie können diesen Code in Ihrer lokalen Umgebung ausführen oder dieses Repo als Vorlage für Ihre eigene Dateisystemimplementierung verwenden.

Die Kernidee

Der Ansatz ist geprüft, nun geht es an die erste Umsetzung.


Vor der Implementierung des „immer eindeutigen Lesens“ sollten wir als Erstes die Erstellungs- und Löschvorgänge für Originaldateien implementieren. Wir werden diese Schnittstelle über ein Verzeichnis in unserem virtuellen Dateisystem implementieren. Der Benutzer legt Originalbilder ab, die er „immer eindeutig“ oder „randomisiert“ machen möchte, und das Dateisystem bereitet den Rest vor.


Mit „immer eindeutiges Lesen“, „Zufallsbild“ oder „Zufallsdatei“ ist hier und in den folgenden Abschnitten eine Datei gemeint, die bei jedem Lesen im binären Sinn eindeutige Inhalte zurückgibt, dabei aber optisch dem Original so ähnlich wie möglich bleibt.


Das Stammverzeichnis des Dateisystems enthält zwei Verzeichnisse: Image Manager und Images. Das erste ist ein Ordner zur Verwaltung der Originaldateien des Benutzers (Sie können es sich als CRUD-Repository vorstellen). Das zweite ist aus Sicht des Benutzers das nicht verwaltete Verzeichnis, das zufällige Bilder enthält.
Benutzer interagiert mit dem Dateisystem


FS-Baum als Terminalausgabe


Wie Sie im Bild oben sehen können, implementieren wir nicht nur „immer eindeutige“ Bilder, sondern auch einen Dateikonverter! Das ist ein zusätzlicher Bonus.


Die Kernidee unserer Implementierung besteht darin, dass das Programm einen Objektbaum enthält, wobei jeder Knoten und jedes Blatt allgemeine FUSE-Methoden bereitstellt. Wenn das Programm einen FS-Aufruf empfängt, sollte es einen Knoten oder ein Blatt im Baum über den entsprechenden Pfad finden. Beispielsweise erhält das Programm den Aufruf getattr(/Images/1/original/) und versucht dann, den Knoten zu finden, an den der Pfad adressiert ist.


Etwas wie das: FS-Baumbeispiel


Die nächste Frage ist, wie wir die Originalbilder speichern werden. Ein Bild im Programm besteht aus Binärdaten und Metadaten (Metadaten umfassen einen ursprünglichen Dateinamen, den MIME-Typ der Datei usw.). Binärdaten werden im Binärspeicher gespeichert. Vereinfachen wir es und erstellen den Binärspeicher als eine Reihe von Binärdateien im Dateisystem des Benutzers (oder des Hosts). Metadaten werden ähnlich gespeichert: JSON in Textdateien im Dateisystem des Benutzers.


Wie Sie sich vielleicht erinnern, haben wir im Abschnitt „Schreiben wir ein minimal funktionsfähiges Produkt“ ein Dateisystem erstellt, das eine Textdatei nach Vorlage zurückgibt. Sie enthält eine zufällige UUID und ein aktuelles Datum, sodass die Eindeutigkeit der Daten nicht das Problem war – die Eindeutigkeit wurde durch die Definition der Daten erreicht. Ab diesem Punkt sollte das Programm jedoch mit vorinstallierten Benutzerbildern arbeiten. Wie können wir also Bilder erstellen, die ähnlich, aber immer eindeutig (in Bezug auf Bytes und folglich Hashes) auf der Grundlage des Originals sind?


Die von mir vorgeschlagene Lösung ist ganz einfach. Lassen Sie uns ein RGB-Rauschquadrat in die obere linke Ecke eines Bildes setzen. Das Rauschquadrat sollte 16 x 16 Pixel groß sein. Dies ergibt fast das gleiche Bild, garantiert aber eine eindeutige Bytefolge. Wird dies ausreichen, um viele unterschiedliche Bilder zu gewährleisten? Lassen Sie uns ein wenig rechnen. Die Größe des Quadrats beträgt 16. 16 × 16 = 256 RGB-Pixel in einem einzigen Quadrat. Jedes Pixel hat 256 × 256 × 256 = 16.777.216 Varianten.


Somit beträgt die Anzahl der eindeutigen Quadrate 16.777.216^256 – eine Zahl mit 1.558 Ziffern, die viel mehr ist als die Anzahl der Atome im beobachtbaren Universum. Bedeutet das, dass wir die Quadratgröße reduzieren können? Leider würde eine verlustbehaftete Komprimierung wie JPEG die Anzahl der eindeutigen Quadrate erheblich reduzieren, daher ist 16x16 die optimale Größe.


Beispiel für Bilder mit Rauschquadraten

Passthrough über Klassen

Der Baum
UML-Klassendiagramm, das Schnittstellen und Klassen für ein FUSE-basiertes System zeigt. Enthält die Schnittstellen IFUSEHandler, ObjectTreeNode und IFUSETreeNode, wobei FileFUSETreeNode und DirectoryFUSETreeNode IFUSETreeNode implementieren. Jede Schnittstelle und Klasse listet Attribute und Methoden auf und veranschaulicht deren Beziehungen und Hierarchie.

IFUSEHandler ist eine Schnittstelle, die allgemeine FUSE-Aufrufe bedient. Sie können sehen, dass ich read/write jeweils durch readAll/writeAll ersetzt habe. Ich habe dies getan, um Lese- und Schreibvorgänge zu vereinfachen: Wenn IFUSEHandler für einen ganzen Teil liest/schreibt, können wir die partielle Lese-/Schreiblogik an einen anderen Ort verschieben. Das bedeutet, dass IFUSEHandler nichts über Dateideskriptoren, Binärdaten usw. wissen muss.


Dasselbe passierte auch mit der open FUSE-Methode. Ein bemerkenswerter Aspekt des Baums ist, dass er bei Bedarf generiert wird. Anstatt den gesamten Baum im Speicher zu speichern, erstellt das Programm Knoten nur, wenn auf sie zugegriffen wird. Durch dieses Verhalten kann das Programm ein Problem beim Wiederaufbau des Baums im Falle der Erstellung oder Entfernung von Knoten vermeiden.


Überprüfen Sie die Schnittstelle ObjectTreeNode und Sie werden feststellen, dass children kein Array, sondern eine Methode ist. So werden sie also bei Bedarf generiert. FileFUSETreeNode und DirectoryFUSETreeNode sind abstrakte Klassen, bei denen einige Methoden einen NotSupported -Fehler auslösen ( FileFUSETreeNode sollte natürlich niemals readdir implementieren).

FUSEFassade

UML-Klassendiagramm, das Schnittstellen und ihre Beziehungen für ein FUSE-System zeigt. Das Diagramm enthält die Schnittstellen IFUSEHandler, IFUSETreeNode, IFileDescriptorStorage und die Klasse FUSEFacade. IFUSEHandler hat die Attribute name und die Methoden checkAvailability, create, getattr, readAll, remove und writeAll. IFileDescriptorStorage hat die Methoden get, openRO, openWO und release. IFUSETreeNode erweitert IFUSEHandler. FUSEFacade enthält die Methoden constructor, create, getattr, open, read, readdir, release, rmdir, safeGetNode, unlink und write und interagiert sowohl mit IFUSETreeNode als auch mit IFileDescriptorStorage.


FUSEFacade ist die wichtigste Klasse, die die Hauptlogik des Programms implementiert und verschiedene Teile miteinander verbindet. node-fuse-bindings hat eine Callback-basierte API, aber FUSEFacade-Methoden werden mit einer Promise-basierten API erstellt. Um dieses Problem zu lösen, habe ich einen Code wie diesen verwendet:

 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); // },


Die FUSEFacade Methoden sind in handleResultWrapper gekapselt. Jede Methode von FUSEFacade , die einen Pfad verwendet, analysiert einfach den Pfad, findet einen Knoten im Baum und ruft die angeforderte Methode auf.


Betrachten Sie einige Methoden aus der FUSEFacade Klasse.

 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; }

Ein Dateideskriptor

Bevor wir den nächsten Schritt machen, schauen wir uns genauer an, was ein Dateideskriptor im Kontext unseres Programms ist.

UML-Klassendiagramm, das Schnittstellen und ihre Beziehungen für Dateideskriptoren in einem FUSE-System zeigt. Das Diagramm enthält die Schnittstellen IFileDescriptor, IFileDescriptorStorage und die Klassen ReadWriteFileDescriptor, ReadFileDescriptor und WriteFileDescriptor. IFileDescriptor hat die Attribute binary, fd, size und die Methoden readToBuffer, writeToBuffer. IFileDescriptorStorage hat die Methoden get, openRO, openWO und release. ReadWriteFileDescriptor implementiert IFileDescriptor mit zusätzlichen Methoden constructor, readToBuffer und writeToBuffer. ReadFileDescriptor und WriteFileDescriptor erweitern ReadWriteFileDescriptor, wobei ReadFileDescriptor eine Methode writeToBuffer und WriteFileDescriptor eine Methode readToBuffer hat.

ReadWriteFileDescriptor ist eine Klasse, die Dateideskriptoren als Zahlen und Binärdaten als Puffer speichert. Die Klasse verfügt über die Methoden readToBuffer und writeToBuffer , die das Lesen und Schreiben von Daten in einen Dateideskriptorpuffer ermöglichen. ReadFileDescriptor und WriteFileDescriptor sind Implementierungen von schreibgeschützten und schreibgeschützten Deskriptoren.


IFileDescriptorStorage ist eine Schnittstelle, die den Speicher von Dateideskriptoren beschreibt. Das Programm hat nur eine Implementierung für diese Schnittstelle: InMemoryFileDescriptorStorage . Wie der Name schon sagt, speichert es Dateideskriptoren im Speicher, da wir für Deskriptoren keine Persistenz benötigen.


Sehen wir uns an, wie FUSEFacade Dateideskriptoren und Speicher verwendet:

 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; }


Der obige Code ist unkompliziert. Er definiert Methoden zum Lesen, Schreiben und Freigeben von Dateideskriptoren und stellt sicher, dass der Dateideskriptor gültig ist, bevor Operationen ausgeführt werden. Die Methode „Release“ schreibt auch Daten aus einem Dateideskriptorobjekt in den Dateisystemknoten und gibt den Dateideskriptor frei.


Wir sind mit dem Code rund um libfuse und den Baum fertig. Es ist Zeit, in den bildbezogenen Code einzutauchen.

Bilder: Teil „Datentransferobjekt“
UML-Klassendiagramm, das Schnittstellen und ihre Beziehungen zur Bildverarbeitung zeigt. Das Diagramm enthält die Schnittstellen ImageBinary, ImageMeta, Image und IImageMetaStorage. ImageBinary hat die Attribute buffer und size. ImageMeta hat die Attribute id, name, originalFileName und originalFileType. Image hat die Attribute binary und meta, wobei binary vom Typ ImageBinary und meta vom Typ ImageMeta ist. IImageMetaStorage hat die Methoden create, get, list und remove.


ImageMeta ist ein Objekt, das Metadaten zu einem Bild speichert. IImageMetaStorage ist eine Schnittstelle, die einen Speicher für Metadaten beschreibt. Das Programm hat nur eine Implementierung für die Schnittstelle: Die Klasse FSImageMetaStorage implementiert die Schnittstelle IImageMetaStorage , um Bildmetadaten zu verwalten, die in einer einzelnen JSON-Datei gespeichert sind.


Es verwendet einen Cache, um Metadaten im Speicher zu speichern, und stellt sicher, dass der Cache bei Bedarf durch Lesen aus der JSON-Datei aufgefüllt wird. Die Klasse bietet Methoden zum Erstellen, Abrufen, Auflisten und Löschen von Bildmetadaten und schreibt Änderungen zurück in die JSON-Datei, um Aktualisierungen dauerhaft beizubehalten. Der Cache verbessert die Leistung, indem er die Anzahl der IO-Operationen reduziert.


ImageBinary ist offensichtlich ein Objekt, das binäre Bilddaten enthält. Die Image Schnittstelle ist die Zusammensetzung von ImageMeta und ImageBinary .

Bilder: Binäre Speicherung und Generatoren

UML-Klassendiagramm, das Schnittstellen und ihre Beziehungen für Bildgenerierung und binäre Speicherung zeigt. Das Diagramm enthält die Schnittstellen IBinaryStorage, IImageGenerator und die Klassen FSBinaryStorage, ImageGeneratorComposite, PassThroughImageGenerator, TextImageGenerator und ImageLoaderFacade. IBinaryStorage hat die Methoden load, remove und write. FSBinaryStorage implementiert IBinaryStorage und hat einen zusätzlichen Konstruktor. IImageGenerator hat eine Methode generate. PassThroughImageGenerator und TextImageGenerator implementieren IImageGenerator. ImageGeneratorComposite hat die Methoden addGenerator und generate. ImageLoaderFacade hat einen Konstruktor und eine Methode load und interagiert mit IBinaryStorage und IImageGenerator


IBinaryStorage ist eine Schnittstelle für die Speicherung binärer Daten. Binärer Speicher sollte nicht mit Bildern verknüpft sein und kann beliebige Daten speichern: Bilder, Videos, JSON oder Text. Diese Tatsache ist uns wichtig, und Sie werden sehen, warum.


IImageGenerator ist eine Schnittstelle, die einen Generator beschreibt. Der Generator ist ein wichtiger Teil des Programms. Er nimmt Rohbinärdaten plus Metadaten und generiert darauf basierend ein Bild. Warum benötigt das Programm Generatoren? Kann das Programm ohne sie funktionieren?


Das ist möglich, aber Generatoren machen die Implementierung flexibler. Mit Generatoren können Benutzer Bilder, Textdaten und im Allgemeinen alle Daten hochladen, für die Sie einen Generator schreiben.


Diagramm, das den Prozess der Konvertierung einer Textdatei in ein Bild mithilfe der IImageGenerator-Schnittstelle zeigt. Auf der linken Seite befindet sich ein Symbol für eine Textdatei mit der Bezeichnung „myfile.txt“ und dem Inhalt „Hallo Welt!“. Ein Pfeil mit der Bezeichnung „IImageGenerator“ zeigt nach rechts, wo sich ein Symbol für eine Bilddatei mit der Bezeichnung „myfile.png“ befindet, in der der gleiche Text „Hallo Welt!“ wie im Bild angezeigt wird.


Der Ablauf ist wie folgt: Binärdaten werden aus dem Speicher geladen ( myfile.txt im Bild oben) und dann werden die Binärdaten an einen Generator weitergeleitet. Dieser generiert „on the fly“ ein Bild. Man kann es als Konverter von einem Format in ein anderes betrachten, was für uns bequemer ist.


Schauen wir uns ein Beispiel für einen Generator an:

 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 }, }; } }


Die Klasse ImageLoaderFacade ist eine Fassade , die den Speicher und den Generator logisch kombiniert – mit anderen Worten, sie implementiert den Ablauf, den Sie oben gelesen haben.

Bilder: Varianten

UML-Klassendiagramm, das Schnittstellen und ihre Beziehungen für Bildgenerierung und binäre Speicherung zeigt. Das Diagramm enthält die Schnittstellen IBinaryStorage, IImageGenerator und die Klassen FSBinaryStorage, ImageGeneratorComposite, PassThroughImageGenerator, TextImageGenerator und ImageLoaderFacade. IBinaryStorage hat die Methoden load, remove und write. FSBinaryStorage implementiert IBinaryStorage und hat einen zusätzlichen Konstruktor. IImageGenerator hat eine Methode generate. PassThroughImageGenerator und TextImageGenerator implementieren IImageGenerator. ImageGeneratorComposite hat die Methoden addGenerator und generate. ImageLoaderFacade hat einen Konstruktor und eine Methode load und interagiert mit IBinaryStorage und IImageGenerator


IImageVariant ist eine Schnittstelle zum Erstellen verschiedener Bildvarianten. In diesem Zusammenhang ist eine Variante ein „on the fly“ generiertes Bild, das dem Benutzer beim Anzeigen von Dateien in unserem Dateisystem angezeigt wird. Der Hauptunterschied zu Generatoren besteht darin, dass als Eingabe ein Bild und keine Rohdaten verwendet werden.


Das Programm hat drei Varianten: ImageAlwaysRandom , ImageOriginalVariant und ImageWithText . ImageAlwaysRandom gibt das Originalbild mit einem zufälligen RGB-Rauschquadrat zurück.


 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 }; } }


Ich verwende die sharp -Bibliothek, da dies die bequemste Möglichkeit ist, in NodeJS mit Bildern zu arbeiten: https://github.com/lovell/sharp .


ImageOriginalVariant gibt ein Bild ohne Änderungen zurück (es kann aber ein Bild in einem anderen Komprimierungsformat zurückgeben). ImageWithText gibt ein Bild mit darüber geschriebenem Text zurück. Dies ist hilfreich, wenn wir vordefinierte Varianten eines einzelnen Bildes erstellen. Wenn wir beispielsweise 10 zufällige Variationen eines Bildes benötigen, müssen wir diese Variationen voneinander unterscheiden.


Die Lösung besteht hier darin, auf Grundlage des Originals 10 Bilder zu erstellen, wobei wir in der oberen linken Ecke jedes Bildes eine fortlaufende Nummer von 0 bis 9 rendern.

Eine Bildfolge, die eine weiß-schwarze Katze mit weit aufgerissenen Augen zeigt. Die Bilder sind mit Zahlen beschriftet, die links bei 0 beginnen, um 1 hochzählen und mit Auslassungspunkten bis 9 rechts weiterlaufen. Der Gesichtsausdruck der Katze bleibt in jedem Bild gleich.


Der ImageCacheWrapper hat einen anderen Zweck als die Varianten und fungiert als Wrapper, indem er die Ergebnisse der jeweiligen IImageVariant Klasse zwischenspeichert. Er wird verwendet, um Entitäten zu umschließen, die sich nicht ändern, wie z. B. einen Bildkonverter, Text-zu-Bild-Generatoren usw. Dieser Zwischenspeichermechanismus ermöglicht einen schnelleren Datenabruf, vor allem wenn dieselben Bilder mehrmals gelesen werden.


Nun haben wir alle Hauptteile des Programms abgedeckt. Es ist Zeit, alles zusammenzuführen.

Die Baumstruktur

UML-Klassendiagramm, das die Hierarchie und Beziehungen zwischen verschiedenen FUSE-Baumknoten im Zusammenhang mit der Bildverwaltung zeigt. Zu den Klassen gehören ImageVariantFileFUSETreeNode, ImageCacheWrapper, ImageItemAlwaysRandomDirFUSETreeNode, ImageItemOriginalDirFUSETreeNode, ImageItemCounterDirFUSETreeNode, ImageManagerItemFileFUSETreeNode, ImageItemDirFUSETreeNode, ImageManagerDirFUSETreeNode, ImagesDirFUSETreeNode und RootDirFUSETreeNode. Jede Klasse verfügt über Attribute und Methoden, die für Bildmetadaten, Binärdaten und Dateioperationen wie „create“, „readAll“, „writeAll“, „remove“ und „getattr“ relevant sind.


Das Klassendiagramm unten zeigt, wie die Baumklassen mit ihren Bildgegenstücken kombiniert werden. Das Diagramm sollte von unten nach oben gelesen werden. RootDir (ich vermeide das Postfix FUSETreeNode in Namen) ist das Stammverzeichnis für das Dateisystem, das das Programm implementiert. In der oberen Reihe sehen Sie zwei Verzeichnisse: ImagesDir und ImagesManagerDir . ImagesManagerDir enthält die Liste der Benutzerbilder und ermöglicht deren Steuerung. ImagesManagerItemFile ist ein Knoten für eine bestimmte Datei. Diese Klasse implementiert CRUD-Operationen.


Betrachten Sie ImagesManagerDir als eine übliche Implementierung eines Knotens:

 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(); } }


Im Folgenden enthält ImagesDir Unterverzeichnisse, die nach den Bildern des Benutzers benannt sind. ImagesItemDir ist für jedes Verzeichnis verantwortlich. Es enthält alle verfügbaren Varianten; wie Sie sich erinnern, beträgt die Anzahl der Varianten drei. Jede Variante ist ein Verzeichnis, das die endgültigen Bilddateien in verschiedenen Formaten enthält (derzeit: jpeg, png und webm). ImagesItemOriginalDir und ImagesItemCounterDir packen alle erzeugten ImageVariantFile Instanzen in einen Cache.


Dies ist notwendig, um eine ständige Neukodierung der Originalbilder zu vermeiden, da die Kodierung CPU-intensiv ist. Ganz oben im Diagramm befindet sich die ImageVariantFile . Sie ist das Kronjuwel der Implementierung und der Zusammensetzung der zuvor beschriebenen IFUSEHandler und IImageVariant . Dies ist die Datei, auf die all unsere Bemühungen hingearbeitet haben.

Testen

Lassen Sie uns testen, wie das endgültige Dateisystem parallele Anfragen an dieselbe Datei verarbeitet. Dazu führen wir das Dienstprogramm md5sum in mehreren Threads aus, das Dateien aus dem Dateisystem liest und ihre Hashes berechnet. Anschließend vergleichen wir diese Hashes. Wenn alles richtig funktioniert, sollten die Hashes unterschiedlich sein.

 #!/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


Ich habe das Skript ausgeführt und die folgende Ausgabe überprüft (der Übersichtlichkeit halber etwas bereinigt):

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


Ausgezeichnet! Alle Hashes sind unterschiedlich, was bedeutet, dass das Dateisystem jedes Mal ein einzigartiges Bild zurückgibt!

Fazit

Ich hoffe, dieser Artikel hat Sie dazu inspiriert, Ihre eigene FUSE-Implementierung zu schreiben. Denken Sie daran, dass der Quellcode für dieses Projekt hier verfügbar ist: https://github.com/pinkiesky/node-fuse-images .


Das von uns erstellte Dateisystem ist vereinfacht, um die Kernprinzipien der Arbeit mit FUSE und Node.js zu demonstrieren. Beispielsweise werden die korrekten Daten nicht berücksichtigt. Es gibt viel Raum für Verbesserungen. Stellen Sie sich vor, Sie fügen Funktionen wie Frame-Extraktion aus Benutzer-GIF-Dateien, Video-Transkodierung oder sogar die Parallelisierung von Aufgaben durch Worker hinzu.


Allerdings ist das Perfekte der Feind des Guten. Beginnen Sie mit dem, was Sie haben, bringen Sie es zum Laufen und iterieren Sie dann. Viel Spaß beim Programmieren!