ข้ามไปยังเนื้อหา

Streams & I/O

ลองนึกภาพการอ่านไฟล์ log ขนาด 4 GB ถ้าคุณโหลดมันทั้งหมดเข้า memory ก่อน Node ต้องจอง 4 GB ก่อนที่คุณจะดูบรรทัดเดียวได้ด้วยซ้ำ บนเครื่องที่มี RAM 2 GB process จะ crash ก่อนที่มันจะเริ่มด้วยซ้ำ

Stream แก้ปัญหานี้ด้วยการประมวลผลข้อมูล เป็น chunk — คุณเริ่มอ่าน chunk แรกในขณะที่ส่วนที่เหลือยังมาไม่ถึงจากดิสก์หรือ network memory จะคงที่ไม่ว่าต้นทางจะมีขนาดใหญ่แค่ไหน

แนวคิดเดียวกันนี้ใช้กับการเขียนด้วย: แทนที่จะ buffer response ทั้งหมดใน memory แล้วส่งทีเดียว stream จะส่งแต่ละ chunk ไปยังปลายทางทันทีที่มันมาถึง

Node.js กำหนด abstract stream สี่ประเภทไว้ใน module node:stream:

ประเภททิศทางตัวอย่าง
Readableข้อมูลไหล ออกfs.createReadStream, process.stdin
Writableข้อมูลไหล เข้าfs.createWriteStream, process.stdout
DuplexสองทิศทางTCP socket (net.Socket)
Transformสองทิศทางพร้อมการแปลงข้อมูลzlib.createGzip (บีบอัดระหว่างทาง)

ทุก stream ก็เป็น EventEmitter ด้วย stream base class จะ emit event มาตรฐาน (data, end, error, drain, finish) ที่คุณผูก handler เข้าไปได้

บทเรียนหัวข้อ
Readable Streamsevent data/end/error, Readable.from, async iteration
Writable Streamswrite(), end() และ event drain
Pipe & Pipelineการเชื่อม stream ด้วย .pipe() และ stream.pipeline()
Backpressureมันคืออะไร ทำไมสำคัญ และ pipeline จัดการมันอย่างไร

demo ด้านล่างใช้ Readable.from เพื่อสร้าง readable stream จาก array ของ string แล้วบริโภคมันทีละ chunk ด้วย for await สังเกตว่าไม่มี array เต็ม ๆ ถูกเก็บไว้ใน buffer เลย — แต่ละ item ถูก yield ตามความต้องการ

Node.js
Output (verified on Node 24)
chunk: "hello"
chunk: " "
chunk: "streaming"
chunk: " "
chunk: "world"
assembled: hello streaming world
flowchart LR
  Readable["Readable — data flows OUT"] -->|".pipe() / pipeline()"| Writable["Writable — data flows IN"]
  Readable -->|"through a"| Transform["Transform — reshapes each chunk"]
  Transform --> Writable
  Duplex["Duplex — both directions independently"]
  All["Readable, Writable, Duplex, Transform"] -->|"all extend"| EE["EventEmitter"]
