Aguarde...

27 de janeiro de 2024

Gerenciamento explícito de recursos: explorando o novo recurso do JavaScript e do TypeScript

Gerenciamento explícito de recursos: explorando o novo recurso do JavaScript e do TypeScript

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

Disposableprotocolo 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 tryfinallysão escopos diferentes, então sempre precisamos declarar variáveis ​​mutáveis, em vez de usar const.

A nova usingsintaxe 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 awaitaqui. O primeiro awaitsignifica descarte assíncrono – ou seja, execução await file[Symbol.asyncDispose]()no final do escopo. O segundo awaitsignifica inicialização assíncrona – é, na verdade, apenas uma await openFile()expressão regular.

Vou implementar openFilecomo um wrapper fino sobre o fs.FileHandleNode.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 Mutexcomo uma fábrica assíncrona de Disposableobjetos:

// 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.txtarquivo 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 outFiletempo 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 AsyncDisposableStackum contêiner que agregue vários AsyncDisposables, 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 Disposableos recursos que contêm sub-recursos devem mantê-los em um DisposableStask, dispondo-os dentro do seu próprio dispose(). O mesmo vale para AsyncDisposableAsyncDisposableStask, é 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ê!

Postado em BlogTags:
Escreva um comentário