Skip to content

บทที่ 4 — Filesystem + Streams: อ่านไฟล์ 10GB โดยไม่ระเบิด RAM

← บทที่ 3: Async + Events | สารบัญ | บทที่ 5: HTTP + Web Server →

ความสามารถที่เบราว์เซอร์ไม่มีแต่ Node มี: อ่าน/เขียนไฟล์บนเครื่อง บทนี้ครอบคลุม:

  1. fs 3 รูปแบบ — sync / callback / promises — ใช้ตัวไหนเมื่อไหร่
  2. งานไฟล์ที่ใช้จริง — อ่าน เขียน ต่อท้าย สร้างโฟลเดอร์ ลิสต์ เช็กว่ามีไหม
  3. Buffer — ข้อมูล binary คืออะไร ทำไมต้องรู้
  4. Streams — ทำไม readFile ไฟล์ 10GB ถึงทำให้ server ตาย และ stream แก้ยังไง
  5. backpressure + pipeline — วิธีจัดการ stream ที่ถูกต้อง (คำศัพท์ทั้งสองคำจะอธิบายละเอียดใน §5)

1. fs มี 3 รูปแบบ — เลือกให้ถูก

core module fs ให้ API เดียวกันใน 3 สไตล์ ต้องรู้ว่าตอนไหนใช้ตัวไหน:

javascript
// (ก) Promise — ใช้ตัวนี้เป็นหลักในโค้ดทั่วไป (async, ไม่บล็อก, เขียนสวยด้วย await)
import { readFile, writeFile } from "node:fs/promises";
const data = await readFile("config.json", "utf8");

// (ข) Callback — แบบเก่า error-first (บทที่ 3) เจอใน library เก่า
import { readFile as readFileCb } from "node:fs";
readFileCb("config.json", "utf8", (err, data) => { /* ... */ });

// (ค) Sync — บล็อก event loop! ใช้เฉพาะตอน startup หรือใน script เล็ก ๆ
import { readFileSync } from "node:fs";
const data2 = readFileSync("config.json", "utf8");   // โค้ดหยุดรอจนอ่านเสร็จ

เลือกยังไง:

รูปแบบใช้เมื่อระวัง
fs/promises (await)งานทั่วไปใน request handler, server✅ แนะนำเป็น default
callbacklibrary เก่าที่ยังไม่รองรับ Promiseจัดการ error ยุ่งกว่า
*Syncscript CLI สั้น ๆ, legacy codeห้ามใช้ใน request handler — บล็อก event loop ทั้งตัว

📌 request handler = ฟังก์ชันที่ตอบ HTTP request ที่เข้ามา (รายละเอียดบทที่ 5 ตอนสร้าง HTTP server) — ตอนนี้รู้แค่ว่ามันถูกเรียกบ่อยมากตอน server รัน ก็พอ เดี๋ยวบทที่ 5 จะโชว์โค้ดเต็ม ตอนนี้แค่รู้ว่าฟังก์ชันนี้ถูกเรียกซ้ำ ๆ เวลามีคนเข้าเว็บ — ถ้าใช้ readFileSync ในนี้ ทุกคนที่เข้าเว็บพร้อมกันจะต้องรอ

⚠️ Pitfall ที่ทำ server ช้าโดยไม่รู้ตัว: ใช้ readFileSync กลาง request handler — ทุก request ที่เข้ามาตอนนั้นจะถูกบล็อก เพราะ main thread ติดอยู่ที่การอ่านไฟล์ (บทที่ 0 — บาริสต้าหยุดรับออเดอร์) · ในโค้ดสมัยใหม่ใช้ await readFile() จาก fs/promises ได้ทุกที่ รวมถึงตอน boot (Node รองรับ top-level await ใน ES module แล้ว) — *Sync ส่วนใหญ่กลายเป็น code smell ของโค้ดเก่า


2. งานไฟล์ที่ใช้จริง

javascript
import {
  readFile, writeFile, appendFile, mkdir, readdir, stat, rm, rename,
} from "node:fs/promises";

// อ่านไฟล์ (ระบุ encoding 'utf8' → ได้ string; ไม่ระบุ → ได้ Buffer)
const text = await readFile("notes.txt", "utf8");

// เขียนไฟล์ (ทับของเดิม! ถ้ามีอยู่แล้ว)
await writeFile("out.txt", "เนื้อหาใหม่", "utf8");

