¿Alguna vez te preguntaste qué sucede cuando ejecutas sshfs user@remote:~/ /mnt/remoteroot
? ¿Cómo aparecen los archivos de un servidor remoto en su sistema local y se sincronizan tan rápidamente? ¿Has oído hablar de WikipediaFS , que te permite editar un artículo de Wikipedia como si fuera un archivo en tu sistema de archivos? No es magia, es el poder de FUSE (sistema de archivos en el espacio de usuario). FUSE le permite crear su propio sistema de archivos sin necesidad de un conocimiento profundo del kernel del sistema operativo o lenguajes de programación de bajo nivel.
Este artículo presenta una solución práctica que utiliza FUSE con Node.js y TypeScript. Exploraremos cómo funciona FUSE bajo el capó y demostraremos su aplicación resolviendo una tarea del mundo real. Únase a mí en una emocionante aventura en el mundo de FUSE y Node.js.
Era responsable de los archivos multimedia (principalmente imágenes) en mi trabajo. Esto incluye muchas cosas: banners laterales o superiores, medios en chats, stickers, etc. Por supuesto, hay muchos requisitos para estos, como "el banner es PNG o WEBP, 300x1000 píxeles". Si no se cumplen los requisitos, nuestro back office no permitirá el paso de una imagen. Y existe un mecanismo de deduplicación de objetos: ninguna imagen puede entrar dos veces en el mismo río.
Esto nos lleva a una situación en la que tenemos un conjunto masivo de imágenes para realizar pruebas. Utilicé frases ingeniosas o alias de shell para hacerme la vida más fácil.
Por ejemplo:
convert -size 300x1000 xc:gray +noise random /tmp/out.png
Una combinación de bash
y convert
es una gran herramienta, pero obviamente, esta no es la forma más conveniente de abordar el problema. Discutir la situación del equipo de control de calidad revela más complicaciones. Aparte del considerable tiempo dedicado a la generación de imágenes, la primera pregunta cuando investigamos un problema es "¿Estás seguro de que subiste una imagen única?" Creo que entiendes lo molesto que es esto.
Podría adoptar un enfoque simple: crear un servicio web que sirva una ruta con un archivo que se explica por sí mismo, como GET /image/1000x100/random.zip?imagesCount=100
. La ruta devolvería un archivo ZIP con un conjunto de imágenes únicas. Esto suena bien, pero no soluciona nuestro problema principal: todos los archivos cargados deben ser únicos para las pruebas.
Su próximo pensamiento podría ser "¿Podemos reemplazar una carga útil al enviarla?" El equipo de control de calidad utiliza Postman para llamadas API. Investigué los aspectos internos de Postman y me di cuenta de que no podemos cambiar el cuerpo de la solicitud "sobre la marcha".
Otra solución es reemplazar un archivo en el sistema de archivos cada vez que algo intenta leerlo. Linux tiene un subsistema de notificación llamado Inotify, que le alerta sobre eventos del sistema de archivos, como cambios en directorios o modificaciones de archivos. Si recibe el mensaje "Visual Studio Code no puede observar los cambios de archivos en este gran espacio de trabajo", hay un problema con Inotify. Puede activar un evento cuando se cambia un directorio, se cambia el nombre de un archivo, se abre un archivo, etc.
La lista completa de eventos se puede encontrar aquí: https://sites.uclouvain.be/SystInfo/usr/include/linux/inotify.h.html
Entonces, el plan es:
Escuchar el evento IN_OPEN
y contar descriptores de archivos.
Escuchando el evento IN_CLOSE
; si el recuento cae a 0, reemplazaremos el archivo.
Suena bien, pero hay un par de problemas con esto:
inotify
.
Para abordar estos problemas, podemos escribir nuestro propio sistema de archivos. Pero hay otro problema: el sistema de archivos normal se ejecuta en el espacio del kernel del sistema operativo. Requiere que conozcamos el kernel del sistema operativo y el uso de lenguajes como C/Rust. Además, para cada núcleo, debemos escribir un módulo específico (controlador).
Por lo tanto, escribir un sistema de archivos es excesivo para el problema que queremos resolver; incluso si hay un fin de semana largo por delante. Afortunadamente, hay una manera de domesticar a esta bestia: el sistema de archivos en el espacio de usuario (FUSE). FUSE es un proyecto que te permite crear sistemas de archivos sin editar el código del kernel. Esto significa que cualquier programa o script a través de FUSE, sin ninguna lógica compleja relacionada con el núcleo, puede emular una memoria flash, un disco duro o un SSD.
En otras palabras, un proceso normal del espacio de usuario puede crear su propio sistema de archivos, al que se puede acceder normalmente a través de cualquier programa normal que desee: Nautilus, Dolphin, ls, etc.
¿Por qué FUSE es bueno para cubrir nuestros requerimientos? Los sistemas de archivos basados en FUSE se construyen sobre procesos espaciados por el usuario. Por lo tanto, puede utilizar cualquier idioma que conozca que tenga un enlace a libfuse
. Además, obtienes una solución multiplataforma con FUSE.
He tenido mucha experiencia con NodeJS y TypeScript y me gustaría elegir esta (maravillosa) combinación como entorno de ejecución para nuestro nuevo FS. Además, TypeScript proporciona una excelente base orientada a objetos. Esto me permitirá mostrarles no sólo el código fuente, que pueden encontrar en el repositorio público de GitHub, sino también la estructura del proyecto.
Permítanme brindarles una cita de la página oficial de FUSE :
FUSE es un marco de sistema de archivos de espacio de usuario. Consiste en un módulo del kernel (fuse.ko), una biblioteca de espacio de usuario (libfuse.*) y una utilidad de montaje (fusermount).
Un marco para escribir sistemas de archivos suena emocionante.
Debo explicar qué significa cada parte de FUSE:
fuse.ko
realiza todos los trabajos de bajo nivel relacionados con el kernel; esto nos permite evitar la intervención en el kernel del sistema operativo.
libfuse
es una biblioteca que proporciona una capa de alto nivel para la comunicación con fuse.ko
fusermount
permite a los usuarios montar/desmontar sistemas de archivos del espacio de usuario (¡llámame Capitán Obvio!).
Los principios generales se ven así:
El proceso del espacio de usuario ( ls
en este caso) realiza una solicitud al kernel del sistema de archivos virtual que enruta la solicitud al módulo del kernel FUSE. El módulo FUSE, a su vez, enruta la solicitud de regreso al espacio de usuario para la realización del sistema de archivos ( ./hello
en la imagen de arriba).
No se deje engañar por el nombre del sistema de archivos virtual. No está directamente relacionado con el FUSE. Es la capa de software en el kernel la que proporciona la interfaz del sistema de archivos para los programas del espacio de usuario. En aras de la simplicidad, puede percibirlo como un patrón compuesto .
libfuse
ofrece dos tipos de API: de alto nivel y de bajo nivel. Tienen similitudes pero diferencias cruciales. El de bajo nivel es asíncrono y funciona sólo con inodes
. Asincrónico, en este caso, significa que un cliente que utiliza API de bajo nivel debe llamar a los métodos de respuesta por sí mismo.
El de alto nivel proporciona la posibilidad de utilizar rutas convenientes (por ejemplo, /etc/shadow
) en lugar de inodes
más "abstractos" y devuelve respuestas de forma sincronizada. En este artículo, explicaré cómo funciona el nivel alto en lugar del nivel bajo y inodes
.
Si desea implementar su propio sistema de archivos, debe implementar un conjunto de métodos responsables de atender las solicitudes desde VFS. Los métodos más comunes son:
open(path, accessFlags): fd
: abre un archivo por ruta. El método devolverá un número identificador, el llamado Descriptor de Archivo (en adelante fd
). Un indicador de acceso es una máscara binaria que describe qué operación desea realizar el programa cliente (solo lectura, solo escritura, lectura-escritura, ejecución o búsqueda).
read(path, fd, Buffer, size, offset): count of bytes read
: size
de lectura en bytes de un archivo vinculado con el descriptor de archivo fd
al búfer pasado. El argumento path
se ignora porque usaremos fd.
write(path, fd, Buffer, size, offset): count of bytes written
: escriba bytes size
desde el búfer a un archivo vinculado con fd
.
release(fd)
- cierra el fd
.
truncate(path, size)
: cambia el tamaño de un archivo. El método debe definirse si desea reescribir archivos (y lo hacemos).
getattr(path)
: devuelve parámetros de archivo como tamaño, creado en, accedido en, etc. El método es el más invocable por el sistema de archivos, así que asegúrese de crear el óptimo.
readdir(path)
: lee todos los subdirectorios.
Los métodos anteriores son vitales para cada sistema de archivos totalmente operable construido sobre la API FUSE de alto nivel. Pero la lista no está completa; la lista completa se puede encontrar en https://libfuse.github.io/doxygen/structfuse__operations.html
Para revisar el concepto de descriptor de archivo: en sistemas tipo UNIX, incluido MacOS, un descriptor de archivo es una abstracción para archivos y otros recursos de E/S como sockets y tuberías. Cuando un programa abre un archivo, el sistema operativo devuelve un identificador numérico llamado descriptor de archivo. Este número entero sirve como índice en la tabla de descriptores de archivos del sistema operativo para cada proceso. Al implementar un sistema de archivos usando FUSE, necesitaremos generar descriptores de archivos nosotros mismos.
Consideremos el flujo de llamadas cuando el cliente abre un archivo:
getattr(path: /random.png) → { size: 98 };
el cliente obtuvo el tamaño del archivo.
open(path: /random.png) → 10;
archivo abierto por ruta; La implementación de FUSE devuelve un número de descriptor de archivo.
read(path: /random.png, fd: 10 buffer, size: 50, offset: 0) → 50;
lee los primeros 50 bytes.
read(path: /random.png, fd: 10 buffer, size: 50, offset: 50) → 48;
lea los siguientes 50. Los 48 bytes se leyeron debido al tamaño del archivo.
release(10);
Se leyeron todos los datos, muy cerca del fd.
Nuestro siguiente paso es desarrollar un sistema de archivos mínimo basado en libfuse
para probar cómo interactuará Postman con un sistema de archivos personalizado.
Los requisitos de aceptación para el FS son sencillos: la raíz del FS debe contener un archivo random.txt
, cuyo contenido debe ser único cada vez que se lee (llamémoslo "lectura siempre única"). El contenido debe contener un UUID aleatorio y una hora actual en formato ISO, separados por una nueva línea. Por ejemplo:
3790d212-7e47-403a-a695-4d680f21b81c 2012-12-12T04:30:30
El producto mínimo constará de dos partes. El primero es un servicio web simple que aceptará solicitudes HTTP POST e imprimirá el cuerpo de una solicitud en la terminal. El código es bastante simple y no merece la pena, principalmente porque el artículo trata sobre FUSE, no sobre Express. La segunda parte es la implementación del sistema de archivos que cumpla con los requisitos. Tiene sólo 83 líneas de código.
Para el código, usaremos la biblioteca node-fuse-bindings, que proporciona enlaces a la API de alto nivel de libfuse
.
Puede omitir el código a continuación; Voy a escribir un resumen del código a continuación.
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();
Sugiero actualizar nuestro conocimiento sobre los bits de permiso en un archivo. Los bits de permiso son un conjunto de bits asociados con un archivo; son una representación binaria de quién puede leer/escribir/ejecutar el archivo. "Quién" incluye tres grupos: el propietario, el grupo propietario y otros.
Los permisos se pueden configurar para cada grupo por separado. Por lo general, cada permiso está representado por un número de tres dígitos: lectura (4 o '100' en el sistema numérico binario), escritura (2 o '010') y ejecución (1 o '001'). Si suma estos números, creará un permiso combinado. Por ejemplo, 4 + 2 (o '100' + '010') darán 6 ('110'), lo que significa permiso de lectura + escritura (RO).
Si el propietario del archivo tiene una máscara de acceso de 7 (111 en binario, que significa leer, escribir y ejecutar), el grupo tiene 5 (101, que significa leer y ejecutar) y los demás tienen 4 (100, que significa solo lectura). Por tanto, la máscara de acceso completa al fichero es 754 en decimal. Tenga en cuenta que el permiso de ejecución se convierte en permiso de lectura para directorios.
Volvamos a la implementación del sistema de archivos y hagamos una versión de texto de esto: cada vez que se abre un archivo (a través de una llamada open
), el contador de enteros se incrementa, produciendo el descriptor de archivo devuelto por la llamada abierta. Luego se crea contenido aleatorio y se guarda en un almacén de valores-clave con el descriptor de archivo como clave. Cuando se realiza una llamada de lectura, se devuelve la parte del contenido correspondiente.
Tras una llamada de liberación, el contenido se elimina. Recuerde manejar SIGINT
para desmontar el sistema de archivos después de presionar Ctrl+C. De lo contrario, tendremos que hacerlo manualmente en la terminal usando fusermount -u ./MOUNT_PATH
.
Ahora, salta a las pruebas. Ejecutamos el servidor web, luego creamos una carpeta vacía como carpeta raíz para el próximo FS y ejecutamos el script principal. Después de que se imprima la línea "Servidor escuchando en el puerto 3000", abra Postman y envíe un par de solicitudes seguidas al servidor web sin cambiar ningún parámetro.
¡Todo se ve bien! Cada solicitud tiene un contenido de archivo único, como previmos. Los registros también prueban que el flujo de llamadas de apertura de archivos descrito anteriormente en la sección "Profundización en FUSE" es correcto.
El repositorio de GitHub con MVP: https://github.com/pinkiesky/node-fuse-mvp . Puede ejecutar este código en su entorno local o utilizar este repositorio como modelo para la implementación de su propio sistema de archivos.
El enfoque está comprobado; ahora es el momento de la implementación primaria.
Antes de la implementación de la "lectura siempre única", lo primero que debemos implementar son operaciones de creación y eliminación de archivos originales. Implementaremos esta interfaz a través de un directorio dentro de nuestro sistema de archivos virtual. El usuario colocará las imágenes originales que quiera que sean "siempre únicas" o "aleatorizadas" y el sistema de archivos preparará el resto.
Aquí y en las siguientes secciones, "lectura siempre única", "imagen aleatoria" o "archivo aleatorio" se refiere a un archivo que devuelve contenido único en sentido binario cada vez que se lee, mientras que visualmente permanece lo más similar posible. al original.
La raíz del sistema de archivos contendrá dos directorios: Administrador de imágenes e Imágenes. La primera es una carpeta para administrar los archivos originales del usuario (puede considerarla como un repositorio CRUD). El segundo es el directorio no administrado desde el punto de vista del usuario que contiene imágenes aleatorias.
Como puede ver en la imagen de arriba, también implementaremos no solo imágenes "siempre únicas", sino también un conversor de archivos. Esa es una ventaja adicional.
La idea central de nuestra implementación es que el programa contendrá un árbol de objetos, en el que cada nodo y hoja proporcionarán métodos FUSE comunes. Cuando el programa recibe una llamada FS, debería encontrar un nodo o una hoja en el árbol por la ruta correspondiente. Por ejemplo, el programa recibe la llamada getattr(/Images/1/original/)
y luego intenta encontrar el nodo al que se dirige la ruta.
La siguiente pregunta es cómo almacenaremos las imágenes originales. Una imagen en el programa constará de datos binarios y metainformación (una meta incluye un nombre de archivo original, un tipo de archivo mime, etc.). Los datos binarios se almacenarán en un almacenamiento binario. Simplifiquémoslo y construyamos almacenamiento binario como un conjunto de archivos binarios en el sistema de archivos del usuario (o host). La metainformación se almacenará de manera similar: JSON dentro de archivos de texto en el sistema de archivos del usuario.
Como recordará, en la sección "Escribamos un producto mínimo viable", creamos un sistema de archivos que devuelve un archivo de texto mediante una plantilla. Contiene un UUID aleatorio más una fecha actual, por lo que la unicidad de los datos no fue el problema: la unicidad se logró mediante la definición de los datos. Sin embargo, a partir de este punto, el programa debería funcionar con imágenes de usuario precargadas. Entonces, ¿cómo podemos crear imágenes que sean similares pero siempre únicas (en términos de bytes y, en consecuencia, hashes) basadas en la original?
La solución que sugiero es bastante simple. Pongamos un cuadrado de ruido RGB en la esquina superior izquierda de una imagen. El cuadrado de ruido debe tener 16x16 píxeles. Esto proporciona casi la misma imagen pero garantiza una secuencia única de bytes. ¿Será suficiente para garantizar muchas imágenes diferentes? Hagamos algunos cálculos. El tamaño del cuadrado es 16. 16×16 = 256 píxeles RGB en un solo cuadrado. Cada píxel tiene 256×256×256 = 16.777.216 variantes.
Por lo tanto, el recuento de cuadrados únicos es 16.777.216^256, un número con 1.558 dígitos, que es mucho más que el número de átomos en el universo observable. ¿Eso significa que podemos reducir el tamaño del cuadrado? Desafortunadamente, la compresión con pérdida como JPEG reduciría significativamente la cantidad de cuadrados únicos, por lo que 16x16 es el tamaño óptimo.
IFUSEHandler
es una interfaz que atiende llamadas FUSE comunes. Puedes ver que reemplacé read/write
con readAll/writeAll
, respectivamente. Hice esto para simplificar las operaciones de lectura y escritura: cuando IFUSEHandler
realiza la lectura/escritura de una parte completa, podemos mover la lógica de lectura/escritura parcial a otro lugar. Esto significa que IFUSEHandler
no necesita saber nada sobre descriptores de archivos, datos binarios, etc.
Lo mismo sucedió también con el método open
FUSE. Un aspecto notable del árbol es que se genera bajo demanda. En lugar de almacenar todo el árbol en la memoria, el programa crea nodos sólo cuando se accede a ellos. Este comportamiento permite al programa evitar problemas con la reconstrucción del árbol en caso de creación o eliminación de nodos.
Verifique la interfaz ObjectTreeNode
y encontrará que children
no son una matriz sino un método, así es como se generan bajo demanda. FileFUSETreeNode
y DirectoryFUSETreeNode
son clases abstractas donde algunos métodos arrojan un error NotSupported
(obviamente, FileFUSETreeNode
nunca debería implementar readdir
).
FUSEFacade es la clase más importante que implementa la lógica principal del programa y une diferentes partes. node-fuse-bindings
tiene una API basada en devolución de llamada, pero los métodos FUSEFacade se crean con una basada en Promesa. Para solucionar este inconveniente, utilicé un código como este:
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); // },
Los métodos FUSEFacade
están incluidos en handleResultWrapper
. Cada método de FUSEFacade
que utiliza una ruta simplemente analiza la ruta, encuentra un nodo en el árbol y llama al método solicitado.
Considere un par de métodos de la clase 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; }
Antes de dar el siguiente paso, echemos un vistazo más de cerca a qué es un descriptor de archivo en el contexto de nuestro programa.
ReadWriteFileDescriptor
es una clase que almacena descriptores de archivos como un número y datos binarios como un búfer. La clase tiene métodos readToBuffer
y writeToBuffer
que brindan la capacidad de leer y escribir datos en un búfer de descriptor de archivos. ReadFileDescriptor
y WriteFileDescriptor
son implementaciones de descriptores de solo lectura y de solo escritura.
IFileDescriptorStorage
es una interfaz que describe el almacenamiento de descriptores de archivos. El programa tiene sólo una implementación para esta interfaz: InMemoryFileDescriptorStorage
. Como puede ver por el nombre, almacena descriptores de archivos en la memoria porque no necesitamos persistencia para los descriptores.
Veamos cómo FUSEFacade
utiliza los descriptores de archivos y el almacenamiento:
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; }
El código anterior es sencillo. Define métodos para leer, escribir y liberar descriptores de archivos, asegurando que el descriptor de archivo sea válido antes de realizar operaciones. El método de liberación también escribe datos desde un objeto descriptor de archivo en el nodo del sistema de archivos y libera el descriptor de archivo.
Hemos terminado con el código relacionado con libfuse
y el árbol. Es hora de sumergirse en el código relacionado con las imágenes.
ImageMeta
es un objeto que almacena metainformación sobre una imagen. IImageMetaStorage
es una interfaz que describe un almacenamiento para meta. El programa tiene solo una implementación para la interfaz: la clase FSImageMetaStorage
implementa la interfaz IImageMetaStorage
para administrar los metadatos de imágenes almacenados en un único archivo JSON.
Utiliza un caché para almacenar metadatos en la memoria y garantiza que el caché esté hidratado leyendo el archivo JSON cuando sea necesario. La clase proporciona métodos para crear, recuperar, enumerar y eliminar metadatos de imágenes, y escribe los cambios en el archivo JSON para conservar las actualizaciones. La caché mejora el rendimiento al reducir el recuento de operaciones de E/S.
ImageBinary
, obviamente, es un objeto que tiene datos de imagen binaria. La interfaz Image
es la composición de ImageMeta
e ImageBinary
.
IBinaryStorage
es una interfaz para almacenamiento de datos binarios. El almacenamiento binario debe estar desvinculado de las imágenes y puede almacenar cualquier dato: imágenes, vídeo, JSON o texto. Este hecho es importante para nosotros y verá por qué.
IImageGenerator
es una interfaz que describe un generador. El generador es una parte importante del programa. Toma datos binarios sin procesar más meta y genera una imagen basada en ellos. ¿Por qué el programa necesita generadores? ¿Puede el programa funcionar sin ellos?
Puede, pero los generadores agregarán flexibilidad a la implementación. Los generadores permiten a los usuarios cargar imágenes, datos de texto y, en términos generales, cualquier dato para el cual escriba un generador.
El flujo es el siguiente: los datos binarios se cargan desde el almacenamiento ( myfile.txt
en la imagen de arriba) y luego el binario pasa a un generador. Genera una imagen "sobre la marcha". Puedes percibirlo como un conversor de un formato a otro que nos resulte más cómodo.
Veamos un ejemplo de un generador:
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 clase ImageLoaderFacade
es una fachada que combina lógicamente el almacenamiento y el generador; en otras palabras, implementa el flujo que leyó anteriormente.
IImageVariant
es una interfaz para crear varias variantes de imágenes. En este contexto, una variante es una imagen generada "sobre la marcha" que se mostrará al usuario cuando vea archivos en nuestro sistema de archivos. La principal diferencia con los generadores es que toma una imagen como entrada en lugar de datos sin procesar.
El programa tiene tres variantes: ImageAlwaysRandom
, ImageOriginalVariant
e ImageWithText
. ImageAlwaysRandom
devuelve la imagen original con un cuadrado de ruido RGB aleatorio.
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 }; } }
Utilizo la biblioteca sharp
como la forma más conveniente de operar con imágenes en NodeJS: https://github.com/lovell/sharp .
ImageOriginalVariant
devuelve una imagen sin ningún cambio (pero puede devolver una imagen en un formato de compresión diferente). ImageWithText
devuelve una imagen con texto escrito encima. Esto será útil cuando creemos variantes predefinidas de una sola imagen. Por ejemplo, si necesitamos 10 variaciones aleatorias de una imagen, debemos distinguir estas variaciones entre sí.
La solución aquí es crear 10 imágenes basadas en el original, donde representamos un número secuencial del 0 al 9 en la esquina superior izquierda de cada imagen.
ImageCacheWrapper
tiene un propósito diferente al de las variantes y actúa como un contenedor al almacenar en caché los resultados de la clase IImageVariant
particular. Se utilizará para envolver entidades que no cambian, como un convertidor de imágenes, generadores de texto a imagen, etc. Este mecanismo de almacenamiento en caché permite una recuperación de datos más rápida, principalmente cuando las mismas imágenes se leen varias veces.
Bueno, hemos cubierto todas las partes principales del programa. Es hora de combinar todo junto.
El siguiente diagrama de clases representa cómo se combinan las clases de árbol con sus contrapartes de imágenes. El diagrama debe leerse de abajo hacia arriba. RootDir
(permítanme evitar el sufijo FUSETreeNode
en los nombres) es el directorio raíz del sistema de archivos que está implementando el programa. Pasando a la fila superior, vea dos directorios: ImagesDir
e ImagesManagerDir
. ImagesManagerDir
contiene la lista de imágenes del usuario y permite controlarlas. Entonces, ImagesManagerItemFile
es un nodo para un archivo en particular. Esta clase implementa operaciones CRUD.
Considere ImagesManagerDir como una implementación habitual de un nodo:
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(); } }
En el futuro, ImagesDir
contiene subdirectorios con nombres de las imágenes del usuario. ImagesItemDir
es responsable de cada directorio. Incluye todas las variantes disponibles; Como recordarás, el número de variantes es tres. Cada variante es un directorio que contiene los archivos de imagen finales en diferentes formatos (actualmente: jpeg, png y webm). ImagesItemOriginalDir
e ImagesItemCounterDir
envuelven todas las instancias ImageVariantFile
generadas en un caché.
Esto es necesario para evitar la recodificación constante de las imágenes originales porque la codificación consume CPU. En la parte superior del diagrama está ImageVariantFile
. Es la joya de la corona de la implementación y composición de IFUSEHandler
e IImageVariant
descritos anteriormente. Este es el archivo hacia el que se han ido encaminando todos nuestros esfuerzos.
Probemos cómo el sistema de archivos final maneja las solicitudes paralelas al mismo archivo. Para hacer esto, ejecutaremos la utilidad md5sum
en múltiples subprocesos, que leerá archivos del sistema de archivos y calculará sus hashes. Luego, compararemos estos hashes. Si todo funciona correctamente, los hashes deberían ser diferentes.
#!/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
Ejecuté el script y verifiqué el siguiente resultado (limpiado un poco para mayor claridad):
Run 1... Run 2... Run 3... Run 4... Run 5... wait... bcdda97c480db74e14b8779a4e5c9d64 0954d3b204c849ab553f1f5106d576aa 564eeadfd8d0b3e204f018c6716c36e9 73a92c5ef27992498ee038b1f4cfb05e 77db129e37fdd51ef68d93416fec4f65
¡Excelente! ¡Todos los hashes son diferentes, lo que significa que el sistema de archivos devuelve una imagen única cada vez!
Espero que este artículo te haya inspirado a escribir tu propia implementación de FUSE. Recuerde, el código fuente de este proyecto está disponible aquí: https://github.com/pinkiesky/node-fuse-images .
El sistema de archivos que hemos creado está simplificado para demostrar los principios básicos del trabajo con FUSE y Node.js. Por ejemplo, no tiene en cuenta las fechas correctas. Hay mucho margen de mejora. Imagine agregar funcionalidades como extracción de fotogramas de archivos GIF de usuarios, transcodificación de videos o incluso paralelización de tareas a través de trabajadores.
Sin embargo, lo perfecto es enemigo de lo bueno. Comience con lo que tiene, hágalo funcionar y luego repita. ¡Feliz codificación!