分类
nodejs

捕获 promisify `child_process.exec` 的错误

这个东西文档里没写清楚,所以写篇博客记一下。

在 Node.js 里,我们可以使用 child_process 下的命令分裂出一个进程,执行其他的命令。这些命令包括 execexecFilespawn

我比较常使用 execspawn。前者用起来比较方便,只要传入完整的命令和参数,很接近日常命令行体验;后者传参要麻烦一些,不过可以实时获取输出,包括 stdoutstderr,比较方便给用户及时反馈。

下面贴一下文档里的例子,spawn 的使用将来有机会再说。

const { exec } = require('child_process');
exec('cat *.js missing_file | wc -l', (error, stdout, stderr) => {
  if (error) {
    console.error(`exec error: ${error}`);
    return;
  }
  console.log(`stdout: ${stdout}`);
  console.error(`stderr: ${stderr}`);
});

Node.js 8 之后,我们可以用 util.promisify() 命令将 exec 转换为 Promise 风格的方法,即不再需要 callback 函数,而是返回 Promise 实例,方便我们链式调用或者使用 Async function。

此时,它的样子是这样的:

const util = require('util');
const exec = util.promisify(require('child_process').exec);

async function lsExample() {
  const { stdout, stderr } = await exec('ls');
  console.log('stdout:', stdout);
  console.error('stderr:', stderr);
}
lsExample();

官方文档没解释清楚错误处理,经过我的尝试,是这样的:

  1. 命令发生错误,或者被意外中断都会引发错误
  2. 如果不出错,就会正确返回 stdoutstderr
  3. 否则,错误实例里会包含 stdoutstderrcode
  4. 1~127 是各种错误编码,128+ 则是 signal + 127 的结果,通常来说是受控于我们的操作。比如使用 FFmpeg 录屏的时候,结束录制就要 Ctrl+C,此时就会得到值为 128 的 code。所以此时很可能不是真的失败。
const util = require('util');
const exec = util.promisify(require('child_process').exec);

(async () => {
  let code, stdout, stderr;
  try {
    ({stdout, stderr} = await exec('ls'));
  } catch (e) {
    ({code, stdout, stderr} = e);
  }

  if (code && code > 127) {
    // 确实失败了
  }
  console.log('stdout:', stdout);
  console.log('stderr:', stderr);
})();
分类
nodejs

使用 Node.js 驱动 FFMPEG 录屏

FFMPEG 功能非常强大,不仅能转换视频格式、压缩视频、添加字幕等,还能录制屏幕内容。使用 FFMPEG 录屏的方法很简单:

# Linux
ffmpeg -video_size 1024x768 -framerate 25 -f x11grab -i :0.0+100,200 output.mp4 

# macOS
ffmpeg -f avfoundation -list_devices true -i "" 

# Windows
ffmpeg -f gdigrab -framerate 30 -offset_x 10 -offset_y 20 -video_size 640x480 -show_region 1 -i desktop output.mkv 

更详细的介绍可以参考官方文档,这里不做太多摘抄。

使用 Node.js child_process 可以调用外部程序执行操作,详细的用法可以参考官方文档。大概来说,分为:

  1. exec/execFile 调用程序,当执行完成,一次性获得结果
  2. spawn 调用程序,过程中流式获得输出

我的录屏对时间点有非常高的要求,力求准确。所以我只能选择 spawn,然后通过检查日志输出得到准确的录制开始时间。

所以,我的代码大概如此:

class FfmpegRecorder {
  // 其它函数略去
  startRecording(args) {
    this.isFileReady = new Promise((resolve, reject) => {
      this._isFileReadyResolve = resolve;
      this._isFileReadyReject = reject;
    });
    this.recording = spawn(this.ffmpeg, args);
    
    this.recording.stdout.on('data', this.onStdData);
    this.recording.stderr.on('data', this.onStdErr);
  }
  stopRecording() {
    this.recording.kill('SIGINT');
    const path = resolve(process.cwd(), 'a.mp4');
    return path;
  }

  onStdData(data) {
    data = data.toString();
    this.stdout += data;
    if (this.stdout.indexOf('Output #0, mp4, to \'a.mp4\'') !== -1) {
      this._isFileReadyResolve();
      this._isFileReadyReject = null;
    }
  }
  onStdErr(data) {
    data = data.toString();
    this.stderr += data;
    if (this.stderr.indexOf('Output #0, mp4, to \'a.mp4\'') !== -1) {
      this._isFileReadyResolve();
      this._isFileReadyReject = null;
    }
  }
}

