Aguarde...

22 de janeiro de 2024

Apresentando fsx: uma API moderna de sistema de arquivos para JavaScript

Apresentando fsx: uma API moderna de sistema de arquivos para JavaScript

APIs de sistema de arquivos em tempos de execução JavaScript não são ótimas há muito tempo. Esta é minha tentativa de fazer algo melhor.

As APIs JavaScript que temos hoje são muito melhores do que aquelas que tínhamos há uma década. Considere a transição para XMLHttpRequestto fetch(): a experiência do desenvolvedor é dramaticamente melhor, permitindo-nos escrever um código mais sucinto e funcional que realiza a mesma coisa. A introdução de promessas para programação assíncrona permitiu essa mudança, juntamente com uma série de outras mudanças que tornaram o JavaScript mais fácil de escrever. Há, no entanto, uma área que tem visto pouca ou nenhuma inovação: APIs de sistema de arquivos em tempos de execução JavaScript do lado do servidor.

Node.js: a origem das APIs de sistema de arquivos atuais

O Node.js foi lançado inicialmente em 2009 e, com ele, fsnasceu o módulo. O fsmódulo foi construído em torno dos principais utilitários do Linux, com muitos métodos refletindo sua inspiração no Linux, como rmdirmkdirstat. Para esse fim, o Node.js conseguiu criar uma API de sistema de arquivos de baixo nível que pudesse lidar com qualquer coisa que os desenvolvedores esperassem realizar na linha de comando. Infelizmente, foi aí que a inovação parou.

A maior mudança na API do sistema de arquivos do Node.js foi a introdução que fs/promisesmoveu todo o utilitário de métodos baseados em retorno de chamada para métodos baseados em promessas. Mudanças incrementais menores incluíram a implementação de web streams e a garantia de que os leitores também implementassem iteradores assíncronos. A API ainda usa a Bufferclasse proprietária para leitura de dados binários. (Mesmo que Bufferagora seja uma subclasse de Uint8Array, ainda existem incompatibilidades que tornam o uso Bufferde s problemático.)

Mesmo Deno, sucessor de Ryan Dhal do Node.js, não fez muito para modernizar as APIs do sistema de arquivos. Ele segue principalmente o mesmo padrão do fsmódulo do Node.js, embora use Uint8Arrays onde o Node.js usa Buffers e use iteradores assíncronos em vários lugares. Caso contrário, ainda é a mesma abordagem de API de baixo nível adotada no Node.js.

Apenas Bun, a mais recente entrada no ecossistema de tempo de execução JavaScript do lado do servidor, fez uma tentativa de modernizar as APIs do sistema de arquivos com Bun.file(), que foi inspirado em fetch(). Embora eu aplauda essa reformulação de como trabalhar com arquivos, criar um novo objeto para cada arquivo com o qual você deseja trabalhar pode ser complicado quando você está lidando com mais do que alguns arquivos (e um grande prejuízo de desempenho ao lidar com milhares). Fora isso, Bun espera que você use o fsmódulo Node.js para outras operações.

Como seria uma API moderna de sistema de arquivos?

Depois de passar anos lutando com o fsmódulo Node.js enquanto mantinha o ESLint, me perguntei: como seria uma API moderna de sistema de arquivos? Aqui estão algumas das coisas que eu descobri:

  • Casos comuns seriam fáceis. Pelo menos 80% do tempo, estou lendo ou gravando arquivos ou verificando se existem arquivos. É basicamente isso. No entanto, essas operações são repletas de perigos, pois preciso verificar várias coisas para evitar erros ou lembrar atributos adicionais (ou seja, { encoding: "utf8" }).
  • Erros seriam raros. Minha maior reclamação sobre o fsmódulo é a frequência com que ele gera erros. Chamar fs.stat()um arquivo inexistente gera um erro, o que significa que você realmente precisa agrupar cada chamada em um arquivo try-catch. Por que? Arquivos ausentes não são um erro irrecuperável para a maioria dos aplicativos.
  • As ações seriam observáveis. Ao testar operações do sistema de arquivos, eu realmente só quero uma maneira de verificar se as coisas que eu esperava que acontecessem realmente aconteceram. Não quero configurar uma rede de espiões com outros utilitários que possam ou não estar alterando o comportamento real dos métodos que estou observando.
  • Zombar seria fácil. Sempre fico surpreso com o quão difícil é simular operações do sistema de arquivos. Acabo usando algo parecido proxyquireou então preciso montar um labirinto de mocks que demoram um pouco para acertar. Este é um requisito tão comum para operações de sistema de arquivos que é surpreendente que não exista solução.