// ต่อท้าย (ไม่ทับ — เหมาะกับ log)
await appendFile("app.log", `${new Date().toISOString()} เริ่มงาน\n`);

// สร้างโฟลเดอร์ (recursive: true = สร้าง path ซ้อนกันได้ + ไม่ error ถ้ามีอยู่แล้ว)
await mkdir("data/cache", { recursive: true });

// ลิสต์ไฟล์ในโฟลเดอร์
const files = await readdir("data");          // ['a.txt', 'b.json', ...]

// ดูข้อมูลไฟล์ (ขนาด, เวลาแก้ไข, เป็นไฟล์หรือโฟลเดอร์)
const info = await stat("notes.txt");
console.log(info.size, info.isDirectory(), info.mtime);

// ลบ (recursive + force = ลบโฟลเดอร์ทั้งอันไม่ error ถ้าไม่มี)
await rm("data/cache", { recursive: true, force: true });

// เปลี่ยนชื่อ/ย้าย
await rename("old.txt", "new.txt");

เช็กว่าไฟล์มีอยู่ไหม — อย่าใช้ existsSync ก่อนอ่าน

javascript
// ❌ anti-pattern: เช็กก่อนแล้วค่อยอ่าน — มี race condition (ไฟล์อาจหายระหว่างนั้น)
// (race condition = ผลลัพธ์ของโปรแกรมขึ้นกับ "จังหวะ" ที่เหตุการณ์เกิด — บางครั้งทำงาน บางครั้งพัง
//  เป็นบั๊กที่หายาก เพราะ reproduce ลำบาก ทดสอบในเครื่อง dev มักไม่เจอ)
import { existsSync } from "node:fs";
if (existsSync("file.txt")) {
  const data = await readFile("file.txt", "utf8");   // ไฟล์อาจถูกลบไปแล้วตรงนี้!
}

// ✅ ถูก: ลองอ่านเลย แล้วจับ error (ENOENT = ไม่เจอไฟล์)
try {
  const data = await readFile("file.txt", "utf8");
} catch (err) {
  if (err.code === "ENOENT") {
    console.log("ไม่มีไฟล์นี้");   // จัดการกรณีไม่มี
  } else {
    throw err;                     // error อื่นโยนต่อ
  }
}

💡 err.code ของ fs เป็นรหัสมาตรฐาน: ENOENT (ไม่เจอไฟล์/โฟลเดอร์), EACCES (ไม่มีสิทธิ์), EEXIST (มีอยู่แล้ว), EISDIR (เป็นโฟลเดอร์ไม่ใช่ไฟล์) — จัดการตาม err.code แม่นยำกว่าอ่านข้อความ

📌 หลายคนเคยเจอ fs.exists() (แบบ callback) ในโค้ดเก่า — deprecated ตั้งแต่ Node 1.0 เพราะ signature ไม่ตามกติกา error-first และยังมีปัญหา race condition เหมือนเดิม · ใช้ try { await readFile(...) } catch หรือ fs.access() แทน


3. Buffer — ข้อมูล binary

JavaScript string เก็บข้อความได้ดี แต่ไฟล์/network มีข้อมูลที่ไม่ใช่ข้อความ — รูปภาพ, zip, เสียง Node ใช้ Buffer เก็บข้อมูล binary ดิบ ๆ (ลำดับของ byte) Buffer คือ global ใช้ได้เลยไม่ต้อง import

javascript
// readFile ไม่ใส่ encoding → ได้ Buffer (byte ดิบ)
const buf = await readFile("photo.jpg");
console.log(buf.length);          // จำนวน byte
console.log(buf);                 // <Buffer ff d8 ff e0 ...> (เลขฐาน 16)

// แปลง Buffer ↔ string ด้วย encoding ต่าง ๆ (encoding = วิธีเข้ารหัส คือกติกาแปลง byte ↔ ตัวอักษร)
const text = Buffer.from("สวัสดี", "utf8");
console.log(text.toString("utf8"));     // 'สวัสดี'
console.log(text.toString("base64"));   // เข้ารหัส base64 (ใช้ตอนฝัง binary ใน JSON/URL)
console.log(text.toString("hex"));      // เลขฐาน 16

// สร้าง Buffer ว่างขนาดกำหนด (เช่นทำ buffer สำหรับเขียน)
const empty = Buffer.alloc(16);   // 16 byte ที่เป็น 0 ทั้งหมด (ปลอดภัย — initialized แล้ว)

