
Um dos meus novos recursos favoritos de JavaScript e TypeScript é o gerenciamento explícito de recursos . Ele traz uma nova sintaxe, using foobar = ...
que habilita o RAII , reduzindo o clichê ao gerenciar o ciclo de vida de diversos recursos.
Neste artigo, explorarei esse recurso conforme implementado no TypeScript 5.2.0 com o polyfill descartávelstack. Mencionarei recursos sincronizados e assíncronos, DisposableStack
/ AsyncDisposableStack
e um erro não óbvio que cometi ao usar o novo recurso. Além disso, ao longo do caminho, usarei alguns recursos mais recentes do Node.js, que algumas pessoas talvez ainda não conheçam.
Todo o código está disponível no repo.
Pré-requisitos
Usarei uma versão mais ou menos recente do Node.js:
$ node --version
v20.3.1
Mas todos os recursos que usarei estão disponíveis pelo menos a partir do Node 18.16.1 LTS.
Vou precisar do TypeScript 5.2 para o nível de sintaxe e de um polyfill para a parte do recurso no nível da biblioteca:
$ npm i -D typescript@5.2 @types/node@20
$ npm i disposablestack
Finalmente, para configurar o compilador. Para esta nova sintaxe, precisarei das opções "lib": "esnext"
ou "lib": "esnext.disposable"
. Também usarei módulos ES.tsconfig.json completo
Sincronizar recursos: assinaturas de eventos
Um dos tipos mais simples de recurso que um programador JavaScript ou TypeScript pode encontrar é uma assinatura de evento. Seu ciclo de vida começa ao assinar um evento e termina ao cancelar a assinatura dele. E em muitos casos, esquecer de cancelar corretamente a assinatura de um evento levará a vazamentos de memória – um manipulador de eventos geralmente é um fechamento que retém uma referência ao objeto emissor do evento, criando um ciclo de referência:
let listener = new SomeListener();
let emitter = new HeavyObject();
emitter.on("event", () => listener.onEvent(emitter));
/* ... */
emitter = null;
// emitter won't be garbage collected
// as long as listener is alive
Usando assinaturas de eventos como exemplo, vamos ver como é a nova sintaxe de gerenciamento de recursos. Primeiro, para implementar a lógica do ciclo de vida:
// src/event-subscription.ts
import "disposablestack/auto";
import { EventEmitter } from "node:events";
export function subscribe(obj: EventEmitter, e: string, fn: (...args: any[]) => void): Disposable {
obj.on(e, fn);
return { [Symbol.dispose]: () => obj.off(e, fn) };
}
O Disposable
protocolo exige que os objetos tenham um [Symbol.dispose]()
método – esse método será chamado para liberar o recurso.
Para demonstrar o uso deste recurso, escreverei um teste de unidade para subscribe()
usar um dos recursos mais recentes do Node.js – um executor de testes integrado:
// src/event-subscription.test.ts
import { subscribe } from "./event-subscription.js";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { describe, it } from "node:test";
describe("event-subscription", () => {
it("is disposed at scope exit", () => {
const expectedEvents = [1, 2, 3];
const actualEvents: number[] = [];
const obj = new EventEmitter();
const fn = (e: number) => actualEvents.push(e);
{
// initializing the resource with a `using` declaration
using guard = subscribe(obj, "event", fn);
// the resource is alive till the end of the variable scope
for (const e of expectedEvents) obj.emit("event", e);
// end of scope for `guard`
// guard[Symbol.dispose]() will be called here
}
obj.emit("event", 123);
assert.deepEqual(actualEvents, expectedEvents);
assert.equal(obj.listenerCount("event"), 0);
});
});
Vamos fazer nosso teste:
$ npm test | grep event-subscription
# Subtest: event-subscription
ok 1 - event-subscription
Recursos assíncronos: arquivos abertos
Ao falar sobre o ciclo de vida dos recursos no Node.js, a maioria das pessoas realmente se refere àqueles que chamarei de recursos assíncronos . Eles incluem arquivos abertos, soquetes, conexões de banco de dados – em resumo, quaisquer recursos que se enquadrem no seguinte modelo de uso:
let resource: Resource;
try {
// the resource is initialized with an async method
resource = await Resource.open();
// doing stuff with resource
} finally {
// the resource is freed with an async method
await resource?.close();
}
À primeira vista, não está claro por que a nova sintaxe foi introduzida. Quer dizer, já temos finally
, certo? Mas assim que temos que lidar com vários recursos ao mesmo tempo, o clichê começa a se acumular:
let resourceA: ResourceA;
try {
resourceA = await ResourceA.open();
let resourceB: ResourceB;
try {
resourceB = await ResourceB.open(resourceA);
} finally {
await resourceB?.close();
}
} finally {
await resourceA?.close();
}
Somando-se a isso, os blocos try
e finally
são escopos diferentes, então sempre precisamos declarar variáveis mutáveis, em vez de usar const
.
A nova using
sintaxe torna isso muito mais gerenciável:
// src/file.test.ts
import { openFile } from "./file.js";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
describe("file", () => {
it("is disposed at scope exit", async () => {
{
await using file = await openFile("dist/test.txt", "w");
await file.writeFile("test", "utf-8");
}
{
await using file = await openFile("dist/test.txt", "r");
assert.equal(await file.readFile("utf-8"), "test");
}
});
});
Observe o await using file = await ...
. Existem dois await
aqui. O primeiro await
significa descarte assíncrono – ou seja, execução await file[Symbol.asyncDispose]()
no final do escopo. O segundo await
significa inicialização assíncrona – é, na verdade, apenas uma await openFile()
expressão regular.
Vou implementar openFile
como um wrapper fino sobre o fs.FileHandle
Node.js existente.
// src/file.ts
import "disposablestack/auto";
import * as fs from "node:fs/promises";
import { Writable } from "node:stream";
// the type of our resource is a union of AsyncDisposable and the fs.FileHandle
export interface DisposableFile extends fs.FileHandle, AsyncDisposable {
// this helper method will become useful later
writableWebStream(options?: fs.CreateWriteStreamOptions): WritableStream;
}
export async function openFile(path: string, flags?: string | number): Promise<DisposableFile> {
const file = await fs.open(path, flags);
// using Object.assign() to monkey-patch the disposal function into the object
return Object.assign(file, {
[Symbol.asyncDispose]: () => file.close(),
writableWebStream: (options: fs.CreateWriteStreamOptions = { autoClose: false }) =>
Writable.toWeb(file.createWriteStream(options)),
});
}
Vamos fazer os testes:
$ npm test | grep file
# Subtest: file
ok 2 - file
O “async-sync”: mutexes
À primeira vista, a await using foo = await ...
sintaxe pode parecer desnecessariamente repetitiva. Mas o fato é que existem recursos que exigem apenas que a inicialização seja assíncrona, bem como aqueles que requerem apenas descarte assíncrono.
Como demonstração de um recurso “init assíncrono – descarte de sincronização”, aqui está um mutex RAII:
// src/mutex.test.ts
import { Mutex } from "./mutex.js";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { setTimeout as sleep } from "node:timers/promises";
describe("mutex-guard", () => {
it("is disposed at scope exit", async () => {
const mutex = new Mutex();
let value: number = 0;
const task = async () => {
for (let i = 0; i < 5; i++) {
// async init - might have to wait until mutex becomes free
// sync dispose - just notifying other awaiters
using guard = await mutex.acquire();
// the scope of `guard` becomes a critical section
const newValue = value + 1;
await sleep(100);
value = newValue;
// comment out the `using guard` line to see a race condition
}
};
await Promise.all([task(), task()]);
assert.equal(value, 10);
});
});
Implementei Mutex
como uma fábrica assíncrona de Disposable
objetos:
// src/mutex.ts
import "disposablestack/auto";
export class Mutex {
#promise: Promise<void> | null = null;
async acquire(): Promise<Disposable> {
while (this.#promise) await this.#promise;
let callback: () => void;
this.#promise = new Promise((cb) => callback = cb);
return {
[Symbol.dispose]: () => {
this.#promise = null;
callback!();
}
};
}
}
Vamos fazer os testes:
$ npm test | grep mutex
# Subtest: mutex-guard
ok 3 - mutex-guard
O “sync-async”: filas de tarefas
Como exemplo de objeto “sync init – async submit”, aqui está uma fila de tarefas simples:
// src/task-queue.test.ts
import { TaskQueue } from "./task-queue.js";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { setTimeout as sleep } from "node:timers/promises";
describe("task-queue", () => {
it("is disposed at scope exit", async () => {
let runningTaskCount = 0;
let maxRunningTaskCount = 0;
const task = async () => {
runningTaskCount += 1;
maxRunningTaskCount = Math.max(maxRunningTaskCount, runningTaskCount);
await sleep(100);
runningTaskCount -= 1;
};
{
await using queue = new TaskQueue({ concurrency: 2 });
queue.push(task);
queue.push(task);
queue.push(task);
queue.push(task);
// at the end of scope, it awaits all remaining tasks in the queue
}
assert.equal(runningTaskCount, 0);
assert.equal(maxRunningTaskCount, 2);
});
});
A implementação é basicamente simples:Implementação de fila de tarefas
Executando nossos testes simples:
$ npm test | grep queue
# Subtest: task-queue
ok 4 - task-queue
Juntando tudo: fetchCat()
Como um exercício simples, vamos escrever uma função que usa todos os quatro recursos definidos anteriormente:
// src/fetch-cat.ts
import { subscribe } from "./event-subscription.js";
import { openFile } from "./file.js";
import { Mutex } from "./mutex.js";
import { TaskQueue } from "./task-queue.js";
/**
* Fetch all `urls` with HTTP GET requests, concatenate all the responses in any order,
* and write them to `outPath`.
*
* @param options.concurrency max number of concurrent requests
* @param options.onError is called on request error
*/
export async function fetchCat(
options: {
urls: string[],
outPath: string,
concurrency: number,
onError: (error: any) => void,
},
): Promise<void> {
const { urls, outPath, concurrency, onError } = options;
// a task queue to limit the concurrency
await using taskQueue = new TaskQueue({ concurrency });
// an event subscription treated as a resource
using errorSubscription = subscribe(taskQueue, "error", onError);
// synchronize file writes with a mutex
const outFileMutex = new Mutex();
// ensure the file is closed at the end of scope
await using outFile = await openFile(outPath, "w");
for (const url of urls) {
taskQueue.push(async () => {
// a brower-compatible global fetch() is also one]
// of the newer Node.js features
const response = await fetch(url);
{
using outFileGuard = await outFileMutex.acquire();
// as are the browser-compatible data streams
await response.body?.pipeTo(outFile.writableWebStream());
}
});
}
}
Resumindo isso em um script com outro recurso do Node.js – um analisador de argumentos CLI integrado:principal.ts
Para testar isso, usarei um urls.txt
arquivo com uma lista de URLs e algumas falsificações:
https://habr.com/ru/companies/ruvds/articles/346442/comments/
https://habr.com/ru/articles/203048/comments/
https://asdfasdfasdfasdf
https://habr.com/ru/articles/144758/comments/
https://habr.com/ru/companies/floor796/articles/673318/comments/
https://habr.com/ru/companies/skyeng/articles/487764/comments/
https://habr.com/ru/articles/177159/comments/
https://habr.com/ru/articles/124899/comments/
https://habr.com/ru/articles/149237/comments/
https://foobarfoobarfoobar
https://habr.com/ru/articles/202304/comments/
https://habr.com/ru/articles/307822/comments/
Vamos tentar isso:
$ npm run demo
> demo
> xargs npm run start -- -o ./cat.html < ./urls.txt
> start
> tsc && node --max-old-space-size=8 ./dist/main-incorrect.js -o ./cat.html https://habr.com/ru/companies/ruvds/articles/346442/comments/ https://habr.com/ru/articles/203048/comments/ https://asdfasdfasdfasdf https://habr.com/ru/articles/144758/comments/ https://habr.com/ru/companies/floor796/articles/673318/comments/ https://habr.com/ru/companies/skyeng/articles/487764/comments/ https://habr.com/ru/articles/177159/comments/ https://habr.com/ru/articles/124899/comments/ https://habr.com/ru/articles/149237/comments/ https://foobarfoobarfoobar https://habr.com/ru/articles/202304/comments/ https://habr.com/ru/articles/307822/comments/
Huh… O script não termina e a saída está vazia. Parece um bug.
O erro não óbvio
Para descobrir meu erro, vamos inspecionar o código um pouco mais de perto:
// src/fetch-cat.ts
import { subscribe } from "./event-subscription.js";
import { openFile } from "./file.js";
import { Mutex } from "./mutex.js";
import { TaskQueue } from "./task-queue.js";
export async function fetchCat(
options: {
urls: string[],
outPath: string,
concurrency: number,
onError: (error: any) => void,
},
): Promise<void> {
const { urls, outPath, concurrency, onError } = options;
// notice the resource init order
await using taskQueue = new TaskQueue({ concurrency });
using errorSubscription = subscribe(taskQueue, "error", onError);
await using outFile = await openFile(outPath, "w");
const outFileMutex = new Mutex();
for (const url of urls) {
taskQueue.push(async () => {
const response = await fetch(url);
{
using outFileGuard = await outFileMutex.acquire();
await response.body?.pipeTo(outFile.writableWebStream());
}
});
}
// This is the end of scope for both `outFile` and `taskQueue`.
// They are disposed of in reverse declaration order.
// That means that `outFile` will be closed before `taskQueue` is finished!
}
Há um erro lógico aqui: o outFile
tempo de vida não deve ser limitado pelo escopo atual, mas pelo tempo de vida de todas as tarefas restantes da fila. O arquivo deve ser fechado somente quando todas as tarefas forem concluídas.
Infelizmente, o Node.js não é inteligente o suficiente para prolongar automaticamente a vida útil dos valores capturados por um encerramento. Isso significa que terei que vinculá-los manualmente, usando AsyncDisposableStack
um contêiner que agregue vários AsyncDisposable
s, liberando-os todos de uma vez.
// src/fetch-cat.ts
import { subscribe } from "./event-subscription.js";
import { openFile } from "./file.js";
import { Mutex } from "./mutex.js";
import { TaskQueue } from "./task-queue.js";
export async function fetchCat(
options: {
urls: string[],
outPath: string,
concurrency: number,
onError: (error: any) => void,
},
): Promise<void> {
const { urls, outPath, concurrency, onError } = options;
await using taskQueue = new TaskQueue({ concurrency });
// The `taskQueue.resources` field is an AsyncDisposableStack.
// As part of TaskQueue's contract, it is disposed only after
// all the tasks are done
const errorSubscription = subscribe(taskQueue, "error", onError);
taskQueue.resources.use(errorSubscription); // связываем время жизни
const outFile = await openFile(outPath, "w");
taskQueue.resources.use(outFile); // связываем время жизни
const outFileMutex = new Mutex();
for (const url of urls) {
taskQueue.push(async () => {
const response = await fetch(url);
{
using outFileGuard = await outFileMutex.acquire();
await response.body?.pipeTo(outFile.writableWebStream());
}
});
}
// Only the `taskQueue` resource is bound directly to this scope.
// When it is disposed of, it first awaits all remaining queue tasks,
// and only then disposes of all the `taskQueue.resources`.
// Only then will the `outFile` be closed.
}
Vamos testar isso:
$ npm run demo
> demo
> xargs npm start -- -o ./cat.html < ./urls.txt
> start
> tsc && node --max-old-space-size=8 ./dist/main.js -o ./cat.html https://habr.com/ru/companies/ruvds/articles/346442/comments/ https://habr.com/ru/articles/203048/comments/ https://asdfasdfasdfasdf https://habr.com/ru/articles/144758/comments/ https://habr.com/ru/companies/floor796/articles/673318/comments/ https://habr.com/ru/companies/skyeng/articles/487764/comments/ https://habr.com/ru/articles/177159/comments/ https://habr.com/ru/articles/124899/comments/ https://habr.com/ru/articles/149237/comments/ https://foobarfoobarfoobar https://habr.com/ru/articles/202304/comments/ https://habr.com/ru/articles/307822/comments/
fetch failed: getaddrinfo ENOTFOUND asdfasdfasdfasdf
fetch failed: getaddrinfo ENOTFOUND foobarfoobarfoobar
Excelente! Todos os URLs (excluindo os falsos) foram obtidos e gravados ./cat.html
, conforme pretendido.
Como regra geral, todos Disposable
os recursos que contêm sub-recursos devem mantê-los em um DisposableStask
, dispondo-os dentro do seu próprio dispose()
. O mesmo vale para AsyncDisposable
e AsyncDisposableStask
, é claro.
article[Symbol.dispose]()
A sintaxe RAII dedicada não é uma ideia nova para uma linguagem de programação – C# a possui, Python também e agora JavaScript e TypeScript. Esta implementação, claro, não é perfeita e tem a sua própria quota de comportamentos não óbvios. Mesmo assim, estou feliz por finalmente termos essa sintaxe – e, espero, ter conseguido explicar o porquê!