Com esses pensamentos em mente, segui em frente com o design do fsx.

noções básicas de fsx

A biblioteca fsx 3 é o culminar de todos os meus pensamentos sobre como deveria ser uma API de sistema de arquivos moderna e de alto nível. Neste ponto, ele está focado no suporte às operações mais comuns do sistema de arquivos, deixando chmodpara trás as operações menos utilizadas ( , por exemplo). (Não estou dizendo que essas operações não serão adicionadas no futuro, mas foi importante para mim focar nos meus casos mais comuns para começar e depois criar mais funcionalidades da mesma maneira deliberada que os métodos iniciais.)

Usando pacotes de tempo de execução fsx

Para começar, a API fsx está disponível em três pacotes de tempo de execução. Todos esses pacotes contêm a mesma funcionalidade, mas estão vinculados a diferentes APIs subjacentes. Os pacotes são:

  • fsx-node– as ligações Node.js para a API fsx
  • fsx-deno– as ligações Deno para a API fsx
  • fsx-memory– uma implementação na memória adequada para qualquer tempo de execução (incluindo navegadores da web)

Então, para começar, você usará o pacote de tempo de execução que melhor se adapta ao seu caso de uso. Para os fins desta postagem, focarei no fsx-node, mas as mesmas APIs existem em todos os pacotes de tempo de execução. Todos os pacotes de tempo de execução exportam um fsxsingleton que você pode usar de maneira semelhante ao fs.

import { fsx } from "node-fsx";

Lendo arquivos com fsx

Os arquivos são lidos usando o método que retorna o tipo de dados específico desejado:

  • fsx.text(filePath)lê o arquivo fornecido e retorna uma string.
  • fsx.json(filePath)lê o arquivo fornecido e retorna um valor JSON.
  • fsx.arrayBuffer(filePath)lê o arquivo fornecido e retorna um arquivo ArrayBuffer.

aqui estão alguns exemplos:

// read plain text
const text = await fsx.text("/path/to/file.txt");

// read JSON
const json = await fsx.json("/path/to/file.json");

// read bytes
const bytes = await fsx.arrayBuffer("/path/to/file.png");

Se um arquivo não existir, cada método retornará undefinedem vez de gerar um erro. Isso significa que você pode usar uma ifinstrução em vez de a try-catche, opcionalmente, usar o operador de coalescência nulo para especificar um valor padrão, como este:

// read plain text
const text = await fsx.text("/path/to/file.txt") ?? "default value";

// read JSON
const json = await fsx.json("/path/to/file.json") ?? {};

// read bytes
const bytes = await fsx.arrayBuffer("/path/to/file.png") ?? new ArrayBuffer(16);

Acho que essa abordagem é muito mais JavaScript em 2024 do que se preocupar constantemente com erros em arquivos que não existem.

Gravando arquivos com fsx

Para gravar arquivos, chame o fsx.write()método. Este método aceita dois argumentos:

  • filePath:string– o caminho para escrever
  • value:string|ArrayBuffer– o valor a ser gravado no arquivo

Aqui está um exemplo:

// write a string
await fsx.write("/path/to/file.txt", "Hello world!");

const bytes = new TextEncoder().encode("Hello world!").buffer;

// write a buffer
await fsx.write("/path/to/file.txt", bytes);

Como um bônus adicional, fsx.write()criará automaticamente quaisquer diretórios que ainda não existam. Este é outro problema que encontro constantemente e que acho que deveria “simplesmente funcionar” em uma API moderna de sistema de arquivos.

Detectando arquivos com fsx

Para determinar se um arquivo existe, use o fsx.isFile(filePath)método, que retorna truese o arquivo fornecido existe ou falsenão.

if (await fsx.isFile("/path/to/file.txt")) {
    // handle the file
}

Ao contrário fs.stat(), este método simplesmente retorna falsese o arquivo não existir, em vez de gerar um erro. Compare com o fs.stat()código equivalente:

try {
    const stat = await fs.stat(filePath);
    return stat.isFile();
} catch (ex) {
    if (ex.code === "ENOENT") {
        return false;
    }

    throw ex;
}

Excluindo arquivos e diretórios

fsx.delete()método aceita um único parâmetro, o caminho a ser excluído, e funciona tanto em arquivos quanto em diretórios.

// delete a file
await fsx.delete("/path/to/file.txt");

// delete a directory
await fsx.delete("/path/to");

fsx.delete()método é intencionalmente agressivo: ele excluirá diretórios recursivamente mesmo que eles não estejam vazios (efetivamente rmdir -r).

