Trong bài trước chúng ta đã tìm hiểu về upload file từ client và lưu file vào ổ cứng, giờ là lúc chúng ta có thể cho phép client tải các tập tin xuống.

Những vấn đề

Nhìn thì có vẻ đơn giản nhưng tải tập tin lại chứa đựng rất nhiều vấn đề trong nó, trong đó có 3 vấn đề chính:

  1. Bộ nhớ (RAM) là hữu hạn.
  2. Có thể gây block luồng main.
  3. Truy cập ổ cứng ngẫu nhiên chậm.

Bộ nhớ (RAM) là hữu hạn

Cách đơn giản nhất để chúng ta trả về một tập tin đó là:

  1. Đọc toàn bộ nội dung tập tin vào bộ nhớ đệm.
  2. Gửi toàn bộ dữ liệu từ bộ nhớ đệm xuống cho client.
    Viết ở dạng giả mã sẽ thế này:
const file = File('path');
const buffer = Buffer.allocate(file.length);
file.read(buffer);
response.write(buffer);

Mặc dù cách làm này là đơn giản nhất nhưng nhược điểm cực kỳ lớn của nó là nếu dung lượng tập tin quá lớn nó sẽ khiến không đủ RAM và tràn bộ nhớ. Thêm vào nữa thì việc khởi tạo một bộ nhớ đệm với hàng triệu byte không hề đơn giản, nó có thể rất chậm, lại thêm việc đọc và ghi hàng triệu byte sẽ càng chậm và làm block luồng main.

Có thể block luồng main

Để giải quyết vấn đề bộ nhớ hữu hạn, chúng ta có thể sử dụng cách sử dụng buffer nhỏ và vòng lặp while với giả mã như sau:

const file = File('path');
const buffer = Buffer.allocate(1024);
while(true) {
    const readBytes = file.read(buffer);
    if (readBytes > 0) {
        response.write(buffer);
    } else {
        break;
    }
}

Cách này cũng tương đối ổn nhưng nó cũng có nhược điểm là phải ghi toàn bộ dung lượng của tập tin xuống client trong 1 lần nên nếu dung lượng tập tin lớn nó vẫn làm block luồng main.

Truy cập ổ cứng ngẫu nhiên chậm

Để tránh block luồng main quán lâu chúng ta cũng có thể sử dụng range request (một http header), dựa vào đây chúng ta sẽ đọc được 1 đoạn tập tin để không làm block luồng main với với giả mã như sau:

const file = RandomFileAccess('path');
const range = header.get('Range');
const from = range[0];
const to = range[1];
const buffer = Buffer.allocate(to - from);
file.seek(from);
file.read(buffer);
response.write(buffer);

Ở đây lưu ý rằng chúng ta đang sử dụng RandomFileAccess và hàm seek, hàm này không có được một hiệu suất tốt nhất do những đặc tính của việc đọc dữ liệu từ ổ cứng, mặc dù với SSD thì tốc độ đã rất nhanh rồi nhưng vẫn có rủi ro chậm, vậy nên nếu chúng ta lạm dụng phương pháp này sẽ không ổn và vẫn có thể làm blog luồng main.

Sử dụng bất đồng bộ để giải quyết vấn đề

Nguồn ảnh: https://youngmonkeys.org/ezyhttp/guides/ezyhttp-download-file
Ở đây chúng ta sẽ sử dụng main thread để đọc và ghi dữ liệu tập tin đến client, thay vào đó chúng ta sẽ sử dụng một hoặc nhiều luồng I/O riêng biệt, ở mỗi luồng chúng ta lại đọc một ít dung lượng từ tập tin và gửi đến client sử dụng Event Loop (kết hợp giữa hàng đợi và các thread) để phân bổ tài nguyên việc ghi cho đồng đều.
Rất may cho chúng ta những thứ phức tạp này đã được thư multer đóng gói lại và chúng ta chỉ cần sử dụng với mã nguồn đơn giản như thế này thôi:

app.get('/download/:fileName', function (req, res, next) {
    console.log('start download')
    const fileName = req.params.fileName;
    const file = fs.createReadStream(`files/${fileName}`);
    res.setHeader('Content-Disposition', 'attachment: filename="' + fileName + '"');
    file.pipe(res);
});

Theo cách này thì luồng main của chúng ta sẽ không bị block, tuy nhiên nó cũng tạo ra cám giác magic cho các lập trình chưa thực sự hiểu sâu về Node.js.
Chúng ta có thể gọi API đơn giản thế này thôi, ví dụ: http://localhost:3000/download/file-1724048849597-52522265.png

Tổng kết

Như vậy chúng ta đã cùng nhau tạo một API để download file với express và multer.


Cám ơn bạn đã quan tâm đến bài viết|video này. Để nhận được thêm các kiến thức bổ ích bạn có thể:

  1. Đọc các bài viết của TechMaster trên facebook: https://www.facebook.com/techmastervn
  2. Xem các video của TechMaster qua Youtube: https://www.youtube.com/@TechMasterVietnam nếu bạn thấy video/bài viết hay bạn có thể theo dõi kênh của TechMaster để nhận được thông báo về các video mới nhất nhé.
  3. Chat với techmaster qua Discord: https://discord.gg/yQjRTFXb7a