paint-brush
Como construir um sistema de arquivos dinâmico com FUSE e Node.js: uma abordagem práticapor@rglr
377 leituras
377 leituras

Como construir um sistema de arquivos dinâmico com FUSE e Node.js: uma abordagem prática

por Aleksandr Zinin33m2024/06/13
Read on Terminal Reader

Muito longo; Para ler

Você já se perguntou o que acontece quando você executa sshfs user@remote:~/ /mnt/remoteroot? Como os arquivos de um servidor remoto aparecem no sistema local e são sincronizados tão rapidamente? Você já ouviu falar do WikipediaFS, que permite editar um artigo da Wikipedia como se fosse um arquivo em seu sistema de arquivos? Não é mágica – é o poder do FUSE (Filesystem in Userspace). O FUSE permite criar seu próprio sistema de arquivos sem a necessidade de conhecimento profundo do kernel do sistema operacional ou de linguagens de programação de baixo nível. Este artigo apresenta uma solução prática usando FUSE com Node.js e TypeScript. Exploraremos como o FUSE funciona nos bastidores e demonstraremos sua aplicação resolvendo uma tarefa do mundo real. Junte-se a mim em uma emocionante aventura no mundo do FUSE e do Node.js.
featured image - Como construir um sistema de arquivos dinâmico com FUSE e Node.js: uma abordagem prática
Aleksandr Zinin HackerNoon profile picture

Você já se perguntou o que acontece quando você executa sshfs user@remote:~/ /mnt/remoteroot ? Como os arquivos de um servidor remoto aparecem no sistema local e são sincronizados tão rapidamente? Você já ouviu falar do WikipediaFS , que permite editar um artigo da Wikipédia como se fosse um arquivo no seu sistema de arquivos? Não é mágica – é o poder do FUSE (Filesystem in Userspace). O FUSE permite criar seu próprio sistema de arquivos sem a necessidade de conhecimento profundo do kernel do sistema operacional ou de linguagens de programação de baixo nível.


Este artigo apresenta uma solução prática usando FUSE com Node.js e TypeScript. Exploraremos como o FUSE funciona nos bastidores e demonstraremos sua aplicação resolvendo uma tarefa do mundo real. Junte-se a mim em uma emocionante aventura no mundo do FUSE e do Node.js.

Introdução

Fui responsável pelos arquivos de mídia (principalmente imagens) em meu trabalho. Isso inclui muitas coisas: banners laterais ou superiores, mídia em bate-papos, adesivos, etc. É claro que há muitos requisitos para isso, como "o banner é PNG ou WEBP, 300x1000 pixels". Se os requisitos não forem atendidos, nosso back office não permitirá a passagem de imagem. E existe um mecanismo de desduplicação de objetos: nenhuma imagem pode entrar duas vezes no mesmo rio.


Isso nos leva a uma situação em que temos um enorme conjunto de imagens para fins de teste. Usei one-liners ou aliases de shell para tornar minha vida mais fácil.


Por exemplo:

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


Exemplo de imagem de ruído


Uma combinação de bash e convert é uma ótima ferramenta, mas obviamente essa não é a maneira mais conveniente de resolver o problema. Discutir a situação da equipe de controle de qualidade revela complicações adicionais. Além do considerável tempo gasto na geração de imagens, a primeira pergunta quando investigamos um problema é “Tem certeza de que carregou uma imagem exclusiva?” Acredito que você entende como isso é irritante.

Vamos escolher a tecnologia que gostaríamos de usar

Você poderia adotar uma abordagem simples: criar um serviço da web que sirva uma rota com um arquivo autoexplicativo, como GET /image/1000x100/random.zip?imagesCount=100 . A rota retornaria um arquivo ZIP com um conjunto de imagens exclusivas. Isso parece bom, mas não resolve o nosso problema principal: todos os arquivos enviados precisam ser exclusivos para teste.


Seu próximo pensamento pode ser "Podemos substituir uma carga útil ao enviá-la?" A equipe de controle de qualidade usa o Postman para chamadas de API. Investiguei os detalhes internos do Postman e percebi que não podemos alterar o corpo da solicitação "instantaneamente"