registro fsx

Um dos principais recursos do fsx é a facilidade de determinar quais métodos foram chamados com quais argumentos, graças ao seu sistema de registro integrado. Para ativar o log em uma fsxinstância, chame o logStart()método e passe um nome de log. Quando terminar de registrar, chame logEnd()e passe o mesmo nome para recuperar uma matriz de entradas de log. Aqui está um exemplo:

fsx.logStart("test1");

const fileFound = await fsx.isFile("/path/to/file.txt");

const logs = fsx.logEnd("test1");

Cada entrada de log é um objeto que contém as seguintes propriedades:

  • timestamp– o carimbo de data/hora numérico de quando o log foi criado
  • type– uma string descrevendo o tipo de log
  • data– dados adicionais relacionados ao log

Para chamadas de método, uma entrada de log typeé "call"e a datapropriedade é um objeto contendo:

  • methodName– o nome do método que foi chamado
  • args– uma matriz de argumentos passados ​​para o método.

Para o exemplo anterior, logsconteria uma única entrada:

// example log entry

{
    timestamp: 123456789,
    type: "call",
    data: {
        methodName: "isFile",
        args: ["/path/to/file.txt"]
    }
}

Sabendo disso, você pode facilmente configurar o log em um teste e então inspecionar quais métodos foram chamados sem precisar de uma biblioteca de terceiros para espiões.

Usando impls fsx

O design do fsx é tal que há funcionalidades básicas e abstratas contidas no fsx-corepacote. Cada pacote de tempo de execução estende essa funcionalidade com implementações específicas de tempo de execução das operações do sistema de arquivos agrupadas em um objeto chamado impl . Cada pacote de tempo de execução exporta três coisas:

  1. fsxsolteiro
  2. Um construtor que permite criar outra instância de fsx(como NodeFsxem fsx-node)
  3. Um construtor que permite criar uma instância impl para o pacote de tempo de execução (como NodeFsxImplem node-fsx)

Isso permite que você use apenas a funcionalidade desejada.

Impls básicos e impls ativos em fsx

Cada fsxinstância é criada com um impl base que define como o fsxobjeto deve se comportar na produção. O impl ativo é o impl em uso em um determinado momento, que pode ou não ser o impl base. Você pode alterar o impl ativo chamando fsx.setImpl(). Por exemplo:

import { fsx } from "fsx-node";

fsx.setImpl({
    json() {
        throw Error("This operation is not supported");
    }
})


// somewhere else

await fsx.json("/path/to/file.json");       // throws error

Neste exemplo, o impl base é trocado por um personalizado que gera um erro quando o fsx.json()método é chamado. Isso torna mais fácil simular métodos para seus testes sem se preocupar em como isso pode afetar o fsxobjeto que o contém como um todo.

Trocando impls por testes

Suponha que você tenha uma função chamada readConfigFile()que usa o fsxsingleton from node-fsxpara ler um arquivo chamado config.json. Quando chegar a hora de testar essa função, você realmente prefere que ela não atinja o sistema de arquivos. Você pode trocar o impl fsxe substituí-lo por uma implementação de sistema de arquivos na memória fornecida por fsx-memory, assim:

import { fsx } from "fsx-node";
import { MemoryFsxImpl } from "fsx-memory";
import { readConfigFile } from "../src/example.js";
import assert from "node:assert";

describe("readConfigFile()", () => {

    beforeEach(() => {
        fsx.setImpl(new MemoryFsxImpl());
    });

    afterEach(() => {
        fsx.resetImpl();
    });

    it("should read config file", async () => {

        await fsx.write("config.json", JSON.stringify({ found: true });

        const result = await readConfigFile();

        assert.isTrue(result.found);
    });

});

É tão fácil simular um sistema de arquivos inteiro na memória usando fsx. Você não precisa se preocupar com a ordem em que importa todos os módulos para o teste, como faria com as interceptações do carregador de módulo, nem precisa passar pelo processo de inclusão de uma biblioteca de simulação para garantir que tudo funcione. Você pode simplesmente trocar o impl pelo teste e redefini-lo posteriormente. Dessa forma, você pode testar as operações do sistema de arquivos com mais desempenho e menos propensa a erros.

Uma nota sobre nomenclatura

Infelizmente, no tempo que levei para lançar o fsx, a Amazon lançou um produto chamado FSx. Provavelmente renomearei esta biblioteca caso ela ganhe força, e sugestões são bem-vindas.

Postado em BlogTags:
Escreva um comentário