0

Ghi Log Thủ Công Với fs.appendFile: Cú Pháp "Vỡ Lòng" Và Những Pha "Chèn Đè" Dữ Liệu Đi Vào Lòng Đất

Chào anh em Viblo! 👋

Khi làm Backend với Node.js, có tới 99% anh em từng phải tự viết một công cụ ghi log đơn giản, hoặc làm tính năng xuất dữ liệu ra file .txt, .csv local trên server. Và phương thức đầu tiên chúng ta tìm đến trong module fs chính là appendFile.

Nhìn vào file định nghĩa kiểu dữ liệu (TypeScript core) của Node.js, hàm này có signature trông như thế này:

export function appendFile(
    file: PathOrFileDescriptor, 
    data: string | Uint8Array, 
    callback: NoParamCallback
): void;

Hàm này nhìn qua thì cực kỳ đơn giản, chỉ là "nhét thêm chữ vào cuối file". Nhưng nếu không hiểu rõ cách Node.js xử lý bất đồng bộ (Asynchronous) dưới tầng hệ điều hành, anh em rất dễ biến file log của mình thành một đống ký tự hỗn độn, đè rối rắm lên nhau khi hệ thống có High Traffic.

Hôm nay, mình sẽ cùng anh em mổ xẻ từng tham số của hàm này và chia sẻ vài cú "vấp ngã" nhớ đời khi làm việc với File System trong Node.js nhé!

1. Bóc Tách Các Tham Số (Signature Breakdown)

Hãy cùng xem ba tham số mà hàm này yêu cầu:

  • file: PathOrFileDescriptor: Đây là nơi bạn chỉ định file cần ghi. Bạn có thể truyền vào một đường dẫn dạng chuỗi (ví dụ: './logs/app.log'), một đối tượng URL, hoặc một File Descriptor (một số nguyên đại diện cho một file đang được mở bởi hệ điều hành).

Điểm cộng: Nếu file bạn truyền vào chưa tồn tại, Node.js sẽ tự động tạo mới file đó rồi mới ghi dữ liệu vào. Bạn không cần mất công viết hàm check fs.existsSync để tạo file trước.

  • data: string | Uint8Array: Nội dung bạn muốn ghi thêm vào cuối file. Nó có thể là một chuỗi văn bản thuần túy (string) hoặc dữ liệu nhị phân dưới dạng một mảng các byte (Uint8Array / Buffer).
  • callback: NoParamCallback: Vì đây là hàm bất đồng bộ (Asynchronous), Node.js sẽ đẩy việc ghi file xuống cho tầng luồng ngầm (Thread Pool của thư viện libuv) xử lý. Khi ghi xong (hoặc bị lỗi sập ổ đĩa, không có quyền ghi quyền đọc), hàm callback này sẽ được kích hoạt. Hàm này nhận vào một tham số duy nhất là lỗi: (err) => { ... }.

2. Luồng Hoạt Động Của appendFile

Khi bạn gọi appendFile, Node.js không hề chặn (block) code của bạn lại để đợi ghi xong file. Nó gửi một yêu cầu xuống hệ điều hành thông qua Libuv và tiếp tục chạy các dòng code phía dưới. Khi hệ điều hành báo cáo "Đã ghi xong vào ổ cứng", Node.js mới bốc hàm callback ra để thực thi.

const fs = require('node:fs');

console.log('1. Bắt đầu ghi log...');
fs.appendFile('app.log', 'User đăng nhập thành công\n', (err) => {
    if (err) throw err;
    console.log('3. Đã append dữ liệu vào file xong!');
});
console.log('2. Làm việc khác thôi!');

// Kết quả in ra console:
// 1. Bắt đầu ghi log...
// 2. Làm việc khác thôi!
// 3. Đã append dữ liệu vào file xong!

3. Những "Vết Sẹo" Thực Chiến Khi Dùng appendFile