Outra solução é substituir um arquivo no sistema de arquivos sempre que algo tentar ler o arquivo. O Linux possui um subsistema de notificação chamado Inotify, que alerta sobre eventos do sistema de arquivos, como alterações em diretórios ou modificações em arquivos. Se você estava recebendo "O Visual Studio Code não consegue observar alterações de arquivo neste grande espaço de trabalho", há um problema com o Inotify. Ele pode disparar um evento quando um diretório é alterado, um arquivo é renomeado, um arquivo é aberto e assim por diante.


A lista completa de eventos pode ser encontrada aqui: https://sites.uclouvain.be/SystInfo/usr/include/linux/inotify.h.html


Então, o plano é:

  1. Ouvindo o evento IN_OPEN e contando descritores de arquivo.

  2. Ouvindo o evento IN_CLOSE ; se a contagem cair para 0, substituiremos o arquivo.


Parece bom, mas há alguns problemas com isso:

  • Somente Linux suporta inotify .
  • Solicitações paralelas ao arquivo devem retornar os mesmos dados.
  • Se um arquivo tiver operações IO intensivas, a substituição nunca aconteceria.
  • Se um serviço que atende eventos do Inotify falhar, os arquivos permanecerão no sistema de arquivos do usuário.


Para resolver esses problemas, podemos escrever nosso próprio sistema de arquivos. Mas há outro problema: o sistema de arquivos normal é executado no espaço do kernel do sistema operacional. Requer que conheçamos o kernel do sistema operacional e o uso de linguagens como C/Rust. Além disso, para cada kernel, devemos escrever um módulo específico (driver).


Portanto, escrever um sistema de arquivos é um exagero para o problema que queremos resolver; mesmo que haja um fim de semana prolongado pela frente. Felizmente, existe uma maneira de domar essa fera: Sistema de arquivos em uso rspace (FUSE). FUSE é um projeto que permite criar sistemas de arquivos sem editar o código do kernel. Isso significa que qualquer programa ou script através do FUSE, sem qualquer lógica complexa relacionada ao núcleo, é capaz de emular um flash, disco rígido ou SSD.


Em outras palavras, um processo comum no espaço do usuário pode criar seu próprio sistema de arquivos, que pode ser acessado normalmente através de qualquer programa comum que você desejar – Nautilus, Dolphin, ls, etc.


Por que o FUSE é bom para atender aos nossos requisitos? Os sistemas de arquivos baseados em FUSE são construídos sobre processos espaçados pelo usuário. Portanto, você pode usar qualquer linguagem que você conheça que tenha uma ligação com libfuse . Além disso, você obtém uma solução multiplataforma com o FUSE.


Tenho muita experiência com NodeJS e TypeScript e gostaria de escolher esta (maravilhosa) combinação como ambiente de execução para nosso novo FS. Além disso, o TypeScript fornece uma excelente base orientada a objetos. Isso me permitirá mostrar não apenas o código-fonte, que você pode encontrar no repositório público do GitHub, mas também a estrutura do projeto.

Mergulhe fundo no FUSE

Deixe-me fornecer uma citação da página oficial do FUSE :

FUSE é uma estrutura de sistema de arquivos do espaço do usuário. Ele consiste em um módulo de kernel (fuse.ko), uma biblioteca de espaço de usuário (libfuse.*) e um utilitário de montagem (fusermount).


Uma estrutura para escrever sistemas de arquivos parece interessante.


Devo explicar o que cada parte do FUSE significa:

  1. fuse.ko está fazendo todos os trabalhos de baixo nível relacionados ao kernel; isso nos permite evitar a intervenção no kernel do sistema operacional.


  2. libfuse é uma biblioteca que fornece uma camada de alto nível para comunicação com fuse.ko .


  3. fusermount permite aos usuários montar/desmontar sistemas de arquivos do espaço do usuário (me chame de Capitão Óbvio!).


Os princípios gerais são assim:
Os princípios gerais do FUSE


O processo do espaço do usuário ( ls neste caso) faz uma solicitação ao kernel do Virtual File System que roteia a solicitação para o módulo do kernel FUSE. O módulo FUSE, por sua vez, roteia a solicitação de volta ao espaço do usuário para a realização do sistema de arquivos ( ./hello na imagem acima).


Não se deixe enganar pelo nome do Virtual File System. Não está diretamente relacionado ao FUSE. É a camada de software no kernel que fornece a interface do sistema de arquivos para os programas do espaço do usuário. Por uma questão de simplicidade, você pode percebê-lo como um padrão Composite .


