โหมดมืด
บทที่ 7 — Config, CLI + Lifecycle: ตั้งค่าให้ถูก ปิดให้นุ่มนวล
← บทที่ 6: Error Handling + Debugging | สารบัญ | บทที่ 8: Testing →
3 เรื่องที่แยก "สคริปต์เล่น ๆ" ออกจาก "แอป production":
- Config ผ่าน environment — ทำไมห้ามฮาร์ดโค้ดรหัส DB ลงไฟล์ (หลัก 12-factor) +
.env+ validate ตอน startup - CLI tool — รับ argument/flag อย่างเป็นระบบ (ไม่ใช่อ่าน
argvดิบ ๆ) - Graceful shutdown — ปิด server โดยไม่ทิ้ง request ที่ค้างกลางคัน
1. ทำไมต้องเอา config ออกจากโค้ด (12-factor)
นี่คือ anti-pattern ที่มือใหม่ทำกันหมด:
javascript
// ❌ อย่าทำ: ฮาร์ดโค้ด config ลงโค้ด
const db = connect("postgres://admin:SuperSecret123@prod-db:5432/app");
const PORT = 3000;ปัญหา: (1) รหัสผ่านโผล่ใน git ใครเห็น repo ก็เห็นรหัส (2) เปลี่ยน config ต้องแก้โค้ด + deploy ใหม่ (3) dev/staging/production ใช้ค่าต่างกันไม่ได้ (โค้ดเดียวกันต้องรันได้ทุกที่)
12-Factor App คือแนวทาง 12 ข้อสำหรับออกแบบแอปให้ deploy ง่าย เขียนโดยทีม Heroku — หนึ่งในนั้นบอกว่า: เก็บ config ที่เปลี่ยนตาม environment ไว้ใน environment variable (บทที่ 2 — process.env)
หลักสำคัญ: โค้ดชุดเดียวต้องรันได้ทุก environment แค่เปลี่ยนค่า env ไม่ต้องแก้โค้ด:
javascript
// ✅ อ่านจาก environment
const PORT = Number(process.env.PORT) || 3000;
const DATABASE_URL = process.env.DATABASE_URL;2. .env file + validate ตอน startup
ตอน dev จะพิมพ์ env ยาว ๆ หน้า command ทุกครั้งก็ลำบาก — เก็บไว้ในไฟล์ .env แทน:
bash
# .env — ไฟล์นี้ใส่ .gitignore เสมอ! (มีความลับ)
PORT=8080
DATABASE_URL=postgres://localhost:5432/myapp
JWT_SECRET=dev-secret-change-in-prod
NODE_ENV=developmentโหลดเข้า process.env — Node มีตัวช่วย built-in แล้ว ไม่ต้องลง dotenv (แนะนำเป็นค่ามาตรฐานปี 2026):
bash
node --env-file=.env app.js # โหลด .env เข้า process.env ก่อนรัน
node --env-file-if-exists=.env app.js # (Node 22+) ถ้าไฟล์ไม่มี → ไม่ error💡 ประวัติเวอร์ชัน:
--env-fileมาใน Node v20.6 (experimental) → stable ใน v20.12 ·--env-file-if-existsมาใน Node 22 (เหมาะกับ production ที่อาจไม่มี.env)ข้อจำกัด:
--env-fileไม่ทำ variable expansion (${OTHER_VAR}) และไม่ support comment ที่อยู่หลังค่า (#ต้องอยู่ต้นบรรทัดอย่างเดียว) — ถ้าต้องการ feature เหล่านี้ ค่อยใช้dotenv
ถ้ายังต้อง support Node เวอร์ชันเก่า หรือใช้ feature ที่ built-in ไม่มี (เช่น dotenv-expand) ใช้ package dotenv:
javascript
import "dotenv/config"; // โหลด .env อัตโนมัติ — วางบรรทัดบนสุดสุด
console.log(process.env.PORT);⭐ Validate config ตอน startup — "fail fast"
process.env.X เป็น string หรือ undefined เสมอ (บทที่ 2) — ถ้าลืมตั้ง env สำคัญ แล้วรู้ตัวตอน request ที่ 1000 เข้ามากลางดึก = หายนะ ตรวจ config ให้ครบตั้งแต่ตอน boot แล้ว crash ทันทีถ้าขาด (fail fast ดีกว่า fail later):
javascript
// config.mjs — รวม config + validate ที่เดียว
function required(name) {
const value = process.env[name];
if (!value) {
// ขาด config สำคัญ → ตายทันทีตอน startup พร้อมบอกว่าขาดอะไร
console.error(`❌ ต้องตั้ง environment variable: ${name}`);
process.exit(1);
}
return value;
}
export const config = {
port: Number(process.env.PORT) || 3000, // มี default ได้
databaseUrl: required("DATABASE_URL"), // บังคับ
jwtSecret: required("JWT_SECRET"), // บังคับ
isProd: process.env.NODE_ENV === "production",
};💡 โปรเจกต์จริงนิยม validate ด้วย Zod — schema (โครงสร้าง/รูปแบบข้อมูลที่กำหนดไว้ล่วงหน้า) validation library ที่ให้ประกาศ schema แล้วใช้ตรวจ runtime + ได้ type อัตโนมัติ (ถ้าใช้ TypeScript) ติดตั้งด้วย
npm i zodแล้วใช้แบบนี้:javascriptimport { z } from "zod"; const env = z.object({ PORT: z.coerce.number().default(3000), // coerce = บังคับแปลงชนิดข้อมูลให้ตรงที่กำหนด เช่น string → number DATABASE_URL: z.string().url(), NODE_ENV: z.enum(["development", "production", "test"]), }).parse(process.env); // ผิด → throw พร้อมบอกว่า field ไหนพังได้ทั้ง validate + type ที่ถูกต้องในตัวเดียว — รายละเอียดเต็มที่ TypeScript บทที่ 11 (ในภาค TS ของหนังสือ)
.env.example — บอกทีมว่าต้องตั้งอะไรบ้าง
.env จริงอยู่ใน .gitignore (มีความลับ) — แต่ commit ไฟล์ .env.example (ไม่มีค่าจริง) ขึ้น git เพื่อบอกเพื่อนร่วมทีมว่าต้องตั้ง env อะไรบ้าง:
bash
# .env.example — commit ขึ้น git ได้ (ไม่มีความลับจริง)
PORT=3000
DATABASE_URL=
JWT_SECRET=
NODE_ENV=development⚠️ Pitfall ที่ทำคนเดือดร้อนบ่อย: เผลอ commit
.envที่มีรหัสจริงขึ้น git สาธารณะ → ถือว่ารหัสรั่วแล้วแม้จะลบทีหลัง (git เก็บประวัติ) ต้องเปลี่ยนรหัสใหม่ทันที · ใส่.envใน.gitignoreตั้งแต่บรรทัดแรกของโปรเจกต์
3. สร้าง CLI tool
หลาย tool ที่คุณใช้ (eslint, vite) คือโปรแกรม Node ที่รับ argument จาก command line
flag/option คือค่าที่ใส่ต่อท้ายคำสั่งแบบ --ชื่อ (เช่น --verbose, --name สมชาย) เพื่อบอกโปรแกรมว่าต้องทำอะไรพิเศษ — Node เก็บ argument ดิบ ๆ ไว้ใน process.argv (array ของ string, ดูรายละเอียดใน บทที่ 2 ถ้ายังไม่เคยอ่าน) แต่พอมี flag เยอะ ๆ การ parse process.argv เองจะยุ่ง Node มี util.parseArgs built-in ช่วย:
javascript
// greet.mjs — รัน: node greet.mjs --name สมชาย --times 3 --shout
import { parseArgs } from "node:util";
const { values } = parseArgs({
options: {
name: { type: "string", short: "n" }, // --name หรือ -n
times: { type: "string", short: "t", default: "1" }, // ⚠️ parseArgs ไม่มี type "number" — รับเป็น string แล้วแปลงเอง
shout: { type: "boolean", short: "s", default: false }, // flag ไม่มีค่า
},
});
const times = Number(values.times); // แปลงเอง — เช็ก Number.isFinite ถ้าผู้ใช้พิมพ์มั่ว ๆ
let msg = `สวัสดี ${values.name ?? "ไม่ทราบชื่อ"}`;
if (values.shout) msg = msg.toUpperCase() + "!!!";
for (let i = 0; i < times; i++) console.log(msg);bash
$ node greet.mjs --name สมชาย --times 2 --shout
สวัสดี สมชาย!!!
สวัสดี สมชาย!!!💡 ถ้า CLI ซับซ้อน (มี subcommand เช่น
git commit, help อัตโนมัติ, validation) ใช้ library เช่นcommanderหรือyargs— แต่งานง่าย ๆparseArgsbuilt-in พอแล้ว ไม่ต้องลง dependency
ทำให้รันเป็นคำสั่งได้ (shebang + bin)
shebang (อ่าน "ชีแบง") = บรรทัด #! บรรทัดแรกของไฟล์ ที่บอก OS ว่าให้ใช้โปรแกรมไหนรันไฟล์นี้:
javascript
#!/usr/bin/env node
// บรรทัดบนสุด (shebang) บอก OS ว่า "รันไฟล์นี้ด้วย node"
console.log("ฉันเป็น CLI tool!");⚠️ Windows caveat: shebang ทำงานเฉพาะ Unix (macOS/Linux/WSL) — บน Windows ต้องเรียก
node cli.mjsตรง ๆ · ถ้า publish เป็น npm package แล้วลง global,npmจะสร้าง.cmdwrapper ให้ทำงานบน Windows ได้
ทีนี้ถ้าอยากให้เรียกไฟล์นี้เป็น "คำสั่ง" ได้เลย (พิมพ์ mytool แทน node cli.mjs) ต้องลงทะเบียนผ่าน bin ใน package.json — bin คือ field ที่ผูกชื่อคำสั่งเข้ากับไฟล์ตัวนี้:
jsonc
"bin": { "mytool": "./cli.mjs" }💡
npm link= ติดตั้ง package ที่อยู่ใน folder ปัจจุบันเข้าเครื่องแบบ global (โดยลิงก์ไฟล์ ไม่ copy) — ใช้ทดสอบ CLI ของตัวเองก่อน publish · ยกเลิกด้วยnpm unlink -g <ชื่อ>
4. Graceful shutdown — ปิดอย่างมีมารยาท
เมื่อ deploy เวอร์ชันใหม่ หรือ scale ลด, ระบบจะสั่งปิด process เก่า ถ้าปิดดื้อ ๆ — request ที่กำลังประมวลผลอยู่จะถูกตัดกลางคัน (user เห็น error, transaction ค้างครึ่งทาง)
graceful shutdown = "หยุดรับงานใหม่ → ทำงานที่ค้างให้จบ → ปิด resource → ค่อยตาย (terminate/exit process)"
Signal คืออะไร
ลองนึกภาพเหมือนคนเคาะประตูบอก "จะปิดร้านแล้วนะ" — ร้านไม่ปิดทันที แต่รอให้ลูกค้าที่กำลังจ่ายเงินอยู่จ่ายให้เสร็จก่อน นั่นคือหลักการของ signal: ระบบปฏิบัติการ "เคาะประตู" บอกโปรแกรมให้ปิดตัว ผ่านการส่ง signal ชนิดต่าง ๆ — Node ดักฟัง signal ได้ผ่าน process.on (บทที่ 2)
มี signal หลักที่ต้องรู้จัก 3 ตัว:
SIGTERM — "ขอให้ปิดด้วยนะ" เป็นมิตร ดักได้ (โปรแกรมมีโอกาสทำความสะอาดก่อนปิด) Docker (เครื่องมือ package + รันแอปใน container) และ Kubernetes/k8s (ระบบ orchestrate container ระดับ cluster) ส่ง signal นี้ตอน stop/redeploy — รายละเอียดสองตัวนี้จะลงลึกในบทที่ 11
SIGINT — เกิดตอนกด Ctrl+C ใน terminal ดักได้เช่นกัน
SIGUSR2 — signal "user-defined" (ผู้ใช้กำหนดเอง) — nodemon ใช้ส่งตัวนี้ตอน restart ระหว่าง dev ถ้าเขียน graceful shutdown ก็ควรดักด้วย ไม่งั้นทุกครั้งที่ nodemon reload connection จะถูกตัดดื้อ ๆ
ส่วน SIGKILL ดักไม่ได้ ตายทันที — k8s ส่งตัวนี้หลังหมด terminationGracePeriodSeconds ถ้าเรายังไม่ยอม exit เอง
javascript
import express from "express";
const app = express();
let isShuttingDown = false;
// readiness probe: ระหว่างปิด ตอบ 503 ให้ load balancer หยุดส่ง traffic ใหม่ก่อน
app.get("/health/ready", (req, res) =>
res.status(isShuttingDown ? 503 : 200).send(isShuttingDown ? "shutting down" : "ok")
);
app.get("/", (req, res) => res.send("ok"));
const server = app.listen(3000, () => console.log("รันแล้ว"));
// AbortController = object ที่ส่ง "สัญญาณยกเลิก" ให้ async operation ที่รองรับมันได้
// เรียก .abort() ครั้งเดียว → ทุกงานที่ผูก .signal ไว้จะถูกยกเลิกพร้อมกัน
// ใช้ยกเลิกงาน background (DB queries ค้าง, fetch ค้าง ฯลฯ) ตอน shutdown
const shutdownAbort = new AbortController();
// ฟังก์ชันปิดอย่างนุ่มนวล
async function shutdown(signal) {
if (isShuttingDown) return; // กันโดน signal ซ้ำ (Ctrl+C สองครั้ง)
isShuttingDown = true;
console.log(`\nได้รับ ${signal} — กำลังปิดอย่างนุ่มนวล...`);
// 1. flip readiness probe ปิดก่อน → รอให้ load balancer drain
// (k8s pattern: readiness=false → รอ ~5s → ค่อย close server)
// ถ้า run ใน k8s ใส่ sleep ตรงนี้ตาม terminationGracePeriodSeconds
await new Promise((r) => setTimeout(r, 1_000));
// 2. หยุดรับ connection ใหม่ (request ที่ค้างอยู่ยังทำต่อจนจบ)
// ⭐ สำคัญ: process.exit() ต้องรอ callback ของ server.close() ก่อนเสมอ
// เพราะ server.close() ปิดแบบ async — ถ้า exit ก่อน callback ทำงาน
// request ที่ยังค้างอยู่จะถูกตัดกลางคัน ขัดกับเป้าหมายของ graceful shutdown เอง
server.close(async (err) => {
if (err) console.error("server.close error:", err);
console.log("ปิดรับ request ใหม่แล้ว");
try {
// 3. ยกเลิกงาน background ที่ใช้ AbortSignal
shutdownAbort.abort();
// 4. ปิด resource อื่น: DB pool, message queue, ฯลฯ
// await db.end();
console.log("ปิด resource เรียบร้อย — บ๊ายบาย");
process.exit(0); // จบด้วย code 0 = สำเร็จ — เรียกหลัง server.close() เสร็จจริงเท่านั้น
} catch (err) {
console.error("error ตอนปิด resource:", err);
process.exit(1);
}
});
// ⭐ ปิด keep-alive sockets — server.close() จะ "รอ" connection พวกนี้ตลอดกาล
// เพราะ HTTP keep-alive เปิดค้างไว้ใช้ซ้ำ ไม่มีใครปิดให้ (อธิบายเพิ่มด้านล่าง หัวข้อ "ทำไมต้อง closeIdleConnections()")
// ⚠️ ต้องใช้ Node 18.2+ ไม่งั้น method นี้ไม่มี — throw TypeError "is not a function"
server.closeIdleConnections(); // ปิด socket ที่ idle ทันที (ไม่มี request ค้าง)
// ให้ socket ที่กำลังส่ง response อยู่ทำงานต่อสักพัก แล้วค่อยบังคับปิด
setTimeout(() => {
server.closeAllConnections(); // ปิด socket ที่เหลือทุกตัว (รวมที่ยัง active) — ต้องใช้ Node 18.2+ เช่นกัน
}, 5_000).unref();
}
// 6. ตาข่ายกันค้าง: ถ้าปิดไม่เสร็จใน 10 วิ บังคับตาย (อย่าค้างตลอดกาล)
function armForceExit() {
setTimeout(() => {
console.error("ปิดไม่เสร็จใน 10 วิ — บังคับปิด");
process.exit(1);
}, 10_000).unref(); // unref() = ไม่ให้ timer นี้กัน process ไม่ให้จบ
}
// ดัก signal
process.on("SIGTERM", () => { armForceExit(); shutdown("SIGTERM"); }); // จาก docker/k8s
process.on("SIGINT", () => { armForceExit(); shutdown("SIGINT"); }); // จาก Ctrl+C
process.on("SIGUSR2", () => { armForceExit(); shutdown("SIGUSR2"); }); // จาก nodemon restartลองเอง: รัน server แล้วกด Ctrl+C — จะเห็นมันปิดเป็นขั้นตอน ไม่ตายทันที
ทำไมต้อง closeIdleConnections() + closeAllConnections()
(keep-alive = socket ที่เปิดค้างไว้ใช้ซ้ำ — ตามที่กล่าวถึงไปแล้วด้านบน)
HTTP/1.1 ใช้ keep-alive เป็น default — client ส่ง request เสร็จแล้ว socket ยัง "เปิดค้าง" ไว้เพื่อใช้ซ้ำ · server.close() รอจนทุก connection ปิดเอง แต่ keep-alive socket ไม่มีใครปิดให้ → server "ค้าง" ตลอดกาล จนโดน force exit ที่ 10 วิ (deploy ช้า, log สับสน)
Node 18.2+ เพิ่ม 2 method นี้แก้ปัญหา:
server.closeIdleConnections()— ปิดเฉพาะ socket ที่ idle (ไม่มี request ค้าง) ใช้คู่กับserver.close()ตอนเริ่ม shutdownserver.closeAllConnections()— ปิด socket ทุกตัว รวมที่ยังส่ง response อยู่ ใช้ตอนหมดเวลา grace period
AbortController สำหรับ background work
ถ้าแอปมีงาน async ที่รันใน background (เช่น polling, batch job, fetch ภายนอก) ให้ pass AbortSignal เข้าไป — ตอน shutdown ก็ abort() ทีเดียว ยกเลิกได้หมด:
javascript
// ในที่ที่ทำงาน background
const res = await fetch(url, { signal: shutdownAbort.signal }); // จะ throw AbortError ตอน shutdown
await someLongTask({ signal: shutdownAbort.signal });⚠️ Pitfall:
- ลืม
server.close()แล้วprocess.exit()เลย → request ที่กำลังตอบครึ่งทางถูกตัด user เจอ connection reset- ลืม
closeIdleConnections()/closeAllConnections()→ keep-alive socket ค้าง deploy ช้าทุกครั้ง- ลืม timeout กันค้าง → connection ดื้อ (เช่น websocket) ทำให้ process ค้างไม่ยอมตาย
💡 หมายเหตุล่วงหน้า: NestJS มี "lifecycle hooks" (
onModuleDestroy,enableShutdownHooks()) ที่ wrap pattern นี้ให้เป็นระบบ — ข้างใต้ก็คือSIGTERM+ ปิด resource แบบนี้แหละ ถ้ายังไม่ถึงภาค Nest ข้ามได้ จะมีบทเฉพาะที่ NestJS บทที่ 8
🛠️ Checkpoint 7 — ลงมือก่อนไปบทถัดไป
- สร้าง
.env(PORT, DATABASE_URL) +.env.example+ เพิ่ม.envใน.gitignoreแล้วรันด้วยnode --env-file=.env app.jsให้อ่านค่าได้ - เขียน
config.mjsที่ validate ว่ามีDATABASE_URLไหม — ถ้าไม่มีprocess.exit(1)พร้อมข้อความ ลองรันทั้งแบบมีและไม่มี - ทำ CLI ด้วย
parseArgsรับ--file <path>แล้วอ่านไฟล์นั้นพิมพ์จำนวนบรรทัด (รวมกับ readline บทที่ 4) - เพิ่ม graceful shutdown ให้ Express server — กด Ctrl+C แล้วสังเกต log ว่าปิดเป็นขั้นตอน
- ทดสอบ timeout กันค้าง: ใส่ route ที่หน่วงเวลา 30 วิ (
setTimeout) เปิด request ค้างไว้ แล้วกด Ctrl+C — ดูว่า server รอ request จบ หรือบังคับปิดที่ 10 วิ - (ถ้ามี Zod) เปลี่ยน config.mjs ให้ validate ด้วย Zod schema แทนการเช็กมือ
เฉลยข้อ 2: ดูโครง
required()ในข้อ 2 ของบท — เรียกrequired("DATABASE_URL")ตอน import config
สรุปบทที่ 7
- Config ผ่าน environment (12-factor) — อย่าฮาร์ดโค้ดรหัส/URL ลงโค้ด · โค้ดชุดเดียวรันได้ทุก env
.envเก็บ config ตอน dev → โหลดด้วยnode --env-file=.env(built-in) หรือdotenv·.envต้องอยู่ใน.gitignore· commit.env.exampleแทน- Validate config ตอน startup (fail fast) — ขาด env สำคัญให้ crash ทันที ไม่ใช่ค้นพบตอน request เข้า · Zod ช่วยได้
- CLI tool ใช้
util.parseArgs(built-in) สำหรับ flag/option · ซับซ้อนค่อยใช้commander/yargs - Graceful shutdown: ดัก
SIGTERM/SIGINT/SIGUSR2→ flip readiness=false →server.close()+closeIdleConnections()/closeAllConnections()(จัดการ keep-alive) →AbortController.abort()ยกเลิกงาน background → ปิด DB/resource → exit · มี timeout กันค้าง เสมอ
บทต่อไป: เขียนเทสต์ — node:test built-in, Vitest, mock, และทดสอบ HTTP API ด้วย supertest