⚠️ อย่าใช้ Buffer.allocUnsafe(n) ถ้าไม่จำเป็น — เร็วกว่า Buffer.alloc เพราะข้าม step "zero-fill" (การเติมค่า 0 ให้ทุก byte ก่อนส่งคืน) แต่ memory ที่ได้คือ "ของเก่าที่เพิ่งคืน" ยังไม่ถูกล้าง อาจมีข้อมูล sensitive (ข้อมูลอ่อนไหว) ค้างอยู่ เช่น password หรือ token ของ request ก่อนหน้า · ถ้าไม่ overwrite ก่อนใช้แล้วบังเอิญส่งออกไปทาง network ข้อมูลนั้นจะ leak (รั่วไหลออกไปโดยไม่ตั้งใจ) · ใช้ allocUnsafe เฉพาะตอนที่จะเขียนทับทั้ง buffer ทันที (เช่น read ลงไป fill เต็ม)

⚠️ อย่าใช้ new Buffer(...) — deprecated มานานหลายปี ใช้ Buffer.from() / Buffer.alloc() แทน

ทำไมต้องรู้: ตอนทำ file upload, สร้าง hash (crypto), หรือจัดการ stream — ข้อมูลมาเป็น Buffer · encoding ที่เจอบ่อย: utf8 (ข้อความ), base64 (ฝัง binary ในข้อความ), hex (hash/checksum)

⚠️ Pitfall: ตัวอักษรไทย/emoji 1 ตัวกินหลาย byte ใน UTF-8 — "ก".length (จำนวนตัวอักษร) ≠ Buffer.byteLength("ก") (จำนวน byte) เวลาตัด string เป็นชิ้น ๆ ระวังตัดกลางตัวอักษร


4. ทำไมต้องมี Stream — ปัญหาไฟล์ใหญ่

readFile โหลด ทั้งไฟล์เข้า memory ทีเดียว — ไฟล์เล็กไม่เป็นไร แต่ถ้าเป็น log 10GB:

javascript
// ❌ หายนะ: โหลดไฟล์ 10GB เข้า RAM ทั้งก้อน → server มี RAM 2GB ตายทันที
const huge = await readFile("server-10gb.log", "utf8");

Stream แก้ปัญหานี้ — แทนที่จะโหลดทั้งก้อน มันอ่าน/ส่งข้อมูล ทีละชิ้น (chunk) เหมือนน้ำไหลผ่านท่อ ใช้ memory แค่ "ชิ้นที่กำลังประมวลผล" ไม่ว่าไฟล์จะใหญ่แค่ไหน:

text
readFile (โหลดทั้งก้อน):     [████████████ 10GB ████████████]  → RAM ระเบิด
                                          ↓ เข้า RAM ทีเดียว

stream (ทีละชิ้น):  ──[64KB]──[64KB]──[64KB]──▶  ประมวลผลทีละชิ้น → RAM แค่ ~64KB

นี่คือเหตุผลที่ Node จัดการไฟล์ใหญ่/วิดีโอ/การส่งข้อมูลจำนวนมากได้ดี — และทำไม HTTP request/response ก็เป็น stream (บทที่ 5)

Stream มี 4 ชนิด

ชนิดคือตัวอย่าง
Readableอ่านข้อมูลออกมาfs.createReadStream, HTTP request
Writableเขียนข้อมูลเข้าไปfs.createWriteStream, HTTP response
Duplexอ่าน+เขียน (2 ทาง)TCP socket (การเชื่อมต่อ network แบบ 2 ทิศทาง)
Transformอ่าน→แปลง→เขียนgzip (อัลกอริทึมบีบอัดข้อมูลยอดนิยม), การเข้ารหัส (= แปลงข้อมูลให้อ่านไม่ออกถ้าไม่มีกุญแจ)

ทุก stream เป็น EventEmitter (บทที่ 3) — มี event data (มีชิ้นใหม่), end (หมดแล้ว), error (พัง)

💡 Web Streams (node:stream/web): Node ยังรองรับมาตรฐาน Web Streams (ReadableStream/WritableStream/TransformStream) แบบเดียวกับเบราว์เซอร์ — fetch() ใน Node คืน body เป็น Web Stream · แปลงไป-มาได้ด้วย Readable.fromWeb() / Readable.toWeb() (จาก node:stream) ในวันที่ต้อง interop กับ fetch หรือ runtime อื่น (Deno/Bun/Edge)