libfuse oferece dois tipos de APIs: alto nível e baixo nível. Eles têm semelhanças, mas diferenças cruciais. O de baixo nível é assíncrono e funciona apenas com inodes . Assíncrono, neste caso, significa que um cliente que usa API de baixo nível deve chamar os métodos de resposta sozinho.


O de alto nível fornece a capacidade de usar caminhos convenientes (por exemplo, /etc/shadow ) em vez de inodes mais "abstratos" e retorna respostas de forma sincronizada. Neste artigo, explicarei como funciona o alto nível, em vez do baixo nível e inodes .


Se você deseja implementar seu próprio sistema de arquivos, você deve implementar um conjunto de métodos responsáveis pelas solicitações atendidas pelo VFS. Os métodos mais comuns são:


  • open(path, accessFlags): fd – abre um arquivo por caminho. O método deve retornar um identificador numérico, o chamado Descritor de Arquivo (daqui em diante fd ). Um sinalizador de acesso é uma máscara binária que descreve qual operação o programa cliente deseja executar (somente leitura, somente gravação, leitura-gravação, execução ou pesquisa).


  • read(path, fd, Buffer, size, offset): count of bytes read - lê bytes size de um arquivo vinculado ao descritor de arquivo fd para o Buffer passado. O argumento path é ignorado porque usaremos fd.


  • write(path, fd, Buffer, size, offset): count of bytes written - grava bytes size do Buffer em um arquivo vinculado a fd .


  • release(fd) – fecha o fd .


  • truncate(path, size) - altera o tamanho do arquivo. O método deve ser definido se você quiser reescrever arquivos (e nós queremos).


  • getattr(path) – retorna parâmetros de arquivo como tamanho, criado em, acessado em, etc. O método é o método mais chamável pelo sistema de arquivos, portanto, certifique-se de criar o método ideal.


  • readdir(path) – lê todos os subdiretórios.


Os métodos acima são vitais para cada sistema de arquivos totalmente operável construído sobre a API FUSE de alto nível. Mas a lista não está completa; a lista completa você pode encontrar em https://libfuse.github.io/doxygen/structfuse__operations.html


Para revisitar o conceito de descritor de arquivo: Em sistemas do tipo UNIX, incluindo MacOS, um descritor de arquivo é uma abstração para arquivos e outros recursos de E/S, como soquetes e pipes. Quando um programa abre um arquivo, o sistema operacional retorna um identificador numérico denominado descritor de arquivo. Este número inteiro serve como um índice na tabela de descritores de arquivos do sistema operacional para cada processo. Ao implementar um sistema de arquivos usando FUSE, precisaremos gerar nós mesmos os descritores de arquivo.


Vamos considerar o fluxo de chamadas quando o cliente abre um arquivo:

  1. getattr(path: /random.png) → { size: 98 }; o cliente obteve o tamanho do arquivo.


  2. open(path: /random.png) → 10; arquivo aberto por caminho; A implementação do FUSE retorna um número de descritor de arquivo.


  3. read(path: /random.png, fd: 10 buffer, size: 50, offset: 0) → 50; leia os primeiros 50 bytes.


  4. read(path: /random.png, fd: 10 buffer, size: 50, offset: 50) → 48; leia os próximos 50. Os 48 bytes foram lidos devido ao tamanho do arquivo.


  5. release(10); todos os dados foram lidos, tão perto do fd.

Vamos escrever um produto mínimo viável e verificar a reação do carteiro a ele

Nosso próximo passo é desenvolver um sistema de arquivos mínimo baseado em libfuse para testar como o Postman irá interagir com um sistema de arquivos personalizado.


Os requisitos de aceitação para o FS são simples: A raiz do FS deve conter um arquivo random.txt , cujo conteúdo deve ser único cada vez que for lido (vamos chamar isso de "leitura sempre única"). O conteúdo deve conter um UUID aleatório e uma hora atual no formato ISO, separados por uma nova linha. Por exemplo:

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


O produto mínimo consistirá em duas partes. O primeiro é um serviço web simples que aceitará solicitações HTTP POST e imprimirá um corpo de solicitação no terminal. O código é bastante simples e não vale o nosso tempo, principalmente porque o artigo é sobre FUSE, não sobre Express. A segunda parte é a implementação do sistema de arquivos que atenda aos requisitos. Possui apenas 83 linhas de código.


