โหมดมืด
บทที่ 3 — Async + Events: หัวใจที่ทำให้ Node เป็น Node
← บทที่ 2: Core Modules + process | สารบัญ | บทที่ 4: Filesystem + Streams →
บทนี้คือบทที่สำคัญที่สุดของเล่ม ถ้าเข้าใจ async ใน Node ลึกจริง ที่เหลือทั้งเล่มจะไหลลื่น เราจะไล่จาก:
- 3 ยุคของ async — callback → Promise → async/await (และ error-first callback ที่เป็นเอกลักษณ์ของ Node)
promisify— แปลง API เก่าแบบ callback ให้ทันสมัยEventEmitter— รูปแบบ async อีกแบบที่ Node ใช้ทั่ว (server, stream ก็เป็น EventEmitter)- timers + event loop เชิงลึก —
setTimeoutvssetImmediatevsprocess.nextTickvs Promise ทำงานลำดับไหน AbortController— ยกเลิกงาน async (timeout, cancel)
📋 บทนี้ต่อยอดจากภาค JavaScript บทที่ 9 (Async Deep) ของหนังสือชุดเดียวกัน — ถ้าเข้าหนังสือเล่มนี้ตรง ๆ และยังไม่แม่น Promise/async-await แนะนำให้กลับไปอ่านภาค JavaScript ก่อน บทนี้จะพูดเฉพาะมุมที่เป็น Node
1. สามยุคของ async ใน Node
Node เกิดมาพร้อมปรัชญา "ไม่นั่งรอ I/O" (บทที่ 0) — แต่วิธีเขียนโค้ด async วิวัฒนาการมา 3 ยุค คุณจะเจอทั้งสามในโค้ดจริง:
ยุค 1: Callback + error-first convention
ยุคแรก Node ใช้ callback — ส่งฟังก์ชันเข้าไป "ให้เรียกตอนงานเสร็จ" และมีกติกาเฉพาะของ Node ที่เรียกว่า error-first callback: argument ตัวแรกของ callback คือ error เสมอ (ถ้าไม่มี error ก็เป็น null):
javascript
import { readFile } from "node:fs";
// error-first callback: (err, data) => ...
// - ถ้าพัง → err = Error object, data = undefined
// - ถ้าสำเร็จ → err = null, data = ข้อมูล
readFile("config.json", "utf8", (err, data) => {
if (err) {
// ⭐ กติกา Node: ต้องเช็ค err ก่อนเสมอ
console.error("อ่านไฟล์ไม่ได้:", err.message);
return; // อย่าลืม return ไม่งั้นโค้ดข้างล่างจะรันทั้งที่ error
}
console.log("ได้ข้อมูล:", data);
});
console.log("บรรทัดนี้รันก่อน callback ข้างบน!"); // เพราะ readFile ไม่นั่งรอปัญหา "callback hell": พองานซ้อนกันหลายชั้น (อ่านไฟล์ → query DB → เขียนไฟล์) callback ซ้อน callback จนเป็นพีระมิดเอียง อ่านยาก จัดการ error ยาก:
javascript
readFile("a.txt", "utf8", (err, a) => {
if (err) return handle(err);
readFile("b.txt", "utf8", (err, b) => { // ซ้อนชั้น 2
if (err) return handle(err);
readFile("c.txt", "utf8", (err, c) => { // ซ้อนชั้น 3... 😱
if (err) return handle(err);
console.log(a + b + c);
});
});
});ยุค 2: Promise
Promise คือ "object ที่แทนค่าที่จะมาในอนาคต" — แบนพีระมิดให้เป็นโซ่ .then():
javascript
import { readFile } from "node:fs/promises"; // ⭐ fs/promises = เวอร์ชัน Promise
readFile("config.json", "utf8")
.then((data) => console.log("ได้ข้อมูล:", data))
.catch((err) => console.error("พัง:", err.message)); // catch จับ error ของทั้งโซ่ยุค 3: async/await (ใช้ตัวนี้เป็นหลัก)
async/await คือ syntax ที่ทำให้โค้ด async อ่านเหมือนโค้ดธรรมดา (เรียงบนลงล่าง) แต่เบื้องหลังยังเป็น Promise:
javascript
import { readFile } from "node:fs/promises";
async function loadConfig() {
try {
// await = "รอ Promise นี้เสร็จ แล้วเอาค่าออกมา" — แต่ไม่บล็อก event loop
const data = await readFile("config.json", "utf8");
console.log("ได้ข้อมูล:", data);
} catch (err) {
// try/catch จับ error ของ await ได้เหมือนโค้ด sync ปกติ
console.error("พัง:", err.message);
}
}
await loadConfig();callback hell เมื่อกี้ กลายเป็นโค้ดเรียงสวย:
javascript
const a = await readFile("a.txt", "utf8");
const b = await readFile("b.txt", "utf8");
const c = await readFile("c.txt", "utf8");
console.log(a + b + c);💡 กฎ: โปรเจกต์ใหม่ใช้ async/await +
fs/promisesเป็นหลัก · เจอ callback API เก่า ๆ ให้แปลงด้วยpromisify(ข้อ 2)
ดักจุดพลาด: await ทีละตัว vs พร้อมกัน
javascript
// ❌ ช้า: 3 งานที่ไม่เกี่ยวกัน แต่ await ทีละตัว → รวม 300ms
const u = await fetchUser(); // 100ms
const p = await fetchPosts(); // 100ms (เริ่มหลัง user เสร็จ)
const c = await fetchComments(); // 100ms
// ✅ เร็ว: ยิงพร้อมกันด้วย Promise.all → รวม ~100ms (รอตัวที่ช้าสุด)
const [u, p, c] = await Promise.all([fetchUser(), fetchPosts(), fetchComments()]);⚠️ Pitfall:
awaitในforloop ทำให้งานเรียงต่อกัน (ช้า) — ถ้างานแต่ละรอบไม่ขึ้นต่อกัน ใช้Promise.all(items.map(...))แทน · แต่ระวัง: ถ้ายิงพร้อมกันเป็นพัน ๆ จะถล่ม DB/API — กรณีนั้นต้องจำกัด concurrency (จำนวนงานที่ยิงพร้อมกัน) เช่นใช้ library ชื่อp-limitจาก npm:javascriptimport pLimit from "p-limit"; const limit = pLimit(5); // ยอมให้ยิงพร้อมกันได้สูงสุด 5 ตัว // map คืน Promise[] ที่ p-limit คุม concurrency ให้ — ตัวที่ 6 ต้องรอตัวก่อนหน้าเสร็จ const results = await Promise.all( items.map((it) => limit(() => fetchOne(it))), );
Promise combinator อื่น ๆ — เลือกตามสถานการณ์
นอกจาก Promise.all ยังมีอีก 3 ตัวที่ backend ใช้บ่อย:
javascript
// 1) Promise.race — ตัวแรกที่ "settle" (settle = จบ ไม่ว่าจะสำเร็จหรือพัง) ชนะ → ใช้ทำ timeout pattern
const result = await Promise.race([
fetch("/api/data"),
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 3000)),
]);
// ถ้า fetch ช้าเกิน 3 วิ → throw timeout
// 2) Promise.any — ตัวแรกที่ "สำเร็จ" ชนะ (ignore reject) → fallback หลายแหล่ง
const data = await Promise.any([
fetchFromPrimary(),
fetchFromMirror1(),
fetchFromMirror2(),
]);
// เอาที่สำเร็จก่อน; ถ้าทุกตัว reject → throw AggregateError
// (AggregateError = error ก้อนเดียวที่รวม error ของทุกตัวไว้ใน property `.errors` ซึ่งเป็น array)
// 3) Promise.allSettled — รอทุกตัวจบ (ไม่ throw แม้บางตัวพัง) → batch ที่ยอมให้บางตัว fail
const results = await Promise.allSettled([
sendNotification(user1),
sendNotification(user2),
sendNotification(user3),
]);
for (const r of results) {
if (r.status === "rejected") console.error("ส่งพัง:", r.reason);
}| Combinator | จบเมื่อ | ทิ้ง error ที่เหลือ? | ใช้กับ |
|---|---|---|---|
Promise.all | ทุกตัวสำเร็จ หรือมีตัวแรก reject | ใช่ (reject แรก) | งานที่ "ทุกตัวต้องสำเร็จ" |
Promise.race | ตัวแรก settle | ใช่ | timeout, cancellation |
Promise.any | ตัวแรกสำเร็จ | สะสมใน AggregateError | fallback/mirror |
Promise.allSettled | ทุกตัว settle | เก็บใน array | batch job ที่ partial-fail OK |
2. promisify — แปลง callback API เก่าให้เป็น Promise
library/core module เก่าบางตัวยังเป็น callback แบบ error-first — แปลงให้เป็น Promise ด้วย util.promisify:
javascript
import { promisify } from "node:util";
import { readFile } from "node:fs"; // เวอร์ชัน callback
// promisify ใช้ได้กับ function ที่ตามกติกา error-first callback
const readFileP = promisify(readFile);
const data = await readFileP("config.json", "utf8"); // ใช้ await ได้แล้ว💡 หลาย core module มีเวอร์ชัน Promise ให้อยู่แล้ว (
node:fs/promises,node:dns/promises,node:timers/promises) — ใช้ของที่มีก่อน ค่อยpromisifyเมื่อไม่มีให้
javascript
// ตัวอย่างมีประโยชน์: "sleep" แบบ async ด้วย timers/promises
import { setTimeout as sleep } from "node:timers/promises";
console.log("เริ่ม");
await sleep(1000); // รอ 1 วินาทีแบบไม่บล็อก (คืน Promise)
console.log("ผ่านไป 1 วิ");3. EventEmitter — รูปแบบ async อีกแบบที่อยู่ทั่ว Node
callback/Promise เหมาะกับงานที่ "เกิดครั้งเดียวแล้วจบ" (อ่านไฟล์ → ได้ผล) แต่บางอย่าง "เกิดซ้ำ ๆ หลายครั้ง" (request เข้ามาเรื่อย ๆ, ข้อมูลไหลมาเป็นชิ้น ๆ) — Node ใช้ EventEmitter: รูปแบบ "ประกาศข่าว/ฟังข่าว" (publish/subscribe)
แนวคิด: object หนึ่ง emit (ป่าวประกาศ) event เมื่อมีอะไรเกิดขึ้น — ใครสนใจก็ on (ลงทะเบียนฟัง) ไว้ เหมือน addEventListener ในเบราว์เซอร์
javascript
import { EventEmitter } from "node:events";
// สร้าง emitter (หรือ extends class ของเราจาก EventEmitter)
const orders = new EventEmitter();
// on() = ลงทะเบียนฟัง event ชื่อ "newOrder" — เรียกทุกครั้งที่มี emit
orders.on("newOrder", (order) => {
console.log(`ออเดอร์ใหม่ #${order.id} ราคา ${order.total}`);
});
orders.on("newOrder", (order) => {
sendEmailReceipt(order); // (sendEmailReceipt = function ที่คุณนิยามเอง) — ฟังหลายคนได้ ทุกคนได้รับ event
});
// emit() = ป่าวประกาศ event พร้อมส่งข้อมูล → ทุก listener ถูกเรียก (ตามลำดับที่ลงทะเบียน)
orders.emit("newOrder", { id: 1, total: 250 });
// once() = ฟังแค่ครั้งเดียวแล้วเลิกฟังอัตโนมัติ
orders.once("ready", () => console.log("พร้อมแล้ว (เรียกครั้งเดียว)"));ทำไมต้องรู้: ของหลายอย่างใน Node คือ EventEmitter — HTTP server (server.on("request", ...)), stream (stream.on("data", ...) — บทที่ 4), process (process.on("SIGTERM", ...)) เมื่อเข้าใจ pattern นี้ คุณจะอ่านโค้ด Node ออกทันที
กติกาสำคัญ: event ชื่อ "error" พิเศษ
javascript
const emitter = new EventEmitter();
// ⭐ ต้องมี listener สำหรับ "error" เสมอ
emitter.on("error", (err) => console.error("จัดการ error:", err.message));
emitter.emit("error", new Error("อะไรพังสักอย่าง"));⚠️ Pitfall ร้ายแรง: ถ้า EventEmitter
emit("error", ...)แต่ไม่มีใครon("error")Node จะ throw แล้ว crash ทั้งโปรแกรมทันที — นี่เป็น default ที่ตั้งใจให้ error ไม่เงียบหาย ต้องลงทะเบียนon("error")กับ emitter/stream ทุกตัวที่อาจ error
Pattern modern: events.once() กับ AbortSignal + ระวัง MaxListeners
นอกจาก .on() / .once() แบบ method มี helper events.once(emitter, eventName) ที่คืน Promise — เขียน flow async ได้สวยกว่า และยกเลิกได้ด้วย AbortSignal:
javascript
import { once, EventEmitter } from "node:events";
const ee = new EventEmitter();
setTimeout(() => ee.emit("ready", { ok: true }), 100);
// รอ event "ready" ครั้งเดียวแบบ await (คืน [args...] ที่ emit มา)
const [payload] = await once(ee, "ready");
console.log(payload.ok); // true
// ยกเลิกการรอด้วย AbortSignal — ใช้ตอน timeout/cancel
const ac = new AbortController();
setTimeout(() => ac.abort(), 1000);
try {
await once(ee, "neverComes", { signal: ac.signal });
} catch (err) {
if (err.name === "AbortError") console.log("เลิกรอแล้ว");
}⚠️ Pitfall — MaxListeners: ถ้าคุณ
.on()event เดียวกันเกิน 10 ตัว Node จะเตือนใน console ว่าPossible EventEmitter memory leak detected(default = 10) — มักเกิดตอนลงทะเบียน listener ใน loop โดยลืม.off()· ถ้าตั้งใจฟังเยอะจริง ใช้ee.setMaxListeners(50)หรือevents.setMaxListeners(50, ee)(helper จากnode:events)
4. Event loop เชิงลึก — ลำดับการทำงานที่ทำให้คนงง
🚀 โซนขั้นสูง — ข้ามได้ หัวข้อนี้ลงรายละเอียดลำดับการทำงานของ event loop (เป็นคำถามสัมภาษณ์ยอดฮิต) ถ้าเพิ่งเริ่ม รู้แค่ "async/await ใช้ยังไง" ก็เขียนงานได้แล้ว — กลับมาอ่านตรงนี้ตอนเจอบั๊กลำดับการทำงานแปลก ๆ ก็ทัน
บทที่ 0 อธิบายภาพรวมว่า event loop "หยิบงานที่พร้อมมาทำ" — บทนี้เจาะว่า "หยิบลำดับไหนก่อน" เพราะมันเป็นคำถามสัมภาษณ์ยอดฮิตและทำให้เกิดบั๊กแปลก ๆ
ภาพ: event loop มีหลาย "เฟส" วนเป็นรอบ
กฎทองที่ต้องจำ
ก่อนอื่นเข้าใจ 2 คำนี้ก่อน — งาน async ใน Node แบ่งเป็น 2 ชั้นความด่วน:
- microtask = "งานด่วนแทรกคิว" (เช่น Promise,
process.nextTick) — Node ทำให้หมดก่อนเสมอ เหมือนงานที่ต้องเคลียร์ก่อนทำอย่างอื่น - macrotask = "งานปกติรอรอบถัดไป" (เช่น
setTimeout,setImmediate) — รอให้ microtask หมดก่อน แล้วค่อยถึงคิวรอบถัดไป
💡 (micro = เล็ก/ด่วน · macro = ใหญ่/ปกติ — micro มาก่อน macro เสมอ)
- microtask ทำก่อน macrotask เสมอ — ทุกครั้งที่ callback หนึ่งจบ Node จะเคลียร์ microtask ทั้งหมดก่อนไปต่อ
- ภายใน microtask:
process.nextTickมาก่อน Promise
ดูตัวอย่างคลาสสิก — ทายผลก่อนดูเฉลย:
javascript
console.log("1: sync");
setTimeout(() => console.log("2: setTimeout"), 0); // macrotask (timers)
setImmediate(() => console.log("3: setImmediate")); // macrotask (check)
Promise.resolve().then(() => console.log("4: promise")); // microtask
process.nextTick(() => console.log("5: nextTick")); // microtask (สูงสุด)
console.log("6: sync");ผลลัพธ์:
text
1: sync ← โค้ด sync รันก่อนตามลำดับ
6: sync
5: nextTick ← microtask: nextTick มาก่อน
4: promise ← microtask: promise
2: setTimeout ← macrotask (timers/check สลับกันได้ขึ้นกับจังหวะ)
3: setImmediateอ่านผลนี้: โค้ด sync ทั้งหมดรันจนจบก่อน → แล้วเคลียร์ microtask (nextTick ก่อน promise) → แล้วค่อยถึง macrotask (timer/immediate)
⚠️ Pitfall:
process.nextTickที่เรียกตัวเองวน ๆ จะ ทำให้ event loop ไม่มีวันเดินไปเฟสอื่น (microtask ไม่หมดสักที) → I/O และ timer ค้างหมด — ใช้nextTickอย่างระวัง ส่วนใหญ่ไม่จำเป็นต้องใช้
setTimeout vs setImmediate — ใช้ตัวไหน
setTimeout(fn, 0)— "ทำในรอบ event loop ถัดไป เร็วที่สุดเท่าที่ timer อนุญาต" (เฟส timers)setImmediate(fn)— "ทำหลังเฟส poll (I/O) ของรอบนี้จบ" (เฟส check)
ลำดับขึ้นกับว่าใครเรียก — ต้องแยก 2 บริบท:
1. เรียกจาก main script (script entry) — ลำดับ ไม่แน่นอน — ขึ้นกับว่า event loop เข้ารอบใหม่ตอน timers phase พร้อมหรือยัง:
javascript
// อาจได้ "timeout" ก่อน หรือ "immediate" ก่อน — สลับได้
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));2. เรียกจาก I/O callback — setImmediate มา ก่อน setTimeout(fn, 0) เสมอ — เพราะ I/O callback อยู่ที่เฟส poll → ถัดไปคือ check (setImmediate) → แล้วค่อยวนกลับ timers รอบใหม่:
javascript
import { readFile } from "node:fs";
// ⭐ ใช้ path เป็น string ตรง ๆ (ไม่ใช้ __filename เพราะเป็น global ของ CommonJS
// ไม่มีใน ESM ที่หนังสือเล่มนี้ใช้ — จะได้ ReferenceError ถ้าใส่ __filename ในไฟล์ ESM)
readFile("package.json", () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
});
// → "immediate" ก่อน "timeout" เสมอ (เพราะอยู่ใน I/O callback)Rule of thumb:
- ต้องการ "ทำหลัง I/O ปัจจุบันเสร็จในรอบเดียวกัน" →
setImmediate - ต้องการหน่วงเวลาจริง (≥ 1ms) →
setTimeout(fn, ms) - ที่ระดับ main script ทั้งคู่ใช้ผลใกล้กัน — เลือกตัวที่ intent ชัดกว่า
queueMicrotask — สั่งงานเข้า microtask queue ตรง ๆ
queueMicrotask(fn) (Node 11+, web standard) คือ primitive ที่ schedule callback เข้า microtask queue โดยไม่ต้องสร้าง Promise:
javascript
queueMicrotask(() => console.log("microtask"));
Promise.resolve().then(() => console.log("promise")); // เทียบเท่า — แต่ allocate Promise ทิ้งใช้เมื่อ:
- เขียน library ที่ต้องการ "เลื่อนงานไปท้าย microtask phase" โดยไม่อยากสร้าง Promise object ทิ้ง (perf-sensitive)
- ต้องการ semantic ชัดเจนว่า "นี่คือ microtask" ไม่ต้องเดาว่าใช้ Promise ทำไม
ลำดับ: process.nextTick > queueMicrotask ≈ Promise.then > macrotask
💡
process.nextTickเป็น API เฉพาะของ Node (ไม่มีในเบราว์เซอร์) — โค้ดสมัยใหม่แทบไม่ต้องใช้ ส่วนใหญ่queueMicrotaskหรือPromise.resolve().then(...)พอ · เก็บnextTickไว้สำหรับเขียน core library ที่ต้องการ "แทรกก่อน Promise" จริง ๆ
5. AbortController — ยกเลิกงาน async (timeout/cancel)
งาน async บางอย่างต้อง "ยกเลิกได้" — เช่น request ที่ช้าเกินไป (timeout) หรือ user กดยกเลิก Node ใช้ AbortController / AbortSignal (มาตรฐานเดียวกับเบราว์เซอร์) เป็นรีโมตปุ่มยกเลิก
ทางลัดสำหรับ timeout: AbortSignal.timeout
ถ้าแค่อยาก "ยกเลิกเมื่อเกิน N ms" ใช้ helper AbortSignal.timeout(ms) — สั้นที่สุดและไม่ต้องบริหาร timer เอง:
javascript
try {
const res = await fetch("https://slow-api.example.com", {
signal: AbortSignal.timeout(5000), // 5 วินาที — abort อัตโนมัติ
});
const data = await res.json();
console.log(data);
} catch (err) {
if (err.name === "TimeoutError" || err.name === "AbortError") {
console.error("⏱️ request ช้าเกิน 5 วิ — ยกเลิก");
} else {
throw err;
}
}ยกเลิกแบบ manual: AbortController เต็มรูป
ถ้าอยากกดยกเลิกเองจากที่อื่น (เช่นปุ่ม Cancel ของ user) ใช้ AbortController:
javascript
// สร้าง controller — มี signal (ตัวรับสัญญาณ) ที่ส่งให้งาน async
const controller = new AbortController();
// ยกเลิกอัตโนมัติใน 5 วินาที — .unref() บอก event loop ว่า timer นี้ไม่ต้องค้างโปรเซสไว้
// (ถ้า fetch สำเร็จก่อน เราเหลือแค่ clearTimeout — โดยไม่ต้องกลัวว่า timer จะถ่วงโปรเซสไม่ให้จบ)
const timer = setTimeout(() => controller.abort(), 5000).unref();
try {
// ส่ง signal เข้าไปกับ fetch — ถ้า abort ถูกเรียก fetch จะ throw
const res = await fetch("https://slow-api.example.com", {
signal: controller.signal,
});
const data = await res.json();
clearTimeout(timer); // สำเร็จแล้ว ยกเลิก timer
console.log(data);
} catch (err) {
if (err.name === "AbortError") {
console.error("⏱️ ยกเลิกแล้ว");
} else {
throw err;
}
}💡 core API หลายตัว (
fs,timers/promises,http, EventEmitteronce) รองรับsignalหมด — ส่งตัวเดียวคุมงานหลาย ๆ ตัวพร้อมกันได้💡 รวมหลาย signal เป็นหนึ่ง (Node 20+):
AbortSignal.any([userSignal, AbortSignal.timeout(5000)])คืน signal ที่ abort เมื่อตัวใดตัวหนึ่ง abort — เหมาะกับเคส "ยกเลิกเมื่อ user กดยกเลิก หรือเกิน 5 วิ"
6. AsyncLocalStorage — request context ที่ flow ตาม async chain
🚀 โซนขั้นสูง — ข้ามได้รอบแรก หัวข้อนี้แก้ปัญหาที่จะเจอตอนเขียน backend ของจริง (request id ใน log, ดึง user ปัจจุบันลึก ๆ) — ใช้ศัพท์อย่าง middleware, JWT, OpenTelemetry ถ้ายังไม่เคยทำ backend มาก่อน รู้แค่ว่า "Node มีของแบบนี้" แล้วข้ามไปก่อน กลับมาตอนถึงบทที่ 5 (HTTP) ก็ทัน
ในงาน backend ของจริง คุณมักต้องมี "ค่าที่ติดอยู่กับ request หนึ่ง ๆ" ที่เข้าถึงได้จากทุก function ลึก ๆ ในชั้นล่าง — เช่น:
- request id — รหัสประจำ request หนึ่ง ๆ ที่ใส่ใน log ทุกบรรทัดของ request นั้น ทำให้ตามอ่าน log ของ request เดียวกันได้ง่าย
- current user / tenant — ใครเป็นคนยิง request นี้ ปกติได้มาจากการตรวจ JWT (token ที่ฝัง user id) ที่ middleware (ฟังก์ชันที่รันก่อน handler หลัก — บทที่ 5)
- trace context ของ OpenTelemetry — มาตรฐานเก็บข้อมูล performance/tracing ที่ต้องโยง "span" (ช่วงเวลาของแต่ละชั้น) ของ request เดียวกันให้ติดกันเป็นกราฟ
ทาง naive = ส่ง parameter ลึกลงไปทุกชั้น (function foo(req, ...)) — code base โตขึ้น → ต้องไหลผ่าน 10+ function ลำบาก และ library ที่เขียนเองก็ไม่อยากผูก signature ทุกตัวกับ req
AsyncLocalStorage คือ primitive ของ Node (อยู่ใน module node:async_hooks) ที่ "เก็บค่าที่ flow ตาม async call chain อัตโนมัติ" — เปรียบเหมือน thread-local (ตัวแปรที่ผูกกับ thread ในภาษา multi-thread เช่น Java/.NET) แต่ Node เป็น single-thread + async จึงผูกกับ "async chain" แทน · ผู้อ่านที่มาจาก .NET เทียบได้กับ AsyncLocal<T>
javascript
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
// 1 instance ต่อ "ค่าที่อยากส่งต่อ" (ปกติ singleton ระดับ module — สร้างครั้งเดียวต่อโปรเจกต์)
const requestContext = new AsyncLocalStorage();
// สมมุติเรามี Express app — รายละเอียดบทที่ 5 ตอนนี้รู้แค่ว่า app.use() = ลงทะเบียน middleware
// import express from "express";
// const app = express();
// ที่ middleware (HTTP entry) — ห่อ handler ทั้ง request ใน store
app.use((req, res, next) => {
const ctx = {
requestId: req.headers["x-request-id"] ?? randomUUID(),
userId: req.user?.id, // ถ้ามี auth middleware ก่อนหน้านี้ จะ set req.user ไว้แล้ว
};
requestContext.run(ctx, () => next()); // ⭐ ทุกอย่างที่ next() เรียกต่อ จะเห็น ctx นี้
});
// ที่ logger (ลึก 5 ชั้นจาก middleware)
function log(msg) {
const ctx = requestContext.getStore(); // ดึงค่าของ request ปัจจุบัน
console.log(JSON.stringify({ msg, requestId: ctx?.requestId, userId: ctx?.userId }));
}
// ที่ service (ลึก 10 ชั้น) — ไม่ต้องส่ง req/ctx ตามมา
async function getOrder(id) {
log(`fetch order ${id}`); // log จะมี requestId อัตโนมัติ
return db.orders.find(id);
}กลไก: ใต้ฝา Node ใช้ async_hooks track ว่า callback ไหน "สืบทอด" มาจากที่ไหน เมื่อ run(ctx, fn) เรียก fn ทุก promise/timer/callback ที่ schedule ภายในจะเห็น getStore() คืน ctx เดียวกัน — แม้ผ่าน await, setTimeout, EventEmitter
Pitfall ที่ต้องรู้
- ห้าม mutate store ตรง ๆ ระหว่างทาง — ใช้
run()ทุกครั้งเพื่อสร้าง scope ใหม่ (mutate store ระหว่างทางจะรั่วข้าม request) - EventEmitter ที่ลงทะเบียน listener "นอก"
run()— listener จะเห็น store ตอนลงทะเบียน ไม่ใช่ตอน emit — ถ้าลงทะเบียนตอน app start (ไม่มี ctx) แล้ว emit ภายใน request →getStore()คืนundefined - Performance overhead — บน Node 18 ลงไป overhead เคยอยู่ที่ 5–15% ทำให้มีคนกลัวเปิดใช้ทั่วโปรเจกต์ · บน Node 22 LTS overhead ลดเหลือ <2% สำหรับ workload ปกติ (Node 19+ ปรับ Promise hooks ใหม่) — ใช้ได้สบายในระดับ middleware ของแอปทั่วไป แค่อย่าใส่ในลูปร้อน ๆ ที่รัน millions ครั้ง
- Pino logger (logger ยอดนิยมของ Node) มี integration ในตัว (
pino-http+genReqId) — ไม่ต้องเขียน AsyncLocalStorage เอง
💡 ถ้าใช้ NestJS (framework ที่บทที่ 8–13 จะสอน) มี package ชื่อ
nestjs-clsให้ใช้ผ่าน DI สะดวกกว่าเรียกAsyncLocalStorageตรง ๆ — ยังไม่ต้องสนตอนนี้ เก็บไว้อ่านตอนถึง NestJS
🛠️ Checkpoint 3 — ลงมือก่อนไปบทถัดไป
- เขียนฟังก์ชัน
delay(ms)ที่คืน Promise แล้ว resolve หลังms(ใช้setTimeoutหรือtimers/promises) แล้วลองawait delay(500)3 ครั้งติดกัน - มี 3 ฟังก์ชัน async ที่ใช้เวลา 1 วิแต่ละตัว — เขียนแบบ
awaitทีละตัว (ได้ ~3 วิ) แล้วเขียนใหม่ด้วยPromise.all(ได้ ~1 วิ) จับเวลาเทียบด้วยconsole.time - ทายผลลัพธ์ของโค้ด event loop ในข้อ 4 ก่อนรันจริง แล้วรันเช็กว่าตรงไหม
- สร้าง
EventEmitterชื่อchatที่มี eventmessage— ลงทะเบียน listener 2 ตัว แล้วemit("message", "สวัสดี")ดูว่าทั้งคู่ทำงาน · เพิ่มonce("join")แล้ว emit 2 ครั้ง ดูว่าทำงานครั้งเดียว - ใช้
fetch+AbortSignal.timeout(2000)ยิงไปเว็บที่ช้า (หรือhttps://httpbin.org/delay/5) แล้วจับAbortError
เฉลยข้อ 1:
javascriptconst delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // หรือ: import { setTimeout as delay } from "node:timers/promises";
สรุปบทที่ 3
- async ใน Node มี 3 ยุค: callback (error-first) → Promise → async/await — ของใหม่ใช้ async/await +
fs/promises - error-first callback = กติกา Node:
(err, data) => ...เช็คerrก่อนเสมอ - งานที่ไม่เกี่ยวกันใช้
Promise.allยิงพร้อมกัน อย่าawaitทีละตัวใน loop promisifyแปลง callback API เก่าเป็น Promise (แต่เช็กnode:*/promisesก่อน)EventEmitter= pattern "ประกาศ/ฟัง" ที่อยู่ทั่ว Node (server, stream, process) — event"error"ที่ไม่มีคนฟัง = crash- event loop: sync → microtask (
nextTick>queueMicrotask/Promise) → macrotask (setTimeout/setImmediate) —setImmediateมาก่อนsetTimeout(fn,0)เสมอใน I/O callback - Promise combinator:
all(ทุกตัว),race(ตัวแรก settle),any(ตัวแรกสำเร็จ),allSettled(ไม่ throw) AbortController/AbortSignal.timeout= ยกเลิก/timeout งาน asyncAsyncLocalStorage= "thread-local สำหรับโลก async" — flow request context (requestId, userId, trace) ทุกชั้นโดยไม่ต้องส่ง parameter
บทต่อไปเอา async มาใช้จริงกับ ไฟล์และ stream — รวมถึง "backpressure" ที่ทำให้ Node จัดการไฟล์ใหญ่ระดับ GB ได้โดยไม่ระเบิด memory