5. pipe และ backpressure

🚀 โซนขั้นสูง — ข้ามได้รอบแรก ส่วนนี้ลงลึกเรื่องการต่อ stream + จัดการจังหวะข้อมูล (backpressure) — ถ้ายังไม่ได้ทำงานกับไฟล์ใหญ่/วิดีโอ จำแค่ "ต่อ stream ใช้ pipeline" (ตัวอย่างท้ายหัวข้อ) ก็พอ แล้วค่อยกลับมาอ่านรายละเอียดตอนต้องใช้จริง

วิธีพื้นฐาน (เห็นภาพ แต่อย่าทำจริง)

javascript
import { createReadStream, createWriteStream } from "node:fs";

const src = createReadStream("big.log");     // Readable
const dst = createWriteStream("copy.log");   // Writable

// ฟัง event 'data' ทุกครั้งที่มีชิ้นใหม่ แล้วเขียนลงปลายทาง
src.on("data", (chunk) => {
  dst.write(chunk);          // ⚠️ ปัญหา: dst.write() คืน false เมื่อ buffer ภายในเต็ม
                             //    แต่โค้ดนี้ไม่ได้สนค่า return → ถ้าอ่านเร็วกว่าเขียน chunk จะกองใน RAM!
                             //    วิธีถูก = เช็ก return false แล้ว src.pause() รอ event 'drain' ของ dst → src.resume()
                             //    (ยุ่ง — ใช้ pipeline ดีกว่า ดูตัวอย่างถัดไป)
});
src.on("end", () => dst.end());

ปัญหาคือ backpressure: ถ้า "ฝั่งอ่าน" (เช่น disk SSD เร็ว) ป้อนข้อมูลเร็วกว่า "ฝั่งเขียน" (เช่น network ช้า) รับไหว — chunk ที่เขียนไม่ทันจะกองสะสมใน memory จนระเบิด เหมือนเทน้ำเข้าถังที่รูระบายเล็กกว่า — น้ำล้นออกมา

pipe — จัดการ backpressure ให้อัตโนมัติ (แต่ error handling ยัง bad)

javascript
// pipe เชื่อม Readable → Writable และ "ปรับจังหวะ" ให้เอง:
// ถ้าปลายทางเขียนไม่ทัน มันจะ "หยุดอ่าน" ต้นทางชั่วคราว แล้วค่อยอ่านต่อ
src.pipe(dst);

ปัญหาของ .pipe():

  1. error ไม่ propagate — ถ้า src (Readable) error ออกมา dst จะไม่ถูกปิด — file descriptor (เลขอ้างอิงที่ระบบปฏิบัติการใช้ติดตามไฟล์ที่กำลังเปิดอยู่) รั่ว ใช้นาน ๆ ระบบหมด FD
  2. ต้องลงทะเบียน on("error") ทุกตัวเอง — ถ้าลืมตัวใดตัวหนึ่ง error จะทำ process crash (เพราะ event "error" ไม่มีคนฟัง — บทที่ 3)
  3. ต่อหลายตัวซ้อน ๆ (a.pipe(b).pipe(c)) → cleanup ยิ่งซับซ้อน

pipeline — วิธีที่ถูกต้องสำหรับ production (จัดการ error + cleanup ครบ)

pipeline คือ helper ที่ปิดทุก stream ให้เรียบร้อยและรวม error มาที่เดียว — ใช้ตัวนี้แทน .pipe() เสมอใน production:

javascript
import { pipeline } from "node:stream/promises";   // เวอร์ชัน Promise
import { createReadStream, createWriteStream } from "node:fs";
import { createGzip } from "node:zlib";

// คัดลอกไฟล์พร้อมบีบอัด gzip — ต่อ stream เป็นทอด ๆ:
//   อ่านไฟล์ (Readable) → บีบอัด (Transform) → เขียนไฟล์ .gz (Writable)
try {
  await pipeline(
    createReadStream("big.log"),
    createGzip(),                          // Transform: บีบอัดทีละ chunk
    createWriteStream("big.log.gz"),
  );
  console.log("✅ บีบอัดเสร็จ — ใช้ RAM น้อยมากแม้ไฟล์ใหญ่");
} catch (err) {
  console.error("pipeline พัง:", err);     // จับ error ของทุก stream ที่เดียว
}