Para o código, usaremos a biblioteca node-fuse-bindings, que fornece ligações para a API de alto nível do libfuse .


Você pode pular o código abaixo; Vou escrever um resumo do código abaixo.

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


Sugiro atualizar nosso conhecimento sobre bits de permissão em um arquivo. Bits de permissão são um conjunto de bits associados a um arquivo; eles são uma representação binária de quem tem permissão para ler/escrever/executar o arquivo. "Quem" inclui três grupos: o proprietário, o grupo proprietário e outros.


As permissões podem ser definidas para cada grupo separadamente. Normalmente, cada permissão é representada por um número de três dígitos: leitura (4 ou '100' no sistema numérico binário), gravação (2 ou '010') e execução (1 ou '001'). Se você somar esses números, criará uma permissão combinada. Por exemplo, 4 + 2 (ou '100' + '010') resultará em 6 ('110'), o que significa permissão de leitura + gravação (RO).


Se o proprietário do arquivo tiver uma máscara de acesso 7 (111 em binário, significando leitura, gravação e execução), o grupo terá 5 (101, significando leitura e execução) e outros terão 4 (100, significando somente leitura). Portanto, a máscara de acesso completa do arquivo é 754 em decimal. Tenha em mente que a permissão de execução se torna permissão de leitura para diretórios.


Vamos voltar à implementação do sistema de arquivos e fazer uma versão em texto disso: Cada vez que um arquivo é aberto (por meio de uma chamada open ), o contador inteiro aumenta, produzindo o descritor de arquivo retornado pela chamada open. O conteúdo aleatório é então criado e salvo em um armazenamento de valores-chave com o descritor de arquivo como chave. Quando uma chamada de leitura é feita, a parte do conteúdo correspondente é retornada.


Após uma chamada de liberação, o conteúdo é excluído. Lembre-se de usar SIGINT para desmontar o sistema de arquivos após pressionar Ctrl+C. Caso contrário, teremos que fazer isso manualmente no terminal usando fusermount -u ./MOUNT_PATH .


Agora, vá para os testes. Executamos o servidor web, criamos uma pasta vazia como pasta raiz para o próximo FS e executamos o script principal. Após a impressão da linha "Servidor escutando na porta 3000", abra o Postman e envie algumas solicitações consecutivas ao servidor web sem alterar nenhum parâmetro.
O lado esquerdo é o FS, o direito é o servidor web


Tudo parece bem! Cada solicitação possui um conteúdo de arquivo exclusivo, conforme previmos. Os logs também provam que o fluxo de chamadas de abertura de arquivo descrito acima na seção "Aprofundamento no FUSE" está correto.


O repositório GitHub com MVP: https://github.com/pinkiesky/node-fuse-mvp . Você pode executar esse código em seu ambiente local ou usar este repositório como modelo para sua própria implementação de sistema de arquivos.

A ideia central

A abordagem foi verificada – agora é hora da implementação primária.


Antes da implementação da "leitura sempre única", a primeira coisa que devemos implementar é criar e excluir operações para arquivos originais. Implementaremos esta interface através de um diretório dentro de nosso sistema de arquivos virtual. O usuário colocará as imagens originais que deseja tornar "sempre únicas" ou "randomizadas" e o sistema de arquivos preparará o resto.


Aqui e nas seções a seguir, "leitura sempre exclusiva", "imagem aleatória" ou "arquivo aleatório" refere-se a um arquivo que retorna conteúdo exclusivo em sentido binário cada vez que é lido, embora visualmente permaneça o mais semelhante possível. ao original.


A raiz do sistema de arquivos conterá dois diretórios: Image Manager e Images. A primeira é uma pasta para gerenciar os arquivos originais do usuário (você pode pensar nela como um repositório CRUD). O segundo é o diretório não gerenciado do ponto de vista do usuário que contém imagens aleatórias.
O usuário interage com o sistema de arquivos


Árvore FS como saída do terminal


Como você pode ver na imagem acima, também implementaremos não apenas imagens “sempre únicas”, mas também um conversor de arquivos! Isso é um bônus adicional.


