Tiếng Việt

Làm chủ tính năng Quản lý Tài nguyên Tường minh mới của JavaScript với `using` và `await using`. Học cách tự động dọn dẹp, ngăn rò rỉ tài nguyên và viết mã sạch hơn, mạnh mẽ hơn.

Siêu năng lực mới của JavaScript: Phân tích sâu về Quản lý Tài nguyên Tường minh

Trong thế giới phát triển phần mềm năng động, quản lý tài nguyên hiệu quả là nền tảng để xây dựng các ứng dụng mạnh mẽ, đáng tin cậy và hiệu năng cao. Trong nhiều thập kỷ, các nhà phát triển JavaScript đã dựa vào các mẫu thủ công như try...catch...finally để đảm bảo rằng các tài nguyên quan trọng—chẳng hạn như handle tệp tin, kết nối mạng hoặc các phiên cơ sở dữ liệu—được giải phóng đúng cách. Mặc dù hoạt động, cách tiếp cận này thường dài dòng, dễ gây lỗi và có thể nhanh chóng trở nên khó quản lý, một mẫu đôi khi được gọi là "kim tự tháp diệt vong" (pyramid of doom) trong các tình huống phức tạp.

Hãy bước vào một sự thay đổi mô thức cho ngôn ngữ này: Quản lý Tài nguyên Tường minh (Explicit Resource Management - ERM). Được hoàn thiện trong tiêu chuẩn ECMAScript 2024 (ES2024), tính năng mạnh mẽ này, lấy cảm hứng từ các cấu trúc tương tự trong các ngôn ngữ như C#, Python và Java, giới thiệu một cách khai báo và tự động để xử lý việc dọn dẹp tài nguyên. Bằng cách tận dụng các từ khóa mới usingawait using, JavaScript giờ đây cung cấp một giải pháp thanh lịch và an toàn hơn nhiều cho một thách thức lập trình muôn thuở.

Hướng dẫn toàn diện này sẽ đưa bạn vào một hành trình khám phá Quản lý Tài nguyên Tường minh của JavaScript. Chúng ta sẽ khám phá các vấn đề mà nó giải quyết, phân tích các khái niệm cốt lõi của nó, xem qua các ví dụ thực tế và khám phá các mẫu nâng cao sẽ giúp bạn viết mã sạch hơn, linh hoạt hơn, bất kể bạn đang phát triển ở đâu trên thế giới.

Phương pháp cũ: Những thách thức của việc dọn dẹp tài nguyên thủ công

Trước khi có thể đánh giá cao sự tinh tế của hệ thống mới, chúng ta phải hiểu những điểm yếu của hệ thống cũ. Mẫu cổ điển để quản lý tài nguyên trong JavaScript là khối lệnh try...finally.

Logic rất đơn giản: bạn cấp phát một tài nguyên trong khối try, và bạn giải phóng nó trong khối finally. Khối finally đảm bảo việc thực thi, cho dù mã trong khối try thành công, thất bại hay trả về sớm.

Hãy xem xét một kịch bản phía máy chủ phổ biến: mở một tệp, ghi một số dữ liệu vào đó, và sau đó đảm bảo tệp được đóng lại.

Ví dụ: Thao tác tệp đơn giản với try...finally


const fs = require('fs/promises');

async function processFile(filePath, data) {
  let fileHandle;
  try {
    console.log('Đang mở tệp...');
    fileHandle = await fs.open(filePath, 'w');
    console.log('Đang ghi vào tệp...');
    await fileHandle.write(data);
    console.log('Dữ liệu đã được ghi thành công.');
  } catch (error) {
    console.error('Đã xảy ra lỗi trong quá trình xử lý tệp:', error);
  } finally {
    if (fileHandle) {
      console.log('Đang đóng tệp...');
      await fileHandle.close();
    }
  }
}

Mã này hoạt động, nhưng nó bộc lộ một vài điểm yếu:

Bây giờ, hãy tưởng tượng việc quản lý nhiều tài nguyên, như một kết nối cơ sở dữ liệu và một handle tệp tin. Mã nhanh chóng trở thành một mớ hỗn độn lồng vào nhau:


