用 Node.js 原生 API 管理多子进程并发
用 Node.js 原生 API 管理多子进程并发
在前后端分离或 Monorepo 全栈项目中,本地开发往往需要同时运行多个进程:前端构建、后端 API、数据库容器等。这通常意味着要打开多个终端窗口,分别执行 npm run dev 或启动脚本。当项目规模扩大,手动管理这些进程不仅占用屏幕空间,还增加了维护成本。
一旦某个子进程异常退出,开发者很难在多个窗口中第一时间发现。更麻烦的是,直接关闭终端窗口往往无法彻底清理后台残留的 Node 进程,导致端口被占用或内存泄漏。
通过 Node.js 的 child_process 模块实现一个轻量级的进程管理器,可以将这些子进程收拢到同一个生命周期中,统一处理启动、日志聚合和优雅退出。
一、多窗口管理的实际困扰
日常开发中,最直接的痛点是上下文切换。每次启动项目,都要依次打开终端、切换目录、输入命令。如果后端服务挂了,你需要在多个窗口间切换查看日志。
关闭项目时的问题更隐蔽。直接关闭终端窗口(尤其是使用 Ctrl+C 时)有时无法正确传递信号给所有子进程,导致“孤儿进程”继续在后台运行。这不仅浪费资源,还会在下次启动时报“端口已被占用”的错误。
核心需求其实很简单:用一个命令启动所有相关进程,在一个窗口里看所有日志,并且能干净利落地关闭它们。
二、进程编排模型
为了避免引入 PM2 等重型工具,我们可以利用 Node.js 原生的 spawn 机制构建一个极简的编排层。
graph TD
A[执行 npm run start:all] --> B[主进程 Process Manager]
B -->|spawn| C[前端构建]
B -->|spawn| D[后端 API]
B -->|spawn| E[数据库容器]
C -->|stdout/stderr| F[日志聚合]
D -->|stdout/stderr| F
E -->|stdout/stderr| F
F -->|带前缀输出| G[终端统一显示]
H[Ctrl+C / SIGINT] --> I[主进程拦截信号]
I -->|发送 SIGTERM| C
I -->|发送 SIGTERM| D
I -->|发送 SIGTERM| E
J{等待子进程退出} -->|超时| K[强制 SIGKILL]
J -->|全部退出| L[主进程退出]
这个模型的核心在于主进程对子进程生命周期的完全掌控。
三、实现方案
以下代码使用 child_process 和 path 模块实现了一个基础的并发管理器。它不依赖 concurrently 等第三方库,重点在于信号处理和日志前缀。
// run_all.js
const { spawn } = require('child_process');
const path = require('path');
const projectRoot = __dirname;
// 定义需要启动的子任务
const TASKS = [
{
name: 'frontend',
cmd: 'npm',
args: ['run', 'dev'],
color: '\x1b[32m' // 绿色
},
{
name: 'backend',
cmd: 'npm',
args: ['run', 'server'],
color: '\x1b[34m' // 蓝色
}
];
const spawnedProcesses = [];
function log(msg) {
console.log(`\x1b[35m[Manager]\x1b[0m ${msg}`);
}
function startAllTasks() {
log('Starting subsystems...');
TASKS.forEach(task => {
const child = spawn(task.cmd, task.args, {
cwd: projectRoot,
shell: true,
env: { ...process.env, FORCE_COLOR: 'true' }
});
spawnedProcesses.push({ name: task.name, process: child });
// 输出 stdout,添加颜色前缀
child.stdout.on('data', data => {
const lines = data.toString().trim().split('\n');
lines.forEach(line => {
console.log(`${task.color}[${task.name}]\x1b[0m ${line}`);
});
});
// 输出 stderr
child.stderr.on('data', data => {
const lines = data.toString().trim().split('\n');
lines.forEach(line => {
console.error(`${task.color}[${task.name}-ERR]\x1b[31m ${line}\x1b[0m`);
});
});
child.on('close', code => {
log(`[${task.name}] exited with code ${code}`);
});
});
}
// 处理退出信号,确保子进程被清理
function setupSignalHandler() {
const cleanShutdown = () => {
log('Shutting down...');
let pending = spawnedProcesses.length;
if (pending === 0) {
process.exit(0);
}
spawnedProcesses.forEach(item => {
// 尝试优雅关闭
item.process.kill('SIGTERM');
});
// 设置超时,防止子进程卡死
setTimeout(() => {
log('Timeout reached, forcing kill.');
spawnedProcesses.forEach(item => item.process.kill('SIGKILL'));
process.exit(1);
}, 3000);
};
process.on('SIGINT', cleanShutdown);
process.on('SIGTERM', cleanShutdown);
}
if (require.main === module) {
setupSignalHandler();
startAllTasks();
}
四、需要注意的实际问题
在落地这个方案时,有几个细节需要处理:
- 日志交织(Log Interleaving):多个进程同时输出会导致日志行混杂。上面的代码通过给每一行添加
[task-name]前缀来区分来源。如果日志量极大,可能需要引入缓冲区或日志文件轮转。 - 跨平台兼容性:Windows 和 Unix 系统对命令解析不同。在
spawn选项中设置shell: true通常能解决大部分路径解析问题,但在某些 CI 环境中可能需要调整。 - 孤儿进程防护:如果主进程本身崩溃(如 OOM),子进程可能会变成孤儿。在生产环境或长期运行的场景中,建议配合系统级的进程监控工具(如 systemd 或 Docker 的 restart policy)使用。
五、总结
通过 Node.js 原生 API 管理子进程,可以省去引入重型依赖的成本。这个脚本的核心价值在于统一了启动和退出的入口,减少了开发过程中的手动操作,让本地环境的维护变得更加可控。
更多推荐
所有评论(0)