A ideia central de nossa implementação é que o programa contenha uma árvore de objetos, com cada nó e folha fornecendo métodos FUSE comuns. Quando o programa recebe uma chamada FS, ele deve encontrar um nó ou folha na árvore pelo caminho correspondente. Por exemplo, o programa recebe a chamada getattr(/Images/1/original/) e então tenta encontrar o nó ao qual o caminho é endereçado.


Algo assim: Exemplo de árvore FS


A próxima questão é como armazenaremos as imagens originais. Uma imagem no programa consistirá em dados binários e meta informações (uma meta inclui um nome de arquivo original, tipo MIME de arquivo, etc.). Os dados binários serão armazenados em armazenamento binário. Vamos simplificá-lo e construir armazenamento binário como um conjunto de arquivos binários no sistema de arquivos do usuário (ou host). As metainformações serão armazenadas de forma semelhante: JSON dentro de arquivos de texto no sistema de arquivos do usuário.


Como você deve se lembrar, na seção “Vamos escrever um produto mínimo viável”, criamos um sistema de arquivos que retorna um arquivo de texto por meio de um modelo. Ele contém um UUID aleatório mais uma data atual, portanto a exclusividade dos dados não era o problema — a exclusividade foi alcançada pela definição dos dados. Porém, a partir deste ponto, o programa deverá funcionar com imagens de usuário pré-carregadas. Então, como podemos criar imagens semelhantes, mas sempre únicas (em termos de bytes e consequentemente de hashes) com base na original?


A solução que sugiro é bastante simples. Vamos colocar um quadrado de ruído RGB no canto superior esquerdo de uma imagem. O quadrado de ruído deve ter 16x16 pixels. Isso fornece quase a mesma imagem, mas garante uma sequência única de bytes. Será suficiente para garantir muitas imagens diferentes? Vamos fazer algumas contas. O tamanho do quadrado é 16,16×16 = 256 pixels RGB em um único quadrado. Cada pixel tem 256×256×256 = 16.777.216 variantes.


Assim, a contagem de quadrados únicos é 16.777.216 ^ 256 – um número com 1.558 dígitos, que é muito mais do que o número de átomos no universo observável. Isso significa que podemos reduzir o tamanho do quadrado? Infelizmente, a compactação com perdas como JPEG reduziria significativamente o número de quadrados exclusivos, portanto 16x16 é o tamanho ideal.


Exemplo de imagens com quadrados de ruído

Passagem por classes

A árvore
Diagrama de classes UML mostrando interfaces e classes para um sistema baseado em FUSE. Inclui interfaces IFUSEHandler, ObjectTreeNode e IFUSETreeNode, com FileFUSETreeNode e DirectoryFUSETreeNode implementando IFUSETreeNode. Cada interface e classe lista atributos e métodos, ilustrando seus relacionamentos e hierarquia

IFUSEHandler é uma interface que atende chamadas FUSE comuns. Você pode ver que substituí read/write por readAll/writeAll , respectivamente. Fiz isso para simplificar as operações de leitura e gravação: quando IFUSEHandler faz leitura/gravação para uma parte inteira, podemos mover a lógica parcial de leitura/gravação para outro local. Isso significa que IFUSEHandler não precisa saber nada sobre descritores de arquivos, dados binários, etc.


A mesma coisa aconteceu com o método FUSE open . Um aspecto notável da árvore é que ela é gerada sob demanda. Em vez de armazenar a árvore inteira na memória, o programa cria nós somente quando eles são acessados. Este comportamento permite que o programa evite problemas com a reconstrução da árvore em caso de criação ou remoção de nós.


Verifique a interface ObjectTreeNode e você descobrirá que children não são um array, mas um método, então é assim que eles são gerados sob demanda. FileFUSETreeNode e DirectoryFUSETreeNode são classes abstratas onde alguns métodos geram um erro NotSupported (obviamente, FileFUSETreeNode nunca deve implementar readdir ).

FUSEFachada

Diagrama de classes UML mostrando interfaces e seus relacionamentos para um sistema FUSE. O diagrama inclui as interfaces IFUSEHandler, IFUSETreeNode, IFileDescriptorStorage e a classe FUSEFacade. IFUSEHandler possui atributos name e métodos checkAvailability, create, getattr, readAll, remove e writeAll. IFileDescriptorStorage possui métodos get, openRO, openWO e release. IFUSETreeNode estende IFUSEHandler. FUSEFacade inclui métodos construtor, create, getattr, open, read, readdir, release, rmdir, safeGetNode, unlink e write e interage com IFUSETreeNode e IFileDescriptorStorage.