async function logQueryResultToFile(query, filePath) {
  let dbConnection;
  try {
    dbConnection = await getDbConnection();
    const result = await dbConnection.query(query);

    let fileHandle;
    try {
      fileHandle = await fs.open(filePath, 'w');
      await fileHandle.write(JSON.stringify(result));
    } finally {
      if (fileHandle) {
        await fileHandle.close();
      }
    }
  } finally {
    if (dbConnection) {
      await dbConnection.release();
    }
  }
}

Việc lồng nhau này khó bảo trì và mở rộng. Đó là một tín hiệu rõ ràng rằng cần có một sự trừu tượng hóa tốt hơn. Đây chính xác là vấn đề mà Quản lý Tài nguyên Tường minh được thiết kế để giải quyết.

Một sự thay đổi Mô thức: Các nguyên tắc của Quản lý Tài nguyên Tường minh

Quản lý Tài nguyên Tường minh (ERM) giới thiệu một hợp đồng giữa một đối tượng tài nguyên và môi trường chạy JavaScript. Ý tưởng cốt lõi rất đơn giản: một đối tượng có thể khai báo cách nó nên được dọn dẹp, và ngôn ngữ cung cấp cú pháp để tự động thực hiện việc dọn dẹp đó khi đối tượng ra khỏi phạm vi.

Điều này đạt được thông qua hai thành phần chính:

  1. Giao thức Disposable: Một cách tiêu chuẩn để các đối tượng định nghĩa logic dọn dẹp của riêng chúng bằng cách sử dụng các symbol đặc biệt: Symbol.dispose cho việc dọn dẹp đồng bộ và Symbol.asyncDispose cho việc dọn dẹp bất đồng bộ.
  2. Khai báo `using` và `await using`: Các từ khóa mới ràng buộc một tài nguyên với một phạm vi khối. Khi thoát khỏi khối, phương thức dọn dẹp của tài nguyên sẽ tự động được gọi.

Các khái niệm cốt lõi: `Symbol.dispose` và `Symbol.asyncDispose`

Trọng tâm của ERM là hai Symbol nổi tiếng mới. Một đối tượng có một phương thức với một trong những symbol này làm khóa của nó được coi là một "tài nguyên có thể hủy bỏ" (disposable resource).

Hủy bỏ đồng bộ với `Symbol.dispose`

Biểu tượng Symbol.dispose chỉ định một phương thức dọn dẹp đồng bộ. Điều này phù hợp với các tài nguyên mà việc dọn dẹp không yêu cầu bất kỳ hoạt động bất đồng bộ nào, như đóng một handle tệp tin một cách đồng bộ hoặc giải phóng một khóa trong bộ nhớ.

Hãy tạo một trình bao bọc (wrapper) cho một tệp tạm thời có khả năng tự dọn dẹp.


const fs = require('fs');
const path = require('path');

class TempFile {
  constructor(content) {
    this.path = path.join(__dirname, `temp_${Date.now()}.txt`);
    fs.writeFileSync(this.path, content);
    console.log(`Đã tạo tệp tạm: ${this.path}`);
  }

  // Đây là phương thức disposable đồng bộ
  [Symbol.dispose]() {
    console.log(`Đang hủy tệp tạm: ${this.path}`);
    try {
      fs.unlinkSync(this.path);
      console.log('Tệp đã được xóa thành công.');
    } catch (error) {
      console.error(`Xóa tệp thất bại: ${this.path}`, error);
      // Xử lý lỗi bên trong dispose cũng rất quan trọng!
    }
  }
}

Bất kỳ thực thể nào của `TempFile` giờ đây là một tài nguyên có thể hủy bỏ. Nó có một phương thức được khóa bởi `Symbol.dispose` chứa logic để xóa tệp khỏi đĩa.

Hủy bỏ bất đồng bộ với `Symbol.asyncDispose`

Nhiều hoạt động dọn dẹp hiện đại là bất đồng bộ. Việc đóng một kết nối cơ sở dữ liệu có thể liên quan đến việc gửi một lệnh `QUIT` qua mạng, hoặc một client hàng đợi tin nhắn có thể cần phải xóa bộ đệm gửi đi của nó. Đối với những kịch bản này, chúng ta sử dụng `Symbol.asyncDispose`.

Phương thức được liên kết với `Symbol.asyncDispose` phải trả về một `Promise` (hoặc là một hàm `async`).

Hãy mô hình hóa một kết nối cơ sở dữ liệu giả (mock) cần được giải phóng trở lại một pool một cách bất đồng bộ.


// Một pool cơ sở dữ liệu giả
const mockDbPool = {
  getConnection: () => {
    console.log('Đã nhận kết nối CSDL.');
    return new MockDbConnection();
  }
};

class MockDbConnection {
  query(sql) {
    console.log(`Đang thực thi truy vấn: ${sql}`);
    return Promise.resolve({ success: true, rows: [] });
  }

  // Đây là phương thức disposable bất đồng bộ
  async [Symbol.asyncDispose]() {
    console.log('Đang giải phóng kết nối CSDL về lại pool...');
    // Mô phỏng độ trễ mạng khi giải phóng kết nối
    await new Promise(resolve => setTimeout(resolve, 50));
    console.log('Kết nối CSDL đã được giải phóng.');
  }
}

Giờ đây, bất kỳ thực thể `MockDbConnection` nào cũng là một tài nguyên có thể hủy bỏ bất đồng bộ. Nó biết cách tự giải phóng một cách bất đồng bộ khi không còn cần thiết nữa.

Cú pháp mới: `using` và `await using` trong thực tế

Với các lớp disposable đã được định nghĩa, chúng ta có thể sử dụng các từ khóa mới để quản lý chúng một cách tự động. Các từ khóa này tạo ra các khai báo có phạm vi khối, giống như `let` và `const`.

Dọn dẹp đồng bộ với `using`

Từ khóa `using` được sử dụng cho các tài nguyên triển khai `Symbol.dispose`. Khi việc thực thi mã rời khỏi khối nơi khai báo `using` được tạo, phương thức `[Symbol.dispose]()` sẽ tự động được gọi.

Hãy sử dụng lớp `TempFile` của chúng ta:


function processDataWithTempFile() {
  console.log('Bắt đầu khối lệnh...');
  using tempFile = new TempFile('Đây là một số dữ liệu quan trọng.');

  // Bạn có thể làm việc với tempFile tại đây
  const content = fs.readFileSync(tempFile.path, 'utf8');
  console.log(`Đọc từ tệp tạm: "${content}"`);

  // Không cần mã dọn dẹp ở đây!
  console.log('...đang thực hiện công việc khác...');
} // <-- tempFile.[Symbol.dispose]() được gọi tự động ngay tại đây!

processDataWithTempFile();
console.log('Đã thoát khỏi khối lệnh.');

Kết quả đầu ra sẽ là:

Bắt đầu khối lệnh...
Đã tạo tệp tạm: /path/to/temp_1678886400000.txt
Đọc từ tệp tạm: "Đây là một số dữ liệu quan trọng."
...đang thực hiện công việc khác...
Đang hủy tệp tạm: /path/to/temp_1678886400000.txt
Tệp đã được xóa thành công.
Đã thoát khỏi khối lệnh.

Hãy xem nó gọn gàng như thế nào! Toàn bộ vòng đời của tài nguyên được chứa trong khối. Chúng ta khai báo nó, chúng ta sử dụng nó, và chúng ta quên nó đi. Ngôn ngữ sẽ xử lý việc dọn dẹp. Đây là một cải tiến lớn về khả năng đọc và tính an toàn.

Quản lý nhiều tài nguyên

Bạn có thể có nhiều khai báo `using` trong cùng một khối. Chúng sẽ được hủy bỏ theo thứ tự ngược lại với thứ tự tạo ra chúng (hành vi LIFO hay "hành vi giống ngăn xếp").


{
  using resourceA = new MyDisposable('A'); // Được tạo trước
  using resourceB = new MyDisposable('B'); // Được tạo sau
  console.log('Bên trong khối lệnh, đang sử dụng các tài nguyên...');
} // resourceB được hủy trước, sau đó đến resourceA

Dọn dẹp bất đồng bộ với `await using`

Từ khóa `await using` là phiên bản bất đồng bộ của `using`. Nó được sử dụng cho các tài nguyên triển khai `Symbol.asyncDispose`. Vì việc dọn dẹp là bất đồng bộ, từ khóa này chỉ có thể được sử dụng bên trong một hàm `async` hoặc ở cấp cao nhất của một module (nếu top-level await được hỗ trợ).

Hãy sử dụng lớp `MockDbConnection` của chúng ta:


async function performDatabaseOperation() {
  console.log('Bắt đầu hàm bất đồng bộ...');
  await using db = mockDbPool.getConnection();

  await db.query('SELECT * FROM users');

  console.log('Thao tác cơ sở dữ liệu hoàn tất.');
} // <-- await db.[Symbol.asyncDispose]() được gọi tự động tại đây!