💡 กฎ: เชื่อม stream ใน production ใช้ pipeline จาก node:stream/promises เสมอ — ไม่ใช่ .pipe() เพราะ pipeline จัดการ cleanup + error ให้ครบ

อ่านไฟล์ทีละบรรทัด (ไฟล์ log ใหญ่)

javascript
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";

const input = createReadStream("huge.log");
const rl = createInterface({
  input,
  crlfDelay: Infinity,          // จัดการ \r\n ของ Windows ให้ถูก
});

let count = 0;
try {
  // for await...of อ่านทีละบรรทัด — ใช้ RAM น้อยแม้ไฟล์ GB
  for await (const line of rl) {
    if (line.includes("ERROR")) count++;
    // ⚠️ ระวัง: ถ้า break/throw กลางทาง file descriptor อาจไม่ถูกปิด — finally ปิดให้ชัวร์
  }
} finally {
  rl.close();
  input.destroy();   // ปิด underlying stream ป้องกัน fd leak
  // 💡 ถ้า break กลาง loop แล้วเรียก destroy() ตรงนี้ stream อาจยิง event 'close'/'error'
  //    ออกมา (เพราะปิดกลางที่ยังอ่านไม่จบ) — ถ้าจะทดลอง break เอง ให้ครอบทั้งบล็อกนี้
  //    ด้วย try/catch อีกชั้น เพื่อกลืน error จากการ teardown ที่คาดไว้แล้ว
}
console.log(`เจอ ERROR ${count} บรรทัด`);

🛠️ Checkpoint 4 — ลงมือก่อนไปบทถัดไป

  1. เขียนสคริปต์อ่าน package.json ของโปรเจกต์ตัวเอง → JSON.parse → พิมพ์ name กับจำนวน dependencies
  2. เขียน logger ง่าย ๆ ที่ appendFile ข้อความพร้อม timestamp ลง app.log ทุกครั้งที่เรียก
  3. เขียนฟังก์ชันที่อ่านไฟล์แล้วจับกรณี ENOENT คืนค่า default แทนการ crash
  4. สร้างไฟล์ข้อความใหญ่ ๆ (เช่นวน appendFile 1 ล้านบรรทัด) แล้วเขียน 2 เวอร์ชันนับจำนวนบรรทัด: (ก) readFile ทั้งก้อนแล้ว split("\n") (ข) อ่านทีละบรรทัดด้วย readline — เทียบ process.memoryUsage().heapUsed ของทั้งสอง
  5. ใช้ pipeline คัดลอกไฟล์พร้อม gzip แล้วเช็กขนาดไฟล์ .gz ที่ได้เทียบกับต้นฉบับด้วย stat
  6. แปลงข้อความไทยเป็น base64 ด้วย Buffer แล้วแปลงกลับ — ลองเทียบ "สวัสดี".length กับ Buffer.byteLength("สวัสดี")

เฉลยข้อ 3:

javascript
async function readOrDefault(path, fallback) {
  try {
    return await readFile(path, "utf8");
  } catch (err) {
    if (err.code === "ENOENT") return fallback;
    throw err;
  }
}

สรุปบทที่ 4

  • fs มี 3 แบบ: fs/promises (default), callback, *Sync (เฉพาะ startup — บล็อก event loop)
  • เช็กไฟล์มีไหม: ลองอ่านแล้วจับ err.code === "ENOENT" ไม่ใช่ existsSync ก่อน (race condition)
  • Buffer = ข้อมูล binary ดิบ — แปลงไป/มา string ด้วย encoding (utf8/base64/hex) · อักษรไทย 1 ตัว = หลาย byte
  • Stream = ประมวลผลข้อมูลทีละชิ้น → จัดการไฟล์ใหญ่/network โดยไม่ระเบิด RAM · มี 4 ชนิด (Readable/Writable/Duplex/Transform) ทุกตัวเป็น EventEmitter
  • backpressure = ปัญหาฝั่งผลิตเร็วกว่าฝั่งบริโภค → ใช้ pipeline (จาก node:stream/promises) ที่จัดการจังหวะ + error + cleanup ให้ครบ
  • อ่านไฟล์ทีละบรรทัดด้วย readline + for await

บทต่อไปเอา stream + async มาประกอบเป็นของจริง — สร้าง HTTP server จาก http เปล่า ๆ แล้วจะเข้าใจว่าทำไมโลกถึงสร้าง Express/Fastify/Nest ขึ้นมา

→ บทที่ 5: HTTP + Web Server