FUSEFacade é a classe mais importante que implementa a lógica principal do programa e une diferentes partes. node-fuse-bindings tem uma API baseada em retorno de chamada, mas os métodos FUSEFacade são feitos com uma API baseada em Promise. Para resolver esse inconveniente, usei um 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); // },


Os métodos FUSEFacade são agrupados em handleResultWrapper . Cada método do FUSEFacade que usa um caminho simplesmente analisa o caminho, encontra um nó na árvore e chama o método solicitado.


Considere alguns métodos da 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; }

Um descritor de arquivo

Antes de dar o próximo passo, vamos dar uma olhada mais de perto no que é um descritor de arquivo no contexto do nosso programa.

Diagrama de classes UML mostrando interfaces e seus relacionamentos para descritores de arquivos em um sistema FUSE. O diagrama inclui as interfaces IFileDescriptor, IFileDescriptorStorage e as classes ReadWriteFileDescriptor, ReadFileDescriptor e WriteFileDescriptor. IFileDescriptor possui atributos binário, fd, tamanho e métodos readToBuffer, writeToBuffer. IFileDescriptorStorage possui métodos get, openRO, openWO e release. ReadWriteFileDescriptor implementa IFileDescriptor com métodos adicionais de construtor, readToBuffer e writeToBuffer. ReadFileDescriptor e WriteFileDescriptor estendem ReadWriteFileDescriptor, com ReadFileDescriptor tendo um método writeToBuffer e WriteFileDescriptor tendo um método readToBuffer

ReadWriteFileDescriptor é uma classe que armazena descritores de arquivo como um número e dados binários como um buffer. A classe possui métodos readToBuffer e writeToBuffer que fornecem a capacidade de ler e gravar dados em um buffer de descritor de arquivo. ReadFileDescriptor e WriteFileDescriptor são implementações de descritores somente leitura e somente gravação.


IFileDescriptorStorage é uma interface que descreve o armazenamento do descritor de arquivo. O programa possui apenas uma implementação para esta interface: InMemoryFileDescriptorStorage . Como você pode perceber pelo nome, ele armazena descritores de arquivos na memória porque não precisamos de persistência para descritores.


Vamos verificar como FUSEFacade usa descritores e armazenamento de arquivos:

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


O código acima é direto. Ele define métodos para ler, gravar e liberar descritores de arquivo, garantindo que o descritor de arquivo seja válido antes de executar operações. O método release também grava dados de um objeto descritor de arquivo no nó do sistema de arquivos e libera o descritor de arquivo.


Concluímos o código em torno libfuse e da árvore. É hora de mergulhar no código relacionado à imagem.

Imagens: Parte "Objeto de transferência de dados"
Diagrama de classes UML mostrando interfaces e seus relacionamentos para manipulação de imagens. O diagrama inclui as interfaces ImageBinary, ImageMeta, Image e IImageMetaStorage. ImageBinary possui atributos buffer e tamanho. ImageMeta possui atributos id, name, originalFileName e originalFileType. A imagem possui atributos binário e meta, onde binário é do tipo ImageBinary e meta é do tipo ImageMeta. IImageMetaStorage possui métodos create, get, list e remove


ImageMeta é um objeto que armazena metainformações sobre uma imagem. IImageMetaStorage é uma interface que descreve um armazenamento para meta. O programa possui apenas uma implementação para a interface: a classe FSImageMetaStorage implementa a interface IImageMetaStorage para gerenciar metadados de imagem armazenados em um único arquivo JSON.


Ele usa um cache para armazenar metadados na memória e garante que o cache seja hidratado lendo o arquivo JSON quando necessário. A classe fornece métodos para criar, recuperar, listar e excluir metadados de imagem e grava as alterações de volta no arquivo JSON para persistir as atualizações. O cache melhora o desempenho reduzindo a contagem de operações de E/S.


ImageBinary , obviamente, é um objeto que possui dados binários de imagem. A interface Image é a composição de ImageMeta e ImageBinary .

