← Lập trình JavaScript nâng cao

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

Signal sinh ra từ thế giới Unix (Linux/macOS). Trên Windows, Node chỉ mô phỏng được một phần: Ctrl+C (SIGINT) hoạt động, còn "SIGTERM" đăng ký được nhưng không bao giờ được giao tới nơi. Mèo con dùng Windows thì chạy demo kill ở trên trong WSL.

Đủ đồ 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 nodeshebang - 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?

CLI chạy được rồi - nhưng làm sao biết chắc nó đúng, kể cả với tệp rỗng hay cờ gõ sai? Niềm tin đó nên do máy kiểm chứng thay vì mắt người: bài kế - Kiểm thử với node:test - dùng test runner có sẵn của Node để đem chính những công cụ như meo-dem ra kiểm tự động.

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).

Trước đây đúng vậy - gói dotenv gần như có mặt trong mọi dự án Node. Từ Node 20.6 thì cờ --env-file=.env làm việc đó không cần cài gì (mọi ví dụ trong bài chạy trên v22). Lưu ý cờ này báo lỗi nếu tệp .env không tồn tại - môi trường nào không có tệp thì đừng truyền cờ.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Trong Node.js, process.argv chứa gì ở vị trí [0][1]?

  1. 1

    Soi căn cước

    Mèo con thêm vào can-cuoc.js: in process.platform, process.versionprocess.uptime(), rồi chạy hai lần liên tiếp.

    Hoàn thành khi: Hai lần chạy cho pid KHÁC nhau (mỗi lần chạy là một tiến trình mới) còn platform/version giữ nguyên.

  2. 2

    Ba nguồn cấu hình

    Viết xin-chao.js in 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. 3

    Tách hai luồng

    Chạy node meo-dem.mjs khong-co.txt ba kiểu: thêm 2>/dev/null, thêm >/dev/null, rồi 2>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.txt chứa đúng thông báo; echo $? cả ba lần đều ra 1.

  4. 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, abc cho echo $? lần lượt 0, 2, 1 - chỉ trường hợp đầu in thêm "qua môn".

  5. 5

    Ctrl+C hai nhịp

    Viết script đếm ngược 10 giây bằng setInterval. Bắt SIGINT: 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ới process.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. 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.txt in đúng 2 dòng; gặp tệp không đọc được thì báo ra stderr và exit code là 1.