给 Node 守护进程加日志转发,结果每行都冒出 undefined?
1. 背景:一个"自己写的小守护进程"
我写了一个零依赖的 Node.js 内网 Web 服务,本地开发靠一个"热重载守护进程"跑:监听服务文件变化自动重启、进程崩溃自动拉起、每 30 秒检查端口兜底。改动代码后刷新页面就能生效,不需要手动杀进程。
骨架代码(分层自愈):
js
// 守护进程骨架:mtime 轮询 + 崩溃自动拉起 + 端口自检
function start() {
child = cp.spawn(NODE, [SERVER], { cwd: DIR, stdio: 'ignore' });
child.on('exit', function (code) {
log('server.js 退出 code=' + code);
setTimeout(start, 2000); // 崩溃后 2 秒拉起
});
}
setInterval(function () { /* 检测文件变化 → 重启 */ }, 1000);
setInterval(function () { /* 端口不通 → 强制重启 */ }, 30000);
本节要点 :写守护进程时,"服务能不能自己活过来"比"功能本身"更重要。这里埋一个伏笔:stdio: 'ignore'。
2. 教训:控制台关闭后的 EPIPE 日志风暴
事故:
- 现象:关掉启动服务的控制台窗口后,守护进程的 stdout 写不出去,抛
EPIPE;这个错误在流上异步抛,try/catch抓不到,冒泡成uncaughtException;如果异常处理器里再console.log,就会无限自我放大,日志被刷爆、守护失效。 - 防护代码:
js
let stdoutBroken = false;
process.stdout.on('error', () => { stdoutBroken = true; });
process.stderr.on('error', () => { stdoutBroken = true; });
function log(msg) {
if (!stdoutBroken) {
try { process.stdout.write(msg + '\n'); } catch (e) { stdoutBroken = true; }
}
try { fs.appendFileSync(LOG_FILE, msg + '\n', 'utf8'); } catch (e) {}
}
process.on('uncaughtException', function (e) {
if (inErrorHandler) return; // 递归防护
inErrorHandler = true;
try { log('uncaughtException: ' + e.message); } catch (x) {}
inErrorHandler = false;
});
要点 :日志系统第一条铁律------写日志本身不能成为崩溃源。控制台坏了就降级只写文件,异常处理器里绝不能再抛。
3. 改进一:子进程日志转发
痛点:stdio: 'ignore' 意味着业务进程的 console.log 全部丢失 。一旦业务出问题,watch.log 里没有任何线索,只能靠业务日志(而业务日志常常没有)。
方案:
js
child = cp.spawn(NODE, [SERVER], { cwd: DIR, stdio: ['ignore', 'pipe', 'pipe'] });
function pipeStream(stream, tag) {
if (!stream) return;
var buf = '';
stream.on('data', function (d) {
var s = buf + d.toString();
buf = '';
var idx;
while ((idx = s.indexOf('\n')) >= 0) {
var line = s.slice(0, idx).trim();
s = s.slice(idx + 1);
if (line) log(tag + line); // log() 自带 EPIPE 防护
}
if (s.length > 65536) { /* 超长无换行兜底 */ }
buf = s;
});
stream.on('error', function () {}); // 子进程退出后管道 error 忽略
}
pipeStream(child.stdout, '[server] ');
pipeStream(child.stderr, '[server-err] ');
要点:
- 转发要按行缓冲 ,否则 chunk 边界会把一行切成两半(
data事件一次收到的字节数是不确定的) - 用
[server]/[server-err]前缀区分来源 - 转发进
log()是安全的------它自带 EPIPE 防护
本节结尾留个悬念 :第一次写完这个函数,跑起来一看------每一行都多了个诡异的 undefined 前缀。
4. 改进二:日志按行截断(UTF-8 安全)
痛点:日志超过阈值后截断,原来的写法是 buf.slice(-256 * 1024)(按字符串/字节切),可能在多字节字符中间 或半行处切断 → 乱码、可读性差。
方案------保留窗口内找第一个换行,从完整行开始切:
js
const st = fs.statSync(LOG_FILE);
if (st.size > 512 * 1024) {
const buf = fs.readFileSync(LOG_FILE, 'utf8');
const maxKeep = 256 * 1024;
let start = buf.length > maxKeep ? buf.length - maxKeep : 0;
const nl = buf.indexOf('\n', start);
if (nl >= 0) start = nl + 1;
fs.writeFileSync(LOG_FILE, buf.slice(start), 'utf8');
}
单测思路:
js
// 构造 512KB+ 的日志,每行含中文和 emoji
// 断言:截断后首行完整(以整行开头)、无 U+FFFD 替换符、末尾标记行保留
要点:日志截断的正确语义是"丢旧行",不是"丢半行"。写日志相关的代码,最好顺手写个 UTF-8 边界单测。
5. 意外:每一行都冒出 undefined(重头戏)
现象:
ini
[10:03:10] [server] undefined[init] 数据库不可用,降级为文件存储
[10:03:10] [server] undefined[boot] 启动完成
[10:03:10] [server] undefined========================================
[10:03:10] [server] 服务已启动: https://localhost:xxxx
注意规律 :不是每行都有 undefined,而是"每个数据块的第一行"有。这个规律本身就是线索。
6. 排查实录:从"怀疑数据"到"最小复现"
这是全文最值得写的部分,按时间线展开:
6.1 怀疑业务输出 → 直接跑一次服务,抓原始 stdout → 干净。排除业务代码。
6.2 抓真实 chunk → 写个脚本 spawn 同一个服务,打印每段 data 的完整内容 → 干净。排除数据本身。
6.3 最小复现 → 用 node -e 输出几行日志 + 复制转发逻辑 → 干净?! 此时矛盾出现:同样的逻辑,为什么在守护进程里就有 undefined?
6.4 完整复刻 → 把守护进程的 log + 转发 + spawn 原样复制到独立脚本 → 还是干净?! 矛盾加深------逻辑看起来完全一样。
6.5 加调试 → 在真实守护进程里,把收到的原始 chunk 原样写进一个 debug 文件 → chunk 干净,但最终日志里有 undefined 。矛盾被压缩到一个点上:问题出在"写入端"。
6.6 顿悟:
js
pipeStream(child.stdout, '[server] ', outBuf); // outBuf 是字符串!
outBuf 是字符串,按值传入 函数;函数里访问 state.buf------字符串没有 .buf 属性,得到 undefined。于是:
js
var s = state.buf + d.toString(); // undefined + "[init] ..." === "undefined[init] ..."
每个数据块的首行都被拼上了 undefined。修复:改成闭包持有缓冲:
js
function pipeStream(stream, tag) {
var buf = ''; // 闭包变量,每次调用独立
stream.on('data', function (d) {
var s = buf + d.toString();
...
});
}
6.7 验证:日志干净、热重载正常、UTF-8 截断单测通过。
总结:
- 排查诡异 bug 的正确姿势是逐层缩小范围:怀疑数据 → 怀疑源 → 最小复现 → 在真实环境打印。每一步都让矛盾更聚焦。
- 当"看起来完全一样的逻辑"在 A 环境正常、在 B 环境异常时,别急着怀疑魔法------检查参数是不是传错了类型 。字符串按值传参、对象按引用传参,
state.buf这种写法只对对象有效。
结尾
我是一位正在学习java的新手小白,我想把自己的经验记录下来。 这个小守护进程本身没什么了不起,但为了让它"活得久、坏得清楚",我踩的坑和攒的经验,也许能帮你少走几步。如果你也在写 Node 工具链,欢迎交流你的日志处理姿势。