Imagens: armazenamento binário e geradores

Diagrama de classes UML mostrando interfaces e seus relacionamentos para geração de imagens e armazenamento binário. O diagrama inclui as interfaces IBinaryStorage, IImageGenerator e as classes FSBinaryStorage, ImageGeneratorComposite, PassThroughImageGenerator, TextImageGenerator e ImageLoaderFacade. IBinaryStorage possui métodos load, remove e write. FSBinaryStorage implementa IBinaryStorage e possui um construtor adicional. IImageGenerator possui um método gerar. PassThroughImageGenerator e TextImageGenerator implementam IImageGenerator. ImageGeneratorComposite possui métodos addGenerator e generate. ImageLoaderFacade possui um construtor e um método de carregamento e interage com IBinaryStorage e IImageGenerator


IBinaryStorage é uma interface para armazenamento de dados binários. O armazenamento binário deve ser desvinculado de imagens e pode armazenar quaisquer dados: imagens, vídeo, JSON ou texto. Este fato é importante para nós e você verá por quê.


IImageGenerator é uma interface que descreve um gerador. O gerador é uma parte importante do programa. Ele pega dados binários brutos mais meta e gera uma imagem com base neles. Por que o programa precisa de geradores? O programa pode funcionar sem eles?


Pode, mas os geradores irão adicionar flexibilidade à implementação. Os geradores permitem aos usuários fazer upload de imagens, dados de texto e, de modo geral, quaisquer dados para os quais você escreve um gerador.


Diagrama mostrando o processo de conversão de um arquivo de texto em uma imagem usando a interface IImageGenerator. À esquerda, há um ícone para um arquivo de texto denominado 'myfile.txt' com o conteúdo 'Hello, world!'. Uma seta chamada 'IImageGenerator' aponta para a direita, onde há um ícone para um arquivo de imagem chamado 'myfile.png' com o mesmo texto 'Olá, mundo!' exibido na imagem


O fluxo é o seguinte: os dados binários são carregados do armazenamento ( myfile.txt na imagem acima) e então o binário passa para um gerador. Ele gera uma imagem “on the fly”. Você pode percebê-lo como um conversor de um formato para outro que é mais conveniente para nós.


Vejamos um exemplo de gerador:

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


A classe ImageLoaderFacade é uma fachada que combina logicamente o armazenamento e o gerador – em outras palavras, implementa o fluxo que você leu acima.

Imagens: variantes

Diagrama de classes UML mostrando interfaces e seus relacionamentos para geração de imagens e armazenamento binário. O diagrama inclui as interfaces IBinaryStorage, IImageGenerator e as classes FSBinaryStorage, ImageGeneratorComposite, PassThroughImageGenerator, TextImageGenerator e ImageLoaderFacade. IBinaryStorage possui métodos load, remove e write. FSBinaryStorage implementa IBinaryStorage e possui um construtor adicional. IImageGenerator possui um método gerar. PassThroughImageGenerator e TextImageGenerator implementam IImageGenerator. ImageGeneratorComposite possui métodos addGenerator e generate. ImageLoaderFacade possui um construtor e um método de carregamento e interage com IBinaryStorage e IImageGenerator


IImageVariant é uma interface para criar várias variantes de imagens. Neste contexto, uma variante é uma imagem gerada “on the fly” que será exibida ao usuário ao visualizar arquivos em nosso sistema de arquivos. A principal diferença dos geradores é que eles recebem uma imagem como entrada, em vez de dados brutos.


O programa possui três variantes: ImageAlwaysRandom , ImageOriginalVariant e ImageWithText . ImageAlwaysRandom retorna a imagem original com um quadrado de ruído RGB aleatório.


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


Eu uso a biblioteca sharp como a maneira mais conveniente de operar sobre imagens no NodeJS: https://github.com/lovell/sharp .


ImageOriginalVariant retorna uma imagem sem qualquer alteração (mas pode retornar uma imagem em um formato de compactação diferente). ImageWithText retorna uma imagem com texto escrito sobre ela. Isso será útil quando criarmos variantes predefinidas de uma única imagem. Por exemplo, se precisarmos de 10 variações aleatórias de uma imagem, devemos distinguir essas variações umas das outras.


A solução aqui é criar 10 imagens baseadas na original, onde renderizamos um número sequencial de 0 a 9 no canto superior esquerdo de cada imagem.