ข้อมูลไหลจาก Readable ผ่าน Transform (ถ้ามี) เข้าสู่ Writable — stream ทุกประเภทก็เป็น EventEmitter ด้วย
ทางเลือกข้อดีต้นทุน
Streaming (แบบ chunk)การใช้ memory คงที่ไม่ว่าข้อมูลทั้งหมดจะมีขนาดเท่าไหร่; เริ่มประมวลผลได้ก่อนที่ต้นทางจะเสร็จมีชิ้นส่วนที่ต้องจัดการมากกว่าการเรียก readFile() ครั้งเดียว — event หรือ async iteration, backpressure, การส่งต่อ error
โหลดทุกอย่างเข้า memory ก่อนcode ง่ายที่สุดเท่าที่จะทำได้ — ไม่ต้อง wire event ผลลัพธ์ทั้งหมดพร้อมใช้ทันทีpeak memory scale ตามขนาด input; input ที่ใหญ่พอจะทำให้ process crash ตรง ๆ
  • “Stream เร็วกว่าการอ่านทั้งไฟล์เสมอ” — ความจริง: stream ประหยัด memory ไม่จำเป็นต้องเร็วกว่าในแง่ wall-clock สำหรับไฟล์ที่พอดีกับ RAM readFile() อาจง่ายกว่าและเร็วพอ ๆ กัน
  • “stream ทุกตัวต้องมี data event listener เพื่อให้ output ออกมา” — ความจริง: for await...of (async iteration) บริโภค Readable ได้โดยไม่แตะ event data เลย และจัดการ backpressure ให้อัตโนมัติไปด้วย
  • “Transform stream กับ Duplex stream คือสิ่งเดียวกัน” — ความจริง: ทั้งคู่เป็นสองทิศทาง แต่ output ของ Transform ได้มาจาก input ของมัน (ฝั่งหนึ่งป้อนอีกฝั่ง); ฝั่ง readable และ writable ของ Duplex เป็นอิสระต่อกัน — TCP socket อ่านและเขียนข้อมูลที่ไม่เกี่ยวข้องกันในแต่ละฝั่ง
  • code เรียก fs.readFileSync() หรือ fs.readFile() เพื่อโหลดไฟล์แค่จะเขียนมันกลับออกไปหรือส่งต่อไปที่อื่นทันทีหรือเปล่า ทั้งที่ stream (หรือ pipeline) จะไม่เก็บทั้งไฟล์ไว้ใน memory เลย?
  • snippet ผูก data listener กับ Readable โดยไม่เคยตรวจ backpressure ปลายทางเลย สมมติว่าพฤติกรรม pause/resume อัตโนมัติของ .pipe() จะเกิดขึ้นทั้งที่ไม่ได้เรียก .pipe() จริง ๆ หรือเปล่า?
  • code ปฏิบัติต่อ Transform และ Duplex ราวกับใช้แทนกันได้ — extend Duplex ทั้งที่เจตนาจริงคือ “รับ X เข้ามา สร้าง Y ที่ได้มาออกไป” (ซึ่งคือ Transform) หรือเปล่า?

🐛 หา bug ในโค้ดนี้

const fs = require('node:fs');
// Loads the whole file into memory just to copy it elsewhere
var data = fs.readFileSync('./huge-input.log');
fs.writeFileSync('./huge-output.log', data);
เฉลย

โค้ดนี้อ่านทั้งไฟล์เข้า Buffer เดียวใน memory ก่อนที่จะเขียนมันกลับออกไป ทั้งที่ไม่มีอะไรตรงนี้ต้องตรวจสอบหรือแปลงเนื้อหาเลย — สำหรับไฟล์ log ขนาดหลายกิกะไบต์ นี่อาจทำให้ memory ที่มีอยู่หมดหรือแค่เปลืองเปล่า ๆ fs.createReadStream('./huge-input.log').pipe(fs.createWriteStream('./huge-output.log')) (หรือ pipeline ของ stream/promises ที่ครอบคลุมทีหลังในโมดูลนี้) จะ copy ไฟล์เป็น chunk ที่มีขอบเขตโดยไม่เคยถือมากกว่าหนึ่ง chunk ใน memory เลย

ทำไมจึงนิยม stream มากกว่าการโหลดทั้งไฟล์เข้า memory?
stream ประเภทใดของ Node.js ที่แปลงข้อมูลขณะที่มันผ่าน?
stream ทั้งสี่ประเภทของ Node.js extend base class ใด?
โค้ดนี้พิมพ์อะไร? const { Readable } = require("node:stream"); var s = Readable.from(["a", "b"]); console.log(typeof s.on);
function หนึ่งอ่านไฟล์ขนาด 2 GB ด้วย fs.readFileSync() แล้วเขียนไปยัง path อื่นทันทีด้วย fs.writeFileSync() ความเสี่ยงหลักคืออะไร?