โหมดมืด
บทที่ 4 — Filesystem + Streams: อ่านไฟล์ 10GB โดยไม่ระเบิด RAM
← บทที่ 3: Async + Events | สารบัญ | บทที่ 5: HTTP + Web Server →
ความสามารถที่เบราว์เซอร์ไม่มีแต่ Node มี: อ่าน/เขียนไฟล์บนเครื่อง บทนี้ครอบคลุม:
fs3 รูปแบบ — sync / callback / promises — ใช้ตัวไหนเมื่อไหร่- งานไฟล์ที่ใช้จริง — อ่าน เขียน ต่อท้าย สร้างโฟลเดอร์ ลิสต์ เช็กว่ามีไหม
Buffer— ข้อมูล binary คืออะไร ทำไมต้องรู้- Streams — ทำไม
readFileไฟล์ 10GB ถึงทำให้ server ตาย และ stream แก้ยังไง - 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 |
| callback | library เก่าที่ยังไม่รองรับ Promise | จัดการ error ยุ่งกว่า |
*Sync | script 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-levelawaitใน 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():
- error ไม่ propagate — ถ้า
src(Readable) error ออกมาdstจะไม่ถูกปิด — file descriptor (เลขอ้างอิงที่ระบบปฏิบัติการใช้ติดตามไฟล์ที่กำลังเปิดอยู่) รั่ว ใช้นาน ๆ ระบบหมด FD - ต้องลงทะเบียน
on("error")ทุกตัวเอง — ถ้าลืมตัวใดตัวหนึ่ง error จะทำ process crash (เพราะ event "error" ไม่มีคนฟัง — บทที่ 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 — ลงมือก่อนไปบทถัดไป
- เขียนสคริปต์อ่าน
package.jsonของโปรเจกต์ตัวเอง →JSON.parse→ พิมพ์nameกับจำนวน dependencies - เขียน logger ง่าย ๆ ที่
appendFileข้อความพร้อม timestamp ลงapp.logทุกครั้งที่เรียก - เขียนฟังก์ชันที่อ่านไฟล์แล้วจับกรณี
ENOENTคืนค่า default แทนการ crash - สร้างไฟล์ข้อความใหญ่ ๆ (เช่นวน
appendFile1 ล้านบรรทัด) แล้วเขียน 2 เวอร์ชันนับจำนวนบรรทัด: (ก)readFileทั้งก้อนแล้วsplit("\n")(ข) อ่านทีละบรรทัดด้วยreadline— เทียบprocess.memoryUsage().heapUsedของทั้งสอง - ใช้
pipelineคัดลอกไฟล์พร้อม gzip แล้วเช็กขนาดไฟล์.gzที่ได้เทียบกับต้นฉบับด้วยstat - แปลงข้อความไทยเป็น base64 ด้วย
Bufferแล้วแปลงกลับ — ลองเทียบ"สวัสดี".lengthกับBuffer.byteLength("สวัสดี")
เฉลยข้อ 3:
javascriptasync 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 ขึ้นมา