Uma sequência de imagens mostrando um gato branco e preto com olhos arregalados. As imagens são rotuladas com números começando em 0 à esquerda, aumentando em 1 e continuando com reticências até 9 à direita. A expressão do gato permanece a mesma em cada imagem


O ImageCacheWrapper tem uma finalidade diferente das variantes e atua como um wrapper armazenando em cache os resultados da classe IImageVariant específica. Ele será usado para agrupar entidades que não mudam, como um conversor de imagem, geradores de texto para imagem e assim por diante. Este mecanismo de cache permite uma recuperação mais rápida de dados, principalmente quando as mesmas imagens são lidas várias vezes.


Bem, cobrimos todas as partes principais do programa. É hora de combinar tudo.

A estrutura da árvore

Diagrama de classes UML mostrando a hierarquia e os relacionamentos entre vários nós da árvore FUSE relacionados ao gerenciamento de imagens. As classes incluem ImageVariantFileFUSETreeNode, ImageCacheWrapper, ImageItemAlwaysRandomDirFUSETreeNode, ImageItemOriginalDirFUSETreeNode, ImageItemCounterDirFUSETreeNode, ImageManagerItemFileFUSETreeNode, ImageItemDirFUSETreeNode, ImageManagerDirFUSETreeNode, ImagesDirFUSETreeNode e RootDirFUSETreeNode. Cada classe possui atributos e métodos relevantes para metadados de imagem, dados binários e operações de arquivo como create, readAll, writeAll, remove e getattr


O diagrama de classes abaixo representa como as classes da árvore são combinadas com suas contrapartes de imagem. O diagrama deve ser lido de baixo para cima. RootDir (deixe-me evitar o postfix FUSETreeNode nos nomes) é o diretório raiz do sistema de arquivos que o programa está implementando. Passando para a linha superior, veja dois diretórios: ImagesDir e ImagesManagerDir . ImagesManagerDir contém a lista de imagens do usuário e permite controlá-las. Então, ImagesManagerItemFile é um nó para um arquivo específico. Esta classe implementa operações CRUD.


Considere ImagesManagerDir como uma implementação usual de um nó:

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


Seguindo em frente, o ImagesDir contém subdiretórios com os nomes das imagens do usuário. ImagesItemDir é responsável por cada diretório. Inclui todas as variantes disponíveis; como você se lembra, a contagem de variantes é três. Cada variante é um diretório que contém os arquivos de imagem finais em diferentes formatos (atualmente: jpeg, png e webm). ImagesItemOriginalDir e ImagesItemCounterDir agrupam todas as instâncias ImageVariantFile geradas em um cache.


Isso é necessário para evitar a recodificação constante das imagens originais porque a codificação consome CPU. No topo do diagrama está ImageVariantFile . É a joia da coroa da implementação e da composição do IFUSEHandler e IImageVariant descritos anteriormente. Este é o arquivo para o qual todos os nossos esforços têm sido construídos.

Teste

Vamos testar como o sistema de arquivos final lida com solicitações paralelas para o mesmo arquivo. Para fazer isso, executaremos o utilitário md5sum em vários threads, que lerá os arquivos do sistema de arquivos e calculará seus hashes. Então, compararemos esses hashes. Se tudo estiver funcionando corretamente, os hashes deverão 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


Executei o script e verifiquei a seguinte saída (limpei um pouco para maior clareza):

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


Excelente! Todos os hashes são diferentes, o que significa que o sistema de arquivos retorna uma imagem única a cada vez!

Conclusão

Espero que este artigo tenha inspirado você a escrever sua própria implementação do FUSE. Lembre-se de que o código-fonte deste projeto está disponível aqui: https://github.com/pinkiesky/node-fuse-images .


O sistema de arquivos que construímos é simplificado para demonstrar os princípios básicos de trabalho com FUSE e Node.js. Por exemplo, não leva em consideração as datas corretas. Há muito espaço para melhorias. Imagine adicionar funcionalidades como extração de quadros de arquivos GIF do usuário, transcodificação de vídeo ou até mesmo paralelização de tarefas por meio de trabalhadores.


No entanto, o perfeito é inimigo do bom. Comece com o que você tem, faça funcionar e depois repita. Boa codificação!