**Lỗi 1: Cơn ác mộng "Race Condition" (Đua Luồng) **

Đây là lỗi kinh điển nhất của các bạn Junior. Giả sử bạn có một vòng lặp for hoặc nhiều Request cùng tràn vào một lúc, và bạn dùng vòng lặp đó để append dữ liệu vào cùng một file:

// ❌ CODE NGUY HIỂM:
for (let i = 0; i < 1000; i++) {
    fs.appendFile('report.txt', `Dòng số ${i}\n`, (err) => {});
}

Hậu quả: Vì appendFile chạy bất đồng bộ, Node.js sẽ mở cùng một lúc hàng trăm kết nối ngầm xuống file report.txt. Các luồng này sẽ tranh giành nhau ghi vào file. Kết quả trong file report.txt sẽ bị đảo lộn thứ tự (ví dụ dòng 10 xuất hiện trước dòng 5), hoặc tệ hơn là các chuỗi ký tự bị ghi đè, dính chặt vào nhau tạo thành đống rác dữ liệu không thể parse nổi.

Bài học: Nếu muốn ghi liên tục nhiều dòng vào một file một cách an toàn, bạn có hai cách:

  1. Dùng phiên bản đồng bộ fs.appendFileSync() (Chấp nhận block luồng, chỉ dùng khi chạy tool/script nhỏ).
  2. Chuẩn bài nhất: Sử dụng Writable Streams (fs.createWriteStream với flag là 'a'). Stream sẽ tạo ra một đường ống xếp hàng (queue) dữ liệu, đảm bảo dòng nào vào trước ghi trước, không bao giờ bị xung đột.

Lỗi 2: Quên ký tự xuống dòng (\n)

Mới nghe thì buồn cười, nhưng appendFile đơn thuần là "dán" chuỗi mới vào ngay vị trí cuối cùng của file. Nếu bạn không chủ động cộng thêm ký tự \n (hoặc os.EOL), các dòng log của bạn sẽ dính liền vào nhau trên một hàng dài vô tận.

Lỗi 3: Ngập lụt "Callback Hell"

Nếu bạn muốn ghi file A xong, lấy kết quả ghi tiếp vào file B, rồi file C... Dùng callback sẽ khiến code của bạn thụt lề vào trong như một cái hình cây thông Noel (Callback Hell), cực kỳ khó đọc và quản lý lỗi.

4. Tối Ưu Hóa Theo Phong Cách Hiện Đại (Modern Node.js)

Nếu dự án của bạn sử dụng Node.js thế hệ mới hoặc TypeScript, hãy "bỏ rơi" phiên bản callback truyền thống này đi. Hãy chuyển sang sử dụng phiên bản Promise-based nằm trong node:fs/promises. Code của bạn nhìn sẽ clean, mượt mà và dễ dùng với cú pháp async/await hơn rất nhiều:

import { appendFile } from 'node:fs/promises';

async function writeLog(message: string): Promise<void> {
    try {
        // Sử dụng await giúp code tuần tự, dễ bắt lỗi bằng try/catch
        await appendFile('app.log', `${message}\n`, 'utf-8');
        console.log('Ghi log thành công!');
    } catch (error) {
        console.error('Lỗi sập ổ đĩa hoặc không có quyền ghi:', error);
    }
}

Đúc kết lại

Hàm appendFile dạng callback là một mảnh ghép lịch sử quan trọng của Node.js, thể hiện rõ tư duy "Non-blocking I/O". Tuy nhiên, khi làm việc với nó, anh em hãy luôn tỉnh táo trước bài toán Concurrency (Ghi file đồng thời). Nếu dữ liệu lớn và liên tục, hãy nhớ đến Stream. Nếu dữ liệu ít và cần code sạch, hãy chọn fs/promises.

Cảm ơn anh em đã theo dõi! Chúc anh em quản lý file mượt mà, không bao giờ lo mất log! Happy Coding! 🚀💻


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí