jsrosetta

Xử lý lỗi

Dọn dẹp tài nguyên: using và defer

using/await using, Symbol.dispose và DisposableStack của Node.js so với defer của Go: phạm vi block hay hàm, thứ tự LIFO và lỗi khi đóng tài nguyên.

Tác giả:
Phiên bản tối thiểu
Node.js ≥ 24Go ≥ 1.20
Đã chạy thử trên
Node.js 24.12.0Go 1.27.1

Code Node.js là ES module: lưu file .mjs hoặc đặt "type": "module" trong package.json.

Mở file, lấy lock, kết nối database — thứ gì đã mở thì phải đóng, kể cả khi code ở giữa ném lỗi. JavaScript từ lâu chỉ có try/finally cho việc này. Đề xuất Explicit Resource Management (TC39 Stage 4, nằm trong ES2027) thêm khai báo using / await using: biến khai báo bằng using sẽ tự gọi [Symbol.dispose]() (hoặc await [Symbol.asyncDispose]()) khi ra khỏi block chứa nó. Go có defer từ phiên bản đầu tiên: lời gọi được hoãn tới khi hàm chứa nó return. Cả hai đều dọn dẹp theo thứ tự ngược với lúc mở (LIFO), nhưng khác nhau ở phạm vi — block hay hàm — và đó là chỗ dễ vấp nhất.

Dọn dẹp đồng bộ (using vs defer)

class Lock {
  constructor(name) {
    this.name = name;
    console.log(`lock ${name}`);
  }
 
  // Bất kỳ object nào có [Symbol.dispose]() đều dùng được với `using`.
  [Symbol.dispose]() {
    console.log(`unlock ${this.name}`);
  }
}
 
function transfer(fail) {
  using lock = new Lock("account");
  if (fail) throw new Error("insufficient funds");
  console.log("transferred");
} // lock[Symbol.dispose]() chạy ở đây, kể cả khi throw
 
transfer(false);
try {
  transfer(true);
} catch (err) {
  console.log("error:", err.message);
}
// → lock account
// → transferred
// → unlock account
// → lock account
// → unlock account
// → error: insufficient funds

Dọn dẹp bất đồng bộ (await using vs defer)

Đóng kết nối mạng hay flush file thường là thao tác async trong Node.js. await using gọi await value[Symbol.asyncDispose]() khi ra khỏi block. Go không phân biệt đồng bộ/bất đồng bộ: Close() chỉ block tới khi xong, nên vẫn là defer như cũ.

import { open, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
 
const path = join(tmpdir(), "jsrosetta-using.txt");
 
class Connection {
  static async connect(name) {
    await new Promise((resolve) => setTimeout(resolve, 10));
    console.log(`connected ${name}`);
    return new Connection(name);
  }
 
  constructor(name) {
    this.name = name;
  }
 
  // Dọn dẹp bất đồng bộ: `await using` sẽ await hàm này.
  async [Symbol.asyncDispose]() {
    await new Promise((resolve) => setTimeout(resolve, 10));
    console.log(`disconnected ${this.name}`);
  }
}
 
async function exportReport() {
  await using db = await Connection.connect("db");
  await using file = await open(path, "w"); // FileHandle của fs/promises có sẵn Symbol.asyncDispose
  await file.write(`report from ${db.name}\n`);
  console.log("written");
} // await file.close(), rồi await db[Symbol.asyncDispose]()
 
try {
  await exportReport();
  console.log((await readFile(path, "utf8")).trim());
} finally {
  await rm(path, { force: true });
}
// → connected db
// → written
// → disconnected db
// → report from db

Nhiều tài nguyên: thứ tự LIFO (DisposableStack vs defer)

Cả hai ngôn ngữ đều đóng theo thứ tự ngược: thứ mở sau đóng trước, vì nó có thể phụ thuộc vào thứ mở trước. Khi số tài nguyên chỉ biết lúc chạy, JavaScript dùng DisposableStack (bản async là AsyncDisposableStack); Go không cần gì thêm vì mỗi hàm vốn đã có một ngăn xếp defer.

function acquire(name) {
  console.log(`open ${name}`);
  return { [Symbol.dispose]: () => console.log(`close ${name}`) };
}
 
function fixed() {
  using a = acquire("a");
  using b = acquire("b");
  console.log("work");
} // đóng ngược thứ tự mở: b rồi a
 
// Số tài nguyên chỉ biết lúc chạy: gom vào một DisposableStack.
function dynamic(names) {
  using stack = new DisposableStack();
  for (const name of names) {
    stack.use(acquire(name));
  }
  stack.defer(() => console.log("flush log")); // gần với `defer` của Go nhất
  console.log("work");
} // stack dispose mọi thứ theo LIFO
 
fixed();
dynamic(["x", "y"]);
// → open a
// → open b
// → work
// → close b
// → close a
// → open x
// → open y
// → work
// → flush log
// → close y
// → close x

Phạm vi: block (using) hay hàm (defer)

Đây là khác biệt quan trọng nhất. using dọn dẹp ở cuối block, nên trong vòng lặp mỗi vòng đóng tài nguyên của nó. defer chỉ chạy khi hàm return — defer trong vòng lặp sẽ giữ mọi tài nguyên mở cho tới cuối hàm.

function acquire(name) {
  console.log(`open ${name}`);
  return { [Symbol.dispose]: () => console.log(`close ${name}`) };
}
 
for (const name of ["a", "b"]) {
  using file = acquire(name);
  console.log(`use ${name}`);
} // `using` gắn với block: dispose ở cuối mỗi vòng lặp
console.log("loop done");
// → open a
// → use a
// → close a
// → open b
// → use b
// → close b
// → loop done

Lỗi khi dọn dẹp (SuppressedError vs errors.Join)

Nếu thân block ném lỗi rồi việc dọn dẹp cũng ném lỗi, lỗi nào được giữ lại? JavaScript gói cả hai vào một SuppressedError: error là lỗi mới (từ dispose), suppressed là lỗi bị nó đè lên (từ thân block). Go không có cơ chế tự động nào — defer f.Close() âm thầm bỏ qua lỗi trả về; muốn giữ lỗi thì dùng named return và errors.Join.

function openFile(name) {
  return {
    [Symbol.dispose]() {
      throw new Error(`close ${name} failed`);
    },
  };
}
 
function save() {
  using file = openFile("a.txt");
  throw new Error("write failed");
}
 
try {
  save();
} catch (err) {
  // Cả thân hàm lẫn dispose đều throw: lỗi bị gói trong SuppressedError.
  console.log(err instanceof SuppressedError); // → true
  console.log(err.error.message); // → close a.txt failed  (lỗi mới nhất, từ dispose)
  console.log(err.suppressed.message); // → write failed  (lỗi bị "đè", từ thân hàm)
}
 
// Chỉ dispose throw: nhận đúng lỗi đó, không có SuppressedError.
try {
  using file = openFile("b.txt");
} catch (err) {
  console.log(err.message); // → close b.txt failed
}