Node 守护进程日志转发踩坑记:stdio 管道、UTF-8 截断,和一个字符串按值传参的故事

给 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 工具链,欢迎交流你的日志处理姿势。


相关推荐
G018_star sky♬9 小时前
如何用nodejs写一个简单后端服务
node.js
kiracrimson9 小时前
npm 入门:包管理器、依赖与 npx
前端·npm·node.js
花花鱼11 小时前
使用 nvm 管理多 Node.js 版本 + pnpm 完整实操,解决 pnpm Node 版本报错
node.js
定栓13 小时前
Webpack迁到Vite第四节:SVG 图标系统
前端·webpack·node.js
小天源1 天前
智叟云集系统架构解析:从品牌 AI 可见度监测到前后端协同
人工智能·react.js·系统架构·node.js·react·ai监测·ai品牌监测
用户6122716650241 天前
电费 9 毛/天:我让一台 9 年前的旧 MacBook 替我上班,它比入门云服务器还能打
node.js
PegasusYu1 天前
Node.js使用nedb轻量级数据库测试脚本
数据库·electron·node.js·脚本·测试·轻量级·nedb
WebInfra2 天前
Rsdoctor 2.0 发布:Agent 驱动的全自动优化工具
前端·javascript·node.js
DevUp2 天前
标着 JavaScript 的 Wiki 与知识库:既不是 CMS 也不是 SSG(番外三)
javascript·node.js·cms
毕业设计7032 天前
(免费领源码)基于node的学生公寓管理系统06412- java、PHP、python、C#、小程序、大数据、单片机、网络工程等)
vue.js·mysql·node.js·maven·mybatis·express·b/s