(async () => {
  await performDatabaseOperation();
  console.log('Hàm bất đồng bộ đã hoàn thành.');
})();

Kết quả đầu ra minh họa việc dọn dẹp bất đồng bộ:

Bắt đầu hàm bất đồng bộ...
Đã nhận kết nối CSDL.
Đang thực thi truy vấn: SELECT * FROM users
Thao tác cơ sở dữ liệu hoàn tất.
Đang giải phóng kết nối CSDL về lại pool...
(chờ 50ms)
Kết nối CSDL đã được giải phóng.
Hàm bất đồng bộ đã hoàn thành.

Giống như với `using`, cú pháp `await using` xử lý toàn bộ vòng đời, nhưng nó `await` một cách chính xác quá trình dọn dẹp bất đồng bộ. Nó thậm chí có thể xử lý các tài nguyên chỉ có thể hủy bỏ đồng bộ—nó sẽ đơn giản là không await chúng.

Các Mẫu Nâng cao: `DisposableStack` và `AsyncDisposableStack`

Đôi khi, việc giới hạn phạm vi khối đơn giản của `using` không đủ linh hoạt. Điều gì sẽ xảy ra nếu bạn cần quản lý một nhóm tài nguyên có vòng đời không bị ràng buộc với một khối từ vựng duy nhất? Hoặc nếu bạn đang tích hợp với một thư viện cũ không tạo ra các đối tượng có `Symbol.dispose`?

Đối với các kịch bản này, JavaScript cung cấp hai lớp trợ giúp: `DisposableStack` và `AsyncDisposableStack`.

`DisposableStack`: Trình quản lý dọn dẹp linh hoạt

Một `DisposableStack` là một đối tượng quản lý một tập hợp các hoạt động dọn dẹp. Bản thân nó là một tài nguyên có thể hủy bỏ, vì vậy bạn có thể quản lý toàn bộ vòng đời của nó với một khối `using`.

Nó có một vài phương thức hữu ích:

Ví dụ: Quản lý tài nguyên có điều kiện

Hãy tưởng tượng một hàm mở một tệp nhật ký chỉ khi một điều kiện nhất định được đáp ứng, nhưng bạn muốn tất cả việc dọn dẹp diễn ra ở một nơi duy nhất ở cuối cùng.


function processWithConditionalLogging(shouldLog) {
  using stack = new DisposableStack();

  const db = stack.use(getDbConnection()); // Luôn sử dụng CSDL

  if (shouldLog) {
    const logFileStream = fs.createWriteStream('app.log');
    // Trì hoãn việc dọn dẹp cho stream
    stack.defer(() => {
      console.log('Đang đóng stream tệp nhật ký...');
      logFileStream.end();
    });
    db.logTo(logFileStream);
  }

  db.doWork();

} // <-- Ngăn xếp được hủy, gọi tất cả các hàm dọn dẹp đã đăng ký theo thứ tự LIFO.

`AsyncDisposableStack`: Dành cho thế giới bất đồng bộ

Như bạn có thể đoán, `AsyncDisposableStack` là phiên bản bất đồng bộ. Nó có thể quản lý cả các disposable đồng bộ và bất đồng bộ. Phương thức dọn dẹp chính của nó là `.disposeAsync()`, trả về một `Promise` sẽ giải quyết khi tất cả các hoạt động dọn dẹp bất đồng bộ được hoàn tất.

Ví dụ: Quản lý một tập hợp hỗn hợp các tài nguyên

Hãy tạo một trình xử lý yêu cầu máy chủ web cần một kết nối cơ sở dữ liệu (dọn dẹp bất đồng bộ) và một tệp tạm thời (dọn dẹp đồng bộ).


async function handleRequest() {
  await using stack = new AsyncDisposableStack();

  // Quản lý một tài nguyên disposable bất đồng bộ
  const dbConnection = await stack.use(getAsyncDbConnection());

  // Quản lý một tài nguyên disposable đồng bộ
  const tempFile = stack.use(new TempFile('request data'));

  // "Nhận nuôi" một tài nguyên từ API cũ
  const legacyResource = getLegacyResource();
  stack.adopt(legacyResource, () => legacyResource.shutdown());

  console.log('Đang xử lý yêu cầu...');
  await doWork(dbConnection, tempFile.path);

} // <-- stack.disposeAsync() được gọi. Nó sẽ await việc dọn dẹp bất đồng bộ một cách chính xác.