根据我在命令行里直接跑 ffmpeg 的结果,它会先初始化,然后开始录屏。录屏开始时,会输出 Output #0, mp4, to xxx.mp4 这样的日志,所以我就会反复检查 stdoutstderr,直到关键日志出现,然后告诉外面的程序开始录制了。

这里比较奇怪的是,日志输出应该是正常的,走 stdout 通道,结果只能从 stderr 通道获取。我为防万一,两边都留下了同样的代码。可能我对 Linux 理解不够,将来再研究一下为什么会这样吧。

上面的代码忽略了 onErroronExit 部分,有兴趣的同学请等我开源(公司代码)。

在 Linux,stopRecording(),即 kill('SIGINT')(相当于按下 Ctrl+C)之后,FFMPEG 会终止录屏,并且生成可以播放的视频文件。但是在 Windows,只会留下一个无法播放的文件。

通过观察命令行直接运行 ffmpeg 的结果和 node.js 保存的结果,我发现缺失了结束录制后处理视频文件的部分。实际上 FFMPEG 录屏时只会记录视频内容,录制结束后才会生成影片的meta 信息,而播放器必须读取后者才可以正常播放。所以我猜测在 Windows 下 kill('SIGINT') 会直接彻底杀死进程,而不仅仅是发送一个信号给 FFMPEG,并让它完成后续的工作。

做出判断后,我尝试用按下 q 的方式通知 FFMPEG 停止工作,并等待 5s。果然成功,于是我构建了新类,继承前面的 FfmpegRecorder,大概代码如下:

const endTag = /kb\/s:\d+.\d{2}/m;

class WindowsRecorder extend FfmpegRecorder {
  stopRecording() {
    // 向 child process 输入 `q`
    this.recording.stdin.setEncoding('utf8');
    this.recording.stdin.write('q');
    const p = new Promise((resolve, reject) => {
      this._stopResolve = resolve;
      this._stopReject = reject;
    });
    // 设置一个超时保护 15s
    setTimeout(() => {
      this._stopReject();
    }, 15E3);
    return p;
  }
  onStdData(data) {
    super.onStdData(data);
    if (this._stopResolve && endTag.test(this.stdout)) {
      const path = resolve(process.cwd(), 'a.mp4');
      this._stopResolve(path);
    }
  }
  onStdErr(data) {
    super.onStdErr(data);
    if (this._stopResolve && endTag.test(this.stderr)) {
      const path = resolve(process.cwd(), 'a.mp4');
      this._stopResolve(path);
    }
  }
}

为了功能完善,我没有选择等待固定的时间,而是继续检查日志,直到发现 endTag 标记。另外,我也留下了 15s 超时保护,避免某些暂时没遇到的问题破坏功能。

至此,功能基本稳定完成。

分类
nodejs

Promise 改造 child_process.exec

child_process 是 Node.js 的一个内建模块,用于分裂出(spawn)一个子进程,执行一些特定操作。.exec() 是它的方法,接受一个参数,即要执行的 shell 命令,然后通过回调返回结果。.exec().spawn() 的不同之处在于,前者重在返回结果,后者则重在返回内容。所以当你需要执行一个命令,你并不关心执行过程中发生了什么,只要看到结果就好,那么就用 .exec();反之,假如执行过程中产生的信息对你特别有价值,你并不是特别在意结果,就应该用 .spawn()

另外,我之前在《Node.js 8 中的 util.promisify》中介绍过,Node.js 8 引入了一个新函数,位于 util 模块,叫做 promisify(),用于将回调风格的 Node.js 函数改造成 Promise 规范的函数。

OK,背景知识介绍结束。近期开发中,我需要执行一个命令,并且取得它的 stdoutstderrexit code,使用 promisify() 之后发现没有 exit code,于是只好重新写了一下,代码如下:

import {exec as BaseExec} from 'util';

function exec(command, options) {
  return new Promise((resolve, reject) => {
    let result = {};
    const cp = baseExec(command, options, (err, stdout, stderr) => {
      if (err) {
        err.stdout = stdout;
        err.stderr = stderr;
        reject(err);
        return;
      }

      result.stdout = stdout;
      result.stderr = stderr;
      if ('code' in result) {
        resolve(result);
      }
    });

    cp.on('exit', (code, signal) => {
      result.code = code;
      result.signal = signal;
      if ('stdout' in result) {
        resolve(result);
      }
    });
  });
}

希望对大家有用。

新键盘到了,FC660C,静电容,试用一下,效果还不错。略硬,段落感不强,声音不大。