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.
- 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 fundspackage main
import (
"errors"
"fmt"
)
type Lock struct{ name string }
func NewLock(name string) *Lock {
fmt.Println("lock", name)
return &Lock{name: name}
}
func (l *Lock) Unlock() {
fmt.Println("unlock", l.name)
}
func transfer(fail bool) error {
lock := NewLock("account")
defer lock.Unlock() // chạy khi transfer return, dù return ở nhánh nào (kể cả panic)
if fail {
return errors.New("insufficient funds")
}
fmt.Println("transferred")
return nil
}
func main() {
if err := transfer(false); err != nil {
fmt.Println("error:", err)
}
if err := transfer(true); err != nil {
fmt.Println("error:", err)
}
}
// → lock account
// → transferred
// → unlock account
// → lock account
// → unlock account
// → error: insufficient fundsDọ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 dbpackage main
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
type Connection struct{ name string }
func Connect(name string) (*Connection, error) {
time.Sleep(10 * time.Millisecond)
fmt.Println("connected", name)
return &Connection{name: name}, nil
}
// Go không tách "đồng bộ" và "bất đồng bộ": Close chỉ đơn giản là block tới khi xong.
func (c *Connection) Close() error {
time.Sleep(10 * time.Millisecond)
fmt.Println("disconnected", c.name)
return nil
}
func exportReport(path string) (err error) {
db, err := Connect("db")
if err != nil {
return err
}
defer db.Close()
file, err := os.Create(path)
if err != nil {
return err // chỉ defer sau khi chắc chắn mở thành công
}
defer func() {
err = errors.Join(err, file.Close()) // file ghi: đừng nuốt lỗi Close (xem phần cuối bài)
}()
if _, err := fmt.Fprintf(file, "report from %s\n", db.name); err != nil {
return err
}
fmt.Println("written")
return nil
} // file.Close(), rồi db.Close()
func main() {
path := filepath.Join(os.TempDir(), "jsrosetta-defer.txt")
defer os.Remove(path)
if err := exportReport(path); err != nil {
fmt.Println("error:", err)
return
}
data, err := os.ReadFile(path)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(strings.TrimSpace(string(data)))
}
// → connected db
// → written
// → disconnected db
// → report from dbNhiề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 xpackage main
import "fmt"
type resource struct{ name string }
func acquire(name string) *resource {
fmt.Println("open", name)
return &resource{name: name}
}
func (r *resource) Close() {
fmt.Println("close", r.name)
}
func fixed() {
a := acquire("a")
defer a.Close()
b := acquire("b")
defer b.Close()
fmt.Println("work")
} // defer chạy theo LIFO: b rồi a
// Mỗi hàm đã có sẵn một "stack" defer, nên số lượng động không cần gì thêm.
func dynamic(names []string) {
for _, name := range names {
defer acquire(name).Close() // acquire chạy ngay, chỉ Close bị hoãn
}
defer fmt.Println("flush log")
fmt.Println("work")
}
func main() {
fixed()
dynamic([]string{"x", "y"})
}
// → open a
// → open b
// → work
// → close b
// → close a
// → open x
// → open y
// → work
// → flush log
// → close y
// → close xPhạ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 donepackage main
import "fmt"
type resource struct{ name string }
func acquire(name string) *resource {
fmt.Println("open", name)
return &resource{name: name}
}
func (r *resource) Close() {
fmt.Println("close", r.name)
}
// Sai: defer gắn với hàm, không phải block — mọi file chỉ được đóng khi hàm return.
func leaky(names []string) {
for _, name := range names {
file := acquire(name)
defer file.Close()
fmt.Println("use", name)
}
fmt.Println("loop done")
}
// Đúng: tách thân vòng lặp thành hàm riêng để mỗi vòng có phạm vi defer của nó.
func fixed(names []string) {
for _, name := range names {
func() {
file := acquire(name)
defer file.Close()
fmt.Println("use", name)
}()
}
fmt.Println("loop done")
}
func main() {
leaky([]string{"a", "b"})
fmt.Println("---")
fixed([]string{"a", "b"})
}
// → open a
// → use a
// → open b
// → use b
// → loop done
// → close b
// → close a
// → ---
// → open a
// → use a
// → close a
// → open b
// → use b
// → close b
// → loop doneLỗ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
}package main
import (
"errors"
"fmt"
)
var (
ErrWrite = errors.New("write failed")
ErrClose = errors.New("close a.txt failed")
)
type file struct{}
func (f *file) Close() error { return ErrClose }
// Named return `err` cho phép hàm defer đọc và sửa giá trị trả về.
func save() (err error) {
f := &file{}
defer func() {
err = errors.Join(err, f.Close()) // giữ cả hai lỗi; Join bỏ qua nil
}()
return ErrWrite
}
func main() {
err := save()
fmt.Println(err)
fmt.Println(errors.Is(err, ErrWrite), errors.Is(err, ErrClose))
}
// → write failed
// → close a.txt failed
// → true true