`AsyncDisposableStack` là một công cụ mạnh mẽ để điều phối logic thiết lập và dọn dẹp phức tạp một cách sạch sẽ, có thể dự đoán được.

Xử lý lỗi mạnh mẽ với `SuppressedError`

Một trong những cải tiến tinh tế nhưng quan trọng nhất của ERM là cách nó xử lý lỗi. Điều gì sẽ xảy ra nếu một lỗi được ném ra bên trong khối `using`, và *một lỗi khác* được ném ra trong quá trình hủy tự động sau đó?

Trong thế giới `try...finally` cũ, lỗi từ khối `finally` thường sẽ ghi đè hoặc "đè nén" lỗi ban đầu, quan trọng hơn từ khối `try`. Điều này thường làm cho việc gỡ lỗi cực kỳ khó khăn.

ERM giải quyết vấn đề này với một loại lỗi toàn cục mới: `SuppressedError`. Nếu một lỗi xảy ra trong quá trình hủy bỏ trong khi một lỗi khác đã được lan truyền, lỗi hủy bỏ sẽ bị "đè nén". Lỗi ban đầu được ném ra, nhưng bây giờ nó có một thuộc tính `suppressed` chứa lỗi hủy bỏ.


class FaultyResource {
  [Symbol.dispose]() {
    throw new Error('Lỗi trong quá trình hủy bỏ!');
  }
}

try {
  using resource = new FaultyResource();
  throw new Error('Lỗi trong quá trình hoạt động!');
} catch (e) {
  console.log(`Bắt được lỗi: ${e.message}`); // Lỗi trong quá trình hoạt động!
  if (e.suppressed) {
    console.log(`Lỗi bị đè nén: ${e.suppressed.message}`); // Lỗi trong quá trình hủy bỏ!
    console.log(e instanceof SuppressedError); // false
    console.log(e.suppressed instanceof Error); // true
  }
}

Hành vi này đảm bảo rằng bạn không bao giờ mất ngữ cảnh của lỗi ban đầu, dẫn đến các hệ thống mạnh mẽ và dễ gỡ lỗi hơn.

Các trường hợp sử dụng thực tế trong hệ sinh thái JavaScript

Ứng dụng của Quản lý Tài nguyên Tường minh rất rộng lớn và phù hợp với các nhà phát triển trên toàn cầu, cho dù họ đang làm việc trên back-end, front-end hay trong kiểm thử.

Hỗ trợ trên Trình duyệt và Môi trường chạy

Là một tính năng hiện đại, điều quan trọng là phải biết bạn có thể sử dụng Quản lý Tài nguyên Tường minh ở đâu. Tính đến cuối năm 2023 / đầu năm 2024, hỗ trợ đã được phổ biến rộng rãi trong các phiên bản mới nhất của các môi trường JavaScript lớn:

Đối với các môi trường cũ hơn, bạn sẽ cần dựa vào các trình chuyển mã như Babel với các plugin thích hợp để chuyển đổi cú pháp `using` và polyfill các symbol và các lớp stack cần thiết.

Kết luận: Một Kỷ nguyên Mới của Sự An toàn và Rõ ràng

Quản lý Tài nguyên Tường minh của JavaScript không chỉ là cú pháp màu mè; nó là một cải tiến cơ bản cho ngôn ngữ nhằm thúc đẩy sự an toàn, rõ ràng và khả năng bảo trì. Bằng cách tự động hóa quy trình dọn dẹp tài nguyên tẻ nhạt và dễ gây lỗi, nó giải phóng các nhà phát triển để tập trung vào logic nghiệp vụ chính của họ.

Những điểm chính cần nhớ là:

Khi bạn bắt đầu các dự án mới hoặc tái cấu trúc mã hiện có, hãy cân nhắc áp dụng mẫu mới mạnh mẽ này. Nó sẽ làm cho JavaScript của bạn sạch hơn, ứng dụng của bạn đáng tin cậy hơn, và cuộc sống của bạn với tư cách là một nhà phát triển dễ dàng hơn một chút. Đó thực sự là một tiêu chuẩn toàn cầu để viết JavaScript chuyên nghiệp, hiện đại.