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 XMLHttpRequest
to 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, fs
nasceu o módulo. O fs
módulo foi construído em torno dos principais utilitários do Linux, com muitos métodos refletindo sua inspiração no Linux, como rmdir
, mkdir
e stat
. 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/promises
moveu 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 Buffer
classe proprietária para leitura de dados binários. (Mesmo que Buffer
agora seja uma subclasse de Uint8Array
, ainda existem incompatibilidades que tornam o uso Buffer
de 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 fs
módulo do Node.js, embora use Uint8Array
s onde o Node.js usa Buffer
s 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 fs
módulo Node.js para outras operações.
Como seria uma API moderna de sistema de arquivos?
Depois de passar anos lutando com o fs
mó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
fs
módulo é a frequência com que ele gera erros. Chamarfs.stat()
um arquivo inexistente gera um erro, o que significa que você realmente precisa agrupar cada chamada em um arquivotry-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
proxyquire
ou 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 chmod
para 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 fsxfsx-deno
– as ligações Deno para a API fsxfsx-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 fsx
singleton 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 arquivoArrayBuffer
.
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á undefined
em vez de gerar um erro. Isso significa que você pode usar uma if
instrução em vez de a try-catch
e, 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 escrevervalue: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 true
se o arquivo fornecido existe ou false
não.
if (await fsx.isFile("/path/to/file.txt")) {
// handle the file
}
Ao contrário fs.stat()
, este método simplesmente retorna false
se 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
O 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");
O 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 fsx
instâ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 criadotype
– uma string descrevendo o tipo de logdata
– dados adicionais relacionados ao log
Para chamadas de método, uma entrada de log type
é "call"
e a data
propriedade é um objeto contendo:
methodName
– o nome do método que foi chamadoargs
– uma matriz de argumentos passados para o método.
Para o exemplo anterior, logs
conteria 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-core
pacote. 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:
- O
fsx
solteiro - Um construtor que permite criar outra instância de
fsx
(comoNodeFsx
emfsx-node
) - Um construtor que permite criar uma instância impl para o pacote de tempo de execução (como
NodeFsxImpl
emnode-fsx
)
Isso permite que você use apenas a funcionalidade desejada.
Impls básicos e impls ativos em fsx
Cada fsx
instância é criada com um impl base que define como o fsx
objeto 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 fsx
objeto que o contém como um todo.
Trocando impls por testes
Suponha que você tenha uma função chamada readConfigFile()
que usa o fsx
singleton from node-fsx
para 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 fsx
e 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.