Bài 11 · Vận dụng · 20 phút
Process, môi trường & công cụ CLI
Biên soạn bởi Nguyễn Anh Tuấn
Tiến trình & môi trường trong Node: đọc process.argv/env, bắt tín hiệu (signal), trả exit code đúng, và dựng một công cụ dòng lệnh (CLI) nhỏ.
Ở bài Đa luồng & đa tiến trình Node.js, bạn đã fork ra cả một bầy tiến trình - server cluster in mỗi response một process.pid khác nhau. Bài này quay ống kính vào chính tiến trình đó, qua object toàn cục process (không cần import) - cầu nối giữa code JS và hệ điều hành:
can-cuoc.js - chạy trong /tmp/vi-du; PID và đường dẫn trên máy mèo con sẽ khác
// Tấm căn cước của tiến trình đang chạy
console.log("pid :", process.pid);
console.log("cwd :", process.cwd());
console.log("argv:", process.argv);
$ node can-cuoc.js xin-chao 42
pid : 12109
cwd : /tmp/vi-du
argv: [ '/opt/node22/bin/node', '/tmp/vi-du/can-cuoc.js', 'xin-chao', '42' ] Nhìn kỹ cấu trúc của argv: phần tử 0 là đường dẫn tới chính node, phần tử 1 là đường dẫn script, và từ vị trí 2 trở đi mới là đối số của bạn - nên mọi công cụ đọc đối số đều bắt đầu từ argv.slice(2). Còn cấu hình ít đổi giữa các lần chạy (cổng, chế độ…) thì theo quy ước đi qua biến môi trường, đọc bằng process.env:
server.js - chạy trần thì in cổng dự phòng 8080 (đã thử); cờ --env-file có sẵn từ Node 20.6, bài này chạy trên v22
// Đọc cấu hình từ biến môi trường, kèm giá trị dự phòng (fallback)
const port = Number(process.env.PORT ?? 8080);
console.log(`Server sẽ nghe ở cổng ${port}`);
$ PORT=3000 node server.js
Server sẽ nghe ở cổng 3000
$ node --env-file=.env server.js # tệp .env chứa dòng: PORT=3000
Server sẽ nghe ở cổng 3000 - ▸process là object toàn cục: pid, cwd(), argv, env - tấm căn cước kiêm túi cấu hình của tiến trình.
- ▸argv = [đường dẫn node, đường dẫn script, ...đối số] - đối số của người dùng bắt đầu từ argv[2].
- ▸Biến môi trường = cấu hình từ bên ngoài: PORT=3000 node server.js đổi hành vi mà không sửa code; gom vào .env rồi nạp bằng --env-file (và đừng commit tệp này).
Mỗi tiến trình có sẵn hai luồng ghi ra: stdout cho kết quả (console.log) và stderr cho lời báo lỗi (console.error). Trên màn hình chúng trông y hệt nhau - cho tới khi bạn chuyển hướng (redirect) hoặc pipe:
bao-cao.js - 2> chỉ chuyển hướng stderr; hai luồng tách nhau thật sự
console.log("kết quả: 42 tệp đã xử lý"); // → stdout (luồng KẾT QUẢ)
console.error("cảnh báo: 1 tệp bị hỏng"); // → stderr (luồng BÁO LỖI)
$ node bao-cao.js 2>loi.txt
kết quả: 42 tệp đã xử lý
$ cat loi.txt
cảnh báo: 1 tệp bị hỏng Kênh thứ ba kín đáo hơn: khi thoát, tiến trình để lại cho shell một con số - exit code (mã thoát), quy ước 0 = ổn, khác 0 = có lỗi. Toán tử && của shell và mọi hệ thống CI đều nhìn con số này:
kiem-tra.js - shell dựa vào exit code để quyết && có chạy vế sau hay không
// Exit code nói lên kết quả: 0 = ổn, khác 0 = có lỗi
const tep = process.argv[2];
if (!tep) {
console.error("Lỗi: mèo con chưa đưa tên tệp!");
process.exit(1); // dừng NGAY với mã lỗi 1
}
console.log(`Ổn: sẽ xử lý ${tep}`); // chạy hết tự nhiên → exit code 0
$ node kiem-tra.js diem.csv && echo "OK, bước tiếp theo"
Ổn: sẽ xử lý diem.csv
OK, bước tiếp theo
$ node kiem-tra.js && echo "OK, bước tiếp theo"
Lỗi: mèo con chưa đưa tên tệp!
$ echo $?
1 - ▸Kết quả → stdout (console.log); lỗi, cảnh báo, tiến độ → stderr (console.error) - tách bằng 2>.
- ▸Exit code: 0 = ổn, khác 0 = lỗi; shell (&&, ||) và CI đều đọc con số này.
- ▸process.exit(1) dừng ngay lập tức; process.exitCode = 1 dịu hơn - đặt mã sẵn rồi thoát tự nhiên (xem Câu hỏi thường gặp).
CLI không chỉ nói - còn phải nghe. Luồng vào stdin mang những gì người dùng gõ, hoặc thứ một lệnh khác pipe sang. Cách hỏi-đáp gọn nhất trong Node là node:readline/promises:
chao.mjs - chạy node chao.mjs để gõ tay; ở đây pipe sẵn câu trả lời vào stdin nên prompt và lời chào dính một dòng
import readline from "node:readline/promises";
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ten = await rl.question("Tên mèo con là gì? ");
console.log(`Chào ${ten}, học vui nhé!`);
rl.close();
$ echo "Miu" | node chao.mjs
Tên mèo con là gì? Chào Miu, học vui nhé! - ▸node:readline/promises + rl.question: hỏi đáp trên terminal bằng await, không cần callback.
- ▸stdin là một Readable stream (họ nhà bài Buffer & Stream) và rl duyệt được từng dòng bằng for await...of (bài Generator & async iterator) - nên echo "Miu" | node chao.mjs chạy không cần ai gõ.
- ▸Xong việc thì rl.close() để readline nhả stdin ra, tiến trình kết thúc gọn gàng.
Hệ điều hành và người dùng đôi lúc gửi cho tiến trình một tín hiệu (signal) - thông điệp ngắn kiểu "dừng lại đi". Hai signal gặp nhiều nhất: SIGINT khi bạn nhấn Ctrl+C, và SIGTERM - thứ lệnh kill gửi theo mặc định, cũng là cách Docker/Kubernetes/systemd yêu cầu dịch vụ tắt. Không bắt thì Node thoát NGAY (shell báo exit code 143 = 128 + 15, số hiệu của SIGTERM) - request đang dở, dữ liệu chưa ghi… mất sạch. process.on("SIGTERM", ...) cho bạn cơ hội dọn dẹp:
don-dep.mjs + hai cửa sổ terminal - PID trên máy mèo con sẽ khác
console.log(`Tiến trình ${process.pid} đang chạy...`);
const congViec = setInterval(() => console.log("vẫn đang làm việc..."), 500);
process.on("SIGTERM", () => {
console.log("Nhận SIGTERM - dọn dẹp rồi mới thoát:");
clearInterval(congViec); // dừng việc đang dở
console.log(" (chỗ này: đóng server, đóng kết nối CSDL...)");
console.log("Dọn xong, tạm biệt!");
process.exit(0);
});
$ node don-dep.mjs # cửa sổ 1
Tiến trình 17820 đang chạy...
vẫn đang làm việc...
vẫn đang làm việc...
# cửa sổ 2 gõ: kill 17820 (gửi SIGTERM)
Nhận SIGTERM - dọn dẹp rồi mới thoát:
(chỗ này: đóng server, đóng kết nối CSDL...)
Dọn xong, tạm biệt! Với server thật, handler này chính là chỗ viết graceful shutdown - "tắt có dọn dẹp": ngừng nhận kết nối mới, làm nốt request đang dở, đóng cơ sở dữ liệu rồi mới thoát. Ghép từ bài Mạng: HTTP, fetch & API và bài Cơ sở dữ liệu, khung quen thuộc trông thế này:
khung graceful shutdown - mẫu rút gọn, không chạy một mình được vì thiếu server & db thật
// "server" từ bài Mạng (node:http), "db" từ bài Cơ sở dữ liệu
process.on("SIGTERM", () => {
server.close(() => { // 1. ngừng nhận kết nối MỚI, chờ request đang dở xong
db.close(); // 2. đóng kết nối cơ sở dữ liệu
process.exit(0); // 3. dọn xong rồi mới thoát
});
}); - ▸Ctrl+C gửi SIGINT; kill <pid> gửi SIGTERM - process.on("SIGINT"/"SIGTERM", ...) để can thiệp.
- ▸Không bắt signal → thoát ngay với mã 128 + số hiệu signal (SIGTERM = 143), việc dở dang mất sạch.
- ▸Graceful shutdown = tắt có dọn dẹp: ngừng nhận việc mới → làm nốt việc dở → đóng tài nguyên → exit(0). Docker/Kubernetes gửi SIGTERM rồi chỉ chờ một khoảng ngắn trước khi SIGKILL.
Trung thực: signal là chuyện Unix
Đủ đồ nghề rồi - ráp thành một công cụ dòng lệnh (CLI - command-line interface) hoàn chỉnh: meo-dem, chiếc wc thu nhỏ của nhà mèo - đếm dòng (mặc định) hoặc đếm từ (--tu/-t) của một tệp. Thay vì tự lần mò argv, dùng util.parseArgs có sẵn của Node: khai báo cờ, phần còn lại là positional argument (đối số vị trí - ở đây là tên tệp):
meo-dem.mjs - cờ qua parseArgs, tệp qua positional, mọi lỗi đi ra stderr kèm exit 1
#!/usr/bin/env node
// meo-dem - đếm dòng (mặc định) hoặc đếm từ (--tu) của một tệp văn bản
import { parseArgs } from "node:util";
import { readFile } from "node:fs/promises";
const { values, positionals } = parseArgs({
options: { tu: { type: "boolean", short: "t" } },
allowPositionals: true // cho phép đối số vị trí (positional argument)
});
const tep = positionals[0];
if (!tep) {
console.error("meo-dem: mèo con quên đưa tên tệp rồi!");
console.error("Cách dùng: meo-dem [--tu | -t] <tệp>");
process.exit(1);
}
let noiDung;
try {
noiDung = await readFile(tep, "utf8");
} catch {
console.error(`meo-dem: không đọc được tệp "${tep}"`);
process.exit(1);
}
if (values.tu) {
const soTu = noiDung.split(/\s+/).filter(Boolean).length;
console.log(`${soTu} từ`);
} else {
const soDong = noiDung.split("\n").length - (noiDung.endsWith("\n") ? 1 : 0);
console.log(`${soDong} dòng`);
} meo-dem trong tay người dùng - kết quả sạch ra stdout, lời than ra stderr (kèm exit code 1)
$ cat tho.txt
Mèo con ham học
ngồi gõ từng dòng code
chờ tia nắng lên
$ node meo-dem.mjs tho.txt
3 dòng
$ node meo-dem.mjs --tu tho.txt
13 từ
$ node meo-dem.mjs
meo-dem: mèo con quên đưa tên tệp rồi!
Cách dùng: meo-dem [--tu | -t] <tệp> Ghi chú trung thực: gặp cờ lạ, parseArgs throw với thông báo tiếng Anh khá dài - CLI kỹ tính sẽ bọc try/catch để báo lỗi thân thiện hơn. Bước cuối: biến script thành lệnh thật. Dòng đầu #!/usr/bin/env node là shebang - lời nhắn cho shell "hãy chạy tệp này bằng node"; trường "bin" trong package.json + npm link cài nó thành lệnh toàn cục (chuyện npm quản lý gói & lệnh thuộc bài npm nâng cao & package managers):
package.json đặt cạnh meo-dem.mjs - npm link còn in vài dòng audit (đã lược); gỡ lệnh bằng npm rm -g meo-dem
{
"name": "meo-dem",
"version": "1.0.0",
"bin": { "meo-dem": "./meo-dem.mjs" }
}
$ chmod +x meo-dem.mjs # dòng shebang cần tệp có quyền thực thi
$ ./meo-dem.mjs tho.txt # chạy thẳng, không cần gõ "node"
3 dòng
$ npm link # cài "meo-dem" thành lệnh toàn cục, trỏ về thư mục này
$ meo-dem --tu tho.txt # giờ đứng ở thư mục nào gõ cũng được
13 từ - ▸util.parseArgs có sẵn trong Node - khai cờ (kèm tên tắt), bật allowPositionals, không cần cài thư viện.
- ▸CLI tử tế: kết quả → stdout, lỗi → stderr + exit 1 - để pipe và && hoạt động an toàn.
- ▸Shebang #!/usr/bin/env node + chmod +x → chạy ./meo-dem.mjs; trường "bin" + npm link → gõ meo-dem ở bất cứ đâu.
Tiếp theo: tin được meo-dem chưa?
Câu hỏi thường gặp
process.exit(1) chấm dứt NGAY LẬP TỨC - việc bất đồng bộ đang dở (ghi tệp, flush log) có thể bị cắt ngang. process.exitCode = 1 chỉ đặt sẵn mã thoát rồi để chương trình kết thúc tự nhiên khi event loop hết việc - an toàn hơn. Quy tắc gọn: lỗi phát hiện ngay đầu chương trình (thiếu đối số) thì exit() thẳng; đang giữa chừng thì ưu tiên exitCode.
Không. SIGKILL và SIGSTOP không được giao tới tiến trình - hệ điều hành tự chấm dứt/đóng băng nó, handler không bao giờ chạy. Vì vậy quy trình tắt tử tế luôn là: gửi SIGTERM trước (cho cơ hội dọn dẹp), chờ một khoảng, bất đắc dĩ mới -9. Docker (mặc định chờ 10 giây) và Kubernetes (30 giây) làm đúng trình tự đó.
argv = đầu vào của MỘT lần chạy: tên tệp, cờ bật tắt - mỗi lần gõ mỗi khác. env = cấu hình của MÔI TRƯỜNG chạy: cổng, chế độ dev/production, khoá API - thường do hệ thống/CI đặt sẵn. Bí mật (mật khẩu, token) để trong env hoặc tệp .env (nhớ thêm .env vào .gitignore), đừng truyền qua argv - đối số bị lộ trong lịch sử shell và danh sách tiến trình (ps).
Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.
Trả lời vài câu để chắc rằng em đã nắm bài.
Trong Node.js, process.argv chứa gì ở vị trí [0] và [1]?
- 1
Soi căn cước
Mèo con thêm vào
can-cuoc.js: inprocess.platform,process.versionvàprocess.uptime(), rồi chạy hai lần liên tiếp.Hoàn thành khi: Hai lần chạy cho
pidKHÁC nhau (mỗi lần chạy là một tiến trình mới) cònplatform/versiongiữ nguyên. - 2
Ba nguồn cấu hình
Viết
xin-chao.jsin lời chào với tên lấy từprocess.env.TEN_MEO, dự phòng"mèo con". Chạy 3 kiểu: không đặt biến;TEN_MEO=Miu node xin-chao.js; qua tệp.env+--env-file.Hoàn thành khi: Ba lần chạy in đúng ba kết quả, và mèo con chỉ được giá trị mỗi lần đến từ nguồn nào.
- 3
Tách hai luồng
Chạy
node meo-dem.mjs khong-co.txtba kiểu: thêm2>/dev/null, thêm>/dev/null, rồi2>loi.txt.Hoàn thành khi: Kiểu 1 màn hình sạch; kiểu 2 lỗi VẪN hiện (vì chỉ chuyển hướng stdout);
loi.txtchứa đúng thông báo;echo $?cả ba lần đều ra 1. - 4
Exit code ba nhánh
Viết
kiem-tra-diem.js: đọc một số từargv[2]; ≥ 5 → in "đậu" và thoát 0; < 5 → thoát 2; không phải số → thoát 1. Nối thử&& echo "qua môn".Hoàn thành khi: Ba đầu vào
7,3,abcchoecho $?lần lượt 0, 2, 1 - chỉ trường hợp đầu in thêm "qua môn". - 5
Ctrl+C hai nhịp
Viết script đếm ngược 10 giây bằng
setInterval. BắtSIGINT: lần nhấn Ctrl+C ĐẦU chỉ in "Đang dọn dẹp - nhấn lần nữa để thoát ngay", lần HAI mớiprocess.exit(130).Hoàn thành khi: Nhấn một lần chương trình vẫn đếm tiếp; nhấn lần hai mới thoát và
echo $?ra 130. - 6
meo-dem v2
Nâng cấp CLI: thêm cờ
--ky-tu/-k(đếm ký tự) và cho nhận NHIỀU tệp positional, in mỗi tệp một dòng dạng<tên tệp>: <số>.Hoàn thành khi:
./meo-dem.mjs -k tho.txt khac.txtin đúng 2 dòng; gặp tệp không đọc được thì báo ra stderr và exit code là 1.